ブラウザで動く「山手線 3Dジオラマ」の開発において、最も苦労した点の一つがMapLibreの地図上にThree.jsの3Dモデル(電車)を正しく配置し、スムーズに動かす部分です。
今回は、その実装の肝となる座標変換と車両の向きの制御について、実際に書いたコードを交えながら振り返ってみたいと思います。

MapLibreとThree.jsの座標系の違い

MapLibre GL JSは、地球全体をメルカトル図法で投影した座標系を持っています。一方、Three.jsは一般的な3D空間(デカルト座標系)です。この2つを同期させるためには、MapLibreが提供する CustomLayerInterface を利用します。

特に重要なのが、緯度経度をMapLibre内部の「メルカトル座標(MercatorCoordinate)」に変換する処理です。これを怠ると、ズームレベルを変えた瞬間に電車の位置がズレてしまったり、大きさがおかしくなったりします。

// 緯度経度からメルカトル座標への変換
const modelOriginMercator = maplibregl.MercatorCoordinate.fromLngLat(MODEL_ORIGIN, 0);
const METER_SCALE = modelOriginMercator.meterInMercatorCoordinateUnits();

meterInMercatorCoordinateUnits() というメソッドが非常に便利で、これを使うことで「1メートルがMapLibreの座標上でどれくらいの大きさか」を取得できます。これのおかげで、電車のサイズをメートル単位で指定しても、地図上で正しい縮尺で表示されるようになります。

電車の向きをリアルに計算する

開発初期は、単に「現在地」と「次の目的地」の2点を結んだ角度を車両の向きとしていました。しかし、これだとカーブに差し掛かったときに車両がカクカクと動いてしまい、まるで板が回っているような不自然な動きになってしまいました。

そこで導入したのが、**「台車間距離(ボギー角)」を考慮した計算ロジック**です。

2点支持による回転制御

実際の電車は、前後の台車(車輪がついている部分)がレールに乗っています。そこで、シミュレーション上でも車両の中心点1つで計算するのではなく、「前方台車の位置」と「後方台車の位置」の2点を計算し、その差分から向きを決定するようにしました。

// 前後の台車位置を計算
let distFront = centerDist + (BOGIE_DISTANCE / 2);
let distRear  = centerDist - (BOGIE_DISTANCE / 2);

// 座標取得
const posFront = config.lut.getPosRot(distFront);
const posRear  = config.lut.getPosRot(distRear);

// 向きの計算 (atan2を使用)
const dx = posFront.x - posRear.x;
const dy = posFront.y - posRear.y;
const angle = Math.atan2(dy, dx); 

// モデルの向きに合わせて補正 (+90度など)
mesh.rotation.y = angle + (Math.PI / 2);

このロジックに変更したことで、カーブ走行時に車体が内側に切り込むような、鉄道模型特有のリアリティが出せるようになりました。特にS字カーブを抜ける時のヌルッとした動きは、見ていて気持ちが良いものです。

GLTFモデルの読み込みと最適化

電車の3DモデルにはglTF 2.0形式(.glbバイナリ)を採用しています。Three.jsの GLTFLoader で読み込み、シーンに追加する流れです。

const loader = new THREE.GLTFLoader();
loader.load('/assets/models/e235.glb', (gltf) => {
    const model = gltf.scene;
    model.scale.set(METER_SCALE, METER_SCALE, METER_SCALE);
    scene.add(model);
});

モデルのポリゴン数は1両あたり約3,000ポリゴンに抑えています。鉄道模型のNゲージスケール(1/150)を意識し、細部よりも全体のシルエットが正しく見えることを優先しました。窓のディテールやドアの溝は、ジオメトリではなくテクスチャ(UV展開した画像)で表現することで、描画負荷を大幅に軽減しています。

複数車両の連結表現

山手線E235系は11両編成です。単純に11個のモデルを並べるだけでは、車両間に隙間ができたり、カーブで連結器が外れたような見た目になってしまいます。

そこで、各車両の配置は先頭車の走行距離を基準として計算しています。先頭車の距離から CAR_GAP_METER(車両間隔:約20m)を順に引いていくことで、後続車両の位置を自動的に決定します。

// 11両編成の各車両位置を算出
const CAR_GAP_METER = 20; // 1両の長さ + 連結器
config.trains.forEach((meshes, tIndex) => {
    meshes.forEach((mesh, carIndex) => {
        const carDist = headDist - (carIndex * CAR_GAP_METER);
        const posRot = config.lut.getPosRot(carDist);
        if (posRot) {
            mesh.position.set(posRot.x, posRot.y, 0);
        }
    });
});

この方式のメリットは、カーブ走行時に各車両が独立してレールに追従する点です。急カーブでは先頭車と最後尾車で大きく角度が異なり、まるで蛇のようにうねりながら曲がっていく様子が自然に再現されます。

デバッグで苦労した点

3Dモデルの配置で最も苦労したのは、モデルの原点とスケールの不一致です。Blenderでモデルを作成した際、原点が車両の底面ではなく中心に設定されていたため、地図上に配置すると車両が半分地面に埋まった状態になりました。

また、Blenderの座標系(Z-up)とThree.jsの座標系(Y-up)の違いにより、モデルが90度横倒しになるという問題も発生しました。GLTFエクスポート時に「+Y Up」オプションを有効にすることで解決しましたが、この設定に気づくまで半日を費やしました。

こうした3Dモデル特有の「座標系の罠」は、ドキュメントだけでは理解しにくく、実際にトライ&エラーを繰り返すことで体得していくしかない部分だと感じています。

まとめ

MapLibreとThree.jsの連携は、最初は座標系の理解に戸惑いますが、一度変換ロジックを作ってしまえば非常に強力な表現力を手に入れられます。
今後は、この仕組みを応用して、ポイント切り替えや複線ドリフト(?)のような複雑な挙動にも挑戦してみたいと思っています。