ジェットコースターのループが円でないのは、6 Gを避けるため

高さ24 mの円のループに時速89 kmで入ると、座席が体を押す力は一瞬で体重の6倍に跳ね上がる。曲がりを少しずつきつくするクロソイドの形なら、同じ高さで4倍あまりに収まる。

記事

ジェットコースターのループが円でないのは、6 Gを避けるため

高さ24 mの円のループに時速89 kmで入ると、座席が体を押す力は一瞬で体重の6倍に跳ね上がる。曲がりを少しずつきつくするクロソイドの形なら、同じ高さで4倍あまりに収まる。

作成日 2026年11月4日・更新日 2026年11月4日・執筆・監修 佐々木 航

現象

円のループは、入口で体重を6倍にする

ジェットコースターの縦のループを横から眺めると、頂上がとがり、下のほうが大きくふくらんだ形をしていることが多い。教科書の問題によく出てくる、きれいな円ではない。

なぜ円ではいけないのか。計算してみると、すぐに分かる。円のループを、頂上で体がちょうど浮くくらいの遅さで回ったとしても、底では座席が体を体重の6倍の力で押す。スウェーデンのルンド大学で物理教育を研究するアンマリー・ペンドリルは、この6 Gは規格で許される大きさだが、近年のコースターの多くは5 Gより下に収めていると書いている1。ただ、彼女は大きさだけでなく、変わり方の急なことも問題に挙げている。ゆるい曲がりから円に入った瞬間、腰から下は向きを変え始めるのに、頭はまっすぐ進もうとする。むち打ちが起きやすい形である。

画面のコースは、高さ34.5 mの巻き上げから落ちて、高さ24 mのループを回る。左下のグラフは先頭の座席に掛かるGで、太線が上下、細線が左右、破線が前後。レールの下に垂れた幕は、上下のGが止まっているときの1 Gよりどれだけ重いか(点の幕)、軽いか(黒い幕)を長さで表している。四角の取っ手を上下にドラッグすると支柱の高さが、地面の点線の輪をドラッグすると支柱の場所が変わり、幕もすぐに描き直される。

仕組み

座席のGは、向きを曲げる分と重さを支える分の和

椅子に座っているだけで、座席は体を下から押している。押す力が体重と同じなら1 G、2倍なら2 G。Gは座席が体を押す力を体重で割った数で、滑り台で体に掛かるGと同じ決め方である。

ところが、コースターでは押す力の向きまで変わる。座席が体を押す力を体の質量で割ったものを(m/s²)とすると、体の加速度と、下向きの重力加速度(9.8 m/s²)とのあいだに、次の関係がある。

止まっていればは0で、は真上を向いた9.8 m/s²、つまり1 Gだ。シミュレーションはを座席から見た上下・左右・前後に分け、それぞれをで割って3つのGにした。上下のGが負になると、体は座席から浮いて安全バーに支えられる。

ループの底と頂上では何が起きているのか。速さ(m/s)で半径(m)の円を回るには、円の中心へ向かうの加速度が要る。底では中心が頭の上にあるので、座席は重さを支える分に、この分を足して押す。頂上では体は逆さまで、中心は頭の側だ。重力が中心へ引く分を肩代わりするので、座席が押す力はそのぶん小さい。

では、円のループではどうなるか。頂上は底よりだけ高い。摩擦を無視すると、底の速さの2乗は頂上よりだけ大きくなる。これを2つの式に入れると、半径も速さも消えてしまう。

つまり、頂上で体が浮かないようにを0以上にすれば、底は必ず6 G以上。円の大きさにも、入る速さにもよらない。

仕組み

曲がりをだんだんきつくすれば、Gは跳ねない

円の弱点をなくすには、底では曲がりの半径を大きくしてGを抑え、頂上では半径を小さくして、遅くても体が座席に押し付けられるようにすればよい。ただ、半径の違う円をそのままつなぐと、つなぎ目でGが段になって跳ねる。

そこで使われるのがクロソイドという曲線だ。走った距離(m)に比例して、曲がりの大きさ(1/m、半径の逆数)が増えていく。

(m)は曲線の大きさを決める定数である。まっすぐなレールから入っても、曲がりは0から増え始める。だから、Gも1 Gからなめらかに上がり、段にはならない。技術者のヴェルナー・シュテンゲルが、半径の違う部分をクロソイドの一部でつなぐループを取り入れた1。

シミュレーションに使ったクロソイドのループは、レールの向きが25°変わるまでに曲がりが頂上の0.4倍まで増え、150°で頂上と同じ大きさになって、頂上の円弧につながる。後ろ半分は前半分を折り返した形だ。高さは円と同じ24 mで、頂上の半径は8.2 m、円の12 mより小さい。

同じ高さ24 mのループ(縮尺は同じ) 円(半径12 m) 底6.2 G 頂上0.1 G 24 m 頂上0.7 G(半径8.2 m) 最大4.3 G クロソイドをつないだ形 入口 出口 走った距離ごとの曲がりの大きさ(1/半径、1/m) 0 25 50 75 100 ループの入口からの距離(m) 1/12 1/8.2 円は入口から1/12 クロソイドは走った距離に比例して増える
高さ24 mの円とクロソイドのループ。円は入口で曲がりが一気に大きくなり、クロソイドは走った距離に比例して増える
仕組み

高さが速さを決め、傾きが横のGを消す

列車の速さは、ほとんど高さで決まる。摩擦が無ければ、高さ(m)を落ちたときの速さは次の式になり、60 m落ちると時速120 kmを超える。

もっとも、実際には車輪の転がり抵抗と空気の抵抗が、速さを少しずつ削っていく。シミュレーションでは、転がり抵抗をレールが車輪を押す力に転がり抵抗係数を掛けた大きさとし、空気の抵抗は速さの2乗に比例させた。円のループのコースでは、巻き上げの頂上から駅に戻るまでに、転がり抵抗と空気の抵抗がそれぞれ高さ11〜12 m分のエネルギーを奪う。両方を合わせると約20 m分で、34.5 mの巻き上げの6割近くにあたる。だから、後ろの丘ほど低くしておかないと、列車は登りきれずに戻ってくる。

列車が長いことも効いてくる。5両は一列につながって同じ速さで動くので、最後尾が頂上を越えるころには、列車の大部分がもう下り坂にいて速くなっている。だから最後尾は速いまま頂上を越え、強く浮き上がるのだ。逆さまになるループの頂上では反対に、速く通る人ほど座席へ押し付けられる。ペンドリルは、スウェーデンの遊園地リセベリのコースター「カノーネン」のループで、いちばん遅く頂上を通る真ん中の人と、先頭や最後尾の人とでは、座席が押す力が体重の0.5倍ほど違うと見積もっている2。

水平なカーブでも、向きを曲げるは要る。傾けないレールでは横の壁がそれを受け持つので、体は外側へ押し付けられる。座席をカーブの内側へ角度だけ傾け、座席が押す力の向きと座席の上をそろえると、左右のGは0になる。

進む向きから見た座席の断面(カーブの中心は左) v²/Rの分 gの分 座席が押す力 傾けないと押す力が横を向き、左右2.3 G 体は外側(右)の壁へ押し付けられる 座席が押す力 φ 傾ける角度はtan φ = v²/(gR)からφ = 67° 左右は0 G、上下は√(1² + 2.3²) = 2.5 G
傾けないカーブでは座席が押す力が横を向き、内側へφだけ傾けると押す力が座席の真上を向く
試す

ループの形と座る車両で、Gを比べる

測った値の欄には、選んだ座席に今掛かっているGと、その走りでいちばん大きいG・小さいGが出る。列車は巻き上げの頂上の少し手前から走り出し、駅に戻るとまた頂上の手前から走り直す。「駅から出発」を押すと、駅から乗れる。

  1. 円のループは、時速89 kmで入ってくる列車を受け止める。グラフの太線は入口で真上に跳ね、上下のGは0.9 Gから6.2 Gへ。4 Gを超えている時間は1.2秒ある。頂上のあたりでは時速41 kmまで落ちて0〜0.1 Gになり、体はほとんど浮く。ループのいちばん上にある取っ手を下げて高さを16 mほどにすると、底は9 G近く、頂上は2.7 Gほどになる。小さい円ほど急に曲がるので、Gはかえって大きくなった。それでも、底と頂上の差は6 Gのままである。
  2. クロソイドのループは、高さも入る速さも円と同じ。それでもGは入口の1 Gからなめらかに上がり、高さ9 mのあたりで最大の4.3 G。4 Gを超えるのは0.5秒だけだ。頂上は半径が小さいので、時速42 kmでも0.7 Gで座席に押し付けられる。
  3. 高い落下では、高さ62 mの頂上から谷まで59 m落ちて時速118 km。谷の底は3.8 G。次の高さ28.5 mの丘を越えるあいだは、上下のGが0を下回って最小で−0.44 Gになり、体が浮く。転がり抵抗係数を0にして空気の抵抗を切ると、谷の速さは時速123 kmになり、高さの差と巻き上げの速さだけから計算した値にほぼ一致する。抵抗を切ったまま最後まで走らせると、途中のブレーキでは速さを落としきれず、駅の手前の最後のカーブを時速約100 kmで回って7 Gを超える。抵抗は速さを奪うだけでなく、コースの後半の速さを抑える役も担っている。
  4. 傾けないカーブでは、谷を抜けた列車が時速110 kmで、上から見て半径40 mほどの曲がりに入る。左右のGは2.3 G。カーブの傾きを「左右のGを消す角度」にすると、レールは最大で67°まで内側へ傾き、左右のGは0.2 Gほどに下がる。代わりに上下のGが増え、カーブの中で最大3.6 Gになる。
  5. 登りきれないコースでは、転がり抵抗係数を0.030と大きくしてある。列車は高さ24.5 mの2つ目の丘を、頂上まであと1.3 mの所で登りきれずに戻り、谷で止まる。係数を0.020まで下げると越える。係数はそのままでも、丘のてっぺんにある取っ手を1.5 m下げれば越えられる。
  6. 先頭の座席からは、クロソイドのループが目の前で立ち上がり、頂上では地面が頭の上に来る。座る車両を最後尾に変えると、巻き上げの頂上から落ち始める所で、上下のGが0.10 Gまで下がる(先頭は0.43 G)。前の車両が先に下り坂へ入って列車全体を速めるので、最後尾は速いまま頂上を越えるからだ。
