雫が水に落ちると、くぼみが閉じる勢いで柱が立つ

直径7 mmの雫が40 cmの高さから水に落ちると、0.1秒あまりの間にくぼみと王冠ができて消え、真ん中から柱が立つ。シミュレーションでは、ビー玉を落としたときにこの柱がはっきり立つ。

記事

雫が水に落ちると、くぼみが閉じる勢いで柱が立つ

直径7 mmの雫が40 cmの高さから水に落ちると、0.1秒あまりの間にくぼみと王冠ができて消え、真ん中から柱が立つ。シミュレーションでは、ビー玉を落としたときにこの柱がはっきり立つ。

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

現象

0.1秒の中の小さな噴水

蛇口から落ちた雫がコップの水に当たると、「ぽちゃん」と音がして、小さなしぶきと輪が広がる。目に残るのはそれくらいだろう。ところが水面では、その0.1秒あまりの間に、くぼみが開き、縁が王冠のように持ち上がり、くぼみが閉じた所から柱が立ち、柱の頭がちぎれて雫が飛んでいる。ただ、どれも速すぎて、目にはひとかたまりの出来事にしか見えない。

この一連の動きを写真で順に追ったのが、イギリスの物理学者アーサー・ワージントンである。1908年の本『A Study of Splashes』には、重さ0.2 g、直径7.36 mmの水の雫を40 cmの高さから落とした連続写真が載っている。受ける側の水には、形を見やすくするために牛乳を混ぜてある。触れてから約0.007秒までくぼみの縁は急に高くなり、0.056秒には縁が沈んで、真ん中のくぼみを囲む輪になった。0.064秒に真ん中から柱が立ち始め、0.116秒に最も高くなる。そのあと、柱の頭はちぎれて雫になりかけた。

では、落ちてきた水はなぜ真ん中から上へ戻ってくるのか。雫の大きさや高さ、液体を変えると、形はどう変わるのか。答えは、落ちる勢いと、表面張力・重力・粘りの3つの力のどれが勝つかにある。

シミュレーションでは、水面の上をたたくと雫が落ちる。水槽を横から見た切り口で解いているので、雫では真ん中が低く盛り上がるだけで、細い柱はビー玉を落としたときに立つ。

仕組み

勢いと3つの力の比べ合い

雫は水面にぶつかると、周りの水を押しのけて、半球のようなくぼみを作る。押しのけられた水は行き場を探すが、下も横も水で詰まっている。だから、くぼみの縁から上へ逃げる。これが王冠のように持ち上がる縁で、縁の先はすぐに細かい雫に分かれていく。

勢いが尽きると、今度は2つの力がくぼみを埋め戻す。1つは重力だ。くぼみの底は周りの水面より低いので、周りの水の重さが底を押し上げる。もう1つは表面張力である。水面は面積を小さくしようと縮むので、強く曲がった面ほど内側へ引っ張られる。丸いくぼみの壁は、周りから真ん中の軸へ向かって一斉に寄ってくる。寄ってきた水がぶつかる所は、真ん中の1か所しかない。行き場をなくした水は上へ噴き出し、細い柱になる。この柱は、写真で詳しく調べたワージントンの名をとって、ワージントンジェットと呼ばれる。

触れる直前 約0.007秒 縁が急に高くなる 0.056秒 縁が沈んで輪になる 0.064秒 柱が立ち始める 0.116秒 柱がいちばん高い 時刻はワージントンの連続写真(直径7.36 mmの雫・高さ40 cm)による。形は模式図 王冠のように持ち上がる縁 ちぎれかける頭
雫が水に落ちてから柱が最も高くなるまでの5つの形を、ワージントンの写真の時刻で並べた模式図

柱の頭がちぎれるのも、表面張力のしわざだ。細長い液体の柱がどこかで少しくびれると、くびれた所の水面は強く曲がり、表面張力が水を太い所へ押し出す。くびれはますます細り、最後は切れて雫になる。ベルギーのジョゼフ・プラトーは1873年に、柱の長さが周の長さ(直径の約3.14倍)を超えるとこの形は保てないことを示した。くびれが育つ速さは1878年にレイリー卿が計算し、最も速く育つのは、間隔が直径の約4.5倍のくびれだと分かった。

くびれた所は水面が強く曲がり、表面張力が水を太い所へ押し出す 雫の間隔 ≈ 直径の約4.5倍(レイリー、1878年) 周の長さ(直径の約3.14倍)より長い間隔のくびれが育つ(プラトー、1873年)
液体の柱は、周の長さより長い間隔のくびれが育ち、ふくらんだ所ごとに雫に分かれる

どの力が勝つかは、力の大きさそのものより、勢いとの比で決まる。比べる数は3つ。ρは液体の密度(kg/m³)、vは水面にぶつかる速さ(m/s)、Dは雫や玉の直径(m)、σは表面張力(N/m)、gは重力加速度(9.81 m/s²)、μは粘度(Pa·s)を表す。

ウェーバー数Weは勢いと表面張力の比、フルード数Frは勢いと重力の比、レイノルズ数Reは勢いと粘りの比だ。つまり、1よりずっと大きければ、その力は勢いに負けている。ぶつかる速さは、空気の抵抗を無視すれば、落とす高さHから次の式で決まる。

直径4 mmの水の雫を20 cmから落とすと、vは1.98 m/s。水のσを0.072 N/m、μを1 mPa·sとすると1、Weはおよそ220、Frは100、Reは7,900になる。3つとも1よりずっと大きい。ぶつかった直後は勢いが勝ち、くぼみと王冠が広がっていく。くぼみが深くなるにつれて水の速さは落ち、表面張力と重力が勢いに追いついた所で、くぼみは最も深くなる。そこから先が、埋め戻しと柱の時間だ。

液体を変えると、比べ合いの結果が変わる。はちみつは粘りが強く、Reが1桁まで下がるので、粘りが勢いに勝って王冠も柱も立たない。石けん水では逆に表面張力が水の半分以下になる。Weが2倍以上になるので、縁は薄く伸びて細かい雫に分かれやすい。

シミュレーションは、水を1,200〜3,000個の粒の集まりとして解いている。粒どうしの押し合い(圧力)、水面にだけ働く表面張力、粘り、重力から、1回に数十〜数百マイクロ秒ずつ動きを進める。網点が濃い所ほど、水が速く動いている。

試す

落として確かめる

  1. 直径4 mmの水の雫を20 cmから落とす。シミュレーションでは、触れて数ミリ秒でくぼみが開き、縁が王冠のように持ち上がって、先から小さな雫がちぎれる。くぼみは30ミリ秒ほどで最も深くなり、0.1秒ほどで周りから埋まって、真ん中が盛り上がる。もっとも、切り口で解いているため、柱は本物ほど細く高くならず、低く太い盛り上がりにとどまる。
  2. 直径16 mmのビー玉を30 cmから落とす。玉は細長いくぼみを引きずって沈む。くぼみの壁は周りの水に押され、玉に近い深い所から閉じていく。残ったV字のくぼみの左右の壁が真ん中でぶつかると、雫のときとは違って細い柱が噴き出し、柱の先がちぎれて雫が飛ぶ。「止める」を押して「1 コマ送る」で0.5 msずつ進めると、くぼみが閉じる瞬間を追える。
  3. 直径5 mmの雫を1 mの高さから落とす。ぶつかる速さは4.4 m/sで、Weはおよそ1,400になる。勢いが表面張力に大きく勝つので、王冠は雫の直径の4倍ほどの高さまで伸び、細くなった縁から雫がいくつもちぎれて飛ぶ。
  4. はちみつに雫を落とす。Reはおよそ3しかなく、粘りが勢いに勝つ。縁はわずかに盛り上がるだけで王冠にならず、浅いくぼみがゆっくり埋まっていく。
  5. 石けん水に雫を落とす。表面張力が水の半分以下なので、同じ高さでもWeは水の2.4倍(およそ520)になる。縁を引き戻す力が弱いぶん、王冠の縁は水のときより細かく枝分かれする。ただ、粒の粗いこのシミュレーションでは、ちぎれる雫の数に水との違いはほとんど出ない。
  6. 直径20 mmの木の玉を20 cmから落とす。木は水より軽いけれど、ぶつかる勢いで一度は水の中までもぐる。そのあと浮力に押し戻されて浮き上がり、上下に揺れながら、半分あまり沈んだ所に落ち着いていく。
現実では

火花と閃光が写したもの

ワージントンが水しぶきの写真に成功したのは1894年で、友人のR・S・コールの助けを借りた。どんなに速いシャッターでも水しぶきには遅すぎるため、電気の火花を一瞬だけ光らせて写した。火花が光る時間は100万分の3秒より短い。本には「1秒に対するこの時間の比は、1,000年に対する1日の比とほぼ同じ」とある。2人の結果は1897年と1900年に英国王立協会の論文になり、1908年の本では197枚の写真が一般の読者向けにまとめられた。

同じ本には、ビー玉を使った簡単な実験が出てくる。乾いたハンカチでよく磨いた石のビー玉を30 cmほどの高さから水に落とすと、ほとんどしぶきを上げずに静かに沈む。ところが、紙やすりで表面を荒らした玉や、一度水に入れてぬれたままの玉を落とすと、泡の音とともに、高い柱が上がるのだ。ワージントンが水の中を写すと、荒れた玉は空気の袋を引きずって沈み、細長い袋は途中でくびれて分かれ、玉のすぐ後ろに泡が残っていた。水の柱がくびれて雫になるのと同じ不安定さが、水の中の空気の柱にも働いていたのである。

