← Blog
Canvas APIPerformanceAnimationTypeScript

60fps Particles with Canvas API — Smooth Animation Design for 200+ Particles

8 min read

Go deeper on this topic

Creative Coding

Mathematical simulations, nature visualization, and generative art in the browser

Article 12 of 12 in this series.

View topic page

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 = writeIdx

Reducing 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.