#03 ビジュアルコーディング パーティクル

重力と風——物理が加わると世界になる

前回まで、パーティクルは等速直線運動をするだけでした。ここに重力・風力・空気抵抗という「力」を加えると、まったく違う世界が生まれます。重力だけで噴水になり、バウンスを加えれば地面が生まれます。

物理シミュレーションは難しそうに聞こえますが、パーティクルの場合はとてもシンプルです。速度に毎フレーム少しだけ加算するだけで表現できます。


物理の基本——加速度と速度の関係

物理の世界では「力は速度の変化(加速度)を引き起こす」という関係があります。これをコードに落とし込むと:

加速度 → 速度を変化させる
速度   → 位置を変化させる
// 毎フレームの更新
p.vy += gravity;   // 重力によって、下向きの速度が増える(加速)
p.y  += p.vy;      // 速度によって位置が変化する

gravity は小さな定数(0.10.3 程度)で、毎フレームわずかに vy を増やし続けます。最初は上向きだった vy がだんだん下向きになり、放物線を描きます。


重力の追加——噴水を作る

上向きに強い初速を与えてから重力を加えると、放物線を描く噴水になります。

// 生成時: 上向きの速度を与える
vy: -(Math.random() * 5 + 3)   // マイナスで上向き
// 毎フレームの更新
p.vy += 0.15;  // 重力(下向きに加速)
p.x  += p.vx;
p.y  += p.vy;
重力で落ちていくパーティクル噴水
var particles = [];

function loop() {
ctx.fillStyle = 'rgba(13,17,23,0.2)';
ctx.fillRect(0, 0, W, H);

for (var i = 0; i < 4; i++) {
  var spread = (Math.random() - 0.5) * 2;
  particles.push({
    x: W / 2,
    y: H - 10,
    vx: spread,
    vy: -(Math.random() * 6 + 3),
    life: 120, maxLife: 120
  });
}

particles.forEach(function(p) {
  p.vy += 0.15;
  p.x += p.vx;
  p.y += p.vy;
  p.life--;

  var ratio = p.life / p.maxLife;
  ctx.globalAlpha = ratio;
  ctx.fillStyle = 'hsl(200, 80%, 70%)';
  ctx.beginPath();
  ctx.arc(p.x, p.y, 2.5, 0, Math.PI * 2);
  ctx.fill();
  ctx.globalAlpha = 1;
});

particles = particles.filter(function(p) {
  return p.life > 0 && p.y < H + 10;
});
requestAnimationFrame(loop);
}
loop();

風力——横方向に一定の力を加える

重力と同じ仕組みで、横方向(vx)に力を加えると風になります。

p.vx += wind;   // wind = 0.05 などの小さな定数

重力(下向きの一定加速)と風(横向きの一定加速)を組み合わせると、斜めに流れていくパーティクルが作れます。

風に流される散らばりパーティクル
var particles = [];

function loop() {
ctx.fillStyle = 'rgba(13,17,23,0.15)';
ctx.fillRect(0, 0, W, H);

for (var i = 0; i < 5; i++) {
  var angle = Math.random() * Math.PI * 2;
  var speed = Math.random() * 2 + 1;
  particles.push({
    x: W / 2,
    y: H / 2,
    vx: Math.cos(angle) * speed,
    vy: Math.sin(angle) * speed,
    life: 90, maxLife: 90
  });
}

particles.forEach(function(p) {
  p.vx += 0.04;   // 右方向の風
  p.vy += 0.08;   // 弱い重力
  p.x += p.vx;
  p.y += p.vy;
  p.life--;

  var ratio = p.life / p.maxLife;
  ctx.globalAlpha = ratio;
  ctx.fillStyle = 'hsl(140, 70%, 65%)';
  ctx.beginPath();
  ctx.arc(p.x, p.y, 2, 0, Math.PI * 2);
  ctx.fill();
  ctx.globalAlpha = 1;
});

particles = particles.filter(function(p) { return p.life > 0; });
requestAnimationFrame(loop);
}
loop();

空気抵抗——フレームごとに速度を減衰させる

現実世界では、空気抵抗によって物体は徐々に減速します。これをコードで再現するには、速度に 1.0 未満の係数をかけ続けます。

p.vx *= 0.99;  // 毎フレーム速度が1%減る
p.vy *= 0.99;

0.99 のような値でも、フレームが積み重なると大きな差が生まれます。60フレーム後には 0.99^60 ≈ 0.547、つまり速度が半分以下になります。

空気抵抗の強さの目安:

  • 0.999 — ほとんど感じない、宇宙空間的
  • 0.99 — 緩やかな減衰、水中っぽい
  • 0.95 — 強い減衰、重い物体的
  • 0.9 — かなり強い、爆発の残滓的
空気抵抗ありとなしの比較(左:あり / 右:なし)
var psA = [], psB = [];

