About this technique →
perlin-field-particles/sketch.js
// Perlin Field Particles
//
// Each particle's movement direction is derived from a multi-octave fBm noise
// field: angle = fbm(x*scale, y*scale) * TAU * fieldFactor.
//
// Unlike curl noise, this field HAS divergence — particles accumulate in sinks
// and thin out at sources, forming organic river and dendritic structures.

export const WARMUP = { framesBeforeReady: 360 };

export const PARAMS = {
  particles:   { value: 3000, min: 100, max: 10000, step: 100, label: "Particles", folder: "Structure", rebuildOnChange: true },
  octaves:     { value: 4, min: 1, max: 8, step: 1, label: "fBm Octaves", folder: "Behavior" },
  lacunarity:  { value: 2.0, min: 1.5, max: 3, step: 0.05, label: "Lacunarity", folder: "Behavior" },
  noiseScale:  { value: 0.004, min: 0.0005, max: 0.02, step: 0.0001, label: "Field Frequency", folder: "Behavior" },
  fieldFactor: { value: 4, min: 1, max: 12, step: 0.5, label: "Angle Range (×TAU)", folder: "Behavior" },
  speed:       { value: 1.0, min: 0.1, max: 4, step: 0.05, label: "Speed", folder: "Behavior" },
  trail:       { value: 0.02, min: 0, max: 0.3, step: 0.005, label: "Background Fade", folder: "Appearance" },
  hue:         { value: 30, min: 0, max: 360, step: 1, label: "Base Hue", folder: "Appearance" },
};

export const SHARE = {
  bookmarked: ["particles", "octaves", "lacunarity", "noiseScale", "fieldFactor", "speed", "hue"],
};

let state;

// Fractal Brownian Motion using p5's built-in Perlin noise.
// Returns a value roughly in [0, 1].
function fbm(p, x, y, octaves, lacunarity) {
  let value = 0;
  let amplitude = 1;
  let frequency = 1;
  let maxAmp = 0;
  const persistence = 0.5;
  for (let i = 0; i < octaves; i++) {
    value += amplitude * p.noise(x * frequency, y * frequency);
    maxAmp += amplitude;
    amplitude *= persistence;
    frequency *= lacunarity;
  }
  return value / maxAmp;
}

export function sketchSetup(p, w, h, params) {
  p.colorMode(p.HSB, 360, 100, 100, 1);
  p.background(0);

  const particles = [];
  for (let i = 0; i < params.particles; i++) {
    particles.push({
      x: p.random(w),
      y: p.random(h),
      age: p.random(200), // stagger ages for variety
    });
  }

  state = { particles, w, h };
  return state;
}

export function sketchDraw(p, w, h, params) {
  // Very slow fade to let trails accumulate and show convergence structure
  p.noStroke();
  p.fill(0, 0, 0, params.trail);
  p.rect(0, 0, w, h);

  const TAU = Math.PI * 2;
  const s = params.noiseScale;

  p.strokeWeight(1.0);

  for (const pt of state.particles) {
    const px = pt.x;
    const py = pt.y;

    // Sample fBm field — angle in [0, TAU * fieldFactor]
    const fieldValue = fbm(p, px * s, py * s, params.octaves | 0, params.lacunarity);
    const angle = fieldValue * TAU * params.fieldFactor;

    const vx = Math.cos(angle) * params.speed;
    const vy = Math.sin(angle) * params.speed;

    const nx2 = px + vx;
    const ny2 = py + vy;

    // Color by field value: warm tones shifting with the noise
    const hue = ((params.hue + fieldValue * 60) % 360 + 360) % 360;
    p.stroke(hue, 70, 88, 0.55);
    p.line(px, py, nx2, ny2);

    pt.age++;

    // Wrap edges
    pt.x = ((nx2 % w) + w) % w;
    pt.y = ((ny2 % h) + h) % h;
  }
}