Jolt Physics 3D · 第 六 篇

窄相:GJK、EPA 与流形裁剪

2026-10-0317,964 字44 张配图本文由@溯光 出品,转载请注明出处。

站点链接:技术博客页 | 公众号:溯光的图形笔记

闵可夫斯基和
题图闵可夫斯基和:两个紧凸集的逐点和构成一个新凸集,GJK 与 EPA 都在这张图上工作@ Wikimedia Commons 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,把这一层放在整条链上看,它的位置很清楚:

窄相在整条链上的位置
图 6-1窄相在整条碰撞检测链上的位置:宽相从一堆体里粗筛出候选配对,窄相把候选对变成接触点与接触流形,随后才是约束的建立与求解;标出本篇的起止边界,左边接着第四五篇、右边交给第八九篇@ 本系列绘制

宽相(broad phase)负责把「一万个体的四千九百万对」砍到几百对;窄相负责把这几百对里真正接触的那些, 算成一组接触点;再往下,这些接触点才被建成接触约束、算出 Jacobian 与有效质量,进入求解。 本篇只走中间那一段。

0.1 两类碰撞,与它们背后的配置空间障碍

先来看看窄相这一层最朴素的直觉:两个形状「叠在一起了没有」。但凸体之间的最近特征只有两类: 一类是点对面(face-vertex),一类是边对边(edge-edge)。3D 里不存在第三种:

两类碰撞与 CSO 面
图 6-2两类碰撞与其对应的配置空间障碍面:左边是点对面(face-vertex)的碰撞,右边是边对边(edge-edge)的碰撞,各自在配置空间障碍(CSO)上对应一个面;GJK 找的最近特征与流形裁剪出的接触多边形都是这两类的组合@ Wikimedia Commons 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 本篇的边界(本篇不涉及什么、为什么)

本篇边界表
图 6-3本篇边界表:左列是窄相篇给出什么,中列是不展开什么,右列归属哪一篇,六条边界一一对应@ 本系列绘制

说白了,边界写两条。第一条是「上游已经给过什么」:候选配对怎么来的、树是怎么长的、层是怎么配的, 这三件事第四、五篇写完了,本篇直接从 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 行号与内容全部有效, 差异只在版本宏本身。

两处方法论坑也一并登记(都是本轮先量出异常、再回源码定位后修掉的):

  1. 接触回调是多线程的。OnContactAdded / OnContactPersisted 在 job 线程上并发调用, 计数器不用原子量时,会量出「点数直方图之和 > 流形总数」这种自相矛盾的结果。第一版基准就踩了这个坑。
  2. 不关休眠就量不到稳态代价。体在约 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/ 下。

六组实验的设计
图 6-4本篇六组本机实验的设计表:E0 用官方自带的窄相 instrumentation 量逐形状对耗时,E1a 与 E1b 分别量凸半径对「面-面接触」与「边-面接触」的影响,E2 与 E3 量 EPA 触发率与 GJK 的四类出口分布,工作负载 C 量体对缓存值多少钱,E4 量接触流形的点数分布@ 本系列绘制
实验 做法 量什么
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 那三道门)。本机把这两条都做成了可复现的配置,命令行同上一张表。

两个症状与同一个根因
图 6-5两个症状与同一个根因:「该碰没碰」与「碰了但没报对」,前者指向窄相没报出嵌入,后者指向接触点太少或法线方向不对;两条症状各自给出一个本机可复现的配置与排查入口@ 本系列绘制

0.6 先说清三处「查无落点」

说白了,按本系列的惯例,凡是在官方材料里找不到对应命名的说法,都要在正文里点出来。本篇有三处:

  1. Jolt/Physics/Collision/NarrowPhase/ 这个目录不存在。 实测:Collision/ 下一级子目录 只有 BroadPhase/ 与 Shape/ 两个(该目录一级共 54 个文件);全仓 NarrowPhase* 文件仅 4 个 (NarrowPhaseQuery.cpp/.h、NarrowPhaseStats.cpp/.h),全部平铺在一级。 算法本体住在 Jolt/Geometry(27 个文件)。按目录找窄相的人会找不到。 这条的定义不在文档里,就在目录结构本身(Jolt/Physics/Collision 目录)。
  2. 「GJK 只能判相交、要深度必须上 EPA」这句话,在 Jolt 里对不上一半。 GJKClosestPoint.h 同时提供 Intersects()(只判相交)与 GetClosestPoints()(返回平方距离与两侧最近点); 而真正决定「要不要上 EPA」的,是 GetPenetrationDepthStepGJK 返回的三态之一 Indeterminate(见 §4.2)。
  3. 「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 行。

