乾いた砂の山は、注ぎ足しても約33°より急にならない

砂は注ぎ足しても、斜面が約33°を超えた分だけ表面が雪崩れて、元の傾きに戻る。固体のように止まるか液体のように流れるかは、粒どうしの摩擦と転がりにくさで決まる。

記事

乾いた砂の山は、注ぎ足しても約33°より急にならない

砂は注ぎ足しても、斜面が約33°を超えた分だけ表面が雪崩れて、元の傾きに戻る。固体のように止まるか液体のように流れるかは、粒どうしの摩擦と転がりにくさで決まる。

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

現象

砂は注ぎ足しても、急な山にならない

砂をどれだけ注いでも、山の斜面はある角度より急にならない。この角度が安息角で、超えた分だけ表面が雪崩れ落ちて、斜面を元の傾きへ戻してしまう。砂丘の風下の斜面も、実験や野外の測定では30°台の前半にそろっている1。

止まっている砂は、斜面を支える固体に見える。ところが同じ砂が、角度を超えたとたんに水のように流れ出す。固体と液体の境目を、砂はどうやって決めているのだろう。

画面の砂は直径20 cmの粒で、1粒ずつ計算している。「33°で止まれ」という決まりは、プログラムのどこにもない。それでも3,000粒を注ぎ終えると、斜面は平均33°で止まった。流れている粒ほど濃く描くので、育っていく山の斜面を細い雪崩が走るのが見える。

床や山をタップすると、そこへ砂を注げる。山をなぞれば砂を押しのけられるが、溝は残らない。えぐった所へまわりの砂が崩れ込み、裾が広がって斜面が緩くなるからだ。

仕組み

粒はくぼみに座り、摩擦で踏みとどまる

板に消しゴムを載せて傾けていくと、ある角度ですべり出す。その角度は、摩擦係数だけで決まる。

が0.5ならは26.6°だ。では、砂山の角度もこの式で分かるのか。そうはいかない。球は板の上なら緩い坂でも転がるはずなのに、球を積んだ山は坂を作って止まっている。

理由は粒の並びにある。上の粒は、下の粒が作るくぼみに座っている。同じ大きさの球3個のくぼみに乗った球は、全体を19.5°傾けるまで転がり出さない2。摩擦がなくても、並びだけで坂を支えられるわけだ。

水平なら、重さの線は支えの内側を通る 下の粒は3個(右は手前と奥の2個) 19.5°で、重さの線が回る軸の真上にくる 回る軸 全体の傾き19.5°
くぼみに座った粒。全体を19.5°傾けると、重さの線が回る軸の真上を通り、粒は転がり出す

そこへ粒どうしの摩擦と転がりにくさが上乗せされる。シミュレーションは、触れている粒の組ごとに3つの力を求める。離散要素法(DEM)と呼ばれる方法で、1979年にカンドールとストラックが発表した3。

1つ目は押し合う力だ。2つの粒がめり込むと、深さ(m)に比例する力で押し返す。

は押し合う力(N)、はばね定数(N/m)、は減衰の係数(N·s/m)、は2つの粒が離れていく速さ(m/s)である。右辺の2つ目の項は、ぶつかった勢いを弱めるブレーキにあたる。

2つ目の接線の力(N)は、接点が横にずれた長さをばねにためた力で、上限を超えるとすべる。クーロンの摩擦と同じ形になる。

3つ目は転がりの抵抗(N·m)。転がり合った角度をねじりのばねにためて、上限を超えると転がる。

は転がり抵抗の係数、(m)は2つの半径から求める長さで、同じ大きさの粒なら半径の半分になる。本物の砂は角ばっていて転がりにくい。球では出せないこの性質を、で代わりに表した4。

粒a 粒b N Ft Mr 押し合う力N(めり込みに比例) 接線の力Ft(μNまで) 転がりの抵抗Mr(μr R* Nまで) 円の重なった幅がめり込みδ
2つの粒の接点に働く、押し合う力・接線の力・転がりの抵抗

3,000粒を注いで止まったときの斜面の角度を、3回ずつ測った平均が次の表である( = 0.5、 = 0.2だけは13回)。

摩擦係数 = 0 = 0.1 = 0.2 = 0.4
0.118°20°21°21°
0.323°29°30°32°
0.526°30°33°36°
0.827°31°34°38°

が0.1の行は、を上げても20°あたりで頭打ちになる。すべってしまえば、転がりにくくても止まれないからだ。消しゴムの式なら5.7°のはずが20°もあるのは、くぼみの19.5°が底上げしているためだろう。一方、が0.5以上の行ではの差がそのまま効き、10°ほど変わる。

山の大きさは角度に関係しない。斜面に平行な面を思い浮かべると、そこにはその上の砂の重さがかかる。面積あたりの重さのうち、面を押す分を、面に沿ってずらそうとする分を(どちらもPa)とすると、比はどの深さでも同じになる。

深い所ではもも同じ割合で増えるので、比は変わらない。この比が粒の並びと摩擦で決まる限界を超えると、砂は動き出す。粒に比べて十分大きな山なら、小さくても大きくても同じ角度になるのは、このためである。

仕組み

動き出す角度と、止まる角度は違う

砂の表面が動き出すには、粒がくぼみの縁を乗り越えなければならない。いったん動き出した粒は勢いでくぼみを飛び越えるので、動き出した角度より緩い坂でも流れ続ける。だから斜面には2つの角度がある。雪崩が始まる角度と、止まる角度だ。注いだ砂がたまってに届くと表面が流れ、まで緩んで止まる。この行き来が、斜面を走る小さな雪崩の正体である。

雪崩のさなかでも、流れるのは上の層だけで、その下の粒は止まっている。固体と液体の境目は、粒の接点が力を支えきれるかどうかにある。支えきれる接点がつながっていれば固体、すべりや転がりが連なれば流れになる。1つの山の中に、両方が同時にあるのだ。

ただし、流れる砂は水とは違う。水槽の底の穴から出る水の速さは、水面までの高さ(m)と重力加速度(9.8 m/s²)を使ってと書ける。水面が下がれば遅くなる。ところが砂の流量を表すベバルー(Beverloo)則には、砂の高さが入っていない5。

は穴から流れ出る質量(kg/秒)、はかさ密度(kg/m³)、は穴の直径(m)、は粒の直径(m)である。係数のは0.55〜0.65、はおよそ1〜2とされる。粒の中心は穴の縁まで近づけないので、穴がだけ狭くなる。

高さが効かないのは、穴の少し上より高い所では粒が詰まってゆっくり動き、速さを決めるのが穴のすぐ上の落下だからと説明されている。穴の大きさに比例した高さから粒が落ちるなら、速さはに比例する。そこに穴の面積を掛けて、になる。

シミュレーションの砂時計は、この式を入れなくても同じ形の流量を出す。

穴の直径粒の何個分かシミュレーションベバルー則
0.8 m4個約580 kg/秒510 kg/秒
1.0 m5個約1,400 kg/秒1,184 kg/秒
1.2 m6個約2,280 kg/秒2,218 kg/秒
1.4 m7個約3,750 kg/秒3,664 kg/秒
1.6 m8個約5,680 kg/秒5,563 kg/秒

差がいちばん大きい1.0 mでも2割ほどに収まる。ベバルー則の列は、、粒の占める割合を0.6としたかさ密度1,590 kg/m³で計算した。当てはめ直すとは1.27で文献の範囲に入り、は0.54で下限をわずかに下回る。筒の中で実際に粒が占める割合0.7を使えばは0.47まで下がるので、合っているのは主に穴の直径と流量の関係である。

試す

注いで崩して、傾けて落とす

