[JavaScript] Three.jsで「列車を運行する」実装に到達するまでの設計と思考ログ

はじめに

今日は、昨日の「Three.jsで線路を引いて列車を走らせた」の続き。

といっても、昨日勢いで書いたコードをリファクタリングしたのがメインで、客観的に目に見える実装があまり進まなかったですが、複数の列車の走行とルート、折り返し、その他、列車の運行するバックエンド的なシステム作成がメインで、実装内容をいつも通り、アウトプットで、後で読み返しできるように記事にまとめてみます。

Three.jsで列車システムを再設計

― 単一オブジェクトから「運行システム」へ ―

Three.jsで列車を走らせる実装を進める中で、 「1台動いた」段階から先に進もうとすると、設計の限界が一気に露出する。

今回やったのは見た目の派手な追加ではなく、 破綻せずに複数列車を運行できる基盤づくりだった。

① 単一列車から複数列車へ

― 「1台動けばOK」という設計が破綻する瞬間 ―

Three.jsで列車を走らせるとき、最初はだいたいこうなる。

  • 列車は1台
  • グローバルに train がいる
  • 毎フレーム train.position を更新する

これは試作としては正しい。 問題は、そのまま先へ進もうとした瞬間に壊れることだ。

なぜ「1台前提」は破綻するのか

1台前提の設計は、暗黙的にこう決め打っている。

  • 列車は世界に1つ
  • 状態は常に1つ
  • 次に何が来るかを考えなくていい

この前提が崩れるのは、だいたい次の瞬間。

  • 列車をもう1台増やしたい
  • 貨物列車を別速度で走らせたい
  • 折り返し中の列車と進行中の列車を同時に扱いたい

ここで「じゃあ train2 を作るか」とやると、 コードは一気に地獄になる。

train.position...
train2.position...
train3.position...

この時点で、設計としてはもう詰んでいる。

グローバル変数が招く事故

単一列車の実装では、だいたいこうなる。

let train;
let t;
let speed;
let direction;

これが意味するのは、

  • 状態が1セットしか存在できない
  • 別の列車を作った瞬間に上書きされる
  • デバッグ時に「どの列車の値かわからない」

という状況。

さらに悪いのは、エラーが出ないこと。

  • 値は存在する
  • 型も合っている
  • でも意味が破綻している

Three.jsはここを一切怒らない。 だから気づいたときには、列車が瞬間移動したり消えたりする。

列車を「配列」で考える

この問題を解決するために、発想を変える。

列車は1つのオブジェクトではなく、 同じ性質を持つ存在の集合として扱う。

そこで導入したのがこれ。

TRAIN.trains = [];

列車は必ずこの配列に入る。 世界に存在する列車は、すべてここに列挙される。

重要なのは、「複数台にするため」ではない。

  • 列車という概念をデータ構造として定義する
  • 1台でも複数台でも同じロジックで動く

この時点で、設計の次元が1段上がる。

updateループの設計

更新処理も根本から変わる。

for (const train of TRAIN.trains) {
  updateTrain(train, dt);
}

ここでやっているのは、

  • 世界を更新しているのではなく
  • 列車1台分の状態遷移を繰り返している

という構造への転換。

updateTrain(train) は、

  • その列車が
  • その路線で
  • 今どう動くべきか

だけを考えればいい。

他の列車の存在を一切知らなくていい。

「複数台対応」は結果でしかない

この設計の本質は、

  • 複数列車を走らせたいから配列にした ではなく、
  • 列車を「状態を持つ存在」として定義し直した

という点にある。

結果として、

  • 列車を増やせる
  • 種類を分けられる
  • 路線を分離できる
  • 停車・折返し・衝突判定へ拡張できる

全部あとから自然に乗ってくる。


この段階でやっているのは、 Three.jsのテクニックではなく設計の話。

ここを飛ばすと、 どれだけ数学やAPIを知っていても、必ずどこかで詰まる。

② 「t」 を捨てて 「progress」 に

― 数学的に正しくても、実装として最悪な変数 ―

Three.jsで曲線上を移動させると、ほぼ確実に登場するのがこれ。

curve.getPointAt(t);

