InkTide は、水中に落としたインクのように広がる流体をブラウザで動かすシミュレーターです。Jos Stam の "Stable Fluids"(1999)を WebGL2 のフラグメントシェーダーで実装し、1 フレームあたり 7 段のパスで解いています。
#🎯 流体を CPU で書くと動かない
流体シミュレーションは、格子の全点について速度と圧力を解く計算です。512×512 なら 26 万点。これを JavaScript のループで毎フレーム回すと、どうやっても間に合いません。
一方 GPU は、全ピクセルに同じ計算を並列に適用するのが得意です。流体の各段はまさにそういう計算なので、フラグメントシェーダーに載せると桁が変わります。
なので設計は最初から「速度場も染料場もテクスチャとして持ち、シェーダーで更新する」形にしました。CPU 側は描画命令を並べるだけで、数値は一切触りません。
#🔁 ping-pong で場を更新する
GPU では、読んでいるテクスチャに同時に書き込めません。そこで同じ場を 2 枚用意して、読み書きを交互に入れ替えます。
[A を読む] → [B へ書く] → 入れ替え → [B を読む] → [A へ書く] → …これが ping-pong 法です。速度場・染料場・圧力場それぞれに 2 枚ずつ持ちます。テクスチャの形式は RGBA16F(半精度浮動小数点)にしました。速度は負の値を取るので、通常の 8bit 整数テクスチャでは表現できません。
#🌊 1 フレーム 7 パス
Stable Fluids の 1 ステップは、こういう順番で解きます。
| # | パス | やること |
|---|---|---|
| 1 | splat | マウス位置に速度と色を足す |
| 2 | advect velocity | 速度場を自分自身で流す |
| 3 | divergence | 発散 ∇·u を求める |
| 4 | pressure (×20〜30) | ∇²p = ∇·u を反復で解く |
| 5 | gradient subtract | 圧力の勾配を速度から引く |
| 6 | advect dye | 染料を速度場で流す |
| 7 | display | 画面に出す |
4 が反復なので、実際の描画呼び出しは 30 回近くになります。
#⏪ 移流は「逆にたどる」
いちばん面白いのが移流(advect)です。素朴には「各点の値を速度方向へ運ぶ」と考えますが、それだと運んだ先が格子点に乗らず、隙間や重なりができます。
Stable Fluids は逆に考えます。「この点の値は、1 ステップ前にどこにあったか」を逆算して、そこから読む。
vec2 vel = texture(uVelocity, vUv).xy;
vec2 prev = vUv - vel * uDt * uTexel;
vec4 result = texture(uSource, prev);
fragColor = uDissipation * result;たった 3 行です。速度の逆方向に dt ぶん戻った座標を作り、そこをサンプリングする。これが半ラグランジュ法で、どんなに大きい dt でも破綻しない(=無条件安定、Stable Fluids の名前の由来)という性質があります。
しかもテクスチャの線形補間がそのまま補間器として使えるので、格子間の値も GPU が勝手に埋めてくれます。GPU の機能が手法にぴったり合っているのが気持ちいいところです。
#💨 非圧縮にするための圧力ステップ
流体らしく見せる核心が、**「圧縮しない」**という条件です。速度場の発散(湧き出し・吸い込み)がゼロでなければなりません。
まず発散を求めます。
float div = 0.5 * (R - L + T - B);隣接 4 点の速度差を取るだけの中心差分です。
次にポアソン方程式 ∇²p = ∇·u を Jacobi 反復で解きます。1 回では収束しないので 20〜30 回まわす。これが 7 パス中で一番重い部分ですが、反復回数を減らすと流体が「べたつく」ような不自然さが出ます。
最後に圧力の勾配を速度から引くと、発散のない速度場になります。この一連がないと、ただ色が流れるだけで渦が生まれません。
#🎨 4 つのプリセット
インク(水中)・けむり・ようがん・せいうんの 4 つは、パラメータの違いだけです。
- 散逸率(dissipation) — 1 に近いほど色が残る。けむりは早く消え、インクは残る
- 色相の基準と幅 — ようがんは赤〜橙の狭い範囲、せいうんは広く回す
- splat の半径と強さ — 太い筆か細い筆か
export function colorForSplat(preset: FluidPreset, t: number): [number, number, number]時間とともに色相を回して、同じ場所を触り続けても色が変わるようにしています。
#😓 苦労したところ
テクスチャ形式のフォールバック。 RGBA16F が使えない環境があります。拡張の有無を見て RGBA32F や RGBA8 に落とす分岐を入れましたが、8bit まで落ちると速度の負値が表現できず挙動が変わります。ここは完全には解決していません。
アスペクト比。 正方形でないキャンバスだと、splat が楕円になり、渦が縦横で違う速度になります。テクセルサイズの計算で補正しています。
触られないと何も起きない。 初回訪問で真っ黒な画面だと離脱します。アイドル時に自動で splat を打つ演出を入れて、開いた瞬間から流体が動いている状態にしました。
dt を実時間にしない。 フレーム間隔をそのまま dt に使うと、重い環境で dt が大きくなり挙動が変わります。固定 dt にして、フレームが飛んでも見た目が変わらないようにしました。
#🔭 今後の拡張
- 温度と浮力(煙が上に立ちのぼる)
- 障害物を置いて流れを変える
- 渦度の強調(vorticity confinement)で渦をはっきりさせる
- 動画としての書き出し
#💡 このサービスから言えること
手法とハードウェアの相性が合うと、実装が驚くほど短くなります。 移流のシェーダーは 3 行、発散は 1 行。半ラグランジュ法がテクスチャサンプリングと補間にそのまま乗るからです。CPU で同じことを書くと、補間器を自分で書くところから始まります。
そして 1999 年の論文が、四半世紀たった今もブラウザで動いて綺麗に見える ── 古い手法を疑わずに読むと、近道が見つかるという例でもありました。
[ ./next_action ]
読んだら、 InkTide を実際に動かす。
この開発ログは InkTide をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。