開発当初、私は野心に燃えていました。「国土交通省が公開しているPLATEAUのLOD2(テクスチャ付き詳細モデル)を使えば、超リアルな東京をブラウザで完全再現できるじゃないか!」と。

しかし、その夢はブラウザのクラッシュという無慈悲な現実によって打ち砕かれました。今回は、リアルな3DデータをWebで扱う難しさと、そこから現在の「軽量ジオラマスタイル」に行き着いた経緯をお話しします。

LOD2モデルの破壊力

PLATEAUのLOD2モデルは、建物の形状だけでなく、壁面の写真テクスチャまで貼り付けられた非常に高精細なデータです。これを deck.glTile3DLayer を使って読み込んでみました。

PCのChromeで実行。最初は「おお!リアルだ!」と感動しましたが、東京駅周辺に視点を移動した瞬間、ファンが爆音を上げ始めました。タスクマネージャーを見ると、GPUメモリ(VRAM)の使用量が危険水準に。

そしてiPhoneで動作確認をしたところ、ページを開いて数秒で「問題が繰り返し起きました」というエラーメッセージと共にSafariが強制終了。完全にスペックオーバーでした。

なぜそんなに重いのか

主な原因は以下の2点です。

  • テクスチャ画像の大量読み込み: 1つ1つのビルに高解像度のテクスチャが貼られており、都心部ではそれが数千枚レベルになります。モバイル端末の限られたメモリでは展開しきれません。
  • ドローコールの増大: 3D Tiles形式で最適化されているとはいえ、個別のマテリアルを持つオブジェクトの描画負荷は甚大でした。

決断:リアルさを捨てる勇気

「どんなにリアルでも、動かなければ意味がない」。
泣く泣くLOD2の使用を諦め、テクスチャのないシンプルな形状データ(LOD1)への切り替えを決断しました。

しかし、ただの白い箱では味気ない。そこで導入したのが、前回の記事でも紹介した「プロシージャルテクスチャ生成」「MapLibreのライト機能」です。容量の重い画像データを使わず、コードで生成したノイズパターンと光の演出で「模型のような質感」を出す方向にシフトしました。

代替アプローチの検討

LOD2を諦めた後、すぐにLOD1に飛びついたわけではありません。いくつかの代替アプローチも検討・検証しました。

アプローチ1: テクスチャの解像度を下げる

LOD2のジオメトリはそのままに、テクスチャだけを低解像度に変換すれば軽くなるのではないか?と考えました。画像を元の1/4サイズにリサイズして試しましたが、見た目が大幅に劣化する割にGPUメモリの使用量は30%程度しか減りませんでした。テクスチャの枚数自体が多い(数千枚)ため、1枚あたりのサイズを減らしても根本的な解決にはなりませんでした。

アプローチ2: 視点周辺のみLOD2を使う

カメラの近くにある建物だけLOD2で表示し、遠くの建物はLOD1で表示するという、ゲームでよく使われるLOD切り替え方式も試しました。

技術的には 3D Tiles の仕組み自体がLODの自動切り替えに対応しているのですが、PLATEAUのデータはWeb向けに最適化されていないため、LOD切り替えの境界で「テクスチャ付きの建物」と「白い箱」が隣り合う不自然な見た目になってしまいました。ゲームエンジン(Unity/Unreal)であれば滑らかにブレンドできるかもしれませんが、WebGLの制約の中では難しいと判断しました。

アプローチ3: 3D Tilesのストリーミングを最適化する

CesiumのIONサービスを経由してPLATEAUデータをストリーミング配信する方法も検討しました。これなら必要な範囲のデータだけをオンデマンドで読み込めるため、初期ロード時の負荷を分散できます。

しかし、CesiumのWeb向けビューア(CesiumJS)はMapLibre GL JSとの統合が難しく、既存のシミュレーター基盤を大幅に作り直す必要がありました。また、IONサービスの利用には月額コストもかかるため、個人開発プロジェクトとしては見送ることにしました。

具体的なパフォーマンス比較

最終的にLOD1 + プロシージャルテクスチャ方式に落ち着きましたが、参考までに各方式のパフォーマンス計測結果を共有します。テスト環境はMacBook Pro(M1, 16GB RAM)+ Chrome 120です。

方式 初期ロード FPS メモリ
LOD2(テクスチャ付き) 15秒以上 8-15fps 2.5GB+
LOD2(テクスチャ縮小) 10秒 15-25fps 1.8GB
LOD1(単色) 3秒 55-60fps 200MB
LOD1 + プロシージャル(採用) 3.5秒 50-60fps 220MB

LOD1にプロシージャルテクスチャを乗せても、パフォーマンスへの影響はほぼ無視できるレベルでした。それでいて見た目の満足度はLOD1単色とは比べものにならないため、コストパフォーマンスが非常に高い手法だと言えます。

クラッシュに至るまでの詳細

