竜巻が地面の近くで細く速くなるのは、摩擦が空気を中心へ送り込むから

回転する空気は中心へ寄るほど速く回る。ところが回転を保ったままでは遠心力に押し返され、中心へは入れない。地面の摩擦で回転を失った空気だけが、その壁を越えて細い芯を作る。

記事

竜巻が地面の近くで細く速くなるのは、摩擦が空気を中心へ送り込むから

回転する空気は中心へ寄るほど速く回る。ところが回転を保ったままでは遠心力に押し返され、中心へは入れない。地面の摩擦で回転を失った空気だけが、その壁を越えて細い芯を作る。

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

現象

幅500 mの帯だけを壊した風

2012年5月6日の昼、茨城県常総市で発生した竜巻は、18分かけてつくば市まで17 km進んだ。被害が出たのは幅500 mの帯の中で、住宅76棟が全壊している1。親雲の回転は差し渡し数kmあるのに2、なぜ地面の近くではここまで細く、速くなるのか。

答えは2つの働きの組み合わせにある。回転する空気は中心へ寄るほど速く回る。ただし回転を保ったままでは遠心力に押し返され、ある半径より内側へ入れない。地面すれすれの空気だけは摩擦で回転を失っているので、その壁を越えて中心まで届く。

画面の竜巻は、この流れを運動の式から計算したものだ。雲の底の穴から空気を吸い上げ、周りから回転を持った空気を入れると、細い芯がひとりでに現れる。回転と上昇は計算の条件として与えるので、親雲の中で竜巻がどう生まれるかは、この画面では分からない。地面をタップすれば砂ぼこりや自動車を落とせて、ドラッグで見る向きも変えられる。

仕組み

中心へ寄るほど、回転は速くなる

空気の塊が中心へ寄るとき、外から回転を強める力も弱める力も働かなければ、半径と回転の速さの積は変わらない。1 kgあたりの角運動量の保存である。

は中心からの距離(m)、は回転の速さ(m/s)だ。半径が半分になれば、回転は2倍になる。仮に半径1 kmで秒速20 mの回転が半径100 mまでそのまま縮まれば、秒速200 mに達する計算になる。

この縮みを引き起こすのが上昇気流である。積乱雲の強い上昇は、その下の空気を縦に引き伸ばす。空気の体積はほぼ変わらないので、高さが2倍になれば断面積は半分になり、半径は倍に縮む。回転の軸の向きに引き伸ばされて回転が速まるこの働きを、渦の引き伸ばしと呼ぶ。

短く太い空気の柱 半径r 回転v 上昇 高さ2倍 半径r/√2 回転√2 v
上昇に引き伸ばされた空気の柱は細くなり、半径が縮んだ分だけ回転が速くなる

もっとも、回転はどこまでも速くなるわけではない。半径が小さくなるほど隣り合う空気の速さの差が大きくなり、乱流による混合がその差をならしていく。引き伸ばしと混合がつり合った形を式にしたのが、バーガースとロットの渦だ3。

は空気が中心へ寄る割合(1/s)で、半径の所では内向きの速さがになる。は混合の強さを表す渦粘性(m²/s)である。中心の近くはかたまりのように回り、外ではで弱まる。いちばん速いのはの所で、この半径の内側を渦の核と呼ぶ。引き伸ばしが強いほど、混合が弱いほど、核は細い。

仕組み

回転したままの空気は、遠心力の壁で止まる

回転する空気が外へ飛び出さないのは、中心の気圧が低く、内向きに押されているからだ。半径の向きの力のつり合いは次の式で書ける。

は気圧(Pa)、は空気の密度(約1.2 kg/m³)である。これを外から積分すれば、中心の気圧の低下が出る。核の中がかたまりのように回る最も単純な模型のランキン渦なら、最大の回転の速さをとしてだ。秒速80 mならおよそ77 hPaになる。

気圧の低い芯は空気を吸い込みそうに見えるが、その低圧を作っているのは回転そのものである。回転を保った空気を半径まで運ぶには、遠心力に逆らう仕事(1 kgあたり)が要り、内へ行くほど急に増える。上昇気流が払える分を超えると、空気はそれ以上入れない。中心にはほとんど動かない空気が残り、回転と上昇はその外側の輪で起きる4。

漏斗雲の形も、この気圧で決まる。湿った空気は気圧が下がると膨らんで冷え、雲になる。漏斗雲の縁の気圧は、親雲の底の気圧にほぼ等しい5。高さのふだんの気圧は、雲の底の高さよりだけ高いので、雲になる範囲は次の式で表せる。

は重力加速度(9.8 m/s²)で、高さ8.5 mにつき約1 hPaの計算である。雲の底が500 mなら、地面まで雲が届くには約59 hPaの低下が要る。だから漏斗雲の先が空中で止まっていても、その下の地面では強い風が吹いていることがある。

仕組み

地面の摩擦が、壁の内側へ空気を送る

紅茶をかき混ぜて手を止めると、茶葉はカップの縁でなく、底の真ん中に集まる。遠心力で外へ飛ばされそうなのに、不思議な眺めだ。アインシュタインも川の蛇行を論じた1926年の論文を、この実験から始めている6。

回転する流れの中では、どの高さでも、中心向きの気圧の力と遠心力がつり合っている。ところが底の近くでは摩擦で回転が遅くなり、遠心力が弱まる。気圧の力は上の層とほとんど変わらないので、底の水だけが内へ流れる7。

中心 気圧の力 遠心力 摩擦層の上では2つの力がつり合い、半径は変わらない 気圧の力 遠心力 摩擦層 地面の近くでは回転が遅く遠心力が弱いので、気圧の力で中心へ流れる 上昇へ
摩擦層の上では2つの力がつり合う。地面の近くでは遠心力が弱まり、空気は気圧の力で中心へ流れる

竜巻の足もとでも同じことが起きる。地面すれすれの空気は、回転を失う代わりに遠心力の壁を通り抜け、中心のそばで上向きに折れ曲がる。回転は弱まっていても、半径がはるかに小さいので速さは上がる。画面の計算では、は摩擦のないときの6割ほどに減るのに、核の半径は116 mから47 mへ縮み、最大の回転の速さは秒速58 mから約80 mへ上がった。

この説明は、竜巻を模した実験装置の測定と数値計算で組み立てられてきた理論である8。本物の竜巻の足もとは測るのが難しく、乱流や3次元の動きを含めてどこまで成り立つかは、まだ研究が続いている。

形を大きく変える量がもう1つある。周りの回転と上昇の比で、スワール比と呼ぶ。竜巻を調べる実験装置で使われ、穴の半径での回転の速さを、穴を通る平均の上昇の速さをとすると、簡単な形になる9。

ウォードの装置やパデュー大学の装置の実験と、ロタンノの数値計算によると、が小さいうちは中心がまるごと上昇する1本の渦になる。を上げると、上昇の柱の途中で流れが急に広がる渦崩壊が現れ、地面へ降りてくる。その先では中心に下降流ができ、上昇の輪がそれを包む2重の渦になる。さらに大きいと、輪の上に小さな渦がいくつも並ぶ10。

1本の渦 中心はすべて上昇 2重の渦 中心の下降を上昇の輪が包む 複数の渦(上から見た図) 輪の上を小さな渦が回る Sが小さい Sが大きい
スワール比が大きくなると、1本の渦から2重の渦、複数の渦へ変わる

中心の流れが下向きに変わるのは、回転が気圧の坂を作るからだ。地面の近くで回転が強く、上ほど弱ければ、中心の気圧は上ほど高い。上昇する空気はこの坂を登らなければならず、回転が強くなると登りきれなくなる。摩擦で強まった足もとの回転は、こうして自分の強まりにも歯止めをかける11。

試す

砂ぼこりを放ち、回転を強める

操作パネルの「地上近くの風」は高さ30 mの値で、地面の輪は各階級以上の風が吹く範囲を示す。左下のグラフの上の段は、実線が最も速い高さの回転で、破線と点線は高さ200 mの上下の流れと回転である。下の段は気圧の低下を示す。

  1. 細い竜巻は、上昇が秒速20 m、周りの回転が秒速15 mで、は0.75だ。少し早送りしたあと、高さ100 m前後で秒速約80 m、半径47 mの核ができる。高さ400 mの秒速約50 m、半径116 mより細くて速い。砂ぼこりの筋が地面の近くでほぼまっすぐ中心へ向かい、芯のそばで急に巻き上がるのは、そこで回転が失われているからである。「渦粘性」のつまみで混合を0.5倍にすると半径34 m、秒速約120 m、2倍では半径約75 m、秒速約40 mに変わる。混合が強いほど摩擦層が厚くなり、核も太るが、半径の変わり方はつまみの倍率より小さい。
  2. 地面の摩擦を切ると、芯は半径116 mに太り、速さは秒速58 mに落ちる。断面の流線は中心を避けて立ち上がり、砂ぼこりは輪の外で巻き上がって、中心に穴を残す。ここで上昇を秒速40 mにすると半径78 m、秒速約82 m、秒速10 mにすると半径約160 m、秒速約40 mになる。半径と速さの積はどれも約6,500 m²/sで、外から入る7,500 m²/sの9割近くが保たれる。上昇が4倍で半径がほぼ半分、速さがほぼ2倍になるのはこのためだ。摩擦を入れたままなら、上昇を秒速40 mにしても半径は52 mとほとんど変わらない。核の太さを決めるのが、上昇ではなく摩擦層の厚さに移るからである12。
  3. 周りの回転を秒速15 mから40 mまで、流れを5倍速にして強める。途中で最大の回転は一時的に秒速100 mを超えるが、やがて上空から渦崩壊が下りてきて核が太くなる。最後は半径240 m、秒速約58 mの渦に落ち着き、中心の流れは高さ200 mより上で秒速約5 mの下降に変わる。気圧の低下は小さくなるので、漏斗雲は高さ240 mあたりまで縮むが、砂ぼこりは中心で巻き上がり続ける。途中の強まりはこの計算の経過で、本物の竜巻が同じ順に変わるとは限らない。
  4. 周りの回転を秒速60 m、を3にすると、半径約270 mの輪の上に小さな渦が4つ並んで回る。この小さな渦は、流れの計算から出たものではなく、後から足した模型である。
  5. 長さを0.08倍、速さを0.2倍にすると砂ぼこりの旋風になる。上昇は秒速4 m、周りの回転は秒速2 mだ。核の半径は約3 m、最大の回転の速さは秒速約12 m、気圧の低下は約3 hPaで、高さ1,000 m級の雲の底まで持ち上げる約120 hPaには遠く、雲はできない。
  6. 上昇が秒速25 m、周りの回転が秒速22 mの竜巻に、物を置く。最大の回転の速さは秒速約100 m、地上近くの風は秒速約68 mだ。トタン板はらせんを描いて雲まで上がる。自動車は外側なら地面を滑るだけで、中心の近くでは上昇流に持ち上げられ、高さ約16 mから外へ投げ出される。鋼管はほとんど浮かない。物の動きは単純な模型で、本物の被害の予測や避難の判断には使えない。
現実では

風速は被害から逆算され、設計にも使われる

竜巻の風速を直接測れることはまれで、ふつうは被害から見積もる。気象庁は2016年4月から、日本の建物に合わせた日本版改良藤田スケール(JEF)を使っている13。

階級風速(3秒平均)被害の例
JEF025〜38 m/s物置や自動販売機の横転
JEF139〜52 m/s軽自動車やコンパクトカーの横転
JEF253〜66 m/sワンボックス車や大型自動車の横転
JEF367〜80 m/s木造住宅の倒壊
JEF481〜94 m/s工場や倉庫の大きなひさしの屋根材のはく離
JEF595 m/s〜鉄骨造の倉庫の倒壊

軽自動車やコンパクトカーはJEF1、秒速39〜52 mの風で横転する。画面の自動車には転がる動きが入っておらず、大きく動くのは上昇流の中だけだ。本物の車の危険は、画面の動きより大きい。

日本で確認される竜巻は、海上を除いて年に25個ほど(2007〜2015年)で、9月と10月に多い。F4以上はまだ確認されていない2。日本で最も犠牲者が多かったのは、2006年11月に北海道佐呂間町で9人が亡くなった竜巻である14。

測れた例もある。1999年5月3日に米オクラホマ州を襲った竜巻では、移動式のドップラーレーダーが秒速140 m前後の風を捉えた15。2003年6月24日のサウスダコタ州マンチェスターの竜巻では、進路に置いた測定器の気圧が950 hPaから850 hPaへ、10秒ほどで約100 hPa下がった16。

画面の細い竜巻は、中心の気圧の低下が高さ100 m前後で150〜170 hPaに達するのに、地面の中心では約20 hPaしかない。測定値は地面のものなので、地面の中心は本物より小さく出ているとみられる。計算の摩擦層が厚く、中心の地面近くで回転が弱いからだろう。漏斗雲の先が高さ約40 mで止まるのも、同じ理由とみられる。旋風のほうは、米アリゾナ州で測定車ごと旋風に入った観測があり、最大の回転の速さは秒速9.4 m、気圧の低下は1 hPaだった17。画面の値と同じ桁である。

原子力発電所の設計では、この風を飛来物の速さに換算する。米国原子力規制委員会の指針は、竜巻をランキン渦で表し、最も厳しい地域で最大風速103 m/s、回転の速さ82 m/s、半径45.7 m、気圧の低下83 hPaとする。質量1,810 kgの自動車は(抗力係数×面積÷質量)を0.0070 m²/kgとして、水平方向に最大41 m/sで飛ぶと定めた18。画面の自動車はこので動き、最高で秒速33 mほどだった。