t は 0〜1 の正規化パラメータで、 数学的にもAPI的にも完全に正しい。

問題は、アプリ側の設計としては致命的に意味が薄いことだ。

Three.jsのAPIとアプリ設計のズレ

t は Three.js にとっては明確な意味を持つ。

  • 曲線全体を 0〜1 に正規化したパラメータ
  • 曲線補間のための内部変数

しかし、アプリ側で扱いたいのは別の概念だ。

  • この列車はどの区間にいるのか
  • その区間の中で、どこまで進んだのか
  • 折り返しなのか、ループなのか

t という名前は、そのどれも表していない。

t が招くデバッグ地獄

単一列車・単一区間なら、t でもなんとかなる。

だが、次の瞬間に破綻する。

  • 複数区間(curveIndex)が導入される
  • 折り返しが入る
  • 停車時間が入る

するとログはこうなる。

console.log(t); // 0.0032

で、これを見て何がわかるのか。

  • どの区間の話?
  • 進行中?戻り?
  • 停車前?停車後?

何もわからない。

しかもエラーは出ない。 動いているように見えるから、余計に厄介だ。

数学的には正しいが、実装的に最悪

t が悪い理由は「間違っている」からではない。

  • 値域は正しい
  • 演算も正しい
  • 結果も正しい

それでも最悪なのは、

この値が「何を意味するのか」をコードが語っていない

から。

実装で重要なのは、

  • 正しい計算 よりも、
  • 正しい意味付け

ここを取り違えると、 後から追加したロジックが全部バグになる。

progress という名前にした理由

そこで、t を完全に捨てた。

代わりに使ったのが progress

train.progress += train.speed * train.direction * dt;

この1行だけで、意味が一気に変わる。

  • progress = この区間の中での進行度
  • 値域は 0〜1
  • 境界を超えたら区間が切り替わる
if (train.progress > 1) {
  train.curveIndex++;
  train.progress = 0;
}

ここで起きていることが、 コードから直接読み取れるようになる。

抽象化は「言葉」で決まる

progress に変えたことで、得られたのはこれ。

  • ログを見ただけで状況がわかる
  • 折り返し・停車・ループが自然に書ける
  • デバッグ時に思考が止まらない

これは Three.js の知識とは無関係。

名前をどう付けたかだけの話だ。

「APIに合わせる」のをやめる

重要なのはここ。

  • Three.js の API は内部表現
  • アプリ側は意味表現

APIの変数名をそのままアプリ設計に持ち込むと、 思考が API に引きずられる。

t は Three.js の言葉。 progress はアプリの言葉。

この一線を引けた瞬間、 実装は一気に書きやすくなった。


この変更は地味だが、 後続のすべての設計を支える土台になっている。

ここを曖昧にしたまま進むと、 どんなにコードを書いても、必ず途中で迷子になる。

③ メッシュと状態を分離する理由

― Object3Dを「列車そのもの」にしない ―

Three.jsで何かを動かし始めると、最初は自然にこうなる。

  • 列車 = THREE.Object3D
  • 位置も速度も向きも、全部 mesh に入れる
  • 更新も mesh.position を直接触る

小規模なデモなら、これは正しい。 だが、列車システムとして考え始めた瞬間、この設計は限界を迎える。

Object3Dに状態を詰めると何が起きるか

Object3D は本来、

  • 描画のための構造
  • 座標変換と階層管理のための器

であって、「列車の状態」を表すものではない。

それにも関わらず、ここに状態を詰め込むとこうなる。

  • mesh.speed
  • mesh.direction
  • mesh.wait
  • mesh.routeId

一見便利だが、致命的な問題がある。

  • Three.js由来のプロパティと混ざる
  • ロジックと描画が密結合になる
  • JSONやAIから操作しづらくなる

さらに悪いのは、 「何が Three.js の都合で、何がアプリの都合か」が見えなくなること。

状態は「世界のルール」、メッシュは「見た目」

そこで、考え方を切り替えた。

列車とは 状態(state) + 見た目(mesh) の組み合わせである

実装上はこうなる。