ここから先は、私が実際に画面の前で震えていた時期の話です。LOD2の取り込みに挑んでいた数週間、私は「今日こそ東京駅を丸ごと表示させる」という目標と、Chromeの「Aw, Snap!」画面に連日敗北する日々を送っていました。

WebGLコンテキストロストの恐怖

最初の兆候は、開発者ツールの右下に突然現れる小さな警告でした。

THREE.WebGLRenderer: Context Lost.
WARNING: Too many active WebGL contexts. Oldest context will be lost.

ブラウザが一気に白くなり、Three.jsのシーン全体が消える。再読み込みしても、キャッシュに詰まったテクスチャのせいか、同じ症状がすぐに再発する。コンテキストロストというのは要するに「GPUがもう面倒を見られません」と宣言してきた状態で、こちらにできることは祈るくらいしかありません。対症療法として webglcontextlost / webglcontextrestored イベントをフックして自動リロードを仕込みましたが、根本原因はまったく解決していません。

GPUメモリが16GBまで積み上がる様子

「なぜこうなるのか」を突き止めるため、私はChromeのPerformance Profilerと chrome://gpu を開きっぱなしで開発していました。PLATEAUのLOD2を新宿・渋谷周辺まで読み込むと、GPUメモリは階段状に増え続け、最終的に16GB付近で頭打ちになった瞬間、ブラウザが固まって落ちるというパターンが繰り返されました。

タスクマネージャーの「GPU メモリ」列がじりじりと増えていくのを見ていると、まるで沈みかけの船のメーターを眺めているような気分になります。ファンが全開で回り、MacBook Proのキーボードは触れないほど熱く、ふと我に返ると深夜3時。そしてだいたいこのタイミングで、Chromeは静かに、しかし容赦なく「Aw, Snap!」と微笑みながら全てを消し去るのです。

テクスチャ地獄への挑戦と敗北

LOD2が重い原因は明確でした。テクスチャです。建物の壁面写真が一つ一つ貼られていて、新宿区だけで数百MB、都心全体では余裕でGB単位になります。私はこの敵に何度も挑みましたが、結果として大半の戦いに敗れました。

並列ダウンロードで帯域を枯らす

最初の失敗は、とにかく速く読み込みたくて並列ダウンロードを走らせたことです。 fetch をPromise.allで大量に叩いたところ、ブラウザがリクエストを詰み上げすぎて、後続のAPI呼び出し(地図タイルなど)が全部タイムアウトするようになりました。モバイル回線だと特に顕著で、「地図が真っ白のまま建物のテクスチャだけが少しずつ浮かび上がってくる」という、なんとも言えない見た目になってしまったのです。

// ❌ 何も考えずに並列で叩いた初期コード
const textures = await Promise.all(
    textureUrls.map((url) => fetch(url).then((r) => r.blob()))
);

// ✅ 最終的にはセマフォで同時接続数を絞った
async function limitedFetch(urls, concurrency = 6) {
    const results = [];
    const queue = urls.slice();
    const workers = Array.from({ length: concurrency }, async () => {
        while (queue.length) {
            const url = queue.shift();
            const blob = await fetch(url).then((r) => r.blob());
            results.push({ url, blob });
        }
    });
    await Promise.all(workers);
    return results;
}

KTX2変換ツールがビルドできない

そこで次に狙ったのが、GPU圧縮テクスチャ形式のKTX2(Basis Universal)への一括変換でした。KTX2は一度GPUに乗せた後もメモリ使用量がほぼそのままで済むため、VRAM対策の切り札とされています。

しかし、basisu のCLIをmacOSでビルドしようとしたところ、依存していた古いCMake設定と自分のXcode Command Line Toolsのバージョンが噛み合わず、延々とリンクエラーに悩まされました。Dockerコンテナでも試しましたが、今度はARM64のmac上でx86_64イメージを動かしていたせいか変換中にプロセスが無音で死ぬという現象に遭遇。都合3晩ほど粘って、最終的には「これに時間を使い続けるのはプロジェクトとして筋が悪い」と判断し、撤退しました。敗北を認めた瞬間です。

deck.glのTile3DLayerで起きた沼

LOD2そのものを扱う層でも、私は別の沼に足を取られていました。PLATEAUの3D Tilesは deck.glTile3DLayer で扱うのが定石ですが、実際に触ってみると「定石」という言葉が如何に無責任かを思い知らされます。

tileset.jsonの形式が配信者ごとにバラバラ

PLATEAU本体が配信する3D Tilesと、自治体・研究機関が派生的に公開している3D Tilesは、同じ「3D Tiles 1.0準拠」を名乗っていても、 tileset.json のルート構造やテクスチャの参照パスの付け方が微妙に違いました。例えば uri が絶対パスだったり相対パスだったり、 glb が外部参照だったりインラインだったり。結果、 Tile3DLayer に同じように渡してもあるデータでは動き、あるデータでは404の雨が降る、という状況が発生しました。

