CurlNoise

@three-blocks/coreWebGPUWebGL
module CurlNoise

What is curl noise? Curl noise constructs a vector field by taking the curl of a vector potential: flow = ∇ × A. Because div(∇ × A) = 0, the resulting field is divergence-free.

Usual usage

  • Advect particles: pos += curlNoise(pos) * dt (or integrate multiple substeps for prettier flow)
  • UV/normal distortion: flow-based warping without obvious compress/expand artifacts
  • Stylized fluid-like motion and turbulence fields

Performance notes

  • Uses analytic derivatives of simplex noise ⇒ 3 noise evaluations per sample
  • Avoids finite differences ⇒ typically far faster than naive curl implementations

Code example

Example
const positionBuffer = instancedArray(COUNT, 'vec3');
const velocityBuffer = instancedArray(COUNT, 'vec3');
const prevVelocityBuffer = instancedArray(COUNT, 'vec3');
const matrixBuffer = instancedArray(COUNT, 'mat4');
const aoBuffer = instancedArray(COUNT, 'float');

// Build lookAt matrix from direction (reusable in compute)
const buildLookAtMatrix = Fn(([direction, position]) => {
  const up = vec3(0, 1, 0);
  const dir = normalize(direction);

  const right = normalize(cross(up, dir));
  const correctedUp = normalize(cross(dir, right));

  // Build full transformation matrix with rotation + translation
  return mat4(
    vec4(right, 0),
    vec4(correctedUp, 0),
    vec4(dir, 0),
    vec4(position, 1)
  );
});

// computeInit: spawn particles at center with curl noise distribution
const computeInit = Fn(() => {
  const i = float(instanceIndex);

  // Spherical distribution from center
  const phi = i.mul(2.399963);  // golden angle
  const theta = i.div(COUNT).mul(Math.PI);
  const r = i.div(COUNT).sqrt().mul(3.0);

  const pos = vec3(
    r.mul(theta.sin()).mul(phi.cos()),
    r.mul(theta.cos()),
    r.mul(theta.sin()).mul(phi.sin())
  );

  const initVel = vec3(0, 1, 0);

  positionBuffer.element(instanceIndex).assign(pos);
  velocityBuffer.element(instanceIndex).assign(initVel);
  prevVelocityBuffer.element(instanceIndex).assign(initVel);
  matrixBuffer.element(instanceIndex).assign(buildLookAtMatrix(initVel, pos));
  aoBuffer.element(instanceIndex).assign(0.0);
});

renderer.compute(computeInit().compute(COUNT));

// Uniforms
const uFrequency = uniform(FREQUENCY);
const uAmplitude = uniform(AMPLITUDE);
const uSpeed = uniform(SPEED);
const uSteps = uniform(STEPS);
const uDt = uniform(DT);

// computeUpdate: advect with curl noise, compute mat4
const computeUpdateFn = Fn(() => {
  const pos = positionBuffer.element(instanceIndex).toVar();
  const vel = velocityBuffer.element(instanceIndex);
  const prevVel = prevVelocityBuffer.element(instanceIndex);

  // Store previous velocity before computing new one
  prevVel.assign(vel);

  // Oscillating scale for grow/shrink effect
  const scale = sin(time.mul(uSpeed)).mul(0.5).add(1.0);
  const dt = float(uDt).mul(scale);

  // Advect through curl noise field
  Loop(int(uSteps), () => {
    const v = curlNoise(pos, uFrequency, time.mul(0.05), uAmplitude, false);
    pos.addAssign(v.mul(dt));
  });

  // Store current velocity
  vel.assign(pos.sub(positionBuffer.element(instanceIndex)));

  // Smooth direction from mix of prev and current velocity
  const smoothDir = normalize(mix(prevVel, vel, 0.5).add(vec3(0.0001)));

  // Compute and store mat4
  matrixBuffer.element(instanceIndex).assign(buildLookAtMatrix(smoothDir, pos));

  // Fake AO based on multiple factors:
  // 1. Distance from center (darker at center where particles cluster)
  const distFromCenter = length(pos);
  const centerAo = float(1.0).sub(clamp(distFromCenter.div(5.0), 0.0, 1.0));

  // 2. Vertical position (darker at bottom)
  const heightAo = clamp(pos.z.div(4.8).add(0.5), 0.0, 1.0);

  // 3. Velocity magnitude (slower = more clustered = darker)
  const speed = length(vel);
  const speedAo = float(1.0).sub(clamp(speed.mul(50.0), 0.0, 1.0));

  // Combine AO factors
  const ao = clamp(centerAo.oneMinus().mul(1).add(heightAo.mul(0.3)).add(speedAo.mul(0.3)), 0.0, 1.0);
  aoBuffer.element(instanceIndex).assign(ao);

  positionBuffer.element(instanceIndex).assign(pos);
});

