2D fluid (Stam's stable fluids)
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.
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
// 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);
}