6

Curl-Noise Particles: Divergence-Free Flow

drag Y for scale · hold and drag to repel

Particles ride a velocity field built as the curl of a scalar noise: . Because the divergence of a curl vanishes identically, — the flow is incompressible, so particles never collapse onto attractors; they orbit forever. Here is a sum of a handful of rotating sinusoidal lumps, , a smooth analytically-differentiable surrogate for Perlin noise. A few thousand particles integrate and are drawn with additive (lighter) compositing so overlapping trails saturate to white in active regions; the color of each trail segment is mapped from the local field magnitude (a proxy for vorticity intensity, since ), cool to warm. Drag the mouse vertically to scrub the spatial frequency — top of the canvas gives sweeping continental flows, bottom gives a turbulent boil of small eddies. Hold and drag to push particles radially out of an ball under the cursor, carving a temporary void in the field that the curl flow then resorbs. The field itself slowly auto-rotates so the topology never freezes.

idle
236 lines · vanilla
view source
// Curl-noise particles. The velocity field is v = curl(psi * z_hat) where
// psi(x,y,t) is a smooth scalar noise. The curl operator guarantees
// div(v) = 0 — divergence-free — so particles can't pile up at sinks; they
// orbit indefinitely.
//
// Implementation: psi is a sum of a few rotating sinusoidal "lumps"
// (cheap surrogate for Perlin; smooth, analytically differentiable, and
// gives a curl field with eddies of a few characteristic scales).
//   psi(x,y,t) = sum_k A_k * sin(kx_k*x + phx_k(t)) * cos(ky_k*y + phy_k(t))
// then v = (d psi / dy, -d psi / dx).
//
// Particles are drawn additively ('lighter') so overlapping trails saturate
// to white in active regions; per-frame the canvas gets a faint dark wash
// for the trail-fade.

const N_DESKTOP = 4500;
const N_MOBILE  = 2500;
const TRAIL_FADE = 0.10;   // alpha of the dark wash each frame; bigger = shorter trails
const REPEL_R    = 80;
const REPEL_R2   = REPEL_R * REPEL_R;
const REPEL_STR  = 320;    // px/s^2 of repulsion at the cursor
const MAX_SPEED  = 220;
const N_LUMPS    = 5;      // number of sinusoidal terms in psi
const FIELD_ROT  = 0.10;   // rad/s — slow auto-rotate of the lump phases

let parts;                 // typed arrays
let lumps;                 // { A, kx, ky, phx, phy, wx, wy } per lump
let palette;               // Uint8Array, 256*3
let lastW = 0, lastH = 0;
let scaleK = 0.012;        // current spatial frequency multiplier (mouseY-driven)
let targetK = 0.012;       // smoothed target

function makePalette() {
  // cool (low |curl|) -> warm (high) gradient
  // deep blue -> teal -> yellow -> magenta-red
  const p = new Uint8Array(256 * 3);
  for (let i = 0; i < 256; i++) {
    const t = i / 255;
    let r, g, b;
    if (t < 0.33) {
      const u = t / 0.33;
      r = 20 + u * 30;
      g = 60 + u * 160;
      b = 140 + u * 100;
    } else if (t < 0.66) {
      const u = (t - 0.33) / 0.33;
      r = 50 + u * 200;
      g = 220 - u * 40;
      b = 240 - u * 200;
    } else {
      const u = (t - 0.66) / 0.34;
      r = 250;
      g = 180 - u * 130;
      b = 40 + u * 120;
    }
    p[i * 3]     = r | 0;
    p[i * 3 + 1] = g | 0;
    p[i * 3 + 2] = b | 0;
  }
  return p;
}

function makeLumps() {
  const ls = [];
  for (let i = 0; i < N_LUMPS; i++) {
    ls.push({
      A:   0.7 + Math.random() * 0.6,
      kxBase: (0.4 + Math.random() * 1.6),  // multiplied by scaleK at sample time
      kyBase: (0.4 + Math.random() * 1.6),
      phx: Math.random() * Math.PI * 2,
      phy: Math.random() * Math.PI * 2,
      wx:  (Math.random() - 0.5) * 0.6,     // temporal angular velocity for phx
      wy:  (Math.random() - 0.5) * 0.6,     // and phy
      sign: Math.random() < 0.5 ? -1 : 1,
    });
  }
  return ls;
}

