Web Workerで生成する、ブラウザの中の手続き生成3D都市
建物、信号のある交通、歩行者、乗れる地下鉄、天候、雲と稜線 — すべてコードから生成。アセットは歩行モーション一つだけです。
- ドローコール
- 608
- 1フレーム、約104万ポリゴン
- 生成時間
- ~750 ms
- メインスレッドは19ミリ秒
- 3Dの転送量
- 187 KB
- gzip、ミニファイで54%減
- 道路グラフのノード
- 121
- 車と歩行者はその上のエージェント
背景
会員管理プロダクトのトップページは営業ページであり、同時に歩き回れる街でもあります。ドラッグで街を回し、キーを一つ押せば一人称に降り、広場の車に乗り込み、階段を降りて地下鉄に乗って二駅先まで行けます。建物もテクスチャも合成された環境音も月も、すべて読み込み時にコードから生成されます。アセットと呼べるものは歩行モーションのキャラクター一体と、3Dライブラリ本体だけです。
見込み客の前に置くものとしては奇妙で、しかも私はすでに一度、同じ種類のものを消しています。以前の3Dアーケードは「見た目が過剰に約束しすぎる」「訪問者に要求が多すぎる」という理由でサイトから外しました。価格表にたどり着くまでのスクロールが10,404ピクセルあったのです。だから今回は最初から一つの規則で組み立てました。どこへ行くにも常にワンクリック以内であること。ナビゲーションのリンクを押せばカメラがその区画へ飛び、対応するパネルが開きます。歩くのは味付けであって、移動手段はクリックです。
そしてこのページは、依然としてページでもなければなりません。クローラーもスクリーンリーダーも、WebGLのない環境の訪問者も、OSに「動きを減らす」と伝えている訪問者も、1MBの3Dを走らせずに営業コピーへ届く必要があります。
作ったもの
ドキュメントは一つだけです。営業コピーはページの中に普通のセマンティックHTMLとして置かれていて、街が開くオーバーレイのパネルはその同じセクションそのものを、CSSで見せ方だけ変えたものです。複製もテンプレート化もしていません。担当のモジュールはクラスを付け外しするだけで、それだけでクローラーから見える内容が変わらないこと、WebGLのない環境へのフォールバックが無料で手に入ること、スクリーンリーダーに本物のテキストが渡ること、そして「動きを減らす」設定の落とし所ができることが、まとめて手に入ります。オプトアウトのトグルも置きませんでした。アーケードにはそれがあり、フラグは一方通行の扉だからです。このプロジェクトで私がいちばん大事にしているアサーションは「セクションのテキスト内容は決して書き換えられない」というもので、これが落ちたときは営業ページが中身を抜かれたときです。
街の生成には約750ミリ秒かかります。タブが目に見えて固まる長さなので、生成はWeb Workerの中で走り、転送可能なFloat配列として返ってきます。そのため生成側は3Dライブラリをimportできなくなり、二つの処理を自前で書き直したうえで、ライブラリ自身の出力に対して固定しました。sRGBからリニアへの色変換と、頂点法線の計算です。どちらも間違っても大きな音を立てません。素朴な16進→浮動小数の変換は例外を投げず、ただ街の色をわずかにずらすだけです。結果として、メインスレッドが止まる時間は750ミリ秒ではなく19ミリ秒になり、生成中も45フレームが描かれています。

街路は121ノードの道路グラフです。車と歩行者はその上のエージェントで、車は右側通行、信号機の頭は各進入方向の向こう側、運転者から見て右に立ちます。歩行者は縁石で青を待ちます。その上に建つファサードは、一つのマテリアルを持つ一つのマージ済みメッシュです。それが毎フレーム100万ポリゴンを現実的な値にしている理由であり、同時に、深度まわりの問題をすべて難しくしている理由でもあります。

