{"slug":"dualtank","number":"29","title":"Dual tank","era":"Stam 1999 solver · 2026 illustration","section":"modern","provenance":"documented","reference":{"label":"J. Stam, \"Stable Fluids\", SIGGRAPH 1999, for the solver; the strained display on the right is a 2026 illustration","url":"https://doi.org/10.1145/311535.311548"},"summary":"Same 48×48 Stam twice. Left is bounded. Right is a lying legend.","description":"Split screen, two copies of the GDC Stam solver. Left is the usual dye tank, labeled bounded. Right runs the same step, then a display-only strain: dye is sampled through an inward spiral and an axial stretch, and the false-color speed scale goes as 1/(T*−t). Orange is the boosted speed, teal is slow. The code on the right is still stable. The legend is not. Stir either side. T* is the horizon slider.","apis":["Canvas 2D","ImageData","Pointer Events","requestAnimationFrame","matchMedia"],"tags":["sim","fluid"],"hasGlsl":false,"interaction":"Move or drag on either half to stir that tank. Right-hand color scale grows as t approaches T*.","url":"https://3d-retro.com/experiments/dualtank","example_url":"https://3d-retro.com/examples/dualtank.html","api_url":"https://3d-retro.com/api/v1/experiments/dualtank","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=\"Split Stam tank. Left is bounded. Right is the same solver with a lying legend.\" />\n  <link rel=\"canonical\" href=\"https://3d-retro.com/experiments/dualtank\" />\n  <title>Dual tank · 3d retro graphics</title>\n  <style>\n    html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: #0b0c0e; color: #e8e6e1; position: absolute; inset: 0; }\n    canvas { display: block; position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; image-rendering: pixelated; }\n    .fallback { position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; font: 14px/1.5 ui-monospace, \"IBM Plex Mono\", monospace; text-align: center; color: #e8e6e1; background: #0b0c0e; }\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: \"#ff6a2a\",\n    accent: \"#3ce0c8\",\n    horizon: 6\n  };\n  function hexToRgb(hex) {\n    const n = parseInt(String(hex).replace(\"#\", \"\"), 16);\n    if (isNaN(n)) return [255, 106, 42];\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, ITER = 4, dim = N + 2, size = dim * dim;\n  function ix(i, j) { return i + dim * j; }\n\n  function makeTank() {\n    function field() { return new Float32Array(size); }\n    const tnk = {\n      u: field(), v: field(), u0: field(), v0: field(),\n      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    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    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    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    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    tnk.step = function (dt) {\n      const u = tnk.u, v = tnk.v, u0 = tnk.u0, v0 = tnk.v0;\n      diffuse(1, u0, u, 0.00012, dt);\n      diffuse(2, v0, v, 0.00012, dt);\n      project(u0, v0, tnk.p, tnk.div);\n      advect(1, u, u0, u0, v0, dt);\n      advect(2, v, v0, u0, v0, dt);\n      project(u, v, tnk.p, tnk.div);\n      diffuse(0, tnk.dens0, tnk.dens, 0.00008, dt);\n      advect(0, tnk.dens, tnk.dens0, u, v, dt);\n      for (let i = 0; i < size; i++) tnk.dens[i] *= 0.994;\n    };\n    tnk.splat = function (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          tnk.dens[ix(ii, jj)] += amount * wgt;\n          tnk.u[ix(ii, jj)] += dx * wgt;\n          tnk.v[ix(ii, jj)] += dy * wgt;\n        }\n      }\n    };\n    tnk.swirl = function (force, dye) {\n      const c = N * 0.5 + 1;\n      for (let j = 1; j <= N; j++) {\n        for (let i = 1; i <= N; i++) {\n          const dx = i - c, dy = j - c;\n          const r2 = dx * dx + dy * dy;\n          const g = Math.exp(-r2 * 0.012);\n          tnk.u[ix(i, j)] += -dy * force * g;\n          tnk.v[ix(i, j)] += dx * force * g;\n          tnk.dens[ix(i, j)] += dye * g * (0.4 + 0.6 * ((i + j) & 1));\n        }\n      }\n    };\n    tnk.sample = function (arr, x, y) {\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      return s0 * (t0 * arr[ix(i0, j0)] + t1 * arr[ix(i0, j1)]) +\n             s1 * (t0 * arr[ix(i1, j0)] + t1 * arr[ix(i1, j1)]);\n    };\n    return tnk;\n  }\n\n  const left = makeTank();\n  const right = makeTank();\n  left.swirl(0.35, 8);\n  right.swirl(0.35, 8);\n\n  const pixelsL = new ImageData(N, N);\n  const pixelsR = new ImageData(N, N);\n  const offL = document.createElement(\"canvas\");\n  const offR = document.createElement(\"canvas\");\n  offL.width = offR.width = N;\n  offL.height = offR.height = N;\n  const ctxL = offL.getContext(\"2d\");\n  const ctxR = offR.getContext(\"2d\");\n  if (!ctxL || !ctxR) {\n    fallback.hidden = false;\n    fallback.textContent = \"Canvas 2D is not available in this browser.\";\n    return;\n  }\n\n  const motionQuery = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  let reduced = motionQuery.matches;\n  const pointer = { x: 0, y: 0, px: 0, py: 0, down: false, side: -1 };\n  let last = 0, tAbs = 0, raf = 0;\n  let primed = false;\n\n  function whichSide(e) {\n    const rect = canvas.getBoundingClientRect();\n    const nx = (e.clientX - rect.left) / rect.width;\n    const ny = (e.clientY - rect.top) / rect.height;\n    const side = nx < 0.5 ? 0 : 1;\n    const localX = side === 0 ? nx / 0.5 : (nx - 0.5) / 0.5;\n    return { side: side, x: localX * N + 1, y: ny * N + 1 };\n  }\n  canvas.addEventListener(\"pointerdown\", function (e) {\n    const g = whichSide(e);\n    pointer.down = true; pointer.side = g.side;\n    pointer.x = pointer.px = g.x; pointer.y = pointer.py = g.y;\n    canvas.setPointerCapture(e.pointerId);\n    wake();\n  });\n  canvas.addEventListener(\"pointerup\", function () { pointer.down = false; wake(); });\n  canvas.addEventListener(\"pointerleave\", function () { pointer.side = -1; wake(); });\n  canvas.addEventListener(\"pointermove\", function (e) {\n    const g = whichSide(e);\n    pointer.px = pointer.x; pointer.py = pointer.y;\n    pointer.x = g.x; pointer.y = g.y;\n    if (pointer.down) pointer.side = g.side;\n    else pointer.side = g.side;\n    wake();\n  });\n\n  function strainAt(t) {\n    const T = Math.max(2, PARAMS.horizon);\n    const cycle = T + 0.9;\n    const tm = t % cycle;\n    if (tm >= T) return 1;\n    return Math.min(22, 1 / Math.max(0.05, 1 - tm / T));\n  }\n\n  function paintLeft() {\n    const data = pixelsL.data;\n    const rgb = hexToRgb(PARAMS.accent);\n    for (let j = 1; j <= N; j++) {\n      for (let i = 1; i <= N; i++) {\n        const d = left.dens[ix(i, j)];\n        const spd = Math.hypot(left.u[ix(i, j)], left.v[ix(i, j)]);\n        const glow = Math.min(1, d * 0.045 + spd * 0.08);\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    ctxL.putImageData(pixelsL, 0, 0);\n  }\n\n  function paintRight(strain) {\n    const data = pixelsR.data;\n    const hot = hexToRgb(PARAMS.color);\n    const cool = hexToRgb(PARAMS.accent);\n    const c = N * 0.5 + 1;\n    for (let j = 1; j <= N; j++) {\n      for (let i = 1; i <= N; i++) {\n        const dx = i - c, dy = j - c;\n        const r = Math.hypot(dx, dy) + 0.001;\n        const th = Math.atan2(dy, dx);\n        const r0 = r * (1 + (strain - 1) * 0.62);\n        const th0 = th - Math.log(r + 1) * (strain - 1) * 0.48;\n        const x0 = c + r0 * Math.cos(th0);\n        const y0 = c + (r0 * Math.sin(th0)) / (1 + (strain - 1) * 1.15);\n        const d = right.sample(right.dens, x0, y0);\n        const uu = right.sample(right.u, x0, y0);\n        const vv = right.sample(right.v, x0, y0);\n        const spd = Math.hypot(uu, vv) * strain;\n        const heat = Math.min(1, (strain - 1) * 0.08 + spd * 0.07);\n        const k = Math.min(1, d * 0.05 + spd * 0.04);\n        const o = ((j - 1) * N + (i - 1)) * 4;\n        data[o] = 10 + k * (cool[0] + (hot[0] - cool[0]) * heat);\n        data[o + 1] = 12 + k * (cool[1] + (hot[1] - cool[1]) * heat);\n        data[o + 2] = 14 + k * (cool[2] + (hot[2] - cool[2]) * heat);\n        data[o + 3] = 255;\n      }\n    }\n    ctxR.putImageData(pixelsR, 0, 0);\n  }\n\n  function labels(w, h, strain, t) {\n    const T = Math.max(2, PARAMS.horizon);\n    const tm = t % (T + 0.9);\n    ctx.save();\n    ctx.font = Math.max(10, (h * 0.028) | 0) + \"px ui-monospace, 'IBM Plex Mono', monospace\";\n    ctx.textBaseline = \"top\";\n    ctx.fillStyle = \"rgba(11,12,14,0.55)\";\n    ctx.fillRect(6, 6, w * 0.28, h * 0.13);\n    ctx.fillRect(w * 0.5 + 6, 6, w * 0.42, h * 0.16);\n    ctx.fillStyle = \"#c8d2dc\";\n    ctx.fillText(\"BOUNDED\", 12, 10);\n    ctx.fillText(\"same Stam 48×48\", 12, 10 + h * 0.045);\n    ctx.fillStyle = \"#ff6a2a\";\n    ctx.fillText(\"DISPLAY STRAIN\", w * 0.5 + 12, 10);\n    ctx.fillStyle = \"#c8d2dc\";\n    ctx.fillText(\"t → T*   scale 1/(T*−t)\", w * 0.5 + 12, 10 + h * 0.045);\n    ctx.fillText(\"×\" + strain.toFixed(1) + (tm >= T ? \"  reset\" : \"\"), w * 0.5 + 12, 10 + h * 0.09);\n    ctx.restore();\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) { canvas.width = w; canvas.height = h; }\n  }\n\n  function frame(now) {\n    const dt = Math.min(0.033, last ? (now - last) * 0.001 : 0.016);\n    last = now;\n    if (!reduced) tAbs += dt;\n    const strain = strainAt(tAbs);\n\n    if (!reduced) {\n      left.swirl(0.018, 0.35);\n      right.swirl(0.018, 0.35);\n    }\n    if (pointer.side === 0 || (pointer.down && pointer.side === 0)) {\n      const dx = (pointer.x - pointer.px) * 12;\n      const dy = (pointer.y - pointer.py) * 12;\n      left.splat(pointer.x, pointer.y, dx, dy, pointer.down ? 90 : 22);\n    }\n    if (pointer.side === 1 || (pointer.down && pointer.side === 1)) {\n      const dx = (pointer.x - pointer.px) * 12;\n      const dy = (pointer.y - pointer.py) * 12;\n      right.splat(pointer.x, pointer.y, dx, dy, pointer.down ? 90 : 22);\n    }\n    pointer.px = pointer.x; pointer.py = pointer.y;\n\n    if (!reduced) {\n      left.step(dt);\n      right.step(dt);\n    } else if (!primed) {\n      left.step(0.016);\n      right.step(0.016);\n    }\n    primed = true;\n\n    paintLeft();\n    paintRight(strain);\n    resize();\n    const w = canvas.width, h = canvas.height;\n    const gap = Math.max(2, (w * 0.006) | 0);\n    const hw = (w - gap) >> 1;\n    ctx.imageSmoothingEnabled = false;\n    ctx.fillStyle = \"#0b0c0e\";\n    ctx.fillRect(0, 0, w, h);\n    ctx.drawImage(offL, 0, 0, hw, h);\n    ctx.drawImage(offR, hw + gap, 0, w - hw - gap, h);\n    ctx.fillStyle = \"#4a5560\";\n    ctx.fillRect(hw, 0, gap, h);\n    labels(w, h, strain, tAbs);\n    if (!reduced) raf = requestAnimationFrame(frame);\n  }\n\n  // Motion. The system's reduced-motion preference decides by default. A\n  // standalone copy animates anyway with \"#motion=play\" in its URL (or holds a\n  // frame with \"#motion=still\"); an embedding page can post {motion: \"play\"}.\n  let motionMode = \"auto\";\n  function applyMotion() {\n    const next = motionMode === \"still\" || (motionMode === \"auto\" && motionQuery.matches);\n    if (next === reduced) return;\n    reduced = next;\n    last = 0;\n    if (reduced) { cancelAnimationFrame(raf); raf = 0; frame(performance.now()); }\n    else if (!raf) raf = requestAnimationFrame(frame);\n  }\n  function setMotion(mode) {\n    if (mode === \"play\" || mode === \"still\" || mode === \"auto\") { motionMode = mode; applyMotion(); }\n  }\n  function readMotionHash() {\n    const m = /motion=(play|still|auto)/.exec(location.hash);\n    let mode = m ? m[1] : \"auto\";\n    // Embedded in the gallery, the page sets window.__demoMotion before any frame loads.\n    try { if (!m && window.parent !== window && typeof window.parent.__demoMotion === \"string\") mode = window.parent.__demoMotion; } catch (e) { /* cross-origin parent */ }\n    setMotion(mode);\n  }\n  function wake() { if (reduced) frame(performance.now()); }\n  if (motionQuery.addEventListener) motionQuery.addEventListener(\"change\", applyMotion);\n  else if (motionQuery.addListener) motionQuery.addListener(applyMotion);\n  window.addEventListener(\"hashchange\", readMotionHash);\n  window.addEventListener(\"message\", function (e) { if (e.data && typeof e.data.motion === \"string\") setMotion(e.data.motion); });\n  window.addEventListener(\"resize\", wake);\n\n  if (reduced) frame(performance.now());\n  else raf = requestAnimationFrame(frame);\n  readMotionHash();\n})();\n</script>\n</body>\n</html>\n"}