ボール同士の衝突——円と円の当たり判定
壁との衝突は比較的シンプルでした。次のステップはボール同士の衝突です。これには「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(法線方向の相対速度の内積)が正の場合は互いに離れているので衝突応答は不要です。
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チェックで重なりを防ぐ) - 質量比による位置補正で、重いボールが少ししか動かない挙動を再現した
次回は「突き抜けない剛体」として、より頑健な位置補正(セパレーション)の手法を学びます。高速移動での突き抜け問題(トンネリング)と、複数ボールのスタック問題を解決します。