はじめに
今日は、昨日の「Three.jsで線路を引いて列車を走らせた」の続き。
[JavaScript] Three.jsで線路を引いて列車を走らせた(Curve×cloneでレール生成)
Three.jsで線路モデルをCurve(QuadraticBezierCurve3)から生成し、配置したレールの上を列車モデルが走るところまで実装した記録。分岐・繋ぎ目・高さ(y)による破綻と、その回避策(height=0、走行用Curve保存、1両編成)を …
https://humanxai.info/posts/javascript-threejs-rail-curve-train/といっても、昨日勢いで書いたコードをリファクタリングしたのがメインで、客観的に目に見える実装があまり進まなかったですが、複数の列車の走行とルート、折り返し、その他、列車の運行するバックエンド的なシステム作成がメインで、実装内容をいつも通り、アウトプットで、後で読み返しできるように記事にまとめてみます。
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.speedmesh.directionmesh.waitmesh.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 はただの遅延ではない
ここで初めて、列車は“運行”になった。
次に来るのは、
- 信号
- 衝突回避
- 優先度
つまり 列車同士の関係性。
💬 コメント