「山手線 3Dジオラマ」をリリースして数週間、私は毎晩Twitterのエゴサーチを欠かさない生活を送っていました。ある日、フォロワーさんから「1時間くらい走らせてると突然タブが真っ白になる」という報告が届きました。自分の環境では試してみても再現せず、半信半疑で検証したところ、MacBook Airで45分ほど電車を走らせ続けた結果、タブが「Aw, Snap!」の画面に変わり、見事にクラッシュしたのです。

この記事は、そこから約1週間、夜な夜なChrome DevToolsと睨めっこしながらメモリリークを潰していった記録です。結論から言うと、原因は一つではなく、5種類のリークが同時に発生していたという地獄のような状況でした。

症状の把握とヒープスナップショット

まず最初にやったのは、DevToolsの「Memory」タブでヒープスナップショットを撮ることでした。やり方は単純で、アプリを起動直後に1回、10分プレイした後に2回目、30分後に3回目と、間隔を空けて3回スナップショットを取得します。そして「Comparison」表示に切り替え、どのオブジェクトが増え続けているかを確認します。

最初のスナップショットでJS Heapは約80MBでした。これが10分後には190MB、30分後には430MBに膨れ上がっていました。明らかな右肩上がりです。GCが走ってもほぼ落ちない。これは確定的なリークだと察しました。

Detached DOM が大量に残っていた

「Summary」表示で「Detached」とフィルタリングして、まず目についたのがDetached HTMLCanvasElementが数百個単位で残っていることでした。ジオラマでは駅の案内表示をCanvas Textureとして生成しているのですが、駅を通過するたびに新しいCanvasを作り、古いものを解放していないのが原因でした。

// 修正前:駅ごとに新しいCanvasを作り捨て
function updateStationLabel(name) {
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    ctx.fillText(name, 10, 30);
    const texture = new THREE.CanvasTexture(canvas);
    stationMesh.material.map = texture;
}

// 修正後:Canvasとテクスチャを使い回す
const labelCanvas = document.createElement('canvas');
const labelCtx = labelCanvas.getContext('2d');
const labelTexture = new THREE.CanvasTexture(labelCanvas);
stationMesh.material.map = labelTexture;

function updateStationLabel(name) {
    labelCtx.clearRect(0, 0, labelCanvas.width, labelCanvas.height);
    labelCtx.fillText(name, 10, 30);
    labelTexture.needsUpdate = true;
}

たった数行の違いですが、これだけで1時間あたりのメモリ増加が150MBほど減りました。「使い捨てのオブジェクト生成はコスト」という基本を完全に忘れていた自分に腹が立ちました。

Three.js の dispose() 問題

次に疑ったのがThree.jsのジオメトリ・マテリアル周りです。Three.jsはGPU上に確保したリソース(バッファ・テクスチャ)を、明示的に dispose() しないと解放してくれません。この仕様は公式ドキュメントに書かれているのですが、実装初期の私はそれを完全に見落としていました。

具体的には、ダイヤ改正のシミュレーション機能を実装した際、ユーザーが路線を切り替えるたびに古い線路メッシュと新しい線路メッシュを置き換える処理を入れていたのですが、古いメッシュは scene.remove() しただけで、GeometryとMaterialは残り続けていたのです。

// 修正後:ジオメトリとマテリアルを明示的に解放
function disposeMesh(mesh) {
    if (mesh.geometry) mesh.geometry.dispose();
    if (mesh.material) {
        if (Array.isArray(mesh.material)) {
            mesh.material.forEach(m => disposeMaterial(m));
        } else {
            disposeMaterial(mesh.material);
        }
    }
    if (mesh.parent) mesh.parent.remove(mesh);
}

function disposeMaterial(material) {
    for (const key of Object.keys(material)) {
        const value = material[key];
        if (value && typeof value.dispose === 'function') {
            value.dispose();
        }
    }
    material.dispose();
}

ここで マテリアルに紐づくテクスチャも個別に dispose() しないといけないのを知らず、最初に作ったdisposeMesh関数では半分しか解放できていませんでした。これに気づかず丸2日間「なぜかGPUメモリだけ減らない」と唸っていた時間は、今思い出しても悔しいです。

GLTFLoader がキャッシュし続けていた

さらに深掘りすると、路線を切り替えるたびに車両の.glbファイルを再読み込みする実装だったのですが、GLTFLoaderはデフォルトでは内部キャッシュを持たないものの、私が独自に「Map」で車両モデルをキャッシュしていて、そのキャッシュが路線切り替え後もクリアされていませんでした。

しかも gltf.scene.clone() で複製したメッシュはジオメトリを共有するため、一括disposeすると元のキャッシュごと壊れる仕様。クローンの解放ルールを調べるのに半日溶かしました。結局、オリジナルは保持して、クローン側は geometry.dispose() を呼ばないという結論に落ち着きました。

