GJK + EPA 算法详解
GJK(Gilbert--Johnson--Keerthi)和 EPA(Expanding Polytope Algorithm)是物理引擎(PhysX、Bullet、Box2D 等)中最核心的凸体碰撞检测与穿透计算算法。本文从零推导,覆盖数学原理、算法流程、数值稳定性和退化处理。
一、为什么需要 GJK
问题
给定两个凸体 A 和 B,判断它们是否相交。
关键变换:Minkowski Difference
定义 Minkowski Difference:
C = A ⊖ B = { a - b | a ∈ A, b ∈ B }
那么一个极其简洁的等价关系成立:
A ∩ B ≠ ∅ ⇔ 原点 0 ∈ C
证明:
-
如果
A ∩ B ≠ ∅,存在点p同时属于 A 和 B,那么p - p = 0属于 C。 -
如果
0 ∈ C,则存在a ∈ A, b ∈ B使得a - b = 0,即a = b,所以p = a = b是两个集合的公共点。
为什么要求凸体
两个凸集的 Minkowski Difference 仍然是凸集。非凸体的 Minkowski Difference 可能不是凸的,GJK 只能工作在凸集上。处理非凸体需要先做凸分解。
思路转变
原来的问题"两个物体相交吗"变成了"一个凸集包含原点吗"。GJK 不显式构造整个 C(那太贵了),而是通过支撑函数按需采样。
二、支撑函数 Support Function
定义
Support(A, B, d) = Support_A(d) - Support_B(-d)
其中:
Support_A(d) = argmax { v · d | v ∈ A }
即 A 中沿方向 d 最远的点。
物理含义
Support(A, B, d) 给出 C 的边界上沿方向 d 的极值点。我们通过在不同方向上调用它,逐步"探测"C 的形状。
常见形状的支撑函数
球体 (center c, radius r):
Support(d) = c + r · normalize(d)
Box (center c, half-extents h):
Support(d) = c + (sign(dx)·hx, sign(dy)·hy, sign(dz)·hz)
Capsule:等价于球的 support,球心是线段两端中沿 d 更远的那个。
凸网格:遍历所有顶点取 max(Vᵢ · d),时间复杂度 O(n)。
计算量
支撑函数是 GJK/EPA 最频繁的调用。对球/Box/Capsule 是 O(1),对凸网格是 O(n)。这是为什么物理引擎里尽量用参数化形状而不是三角网格做碰撞。
三、GJK 核心算法
3.1 直觉
GJK 维护一个 simplex(在 2D 中最多 3 个顶点,在 3D 中最多 4 个顶点),这些顶点都是 C 中的点。算法逐步移动 simplex 使其"包围"或"逼近"原点。
每一步:
-
取当前 simplex 中离原点最近的点
p -
用方向
d = -p(指向原点)调用 support,得到新顶点w -
如果
w · d ≤ 0(新点没有跨越原点方向)→ 原点在 C 外面,‖p‖就是最近距离。终止。 -
把 w 加入 simplex
-
重新计算 simplex 中离原点最近的点,丢弃不再需要的顶点
-
返回步骤 1
关键不变量
每一轮 simplex 中离原点最近的点,是 C 中离原点最近的点的下界逼近。
3.2 Simplex 的维数递进:Johnson 距离算法
对于 simplex 中离原点最近的点 p,我们需要确定 p 在 simplex 的哪个子集上(面、边、或顶点),并扔掉无关顶点。
0-simplex(一个点)
最近点就是那个点本身。
1-simplex(一条线段 AB)
求原点在线段上的投影。设参数 t ∈ [0,1]:
p = A + t(B - A)
由 p · (B - A)⊥ = 0,解出:
t = -(A · (B - A)) / ‖B - A‖²
Clamp 到 [0,1]。
-
t = 0→ 最近点是 A,丢弃 B -
t = 1→ 最近点是 B,丢弃 A -
0 < t < 1→ 最近点在线段上
2-simplex(三角形 ABC)
用 Voronoi 区域 方法。三角形有三条边。每条边定义一个外侧半空间:
如果原点在边 BC 的外侧
→ 丢弃 A(对角顶点),降为线段 BC,重新计算
如果原点在边 CA 的外侧
→ 丢弃 B
如果原点在边 AB 的外侧
→ 丢弃 C
如果三条边都不需要丢弃
→ 原点在三角形内部,最近点在三角形面上
"外侧"的判断方式:
对边 BC(对顶点 A):
取法线 n = (C - B) × ((A - B) × (C - B)) // 指向 A 的反方向
如果 p · n < 0 → p 在 BC 外侧 → 丢弃 A
3-simplex(四面体,仅 3D)
推广到 3D:对四面体的每个三角面,如果原点在该面的外侧(即 p 在该面法线的外侧),丢弃对面的顶点。
如果原点在所有面的内侧 → simplex 包含原点 → 相交,进入 EPA。
3.3 初始方向
第一个 support 方向通常取两个物体中心连线方向,或 (1, 0, 0)。选得不好只会多迭代几步,不影响正确性。
3.4 收敛判定
非相交情况:
如果 w · d ≤ 0
→ 原点在 C 外面
→ 返回距离 = ‖p‖, 最近点 = p
或者:
如果 ‖p‖ 的变化 < ε
→ 收敛,同样返回距离
相交情况:
如果 simplex 包含原点(在 3D 中即四面体 4 个顶点且原点在内部)
→ 进入 EPA
3.5 数值稳定性问题
实际实现中常见的坑:
退化 simplex:三个点几乎共线 / 四个点几乎共面。面积/体积判断可能除零。加 epsilon 容差,退化时跳过。
方向退化 :p ≈ 0(原点在 simplex 面/边界上)。此时 d = -p 几乎是零向量。处理方法:检测 p 长度低于阈值则认为相交。
浮点误差累积:反复的 support → simplex → 投影 可能让 simplex 中的点出现微小不一致。处理方式是重新计算或回退。
四、EPA(Expanding Polytope Algorithm)
GJK 告诉你"相交了",但不告诉你:
-
穿透了多深
-
碰撞法线是哪个方向
-
怎么把物体推开
这些是 EPA 的工作。
4.1 核心直觉
GJK 结束时留下一个包含原点的四面体。
EPA 把这个四面体逐步扩展成越来越大的凸多面体(polytope),每次往"离原点最近的面"的外侧加一个支撑点。当多面体足够逼近 C 的真实表面时,最近面到原点的距离就是穿透深度 ,面法线就是碰撞法线。
4.2 关键几何量
polytope 上每个三角面定义一个到原点的距离:
d_face = face_vertex · face_normal
其中 face_normal 是该面的指向原点方向的法线。
EPA 每轮找 d_face 最小的面------也就是离原点最近的面------来扩展。
4.3 EPA 迭代过程
初始化:把 GJK 的四面体做成初始 polytope。4 个三角面,法线全部指向四面体内部(原点方向)。
每轮迭代:
-
从 polytope 的所有面中,选出
d_face最小的面 F(离原点最近的面) -
取 F 的面法线
n(指向原点方向),做 support:w = Support(A, B, n) -
如果
w · n - d_face < ε:-
当前面已经近乎是 C 的真实边界
-
收敛 :穿透深度 =
d_face,碰撞法线 =n
-
-
找到 polytope 中所有被 w "看到"的面(新点在面的外侧)
-
删除这些面
-
找到删除和保留之间的边界边(每条边界边只被一个保留面引用)
-
每条边界边和 w 构成新的三角面,加入 polytope
-
回到步骤 1
4.4 "看得到"的面
新顶点 w 在 polytope 外部。在它"视线"内的面需要删除。
判断:
如果 (w - face_vertex) · face_normal > 0
→ 这个面被 w 看到
→ 删除
这和 incremental convex hull 构建算法(QuickHull)中的删面-补面操作完全一致。
4.5 补面
被删除的面和保留的面之间形成一条闭合的边界(一系列边)。每条边界边只连接一个保留面和一个被删除面。
对每条边界边 (v₁, v₂),和 w 构成新三角形:
新面 = (v₁, v₂, w)
法线 = normalize((v₂ - v₁) × (w - v₁))
确保法线指向 polytope 内部(即原点方向)。法线方向判断:
如果 (w - face_vertex) · new_normal > 0 → 翻转法线
4.6 收敛判定
EPA 收敛条件是:
w · n - d_face < ε
含义是:新 support 点沿面法线方向没有超出当前面多远。面和 C 的真实边界已经足够接近了。
ε 通常取 1e-4 到 1e-6,取决于场景尺度和精度需求。
4.7 最大迭代次数
EPA 通常 2-20 次迭代就能收敛。但在极端退化情况下可能不收敛。实现中通常设上限 64-128,超过后取当前最优结果(最小 d_face 的面)。
4.8 退化情况处理
精度不够,面太小:法线不稳定。限制最大迭代次数,超限后返回当前最优。
法线退化到零:补面时检测三角形面积,退化则跳过。
原点在 polytope 表面上 :GJK 刚好检测到接触但没有穿透(擦边)。EPA 返回 d_face = 0 和最近的 simplex 面法线。
五、算法复杂度
GJK
| 项目 | 量级 |
|---|---|
| 每次迭代 | O(1),取决于支撑函数实现 |
| 迭代次数(分离情况) | 典型 3-10 次 |
| 迭代次数(相交情况) | 典型 1-5 次到达四面体 |
EPA
| 项目 | 量级 |
|---|---|
| 每次迭代 | O(k),k 是删除面数 |
| 总迭代次数 | 典型 2-20 次 |
| polytope 面数 | 通常 ≤ 50 个 |
| 最大迭代限制 | 64-128 |
六、完整流程伪代码
text
输入:凸体 A,凸体 B
输出:(intersecting, distance, normal, penetration_depth)
初始化:
simplex = ∅
取初始方向 d = A.center - B.center(或 (1,0,0))
┌─────────────────────────────────────────┐
│ GJK │
│ │
│ 循环: │
│ 1. w = Support(A, B, d) │
│ 2. 如果 w · d ≤ 0: │
│ 返回 (false, ‖p‖, normalize(-p), 0) │
│ 3. 把 w 加入 simplex │
│ 4. (p, simplex) = closest_to_origin(simplex) │
│ 5. d = -p │
│ 6. 如果 simplex 是四面体且包含原点: │
│ 进入 EPA │
│ 7. 如果 ‖p‖ 变化 < ε: │
│ 返回 (false, ‖p‖, normalize(-p), 0) │
│ │
└─────────────────────────────────────────┘
↓ (相交)
┌─────────────────────────────────────────┐
│ EPA │
│ │
│ 1. polytope = GJK 的四面体 simplex │
│ 把 4 个三角面初始化(法线指向内部) │
│ │
│ 循环 (最多 64 次): │
│ 2. 找离原点最近的面 F: │
│ d = F.vertex · F.normal │
│ n = F.normal │
│ 3. w = Support(A, B, n) │
│ 4. 如果 w · n - d < ε: │
│ 返回 (true, NaN, n, d) │
│ 5. 找到所有被 w "看到"的面 → 删除 │
│ 6. 找到边界边 │
│ 7. 每条边界边 + w → 新三角形面 │
│ 8. 如果 polytope 面数 > 128: │
│ 取当前最优面,返回 │
│ │
│ 超限: 返回当前最优面的结果 │
└─────────────────────────────────────────┘
七、GJK / EPA 在 Minkowski Difference 中的可视化理解
把问题放在 Minkowski Difference 空间里就变得很直观:
C = A ⊖ B
GJK 在做什么?
在 C 中移动一个 simplex,每次往原点的方向扩展一个新顶点。
最终要么 simplex 包住原点(相交),要么把 simplex 收缩到 C 上离原点最近的点(分离)。
EPA 在做什么?
从包含原点的四面体出发,把它变大成多面体,
逼近 C 的真实表面。离原点最近的面 → 穿透深度和方向。
这比直接思考两个物体的碰撞直观得多------因为整个问题变成了"原点到一个凸集的距离"这个标准几何问题。
八、需要 GJK 还是 GJK + EPA
| 场景 | 需要什么 | 说明 |
|---|---|---|
| 仅判断是否碰撞(trigger 检测) | 仅 GJK | 返回 bool 即可,不需要穿透/法线 |
| 求分离距离(不穿透) | 仅 GJK | 返回最近距离 |
| 需要穿透深度和碰撞法线(推开物体) | GJK + EPA | EPA 在 GJK 确认相交后运行 |
| 需要接触点和 contact manifold | GJK + EPA + 接触生成 | EPA 只给法线和深度 |
九、为什么主流物理引擎选 GJK + EPA
| 优势 | 说明 |
|---|---|
| 通用性强 | 任何凸体都行,只要提供支撑函数 |
| 复杂度低 | 每次迭代只做 1 次 support + O(1) 的 simplex 维护 |
| 精度高 | EPA 收敛快,穿透方向准确 |
| 实现简洁 | 核心循环不超过 50 行代码 |
| 天然支持连续碰撞 | 可直接用于 TOI (Time of Impact) 搜索 |
与 Separating Axis Theorem (SAT) 相比:
-
SAT 在 Box-Box 上很快,但对凸网格需要遍历所有面,O(n²)
-
GJK 对网格也只需要 O(迭代次数 × n),通常快得多
-
SAT 在分离时天然给出分离轴,GJK 对分离情况也天然给出最近方向
-
PhysX 内部对常见形状混合使用两种方法以取长补短
十、工程实现注意事项
-
support 函数是性能热点。对网格做缓存(比如空间哈希、AABB 树)可以大幅加速。
-
GJK 不依赖物体是世界变换后的坐标系。可在局部空间做 Minkowski Difference,减少数值误差。
-
浮点 delta 需要根据场景尺度调。太大导致虚假碰撞,太小导致不收敛。
-
EPA 初始化时的四面体法线方向必须指向多面体内部(即原点方向)。方向反了会导致 EPA 向错误方向膨胀。
-
EPA 面数限制要严格。极端情况下的面爆炸会导致性能崩溃。
-
不要忽略 GJK 分离情况下的最近点。它可以用于 margin 判断、提前碰撞预测等。
-
Simplex 退化时重新取一个随机方向重启 GJK是一个有效的兜底策略。
总结
| 问题 | 答案 |
|---|---|
| GJK 解决了什么 | 判断两个凸体是否相交;不相交时求最近距离 |
| 关键抽象 | Minkowski Difference:相交 ⇔ 原点在其中 |
| 计算手段 | 支撑函数按方向采样极值点,逐步构造 simplex |
| GJK 核心循环 | 取最近点 → 沿 -p 方向 support → Johnson 收缩判断 |
| EPA 解决了什么 | 已知相交,求穿透深度和碰撞法线 |
| EPA 核心循环 | 找最近面 → 沿法线 support → 删面补面 → 收敛 |
| 精度保障 | ε 容差、最大迭代次数、退化检测 |
| 非凸体处理 | 需要先做凸分解 或 SDF |