はじめに
バトル実装が最低限ですが終了して、更に作りこむか、それとも別の実装をするか悩んでた際、何となく
「街と街を繋ぐ駅を作って鉄道を作れないか?」
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,
});
start→endが基本の進行方向- 中間制御点を横にずらして曲率を作る
- 数学的に理解しやすい形を維持する
高さ(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を読み込んだあと、
gltfgltf.scenegltf.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 はそれを可視化するだけ
という構成にすると、 実装もデバッグも驚くほど楽になる。
とりあえず今回はここまで。 午後から触り始めて、 「線路を引いて列車を走らせる」ところまで辿り着けた。
十分すぎる成果だと思う。
💬 コメント