Canvas APIで60fpsパーティクル — 200個でもヌルヌル動く設計
このトピックを深掘りする
クリエイティブコーディング
ブラウザでの数学的シミュレーション・自然現象の可視化・生成アートの実装技法
全12本中 12 本目。
次に読む記事
セルオートマトンの世界 — ライフゲームから波動関数崩壊まで
ライフゲーム、WaveFunctionCollapse、SandFallの実装を通じて、ルールベースのセルオートマトンの設計パターンとUint8Arrayによるグリッド最適化手法を解説する。
ブラウザで流体シミュレーション — 100×60グリッドで実現するリアルタイム演算
Navier-Stokes方程式を簡略化し、100×60のグリッドでリアルタイムに流体を動かす。拡散・移流・圧力ソルバーの実装詳細。
フラクタルの数学をブラウザで描く — マンデルブロ集合からバーンズリーのシダまで
複素数反復、エスケープタイムアルゴリズム、IFS(反復関数系)、再帰描画を使い、ブラウザでフラクタルを描画する実装解説。
シリーズ全体の流れを見ながら、次に読む記事へ進めます。 初めての方はホームへ →
パーティクルシステムの設計方針
ParticleField、Fireworks、GravityPainterの3コンポーネントに共通するパーティクルシステムを設計した。目標は200個以上のパーティクルをCanvas APIで60fpsに安定描画すること。
DPI対応の罠
Retina対応を怠るとパーティクルがぼやける。canvas.ts の 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`
}論理ピクセルと物理ピクセルを分離するだけで、描画がシャープになる。ただし 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.min で dt を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 = writeIdxGC負荷の削減は、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 パーティクル)まで扱っている。