function setup() {
createCanvas(windowWidth, windowHeight);
colorMode(HSB, 360, 100, 100, 100); // Use HSB for vibrant colors and easy hue manipulation
noFill(); // Particles are drawn with stroke
cols = floor(width / resolution);
rows = floor(height / resolution);
flowfield = new Array(cols * rows);
// Initialize particles with random positions
for (let i = 0; i < particleCount; i++) {
particles[i] = new Particle(random(width), random(height));
}
background(0); // Initial black background
}
Line-by-line explanation (8 lines)
🔧 Subcomponents:
for-loop
Particle Initialization Loop
for (let i = 0; i < particleCount; i++) {
Creates particleCount Particle objects at random starting positions and stores them in the particles array
createCanvas(windowWidth, windowHeight);
- Makes the canvas fill the entire browser window.
colorMode(HSB, 360, 100, 100, 100);
- Switches from default RGB to Hue-Saturation-Brightness color mode, with hue 0-360, saturation/brightness 0-100, and alpha 0-100 - this makes it easy to smoothly rotate through rainbow colors by just changing the hue number.
noFill();
- Tells p5 not to fill shapes with color - particles will only show their stroke (outline), which is how the circles appear as glowing dots.
cols = floor(width / resolution);
- Calculates how many grid columns fit across the canvas width, based on the resolution (cell size).
rows = floor(height / resolution);
- Calculates how many grid rows fit down the canvas height.
flowfield = new Array(cols * rows);
- Creates an empty array big enough to hold one vector per grid cell - this will store the flow field.
particles[i] = new Particle(random(width), random(height));
- Creates a new Particle object at a random x,y position and stores it in the particles array.
background(0);
- Paints the canvas solid black once at the start, before the trail-fading effect takes over in draw().
🔬 The noise value is multiplied by TWO_PI * 4, giving particles lots of swirling variation. What happens if you change the 4 to just 1 (a single full rotation) or up to 8?
let angle = noise(xoff, yoff, zoff) * TWO_PI * 4;
let v = p5.Vector.fromAngle(angle);
v.setMag(1); // Normalize force vector
🔬 This loop runs the full particle lifecycle every frame. What happens if you comment out particles[i].edges() - can you see particles disappearing off-screen instead of wrapping?
for (let i = 0; i < particles.length; i++) {
particles[i].follow(flowfield);
particles[i].update();
particles[i].edges();
particles[i].show();
}
function draw() {
// Use ADD blend mode for glowing trails
// A semi-transparent black background fades old trails slowly
blendMode(ADD);
background(0, 0, 0, 10); // Alpha value controls trail length (lower = longer)
// Update base hue for dynamic color changes
baseHue = (frameCount * 0.1) % 360;
// Generate the flow field for this frame
let yoff = 0;
for (let y = 0; y < rows; y++) {
let xoff = 0;
for (let x = 0; x < cols; x++) {
let index = x + y * cols;
// Map Perlin noise value (0-1) to an angle (0 to TWO_PI * 4 for more variation)
let angle = noise(xoff, yoff, zoff) * TWO_PI * 4;
let v = p5.Vector.fromAngle(angle);
v.setMag(1); // Normalize force vector
flowfield[index] = v;
xoff += noiseScale;
}
yoff += noiseScale;
}
zoff += 0.005; // Increment zoff to evolve the noise pattern over time
// Update and display all particles
for (let i = 0; i < particles.length; i++) {
particles[i].follow(flowfield);
particles[i].update();
particles[i].edges();
particles[i].show();
}
}
Line-by-line explanation (12 lines)
🔧 Subcomponents:
for-loop
Flow Field Generation (nested loop)
for (let y = 0; y < rows; y++) {
Walks through every cell in the grid, samples Perlin noise, and converts the result into a direction vector stored in the flowfield array
for-loop
Particle Update Loop
for (let i = 0; i < particles.length; i++) {
Calls follow(), update(), edges() and show() on every particle each frame, driving their motion and rendering
blendMode(ADD);
- Switches drawing so new colors are added to existing pixel colors instead of replacing them - overlapping particles brighten toward white, creating a glow effect.
background(0, 0, 0, 10);
- Draws a nearly-transparent black rectangle over everything. Instead of erasing the frame, this slightly fades old particle trails, leaving faint streaks behind.
baseHue = (frameCount * 0.1) % 360;
- Slowly cycles a hue value from 0 to 360 based on how many frames have played, so the overall color scheme drifts through the rainbow over time.
let angle = noise(xoff, yoff, zoff) * TWO_PI * 4;
- Perlin noise returns a smooth value between 0 and 1; multiplying by TWO_PI * 4 converts it into an angle (with extra multiples of a full circle for more dramatic swirling).
let v = p5.Vector.fromAngle(angle);
- Creates a vector pointing in the direction of that angle - this becomes the 'wind' direction for particles in this grid cell.
v.setMag(1);
- Sets the vector's length to exactly 1, so it represents pure direction without adding extra force strength.
flowfield[index] = v;
- Stores the direction vector into the flat 1D array at the calculated index for this grid cell.
zoff += 0.005;
- Nudges the z-coordinate used for 3D noise forward slightly every frame, which makes the entire noise pattern - and therefore the flow field - slowly morph over time.
particles[i].follow(flowfield);
- Tells the particle to look up the flow field vector at its current position and steer toward it.
particles[i].update();
- Moves the particle according to its velocity and acceleration, and updates its color.
particles[i].edges();
- Wraps the particle back onto the canvas if it has drifted off any edge.
particles[i].show();
- Draws the particle as a small colored circle at its current position.
🔬 This wraps particles around the screen like Pac-Man. What happens if you replace the wrap with a bounce instead, by flipping this.vel.x or this.vel.y when a particle hits an edge?
edges() {
if (this.pos.x > width) this.pos.x = 0;
if (this.pos.x < 0) this.pos.x = width;
if (this.pos.y > height) this.pos.y = 0;
if (this.pos.y < 0) this.pos.y = height;
}
class Particle {
constructor(x, y) {
this.pos = createVector(x, y);
this.vel = createVector(0, 0);
this.acc = createVector(0, 0);
this.maxSpeed = maxSpeed;
// Initial color based on baseHue with noise offset and low alpha for trails
this.color = {
h: (baseHue + noise(x * noiseScale, y * noiseScale, zoff) * 60) % 360,
s: 100,
b: 100,
a: 10,
};
}
// Apply a force vector to the particle's acceleration
applyForce(force) {
this.acc.add(force);
}
// Follow the flow field
follow(vectors) {
let x = floor(this.pos.x / resolution);
let y = floor(this.pos.y / resolution);
let index = x + y * cols;
if (index >= 0 && index < vectors.length) { // Ensure index is within bounds
let force = vectors[index];
this.applyForce(force);
}
}
// Update particle's position and velocity
update() {
this.vel.add(this.acc);
this.vel.limit(this.maxSpeed); // Limit velocity to prevent particles from moving too fast
this.pos.add(this.vel);
this.acc.mult(0); // Reset acceleration each frame
// Update particle hue dynamically based on baseHue and a noise offset
this.color.h = (baseHue + noise(this.pos.x * noiseScale, this.pos.y * noiseScale, zoff) * 60) % 360;
}
// Display the particle
show() {
strokeWeight(particleSize);
stroke(this.color.h, this.color.s, this.color.b, this.color.a);
circle(this.pos.x, this.pos.y, particleSize);
}
// Wrap particle around the screen edges
edges() {
if (this.pos.x > width) this.pos.x = 0;
if (this.pos.x < 0) this.pos.x = width;
if (this.pos.y > height) this.pos.y = 0;
if (this.pos.y < 0) this.pos.y = height;
}
}
Line-by-line explanation (16 lines)
🔧 Subcomponents:
conditional
Edge Wrap Checks
if (this.pos.x > width) this.pos.x = 0;
Teleports the particle to the opposite edge when it exits the canvas, so it never disappears
conditional
Flow Field Index Bounds Check
if (index >= 0 && index < vectors.length) {
Prevents an out-of-bounds array access if a particle's calculated grid index falls outside the flowfield array
this.pos = createVector(x, y);
- Stores the particle's position as a p5.Vector, so x and y can be manipulated together with vector math.
this.vel = createVector(0, 0);
- Starts the particle with zero velocity - it will speed up as flow field forces push it.
this.acc = createVector(0, 0);
- Acceleration starts at zero and gets refreshed each frame from applied forces.
this.maxSpeed = maxSpeed;
- Copies the global maxSpeed value onto the particle so each particle instance has its own speed cap.
h: (baseHue + noise(x * noiseScale, y * noiseScale, zoff) * 60) % 360,
- Calculates a starting hue by combining the current global baseHue with a noise-based offset (up to 60 degrees), so nearby particles get similar but not identical colors.
this.acc.add(force);
- Adds an incoming force vector onto the particle's acceleration - this is how the flow field 'pushes' particles.
let x = floor(this.pos.x / resolution);
- Converts the particle's pixel position into a grid column index by dividing by the cell size.
let index = x + y * cols;
- Converts the 2D grid column/row into a single index for the flat flowfield array (standard 2D-to-1D array conversion).
this.vel.add(this.acc);
- Applies the accumulated acceleration to velocity, speeding up or changing direction.
this.vel.limit(this.maxSpeed);
- Caps the velocity's length so particles never move faster than maxSpeed, keeping motion smooth and controlled.
this.pos.add(this.vel);
- Moves the particle by adding velocity to its position - this is the actual motion step.
this.acc.mult(0);
- Resets acceleration to zero after applying it, so forces don't accumulate infinitely across frames.
strokeWeight(particleSize);
- Sets how thick the outline (and therefore the visible dot) will be drawn.
stroke(this.color.h, this.color.s, this.color.b, this.color.a);
- Sets the drawing color using this particle's current HSB values, including a low alpha for a soft glowing look.
circle(this.pos.x, this.pos.y, particleSize);
- Draws the particle as a tiny circle at its current position (since noFill() is active, only the colored stroke shows).
if (this.pos.x > width) this.pos.x = 0;
- If the particle drifts past the right edge, teleport it to the left edge instead of letting it disappear.