60fps Particles with Canvas API — Smooth Animation Design for 200+ Particles
Go deeper on this topic
Creative Coding
Mathematical simulations, nature visualization, and generative art in the browser
Article 12 of 12 in this series.
Next reads
The World of Cellular Automata — From Game of Life to Wave Function Collapse
Design patterns for rule-based cellular automata and grid optimization with Uint8Array, explored through implementations of Game of Life, WaveFunctionCollapse, and SandFall.
Fluid Simulation in the Browser — Real-Time Computation on a 100x60 Grid
Running a real-time fluid simulation by simplifying the Navier-Stokes equations on a 100x60 grid. Implementation details of diffusion, advection, and pressure solver.
Drawing Fractal Mathematics in the Browser — From Mandelbrot Set to Barnsley Fern
Rendering fractals in the browser using complex number iteration, escape time algorithm, IFS (iterated function systems), and recursive drawing.
Use the topic hub as a map, then continue to the next article in the series. New here? Start at the home hub →
Particle System Design Philosophy
I designed a particle system shared across three components: ParticleField, Fireworks, and GravityPainter. The goal was to render 200+ particles at a stable 60fps using Canvas API.
The DPI Trap
Neglecting Retina support makes particles look blurry. Apply the device pixel ratio in canvas.ts's resizeCanvas:
function resizeCanvas(canvas: HTMLCanvasElement, width: number, height: number) {
const ratio = window.devicePixelRatio || 1
canvas.width = width * ratio
canvas.height = height * ratio
canvas.style.width = `\${width}px`
canvas.style.height = `\${height}px`
}Simply separating logical pixels from physical pixels makes rendering sharp. However, forgetting ctx.scale(ratio, ratio) will shift the coordinate system, so be careful.
Stabilizing Physics with a Fixed Timestep
The interval between requestAnimationFrame callbacks depends on the monitor's refresh rate. 60Hz is about 16ms, 144Hz about 7ms. Using this directly as dt makes particles move slower on high refresh rate monitors.
const FIXED_DT = 1 / 60
let accumulator = 0
let lastTime = 0
function loop(timestamp: number) {
const frameDt = Math.min((timestamp - lastTime) / 1000, 0.05)
lastTime = timestamp
accumulator += frameDt
while (accumulator >= FIXED_DT) {
for (const p of particles) {
p.vy += gravity * FIXED_DT
p.x += p.vx * FIXED_DT
p.y += p.vy * FIXED_DT
p.life -= FIXED_DT
}
accumulator -= FIXED_DT
}
render(particles)
requestAnimationFrame(loop)
}The Math.min clamping dt to 0.05 seconds prevents the "warp" when a tab returns from the background.
Reducing Collision Detection Toward O(n) with Spatial Partitioning
Checking all pairs of 200 particles is O(n²) = 40,000 comparisons. We reduce this with grid-based spatial partitioning:
function buildSpatialGrid(particles: Particle[], cellSize: number) {
const grid = new Map<string, Particle[]>()
for (const p of particles) {
const key = `\${Math.floor(p.x / cellSize)},\${Math.floor(p.y / cellSize)}`
let bucket = grid.get(key)
if (!bucket) {
bucket = []
grid.set(key, bucket)
}
bucket.push(p)
}
return grid
}Only checking the 8 neighboring cells plus the current cell drastically reduces actual comparisons. The effect is most pronounced in dense scenes like Fireworks.
Particle Lifecycle Management
New particles are generated every frame and expired ones are removed. Calling array filter every frame triggers GC:
// Bad: creates new array every frame
particles = particles.filter(p => p.life > 0)
// Good: compact in-place
let writeIdx = 0
for (let i = 0; i < particles.length; i++) {
if (particles[i].life > 0) {
particles[writeIdx++] = particles[i]
}
}
particles.length = writeIdxReducing GC pressure is the lifeline for maintaining 60fps.
Canvas Rendering Optimization
Calling beginPath / fill for each drawCircle is inefficient. Batch-render same-colored particles:
function renderBatch(ctx: CanvasRenderingContext2D, particles: Particle[]) {
const groups = new Map<string, Particle[]>()
for (const p of particles) {
let group = groups.get(p.color)
if (!group) {
group = []
groups.set(p.color, group)
}
group.push(p)
}
for (const [color, group] of groups) {
ctx.fillStyle = color
ctx.beginPath()
for (const p of group) {
ctx.moveTo(p.x + p.radius, p.y)
ctx.arc(p.x, p.y, Math.max(0, p.radius), 0, Math.PI * 2)
}
ctx.fill()
}
}The number of beginPath calls drops from "particle count" to "color count." Reducing draw calls eliminates GPU-side bottlenecks.
Why Canvas Over DOM?
In scenarios with 100+ particles, DOM element creation, destruction, and reflow calculations become the bottleneck. Canvas is immediate mode, so redrawing everything each frame is still fast. On the other hand, elements containing text (like GravityBadges) benefit from DOM for accessibility.
Decision criteria: If there are many elements and text selection isn't needed, use Canvas. If you want accessibility with fewer elements, use DOM + CSS Transform.
Summary: Tools for Canvas Particle Systems
Particle system design is a foundation of game programming. The books on the tool shelf cover more advanced techniques like object pools and GPU particles.