このシミュレーションは、取っ手の点とループの式からレールの形を作り、0.25 mごとに位置・向き・曲がり・座席の向きを表にしておく。列車は1/120秒ごとに、5両それぞれの場所の重力と転がり抵抗、空気の抵抗から速さを進め、選んだ座席のGを求める。立体・光・影・視点を描くのはthree.js(WebGL2の3Dの部品集)で、記事の計算からは、描くための点の座標を渡すだけである。
長さはm、時間は秒
1 = 1 mで作っているので、重力加速度9.80665 m/s²をそのまま式に使える。列車の重さとC d A は空気の抵抗にだけ効き、重力と転がり抵抗の加速度には質量が出てこない。
定数と単位(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次式になり、位置はcos θ とsin θ をシンプソンの公式で足し合わせて表にできる。円は曲がりが最初から最後まで一定の場合だ。出口が入口とぶつからないように、横へのずれを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]; }
どの区間も、位置p と1階微分p ′ ・2階微分p ′′ を返す。レールの向きはT = p ′ /∣ p ′ ∣ 、曲がりのベクトルはκ = ( p ′′ − ( p ′′ ⋅ T ) T ) /∣ p ′ ∣ 2 で、向きは曲がる中心の側、大きさが1/半径になる。座席の上の向きは、真上をレールに直角にしたもの。ループでは縦の平面の中で回る向きを使い、ほぼ真上を向くレールでは、1つ前の点の上の向きを引き継ぐ。
座席の向きとG
seatGが本文のf = a − g を計算する。体の加速度は、レールに沿った加速度a T と、向きを曲げるv 2 κ の和である。これに真上のg を足し、座席の上U ・右R ・前T との内積をとってg で割ると、上下・左右・前後の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はカーブの傾きφ を決める。先回りで求めた速さでf のうちレールに直角な分を計算し、右向きの分と上向きの分の比からtan φ を出す。本文のtan φ = v 2 / ( g R ) を、坂の途中のカーブにも使える形にしたものだ。上向きの分が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で、同じ形を位置と向きの行列だけ変えて並べる。車両はレールのT ・U ・R を列にした回転の行列で置く。白黒の網点と輪郭線への変換は、全記事に共通の部品が受け持つ。
取っ手を動かす
取っ手は画面の上の距離で選ぶ。四角は、指の下の光線と「取っ手の点を通り、視線に向き合う縦の面」の交わる高さへ動かし、点線の輪は地面の面と交わる所へ動かす。隣の点に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 ) }); }