#01 ビジュアルコーディング パーティクル

はじめてのパーティクル——点が生まれ、動き、消える

点が生まれ、動き、そして消えていく——これだけで「世界」になります。パーティクルシステムはゲームエフェクト・映像演出・データビジュアライゼーションまで幅広く使われる、ビジュアルコーディングの核心技術です。

このシリーズでは、1粒の点から始めて、炎・爆発・煙・インタラクティブなエフェクトまでを段階的に実装していきます。第1回はパーティクルの「生成・移動・削除」という3つの基本操作をゼロから作ります。


パーティクルとは何か

パーティクル(particle)は「粒子」を意味します。プログラム上では、位置・速度・寿命などのデータを持つ小さなオブジェクトです。1粒では何でもありませんが、数百〜数千の粒子を同時に動かすと、煙・水しぶき・光の粒など、連続した自然現象のように見えてきます。

パーティクルシステムは次の3つのサイクルで動きます。

  1. 生成(Spawn) — エミッターが新しいパーティクルを生み出す
  2. 更新(Update) — 毎フレーム、位置や寿命を書き換える
  3. 削除(Remove) — 寿命が尽きたパーティクルを配列から取り除く

パーティクルオブジェクトの構造

最小限のパーティクルは次の6つのプロパティで表現できます。

{
  x: 300,      // 現在のX座標
  y: 200,      // 現在のY座標
  vx: 1.5,     // X方向の速度(velocity x)
  vy: -2.0,    // Y方向の速度(velocity y)
  life: 60,    // 残り寿命(フレーム数)
  maxLife: 60  // 最大寿命(寿命比率の計算に使う)
}

vxvy は「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 イベントを使えば、マウスの動きに連続してパーティクルを噴射できます。

マウス位置から噴射するパーティクル——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.filterlife <= 0 のパーティクルを削除した
  • mousemove イベントでエミッターをインタラクティブに動かした

次回は、パーティクルに透明度の変化サイズ変化を加えます。寿命に応じてフェードアウトしながら縮んでいくパーティクルは、ぐっと「生き物らしく」なります。