こんにちは、山手線3Dジオラマの開発者です。
今回は、ブラウザ上でリアルな鉄道模型シミュレーターを動かすために採用した技術スタックについて解説します。

1. なぜMapLibre GL JSなのか?

地図ライブラリにはLeafletやMapbox GL JSなど様々な選択肢がありますが、今回はオープンソースでWebGLのパフォーマンスを最大限に引き出せるMapLibre GL JSを採用しました。

特に、カスタムレイヤー機能を使ってThree.jsと連携できる点が決め手となりました。

Three.jsとの座標同期の課題

地図(メルカトル図法)の座標系と、3D空間(Three.js)の座標系を合わせるのには苦労しました。以下のコードで、MapLibreの行列をThree.jsのカメラに適用しています。

// MapLibreの行列をThree.jsに適用する処理
render: function (gl, matrix) {
    const m = new THREE.Matrix4().fromArray(matrix);
    const l = new THREE.Matrix4()
        .makeTranslation(modelOrigin.x, modelOrigin.y, 0)
        .scale(new THREE.Vector3(scale, -scale, scale));
        
    this.camera.projectionMatrix = m.multiply(l);
    this.renderer.render(this.scene, this.camera);
}

2. 電車の滑らかな動きの実現

ただ座標を移動させるだけでは、カクついたり、カーブで不自然な動きをしてしまいます。そこで、事前に計算した軌道データ(LUT: Look Up Table)を用意し、線形補間を行うことで滑らかなアニメーションを実現しました。

Turf.jsを使って線路データを1m単位で再計算

2. GeoJSONデータから線路を生成する

電車を走らせるためには、まず「どこを走るか」という線路データが必要です。本プロジェクトでは、OpenStreetMapから取得した山手線の軌道データをGeoJSON形式で扱っています。

しかし、生のGeoJSONデータは点と点の間隔がバラバラで、そのまま使うと電車の速度が区間ごとに変動してしまいます。そこで、Turf.jsの along 関数を使い、線路全体を1メートル間隔で等分割した座標テーブル(LUT: Look Up Table)を事前に計算しています。

// Turf.jsで線路を1m間隔にリサンプリング
const totalLength = turf.length(lineString, { units: 'meters' });
const lut = [];
for (let d = 0; d <= totalLength; d += 1) {
    const point = turf.along(lineString, d, { units: 'meters' });
    const [lng, lat] = point.geometry.coordinates;
    lut.push({ dist: d, lng, lat });
}

この前処理により、実行時は距離値からO(1)で座標を引けるようになり、毎フレームの計算コストを大幅に削減できました。山手線一周約34.5kmのデータで約34,500エントリのテーブルが生成されますが、メモリ消費は数MB程度に収まっています。

線路データの精度と課題

OpenStreetMapのデータは有志によるマッピングのため、場所によって精度にばらつきがあります。特に地下区間やトンネル内では、実際の線路位置と数十メートルのズレが発生する箇所もありました。

現時点では手動で座標を補正していますが、将来的には鉄道事業者が公開する正式な軌道データとの統合も検討しています。

3. パフォーマンスの最適化

ブラウザでリアルタイム3Dを動かす以上、パフォーマンスは常に意識しなければなりません。特に以下の3点が重要でした。

requestAnimationFrameとの同期

MapLibre GL JSのカスタムレイヤーは、地図の再描画タイミングで render メソッドが呼ばれます。この中でThree.jsのレンダリングも行うことで、地図と3Dオブジェクトの描画タイミングを完全に同期させています。別々のrequestAnimationFrameで回すと、微妙なフレームズレが発生し、電車が地図からわずかに浮いたり沈んだりする現象が起きてしまいます。

ジオメトリの使い回し

山手線には複数の編成が同時に走っています。各車両に個別のジオメトリを割り当てるとメモリが大量に必要になるため、GLTFモデルを一度だけ読み込み、InstancedMesh のように同一ジオメトリを共有する設計を採用しています。これにより、10編成110両を同時に表示しても、GPUメモリの消費を最小限に抑えることができました。

LOD(Level of Detail)の切り替え