数字は画質「高」(3,000粒)で3回以上測った平均か範囲で、1回ごとに少し違う。スマートフォンや動きの重い端末では画質が「中」や「低」に下がり、粒が減るぶん数字も変わる。操作パネルの「流れている粒」は、秒速0.15 mより速い粒の割合である。

  1. 乾いた砂を注ぐ。約26秒で注ぎ終わり、約33秒ですべての粒が止まる。山の高さは1.8 m、安息角は平均32.8°(13回で31.1〜34.6°)。止まったあとを0にすると山が崩れ、約26°、高さ1.5 mで止まる。転がりにくさだけで、7°分の傾きを支えていたことになる。を0.2に戻して山の上を4回タップすると、注ぎ足した砂で斜面は約32°まで立ち上がる。
  2. 高さ3.6 m、長さ2.4 mの砂の柱を支えた壁を引き抜く(6秒たつと自動で抜ける)。砂は約4秒で止まり、先端は壁から約5.2 m先に届き、高さは2.4 mまで下がる。崩れたあとの斜面は17〜18°で、安息角よりずっと緩い。勢いがついた砂は、静かに積もる砂より遠くまで流れるからだ。
  3. 砂を入れた箱を1秒に1°ずつ傾ける。砂の斜面が31〜33°に届くと雪崩が始まり、大きいときは箱の砂の4〜6割が動いて、斜面は28〜29°で止まる。傾け続けると、また31〜33°で次の雪崩が起きる。左上の線が、のこぎりの歯のように上がり下がりする。
  4. 直径1 mの穴を開けた筒から砂を落とす。流量は砂の高さが2.5 mでも1 mでも1秒に約1,400 kgのままで、1 mを切るとようやく下がる。つまみで穴を0.6 m(粒3個分)に狭めると、長くても10秒ほどで粒が橋を組んで止まる。0.7 mでは詰まる回と最後まで流れる回に分かれ、0.8 mなら最後まで流れる。穴を広げ直すと、橋が崩れてまた流れ出す。
  5. 粒どうしに水の引力を足して注ぐ。水の引力が粒の重さと同じなら斜面は約42°、3倍で約46°(42〜51°)まで立つ。乾いた砂より10°ほど急だ。ただ6倍では約39°(30〜45°)と回ごとの差が大きく、注ぐだけではこれ以上急にならない。粒がかたまりになり、かたまりごと転がり落ちるからだろう。
  6. 山を断面で切り、大きな力を受けている接点を棒で描く。太い棒は真下へ伸びるだけでなく、斜めに枝分かれしてつながっている。これが力の鎖だ。平均の2倍を超える力を受ける接点は全体の13%で、すべての接点が受ける力の36%を担う。一方、平均より小さい62%の接点の力を合わせても28%しかない。
現実では

砂丘から工事現場、1年の砂時計まで

砂丘の風下の斜面では、2つの角度が実際に測られている。ペルティエらの論文によると、サットンらが実験で作った高さ1.2 mの斜面では、雪崩が始まる角度の平均が34°、止まる角度の平均が32°だった。ペルティエらがブラジルの砂丘をレーザーで測ると、始まる角度は風の強い時間帯に約32.2°まで下がった。風の乱れが斜面に沿って砂を押し下げるためと説明している1。

砂の安息角は、工事の安全基準にも顔を出す。日本の労働安全衛生規則は、砂の地山を手掘りするとき、掘削面の勾配を35°以下にするか、高さを5 m未満にするよう定めている。米国労働安全衛生局(OSHA)の基準でも、砂を含む崩れやすい土(Type C)の斜面は1.5対1、角度にして34°までだ6。どちらも砂が自分で保てる傾きと、ほぼ同じ数字である。ただし基準の一部を抜き出した紹介で、この角度なら崩れないという保証ではない。現場の判断に、この記事の数字は使えない。

砂の柱の崩れ方も、実験と同じ傾向になった。スタロンとヒンチの論文が引くルーベらの水路実験では、柱の高さと長さの比が小さい範囲で、広がった距離が元の長さの約倍になる。高さ3.6 m、長さ2.4 mの画面の柱ならは1.5で1.8倍、シミュレーションでは約2.2倍である7。

流量が砂の高さに左右されない性質は、砂時計が時を刻める理由そのものだろう。島根県大田市の仁摩サンドミュージアムにある一年計砂時計「砂暦」は、高さ5.2 mのガラス容器に入った1トンの砂を、1年かけて落とす。割り算すると1秒に約0.03 g。大晦日には108人が綱を引いて、この砂時計を反転させる8。

力の鎖も、本物の粒で確かめられている。リュウらは積んだビーズの中に、平均よりずっと大きな力がまれに現れることを示した。マジュムダルとベーリンガーは、力がかかると偏光で見える縞模様が変わる円板を平らに並べ、枝のように連なる力の鎖の中で1粒ごとの力を測っている9。

濡れた砂の城には、また別の限界がある。パクプールらの実験では、砂の強さは水が体積で約1%のときに最も高くなった。砂の柱は自分の重さで座屈して折れ、立てられる高さは底の半径の2/3乗に比例する10。

省いていること

粒を大きく、ばねを柔らかくした代償

本物の砂は粒がずっと小さく、直径0.5 mmなら1 Lに約900万個入る11。しかも硬い。硬いばねほど揺れが速く、時間の刻みを細かくしないと計算が発散する。そこで粒を直径20 cm(質量約11 kg)に大きくし、1粒の重さで2.7 mm縮む柔らかいばねにして、1/240秒の刻みで計算した。ばねを硬くして測り直すと、影響は次のように出る。

ばね定数刻み安息角いちばん深いめり込み山の中で粒が占める割合
4万 N/m1/240秒32.8°3.3 cm0.67
16万 N/m1/480秒33.0°1.1 cm0.62
64万 N/m1/960秒32.6°0.5 cm0.62

角度の差は、同じ条件を繰り返したときのばらつきより小さい。代わりに砂は押し縮められて、粒の詰まり方は1割ほど密になる。筒の砂時計はさらに深いので、粒が占める割合は約0.7まで上がる。

画質を下げると粒の数が減り、柱や筒も小さくなる。「中」(1,500粒)の安息角は約32°、柱を崩した先端は約4 m先、箱の雪崩の始まりは34〜35°、穴1 mの流量は1秒に約1,300 kgである。水の引力3倍の山は約54°まで立つ。引力は深さによらず一定なので、小さな山ほど急になるのだろう。「低」(750粒)の山は高さが粒5個分ほどしかなく、安息角は約29°と低く測れる。

  • 粒が割れることはなく、粒のあいだの空気も入れていない
  • 床に触れて止まった粒はその場に固定し、床を粗い面として扱う。砂時計の筒は粒14個分の太さしかなく、筒が流量に関わらない目安5より細い
  • 壁の摩擦はばねを持たない簡単な形にした。箱の雪崩を山の安息角と比べられるのは、既定の値の近くまでである。を0.8、を0.4に上げても最初の雪崩は31〜33°で始まり、傾きを上限の42°にしても斜面は約34°にとどまる。注いだ山の38°に届かないのは、壁のそばで砂が少しずつずれるうえ、円すいの山のようにまわりから支え合えないためとみられる
  • 本物の水の引力は粒が小さいほど重さに比べて強くなる。直径0.5 mmなら重さの約66倍で、直径20 cmなら0.0004倍しかない12。そこで引力は粒の重さとの比で与え、6倍までとした。水の量や動きは計算していない。6倍でも高さ3.6 mの柱は立ったままにならない
参考文献

参考文献

  1. Pelletier, J. D.ほか「Dynamics of sediment storage and release on aeolian dune slip faces: A field study in Jericoacoara, Brazil」『Journal of Geophysical Research: Earth Surface』120(9), 1911-1934, 2015. https://openaccess.city.ac.uk/id/eprint/12538/
  2. Cundall, P. A., Strack, O. D. L.「A discrete numerical model for granular assemblies」『Géotechnique』29(1), 47-65, 1979. およびAi, J., Chen, J.-F., Rotter, J. M., Ooi, J. Y.「Assessment of rolling resistance models in discrete element simulations」『Powder Technology』206(3), 269-282, 2011.
  3. Beverloo, W. A., Leniger, H. A., van de Velde, J.「The flow of granular solids through orifices」『Chemical Engineering Science』15(3-4), 260-269, 1961. およびMankoc, C.ほか「The flow rate of granular materials through an orifice」『Granular Matter』9(6), 407-414, 2007. https://arxiv.org/abs/0707.4550
  4. 熊本労働局「掘削の高さ、深さが10 m以上の地山の掘削 事前チェックリスト」(労働安全衛生規則第357条). https://jsite.mhlw.go.jp/kumamoto-roudoukyoku/content/contents/000969712.pdf およびOccupational Safety and Health Administration「29 CFR 1926 Subpart P Appendix A・B」. https://www.osha.gov/laws-regs/regulations/standardnumber/1926/1926SubpartPAppB
  5. 仁摩サンドミュージアム「時の祭典」. https://www.sandmuseum.jp/festival/ および旅色「仁摩サンドミュージアム」. https://tabiiro.jp/leisure/s/206559-oda-sandmuseum/
  6. Liu, C.-h.ほか「Force fluctuations in bead packs」『Science』269(5223), 513-515, 1995. およびMajmudar, T. S., Behringer, R. P.「Contact force measurements and stress-induced anisotropy in granular materials」『Nature』435, 1079-1082, 2005.
  7. Pakpour, M., Habibi, M., Møller, P., Bonn, D.「How to construct the perfect sandcastle」『Scientific Reports』2, 549, 2012. およびMitarai, N., Nori, F.「Wet granular materials」『Advances in Physics』55(1-2), 1-45, 2006. https://arxiv.org/abs/cond-mat/0601660
  8. Lube, G., Huppert, H. E., Sparks, R. S. J., Freundt, A.「Collapses of two-dimensional granular columns」『Physical Review E』72(4), 041301, 2005. およびStaron, L., Hinch, E. J.「Study of the collapse of granular columns using two-dimensional discrete-grain simulation」『Journal of Fluid Mechanics』545, 1-27, 2005. https://arxiv.org/abs/physics/0501022

