6
Curl-Noise Particles: Divergence-Free Flow
drag Y for scale · hold and drag to repel
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.