このシミュレーションは、1刻みごとに「回転Γ と渦度ξ を流れで運ぶ → 流れ関数ψ を解いて速さを出す」をくり返し、1コマごとに気圧を解いて漏斗雲の形を決める。ここまでは普通の数値計算で、three.jsは使わない。three.jsが受け持つのは、地面や雲の立体、影、視点である。そこへ計算した半径を頂点として渡し、砂ぼこりの粒はGPUの中で流れに乗せて運ぶ。
長さはメートル、格子は中心と地面で細かい
竜巻の大きさでは、外の縁が半径1,000 m、雲の底が高さ500 m、吸い上げる穴の半径r 0 が500 mである。旋風はこれをL倍、速さをU倍した相似形にする。
定数と単位(src/sims/tornado-vortex.ts の 26〜52 行) const NR = 64 , NZ = 40 , NI = NR + 1 , NJ = NZ + 1 , NN = NI * NJ; // 格子の数。画質を変えても同じ(同じ条件なら同じ竜巻になる) const BR = 3.2 , BZ = 2.6 ; // 格子の間隔の広がり方。中心の近くと地面の近くを細かくする const RHO = 1.2 , GRAV = 9.8 ; // 空気の密度 kg/m³・重力加速度 m/s² const TICK = 0.04 ; // 流れの計算の刻み(秒)。これを安定な長さにさらに分けて進める const SUBMAX = 12 ; // 1 刻みを分ける回数の上限 const SPINMS = 6 ; // 早送りに 1 コマで使ってよい時間(ミリ秒) const RH = 0.7 ; // 雲の穴のうち、流れの形を決めない内側の割合 const ZF = 80 , FNEAR = 0.2 ; // 高さ ZF より下で渦粘性を小さくする。地面での割合が FNEAR const Z_SFC = 30 ; // 「地上近くの風」を測る高さ(竜巻の大きさのとき。m) const S_MULTI = 2.4 ; // 小さな渦が現れるスワール比(想定の値) const JEF = [25 , 39 , 53 , 67 , 81 , 95 ]; // 日本版改良藤田スケールの各階級の下限 m/s const V_RECORD = 145 ; // これまでに竜巻で測られた風速(約 140 m/s)を超えたと知らせる値 m/s // 大きさごとの尺度。旋風は長さ 0.08 倍・速さ 0.2 倍の相似形 interface Size { L: number; U: number; cloud: boolean; wmax: number; vmax: number }const SIZES: Record<string, Size> = { tornado: { L: 1 , U: 1 , cloud: true , wmax: 40 , vmax: 60 }, dust: { L: 0.08 , U: 0.2 , cloud: false , wmax: 12 , vmax: 12 }, }; // 飛ばされる物。KD は抗力係数 × 面積 ÷ 質量(m²/kg)。自動車と鋼管は米国原子力規制委員会の指針の表の値。トタン板は想定の値 interface Kind { name: string; KD: number; size: [number, number, number] }const KINDS: Record<string, Kind> = { car: { name: "自動車" , KD: 0.007 , size: [5 , 1.3 , 2 ] }, pipe: { name: "鋼管" , KD: 0.0043 , size: [4.58 , 0.17 , 0.17 ] }, sheet: { name: "トタン板" , KD: 0.39 , size: [1.8 , 0.03 , 0.9 ] }, }; const VIS = 3 ; // 竜巻の大きさのとき、飛ばされる物を見やすいように描く倍率(計算には使わない) const MAXOBJ = 12 , TRAIL = 240 ;
格子の数は画質に関係なく同じ(NR×NZ)にした。画質で流れが変わると、本文の数字と合わなくなるからだ。格子の間隔はsinhの形で広げてあり、中心の近くは約4 m、地面の近くは約5 m、外の縁では約50 mになる。
回転と渦度を運び、流れ関数を解く
本文のΓ = r v に加えて、流れの面内の渦度を半径で割ったξ を未知数にする。ξ は中心でも発散しないので、割り算で困らない。高さによって回転が違うと遠心力に差が出て、縦の循環が生まれる。その項がratesのkind === 1の部分で、∂ ( Γ 2 / r 4 ) / ∂ z にあたる。
流れの計算(src/sims/tornado-vortex.ts の 54〜261 行) interface Flow { L: number; R: number; H: number; r0: number; r: Float64Array; z: Float64Array; ir: Float64Array; iz: Float64Array; // 格子の位置と、隣との間隔の逆数 rm: Float64Array; rp: Float64Array; r1: Float64Array; zm: Float64Array; zp: Float64Array; // 2 階微分と 1 階微分の係数 fn: Float64Array; // 高さごとの渦粘性の割合 G: Float64Array; X: Float64Array; P: Float64Array; U: Float64Array; W: Float64Array; // Γ・ξ・ψ・u・w G2: Float64Array; X2: Float64Array; dG: Float64Array; dX: Float64Array; NU: Float64Array; A: Float64Array; b: Float64Array; iH: number; // ψ を解く行列(分解済み)と右辺 Qs: number; Gam: number; nu: number; l2: number; numax: number; uScale: number; noslip: boolean; } const Q = (i: number, j: number) => i + j * NI;const NUK = NR - 1 , BW = NZ, W2 = 2 * BW + 1 , NU_ROWS = NUK * NZ; // ψ の未知数の並びと帯の幅 const row = (i: number, j: number) => (i - 1 ) * NZ + (j - 1 );const lidShape = (s: number) => { const q = Math.min(1 , s); return q * q * (2 - q * q); }; // 雲の底の穴を通る流量の分布 // ψ の方程式は格子の形だけで決まるので、1 回だけ分解して使い回す(大きさごと) const factored = new Map<number, { A: Float64Array; iH: number }>();function factor(F: Omit<Flow, "A" | "iH" >) { const got = factored.get(F.L); if (got) return got; const { r, z, rm, rp, r1, zm, zp, r0 } = F; let iH = 0 ; while (iH < NR && r[iH] < RH * r0) iH++; const A = new Float64Array(NU_ROWS * W2); const at = (a: number, b: number) => a * W2 + (b - a + BW); for (let i = 1 ; i < NR; i++) for (let j = 1 ; j <= NZ; j++) { const k = row(i, j); if (j === NZ && i >= iH) { A[at(k, k)] = 1 ; continue ; } // 雲の底の穴の外側: ψ を決める let cS: number, cN: number, cC: number; if (j < NZ) { cS = zm[j]; cN = zp[j]; cC = -(rm[i] + rp[i] + zm[j] + zp[j]); } else { const d = z[NZ] - z[NZ - 1 ]; cS = 2 / (d * d); cN = 0 ; cC = -(rm[i] + rp[i] + cS); } // 穴の内側: 上下の向きにだけ流れる(∂ψ/∂z = 0) A[at(k, k)] = cC; if (i > 1 ) A[at(k, row(i - 1 , j))] = rm[i] + r1[i]; if (i < NR - 1 ) A[at(k, row(i + 1 , j))] = rp[i] - r1[i]; if (j > 1 ) A[at(k, row(i, j - 1 ))] = cS; if (j < NZ) A[at(k, row(i, j + 1 ))] = cN; } for (let k = 0 ; k < NU_ROWS - 1 ; k++) { const piv = A[k * W2 + BW], mx = Math.min(BW, NU_ROWS - 1 - k); for (let m = 1 ; m <= mx; m++) { const rw = (k + m) * W2, l = A[rw + BW - m]; if (l === 0 ) continue ; const f = l / piv; A[rw + BW - m] = f; for (let n = 1 ; n <= mx; n++) A[rw + BW - m + n] -= f * A[k * W2 + BW + n]; } } const out = { A, iH }; factored.set(F.L, out); return out; } function makeFlow(L: number): Flow { const R = 1000 * L, H = 500 * L, r0 = 500 * L; const r = new Float64Array(NI), z = new Float64Array(NJ); for (let i = 0 ; i < NI; i++) r[i] = (R * Math.sinh((BR * i) / NR)) / Math.sinh(BR); for (let j = 0 ; j < NJ; j++) z[j] = (H * Math.sinh((BZ * j) / NZ)) / Math.sinh(BZ); const f64 = (n: number) => new Float64Array(n); const ir = f64(NI), iz = f64(NJ), rm = f64(NI), rp = f64(NI), r1 = f64(NI), zm = f64(NJ), zp = f64(NJ), fn = f64(NJ); for (let i = 1 ; i < NR; i++) { const a = r[i] - r[i - 1 ], b = r[i + 1 ] - r[i]; ir[i] = 2 / (a + b); rm[i] = 2 / (a * (a + b)); rp[i] = 2 / (b * (a + b)); r1[i] = 1 / (r[i] * (a + b)); } for (let j = 1 ; j < NZ; j++) { const a = z[j] - z[j - 1 ], b = z[j + 1 ] - z[j]; iz[j] = 2 / (a + b); zm[j] = 2 / (a * (a + b)); zp[j] = 2 / (b * (a + b)); } for (let j = 0 ; j < NJ; j++) fn[j] = FNEAR + (1 - FNEAR) * Math.min(1 , z[j] / (ZF * L)); const base = { L, R, H, r0, r, z, ir, iz, rm, rp, r1, zm, zp, fn, G: f64(NN), X: f64(NN), P: f64(NN), U: f64(NN), W: f64(NN), G2: f64(NN), X2: f64(NN), dG: f64(NN), dX: f64(NN), NU: f64(NN), b: f64(NU_ROWS), Qs: 0 , Gam: 0 , nu: 0 , l2: 0 , numax: 250 , uScale: 1 , noslip: true }; const { A, iH } = factor(base); return { ...base, A, iH }; } /** 読者のつまみの値を、流れの条件にする。Qs = 穴の流量 ÷ 2π = W r0² / 2、Γ∞ = r0 v0 */ function setForcing(F: Flow, W: number, v0: number, core: number, U: number) { const S = v0 / W, c = Math.min(6 , core * (1 + 1.5 * Math.max(0 , S - 0.75 ))); // 回転が強いほど(S が大きいほど)乱れも強い(想定の式) const old = F.Gam; F.Qs = (W * F.r0 * F.r0) / 2 ; F.Gam = F.r0 * v0; F.nu = (c * F.Gam) / 750 ; F.l2 = c * 0.6 * (v0 / U) * F.L * F.L; F.numax = 250 * F.L * U; F.uScale = U; // 渦粘性の上限と、速さの尺度 // 周りの回転を変えたら、外から伝わるのを待たずに全体の回転を同じ割合で変える if (old > 0 && old !== F.Gam) { const k = F.Gam / old; for (let q = 0 ; q < NN; q++) F.G[q] *= k; } } /** ψ を解き、u と w を求める。外の縁の ψ は高さに比例(一様に流れ込む)、雲の底の穴の外側の ψ は lidShape */ function solvePsi(F: Flow) { const { r, z, X, P, U, W, A, b, Qs, iH, r0, H, rp, r1 } = F; for (let i = 1 ; i < NR; i++) for (let j = 1 ; j <= NZ; j++) { const k = row(i, j), q = Q(i, j); if (j === NZ && i >= iH) { b[k] = -Qs * lidShape(r[i] / r0); continue ; } let v = r[i] * r[i] * X[q]; if (i === NR - 1 ) v -= (rp[i] - r1[i]) * (-Qs * z[j] / H); b[k] = v; } for (let k = 1 ; k < NU_ROWS; k++) { let s = b[k]; const c0 = Math.max(0 , k - BW), base = k * W2 + BW - k; for (let c = c0; c < k; c++) s -= A[base + c] * b[c]; b[k] = s; } for (let k = NU_ROWS - 1 ; k >= 0 ; k--) { let s = b[k]; const c1 = Math.min(NU_ROWS - 1 , k + BW), base = k * W2 + BW - k; for (let c = k + 1 ; c <= c1; c++) s -= A[base + c] * b[c]; b[k] = s / A[k * W2 + BW]; } for (let i = 1 ; i < NR; i++) for (let j = 1 ; j <= NZ; j++) P[Q(i, j)] = b[row(i, j)]; for (let j = 0 ; j <= NZ; j++) { P[Q(0 , j)] = 0 ; P[Q(NR, j)] = (-Qs * z[j]) / H; } for (let i = 0 ; i <= NR; i++) P[Q(i, 0 )] = 0 ; for (let i = iH; i <= NR; i++) P[Q(i, NZ)] = -Qs * lidShape(r[i] / r0); for (let j = 0 ; j <= NZ; j++) { const jm = Math.max(0 , j - 1 ), jp = Math.min(NZ, j + 1 ), dz = 1 / (z[jp] - z[jm]); for (let i = 1 ; i <= NR; i++) { const q = Q(i, j), ip = Math.min(NR, i + 1 ); U[q] = j === NZ ? 0 : ((P[Q(i, jp)] - P[Q(i, jm)]) * dz) / r[i]; W[q] = -(P[Q(ip, j)] - P[Q(i - 1 , j)]) / (r[ip] - r[i - 1 ]) / r[i]; } W[Q(0 , j)] = (-2 * P[Q(1 , j)]) / (r[1 ] * r[1 ]); U[Q(0 , j)] = 0 ; } for (let i = 0 ; i <= NR; i++) { const q = Q(i, 0 ); U[q] = 0 ; W[q] = 0 ; } } /** 境界の値を入れる */ function applyBc(F: Flow, G: Float64Array, X: Float64Array) { const { r, z, P, W, noslip, Gam, iH } = F; for (let j = 0 ; j <= NZ; j++) { G[Q(0 , j)] = 0 ; X[Q(0 , j)] = X[Q(1 , j)]; G[Q(NR, j)] = j === 0 && noslip ? 0 : Gam; X[Q(NR, j)] = 0 ; } const dz2 = z[1 ] * z[1 ]; for (let i = 1 ; i <= NR; i++) { const q0 = Q(i, 0 ); if (noslip) { G[q0] = 0 ; X[q0] = (2 * P[Q(i, 1 )]) / (r[i] * r[i] * dz2); } // 滑らない地面の渦度(トムの式) else { G[q0] = G[Q(i, 1 )]; X[q0] = 0 ; } } X[Q(0 , 0 )] = X[Q(1 , 0 )]; for (let i = 1 ; i < NR; i++) { const qt = Q(i, NZ), qb = Q(i, NZ - 1 ); G[qt] = G[qb]; X[qt] = i >= iH ? 0 : W[qt] > 0 ? X[qb] : 0 ; // 雲の底は滑る面。穴から入ってくる空気は渦度なし } X[Q(0 , NZ)] = X[Q(1 , NZ)]; } const lim = (a: number, b: number) => { const p = a * b; return p > 0 ? (2 * p) / (a + b) : 0 ; }; // 変化の急な所で振動しないようにする制限(van Leer) /** 乱れの強さ: ν + l² × 変形の速さ(混合距離の考え)。強い噴流ができるほど混ざりやすくなり、風速が際限なく上がらない */ function eddy(F: Flow) { const { r, ir, iz, G, U, W, NU, fn, nu, l2 } = F; for (let j = 1 ; j < NZ; j++) for (let i = 1 ; i < NR; i++) { const q = Q(i, j), ri = 1 / r[i], v = G[q] * ri; const ve = G[q + 1 ] / r[i + 1 ], vw = i > 1 ? G[q - 1 ] / r[i - 1 ] : 0 , vn = G[q + NI] * ri, vs = G[q - NI] * ri; const a = (ve - vw) * ir[i] * 0.5 - v * ri, b = (vn - vs) * iz[j] * 0.5 ; const c = (W[q + 1 ] - W[q - 1 ]) * ir[i] * 0.5 + (U[q + NI] - U[q - NI]) * iz[j] * 0.5 ; const d = (U[q + 1 ] - U[q - 1 ]) * ir[i] * 0.5 , e = (W[q + NI] - W[q - NI]) * iz[j] * 0.5 , f = U[q] * ri; NU[q] = Math.min(F.numax, nu * fn[j] + l2 * Math.sqrt(a * a + b * b + c * c + 2 * (d * d + e * e + f * f))); } } /** 1 つの量の変化の速さ: 運ぶ項(2 次の風上差分)+ 粘性の項。kind = 1 は ξ で、遠心力の差の項が足される */ function rates(F: Flow, f: Float64Array, out: Float64Array, kind: number, G: Float64Array) { const { r, ir, iz, U, W, rm, rp, r1, zm, zp, NU } = F; const c3 = kind === 1 ? 3 : -1 ; for (let j = 1 ; j < NZ; j++) { const izj = iz[j], zmj = zm[j], zpj = zp[j]; for (let i = 1 ; i < NR; i++) { const q = i + j * NI, c = f[q], w = f[q - 1 ], e = f[q + 1 ], s = f[q - NI], n = f[q + NI]; const a = U[q] * ir[i], b = W[q] * izj; let dr: number, dz: number; if (a > 0 ) { const ww = i > 1 ? f[q - 2 ] : w; dr = c - w + 0.5 * (lim(c - w, e - c) - lim(w - ww, c - w)); } else { const ee = i < NR - 1 ? f[q + 2 ] : e; dr = e - c - 0.5 * (lim(e - c, ee - e) - lim(c - w, e - c)); } if (b > 0 ) { const ss = j > 1 ? f[q - 2 * NI] : s; dz = c - s + 0.5 * (lim(c - s, n - c) - lim(s - ss, c - s)); } else { const nn = j < NZ - 1 ? f[q + 2 * NI] : n; dz = n - c - 0.5 * (lim(n - c, nn - n) - lim(c - s, n - c)); } let d = -(a * dr + b * dz) + NU[q] * (rm[i] * w + rp[i] * e - (rm[i] + rp[i]) * c + c3 * r1[i] * (e - w) + zmj * s + zpj * n - (zmj + zpj) * c); if (kind === 1 ) { const k = 1 / (r[i] * r[i]), on = G[q + NI] * k, os = G[q - NI] * k; d += (on * on - os * os) * izj * 0.5 ; } out[q] = d; } } } /** 短い時間 dt 進める(2 段のルンゲ・クッタ法)。そのあと ψ を解き直す */ function substep(F: Flow, dt: number) { const { G, X, G2, X2, dG, dX } = F; applyBc(F, G, X); eddy(F); rates(F, G, dG, 0 , G); rates(F, X, dX, 1 , G); G2.set(G); X2.set(X); for (let j = 1 ; j < NZ; j++) for (let q = 1 + j * NI, e = NR + j * NI; q < e; q++) { G2[q] = G[q] + dt * dG[q]; X2[q] = X[q] + dt * dX[q]; } applyBc(F, G2, X2); rates(F, G2, dG, 0 , G2); rates(F, X2, dX, 1 , G2); for (let j = 1 ; j < NZ; j++) for (let q = 1 + j * NI, e = NR + j * NI; q < e; q++) { G[q] = 0.5 * (G[q] + G2[q] + dt * dG[q]); X[q] = 0.5 * (X[q] + X2[q] + dt * dX[q]); } applyBc(F, G, X); solvePsi(F); } /** 流れを 1 刻み(TICK 秒)進める。速さと粘性から安定な長さを求めて、必要なら分ける */ function tick(F: Flow) { const { r, z, ir, iz, U, W, NU } = F; let rate = 1e-6 , numax = F.nu; for (let j = 1 ; j < NZ; j++) for (let i = 1 ; i < NR; i++) { const q = i + j * NI, a = Math.abs(U[q]) * ir[i] + Math.abs(W[q]) * iz[j]; if (a > rate) rate = a; if (NU[q] > numax) numax = NU[q]; } const dif = numax * (2 / (r[1 ] * r[1 ]) + 2 / (z[1 ] * z[1 ])); const n = Math.min(SUBMAX, Math.ceil(TICK * Math.max(rate / 0.5 , dif / 0.45 ))); const dt = TICK / n; for (let k = 0 ; k < n; k++) substep(F, dt); } /** 最初の状態: 中心に太めの回転があり、地面の近くだけ回転が弱い */ function initFlow(F: Flow) { const { r, z, G, X, Gam, L, noslip } = F; const rc = (noslip ? 80 : 110 ) * L, d0 = 12 * L; // 落ち着く形に近い太さから始めると、早送りが短くて済む for (let j = 0 ; j <= NZ; j++) for (let i = 0 ; i <= NR; i++) { const q = Q(i, j); G[q] = Gam * (1 - Math.exp(-(r[i] * r[i]) / (rc * rc))) * (noslip ? 1 - Math.exp(-z[j] / d0) : 1 ); X[q] = 0 ; } solvePsi(F); eddy(F); }
solvePsiはψ の方程式を解く部分である。行列は格子の形だけで決まるので、最初に1回だけLU分解してfactoredにしまい、毎回は前進代入と後退代入だけで済ませる。外の縁のψ を高さに比例させると空気が一様に流れ込み、雲の底の穴で出ていく。全体の流量はψ の差で決まるので、つまみのW がそのまま流量になる。
運ぶ項は風上の値に寄せた差分で、limが急な変化での振動を抑える。乱れの強さはeddyで求めている。基本の渦粘性に、混合距離l の2乗×流れの変形の速さを足す形で、噴流が強くなるほど混合が強まる。渦粘性は場所で変わるが、式の中の渦粘性の勾配の項は省いた。tickは速さと渦粘性から安定な刻みの長さを求め、TICK(0.04秒)を必要な回数に分ける。回数は流れの状態だけで決まるので、画面の速さで結果は変わらない。
気圧を解いて、漏斗雲の形を出す
本文の∂ p / ∂ r = ρ v 2 / r は、上下の動きが弱い所のつり合いである。上昇の噴流の中はそれでは足りないので、運動の式の発散をとった気圧の方程式を解く。右辺には遠心力の項と、流れの加速の項が入る。
気圧と漏斗雲(src/sims/tornado-vortex.ts の 263〜363 行) interface Derived { dp: Float64Array; // 気圧の低下 Pa(半径と高さごと) rf: Float64Array; rfs: Float64Array; // 漏斗雲の半径(高さごと)と、なめらかにした値 tip: number; // 漏斗雲の先の高さ m(雲の底のままなら H) vm: number; rm: number; zm: number; jm: number; // いちばん速い回転・その半径・その高さ・高さの番号 sfc: number; rs: number; sfcSpeed: Float64Array; // 地上近くの風の最大とその半径・半径ごとの値 wc: number; jc: number; // 中心の上下の流れ・測る高さの番号 dp0: number; dpm: number; zp: number; jsfc: number; pA: Float64Array; pb: Float64Array; // 地面の中心の Δp・中心でいちばん大きい Δp とその高さ } function makeDerived(F: Flow): Derived { let jsfc = 1 ; while (F.z[jsfc + 1 ] <= Z_SFC * F.L) jsfc++; // 格子の段のうち、Z_SFC の高さに最も近い段(約 29.9 m) let jc = 1 ; while (F.z[jc] < 0.4 * F.H) jc++; return { dp: new Float64Array(NN), rf: new Float64Array(NJ), rfs: new Float64Array(NJ), tip: F.H, vm: 0 , rm: 0 , zm: 0 , jm: 1 , sfc: 0 , rs: 0 , sfcSpeed: new Float64Array(NI), wc: 0 , jc, dp0: 0 , dpm: 0 , zp: 0 , jsfc, pA: factorP(F), pb: new Float64Array(PROWS) }; } // 気圧の方程式: 運動の式の発散をとると ∇²(p/ρ) = (1/r)∂(v²)/∂r − (u_r² + u²/r² + w_z² + 2 u_z w_r)。 // 右辺の 1 項目が回転の遠心力、残りが上昇や流れ込みの加速の分。外の縁を基準(p = 0)にして、地面と雲の底では上下の気圧の差を 0 とする const PBW = NZ + 1 , PW2 = 2 * PBW + 1 , PROWS = NR * NJ;const prow = (i: number, j: number) => i * NJ + j;const pfactored = new Map<number, Float64Array>();function factorP(F: Flow) { const got = pfactored.get(F.L); if (got) return got; const { r, z, rm, rp, r1, zm, zp } = F, A = new Float64Array(PROWS * PW2); const at = (a: number, b: number) => a * PW2 + (b - a + PBW); for (let i = 0 ; i < NR; i++) for (let j = 0 ; j <= NZ; j++) { const k = prow(i, j); let cC = 0 ; if (j === 0 ) { const c = 2 / (z[1 ] * z[1 ]); A[at(k, prow(i, 1 ))] = c; cC -= c; } else if (j === NZ) { const d = z[NZ] - z[NZ - 1 ], c = 2 / (d * d); A[at(k, prow(i, NZ - 1 ))] = c; cC -= c; } else { A[at(k, prow(i, j - 1 ))] = zm[j]; A[at(k, prow(i, j + 1 ))] = zp[j]; cC -= zm[j] + zp[j]; } if (i === 0 ) { const c = 4 / (r[1 ] * r[1 ]); A[at(k, prow(1 , j))] = c; cC -= c; } // 中心では ∂p/∂r = 0 else { A[at(k, prow(i - 1 , j))] = rm[i] - r1[i]; if (i < NR - 1 ) A[at(k, prow(i + 1 , j))] = rp[i] + r1[i]; cC -= rm[i] + rp[i]; } A[at(k, k)] = cC; } for (let k = 0 ; k < PROWS - 1 ; k++) { const piv = A[k * PW2 + PBW], mx = Math.min(PBW, PROWS - 1 - k); for (let m = 1 ; m <= mx; m++) { const rw = (k + m) * PW2, l = A[rw + PBW - m]; if (l === 0 ) continue ; const f = l / piv; A[rw + PBW - m] = f; for (let n = 1 ; n <= mx; n++) A[rw + PBW - m + n] -= f * A[k * PW2 + PBW + n]; } } pfactored.set(F.L, A); return A; } /** 流れの場から気圧の低下 Δp(Pa)を求める */ function solvePressure(F: Flow, D: Derived) { const { r, z, G, U, W, ir, iz } = F, A = D.pA, b = D.pb, dp = D.dp; for (let i = 0 ; i < NR; i++) for (let j = 0 ; j <= NZ; j++) { const q = Q(i, j), jm = Math.max(0 , j - 1 ), jp = Math.min(NZ, j + 1 ), dzi = 1 / (z[jp] - z[jm]); const wz = (W[Q(i, jp)] - W[Q(i, jm)]) * dzi; let v = 0 ; if (i === 0 ) { const om = G[Q(1 , j)] / (r[1 ] * r[1 ]); v = 2 * om * om - 1.5 * wz * wz; } else { const ve = G[q + 1 ] / r[i + 1 ], vw = i > 1 ? G[q - 1 ] / r[i - 1 ] : 0 , ri = 1 / r[i]; const v2r = (ve * ve - vw * vw) * ir[i] * 0.5 * ri; const ur = (U[q + 1 ] - U[q - 1 ]) * ir[i] * 0.5 , wr = (W[q + 1 ] - W[q - 1 ]) * ir[i] * 0.5 , uz = (U[Q(i, jp)] - U[Q(i, jm)]) * dzi, ou = U[q] * ri; v = v2r - (ur * ur + ou * ou + wz * wz + 2 * uz * wr); } b[prow(i, j)] = v; } for (let k = 1 ; k < PROWS; k++) { let s = b[k]; const c0 = Math.max(0 , k - PBW), base = k * PW2 + PBW - k; for (let c = c0; c < k; c++) s -= A[base + c] * b[c]; b[k] = s; } for (let k = PROWS - 1 ; k >= 0 ; k--) { let s = b[k]; const c1 = Math.min(PROWS - 1 , k + PBW), base = k * PW2 + PBW - k; for (let c = k + 1 ; c <= c1; c++) s -= A[base + c] * b[c]; b[k] = s / A[k * PW2 + PBW]; } for (let i = 0 ; i < NR; i++) for (let j = 0 ; j <= NZ; j++) dp[Q(i, j)] = -RHO * b[prow(i, j)]; for (let j = 0 ; j <= NZ; j++) dp[Q(NR, j)] = 0 ; } /** 気圧の低下・いちばん速い回転・漏斗雲の形を求める */ function derive(F: Flow, D: Derived, cloud: boolean) { const { r, z, G, U, W, H } = F, dp = D.dp; solvePressure(F, D); let vm = 0 , im = 1 , jm = 1 ; for (let j = 1 ; j < NZ; j++) { if (z[j] > 0.85 * H) break ; for (let i = 1 ; i < NR; i++) { const v = G[Q(i, j)] / r[i]; if (v > vm) { vm = v; im = i; jm = j; } } } D.vm = vm; D.rm = r[im]; D.zm = z[jm]; D.jm = jm; let sm = 0 , sr = 0 ; for (let i = 1 ; i <= NR; i++) { const q = Q(i, D.jsfc), v = G[q] / r[i], u = U[q], sp = Math.sqrt(v * v + u * u); D.sfcSpeed[i] = sp; if (sp > sm) { sm = sp; sr = r[i]; } } D.sfc = sm; D.rs = sr; // 中心の流れ: 途中に下降があれば、その中でいちばん速い下降を、なければ雲の底の 4 割の高さの上昇を測る let wlow = 0 ; for (let j = 1 ; j < NZ; j++) if (z[j] > 0.1 * H && z[j] < 0.85 * H && W[Q(0 , j)] < wlow) wlow = W[Q(0 , j)]; D.wc = wlow < -3 * F.uScale ? wlow : W[Q(0 , D.jc)]; D.dp0 = dp[Q(0 , 0 )]; let dpm = 0 , zp = 0 ; for (let j = 0 ; j <= NZ; j++) { const d = dp[Q(0 , j)]; if (d > dpm) { dpm = d; zp = z[j]; } } D.dpm = dpm; D.zp = zp; let tip = H; for (let j = 0 ; j <= NZ; j++) { const need = RHO * GRAV * (H - z[j]); let rr = 0 ; if (cloud) { // 外から中心へ調べて、初めて雲になる半径 let i = NR; while (i > 0 && dp[Q(i, j)] <= need) i--; if (i > 0 || dp[Q(0 , j)] > need) { const a = dp[Q(i, j)], b = dp[Q(Math.min(NR, i + 1 ), j)]; rr = r[i] + (i < NR ? ((a - need) / Math.max(1e-6 , a - b)) * (r[i + 1 ] - r[i]) : 0 ); } } D.rf[j] = 280 * F.L * Math.tanh(rr / (280 * F.L)); // 雲の底のすぐ下で広がりすぎないように、なめらかに抑える } if (cloud) for (let j = 0 ; j <= NZ; j++) if (D.rf[j] > 0 ) { tip = z[j]; break ; } D.tip = tip; }
地面の中心のΔ p が上空より小さく出るのは、中心の空気が地面から上へ加速する分を、右辺の流れの加速の項が受け持つからである。摩擦層の厚いこの計算では、この差が本物より大きく出ているとみられる。deriveは高さごとに外から中心へ調べ、Δ p が初めてρ g ( H − z ) を超える半径を漏斗雲の半径にする。雲の底のすぐ下では半径が広がりすぎるので、tanhでなめらかに抑えた。
砂ぼこりはGPUの中で運ぶ
粒の位置( r , θ , z ) と残り時間を、1粒1画素のテクスチャに入れる。流れのu 、w 、角速度Γ/ r 2 は別のテクスチャに写し、毎コマ「前の位置を読む → 次の位置を書く」を全部の粒で計算する。
粒の運び(src/sims/tornado-vortex.ts の 365〜577 行) // 砂ぼこりの粒を GPU で運ぶ。粒の位置(r, θ, z, 残り時間)を浮動小数点のテクスチャに持ち、1 コマごとに次の位置を計算して書き込む const FLOW_GLSL = /* glsl */ ` uniform sampler2D tFlow; uniform vec4 uGrid; // R, H, NR, NZ uniform vec4 uMap; // sinh(BR)/R, sinh(BZ)/H, BR, BZ vec4 flowAt(float r, float z) { float s = asinh(r * uMap.x) / uMap.z, t = asinh(max(z, 0.0) * uMap.y) / uMap.w; return texture(tFlow, (vec2(s * uGrid.z, t * uGrid.w) + 0.5) / vec2(uGrid.z + 1.0, uGrid.w + 1.0)); }` ;const UPDATE_VS = /* glsl */ ` void main() { gl_Position = vec4(position.xy, 0.0, 1.0); }` ;const UPDATE_FS = /* glsl */ ` precision highp float; precision highp sampler2D; ${FLOW_GLSL} uniform sampler2D tPos; uniform float uDt, uL, uCore, uReset, uSatR, uSatV, uInjR; uniform int uSide, uFrame, uNs, uInjA, uInjB; uniform vec3 uInj; uniform vec3 uSat[6]; // 小さな渦の角度・ねじれ・輪の半径 out vec4 o; const float TAU_ = 6.28318530718; uvec4 pcg(uvec4 v) { v = v * 1664525u + 1013904223u; v.x += v.y * v.w; v.y += v.z * v.x; v.z += v.x * v.y; v.w += v.y * v.z; v ^= v >> 16u; v.x += v.y * v.w; v.y += v.z * v.x; v.z += v.x * v.y; v.w += v.y * v.z; return v; } vec2 satAt(int k, float z) { float a = uSat[k].x + uSat[k].y * z; return uSat[k].z * vec2(cos(a), sin(a)); } vec4 spawn(int role, vec4 u) { float R = uGrid.x, H = uGrid.y; if (role == 0) return vec4(0.95 * R * sqrt(u.x), TAU_ * u.y, 0.97 * H * u.z, 30.0 + 60.0 * u.w); // 周りの空気(全体に薄く) if (role == 1) { float k = floor(u.y * 14.0); return vec4(min(0.95 * R, uCore * 3.2) * (1.0 + 0.05 * u.x), TAU_ * k / 14.0 + 0.02 * u.w, uL * (2.0 + 2.0 * u.z), 40.0 + 20.0 * u.w); } // 同じ場所から出る筋 if (role == 2 && uNs > 0) { int k = int(u.y * float(uNs)) % uNs; vec2 c = satAt(k, 0.0) + (u.xz - 0.5) * uSatR * 3.0; return vec4(length(c), atan(c.y, c.x), uL * (1.0 + 6.0 * u.w), 12.0 + 18.0 * u.w); } // 小さな渦の足もと return vec4(min(0.95 * R, uCore * mix(0.6, 7.0, u.x * u.x)), TAU_ * u.y, uL * (1.5 + 8.0 * u.z), 25.0 + 35.0 * u.w); // 地面の砂ぼこり } void main() { ivec2 ij = ivec2(gl_FragCoord.xy); int idx = ij.x + ij.y * uSide; vec4 p = texelFetch(tPos, ij, 0); vec4 u = vec4(pcg(uvec4(uint(idx), uint(uFrame), uint(ij.x), uint(ij.y)))) / 4294967296.0; int role = idx % 8; if (idx >= uInjA && idx < uInjB) { vec2 c = uInj.xy + (u.xy - 0.5) * uInjR; o = vec4(length(c), atan(c.y, c.x), uL * (1.0 + 10.0 * u.z), 30.0 + 30.0 * u.w); return; } if (uReset > 0.5) { o = spawn(role, u); o.w *= fract(u.x + u.y); return; } float R = uGrid.x, H = uGrid.y; if (p.w <= 0.0 || p.x >= R || p.z >= H * 0.995) { o = spawn(role, u); return; } if (role != 0 && p.z > 0.2 * H && p.x > 2.2 * uCore) { o = spawn(role, u); return; } // 芯から離れた上空には、砂ぼこりは上がってこない float r = p.x, th = p.y, z = p.z; // 中点法: 半分進めた所の流れで、全部進める vec4 a = flowAt(r, z); float rh = max(r + 0.5 * uDt * a.x, 0.0), zh = z + 0.5 * uDt * a.y; vec4 b = flowAt(rh, zh); float dr = b.x, dz = b.y, dth = b.z; if (uNs > 0) { // 小さな渦が作る流れを足す vec2 q = r * vec2(cos(th), sin(th)), ad = vec2(0.0); float az = 0.0, rs2 = uSatR * uSatR; for (int k = 0; k < uNs; k++) { vec2 d = q - satAt(k, z); float d2 = dot(d, d) + 1e-3 * rs2; float kv = 1.4 * uSatV * uSatR / d2 * (1.0 - exp(-1.26 * d2 / rs2)); ad += kv * vec2(-d.y, d.x) - 0.25 * kv * d * exp(-z / (0.2 * H)); az += 0.9 * uSatV * exp(-d2 / (2.2 * rs2)); } float c = cos(th), s = sin(th); dr += ad.x * c + ad.y * s; dth += (-ad.x * s + ad.y * c) / max(r, uSatR * 0.2); dz += az; } r = max(r + uDt * dr, 0.0); z = max(z + uDt * dz, 0.4 * uL); th = mod(th + uDt * dth, TAU_); o = vec4(r, th, z, p.w - uDt); }` ;const POINT_VS = /* glsl */ ` precision highp float; precision highp sampler2D; ${FLOW_GLSL} uniform sampler2D tPos; uniform float uSide, uPx, uDeckY, uDeckD, uDeckW, uSparse, uFar; uniform int uCloud; void main() { int id = gl_VertexID, side = int(uSide); vec4 p = texelFetch(tPos, ivec2(id % side, id / side), 0); vec3 P = vec3(p.x * cos(p.y), p.z, -p.x * sin(p.y)); float h = fract(float(id) * 0.61803398875), keep = smoothstep(0.0, 2.0, p.w) * uSparse; float H = uGrid.y; if (uCloud == 1 && cameraPosition.y < uDeckY) { if (p.z > uDeckY - uDeckD * exp(-p.x * p.x / (uDeckW * uDeckW))) keep = 0.0; } // 雲の中の粒は見えない else keep *= 1.0 - smoothstep(0.7 * H, H, p.z); // 雲のない旋風は、上へ行くほど薄くなる if (id % 8 == 0) keep *= 0.6; // 漏斗雲の中の粒は、雲に隠れて上ほど見えない float rfz = flowAt(0.0, p.z).a; if (p.x < rfz) keep *= 1.0 - smoothstep(0.1 * H, 0.42 * H, p.z); // 漏斗雲の後ろにある粒は描かない(その高さの漏斗雲を縦の円柱とみなして、視線と交わるかを調べる) vec2 C = cameraPosition.xz, D = P.xz - C; float t = -dot(C, D) / max(dot(D, D), 1e-6); if (t > 0.0 && t < 1.0) { float y = mix(cameraPosition.y, P.y, t), rf = flowAt(0.0, y).a; if (rf > 0.0 && length(C + t * D) < rf) keep = 0.0; } vec4 mv = viewMatrix * vec4(P, 1.0); keep *= 1.0 - smoothstep(0.5 * uFar, uFar, -mv.z); gl_Position = h < keep ? projectionMatrix * mv : vec4(2.0, 2.0, 2.0, 1.0); gl_PointSize = uPx * clamp(uFar * 0.28 / max(-mv.z, 1.0), 1.0, 2.6); }` ;const POINT_FS = /* glsl */ ` precision highp float; uniform vec3 uInk; out vec4 o; void main() { o = vec4(uInk, 1.0); }` ; interface Parts { side: number; n: number; gpu: boolean; cursor: number; frame: number; acc: number; first: boolean; a: THREE.WebGLRenderTarget | null ; b: THREE.WebGLRenderTarget | null ; cpu: Float32Array | null ; cpuTex: THREE.DataTexture | null ; rng: () => number; upd: THREE.ShaderMaterial | null ; updScene: THREE.Scene | null ; updCam: THREE.OrthographicCamera | null ; mat: THREE.ShaderMaterial; scene: THREE.Scene; geo: THREE.BufferGeometry; quad: THREE.Mesh | null ; injA: number; injB: number; inj: [number, number]; } /** 粒の数: 画質 高 20 万・中 6.5 万。画質 低か、浮動小数点のテクスチャに書き込めない端末では CPU で 8 千〜1.6 万個を運ぶ */ function buildParts(api: Api, s: S): Parts { const F = s.F, gl = api.gl!; const canFloat = !!gl.getExtension("EXT_color_buffer_float" ); const gpu = canFloat && api.quality > 0 ; const side = gpu ? (api.quality === 2 ? 448 : 256 ) : api.quality === 0 ? 90 : 128 ; const n = side * side; const common = { tFlow: { value: s.flowTex }, tPos: { value: null as THREE.Texture | null }, uGrid: { value: new THREE.Vector4(F.R, F.H, NR, NZ) }, uMap: { value: new THREE.Vector4(Math.sinh(BR) / F.R, Math.sinh(BZ) / F.H, BR, BZ) }, }; const mat = new THREE.ShaderMaterial({ glslVersion: THREE.GLSL3, vertexShader: POINT_VS, fragmentShader: POINT_FS, depthTest: false , depthWrite: false , uniforms: { ...common, uSide: { value: side }, uPx: { value: 1 }, uDeckY: { value: F.H }, uDeckD: { value: 70 * F.L }, uDeckW: { value: 260 * F.L }, uSparse: { value: 1 }, uFar: { value: 3000 * F.L }, uCloud: { value: s.size.cloud ? 1 : 0 }, uInk: { value: new THREE.Vector3() } }, }); const geo = new THREE.BufferGeometry(); geo.setAttribute("position" , new THREE.BufferAttribute(new Float32Array(n * 3 ), 3 )); const pts = new THREE.Points(geo, mat); pts.frustumCulled = false ; const scene = new THREE.Scene(); scene.add(pts); const p: Parts = { side, n, gpu, cursor: 0 , frame: 0 , acc: 0 , first: true , a: null , b: null , cpu: null , cpuTex: null , rng: api.rng(7 ), upd: null , updScene: null , updCam: null , mat, scene, geo, quad: null , injA: 0 , injB: 0 , inj: [0 , 0 ] }; if (gpu) { const opt = { type : THREE.FloatType, format: THREE.RGBAFormat, minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter, depthBuffer: false , stencilBuffer: false , generateMipmaps: false }; p.a = new THREE.WebGLRenderTarget(side, side, opt); p.b = new THREE.WebGLRenderTarget(side, side, opt); const sats = Array.from ({ length: 6 }, () => new THREE.Vector3()); p.upd = new THREE.ShaderMaterial({ glslVersion: THREE.GLSL3, vertexShader: UPDATE_VS, fragmentShader: UPDATE_FS, depthTest: false , depthWrite: false , uniforms: { ...common, uDt: { value: 0 }, uL: { value: F.L }, uCore: { value: 50 * F.L }, uReset: { value: 1 }, uSatR: { value: 1 }, uSatV: { value: 0 }, uInjR: { value: 24 * F.L }, uSide: { value: side }, uFrame: { value: 0 }, uNs: { value: 0 }, uInjA: { value: 0 }, uInjB: { value: 0 }, uInj: { value: new THREE.Vector3() }, uSat: { value: sats } }, }); p.quad = new THREE.Mesh(new THREE.PlaneGeometry(2 , 2 ), p.upd); p.quad.frustumCulled = false ; p.updScene = new THREE.Scene(); p.updScene.add(p.quad); p.updCam = new THREE.OrthographicCamera(-1 , 1 , 1 , -1 , 0 , 1 ); } else { p.cpu = new Float32Array(n * 4 ); p.cpuTex = new THREE.DataTexture(p.cpu, side, side, THREE.RGBAFormat, THREE.FloatType); p.cpuTex.minFilter = THREE.NearestFilter; p.cpuTex.magFilter = THREE.NearestFilter; p.cpuTex.needsUpdate = true ; mat.uniforms.tPos.value = p.cpuTex; for (let i = 0 ; i < n; i++) cpuSpawn(p, s, i, true ); } return p; } function disposeParts(p: Parts) { p.a?.dispose(); p.b?.dispose(); p.cpuTex?.dispose(); p.upd?.dispose(); p.quad?.geometry.dispose(); p.geo.dispose(); p.mat.dispose(); } /** CPU で運ぶときの、粒を出す場所(GPU の spawn と同じ決まり) */ function cpuSpawn(p: Parts, s: S, i: number, reset: boolean) { const P = p.cpu!, F = s.F, rn = p.rng, k = i * 4 , role = i % 8 , core = s.D.rs || 50 * F.L; let r: number, th: number, z: number, life: number; if (role === 0 ) { r = 0.95 * F.R * Math.sqrt(rn()); th = TAU * rn(); z = 0.97 * F.H * rn(); life = 30 + 60 * rn(); } else if (role === 1 ) { r = Math.min(0.95 * F.R, core * 3.2 ) * (1 + 0.05 * rn()); th = (TAU * Math.floor(rn() * 14 )) / 14 ; z = F.L * (2 + 2 * rn()); life = 40 + 20 * rn(); } else if (role === 2 && s.sat.n > 0 ) { const a = s.sat.a[Math.floor(rn() * s.sat.n)], x = s.sat.ring * Math.cos(a) + (rn() - 0.5 ) * s.sat.rs * 3 , y = s.sat.ring * Math.sin(a) + (rn() - 0.5 ) * s.sat.rs * 3 ; r = Math.hypot(x, y); th = Math.atan2(y, x); z = F.L * (1 + 6 * rn()); life = 12 + 18 * rn(); } else { const u = rn(); r = Math.min(0.95 * F.R, core * (0.6 + 6.4 * u * u)); th = TAU * rn(); z = F.L * (1.5 + 8 * rn()); life = 25 + 35 * rn(); } P[k] = r; P[k + 1 ] = th; P[k + 2 ] = z; P[k + 3 ] = reset ? life * rn() : life; } /** 1 回分の粒の移動。GPU なら描画先を入れ替えて全部の粒を計算し、CPU なら配列を更新する */ function moveParts(api: Api, s: S, dt: number) { const p = s.parts, F = s.F, D = s.D, sat = s.sat; if (p.gpu) { const u = p.upd!.uniforms, r = s.v.renderer; u.tPos.value = p.a!.texture; u.uDt.value = dt; u.uReset.value = p.first ? 1 : 0 ; u.uFrame.value = ++p.frame; u.uCore.value = D.rs || 50 * F.L; u.uNs.value = sat.n; u.uSatR.value = sat.rs; u.uSatV.value = sat.vs; for (let k = 0 ; k < sat.n; k++) (u.uSat.value as THREE.Vector3[])[k].set(sat.a[k], sat.tw, sat.ring); u.uInjA.value = p.injA; u.uInjB.value = p.injB; (u.uInj.value as THREE.Vector3).set(p.inj[0 ], p.inj[1 ], 0 ); r.setRenderTarget(p.b); r.render(p.updScene!, p.updCam!); r.setRenderTarget(null ); const t = p.a; p.a = p.b; p.b = t; p.mat.uniforms.tPos.value = p.a!.texture; } else { const P = p.cpu!, ve = s.vel; for (let i = 0 ; i < p.n; i++) { const k = i * 4 ; if (i >= p.injA && i < p.injB) { const x = p.inj[0 ] + (p.rng() - 0.5 ) * 24 * F.L, y = p.inj[1 ] + (p.rng() - 0.5 ) * 24 * F.L; P[k] = Math.hypot(x, y); P[k + 1 ] = Math.atan2(y, x); P[k + 2 ] = F.L * (1 + 10 * p.rng()); P[k + 3 ] = 30 + 30 * p.rng(); continue ; } if (P[k + 3 ] <= 0 || P[k] >= F.R || P[k + 2 ] >= F.H * 0.995 || (i % 8 !== 0 && P[k + 2 ] > 0.2 * F.H && P[k] > 2.2 * (s.D.rs || 50 * F.L))) { cpuSpawn(p, s, i, false ); continue ; } const r = P[k], th = P[k + 1 ], z = P[k + 2 ]; flowPolar(F, r, z, ve); const rh = Math.max(0 , r + 0.5 * dt * ve[0 ]), zh = z + 0.5 * dt * ve[1 ]; flowPolar(F, rh, zh, ve); P[k] = Math.max(0 , r + dt * ve[0 ]); P[k + 2 ] = Math.max(0.4 * F.L, z + dt * ve[1 ]); P[k + 1 ] = (th + dt * ve[2 ]) % TAU; P[k + 3 ] -= dt; } p.cpuTex!.needsUpdate = true ; } p.injA = 0 ; p.injB = 0 ; p.first = false ; } /** 半径と高さから、u・w・角速度を格子の補間で読む */ function flowPolar(F: Flow, r: number, z: number, out: number[]) { const fi = Math.min(NR - 1e-3 , (Math.asinh((r * Math.sinh(BR)) / F.R) / BR) * NR), fj = Math.min(NZ - 1e-3 , (Math.asinh((Math.max(0 , z) * Math.sinh(BZ)) / F.H) / BZ) * NZ); const i = Math.floor(fi), j = Math.floor(fj), a = fi - i, b = fj - j, q = i + j * NI; const w00 = (1 - a) * (1 - b), w10 = a * (1 - b), w01 = (1 - a) * b, w11 = a * b; out[0 ] = w00 * F.U[q] + w10 * F.U[q + 1 ] + w01 * F.U[q + NI] + w11 * F.U[q + NI + 1 ]; out[1 ] = w00 * F.W[q] + w10 * F.W[q + 1 ] + w01 * F.W[q + NI] + w11 * F.W[q + NI + 1 ]; const g = w00 * F.G[q] + w10 * F.G[q + 1 ] + w01 * F.G[q + NI] + w11 * F.G[q + NI + 1 ]; // 回転の速さ v = Γ/r は中心で 0 に近づくので、角速度は格子の Γ/r² を補間する const i0 = Math.max(1 , i), i1 = i + 1 , ri0 = 1 / (F.r[i0] * F.r[i0]), ri1 = 1 / (F.r[i1] * F.r[i1]); out[2 ] = w00 * F.G[i0 + j * NI] * ri0 + w10 * F.G[i1 + j * NI] * ri1 + w01 * F.G[i0 + (j + 1 ) * NI] * ri0 + w11 * F.G[i1 + (j + 1 ) * NI] * ri1; out[3 ] = g; } /** 流れと漏斗雲の半径をテクスチャに写す(GPU の粒が読む) */ function uploadFlow(s: S) { const F = s.F, D = s.D, a = s.flowData, occ = s.sect || s.outline ? 0 : 1 ; for (let j = 0 ; j < NJ; j++) { const rfj = (D.rfs[j] || 0 ) * occ; for (let i = 0 ; i < NI; i++) { const q = i + j * NI, k = q * 4 , ii = Math.max(1 , i); a[k] = F.U[q]; a[k + 1 ] = F.W[q]; a[k + 2 ] = F.G[ii + j * NI] / (F.r[ii] * F.r[ii]); a[k + 3 ] = rfj; } } s.flowTex.needsUpdate = true ; }
積分は中点法である。半分進めた所の流れで全体を進めると、回転の速い中心でも粒が外へ逃げにくい。座標を円柱のままにしたのは、θ を角速度で進めればr が変わらないからだ。粒のうち8分の1は周りの空気で、残りは地面の近くから放つ。同じ場所から放つ筋を混ぜると、砂ぼこりの道が線になって見える。古い端末や画質「低」では、同じ決まりをCPUで計算し、粒の数を減らす。
粒を描くPOINT_VSは、白黒の変換が終わったあとの画面へ直接点を打つ。変換の前に描くと、輪郭線の処理で点が消えるからだ。奥行きの情報が使えないので、漏斗雲の後ろの粒は、視線が漏斗雲の半径の内側を通るかどうかで隠している。
物には抗力と重力を
物の運動は、本文にあるC D A / m をKDとして、抗力と重力だけで計算する。
飛ばされる物(src/sims/tornado-vortex.ts の 579〜644 行) interface Obj { kind: Kind; x: number; y: number; z: number; vx: number; vy: number; vz: number; // 位置と速度(x, y は水平・z は高さ) m: THREE.Mesh; line: THREE.Line; pos: Float32Array; n: number; acc: number; vmax: number; zmax: number; rx: number; ry: number; rz: number; lifted: boolean; gone: boolean; } const OBJ_MU = 0.6 ; // 地面との摩擦係数(想定の値) const OBJ_Z = Z_SFC; // 物に当たる風を読む最も低い高さ m(計算の摩擦層は実物より厚いので、「地上近くの風」と同じ高さの風を使う) /** 水平の位置と高さの風(x, y, z の向き)を読む。小さな渦の流れも足す */ function windAt(s: S, x: number, y: number, z: number, out: number[]) { const F = s.F, r = Math.hypot(x, y), c = r > 1e-6 ? x / r : 1 , sn = r > 1e-6 ? y / r : 0 , ve = s.vel; flowPolar(F, r, Math.max(z, OBJ_Z * F.L), ve); const v = ve[3 ] / Math.max(r, F.r[1 ]); out[0 ] = ve[0 ] * c - v * sn; out[1 ] = ve[0 ] * sn + v * c; out[2 ] = ve[1 ]; const sat = s.sat; if (sat.n) { const rs2 = sat.rs * sat.rs; for (let k = 0 ; k < sat.n; k++) { const a = sat.a[k] + sat.tw * z, dx = x - sat.ring * Math.cos(a), dy = y - sat.ring * Math.sin(a), d2 = dx * dx + dy * dy + 1e-3 * rs2; const kv = ((1.4 * sat.vs * sat.rs) / d2) * (1 - Math.exp((-1.26 * d2) / rs2)); out[0 ] += -kv * dy - 0.25 * kv * dx * Math.exp(-z / (0.2 * F.H)); out[1 ] += kv * dx - 0.25 * kv * dy * Math.exp(-z / (0.2 * F.H)); out[2 ] += 0.9 * sat.vs * Math.exp(-d2 / (2.2 * rs2)); } } } /** 風に引かれる物の運動: 加速度 = ½ρ(C_D A/m)|V_w − V|(V_w − V) − g。抗力の項は陰に解くので、軽い物でも刻みで発散しない */ function stepObj(api: Api, s: S, o: Obj, h: number) { if (o.gone) return ; const w = s.wind; windAt(s, o.x, o.y, o.z, w); const dx = w[0 ] - o.vx, dy = w[1 ] - o.vy, dz = w[2 ] - o.vz, sp = Math.sqrt(dx * dx + dy * dy + dz * dz); const rate = 0.5 * RHO * o.kind.KD * sp, f = (rate * h) / (1 + rate * h); o.vx += dx * f; o.vy += dy * f; o.vz += dz * f - GRAV * h; o.x += o.vx * h; o.y += o.vy * h; o.z += o.vz * h; if (o.z <= 0 ) { o.z = 0 ; if (o.vz < -3 ) o.vz *= -0.25 ; else if (o.vz < 0 ) o.vz = 0 ; // 地面の上では摩擦で止まろうとする。摩擦より風の力が弱ければ動かない const hs = Math.hypot(o.vx, o.vy), dv = OBJ_MU * GRAV * h; if (hs <= dv) { o.vx = 0 ; o.vy = 0 ; } else { o.vx -= (o.vx / hs) * dv; o.vy -= (o.vy / hs) * dv; } } else if (o.z > 8 * s.F.L && !o.lifted) { o.lifted = true ; api.note(` ${o.kind.name}が浮き上がりました` ); } // 雲の底より上や、計算の範囲の外の流れは解いていないので、そこで追うのをやめる if (o.z > s.F.H * 0.92 || Math.hypot(o.x, o.y) > s.F.R * 0.98 ) { o.gone = true ; o.m.visible = false ; api.note(o.z > s.F.H * 0.9 ? ` ${o.kind.name}が雲の中へ入りました` : ` ${o.kind.name}が計算の範囲の外へ出ました` ); } const v = Math.sqrt(o.vx * o.vx + o.vy * o.vy + o.vz * o.vz); if (v > o.vmax) o.vmax = v; if (o.z > o.zmax) o.zmax = o.z; o.acc += h; if (o.acc >= 0.1 && o.n < TRAIL) { o.acc = 0 ; const k = o.n * 3 ; o.pos[k] = o.x; o.pos[k + 1 ] = Math.max(0.2 , o.z); o.pos[k + 2 ] = -o.y; o.n++; (o.line.geometry as THREE.BufferGeometry).setDrawRange(0 , o.n); (o.line.geometry.attributes.position as THREE.BufferAttribute).needsUpdate = true ; } } function dropObj(s: S, key: string, x: number, y: number, z = 3 ) { const kind = KINDS[key], F = s.F; if (!kind) return ; if (s.objs.length >= MAXOBJ) { const old = s.objs.shift()!; s.v.scene.remove(old.m, old.line); old.line.geometry.dispose(); old.m.geometry.dispose(); } const vis = s.size.L >= 1 ? VIS : 1 ; const m = new THREE.Mesh(new THREE.BoxGeometry(kind.size[0 ] * vis, kind.size[1 ] * vis, kind.size[2 ] * vis), s.objMat[kind === KINDS.pipe ? 1 : 0 ]); m.castShadow = true ; s.v.scene.add(m); const pos = new Float32Array(TRAIL * 3 ), g = new THREE.BufferGeometry(); g.setAttribute("position" , new THREE.BufferAttribute(pos, 3 )); g.setDrawRange(0 , 0 ); const line = new THREE.Line(g, s.lineMat); line.frustumCulled = false ; s.v.scene.add(line); const rn = s.rng; s.objs.push({ kind, x, y, z: z * F.L + 0.01 , vx: 0 , vy: 0 , vz: 0 , m, line, pos, n: 0 , acc: 0 , vmax: 0 , zmax: 0 , rx: rn(), ry: rn(), rz: rn(), lifted: false , gone: false }); }
風の中の加速度は2 1 ρ K ∣ V w − V ∣ ( V w − V ) − g (K がKD)で、軽い物ほど速さの差がすぐ縮まる。トタン板のような軽い物では、普通に足していくと刻みの中で行き過ぎて発散するため、抗力の項を陰に解いて割合fを掛けている。地面に触れている間は摩擦で減速し、風の力が摩擦より弱ければ動かない。
three.jsに渡すもの
雲の底は頂点の濃さを変えた面、地面は影を受ける円、家と木は同じ形を並べるInstancedMeshで、どれも最初に1回作るだけである。毎コマ書き直すのは、漏斗雲と小さな渦の管の頂点の配列で、形を作り直さずにneedsUpdateで知らせる。
描く(src/sims/tornado-vortex.ts の 657〜857 行) interface FunnelMesh { mesh: THREE.Mesh; pos: Float32Array; nor: Float32Array; nth: number; cs: Float32Array; sn: Float32Array; tmp: Float64Array }function buildFunnel(api: Api, s: { v: View3 }, mat: THREE.Material): FunnelMesh { const nth = [24 , 40 , 64 ][api.quality], cols = nth + 1 , count = NJ * cols; const pos = new Float32Array(count * 3 ), nor = new Float32Array(count * 3 ), idx: number[] = []; for (let j = 0 ; j < NZ; j++) for (let k = 0 ; k < nth; k++) { const a = j * cols + k, b = a + 1 , c = a + cols, d = c + 1 ; idx.push(a, b, c, b, d, c); } const g = new THREE.BufferGeometry(); g.setAttribute("position" , new THREE.BufferAttribute(pos, 3 ).setUsage(THREE.DynamicDrawUsage)); g.setAttribute("normal" , new THREE.BufferAttribute(nor, 3 ).setUsage(THREE.DynamicDrawUsage)); g.setIndex(idx); const mesh = new THREE.Mesh(g, mat); mesh.castShadow = true ; mesh.frustumCulled = false ; s.v.scene.add(mesh); const cs = new Float32Array(cols), sn = new Float32Array(cols); for (let k = 0 ; k <= nth; k++) { const t = (k / nth) * TAU; cs[k] = Math.cos(t); sn[k] = Math.sin(t); } return { mesh, pos, nor, nth, cs, sn, tmp: new Float64Array(NJ) }; } /** 漏斗雲の面の頂点を、高さごとの半径から書き直す。細かな凹凸(見た目のため)はゆっくり回す */ function updateFunnel(s: S, dt: number, fast: number) { const fm = s.funnel, D = s.D, F = s.F, z = F.z, L = F.L, a = dt > 0 ? Math.min(1 , dt * fast) : 1 ; for (let j = 0 ; j <= NZ; j++) fm.tmp[j] = D.rfs[j] + (D.rf[j] - D.rfs[j]) * a; for (let j = 1 ; j < NZ; j++) D.rfs[j] = 0.25 * fm.tmp[j - 1 ] + 0.5 * fm.tmp[j] + 0.25 * fm.tmp[j + 1 ]; D.rfs[0 ] = fm.tmp[0 ]; D.rfs[NZ] = fm.tmp[NZ]; let jt = 0 ; while (jt < NZ && D.rfs[jt] < 0.8 * L) jt++; const on = s.size.cloud && !s.sect && !s.outline && jt < NZ - 1 ; fm.mesh.visible = on; if (!on) return ; const cols = fm.nth + 1 , zt = jt > 0 ? z[jt] - 0.6 * (z[jt] - z[jt - 1 ]) : 0 ; // 雲の上から見るときは、雲の底のすぐ下で広がる部分を描かない(広がりが下の竜巻を隠すので) let jc = NZ; if (s.v.camera.position.y > F.H * 0.92 ) { jc = 0 ; while (jc < NZ && z[jc] < 0.65 * F.H) jc++; } for (let j0 = 0 ; j0 <= NZ; j0++) { const j = Math.min(j0, jc), rr = j < jt ? 0 : D.rfs[j], y = j < jt ? zt : z[j]; const jp = Math.min(NZ, j + 1 ), jm = Math.max(0 , j - 1 ), slope = clamp((D.rfs[jp] - D.rfs[jm]) / Math.max(1e-3 , z[jp] - z[jm]), -6 , 6 ); const nl = 1 / Math.sqrt(1 + slope * slope); for (let k = 0 ; k < cols; k++) { const q = (j0 * cols + k) * 3 , c = fm.cs[k], sn = fm.sn[k], th = (k / fm.nth) * TAU; const b = rr * (1 + 0.05 * Math.sin(3 * (th + s.ph) + (y / L) * 0.012 ) + 0.03 * Math.sin(7 * (th + s.ph) - (y / L) * 0.03 )); fm.pos[q] = b * c; fm.pos[q + 1 ] = y; fm.pos[q + 2 ] = -b * sn; fm.nor[q] = c * nl; fm.nor[q + 1 ] = -slope * nl; fm.nor[q + 2 ] = -sn * nl; } } const g = fm.mesh.geometry; (g.attributes.position as THREE.BufferAttribute).needsUpdate = true ; (g.attributes.normal as THREE.BufferAttribute).needsUpdate = true ; } interface TubeMesh { mesh: THREE.Mesh; pos: Float32Array; nor: Float32Array }const TSEG = 10 , TMAX = 6 ;function buildTubes(v: View3, mat: THREE.Material): TubeMesh { const cols = TSEG + 1 , count = TMAX * NJ * cols, pos = new Float32Array(count * 3 ), nor = new Float32Array(count * 3 ), idx: number[] = []; for (let t = 0 ; t < TMAX; t++) for (let j = 0 ; j < NZ; j++) for (let k = 0 ; k < TSEG; k++) { const a = (t * NJ + j) * cols + k, b = a + 1 , c = a + cols, d = c + 1 ; idx.push(a, b, c, b, d, c); } const g = new THREE.BufferGeometry(); g.setAttribute("position" , new THREE.BufferAttribute(pos, 3 ).setUsage(THREE.DynamicDrawUsage)); g.setAttribute("normal" , new THREE.BufferAttribute(nor, 3 ).setUsage(THREE.DynamicDrawUsage)); g.setIndex(idx); g.setDrawRange(0 , 0 ); const mesh = new THREE.Mesh(g, mat); mesh.castShadow = true ; mesh.frustumCulled = false ; v.scene.add(mesh); return { mesh, pos, nor }; } /** 小さな渦の位置を進め、その漏斗雲の管を描く。管の半径は、親の渦の気圧の低下に小さな渦の分を足して、雲になる範囲を解いたもの */ function updateSats(s: S, dt: number) { const sat = s.sat, D = s.D, F = s.F, Sw = F.Qs > 0 ? (F.Gam / F.r0) / ((2 * F.Qs) / (F.r0 * F.r0)) : 0 ; const k = clamp((Sw - S_MULTI) / 0.3 , 0 , 1 ); const n = k > 0 && F.noslip && s.size.cloud ? clamp(Math.round(Sw + 1 ), 3 , TMAX) : 0 ; sat.n = n; sat.ring = D.rm * 0.95 ; sat.rs = Math.max(3 * F.L, 0.1 * D.rm); sat.vs = 0.55 * D.vm * k; sat.tw = 0.0009 / F.L; // 小さな渦は、親の渦のいちばん速い回転の半分ほどの速さで輪の上を回る sat.ph += (dt * 0.5 * D.vm) / Math.max(1 , D.rm); for (let i = 0 ; i < n; i++) sat.a[i] = sat.ph + (i * TAU) / n; const tm = s.tubes, on = n > 0 && !s.sect; tm.mesh.visible = on; if (!on) return ; const cols = TSEG + 1 , { z, r, H } = F, rho = RHO; let ir = 1 ; while (ir < NR && r[ir] < sat.ring) ir++; for (let t = 0 ; t < n; t++) for (let j = 0 ; j <= NZ; j++) { const need = rho * GRAV * (H - z[j]), rq = need - D.dp[Q(ir, j)], pk = rho * sat.vs * sat.vs; // 回転の気圧の低下は、核の中で ρV²(1 − d²/2R²)、外で ρV²R²/(2d²)(ランキン渦) let rad = 0 ; if (rq <= 0 ) rad = sat.rs * 1.6 ; else if (rq < pk) rad = rq > 0.5 * pk ? sat.rs * Math.sqrt(2 * (1 - rq / pk)) : sat.rs * Math.sqrt(pk / (2 * rq)); rad = Math.min(rad, sat.rs); const ang = sat.a[t] + sat.tw * z[j], cx = sat.ring * Math.cos(ang), cy = sat.ring * Math.sin(ang); for (let m = 0 ; m < cols; m++) { const q = ((t * NJ + j) * cols + m) * 3 , th = (m / TSEG) * TAU, c = Math.cos(th), sn = Math.sin(th); tm.pos[q] = cx + rad * c; tm.pos[q + 1 ] = z[j]; tm.pos[q + 2 ] = -(cy + rad * sn); tm.nor[q] = c; tm.nor[q + 1 ] = 0 ; tm.nor[q + 2 ] = -sn; } } const g = tm.mesh.geometry; g.setDrawRange(0 , n * NZ * TSEG * 6 ); (g.attributes.position as THREE.BufferAttribute).needsUpdate = true ; (g.attributes.normal as THREE.BufferAttribute).needsUpdate = true ; } /** 断面の流線: ψ の等高線を、視線に直角で中心を通る面の上に引く(等高線に沿って空気が流れる)。漏斗雲の輪郭も同じ面に描く */ function updateLines(s: S) { const ln = s.lines, F = s.F, D = s.D; ln.visible = s.sect || s.outline; if (!ln.visible) return ; const g = ln.geometry, p = (g.attributes.position as THREE.BufferAttribute).array as Float32Array; const yaw = s.v.orbit.yaw, ex = Math.cos(yaw), ez = -Math.sin(yaw), max = p.length / 3 - 4 ; const { r, z, P, Qs } = F; let n = 0 ; const put = (ra: number, za: number, rb: number, zb: number) => { if (n >= max) return ; p[n * 3 ] = ra * ex; p[n * 3 + 1 ] = za; p[n * 3 + 2 ] = ra * ez; n++; p[n * 3 ] = rb * ex; p[n * 3 + 1 ] = zb; p[n * 3 + 2 ] = rb * ez; n++; p[n * 3 ] = -ra * ex; p[n * 3 + 1 ] = za; p[n * 3 + 2 ] = -ra * ez; n++; p[n * 3 ] = -rb * ex; p[n * 3 + 1 ] = zb; p[n * 3 + 2 ] = -rb * ez; n++; }; const LV = [0.002 , 0.008 , 0.02 , 0.05 , 0.1 , 0.2 , 0.32 , 0.46 , 0.62 , 0.8 ].map((x) => -x * Qs).concat([0.002 , 0.01 , 0.03 ].map((x) => x * Qs)); if (s.sect) for (const lv of LV) for (let j = 0 ; j < NZ; j++) for (let i = 0 ; i < NR; i++) { const a = P[Q(i, j)] - lv, b = P[Q(i + 1 , j)] - lv, c = P[Q(i + 1 , j + 1 )] - lv, d = P[Q(i, j + 1 )] - lv; const code = (a > 0 ? 1 : 0 ) | (b > 0 ? 2 : 0 ) | (c > 0 ? 4 : 0 ) | (d > 0 ? 8 : 0 ); if (code === 0 || code === 15 ) continue ; const xs: number[] = []; if ((a > 0 ) !== (b > 0 )) xs.push(r[i] + (a / (a - b)) * (r[i + 1 ] - r[i]), z[j]); if ((b > 0 ) !== (c > 0 )) xs.push(r[i + 1 ], z[j] + (b / (b - c)) * (z[j + 1 ] - z[j])); if ((d > 0 ) !== (c > 0 )) xs.push(r[i] + (d / (d - c)) * (r[i + 1 ] - r[i]), z[j + 1 ]); if ((a > 0 ) !== (d > 0 )) xs.push(r[i], z[j] + (a / (a - d)) * (z[j + 1 ] - z[j])); if (xs.length >= 4 ) put(xs[0 ], xs[1 ], xs[2 ], xs[3 ]); if (xs.length >= 8 ) put(xs[4 ], xs[5 ], xs[6 ], xs[7 ]); } for (let j = 0 ; j < NZ; j += 2 ) if ((D.rfs[j] > 0 || D.rfs[j + 1 ] > 0 ) && D.rfs[j + 1 ] < 200 * F.L) put(D.rfs[j], z[j], D.rfs[j + 1 ], z[j + 1 ]); // 漏斗雲の輪郭は破線で g.setDrawRange(0 , n); (g.attributes.position as THREE.BufferAttribute).needsUpdate = true ; } function buildWorld(api: Api, s: { v: View3; size: Size; F: Flow }) { const { v, size, F } = s, L = F.L, rn = api.rng(11 ); const ground = new THREE.Mesh(new THREE.CircleGeometry(3200 * L, 96 ), v.mat(0.22 )); ground.rotation.x = -Math.PI / 2 ; ground.receiveShadow = true ; v.scene.add(ground); const m4 = new THREE.Matrix4(), place = (mesh: THREE.InstancedMesh, k: number, x: number, y: number, z: number, sx: number, sy: number, sz: number, a: number) => { m4.makeRotationY(a); m4.scale(new THREE.Vector3(sx, sy, sz)); m4.setPosition(x, y, z); mesh.setMatrixAt(k, m4); }; if (size.cloud) { // 大きさの目安になる家と木(10 m 四方ほど) const nh = 150 , houses = new THREE.InstancedMesh(new THREE.BoxGeometry(1 , 1 , 1 ), v.mat(0.62 , { flat: true }), nh); for (let k = 0 ; k < nh; k++) { const r = 170 + 1250 * Math.sqrt(rn()), t = TAU * rn(), h = 5 + 4 * rn(); place(houses, k, r * Math.cos(t), h / 2 , r * Math.sin(t), 9 + 5 * rn(), h, 8 + 4 * rn(), TAU * rn()); } houses.castShadow = true ; houses.receiveShadow = true ; v.scene.add(houses); const nt = 90 , trees = new THREE.InstancedMesh(new THREE.ConeGeometry(1 , 1 , 6 ), v.mat(0.42 , { flat: true }), nt); for (let k = 0 ; k < nt; k++) { const r = 130 + 1400 * rn(), t = TAU * rn(), h = 10 + 8 * rn(); place(trees, k, r * Math.cos(t), h / 2 , r * Math.sin(t), 3.5 , h, 3.5 , 0 ); } trees.castShadow = true ; v.scene.add(trees); } else { // 旋風の大きさの目安になる草のかたまりと、身長 1.7 m の人 const nb = 70 , bush = new THREE.InstancedMesh(new THREE.IcosahedronGeometry(1 , 0 ), v.mat(0.5 , { flat: true }), nb); for (let k = 0 ; k < nb; k++) { const r = 8 + 90 * Math.sqrt(rn()), t = TAU * rn(), h = 0.25 + 0.35 * rn(); place(bush, k, r * Math.cos(t), h * 0.6 , r * Math.sin(t), h * 1.5 , h, h * 1.5 , TAU * rn()); } bush.castShadow = true ; v.scene.add(bush); const body = new THREE.Mesh(new THREE.CylinderGeometry(0.2 , 0.22 , 1.45 , 10 ), v.mat(0.9 )), head = new THREE.Mesh(new THREE.SphereGeometry(0.14 , 12 , 8 ), v.mat(0.9 )); body.position.set(14 , 0.72 , 6 ); head.position.set(14 , 1.58 , 6 ); body.castShadow = head.castShadow = true ; v.scene.add(body, head); } // 親の雲の底。中央が少し垂れ下がる(壁雲)。濃さの濃淡は頂点ごとの濃さで付ける const nr = size.cloud ? 44 : 2 , nt2 = size.cloud ? 120 : 8 , cols = nt2 + 1 , deckPos = new Float32Array((nr + 1 ) * cols * 3 ), deckCol = new Float32Array((nr + 1 ) * cols * 3 ), di: number[] = []; const Rd = 3000 * L, H = F.H, D = 70 * L, W = 260 * L; for (let i = 0 ; i <= nr; i++) for (let k = 0 ; k < cols; k++) { const r = Rd * (i / nr) ** 1.7 , t = (k / nt2) * TAU, q = (i * cols + k) * 3 ; const lump = Math.sin(t * 5 + r * 0.011 / L) * Math.sin(r * 0.019 / L - t * 3 ) + 0.6 * Math.sin(t * 11 - r * 0.031 / L); deckPos[q] = r * Math.cos(t); deckPos[q + 1 ] = H - D * Math.exp(-(r * r) / (W * W)) + 7 * L * lump * Math.exp(-(r * r) / (650 * L * 650 * L)); deckPos[q + 2 ] = r * Math.sin(t); const g = clamp(0.2 + 0.13 * lump, 0.05 , 0.42 ); deckCol[q] = g; deckCol[q + 1 ] = g; deckCol[q + 2 ] = g; } for (let i = 0 ; i < nr; i++) for (let k = 0 ; k < nt2; k++) { const a = i * cols + k, b = a + 1 , c = a + cols, d = c + 1 ; di.push(a, b, c, b, d, c); } const dg = new THREE.BufferGeometry(); dg.setAttribute("position" , new THREE.BufferAttribute(deckPos, 3 )); dg.setAttribute("color" , new THREE.BufferAttribute(deckCol, 3 )); dg.setIndex(di); const dmat = v.flat(1 , { double: true }) as THREE.MeshBasicMaterial; dmat.vertexColors = true ; const deck = new THREE.Mesh(dg, dmat); deck.visible = size.cloud; v.scene.add(deck); // 風の階級の輪(大きさは半径 1 の輪を縮尺で伸ばす) const circle = new THREE.BufferGeometry(), cp = new Float32Array(128 * 3 ); for (let k = 0 ; k < 128 ; k++) { const t = (k / 128 ) * TAU; cp[k * 3 ] = Math.cos(t); cp[k * 3 + 1 ] = 0 ; cp[k * 3 + 2 ] = Math.sin(t); } circle.setAttribute("position" , new THREE.BufferAttribute(cp, 3 )); const rmat = v.lineMat(1 ), rings = JEF.map(() => { const l = new THREE.LineLoop(circle, rmat); l.position.y = 0.6 * L; l.visible = false ; l.frustumCulled = false ; v.scene.add(l); return l; }); const lg = new THREE.BufferGeometry(); lg.setAttribute("position" , new THREE.BufferAttribute(new Float32Array(3 * 2 * 2 * 16000 ), 3 ).setUsage(THREE.DynamicDrawUsage)); lg.setDrawRange(0 , 0 ); const lines = new THREE.LineSegments(lg, v.lineMat(1 )); lines.frustumCulled = false ; lines.visible = false ; v.scene.add(lines); return { deck, rings, lines }; } const nice = (x: number, list: number[]) => { for (const a of list) if (a >= x) return a; return list[list.length - 1 ]; };const graphList = (L: number) => [20 , 40 , 80 , 160 , 320 , 640 , 1000 ].map((x) => x * (L < 1 ? 0.2 : 1 ));/** 2D の層のグラフ: 半径ごとの回転の速さ v と上下の流れ w、気圧の低下 Δp */ function drawGraph(api: Api, s: S) { const F = s.F, D = s.D, L = F.L, small = api.w < 560 ; const gw = Math.min(api.w * (small ? 0.44 : 0.31 ), api.u(330 )), gh = Math.min(gw * (small ? 0.95 : 0.74 ), api.h * 0.44 ), x0 = api.u(8 ), y0 = api.h - gh - api.u(8 ); api.rect(x0, y0, gw, gh, { fill: "paper" , stroke: "ink" , w: api.u(0.8 ) }); const fs = Math.max(9 , api.u(11 )), pl = x0 + fs * 2.5 , pr = x0 + gw - fs * 0.7 , tTop = y0 + fs * 1.6 , tBot = y0 + gh * 0.56 , bTop = tBot + fs * 1.5 , bBot = y0 + gh - fs * 1.75 ; // 横軸の範囲は核の 3.5 倍まで。少しの変化では変えない const want = D.rm * 3.5 , list = graphList(L); if (want > s.graphR * 1.1 || want < s.graphR * 0.4 ) s.graphR = nice(want, list); const rg = s.graphR, X = (r: number) => pl + (r / rg) * (pr - pl); let vtop = 0 , wlo = 0 ; const jv = D.jm, jw = D.jc, { r, G, W } = F; for (let i = 1 ; i <= NR && r[i - 1 ] <= rg; i++) { const v = G[Q(i, jv)] / r[i], w = W[Q(i, jw)]; if (v > vtop) vtop = v; if (w > vtop) vtop = w; if (w < wlo) wlo = w; } const vs = [10 , 20 , 40 , 60 , 80 , 100 , 120 , 160 , 200 , 300 , 400 ], vmax = nice(vtop * 1.05 , vs), vmin = Math.min(0 , wlo * 1.3 ); const Yv = (v: number) => tBot - ((v - vmin) / (vmax - vmin)) * (tBot - tTop); const dpA = Math.max(D.dp[Q(0 , jv)], D.dp0, 1 ) / 100 , dps = [1 , 2 , 5 , 10 , 20 , 50 , 100 , 150 , 200 , 300 , 500 ], dpm = nice(dpA * 1.05 , dps), Yp = (p: number) => bBot - (p / dpm) * (bBot - bTop); const lw = api.u(1.6 ), P = s.poly; const curve = (f: (i: number) => number, o: { w: number; dash?: number[] }, Y: (v: number) => number) => { let n = 0 ; for (let i = 0 ; i <= NR; i++) { if (r[i] > rg && n > 0 ) { P[n++] = X(rg); P[n++] = Y(f(Math.max(0 , i - 1 ))); break ; } P[n++] = X(r[i]); P[n++] = Y(f(i)); } api.poly(P.subarray(0 , n) as unknown as number[], { w: o.w, dash: o.dash }); }; api.line(pl, Yv(0 ), pr, Yv(0 ), { w: api.u(0.7 ) }); api.line(pl, tTop, pl, tBot, { w: api.u(0.7 ) }); curve((i) => (i > 0 ? G[Q(i, jw)] / r[i] : 0 ), { w: api.u(0.9 ), dash: [api.u(1.5 ), api.u(3 )] }, Yv); // 高い所の回転 curve((i) => W[Q(i, jw)], { w: api.u(1 ), dash: [api.u(5 ), api.u(3 )] }, Yv); curve((i) => (i > 0 ? G[Q(i, jv)] / r[i] : 0 ), { w: lw }, Yv); api.line(pl, bBot, pr, bBot, { w: api.u(0.7 ) }); api.line(pl, bTop, pl, bBot, { w: api.u(0.7 ) }); curve((i) => D.dp[Q(i, 0 )] / 100 , { w: api.u(0.9 ), dash: [api.u(1.5 ), api.u(3 )] }, Yp); // 地面 curve((i) => D.dp[Q(i, jv)] / 100 , { w: lw }, Yp); if (D.rm < rg) { const xm = X(D.rm); api.line(xm, tTop, xm, bBot, { w: api.u(0.7 ), dash: [api.u(2 ), api.u(3 )] }); } const T = (t: string, x: number, y: number, o?: { align?: CanvasTextAlign; font?: "mono" | "sans" }) => api.text(t, x, y, { size: fs, font: o?.font ?? "sans" , align: o?.align }); T("v 回転(実線)・w 上下(破線)m/s" , x0 + fs * 0.5 , y0 + fs * 1.15 ); T(fmt(vmax, 0 ), pl - fs * 0.3 , tTop + fs * 0.7 , { align: "right" , font: "mono" }); T("0" , pl - fs * 0.3 , Yv(0 ) + fs * 0.35 , { align: "right" , font: "mono" }); T("Δp 気圧の低下 hPa(点線は地面)" , x0 + fs * 0.5 , tBot + fs * 1.15 ); T(fmt(dpm, 0 ), pl - fs * 0.3 , bTop + fs * 0.7 , { align: "right" , font: "mono" }); T("0" , pl - fs * 0.3 , bBot, { align: "right" , font: "mono" }); T("半径 r" , pl, bBot + fs * 1.25 ); T(` ${fmt(rg, 0)} m` , pr, bBot + fs * 1.25 , { align: "right" , font: "mono" }); }
漏斗雲の面の向きは、半径の高さごとの傾きから計算する。断面の流線はψ の等高線で、視線に直角で中心を通る面の上に5分の1秒ごとに引き直す。グラフはthree.jsを使わず、描画のあとに2Dの描画の部品で重ねている。
1コマの進め方
場面を切り替えた直後とつまみを動かしたあとは、流れが落ち着くまで早送りする。1コマに使う時間を6ミリ秒までと決め、その分だけtickを回す。遅い端末でも画面は止まらず、竜巻が育つのに時間がかかるだけである。
1コマの計算(src/sims/tornado-vortex.ts の 926〜973 行) // 場面の最初は流れが落ち着くまで早送りする。使ってよい時間を決めて、その分だけ進める let ran = 0 ;if (s.spin > 0 ) { const t0 = performance.now(), budget = dt === 0 ? 200 : SPINMS; do { tick(F); s.spin -= TICK; ran++; } while (s.spin > 0 && (api.fast ? ran < 6 : performance.now() - t0 < budget)); } else { if (s.ramp && dt > 0 ) { // 2 重の渦の場面: 周りの回転を強めたあと、流れが落ち着くまで同じ早さで待つ。この間は流れを 5 倍の速さで進める const m = s.ramp; m.t += dt; const vv = Math.round((m.from + (m.to - m.from ) * Math.min(1 , m.t / m.T)) * 2 ) / 2 ; if (vv !== api.num("v0" )) { api.p.v0 = vv; api.set("v0" , vv); applyForcing(api, s); } if (m.t >= 2 * m.T) s.ramp = null ; } ran += s.step(s.ramp ? dt * 5 : dt, () => tick(F)); } if (ran > 0 || api.frame <= 1 ) { derive(F, D, s.size.cloud); } const spinning = s.spin > 0 ;s.ph += dt * Math.min(2.5 , (0.5 * D.vm) / Math.max(1 , D.rm)); updateFunnel(s, spinning ? Math.max(dt, 0.02 ) : dt, spinning ? 12 : 2.5 ); updateSats(s, dt); if (ran > 0 || api.frame <= 1 ) uploadFlow(s);s.deck.rotation.y += (dt * F.Gam) / (F.r0 * F.r0) / 2 ; // 雲の底の模様は、周りの回転の半分の速さでゆっくり回す(見た目のため) // 風の階級の輪: 地上近くの風がその階級の下限を超えている、いちばん外の半径 for (let k = 0 ; k < JEF.length; k++) { let i = NR; while (i > 0 && D.sfcSpeed[i] < JEF[k]) i--; const l = s.rings[k]; l.visible = i > 0 && s.size.cloud; if (l.visible) l.scale.set(F.r[i], 1 , F.r[i]); } // 粒を運ぶ const p = s.parts;if (p.first && !api.playing) for (let k = 0 ; k < 24 ; k++) moveParts(api, s, k === 0 ? 0 : 0.5 );else if (api.fast) { p.acc += dt; if (p.acc >= 0.25 || p.first) { moveParts(api, s, Math.min(0.25 , p.acc)); p.acc = 0 ; } }else if (spinning) { moveParts(api, s, 0.25 ); moveParts(api, s, 0.25 ); }else if (dt > 0 || p.first || p.injB > p.injA) moveParts(api, s, dt);// 物を動かす(竜巻が育ってから) if (!spinning && s.objs.length) { s.ostep(dt, (h) => { for (const o of s.objs) stepObj(api, s, o, h); }); for (const o of s.objs) { o.m.position.set(o.x, o.z + 0.65 * o.kind.size[1 ] * (s.size.L >= 1 ? VIS : 1 ), -o.y); const sp = Math.hypot(o.vx, o.vy, o.vz); if (o.z > 0.5 ) { o.m.rotation.x += o.rx * sp * dt * 0.12 ; o.m.rotation.y += o.ry * sp * dt * 0.12 ; o.m.rotation.z += o.rz * sp * dt * 0.12 ; } } } s.lineTime += dt; if ((s.sect || s.outline) && (s.lineTime > 0.2 || api.frame <= 2 || dt === 0 )) { s.lineTime = 0 ; updateLines(s); }s.lines.visible = s.sect || s.outline;
早送りのあとはstepperで実際の時間と同じ速さに進める。地面をタップしたときは、平面と視線の交点を求めて物を置く。砂ぼこりを選んでいれば、粒の一部をその場所へ放ち直す。