3D地図上に電車を走らせると、次にやりたくなるのが「その電車に乗っているような視点」や「ドローンで追いかけるような視点」です。
MapLibre GL JSのカメラ機能を駆使して、移動するオブジェクトを滑らかに追尾する「おいかけモード」を実装したので、その技術的なポイントをまとめます。

easeToによる滑らかな追尾

地図の中心を移動させるメソッドには jumpToflyTo がありますが、リアルタイムに移動する電車を追いかける場合、単純に毎フレームこれらを呼ぶと画面がガタついてしまいます。

そこで採用したのが、easeTo メソッドを短いデュレーション(持続時間)で呼び出し続ける手法です。これにより、計算上の位置と描画上の位置の間で微細な補間がかかり、ヌルッとした滑らかな追従が可能になります。

// アニメーションループ内で実行
map.easeTo({
    center: [lng, lat],
    bearing: camBearing, // カメラの向き
    pitch: 75,           // 見下ろす角度
    zoom: 17.5,
    animate: true,
    duration: 100        // 100msかけて補間移動
});

マルチアングルの計算ロジック

ただ後ろから追いかけるだけでは面白くないので、「運転士視点」や「撮り鉄視点」など、アングルを切り替えられるようにしました。
これは、電車の進行方向(Bearing)に対して、カメラのオフセット角度を加算することで実現しています。

// 進行方向の角度 (Degree)
const trainBearing = (Math.PI - posRot.rotation) * (180 / Math.PI);

// カメラのオフセット設定
const CAM_ANGLES = [
    { label: '運転士', offset: 0 },
    { label: '右斜め前', offset: 150 },
    { label: '左斜め前', offset: 210 }
];

// カメラの向きを決定
const camBearing = trainBearing + CAM_ANGLES[mode].offset;

特に「斜め前」からの視点は、カーブを曲がる際に車両の側面が見え隠れするため、非常にダイナミックな映像になります。Turf.jsで計算した精密な軌道データのおかげで、カメラがブレることなく綺麗に回り込んでくれます。