ズームレベルが低い(広域表示)ときは車両のディテールが見えないため、簡略化した低ポリゴンモデルに自動切り替えしています。これにより、ズームアウト時の描画負荷を約40%削減することに成功しました。

4. 開発中にハマった座標系の落とし穴

ここまで割とスマートに書いてきましたが、実際の開発現場ではとにかく座標系にまつわるバグと延々と格闘していました。MapLibreとThree.jsの座標を繋ぐ処理は、ちょっとした型の取り違えや精度不足で即座に破綻するため、何度も絶望的な状態になりました。以下、私が実際にハマった3つの落とし穴を供養がてら書き残しておきます。

ズームレベル20以上で車両が震える

最初に遭遇したのは、ズームレベル20付近まで拡大すると、停車中のはずの電車が1pxくらいの幅でブルブル震えるという現象でした。「これはさすがにMapLibreのバグでは?」と思ってIssueを漁った結果、原因はThree.js側のFloat32精度不足でした。

メルカトル座標は地球全体を[0, 1]の範囲に正規化して持っているのですが、ズームレベル20まで拡大すると東京周辺の座標値が小数点以下10桁目くらいまで意味を持つようになります。そのままGPUにFloat32で渡すと丸め誤差が出て、電車が毎フレーム数十cmだけ揺れ動いてしまうわけです。都心部で精密に計測すると、見た目上0.5mほどずれている瞬間がありました。

// 修正前: メルカトル原点をそのままGPUに渡していた
const px = mercator.x * scale;
const py = mercator.y * scale;

// 修正後: カメラ基準でローカル化してから渡す
const offsetX = (mercator.x - cameraOrigin.x) * scale;
const offsetY = (mercator.y - cameraOrigin.y) * scale;
// これで数値が小さくなり、Float32の有効桁数内に収まる

要するに「描画対象の近くに原点を持ってきてから変換する」という、3Dゲームプログラミングでは王道のテクニックを忘れていたというオチでした。この修正以降、ズームレベル22まで拡大しても車両はピタリと静止するようになりました。

メルカトル座標を忘れて札幌に飛んだ話

これは本当に恥ずかしい話なのですが、ある日「車両の位置を1メートル右にずらしたい」と思い、何も考えずに mesh.position.x += 1 と書いて実行しました。結果、車両は画面から消えました。

慌てて遠くまでズームアウトしていくと、なんと北海道の札幌近郊にE235系が1編成だけ置かれていたのです。理由は単純で、MapLibreのメルカトル座標系では「1」という値が地球の赤道半周分(約20,000km)に相当するため、1を足した瞬間に車両が銀河の彼方へワープしたというわけです。

前回の記事で出てきた meterInMercatorCoordinateUnits() で得られる係数を掛けない限り、「1」は1メートルではなく「地球半周」を意味します。以降、座標を操作するときは必ず以下のようなヘルパー関数を経由するようにしました。

// 必ずメートル単位で渡すためのラッパー
function moveMesh(mesh, meterX, meterY) {
    const s = modelOrigin.meterInMercatorCoordinateUnits();
    mesh.position.x += meterX * s;
    mesh.position.y += meterY * s;
}

このラッパーを通さずに生のposition操作を書いたら、レビューで弾く運用にしています。なにしろ、バグっても画面上では「車両が消える」という症状にしかならず、まさか札幌まで飛んでいるとは誰も想像しないからです。

5. 描画パフォーマンスの最終調整

ひととおり動くようになってから、本腰を入れてパフォーマンスチューニングに取り組みました。結果的にノートPCでもモバイルでも60fpsで回るようになりましたが、そこに至るまでには何度か「なぜかCPUが100%張り付く」という原因不明の症状と向き合う必要がありました。

requestAnimationFrameが2重に走っていたバグ

開発中盤、ふとブラウザのタスクマネージャを見たらCPU使用率が常時80%を超えていることに気づきました。最初はThree.jsのシーンが重いのかと疑い、車両数を減らしたりLODを激しくしたりしましたが、まったく改善しません。

地道にプロファイラを眺めていたところ、render 関数が1フレームに2回呼ばれていることが判明しました。原因は、MapLibreのCustomLayerの render メソッド内で map.triggerRepaint() を呼んでいたことです。CustomLayerのrender自体が再描画を要求するので、ループが意図せず暴走していたのです。