現実では

1976年のループと、日本の決まり

鋼管のレールで縦のループを回る、今の形のコースターは「レボリューション」から始まったとされる。1976年5月8日、米国カリフォルニア州の遊園地シックス・フラッグス・マジック・マウンテンで開業した。米国のコースター愛好家団体ACEは、このループがクロソイドの考え方で作られ、行き過ぎたGを抑えて縦のループを快適にしたと紹介している。同じ記事は、ループを45フィート(約14 m)、Gを最大4.9 Gとしている3。

高さが速さを決める様子は、日本のコースターの公表値でも確かめられる。富士急ハイランドの「FUJIYAMA」は1996年に登場した宙返りのないコースターで、最大落差70 m、最高速度は時速130 km4。70 mを摩擦なしで落ちると時速133 kmになる計算で、公表の値に近い。

大きなGに人がどこまで耐えられるかは、続く時間しだいだ。NASAの技術資料によると、頭から足の向きに3〜4 Gが続くと3〜4秒で視野が暗くなり始め、4〜6 Gでは5秒ほどで見えなくなる5。ループの大きなGは、長くても1〜2秒で終わる。体への負担を考えるなら、大きさとあわせて、どれだけ急に変わるかを見ておく必要がありそうだ。

日本の遊園地の乗り物は、建築基準法の「遊戯施設」として、国土交通省が定めた告示に従う。告示は、0.2秒以上続く上下と前後の加速度の組み合わせを3つの領域に分け、領域ごとにシートベルトなどの身体保持装置に求める性能を決めている。前後の加速度が小さいとき、上下の加速度が2 m/s²(約0.2 G)より小さい所は領域三に入り、体格に合わせて固定する位置を調整できる身体保持装置が要る。上下の加速度が下向き、つまり体が座席から浮く向きになると、領域三を超える。領域三を超える乗り物は、建築基準法に基づく性能評価を受け、国土交通大臣の認定を得なければならない67。横の加速度にも、3 m/s²、5 m/s²、12 m/s²の区切りがある。シミュレーションの傾けないカーブの2.3 G(約23 m/s²)は、最後の区切りを大きく超える。

省いていること

シミュレーションが省いていること

  • 乗る人は、レールの中心線の上の1点として計算している。実際には頭がレールより1 mほど高く、列車がひねられるときは頭だけが大きく振られる。車体の揺れや座席のばねも入れていないので、円のループの入口のGは、本物より急に跳ね上がる
  • 転がり抵抗係数は試すための仮の値で、速さによらない一定の値にした。空気の抵抗のは、乗る人20人を含む列車全体で3 m²(列車の重さ4,500 kg)という見積もりで、風は無い
  • カーブの傾きは、先回りで求めた速さのときに左右のGが0になる角度を、前後4 mずつならして決めた
  • 巻き上げは毎秒4 mで引く。ブレーキは駅の手前と、高い落下のコースで最後に曲がる手前に置き、どちらも一定の強さで掛かる。コースの形は、どれも本物のコースターを写したものではない
  • 表示するGは刻みごとの値で、告示のように0.2秒以上続くものに限って数えてはいない
参考文献

参考文献

  1. Pendrill, A.-M.「Roller coaster loop shapes revisited」『Physics Education』51(3), 030106, IOP Publishing, 2016. https://doi.org/10.1088/0031-9120/51/3/030106 著者版 https://lup.lub.lu.se/search/publication/4fec4974-030d-4b28-8f50-be487802dbc2
  2. Pendrill, A.-M.「Student investigations of the forces in a roller coaster loop」『European Journal of Physics』34(6), 1379–1389, IOP Publishing, 2013. https://doi.org/10.1088/0143-0807/34/6/1379
  3. Perry, D.「The Great American Revolution at 50 Years」American Coaster Enthusiasts, 2026. https://www.ridewithace.com/blogs/ace-news/2026/05/30/ithe-great-american-revolutioni-at-50-years
  4. 富士急ハイランド「FUJIYAMA」. https://www.fujiq.jp/attraction/fujiyama.html
  5. NASA Office of the Chief Health & Medical Officer「Occupant Protection」NASA-STD-3001 Technical Brief OCHMO-TB-024 Rev D, 2025. https://www.nasa.gov/wp-content/uploads/2023/12/ochmo-tb-024-acceleration.pdf
  6. 国土交通省「遊戯施設の客席部分の構造方法を定める件(平成29年国土交通省告示第247号)」2017. https://www.mlit.go.jp/common/001178586.pdf
  7. 国土交通省住宅局建築指導課長「遊戯施設の客席部分の構造方法を定める件等の改正について(技術的助言)」2017. https://www.mlit.go.jp/common/001178585.pdf
  8. Pendrill, A.-M.「Rollercoaster loop shapes」『Physics Education』40(6), 517–521, IOP Publishing, 2005. https://doi.org/10.1088/0031-9120/40/6/001

注

  1. 参考文献1。6 Gと5 G、むち打ち、シュテンゲルのクロソイドのループ。ループの形の数式と乗り心地は、参考文献8にまとめられている。 ↩ ↩2

  2. 参考文献2。カノーネンのループでは、列車の長さが頂上の円弧の中心角で約90°にあたる。先頭や最後尾の人が頂上で座席から0.3倍の力で押されているとき、真ん中の人は肩の安全バーから0.2倍の力で押される、という例が示されている。 ↩

  3. 参考文献3。マジック・マウンテンでの開業日、鋼管のレールで初めての近代的なループのコースターであること、ループの大きさ(45フィート)、最大のG。 ↩

  4. 参考文献4。最高部79 m、巻き上げの高さ71.5 m、コースの長さ2,045 m。時速133 kmは m/sを時速に直した値で、頂上での速さと摩擦・空気の抵抗は入れていない。 ↩

  5. 参考文献5の「Effects of Sustained +Az Acceleration」の表。3〜4 Gは29.4〜39.2 m/s²、4〜6 Gは39.2〜58.8 m/s²の範囲として書かれている。 ↩

  6. 参考文献6の第一と別図。別図は前後の加速度を横軸、上下の加速度を縦軸にとり、止まっているときの上下の加速度を9.8 m/s²として、領域を描いている。 ↩

  7. 参考文献7。領域三を超える場合の性能評価と大臣認定、横の加速度の区分。 ↩

プログラム
プログラム

このシミュレーションのプログラム

このシミュレーションは、取っ手の点とループの式からレールの形を作り、0.25 mごとに位置・向き・曲がり・座席の向きを表にしておく。列車は1/120秒ごとに、5両それぞれの場所の重力と転がり抵抗、空気の抵抗から速さを進め、選んだ座席のGを求める。立体・光・影・視点を描くのはthree.js(WebGL2の3Dの部品集)で、記事の計算からは、描くための点の座標を渡すだけである。

長さはm、時間は秒

1 = 1 mで作っているので、重力加速度9.80665 m/s²をそのまま式に使える。列車の重さとは空気の抵抗にだけ効き、重力と転がり抵抗の加速度には質量が出てこない。

