Jolt Physics 3D · 第 八 篇

约束家族:13 类约束与可复用的求解原语

2026-10-0418,197 字31 张配图本文由@溯光 出品,转载请注明出处。

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

万向节
题图万向节(universal joint):同一根输入轴在两个不同角度下继续把扭矩传下去,这是「两体之间被约束住某些自由度」最直白的实物形态@ Wikimedia Commons Cardan-joint DIN808 type-D w-arrangement 3D.png(CC BY-SA 2.5,Silberwolf)

上一篇把形状谱系拆完了:形状回答「几何是什么」。这一篇换一个问题:两个体之间被允许怎么动。形状可以单独存在,约束至少牵着两个体。前者是名词,后者是动词。

篇幅不小,先把本篇的知识地图摆在开头。谱系的代价:13 类内置约束是追加式演进的产物,文档的列举顺序与枚举的声明顺序并不一致,本机逐项比过,13 项里只有 5 项同位。原语的代价:求解被抽成 14 个可复用的求解原语(solver primitive),一个具体约束只是把其中几个装配起来,GetTotalLambda* 就是这层装配的机器可读证据。量纲的代价:同一个弹簧旋钮在三种模式下量纲不同,纯刚度写法会随质量崩掉,频率写法不会。

证据构成交代一句。13 类枚举由本机逐个构造出来读回,14 个原语逐文件核过,文中的四组读数只用公开 API 跑出来,编译口径与场景条件写在 §八。

OK,正式进入正文。

入口:约束是「两体之间的关系」

0.1 从「碰上了之后怎么动」说起

先把这一层在整条链上的位置摆清。宽相(broad phase)负责配对,窄相(narrow phase)负责接触,这两件事在前面几章都已经说完:前者回答「哪些体的包围盒重叠了」,后者回答「碰上了没有、碰得有多深、接触面在哪」。约束(constraint)管的是第三件事,两个体之间被允许怎么动。接触的输出是一条否定句,不许穿;约束的输出是一组规矩,按规矩动。

好了,两者的来源也不一样。接触是几何算出来的,窄相从两个形状里取出接触点、法线、穿透深度,数量随场景每帧变化;约束是用户声明的,你在建库时写下「这两节之间只许绕一根轴转」,引擎照办。一辆车有四个轮子,每个轮子与车身之间可以各挂一个铰链;一扇门有门框与门板,两者之间是一个单轴旋转副;一台机械臂从底座到末端是一串关节。这些关系不会从几何里自己长出来,全是人写进 settings 的。

数据来源分属两处,落点却汇到同一个求解循环上。接触走窄相产出的接触流形,约束走 ConstraintManager,两者在进入求解前合并,判据、迭代次数、退出条件同一套。这条合并的时机与 job 顺序在 §四 落到源码行号上。

这段分工写成一个最小例子只有两步:

// 【最小约束】两体约束的创建只有两步:Settings 描述关系,Create 把它绑到两个体上
TwoBodyConstraint *c = settings.Create(*body1, *body2);   // 【1】构造:把 settings 变成约束
physics_system.AddConstraint(c);                          // 【2】登记:交给约束管理器参与求解

其中:settings 是「关系」的描述,Create() 把它实例化成约束对象,AddConstraint() 才让它进入求解循环。基类定义在 Constraint.h 上,而「单体还是两体」这条分界由 EConstraintType 标出(第 26 至 30 行)。

两者的差别摊开来看会更清楚:

维度 接触(contact) 约束(constraint)
来源 窄相从几何算出 用户在建库时声明
数量 每帧随场景变化 通常固定
数据 接触点、法线、穿透深度 设置项加两个体当前变换
归属 接触流形 ConstraintManager
求解 进入求解前与约束合并,共用同一套迭代与退出条件 同上
约束在物理链上的位置
图 8-1约束在物理链上的位置:宽相只答「谁和谁可能碰上」,窄相只答「碰在哪、插多深」,约束答「碰上了之后两者被允许怎么动」,三者各管一段@ 本系列绘制

把三者并排看,各自的输入与输出很清楚。宽相的输入是两个体的包围盒,输出是候选对;窄相的输入是候选对里的两个形状,输出是接触流形;约束的输入是用户给的设置项与两个体当前的变换,输出是修正后续帧速度与位置的冲量。前两层的对象是几何,第三层的对象是关系。这也解释了一个常见的困惑:体与体之间没有任何接触,却依然被推着走,那多半是有一条约束在起作用。

0.2 一个实物:机械臂的关节链

一条机械臂就是一条约束链。每个关节约束掉若干个自由度(degree of freedom),从底座的固定端一路传到末端执行器。单看每个关节都不复杂,底座与第一节之间可能只是一个铰链,两节之间可能只是一个锥形限位,可链一长,误差就愿意在末端聚起来。

说白了,原因落在求解方式上。每一步的位置修正都是一次迭代近似,拿到的是近似解;链上的每一节都要把上一节留下来的残差接住,节数越多,接住的残差越多,末端的偏差就越容易被放大。这条观察在本机是可以量出来的:把若干节固定约束竖直串成一条链,节数从 1 加到 16,末端的下垂从测不出来一路跳到 9.24 mm,这个数在 §八 给出完整读数。

机械臂的关节链
图 8-2外部真图:机械臂的关节链。一条由多个关节串起来的链,每个关节就是一处约束,链越长末端误差越容易被放大@ Wikimedia Commons Robot Arm (NIH BioArt 457).png(Public domain,Courtesy of NIAID Ryan Kissinger)

换个角度说这件事。单个约束的硬度在读数上很难分辨,因为约束在一步之内就把相对速度消掉了,测量工具看不到偏差。链把这件事放大了,它把每个关节的残差按次序累加,于是「约束的硬度由求解器决定」这句话从一句经验变成了一个可以复算的量。工程上要记住的落点也在这里:关节链越长,越不能指望默认迭代数下的末端精度。

0.3 本篇的三条主线与边界

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

  1. 谱系的代价:13 类是追加式演进的产物,文档的列举顺序与枚举的声明顺序只有 5 项同位。读文档记住的是分类,读枚举记住的才是序号,而序列化只认后者。落点 §一。
  2. 原语的代价:求解被抽成 14 个可复用原语,约束只是把它们装配起来。GetTotalLambda* 的转发实现就是这层装配的机器可读证据。落点 §三、§五。
  3. 量纲的代价:同一个弹簧旋钮在三种模式下量纲不同,频率是与质量无关的写法,刚度不是;位置马达配频率 0 会完全不动。落点 §二、§八。

边界声明也写进正文,供后面的交叉检验用:本篇不涉及求解迭代的数学推导(第九篇)、岛与并行、睡眠(第十篇)、连续碰撞检测(CCD)与确定性(第十一篇)、角色与载具成品件(第十二篇)、柔体与发束(第十三篇),也不展开约束的二进制序列化字节布局。本篇只管约束有哪些、由什么装配、怎么配参数、什么时候不生效。

好了,把范围收好,从被冻结成枚举的那份清单开始拆。

一、两张枚举:13 类约束的谱系

先看这份谱系被「冻」成的样子。约束这一层用两张枚举描述,一张粗、一张细,细的那张决定了序列化里每个约束的身份。

1.1 EConstraintSubType:13 项与 4 个用户槽

粗的那张叫 EConstraintType,只有 2 项:Constraint 与 TwoBodyConstraint(Constraint.h:26–30)。它区分的是「只有一个体的约束」与「牵着两个体的约束」,本系列涉及的场景全是后者。这个分类很薄,簿到只有一个位的作用,可它把「约束必然是两体的」这条前提显式写进了类型系统。

OK,细的那张叫 EConstraintSubType,一共 17 项,结构是 13 个内置(第 35 至 47 行)+ 4 个用户槽(第 50 至 53 行)。13 个内置的名称与序号,本机逐个构造之后读回,与源码声明顺序一致:

序号 名称 含义
0 Fixed 固定约束,锁死两个体
1 Point 点约束,让两个体在一点上相连
2 Hinge 铰链,只许绕一根轴转
3 Slider 滑轨,只许沿一根轴平移
4 Distance 距离约束,维持两点间距
5 Cone 锥形限位,绕一根轴转且限制张角
6 SwingTwist 摆-扭转,绕两轴摆动加绕自身轴扭转
7 SixDOF 六自由度约束
8 Path 路径约束
9 Vehicle 载具约束
10 RackAndPinion 齿条-小齿轮
11 Gear 齿轮
12 Pulley 滑轮
13–16 User1 – User4 引擎侧自定义约束槽

把 13 项按「限制的是平移还是旋转」重新看一眼,会出现几个自然的簇:Point、Distance、Slider 管的是位置上的关系,Hinge、Cone、SwingTwist 管的是旋转上的关系,Fixed、SixDOF 把平移与旋转一起管,Gear、RackAndPinion、Pulley 是传动,把两个体的运动按比例绑在一起,Path 与 Vehicle 则是把前面几种组合起来用的成品件。这张归类与源码里的序号无关,它是给人看的。

声明原文在 Constraint.h,没有显式赋值,序号全靠写入顺序得到:

//-----------------------------------【约束子类型】---------------------------------------
// 描述:Constraint.h:35-53,13 个内置 + 4 个用户槽,无显式赋值
//------------------------------------------------------------------------------------------
enum class EConstraintSubType : uint8
{
    //【1】13 个内置约束(第 35 至 47 行),序号 0 到 12
    Fixed,
    Point,
    Hinge,
    Slider,
    Distance,
    Cone,
    SwingTwist,
    SixDOF,
    Path,
    Vehicle,
    RackAndPinion,
    Gear,
    Pulley,

    //【2】4 个用户槽(第 50 至 53 行),序号 13 到 16
    User1,
    User2,
    User3,
    User4,
};

上面这段代码里,两个编号注释就是阅读它的抓手:第【1】段是引擎自带的 13 类,第【2】段留给使用方扩展。没有显式赋值这件事看着无所谓,可它决定了一条硬约束:任何插入都会把后面所有序号整体后移。§1.2 会讲这条为什么不能动。

源码里的两份声明都在 Constraint.h 第 26 至 54 行,官方文档的清单在 Architecture.md 第 471 至 483 行。其中:前一张枚举只有 2 项,后一张有 17 项,而序列化只认后一张的序号。

13 类内置约束的谱系
图 8-313 类内置约束按「限制什么」归类的谱系总表:13 个内置(序号 0–12)+ 4 个用户槽(序号 13–16),并逐行标出各类的枚举序号@ 本系列绘制
EConstraintSubType 的声明原文
图 8-4EConstraintSubType 的声明原文:13 个内置(第 35 至 47 行)+ 4 个用户槽(第 50 至 53 行),一个显式赋值都没有,序号全靠写入顺序得到@ Jolt Physics v5.6.0 源码原文截取(MIT)