運転士視点(Driver's View)の実装

カメラ追従の中でも、最も没入感が高いのが「運転士視点」です。実際の運転席から前方を見ているような視点をどう実現するか、試行錯誤を重ねました。

運転士視点では、カメラ位置を電車の先頭位置よりも少し前方(進行方向に100m程度オフセット)に配置しています。これは、実際の運転席から見える景色をシミュレートするためです。真上から見下ろすのではなく、pitch(仰角)を80度程度に設定することで、線路が奥に伸びていく遠近感のある映像が得られます。

// 運転士視点の設定
if (currentCamSetting.isDriverView) {
    // 先頭車両から100m前方をカメラ位置とする
    dist += 100;
    const posRot = config.lut.getPosRot(dist);
    map.easeTo({
        center: [posRot.lng, posRot.lat],
        bearing: trainBearing, // 進行方向を正面に
        pitch: 80,             // ほぼ水平の視点
        zoom: 18,              // 高ズームで迫力を出す
        duration: 100
    });
}

速度によるズーム変動

固定ズームだと、高速走行時に景色が近すぎて酔いやすく、低速時は遠すぎて臨場感に欠けます。そこで、速度に連動してズームレベルを微調整する仕組みを入れています。時速100km以上では若干ズームアウト(17.5)、停車中はズームイン(18.5)するように補間しています。

PCとモバイルでの挙動の違い

PC版ではピッチ(見下ろし角度)をカメラ設定で固定していますが、モバイル版ではユーザーがピンチ操作で自由にピッチを変えられるようにしています。

これは、スマートフォンでは画面が小さいため、ユーザーが自分の見やすい角度に調整できた方が快適だという判断からです。技術的には、モバイル判定(画面幅600px以下)のとき、easeToのpitchパラメータに現在の map.getPitch() をそのまま渡すことで、ユーザーの操作を上書きしないようにしています。

// モバイルではユーザーのピッチ操作を尊重
let pitch;
if (window.innerWidth > 600) {
    pitch = currentCamSetting.pitch; // PC: 設定値で固定
} else {
    pitch = map.getPitch();          // モバイル: 現在値を維持
}

カメラ追従のON/OFF切り替え

常にカメラが追従していると、ユーザーが地図を自由にスクロールして他の場所を見ることができません。そこで、「おいかけモード」のトグルスイッチを用意しました。

追従をOFFにすると、地図は自由に操作できるようになりますが、電車は画面外で走り続けています。追従を再度ONにすると、easeToのdurationを500msに設定して、滑らかに電車の位置まで画面が移動します。いきなりジャンプするよりも、「カメラが飛んでいく」ような演出の方がユーザー体験として心地よいものになります。

カメラ酔いとの戦い

カメラ追従の実装で最も苦戦したのは、技術的なコードよりも「人間の生理」との戦いでした。自分では気持ちよく動いていると思っていた映像が、他の人にテストしてもらうと「5分で気持ち悪くなる」と酷評されたのです。

easeToでズーム・ピッチ・ベアリングを一気に動かした初期実装

最初の実装では、モード切り替え時に easeTo 1回でズーム・ピッチ・ベアリングを同時に変化させていました。見た目には「カメラがシュッと動く」感じで気持ちよく、自分ではむしろ気に入っていたのですが、テスター数名から口を揃えて「目が追いつかない」「画面の回転が速すぎて酔う」というフィードバックが返ってきました。特にピッチが急激に変わると、水平線が傾く速度が脳の予測を超えてしまうらしく、三半規管への負担が大きいようです。

パラメータごとに別周期でアニメーションさせる

解決策として採用したのが、ズーム・ピッチ・ベアリングをそれぞれ別のイージング関数で独立に制御する方法です。ズームは比較的速く(400ms)、ベアリングは中くらい(700ms)、そしてピッチは最も遅く(1200ms)動かすことで、視点の変化が段階的に感じられるようになりました。

// Smoothstep関数(t: 0〜1)
function smoothstep(t) {
    return t * t * (3 - 2 * t);
}

// 各パラメータを別周期で補間
function animateCameraTransition(from, to, now) {
    const tZoom    = smoothstep(Math.min((now - startTime) / 400,  1));
    const tBearing = smoothstep(Math.min((now - startTime) / 700,  1));
    const tPitch   = smoothstep(Math.min((now - startTime) / 1200, 1));
    map.jumpTo({
        zoom:    from.zoom    + (to.zoom    - from.zoom)    * tZoom,
        bearing: from.bearing + (to.bearing - from.bearing) * tBearing,
        pitch:   from.pitch   + (to.pitch   - from.pitch)   * tPitch,
    });
}

ピッチだけを遅く動かすこのアプローチに切り替えてから、「酔う」という報告はほぼなくなりました。視点の回転速度は、技術的にできるからといって最速で動かしていい要素ではないという、当たり前すぎる学びを身をもって得ました。

先行車追尾で起きた悲劇

「おいかけモード」で自車だけでなく先行車も画面に入れたい、というアイデアがありました。後ろから迫る山手線の編成を眺めながら走る、いわば「鉄道版バックミラー」のような演出です。実装してみるとなかなか壮観だったのですが、早々に問題が発生しました。

複数編成が重なって見えなくなる

山手線は1周を複数の編成が走っているので、駅間によっては手前の編成が奥の編成を完全に隠してしまうという状況が頻発しました。特に直線区間では、カメラから見て車両がきれいに一直線に並び、後ろの車両の存在感がゼロになります。「追いかけているのに何も見えない」という、カメラ追従として致命的な絵面です。

カメラオフセットを動的に調整するロジック

そこで、前方に他の編成が存在する場合のみ、カメラを少し左右にずらす「動的オフセット」を実装しました。自車の進行方向ベクトルに対して、前方10秒以内に他編成が検出されたらカメラを右に15mオフセットし、編成同士の視差で両方が見えるようにする、という仕組みです。

// 前方の他編成検出とオフセット計算
function calcDynamicOffset(selfTrain, allTrains) {
    const aheadTrains = allTrains.filter(t => {
        if (t.id === selfTrain.id) return false;
        const deltaDist = t.distOnLine - selfTrain.distOnLine;
        return deltaDist > 0 && deltaDist < selfTrain.speed * 10;
    });
    if (aheadTrains.length === 0) return { x: 0, y: 0 };
    // 他編成があれば横にずらして視差を作る
    return { x: 15, y: 0 };
}

複雑化してバグの温床に

ところが、この動的オフセット機構が予想外にバグを生みました。「前方に他編成がいる/いない」の判定がカメラフレームごとに切り替わる状況(ちょうど10秒前後に他編成がいる場合)で、オフセットが毎フレーム右15mと0mの間を行き来してカメラがガタガタ震えるという現象が発生したのです。ヒステリシス(検出閾値に余裕を持たせる仕組み)を入れることで一応は収まりましたが、コードはどんどん膨らみ、何のためのロジックなのか自分でも分からなくなる寸前でした。結局、先行車追尾モードは「上級者向けオプション」に格下げして、デフォルトはシンプルな自車追尾に戻しました。機能を増やせば増やすほど、その分だけ状態空間が爆発するという典型例です。

運転士視点の画角調整

運転士視点は没入感の要ですが、fov(画角)の数字ひとつで印象が劇的に変わる要素でもあります。たった数十度の違いで、「狭くて息苦しい」「酔う」「ちょうどいい」の境界線を跨ぎます。

45度・60度・75度の比較

まず fov: 45 で試したところ、中望遠レンズで覗いているような絵になり、トンネルに頭を突っ込んだような圧迫感がありました。次に 75 で試すと、広角レンズの効果で視界は広がるものの、画面端の建物が大きく歪み、またカーブを曲がる時の横方向の動きが激しすぎて酔いが倍増しました。最終的に採用したのは真ん中の 60。人間の単眼視野にも近く、広すぎず狭すぎない落としどころです。

// FOVとクリッピングプレーンの最終設定
map.transform._fov = 60 * Math.PI / 180; // 60度
map.transform._nearZ = 0.5;  // 近距離建物のちらつき対策
map.transform._farZ  = 3000; // 遠景までしっかり描画

視界の端で建物が歪むFOV distortion

fov: 75 で致命的だったのは、画面端の建物が縦に引き伸ばされたように歪んで見える現象です。これは広角レンズ特有の「球面歪曲」に似たもので、カーブに差し掛かった瞬間に画面外縁のビル群がグニャっと伸び、運転士視点のリアリティを完全に壊していました。60度に下げることでこの歪みも自然なレベルに収まり、視覚的な違和感がかなり減りました。

cameraClippingPlanesで近距離建物のちらつきを消す

もう一つの大きな問題が、線路脇の近接建物が一瞬消えたり点滅したりする現象でした。これはクリッピングプレーンのnear値がデフォルトで近すぎるため、カメラのすぐ手前にある建物がZ-fighting(深度衝突)を起こしていたのが原因です。nearZ を 0.5、farZ を 3000 に明示的に設定することで、ちらつきはほぼ完全に解消されました。Webの地図ライブラリではあまり意識しない値ですが、カメラをかなり低い位置に落とすシミュレーター用途では必須の調整です。

カメラ制御の状態管理

カメラ関連のバグで最もデバッグが難しかったのが、モード切替時にカメラが瞬間移動する現象でした。例えば「俯瞰モード」から「運転士視点」に切り替えた瞬間、画面がフラッシュするように別の場所にワープしてしまうのです。

補間用の中間状態を挟む

原因は、モード切替時に前モードの easeTo がまだアニメーション中で、その最終座標に到達する前に新モードの easeTo が開始されることでした。結果として「前モードの中間地点」から「新モードの目標地点」への補間が走り、辻褄が合わずに瞬間移動に見えていたのです。

// モード切替時に中間状態を経由する
async function switchCameraMode(newMode) {
    // 1. 現在のカメラ状態を確定させる
    const current = {
        center:  map.getCenter(),
        zoom:    map.getZoom(),
        pitch:   map.getPitch(),
        bearing: map.getBearing()
    };
    map.stop(); // 進行中のアニメを停止
    map.jumpTo(current); // 現在位置で確定

    // 2. 新モードへ遷移
    await easeToPromise(newMode.targetCamera, 800);
    currentMode = newMode;
}

ポイントは map.stop() で進行中のアニメーションを一度打ち切り、jumpTo で「今の見た目」をスナップショットとして確定させてから、新しいアニメーションを始めることです。この一手間で、モード切替時のワープ現象は完全になくなりました。カメラは状態機械として扱わないと簡単に破綻する、というのがこの件で得た教訓です。

まとめ

カメラ制御は、ユーザーの没入感を高める最も重要な要素の一つです。今後は、マウスドラッグでカメラを自由に回せる機能や、自動的に見どころスポットを巡るオートパイロット機能なども検討していきたいと思います。