[JavaScript] Three.jsでS字レールをつなぐ:クロソイド曲線で2本で分岐カーブを作る

はじめに

ここ数日で、複数の列車を同時に走らせ、複数の運行ルートを経由して走行する下地の実装をしました。

街がある拠点に駅舎を作成して正面に線路を引き、列車を走らせる実装は終わっているので、 今回は駅舎の裏に貨物列車用の新たな線路を引き、途中で線路を合流させて、上空へ上る空中ルートの線路に接続。

イメージとしてはこんな感じ。

それにあたり、S字のクロソイド曲線を作る必要があり分解すると、

直線から右に緩やかに曲がる曲線を2つの直線の中央まで作成
途中から逆方向の曲線を作成して隣の線路に接続

という2つの曲線を作成して、接続すれば実現可能な事は何となく分かります。

ただ、頭で考えるのと実装には乖離があり、先日作成したコードでは実現できなかったので 新たな関数を作成して対応したので、実装内容を後で読み返して理解できるように記事としてまとめておきます。

数学・物理演算は難しいですが、ひも解いてみると面白いですし、リアルの鉄道も綺麗な線路の曲線を同じような数式で実現してるようで、 今回、物理演算を使って実装しなければ背後でどんな数式が使われているか気づくことがなかったと思います。

日常の中に沢山の数学が隠れているという1つの例ですね…。

動画(パソコン)

1. なぜS字レールを作ろうとしたか

three.jsで街や線路を作り始めた当初、レールは「直線」か「単純なカーブ」だけで十分だと思っていた。 始点と終点を決めて、Bezierでつなげば、それっぽい線路は描ける。

ところが、実装を進めていくと必ず出てくる場面がある。

  • 平行に走る2本のレールをつなぎたい
  • 分岐や合流を、見た目も挙動も破綻せずに作りたい
  • 直線 → カーブ → 直線、ではなく 直線 → 緩やかに曲がる → 逆方向に曲がる → 別の直線 という接続が必要になる

いわゆる S字カーブ だ。

鉄道シミュレーションや街づくりゲームでは当たり前のように使われているし、 マウスでドラッグしてドロップすれば、何事もなかったかのようにつながる。

でも、いざ自分で実装しようとすると、急に違和感が出てくる。

  • Bezierでつないだら直線になった
  • 曲がったと思ったら、接続部で「折れ」が見える
  • レールの向きがガタガタになる
  • 見た目はつながっているのに、走行させると不自然

「S字でつなぐだけ」が、まったく簡単ではない。

ここでふと頭に浮かんだのが、リアルの鉄道レールだった。

街を歩いていて何気なく見る線路は、 どれも驚くほど滑らかで、どこにも無理がない。 S字であっても、曲線の始まりも終わりも自然で、 「どうやってつないでいるのか」を普段は考えもしない。

しかし、自分で実装しようとした瞬間、

あの綺麗な曲線は、偶然じゃない 人間が感覚で置いているわけでもない

という事実に気づかされる。

この「当たり前に見えていたものが、突然難題になる感覚」が、 S字レールをちゃんと作ろうと思った理由だった。

単に見た目をそれっぽくするためではなく、

  • なぜ破綻するのか
  • どこをどう分解すれば成立するのか
  • 市販のシミュレーションゲームは、裏で何をしているのか

それを理解したかった。

結果として、このS字レールの実装は、 three.jsのコードを書く作業であると同時に、 現実の鉄道設計や曲線理論に視点がつながる入口になった。

次の章では、最初に試した「素朴な実装」と、 なぜそれがうまくいかなかったのかを書いていく。

2. 最初の素朴な Bezier 実装

S字レールを作ろうとしたとき、最初に思いつくのはとても単純な方法だった。

  • 始点 start
  • 終点 end
  • その間を Bezier 曲線でつなぐ

three.js には THREE.QuadraticBezierCurve3 が用意されているし、 制御点を少し横にずらせば、それっぽく曲がるはずだ、と。

実際、直線レールや単純なカーブは、この方法で問題なく作れていた。

