GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

流体シミュレーション9種 — canvas の煙・インク格子計算 (WebGLなし)

流体シミュレーションとは、ナビエ–ストークス方程式を厳密に解くことではなく、格子状に並べた密度と速度の配列を毎フレーム「移流・拡散・減衰」させて流れを模す計算のことだ。この記事では canvas 2D コンテキストだけで、32×32 の Float32Array 格子1枚から9つのシーンを作る。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

なぜこの9種を、この順番で並べたのか

本記事の軸は、canvas シーンエフェクト9種のピクセル単位の屈折・投影でも、canvas パーティクルエフェクト9種の粒子一つひとつの散らばりでもない。毎フレーム Float32Array の格子に速度(u, v)と密度(d)を保存し、格子全体を移流させ、拡散させ、減衰させるオイラー的な Stable Fluids 系の計算こそが、この9種の正体だ。粒子を積み上げるわけでも、波動方程式で表面を屈折させるわけでもなく、各セルの数字2〜3個をループで更新しているだけである。

ボード01はそのループの骨格(後方移流)だけを見せ、02は隣接セルと値を混ぜる拡散の1ステップを取り出す。03は格子近似が削り落とした回転を計算で戻し、04・05は01の骨格にクリック注入と浮力を乗せた応用だ。06は初期条件を1つだけ植えて減衰を観察する逆方向の実験、07はチャンネルを3つに広げたバリエーション、08は人の手が直接力を入れるインタラクション、09は計算結果そのものではなく、結果を画面へどう拡大して描くかという問題を扱う。順に見ていくと、流体シミュレーションが移流・拡散・渦度・境界という独立した部品に分解できることがわかる。

01安定流体の移流

何が見えるか

速度場が自分自身を運ぶ自己移流と、密度を運ぶ物質移流が1つの画面で重なって回る — 注目するセルが前フレームのどこから来たかを速度をさかのぼってサンプリングする後方移流(backtrace)が、時間刻みを大きくしても値が発散しない安定性の鍵である。ヒーロー背景のゆっくり回る流体に使える。

安定流体の移流
新しいタブで開く

01 · 安定流体の移流 — 計測 累積面積 11.342% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

Float32ArrayMath.hypotrequestAnimationFrame

コードのどこを見るか

レンダリング実測は、累積面積 11.342% · 強度 155.3 · 動いたフレーム 23/23 である。

function advect(b, dt) {
  var src = b === 'u' ? u0 : (b === 'v' ? v0 : d0);
  var dst = b === 'u' ? u : (b === 'v' ? v : d);
  for (var j = 1; j <= N; j++) {
    for (var i = 1; i <= N; i++) {
      // 速度をさかのぼって、このセルは前フレームのどこから来たか
      var x = i - dt * N * u0[IX(i, j)];
      var y = j - dt * N * v0[IX(i, j)];
      if (x < 0.5) x = 0.5; else if (x > N + 0.5) x = N + 0.5;
      var i0 = x | 0, i1 = i0 + 1;
      if (y < 0.5) y = 0.5; else if (y > N + 0.5) y = N + 0.5;
      var j0 = y | 0, j1 = j0 + 1;
      // 4近傍の双線形補間で滑らかにサンプリング
      var s1 = x - i0, s0 = 1 - s1, t1 = y - j0, t0 = 1 - t1;
      dst[IX(i, j)] = s0 * (t0 * src[IX(i0, j0)] + t1 * src[IX(i0, j1)]) +
                      s1 * (t0 * src[IX(i1, j0)] + t1 * src[IX(i1, j1)]);
    }
  }
}

02拡散のにじみ

何が見えるか

各セルが隣接4セルと値を少しずつ混ぜる拡散の1ステップが、インクが水ににじむようなスムージングである — 拡散係数を大きくするとデカルコマニーのように柔らかく広がり、0に近づけると境界が鮮明に残る。水彩背景やテクスチャーセクションに使える。

拡散のにじみ
新しいタブで開く

02 · 拡散のにじみ — 計測 累積面積 45.362% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

Float32ArrayputImageData

コードのどこを見るか

レンダリング実測は、累積面積 45.362% · 強度 31.5 · 動いたフレーム 23/23 である。

function diffuse(dt) {
  var a = dt * DIFF * N * N;
  // ガウス=ザイデル風の反復緩和で少しずつ混ぜる
  for (var k = 0; k < ITER; k++) {
    for (var j = 1; j <= N; j++) {
      for (var i = 1; i <= N; i++) {
        d[IX(i, j)] = (d0[IX(i, j)] + a * (d[IX(i - 1, j)] + d[IX(i + 1, j)] +
                       d[IX(i, j - 1)] + d[IX(i, j + 1)])) / (1 + 4 * a);
      }
    }
    bounds(); // 境界セルの値を毎反復で更新
  }
}