// Sample the curl field at (x,y) given current scaleK and time-rotated phases.
// v = ( d psi / dy , -d psi / dx )
//
// psi = sum A * sin(kx*x + phx) * cos(ky*y + phy)
// d psi / dx =  A * kx * cos(kx*x + phx) * cos(ky*y + phy)
// d psi / dy = -A * ky * sin(kx*x + phx) * sin(ky*y + phy)
//
// so vx =  d psi / dy = -A * ky * sin(kx*x + phx) * sin(ky*y + phy)
//    vy = -d psi / dx = -A * kx * cos(kx*x + phx) * cos(ky*y + phy)
function sampleVel(x, y, time, out) {
  let vx = 0, vy = 0;
  const k = scaleK;
  // Auto-rotate: the phases drift over time. This is what makes the field
  // never stop reorganizing even when the user does nothing.
  const tRot = time * FIELD_ROT;
  for (let i = 0; i < N_LUMPS; i++) {
    const L = lumps[i];
    const kx = L.kxBase * k;
    const ky = L.kyBase * k;
    const phx = L.phx + L.wx * time + tRot * L.sign;
    const phy = L.phy + L.wy * time - tRot * L.sign;
    const ax = kx * x + phx;
    const ay = ky * y + phy;
    const sX = Math.sin(ax), cX = Math.cos(ax);
    const sY = Math.sin(ay), cY = Math.cos(ay);
    vx += -L.A * ky * sX * sY;
    vy += -L.A * kx * cX * cY;
  }
  // Field magnitude needs scaling — at small k, derivatives are tiny;
  // at large k, derivatives are large. Renormalize to a usable speed range.
  // Empirically dividing by k * 0.6 keeps the apparent flow speed roughly
  // constant as the user scrubs scale.
  const norm = 1 / (k * 0.6);
  out[0] = vx * norm;
  out[1] = vy * norm;
}

function allocParticles(n, width, height) {
  parts = {
    n,
    x:   new Float32Array(n),
    y:   new Float32Array(n),
    px:  new Float32Array(n),  // previous position (for trail segments)
    py:  new Float32Array(n),
    age: new Float32Array(n),  // particle age, for periodic respawn
    life: new Float32Array(n),
  };
  for (let i = 0; i < n; i++) {
    parts.x[i]  = Math.random() * width;
    parts.y[i]  = Math.random() * height;
    parts.px[i] = parts.x[i];
    parts.py[i] = parts.y[i];
    parts.age[i] = Math.random() * 6;
    parts.life[i] = 4 + Math.random() * 8;
  }
}

function init({ canvas, ctx, width, height, input }) {
  lumps = makeLumps();
  palette = makePalette();
  // particle count: scale down on tiny canvases (mobile)
  const small = width * height < 360 * 600;
  const N = small ? N_MOBILE : N_DESKTOP;
  allocParticles(N, width, height);
  lastW = width; lastH = height;

  // Clear once to a deep background so the very first frame doesn't flash.
  ctx.fillStyle = '#05060a';
  ctx.fillRect(0, 0, width, height);
}

const _v = [0, 0];