这里还要记一个查无落点。约束这一层没有子类型名表,形状那一层有 sSubShapeTypeNames,ep07 的 §1.2 用过它,能把序号翻成字符串。约束这一层没有对应的表,引擎只给你序号,名字得由调用方自己维护。所以日志里看到「子类型 10」时,把它翻译成人能读懂的东西是使用者的活,引擎不代劳。这一处空缺值得单独记一笔,它让后面所有「按名字查约束」的想当然都失去了落点。

1.2 追加式演进:为什么 Gear 排在 RackAndPinion 之后

好了,既然序号是写入顺序给的,那它反映的就是加入时间的先后。RackAndPinion、Gear、Pulley 三类是在 v1.0.0 一起加进来的(ReleaseNotes.md:462),它们被插在内置列表的尾部,而不是按语义重排到 Hinge 或 Slider 旁边。按「谁能带动谁」这一层语义,齿条-小齿轮与齿轮显然该排在一起,可枚举里顺序是 RackAndPinion、Gear、Pulley,只能读出加入时间,读不出语义亲疏。这就是 ep07 里那套「化石层」规律,形状与约束用的是同一个规律。

为什么不能趁重构把顺序理顺?因为序号会进二进制流。约束的状态走 SaveBinaryState,序列化时写进去的是序号而不是名字,一旦重排,历史存档在读取时会把序号对应到错误的类型上。这类错位不会报错,只会在某次加载后给出一个行为不对的约束。反过来,如果序列化写的是名字,重排就安全了,代价是每条记录的字节数变大、字符串比较也慢。引擎选了序号,于是顺序被钉死。

文档的列举顺序与枚举的声明顺序并不一致,这一点值得单独摆一张对照。

文档顺序与枚举顺序的对照
图 8-5文档列举顺序与枚举声明顺序的对照:逐位对齐只有 5 项同位(Fixed / SwingTwist / SixDOF / Path / RackAndPinion),其余 8 项都不同@ 本系列绘制

1.3 一处必须说清的差异:文档顺序 ≠ 枚举顺序

OK,官方文档 Architecture.md 第 471 至 483 行列举 13 类时的顺序是:

Fixed / Distance / Point / Hinge / Cone / Slider / SwingTwist / SixDOF / Path / Gear / RackAndPinion / Pulley / Vehicle;

而源码枚举的顺序是:

Fixed / Point / Hinge / Slider / Distance / Cone / SwingTwist / SixDOF / Path / Vehicle / RackAndPinion / Gear / Pulley。

本机把两个顺序并排比了一遍,13 项里只有 5 项位次相同,分别是 Fixed、SwingTwist、SixDOF、Path、RackAndPinion,其余 8 项都不同。把两个顺序逐位对齐看:

位次 文档顺序(Architecture.md:471-483) 枚举顺序(Constraint.h:35-47) 是否同位
0 Fixed Fixed 是
1 Distance Point 否
2 Point Hinge 否
3 Hinge Slider 否
4 Cone Distance 否
5 Slider Cone 否
6 SwingTwist SwingTwist 是
7 SixDOF SixDOF 是
8 Path Path 是
9 Gear Vehicle 否
10 RackAndPinion RackAndPinion 是
11 Pulley Gear 否
12 Vehicle Pulley 否

文档是按「类之间的语义关系」组织的:把三个基础的旋转约束 Point、Hinge、Cone 相邻,把两个传动约束 Gear、RackAndPinion 相邻。源码是按「加入时间」排列的:谁先写进来谁排前面,后加的只能往尾巴上接。

工程含义很短:读文档记住的是分类,读枚举记住的才是序号,而序列化只看后者。写存档、写统计表、写调试输出时,拿到的都是序号,名字表得自己维护。这一条也是 §1.1 那个「查无落点」的直接后果。

说白了,两张顺序服务于两种读者。给人看的顺序追求「一眼看懂关系」,给机器看的顺序追求「永不改变」,这两件事在追加式演进下没法同时满足,于是文档重排一遍、源码保持原样。

二、弹簧与马达:一个旋钮的三种量纲

约束的谱系讲完,接下来拧一个具体的旋钮。约束上有两类可调机构,一类是弹簧(spring),决定「这个约束多软」,一类是马达(motor),决定「这个约束主动往哪走」。这两类机构在源码里并没有绑死在某个约束上,它们被抽成独立的设置类,供所有约束共享。本章量的是弹簧与马达的量纲,以及一个量纲问题会怎样变成一个「设了不动」的坑。

2.1 SpringSettings 的三种模式

好了,弹簧设置叫 SpringSettings,模式枚举 ESpringMode(SpringSettings.h:15–20)一共 3 项:FrequencyAndDamping(默认)、StiffnessAndDamping、MassNormalizedStiffnessAndDamping。三项对应两套参数写法,一套用频率,一套用刚度;刚度那套又分「绝对刚度」与「质量归一刚度」。三种模式听起来只是换个单位,实际上换的是整根弹簧的标定方式。

一个 union 陷阱先摆出来。mFrequency(SpringSettings.h:54)与 mStiffness(:70)是同一个字段的两个名字,两者被塞在一个 union 里(union 在 :49–71),各自所在的结构还都带一个阻尼字段,两个结构共用同一块内存。

//-----------------------------------【两种别名,同一块存储】---------------------------------------
// 描述:SpringSettings.h:49-71 用 union 让两套参数名共享内存
//------------------------------------------------------------------------------------------
union
{
    //【1】频率模式:每秒振动次数,量纲是 Hz
    float   mFrequency;     // SpringSettings.h:54
    //【2】刚度模式:量纲是 力/长度,与上面是同一个地址
    float   mStiffness;     // SpringSettings.h:70
};

// SpringSettings.h:41,名字叫「刚度」,检查的却是频率字段
inline bool HasStiffness() const    { return mFrequency > 0.0f; }

上面这段的【1】与【2】是同一个存储位置的两个标签,换个标签不会换数值。这条陷阱的后果很直接:换模式不改数值,等于没改行为。把一个写着 mFrequency = 0.0f 的设置改成刚度模式,以为它会变硬,实际那个地址上还是 0。要真正切过去,必须重新标定一个数值。

三种模式的量纲摆在一起看最清楚:

模式 主参数 主参数量纲 说明
FrequencyAndDamping mFrequency Hz(每秒次数) 默认模式;阻尼是无单位比值,0 无阻尼、1 临界阻尼
StiffnessAndDamping mStiffness 力/长度 绝对刚度,会随质量变化
MassNormalizedStiffnessAndDamping mStiffness 加速度/长度 折算掉了质量
SpringSettings 三种模式的量纲对照
图 8-6SpringSettings 三种模式的量纲对照:频率模式给 Hz 与阻尼比、与质量无关;刚度模式给 N·m⁻¹ 与阻尼系数、重物下会失稳;质量归一化刚度给 1/s²,同样与质量无关@ 本系列绘制

HasStiffness() 那一行值得单独记住。它的名字叫「有没有刚度」,实现却是 mFrequency > 0.0f,检查的是频率字段。这不是笔误,两个名字共享内存,查频率与查刚度本就是同一件事。可它给使用者留下的印象是反的:明明配的是频率,函数名读起来像在问刚度。§2.4 那个「马达不动」的坑,根就扎在这一行上。

2.2 「硬限制」是 frequency = 0 的一个特例

OK,弹簧在什么情况下退化成硬限制,官方给了明确的口径。SpringSettings.h:53 的原注写着:频率小于等于 0 时 mDamping 被忽略,约束退化为硬限制(hard constraint),硬到什么程度由时间步与求解迭代数决定。所以硬限制在实现上是频率为 0 时的一个特例,而不是另一套代码路径。

本机把硬限制与软限制并排跑了一组读数。硬限制 6 步到位、零超调;2 Hz 欠阻尼反而要 16 步、超调 0.58%。两个数字放一起,能看出一件容易被误解的事:软限制并不比硬限制更稳,它只是更慢。硬限制在一步之内把相对速度消掉,软限制则像一根真弹簧,先超出去一点再收回来。

这条行为在源码里也写实了。DistanceConstraint.cpp:184 的原注是这么一句:When the spring is active, we don't need to solve the position constraint。弹簧一旦生效,位置约束就不必再解一遍,因为弹簧自己会把位置往目标上拉。这句话把「软」与「硬」在实现上的分工说清楚了:硬限制靠位置修正,软限制靠弹簧的力。两种手段都在同一个求解循环里,只是走的子路径不同。

2.3 MotorSettings 的四态

马达设置叫 MotorSettings,状态枚举 EMotorState(MotorSettings.h:16–22)一共 4 项:

//-----------------------------------【马达四态】---------------------------------------
// 描述:MotorSettings.h:16-22,四档状态
//------------------------------------------------------------------------------------------
enum class EMotorState
{
    Off,                    //【1】马达关闭,不施加任何主动力
    Velocity,               //【2】只追目标角速度
    Position,               //【3】只追目标角度
    PositionAndVelocity,    //【4】位置与速度两个目标同时追
};

MotorSettings 自己带一个默认弹簧,取值是 { FrequencyAndDamping, 2.0f, 1.0f }(:75),也就是 2 Hz、阻尼比 1(临界阻尼);四档限幅在 :76–79,分别是 mMinForceLimit / mMaxForceLimit / mMinTorqueLimit / mMaxTorqueLimit。默认弹簧这一项是理解 §2.4 的关键,先记下来。

四态各自的行为:

状态 目标 生效条件 常见用途
Off 无 恒生效(就是不干活) 关节自由摆动
Velocity 目标角速度 恒生效 匀速转动的轮子
Position 目标角度 需要弹簧刚度为正 伺服定位
PositionAndVelocity 目标角度加目标角速度 需要弹簧刚度为正 带速度前馈的伺服

官方马达公式(Architecture.md:499)把它写成一句话:作用力正比于「刚度 ×(目标位置 − 当前位置)+ 阻尼 ×(目标速度 − 当前速度)」。

其中:

  • 刚度与阻尼就是 MotorSettings 里那个弹簧的设置,由模式和数值决定;
  • 目标位置与目标速度由状态决定,Position 只给出前者,Velocity 只给出后者;
  • 两个括号里的差值各自带符号,说明马达是一根有方向的弹簧,而不是一个恒力。