computeUpdate = computeUpdateFn().compute(COUNT);

// Material with direction-based coloring
const material = new THREE.MeshPhysicalNodeMaterial({
  metalness: 0.8,
  roughness: 0.3
});

const vel = velocityBuffer.element(instanceIndex);
const prevVel = prevVelocityBuffer.element(instanceIndex);

// Color based on velocity direction
const smoothDir = normalize(mix(prevVel, vel, 0.5).add(vec3(0.0001)));
const colX = abs(smoothDir.x);
const colY = abs(smoothDir.y);
const colZ = abs(smoothDir.z);

const cA = color(0x00ffff);  // cyan
const cB = color(0xff00ff);  // magenta
const cC = color(0xffff00);  // yellow

material.colorNode = mix(mix(cA, cB, colX), cC, colY.add(colZ).mul(0.5));

// Apply fake AO from buffer
material.aoNode = aoBuffer.element(instanceIndex);

// Mesh with small box geometry
const geometry = new THREE.BoxGeometry(0.08, 0.28, 0.08);
const mesh = new THREE.InstancedMesh(geometry, material, COUNT);

// Use mat4 buffer directly for instance matrices
mesh.instanceMatrix = matrixBuffer.value;
scene.add(mesh);

Methods

curlNoise#

curlNoise(position : vec3, frequency : number|float, time : number|float, amplitude : number|float, normalize : boolean|bool) : vec3

Divergence-free curl noise vector field (incompressible swirling flow).

Note This function returns a velocity field. For “pretty curl-noise visuals”, integrate it over time (see curlAdvect) or use it to advect particles.

Parameters
positionvec3
World/texture position.
frequencyoptionalnumber | float
Spatial frequency (detail scale).
Default is 1.0.
timeoptionalnumber | float
Animation time (translation in noise space).
Default is 0.0.
amplitudeoptionalnumber | float
Output magnitude.
Default is 1.0.
normalizeoptionalboolean | bool
When true, output is unit length (direction only).
Default is false.
Returns
vec3 — Divergence-free flow vector.

curlNoiseFbm#

curlNoiseFbm(position : vec3, baseFrequency : number|float, time : number|float, octaves : number|int, lacunarity : number|float, gain : number|float, amplitude : number|float, normalize : boolean|bool) : vec3

Multi-octave curl noise (FBM) for richer turbulence.

Keep octaves low for realtime (2–4). Each octave is still efficient (3 derivative-noise evaluations), but it adds up quickly.

Parameters
positionvec3
baseFrequencyoptionalnumber | float
Default is 1.0.
timeoptionalnumber | float
Default is 0.0.
octavesoptionalnumber | int
Default is 3.
lacunarityoptionalnumber | float
Default is 2.0.
gainoptionalnumber | float
Default is 0.5.
amplitudeoptionalnumber | float
Default is 1.0.
normalizeoptionalboolean | bool
Default is false.
Returns
vec3

curlAdvect#

curlAdvect(position : vec3, frequency : number|float, time : number|float, steps : number|int, dt : number|float, warp : number|float, amplitude : number|float, useFbm : boolean|bool, octaves : number|int, lacunarity : number|float, gain : number|float) : vec3

Integrate (advect) a position through a curl-noise field to reveal coherent vortices.

This is the “missing piece” when demos look like generic displacement. Advection performs: p = p + v(p) * dt repeatedly. Even 4–8 steps can transform the look dramatically.

Design choices

  • The field is kept mostly stable by using a very small internal time drift.
  • A cheap domain warp breaks axis-aligned / lattice artifacts (especially in instanced grids).
Parameters
positionvec3
Starting position.
frequencyoptionalnumber | float
Field frequency (detail scale).
Default is 1.0.
timeoptionalnumber | float
Advection time driver (used for subtle drift).
Default is 0.0.
stepsoptionalnumber | int
Number of substeps (4–8 typical).
Default is 6.
dtoptionalnumber | float
Step size per substep (0.10–0.25 typical).
Default is 0.18.
warpoptionalnumber | float
Domain warp strength (0 disables).
Default is 0.75.
amplitudeoptionalnumber | float
Scales final displacement from the original position.
Default is 1.0.
useFbmoptionalboolean | bool
Uses FBM curl field when true, single-octave when false.
Default is true.
octavesoptionalnumber | int
FBM octaves (used if useFbm=true).
Default is 3.
lacunarityoptionalnumber | float
FBM lacunarity.
Default is 2.0.
gainoptionalnumber | float
FBM gain.
Default is 0.5.
Returns
vec3 — Advected position.