定数と単位(src/sims/roller-coaster.ts の 22〜39 行)
const G0 = 9.80665; // 重力加速度 g(m/s²)const RHO = 1.2; // 空気の密度 ρ(kg/m³)const MASS = 4500; // 列車の重さ(乗る人を含む)(kg)const CDA = 3; // 列車全体の C_d·A(抵抗係数 × 前から見た面積)(m²)const MU0 = 0.012; // 転がり抵抗係数の初めの値(試すための仮の値)const NCAR = 5, GAP = 2.6; // 車両の数と、隣どうしの車両の中心の距離(m)const OFF = [2, 1, 0, -1, -2].map((k) => k * GAP); // 先頭から最後尾まで、各車両の列車の中心からの距離(m)const SEATS: Record<string, number> = { front: 0, mid: 2, back: 4 }; // 「座る車両」のつまみの値と、車両の番号const DS = 0.25; // レールの形を調べておく間隔(m)const H = 1 / 120; // 計算の刻み(秒)const V_CHAIN = 4, V_STATION = 3, V_TRIM = 9, BRAKE = 4; // 巻き上げの速さ・駅の中の速さ・途中のブレーキで落とす速さ(m/s)・ブレーキの減速(m/s²)const TRIM_LEN = 45; // 途中のブレーキの長さ(m)const Y0 = 1.2; // 駅とループの入口のレールの高さ(m)const LEAD = 10; // 駅とループの出入り口の前後に置く、まっすぐな部分の長さ(m)const ST_A = -100, ST_B = 10, ST_PLAT = -24; // ブレーキの区間の入口・駅の出口・列車が止まる所の x(m)。x の向きにまっすぐconst NMAX = 8000; // レールの点の数の上限(2 km 分)const FIN = 0.8; // G を表す幕の長さ(1 G との差 1 G あたり m)const KMH = 3.6, DEG = 180 / Math.PI;

OFFは列車の中心から各車両までの距離で、先頭が+5.2 m、最後尾が−5.2 m。「座る車両」のつまみは、このどれを使うかを選んでいる。

点を通る曲線と、ループの式

コースは「駅とブレーキのまっすぐな区間 → 曲線 → ループ → 曲線 → 駅」の順につなぐ。曲線は取っ手の点を通る3次スプラインで、媒介変数には点と点の直線距離の和を使う。端の1階微分を与えて両端の向きを決め、駅やループとの境で向きがそろうようにした。各点の2階微分は、3重対角の連立方程式を前から消して後ろから代入する方法(トーマス法)で解く。

ループは式で作る。曲がりを[向きの角, 曲がり]の折れ線で決め、区間の中では距離に比例して変える。すると向きの角は距離の2次式になり、位置はとをシンプソンの公式で足し合わせて表にできる。円は曲がりが最初から最後まで一定の場合だ。出口が入口とぶつからないように、横へのずれを5次式でなめらかに足している。

