想象一座完全漆黑的圆形舞池。两位舞者正在里面高速旋转,主办方站在场边,手里只有一支探照灯,必须判断他们是否撞在了一起。这支灯往任意方向一扫,就能找到某人身上最靠近光柱的那个亮点;再把两个人的“影子”在脑中重叠,便得到了一张能回答“是否相撞”的神秘图像。这就是 GJK 与 EPA 的故事——游戏物理引擎每帧都在执行的“黑暗验身术”。本文会带你从零搭建这套直觉:先用探照灯认识支撑函数,再学会把两个形状折叠成一张影子图,然后看 GJK 如何用三五个探针就断定碰撞,最后用 EPA 量出把两人分开所需的最小一步。全程只用一个比喻,不堆砌公式。
对于 AI 工程师而言,碰撞检测是物理模拟、机器人操作与空间智能的“底层触觉”。读懂 GJK/EPA,你才能为智能体训练、数字孪生与具身交互写出真正可信的物理反馈——这是从“会调接口”走向“懂引擎”的必修课。
可视化演示
展示GJK单纯形迭代逼近原点判定碰撞、支撑函数沿方向取凸体最远点、Minkowski差集包含原点判定相交及EPA扩展多面体计算穿透深度方向,含六边网格背景动画。
本节配有交互动效(进入视口后自动加载,约 3KB)。
碰撞检测基础
想象一座完全漆黑的圆形舞池,上百位舞者同时旋转。主办方不可能让每对人都停下互相“验身”——那会让整场舞会瘫痪。聪明的做法是先看座位表:只有座位相邻的几对才可能撞上,其余的连看都不用看。这套“先分区域、再细查”的思路,正是物理引擎碰撞检测的灵魂。
游戏物理引擎面对同样的窘境:一帧只有约 16 毫秒,却要处理上百个刚体。若对每一对都做精确求交,帧率会立刻崩溃。于是检测被拆成两个阶段——宽相位先用球或 AABB 包围盒快速排除明显不相邻的对象,窄相位再对留下的少数候选对做精确判定。我们今天的主角 GJK 与 EPA,就属于窄相位。
这里有一个重要前提:GJK 只对凸体严格有效。凸体就像饱满的糖球,任意两点连线都留在内部;而凹体(如马蹄形)会让“最远点探测”产生歧义。游戏中的凹地形通常先做凸分解,拆成若干凸块再逐对测试。每秒要检查的候选对数量可以直接估算:
回到舞池:宽相位像翻座位表,窄相位才是两位舞者真正面对面“验身”的那一刻。GJK 正是那位一丝不苟、又极其节俭的验身官——他不碰任何一盏灯之外的东西,却总能在最短时间内给出答案。
- 帧率 f每秒渲染的画面帧数,游戏通常为 60,代表一秒钟内要检查 60 轮碰撞。
- 配对总数n 个刚体两两组合成候选对,数量是组合数公式。
- 每秒成本两者相乘即每秒碰撞查询总量,由此看到暴力检测的爆炸式增长。
支撑函数
裁判站在舞池中央,手里唯一的工具是一支探照灯。他朝任意方向照去,光柱最先照亮舞者身上的那个亮点,就是“该方向上的支撑点”。奇妙的是,只要不断变换光照方向,把这些亮点连起来,就能完整描出这位舞者的轮廓——在黑暗中,探照灯就是裁判认识世界的全部方式。
数学上,支撑函数就是上面这个动作的精确化:给定方向向量,返回形状上在该方向投影最大的顶点。对一个凸多边形或凸多面体而言,任意方向的支撑点就能唯一确定它的形状,因此凸体可以完全用支撑函数来描述,无需保存全部顶点信息。
支撑函数还有一个极其优雅的组合性质:如果形状由多个子体组成,整体在某方向的支撑点可以通过子体的支撑点快速合并得到;而两个形状的闵可夫斯基和的支撑点,等于各自支撑点之和。这个性质让 GJK 每次查询只需要计算两次支撑函数,成本极低。它的定义如下:
裁判只要会“打光”,就能在黑暗中摸清任何一位舞者的身形。支撑函数是整个 GJK/EPA 大厦的地基——没有它,后面所有精妙算法都无从谈起。
- 单位方向向量探照灯照射的方向,长度为 1,保证投影结果不受光强影响。
- 点积投影顶点 p 在方向 d 上的投影长度,衡量“这个点有多靠前”。
- argmax 选取遍历形状 A 的所有顶点,取投影最大者作为支撑点。
闵可夫斯基差
光知道两个人的身形还不够,裁判还需要一个统一标尺来回答“是否相撞”。他想出一个妙招:在脑海中把两张剪影叠印——甲方身上的每个点,减去乙方身上的每个点,得到一张全新的“双人影子图”。这张影子图浓缩了两人的全部相对位置关系。
这张影子图就是闵可夫斯基差。它的惊人之处在于:只要这张影子图覆盖了舞台中央的原点,就意味着两人能恰好占据同一个位置——也就是相撞了。反之,如果原点在影子图之外,两人即使贴身也还差着一点距离。
于是,一个复杂的“两个形状是否相交”问题,被转化为一个简单得多的问题:“原点是否落在一个区域内”。GJK 不需要真的找出交点坐标,它只需要判断包含关系,这就大大降低了对几何细节的要求。形式化表述如下:
有了这张影子图,裁判不再需要同时盯着两个人——他只要盯住一个点。空间相交这一难题被浓缩成“点在区域内吗”的简单提问,一切豁然开朗。
- 差集构造对 A 中每个点 a 与 B 中每个点 b 做减法,得到影子图上的一个点。
- 原点含义坐标原点代表“两人位置完全相同”这一碰撞状态。
- 等价判定差集是否包含原点,与两形状是否相交完全等价。
GJK核心迭代
影子图虽然清晰,但覆盖范围可能极其巨大且形状复杂,直接判断原点是否在内部仍然昂贵。裁判改用“收网”策略:先朝一个方向打光取一个支撑点,再朝能逼近原点的方向打光取下一个点,把取到的点连成一个不断收紧的绳圈——这个绳圈在数学上叫单纯形。
每一次迭代,裁判都做三件事:从当前单纯形上找到离原点最近的点,把探照方向设为这个最近点的反方向,然后沿新方向取一个支撑点加入单纯形。若新支撑点出现在原点更远的一侧(满足终止条件),说明影子图根本够不到原点,两人不可能相撞;若单纯形成功把原点包围在内部,则判定碰撞成立。
这个迭代过程保证收敛:每一轮单纯形到原点的距离都在单调减小,而单纯形的顶点组合是有限的,因此必然终止。实际工程中,得益于初始方向选得好,平均只需要 2 到 4 次迭代就能出结果。核心更新式如下:
渔夫收网——每一次只问一个问题,用极少的探针就能断定相撞与否。这就是 GJK 优雅与高效并存的秘密:不问“你们怎么撞的”,只问“你们撞没撞”。
- 单纯形凸包当前所有支撑点围成的凸包,在 2D 中是三角形,3D 中是四面体。
- 最近点凸包上距离原点最近的点,代表当前绳圈与碰撞状态的最小距离。
- 取反方向新探照方向指向最近点的反方向,推动绳圈朝原点收拢。
EPA穿透深度
确认相撞之后,裁判的任务还没结束:他必须把两位舞者分开,而且要知道往哪个方向推、推多远,两人才能刚好脱开。GJK 只回答“撞没撞”,而“撞得多深、怎么推开”是 EPA 的职责——它是 GJK 的续集。
EPA 从 GJK 留下的那个包含原点的三角形出发,不断扩展影子图的边界。具体做法是:找出当前多边形上距离原点最近的边,沿这条边的外法线方向打光取一个新支撑点,把它插入多边形,如此循环。每扩展一次,多边形就离“理想影子图”更近一分,直到深度变化小于容差,迭代收敛。
最终输出的是一个向量:最小穿透向量(MTV)。它的方向就是分离方向,长度就是穿透深度。物理引擎拿到 MTV 后,可以立刻修正刚体位置并生成碰撞响应。用公式表达,穿透深度就是影子图边界到原点的最短距离:
就像在黑暗中为缠绕的舞伴找准那个“最小的分开动作”——方向错一点会推倒别人,深度少一分两人还贴着彼此。EPA 让这一推恰到好处。
- 边集闵可夫斯基多边形上已经扩展出的所有边,代表影子图的候选边界。
- 点到边距离原点到每条边所在直线的垂直距离,衡量这条边离碰撞状态多重有多近。
- 取最小值最短距离即穿透深度,对应的法线方向即分离方向。
工程优化实践
再聪明的裁判也架不住舞池里同时涌进上万对舞者。真正的舞会调度是分层艺术:先用宽相位把明显隔开的人流筛掉,再对余下的小批量候选对请出 GJK 与 EPA。这也是物理引擎的标准流程——GJK 从不孤军奋战。
第二个优化诀窍是“热启动”:相邻两帧之间,刚体的运动通常很小,上一帧的单纯形和探照方向几乎仍然有效。直接复用这些中间结果,GJK 平均能省掉一半的迭代次数;EPA 也可以从上一帧的 MTV 方向起步,快速收敛到新的穿透状态。
第三个关键是数值稳定性与预算控制。浮点误差会让支撑点计算产生微小偏差,工程上普遍使用容差 epsilon 和最大迭代次数双重保护;对凹地形预先做凸分解,对静态场景预计算 BVH 层级结构。整体成本可以这样估算:
金牌裁判的秘诀从来不是力气大,而是“该查的才查、能省的全省”。宽窄结合、增量复用、容差保护——这三招足以让舞池在万千舞者中依然行云流水。
- 候选对数 N宽相位筛选后进入窄相位的刚体对数量,是最大的成本杠杆。
- 迭代次数 IGJK 平均迭代次数,靠 warm start 可压到 2 至 4 次。
- 支撑代价单次支撑函数查询的花费,与两形状的顶点数及组合方式相关。
AI 应用场景
游戏物理引擎的刚体接触响应
阶段:窄相位碰撞检测与物理求解
解决的问题:传统做法用包围盒近似碰撞,物体贴身时会出现“假碰”或“穿模”;AI 驱动的物理模拟需要可靠接触信息来训练策略。
具体案例:Box2D、Bullet 等引擎中,坦克碾过箱子、角色踩上台阶这类场景,引擎用 GJK 判定凸多边形是否相交,再用 EPA 求出最小穿透向量,交给求解器生成冲量与摩擦,保证物体既不穿模也不弹飞。
机器人运动规划与避障
阶段:运动规划与阻抗控制
解决的问题:机械臂在狭小空间作业时,必须实时知道各连杆与障碍物的最短分离距离;传统网格求交慢且不稳定。
具体案例:六自由度机械臂把每段连杆近似为凸包,用 GJK/EPA 每毫秒查询一次与工作台、墙壁的穿透深度和方向,规划器据此调整路径,实现安全避障与柔顺力控。
VR 手势交互与抓取判定
阶段:交互层的碰撞判定与反馈
解决的问题:VR 中手部模型需要高帧率判定与物体是否接触,并实时给出“推离量”防止手指陷入物体。
具体案例:Quest 手柄的指尖模型用凸体 GJK 判定与桌面、水杯的碰撞,EPA 输出 MTV 后驱动震动反馈和抓取判定,让用户感觉“物有所触”。
Python 演示
import math class Vec2: def __init__(self, x=0.0, y=0.0): self.x = x self.y = y def __add__(self, o): return Vec2(self.x + o.x, self.y + o.y) def __sub__(self, o): return Vec2(self.x - o.x, self.y - o.y) def __mul__(self, k): return Vec2(self.x * k, self.y * k) __rmul__ = __mul__ def dot(self, o): return self.x * o.x + self.y * o.y def cross(self, o): return self.x * o.y - self.y * o.x def norm(self): d = math.hypot(self.x, self.y) return Vec2(self.x / d, self.y / d) if d > 1e-12 else Vec2() def support(shape, d): best = shape[0] score = best.dot(d) for p in shape[1:]: s = p.dot(d) if s > score: score, best = s, p return best def minkowski_support(a, b, d): return support(a, d) - support(b, Vec2(-d.x, -d.y)) def perp_toward(edge, target): pl = Vec2(-edge.y, edge.x) pr = Vec2(edge.y, -edge.x) return pl if pl.dot(target) > 0 else pr def outward(edge, interior): pl = Vec2(-edge.y, edge.x) pr = Vec2(edge.y, -edge.x) return pl if pl.dot(interior) < 0 else pr def gjk(a, b): d = Vec2(1.0, 0.0) simplex = [minkowski_support(a, b, d)] d = simplex[0] * -1.0 for _ in range(32): p = minkowski_support(a, b, d) if p.dot(d) < 0.0: return False, simplex simplex.insert(0, p) if len(simplex) == 2: edge = simplex[1] - simplex[0] ao = simplex[0] * -1.0 d = perp_toward(edge, ao) if abs(d.dot(ao)) < 1e-12: return False, simplex else: a0, b0, c0 = simplex ab, ac = b0 - a0, c0 - a0 ao = a0 * -1.0 nab = outward(ab, ac) if nab.dot(ao) > 0.0: simplex.pop(2) d = nab else: nac = outward(ac, ab) if nac.dot(ao) > 0.0: simplex.pop(1) d = nac else: return True, simplex return False, simplex def epa(a, b, simplex): poly = list(simplex) while len(poly) < 3: poly.append(minkowski_support(a, b, Vec2(1.0, 0.0))) for _ in range(32): n = len(poly) min_d = float('inf') min_n = Vec2() min_i = 0 for i in range(n): j = (i + 1) % n e = poly[j] - poly[i] nrm = Vec2(e.y, -e.x) if nrm.dot(poly[i]) < 0.0: nrm = nrm * -1.0 nd = nrm.dot(poly[i]) / math.hypot(nrm.x, nrm.y) if nd < min_d: min_d, min_n, min_i = nd, nrm.norm(), i p = minkowski_support(a, b, min_n) if p.dot(min_n) - min_d < 1e-5: return min_n * min_d poly.insert(min_i + 1, p) return min_n * min_d def main(): tri = [Vec2(-1.2, -0.8), Vec2(1.4, -0.6), Vec2(-0.2, 1.5)] sq = [Vec2(-0.8, -0.9), Vec2(1.0, -0.9), Vec2(1.0, 0.7), Vec2(-0.8, 0.7)] hit, simplex = gjk(tri, sq) print('gjk hit:', hit, 'simplex size:', len(simplex)) if hit: mtv = epa(tri, sq, simplex) print('mtv:', round(mtv.x, 4), round(mtv.y, 4)) moved = [p + mtv for p in sq] print('collision after separation:', gjk(tri, moved)[0]) if __name__ == '__main__': main()
def support(shape, d)支撑函数:遍历顶点,返回方向 d 上投影最大的点,对应文中的探照灯。def minkowski_support(a, b, d)在闵可夫斯基差空间中取支撑点,一次调用产生一个影子图顶点。def outward(edge, interior)计算边的外法线方向,用于判断原点在单纯形的哪一侧。if p.dot(d) < 0.0终止条件:新支撑点无法越过原点,直接判定不相交。simplex.insert(0, p)新支撑点插入最前,单纯形逐步收网逼近原点。if nab.dot(ao) > 0.0原点在边 AB 外侧,剔除最旧顶点 C 并切换方向。else: return True, simplex原点被三角形包围,碰撞成立。epa(a, b, simplex)从 GJK 的三角形出发,循环找最近边、取新支撑点、扩展多边形。if p.dot(min_n) - min_d < 1e-5深度收敛,返回最小穿透向量。
常见误区
误区我跑完 GJK 拿到返回结果,以为它就是碰撞点,直接拿去做特效、音效与伤害判定。
正解GJK 只回答“是/否相交”,不产生任何碰撞点。真正的接触信息需要 EPA 求出穿透深度与方向后,再由接触裁剪(contact clipping)算法在边界上生成接触流形。GJK 是“验身官”,不是“法医”。
为何常见:许多教程只讲 GJK 的判定过程,却很少交代工程中接触点由另一套流程产出,两者经常被混为一谈。
误区游戏里很多地形是凹的,我把所有顶点丢给 GJK,以为它一定能判断正确。
正解GJK 依赖凸体的支撑性质与闵可夫斯基差;凹体的“某方向最远点”无法唯一确定形状,判定结果会出错。必须先把凹体分解成若干凸体(凸分解),再对每个凸对分别测试。
为何常见:支撑函数对凹体也能算出“最远点”,让人误以为算法依然成立,但闵可夫斯基差的等价性在凹体上不成立。
误区我把 EPA 的迭代上限设得很大,容差设得极小,以为穿透深度越精确越好,结果帧率暴跌。
正解物理引擎只需要近似 MTV 就足以把物体分开,容差在 1e-3 量级已能肉眼无感,配合最大迭代次数保护实时性。过高的“精确”换来的只是小数点后五六位的差异,却会拖垮整个模拟。
为何常见:误把“数值精确”当“系统正确”,忽略了实时物理系统每一毫秒都极其宝贵的算力预算。
误区多边形有几百个顶点,每次支撑查询都要遍历所有顶点,GJK 应该很慢。
正解支撑查询是 O(n),但 GJK 平均只有 2 到 4 次迭代;对球体、盒子等基础体还有解析公式,对组合体可按子体支撑合并。再叠加宽相位过滤,实际开销远小于暴力检测。
为何常见:只盯着单次查询的复杂度,忽略了迭代次数极少与宽相位大幅削减候选对这两个决定性事实。
面试题
Q1. GJK 算法主要服务于碰撞检测的哪个阶段?
- A.宽相位粗筛阶段
- B.窄相位精确判断← 正确答案
- C.渲染裁剪阶段
- D.光照烘焙阶段
答案:B。宽相位用包围盒或空间哈希快速排除明显不相邻的对象,GJK 处理的是筛选后剩余的少数候选对,属于窄相位精确判定。
Q2. 支撑函数 S_A(d) 返回的是形状 A 的哪个顶点?
- A.在方向 d 上投影最大的点← 正确答案
- B.在方向 d 上投影最小的点
- C.距离质心最近的顶点
- D.随机选取的一个顶点
答案:A。支撑函数沿方向 d 求投影最大者,正如探照灯最先照亮的亮点;投影最小点、质心最近点都与定义无关。
Q3. 两凸体 A、B 相交的充要条件是?
- A.原点位于 A∪B 中
- B.原点位于 A⊖B 中← 正确答案
- C.原点位于 A∩B 中
- D.原点位于 A+B 中
答案:B。A⊖B 是闵可夫斯基差,即影子图;影子图覆盖原点等价于两形状存在同一点,因此相交充要条件是原点落在差集中。
Q4. 二维空间中,GJK 判定碰撞时单纯形需要满足?
- A.三角形包含原点← 正确答案
- B.线段包住原点
- C.两点完全重合
- D.四边形包含原点
答案:A。2D 单纯形最高维是三角形,当三角形包含原点时判定碰撞;线段只能说明原点在一条边上,不构成内部包含。
Q5. EPA 算法最终输出的 MTV 是什么?
- A.两个形状的交点坐标
- B.物体表面的法线贴图
- C.最小分离方向与深度← 正确答案
- D.形状包围盒的对角线
答案:C。MTV 即最小穿透向量,方向表示分离方向、长度表示穿透深度,是物理引擎推离刚体的直接依据。
Q6. GJK 每次迭代的探照方向如何确定?
- A.单纯形上最近点取反方向← 正确答案
- B.随机生成一个新的方向
- C.固定沿 x 轴正方向
- D.与上次方向保持正交
答案:A。方向取当前单纯形上离原点最近点的反方向,能保证收敛并推动单纯形逐步包围原点;随机或固定方向都无法保证逼近。
Q7. 新支撑点 p 满足 p·d<0 意味着什么?
- A.两形状已经发生穿透
- B.单纯形发生退化异常
- C.沿 d 无法逼近原点即不相交← 正确答案
- D.需要立刻切换到 EPA
答案:C。p·d<0 表示沿方向 d 连支撑点都投影在原点之后,说明影子图够不到原点,可直接判定不相交。
Q8. 下列哪些方法适合宽相位粗筛?
- A.包围球快速距离剔除← 正确答案
- B.AABB 包围盒层级树← 正确答案
- C.对每对刚体跑 GJK
- D.空间哈希网格分桶← 正确答案
答案:ABD。宽相位追求“快而糙”,包围球、AABB 层级树、空间哈希都能快速排除远距离对;GJK 是精确窄相位算法,放在宽相位属于杀鸡用牛刀。
Q9. 以下哪些能有效加速 GJK 判定?
- A.复用上一帧单纯形热启动← 正确答案
- B.凹体先做凸分解预处理← 正确答案
- C.取消终止条件无限迭代
- D.先用包围体粗剔除非邻近对← 正确答案
答案:ABD。热启动减少迭代次数,凸分解让 GJK 适用于凹地形,包围体剔除减少候选对;取消终止条件会导致死循环而非加速。
Q10. 关于 EPA 的说法正确的是?
- A.必须以 GJK 确认碰撞为前提← 正确答案
- B.持续扩展边直到深度收敛← 正确答案
- C.可以无限迭代保证精确
- D.输出用于刚体位置修正← 正确答案
答案:ABD。EPA 从 GJK 留下的含原点三角形出发,扩展最近边直至收敛,输出 MTV 用于修正刚体位置;无限迭代违背实时性,必须用容差与上限保护。