速い車は水面を跳ねて滑る。遅くなると水を押しのけて沈む

水が車を押し上げる力は、速さの2乗と、水に触れる底の面積と傾きで決まる。速さが半分になれば力は4分の1になり、車は水を押しのけて浮くだけになって、すき間から入る水で沈む。

記事

速い車は水面を跳ねて滑る。遅くなると水を押しのけて沈む

水が車を押し上げる力は、速さの2乗と、水に触れる底の面積と傾きで決まる。速さが半分になれば力は4分の1になり、車は水を押しのけて浮くだけになって、すき間から入る水で沈む。

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

現象

100 gの石は秒速2 mで跳ねる

平たい石を水面すれすれに投げると、石は沈まずに何度も跳ねる。フランスの物理学者ボケは、大きさ10 cm・重さ100 gの石について、水が石を押し返す力を見積もった1。秒速1 mほどで水面に当たれば、押し返す力が石の重さに届く。水に当たるたびに失う勢いも計算に入れると、少なくとも秒速2 mほどあれば1回は跳ねる。秒速12 mで投げれば、当時の世界記録と同じ38回跳ねる計算になった。

では、重さ1.5 tの車ならどうなるか。石の1万5千倍の重さだが、水が押し返す理屈は変わらない。速く当たるほど、水は強く押し返す。

画面の車は、高さ0.6 mの岸から、40 m先に向こう岸がある池へ水平に飛び出す。時速120 kmで入ると、車は水面をたたいてしぶきを上げ、そのまま水の上を滑って向こう岸に届く。速さを落とすと途中で水の上に乗っていられなくなり、水を押しのけて止まる。止まった車はしばらく浮いているが、すき間から水が入り、やがて沈む。

車を後ろへ引いて離せば、引いた長さで入る速さが決まり、下へ引くほど鼻先が上を向く。水面を指でなぞると、波も立てられる。

仕組み

水は速さの2乗で押し返す

速く動く板が水に当たると、板の前の水は急に押しのけられる。そのとき板にかかる圧力の目安が、動圧である。は水の密度(1,000 kg/m³)、は速さ(m/s)。時速100 km(秒速27.8 m)なら、動圧は約39万 Paになる。一方、1.5 tの車を床下の約8 m²で支えるのに要る圧力は約1,800 Paで、動圧の200分の1ほどにすぎない。速い車なら、底を水平から少し傾けて水をたたくだけで、重さを支えられるわけだ。

水面を滑る船(滑走艇)の底でも、同じことが起きている。底を持ち上げる力(N)は、動圧と、水に触れている底の面積(m²)の積に、傾きで決まる係数を掛けたものになる。飛行機の翼と同じく、この力を揚力と呼ぶ。

サビツキーは1964年に、滑走する板の試験の結果を式にまとめた2。その式では、は底が水面となす角にほぼ比例する。水に触れる部分が長くなるほど1 m²あたりの力は小さくなり、水が最初に当たる前の縁がいちばん強く押される。傾き6°で、長さ2 m・幅1.8 mが水に触れているとき、は約0.08になる。

水面を滑る車(傾きは実際より大きく表示) 重さ W 持ち上げる力 L 底を押す水の力は、前の縁ほど強い 底の板にかかる力の分け方 τ L = ½ρv² S C (持ち上げる力) 水の力(板に垂直) D D = L tan τ(抵抗) 速さ v
水面を滑る車の底にかかる力。力は底に垂直にかかり、上向きの持ち上げる力と後ろ向きの抵抗に分けられる

持ち上げる力が重さ(N)と釣り合う速さが、水の上に乗っていられる最低の速さである。

1.5 tの車(は約14,700 N)を上の数字で計算すると、は秒速約10 m、時速36 kmになる。重さが2倍ならは約1.4倍。ボケも、水切りの石が跳ねるのに要る速さを、同じ形の式で見積もっている。

ただし、水の力は底に垂直にかかる。底がだけ傾いていれば、力は真上から後ろへだけ傾き、進む向きと逆の成分が抵抗になる。

傾き6°なら、抵抗は持ち上げる力の約1割。底が平らでなめらかな滑走艇なら、これに水との摩擦が加わる程度で済む。ところが、ふつうの車の床下には排気管や燃料タンク、足回りが出っ張り、タイヤも水をかく。シミュレーションでは、床下の凸凹をざらざらの板の摩擦として数え、タイヤには前から水が当たる。

速さが落ちると、持ち上げる力は速さの2乗で減っていく。半分の速さなら4分の1。車は深く沈んで水に触れる面が広がり、抵抗も増えて、さらに速さを失う。水の上に乗っていた車は、水を押しのけて進む状態に変わり、すぐに止まる。

止まった車を支えるのは浮力(N)だけになる。浮力は、水面より下にある、空気の入った部分の体積(m³)で決まる。

は重力加速度(9.81 m/s²)。1.5 tの車なら、空気の入った部分が1.5 m³沈めば浮いていられる。ところが車体にはすき間があり、外の水面が車の中の水面より(m)高いと、水は1秒に(m³)の割合で流れ込む。はすき間の面積(m²)で、0.6は小さな穴から水が流れ出るときの流量係数である。中の水が増えたぶん車は沈み、が大きくなって、流れ込む水はさらに増える。

水切りの石とは、もう1つ違いがある。石は投げるときにひねって回すので、こまのように傾きが保たれる。回っていない石は、水に当たった力で傾きが変わり、次の1〜2回でうまく当たらなくなる1。車も回っていないので、水に当たった瞬間の力で鼻先の傾きが変わる。

試す

6つの入り方を試す

画面は池を横から切った断面で、上の帯は池の全体と車の重心が通った道すじ、丸は車が水に入った所を表す。力の矢印は重さを基準にした長さで描かれ、重さの3倍を超える分は切れ目の印を付けて縮めてある。浮いて止まっている間は、時間を6倍に進めて見せる。

  1. 1.2 tの車が、鼻先を4°上げて時速110 kmで入る。車体の後ろから水をたたいた瞬間、持ち上げる力は重さの数倍になり、車は一度跳ねる。そのあとは、持ち上げる力が重さを上回ったり下回ったりして、車は小さく上下しながら水面を滑り、時速60 kmほどを残して向こう岸に届く。
  2. 1.5 tの車が時速50 kmで入ると、水をたたいた衝撃で時速30 kmほどまで遅くなり、水の上に乗っていられる最低の速さ(時速約36 km)を下回る。車は水を押しのけて進み、岸から20 mほどで止まって浮く。すき間から水が入り、外の水が窓の下の縁に届くのは走り出してから40秒あまり。そこから車は鼻先を下にして、一気に沈んでいく。
  3. 同じ時速50 kmでも、2.8 tの車は空気の入った部分に余裕がほとんどない。速さを失ったとたんに鼻先から沈み始め、走り出してから約4秒で窓の上まで水に入る。
  4. 床の下を平らな板でおおうと、時速60 kmでも車は水の上に乗る。重さの約8割を持ち上げる力が、残りの約2割を浮力が支える。凸凹もタイヤも水に触れないので抵抗が小さく、時速40 kmほどで向こう岸に届く。
  5. 底の断面を丸くすると、浅く沈んだときに水に触れる幅が狭く、面も横へ傾く。同じ時速60 kmでも、水に入って1秒もたたないうちに水の上に乗っていられなくなる。車は水を押しのけて進み、向こう岸の3 mほど手前で止まって浮き、やがて沈む。
  6. 鼻先を10°下げて、時速100 kmで入る。鼻先の下の面が水をたたいて押し上げられ、0.2秒ほどで車はほぼ水平に戻る。つまみで鼻先の傾きを6°にして走らせても、そのあとの進み方はほとんど変わらない。回っていない車の傾きは、入るときの角度より水の力で決まるからだ。
仕組み

雨の道路では、タイヤが水に乗る

同じことが、雨の道路を走るタイヤでも起きる。タイヤの前の水は逃げきれず、速く走るほど、その水は動圧でタイヤを押し上げる。タイヤが路面を押す圧力は、おおよそタイヤの空気圧と同じくらいになる。動圧が空気圧に近づくと、タイヤは路面から浮いて水に乗る。これがハイドロプレーニング現象だ。

米国航空宇宙局(NASA)のホーンとドレーアーは、空気圧24〜150 psi(約165〜1,030 kPa)、時速45〜120マイル(約72〜193 km)の範囲でタイヤを試験した。浮き始めるときの、水が押し上げる力の係数は、どの条件でもほぼ0.7だった3。動圧の0.7倍が空気圧に届く速さで浮くので、浮き始める速さは空気圧の平方根に比例する。

