▍AI Dev Lab
InkTide

InkTide ができるまで — Stable Fluids を WebGL2 で動かす

GPU 流体シミュレーターの開発ログ。 Jos Stam の Stable Fluids を WebGL2 のシェーダーで実装し、 速度場と染料場を 2 枚のテクスチャで入れ替えながら 1 フレーム 7 段階で解く構成を記録しています。

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 ステップは、こういう順番で解きます。

#パスやること
1splatマウス位置に速度と色を足す
2advect velocity速度場を自分自身で流す
3divergence発散 ∇·u を求める
4pressure (×20〜30)∇²p = ∇·u を反復で解く
5gradient subtract圧力の勾配を速度から引く
6advect dye染料を速度場で流す
7display画面に出す

4 が反復なので、実際の描画呼び出しは 30 回近くになります。

#⏪ 移流は「逆にたどる」

いちばん面白いのが移流(advect)です。素朴には「各点の値を速度方向へ運ぶ」と考えますが、それだと運んだ先が格子点に乗らず、隙間や重なりができます。

Stable Fluids は逆に考えます。「この点の値は、1 ステップ前にどこにあったか」を逆算して、そこから読む。

glsl
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 の機能が手法にぴったり合っているのが気持ちいいところです。

#💨 非圧縮にするための圧力ステップ

流体らしく見せる核心が、**「圧縮しない」**という条件です。速度場の発散(湧き出し・吸い込み)がゼロでなければなりません。

まず発散を求めます。

glsl
float div = 0.5 * (R - L + T - B);

隣接 4 点の速度差を取るだけの中心差分です。

次にポアソン方程式 ∇²p = ∇·u を Jacobi 反復で解きます。1 回では収束しないので 20〜30 回まわす。これが 7 パス中で一番重い部分ですが、反復回数を減らすと流体が「べたつく」ような不自然さが出ます。

最後に圧力の勾配を速度から引くと、発散のない速度場になります。この一連がないと、ただ色が流れるだけで渦が生まれません。

#🎨 4 つのプリセット

インク(水中)・けむり・ようがん・せいうんの 4 つは、パラメータの違いだけです。

  • 散逸率(dissipation) — 1 に近いほど色が残る。けむりは早く消え、インクは残る
  • 色相の基準と幅 — ようがんは赤〜橙の狭い範囲、せいうんは広く回す
  • splat の半径と強さ — 太い筆か細い筆か
ts
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 をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。

[ ./related_logs ]

関連する開発ログ

all logs →
ShadowTwin

立体パズルの別解を作る:同じ影と最少の積み木

ShadowTwinの開発記録。3方向の図から別々の最少配置を2つ探す仕組み、5問の独立全探索、スマホの横並び比較、保存・再編集と入力検査を解説します。実照明の計算や能力診断ではありません。

read log →
SlitCard

スリットアニメーションを作る:4コマの合成と透明原稿の設計

SlitCardの開発記録。4コマの帯を交互に合成し、すき間と黒帯で別のコマを見せる仕組みを解説。同縮尺の台紙・透明シートSVG、50mm確認線、手動プレビュー・初期非表示、再編集と実出力検査を整理します。

read log →
WindowLight

日当たりシミュレーションを作る:窓の光を床へ投影する幾何モデル

WindowLightの開発記録。太陽位置近似から窓の4隅を床へ投影し、凸多角形を部屋で切り取る設計を解説。国立天文台の16時刻との限定照合、大気差・低高度・実際の採光との違い、SVGと設定の再編集を整理します。

read log →
GridLights

電源構成の比較を作る:割合と実量を100個の電球で読み解く

電源構成比較GridLightsの開発記録。12か国の年次発電データを最大剰余法で100個の電球に変換し、割合とTWhを比較する設計を解説。欠損を0補完しない7分類、予想と実値、出典付きSVGと実データ検証を整理します。

read log →
SoundLoom

音のお絵かきを作る:実WAVから絵を測り直す設計

音のお絵かきの開発記録。描画を正弦波のPCMとWAVへ変換し、保存音声の周波数をGoertzel法で測って比較する仕組みを解説。音量・測定窓・量子化の限界と、編集・離脱時の古い結果を抑止する検証を整理します。

read log →
MazeLoom

宝探し迷路を作る:寄り道まで含めた最短ルートの探索

宝探し迷路の開発記録。通路を木として生成し、現在地と3つの宝の取得状態を組み合わせて最短ルートを探す仕組みを解説。問題コードの版固定、画面と解答付きPDFの一致、手数と印刷の検証を整理します。

read log →