Narrow Phase 一节全文
图 6-6Docs/Architecture.md 第 595 至 602 行原文截取:整个 Narrow Phase 一节只有 8 行,说的是先查宽相、在 body lock 保护下构造 TransformedShape 快照、主要计算在锁外完成;末行一句「窄相查询全部由 GJK 与 EPA 算法处理」@ Jolt Physics v5.6.0 源码原文截取(MIT)

说白了,整节只有 8 行,说明算法本身官方没打算在文档里讲。这是本篇必须读源码的原因。

1.2 官方真正讲接触点的是另一节

那接触点从哪来?先看看官方把它写在哪儿:## Find Collisions(第 944 至 964 行,属「The Simulation Step in Detail」)。 第 957 行的原文是:两体之间的接触点由 GJK 与 EPA 决定;对每个接触点会算出它所属的那个面; 两个体的面互相裁剪(ManifoldBetweenTwoFaces),得到一个多边形(或者点、线)来表示两体之间的接触, 也就是接触流形(contact manifold)。

接触点与流形合并
图 6-7Docs/Architecture.md 第 957 至 963 行原文截取:接触点由 GJK 与 EPA 决定、每个接触点找所属面、两个面互相裁剪成接触流形、相似法线的流形随后合并到 ContactConstraintManager、以及窄相使用无锁的双接触缓存@ Jolt Physics v5.6.0 源码原文截取(MIT)

紧接着第 959 行说:法线相似的多个流形会被合并(在 PhysicsSystem::ProcessBodyPair 的 ReductionCollideShapeCollector 里),合并之后才在 ContactConstraintManager 里建接触约束、 算 Jacobian 与有效质量。第 963 行说窄相用了一个无锁的接触缓存:一读一写两个 cache, 上一步被保留下来的接触点会从读缓存复制进写缓存。

这三句话就是本篇的骨架:判定 → 裁剪 → 缓存。

1.3 锁的三段作用域

OK,把 §1.1 那句话展开成一张图,锁的作用域是三段:

锁的三段作用域
图 6-8锁的三段作用域:第一段在锁内做宽相查询拿到相交体,第二段在锁内构造 TransformedShape 快照(变换加引用计数形状加 BodyID),第三段整段在锁外做碰撞检测;标出快照为什么是一致的,以及为什么这样能把锁的持有时间压到最短@ 本系列绘制

这也解释了另一件在 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 里需要的全部能力,就只有这一个函数。

支持函数
图 6-9支持函数的几何:给定方向 v,取凸体上让点积最大的那个点;图示给出球、盒子、凸包三种形状在同一个方向上的支持点,并说明为什么一个凸体在 GJK 里只需要提供这一个函数@ 本系列绘制

2.2 把「距离」变成「原点在不在里面」

OK,有了支持函数,就可以做那件关键的事:把两个形状的距离问题,转成单集合的问题。

构造 A − B(闵可夫斯基差):把 B 关于原点做中心对称(也就是取 −B),再和 A 做闵可夫斯基和。 这个新集合有一个漂亮的性质:「A 与 B 相交」等价于「原点在 A − B 内」。 更一般地,A 与 B 的距离等于原点到集合 A − B 的距离。

闵可夫斯基差
图 6-10闵可夫斯基差的构造:先把 B 关于原点做中心对称得到负 B,再与 A 逐点相加;于是「A 与 B 相交」等价于「原点落在 A 减 B 内部」,GJK 的单纯形就是逐步逼近这个集合的过程@ 本系列绘制

说白了,GJK 的整个策略变成:在 A − B 上迭代地构造一个尽量靠近原点的单纯形(simplex)。 单纯形在 3D 里依次是 1 个点、2 个点的线段、3 个点的三角形、4 个点的四面体。 每一轮取一个新支持点,更新单纯形,让它离原点更近;如果某轮发现原点已经在四面体里, 就判相交。