の単位はkm/h、はkPaで、230 kPaなら時速約96 kmになる4。ただ、本物の道路では、タイヤの溝が水を逃がし、水の深さでも浮き方が変わる。米国連邦道路庁の報告は、約1,300回の試験をもとに、空気圧に溝の深さ、水の深さ、路面のきめの深さを加えた式を示している4。

小さなシミュレーションでは、浮き始める速さをこの報告の式で求め、接地面のうち水が入りこむ割合をと見積もっている。水が支える力が速さの2乗で増え、で重さのすべてを支える、という考え方である。

溝が7 mm残る新しいタイヤで、水深5 mmの道を時速70 kmで走ると、浮き始める速さは時速約87 km。接地面の約35%が路面に触れている。

溝を1.6 mmまで減らすと、浮き始める速さは時速約81 kmに下がり、路面に触れている部分は約25%に減る。

空気圧を230 kPaから160 kPaに下げると、浮き始める速さは時速約78 kmになる。タイヤが路面を押す圧力が下がり、水がタイヤを持ち上げやすくなるからだ。

水深12 mmの水たまりを時速100 kmで走ると、溝が4 mmあっても、タイヤは水の上に乗る。

現実では

雨の道路と、水に落ちた車

米国連邦道路庁の報告は、実際の道路に近い条件の試験(水深2.5 mm以上)で、タイヤが水に乗る速さが時速72〜124 kmだったとまとめている4。同じ報告が紹介するドラム試験では、溝のあるタイヤが水深2 mm未満で浮いた例はなかった。接地面の水が、ほとんど溝に収まったためだ。日本では、タイヤの溝はどの部分でも1.6 mm以上の深さがなければならない5。

国家公安委員会の「交通の方法に関する教則」は、雨の中を高速で走るとタイヤが浮いて、ハンドルやブレーキが効かなくなることがあると注意している6。路面が雨にぬれ、タイヤがすり減っていると、止まるまでの距離は、乾いた路面でタイヤの状態が良いときの2倍程度に延びることがある。教則は、雨の日は晴れの日より速度を落とし、車間距離を十分にとるよう求めている。

ふつうの車で水に入るのは、命にかかわる。

シミュレーションでも、ふつうの車が向こう岸に届くのは、時速100 kmほどより速く飛び込んだときだけだ。しかも、水をたたいた衝撃で車が壊れることも、窓が割れることも、車が横に傾くことも計算に入れていない。北米では毎年350〜400人が水に沈んだ車の中で亡くなり、おぼれて亡くなる人の最大1割にあたる7。

外へ出やすいのは、外の水が横の窓の下の縁に届くまでの、車が浮いている間だ8。この間なら、窓を開けて外へ出られる。窓まで水が来たあとは、外からの水の圧力で、ドアも窓も開けにくくなる。水に沈む車からの脱出を調べてきたギースブレヒトは、外へ出るのに最もよい時間を、水に入ってから最初の1分ほどとしている7。

ある試験の1台の例。横の目盛りは、車が水に入ってからの秒数 0306090120150180秒 浮いている(63秒)窓から外へ出やすい 沈んでいく水の圧力で窓もドアも開けにくい 水の中(150秒〜)ドアは満水まで開かない 浮いている段階は、外の水が横の窓の下の縁に届くまで。外へ出やすいのは最初の1分ほど (ギースブレヒトとマクドナルドの2010年の試験による)
水に入った車が沈むまでの3つの段階。ある試験の1台では、浮いている段階が63秒、水面に見えていた時間全体が150秒だった

国土交通省は、冠水した道路を車で走るとエンジンなどが止まって動けなくなり、水位によっては水圧などで車から出るのが難しくなるとして、水没した車から逃げる手順を示している9。水位が低いうちにドアを開けて出る。ドアが開かなければ、窓を開けて出る。窓も開かなければ、脱出用ハンマーで窓を割る。ただし、フロントガラスなどの合わせガラスは割れない。それでも出られないときも、あきらめない。車内に水が入って内と外の水位が同じになると、ドアが開く可能性が高まる。

いちばん確かなのは、冠水した道路や川に、車で入らないことだ。

省いていること

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

  • 横から見た2次元で計算している。車が左右に傾くこと、横を向くこと、しぶきが横へ逃げる3次元の流れは入れていない。
  • 水の力は、車の外形を短い板に分け、板ごとの静水圧・動圧・摩擦を足し合わせて求めている。動圧の係数は滑走する板の実験式に合わせた値で、車の形で測った値ではない。車と一緒に動かされる水(付加質量)や、底と水の間に入る空気も入れていない。
  • 車体のうち水を押しのける割合(客室と荷室70%・エンジンルーム35%)と、すき間の面積(0.012 m²)は、沈むまでの時間が試験の車に近くなるように選んだ値である。エンジンが止まること、窓が割れること、電気が切れることは入れていない。車輪は水の中で空回りし、前へ押す力を出さないとした。飛び出すときの鼻先の傾きはつまみで決められるが、本物の車では選べない。
  • 水面の波は、車の幅の約3倍の水路を伝わる1次元の線形の波として描いた。車が立てた波は絵にだけ使い、車の動きには戻していない。本物の池では、波は円く広がって早く小さくなる。
  • 小さなシミュレーションの浮き始める速さの式は、水深2.5 mm以上の試験から作られたものである。路面のきめの深さは0.5 mmに固定した。1輪にかかる重さは375 kg(1.5 tの車の4分の1)とし、接地面の長さの絵にだけ使っている。
参考文献

参考文献

  1. Bocquet, L.「The physics of stone skipping」『American Journal of Physics』71(2), 150-155, 2003. https://arxiv.org/abs/physics/0210015
  2. Savitsky, D.「Hydrodynamic Design of Planing Hulls」『Marine Technology and SNAME News』1(4), 71-95, 1964.
  3. Martin, C. S.「Hydrodynamics of Tire Hydroplaning」NASA CR-601, NASA, 1966. https://ntrs.nasa.gov/citations/19660026826
  4. Gallaway, B. M.ほか「Pavement and Geometric Design Criteria for Minimizing Hydroplaning: A Technical Summary」FHWA-RD-79-30, 米国連邦道路庁, 1979. https://rosap.ntl.bts.gov/view/dot/67508
  5. Giesbrecht, G. G., McDonald, G. K.「My Car Is Sinking: Automobile Submersion, Lessons in Vehicle Escape」『Aviation, Space, and Environmental Medicine』81(8), 779-784, 2010.
  6. Giesbrecht, G. G.「The Evidence Base for a New "Vehicle in Water" Emergency Dispatch Protocol」『Annals of Emergency Dispatch & Response』4(1), 2016. https://www.aedrjournal.org/the-evidence-base-for-a-new-vehicle-in-water-emergency-dispatch-protocol
  7. 国家公安委員会「交通の方法に関する教則」, 警察庁. https://www.npa.go.jp/bureau/traffic/20241113kyousoku.pdf
  8. 国土交通省 自動車局「水没車両からの脱出手順と脱出用ハンマー搭載のお願いについて」, 2020. https://www.mlit.go.jp/report/press/content/001358642.pdf

注

  1. Bocquet(2003)。石の大きさ10 cm・重さ100 g、水が押し返す力の係数を1とした見積もり。回転による傾きの安定は同じ論文の第5節。 ↩ ↩2

  2. Savitsky(1964)。平らな滑走面の持ち上げる力の係数(幅の2乗あたり)のうち、動圧による部分は τ^1.1 × 0.0120 λ^0.5(τは度、λは水に触れる長さ÷幅)。本文のCは、これを水に触れる面積あたりに直した値。 ↩

  3. NASAの報告CR-601(1966)による、HorneとDreherの試験(NASA TN D-2056, 1963)のまとめ。車輪にかかる重さは925〜22,000ポンド。 ↩

  4. 米国連邦道路庁FHWA-RD-79-30の表1(HorneとJoynerの式)と式7(Gallawayほかの式)。時速72〜124 kmは研究HPR-147の試験の範囲。ドラム試験はGengenbachによる。 ↩ ↩2 ↩3

  5. 道路運送車両の保安基準の細目を定める告示 第89条第4項第2号(二輪自動車は0.8 mm)。 ↩

  6. 交通の方法に関する教則(昭和53年国家公安委員会告示第3号)の「停止距離と車間距離」「悪天候など」の節と、高速道路での運転の節。 ↩

  7. Giesbrecht(2016)。浮いている段階は1〜2分続くこともある。 ↩ ↩2

  8. GiesbrechtとMcDonald(2010)の試験。浮いている段階は、外の水が横の窓の下の縁に届くまで。図の63秒・150秒は、この試験の1台。 ↩

  9. 国土交通省 自動車局 審査・リコール課の発表(2020年8月12日)。 ↩