function spawn(arr, ox) {
for (var i = 0; i < 3; i++) {
  var angle = Math.random() * Math.PI * 2;
  var speed = Math.random() * 5 + 2;
  arr.push({
    x: ox, y: H / 2,
    vx: Math.cos(angle) * speed,
    vy: Math.sin(angle) * speed,
    life: 120, maxLife: 120
  });
}
}

function loop() {
ctx.fillStyle = 'rgba(13,17,23,0.2)';
ctx.fillRect(0, 0, W, H);

ctx.fillStyle = 'rgba(255,255,255,0.08)';
ctx.fillRect(W / 2, 0, 1, H);

ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '13px monospace';
ctx.fillText('空気抵抗あり', 10, 20);
ctx.fillText('空気抵抗なし', W / 2 + 10, 20);

spawn(psA, W / 4);
spawn(psB, W * 3 / 4);

[psA, psB].forEach(function(ps, idx) {
  ps.forEach(function(p) {
    if (idx === 0) { p.vx *= 0.96; p.vy *= 0.96; }
    p.x += p.vx; p.y += p.vy; p.life--;
    var ratio = p.life / p.maxLife;
    ctx.globalAlpha = ratio;
    ctx.fillStyle = idx === 0 ? 'hsl(40,100%,70%)' : 'hsl(200,80%,70%)';
    ctx.beginPath();
    ctx.arc(p.x, p.y, 2.5, 0, Math.PI * 2);
    ctx.fill();
    ctx.globalAlpha = 1;
  });
  if (idx === 0) psA = ps.filter(function(p) { return p.life > 0; });
  else psB = ps.filter(function(p) { return p.life > 0; });
});

requestAnimationFrame(loop);
}
loop();

地面バウンス——衝突と反発を実装する

パーティクルが地面(画面下端)に当たったときに反発させると、弾む動きが生まれます。

if (p.y > H - 5) {
  p.y = H - 5;       // 地面にめり込まないよう位置を修正
  p.vy = -p.vy * 0.6; // Y方向の速度を反転&減衰
}

0.6反発係数です。1.0 なら完全弾性衝突(エネルギー損失なし)、0.0 なら完全非弾性衝突(バウンドしない)です。0.40.7 が自然に見えます。

地面の横方向の摩擦も加えると、より自然です。

if (p.y > H - 5) {
  p.vy = -p.vy * 0.6;
  p.vx *= 0.85;  // 地面との摩擦
}
地面に着地してバウンドするパーティクル
var particles = [];

function loop() {
ctx.fillStyle = 'rgba(13,17,23,0.3)';
ctx.fillRect(0, 0, W, H);

ctx.fillStyle = 'rgba(255,255,255,0.15)';
ctx.fillRect(0, H - 4, W, 4);

if (particles.length < 200) {
  for (var i = 0; i < 4; i++) {
    var angle = -Math.PI / 2 + (Math.random() - 0.5) * 1.5;
    var speed = Math.random() * 6 + 3;
    particles.push({
      x: W / 2 + (Math.random() - 0.5) * 20,
      y: H - 10,
      vx: Math.cos(angle) * speed,
      vy: Math.sin(angle) * speed,
      life: 180, maxLife: 180,
      hue: Math.random() * 60 + 180
    });
  }
}

particles.forEach(function(p) {
  p.vy += 0.2;
  p.x += p.vx;
  p.y += p.vy;

  if (p.y > H - 5) {
    p.y = H - 5;
    p.vy = -p.vy * 0.55;
    p.vx *= 0.82;
  }

  p.life--;
  var ratio = p.life / p.maxLife;
  ctx.globalAlpha = ratio;
  ctx.fillStyle = 'hsl(' + p.hue + ', 70%, 65%)';
  ctx.beginPath();
  ctx.arc(p.x, p.y, 2.5, 0, Math.PI * 2);
  ctx.fill();
  ctx.globalAlpha = 1;
});

particles = particles.filter(function(p) {
  return p.life > 0 && Math.abs(p.vy) > 0.1;
});
requestAnimationFrame(loop);
}
loop();

壁への反発

側面の壁(左端・右端)でも同様に反発させられます。

if (p.x < 0) {
  p.x = 0;
  p.vx = -p.vx * 0.7;  // 左壁で反発
}
if (p.x > W) {
  p.x = W;
  p.vx = -p.vx * 0.7;  // 右壁で反発
}

地面・天井・左右の壁すべてに反発を実装すると、箱の中で跳ね回るパーティクルが作れます。


まとめ

この回でやったこと:

  • p.vy += gravity でフレームごとに下向き加速度を与えた
  • p.vx += wind で横方向の風力を実装した
  • p.vx *= 0.99 で空気抵抗による減速を実装した
  • p.y > H の判定で地面バウンスを実装し、反発係数で跳ね返りを調整した

物理の力はすべて「速度への毎フレームの加算・乗算」で表現できます。複数の力を組み合わせると、それだけで自然界の現象に近い動きが生まれます。

次回は引力場・渦場・フローフィールドを使った、より複雑な力の場を実装します。パーティクルが特定のポイントに引き寄せられたり、渦を巻いたりするエフェクトです。