function tick({ ctx, dt, time, width, height, input }) {
  if (dt > 0.05) dt = 0.05;

  // Resize: keep particle counts; just rewrap any out-of-bounds onto the new canvas.
  if (width !== lastW || height !== lastH) {
    for (let i = 0; i < parts.n; i++) {
      if (parts.x[i] > width)  parts.x[i] = Math.random() * width;
      if (parts.y[i] > height) parts.y[i] = Math.random() * height;
      parts.px[i] = parts.x[i];
      parts.py[i] = parts.y[i];
    }
    lastW = width; lastH = height;
  }

  // mouseY scrubs noise scale. Map 0..height -> 0.003 .. 0.030 (log-ish).
  // Top of canvas = sweeping flows; bottom = small eddies.
  const my = Math.max(0, Math.min(height, input.mouseY || height * 0.5));
  const u = my / Math.max(1, height);
  targetK = 0.003 * Math.pow(10, u);   // 0.003 -> 0.03
  // Smooth the scale change so the field doesn't snap.
  scaleK += (targetK - scaleK) * Math.min(1, dt * 4);

  // Fade the previous frame to make trails.
  ctx.globalCompositeOperation = 'source-over';
  ctx.fillStyle = `rgba(5,6,10,${TRAIL_FADE})`;
  ctx.fillRect(0, 0, width, height);

  // Draw trail segments additively.
  ctx.globalCompositeOperation = 'lighter';
  ctx.lineWidth = 1;

  const mx = input.mouseX || 0;
  const myy = input.mouseY || 0;
  const repelOn = !!input.mouseDown;

  // We need a per-particle color decided by local curl magnitude. To avoid
  // a beginPath/stroke per particle (which is the bottleneck), we bucket
  // segments by quantized color index, then stroke each bucket once.
  // 24 buckets is plenty perceptually.
  const NB = 24;
  // Reuse arrays across frames — but we don't know sizes, so allocate cheaply.
  // Use a single Float32Array of x1,y1,x2,y2 quads per bucket. Worst case
  // every particle into one bucket: 4 * n floats. Allocate once and reuse.
  if (!tick._buckets || tick._buckets.length !== NB) {
    tick._buckets = new Array(NB);
    for (let b = 0; b < NB; b++) {
      tick._buckets[b] = { arr: new Float32Array(parts.n * 4), len: 0 };
    }
  }
  const buckets = tick._buckets;
  for (let b = 0; b < NB; b++) buckets[b].len = 0;

  // Integrate every particle.
  const n = parts.n;
  for (let i = 0; i < n; i++) {
    const ox = parts.x[i], oy = parts.y[i];
    sampleVel(ox, oy, time, _v);
    let vx = _v[0], vy = _v[1];

    // Repulsion from cursor while held.
    if (repelOn) {
      const dx = ox - mx, dy = oy - myy;
      const d2 = dx * dx + dy * dy;
      if (d2 < REPEL_R2 && d2 > 0.01) {
        const d = Math.sqrt(d2);
        const falloff = 1 - d / REPEL_R;        // 1 at cursor, 0 at radius
        const s = REPEL_STR * falloff * falloff; // quadratic falloff
        vx += (dx / d) * s;
        vy += (dy / d) * s;
      }
    }

    // Clamp speed (mostly matters when the repel is active).
    const sp2 = vx * vx + vy * vy;
    if (sp2 > MAX_SPEED * MAX_SPEED) {
      const sp = Math.sqrt(sp2);
      vx = (vx / sp) * MAX_SPEED;
      vy = (vy / sp) * MAX_SPEED;
    }

    let nx = ox + vx * dt;
    let ny = oy + vy * dt;

    // Wrap edges (toroidal) — particles in/out feels seamless.
    let wrapped = false;
    if (nx < 0)        { nx += width;  wrapped = true; }
    else if (nx >= width)  { nx -= width;  wrapped = true; }
    if (ny < 0)        { ny += height; wrapped = true; }
    else if (ny >= height) { ny -= height; wrapped = true; }

    // Periodic respawn so trails don't all look "old"; also prevents
    // tiny populations getting stuck in a slow region of the field.
    parts.age[i] += dt;
    if (parts.age[i] > parts.life[i]) {
      parts.age[i] = 0;
      parts.life[i] = 4 + Math.random() * 8;
      nx = Math.random() * width;
      ny = Math.random() * height;
      wrapped = true;
    }

    // Color by local curl-field speed (= field magnitude, our proxy for
    // angular momentum / vorticity intensity).
    const speed = Math.sqrt(sp2);
    // Map 0..MAX_SPEED -> 0..1, sqrt for perceptual punch.
    const t = Math.min(1, Math.sqrt(speed / MAX_SPEED));
    const bi = Math.min(NB - 1, (t * NB) | 0);
    const b = buckets[bi];

    if (!wrapped) {
      // Push line segment ox,oy -> nx,ny into the right bucket.
      const L = b.len;
      b.arr[L]     = ox;
      b.arr[L + 1] = oy;
      b.arr[L + 2] = nx;
      b.arr[L + 3] = ny;
      b.len = L + 4;
    }

    parts.px[i] = ox;
    parts.py[i] = oy;
    parts.x[i]  = nx;
    parts.y[i]  = ny;
  }

  // Stroke each bucket once with its color. Use a fixed-alpha stroke; the
  // 'lighter' composite handles the brightness build-up.
  for (let bi = 0; bi < NB; bi++) {
    const b = buckets[bi];
    if (b.len === 0) continue;
    const t = bi / (NB - 1);
    const pi = Math.min(255, (t * 255) | 0);
    const r = palette[pi * 3];
    const g = palette[pi * 3 + 1];
    const bl = palette[pi * 3 + 2];
    ctx.strokeStyle = `rgba(${r},${g},${bl},0.55)`;
    ctx.beginPath();
    const arr = b.arr;
    const L = b.len;
    for (let k = 0; k < L; k += 4) {
      ctx.moveTo(arr[k], arr[k + 1]);
      ctx.lineTo(arr[k + 2], arr[k + 3]);
    }
    ctx.stroke();
  }

  // Cursor ring while repelling.
  if (repelOn) {
    ctx.globalCompositeOperation = 'source-over';
    ctx.strokeStyle = 'rgba(255,255,255,0.45)';
    ctx.lineWidth = 1.2;
    ctx.beginPath();
    ctx.arc(mx, myy, REPEL_R, 0, Math.PI * 2);
    ctx.stroke();
  }

  // HUD: scale readout + scale bar.
  ctx.globalCompositeOperation = 'source-over';
  ctx.fillStyle = 'rgba(0,0,0,0.55)';
  ctx.fillRect(8, 8, 178, 46);
  ctx.fillStyle = '#fff';
  ctx.font = '12px monospace';
  ctx.textBaseline = 'top';
  ctx.fillText('scale k = ' + scaleK.toFixed(4), 14, 14);
  ctx.fillText(repelOn ? 'mode: repel' : 'mode: drift', 14, 32);
  // mini bar showing where scaleK sits in the [0.003, 0.030] range
  const barX = 110, barY = 16, barW = 64, barH = 6;
  ctx.strokeStyle = 'rgba(255,255,255,0.35)';
  ctx.strokeRect(barX, barY, barW, barH);
  const frac = Math.max(0, Math.min(1, (Math.log10(scaleK / 0.003)) / 1));
  ctx.fillStyle = 'rgba(255,255,255,0.85)';
  ctx.fillRect(barX, barY, barW * frac, barH);
}

Comments (0)

Log in to comment.