透明度とサイズ——生まれて消えていく美しさ
第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にリセットする習慣を身につけた
次回は重力と風を加えます。物理的な力が加わると、パーティクルは途端に「自然界の現象」らしくなります。