Three.js From Zero · Article s0-07

Fireworks

Fireworks is Article s0-07 of Three.js From Zero, a MasterAllArts free interactive lesson for artists learning creative 3D on the web.

← Three.js From ZeroS0-07 · Quick Wins

Season 0 · Article 07 · Quick Wins

Click the sky, get a burst. 200 particles per firework, full color palette, gravity, fade-out, trails. One BufferGeometry, dynamic vertex updates, additive blending. A demo that makes everyone want to make their own.

— fps · 0 bursts
Click anywhere in the scene to launch a firework

The pool pattern

The naive approach: each firework gets its own Points object, with the geometry built on click. That works but garbage-collects every burst and stutters. The pro approach: one big particle pool — say 5000 particles total — and we "spawn" by reusing dead slots. The geometry is allocated once at startup.

Step 1 — The pool

const MAX = 5000;
const positions = new Float32Array(MAX * 3);
const colors = new Float32Array(MAX * 3);
const velocities = new Float32Array(MAX * 3);  // CPU-side, not uploaded
const lives = new Float32Array(MAX);            // 0 = dead, 1 = fresh

for (let i = 0; i < MAX; i++) lives[i] = 0;

const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3).setUsage(THREE.DynamicDrawUsage));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3).setUsage(THREE.DynamicDrawUsage));

DynamicDrawUsage is a hint to the GPU driver that this buffer changes every frame. Without it, the driver assumes StaticDrawUsage and may copy the buffer to slower memory.

Step 2 — Spawn a burst

function spawnFirework(x, y) {
  const burstColor = new THREE.Color().setHSL(Math.random(), 0.9, 0.6);
  const burstSize = 200;
  let placed = 0;

  for (let i = 0; i < MAX && placed < burstSize; i++) {
    if (lives[i] > 0) continue;          // already alive — skip
    lives[i] = 1.0;

    // Spherical random direction
    const theta = Math.random() * Math.PI * 2;
    const phi = Math.acos(Math.random() * 2 - 1);
    const speed = 1.5 + Math.random() * 2;
    velocities[i*3+0] = Math.sin(phi) * Math.cos(theta) * speed;
    velocities[i*3+1] = Math.sin(phi) * Math.sin(theta) * speed;
    velocities[i*3+2] = Math.cos(phi) * speed;

    positions[i*3+0] = x; positions[i*3+1] = y; positions[i*3+2] = 0;
    colors[i*3+0] = burstColor.r;
    colors[i*3+1] = burstColor.g;
    colors[i*3+2] = burstColor.b;
    placed++;
  }
}

The "find first dead slot" loop is O(n) — not great if you spawn 50 bursts. For most cases (5000 pool, 200 per burst), it's fine. If you needed more, you'd keep a freelist of dead indices.

Step 3 — Step the simulation

function step(dt) {
  for (let i = 0; i < MAX; i++) {
    if (lives[i] <= 0) continue;

    // Gravity
    velocities[i*3+1] -= 1.5 * dt;
    // Air drag
    velocities[i*3+0] *= 0.985;
    velocities[i*3+1] *= 0.985;
    velocities[i*3+2] *= 0.985;

    // Move
    positions[i*3+0] += velocities[i*3+0] * dt;
    positions[i*3+1] += velocities[i*3+1] * dt;
    positions[i*3+2] += velocities[i*3+2] * dt;

    // Fade — colors dim with life
    lives[i] -= dt * 0.5;     // 2-second lifetime
    const fade = Math.max(lives[i], 0);
    // (we'll multiply color by fade in the shader, or here for simplicity)
  }
  geometry.attributes.position.needsUpdate = true;
}

Three forces: gravity (downward), air drag (multiplicative slowdown), velocity advection (the move step). The order matters in a stiff sim, but for fireworks at 60fps any order is fine — they don't interact with each other.

Step 4 — Fade via shader (cleaner than CPU)

Instead of updating the color buffer every frame to fade, do it in a custom ShaderMaterial using a per-particle life attribute:

geometry.setAttribute('life', new THREE.BufferAttribute(lives, 1).setUsage(THREE.DynamicDrawUsage));

const material = new THREE.ShaderMaterial({
  uniforms: { uSize: { value: 8 } },
  vertexShader: /*glsl*/`
    attribute float life;
    varying float vLife;
    varying vec3 vColor;
    uniform float uSize;
    void main() {
      vLife = life;
      vColor = color;
      vec4 mv = modelViewMatrix * vec4(position, 1.0);
      gl_PointSize = uSize * life;
      gl_Position = projectionMatrix * mv;
    }
  `,
  fragmentShader: /*glsl*/`
    varying float vLife;
    varying vec3 vColor;
    void main() {
      vec2 uv = gl_PointCoord - 0.5;
      float d = length(uv) * 2.0;
      if (d > 1.0) discard;
      float alpha = (1.0 - d) * vLife;
      gl_FragColor = vec4(vColor, alpha);
    }
  `,
  vertexColors: true,
  blending: THREE.AdditiveBlending,
  depthWrite: false,
  transparent: true,
});

The shader does three things the CPU otherwise would: makes each particle a soft round dot (the discard outside the unit circle), shrinks dying particles, and fades alpha proportional to life. Cleaner code, faster runtime.

Step 5 — Click to spawn

renderer.domElement.addEventListener('click', (e) => {
  const rect = renderer.domElement.getBoundingClientRect();
  const x = ((e.clientX - rect.left) / rect.width  - 0.5) *  10;
  const y = ((e.clientY - rect.top)  / rect.height - 0.5) * -6;
  spawnFirework(x, y);
});

Map pixel coords to world coords. Hardcoded scale (10, -6) for our viewport; the Y axis flips because screen Y grows downward, world Y grows upward.

Common first-time pitfalls

"Particles spawn but never move." Forgot geometry.attributes.position.needsUpdate = true after writing the array. Three.js doesn't auto-detect typed array mutations.
"Bursts only spawn in one corner." Coordinate mapping is off — you're using e.clientX directly instead of subtracting rect.left. The canvas might not be at the page top-left.
"All bursts are the same color." You're constructing burstColor outside the click handler. Move it inside so each call gets a fresh random hue.
"FPS tanks after many bursts." Pool is too small — you're iterating through 5000 slots looking for dead ones. Either increase the pool to 20k, or add a freelist of dead indices.

Exercises

  1. Two-stage fireworks. A "rocket" particle launches up with no horizontal spread, then explodes (spawning 200 child particles) when its velocity hits zero. Realistic firework lifecycle.
  2. Heart shape. Replace the spherical random direction with a heart-shaped parametric distribution. Cosine/sine math — heart, smiley, peace sign. Bonus on Valentine's.
  3. Auto-spawn from rhythm. Hook WebAudio volume analysis up; spawn bursts when bass kicks. See S9-03.