{
  mesh,        // 見た目(Three.js)
  curveIndex,  // どの区間にいるか
  progress,    // 区間内の進行度
  speed,       // 速度
  direction,   // 進行方向
  wait,        // 停車状態
  routeId      // 路線
}

updateTrain(train) が触るのは基本的に状態だけ。

  • 位置計算 → 状態から導く
  • 回転 → 状態から導く
  • 描画反映 → 最後に mesh に反映

この一方向の流れが重要。

JSON駆動にするための必須条件

JSONから列車を定義したい場合、

{
  "trainSpeed": 0.03,
  "trainRouteId": "freightLine",
  "trainWait": 15.0
}

これを直接 Object3D に流し込むのは無理がある。

  • JSONは「データ」
  • Object3Dは「描画構造」

間に状態オブジェクトがないと、

  • 初期化処理が肥大化する
  • 後から設定を差し替えられない
  • 実行中に意味が変わる

状態を分離したことで、

  • JSON → state
  • state → updateロジック
  • updateロジック → mesh

という一直線の流れができた。

将来のAI・信号制御への布石

この設計が効いてくるのは、ここから先。

例えば、

  • 前方に列車がいたら減速
  • 信号が赤なら停車
  • ダイヤに従って発車

これらはすべて 状態遷移の話。

AIや信号は、

  • mesh を見る必要はない
  • Three.js を知る必要もない

必要なのは、

  • 列車の現在状態
  • 路線情報
  • 他列車の状態

だけ。

状態と描画を分離していれば、

  • ロジックは純粋なJSで書ける
  • テストも可能
  • 将来Three.js以外に移植する余地も残る

「動く」から「運行する」へ

Object3Dに状態を詰める設計は、

  • 動かすことが目的なら最短
  • システムにするなら最悪

今回やったのは、

  • 列車を動かす ではなく、
  • 列車を運行する

ための下地作り。

ここを分けた瞬間、 列車は「モデル」ではなく「存在」になった。

④ 曲線ベース走行の実装

― waypoint を捨てて「曲線をデータ構造として扱う」 ―

列車を走らせる実装で、まず思いつくのは waypoint 方式だと思う。

  • 点を配列で並べる
  • 次の点へ向かって移動する
  • 到達したら次の点へ

キャラクター移動ではよく使われるし、 小規模なら実際に動く。

ただし、鉄道として考えた瞬間に破綻する。

なぜ waypoint 配列ではダメか

waypoint 方式の問題は、すべて「点」しか持たないことにある。

  • 曲がり角は角になる
  • 速度を上げるとカクつく
  • 向き補正がフレーム依存になる

何より致命的なのは、

「進行方向」を定義できない

という点。

列車は、

  • 常にレールの接線方向を向く
  • 進行方向が連続的に変化する

waypoint は「次の点へのベクトル」しか持たないため、 数学的に不連続になる。

結果として、

  • lookAt が暴れる
  • 回転が突然反転する
  • 速度を変えると挙動が変わる

という事故が起きる。

曲線を「区間」として扱う

そこで採用したのが、曲線ベースの走行。

  • レール1区間 = 1本の曲線
  • 列車はその曲線上を 0〜1 で移動する

実装上はこうなる。

curveIndex   // どの曲線区間か
progress     // その区間の進行度(0〜1)

この2つだけで、列車の位置は一意に決まる。

const pos = curve.getPointAt(progress);

ここで重要なのは、

  • 座標を「保持しない」
  • 必要なときに「計算する」

という設計。

curveIndex + progress の強さ

この組み合わせが強い理由は明確。

  • 区間を跨ぐ処理が書きやすい
  • 折り返し・ループが自然に書ける
  • 停車中でも位置は不変

例えば区間の終端処理。

if (progress > 1) {
  curveIndex++;
  progress = 0;
}

戻る場合も同様。

if (progress < 0) {
  curveIndex--;
  progress = 1;
}

「次の区間に入った」という事実が、 コードの構造そのものに表れる。

Bezier を「描画用」ではなく「データ」として使う

Bezier 曲線というと、

  • 見た目を滑らかにするもの
  • エディタでいじるもの

という印象が強い。

だが今回の使い方は違う。

Bezier を 移動のためのデータ構造として使う

