← Blog
Canvas APIPerformanceAnimationTypeScript

Canvas APIで60fpsパーティクル — 200個でもヌルヌル動く設計

6 分で読める

このトピックを深掘りする

クリエイティブコーディング

ブラウザでの数学的シミュレーション・自然現象の可視化・生成アートの実装技法

全12本中 12 本目。

このトピックの記事一覧

シリーズ全体の流れを見ながら、次に読む記事へ進めます。 初めての方はホームへ →

パーティクルシステムの設計方針

ParticleField、Fireworks、GravityPainterの3コンポーネントに共通するパーティクルシステムを設計した。目標は200個以上のパーティクルをCanvas APIで60fpsに安定描画すること。

DPI対応の罠

Retina対応を怠るとパーティクルがぼやける。canvas.tsresizeCanvas でデバイスピクセル比を適用する:

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`
}

論理ピクセルと物理ピクセルを分離するだけで、描画がシャープになる。ただし ctx.scale(ratio, ratio) を忘れると座標系がずれるので注意。

固定タイムステップで物理を安定させる

requestAnimationFrameのコールバック間隔はモニタのリフレッシュレートに依存する。60Hzなら約16ms、144Hzなら約7ms。これを直接 dt として使うと、高リフレッシュレートのモニタでパーティクルが遅く動く。

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)
}

Math.mindt を0.05秒にクランプしているのは、タブがバックグラウンドから復帰したときの「ワープ」を防ぐため。

空間分割で衝突判定を O(n) に近づける

200個のパーティクルの全ペア判定は O(n²) = 40,000回。これをグリッドベースの空間分割で削減する:

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
}

近傍8セル + 自セルのみをチェックすれば、実質的な比較回数は大幅に減る。Fireworks のような密集シーンで効果が顕著。

パーティクルのライフサイクル管理

毎フレーム新規パーティクルを生成し、寿命が尽きたものを除去する。ここで配列の filter を毎フレーム呼ぶとGCが走る:

// 悪い例: 毎フレーム新しい配列を生成
particles = particles.filter(p => p.life > 0)

// 良い例: インプレースで詰める
let writeIdx = 0
for (let i = 0; i < particles.length; i++) {
  if (particles[i].life > 0) {
    particles[writeIdx++] = particles[i]
  }
}
particles.length = writeIdx

GC負荷の削減は、60fps維持の生命線。

Canvas描画の最適化

drawCircle を呼ぶたびに beginPath / fill するのは非効率。同色パーティクルをバッチ描画する:

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()
  }
}

beginPath の呼び出し回数が「パーティクル数」から「色数」に減る。描画コールの削減はGPU側のボトルネックを解消する。

なぜDOMではなくCanvasか

パーティクル100個超のシナリオでは、DOM要素の生成・破棄・リフロー計算がボトルネックになる。Canvasは即時モードなので、フレームごとに全て描き直しても高速。一方、テキストを含む要素(GravityBadgesなど)はDOMのほうがアクセシビリティで有利。

判断基準: 要素数が多く、テキスト選択不要ならCanvas。少数でアクセシブルにしたいならDOM + CSS Transform

まとめ:Canvasパーティクルシステムの道具たち

パーティクルシステムの設計はゲームプログラミングの基礎。道具棚に置いた書籍では、より高度なテクニック(オブジェクトプール、GPU パーティクル)まで扱っている。