表面の状態でしぶきが変わる理由が、ぬれやすさで説明されたのは、およそ100年後のことだ。フランスのデュエズらは2007年、表面のぬれやすさだけが違うガラス玉を、同じ秒速5 mで水に落とした。水をはじく玉(接触角およそ100°)は大きな空気のくぼみを作り、水になじむ玉(接触角およそ15°)は小さな「ぽちゃっ」という音を立てただけだった。くぼみができる速さの境目は、玉の接触角と、液体の表面張力と粘度の比で決まる。水を非常に強くはじく玉は、どんな速さでも大きなしぶきを上げる。

火花の代わりにストロボの閃光を使ったのが、アメリカのマサチューセッツ工科大学で電気工学を教えていたハロルド・エジャートンだ。1957年1月10日に撮った「ミルク・ドロップ・コロネット」は、ミルクの雫が作った王冠の縁から小さな雫が並んでちぎれる瞬間をとらえ、今もマサチューセッツ工科大学博物館に記録が残る。縁がほぼ等しい間隔で雫に分かれるのは、柱の頭がちぎれるのと同じ表面張力の不安定さが、縁にできた太い輪にも働くためと考えられている。

省いていること

この切り口が省いていること

  • 横から見た切り口、つまり2次元で解いている。本物の丸いくぼみでは壁が真ん中の軸へ集まるが、切り口では左右の2つの壁が寄るだけなので、雫を落としたときの柱は本物より太く低い。ビー玉で柱が立つのは、深いくぼみの左右の壁が真正面からぶつかるからだ。
  • 液体の柱が細る本物のレイリー・プラトー不安定は、柱の周りの曲がりが無い切り口では起きない。シミュレーションで柱や縁がちぎれるのは、引き伸ばされて粒1〜2個の細さになったときで、仕組みが違う。
  • 水を1,200〜3,000個の粒で表し、雫の直径は粒4〜7個ぶんしかない。細かいしぶきや薄い膜は表せない。計算を軽くするため、水の音速を本物の1,500 m/sからおよそ9〜20 m/sに下げてあり、ぶつかった所では水が数%縮む。安定のための人工の粘りを足したので、水の流れは本当のレイノルズ数より粘っこい。水面の粒の細かい揺れも抑えているので、小さな波や雫の揺れは本物より早く収まる。
  • 空気は計算していない。閉じたくぼみに泡は残らないし、ビー玉は表面のぬれに関係なく、いつもくぼみを作る。本物のよく磨いた玉のように、静かに沈むことはない。
  • ビー玉と木の玉は、奥行きの向きに長い円柱として扱い、回転と摩擦は省いた。はちみつの粘りは温度で大きく変わる2ため、6 Pa·sを仮の値にした。粘りが強すぎる分は計算の上限で頭打ちになり、本物より流れやすい。
参考文献

参考文献

  1. A. M. Worthington「A Study of Splashes」Longmans, Green, and Co., 1908. https://www.gutenberg.org/ebooks/39831
  2. A. M. Worthington, R. S. Cole「Impact with a liquid surface, studied by the aid of instantaneous photography」『Philosophical Transactions of the Royal Society of London. Series A』189, 137–148, 1897(Paper IIは194, 175–199, 1900).
  3. Lord Rayleigh「On the instability of jets」『Proceedings of the London Mathematical Society』s1-10(1), 4–13, 1878.
  4. C. Duez, C. Ybert, C. Clanet, L. Bocquet「Making a splash with water repellency」『Nature Physics』3(3), 180–183, 2007. https://arxiv.org/abs/cond-mat/0701093
  5. MIT Museum「Milk Drop Coronet(HEE-NC-57001)」. https://mitmuseum.mit.edu/collections/object/HEE-NC-57001
  6. IAPWS「Revised Release on the Surface Tension of Ordinary Water Substance」R1-76(2014)、「Release on the IAPWS Formulation 2008 for the Viscosity of Ordinary Water Substance」R12-08. https://iapws.org/technical-guidance/release
  7. D. M. Oliveiraほか「Physicochemical and Rheological Evaluation of Cassava Flower Honey Produced by Africanized Apis mellifera」『Food Science and Technology Research』21(1), 23–29, 2015. https://www.jstage.jst.go.jp/article/fstr/21/1/21_23/_article/-char/ja
  8. J. J. Monaghan「Simulating Free Surface Flows with SPH」『Journal of Computational Physics』110(2), 399–406, 1994.

注

  1. 国際水・蒸気性質協会(IAPWS)の式では、20 ℃の水の表面張力は72.74 mN/m。粘度は20 ℃でおよそ1.0 mPa·s、25 ℃で0.89 mPa·sである。 ↩

  2. Oliveiraらが測ったはちみつの粘度の活性化エネルギー69.35 kJ/molをアレニウスの式に入れると、20 ℃から30 ℃に温まるだけで粘度はおよそ2.6分の1になる。 ↩

プログラム
プログラム

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

このシミュレーションは、水槽の水を千数百〜3,000個の粒に分け、1回に数十〜数百マイクロ秒ずつ「近くの粒を探す → 密度と圧力を求める → 力を足す → 速さと位置を進める」をくり返している。物を落とすと粒が押しのけられてくぼみができ、表面張力と重力で埋め戻される様子が、そのまま粒の動きとして出てくる。画面に出すときは、粒を格子にぼかして水面の輪郭を引き、速く動く所ほど濃い網点で塗る。

液体と物の値

液体ごとに、密度ρ(kg/m³)、表面張力σ(N/m)、粘度μ(Pa·s)を本物の値で持っている。落とす物は密度と、水槽の深さと空気の高さを、物の直径の何倍にするかを持つ。

定数と液体(src/sims/drop-splash.ts の 37〜65 行)
const G = 9.81; // 重力加速度 m/s²const HS = 2.0; // 影響半径 h ÷ 粒の間隔 s0const SKIN = 0.25; // 近くの粒の表に入れる余白(h の 25%)。粒がその半分動いたら表を作り直すconst NPART = [1200, 2000, 3000]; // 画質ごとの水槽の粒の数const BUDGET = [4, 5, 6]; // 1 コマで物理の計算に使う時間の上限(ms)。超えた分は、さらにゆっくり見せるconst CELL = [4.6, 3.8, 3.2]; // 水面を描く格子の 1 マス(api.u の長さ)const FRAME_STEP = 0.0005; // 「1 コマ送る」で進める時間(0.5 ms = 毎秒 2,000 コマの高速度カメラ)const MACH = 3.5; // 音速 ÷ 基準の速さconst GAMMA = 7; // Tait の式の指数const CFL = 0.45; // 1 刻みで音が進む長さ ÷ hconst ALPHA = 0.01; // 人工の粘りの強さconst TN = 32; // 壁の表の刻みconst SURF = 0.1; // 表面の粒の細かい揺れを周りにならす割合(1 刻みあたり) interface Liquid { label: string; rho: number; sigma: number; mu: number }/** 液体の値: 密度 ρ(kg/m³)・表面張力 σ(N/m)・粘度 μ(Pa·s) */const LIQ: Record<string, Liquid> = {  water: { label: "水", rho: 1000, sigma: 0.072, mu: 1.0e-3 },  soap: { label: "石けん水", rho: 1000, sigma: 0.03, mu: 1.0e-3 },  oil: { label: "油", rho: 920, sigma: 0.033, mu: 0.06 },  honey: { label: "はちみつ", rho: 1400, sigma: 0.05, mu: 6 },};interface Kind { label: string; rho: number; depth: number; air: number }/** 落とす物: 密度(0 は液体の雫)・水槽の深さと空気の高さ(直径の何倍か) */const KIND: Record<string, Kind> = {  drop: { label: "雫", rho: 0, depth: 4, air: 4.4 },  marble: { label: "ビー玉", rho: 2500, depth: 6, air: 2.6 },  wood: { label: "木の玉", rho: 600, depth: 4, air: 4.4 },};

MACHとCFLは計算を軽くするための値である。本物の水の中を音は秒速1,500 mで伝わるが、その速さに合わせて刻むと、1コマに数万回の計算が要る。そこで音の速さを、ぶつかる速さの4倍あまりまで下げている(ぶつかる速さの1.25倍を基準の速さとし、そのMACH倍にする)。水はわずかに縮むが、縮みは数%で済む。BUDGETは1コマで物理に使ってよい時間(ミリ秒)で、これを超えた分はさらにゆっくり見せる。

水槽の大きさと粒の並べ方

水槽は、落とす物の直径Dに比例した大きさにする。描画面の縦横の比に合わせて幅を決めるので、横長の画面でも縦長のスマホでも、水槽が画面いっぱいに収まる。粒は六方格子(蜂の巣の形)に並べ、間隔s0は画質ごとの粒の数から決まる。