// 問題のコード(再帰的にrepaintを呼んでしまう)
render: function (gl, matrix) {
    this.renderer.render(this.scene, this.camera);
    this.map.triggerRepaint(); // ← これが毎フレームrenderを呼び出す
}

正しくは、アニメーションが必要な時だけ外側から triggerRepaint を呼び、render関数自体は副作用を持たないようにすべきでした。この1行を消しただけで、CPU使用率は一気に25%まで下がりました。

console.logが毎フレーム出ていてCPUが跳ね上がった話

もう一つの恥ずかしいやつです。座標計算のデバッグのためにrender関数の中に console.log(mesh.position) を仕込んでいたのを忘れたままリリースビルドに乗せていました。

60fpsで10編成110両ぶんの座標を全部ログ出力していたため、Chromeのコンソールがあっという間に数十万行のログで埋まり、DevToolsを開いているだけでCPU使用率が跳ね上がるという症状に悩まされていました。「本番環境では console.log も立派な重い処理」というのを身をもって理解しました。現在はビルド時に一括で console.log を削除するBabelプラグインを入れて、再発を防止しています。

CustomLayerのonRemoveを書き忘れてメモリリーク

画面遷移やリロードを繰り返していると、徐々にブラウザのメモリ使用量が増え、数十分放置するとタブがクラッシュする現象にも遭遇しました。原因はCustomLayerの onRemove を実装していなかったことです。

Three.jsのシーン、GLTFで読み込んだジオメトリ、テクスチャ、WebGLのバッファ……これらはすべて明示的に dispose() を呼ばないと解放されません。MapLibreがレイヤーを破棄するタイミングに合わせて、自分で地道に後始末をする必要がありました。

onRemove: function () {
    this.scene.traverse((obj) => {
        if (obj.geometry) obj.geometry.dispose();
        if (obj.material) {
            if (Array.isArray(obj.material)) {
                obj.material.forEach(m => m.dispose());
            } else {
                obj.material.dispose();
            }
        }
    });
    this.renderer.dispose();
}

これを書いてから、SPAルーティングで画面を何度切り替えてもメモリが増えなくなり、ようやく安定して公開できる状態になりました。

6. この技術を選んで正解だった理由

MapLibre GL JS + Three.jsという構成を選んだのは、半分は直感、半分は消去法でした。当初はdeck.glやCesiumも候補に挙げて比較検討していましたが、結果的にMapLibreにして本当に良かったと思っています。

deck.glとCesiumを検討した結果

deck.glは非常に強力なデータ可視化フレームワークで、TripsLayerを使えば電車の軌跡を描くこと自体は簡単そうに見えました。ただ、車両を「モデル」として表示したかった私には、結局Three.jsとの連携が必要になる構成で、それならば最初からThree.jsを自分で制御したほうが自由度が高いと判断しました。

Cesiumは地球全体を正確に再現できる素晴らしいライブラリですが、そのぶん描画パイプラインが重く、モバイルでサクサク動かすのはかなり厳しそうでした。また、日本の地図タイルとの相性や、東京周辺に特化した表示のカスタマイズのしやすさを考えると、MapLibreの方が圧倒的にコントロールしやすかったです。

結果的に得られた恩恵

MapLibre GL JSはオープンソースであるため、不可解な挙動に遭遇したときもソースコードを直接追いかけて原因を特定できます。実際、内部のMercatorCoordinateの実装を読んで、自分のバグの原因を特定できた場面が何度もありました。「困ったらソースコードを読める」というのは、長期的に個人開発を続けるうえで想像以上に大きな安心材料になります。

また、MapLibreはスタイル定義がJSONベースで柔軟なので、「夜の東京」「昭和の風景」「ジオラマ風」といったテーマを切り替える機能も、スタイルJSONを差し替えるだけで実現できました。Cesiumだとここまで軽快にいかなかったと思います。

7. 今後の展望

現在は山手線のみですが、今後は京浜東北線や中央線など、並走する路線の実装も予定しています。複数路線のダイヤグラム同期など、さらなる技術的課題に挑戦していきます。