{"slug":"fluid","number":"08","title":"Fluid tank","era":"Stam · 1999","summary":"A stable 2D Navier-Stokes dye tank. Stir it with the pointer.","description":"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.","apis":["Canvas 2D","ImageData","Pointer Events","requestAnimationFrame","matchMedia"],"tags":["sim","fluid"],"hasGlsl":false,"interaction":"Move or drag on the preview to stir the dye. A slow vortex keeps it in motion on its own.","url":"https://3d-retro.com/experiments/fluid","example_url":"https://3d-retro.com/examples/fluid.html","api_url":"https://3d-retro.com/api/v1/experiments/fluid","html":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n  <meta name=\"description\" content=\"A stable 2D Navier-Stokes dye tank. Stir it with the pointer.\" />\n  <link rel=\"canonical\" href=\"https://3d-retro.com/experiments/fluid\" />\n  <title>Fluid tank · 3d retro graphics</title>\n  <style>\n    html, body {\n      margin: 0;\n      width: 100%;\n      height: 100%;\n      overflow: hidden;\n      background: #0b0c0e;\n      color: #e8e6e1;\n      position: absolute;\n      inset: 0;\n    }\n    canvas {\n      display: block;\n      position: absolute;\n      inset: 0;\n      width: 100%;\n      height: 100%;\n      touch-action: none;\n    }\n    .fallback {\n      position: absolute;\n      inset: 0;\n      display: grid;\n      place-items: center;\n      padding: 24px;\n      font: 14px/1.5 ui-monospace, \"IBM Plex Mono\", monospace;\n      text-align: center;\n      color: #e8e6e1;\n      background: #0b0c0e;\n    }\n    .fallback[hidden] { display: none; }\n  </style>\n</head>\n<body>\n<canvas id=\"c\"></canvas>\n<div id=\"fallback\" class=\"fallback\" hidden></div>\n<script>\n(function () {\n  const PARAMS = {\n    color: \"#6ee7b7\",\n    amount: 42\n  };\n  function hexToRgb(hex) {\n    const n = parseInt(String(hex).replace(\"#\", \"\"), 16);\n    if (isNaN(n)) return [110, 231, 183];\n    return [n >> 16 & 255, n >> 8 & 255, n & 255];\n  }\n\n  const canvas = document.getElementById(\"c\");\n  const fallback = document.getElementById(\"fallback\");\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) {\n    fallback.hidden = false;\n    fallback.textContent = \"Canvas 2D is not available in this browser.\";\n    return;\n  }\n\n  const N = 48;\n  const ITER = 4;\n  const dim = N + 2;\n  const size = dim * dim;\n  function ix(i, j) { return i + dim * j; }\n\n  function field() { return new Float32Array(size); }\n  let u = field(), v = field(), u0 = field(), v0 = field();\n  let dens = field(), dens0 = field(), p = field(), div = field();\n\n  function setBnd(b, x) {\n    for (let i = 1; i <= N; i++) {\n      x[ix(0, i)] = b === 1 ? -x[ix(1, i)] : x[ix(1, i)];\n      x[ix(N + 1, i)] = b === 1 ? -x[ix(N, i)] : x[ix(N, i)];\n      x[ix(i, 0)] = b === 2 ? -x[ix(i, 1)] : x[ix(i, 1)];\n      x[ix(i, N + 1)] = b === 2 ? -x[ix(i, N)] : x[ix(i, N)];\n    }\n    x[ix(0, 0)] = 0.5 * (x[ix(1, 0)] + x[ix(0, 1)]);\n    x[ix(0, N + 1)] = 0.5 * (x[ix(1, N + 1)] + x[ix(0, N)]);\n    x[ix(N + 1, 0)] = 0.5 * (x[ix(N, 0)] + x[ix(N + 1, 1)]);\n    x[ix(N + 1, N + 1)] = 0.5 * (x[ix(N, N + 1)] + x[ix(N + 1, N)]);\n  }\n\n  function linSolve(b, x, x0, a, c) {\n    const inv = 1 / c;\n    for (let k = 0; k < ITER; k++) {\n      for (let j = 1; j <= N; j++) {\n        for (let i = 1; i <= N; i++) {\n          x[ix(i, j)] = (x0[ix(i, j)] + a * (\n            x[ix(i - 1, j)] + x[ix(i + 1, j)] + x[ix(i, j - 1)] + x[ix(i, j + 1)]\n          )) * inv;\n        }\n      }\n      setBnd(b, x);\n    }\n  }\n\n  function diffuse(b, x, x0, diff, dt) {\n    const a = dt * diff * N * N;\n    linSolve(b, x, x0, a, 1 + 4 * a);\n  }\n\n  function advect(b, d, d0, uu, vv, dt) {\n    const dt0 = dt * N;\n    for (let j = 1; j <= N; j++) {\n      for (let i = 1; i <= N; i++) {\n        let x = i - dt0 * uu[ix(i, j)];\n        let y = j - dt0 * vv[ix(i, j)];\n        if (x < 0.5) x = 0.5;\n        if (x > N + 0.5) x = N + 0.5;\n        if (y < 0.5) y = 0.5;\n        if (y > N + 0.5) y = N + 0.5;\n        const i0 = x | 0, i1 = i0 + 1;\n        const j0 = y | 0, j1 = j0 + 1;\n        const s1 = x - i0, s0 = 1 - s1;\n        const t1 = y - j0, t0 = 1 - t1;\n        d[ix(i, j)] =\n          s0 * (t0 * d0[ix(i0, j0)] + t1 * d0[ix(i0, j1)]) +\n          s1 * (t0 * d0[ix(i1, j0)] + t1 * d0[ix(i1, j1)]);\n      }\n    }\n    setBnd(b, d);\n  }\n\n  function project(uu, vv, pp, dv) {\n    for (let j = 1; j <= N; j++) {\n      for (let i = 1; i <= N; i++) {\n        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;\n        pp[ix(i, j)] = 0;\n      }\n    }\n    setBnd(0, dv);\n    setBnd(0, pp);\n    linSolve(0, pp, dv, 1, 4);\n    for (let j = 1; j <= N; j++) {\n      for (let i = 1; i <= N; i++) {\n        uu[ix(i, j)] -= 0.5 * N * (pp[ix(i + 1, j)] - pp[ix(i - 1, j)]);\n        vv[ix(i, j)] -= 0.5 * N * (pp[ix(i, j + 1)] - pp[ix(i, j - 1)]);\n      }\n    }\n    setBnd(1, uu);\n    setBnd(2, vv);\n  }\n\n  function velStep(dt) {\n    diffuse(1, u0, u, 0.00012, dt);\n    diffuse(2, v0, v, 0.00012, dt);\n    project(u0, v0, p, div);\n    advect(1, u, u0, u0, v0, dt);\n    advect(2, v, v0, u0, v0, dt);\n    project(u, v, p, div);\n  }\n\n  function densStep(dt) {\n    diffuse(0, dens0, dens, 0.00008, dt);\n    advect(0, dens, dens0, u, v, dt);\n    for (let i = 0; i < size; i++) dens[i] *= 0.994;\n  }\n\n  function splat(gx, gy, dx, dy, amount) {\n    const i = Math.max(1, Math.min(N, gx | 0));\n    const j = Math.max(1, Math.min(N, gy | 0));\n    for (let oj = -1; oj <= 1; oj++) {\n      for (let oi = -1; oi <= 1; oi++) {\n        const ii = i + oi, jj = j + oj;\n        if (ii < 1 || jj < 1 || ii > N || jj > N) continue;\n        const wgt = oi === 0 && oj === 0 ? 1 : 0.45;\n        dens[ix(ii, jj)] += amount * wgt;\n        u[ix(ii, jj)] += dx * wgt;\n        v[ix(ii, jj)] += dy * wgt;\n      }\n    }\n  }\n\n  const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  const pointer = { x: 0, y: 0, px: 0, py: 0, down: false, inside: false };\n  let last = 0;\n  let raf = 0;\n  const pixels = new ImageData(N, N);\n  const off = document.createElement(\"canvas\");\n  off.width = N;\n  off.height = N;\n  const offCtx = off.getContext(\"2d\");\n  if (!offCtx) {\n    fallback.hidden = false;\n    fallback.textContent = \"Canvas 2D is not available in this browser.\";\n    return;\n  }\n\n  function toGrid(e) {\n    const rect = canvas.getBoundingClientRect();\n    return {\n      x: ((e.clientX - rect.left) / rect.width) * N + 1,\n      y: ((e.clientY - rect.top) / rect.height) * N + 1,\n    };\n  }\n  canvas.addEventListener(\"pointerdown\", function (e) {\n    const g = toGrid(e);\n    pointer.down = true;\n    pointer.inside = true;\n    pointer.x = pointer.px = g.x;\n    pointer.y = pointer.py = g.y;\n    canvas.setPointerCapture(e.pointerId);\n  });\n  canvas.addEventListener(\"pointerup\", function () { pointer.down = false; });\n  canvas.addEventListener(\"pointerleave\", function () { pointer.inside = false; });\n  canvas.addEventListener(\"pointerenter\", function () { pointer.inside = true; });\n  canvas.addEventListener(\"pointermove\", function (e) {\n    const g = toGrid(e);\n    pointer.px = pointer.x;\n    pointer.py = pointer.y;\n    pointer.x = g.x;\n    pointer.y = g.y;\n    pointer.inside = true;\n  });\n\n  function resize() {\n    const dpr = Math.min(window.devicePixelRatio || 1, 2);\n    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));\n    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));\n    if (canvas.width !== w || canvas.height !== h) {\n      canvas.width = w;\n      canvas.height = h;\n    }\n  }\n\n  function frame(now) {\n    const dt = Math.min(0.033, last ? (now - last) * 0.001 : 0.016);\n    last = now;\n    const t = now * 0.001;\n\n    if (!reduced) {\n      const ang = t * 0.85;\n      const gx = N * 0.5 + Math.cos(ang) * N * 0.22;\n      const gy = N * 0.5 + Math.sin(ang * 0.72) * N * 0.18;\n      splat(gx, gy, -Math.sin(ang) * 22, Math.cos(ang * 0.72) * 22, PARAMS.amount);\n    }\n\n    if (pointer.inside || pointer.down) {\n      const dx = (pointer.x - pointer.px) * 12;\n      const dy = (pointer.y - pointer.py) * 12;\n      splat(pointer.x, pointer.y, dx, dy, pointer.down ? 90 : 22);\n      pointer.px = pointer.x;\n      pointer.py = pointer.y;\n    }\n\n    if (!reduced) {\n      velStep(dt);\n      densStep(dt);\n    }\n\n    const data = pixels.data;\n    for (let j = 1; j <= N; j++) {\n      for (let i = 1; i <= N; i++) {\n        const d = dens[ix(i, j)];\n        const spd = Math.hypot(u[ix(i, j)], v[ix(i, j)]);\n        const t0 = Math.min(1, d * 0.045);\n        const glow = Math.min(1, t0 + spd * 0.08);\n        const rgb = hexToRgb(PARAMS.color);\n        const o = ((j - 1) * N + (i - 1)) * 4;\n        data[o] = 12 + glow * rgb[0];\n        data[o + 1] = 14 + glow * rgb[1];\n        data[o + 2] = 16 + glow * rgb[2];\n        data[o + 3] = 255;\n      }\n    }\n\n    resize();\n    offCtx.putImageData(pixels, 0, 0);\n    ctx.imageSmoothingEnabled = true;\n    ctx.drawImage(off, 0, 0, canvas.width, canvas.height);\n\n    if (!reduced) raf = requestAnimationFrame(frame);\n  }\n\n  if (reduced) {\n    splat(N * 0.45, N * 0.5, 8, -4, 120);\n    splat(N * 0.62, N * 0.42, -6, 5, 90);\n    velStep(0.016);\n    densStep(0.016);\n    frame(performance.now());\n  } else {\n    raf = requestAnimationFrame(frame);\n  }\n})();\n</script>\n</body>\n</html>\n"}