说白了,四态里只有后两态真正依赖弹簧设置,前两态要么不干活,要么直接把速度设到目标上。上表第三列那条「需要弹簧刚度为正」是一条真闸门,它把状态与弹簧设置绑在了一起,也是下一节那个坑的入口。

把上面那条公式与四档限幅放在一起,马达的行为就完全确定了:弹簧负责「往目标拉」,限幅负责「最多用多大力」。其中:作用力等于刚度乘位置差加阻尼乘速度差,再由 mMinForceLimit 与 mMaxForceLimit 夹住,力矩侧同理。

马达的驱动力公式与四档限幅
图 8-7马达的驱动力公式与四档限幅:位置态 force = k·Δ位置 − c·速度,双目标态再加一个速度差项,最后由力与力矩各一对上下限夹住@ 本系列绘制
马达的四种状态
图 8-8马达的四种状态与各自行为:Off / Velocity / Position / PositionAndVelocity;位置态只在弹簧刚度大于 0 时才真正出力@ 本系列绘制

2.4 一个坑:位置马达配频率 0 完全不动

好了,源码位置很清楚,HingeConstraint.cpp:221:位置态在装配马达之前先判一次 mMotorSettings.mSpringSettings.HasStiffness(),为假就直接不装配马达。而 HasStiffness() 就是 §2.1 那一行 mFrequency > 0.0f。于是链条闭合了:位置态先查刚度,查的却是频率,频率为 0 就判定「没有刚度」,接着不装马达,最后设置看起来装上了,运行时完全没反应。

本机把这条量成了读数。Position 状态配频率 0 跑 900 步,角度仍是 0.0000;同样配 2 Hz,则 35 步到 1.5708 rad。两个读数只差一个弹簧设置,一个纹丝不动,一个按预期到位。

这条解释了社区里「马达设了却不动」的第一大来源。默认 SpringSettings() 的频率是 0,默认 MotorSettings() 的弹簧频率是 2 Hz。使用 MotorSettings 的默认值时,马达能动;自己往 MotorSettings 里塞一个空的 SpringSettings,马达就不动了。两个默认值一个 0 一个 2,看起来都叫「默认」,含义却相反。排查这类问题时,先看一眼那个弹簧对象的频率字段,比盯着一堆别的参数有效。

2.5 官方两张马达图在讲什么

官方文档为马达的频率与阻尼各配了一张收敛曲线图,Architecture.md:543 与 :549 分别引用它们,原图是 Docs/Images/MotorFrequency.jpg 与 MotorDamping.jpg,各 1000×750,MIT 许可。两张图讲的是同一件事的两半:频率决定马达快慢,阻尼决定它晃不晃。

频率那张图里,随着共振频率变化,马达从「慢」走到「快而抖」:频率太高会追得太急,出现抖动;频率太低则迟迟到不了目标。阻尼那张图里,随着阻尼比变化,超调从「来回晃」走到「一步到位」:阻尼太小会来回震荡,阻尼合适则直接收敛。两张图合起来,正好是 §2.3 那条公式里刚度项与阻尼项各自的作用。

官方马达频率图
图 8-9官方马达频率图:共振频率变化时,马达从「慢」到「快而抖」的收敛曲线(官方一手图)@ Jolt Physics v5.6.0 官方文档配图 Docs/Images/MotorFrequency.jpg(MIT,Jorrit Rouwe)
官方马达阻尼图
图 8-10官方马达阻尼图:阻尼比变化时,超调从「来回晃」到「一步到位」(官方一手图)@ Jolt Physics v5.6.0 官方文档配图 Docs/Images/MotorDamping.jpg(MIT,Jorrit Rouwe)

两张原图的长边都是 1000,不足 1280,本篇按原理小图使用,不放大、不裁切。它们与 §2.2 那组本机读数指向同一结论:弹簧的参数决定的是收敛速度与超调量,与「有没有作用」无关。

2.6 一个实物对照:门铰链

OK,最后把这个旋钮落回一个实物。门铰链把门板的姿态限制在一条轴上,只允许它绕着一根竖直轴转;与此同时,门还常常带一个「开到某个角度就停」的限位。限制在一个轴上,是旋转副本身;停在某个角度,是限位。

HingeConstraint 的 mLimitsMin / mLimitsMax 就是这道限位的两种写法,默认取 −π 到 π,等价于没有限位(一整圈都能转)。工程里给门做限位,是把这两个值收窄到实际需要的角度区间;不给限位,是两个值保持默认。门铰链这个实物说明,限位在现实里本来就存在,只是被搬进了设置项。

门铰链实物
图 8-11外部真图:门铰链。单轴旋转副在工程里的形态,也说明「限制到什么角度」是一条真实存在的设计约束@ Wikimedia Commons Door Hinge MET 216811.jpg(CC0,The Metropolitan Museum of Art)

实物、谱系、弹簧、马达都各自有了落点。再往下拧一层,看看这些约束在引擎里到底是由什么拼起来的。

三、ConstraintPart:14 个可复用的求解原语

前面两张枚举回答了「Jolt 有哪些约束」,接下来换个问法:这些约束在每一个时间步里到底靠什么被解开。Jolt 的答案是把「求解」这个动作从具体约束里抽出来,单独做成一层可复用的求解原语(solver primitive),具体约束只负责把若干原语按顺序装配起来。这层的目录名就把意图写在脸上,叫 ConstraintPart。看清这一层,才能解释后面那些看起来各不相同的约束,为什么代码结构如此相似。

3.1 目录全量清单

Jolt/Physics/Constraints/ConstraintPart/ 下实测 14 个文件,逐个点开核过,全是头文件实现(模板与内联),没有隐藏的 .cpp。清单如下,括号内是 raw 直链。

文件 一个词概括
AngleConstraintPart.h 单个角度
AngularFrictionConstraintPart.h 角摩擦
AxisConstraintPart.h 单轴平移
ContactConstraintPart.h 接触侧复用
DualAxisConstraintPart.h 双轴平移
GearConstraintPart.h 齿轮传动
HingeRotationConstraintPart.h 铰链轴旋转
IndependentAxisConstraintPart.h 独立轴
PointConstraintPart.h 一点三自由度
RackAndPinionConstraintPart.h 齿条小齿轮
RotationEulerConstraintPart.h 欧拉整姿态
RotationQuatConstraintPart.h 四元整姿态
SpringPart.h 软约束核心
SwingTwistConstraintPart.h 摆加扭

OK,这 14 个盒子摆在一起,能看出一个明显的信号:Jolt 没有为「铰链」「滑轨」这类给人看的名字各写一套求解代码。它先把「限制一个方向」「限制一个角度」「限制整姿态」这些几何动作造出来,再让约束去点菜。命名跟着动作走,代码复用才有落点。

还有一层设计藏在文件后缀里:14 个文件全是头文件,实现直接写在类里。求解函数会被反复调用,跨编译单元的调用开销在这里舍不得给,内联到调用点更划算;代价是编译期变重,换来运行期没有间接跳转的稳定耗时。这份形态也解释了 §3.4 里那句注释为何能一字不差复制到十个文件。

这套目录的存在原理是「算一次、用多次」:有效质量与偏置在装配阶段算好,之后的每一次速度迭代只做乘加,不再重复求逆。其中:每个原语自己维护一份有效质量与一份累计冲量,约束本体不持有这两样东西,这正是下一节要展开的那条规律。

ConstraintPart 目录的 14 个文件
图 8-12ConstraintPart/ 目录的全量清单:14 个原语文件,求解被抽成可复用的一层,约束只是把它们装配起来@ Jolt Physics v5.6.0 源码目录截取(MIT)

3.2 二次归类:按「限制什么」分四组

好了,把 14 个文件名按它们限制的自由度重新归一次类,四组就分完了。位置类里,PointConstraintPart 管一点三自由度(三个平移方向同时锁死),AxisConstraintPart 只锁一个方向,DualAxisConstraintPart 锁两个方向,IndependentAxisConstraintPart 处理两根互不相干的轴(滑轮约束用它)。旋转类里,AngleConstraintPart 管一个角,HingeRotationConstraintPart 管铰链轴的旋转,RotationEulerConstraintPart 与 RotationQuatConstraintPart 管整姿态,SwingTwistConstraintPart 管「摆 + 扭」这一对组合自由度。传动类里,GearConstraintPart 与 RackAndPinionConstraintPart 管两个体之间的传动比。剩下的三个归到弹簧与辅助:SpringPart 是软约束的核心,ContactConstraintPart 让接触求解复用同一套接口,AngularFrictionConstraintPart 管角摩擦。

组 原语 限制的对象
位置类 Point / Axis / DualAxis / IndependentAxis 平移自由度
旋转类 Angle / HingeRotation / RotationEuler / RotationQuat / SwingTwist 旋转自由度
传动类 Gear / RackAndPinion 两体之间的传动比
弹簧与辅助 Spring / Contact / AngularFriction 软约束与接触侧复用

这张表把「约束名」与「求解动作」彻底分开了:一个 Hinge 在给人看的层面是「铰链」,在求解层面只是位置类的一个 Axis 加旋转类的两个件。后面的 §5 会把这条对应关系逐个读出来。

值得停下来看两处分组的细节。第一处是 IndependentAxisConstraintPart,它被单独归进位置类,因为它处理的是两根独立方向上的约束,一根轴的冲量不影响另一根,滑轮(pulley)约束正是这种结构:两段绳子各自有方向,绳长之和被约束成定值。把它塞进普通单轴或双轴原语里都别扭,于是单列一个。第二处是 ContactConstraintPart,它属于接触侧,却被摆进同一个目录。这说明了原语这一层的边界:它服务的对象可以是用户声明的约束,也可以是窄相(narrow phase)算出来的接触,两者走的是同一套求解接口。这也是本篇开头那句「约束与接触共享同一个求解循环」在目录结构上的落点。

分组之后能看出一个取舍:位置类与旋转类各有明确的几何语义,传动类则更偏「比值关系」,GearConstraintPart 约束的是两个角速度之比,RackAndPinionConstraintPart 约束的是旋转与平移之比。比值关系的量纲与前两类不一样,实现里对有效质量的处理也就不同。弹簧与辅助这一组则是横切面,SpringPart 被前两组大量调用,它把「硬限制」与「软约束」统一成同一套参数入口。

14 个原语按「限制什么」归类
图 8-1314 个原语按「限制什么」分四组:点/轴/双轴、角度/四元/欧拉、传动/独立轴、摆锥/铰链旋转,再加软约束、接触与角摩擦@ 本系列绘制

3.3 每个原语都提供同一套四个接口

