#05 ビジュアルコーディング パーティクル
分裂するパーティクル——子パーティクルを生む
パーティクルが寿命を迎えると同時に、複数の子パーティクルを生み出す——これが「分裂(split)」です。1粒が2粒に、2粒が4粒にと連鎖することで、花火・爆発・細胞分裂などのエフェクトが実現できます。
分裂の基本的な実装
分裂の仕組みはシンプルです。「寿命が特定の値になったとき、その位置から子パーティクルを放出する」というだけです。
particles.forEach(function(p) {
// 寿命が splitAt に達したら分裂
if (p.life === p.splitAt) {
spawnChildren(p);
}
p.x += p.vx;
p.y += p.vy;
p.life--;
});
子パーティクルを放出する関数では、n方向に均等に散らばる速度を与えます。
function spawnChildren(parent, n) {
for (var i = 0; i < n; i++) {
var angle = (Math.PI * 2 / n) * i; // 均等角度
var speed = Math.random() * 2 + 1;
newParticles.push({
x: parent.x,
y: parent.y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 40,
maxLife: 40,
splitAt: -1 // 子は分裂しない
});
}
}
Math.PI * 2 / n * i で n 等分した角度を計算し、全方向に均等に放射します。
1段階分裂パーティクル
1段階分裂パーティクル——寿命の途中で子が生まれる
var particles = [];
var newBorn = [];
function spawnChild(parent, n) {
for (var i = 0; i < n; i++) {
var angle = (Math.PI * 2 / n) * i + Math.random() * 0.3;
var speed = Math.random() * 2.5 + 1;
newBorn.push({
x: parent.x, y: parent.y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 50, maxLife: 50,
splitAt: -1,
hue: parent.hue + 30
});
}
}
function emit() {
for (var i = 0; i < 2; i++) {
var angle = -Math.PI / 2 + (Math.random() - 0.5) * 0.8;
var speed = Math.random() * 4 + 3;
particles.push({
x: W / 2 + (Math.random() - 0.5) * 60,
y: H - 10,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 50, maxLife: 50,
splitAt: 25,
hue: Math.random() * 60 + 20
});
}
}
function loop() {
ctx.fillStyle = 'rgba(13,17,23,0.2)';
ctx.fillRect(0, 0, W, H);
newBorn = [];
emit();
particles.forEach(function(p) {
p.vy += 0.1;
p.x += p.vx; p.y += p.vy;
p.life--;
if (p.splitAt > 0 && p.life === p.splitAt) {
spawnChild(p, 8);
}
var ratio = p.life / p.maxLife;
ctx.globalAlpha = ratio;
ctx.fillStyle = 'hsl(' + p.hue + ', 90%, 65%)';
ctx.beginPath();
ctx.arc(p.x, p.y, p.splitAt > 0 ? 4 : 2.5 * ratio, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
});
particles = particles.filter(function(p) { return p.life > 0; });
particles = particles.concat(newBorn);
requestAnimationFrame(loop);
}
loop(); 親パーティクルは少し大きく(4px)、子パーティクルは寿命比率で縮小していきます。
再帰的分裂——孫パーティクルまで
子パーティクル自身にも splitAt を設定することで、2段階・3段階の連鎖分裂が作れます。
function spawnChildren(parent, n, childLife, childSplitAt) {
for (var i = 0; i < n; i++) {
var angle = (Math.PI * 2 / n) * i;
newBorn.push({
...
life: childLife,
maxLife: childLife,
splitAt: childSplitAt, // -1 なら子は分裂しない
generation: parent.generation + 1
});
}
}
世代数(generation)を記録しておくと、世代ごとに色やサイズを変えるのに便利です。
2段階分裂——子が孫パーティクルを生む
var particles = [];
var newBorn = [];
function spawnChildren(parent, n, childLife, childSplit, gen) {
for (var i = 0; i < n; i++) {
var angle = (Math.PI * 2 / n) * i + (Math.random() - 0.5) * 0.5;
var speed = (Math.random() * 2 + 1) * (gen === 1 ? 1 : 0.6);
newBorn.push({
x: parent.x, y: parent.y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: childLife, maxLife: childLife,
splitAt: childSplit,
generation: gen,
hue: parent.hue + gen * 40
});
}
}
function emit() {
if (Math.random() < 0.05) {
particles.push({
x: Math.random() * W * 0.6 + W * 0.2,
y: H,
vx: (Math.random() - 0.5) * 2,
vy: -(Math.random() * 5 + 5),
life: 55, maxLife: 55,
splitAt: 28,
generation: 0,
hue: Math.random() * 360
});
}
}
function loop() {
ctx.fillStyle = 'rgba(13,17,23,0.15)';
ctx.fillRect(0, 0, W, H);
newBorn = [];
emit();
particles.forEach(function(p) {
p.vy += 0.12;
p.x += p.vx; p.y += p.vy;
p.life--;
if (p.splitAt > 0 && p.life === p.splitAt) {
var childN = p.generation === 0 ? 7 : 6;
var childLife = p.generation === 0 ? 45 : 30;
var nextSplit = p.generation === 0 ? 20 : -1;
spawnChildren(p, childN, childLife, nextSplit, p.generation + 1);
}
var ratio = p.life / p.maxLife;
var sz = [4, 3, 1.5][Math.min(p.generation, 2)];
ctx.globalAlpha = ratio;
ctx.fillStyle = 'hsl(' + p.hue + ', 90%, ' + (55 + ratio * 20) + '%)';
ctx.beginPath();
ctx.arc(p.x, p.y, sz * ratio + 0.5, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
});
particles = particles.filter(function(p) { return p.life > 0; });
particles = particles.concat(newBorn);
requestAnimationFrame(loop);
}
loop(); 花火の実装
花火は「打ち上げ → 頂点で爆発 → 多数の子パーティクル」という2段階分裂の応用です。
- 打ち上げ弾:強い上向き速度、重力あり、色は薄い白
- 爆発時:
life === splitAtで多数の子を放射 - 子パーティクル:ランダム全方向・重力・空気抵抗で落下
花火シミュレーション
var particles = [];
var newBorn = [];
function explode(x, y, hue) {
var n = 60 + Math.floor(Math.random() * 30);
for (var i = 0; i < n; i++) {
var angle = (Math.PI * 2 / n) * i + (Math.random() - 0.5) * 0.2;
var speed = Math.random() * 4 + 2;
newBorn.push({
x: x, y: y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 80 + Math.floor(Math.random() * 40),
maxLife: 120,
hue: hue + (Math.random() - 0.5) * 30,
isChild: true
});
}
}
function launch() {
var tx = W * 0.2 + Math.random() * W * 0.6;
var ty = H * 0.1 + Math.random() * H * 0.35;
var speed = Math.sqrt(2 * 0.15 * (H - ty));
particles.push({
x: tx + (Math.random() - 0.5) * 20,
y: H,
vx: (Math.random() - 0.5) * 0.5,
vy: -speed,
life: 1, maxLife: 1,
targetY: ty,
hue: Math.random() * 360,
isChild: false,
exploded: false
});
}
function loop() {
ctx.fillStyle = 'rgba(13,17,23,0.18)';
ctx.fillRect(0, 0, W, H);
newBorn = [];
if (Math.random() < 0.025) launch();
particles.forEach(function(p) {
if (!p.isChild) {
p.vy += 0.15;
p.x += p.vx; p.y += p.vy;
if (!p.exploded && p.vy >= 0) {
explode(p.x, p.y, p.hue);
p.exploded = true;
p.life = 0;
}
ctx.fillStyle = 'white';
ctx.beginPath();
ctx.arc(p.x, p.y, 2, 0, Math.PI * 2);
ctx.fill();
} else {
p.vy += 0.07;
p.vx *= 0.985; p.vy *= 0.985;
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%, ' + (50 + ratio * 30) + '%)';
ctx.beginPath();
ctx.arc(p.x, p.y, 2.5 * ratio + 0.5, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
});
particles = particles.filter(function(p) { return p.life > 0; });
particles = particles.concat(newBorn);
requestAnimationFrame(loop);
}
loop(); パーティクル数の上限管理
分裂を繰り返すとパーティクル数が急増します。特に多段階分裂では指数的に増えるため、上限を設けることが重要です。
var MAX_PARTICLES = 1000;
function spawnChildren(parent, n) {
// 上限を超えたら生成しない
if (particles.length + n > MAX_PARTICLES) return;
// ...
}
または newBorn を追加する前に上限チェックを行う方法もあります。
// 既存を削除しながら新規を追加(リングバッファ的な考え方)
if (particles.length > MAX_PARTICLES) {
particles.splice(0, particles.length - MAX_PARTICLES);
}
まとめ
この回でやったこと:
life === splitAtで分裂タイミングを制御した- 子パーティクルを均等角度
(2π/n) * iで全方向に放出した generationプロパティで世代を管理し、色・サイズを変えた- 花火エフェクトを「打ち上げ → 速度ゼロ通過 → 爆発」で実装した
MAX_PARTICLESでスプライト枯渇を防いだ
次回は爆発エフェクトを本格的に実装します。閃光・衝撃波・破片・煙を組み合わせた、瞬間的なインパクトの演出を作ります。