言归正传,关键在于:每一步只需要调用两侧的支持函数各一次(A 沿着 v,B 沿着 −v), 就能拿到 A − B 上的一个新点。这就是 GJK 便宜的来源:它从来不去枚举面或边, 只做「问方向、要极值点」这一件事。

2.3 GJK 的三个入口,与它挂在谁的名字下

OK,Jolt 的 GJKClosestPoint 对外有三个入口,分别对应三种不同的问法:有的只想知道「碰没碰」,有的想知道「离多远、两侧最近的点各在哪」,还有的想知道「一条射线打到哪儿」。三个入口共用同一套单纯形迭代,区别只在收敛判据的用法与返回值上。

GJK 的三个入口
图 6-11GJKClosestPoint.h 第 19 至 21 行与第 207 与 327 与 516 行原文截取:类声明上注明基于 Gino van den Bergen 的快速鲁棒 GJK 实现;三个入口分别是 Intersects 只判相交、GetClosestPoints 返回平方距离与两侧最近点、CastRay 沿射线找凸体;另有第 506 行注明 CastRay 基于另一篇论文@ Jolt Physics v5.6.0 源码原文截取(MIT)
入口 位置 回答什么
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)。 两者的共同几何是「找一个轴,看投影有没有间隙」:

分离轴与投影区间
图 6-12两个凸集在某个轴上的投影区间不重叠即分离:图上画出分离轴、两个集合的投影区间与那条间隙。GJK 每轮迭代得到的向量 v 就是一个候选分离轴,收敛时它成为真正的分离轴;这是 GJK 与分离轴定理的共同几何@ Wikimedia Commons 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 (;;),没有任何计数器、也没有最大轮数:

主循环与四类终止判据
图 6-13GJKClosestPoint.h 第 353 至 470 行原文截取:主循环是 for 无限循环、没有迭代计数器;循环体内依次给出四类退出判据的原文,分别是 set 等于 0xf 的满单纯形、v_len_sq 小于等于 tolerance_sq 的距离归零、v_len_sq 小于等于 FLT_EPSILON 乘 y 长度平方的机器精度、以及 prev_v_len_sq 减 v_len_sq 小于等于 FLT_EPSILON 乘 prev_v_len_sq 的收敛分离@ Jolt Physics v5.6.0 源码原文截取(MIT)
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 的全部「出口」:

四类出口
图 6-14GJK 主循环的四类出口判定树:满单纯形(原点在四面体内,判相交)、距离归零(长度平方小于容差平方,判相交)、机器精度(长度平方小于浮点精度乘 y 长度平方,判相交)、收敛分离(长度不再下降且 v 成为分离轴,判不相交),另有一类提前退出是超过 inMaxDistSq;每处标出官方在源码里打印的 Trace 原文@ 本系列绘制

三处判相交,一处判分离,一处提前退出。注意官方的 Trace 文案本身就是一份命名: Full simplex、Distance zero、Machine precision reached、Converged。 本机实验里的计数器就是挂在这四个字符串对应的位置上,不改任何逻辑。

3.3 本机实测:四类出口各占多少

把 144 个箱子堆成 4 层,下落期与稳态分开量,再单独跑 2000 次 CollideShape 探针 (球,半径 0.45 m,绕着堆转圈)。结果如下:

GJK 出口分布
图 6-15本机实测:GJK 四类出口的分布。下落期 480 次调用里 94.6% 以「无更近点」结束、5.4% 以「收敛分离」结束,满单纯形与距离归零与机器精度全为 0;查询期 2340 次调用里则是「无更近点」68.2%、「满单纯形」15.3%、「超 inMaxDistSq」14.5%、「收敛分离」1.8%,机器精度 1 次@ 本机基准实测(原创)
工作负载 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 本机实测:每次查询平均跑几次

下面进入迭代数这一段,先看一组数:

每次查询的迭代数
图 6-16本机实测:每次 GJK 调用的迭代数。下落期平均 4.719 次、查询期平均 3.436 次,两个工作负载的单次最多都是 25 次;而源码里根本没有迭代上限这个变量,所以 25 是本机场景下实测到的最大值,不是引擎的设定值@ 本机基准实测(原创)
工作负载 迭代合计 平均每次 单次最多
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 行)写得很完整, 连自己偏离论文的地方都承认了:

EPA 的出处与步骤
图 6-17EPAPenetrationDepth.h 第 16 至 35 行原文截取:先注明算法来自 Gino van den Bergen 的 Proximity Queries and Penetration Depth Computation on 3D Game Objects,随后明确写了本实现不完全照论文走:不按论文图 7 那样逐边切三角形,而是构建凸包并删掉朝向新点的三角形,以避免过长的细三角形;下面是一串算法步骤注释@ Jolt Physics v5.6.0 源码原文截取(MIT)

官方承认的那处偏离,值得原样抄一遍: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 去求穿透深度。
EPA 的三道门
图 6-18EPA 的三道门:左门是核心间距平方大于两侧凸半径之和的平方,判没碰;中门是核心间距平方大于 0 而小于等于凸半径之和的平方,判接触成立且接触点直接由 GJK 的最近点给出,不进 EPA;右门是核心间距平方等于 0,即两个核心真的重叠,才返回 Indeterminate 交给 EPA 求穿透深度@ 本系列绘制

把上一层的 GetPenetrationDepth 并排看,这条链就闭合了。它的正文是一个三分支 switch:

三分支 switch
图 6-19EPAPenetrationDepth.h 第 499 至 517 行原文截取:GetPenetrationDepth 把 StepGJK 的返回值丢进一个三分支 switch:Colliding 直接 return true(接触点已由 GJK 给出)、NotColliding 直接 return false、只有 Indeterminate 才去调 StepEPA@ Jolt Physics v5.6.0 源码原文截取(MIT)

言归正传,这里有一条可以自己验的恒等式。 既然只有 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 触发率是多少

EPA 触发率
图 6-20本机实测:EPA 触发率。下落期 480 次 StepGJK 调用一次都没进 EPA(触发率 0%);查询期 2340 次里进了 360 次,触发率 15.38%。差别在于探针球有没有插进体的内部,静止堆叠的箱子核心之间隔着两侧凸半径的垫层,永远进不了 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 的迭代数
图 6-21本机实测:EPA 的迭代数与硬上限。查询期 360 次 EPA 共迭代 8328 次,平均 23.13 次每次,其中 328 次(91.1%)以「误差足够小」收敛退出;而 EPAConvexHullBuilder 的硬上限是 cMaxTriangles 等于 256、cMaxEdgeLength 等于 128、cMinTriangleArea 等于 1e-10@ 本机基准实测(原创)
项 值 出处
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 是穿透最深方向上的一对点, 它们是「深度在哪最浅」的见证,不是接触面的取样。

EPA 不给接触点
图 6-22EPA 的输出与接触点的来源分成两栏:左栏是 EPA 给出的东西(一个分离轴方向加一个穿透深度,以及穿透最短路径上的一对见证点),右栏是随后由 ManifoldBetweenTwoFaces 用两侧支撑面互相裁剪出的接触点集合;两栏之间标出「EPA 到此为止」的分界线@ 本系列绘制

那接触点是谁产的?是 §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】两侧接触点输出
流形裁剪的入口
图 6-23ManifoldBetweenTwoFaces.h 第 38 行签名与 ClipPoly.h 四个裁剪函数原文截取:入口收两侧接触点、穿透轴、最大接触距离与两侧支撑面,输出两侧接触点数组;内部调用 ClipPolyVsPoly 与 ClipPolyVsEdge,而这两个函数住在 Jolt 的 Geometry 目录而不在 Collision 目录@ Jolt Physics v5.6.0 源码原文截取(MIT)

它的实现里连着两步。第一步 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 面-面互裁的三步

把这件事画出来是这样:

面-面互裁三步
图 6-24面-面互裁的三步:先把形状 2 的支撑面裁到形状 1 的支撑面上(ClipPolyVsPoly),得到交集多边形;退化到只有一个点或一条边时改走 ClipPolyVsEdge;最后把结果里离穿透轴太远的点剪掉(PruneContactPoints)。图中标出「点-面」与「边-边」两类接触各自会裁出什么形状@ 本系列绘制

用一句话概括:把两个支撑面摆到同一个平面上,求它们的交集。 两个面都是凸多边形,交集自然还是凸多边形(或者退化成线段、点), 这个多边形的顶点就是接触点。

5.3 本机实测:实际报几个点