03カール(回転)の渦

何が見えるか

速度場の回転成分(curl)を取り出して接線方向の力として足し戻すと、格子近似が削り落とした小さな渦が復活する — 濃度の境目に糸を巻くような螺旋ができ、油の滴や渦巻き背景の表現に向く。渦が自発的に発生するのではなく、計算が意図的に回転を補っている点がポイントだ。

カールの渦
新しいタブで開く

03 · カールの渦 — 計測 累積面積 7.641% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

Float32ArrayMath.hypot

コードのどこを見るか

レンダリング実測は、累積面積 7.641% · 強度 115.1 · 動いたフレーム 23/23 である。

function vorticity(dt) {
  for (var j = 1; j <= N; j++) {
    for (var i = 1; i <= N; i++) {
      // カール = 速度場の回転(z成分のみ)
      curl[IX(i, j)] = (v[IX(i + 1, j)] - v[IX(i - 1, j)]) -
                       (u[IX(i, j + 1)] - u[IX(i, j - 1)]);
      curl[IX(i, j)] *= 0.5;
    }
  }
  for (var j = 1; j <= N; j++) {
    for (var i = 1; i <= N; i++) {
      var cx = 0.5 * (Math.abs(curl[IX(i + 1, j)]) - Math.abs(curl[IX(i - 1, j)]));
      var cy = 0.5 * (Math.abs(curl[IX(i, j + 1)]) - Math.abs(curl[IX(i, j - 1)]));
      var len = Math.sqrt(cx * cx + cy * cy) + 1e-5;
      // 渦度の勾配に垂直な向きへ力を加える
      u[IX(i, j)] += VORT * dt * (cy / len) * curl[IX(i, j)];
      v[IX(i, j)] -= VORT * dt * (cx / len) * curl[IX(i, j)];
    }
  }
}

04インクのにじみ(境界反射)

何が見えるか

クリックした位置に染料を注入すると、移流と拡散が組み合わさって紙に落としたインクが滲むように広がる — 格子の端では濃度を反射させる境界処理を入れており、壁に当たった染料が跳ね返る様子も見える。ローディング演出の背景や、クリック誘導の遊びに使える。

インクのにじみ
新しいタブで開く

04 · インクのにじみ — 計測 累積面積 13.76% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

Math.exppointerdown

コードのどこを見るか

レンダリング実測は、累積面積 13.76% · 強度 46.8 · 動いたフレーム 23/23 である。

// 境界セル:濃度は反射、速度は反転(壁で跳ね返す)
function bounds(b) {
  for (var i = 1; i <= N; i++) {
    d[IX(0, i)]     =  b === 0 ? -d[IX(1, i)] : d[IX(1, i)];
    d[IX(N + 1, i)] =  b === 0 ? -d[IX(N, i)] : d[IX(N, i)];
    d[IX(i, 0)]     =  b === 0 ? -d[IX(i, 1)] : d[IX(i, 1)];
    d[IX(i, N + 1)] =  b === 0 ? -d[IX(i, N)] : d[IX(i, N)];
  }
}

05煙の上昇

何が見えるか

下から湧き上がる煙が浮力で持ち上がり、渦を巻きながら天井近くで横に広がる — 20ステップごとに3点同時点火し、揺らぎは固定シードの決定論的ガウスノイズで作るので、リロードしても同じ煙が立ち上がる。ヒーローやカードの背景に、煙エフェクトとしてそのまま配置できる。

煙の上昇
新しいタブで開く

05 · 煙の上昇 — 計測 累積面積 47.947% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFrameMath.sin

コードのどこを見るか

レンダリング実測は、累積面積 47.947% · 強度 57.1 · 動いたフレーム 23/23 である。

// 浮力:密度が高いほど強く上向きの力が働く
for (var k = 0; k < size; k++) {
  v0[k] -= Math.min(1.6, d0[k]) * BUOY * dt;
}
// 20ステップごとに3点同時点火(シード固定のガウスノイズ)
if (frame % 20 === 0) {
  for (var s = 0; s < 3; s++) {
    var px = 0.5 + 0.28 * Math.cos(2.1 * s + frame * 0.013);
    var py = 0.12 + 0.05 * noise(frame * 0.7 + s * 31);
    inject(px, py);
  }
}

06渦の減衰

何が見えるか

最初に1つの大きな渦を植え、あとは何も足さずに減衰だけを観察する — 渦の糸がほどけ、濃度が全体へ均されて消えていく様子は、減衰係数と拡散が実際に何をしているかを一番素直に見せてくれる。流体シミュレーション サイトのアニメーション終了フレームを設計するときの参考にもできる。

