Skip to content
cfd-lab:~/ja/posts/2026-06-08-continuous-co…online
NOTE #068DAY MON CFD기법DATE 2026.06.08READ 7 min readWORDS 3,321#Collision-Detection#Computational-Geometry#FSI#Root-Finding#Interval-Arithmetic

弾丸が壁をすり抜けるとき — 連続衝突検出と包含関数による二分法

トンネリングを防ぐCCD:共面条件と保守的な二分法による求根

60fpsで動くシミュレーションでは、速い弾丸は壁をそのまますり抜けます。あるフレームでは壁の左、次のフレームでは右。どちらの瞬間でも重なっていないので、衝突検出器は何も起きなかったと報告します。本記事はその「あいだ」を検査する連続衝突検出(CCD、二物体の線形軌道全体で最初の接触時刻を求める手法)を扱います。共面条件によって衝突を多項式の求根へと変える過程、そして浮動小数点でも衝突を決して見逃さない包含関数による二分法を実装します。

このテーマは、流体構造連成(FSI)、粒子法、メッシュベースの接触シミュレーションなど、「フレーム間で物体が高速に動く」あらゆるコードに直結します。

二つのフレームの間をすり抜けた弾丸#

静的衝突検出は一瞬のスナップショットしか見ません。二つの三角形が今重なっているかを問います。遅い物体には十分です。問題は速度です。

タイムステップ Δt\Delta t のあいだに物体が自身の厚みより遠くへ動くと、スナップショットの間をすり抜けます。この現象をトンネリングと呼びます。さらに厄介なのは復旧できない点です。衝突を一度見逃すと二物体は貫通したまま残り、次のステップではどの面がどちら側かさえ分からなくなります。

下のシミュレーションで実際に操作してみましょう。点は1タイムステップのあいだに左から右へ動き、灰色の棒が壁です。

discrete (endpoints only): MISScontinuous TOI: t = 0.517

point travel を上げると、端点だけを見る discrete 検査はすぐ MISS に落ちます。両端点とも壁から遠いからです。一方 continuous 検査は赤い円で最初の接触時刻 t\*t^\* を正確に指し示します。

端点だけ検査するとなぜ漏れるのか#

端点を二つだけ検査するとは、連続軌道を二点で標本化するということです。標本のあいだで起きたことは見えません。

解決の出発点は視点を変えることです。「今重なっているか」ではなく「t[0,1]t \in [0,1] のいつ重なるか」を問います。各頂点が1ステップのあいだ直線で動くと仮定すれば、位置は時間の1次関数になります。

xi(t)=(1t)xi0+txi1,t[0,1]\mathbf{x}_i(t) = (1-t)\,\mathbf{x}_i^{0} + t\,\mathbf{x}_i^{1}, \qquad t \in [0,1]

ここで xi0\mathbf{x}_i^{0} はステップ開始位置、xi1\mathbf{x}_i^{1} は終了位置です。これで衝突は時間に関する求根問題になります。

共面条件 — 衝突を多項式の根へ#

動く三角形どうしの最初の衝突は二つの場合しかありません。頂点が面に触れるか(vertex–face)、辺が別の辺に触れるか(edge–edge)。どちらの場合も、接触の瞬間に四点が同一平面上に並ぶという幾何的観察が核心です。

頂点 p\mathbf{p} と三角形 (a,b,c)(\mathbf{a},\mathbf{b},\mathbf{c}) の共面条件は、体積(スカラー三重積)が0になることです。

f(t)=(p(t)a(t))[(b(t)a(t))×(c(t)a(t))]=0f(t) = \big(\mathbf{p}(t)-\mathbf{a}(t)\big)\cdot \Big[\big(\mathbf{b}(t)-\mathbf{a}(t)\big)\times\big(\mathbf{c}(t)-\mathbf{a}(t)\big)\Big] = 0

x(t)\mathbf{x}(t)tt の1次式なので、f(t)f(t)tt に関する3次多項式です。これを単変数(univariate)アプローチと呼びます。速いですが落とし穴があります。共面であることは衝突と同じではありません。根 t\*t^\* を求めた後、点が実際に三角形の内部にあるかを別途確認する必要があります。

より頑健な道は多変数(multivariate)形式です。衝突をギャップベクトルの零点として直接書きます。

F(t,u,v)=p(t)[a(t)+u(b(t)a(t))+v(c(t)a(t))]\mathbf{F}(t,u,v) = \mathbf{p}(t) - \big[\mathbf{a}(t) + u\,(\mathbf{b}(t)-\mathbf{a}(t)) + v\,(\mathbf{c}(t)-\mathbf{a}(t))\big]