Event Listener の溜め込みバグ

ヒープスナップショットをもう一度見ると、今度は「EventListener」というオブジェクトが数千個単位で残っていることに気づきました。これは衝撃的でした。何かが addEventListener を呼び続けている。

追跡してみると、カメラの追従モード切り替えボタンに設定していたクリックハンドラが、モード切替のたびに新しく追加されていました。removeEventListener を呼んでいない典型的なミスです。

// 修正前:切替のたびにリスナーが増える
function setupCameraMode(mode) {
    button.addEventListener('click', () => {
        switchMode(mode);
    });
}

// 修正後:AbortControllerでまとめて破棄
let cameraController = null;
function setupCameraMode(mode) {
    if (cameraController) cameraController.abort();
    cameraController = new AbortController();
    button.addEventListener('click', () => {
        switchMode(mode);
    }, { signal: cameraController.signal });
}

最近は私は AbortController を使う書き方を気に入っています。関連するリスナーを一気に解除できて、匿名関数でも追跡が不要になるからです。実装を書き換えた後、スナップショット上のEventListenerの数は安定し、増加が止まりました。

MapLibre の remove() 忘れ

もう一つ盲点だったのが、MapLibre GL JS 自体の解放です。私は「全域ビュー」と「運転席ビュー」を切り替える機能を入れていたのですが、切り替えのたびに新しい maplibregl.Map インスタンスを作り、古いものを単にDOMから外していました。

MapLibreには map.remove() という明示的な破棄メソッドがあり、これを呼ばないと内部のWebGLコンテキスト・ソース・レイヤーが全部残り続けます。ブラウザのWebGLコンテキスト数には上限があり、この仕様を知らないまま30回くらい切り替えたあとに「Too many active WebGL contexts. Oldest context will be lost.」という警告が出て、画面が真っ黒になるバグに遭遇しました。

これも結構ハマりポイントで、最初は「切り替え後ランダムで真っ黒になる」という再現性の怪しい症状だったので、原因特定に2日かかりました。map.remove() を呼ぶだけで、真っ黒問題は綺麗に消えました。

Performance Profiler で Long Task を見つけた話

メモリリークと並行して、フレームレートが時々ガクッと落ちる問題も抱えていました。これはメモリとは別の話ですが、同じDevToolsの「Performance」タブで調査しました。

プロファイルを取ると、200ms級のLong Taskが10秒に1回くらい発生していました。犯人は駅間距離の再計算でした。11両×4編成×前後台車で88個のLUT検索を、毎秒60回、さらに10秒ごとに「経路の再サンプリング」も走らせていたので、不定期に重い処理が集中していたのです。

// 経路の再サンプリングを、次回のrequestIdleCallbackで実行
function schedulePathResample() {
    if ('requestIdleCallback' in window) {
        requestIdleCallback(() => {
            resamplePath();
        }, { timeout: 2000 });
    } else {
        setTimeout(resamplePath, 0);
    }
}

重い再計算を requestIdleCallback に逃がしたところ、Long Taskはほぼゼロになりました。体感FPSも「60付近で安定してカクつかない」になり、プロファイル画面の緑色が綺麗に並ぶのを見て達成感を覚えました。

1週間格闘した夜の所感

結果として、1週間で5種類のリークを潰し、1時間連続プレイ後のJS Heapは180MB付近で安定、GPUメモリも目に見えて増えなくなりました。もちろん完全にゼロになったわけではなく、ブラウザの内部バッファやGCタイミングの都合で上下はしますが、「タブがクラッシュする」という報告はその後いただいていません。

この戦いを通して学んだのは、「ブラウザは賢いけど、3D・WebGL領域では人間が手動で解放する責任が大きい」ということです。普通のDOMしか触らないWebアプリだったら、ここまで神経質にならなくても済んだでしょう。でも、GPUを直接操作する以上、C言語で malloc/free するのと感覚が似ています。

そしてもう一つ、DevToolsのMemoryタブは食わず嫌いしていた自分が恥ずかしくなるくらい強力でした。GCルートからの参照パスまで追跡できる機能は、慣れれば「どのオブジェクトが誰に捕まっているか」が一目で分かります。今では、新機能を入れるたびに必ずヒープスナップショットを3回撮るのが開発ルーティンになりました。

まとめ

メモリリークは症状が出るまでに時間がかかるので、後回しになりがちですが、ブラウザゲームやWebGLアプリの体験を決定的に左右します。私のように1週間溶かす前に、リリース前に一度だけでもヒープスナップショットを撮っておくと、かなりの事故を防げると思います。
今後はCIに簡易的なメモリ増加テストを組み込んで、回帰を早期に検知できるようにしたいと思っています。