站点链接:技术博客页 | 公众号:溯光的图形笔记
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 |
| 求解 | 进入求解前与约束合并,共用同一套迭代与退出条件 | 同上 |
把三者并排看,各自的输入与输出很清楚。宽相的输入是两个体的包围盒,输出是候选对;窄相的输入是候选对里的两个形状,输出是接触流形;约束的输入是用户给的设置项与两个体当前的变换,输出是修正后续帧速度与位置的冲量。前两层的对象是几何,第三层的对象是关系。这也解释了一个常见的困惑:体与体之间没有任何接触,却依然被推着走,那多半是有一条约束在起作用。
0.2 一个实物:机械臂的关节链
一条机械臂就是一条约束链。每个关节约束掉若干个自由度(degree of freedom),从底座的固定端一路传到末端执行器。单看每个关节都不复杂,底座与第一节之间可能只是一个铰链,两节之间可能只是一个锥形限位,可链一长,误差就愿意在末端聚起来。
说白了,原因落在求解方式上。每一步的位置修正都是一次迭代近似,拿到的是近似解;链上的每一节都要把上一节留下来的残差接住,节数越多,接住的残差越多,末端的偏差就越容易被放大。这条观察在本机是可以量出来的:把若干节固定约束竖直串成一条链,节数从 1 加到 16,末端的下垂从测不出来一路跳到 9.24 mm,这个数在 §八 给出完整读数。
Robot Arm (NIH BioArt 457).png(Public domain,Courtesy of NIAID Ryan Kissinger)换个角度说这件事。单个约束的硬度在读数上很难分辨,因为约束在一步之内就把相对速度消掉了,测量工具看不到偏差。链把这件事放大了,它把每个关节的残差按次序累加,于是「约束的硬度由求解器决定」这句话从一句经验变成了一个可以复算的量。工程上要记住的落点也在这里:关节链越长,越不能指望默认迭代数下的末端精度。
0.3 本篇的三条主线与边界
三条主线各自的落点写清。
- 谱系的代价:13 类是追加式演进的产物,文档的列举顺序与枚举的声明顺序只有 5 项同位。读文档记住的是分类,读枚举记住的才是序号,而序列化只认后者。落点 §一。
- 原语的代价:求解被抽成 14 个可复用原语,约束只是把它们装配起来。
GetTotalLambda*的转发实现就是这层装配的机器可读证据。落点 §三、§五。 - 量纲的代价:同一个弹簧旋钮在三种模式下量纲不同,频率是与质量无关的写法,刚度不是;位置马达配频率 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 项,而序列化只认后一张的序号。
这里还要记一个查无落点。约束这一层没有子类型名表,形状那一层有 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,序列化时写进去的是序号而不是名字,一旦重排,历史存档在读取时会把序号对应到错误的类型上。这类错位不会报错,只会在某次加载后给出一个行为不对的约束。反过来,如果序列化写的是名字,重排就安全了,代价是每条记录的字节数变大、字符串比较也慢。引擎选了序号,于是顺序被钉死。
文档的列举顺序与枚举的声明顺序并不一致,这一点值得单独摆一张对照。
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 |
加速度/长度 | 折算掉了质量 |
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 夹住,力矩侧同理。
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 那条公式里刚度项与阻尼项各自的作用。
Docs/Images/MotorFrequency.jpg(MIT,Jorrit Rouwe)
Docs/Images/MotorDamping.jpg(MIT,Jorrit Rouwe)两张原图的长边都是 1000,不足 1280,本篇按原理小图使用,不放大、不裁切。它们与 §2.2 那组本机读数指向同一结论:弹簧的参数决定的是收敛速度与超调量,与「有没有作用」无关。
2.6 一个实物对照:门铰链
OK,最后把这个旋钮落回一个实物。门铰链把门板的姿态限制在一条轴上,只允许它绕着一根竖直轴转;与此同时,门还常常带一个「开到某个角度就停」的限位。限制在一个轴上,是旋转副本身;停在某个角度,是限位。
HingeConstraint 的 mLimitsMin / mLimitsMax 就是这道限位的两种写法,默认取 −π 到 π,等价于没有限位(一整圈都能转)。工程里给门做限位,是把这两个值收窄到实际需要的角度区间;不给限位,是两个值保持默认。门铰链这个实物说明,限位在现实里本来就存在,只是被搬进了设置项。
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 里那句注释为何能一字不差复制到十个文件。
这套目录的存在原理是「算一次、用多次」:有效质量与偏置在装配阶段算好,之后的每一次速度迭代只做乘加,不再重复求逆。其中:每个原语自己维护一份有效质量与一份累计冲量,约束本体不持有这两样东西,这正是下一节要展开的那条规律。
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 被前两组大量调用,它把「硬限制」与「软约束」统一成同一套参数入口。
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 打开时才存在。
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 决定调用时机。
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() 返回的是本步内该原语累计的冲量,量纲是牛秒,这个口径在 §七 还会再出现一次,并且是一个容易踩的量纲陷阱。
5.2 一个 Hinge 拆开看
OK,拿最常见的铰链(hinge)把装配关系拆到零件级。一个 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 兜底正是为了这个:它给每条约束一个稳定序号,优先级相等时按序号排,顺序就唯一了。这也解释了为什么确定性模拟开启时要在求解循环里再排一次:求解循环处理的是活跃约束的子集,子集顺序可能与建岛时的全集顺序不一致,重排一次才能保证子集内部同样稳定。不开确定性模拟时,省下这一次排序换取速度,代价是顺序不再有保证。
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 等于白提;位置侧则真的一直算到八步。
把 §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(),读的就是这一处的累计冲量。
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 步里一次都没破,当时还以为是阈值设太大。
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)相比,这是证据形态的第三档:零开关、零补丁,任何人拉一份官方代码就能复跑。前两章分别动过源码、动过编译选项,这一章把两者都收掉,只留公开接口这一条通道,代价是有些内部量读不到,好处是读数与源码树解耦。换台机器、换份官方快照,这套读数照样能重跑,这一点比多读两个内部量更值钱。
四条纪律都在实验里踩出来过,值得单独记:
- 一律禁止睡眠(
mAllowSleeping = false)。体睡着之后约束根本不解,稳态量到的是「什么都没做」。 - 弹簧与马达实验一律
SetGravity(0)。这两组要量的是约束本身的收敛,掺进重力只会把两件事混在一张表里。 - 每轮只放一种约束。避免建岛与排序把两类约束混进同一张表,读数失去对照意义。
- 必须报告两个全局迭代数(
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.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{约束数}}$ 折算,这样不同规模之间才能横向比。
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.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 节链,速度迭代 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 微秒往外推即可。
迭代侧更有意思。固定 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 |
这条时间线里最值得记住的是末尾两行。本篇 §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 三个纯虚解算阶段 |
结论落在一句上:Jolt 是这几家里唯一把求解原语做成一等公民目录的。PhysX 与 Bullet 把原语嵌在约束类里或收进一个统一基类,Box2D 只留三个纯虚阶段;只有 Jolt 有 ConstraintPart/ 这个 14 个文件的目录,每个原语还配着自己的 CalculateConstraintProperties* / WarmStart / SolveVelocityConstraint / SolvePositionConstraint。另一头,Box2D 3.x 做了一次反向减法,把 pulley / gear / friction 三类移出,说明「类数多」本身不构成优劣,要看它换来了什么。
9.4 资源清单
官方文档
Docs/Architecture.md:# Constraints(465–492)、## Constraint Motors(493–554)、## Breakable Constraints(555–558)、求解循环(934–1021);Catto GDC 2011 出处 539、GDC 2009 出处 731Docs/ReleaseNotes.md与Docs/APIChanges.md
官方源码(逐条核过行号)
Jolt/Physics/Constraints/Constraint.h: 两张枚举(26–30 / 33–54)、生命周期四纯虚(177–180)、mConstraintPriority(82 / 228)、可断裂注释(149–150)Jolt/Physics/Constraints/ConstraintManager.h与…/ConstraintManager.cpp:sSortConstraints(103–116)等五个静态回调Jolt/Physics/Constraints/ConstraintPart/下 14 个头文件(SpringPart.h的软约束出处 40、SpringPart.h:123)Jolt/Physics/Constraints/SpringSettings.h、…/MotorSettings.h、13 个具体约束头文件、Jolt/Physics/Vehicle/VehicleConstraint.h、Jolt/Physics/PhysicsSystem.cpp
社区与第三方
- jrouwe 讨论区 #1857 / #1077 / #2091 / #2138 / #1840 / #2074
- jolt-jni 约束文档、Godot《Using Jolt Physics》、ezEngine 的 Jolt 约束组件、gamebop 的
SixDOFConstraintSettings
竞品
本机一手产物
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.