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

透明度とサイズ——生まれて消えていく美しさ

第1回で作ったパーティクルは「白い点が動いて消える」だけでした。今回はそこに透明度の変化サイズ変化を加えます。寿命に応じてフェードアウトしながら縮んでいくパーティクルは、それだけで「生き物のような」印象を与えます。

さらに色の変化と残像エフェクトを組み合わせることで、炎のような表現も実現できます。


寿命比率(ratio)という考え方

透明度やサイズを寿命と連動させるために、寿命比率という値を使います。

var ratio = p.life / p.maxLife;
  • パーティクルが生まれた直後: ratio = 1.0
  • 消える直前: ratio = 0.0

この ratio は 1→0 に向かって線形に変化する数値です。これをかけ合わせるだけで、あらゆるプロパティを寿命に合わせて変化させられます。


透明度を寿命に連動させる

ctx.globalAlpha はこれ以降の描画すべてに適用される透明度です。0.0 で完全透明、1.0 で完全不透明です。

ctx.globalAlpha = ratio;  // 寿命が減るにつれ透明になる
ctx.beginPath();
ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1.0;    // 必ず元に戻す!

重要: globalAlpha は描画後に 1.0 に戻さないと、後続のすべての描画が透明になってしまいます。描画が終わったら必ずリセットしましょう。


サイズを寿命に連動させる

円のサイズ(半径)にも ratio をかけます。

var radius = 4 * ratio;  // 生まれた直後は4px、消える直前は0px
ctx.beginPath();
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
ctx.fill();

透明度とサイズを両方変化させると、徐々に小さくなりながら消えていく自然なアニメーションになります。

フェードアウトしながら縮む白いパーティクル
var particles = [];

function loop() {
ctx.fillStyle = '#0d1117';
ctx.fillRect(0, 0, W, H);

if (particles.length < 300) {
  for (var i = 0; i < 5; i++) {
    var angle = Math.random() * Math.PI * 2;
    var speed = Math.random() * 3 + 1;
    particles.push({
      x: W / 2, y: H / 2,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed,
      life: 80, maxLife: 80
    });
  }
}

particles.forEach(function(p) {
  p.x += p.vx;
  p.y += p.vy;
  p.life--;

  var ratio = p.life / p.maxLife;
  var radius = 4 * ratio;

  ctx.globalAlpha = ratio;
  ctx.fillStyle = 'white';
  ctx.beginPath();
  ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
  ctx.fill();
  ctx.globalAlpha = 1;
});

particles = particles.filter(function(p) { return p.life > 0; });
requestAnimationFrame(loop);
}
loop();

色の変化——HSLで寿命と色を連動させる

HSL記法は「色相・彩度・明度」を数値で指定します。この数値に ratio を組み込むことで、寿命と連動して色が変化するパーティクルが作れます。

var hue = 0;           // 色相(赤)を固定
var lightness = 50 + ratio * 30;  // ratio=1 → 80%, ratio=0 → 50%
ctx.fillStyle = 'hsl(' + hue + ', 80%, ' + lightness + '%)';

炎を表現するには「黄→オレンジ→赤」と変化させます。色相では黄が60°、赤が0°なので、ratio を使って 60 * ratio と計算します。

var hue = 60 * ratio;   // ratio=1(生まれたて) → 黄色, ratio=0(消える直前) → 赤
var lightness = 40 + ratio * 40;  // 生まれたては明るく
ctx.fillStyle = 'hsl(' + hue + ', 100%, ' + lightness + '%)';
赤→黄色に変化する炎っぽいパーティクル
var particles = [];