プログラム
プログラム

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

このシミュレーションは、1/400秒刻みで「車の外形を短い板に分け、水面より下にある板ごとに水の圧力を足す → 地面に触れた点の力を足す → 車の位置と傾きを進める → すき間から入る水を増やす」をくり返している。水面の波は1コマに1回、波長ごとに分けた形で進める。しぶきの粒と、上の帯・速さのグラフは、その結果を描いているだけである。

長さはメートル、速さはm/s

長さはメートル、時間は秒、力はニュートンで数える。画面の速さはkm/hで出すので、KMHを掛けてm/sに直す。SEALとSEAL_Fは、車体の外形の体積のうち、空気が閉じ込められて水を押しのける割合で、客室と荷室で7割、前のエンジンルームで3割5分とした。この2つとすき間の面積LEAKは、浮いていられる時間が試験の車(窓の下の縁まで約1分)に近くなるように選んだ値である。

定数と単位(src/sims/car-on-water.ts の 25〜47 行)
const G = 9.81;            // 重力加速度 m/s²const RHO = 1000;          // 水の密度 kg/m³const RHO_AIR = 1.2;       // 空気の密度 kg/m³const SIGMA = 0.073;       // 水の表面張力 N/m(短いさざ波の速さに効く)const KMH = 1 / 3.6;       // 1 km/h = 0.278 m/sconst DEG = Math.PI / 180;const POND = 40;           // 池の長さ: 手前の岸の端から向こう岸の水際まで mconst BANK = 0.6;          // 岸の高さ(水面から)mconst TOE = 38.5, TOE_Z = -0.25; // 向こう岸: 池の底から水深 0.25 m の所(x = 38.5 m)までは 1:2 の急な斜面、その先は 1:6 のゆるい浜const BW = 1.8;            // 車の幅 mconst RG = 1.25;           // 前後の傾きの回りの回転半径 m(慣性モーメント I = m × RG²)const SEAL = 0.7, SEAL_F = 0.35; // 車体の外形の体積のうち、水を押しのける割合(客室と荷室・前のエンジンルーム)const C_N = 0.8, C_X = 1.0;    // 底の板の動圧の係数(滑走で持ち上げる力・上下にたたく力)const C_N2 = 0.5, C_X2 = 0.5;  // それ以外の板(前の面・ボンネット・車輪)の係数const CDA_AIR = 0.7;       // 空気の抵抗の係数 × 前から見た面積 m²const CF_UB = 0.04;        // ふつうの車の床下の抵抗の係数(排気管・燃料タンク・足回りの凸凹を、ざらざらの板の摩擦とみなす)const W_CH = 5;            // 波を伝える水路の幅 m(車の幅の約 3 倍。1 次元の波で、円く広がって小さくなる分を大まかに表す)const LEAK = 0.012;        // 車体のすき間の広さ m²const V_CAB = 4.4;         // 車の中に入りうる水の体積 m³const A_CAB = 4.0;         // 車の中の床の広さ m²(中の水の高さを出す)const H = 1 / 400;         // 車の運動を進める刻み 秒const START = -6;          // 走り出す前の後ろの車輪の位置 m(岸の端が 0)const FAST = 6;            // 浮いて止まっている間の時間の進め方(倍)

車の外形を板に分ける

車の横から見た形を、重心を原点にした点の列で持つ。前が、上がで、点は左回りに並べる。buildShapeは、その辺を0.1 m以下の板に切り分け、板ごとに外向きの法線を求める。左回りの辺の外向きの法線はになる。法線が下を向いた板が「底の板」で、滑走で持ち上げる力の多くはここから生まれる。

車の形(src/sims/car-on-water.ts の 49〜98 行)
// 車体の外形。重心を原点に、前が +ξ・上が +η(m)。左回りに並べる。長さ 4.55 m・高さ 1.47 m・地面から重心まで 0.55 mconst BODY_TOP: Pt[] = [  [2.0, -0.36], [2.08, -0.22], [2.1, -0.02], [2.06, 0.13], [1.6, 0.22], [0.8, 0.3], [-0.05, 0.86], [-0.35, 0.93], [-1.3, 0.92],  [-1.95, 0.7], [-2.3, 0.5], [-2.42, 0.42], [-2.45, 0.15], [-2.4, -0.22], [-2.25, -0.36],];const AXLE = [1.15, -1.55];   // 前と後ろの車軸の位置 ξ(m)。重さの 57% が前の車輪にかかるconst WR = 0.32, WY = -0.23;  // 車輪の半径と、車軸の高さ η(m)const FLOOR = -0.4;           // ふつうの車の床の高さ η(m)。地面から 0.15 mconst KEEL = -0.47;           // 「平ら」「丸い」の底の高さ η(m)const RB = 1.06;              // 「丸い」底の断面の半径 m(幅 1.8 m で、中央が端より 0.5 m 低い)type Hull = "normal" | "flat" | "round";/** 底の線(後ろから前へ)。ふつうの車は平らな床、ほかは床の下に板を張る */function bottomLine(hull: Hull): Pt[] {  if (hull === "normal") return [[-1.9, FLOOR], [1.75, FLOOR]];  if (hull === "flat") return [[-2.32, -0.4], [-2.18, KEEL], [1.25, KEEL], [1.7, -0.43], [1.95, -0.38]];  const o: Pt[] = [];  for (let i = 0; i <= 12; i++) { const t = i / 12, x = -2.3 + 4.25 * t; o.push([x, KEEL + 0.17 * Math.pow((x + 0.15) / 2.15, 2)]); }  return o;}interface Pan { a: number; b: number; nx: number; ny: number; len: number; w: number; bottom: boolean; wheel: boolean; cf: number; seal: number; mx: number; my: number; front: boolean }interface Shape { vx: Float64Array; vy: Float64Array; pan: Pan[]; order: number[]; outline: Pt[]; X: Float64Array; Z: Float64Array; D: Float64Array }/** 外形を 0.1 m 以下の板に分ける。板の外向きの法線は、左回りの辺 (dx, dy) に対して (dy, −dx) */function buildShape(hull: Hull): Shape {  const outline: Pt[] = [...BODY_TOP, ...bottomLine(hull)];  const xs: number[] = [], ys: number[] = [], pan: Pan[] = [];  const addRing = (pts: Pt[], o: { w: number; wheel: boolean; keep?: (x: number, y: number) => boolean }) => {    const idx: number[] = [];    for (let i = 0; i < pts.length; i++) {      const [x1, y1] = pts[i], [x2, y2] = pts[(i + 1) % pts.length], n = Math.max(1, Math.ceil(Math.hypot(x2 - x1, y2 - y1) / 0.1));      for (let k = 0; k < n; k++) { idx.push(xs.length); xs.push(x1 + ((x2 - x1) * k) / n); ys.push(y1 + ((y2 - y1) * k) / n); }    }    for (let i = 0; i < idx.length; i++) {      const a = idx[i], b = idx[(i + 1) % idx.length], dx = xs[b] - xs[a], dy = ys[b] - ys[a], len = Math.hypot(dx, dy);      const mx = (xs[a] + xs[b]) / 2, my = (ys[a] + ys[b]) / 2;      if (o.keep && !o.keep(mx, my)) continue;      const nx = dy / len, ny = -dx / len, bottom = !o.wheel && ny < -0.6;      pan.push({ a, b, nx, ny, len, w: o.w, bottom, wheel: o.wheel, cf: o.wheel ? 0.01 : bottom && hull === "normal" ? CF_UB : 0.004, seal: o.wheel ? 0 : mx > 0.75 ? SEAL_F : SEAL, mx, my, front: !o.wheel && !bottom && mx > 0.5 && nx > -0.2 });    }  };  addRing(outline, { w: BW, wheel: false });  // ふつうの車だけ、床より下に出た車輪(左右 2 本で幅 0.42 m)が水を受ける  if (hull === "normal") for (const ax of AXLE) {    const ring: Pt[] = [];    for (let i = 0; i < 20; i++) { const a = (i / 20) * TAU; ring.push([ax + WR * Math.cos(a), WY + WR * Math.sin(a)]); }    addRing(ring, { w: 0.42, wheel: true, keep: (_x, y) => y < FLOOR });  }  const order = pan.map((p, i) => [p.mx, i]).filter(([, i]) => pan[i].bottom).sort((p, q) => q[0] - p[0]).map(([, i]) => i);  const nv = xs.length;  return { vx: Float64Array.from(xs), vy: Float64Array.from(ys), pan, order, outline, X: new Float64Array(nv), Z: new Float64Array(nv), D: new Float64Array(nv) };}

