Two hundred particles that know nothing. At each position they read one angle from an invisible field and take a step. The structure you see was never drawn: it is what the field looks like when enough things follow it.
EXP-004
Move the cursor over the canvas — it is part of the simulation.
Drag your finger across the canvas — it is part of the simulation.
What to look for
Bright ridges form where many trajectories converge, and empty regions where the field pushes everything away. Those are the attractors and repellers of the field, visible only because something is moving through them.
The field. Two sine waves at different spatial frequencies, both drifting in time, which is what keeps the structure from freezing.
p ← p + (cos θ, sin θ)·v
Each particle steps in the direction the field gives it. No inertia, no memory: the trajectory is entirely a property of the field.
How it is built
The canvas is never cleared. Each frame it gets a black rectangle at 5.5% opacity, so old strokes fade instead of disappearing, and that fade is what produces the trails. Particles that leave the canvas or exhaust their lifetime respawn at random.
The code, step by step
Cut straight from lab.js. These are the real lines that run above, not a simplified version.
01
Read the field
const a = (Math.sin(p.x * f + t * 0.35) + Math.cos(p.y * f * 1.18 - t * 0.28)) * Math.PI;
let vx = Math.cos(a) * Q.speed, vy = Math.sin(a) * Q.speed;
The field is a closed-form function of position and time: no noise table, no lookup. Each particle asks for the angle where it stands. The scale slider changes f, which is how tight the swirls are.
02
Bend it with the cursor
if (M.in && Q.pull) { // el cursor curva las trayectorias
const dx = M.x - p.x, dy = M.y - p.y, d = Math.hypot(dx, dy) + 1;
if (d < 220) { const g = Q.pull * (1 - d / 220); vx += (dx / d) * g; vy += (dy / d) * g; }
Within 220px the cursor adds a pull that falls off linearly with distance. It does not replace the field: it is added on top, so you deform the flow instead of overriding it.
03
The trails are a fade, not a buffer
fade: 0.055,
The engine reads this flag and paints a black rectangle at 5.5% opacity instead of clearing. Old strokes decay exponentially, which costs nothing and stores no history.
Why it matters
It is the same idea behind streamline plots in fluid dynamics and behind how wind maps are drawn. The field is the physics; the particles are just how you make it visible.
The whole thing
Everything above, in one piece. This is the entire experiment as it lives in lab.js.
FULL
lab.js · flow
{ id: 'flow',
name: L('Flow field', 'Campo de flujo'),
note: L('Particles with no plan, following a field they cannot see.',
'Partículas sin plan, siguiendo un campo que no pueden ver.'),
params: [
{ key: 'scale', label: L('Field scale', 'Escala del campo'), min: 3, max: 30, step: 0.5, def: 11 },
{ key: 'speed', label: L('Step size', 'Tamaño del paso'), min: 0.3, max: 4, step: 0.05, def: 1.25 },
{ key: 'pull', label: L('Cursor pull', 'Atracción del cursor'), min: 0, max: 3, step: 0.1, def: 1.2 }
],
make(w, h) {
const N = 220, P = [];
for (let i = 0; i < N; i++) P.push({ x: Math.random() * w, y: Math.random() * h, life: Math.random() * 200 });
return {
fade: 0.055,
step(ctx, w, h, t, acc, Q, M) {
const f = Q.scale / 1000;
ctx.lineWidth = 1;
for (const p of P) {
const a = (Math.sin(p.x * f + t * 0.35) + Math.cos(p.y * f * 1.18 - t * 0.28)) * Math.PI;
let vx = Math.cos(a) * Q.speed, vy = Math.sin(a) * Q.speed;
if (M.in && Q.pull) { // el cursor curva las trayectorias
const dx = M.x - p.x, dy = M.y - p.y, d = Math.hypot(dx, dy) + 1;
if (d < 220) { const g = Q.pull * (1 - d / 220); vx += (dx / d) * g; vy += (dy / d) * g; }
}
const nx = p.x + vx, ny = p.y + vy;
ctx.strokeStyle = `rgba(${acc},0.30)`;
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(nx, ny); ctx.stroke();
p.x = nx; p.y = ny; p.life--;
if (p.life < 0 || p.x < 0 || p.x > w || p.y < 0 || p.y > h) {
p.x = Math.random() * w; p.y = Math.random() * h; p.life = 120 + Math.random() * 160;
}
}
}
};
}
},