#06 ビジュアルコーディング 物理シミュレーション

ボール同士の衝突——円と円の当たり判定

壁との衝突は比較的シンプルでした。次のステップはボール同士の衝突です。これには「2つの円が重なっているかどうか」の判定と、「衝突後の速度をどう計算するか」という2つの問題があります。

運動量保存とエネルギー保存——高校物理で学んだ公式がここで登場します。


円の当たり判定: distance < r1 + r2

2つの円が衝突しているかどうかは、中心間の距離半径の和を比較するだけです。

var dx = b.x - a.x;
var dy = b.y - a.y;
var distance = Math.sqrt(dx * dx + dy * dy);
var minDist = a.r + b.r;

if (distance < minDist) {
  // 衝突している!
}

Math.hypot(dx, dy) を使うと同じ計算をより簡潔に書けます。

var distance = Math.hypot(b.x - a.x, b.y - a.y);

衝突法線ベクトルの正規化

衝突が発生したら、次に「どの方向に力を加えるか」を決める衝突法線を計算します。

衝突法線は、2つの円の中心を結ぶ方向のベクトルです。これを単位ベクトル(長さ1)に正規化します。

var dx = b.x - a.x;
var dy = b.y - a.y;
var distance = Math.hypot(dx, dy);

// 正規化(単位ベクトル化)
var nx = dx / distance;  // 法線のX成分
var ny = dy / distance;  // 法線のY成分

この法線ベクトル (nx, ny) が衝突の「押し返す方向」になります。


弾性衝突の公式

弾性衝突(エネルギーが保存される衝突)では、運動量保存とエネルギー保存の2つの法則が同時に成り立ちます。

1次元の弾性衝突の公式:

v1_new = ((m1 - m2) * v1 + 2 * m2 * v2) / (m1 + m2)
v2_new = ((m2 - m1) * v2 + 2 * m1 * v1) / (m1 + m2)

同質量の場合(m1 = m2):

v1_new = v2    (速度が入れ替わる)
v2_new = v1

これは玉突きを想像すると直感的です。止まっているボールに同じ質量のボールが当たると、当てた方が止まり、当たった方が動き出します。


2次元への拡張

2次元では、法線方向の速度成分だけを交換します。接線方向(法線に垂直な方向)は変わりません。

function resolveCollision(a, b) {
  var dx = b.x - a.x;
  var dy = b.y - a.y;
  var dist = Math.hypot(dx, dy);
  var nx = dx / dist;
  var ny = dy / dist;

  // 法線方向の相対速度
  var dvx = a.vx - b.vx;
  var dvy = a.vy - b.vy;
  var dvn = dvx * nx + dvy * ny;  // 内積(法線方向成分)

  // 近づいていなければ処理しない
  if (dvn > 0) return;

  // 弾性衝突のインパルス
  var e = 0.9;  // 反発係数
  var impulse = -(1 + e) * dvn / (1/a.mass + 1/b.mass);

  a.vx += (impulse / a.mass) * nx;
  a.vy += (impulse / a.mass) * ny;
  b.vx -= (impulse / b.mass) * nx;
  b.vy -= (impulse / b.mass) * ny;
}

dvn(法線方向の相対速度の内積)が正の場合は互いに離れているので衝突応答は不要です。

2つのボールの弾性衝突
var balls = [
{x: W * 0.22, y: H / 2, vx: 200, vy: 30,  r: 38, mass: 1, hue: 0  },
{x: W * 0.78, y: H / 2, vx: -140, vy: -40, r: 38, mass: 1, hue: 200},
];
var e = 0.92;

function resolveCollision(a, b) {
var dx = b.x - a.x;
var dy = b.y - a.y;
var dist = Math.hypot(dx, dy);
if (dist === 0) return;
var nx = dx / dist, ny = dy / dist;
var dvx = a.vx - b.vx, dvy = a.vy - b.vy;
var dvn = dvx * nx + dvy * ny;
if (dvn > 0) return;
var impulse = -(1 + e) * dvn / (1/a.mass + 1/b.mass);
a.vx += impulse / a.mass * nx;
a.vy += impulse / a.mass * ny;
b.vx -= impulse / b.mass * nx;
b.vy -= impulse / b.mass * ny;
var overlap = (a.r + b.r) - dist;
var corr = overlap / 2;
a.x -= nx * corr;
a.y -= ny * corr;
b.x += nx * corr;
b.y += ny * corr;
}