说白了,这一层之所以叫「可复用」,靠的是 14 个原语长的接口几乎一样。它们都提供同一套四个动作。

//-----------------------------------【ConstraintPart 的公共接口】-----------------------------------
// 描述:每个原语都提供同一套四个动作,外加一组状态查询
//------------------------------------------------------------------------------------------
bool CalculateConstraintProperties(...);        // 算有效质量与偏置;另有四个 WithXxx 变体
void WarmStart(...);                            // 【1】用上一步的冲量做暖启动
void SolveVelocityConstraint(...);              // 【2】速度侧迭代
void SolvePositionConstraint(...);              // 【3】位置侧修正
float GetTotalLambda() const;                   // 【4】读本步累计冲量
void Deactivate();  bool IsActive() const;      // 状态查询与关闭

下面对这个接口形状特别说明。计算入口不止一个,CalculateConstraintProperties 有四个变体,分别对应 WithFrequencyAndDamping、WithStiffnessAndDamping、WithSettingsForLimit、WithSettingsForMotor,把上一章讲过的三种弹簧模式与限位、马达两种情况都收进同一族函数名里。逐个举两个原语给行号:AxisConstraintPart.h 的 CalculateConstraintProperties 在 145 行、...WithSettingsForMotor 在 229 行、SolveVelocityConstraint 在 272 行、SolvePositionConstraint 在 321 行;SpringPart.h 的 CalculateSpringPropertiesWithBias 在 21 行、...WithFrequencyAndDamping 在 98 行、...WithMassNormalizedStiffnessAndDamping 在 123 行。外加 ApplyVelocityStep、Deactivate、IsActive、GetTotalLambda、SaveState 这几个收尾与查询接口。名字对得上,行为才对得上,这是后面「读一个 getter 就能知道内部用了哪些原语」的前提。

这套接口形状还回答了上一章留下的一处疑问。上一章讲弹簧有三种模式,量纲彼此不同,这里四个 WithXxx 变体就是它们的入口:WithFrequencyAndDamping 与 WithStiffnessAndDamping 分别对应频率模式与纯刚度模式,WithSettingsForLimit 与 WithSettingsForMotor 则分别服务限位与马达这两个调用场景。一个原语不必关心调用它的是铰链的限位还是马达,只要按传入的设置算出有效质量(effective mass)与偏置即可。WarmStart 与 SolveVelocityConstraint、SolvePositionConstraint 三个动作,再配上 ApplyVelocityStep 把解出的冲量真正作用到两个体上。Deactivate 与 IsActive 管开关,GetTotalLambda 管读数,SaveState 管状态存取。九个名字摊开,求解的完整来回都在里面了。

把 14 个原语的函数名排成一列,会发现只有 CalculateConstraintProperties 这一族有变体,其余动作的签名在各文件之间几乎一致。这种「入口多态、出口统一」的安排,让约束在装配时只需要选对入口,后面三个阶段照抄即可。这也是 §4 那条生命周期能对所有约束一视同仁的原因。

3.4 同一句注释出现在 10 个原语里

好了,这条证据比接口名更硬。在 10 个原语的 SolvePositionConstraint 里,逐字相同地出现了同一句注释:「Solving Constraints by Erin Catto presented at GDC 2007. On slide 78 it is suggested to split up the Baumgarte…」。相同的出处,出现在 10 个不同的文件里。

原语 行号
AngleConstraintPart.h 250
AxisConstraintPart.h 345
DualAxisConstraintPart.h 219
GearConstraintPart.h 163
HingeRotationConstraintPart.h 178
IndependentAxisConstraintPart.h 206
PointConstraintPart.h 192
RackAndPinionConstraintPart.h 164
RotationEulerConstraintPart.h 245
RotationQuatConstraintPart.h 207

另一处出处在 SpringPart.h:40,注释写「Soft constraints as per: Soft Constraints: Reinventing The Spring - Erin Catto - GDC 2011」,这跟官方文档 Architecture.md:539 是同一个出处。把两件事并排看,结论就落下来了:这 14 个原语各自发明的说法站不住脚,它们共享同一份方法论来源,一份是 Catto 在 GDC 2007 讲的 Baumgarte 拆分,一份是他在 GDC 2011 讲的软约束。同源的注释意味着同源的数值行为,这也是为什么换一个约束去调同一个弹簧参数,曲线形状会对得上。

那句注释里提到的「拆分 Baumgarte」值得多写一句,因为它解释了 §4 里为什么位置修正要单列一个阶段。Baumgarte 稳定化原本把位置误差直接掺进速度求解里,用来抑制漂移;Catto 在 GDC 2007 建议把它拆开,速度侧只管速度约束,位置误差留到一个独立的位置修正阶段处理。Jolt 的 SolveVelocityConstraint 与 SolvePositionConstraint 两个名字,正好对应这次拆分。位置迭代与速度迭代能各自独立设置上限,前提就是这两件事在实现里本就是分开的。

至于 GDC 2011 那次讲的软约束,它管的是另一件事:把弹簧的刚度与阻尼写进约束的偏置里,让约束像一根有弹性的杆。SpringPart.h 引用它,说明软约束从求解内部表达出来,不靠外挂一个力。两份出处一份管误差、一份管弹性,合起来覆盖了这 14 个原语的全部数学来源。

四、约束的生命周期

上一节把「零件」看完了,这一节看「装配线」。一个约束从被创建到被求解,要跨过几个明确的阶段,而阶段之间的接口定义在 Constraint.h 里。走一遍这条线,就能解释为什么所有约束都长着相似的骨架。

4.1 四个纯虚与一批可选

Constraint.h 里立着四个纯虚函数,任何一个具体约束都必须实现它们,求解时四个都要走一遍:SetupVelocityConstraint 在 :177、WarmStartVelocityConstraint 在 :178、SolveVelocityConstraint 在 :179、SolvePositionConstraint 在 :180。纯虚的写法意味着「不实现就编译不过」,这是骨架的第一层保证。

//-----------------------------------【Constraint.h 的求解接口】-----------------------------------
// 描述:四个纯虚是每个约束必须走完的一遍,可选接口按需覆写
//------------------------------------------------------------------------------------------
virtual void SetupVelocityConstraint(float inDeltaTime) = 0;                  // :177
virtual void WarmStartVelocityConstraint(float inWarmStartImpulseRatio) = 0;  // :178
virtual bool SolveVelocityConstraint(float inDeltaTime) = 0;                 // :179
virtual bool SolvePositionConstraint(float inDeltaTime, ...) = 0;            // :180
// 可选:IsActive :176、NotifyShapeChanged :166、ResetWarmStart :172、
//       BuildIslands :184、BuildIslandSplits :187、DrawConstraint* :191-193、
//       SaveState :201 / RestoreState :204、GetConstraintSettings :207

除了四个纯虚,还有一批带默认实现的可选接口,按用途可以分成几类。

接口 行号 作用
IsActive :176 这条约束本步要不要参与求解
NotifyShapeChanged :166 形状变了通知约束重算
ResetWarmStart :172 清掉暖启动缓存
BuildIslands :184 建岛时把自己挂进岛里
BuildIslandSplits :187 岛分裂时的处理
DrawConstraint* :191–193 调试绘制
SaveState / RestoreState :201 / :204 状态存取
GetConstraintSettings :207 反查创建时的 settings

值得单独点一句的是序列化的分工:SaveBinaryState 落在 Settings 类上(:72),约束本体走的是 SaveState。两者一个负责「当初怎么设的」,一个负责「现在处于什么状态」,这也是为什么本机实验里改完参数再 GetConstraintSettings() 能原样读回来。

四个纯虚的顺序不是随手排的,它跟 §4.2 那条流水线一一对应:先装配,再暖启动,然后速度迭代,最后位置修正。引擎按这个顺序调用,约束只要实现这四个方法,就被接进了同一条线。可选接口则留出扩展点:IsActive 让约束能按条件临时退出,NotifyShapeChanged 让形状变动能触发重算,BuildIslands 与 BuildIslandSplits 关系到岛与并行,DrawConstraint* 三个重载服务调试绘制。这些可选件不是每条约束都需要的,写不写都不影响它被求解,写了则在特定场景下多一层配合。分发到具体约束时,引擎只认这四个纯虚,这就是「所有约束长得像」的根。

这份接口清单按「必须实现还是可以偷懒」分成两层,读起来就清楚了:四个纯虚是每个约束都得交代的求解动作,其余是带默认实现的选装件,其中调试绘制那三个还只在 JPH_DEBUG_RENDERER 打开时才存在。

约束接口的两层结构
图 8-14约束接口的两层结构:四个必须实现的纯虚(Setup / WarmStart / SolveVelocity / SolvePosition)与一批带默认实现的选装件,逐项标出行号@ 本系列绘制
Constraint.h 的求解接口原文
图 8-15Constraint.h 第 174 至 207 行的求解接口原文:四个纯虚(:177–180)与一批可选件(IsActive / NotifyShapeChanged / ResetWarmStart / BuildIslands / DrawConstraint* / SaveState / GetConstraintSettings)@ Jolt Physics v5.6.0 源码原文截取(MIT)

4.2 一步里的四阶段

OK,接口定了顺序,再看引擎在哪一步调用它们。PhysicsSystem.cpp 里的 job 排布给出了四阶段的主线:SetupVelocityConstraints 的 job 在 :360,BuildIslandsFromConstraints 在 :368,DetermineActiveConstraints 在 :379,SolveVelocityConstraints 在 :492,SolvePositionConstraints 在 :556。

真正干活的实体在 ConstraintManager.cpp,七个回调按顺序串起整条链:

回调 行号 干什么
GetActiveConstraints :71 取出本步活跃约束
sBuildIslands :92 约束建岛
sSortConstraints :103–116 按优先级排序
sSetupVelocityConstraints :118–124 装配有效质量与偏置
sWarmStartVelocityConstraints :127–137 暖启动
sSolveVelocityConstraints :143–156 速度侧迭代
sSolvePositionConstraints :158–171 位置侧修正

把这七个回调连起来读,就是「装配 → 暖启动 → 速度迭代 → 位置修正」这条流水线。有效质量(effective mass)在装配阶段一次算好,暖启动(warm starting)把上一步的冲量搬进这一步当起点,后两个阶段才是迭代求解。