function loop() {
ctx.fillStyle = '#0d1117';
ctx.fillRect(0, 0, W, H);

if (particles.length < 400) {
  for (var i = 0; i < 8; i++) {
    var angle = -Math.PI / 2 + (Math.random() - 0.5) * 1.2;
    var speed = Math.random() * 3 + 1.5;
    particles.push({
      x: W / 2 + (Math.random() - 0.5) * 40,
      y: H - 20,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed,
      life: 70, maxLife: 70
    });
  }
}

particles.forEach(function(p) {
  p.x += p.vx;
  p.y += p.vy;
  p.life--;

  var ratio = p.life / p.maxLife;
  var hue = 60 * ratio;
  var lightness = 40 + ratio * 40;
  var radius = 5 * ratio + 1;

  ctx.globalAlpha = ratio * 0.9;
  ctx.fillStyle = 'hsl(' + hue + ', 100%, ' + lightness + '%)';
  ctx.beginPath();
  ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
  ctx.fill();
  ctx.globalAlpha = 1;
});

particles = particles.filter(function(p) { return p.life > 0; });
requestAnimationFrame(loop);
}
loop();

下から放射して色相を変化させるだけで、炎らしいビジュアルになります。


残像エフェクト——背景を薄く上書きするテクニック

パーティクルの軌跡を残す定番テクニックが残像エフェクトです。背景を完全に塗りつぶす代わりに、半透明で上書きします。

// 完全消去(残像なし)
ctx.fillStyle = '#0d1117';
ctx.fillRect(0, 0, W, H);

// 残像あり(過去の描画がゆっくり消える)
ctx.fillStyle = 'rgba(13, 17, 23, 0.1)';
ctx.fillRect(0, 0, W, H);

rgba の4番目の値(アルファ)が小さいほど、残像が長く残ります。0.1 なら約10フレーム分が薄く残ります。


残像を使った軌跡パーティクル

残像エフェクトで軌跡が見える——ゆっくり消える背景
var particles = [];
var t = 0;

function loop() {
ctx.fillStyle = 'rgba(13,17,23,0.12)';
ctx.fillRect(0, 0, W, H);

t += 0.04;
var cx = W / 2 + Math.cos(t) * 100;
var cy = H / 2 + Math.sin(t * 1.3) * 60;

for (var i = 0; i < 4; i++) {
  var angle = Math.random() * Math.PI * 2;
  var speed = Math.random() * 2 + 0.5;
  particles.push({
    x: cx, y: cy,
    vx: Math.cos(angle) * speed,
    vy: Math.sin(angle) * speed,
    life: 50, maxLife: 50,
    hue: (t * 60) % 360
  });
}

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 = 'hsl(' + p.hue + ', 90%, 70%)';
  ctx.beginPath();
  ctx.arc(p.x, p.y, 3 * ratio, 0, Math.PI * 2);
  ctx.fill();
  ctx.globalAlpha = 1;
});

particles = particles.filter(function(p) { return p.life > 0; });
requestAnimationFrame(loop);
}
loop();

エミッターが8の字を描くように動き、残像と組み合わせることで光の軌跡のような表現になります。


ctx.globalAlpha のリセットを忘れずに

globalAlpha はキャンバスの「状態」に相当します。パーティクルの描画後に 1.0 に戻さないと、背景の塗りつぶしまで透明になってしまい、期待通りの残像が出ません。

悪い例(リセット忘れ):

ctx.globalAlpha = ratio;
ctx.arc(...);
ctx.fill();
// ← ここでリセットしていないと、次の fillRect も透明になる

正しい例:

ctx.globalAlpha = ratio;
ctx.arc(...);
ctx.fill();
ctx.globalAlpha = 1.0;  // 必ずここでリセット

または ctx.save() / ctx.restore() でコンテキストの状態をスタックに保存・復元する方法もあります。

ctx.save();
ctx.globalAlpha = ratio;
ctx.arc(...);
ctx.fill();
ctx.restore();  // save() 時点の状態に戻る(globalAlpha も戻る)

まとめ

この回でやったこと:

  • ratio = life / maxLife で 1→0 の寿命比率を計算した
  • ctx.globalAlpha = ratio で寿命に応じたフェードアウトを実装した
  • radius = 4 * ratio で縮小するサイズ変化を実装した
  • hsl(60 * ratio, 100%, ...) で赤→黄の色変化を実装した
  • 背景を rgba(13,17,23,0.1) で上書きする残像エフェクトを使った
  • globalAlpha は描画後に 1.0 にリセットする習慣を身につけた

次回は重力と風を加えます。物理的な力が加わると、パーティクルは途端に「自然界の現象」らしくなります。