🏀

ボール物理シミュレーションで「めり込んでから押し戻す」より「衝突時刻を計算する」方が楽だった話

に公開

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つの円が重なっているかを検出します。中心間距離が半径の和より小さければ衝突しています。

|P_A - P_B| < r_A + r_B
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  // めり込み量
  };
}

位置補正(押し戻し)

めり込んだ分だけ位置を補正します。質量に応じて押し戻し量を分配します。

\Delta P_A = -\frac{m_B}{m_A + m_B} \cdot penetration \cdot \vec{n}
\Delta P_B = \frac{m_A}{m_A + m_B} \cdot penetration \cdot \vec{n}
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;
}

衝突応答(速度計算)

反発係数を考慮して速度を更新します。衝撃力 j は以下の式で計算します。

j = \frac{-(1 + e) \cdot v_{rel} \cdot \vec{n}}{\frac{1}{m_A} + \frac{1}{m_B}}

ここで e は反発係数、v_{rel} = V_A - V_B は相対速度です。

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つの円の中心位置を時刻 t の関数として表します。

P_A(t) = P_A + V_A \cdot t
P_B(t) = P_B + V_B \cdot t

衝突条件は「中心間距離 = 半径の和」です。

|P_A(t) - P_B(t)|^2 = (r_A + r_B)^2

相対位置 \Delta P = P_A - P_B、相対速度 \Delta V = V_A - V_B とすると、

|\Delta P + \Delta V \cdot t|^2 = R^2

展開して整理すると、t についての二次方程式になります。

at^2 + bt + c = 0

ここで、

  • 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} (接触終了時刻)

判定ロジック

条件 状態
D < 0 衝突しない
0 \le t_0 \le 1 このフレーム内で衝突する
t_0 < 0 < t_1 既にめり込んでいる

実装

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 };
}

衝突が検出されたら、t_0 の時点まで位置を進めてから速度計算を行います。これだけで「めり込む前に衝突処理」が実現できました。

比較まとめ

めり込み→押し戻し 衝突時刻計算
理論的正しさ
実装の楽さ
必要な要素 多い 少ない
複雑な形状 対応可 対応不可(形状が限られる)
パラメータ調整 多い ほぼ不要

円同士の衝突だけなら、二次方程式方式で十分かと思います。Box2D方式は汎用的ですが、正しく動かすにはそれなりの実装コストがかかるようです。

参考

Discussion