F=0\mathbf{F}=\mathbf{0} かつ u,v0u,v \ge 0u+v1u+v \le 1 となる (t,u,v)(t,u,v) が存在すれば衝突です。u,vu,v は面上の重心座標です。この形式には境界ケースがなく、純粋に代数的です。

数値的求根が見逃す衝突#

3次式の根を浮動小数点で解くのが最も速いので、ほとんどのシミュレータがそうしています。しかし2012年以降の大規模ベンチマークが衝撃的な結論を出しました。単変数の数値ソルバの多くが false negative(見逃した衝突)を生むのです。

原因は二つあります。第一に、3次式の根は一般に無理数で、浮動小数点では正確に表現できません。第二に、二物体が同一平面上を平行に動くと f(t)f(t) が恒等的に0になります。根が無限に存在するこの場合を数値しきい値でふるい落とそうとして、本物の衝突まで捨ててしまいます。

false positive(無い衝突を報告)は少し余分にコストがかかるだけで復旧可能です。しかし false negative はトンネリングへ直結します。衝突応答コードがラインサーチで貫通を防ぐ構造なら、一度の見逃しがシミュレーション全体を崩します。だからCCDに本当に求められる性質は速度ではなく**保守性(conservativeness)**です。見逃すくらいなら幻でも報告せよ、ということです。

包含関数と二分法 — 根を保守的に囲い込む#

保守性を保証する最も古く、今なお最も明快な方法は Snyder(1992)の包含関数による二分法です。

核となる道具は包含関数(inclusion function)です。パラメータ箱 BB が与えられたとき、F\mathbf{F}BB 上で取りうる全ての値を包む軸平行境界箱 F(B)\Box\mathbf{F}(B) を求めます。すると単純な事実が成り立ちます。0F(B)\mathbf{0} \notin \Box\mathbf{F}(B) なら BB の中に根は決してありません。

幸い F\mathbf{F} は各パラメータについて1次(多重線形)です。多重線形関数は直方体上で極値を必ず頂点で取ります。したがって最もタイトな包含箱は、箱の四つ(または八つ)の頂点で F\mathbf{F} を評価した値の境界箱です。別途の区間演算は不要です。

アルゴリズムは分割統治です。

  1. 候補キューをパラメータ全領域 [0,1]d[0,1]^d で初期化します。
  2. キューから箱 BB を取り出し F(B)\Box\mathbf{F}(B) を評価します。
  3. 0F(B)\mathbf{0} \notin \Box\mathbf{F}(B) なら捨てます(根なし保証)。
  4. 箱の幅が δ\delta より小さければ根として採用します。
  5. そうでなければ最も広い次元を半分に割り、両方をキューに入れます。
  6. 最初の接触時刻が欲しければキューを tt 基準で整列します。

下で (t,s)(t,s) パラメータ正方形がどう分割されるかを段階的に見てみましょう。step を押すと1レベルずつ進みます。

level: 0
active boxes: 1
earliest TOI ≈ 0.000

赤いセルが δ\delta 以下に収束した根領域です。δ\delta を上げるとより早く止まりますが赤いセルが大きくなります。精度を速度と交換しているわけです。

Python — 動く点と線分の最初の接触#

2Dで動く点と動く線分の最初の接触時刻を、包含関数による二分法で実装します。ギャップ関数は F(t,s)=p(t)[a(t)+s(b(t)a(t))]\mathbf{F}(t,s) = \mathbf{p}(t) - [\mathbf{a}(t) + s(\mathbf{b}(t)-\mathbf{a}(t))] で、根は (t,s)[0,1]2(t,s)\in[0,1]^2 で探します。

import heapq
import numpy as np
 
def gap_F(t, s, query):
    """F(t,s) = P(t) - [A(t) + s (B(t)-A(t))]、直線軌道(t in [0,1])。"""
    P0, P1, A0, A1, B0, B1 = query
    P = (1 - t) * P0 + t * P1
    A = (1 - t) * A0 + t * A1
    B = (1 - t) * B0 + t * B1
    return P - (A + s * (B - A))
 
def inclusion_aabb(box, query):
    """Fの最もタイトな軸平行境界:四頂点の値のhull(多重線形なので厳密)。"""
    t0, t1, s0, s1 = box
    corners = [gap_F(t0, s0, query), gap_F(t0, s1, query),
               gap_F(t1, s0, query), gap_F(t1, s1, query)]
    lo = np.min(corners, axis=0)
    hi = np.max(corners, axis=0)
    return lo, hi
 