顺序里藏着两处工程考虑。sBuildIslands 排在装配之前,是因为迭代次数与并行分组都要按岛来定,岛划好了才知道每条约束该跟谁一起算。sSortConstraints 插在 sBuildIslands 之后、装配之前,说明排序在建岛阶段就要完成一次。DetermineActiveConstraints 对应的 job 在 PhysicsSystem.cpp:379,位置在装配 :360 之后、速度迭代 :492 之前:先确定哪些约束本步真的参与,后面的循环才有对象可解。把这几行行号按时间顺序连起来,一条「建岛 → 排序 → 定活跃 → 装配 → 暖启动 → 速度 → 位置」的链条就完整了。七个回调分布在两个文件里,ConstraintManager.cpp 提供实现,PhysicsSystem.cpp 决定调用时机。

约束在一步里的四阶段
图 8-16约束在一步里的四阶段:装配 → 暖启动 → 速度迭代 → 位置修正;后两段各是一个循环,本步没再施加冲量就 break 提前退出@ 本系列绘制

4.3 两个迭代循环都会「提前退出」

好了,流水线的最后两段各是一个循环,而这两个循环里藏着一条容易被忽略的机制。PhysicsSystem.cpp:1530–1535(速度侧)与 :2664–2669(位置侧)是同一个结构:

// PhysicsSystem.cpp:1530-1535(速度侧)
for (uint step = 0; step < num_velocity_steps; ++step)
    if (!constraint->SolveVelocityConstraint(dt))
        break;                                  // 【1】本步没再施加任何冲量就跳出
// PhysicsSystem.cpp:2664-2669(位置侧)
for (uint step = 0; step < num_position_steps; ++step)
    if (!constraint->SolvePositionConstraint(dt))
        break;                                  // 【2】同样的提前退出结构

读一下 break 的触发条件:本步没有再施加任何冲量就跳出。工程含义很直接,提高迭代上限不等于提高开销。本机把速度迭代上限从 2 提到 30,总耗时几乎不动;把位置迭代上限从 2 提到 8,总耗时翻了一倍多。这一对的读数在 §8.5 展开,此处先记住结论:两个循环名义上共享同一套提前退出结构,实际行为却因场景而异,速度侧在那组实验里两步内就收敛完了,位置侧则真的一直算到八步。

把这两个循环与 §6.3 的两个迭代覆盖对上,就能拼出完整的调度图:速度覆盖决定速度侧循环的上限,位置覆盖决定位置侧循环的上限,两个上限互相独立;而真正跑了几遍,由上限与那道 break 闸门谁先到决定。官方文档里「迭代次数」给的是上限,不是实际执行遍数,做性能对照时只看上限会得出与实际不符的结论。

五、装配:每个约束用了哪几个原语

5.1 GetTotalLambda* 就是装配的机器可读证据

说白了,「约束由原语装配而成」这句话是可以被读出来的,不必靠比喻。每个具体约束都暴露若干 GetTotalLambda*,而它们的实现就是转发给内部某个原语的 GetTotalLambda()。以 HingeConstraint.h 为例:

//-----------------------------------【HingeConstraint.h 的累计冲量转发】-----------------------------------
// 描述:约束暴露的每个累计冲量,转发给内部某个原语
//------------------------------------------------------------------------------------------
float GetTotalLambdaPosition() const      { return mPointConstraintPart.GetTotalLambda(); }        // :143
float GetTotalLambdaRotationLimits() const{ return mRotationLimitsConstraintPart.GetTotalLambda();} // :145
float GetTotalLambdaMotor() const         { return mMotorConstraintPart.GetTotalLambda(); }        // :146

下面对这种转发特别说明。GetTotalLambda* 读的是本步内各次求解迭代累计的冲量,一个约束里有几股力在撑着,就有几个这样的 getter。顺着这条线索去数,就能推出每个约束内部装了几个原语。同类证据在 12 类约束里都能找到。

约束 行号
ConeConstraint.h 102 / 103
SliderConstraint.h 126–129
DistanceConstraint.h 86
SwingTwistConstraint.h 152–156
SixDOFConstraint.h 196–199
PathConstraint.h 133–137
PulleyConstraint.h 95
GearConstraint.h 83
RackAndPinionConstraint.h 85
FixedConstraint.h 76 / 77
PointConstraint.h 80
HingeConstraint.h 143–146

这张表顺带给出了一个边界:13 类约束里 12 类都有 GetTotalLambda*,只有载具约束(Vehicle)没有。原因也清楚,载具约束内部是一个轮子加悬挂的复合结构,它读出的量按轮子组织,不按单一原语组织。

这些 getter 的用处不只是自证结构。调试约束抖动时,想知道「这一步是哪股力在撑着」,看对应原语的累计冲量比看位置曲线直接。前面 §3.3 讲过 GetTotalLambda 读的是本步累计冲量,具体约束把它按原语拆开转发出去,于是每条 GetTotalLambda* 就是一个可读的探针。一条约束暴露哪几个探针,就说明它内部装了几块原语。数一数 getter 的个数,再看一眼 Hinge 那三行转发的目标成员名,装配关系就摆在那里了,不需要打开私有成员。

原语可复用这条原理在这里可以直接读到:每个原语只对付一个自由度,约束层要做的事只是把几个原语的读数拼成一份对人友好的接口。其中:GetTotalLambda() 返回的是本步内该原语累计的冲量,量纲是牛秒,这个口径在 §七 还会再出现一次,并且是一个容易踩的量纲陷阱。

GetTotalLambda* 的转发实现原文
图 8-17GetTotalLambda* 的转发实现原文:原语侧各暴露一个累计冲量,约束侧每个 getter 都转发给内部某个原语,这就是「装配」的机器可读证据@ Jolt Physics v5.6.0 源码原文截取(MIT)

5.2 一个 Hinge 拆开看

OK,拿最常见的铰链(hinge)把装配关系拆到零件级。一个 Hinge 由四块组成,每一块都对应一个可读的累计冲量。

一个 Hinge 由哪几个原语组成
图 8-18一个 Hinge 由哪几个原语组成:点约束 + 旋转约束 + 旋转限制 + 马达四块,每块对应一个可读的累计冲量@ 本系列绘制

四块分别是 mPointConstraintPart(点,锁住两体的相对位置)、mRotationConstraintPart(铰链轴旋转,锁住两根轴的相对姿态)、mRotationLimitsConstraintPart(角度限位,管开合范围)、mMotorConstraintPart(马达,主动驱动)。上一节那三个 GetTotalLambda* 与它们一一对应,读起来就是「这条铰链此刻被哪几股力撑着」。点那块承担平移,旋转与限位那块承担转动,马达那块只在启用时才出力,四块的数学形式都来自 §3 那批原语,Hinge 自己只负责把它们按几何关系接好。

把这四块与 §3 的分组对上,会发现一个 Hinge 同时用到了位置类与旋转类两组的零件。位置类贡献 mPointConstraintPart,旋转类贡献 mRotationConstraintPart 与 mRotationLimitsConstraintPart。马达那块则跨组,它在位置或旋转方向上都能工作,取决于铰链配置把马达装在哪个自由度上。这也是「一个具体约束横跨多组原语」的第一个实例,SixDOF 会把这条路走到极端。

5.3 一条横向规律:类的复杂度 = 装配数

好了,把 13 类约束都按装配的原语个数排一遍,一条规律浮出来:约束的复杂度直接等于它装配的原语数。

装配原语数 约束
1 个 Point、Distance、Gear
2 到 3 个 Hinge、Slider、Cone、Pulley
6 个以上 SixDOF

一条约束装的原语越多,它能表达的自由度约束就越细,暴露的 GetTotalLambda* 也越多,代码与文档的体量跟着上去。装得最多的是 SixDOF,每个轴一个原语,加起来六个以上,而且它还能退化成其它约束。这也解释了 SixDOFConstraintSettings 的字段为什么是一串数组:mLimitMin[6]、mLimitMax[6]、mLimitsSpringSettings[6]、mMotorSettings[6],六份参数一字排开,与六个原语一一对应。到这里,§3 开头那句「约束只是把原语装配起来」就落到了具体的数字上。

这条规律也能当工具用:先数一个约束暴露了几个 GetTotalLambda*,就能判断它内部装了几块、复杂度落在哪一档。约束家族的类数只有 13,靠的就是这 14 个原语反复组合,而非为每种行为再添一个类。

六、优先级与迭代:谁先被解、解几遍

零件与装配看完了,最后看调度。同一批约束在一步里被求解,谁先谁后、各解几遍,是两条独立的旋钮。

6.1 「约束优先级」是真的

OK,先说一条被社区说法带偏的事。检索里出现过「Jolt 官方文档里没有约束优先级 API,等价机制只有逐约束覆盖迭代次数」的讲法。这条不成立。ConstraintSettings::mConstraintPriority 定义在 Constraint.h:82,约束侧另有一份 mConstraintPriority 在 :228,两者的 getter 与 setter 在 :138–139,注释原文是 Higher numbers are more likely to be solved correctly。定义清晰,还有 getter 可以读回,谈不上「没有」。

本机把它跑成了读数:设成 7 读回 7,两个迭代覆盖设成 30 与 5 也原样读回,连 GetConstraintSettings() 带回来的 settings 里同样是 7 / 30 / 5。另外把默认值也读了出来:优先级默认 0、mEnabled 默认 1。数字对得上,说明这三个旋钮既被存住也被完整取出。

这条读数把社区说法推翻得比较彻底。一句「没有约束优先级 API」的判断,查一下 Constraint.h 就能证伪。默认值为 0 意味着不设优先级时所有约束平起平坐,mEnabled 默认 1 意味着新约束默认参与求解,构造后不改动任何一位,行为就是「开启且最低优先级」。

6.2 排序发生在哪两处

好了,优先级被存住了,再看它在哪一步生效。排序的实体在 ConstraintManager.cpp:103–116 的 sSortConstraints,:111–114 那段是排序本体:先按 GetConstraintPriority() 升序,相同的再按 mConstraintIndex 兜底。升序加索引兜底,合起来是一个稳定排序(stable sort),相同的优先级不会因为排序本身而互相换位。

第二处发生在 PhysicsSystem.cpp:1503,它在求解循环里再次调用 sSortConstraints,而这个调用挂在一个条件上:mPhysicsSettings.mDeterministicSimulation 为真(:1500)。把两处并排读,结论是:优先级排序只在确定性模拟(deterministic simulation)开启时才在求解循环里再生效;不开的时候,排序仍然发生在建岛阶段那一次。这条是「优先级到底什么时候管用」的准确落点,比笼统地说「支持优先级」有用得多。

