Flow Field
Particles trace invisible contours through Perlin noise
A flow field is a map of angles — at each point, a direction. Perlin noise generates smooth gradient terrain, and particles follow its curl like water over landscape. Click anywhere to release a burst of particles and watch them find their paths.
Click on the canvas to spawn particle bursts.
The Perlin Noise Field
At each pixel coordinate, Perlin noise produces a smooth gradient value between -1 and 1. The key insight: we're not using the noise value directly as a height or color, but as an angle. This transforms abstract numbers into coherent directional flow.
const angle = perlin.noise(this.x * noiseScale, this.y * noiseScale) * Math.PI * 4;
The multiplication by Math.PI * 4 maps the noise range to angles. A value of -1 becomes -4π (pointing left and down), while +1 becomes +4π (pointing right and up). The intermediate values create the characteristic swirl of flow fields — not random chaos, but coherent vortices that particles naturally follow.
The noiseScale parameter controls spatial frequency. Small values like 0.005 mean the noise changes slowly across the canvas, creating vast swirling basins. Larger values create turbulent, rapidly-changing directions that fragments movement into chaos.
Particle Movement
Each particle queries its current position against the noise field and moves in the direction it finds there. This is the entire simulation — no forces, no goals, just follow the gradient.
this.x += Math.cos(angle) * this.speed;
this.y += Math.sin(angle) * this.speed;
The trigonometry converts an angle back into X and Y displacement. What makes this mesmerizing is that nearby particles query nearly the same noise value, creating coherent streams. A particle at (100, 100) and another at (100, 101) see almost identical angles, so they flow together.
The small random variation in speed (between 1 and 1.5) prevents particles from bunching up. Without it, particles on identical trajectories would overlap perfectly, creating visual artifacts rather than organic spread.
Trail History
Particles don't just exist at a point — they remember where they've been. Each frame pushes the current position into a history array, and drawing connects these points into visible paths.
this.history.push({
x: this.x,
y: this.y,
newSegment: isWrap
});
if (this.history.length > trailLength) {
this.history.shift();
}
The history array is bounded by trailLength. Old positions fall off the front, creating the characteristic fading trail. Without this bound, memory would grow unbounded and eventually crash the page.
The newSegment flag marks where particles wrapped around the edge — these points shouldn't connect to their predecessors, or you'd see lines shooting across the entire canvas.
Toroidal Space
When a particle reaches the canvas edge, it doesn't bounce or die — it wraps to the opposite side. This is toroidal topology: the canvas folds back on itself like a donut surface.
if (this.x < 0) this.x += canvas.width;
if (this.x > canvas.width) this.x -= canvas.width;
if (this.y < 0) this.y += canvas.height;
if (this.y > canvas.height) this.y -= canvas.height;
Toroidal space has no boundaries, no corners where particles accumulate. This is essential for flow fields — edges would otherwise become sinks or reflectors, distorting the natural curl the noise creates.
The wrap detection (isWrap) handles a subtle rendering issue: when a particle jumps from right edge to left edge, we shouldn't draw a line connecting those points. The visual should show two separate trail segments that happen to be the same particle's journey.
Trail Rendering and Fading
The trail draw routine walks through the history array, connecting points with line segments. But it breaks at wrap points to avoid visual artifacts.
for (let i = 1; i < this.history.length; i++) {
const curr = this.history[i];
if (curr.newSegment) {
ctx.stroke();
ctx.beginPath();
ctx.moveTo(curr.x, curr.y);
} else {
ctx.lineTo(curr.x, curr.y);
}
}
Each stroke() draws the accumulated path, then a new beginPath() starts fresh. This creates discontinuous segments that appear as one coherent trail but don't connect across wrapped edges.
The alpha fade (Math.max(0.1, 1 - (this.age / 500))) makes older particles increasingly transparent. Young particles are opaque, old ones fade to near-invisibility. This temporal dimension shows particle age through opacity, creating depth from time.
The Overlay Fade
There's a second kind of fading: the canvas itself, not just individual particles. Each animation frame draws a semi-transparent rectangle over the existing content.
ctx.fillStyle = 'rgba(250, 248, 245, 0.05)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
This technique creates the soft-fade trails. At 5% opacity, the overlay takes ~20 frames to halve the brightness of an old trail. Trails persist long enough to see patterns, but fade before the canvas becomes an unreadable tangle.
Combined with individual particle fading, this creates two temporal scales: individual particles fade based on age, while the canvas accumulates and erodes based on time. The result is readable layers — recent activity stands out, history recedes.
What This Reveals
Flow fields demonstrate how order emerges from following local gradients. Each particle makes only one decision: move in the direction indicated at this pixel. Yet collectively, they trace coherent vortices, streams, and eddies — the geometry of the noise field made visible through accumulation.
The technique scales beautifully. A 500-particle simulation and a 50,000-particle simulation use the same noise field — the field is just a function of position. Increasing particle count adds detail without changing behavior. The complexity lives in the field, not the agents.
What's surprising: Perlin noise was invented for texture generation, not flow. That the same function produces coherent curl when interpreted as angle is a happy accident of mathematics. Smooth gradients in any dimension tend to produce vortex-like dynamics when used this way. The aesthetic is a side effect of spatial coherence.