煙エフェクト——柔らかく広がる雲を作る
これまでのパーティクルは「点」や「矩形」でした。煙は全く逆の特徴を持ちます。大きく・柔らかく・ゆっくり広がり、境界が曖昧なふわふわした表現です。
今回は煙のような柔らかいパーティクルを実装し、ラジアルグラデーション・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種類を作ります。