[JavaScript] Three.jsで線路を引いて列車を走らせた(Curve×cloneでレール生成)

はじめに

バトル実装が最低限ですが終了して、更に作りこむか、それとも別の実装をするか悩んでた際、何となく

「街と街を繋ぐ駅を作って鉄道を作れないか?」

AIと壁打ちして、今の自分のスキルなら実装できそうだったのでやってみました。

最初、駅を作り、次に直線の線路を引いて、その後、曲線を作成し、完成後、列車が線路の上を走る所まで実装してみたのでそのメモです。

完成後、列車の走行シーン。

動画(パソコン)

動画(VR)

1. 何を作ろうとしたか

やりたかったのは、とても単純で、

  • three.jsで線路を引く
  • その線路の上を列車が走る

それだけ。

ただし前提として、物理エンジンや衝突判定は一切使わないことにした。 車輪が回るかどうかも、連結器がどうなるかも今回は関係ない。

目的はあくまで、

  • 「線路」という道を定義し
  • その道に沿って
  • オブジェクトが自然に動く

という データ駆動の表現を three.js でやってみること。


線路を「モデルの集合」として扱うのではなく、

  • 線路 = Curve(数学的な一本の道)
  • レールモデル = 見た目を補うために並べるもの

という役割分担にしたかった。

列車も同様で、

  • 列車が線路に当たって走る、ではなく
  • 線路(Curve)が列車の位置と向きを決める

という構造を試した。


ゲームっぽい挙動やリアルなシミュレーションを目指したわけではない。 three.jsを描画エンジンとして割り切って使ったとき、どこまで気持ちよく作れるかを確認するための実験だった。

結果的に、 「線路を引く」「曲げる」「分岐させる」「その上を走らせる」 という一連の流れを、かなりシンプルなコードで実現できるところまで辿り着いた。

2. 線路生成の方針

線路生成で最初に決めたのは、全部を一気にやらないことだった。

いきなり複雑な線形や分岐を扱うと、 「どこが壊れているのか分からない状態」になりやすい。

そこで方針をかなり割り切った。


直線と曲線を分けて考える

  • 完全な直線区間
  • ゆるく曲がる曲線区間

この2つを別々のCurveとして作り、 あとから「つなげる」だけにした。

曲げたいからといって、最初から全区間をカーブにしない。 まずは直線で確実に繋がることを優先した。


カーブは最小構成にする

カーブには QuadraticBezierCurve3 を使った。

理由は単純で、

  • 制御点が1つだけ
  • 意図が分かりやすい
  • パラメータが暴れにくい

から。

const curve = createRailCurve(start, end, {
  curveStrength: 0.4,
  height: 0,
});
  • startend が基本の進行方向
  • 中間制御点を横にずらして曲率を作る
  • 数学的に理解しやすい形を維持する

高さ(y)は一旦捨てる

最初は高さも含めて立体的にやろうとしたが、 これはすぐに破綻した。

  • 横から見ると繋ぎ目が目立つ
  • カーブの途中で浮いて見える
  • レールモデルの段差が強調される

原因は、y方向の変化が見た目に非常に敏感なこと。

そこで、

  • 高さを含む表現は後回し
  • まずは「平面上で綺麗につながる」ことに集中

という判断をした。


結果として、

  • 曲線はシンプル
  • 直線は安定
  • 問題が起きたときに原因が特定しやすい

という状態を作れた。

線路生成は、 見た目よりも構造を先に固めるのが正解だった。

3. レールは「見た目」、Curveは「本体」

この実装で一番はっきり分かったのは、 レールと線路は同一じゃないということだった。


レールMeshはただの「装飾」

レールモデルは、やっていることとしては本当に単純で、

  • Curve 上の点をサンプリングして
  • そこに Mesh を clone して並べる

それだけ。

const p1 = curve.getPointAt(t1);
const p2 = curve.getPointAt(t2);

rail.position.copy(p1);
rail.lookAt(p2);

見た目は「線路」だけど、 このMesh自体には何の意味も持たせていない。

当たり判定もしないし、 この上に乗っているかどうかも見ていない。


本体はあくまで Curve

本当に大事なのは、 QuadraticBezierCurve3 や直線で作った Curveそのもの。

  • 列車の位置は curve.getPointAt(t)
  • 列車の向きは curve.getTangentAt(t)
  • 走行ロジックは Curve に完全依存

という構造にした。

つまり、

  • レールMeshが多少ズレていようが
  • 密度を変えようが
  • 別モデルに差し替えようが

列車の挙動は一切変わらない。


Curveを「保存する」発想が重要

最初は、線路を描画したらそれで終わり、という感覚だった。 でも列車を走らせる段階で気づいた。

  • 描いたCurveを捨ててしまうと、走らせようがない
  • 走行用には「どの道を走るか」という情報が必要

そこで、

  • レールを配置したときに
  • そのCurveを配列に保存する

という形にした。

railCurves.push(curve);

これだけで、

  • 列車はどの順番で走るか
  • 分岐をどう扱うか
  • ループさせるか

といった話が、すべてデータの問題になる。


分岐は「描画」と「走行」を分ける

線路には分岐があるが、 今回すべてを走行対象にする必要はなかった。

  • 地上ルート:走行用 Curve として保存
  • 空中ルート:描画のみ(走行しない)

このように、

  • 見た目としては存在する
  • でも列車は走らない

という扱いが簡単にできる。

分岐を無理にロジックで処理するのではなく、 どのCurveを使うかを選ぶだけにしたのは正解だった。