水槽と粒(src/sims/drop-splash.ts の 103〜196 行)
function pads(api: Api): [number, number, number, number] { return [api.u(44), api.u(12), api.u(32), api.u(12)]; }/** 描画面のうち水槽に使える部分の 縦 ÷ 横 */function aspectOf(api: Api) { const p = pads(api); return Math.max(80, api.h - p[0] - p[2]) / Math.max(100, api.w - p[1] - p[3]); } /** 六方格子の点(原点のまわり・半径 h の中)で関数を足し合わせる */function latticeSum(s0: number, h: number, f: (r2: number) => void) {  const dy = (Math.sqrt(3) / 2) * s0, nr = Math.ceil(h / dy) + 1, nc = Math.ceil(h / s0) + 2;  for (let r = -nr; r <= nr; r++) for (let c = -nc; c <= nc; c++) {    const x = c * s0 + (r & 1 ? s0 / 2 : 0), y = r * dy, r2 = x * x + y * y;    if (r2 < h * h) f(r2);  }} /** 水槽を作り直す: 大きさを決め、粒を六方格子に並べ、核の定数と壁の表を用意して、静かになるまで進める */function setup(api: Api, s: S) {  const kind = KIND[api.str("obj")] ? api.str("obj") : "drop", liqKey = LIQ[api.str("liq")] ? api.str("liq") : "water";  const K = KIND[kind], L = LIQ[liqKey], D = clamp(api.num("D") || 4, 1, 40) / 1000, q = api.quality;  const asp = aspectOf(api), depth = K.depth * D;  const W = clamp((depth + K.air * D) / asp, 8 * D, 18 * D);  const Ht = Math.max(W * asp, depth + 1.6 * D);  const s0 = Math.sqrt((W * depth) / (NPART[q] * (Math.sqrt(3) / 2)));  const h = HS * s0, h2 = h * h, n = 2 / (Math.sqrt(3) * s0 * s0);  Object.assign(s, { kind, liqKey, D, q, asp, wantAsp: asp, liq: L, W, Ht, depth, Ymax: Ht * 3, s0, h, h2, hl: h * (1 + SKIN), n, mp: L.rho / n, rp: 0.5 * s0, ap: 1 / n });  // 核: Wendland C2(W = 7/πh² (1−q)⁴(1+4q)、q = r/h)。密度と圧力の勾配に同じ核を使うと、圧力の力がエネルギーを保つ。  // 静止の格子の和 S0 で割り、静止の密度を 1 にそろえる  const Kw = 7 / (Math.PI * h2), Kg = 140 / (Math.PI * h ** 4), wq = (qq: number) => (1 - qq) ** 4 * (1 + 4 * qq);  let S0 = 0; latticeSum(s0, h, (r2) => { S0 += Kw * wq(Math.sqrt(r2) / h); });  s.Kw = Kw / S0; s.Kg = Kg / S0; s.Wself = Kw / S0; s.nS = n / S0;  // 表面張力(CSF): 色の場の勾配 ∇c = Σ V ∇W と、核の和 Σ V W の係数(V = 1/n は粒 1 個の面積)  s.Kn = Kg / n; s.Kv = Kw / n;  // 粘り(Morris の式の重み 2 V |W'| r / (r² + η²)。V = 1/n は粒 1 個の面積。|W'| = 140/πh³ q(1−q)³)  s.Wv = (2 * 140) / (Math.PI * h ** 3 * n); s.eta2 = 0.01 * h2; s.nu = L.mu / L.rho;  // 壁の向こうに足りない分の表(連続体の積分): 密度と、圧力の勾配。壁からの距離 0〜h を TN 等分  s.wallW = new Float64Array(TN + 1); s.wallG = new Float64Array(TN + 1);  const M = 40;  for (let k = 0; k <= TN; k++) {    const d = (k / TN) * h, dA = ((h - d) / M) * (h / M);    let w = 0, g = 0;    for (let iy = 0; iy < M; iy++) for (let ix = 0; ix < 2 * M; ix++) {      const yy = d + ((iy + 0.5) / M) * (h - d), xx = -h + ((ix + 0.5) / M) * h, r = Math.hypot(xx, yy);      if (r >= h || r <= 0) continue;      const qq = r / h;      w += Kw * wq(qq); g += Kg * h * qq * (1 - qq) ** 3 * (yy / r);    }    s.wallW[k] = w * dA * (n / S0); s.wallG[k] = g * dA * (n / S0);  }  // 壁が押し返す力は、壁の分の密度 Wd(δ) の傾き −dWd/dδ × p̃/ρ̃²(内部エネルギーの傾きそのもの。エネルギーが保たれる)  s.wallF = new Float64Array(TN + 1);  for (let k = 0; k <= TN; k++) { const a = Math.max(0, k - 1), b = Math.min(TN, k + 1); s.wallF[k] = -(s.wallW[b] - s.wallW[a]) / (((b - a) / TN) * h); }  // 表面の粒とみなす |∇c| の下限(平らな水面のちょうど上の粒の 15%)  s.nThr = (0.15 * s.wallG[0]) / s.nS;  // 粒を六方格子に並べる  const dy = (Math.sqrt(3) / 2) * s0, rows = Math.floor((depth - s.rp) / dy) + 1;  s.cap = Math.ceil(rows * (W / s0 + 1) * 1.12) + 400;  s.P = parts(s.cap); s.Q = parts(s.cap);  let N = 0;  for (let r = 0; r < rows; r++) {    const yy = s.rp + r * dy;    for (let xx = s.rp + (r & 1 ? s0 / 2 : 0); xx <= W - s.rp + 1e-12; xx += s0) { s.P.x[N] = xx; s.P.y[N] = yy; N++; }  }  s.N = N; s.N0 = N;  const C = s.cap;  s.rx = new Float64Array(C); s.ry = new Float64Array(C);  s.rho = new Float64Array(C); s.pr = new Float64Array(C); s.ax = new Float64Array(C); s.ay = new Float64Array(C);  s.sw = new Float64Array(C); s.swx = new Float64Array(C); s.swy = new Float64Array(C);  s.cx = new Float64Array(C); s.cy = new Float64Array(C); s.ux = new Float64Array(C); s.uy = new Float64Array(C); s.ok = new Uint8Array(C); s.div = new Float64Array(C); s.wv = new Float64Array(C);  s.gc = Math.ceil(W / s.hl); s.gr = Math.ceil(s.Ymax / s.hl);  const nc = s.gc * s.gr;  s.cs = new Int32Array(nc + 1); s.cur = new Int32Array(nc); s.cellOf = new Int32Array(C); s.order = new Int32Array(C);  s.ps = new Int32Array(C + 1); s.pb = new Int32Array(C * 14); s.np = 0; s.needList = true;  s.bodies = []; s.ev = null; s.tagN = 0; s.time = 0; s.fresh = true;  s.stepReq = 0; s.realAcc = 0; s.simAcc = 0; s.eff = 0; s.rebuildAt = 0; s.grab = null;  s.aim = { press: false, moved: false, x: W / 2, H: api.num("H") / 100, H0: api.num("H") / 100 };  s.stir = { on: false, x: 0, y: 0, vx: 0, vy: 0 };  s.look = { depth: 0, cavY: 0, seal: false, jet: 0, jetY: 0, narrow: 0, rim: 0, rimX: 0, rimY: 0, drops: 0, topX: 0, topY: 0 };  setDt(s, Math.sqrt(2 * G * Math.max(0.02, api.num("H") / 100)));  // 重さで少し縮むまで、揺れを止めながら進める  const settle = Math.ceil((3 * depth) / (s.c * s.dt));  for (let k = 0; k < settle; k++) {    physStep(s, s.dt);    const { vx, vy } = s.P, f = k < settle - 5 ? 0.85 : 1;    for (let i = 0; i < s.N; i++) { vx[i] *= f; vy[i] *= f; }  }  s.time = 0;  s.yRest0 = restLevel(s); s.yRest = s.yRest0;} /** 静かな水面の高さ: 幅を区切って、いちばん上の粒の高さの平均 + 粒の半径 */function restLevel(s: S) {  const nb = Math.max(4, Math.floor(s.W / (2 * s.s0))), top = new Float64Array(nb).fill(-1), { x, y } = s.P;  for (let i = 0; i < s.N; i++) { const b = clamp(Math.floor((x[i] / s.W) * nb), 0, nb - 1); if (y[i] > top[b] && y[i] < s.depth * 1.5) top[b] = y[i]; }  let a = 0, c = 0; for (let b = 2; b < nb - 2; b++) if (top[b] > 0) { a += top[b]; c++; }  return (c ? a / c : s.depth) + s.rp;}

粒の影響が届く半径はで、重みにはWendlandの核()を使う。静止した格子で重みを足した値S0で割っておくと、静かな水の密度がちょうど1になる。壁の近くの粒は、壁の向こうに相手の粒がいない。足りない分を連続体の積分で前もって表(wallW)にしておき、壁からの距離で引いて足す。最後のsettleのくり返しは、水が自分の重さで少し縮むまで、揺れを止めながら進める部分である。

近くの粒を探す

すべての粒の組を調べると、3,000個なら約450万組になる。そこで画面を格子に区切り、自分のマスと隣のマスの粒だけを見る。

近くの粒(src/sims/drop-splash.ts の 198〜239 行)
/** 格子(1 マス = 表の半径)に入れて、粒の並びを格子の順に並べ替える。近くの粒がメモリの上でも近くなり、速くなる */function sortGrid(s: S) {  const P = s.P, Q = s.Q, N = s.N, gc = s.gc, gr = s.gr, ih = 1 / s.hl, cs = s.cs, cur = s.cur, co = s.cellOf, ord = s.order, nc = gc * gr;  cs.fill(0);  for (let i = 0; i < N; i++) {    let cx = (P.x[i] * ih) | 0, cy = (P.y[i] * ih) | 0;    if (cx < 0) cx = 0; else if (cx >= gc) cx = gc - 1;    if (cy < 0) cy = 0; else if (cy >= gr) cy = gr - 1;    const c = cy * gc + cx; co[i] = c; cs[c + 1]++;  }  for (let c = 0; c < nc; c++) { cs[c + 1] += cs[c]; cur[c] = cs[c]; }  for (let i = 0; i < N; i++) ord[cur[co[i]]++] = i;  for (let k = 0; k < N; k++) { const i = ord[k]; Q.x[k] = P.x[i]; Q.y[k] = P.y[i]; Q.vx[k] = P.vx[i]; Q.vy[k] = P.vy[i]; Q.tag[k] = P.tag[i]; }  s.P = Q; s.Q = P;} /** * 近くの粒の組(i < j)の表を作る。半径は h に余白を足した長さ。同じ組を 2 回数えないよう、自分より後ろのマスだけを見る。 * i ごとに相手 j を並べる(ps[i] から ps[i+1] の手前までが i の相手) */function buildPairs(s: S) {  sortGrid(s);  const { x, y } = s.P, N = s.N, cs = s.cs, gc = s.gc, gr = s.gr, ih = 1 / s.hl, R2 = s.hl * s.hl, ps = s.ps, pb = s.pb, max = pb.length;  let n = 0;  for (let i = 0; i < N; i++) {    ps[i] = n;    const xi = x[i], yi = y[i];    let cx = (xi * ih) | 0, cy = (yi * ih) | 0;    if (cx < 0) cx = 0; else if (cx >= gc) cx = gc - 1;    if (cy < 0) cy = 0; else if (cy >= gr) cy = gr - 1;    // 同じ行: 同じマスの後ろの粒と、右のマス(並べ替えたので 1 続きの番号)    const row = cy * gc, e1 = cs[row + (cx < gc - 1 ? cx + 2 : cx + 1)];    for (let j = i + 1; j < e1; j++) { const dx = xi - x[j], dy = yi - y[j]; if (dx * dx + dy * dy < R2 && n < max) pb[n++] = j; }    // 1 つ上の行: 左・真ん中・右の 3 マス    if (cy < gr - 1) {      const r2 = row + gc, e2 = cs[r2 + (cx < gc - 1 ? cx + 2 : cx + 1)];      for (let j = cs[r2 + (cx > 0 ? cx - 1 : 0)]; j < e2; j++) { const dx = xi - x[j], dy = yi - y[j]; if (dx * dx + dy * dy < R2 && n < max) pb[n++] = j; }    }  }  ps[N] = n; s.np = n;  s.rx.set(x.subarray(0, N)); s.ry.set(y.subarray(0, N));}

sortGridは、粒を格子のマスの順に並べ替える。近くの粒がメモリの上でも近くに並ぶので、計算が速くなる。buildPairsは、半径hに25%の余白を足した範囲の組を表にする。どの粒もその余白の半分より動かないうちは、同じ表を使い回せる。

密度・圧力・表面張力・粘り

ここがSPH(滑らかな粒子の流体力学)の式そのものである。

SPHの式(src/sims/drop-splash.ts の 241〜332 行)
/** * 密度と圧力: ρ_i = Σ m W(r_ij)。静止を 1 にした密度 ρ̃ から Tait の式で圧力 p̃ = p/ρ0 を出し、p̃/ρ̃² を覚える。 * 同じ組の和で、色の場の勾配 ∇c_i = Σ V ∇W_ij(液体の内側を向き、表面でだけ大きい)も求める */function densityPass(s: S) {  const { x, y } = s.P, N = s.N, rho = s.rho, ps = s.ps, pb = s.pb, h2 = s.h2, Kw = s.Kw, Kn = s.Kn, ih = 1 / s.h, cx = s.cx, cy = s.cy;  rho.fill(s.Wself, 0, N); cx.fill(0, 0, N); cy.fill(0, 0, N);  for (let i = 0; i < N; i++) {    const xi = x[i], yi = y[i];    let ri = 0, gxi = 0, gyi = 0;    for (let p = ps[i], e = ps[i + 1]; p < e; p++) {      const j = pb[p], dx = xi - x[j], dy = yi - y[j], r2 = dx * dx + dy * dy;      if (r2 >= h2) continue;      const q = Math.sqrt(r2) * ih, u = 1 - q, u2 = u * u, w = Kw * u2 * u2 * (1 + 4 * q), g = Kn * u2 * u, gx = g * dx, gy = g * dy;      ri += w; rho[j] += w;      gxi -= gx; gyi -= gy; cx[j] += gx; cy[j] += gy;    }    rho[i] += ri; cx[i] += gxi; cy[i] += gyi;  }  const h = s.h, it = TN / h, W = s.W, wW = s.wallW, wG = s.wallG, iS = 1 / s.nS, B = s.bodies, pr = s.pr, Bp = s.B, ux = s.ux, uy = s.uy, ok = s.ok, thr = s.nThr;  for (let i = 0; i < N; i++) {    const xi = x[i], yi = y[i];    let r = rho[i], gx = cx[i], gy = cy[i];    // 壁と物の向こうに足りない分(鏡に映した粒の分)。色の場も、壁の向こうを液体の続きとして数える    if (xi < h) { const k = (xi * it) | 0; r += wW[k]; gx -= wG[k] * iS; } else if (W - xi < h) { const k = ((W - xi) * it) | 0; r += wW[k]; gx += wG[k] * iS; }    if (yi < h) { const k = (yi * it) | 0; r += wW[k]; gy -= wG[k] * iS; }    for (let b = 0; b < B.length; b++) {      const o = B[b], dx = o.x - xi, dy = o.y - yi, d = Math.sqrt(dx * dx + dy * dy) || 1e-12, g = d - o.R;      if (g < h) { const k = g > 0 ? (g * it) | 0 : 0; r += wW[k]; gx += (wG[k] * iS * dx) / d; gy += (wG[k] * iS * dy) / d; }    }    rho[i] = r; cx[i] = gx; cy[i] = gy;    if (r > 1) { const r2 = r * r, r4 = r2 * r2, r7 = r4 * r2 * r; pr[i] = (Bp * (r7 - 1)) / r2; } else pr[i] = 0;    // 表面の粒: 向き n̂ = ∇c / |∇c|    const m = Math.sqrt(gx * gx + gy * gy);    if (m > thr) { ok[i] = 1; ux[i] = gx / m; uy[i] = gy / m; } else { ok[i] = 0; ux[i] = 0; uy[i] = 0; }  }} /** * 力: 圧力 a_i = −Σ (p̃_i/ρ̃_i² + p̃_j/ρ̃_j²) ∇W_ij と、近すぎる粒の押し合い。組ごとに 1 回だけ計算し、i と j に逆向きに足す * (運動量が保たれる)。同じ組で、粘りの重みと、表面の曲がり ∇·n̂ = Σ V (n̂_j − n̂_i)·∇W_ij も集める */function forcePass(s: S) {  const { x, y, vx, vy } = s.P, N = s.N, ps = s.ps, pb = s.pb, h = s.h, h2 = s.h2, ihh = 1 / h;  const pr = s.pr, ax = s.ax, ay = s.ay, sw = s.sw, swx = s.swx, swy = s.swy, Kg = s.Kg, Kn = s.Kn, Kv = s.Kv, Wv = s.Wv, e2 = s.eta2;  const ok = s.ok, ux = s.ux, uy = s.uy, div = s.div, wv = s.wv;  ax.fill(0, 0, N); ay.fill(0, 0, N); sw.fill(0, 0, N); swx.fill(0, 0, N); swy.fill(0, 0, N); div.fill(0, 0, N); wv.fill(0, 0, N);  for (let i = 0; i < N; i++) {    const xi = x[i], yi = y[i], pi = pr[i], vxi = vx[i], vyi = vy[i], oi = ok[i], uxi = ux[i], uyi = uy[i];    let axi = 0, ayi = 0, swi = 0, sxi = 0, syi = 0, dvi = 0, wvi = 0;    for (let p = ps[i], e = ps[i + 1]; p < e; p++) {      const j = pb[p], dx = xi - x[j], dy = yi - y[j], r2 = dx * dx + dy * dy;      if (r2 >= h2 || r2 < 1e-24) continue;      const r = Math.sqrt(r2), q = r * ihh, u = 1 - q, u3 = u * u * u;      // 圧力: −∇W = Kg (1−q)³ (x_i − x_j)。i を j から離す向き      const k = (pi + pr[j]) * Kg * u3, fx = k * dx, fy = k * dy;      axi += fx; ayi += fy; ax[j] -= fx; ay[j] -= fy;      // 表面の曲がり: 表面の粒どうしだけで和をとる(V ∇_i W_ij = −Kn (1−q)³ (x_i − x_j))      const oj = ok[j];      if (oi | oj) {        const t = -Kn * u3 * ((ux[j] - uxi) * dx + (uy[j] - uyi) * dy), vw = Kv * u3 * u * (1 + 4 * q);        if (oj) { dvi += t; wvi += vw; }        if (oi) { div[j] += t; wv[j] += vw; }      }      // 粘り: 重み w で周りの速さにならす      const w = (Wv * q * u3 * r) / (r2 + e2);      swi += w; sw[j] += w; sxi += w * vx[j]; syi += w * vy[j]; swx[j] += w * vxi; swy[j] += w * vyi;    }    ax[i] += axi; ay[i] += ayi; sw[i] += swi; swx[i] += sxi; swy[i] += syi; div[i] += dvi; wv[i] += wvi;  }  // 壁と物: 壁の分の密度 Wd(δ) が距離で変わるぶんだけ押し返す(a = p̃/ρ̃² × (−dWd/dδ))。物には反対向きの力を返す  const W = s.W, it = TN / h, wF = s.wallF, B = s.bodies, mp = s.mp, sig = s.liq.sigma / s.liq.rho, cx = s.cx, cy = s.cy, kmax = 0.5 / s.s0, Ks = s.Kv;  for (let i = 0; i < N; i++) {    const xi = x[i], yi = y[i], p1 = pr[i];    if (p1 > 0) {      if (xi < h) ax[i] += p1 * wF[(xi * it) | 0];      else if (W - xi < h) ax[i] -= p1 * wF[((W - xi) * it) | 0];      if (yi < h) ay[i] += p1 * wF[(yi * it) | 0];      for (let b = 0; b < B.length; b++) {        const o = B[b], dx = xi - o.x, dy = yi - o.y, d = Math.sqrt(dx * dx + dy * dy) || 1e-12, g = d - o.R;        if (g >= h) continue;        const a = p1 * wF[g > 0 ? (g * it) | 0 : 0], ex = dx / d, ey = dy / d;        ax[i] += a * ex; ay[i] += a * ey; o.fx -= mp * a * ex; o.fy -= mp * a * ey;      }    }    // 表面張力(CSF): a = (σ/ρ) κ ∇c。κ = −∇·n̂ を、表面の粒の核の和で割って、核が欠けたぶんを補う    if (ok[i]) {      const kap = clamp(-div[i] / Math.max(0.3, wv[i] + Ks), -kmax, kmax);      ax[i] += sig * kap * cx[i]; ay[i] += sig * kap * cy[i];    }  }}

densityPassは、密度を求め、Taitの式

で圧力に直す。密度が静止より小さい所では圧力を0にする。液体は引っ張りに耐えられないからだ。コードのpr[i]はを、静止の密度で割った値で持つ。

forcePassは、圧力の力

を組ごとに1回だけ計算し、iには足し、jには引く。押す力と押し返す力が必ず等しくなるので、水全体の運動量は保たれる。k = (pi + pr[j]) * Kg * u3に粒の間の向き(dx、dy)を掛けたものが、括弧の中との積にあたる。

表面張力は、水面にだけ働く力で入れている。cは水の中で1、外で0になる「色」で、は水面で内側を向く。は水面の曲がりで、粒ごとの向きが隣とどれだけ違うかから求める(divとwv)。粒が粗いと曲がりの数え方が揺れるので、大きさに上限を付けている。粘りは、周りの粒の速さとの差に重みwを掛けて集めておき、次の「時間を進める」で使う。

時間を進める

1回に進める時間dtは、音と流れを合わせた速さで、粒の間隔の9割ほどを進む時間にする(CFL条件)。表面張力でできる細かい波のほうが速く伝わるときは、それに合わせてさらに短くする。

時間を進める(src/sims/drop-splash.ts の 334〜421 行)
/** 刻み dt を決める: 音速 c と CFL 条件・毛管波の条件。速さの上限もここで */function setDt(s: S, vImp: number) {  s.vref = Math.max(0.6, 1.25 * vImp);  s.c = Math.max(MACH * s.vref, 10 * Math.sqrt(G * s.depth));  s.B = (s.c * s.c) / GAMMA;  const cap = 0.5 * Math.sqrt((s.liq.rho * s.s0 ** 3) / (TAU * s.liq.sigma));  s.dt = Math.min((CFL * s.h) / (s.c + s.vref), cap);  s.vcap = 0.6 * s.c;  s.nu = s.liq.mu / s.liq.rho + (ALPHA * s.h * s.c) / 8;  s.acc = 0;} /** 1 刻み: 密度 → 力 → 速さ(重力・粘り)→ 位置(シンプレクティック・オイラー法) */function physStep(s: S, dt: number) {  if (s.needList) { buildPairs(s); s.needList = false; }  densityPass(s);  forcePass(s);  const { x, y, vx, vy, tag } = s.P, N = s.N, ax = s.ax, ay = s.ay, sw = s.sw, swx = s.swx, swy = s.swy;  const nuDt = s.nu * dt, gdt = G * dt, vc = s.vcap, vc2 = vc * vc, lo = s.rp, hi = s.W - s.rp, top = s.Ymax - s.rp, rx = s.rx, ry = s.ry;  const st = s.stir, sR = 1.1 * s.D, sk = st.on ? 1 - Math.exp(-dt / 0.002) : 0, ct = s.ev ? s.ev.tag : -1, ok = s.ok, jit2 = (0.05 * s.vref) ** 2;  let moved = 0, minY = Infinity;  for (let i = 0; i < N; i++) {    // 速さ: 力と重力を足し、粘りは陰的に(周りの速さとの重み付きの平均へ寄せる)    let ux = vx[i] + ax[i] * dt, uy = vy[i] + ay[i] * dt - gdt;    const inv = 1 / (1 + nuDt * sw[i]);    ux = (ux + nuDt * swx[i]) * inv; uy = (uy + nuDt * swy[i]) * inv;    // 表面の粒が周りとわずかに違う速さで揺れているときだけ、周りの平均へ寄せる(曲率を粒で数えるときの細かい揺れを消す。    // ぶつかるときのような大きな速さの差には効かせない)    if (ok[i] && sw[i] > 0) {      const dx = swx[i] / sw[i] - ux, dy = swy[i] / sw[i] - uy, d2 = dx * dx + dy * dy;      if (d2 < jit2) { const k = SURF * (1 - d2 / jit2); ux += k * dx; uy += k * dy; }    }    if (sk > 0) { const dx = x[i] - st.x, dy = y[i] - st.y, d = Math.sqrt(dx * dx + dy * dy); if (d < sR) { const w = sk * (1 - d / sR); ux += (st.vx - ux) * w; uy += (st.vy - uy) * w; } }    const v2 = ux * ux + uy * uy;    if (v2 > vc2) { const f = vc / Math.sqrt(v2); ux *= f; uy *= f; }    let xi = x[i] + ux * dt, yi = y[i] + uy * dt;    // 壁: めり込んだら戻し、壁へ向かう速さを消す    if (xi < lo) { xi = lo; if (ux < 0) ux = 0; } else if (xi > hi) { xi = hi; if (ux > 0) ux = 0; }    if (yi < lo) { yi = lo; if (uy < 0) uy = 0; } else if (yi > top) { yi = top; if (uy > 0) uy = 0; }    if (!(xi === xi && yi === yi && ux === ux && uy === uy)) { xi = s.W * (0.3 + 0.4 * s.rnd()); yi = lo; ux = 0; uy = 0; s.needList = true; }    x[i] = xi; y[i] = yi; vx[i] = ux; vy[i] = uy;    const mx = xi - rx[i], my = yi - ry[i], m2 = mx * mx + my * my;    if (m2 > moved) moved = m2;    if (tag[i] === ct && yi < minY) minY = yi;  }  bodiesStep(s, dt);  const lim = 0.5 * SKIN * s.h;  if (moved > lim * lim) s.needList = true;  s.time += dt;  const ev = s.ev;  if (ev && !(ev.t0 >= 0)) {    const bottom = ev.body ? ev.body.y - ev.body.R : minY;    if (bottom <= s.yRest) { ev.t0 = s.time; ev.vImp = ev.body ? Math.hypot(ev.body.vx, ev.body.vy) : Math.sqrt(2 * G * ev.H); }  }} /** 物(円柱): 水から受けた力と重力で動かす。めり込んだ粒は押し出し、近づく向きの速さを質量の比で分け合う */function bodiesStep(s: S, dt: number) {  const B = s.bodies; if (!B.length) return;  const { x, y, vx, vy } = s.P, N = s.N, mp = s.mp;  for (const b of B) {    if (b.grab) {      const k = Math.min(1, dt / 0.004), mx = 0.5 * s.vcap;      b.vx = ((b.tx - b.x) * k) / dt; b.vy = ((b.ty - b.y) * k) / dt;      const sp = Math.hypot(b.vx, b.vy); if (sp > mx) { b.vx *= mx / sp; b.vy *= mx / sp; }    } else { b.vx += (b.fx / b.M) * dt; b.vy += (b.fy / b.M - G) * dt; }    b.fx = 0; b.fy = 0;    b.x += b.vx * dt; b.y += b.vy * dt;    if (b.x < b.R) { b.x = b.R; b.vx = Math.max(0, b.vx); } else if (b.x > s.W - b.R) { b.x = s.W - b.R; b.vx = Math.min(0, b.vx); }    if (b.y < b.R) { b.y = b.R; b.vy = Math.max(0, b.vy); }    const Rr = b.R + 0.35 * s.s0, Rr2 = Rr * Rr, wb = b.grab ? 0 : mp / (b.M + mp), wp = 1 - wb;    for (let i = 0; i < N; i++) {      const dx = x[i] - b.x, dy = y[i] - b.y, d2 = dx * dx + dy * dy;      if (d2 >= Rr2) continue;      const d = Math.sqrt(d2) || 1e-12, nx = dx / d, ny = dy / d, pen = Rr - d;      x[i] += nx * pen * wp; y[i] += ny * pen * wp; b.x -= nx * pen * wb; b.y -= ny * pen * wb;      const vn = (vx[i] - b.vx) * nx + (vy[i] - b.vy) * ny;      if (vn < 0) { vx[i] -= vn * nx * wp; vy[i] -= vn * ny * wp; b.vx += vn * nx * wb; b.vy += vn * ny * wb; }    }    const sp = Math.hypot(b.vx, b.vy); if (!(sp < s.vcap)) { if (sp > 0 && sp === sp) { b.vx *= s.vcap / sp; b.vy *= s.vcap / sp; } else { b.vx = 0; b.vy = 0; b.x = s.W / 2; b.y = s.Ht; } }  }  for (let a = 0; a < B.length; a++) for (let c = a + 1; c < B.length; c++) {    const p = B[a], o = B[c], dx = o.x - p.x, dy = o.y - p.y, d = Math.hypot(dx, dy) || 1e-9, pen = p.R + o.R - d;    if (pen <= 0) continue;    const wp = p.grab ? 0 : 1 / p.M, wo = o.grab ? 0 : 1 / o.M, sum = wp + wo || 1, nx = dx / d, ny = dy / d;    p.x -= nx * pen * (wp / sum); p.y -= ny * pen * (wp / sum); o.x += nx * pen * (wo / sum); o.y += ny * pen * (wo / sum);  }}

physStepは、速さを先に進め、新しい速さで位置を進める(シンプレクティック・オイラー法)。この順にすると、長く進めてもエネルギーが増えたり減ったりしにくい。粘りの式は、強い粘りでも計算が暴れないよう、新しい速さを周りの速さとの重み付きの平均として求める(陰的な解き方)。

速さには音の速さの0.6倍の上限を付け、壁にめり込んだ粒は戻し、計算が壊れた粒(NaN)は底に戻す。ビー玉と木の玉はbodiesStepで、粒から受けた圧力の反作用と重力で動かす。浮力は、ここから自然に出てくる。

落とす・つかむ・かき回す

画面に見えている空気の高さは数cmしかない。そこで物は画面の上から、落とす高さHから落ちてきたときの速さに合わせて放す。

落とす操作(src/sims/drop-splash.ts の 423〜502 行)
/** 物を落とす: 落とす高さ H の速さ √(2gH) から、見えている高さを落ちたぶんを引いた速さで、画面の上から放す */function launch(api: Api, s: S, x0: number, Hm: number) {  const D = s.D, R = D / 2, kind = s.kind, K = KIND[kind];  const x = clamp(x0, R + 1.2 * s.s0, s.W - R - 1.2 * s.s0);  const gap = Math.max(0, Math.min(Hm, s.Ht - s.yRest - D - 0.25 * D));  const vs = Math.sqrt(Math.max(0, 2 * G * (Hm - gap))), yc = s.yRest + gap + R + s.rp;  const vImp = Math.sqrt(2 * G * Hm);  const fresh = !s.ev || (s.ev.t0 >= 0 && s.time - s.ev.t0 > 0.15);  setDt(s, fresh ? vImp : Math.max(vImp, s.vref / 1.25));  s.tagN = (s.tagN % 250) + 1;  const ev: Ev = { x, H: Hm, D, kind, liq: s.liqKey, tag: s.tagN, body: null, t0: NaN, tStart: s.time, vImp, maxDepth: 0, maxJet: 0, noted: 0 };  if (kind === "drop") {    // 雫: 同じ間隔の六方格子で円を埋めた粒    const dy = (Math.sqrt(3) / 2) * s.s0, pts: number[] = [];    for (let r = -Math.ceil(R / dy); r <= Math.ceil(R / dy); r++) for (let c = -Math.ceil(R / s.s0) - 1; c <= Math.ceil(R / s.s0) + 1; c++) {      const px = c * s.s0 + (r & 1 ? s.s0 / 2 : 0), py = r * dy;      if (px * px + py * py <= (R - 0.25 * s.s0) ** 2) pts.push(px, py);    }    const add = pts.length / 2;    if (s.N + add > s.cap) drain(s, s.N + add - s.cap);    const P = s.P;    for (let k = 0; k < add; k++) { const i = s.N++; P.x[i] = x + pts[2 * k]; P.y[i] = yc + pts[2 * k + 1]; P.vx[i] = 0; P.vy[i] = -vs; P.tag[i] = s.tagN; }    s.yRest = s.yRest0 + ((s.N - s.N0) * s.ap) / s.W;  } else {    if (s.bodies.length >= 3) s.bodies.shift();    const b: Body = { x, y: yc, vx: 0, vy: -vs, R, M: K.rho * Math.PI * R * R, kind, fx: 0, fy: 0, grab: false, tx: x, ty: yc, sunk: false, spin: s.rnd() * TAU };    s.bodies.push(b); ev.body = b;  }  s.needList = true; s.ev = ev; s.fresh = false;  api.emit("drop", { kind, H: Math.round(Hm * 100), D: Math.round(D * 10000) / 10 });} /** 水が増えすぎたら、深い所の粒を少し抜く(穴は水圧ですぐ埋まる) */function drain(s: S, k: number) {  const P = s.P, lim = s.yRest - 3 * s.h;  for (let tries = 0; k > 0 && tries < 20000 && s.N > 10; tries++) {    const i = Math.floor(s.rnd() * s.N);    if (P.y[i] > lim) continue;    const l = --s.N;    P.x[i] = P.x[l]; P.y[i] = P.y[l]; P.vx[i] = P.vx[l]; P.vy[i] = P.vy[l]; P.tag[i] = P.tag[l];    k--;  }  s.yRest = s.yRest0 + ((s.N - s.N0) * s.ap) / s.W;  s.needList = true;} /** * 指: 水面より上を押すと落とす位置を決め、押したまま上下に動かすと高さを変え(17% 上げるごとに 2 倍)、離すと落とす。 * 物を押すとつかんで動かせる。水の中をなぞると、指の速さ(スローモーションの倍率で本物の速さに直す)で水をかき回す */function onPointer(type: string, api: Api, s: S) {  const v = s.view; if (!v) return;  const P = api.pointer, wx = v.ix(P.x), wy = v.iy(P.y), slow = api.num("slow") || 50, mpp = 1 / v.k;  if (type === "down") {    for (const b of s.bodies) if (Math.hypot(wx - b.x, wy - b.y) < b.R + api.u(10) * mpp) { s.grab = b; b.grab = true; b.tx = wx; b.ty = wy; return; }    if (wy > s.yRest + 0.25 * s.D) s.aim = { press: true, moved: false, x: wx, H: api.num("H") / 100, H0: api.num("H") / 100 };    else s.stir = { on: true, x: wx, y: wy, vx: 0, vy: 0 };  } else if (type === "move") {    if (s.grab) { s.grab.tx = clamp(wx, 0, s.W); s.grab.ty = clamp(wy, 0, s.Ht); }    else if (s.aim.press) {      s.aim.x = wx;      if (Math.abs(P.y - P.y0) > 6) s.aim.moved = true;      if (s.aim.moved) s.aim.H = clamp(Math.round(s.aim.H0 * 100 * Math.pow(2, (P.y0 - P.y) / (0.17 * api.h))), 2, 100) / 100;    } else if (s.stir.on) {      const f = mpp * slow, cap = 0.4 * s.vcap;      let ux = P.vx * f, uy = -P.vy * f; const sp = Math.hypot(ux, uy); if (sp > cap) { ux *= cap / sp; uy *= cap / sp; }      s.stir.x = wx; s.stir.y = wy; s.stir.vx = ux; s.stir.vy = uy;    } else s.aim.x = wx;  } else {    if (s.grab) {      const b = s.grab, f = mpp * slow, cap = 3;      b.grab = false; s.grab = null;      b.vx = P.vx * f; b.vy = -P.vy * f; const sp = Math.hypot(b.vx, b.vy); if (sp > cap) { b.vx *= cap / sp; b.vy *= cap / sp; }    } else if (s.aim.press) {      s.aim.press = false;      if (type === "up") { if (s.aim.moved) api.set("H", Math.round(s.aim.H * 100)); launch(api, s, s.aim.x, s.aim.moved ? s.aim.H : api.num("H") / 100); }    }    s.stir.on = false;  }}

雫は、水槽と同じ間隔の六方格子で円を埋めた粒として足す。粒が増えすぎたら、深い所の粒を少し抜く。指でかき回すときは、指の速さをスローモーションの倍率で本物の時間の速さに直してから、近くの粒の速さを寄せていく。

水面を網点で描く

粒の点のまま描くと、水のかたまりに見えない。そこで粒を格子に配ってぼかし(メタボールの場)、濃さがしきい値を超える所を水とみなす。

描画(src/sims/drop-splash.ts の 504〜716 行)
function ensureGrid(s: S, cols: number, rows: number) {  if (s.rc === cols && s.rr === rows && s.A) return;  const n = cols * rows;  s.rc = cols; s.rr = rows; s.A = new Float32Array(n); s.Bv = new Float32Array(n); s.T = new Float32Array(n); s.F = new Float32Array(n); s.lab = new Int32Array(n); s.stack = new Int32Array(n);}/** 箱型のぼかし(横 → 縦)。端は端の値を伸ばす(壁ぎわの水が薄くならない) */function blur(src: Float32Array, tmp: Float32Array, C: number, R: number, r: number) {  const inv = 1 / (2 * r + 1);  for (let j = 0; j < R; j++) {    const o = j * C; let acc = 0;    for (let k = -r; k <= r; k++) acc += src[o + clamp(k, 0, C - 1)];    for (let i = 0; i < C; i++) { tmp[o + i] = acc * inv; const a = i + r + 1, b = i - r; acc += src[o + (a < C ? a : C - 1)] - src[o + (b > 0 ? b : 0)]; }  }  for (let i = 0; i < C; i++) {    let acc = 0;    for (let k = -r; k <= r; k++) acc += tmp[clamp(k, 0, R - 1) * C + i];    for (let j = 0; j < R; j++) { src[j * C + i] = acc * inv; const a = j + r + 1, b = j - r; acc += tmp[(a < R ? a : R - 1) * C + i] - tmp[(b > 0 ? b : 0) * C + i]; }  }}/** 粒を格子に配ってぼかし、水の濃さ A と速さ B の面を作る(メタボールの場)。しきい値より濃い所が水 */function splat(s: S) {  const A = s.A, B = s.Bv, C = s.rc, R = s.rr, cw = s.W / C, ch = s.Ht / R, { x, y, vx, vy } = s.P;  A.fill(0); B.fill(0);  for (let i = 0; i < s.N; i++) {    const yy = y[i]; if (yy > s.Ht + ch) continue;    const gx = x[i] / cw - 0.5, gy = (s.Ht - yy) / ch - 0.5, i0 = Math.floor(gx), j0 = Math.floor(gy), fx = gx - i0, fy = gy - j0;    const sp = Math.sqrt(vx[i] * vx[i] + vy[i] * vy[i]);    for (let q = 0; q < 4; q++) {      const ii = i0 + (q & 1), jj = j0 + (q >> 1);      if (ii < 0 || jj < 0 || ii >= C || jj >= R) continue;      const w = (q & 1 ? fx : 1 - fx) * (q >> 1 ? fy : 1 - fy), k = jj * C + ii;      A[k] += w; B[k] += w * sp;    }  }  const r = Math.max(1, Math.round((0.55 * s.h) / cw));  blur(A, s.T, C, R, r); blur(B, s.T, C, R, r);  s.thr = (0.36 * (cw * ch)) / s.ap;} /** 水の面から測る: つながった水(底から塗りつぶし)とちぎれた雫、くぼみの深さ、柱の高さ、縁の高さ */function analyse(s: S): Look {  const A = s.A, C = s.rc, R = s.rr, thr = s.thr, lab = s.lab, st = s.stack, cw = s.W / C, ch = s.Ht / R, n = C * R;  lab.fill(0);  let sp = 0;  const fill = (id: number): [number, number] => {    let cnt = 0, top = R;    while (sp > 0) {      const k = st[--sp], i = k % C, j = (k / C) | 0; cnt++; if (j < top) top = j;      if (i > 0 && !lab[k - 1] && A[k - 1] > thr) { lab[k - 1] = id; st[sp++] = k - 1; }      if (i < C - 1 && !lab[k + 1] && A[k + 1] > thr) { lab[k + 1] = id; st[sp++] = k + 1; }      if (j > 0 && !lab[k - C] && A[k - C] > thr) { lab[k - C] = id; st[sp++] = k - C; }      if (j < R - 1 && !lab[k + C] && A[k + C] > thr) { lab[k + C] = id; st[sp++] = k + C; }    }    return [cnt, top];  };  for (let i = 0; i < C; i++) { const k = (R - 1) * C + i; if (A[k] > thr) { lab[k] = 1; st[sp++] = k; } }  fill(1);  const L: Look = { depth: 0, cavY: s.yRest, seal: false, jet: 0, jetY: s.yRest, narrow: 0, rim: 0, rimX: 0, rimY: 0, drops: 0, topX: 0, topY: -1 };  let id = 2;  for (let k = 0; k < n; k++) {    if (lab[k] || !(A[k] > thr)) continue;    lab[k] = id; st[sp++] = k;    const [cnt, top] = fill(id); id++;    if (cnt >= 2) { L.drops++; const yy = s.Ht - (top + 0.5) * ch; if (yy > L.topY) { L.topY = yy; L.topX = ((k % C) + 0.5) * cw; } }  }  const ev = s.ev; if (!ev) return L;  const topBulk = (i: number) => { for (let j = 0; j < R; j++) if (lab[j * C + i] === 1) return j; return R; };  const ia = clamp(Math.floor(ev.x / cw), 0, C - 1), yAt = (j: number) => s.Ht - (j + 0.5) * ch;  const ja = topBulk(ia), ya = ja < R ? yAt(ja) + 0.5 * ch : 0;  // 物が入っている所は空気ではない。くぼみの底に物があれば、物の上の端までをくぼみとする(浮いている木の玉の下は数えない)  const xa = (ia + 0.5) * cw, solid = (yy: number) => s.bodies.some((b) => Math.hypot(xa - b.x, yy - b.y) < b.R + s.s0);  let yb = ya;  for (const b of s.bodies) if (Math.abs(b.x - xa) < b.R && b.y - b.R < ya + 2 * s.s0) yb = Math.max(yb, b.y + b.R);  if (yb < s.yRest - 0.12 * s.D) { L.depth = s.yRest - yb; L.cavY = yb; }  else if (ya >= s.yRest - 0.12 * s.D) {    // 上が閉じて、下に空気が残っている(深い所で閉じたくぼみ)    let low = -1;    for (let j = ja + 1; j < R; j++) if (lab[j * C + ia] !== 1 && yAt(j) < s.yRest - 0.3 * s.D && !solid(yAt(j))) low = j;    if (low > 0) { L.seal = true; L.cavY = yAt(low) - 0.5 * ch; L.depth = s.yRest - L.cavY; }  }  const wj = Math.max(1, Math.round((0.45 * s.D) / cw));  let best = R;  for (let i = Math.max(0, ia - wj); i <= Math.min(C - 1, ia + wj); i++) best = Math.min(best, topBulk(i));  if (best < R) {    const yy = yAt(best) + 0.5 * ch; L.jet = Math.max(0, yy - s.yRest); L.jetY = yy;    // 柱の細さ: 高さの半分の所での幅が、高さの 0.7 倍より狭いときだけ柱と呼ぶ(背が幅より高い形。広い盛り上がりは柱と呼ばない)    const half = s.yRest + L.jet / 2;    const above = (i: number) => { const j = topBulk(i); return j < R && yAt(j) + 0.5 * ch > half; };    let il = ia, ir = ia;    while (il > 0 && above(il - 1)) il--;    while (ir < C - 1 && above(ir + 1)) ir++;    L.narrow = (ir - il + 1) * cw < 0.7 * L.jet ? L.jet : 0;    // 真ん中の水の下に物があれば、物に押し上げられた水なので柱と数えない    if (s.bodies.some((o) => Math.abs(o.x - ev.x) < o.R + s.D && Math.abs(o.y - yy) < o.R + 1.5 * s.D)) { L.jet = 0; L.narrow = 0; }  }  const i0 = Math.round(s.D / cw), i1 = Math.round((5 * s.D) / cw);  for (let side = -1; side <= 1; side += 2) for (let d = i0; d <= i1; d++) {    const i = ia + side * d; if (i < 0 || i >= C) continue;    const j = topBulk(i); if (j >= R) continue;    const r = yAt(j) + 0.5 * ch - s.yRest;    if (r > L.rim) { L.rim = r; L.rimX = (i + 0.5) * cw; L.rimY = yAt(j) + 0.5 * ch; }  }  return L;} /** 水面の輪郭: マーチングスクエア(4 隅の濃さがしきい値をまたぐ辺の上に、直線で補間した点を結ぶ) */function contour(api: Api, s: S, X0: number, Y0: number, cw: number, ch: number) {  const A = s.A, C = s.rc, R = s.rr, t = s.thr, ctx = api.ctx;  ctx.beginPath();  for (let j = 0; j < R - 1; j++) for (let i = 0; i < C - 1; i++) {    const k = j * C + i, a = A[k] - t, b = A[k + 1] - t, c = A[k + C + 1] - t, d = A[k + C] - t;    const m = (a > 0 ? 1 : 0) | (b > 0 ? 2 : 0) | (c > 0 ? 4 : 0) | (d > 0 ? 8 : 0);    if (m === 0 || m === 15) continue;    const x = X0 + (i + 0.5) * cw, y = Y0 + (j + 0.5) * ch;    const E = (e: number): [number, number] => (e === 0 ? [x + (cw * a) / (a - b), y] : e === 1 ? [x + cw, y + (ch * b) / (b - c)] : e === 2 ? [x + (cw * d) / (d - c), y + ch] : [x, y + (ch * a) / (a - d)]);    const seg = (e1: number, e2: number) => { const p = E(e1), q = E(e2); ctx.moveTo(p[0], p[1]); ctx.lineTo(q[0], q[1]); };    switch (m) {      case 1: case 14: seg(3, 0); break;      case 2: case 13: seg(0, 1); break;      case 3: case 12: seg(3, 1); break;      case 4: case 11: seg(1, 2); break;      case 6: case 9: seg(0, 2); break;      case 7: case 8: seg(3, 2); break;      case 5: if (a + b + c + d > 0) { seg(3, 2); seg(0, 1); } else { seg(3, 0); seg(1, 2); } break;      case 10: if (a + b + c + d > 0) { seg(0, 3); seg(1, 2); } else { seg(0, 1); seg(2, 3); } break;    }  }  ctx.lineWidth = api.u(1.35); ctx.strokeStyle = api.ink; ctx.lineCap = "round"; ctx.setLineDash([]); ctx.stroke(); ctx.lineCap = "butt";} function drawBody(api: Api, v: View, b: Body) {  const X = v.X(b.x), Y = v.Y(b.y), r = b.R * v.k, ctx = api.ctx;  api.circle(X, Y, r, { fill: "paper", stroke: false });  if (b.kind === "marble") {    // ガラスの中のねじれた模様    ctx.save(); ctx.beginPath(); ctx.arc(X, Y, r, 0, TAU); ctx.clip();    ctx.strokeStyle = api.ink; ctx.lineWidth = Math.max(api.u(1), r * 0.12); ctx.beginPath();    const c = Math.cos(b.spin), sn = Math.sin(b.spin), P = (u: number, w: number): [number, number] => [X + r * (u * c - w * sn), Y + r * (u * sn + w * c)];    const p0 = P(-0.9, -0.15), q1 = P(-0.3, -0.75), q2 = P(0.25, 0.55), p1 = P(0.9, 0.1);    ctx.moveTo(p0[0], p0[1]); ctx.bezierCurveTo(q1[0], q1[1], q2[0], q2[1], p1[0], p1[1]); ctx.stroke(); ctx.restore();    api.dot(X - r * 0.38, Y - r * 0.42, Math.max(api.u(1.3), r * 0.11));  } else {    // 木目    const pts: Pt[] = []; for (let k = 0; k < 28; k++) { const a = (k / 28) * TAU; pts.push([X + r * Math.cos(a), Y + r * Math.sin(a)]); }    api.hatch(pts, { gap: Math.max(api.u(2.6), r * 0.2), angle: 70 + ((b.spin * 10) % 30), w: api.u(0.8) });  }  api.circle(X, Y, r, { w: api.u(1.6) });  api.circle(X, Y, r + api.u(5), { w: api.u(1), dash: [api.u(2), api.u(3)] }); // つかめる印} const lenTxt = (m: number) => (m < 0.0095 ? `${fmt(m * 1000, 0)} mm` : `${fmt(m * 100, 0)} cm`); /** 1 枚を描く: 網点の水(速い所ほど濃い)・輪郭・物・水槽・物差し・落とす印・起きていることの名前・経過時間 */function draw(api: Api, s: S) {  const v = (s.view = api.view(0, s.W, 0, s.Ht, { pad: pads(api), equal: true }));  const X0 = v.X(0), X1 = v.X(s.W), Y0 = v.Y(s.Ht), Y1 = v.Y(0), wpx = X1 - X0, hpx = Y1 - Y0;  const cpx = Math.max(2, api.u(CELL[api.quality] ?? 3.8));  ensureGrid(s, Math.max(8, Math.round(wpx / cpx)), Math.max(8, Math.round(hpx / cpx)));  splat(s);  const C = s.rc, R = s.rr, cw = wpx / C, ch = hpx / R, A = s.A, Bv = s.Bv, F = s.F, thr = s.thr;  const ev = s.ev, vr = Math.max(0.25, ev ? ev.vImp : 0.5);  // 網点の濃さ: 静かな水は 0.07(まばらな点)、衝突の速さで動く所は 0.95  for (let k = 0; k < C * R; k++) { const a = A[k]; F[k] = a > thr ? 0.07 + 0.88 * Math.pow(Math.min(1, Bv[k] / a / vr), 0.7) : 0; }  api.clear();  api.field(C, R, F, { x: X0, y: Y0, w: wpx, h: hpx });  contour(api, s, X0, Y0, cw, ch);  const L = (s.look = analyse(s));  for (const b of s.bodies) drawBody(api, v, b);  // 水槽(ガラスの壁と底)と、静かなときの水面の目印  const ww = api.u(2.2);  api.line(X0 - ww / 2, Y1 + ww / 2, X1 + ww / 2, Y1 + ww / 2, { w: ww });  api.line(X0 - ww / 2, Y0, X0 - ww / 2, Y1 + ww, { w: ww });  api.line(X1 + ww / 2, Y0, X1 + ww / 2, Y1 + ww, { w: ww });  const yr = v.Y(s.yRest);  api.line(X0 - api.u(9), yr, X0 - api.u(3), yr, { w: api.u(1.2) });  api.line(X1 + api.u(3), yr, X1 + api.u(9), yr, { w: api.u(1.2) });  // 物差し  let sl = 0.001; for (const o of [0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1]) if (o <= 0.26 * s.W) sl = o;  const by = Y1 + api.u(17), bx = X1 - sl * v.k;  api.line(bx, by, X1, by, { w: api.u(1.4) }); api.line(bx, by - api.u(4), bx, by + api.u(4), { w: api.u(1.2) }); api.line(X1, by - api.u(4), X1, by + api.u(4), { w: api.u(1.2) });  api.text(lenTxt(sl), bx - api.u(8), by + api.u(4), { size: api.u(11.5), font: "mono", align: "right" });  // 落とす前の印(点線の輪)と高さ  if (!busy(s) || s.aim.press || (api.pointer.inside && !s.stir.on && !s.grab)) {    const ax = clamp(s.aim.press || (api.pointer.inside && !s.stir.on && !s.grab) ? s.aim.x : s.W / 2, s.D * 0.6, s.W - s.D * 0.6);    const Hm = s.aim.press ? s.aim.H : api.num("H") / 100;    const gap = Math.max(0, Math.min(Hm, s.Ht - s.yRest - s.D * 1.25)), cy = s.yRest + gap + s.D / 2 + s.rp;    const X = v.X(ax), Y = v.Y(cy), r = (s.D / 2) * v.k;    api.circle(X, Y, r, { w: api.u(1.2), dash: [api.u(2), api.u(3)] });    api.line(X, Y + r + api.u(3), X, Math.max(Y + r + api.u(4), yr - api.u(4)), { w: api.u(0.9), dash: [api.u(1.5), api.u(4)] });    const right = X + r + api.u(130) < X1;    api.label(`高さ ${fmt(Hm * 100, 0)} cm から`, right ? X + r + api.u(10) : X - r - api.u(10), Y + api.u(4), { size: api.u(12), align: right ? "left" : "right" });  }  // 起きていることの名前(引き出し線つき)  if (ev && ev.t0 >= 0) {    const lab = (t: string, px: number, py: number, dx: number, dy: number) => {      const x2 = clamp(px + dx, X0 + api.u(4), X1 - api.u(4)), y2 = py + dy;      api.line(px, py, x2, y2, { w: api.u(0.9) });      const left = x2 < px || x2 > X1 - api.u(140);      api.label(t, x2 + (left ? -api.u(3) : api.u(3)), y2 + api.u(4), { size: api.u(11.5), align: left ? "right" : "left" });    };    const xa = v.X(ev.x), side = ev.x < s.W * 0.5 ? 1 : -1, top = Y0 + api.u(36);    if (L.depth > 0.35 * s.D) lab(L.seal ? "閉じたくぼみ" : "くぼみ", xa, v.Y(L.cavY), side * api.u(48), api.u(10));    if (L.jet > 0.5 * s.D) lab(L.narrow > 0.7 * s.D ? "ワージントンジェット" : "はね返りの盛り上がり", xa, v.Y(L.jetY), side * api.u(40), -api.u(8));    if (L.rim > 0.5 * s.D && L.jet < 0.5 * s.D && L.depth > 0.2 * s.D && v.Y(L.rimY) - api.u(16) > top) lab("盛り上がる縁(王冠)", v.X(L.rimX), v.Y(L.rimY), (L.rimX < ev.x ? -1 : 1) * api.u(30), -api.u(16));    if (L.drops > 0 && L.topY > s.yRest + 0.5 * s.D && v.Y(L.topY) - api.u(6) > top) lab("ちぎれた雫", v.X(L.topX), v.Y(L.topY), -side * api.u(34), -api.u(6));  }  // 経過時間(水面に触れてから)と、スローモーションの倍率  const slow = api.num("slow") || 50;  if (ev && ev.t0 >= 0) api.label(`t = ${fmt((s.time - ev.t0) * 1000, 1)} ms`, api.u(14), api.u(26), { size: api.u(17), font: "mono" });  else api.label(ev ? "落下中" : "t = 0.0 ms", api.u(14), api.u(26), { size: api.u(ev ? 15 : 17), font: ev ? "sans" : "mono" });  const heavy = s.eff > slow * 1.2 ? `(重いため実際は ${Math.round(s.eff / 5) * 5} 分の 1)` : "";  api.text(s.hold ? `止めています  1 コマ = ${fmt(FRAME_STEP * 1000, 1)} ms` : `スローモーション ${slow} 分の 1${heavy}`, api.u(14), api.u(42), { size: api.u(11.5), font: "mono" });}

輪郭はマーチングスクエアで引く。格子の4隅のうち、しきい値をまたぐ辺の上に点をとって結ぶ方法である。水の中は、速さを濃さに直した網点で塗るので、動いている所が浮き上がって見える。analyseは、底からつながった水を塗りつぶし、つながっていないかたまりを「ちぎれた雫」として数える。くぼみの深さと柱の高さも、ここで同じ格子から測っている。柱の両側との高さの差を見て、広い盛り上がりは柱と呼ばないようにしている。

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

プログラム全体を見る(src/sims/drop-splash.ts・829 行)

読み込んでいます

姉妹サイト

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