29. Stam · 1999 / 2026
Dual tank
About
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.
Move or drag on either half to stir that tank. Right-hand color scale grows as t approaches T*.
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. prefers-reduced-motion freezes the first still.
Source
(function () {
const PARAMS = {
color: "#ff6a2a",
accent: "#3ce0c8",
horizon: 6
};
function hexToRgb(hex) {
const n = parseInt(String(hex).replace("#", ""), 16);
if (isNaN(n)) return [255, 106, 42];
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, ITER = 4, dim = N + 2, size = dim * dim;
function ix(i, j) { return i + dim * j; }
function makeTank() {
function field() { return new Float32Array(size); }
const tnk = {
u: field(), v: field(), u0: field(), v0: field(),
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);
}
tnk.step = function (dt) {
const u = tnk.u, v = tnk.v, u0 = tnk.u0, v0 = tnk.v0;
diffuse(1, u0, u, 0.00012, dt);
diffuse(2, v0, v, 0.00012, dt);
project(u0, v0, tnk.p, tnk.div);
advect(1, u, u0, u0, v0, dt);
advect(2, v, v0, u0, v0, dt);
project(u, v, tnk.p, tnk.div);
diffuse(0, tnk.dens0, tnk.dens, 0.00008, dt);
advect(0, tnk.dens, tnk.dens0, u, v, dt);
for (let i = 0; i < size; i++) tnk.dens[i] *= 0.994;
};
tnk.splat = function (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;
tnk.dens[ix(ii, jj)] += amount * wgt;
tnk.u[ix(ii, jj)] += dx * wgt;
tnk.v[ix(ii, jj)] += dy * wgt;
}
}
};
tnk.swirl = function (force, dye) {
const c = N * 0.5 + 1;
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
const dx = i - c, dy = j - c;
const r2 = dx * dx + dy * dy;
const g = Math.exp(-r2 * 0.012);
tnk.u[ix(i, j)] += -dy * force * g;
tnk.v[ix(i, j)] += dx * force * g;
tnk.dens[ix(i, j)] += dye * g * (0.4 + 0.6 * ((i + j) & 1));
}
}
};
tnk.sample = function (arr, x, y) {
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;
return s0 * (t0 * arr[ix(i0, j0)] + t1 * arr[ix(i0, j1)]) +
s1 * (t0 * arr[ix(i1, j0)] + t1 * arr[ix(i1, j1)]);
};
return tnk;
}
const left = makeTank();
const right = makeTank();
left.swirl(0.35, 8);
right.swirl(0.35, 8);
const pixelsL = new ImageData(N, N);
const pixelsR = new ImageData(N, N);
const offL = document.createElement("canvas");
const offR = document.createElement("canvas");
offL.width = offR.width = N;
offL.height = offR.height = N;
const ctxL = offL.getContext("2d");
const ctxR = offR.getContext("2d");
if (!ctxL || !ctxR) {
fallback.hidden = false;
fallback.textContent = "Canvas 2D is not available in this browser.";
return;
}
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const pointer = { x: 0, y: 0, px: 0, py: 0, down: false, side: -1 };
let last = 0, tAbs = 0;
function whichSide(e) {
const rect = canvas.getBoundingClientRect();
const nx = (e.clientX - rect.left) / rect.width;
const ny = (e.clientY - rect.top) / rect.height;
const side = nx < 0.5 ? 0 : 1;
const localX = side === 0 ? nx / 0.5 : (nx - 0.5) / 0.5;
return { side: side, x: localX * N + 1, y: ny * N + 1 };
}
canvas.addEventListener("pointerdown", function (e) {
const g = whichSide(e);
pointer.down = true; pointer.side = g.side;
pointer.x = pointer.px = g.x; pointer.y = pointer.py = g.y;
canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener("pointerup", function () { pointer.down = false; });
canvas.addEventListener("pointerleave", function () { pointer.side = -1; });
canvas.addEventListener("pointermove", function (e) {
const g = whichSide(e);
pointer.px = pointer.x; pointer.py = pointer.y;
pointer.x = g.x; pointer.y = g.y;
if (pointer.down) pointer.side = g.side;
else pointer.side = g.side;
});
function strainAt(t) {
const T = Math.max(2, PARAMS.horizon);
const cycle = T + 0.9;
const tm = t % cycle;
if (tm >= T) return 1;
return Math.min(22, 1 / Math.max(0.05, 1 - tm / T));
}
function paintLeft() {
const data = pixelsL.data;
const rgb = hexToRgb(PARAMS.accent);
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
const d = left.dens[ix(i, j)];
const spd = Math.hypot(left.u[ix(i, j)], left.v[ix(i, j)]);
const glow = Math.min(1, d * 0.045 + spd * 0.08);
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;
}
}
ctxL.putImageData(pixelsL, 0, 0);
}
function paintRight(strain) {
const data = pixelsR.data;
const hot = hexToRgb(PARAMS.color);
const cool = hexToRgb(PARAMS.accent);
const c = N * 0.5 + 1;
for (let j = 1; j <= N; j++) {
for (let i = 1; i <= N; i++) {
const dx = i - c, dy = j - c;
const r = Math.hypot(dx, dy) + 0.001;
const th = Math.atan2(dy, dx);
const r0 = r * (1 + (strain - 1) * 0.62);
const th0 = th - Math.log(r + 1) * (strain - 1) * 0.48;
const x0 = c + r0 * Math.cos(th0);
const y0 = c + (r0 * Math.sin(th0)) / (1 + (strain - 1) * 1.15);
const d = right.sample(right.dens, x0, y0);
const uu = right.sample(right.u, x0, y0);
const vv = right.sample(right.v, x0, y0);
const spd = Math.hypot(uu, vv) * strain;
const heat = Math.min(1, (strain - 1) * 0.08 + spd * 0.07);
const k = Math.min(1, d * 0.05 + spd * 0.04);
const o = ((j - 1) * N + (i - 1)) * 4;
data[o] = 10 + k * (cool[0] + (hot[0] - cool[0]) * heat);
data[o + 1] = 12 + k * (cool[1] + (hot[1] - cool[1]) * heat);
data[o + 2] = 14 + k * (cool[2] + (hot[2] - cool[2]) * heat);
data[o + 3] = 255;
}
}
ctxR.putImageData(pixelsR, 0, 0);
}
function labels(w, h, strain, t) {
const T = Math.max(2, PARAMS.horizon);
const tm = t % (T + 0.9);
ctx.save();
ctx.font = Math.max(10, (h * 0.028) | 0) + "px ui-monospace, 'IBM Plex Mono', monospace";
ctx.textBaseline = "top";
ctx.fillStyle = "rgba(11,12,14,0.55)";
ctx.fillRect(6, 6, w * 0.28, h * 0.13);
ctx.fillRect(w * 0.5 + 6, 6, w * 0.42, h * 0.16);
ctx.fillStyle = "#c8d2dc";
ctx.fillText("BOUNDED", 12, 10);
ctx.fillText("same Stam 48×48", 12, 10 + h * 0.045);
ctx.fillStyle = "#ff6a2a";
ctx.fillText("DISPLAY STRAIN", w * 0.5 + 12, 10);
ctx.fillStyle = "#c8d2dc";
ctx.fillText("t → T* scale 1/(T*−t)", w * 0.5 + 12, 10 + h * 0.045);
ctx.fillText("×" + strain.toFixed(1) + (tm >= T ? " reset" : ""), w * 0.5 + 12, 10 + h * 0.09);
ctx.restore();
}
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;
tAbs += dt;
const strain = strainAt(tAbs);
if (!reduced) {
left.swirl(0.018, 0.35);
right.swirl(0.018, 0.35);
}
if (pointer.side === 0 || (pointer.down && pointer.side === 0)) {
const dx = (pointer.x - pointer.px) * 12;
const dy = (pointer.y - pointer.py) * 12;
left.splat(pointer.x, pointer.y, dx, dy, pointer.down ? 90 : 22);
}
if (pointer.side === 1 || (pointer.down && pointer.side === 1)) {
const dx = (pointer.x - pointer.px) * 12;
const dy = (pointer.y - pointer.py) * 12;
right.splat(pointer.x, pointer.y, dx, dy, pointer.down ? 90 : 22);
}
pointer.px = pointer.x; pointer.py = pointer.y;
if (!reduced) {
left.step(dt);
right.step(dt);
}
paintLeft();
paintRight(strain);
resize();
const w = canvas.width, h = canvas.height;
const gap = Math.max(2, (w * 0.006) | 0);
const hw = (w - gap) >> 1;
ctx.imageSmoothingEnabled = false;
ctx.fillStyle = "#0b0c0e";
ctx.fillRect(0, 0, w, h);
ctx.drawImage(offL, 0, 0, hw, h);
ctx.drawImage(offR, hw + gap, 0, w - hw - gap, h);
ctx.fillStyle = "#4a5560";
ctx.fillRect(hw, 0, gap, h);
labels(w, h, strain, tAbs);
if (!reduced) requestAnimationFrame(frame);
}
if (reduced) {
left.step(0.016); right.step(0.016);
frame(performance.now());
} else requestAnimationFrame(frame);
})();