注

  1. 参考文献1。サットンらの実験(Suttonほか2013)の値は、この論文の紹介による。野外の砂丘で風の弱い時間帯に始まる角度は約32.35〜33°。実験室で測った2つの角度の差は、報告によって1°未満から5°まで幅がある(Bagnold 1966、Börzsönyiほか2008。同じ論文の紹介による)。 ↩ ↩2

  2. 3個の球の中心が作る正三角形の重心の真上に、上の球の中心がある。球の直径をとすると、中心の高さの差は、重心から三角形の辺までは。傾けて上の球の中心が辺の真上に来るのは、、つまりのときで、は19.47°。三角形の頂点の向きへ傾けるなら、35.3°まで耐える。 ↩

  3. 参考文献2。 ↩

  4. 参考文献2のAiらが比べた転がり抵抗の形のうち、ばねと上限を組み合わせたものにならった。転がりの上限のはで、とは2つの粒の半径。 ↩

  5. 参考文献3。Mankocらは、砂の層の厚さが筒の直径の約1.2倍を超えると、底の圧力が頭打ちになって流量がほぼ一定になるとも説明する。筒の直径が流量に関わらない目安は、穴の直径の2.5倍以上で、かつ穴の直径と粒の直径の30倍の和より太いこと。砂ではが2.9という報告もある。 ↩ ↩2

  6. 参考文献4。日本の基準は手掘りの掘削に限る。OSHAの基準は深さ20フィート(約6 m)までの掘削の値。 ↩

  7. 参考文献8。実験式は、StaronとHinchの論文に引かれた形による。は最初の高さ ÷ 長さで、Lubeらは流れ方の境目をが約1.8としている。 ↩

  8. 参考文献5。高さと砂の量は旅色の施設紹介による。施設の公式ページでは確認できなかった。1トン ÷(365日 × 86,400秒)≈ 0.032 g/秒。 ↩

  9. 参考文献6。マジュムダルとベーリンガーの実験は、光弾性の円板を平面に並べたもの。 ↩

  10. 参考文献7。 ↩

  11. 粒が占める割合を0.6として、直径0.5 mmの球の体積(6.5 × 10⁻¹¹ m³)で割って求めた。 ↩

  12. 参考文献7のMitaraiとNoriが示す、接した2つの球のあいだの小さな水の橋の力(は粒の半径、は水の表面張力0.072 N/m)を、密度2,650 kg/m³の粒の重さで割った。 ↩

プログラム
プログラム

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

このシミュレーションは、1/240秒ごとに「触れそうな粒の組の表を確かめる → 組ごとの力を足す → 床と壁の力を足す → すべての粒の速さと位置を進める」をくり返している。計算はすべて自前の型付き配列の上で動き、three.jsは結果の位置を受け取って球を並べて描くだけだ。粒の物理にthree.jsの機能は使っていない。

長さはメートル、粒は直径20 cm

長さはメートル、時間は秒、質量はキログラム、力はニュートンで数える。粒の直径は平均0.2 mで、±15%の散らばりを持たせた。大きさがそろった球は結晶のように並び、山の形が変わってしまうからである。

定数と単位(src/sims/sand-pile.ts の 20〜44 行)
// 長さ m・時間 秒・質量 kg・力 N(1 = 1 m)const G = 9.8;                   // 重力加速度 m/s²const D0 = 0.2;                  // 粒の直径の平均 mconst SPREAD = 0.15;             // 粒の大きさの散らばり ±15 %。そろった球は結晶のように並び、山の形が変わってしまうconst RHO = 2650;                // 粒の密度 kg/m³(石英)const M0 = RHO * Math.PI / 6 * D0 ** 3; // 平均の粒の質量 約 11 kgconst KN = 40000;                // 押し合うばね N/m。粒の重さ(約 109 N)で 2.7 mm 縮むconst KT = KN * 2 / 7;           // 接線のばね N/mconst LOG_E = -Math.log(0.3);    // 反発係数 0.3 の対数const CN = 2 * (LOG_E / Math.hypot(Math.PI, LOG_E)) * Math.sqrt(KN * M0 / 2); // 法線の減衰 N·s/mconst CT = 0.2 * Math.sqrt(KT * M0 / 2);   // 接線の減衰 N·s/mconst KR = KT * (D0 / 4) ** 2;   // 転がりのばね N·m/radconst CR = 0.2 * Math.sqrt(KR * 0.4 * M0 * (D0 / 2) ** 2); // 転がりの減衰 N·m·s/radconst CW = 1200;                 // 壁の摩擦の、遅いときの粘り N·s/mconst H = 1 / 240;               // 時間の刻み 秒。1 コマ(1/60 秒)に 4 回const SKIN = 0.1;                // 近くの粒の表に入れる余裕 mconst MU_FLOOR = 1.0;            // 床と穴の開いた板の摩擦係数const MUR_FLOOR = 0.3;           // 床の転がり抵抗const VFLOW = 0.15;              // これより速い粒を「流れている」と数える m/sconst VMAX = 30;                 // 速さの上限 m/s(計算が暴れないため)const BASE = [750, 1500, 3000];  // 画質 0・1・2 の粒の数const GAP_WET = 0.01;            // 水の橋が切れるすきま mconst RHO_B = 0.6 * RHO;         // ベバルー則に入れるかさ密度 kg/m³(粒が占める割合 0.6)const DEG = Math.PI / 180;const LAT_A = 0.235, LAT_G = 0.17, LAT_V = LAT_A * LAT_A * LAT_G; // 最初に粒を置く格子: 横の間隔・段の間隔(一段おきに半分ずらす)・粒 1 個あたりの体積

ばね定数KNは、ばねの揺れが刻みHで十分追える固さから決めた。粒の質量(約11 kg)とばね定数で決まるに対して、刻みはその4分の1ほどにしてある。ばねを4倍硬くすると、同じ割合を保つために刻みは半分になる。減衰のCNは、反発係数0.3から求めた。

近くの粒だけを調べる

3,000粒のすべての組を毎回調べると、組の数は約450万になる。そこで「いつか触れるかもしれない組の表」を作っておき、力の計算ではこの表だけを見る。

近くの粒を探す(src/sims/sand-pile.ts の 139〜188 行)
// 触れそうな組の表を、粒が表の余裕の半分だけ動くまで使い回す。表を作るときは 1 辺約 0.33 m の格子で近くのますだけを見るprivate rebuild() {  const { P, R, alive, fixed, cellHead, cellNext, cellOf, chainHead, chainNext, cs, nx, ny, nz, ox, oy, oz } = this;  const n = this.n, PA = this.PA, PB = this.PB, HS = this.HS, FC = this.FC, A2 = this.PA2, B2 = this.PB2, H2 = this.HS2, C2 = this.FC2, maxP = this.maxP;  cellHead.fill(-1);  for (let i = 0; i < n; i++) {    if (!alive[i]) continue;    const k = i * 3;    let ix = ((P[k] - ox) / cs) | 0, iy = ((P[k + 1] - oy) / cs) | 0, iz = ((P[k + 2] - oz) / cs) | 0;    ix = ix < 0 ? 0 : ix >= nx ? nx - 1 : ix; iy = iy < 0 ? 0 : iy >= ny ? ny - 1 : iy; iz = iz < 0 ? 0 : iz >= nz ? nz - 1 : iz;    const c = ix + nx * (iy + ny * iz);    cellOf[i] = c; cellNext[i] = cellHead[c]; cellHead[c] = i;  }  // 触れていた組の、ばねにたまった伸びを引き継ぐ。小さいほうの番号ごとにつないでおき、新しい表を作るときに探す  chainHead.fill(-1, 0, n);  for (let p = 0, e = this.np; p < e; p++) if (FC[p] !== 0) { const a = PA[p]; chainNext[p] = chainHead[a]; chainHead[a] = p; }  let m = 0;  for (let i = 0; i < n; i++) {    if (!alive[i]) continue;    const ki = i * 3, xi = P[ki], yi = P[ki + 1], zi = P[ki + 2], ri = R[i], c = cellOf[i], fi = fixed[i];    const iz = (c / (nx * ny)) | 0, rest = c - iz * nx * ny, iy = (rest / nx) | 0, ix = rest - iy * nx;    // 自分のますの残りと、前半分の 13 のますだけを見る(同じ組を二度数えない)    for (let s = 0; s < 14; s++) {      let j: number;      if (s === 0) j = cellNext[i];      else {        const jx = ix + STX[s], jy = iy + STY[s], jz = iz + STZ[s];        if (jx < 0 || jx >= nx || jy < 0 || jy >= ny || jz < 0 || jz >= nz) continue;        j = cellHead[jx + nx * (jy + ny * jz)];      }      for (; j >= 0; j = cellNext[j]) {        const kj = j * 3, dx = P[kj] - xi, dy = P[kj + 1] - yi, dz = P[kj + 2] - zi, rr = ri + R[j] + SKIN;        if (dx * dx + dy * dy + dz * dz >= rr * rr || m >= maxP || (fi && fixed[j])) continue;        const a = i < j ? i : j, b = i < j ? j : i, q = m * 6;        A2[m] = a; B2[m] = b; C2[m] = 0;        H2[q] = 0; H2[q + 1] = 0; H2[q + 2] = 0; H2[q + 3] = 0; H2[q + 4] = 0; H2[q + 5] = 0;        for (let o = chainHead[a]; o >= 0; o = chainNext[o]) if (PB[o] === b) {          const u = o * 6;          H2[q] = HS[u]; H2[q + 1] = HS[u + 1]; H2[q + 2] = HS[u + 2]; H2[q + 3] = HS[u + 3]; H2[q + 4] = HS[u + 4]; H2[q + 5] = HS[u + 5];          C2[m] = FC[o];          break;        }        m++;      }    }  }  this.np = m;  this.PA = A2; this.PB = B2; this.HS = H2; this.FC = C2; this.PA2 = PA; this.PB2 = PB; this.HS2 = HS; this.FC2 = FC;  this.disp = 0; this.dirty = false;}