Bezier 曲線が持っているのは、

  • 任意位置の座標
  • その位置での接線ベクトル
  • 曲線長という概念

列車に必要なものが、最初から全部入っている。

curve.getPointAt(progress);
curve.getTangentAt(progress);

waypoint 方式では自前で計算していたものが、 すべて数学的に保証された形で手に入る。

曲線は「世界のルール」になる

この方式にすると、

  • 列車は曲線に従うだけ
  • 列車側はレールの形を知らない
  • レールを変えれば挙動が変わる

つまり、

  • 挙動はデータで決まる
  • ロジックは一切変えなくていい

これはゲーム実装として非常に強い。

数学が前に出てこない設計

面白いのは、 この実装では数学を意識する場面がほとんどないこと。

  • ベクトル計算は Three.js 任せ
  • 正規化も内部処理
  • こちらは「意味」だけを書く

数学を前面に出さず、 数学が裏で支えている設計になっている。


曲線ベース走行は、

  • 見た目を良くするため ではなく、
  • 設計を安定させるため

に選んだ。

この段階で、 列車は「点の集合」ではなく レールという連続体の上を走る存在になった。

⑤ 回転処理を全面的に作り直した話

― lookAt を信用するな ―

Three.jsで「オブジェクトを進行方向に向ける」と聞くと、 真っ先に出てくるのがこれ。

mesh.lookAt(target);
mesh.rotateY(offset);

実際、最初は動く。 静止状態なら、ほぼ問題ない。

ただし―― 動き出した瞬間に破綻する。

lookAt + rotate が壊れる理由

lookAt() は便利そうに見えて、実態はかなり危険。

理由はシンプルで、

  • lookAt は 絶対回転
  • rotateY は 相対回転

この2つを混ぜると、

  • フレームごとに回転が積み重なる
  • 向きが突然反転する
  • NaN や ±π 境界でスナップする

特に列車のように、

  • 常に回転し続ける
  • 曲線上を移動する
  • 折り返しがある

という条件では、ほぼ確実に壊れる。

実際に起きた症状はこれ。

  • 突然横向きになる
  • 180度反転する
  • 回転補正を入れた瞬間に消える
  • 数フレーム後にワープしたように見える

しかもエラーは出ない。 ログも静か。

一番タチが悪いタイプのバグ。

「進行方向」は曲線が知っている

そこで発想を切り替えた。

列車がどこを向くかは、 モデルでも lookAt でもなく、 レールが決めるべき

曲線には、すでに答えがある。

const tan = curve.getTangentAt(progress);

これはその地点での接線ベクトル。

  • 正規化済み
  • 連続
  • 曲線と完全に一致

これ以上信頼できる「進行方向」は存在しない。

tangent から Y軸回転を直接求める

次にやるのは、 このベクトルを Y軸回転角に変換すること。

const angle = Math.atan2(tan.x, tan.z);

これで、

  • Z前方基準
  • 左右も正しく判定
  • π境界も安定

という、完全に制御された回転角が手に入る。

折り返し時はこれだけ。

if (train.direction < 0) {
  tan.negate();
}

余計なフラグも、条件分岐もいらない。

回転は「上書き」する

ここが一番重要。

mesh.rotation.set(0, angle, 0);
  • 加算しない
  • rotate しない
  • 前フレームを信用しない

毎フレーム、正解を再計算して上書きする。

これだけで、

  • ドリフトしない
  • フレーム依存しない
  • 再現性が保証される

回転が「状態」ではなく 関数の結果になる。

モデルの向きがバラバラ問題

ここで次の現実にぶつかる。

  • Aのモデルは前向き
  • Bのモデルは横向き
  • Cのモデルは後ろ向き

Blenderで直すのが理想だが、 現実はそう簡単じゃない。

そこでやったのがこれ。

train.mesh.rotation.set(
  0,
  angle + train.rotation,
  0
);

train.rotation は、

  • モデル固有の向き補正
  • JSONで定義
  • ラジアン値
"rotation": 1.5

これで、

  • ロジックは完全に共通
  • モデル差分はデータで吸収
  • 実行中の条件分岐ゼロ

lookAt を捨てた結果