渦の減衰
新しいタブで開く

06 · 渦の減衰 — 計測 累積面積 61.031% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

Math.powFloat32Array

コードのどこを見るか

レンダリング実測は、累積面積 61.031% · 強度 26.8 · 動いたフレーム 20/23 である。

// 毎フレーム、全セルへ一律の減衰をかけるだけ
for (var k = 0; k < size; k++) {
  u0[k] = u[k] * DISSIPATION;
  v0[k] = v[k] * DISSIPATION;
  d0[k] = d[k] * DISSIPATION_D;
}
// 初期条件:中心に1つの回転を植える(以後は足さない)
for (var j = 1; j <= N; j++) {
  for (var i = 1; i <= N; i++) {
    var dx = i - N * 0.5, dy = j - N * 0.5;
    var r = Math.sqrt(dx * dx + dy * dy) + 1e-5;
    u[IX(i, j)] = -dy / r * Math.exp(-r * 0.18);
    v[IX(i, j)] =  dx / r * Math.exp(-r * 0.18);
  }
}

073色の染料混合

何が見えるか

密度チャンネルを1つから3つに増やし、パステル調の染料3色が互いに混ざりながら流れる — チャンネルごとに独立した移流・拡散を回すだけで、色同士のなじみ方が自然に決まる。ブランドカラー3色を使った液状ににじむ見出し背景や、セクションの遷移に向く。

染料の混合
新しいタブで開く

07 · 染料の混合 — 計測 累積面積 43.042% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

putImageDataFloat32Array

コードのどこを見るか

レンダリング実測は、累積面積 43.042% · 強度 21.6 · 動いたフレーム 23/23 である。

// 染料3チャンネルをそれぞれ独立に移流・拡散する
for (var c = 0; c < 3; c++) {
  advectChannel(dye[c], dye0[c], dt);
  diffuseChannel(dye[c], dye0[c], dt);
}
// 3チャンネルの密度をそのままRGBにマップ
for (var k = 0; k < size; k++) {
  px[k * 4]     = palR(dye[0][k]); // チャンネル0 → R
  px[k * 4 + 1] = palG(dye[1][k]); // チャンネル1 → G
  px[k * 4 + 2] = palB(dye[2][k]); // チャンネル2 → B
}

08ポインターでかき混ぜる

何が見えるか

ポインターの移動がそのまま力と染料の注入になる — なぞった軌跡に沿って流体が引きずられ、離すと慣性で流れ続け、減衰しながら静まっていく。pointermove の差分を速度場に足すだけで実現でき、流体シミュレーション web ページの最小構成のインタラクションとしてそのまま使える。

ポインタのかき混ぜ
新しいタブで開く

08 · ポインタのかき混ぜ — 計測 累積面積 51.135% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pointermoverequestAnimationFrame

コードのどこを見るか

レンダリング実測は、累積面積 51.135% · 強度 62.1 · 動いたフレーム 23/23 である。

canvas.addEventListener('pointermove', function (e) {
  var dx = e.offsetX / W - last.x, dy = e.offsetY / H - last.y;
  // ポインターの移動差分を速度と染料の両方に加算
  var i = 1 + Math.floor(e.offsetX / W * N);
  var j = 1 + Math.floor(e.offsetY / H * N);
  u0[IX(i, j)] += dx * FORCE;
  v0[IX(i, j)] += dy * FORCE;
  d0[IX(i, j)] += 0.35; // なぞった跡に染料を残す
  last.x = e.offsetX / W; last.y = e.offsetY / H;
});

09密度場の可視化

何が見えるか

計算ではなく描画の問題に焦点を当てた1枚 — 密度の数値を等高線風の階調に量子化して示すと、同じ格子計算でも見え方が測定器のように変わる。流体シミュレーション web 上で動く学習素材として、目に見える「美しさ」と計算の中身の「数値」を橋渡しするビューだ。

密度フィールドの描画
新しいタブで開く

09 · 密度フィールドの描画 — 計測 累積面積 54.627% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

createImageDataimageSmoothingEnabled

コードのどこを見るか

レンダリング実測は、累積面積 54.627% · 強度 40.0 · 動いたフレーム 23/23 である。

