#09 ビジュアルコーディング パーティクル
炎エフェクト——揺らめく火を表現する
炎はビジュアルコーディングの定番テーマです。第2回で少し触れましたが、今回は本格的に炎エフェクトを実装します。パーティクルアプローチと、全く異なるピクセルシミュレーションアプローチの2種類を解説します。
炎の視覚的特徴を分析する
炎らしい表現を実装するには、まず実際の炎の特徴を言語化することが重要です。
| 特徴 | 実装方法 |
|---|---|
| 下から上に向かう | vy を上向き(マイナス)に設定 |
| 揺らぎがある | 毎フレーム vx にランダムな小さな値を加算 |
| 根元が明るく先端が暗い | 根元では速度が速く生まれ、短命で高いところで消える |
| 色が変化する | 生まれたて(根元)は白黄色、消える直前(先端)は暗い赤 |
| 広がり方 | 根元は細く、上に行くほど広がる |
炎パーティクルの色変化
炎の色は下から上に向かって「白→黄→オレンジ→赤→暗い赤」と変化します。HSLで表現すると:
- 色相:
hsl(60, ...)= 黄色(根元)→hsl(0, ...)= 赤(先端) - 明度: 根元は明るく(100%)→ 先端は暗く(30%)
ratio = life / maxLife を使った計算式:
// ratio=1(生まれたて・根元): hsl(60, 100%, 100%) = 白黄色
// ratio=0(消える直前・先端): hsl(0, 100%, 30%) = 暗い赤
var hue = ratio * 60; // 0→60
var lightness = 30 + ratio * 70; // 30→100
var saturation = 100;
ctx.fillStyle = 'hsl(' + hue + ',' + saturation + '%,' + lightness + '%)';
炎パーティクルのデモ
炎パーティクルのデモ——色が変化しながら上昇する
var particles = [];
function loop() {
ctx.fillStyle = 'rgba(13,17,23,0.25)';
ctx.fillRect(0, 0, W, H);
for (var i = 0; i < 6; i++) {
var angle = -Math.PI / 2 + (Math.random() - 0.5) * 0.8;
var speed = Math.random() * 3 + 2;
particles.push({
x: W / 2 + (Math.random() - 0.5) * 50,
y: H - 10,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 50 + Math.floor(Math.random() * 30),
maxLife: 80
});
}
ctx.globalCompositeOperation = 'lighter';
particles.forEach(function(p) {
p.x += p.vx;
p.y += p.vy;
p.life--;
var ratio = p.life / p.maxLife;
var hue = ratio * 60;
var lightness = 30 + ratio * 70;
var radius = (3 + ratio * 3) * ratio + 0.5;
ctx.globalAlpha = ratio * 0.8;
ctx.fillStyle = 'hsl(' + hue + ',100%,' + lightness + '%)';
ctx.beginPath();
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
});
ctx.globalCompositeOperation = 'source-over';
particles = particles.filter(function(p) { return p.life > 0; });
requestAnimationFrame(loop);
}
loop(); 揺らぎを加えた炎
炎の最も重要な特徴は「揺らぎ」です。毎フレーム vx にランダムな微小な力を加えることで、不規則な揺れが生まれます。
p.vx += (Math.random() - 0.5) * 0.5;
この一行を更新ループに追加するだけで、炎が自然に揺らめきます。さらに上昇するにつれて広がる効果を付け加えます。
// 上に行くほど横方向の揺れを強くする
var ageRatio = 1 - p.life / p.maxLife; // 0(生まれたて)→1(消える直前)
p.vx += (Math.random() - 0.5) * 0.4 * (1 + ageRatio * 2);
揺らぐ炎——ベースから放射・揺らぎあり
var particles = [];
function loop() {
ctx.fillStyle = 'rgba(13,17,23,0.22)';
ctx.fillRect(0, 0, W, H);
for (var i = 0; i < 8; i++) {
var spreadX = (Math.random() - 0.5) * 80;
var speed = Math.random() * 2.5 + 1.5;
particles.push({
x: W / 2 + spreadX,
y: H - 15,
vx: spreadX * 0.04 + (Math.random() - 0.5) * 0.5,
vy: -(speed),
life: 55 + Math.floor(Math.random() * 35),
maxLife: 90
});
}
ctx.globalCompositeOperation = 'lighter';
particles.forEach(function(p) {
var ageRatio = 1 - p.life / p.maxLife;
p.vx += (Math.random() - 0.5) * (0.3 + ageRatio * 0.8);
p.vx *= 0.97;
p.vy -= 0.03;
p.x += p.vx;
p.y += p.vy;
p.life--;
var ratio = p.life / p.maxLife;
var hue = ratio * 55;
var lightness = 25 + ratio * 65;
var radius = (5 + ratio * 3) * ratio;
ctx.globalAlpha = ratio * 0.75;
ctx.fillStyle = 'hsl(' + hue + ',100%,' + lightness + '%)';
ctx.beginPath();
ctx.arc(p.x, p.y, Math.max(radius, 0.5), 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
});
ctx.globalCompositeOperation = 'source-over';
particles = particles.filter(function(p) { return p.life > 0; });
requestAnimationFrame(loop);
}
loop(); ピクセル炎シミュレーション
パーティクルとは全く異なるアプローチ——ピクセルデータを直接操作する古典的な炎アルゴリズムです。
アルゴリズムの概要:
- 画面下端の行にランダムな高い値(熱量)を書き込む
- 毎フレーム、各ピクセルの値を「下の行の平均」に置き換えながら少しずつ減衰させる
- 値(熱量)を色にマッピングして描画する
このシンプルなルールだけで、現実の炎に近い動きが生まれます。
// 熱量を上方向に拡散・減衰
for (var y = 0; y < H - 1; y++) {
for (var x = 0; x < W; x++) {
var sum = heat[y + 1][x - 1 >= 0 ? x - 1 : 0]
+ heat[y + 1][x]
+ heat[y + 1][x + 1 < W ? x + 1 : W - 1]
+ heat[y + 1][x];
heat[y][x] = Math.max(sum / 4 - decay, 0);
}
}
色のマッピング:
- 熱量 0 → 黒(
rgb(0,0,0)) - 熱量 128 → 赤(
rgb(255,0,0)) - 熱量 192 → オレンジ(
rgb(255,128,0)) - 熱量 255 → 白(
rgb(255,255,255))
ピクセル炎シミュレーション——古典アルゴリズムによる炎
var scale = 4;
var fw = Math.floor(W / scale);
var fh = Math.floor(H / scale);
var heat = [];
for (var y = 0; y < fh; y++) {
heat[y] = new Float32Array(fw);
}
function heatToColor(v) {
var val = Math.min(v, 255);
var r, g, b;
if (val < 85) {
r = Math.floor(val * 3); g = 0; b = 0;
} else if (val < 170) {
r = 255; g = Math.floor((val - 85) * 3); b = 0;
} else {
r = 255; g = 255; b = Math.floor((val - 170) * 3);
}
return 'rgb(' + r + ',' + g + ',' + b + ')';
}
function loop() {
for (var x = 0; x < fw; x++) {
heat[fh - 1][x] = Math.random() < 0.6 ? Math.random() * 200 + 55 : 0;
}
for (var y = 0; y < fh - 1; y++) {
for (var x2 = 0; x2 < fw; x2++) {
var left = x2 > 0 ? heat[y + 1][x2 - 1] : heat[y + 1][x2];
var mid = heat[y + 1][x2];
var right = x2 < fw - 1 ? heat[y + 1][x2 + 1] : heat[y + 1][x2];
heat[y][x2] = Math.max((left + mid + right + mid) / 4 - 1.5, 0);
}
}
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, W, H);
for (var iy = 0; iy < fh; iy++) {
for (var ix = 0; ix < fw; ix++) {
var v = heat[iy][ix];
if (v > 5) {
ctx.fillStyle = heatToColor(v);
ctx.fillRect(ix * scale, iy * scale, scale, scale);
}
}
}
requestAnimationFrame(loop);
}
loop(); 2つのアプローチの比較
| 観点 | パーティクル炎 | ピクセル炎 |
|---|---|---|
| 実装の考え方 | オブジェクト指向的 | データ駆動的 |
| 制御のしやすさ | 形状・色を直接制御 | 物理的なルールで自然に生まれる |
| 処理負荷 | パーティクル数に比例 | 解像度の2乗に比例 |
| カスタマイズ | 1粒の挙動を変える | 拡散ルールを変える |
| 用途 | 炎の噴射・たいまつ | 床全体の炎・背景効果 |
実際のゲームや演出では、両者を組み合わせることもあります。ピクセル炎を背景に使いつつ、爆発点にはパーティクル炎を重ねるといった使い方です。
炎の根元(ベース)を追加する
炎のリアリティを高めるには、根元に「光り輝く核」を加えます。
// 炎の根元に輝く点を追加
ctx.globalCompositeOperation = 'lighter';
var baseGrad = ctx.createRadialGradient(cx, baseY, 0, cx, baseY, 40);
baseGrad.addColorStop(0, 'rgba(255, 200, 80, 0.8)');
baseGrad.addColorStop(1, 'rgba(255, 100, 0, 0)');
ctx.fillStyle = baseGrad;
ctx.fillRect(cx - 40, baseY - 40, 80, 80);
ctx.globalCompositeOperation = 'source-over';
まとめ
この回でやったこと:
- 炎を「上向き速度・揺らぎ・色変化(黄→赤)」の3要素で特徴づけた
hue = ratio * 60/lightness = 30 + ratio * 70で炎らしい色遷移を実装した- 毎フレーム
vx += random * factorで揺らぎを加えた globalCompositeOperation = 'lighter'で炎の輝きを表現した- 熱量配列を上方向に拡散・減衰させる古典ピクセル炎アルゴリズムを実装した
次回はシリーズの総まとめとして、インタラクティブパーティクルを作ります。クリック爆発・ドラッグ軌跡・ホバー反発をひとつのシステムに統合します。