表を作るときは、空間を1辺約0.33 mのますに分け、各粒が自分のますと隣のますの粒だけを調べる。隣のますを半分の13個に絞っているのは、同じ組を二度数えないためだ。表には、中心の距離が半径の和より0.1 m長い組まで入れる。この余裕の8割だけ粒が動くまで、同じ表を使い回す。止まった山なら表の作り直しはほとんど起きない。

表を作り直すときに忘れてはならないのが、ばねにたまった伸びである。接線と転がりのばねは組ごとの記憶なので、触れていた組の伸びを新しい表へ写している。

粒どうしの力

本文の3つの式が、この部分にそのまま入っている。

粒どうしの力(src/sims/sand-pile.ts の 190〜262 行)
private contacts() {  const { P, V, W, F, T, R, PA, PB, HS, FC } = this;  const mu = this.mu, mur = this.mur, coh = this.coh, gap = this.gapR;  let omax = 0;  for (let p = 0, np = this.np; p < np; p++) {    const a = PA[p], b = PB[p], ia = a * 3, ib = b * 3;    const dx = P[ib] - P[ia], dy = P[ib + 1] - P[ia + 1], dz = P[ib + 2] - P[ia + 2];    const ra = R[a], rb = R[b], sum = ra + rb, reach = sum + gap, d2 = dx * dx + dy * dy + dz * dz;    if (d2 >= reach * reach) {      if (FC[p] !== 0) { const q = p * 6; FC[p] = 0; HS[q] = 0; HS[q + 1] = 0; HS[q + 2] = 0; HS[q + 3] = 0; HS[q + 4] = 0; HS[q + 5] = 0; }      continue;    }    const dist = Math.sqrt(d2) + 1e-9, inv = 1 / dist, nx = dx * inv, ny = dy * inv, nz = dz * inv, over = sum - dist;    if (over <= 0) {      // 触れる手前: 水の橋の引力だけが働く(離れるほど弱まり、すきま 1 cm で切れる)。乾いた砂(gap = 0)では 0 で割らないよう何もしない      if (FC[p] !== 0) { const q = p * 6; FC[p] = 0; HS[q] = 0; HS[q + 1] = 0; HS[q + 2] = 0; HS[q + 3] = 0; HS[q + 4] = 0; HS[q + 5] = 0; }      if (gap > 0) {        const f = coh * (1 + over / gap);        F[ia] += f * nx; F[ia + 1] += f * ny; F[ia + 2] += f * nz; F[ib] -= f * nx; F[ib + 1] -= f * ny; F[ib + 2] -= f * nz;      }      continue;    }    if (over > omax) omax = over;    // 接点での相対速度(b から a を引いた向き)    const rvx = V[ib] - V[ia], rvy = V[ib + 1] - V[ia + 1], rvz = V[ib + 2] - V[ia + 2];    const vn = rvx * nx + rvy * ny + rvz * nz;    // 押し合う力 N = k_n δ − c_n v_n(引っ張りにはならない)    let fn = KN * over - CN * vn;    if (fn < 0) fn = 0;    FC[p] = fn > 1e-6 ? fn : 1e-6;    const wax = W[ia], way = W[ia + 1], waz = W[ia + 2], wbx = W[ib], wby = W[ib + 1], wbz = W[ib + 2];    const sx = ra * wax + rb * wbx, sy = ra * way + rb * wby, sz = ra * waz + rb * wbz;    const vtx = rvx - vn * nx - (sy * nz - sz * ny), vty = rvy - vn * ny - (sz * nx - sx * nz), vtz = rvz - vn * nz - (sx * ny - sy * nx);    // 接線のばね: 接点が横にずれた長さをためる。ためた向きは今の接平面の上に入れ直す    const q = p * 6;    let ux = HS[q], uy = HS[q + 1], uz = HS[q + 2];    const un = ux * nx + uy * ny + uz * nz;    ux += vtx * H - un * nx; uy += vty * H - un * ny; uz += vtz * H - un * nz;    let ftx = -KT * ux, fty = -KT * uy, ftz = -KT * uz;    const lim = mu * fn, ft2 = ftx * ftx + fty * fty + ftz * ftz;    if (ft2 > lim * lim) {      // クーロンの上限 μN を超えたらすべる。ばねの伸びは上限の分まで縮める      const c = lim / Math.sqrt(ft2);      ftx *= c; fty *= c; ftz *= c;      ux = -ftx / KT; uy = -fty / KT; uz = -ftz / KT;    } else { ftx -= CT * vtx; fty -= CT * vty; ftz -= CT * vtz; }    HS[q] = ux; HS[q + 1] = uy; HS[q + 2] = uz;    // 粒 b が受ける力 = (押し合い − 水の引力)の法線の力 + 接線の力。粒 a はその逆(作用と反作用)    const fnn = fn - coh, fx = fnn * nx + ftx, fy = fnn * ny + fty, fz = fnn * nz + ftz;    F[ib] += fx; F[ib + 1] += fy; F[ib + 2] += fz; F[ia] -= fx; F[ia + 1] -= fy; F[ia + 2] -= fz;    const cx = ny * ftz - nz * fty, cy = nz * ftx - nx * ftz, cz = nx * fty - ny * ftx;    T[ia] -= ra * cx; T[ia + 1] -= ra * cy; T[ia + 2] -= ra * cz; T[ib] -= rb * cx; T[ib + 1] -= rb * cy; T[ib + 2] -= rb * cz;    // 転がりの抵抗: 転がり合った角度をためる。上限 μr R* N を超えたら転がる    if (mur > 0) {      let rx = wax - wbx, ry = way - wby, rz = waz - wbz;      const rn = rx * nx + ry * ny + rz * nz;      rx -= rn * nx; ry -= rn * ny; rz -= rn * nz;      let tx = HS[q + 3], ty = HS[q + 4], tz = HS[q + 5];      const tn = tx * nx + ty * ny + tz * nz;      tx += rx * H - tn * nx; ty += ry * H - tn * ny; tz += rz * H - tn * nz;      let mx = -KR * tx, my = -KR * ty, mz = -KR * tz;      const mlim = mur * (ra * rb / sum) * fn, m2 = mx * mx + my * my + mz * mz;      if (m2 > mlim * mlim) {        const c = mlim / Math.sqrt(m2);        mx *= c; my *= c; mz *= c;        tx = -mx / KR; ty = -my / KR; tz = -mz / KR;      } else { mx -= CR * rx; my -= CR * ry; mz -= CR * rz; }      HS[q + 3] = tx; HS[q + 4] = ty; HS[q + 5] = tz;      T[ia] += mx; T[ia + 1] += my; T[ia + 2] += mz; T[ib] -= mx; T[ib + 1] -= my; T[ib + 2] -= mz;    }  }  this.overMax = omax;}

まず2つの粒の距離からめり込みを求め、押し合う力を計算する。引っ張りの力にならないよう、負なら0にする。次に接点の相対速度を計算する。粒の回転も接点を動かすので、sx、sy、szの項で足している。

接線のばねは、横にずれた長さux、uy、uzをためていく。接点の向きは粒の動きとともに変わるので、ためた向きを今の接平面に入れ直してから足す。力がを超えたら、大きさをに縮め、ばねの伸びもその分まで戻す。本文のは、この数行にあたる。転がりの抵抗も同じ形で、たまるものが角度に変わるだけだ。

力は粒bに足し、粒aには逆向きに足す。作用と反作用である。接線の力は粒を回す力にもなるので、半径を掛けて回転の力Tにも足す。濡れた砂では、押し合う力から水の引力を引くだけで、接点が離れても1 cmまでは引力が残る。摩擦の上限は押し合う力で決まるので、水が粒を押し付ける分だけ、摩擦も強くなる。

床と壁

床と壁(src/sims/sand-pile.ts の 264〜340 行)
/** 壁との接触: n は壁から粒へ向かう向き、over はめり込み。摩擦はばねを持たず、遅いときは粘りで止める */private wall(i: number, nx: number, ny: number, nz: number, over: number, mu: number, wvx = 0, wvy = 0, wvz = 0) {  const { V, W, F, T, R } = this;  const k = i * 3, r = R[i], wx = W[k], wy = W[k + 1], wz = W[k + 2];  const vx = V[k] - wvx - r * (wy * nz - wz * ny), vy = V[k + 1] - wvy - r * (wz * nx - wx * nz), vz = V[k + 2] - wvz - r * (wx * ny - wy * nx);  const vn = vx * nx + vy * ny + vz * nz;  const fn = KN * over - CN * vn;  if (fn <= 0) return;  let fx = fn * nx, fy = fn * ny, fz = fn * nz;  const tx = vx - vn * nx, ty = vy - vn * ny, tz = vz - vn * nz, ts = Math.sqrt(tx * tx + ty * ty + tz * tz);  if (ts > 1e-9 && mu > 0) {    const lim = mu * fn, ft = CW * ts < lim ? CW * ts : lim, c = -ft / ts, ftx = c * tx, fty = c * ty, ftz = c * tz;    fx += ftx; fy += fty; fz += ftz;    T[k] -= r * (ny * ftz - nz * fty); T[k + 1] -= r * (nz * ftx - nx * ftz); T[k + 2] -= r * (nx * fty - ny * ftx);  }  F[k] += fx; F[k + 1] += fy; F[k + 2] += fz;} private boundaries() {  const { P, V, W, F, T, R, alive, fixed, onFloor, HF } = this;  const n = this.n, krf = KR * 4, crf = CR * 2;  const xMin = this.xMin, xMax = this.xMax, zMin = this.zMin, zMax = this.zMax, muW = this.muW, silo = this.siloR, plate = this.hasPlate, py = this.plateY, hr = this.holeR, damOn = this.damOn, damX = this.damX, damEdge = this.damEdge;  const fy0 = this.floorY, fx0 = this.fx0, fx1 = this.fx1, fz0 = this.fz0, fz1 = this.fz1, tool = this.toolOn, tr = this.tr, tx = this.tx, ty = this.ty, tz = this.tz;  for (let i = 0; i < n; i++) {    if (!alive[i] || fixed[i]) continue;    const k = i * 3, x = P[k], y = P[k + 1], z = P[k + 2], r = R[i];    // 床(穴の開いた板も同じ)。平らな面なので、接線と転がりのばねの伸びを粒ごとに持つ    let ground = fy0, flat = x >= fx0 && x <= fx1 && z >= fz0 && z <= fz1;    if (plate && y > py - r * 0.5) {      const rho2 = x * x + z * z;      if (rho2 >= hr * hr) { ground = py; flat = true; }      else {        flat = false;        // 穴の縁(輪)に当たる        const rho = Math.sqrt(rho2) + 1e-9, e = hr - rho, dy = y - py, dd = e * e + dy * dy;        if (dd < r * r) { const dist = Math.sqrt(dd) + 1e-9; this.wall(i, -x / rho * e / dist, dy / dist, -z / rho * e / dist, r - dist, muW); }      }    }    const over = ground + r - y;    if (flat && over > 0) {      const fn0 = KN * over - CN * V[k + 1], fn = fn0 > 0 ? fn0 : 0, q = i * 4, wx = W[k], wz = W[k + 2];      const vcx = V[k] + r * wz, vcz = V[k + 2] - r * wx;      let ux = HF[q] + vcx * H, uz = HF[q + 1] + vcz * H;      let fx = -KT * ux, fz = -KT * uz;      const lim = MU_FLOOR * fn, f2 = fx * fx + fz * fz;      if (f2 > lim * lim) { const c = lim / Math.sqrt(f2); fx *= c; fz *= c; ux = -fx / KT; uz = -fz / KT; }      else { fx -= CT * vcx; fz -= CT * vcz; }      HF[q] = ux; HF[q + 1] = uz;      F[k] += fx; F[k + 1] += fn; F[k + 2] += fz;      T[k] -= r * fz; T[k + 2] += r * fx;      let ax = HF[q + 2] + wx * H, az = HF[q + 3] + wz * H;      let mx = -krf * ax, mz = -krf * az;      const ml = MUR_FLOOR * r * fn, m2 = mx * mx + mz * mz;      if (m2 > ml * ml) { const c = ml / Math.sqrt(m2); mx *= c; mz *= c; ax = -mx / krf; az = -mz / krf; }      else { mx -= crf * wx; mz -= crf * wz; }      HF[q + 2] = ax; HF[q + 3] = az;      T[k] += mx; T[k + 2] += mz;      onFloor[i] = 1;    } else if (onFloor[i]) { onFloor[i] = 0; const q = i * 4; HF[q] = 0; HF[q + 1] = 0; HF[q + 2] = 0; HF[q + 3] = 0; }    // 容器の壁    if (x - r < xMin) this.wall(i, 1, 0, 0, xMin - x + r, muW);    if (x + r > xMax) this.wall(i, -1, 0, 0, x + r - xMax, muW);    if (z - r < zMin) this.wall(i, 0, 0, 1, zMin - z + r, muW);    if (z + r > zMax) this.wall(i, 0, 0, -1, z + r - zMax, muW);    // 引き抜く壁: 引き上げた縁より上の粒だけを止める    if (damOn && x + r > damX && y > damEdge) this.wall(i, -1, 0, 0, x + r - damX, muW);    if (silo > 0 && y > py - r) {      const rho2 = x * x + z * z, lim = silo - r;      if (rho2 > lim * lim) { const rho = Math.sqrt(rho2); this.wall(i, -x / rho, 0, -z / rho, rho - lim, muW); }    }    // 指の玉に押される    if (tool) {      const dx = x - tx, dy = y - ty, dz = z - tz, rr = tr + r, d2 = dx * dx + dy * dy + dz * dz;      if (d2 < rr * rr && d2 > 1e-12) { const d = Math.sqrt(d2); this.wall(i, dx / d, dy / d, dz / d, rr - d, 0.3, this.tvx, this.tvy, this.tvz); }    }  }}

床は平らなので、粒どうしと同じばねの伸びを粒ごとに持つ。穴の開いた板では、中心が穴の中にある粒だけ、穴の縁(輪)との距離で当たりを調べる。壁の摩擦はばねを持たない簡単な形で、遅いときは粘りで止める。

時間を進める

時間を進める(src/sims/sand-pile.ts の 342〜391 行)
// 1 刻みの流れ: 表を確かめる → 力を 0 にする → 粒どうしの力 → 床と壁の力 → 速さと位置を進めるprivate integrate() {  const { P, V, W, F, T, IM, II, alive, fixed } = this;  const n = this.n, h = H, gx = this.gx, gy = this.gy, killY = this.killY, vf2 = VFLOW * VFLOW;  // 格子に置いた粒を詰まらせる最初の間だけ、空気の抵抗のように速さを落とす(勢いよく詰まって、ばねが縮みすぎないように)  const drag = this.t < this.settle ? 0.97 : 1;  let vm2 = 0, mov = 0, cnt = 0;  for (let i = 0; i < n; i++) {    if (!alive[i] || fixed[i]) continue;    const k = i * 3, im = IM[i], ii = II[i];    // 半陰的オイラー法: 新しい速さで位置を進める    let vx = (V[k] + (F[k] * im + gx) * h) * drag, vy = (V[k + 1] + (F[k + 1] * im + gy) * h) * drag, vz = (V[k + 2] + F[k + 2] * im * h) * drag;    let v2 = vx * vx + vy * vy + vz * vz;    if (!(v2 < VMAX * VMAX)) {      if (v2 !== v2) { this.kill(i); continue; }      const c = VMAX / Math.sqrt(v2); vx *= c; vy *= c; vz *= c; v2 = VMAX * VMAX;    }    V[k] = vx; V[k + 1] = vy; V[k + 2] = vz;    W[k] += T[k] * ii * h; W[k + 1] += T[k + 1] * ii * h; W[k + 2] += T[k + 2] * ii * h;    P[k] += vx * h; P[k + 1] += vy * h; P[k + 2] += vz * h;    if (v2 > vm2) vm2 = v2;    if (v2 > vf2) mov++;    cnt++;    if (P[k + 1] < killY) this.kill(i);  }  this.moving = cnt > 0 ? mov / cnt : 0;  this.vmax = Math.sqrt(vm2);  this.disp += this.vmax * h;} step() {  if (!this.sleeping) {    // 粒が表の余裕の 8 割だけ動いたら表を作り直す(落ちてくる粒が止まっている粒に近づく速さは、落ちる速さと同じ)    if (this.dirty || this.disp > (SKIN - this.gapR) * 0.8) this.rebuild();    const e = this.n * 3;    this.F.fill(0, 0, e); this.T.fill(0, 0, e);    this.contacts();    this.boundaries();    this.integrate();    // すべての粒が止まって 1 秒たったら、計算を休む(何かが変わるまで結果は同じなので)。    // 壁の摩擦はばねを持たないので、壁のそばの粒はわずかに回り続ける。水路と箱では少し大きな速さまで止まったとみなす    const calm = this.key === "slump" || this.key === "tilt" ? 0.08 : 0.02;    this.still = this.vmax < calm && !this.pouring && !this.toolOn ? this.still + H : 0;    if (this.still > 1) { this.sleeping = true; this.moving = 0; }  }  this.stepN++; this.t += H;  this.sceneStep();}/** 止まって休んでいる計算を起こす(材料や容器を変えたとき) */wake() { this.sleeping = false; this.still = 0; }

半陰的オイラー法で進める。先に力から速さを更新し、その新しい速さで位置を動かす。ばねの揺れのエネルギーが増えにくい、単純で扱いやすい方法である。回転の刻みも同じだが、小さな粒は慣性モーメントが半径の5乗で小さくなるので、接線と転がりの減衰を弱めてある。強すぎると、減衰の計算そのものが揺れを増やしてしまう。

すべての粒が止まって1秒たったら、力の計算を休む。何かが変わるまで結果は同じだからだ。つまみを動かしたり砂を注いだりすると、wakeで計算を起こす。水路と箱では、壁のそばの粒がわずかに回り続けるので、止まったとみなす速さを少し大きくしてある。

格子に置いた粒を詰まらせる最初の間だけ、空気の抵抗のように速さを落としている。勢いよく詰まると、柔らかいばねが縮みすぎて、砂が固まってしまうためである。

山の形から角度を測る

山を測る(src/sims/sand-pile.ts の 528〜650 行)
/** 高さの表: 1 辺 20 cm のますごとに、止まっている粒のてっぺんの高さ(落ちている粒は入れない) */surface(y0 = this.floorY, onlyAbove = -Infinity) {  const { P, V, R, alive, top, tcs, tnx, tnz, tx0, tz0 } = this;  top.fill(y0);  for (let i = 0; i < this.n; i++) {    if (!alive[i]) continue;    const k = i * 3;    if (P[k + 1] < onlyAbove || V[k] * V[k] + V[k + 1] * V[k + 1] + V[k + 2] * V[k + 2] > 1) continue;    const a = ((P[k] - tx0) / tcs) | 0, b = ((P[k + 2] - tz0) / tcs) | 0;    if (a < 0 || b < 0 || a >= tnx || b >= tnz) continue;    const c = a + tnx * b, h = P[k + 1] + R[i];    if (h > top[c]) top[c] = h;  }}/** 山の形: 中心からの距離ごとに高さをならし、高さが 25〜80 % の所に直線を当てはめた傾きを、斜面の角度とする */pileAngle() {  this.surface();  const { top, tcs, tnx, tnz, tx0, tz0, bin, binN, m } = this, y0 = this.floorY;  let hm = 0;  for (let i = 0; i < top.length; i++) if (top[i] - y0 > hm) hm = top[i] - y0;  m.top = hm; m.angle = NaN;  if (hm < 0.5) return;  let sw = 0, cx = 0, cz = 0;  for (let b = 0; b < tnz; b++) for (let a = 0; a < tnx; a++) { const h = top[a + tnx * b] - y0; if (h > 0.8 * hm) { sw += h; cx += h * (tx0 + (a + 0.5) * tcs); cz += h * (tz0 + (b + 0.5) * tcs); } }  cx /= sw; cz /= sw; m.cx = cx; m.cz = cz;  bin.fill(0); binN.fill(0);  for (let b = 0; b < tnz; b++) for (let a = 0; a < tnx; a++) {    const r = Math.hypot(tx0 + (a + 0.5) * tcs - cx, tz0 + (b + 0.5) * tcs - cz), k = (r / tcs) | 0;    if (k < NBIN) { bin[k] += top[a + tnx * b] - y0; binN[k]++; }  }  let peak = 0;  for (let k = 0; k < 6; k++) if (binN[k]) { const v = bin[k] / binN[k]; if (v > peak) peak = v; }  this.fit(0, NBIN, 0.25 * peak, 0.8 * peak, (k) => (k + 0.5) * tcs, -1);}/** 区切りごとの高さに直線を当てはめる。m.x1〜x2 に両端の位置、m.y1・y2 に高さを入れ、傾きを返す */private fit(k0: number, k1: number, lo: number, hi: number, pos: (k: number) => number, sign: number) {  const { bin, binN, m } = this;  let sx = 0, sy = 0, sxx = 0, sxy = 0, c = 0, a = NaN, b = NaN;  for (let k = k0; k < k1; k++) {    if (!binN[k]) continue;    const v = bin[k] / binN[k], x = pos(k);    if (v < lo) break;    if (v > hi) continue;    if (c === 0) a = x;    b = x; sx += x; sy += v; sxx += x * x; sxy += x * v; c++;  }  if (c < 3) { m.angle = NaN; return 0; }  const sl = (c * sxy - sx * sy) / (c * sxx - sx * sx), y0 = (sy - sl * sx) / c;  m.angle = sign * Math.atan(sl) / DEG; m.x1 = a; m.y1 = y0 + sl * a; m.x2 = b; m.y2 = y0 + sl * b;  return sl;}/** 水路の形: 長さの向きに高さをならす。場面「壁を抜いて崩す」で使う */private channelProfile() {  this.surface();  const { top, tcs, tnx, tnz, tx0, tz0, bin, binN, m } = this, y0 = this.floorY, W = this.WZ;  bin.fill(0); binN.fill(0);  for (let b = 0; b < tnz; b++) {    const z = tz0 + (b + 0.5) * tcs;    if (Math.abs(z) > W) continue;    for (let a = 0; a < tnx && a < NBIN; a++) { bin[a] += top[a + tnx * b] - y0; binN[a]++; }  }  let peak = 0, kp = 0, front = -this.L0;  for (let k = 0; k < tnx && k < NBIN; k++) if (binN[k]) {    const v = bin[k] / binN[k];    if (v > peak) { peak = v; kp = k; }    if (v > 0.1) front = tx0 + (k + 1) * tcs;  }  m.top = peak; m.front = front;  this.fit(kp, Math.min(tnx, NBIN), 0.2 * peak, 0.8 * peak, (k) => tx0 + (k + 0.5) * tcs, -1);}/** 容器の中の形: 容器の座標で直線を当てはめ、容器の傾きを足して水平からの角度に直す */trayProfile() {  this.surface();  const { top, tcs, tnx, tnz, tx0, tz0, bin, binN, m } = this, y0 = this.floorY, W = this.WZ, L = this.LX;  bin.fill(0); binN.fill(0);  let hm = 0;  for (let b = 0; b < tnz; b++) {    const z = tz0 + (b + 0.5) * tcs;    if (Math.abs(z) > W) continue;    for (let a = 0; a < tnx && a < NBIN; a++) { const v = top[a + tnx * b] - y0; bin[a] += v; binN[a]++; if (v > hm) hm = v; }  }  m.top = hm;  // 両端の壁のそばを除いた中ほどだけを見る  const k0 = Math.ceil((-L + 0.15 * 2 * L - tx0) / tcs), k1 = Math.floor((L - 0.15 * 2 * L - tx0) / tcs);  const sl = this.fit(k0, k1, -1, 1e9, (k) => tx0 + (k + 0.5) * tcs, 1);  if (Number.isFinite(m.angle)) m.angle = this.phi / DEG - Math.atan(sl) / DEG;}private siloMeasure() {  const { P, IM, alive, m } = this;  let mass = 0;  for (let i = 0; i < this.n; i++) if (alive[i] && P[i * 3 + 1] > -0.1) mass += 1 / IM[i];  this.above = mass;  // 2 秒前からの減り方で流量を求める  const L = this.aboveLog, N = L.length;  L[this.logN % N] = mass; this.logN++;  const old = this.logN >= N ? L[this.logN % N] : L[0], span = (Math.min(this.logN, N) - 1) * 0.25;  m.flow = !this.plugged && span > 0 ? Math.max(0, old - mass) / span : 0;  this.surface(this.plateY, this.plateY);  const { top, tcs, tnx, tnz, tx0, tz0 } = this, Rs = this.siloR * 0.8;  let sum = 0, c = 0;  for (let b = 0; b < tnz; b++) for (let a = 0; a < tnx; a++) { const x = tx0 + (a + 0.5) * tcs, z = tz0 + (b + 0.5) * tcs; if (x * x + z * z < Rs * Rs) { sum += top[a + tnx * b] - this.plateY; c++; } }  m.fill = c ? sum / c : 0;  if (!this.plugged && !this.jam && !this.done && this.opened >= 0 && this.t > this.opened + 3 && m.flow < 5 && this.moving < 0.02) {    // 砂の高さが、穴の縁から筒の壁まで斜面で積もった形より高ければ、粒が橋を組んで詰まった。低ければ、流れ終わって穴のまわりに山が残っただけ    if (m.fill > 0.8 * (this.siloR - this.holeR)) { this.jam = true; this.msg = "穴の上で粒が橋を組み、流れが止まりました"; }    else { this.done = true; this.msg = "流れ終わりました。穴のまわりの砂は、斜面の形で残ります"; }  }}/** 0.25 秒ごとに呼ぶ */measure() {  const m = this.m;  if (this.sleeping && this.measured) return;  this.measured = this.sleeping;  if (this.key === "pour" || this.key === "wet" || this.key === "chains") {    this.pileAngle();    if (this.pouring) { const h = this.topAt(this.sx, this.sz); this.sy = Math.max(0.9, h + 0.6); }  } else if (this.key === "slump") this.channelProfile();  else if (this.key === "tilt") this.trayProfile();  else this.siloMeasure();  m.over = this.overMax;}/** ベバルー則: 穴から流れる質量 W = C ρ_b √g (D − k d)^(5/2) kg/s。C = 0.58・k = 1.5 */beverloo(D = this.holeD) { const x = D - 1.5 * D0; return x > 0 ? 0.58 * RHO_B * Math.sqrt(G) * x ** 2.5 : 0; }

20 cm四方のますごとに、止まっている粒のてっぺんの高さを持つ。山なら、いちばん高い所からの距離ごとに高さをならし、高さが25〜80%の範囲に直線を当てはめ、その傾きを角度とする。てっぺんの丸みと、ふもとに散らばった粒を外すためである。画面に引く線も、この直線そのものだ。

箱を傾ける場面では、重力の向きのほうを回している。箱の中の座標で計算すれば壁も床も軸に沿ったままなので、当たりの計算を変えずに済む。測った傾きに箱の傾きを足し引きして、水平からの角度に直している。

three.jsで描く

three.jsで描く(src/sims/sand-pile.ts の 660〜823 行)
const SP_V2 = 0.5 * 0.5;         // この速さ(m/s)で粒をいちばん濃く描くconst MAX_RODS = 1400;interface S {  v: View3; sim: Sand; step: ReturnType<typeof stepper>; key: SceneKey; group: THREE.Group; deco: THREE.Group; world: THREE.Group;  grains: THREE.InstancedMesh; rods: THREE.InstancedMesh; spout: THREE.Mesh; tool: THREE.Mesh; dam: THREE.Mesh | null; plateMesh: THREE.Mesh | null;  grab: "" | "spout" | "dig" | "view" | "tilt" | "dam"; px0: number; py0: number; phi0: number; moved: boolean;  tNext: number; hist: Float32Array; histN: number;}const CUT = new THREE.Vector3(), CAM = new THREE.Vector3();const isPile = (k: SceneKey) => k === "pour" || k === "wet" || k === "chains"; function makeGrains(s: { v: View3; group: THREE.Group }, cap: number) {  // 三角形 80 個の球(各面を 4 つに分けた正二十面体)。濃さは粒ごとに変えるので、材質は濃さ 1 にして、粒の色で掛ける  const mesh = new THREE.InstancedMesh(new THREE.IcosahedronGeometry(1, 1), s.v.mat(1), cap);  mesh.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(cap * 3), 3);  mesh.instanceColor.setUsage(THREE.DynamicDrawUsage);  mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);  const A = mesh.instanceMatrix.array as Float32Array;  A.fill(0);  for (let i = 0; i < cap; i++) A[i * 16 + 15] = 1;  mesh.frustumCulled = false; mesh.castShadow = true; mesh.receiveShadow = true; mesh.count = 0;  s.group.add(mesh);  return mesh;}function makeRods(s: { v: View3; group: THREE.Group }) {  // 力の鎖の棒。粒の奥に隠れないよう、奥行きを無視して最後に描く  const m = s.v.flat(1); m.depthTest = false; m.depthWrite = false;  const mesh = new THREE.InstancedMesh(new THREE.CylinderGeometry(1, 1, 1, 5, 1, true), m, MAX_RODS);  mesh.frustumCulled = false; mesh.renderOrder = 5; mesh.count = 0; mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);  s.group.add(mesh);  return mesh;}/** 粒の位置と濃さを、描画用の配列へ写す(毎コマ。部品は作り直さない) */function syncGrains(api: Api, s: S) {  const sim = s.sim, A = s.grains.instanceMatrix.array as Float32Array, C = s.grains.instanceColor!.array as Float32Array;  const { P, V, R, alive, fixed } = sim, chain = api.bool("chain");  // 断面の面: 注目点を通り、カメラの水平の向きを向いた面。手前の粒を消す  let cx = 0, cz = 0, nx = 0, nz = 0;  if (chain) {    s.group.updateWorldMatrix(true, false);    CAM.copy(s.v.camera.position); s.group.worldToLocal(CAM);    CUT.set(isPile(s.key) ? sim.m.cx : s.key === "slump" ? 2 : 0, 0, isPile(s.key) ? sim.m.cz : 0);    nx = CAM.x - CUT.x; nz = CAM.z - CUT.z;    const l = Math.hypot(nx, nz) || 1; nx /= l; nz /= l; cx = CUT.x; cz = CUT.z;  }  const rest = chain ? 0.3 : 0.8;  for (let i = 0; i < sim.n; i++) {    const o = i * 16, k = i * 3;    if (!alive[i]) { A[o] = 0; A[o + 5] = 0; A[o + 10] = 0; continue; }    const x = P[k], y = P[k + 1], z = P[k + 2];    let r = R[i];    if (chain && (x - cx) * nx + (z - cz) * nz > 0) r = 0;    A[o] = r; A[o + 5] = r; A[o + 10] = r; A[o + 12] = x; A[o + 13] = y; A[o + 14] = z;    const v2 = V[k] * V[k] + V[k + 1] * V[k + 1] + V[k + 2] * V[k + 2];    // 止まっている粒は薄く、流れている粒は濃く。床に固定した粒はさらに薄く    const t = fixed[i] ? rest * 0.7 : v2 >= SP_V2 ? 1.6 : rest + (1.6 - rest) * v2 / SP_V2;    C[k] = t; C[k + 1] = t; C[k + 2] = t;  }  s.grains.count = sim.n;  s.grains.instanceMatrix.needsUpdate = true; s.grains.instanceColor!.needsUpdate = true;  // 力の鎖: 断面のすぐ奥(粒 3 個分)の、平均の 1.6 倍より大きな力の接触を棒で描く  let nr = 0;  if (chain) {    const R2 = s.rods.instanceMatrix.array as Float32Array, { PA, PB, FC, np } = sim, slab = 3 * D0;    let sum = 0, c = 0;    for (let p = 0; p < np; p++) if (FC[p] > 0) { sum += FC[p]; c++; }    const mean = c ? sum / c : 1;    for (let p = 0; p < np && nr < MAX_RODS; p++) {      const f = FC[p];      if (f < mean * 1.6) continue;      const a = PA[p] * 3, b = PB[p] * 3, ax = P[a], ay = P[a + 1], az = P[a + 2], bx = P[b], by = P[b + 1], bz = P[b + 2];      const dep = ((ax + bx) / 2 - cx) * nx + ((az + bz) / 2 - cz) * nz;      if (dep > 0 || dep < -slab) continue;      let dx = bx - ax, dy = by - ay, dz = bz - az;      const len = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1; dx /= len; dy /= len; dz /= len;      // 棒の向きの行列: y 軸を接触の向きに、x・z 軸をそれと直角に。太さは力に比例させる      let ux = dy, uy = -dx, uz = 0;      const ul = Math.hypot(ux, uy) || 1; if (Math.hypot(ux, uy) < 1e-3) { ux = 1; uy = 0; } else { ux /= ul; uy /= ul; }      const vx = dy * uz - dz * uy, vy = dz * ux - dx * uz, vz = dx * uy - dy * ux;      const w = Math.min(0.05, 0.0065 * f / mean), o = nr * 16;      R2[o] = ux * w; R2[o + 1] = uy * w; R2[o + 2] = uz * w; R2[o + 3] = 0;      R2[o + 4] = dx * len; R2[o + 5] = dy * len; R2[o + 6] = dz * len; R2[o + 7] = 0;      R2[o + 8] = vx * w; R2[o + 9] = vy * w; R2[o + 10] = vz * w; R2[o + 11] = 0;      R2[o + 12] = (ax + bx) / 2; R2[o + 13] = (ay + by) / 2; R2[o + 14] = (az + bz) / 2; R2[o + 15] = 1;      nr++;    }  }  s.rods.count = nr;  s.rods.instanceMatrix.needsUpdate = true;} const lines = (v: View3, pts: number[]) => { const g = new THREE.BufferGeometry(); g.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3)); return new THREE.LineSegments(g, v.lineMat(1)); };/** 長方形の枠の線 */const rect = (x0: number, x1: number, y0: number, y1: number, z0: number, z1: number) => x0 === x1  ? [x0, y0, z0, x0, y1, z0, x0, y1, z0, x0, y1, z1, x0, y1, z1, x0, y0, z1, x0, y0, z1, x0, y0, z0]  : z0 === z1 ? [x0, y0, z0, x1, y0, z0, x1, y0, z0, x1, y1, z0, x1, y1, z0, x0, y1, z0, x0, y1, z0, x0, y0, z0]  : [x0, y0, z0, x1, y0, z0, x1, y0, z0, x1, y0, z1, x1, y0, z1, x0, y0, z1, x0, y0, z1, x0, y0, z0];const circle = (R: number, y: number, n = 48) => { const p: number[] = []; for (let i = 0; i < n; i++) { const a = i / n * Math.PI * 2, b = (i + 1) / n * Math.PI * 2; p.push(R * Math.cos(a), y, R * Math.sin(a), R * Math.cos(b), y, R * Math.sin(b)); } return p; };function plane(v: View3, x0: number, x1: number, z0: number, z1: number, y: number, tone: number) {  const g = new THREE.PlaneGeometry(x1 - x0, z1 - z0); g.rotateX(-Math.PI / 2);  const m = new THREE.Mesh(g, v.mat(tone, { double: true }));  m.position.set((x0 + x1) / 2, y, (z0 + z1) / 2); m.receiveShadow = true;  return m;}function clearDeco(s: S) {  for (const gr of [s.deco, s.world]) for (const o of [...gr.children]) {    gr.remove(o);    const m = o as THREE.Mesh; m.geometry?.dispose?.();    (Array.isArray(m.material) ? m.material : [m.material]).forEach((x) => x?.dispose?.());  }  for (const o of [s.dam, s.plateMesh]) if (o) { o.parent?.remove(o); o.geometry.dispose(); (o.material as THREE.Material).dispose(); }  s.dam = null; s.plateMesh = null;}function plateRing(s: S) {  if (s.plateMesh) { s.plateMesh.parent?.remove(s.plateMesh); s.plateMesh.geometry.dispose(); (s.plateMesh.material as THREE.Material).dispose(); }  const sim = s.sim, g = new THREE.RingGeometry(sim.holeD / 2, sim.siloR + 0.04, 48, 1); g.rotateX(-Math.PI / 2);  const m = new THREE.Mesh(g, s.v.mat(0.45, { double: true })); m.receiveShadow = true; m.castShadow = true;  s.plateMesh = m; s.group.add(m);}/** 場面の容器と視点を作る */function build(api: Api, s: S, key: SceneKey) {  const v = s.v, sim = s.sim, N = Math.round(BASE[api.quality] * api.num("amount") / 100), sc = Math.cbrt(N / 3000);  clearDeco(s);  s.key = key; s.tNext = 0; s.histN = 0; s.hist.fill(0); s.grab = "";  sim.mu = api.num("mu"); sim.mur = api.num("mur"); sim.setCoh(api.num("coh"));  sim.setup(key, N, api.num("hole"), api.reduced);  s.group.rotation.set(0, 0, 0);  s.spout.visible = isPile(key); s.tool.visible = false;  v.orbit.auto = 0;  if (isPile(key)) {    s.deco.add(plane(v, -6, 6, -6, 6, 0, 0.13), lines(v, rect(-6, 6, 0.002, 0, -6, 6)));    if (key === "chains") v.orbit.set({ yaw: 0.7, pitch: 0.1, dist: 7 * sc + 1, target: [0, 0.75 * sc, 0] });    else v.orbit.set({ yaw: 0.7, pitch: 0.36, dist: 8.2 * sc + 1.2, target: [0, 0.75 * sc, 0] });    v.shadowSize(8); v.sunAt([0.55, 0.85, 0.35], new THREE.Vector3(0, 0, 0));  } else if (key === "slump") {    const L0 = sim.L0, W = sim.WZ, X1 = sim.fx1, Hh = sim.damTop;    s.deco.add(plane(v, -L0, X1, -W, W, 0, 0.13));    s.deco.add(lines(v, [...rect(-L0, X1, 0.5, 0.5, W, W), ...rect(-L0, X1, 0.5, 0.5, -W, -W), ...rect(-L0, 0, 0, Hh, W, W), ...rect(-L0, 0, 0, Hh, -W, -W), ...rect(-L0, -L0, 0, Hh, -W, W)]));    const dam = new THREE.Mesh(new THREE.BoxGeometry(0.08, Hh, 2 * W), v.mat(0.8));    dam.position.set(0.04, Hh / 2, 0); dam.castShadow = true; s.dam = dam; s.group.add(dam);    v.orbit.set({ yaw: 0.3, pitch: 0.24, dist: 12.5 * sc + 1, target: [2.0 * sc, 1.2 * sc, 0] });    v.shadowSize(11); v.sunAt([0.3, 0.85, 0.45], new THREE.Vector3(2.5, 0, 0));  } else if (key === "tilt") {    const L = sim.LX, W = sim.WZ, Hw = 3.1 * sc;    s.deco.add(plane(v, -L, L, -W, W, -0.005, 0.18));    s.deco.add(lines(v, [...rect(-L, L, 0, Hw, W, W), ...rect(-L, L, 0, Hw, -W, -W), ...rect(-L, -L, 0, Hw, -W, W), ...rect(L, L, 0, Hw, -W, W)]));    s.world.add(plane(v, -9, 9, -7, 7, -2.6 * sc, 0.07));    // 箱を支える台(回る軸は原点)    s.world.add(lines(v, [0, 0, W + 0.1, -1.1 * sc, -2.6 * sc, W + 0.1, 0, 0, W + 0.1, 1.1 * sc, -2.6 * sc, W + 0.1, 0, 0, -W - 0.1, -1.1 * sc, -2.6 * sc, -W - 0.1, 0, 0, -W - 0.1, 1.1 * sc, -2.6 * sc, -W - 0.1, 0, 0, W + 0.1, 0, 0, -W - 0.1]));    v.orbit.set({ yaw: 0.22, pitch: 0.18, dist: 12.5 * sc + 1, target: [0, 0.1 * sc, 0] });    v.shadowSize(8); v.sunAt([0.3, 0.85, 0.45], new THREE.Vector3(0, 0, 0));  } else {    const Rs = sim.siloR, y = sim.floorY, top = N * LAT_V / (Math.PI * Rs * Rs) * 0.75 + 0.3;    s.deco.add(plane(v, -6, 6, -6, 6, y, 0.13));    const pts = [...circle(Rs, 0), ...circle(Rs, top)];    for (let k = 0; k < 10; k++) { const a = k / 10 * Math.PI * 2; pts.push(Rs * Math.cos(a), 0, Rs * Math.sin(a), Rs * Math.cos(a), top, Rs * Math.sin(a)); }    // 板を支える脚    for (const [a, b] of [[1, 1], [-1, 1], [1, -1], [-1, -1]]) pts.push(a * Rs * 0.7, 0, b * Rs * 0.7, a * Rs * 0.7, y, b * Rs * 0.7);    s.deco.add(lines(v, pts));    plateRing(s);    v.orbit.set({ yaw: 0.45, pitch: 0.16, dist: 13.5 * sc + 1, target: [0, 0.3 * sc, 0] });    v.shadowSize(8); v.sunAt([0.55, 0.85, 0.35], new THREE.Vector3(0, 0, 0));  }}

球は三角形80個で、InstancedMeshという、同じ形をまとめて描く部品で並べる。毎コマ、位置と半径を行列の配列へ、濃さを色の配列へ写すだけで、部品は作り直さない。影と視点と光の当たり方は、three.jsと共通部品に任せている。

力の鎖は、平均の1.6倍を超える力の接点を細い円柱で描く。粒の奥に隠れないよう、奥行きを無視して最後に描き、断面より手前の粒は半径を0にして消している。

画質を下げると粒の数が減り、同じ場面を作り直す。「高」の3,000粒は、山が育つと1コマ(刻み4回)の計算が5〜8 msほどになる。「中」の1,500粒なら半分ほどで済む。砂時計だけは、粒が減っても筒を細くしすぎないよう、半径を粒の数の6乗根に合わせて変える。筒が穴の直径の2.5倍より細いと、流量が砂の高さで変わってしまうからである。

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

プログラム全体を見る(src/sims/sand-pile.ts・1027 行)

読み込んでいます

姉妹サイト

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