この方式に変えてから起きなくなったこと。

  • 突然の反転
  • フレーム依存の挙動
  • 角度が NaN になる事故
  • 回転補正で列車が消える現象

逆に得られたもの。

  • 数学的に一意な回転
  • 再現性のある挙動
  • JSONだけで調整可能な構造

⑥ route / loop / 停車制御

了解。⑥いく。


⑥ route / loop / 停車制御

― 「線路」ではなく「運行」を作り始めた瞬間 ―

ここで実装の性質が、はっきり変わった。

それまでやっていたのは 「曲線の上を動く物体」だった。

この段階からやっているのは 「列車の運行ロジック」。

同じ Three.js のコードでも、意味がまったく違う。


折り返しと環状線は“性質が違う”

最初はこう思いがち。

折り返しもループも、端でどう処理するかの違いでしょ?

違う。 概念が違う。

  • 折り返し線:終点という「状態」が存在する
  • 環状線:終点が存在しない

だから分岐条件を if 文で書き始めると、必ず破綻する。

ここでやったのが、

routes: {
  mainLine: {
    id: 'mainLine',
    curves: [],
    loop: false,
  },
  freightLine: {
    id: 'freightLine',
    curves: [],
    loop: true,
  },
}

線路の性質を「データ」に押し出す。

if 文で判断するのはロジックじゃない。 状態だ。


routeId を列車が持つ意味

列車側はこうなった。

{
  routeId: 'mainLine',
  curveIndex: 0,
  progress: 0,
}

これで、

  • 同じ線路データを共有しない
  • 列車ごとに走る世界が違う
  • 貨物線と本線を完全に分離できる

TRAIN.railCurves を捨てた理由がここ。

単一配列は「全部同じ世界」を前提にする route は「列車ごとに世界を切り替える」

この差は大きい。


折り返しロジックは「境界条件」ではない

進行処理の核心はここ。

if (train.progress > 1) {
  train.curveIndex++;

  if (train.curveIndex >= curves.length) {
    if (route.loop) {
      train.curveIndex = 0;
      train.progress = 0;
    } else {
      train.curveIndex = curves.length - 1;
      train.direction = -1;
      train.progress = 1;
      train.wait = train.trainWait;
    }
  } else {
    train.progress = 0;
  }
}

ここでやっているのは、

  • 数値の補正 ではなく
  • 状態遷移

特に重要なのはこれ。

train.wait = train.trainWait;

折り返し=即反転、ではない。 終点に到着した、という状態を作っている。


「駅」はオブジェクトじゃなかった

この実装で気づいたことがある。

駅を mesh で作る必要、実はない

駅とは何か。

  • 停車する
  • 待つ
  • 再出発する

全部 時間と状態。

だから駅は、

  • 特別なモデル
  • 特別な判定

じゃなくて、

wait > 0

この1行で成立する。

if (train.wait > 0) {
  train.wait -= dt;
  return;
}

ここで列車は、

  • 動かない
  • 回転もしない
  • ただ「そこにいる」

これを入れた瞬間、 世界が「動いている」から「運行している」に変わった。


wait を入れた瞬間に変わったこと

正直、ここが一番デカい。

  • 列車が“急に生き物っぽく”なった
  • 動きに間が生まれた
  • プレイヤーが「待てる」ようになった

技術的にはたったこれだけ。

wait
trainWait
dt

でも意味的には、

  • 一本道のアニメーション → 時間を持つ存在
  • 単なる移動 → スケジュール

に変わっている。


ゲームロジックとしての分水嶺

この段階で、実装は完全にこうなった。

  • 曲線:空間データ
  • route:運行ルール
  • train:状態機械
  • wait:時間制御

Three.js の話はほぼ終わっている。

ここから先は、

  • 信号
  • ダイヤ
  • 優先通行
  • AI制御

全部、この構造の上に自然に乗る。


この章の本質

  • 折り返しとループは if 文の違いじゃない
  • route は「線路」じゃなく「世界」
  • 駅はオブジェクトではなく状態
  • wait はただの遅延ではない

ここで初めて、列車は“運行”になった。

次に来るのは、

  • 信号
  • 衝突回避
  • 優先度

つまり 列車同士の関係性。