def origin_inside(lo, hi):
    return bool(np.all(lo <= 0) and np.all(hi >= 0))
 
def inclusion_bisection_toi(query, delta=1e-4, max_checks=200000):
    """[0,1]での最初の接触時刻、無ければNone。Snyder/Redonの包含二分法。"""
    # 優先度キューをt0基準で整列 → 最初に収束した箱が最初の接触
    pq = [(0.0, 0.0, 1.0, 0.0, 1.0)]   # (t0, t0, t1, s0, s1)
    checks = 0
    while pq and checks < max_checks:
        _, t0, t1, s0, s1 = heapq.heappop(pq)
        checks += 1
        lo, hi = inclusion_aabb((t0, t1, s0, s1), query)
        if not origin_inside(lo, hi):
            continue                              # この箱に衝突なし
        if max(t1 - t0, s1 - s0) < delta:
            return t0, checks                     # 収束:最初の接触時刻
        if (t1 - t0) >= (s1 - s0):                # 広い次元を半分に
            tm = 0.5 * (t0 + t1)
            heapq.heappush(pq, (t0, t0, tm, s0, s1))
            heapq.heappush(pq, (tm, tm, t1, s0, s1))
        else:
            sm = 0.5 * (s0 + s1)
            heapq.heappush(pq, (t0, t0, t1, s0, sm))
            heapq.heappush(pq, (t0, t0, t1, sm, s1))
    return None, checks
 
# 1フレームで壁を横切る弾丸。端点だけ見ると両方とも壁から遠い。
query = (np.array([0.5, 3.5]), np.array([9.5, 3.5]),   # 点 P0 -> P1
         np.array([5.0, 1.0]), np.array([5.0, 1.0]),   # 壁端 A(静止)
         np.array([5.0, 6.0]), np.array([5.0, 6.0]))   # 壁端 B(静止)
 
toi, checks = inclusion_bisection_toi(query, delta=1e-4)
print(f"time-of-impact t* = {toi:.5f}   (検査した箱: {checks})")

出力:

time-of-impact t* = 0.50000   (検査した箱: 53)

解析解と正確に一致します。点は x=5x=5t=0.5t=0.5 で到達し、そのとき y=3.5y=3.5 は線分の範囲 [1,6][1,6] の中にあります。端点だけ見る検査が MISS と答えたまさにその衝突を、53回の箱評価で囲い込みました。保守的でありながら安価です。

δ ひとつで精度と速度を交換する#

包含二分法の最も実用的な美点は、ユーザのつまみがただ一つ、δ\delta である点です。

δ\delta を上げると箱の分割が減り早く止まります。代わりに根領域が大きくなり false positive が増えます。下げると精密になりますが検査回数が増えます。リアルタイムシミュレーションでは検査回数の上限 mIm_I を併せて置き、最悪実行時間を固定します。上限に達したら最も最近記録した衝突区間を保守的に返します — それでも見逃しません。

ここに最小分離(minimum separation)を加えると、製造公差や丸めによる貫通まで防げます。距離をユークリッドの代わりにチェビシェフ(L∞)に変える小さなトリックが問題を単純化します。F=0\mathbf{F}=\mathbf{0} の代わりに Fd\lVert\mathbf{F}\rVert_\infty \le d を解けばよく、これは原点の代わりに辺の長さ 2d2d の立方体が包含箱と重なるかを見ることと同じです。コードでは箱を dd だけ大きくする一行で済みます。

コードに移す前に押さえること#

  • 常に保守的な向きに丸める。 頂点での F\mathbf{F} 評価に前進誤差限界を足して包含箱を少し大きくします。この一行が false negative を防ぎます。
  • 単変数3次式の誘惑を警戒する。 速いですが共面平行運動で無限の根に引っかかります。保守性が必要なら多変数+二分法を使います。
  • 広域フェーズ(broad phase)を先に敷く。 空間ハッシュやAABB木で候補ペアをふるってからCCDを呼びます。CCDは狭域フェーズの高価な最後の関門です。
  • δ\deltamIm_I をシーンスケールで正規化する。 絶対長ではなくドメインの大きさに合わせれば、速度やスケールが変わっても動きます。

残しておきたい一行#

  • トンネリングは連続軌道を端点二つで標本化することから生じます。CCDは t[0,1]t\in[0,1] 全体を見ます。
  • 衝突は共面条件の多項式の根(単変数)またはギャップベクトルの零点(多変数)として表されます。
  • 包含関数による二分法は、多重線形な F\mathbf{F} の頂点評価だけで根を保守的に囲い込みます。つまみは δ\delta ひとつ、決して見逃しません。

役に立ったらシェアしてください。