「ふつうの車」だけは、床より下に出たタイヤの弧も板として足す。タイヤは前から水を受けて、大きな抵抗になる。「平らな底」と「丸い底」は、床の下に板を張った形で、タイヤは水に触れない。

板ごとに水の圧力を足す

板の両端が水面より下にあれば、その板は水に触れている。片方だけなら、水に入った部分の長さだけを数える。板にかかる圧力は3つある。

1つ目は静水圧で、浮力のもとになる。閉じた外形のまわりで足し合わせると、前後の向きは打ち消し合い、上向きの浮力だけが残る。

2つ目は動圧である。は板が水へ向かって進む速さ(法線の向きの速さ)で、だから、底の板では本文の式の形になる。底の板のは、サビツキーの滑走面の式に合うように決めた。その式では、水に触れる長さが長いほど1 m²あたりの力が小さい。そこで、水が流れてくる前の縁からの長さを数え、圧力にの重みを掛けている(は車の幅1.8 m)。この重みを長さまで足すとに比例し、サビツキーの式のと同じ形になる。

水から受ける力(src/sims/car-on-water.ts の 232〜287 行)
/** 水から受ける力を、板ごとに足し合わせる。戻り値は力 (Fx, Fz) と、重心の回りに回す力 M */function water(s: S, h: number, out: Disp): [number, number, number] {  const car = s.car, W = s.wave, sh = s.shape, c = Math.cos(car.th), sn = Math.sin(car.th);  const X = sh.X, Z = sh.Z, Dp = sh.D;  for (let i = 0; i < X.length; i++) { X[i] = car.x + sh.vx[i] * c - sh.vy[i] * sn; Z[i] = car.z + sh.vx[i] * sn + sh.vy[i] * c; Dp[i] = etaAt(W, X[i]) - Z[i]; }  let Fx = 0, Fz = 0, M = 0, B = 0, xB = 0, zB = 0, L = 0, La = 0, xL = 0, zL = 0, deep = -1, run = 0;  const fwd = car.vx * c + car.vz * sn >= 0; // 底を流れる水の向き(前から後ろへ)  const one = (p: Pan, le: number) => {    const d1 = Dp[p.a], d2 = Dp[p.b];    if (d1 <= 0 && d2 <= 0) return 0;    // 水面が板の途中を通るときは、水に入った部分だけを数える    let f = 1, dm = (d1 + d2) / 2, mx = (X[p.a] + X[p.b]) / 2, mz = (Z[p.a] + Z[p.b]) / 2;    if (d1 <= 0 || d2 <= 0) {      const ip = d1 > 0 ? p.a : p.b, io = ip === p.a ? p.b : p.a, dp = Math.max(d1, d2), dn = Math.min(d1, d2);      f = dp / (dp - dn); dm = dp / 2; mx = X[ip] + (X[io] - X[ip]) * f * 0.5; mz = Z[ip] + (Z[io] - Z[ip]) * f * 0.5;    }    deep = Math.max(deep, d1, d2);    const rx = mx - car.x, rz = mz - car.z;    const ux = car.vx - car.om * rz, uz = car.vz + car.om * rx - etaDotAt(W, mx); // 水面の上下の速さを引いた、水に対する速さ    const nx = p.nx * c - p.ny * sn, nz = p.nx * sn + p.ny * c;    const un = ux * nx + uz * nz, V = Math.hypot(ux, uz);    // 丸い底: 浅く沈んだときに水に触れる幅 2y = 2√(2Rd − d²) が狭く、面も横へ傾くので、動圧による持ち上げる力が小さい    let w = p.w, kr = 1;    if (p.bottom && s.hull === "round") { const d = Math.max(d1, d2), y = Math.sqrt(Math.max(0, 2 * RB * d - d * d)); w = Math.min(BW, 2 * y); kr = Math.cos(Math.asin(Math.min(1, y / RB))); }    const A = f * p.len * p.w, Ad = f * p.len * w;    const ph = RHO * G * dm;                                                     // 静水圧 ρ g d    const cn = p.bottom ? C_N * kr * le : C_N2, cx = p.bottom ? C_X : C_X2;    // 動圧 ½ρ(C_N |v| u_n + C_X u_n²)。板が水から離れる向き(u_n < 0)では水が板を吸い寄せるが、    // 圧力が大気圧を下回ると空気が入り込むので、静水圧と合わせて 0 より小さくはしない    const pd = Math.max(-ph, 0.5 * RHO * (cn * V * un + (un > 0 ? cx * un * un : 0)));    const tx = ux - un * nx, tz = uz - un * nz, ff = 0.5 * RHO * p.cf * Math.hypot(tx, tz) * A; // 摩擦 ½ρ C_f u_t²    // 浮力: 上下の向きは、場所ごとに水を押しのける割合(エンジンルームは小さい)を掛ける。前後の向きは同じ割合にして、閉じた外形で打ち消し合うようにする    const fhx = -nx * ph * (p.wheel ? 0 : SEAL) * A, fhz = -nz * ph * p.seal * A, fdx = -nx * pd * Ad - ff * tx, fdz = -nz * pd * Ad - ff * tz;    const fx = fhx + fdx, fz = fhz + fdz;    Fx += fx; Fz += fz; M += rx * fz - rz * fx;    B += fhz; xB += fhz * mx; zB += fhz * mz;    L += fdz; La += Math.abs(fdz); xL += Math.abs(fdz) * mx; zL += Math.abs(fdz) * mz;    pushWater(s.wake, mx, fdz, h); // 車が水を押す力(作用と反作用)のうち動圧の分。車が立てた波は絵にだけ使い、車の力には戻さない    if (p.front && p.my > -0.1 && Math.hypot(car.vx, car.vz) > 6 && car.th < -2 * DEG && !s.fl.float) s.fl.diving = true;    if (pd > 0 && un > 0) emitSpray(s, mx, mz, nx, ux, uz, un, pd * A * h, run === 0 && p.bottom, p.bottom && p.mx < -1.6);    return f * p.len;  };  // 底の板は、水が流れてくる前の縁から順に濡れた長さ s を数え、重み 0.5 (s/B)^(−1/2) の板の上の平均を掛ける  const ord = sh.order;  for (let k = 0; k < ord.length; k++) {    const p = sh.pan[fwd ? ord[k] : ord[ord.length - 1 - k]];    if (Dp[p.a] <= 0 && Dp[p.b] <= 0) { run = 0; continue; }    const s1 = run, s2 = run + p.len;    const le = Math.min(3.5, (BW * (Math.sqrt(s2 / BW) - Math.sqrt(s1 / BW))) / (s2 - s1));    run += one(p, le);  }  for (const p of sh.pan) if (!p.bottom) one(p, 1);  out.B = B; out.L = L; out.D = -Fx; out.xB = B > 0 ? xB / B : car.x; out.zB = B > 0 ? zB / B : car.z;  out.xL = La > 0 ? xL / La : car.x; out.zL = La > 0 ? zL / La : car.z; out.depth = Math.max(0, deep); out.wet = deep > 0;  return [Fx, Fz, M];}

板が水から離れる向き()では、水が板を吸い寄せる。ただし、圧力が大気圧を下回ると空気が入り込むので、静水圧と合わせて0より小さくはしない。鼻先を下げたまま水に触れたボンネットが下へ引かれるのは、この吸い寄せる力である。3つ目の摩擦は、板に沿った速さの2乗に比例する。ふつうの車の床下は、排気管や足回りの凸凹をざらざらの板とみなして、係数を10倍にした。

運動方程式を進める

水の力・地面の力・空気の抵抗・重さを足し、質量で割って速さを、速さから位置を進める。前後の傾きも、重心の回りに回す力を慣性モーメントで割って同じように進める。中に入った水は重さに足し、車の低いほうへ寄せて置く。エンジンルームは水を押しのける割合が小さいので、車は前から沈んでいく。

1刻みの計算(src/sims/car-on-water.ts の 312〜352 行)
/** 1 刻み(h 秒)進める */function tick(api: Api, s: S, h: number) {  const car = s.car;  if (s.phase === "ready") return;  s.t += h;  if (s.phase === "runup") {    s.q += s.v0 * h; placeOnPath(s); car.ww = s.v0 / WR;    if (s.q >= 0) { // 後ろの車輪が岸の端を離れた: ここから先は力だけで動く      s.phase = "free"; car.vx = s.v0; car.vz = 0; car.om = 0;    }  } else {    const Mt = total(s), I = s.mass * RG * RG + RHO * car.vin;    const [wx, wz, wm] = water(s, h, s.raw);    const [gx, gz, gm, wheelOn] = contact(s);    const V = Math.hypot(car.vx, car.vz), fa = 0.5 * RHO_AIR * CDA_AIR * V;    // 中に入った水の重さ: 水は低いほうへ寄る(鼻先が下がると前へ)    const c = Math.cos(car.th), sn = Math.sin(car.th), xi = -0.4 - 1.0 * clamp(sn * 3, -1, 1) * (1 - car.vin / V_CAB), yi = -0.3 + (0.5 * car.vin) / A_CAB;    const mIn = -(xi * c - yi * sn) * RHO * car.vin * G;    // 運動方程式: 質量 × 加速度 = 水の力 + 地面の力 + 空気の抵抗 + 重さ    car.vx += ((wx + gx - fa * car.vx) / Mt) * h;    car.vz += ((wz + gz - fa * car.vz) / Mt - G) * h;    car.om += ((wm + gm + mIn) / I) * h;    car.x += car.vx * h; car.z += car.vz * h; car.th += car.om * h;    // 車輪: 地面の上では転がり、水の中では空回りが少しずつ遅くなる(前へ押す力は出さない)    if (wheelOn) car.ww = (car.vx * c + car.vz * sn) / WR; else if (s.raw.wet) car.ww *= 1 - 0.6 * h;    flood(s, h);    events(api, s, h, wheelOn);  }  car.wa += car.ww * h;  s.histT += h;  if (s.histT >= 0.05) { s.histT = 0; s.hist.push((s.phase === "runup" ? s.v0 : Math.hypot(car.vx, car.vz)) / KMH); if (s.hist.length > 240) s.hist.shift(); }  s.trackT += h;  if (s.trackT >= 0.04) { s.trackT = 0; s.track.push(car.x, car.z); if (s.track.length > 1600) s.track.splice(0, 2); }}/** すき間から入る水: 外の水面が床より Δh 高いと、Q = 0.6 × すき間の広さ × √(2gΔh) の割合で流れ込む */function flood(s: S, h: number) {  const car = s.car, c = Math.cos(car.th);  const out = etaAt(s.wave, car.x), zFloor = car.z - 0.3 * c, zSill = car.z - 0.25 * c, zWin = car.z + 0.33 * c;  const dh = out - Math.max(zSill, zFloor + car.vin / A_CAB);  if (dh > 0 && car.vin < V_CAB) car.vin = Math.min(V_CAB, car.vin + 0.6 * LEAK * (out > zWin ? 1.5 : 1) * Math.sqrt(2 * G * dh) * h);}

刻みを1/400秒にしたのは、水に強く当たった瞬間の力が大きく、刻みが粗いと計算が暴れるからである。1コマで進める時間を、この刻みで何回にも分けて計算するので、端末の速さで描くコマの数が変わっても、車の動きは同じになる。すき間から入る水は、外の水面が、ドアの下の縁と車の中の水面の高いほうより高いとき、1秒にの割合で増える。窓より上まで水が来ると、すき間を1.5倍にする。

水面の波を波長ごとに進める

水面の高さを、池の両端で傾きが0になるcosの波の重ね合わせで持つ。深さの水では、波数の波の振動数がで決まり、深い水では長い波ほど速く進む(は水の表面張力)。波ごとに分けておけば、どの波も正確に回せるので、1コマに何秒進めても計算が暴れない。水面の形と波ごとの大きさの行き来には、高速フーリエ変換を使う。

水面の波(src/sims/car-on-water.ts の 100〜187 行)
// 水面の高さ η を、池の両端で傾きが 0 になる波(cos)の重ね合わせで持つ。波長ごとの振動数は、深さ D の水の波の式//   ω² = (g k + σk³/ρ) tanh(k D)// で決まる。深い水では長い波ほど速く進み、浅い水では √(gD) に近づく。車が水を押す圧力 p は、波ごとに分けてから加えるinterface Wave { n: number; dx: number; eta: Float64Array; etaDot: Float64Array; A: Float64Array; Q: Float64Array; om: Float64Array; ak: Float64Array; ge: Float64Array; damp: Float64Array; force: Float64Array; hit: boolean; quiet: boolean; re: Float64Array; im: Float64Array; rev: Uint32Array; cs: Float64Array; sn: Float64Array; tmp: Float64Array }function makeWave(n: number, depth: number): Wave {  const M = 2 * n, rev = new Uint32Array(M), bits = Math.round(Math.log2(M));  for (let i = 0; i < M; i++) { let r = 0; for (let b = 0; b < bits; b++) r |= ((i >> b) & 1) << (bits - 1 - b); rev[i] = r; }  const cs = new Float64Array(M / 2), sn = new Float64Array(M / 2);  for (let i = 0; i < M / 2; i++) { cs[i] = Math.cos((TAU * i) / M); sn[i] = -Math.sin((TAU * i) / M); }  const W: Wave = {    n, dx: POND / n, eta: new Float64Array(n + 1), etaDot: new Float64Array(n + 1), A: new Float64Array(n + 1), Q: new Float64Array(n + 1),    om: new Float64Array(n + 1), ak: new Float64Array(n + 1), ge: new Float64Array(n + 1), damp: new Float64Array(n + 1), force: new Float64Array(n + 1), hit: false, quiet: true,    re: new Float64Array(M), im: new Float64Array(M), rev, cs, sn, tmp: new Float64Array(n + 1),  };  setDepth(W, depth);  return W;}/** 深さが変わったら、波ごとの振動数を作り直す */function setDepth(W: Wave, D: number) {  for (let m = 1; m <= W.n; m++) {    const k = (Math.PI * m) / POND, ge = G + (SIGMA * k * k) / RHO, ak = k * Math.tanh(k * D);    W.ge[m] = ge; W.ak[m] = ak; W.om[m] = Math.sqrt(ge * ak);    W.damp[m] = 0.05 + 2e-4 * k * k; // 粘り(本物の水より強くして、格子より細かい波を消す)  }}/** 端で折り返した列の高速フーリエ変換で、cos の重ね合わせ(DCT-I)を求める: out_m = v_0 + (−1)^m v_n + 2 Σ v_j cos(π m j / n) */function dct(W: Wave, v: Float64Array, out: Float64Array) {  const n = W.n, M = 2 * n, re = W.re, im = W.im;  for (let j = 0; j <= n; j++) re[W.rev[j]] = v[j];  for (let j = 1; j < n; j++) re[W.rev[M - j]] = v[j];  im.fill(0);  for (let size = 2; size <= M; size *= 2) {    const half = size / 2, step = M / size;    for (let i = 0; i < M; i += size) for (let j = 0; j < half; j++) {      const k = j * step, wr = W.cs[k], wi = W.sn[k], a = i + j, b = a + half;      const tr = re[b] * wr - im[b] * wi, ti = re[b] * wi + im[b] * wr;      re[b] = re[a] - tr; im[b] = im[a] - ti; re[a] += tr; im[a] += ti;    }  }  for (let m = 0; m <= n; m++) out[m] = re[m];}/** 波を dt 秒進める。この間の圧力(force に「圧力 × 時間」をためたもの)は一定とみなし、波ごとに正確に回す */function waveStep(W: Wave, dt: number) {  if (dt <= 0 || (!W.hit && W.quiet)) return; // 波も押す力も無ければ計算しない  const n = W.n, P = W.tmp;  // 圧力は 6 kPa まで(水面が 0.6 m より深くへこまないように)  for (let j = 0; j <= n; j++) W.force[j] = clamp(W.force[j] / dt, -6000, 6000);  dct(W, W.force, P);  for (let m = 0; m <= n; m++) P[m] /= n;  for (let m = 1; m <= n; m++) {    const etaS = -P[m] / (RHO * W.ge[m]);   // その圧力のままなら水面が落ち着く高さ    const om = W.om[m], r = W.ak[m] / om, c = Math.cos(om * dt), s = Math.sin(om * dt), dec = Math.exp(-W.damp[m] * dt);    const e = W.A[m] - etaS, q = W.Q[m];    W.A[m] = (e * c + r * q * s) * dec + etaS;    W.Q[m] = (-(e * s) / r + q * c) * dec;  }  W.A[0] = 0;  let amp = 0;  for (let m = 1; m <= n; m++) amp = Math.max(amp, Math.abs(W.A[m]));  W.quiet = amp < 2e-6; W.hit = false;  dct(W, W.A, P);  for (let j = 0; j <= n; j++) { const e = P[j] / 2; W.etaDot[j] = (e - W.eta[j]) / dt; W.eta[j] = e; }  W.force.fill(0);}/** 水面の高さ(池の外は水が無い) */function etaAt(W: Wave, x: number) {  if (x < 0 || x > POND) return -99;  const f = x / W.dx, j = Math.min(W.n - 1, Math.floor(f)), t = f - j;  return W.eta[j] * (1 - t) + W.eta[j + 1] * t;}function etaDotAt(W: Wave, x: number) {  if (x < 0 || x > POND) return 0;  const f = x / W.dx, j = Math.min(W.n - 1, Math.floor(f)), t = f - j;  return clamp(W.etaDot[j] * (1 - t) + W.etaDot[j + 1] * t, -3, 3);}/** 水面を押す力 F(N・下向きが正)を、x を中心に幅 ±2σ の三角の重みで広げ、「圧力 × 時間」として加える */function pushWater(W: Wave, x: number, F: number, h: number, sig = 0.08) {  if (x < 0 || x > POND) return;  const r = Math.max(W.dx, 2 * sig), j0 = Math.max(0, Math.ceil((x - r) / W.dx)), j1 = Math.min(W.n, Math.floor((x + r) / W.dx));  let sum = 0;  for (let j = j0; j <= j1; j++) sum += 1 - Math.abs(j * W.dx - x) / r;  if (sum <= 0) return;  const k = (F * h) / (W_CH * W.dx * sum);  W.hit = true;  for (let j = j0; j <= j1; j++) W.force[j] += k * (1 - Math.abs(j * W.dx - x) / r);}/** 画面に描く水面: 池にもとからある波(指で立てた波)と、車が立てた波の和 */const etaView = (s: S, x: number) => (x < 0 || x > POND ? -99 : etaAt(s.wave, x) + etaAt(s.wake, x));

車が水を押す力は、波を立てる圧力として水面にかける。ただし、車が立てた波は絵にだけ使い、車の力には戻していない。1次元の水面では、車に押された水が逃げる場所がなく、波が車に返ってきて計算が暴れるからである。指で立てた波のほうは、車の力にも効く。

触ったときの動きと、絵の描き方

走る前の車をつかんで後ろへ引くと、引いた長さ1 mごとに入る速さが26 km/h上がり、下へ1 m引くごとに鼻先が10°上を向く。それ以外の所で水面の近くを押すと、指の深さぶんの圧力を水面にかける。

操作(src/sims/car-on-water.ts の 634〜659 行)
pointer(type, api, s) {  const L = s.lay; if (!L) return;  const P = api.pointer, wx = s.camX + (P.x - api.w / 2) / L.k, wz = (L.yw - P.y) / L.k;  if (type === "down") {    s.autoT = -1;    const car = s.car;    // 走る前の車をつかむ。それ以外の所で水面の近くを押すと、水を押し下げる    if (s.phase === "ready" && Math.abs(wx - car.x) < 3 && wz > car.z - 1.1 && wz < car.z + 1.6) { s.drag = { x0: wx, z0: wz, on: false }; return; }    if (wx > 0 && wx < POND && wz < etaAt(s.wave, wx) + 0.4) s.finger = { x: wx, z: wz };    return;  }  if (type === "move") {    if (s.drag) {      const dx = s.drag.x0 - wx, dz = wz - s.drag.z0;      if (Math.hypot(dx, dz) > 0.15) s.drag.on = true;      if (!s.drag.on) return;      // 後ろへ 1 m 引くごとに 26 km/h 速く、下へ 1 m 引くごとに鼻先が 10° 上を向く      const pull = clamp(dx, 0, 6.5), v0 = clamp(Math.round((30 + pull * 26) / 5) * 5, 30, 200), th = clamp(Math.round(-dz * 10), -15, 25);      api.set("v0", v0); api.set("pitch", th);      s.v0 = v0 * KMH; s.th0 = th * DEG; s.q = START - pull * 0.5; placeOnPath(s);    } else if (s.finger) { s.finger.x = clamp(wx, 0.05, POND - 0.05); s.finger.z = wz; }    return;  }  if (s.drag) { const on = s.drag.on; s.drag = null; if (type === "up" && on) startRun(api, s); else { s.q = START; placeOnPath(s); } }  s.finger = null;},

水の中の細い線は、水面の形を深さとともに小さくして写したもので、深い所ほど線が切れて薄くなる。沈んだ車の上にもこの線を重ねるので、水の中にある部分が分かる。力の矢印は重さの長さを基準に、ほかの力をとの比で描く。

描画(src/sims/car-on-water.ts の 414〜579 行)
function layout(api: Api): Lay {  const w = api.w, h = api.h, u = api.u;  const sh = clamp(h * 0.07, u(30), u(44)), sy = u(10), top = sy + sh + u(12), mh = h - top;  // 見せる横幅: 横長の画面で 16 m、縦長の画面で 7.2 m(車の 1.6 台分)  const asp = w / mh, tall = asp < 0.9, span = lerp(7.2, 16, clamp((asp - 0.55) / 1.35, 0, 1)), k = w / span;  const yw = top + clamp(mh * (tall ? 0.4 : 0.44), 2.4 * k, mh - 1.6 * k);  // 速さのグラフ: 横長では左下、縦長では池の底より下の広い所に横いっぱい。右下の角(記事ビューのボタン)は空けておく  if (tall) {    const gh = clamp(h * 0.1, u(56), u(100)), gy = Math.max(yw + 4.2 * k, h - gh - 64);    return { k, yw, top, sx0: u(14), sx1: w - u(14), sy, sh, gx: u(14), gy: Math.min(gy, h - gh - 64), gw: w - u(28), gh, span };  }  const gh = u(50), gw = Math.min(w - u(28) - 180, u(260));  return { k, yw, top, sx0: u(14), sx1: w - u(14), sy, sh, gx: u(14), gy: h - gh - u(14), gw, gh, span };}type Tf = (x: number, z: number) => Pt;/** 岸と池の底: 断面図のように、地面の線に沿った帯だけを斜線で塗る */function drawGround(api: Api, s: S, P: Tf, x0: number, x1: number) {  const u = api.u, D = s.depth, B = 0.7, hs = { gap: u(7), angle: 45, w: u(0.7) };  // 手前の岸: 上の面と、池に面した壁  const xl = Math.min(x0 - 2, -8), top: Pt[] = [P(xl, BANK), P(0, BANK), P(0, BANK - B), P(xl, BANK - B)], wall: Pt[] = [P(-B, BANK), P(0, BANK), P(0, -D - B), P(-B, -D - B)];  for (const r of [top, wall]) { api.poly(r, { close: true, fill: "paper", stroke: false }); api.hatch(r, hs); }  api.poly([P(xl, BANK), P(0, BANK), P(0, -D)], { w: u(1.8) });  // 道の白線(破線)  for (let x = -1.5; x > x0 - 4; x -= 3.5) { const [a, b] = P(x, BANK), [c2] = P(x - 1.6, BANK); api.line(a, b + u(5), c2, b + u(5), { w: u(1.1) }); }  // 池の底と向こう岸(急な斜面とゆるい浜)  const xr = Math.max(x1 + 2, POND + 12), F: Pt[] = [[0, -D], [TOE - 2 * Math.max(0, D + TOE_Z), -D], [TOE, Math.max(-D, TOE_Z)], [TOE + 6 * (BANK - TOE_Z), BANK], [xr, BANK]];  const band: Pt[] = [...F.map(([x, z]) => P(x, z)), ...F.slice().reverse().map(([x, z]) => P(x, z - B))];  api.poly(band, { close: true, fill: "paper", stroke: false }); api.hatch(band, hs);  api.poly(F.map(([x, z]) => P(x, z)), { w: u(1.8) });}function drawWater(api: Api, s: S, L: Lay, P: Tf, x0: number, x1: number) {  const u = api.u, ctx = api.ctx, k = L.k;  const a = Math.max(0, x0), b = Math.min(POND, x1);  if (b <= a) return;  const n = Math.max(8, Math.ceil(((b - a) * k) / 3)), xs = new Float64Array(n + 1), es = new Float64Array(n + 1);  for (let i = 0; i <= n; i++) { xs[i] = a + ((b - a) * i) / n; es[i] = etaView(s, xs[i]); }  // 水の中: 水面の形を、深くなるほど小さくして写した細い線。深い所ほど線が切れて薄くなる  ctx.save(); ctx.beginPath();  for (let i = 0; i <= n; i++) { const [X, Y] = P(xs[i], es[i]); if (i) ctx.lineTo(X, Y); else ctx.moveTo(X, Y); }  // 下の縁は池の底と向こう岸の斜面に沿わせる(地面の中に水の線を描かない)  for (let i = n; i >= 0; i -= 4) { const [X, Y] = P(xs[i], ground(s, xs[i]).z); ctx.lineTo(X, Y); }  ctx.lineTo(...P(a, ground(s, a).z)); ctx.closePath(); ctx.clip();  const gap = u(8) / k, rnd = api.rng(7);  ctx.strokeStyle = api.ink;  for (let d = gap * 0.9, j = 0; d < s.depth + 0.5; d += gap * (1 + j * 0.07), j++) {    const fade = Math.exp(-1.6 * d), len = u(26 + 34 * rnd()), sp = u(2 + 3.5 * Math.min(4, d) + 2 * rnd());    ctx.beginPath();    for (let i = 0; i <= n; i += 2) { const [X, Y] = P(xs[i], -d + es[i] * fade); if (i) ctx.lineTo(X, Y); else ctx.moveTo(X, Y); }    ctx.lineWidth = u(Math.max(0.45, 0.9 - d * 0.12)); ctx.setLineDash([len, sp]); ctx.lineDashOffset = rnd() * 200 + P(0, 0)[0]; ctx.stroke();  }  ctx.setLineDash([]); ctx.lineDashOffset = 0; ctx.restore();}function drawSurface(api: Api, s: S, L: Lay, P: Tf, x0: number, x1: number) {  const a = Math.max(0, x0), b = Math.min(POND, x1);  if (b <= a) return;  const n = Math.max(8, Math.ceil(((b - a) * L.k) / 2.5)), pts: number[] = [];  for (let i = 0; i <= n; i++) { const x = a + ((b - a) * i) / n, [X, Y] = P(x, etaView(s, x)); pts.push(X, Y); }  api.poly(pts, { w: api.u(1.9) });}/** 車の絵。車体は地の色で塗り、窓は細い斜線、車輪は黒。ふつうでない底は、床の下の板を点で描く */function drawCar(api: Api, s: S, P: Tf, k: number) {  const u = api.u, car = s.car, c = Math.cos(car.th), sn = Math.sin(car.th);  const T = (x: number, y: number): Pt => P(car.x + x * c - y * sn, car.z + x * sn + y * c);  api.poly(s.shape.outline.map(([x, y]) => T(x, y)), { close: true, fill: "paper", w: u(1.7) });  for (const ax of AXLE) {    const [X, Y] = T(ax, WY), R = WR * k;    if (s.hull === "normal") { const arch: Pt[] = []; for (let i = 0; i <= 14; i++) { const a = Math.PI * (i / 14); arch.push(T(ax + 0.39 * Math.cos(a), WY + 0.39 * Math.sin(a))); } api.poly(arch, { fill: "paper", w: u(1.2) }); }    // タイヤは太い輪、ホイールは細い輪と 5 本のスポーク(回ると向きが変わる)    api.circle(X, Y, R - u(1.8), { fill: "paper", w: u(3.6) });    api.circle(X, Y, R * 0.58, { w: u(0.9) });    for (let i = 0; i < 5; i++) { const a = -car.wa + (i / 5) * TAU - car.th; api.line(X + Math.cos(a) * R * 0.14, Y + Math.sin(a) * R * 0.14, X + Math.cos(a) * R * 0.56, Y + Math.sin(a) * R * 0.56, { w: u(1.1) }); }    api.dot(X, Y, u(1.8));  }  if (s.hull !== "normal") {    // 床の下の板: 底の線と床の間を点で塗る    const bl = bottomLine(s.hull), sk: Pt[] = [T(bl[0][0], FLOOR + 0.04), ...bl.map(([x, y]) => T(x, y)), T(bl[bl.length - 1][0], FLOOR + 0.04)];    api.poly(sk, { close: true, fill: "paper", w: u(1.4) }); api.dots(sk, { gap: u(3.4), r: u(0.6) });  }  const win1: Pt[] = [T(0.66, 0.36), T(-0.06, 0.8), T(-0.62, 0.82), T(-0.62, 0.36)], win2: Pt[] = [T(-0.74, 0.36), T(-0.74, 0.82), T(-1.27, 0.82), T(-1.8, 0.6), T(-1.8, 0.36)];  for (const wv of [win1, win2]) { api.hatch(wv, { gap: u(2.6), angle: 62, w: u(0.7) }); api.poly(wv, { close: true, w: u(1.1) }); }  api.line(...T(0.72, 0.3), ...T(0.66, -0.3), { w: u(0.8) }); api.line(...T(-0.68, 0.36), ...T(-0.68, -0.32), { w: u(0.8) }); api.line(...T(-1.85, 0.36), ...T(-1.75, -0.28), { w: u(0.8) });  api.line(...T(0.1, 0.2), ...T(-0.12, 0.2), { w: u(1.6) }); api.line(...T(-1.0, 0.2), ...T(-1.22, 0.2), { w: u(1.6) });  api.poly([T(2.02, 0.07), T(1.75, 0.15), T(1.72, 0.06), T(2.0, -0.01)], { close: true, fill: "ink", stroke: false });  api.poly([T(-2.41, 0.36), T(-2.3, 0.4), T(-2.3, 0.22), T(-2.43, 0.22)], { close: true, fill: "ink", stroke: false });  api.line(...T(2.0, -0.12), ...T(-2.38, -0.12), { w: u(0.6), dash: [u(1.5), u(3)] });}/** 力の矢印: 重さ W の長さを基準に、ほかの力は W との比で描く(本物の比率)。3 W を超える分は、切れ目の印を付けて縮める */function drawForces(api: Api, s: S, P: Tf, L: Lay) {  const u = api.u, d = s.disp, car = s.car, W = total(s) * G, base = Math.min(1.2 * L.k, (L.yw - L.top - u(40)) / 3.2), cap = 3;  const narrow = api.w < 560, fs = u(narrow ? 10.5 : 11.5), kn = (f: number) => `${fmt(f / 1000, f < 10000 ? 1 : 0)} kN`;  const arr = (x: number, y: number, dx: number, dy: number, f: number, lab: string, al: CanvasTextAlign, wide: number) => {    let r = f / W; if (r < 0.04) return;    const big = r > cap; if (big) r = cap;    const len = r * base, x2 = x + dx * len, y2 = y + dy * len;    api.arrow(x, y, x2, y2, { w: wide, head: Math.min(u(10), Math.max(u(5), len * 0.4)) });    if (big) { const mx = x + dx * len * 0.5, my = y + dy * len * 0.5, px = -dy * u(7), py = dx * u(7); for (const o of [-u(2.5), u(2.5)]) api.line(mx - px + dx * o, my - py + dy * o, mx + px + dx * o, my + py + dy * o, { w: u(1.3) }); }    // 札は描画面からはみ出さないようにずらす(幅は文字数から見積もる)    let tw = 0; for (const ch of lab) tw += ch.charCodeAt(0) > 255 ? fs : fs * 0.6;    let lx = x2 + (al === "right" ? -u(6) : al === "left" ? u(6) : 0);    const left = al === "right" ? lx - tw : al === "center" ? lx - tw / 2 : lx;    lx += Math.max(u(6) - left, 0) + Math.min(api.w - u(6) - (left + tw), 0);    api.label(lab, lx, Math.max(L.top + u(14), y2 + (dy > 0 ? u(14) : dy < 0 ? -u(4) : u(4))), { size: fs, font: "mono", align: al });  };  const [cx, cy] = P(car.x, car.z);  arr(cx, cy, 0, 1, W, narrow ? `W ${kn(W)}` : `重さ W ${kn(W)}`, "left", u(2.4));  api.circle(cx, cy, u(4), { fill: "paper", w: u(1.4) }); api.dot(cx, cy, u(1.6));  if (d.B > 0.04 * W) { const [bx, by] = P(d.xB, d.zB); arr(bx, by, 0, -1, d.B, narrow ? `B ${fmt(d.B / W, 2)}W` : `浮力 B ${fmt(d.B / W, 2)} W`, "right", u(1.8)); }  const [lx, ly] = P(d.xL, Math.max(d.zL, etaView(s, d.xL)));  if (d.L > 0.04 * W) arr(lx, ly, 0, -1, d.L, narrow ? `L ${fmt(d.L / W, 2)}W` : `持ち上げる力 L ${fmt(d.L / W, 2)} W`, "left", u(2.6));  // 抵抗は水面のすぐ下に、後ろ向きに描く(車体の絵と重ならないように)  const [, wy] = P(d.xL, etaView(s, d.xL) - 0.25);  if (d.D > 0.04 * W) arr(lx, wy, -1, 0, d.D, narrow ? `D ${fmt(d.D / W, 2)}W` : `抵抗 D ${fmt(d.D / W, 2)} W`, "right", u(2));}/** 上の帯: 池の全体と、車の重心が通った道すじ。丸は水に入った所 */function drawStrip(api: Api, s: S, L: Lay) {  const u = api.u, x0 = -8, x1 = POND + 8, sx = (x: number) => L.sx0 + ((x - x0) / (x1 - x0)) * (L.sx1 - L.sx0);  const ym = L.sy + L.sh * 0.42, kz = L.sh / 4.6, sz = (z: number) => ym - z * kz, fs = u(10), yb = L.sy + L.sh;  api.rect(L.sx0, L.sy, L.sx1 - L.sx0, L.sh, { fill: "paper", w: u(0.8) });  api.hatch([[sx(x0), sz(BANK)], [sx(0), sz(BANK)], [sx(0), yb], [sx(x0), yb]], { gap: u(4), w: u(0.6) });  api.hatch([[sx(POND), sz(0)], [sx(TOE + 6 * (BANK - TOE_Z)), sz(BANK)], [sx(x1), sz(BANK)], [sx(x1), yb], [sx(POND), yb]], { gap: u(4), w: u(0.6) });  api.line(sx(0), sz(0), sx(POND), sz(0), { w: u(1) });  for (let x = 0; x <= POND; x += 10) { api.line(sx(x), sz(0), sx(x), sz(0) + u(3), { w: u(0.8) }); api.label(`${x}${x === POND ? " m" : ""}`, sx(x), yb - u(3), { size: fs, font: "mono", align: "center", pad: u(1.5) }); }  const t = s.track, pts: number[] = [];  for (let i = 0; i + 1 < t.length; i += 2) pts.push(sx(t[i]), clamp(sz(t[i + 1] - 0.55), L.sy + u(2), yb - u(2)));  if (pts.length >= 4) api.poly(pts, { w: u(1.2) });  for (const x of s.hits) api.circle(sx(x), sz(0), u(2.6), { fill: "paper", w: u(1) });  const X = sx(s.car.x), Z = clamp(sz(s.car.z - 0.55), L.sy + u(3), yb - u(3));  api.rect(X - u(5), Z - u(4), u(10), u(5), { fill: "ink", stroke: false });  const a = sx(s.camX - L.span / 2), b = sx(s.camX + L.span / 2);  for (const e of [a, b]) api.line(e, L.sy + u(2), e, yb - u(2), { w: u(0.8), dash: [u(2), u(2)] });}function draw(api: Api, s: S) {  const L = (s.lay = layout(api)), u = api.u, car = s.car;  const P: Tf = (x, z) => [(x - s.camX) * L.k + api.w / 2, L.yw - z * L.k];  const x0 = s.camX - api.w / 2 / L.k, x1 = s.camX + api.w / 2 / L.k;  api.clear();  drawCar(api, s, P, L.k);  drawWater(api, s, L, P, x0, x1);   // 水の中の線は、沈んだ車の上にも重ねる  drawGround(api, s, P, x0, x1);  drawSurface(api, s, L, P, x0, x1);  // しぶき: 粒をまとめて 1 回で塗る  const sp = s.spray, ctx = api.ctx;  ctx.beginPath();  for (let i = 0; i < sp.live.length; i++) if (sp.live[i]) { const [X, Y] = P(sp.x[i], sp.z[i]); if (X > -5 && X < api.w + 5 && Y > L.top) { const r = u(0.5 + 0.7 * sp.r[i]); ctx.moveTo(X + r, Y); ctx.arc(X, Y, r, 0, TAU); } }  ctx.fillStyle = api.ink; ctx.fill();  if (s.phase === "free" && api.bool("forces")) drawForces(api, s, P, L);  if (s.phase === "ready") {    // 走る前: つかめる印(点線の枠と四角の取っ手)と、引いた長さ    const c = Math.cos(car.th), sn = Math.sin(car.th), T = (x: number, y: number): Pt => P(car.x + x * c - y * sn, car.z + x * sn + y * c);    api.poly([T(-2.7, -0.75), T(2.35, -0.75), T(2.35, 1.15), T(-2.7, 1.15)], { close: true, w: u(1), dash: [u(3), u(3)] });    const [hx, hy] = T(-2.7, 0.2), q = u(5); api.rect(hx - q, hy - q, 2 * q, 2 * q, { fill: "paper", w: u(1.3) });    const v0 = api.num("v0"), th0 = api.num("pitch"), [tx, ty] = T(-0.2, 1.15);    const msg = s.drag?.on ? `入る速さ ${fmt(v0, 0)} km/h・鼻先 ${th0 > 0 ? "+" : ""}${fmt(th0, 0)}°` : "後ろへ引いて離すと走り出す";    api.ctx.font = `400 ${u(12)}px sans-serif`; const mw = api.ctx.measureText(msg).width / 2 + u(8);    api.label(msg, clamp(tx, mw, api.w - mw), ty - u(10), { size: u(12), align: "center" });    if (s.drag?.on) { const [ax, ay] = P(s.drag.x0, s.drag.z0); api.line(ax, ay, hx, hy, { w: u(1.2), dash: [u(5), u(3)] }); api.circle(ax, ay, u(3), { fill: "paper", w: u(1) }); }  }  if (s.finger) { const [X, Y] = P(s.finger.x, s.finger.z); api.circle(X, Y, u(9), { w: u(1.2), dash: [u(2), u(2)] }); api.dot(X, Y, u(2)); }  drawStrip(api, s, L);  if (s.hist.length > 2) {    const mx = Math.max(60, Math.ceil((s.v0 / KMH + 10) / 20) * 20);    api.rect(L.gx, L.gy - u(16), L.gw, L.gh + u(18), { fill: "paper", w: u(0.8) });    api.spark(s.hist, L.gx + u(8), L.gy + u(4), L.gw - u(16), L.gh - u(12), { min: 0, max: mx, label: `速さ 0〜${mx} km/h(12 秒ぶん)` });  }  if (s.phase !== "ready") api.label(`経過 ${fmt(s.t, 1)} 秒${s.rate > 1 ? `(${s.rate} 倍で進めています)` : ""}`, u(14), L.top + u(16), { size: u(12), font: "mono" });}

小さなシミュレーションで浮き始める速さを出す

雨の道路のタイヤでは、米国連邦道路庁の報告の式で浮き始める速さを求める。式はmph・psi・インチで書かれているので、km/h・kPa・mmから直してから入れる。接地面のうち水が入りこむ割合はとした。

浮き始める速さ(src/sims/car-on-water-hydroplane.ts の 16〜25 行)
/** 浮き始める速さ v_p(km/h)。水の深さ・溝の深さ(mm)、空気圧(kPa)から、Gallaway ほかの実験式で求める */function vpGallaway(wdMM: number, tdMM: number, pKPa: number) {  const SD = 10, P = pKPa / 6.895, TD = (tdMM * 32) / 25.4, WD = wdMM / 25.4, TXD = 0.5 / 25.4;  const A = Math.max(10.409 / Math.pow(WD, 0.06) + 3.507, (28.952 / Math.pow(WD, 0.06) - 7.817) * Math.pow(TXD, 0.14));  return Math.pow(SD, 0.04) * Math.pow(P, 0.3) * Math.pow(TD + 1, 0.06) * A * 1.609;}/** 空気圧だけから決める NASA の式(Horne と Joyner): v_p = 6.35 √p(km/h・kPa) */const vpNasa = (pKPa: number) => 6.35 * Math.sqrt(pKPa);/** 水が支える重さの割合 (v / v_p)²。1 を超えたらタイヤは水の上に乗っている */const waterShare = (v: number, vp: number) => clamp((v / vp) ** 2, 0, 1);

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

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

読み込んでいます

プログラム全体を見る(src/sims/car-on-water-hydroplane.ts・144 行)

読み込んでいます

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

姉妹サイト

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