function createRailCurve(start, end, options = {}) {
  const { curveStrength = 0.5, height = 0 } = options;

  const dir = end.clone().sub(start);
  const length = dir.length();
  const forward = dir.clone().normalize();

  const right = new THREE.Vector3()
    .crossVectors(new THREE.Vector3(0, 1, 0), forward)
    .normalize();

  const control = start.clone().addScaledVector(forward, length * 0.5);
  control.addScaledVector(right, length * curveStrength);
  control.y += height;

  return new THREE.QuadraticBezierCurve3(start, control, end);
}

この関数で、

  • curveStrength = 0 → 完全な直線
  • curveStrength > 0 → 右に膨らむカーブ
  • curveStrength < 0 → 左に膨らむカーブ

という具合に、カーブの向きと強さを制御できる。

ここまでは順調だった。


「じゃあ S字もいけるのでは?」

次に考えたのは、単純な発想だ。

  • 直線区間の終点から
  • 別の直線区間の始点まで
  • Bezier でつなげばいい

S字だから、少し大きめに curveStrength を与えれば、 自然に右→左へ曲がってくれるだろう、と。

ところが、実際にやってみると、すぐに違和感が出た。

  • 思ったほど曲がらない
  • 曲げたつもりなのに、ほぼ直線に見える
  • 無理に曲げると、接続部分で不自然な折れが出る

特に印象的だったのは、

「なぜか直線になる」

という現象だった。

制御点を計算しているのに、 画面上ではレールがほとんど真っ直ぐに並んでしまう。


なぜ失敗に見えたのか

この時点では、

  • three.js の使い方が間違っているのか
  • Bezier 曲線の理解が足りないのか
  • パラメータ調整が下手なだけなのか

そう思っていた。

でも実際には、 この「素朴な実装」は間違っていなかった。

問題は、

  • S字カーブというものを
  • 「1本の Bezier 曲線で表現しようとした」

そこにあった。

この段階ではまだ、

  • 曲率が途中でどう変化しているか
  • 接線がどこでどう向いているか

といったことを、ほとんど意識していなかった。

次の章では、 「なぜ直線になったのか」「なぜ破綻して見えたのか」を、 実装と挙動を元に掘り下げていく。

3. なぜ直線になったのか

「Bezier で制御点を計算しているのに、なぜ直線になるのか?」

これは実装して一番混乱したポイントだった。 見た目はちゃんと計算しているし、curveStrength も与えている。 それなのに、画面上のレールはほぼ一直線に並ぶ。

原因は three.js のバグでも、数値の桁落ちでもない。 Bezier 曲線の性質そのものだった。


Quadratic Bezier が直線になる条件

THREE.QuadraticBezierCurve3 は、

  • 始点 start
  • 制御点 control
  • 終点 end

この3点で曲線を定義する。

そして重要な事実がある。

この3点が同一直線上に並ぶと、Bezier 曲線は必ず直線になる

これは three.js 固有の挙動ではなく、 Bezier 曲線の数学的な定義そのもの。

どれだけ複雑な計算式を書いても、

start ---- control ---- end

この配置になった瞬間、曲線は消える。


自分の実装で何が起きていたか

S字を作ろうとして書いたコードでは、 「つなぎ目で接線をそろえよう」と考えて、

  • 始点 A
  • 中点 M
  • 終点 B

の方向を基準にして制御点を配置していた。

ところがそのとき、

  • 進行方向(A → B)
  • 制御点のオフセット方向

を同じベクトルで計算してしまっていた。

結果として、

A → C1 → M → C2 → B

が、すべて同一直線上に並ぶ。

計算は正しい。 three.js も正しい。 だからこそ、結果も「正しく直線」になる。


「接線をそろえる」と「曲げる」は別の話

ここで一度、勘違いに気づかされた。

  • 接線をそろえる → 滑らかにつながる
  • 曲げる → 横方向への自由度が必要

この2つは同時には成立しない。

S字カーブを作るには、

  • 進行方向(forward)
  • それに直交する横方向(right)

この 2つの軸 が必須になる。

進行方向だけで制御点を動かすと、 どれだけ頑張っても直線にしかならない。


