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 →
ToonCast

ToonCast ができるまで — AnimeGANv2 をブラウザで動かす

AnimeGANv2 の小さな ONNX (約9MB) を onnxruntime-web (単一スレッド WASM=COOP/COEP不要、 color-revive で承認済みライブラリの再利用) で実行。 512x512・[-1,1] 正規化で推論し、 結果を元解像度に戻して表示する設計記録。 写真は端末内処理。

read log →
ColorRevive

ColorRevive ができるまで — onnxruntime-web で白黒写真をカラー化

DeOldify の量子化 ONNX を onnxruntime-web (CDN side-load・単一スレッド WASM=COOP/COEP不要) で実行。 256x256 でモデル推論し、 輝度は元写真・色だけ AI を YCbCr で再合成して輪郭を保つ設計記録。 写真は端末内処理。

read log →
PhotoTwin

PhotoTwin ができるまで — CLIP画像埋め込みで似た写真を見つける

CLIP (Xenova/clip-vit-base-patch32) の image-feature-extraction を transformers.js の CDN ESM で side-load し、 各写真を正規化ベクトル化。 cosine 類似度で重複・似た写真をブラウザ内で検出する設計記録 (新ライブラリ追加なし=what-cam と同じ CLIP の再利用)。

read log →
AkinFind

AkinFind ができるまで — ブラウザ内で完結する embeddings 意味検索

多言語の文章埋め込みモデル (Xenova/multilingual-e5-small) を transformers.js の CDN ESM で side-load し、 各文を正規化ベクトル化。 cosine 類似度で意味検索と似ている文ペア検出を全て端末内で行う設計記録。

read log →
WhatCam

WhatCam ができるまで — CLIP のゼロショット画像分類をブラウザで動かす

CLIP (Xenova/clip-vit-base-patch32) を transformers.js の CDN ESM で side-load し、 写真と候補ラベルの近さをブラウザ内で計算。 日本語ラベルを英語プロンプトに変換し、 図鑑と自由入力の両モードで「これ何?」を判定する設計記録。

read log →
DepthCast

DepthCast ができるまで — 1枚の写真をAIの深度推定で立体にする

Depth Anything (transformers.js) を CDN ESM で side-load し、 1枚の写真から深度マップを推定。 WebGL2 フラグメントシェーダで深度に比例した視差 (iterative backward parallax) を作り、 赤青アナグリフ / WebM 書き出しまで端末内で完結させた設計記録。

read log →