コースの形(src/sims/roller-coaster.ts の 62〜201 行)
// ループの形(頂上の半径を 1 とした大きさ)。[レールの向きの角(度), 曲がり] の折れ線で、曲がりは区間の中で走った距離に比例して変わる。// 円は最初から最後まで曲がりが 1。クロソイドは、入口の 0 から 25° で 0.4 へ、150° で頂上の 1 へと、だんだんきつくなるconst LOOP_KNOTS: Record<Kind, [number, number][]> = {  circle: [[0, 1], [180, 1]],  clothoid: [[0, 0], [25, 0.4], [150, 1], [180, 1]],};const units: Partial<Record<Kind, Unit>> = {};const TK = { th: 0, k: 0 };/** ループの中の距離 u での、レールの向きの角 θ と曲がり κ(区間の中で κ は距離に比例するので、θ は距離の 2 次式) */function thk(U: Unit, u: number) {  const S = U.s; let i = 0;  while (i < S.length - 2 && u > S[i + 1]) i++;  const ds = S[i + 1] - S[i], w = u - S[i], ka = U.k[i], kb = U.k[i + 1];  TK.k = ds > 0 ? ka + ((kb - ka) * w) / ds : ka;  TK.th = U.th[i] + ka * w + (ds > 0 ? ((kb - ka) * w * w) / (2 * ds) : 0);}/** 大きさ 1 のループを作る: 曲がりの折れ線から距離ごとの θ を求め、cos θ と sin θ を足し合わせて形(X, Y)にする */function unitLoop(kind: Kind): Unit {  const done = units[kind]; if (done) return done;  const kn = LOOP_KNOTS[kind], s = [0], th = [kn[0][0] / DEG], k = [kn[0][1]];  for (let i = 1; i < kn.length; i++) { const a = kn[i][0] / DEG, b = kn[i][1]; s.push(s[i - 1] + (2 * (a - th[i - 1])) / (k[i - 1] + b)); th.push(a); k.push(b); }  const m = s.length, half = s[m - 1];  for (let i = m - 2; i >= 0; i--) { s.push(2 * half - s[i]); th.push(2 * Math.PI - th[i]); k.push(k[i]); }  const N = 3000, L = 2 * half, du = L / N, X = new Float64Array(N + 1), Y = new Float64Array(N + 1);  const U: Unit = { s, th, k, L, X, Y, du, H: 0 };  for (let i = 0; i < N; i++) {    thk(U, i * du); const a = TK.th; thk(U, (i + 0.5) * du); const b = TK.th; thk(U, (i + 1) * du); const c = TK.th;    X[i + 1] = X[i] + (du * (Math.cos(a) + 4 * Math.cos(b) + Math.cos(c))) / 6; // シンプソンの公式    Y[i + 1] = Y[i] + (du * (Math.sin(a) + 4 * Math.sin(b) + Math.sin(c))) / 6;    U.H = Math.max(U.H, Y[i + 1]);  }  return (units[kind] = U);}/** 大きさ 1 のループの距離 u での位置(表の間を、向きも合わせる 3 次式でつなぐ) */function unitXY(U: Unit, u: number, out: number[]) {  const N = U.X.length - 1, i = Math.min(N - 1, Math.max(0, Math.floor(u / U.du))), f = clamp(u / U.du - i, 0, 1), du = U.du;  thk(U, i * du); const a = TK.th; thk(U, (i + 1) * du); const b = TK.th;  const h00 = 2 * f * f * f - 3 * f * f + 1, h10 = f * f * f - 2 * f * f + f, h01 = -2 * f * f * f + 3 * f * f, h11 = f * f * f - f * f;  out[0] = h00 * U.X[i] + h10 * du * Math.cos(a) + h01 * U.X[i + 1] + h11 * du * Math.cos(b);  out[1] = h00 * U.Y[i] + h10 * du * Math.sin(a) + h01 * U.Y[i + 1] + h11 * du * Math.sin(b);}function makeLoop(L: LoopDef): Pc2 {  const u = unitLoop(L.kind), sc = L.H / u.H;  return { kind: 2, len: u.L * sc, u, sc, E: [L.x, Y0, L.z], side: L.side };}/** 端の向きを決めた 3 次スプライン(1 つの座標の分)。各点の 2 階微分 M を、3 重対角の連立方程式(トーマス法)で解く */function clamped(t: Float64Array, y: Float64Array, da: number, db: number) {  const n = t.length, a = new Float64Array(n), b = new Float64Array(n), c = new Float64Array(n), d = new Float64Array(n), M = new Float64Array(n);  const h = (i: number) => t[i + 1] - t[i];  b[0] = 2 * h(0); c[0] = h(0); d[0] = 6 * ((y[1] - y[0]) / h(0) - da);  for (let i = 1; i < n - 1; i++) { a[i] = h(i - 1); b[i] = 2 * (h(i - 1) + h(i)); c[i] = h(i); d[i] = 6 * ((y[i + 1] - y[i]) / h(i) - (y[i] - y[i - 1]) / h(i - 1)); }  a[n - 1] = h(n - 2); b[n - 1] = 2 * h(n - 2); d[n - 1] = 6 * (db - (y[n - 1] - y[n - 2]) / h(n - 2));  for (let i = 1; i < n; i++) { const w = a[i] / b[i - 1]; b[i] -= w * c[i - 1]; d[i] -= w * d[i - 1]; }  M[n - 1] = d[n - 1] / b[n - 1];  for (let i = n - 2; i >= 0; i--) M[i] = (d[i] - c[i] * M[i + 1]) / b[i];  return M;}/** 点の列を通る曲線。媒介変数は点と点の直線距離の和(どの向きにも同じ速さで進む)。両端の向きは da・db */function spline(pts: V3[], node: number[], da: V3, db: V3): Pc1 {  const n = pts.length, t = new Float64Array(n);  for (let i = 1; i < n; i++) t[i] = t[i - 1] + Math.max(0.5, Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1], pts[i][2] - pts[i - 1][2]));  const P = [0, 1, 2].map((k) => Float64Array.from(pts, (p) => p[k]));  const M = [0, 1, 2].map((k) => clamped(t, P[k], da[k], db[k]));  return { kind: 1, len: t[n - 1], t, P, M, node };}const EV = { p: [0, 0, 0], d1: [0, 0, 0], d2: [0, 0, 0], u: [0, 0, 0], hasU: false };const XY = [0, 0];/** 区間の中の媒介変数 t での位置 p・1 階微分 d1・2 階微分 d2(ループは上の向き u も) */function evalPc(pc: Pc, t: number) {  EV.hasU = false;  if (pc.kind === 0) { EV.p[0] = ST_A + t; EV.p[1] = Y0; EV.p[2] = 0; EV.d1[0] = 1; EV.d1[1] = 0; EV.d1[2] = 0; EV.d2[0] = EV.d2[1] = EV.d2[2] = 0; return; }  if (pc.kind === 1) {    const T = pc.t, n = T.length; t = clamp(t, 0, T[n - 1]);    let lo = 0, hi = n - 2;    while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (T[mid] <= t) lo = mid; else hi = mid - 1; }    const i = lo, h = T[i + 1] - T[i], A = (T[i + 1] - t) / h, B = 1 - A;    for (let k = 0; k < 3; k++) {      const y = pc.P[k], M = pc.M[k];      EV.p[k] = A * y[i] + B * y[i + 1] + ((A * A * A - A) * M[i] + (B * B * B - B) * M[i + 1]) * h * h / 6;      EV.d1[k] = (y[i + 1] - y[i]) / h - ((3 * A * A - 1) / 6) * h * M[i] + ((3 * B * B - 1) / 6) * h * M[i + 1];      EV.d2[k] = A * M[i] + B * M[i + 1];    }    return;  }  // ループ: 縦の平面の中の形(X, Y)に、横へずらす量 d(滑らかな 5 次式)を足す  const u = clamp(t / pc.sc, 0, pc.u.L), x = clamp(t / pc.len, 0, 1);  thk(pc.u, u); const th = TK.th, k = TK.k / pc.sc, c = Math.cos(th), s = Math.sin(th);  unitXY(pc.u, u, XY);  const q = x * x * x * (10 - 15 * x + 6 * x * x), q1 = 30 * x * x * (1 - x) * (1 - x), q2 = 60 * x * (1 - x) * (1 - 2 * x);  EV.p[0] = pc.E[0] + pc.sc * XY[0]; EV.p[1] = pc.E[1] + pc.sc * XY[1]; EV.p[2] = pc.E[2] + pc.side * q;  EV.d1[0] = c; EV.d1[1] = s; EV.d1[2] = (pc.side * q1) / pc.len;  EV.d2[0] = -k * s; EV.d2[1] = k * c; EV.d2[2] = (pc.side * q2) / (pc.len * pc.len);  EV.u[0] = -s; EV.u[1] = c; EV.u[2] = 0; EV.hasU = true;}function loopExit(L: LoopDef): V3 { const pc = makeLoop(L); evalPc(pc, pc.len); return [EV.p[0], Y0, EV.p[2]]; }/** 1 つの点を記録する: 向き T、曲がり κ = (d2 − (d2·T)T)/|d1|²、傾ける前の上 U(真上をレールに直角にしたもの。ほぼ縦のときは前の点の上を引き継ぐ) */function put(c: Course, j: number, kind: number) {  const [x1, y1, z1] = EV.d1, [x2, y2, z2] = EV.d2, l = Math.hypot(x1, y1, z1) || 1, tx = x1 / l, ty = y1 / l, tz = z1 / l, dt = x2 * tx + y2 * ty + z2 * tz;  c.px[j] = EV.p[0]; c.py[j] = EV.p[1]; c.pz[j] = EV.p[2]; c.tx[j] = tx; c.ty[j] = ty; c.tz[j] = tz;  c.kx[j] = (x2 - dt * tx) / (l * l); c.ky[j] = (y2 - dt * ty) / (l * l); c.kz[j] = (z2 - dt * tz) / (l * l);  let ux: number, uy: number, uz: number;  if (EV.hasU) [ux, uy, uz] = EV.u;  else if (1 - ty * ty > 0.09 || j === 0) { ux = -ty * tx; uy = 1 - ty * ty; uz = -ty * tz; }  else { ux = c.ux[j - 1]; uy = c.uy[j - 1]; uz = c.uz[j - 1]; }  const ud = ux * tx + uy * ty + uz * tz; ux -= ud * tx; uy -= ud * ty; uz -= ud * tz;  const ul = Math.hypot(ux, uy, uz) || 1;  c.ux[j] = ux / ul; c.uy[j] = uy / ul; c.uz[j] = uz / ul; c.kind[j] = kind;}/** 設計からレールを作り直す: 駅 → 曲線 → ループ → 曲線 → 駅 の順につなぎ、DS ごとに点を記録する */function shape(c: Course) {  const d = c.d, X: V3 = [1, 0, 0], A0: V3 = [ST_B, Y0, 0], Z0: V3 = [ST_A, Y0, 0];  const pcs: Pc[] = [{ kind: 0, len: ST_B - ST_A }];  // 駅とループの出入り口の前後には、まっすぐな部分(LEAD m)を置く。曲がりが急に変わる所を作らないため  const fwd = (p: V3, k: number): V3 => [p[0] + k * LEAD, p[1], p[2]];  const idx = (a: number, b: number) => [-1, -1, ...Array.from({ length: b - a }, (_, i) => a + i), -1, -1];  if (d.loop) {    const lp = makeLoop(d.loop), E: V3 = [d.loop.x, Y0, d.loop.z], Ex = loopExit(d.loop);    pcs.push(spline([A0, fwd(A0, 1), ...d.nodes.slice(0, d.loopAt), fwd(E, -1), E], idx(0, d.loopAt), X, X), lp,      spline([Ex, fwd(Ex, 1), ...d.nodes.slice(d.loopAt), fwd(Z0, -1), Z0], idx(d.loopAt, d.nodes.length), X, X));  } else pcs.push(spline([A0, fwd(A0, 1), ...d.nodes, fwd(Z0, -1), Z0], idx(0, d.nodes.length), X, X));  let s = 0, k = 0;  c.nodeS = d.nodes.map(() => 0); c.loopS = [0, 0];  for (const pc of pcs) {    if (pc.kind === 2) c.loopS[0] = s;    const steps = Math.max(2, Math.ceil(pc.len / 0.1)), dt = pc.len / steps;    evalPc(pc, 0); let px = EV.p[0], py = EV.p[1], pz = EV.p[2], sp = s, tp = 0;    for (let i = 1; i <= steps; i++) {      const tt = i * dt; evalPc(pc, tt);      const sc = sp + Math.hypot(EV.p[0] - px, EV.p[1] - py, EV.p[2] - pz);      px = EV.p[0]; py = EV.p[1]; pz = EV.p[2];      if (pc.kind === 1) for (let q = 0; q < pc.t.length; q++) if (pc.node[q] >= 0 && pc.t[q] > tp && pc.t[q] <= tt) c.nodeS[pc.node[q]] = sp + ((pc.t[q] - tp) / dt) * (sc - sp);      while (k * DS <= sc && k < NMAX) { evalPc(pc, tp + ((k * DS - sp) / Math.max(1e-9, sc - sp)) * dt); put(c, k, pc.kind); k++; }      sp = sc; tp = tt;    }    s = sp;    if (pc.kind === 2) c.loopS[1] = s;  }  c.n = k; c.len = k * DS;  c.chain0 = ST_B - ST_A; c.chain1 = c.nodeS[d.crest]; c.sPlat = ST_PLAT - ST_A; c.trim0 = d.trim === undefined ? -1 : c.nodeS[d.trim];}

どの区間も、位置と1階微分・2階微分を返す。レールの向きは、曲がりのベクトルはで、向きは曲がる中心の側、大きさが1/半径になる。座席の上の向きは、真上をレールに直角にしたもの。ループでは縦の平面の中で回る向きを使い、ほぼ真上を向くレールでは、1つ前の点の上の向きを引き継ぐ。

座席の向きとG

seatGが本文のを計算する。体の加速度は、レールに沿った加速度と、向きを曲げるの和である。これに真上のを足し、座席の上・右・前との内積をとってで割ると、上下・左右・前後のGになる。