「稳定排序」这个限定词值得单独说。相同优先级的约束之间必须保持某种确定顺序,否则同一份场景在不同运行里可能解出不同结果。mConstraintIndex 兜底正是为了这个:它给每条约束一个稳定序号,优先级相等时按序号排,顺序就唯一了。这也解释了为什么确定性模拟开启时要在求解循环里再排一次:求解循环处理的是活跃约束的子集,子集顺序可能与建岛时的全集顺序不一致,重排一次才能保证子集内部同样稳定。不开确定性模拟时,省下这一次排序换取速度,代价是顺序不再有保证。

优先级排序的实现原文
图 8-19优先级排序的实现原文:升序 + 索引兜底的稳定排序(ConstraintManager.cpp:103–116),以及它在确定性模拟开启时才在求解循环里再生效(PhysicsSystem.cpp:1499–1507)@ Jolt Physics v5.6.0 源码原文截取(MIT)

6.3 两个迭代覆盖与「上限不等于开销」

说白了,最后一个旋钮是迭代次数。两个覆盖字段定义在 ConstraintSettings::mNumVelocityStepsOverride(:85)与 mNumPositionStepsOverride(:88)。它们的取法是一条分组规则。

其中:字段值为 0 表示改用全局值,全局默认是 mNumVelocitySteps = 10、mNumPositionSteps = 2(PhysicsSettings.h:84/87);字段值非 0 时,取该岛内所有接触与约束的最大值。同一条约束被放进不同的岛,实际用的迭代数可能不同,这一点在做性能对照时要先想清楚。

把这条规则与 §4.3 的提前退出合起来,就得到一句反直觉的话:迭代上限不决定开销。本机在 N = 200 个 Hinge 的场景里把速度迭代从 2 提到 30,总耗时是 40.03 / 39.32 / 38.97 ms,没有趋势;而把位置迭代从 2 提到 8,总耗时从 39.32 ms 变成 100.53 ms。同样的上限提升,一个几乎不动,一个涨了一倍半。原因回到那个 break:速度侧在这组场景里两步内就收敛,上限提到 30 等于白提;位置侧则真的一直算到八步。

优先级与两个迭代覆盖的作用位置
图 8-20优先级与两个迭代覆盖的作用位置:谁决定顺序、谁决定迭代上限,以及「按需提前退出」让上限不等于实际开销@ 本系列绘制

把 §6 的两条旋钮合起来看,调度这一层给出的信息量很有限:优先级决定顺序,覆盖决定上限,而上限还要被 break 打折。真正决定数值结果的,仍是 §3 那 14 个原语里的有效质量与偏置,以及 §4 那条四个纯虚串起来的求解流程。调度只是让同样的求解动作按不同次序、不同遍数发生。下一节换到可断裂约束,看当累计冲量越过一个阈值时,这套流程会怎样被主动关掉一段。

七、可断裂约束:官方的半页与一个量纲陷阱

前面六节把约束的家族、原语、生命周期、装配与优先级都过了一遍,还剩一个角落没翻到:约束在被解崩之前,能不能自己断掉。官方把这个能力叫可断裂约束(breakable constraint),写得很短,短到只有半页。这一节把它拆开,再指出一个量纲(dimension)上的坑。

7.1 官方只写了半页

说白了,Docs/Architecture.md 里的 ## Breakable Constraints 一节只占了 555 至 558 行。四行讲了三件事:每步检查累计冲量,超阈值就把约束禁用,并补一句「被禁用的约束不再影响模拟,但仍然付处理开销」。末尾那半个分句容易被漏读,SetEnabled(false) 关掉的是它对模拟的作用,关不掉它在 ConstraintManager 里占的那个位子,排序、建岛、遍历都还会走到它。

那到底该读哪个量,源码把答案钉在 Constraint.h 第 149 至 150 行的注释里:see e.g. PointConstraint::GetTotalLambdaPosition。翻到 PointConstraint.h 第 80 行的 GetTotalLambdaPosition(),它转发给内部的 mPointConstraintPart.GetTotalLambda(),读的就是这一处的累计冲量。

官方可断裂约束一节原文
图 8-21官方「Breakable Constraints」一节原文(第 555 至 558 行):用累计冲量判阈值后禁用约束,并提示「被禁用的约束仍付一部分处理开销」@ Jolt Physics v5.6.0 官方文档 Docs/Architecture.md 原文截取(MIT)

官方这半页给出的是一只旋钮,没给出刻度。它没写这颗冲量是牛顿秒还是牛顿秒乘以步长,也没写它跨不跨步。下一小节补的就是刻度,这是本机在实验里先踩坑、再回头读源码才钉住的一处。

7.2 量纲陷阱:GetTotalLambda 是「本步冲量」

OK,把旋钮接到表上看。本机做了一个最小场景:两体等质量,外力 F 只施加在 b 上,约束承担一半的力。约束每一步要提供的冲量,写成式子就是

$$\lambda = \frac{F}{2} \cdot dt$$

其中:$\lambda$ 是这一步里约束累计的冲量,$F$ 是施加在 b 上的外力,$dt$ 是时间步。取 F = 60 N、dt = 1/60 s,右边算出来是 0.5。本机读回 λ = 0.4996 N·s,与预测的 0.5 只差千分之八。

这条读数钉死了一件事:GetTotalLambda* 是本步内各次求解迭代累计的冲量,单位是 N·s/步。它每一步开头都会被清零重算,不跨步累积。把它读成「跨步的总冲量」,再配一个「总冲量阈值」去比,那只会永远比不到。本机第一版实验就是这么写的,200 步里一次都没破,当时还以为是阈值设太大。

量纲陷阱与力上限判据
图 8-22量纲陷阱与力上限判据:GetTotalLambda* 读的是本步冲量(N·s/步),所以阈值 τ 除以时间步才等价于力的上限 F_max = 2τ/dt@ 本系列绘制

7.3 力上限判据与分界(本机)

好了,从 λ = (F/2)·dt 往前推一步。给定阈值 τ,它等价于一个力的上限:

$$\tau \iff F_{\max} = \frac{2\tau}{dt}$$

其中:$\tau$ 是设定的冲量阈值,$dt$ 是时间步,那个 2 来自「两体等质量时约束只承担一半的力」。

把这条推论与实测分界对一遍。阈值 1.0 N·s,预测 F_max = 2 × 1.0 × 60 = 120 N;本机细扫得到 120 N 不破(λ 0.9992,比阈值低 0.08%),125 N 破(λ 1.0408,第 0 步就破)。阈值 2.0 N·s,预测 F_max = 240 N;实测 240 N 不破(λ 1.9984),260 N 破(λ 2.1649)。两档阈值、两组分界,预测与实测一一对上:

阈值 τ (N·s) 外力 F (N) 本步冲量 λ (N·s) 是否破 破的步号
1.0 60 0.4996 不破 未破
1.0 100 0.8326 不破 未破
1.0 110 0.9159 不破 未破
1.0 120 0.9992 不破 未破
1.0 125 1.0408 破 第 0 步
1.0 130 1.0824 破 第 0 步
1.0 140 1.1657 破 第 0 步
1.0 240 1.9983 破 第 0 步
2.0 120 0.9992 不破 未破
2.0 200 1.6653 不破 未破
2.0 230 1.9151 不破 未破
2.0 240 1.9984 不破 未破
2.0 260 2.1649 破 第 0 步

那个 0.08% 的差值得单独说。它既不是浮点噪声,也不是实验误差,它是求解器在有限迭代次数下留下的残差(residual)。迭代解不是精确解,约束还在承力,只是没能把这一步的力完全消掉,λ 就停在阈值下方一点点。读这张表时要把它读成残差,不然容易把 120 N 与 125 N 之间那条分界当成一条干净的直线。换成可运行的代码,判据就是这个形状:

//-----------------------------------【可断裂约束:本步冲量判据】---------------------------------------
// 描述:每步读一次约束累计的本步冲量,超阈值就把约束禁用;阈值与力的上限等价
//------------------------------------------------------------------------------------------
//【1】读本步冲量:单位是 N·s/步,不跨步累积(§7.2 实测 λ ≈ (F/2)·dt)
//     两体等质量时,约束只承担一半的力,所以 F = 60 N 读回 0.4996
float lambda = point->GetTotalLambdaPosition();
//【2】阈值判据:τ 与时间步决定一个力的上限,F_max = 2τ/dt
//     τ = 1.0 N·s、dt = 1/60 s 时分界落在 120 N 与 125 N 之间
point->SetEnabled(lambda <= threshold);

下面对这段判据特别说明两点。第【1】条读的是本步冲量,量纲在前面已经钉住;第【2】条把阈值除以时间步,换算成一个力的上限,好处是同一个阈值在不同帧率下都能解释成力,换引擎、换步长时不必重新标定,只要把 dt 一起带进去算。到这里,可断裂约束这条支线就闭环了:机制在官方那四行里,刻度在本机读数里,分界在两个阈值上量了出来。它随后把话题交给约束的求解开销,也就是下面四组读数要回答的事。

八、本机实测:四组读数

前面几节的材料来自源码与官方文档,这一节换一条通道:把 13 类约束真的放进物理世界跑起来,看它们有多硬、值多少钱、会不会崩。方法是自写基准程序 evidence/bench_constraint.cpp,只用 Jolt 公开 API;数据由脚本现场解析运行输出生成,不写死数字。

8.1 实验设计与四条纪律

说白了,编译口径先说清:新建构建目录 Build\VS2022_CL_ep08,不加任何开关。程序只用公开 API,与 ep06(往源码里插计数器)、ep07(打开官方 instrumentation)相比,这是证据形态的第三档:零开关、零补丁,任何人拉一份官方代码就能复跑。前两章分别动过源码、动过编译选项,这一章把两者都收掉,只留公开接口这一条通道,代价是有些内部量读不到,好处是读数与源码树解耦。换台机器、换份官方快照,这套读数照样能重跑,这一点比多读两个内部量更值钱。

四条纪律都在实验里踩出来过,值得单独记:

  1. 一律禁止睡眠(mAllowSleeping = false)。体睡着之后约束根本不解,稳态量到的是「什么都没做」。
  2. 弹簧与马达实验一律 SetGravity(0)。这两组要量的是约束本身的收敛,掺进重力只会把两件事混在一张表里。
  3. 每轮只放一种约束。避免建岛与排序把两类约束混进同一张表,读数失去对照意义。
  4. 必须报告两个全局迭代数(mNumVelocitySteps / mNumPositionSteps)。约束的读数强烈依赖它们,不给就是不可复现的读数。

一条口径必须写明:整组实验把碰撞全部关掉(PairRule 恒 false)。所以下面所有读数都只来自约束(外加重力),不能读成「物理 step 的耗时」。计时用 0 个 worker 线程的 job system,作业内联执行,避开线程调度噪声。基准程序的头部就是这些口径的落地:

