このシミュレーションは、水槽の水を千数百〜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 ÷ 粒の間隔 s0 const 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 刻みで音が進む長さ ÷ h const 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; }
粒の影響が届く半径はh = 2 s 0 で、重みにはWendlandの核W = π h 2 7 ( 1 − q ) 4 ( 1 + 4 q ) (q = r / h )を使う。静止した格子で重みを足した値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は、密度ρ i = ∑ j m W ( r ij ) を求め、Taitの式
p = 7 ρ 0 c 2 [ ( ρ 0 ρ ) 7 − 1 ]
で圧力に直す。密度が静止より小さい所では圧力を0にする。液体は引っ張りに耐えられないからだ。コードのpr[i]はp / ρ 2 を、静止の密度で割った値で持つ。
forcePassは、圧力の力
a i = − j ∑ m ( ρ i 2 p i + ρ j 2 p j ) ∇ W ij
を組ごとに1回だけ計算し、iには足し、jには引く。押す力と押し返す力が必ず等しくなるので、水全体の運動量は保たれる。k = (pi + pr[j]) * Kg * u3に粒の間の向き(dx、dy)を掛けたものが、括弧の中と∇ W の積にあたる。
表面張力は、水面にだけ働く力f = σ κ ∇ c で入れている。cは水の中で1、外で0になる「色」で、∇ c は水面で内側を向く。κ は水面の曲がりで、粒ごとの向きn ^ が隣とどれだけ違うかから求める(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は、速さを先に進め、新しい速さで位置を進める(シンプレクティック・オイラー法)。この順にすると、長く進めてもエネルギーが増えたり減ったりしにくい。粘りの式は、強い粘りでも計算が暴れないよう、新しい速さを周りの速さとの重み付きの平均として求める(陰的な解き方)。
v i 新 = 1 + ν Δ t ∑ j w ij v i + ν Δ t ∑ j w ij v j
速さには音の速さの0.6倍の上限を付け、壁にめり込んだ粒は戻し、計算が壊れた粒(NaN)は底に戻す。ビー玉と木の玉はbodiesStepで、粒から受けた圧力の反作用と重力で動かす。浮力は、ここから自然に出てくる。
落とす・つかむ・かき回す
画面に見えている空気の高さは数cmしかない。そこで物は画面の上から、落とす高さHから落ちてきたときの速さ2 g 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は、底からつながった水を塗りつぶし、つながっていないかたまりを「ちぎれた雫」として数える。くぼみの深さと柱の高さも、ここで同じ格子から測っている。柱の両側との高さの差を見て、広い盛り上がりは柱と呼ばないようにしている。