竜巻が迫ったときの声として気象庁が挙げるものに、「気圧の変化で耳に異常を感じた」がある。「ゴーという音」や、筒状に舞い上がる飛散物も同じ兆しだ。同じ案内によると、屋内なら窓から離れて1階の窓のない部屋へ移り、丈夫な机の下で頭を守る。屋外なら頑丈な構造物の物陰に入って身を小さくする。物置や車庫、プレハブの中は危険で、電柱や太い樹木も倒れることがあるという19。

省いていること

計算が省いていること

  • 竜巻の発生は計算していない。周りの回転と上昇は条件として与え、雲の中の浮力、水蒸気、雨や冷たい下降流は入れていない。雲の底は高さ500 mの平らな蓋とした。竜巻がどう生まれるかは、研究者の間でもまだよく分かっていない20
  • 流れはどの向きから見ても同じ(軸対称)で、空気の密度は一定とした。竜巻の移動や本物の乱流の渦は入っていない。乱れは渦粘性で置き換え、その大きさと、地面の近くで弱める割合、が大きいほど強める割合は、風速が現実の範囲に収まるように選んだ想定の値である。つまみの端では風速が秒速200 m、気圧の低下が900 hPaを超えることがあるが、この単純な計算の中だけの値だ
  • 摩擦のある地面は滑らない面とし、地面の近くでも高さ5 m刻みほどの粗い格子で解いた。計算の摩擦層は厚さ約70 mあり、最も速い高さの100 m前後とともに、本物より高いとみられる21。中心の上昇も秒速約150 mと、穴を通る平均の7倍を超え、トタン板が秒速140 m台まで速くなるのはこの上昇に乗るからである。地上近くの風は高さ30 mの値で代表させた。JEFの風速は3秒平均で、画面の値は計算の瞬間の値である
  • 複数の渦は、が2.4を超えると輪の上に足す模型だ。数はから決め、輪を回る速さは最大の回転の半分、半径は核の1割とした
  • 飛ばされる物は、風の抗力と重力と地面の摩擦(係数0.6の想定)だけで動く。揚力、転がり、物どうしの衝突は入れていない。高さ30 mより下でも、物には高さ30 mの風を当てた。トタン板の(0.39 m²/kg)は想定の値で、画面の物は3倍の大きさで描いた
参考文献

参考文献

  1. 気象庁「竜巻等の突風データベース 2012/05/06 12:35 茨城県常総市で発生した竜巻」. https://www.data.jma.go.jp/stats/data/bosai/tornado/2012050603/list.html
  2. 気象庁「アメリカで発生するトルネードとの違い」. https://www.jma.go.jp/jma/kishou/know/toppuu/tornado1-3.html
  3. 気象庁「日本版改良藤田(JEF)スケールとは」. https://www.jma.go.jp/jma/kishou/know/toppuu/tornado1-2-2.html
  4. Rotunno, R.「The Fluid Dynamics of Tornadoes」講義資料Lecture 2〜5, 米国大気研究センター. https://www2.mmm.ucar.edu/people/rotunno/Papers/FluidDynamicsTornadoes2.pdf
  5. U.S. Nuclear Regulatory Commission「Design-Basis Tornado and Tornado Missiles for Nuclear Power Plants」Regulatory Guide 1.76 Revision 1, 2007. https://www.nrc.gov/docs/ML0703/ML070360253.pdf
  6. 原子力規制委員会「原子力発電所の竜巻影響評価ガイドの一部改正について」2014年9月17日. https://www.nra.go.jp/data/000069285.pdf
  7. Tratt, D. M., Hecht, M. H., Catling, D. C., Samulon, E. C., Smith, P. H.「In situ measurement of dust devil dynamics: Toward a strategy for Mars」『Journal of Geophysical Research: Planets』108(E11), 2003. https://faculty.washington.edu/dcatling/Tratt_etal2003.pdf

注

  1. 参考文献1。藤田スケールF3、被害域の幅500 m、長さ17.0 km、継続時間18分、移動速度は時速60 km、回転は反時計回り。スーパーセルに伴う竜巻とされる。全壊76棟は住家の数で、ほかに非住家の全壊が105棟ある。 ↩

  2. 参考文献2。親雲の回転はメソサイクロンと呼ばれ、直径は数kmである。日本の竜巻には、スーパーセルに伴わないものも多いという。米国の竜巻は年に約1,300個(2004〜2006年)で、単位面積あたりでは日本の約2倍にあたる。 ↩ ↩2

  3. Burgers, J. M.「A Mathematical Model Illustrating the Theory of Turbulence」『Advances in Applied Mechanics』1, 171–199, 1948と、Rott, N.「On the viscous core of a line vortex」『ZAMP』9, 543–553, 1958。式の形はWikipedia「Burgers vortex」の式を、と置いて書き直した。https://en.wikipedia.org/wiki/Burgers_vortex ↩

  4. 参考文献4のLecture 2。回転を保った空気を小さな半径まで運ぶには大きな仕事が要り、渦は有限の半径で流れが離れる形に落ち着く。中心に動かない空気を置く簡単な模型は、Davies-Jones(1973年)のものとして紹介されている。本物の核が完全に止まっているわけではない。 ↩

  5. Wikipedia「Funnel cloud」。https://en.wikipedia.org/wiki/Funnel_cloud 親雲の底の気圧と同じ気圧になる面を、計算ではとして求めた。 ↩

  6. Einstein, A.「Die Ursache der Mäanderbildung der Flußläufe und des sogenannten Baerschen Gesetzes」『Die Naturwissenschaften』14(11), 223–224, 1926。底の近くでは摩擦で回転と遠心力が弱まり、底を中心へ向かう流れができるという説明が冒頭にある。英訳 https://www.literature.wolkersdorfer.info/literature/The%20cause%20of%20the%20formation%20of%20meanders%20in%20the%20courses%20of%20rivers%20and%20of%20the%20so-called%20Baers%20law.pdf ↩

  7. 参考文献4のLecture 4。上の層ではがつり合い、摩擦のある底の近くでは必ず内向きの流れができる。 ↩

  8. 参考文献4のLecture 2〜5。同資料はFiedler, B. H., Rotunno, R.「A Theory for the Maximum Windspeeds in Tornado-like Vortices」『Journal of the Atmospheric Sciences』43(21), 2328–2340, 1986と、Lewellen, D. C., Lewellen, W. S.「Near-Surface Intensification of Tornado Vortices」同誌64(7), 2176–2194, 2007を引き、地面を滑る面にした計算では摩擦層と足もとの細い渦が消えることも示す。結びでは、軸対称の定常な理論は実験と計算で確かめられているが、3次元の効果はそれほどでもないとする。 ↩

  9. 参考文献4のLecture 2。元の定義はで、は、は単位角度あたりの流量である。穴を通る流量をで書くととなり、に整理できる。式はDavies-Jones, R. P.「The Dependence of Core Radius on Swirl Ratio in a Tornado Simulator」『Journal of the Atmospheric Sciences』30(7), 1427–1430, 1973による。 ↩

  10. 参考文献4のLecture 2と5。同資料はWard, N. B.「The Exploration of Certain Features of Tornado Dynamics Using a Laboratory Model」『Journal of the Atmospheric Sciences』29(6), 1194–1204, 1972、Church, C. R., Snow, J. T., Baker, G. L., Agee, E. M.「Characteristics of Tornado-Like Vortices as a Function of Swirl Ratio: A Laboratory Investigation」同誌36(9), 1755–1776, 1979、Rotunno, R.「A Study in Tornado-Like Vortex Dynamics」同誌36(1), 140–155, 1979を引く。同資料のパデュー大学の装置の図はが0.1〜1.0の範囲で、画面の計算で2重の渦になる(2前後)とは一致しない。 ↩

  11. Lewellen, D. C., Lewellen, W. S.「On the limits to near-surface intensification of tornado vortices」第23回Conference on Severe Local Storms, 2006の要旨は、地面近くの強い回転と気圧の低下が上下の気圧の差を生み、中心に下降流を起こして強まりを抑えると説明している。https://ams.confex.com/ams/23SLS/webprogram/Paper115321.html ↩

  12. 参考文献4のLecture 4。摩擦層の厚さはに比例し、足もとの細い渦の半径はこの厚さに比例するとされる。この関係に上昇の強さは入らない。 ↩

  13. 参考文献3。被害の例は表のために短くした。被害の指標は2024年4月から31種類ある。 ↩

  14. 参考文献2。同じ竜巻はF3で、気象庁「竜巻等の突風の顕著な事例(過去の主な事例)」にも載っている。https://www.data.jma.go.jp/stats/data/bosai/tornado/special/special_history.html ↩

  15. 米国立気象局ノーマン予報所の解説は、研究チームが発表した値を時速318マイル(約142 m/s)とし、検証の途中で少し下がる見込みにも触れている。https://www.weather.gov/oun/events-19990503 その後の値はWikipedia「Mobile radar observation of tornadoes」の紹介によると、Wurman, J., Alexander, C., Robinson, P., Richardson, Y.「Low-Level Winds in Tornadoes and Potential Catastrophic Tornado Impacts in Urban Areas」『Bulletin of the American Meteorological Society』88(1), 31–46, 2007で地上約32 mの135 m/s、2021年の再解析で時速321マイル(約143 m/s)である。https://en.wikipedia.org/wiki/Mobile_radar_observation_of_tornadoes ↩

  16. National Weather Service Sioux Falls「Record Pressure Drop Recorded with Manchester Tornado - 24 June 2003」の図(Probe 3)。https://www.weather.gov/fsd/20030624-tornadosamaras F4と評定された竜巻で、幅は約800 mだった。別の測定器(Probe 5)の低下は約55 hPaである。 ↩

  17. 参考文献7のTable 1。見える砂ぼこりの半径は1〜2 mのものが多かったという。 ↩

  18. 参考文献5のTable 1とTable 2(Region I)。鋼管は質量130 kg、は0.0043 m²/kgで、画面でも同じ値を使った。日本の原子力規制委員会のガイドの例(参考文献6の解説表4.1)では、設計竜巻の最大風速を100 m/sとしたとき、質量8.4 kgの鋼製パイプは49 m/s、2,300 kgのコンテナは60 m/sとされる。 ↩

  19. 気象庁「竜巻から身を守るには」。https://www.jma.go.jp/jma/kishou/know/toppuu/tornado4-3.html 気象庁の情報の名前は、2026年5月に「竜巻注意情報」から「気象防災速報(竜巻注意/竜巻目撃)」に変わった。 ↩

  20. 参考文献4のLecture 5の結び。 ↩

  21. Wikipedia「Mobile radar observation of tornadoes」によると、1998年5月30日の米サウスダコタ州スペンサーの竜巻では、地上50 mよりかなり低い高さで時速264マイル(約118 m/s)が測られた。 ↩

プログラム
プログラム

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

このシミュレーションは、1刻みごとに「回転と渦度を流れで運ぶ → 流れ関数を解いて速さを出す」をくり返し、1コマごとに気圧を解いて漏斗雲の形を決める。ここまでは普通の数値計算で、three.jsは使わない。three.jsが受け持つのは、地面や雲の立体、影、視点である。そこへ計算した半径を頂点として渡し、砂ぼこりの粒はGPUの中で流れに乗せて運ぶ。

長さはメートル、格子は中心と地面で細かい

竜巻の大きさでは、外の縁が半径1,000 m、雲の底が高さ500 m、吸い上げる穴の半径が500 mである。旋風はこれをL倍、速さをU倍した相似形にする。

定数と単位(src/sims/tornado-vortex.ts の 26〜52 行)
const NR = 64, NZ = 40, NI = NR + 1, NJ = NZ + 1, NN = NI * NJ; // 格子の数。画質を変えても同じ(同じ条件なら同じ竜巻になる)const BR = 3.2, BZ = 2.6; // 格子の間隔の広がり方。中心の近くと地面の近くを細かくするconst RHO = 1.2, GRAV = 9.8; // 空気の密度 kg/m³・重力加速度 m/s²const TICK = 0.04; // 流れの計算の刻み(秒)。これを安定な長さにさらに分けて進めるconst SUBMAX = 12; // 1 刻みを分ける回数の上限const SPINMS = 6; // 早送りに 1 コマで使ってよい時間(ミリ秒)const RH = 0.7; // 雲の穴のうち、流れの形を決めない内側の割合const ZF = 80, FNEAR = 0.2; // 高さ ZF より下で渦粘性を小さくする。地面での割合が FNEARconst Z_SFC = 30; // 「地上近くの風」を測る高さ(竜巻の大きさのとき。m)const S_MULTI = 2.4; // 小さな渦が現れるスワール比(想定の値)const JEF = [25, 39, 53, 67, 81, 95]; // 日本版改良藤田スケールの各階級の下限 m/sconst V_RECORD = 145; // これまでに竜巻で測られた風速(約 140 m/s)を超えたと知らせる値 m/s// 大きさごとの尺度。旋風は長さ 0.08 倍・速さ 0.2 倍の相似形interface Size { L: number; U: number; cloud: boolean; wmax: number; vmax: number }const SIZES: Record<string, Size> = {  tornado: { L: 1, U: 1, cloud: true, wmax: 40, vmax: 60 },  dust: { L: 0.08, U: 0.2, cloud: false, wmax: 12, vmax: 12 },};// 飛ばされる物。KD は抗力係数 × 面積 ÷ 質量(m²/kg)。自動車と鋼管は米国原子力規制委員会の指針の表の値。トタン板は想定の値interface Kind { name: string; KD: number; size: [number, number, number] }const KINDS: Record<string, Kind> = {  car: { name: "自動車", KD: 0.007, size: [5, 1.3, 2] },  pipe: { name: "鋼管", KD: 0.0043, size: [4.58, 0.17, 0.17] },  sheet: { name: "トタン板", KD: 0.39, size: [1.8, 0.03, 0.9] },};const VIS = 3; // 竜巻の大きさのとき、飛ばされる物を見やすいように描く倍率(計算には使わない)const MAXOBJ = 12, TRAIL = 240;

