Jolt Physics 3D · 第 九 篇

接触求解:sequential impulse、暖启动与 Baumgarte

2026-10-0520,134 字35 张配图本文由@溯光 出品,转载请注明出处。

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

牛顿摆
题图牛顿摆(Newton's cradle):一端抬起放下,另一端的球以同样的速度弹出,冲量在接触里一对一传递,这是「顺序冲量」最直白的实物形态@ Wikimedia Commons Newtons Cradle.jpg(CC BY-SA 4.0,The Dean of Physics)

承接上一篇(第八篇《约束家族》)的落点。第八篇把「约束(constraint)与接触(contact)共享同一个求解循环」这句话落在了目录结构上:ConstraintPart/ 里既有用户声明的约束,也有窄相(narrow phase)算出来的接触,两者走同一套求解接口、共用同一套迭代(iteration)与退出条件。这一篇把这个循环拆开,回答三件事:它一步里到底做了哪几个动作、每个动作被哪些参数控制、这些参数在实测读数上留下什么痕迹。

先把问题的形状摆出来。一个方块落到地面上,中间没有任何人写约束;落地的瞬间,地面与方块之间就出现了一条不允许穿透的关系。速度被重力积出来,穿透(penetration)随之出现,求解器要在这个时间步里把穿透方向上的相对速度抹掉,再把已经积进位置的偏差收回。这件事一次算不准,原因有两层。一层是规模,接触点的数量随场景每帧变化,上一帧有几个、这一帧落在哪几个方向上,都要等窄相算完才知道。另一层是耦合,一个接触点上的冲量会改变涉及的那两个体的速度,相邻接触点解出来的冲量又反过来影响它。要求精确解,等于每帧解一个规模随场景变化的线性互补问题(linear complementarity problem,LCP);工程上更划算的路子是迭代求近似。本篇要讲的顺序冲量(sequential impulse,SI)就是这条路在 Jolt 里的实现。

这条路有可以查证的出处。Docs/Architecture.md 第 731 行写着,Jolt 用的是「a Sequential Impulse solver with warm starting」,来源是 Erin Catto 在 GDC 2009 的讲稿《Modeling and Solving Constraints》。讲稿对这套方法的定义很短:一个迭代求解器,在每个约束上施加冲量去修正速度误差,快、稳,并且会收敛到一个全局解。这句话把本篇的三条主线都框住了:施加的动作、迭代的次数、以及一个近似解如何被稳住。

OK,正文从这一步在整条链上的位置讲起。

入口:迭代是这一层的唯一解法

OK,一句话说清这一节的要点:接触与约束都是「关系」,关系的精确解随场景规模浮动,能落地的只有迭代近似解,而迭代的每一遍都必须快。剩下的篇幅把这件事拆成两半,一半看一次「方块砸地」里发生了什么,一半交代本篇的主线与边界。

0.1 从一次「方块砸地」说起

好了,把镜头放慢到一个时间步。设方块此刻还悬在地面上方,速度为竖直向下。PhysicsSystem::Update 走完一步,顺序大致是这样:先装配速度约束,接触点带来的法线、接触点相对两个质心的力臂、两体的质量与惯性,在这里被折算成有效质量与偏置;接着把互相牵连的体划进同一个仿真岛(simulation island);再判定本步哪些接触真的参与求解;然后进入速度迭代(velocity iteration),把穿透方向上的相对速度按冲量的形式逐步削掉。速度解完,引擎把速度积分(integration)进位置,这一步会带来数值漂移;于是再进一轮位置迭代(position iteration),把已经发生的穿透收回一个可接受的范围。整条链上没有任何一步在解全局方程组,每一步都在局部做一次近似。

为什么近似是唯一可行的答案?回到耦合那一层。假设地面上同时压着三个方块,中间那块与左右两块各有一个接触点,左右两块又各自与地面有接触点。解左接触点得到的冲量会改变中间块的速度,中间块速度一变,中间接触点该出的冲量也跟着变,而这个变化又传回左接触点。四个接触点互相牵连,想一次全部算准,就得把它们的方程联立起来解一个随场景增长的系统。接触点的数量还在每帧变化,上一帧的方案这一帧未必能用。迭代避开了联立:它每一遍只处理一个接触点,把当前这一遍算出的冲量立刻打进两个体的速度里,让下一个接触点直接看到更新后的速度。第一遍给出的解是粗的,第二遍站在第一遍的结果上再修一次,如此往复。接触点越多、质量比越悬殊,需要的遍数越多,这一点在 §三 会量出具体读数。

把这一步的两段分开看,还能看出一处设计取舍。速度迭代在积分之前,位置迭代在积分之后。速度侧把「不该有的相对速度」消掉,位置侧把「已经错掉的位置」拉回来。两段之间夹着一次积分,这个次序决定了位置修正不再回写速度,也决定了两个迭代码可以各自独立设置。§1.2 会把这两段循环的结构摊开。

0.2 三条主线与边界

三条主线各自的落点写清。

  1. 结构的代价(顺序冲量):求解被拆成「一次迭代只处理一件事」,多个方向、多个接触点靠轮流逼近。落点 §一、§二。
  2. 精度的代价(迭代次数):速度迭代码决定穿透收敛,位置迭代码决定恢复速度,两者不能互相替代。本机 E1(1 kg 底 + 20 kg 顶,h = 0.25 m,300 步)里,速度迭代从 1 提到 30,最大穿透从 100.39 mm 收到 1.14 mm;本机 E5(8 层塔,300 步)里,暖启动关掉后把迭代从 2 加到 30,穿透仍在 3508 至 3520 mm 一带下不来。落点 §三 与 §五。
  3. 起点与余量的代价(暖启动(warm starting)、穿透容差(penetration slop)、Baumgarte 稳定化(Baumgarte stabilization)、投机接触(speculative contact)):好的初值让塔立住,允许一点穿透换来稳定。落点 §五、§六、§七。

反误区与四家引擎对照落在 §八,边界、演进与资源清单落在 §九。

边界声明也写进正文,供后面的交叉检验用:本篇不涉及岛屿切分与睡眠调度(第十篇)、连续碰撞检测(CCD)与确定性(第十一篇)、角色与载具成品件(第十二篇)、柔体与发束(第十三篇);也不从头证明投影迭代的收敛性,只用到少量线性代数记号。实验纪律一并交代,供复现:碰撞必须打开(本篇量的就是接触),只留 NON_MOVING×MOVING 与 MOVING×MOVING 两类配对;mAllowSleeping = false(体睡着后接触根本不建);线性与角阻尼一律置 0,把摩擦(friction)与阻尼分开;每个实验都报告它用的全局设置。编译口径是 evidence/build_bench_solver.ps1,零补丁、零 instrumentation,与第八篇同档的第四档证据形态。可复现性也有一条硬读数:同一个可执行文件连跑三次,剥掉耗时列之后 36 行数据 0 处差异,位置与穿透读数确定,只有耗时随负载浮动。

好了,把范围收好,从求解在一步里的位置开始拆。

源码落点:这一节的两条判断各有出处。Docs/Architecture.md 第 731 行给出求解器的名字,PhysicsSystem.cpp 第 492 行与第 556 行给出两段求解在一步里的位置。其中:「求解」在这份代码里没有对应的类,它落成两个 job;这也解释了为什么在源码目录里搜不到任何名叫 Solver 的文件。

一、一步里的求解:六站流水与两个循环

说白了这一步的骨架可以先用一句话说清:先定谁参与,再定轮到谁、解几遍,最后才轮到每一遍算什么。这一节把前半截摆出来:求解在一步里的站位、两个循环的形状、控制它们的六个旋钮,以及这六个旋钮该怎么归类。

1.1 一步里的求解位置

OK,顺序冲量这个名字来自 Catto 的讲稿,定义里有两个关键点:它施加的是冲量(impulse,单位牛秒),它在每个约束上依次施加。把这两点拆开,正好对应这一层在一步里的位置与单次迭代的动作。

先看位置。求解不是孤立的一段代码,它是 PhysicsSystem::Update 里排好队的一串求解作业(solve job)。本机核过的顺序是六站:SetupVelocityConstraints 在 PhysicsSystem.cpp 第 360 行,建岛(build islands)在第 368 行,确定活跃约束(determine active constraints)在第 379 行,SolveVelocityConstraints 在第 492 行,把速度积分进位置在第 521 行,SolvePositionConstraints 在第 556 行。前四站服务速度,第五站把速度落成位置,第六站收拾位置上的偏差。

每一站各管一件事。装配站把接触点带来的法线、接触点相对质心的力臂、两体的质量与惯性这些几何量,折算成后续迭代反复要用的有效质量与偏置,算一次用很多次。建岛站把彼此牵连的体拢在一起,让同一组内的问题交给同一批线程,也决定了迭代次数按哪个范围取上限。定活跃站剔掉本步不再需要的接触,避免为已经分开的两体白算一遍。速度迭代站是主力,它在一个循环里反复调用约束与接触的求解接口,直到冲量过小或触到上限。积分站把解出来的速度沿时间步推进到位置上。位置迭代站处理位置上的漂移,把穿透收回一个可接受的范围。

一步里的求解:六站流水
图 9-1一步里的求解:六站流水。速度侧在积分之前、位置侧在积分之后,位置修正因此不再回写速度@ 本系列绘制

一句结论压住这一小节:速度与位置是分开的两段,中间夹着一次积分。这个次序有个直接后果,位置修正发生在积分之后,所以它改的是「已经错掉的位置」,不会再回写速度;这也解释了为什么位置迭代次数与速度迭代次数可以各自独立设置而互不干涉,两段循环的上限分别来自 mNumVelocitySteps 与 mNumPositionSteps。还有一层:这六站里只有两站是真正跑循环的,其余四站都是「准备」或「收尾」,把成本主要压在速度与位置两个循环上。

1.2 两个循环与「按需提前退出」

好了,两段求解各自是一个循环,结构一模一样:跑满上限,或者在某一遍里发现再没有冲量可施加时提前退出。速度侧在 PhysicsSystem.cpp 第 1530 至 1536 行,位置侧在第 2664 至 2670 行。速度侧那几行的原文是:

// -------【速度迭代循环】PhysicsSystem.cpp:1529-1539------
// Solve velocity constraints
for (uint velocity_step = 0; velocity_step < steps_calculator.GetNumVelocitySteps(); ++velocity_step)
{
    bool applied_impulse =
        ConstraintManager::sSolveVelocityConstraints(active_constraints, constraints_begin, constraints_end, delta_time);
    applied_impulse |= mContactManager.SolveVelocityConstraints(contacts_begin, contacts_end);
    if (!applied_impulse)
        break;
}

// Save back the lambdas in the contact cache for the warm start of the next physics update
mContactManager.StoreAppliedImpulses(contacts_begin, contacts_end);

下面对这两个循环特别说明。第一,循环上限来自 mNumVelocitySteps,它是上限而不是实际执行遍数;只要某一遍里两个求解调用都没有施加任何冲量(applied_impulse 为假),循环立刻跳出。工程含义很直接,把上限从 2 提到 30,成本未必跟着涨,收敛快的问题两步就跑完了。第二,速度侧循环结束后紧跟一行 StoreAppliedImpulses(第 1539 行),把本步累计的冲量写回接触缓存,作为下一物理步的暖启动初值;这一行是「上一步的答案当这一步的起点」在源码里的落点,也是 §五 的主角。第三,位置侧循环同样带 break,但它的判据是位置修正量是否已经足够小,判据换个名字,结构不变。

速度迭代循环原文
图 9-2速度迭代循环原文(PhysicsSystem.cpp:1529–1539):循环上限来自每岛算出的步数,没有冲量可施加就 break 提前退出;第 1539 行把本步冲量写回接触缓存@ Jolt Physics v5.6.0 源码原文截取(MIT)

这里要留一个口子给后文:上限不等于开销,这句在 §五 会用读数检验。速度侧与位置侧在这组场景里的实际遍数并不相同,原因就藏在这个 break 的触发时机里。判据越严,循环越容易提前退出,名义上限与实际遍数的差距就越大。

1.3 PhysicsSettings 里的六个旋钮

把这一层的可调参数摊开,PhysicsSettings.h 里有六个直接管求解的默认值,它们决定了上面那条流水线在没有逐约束覆盖时的行为。

//------【PhysicsSettings 旋钮】PhysicsSettings.h:82-111(摘录:省略类型与文档注释,取值逐字)------
mNumVelocitySteps           = 10;    // 【1】速度迭代,注释:需 >= 2 摩擦才生效
mNumPositionSteps           = 2;     // 【2】位置迭代
mBaumgarte                  = 0.2f;  // 【3】位置误差被喂回的比例
mConstraintWarmStart        = true;  // 【4】暖启动开关,用上一步的冲量起步
mPenetrationSlop            = 0.02f; // 【5】允许穿透,0.02 m 即 20 mm
mSpeculativeContactDistance = 0.02f; // 【6】投机接触距离,20 mm

下面对这六个旋钮特别说明,逐个给注释里的原话与落点。

mNumVelocitySteps = 10,注释写着「this needs to be >= 2 in order for friction to work(friction is applied using the non-penetration impulse from the previous iteration)」。摩擦用的是上一轮的非穿透冲量,所以至少要有两轮才有「上一轮」可借;§4.1 会量出「1 次迭代摩擦完全失效」的读数。mNumPositionSteps = 2,位置侧的迭代上限。mBaumgarte = 0.2f,注释「how much of the position error to 'fix' in 1 update(0 = nothing, 1 = 100%)」,它是位置误差被喂回的比例,三档取舍落在 §六。mConstraintWarmStart = true,注释「initially applying previous frames impulses」,暖启动默认开着,关掉它的后果在 §五 与 §5.2 各量一次。mPenetrationSlop = 0.02f,注释「How much bodies are allowed to sink into each other」,允许一点穿透以避免抖动洗掉接触缓存,读数落在 §六。mSpeculativeContactDistance = 0.02f,注释警告太大会出现 ghost collisions,它让接触在还没真正碰上时就开始刹车,读数落在 §七。

六个求解旋钮的声明与文档注释
图 9-3PhysicsSettings.h 六个求解旋钮的声明与文档注释(节选,取值逐字):mNumVelocitySteps = 10(注释写明需 >= 2 摩擦才生效)、mNumPositionSteps = 2、mBaumgarte = 0.2f、mPenetrationSlop = 0.02f、mSpeculativeContactDistance = 0.02f、mConstraintWarmStart = true@ Jolt Physics v5.6.0 源码原文截取(MIT)

这六个值放一起看,职责分得很开:两个迭代码管「解几遍」,一个比例管「修多狠」,一个开关管「从哪起步」,两个距离管「提前多远刹车、允许陷多深」。四类职责各自独立,谁都不越界。

还有一条与这六个旋钮配套的取值规则得在这里交代。CalculateSolverSteps.h 第 20 至 40 行给出的算法是:一个岛的步数先取该岛内所有体与约束的覆盖值里的最大值,再(只要岛上有人用默认值)与全局默认取一次最大值。参与取最大的是每一个体与每一条约束的覆盖,而不是「接触与约束两类各取一个代表」。这条规则决定了上面那六个默认值什么时候是上限、什么时候会被抬高,做参数对照时要先想清楚自己落在哪一档。相邻的还有两个不在这六项里但同属这一层的值:最大单轮修正距离是 0.2 m(mMaxPenetrationDistance,注释写明是「in a single iteration」的上限),接触点保留冲量的距离阈值是 0.01 m 的平方(mContactPointPreserveLambdaMaxDistSq),它们分别服务位置修正的钳制与暖启动的复用条件,§五、§六 会用到。

1.4 六个旋钮的二次归类

把六个旋钮按「它管什么」重新归一次类,会发现它们分成三组,各管一段,而且三组的作用点完全不同:

归类 旋钮 默认值 作用点
迭代类(解几遍) mNumVelocitySteps 10 速度侧循环的上限(本机 E1 证明它是精度旋钮)
迭代类 mNumPositionSteps 2 位置侧循环的上限(本机 E2 证明恢复步数与它成反比)
稳定化类(怎么改) mBaumgarte 0.2 每轮把多少位置误差喂回去(本机 E3 的 β 扫描)
稳定化类 mPenetrationSlop 0.02 m 允许留多少穿透不修(本机 E4 证明它就是目标穿透)
稳定化类 mMaxPenetrationDistance 0.2 m 单轮修正量的上限(防止一次修太狠)
接触检测类(认不认得出) mSpeculativeContactDistance 0.02 m 提前多远就开始建接触(本机 E7 的扫描)

这组归类解释了后面两节的分工:迭代类旋钮买精度,稳定化类旋钮买平稳,接触检测类旋钮买「来得及」。三组里前两组互相不能替代(本机 E5 的二维网格),第三组决定的是前两组有没有机会起作用。

源码落点:六个站在 PhysicsSystem.cpp 的 Update 里,行号依次是 350、360、368、379、492、556;两个循环在 :1530-1536 与 :2664-2670;三组旋钮的默认值在 PhysicsSettings.h 第 44 至 111 行。其中:mNumVelocitySteps 那个 >= 2 的下限有一条独立的公式依赖,第四节会把它拆开。

二、单次迭代里算什么

站位与遍数都定了,问题落回最小的一步:一次迭代里到底算了什么。这一节把这一遍拆成四个量,并指出接触在这一层与约束享有同一套零件。

2.1 单次迭代的四个量

说白了速度迭代站里反复发生的事情只有一件:对一个约束,算一次冲量,立刻作用到两个体上,再换下一个约束。把这一次算清楚,整段循环就透明了。一次迭代涉及四个量,它们首尾相接。

第一个量是雅可比(Jacobian,记作 J)。它把一个接触点上的约束方向写成一行:法线方向 n,以及接触点相对两个体质心的力臂 r1、r2。源码里接触的雅可比是 J = [-n, -r1×n, n, r2×n],前两项属于第一个体,后两项属于第二个体,负号表示两边受的冲量方向相反。第二个量是有效质量(effective mass,记作 K),由 K = J M⁻¹ Jᵀ 算出,M 是两体的质量与惯性拼成的对角阵。它把「这一个约束方向上,单位冲量能换来多少相对速度变化」算成一个标量,后面的每次迭代反复用,不再重算。第三个量是冲量(记作 λ),由 λ = -K⁻¹ J v1 给出,v1 是这一刻的相对速度;负号的含义是把当前违反约束的那部分速度抵消掉。第四个量是速度更新,v1' = v1 + M⁻¹ P,其中 P = Jᵀλ 是把标量冲量摊回两个体上的投影。

这四行在源码里就是一段被冻结的公式块。ContactConstraintManager.h 第 117 至 148 行把它抄在了注释里:

//------【接触公式块】------
// 描述:ContactConstraintManager.h:117-148(公式在第 120-142 行),出处 GDC 2009,formulas from slide 50-53
v1'    = v1 + M^-1 P              // 【1】速度更新:把冲量折算成速度增量
P      = J^T lambda               // 【2】把标量冲量投影回两个体
lambda = -K^-1 J v1               // 【3】解出这一次迭代的冲量
K      = J M^-1 J^T               // 【4】有效质量,只跟几何与质量有关
C      = (p2 - p1) . n            // 【5】位置误差
J      = [-n, -r1 x n, n, r2 x n] // 【6】接触的雅可比,两体各占两项

下面对这四行公式特别说明。第一,K 只跟几何与质量有关,与其他约束当前解到哪一步无关,所以它能被装配站提前算好,此后每次迭代都只是标量与向量的乘加。第二,λ 里出现的是「这一刻」的相对速度,前一个接触点刚改过的速度会立刻体现在这里,这是顺序冲量名字里「顺序」两个字的来源,也是它区别于雅可比迭代(Jacobi iteration)的地方,后者要先算完所有约束的新速度再统一更新。第三,P = Jᵀλ 把一维冲量摊回两个体的线速度与角速度,角速度那一半靠力臂 r1、r2 的叉乘带出来,所以同样大小的冲量打在离质心更远的接触点上,会制造更多转动。

单次迭代里的四个量
图 9-4单次迭代里的四个量:Jacobian J → 有效质量 K = J M⁻¹ Jᵀ → 冲量 λ = −K⁻¹ J v1 → 速度更新 v1' = v1 + M⁻¹ P@ 本系列绘制
接触公式注释块原文
图 9-5接触公式注释块原文(ContactConstraintManager.h:117–148):出处写明 GDC 2009《Modeling and Solving Constraints》,公式取自 slide 50–53@ Jolt Physics v5.6.0 源码原文截取(MIT)

OK,把这四个量放回循环里看,「一次迭代只做一件事」的准确含义就浮出来了:它一次只消一个方向上的一部分速度误差,多个方向、多个接触点要靠反复轮流来逼近。这也是迭代次数能当精度旋钮用的根。还有一处值得记:C 那一行是位置误差,它在速度侧只以偏置的形式间接出现,真正拿它做修正的是位置迭代,这条分工是下面两个循环各管一段的依据。

2.2 接触也是原语之一

OK,前面四节讲的都是接触侧,为了不让读者觉得这一层是接触专属,最后把接口的形状点破。接触在求解侧的落点叫 ContactConstraintPart,它在 ConstraintPart/ 目录里,与第八篇数过的那 14 个可复用求解原语(solver primitive)同处一处。ContactConstraintPart.h 第 84 行有一句原话:「This is a copy of AxisConstraintPart, specialized to handle contact constraints」。

这句话把两层关系说清了。一层是构造关系,接触的求解不是另起炉灶写的,它是单轴平移原语的一个特化版本;单轴原语负责「沿一个方向消掉相对速度」,接触在这个骨架上补上接触特有的东西,比如非穿透的方向约束、两个切线方向上的摩擦,以及接触只能推不能拉(total_lambda = max(total_lambda, 0))。另一层是接口关系,特化之后,它提供的那套动作与约束侧一致,装配、暖启动、速度迭代、位置修正四件都在,所以速度循环里那两行相邻的调用(约束一行、接触一行)能并排出现,用的是同一个 SolveVelocityConstraint 名字。

接触与约束共享同一套求解接口
图 9-6接触与约束共享同一套求解接口:接触侧的 ContactConstraintPart 就是单轴平移原语的接触特化版,它提供的四个动作与约束侧一致@ 本系列绘制

把这一节收成一句:接触与约束共享同一套求解接口,这套接口的公共形状是第八篇 §3.3 那四个动作。此后所有的讨论都落在这个接口上,与求解的对象是「人写的约束」还是「几何算出的接触」无关。这也是本篇开头那句话的最后一块拼图。

把两条入口并排看,零件是同一批。

约束侧与接触侧的两条入口
图 9-7约束侧与接触侧的两条入口:一侧是用户声明的 14 个可复用原语,一侧是窄相每帧送来的接触约束,两者在速度迭代循环里并排调用同一个接口@ 本系列绘制

源码落点:四个量的公式定义在 ContactConstraintManager.h 第 117 至 148 行的注释块里(出处 117–118 行、公式 120–142 行),真正算它们的是 ContactConstraintPart.h 的 CalculateConstraintProperties(第 112 至 158 行)与 SolveVelocityConstraintGetTotalLambda(第 169 至 195 行)。其中:K = J M⁻¹ Jᵀ 这个公式决定迭代求解能不能跑起来,它把「质量与转动惯量」翻译成「这条约束上的有效质量」,冲量才有量纲可除。

三、精度旋钮:迭代次数

好了,一句话说清这一节:迭代次数是这一层最直接的精度旋钮,它分成速度与位置两个独立方向,分别管穿透收敛与位置恢复。这一节用四组本机实验把它量清楚:E1 扫速度迭代看穿透收敛,E2 扫位置迭代看恢复速度,E6 扫速度迭代看摩擦,E5 把速度迭代与暖启动交叉扫,检验两个旋钮能不能互相替代。四组实验的场景与全局设置各自标在表头,全部满足 §0.2 那条纪律:碰撞打开、mAllowSleeping = false、阻尼置 0。

先看总览:

实验 扫的旋钮 场景(本机口径) 一句话结论
E1 速度迭代 1 / 2 / 3 / 4 / 10 / 30 1 kg 底 + 20 kg 顶,h = 0.25 m,300 步 最大穿透 100.39 → 1.14 mm,单调收敛
E2 位置迭代 1 / 2 / 4 / 8 / 32 5 cm 初始重叠,h = 0.25 m,slop 置 0,300 步 恢复步号 20 / 10 / 5 / 2 / 0,成反比
E6 速度迭代 1 / 2 / 3 / 4 / 10 方块 5 m/s 平地滑行,μ = 0.5,300 步 = 5 s 1 次迭代滑满 25000 mm,摩擦失效
E5 暖启动 × 速度迭代 2 / 10 / 30 8 层塔,300 步 暖启动关时迭代加不上去,两旋钮不可互替

3.1 速度迭代:轻底重顶的收敛曲线(E1)

说白了先量速度迭代。E1 的场景是一个两层塔:底下 1 kg,顶上 20 kg,落差 h = 0.25 m,跑 300 步,质量比 20 比 1(本机 E1)。顶块比底块重二十倍,这个比例把「冲量要一层层传下去」的难度放大了。速度迭代数取 1、2、3、4、10、30,读最大穿透与两端稳态穿透:

vel_steps 最大穿透 (mm) 底块稳态 (mm) 顶块稳态 (mm) 顶端 vy 抖动
1 100.3859 19.2340 39.0772 9
2 48.3830 18.8858 33.4977 6
3 30.7351 8.3928 19.2789 10
4 22.0304 2.1864 14.6538 7
10 6.7437 0.3893 5.1752 11
30 1.1371 0.0616 1.0071 21

读数里能读出四件事。第一,收敛是单调的,速度迭代从 1 提到 30,最大穿透从 100.39 mm 收到 1.14 mm,掉了约两个数量级,没有反复。第二,4 次到 10 次之间还留着一大截,22.03 mm 到 6.74 mm,说明默认 10 次是「够用」而非「富余」,重顶压轻底的场景还得往上加才干净。第三,抖动次数并不随迭代数单调,1 次是 9、10 次是 11、30 次是 21;迭代越多,解越接近精确,越会把接触反复建立又分开的小幅振荡表现出来,穿透在收、抖动在涨,两个指标得一起看。第四,顶块稳态穿透始终大于底块,10 次迭代时 5.1752 mm 对 0.3893 mm,因为冲量从接触面往下传,重的顶块把轻的底块压住,传不到位时最先露馅的正是底部那个接触。

本机 E1 速度迭代收敛曲线
图 9-8本机 E1 速度迭代收敛曲线:1 kg 底 + 20 kg 顶两层塔,速度迭代 1/2/3/4/10/30 对应的最大穿透是 100.39 / 48.38 / 30.74 / 22.03 / 6.74 / 1.14 mm,单调收敛@ 本机基准实测(原创)

这条曲线的形状也解释了 §2.1 那四个量为何要「顺序」施加。每一遍迭代只把当前速度改一点点,重物的速度要经过好几遍才能把冲量攒够,攒不够的部分就留在穿透里。迭代次数在这里的作用是「给几次机会」,给的次数越多,欠账越少。

3.2 位置迭代:恢复步数与迭代数成反比(E2)

OK,再量位置迭代。E2 的场景是一个已经重叠的接触:初始重叠 5 cm,落差 h = 0.25 m,把穿透容差 slop 置 0,跑 300 步(本机 E2)。slop 置 0 是为了让「修到多干净」看得见,否则允许的 20 mm 穿透会把小误差盖住。位置迭代数取 1、2、4、8、32,读恢复到 0.5 mm 以内所需的步号与稳态穿透:

pos_steps 恢复到 ≤0.5 mm 的步号 稳态穿透 (mm)
1 20 0.0001
2 10 0.0001
4 5 0.0001
8 2 0.0001
32 0 0.0001

读数里能读出三件事。第一,恢复步数与迭代数成反比,1×20、2×10、4×5 三组的乘积都是 20,8×2 得到 16 也接近,规律干净:每多一倍迭代,同样的初始重叠用大约一半的步数就能收回阈值内。第二,32 次时恢复步号是 0,也就是第一步就到位;这不神秘,32 遍迭代里每轮按同一比例收一段,累计下来一步内就把 5 cm 收到阈值以下。第三,稳态穿透全部是 0.0001 mm,与迭代数无关;slop 置 0 时,位置迭代会把穿透一直收到读数精度的极限,迭代数改变的是「多快收完」,不改变「最终收到哪」。

本机 E2 位置迭代与恢复步号
图 9-9本机 E2 位置迭代:5 cm 初始重叠、slop 置 0 时,位置迭代 1/2/4/8/32 对应的恢复步号是 20 / 10 / 5 / 2 / 0,与迭代数成反比@ 本机基准实测(原创)

这组读数正面回答了一个常见疑问:位置迭代不是精度不够,它是「收得慢」。只要给够步数,它会收到 0.0001 mm。反过来说,位置迭代次数低时看到的「残余穿透」,多半是还没轮到的欠账,而不是它修不动。

3.3 一个实物对照:叠叠乐

好了,最后落一个实物。叠叠乐(Jenga)把一叠木条交错码成塔,玩家从下面抽一根再摆到顶上,塔能不能立住,取决于每一层的接触面有没有足够摩擦。木条之间没有胶、没有榫,全部的稳定都来自正向压力换来的摩擦。抽走一根,压力重新分配,摩擦跟着重算,塔面上任何一处的欠账都会往上层累。

这个实物与前面两组读数指向同一件事:摩擦要靠反复迭代才立得住。E6 里 1 次迭代的方块等于在冰面上滑,摩擦上界是 0,叠叠乐对应的是每一层都滑一下;E5 里暖启动关掉后塔整层下沉,叠叠乐对应的是每一根都失去上一帧的支撑力记忆、从头重新商量该出多大力。堆叠场景的难度不在单个接触,在接触之间的传递:冲量要从最下面一层往上走,走的轮数不够,或者起点给得不好,塔顶就先出问题。这也解释了为什么叠叠乐这种玩法对帧率与参数格外敏感,它把求解器的欠账直接暴露成了一个能看见的塔形。

叠叠乐实物
图 9-10外部真图:叠叠乐。抽木块的过程把「堆叠靠摩擦立住」变成可见的动作,对应「摩擦必须 ≥ 2 次速度迭代才生效」@ Wikimedia Commons Jenga arrangement.jpg(CC BY-SA 3.0,Jason7825)

本节与 §四、§五 的四组读数合起来,结论可以收成三句:速度迭代决定穿透收敛,位置迭代决定恢复速度,暖启动与迭代次数各管一段、不能互替。迭代次数解决的是「给几次机会」,它解决不了「起点对不对」与「允不允许留一点误差」。位置修正具体按什么比例收、允许收不干净到什么程度,越过了迭代次数这条主线,留到 §六。

源码落点:三组读数分别来自 evidence/bench_solver_run_20261004.txt 的 E1、E2、E5 三段,实验代码随本篇存档在 evidence/bench_solver.cpp(E1() / E2() / E5() 三个函数,可直接复跑);旋钮定义在 PhysicsSettings.h 第 82 至 87 行。其中:这三组的共同结论可以收成一句:迭代数是精度旋钮,但它买不到暖启动买的东西。

四、摩擦:一次迭代就完全失效

说白了迭代次数是精度旋钮,但它不是这一层唯一的约束条件。有一类量会被迭代的次序直接卡住,摩擦就是最典型的一个:它的上限来自上一轮的非穿透冲量,而第 1 轮还没有「上一轮」。

4.1 摩擦:一次迭代就完全失效(E6)

第三节量摩擦。E6 的场景是一个方块以 5 m/s 初速在平地上滑行,动摩擦系数 μ = 0.5,落差 h = 0.25 m,跑 300 步(也就是 5 秒),读滑行距离、末速与穿透(本机 E6)。速度迭代数取 1、2、3、4、10:

vel_steps 滑行距离 (mm) 末速 (m/s) 穿透 (mm)
1 25000.04 5.0000 10.3768
2 2749.23 0.0000 0.6565
3 2386.98 0.0000 0.1199
4 2495.36 0.0000 0.5093
10 2506.84 0.0000 0.0003

读数里能读出三件事。第一,1 次迭代时方块滑满 25000.04 mm 且末速仍是 5.0000 m/s;无摩擦上界是 5 m/s × 5 s = 25000 mm,实际值与上界几乎相同,等效于摩擦力一点都没出。第二,从 2 次开始,滑行距离掉回 2387 至 2749 mm 区间,末速全部归零;理论刹停距离是 v₀²/(2μg) ≈ 2548 mm,四个读数围着这个值上下分布,说明摩擦恢复了正常工作。第三,穿透一列顺带解释了摩擦为什么卡在第一次,1 次时穿透 10.3768 mm,从 2 次起掉到 1 mm 以下,法向的欠账没了,切向才有上限可用。

机理写在源码注释里。PhysicsSettings.h 第 82 至 84 行写着「this needs to be >= 2 in order for friction to work(friction is applied using the non-penetration impulse from the previous iteration)」。摩擦切线方向的冲量上限受法向冲量约束,法向冲量又只能取自「上一轮」,所以第一轮里没有可用的法向冲量,摩擦上界是 0,方块就在冰面上滑。这也是一条容易踩的坑:把速度迭代设成 1 想省性能,代价是整套摩擦消失。

本机 E6 摩擦滑行距离
图 9-11本机 E6 摩擦滑行距离:方块以 5 m/s 平地滑行、μ = 0.5,速度迭代 = 1 时滑满 25000 mm 且末速仍 5.0000 m/s;≥ 2 后回到理论刹停距离 2548 mm 附近@ 本机基准实测(原创)

还要带一条版本提醒。本快照的摩擦模型是 2026-05-31 的新模型(ReleaseNotes.md 第 18 行,属于破坏性变更),与 v5.5.0 及之前不同;「1 次迭代摩擦完全失效」这条结论在此快照版本下成立。

新模型本身也值得记一笔。它把摩擦从「每个接触点各算一次」改成「先求平均接触点,在那里统一施加」,摩擦整体由 2 个线性约束与 1 个角约束组成,线性约束的上限取 μ·sum(contact_impulse),角约束的上限取 μ·sum(距离 × contact_impulse)。这组约束的数学形式与本节前两条(非穿透、恢复系数)处在同一层,区别只在它们是切向的。

一个流形上的三条摩擦约束
图 9-12一个流形上的三条摩擦约束:2 个线性约束(切向 t1 / t2)加 1 个角约束,上限分别是 μ × Σ非穿透冲量与 μ × Σ(离摩擦中心距离 × 冲量)@ 本系列绘制

4.2 摩擦为什么排在非穿透之后

摩擦为什么必须排在非穿透之后,源码把理由写在了紧挨着的两行注释里。第一行是次序:First apply friction constraint (non-penetration is more important than friction)。第二行给出原因:we're using the non-penetration impulse from the previous iteration here;紧跟的一句把道理点破:non-penetration is more important so is solved last,再补一句 the last things that are solved in an iterative solver contribute the most。

其中:「上一轮的非穿透冲量」就是那道 2 次迭代的门槛。第 1 轮迭代时,本步还没有任何非穿透冲量可用,摩擦的上限只能取到 0(或上一步存回的暖启动值),于是摩擦等于没施加。到了第 2 轮,第 1 轮算出的非穿透冲量已经就位,摩擦这才被真正钳住。E6 读到的那一组数据正是这条次序的产物:迭代数 1 时方块滑满 25000 mm 且末速仍是 5.0000 m/s,迭代数 2 时才回到 2749.23 mm。

再看第三个事实:Contact constraints can only push and not pull。接触约束的累计冲量在每一轮结束时被钳到非负,total_lambda = max(total_lambda, 0)。触摸只能推、不能拉,这条约束是所有摩擦上限的来源:没有非零的正向法向冲量,就没有可用的摩擦预算。三件事连起来读,mNumVelocitySteps 那个 >= 2 的下限是这条求解次序的数学要求,而不是一句调参建议。

次序说完,可以把它放回一条时间轴上:一轮迭代里摩擦先算,非穿透后算,最后把非穿透的累计冲量钳到非负。

单次迭代内的施加次序
图 9-13单次迭代内的施加次序:先解摩擦、再解非穿透约束、最后把累计冲量钳到非负;第 1 轮没有非穿透冲量可用,摩擦预算为零@ 本系列绘制

源码落点:摩擦的求解次序在 ContactConstraintManager.cpp 第 1701 至 1755 行,钳制规则的定义在第 187 与 193 行两行注释里(|F_T| <= mu |F_N| 与 |lambda_T| <= mu |lambda_N|)。其中:mNumVelocitySteps 的下限之所以是 2,公式侧的原因就在这段次序里:第 1 轮没有非穿透冲量可用,摩擦预算为零。

五、暖启动:上一步的答案当这一步的起点

OK,两个旋钮能拧到什么精度,还取决于每一步从什么初值起步。求解器在一个时间步里做的是迭代逼近,初值离答案越近,少数几轮就能收敛;初值离答案越远,迭代数再加也追不回来。暖启动(warm starting)做的就是把上一帧算出的冲量当作这一帧的起点,让上一帧的答案直接成为这一帧的初值。这一节量它到底有没有用、落在源码哪一行、什么条件下老接触点才配得上这份缓存,最后用多米诺与官方两张原理小图把口径收拢。

5.1 关掉暖启动,十层塔直接塌(E0)

好了,本机 E0 把唯一的变量设成这个开关。场景是 10 层堆叠塔,每块 1 kg,块高 h=0.25 m,跑 600 步,全局速度迭代与位置迭代取 10/2,Baumgarte 系数 β=0.2,穿透容差(penetration slop)20 mm。开关打开时,整塔最大穿透 27.9016 mm,末端底块的穿透 0.4438 mm,顶端垂直速度的抖动计数 66,总耗时 8.79 ms;开关关掉后,最大穿透 4571.9785 mm,末端底块 10.9170 mm,抖动计数 5,总耗时 12.01 ms。

把两个最大穿透并排看,量级差了一百六十倍以上。塔的总高是 10 × 0.25 m = 2.5 m,而关掉暖启动后的最大穿透是 4571.9785 mm,约 4.57 m,比整座塔还高。读数里那句「整塔陷进自己里」就是这么来的:每一块都在往下一块里面钻,钻进去的深度超过了它们叠起来的总高度。

同一个求解器、同一组迭代次数,只差一个开关,为什么会崩成这样?暖启动打开时,每一帧的求解从上一帧存下的冲量起步,接触在第一时间就有了接近所需的支撑力,重力被立刻顶住。暖启动关掉时,每一帧的冲量从零开始爬升,前几轮迭代还在把支撑力从零建立,重力在这段时间里持续把块往下压,穿透就这样一层层累积起来。迭代次数相同,区别只在于初值有没有借用上一帧的结果。这也解释了为什么「加大迭代数」无法完全替代暖启动:加大迭代数只是把爬升的轮数增多,起点依旧是零。

本机 E0 暖启动开 / 关对照
图 9-14本机 E0 暖启动开 / 关对照:10 层塔、600 步,开时最大穿透 27.90 mm、末端底部 0.44 mm;关掉后最大穿透 4571.98 mm,整塔陷进自己里@ 本机基准实测(原创)

抖动计数那一列反而要小心读。关掉暖启动时抖动只有 5 次,打开时是 66 次,单看这一列会以为关掉更稳。事实相反。塔一旦塌陷,块与块从头到尾保持着深度接触,接触状态不再来回切换,抖动计数器缺少可计的事件;打开暖启动的塔稳稳立着,顶端的接触在接触与微分离之间来回切换,抖动计数于是更高。抖动计数在这里量的是接触状态的翻转频率,把姿态的稳定程度与它混为一谈会读出相反结论。耗时那一列同理:塌陷后接触更深,求解要处理的量更大,总耗时从 8.79 ms 涨到 12.01 ms。

5.2 两个旋钮不能互相替代(E5)

第四节把两个旋钮交叉起来。E5 的场景是一个 8 层塔,跑 300 步,同时在「暖启动开 / 关」与「速度迭代 2 / 10 / 30」两条轴上取值(本机 E5),读最大穿透、顶端稳态穿透与总耗时:

warmstart vel_steps 最大穿透 (mm) 顶端稳态 (mm) 总耗时 (ms)
1 2 3439.2358 3415.5425 3.23
1 10 11.1561 8.1499 4.09
1 30 2.1408 1.7827 6.79
0 2 3520.5159 3510.1731 7.03
0 10 3507.9333 3503.2231 7.66
0 30 3512.3728 3510.6060 8.32

读数里能读出四件事。第一,暖启动开着时迭代数很管用:2 次时最大穿透 3439.24 mm(塔基本垮了),10 次时 11.16 mm,30 次时 2.14 mm。第二,暖启动关掉后迭代数完全救不回来:2 次 3520.52 mm、10 次 3507.93 mm、30 次 3512.37 mm,三档都落在 3508 至 3520 mm 这一带,塔整层陷进去,迭代从 2 加到 30 没有把穿透收下来。第三,暖启动开 + 2 次迭代是 3439.24 mm,仍然接近垮塌,说明只靠一个好的起点不够,还得有足够的迭代把冲量传下去,两件事要一起给。第四,耗时那一列顺带印证了 §1.2 的「上限不等于开销」:暖启动关时三档耗时 7.03 / 7.66 / 8.32 ms,几乎不随迭代数变化;暖启动开时从 3.23 涨到 6.79 ms,涨得明显,因为它真的在解东西。

本机 E5 暖启动 × 速度迭代网格
图 9-15本机 E5 暖启动 × 速度迭代网格:暖启动开时 3439.24 → 11.16 → 2.14 mm;关掉后迭代 2 / 10 / 30 三档全部停在 3508 至 3520 mm@ 本机基准实测(原创)

OK,这组网格给出的结论很短:暖启动提供的「起点」与迭代次数提供的「遍数」是两件独立的事,一颗旋钮拧到底也补不上另一颗没拧。网格的第三、四列还透露出一层关系,暖启动关掉后塔整层下沉,说明缺的是一份「上一帧的记忆」,而这份记忆不是迭代能现场造出来的,它只能从上一步存下来。§5.3 会顺着这条线去源码里找那份记忆到底存在哪。

5.3 暖启动在源码里落在哪一行

说白了整个开关在源码里只有一个落点,PhysicsSystem.cpp 第 1397 行的 warm_start_impulse_ratio。这一行是一个三元表达式:开关为真时取一个按当前步长算出的比例,开关为假时直接给 0.0f。这个比例随后被传进接触侧的暖启动函数,在 ContactConstraintPart.h:163 上与每个接触的累计冲量(total lambda)相乘。

//------【暖启动比例的唯一落点】------
// 描述:PhysicsSystem.cpp:1397 决定比例,ContactConstraintPart.h:163 完成相乘
//------------------------------------------------------------
float warm_start_impulse_ratio = mPhysicsSettings.mConstraintWarmStart? (...) : 0.0f;   // 【1】开关为假时比例直接置 0
// ...同一步内,接触侧的暖启动按这个比例施加回去
mTotalLambda *= inWarmStartImpulseRatio;   // 【2】比例乘到累计冲量上,比例为 0 时初值归零

下面对这段特别说明。三元表达式的两个分支决定了 warm_start_impulse_ratio 是一个正比例还是 0;开关为假时它取 0.0f,第 163 行那次乘法就把 mTotalLambda 直接乘成 0,等价于没有暖启动。这一点解释了一处容易被误判的地方:关掉暖启动并不需要另写一条清缓存的分支,把比例设成 0,后续所有接触的初值自然归零。开关的作用面很窄,窄到只改一个乘数,正是这种窄让它可以被安全地当作对照实验里的唯一变量。

求解速度 job 在 PhysicsSystem.cpp:1522 至 1524 行先调 sWarmStartVelocityConstraints 与 ContactManager.WarmStartVelocityConstraints,再用 steps_calculator.Finalize() 收口;接触侧的暖启动实现落在 ContactConstraintManager.cpp:1588 至 1622,第 1604 行的注释写着「Warm starting: Apply impulse from last frame」。求解结束时,PhysicsSystem.cpp:1539 的 StoreAppliedImpulses 把本帧各接触的冲量存回接触缓存,注释点明这是为了下一帧的暖启动。这几处连起来,暖启动是一个完整的来回:上一步存,这一步取,按比例施回。

Architecture.md:979 至 983 行对速度求解 job 的描述与此一致:先施加存下的冲量做暖启动,再反复迭代直到冲量过小或到达 mNumVelocitySteps 上限,最后把冲量存回接触缓存。官方文档给的是名字与流程,具体落点仍要看源码行号。这里正好是那句「文档给名字,源码给刻度」的又一次印证:Architecture.md 全文没有出现 Baumgarte 与 speculative 字样,暖启动偏偏是它少数愿意展开写的一段,因为这一段的机制足够简单,说清了就没有歧义。

暖启动是一个完整的来回
图 9-16暖启动是一个完整的来回:上一步末把累计冲量存回接触缓存,这一步初按开关取比例,再按比例施回去当迭代初值@ 本系列绘制
mConstraintWarmStart 的唯一落点
图 9-17mConstraintWarmStart 在源码里的唯一落点(PhysicsSystem.cpp:1397):三元表达式在开关为假时直接给 0.0f,比例再在 ContactConstraintPart.h:163 上乘到累计冲量@ Jolt Physics v5.6.0 源码原文截取(MIT)

两处图并排看,一边是时间的来回,一边是那一行的原文。时序图回答「什么时候取、什么时候存」,代码图回答「开关为假时比例是多少」。把两件事钉在一起,暖启动的因果链就闭合了:开关影响比例,比例影响乘法,乘法决定初值,初值决定这一步要迭代多久才收敛。

5.4 老接触点的复用条件

OK,缓存要能复用,前提是这一步的接触点还认得出上一步的那一个。判据写在 PhysicsSettings.h:76 至 77:mContactPointPreserveLambdaMaxDistSq = Square(0.01f),注释是「Maximum allowed distance between old and new contact point to preserve contact forces for warm start」。同一个接触点在这一步与上一步的位置差小于 0.01 m,上一帧的冲量才被保留;超过这个距离,缓存作废,这个接触点这一步又从零起步。

//------【老接触点的复用判据】------
// 描述:PhysicsSettings.h:76-77 的距离门槛,ContactConstraintManager.cpp:1475-1498 的稳定排序
//------------------------------------------------------------
static constexpr float mContactPointPreserveLambdaMaxDistSq = Square(0.01f);   // 【1】新旧接触点距离上限 1 cm,超出则丢弃暖启动冲量
// ...同一接触点在两帧之间要能被对上,靠的是确定性排序
// SortContacts 按 mSortKey -> body1 -> body2 排序,for a deterministic simulation   // 【2】顺序稳定,缓存匹配才有落点

下面对这段特别说明。0.01 m 这个尺度与接触点的移动速度绑定。物体滑动、滚动、翻转时,接触点会在形状表面挪动;挪动量小,认为还是同一处接触;挪动量大,认为已经换了接触位置,旧冲量对新位置没有意义。判据设得越松,越多旧冲量会被错误沿用;设得越紧,越多有效缓存被白丢。默认值取 1 cm,是给常见运动速度留出的余量,也是「接触点还是原来那个」在数值上的定义。

复用的另一半在排序上。ContactConstraintManager.cpp:1475 至 1498 的 SortContacts 用 QuickSort,键依次是 mSortKey、body1、body2,注释写明「for a deterministic simulation」。接触点经过稳定排序,缓存里同一个接触点在不同帧之间保持固定的次序,匹配时不必再去猜哪个对哪个。若排序不确定,同样的接触点在不同帧可能落到不同位置,缓存匹配就会随机失手,暖启动的收益也就时有时无。

还有一条反作用要记下。位置求解里那句注释(ContactConstraintManager.cpp:1904 至 1906)提到,允许一点穿透是为了避免接触与无接触之间的抖动把接触缓存与暖启动冲量洗掉。抖动一旦把接触点判成「刚才没有接触」,上一帧的冲量就配不上这一步的接触点,暖启动随即失效。缓存的有效期与位置的稳定性绑在一起,这条联系会在 §6.3 的三段区间里再出现一次。

老接触点复用的两道门槛
图 9-18老接触点复用的两道门槛:距离判据 Square(0.01f) 决定旧冲量还认不认得,SortContacts 的确定性排序决定同一接触点在不同帧配不配得上@ 本系列绘制

5.5 一个实物对照:多米诺

好了,多米诺(domino)摆成一条长队,第一块被推倒,它撞上第二块,第二块再撞第三块,冲击沿队伍一块一块传下去。整个过程里没有一步是「所有块同时动」,力量始终在一处接触上推进,走完一处才轮到下一处。这就是顺序冲量(sequential impulse)逐约束依次推进最直观的实物形态。

暖启动在这幅图里对应的是「每一块都记得上一次被推的力度」。第一块被推倒需要的力由手给出,第二块从第一块接过这份力,第三块再从第二块接过。若每一块都在接力的瞬间把上一份力忘掉,队伍每传一次都要重新从零建立推力,传得越远越传不动,这与 §5.1 里关掉暖启动的十层塔是同一个道理:接力并不取决于单次用力有多大,它取决于每一棒是否从前一棒的结果起步。

多米诺还有一个细节值得点出:队伍能不能一路倒下去,取决于每一处接触是否在正确的位置上被接住。块与块之间的距离、角度差一点,接力就会中断。§5.4 那条 1 cm 的复用判据管的正是这件事,接触点位置对得上,缓存才接得住,链条才不断。

多米诺实物
图 9-19外部真图:多米诺。一块推一块的顺序传播,力量始终在一处接触上推进,走完一处才轮到下一处@ Wikimedia Commons Dominoes waiting to fall.jpg(CC BY-SA 4.0,Pauloleong2002)

多米诺与十层塔合起来,把暖启动的两面说全了:塔说明「没有记忆就站不住」,多米诺说明「记忆沿顺序一棒一棒传」。两者都指向同一个机制,把上一步的结果当作这一步的初值。

5.6 官方两张图在讲什么

说白了官方文档为求解配了两张原理小图,讲的是两件不同的事。第一张画的是活跃与非活跃接触的法向:这一步真正参与求解的接触被标出来,其余接触不进入循环。暖启动只对活跃接触发生,非活跃接触这一步根本不建约束,也就谈不上升值缓存。第二张画的是仿真岛(simulation island):岛内互相牵连的接触与约束被划成一组,一起求解。暖启动的作用范围与岛的范围一致,岛划到哪里,缓存就在哪里被复用。

两张图合起来框定了暖启动的边界。它不负责决定哪些接触参与求解,那是活跃性判据的活;它也不负责决定谁和谁一起解,那是建岛的活。它只做一件事:把已经进入本步求解的接触,用上一帧存下的冲量做初值。顺序冲量与建岛这两层若没有先安排好,暖启动连对象都找不到,开关打开了也是空转。

这两张图是原理小图,本篇按原比例使用,不放大也不裁切。它们与 §5.1 的读数指向同一件事:暖启动的收益发生在「接触已经建起来」之后,它让已经建起的接触收敛得快,让长塔立得住。把这一层放回三步链条上,顺序冲量负责一处一处推进,暖启动负责给每一处一个好起点,两者一起构成了接触求解在速度侧的主干。

官方原理小图:活跃与非活跃接触
图 9-20官方原理小图:活跃与非活跃接触的法向。只有活跃的那部分接触本步参与求解(官方一手图)@ Jolt Physics v5.6.0 官方文档配图 Docs/Images/ActiveVsInactiveContactNormal.jpg(MIT,Jorrit Rouwe)
官方原理小图:仿真岛
图 9-21官方原理小图:仿真岛。岛内的接触与约束被放在一起求解(官方一手图)@ Jolt Physics v5.6.0 官方文档配图 Docs/Images/SimulationIsland.jpg(MIT,Jorrit Rouwe)

把这一节的落点收一句:暖启动是速度侧的初值机制,它不改变求解器迭代的轮数上限,只改变每一帧从多远的地方起步。十层塔塌不塌、多米诺传不传得下去,都由这份初值决定。有了速度侧的收敛,才轮到位置侧谈「收多少、留多少」。

源码落点:这一节的读数来自 E0 与 E5 两段;源码侧的三处落点是 ContactConstraintPart.h 第 163 行的比例相乘、PhysicsSystem.cpp 第 1397 行的比例计算、PhysicsSettings.h 第 76 至 77 行的复用距离上限。其中:暖启动的定义可以写成一句话:把上一步的冲量当作这一步迭代的初值;初值越好,同样迭代数下解出来的结果越准。

六、不追零:Baumgarte 与 slop

OK,速度侧的收敛解决「这一步速度对不对」,位置侧解决「这一步位置对不对」。位置侧有一个反直觉之处:它的目标落在一条误差带上,这条带被刻意留下。这一节量三个量:Baumgarte 系数决定回喂多少、穿透容差决定目标留在哪、投机接触决定提前多久开始刹车,最后把三段区间与整条流水线合起来看,顺带把 split impulse 的归属边界说清。

6.1 Baumgarte 的三档取舍(E3)

好了,把位置误差回喂速度约束这件事有个名字,叫 Baumgarte 稳定化(Baumgarte stabilization)。它的出处是 Catto 在 GDC 2009 的《Modeling and Solving Constraints》:把位置误差按比例喂回速度约束,写成 Ċ_B = J v + (β/h) C = 0,其中 0 ≤ β ≤ 1,误差按 C = C₀ exp(−βt/h) 衰减。Jolt 把这个系数收在 PhysicsSettings.h:44 至 45,默认 mBaumgarte = 0.2f,注释是「how much of the position error to 'fix' in 1 update(0 = nothing, 1 = 100%)」。注释里的两个端点把含义钉死了:0 表示一点不修,1 表示一次全修。

系数在一次求解里怎么用,有三处落点可查。ContactConstraintManager.h:229 把它写成偏置 b = beta / dt C;ContactConstraintPart.h:222 在位置求解里写成 lambda = -mEffectiveMass * inBaumgarte * inC;PhysicsSystem.cpp:2577 再把设置里的 mBaumgarte 取出来传下去。同一个系数,从设置项到偏置到冲量,一路传递到每轮位置迭代。系数是唯一被调的量,其余结构不变,这也让它适合被单独扫一遍。

本机 E3 扫了这个系数。场景是 5 cm 初始重叠,块高 h=0.25 m,穿透容差保持 20 mm,跑 240 步。读数里「恢复到 slop 加 0.5 mm 的步号」指的是稳态穿透收进 20.5 mm 用了多少步。

β=0.00:永不恢复,步号记为 -1,抖动 20 次,稳态穿透 50.0000 mm。系数为 0 意味着误差完全不被回喂,5 cm 的初始重叠原样留在那里。 β=0.05:39 步恢复,抖动 162 次,稳态 20.0006 mm。系数太小,修正拖沓,一路抖着往上收。 β=0.20(默认):9 步恢复,抖动 58 次,稳态 20.0001 mm。 β=0.50:2 步恢复,抖动 21 次,稳态 20.0000 mm。这一档恢复最快,抖动也最少。 β=1.00:0 步即到位,抖动 160 次,稳态 19.9771 mm。系数拉满,一步就压过惯例带,稳态穿到了 20 mm 以下。

把这五档排开看,取舍的形状很清楚。β=0 与 β=0.05 落在「修不动或修太慢」一侧,β=1.0 落在「修过头」一侧,19.9771 mm 低于设定的 20 mm,说明一次修正超过了目标,紧接着的反向修正又把状态拉回,抖动因此涨到 160 次。默认的 0.2 处在中间偏保守的位置;本组里 0.5 在恢复步数与抖动两项上同时最好。这里量的是「恢复到 slop 加 0.5 mm 用了多少步」与「稳定后抖动多少次」,两个指标一起看,才能分辨「收到位」与「收得稳」。

一条术语边界先写在这里。Baumgarte 项把位置误差喂回速度约束,位置修正因此会间接影响速度。Jolt 的位置侧是独立的一段 SolvePositionConstraints,速度侧与位置侧分成两个阶段,位置求解的迭代数由 mNumPositionSteps 单独控制(PhysicsSettings.h:86 至 87 默认 2)。关于 split impulse(分离冲量):可查证的归属是 Bullet,它的 m_splitImpulse 默认 true,穿透恢复速度单独存在 m_pushVelocity 字段里;把它记成 Catto 的贡献是不对的,Catto 的 GDC 2007 讲稿实为《Contact Manifolds》。Jolt 这一段独立的位置求解在机制上与 split impulse 同源,都是为了不让位置修正往系统里注入动能,但本篇不替 Jolt 宣称它用了 Bullet 的 split impulse,两者的结构并不一样。

本机 E3 Baumgarte 三档取舍
图 9-22本机 E3 Baumgarte 三档取舍:β = 0 永不修正(稳态 50.0000 mm);β = 0.5 恢复最快(2 步)且抖动最少(21 次);β = 1.0 过冲后抖动 160 次@ 本机基准实测(原创)

这张图把五档并成一条取舍曲线:左端是修不动,右端是修过头,中间两档才是可用区间。默认值 0.2 与实测最优的 0.5 之间的差距说明,默认值选得偏保守,工程上若场景对抖动敏感,把系数往 0.5 靠是有一手读数支撑的。

6.2 slop 就是位置求解的目标(E4)

说白了位置求解的目标值落在穿透容差上,零不在目标之列。PhysicsSettings.h:52 至 53 的 mPenetrationSlop = 0.02f,注释是「How much bodies are allowed to sink into each other」。Catto 在 GDC 2009 里写了同样的取向:分离量小于 slop 时才施加修正 Ċ = J v + (β/h)(δ − δ_slop),并留下一句「Aiming for zero overlap leads to JITTER!」。目标定成零,接触会不停在「有接触」与「无接触」之间反复翻转,抖动随之而来。

Jolt 的实现把这份取向写进了 ContactConstraintManager.cpp 的 sSolvePositionConstraint。它先算分离量 separation = max((p2-p1)·n + slop, -maxPenetrationDistance),再用 mBaumgarte 调位置求解。公式里那个 + slop 的作用在于:穿透小于 slop 时,分离量仍是正的,位置迭代不做任何修正,这个穿透被有意留下。

// -------【位置求解的 slop 与单轮上限】ContactConstraintManager.cpp:1904-1906------
// Allow a little penetration by default (PhysicsSettings::mPenetrationSlop) to avoid jittering
// between contact/no-contact which wipes out the contact cache and warm start impulses
// Clamp penetration to a max PhysicsSettings::mMaxPenetrationDistance so that we don't
// apply a huge impulse if we're penetrating a lot
float separation = max(Vec3(p2 - p1).Dot(ws_normal) + inSettings.mPenetrationSlop,
                       -inSettings.mMaxPenetrationDistance);
// ...第 1933 行用 mBaumgarte 调位置求解,把分离量喂回去

下面对这段特别说明。第 1904 至 1906 行的注释给出了允许穿透的理由:留一点穿透是为了避免接触与无接触之间的抖动把接触缓存与暖启动冲量洗掉。这与 §5.4 那条 1 cm 复用判据接上了:位置一旦抖到接触反复建立与丢失,缓存匹配失败,暖启动的收益也跟着丢。同一段代码还把每轮修正量钳到 mMaxPenetrationDistance(PhysicsSettings.h:64 至 65,默认 0.2f,注释是「Maximum distance to correct in a single iteration」),这一钳位就是 §6.3 三段区间里的第二段。

本机 E4 扫了 mPenetrationSlop。场景是 5 cm 初始重叠,跑 300 步。

slop = 0 mm:稳态穿透 0.0001 mm,抖动 74 次。 slop = 20 mm:稳态穿透 20.0001 mm,抖动 24 次。 slop = 50 mm:稳态穿透 50.0000 mm,抖动 0 次。

三行读数摆在一起,结论很直白。稳态穿透与设定的 slop 逐位吻合,把目标设成多少,稳定后就停在多少。目标越大越不抖,74 次、24 次、0 次依次下降。slop 为 0 时目标就是零,接触在临界位置上反复切换,抖动次数最多;slop 给到 50 mm,接触状态稳定落在带内,一次抖动都没有。稳态穿透那一列的 0.0001 mm 不是零,它是浮点精度下的残差,量级远小于任何有意义的穿透。

本机 E4 slop 扫描
图 9-23本机 E4 slop 扫描:稳态穿透 0.0001 / 20.0001 / 50.0000 mm 与设定的 slop 逐位吻合,目标越大抖动越少(74 / 24 / 0 次)@ 本机基准实测(原创)
位置求解的 slop 与单轮上限原文
图 9-24位置求解的 slop 与单轮上限原文(ContactConstraintManager.cpp:1904–1906):留一点穿透是为了别让抖动洗掉接触缓存与暖启动冲量,同一行再把每轮修正量钳到 mMaxPenetrationDistance@ Jolt Physics v5.6.0 源码原文截取(MIT)

两张图一前一后,先说清目标值在哪,再给出源码依据。读数图证明「目标就是 slop」,代码图证明「源码确实按 slop 与钳位处理分离量」。两件事对上了,位置求解就不追求零穿透这一点,从一句经验变成了可复算的事实。

6.3 slop 的三段区间

OK,把 §6.2 那行 clamp 拆开看,位置求解把穿透分成三段处理。第一段是惯例带:穿透小于 mPenetrationSlop 的部分不修正,允许留在那里。第二段是单轮上限:需要修正的量每轮最多修 mMaxPenetrationDistance,一次修不完就留到下一轮。第三段是余量:被上限截住的其余部分,由后续的位置迭代继续处理,迭代次数由 mNumPositionSteps 决定。

位置求解把穿透分成三段
图 9-25位置求解把穿透分成三段:惯例带内不修正、每轮最多修 mMaxPenetrationDistance、被截住的余量留给后续位置迭代@ 本系列绘制

这三段区间的工程含义与 §五 的缓存绑在一起。带内不修正,接触状态就稳,缓存与暖启动冲量保得住;单轮上限存在,一次冲击不会把所有修正量堆到一步里,避免修正过大反过来激起抖动。位置求解的上限默认 0.2 m,容差默认 0.02 m,两者相差一个量级,常见穿透落进带与上限之间时,一轮就能收进带内,只有超出上限的深度才需要多轮。

整条流水线也在这里合成一句话:一次冲击从窄相(narrow phase)给出接触流形(contact manifold)开始,进入建接触约束并算有效质量(effective mass),接着暖启动把上一帧的冲量施加回来,然后速度迭代在速度侧收敛,最后位置迭代把穿透收回惯例带。Architecture.md:1014 至 1020 行对位置求解 job 的描述正是最后这一段:它修的是线性积分引入的数值漂移,迭代到位置修正过小或到达 mNumPositionSteps,随后把新的 AABB 通知宽相。速度侧与位置侧在同一帧里前后相接,暖启动只作用于前者的起点,位置侧的收尾决定了下一帧的初始穿透是多少。

一次冲击穿过求解器的顺序
图 9-26一次冲击穿过求解器的顺序:窄相给出接触流形 → 装配算出有效质量 → 暖启动施回旧冲量 → 速度迭代 → 位置迭代@ 本系列绘制

两张图串起来看,三段区间管的是「一段穿透怎么被分摊到多轮迭代」,流水线管的是「一次冲击怎么穿过五个阶段」。前者是位置侧的内部刻度,后者是整条求解链的顺序。把这层顺序记住,前面几节里散开的量就归位了:迭代次数定收敛轮数,暖启动定起步初值,slop 与 Baumgarte 定位置侧的目标与步幅,投机接触把刹车提前到接触发生之前。

6.4 一个实物对照:减震器

好了,减震器(shock absorber)靠阻尼把振动一点点吃掉。压缩后回弹的快慢、回弹过头多少,都由阻尼系数决定:阻尼太小,车身来回晃,收不住;阻尼太大,动作迟滞,冲击过后又冒出一段反向过冲。把阻尼调到一个中间值,回弹最快而余振最少。

Baumgarte 系数在这幅图里的角色与阻尼系数相近。它决定位置误差按多大比例喂回去:β 太小,误差收得慢;β 太大,一步过头再反向修正,抖动冒出来。§6.1 那组读数里,β=0.05 的 162 次抖动是阻尼不足,β=1.0 的 160 次抖动是过冲,β=0.5 的 21 次是最少的一档,β=0.2 的 58 次是默认下的折中。

减震器还有个值得对照的地方:它从不追求「瞬间回到原位」。压缩的行程被留出来,阻尼在行程里逐步做功。位置求解里的 slop 也是同一思路,它留出一条惯例带当作目标,也不追求零穿透。把这一点与 §6.2 的读数对上,slop 越大抖动越少这条规律就有了工程解释:目标带越宽,接触状态越稳,反复翻转的机会越少。

减震器实物
图 9-27外部真图:减震器。压缩行程被留出来、阻尼在行程里逐步做功,与 Baumgarte 项「把位置误差按比例喂回」形成概念对照@ Wikimedia Commons Shock Absorbers (PSF).png(Public domain,Pearson Scott Foresman)

到此,位置侧的三个量都落到了读数上。Baumgarte 系数是回喂的力度,slop 是留下的目标带,投机距离是提前的刹车量,三者各自可调,各自有它的边界。把 §五 的暖启动与 §六、§七 的这三个量合起来,接触求解在速度侧与位置侧的主干就算走完了一遍。

源码落点:位置侧的三段逻辑在 ContactConstraintManager.cpp 第 1880 至 1939 行,Baumgarte 的引入点在 ContactConstraintPart.h 第 218 至 237 行,三个参数的定义在 PhysicsSettings.h 第 44 至 65 行。其中:lambda = -mEffectiveMass * inBaumgarte * inC 这一行就是「按比例喂回位置误差」的全部实现,β 这个旋钮的全部作用都在这里。

七、投机接触:提前多久开始刹车

说白了位置侧留穿透是为了稳定,接触检测侧还有一个更靠前的旋钮:在还没碰上之前,要不要先把刹车建起来。这个字段叫投机接触距离,它买到的东西与字面印象略有出入。

7.1 投机接触:提前多久开始刹车(E7)

OK,前面两节处理的是「已经穿进去了怎么办」。投机接触(speculative contact)提前一步:在还没穿进去的时候就开始减速,让它刚好停在表面。实现的落点在 ContactConstraintManager.cpp:71 至 75,无穿透时给接触约束一个偏置,使约束从 relative_velocity·n > 0 变成 relative_velocity·n > -penetration/dt,也就是允许一个与当前接近速度对应的负速度下限。这段注释给出的出处是 Catto 在 GDC 2013 的《Continuous Collision》,实现只有一行 speculative_contact_velocity_bias = max(0, -penetration/dt)。这条出处与 GDC 2009 是两次分开的讲稿,引的时候不能混在一起。

设置项是 PhysicsSettings.h:47 至 50 的 mSpeculativeContactDistance = 0.02f,注释还警告设得太大可能出现幽灵碰撞(ghost collisions)。投机距离决定「提前多远开始刹车」:接触点进入这个距离内,约束就先按接近速度限制它。距离设得不够远,物体在这一步里已经穿了过去,刹车指令来晚了;设得过远,相向而行的物体可能被提前挡住,出现幽灵碰撞。

本机 E7 扫了这个距离。场景是半径 r=0.1 m 的小球以 5 m/s 落地,每步的位移是 5/60 ≈ 83.3 mm,跑 120 步。

speculative = 0 mm:最大穿透 83.2082 mm,稳态 20.0000 mm。 speculative = 20 mm:最大穿透 83.2082 mm,稳态 20.0000 mm。 speculative = 50 mm:最大穿透 0.0000 mm,稳态 0.0000 mm。 speculative = 100 mm:最大穿透 0.0000 mm,稳态 0.0000 mm。

读数把「提前量」这个词量出来了。小球每步走 83.3 mm,投机距离必须不小于这个步长,才能在它落到表面之前的那一步就发出刹车指令。20 mm 小于 83.3 mm,球在这一步里已经穿过去了才被看到,投机距离毫无作用,最大穿透与不设时一模一样,都是 83.2082 mm。50 mm 与 100 mm 都超过了 83.3 mm,球在还没穿进去的那一步就被限速,最大穿透与稳态穿透一起归零。这条门限与步长绑定:步长越大,需要的投机距离越大,固定取默认的 20 mm 在高速场景里可能一直不起作用。

本机 E7 投机接触距离扫描
图 9-28本机 E7 投机接触距离扫描:r = 0.1 m 小球以 5 m/s 落地、每步位移 83.3 mm;投机距离 ≤ 20 mm 时穿透 83.21 mm 毫无作用,≥ 50 mm 时一步都没陷进去@ 本机基准实测(原创)

这张图的门限很清楚:20 mm 与 50 mm 之间隔着一个 83.3 mm 的步长,读数在门限两侧是断开的。投机接触不是一个可以慢慢调的连续旋钮,它的有效与否由「投机距离是否跨过单步位移」决定,这是它和 §6.2 那个 slop 最不一样的地方。

7.2 投机接触不是防穿透的通用手段

投机接触容易让人误以为是一种防穿透手段,E7 的读数正好把这个印象校正过来。

先看它管什么。mSpeculativeContactDistance 定义的是提前多远开始建接触:球心离地面还有 50 mm 时,接触约束就已经建起来了,约束的偏置被写成 relative_velocity·n > -penetration/dt,也就是「这一步最多只准靠近这么多」。E7 里每步位移是 83.3 mm,投机距离设到 50 mm 与 100 mm 时,小球一步都没陷进去,最大穿透与稳态穿透都是 0.0000 mm;设到 0 与 20 mm 时,读数一模一样地停在 83.21 mm 的穿透上。同一份代码,只换一个字段,结果从「完全不陷」跳到「陷了一整步」。

再看它不管什么。官方把这个字段的注释写得很直白:如果这个值太大会出现 ghost collisions,理由是投机接触基于碰撞检测那一刻的最近点,而那一刻的最近点未必是两个物体真正接触时的最近点。所以它买的是「提前刹车」,不是「一定不穿」。真正的高速防穿透交给另一条通道:按 EMotionQuality 决定是否给这一体排一次连续碰撞检测(CCD),线性投射找出首次碰撞,并把这次移动的「时间偷掉」。

其中:mSpeculativeContactDistance 与 CCD 不是替代关系。前者处理「这一步会碰到、但还没碰上」,后者处理「这一步会整个穿过去」。本篇只把这条边界划出来,CCD 自己那套机制留给第十一篇。

两条通道的分工可以并排看。

投机接触与连续碰撞检测的分工边界
图 9-29投机接触与连续碰撞检测的分工边界:前者管「这一步会碰到、但还没碰上」,后者管「这一步会整个穿过去」,两者的定义域不重叠@ 本系列绘制

源码落点:投机接触的偏置计算在 ContactConstraintManager.cpp 第 71 至 75 行(出处标注为 GDC 2013),字段定义与幽灵碰撞的警告在 PhysicsSettings.h 第 47 至 50 行,连续碰撞检测的排布判据在 Docs/Architecture.md 第 993 与第 1001 行。其中:投机接触管的是「这一步会碰到」,连续碰撞检测管的是「这一步会整个穿过去」,两者的定义域不重叠。

八、反误区与横向对照

好了,§六 把「不追零」的三个量量完了:Baumgarte 系数决定回喂多少,穿透容差决定目标留在哪,投机接触决定提前多久刹车。位置侧的取舍排开之后,另有一个流传很广的说法要先处理掉,它说 Jolt 的接触求解器用的是 TGS。本节把这个说法放到两重证据下核对,再把几家引擎的求解器并排放一次,最后收拢一条容易越界的术语边界。

8.1 三条证据:Jolt 里没有 TGS

三条证据,机器出两条、作者出一条,指向同一个结论。

第一条来自文件名检索。官方快照的 Jolt/ 目录下,文件名里含 Solver 或 TGS 的只有一个 CalculateSolverSteps.h。打开看,它算的是「每个岛该解几步」,给出的是迭代次数上限(CalculateSolverSteps.h:20–40),并不实现求解器本身。

第二条来自内容检索。把 TGS、Temporal Gauss、SolverSequentialImpulse、class Solver 这几组词(大小写不敏感)在 Jolt/ 全目录检索,结果是 0 匹配。求解没有做成一个叫 Solver 的类,它以 job 的形式内联挂在物理系统上:JobSolveVelocityConstraints(PhysicsSystem.cpp:1386)与 JobSolvePositionConstraints(PhysicsSystem.cpp:2566)。文档侧也对得上,Docs/Architecture.md:731 的原话是「We use a Sequential Impulse solver with warm starting as described in Modeling and Solving Constraints - Erin Catto (GDC 2009)」。

第三条来自作者本人。Jorrit Rouwé 在 GDC 2022《Architecting Jolt Physics for Horizon Forbidden West》的讲稿备注里写的是:「Jolt Physics is using the Sequential Impulse Solver as described by Erin Catto at GDC 2009.」

三条摆在一起,是机器与作者同时作证:检索工具在源码树里找不到 TGS 的痕迹,作者本人点名的是 Sequential Impulse。

三条证据说明 Jolt 里没有 TGS
图 9-30三条证据说明 Jolt 里没有 TGS:文件名列举只命中 CalculateSolverSteps.h、全目录内容检索 0 匹配、作者 GDC 2022 讲稿第 8 页点名 Sequential Impulse@ 本系列绘制

还有一处容易混淆的地方要点出来:TGS 在这张横向表里只属于 PhysX,它官方手册给出的 PxSolverType 有 ePGS(默认)与 eTGS 两个取值。TGS 很可能就是这条误读的迁移来源。这一条因果链是推断,正文只摆事实:Jolt 一侧,文件名、内容检索、作者表述三处同时指向 Sequential Impulse;PhysX 一侧,TGS 是它自己官方文档里的可选求解器类型。两件事各有各的出处。把这条边界守住的实际意义落在选型上:若一篇对比文把 Jolt 归进 TGS 阵营,它给出的调参建议(比如子步进次数)就会与 Jolt 实际的六个旋钮错位,读者按错坐标系调参,读数自然对不上。

8.2 四家引擎的求解器对照

把 Jolt 与四家引擎放到一张表里,差异一眼可见:

引擎 求解器 关键出处
Jolt 顺序冲量(Sequential Impulse)+ 暖启动 Docs/Architecture.md:731
Box2D v3 子步进 Soft Step(旧称 TGS Soft,2024-02 起改名) Box2D 官方 Simulation 文档(推荐 4 个 sub-step)
Bullet btSequentialImpulseConstraintSolver Bullet 源码文档自述「a form of Projected Gauss-Seidel」;m_numIterations 默认 10
PhysX 默认 PGS,可选 TGS PhysX 官方手册(PxSolverType:ePGS / eTGS)
Rapier 外层迭代次数 + 内部 PGS 与稳定化迭代 Rapier 官方文档(num_solver_iterations = 4)

表格里的每个名字都能指到官方文档。Bullet 的 btSequentialImpulseConstraintSolver 在它源码文档里自述为「a form of Projected Gauss-Seidel」,m_numIterations 默认 10,另有 m_splitImpulse 默认 true;Box2D v3 从 2024-02 起把「速度/位置迭代」换成子步进,官方 Simulation 文档推荐 4 个 sub-step;PhysX 官方手册写的是「a default Projected Gauss-Seidel-style (PGS) solver and an optional Temporal Gauss-Seidel (TGS) solver」;Rapier 给出的参数是 num_solver_iterations = 4、num_internal_pgs_iterations = 1、num_internal_stabilization_iterations = 1,接触侧 warmstart_coefficient 默认 1.0、关节侧 warmstart_joints 默认 false。这些数字都出自各自的官方文档。

对照下来有一个共性值得说:除了 Box2D v3 换成子步进,其余几家都还在迭代求解这条主路上,差别在旋钮的细分程度。Jolt 把速度/位置迭代数、Baumgarte、slop、投机距离摊成六个独立旋钮(PhysicsSettings.h:44–111);Rapier 把迭代数拆成外层、内部 PGS、内部稳定化三层;Bullet 用一个 m_numIterations 加一个 m_splitImpulse 开关。旋钮越多,能调到的角落越多,能踩错的坑也越多。E5 那张「暖启动 × 速度迭代数」的二维网格(本机,8 层塔,300 步)量出来的正是「两个旋钮不能互相替代」:暖启动开且 10 次迭代是 11.16 mm,暖启动开只给 2 次迭代就掉到 3439.24 mm,而暖启动关掉之后 2/10/30 次迭代全部塌到 3508 至 3520 mm。把这几家的旋钮数并排数一遍能看出一个方向:引擎在把「一步解几次、一次修多少」拆得越来越细,拆得越细,越需要读者分清新手该动的旋钮与调优才动的旋钮。

四家引擎的求解器对照
图 9-31四家引擎的求解器对照:Jolt 顺序冲量、Box2D v3 子步进 Soft Step、Bullet btSequentialImpulseConstraintSolver、PhysX 默认 PGS 可选 TGS、Rapier 外层次数加内部 PGS@ 本系列绘制

8.3 一条术语边界:SI 与 PGS

先把 SI 的定义放在台面上。Catto 在 GDC 2009《Modeling and Solving Constraints》里把它写成「An iterative solver. SI applies impulses at each constraint to correct the velocity error. SI is fast and stable. Converges to a global solution.」,单约束的冲量解是 λ = −m_C(J v̄₂ + b),有效质量 m_C = 1/(J M⁻¹ Jᵀ)。Jolt 的公式块(ContactConstraintManager.h:120–148)与这套记号对得上,λ = -K⁻¹ J v1(:130)、K = J M⁻¹ Jᵀ(:134),Catto 的 m_C 在这里由 K 承载。

第一条边界,SI 与 PGS 的关系。Bullet 的源码文档把 SI 描述为「a form of Projected Gauss-Seidel」,Tamis 的《Comparison between Projected Gauss-Seidel and Sequential Impulse Solvers》却把两者并列比较。两处口径不一致,正文取保守的一侧:SI 属于 PGS 家族,是矩阵无关的增量冲量表述。这里不写「两者完全一样」。取保守一侧的理由很直接:把 SI 当成 PGS 家族的一员,两边文献都能自洽;写成「完全等同」则会被 Tamis 的并列比较直接反驳。

第二条边界,split impulse 的归属。可查证的归属是 Bullet,m_splitImpulse 默认 true,穿透恢复速度单独存在 m_pushVelocity 字段里。Catto 的 GDC 2007 讲稿实为《Contact Manifolds》,不能把 split impulse 记成他的贡献。Jolt 的位置侧是独立的一段 SolvePositionConstraints(ContactConstraintManager.cpp:1880–1939,由 PhysicsSystem.cpp:556 调用),机制上与 split impulse 同源,都是不让位置修正往速度里注入动能,但本篇不替 Jolt 宣称它用了 Bullet 的 split impulse。

第三条边界,投机接触的出处。它是 GDC 2013《Continuous Collision》- Erin Catto,与 GDC 2009《Modeling and Solving Constraints》分开引。Jolt 这一侧的落点在 ContactConstraintManager.cpp:71–75:无穿透时给接触约束一个偏置,把约束从 relative_velocity·n > 0 放宽成 relative_velocity·n > -penetration/dt,实现是一行 speculative_contact_velocity_bias = max(0, -penetration/dt)。E7 量的就是这条偏置提前多久开始刹车(本机,r=0.1 m 小球以 5 m/s 落地,每步位移 83.3 mm):投机距离 ≤ 20 mm 时穿透 83.21 mm 毫无作用,≥ 50 mm 时一步都没陷进去。

三条术语边界与可写入正文的措辞
图 9-32三条术语边界与可写入正文的措辞:SI 与 PGS 的关系取保守口径、split impulse 归属 Bullet、投机接触的出处单列 GDC 2013@ 本系列绘制

三条边界收在同一个原则上:同一个词在不同引擎里有不同坐标,引用之前先问它出自哪一层。文档给名字,源码给刻度,讲稿给出处,三者对得上才敢下笔。这一节把名字与出处钉住之后,剩下的收尾就交给下一节的边界、演进与清单。

源码落点:反误区的三条证据分别对应一次文件列举、一次内容检索与一份讲稿,讲稿的公开地址是 jrouwe.nl/architectingjolt/ArchitectingJoltPhysics_Rouwe_Jorrit_Notes.pdf;竞品侧的官方出处是 Box2D Simulation 与 Rapier Integration parameters。其中:一个名字的归属要靠三方对齐:源码里有没有、作者怎么说、竞品是不是同一个东西。三件事都指向同一个答案时,这条结论才算立住。

九、边界、演进与清单

说白了本篇一整条讲的是「冲击进来之后,位置怎么被收回去」。位置修正被钳到每轮上限,超出部分留到下一轮,工程上最直白的对照是打桩机:锤头砸下去,桩身被压进土层,每一锤的行程有限,剩余的沉降留给下一锤。

打桩机实物
图 9-33外部真图:打桩机。锤头砸下去、桩身被压进土层,每一锤行程有限、剩余沉降留给下一锤,对应「位置修正被钳到每轮上限」@ Wikimedia Commons Big pile driver of Volker Staal en Funderingnen, BAM.JPG(CC0,Alf van Beem)

9.1 本篇的边界

按本系列的惯例,边界写两遍。开头写过「本篇不涉及什么」,这里写第二次:本篇给出了什么,剩下什么归谁。

方向 本篇已给出 归谁
求解的数学表示 单次迭代的四个量与两个循环 已闭环
精度旋钮 速度迭代与位置迭代不可互替(E1 / E2 / E5 / E6) 已闭环
暖启动 源码落点、复用条件与不变量(E0) 已闭环
不追零 slop / Baumgarte / 投机接触三档刻度(E3 / E4 / E7) 已闭环
求解器的命名 顺序冲量,非 TGS 已闭环
岛与并行、睡眠 只登记求解发生在岛内 第十篇
CCD 与确定性 只登记投机接触的提前量 第十一篇
接触的序列化与调试可视化 不涉及 后续专篇

边界表里「已闭环」的五项都能在源码或读数里落点,「归谁」的三项则是下一篇与后续章节的入口。

再给一张「可复算的数」表,六条,每条附实验编号与复算入口。复算入口与前几章一致:官方源码、本篇的 evidence/bench_solver.cpp、一台能编译 Jolt 的机器。

# 可复算的数 复算入口
1 暖启动关掉后,10 层塔最大穿透 4571.98 mm,整塔陷进自己里 E0
2 速度迭代 1/2/3/4/10/30 → 最大穿透 100.39 / 48.38 / 30.74 / 22.03 / 6.74 / 1.14 mm E1
3 位置迭代 1/2/4/8/32 → 恢复步号 20 / 10 / 5 / 2 / 0 E2
4 位置迭代各档的稳态穿透全是 0.0001 mm E2
5 slop 0/20/50 mm → 稳态穿透 0.0001 / 20.0001 / 50.0000 mm,与设定逐位吻合 E4
6 速度迭代 = 1 时摩擦失效,滑行 25000.04 mm、末速仍 5.0000 m/s E6

evidence/bench_solver_run_20261004.txt 是这六条的原始输出,evidence/build_bench_solver.ps1 是复现脚本。

9.2 演进:与接触求解有关的版本变更

OK,把 Docs/ReleaseNotes.md 与 Docs/APIChanges.md 里与接触求解有关的条目按版本排开,能看到一条清楚的脉络:先把迭代覆盖交出来,再把大岛送上多线程,然后集中修摩擦与投机接触的三处偏差,最后重定义覆盖语义并换掉摩擦模型。

版本 条目 出处
v2.0.0 加入位置/速度迭代数覆盖(Constraint::mNumVelocity/PositionStepsOverride),初期只影响序列化 APIChanges.md:126(20220903)
v3.0.0 允许大岛在多线程上求解 ReleaseNotes.md:441
v4.0.0 摩擦与投机接触三处修复:CCD 摩擦偏大 √2 倍;两切线轴各自 clamp 致总摩擦超限;速度不够触发恢复系数时投机接触按当前位置执行 ReleaseNotes.md:424–426
v5.0.0 覆盖语义变更(0 = 用默认值);body 也能覆盖迭代数;新增 ResetWarmStart;SetupVelocityConstraints 多线程 ReleaseNotes.md:313 / 314 / 316 / 317
快照最新(2026-05-31) 新摩擦模型:不再偏向第一个接触点,摩擦 = 2 个线性约束 + 1 个角约束;Pyramid 测试快 15%、内存少 40% ReleaseNotes.md:18、APIChanges.md:9
与接触求解有关的版本演进
图 9-34与接触求解有关的版本演进:v2.0.0 迭代数覆写、v3.0.0 大岛多线程、v4.0.0 摩擦修复、v5.0.0 覆写语义变更与 ResetWarmStart,到快照里的 2026-05-31 新摩擦模型@ 本系列绘制

这条线里有两处要特别说明。一处是 v5.0.0 那次覆盖语义变更。改之前,一个岛里所有约束的步数取「全部覆盖值与默认值的最大」;改之后,0 表示沿用默认值,如果全岛的覆盖值都低于默认值,就真的用低值。这条修改对应 CalculateSolverSteps.h:20–40 的现行算法,也正是 §8.2 里「旋钮细分」那句的来处,SetupVelocityConstraints 的多线程化同属这一版。另一处是快照里的新摩擦模型。它是一次破坏性变更(ReleaseNotes.md:18 / APIChanges.md:9),与 v5.5.0 及之前不同,摩擦从「每个接触点各算一份」改成「取平均接触点算 2 个线性约束 + 1 个角约束」。麻烦记住一句:凡引用摩擦行为,一律按本快照版本。§四 E6 那条「1 次速度迭代 → 摩擦完全失效」的结论,只在这个快照版本下成立。

9.3 资源清单

官方文档

  • Docs/Architecture.md:求解器命名(731)、求解速度 job(979–983)、求解位置 job(1014–1020);全文无 Baumgarte 与 speculative 字样
  • Docs/ReleaseNotes.md:快照最新摩擦模型(18)、v5.0.0(313–317)、v4.0.0(424–426)、v3.0.0(441)
  • Docs/APIChanges.md:新摩擦模型(9)、v2.0.0 迭代覆盖(126)

官方源码(逐条核过行号)

社区与第三方(Catto 讲稿与论文)

竞品

本机一手产物

  • evidence/bench_solver.cpp(E0–E7 八组实验,含四条纪律的源码级注释)、evidence/build_bench_solver.ps1(复现脚本)、evidence/bench_solver_run_20261004.txt(原始输出,正式档 57 行 / 36 行读数)

视觉素材(逐张核过许可与尺寸)

本清单的原则与前几章一致:每条结论都能指到它那一层的原始出处。凡是官方文档与源码里找不到对应命名的说法,本清单不收。

9.4 下一篇预告

好了,本篇解的是「一遍怎么解」:一遍迭代里四个量的先后、暖启动把上一步的答案当起点、不追零的三档刻度。第十篇换一个问题,解的是「谁跟谁一起解、谁不用解」:岛怎么切、大岛怎么并、什么时候可以睡。求解循环本身已经摆好,接着要回答的是它一次处理哪些体,以及并行与睡眠如何与这套迭代结构对接。这也解释了为什么本篇把求解循环写得这么细:循环是下一篇的接口,岛与睡眠要么往这个循环里送一批体,要么把它整段跳过。

下一篇:第十篇 · 岛、并行与睡眠:IslandBuilder、LargeIslandSplitter 与三判据。

With Best Wishes.

源码落点:版本条目的原话在 Docs/ReleaseNotes.md 与 Docs/APIChanges.md,本机一手产物是 evidence/bench_solver.cpp、evidence/build_bench_solver.ps1 与 evidence/bench_solver_run_20261004.txt 三份。其中:本清单的原理与前几篇一致:每条结论都能指到它那一层的原始出处,凡在官方文档与源码里找不到对应命名的说法,本清单不收。