function loop(ts) {
if (!loop.last) loop.last = ts;
var dt = Math.min((ts - loop.last) / 1000, 0.05);
loop.last = ts;

balls.forEach(function(b) {
  b.x += b.vx * dt;
  b.y += b.vy * dt;
  if (b.x - b.r < 0) { b.x = b.r; b.vx = Math.abs(b.vx); }
  if (b.x + b.r > W) { b.x = W - b.r; b.vx = -Math.abs(b.vx); }
  if (b.y - b.r < 0) { b.y = b.r; b.vy = Math.abs(b.vy); }
  if (b.y + b.r > H) { b.y = H - b.r; b.vy = -Math.abs(b.vy); }
});

var a = balls[0], b = balls[1];
if (Math.hypot(b.x - a.x, b.y - a.y) < a.r + b.r) {
  resolveCollision(a, b);
}

ctx.fillStyle = '#0d1117';
ctx.fillRect(0, 0, W, H);

balls.forEach(function(b) {
  ctx.beginPath();
  ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
  ctx.fillStyle = 'hsl(' + b.hue + ',80%,60%)';
  ctx.fill();
  ctx.strokeStyle = 'hsl(' + b.hue + ',80%,80%)';
  ctx.lineWidth = 2;
  ctx.stroke();
});

requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

2つのボールが衝突する瞬間に速度が交換されます。位置補正も入っているのでボールが重なって止まることはありません。


質量の異なる複数ボールの衝突

異なる質量のボール同士が衝突すると、重いボールはあまり動かず、軽いボールが大きく弾き飛ばされます。

質量の異なる複数ボールの衝突
var balls = [];
// 重なりなく初期配置
var positions = [{x:W*0.15,y:H*0.3},{x:W*0.4,y:H*0.2},{x:W*0.65,y:H*0.3},
               {x:W*0.85,y:H*0.5},{x:W*0.5,y:H*0.65},{x:W*0.2,y:H*0.7}];
var masses = [0.6, 1.0, 1.8, 0.8, 1.4, 0.5];
var hues   = [0, 200, 120, 280, 50, 160];
for (var i = 0; i < 6; i++) {
var m = masses[i];
var r = 16 + m * 14;
balls.push({
  x: positions[i].x, y: positions[i].y,
  vx: (Math.random()-0.5)*200, vy: (Math.random()-0.5)*180,
  r: r, mass: m, hue: hues[i]
});
}
var e = 0.88;

function resolveCollision(a, b) {
var dx = b.x - a.x, dy = b.y - a.y;
var dist = Math.hypot(dx, dy) || 0.001;
if (dist >= a.r + b.r) return;
var nx = dx / dist, ny = dy / dist;
// 離れていく方向なら無視
var dvn = (a.vx - b.vx)*nx + (a.vy - b.vy)*ny;
if (dvn > 0) return;
var impulse = -(1 + e) * dvn / (1/a.mass + 1/b.mass);
a.vx += impulse/a.mass*nx; a.vy += impulse/a.mass*ny;
b.vx -= impulse/b.mass*nx; b.vy -= impulse/b.mass*ny;
// 位置補正(質量比で分担)
var overlap = a.r + b.r - dist;
var ta = b.mass/(a.mass+b.mass), tb = a.mass/(a.mass+b.mass);
a.x -= nx*overlap*ta; a.y -= ny*overlap*ta;
b.x += nx*overlap*tb; b.y += ny*overlap*tb;
}

function loop(ts) {
if (!loop.last) loop.last = ts;
var dt = Math.min((ts - loop.last) / 1000, 0.05);
loop.last = ts;

balls.forEach(function(b) {
  b.x += b.vx * dt;
  b.y += b.vy * dt;
  if (b.x - b.r < 0) { b.x = b.r; b.vx = Math.abs(b.vx) * 0.9; }
  if (b.x + b.r > W) { b.x = W - b.r; b.vx = -Math.abs(b.vx) * 0.9; }
  if (b.y - b.r < 0) { b.y = b.r; b.vy = Math.abs(b.vy) * 0.9; }
  if (b.y + b.r > H) { b.y = H - b.r; b.vy = -Math.abs(b.vy) * 0.9; }
});

for (var i = 0; i < balls.length - 1; i++) {
  for (var j = i + 1; j < balls.length; j++) {
    resolveCollision(balls[i], balls[j]);
  }
}

ctx.fillStyle = 'rgba(13,17,23,0.35)';
ctx.fillRect(0, 0, W, H);

balls.forEach(function(b) {
  ctx.beginPath();
  ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
  ctx.fillStyle = 'hsl(' + b.hue + ',75%,55%)';
  ctx.fill();
  ctx.strokeStyle = 'hsl(' + b.hue + ',75%,75%)';
  ctx.lineWidth = 1.5;
  ctx.stroke();

  ctx.fillStyle = '#fff';
  ctx.font = (b.r > 14 ? '10' : '8') + 'px monospace';
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  ctx.fillText(b.mass.toFixed(1), b.x, b.y);
  ctx.textBaseline = 'alphabetic';
  ctx.textAlign = 'left';
});

requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

各ボールに質量を表示しています。重いボール(大きい)にぶつかっても軽いボール(小さい)は大きく弾き飛ばされますが、重いボールはほとんど動きません。


衝突の最適化: ブロードフェーズ

上のデモでは全ペアを確認する O(n²) のアプローチを使っています。ボール数が多くなると処理が重くなります。

実際のゲームエンジンではブロードフェーズと呼ばれる粗い判定で候補を絞り込んでから、精密な判定(ナローフェーズ)を行います。

// ブロードフェーズの例: 空間分割グリッド
// 各ボールをグリッドセルに割り当て、同じセルのボールだけを詳細チェック
var CELL_SIZE = 60;
var grid = {};

balls.forEach(function(b) {
  var cellX = Math.floor(b.x / CELL_SIZE);
  var cellY = Math.floor(b.y / CELL_SIZE);
  var key = cellX + ',' + cellY;
  if (!grid[key]) grid[key] = [];
  grid[key].push(b);
});

このシリーズではシンプルさを優先してO(n²)を使いますが、ボール数が100を超える場合はこの最適化が必要です。


まとめ

この回でやったこと:

  • 円の当たり判定 Math.hypot(dx, dy) < r1 + r2 を実装した
  • 衝突法線ベクトルの計算と正規化(nx = dx / dist)を学んだ
  • インパルス法による弾性衝突の速度更新を実装した(dvn > 0 チェックで重なりを防ぐ)
  • 質量比による位置補正で、重いボールが少ししか動かない挙動を再現した

次回は「突き抜けない剛体」として、より頑健な位置補正(セパレーション)の手法を学びます。高速移動での突き抜け問題(トンネリング)と、複数ボールのスタック問題を解決します。