站点链接:技术博客页 | 公众号:溯光的图形笔记
Somme_de_Minkowski_(2).jpg(CC BY-SA 3.0,Jean-Luc W)上一篇把「谁能撞谁」讲完了:宽相层、对象层、碰撞组、六级漏斗,写错一级的代价是「先做完活再扔掉」。可那一篇从头到尾都在处理配对,从来没有回答过另一个问题:两个体既然被允许碰,那它们到底碰上了没有、碰得有多深、接触面在哪。
这两件事是分开的。宽相只会说「这两个包围盒重叠了,可能相碰」;把「可能」变成「确实」的活,交给窄相(narrow phase)。而它交出来的东西也不止一个布尔值:它给的是一组接触点。
OK,先把这一层要回答的问题列出来。它有三问:碰没碰(GJK 的判定)、嵌多深(EPA 的穿透深度)、接触面长什么样(流形裁剪)。这一篇就拆这三件事,外加它们之间那条最容易被讲错的缝。
系列文章写作风格说明
- 术语双语:首次出现的核心术语写成「中文(English)」,后文可只用英文;
- 三层分开说:官方文档怎么写、源码怎么实现、本机跑出来是什么,这三层在文中分开陈述,不混成一句;
- 读数标口径:每一个数字都标来源与条件(版本、场景、规模、编译选项、日期);
- 自跑优先:本专题的图以本机编译运行 Jolt 得到的真图与官方一手图为主,自绘为辅;
- 不写没有落点的说法:凡是官方文档与源码里找不到对应命名的说法,文中会明确指出查无落点;
- 本篇的知识地图:三条主线贯穿全篇,锁的代价(窄相的第一件事是把锁放掉)、 两个算法的分工(GJK 判距离与相交,EPA 只在核心真重叠时才跑)、 缓存的代价(两体相对姿态没变就不需要窄相)。三条线各自的落点见 §0.4;
- 本篇不涉及:宽相树的结构与退化(第四篇)、过滤与层的配置(第五篇)、形状谱系与
SubShapeID的路径编码(第七篇)、约束家族与求解迭代(第八、九篇)、岛与睡眠(第十篇)、 连续碰撞检测(第十一篇)、GPU 宽相(Jolt 没有这条路)。
入口:宽相只说「可能」,窄相说「多深」
好了,先说两个症状。它们看起来毫不相干,根因却都在窄相的输出上。
// 症状 A:两个盒子明明贴上了,人却站在上面打滑、抖个不停
// → 接触点太少(只报 1 个点),或者接触法线方向不对
bodyA.mAllowSleeping = false; // 抖到睡不着
// 症状 B:一个薄薄的盒子从另一个盒子上穿了过去
// → 不是过滤的问题,是窄相根本没报出「已经嵌进去了」
// → 常见成因:两体的「核心」还没重叠,只有凸半径的垫层贴上了
settings.mMotionQuality = EMotionQuality::LinearCast; // 想靠 CCD 兜住,但成因在别处
A 是「碰了但没报对」,B 是「该报没报」。 两者的成因都在同一处:窄相交出来的不是「碰/不碰」这一个布尔值, 而是一组接触点(接触点个数、每个点的位置、以及一个共同的接触法线)。
OK,把这一层放在整条链上看,它的位置很清楚:
宽相(broad phase)负责把「一万个体的四千九百万对」砍到几百对;窄相负责把这几百对里真正接触的那些, 算成一组接触点;再往下,这些接触点才被建成接触约束、算出 Jacobian 与有效质量,进入求解。 本篇只走中间那一段。
0.1 两类碰撞,与它们背后的配置空间障碍
先来看看窄相这一层最朴素的直觉:两个形状「叠在一起了没有」。但凸体之间的最近特征只有两类: 一类是点对面(face-vertex),一类是边对边(edge-edge)。3D 里不存在第三种:
Two_types_of_collisions_and_corresponding_CSO_faces.svg(Public domain,NeD80)这张图上的「配置空间障碍」(configuration space obstacle,CSO)是理解 GJK 与 EPA 的钥匙: 把 B 关于原点做中心对称变换,再和 A 做闵可夫斯基和,得到一个新集合 A−B。 「A 与 B 相交」等价于「原点落在 A−B 里面」。于是「两个形状有没有碰上」这个几何问题, 被换成了一个更简单的问题:原点在不在一个凸集里面。
0.2 本篇的边界(本篇不涉及什么、为什么)
说白了,边界写两条。第一条是「上游已经给过什么」:候选配对怎么来的、树是怎么长的、层是怎么配的,
这三件事第四、五篇写完了,本篇直接从 NarrowPhaseQuery 的入口接手。
第二条是「下游留给谁」:接触流形怎么变成约束、迭几轮、怎么 warm start,那是第八、九篇的账;
本篇到「流形交给 ContactConstraintManager」为止。
0.3 本机环境与读数口径
OK,先把读数口径交代清楚。本篇所有本机读数来自同一台机器、同一份源码、同一次构建,口径如下:
| 项 | 值 |
|---|---|
| CPU | 16 逻辑核(8 物理核 + 超线程) |
| 编译器 | MSVC 19.44(VS 2022 BuildTools),/O2 /MT /std:c++17 |
| Jolt 版本 | v5.6.0(本地快照 v5.5.0-183-g765845d6,与 v5.6.0 tag 之间只隔 2 个提交) |
| 编译宏 | JPH_TRACK_NARROWPHASE_STATS(官方窄相计时设施)+ JPH_BENCH_EP06_COUNTERS(本篇自加计数器) |
| 物理步长 | 1/60 s |
| 基准程序 | evidence/bench_narrow.cpp(六组实验),原始输出 evidence/bench_narrow_run_20261003.txt |
| 官方 CSV | evidence/narrow_phase_stats.csv(47 行,由 NarrowPhaseStat::sReportStats() 直接产出) |
关于版本,这里要补一句必须说清的话。 本地源码仓库的 Jolt/Core/Core.h 第 8 至 10 行写的版本宏是
5.5.1,git describe 是 v5.5.0-183-g765845d6(HEAD 提交日期 2026-07-03),而本系列一直写 v5.6.0。
本次核清了:v5.6.0 的 tag 确实存在(commit e77f1755),本地 HEAD 是它的祖先,
两者之间只隔 2 个提交,而这 2 个提交只动了 4 个文件
(Build/CMakeLists.txt、Docs/APIChanges.md、Docs/ReleaseNotes.md、Jolt/Core/Core.h),
没有任何源码文件变化,两个文档文件还是净 0 行位移。所以本系列所引的 v5.6.0 行号与内容全部有效,
差异只在版本宏本身。
两处方法论坑也一并登记(都是本轮先量出异常、再回源码定位后修掉的):
- 接触回调是多线程的。
OnContactAdded/OnContactPersisted在 job 线程上并发调用, 计数器不用原子量时,会量出「点数直方图之和 > 流形总数」这种自相矛盾的结果。第一版基准就踩了这个坑。 - 不关休眠就量不到稳态代价。体在约 0.5 秒后入睡,第一版基准的「稳态每步 0.001 毫秒」就是这么来的,
量到的是「体已入睡、什么都没做」。本篇所有耗时实验都设
mAllowSleeping = false。
0.4 本篇的知识地图:三条主线
言归正传,三条主线分别来看。主线一,锁的代价。 窄相的第一件事不是算法,是先把锁放掉。官方的写法是:先查宽相缩小范围,
在 body lock 的保护下只做一件事(造一个 TransformedShape 快照),之后整段计算都在锁外做。
这条线回答「为什么窄相能并行」。
主线二,两个算法的分工。 GJK 负责判相交与算最近点,它的主循环没有迭代上限, 只有四类收敛判据;EPA 只在两个形状的核心(减掉凸半径之后)真的互相重叠时才跑, 而且它输出的是穿透深度与方向,不是接触点。接触点是随后用两个支撑面互相裁剪出来的。 这条线回答「穿透深度是算出来的还是裁出来的」(两者都占:深度靠 EPA,接触点靠裁剪)。
主线三,缓存的代价。 Jolt 有一个体对缓存:两个体的相对姿态没变时,整段窄相被跳过。
官方注释原文是 removes the need for narrow phase collision detection when orientation between
two bodies didn't change。这条线回答「稳态为什么不花窄相的钱」。
0.5 本机实验设计:六组
下面六组实验都只改一个变量,其余全部固定:同一台机器、同一份源码、同一次构建、同一步长。这样做的目的是让每个读数都能被指到唯一的原因上。比如「凸半径改了会怎样」那一组,除了凸半径之外其他一切都不动。每一组都给出复现命令,原始输出落在 evidence/ 下。
| 实验 | 做法 | 量什么 |
|---|---|---|
| E0 | 七种静态形状 × 五种动态形状真实相撞,再补 3000 条射线与 400 次形状扫掠;打开官方 JPH_TRACK_NARROWPHASE_STATS |
逐形状对的窄相耗时矩阵(官方口径,含 mChildTicks) |
| E1a | 125 个箱子落成 5 层堆(面-面接触),凸半径 0 → 0.2 共 5 档 | 稳态每步耗时 |
| E1b | 同一个箱子以 45° 姿态落下(边-面接触),凸半径 0 → 0.2 共 5 档 | 最终倾角、最终高度、水平位移 |
| E2 / E3 | 144 个箱子的 4 层堆,下落期(前 120 步)与稳态(后 300 步)分开量;再单独跑 2000 次 CollideShape 探针 |
GJK 的四类出口分布、每次查询的迭代数、EPA 触发率与迭代数 |
| C | 同一场景、同样 420 步,体对缓存开一次关一次 | GJK 调用数与总耗时 |
| E4 | 球堆与箱堆各 100 个,各跑 150 步,用 ContactListener 统计每个流形的点数 |
流形点数分布 |
这两条症状各配一个排查入口。症状 A(打滑、抖动)先看接触流形报了几个点:球只有 1 个点、箱子应当有 4 个点(§5.3 给了这两类形状的实测分布),点太少求解器就稳不住;症状 B(穿透)先看 mMotionQuality 是不是 Discrete,再看两体的核心有没有真的重叠(§4.2 那三道门)。本机把这两条都做成了可复现的配置,命令行同上一张表。
0.6 先说清三处「查无落点」
说白了,按本系列的惯例,凡是在官方材料里找不到对应命名的说法,都要在正文里点出来。本篇有三处:
Jolt/Physics/Collision/NarrowPhase/这个目录不存在。 实测:Collision/下一级子目录 只有BroadPhase/与Shape/两个(该目录一级共 54 个文件);全仓NarrowPhase*文件仅 4 个 (NarrowPhaseQuery.cpp/.h、NarrowPhaseStats.cpp/.h),全部平铺在一级。 算法本体住在Jolt/Geometry(27 个文件)。按目录找窄相的人会找不到。 这条的定义不在文档里,就在目录结构本身(Jolt/Physics/Collision目录)。- 「GJK 只能判相交、要深度必须上 EPA」这句话,在 Jolt 里对不上一半。
GJKClosestPoint.h同时提供Intersects()(只判相交)与GetClosestPoints()(返回平方距离与两侧最近点); 而真正决定「要不要上 EPA」的,是GetPenetrationDepthStepGJK返回的三态之一Indeterminate(见 §4.2)。 - 「4 点接触流形」是通用引擎的做法,不是 Jolt 的硬上限。
Jolt 的接触点是
using ContactPoints = StaticArray<Vec3, 64>,容量是 64; 实际报几个点由裁剪结果决定,本机量出来的是球堆 1 点、箱堆 4 点(见 §5.3)。
一、锁的代价:先把锁放掉
OK,窄相这一层最容易被忽略的一件事,是它在一开始就把「并发」这件事处理掉了。官方文档里
## Narrow Phase 这一节只有 8 行,而这 8 行里有一半在讲怎么把锁的范围缩到最小。
1.1 窄相查询的第一步不是算法
官方原文(Docs/Architecture.md 第 597 行)说得非常直白:一次窄相查询会先查宽相拿到相交的体,
然后在 body lock 的保护下构造一个 TransformedShape 对象。这个对象装三样东西:
变换、一个引用计数的形状、一个 BodyID。因为形状在你销毁这个 TransformedShape 之前不会被删掉,
所以它是一份一致的快照。官方紧接着给了这做法的目的:保证体只被锁住很短一段时间,
让碰撞检测的主要工作在锁外完成。这条设计原则的原理是把必须串行的那一段压到最短,原文见 Architecture.md 第 595 至 602 行。
说白了,整节只有 8 行,说明算法本身官方没打算在文档里讲。这是本篇必须读源码的原因。
1.2 官方真正讲接触点的是另一节
那接触点从哪来?先看看官方把它写在哪儿:## Find Collisions(第 944 至 964 行,属「The Simulation Step in Detail」)。
第 957 行的原文是:两体之间的接触点由 GJK 与 EPA 决定;对每个接触点会算出它所属的那个面;
两个体的面互相裁剪(ManifoldBetweenTwoFaces),得到一个多边形(或者点、线)来表示两体之间的接触,
也就是接触流形(contact manifold)。
紧接着第 959 行说:法线相似的多个流形会被合并(在 PhysicsSystem::ProcessBodyPair 的
ReductionCollideShapeCollector 里),合并之后才在 ContactConstraintManager 里建接触约束、
算 Jacobian 与有效质量。第 963 行说窄相用了一个无锁的接触缓存:一读一写两个 cache,
上一步被保留下来的接触点会从读缓存复制进写缓存。
这三句话就是本篇的骨架:判定 → 裁剪 → 缓存。
1.3 锁的三段作用域
OK,把 §1.1 那句话展开成一张图,锁的作用域是三段:
这也解释了另一件在 ep04 里已经见过的事:为什么这一层可以吃满多核。 宽相那边靠「放宽父节点路径」做到无锁;窄相这边靠「先取快照、再放锁」, 两者共同的思路是把必须串行的那一小段压到最短。
官方还给了一条与长任务有关的说明(第 599 行):对跑很久的作业(它举的例子是导航网格生成),
可以先把一个区域里所有 TransformedShape 收集出来,再在长跑线程里处理,不需要额外加锁
(入口是 NarrowPhaseQuery::CollectTransformedShapes)。
// 窄相查询的三种入口(NarrowPhaseQuery.h 第 31 / 35 / 40 / 53 / 67 行)
bool CastRay(const RRayCast &inRay, RayCastResult &ioHit, ...) const; //【1】单命中射线
void CastRay(const RRayCast &inRay, const RayCastSettings &, CastRayCollector &, ...) const; //【2】多命中射线
void CollidePoint(RVec3Arg inPoint, CollidePointCollector &, ...) const; //【3】点是否在体内
void CollideShape(const Shape *, Vec3Arg inShapeScale, RMat44Arg inCenterOfMassTransform,
const CollideShapeSettings &, RVec3Arg inBaseOffset, CollideShapeCollector &, ...) const; //【4】静态形状查询
void CastShape(const RShapeCast &, const ShapeCastSettings &, RVec3Arg inBaseOffset,
CastShapeCollector &, ...) const; //【5】扫掠查询
下面进入这段代码的说明。五种入口在外面看是五个功能,在里面它们都走同一条路
(先宽相、再取快照、然后落到 CollisionDispatch::sCollideShapeVsShape 或它的 cast 版本),
区别只在收集器(collector)与设置项不同。这也是为什么本机实验里能用同一个基准程序
把「模拟期的窄相」与「查询期的窄相」分开量(见 §3.3)。
二、GJK:support 函数与闵可夫斯基差
好了,进算法。GJK 是三个人的姓氏缩写(Gilbert、Johnson、Keerthi),1988 年提出, 本机跑的是 Jolt 的实现版本,源码第一行就写明了出处。
2.1 support 函数:一个凸体只需要会回答一个问题
先来看看支持函数。凸体(convex shape)在窄相里被当成一个「支持函数提供者」。所谓支持函数(support function), 就是给它一个方向,它告诉你这个方向上最远的那个点:
$$s_A(\mathbf{v}) = \arg\max_{\mathbf{p} \in A} \mathbf{v} \cdot \mathbf{p}$$
这句话看着抽象,说白了很好想:拿一块橡皮,从一个方向把一块凸石头压到底,最先碰到桌面的那个点, 就是那个方向上的支持点。球的支持点是球心沿方向走半径;盒子的支持点是三个半长按方向取符号; 凸包的支持点是遍历所有顶点取点积最大者。 一个凸体在 GJK 里需要的全部能力,就只有这一个函数。
2.2 把「距离」变成「原点在不在里面」
OK,有了支持函数,就可以做那件关键的事:把两个形状的距离问题,转成单集合的问题。
构造 A − B(闵可夫斯基差):把 B 关于原点做中心对称(也就是取 −B),再和 A 做闵可夫斯基和。 这个新集合有一个漂亮的性质:「A 与 B 相交」等价于「原点在 A − B 内」。 更一般地,A 与 B 的距离等于原点到集合 A − B 的距离。
说白了,GJK 的整个策略变成:在 A − B 上迭代地构造一个尽量靠近原点的单纯形(simplex)。 单纯形在 3D 里依次是 1 个点、2 个点的线段、3 个点的三角形、4 个点的四面体。 每一轮取一个新支持点,更新单纯形,让它离原点更近;如果某轮发现原点已经在四面体里, 就判相交。
言归正传,关键在于:每一步只需要调用两侧的支持函数各一次(A 沿着 v,B 沿着 −v), 就能拿到 A − B 上的一个新点。这就是 GJK 便宜的来源:它从来不去枚举面或边, 只做「问方向、要极值点」这一件事。
2.3 GJK 的三个入口,与它挂在谁的名字下
OK,Jolt 的 GJKClosestPoint 对外有三个入口,分别对应三种不同的问法:有的只想知道「碰没碰」,有的想知道「离多远、两侧最近的点各在哪」,还有的想知道「一条射线打到哪儿」。三个入口共用同一套单纯形迭代,区别只在收敛判据的用法与返回值上。
| 入口 | 位置 | 回答什么 |
|---|---|---|
Intersects |
第 207 行 | 只回答「相交 / 不相交」 |
GetClosestPoints |
第 327 行 | 返回平方距离,以及两侧各自最近的点 |
CastRay |
第 516 行 | 一条射线打到凸体上的 λ 值 |
CastShape(两个重载) |
第 658 / 689 行 | 扫掠:从起点沿方向走到哪里碰上 |
先看看两个出处注解,值得抄下来:类声明占了三行(第 19 至 21 行),其中第 20 行写 Based on: A Fast and Robust GJK Implementation for Collision
Detection of Convex Objects - Gino van den Bergen;第 506 行写 Code based upon: Ray Casting against
General Convex Objects with Application to Continuous Collision Detection。同一位作者的两篇工作。
还有一个方便调试的出口:第 495 行 GetClosestPointsSimplex(outY, outP, outQ, outNumPoints),
把算法结束时手里的那个单纯形摊出来。EPA 正是从它接手(见 §4.1)。
2.4 GJK 找的就是分离轴
GJK 每轮迭代得到的那个向量 v,几何意义是「单纯形上离原点最近的点」。当它收敛时, v 就是一个分离轴(separating axis):这条轴上两个集合的投影不重叠,说明它们不相交。
这条线索把 GJK 和另一个常被拿来对比的算法连了起来:分离轴定理(separating axis theorem,SAT)。 两者的共同几何是「找一个轴,看投影有没有间隙」:
Separating_axis_theorem2008.png(Public domain,Oleg Alexandrov / Jon Watte)那什么时候用哪个? 权威依据是 Gino van den Bergen 在 GDC 2009 那场《Collision Detection Crash Course》里给的轴数表:盒子对盒子最坏要测 15 根轴(3 面 + 3 面 + 9 交叉边)。SAT 在轴数少且固定 的场合(2D 多边形、盒对盒)非常快,而且能直接给出最小平移向量;但形状一复杂(凸包顶点多), 候选轴的数量随特征数增长,成本就压不住了。GJK 的迭代次数与形状的特征数无关, 只与「离得多近、收敛多快」有关,这也是它成为 3D 通用窄相主流的原因。
不过要老实说一句:Jolt 官方并没有主张「GJK 全面优于 SAT」,它只是选了 GJK。这一段的取舍结论, 一半来自 vdB 的公开材料,一半来自社区横向对照,属于「社区归纳」而非官方口径。前一半的原始出处是 vdB 的 GDC 2009 slides,上面那张轴数表就是这套几何原理的依据。
三、GJK 的四个出口
OK,上一节说 GJK 是「迭代地构造单纯形」。那它迭代几次?这个问题在社区材料里答案五花八门 (三个常见说法是 3 次、5 次、「一般一两次」),但它们几乎全是从教学实现里抄来的经验值。 Jolt 的答案是:它根本没有迭代上限这个变量。
3.1 主循环是 for (;;),退出全靠判据
先看看源码。GetClosestPoints 的主循环写成 for (;;),没有任何计数器、也没有最大轮数:
for (;;)
{
Vec3 p = inA.GetSupport(ioV); //【1】A 沿 v 方向的支持点
Vec3 q = inB.GetSupport(-ioV); //【2】B 沿 -v 方向的支持点
Vec3 w = p - q; //【3】A - B 上的一个新点
float dot = ioV.Dot(w);
//【4】超过 inMaxDistSq 就提前退出(远到不值得算)
if (dot < 0.0f && dot * dot > v_len_sq * inMaxDistSq) return FLT_MAX;
uint32 set;
if (!GetClosest<true>(prev_v_len_sq, ioV, v_len_sq, set))
break; //【5】长度不再下降:没有更近的点
if (set == 0xf) { ioV = Vec3::sZero(); v_len_sq = 0.0f; break; } //【6】满单纯形
UpdatePointSetYPQ(set);
if (v_len_sq <= tolerance_sq) { ioV = Vec3::sZero(); v_len_sq = 0.0f; break; } //【7】距离归零
if (v_len_sq <= FLT_EPSILON * GetMaxYLengthSq()) { ... break; } //【8】机器精度
ioV = -ioV;
if (prev_v_len_sq - v_len_sq <= FLT_EPSILON * prev_v_len_sq)
break; //【9】收敛:v 是分离轴
prev_v_len_sq = v_len_sq;
}
下面对上面这段代码特别说明四点:
第一,for (;;) 是真的没有上限。这在一开始看着让人不安(万一对某个病态输入不收敛怎么办),
但四类判据里第【8】条就是为这件事兜底的:当 v 的长度小到机器精度量级,直接判相交返回。
第二,第【5】条那个 GetClosest<true> 的返回值判断里藏了一个细节,源码第 79 行专门写了注释:
比较写成 if (v_len_sq < inPrevVLenSq),比较顺序是重要的,因为如果 v_len_sq 是 NaN,
这个表达式为假,函数就会返回 false。这是用比较的写法来处理 NaN,而不是加一个 isnan 判断。
第三,第【6】条 set == 0xf 里的 0xf 是四个二进制位全 1,表示单纯形四个点都用上了。这四类判据的原理与官方实现逐一对应(GJKClosestPoint.h 第 353 至 470 行)。
原点落在四面体内部,就是相交。这条判据正是 EPA 的入口(见 §4.2 的恒等式)。
第四,第【4】条用的是 inMaxDistSq,而窄相调用它时传进来的是两侧凸半径之和的平方。
所以「太远」这件事在这一层是有明确阈值的,不是凭感觉。
3.2 四个出口的判定树
言归正传,把上面九条整理成判定树,就是 GJK 的全部「出口」:
三处判相交,一处判分离,一处提前退出。注意官方的 Trace 文案本身就是一份命名:
Full simplex、Distance zero、Machine precision reached、Converged。
本机实验里的计数器就是挂在这四个字符串对应的位置上,不改任何逻辑。
3.3 本机实测:四类出口各占多少
把 144 个箱子堆成 4 层,下落期与稳态分开量,再单独跑 2000 次 CollideShape 探针
(球,半径 0.45 m,绕着堆转圈)。结果如下:
| 工作负载 | GJK 进入 | 满单纯形 | 距离归零 | 机器精度 | 收敛分离 | 超 inMaxDistSq | 无更近点 |
|---|---|---|---|---|---|---|---|
| A1 下落期(前 120 步) | 480 | 0 | 0 | 0 | 26(5.4%) | 0 | 454(94.6%) |
| A2 稳态(后 300 步) | 0 | — | — | — | — | — | — |
| B 查询期(2000 次探针) | 2340 | 359(15.3%) | 0 | 1 | 43(1.8%) | 340(14.5%) | 1597(68.2%) |
说白了,有三件事值得停下来说。
第一,绝大多数 GJK 调用是在判「不相交」。 下落期 94.6% 的调用以「无更近点」结束, 意思是「我把最近点找到了,两个形状就是没碰上」。查询期也有 68.2%。 这说明窄相的工作量主体是排除,不在「算出深度」上。
第二,稳态那一格是 0。 体都停住之后,窄相一次都没被调用。这不是基准写错了, 是体对缓存在起作用(见 §6.1)。这一格是「零」这件事,本身就是本篇最重要的实测结论之一。
第三,超 inMaxDistSq 那一列只在查询期出现(340 次)。 因为探针绕着堆转圈,
大部分时候离得很远,GJK 在第一轮就凭「距离已经超过两侧凸半径之和」退出了。
这条判据省下的不是一点点,是整个迭代过程。
3.4 本机实测:每次查询平均跑几次
下面进入迭代数这一段,先看一组数:
| 工作负载 | 迭代合计 | 平均每次 | 单次最多 |
|---|---|---|---|
| A1 下落期 | 2265 | 4.719 | 25 |
| B 查询期 | 8041 | 3.436 | 25 |
| C 缓存 OFF(420 步) | 304890 | 5.158 | 25 |
平均 3 到 5 次,这个量级与 vdB 在 GDC 2009 上给的说法吻合:帧间相干性高时, 分离轴可以从上一帧继承,往往一次迭代就能确认。本机实测到的平均值 3 到 5 次, 正好落在「比一次多一点」这个区间里。
单次最多 25 次这个数要小心读:它是本机场景下观测到的最大值, 不是引擎的设定值(源码里没有这个变量)。它对应的是「两个形状擦着边、单纯形反复退化」这类情况。
// 复现命令(Windows,MSVC)
// 1) 建插桩库:官方 cmake 选项 TRACK_NARROWPHASE_STATS 打开,
// 再加本篇自加的 JPH_BENCH_EP06_COUNTERS
cmake -S <jolt>\Build -B <jolt>\Build\VS2022_CL_np -G "Visual Studio 17 2022" -A x64 ^
-DTRACK_NARROWPHASE_STATS=ON ^
-DCMAKE_CXX_FLAGS="/DWIN32 /D_WINDOWS /EHsc /DJPH_BENCH_EP06_COUNTERS /utf-8"
cmake --build <jolt>\Build\VS2022_CL_np --config Release --target Jolt -- /m
// 2) 编译并运行基准
pwsh -File build_bench.ps1 ; .\bench_narrow.exe 300
下面对上面这段命令特别说明:插桩只加了计数器,没有改任何判定逻辑。
bench_narrow.cpp 连同插桩头与补丁脚本都在 evidence/ 里,跑完会把 Jolt 源码树还原到与 HEAD 逐字节相同。
四、EPA:什么时候才跑
OK,上一篇结尾我留了一句预告,说「把候选变成接触点要过 GJK 与 EPA 两道算法」。这一节要把这句话修正得更准一点: GJK 与 EPA 不是两道都要过,EPA 是一道只有特定条件下才走的后门。
4.1 从单纯形到凸包
先看看 EPA 自己的做法。它的类注释(EPAPenetrationDepth.h 第 16 至 35 行)写得很完整,
连自己偏离论文的地方都承认了:
官方承认的那处偏离,值得原样抄一遍:The implementation of this algorithm does not completely
follow the article, instead of splitting triangles at each edge as in fig. 7 in the article, we build
a convex hull (removing any triangles that are facing the new point, thereby avoiding the problem of
getting really oblong triangles as mentioned in the article).
论文的做法是逐边切三角形,切久了会得到又细又长的三角形;Jolt 改成维护一个凸包,
每加一个新点就把朝向它的面删掉、再补洞。这是全系列里第一次见到官方在注释里直接说「我没照论文写」。这段注释本身就是最好的出处(EPAPenetrationDepth.h 第 16 至 35 行),它的原理是把「切三角形」换成「维护凸包」。
下面那串算法步骤同样可以直接改写成图:从 GJK 交给它的单纯形出发;如果单纯形只有 1 到 3 个点,
先补几个支撑点凑到至少 4 个;转成一个含原点、体积非零的凸包;然后循环:
对所有三角形求离原点最近的那个 → 在这个方向取一个新支撑点并入凸包 → 删掉朝向新点的面并补洞 →
回到上一步,直到找不到更近的点。最后注释给了一句总结:
The closest point indicates the position / direction of least penetration。
// EPA 的三态返回值与两段分工(EPAPenetrationDepth.h 第 85 / 104 / 149 / 487 行)
enum class EStatus { NotColliding, Colliding, Indeterminate }; //【1】三条出口,不是两条
EStatus GetPenetrationDepthStepGJK(const AE &inAExcludingConvexRadius, float inConvexRadiusA,
const BE &inBExcludingConvexRadius, float inConvexRadiusB,
float inTolerance, Vec3 &ioV, Vec3 &outPointA, Vec3 &outPointB); //【2】第一步
bool GetPenetrationDepthStepEPA(const AI &inAIncludingConvexRadius, const BI &inBIncludingConvexRadius,
float inTolerance, Vec3 &outV, Vec3 &outPointA, Vec3 &outPointB); //【3】第二步
bool GetPenetrationDepth(...); //【4】便利封装
下面对上面这段代码特别说明【1】:EStatus 有三个值,这是整件事的关键。
若它只有「碰 / 没碰」两态,那 EPA 就没有存在的余地了;正因为中间多了一个 Indeterminate,
后面才有「什么时候跑 EPA」这个问题。
4.2 三道门:EPA 的入场券是「核心重叠」
好了,现在看那位「守门人」。GetPenetrationDepthStepGJK 的正文很短,全是判定:
float combined_radius = inConvexRadiusA + inConvexRadiusB; //【1】两侧凸半径之和
float combined_radius_sq = combined_radius * combined_radius; //【2】平方,省一次开方
float closest_points_dist_sq = mGJK.GetClosestPoints(
inAExcludingConvexRadius, inBExcludingConvexRadius, //【3】注意:两个形状都**减掉了凸半径**
inTolerance, combined_radius_sq, ioV, outPointA, outPointB);
if (closest_points_dist_sq > combined_radius_sq) return EStatus::NotColliding; //【4】真没碰上
if (closest_points_dist_sq > 0.0f) return EStatus::Colliding; //【5】进垫层,但核心没重叠
return EStatus::Indeterminate; //【6】核心叠上了,交给 EPA
下面对上面这段代码特别说明【3】与【6】:传进去的是减掉凸半径之后的两个形状(参数名里
ExcludingConvexRadius 就是这个意思),而比对的阈值是凸半径之和的平方。于是三道门是:
- 核心间距² > (rA + rB)² → 连垫层都没碰上,
NotColliding; - 0 < 核心间距² ≤ (rA + rB)² → 核心没重叠,但两个形状的垫层已经交上了,
判定为接触成立(
Colliding),接触点与法线直接由 GJK 的最近点给出,不进 EPA; - 核心间距² == 0 → 两个形状的核心真的互相嵌进去了,这时才返回
Indeterminate, 交给 EPA 去求穿透深度。
把上一层的 GetPenetrationDepth 并排看,这条链就闭合了。它的正文是一个三分支 switch:
言归正传,这里有一条可以自己验的恒等式。 既然只有 Indeterminate(核心间距² == 0)才会调 EPA,
而 GJK 里能让它返回 0 的出口只有三个(满单纯形、距离归零、机器精度),那么:
$$\#\text{StepEPA} = \#\text{满单纯形} + \#\text{距离归零} + \#\text{机器精度}$$
本机实测完美对上:查询期 360 = 359 + 0 + 1;下落期 0 = 0 + 0 + 0;
缓存关闭的 420 步 0 = 0 + 0 + 0。两处独立计数器给出同一个数,这不是巧合,是结构。
4.3 本机实测:EPA 触发率是多少
| 工作负载 | StepGJK 次数 | StepEPA 次数 | 触发率 |
|---|---|---|---|
| A1 下落期(144 箱 4 层堆,前 120 步) | 480 | 0 | 0.00% |
| B 查询期(2000 次球探针) | 2340 | 360 | 15.38% |
| C 缓存 OFF(同场景 420 步) | 59112 | 0 | 0.00% |
下落期的 0% 值得多看一眼。 144 个箱子自由落体、撞在一起、堆成 4 层,这个过程里窄相被调用了 480 次,
但一次都没有进 EPA。原因就在 §4.2 的三道门:默认凸半径是 0.05,两个静止箱子贴在一起时,
它们的核心之间隔着 0.05 + 0.05 = 0.1 米的垫层,核心从未重叠,于是永远走不到 Indeterminate。
这条结论推翻了社区材料里一个很常见的说法(「相交之后必跑 EPA」)。 在默认配置下,静止堆叠根本不进 EPA;EPA 只在几何上真的嵌进去时才跑。 而这也解释了一个工程直觉:为什么剧烈碰撞(穿深大)比静止接触更花窄相的钱。
4.4 本机实测:EPA 跑几轮,硬上限是多少
| 项 | 值 | 出处 |
|---|---|---|
| EPA 总迭代(查询期) | 8328 次 | 本机实测 |
| 平均每次 EPA 迭代 | 23.13 次 | 本机实测 |
| 以「误差足够小」收敛退出 | 328 / 360 = 91.1% | 本机实测 |
| 凸包三角形上限 | cMaxTriangles = 256 |
EPAConvexHullBuilder.h:36 |
| 凸包顶点上限 | cMaxPoints = cMaxTriangles / 2 = 128 |
EPAConvexHullBuilder.h:37 |
| 新三角形最长边上限 | cMaxEdgeLength = 128 |
EPAConvexHullBuilder.h:40 |
| 最小三角形面积 | cMinTriangleArea = 1.0e-10f |
EPAConvexHullBuilder.h:41 |
| 重心坐标 epsilon | cBarycentricEpsilon = 1.0e-3f |
EPAConvexHullBuilder.h:42 |
| 凑原点用的点数上限 | cMaxPointsToIncludeOriginInHull = 32 |
EPAPenetrationDepth.h:41 |
EPA 的终止条件与 GJK 不同。它的判据是平方距离的相对变化,写在 EPAPenetrationDepth.h
第 380 行:if (dist_sq - t->mClosestLenSq < t->mClosestLenSq * inTolerance),
而 inTolerance 就是那个默认 1e-4 的穿透容差(注释原文:Stop when there's less than 1% change)。
换句话说,EPA 是「精度换轮数」:容差调大,轮数少、深度粗;容差调小,轮数多、深度细。
顺带记一笔口径差异:社区材料里 EPA 的收敛判据常写成「按三角形面积」的
|M1−M3|+|M2−M3| < ε(学术写法)。Jolt 用的是「按平方距离的相对变化」, 两者不是同一个式子。正文里引哪个,必须说清。
4.5 EPA 不给接触点
这是本篇要纠的第二个误区,也是最常见的一个。
EPA 的输出只有两样东西:一个方向(分离轴)和一个深度。 它不产出接触点。
看它的函数签名就明白:GetPenetrationDepthStepEPA(..., Vec3 &outV, Vec3 &outPointA, Vec3 &outPointB)
里的 outV 是「把 B 移出碰撞的最短方向」,outPointA / outPointB 是穿透最深方向上的一对点,
它们是「深度在哪最浅」的见证,不是接触面的取样。
那接触点是谁产的?是 §5 要讲的裁剪:拿到接触点与穿透轴之后,去找两侧各自的那个支撑面, 再把两个面互相裁一遍。「深度靠算、接触面靠裁」,这位是本层的完整分工。
五、一次裁剪:从接触点到流形
好了,最后一个算法环节。到这一步手里有:一个接触法线、一个穿透深度、以及两侧各自的最近点。 但求解器要的不是这些,它要的是一组接触点。一个面压在一个面上时, 只报一个点是稳不住的(会像 §0 的症状 A 那样打滑抖动)。
5.1 裁剪的入口与签名
入口是 ManifoldBetweenTwoFaces,声明在 ManifoldBetweenTwoFaces.h 第 38 行,签名长得很有信息量:
JPH_EXPORT void ManifoldBetweenTwoFaces(
Vec3Arg inContactPoint1, Vec3Arg inContactPoint2, //【1】两侧的接触点
Vec3Arg inPenetrationAxis, //【2】穿透轴(来自 EPA 或 GJK)
float inMaxContactDistance, //【3】多远还算这个流形的点
const ConvexShape::SupportingFace &inShape1Face, //【4】形状 1 的支撑面
const ConvexShape::SupportingFace &inShape2Face, //【5】形状 2 的支撑面
ContactPoints &outContactPoints1, ContactPoints &outContactPoints2); //【6】两侧接触点输出
它的实现里连着两步。第一步 PruneContactPoints(.cpp 第 16 行)先把多余的点剪掉;
第二步才是裁剪:第 194 行 ClipPolyVsPoly(*shape2_face, *shape1_face, penetration_axis, clipped_face),
第 202 行 ClipPolyVsEdge(...)。
注意一个容易找错的地方:这两个裁剪函数住在 Jolt/Geometry/ClipPoly.h(第 14 / 62 / 98 / 156 行
分别是 ClipPolyVsPlane、ClipPolyVsPoly、ClipPolyVsEdge、ClipPolyVsAABox),
不在 Jolt/Physics/Collision/ 下(ClipPoly.h)。这和 §0.6 那条「按目录找窄相会找不到」是同一件事的两个侧面:
Jolt 把「几何算法」和「碰撞分发」分得很干净。
5.2 面-面互裁的三步
把这件事画出来是这样:
用一句话概括:把两个支撑面摆到同一个平面上,求它们的交集。 两个面都是凸多边形,交集自然还是凸多边形(或者退化成线段、点), 这个多边形的顶点就是接触点。
5.3 本机实测:实际报几个点
这是本篇最干净的一组数据。同一套统计代码,一个球堆、一个箱堆,各 100 个、各 150 步:
两个堆都禁睡,跑完看统计:球堆每步同时存在的点数峰值是 124,箱堆是 400。
| 场景 | 流形累计 | 合计点数 | 平均每流形 | 点数分布 |
|---|---|---|---|---|
| 球堆(100 个球,150 步) | 14346 | 14346 | 1.00 | 1 点:14346 次(100.00%) |
| 箱堆(100 个箱,150 步) | 14000 | 56000 | 4.00 | 4 点:14000 次(100.00%) |
球 100% 报 1 个点,箱 100% 报 4 个点,没有一个例外。 这不是引擎挑了「1」或「4」这两个数, 而是几何决定的:球与球之间只有一个点接触(点-面 / 点-点),而两个平行的平面相压, 裁出来的交集就是那个矩形的四个角。
这条实测顺带说明了一件事:接触点数是几何的输出,不是引擎的配置项。
工程上真正能调的是 CollideShapeSettings::mMaxSeparationDistance(多远还算接触)
与流形合并开关,而不是「一次报几个点」。
5.4 数组容量是 64,不是 4
那 Jolt 里到底有没有「4」这个上限?查 ContactListener.h:
using ContactPoints = StaticArray<Vec3, 64>; //【1】容量 64,不是 4
class ContactManifold
{
RVec3 mBaseOffset; //【2】所有接触点相对这个基准
Vec3 mWorldSpaceNormal; //【3】沿最短路径把体 2 移出碰撞的方向
float mPenetrationDepth; //【4】为负 = 推测接触点,可能不会真的产生速度变化
SubShapeID mSubShapeID1, mSubShapeID2; //【5】构成这个流形的子形状(合并后会丢信息)
ContactPoints mRelativeContactPointsOn1; //【6】形状 1 表面的接触点
ContactPoints mRelativeContactPointsOn2; //【7】形状 2 表面的接触点
};
下面对上面这段代码特别说明两点。第一,容量是 64,本机实测最多也就见过 4 个点,
两者差着 16 倍:容量不是上限,是缓冲区大小。第二,第【4】条那个注释里提到 mPenetrationDepth
为负表示这是一个「推测接触点」(speculative contact)。这与 PhysicsSettings.h
第 50 行的 mSpeculativeContactDistance = 0.02f 是配套的:两体还没真正接触、但距离小于 2 厘米时,
窄相就先报一个「预判的」接触点出来。这是防抖动与防穿透的常规手段,
也解释了为什么接触点数会略多于「真实接触」的数量。
5.5 「4 点」是通行做法,不是 Jolt 的上限
言归正传,那「4 点流形」这个说法从哪来的?它是通用引擎做法,而且有它的道理:
| 说法 | 出处 | 具体内容 |
|---|---|---|
| 每个 arbiter 最多 4 个接触点,新点与最近点合并并继承冲量 | Jitter2 文档《Arbiters》 | 通用引擎做法(非 Jolt) |
| Persistent Contact Manifold:每帧生成更少接触点、跨帧共享更多接触数据 | Unity 手册 Physics | 通用引擎做法(非 Jolt) |
| 6 个接触点时力控不稳定,簇数应 ≤ 4 | arXiv 2306.06675 | 学术旁证(机器人 sim-to-real) |
| 数组容量 64,实际点数由裁剪决定;本机实测球堆 1 点、箱堆 4 点 | 本机实测 + ContactListener.h:16 |
Jolt 的实际口径 |
这张表的意义在于:写法上必须把「行业通行做法」与「本对象引擎的实际实现」分开。
Jolt 官方文档只说到「相似法线的流形会被合并」(Architecture.md 第 959 行),从来没有说「上限 4」。
正文若写 4,必须标成通用做法,否则就是把别人的口径贴到 Jolt 头上。
5.6 法线的上游:active 边与 inactive 边
裁剪用的是「接触点所属的那个面」,那么「面」找得对不对,就直接决定流形裁得对不对。 这里有一个坑,官方专门画了图:
Docs/Images/ActiveVsInactiveContactNormal.jpg(MIT,Jorrit Rouwe)这属于网格内部边(internal edge)问题:三角网格里相邻两个三角形共享的那条边, 在几何上并不存在一个「真正的硬边」,但如果窄相把它当硬边处理,就会出现幽灵碰撞 (物体从平坦的网格面上滑过时被看不见的边绊一下)。Jolt 的处置有两件:
CollideShapeSettings里有一个mActiveEdgeMode(CollideShape.h:77),控制只与 active 边碰撞 还是与所有边碰撞;mActiveEdgeMovementDirection(第 89 行)用来判断哪个方向的撞击才算数。- 另有一个
InternalEdgeRemovingCollector(Jolt/Physics/Collision/InternalEdgeRemovingCollector.h), 在收集阶段把内部边引起的伪命中剔掉;mInternalEdgeRemovalVertexToleranceSq(PhysicsSettings.h:22,默认1.0e-8)就是它判定「两个顶点算不算同一个顶点」的阈值。
第六篇的第五篇(过滤篇)里已经用过一次官方的幽灵碰撞示意图,那一处讲的是「幽灵体(sensor)该占一个独立的宽相层」; 本篇这张讲的是法线取法的成因。同一族现象,两个层次,两篇各取一面。
到这里,窄相的三件事(判定、深度、裁剪)都讲完了。回到 §0 的主线二: GJK 判距离与相交,EPA 只在核心真重叠时算深度,接触点由裁剪产出。 三者是串联的,但任何一步都可能提前退场。
六、一个缓存:姿态没变就不需要窄相
§3.3 里有一格是 0:稳态 300 步、零次 GJK 调用。这看着像基准写错了。这一节把它解释清楚。
6.1 体对缓存
PhysicsSystem::ProcessBodyPair 的第 1079 至 1085 行是全篇最「提气」的一段代码:
// Check if the contact points from the previous frame are reusable and if so copy them
bool pair_handled = false;
if (mPhysicsSettings.mUseBodyPairContactCache //【1】开关,默认 true
&& !(body1->IsCollisionCacheInvalid() || body2->IsCollisionCacheInvalid())) //【2】两边都没失效
mContactManager.GetContactsFromCache(ioContactAllocator, *body1, *body2, pair_handled);
// If the cache hasn't handled this body pair do actual collision detection
if (!pair_handled) //【3】缓存没接住才做窄相
{
CollideShapeSettings settings;
settings.mCollectFacesMode = ECollectFacesMode::CollectFaces; //【4】要收面,才能裁流形
settings.mMaxSeparationDistance = ... mSpeculativeContactDistance; //【5】推测接触距离
...
}
下面对上面这段代码特别说明两点。
第【1】条的开关叫 mUseBodyPairContactCache,在 PhysicsSettings.h 第 114 行,
默认值 true,官方注释把它的作用写得很完整:
Whether or not to use the body pair cache, which removes the need for narrow phase collision detection
when orientation between two bodies didn't change。判据是「姿态没变」,不是「速度为零」。这段代码的原理是用标志位代替逐帧脏检查,官方开关与说明见 PhysicsSettings.h 第 114 行。
第【2】条的失效标志位是 Body::IsCollisionCacheInvalid()(Body.h 第 267 行),
它读的是 EFlags::InvalidateContactCache。也就是说:体一被移动或被改(位置、旋转、形状),
标志位立起来,相关的体对缓存全部作废,下一次必须重算。这是一个「宁可重算,绝不用脏数据」的设计。
6.2 本机实测:这个缓存值多少钱
OK,把开关关掉再跑一遍同一场景,最能说明问题:
| 配置 | GJK 调用次数 | 总耗时(420 步) | 平均每次查询迭代 |
|---|---|---|---|
| 缓存 ON | 480 | 38.9 ms | 4.719 |
| 缓存 OFF | 59112 | 45.3 ms | 5.158 |
| 比值 | 123.2 倍 | 1.16 倍 | 1.09 倍 |
两个数字要分开读,这是本机实测里最容易误读的一处:
调用次数差 123 倍,而总耗时只差 16%。 原因很简单:窄相不是这一帧的全部。 420 步里还有宽相、约束迭代、积分、碰撞收集等等,窄相只占其中一段。 把窄相整段省掉,端到端省下 16%;但窄相自己那一份,几乎全省了。
这也顺手回答了一个工程问题:为什么「稳态几乎不花窄相的钱」不等于「窄相不贵」。 它贵,只是被缓存挡住了。真贵的时候(下落、剧烈碰撞)才见真章。
6.3 无锁双缓存的读写
先看看这套缓存的形状。官方文档第 963 行给了答案:一读一写,两个 cache。 跑这一步时,读缓存放着上一步的接触对,写缓存收这一步的新配对; 如果某个接触点被保留下来(判定为持久接触),它就从读缓存复制进写缓存。
「无锁」这件事在这里是自然的。 因为每一步只有一个线程负责写写缓存,读缓存只读不写, 两个缓存按步交替角色,天然没有写写冲突。这和 ep04 里宽相树的「放宽父节点路径」是同一族的思路: 用「数据布局」换「不加锁」。
而「把上一步的接触点复制过来」这件事,正是 warm start(热启动)的地基: 求解器拿到上一步的接触点与冲量,这一帧就不必从零开始迭代。那条线归第八、九篇。
七、三个容差常量
OK,窄相这一层所有的「精度」与「什么时候停」,最后都落在几个常量上。它们集中在一个文件里:
| 常量 | 值 | 单位 | 管什么 | 位置 |
|---|---|---|---|---|
cDefaultCollisionTolerance |
1.0e-4 |
米 | GJK 的停止条件:比这个距离更近就算碰撞 | PhysicsSettings.h:10 |
cDefaultPenetrationTolerance |
1.0e-4 |
无量纲 | EPA 的停止条件:平方距离变化 < 容差 × 当前穿透深度² | PhysicsSettings.h:13 |
cDefaultConvexRadius |
0.05 |
米 | 几何垫层:所有凸形状先内缩这么多、碰撞时再等量外扩 | PhysicsSettings.h:16 |
cCapsuleProjectionSlop |
0.02 |
米 | 胶囊的支撑面算「边」还是「点」 | PhysicsSettings.h:19 |
cDefaultInternalEdgeRemovalVertexToleranceSq |
1.0e-8 |
米² | 内边消除:两个顶点算不算同一个顶点 | PhysicsSettings.h:22 |
// 三个容差的定义原文(PhysicsSettings.h 第 10 / 13 / 16 行)
constexpr float cDefaultCollisionTolerance = 1.0e-4f; //【1】比这更近就算碰撞(用于 GJK),单位米
constexpr float cDefaultPenetrationTolerance = 1.0e-4f; //【2】平方距离变化 < 容差 × 穿透深度² 就停,无量纲
constexpr float cDefaultConvexRadius = 0.05f; //【3】给物体加多少垫层,单位米
下面对上面这段代码特别说明:三者落在这条准则上:$\delta < \varepsilon \cdot d^{2}$,其中 $\delta$ 是平方距离的变化量、$d$ 是当前穿透深度、$\varepsilon$ 就是第【2】条那个 1e-4。定义与出处都可在 PhysicsSettings.h 核到。
三个常量里最容易搞混的是后两个:碰撞容差 1e-4 与穿透容差 1e-4 数值一样,
但一个是长度(米)、一个是无量纲因子,管的是两个不同算法的停止条件。
把它们当成「同一个容差抄了两遍」是最常见的误读。
7.1 凸半径:浸透容差的真正来源
言归正传,前两篇里我提过一句「0.05 才是浸透容差的来源」,这一节把它说清。
BoxShapeSettings 的构造函数注释(BoxShape.h 第 22 行)写着:
internally the convex radius will be subtracted from the half extent so the total box will not grow
with the convex radius。也就是:你说外廓半长 0.5、凸半径 0.2,它内部就把核心做成半长 0.3,
碰撞时再等量外扩 0.2,外廓还是 0.5。 尺寸不变,变的是「边角有多圆」。
Docs/Images/ConvexRadius.jpg(MIT,Jorrit Rouwe)这条链解释了三件在前面出现过的事:
- 为什么 §4.3 里下落期的 EPA 触发率是 0%:静止堆叠的箱子核心之间隔着 0.1 米的垫层, 核心从未重叠。
- 为什么会有
Indeterminate这个中间态:核心没重叠但垫层交上,几何上确实算接触, 但穿透深度算不出正值(真穿透只有核心重叠时才有),所以 GJK 单干就行、不必上 EPA。 - 为什么 §0 的症状 B(薄盒子穿过去)会发生:如果两个体的核心还没重叠就报「未接触」,
那么当相对速度很高、一帧跨过垫层时,就会出现「上一步没碰、这一步已经到另一边」的漏检。
这也是推测接触距离(
mSpeculativeContactDistance = 0.02)存在的理由:提前报接触。
八、本机实测:逐形状对与凸半径
前面几节的实测都在回答「什么时候跑、跑几次」。这一节换两个问题:哪些形状对最贵, 以及凸半径到底改变了什么。
8.1 官方口径:逐形状对耗时
要说清这件事,得先解决一个问题:怎么量才不是自说自话。
本机用的是官方自带的窄相计时设施 JPH_TRACK_NARROWPHASE_STATS(NarrowPhaseStats.h)。
它内部维护两张按形状子类型索引的二维表:
class NarrowPhaseStat //【1】每个形状对一格
{
atomic<uint64> mNumQueries = 0; //【2】这一对形状被查过几次
atomic<uint64> mHitsReported = 0; //【3】其中报到多少次命中
atomic<uint64> mTotalTicks = 0; //【4】总耗时(含子查询)
atomic<uint64> mChildTicks = 0; //【5】花在子查询上的时间
static NarrowPhaseStat sCollideShape[NumSubShapeTypes][NumSubShapeTypes]; //【6】静态碰撞
static NarrowPhaseStat sCastShape [NumSubShapeTypes][NumSubShapeTypes]; //【7】扫掠
};
下面对上面这段代码特别说明【5】:mChildTicks 这个字段是本篇能比第三方基准多讲一句的原因。
官方输出里同时给了 Total Time (%) 与 Total Time Excl Children (%) 两列,
于是复合形状(compound)的「自耗时」与「子耗时」能分开看:
同一个「球撞球」,直接对撞与「复合形状里的一颗球撞球」的账不是一回事。
第三方基准一般只给端到端时间,看不出这一层。这套设施的原理是按「形状子类型对」分组计时,官方头文件见 NarrowPhaseStats.h。
8.2 逐形状对耗时(CollideShape)
场景:七种静态形状(盒、球、胶囊、凸包、复合、网格、高度场)各撒 20 个动态体(球、盒、胶囊、圆柱、凸包),
跑 150 步;再补 3000 条射线与 400 次形状扫掠。官方 CSV 共 47 行(39 行 CollideShape + 8 行 CastShape),
原始文件是 evidence/narrow_phase_stats.csv。
| 形状对(形状 1 × 形状 2) | 查询次数 | 占总窄相时间 | 每次查询 |
|---|---|---|---|
| Sphere × Cylinder | 354 | 5.10% | 0.014476%(最贵) |
| ConvexHull × HeightField | 294 | 2.95% | 0.010503% |
| Capsule × HeightField | 368 | 2.19% | 0.005950% |
| Box × Cylinder | 3167 | 20.14%(绝对量最大) | 0.005798% |
| Sphere × Capsule | 469 | 3.90% | 0.008310% |
| Sphere × Sphere | 482 | 0.36% | 0.000753% |
| Cylinder × Cylinder | 20 | 0.005% | 0.000270%(最便宜) |
说白了,同一层代码,最贵与最便宜的形状对差 53.5 倍。 这个数回答了一个工程上的常见疑问:
「为什么我的场景换了形状构成,物理帧率就变了」:窄相的代价不按体数均摊,
它按形状对的种类分布。表里 Box × Cylinder 一个对就吃掉全篇窄相的五分之一。
8.3 逐形状对耗时(CastShape)
扫掠那一半是另一张表,规律不太一样:
| 形状对 | 查询次数 | 每次查询 |
|---|---|---|
| Box × Box | 200 | 0.001551% |
| Box × Capsule | 22 | 0.001586% |
| ConvexHull × Box | 20 | 0.001406% |
| Sphere × Box | 200 | 0.001225% |
| Sphere × Sphere | 14 | 0.000547% |
说白了,扫掠的每次代价整体比静态碰撞低一个量级(球对球 0.000547% vs 0.000753%,同量级但更低;
盒对盒 0.001551% 也低于多数静态形状对)。这条与直觉有点反:扫掠不是要扫一大段距离吗?
原因是大部分扫掠在前几轮就被「超 inMaxDistSq」提前退出了(CastShape 也吃 inMaxDistSq 这条判据)。
8.4 凸半径对「面-面接触」几乎无影响
OK,官方文档对凸半径给的是定性说法(半径越大越快、但仿真越不精确)。本机把它拉成曲线, 先测最规整的一类接触:两个平面相压。
| 凸半径 r | 首步(ms) | 稳态每步(ms) | 总耗时 300 步(ms) |
|---|---|---|---|
| 0.000 | 0.063 | 0.0877 | 27.36 |
| 0.010 | 0.060 | 0.0868 | 25.07 |
| 0.050(默认) | 0.054 | 0.0916 | 26.06 |
| 0.100 | 0.059 | 0.0867 | 25.16 |
| 0.200 | 0.059 | 0.0801 | 24.65 |
结论是一条平的线。 稳态每步在 0.0801 到 0.0916 毫秒之间,最大值出现在默认值 0.05 那一档, 最小值出现在 0.2 那一档。这个抖动幅度(约 7%)与测量噪声同量级,读不出单调趋势。
所以「半径越大越快」这句话,在面-面接触这个场景里是量不出来的。为什么?因为面-面接触下, 凸半径只是把核心平面缩小了,接触类型没变(还是 4 个点),GJK 的迭代轮的也没变。 这句话真正成立的场合在下一节。
8.5 凸半径对「边-面接触」是决定性的
先看看换个姿态会怎样。把同一个箱子转到 45°,让它用一条边落在平面上,其余全不变:
| 凸半径 r | 最终倾角(°) | 最终高度(m) | 水平位移(m) |
|---|---|---|---|
| 0.000 | 38.89 | 0.70310 | 0.07530 |
| 0.010 | 38.70 | 0.70285 | 0.07763 |
| 0.050(默认) | 45.04 | 0.70389 | 0.00049 |
| 0.100 | 45.04 | 0.70389 | 0.00049 |
| 0.200 | 45.04 | 0.70389 | 0.00049 |
OK,这组数据是本次所有实验里最锐利的一条:
- r = 0 或 0.01:箱子用尖锐的边接触平面,边「咬」进地面,箱子倾斜着滑走 7.53 厘米(
r = 0)与 7.76 厘米(r = 0.01), 最终倾角落在 38.9°(从初始 45° 塌下来一段)。 - r ≥ 0.05(含默认值):边变成了圆柱面,箱子稳稳立住,水平位移只有 0.049 厘米, 倾角原封不动保持 45.04°。
说白了,两个世界的分界线正好落在默认值 0.05 上,位移相差约 154 倍。
这一条同时把官方那句话的两半都量出来了:
- 「越不精确」这一半:凸半径把边角几何改成了圆角,
r = 0.2时的形状已经不是几何意义上的盒子了。 换来的是「边不再咬住」这种真实几何里没有的行为。 - 「半径越大越快」这一半:在面-面上量不出来(§8.4),在边-面上表现也不在耗时上, 而在行为上:圆角让接触特征从「边」退化成「点/线」,接触流形能裁出的点更少、求解更容易收敛。 官方那句里的「快」,更准确的说法是「让不容易解的接触变得容易解」。
本机读数口径再说一次:Jolt v5.6.0,MSVC
/O2,步长 1/60 s,全部体mAllowSleeping = false, 场景与参数在evidence/bench_narrow.cpp的E1a_Stack/E1b_TiltedBox两个函数里逐行可查。
九、边界、演进与处方
好了,把全篇收一下。先走一遍判定链,再给反模式、演进、处方与可检验命题。
9.1 一次配对要过几道判定
OK,把 §1 到 §6 串起来,一次体对的窄相之旅是这样的:
| 关卡 | 判定 | 不过时省下了什么 |
|---|---|---|
| ① 体对缓存 | 两体相对姿态变了吗 | 整段窄相(§6.2 实测:调用数 123 倍差) |
| ② 形态取快照 | 锁内只做这一件事 | 锁外整段并行(§1.3) |
| ③ GJK 超距 | 核心距离² > (rA+rB)² 吗 | 整段迭代(§3.3 实测:查询期 14.5% 走这条) |
| ④ GJK 迭代 | 四类出口(§3.2) | 主体是「无更近点」:94.6% / 68.2% |
| ⑤ EPA 门槛 | 核心间距² == 0 吗 | 整段 EPA(§4.3 实测:下落期 0%) |
| ⑥ 流形裁剪 | 两侧支撑面求交(§5.2) | 输出的接触点个数由几何定,1 点或 4 点 |
| ⑦ 流形合并 | 法线相似吗(Architecture.md:959) |
减少下游约束数 |
这七道关卡合起来的原理是逐层退场:能退整段的绝不退一半,能退一半的绝不进迭代。写成式子就是 $\#\text{窄相调用} = \#\text{通过第 ① 关的体对}$,而第 ③ 关那一条判据在 GJKClosestPoint.h 第 384 行 可逐字核到。
说白了,七道关卡里,有六道是「提前退场」的设计。 这与 ep05 那条「越早越省」是同一个原则, 只不过这一层退场退得更彻底:最省的那一次退了整段(缓存命中),次省的那一次退了一半(GJK 判不相交)。
9.2 十条窄相侧反模式
| # | 反模式 | 后果 | 对位处方 |
|---|---|---|---|
| 1 | 把凸半径当「误差」一律调 0 | 边会咬住、接触特征从面退化成边(§8.5:位移差 154 倍) | 保留默认 0.05,除非确实需要尖锐边 |
| 2 | 反过来把凸半径调到远大于默认值 | 几何失真,「边不再咬住」这类真实几何里没有的行为 | 只在确实想消除边粘滞时用,并记录代价 |
| 3 | 用 ContactListener 里数接触点来当「碰了几下」 |
推测接触点也计入(mPenetrationDepth 可为负) |
判 mPenetrationDepth > 0 才是真穿透 |
| 4 | 为了省事把 mUseBodyPairContactCache 关掉调试 |
窄相调用数暴涨(§6.2:123 倍) | 只在排查「缓存用了脏数据」时临时关 |
| 5 | 在 ContactListener 里改体的变换 |
会把体对缓存标志位立起来,缓存全废 | 回调里只读,需要改就记下来在帧末统一处理 |
| 6 | 把 CollisionListener 回调当单线程写 |
回调在 job 线程上并发调用,非原子计数会算错(§0.3 坑一) | 回调内的共享状态一律用原子量或加锁 |
| 7 | 相信「GJK 迭代三次就停」 | Jolt 没有迭代上限这个变量(§3.1),实测平均 3.4 到 5.2 次、最多 25 | 按实测口径写,不引教学实现的经验值 |
| 8 | 相信「相交必跑 EPA」 | 默认配置下静止堆叠的 EPA 触发率是 0%(§4.3) | 按「核心重叠才跑」来理解成本 |
| 9 | 写「Jolt 的流形上限是 4 点」 | 容量实为 64(ContactListener.h:16),4 是通用做法 |
分开写「通用做法」与「Jolt 实际」 |
| 10 | 只按体数量估算窄相开销 | 代价按形状对种类分布,单个对可占 20%(§8.2) | 用 JPH_TRACK_NARROWPHASE_STATS 先量形状对矩阵 |
9.3 演进:与窄相有关的版本变更
OK,把 Release Notes 与 API Changes 里和窄相有关的条目按月历排开,会看到一个很集中的规律:
| 版本 | 条目 | 出处 |
|---|---|---|
| v4.0.0 | 修 EPA 穿透深度算法的法线翻转(可能指向错误方向) | ReleaseNotes.md:418 |
| v5.0.0 | 新增 BodyInterface::SetUseManifoldReduction(会清接触缓存) |
ReleaseNotes.md:329 |
| v5.0.0 | 修「cast 球 vs 退化三角形」的假阳性;修「圆柱 vs 大三角形」接触点错误 | ReleaseNotes.md:344 / 346 |
| v5.1.0 | 修 GetPenetrationDepthStepEPA 断言里的容差 |
ReleaseNotes.md:274 |
| v5.2.0 | 修 SoftBodyManifold 单独返回 sensor 接触 |
ReleaseNotes.md:190 |
| v5.3.0 | mManifoldToleranceSq 去平方、改名 mManifoldTolerance;ManifoldBetweenTwoFaces 改收 inMaxContactDistance |
APIChanges.md:30 |
| v5.3.0 | 修 mMaxSeparationDistance 设很大(如 1000)时穿透深度数值不准 |
ReleaseNotes.md:163 |
| v5.3.0 | 修 ManifoldBetweenTwoFaces 在接触法线与面法线不平行时相对接触点错误(并可能引起抖动) |
ReleaseNotes.md:165 |
| v5.4.0 | 修 ManifoldBetweenTwoFaces:面 1 有 ≥3 顶点而面 2 只有 2 顶点时只给出 1 个接触点而不是 2 个 |
ReleaseNotes.md:88 |
| v5.4.0 | 修 MeshShape active edge 计算(可能把非流形网格的边标成 inactive) |
ReleaseNotes.md:91 |
| v5.4.0 | 修「复合形状 vs 普通形状忽略 mMaxSeparationDistance」,会漏碰撞 |
ReleaseNotes.md:107 |
| v5.5 开发期 | 内边消除容差改为可配(mInternalEdgeRemovalVertexToleranceSq) |
ReleaseNotes.md:20 |
言归正传,规律很清楚:与窄相有关的每一次修补,几乎都落在「容差」与「流形」上。 算法本体(GJK 的主循环、EPA 的凸包扩展)在这十几个版本里没有结构性改动。 稳的是框架,动的是边界条件。这条对写引擎或调引擎的人都是有用的判断: 窄相出问题,先怀疑容差与退化情形,别先怀疑算法。
9.4 三条处方
好了,把全篇收成三条能直接落地的做法。
先看看三条处方。处方一,先量形状对矩阵,再谈优化。 打开 JPH_TRACK_NARROWPHASE_STATS,
跑一遍真实场景,看 Total Time Excl. Children / Query 那一列。单形状对可占全篇窄相 20%(§8.2),
把它换掉或特化掉,收益比调参大得多。官方自带设施,零成本,没有理由不先量。
处方二,理解「提前退场」,不要跟它作对。 体对缓存靠「姿态没变」工作,
所以在回调里改体的变换是最贵的写法(反模式 5);GJK 靠 inMaxDistSq 提前退出,
所以把大量远距离查询混进同一个批次并不亏;EPA 只在核心重叠时跑,所以静止场景的窄相成本远低于直觉。
顺着这些设计走,比逆着它调参数划算。
处方三,凸半径是几何参数,不是精度参数。 它决定「边咬不咬住」(§8.5 差 154 倍), 不决定「碰不碰得准」。想提高精度要动的是碰撞容差与穿透容差;想改行为才动凸半径。
9.5 六条可被检验的命题
说白了,本篇的结论都可以自己验,不需要相信本文。下面六条都给了「检验入口」:左列是我下的断言,右列是一条可以自己走一遍的路径:读某个文件的某几行,或者在本机跑一次 bench_narrow.exe。其中四条只要读一个头文件就能确认,两条需要真跑一次程序;任何一条对不上,本篇对应的结论就该被推翻。
| # | 命题 | 检验入口 |
|---|---|---|
| 1 | GJK 主循环没有迭代上限,只有四类收敛判据 | 读 GJKClosestPoint.h 第 353 至 470 行;或跑 bench_narrow 看「单次最多 25 次」这个数来自实测而非配置 |
| 2 | EPA 只在核心间距² == 0时才跑 | 读 EPAPenetrationDepth.h 第 119 至 137 行与第 499 至 517 行;或验恒等式 StepEPA == 满单纯形 + 距离归零 + 机器精度 |
| 3 | 触发的 StepEPA 次数等于 GJK「满单纯形」次数(加另两类归零出口) |
跑 bench_narrow 工作负载 B 与 C,对照两处计数器 |
| 4 | 接触流形点数由几何决定,球 1 点、箱 4 点 | 读 ContactListener.h 第 16 行的容量 64,或跑 E4 看分布为 100% / 100% |
| 5 | 体对缓存在姿态不变时整段跳过窄相 | 读 PhysicsSystem.cpp 第 1079 至 1085 行与 PhysicsSettings.h 第 114 行;或跑工作负载 C 看 123 倍调用差 |
| 6 | 凸半径在边-面接触上是决定性的,阈值落在默认 0.05 | 跑 E1b:r = 0.01 滑走 7.76 cm,r = 0.05 位移 0.049 cm |
9.6 本篇的边界(已给出的结论与不展开的部分)
说白了,按本系列的惯例,边界要写两遍:开头写一次「本篇不涉及什么」,收口时再写一次 「已经给出了什么、剩下什么归别人」。
| 方向 | 本篇已给出 | 归谁 |
|---|---|---|
| 上游:候选对从哪来 | 只提「先查宽相」这一句 | 第四、五篇 |
| 上游:体与运动状态 | 只用其「体对缓存失效标志位」 | 第三篇 |
| 算法:GJK | 三个入口、四类出口、实测迭代分布 | 本篇收口 |
| 算法:EPA | 三态门槛、凸包扩展、实测触发率与迭代数 | 本篇收口 |
| 算法:流形裁剪 | 入口签名、两步裁剪、实测点数分布 | 本篇收口 |
| 缓存 | 体对缓存的作用与代价、双 cache 的读写 | 本篇收口;warm start 的具体机制归第八、九篇 |
| 下游:接触约束 | 只到「流形交给 ContactConstraintManager」 |
第八、九篇 |
| 形状谱系 | 只把形状当成「支持函数提供者」 | 第七篇 |
| 连续碰撞检测 | 只提 CastRay 与 CastShape 共用支持函数逻辑 |
第十一篇 |
| GPU 宽相 | Jolt 没有这条路(系列级硬边界) | 不写 |
9.7 本篇的三层证据,与它们的分工
| 层 | 它说什么 | 本篇的落点 |
|---|---|---|
| 官方文档层 | 设计意图 | ## Narrow Phase 全节 8 行(§1.1);## Find Collisions 第 957 至 963 行(§1.2) |
| 官方源码层 | 实现方式 | 四类终止判据且无迭代上限(§3.1);三态门槛(§4.2);容量 64(§5.4);体对缓存(§6.1) |
| 本机实测层 | 真实代价 | EPA 触发率 0% / 15.38%(§4.3);缓存 123 倍(§6.2);形状对 53.5 倍(§8.2);凸半径阈值 0.05(§8.5) |
OK,三层分开说的意义在于:任何一条结论都能被指到它的那一层上去。 比如「EPA 只在核心重叠时才跑」是源码层的结论;「下落期触发率 0%」是实测层的读数; 两者一致,才敢说这条成立。而「半径越大越快」是官方文档层的定性说法, 本机在面-面接触上量不出单调趋势(§8.4),在边-面接触上表现为行为差异(§8.5), 所以在正文里被改写成「让不容易解的接触变得容易解」。
资源清单
官方文档(v5.6.0)
Docs/Architecture.md,## Narrow Phase全节(第 595 至 602 行,只有 8 行)/## Find Collisions(第 944 至 964 行,含接触点、面裁剪、流形合并、无锁接触缓存)/## Broad Phase(第 577 至 594 行)/## Continuous Collision Detection(第 669 至 678 行)/## Ghost Collisions(第 679 至 700 行)Docs/ReleaseNotes.md, v4.0 的 EPA 法线翻转修复(第 418 行)/v5.0 的SetUseManifoldReduction(第 329 行)/v5.1 的 EPA 断言的容差(第 274 行)/v5.3 的穿透深度数值修复(第 163 行)/v5.4 的两处流形修复与MeshShapeactive edge(第 88 / 91 / 107 行)/v5.5 开发期的内边容差可配(第 20 行)Docs/APIChanges.md,mManifoldToleranceSq去平方改名(第 30 行)/Shape::CollideSoftBodyVertices接口变更(第 41 行)/RayCastSettings::mBackFaceMode拆分(第 42 行)/ShapeFilter签名变更(第 105 行)/窄相查询的 base offset 与相对接触点改名(第 114 / 115 / 118 行)Build/README.md,JPH_TRACK_NARROWPHASE_STATS的编译期开关说明- 官方配图:
ConvexRadius.jpg(370×192)/ActiveVsInactiveContactNormal.jpg(817×338)
官方源码(v5.6.0)
Jolt/Geometry/GJKClosestPoint.h, 实现出处(第 20 行)/Intersects(第 207 行)/GetClosestPoints(第 327 行)/GetClosestPointsSimplex(第 495 行)/CastRay及其出处(第 506 / 516 行)/CastShape两个重载(第 658 / 689 行)/主循环与四类终止判据(第 353 至 470 行)Jolt/Geometry/EPAPenetrationDepth.h, 实现出处与「不照论文写」的说明(第 16 至 23 行)/算法步骤注释(第 25 至 35 行)/cMaxPointsToIncludeOriginInHull(第 41 行)/EStatus三态(第 85 行)/GetPenetrationDepthStepGJK(第 104 行)/StepGJK 的三道门(第 119 至 137 行)/GetPenetrationDepthStepEPA(第 149 行)/EPA 终止条件(第 380 行)/GetPenetrationDepth三分支(第 487 至 517 行)Jolt/Geometry/EPAConvexHullBuilder.h,cMaxTriangles(第 36 行)/cMaxPoints(第 37 行)/cMaxEdgeLength(第 40 行)/cMinTriangleArea(第 41 行)/cBarycentricEpsilon(第 42 行)Jolt/Geometry/ClipPoly.h,ClipPolyVsPlane(第 14 行)/ClipPolyVsPoly(第 62 行)/ClipPolyVsEdge(第 98 行)/ClipPolyVsAABox(第 156 行)Jolt/Physics/Collision/ManifoldBetweenTwoFaces.h, 入口签名(第 38 行)Jolt/Physics/Collision/ContactListener.h,ContactPoints = StaticArray<Vec3, 64>(第 16 行)/ContactManifold六个字段与「负深度即推测接触」的注释(第 19 至 36 行)Jolt/Physics/Collision/NarrowPhaseStats.h,NarrowPhaseStat的四个计数与两张二维表(第 27 至 44 行)/CSV 输出(第 31 / 35 行)Jolt/Physics/Collision/NarrowPhaseQuery.h, 五个入口(第 31 / 35 / 40 / 53 / 67 行)/CollectTransformedShapes(第 70 行)Jolt/Physics/Collision/CollisionDispatch.h,sCollideShapeVsShape(第 33 行)/两个 cast 版本(第 53 / 65 行)/注册函数(第 81 / 84 行)Jolt/Physics/Collision/CollideShape.h,mActiveEdgeMode(第 77 行)/mCollisionTolerance(第 83 行)/mPenetrationTolerance(第 86 行)/mActiveEdgeMovementDirection(第 89 行)/mMaxSeparationDistance(第 100 行)Jolt/Physics/PhysicsSettings.h, 三个容差常量(第 9 至 16 行)/cCapsuleProjectionSlop(第 19 行)/内边顶点容差(第 22 行)/mSpeculativeContactDistance(第 50 行)/mUseBodyPairContactCache(第 114 行)Jolt/Physics/PhysicsSystem.cpp,ProcessBodyPair与体对缓存(第 1049 至 1103 行)Jolt/Physics/Body/Body.h,IsCollisionCacheInvalid(第 267 行)Jolt/Core/Core.h,版本宏(第 8 至 10 行)- 仓库 tags 与版本:
api.github.com/repos/jrouwe/JoltPhysics/tags
权威算法原文
- Gino van den Bergen, GDC 2009《Collision Detection Crash Course》,配置空间障碍、support mapping、GJK 流程、SAT 轴数表(box-box ≤ 15 轴)
- Gino van den Bergen《Proximity Queries and Penetration Depth Computation on 3D Game Objects》,EPA 的原始出处
- Erin Catto, GDC 2010《Computing Distance》,把 polygon-polygon 化为 point-polygon 的几何直觉
第三方素材与旁证
- 闵可夫斯基和(题图)(CC BY-SA 3.0,Jean-Luc W)
- 两类碰撞与对应的 CSO 面(Public domain,NeD80)
- 分离轴定理(含投影区间)(Public domain,Oleg Alexandrov / Jon Watte)
- dyn4j《GJK》/baziotis《GJK: A Visual Derivation》/AtlasPhys《Narrow Phase: GJK and EPA》/Toptal《Video Game Physics Part II》
- deepwiki:JoltPhysics 碰撞检测管线(逐行对照源码的第三方解读)
- Godot 官方文档:Using Jolt Physics(确认 Jolt 的 convex radius 即其它引擎的 collision margin)
- Jitter2 文档《Arbiters》/Unity 手册 Physics(Persistent Contact Manifold)/arXiv 2306.06675《Contact Reduction with Bounded Stiffness》(「4 点流形」的第三方口径,均非 Jolt 官方)
- crashcat CHANGELOG(凸半径折叠进结构体、半径过大要缩减、用解析式替代通用 GJK/EPA 的工程实证)
本机一手产物
evidence/bench_narrow.cpp(六组实验,含源码级注释)、evidence/build_bench.ps1(含五步复现流程)evidence/bench_narrow_run_20261003.txt(原始输出)、evidence/narrow_phase_stats.csv(官方 instrumentation 的 47 行 CSV)evidence/Ep06Counters.h+evidence/patch_jolt_ep06.py(插桩件与补丁脚本,跑完已还原源码树)
本清单的原理是每条结论都能指到它那一层的原始出处;三层证据的分工写成式子即 $\text{结论} = \text{官方层} + \text{源码层} + \text{实测层}$。凡是在官方文档与源码里找不到对应命名的说法,本清单不收。
好了,下一篇往上游走:形状谱系(Shape)与 SubShapeID。
窄相把形状当成「支持函数提供者」,可形状本身是一大家子:从球到凸包到三角网格到高度场,
复杂度差着量级;复合形状还要回答「到底哪一块被命中了」,
而那个答案被编码进一个 32 位的 SubShapeID 里。那条路径上也有它自己的代价故事:
一个形状的表示方式,会一路决定它的窄相开销。
With Best Wishes.