座席に掛かるG(src/sims/roller-coaster.ts の 218〜271 行)
/** 座席が人を押す力 ÷ 質量 f = a T + v² κ + g ŷ を、座席の上 U・右 R・前 T に分けて g で割る */const GG = { z: 1, y: 0, x: 0 };function seatG(c: Course, s: number, v: number, a: number) {  const L = look(c, s, true), v2 = v * v;  const fx = a * L.tx + v2 * L.kx, fy = a * L.ty + v2 * L.ky + G0, fz = a * L.tz + v2 * L.kz;  GG.z = (fx * L.Ux + fy * L.Uy + fz * L.Uz) / G0; // 上下: 正なら座席に押し付けられ、負なら体が浮く  GG.y = (fx * L.Rx + fy * L.Ry + fz * L.Rz) / G0; // 左右: 正なら座席が人を右へ押す(体は左へ寄る)  GG.x = (fx * L.tx + fy * L.ty + fz * L.tz) / G0; // 前後: 正なら背もたれに押し付けられ、負なら前へ投げ出される  return GG;}/** カーブの傾き: 先回りで求めた速さのとき、座席を押す力がちょうど座席の上を向く角度 φ(tan φ = 右向きの分 ÷ 上向きの分)。前後 4 m ずつならす */function bankUp(c: Course, auto: boolean) {  const n = c.n, phi = c.bank, tmp = c.tmp;  for (let j = 0; j < n; j++) {    const v = c.vC[j];    if (!auto || c.kind[j] !== 1 || !Number.isFinite(v)) { phi[j] = 0; continue; }    const tx = c.tx[j], ty = c.ty[j], tz = c.tz[j], ux = c.ux[j], uy = c.uy[j], uz = c.uz[j], v2 = v * v;    const fx = v2 * c.kx[j] - G0 * ty * tx, fy = v2 * c.ky[j] + G0 * (1 - ty * ty), fz = v2 * c.kz[j] - G0 * ty * tz;    const rx = ty * uz - tz * uy, ry = tz * ux - tx * uz, rz = tx * uy - ty * ux, fu = fx * ux + fy * uy + fz * uz;    phi[j] = fu > 0.3 * G0 ? Math.atan2(fx * rx + fy * ry + fz * rz, fu) : 0;  }  const W = 16;  for (let pass = 0; pass < 2; pass++) {    let acc = 0; for (let k = -W; k <= W; k++) acc += phi[((k % n) + n) % n];    for (let j = 0; j < n; j++) { tmp[j] = acc / (2 * W + 1); acc += phi[(j + W + 1) % n] - phi[(((j - W) % n) + n) % n]; }    phi.set(tmp.subarray(0, n));  }  // ループと駅の近く(3 m 以内)では傾きを 0 に戻す  let last = -1e9;  for (let j = 0; j < 2 * n; j++) { const i = j % n; if (c.kind[i] !== 1) last = j; else tmp[i] = j - last; }  last = 1e9;  for (let j = 2 * n - 1; j >= 0; j--) { const i = j % n; if (c.kind[i] !== 1) { last = j; phi[i] = 0; } else if (j < n) phi[i] *= clamp(Math.min(tmp[i], last - j) / 12, 0, 1); }  for (let j = 0; j < n; j++) {    const tx = c.tx[j], ty = c.ty[j], tz = c.tz[j], ux = c.ux[j], uy = c.uy[j], uz = c.uz[j], cs = Math.cos(phi[j]), sn = Math.sin(phi[j]);    const rx = ty * uz - tz * uy, ry = tz * ux - tx * uz, rz = tx * uy - ty * ux;    const Ux = ux * cs + rx * sn, Uy = uy * cs + ry * sn, Uz = uz * cs + rz * sn;    c.Ux[j] = Ux; c.Uy[j] = Uy; c.Uz[j] = Uz;    c.Rx[j] = ty * Uz - tz * Uy; c.Ry[j] = tz * Ux - tx * Uz; c.Rz[j] = tx * Uy - ty * Ux;  }}/** 選んだ座席が、レールの各点を通るときの G(先回りの速さから)。いちばん大きい所・小さい所・左右がいちばん大きい所も */function profile(c: Course, seat: number) {  const n = c.n, sh = Math.round(OFF[seat] / DS), P = c.pred;  P.maxG = -9; P.minG = 9; P.lat = 0; P.iMax = P.iMin = P.iLat = -1;  for (let j = 0; j < n; j++) {    const jc = (((j - sh) % n) + n) % n, v = c.vC[jc];    if (!Number.isFinite(v)) { c.gz[j] = c.gy[j] = c.gx[j] = NaN; continue; }    const g = seatG(c, j * DS, v, c.aC[jc]);    c.gz[j] = g.z; c.gy[j] = g.y; c.gx[j] = g.x;    if (g.z > P.maxG) { P.maxG = g.z; P.iMax = j; }    if (g.z < P.minG) { P.minG = g.z; P.iMin = j; }    if (Math.abs(g.y) > P.lat) { P.lat = Math.abs(g.y); P.iLat = j; }  }}

bankUpはカーブの傾きを決める。先回りで求めた速さでのうちレールに直角な分を計算し、右向きの分と上向きの分の比からを出す。本文のを、坂の途中のカーブにも使える形にしたものだ。上向きの分が0.3 Gより小さい所は傾けない。浮き上がる丘の頂上がこれにあたる。そのうえで前後4 mの平均を2回とってならし、駅とループに近づく最後の3 mで、傾きをだんだん0に戻す。profileは、選んだ座席がレールの各点を通るときのGを、先回りの速さから求める。レールの下の幕と、最大・最小の札はこの結果である。

5両をまとめて進める

stepは、5両の場所それぞれで重力のレールに沿った分と、レールが車輪を押す力を求めて平均する。車両はつながって同じ速さで動くので、列車全体の加速度はこの平均で決まる。最後尾がまだ上っているあいだは列車が遅く、頂上を越えた車両が増えると速くなる。本文の「最後尾ほど浮く」理由は、この平均に入っている。

時間を進める(src/sims/roller-coaster.ts の 273〜317 行)
/** 列車。s は列車の中心のレールの上の距離、v は速さ(後ろへ戻るときは負)。mode 1 走る・2 駅に戻った・3 登りきれずに止めた */interface Run { s: number; v: number; a: number; t: number; lap: number; mode: number; still: number; back: boolean; backT: number; air: boolean; maxG: number; minG: number; gz: number; gy: number; gx: number }const newRun = (): Run => ({ s: 0, v: 0, a: 0, t: 0, lap: 0, mode: 0, still: 0, back: false, backT: 0, air: false, maxG: 1, minG: 1, gz: 1, gy: 0, gx: 0 });/** h 秒進める。各車両の場所で重力の分力と、レールが車輪を押す力を求めて平均する(5 両は同じ速さで動く) */function step(c: Course, r: Run, mu: number, kd: number, h: number) {  const v = r.v, v2 = v * v;  let gs = 0, ns = 0;  for (let k = 0; k < NCAR; k++) {    const L = look(c, r.s + OFF[k], false);    gs -= G0 * L.ty; // 重力の、レールに沿った分(上り坂で負)    // レールが車輪を押す力 ÷ 質量 = 向きを曲げる分 v²κ と、重力のうちレールに直角な分の和    const fx = v2 * L.kx - G0 * L.ty * L.tx, fy = v2 * L.ky + G0 * (1 - L.ty * L.ty), fz = v2 * L.kz - G0 * L.ty * L.tz;    ns += Math.hypot(fx, fy, fz);  }  const ag = gs / NCAR, fr = (mu * ns) / NCAR, dr = kd * v2;  let v1: number;  if (v !== 0) { v1 = v + (ag - Math.sign(v) * (fr + dr)) * h; if (v1 * v < 0) v1 = 0; } // 摩擦だけで向きは反転しない  else v1 = Math.abs(ag) > fr ? (ag - Math.sign(ag) * fr) * h : 0; // 止まっているとき: 重力の分力が転がり抵抗に勝てば動き出す  if (r.lap === 0 && r.s < c.chain0 && v1 < V_STATION) v1 = V_STATION; // 駅から送り出す  else if (r.lap === 0 && r.s <= c.chain1 && !r.back && v1 < V_CHAIN) v1 = V_CHAIN; // 巻き上げのチェーン  if (r.lap === 0 && !r.back && c.trim0 > 0 && r.s > c.trim0 && r.s < c.trim0 + TRIM_LEN && v1 > V_TRIM) v1 = Math.max(V_TRIM, v1 - BRAKE * h); // 途中のブレーキ  if (r.lap === 1 && r.s < c.sPlat) v1 = v1 > V_STATION ? Math.max(V_STATION, v1 - BRAKE * h) : V_STATION; // 駅の手前のブレーキ  r.a = (v1 - v) / h; r.v = v1; r.s += v1 * h; r.t += h;  if (r.s >= c.len) { r.s -= c.len; r.lap = 1; }  if (r.s < 0) r.s += c.len;  if (r.lap === 1 && r.s >= c.sPlat && r.s < c.chain0) { r.s = c.sPlat; r.v = 0; r.a = 0; r.mode = 2; }  if (r.lap === 0 && !r.back && r.v < 0 && r.s > c.chain1) { r.back = true; r.backT = r.t; }}/** 先回りの計算: 1 周させ、列車の中心が各点を初めて通るときの速さ vC と加速度 aC を記録する(戻り始めたらそこまで)。 *  駅と巻き上げの中は決まった速さなので、巻き上げの頂上の手前から走らせ、そこまでは決まった速さで埋める。h は刻み(ドラッグ中は粗くする) */function predict(c: Course, mu: number, kd: number, h = H) {  const r = newRun(), n = c.n;  c.vC.fill(NaN); c.aC.fill(NaN);  r.mode = 1; r.s = Math.max(c.chain0, c.chain1 - 0.5); r.v = V_CHAIN;  let last = Math.round(r.s / DS) % n;  for (let j = Math.round(c.sPlat / DS); j <= last; j++) { c.vC[j] = j * DS < c.chain0 ? V_STATION : V_CHAIN; c.aC[j] = 0; }  for (let i = 0; i < 400 / h && r.mode === 1; i++) {    step(c, r, mu, kd, h);    if (r.back) break;    const j = Math.round(r.s / DS) % n;    for (let q = last; q !== j; ) { q = (q + 1) % n; if (Number.isNaN(c.vC[q])) { c.vC[q] = r.v; c.aC[q] = r.a; } }    last = j;  }  c.pred.t = r.t; c.pred.done = r.mode === 2; c.pred.stall = r.back ? r.s : -1;}

計算は、今の力で速さを進めてから位置を進める半陰的オイラー法で、刻みは1/120秒。時速120 kmでも1刻みに進むのは28 cmで、0.25 mおきの表との釣り合いがとれている。摩擦だけで向きが逆になることはないので、速さが0をまたいだら0で止め、止まっているときは重力の分が転がり抵抗に勝つかどうかで動き出すかを決める。チェーンは速さが毎秒4 mより遅ければ4 mにそろえ、駅の手前のブレーキは毎秒毎秒4 mで減速させる。

predictは同じstepを画面に出さずに1周走らせ、列車の中心が各点を初めて通るときの速さと加速度を記録する。駅と巻き上げの中は速さが決まっているので、巻き上げの頂上の手前から走らせ、それより前は決まった速さで埋めている。途中で後ろへ戻り始めたら、そこで打ち切る。

three.js が受け持つところ

ここから先は描画である。レールの管・枕木・支柱・Gの幕・車両を、three.jsの立体として作る。

立体を作る(src/sims/roller-coaster.ts の 336〜468 行)
// ここから下の立体・光・影・視点は three.js が受け持つ。レールの管・枕木・支柱・G の幕・車両の形を作り、点の座標を書き換える/** 管(レールや背骨)。輪を max 個まで用意しておき、作り直すときは位置の配列だけを書き換える */interface Tube { mesh: THREE.Mesh; pos: Float32Array; nor: Float32Array; sides: number }function tube(max: number, sides: number, mat: THREE.Material): Tube {  const geo = new THREE.BufferGeometry(), pos = new Float32Array(max * sides * 3), nor = new Float32Array(max * sides * 3), idx = new Uint32Array((max - 1) * sides * 6);  for (let i = 0, q = 0; i < max - 1; i++) for (let a = 0; a < sides; a++) {    const p0 = i * sides + a, p1 = i * sides + ((a + 1) % sides), p2 = p0 + sides, p3 = p1 + sides;    idx[q++] = p0; idx[q++] = p2; idx[q++] = p1; idx[q++] = p1; idx[q++] = p2; idx[q++] = p3;  }  geo.setAttribute("position", new THREE.BufferAttribute(pos, 3)); geo.setAttribute("normal", new THREE.BufferAttribute(nor, 3)); geo.setIndex(new THREE.BufferAttribute(idx, 1));  const mesh = new THREE.Mesh(geo, mat); mesh.frustumCulled = false; mesh.castShadow = true; mesh.receiveShadow = true;  return { mesh, pos, nor, sides };}/** 管を DS の stride 倍ごとの輪で作り直す。中心はレールの点から上へ ou・右へ or ずらした所、半径 r */function setTube(tb: Tube, c: Course, stride: number, ou: number, or: number, r: number) {  const rings = Math.floor(c.n / stride) + 1, S = tb.sides;  for (let i = 0; i < rings; i++) {    const j = (i * stride) % c.n;    const cx = c.px[j] + c.Ux[j] * ou + c.Rx[j] * or, cy = c.py[j] + c.Uy[j] * ou + c.Ry[j] * or, cz = c.pz[j] + c.Uz[j] * ou + c.Rz[j] * or;    for (let a = 0; a < S; a++) {      const an = (a / S) * Math.PI * 2, ca = Math.cos(an), sa = Math.sin(an), q = (i * S + a) * 3;      const nx = c.Rx[j] * ca + c.Ux[j] * sa, ny = c.Ry[j] * ca + c.Uy[j] * sa, nz = c.Rz[j] * ca + c.Uz[j] * sa;      tb.nor[q] = nx; tb.nor[q + 1] = ny; tb.nor[q + 2] = nz;      tb.pos[q] = cx + r * nx; tb.pos[q + 1] = cy + r * ny; tb.pos[q + 2] = cz + r * nz;    }  }  const g = tb.mesh.geometry;  g.attributes.position.needsUpdate = true; g.attributes.normal.needsUpdate = true;  g.setDrawRange(0, (rings - 1) * S * 6); g.computeBoundingSphere();}/** G の幕: レールに沿った帯。点ごとに 2 つの頂点(根元と先)を書き換える */interface Fin { mesh: THREE.Mesh; pos: Float32Array }function fin(max: number, mat: THREE.Material): Fin {  const geo = new THREE.BufferGeometry(), pos = new Float32Array(max * 6), idx = new Uint32Array((max - 1) * 6);  for (let i = 0, q = 0; i < max - 1; i++) { const a = 2 * i; idx[q++] = a; idx[q++] = a + 1; idx[q++] = a + 2; idx[q++] = a + 1; idx[q++] = a + 3; idx[q++] = a + 2; }  geo.setAttribute("position", new THREE.BufferAttribute(pos, 3)); geo.setIndex(new THREE.BufferAttribute(idx, 1));  const mesh = new THREE.Mesh(geo, mat); mesh.frustumCulled = false;  return { mesh, pos };}const FIN_STRIDE = 4;/** 幕を作り直す: 上下の G は背骨の下へ垂らす。1 G より重い所は点の幕、軽い所(0 G より下で体が浮く所も)は黒い幕。左右の G は、体が押される側へ水平に出す */function setFins(fins: Fin[], c: Course) {  const m = Math.floor(c.n / FIN_STRIDE) + 1;  for (let i = 0; i < m; i++) {    const j = (i * FIN_STRIDE) % c.n, gz = c.gz[j], gy = c.gy[j], ok = Number.isFinite(gz);    const pz = ok ? Math.max(0, gz - 1) : 0, nz = ok ? Math.max(0, 1 - gz) : 0, ly = ok ? Math.abs(gy) : 0, side = gy > 0 ? -1 : 1;    const bx = c.px[j] - c.Ux[j] * 0.7, by = c.py[j] - c.Uy[j] * 0.7, bz = c.pz[j] - c.Uz[j] * 0.7;    const put = (f: Fin, x0: number, y0: number, z0: number, x1: number, y1: number, z1: number) => { const q = i * 6; f.pos[q] = x0; f.pos[q + 1] = y0; f.pos[q + 2] = z0; f.pos[q + 3] = x1; f.pos[q + 4] = y1; f.pos[q + 5] = z1; };    put(fins[0], bx, by, bz, bx - c.Ux[j] * FIN * pz, by - c.Uy[j] * FIN * pz, bz - c.Uz[j] * FIN * pz);    put(fins[1], bx, by, bz, bx - c.Ux[j] * FIN * nz, by - c.Uy[j] * FIN * nz, bz - c.Uz[j] * FIN * nz);    const lx = c.px[j] + c.Rx[j] * side * 0.8, lyy = c.py[j] + c.Ry[j] * side * 0.8, lz = c.pz[j] + c.Rz[j] * side * 0.8, w = side * FIN * ly;    put(fins[2], lx, lyy, lz, lx + c.Rx[j] * w, lyy + c.Ry[j] * w, lz + c.Rz[j] * w);  }  for (const f of fins) { const g = f.mesh.geometry; g.attributes.position.needsUpdate = true; g.setDrawRange(0, (m - 1) * 6); }}interface Meshes { rails: Tube[]; spine: Tube; ties: THREE.InstancedMesh; posts: THREE.InstancedMesh; fins: Fin[]; cars: THREE.Group[]; heads: THREE.Mesh[][]; loopRing: THREE.Mesh }const M4 = new THREE.Matrix4(), VA = new THREE.Vector3(), VB = new THREE.Vector3(), VC = new THREE.Vector3(), VP = new THREE.Vector3(), Q4 = new THREE.Quaternion(), SC = new THREE.Vector3();function makeMeshes(v: View3): Meshes {  const max = Math.ceil(NMAX / 2) + 2;  const rails = [tube(max, 6, v.mat(0.95)), tube(max, 6, v.mat(0.95))], spine = tube(max, 8, v.mat(0.7));  const ties = new THREE.InstancedMesh(new THREE.BoxGeometry(0.14, 0.5, 1.3), v.mat(0.6), Math.ceil(NMAX / 6) + 2);  const postGeo = new THREE.CylinderGeometry(0.2, 0.26, 1, 8); postGeo.translate(0, 0.5, 0);  const posts = new THREE.InstancedMesh(postGeo, v.mat(0.55), 700);  for (const m of [ties, posts]) { m.castShadow = true; m.receiveShadow = true; m.frustumCulled = false; }  const fm = [v.flat(0.4), v.flat(1), v.flat(0.65)];  for (const m of fm) m.side = THREE.DoubleSide;  const fins = fm.map((m) => fin(Math.ceil(NMAX / FIN_STRIDE) + 2, m));  // 車両: 前が x、上が y、右が z の向きに作り、走るときはレールの T・U・R の向きに合わせる  const body = new THREE.BoxGeometry(2.2, 0.5, 1.5), seat = new THREE.BoxGeometry(0.3, 0.75, 1.3), head = new THREE.SphereGeometry(0.15, 10, 8), nose = new THREE.BoxGeometry(0.7, 0.4, 1.3);  const mb = v.mat(1, { flat: true }), ms = v.mat(0.8), mh = v.mat(0.55);  const cars: THREE.Group[] = [], heads: THREE.Mesh[][] = [];  for (let k = 0; k < NCAR; k++) {    const g = new THREE.Group(), hs: THREE.Mesh[] = [];    const b = new THREE.Mesh(body, mb); b.position.set(0, 0.35, 0); g.add(b);    for (const x of [0.45, -0.55]) {      const st = new THREE.Mesh(seat, ms); st.position.set(x - 0.35, 0.85, 0); g.add(st);      for (const z of [-0.33, 0.33]) { const h = new THREE.Mesh(head, mh); h.position.set(x, 1.35, z); g.add(h); hs.push(h); }    }    if (k === 0) { const n = new THREE.Mesh(nose, mb); n.position.set(1.4, 0.45, 0); n.rotation.z = -0.35; g.add(n); }    g.traverse((o) => { o.castShadow = true; });    g.matrixAutoUpdate = false; cars.push(g); heads.push(hs); v.scene.add(g);  }  const loopRing = new THREE.Mesh(new THREE.RingGeometry(2.2, 2.6, 32).rotateX(-Math.PI / 2), v.flat(1));  for (const t of [...rails, spine]) v.scene.add(t.mesh);  v.scene.add(ties, posts, ...fins.map((f) => f.mesh));  return { rails, spine, ties, posts, fins, cars, heads, loopRing };}/** レールの形が変わったときに、管・枕木・支柱を作り直す */function setTrack(m: Meshes, c: Course, q: number) {  const st = q >= 2 ? 2 : 4;  setTube(m.rails[0], c, st, 0, -0.55, 0.075); setTube(m.rails[1], c, st, 0, 0.55, 0.075); setTube(m.spine, c, st, -0.42, 0, 0.22);  let nt = 0;  for (let j = 0; j < c.n; j += 6) {    VA.set(c.tx[j], c.ty[j], c.tz[j]); VB.set(c.Ux[j], c.Uy[j], c.Uz[j]); VC.set(c.Rx[j], c.Ry[j], c.Rz[j]);    M4.makeBasis(VA, VB, VC).setPosition(c.px[j] - c.Ux[j] * 0.2, c.py[j] - c.Uy[j] * 0.2, c.pz[j] - c.Uz[j] * 0.2);    m.ties.setMatrixAt(nt++, M4);  }  m.ties.count = nt; m.ties.instanceMatrix.needsUpdate = true;  // 支柱: レールが上を向いている所に 8 m ごと(取っ手の点には必ず)。ループは底の近くだけ  let np = 0; const marks = new Set(c.nodeS.map((s) => Math.round(s / DS) % c.n));  const add = (j: number) => {    const top = c.py[j] - c.Uy[j] * 0.42 - 0.2;    if (top < 0.4 || c.Uy[j] < 0.55 || np >= 700) return;    Q4.identity(); SC.set(1, top, 1); VP.set(c.px[j] - c.Ux[j] * 0.42, 0, c.pz[j] - c.Uz[j] * 0.42);    m.posts.setMatrixAt(np++, M4.compose(VP, Q4, SC));  };  for (let j = 0; j < c.n; j++) if (marks.has(j) || (j % 32 === 0 && (c.kind[j] !== 2 || c.py[j] < 3))) add(j);  m.posts.count = np; m.posts.instanceMatrix.needsUpdate = true;}/** 車両を、レールの上の今の場所と向きに置く */function placeCars(m: Meshes, c: Course, s: number) {  for (let k = 0; k < NCAR; k++) {    const L = look(c, s + OFF[k], true);    VA.set(L.tx, L.ty, L.tz).normalize(); VC.set(L.Rx, L.Ry, L.Rz); VB.crossVectors(VC, VA).normalize(); VC.crossVectors(VA, VB);    M4.makeBasis(VA, VB, VC).setPosition(L.px + L.Ux * 0.05, L.py + L.Uy * 0.05, L.pz + L.Uz * 0.05);    m.cars[k].matrix.copy(M4); m.cars[k].matrixWorldNeedsUpdate = true;  }}/** 地面と駅(動かない物) */function scenery(v: View3) {  const ground = new THREE.Mesh(new THREE.PlaneGeometry(2400, 2400).rotateX(-Math.PI / 2), v.mat(0.14));  ground.position.set(90, 0, -40); ground.receiveShadow = true; v.scene.add(ground);  // 20 m ごとの目盛りの線(遠くは霧に溶ける)  const pts: number[] = [];  for (let x = -610; x <= 790; x += 20) pts.push(x, 0.03, -740, x, 0.03, 660);  for (let z = -740; z <= 660; z += 20) pts.push(-610, 0.03, z, 790, 0.03, z);  const lg = new THREE.BufferGeometry(); lg.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3));  v.scene.add(new THREE.LineSegments(lg, v.lineMat(0.4)));  const plat = new THREE.Mesh(new THREE.BoxGeometry(34, 1, 3.2), v.mat(0.45)); plat.position.set(ST_PLAT, 0.5, -2.8);  const roof = new THREE.Mesh(new THREE.BoxGeometry(36, 0.35, 8), v.mat(0.6)); roof.position.set(ST_PLAT, 5.2, -1);  for (const b of [plat, roof]) { b.castShadow = true; b.receiveShadow = true; v.scene.add(b); }  for (const x of [-16, 16]) for (const z of [-4.4, 2.4]) { const p = new THREE.Mesh(new THREE.BoxGeometry(0.3, 5, 0.3), v.mat(0.6)); p.position.set(ST_PLAT + x, 2.6, z); p.castShadow = true; v.scene.add(p); }}

管は、輪の頂点をFloat32Arrayに並べた形を最初に最大の数まで用意し、コースが変わったら座標を書き換えてsetDrawRangeで使う数だけ描く。毎コマ立体を作り直さないためである。枕木と支柱はInstancedMeshで、同じ形を位置と向きの行列だけ変えて並べる。車両はレールの・・を列にした回転の行列で置く。白黒の網点と輪郭線への変換は、全記事に共通の部品が受け持つ。

取っ手を動かす

取っ手は画面の上の距離で選ぶ。四角は、指の下の光線と「取っ手の点を通り、視線に向き合う縦の面」の交わる高さへ動かし、点線の輪は地面の面と交わる所へ動かす。隣の点に5 mより近づく動きは受け付けない。

操作(src/sims/roller-coaster.ts の 527〜584 行)
/** 指の下の取っ手(画面の上で近いもの)。四角を先に調べる */function pick(api: Api, s: S): Hd | null {  const p = api.pointer, R = api.u(p.kind === "touch" ? 26 : 16);  let best: Hd | null = null, bd = R;  for (const pass of [true, false]) {    for (const h of s.hs) if (h.top === pass) { const d = Math.hypot(h.x - p.x, h.y - p.y); if (d < bd) { bd = d; best = h; } }    if (best) return best;  }  return null;}const FIELD = { x0: -170, x1: 350, z0: -140, z1: 40 };/** 取っ手を動かす: 四角は、指の下の光線と「取っ手の点を通り、視線に向き合う縦の面」が交わる高さへ。輪は、光線が地面と交わる所へ */function drag(api: Api, s: S) {  const g = s.grab!, v = s.v, d = s.c.d, p = api.pointer;  const cur: V3 = g.n >= 0 ? d.nodes[g.n] : [d.loop!.x, d.loop!.H, d.loop!.z];  if (g.top) {    const at: V3 = g.n >= 0 ? cur : (() => { const lp = makeLoop(d.loop!); evalPc(lp, lp.len / 2); return [EV.p[0], EV.p[1], EV.p[2]] as V3; })();    v.camera.getWorldDirection(VA); VA.y = 0;    let y: number | null = null;    if (VA.lengthSq() > 1e-4) { VA.normalize(); const hit = v.onPlane(p.x, p.y, new THREE.Plane().setFromNormalAndCoplanarPoint(VA, VP.set(at[0], at[1], at[2]))); if (hit) y = hit.y; }    if (y === null) y = g.base[1] - ((p.y - g.py) * v.orbit.dist) / Math.max(200, api.h);    if (g.n >= 0) cur[1] = clamp(y, 1.5, 90); else d.loop!.H = clamp(y - Y0, 10, 40);  } else {    const hit = v.onPlane(p.x, p.y, new THREE.Plane(new THREE.Vector3(0, 1, 0), 0));    if (!hit) return;    const x = clamp(hit.x, FIELD.x0, FIELD.x1), z = clamp(hit.z, FIELD.z0, FIELD.z1);    if (g.n >= 0) {      // 隣の点(駅とループの出入り口も)から 5 m より近づけない      const nb = d.nodes.filter((_, i) => Math.abs(i - g.n) === 1).concat(d.loop ? [[d.loop.x, Y0, d.loop.z] as V3, loopExit(d.loop)] : []).concat([[ST_B + LEAD, Y0, 0], [ST_A - LEAD, Y0, 0]]);      if (nb.some((q) => Math.hypot(q[0] - x, q[2] - z) < 5)) return;      cur[0] = x; cur[2] = z;    } else { d.loop!.x = x; d.loop!.z = z; }  }  s.dirty = 4;}function pointer(type: "down" | "move" | "up" | "cancel" | "wheel", api: Api, s: S, e?: { deltaY: number }) {  const v = s.v, seat = api.str("view") === "seat";  if (v.orbit.busy) { if (s.grab) { s.grab = null; s.dirty = 4; restart(api, s, "crest"); } return; }  if (type === "wheel") { if (!seat) v.orbit.pointer(type, api, e); return; }  if (type === "down" && !seat) {    const h = pick(api, s);    if (h) {      const d = s.c.d, base: V3 = h.n >= 0 ? [...d.nodes[h.n]] as V3 : [d.loop!.x, d.loop!.H + Y0, d.loop!.z];      s.grab = { n: h.n, top: h.top, px: api.pointer.x, py: api.pointer.y, base }; s.r.mode = 0; return;    }  }  if (s.grab) {    if (type === "move") drag(api, s);    else if (type === "up" || type === "cancel") { s.grab = null; rebuild(api, s, 4); restart(api, s, "crest"); }    return;  }  if (seat) {    // 座席の視点: ドラッグで首を回す(離すとゆっくり前へ戻る)    if (type === "move" && api.pointer.down) { const k = 2.4 / Math.max(320, api.w); s.look[0] = clamp(s.look[0] - api.pointer.vx * k * 0.016, -1.4, 1.4); s.look[1] = clamp(s.look[1] - api.pointer.vy * k * 0.016, -0.8, 0.8); }    return;  }  v.orbit.pointer(type, api, e);}

ドラッグ中は0.12秒ごとに形と先回りの計算を作り直し(先回りの刻みは1/40秒に粗くする)、指を離したら1/120秒の刻みで作り直して走らせる。座席からの視点では、ドラッグで首を左右と上下に回し、離すと前へ戻る。

2Dの文字とグラフ

3Dの絵を描いたあとに、取っ手・札・グラフを2Dの面に重ねる。グラフは1秒に30回記録した過去20秒のGで、上下を太線、左右を細線、前後を破線にして、白黒の2色だけで見分けられるようにした。

文字とグラフ(src/sims/roller-coaster.ts の 586〜643 行)
/** 2D の面に描く: 取っ手・G の目立つ所の札・座席の G のグラフ */function overlay(api: Api, s: S) {  const v = s.v, u = api.u, c = s.c, d = c.d, seat = api.str("view") === "seat", fs = clamp(u(12.5), 11, 15);  s.hs.length = 0;  if (!seat) {    const hd = (n: number, top: V3) => {      const a = v.project(top), b = v.project([top[0], 0, top[2]]);      const g = s.grab && s.grab.n === n;      if (b.on) { api.circle(b.x, b.y, u(8), { w: u(1.3), dash: [u(2.5), u(2.5)], fill: false }); s.hs.push({ n, top: false, x: b.x, y: b.y }); }      if (a.on) { const r = u(5); api.rect(a.x - r, a.y - r, 2 * r, 2 * r, { fill: g && s.grab!.top ? "ink" : "paper", w: u(1.6) }); s.hs.push({ n, top: true, x: a.x, y: a.y }); }    };    d.nodes.forEach((p, i) => hd(i, p));    if (d.loop) { const lp = makeLoop(d.loop); evalPc(lp, lp.len / 2); hd(-1, [EV.p[0], EV.p[1], EV.p[2]]); }    // 先回りの計算で G がいちばん大きい所・小さい所・左右がいちばん大きい所に札を立てる    const P = c.pred, tag = (j: number, t: string, dy: number) => { if (j < 0) return; const a = v.project([c.px[j], c.py[j], c.pz[j]]); if (a.on) { api.line(a.x, a.y, a.x, a.y + dy, { w: u(1) }); api.label(t, clamp(a.x, u(48), api.w - u(48)), a.y + dy + (dy < 0 ? -u(4) : u(14)), { size: fs, align: "center" }); } };    tag(P.iMax, `最大 ${fmt(P.maxG, 1)} G`, u(30));    if (P.minG < 0.6) tag(P.iMin, `最小 ${fmt(P.minG, 1)} G`, -u(26));    if (P.lat > 0.6) tag(P.iLat, `左右 ${fmt(P.lat, 1)} G`, -u(40));  }  // 列車の近くに、今の上下の G  const L = look(c, s.r.s + OFF[seatOf(api)], true), a = v.project([L.px + L.Ux * 2.2, L.py + L.Uy * 2.2, L.pz + L.Uz * 2.2]);  if (!seat && a.on) { const lx = Math.min(a.x + u(22), api.w - u(56)), ly = Math.max(u(20), a.y - u(22)); api.line(a.x, a.y, lx, ly + u(4), { w: u(1.2) }); api.label(`${fmt(s.r.gz, 1)} G`, lx, ly, { size: fs * 1.1, weight: 600 }); }  if (seat) api.label(`${api.str("seat") === "front" ? "先頭" : api.str("seat") === "mid" ? "真ん中" : "最後尾"}の車両の座席から  上下 ${fmt(s.r.gz, 1)} G`, u(14), u(24), { size: fs });  graph(api, s, fs);}/** 座席の G の時間のグラフ(過去 20 秒)。太線 = 上下・細線 = 左右・破線 = 前後 */function graph(api: Api, s: S, fs: number) {  const u = api.u, mob = api.w < 560, pad = u(12);  const w = mob ? api.w - 2 * pad : Math.min(api.w * 0.42, u(400)), h = mob ? clamp(api.h * 0.2, u(80), u(120)) : clamp(api.h * 0.24, u(90), u(150));  const x0 = pad, y0 = api.h - pad - h, gr = s.gr;  api.rect(x0, y0, w, h, { fill: "paper", w: u(1) });  const top = Math.max(5, Math.ceil(Math.max(s.r.maxG, s.c.pred.maxG) + 0.3)), bot = -1.5;  const ix0 = x0 + u(30), iw = w - u(38), iy0 = y0 + u(22), ih = h - u(30);  const Y = (g: number) => iy0 + ((top - clamp(g, bot, top)) / (top - bot)) * ih;  // 低いグラフでは目盛りの数字が重ならないように間引く  const ticks = ih > fs * 6 ? [-1, 0, 1, 3, 5, 7] : [0, 1, 5];  for (const g of ticks) if (g <= top) {    api.line(ix0, Y(g), ix0 + iw, Y(g), g === 0 ? { w: u(1) } : { w: u(0.6), dash: [u(2), u(4)] });    if (ticks.length > 3 || g !== 1) api.text(`${g < 0 ? "−" : ""}${Math.abs(g)}`, ix0 - u(5), Y(g) + fs * 0.32, { size: fs * 0.85, align: "right", font: "mono" });  }  api.text("座席の G(過去 20 秒)", x0 + u(8), y0 + u(15), { size: fs * 0.9 });  const lg = x0 + w - u(8);  // 凡例: 線の見本と名前を右から並べる  let lx = lg;  for (const [name, o] of [["前後", { w: u(1.1), dash: [u(3), u(3)] }], ["左右", { w: u(1) }], ["上下", { w: u(2.4) }]] as [string, { w: number; dash?: number[] }][]) {    api.text(name, lx, y0 + u(15), { size: fs * 0.85, align: "right" }); lx -= fs * 0.85 * 2 + u(4);    api.line(lx - u(16), y0 + u(11), lx, y0 + u(11), o); lx -= u(26);  }  const n = gr.n, pts = gr.pts;  const series = (arr: Float32Array, o: { w: number; dash?: number[] }) => {    if (n < 2) return;    for (let k = 0; k < n; k++) { const i = (gr.i - n + k + GN) % GN; pts[2 * k] = ix0 + iw - ((n - 1 - k) / (GN - 1)) * iw; pts[2 * k + 1] = Y(arr[i]); }    api.poly(pts.subarray(0, 2 * n), o);  };  series(gr.x, { w: u(1.1), dash: [u(3), u(3)] });  series(gr.y, { w: u(1) });  series(gr.z, { w: u(2.4) });}

画面の部品(描画・場面の切り替え・画質の自動調整・GPU の網点)は全記事で共通です。シミュレーションの共通の仕組みで解説しています。

プログラム全体を見る(src/sims/roller-coaster.ts・752 行)

読み込んでいます

姉妹サイト

株式会社Game が運営するメディアです。