「直線になった」のは失敗ではない

重要なのは、

直線になったのは「間違い」ではなく「必然」

だったという点。

この挙動を通して、

  • Bezier 曲線は魔法ではない
  • 曲がりは「方向の自由度」から生まれる
  • S字は 1 本の曲線では表現できない

ということを、体感として理解できた。

この気づきがなければ、 次の「分解して考える」という発想には進めなかったと思う。

次の章では、 この問題をどう分解し、 S字を2本の曲線として組み立てる発想に至ったかを書いていく。

4. 「中点+逆曲率」という発想

直線になってしまう理由が分かったあと、次にぶつかったのは、

「じゃあ、S字ってどう作るんだ?」

という問いだった。

1本の Bezier では無理。 では、何が足りないのか。

ここで視点を切り替える必要があった。


S字を「1本の曲線」として考えるのをやめる

最初は無意識に、

  • S字 = 1つの特殊な曲線
  • それを表す式がどこかにある

と考えていた。

でも、冷静に見直すと、S字はこう分解できる。

  • 最初は 右に曲がる
  • 途中で向きが切り替わる
  • 次は 左に曲がる

つまり、

曲率の符号が途中で反転している

という構造をしている。

これを1本の Quadratic Bezier に押し込めようとするのが、そもそも無理だった。


「中点」を作るという発想

そこで考えたのが、

  • 始点 A
  • 終点 B

のあいだに、明示的な中点 M を置くことだった。

A ——→ (右カーブ) ——→ M ——→ (左カーブ) ——→ B

こう考えると、

  • A → M は「右に曲がるカーブ」
  • M → B は「左に曲がるカーブ」

として、2本の曲線に分解できる。

S字は「特殊な1本」ではなく、 性質の違う2本の曲線の組み合わせだと捉え直した。


逆曲率を意識する

重要なのは、中点 M を境にして、

  • 最初のカーブは +曲率
  • 次のカーブは −曲率

になること。

実装上は、

  • 制御点を進行方向に直交するベクトル(right)でオフセットする
  • 1本目と2本目で、その符号を反転させる

という形で表現できる。

1本目:control = +right
2本目:control = -right

これが 「逆曲率」。

S字らしさは、ここで初めて生まれる。


なぜ「中点+逆曲率」でうまくいくのか

この構造には、実装上のメリットがいくつもある。

  • 1本あたりの曲がりを緩くできる
  • 制御点の意味が直感的
  • 破綻したら、どちらの曲線が原因か切り分けできる
  • 調整が「全体」ではなく「局所」で済む

特に大きかったのは、

調整できる感覚が一気に現実的になった

ことだった。

1本の Bezier をこね回しているときは、 少し数値を変えるだけで全体が壊れる。

しかし、2本に分けると、

  • まず A → M を自然にする
  • 次に M → B を自然にする

という手順で考えられる。


「設計っぽさ」が出てきた瞬間

この発想に切り替えたあたりから、

  • 数式を探す
  • 正解を当てにいく

という感覚が薄れ、

  • 条件を満たす構造を作る
  • 破綻しない範囲を探す

という 設計寄りの思考 に変わっていった。

S字レールは、 「計算で一発で出すもの」ではなく、 分解して組み立てるものだった。

次の章では、 この発想を実際のコードに落とし込み、 どうやって three.js 上で S字を成立させたかを書いていく。

5. 実装コードと調整地獄

「中点+逆曲率」という発想にたどり着いても、 それで一気に完成、というわけではなかった。

むしろここからが本番だった。


S字を作るための最小構成

最終的に落ち着いた構成は、次のようなものだ。

  • 始点 A
  • 終点 B
  • 明示的な中点 M
  • A → M を結ぶ Quadratic Bezier
  • M → B を結ぶ Quadratic Bezier
  • 制御点は進行方向に直交する方向へオフセット
  • 2本目では符号を反転(逆曲率)

コードにすると、以下のようになる。

