Particle Systems
Emitters, force fields, coloured trails, and tips for running thousands of particles efficiently.
The anatomy of a particle system
A particle system has three components:
- Emitter — spawns particles at a position, with initial velocity and properties
- Particles — individual units with position, velocity, lifespan, and visual representation
- Forces — gravity, wind, attraction — modify particle velocity each frame
Emitter class
class Emitter {
constructor(x, y) {
this.pos = createVector(x, y);
this.particles = [];
this.rate = 5; // particles per frame
this.active = true;
}
emit() {
if (!this.active) return;
for (let i = 0; i < this.rate; i++) {
this.particles.push(new Particle(
this.pos.x,
this.pos.y,
p5.Vector.random2D().mult(random(0.5, 3))
));
}
}
applyForce(force) {
for (let p of this.particles) p.applyForce(force);
}
update() {
this.emit();
for (let p of this.particles) p.update();
this.particles = this.particles.filter(p => !p.isDead());
}
draw() {
for (let p of this.particles) p.draw();
}
moveTo(x, y) {
this.pos.set(x, y);
}
}
Particle class with forces
class Particle {
constructor(x, y, initialVel) {
this.pos = createVector(x, y);
this.vel = initialVel || p5.Vector.random2D().mult(2);
this.acc = createVector(0, 0);
this.life = 1.0;
this.maxLife = random(0.008, 0.015);
this.r = random(2, 8);
}
applyForce(force) {
this.acc.add(force);
}
update() {
this.vel.add(this.acc);
this.vel.limit(6);
this.pos.add(this.vel);
this.acc.mult(0);
this.life -= this.maxLife;
}
isDead() { return this.life <= 0; }
draw() {
let alpha = map(this.life, 0, 1, 0, 220);
let size = map(this.life, 0, 1, 0, this.r * 2);
colorMode(HSB, 360, 100, 100, 255);
fill(map(this.life, 0, 1, 20, 50), 80, 95, alpha);
noStroke();
circle(this.pos.x, this.pos.y, size);
}
}
Global forces
Attach forces to the emitter rather than individual particles:
let emitter;
let gravity = createVector(0, 0.08);
let wind = createVector(0, 0);
function setup() {
createCanvas(700, 500);
emitter = new Emitter(width / 2, height / 2);
}
function draw() {
background(15, 15, 20, 30); // trail effect
wind.x = map(noise(frameCount * 0.005), 0, 1, -0.04, 0.04);
emitter.moveTo(mouseX, mouseY);
emitter.applyForce(gravity);
emitter.applyForce(wind);
emitter.update();
emitter.draw();
// HUD
colorMode(RGB);
fill(255, 80);
textSize(12);
text(`Particles: ${emitter.particles.length}`, 10, 20);
}
Attraction and repulsion
Particles attracted to or repelled from a point:
function attractorForce(particle, target, strength) {
let force = p5.Vector.sub(target, particle.pos);
let d = constrain(force.mag(), 5, 50);
force.normalize();
force.mult(strength / (d * d));
return force;
}
// In draw():
let attract = attractorForce(p, createVector(width / 2, height / 2), 200);
p.applyForce(attract);
Particle trails
Two approaches:
Approach 1: Fade the background — don’t clear, just overlay a semi-transparent background:
function draw() {
background(15, 15, 20, 15); // alpha ~= 15
// ... draw particles
}
Approach 2: Store history — each particle keeps its recent positions:
class TrailParticle extends Particle {
constructor(x, y) {
super(x, y);
this.history = [];
this.maxHistory = 20;
}
update() {
this.history.push(this.pos.copy());
if (this.history.length > this.maxHistory) this.history.shift();
super.update();
}
draw() {
noFill();
for (let i = 1; i < this.history.length; i++) {
let alpha = map(i, 0, this.history.length, 0, 200);
stroke(150, 100, 255, alpha);
strokeWeight(map(i, 0, this.history.length, 0.5, 3));
line(
this.history[i - 1].x, this.history[i - 1].y,
this.history[i].x, this.history[i].y
);
}
}
}
Fireworks
Multiple emitters spawning burst particles:
let rockets = [];
let explosions = [];
class Rocket {
constructor() {
this.pos = createVector(random(100, width - 100), height + 10);
this.vel = createVector(random(-1, 1), random(-12, -8));
this.alive = true;
}
update() {
this.vel.y += 0.2; // gravity
this.pos.add(this.vel);
if (this.vel.y >= 0) {
this.alive = false;
explosions.push(new Explosion(this.pos.x, this.pos.y));
}
}
draw() {
fill(255, 200, 100);
noStroke();
circle(this.pos.x, this.pos.y, 6);
}
}
class Explosion {
constructor(x, y) {
this.particles = [];
let hue = random(360);
for (let i = 0; i < 80; i++) {
let v = p5.Vector.random2D().mult(random(1, 5));
this.particles.push({ pos: createVector(x, y), vel: v, life: 1.0, hue });
}
}
update() {
for (let p of this.particles) {
p.vel.mult(0.96);
p.vel.y += 0.05;
p.pos.add(p.vel);
p.life -= 0.015;
}
this.particles = this.particles.filter(p => p.life > 0);
}
isDead() { return this.particles.length === 0; }
draw() {
colorMode(HSB, 360, 100, 100, 255);
noStroke();
for (let p of this.particles) {
fill(p.hue, 80, 90, p.life * 255);
circle(p.pos.x, p.pos.y, 5);
}
}
}
Key takeaways
- Separate concerns: Emitter spawns, Particle stores state, forces modify it
p5.Vector.random2D()creates a random unit vector — perfect for initial particle directions- Apply forces by adding to acceleration each frame, then zero the acceleration
- Fade the background (semi-transparent
background()) for trails without storing history - History arrays per particle give precise, controllable trails
- Object pooling (covered in article 1) is essential when spawning hundreds of particles per second