Shader Lab
30.Curl-noise particles

Curl-noise particles

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

Sim state = particle positions in a ping-pong texture. The field they move through is a pure math identity (curl of noise), so there is no Navier-Stokes anywhere — procedural field, simulated particles.

1.40
0.40
0.00

Purpose

You can get fluid-looking swarms without solving Navier-Stokes by driving particles through a vector field that is mathematically guaranteed to neither compress nor expand space.

Key insight

A Stam fluid (L28) is expensive because most of its cost is the projection pass enforcing incompressibility. Curl noise sidesteps all of that with a single math identity: div(curl(F)) = 0. If you take the curl of any scalar noise field — in 2D, that's the gradient rotated 90° — the resulting vector field is automatically divergence-free. Free pass, no solving.

Particles moving through this field swirl like fluid, never collecting into sinks, never dispersing into nothing — because the field itself preserves volume. This is a fake in the trilemma sense: nothing is being simulated hydrodynamically, just a pre-designed vector field that has one true fluid property (incompressibility) baked in by construction. The particles themselves are real — their positions persist in ping-pong textures, updated each frame. But the field they live in is procedural math, not a solved equation.

Bradley & Bridson introduced this in 2007 precisely because it gives you the look of fluid at a fraction of the cost. It's the default choice for magic sparks, dust motes, incense smoke — anything fluid-ish that doesn't need to respond to the player.

Break it

1. Drag gradient-mode from 0 to 1. At 0 the field is curl(noise). At 1 it is gradient(noise) — same noise, different derivative. Watch what happens: particles collect into pockets (sinks) and drain out of others (sources). Within seconds the screen has bald patches and clumps. Teaches: curl matters. The divergence-free property isn't an aesthetic choice — it is the one thing keeping particles evenly distributed. Without it you get the pooling disease.

2. Pull breath to 0. The field freezes. Particles still flow, but along fixed streamlines that never change. Teaches: the "alive" look of a curl-noise swarm comes from the noise slowly drifting over time — not from the particles. The particles follow; the field breathes.

3. Crank swirl-scale up (bigger noise scale → smaller eddies). The swarm gains tight little vortices everywhere. Pull it down and the flow becomes long sweeping currents. Teaches: the one knob a director usually needs. Eddy size tracks the slider directly.

Direct Claude

"more swirly / more eddies" swirl-scale up (smaller scale → more vortices) "more laminar / longer streams" swirl-scale down "field breathes faster" breath up "field frozen / sculptural" breath to 0 "break it / show me the pooling" gradient-mode to 1
Meta-phrase you gain here: "curl-noise particles" is now a specific brief — distinct from "Stam fluid with dye" (L28) and distinct from "stylized noise smoke" (L31). The trilemma is visible: procedural field, simulated particles, no Navier-Stokes.
Combines with: L28 (drop curl-noise particles inside a Stam velocity field — they're pushed by both, a common production trick), Arc 3 FBM (use FBM as the underlying potential for richer eddy distribution), Arc 7 bloom (glowing particles are where curl noise earns its magic-VFX reputation).
the curl-of-noise velocity step
// SIM PASS — each texel is one particle's XY position.
// Each frame: read previous position, sample curl(noise) there, step forward.
uniform sampler2D u_prev;
uniform float u_time;
uniform float u_scale;
uniform float u_breath;
uniform float u_mix;   // 0 = curl, 1 = gradient (break-it)

float hash(vec2 p) {
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
float vnoise(vec2 p) { /* smooth value noise, omitted for brevity */ }

// Potential field (a scalar). Animate by slowly drifting z.
float potential(vec2 p) {
  float t = u_time * u_breath * 0.3;
  return vnoise(p * u_scale + vec2(0.0, t))
       + 0.5 * vnoise(p * u_scale * 2.0 + vec2(t, 0.0));
}

void main() {
  vec2 pos = texture2D(u_prev, gl_FragCoord.xy / textureSize).xy;
  float eps = 0.01;
  float pR = potential(pos + vec2(eps, 0.0));
  float pL = potential(pos - vec2(eps, 0.0));
  float pT = potential(pos + vec2(0.0, eps));
  float pB = potential(pos - vec2(0.0, eps));
  vec2 grad = vec2(pR - pL, pT - pB) / (2.0 * eps);
  vec2 curl = vec2(grad.y, -grad.x);            // perpendicular to gradient
  vec2 vel  = mix(curl, grad, u_mix);            // break-it lever
  pos += vel * 0.004;
  if (pos.x < -1.0 || pos.x > 1.0 || pos.y < -1.0 || pos.y > 1.0) {
    pos = vec2(hash(pos + u_time) - 0.5, hash(pos - u_time) - 0.5) * 1.6;
  }
  gl_FragColor = vec4(pos, 0.0, 1.0);
}