08. Stam · 1999
Fluid tank
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.
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: "#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 reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const pointer = { x: 0, y: 0, px: 0, py: 0, down: false, inside: false };
let last = 0;
let raf = 0;
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);
});
canvas.addEventListener("pointerup", function () { pointer.down = false; });
canvas.addEventListener("pointerleave", function () { pointer.inside = false; });
canvas.addEventListener("pointerenter", function () { pointer.inside = true; });
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;
});
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 (!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);
}
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);
frame(performance.now());
} else {
raf = requestAnimationFrame(frame);
}
})();