「山手線 3Dジオラマ」では、E235系をはじめとして将来的に中央線や京浜東北線の車両も並走させたいと考えています。ただ1両あたりのモデルがそこそこ容量を食う上に、11両編成×複数路線ぶんを読み込ませるとなると、ページ読み込みの初速が致命的に遅くなるという問題にぶつかりました。

この記事では、私がDraco圧縮で glTF を約1/5まで縮めた手順と、そこで踏んだ罠について書きます。「Dracoを入れれば小さくなる」と軽く考えていた私が、数日間ハマる羽目になった話です。

膨らんでいくモデル容量

もともとE235系の.glbは1両あたり約1.8MBで、11両編成で約20MB。そこに常磐線向けのE531系(2.1MB/両)や中央線のE233系(1.9MB/両)を追加していくと、すぐに数十MBに到達します。Wi-Fi環境なら数秒ですが、モバイル回線だと読み込みに10秒以上かかり、離脱率が目に見えて悪化しました。

Google Analyticsのリアルタイムビューでも、モバイル経由の訪問者が「トップに入って3秒以内に離脱」している数字が多く、これは看過できないと判断しました。

まずは無圧縮時のベンチマーク

闇雲に圧縮をかける前に、まず現状を定量化しました。DevToolsのNetworkタブとPerformanceタブで、以下の数値を記録しました。

東京駅の全車両を読み込む状況で、glbファイルの総転送量は約78MB、デコード時間は合計で3.2秒、初期描画までのTime to Interactiveは約9.5秒でした。これは「もう我慢できる限界を超えている」数字でした。

gltf-pipeline による Draco 圧縮

Draco圧縮はGoogleが開発したジオメトリ圧縮フォーマットで、glTFの拡張仕様 KHR_draco_mesh_compression として標準化されています。ブラウザ側では独自のデコーダが必要ですが、Three.jsには公式の DRACOLoader が付属しています。

圧縮は gltf-pipeline というNode.js製のCLIで行います。以下のようにインストールして、1行でglbを変換できます。

# インストール
npm install -g gltf-pipeline

# Draco 圧縮を適用
gltf-pipeline -i e235.glb -o e235.draco.glb \
    -d \
    --draco.compressionLevel 7 \
    --draco.quantizePositionBits 14 \
    --draco.quantizeTexcoordBits 12 \
    --draco.quantizeNormalBits 10

オプションの意味を簡単に説明すると、compressionLevel は圧縮強度(0-10、高いほど小さく遅い)、quantizePositionBits は位置座標の量子化ビット数で、デフォルトの11だと車両のパネルが微妙に歪むので14まで上げています。テクスチャ座標は12、法線は10でバランスが良い感触でした。

変換結果は驚きで、E235系1両が1.8MB → 約340KB。11両編成で4MB弱に収まりました。全路線合わせても15MB程度で、無圧縮時の約1/5です。

画質劣化はほぼゼロだった

量子化による劣化を心配していたのですが、実際に並べて表示して比較すると、ピクセル単位で違いを探しても区別がつかないレベルでした。ボディの曲面もスムーズで、ライトの反射もほぼ同じ。これは本当に衝撃的で、「なぜ今まで使っていなかったんだ」と自分を責めました。

Three.js 側のセットアップで半日溶かした話

圧縮したglbをそのままGLTFLoaderに読み込ませようとしたら、派手にエラーが出ました。

THREE.GLTFLoader: No DRACOLoader instance provided.

DRACOLoaderを明示的にセットアップする必要があります。公式ドキュメントには書いてあるのですが、私は最初「Three.jsに同梱されてるから勝手に動くだろう」と高を括っていて、1時間ほど無駄にしました。

import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';

const dracoLoader = new DRACOLoader();
// decoder は CDN から読むのが楽
dracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.7/');
dracoLoader.setDecoderConfig({ type: 'js' }); // デフォルトは wasm

const gltfLoader = new GLTFLoader();
gltfLoader.setDRACOLoader(dracoLoader);
gltfLoader.load('/assets/models/e235.draco.glb', (gltf) => {
    scene.add(gltf.scene);
});

WASM デコーダのロード失敗問題

次のトラップは、WASMデコーダのロード失敗でした。Draco 1.5系のデコーダには「js版」と「wasm版」があり、wasm版の方が高速です。私は当然wasm版を選んだのですが、本番環境(Cloudflare Pages)で Content-Security-Policywasm-unsafe-eval を許可しておらず、コンソールに以下のエラーが出てDracoモデルが完全に表示されなくなりました。

CompileError: WebAssembly.instantiate(): Wasm code generation disallowed by embedder

本番デプロイ直後に気づいたので、あの日はサイトが約20分間「真っ黒な画面」になっていたはずです。慌ててCSPを修正し、script-src 'self' 'wasm-unsafe-eval' https://www.gstatic.com; のように許可を追加して事なきを得ましたが、リリース直前の環境確認を怠った自分を激しく反省しました。

Safari でのデコード速度問題