// 32×32のImageDataをCanvas全体へ最近傍で拡大
var img = ctx.createImageData(N, N);
for (var k = 0; k < size; k++) {
  // 密度を5段階に量子化して等高線風の階調に
  var q = Math.min(4, Math.floor(d[k] * 5));
  var c = LEVELS[q];
  img.data[k * 4]     = c[0];
  img.data[k * 4 + 1] = c[1];
  img.data[k * 4 + 2] = c[2];
  img.data[k * 4 + 3] = 255;
}
off.putImageData(img, 0, 0);
ctx.imageSmoothingEnabled = false; // ピクセル感を残す
ctx.drawImage(off.canvas, 0, 0, W, H);

格子シミュレーションは毎フレーム何を計算しているのか

3ステップを繰り返すだけ

9種すべてが毎フレーム同じ3ステップを回す — 力を足す(浮力・クリック・渦強化)、移流する(速度に沿って値をさかのぼってサンプリングする)、拡散・減衰する(隣と混ぜ、乗算で減らす)。順序を変えるか1ステップを抜くと、01〜09の別の項目が生まれるのがこのシリーズの核である。

なぜ格子なのか

粒子の位置を1つずつ積む方式と違い、格子はセル数が固定なので、値がどれだけ長く溜まっても計算量は増えない。32×32 という小さなサイズ1つで9通りの異なるシーンが作れる理由でもある。

Python の流体シミュレーションと何が違うのか

同じ移流・拡散・減衰の手続きを Python のコードに書き換えても結果は同じだ — 言語ではなく格子配列とループがこの計算の本質だからである。JS で書いているのはブラウザーで直接動かすためで、別の環境に移植するなら advect と diffuse の2関数だけをコピーすれば足りる。web で動く流体シミュレーションを探すと今日は WebGL シェーダーがほとんどだが、この9種は意図的に 2D コンテキストに留めている。

どこで破綻するか — 落とし穴

圧力投影を省略している

この9種はいずれも、ポアソン方程式(非圧縮性、すなわち密度が消えない条件を強制する)、すなわち圧力投影(pressure projection)のステップを解いていない。本来の Stable Fluids なら移流の後に発散を除去する計算が入るはずだが、ここではその場所を減衰係数1つで代用した。そのため08のように力を強く速く何度も入れると、密度が1セルに爆発的に溜まる瞬間が現れる。

流体シミュレーションと呼ぶには近似が多い

圧力投影がないということは、これらのデモが厳密な流体シミュレーションではなく「それらしく見える」視覚的近似だということだ。実務で物理的に正確な流体が必要なら、この9種ではなく SPH や格子ベースのソルバー(Verlet ソフトボディ物理のような積分方式)を探すべきである。2zk3kwsz のソースにはその省略をコメントで明示してあるので、実プロジェクトに使う前に一度目を通すことを勧める。

バリエーション3つとアクセシビリティ

拡散係数を上げると

02の拡散係数を 0.08 から 0.30 に上げると、インクが水路のように広くにじみ、境界が柔らかくなる。反復緩和の回数はそのままで、係数だけ変えれば十分だ。

渦の強化を上げると

03の渦強化(VORT)を 12 から 30 に上げると、煙の縁にさざ波のような渦が増える。値を上げすぎると格子が震えて見えるので、30 付近が安全ラインだ。

色を変えると

$subject-blue を $subject-mint に変えると、冷たいインクが爽やかなミントの染料に変わる。計算ロジックはそのままに、パレット変数だけを差し替える。

アクセシビリティが残す静止フレーム

01・03・06・07・09のように自動で繰り返し再生される項目は、prefers-reduced-motion を検知するとシミュレーションを40ステップだけ先行して回し、流れがすでに定着した静止フレームを1枚だけ描いて止まる。02・04・05・08のようにポインターで操作する項目は最初の1枚だけを残し、以後はクリックや移動のたびに結果を1フレームずつ更新して見せる。どちらの場合も、画面が完全な空白のまま残ることはない。

FAQ

流体シミュレーションと物理エンジンの違いは?

異なるものである。物理エンジンは主に剛体や粒子間の衝突・拘束を計算するが、本記事の流体シミュレーションは格子状の密度・速度場を移流・拡散させるオイラー的な計算だ。計算対象そのものが違う。

このデモをそのまま実案件で使えるか?

視覚効果としてはそのまま使えるが、圧力投影を省いた近似なので、物理的に正確な流体が必要な用途(工学用 CFD など)には合わない。背景やインタラクション装飾向けに作られている。

格子サイズ(N)を大きくするとリアルになる?

細かくはなるが、計算量は N の2乗で増え、60fps の維持が難しくなる。この9種は 32×32 と、canvas アニメーションに必要なだけの精細さに抑えた値である。


参考: MDN getImageData · MDN putImageData · MDN Float32Array · MDN requestAnimationFrame · MDN pointermove · MDN prefers-reduced-motion · Wikipedia: Fluid simulation

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。