function createSCurve2Quadratics(A, B, opts = {}) {
  const {
    midT = 0.5,
    midX = null,
    curveStrength = 0.05,
    y = A.y,
  } = opts;

  // 中点 M
  const M = new THREE.Vector3(
    midX ?? (A.x + B.x) * 0.5,
    y,
    THREE.MathUtils.lerp(A.z, B.z, midT)
  );

  // 進行方向
  const forward = B.clone().sub(A);
  forward.y = 0;
  const length = forward.length();
  forward.normalize();

  // 横方向(右ベクトル)
  const right = new THREE.Vector3()
    .crossVectors(new THREE.Vector3(0, 1, 0), forward)
    .normalize();

  // 1本目:右にふくらむ
  const C1 = A.clone()
    .addScaledVector(forward, length * 0.25)
    .addScaledVector(right, length * curveStrength);

  // 2本目:左にふくらむ
  const C2 = M.clone()
    .addScaledVector(forward, length * 0.25)
    .addScaledVector(right, -length * curveStrength);

  const curve1 = new THREE.QuadraticBezierCurve3(
    A.clone().setY(y),
    C1,
    M
  );

  const curve2 = new THREE.QuadraticBezierCurve3(
    M,
    C2,
    B.clone().setY(y)
  );

  return [curve1, curve2];
}

見た目はそれほど複雑ではない。 だが、ここからが本当に大変だった。


調整地獄の正体

S字が成立するかどうかは、 ほぼすべてパラメータ調整にかかっている。

特に影響が大きかったのは次の点。

  • curveStrength

    • 小さすぎると、ほぼ直線
    • 大きすぎると、不自然な蛇行
  • midX

    • 平行な2本のレールの中間に置くか
    • 少しずらすかで、見た目が激変
  • 始点・終点の微妙なズレ

    • 数十センチ単位の違いで破綻する
  • レールモデルの向き

    • 曲線が合っていても、向きがズレると一気に違和感が出る

数値を少し動かすだけで、

  • 「あ、直線に戻った」
  • 「ここで折れて見える」
  • 「さっきまで良かったのに壊れた」

ということが頻繁に起きる。


正解は「存在しない」

この作業で強く感じたのは、

正解の数値は存在しない

ということだった。

  • 地形が変われば数値も変わる
  • レール間隔が変われば最適解も変わる
  • モデルの大きさが変われば全部やり直し

つまり、

S字レールは計算で解く問題ではなく、探索する問題

だった。


それでも成立する瞬間がある

調整を続けていると、ある瞬間、

  • 折れが消える
  • 曲がり始めと曲がり終わりが自然になる
  • レールが「流れる」ように見える

ポイントが突然現れる。

その瞬間、

「あ、これだ」

と分かる。

数式的に説明できなくても、 視覚と感覚が一致する瞬間が確かに存在した。


市販シミュレーションへの見方が変わった

この調整地獄を経験してから、

  • 鉄道シミュレーション
  • 街づくりゲーム
  • マウスでレールを引くだけのUI

を見る目が完全に変わった。

「ああ、裏でこれを毎フレームやってるんだな」 「失敗パターンを全部潰してるんだな」

そう思えるようになった。

次の章では、 この実装を通して気づいた リアルの鉄道レールとの共通点について書いていく。

6. ふと気づいたリアル鉄道との共通点

S字レールの調整を何度も繰り返しているうちに、 ある瞬間、頭の中で実装と現実がつながった。

リアルの鉄道レールって、いつもこんな曲がり方をしていないか?

という感覚だ。


街で見るレールが、急に「異常に綺麗」に見えた

普段、街を歩いていて線路を見ることはあっても、

  • どうやって曲げているか
  • なぜあの形なのか

を考えることはほとんどなかった。

ところが、S字カーブを自分で実装し始めてから、 リアルの線路を見ると違和感が出てくる。

  • 曲がり始めが極端に滑らか
  • 途中で「急に曲がった」感じが一切ない
  • S字でも、切り替わりが分からない
  • どこにも折れや歪みがない

three.js で少しでも雑に実装すると即破綻するのに、 現実のレールは完璧すぎるほど自然だ。


「見た目が綺麗」では説明できない

