Shader Lab
28.2D fluid (Stam)

2D fluid (Stam's stable fluids)

Arc 6 · Simulations · Lesson 28 of 35
coordinate signal color

Arc 6 extension — sim state (velocity + dye) lives in ping-pong textures read across frames. This is where L1's "no past frames" rule gets its first exception.

Click and drag on the canvas to push the fluid and inject dye.

0.10
0.005
18

Purpose

Real fluid on a GPU is four small passes per frame — add force, advect, diffuse, project — and the thing that makes it stable is reading backward along the velocity instead of pushing stuff forward.

Key insight

Jos Stam's 1999 paper solved the problem that had kept fluid out of real-time graphics: naïvely pushing each pixel's dye forward along its velocity blows up the moment velocities get large. Stam flipped it. For each pixel, we ask "where was the fluid that is now here, one step ago?" — step backward along the velocity vector, sample the previous frame's texture at that location. Always stable: we're reading a value that already exists, not guessing a new one.

The full recipe is four passes:

(1) add — inject velocity and dye where the user drags.
(2) advect — for each pixel, trace backward along the velocity and copy what's there.
(3) diffuse — blur the velocity field slightly (that's viscosity).
(4) project — Jacobi iterations solve for a pressure field that, when subtracted, leaves the velocity divergence-free — incompressible, like real fluid.

All of it runs in fragment shaders on ping-pong textures. The magic is the composition, not any one piece.

Break it

1. Drag projection down to 0. Keep pushing dye around. The fluid no longer acts like fluid — dye pools in pockets, drains out of others, smears in odd directions. Teaches: projection is the pass that enforces incompressibility. Without it the velocity field has sources and sinks; fluid "compresses." Projection is not a polish — it is what makes it a fluid.

2. Crank viscosity to max. Now flick the canvas. The sharp vortex dissolves into slow, thick motion — every velocity averages so strongly with its neighbors that nothing stays concentrated. Teaches: viscosity isn't a color or a shape — it is velocity-smoothing per frame. This is the moment the word "viscous" becomes physically legible.

3. Crank dissipation high. Dye fades almost as fast as you inject it. Teaches: dissipation is the "fade the memory" knob. Long-lived ink trails need near-zero; quick-puff smoke needs a lot.

Direct Claude

"more viscous / honey-like" viscosity up "thinner, more watery" viscosity down "more turbulent / chaotic" viscosity down, force strength up "calmer / more laminar" viscosity up, force strength down "dye lingers longer" dissipation down "dye fades quickly" dissipation up "real fluid / incompressible" projection 10-30 iters "break the fluid / show me compressibility" projection 0
Meta-phrase you gain here: "this is a Stam fluid" is now a specific brief — four-pass Navier-Stokes with ping-pong textures — not the fuzzy "add fluid-looking swirls" (that's L30 curl-noise, or L31 procedural fire).
Combines with: L30 (drop curl-noise particles into this velocity field so they're carried by the flow), L29 (run reaction-diffusion on top of the velocity — Karl Sims' classic), L31 (use the dye channel here as a fire-density field), Arc 7 bloom (dye + bloom is the classic magic-fluid look).
the advection + projection core
// ADVECT — for each pixel, step backward along the velocity and copy.
// This is the shader that makes Stam fluid stable.
uniform sampler2D u_velocity;
uniform sampler2D u_field;   // whatever we're advecting (velocity or dye)
uniform vec2 u_texel;
uniform float u_dt;
uniform float u_dissipation;

void main() {
  vec2 uv = gl_FragCoord.xy * u_texel;
  vec2 v = texture2D(u_velocity, uv).xy;
  vec2 past = uv - v * u_dt * u_texel;       // backward trace
  vec4 sampled = texture2D(u_field, past);   // read what was there
  gl_FragColor = sampled * (1.0 - u_dissipation);
}

// PROJECT (Jacobi step) — relax pressure toward divergence-free velocity.
// Run N times; more iterations = closer to truly incompressible.
uniform sampler2D u_pressure;
uniform sampler2D u_divergence;
void main() {
  vec2 uv = gl_FragCoord.xy * u_texel;
  float L = texture2D(u_pressure, uv - vec2(u_texel.x, 0.0)).r;
  float R = texture2D(u_pressure, uv + vec2(u_texel.x, 0.0)).r;
  float B = texture2D(u_pressure, uv - vec2(0.0, u_texel.y)).r;
  float T = texture2D(u_pressure, uv + vec2(0.0, u_texel.y)).r;
  float div = texture2D(u_divergence, uv).r;
  gl_FragColor = vec4((L + R + B + T - div) * 0.25, 0.0, 0.0, 1.0);
}