#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 でスプライト枯渇を防いだ

次回は爆発エフェクトを本格的に実装します。閃光・衝撃波・破片・煙を組み合わせた、瞬間的なインパクトの演出を作ります。