Chris Collins

Lab · Playground

Flow fields

Thousands of particles drifting through a Perlin-noise vector field, leaving trails that pile up into silk.

Code blocks on this page are editable — change them and press Run.

A flow field gives every point on the canvas a direction. Here the direction comes from Perlin noise: noise(x, y, t) is a smooth random value between 0 and 1, and multiplying it by a few turns of a circle gives an angle that changes gradually from one point to its neighbours. Drop particles in, let each one step along the angle under it, and draw a faint line for every step. Where the field converges, the lines pile up into bright ridges; where it diverges, the canvas stays dark.

The third noise coordinate, t, creeps forward each frame, so the field slowly breathes and the pattern never settles. Click the canvas to clear it and scatter the particles again.

A noise flow field
// Things to change: these five numbers.
const COUNT = 1400;      // particles
const SCALE = 0.004;     // noise zoom: smaller = broader, lazier curves
const TURNS = 3;         // how many full turns the noise maps to
const SPEED = 1.4;       // pixels per step
const DRIFT = 0.0012;    // how fast the field itself changes

let particles = [];
let t = 0;

function setup() {
  createCanvas(min(windowWidth, 640), 320);
  colorMode(HSB, 360, 100, 100, 100);
  reset();
}

function reset() {
  background(230, 40, 8);
  noiseSeed(floor(random(1e6)));
  particles = [];
  for (let i = 0; i < COUNT; i++) particles.push(spawn());
}

function spawn() {
  const p = createVector(random(width), random(height));
  return { pos: p, prev: p.copy(), life: random(80, 300) };
}

function draw() {
  for (const p of particles) {
    const a = noise(p.pos.x * SCALE, p.pos.y * SCALE, t) * TWO_PI * TURNS;
    p.prev.set(p.pos);
    p.pos.add(cos(a) * SPEED, sin(a) * SPEED);
    // Hue follows the direction of travel, so parallel streams share a colour.
    const hue = (degrees(a) + 180) % 360;
    stroke(hue, 60, 100, 6);
    line(p.prev.x, p.prev.y, p.pos.x, p.pos.y);
    p.life--;
    if (p.life < 0 || p.pos.x < 0 || p.pos.x > width || p.pos.y < 0 || p.pos.y > height) {
      Object.assign(p, spawn());
    }
  }
  t += DRIFT;
}

function mousePressed() {
  if (mouseX >= 0 && mouseX <= width && mouseY >= 0 && mouseY <= height) reset();
}

Things to change

  • SCALE — try 0.015 for tight, nervous whorls or 0.001 for long sweeping rivers.

  • TURNS — 1 gives a gentle drift in one general direction; 8 makes a tangle of eddies.

  • COUNT and the alpha in stroke(…, 6) — more particles and a lower alpha build up slowly into something like brushed metal.

  • DRIFT = 0 freezes the field, so the particles trace the same streamlines over and over.

  • Replace the angle with something that is not noise: atan2(p.pos.y - height / 2, p.pos.x - width / 2) + HALF_PI is a vortex around the centre.