格子の数は画質に関係なく同じ(NR×NZ)にした。画質で流れが変わると、本文の数字と合わなくなるからだ。格子の間隔はsinhの形で広げてあり、中心の近くは約4 m、地面の近くは約5 m、外の縁では約50 mになる。

回転と渦度を運び、流れ関数を解く

本文のに加えて、流れの面内の渦度を半径で割ったを未知数にする。は中心でも発散しないので、割り算で困らない。高さによって回転が違うと遠心力に差が出て、縦の循環が生まれる。その項がratesのkind === 1の部分で、にあたる。

流れの計算(src/sims/tornado-vortex.ts の 54〜261 行)
interface Flow {  L: number; R: number; H: number; r0: number;  r: Float64Array; z: Float64Array; ir: Float64Array; iz: Float64Array; // 格子の位置と、隣との間隔の逆数  rm: Float64Array; rp: Float64Array; r1: Float64Array; zm: Float64Array; zp: Float64Array; // 2 階微分と 1 階微分の係数  fn: Float64Array; // 高さごとの渦粘性の割合  G: Float64Array; X: Float64Array; P: Float64Array; U: Float64Array; W: Float64Array; // Γ・ξ・ψ・u・w  G2: Float64Array; X2: Float64Array; dG: Float64Array; dX: Float64Array; NU: Float64Array;  A: Float64Array; b: Float64Array; iH: number; // ψ を解く行列(分解済み)と右辺  Qs: number; Gam: number; nu: number; l2: number; numax: number; uScale: number; noslip: boolean;}const Q = (i: number, j: number) => i + j * NI;const NUK = NR - 1, BW = NZ, W2 = 2 * BW + 1, NU_ROWS = NUK * NZ; // ψ の未知数の並びと帯の幅const row = (i: number, j: number) => (i - 1) * NZ + (j - 1);const lidShape = (s: number) => { const q = Math.min(1, s); return q * q * (2 - q * q); }; // 雲の底の穴を通る流量の分布 // ψ の方程式は格子の形だけで決まるので、1 回だけ分解して使い回す(大きさごと)const factored = new Map<number, { A: Float64Array; iH: number }>();function factor(F: Omit<Flow, "A" | "iH">) {  const got = factored.get(F.L);  if (got) return got;  const { r, z, rm, rp, r1, zm, zp, r0 } = F;  let iH = 0; while (iH < NR && r[iH] < RH * r0) iH++;  const A = new Float64Array(NU_ROWS * W2);  const at = (a: number, b: number) => a * W2 + (b - a + BW);  for (let i = 1; i < NR; i++) for (let j = 1; j <= NZ; j++) {    const k = row(i, j);    if (j === NZ && i >= iH) { A[at(k, k)] = 1; continue; } // 雲の底の穴の外側: ψ を決める    let cS: number, cN: number, cC: number;    if (j < NZ) { cS = zm[j]; cN = zp[j]; cC = -(rm[i] + rp[i] + zm[j] + zp[j]); }    else { const d = z[NZ] - z[NZ - 1]; cS = 2 / (d * d); cN = 0; cC = -(rm[i] + rp[i] + cS); } // 穴の内側: 上下の向きにだけ流れる(∂ψ/∂z = 0)    A[at(k, k)] = cC;    if (i > 1) A[at(k, row(i - 1, j))] = rm[i] + r1[i];    if (i < NR - 1) A[at(k, row(i + 1, j))] = rp[i] - r1[i];    if (j > 1) A[at(k, row(i, j - 1))] = cS;    if (j < NZ) A[at(k, row(i, j + 1))] = cN;  }  for (let k = 0; k < NU_ROWS - 1; k++) {    const piv = A[k * W2 + BW], mx = Math.min(BW, NU_ROWS - 1 - k);    for (let m = 1; m <= mx; m++) {      const rw = (k + m) * W2, l = A[rw + BW - m];      if (l === 0) continue;      const f = l / piv; A[rw + BW - m] = f;      for (let n = 1; n <= mx; n++) A[rw + BW - m + n] -= f * A[k * W2 + BW + n];    }  }  const out = { A, iH }; factored.set(F.L, out);  return out;} function makeFlow(L: number): Flow {  const R = 1000 * L, H = 500 * L, r0 = 500 * L;  const r = new Float64Array(NI), z = new Float64Array(NJ);  for (let i = 0; i < NI; i++) r[i] = (R * Math.sinh((BR * i) / NR)) / Math.sinh(BR);  for (let j = 0; j < NJ; j++) z[j] = (H * Math.sinh((BZ * j) / NZ)) / Math.sinh(BZ);  const f64 = (n: number) => new Float64Array(n);  const ir = f64(NI), iz = f64(NJ), rm = f64(NI), rp = f64(NI), r1 = f64(NI), zm = f64(NJ), zp = f64(NJ), fn = f64(NJ);  for (let i = 1; i < NR; i++) { const a = r[i] - r[i - 1], b = r[i + 1] - r[i]; ir[i] = 2 / (a + b); rm[i] = 2 / (a * (a + b)); rp[i] = 2 / (b * (a + b)); r1[i] = 1 / (r[i] * (a + b)); }  for (let j = 1; j < NZ; j++) { const a = z[j] - z[j - 1], b = z[j + 1] - z[j]; iz[j] = 2 / (a + b); zm[j] = 2 / (a * (a + b)); zp[j] = 2 / (b * (a + b)); }  for (let j = 0; j < NJ; j++) fn[j] = FNEAR + (1 - FNEAR) * Math.min(1, z[j] / (ZF * L));  const base = { L, R, H, r0, r, z, ir, iz, rm, rp, r1, zm, zp, fn,    G: f64(NN), X: f64(NN), P: f64(NN), U: f64(NN), W: f64(NN), G2: f64(NN), X2: f64(NN), dG: f64(NN), dX: f64(NN), NU: f64(NN),    b: f64(NU_ROWS), Qs: 0, Gam: 0, nu: 0, l2: 0, numax: 250, uScale: 1, noslip: true };  const { A, iH } = factor(base);  return { ...base, A, iH };} /** 読者のつまみの値を、流れの条件にする。Qs = 穴の流量 ÷ 2π = W r0² / 2、Γ∞ = r0 v0 */function setForcing(F: Flow, W: number, v0: number, core: number, U: number) {  const S = v0 / W, c = Math.min(6, core * (1 + 1.5 * Math.max(0, S - 0.75))); // 回転が強いほど(S が大きいほど)乱れも強い(想定の式)  const old = F.Gam;  F.Qs = (W * F.r0 * F.r0) / 2; F.Gam = F.r0 * v0;  F.nu = (c * F.Gam) / 750; F.l2 = c * 0.6 * (v0 / U) * F.L * F.L; F.numax = 250 * F.L * U; F.uScale = U; // 渦粘性の上限と、速さの尺度  // 周りの回転を変えたら、外から伝わるのを待たずに全体の回転を同じ割合で変える  if (old > 0 && old !== F.Gam) { const k = F.Gam / old; for (let q = 0; q < NN; q++) F.G[q] *= k; }} /** ψ を解き、u と w を求める。外の縁の ψ は高さに比例(一様に流れ込む)、雲の底の穴の外側の ψ は lidShape */function solvePsi(F: Flow) {  const { r, z, X, P, U, W, A, b, Qs, iH, r0, H, rp, r1 } = F;  for (let i = 1; i < NR; i++) for (let j = 1; j <= NZ; j++) {    const k = row(i, j), q = Q(i, j);    if (j === NZ && i >= iH) { b[k] = -Qs * lidShape(r[i] / r0); continue; }    let v = r[i] * r[i] * X[q];    if (i === NR - 1) v -= (rp[i] - r1[i]) * (-Qs * z[j] / H);    b[k] = v;  }  for (let k = 1; k < NU_ROWS; k++) {    let s = b[k]; const c0 = Math.max(0, k - BW), base = k * W2 + BW - k;    for (let c = c0; c < k; c++) s -= A[base + c] * b[c];    b[k] = s;  }  for (let k = NU_ROWS - 1; k >= 0; k--) {    let s = b[k]; const c1 = Math.min(NU_ROWS - 1, k + BW), base = k * W2 + BW - k;    for (let c = k + 1; c <= c1; c++) s -= A[base + c] * b[c];    b[k] = s / A[k * W2 + BW];  }  for (let i = 1; i < NR; i++) for (let j = 1; j <= NZ; j++) P[Q(i, j)] = b[row(i, j)];  for (let j = 0; j <= NZ; j++) { P[Q(0, j)] = 0; P[Q(NR, j)] = (-Qs * z[j]) / H; }  for (let i = 0; i <= NR; i++) P[Q(i, 0)] = 0;  for (let i = iH; i <= NR; i++) P[Q(i, NZ)] = -Qs * lidShape(r[i] / r0);  for (let j = 0; j <= NZ; j++) {    const jm = Math.max(0, j - 1), jp = Math.min(NZ, j + 1), dz = 1 / (z[jp] - z[jm]);    for (let i = 1; i <= NR; i++) {      const q = Q(i, j), ip = Math.min(NR, i + 1);      U[q] = j === NZ ? 0 : ((P[Q(i, jp)] - P[Q(i, jm)]) * dz) / r[i];      W[q] = -(P[Q(ip, j)] - P[Q(i - 1, j)]) / (r[ip] - r[i - 1]) / r[i];    }    W[Q(0, j)] = (-2 * P[Q(1, j)]) / (r[1] * r[1]); U[Q(0, j)] = 0;  }  for (let i = 0; i <= NR; i++) { const q = Q(i, 0); U[q] = 0; W[q] = 0; }} /** 境界の値を入れる */function applyBc(F: Flow, G: Float64Array, X: Float64Array) {  const { r, z, P, W, noslip, Gam, iH } = F;  for (let j = 0; j <= NZ; j++) { G[Q(0, j)] = 0; X[Q(0, j)] = X[Q(1, j)]; G[Q(NR, j)] = j === 0 && noslip ? 0 : Gam; X[Q(NR, j)] = 0; }  const dz2 = z[1] * z[1];  for (let i = 1; i <= NR; i++) {    const q0 = Q(i, 0);    if (noslip) { G[q0] = 0; X[q0] = (2 * P[Q(i, 1)]) / (r[i] * r[i] * dz2); } // 滑らない地面の渦度(トムの式)    else { G[q0] = G[Q(i, 1)]; X[q0] = 0; }  }  X[Q(0, 0)] = X[Q(1, 0)];  for (let i = 1; i < NR; i++) {    const qt = Q(i, NZ), qb = Q(i, NZ - 1);    G[qt] = G[qb];    X[qt] = i >= iH ? 0 : W[qt] > 0 ? X[qb] : 0; // 雲の底は滑る面。穴から入ってくる空気は渦度なし  }  X[Q(0, NZ)] = X[Q(1, NZ)];} const lim = (a: number, b: number) => { const p = a * b; return p > 0 ? (2 * p) / (a + b) : 0; }; // 変化の急な所で振動しないようにする制限(van Leer) /** 乱れの強さ: ν + l² × 変形の速さ(混合距離の考え)。強い噴流ができるほど混ざりやすくなり、風速が際限なく上がらない */function eddy(F: Flow) {  const { r, ir, iz, G, U, W, NU, fn, nu, l2 } = F;  for (let j = 1; j < NZ; j++) for (let i = 1; i < NR; i++) {    const q = Q(i, j), ri = 1 / r[i], v = G[q] * ri;    const ve = G[q + 1] / r[i + 1], vw = i > 1 ? G[q - 1] / r[i - 1] : 0, vn = G[q + NI] * ri, vs = G[q - NI] * ri;    const a = (ve - vw) * ir[i] * 0.5 - v * ri, b = (vn - vs) * iz[j] * 0.5;    const c = (W[q + 1] - W[q - 1]) * ir[i] * 0.5 + (U[q + NI] - U[q - NI]) * iz[j] * 0.5;    const d = (U[q + 1] - U[q - 1]) * ir[i] * 0.5, e = (W[q + NI] - W[q - NI]) * iz[j] * 0.5, f = U[q] * ri;    NU[q] = Math.min(F.numax, nu * fn[j] + l2 * Math.sqrt(a * a + b * b + c * c + 2 * (d * d + e * e + f * f)));  }} /** 1 つの量の変化の速さ: 運ぶ項(2 次の風上差分)+ 粘性の項。kind = 1 は ξ で、遠心力の差の項が足される */function rates(F: Flow, f: Float64Array, out: Float64Array, kind: number, G: Float64Array) {  const { r, ir, iz, U, W, rm, rp, r1, zm, zp, NU } = F;  const c3 = kind === 1 ? 3 : -1;  for (let j = 1; j < NZ; j++) {    const izj = iz[j], zmj = zm[j], zpj = zp[j];    for (let i = 1; i < NR; i++) {      const q = i + j * NI, c = f[q], w = f[q - 1], e = f[q + 1], s = f[q - NI], n = f[q + NI];      const a = U[q] * ir[i], b = W[q] * izj;      let dr: number, dz: number;      if (a > 0) { const ww = i > 1 ? f[q - 2] : w; dr = c - w + 0.5 * (lim(c - w, e - c) - lim(w - ww, c - w)); }      else { const ee = i < NR - 1 ? f[q + 2] : e; dr = e - c - 0.5 * (lim(e - c, ee - e) - lim(c - w, e - c)); }      if (b > 0) { const ss = j > 1 ? f[q - 2 * NI] : s; dz = c - s + 0.5 * (lim(c - s, n - c) - lim(s - ss, c - s)); }      else { const nn = j < NZ - 1 ? f[q + 2 * NI] : n; dz = n - c - 0.5 * (lim(n - c, nn - n) - lim(c - s, n - c)); }      let d = -(a * dr + b * dz) + NU[q] * (rm[i] * w + rp[i] * e - (rm[i] + rp[i]) * c + c3 * r1[i] * (e - w) + zmj * s + zpj * n - (zmj + zpj) * c);      if (kind === 1) { const k = 1 / (r[i] * r[i]), on = G[q + NI] * k, os = G[q - NI] * k; d += (on * on - os * os) * izj * 0.5; }      out[q] = d;    }  }} /** 短い時間 dt 進める(2 段のルンゲ・クッタ法)。そのあと ψ を解き直す */function substep(F: Flow, dt: number) {  const { G, X, G2, X2, dG, dX } = F;  applyBc(F, G, X); eddy(F);  rates(F, G, dG, 0, G); rates(F, X, dX, 1, G);  G2.set(G); X2.set(X);  for (let j = 1; j < NZ; j++) for (let q = 1 + j * NI, e = NR + j * NI; q < e; q++) { G2[q] = G[q] + dt * dG[q]; X2[q] = X[q] + dt * dX[q]; }  applyBc(F, G2, X2);  rates(F, G2, dG, 0, G2); rates(F, X2, dX, 1, G2);  for (let j = 1; j < NZ; j++) for (let q = 1 + j * NI, e = NR + j * NI; q < e; q++) { G[q] = 0.5 * (G[q] + G2[q] + dt * dG[q]); X[q] = 0.5 * (X[q] + X2[q] + dt * dX[q]); }  applyBc(F, G, X);  solvePsi(F);} /** 流れを 1 刻み(TICK 秒)進める。速さと粘性から安定な長さを求めて、必要なら分ける */function tick(F: Flow) {  const { r, z, ir, iz, U, W, NU } = F;  let rate = 1e-6, numax = F.nu;  for (let j = 1; j < NZ; j++) for (let i = 1; i < NR; i++) {    const q = i + j * NI, a = Math.abs(U[q]) * ir[i] + Math.abs(W[q]) * iz[j];    if (a > rate) rate = a;    if (NU[q] > numax) numax = NU[q];  }  const dif = numax * (2 / (r[1] * r[1]) + 2 / (z[1] * z[1]));  const n = Math.min(SUBMAX, Math.ceil(TICK * Math.max(rate / 0.5, dif / 0.45)));  const dt = TICK / n;  for (let k = 0; k < n; k++) substep(F, dt);} /** 最初の状態: 中心に太めの回転があり、地面の近くだけ回転が弱い */function initFlow(F: Flow) {  const { r, z, G, X, Gam, L, noslip } = F;  const rc = (noslip ? 80 : 110) * L, d0 = 12 * L; // 落ち着く形に近い太さから始めると、早送りが短くて済む  for (let j = 0; j <= NZ; j++) for (let i = 0; i <= NR; i++) {    const q = Q(i, j);    G[q] = Gam * (1 - Math.exp(-(r[i] * r[i]) / (rc * rc))) * (noslip ? 1 - Math.exp(-z[j] / d0) : 1);    X[q] = 0;  }  solvePsi(F);  eddy(F);}

solvePsiはの方程式を解く部分である。行列は格子の形だけで決まるので、最初に1回だけLU分解してfactoredにしまい、毎回は前進代入と後退代入だけで済ませる。外の縁のを高さに比例させると空気が一様に流れ込み、雲の底の穴で出ていく。全体の流量はの差で決まるので、つまみのがそのまま流量になる。

運ぶ項は風上の値に寄せた差分で、limが急な変化での振動を抑える。乱れの強さはeddyで求めている。基本の渦粘性に、混合距離の2乗×流れの変形の速さを足す形で、噴流が強くなるほど混合が強まる。渦粘性は場所で変わるが、式の中の渦粘性の勾配の項は省いた。tickは速さと渦粘性から安定な刻みの長さを求め、TICK(0.04秒)を必要な回数に分ける。回数は流れの状態だけで決まるので、画面の速さで結果は変わらない。

気圧を解いて、漏斗雲の形を出す

本文のは、上下の動きが弱い所のつり合いである。上昇の噴流の中はそれでは足りないので、運動の式の発散をとった気圧の方程式を解く。右辺には遠心力の項と、流れの加速の項が入る。

気圧と漏斗雲(src/sims/tornado-vortex.ts の 263〜363 行)
interface Derived {  dp: Float64Array; // 気圧の低下 Pa(半径と高さごと)  rf: Float64Array; rfs: Float64Array; // 漏斗雲の半径(高さごと)と、なめらかにした値  tip: number; // 漏斗雲の先の高さ m(雲の底のままなら H)  vm: number; rm: number; zm: number; jm: number; // いちばん速い回転・その半径・その高さ・高さの番号  sfc: number; rs: number; sfcSpeed: Float64Array; // 地上近くの風の最大とその半径・半径ごとの値  wc: number; jc: number; // 中心の上下の流れ・測る高さの番号  dp0: number; dpm: number; zp: number; jsfc: number; pA: Float64Array; pb: Float64Array; // 地面の中心の Δp・中心でいちばん大きい Δp とその高さ}function makeDerived(F: Flow): Derived {  let jsfc = 1; while (F.z[jsfc + 1] <= Z_SFC * F.L) jsfc++; // 格子の段のうち、Z_SFC の高さに最も近い段(約 29.9 m)  let jc = 1; while (F.z[jc] < 0.4 * F.H) jc++;  return { dp: new Float64Array(NN), rf: new Float64Array(NJ), rfs: new Float64Array(NJ), tip: F.H, vm: 0, rm: 0, zm: 0, jm: 1, sfc: 0, rs: 0, sfcSpeed: new Float64Array(NI), wc: 0, jc, dp0: 0, dpm: 0, zp: 0, jsfc, pA: factorP(F), pb: new Float64Array(PROWS) };}// 気圧の方程式: 運動の式の発散をとると ∇²(p/ρ) = (1/r)∂(v²)/∂r − (u_r² + u²/r² + w_z² + 2 u_z w_r)。// 右辺の 1 項目が回転の遠心力、残りが上昇や流れ込みの加速の分。外の縁を基準(p = 0)にして、地面と雲の底では上下の気圧の差を 0 とするconst PBW = NZ + 1, PW2 = 2 * PBW + 1, PROWS = NR * NJ;const prow = (i: number, j: number) => i * NJ + j;const pfactored = new Map<number, Float64Array>();function factorP(F: Flow) {  const got = pfactored.get(F.L);  if (got) return got;  const { r, z, rm, rp, r1, zm, zp } = F, A = new Float64Array(PROWS * PW2);  const at = (a: number, b: number) => a * PW2 + (b - a + PBW);  for (let i = 0; i < NR; i++) for (let j = 0; j <= NZ; j++) {    const k = prow(i, j);    let cC = 0;    if (j === 0) { const c = 2 / (z[1] * z[1]); A[at(k, prow(i, 1))] = c; cC -= c; }    else if (j === NZ) { const d = z[NZ] - z[NZ - 1], c = 2 / (d * d); A[at(k, prow(i, NZ - 1))] = c; cC -= c; }    else { A[at(k, prow(i, j - 1))] = zm[j]; A[at(k, prow(i, j + 1))] = zp[j]; cC -= zm[j] + zp[j]; }    if (i === 0) { const c = 4 / (r[1] * r[1]); A[at(k, prow(1, j))] = c; cC -= c; } // 中心では ∂p/∂r = 0    else {      A[at(k, prow(i - 1, j))] = rm[i] - r1[i];      if (i < NR - 1) A[at(k, prow(i + 1, j))] = rp[i] + r1[i];      cC -= rm[i] + rp[i];    }    A[at(k, k)] = cC;  }  for (let k = 0; k < PROWS - 1; k++) {    const piv = A[k * PW2 + PBW], mx = Math.min(PBW, PROWS - 1 - k);    for (let m = 1; m <= mx; m++) {      const rw = (k + m) * PW2, l = A[rw + PBW - m];      if (l === 0) continue;      const f = l / piv; A[rw + PBW - m] = f;      for (let n = 1; n <= mx; n++) A[rw + PBW - m + n] -= f * A[k * PW2 + PBW + n];    }  }  pfactored.set(F.L, A);  return A;}/** 流れの場から気圧の低下 Δp(Pa)を求める */function solvePressure(F: Flow, D: Derived) {  const { r, z, G, U, W, ir, iz } = F, A = D.pA, b = D.pb, dp = D.dp;  for (let i = 0; i < NR; i++) for (let j = 0; j <= NZ; j++) {    const q = Q(i, j), jm = Math.max(0, j - 1), jp = Math.min(NZ, j + 1), dzi = 1 / (z[jp] - z[jm]);    const wz = (W[Q(i, jp)] - W[Q(i, jm)]) * dzi;    let v = 0;    if (i === 0) { const om = G[Q(1, j)] / (r[1] * r[1]); v = 2 * om * om - 1.5 * wz * wz; }    else {      const ve = G[q + 1] / r[i + 1], vw = i > 1 ? G[q - 1] / r[i - 1] : 0, ri = 1 / r[i];      const v2r = (ve * ve - vw * vw) * ir[i] * 0.5 * ri;      const ur = (U[q + 1] - U[q - 1]) * ir[i] * 0.5, wr = (W[q + 1] - W[q - 1]) * ir[i] * 0.5, uz = (U[Q(i, jp)] - U[Q(i, jm)]) * dzi, ou = U[q] * ri;      v = v2r - (ur * ur + ou * ou + wz * wz + 2 * uz * wr);    }    b[prow(i, j)] = v;  }  for (let k = 1; k < PROWS; k++) { let s = b[k]; const c0 = Math.max(0, k - PBW), base = k * PW2 + PBW - k; for (let c = c0; c < k; c++) s -= A[base + c] * b[c]; b[k] = s; }  for (let k = PROWS - 1; k >= 0; k--) { let s = b[k]; const c1 = Math.min(PROWS - 1, k + PBW), base = k * PW2 + PBW - k; for (let c = k + 1; c <= c1; c++) s -= A[base + c] * b[c]; b[k] = s / A[k * PW2 + PBW]; }  for (let i = 0; i < NR; i++) for (let j = 0; j <= NZ; j++) dp[Q(i, j)] = -RHO * b[prow(i, j)];  for (let j = 0; j <= NZ; j++) dp[Q(NR, j)] = 0;}/** 気圧の低下・いちばん速い回転・漏斗雲の形を求める */function derive(F: Flow, D: Derived, cloud: boolean) {  const { r, z, G, U, W, H } = F, dp = D.dp;  solvePressure(F, D);  let vm = 0, im = 1, jm = 1;  for (let j = 1; j < NZ; j++) { if (z[j] > 0.85 * H) break; for (let i = 1; i < NR; i++) { const v = G[Q(i, j)] / r[i]; if (v > vm) { vm = v; im = i; jm = j; } } }  D.vm = vm; D.rm = r[im]; D.zm = z[jm]; D.jm = jm;  let sm = 0, sr = 0;  for (let i = 1; i <= NR; i++) { const q = Q(i, D.jsfc), v = G[q] / r[i], u = U[q], sp = Math.sqrt(v * v + u * u); D.sfcSpeed[i] = sp; if (sp > sm) { sm = sp; sr = r[i]; } }  D.sfc = sm; D.rs = sr;  // 中心の流れ: 途中に下降があれば、その中でいちばん速い下降を、なければ雲の底の 4 割の高さの上昇を測る  let wlow = 0; for (let j = 1; j < NZ; j++) if (z[j] > 0.1 * H && z[j] < 0.85 * H && W[Q(0, j)] < wlow) wlow = W[Q(0, j)];  D.wc = wlow < -3 * F.uScale ? wlow : W[Q(0, D.jc)];  D.dp0 = dp[Q(0, 0)]; let dpm = 0, zp = 0;  for (let j = 0; j <= NZ; j++) { const d = dp[Q(0, j)]; if (d > dpm) { dpm = d; zp = z[j]; } }  D.dpm = dpm; D.zp = zp;  let tip = H;  for (let j = 0; j <= NZ; j++) {    const need = RHO * GRAV * (H - z[j]);    let rr = 0;    if (cloud) {      // 外から中心へ調べて、初めて雲になる半径      let i = NR; while (i > 0 && dp[Q(i, j)] <= need) i--;      if (i > 0 || dp[Q(0, j)] > need) { const a = dp[Q(i, j)], b = dp[Q(Math.min(NR, i + 1), j)]; rr = r[i] + (i < NR ? ((a - need) / Math.max(1e-6, a - b)) * (r[i + 1] - r[i]) : 0); }    }    D.rf[j] = 280 * F.L * Math.tanh(rr / (280 * F.L)); // 雲の底のすぐ下で広がりすぎないように、なめらかに抑える  }  if (cloud) for (let j = 0; j <= NZ; j++) if (D.rf[j] > 0) { tip = z[j]; break; }  D.tip = tip;}

地面の中心のが上空より小さく出るのは、中心の空気が地面から上へ加速する分を、右辺の流れの加速の項が受け持つからである。摩擦層の厚いこの計算では、この差が本物より大きく出ているとみられる。deriveは高さごとに外から中心へ調べ、が初めてを超える半径を漏斗雲の半径にする。雲の底のすぐ下では半径が広がりすぎるので、tanhでなめらかに抑えた。

砂ぼこりはGPUの中で運ぶ

粒の位置と残り時間を、1粒1画素のテクスチャに入れる。流れの、、角速度は別のテクスチャに写し、毎コマ「前の位置を読む → 次の位置を書く」を全部の粒で計算する。

粒の運び(src/sims/tornado-vortex.ts の 365〜577 行)
// 砂ぼこりの粒を GPU で運ぶ。粒の位置(r, θ, z, 残り時間)を浮動小数点のテクスチャに持ち、1 コマごとに次の位置を計算して書き込むconst FLOW_GLSL = /* glsl */ `uniform sampler2D tFlow;uniform vec4 uGrid; // R, H, NR, NZuniform vec4 uMap; // sinh(BR)/R, sinh(BZ)/H, BR, BZvec4 flowAt(float r, float z) {  float s = asinh(r * uMap.x) / uMap.z, t = asinh(max(z, 0.0) * uMap.y) / uMap.w;  return texture(tFlow, (vec2(s * uGrid.z, t * uGrid.w) + 0.5) / vec2(uGrid.z + 1.0, uGrid.w + 1.0));}`;const UPDATE_VS = /* glsl */ `void main() { gl_Position = vec4(position.xy, 0.0, 1.0); }`;const UPDATE_FS = /* glsl */ `precision highp float; precision highp sampler2D;${FLOW_GLSL}uniform sampler2D tPos;uniform float uDt, uL, uCore, uReset, uSatR, uSatV, uInjR;uniform int uSide, uFrame, uNs, uInjA, uInjB;uniform vec3 uInj;uniform vec3 uSat[6]; // 小さな渦の角度・ねじれ・輪の半径out vec4 o;const float TAU_ = 6.28318530718;uvec4 pcg(uvec4 v) { v = v * 1664525u + 1013904223u; v.x += v.y * v.w; v.y += v.z * v.x; v.z += v.x * v.y; v.w += v.y * v.z; v ^= v >> 16u; v.x += v.y * v.w; v.y += v.z * v.x; v.z += v.x * v.y; v.w += v.y * v.z; return v; }vec2 satAt(int k, float z) { float a = uSat[k].x + uSat[k].y * z; return uSat[k].z * vec2(cos(a), sin(a)); }vec4 spawn(int role, vec4 u) {  float R = uGrid.x, H = uGrid.y;  if (role == 0) return vec4(0.95 * R * sqrt(u.x), TAU_ * u.y, 0.97 * H * u.z, 30.0 + 60.0 * u.w); // 周りの空気(全体に薄く)  if (role == 1) { float k = floor(u.y * 14.0); return vec4(min(0.95 * R, uCore * 3.2) * (1.0 + 0.05 * u.x), TAU_ * k / 14.0 + 0.02 * u.w, uL * (2.0 + 2.0 * u.z), 40.0 + 20.0 * u.w); } // 同じ場所から出る筋  if (role == 2 && uNs > 0) { int k = int(u.y * float(uNs)) % uNs; vec2 c = satAt(k, 0.0) + (u.xz - 0.5) * uSatR * 3.0; return vec4(length(c), atan(c.y, c.x), uL * (1.0 + 6.0 * u.w), 12.0 + 18.0 * u.w); } // 小さな渦の足もと  return vec4(min(0.95 * R, uCore * mix(0.6, 7.0, u.x * u.x)), TAU_ * u.y, uL * (1.5 + 8.0 * u.z), 25.0 + 35.0 * u.w); // 地面の砂ぼこり}void main() {  ivec2 ij = ivec2(gl_FragCoord.xy);  int idx = ij.x + ij.y * uSide;  vec4 p = texelFetch(tPos, ij, 0);  vec4 u = vec4(pcg(uvec4(uint(idx), uint(uFrame), uint(ij.x), uint(ij.y)))) / 4294967296.0;  int role = idx % 8;  if (idx >= uInjA && idx < uInjB) { vec2 c = uInj.xy + (u.xy - 0.5) * uInjR; o = vec4(length(c), atan(c.y, c.x), uL * (1.0 + 10.0 * u.z), 30.0 + 30.0 * u.w); return; }  if (uReset > 0.5) { o = spawn(role, u); o.w *= fract(u.x + u.y); return; }  float R = uGrid.x, H = uGrid.y;  if (p.w <= 0.0 || p.x >= R || p.z >= H * 0.995) { o = spawn(role, u); return; }  if (role != 0 && p.z > 0.2 * H && p.x > 2.2 * uCore) { o = spawn(role, u); return; } // 芯から離れた上空には、砂ぼこりは上がってこない  float r = p.x, th = p.y, z = p.z;  // 中点法: 半分進めた所の流れで、全部進める  vec4 a = flowAt(r, z);  float rh = max(r + 0.5 * uDt * a.x, 0.0), zh = z + 0.5 * uDt * a.y;  vec4 b = flowAt(rh, zh);  float dr = b.x, dz = b.y, dth = b.z;  if (uNs > 0) {    // 小さな渦が作る流れを足す    vec2 q = r * vec2(cos(th), sin(th)), ad = vec2(0.0); float az = 0.0, rs2 = uSatR * uSatR;    for (int k = 0; k < uNs; k++) {      vec2 d = q - satAt(k, z); float d2 = dot(d, d) + 1e-3 * rs2;      float kv = 1.4 * uSatV * uSatR / d2 * (1.0 - exp(-1.26 * d2 / rs2));      ad += kv * vec2(-d.y, d.x) - 0.25 * kv * d * exp(-z / (0.2 * H));      az += 0.9 * uSatV * exp(-d2 / (2.2 * rs2));    }    float c = cos(th), s = sin(th);    dr += ad.x * c + ad.y * s; dth += (-ad.x * s + ad.y * c) / max(r, uSatR * 0.2); dz += az;  }  r = max(r + uDt * dr, 0.0); z = max(z + uDt * dz, 0.4 * uL); th = mod(th + uDt * dth, TAU_);  o = vec4(r, th, z, p.w - uDt);}`;const POINT_VS = /* glsl */ `precision highp float; precision highp sampler2D;${FLOW_GLSL}uniform sampler2D tPos;uniform float uSide, uPx, uDeckY, uDeckD, uDeckW, uSparse, uFar;uniform int uCloud;void main() {  int id = gl_VertexID, side = int(uSide);  vec4 p = texelFetch(tPos, ivec2(id % side, id / side), 0);  vec3 P = vec3(p.x * cos(p.y), p.z, -p.x * sin(p.y));  float h = fract(float(id) * 0.61803398875), keep = smoothstep(0.0, 2.0, p.w) * uSparse;  float H = uGrid.y;  if (uCloud == 1 && cameraPosition.y < uDeckY) { if (p.z > uDeckY - uDeckD * exp(-p.x * p.x / (uDeckW * uDeckW))) keep = 0.0; } // 雲の中の粒は見えない  else keep *= 1.0 - smoothstep(0.7 * H, H, p.z); // 雲のない旋風は、上へ行くほど薄くなる  if (id % 8 == 0) keep *= 0.6;  // 漏斗雲の中の粒は、雲に隠れて上ほど見えない  float rfz = flowAt(0.0, p.z).a;  if (p.x < rfz) keep *= 1.0 - smoothstep(0.1 * H, 0.42 * H, p.z);  // 漏斗雲の後ろにある粒は描かない(その高さの漏斗雲を縦の円柱とみなして、視線と交わるかを調べる)  vec2 C = cameraPosition.xz, D = P.xz - C;  float t = -dot(C, D) / max(dot(D, D), 1e-6);  if (t > 0.0 && t < 1.0) {    float y = mix(cameraPosition.y, P.y, t), rf = flowAt(0.0, y).a;    if (rf > 0.0 && length(C + t * D) < rf) keep = 0.0;  }  vec4 mv = viewMatrix * vec4(P, 1.0);  keep *= 1.0 - smoothstep(0.5 * uFar, uFar, -mv.z);  gl_Position = h < keep ? projectionMatrix * mv : vec4(2.0, 2.0, 2.0, 1.0);  gl_PointSize = uPx * clamp(uFar * 0.28 / max(-mv.z, 1.0), 1.0, 2.6);}`;const POINT_FS = /* glsl */ `precision highp float;uniform vec3 uInk;out vec4 o;void main() { o = vec4(uInk, 1.0); }`; interface Parts {  side: number; n: number; gpu: boolean; cursor: number; frame: number; acc: number; first: boolean;  a: THREE.WebGLRenderTarget | null; b: THREE.WebGLRenderTarget | null;  cpu: Float32Array | null; cpuTex: THREE.DataTexture | null; rng: () => number;  upd: THREE.ShaderMaterial | null; updScene: THREE.Scene | null; updCam: THREE.OrthographicCamera | null;  mat: THREE.ShaderMaterial; scene: THREE.Scene; geo: THREE.BufferGeometry; quad: THREE.Mesh | null;  injA: number; injB: number; inj: [number, number];}/** 粒の数: 画質 高 20 万・中 6.5 万。画質 低か、浮動小数点のテクスチャに書き込めない端末では CPU で 8 千〜1.6 万個を運ぶ */function buildParts(api: Api, s: S): Parts {  const F = s.F, gl = api.gl!;  const canFloat = !!gl.getExtension("EXT_color_buffer_float");  const gpu = canFloat && api.quality > 0;  const side = gpu ? (api.quality === 2 ? 448 : 256) : api.quality === 0 ? 90 : 128;  const n = side * side;  const common = {    tFlow: { value: s.flowTex }, tPos: { value: null as THREE.Texture | null },    uGrid: { value: new THREE.Vector4(F.R, F.H, NR, NZ) },    uMap: { value: new THREE.Vector4(Math.sinh(BR) / F.R, Math.sinh(BZ) / F.H, BR, BZ) },  };  const mat = new THREE.ShaderMaterial({    glslVersion: THREE.GLSL3, vertexShader: POINT_VS, fragmentShader: POINT_FS, depthTest: false, depthWrite: false,    uniforms: { ...common, uSide: { value: side }, uPx: { value: 1 }, uDeckY: { value: F.H }, uDeckD: { value: 70 * F.L }, uDeckW: { value: 260 * F.L }, uSparse: { value: 1 }, uFar: { value: 3000 * F.L }, uCloud: { value: s.size.cloud ? 1 : 0 }, uInk: { value: new THREE.Vector3() } },  });  const geo = new THREE.BufferGeometry();  geo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(n * 3), 3));  const pts = new THREE.Points(geo, mat); pts.frustumCulled = false;  const scene = new THREE.Scene(); scene.add(pts);  const p: Parts = { side, n, gpu, cursor: 0, frame: 0, acc: 0, first: true, a: null, b: null, cpu: null, cpuTex: null, rng: api.rng(7), upd: null, updScene: null, updCam: null, mat, scene, geo, quad: null, injA: 0, injB: 0, inj: [0, 0] };  if (gpu) {    const opt = { type: THREE.FloatType, format: THREE.RGBAFormat, minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter, depthBuffer: false, stencilBuffer: false, generateMipmaps: false };    p.a = new THREE.WebGLRenderTarget(side, side, opt); p.b = new THREE.WebGLRenderTarget(side, side, opt);    const sats = Array.from({ length: 6 }, () => new THREE.Vector3());    p.upd = new THREE.ShaderMaterial({      glslVersion: THREE.GLSL3, vertexShader: UPDATE_VS, fragmentShader: UPDATE_FS, depthTest: false, depthWrite: false,      uniforms: { ...common, uDt: { value: 0 }, uL: { value: F.L }, uCore: { value: 50 * F.L }, uReset: { value: 1 }, uSatR: { value: 1 }, uSatV: { value: 0 }, uInjR: { value: 24 * F.L }, uSide: { value: side }, uFrame: { value: 0 }, uNs: { value: 0 }, uInjA: { value: 0 }, uInjB: { value: 0 }, uInj: { value: new THREE.Vector3() }, uSat: { value: sats } },    });    p.quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), p.upd); p.quad.frustumCulled = false;    p.updScene = new THREE.Scene(); p.updScene.add(p.quad);    p.updCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);  } else {    p.cpu = new Float32Array(n * 4);    p.cpuTex = new THREE.DataTexture(p.cpu, side, side, THREE.RGBAFormat, THREE.FloatType);    p.cpuTex.minFilter = THREE.NearestFilter; p.cpuTex.magFilter = THREE.NearestFilter; p.cpuTex.needsUpdate = true;    mat.uniforms.tPos.value = p.cpuTex;    for (let i = 0; i < n; i++) cpuSpawn(p, s, i, true);  }  return p;}function disposeParts(p: Parts) {  p.a?.dispose(); p.b?.dispose(); p.cpuTex?.dispose(); p.upd?.dispose(); p.quad?.geometry.dispose(); p.geo.dispose(); p.mat.dispose();}/** CPU で運ぶときの、粒を出す場所(GPU の spawn と同じ決まり) */function cpuSpawn(p: Parts, s: S, i: number, reset: boolean) {  const P = p.cpu!, F = s.F, rn = p.rng, k = i * 4, role = i % 8, core = s.D.rs || 50 * F.L;  let r: number, th: number, z: number, life: number;  if (role === 0) { r = 0.95 * F.R * Math.sqrt(rn()); th = TAU * rn(); z = 0.97 * F.H * rn(); life = 30 + 60 * rn(); }  else if (role === 1) { r = Math.min(0.95 * F.R, core * 3.2) * (1 + 0.05 * rn()); th = (TAU * Math.floor(rn() * 14)) / 14; z = F.L * (2 + 2 * rn()); life = 40 + 20 * rn(); }  else if (role === 2 && s.sat.n > 0) {    const a = s.sat.a[Math.floor(rn() * s.sat.n)], x = s.sat.ring * Math.cos(a) + (rn() - 0.5) * s.sat.rs * 3, y = s.sat.ring * Math.sin(a) + (rn() - 0.5) * s.sat.rs * 3;    r = Math.hypot(x, y); th = Math.atan2(y, x); z = F.L * (1 + 6 * rn()); life = 12 + 18 * rn();  } else { const u = rn(); r = Math.min(0.95 * F.R, core * (0.6 + 6.4 * u * u)); th = TAU * rn(); z = F.L * (1.5 + 8 * rn()); life = 25 + 35 * rn(); }  P[k] = r; P[k + 1] = th; P[k + 2] = z; P[k + 3] = reset ? life * rn() : life;}/** 1 回分の粒の移動。GPU なら描画先を入れ替えて全部の粒を計算し、CPU なら配列を更新する */function moveParts(api: Api, s: S, dt: number) {  const p = s.parts, F = s.F, D = s.D, sat = s.sat;  if (p.gpu) {    const u = p.upd!.uniforms, r = s.v.renderer;    u.tPos.value = p.a!.texture; u.uDt.value = dt; u.uReset.value = p.first ? 1 : 0; u.uFrame.value = ++p.frame;    u.uCore.value = D.rs || 50 * F.L; u.uNs.value = sat.n; u.uSatR.value = sat.rs; u.uSatV.value = sat.vs;    for (let k = 0; k < sat.n; k++) (u.uSat.value as THREE.Vector3[])[k].set(sat.a[k], sat.tw, sat.ring);    u.uInjA.value = p.injA; u.uInjB.value = p.injB; (u.uInj.value as THREE.Vector3).set(p.inj[0], p.inj[1], 0);    r.setRenderTarget(p.b); r.render(p.updScene!, p.updCam!); r.setRenderTarget(null);    const t = p.a; p.a = p.b; p.b = t; p.mat.uniforms.tPos.value = p.a!.texture;  } else {    const P = p.cpu!, ve = s.vel;    for (let i = 0; i < p.n; i++) {      const k = i * 4;      if (i >= p.injA && i < p.injB) { const x = p.inj[0] + (p.rng() - 0.5) * 24 * F.L, y = p.inj[1] + (p.rng() - 0.5) * 24 * F.L; P[k] = Math.hypot(x, y); P[k + 1] = Math.atan2(y, x); P[k + 2] = F.L * (1 + 10 * p.rng()); P[k + 3] = 30 + 30 * p.rng(); continue; }      if (P[k + 3] <= 0 || P[k] >= F.R || P[k + 2] >= F.H * 0.995 || (i % 8 !== 0 && P[k + 2] > 0.2 * F.H && P[k] > 2.2 * (s.D.rs || 50 * F.L))) { cpuSpawn(p, s, i, false); continue; }      const r = P[k], th = P[k + 1], z = P[k + 2];      flowPolar(F, r, z, ve);      const rh = Math.max(0, r + 0.5 * dt * ve[0]), zh = z + 0.5 * dt * ve[1];      flowPolar(F, rh, zh, ve);      P[k] = Math.max(0, r + dt * ve[0]); P[k + 2] = Math.max(0.4 * F.L, z + dt * ve[1]); P[k + 1] = (th + dt * ve[2]) % TAU; P[k + 3] -= dt;    }    p.cpuTex!.needsUpdate = true;  }  p.injA = 0; p.injB = 0; p.first = false;}/** 半径と高さから、u・w・角速度を格子の補間で読む */function flowPolar(F: Flow, r: number, z: number, out: number[]) {  const fi = Math.min(NR - 1e-3, (Math.asinh((r * Math.sinh(BR)) / F.R) / BR) * NR), fj = Math.min(NZ - 1e-3, (Math.asinh((Math.max(0, z) * Math.sinh(BZ)) / F.H) / BZ) * NZ);  const i = Math.floor(fi), j = Math.floor(fj), a = fi - i, b = fj - j, q = i + j * NI;  const w00 = (1 - a) * (1 - b), w10 = a * (1 - b), w01 = (1 - a) * b, w11 = a * b;  out[0] = w00 * F.U[q] + w10 * F.U[q + 1] + w01 * F.U[q + NI] + w11 * F.U[q + NI + 1];  out[1] = w00 * F.W[q] + w10 * F.W[q + 1] + w01 * F.W[q + NI] + w11 * F.W[q + NI + 1];  const g = w00 * F.G[q] + w10 * F.G[q + 1] + w01 * F.G[q + NI] + w11 * F.G[q + NI + 1];  // 回転の速さ v = Γ/r は中心で 0 に近づくので、角速度は格子の Γ/r² を補間する  const i0 = Math.max(1, i), i1 = i + 1, ri0 = 1 / (F.r[i0] * F.r[i0]), ri1 = 1 / (F.r[i1] * F.r[i1]);  out[2] = w00 * F.G[i0 + j * NI] * ri0 + w10 * F.G[i1 + j * NI] * ri1 + w01 * F.G[i0 + (j + 1) * NI] * ri0 + w11 * F.G[i1 + (j + 1) * NI] * ri1;  out[3] = g;}/** 流れと漏斗雲の半径をテクスチャに写す(GPU の粒が読む) */function uploadFlow(s: S) {  const F = s.F, D = s.D, a = s.flowData, occ = s.sect || s.outline ? 0 : 1;  for (let j = 0; j < NJ; j++) {    const rfj = (D.rfs[j] || 0) * occ;    for (let i = 0; i < NI; i++) {      const q = i + j * NI, k = q * 4, ii = Math.max(1, i);      a[k] = F.U[q]; a[k + 1] = F.W[q]; a[k + 2] = F.G[ii + j * NI] / (F.r[ii] * F.r[ii]); a[k + 3] = rfj;    }  }  s.flowTex.needsUpdate = true;}

積分は中点法である。半分進めた所の流れで全体を進めると、回転の速い中心でも粒が外へ逃げにくい。座標を円柱のままにしたのは、を角速度で進めればが変わらないからだ。粒のうち8分の1は周りの空気で、残りは地面の近くから放つ。同じ場所から放つ筋を混ぜると、砂ぼこりの道が線になって見える。古い端末や画質「低」では、同じ決まりをCPUで計算し、粒の数を減らす。

粒を描くPOINT_VSは、白黒の変換が終わったあとの画面へ直接点を打つ。変換の前に描くと、輪郭線の処理で点が消えるからだ。奥行きの情報が使えないので、漏斗雲の後ろの粒は、視線が漏斗雲の半径の内側を通るかどうかで隠している。

物には抗力と重力を

物の運動は、本文にあるをKDとして、抗力と重力だけで計算する。

飛ばされる物(src/sims/tornado-vortex.ts の 579〜644 行)
interface Obj {  kind: Kind; x: number; y: number; z: number; vx: number; vy: number; vz: number; // 位置と速度(x, y は水平・z は高さ)  m: THREE.Mesh; line: THREE.Line; pos: Float32Array; n: number; acc: number;  vmax: number; zmax: number; rx: number; ry: number; rz: number; lifted: boolean; gone: boolean;}const OBJ_MU = 0.6; // 地面との摩擦係数(想定の値)const OBJ_Z = Z_SFC; // 物に当たる風を読む最も低い高さ m(計算の摩擦層は実物より厚いので、「地上近くの風」と同じ高さの風を使う)/** 水平の位置と高さの風(x, y, z の向き)を読む。小さな渦の流れも足す */function windAt(s: S, x: number, y: number, z: number, out: number[]) {  const F = s.F, r = Math.hypot(x, y), c = r > 1e-6 ? x / r : 1, sn = r > 1e-6 ? y / r : 0, ve = s.vel;  flowPolar(F, r, Math.max(z, OBJ_Z * F.L), ve);  const v = ve[3] / Math.max(r, F.r[1]);  out[0] = ve[0] * c - v * sn; out[1] = ve[0] * sn + v * c; out[2] = ve[1];  const sat = s.sat;  if (sat.n) {    const rs2 = sat.rs * sat.rs;    for (let k = 0; k < sat.n; k++) {      const a = sat.a[k] + sat.tw * z, dx = x - sat.ring * Math.cos(a), dy = y - sat.ring * Math.sin(a), d2 = dx * dx + dy * dy + 1e-3 * rs2;      const kv = ((1.4 * sat.vs * sat.rs) / d2) * (1 - Math.exp((-1.26 * d2) / rs2));      out[0] += -kv * dy - 0.25 * kv * dx * Math.exp(-z / (0.2 * F.H)); out[1] += kv * dx - 0.25 * kv * dy * Math.exp(-z / (0.2 * F.H));      out[2] += 0.9 * sat.vs * Math.exp(-d2 / (2.2 * rs2));    }  }}/** 風に引かれる物の運動: 加速度 = ½ρ(C_D A/m)|V_w − V|(V_w − V) − g。抗力の項は陰に解くので、軽い物でも刻みで発散しない */function stepObj(api: Api, s: S, o: Obj, h: number) {  if (o.gone) return;  const w = s.wind;  windAt(s, o.x, o.y, o.z, w);  const dx = w[0] - o.vx, dy = w[1] - o.vy, dz = w[2] - o.vz, sp = Math.sqrt(dx * dx + dy * dy + dz * dz);  const rate = 0.5 * RHO * o.kind.KD * sp, f = (rate * h) / (1 + rate * h);  o.vx += dx * f; o.vy += dy * f; o.vz += dz * f - GRAV * h;  o.x += o.vx * h; o.y += o.vy * h; o.z += o.vz * h;  if (o.z <= 0) {    o.z = 0;    if (o.vz < -3) o.vz *= -0.25; else if (o.vz < 0) o.vz = 0;    // 地面の上では摩擦で止まろうとする。摩擦より風の力が弱ければ動かない    const hs = Math.hypot(o.vx, o.vy), dv = OBJ_MU * GRAV * h;    if (hs <= dv) { o.vx = 0; o.vy = 0; } else { o.vx -= (o.vx / hs) * dv; o.vy -= (o.vy / hs) * dv; }  } else if (o.z > 8 * s.F.L && !o.lifted) {    o.lifted = true;    api.note(`${o.kind.name}が浮き上がりました`);  }  // 雲の底より上や、計算の範囲の外の流れは解いていないので、そこで追うのをやめる  if (o.z > s.F.H * 0.92 || Math.hypot(o.x, o.y) > s.F.R * 0.98) { o.gone = true; o.m.visible = false; api.note(o.z > s.F.H * 0.9 ? `${o.kind.name}が雲の中へ入りました` : `${o.kind.name}が計算の範囲の外へ出ました`); }  const v = Math.sqrt(o.vx * o.vx + o.vy * o.vy + o.vz * o.vz);  if (v > o.vmax) o.vmax = v;  if (o.z > o.zmax) o.zmax = o.z;  o.acc += h;  if (o.acc >= 0.1 && o.n < TRAIL) {    o.acc = 0; const k = o.n * 3; o.pos[k] = o.x; o.pos[k + 1] = Math.max(0.2, o.z); o.pos[k + 2] = -o.y; o.n++;    (o.line.geometry as THREE.BufferGeometry).setDrawRange(0, o.n); (o.line.geometry.attributes.position as THREE.BufferAttribute).needsUpdate = true;  }}function dropObj(s: S, key: string, x: number, y: number, z = 3) {  const kind = KINDS[key], F = s.F; if (!kind) return;  if (s.objs.length >= MAXOBJ) { const old = s.objs.shift()!; s.v.scene.remove(old.m, old.line); old.line.geometry.dispose(); old.m.geometry.dispose(); }  const vis = s.size.L >= 1 ? VIS : 1;  const m = new THREE.Mesh(new THREE.BoxGeometry(kind.size[0] * vis, kind.size[1] * vis, kind.size[2] * vis), s.objMat[kind === KINDS.pipe ? 1 : 0]);  m.castShadow = true; s.v.scene.add(m);  const pos = new Float32Array(TRAIL * 3), g = new THREE.BufferGeometry();  g.setAttribute("position", new THREE.BufferAttribute(pos, 3)); g.setDrawRange(0, 0);  const line = new THREE.Line(g, s.lineMat); line.frustumCulled = false; s.v.scene.add(line);  const rn = s.rng;  s.objs.push({ kind, x, y, z: z * F.L + 0.01, vx: 0, vy: 0, vz: 0, m, line, pos, n: 0, acc: 0, vmax: 0, zmax: 0, rx: rn(), ry: rn(), rz: rn(), lifted: false, gone: false });}

風の中の加速度は(がKD)で、軽い物ほど速さの差がすぐ縮まる。トタン板のような軽い物では、普通に足していくと刻みの中で行き過ぎて発散するため、抗力の項を陰に解いて割合fを掛けている。地面に触れている間は摩擦で減速し、風の力が摩擦より弱ければ動かない。

three.jsに渡すもの

雲の底は頂点の濃さを変えた面、地面は影を受ける円、家と木は同じ形を並べるInstancedMeshで、どれも最初に1回作るだけである。毎コマ書き直すのは、漏斗雲と小さな渦の管の頂点の配列で、形を作り直さずにneedsUpdateで知らせる。

描く(src/sims/tornado-vortex.ts の 657〜857 行)
interface FunnelMesh { mesh: THREE.Mesh; pos: Float32Array; nor: Float32Array; nth: number; cs: Float32Array; sn: Float32Array; tmp: Float64Array }function buildFunnel(api: Api, s: { v: View3 }, mat: THREE.Material): FunnelMesh {  const nth = [24, 40, 64][api.quality], cols = nth + 1, count = NJ * cols;  const pos = new Float32Array(count * 3), nor = new Float32Array(count * 3), idx: number[] = [];  for (let j = 0; j < NZ; j++) for (let k = 0; k < nth; k++) { const a = j * cols + k, b = a + 1, c = a + cols, d = c + 1; idx.push(a, b, c, b, d, c); }  const g = new THREE.BufferGeometry();  g.setAttribute("position", new THREE.BufferAttribute(pos, 3).setUsage(THREE.DynamicDrawUsage));  g.setAttribute("normal", new THREE.BufferAttribute(nor, 3).setUsage(THREE.DynamicDrawUsage));  g.setIndex(idx);  const mesh = new THREE.Mesh(g, mat); mesh.castShadow = true; mesh.frustumCulled = false; s.v.scene.add(mesh);  const cs = new Float32Array(cols), sn = new Float32Array(cols);  for (let k = 0; k <= nth; k++) { const t = (k / nth) * TAU; cs[k] = Math.cos(t); sn[k] = Math.sin(t); }  return { mesh, pos, nor, nth, cs, sn, tmp: new Float64Array(NJ) };}/** 漏斗雲の面の頂点を、高さごとの半径から書き直す。細かな凹凸(見た目のため)はゆっくり回す */function updateFunnel(s: S, dt: number, fast: number) {  const fm = s.funnel, D = s.D, F = s.F, z = F.z, L = F.L, a = dt > 0 ? Math.min(1, dt * fast) : 1;  for (let j = 0; j <= NZ; j++) fm.tmp[j] = D.rfs[j] + (D.rf[j] - D.rfs[j]) * a;  for (let j = 1; j < NZ; j++) D.rfs[j] = 0.25 * fm.tmp[j - 1] + 0.5 * fm.tmp[j] + 0.25 * fm.tmp[j + 1];  D.rfs[0] = fm.tmp[0]; D.rfs[NZ] = fm.tmp[NZ];  let jt = 0; while (jt < NZ && D.rfs[jt] < 0.8 * L) jt++;  const on = s.size.cloud && !s.sect && !s.outline && jt < NZ - 1;  fm.mesh.visible = on;  if (!on) return;  const cols = fm.nth + 1, zt = jt > 0 ? z[jt] - 0.6 * (z[jt] - z[jt - 1]) : 0;  // 雲の上から見るときは、雲の底のすぐ下で広がる部分を描かない(広がりが下の竜巻を隠すので)  let jc = NZ; if (s.v.camera.position.y > F.H * 0.92) { jc = 0; while (jc < NZ && z[jc] < 0.65 * F.H) jc++; }  for (let j0 = 0; j0 <= NZ; j0++) {    const j = Math.min(j0, jc), rr = j < jt ? 0 : D.rfs[j], y = j < jt ? zt : z[j];    const jp = Math.min(NZ, j + 1), jm = Math.max(0, j - 1), slope = clamp((D.rfs[jp] - D.rfs[jm]) / Math.max(1e-3, z[jp] - z[jm]), -6, 6);    const nl = 1 / Math.sqrt(1 + slope * slope);    for (let k = 0; k < cols; k++) {      const q = (j0 * cols + k) * 3, c = fm.cs[k], sn = fm.sn[k], th = (k / fm.nth) * TAU;      const b = rr * (1 + 0.05 * Math.sin(3 * (th + s.ph) + (y / L) * 0.012) + 0.03 * Math.sin(7 * (th + s.ph) - (y / L) * 0.03));      fm.pos[q] = b * c; fm.pos[q + 1] = y; fm.pos[q + 2] = -b * sn;      fm.nor[q] = c * nl; fm.nor[q + 1] = -slope * nl; fm.nor[q + 2] = -sn * nl;    }  }  const g = fm.mesh.geometry;  (g.attributes.position as THREE.BufferAttribute).needsUpdate = true; (g.attributes.normal as THREE.BufferAttribute).needsUpdate = true;} interface TubeMesh { mesh: THREE.Mesh; pos: Float32Array; nor: Float32Array }const TSEG = 10, TMAX = 6;function buildTubes(v: View3, mat: THREE.Material): TubeMesh {  const cols = TSEG + 1, count = TMAX * NJ * cols, pos = new Float32Array(count * 3), nor = new Float32Array(count * 3), idx: number[] = [];  for (let t = 0; t < TMAX; t++) for (let j = 0; j < NZ; j++) for (let k = 0; k < TSEG; k++) { const a = (t * NJ + j) * cols + k, b = a + 1, c = a + cols, d = c + 1; idx.push(a, b, c, b, d, c); }  const g = new THREE.BufferGeometry();  g.setAttribute("position", new THREE.BufferAttribute(pos, 3).setUsage(THREE.DynamicDrawUsage));  g.setAttribute("normal", new THREE.BufferAttribute(nor, 3).setUsage(THREE.DynamicDrawUsage));  g.setIndex(idx); g.setDrawRange(0, 0);  const mesh = new THREE.Mesh(g, mat); mesh.castShadow = true; mesh.frustumCulled = false; v.scene.add(mesh);  return { mesh, pos, nor };}/** 小さな渦の位置を進め、その漏斗雲の管を描く。管の半径は、親の渦の気圧の低下に小さな渦の分を足して、雲になる範囲を解いたもの */function updateSats(s: S, dt: number) {  const sat = s.sat, D = s.D, F = s.F, Sw = F.Qs > 0 ? (F.Gam / F.r0) / ((2 * F.Qs) / (F.r0 * F.r0)) : 0;  const k = clamp((Sw - S_MULTI) / 0.3, 0, 1);  const n = k > 0 && F.noslip && s.size.cloud ? clamp(Math.round(Sw + 1), 3, TMAX) : 0;  sat.n = n; sat.ring = D.rm * 0.95; sat.rs = Math.max(3 * F.L, 0.1 * D.rm); sat.vs = 0.55 * D.vm * k; sat.tw = 0.0009 / F.L;  // 小さな渦は、親の渦のいちばん速い回転の半分ほどの速さで輪の上を回る  sat.ph += (dt * 0.5 * D.vm) / Math.max(1, D.rm);  for (let i = 0; i < n; i++) sat.a[i] = sat.ph + (i * TAU) / n;  const tm = s.tubes, on = n > 0 && !s.sect;  tm.mesh.visible = on;  if (!on) return;  const cols = TSEG + 1, { z, r, H } = F, rho = RHO; let ir = 1; while (ir < NR && r[ir] < sat.ring) ir++;  for (let t = 0; t < n; t++) for (let j = 0; j <= NZ; j++) {    const need = rho * GRAV * (H - z[j]), rq = need - D.dp[Q(ir, j)], pk = rho * sat.vs * sat.vs;    // 回転の気圧の低下は、核の中で ρV²(1 − d²/2R²)、外で ρV²R²/(2d²)(ランキン渦)    let rad = 0;    if (rq <= 0) rad = sat.rs * 1.6; else if (rq < pk) rad = rq > 0.5 * pk ? sat.rs * Math.sqrt(2 * (1 - rq / pk)) : sat.rs * Math.sqrt(pk / (2 * rq));    rad = Math.min(rad, sat.rs);    const ang = sat.a[t] + sat.tw * z[j], cx = sat.ring * Math.cos(ang), cy = sat.ring * Math.sin(ang);    for (let m = 0; m < cols; m++) {      const q = ((t * NJ + j) * cols + m) * 3, th = (m / TSEG) * TAU, c = Math.cos(th), sn = Math.sin(th);      tm.pos[q] = cx + rad * c; tm.pos[q + 1] = z[j]; tm.pos[q + 2] = -(cy + rad * sn);      tm.nor[q] = c; tm.nor[q + 1] = 0; tm.nor[q + 2] = -sn;    }  }  const g = tm.mesh.geometry;  g.setDrawRange(0, n * NZ * TSEG * 6);  (g.attributes.position as THREE.BufferAttribute).needsUpdate = true; (g.attributes.normal as THREE.BufferAttribute).needsUpdate = true;} /** 断面の流線: ψ の等高線を、視線に直角で中心を通る面の上に引く(等高線に沿って空気が流れる)。漏斗雲の輪郭も同じ面に描く */function updateLines(s: S) {  const ln = s.lines, F = s.F, D = s.D;  ln.visible = s.sect || s.outline; if (!ln.visible) return;  const g = ln.geometry, p = (g.attributes.position as THREE.BufferAttribute).array as Float32Array;  const yaw = s.v.orbit.yaw, ex = Math.cos(yaw), ez = -Math.sin(yaw), max = p.length / 3 - 4;  const { r, z, P, Qs } = F; let n = 0;  const put = (ra: number, za: number, rb: number, zb: number) => {    if (n >= max) return;    p[n * 3] = ra * ex; p[n * 3 + 1] = za; p[n * 3 + 2] = ra * ez; n++; p[n * 3] = rb * ex; p[n * 3 + 1] = zb; p[n * 3 + 2] = rb * ez; n++;    p[n * 3] = -ra * ex; p[n * 3 + 1] = za; p[n * 3 + 2] = -ra * ez; n++; p[n * 3] = -rb * ex; p[n * 3 + 1] = zb; p[n * 3 + 2] = -rb * ez; n++;  };  const LV = [0.002, 0.008, 0.02, 0.05, 0.1, 0.2, 0.32, 0.46, 0.62, 0.8].map((x) => -x * Qs).concat([0.002, 0.01, 0.03].map((x) => x * Qs));  if (s.sect) for (const lv of LV) for (let j = 0; j < NZ; j++) for (let i = 0; i < NR; i++) {    const a = P[Q(i, j)] - lv, b = P[Q(i + 1, j)] - lv, c = P[Q(i + 1, j + 1)] - lv, d = P[Q(i, j + 1)] - lv;    const code = (a > 0 ? 1 : 0) | (b > 0 ? 2 : 0) | (c > 0 ? 4 : 0) | (d > 0 ? 8 : 0);    if (code === 0 || code === 15) continue;    const xs: number[] = [];    if ((a > 0) !== (b > 0)) xs.push(r[i] + (a / (a - b)) * (r[i + 1] - r[i]), z[j]);    if ((b > 0) !== (c > 0)) xs.push(r[i + 1], z[j] + (b / (b - c)) * (z[j + 1] - z[j]));    if ((d > 0) !== (c > 0)) xs.push(r[i] + (d / (d - c)) * (r[i + 1] - r[i]), z[j + 1]);    if ((a > 0) !== (d > 0)) xs.push(r[i], z[j] + (a / (a - d)) * (z[j + 1] - z[j]));    if (xs.length >= 4) put(xs[0], xs[1], xs[2], xs[3]);    if (xs.length >= 8) put(xs[4], xs[5], xs[6], xs[7]);  }  for (let j = 0; j < NZ; j += 2) if ((D.rfs[j] > 0 || D.rfs[j + 1] > 0) && D.rfs[j + 1] < 200 * F.L) put(D.rfs[j], z[j], D.rfs[j + 1], z[j + 1]); // 漏斗雲の輪郭は破線で  g.setDrawRange(0, n);  (g.attributes.position as THREE.BufferAttribute).needsUpdate = true;} function buildWorld(api: Api, s: { v: View3; size: Size; F: Flow }) {  const { v, size, F } = s, L = F.L, rn = api.rng(11);  const ground = new THREE.Mesh(new THREE.CircleGeometry(3200 * L, 96), v.mat(0.22)); ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; v.scene.add(ground);  const m4 = new THREE.Matrix4(), place = (mesh: THREE.InstancedMesh, k: number, x: number, y: number, z: number, sx: number, sy: number, sz: number, a: number) => {    m4.makeRotationY(a); m4.scale(new THREE.Vector3(sx, sy, sz)); m4.setPosition(x, y, z); mesh.setMatrixAt(k, m4);  };  if (size.cloud) {    // 大きさの目安になる家と木(10 m 四方ほど)    const nh = 150, houses = new THREE.InstancedMesh(new THREE.BoxGeometry(1, 1, 1), v.mat(0.62, { flat: true }), nh);    for (let k = 0; k < nh; k++) { const r = 170 + 1250 * Math.sqrt(rn()), t = TAU * rn(), h = 5 + 4 * rn(); place(houses, k, r * Math.cos(t), h / 2, r * Math.sin(t), 9 + 5 * rn(), h, 8 + 4 * rn(), TAU * rn()); }    houses.castShadow = true; houses.receiveShadow = true; v.scene.add(houses);    const nt = 90, trees = new THREE.InstancedMesh(new THREE.ConeGeometry(1, 1, 6), v.mat(0.42, { flat: true }), nt);    for (let k = 0; k < nt; k++) { const r = 130 + 1400 * rn(), t = TAU * rn(), h = 10 + 8 * rn(); place(trees, k, r * Math.cos(t), h / 2, r * Math.sin(t), 3.5, h, 3.5, 0); }    trees.castShadow = true; v.scene.add(trees);  } else {    // 旋風の大きさの目安になる草のかたまりと、身長 1.7 m の人    const nb = 70, bush = new THREE.InstancedMesh(new THREE.IcosahedronGeometry(1, 0), v.mat(0.5, { flat: true }), nb);    for (let k = 0; k < nb; k++) { const r = 8 + 90 * Math.sqrt(rn()), t = TAU * rn(), h = 0.25 + 0.35 * rn(); place(bush, k, r * Math.cos(t), h * 0.6, r * Math.sin(t), h * 1.5, h, h * 1.5, TAU * rn()); }    bush.castShadow = true; v.scene.add(bush);    const body = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.22, 1.45, 10), v.mat(0.9)), head = new THREE.Mesh(new THREE.SphereGeometry(0.14, 12, 8), v.mat(0.9));    body.position.set(14, 0.72, 6); head.position.set(14, 1.58, 6); body.castShadow = head.castShadow = true; v.scene.add(body, head);  }  // 親の雲の底。中央が少し垂れ下がる(壁雲)。濃さの濃淡は頂点ごとの濃さで付ける  const nr = size.cloud ? 44 : 2, nt2 = size.cloud ? 120 : 8, cols = nt2 + 1, deckPos = new Float32Array((nr + 1) * cols * 3), deckCol = new Float32Array((nr + 1) * cols * 3), di: number[] = [];  const Rd = 3000 * L, H = F.H, D = 70 * L, W = 260 * L;  for (let i = 0; i <= nr; i++) for (let k = 0; k < cols; k++) {    const r = Rd * (i / nr) ** 1.7, t = (k / nt2) * TAU, q = (i * cols + k) * 3;    const lump = Math.sin(t * 5 + r * 0.011 / L) * Math.sin(r * 0.019 / L - t * 3) + 0.6 * Math.sin(t * 11 - r * 0.031 / L);    deckPos[q] = r * Math.cos(t); deckPos[q + 1] = H - D * Math.exp(-(r * r) / (W * W)) + 7 * L * lump * Math.exp(-(r * r) / (650 * L * 650 * L)); deckPos[q + 2] = r * Math.sin(t);    const g = clamp(0.2 + 0.13 * lump, 0.05, 0.42); deckCol[q] = g; deckCol[q + 1] = g; deckCol[q + 2] = g;  }  for (let i = 0; i < nr; i++) for (let k = 0; k < nt2; k++) { const a = i * cols + k, b = a + 1, c = a + cols, d = c + 1; di.push(a, b, c, b, d, c); }  const dg = new THREE.BufferGeometry();  dg.setAttribute("position", new THREE.BufferAttribute(deckPos, 3)); dg.setAttribute("color", new THREE.BufferAttribute(deckCol, 3)); dg.setIndex(di);  const dmat = v.flat(1, { double: true }) as THREE.MeshBasicMaterial; dmat.vertexColors = true;  const deck = new THREE.Mesh(dg, dmat); deck.visible = size.cloud; v.scene.add(deck);  // 風の階級の輪(大きさは半径 1 の輪を縮尺で伸ばす)  const circle = new THREE.BufferGeometry(), cp = new Float32Array(128 * 3);  for (let k = 0; k < 128; k++) { const t = (k / 128) * TAU; cp[k * 3] = Math.cos(t); cp[k * 3 + 1] = 0; cp[k * 3 + 2] = Math.sin(t); }  circle.setAttribute("position", new THREE.BufferAttribute(cp, 3));  const rmat = v.lineMat(1), rings = JEF.map(() => { const l = new THREE.LineLoop(circle, rmat); l.position.y = 0.6 * L; l.visible = false; l.frustumCulled = false; v.scene.add(l); return l; });  const lg = new THREE.BufferGeometry();  lg.setAttribute("position", new THREE.BufferAttribute(new Float32Array(3 * 2 * 2 * 16000), 3).setUsage(THREE.DynamicDrawUsage)); lg.setDrawRange(0, 0);  const lines = new THREE.LineSegments(lg, v.lineMat(1)); lines.frustumCulled = false; lines.visible = false; v.scene.add(lines);  return { deck, rings, lines };} const nice = (x: number, list: number[]) => { for (const a of list) if (a >= x) return a; return list[list.length - 1]; };const graphList = (L: number) => [20, 40, 80, 160, 320, 640, 1000].map((x) => x * (L < 1 ? 0.2 : 1));/** 2D の層のグラフ: 半径ごとの回転の速さ v と上下の流れ w、気圧の低下 Δp */function drawGraph(api: Api, s: S) {  const F = s.F, D = s.D, L = F.L, small = api.w < 560;  const gw = Math.min(api.w * (small ? 0.44 : 0.31), api.u(330)), gh = Math.min(gw * (small ? 0.95 : 0.74), api.h * 0.44), x0 = api.u(8), y0 = api.h - gh - api.u(8);  api.rect(x0, y0, gw, gh, { fill: "paper", stroke: "ink", w: api.u(0.8) });  const fs = Math.max(9, api.u(11)), pl = x0 + fs * 2.5, pr = x0 + gw - fs * 0.7, tTop = y0 + fs * 1.6, tBot = y0 + gh * 0.56, bTop = tBot + fs * 1.5, bBot = y0 + gh - fs * 1.75;  // 横軸の範囲は核の 3.5 倍まで。少しの変化では変えない  const want = D.rm * 3.5, list = graphList(L);  if (want > s.graphR * 1.1 || want < s.graphR * 0.4) s.graphR = nice(want, list);  const rg = s.graphR, X = (r: number) => pl + (r / rg) * (pr - pl);  let vtop = 0, wlo = 0;  const jv = D.jm, jw = D.jc, { r, G, W } = F;  for (let i = 1; i <= NR && r[i - 1] <= rg; i++) { const v = G[Q(i, jv)] / r[i], w = W[Q(i, jw)]; if (v > vtop) vtop = v; if (w > vtop) vtop = w; if (w < wlo) wlo = w; }  const vs = [10, 20, 40, 60, 80, 100, 120, 160, 200, 300, 400], vmax = nice(vtop * 1.05, vs), vmin = Math.min(0, wlo * 1.3);  const Yv = (v: number) => tBot - ((v - vmin) / (vmax - vmin)) * (tBot - tTop);  const dpA = Math.max(D.dp[Q(0, jv)], D.dp0, 1) / 100, dps = [1, 2, 5, 10, 20, 50, 100, 150, 200, 300, 500], dpm = nice(dpA * 1.05, dps), Yp = (p: number) => bBot - (p / dpm) * (bBot - bTop);  const lw = api.u(1.6), P = s.poly;  const curve = (f: (i: number) => number, o: { w: number; dash?: number[] }, Y: (v: number) => number) => {    let n = 0;    for (let i = 0; i <= NR; i++) { if (r[i] > rg && n > 0) { P[n++] = X(rg); P[n++] = Y(f(Math.max(0, i - 1))); break; } P[n++] = X(r[i]); P[n++] = Y(f(i)); }    api.poly(P.subarray(0, n) as unknown as number[], { w: o.w, dash: o.dash });  };  api.line(pl, Yv(0), pr, Yv(0), { w: api.u(0.7) }); api.line(pl, tTop, pl, tBot, { w: api.u(0.7) });  curve((i) => (i > 0 ? G[Q(i, jw)] / r[i] : 0), { w: api.u(0.9), dash: [api.u(1.5), api.u(3)] }, Yv); // 高い所の回転  curve((i) => W[Q(i, jw)], { w: api.u(1), dash: [api.u(5), api.u(3)] }, Yv);  curve((i) => (i > 0 ? G[Q(i, jv)] / r[i] : 0), { w: lw }, Yv);  api.line(pl, bBot, pr, bBot, { w: api.u(0.7) }); api.line(pl, bTop, pl, bBot, { w: api.u(0.7) });  curve((i) => D.dp[Q(i, 0)] / 100, { w: api.u(0.9), dash: [api.u(1.5), api.u(3)] }, Yp); // 地面  curve((i) => D.dp[Q(i, jv)] / 100, { w: lw }, Yp);  if (D.rm < rg) { const xm = X(D.rm); api.line(xm, tTop, xm, bBot, { w: api.u(0.7), dash: [api.u(2), api.u(3)] }); }  const T = (t: string, x: number, y: number, o?: { align?: CanvasTextAlign; font?: "mono" | "sans" }) => api.text(t, x, y, { size: fs, font: o?.font ?? "sans", align: o?.align });  T("v 回転(実線)・w 上下(破線)m/s", x0 + fs * 0.5, y0 + fs * 1.15);  T(fmt(vmax, 0), pl - fs * 0.3, tTop + fs * 0.7, { align: "right", font: "mono" }); T("0", pl - fs * 0.3, Yv(0) + fs * 0.35, { align: "right", font: "mono" });  T("Δp 気圧の低下 hPa(点線は地面)", x0 + fs * 0.5, tBot + fs * 1.15);  T(fmt(dpm, 0), pl - fs * 0.3, bTop + fs * 0.7, { align: "right", font: "mono" }); T("0", pl - fs * 0.3, bBot, { align: "right", font: "mono" });  T("半径 r", pl, bBot + fs * 1.25); T(`${fmt(rg, 0)} m`, pr, bBot + fs * 1.25, { align: "right", font: "mono" });}

漏斗雲の面の向きは、半径の高さごとの傾きから計算する。断面の流線はの等高線で、視線に直角で中心を通る面の上に5分の1秒ごとに引き直す。グラフはthree.jsを使わず、描画のあとに2Dの描画の部品で重ねている。

1コマの進め方

場面を切り替えた直後とつまみを動かしたあとは、流れが落ち着くまで早送りする。1コマに使う時間を6ミリ秒までと決め、その分だけtickを回す。遅い端末でも画面は止まらず、竜巻が育つのに時間がかかるだけである。

1コマの計算(src/sims/tornado-vortex.ts の 926〜973 行)
// 場面の最初は流れが落ち着くまで早送りする。使ってよい時間を決めて、その分だけ進めるlet ran = 0;if (s.spin > 0) {  const t0 = performance.now(), budget = dt === 0 ? 200 : SPINMS;  do { tick(F); s.spin -= TICK; ran++; } while (s.spin > 0 && (api.fast ? ran < 6 : performance.now() - t0 < budget));} else {  if (s.ramp && dt > 0) {    // 2 重の渦の場面: 周りの回転を強めたあと、流れが落ち着くまで同じ早さで待つ。この間は流れを 5 倍の速さで進める    const m = s.ramp; m.t += dt;    const vv = Math.round((m.from + (m.to - m.from) * Math.min(1, m.t / m.T)) * 2) / 2;    if (vv !== api.num("v0")) { api.p.v0 = vv; api.set("v0", vv); applyForcing(api, s); }    if (m.t >= 2 * m.T) s.ramp = null;  }  ran += s.step(s.ramp ? dt * 5 : dt, () => tick(F));}if (ran > 0 || api.frame <= 1) {  derive(F, D, s.size.cloud);}const spinning = s.spin > 0;s.ph += dt * Math.min(2.5, (0.5 * D.vm) / Math.max(1, D.rm));updateFunnel(s, spinning ? Math.max(dt, 0.02) : dt, spinning ? 12 : 2.5);updateSats(s, dt);if (ran > 0 || api.frame <= 1) uploadFlow(s);s.deck.rotation.y += (dt * F.Gam) / (F.r0 * F.r0) / 2; // 雲の底の模様は、周りの回転の半分の速さでゆっくり回す(見た目のため)// 風の階級の輪: 地上近くの風がその階級の下限を超えている、いちばん外の半径for (let k = 0; k < JEF.length; k++) {  let i = NR; while (i > 0 && D.sfcSpeed[i] < JEF[k]) i--;  const l = s.rings[k]; l.visible = i > 0 && s.size.cloud;  if (l.visible) l.scale.set(F.r[i], 1, F.r[i]);}// 粒を運ぶconst p = s.parts;if (p.first && !api.playing) for (let k = 0; k < 24; k++) moveParts(api, s, k === 0 ? 0 : 0.5);else if (api.fast) { p.acc += dt; if (p.acc >= 0.25 || p.first) { moveParts(api, s, Math.min(0.25, p.acc)); p.acc = 0; } }else if (spinning) { moveParts(api, s, 0.25); moveParts(api, s, 0.25); }else if (dt > 0 || p.first || p.injB > p.injA) moveParts(api, s, dt);// 物を動かす(竜巻が育ってから)if (!spinning && s.objs.length) {  s.ostep(dt, (h) => { for (const o of s.objs) stepObj(api, s, o, h); });  for (const o of s.objs) {    o.m.position.set(o.x, o.z + 0.65 * o.kind.size[1] * (s.size.L >= 1 ? VIS : 1), -o.y);    const sp = Math.hypot(o.vx, o.vy, o.vz);    if (o.z > 0.5) { o.m.rotation.x += o.rx * sp * dt * 0.12; o.m.rotation.y += o.ry * sp * dt * 0.12; o.m.rotation.z += o.rz * sp * dt * 0.12; }  }}s.lineTime += dt;if ((s.sect || s.outline) && (s.lineTime > 0.2 || api.frame <= 2 || dt === 0)) { s.lineTime = 0; updateLines(s); }s.lines.visible = s.sect || s.outline;

早送りのあとはstepperで実際の時間と同じ速さに進める。地面をタップしたときは、平面と視線の交点を求めて物を置く。砂ぼこりを選んでいれば、粒の一部をその場所へ放ち直す。

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

プログラム全体を見る(src/sims/tornado-vortex.ts・1066 行)

読み込んでいます

姉妹サイト

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