ブラウザで動く「山手線 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モデル特有の「座標系の罠」は、ドキュメントだけでは理解しにくく、実際にトライ&エラーを繰り返すことで体得していくしかない部分だと感じています。

ボギー計算で起きた謎の挙動

ボギー台車を考慮した向き計算は、理屈の上では非常にシンプルです。しかし実装当初、私はこのロジックを何度も何度も壊しました。ここでは、特に印象に残っている「新橋〜有楽町間でだけ車両が逆走する」という、深夜2時に泣きながらデバッグしたバグの話をさせてください。

atan2の引数を逆に渡していた話

最初にボギー計算を実装したとき、急カーブに差し掛かった瞬間に車両が180度クルッと反転して、後ろ向きに走り出すというバグに遭遇しました。山手線の中でもとりわけカーブの多い田端〜西日暮里あたりで頻発し、画面を見ていると「ああ、また裏返った」と悲しい気持ちになる日々でした。

原因は、 Math.atan2 の引数を逆に渡していただけでした。JavaScriptのMath.atan2は第1引数がY、第2引数がXです。X成分を先に書く癖があるC言語系の感覚で書いてしまい、結果として90度ずれた値が返り、急カーブで累積した誤差が反転としてあふれ出ていたのです。

// 修正前(やりがちなミス)
const angle = Math.atan2(dx, dy); // ← 引数が逆!

// 修正後(正しい順序)
const angle = Math.atan2(dy, dx);

たった2文字の入れ替えにたどり着くまで、3時間ほどかかりました。しかもこのバグの厄介だったところは、直線区間では一見正しく動いているように見える点です。atan2の結果が90度ずれていても、メッシュのrotationを別のところで90度補正していたせいで相殺されてしまい、カーブに差し掛かってから初めて破綻するという、なんとも性格の悪い症状でした。

新橋〜有楽町だけで逆走する

もう一つ面白かったのが、「新橋〜有楽町間を走る時だけ車両が逆を向く」という非常にピンポイントなバグです。最初は「そんなバカな、路線の一部区間でだけバグるなんてありえない」と思ったのですが、実際にカメラを追従させて観察すると、確かにその区間だけきれいに反対を向いて走っていました。

原因は、GeoJSONの該当区間だけ座標列の向きが逆に入っていたことです。OpenStreetMapから取ってきた線路データは、セグメントごとに作成者が異なり、ある人は内回り方向に、ある人は外回り方向に座標を並べていたのです。全体のLUTを作るとき、そのまま連結してしまうとその区間だけ「前後の台車」の前後関係が反転してしまいます。

// 区間の向きを進行方向で揃える前処理
function normalizeDirection(segments, reference) {
    return segments.map((seg) => {
        const start = seg[0];
        const end = seg[seg.length - 1];
        // 前の区間の終点と「始点」の距離が遠ければ座標列を反転
        if (distance(reference, start) > distance(reference, end)) {
            seg = seg.slice().reverse();
        }
        reference = seg[seg.length - 1];
        return seg;
    });
}

LUT構築の前にこの正規化を入れたことで、新橋〜有楽町の逆走問題は解消しました。気づいたのが明け方4時頃で、嬉しさとともにしばらく呆然としていたのを覚えています。

モデルスケールの地獄

3Dモデルのスケール問題も、本当に心を折られかけたポイントでした。座標系の話と並んで、私の開発ノートに「二度とやらない」と赤字で書かれている項目です。

mm単位で書き出した結果、米粒になった話

Blenderで作成したE235系モデルを初めてglTFで書き出してアプリに読み込んだ時、どこにも車両が見当たりませんでした。「読み込み失敗か?」と思ってカメラを地面すれすれまで寄せたところ、なんとマップ上に米粒サイズのピカピカ光る点が置かれていたのです。

原因はBlender側の単位設定でした。ちょうどフィギュア制作用に単位をミリメートルに切り替えた直後に書き出してしまい、20,000mmのつもりが「20」という値で出力されていました。20mの車両が20mmに縮んでいたので、ちょうど1/1000スケール。顕微鏡が必要なサイズです。

100倍にしたら逆にビルを突き抜けた話

「じゃあscaleを100倍にすればいい」と安直に model.scale.set(100, 100, 100) とした結果、今度は車両が東京タワー並みの巨大ロボと化して、周辺のビルを突き抜けて走り出しました。あまりに巨大すぎて、画面を占める緑のシルエットが一体何なのか数秒間認識できなかったほどです。

この一件で反省し、以後はBlenderの単位・glTFの出力スケール・Three.js側での適用スケールを、プロジェクト全体で「メーター単位」に統一するルールを明文化しました。

// スケールはメーター単位で統一。適用時にメルカトル係数を掛ける
const meterScale = modelOrigin.meterInMercatorCoordinateUnits();
model.scale.set(meterScale, meterScale, meterScale);
// Blender出力時: "Unit Scale = 1.0", "Apply Transform" ON, メートル単位

たったこれだけのルールですが、決めておかないと毎回「あれ、今のモデルは何単位だっけ?」と迷うことになります。プロジェクトのREADMEにも「単位はメートル。以上」と太字で書いてあります。

連結器のガタつきを表現する工夫

前章で11両編成の位置を先頭車基準で計算していると書きましたが、実はその後、ちょっとした追加実装を入れています。すべての車両を完全剛結で繋いでしまうと、どうしても模型っぽさが薄れてしまい、一体成形のプラモデルのように見えてしまうのです。

そこで、各車両の連結部分に微小な「遊び」を入れて、カーブ走行時にほんのわずかにガタつく演出を追加しました。実装は非常にシンプルで、車両ごとに微小な乱数ノイズを加算するだけです。

// 連結器のわずかな遊びを表現
const JOINT_PLAY = 0.03; // ±3cm程度のゆらぎ
meshes.forEach((mesh, i) => {
    const seed = (i + trainIndex * 11) * 0.137;
    const wobble = Math.sin(time * 2.1 + seed) * JOINT_PLAY;
    mesh.position.y += wobble * meterScale;
});

この3cmほどのゆらぎが加わるだけで、車両全体が「連結された別の物体の集合」として見えるようになり、見た目の情報量がぐっと増えました。特にカーブを抜ける瞬間、車両同士がコトンと小さく揺れる感じが、鉄道模型の重さを感じさせてくれます。機能としては何も増えていませんが、こうした「何気ない揺れ」こそが、リアリティを決定づける要素だと感じています。

まとめ

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