ここで気づいたのは、

あれは見た目を整えているだけじゃない

ということだった。

もし見た目だけの問題なら、

  • 多少いびつでもいい
  • 多少ガタついてもいい

はずだ。

でも、現実の鉄道ではそうなっていない。

なぜなら、

  • 車輪とレールは常に接触している
  • 重量は何十トン
  • 速度も一定ではない

少しでも曲率が急変すると、

  • 横方向の力が一気に変わる
  • 乗り心地が悪化する
  • 摩耗や破損につながる

つまり、鉄道の曲線は 見た目ではなく力学で決まっている。


自分の実装で起きていた「違和感」と同じもの

three.js でレールを走らせたとき、

  • 見た目はつながっているのに違和感がある
  • 少しガクッとする
  • 曲がり始めが唐突に感じる

そういう瞬間があった。

それはまさに、

曲率が途中で不連続になっている

状態だった。

リアルの鉄道は、 その「不連続」を徹底的に排除している。


世界中どこでも同じ曲線を使っている理由

さらに不思議なのは、

  • 日本の鉄道
  • ヨーロッパの鉄道
  • アメリカの鉄道

どこを見ても、 曲線の雰囲気が驚くほど似ていることだ。

文化や美意識の問題ではない。

  • 人間の身体
  • 鋼鉄の性質
  • 重力
  • 摩擦

これらが世界中で同じだから、 設計も同じ数学に収束する。


「S字」は特別な形ではなかった

ここでようやく腑に落ちた。

S字カーブは、

  • 特殊なデザイン
  • 見た目を良くするための技巧

ではない。

直線と直線を、無理なく接続するための必然的な形

だった。

自分が three.js で苦労して作っていた S字は、 現実世界ではすでに 100年以上前から体系化されていた解だった。

次の章では、 その「解」の正体である クロソイド曲線について触れていく。

7. クロソイド曲線の存在

リアルの鉄道と実装上の違和感が重なったところで、 ようやく名前のある存在に行き当たった。

クロソイド曲線(Clothoid / Euler spiral)。


「曲がり具合が、少しずつ変わる」曲線

クロソイドの最大の特徴は、形そのものではない。

曲率が、距離に比例して連続的に変化する

という性質にある。

直線では曲率は 0。 円弧では曲率は一定。

クロソイドはその間を埋める。

  • 直線 → 少しだけ曲がる
  • 少し曲がる → もう少し曲がる
  • 徐々に円弧へ近づく

そして逆に、

  • 円弧 → 徐々に曲がりが弱まる
  • 最後は自然に直線へ戻る

この「徐々に」という部分が、 three.js の実装で一番苦労したところと完全に一致していた。


なぜ Bezier だけでは苦しかったのか

自分の実装では、

  • Bezier を 2 本に分ける
  • 中点を置く
  • 逆曲率にする

という方法で、見た目上は S 字を成立させた。

ただし、正直に言えば、

  • 曲率が本当に滑らかにつながっているか
  • 力学的に自然か

までは保証できていない。

それでも「それっぽく」見えたのは、

クロソイドを手作業で近似していた

からだった。

Bezier を何本もつなぐ方法は、 クロソイドの厳密解ではないが、 近似としては非常に現実的。

実際、ゲームやシミュレーションでも クロソイドを厳密計算するケースは少ない。


なぜクロソイドが鉄道で使われるのか

理由は単純で、しかも重い。

  • 曲率が急変しない
  • 横方向の加速度が滑らか
  • 車輪とレールへの負担が減る
  • 乗客が「ガクッ」と感じない

これは美しさの話ではない。

安全性と耐久性の話

だ。

だから、

  • 鉄道
  • 高速道路
  • ジェットコースター
  • 自動車専用道路

といった「速度と質量を扱う構造物」では、 必ずクロソイドが登場する。


世界中で同じ曲線になる理由

クロソイドは文化ではない。

  • 人間の身体構造
  • 重力
  • 摩擦
  • 材料強度

これらが同じなら、 導かれる曲線も同じになる。

だから、

世界中どこでも、レールの曲がり方が似ている

