乗用車は傾いた坂で、転ぶより先にすべり出す

横に傾いた坂を横切る車は、谷側のタイヤに重さが寄る。乗用車は乾いた舗装でも傾き約40°でタイヤがすべり出し、転ぶ傾きの約55°には届かない。重心が高い車では、この順番が入れ替わる。

記事

乗用車は傾いた坂で、転ぶより先にすべり出す

横に傾いた坂を横切る車は、谷側のタイヤに重さが寄る。乗用車は乾いた舗装でも傾き約40°でタイヤがすべり出し、転ぶ傾きの約55°には届かない。重心が高い車では、この順番が入れ替わる。

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

現象

坂を押す力の3分の2が、谷側の2輪に寄る

画面の車は、傾き12°の斜面に描いた半径14 mの円を、時速15 kmで回っている。画面に重ねた図の4つの円は、タイヤが地面を押す力の大きさを表し、上の2つが前輪。坂を上る向きでは後輪の円が、下る向きでは前輪の円が大きくなる。坂を横切るときにふくらむのは、谷側の2つだ。車が向きを変えるたびに、重さは4つのタイヤのあいだを行き来する。

傾きを増やすほど、谷側へ寄る重さも大きくなっていく。カローラと同じくらい大きいセダン(質量1,370 kg)が傾き25°の坂を横切ると、4輪で坂を押す力のうち3分の2が谷側の2輪にかかる1。もっと傾ければ、車はいつか坂を下へすべり出すか、谷側へ転がるはずだ。では、どちらが先に起きるのか。

乗用車なら、すべるほうが先である。よく効く乾いた舗装でも、タイヤが坂を支えきれなくなるのは傾き約40°。転ぶ約55°よりずっと手前だ。ところが重心を高くしていくと、ある所で順番が入れ替わり、すべる前に転ぶようになるのだ。

車は、描画面の下のボタンか、矢印キーとWASDのキーで自分でも運転できる。操作パネルの「指で」を「地面を盛る」に切り替えれば、なぞった所に小山ができる。

仕組み

転ぶかすべるかは、2つの数の比べ合い

坂に止まった車を後ろから見ると、重さは真下にかかる。は車の質量(kg)、は重力加速度(9.81 m/s²)だ。この重さは、タイヤを坂に押し付ける分と、坂に沿って車を谷へ引く分に分けられる。は坂の傾き。車がずり落ちないのは、谷へ引く分をタイヤと地面の摩擦が受け止めているからだ。

θ 重心 重さ mg 山側の車輪 N₁ と F₁ 谷側の車輪 N₂ と F₂(大きい) 点線の三角の中を重さの線が通るうちは転ばない。 重さの線が谷側の車輪の外へ出ると、車は谷側へ転ぶ。
横に傾いた坂の車を後ろから見た図。重さの線が谷側の車輪より内側を通るうちは転ばない

4つのタイヤが坂に沿って出す横の力を合わせて、地面を押す力を合わせてとする。止まっている車なら、この比は坂の傾きだけで決まる。

ただし、タイヤが横に出せる力には上限がある。に摩擦係数を掛けたを超えられないので、がより大きくなると、車は坂をすべり落ちる。乾いた舗装のを0.83とすると、境目は約40°だ。

一方、転ぶかどうかを決めるのは重心の位置だ。重心から真下へ引いた線が谷側のタイヤより内側に落ちるうちは、車は元の姿勢へ戻ろうとする。線が外へ出た瞬間に山側のタイヤが浮き、車は谷側へ転がる。そのぎりぎりのが、静的安定係数(SSF)である。

は左右のタイヤの間隔(トレッド、m)、は重心の高さ(m)である。米国道路交通安全局(NHTSA)は、この比で横転のしにくさを評価してきた。2003年型の平均は、乗用車が1.41、SUVが1.172。トレッド1.515 mのセダンでSSFが1.41なら、重心は地面から約0.54 mの高さにあり、転ぶ傾きはで約55°になる。

傾いていくあいだに谷側のタイヤが受け持つ割合を表すのが、次の式だ。

傾き25°で0.67、すべり出す40°で0.80。がSSFに届くと割合は1になり、山側のタイヤはもう地面を押していない。

つまり、転ぶのが先かすべるのが先かは、とSSFの小さいほうで決まる。乗用車のSSFはたいてい1.30〜1.50で2、乾いた舗装のよりずっと大きい。だから平らに傾いた舗装の上では、乗用車は転ぶ前にすべる。

仕組み

曲がるときも止まるときも、同じものさしで測れる

カーブを曲がる車には、円の中心へ向かう力が要る。半径(m)の円を速さ(m/s)で回るなら、タイヤは横にの力を出さなければならない。乗っている人には、重さの向きが外側へ傾いたように感じられるだろう。この見かけの重力が傾く角度は、で決まる。

坂のをに置き換えれば、あとは同じ話だ。がを超えると車は外へすべり、SSFを超えると外側へ転ぶ。画面に重ねた図の「横の力 ÷ 支える力」は、坂でもカーブでも、タイヤが受け持っている比そのもの。

カーブの路面を内側へ傾けておけば、重さの一部が曲がる力に回る。傾きのバンクでは、次の速さでタイヤの横の力がちょうど0になる。

半径30 m、傾き20°なら秒速約10.3 m、時速37 kmだ。これより速いとタイヤは内向きに、遅いと坂を上る向きに力を出す。

ブレーキを踏むと、重さは前へ移る。止める力は地面の高さでかかるが、車の重さは0.54 m上の重心にかかっている。この高さのずれが車を前へ回そうとするので、前輪のが増え、後輪のが減るのだ。止める力の合計を、前後の車軸の間隔(ホイールベース)を(m)とすると、前輪へ移る力は次のとおり。

タイヤ1つが出せる力には、前後と横を合わせてという上限もある。

は横の力だ。上から見ると、タイヤの力を表す矢印は半径の円の中にしか描けない。これが摩擦円である3。強いブレーキで前後の力を使えば、横に出せる力はそのぶん減ってしまう。

横の力だけ 前 後ろ(ブレーキ) 横 強いブレーキを足す 前 後ろ(ブレーキ) 横 0.70 μN 半径 μN 求める力 1.18 μN 出せる力 μN 横 0.59 μN
タイヤの摩擦円。横の力だけなら円の中に収まるが、強いブレーキを足すと円からはみ出し、横の力も削られる
試す

6つの場面で、すべる・転ぶを見比べる

画面に重ねた図で、円の大きさはタイヤが地面を押す力を表し、そばの数字はそれをkgに直した値。矢印はタイヤが今出している力で、先が円に届くと円が太くなる。そのタイヤは、すべっている。下のものさしは「横の力 ÷ 支える力」で、とSSFの所に目盛りを付けてある。

  1. 傾き25°の坂を、時速15 kmで横切る。谷側の2輪が合わせて約840 kg、山側の2輪が約400 kgを支え、ものさしの値はtan 25°と同じ0.47。坂の傾きのつまみを40°まで上げると、ものさしがの目盛りに届き、車は横へすべり出して谷へ流れていく。ところが重心の高さを1.0 m(SSF 0.76)にして傾きを32°にすると、山側の2輪が浮き、車はすべる前に谷側へ転がる。SSFから求めた約37°より手前で転ぶのは、ばねで車体が谷側へ傾き、重心が外へずれるからだ。
  2. 時速65 kmで左へ急にハンドルを切り、すぐ右へ切り返す。外側の前輪には700 kgほどがかかり、ものさしは0.8前後まで上がるが、内側のタイヤは浮かない。4輪とも摩擦円の端に届き、比がで頭打ちになるからだ。重心の高さを0.9 m(SSF 0.84)にすると場面が最初から走り直し、最初に左へ切ったところで内側の2輪が浮いて、車は横転する。SSFはをわずかに上回るのに転ぶのは、手順1と同じく、ばねで車体が外へ傾いて重心が外へずれるからだ。
  3. 内側へ20°傾いた半径30 mのカーブを、時速37 kmで回る。ものさしは0.00で、4つの円から矢印はほとんど出ていない。重さとバンクが押し返す力だけで曲がれているわけだ。速さのつまみを時速60 kmにすると比は0.43になり、矢印はカーブの内側を向く。逆に時速20 kmでは0.25で、矢印は坂の上を向いて、車がずり落ちないように支える。
  4. 傾き6°の下り坂を時速60 kmで下り、途中でブレーキをいっぱいに踏む。前輪2つで約1,050 kg、後輪2つで約320 kg。前輪の割合は、ブレーキを踏む前の62%から77%に上がった。ABSを切ると場面が最初から走り直し、4輪ともロックして円が点線に変わり、地面にタイヤの跡が残る。止まるまでの距離はどちらも約21 mだが、ブレーキのボタンを押したまま左のボタンを押しても、車の向きは変わらない。ロックしたタイヤは、こすられる向きの逆にしか力を出せないからだ。ABSがあれば、同じ操作で車は左へ曲がる。
  5. 0.075の凍った斜面(傾き4°)を、時速25 kmで横切る。tan 4°は0.07なので、ブレーキを踏む前は横の力が足りている。ところがブレーキが摩擦円の大半を使うと横に出せる力が足りなくなり、ABSがあっても車は谷側へ流れ始める。ブレーキを踏んで9秒で、ずれは約3.5 m。ABSを切ると4輪がロックして横の力がほとんど出なくなり、同じ9秒で約19 m流される。
  6. 時速40 kmで、傾き6°・長さ6 mの上り坂の先にある段差(高さ0.63 m)から飛び出す。前輪が先に段差を越えて落ち始めるので、車は鼻先を下げて飛ぶ。4輪とも浮いているのは約0.2秒と短い。それでも前輪から着地した瞬間、いちばん重い車輪には止まっているときの約9倍の力がかかり、車体の底も地面を打つ。
現実では

横転は衝突の3%、亡くなった人の3分の1

