About this technique →
curl-noise-flow/sketch.js
// Curl-Noise Flow
//
// Compute the curl of a 2D Perlin noise field N(x,y):
//   curl_x =  ∂N/∂y  ≈ (N(x, y+ε) - N(x, y-ε)) / 2ε
//   curl_y = -∂N/∂x  ≈ -(N(x+ε, y) - N(x-ε, y)) / 2ε
//
// The resulting vector field is divergence-free — particles circulate
// but never converge or diverge, producing fluid eddies and spirals.

export const WARMUP = { framesBeforeReady: 240 };

export const PARAMS = {
  particles:  { value: 2000, min: 100, max: 8000, step: 100, label: "Particle Count", folder: "Structure", rebuildOnChange: true },
  noiseScale: { value: 0.003, min: 0.0005, max: 0.02, step: 0.0001, label: "Field Frequency", folder: "Behavior" },
  speed:      { value: 1.5, min: 0.1, max: 5, step: 0.05, label: "Speed", folder: "Behavior" },
  trail:      { value: 0.04, min: 0, max: 0.3, step: 0.005, label: "Background Fade", folder: "Appearance" },
  hue:        { value: 200, min: 0, max: 360, step: 1, label: "Base Hue", folder: "Appearance" },
  hueSpread:  { value: 80, min: 0, max: 200, step: 1, label: "Hue Spread", folder: "Appearance" },
};

export const SHARE = {
  bookmarked: ["particles", "noiseScale", "speed", "trail", "hue", "hueSpread"],
};

const EPS = 1.0; // finite-difference epsilon in noise-space pixels

let state;

export function sketchSetup(p, w, h, params) {
  p.colorMode(p.HSB, 360, 100, 100, 1);
  p.background(0);

  const particles = [];
  for (let i = 0; i < params.particles; i++) {
    particles.push({
      x: p.random(w),
      y: p.random(h),
    });
  }

  state = { particles, w, h };
  return state;
}

export function sketchDraw(p, w, h, params) {
  // Fade previous frame — low alpha creates trail effect
  p.noStroke();
  p.fill(0, 0, 0, params.trail);
  p.rect(0, 0, w, h);

  const s = params.noiseScale;
  const speed = params.speed;

  p.strokeWeight(1.2);

  for (const pt of state.particles) {
    const px = pt.x;
    const py = pt.y;

    // Sample noise for curl approximation
    const nx = px * s;
    const ny = py * s;

    // ∂N/∂y — vary y
    const dny = (p.noise(nx, ny + EPS * s) - p.noise(nx, ny - EPS * s)) / (2 * EPS * s);
    // ∂N/∂x — vary x
    const dnx = (p.noise(nx + EPS * s, ny) - p.noise(nx - EPS * s, ny)) / (2 * EPS * s);

    // Curl: (∂N/∂y, -∂N/∂x)
    const vx = dny;
    const vy = -dnx;

    // Advance particle
    const nx2 = px + vx * speed;
    const ny2 = py + vy * speed;

    // Color by angle of velocity vector for variety
    const angle = Math.atan2(vy, vx); // -PI..PI
    const hue = ((params.hue + (angle / Math.PI) * params.hueSpread) % 360 + 360) % 360;

    p.stroke(hue, 75, 90, 0.6);
    p.line(px, py, nx2, ny2);

    // Wrap around canvas edges
    pt.x = ((nx2 % w) + w) % w;
    pt.y = ((ny2 % h) + h) % h;
  }
}