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

煙エフェクト——柔らかく広がる雲を作る

これまでのパーティクルは「点」や「矩形」でした。煙は全く逆の特徴を持ちます。大きく・柔らかく・ゆっくり広がり、境界が曖昧なふわふわした表現です。

今回は煙のような柔らかいパーティクルを実装し、ラジアルグラデーション・ctx.filter・合成モードといった高度な描画技法を学びます。


煙パーティクルの特徴

炎パーティクルと煙パーティクルを比較すると:

特徴
サイズ小さく縮む大きく膨らむ
速度速い遅い
透明度明るく不透明半透明
動き上向き・力強い上向き・ゆらぎ
赤〜黄灰色〜白

特に重要なのは「生まれた後に半径が大きくなる」という点です。炎は縮みながら消えましたが、煙は膨らみながら消えます。

// 炎(縮む)
var radius = 5 * ratio;        // ratio = life / maxLife → 1→0

// 煙(膨らむ)
var radius = 5 + 30 * (1 - ratio);  // ratio→1(生まれたて)小さい、ratio→0(消える)大きい

シンプルな煙パーティクル

シンプルな煙パーティクル——灰色の膨らむ円
var particles = [];

function spawnSmoke(x, y) {
for (var i = 0; i < 2; i++) {
  particles.push({
    x: x + (Math.random() - 0.5) * 20,
    y: y,
    vx: (Math.random() - 0.5) * 0.8,
    vy: -(Math.random() * 1.5 + 0.5),
    life: 120 + Math.floor(Math.random() * 60),
    maxLife: 180,
    startRadius: Math.random() * 6 + 4,
    endRadius: Math.random() * 30 + 20,
    gray: Math.floor(Math.random() * 60 + 80)
  });
}
}

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

spawnSmoke(W / 2, H - 20);

