p5.js Intermediate Article 2

Particle Systems

Emitters, force fields, coloured trails, and tips for running thousands of particles efficiently.

⏱ 22 min read particles emitter forces trails performance

The anatomy of a particle system

A particle system has three components:

  1. Emitter — spawns particles at a position, with initial velocity and properties
  2. Particles — individual units with position, velocity, lifespan, and visual representation
  3. 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