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.
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.
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
geometry.attributes.position.needsUpdate = true after writing the array. Three.js doesn't auto-detect typed array mutations.e.clientX directly instead of subtracting rect.left. The canvas might not be at the page top-left.burstColor outside the click handler. Move it inside so each call gets a fresh random hue.Exercises
- 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.
- Heart shape. Replace the spherical random direction with a heart-shaped parametric distribution. Cosine/sine math — heart, smiley, peace sign. Bonus on Valentine's.
- Auto-spawn from rhythm. Hook
WebAudiovolume analysis up; spawn bursts when bass kicks. See S9-03.
UP NEXT
S0-08 — Coffee Smoke → A perlin-distorted plane that rises and dissipates. The cozy atmospheric demo.