地下には二駅の地下鉄があり、実際に乗れる電車が走っていて、車内の壁には次の駅が出ます。「いま自分が立っている高さ」を決める関数には、この階段室のバグが二度とも宿りました。二度目は、階段の裏側の空洞に立った歩行者が、構造物を貫いて5.94ユニット持ち上げられてしまうというものです。修正は意図的に二重にしました。実在の駅がそうであるように階段の背面を壁で塞いでその場所へ行けなくし、さらに1ユニットを超える上りを歩行面が拒むようにして、将来また隙間が開いても瞬間移動が起きないようにしています。
設計上の判断
「前に何かあれば待つ」ではなく、順位で譲る。 車は自分とだいたい同じ向きに進んでいる相手の後ろには並びますが、交差する交通に対しては固定された全順序でしか譲りません。厳密な全順序に閉路は作れないので、どの集まりでも最上位の車には必ず「待たなくてよい相手」がいます。これで渋滞のデッドロックは「起きにくい」ではなく「起こりえない」になります。
プロダクトの話は建物ではなく配達ミッションに運ばせる。 店舗の看板に機能名を並べる案は早い段階で退けました。顧客層そのものに見える街のほうが、広告としては強いからです。街はすでにこのプロダクトが相手にしている業種で埋まっているので、配達の積み荷は本物の会員業務であり、報酬は仕事の終わりの一文です。その一文が効くのは、たった今それを届けるために街を横断してきたからで、建物の看板にはそれができません。
跳ね飛ばすのではなく、避けさせて、固くする。 はねた歩行者を速度に応じて宙に舞わせたい、という依頼に対しては、一度だけ調子の話をしました。ここはジムや非営利団体に会員管理ソフトを売るページです。結論は、歩行者が横に一歩逃げること、そして車が人をすり抜けられないこと、の二つになりました。
うまくいかなかったこと
ポインタロックが握られている間、UIは食べられていた。 徒歩モードはポインタロック中で、ロックが握られている限り、ページの上に何をどう重ねようと、マウスイベントはすべてキャンバスに届きます。つまり、街を探索するためにこそ存在するはずのそのモードで、看板の付いた建物をクリックする機能は一度も動いていませんでした。仕様上、ロック中はカーソル座標がロック開始時の位置で凍結されるため、クリックの判定は視界と無関係な古い一点を狙っていたのです。いまは、ロック中は画面中央から狙い、狙いが合う「前」から見える照準点を出します。同じ事実がキャンバスの上に描くものすべてを縛ります。モーダルは開いている間ポインタを借りなければならず、閉じるボタンはカーソルが存在する場面にしか置けません。Escapeは使えません。ブラウザがロック解除のために食べてしまうからです。
二つの正しいコミットが、しゃべれない店の前に49枚の看板を残した。 一方は、建物ラベルが両方の役目を引き継ぐという計画のもとで、店先の黒板と路上の短い台詞をまとめて撤去しました。もう一方は、開幕の俯瞰視点では建物ラベルが一文字あたり1.3ピクセルしかなく、その距離で機能する発見の手がかりは黒板だけだったので、黒板を戻しました。どちらのコミットも間違っていません。ただ二つが重なった結果、「機構のないアフォーダンス」が残りました。ポインタロックのバグのちょうど裏返しです。黒板とその中身を結びつけるテストは一つもなかったので、これは黒板の前に立って初めて見つかりました。いまは「その店先について語れるときに限って黒板が描かれる」ことが固定されています。

空は街より難しかった。 雲は最初、真上から見下ろすカメラを基準に高度を決めて入れ、すべての周回視点で画面内の雲がゼロになりました。周回カメラは上を向かないからです。カメラは街「を」見ていて水平線で止まるので、訪問者にとっての空は画面上端の帯にすぎず、既定の視点では水平から3度ほどしかありません。雲が周回視点に写るのは、遠くて低いときだけです。いまは高度350ユニットに置き、カメラが上がるにつれて消えるようにしています。稜線は逆方向で失敗しました。距離フォグは950ユニットで100%に達するので、その先に置いたものは空の色そのものになって消えます。だから三本のリングはライティングもフォグも切り、毎フレーム空の色へ寄せてブレンドすることで遠近感を作っています。この色は昼夜サイクルと天候をそのまま受け継ぎます。
結果
1フレームあたり608ドローコール、約104万ポリゴン。3Dの転送量はgzipで187KB、ミニファイ前から54%減です。交通ルールの入れ替えで、計測された最悪の停止時間は356秒から10.6秒 — ちょうど赤信号一周期分 — になりました。信号機を本物の造形に作り直したときは、同じ視点でシーンのドローコールが786から642に下がり、しかも見た目は増えています。雲はドローコール1本、稜線は3本です。
これらを支えているのは、依存のないモジュール群をブラウザ上で直接動かすハーネスと、絵にしないと決着しないものについてのフレーム描画チェックです。そのテスト群が一度も何も言ってくれなかったのは、その機能が見つけられるかどうかでした。ポインタロックまわりのバグも、黙ったままの49枚の看板も、見えない照準点も、すべて街を歩いた人間が見つけています。