という現象が起きる。

それはデザインの模倣ではなく、 物理法則への降伏に近い。


three.js 実装との距離感

ここで一つ安心したことがある。

自分がやっていた実装は、間違っていなかった

厳密なクロソイドを実装していなくても、

  • 曲線を分割し
  • 曲率を反転させ
  • 無理のない接続を探す

という方向性は、 現実の鉄道設計と同じ方向を向いていた。

three.js でクロソイドを正確に実装するのは、 正直かなり大変だ。

でも、

「なぜ必要なのか」を理解しているかどうか

ここが一番重要だった。

次の章では、 今回の実装を通して感じた 市販シミュレーションゲームの裏側についてまとめていく。

8. 「ゲームの裏でこれをやってる人たち」への視線の変化

8. 「ゲームの裏でこれをやってる人たち」への視線の変化

S字レールの実装とクロソイド曲線の話が頭の中でつながったとき、 街づくりシミュレーションや鉄道シミュレーションを見る視線が、 完全に変わってしまった。


「簡単につながるUI」の正体

市販のシミュレーションゲームでは、

  • レールをドラッグする
  • カーソルを離す
  • 自然につながる

これが当たり前の体験として提供されている。

でも今は分かる。

あの裏側では、 失敗しうるすべてのケースを潰す作業が走っている

単に曲線を1本引いているわけではない。

  • 曲率が急すぎないか
  • 逆曲率が不自然になっていないか
  • 地形と干渉していないか
  • 既存のレールと交差していないか
  • 最小半径を下回っていないか

これらを、毎フレーム評価しながら 「OK な形」だけをユーザーに見せている。


複雑なのは数式よりも「条件」

実装前は、

裏でとんでもなく複雑な数式を解いているのでは?

と思っていた。

実際は少し違う。

  • 数式はそこまで特殊ではない
  • Bezier や spline の組み合わせ
  • 近似で十分な場面が多い

本当に大変なのは、

破綻しない条件を列挙し、それを全部満たすこと

だった。

数式の難しさより、 地獄のような条件分岐と例外処理。


「うまくいかない形」を最初から見せない

ユーザーが見るのは、

  • 成功したレール
  • 自然につながった曲線

だけ。

その裏で、

  • 少しでも危ない形
  • 見た目が破綻する形
  • 力学的に怪しい形

は、すべて弾かれている。

つまり、

プレイヤーは「成功例」しか見ていない

自分で実装してみて初めて、 この設計思想の重さが分かる。


「作れる人間」がどんな人かも見えてきた

ここまで考えると、

これを作っているのは、どんな人たちなんだ?

という疑問が湧く。

答えは意外とシンプルだ。

  • 数学が得意なだけの人
  • プログラムが書けるだけの人

ではない。

  • 壊れるパターンを全部経験している
  • 「ここがダメ」という違和感を言語化できる
  • 正解がなくても探索を続けられる

今回、自分が味わった

  • 直線になる
  • 折れる
  • 変になる
  • 調整してもダメ

この一連の体験を、 何十倍・何百倍も積み上げた人たち。


見えない仕事への尊敬

S字レールを1つ作っただけで、 これだけの試行錯誤が必要だった。

それを、

  • 数百本
  • 数千本
  • リアルタイム

で扱える形にしている。

しかもユーザーには 「難しさ」を一切感じさせない。

今では、

レールが綺麗につながる → 当たり前

とは、もう思えない。

そこには、

見えない設計と、見えない失敗の山

がある。


視線が変わると、世界の密度が変わる

この実装を通して一番大きかったのは、

  • 技術を学んだ
  • three.js を理解した

こと以上に、

世界の見え方が一段変わった

という感覚だった。

線路1本。 カーブ1つ。

その裏に、

  • 工学
  • 数学
  • 試行錯誤
  • 人の時間

が詰まっていると分かると、 何気ない景色やゲームの画面が、 急に情報量の多いものに見えてくる。

これで、この一連の実装と考察は一区切りになる。

S字レールを作った話は、 three.js の小さな実装メモでありながら、 現実世界とつながる入口でもあった。