//-----------------------------------【E0-E6 基准:实验口径】---------------------------------------
// 描述:只用公开 API,零开关、零补丁;所有实验关碰撞,弹簧与马达实验再关重力
//------------------------------------------------------------------------------------------
//【1】四条纪律之一:一律禁止睡眠,否则睡着之后约束根本不解
physics.SetAllowSleeping(false);
//【2】四条纪律之二:弹簧与马达实验把所有重力去掉,读数只来自约束
physics.SetGravity(Vec3::sZero());
//【3】四条纪律之四:每次运行都必须把两个全局迭代数一起打印,否则不可复现
printf("velocity_steps=%d,position_steps=%d,baumgarte=%.2f\n",
       settings.mNumVelocitySteps, settings.mNumPositionSteps, settings.mBaumgarte);

下面对第【3】条特别说明。正式档的口径读数是 velocity_steps=10,position_steps=2,baumgarte=0.20,下面每组实验都按这一组迭代数跑,改动的地方会单独标出来。附一条硬事实表。13 类约束里,除了载具,12 类都暴露了 GetTotalLambda*:

约束 GetTotalLambda* 出处(行号)
Fixed FixedConstraint.h:76/77
Point PointConstraint.h:80
Hinge HingeConstraint.h:143–146
Slider SliderConstraint.h:126–129
Distance DistanceConstraint.h:86
Cone ConeConstraint.h:102/103
SwingTwist SwingTwistConstraint.h:152–156
SixDOF SixDOFConstraint.h:196–199
Path PathConstraint.h:133–137
RackAndPinion RackAndPinionConstraint.h:85
Gear GearConstraint.h:83
Pulley PulleyConstraint.h:95

这张表就是 §5 那条结论的全量清单:约束由原语装配,而装配的证据逐个写在 getter 上。载具那一类不在表里:它的封装更厚,内部把若干子约束收进 VehicleConstraint,不把累计冲量直接暴露在外层。下面四组读数分别量弹簧、马达、固定链与数量阶梯,量纲(dimension)与迭代数贯穿其中。

三档证据形态放在一起看,边界很清楚:ep06 为了数 GJK 的四类出口往源码里插了计数器,证据最细但源码树被改动;ep07 只打开官方自带的 instrumentation 开关,源码干净但要重新编译;这一章连开关都不加,只用公开接口与外部可观测量。三档图的取舍是同一条:读得到多少内部量,换源码树有多干净。

三档证据形态对照
图 8-23三档证据形态对照:ep06 往源码插计数器、ep07 打开官方 instrumentation、本篇零开关零补丁,读到的内部量递减而源码树逐档变干净@ 本系列绘制

8.2 弹簧量纲(E1 / E3)

OK,先量弹簧。E1 的场景是质量 1 kg、目标距离 1.000 m、初始拉伸 0.200 m,把四种配置各跑一遍:

配置 参数 收敛步数 超调 稳态误差
硬限制 freq 0 6 0.00% 0.0002 mm
频率 + 阻尼 2 Hz / 0.7 16 0.58% 0.0000 mm
质量归一刚度 100 / 10 33 2.40% 0.0002 mm
纯刚度(1 kg) 100 / 10 33 2.40% 0.0002 mm

硬限制 6 步到位、零超调,这个结果符合频率 0 的语义(§2.2 讲过,频率 ≤ 0 时约束退化成硬限制)。有意思的是 2 Hz 欠阻尼要 16 步、超调 0.58%,比硬限制慢。软限制更慢,谈不上更稳。换个角度看,硬限制把这一步的偏差一次拉平,代价是力可能很尖;软弹簧把力摊薄,代价是多走十步。这两种取舍在官方文档里没有摆在一起,这里用步数把它们量了出来。单看「2 Hz 用 16 步」这一格,很容易误以为它比硬限制差;配合下一节的跨质量读数才看得出,它用十步换来了一个不随质量漂移的特性。

这组实验的原理是把「约束的开销」从整步里拆出来单独记账:碰撞全程关掉,场景里只剩约束与外加重力,于是每个读数都只归约束。其中:每约束步的耗时按 $\text{每约束步} = \dfrac{\text{总耗时}}{\text{步数} \times \text{约束数}}$ 折算,这样不同规模之间才能横向比。

本机实测的弹簧四配置对照
图 8-24本机实测的弹簧四配置对照:硬限制 6 步到位、2 Hz 欠阻尼 16 步超调 0.58%,质量归一刚度与纯刚度在 1 kg 下同为 33 步@ 本机基准实测(原创)
本机实测的跨质量不变量
图 8-25本机实测的跨质量不变量:频率与质量归一刚度在 1/10/100 kg 下读数完全一致,纯刚度在 100 kg 下 901 步未收敛、稳态误差 17.4344 mm@ 本机基准实测(原创)

E3 把同一件事放大到三种质量上,读数按模式分三行摆开:

模式(参数) 1 kg 10 kg 100 kg
频率 + 阻尼(2 Hz) 16 步 / 0.58% / 0.0000 mm 16 步 / 0.58% / 0.0000 mm 16 步 / 0.58% / 0.0000 mm
质量归一刚度(100) 33 步 / 2.40% / 0.0002 mm 33 步 / 2.40% / 0.0002 mm 33 步 / 2.40% / 0.0002 mm
纯刚度(100) 33 步 / 2.40% / 0.0002 mm 263 步 / 10.84% / 0.0222 mm 901 步 / 15.38% / 17.4344 mm

频率与质量归一刚度在 1 / 10 / 100 kg 下读数完全一致,三行一模一样。纯刚度随质量崩掉:33 → 263 → 901 步仍未收敛,超调 2.40% → 10.84% → 15.38%,稳态误差 0.0002 → 0.0222 → 17.4344 mm。这就把结论钉死了:频率是与质量无关的写法,刚度不是。同一个数字 100,在「质量归一刚度」下量纲是加速度/长度,在「纯刚度」下量纲是力/长度,两者不是同一件东西;换模式却不改数值,行为就完全对不上。

8.3 马达四态(E2)

好了,再看马达。场景是 Y 轴铰链,目标角 1.571 rad、目标角速度 1.000 rad/s,四态各跑一遍:

状态 弹簧频率 收敛步数 末角度 (rad) 末角速度 (rad/s) λ 峰值
Off 2.0 901 0.0000 0.0000 0.0000
Velocity 2.0 1 2.4336 1.0000 0.0267
Position 0.0 901 0.0000 0.0000 0.0000
Position 2.0 35 1.5708 0.0000 0.0754
PositionAndVelocity(目标 ω 0) 2.0 35 1.5708 0.0000 0.0754
PositionAndVelocity(目标 ω 1) 2.0 901 1.7466 0.0000 0.0838

表里有两处要圈出来。第一处是 Position 配频率 0:901 步之后角度仍是 0.0000,一步没动。这正好对上 §2.4 的源码那条闸门,位置态先判 HasStiffness(),而 HasStiffness() 就是 mFrequency > 0.0f。默认 SpringSettings() 的频率是 0,所以自己塞一个空 SpringSettings 进去,位置马达就是不动;同一种状态的另一行配 2 Hz,35 步就到 1.5708 rad。两行一对照,就能解释社区里「马达设了不动」的第一大来源。

第二处是 PositionAndVelocity 配目标速度 1 rad/s:位置目标要它停在 1.571,速度目标要它一直转,两个目标互相拉扯,901 步停在 1.7466,不收敛。而把目标速度设成 0,它就退化成纯位置态,读数与 35 步 / 1.5708 完全一致。这也说明第四态不是一种全新的求解方式,它是位置与速度两个目标同时进场。

马达累计冲量还有一个统一的读法:稳态时回到 0。约束不再需要出力,λ 就归零;峰值都出现在过渡段,速度态 0.0267、位置态 0.0754、双目标态 0.0838,末值一律 0。把这条读法与 §7.2 的量纲放在一起看就不奇怪了,λ 是本步冲量,稳态没有偏差要修,本步冲量自然是零。λ 的峰值读的是「这段过渡有多难」,末值读的是「还有没有活要干」,两者分工清楚。

本机实测的马达四态
图 8-26本机实测的马达四态:位置态配频率 0 完全不动(901 步仍为 0),配 2 Hz 则 35 步到位;双目标态两个目标互相拉扯@ 本机基准实测(原创)

8.4 固定约束链的下垂(E5)

