はじめてのパーティクル——点が生まれ、動き、消える
点が生まれ、動き、そして消えていく——これだけで「世界」になります。パーティクルシステムはゲームエフェクト・映像演出・データビジュアライゼーションまで幅広く使われる、ビジュアルコーディングの核心技術です。
このシリーズでは、1粒の点から始めて、炎・爆発・煙・インタラクティブなエフェクトまでを段階的に実装していきます。第1回はパーティクルの「生成・移動・削除」という3つの基本操作をゼロから作ります。
パーティクルとは何か
パーティクル(particle)は「粒子」を意味します。プログラム上では、位置・速度・寿命などのデータを持つ小さなオブジェクトです。1粒では何でもありませんが、数百〜数千の粒子を同時に動かすと、煙・水しぶき・光の粒など、連続した自然現象のように見えてきます。
パーティクルシステムは次の3つのサイクルで動きます。
- 生成(Spawn) — エミッターが新しいパーティクルを生み出す
- 更新(Update) — 毎フレーム、位置や寿命を書き換える
- 削除(Remove) — 寿命が尽きたパーティクルを配列から取り除く
パーティクルオブジェクトの構造
最小限のパーティクルは次の6つのプロパティで表現できます。
{
x: 300, // 現在のX座標
y: 200, // 現在のY座標
vx: 1.5, // X方向の速度(velocity x)
vy: -2.0, // Y方向の速度(velocity y)
life: 60, // 残り寿命(フレーム数)
maxLife: 60 // 最大寿命(寿命比率の計算に使う)
}
vx と vy は「1フレームあたりに移動するピクセル数」です。vy がマイナスだと上に移動します(Canvas の Y 軸は下向きなので)。
life は毎フレームひとつずつ減らし、0 になったら削除します。maxLife は後述する透明度やサイズの計算で使います。
エミッターを作る
エミッター(emitter)はパーティクルを生み出す「放出源」です。指定した座標から、ランダムな方向に向けてパーティクルを生成します。
var particles = [];
function emitter(x, y) {
var angle = Math.random() * Math.PI * 2; // 0〜360度のランダムな方向
var speed = Math.random() * 3 + 1; // 1〜4のランダムな速さ
particles.push({
x: x,
y: y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 60,
maxLife: 60
});
}
Math.cos(angle) と Math.sin(angle) で、任意の角度を X / Y 方向の速度成分に分解します。これで全方向にまんべんなく散らばるパーティクルが作れます。
更新と削除
毎フレームの処理は「更新してから描画、そして削除」の順番で行います。
// 更新
particles.forEach(function(p) {
p.x += p.vx;
p.y += p.vy;
p.life--;
});
// 削除(life が 0 以下のものを除く)
particles = particles.filter(function(p) {
return p.life > 0;
});
Array.filter は条件を満たす要素だけを残した新しい配列を返します。life <= 0 のパーティクルは自動的に取り除かれます。
最初のデモ——画面中央から飛び散るパーティクル
毎フレーム5粒ずつ中央から放出し、残像なしのシンプルなデモです。
var particles = [];
function emitter(x, y) {
for (var i = 0; i < 5; i++) {
var angle = Math.random() * Math.PI * 2;
var speed = Math.random() * 3 + 1;
particles.push({
x: x, y: y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 80, maxLife: 80
});
}
}
function loop() {
ctx.fillStyle = 'rgba(13,17,23,0.3)';
ctx.fillRect(0, 0, W, H);
emitter(W / 2, H / 2);
particles.forEach(function(p) {
p.x += p.vx;
p.y += p.vy;
p.life--;
ctx.fillStyle = 'white';
ctx.beginPath();
ctx.arc(p.x, p.y, 2, 0, Math.PI * 2);
ctx.fill();
});
particles = particles.filter(function(p) { return p.life > 0; });
requestAnimationFrame(loop);
}
loop(); 背景を完全に消さずに rgba(13,17,23,0.3) で薄く上書きすることで、わずかな残像がついて軌跡が見えます。この技法は後の回でくわしく解説します。
エミッターをクリックで移動させる
パーティクルの放出源を固定ではなくクリック位置に変えるだけで、インタラクティブになります。canvas 要素のクリックイベントを取得して、エミッター座標を更新します。
canvas.addEventListener('click', function(e) {
var rect = canvas.getBoundingClientRect();
emitterX = e.clientX - rect.left;
emitterY = e.clientY - rect.top;
});
getBoundingClientRect() でキャンバスのページ上の位置を取得し、クリック座標からオフセットを引くことで、キャンバス内の正確な座標に変換します。
マウスを追いかけるパーティクルの噴射
mousemove イベントを使えば、マウスの動きに連続してパーティクルを噴射できます。
var particles = [];
function loop() {
ctx.fillStyle = 'rgba(13,17,23,0.25)';
ctx.fillRect(0, 0, W, H);
for (var i = 0; i < 4; i++) {
var angle = Math.random() * Math.PI * 2;
var speed = Math.random() * 2.5 + 0.5;
particles.push({
x: mx, y: my,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 60, maxLife: 60
});
}
particles.forEach(function(p) {
p.x += p.vx;
p.y += p.vy;
p.life--;
var ratio = p.life / p.maxLife;
ctx.globalAlpha = ratio;
ctx.fillStyle = 'white';
ctx.beginPath();
ctx.arc(p.x, p.y, 2.5, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
});
particles = particles.filter(function(p) { return p.life > 0; });
requestAnimationFrame(loop);
}
loop(); マウスをキャンバス上で動かすと、その軌跡に沿ってパーティクルが広がります。ctx.globalAlpha = ratio で寿命に応じて透明になり、自然なフェードアウトが実現しています。
フレームレートとパーティクル数の調整
パーティクルの数が増えすぎると処理が重くなります。実用上の目安は次のとおりです。
| パーティクル数 | 負荷の目安 |
|---|---|
| 〜500粒 | 軽い。スマートフォンでも快適 |
| 500〜2000粒 | 中程度。デスクトップなら問題なし |
| 2000粒〜 | 重い。描画の最適化が必要になることも |
負荷を下げるには以下の方法が効果的です。
maxLifeを短くして1粒の滞在時間を減らす- 毎フレームの生成数を減らす(5粒→3粒など)
arcの代わりにfillRect(p.x, p.y, 2, 2)で矩形にする(arcより軽い)
パーティクル数の上限をセットするのも安全策です。
// 上限を超えたらエミットしない
if (particles.length < 500) {
emitter(W / 2, H / 2);
}
まとめ
この回でやったこと:
- パーティクルを
{x, y, vx, vy, life, maxLife}のオブジェクトで表現した emitter関数でランダム方向に向けた粒子を生成した- 毎フレーム
x += vx/y += vy/life--で状態を更新した Array.filterでlife <= 0のパーティクルを削除したmousemoveイベントでエミッターをインタラクティブに動かした
次回は、パーティクルに透明度の変化とサイズ変化を加えます。寿命に応じてフェードアウトしながら縮んでいくパーティクルは、ぐっと「生き物らしく」なります。