Home

08. Stam · 1999 · Retro techniques

Fluid tank

Demos are animating. Following your system setting.
Colors and knobs

These rewrite the live file. Copy HTML picks up whatever you set.

About

Jos Stam's stable fluids on a 48x48 grid: diffuse, project (Jacobi pressure), advect. Density is a phosphor dye; velocity is splatted along the pointer stroke. An ambient vortex keeps the tank moving when nobody is stirring. The solver is the textbook Game Developer Conference version.

Move or drag on the preview to stir the dye. A slow vortex keeps it in motion on its own.

Reference

J. Stam, "Stable Fluids", SIGGRAPH 1999; the solver follows his GDC 2003 paper "Real-Time Fluid Dynamics for Games"

Browser APIs

  • Canvas 2D
  • ImageData
  • Pointer Events
  • requestAnimationFrame
  • matchMedia

If WebGL is missing, the demo draws a message on the canvas instead of a blank frame. By default a demo holds a still frame when your system asks for reduced motion; the Motion buttons override that, and a standalone copy animates with #motion=play on its URL.

Source

(function () {
  const PARAMS = {
    color: "#6ee7b7",
    amount: 42
  };
  function hexToRgb(hex) {
    const n = parseInt(String(hex).replace("#", ""), 16);
    if (isNaN(n)) return [110, 231, 183];
    return [n >> 16 & 255, n >> 8 & 255, n & 255];
  }

  const canvas = document.getElementById("c");
  const fallback = document.getElementById("fallback");
  const ctx = canvas.getContext("2d");
  if (!ctx) {
    fallback.hidden = false;
    fallback.textContent = "Canvas 2D is not available in this browser.";
    return;
  }

  const N = 48;
  const ITER = 4;
  const dim = N + 2;
  const size = dim * dim;
  function ix(i, j) { return i + dim * j; }

  function field() { return new Float32Array(size); }
  let u = field(), v = field(), u0 = field(), v0 = field();
  let dens = field(), dens0 = field(), p = field(), div = field();

  function setBnd(b, x) {
    for (let i = 1; i <= N; i++) {
      x[ix(0, i)] = b === 1 ? -x[ix(1, i)] : x[ix(1, i)];
      x[ix(N + 1, i)] = b === 1 ? -x[ix(N, i)] : x[ix(N, i)];
      x[ix(i, 0)] = b === 2 ? -x[ix(i, 1)] : x[ix(i, 1)];
      x[ix(i, N + 1)] = b === 2 ? -x[ix(i, N)] : x[ix(i, N)];
    }
    x[ix(0, 0)] = 0.5 * (x[ix(1, 0)] + x[ix(0, 1)]);
    x[ix(0, N + 1)] = 0.5 * (x[ix(1, N + 1)] + x[ix(0, N)]);
    x[ix(N + 1, 0)] = 0.5 * (x[ix(N, 0)] + x[ix(N + 1, 1)]);
    x[ix(N + 1, N + 1)] = 0.5 * (x[ix(N, N + 1)] + x[ix(N + 1, N)]);
  }

  function linSolve(b, x, x0, a, c) {
    const inv = 1 / c;
    for (let k = 0; k < ITER; k++) {
      for (let j = 1; j <= N; j++) {
        for (let i = 1; i <= N; i++) {
          x[ix(i, j)] = (x0[ix(i, j)] + a * (
            x[ix(i - 1, j)] + x[ix(i + 1, j)] + x[ix(i, j - 1)] + x[ix(i, j + 1)]
          )) * inv;
        }
      }
      setBnd(b, x);
    }
  }

  function diffuse(b, x, x0, diff, dt) {
    const a = dt * diff * N * N;
    linSolve(b, x, x0, a, 1 + 4 * a);
  }

  function advect(b, d, d0, uu, vv, dt) {
    const dt0 = dt * N;
    for (let j = 1; j <= N; j++) {
      for (let i = 1; i <= N; i++) {
        let x = i - dt0 * uu[ix(i, j)];
        let y = j - dt0 * vv[ix(i, j)];
        if (x < 0.5) x = 0.5;
        if (x > N + 0.5) x = N + 0.5;
        if (y < 0.5) y = 0.5;
        if (y > N + 0.5) y = N + 0.5;
        const i0 = x | 0, i1 = i0 + 1;
        const j0 = y | 0, j1 = j0 + 1;
        const s1 = x - i0, s0 = 1 - s1;
        const t1 = y - j0, t0 = 1 - t1;
        d[ix(i, j)] =
          s0 * (t0 * d0[ix(i0, j0)] + t1 * d0[ix(i0, j1)]) +
          s1 * (t0 * d0[ix(i1, j0)] + t1 * d0[ix(i1, j1)]);
      }
    }
    setBnd(b, d);
  }

  function project(uu, vv, pp, dv) {
    for (let j = 1; j <= N; j++) {
      for (let i = 1; i <= N; i++) {
        dv[ix(i, j)] = -0.5 * (uu[ix(i + 1, j)] - uu[ix(i - 1, j)] + vv[ix(i, j + 1)] - vv[ix(i, j - 1)]) / N;
        pp[ix(i, j)] = 0;
      }
    }
    setBnd(0, dv);
    setBnd(0, pp);
    linSolve(0, pp, dv, 1, 4);
    for (let j = 1; j <= N; j++) {
      for (let i = 1; i <= N; i++) {
        uu[ix(i, j)] -= 0.5 * N * (pp[ix(i + 1, j)] - pp[ix(i - 1, j)]);
        vv[ix(i, j)] -= 0.5 * N * (pp[ix(i, j + 1)] - pp[ix(i, j - 1)]);
      }
    }
    setBnd(1, uu);
    setBnd(2, vv);
  }

  function velStep(dt) {
    diffuse(1, u0, u, 0.00012, dt);
    diffuse(2, v0, v, 0.00012, dt);
    project(u0, v0, p, div);
    advect(1, u, u0, u0, v0, dt);
    advect(2, v, v0, u0, v0, dt);
    project(u, v, p, div);
  }

  function densStep(dt) {
    diffuse(0, dens0, dens, 0.00008, dt);
    advect(0, dens, dens0, u, v, dt);
    for (let i = 0; i < size; i++) dens[i] *= 0.994;
  }

  function splat(gx, gy, dx, dy, amount) {
    const i = Math.max(1, Math.min(N, gx | 0));
    const j = Math.max(1, Math.min(N, gy | 0));
    for (let oj = -1; oj <= 1; oj++) {
      for (let oi = -1; oi <= 1; oi++) {
        const ii = i + oi, jj = j + oj;
        if (ii < 1 || jj < 1 || ii > N || jj > N) continue;
        const wgt = oi === 0 && oj === 0 ? 1 : 0.45;
        dens[ix(ii, jj)] += amount * wgt;
        u[ix(ii, jj)] += dx * wgt;
        v[ix(ii, jj)] += dy * wgt;
      }
    }
  }

  const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  let reduced = motionQuery.matches;
  const pointer = { x: 0, y: 0, px: 0, py: 0, down: false, inside: false };
  let last = 0;
  let raf = 0;
  let seeded = false;
  const pixels = new ImageData(N, N);
  const off = document.createElement("canvas");
  off.width = N;
  off.height = N;
  const offCtx = off.getContext("2d");
  if (!offCtx) {
    fallback.hidden = false;
    fallback.textContent = "Canvas 2D is not available in this browser.";
    return;
  }

  function toGrid(e) {
    const rect = canvas.getBoundingClientRect();
    return {
      x: ((e.clientX - rect.left) / rect.width) * N + 1,
      y: ((e.clientY - rect.top) / rect.height) * N + 1,
    };
  }
  canvas.addEventListener("pointerdown", function (e) {
    const g = toGrid(e);
    pointer.down = true;
    pointer.inside = true;
    pointer.x = pointer.px = g.x;
    pointer.y = pointer.py = g.y;
    canvas.setPointerCapture(e.pointerId);
    wake();
  });
  canvas.addEventListener("pointerup", function () { pointer.down = false; wake(); });
  canvas.addEventListener("pointerleave", function () { pointer.inside = false; wake(); });
  canvas.addEventListener("pointerenter", function () { pointer.inside = true; wake(); });
  canvas.addEventListener("pointermove", function (e) {
    const g = toGrid(e);
    pointer.px = pointer.x;
    pointer.py = pointer.y;
    pointer.x = g.x;
    pointer.y = g.y;
    pointer.inside = true;
    wake();
  });

  function resize() {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
    if (canvas.width !== w || canvas.height !== h) {
      canvas.width = w;
      canvas.height = h;
    }
  }

  function frame(now) {
    const dt = Math.min(0.033, last ? (now - last) * 0.001 : 0.016);
    last = now;
    const t = now * 0.001;

    if (!seeded) {
      seeded = true;
      if (reduced) {
        splat(N * 0.45, N * 0.5, 8, -4, 120);
        splat(N * 0.62, N * 0.42, -6, 5, 90);
        velStep(0.016);
        densStep(0.016);
      }
    }

    if (!reduced) {
      const ang = t * 0.85;
      const gx = N * 0.5 + Math.cos(ang) * N * 0.22;
      const gy = N * 0.5 + Math.sin(ang * 0.72) * N * 0.18;
      splat(gx, gy, -Math.sin(ang) * 22, Math.cos(ang * 0.72) * 22, PARAMS.amount);
    }

    if (pointer.inside || pointer.down) {
      const dx = (pointer.x - pointer.px) * 12;
      const dy = (pointer.y - pointer.py) * 12;
      splat(pointer.x, pointer.y, dx, dy, pointer.down ? 90 : 22);
      pointer.px = pointer.x;
      pointer.py = pointer.y;
    }

    if (!reduced) {
      velStep(dt);
      densStep(dt);
    }

    const data = pixels.data;
    for (let j = 1; j <= N; j++) {
      for (let i = 1; i <= N; i++) {
        const d = dens[ix(i, j)];
        const spd = Math.hypot(u[ix(i, j)], v[ix(i, j)]);
        const t0 = Math.min(1, d * 0.045);
        const glow = Math.min(1, t0 + spd * 0.08);
        const rgb = hexToRgb(PARAMS.color);
        const o = ((j - 1) * N + (i - 1)) * 4;
        data[o] = 12 + glow * rgb[0];
        data[o + 1] = 14 + glow * rgb[1];
        data[o + 2] = 16 + glow * rgb[2];
        data[o + 3] = 255;
      }
    }

    resize();
    offCtx.putImageData(pixels, 0, 0);
    ctx.imageSmoothingEnabled = true;
    ctx.drawImage(off, 0, 0, canvas.width, canvas.height);

    if (!reduced) raf = requestAnimationFrame(frame);
  }

  // Motion. The system's reduced-motion preference decides by default. A
  // standalone copy animates anyway with "#motion=play" in its URL (or holds a
  // frame with "#motion=still"); an embedding page can post {motion: "play"}.
  let motionMode = "auto";
  function applyMotion() {
    const next = motionMode === "still" || (motionMode === "auto" && motionQuery.matches);
    if (next === reduced) return;
    reduced = next;
    last = 0;
    if (reduced) { cancelAnimationFrame(raf); raf = 0; frame(performance.now()); }
    else if (!raf) raf = requestAnimationFrame(frame);
  }
  function setMotion(mode) {
    if (mode === "play" || mode === "still" || mode === "auto") { motionMode = mode; applyMotion(); }
  }
  function readMotionHash() {
    const m = /motion=(play|still|auto)/.exec(location.hash);
    let mode = m ? m[1] : "auto";
    // Embedded in the gallery, the page sets window.__demoMotion before any frame loads.
    try { if (!m && window.parent !== window && typeof window.parent.__demoMotion === "string") mode = window.parent.__demoMotion; } catch (e) { /* cross-origin parent */ }
    setMotion(mode);
  }
  function wake() { if (reduced) frame(performance.now()); }
  if (motionQuery.addEventListener) motionQuery.addEventListener("change", applyMotion);
  else if (motionQuery.addListener) motionQuery.addListener(applyMotion);
  window.addEventListener("hashchange", readMotionHash);
  window.addEventListener("message", function (e) { if (e.data && typeof e.data.motion === "string") setMotion(e.data.motion); });
  window.addEventListener("resize", wake);

  if (reduced) frame(performance.now());
  else raf = requestAnimationFrame(frame);
  readMotionHash();
})();