说白了,固定约束到底有多硬,作者在讨论区里说过它是「极硬的弹簧」(#1077)。本机把这句话量成了数。

先看单约束基准:两体之间一个固定约束,重力拉着,测不到下垂(0.0000 mm)。它的 λ 精确等于 m·g·dt:1 kg → 0.1634 N·s,1000 kg → 163.3638 N·s。这条读数不能读成「刚性焊接」的证据,它是速度侧每步把相对速度消掉的结果,重力在一步内被完全抵掉,所以位移读数看不到。要让硬度显形,得让它承担一条链的累积误差。

换成竖直悬挂的固定约束链,链长 1 / 4 / 8 / 16 节,每节 10 kg:

链长(节) 下垂 (mm) 每节 (mm)
1 0.0000 0.0000
4 0.0153 0.0038
8 0.0496 0.0062
16 9.2373 0.5773

16 节那一行跳了一个量级。这是阈值效应,不是线性累积:前 8 节还在毫米的百分位上,到了 16 节,末端误差累积过了求解器一次能修回来的量,读数直接跳到 9.24 mm。§0.2 提过一句「16 节时下垂 9.24 mm」,说的就是这一格。链的搭法本身很短:

//-----------------------------------【E5:固定约束链的下垂】---------------------------------------
// 描述:把固定约束串成竖直悬挂的链,量末端相对基准的伸长;链越长误差越容易累积
//------------------------------------------------------------------------------------------
//【1】链长扫描:1 / 4 / 8 / 16 节,每节 10 kg,全局迭代 10 / 2
//     读数 0.0000 / 0.0153 / 0.0496 / 9.2373 mm,16 节是阈值效应
//【2】迭代扫描:8 节链,速度迭代 1 / 2 / 10 / 30
//     读数 0.5045 / 0.2537 / 0.0496 / 0.0343 mm
for (int n : {1, 4, 8, 16})
    chain->AddLink(link_settings);   // 每节一个 FixedConstraint,首节挂到静态锚点

下面对两条扫描特别说明:第【1】条改的是链长,看误差怎么沿链累积;第【2】条锁住链长只改迭代数,看求解器能修回多少。两条扫描共用同一套场景,读数才可对照。

本机实测的固定约束链下垂
图 8-27本机实测的固定约束链下垂:单约束测不到下垂,链长 16 节时跳到 9.2373 mm;提高速度迭代数把下垂从 0.5045 压到 0.0343 mm@ 本机基准实测(原创)

再看迭代数能不能压住它。8 节链,速度迭代 1 / 2 / 10 / 30 → 0.5045 / 0.2537 / 0.0496 / 0.0343 mm;位置迭代 1 / 2 / 8 / 32 → 0.5426 / 0.0496 / 0.0343 / 0.0343 mm。位置侧到 8 步就饱和,再加到 32 步不再改善,说明这个场景里位置修正的余地已经被前 8 步吃掉了。另外把每节质量从 1 kg 加到 100 kg,读数都是 0.0496 mm,与质量无关,这一点和 §8.2 的弹簧不变量是同一种气质。

到这里可以写一句结论:固定约束的硬度由求解器迭代数决定,这句话是可以量出来的。速度迭代从 1 提到 30,下垂从 0.50 mm 压到 0.034 mm,一个数量级的差别,比任何形容词都直接。

8.5 约束数量阶梯(E4)

OK,收尾这一组看数量。场景里只放同一类 Hinge,数量从 10 加到 400:

N(约束数) 每步 (ms) 每约束步 (微秒)
10 0.0067 0.668
50 0.0258 0.516
100 0.0491 0.491
200 0.1028 0.514
400 0.2088 0.522

每步耗时随 N 近似线性增长,折算到每约束步稳定在 0.5 微秒上下,没有隐藏的二次项。约束数量翻一倍,每步耗时翻一倍,这大概是全篇最干净的一档阶梯。有了它,场景里能挂多少约束就变成一道可以事先估算的题,按每约束步 0.5 微秒往外推即可。

本机实测的约束数量阶梯
图 8-28本机实测的约束数量阶梯:N 从 10 到 400 每步耗时近似线性(每约束步约 0.5 微秒);N = 200 时速度迭代上限 2→30 耗时几乎不变,位置迭代 2→8 却涨到 2.6 倍@ 本机基准实测(原创)

迭代侧更有意思。固定 N = 200,把速度迭代从 2 提到 30,总耗时 40.03 / 39.32 / 38.97 ms,三档之间看不出趋势,甚至有回落;而把位置迭代从 2 提到 8,总耗时从 39.32 跳到 100.53 ms,涨了一倍半。同样的 N、同样的机器,只换一个迭代数,一边纹丝不动、一边涨上去。

对照 §4.3 的源码结构就明白了:两个迭代循环都写着「这一轮没有再施加任何冲量就 break」。速度侧在这个场景里两步内就收敛完,上限提到 30 等于白提;位置侧则真的一直算到 8 步。提高迭代上限不等于提高开销,取决于循环会不会提前退出。这一条与 §8.4 的读数互相印证:位置迭代从 8 再加到 32,下垂不再改善,因为多余的迭代已经无事可做。

四组读数到这里讲完。它们共同指向一句话:约束的参数决定它稳不稳,约束的迭代数与数量决定它值多少钱,而这两件事都能落到可复算的数字上。

九、边界、演进与清单

9.1 本篇的边界

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

方向 本篇已给出 归谁
形状的几何谱系 沿用 ep07 的两张形状枚举与 SubShapeID 已闭环(ep07)
约束的表示 13 类内置 + 4 用户槽、14 个可复用原语、装配关系 已闭环
约束的参数 弹簧三模式与量纲、马达四态、优先级与两个迭代覆盖 已闭环
约束的生命周期 四纯虚与可选接口、一步里的四阶段 已闭环
约束的断裂 本步冲量判据与力上限 2τ/dt 已闭环
求解迭代的数学 只登记 sequential impulse 与暖启动的存在 第九篇
岛与并行、睡眠 只登记 BuildIslands / BuildIslandSplits 第十篇
CCD 与确定性 只登记「优先级只在确定性模拟里重排序」 第十一篇
角色与载具成品件 只把 VehicleConstraint 作为 13 类之一登记 第十二篇
柔体与发束 不涉及 第十三篇
约束的二进制序列化字节布局 只登记 SaveBinaryState 与 SaveState 后续专篇

再说一张「可复算的数」表,六条,每条给出实验编号与复算入口。复算入口那三样东西与前两章一致:官方源码、本篇的 evidence/bench_constraint.cpp、一台能编译 Jolt 的机器。

# 可复算的数 复算入口
1 13 类枚举与序号 0–12、用户槽 13–16;文档顺序 13 项里只有 5 项同位 E0 段
2 弹簧跨质量不变量:频率与质量归一刚度在 1/10/100 kg 下一致;纯刚度 901 步 / 17.4344 mm E3 段
3 位置马达配频率 0 完全不动(901 步仍 0.0000),配 2 Hz 则 35 步到 1.5708 E2 段
4 固定链下垂:链长 1/4/8/16 → 0.0000 / 0.0153 / 0.0496 / 9.2373 mm E5 段
5 每约束步约 0.5 微秒,N 从 10 到 400 近似线性 E4 段
6 力上限分界 F_max = 2τ/dt;1.0 N·s 下 120 N 不破、125 N 破 E6 段

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

9.2 演进:与约束有关的版本变更

说白了,把 Docs/ReleaseNotes.md 与 Docs/APIChanges.md 里与约束有关的条目按版本排开,能看出一条清楚的脉络:早期在补类型(三类传动约束),中期在补排序与参数(优先级、SpringSettings),近期在补细粒度控制(每 body 迭代数覆写、多线程装配)。

版本 条目 出处
v1.0.0 Rack and pinion / gear / pulley 三类约束加入 ReleaseNotes.md:462
v1.0.0 多数约束支持 local space ReleaseNotes.md:465
v2.0.0 新增额外 ragdoll 约束;约束优先级加入 ReleaseNotes.md:396/398、APIChanges.md:89
v2.0.0 Vehicle 与 Distance / Slider / MotorSettings 改用新的 SpringSettings(频率 + 阻尼) APIChanges.md:96/97
v2.0.0 Constraint::GetType 改名为 GetSubType APIChanges.md:132
v3.0.0 Hinge 极限夹取修复;Distance 的 min/max 夹取;SixDOF 的 RestoreState 马达修复 ReleaseNotes.md:349/360/370
v5.0.0 金字塔 swing 限、非对称 twist 限、每 body 迭代数覆写、Path 的 fraction hint、SetupVelocityConstraints 多线程 ReleaseNotes.md:309–317
v5.1.0 Hinge 极限 ±π 修复 ReleaseNotes.md:172
v5.5.0 RagdollSettings::CalculateConstraintPriorities ReleaseNotes.md:58
v5.6.0 未发布 glTF KHR_physics_rigid_bodies 约束马达;ESpringMode::MassNormalizedStiffnessAndDamping;EMotorState::PositionAndVelocity ReleaseNotes.md:24
v5.6.0 未发布 约束求解器 ping-pong 修复 ReleaseNotes.md:34
与约束有关的版本演进
图 8-29与约束有关的版本演进:v1.0.0 三类传动约束、v2.0.0 约束优先级、v5.0.0 每 body 迭代数覆写,到 v5.6.0 未发布的第三模式与第四态@ 本系列绘制

这条时间线里最值得记住的是末尾两行。本篇 §2 讲的「第三种弹簧模式」与「第四种马达状态」,都是 v5.6.0 未发布区里的新东西,而它们的实现(SpringPart.h:123、MotorSettings.h 的四态)已经躺在源码里。源码快照领先于已发布的 release notes,这一点在本机逐条核行号的时候反复出现。v2.0.0 那次「优先级加入」对应 §6.1 那处被源码推翻的社区说法,v5.0.0 的「每 body 迭代数覆写」对应 §6.3 的两个迭代覆盖,都能在这条线上找到来处。

9.3 两张横向对照

好了,先把与 ep07 的对照说一句。上一章的形状枚举讲的是「几何的分类」,这一章的约束枚举讲的是「关系的分类」;两张枚举都是追加式化石层,新类型往尾部插、用户槽占末尾。差别在一处:形状那一层有子类型名表,约束这一层没有,名字只能由调用方给。读的时候记住这一点,序列化只看序号、不看名字。

再把四家引擎并排放:

引擎 约束类数 可复用求解原语
Jolt 13(+4 用户槽) 14 个独立命名的 ConstraintPart
PhysX 6(fixed / distance / spherical / revolute / prismatic / D6) 统一 PxConstraint 插件
Bullet btTypedConstraint 一族 统一基类,btGeneric6Dof 可退化成多种约束
Box2D 2.x 12 项 / 3.x 9 项(移除 pulley / gear / friction) b2Joint 三个纯虚解算阶段
两张横向对照
图 8-30两张横向对照:与 ep07 形状枚举的「化石层」对照,以及四家引擎(Jolt / PhysX / Bullet / Box2D)的家族对照@ 本系列绘制

结论落在一句上:Jolt 是这几家里唯一把求解原语做成一等公民目录的。PhysX 与 Bullet 把原语嵌在约束类里或收进一个统一基类,Box2D 只留三个纯虚阶段;只有 Jolt 有 ConstraintPart/ 这个 14 个文件的目录,每个原语还配着自己的 CalculateConstraintProperties* / WarmStart / SolveVelocityConstraint / SolvePositionConstraint。另一头,Box2D 3.x 做了一次反向减法,把 pulley / gear / friction 三类移出,说明「类数多」本身不构成优劣,要看它换来了什么。

9.4 资源清单

官方文档

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

社区与第三方

竞品

本机一手产物

  • evidence/bench_constraint.cpp(E0–E6 七组实验,含四条纪律的源码级注释)、evidence/build_bench_constraint.ps1(复现脚本)、evidence/bench_constraint_run_20261004.txt(原始输出)

本清单的原理与前两章一致:每条结论都能指到它那一层的原始出处。三层证据的分工写成式子即 $\text{结论} = \text{官方层} + \text{源码层} + \text{实测层}$。凡是官方文档与源码里找不到对应命名的说法,本清单不收。

9.5 下一篇预告

下一篇往「求解」那一层走:接触求解的数学。约束与接触共享同一个求解循环,前面几篇给出了它们长什么样、由什么装配、值多少钱,接下来该回答它们是怎么算出来的:sequential impulse、暖启动(warm starting)、Baumgarte 项,一个都不绕。本篇里那些「提高位置迭代才压得下下垂」「硬限制一步到位」的现象,都会在那一篇里找到数学侧的落点。

下一篇:第九篇 · 接触求解的数学:sequential impulse、暖启动与 Baumgarte。

With Best Wishes.