キャンセルが効かず流れ続けるネットワーク

最悪だったのは、タブを閉じてもリクエストが止まらないバグ(というより仕様の組み合わせ)です。 Tile3DLayer の内部で投入されたXHR/fetchは、コンポーネントのアンマウント時にキャンセルされないケースがあり、タブを閉じたつもりが裏で大量のダウンロードが続いていました。モバイルで見たら通信量を使い切る勢いです。

// Tile3DLayerの残骸を手動で掃除する試み
useEffect(() => {
    const controller = new AbortController();
    const layer = new Tile3DLayer({
        id: 'plateau',
        data: tilesetUrl,
        loadOptions: { fetch: { signal: controller.signal } },
    });
    return () => {
        controller.abort(); // これでも完全には止まらなかった
    };
}, [tilesetUrl]);

DevToolsのNetworkタブを開きながら操作していると、視線の端でリクエストがずらっと並び、ステータス列が真っ赤(キャンセル)と灰色(pending)で染まっていきます。「自分の書いたコードが、自分の見えないところで何かを壊している」という感覚は、エンジニアにとって最も居心地の悪い瞬間のひとつです。

リアルと軽さの狭間で

LOD2を手放す決断をする夜、私は長いことエディタを開いたまま動けませんでした。「リアルな東京をブラウザで再現する」という最初のビジョンを、自分の手で縮小するのは、正直言って悔しかったのです。

「リアル」から「模型らしさ」への価値観の転換

転機になったのは、LOD1(単色の箱型)の建物群に、試しにプロシージャルなノイズテクスチャと少し強めのアンビエントオクルージョンを乗せてみた時です。窓の粒感、屋根のマット感、壁面の微妙なムラ。それらが重なった画面を見て、私は「これはリアルじゃない。でも、鉄道模型っぽい」と感じました。そして、そもそも自分が作りたかったのは「現実の写し」ではなく「ジオラマ」だったのではないか、と気づいたのです。

リアルさを追求していた時、私は「情報量が多いほど価値がある」と思い込んでいました。しかし、模型の魅力は、現実を縮小する過程で省略された情報の中にある。省略こそが美学である、という視点は、LOD2をあきらめたからこそたどり着けた場所でした。

諦めたくなかった夜を、振り返って

とはいえ、諦めきれずに粘っていたあの数週間がなければ、「LOD1+プロシージャル」という現在のアプローチには辿り着けなかったとも思います。失敗の蓄積があるから、「これでいい」ではなく「これがいい」と言えるようになりました。深夜に泣きそうになりながらGPUメモリを眺めていたあの時間は、たぶん無駄ではなかったのだと、今はようやく書けます。

同じ沼にハマる人へのアドバイス

最後に、同じようにPLATEAUやWeb 3Dに挑戦する人へ、私が沼の底から拾い上げてきた教訓を整理しておきます。

ブラウザは無限のメモリを持っていない

当たり前のことですが、本気で忘れている人が意外と多い事実です。モバイルSafariのWebGLコンテキストは実質1GB前後のメモリしか使えないと思ったほうがいい。デスクトップでサクサク動くからといって、モバイルで動くとは限りません。最初からモバイルの上限に合わせて設計するくらいがちょうど良いです。

LOD1 + 工夫こそが総合的な正解

高解像度テクスチャで勝負するより、軽量なジオメトリにプロシージャルな演出を乗せるほうが、動く・軽い・可愛いの三拍子を揃えやすい。特にWebという制約の厳しい環境では、「重いリアル」より「軽いスタイライズ」のほうが、ユーザー体験の総合点は圧倒的に高くなります。

並列数は必ず制限する(Semaphoreパターン)

Promise.allで一気に叩くのではなく、同時接続数を6〜8程度に抑えるだけで、ブラウザとサーバー両方の挙動が劇的に安定します。前述のセマフォ的な実装は、私のプロジェクト全体で共通ユーティリティとして使い回しています。ダウンロード系の処理を書くときは、ほぼ例外なく必要になると思っておいていいでしょう。

これらの教訓はどれも、落ちたブラウザの残骸と、再起動を繰り返したMacBookの熱から得たものです。もし今あなたが同じ沼にいるなら、一度踏みとどまって「何を捨てるか」を考えてみてください。捨てた先に、思いがけず良い景色が広がっていることもあります。

結果と学び

結果として、スマホでもサクサク動く軽快な動作を実現できました。ユーザーからも「模型みたいで可愛い」「動作が軽くて良い」というポジティブな反応を頂いています。

Webブラウザ、特にモバイル環境をターゲットにする場合、リソースの制限は非常にシビアです。「あるものを全部出す」のではなく、「何を捨てて、何を強調するか」という取捨選択こそが、エンジニアの腕の見せ所なのだと痛感した出来事でした。