particles.forEach(function(p) {
  p.x += p.vx + (Math.random() - 0.5) * 0.3;
  p.y += p.vy;
  p.life--;

  var ratio = p.life / p.maxLife;
  var radius = p.startRadius + (p.endRadius - p.startRadius) * (1 - ratio);
  var alpha = ratio * 0.35;

  ctx.globalAlpha = alpha;
  ctx.fillStyle = 'rgb(' + p.gray + ',' + p.gray + ',' + p.gray + ')';
  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.createRadialGradient を使って中心は不透明・外縁は透明のグラデーション円を描くと、エッジがぼけた本物らしい煙になります。

var gradient = ctx.createRadialGradient(
  p.x, p.y, 0,        // 内円の中心・半径
  p.x, p.y, radius    // 外円の中心・半径
);
gradient.addColorStop(0, 'rgba(120, 120, 120, 0.6)');  // 中心: 不透明
gradient.addColorStop(1, 'rgba(120, 120, 120, 0.0)');  // 外縁: 透明
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
ctx.fill();

createRadialGradient は毎フレーム・毎パーティクルで作成するので処理は重くなります。パーティクル数を絞る(30粒程度)か、グラデーションをキャッシュする最適化が必要です。

ラジアルグラデーション円で本物っぽい煙
var particles = [];

function spawnSmoke(x, y) {
if (particles.length < 40 && Math.random() < 0.4) {
  particles.push({
    x: x + (Math.random() - 0.5) * 30,
    y: y,
    vx: (Math.random() - 0.5) * 0.6,
    vy: -(Math.random() * 1.2 + 0.4),
    life: 150, maxLife: 150,
    startRadius: 8 + Math.random() * 6,
    endRadius: 45 + Math.random() * 25,
    gray: 100 + Math.floor(Math.random() * 60)
  });
}
}

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

spawnSmoke(W / 2, H * 0.65);

particles.forEach(function(p) {
  p.x += p.vx + (Math.random() - 0.5) * 0.4;
  p.y += p.vy;
  p.vx *= 0.995;
  p.life--;

  var ratio = p.life / p.maxLife;
  var radius = p.startRadius + (p.endRadius - p.startRadius) * (1 - ratio);
  var alpha = ratio * 0.45;

  var g = p.gray;
  var grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, radius);
  grad.addColorStop(0, 'rgba(' + g + ',' + g + ',' + g + ',' + alpha + ')');
  grad.addColorStop(0.5, 'rgba(' + g + ',' + g + ',' + g + ',' + (alpha * 0.5) + ')');
  grad.addColorStop(1, 'rgba(' + g + ',' + g + ',' + g + ',0)');
  ctx.fillStyle = grad;
  ctx.beginPath();
  ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
  ctx.fill();
});

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

合成モード(globalCompositeOperation)

ctx.globalCompositeOperation で「新しい描画内容を既存の描画とどう合成するか」を変えられます。

モード効果
source-overデフォルト。新しい描画が上に乗る
lighter加算合成。重なった部分が明るくなる(炎・発光に最適)
multiply乗算合成。重なった部分が暗くなる(影・墨煙に)
screenスクリーン合成。明るい部分が保持される

煙には source-over(デフォルト)が自然ですが、炎の粒子には lighter が効果的です。

ctx.globalCompositeOperation = 'lighter';
// ... 発光パーティクルを描画 ...
ctx.globalCompositeOperation = 'source-over';  // 必ず元に戻す

焚き火の煙シミュレーション

炎パーティクル(第9回で詳しく解説)と煙パーティクルを組み合わせた焚き火シミュレーションです。

焚き火の煙シミュレーション——炎と煙の組み合わせ
var flames = [], smokes = [];

function spawnFire(x, y) {
for (var i = 0; i < 3; i++) {
  var angle = -Math.PI / 2 + (Math.random() - 0.5) * 1.0;
  var speed = Math.random() * 3 + 1.5;
  flames.push({
    x: x + (Math.random() - 0.5) * 30,
    y: y,
    vx: Math.cos(angle) * speed,
    vy: Math.sin(angle) * speed,
    life: 40 + Math.floor(Math.random() * 20),
    maxLife: 60
  });
}
}

function spawnSmoke(x, y) {
if (smokes.length < 30 && Math.random() < 0.15) {
  smokes.push({
    x: x + (Math.random() - 0.5) * 20,
    y: y,
    vx: (Math.random() - 0.5) * 0.5,
    vy: -(Math.random() * 0.8 + 0.3),
    life: 160, maxLife: 160,
    startR: 6, endR: 50,
    gray: 90 + Math.floor(Math.random() * 50)
  });
}
}

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

var bx = W / 2, by = H - 20;
spawnFire(bx, by);
spawnSmoke(bx, by - 30);

ctx.globalCompositeOperation = 'lighter';
flames.forEach(function(p) {
  p.vx += (Math.random() - 0.5) * 0.4;
  p.vy -= 0.05;
  p.x += p.vx; p.y += p.vy;
  p.life--;
  var ratio = p.life / p.maxLife;
  var hue = 60 * ratio;
  ctx.globalAlpha = ratio * 0.8;
  ctx.fillStyle = 'hsl(' + hue + ', 100%, ' + (40 + ratio * 40) + '%)';
  ctx.beginPath();
  ctx.arc(p.x, p.y, (4 + ratio * 3) * ratio, 0, Math.PI * 2);
  ctx.fill();
  ctx.globalAlpha = 1;
});
ctx.globalCompositeOperation = 'source-over';

smokes.forEach(function(p) {
  p.x += p.vx + (Math.random() - 0.5) * 0.3;
  p.y += p.vy;
  p.life--;
  var ratio = p.life / p.maxLife;
  var radius = p.startR + (p.endR - p.startR) * (1 - ratio);
  var g = p.gray;
  var grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, radius);
  grad.addColorStop(0, 'rgba(' + g + ',' + g + ',' + g + ',' + (ratio * 0.3) + ')');
  grad.addColorStop(1, 'rgba(' + g + ',' + g + ',' + g + ',0)');
  ctx.fillStyle = grad;
  ctx.beginPath();
  ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
  ctx.fill();
});

ctx.fillStyle = 'rgba(100,60,20,0.8)';
ctx.fillRect(bx - 30, by, 60, 8);

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

炎部分は globalCompositeOperation = 'lighter'(加算合成)で輝かせています。


ctx.filter のぼかし——注意点

ctx.filter = 'blur(8px)' を使うと Canvas API レベルでぼかしが適用されます。

ctx.filter = 'blur(8px)';
ctx.arc(...);
ctx.fill();
ctx.filter = 'none';  // 必ず解除する

見た目は非常にソフトになりますが、処理が非常に重いという欠点があります。毎フレーム多数のパーティクルに適用するのは避け、1回の効果的な場面(爆発直後の光球など)に限定して使うのが実用的です。

代替として、createRadialGradient で擬似ぼかしを実現する方が軽量です。


まとめ

この回でやったこと:

  • 煙パーティクルは「半径が時間とともに大きくなる」ことで表現した
  • createRadialGradient で中心が濃く外縁が透明なグラデーション円を描いた
  • globalCompositeOperation = 'lighter' で炎部分を加算合成で輝かせた
  • ctx.filter = 'blur' は処理コストが高いため用途を絞ることが重要

次回は炎エフェクトを本格的に実装します。炎パーティクルの色変化と、古典的なピクセル炎シミュレーションの2種類を作ります。