この構造にしたことで、

  • 線路を引く作業
  • 列車を走らせる作業

が完全に分離できた。

three.jsでは、 見た目とロジックを切り離したほうが圧倒的に楽だと実感した。

4. 列車を走らせる

列車を走らせる処理自体は、驚くほど単純だった。

やっていることは、

  • Curve 上の位置を取得する
  • Curve の接線方向を取得する
  • その方向に列車を向ける

これだけ。


Curve.getPointAt / getTangentAt

three.js の Curve には、すでに必要な API が全部揃っている。

const pos = curve.getPointAt(t);
const tan = curve.getTangentAt(t);
  • t は 0〜1 の正規化された値
  • getPointAt(t) で現在位置
  • getTangentAt(t) で進行方向

物理計算も、当たり判定も不要。 位置と向きが同時に手に入る。


向きは lookAt だけでいい

列車の向きは、

train.lookAt(pos.clone().add(tan));

これだけで決まる。

  • 接線ベクトルを少し先に足す
  • その点を見るように回転させる

結果として、

  • カーブでも自然に向きが変わる
  • レールに沿って進んでいるように見える

回転行列やクォータニオンを直接触る必要はなかった。


1両編成なら破綻しない

途中で気づいた重要なポイントがこれ。

  • 長い車両を1つのMeshとして動かすと
  • カーブで必ず「後ろが脱輪して見える」

これはバグではなく、 1点拘束で長い剛体を曲線に乗せている以上、必ず起きる現象。

そこで割り切って、

  • 短い車両
  • 1両編成

にしたところ、見た目の破綻は一気に消えた。


今回の目的は「正確な鉄道シミュレーション」ではない。 Curve 上をオブジェクトが気持ちよく動くことが最優先だった。

その意味では、

  • 1両編成
  • Curve 追従
  • lookAt による向き制御

という構成は、十分すぎるほど成功だった。

5. ハマりどころ

実装自体はシンプルだったが、実際に手を動かすと何度かきれいにハマった。 どれも three.js あるあるだと思う。


GLTFの参照先ミス(mesh vs scene)

一番最初に詰まったのがこれ。

GLTFを読み込んだあと、

  • gltf
  • gltf.scene
  • gltf.scene.children[0]

のどれを「列車」として扱うのかを間違えると、普通に落ちる。

今回も、

train.position.copy(pos);

でエラーが出て、原因を追ったら position を持たないオブジェクトを train に代入していた。

最終的には、

train = config.model.collider
  .filter(c => c.mesh.name === 'steam_train')[0]
  .mesh;

のように、確実に Mesh を指す形にして解決した。

three.js では「見えているもの」と「操作すべきオブジェクト」が 必ずしも同じではない、というのを再確認した。


長い車両は曲線で必ず破綻する

最初は蒸気機関車+炭水車のような 長い1両モデルをそのまま走らせようとした。

結果は予想どおりで、

  • 先頭は線路に乗っている
  • 後ろが外側にズレて「脱輪している」ように見える

これは実装ミスではなく、

  • Curve 上の 1点で
  • 長い剛体を制御している

以上、必ず起きる現象だった。

対策としては、

  • 短い車両にする
  • 1両編成にする
  • もしくは車両を分割して別々に Curve に乗せる

今回はスケールダウンして 1両編成にすることで解決した。


高さを入れると繋ぎ目が目立つ

線路に高さ(y方向)を混ぜ始めた瞬間、見た目が一気に崩れた。

  • 横から見ると段差が目立つ
  • カーブと直線の接続部が折れて見える
  • 少しのズレでも強調される

原因は単純で、

  • 曲率の変化
  • 高さの変化

を 同じ区間でやろうとしたこと。

結果的に、

  • カーブは水平
  • 高さ変化は直線のみ

というルールに落ち着いた。

今回は思い切って高さを封印し、 まずは平面で綺麗につながることを優先した。


これらのハマりどころは、 コードを読んでいるだけではなかなか分からない。

実際に「動かして」「眺めて」「壊して」みて、 初めて納得できる類のものだった。

6. 結果

最終的にできたものは、派手な機能は何もない。

  • 線路を引き
  • その上を
  • 列車が走る

ただそれだけ。


それでも、

  • 線路(Curve)に沿って
  • 向きが自然に変わり
  • 破綻せずに進む

という最低限の「鉄道らしさ」は、ちゃんと成立した。


線路の上を列車がちゃんと走った

物理も衝突も使っていない。 当たり判定で線路をなぞっているわけでもない。

Curve がすべてを決めている。

  • 位置
  • 向き
  • 進行方向

この割り切りのおかげで、 挙動は最後まで安定した。


1両なら見た目も安定

長い車両を無理に動かそうとせず、

  • 短いモデル
  • 1両編成

に切り替えたのは正解だった。

Curve 上の 1点拘束という前提では、 これが一番破綻しない。

リアルさよりも、 「見た目が納得できること」を優先した結果でもある。


three.jsは「描画エンジン」として使うのが一番気持ちいい

今回一番強く感じたのはこれ。

three.js は、

  • 物理を頑張る場所でも
  • 厳密なシミュレーションをする場所でもなく

数学で決めた結果を、気持ちよく描画するためのエンジンだと思った。

Curve を中心に据えて、

  • データが挙動を決め
  • Mesh はそれを可視化するだけ

という構成にすると、 実装もデバッグも驚くほど楽になる。


とりあえず今回はここまで。 午後から触り始めて、 「線路を引いて列車を走らせる」ところまで辿り着けた。

十分すぎる成果だと思う。