这是本篇最干净的一组数据。同一套统计代码,一个球堆、一个箱堆,各 100 个、各 150 步:

两个堆都禁睡,跑完看统计:球堆每步同时存在的点数峰值是 124,箱堆是 400。

流形点数分布
图 6-25本机实测:接触流形的点数分布。球堆 14346 个流形百分之百是 1 点,合计点数 14346,平均每流形 1.00 点;箱堆 14000 个流形百分之百是 4 点,合计点数 56000,平均每流形 4.00 点。两类形状的点数分布是彻底的、无例外的两极化@ 本机基准实测(原创)
场景 流形累计 合计点数 平均每流形 点数分布
球堆(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:

容量 64 与六个字段
图 6-26ContactListener.h 第 16 行与第 19 至 36 行原文截取:ContactPoints 定义为 StaticArray 小于 Vec3 与 64 大于,也就是容量 64;ContactManifold 的六个字段分别是基准偏移、世界空间法线、穿透深度(为负表示这是推测接触点)、两个子形状 ID 与两侧相对接触点数组@ Jolt Physics v5.6.0 源码原文截取(MIT)
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 点流形」这个说法从哪来的?它是通用引擎做法,而且有它的道理:

4 点与 64 容量
图 6-27两条口径并排:左列是通用引擎的「每个流形最多 4 点」做法及其理由(接触点太多会让求解不稳、且要跨帧继承冲量做 warm start),右列是 Jolt 的实际口径(数组容量 64,实际点数由裁剪结果决定,本机实测球堆 1 点、箱堆 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 边

裁剪用的是「接触点所属的那个面」,那么「面」找得对不对,就直接决定流形裁得对不对。 这里有一个坑,官方专门画了图:

active 边与 inactive 边的法线
图 6-28Jolt 官方图:命中 active 边(凸边)与 inactive 边(凹边)时的接触法线取法不同。同一个接触,若按 inactive 边算,会得到一条指向「内部」的伪法线,于是裁出一个错误的接触面。这张图是流形裁剪的上游条件,也是幽灵碰撞的成因@ Jolt Physics Docs/Images/ActiveVsInactiveContactNormal.jpg(MIT,Jorrit Rouwe)

这属于网格内部边(internal edge)问题:三角网格里相邻两个三角形共享的那条边, 在几何上并不存在一个「真正的硬边」,但如果窄相把它当硬边处理,就会出现幽灵碰撞 (物体从平坦的网格面上滑过时被看不见的边绊一下)。Jolt 的处置有两件:

  1. CollideShapeSettings 里有一个 mActiveEdgeMode(CollideShape.h:77),控制只与 active 边碰撞 还是与所有边碰撞;mActiveEdgeMovementDirection(第 89 行)用来判断哪个方向的撞击才算数。
  2. 另有一个 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】推测接触距离
    ...
}
体对缓存分支
图 6-29PhysicsSystem.cpp 第 1079 至 1085 行原文截取:体对缓存命中则整段窄相被跳过,官方注释写明这是为了在「两个体的相对姿态没变」时省掉窄相碰撞检测;开关叫 mUseBodyPairContactCache 且默认为真,失效判定走 Body::IsCollisionCacheInvalid@ Jolt Physics v5.6.0 源码原文截取(MIT)

下面对上面这段代码特别说明两点。

第【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,把开关关掉再跑一遍同一场景,最能说明问题:

体对缓存的代价
图 6-30本机实测:体对缓存的开与关。同一场景同样 420 步(含下落与稳态),缓存打开时 GJK 调用 480 次、总耗时 38.9 毫秒;缓存关闭时 GJK 调用 59112 次、总耗时 45.3 毫秒。调用数相差 123 倍,耗时相差 1.16 倍@ 本机基准实测(原创)
配置 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。 跑这一步时,读缓存放着上一步的接触对,写缓存收这一步的新配对; 如果某个接触点被保留下来(判定为持久接触),它就从读缓存复制进写缓存。

无锁双缓存
图 6-31无锁双接触缓存的读写:读缓存放着上一步的接触对、写缓存收这一步的新配对;体对缓存命中时接触点直接从读缓存复制进写缓存(持久接触),未命中时才走窄相并把结果写进写缓存;两个缓存按步轮换,不需要加锁@ 本系列绘制

「无锁」这件事在这里是自然的。 因为每一步只有一个线程负责写写缓存,读缓存只读不写, 两个缓存按步交替角色,天然没有写写冲突。这和 ep04 里宽相树的「放宽父节点路径」是同一族的思路: 用「数据布局」换「不加锁」。

而「把上一步的接触点复制过来」这件事,正是 warm start(热启动)的地基: 求解器拿到上一步的接触点与冲量,这一帧就不必从零开始迭代。那条线归第八、九篇。

七、三个容差常量

OK,窄相这一层所有的「精度」与「什么时候停」,最后都落在几个常量上。它们集中在一个文件里:

三个容差常量
图 6-32PhysicsSettings.h 第 9 至 22 行原文截取:cDefaultCollisionTolerance 等于 1e-4(注释说明比这个距离更近就算碰撞,用于 GJK)、cDefaultPenetrationTolerance 等于 1e-4(注释说明平方距离的变化小于容差乘当前穿透深度的平方就终止,行尾写着「变化小于 1% 就停」)、cDefaultConvexRadius 等于 0.05(如何给物体加垫层)、cCapsuleProjectionSlop 等于 0.02、以及内边消除的顶点容差平方 1e-8@ Jolt Physics v5.6.0 源码原文截取(MIT)
常量 值 单位 管什么 位置
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 核到。

三个容差的管界
图 6-33三个容差各管哪一段:碰撞容差管 GJK 什么时候宣布「碰上了」、穿透容差管 EPA 什么时候宣布「深度够准了」、凸半径管几何上「多近才算接触」;三者分别落在判定的不同阶段,量纲也不同(前两个一个是长度、一个是无量纲因子、第三个是长度)@ 本系列绘制

三个常量里最容易搞混的是后两个:碰撞容差 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。 尺寸不变,变的是「边角有多圆」。

凸半径:先内缩再等量外扩
图 6-34Jolt 官方 ConvexRadius.jpg:凸形状先按凸半径内缩、碰撞时再等量外扩,得到碰撞系统实际使用的圆角凸形;官方在注释里把它称为 padding,Godot 侧文档则把它对应到其它引擎的 collision margin,两者说的是同一件事:碰撞用的几何比画出来的几何小一圈、外面裹一层垫子@ Jolt Physics Docs/Images/ConvexRadius.jpg(MIT,Jorrit Rouwe)
凸半径的几何链
图 6-35凸半径的几何链:外廓半长减凸半径得到核心半长,碰撞检测在核心上做,再按凸半径等量外扩回外廓;于是「刚好接触」在数值上等价于两个表面之间隔着一层厚度为两侧凸半径之和的垫层,默认各 0.05 米,合计 0.1 米@ 本系列绘制

这条链解释了三件在前面出现过的事:

  1. 为什么 §4.3 里下落期的 EPA 触发率是 0%:静止堆叠的箱子核心之间隔着 0.1 米的垫层, 核心从未重叠。
  2. 为什么会有 Indeterminate 这个中间态:核心没重叠但垫层交上,几何上确实算接触, 但穿透深度算不出正值(真穿透只有核心重叠时才有),所以 GJK 单干就行、不必上 EPA。
  3. 为什么 §0 的症状 B(薄盒子穿过去)会发生:如果两个体的核心还没重叠就报「未接触」, 那么当相对速度很高、一帧跨过垫层时,就会出现「上一步没碰、这一步已经到另一边」的漏检。 这也是推测接触距离(mSpeculativeContactDistance = 0.02)存在的理由:提前报接触。

八、本机实测:逐形状对与凸半径

前面几节的实测都在回答「什么时候跑、跑几次」。这一节换两个问题:哪些形状对最贵, 以及凸半径到底改变了什么。

8.1 官方口径:逐形状对耗时

要说清这件事,得先解决一个问题:怎么量才不是自说自话。 本机用的是官方自带的窄相计时设施 JPH_TRACK_NARROWPHASE_STATS(NarrowPhaseStats.h)。 它内部维护两张按形状子类型索引的二维表:

官方窄相计时设施
图 6-36NarrowPhaseStats.h 第 27 至 44 行原文截取:NarrowPhaseStat 每个实例记四个量(查询数、上报命中数、总 tick、子 tick),并且有两张按形状子类型索引的静态二维表 sCollideShape 与 sCastShape;sReportStats 直接把结果输出成 CSV@ Jolt Physics v5.6.0 源码原文截取(MIT)
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。

CollideShape 逐形状对耗时
图 6-37本机实测(官方 instrumentation 口径):CollideShape 逐形状对耗时的两极化。每次查询最贵的是球对圆柱 0.014476%,最便宜的是圆柱对圆柱 0.000270%,相差 53.5 倍;绝对量最大的是盒对圆柱,3167 次查询占全篇窄相时间的 20.14%@ 本机基准实测(原创)
形状对(形状 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)

扫掠那一半是另一张表,规律不太一样:

CastShape 逐形状对耗时
图 6-38本机实测(官方 instrumentation 口径):CastShape 的 8 个形状对。盒对盒每次 0.001551%、球对球每次 0.000547%,整体比静态碰撞低一个量级;扫掠场景里 400 次查询只有 8 个形状对出现@ 本机基准实测(原创)
形状对 查询次数 每次查询
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,官方文档对凸半径给的是定性说法(半径越大越快、但仿真越不精确)。本机把它拉成曲线, 先测最规整的一类接触:两个平面相压。

凸半径与面-面接触
图 6-39本机实测:凸半径对箱堆(面-面接触)的影响。125 个箱子落成 5 层堆,凸半径从 0 到 0.01 到 0.05 到 0.1 到 0.2 五档,稳态每步耗时分别是 0.0877、0.0868、0.0916、0.0867、0.0801 毫秒,没有单调趋势,总量只有约 7% 的抖动@ 本机基准实测(原创)
凸半径 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°,让它用一条边落在平面上,其余全不变:

凸半径与边-面接触
图 6-40本机实测:凸半径对斜置单箱(边-面接触)的影响。45° 姿态落下 300 步,凸半径 0 与 0.01 时箱子滑走 7.53 与 7.76 厘米、倾角从 45 度落到 38.9 度;凸半径 0.05 与 0.1 与 0.2 时箱子原位不动(水平位移 0.049 厘米)、倾角保持 45.04 度。阈值正好落在默认值 0.05 上@ 本机基准实测(原创)
凸半径 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 里和窄相有关的条目按月历排开,会看到一个很集中的规律:

版本时间线
图 6-41与窄相有关的版本变更时间线:v4.0 修 EPA 穿透深度算法的法线翻转;v5.0 新增 SetUseManifoldReduction(会清接触缓存);v5.1 修 GetPenetrationDepthStepEPA 断言里的容差;v5.2 把 NarrowPhaseQuery 与 TransformedShape 的查询改成带 base offset、CastRay 与 CastShape 改收 RRayCast 与 RShapeCast;v5.3 把 mManifoldToleranceSq 去平方并改名 mManifoldTolerance、ManifoldBetweenTwoFaces 改收 inMaxContactDistance;v5.4 连修 ManifoldBetweenTwoFaces 两处(三点对两点找不到流形、法线与面法线不平行时相对接触点错误);v5.5 内边消除容差改为可配@ 本系列绘制
版本 条目 出处
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。其中四条只要读一个头文件就能确认,两条需要真跑一次程序;任何一条对不上,本篇对应的结论就该被推翻。

六条可检验命题
图 6-42六条可检验命题与各自的检验入口:左列命题,右列一条读源码或跑本机程序的路径。其中四条只要读一个头文件或一段官方文档就能确认,两条需要跑一次本机的 bench_narrow@ 本系列绘制
# 命题 检验入口
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 本篇的三层证据,与它们的分工

三层证据
图 6-43本篇三层证据的分工:官方层说设计意图(窄相第一节只有 8 行、先把锁放掉、接触点由两侧面裁剪而来、相似法线流形合并、无锁双接触缓存),源码层说实现方式(GJK 的四类终止判据且无迭代上限、EPA 的三态门槛、接触点容量 64、体对缓存失效标志位),实测层说真实代价(EPA 触发率 0% 与 15.38%、体对缓存 123 倍调用差、凸半径阈值 0.05、形状对耗时差 53.5 倍)@ 本系列绘制
层 它说什么 本篇的落点
官方文档层 设计意图 ## 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 的两处流形修复与 MeshShape active 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

权威算法原文

第三方素材与旁证

本机一手产物

  • 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.