NHTSAの技術報告によると、米国で乗用車とライトトラック(SUV・ピックアップ・バン)が起こす衝突のうち、横転を伴うものは約3%にすぎない。それでも2003年には、亡くなった乗員の3分の1にあたる10,376人が、横転した車に乗っていた。亡くなった人のうち横転した車にいた割合は、乗用車で23%、SUVでは59%に上る2。

NHTSAは2001年型から、SSFをもとに横転のしにくさを星の数で示してきた。報告は、SSFが小さい車ほど、単独事故で何かにつまずいて横転しやすいと説明している2。すべる車のタイヤが縁石や柔らかい土に引っかかると、横の力はで頭打ちにならない。が一気にSSFを超え、車は転がる。2004年型からは、5人乗りに相当する重りと満タンの燃料を積み、左右へ急にハンドルを切る「フィッシュフック」の走行試験も加わった2。

日本では、農業機械の事故で人が亡くなる原因のうち、最も多いのが機械の転落・転倒である。農林水産省の資料によると、2023年に農作業の事故で亡くなった236人のうち、最も多い61人が乗用型トラクターによる事故で、その半分を超える33人が転落・転倒で亡くなった4。

道路は、こうした限界のずっと手前で設計されている。道路構造令は、カーブの路面を内側へ傾ける片勾配の上限を10%(約5.7°)とし、雪の多い地域では6〜8%に抑える。片勾配を大きくしすぎると、遅い車が内側へずれたり、雨や雪の日にすべったりするからだ5。設計速度100 km/hの道なら曲線の半径は460 m以上で、片勾配10%のカーブを時速100 kmで回っても、タイヤが受け持つは約0.07にすぎない6。

路面のは、天気で大きく変わる。JAFが急ブレーキを試した結果では、乾いた舗装を時速60 kmで走る車が17.0 mで止まった7。ところが雪を踏み固めた路面では時速40 kmから20.2 m、凍った路面では84.1 mかかっている8。止まるまでの減速を平均すると、重力加速度の0.83倍、0.31倍、0.075倍。乾いた舗装ならは0.83だが、凍った路面では10分の1にも届かない。シミュレーションでも、3つの路面のをこの値にしてある。

省いていること

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

  • タイヤは幅のない円板とし、すべり出す前とすべっているときの摩擦係数を同じにした。本物のタイヤは、すべり出すと摩擦が少し下がり、強く押し付けるほどが小さくなる。ABSは、ブレーキの力を各輪のの95%までに抑える装置として簡単に表した
  • 車輪の回る速さは計算せず、前後の力は駆動とブレーキの力をそのまま使う。車輪とブレーキの重さは車体に含め、左右の前輪は同じ角度に切る
  • 慣性モーメント、前後の重さの配分(前60%)、ばねの固さ(車体が1秒に1.3回揺れる固さ)、エンジンの力は、同じ大きさの乗用車を想定して決めた値で、特定の車を測った値ではない。重心の高さを変えても、慣性モーメントは変えていない
  • SSFは、ばねもタイヤも変形しない車の値である。シミュレーションの車は、ばねで車体が谷側へ傾くぶん、SSFより早く転ぶ。本物の車ではタイヤもたわむ。また、場面の初めに置いた直後の1秒は、車体の横と前後の揺れを強く弱めている
  • 地面は0.5 mごとの高さの表で、硬い。縁石や柔らかい土にタイヤが引っかかる「つまずき」は、段差としてしか入れていない
参考文献

参考文献

  1. トヨタ自動車「カローラ 主要諸元表」2026年5月. https://toyota.jp/pages/contents/corolla/002_p_001/pdf/corolla_spec_202605.pdf
  2. Walz, M. C.「Trends in the Static Stability Factor of Passenger Cars, Light Trucks, and Vans」DOT HS 809 868, National Highway Traffic Safety Administration, 2005. https://crashstats.nhtsa.dot.gov/Api/Public/ViewPublication/809868
  3. Pacejka, H. B.『Tire and Vehicle Dynamics』3rd ed., Elsevier, 2012.
  4. 農林水産省「令和5年に発生した農作業死亡事故の概要」2025. https://www.maff.go.jp/j/press/nousan/sizai/attach/pdf/250226-1.pdf
  5. 国土交通省 道路局「道路構造令の各規定の解説 4 線形」. https://www.mlit.go.jp/road/sign/pdf/kouzourei_3-4.pdf
  6. JAF「摩耗タイヤの検証(JAFユーザーテスト)」2015年3月実施. https://jaf.or.jp/common/safety-drive/car-learning/user-test/driving-environment/wear-test
  7. JAF「ブラックアイスバーン(JAFユーザーテスト)」2013年1月実施. https://jaf.or.jp/common/safety-drive/car-learning/user-test/snow/black-ice-burn

注

  1. 参考文献1。カローラのセダンの車両重量は1,330〜1,430 kg、ホイールベースは2,640 mm、トレッドは前1,510〜1,530 mm・後1,520〜1,540 mm(車両重量はグレードと駆動方式で、トレッドはグレードで違う)。シミュレーションの車は、前輪駆動のW×Bの質量1,370 kgとトレッド(前後の平均1.515 m)、ホイールベース2.64 mを使い、タイヤの半径はGの205/55R16から0.316 mとした。3分の2は、本文の3つ目の式に重心の高さ0.54 mを入れた値。 ↩

  2. 参考文献2。乗用車とSUVの平均SSFは、販売台数で重みを付けた2003年型の値で、ピックアップは1.18、ミニバンは1.24。亡くなった人の数と割合は、米国の2003年の死亡事故の記録(FARS)による。 ↩ ↩2 ↩3 ↩4 ↩5

  3. 参考文献3。タイヤの横の力が、すべり角に比例して立ち上がり、地面を押す力と摩擦係数で頭打ちになる考え方。シミュレーションのタイヤもこの形にした。 ↩

  4. 参考文献4。農業機械による事故の147人のうち、転落・転倒は60人(40.8%)で、原因の中で最も多い。乗用型トラクターの61人は農作業死亡事故全体の25.8%、転落・転倒の33人は乗用型トラクターによる事故の54.1%。 ↩

  5. 参考文献5。道路構造令第16条。第1種〜第3種の道路で、積雪寒冷の度がはなはだしい地域は6%、そのほかの積雪寒冷地域は8%、それ以外の地域は10%。第4種の道路は6%。 ↩

  6. 参考文献5の曲線半径の式(は設計速度 km/h、は片勾配、は横すべりの摩擦係数)に、、、を入れてを求めた。460 mは道路構造令第15条の値で、やむを得ない所は380 mまで縮められる。 ↩

  7. 参考文献6。溝が減っていない(10分山の)夏用タイヤで、乾いた路面をまっすぐ走りながらの急ブレーキ。時速100 kmからは47.5 m。 ↩

  8. 参考文献7。ABSが働く急ブレーキで、3回の平均。ぬれた舗装は11.0 m、ブラックアイスバーンは69.5 m。 ↩

プログラム
プログラム

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

このシミュレーションは、1/240秒刻みで「4つの車輪の下の地面までの長さを測る → ばねの力とタイヤの力を求める → 車を1つの剛体として動かす」をくり返している。地面の網目や車の形を立体に描き、影を落とし、視点を回すのはthree.jsの受け持ちで、記事の物理はその前に終わっている。画面に重ねた図と測った値は、計算の結果を2Dの面に重ねて描いたものだ。

長さはメートル、車はカローラの大きさ

長さはメートル、時間は秒、力はニュートンで数える。質量1,370 kg、ホイールベース2.64 m、トレッド1.515 mは、カローラのセダン(前輪駆動)の主要諸元表から取った。トレッドは前1.51 m・後1.52 mの平均である。重心の高さ0.54 mは、NHTSAがまとめた乗用車の平均のSSF(1.41)とトレッドから逆算した。

定数と単位(src/sims/car-terrain.ts の 21〜66 行)
const G = 9.81;                 // 重力加速度 m/s²const DEG = Math.PI / 180;const KMH = 1 / 3.6;            // 1 km/h = 0.278 m/s// 車: 車両重量 1,370 kg・ホイールベース 2.64 m・トレッド 1.51〜1.52 m の前輪駆動のセダン(カローラの主要諸元表)const MASS = 1370;              // 質量 kgconst WB = 2.64;                // ホイールベース(前と後ろの車軸の間)mconst TRACK = 1.515;            // トレッド(左右のタイヤの中心の間)mconst FRONT = 0.6;              // 前輪にかかる重さの割合const LA = WB * (1 - FRONT);    // 重心から前の車軸まで mconst LB = WB * FRONT;          // 重心から後ろの車軸まで mconst R_W = 0.316;              // タイヤの半径 m(205/55R16。カローラの G のタイヤ)const INERTIA = new THREE.Vector3(500, 2300, 2100); // 慣性モーメント kg·m²(前後の軸=横に傾く・上下の軸=向きを変える・左右の軸=前後に傾く)// ばねとダンパー: 車体が 1 秒に 1.3 回上下に揺れ、2 回ほどで揺れが収まる固さconst F_RIDE = 1.3;             // 車体が上下に揺れる振動数 Hzconst ZETA = 0.35;              // 減衰比const S_MAX = 0.3;              // ばねがいちばん伸びたときの、取り付け点から車輪の中心までの長さ mconst X_STATIC = G / (2 * Math.PI * F_RIDE) ** 2; // 止まっているときのばねの縮み m(約 0.15 m)const X_BUMP = 0.24;            // これより縮むと硬いゴム(バンプストッパー)に当たる mconst K_BUMP = 3e5;             // バンプストッパーの固さ N/mconst K_ARB = [14000, 7000];    // スタビライザー(左右の縮みの差に逆らうばね)前・後 N/m// タイヤconst C_ALPHA = 18;             // 横の固さ(コーナリングパワー)÷ 荷重 1/rad。4,000 N の荷重で 72,000 N/radconst SIGMA = 0.4;              // 緩和長 m(横の力が立ち上がるまでに転がる距離)const C_RR = 0.012;             // 転がり抵抗の係数const CDA = 0.64;               // 空気抵抗の係数 × 前から見た面積 m²const RHO_AIR = 1.2;            // 空気の密度 kg/m³const P_MAX = 90e3;             // エンジンの最大の仕事率 Wconst F_DRIVE = 5500;           // 前輪を回す最大の力 Nconst B_MAX = 1.1;              // ブレーキをいっぱいに踏んだときの力 ÷ 重さconst B_FRONT = 0.7;            // ブレーキの力のうち前輪の割合const STEER_MAX = 0.6;          // 前輪を切れる最大の角度 rad(約 34°)const K_BODY = 2e5, C_BODY = 1.2e4; // 車体が地面に当たったときに押し返すばね N/m と減衰 N·s/m(1 点あたり)// 路面の摩擦係数 μ: JAF の急ブレーキの試験で、止まるまでの距離から求めた平均の減速 ÷ 重力加速度(雪と氷は ABS が働く試験)const ROADS: Record<string, number> = {  dry: 0.83,   // 乾いた舗装: 時速 60 km から 17.0 m  snow: 0.31,  // 踏み固められた雪: 時速 40 km から 20.2 m  ice: 0.075,  // 凍った路面(氷盤): 時速 40 km から 84.1 m};const H_STEP = 1 / 240;         // 運動を進める刻み 秒// 地形: 160 m 四方を 0.5 m の格子で持つconst WORLD = 160, N = 320, NV = N + 1, DX = WORLD / N, HALF = WORLD / 2;// 車体の前の端と後ろの端(重心から)m。全長 4.495 mconst XF = LA + 0.95, XR = -LB - 0.905;// 車体が地面に当たるか調べる 14 点(重心からの前後・地面からの高さ・左右)m。床の下の 3 点はタイヤより内側に置くconst HULL: [number, number, number][] = [];for (const z of [-0.86, 0.86]) HULL.push([XF, 0.25, z * 0.75], [XR, 0.28, z * 0.75], [0, 0.15, z * 0.65], [XF, 0.72, z], [XR, 0.85, z], [-0.75, 1.4, z * 0.78], [0.2, 1.43, z * 0.78]);

