Vector fields

Flow field

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 maths

θ(x,y,t) = π[sin(0.011x + 0.35t) + cos(0.013y − 0.28t)]

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;
              }
            }
          }
        };
      }
    },