動作確認を進めていると、Safari 16系(iPhone含む)ではデコードに異様に時間がかかることが判明しました。Chromeでは150msでデコードできるモデルが、iPhone 12のSafariでは800ms近くかかります。

調査すると、SafariのWebAssembly最適化が他ブラウザに比べてやや遅いのと、Draco自体がデコード時に計算量を要求するフォーマットであることが重なっている模様でした。特に古い iPad では、読み込み中に画面が白飛びしたように一瞬固まることもあり、UX的にはマイナスに働いていました。

解決策:ローディングのプログレッシブ表示

デコード自体を速くするのは難しいので、UX側で「待たせていることを見せない」方針に切り替えました。駅のホームだけを先に表示し、車両が読み込めたら徐々にフェードインさせる実装にしたところ、体感的な遅延はかなり軽減されました。

// 車両モデルのフェードイン
async function loadTrainWithFade(path) {
    const gltf = await gltfLoader.loadAsync(path);
    const model = gltf.scene;
    model.traverse((obj) => {
        if (obj.isMesh) {
            obj.material.transparent = true;
            obj.material.opacity = 0;
        }
    });
    scene.add(model);

    // 400ms かけてフェードイン
    const start = performance.now();
    function step(t) {
        const p = Math.min(1, (t - start) / 400);
        model.traverse((obj) => {
            if (obj.isMesh) obj.material.opacity = p;
        });
        if (p < 1) requestAnimationFrame(step);
    }
    requestAnimationFrame(step);
}

Meshopt 圧縮との比較

途中、「Dracoよりも新しいMeshopt圧縮の方が良いのでは」という話を知り、比較実験もしました。Meshoptはglb側の拡張 EXT_meshopt_compression で、Dracoに比べてデコードが非常に高速(ほぼネイティブ速度)という特徴があります。

同じE235系モデルで、gltfpack ツールを使ってMeshopt圧縮をかけた結果、容量はDracoの約1.5倍(340KB→520KB)に膨らむものの、iPhoneでのデコードは800ms→120msに短縮されました。

「容量を取るか、デコード速度を取るか」という典型的なトレードオフです。私のケースでは、初回訪問者は回線が細い想定、リピーターはキャッシュが効くことを考え、最終的にDracoを採用しました。ただし、WebViewアプリ化する計画が出てきたら、容量よりデコード速度が重要になるのでMeshoptに切り替えるかもしれません。

gltfpack のワンライナー

# Meshopt 圧縮で比較
gltfpack -i e235.glb -o e235.meshopt.glb -cc -kn -km

-cc がMeshopt圧縮、-kn でノード名を保持、-km でマテリアル名を保持するオプションです。

バージョン管理とデコーダの整合性

地味に苦しめられたのが、エンコーダとデコーダのバージョン整合性です。gltf-pipelineが内部で使っているDracoエンコーダのバージョンと、クライアント側でロードしているデコーダのバージョンがズレると、微細な描画不具合が出ることがあります。私の場合、Draco 1.4系でエンコードしたモデルを1.5系のデコーダで読み込ませたところ、車両側面のスムースシェーディングが一部ブロッキーに見えるという現象が出ました。

原因特定には半日かかりました。最終的に gltf-pipeline を最新版にアップデートし、エンコーダとデコーダを両方とも1.5.7で揃えたところ、現象は消えました。バージョンアップを怠ると、見た目に出ないレベルで品質が下がる可能性があるので、以降はCI上で自動的にバージョン整合をチェックする仕組みを入れています。

量子化ビットの調整でアーティファクトを回避

もう一つ、量子化ビットを下げすぎて痛い目を見た事例があります。試しに quantizePositionBits を11まで下げてみたら、車両の屋根のRの部分に階段状のアーティファクトが出てしまいました。遠景で見ると気にならないのですが、ユーザーが一人称視点でズームするシーンでは露骨にバレる品質劣化です。

このトレードオフは、「サイト内でユーザーがどのくらいモデルに近寄るか」で決まります。ジオラマ視点が中心の私のサイトでは14ビットで十分でしたが、コックピット視点を実装する場合は16ビットで妥協する必要が出るかもしれません。

ビルドパイプラインに組み込む

手動で毎回コマンドを打つのは事故の元なので、npm scriptsに組み込みました。

// package.json
"scripts": {
    "build:models": "node scripts/compress-models.js",
    "build": "npm run build:models && vite build"
}

スクリプトは assets/models_src/ 以下のglbをすべて走査し、Draco圧縮したものを assets/models/ に出力します。これをGit管理外にして、ビルド時に自動生成される形にすることで、巨大バイナリをリポジトリに入れずに済むようにしました。

まとめ

Draco圧縮は、正直「もっと早くやっておけば」と後悔するレベルで効果的でした。総転送量が1/5、初期表示までの時間も約40%短縮できて、Analyticsの「モバイル直帰率」も明確に下がりました。
ただ、セットアップには思わぬ罠があり、CSP設定・Safariのデコード速度・デコーダのバージョン管理など、気を配るポイントが多いのも事実です。「圧縮は1行で終わるが、配信と互換性は泥臭い」という3Dコンテンツの現実を、この作業で実感しました。