ばねは、車体が1秒に1.3回上下に揺れる固さにした。車輪1つが受け持つ質量をとすると、ばね定数はで、止まっているときの縮みは、約0.15 mになる。前輪と後輪で受け持つ重さは違うが、縮みはどちらも同じだ。

路面のは、JAFの急ブレーキの試験の止まるまでの距離から、平均の減速を重力加速度で割って求めた。時速60 km(秒速16.7 m)から17.0 mで止まった乾いた舗装なら0.83になる。

地面は0.5 mごとの高さの表

地面は、160 m四方を0.5 mのますに分けた321×321個の高さで持つ。ますの中の高さは、4すみの高さから双一次の補間で求め、地面の向き(法線)は、0.35 m離れた4点の高さの差から出す。地形の外へ出た所は、端の高さのまま平らに続くとした。

地形(src/sims/car-terrain.ts の 68〜122 行)
type Kind = "hill" | "down" | "kick" | "bank";/** 区間 [−a, a] ではそのまま、その外の幅 w でなめらかに傾きを 0 にする(坂の上と下を平らにする) */function ramp(u: number, a: number, w: number) {  const x = Math.abs(u), sg = u < 0 ? -1 : 1;  if (x <= a) return u;  const d = Math.min(x - a, w);  return sg * (a + d - (d * d) / (2 * w));}/** 地面の高さ m。th は坂の傾き(rad) */function terrainHeight(kind: Kind, th: number, x: number, z: number, extra: boolean): number {  const t = Math.tan(th);  if (kind === "hill") {    // +z の向きへ下る斜面(z = −40〜40 m)。最初の画面では、右手にでこぼこ、左手に小山を足す    let h = -t * ramp(z, 28, 12);    if (extra) {      const wx = clamp(3 - Math.abs(x - 36) / 4, 0, 1), wz = clamp(3 - Math.abs(z) / 4, 0, 1);      h += 0.14 * Math.sin(x * 2.6) * Math.sin(z * 2.1) * wx * wz;      h += 1.6 * Math.exp(-((x + 36) ** 2 + z * z) / 60);    }    return h;  }  if (kind === "down") return -t * ramp(x + 12, 34, 10);  // +x の向きへ下る坂(x = −46〜22 m)  if (kind === "kick") return x >= 0 && x <= 6 ? x * t : 0; // 長さ 6 m の上り坂の先が、段差で切れている  return clamp(Math.hypot(x, z) - 24, 0, 12) * t;          // バンク: 中心から 24〜36 m の輪が、外へ向かって上がる}function buildHeights(H: Float32Array, kind: Kind, th: number, extra: boolean) {  for (let j = 0; j <= N; j++) for (let i = 0; i <= N; i++) H[j * NV + i] = terrainHeight(kind, th, -HALF + i * DX, -HALF + j * DX, extra);}/** 格子の高さを双一次で補間する。地形の外は端の高さのまま平らに続く */function heightAt(H: Float32Array, x: number, z: number) {  if (!(Math.abs(x) < 1e6) || !(Math.abs(z) < 1e6)) return 0;  const fx = clamp((x + HALF) / DX, 0, N - 1e-6), fz = clamp((z + HALF) / DX, 0, N - 1e-6);  const i = fx | 0, j = fz | 0, tx = fx - i, tz = fz - j, k = j * NV + i;  const a = H[k], b = H[k + 1], c = H[k + NV], d = H[k + NV + 1];  return a + (b - a) * tx + (c - a) * tz + (a - b - c + d) * tx * tz;}/** 地面の向き(上向きの法線)。0.35 m 離れた 4 点の高さの差から */function normalAt(H: Float32Array, x: number, z: number, out: THREE.Vector3) {  const e = 0.35;  const gx = (heightAt(H, x + e, z) - heightAt(H, x - e, z)) / (2 * e), gz = (heightAt(H, x, z + e) - heightAt(H, x, z - e)) / (2 * e);  return out.set(-gx, 1, -gz).normalize();}/** 指で盛る・削る: 半径 3.2 m の中を、中心ほど多く動かす */function sculpt(s: S, x: number, z: number, dh: number) {  const R = 3.2;  const i0 = Math.max(0, Math.floor((x - R + HALF) / DX)), i1 = Math.min(N, Math.ceil((x + R + HALF) / DX));  const j0 = Math.max(0, Math.floor((z - R + HALF) / DX)), j1 = Math.min(N, Math.ceil((z + R + HALF) / DX));  if (i0 > i1 || j0 > j1) return;  for (let j = j0; j <= j1; j++) for (let i = i0; i <= i1; i++) {    const d2 = ((-HALF + i * DX - x) ** 2 + (-HALF + j * DX - z) ** 2) / (R * R);    if (d2 < 1) { const k = j * NV + i; s.H[k] = clamp(s.H[k] + dh * (1 - d2) ** 2, -30, 45); }  }  refreshLand(s, i0, i1, j0, j1);  placePosts(s, x, z, R + 0.3);}

場面ごとの地形は、式1つで書いてある。横に傾いた坂は、中ほどの56 mだけが一様な傾きで、その上と下はなめらかに平らへつながる。指で盛る・削るときは、半径3.2 mの中の高さを、中心ほど大きく動かす。変えた範囲の番号を描画に渡し、そこだけ網目を書き換える。

車輪の下へ線を下ろし、タイヤの力を摩擦円に収める

ばねの縮みは、車体のばねの取り付け点から、車体の下向きに線を下ろして測る。車輪は半径の円板で、地面を平面とみなすと、中心は真下の地面よりだけ上にある(は地面の法線の上向きの成分)。線を伸ばすと、その先の真下の地面の高さも変わる。そこで、地面の傾きを入れて長さを直す計算(ニュートン法)を3回くり返す。傾きを入れないと、急な坂で谷側のばねの縮みを小さく見積もり、車体が谷側へ沈みすぎる。段差の角に乗るときのために、車輪の前後6点の高さも調べ、円が地面に触れるいちばん高い中心の位置を使う。こうすると、丸い車輪が段差の角を転がって乗り越える。

車輪とタイヤ(src/sims/car-terrain.ts の 124〜238 行)
interface Wheel {  front: boolean;  mb: THREE.Vector3;        // ばねの取り付け点(車体に固定した座標。重心が原点・前が +x・上が +y・右が +z)  k: number; c: number;     // ばね定数 N/m・減衰係数 N·s/m  len: number;              // 取り付け点から車輪の中心までの長さ m  x: number; xPrev: number; // ばねの縮み m  Fs: number;               // ばねとダンパーの力 N  Fz: number; Fx: number; Fy: number; Fmax: number; // 地面を押す力・前後と横(左が +)のタイヤの力・摩擦円の半径 N  dy: number; dx: number;   // タイヤの横のたわみ・ブレーキで止まっているときの前後のたわみ m  contact: boolean; sliding: boolean; locked: boolean; slipV: number;  spin: number;             // 車輪の回った角度 rad(絵のため)  C: THREE.Vector3; P: THREE.Vector3; n: THREE.Vector3; hw: THREE.Vector3; lw: THREE.Vector3; // 中心・接点・地面の向き・タイヤの前と左  mark: THREE.Vector3; markOn: boolean;  pivot: THREE.Object3D; spinner: THREE.Object3D;}const EDGE = [-0.8, -0.5, -0.25, 0.25, 0.5, 0.8]; // 段差の角を探す、車輪の前後の点(半径に対する割合)const _M = new THREE.Vector3(), _C = new THREE.Vector3(), _d = new THREE.Vector3(), _hd = new THREE.Vector3(), _fk = new THREE.Vector3();const _vc = new THREE.Vector3(), _rel = new THREE.Vector3(), _f = new THREE.Vector3(), _n = new THREE.Vector3(), _vt = new THREE.Vector3(), _tq = new THREE.Vector3(); /** ばねの縮みを測る: 取り付け点から車体の下向きに線を下ろし、車輪(半径 R_W の円板)が地面に触れる長さを求める */function measure(s: S, wh: Wheel, steer: number) {  _M.copy(wh.mb).applyQuaternion(s.q).add(s.p);  _d.copy(s.up).negate();  wh.contact = false; wh.x = 0; wh.len = S_MAX;  wh.C.copy(_M).addScaledVector(_d, S_MAX);  if (s.up.y < 0.2) return; // 大きく傾いたら車輪は地面に届かない(車体の角で支える)  _fk.copy(s.fwd).multiplyScalar(Math.cos(steer)).addScaledVector(s.right, -Math.sin(steer));  _hd.set(_fk.x, 0, _fk.z);  if (_hd.lengthSq() < 1e-6) _hd.set(1, 0, 0);  _hd.normalize();  let len = S_MAX;  for (let it = 0; it < 3; it++) {    _C.copy(_M).addScaledVector(_d, len);    normalAt(s.H, _C.x, _C.z, wh.n);    // 地面を平面とみなすと、車輪の中心は真下の地面から R_W / n_y の高さにある    let need = heightAt(s.H, _C.x, _C.z) + R_W / Math.max(0.3, wh.n.y);    for (const e of EDGE) {      const hh = heightAt(s.H, _C.x + _hd.x * e * R_W, _C.z + _hd.z * e * R_W) + R_W * Math.sqrt(1 - e * e);      if (hh > need) need = hh;    }    // 中心の高さが need にそろう長さへ進める。線を伸ばすと真下の地面の高さも変わるので、地面の傾きを入れて割る    // (傾きを入れないと急な坂で収まらず、谷側のばねの縮みを小さく見積もる)    len += ((_C.y - need) * wh.n.y) / Math.max(0.2, s.up.dot(wh.n));  }  wh.len = clamp(len, S_MAX - 0.36, S_MAX);  wh.x = S_MAX - wh.len;  wh.contact = wh.x > 0;  wh.C.copy(_M).addScaledVector(_d, wh.len);}/** ばねとダンパーの力 F = k x + c dx/dt。スタビライザーは、同じ軸の左右がどちらも地面に触れているときだけ働く */function springs(s: S, h: number) {  for (const wh of s.wheels) {    if (!wh.contact) { wh.Fs = 0; wh.xPrev = 0; continue; }    const xd = clamp((wh.x - wh.xPrev) / h, -4, 4);    wh.Fs = wh.k * wh.x + wh.c * xd + (wh.x > X_BUMP ? K_BUMP * (wh.x - X_BUMP) : 0);    wh.xPrev = wh.x;  }  for (let a = 0; a < 2; a++) {    const L = s.wheels[a * 2], R = s.wheels[a * 2 + 1];    if (L.contact && R.contact) { const f = K_ARB[a] * (L.x - R.x); L.Fs += f; R.Fs -= f; }  }  for (const wh of s.wheels) wh.Fs = Math.max(0, wh.Fs);}/** タイヤの力: 横はたわみから、前後は駆動とブレーキから求め、合わせた大きさを摩擦円 μN の中に収めて、接点にかける */function tire(s: S, wh: Wheel, steer: number, drive: number, brake: number, h: number) {  wh.Fx = wh.Fy = wh.Fz = wh.Fmax = 0; wh.sliding = wh.locked = false; wh.slipV = 0;  if (!wh.contact) { wh.dy = 0; wh.dx = 0; return; }  const n = wh.n;  wh.P.copy(wh.C).addScaledVector(n, -R_W);  const Fz = wh.Fs * Math.max(0, s.up.dot(n)), Fmax = s.mu * Fz;  wh.Fz = Fz; wh.Fmax = Fmax;  // 地面に沿ったタイヤの前(hw)と左(lw)  _fk.copy(s.fwd).multiplyScalar(Math.cos(steer)).addScaledVector(s.right, -Math.sin(steer));  wh.hw.copy(_fk).addScaledVector(n, -_fk.dot(n)).normalize();  wh.lw.crossVectors(n, wh.hw);  // 接点が地面に対して動く速さ v + ω × r  _rel.subVectors(wh.P, s.p);  _vc.crossVectors(s.w, _rel).add(s.vel);  const vx = _vc.dot(wh.hw), vy = _vc.dot(wh.lw), va = Math.abs(vx);  const kT = (C_ALPHA * Fz) / SIGMA;                                  // たわみ 1 m あたりの力 N/m  const cLow = Math.sqrt((kT * MASS) / 4) * Math.max(0, 1 - va / 3); // 止まりかけのときだけ効かせる減衰  // 横: たわみは横にずれる速さで増え、転がった距離 σ ごとに戻る。速いときは F = −Cα × (すべり角) に近づく  wh.dy = (wh.dy + vy * h) / (1 + (va * h) / SIGMA);  let Fy = -kT * wh.dy - cLow * vy;  // 前後: 駆動の力と転がり抵抗とブレーキ  let Fx = drive - C_RR * Fz * Math.tanh(vx / 0.3);  const B = s.abs ? Math.min(brake, 0.95 * Fmax) : brake;  const vpl = Math.hypot(vx, vy);  if (!s.abs && B > Fmax && vpl > 0.5) {    // ロック: 車輪が止まり、タイヤは接点の動きと逆の向きに μN でこすられる。横の力を別に出す余裕はない    wh.locked = wh.sliding = true; wh.slipV = vpl; wh.dy = 0; wh.dx = 0;    Fx = (-Fmax * vx) / vpl; Fy = (-Fmax * vy) / vpl;  } else {    if (B > 0) {      // ブレーキ: 止まっている車輪はたわみのばねで支える(坂の途中で止まっていられる)。B を超える力は出ない      wh.dx += vx * h;      let Fb = -kT * wh.dx - cLow * vx;      if (Math.abs(Fb) > B) { Fb = Math.sign(Fb) * B; wh.dx = -Fb / Math.max(1, kT); }      Fx += Fb;    } else wh.dx = 0;    // 摩擦円: 前後と横を合わせた力は μN までしか出ない。超えたら向きはそのままで大きさを μN にする(すべる)    const Ft = Math.hypot(Fx, Fy);    if (Ft > Fmax) {      const k = Fmax / Ft; Fx *= k; Fy *= k;      wh.dy = -Fy / Math.max(1, kT);      wh.sliding = true; wh.slipV = Math.abs(vy);    }  }  wh.Fx = Fx; wh.Fy = Fy;  // 地面から受ける力は、地面に垂直な N と、地面に沿ったタイヤの力の和。  // ばねの力を車体の上向きのまま足すと、車体が傾いた分だけ地面に沿った力が摩擦円の外で増えるので、垂直な分だけを使う  _f.copy(n).multiplyScalar(Fz).addScaledVector(wh.hw, Fx).addScaledVector(wh.lw, Fy);  addForce(s, _f, wh.P);  wh.spin = (wh.spin + (wh.locked ? 0 : (vx / R_W) * h)) % (2 * Math.PI);}

ばねの力はで、左右の縮みの差に逆らうスタビライザーと、縮み切ったときに当たる硬いゴムを足す。タイヤが地面を押す力は、この力の地面に垂直な成分である。車体に足すのは、このと、地面に沿ったタイヤの力だけだ。ばねの力を車体の上向きのまま足すと、車体が傾いた分だけ地面に沿った力が摩擦円の外で生まれ、がに届く前に車がすべり出してしまう。

タイヤの横の力は、たわみから求める。は横にずれる速さで増え、タイヤが距離(緩和長)転がるごとに元へ戻る。走っているときはがに落ち着き、力は、つまり横すべり角に比例する力(コーナリングフォース)になる。止まっているときはがそのままばねになるので、横に傾いた坂の途中でも車がずり落ちない。ブレーキで止まっている車輪の前後の力も、同じばねの形で求める。

最後に、前後の力と横の力を合わせた大きさをと比べる。超えていたら、向きはそのままで大きさをに縮める。本文のが、この数行にあたる。ABSが無いときにブレーキの力がを超えると車輪がロックし、タイヤは接点が動く向きの逆へでこすられるだけになる。

1つの剛体として動かす

4つの接点の力と車体の重さを足して、重心を動かす。力が重心からだけ離れた点にかかれば、重心のまわりにのトルクも生まれる。荷重の移動を表す式は書いていない。ブレーキの力は地面の高さにかかり、重心はその0.54 m上にあるので、トルクが車体を前へ傾け、前輪のばねを縮めて前輪のを増やす。

剛体の運動(src/sims/car-terrain.ts の 240〜310 行)
/** 力 f を点 at にかける: 重心を動かす力と、重心のまわりに回す力(トルク r × f)に分けて足す */function addForce(s: S, f: THREE.Vector3, at: THREE.Vector3) {  s.F.add(f); s.Fc.add(f);  _rel.subVectors(at, s.p);  s.T.add(_tq.crossVectors(_rel, f));}/** 車体の角が地面に入ったら、硬いばねで押し返し、こする向きと逆に摩擦をかける */function hull(s: S) {  const mub = Math.min(0.5, s.mu);  for (const pb of s.hullB) {    _C.copy(pb).applyQuaternion(s.q).add(s.p);    const gh = heightAt(s.H, _C.x, _C.z);    if (_C.y > gh + 0.02) continue;    normalAt(s.H, _C.x, _C.z, _n);    const depth = (gh - _C.y) * _n.y;    if (depth <= 0) continue;    _rel.subVectors(_C, s.p);    _vc.crossVectors(s.w, _rel).add(s.vel);    const vn = _vc.dot(_n), Fn = Math.max(0, K_BODY * depth - C_BODY * vn);    _vt.copy(_vc).addScaledVector(_n, -vn);    _f.copy(_n).multiplyScalar(Fn).addScaledVector(_vt, (-mub * Fn) / Math.max(_vt.length(), 0.3));    addForce(s, _f, _C);    s.bodyHit = true;  }}const _ng = new THREE.Vector3(), _rg = new THREE.Vector3();const _qi = new THREE.Quaternion(), _wb = new THREE.Vector3(), _tb = new THREE.Vector3(), _Lb = new THREE.Vector3(), _gy = new THREE.Vector3();/** 速さと位置、回る速さと向きを 1 刻み進める(半陰的オイラー法: 先に速さを、新しい速さで位置を進める) */function integrate(s: S, h: number) {  const { p, vel, q, w } = s;  vel.addScaledVector(s.F, h / MASS);  p.addScaledVector(vel, h);  // 車体に固定した座標では慣性モーメントが対角になるので、オイラーの運動方程式 I dω/dt = τ − ω × (Iω) で回す速さを進める  _qi.copy(q).invert();  _wb.copy(w).applyQuaternion(_qi); _tb.copy(s.T).applyQuaternion(_qi);  _Lb.set(_wb.x * INERTIA.x, _wb.y * INERTIA.y, _wb.z * INERTIA.z);  _gy.crossVectors(_wb, _Lb);  _wb.x += (h * (_tb.x - _gy.x)) / INERTIA.x;  _wb.y += (h * (_tb.y - _gy.y)) / INERTIA.y;  _wb.z += (h * (_tb.z - _gy.z)) / INERTIA.z;  // 置いた直後の 1 秒は、横と前後に揺れる回転を強く止める(ばねが釣り合う前の揺れで転ばないように)  if (s.tPhys - s.tPlace < 1) { const d = Math.exp(-h * 6); _wb.x *= d; _wb.z *= d; }  w.copy(_wb).applyQuaternion(q);  // 向き(四元数): dq/dt = ½ (ω, 0) q  const ax = w.x, ay = w.y, az = w.z, bx = q.x, by = q.y, bz = q.z, bw = q.w, k = 0.5 * h;  q.set(bx + k * (ax * bw + ay * bz - az * by), by + k * (ay * bw + az * bx - ax * bz), bz + k * (az * bw + ax * by - ay * bx), bw - k * (ax * bx + ay * by + az * bz)).normalize();}/** 1 刻み: 運転 → 4 輪のばね → タイヤ → 車体の角 → 運動を進める */function physicsStep(s: S, api: Api, h: number) {  s.fwd.set(1, 0, 0).applyQuaternion(s.q); s.up.set(0, 1, 0).applyQuaternion(s.q); s.right.set(0, 0, 1).applyQuaternion(s.q);  control(s, h);  s.F.set(0, -MASS * G, 0); s.T.set(0, 0, 0); s.Fc.set(0, 0, 0); s.bodyHit = false;  s.F.addScaledVector(s.vel, -0.5 * RHO_AIR * CDA * s.vel.length());  for (const wh of s.wheels) measure(s, wh, wh.front ? s.delta : 0);  springs(s, h);  for (const wh of s.wheels) tire(s, wh, wh.front ? s.delta : 0, wh.front ? s.driveF / 2 : 0, (s.brakeF * (wh.front ? B_FRONT : 1 - B_FRONT)) / 2, h);  hull(s);  integrate(s, h);  s.tPhys += h;  // 地面から受ける力を、タイヤが触れている地面の横と上に分ける。横 ÷ 上 が μ を超えるとすべり、SSF を超えると転ぶ  _ng.set(0, 0, 0);  for (const wh of s.wheels) if (wh.contact) _ng.add(wh.n);  if (_ng.lengthSq() < 1e-6) _ng.copy(s.up); else _ng.normalize();  _rg.copy(s.right).addScaledVector(_ng, -s.right.dot(_ng)).normalize();  const kf = Math.min(1, h / 0.12);  s.latF += (s.Fc.dot(_rg) - s.latF) * kf;  s.vertF += (s.Fc.dot(_ng) - s.vertF) * kf;  s.roll += (Math.asin(clamp(s.up.dot(_rg), -1, 1)) - s.roll) * kf;  // ばねで車体が地面に対して横に傾いた角度(右が下がると +)  s.ratio = s.vertF > 0.2 * MASS * G ? Math.abs(s.latF) / s.vertF : NaN;  events(s, api, h);}

回る速さは、車体に固定した座標で進める。この座標では慣性モーメントが3つの数で表せるので、オイラーの運動方程式をそのまま使える。向きは四元数で持ち、で進めてから長さを1に戻す。

刻みを1/240秒にしたのは、タイヤのたわみのばねが1輪で18万 N/mほどと硬く、刻みが粗いと計算が暴れるからである。横転して車体が地面に当たったときは、車体の角など14点を硬いばねで押し返す。場面の初めに車を置くと、4輪のばねは同じだけ縮んだ形から始まる。坂ではばねが支える重さが倍になるので、縮みもそのぶん少なくして置く。それでも左右の重さが釣り合うまでに車体が揺れ、それだけで転ぶことがあるので、置いた直後の1秒だけは横と前後に揺れる回転を強く弱めている。横に傾いた坂をまっすぐ走る場面では、タイヤが坂を支えるたわみも先に入れ、車体をそのすべり角だけ山側へ向けてから走り出す。こうしないと、置いた瞬間にタイヤがすべり、より緩い坂でも谷へ流れ出してしまう。

測った値の「横の力 ÷ 支える力」は、4輪が触れている地面の向きを基準にして、地面から受ける力を横と上に分けた比である。ばねで車体が傾いた分は、画面に重ねた図で車体を回して描いている。

決まった操作と、手で運転する操作

場面の決まった操作は、道すじの少し先の点を狙ってハンドルを切る「純追従」という方法で走る。先の点が車から見て角度、距離にあるとき、前輪をだけ切る(はホイールベース)。速さは、つまみの値との差に比例してアクセルとブレーキを踏む。場面ごとに、決まった位置でブレーキを踏んだり、左右に急ハンドルを切ったりする。

運転(src/sims/car-terrain.ts の 312〜430 行)
interface Program { path: "line" | "circle"; ax: number; az: number; R: number; kind: string; phase: number; t0: number }type PadKey = "left" | "right" | "brake" | "accel";const SWERVE = 0.22; // 急ハンドルで前輪を切る角度 rad(約 13°)/** 急ハンドル: 左へ切って 0.6 秒保ち、右へ切り返して 2 秒保つ(前輪の角度 rad) */function fishhook(t: number) {  if (t < 0.25) return (SWERVE * t) / 0.25;  if (t < 0.85) return SWERVE;  if (t < 1.25) return SWERVE * (1 - (2 * (t - 0.85)) / 0.4);  if (t < 3.2) return -SWERVE;  return -SWERVE * Math.max(0, 1 - (t - 3.2) / 0.8);}/** 決まった操作: 道すじ(直線か円)の少し先の点へ向けてハンドルを切り(純追従)、速さを保つ。場面ごとにブレーキや急ハンドルを足す */function autopilot(s: S, v: number): [number, number, number] {  const P = s.prog, p = s.p, ld = clamp(0.8 * Math.abs(v) + 4, 5, 16);  let tx: number, tz: number;  if (P.path === "circle") {    const a = Math.atan2(p.z - P.az, p.x - P.ax) + ld / P.R;    tx = P.ax + P.R * Math.cos(a); tz = P.az + P.R * Math.sin(a);  } else { tx = p.x + ld; tz = P.az; }  const fl = Math.hypot(s.fwd.x, s.fwd.z) || 1, hx = s.fwd.x / fl, hz = s.fwd.z / fl;  const rx = tx - p.x, rz = tz - p.z, a = Math.atan2(rx * hz - rz * hx, rx * hx + rz * hz);  let steer = clamp(Math.atan((2 * WB * Math.sin(a)) / ld), -STEER_MAX, STEER_MAX);  let thr = clamp((s.vT - v) * 0.6, 0, 1), brk = clamp((v - s.vT - 0.5) * 0.4, 0, 0.6);  const t = s.tPhys;  if (P.phase === 0) {    if ((P.kind === "slope" && p.x > 50) || (P.kind === "ice" && p.x > -24) || (P.kind === "brake" && p.x > -26) || (P.kind === "jump" && p.x > 26)) P.phase = 1;    if (P.kind === "swerve" && p.x > -32) { P.phase = 1; P.t0 = t; }  }  if (P.phase === 1) {    if (P.kind === "swerve" && t - P.t0 < 4) { steer = fishhook(t - P.t0); thr = 0; brk = 0; }    else { thr = 0; brk = P.kind === "brake" ? 1 : P.kind === "ice" ? 0.5 : 0.6; }  }  return [thr, brk, steer];}/** 運転: 手で運転しているときはキーと画面のボタン、そうでなければ決まった操作。ハンドルは少しずつ切る */function control(s: S, h: number) {  const v = s.vel.dot(s.fwd);  let thr = 0, brk = 0, steer = 0;  if (s.manual) {    const up = s.keys.up || s.pad.accel, dn = s.keys.down || s.pad.brake;    if (up && dn) brk = 1;    else if (up) { if (v < -0.5) brk = 1; else thr = 1; }    else if (dn) { if (v > 0.5) brk = 1; else thr = -0.5; }  // 止まってから押し続けると後ろへ進む    else if (Math.abs(v) < 0.4) brk = 0.35;                   // 止まったら、ブレーキで止めておく    const st = (s.keys.left || s.pad.left ? 1 : 0) - (s.keys.right || s.pad.right ? 1 : 0);    steer = (st * STEER_MAX) / (1 + (v / 8) ** 2);           // 速いほど小さく切る  } else [thr, brk, steer] = autopilot(s, v);  const rate = s.manual ? 1.6 : 5;  s.delta += clamp(steer - s.delta, -rate * h, rate * h);  s.driveF = thr * Math.min(F_DRIVE, P_MAX / Math.max(Math.abs(v), 1)) * (Math.abs(v) > 34 && thr * v > 0 ? 0 : 1);  s.brakeF = brk * B_MAX * MASS * G;}function takeOver(api: Api, s: S) {  if (s.manual) return;  s.manual = true;  api.note("手で運転しています。場面のボタンを押すと、決まった操作に戻ります", 2600);}/** 画面の下の運転のボタンの位置(左右のハンドルとブレーキ・アクセル) */function padRects(api: Api): Record<PadKey, [number, number, number, number]> {  const P = api.u(56), m = api.u(14), g = api.u(8), y = api.h - m - P;  return { left: [m, y, P, P], right: [m + P + g, y, P, P], brake: [api.w - m - 2 * P - g, y, P, P], accel: [api.w - m - P, y, P, P] };}/** 運転のボタンは描画面に直接付け、視点を回す操作より先に受ける(2 本の指でハンドルとアクセルを同時に押せる) */function bindPads(api: Api, s: S) {  const el = api.ctx.canvas;  const at = (e: PointerEvent) => { const b = el.getBoundingClientRect(); return [e.clientX - b.left, e.clientY - b.top]; };  const which = (x: number, y: number): PadKey | null => {    const r = padRects(api);    for (const k of ["left", "right", "brake", "accel"] as PadKey[]) { const [a, b, w, h] = r[k]; if (x >= a && x <= a + w && y >= b && y <= b + h) return k; }    return null;  };  const down = (e: PointerEvent) => {    const [x, y] = at(e), k = which(x, y);    if (!k) return;    e.stopImmediatePropagation(); e.preventDefault();    try { el.setPointerCapture(e.pointerId); } catch { /* 取れなくても押した状態は保つ */ }    s.padIds.set(e.pointerId, k); s.pad[k] = true; takeOver(api, s);  };  const move = (e: PointerEvent) => { if (s.padIds.has(e.pointerId)) e.stopImmediatePropagation(); };  const up = (e: PointerEvent) => {    const k = s.padIds.get(e.pointerId);    if (!k) return;    e.stopImmediatePropagation();    s.padIds.delete(e.pointerId);    s.pad[k] = [...s.padIds.values()].includes(k);  };  el.addEventListener("pointerdown", down, true); el.addEventListener("pointermove", move, true);  el.addEventListener("pointerup", up, true); el.addEventListener("pointercancel", up, true);  return () => {    el.removeEventListener("pointerdown", down, true); el.removeEventListener("pointermove", move, true);    el.removeEventListener("pointerup", up, true); el.removeEventListener("pointercancel", up, true);  };}/** 出来事の知らせ: 片側の 2 輪が浮く・横転・横すべり・ロック・4 輪が浮く・着地 */function events(s: S, api: Api, h: number) {  const ev = s.ev, W = s.wheels;  let n = 0, nsl = 0;  for (const w of W) { if (w.contact) n++; if (w.sliding && !w.locked) nsl++; }  const say = (t: string, ms = 2400) => { api.note(t, ms); ev.last = s.tPhys; };  if (s.up.y < 0.3) { if (!ev.roll) { ev.roll = true; say("横転しました", 3000); } }  else if (n === 2 && W[0].contact === W[2].contact && W[0].contact !== W[1].contact && !ev.lift) {    ev.lift = true; say(`${W[0].contact ? "右" : "左"}側の2輪が地面を離れました。重さがすべて反対側の2輪にかかっています`, 3000);  }  if (n === 0 && !s.bodyHit) s.air += h;  else {    if (s.air > 0.2 && !ev.roll) { ev.land = 0.4; ev.peak = 0; ev.flight = s.air; } // 横転したあとの跳ね返りは知らせない    s.air = 0;  }  if (ev.land > 0) {    for (const w of W) ev.peak = Math.max(ev.peak, w.Fz / ((MASS * G * (w.front ? FRONT : 1 - FRONT)) / 2));    ev.land -= h;    if (ev.land <= 0) say(`${fmt(ev.flight, 2)}秒飛んで着地。いちばん重い車輪に、止まっているときの${fmt(ev.peak, 1)}倍の重さがかかりました`, 3600);  }  const vlat = Math.abs(s.vel.dot(s.right));  ev.slide = clamp(ev.slide + (vlat > 0.8 && nsl >= 2 ? h : -h), 0, 1);  if (ev.slide > 0.3 && s.tPhys - ev.slideT > 6 && s.tPhys - ev.last > 1.2 && s.up.y > 0.5) { ev.slideT = s.tPhys; say("タイヤの力が摩擦円の端に届き、車が横へすべっています"); }  if (!ev.lock && (W[0].locked || W[1].locked || W[2].locked || W[3].locked)) { ev.lock = true; say("ブレーキで車輪がロックしました。タイヤはこすられる向きにしか力を出せません", 3000); }  if (!ev.scrape && s.bodyHit && s.up.y > 0.7 && n > 0) { ev.scrape = true; if (s.tPhys - ev.last > 0.8) say("車体の底が地面に当たりました"); }}

キーか画面のボタンを押すと、手で運転する操作に切り替わる。画面のボタンは、視点を回す操作より先に指を受けるように描画面に付けてあるので、2本の指でハンドルとアクセルを同時に押せる。速いほどハンドルを小さく切るのは、キーでの運転で急に曲がりすぎないためだ。

three.jsが受け持つ描画

地面は、three.jsの平面を高さの表のとおりに曲げた網目で描く。画質が低いときは1 mのますに粗くするが、計算に使う高さはいつも0.5 mのままだ。車体は横から見た形を押し出して作り、車輪の部分は、ハンドルの角度で回る部品と、転がる角度で回る部品の2段に分けてある。

three.jsで描く(src/sims/car-terrain.ts の 432〜580 行)
interface Land { mesh: THREE.Mesh; geo: THREE.BufferGeometry; lines: THREE.LineSegments; lineH: Int32Array; step: number; n: number }/** 地面の網目(画質 0 は 1 m、それ以外は 0.5 m のます)と、5 m ごとの線を作る。計算に使う高さはいつも 0.5 m の格子 */function makeLand(api: Api, s: S) {  if (s.land) { s.v.scene.remove(s.land.mesh, s.land.lines); s.land.geo.dispose(); s.land.lines.geometry.dispose(); }  const step = api.quality === 0 ? 2 : 1, n = N / step;  const g = heightGrid(n, n, WORLD, WORLD, s.mats.land);  g.mesh.frustumCulled = false;  const L = WORLD / 5 + 1, nv = L * (n + 1) * 2, lp = new Float32Array(nv * 3), lineH = new Int32Array(nv), idx: number[] = [];  let k = 0;  for (let dir = 0; dir < 2; dir++) for (let l = 0; l < L; l++) for (let a = 0; a <= n; a++) {    const i = dir === 0 ? a * step : l * 10, j = dir === 0 ? l * 10 : a * step;    lineH[k] = j * NV + i; lp[k * 3] = -HALF + i * DX; lp[k * 3 + 2] = -HALF + j * DX;    if (a > 0) idx.push(k - 1, k);    k++;  }  const lg = new THREE.BufferGeometry();  lg.setAttribute("position", new THREE.BufferAttribute(lp, 3)); lg.setIndex(idx);  const lines = new THREE.LineSegments(lg, s.mats.line); lines.frustumCulled = false;  s.land = { mesh: g.mesh, geo: g.geo, lines, lineH, step, n };  s.v.scene.add(g.mesh, lines);  refreshLand(s, 0, N, 0, N);}/** 高さが変わった範囲(格子の番号)だけ、網目の頂点と面の向き、線を書き換える */function refreshLand(s: S, i0: number, i1: number, j0: number, j1: number) {  const L = s.land;  if (!L) return;  const st = L.step, n = L.n, H = s.H;  const pos = L.geo.attributes.position as THREE.BufferAttribute, nor = L.geo.attributes.normal as THREE.BufferAttribute;  const a0 = Math.max(0, Math.floor(i0 / st) - 1), a1 = Math.min(n, Math.ceil(i1 / st) + 1);  const b0 = Math.max(0, Math.floor(j0 / st) - 1), b1 = Math.min(n, Math.ceil(j1 / st) + 1);  const hv = (a: number, b: number) => H[clamp(b, 0, n) * st * NV + clamp(a, 0, n) * st], d2 = 2 * st * DX;  for (let b = b0; b <= b1; b++) for (let a = a0; a <= a1; a++) {    const k = b * (n + 1) + a, gx = (hv(a + 1, b) - hv(a - 1, b)) / d2, gz = (hv(a, b + 1) - hv(a, b - 1)) / d2, l = 1 / Math.hypot(gx, 1, gz);    pos.setY(k, hv(a, b)); nor.setXYZ(k, -gx * l, l, -gz * l);  }  const r0 = b0 * (n + 1) * 3, cnt = (b1 - b0 + 1) * (n + 1) * 3;  pos.addUpdateRange(r0, cnt); nor.addUpdateRange(r0, cnt); pos.needsUpdate = true; nor.needsUpdate = true;  const la = L.lines.geometry.attributes.position as THREE.BufferAttribute, arr = la.array as Float32Array;  for (let k = 0; k < L.lineH.length; k++) {    const hi = L.lineH[k], i = hi % NV, j = (hi / NV) | 0;    if (i >= i0 - 2 && i <= i1 + 2 && j >= j0 - 2 && j <= j1 + 2) arr[k * 3 + 1] = H[hi] + 0.05;  }  la.needsUpdate = true;}/** 車の形: 横から見た形を押し出した車体(左右のタイヤの間)・車輪の所を切り欠いたフェンダー・客室・4 つの車輪 */function makeCar(s: S) {  const car = new THREE.Group(), shell = new THREE.Group();  car.add(shell); shell.position.y = -s.h;  const ext = (pts: [number, number][], depth: number, z0: number, mat: THREE.Material) => {    const g = new THREE.ExtrudeGeometry(new THREE.Shape(pts.map(([x, y]) => new THREE.Vector2(x, y))), { depth, bevelEnabled: false, curveSegments: 4 });    g.translate(0, 0, z0);    const m = new THREE.Mesh(g, mat); m.castShadow = true; m.receiveShadow = true; shell.add(m);  };  const top: [number, number][] = [[XF, 0.32], [XF, 0.62], [XF - 0.2, 0.74], [0.85, 0.9], [XR + 0.3, 0.93], [XR, 0.8], [XR, 0.36]];  ext([[XR + 0.2, 0.17], [XF - 0.25, 0.17], ...top], 1.3, -0.65, s.mats.body);  const fender: [number, number][] = [[XR + 0.2, 0.17]];  for (const cx of [-LB, LA]) {    fender.push([cx - 0.39, 0.17]);    for (let i = 0; i <= 10; i++) { const a = Math.PI - (i / 10) * Math.PI; fender.push([cx + 0.39 * Math.cos(a), 0.31 + 0.39 * Math.sin(a)]); }    fender.push([cx + 0.39, 0.17]);  }  fender.push([XF - 0.25, 0.17], ...top);  ext(fender, 0.2225, 0.65, s.mats.body); ext(fender, 0.2225, -0.8725, s.mats.body);  ext([[-1.0, 0.9], [0.85, 0.9], [0.15, 1.43], [-0.72, 1.4]], 1.36, -0.68, s.mats.glass);  const tireG = new THREE.CylinderGeometry(R_W, R_W, 0.205, 20, 1); tireG.rotateX(Math.PI / 2);  const capG = new THREE.BoxGeometry(0.44, 0.08, 0.215);  for (const wh of s.wheels) {    const t = new THREE.Mesh(tireG, s.mats.tire); t.castShadow = true;    wh.spinner.add(t, new THREE.Mesh(capG, s.mats.cap)); wh.pivot.add(wh.spinner); car.add(wh.pivot);  }  s.v.scene.add(car);  return { car, shell };}/** 道の両側の目印の柱(1.5 m)。真上を向いて立つので、車体の傾きと比べられる */function makePosts(s: S) {  if (s.posts) { s.v.scene.remove(s.posts); s.posts.geometry.dispose(); }  const xz: number[] = [], key = s.scene;  if (key === "circle" || key === "bank") {    const [r1, r2, n] = key === "bank" ? [22, 38, 18] : [9, 19, 12];    for (const r of [r1, r2]) for (let i = 0; i < n; i++) { const a = (i / n) * Math.PI * 2; xz.push(r * Math.cos(a), r * Math.sin(a)); }  } else for (let x = -70; x <= 70; x += 10) { xz.push(x, -6); if (key !== "brake" && key !== "jump") xz.push(x, 6); } // 横から見る場面は、カメラの側に柱を立てない  const g = new THREE.BoxGeometry(0.12, 1.5, 0.12); g.translate(0, 0.75, 0);  s.posts = new THREE.InstancedMesh(g, s.mats.post, xz.length / 2);  s.posts.castShadow = true; s.posts.frustumCulled = false;  s.postXZ = xz;  s.v.scene.add(s.posts);  placePosts(s, 0, 0, 1e9);}const _pm = new THREE.Matrix4();function placePosts(s: S, x: number, z: number, r: number) {  if (!s.posts) return;  for (let i = 0; i < s.postXZ.length / 2; i++) {    const px = s.postXZ[i * 2], pz = s.postXZ[i * 2 + 1];    if ((px - x) ** 2 + (pz - z) ** 2 > r * r) continue;    s.posts.setMatrixAt(i, _pm.makeTranslation(px, heightAt(s.H, px, pz), pz));  }  s.posts.instanceMatrix.needsUpdate = true;}const SKID = 2400; // タイヤの跡として残す四角の数/** すべったタイヤの跡: 地面に貼った細い四角を、古いものから順に上書きする */function addSkid(s: S, a: THREE.Vector3, b: THREE.Vector3, n: THREE.Vector3) {  _vt.subVectors(b, a); _d.crossVectors(_vt, n); // 進む向き × 上 = 右。三角形を上から見て左回りに並べる(表の面を上へ)  if (_d.lengthSq() < 1e-8) return;  _d.setLength(0.09);  const k = s.skidN % SKID, arr = s.skidPos, o = k * 18, y = 0.03;  const P = [a.x - _d.x, a.y - _d.y + y, a.z - _d.z, a.x + _d.x, a.y + _d.y + y, a.z + _d.z, b.x + _d.x, b.y + _d.y + y, b.z + _d.z,    a.x - _d.x, a.y - _d.y + y, a.z - _d.z, b.x + _d.x, b.y + _d.y + y, b.z + _d.z, b.x - _d.x, b.y - _d.y + y, b.z - _d.z];  for (let i = 0; i < 18; i++) arr[o + i] = P[i];  const at = s.skid.geometry.attributes.position as THREE.BufferAttribute;  at.addUpdateRange(o, 18); at.needsUpdate = true;  s.skidN++;  s.skid.geometry.setDrawRange(0, Math.min(s.skidN, SKID) * 6);}function skids(s: S) {  for (const wh of s.wheels) {    if (!wh.contact || wh.slipV < 1.2) { wh.markOn = false; continue; }    if (!wh.markOn) { wh.mark.copy(wh.P); wh.markOn = true; continue; }    if (wh.mark.distanceToSquared(wh.P) > 0.09) { addSkid(s, wh.mark, wh.P, wh.n); wh.mark.copy(wh.P); }  }}/** 剛体の位置と向き、ばねの長さ・ハンドルの角度・車輪の回転を、絵の部品に写す */function syncCar(s: S) {  s.car.position.copy(s.p); s.car.quaternion.copy(s.q);  for (const wh of s.wheels) {    wh.pivot.position.copy(wh.mb); wh.pivot.position.y -= wh.len;    wh.pivot.rotation.y = wh.front ? s.delta : 0;    wh.spinner.rotation.z = -wh.spin;  }}const CAB_Q = new THREE.Quaternion().setFromEuler(new THREE.Euler(-0.06, -Math.PI / 2, 0, "YXZ"));const _e = new THREE.Vector3();/** 視点: ふだんは車を追う。運転席から見るときは、目の位置にカメラを置いて車体と一緒に傾ける */function updateCamera(api: Api, s: S) {  const o = s.v.orbit;  if (api.bool("cab")) {    o.enabled = false; s.v.fov = 70;    _e.set(-0.3, 1.12 - s.h, 0.36).applyQuaternion(s.q).add(s.p);    s.v.camera.position.copy(_e); s.v.camera.quaternion.copy(s.q).multiply(CAB_Q);    return;  }  o.enabled = true; s.v.fov = 45;  const k = s.camSnap ? 1 : 1 - Math.exp(-api.dt * 4);  if (s.cam.follow) o.target.lerp(_e.set(s.p.x, s.p.y + 0.3, s.p.z), k);  if (s.cam.yaw === null && api.t - s.lastDrag > 5) {    const want = Math.atan2(-s.fwd.x, -s.fwd.z);    o.yaw += wrapAngle(want - o.yaw) * (s.camSnap ? 1 : 1 - Math.exp(-api.dt * 1.5));  }  s.camSnap = false;}

すべったタイヤの跡は、地面に細い四角を貼って残す。四角を入れる配列は最初に作っておき、古い跡から順に上書きするので、毎コマ部品を作ることはない。白黒の網点に直す仕組みは、プログラムのページで説明している。

画面に重ねた図

上から見た図の円の半径は、そのタイヤが地面を押す力に比例する。矢印は、タイヤが今出している力で、円の半径をとみなした長さで描くので、矢印の先が円に届けば、そのタイヤはすべっている。後ろから見た図では、重心から見かけの重力の向きへ線を伸ばし、地面と交わる点が左右のタイヤの外に出たら、線を太くする。

重さと摩擦円の図(src/sims/car-terrain.ts の 582〜694 行)
/** 重心の印(4 つに分けた円の 2 つを塗る) */function cgMark(api: Api, x: number, y: number, r: number) {  api.circle(x, y, r, { fill: "paper", stroke: "ink", w: api.u(1.2) });  for (const q of [0, 2]) {    const pts: Pt[] = [[x, y]];    for (let i = 0; i <= 8; i++) { const a = ((q + i / 8) * Math.PI) / 2; pts.push([x + r * Math.cos(a), y + r * Math.sin(a)]); }    api.poly(pts, { fill: "ink", stroke: false, close: true });  }}/** 重ねて描く図(ふだんは左上): 上から見た 4 輪の摩擦円とタイヤの力、後ろから見た見かけの重力、横の力の割合のものさし */function drawPanel(api: Api, s: S) {  // 画面全体に広げたときは、上の中央に出る知らせと重ならないようにする(横長は少し下げ、縦長は運転のボタンの上に置く)  const u = api.u, x0 = u(12), W = Math.min(api.w - u(24), u(330)), H = u(212), half = W / 2, fs = u(11);  const y0 = !api.full ? u(12) : api.h > api.w * 1.3 ? padRects(api).left[1] - u(36) - H : Math.max(u(12), 58);  api.rect(x0, y0, W, H, { fill: "paper", stroke: "ink", w: u(1) });  api.text("タイヤの力(上から)", x0 + u(10), y0 + u(17), { size: fs });  api.text("見かけの重力(後ろから)", x0 + half + u(6), y0 + u(17), { size: fs });  const areaH = H - u(22) - u(62), cx = x0 + half / 2, cy = y0 + u(24) + areaH / 2, dx = u(34), dy = u(37);  // 上から: 円の半径は μN(地面を押す力 N に比例)、矢印はタイヤが出している力。前が上  const ref = (MASS * G) / 4, kf = u(15) / (Math.max(s.mu, 1e-3) * ref);  api.rect(cx - dx * 0.62, cy - dy * 1.5, dx * 1.24, dy * 2.85, { stroke: "ink", w: u(1), dash: [u(3), u(3)] });  s.wheels.forEach((wh, i) => {    const wx = cx + (i % 2 ? dx : -dx), wy = cy + (i < 2 ? -dy : dy), st = wh.front ? s.delta : 0;    const r = Math.min(u(30), (u(15) * wh.Fz) / ref);    const c = Math.cos(st), sn = Math.sin(st), tw = u(3), tl = u(8);    api.poly([[wx - tw * c - tl * sn, wy - tl * c + tw * sn], [wx + tw * c - tl * sn, wy - tl * c - tw * sn], [wx + tw * c + tl * sn, wy + tl * c - tw * sn], [wx - tw * c + tl * sn, wy + tl * c + tw * sn]], { fill: "ink", stroke: false, close: true });    if (r > u(1)) api.circle(wx, wy, r, { stroke: "ink", w: wh.sliding ? u(2.6) : u(1), dash: wh.locked ? [u(3), u(3)] : undefined });    const ff = wh.Fx * c - wh.Fy * sn, fl = wh.Fx * sn + wh.Fy * c;    if (Math.hypot(ff, fl) * kf > u(3)) api.arrow(wx, wy, wx - fl * kf, wy - ff * kf, { w: u(1.8), head: u(6) });    const ty = i < 2 ? Math.max(y0 + u(32), wy - r - u(4)) : Math.min(y0 + u(24) + areaH + u(4), wy + r + u(12));    api.text(wh.contact ? fmt(wh.Fz / G, 0) : "浮", wx, ty, { size: u(10.5), align: "center", font: "mono" });  });  const vf = s.vel.dot(s.fwd), vl = -s.vel.dot(s.right), sp = Math.hypot(vf, vl);  if (sp > 0.5) api.arrow(cx, cy, cx - (vl / sp) * u(24), cy - (vf / sp) * u(24), { w: u(1.2), head: u(5), dash: [u(2), u(2)] });  // 後ろから: 地面を水平に描く。車体はばねで傾いた分だけ回し、重心から見かけの重力(地面から受ける力の逆向き)を伸ばす  const k2 = Math.min((half * 0.6) / 1.75, (areaH * 0.6) / 1.45), c2 = x0 + half + half / 2, yb = y0 + u(24) + areaH - u(4);  const X = (m: number) => c2 + m * k2, Y = (m: number) => yb - m * k2;  // 片側の 2 輪が浮いたら、地面に残った側のタイヤを軸にして車ごと回す  const WH = s.wheels, liftL = !WH[0].contact && !WH[2].contact, liftR = !WH[1].contact && !WH[3].contact;  const px = liftL !== liftR ? (liftL ? TRACK / 2 : -TRACK / 2) : 0, cr = Math.cos(s.roll), sr = Math.sin(s.roll);  const B = (x: number, y: number): Pt => [X(px + (x - px) * cr + y * sr), Y(y * cr - (x - px) * sr)];  api.line(X(-1.15), yb, X(1.15), yb, { w: u(1) });  api.poly([B(-0.87, 0.17), B(0.87, 0.17), B(0.87, 0.88), B(0.68, 0.9), B(0.55, 1.43), B(-0.55, 1.43), B(-0.68, 0.9), B(-0.87, 0.88)], { fill: "paper", stroke: "ink", w: u(1), close: true });  for (const sg of [-1, 1]) { const c = (sg * TRACK) / 2; api.poly([B(c - 0.1, 0), B(c + 0.1, 0), B(c + 0.1, 2 * R_W), B(c - 0.1, 2 * R_W)], { fill: "ink", stroke: false, close: true }); }  const [gx, gy] = B(0, s.h), [ax, ay] = B(-TRACK / 2, 0), [bx, by] = B(TRACK / 2, 0);  api.line(gx, gy, ax, ay, { w: u(1), dash: [u(3), u(3)] }); api.line(gx, gy, bx, by, { w: u(1), dash: [u(3), u(3)] });  if (Number.isFinite(s.ratio)) {    const L = Math.hypot(s.latF, s.vertF), dr = -s.latF / L, du = -s.vertF / L;    const xg = (gx - c2) / k2, yg = (yb - gy) / k2, xh = clamp(xg + (dr * yg) / -du, -1.6, 1.6), tip = Math.abs(xh) > TRACK / 2;    api.line(gx, gy, X(xh), yb, { w: tip ? u(2.4) : u(1.2) });    api.arrow(gx, gy, gx + dr * u(30), gy - du * u(30), { w: u(2), head: u(7) });    api.dot(X(xh), yb, u(3));    api.text(`${fmt(Math.atan(s.ratio) / DEG, 0)}°`, gx + (dr > 0 ? -u(9) : u(9)), gy - u(9), { size: u(11), align: dr > 0 ? "right" : "left" });  } else api.text("宙に浮いている", c2, Y(1.7), { size: u(10.5), align: "center" });  cgMark(api, gx, gy, u(5));  // ものさし: 横の力 ÷ 支える力。μ を超えるとすべり、SSF を超えると転ぶ  const ssf = TRACK / (2 * s.h), top = Math.max(1.6, ssf * 1.08), gx0 = x0 + u(12), gx1 = x0 + W - u(12), ga = y0 + H - u(34);  const GX = (r: number) => gx0 + (clamp(r, 0, top) / top) * (gx1 - gx0);  api.text(`横の力 ÷ 支える力  ${Number.isFinite(s.ratio) ? fmt(s.ratio, 2) : "-"}`, gx0, ga - u(9), { size: fs });  api.line(gx0, ga, gx1, ga, { w: u(1) });  for (let r = 0; r <= top; r += 0.5) api.line(GX(r), ga - u(3), GX(r), ga + u(3), { w: u(1) });  if (Number.isFinite(s.ratio)) api.line(gx0, ga, GX(s.ratio), ga, { w: u(5) });  const xm = GX(s.mu), xs = GX(ssf), two = Math.abs(xm - xs) < u(78);  api.line(xm, ga - u(7), xm, ga + u(7), { w: u(2) });  api.line(xs, ga - u(7), xs, ga + u(7), { w: u(2) });  const lab = (t: string, x: number, y: number) => api.text(t, clamp(x, gx0 + u(34), gx1 - u(34)), y, { size: u(10.5), align: "center" });  lab(`すべる μ ${fmt(s.mu, s.mu < 0.1 ? 3 : 2)}`, xm, ga + u(17));  lab(`転ぶ SSF ${fmt(ssf, 2)}`, xs, ga + u(two ? 29 : 17));}/** 運転のボタン: 押している間は塗る */function drawPads(api: Api, s: S) {  const r = padRects(api), u = api.u;  const on = (k: PadKey) => s.pad[k] || (k === "left" ? s.keys.left : k === "right" ? s.keys.right : k === "brake" ? s.keys.down : s.keys.up);  for (const k of ["left", "right", "brake", "accel"] as PadKey[]) {    const [x, y, w, h] = r[k], o = on(k), col = o ? "paper" : "ink";    api.rect(x, y, w, h, { fill: o ? "ink" : "paper", stroke: "ink", w: u(1.4) });    if (k === "left" || k === "right") {      const sg = k === "left" ? -1 : 1, mx = x + w / 2, my = y + h / 2, a = w * 0.2;      api.poly([[mx + sg * a, my], [mx - sg * a, my - a], [mx - sg * a, my + a]], { fill: col, stroke: false, close: true });    } else api.text(k === "brake" ? "ブレーキ" : "アクセル", x + w / 2, y + h / 2 + u(4), { size: u(11), align: "center", color: col });  }  api.label(s.manual ? "手で運転" : "決まった操作", r.accel[0] + r.accel[2], r.accel[1] - u(9), { size: u(11), align: "right" });}function drawBrush(api: Api, s: S) {  const b = s.brush, tool = api.str("tool");  if (tool === "view" || !b.on || api.bool("cab") || !(api.pointer.inside || b.down)) return;  const pts: Pt[] = [];  for (let k = 0; k <= 32; k++) {    const a = (k / 32) * Math.PI * 2, x = b.x + 3.2 * Math.cos(a), z = b.z + 3.2 * Math.sin(a);    const q = s.v.project([x, heightAt(s.H, x, z) + 0.05, z]);    if (q.on) pts.push([q.x, q.y]);  }  if (pts.length > 2) api.poly(pts, { w: api.u(1.8), dash: [api.u(5), api.u(4)] });  const c = s.v.project([b.x, heightAt(s.H, b.x, b.z), b.z]);  if (c.on) api.label(tool === "raise" ? "盛る" : "削る", c.x, c.y, { size: api.u(12), align: "center", baseline: "middle" });}function overlay(api: Api, s: S) {  if (!api.bool("cab")) { const c = s.v.project(s.p); if (c.on) cgMark(api, c.x, c.y, api.u(6)); }  drawBrush(api, s);  drawPanel(api, s);  drawPads(api, s);}function readout(api: Api, s: S) {  const W = s.wheels, kg = (w: Wheel) => fmt(w.Fz / G, 0);  const roll = Math.asin(clamp(s.right.y, -1, 1)) / DEG, pitch = Math.asin(clamp(s.fwd.y, -1, 1)) / DEG;  api.readout([    `速さ  ${fmt(Math.abs(s.vel.dot(s.fwd)) / KMH, 0)} km/h`,    `前輪の重さ  左 ${kg(W[0])}・右 ${kg(W[1])} kg`,    `後輪の重さ  左 ${kg(W[2])}・右 ${kg(W[3])} kg`,    `横の力 ÷ 支える力  ${Number.isFinite(s.ratio) ? fmt(s.ratio, 2) : "-"}(μ ${fmt(s.mu, s.mu < 0.1 ? 3 : 2)}・SSF ${fmt(TRACK / (2 * s.h), 2)})`,    `車体の傾き  横 ${fmt(Math.abs(roll), 1)}°・前後 ${fmt(Math.abs(pitch), 1)}°`,  ]);}

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

プログラム全体を見る(src/sims/car-terrain.ts・954 行)

読み込んでいます

この記事は、人が監修しながらAIを活用して執筆しています。執筆・監修: 佐々木 航(株式会社Game)

姉妹サイト

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