このシミュレーションは、1刻みごとに「水蒸気を隣のますと混ぜる → 結晶の縁で凍らせる → しきい値を超えたますを結晶にする → 少し融かして戻す」をくり返している。立体の描画と影と視点はthree.jsが受け持ち、このファイルが計算するのは、どのますが結晶になったかである。描くときは、表面に出ている面だけを三角形にしてthree.jsに渡す。
長さはmm、時間は分
計算する範囲は半径2.5 mmで、格子の半径(RQ)を画質で変える。1ますの幅は2.5 ÷ R mmで、画質「高」では約22 μmになる。
定数と単位(src/sims/snow-crystal.ts の 31〜49 行) const SQ = Math.sqrt(3 ) / 2 ; // 隣の列までの距離(ますの幅を 1 として) const SIZE = 2.5 ; // 計算する範囲の半径 mm const RQ = [56 , 84 , 116 ], ZQ = [46 , 70 , 96 ]; // ひとつの結晶の格子: 横の半径と縦の半分の高さ(ます。画質 0・1・2) const RT = [40 , 58 , 76 ], ZT = [34 , 48 , 64 ]; // 2 つ並べるときの格子 const RF = [36 , 48 , 64 ], ZF = [14 , 18 , 24 ]; // 横の全面を解くときの格子 const RP = 32 , ZP = 28 ; // 図の点を動かしている間の下書きの格子 const PLAN_RS = 0.72 ; // 履歴をたどるときの格子の倍率(柱の両端に板が 2 枚広がると面の数が倍になるので、格子を少し粗くする) const SEC_120 = 0.27 ; // 半径 120 ますの格子の 1 刻み(秒)。ますが粗くなると枝先がほぼ同じ速さになるよう、その分だけ長くする const STEP_RATE = [240 , 340 , 480 ]; // 「早送り 1 倍」で画面の 1 秒に進める刻みの数(画質 0・1・2) const BUDGET_MS = 6 ; // 1 コマで格子の計算に使ってよい時間 const TRI_MAX = 300000 ; // 1 コマに描く三角形の上限。結晶がここまで細かくなったら、育てるのを止める const STOP_MIN = 90 ; // 条件を変えないとき、ここまで育てる(分) const PLAN_MIN = 36 ; // 図の上をなぞったときの履歴の長さ(分) const PREVIEW_MIN = 35 ; // 下書きを育てる時間(分) const TMIN = -30 , TMAX = -1 , SMIN = 1 , SMAX = 40 ; // 温度 °C と過飽和度 % の範囲 const EXPOSURE = 2.05 ; // 明るさの倍率(面の明るさを網点の濃さへ) const TONE0 = 0.66 ; // いちばん低い上の面の濃さ const FIELD = 0.115 ; // 遠くの水蒸気の濃さを描くときの明るさ const A0 = 0.1 ; // 拡散の重み(横の隣 1 つあたり)。縦の隣は 1.5 倍にすると、横と縦で同じ速さに広がる
1刻みの時間はSEC_120から決める。枝先の速さは格子の粗さによらず1刻みあたり約0.02ます(画質「高」で0.017、「低」で0.020)なので、ますが粗いほど1刻みを長くすると、同じ条件の枝先がほぼ同じ速さになる。粗い格子のほうが1割あまり速い。SEC_120は、中谷の測った羊歯状の結晶の速さ4.6 mm/時を枝先の速さと読み、それに近くなるように決めた想定である。BUDGET_MSは1コマで計算に使ってよい時間で、これを超えた刻みはそのコマでは進めない。遅い端末では結晶がゆっくり育つだけで、形は変わらない。
温度と過飽和度を係数にする
coefは、温度T と過飽和度σ から、しきい値β と融ける割合μ と遠くの水蒸気の量ρ を決める。
温度と過飽和度から係数へ(src/sims/snow-crystal.ts の 51〜91 行) /** 氷と水の飽和水蒸気圧(Pa)。Murphy と Koop の式(2005 年) */ const eIce = (T: number) => { const K = T + 273.15 ; return Math.exp(9.550426 - 5723.265 / K + 3.53068 * Math.log(K) - 0.00728332 * K); };const eLiq = (T: number) => { const K = T + 273.15 ; return Math.exp(54.842763 - 6763.22 / K - 4.21 * Math.log(K) + 0.000367 * K + Math.tanh(0.0415 * (K - 218.8 )) * (53.878 - 1331.22 / K - 9.44523 * Math.log(K) + 0.014025 * K)); };/** 水滴のある雲の中の過飽和度(氷に対して・%)。この値を中谷ダイヤグラムに「水の飽和」の線で引く */ const sigmaWater = (T: number) => 100 * (eLiq(T) / eIce(T) - 1 );/** 氷の飽和水蒸気密度(g/m³) */ const rhoSat = (T: number) => (eIce(T) / (461.5 * (T + 273.15 ))) * 1000 ; /** 温度ごとの係数の表(想定の値)。β は 1 + 値 × g(σ) で決める。行の順: T °C・β(上)・β(横)・β(横 1 縦 1)・β(横 2 縦 1)・μ(横)・μ(ほか) */ const TABLE = [ [-1 , 1.5 , 0.8 , 0.8 , 0 , 0.002 , 0.001 ], [-3 , 1.0 , 1.0 , 1.0 , 0 , 0.002 , 0.001 ], [-5 , 0.4 , 3.0 , 3.0 , 0 , 0.001 , 0.001 ], [-7 , 0.0 , 1.0 , -0.5 , -0.5 , 0.01 , 0.01 ], [-10 , 0.5 , 0.6 , 0 , 0 , 0.015 , 0.015 ], [-12 , 1.5 , 1.0 , 1.0 , 0 , 0.001 , 0.001 ], [-15 , 2.0 , 0.4 , 0.4 , 0 , 0.005 , 0.001 ], [-18 , 1.8 , 1.2 , 0.6 , 0.6 , 0.001 , 0.0005 ], [-22 , 1.5 , 1.0 , 1.0 , 0 , 0.001 , 0.001 ], [-26 , 0.5 , 0.6 , 0 , 0 , 0.015 , 0.015 ], [-30 , 0.7 , 1.0 , 0 , 0 , 0.01 , 0.01 ], ]; interface Coef { beta: Float32Array; mu: Float32Array; kappa: number; rho: number; A: number; B: number; C0: number; passes: number }const newCoef = (): Coef => ({ beta: new Float32Array(8 ), mu: new Float32Array(8 ), kappa: 0.1 , rho: 0.1 , A: A0, B: 1.5 * A0, C0: 1 - 9 * A0, passes: 1 }); /** T(°C)・σ(%)・拡散の強さ D から係数を決める。表の添え字は nT(3 まで)+ 4 ×(nZ が 1 以上か) */ function coef(C: Coef, T: number, sig: number, D: number) { const t = clamp(T, TMIN, TMAX); let i = 0 ; while (i < TABLE.length - 2 && t < TABLE[i + 1 ][0 ]) i++; const f = (TABLE[i][0 ] - t) / (TABLE[i][0 ] - TABLE[i + 1 ][0 ]), at = (n: number) => TABLE[i][n] + (TABLE[i + 1 ][n] - TABLE[i][n]) * f; // σ が水の飽和より低いほど、結晶の角から新しい層を始めにくくする(g が大きいほど β が高い) const x = sig / Math.max(sigmaWater(t), 3 ), g = Math.min(3 , Math.sqrt(1 / Math.max(x, 0.05 ))); const up = at(1 ), side = at(2 ), b11 = at(3 ), b21 = at(4 ), mt = at(5 ), mo = at(6 ), B = C.beta, M = C.mu, th = (v: number) => Math.max(0.3 , 1 + v * g); B[1 ] = B[2 ] = th(side); B[3 ] = th(Math.min(0 , b21)); B[4 ] = th(up); B[5 ] = th(b11); B[6 ] = th(b21); B[7 ] = 1 ; M[1 ] = M[2 ] = mt; M[3 ] = M[4 ] = M[5 ] = M[6 ] = M[7 ] = mo; // 遠くの水蒸気の量 ρ: 余分な水蒸気の密度(g/m³)に比例。0.64 は −15 °C の水の飽和が手本の ρ = 0.14 になる倍率 C.rho = clamp(0.64 * (sig / 100 ) * rhoSat(t), 0.004 , 0.22 ); // 拡散の強さ: 1 回の混ぜる量を変える。1 より強いときは 1 刻みに 2 回混ぜる C.passes = D > 1 ? 2 : 1 ; C.A = (A0 * D) / C.passes; C.B = 1.5 * C.A; C.C0 = 1 - 9 * C.A; }
TABLEが温度ごとの表で、値はグラブナーとグリフィースの論文の例をもとに、中谷ダイヤグラムに合うように決めた。表のあいだは直線でつなぐ。β は1 + 値 × g の形で、g は水の飽和の過飽和度σ w と今のσ からg = σ w / σ (3まで)と決めた想定である。σ が低いと新しい層を始めにくくなり、面が平らなまま育つ。eIceとeLiqはマーフィーとコープの飽和水蒸気圧の式で、sigmaWaterが図の「水の飽和」の曲線を与える。ρ は余分な水蒸気の密度に比例させた。比例の係数0.64は、−15 °Cの水の飽和が論文の羊歯状のρ = 0.14 になるように決めた想定である。
全体の24分の1だけを解く
結晶が上下と6回の対称を保つ場面では、全体の24分の1を解けば足りる。makeLatは柱(横の1か所に縦に並ぶます)を中心からの距離の順に並べ、隣の表を作る。
格子を作る(src/sims/snow-crystal.ts の 93〜168 行) const DI = [1 , 0 , -1 , 0 , 1 , -1 ], DJ = [0 , 1 , 0 , -1 , -1 , 1 ]; // 横の隣 6 つ(軸の座標 i・j) const hexDist = (i: number, j: number) => Math.max(Math.abs(i), Math.abs(j), Math.abs(i + j));interface Lat { R: number; Nz: number; S: number; wedge: boolean; nc: number; ci: Int16Array; cj: Int16Array; sh: Int16Array; wt: Uint8Array; nb: Int32Array; shellEnd: Int32Array; index: Int32Array; n: number; d: Float32Array; dn: Float32Array; b: Float32Array; a: Uint8Array; nt: Uint8Array; nz: Uint8Array; isb: Uint8Array; flat: Uint8Array; top: Int16Array; bl: Int32Array; nbl: number; srf: Int32Array; nsrf: number; att: Int32Array; rho: number; rmax: number; zmax: number; t: number; mx: number; mz: number; area: number; cells: number; dirty: boolean; } /** 回転と鏡で、全体の 1/12 の扇(0 ≦ j ≦ i)へ写す */ function canon(i: number, j: number): [number, number] { for (let r = 0 ; r < 6 ; r++) { if (i >= 0 && j >= 0 ) return j > i ? [j, i] : [i, j]; const t = i; i = -j; j = t + j; } return [0 , 0 ]; } function colAt(L: Lat, i: number, j: number) { if (L.wedge) [i, j] = canon(i, j); if (hexDist(i, j) > L.R) return L.nc; return L.index[(i + L.R) * (2 * L.R + 1 ) + j + L.R]; } /** 柱(横の 1 か所に縦に並ぶます)を、中心からの距離の順に並べる。隣の表には、扇の外へ出るところに鏡で写した先を入れる */ function makeLat(R: number, Nz: number, wedge: boolean): Lat { const ci: number[] = [], cj: number[] = [], sh: number[] = []; if (wedge) { for (let s = 0 ; s <= R; s++) for (let j = 0 ; j <= s >> 1 ; j++) { ci.push(s - j); cj.push(j); sh.push(s); } } else { const a: number[][] = []; for (let i = -R; i <= R; i++) for (let j = -R; j <= R; j++) { const h = hexDist(i, j); if (h <= R) a.push([h, i, j]); } a.sort((p, q) => p[0 ] - q[0 ] || p[1 ] - q[1 ] || p[2 ] - q[2 ]); for (const [h, i, j] of a) { ci.push(i); cj.push(j); sh.push(h); } } const nc = ci.length, S = Nz + 1 , n = (nc + 1 ) * S, index = new Int32Array((2 * R + 1 ) ** 2 ).fill(nc); const wt = new Uint8Array(nc), shellEnd = new Int32Array(R + 2 ); for (let c = 0 ; c < nc; c++) { index[(ci[c] + R) * (2 * R + 1 ) + cj[c] + R] = c; wt[c] = !wedge ? 1 : ci[c] === 0 && cj[c] === 0 ? 1 : cj[c] === 0 || cj[c] === ci[c] ? 6 : 12 ; shellEnd[sh[c]] = c + 1 ; } const L: Lat = { R, Nz, S, wedge, nc, ci: Int16Array.from (ci), cj: Int16Array.from (cj), sh: Int16Array.from (sh), wt, nb: new Int32Array(nc * 6 ), shellEnd, index, n, d: new Float32Array(n), dn: new Float32Array(n), b: new Float32Array(n), a: new Uint8Array(n), nt: new Uint8Array(n), nz: new Uint8Array(n), isb: new Uint8Array(n), flat: new Uint8Array(n), top: new Int16Array(nc + 1 ), bl: new Int32Array(n >> 1 ), nbl: 0 , srf: new Int32Array(n), nsrf: 0 , att: new Int32Array(1 << 15 ), rho: 0.1 , rmax: 0 , zmax: 0 , t: 0 , mx: Math.round(R / 4 ), mz: Math.round(R / 5 ), area: 0 , cells: 0 , dirty: true , }; for (let c = 0 ; c < nc; c++) for (let d = 0 ; d < 6 ; d++) L.nb[c * 6 + d] = colAt(L, ci[c] + DI[d], cj[c] + DJ[d]); return L; } /** ます k の、結晶の隣の数を数え直す。鏡で写した隣は 2 回数えるので、全体の格子で数えるのと同じになる */ function recount(L: Lat, k: number) { const { S, nb, a } = L, w = (k / S) | 0 , z = k - w * S; let t = 0 ; for (let e = w * 6 , f = e + 6 ; e < f; e++) t += a[nb[e] * S + z]; L.nt[k] = t; L.nz[k] = z === 0 ? 2 * a[k + 1 ] : a[k - 1 ] + a[k + 1 ]; } function touch(L: Lat, q: number) { if (L.a[q]) return ; if (!L.isb[q]) { L.isb[q] = 1 ; L.bl[L.nbl++] = q; } recount(L, q); } /** ます k を結晶にする。まわりのますが結晶の外の縁になる */ function attach(L: Lat, k: number) { const { S, nb, nc, sh, top } = L, w = (k / S) | 0 , z = k - w * S; L.a[k] = 1 ; L.isb[k] = 0 ; L.srf[L.nsrf++] = k; L.d[k] = 0 ; L.dn[k] = 0 ; L.dirty = true ; if (sh[w] > L.rmax) L.rmax = sh[w]; if (z > L.zmax) L.zmax = z; if (top[w] < 0 ) L.area += L.wt[w]; if (z > top[w]) top[w] = z; L.cells += L.wt[w] * (z === 0 ? 1 : 2 ); for (let e = w * 6 , f = e + 6 ; e < f; e++) if (nb[e] !== nc) touch(L, nb[e] * S + z); if (z + 1 < L.Nz) touch(L, k + 1 ); if (z > 0 ) touch(L, k - 1 ); } /** 種: 半径 r の薄い六角形。初めから水蒸気の量 ρ で満たす */ function clearLat(L: Lat, rho: number) { L.d.fill(rho); L.dn.fill(rho); L.b.fill(0 ); L.a.fill(0 ); L.nt.fill(0 ); L.nz.fill(0 ); L.isb.fill(0 ); L.flat.fill(0 ); L.top.fill(-1 ); L.nbl = 0 ; L.nsrf = 0 ; L.rho = rho; L.rmax = 0 ; L.zmax = 0 ; L.t = 0 ; L.area = 0 ; L.cells = 0 ; L.dirty = true ; } function addSeed(L: Lat, ci0: number, cj0: number, r = 2 ) { for (let c = 0 ; c < L.nc; c++) if (hexDist(L.ci[c] - ci0, L.cj[c] - cj0) <= r) { const k = c * L.S; if (!L.a[k]) { L.b[k] = 1 ; attach(L, k); } } // 種を順に置く途中で、次に種になるますも縁の表に入る。結晶になったますは表から外す(残すと、種から水蒸気が漏れ、置いた順番で枝がずれる) let w = 0 ; for (let i = 0 ; i < L.nbl; i++) { const k = L.bl[i]; if (!L.a[k]) L.bl[w++] = k; } L.nbl = w; } /** 遠くの水蒸気の量が変わったら、場全体を同じ倍率にする(本物の空気では、拡散は成長よりずっと速く、場はすぐに新しい形に落ち着く) */ function setRho(L: Lat, rho: number) { const f = rho / L.rho, { d, dn, n } = L; for (let i = 0 ; i < n; i++) { d[i] *= f; dn[i] *= f; } L.rho = rho; }
扇の外へ出る隣は、canonで鏡に写した先のますを指す。高さ0の面も鏡で、下の隣は上の隣と同じになる。こうすると計算量は24分の1になり、半径116ますの格子でも、1刻みはおよそ1 ms以下で済む。「ゆらぎ」の場面ではwedgeをfalseにして、横の全面を解く。ゆらぎが0なら、全面を解いても24分の1を写したのと同じ結晶になる。recountは隣の結晶のますを数えるところで、鏡で写した隣は2回数えるので、全体を解いたときと同じ数になる。
混ぜて、凍らせて、付ける
本文の4段階をそのまま書いた部分である。
格子の1刻み(src/sims/snow-crystal.ts の 170〜209 行) /** 拡散: 横 6 つと縦 2 つの隣と混ぜる。結晶のますは d = 0 として足し、結晶の隣のますは跳ね返った分をあとで足す */ function diffuse(L: Lat, C: Coef) { const { d, dn, nb, S } = L, A = C.A, B = C.B, C0 = C.C0; // 結晶のまわりだけを解く。外側のますは遠くの値 ρ のまま(結晶から少し離れた所は、ほぼ一様とみなす) const wa = L.shellEnd[Math.min(L.R, L.rmax + L.mx)], za = Math.min(L.Nz, L.zmax + L.mz); for (let w = 0 ; w < wa; w++) { const base = w * S, e = w * 6 , n0 = nb[e] * S, n1 = nb[e + 1 ] * S, n2 = nb[e + 2 ] * S, n3 = nb[e + 3 ] * S, n4 = nb[e + 4 ] * S, n5 = nb[e + 5 ] * S; dn[base] = C0 * d[base] + A * (d[n0] + d[n1] + d[n2] + d[n3] + d[n4] + d[n5]) + 2 * B * d[base + 1 ]; // 高さ 0 の面は上下の鏡 for (let z = 1 ; z < za; z++) { const k = base + z; dn[k] = C0 * d[k] + A * (d[n0 + z] + d[n1 + z] + d[n2 + z] + d[n3 + z] + d[n4 + z] + d[n5 + z]) + B * (d[k - 1 ] + d[k + 1 ]); } } const { srf, bl, nt, nz } = L; for (let i = 0 , m = L.nsrf; i < m; i++) dn[srf[i]] = 0 ; for (let i = 0 , m = L.nbl; i < m; i++) { const k = bl[i]; dn[k] += d[k] * (A * nt[k] + B * nz[k]); } L.d = dn; L.dn = d; } /** 凍る・付く・融ける。結晶の縁のますだけを調べる。付くますはいったん集めてから、まとめて結晶にする(どの枝も同じ順序になる) */ function settle(L: Lat, C: Coef, noise: number, rng: (() => number) | null ) { const { d, b, bl, nt, nz, att } = L, beta = C.beta, mu = C.mu, keep = C.kappa; let na = 0 , w = 0 ; for (let i = 0 , m = L.nbl; i < m; i++) { const k = bl[i], nT = nt[k], nZ = nz[k], t = (nT > 3 ? 3 : nT) + (nZ > 0 ? 4 : 0 ); let f = 1 - keep; if (rng) f *= 1 + noise * (2 * rng() - 1 ); // ゆらぎ: 凍る割合を、ますごと刻みごとに少しだけ変える const v = d[k], fr = f * v, bb = b[k] + fr; // 横に 4 つ以上・上下に 1 つ以上の隣があるますは、穴を埋めるために β によらず結晶にする(論文と同じ) if ((bb >= beta[t] || (nT >= 4 && nZ >= 1 )) && na < att.length) { b[k] = bb + v - fr; d[k] = 0 ; att[na++] = k; continue ; } const me = mu[t] * bb; b[k] = bb - me; d[k] = v - fr + me; bl[w++] = k; } L.nbl = w; for (let i = 0 ; i < na; i++) attach(L, att[i]); L.t++; } function stepLat(L: Lat, C: Coef, noise: number, rng: (() => number) | null ) { for (let p = 0 ; p < C.passes; p++) diffuse(L, C); settle(L, C, noise, rng); }
diffuseは本文のd ′ の式で、横6つと縦2つの隣を足す。論文は横と縦を2段階に分けて混ぜるが、ここでは1段階にまとめ、横の重みa と縦の重み1.5 a の比で等方に広げている。a = 0.1 は、中心の重み1 − 9 a が負にならない範囲で大きい値だ。
この混ぜ方の拡散係数は、ますの幅Δ x と1刻みの時間Δ t から1.5 a Δ x 2 /Δ t と決まる。画質「高」では約2.5 × 1 0 − 10 m²/秒で、本物の2 × 1 0 − 5 m²/秒の約8万分の1だ。水蒸気の量ρ = 0.14 も、氷との比で本物の約60万倍ある(本物は−15 °Cの水の飽和で、余分な水蒸気0.22 g/m³と氷0.92 g/cm³の比の約2.4 × 1 0 − 7 )。論文のモデルの時間は物理の単位を持たないので、時間の目盛りは枝先の速さだけで決めた。
結晶のますはd = 0 として足し、あとで結晶の隣のますに、跳ね返った分A * nt + B * nzを足す。こうすると、ループの中で結晶かどうかを調べずに済む。
settleは結晶の縁のますだけを調べる。bb >= beta[t]が本文のしきい値の式で、横に4つ以上、上下に1つ以上の隣があるますは、穴を埋めるためにβ によらず結晶にする(論文と同じ規則)。付くますはいったんattに集めてから、まとめて結晶にする。調べる順番で結果が変わらないようにするためで、順番の影響があると、対称な条件でも枝がずれてしまう。外側のますはwaとzaの範囲の外にあり、計算せず遠くの値ρ のままにしてある。結晶が小さいうちは、これで計算がさらに軽くなる。
表面の面だけをthree.jsに渡す
ここから先は描画である。計算の結果のうち、three.jsに渡すのは三角形の座標と向きと濃さだけで、照明と影、視点は共通部品とthree.jsが受け持つ。白黒の網点への変換は全記事に共通の部品が行う。
形を描く(src/sims/snow-crystal.ts の 246〜430 行) // three.js が受け持つのは、ここで作った三角形を照らし、影を落として画面に描くところ。計算の結果は「面がどこに出ているか」だけを渡す /** 1 つの結晶の描画の部品を作る。全体の 1/24 の形を 1 つの geometry に入れ、回転と鏡で並べて描く */ function makeCry(s: S, R: number, Nz: number, wedge: boolean, offX: number): Cry { const L = makeLat(R, Nz, wedge), dx = SIZE / R, capTri = (wedge ? 3 : 16 ) * R * R; const pos = new Float32Array(capTri * 9 ), nor = new Float32Array(capTri * 9 ), tone = new Float32Array(capTri * 9 ), idx = new Uint32Array(capTri * 3 ); const geo = new THREE.BufferGeometry(); geo.setAttribute("position" , new THREE.BufferAttribute(pos, 3 ).setUsage(THREE.DynamicDrawUsage)); geo.setAttribute("normal" , new THREE.BufferAttribute(nor, 3 ).setUsage(THREE.DynamicDrawUsage)); geo.setAttribute("color" , new THREE.BufferAttribute(tone, 3 ).setUsage(THREE.DynamicDrawUsage)); geo.setIndex(new THREE.BufferAttribute(idx, 1 ).setUsage(THREE.DynamicDrawUsage)); const count = wedge ? 12 : 1 ; const mA = new THREE.InstancedMesh(geo, s.mats.ice, count), mB = new THREE.InstancedMesh(geo, s.mats.ice, count); mA.frustumCulled = mB.frustumCulled = false ; mA.castShadow = mB.castShadow = true ; mA.receiveShadow = mB.receiveShadow = true ; const flip = new THREE.Group(); flip.scale.y = -1 ; flip.add(mB); // 上下を返した側。three.js が面の表裏を自動で入れ替える const group = new THREE.Group(); group.position.x = offX; group.add(mA, flip); // 断面の切り口 const capPos = new Float32Array((wedge ? 2 : 1 ) * 2 * (2 * R + 2 ) * 4 * 18 ), capGeo = new THREE.BufferGeometry(); capGeo.setAttribute("position" , new THREE.BufferAttribute(capPos, 3 ).setUsage(THREE.DynamicDrawUsage)); const cap = new THREE.Mesh(capGeo, s.mats.cap); cap.frustumCulled = false ; cap.visible = false ; group.add(cap); // 水蒸気の濃さ: 高さ 0 の面。粗い三角形の頂点に濃さを持たせる const st = R >= 100 ? 3 : 2 , verts: number[] = [], cols: number[] = [], tris: number[] = [], ids = new Map<number, number>(); const vid = (i: number, j: number) => { const key = (i + 4096 ) * 8192 + j + 4096 ; let v = ids.get(key); if (v === undefined ) { v = cols.length; ids.set(key, v); cols.push(colAt(L, i, j)); verts.push((i + j * 0.5 ) * dx, 0 , j * SQ * dx); } return v; }; const inside = (i: number, j: number) => (wedge ? i >= 0 && j >= 0 : true ) && hexDist(i, j) <= R; const lo = wedge ? 0 : -R; for (let i = lo; i <= R; i += st) for (let j = lo; j <= R; j += st) { if (!inside(i, j)) continue ; if (inside(i + st, j) && inside(i, j + st)) tris.push(vid(i, j), vid(i, j + st), vid(i + st, j)); if (inside(i + st, j) && inside(i, j + st) && inside(i + st, j + st)) tris.push(vid(i + st, j), vid(i, j + st), vid(i + st, j + st)); } const fGeo = new THREE.BufferGeometry(), fCol = new Float32Array(cols.length * 3 ); fGeo.setAttribute("position" , new THREE.BufferAttribute(new Float32Array(verts), 3 )); fGeo.setAttribute("color" , new THREE.BufferAttribute(fCol, 3 ).setUsage(THREE.DynamicDrawUsage)); fGeo.setIndex(tris); const field = new THREE.InstancedMesh(fGeo, s.mats.field, wedge ? 6 : 1 ); field.frustumCulled = false ; group.add(field); const m = new THREE.Matrix4(); for (let k = 0 ; k < (wedge ? 6 : 1 ); k++) field.setMatrixAt(k, m.makeRotationY((k * Math.PI) / 3 )); s.v.scene.add(group); const c: Cry = { L, dx, h: stepMin(R), C: newCoef(), T: s.T, s: s.sig, done: false , group, geo, pos, nor, tone, idx, mA, mB, flip, capGeo, capPos, cap, field, fGeo, fCol, fIdx: Int32Array.from (cols), tris: 0 , lastR: 0 , lastM: 0 , rate: 0 , rot: null , phase: 0 , bn: 0 , bm: 0 , nv: 0 , ni: 0 }; // 全面を解くときは、60° 回した先のますを控えておく(6 本の枝を比べるため) if (!wedge) { const rot = new Int32Array(L.nc); for (let w = 0 ; w < L.nc; w++) rot[w] = colAt(L, -L.cj[w], L.ci[w] + L.cj[w]); c.rot = rot; } setInstances(c, false ); return c; } function dropCry(s: S, c: Cry) { s.v.scene.remove(c.group); c.mA.dispose(); c.mB.dispose(); c.field.dispose(); c.geo.dispose(); c.capGeo.dispose(); c.fGeo.dispose(); } /** 並べ方の行列。断面を見るときは、手前(z が正)の半分を外す */ function setInstances(c: Cry, section: boolean) { if (!c.L.wedge) { c.mA.setMatrixAt(0 , new THREE.Matrix4()); c.mB.setMatrixAt(0 , new THREE.Matrix4()); c.mA.count = c.mB.count = 1 ; c.mA.instanceMatrix.needsUpdate = c.mB.instanceMatrix.needsUpdate = true ; return ; } const all: THREE.Matrix4[] = [], mirror = new THREE.Matrix4().makeScale(1 , -1 , -1 ); for (let k = 0 ; k < 6 ; k++) { const r = new THREE.Matrix4().makeRotationY((k * Math.PI) / 3 ); all.push(r.clone(), r.clone().multiply(mirror)); } const P = new THREE.Vector3(), keep = (m: THREE.Matrix4) => P.set(Math.cos(0.26 ), 0 , Math.sin(0.26 )).applyMatrix4(m).z < 0 ; const sorted = section ? [...all.filter(keep), ...all.filter((m) => !keep(m))] : all; sorted.forEach((m, n) => { c.mA.setMatrixAt(n, m); c.mB.setMatrixAt(n, m); }); c.mA.count = c.mB.count = section ? sorted.filter(keep).length : 12 ; c.mA.instanceMatrix.needsUpdate = c.mB.instanceMatrix.needsUpdate = true ; } /** 結晶の表面に出ている面を三角形にする。上の面が平らに続く所は大きな三角形でまとめ、縦に続く横の面は 1 枚にする。 * ms ミリ秒で区切り、数コマに分けて進める。書き終えたら true を返し、three.js に渡す */ function buildMesh(c: Cry, section: boolean, ms: number) { const L = c.L, { a, nb, S, Nz, ci, cj, nc, flat, srf, wedge } = L, dx = c.dx, P = c.pos, N = c.nor, G = c.tone, I = c.idx; const maxV = P.length / 3 - 16 , maxI = I.length - 48 , t0 = performance.now(); if (c.phase === 0 ) { c.phase = 1 ; c.bn = 0 ; c.bm = 0 ; L.dirty = false ; } if (c.phase === 1 ) { // 全部のますが結晶に囲まれたら、もう描かない。上の面が平らかどうかを調べる let n = c.bn, m = c.bm; for (; n < L.nsrf; n++) { if ((n & 255 ) === 255 && performance.now() - t0 > ms) { c.bn = n; c.bm = m; return false ; } const k = srf[n], w = (k / S) | 0 , z = k - w * S, e = w * 6 , open0 = !a[k + 1 ]; // flat の印: 1 = 上の面が隣と同じ高さで続く・2 = 下の面が続く let f = (open0 ? 1 : 0 ) | (z > 0 && !a[k - 1 ] ? 2 : 0 ), covered = !open0 && (z === 0 || !!a[k - 1 ]); for (let d = 0 ; d < 6 ; d++) { const q = nb[e + d] * S + z; if (!a[q]) { covered = false ; f = 0 ; } else { if (a[q + 1 ]) f &= 2 ; if (z === 0 || a[q - 1 ]) f &= 1 ; } } flat[k] = f; if (!covered) srf[m++] = k; } L.nsrf = m; c.phase = 2 ; c.bn = 0 ; c.nv = 0 ; c.ni = 0 ; } let nv = c.nv, ni = c.ni, g = 1 ; // g は面の濃さ(高い所ほど明るくして、段の重なりが読めるように) const vert = (x: number, y: number, z: number, nx: number, ny: number, nz: number) => { const o = nv * 3 ; P[o] = x; P[o + 1 ] = y; P[o + 2 ] = z; N[o] = nx; N[o + 1 ] = ny; N[o + 2 ] = nz; G[o] = G[o + 1 ] = G[o + 2 ] = g; return nv++; }; const gz = 1 / (L.zmax + 1 ); /** 平らに続く面: このますと、右と奥の隣を結ぶ三角形を書く(3 つとも平らなときだけ。扇の外へ写った隣は使わない)。周りが全部平らなら六角形は要らない */ const flatPart = (e: number, i: number, j: number, z: number, X: number, Z: number, bit: number, y: number, ny: number) => { let deep = !(wedge && i - j <= 1 ); for (let d = 0 ; d < 6 && deep; d++) if (!(flat[nb[e + d] * S + z] & bit)) deep = false ; const direct = !wedge || j + 1 <= i, w0 = nb[e], w1 = nb[e + 1 ]; if (direct && w0 !== nc && w1 !== nc && flat[w0 * S + z] & bit && flat[w1 * S + z] & bit) { const v0 = vert(X, y, Z, 0 , ny, 0 ), v1 = vert(X + dx, y, Z, 0 , ny, 0 ), v2 = vert(X + dx * 0.5 , y, Z + SQ * dx, 0 , ny, 0 ); I[ni++] = v0; I[ni++] = ny > 0 ? v2 : v1; I[ni++] = ny > 0 ? v1 : v2; const w3 = nb[w0 * 6 + 1 ]; if (w3 !== nc && flat[w3 * S + z] & bit) { const v3 = vert(X + dx * 1.5 , y, Z + SQ * dx, 0 , ny, 0 ); I[ni++] = v1; I[ni++] = ny > 0 ? v2 : v3; I[ni++] = ny > 0 ? v3 : v2; } } return !deep; }; for (let n = c.bn; n < L.nsrf && nv < maxV && ni < maxI; n++) { if ((n & 127 ) === 127 && performance.now() - t0 > ms) { c.bn = n; c.nv = nv; c.ni = ni; return false ; } const k = srf[n], w = (k / S) | 0 , z = k - w * S, e = w * 6 , i = ci[w], j = cj[w]; if (section && !wedge && j > 0 ) continue ; const X = (i + j * 0.5 ) * dx, Z = j * SQ * dx, y0 = z === 0 ? 0 : (z - 0.5 ) * dx, y1 = (z + 0.5 ) * dx; // 横の面: 下のますから続いている面は、下のますのところでまとめて描く g = 0.5 ; for (let d = 0 ; d < 6 ; d++) { const q = nb[e + d] * S; if (a[q + z] || (z > 0 && a[k - 1 ] && !a[q + z - 1 ])) continue ; let e2 = z; while (e2 + 1 < Nz && a[w * S + e2 + 1 ] && !a[q + e2 + 1 ]) e2++; const f = SIDE[d], ca = f[0 ], sa = f[1 ], yt = (e2 + 0.5 ) * dx, c1x = X + f[2 ] * dx, c1z = Z + f[3 ] * dx, c2x = X + f[4 ] * dx, c2z = Z + f[5 ] * dx; const b0 = vert(c2x, y0, c2z, ca, 0 , sa), b1 = vert(c1x, y0, c1z, ca, 0 , sa), b2 = vert(c1x, yt, c1z, ca, 0 , sa), b3 = vert(c2x, yt, c2z, ca, 0 , sa); I[ni++] = b0; I[ni++] = b1; I[ni++] = b2; I[ni++] = b0; I[ni++] = b2; I[ni++] = b3; } // 上の面 g = TONE0 + (1 - TONE0) * Math.min(1 , (z + 1 ) * gz); if (!a[k + 1 ]) { let fan = true , nx = 0 , ny = 1 , nz = 0 ; if (flat[k] & 1 ) { fan = flatPart(e, i, j, z, X, Z, 1 , y1, 1 ); } else { // 段差のそばの面は、段差の向きへ少し傾けて照らす(尾根と溝が明暗で読める) let gx = 0 , gy = 0 ; for (let d = 0 ; d < 6 ; d++) { const q = nb[e + d] * S + z, h = a[q + 1 ] ? 1 : a[q] ? 0 : -1 ; gx += h * DX[d]; gy += h * DZ[d]; } nx = -0.3 * gx; nz = -0.3 * gy; const l = 1 / Math.hypot(nx, 1 , nz); nx *= l; ny = l; nz *= l; } if (fan) { const b = nv; for (let q = 0 ; q < 6 ; q++) vert(X + CORN[q][0 ] * dx, y1, Z + CORN[q][1 ] * dx, nx, ny, nz); I[ni++] = b; I[ni++] = b + 2 ; I[ni++] = b + 1 ; I[ni++] = b; I[ni++] = b + 3 ; I[ni++] = b + 2 ; I[ni++] = b; I[ni++] = b + 4 ; I[ni++] = b + 3 ; I[ni++] = b; I[ni++] = b + 5 ; I[ni++] = b + 4 ; } } // 下の面(柱の両端に広がった板の裏など) if (z > 0 && !a[k - 1 ]) { g = 0.42 ; if (!(flat[k] & 2 ) || flatPart(e, i, j, z, X, Z, 2 , y0, -1 )) { const b = nv; for (let q = 0 ; q < 6 ; q++) vert(X + CORN[q][0 ] * dx, y0, Z + CORN[q][1 ] * dx, 0 , -1 , 0 ); I[ni++] = b; I[ni++] = b + 1 ; I[ni++] = b + 2 ; I[ni++] = b; I[ni++] = b + 2 ; I[ni++] = b + 3 ; I[ni++] = b; I[ni++] = b + 3 ; I[ni++] = b + 4 ; I[ni++] = b; I[ni++] = b + 4 ; I[ni++] = b + 5 ; } } } const ia = c.geo.index as THREE.BufferAttribute; for (const key of ["position" , "normal" , "color" ]) { const at = c.geo.attributes[key] as THREE.BufferAttribute; at.clearUpdateRanges(); at.addUpdateRange(0 , nv * 3 ); at.needsUpdate = true ; } ia.clearUpdateRanges(); ia.addUpdateRange(0 , ni); ia.needsUpdate = true ; c.geo.setDrawRange(0 , ni); c.tris = ni / 3 ; c.phase = 0 ; c.cap.visible = section; if (section) buildCap(c); return true ; } /** 断面の切り口: 枝の中心線(j = 0)を通る面で切った長方形 */ function buildCap(c: Cry) { const L = c.L, dx = c.dx, CP = c.capPos, face = 0.59 * dx; let o = 0 ; const rect = (x0: number, x1: number, y0: number, y1: number) => { if (o + 18 > CP.length) return ; CP[o++] = x0; CP[o++] = y0; CP[o++] = face; CP[o++] = x1; CP[o++] = y0; CP[o++] = face; CP[o++] = x1; CP[o++] = y1; CP[o++] = face; CP[o++] = x0; CP[o++] = y0; CP[o++] = face; CP[o++] = x1; CP[o++] = y1; CP[o++] = face; CP[o++] = x0; CP[o++] = y1; CP[o++] = face; }; for (let w = 0 ; w < L.nc; w++) { if (L.cj[w] !== 0 || L.top[w] < 0 ) continue ; const i = L.ci[w], cx = i * dx; let z = 0 ; while (z <= L.top[w]) { if (!L.a[w * L.S + z]) { z++; continue ; } let e = z; while (e + 1 <= L.top[w] && L.a[w * L.S + e + 1 ]) e++; const y0 = (z - 0.5 ) * dx, y1 = (e + 0.5 ) * dx; for (const sx of L.wedge && i !== 0 ? [1 , -1 ] : [1 ]) { rect(sx * cx - dx / 2 , sx * cx + dx / 2 , z === 0 ? 0 : y0, y1); rect(sx * cx - dx / 2 , sx * cx + dx / 2 , -y1, z === 0 ? 0 : -y0); } z = e + 1 ; } } const pa = c.capGeo.attributes.position as THREE.BufferAttribute; pa.clearUpdateRanges(); pa.addUpdateRange(0 , o); pa.needsUpdate = true ; c.capGeo.setDrawRange(0 , o / 3 ); } /** 水蒸気の濃さを頂点の明るさにする。遠くの値 ρ を基準に、少なくなった所ほど点がまばらになる */ function paintField(c: Cry) { const L = c.L, F = c.fCol, I = c.fIdx, inv = FIELD / Math.max(1e-6 , L.rho); for (let v = 0 ; v < I.length; v++) { const t = Math.min(0.23 , L.d[I[v] * L.S] * inv); F[v * 3 ] = F[v * 3 + 1 ] = F[v * 3 + 2 ] = t; } (c.fGeo.attributes.color as THREE.BufferAttribute).needsUpdate = true ; }
buildMeshは、結晶の表面のますを順に見て、外に出ている面を三角形にする。縦に続く横の面は1枚にまとめ、平らに続く上の面は六角形でなく大きな三角形で埋める。こうして作った24分の1の形を、setInstancesの行列で12回ずつ並べる(InstancedMesh)。上下を返した側はflipの中に入れると、three.jsが面の表裏を入れ替えてくれる。断面を見るときは、手前の半分の行列を外し、切り口の長方形をbuildCapで足す。三角形を書く作業は1コマ1.2 msで区切り、書き終えたときだけGPUへ送る。描く三角形がTRI_MAX(30万個)を超えたら、育てるのを止める。水蒸気の濃さはpaintFieldが高さ0の面の頂点の明るさにする。
図と履歴
中谷ダイヤグラムは3Dの絵の上に2Dで描く。結晶は図を除いた広さの真ん中に写すので、layoutでカメラの中心をずらしている。
図と履歴(src/sims/snow-crystal.ts の 500〜585 行) /** 画面の配置を決める。横長なら図を左下、縦長なら下に置き、結晶は残りの広さの真ん中に写す */ function layout(api: Api, s: S) { const W = api.w, H = api.h, U = api.u, land = W >= H * 1.05 , m = U(10 ); let pw: number, ph: number; if (land) { pw = Math.min(W * 0.38 , H * 0.66 , U(300 )); ph = pw * 0.8 ; } else { pw = W - 2 * m; ph = Math.min(H * 0.36 , pw * 0.62 ); } pw = Math.max(pw, Math.min(W - 2 * m, 200 )); ph = Math.max(ph, 150 ); const x = m, y = H - ph - Math.max(m, U(34 )), b = s.box, al = U(30 ), ab = U(26 ), at = U(24 ), ar = U(16 ); b.x = x; b.y = y; b.w = pw; b.h = ph; b.x0 = x + al; b.x1 = x + pw - ar; b.y0 = y + at; b.y1 = y + ph - ab; // 上の端は空けておく(画面の知らせが結晶に重ならないように) const top = U(land ? 44 : 54 ); if (land) { s.freeX = x + pw; s.freeY = top; s.freeW = W - s.freeX; s.freeH = H - top; } else { s.freeX = 0 ; s.freeY = top; s.freeW = W; s.freeH = y - top; } s.v.camera.setViewOffset(W, H, W / 2 - (s.freeX + s.freeW / 2 ), H / 2 - (s.freeY + s.freeH / 2 ), W, H); } const tx = (b: Box, T: number) => b.x0 + (-T / 30 ) * (b.x1 - b.x0);const sy = (b: Box, sg: number) => b.y1 - Math.sqrt(clamp(sg, 0 , SMAX) / SMAX) * (b.y1 - b.y0);const txi = (b: Box, x: number) => clamp((-(x - b.x0) / (b.x1 - b.x0)) * 30 , TMIN, TMAX);const syi = (b: Box, y: number) => { const v = (b.y1 - y) / (b.y1 - b.y0); return clamp(SMAX * v * Math.max(v, 0 ), SMIN, SMAX); }; /** 中谷ダイヤグラムの大まかな区切りと、今の条件・履歴を描く */ function drawDiagram(api: Api, s: S) { const b = s.box, U = api.u, fs = Math.max(U(10 ), 9.5 ), small = fs * 0.92 ; api.rect(b.x, b.y, b.w, b.h, { fill: "paper" , w: U(1 ) }); api.text("中谷ダイヤグラム" , b.x + U(8 ), b.y + U(14 ), { size: fs * 1.05 , weight: 600 }); // 板と柱の帯 for (const T of [-4 , -10 , -22 ]) { const x = tx(b, T); api.line(x, b.y0, x, b.y1, { w: U(0.7 ), dash: [U(2 ), U(3 )] }); } const band = (T: number, sg: number, t: string, o?: { weight?: number }) => api.text(t, tx(b, T), sy(b, sg), { size: small, align: "center" , baseline: "middle" , weight: o?.weight ?? 400 }); band(-2 , 32 , "板" , { weight: 700 }); band(-7 , 32 , "柱" , { weight: 700 }); band(-16 , 36 , "板" , { weight: 700 }); band(-26 , 36 , "柱" , { weight: 700 }); band(-15.5 , 24 , "樹枝" ); band(-15 , 2.2 , "角板" ); band(-7 , 1.2 , "角柱" ); band(-5.5 , 11 , "針" ); // 軸 api.line(b.x0, b.y1, b.x1, b.y1, { w: U(1 ) }); api.line(b.x0, b.y0, b.x0, b.y1, { w: U(1 ) }); for (const T of [0 , -10 , -20 , -30 ]) { const x = tx(b, T); api.line(x, b.y1, x, b.y1 + U(3 ), { w: U(1 ) }); api.text(fmt(T, 0 ), x, b.y1 + U(13 ), { size: small, align: "center" , font: "mono" }); } api.text("温度 °C" , b.x1, b.y1 + U(23 ), { size: small, align: "right" }); for (const v of [1 , 5 , 10 , 20 , 40 ]) { const y = sy(b, v); api.line(b.x0 - U(3 ), y, b.x0, y, { w: U(1 ) }); api.text(String(v), b.x0 - U(5 ), y + small * 0.34 , { size: small, align: "right" , font: "mono" }); } api.text("過飽和度 %" , b.x + U(4 ), b.y1 + U(23 ), { size: small }); // 水の飽和(雲の水滴に接している空気) const pts: number[] = []; for (let T = -0.5 ; T >= -30 ; T -= 1 ) pts.push(tx(b, T), sy(b, sigmaWater(T))); api.poly(pts, { w: U(1.6 ) }); { const q = -25.5 ; api.text("水の飽和" , tx(b, q), sy(b, sigmaWater(q)) + small * 1.5 , { size: small, align: "center" }); } // 履歴 if (s.grab === "draw" && s.draft.length > 1 ) { const d: number[] = []; for (const p of s.draft) d.push(tx(b, p[1 ]), sy(b, p[2 ])); api.poly(d, { w: U(2.6 ) }); } else if (s.plan) { const P = s.plan, d: number[] = []; for (let i = 0 ; i < P.n; i++) d.push(tx(b, P.T[i]), sy(b, P.s[i])); api.poly(d, { w: U(2.2 ) }); const o = { T: 0 , s: 0 }; for (let m = 0 ; m <= P.end + 1e-3 ; m += 10 ) { planAt(P, m, o); api.dot(tx(b, o.T), sy(b, o.s), U(2.6 )); } } else if (s.trail.length > 4 ) { const d: number[] = []; for (let i = 0 ; i + 1 < s.trail.length; i += 2 ) d.push(tx(b, s.trail[i]), sy(b, s.trail[i + 1 ])); api.poly(d, { w: U(1.6 ), dash: [U(3 ), U(3 )] }); } // 今の点: 地の色で縁どった丸と、つかめる印の点線の輪 const c0 = s.cry[0 ], T = s.grab === "handle" || !c0 ? s.T : c0.T, sg = s.grab === "handle" || !c0 ? s.sig : c0.s, X = tx(b, T), Y = sy(b, sg); if (s.mode === "twin" && s.cry[1 ]) { const c1 = s.cry[1 ]; api.circle(tx(b, c1.T), sy(b, c1.s), U(4 ), { fill: "paper" , w: U(1.4 ) }); } api.circle(X, Y, U(5 ), { fill: "ink" , stroke: "paper" , w: U(1.5 ) }); api.circle(X, Y, U(11 ), { stroke: "paper" , w: U(3 ) }); api.circle(X, Y, U(11 ), { w: U(1.4 ), dash: [U(3 ), U(3 )] }); } /** 結晶の絵に重ねる文字: 物差しと名札 */ function drawLabels(api: Api, s: S) { const U = api.u, v = s.v, d = v.orbit.dist, px = api.h / (2 * Math.tan((v.camera.fov * Math.PI) / 360 ) * d); let L = 1 ; for (const q of [0.05 , 0.1 , 0.2 , 0.5 , 1 , 2 , 5 ]) if (q * px <= s.freeW * 0.28 ) L = q; const x0 = s.freeX + s.freeW / 2 - (L * px) / 2 , y = s.freeY + s.freeH - U(18 ), x1 = x0 + L * px; for (const [w, c] of [[U(5 ), "paper" ], [U(2 ), "ink" ]] as const ) { api.line(x0, y, x1, y, { w, stroke: c }); api.line(x0, y - U(5 ), x0, y + U(5 ), { w, stroke: c }); api.line(x1, y - U(5 ), x1, y + U(5 ), { w, stroke: c }); } api.label(` ${L} mm` , (x0 + x1) / 2 , y - U(9 ), { size: U(12 ), align: "center" }); if (s.mode === "twin" && s.cry.length === 2 ) ["A" , "B" ].forEach((t, n) => { const c = s.cry[n], p = v.project([c.group.position.x, 0 , -(c.L.rmax + 4 ) * c.dx]); if (p.on) api.label(t, p.x, Math.max(U(18 ), p.y - U(8 )), { size: U(15 ), weight: 700 , align: "center" }); }); } function readouts(api: Api, s: S) { const c = s.prev && s.grab === "handle" ? s.prev : s.cry[0 ]; if (!c) return ; const L = c.L, d = (2 * L.rmax + 1 ) * c.dx, th = (2 * L.zmax + 1 ) * c.dx, wr = sigmaWater(c.T), lines: string[] = []; lines.push(` 経過 ${fmt(s.grab === "handle" ? L.t * c.h : s.minute, 1)} 分${s.grab === "handle" ? "(粗い下書き)" : ""}` ); lines.push(` 温度と過飽和度 ${fmt(c.T, 1)} °C ${fmt(c.s, 1)} %(水の飽和の ${fmt(c.s / Math.max(wr, 0.1), 2)} 倍)` ); lines.push(` 大きさ 差し渡し ${fmt(d, 2)} mm 厚さ ${fmt(th, 2)} mm` ); lines.push(` 形 ${s.shape} 先の伸びる速さ ${fmt(c.rate, 1)} mm/時` ); if (s.mode === "twin" ) lines.push(` A と B の違い 結晶のますの ${fmt(s.diff, 1)} %` ); if (s.mode === "full" ) lines.push(` 6 本の枝 長さ ${fmt(s.arms[0], 2)}〜${fmt(s.arms[1], 2)} mm 回して重ねると ${fmt(s.sym, 1)} % が違う` ); api.readout(lines); }
横軸は温度で、縦軸は過飽和度の平方根にした。水の飽和の曲線は−5 °Cの5%から−30 °Cの34%まで広がるので、平方根の目盛りなら低い所も読める。板と柱の区切りの点線は−4 °C、−10 °C、−22 °Cに引いた。資料にある−2、−5、−15、−30 °C付近の記述から決めた目安で、計算には使っていない。
点を動かす、なぞる、種を置く
図の点をつかむとrunPreviewが半径32ますの粗い格子で結晶を育て、形をすぐに見せる。離すと、本来の格子で育て直す。
操作(src/sims/snow-crystal.ts の 675〜710 行) function startDraw(api: Api, s: S) { s.grab = "draw" ; s.draft = []; s.draftT0 = performance.now(); addDraft(api, s); }function addDraft(api: Api, s: S) { const b = s.box, p = api.pointer; s.draft.push([(performance.now() - s.draftT0) / 1000 , txi(b, p.x), syi(b, p.y)]); } /** なぞり終えたら、指の動いた時間をそのまま履歴の時間にする(ゆっくりなぞった所に長くとどまる) */ function finishDraw(api: Api, s: S) { const D = s.draft, span = D.length ? D[D.length - 1 ][0 ] : 0 ; const moved = D.length > 1 && Math.hypot(tx(s.box, D[0 ][1 ]) - tx(s.box, D[D.length - 1 ][1 ]), sy(s.box, D[0 ][2 ]) - sy(s.box, D[D.length - 1 ][2 ])) > api.u(10 ); s.grab = null ; if (!moved || span <= 0 ) { // たたいただけなら、その条件で育て直す s.plan = null ; s.T = D[0 ]?.[1 ] ?? s.T; s.sig = D[0 ]?.[2 ] ?? s.sig; s.rs = 1 ; syncKnobs(api, s); if (s.curRs === s.rs) restart(api, s); return ; } // 点の数を 120 までに間引く const st = Math.max(1 , Math.floor(D.length / 120 )), pts: [number, number, number][] = []; for (let i = 0 ; i < D.length; i += st) pts.push([(D[i][0 ] / span) * PLAN_MIN, D[i][1 ], D[i][2 ]]); const last = D[D.length - 1 ]; if (pts[pts.length - 1 ][0 ] < PLAN_MIN) pts.push([PLAN_MIN, last[1 ], last[2 ]]); s.plan = makePlan(pts); if (s.mode === "one" && s.curRs !== PLAN_RS) s.rs = PLAN_RS; else restart(api, s); api.note("なぞった道すじのとおりに育てます" , 2200 ); } function syncKnobs(api: Api, s: S) { api.set("T" , Math.round(s.T * 10 ) / 10 ); api.set("sig" , Math.round(s.sig * 10 ) / 10 ); }function placeSeed(api: Api, s: S) { if (s.mode !== "full" ) { if (!s.noted.seed) { s.noted.seed = true ; api.note("種を足せるのは「ゆらぎ」の場面です(ほかの場面は 6 本がそろう計算です)" , 2600 ); } return ; } const c = s.cry[0 ], L = c.L, q = s.v.onPlane(api.pointer.x, api.pointer.y, new THREE.Plane(new THREE.Vector3(0 , 1 , 0 ), 0 )); if (!q) return ; const j = Math.round(q.z / (SQ * c.dx)), i = Math.round(q.x / c.dx - j / 2 ); if (hexDist(i, j) > L.R - 6 ) return ; addSeed(L, i, j, 2 ); L.rmax = Math.max(L.rmax, hexDist(i, j) + 2 ); c.done = false ; api.note("新しい種を置きました。近くの枝と水蒸気を取り合います" , 2200 ); }
点のない所から図をなぞるとaddDraftが時刻と条件を記録し、finishDrawが全体を36分に引き伸ばして履歴(Plan)にする。動かした時間を残しているので、指を止めた所に結晶が長くとどまる。履歴をたどる間はupdateCoefが16刻みごとに係数を更新し、ρ が変わればsetRhoで場全体を同じ倍率にする。本物の空気では拡散が成長よりはるかに速いので、遠くから新しい濃さがしみ込むのを待たない。「ゆらぎ」の場面で空いた所をたたくとplaceSeedが種を足し、向かい合う枝が水蒸気を取り合う。