ボール物理シミュレーションで「めり込んでから押し戻す」より「衝突時刻を計算する」方が楽だった話
TL;DR
2Dのボール物理シミュレーションを実装する際、「めり込みを検出して押し戻す」アプローチより「二次方程式で衝突時刻を計算する」アプローチの方が楽かもしれません。前者はBox2D等で採用されている正統派ですが、ちゃんと動かすには多くの要素が必要になるようです。後者は数式1つでシンプルに解決できました。
まず結果のデモ
それぞれをResetで何度か試してみると良いと思います。
A. めり込み検出 → 押し戻し方式(問題あり)
B. 衝突時刻を計算する方式(自然な動き)
Aではボールが他のボールの縁を登ったり、めり込んだり、不自然な動きが発生しています。Bでは自然な衝突が実現できています。
2つのアプローチ
ボール同士の衝突処理にはいくつかアプローチがありますが、今回は以下の2つを試しました。
A. めり込み検出 → 押し戻し(Box2D方式)
1. 位置を更新
2. めり込みを検出
3. 位置を補正して押し戻す
4. 速度を計算
多くの物理エンジンで採用されている方式です。汎用的で、複雑な形状にも対応できるようです。
B. 衝突時刻を計算(二次方程式方式)
1. 衝突する時刻を計算
2. その時刻まで位置を進める
3. 速度を計算
4. 残りの時間分移動
円同士など、軌道が単純な場合に使える方式のようです。
Aの実装
まずAの実装を見てみます。
衝突検出
2つの円が重なっているかを検出します。中心間距離が半径の和より小さければ衝突しています。
function detectCollision(a, b) {
const dx = b.x - a.x;
const dy = b.y - a.y;
const distSq = dx * dx + dy * dy;
const radiusSum = a.radius + b.radius;
if (distSq >= radiusSum * radiusSum) return null;
const dist = Math.sqrt(distSq);
return {
a, b,
nx: dx / dist, // 衝突法線
ny: dy / dist,
penetration: radiusSum - dist // めり込み量
};
}
位置補正(押し戻し)
めり込んだ分だけ位置を補正します。質量に応じて押し戻し量を分配します。
function positionalCorrection(collision) {
const { a, b, nx, ny, penetration } = collision;
const slop = 0.1; // 許容めり込み量
const percent = 0.4; // 補正係数
const correction = Math.max(penetration - slop, 0) * percent;
const totalInverseMass = a.inverseMass + b.inverseMass;
const correctionX = nx * correction / totalInverseMass;
const correctionY = ny * correction / totalInverseMass;
a.x -= correctionX * a.inverseMass;
a.y -= correctionY * a.inverseMass;
b.x += correctionX * b.inverseMass;
b.y += correctionY * b.inverseMass;
}
衝突応答(速度計算)
反発係数を考慮して速度を更新します。衝撃力
ここで
function resolveCollision(collision) {
const { a, b, nx, ny } = collision;
// 相対速度
const dvx = a.vx - b.vx;
const dvy = a.vy - b.vy;
const dvn = dvx * nx + dvy * ny;
if (dvn > 0) return; // 離れていく方向なら何もしない
// 衝撃力の計算
const totalInverseMass = a.inverseMass + b.inverseMass;
const j = -(1 + RESTITUTION) * dvn / totalInverseMass;
a.vx += j * nx * a.inverseMass;
a.vy += j * ny * a.inverseMass;
b.vx -= j * nx * b.inverseMass;
b.vy -= j * ny * b.inverseMass;
}
更新ループ
これらを組み合わせて、イテレーションを回します。
function update() {
// 重力・移動
for (const ball of balls) {
ball.vy += GRAVITY;
ball.x += ball.vx;
ball.y += ball.vy;
}
// 衝突処理(複数回イテレーション)
const iterations = 8;
for (let iter = 0; iter < iterations; iter++) {
for (let i = 0; i < balls.length; i++) {
for (let j = i + 1; j < balls.length; j++) {
const collision = detectCollision(balls[i], balls[j]);
if (collision) {
positionalCorrection(collision);
resolveCollision(collision);
}
}
}
}
}
Aで発生した問題
このように、Aの実装自体はそこまで複雑ではありません。しかし、これだけでは冒頭のデモのように「登る」「めり込む」問題が発生してしまいました。
問題:ボールが他のボールの縁を「登る」、「めり込む」
複数のボールに挟まれた時、位置補正が上方向に働いてしまい、物理的にありえない動きになってしまいました。また、位置補正が追いつかずボール同士がめり込んでしまうこともあります。
原因と対策(本来必要なもの)
| 要素 | 説明 |
|---|---|
| 十分なイテレーション | 位置補正を数十回繰り返して収束させる |
| 接触マニフォールド | 接触点の情報を厳密に管理 |
| Warm Starting | 前フレームの計算結果を初期値に使う |
| 接触フラグ管理 | 接触の開始・継続・終了を追跡 |
これらを正しく実装しないと、押し戻しの方向が不自然になったり、同じペアに何度も衝突処理が走ったりするようです。Box2Dのソースを見ると、これらが緻密に実装されているのがわかります。
Bの実装
一方、二次方程式で衝突時刻を計算するアプローチはAに比べて必要な要素が少ないです。
衝突時刻の導出
2つの円の中心位置を時刻
衝突条件は「中心間距離 = 半径の和」です。
相対位置
展開して整理すると、
ここで、
a = |\Delta V|^2 b = 2(\Delta P \cdot \Delta V) c = |\Delta P|^2 - R^2
解の公式から、
-
(接触開始時刻)t_0 = \frac{-b - \sqrt{b^2 - 4ac}}{2a} -
(接触終了時刻)t_1 = \frac{-b + \sqrt{b^2 - 4ac}}{2a}
判定ロジック
| 条件 | 状態 |
|---|---|
| 衝突しない | |
| このフレーム内で衝突する | |
| 既にめり込んでいる |
実装
function findCollisionTime(a, b) {
const dx = a.x - b.x;
const dy = a.y - b.y;
const dvx = a.vx - b.vx;
const dvy = a.vy - b.vy;
const r = a.radius + b.radius;
const A = dvx * dvx + dvy * dvy;
const B = 2 * (dx * dvx + dy * dvy);
const C = dx * dx + dy * dy - r * r;
if (A < 0.0001) {
// 相対速度がほぼ0
return C < 0 ? { t0: 0, t1: 0, overlapping: true } : null;
}
const discriminant = B * B - 4 * A * C;
if (discriminant < 0) return null;
const sqrtD = Math.sqrt(discriminant);
const t0 = (-B - sqrtD) / (2 * A);
const t1 = (-B + sqrtD) / (2 * A);
return { t0, t1, overlapping: false };
}
衝突が検出されたら、
比較まとめ
| めり込み→押し戻し | 衝突時刻計算 | |
|---|---|---|
| 理論的正しさ | ○ | ○ |
| 実装の楽さ | △ | ◎ |
| 必要な要素 | 多い | 少ない |
| 複雑な形状 | 対応可 | 対応不可(形状が限られる) |
| パラメータ調整 | 多い | ほぼ不要 |
円同士の衝突だけなら、二次方程式方式で十分かと思います。Box2D方式は汎用的ですが、正しく動かすにはそれなりの実装コストがかかるようです。
参考
- Real-Time Collision Detection (Christer Ericson)
- Wikipedia: Collision response
- Box2D
Discussion