Jolt Physics 3D · 第 七 篇

形状谱系:34 个形状类型与 32 位路径

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

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

高度场地形
题图高度场(height field):一张灰度图就能描述起伏的地面,Jolt 用 HeightFieldShape 表示它,而它的 GetVolume() 恒返回 0。形状谱系的一头是解析式形状(球、盒),另一头就是这种「几万个格子拼成的大几何」@ Wikimedia Commons Hand made terrain heightmap.png(CC0,Drummyfish)

上一篇把窄相拆完了:GJK 判相交、EPA 算穿透深度、面-面裁剪出接触流形。那一篇从头到尾都把形状当成一个支持函数提供者,好像它天生就存在。可形状本身是一大家子:球、盒、胶囊、圆柱、锥形胶囊、锥形圆柱、凸包、三角形、平面、空形状、三角网格、高度场,外加三种复合形状与三种装饰器。它们从最简单的解析几何一直到几万个三角形的网格,复杂度差着好几个量级,窄相的开销也就跟着差着量级。

更要紧的是另一件事。窄相算完之后,它会交出一组接触点和一个法线,可它还会附上一张回执:这一下到底打在了谁的哪一块上。对一个球来说这问题没有意义,对一个由两百块碎片拼成的复合形状来说,这个问题必须有答案。Jolt 的答案是 SubShapeID,一个 32 位的整数。

这一篇就拆这两件事:形状谱系(有哪些形状、它们各自有什么硬约束、怎么组织)与命中的编码(32 位怎么装下一条路径)。

先把这一层要回答的问题列出来。它有三问:有哪些形状(谱系与复杂度阶梯)、怎么组织(复合三支与装饰器)、怎么指出是哪一块(SubShapeID 的位编码)。外加一个容易被忽略的问题:这套编码的边界在哪,什么时候会撞墙。

系列文章写作风格说明

  • 术语双语:首次出现的核心术语写成「中文(English)」,后文可只用英文;
  • 三层分开说:官方文档怎么写、源码怎么实现、本机跑出来是什么,这三层在文中分开陈述,不混成一句;
  • 读数标口径:每一个数字都标来源与条件(版本、场景、规模、编译选项、日期);
  • 自跑优先:本专题的图以本机编译运行 Jolt 得到的真图与官方一手图为主,自绘为辅;
  • 不写没有落点的说法:凡是官方文档与源码里找不到对应命名的说法,文中会明确指出查无落点;
  • 本篇的知识地图:三条主线贯穿全篇,谱系的代价(复杂度是性能与约束的分层,谈不上学术分类)、 编码的代价(SubShapeID 是一条路径压进 32 位,不是序号)、 边界的代价(位宽是硬上限,撞墙时引擎会明确报错)。三条线各自的落点见 §0.4;
  • 本篇不涉及:约束家族与求解迭代(第八、九篇)、岛与睡眠(第十篇)、连续碰撞检测(第十一篇)、 形状的二进制序列化格式细节(Saving Shapes 一节只作索引)、自定义形状的实现教程 (Creating Custom Shapes 只登记注册机制)、软体与 GPU 那条路(Jolt 的 GPU 通路只服务发束头发, 本系列另有专篇)。

入口:几何输入与命中的回执

先把这个话题在整条链上的位置摆清。宽相(broad phase)回答「哪些体的包围盒重叠了」,窄相回答「碰上了没有、碰得有多深、接触面在哪」。这两层都有一个共同前提:体身上挂着一个形状。宽相算的是这个形状的包围盒,窄相要的是这个形状的支持函数、体积、质心、以及「打到哪一块」的分辨能力。

形状在碰撞链上的位置
图 7-1形状在碰撞链上的位置:静态几何与动态体各自挂一个 Shape,宽相只消费它的 AABB,窄相消费它的支持函数与体积,接触回调再从 SubShapeID 反查具体是哪一块子形状 @@ 本系列绘制

0.1 形状是「几何 + 元数据 + 能力」的三合一

OK,打开 Jolt/Physics/Collision/Shape/Shape.h,基类 Shape 一共声明了三十多个接口。把它们按用途分一下,会看出形状这个东西是三合一:

  • 几何:GetLocalBounds()、GetVolume()、GetCenterOfMass()、GetMassProperties()、GetInnerRadius();
  • 能力:CastRay()(射线打它)、CollidePoint()(点是否在它里面)、GetSupportingFace()(给窄相裁流形用)、CollectTransformedShapes()(把复合形状摊平);
  • 元数据:GetType() / GetSubType()(两个枚举,本篇的主角)、GetMaterial()、GetSubShapeUserData()、GetSubShapeIDBitsRecursive()。

这三十多个接口里,有十一个被标成纯虚(GetLocalBounds、GetSubShapeIDBitsRecursive、GetInnerRadius、GetMassProperties、GetMaterial、GetSurfaceNormal、GetSubmergedVolume、两处 CastRay、CollidePoint、CollideSoftBodyVertices、GetTrianglesStart/Next、GetStats、GetVolume)。剩下的是带默认实现的可选能力,比如 MustBeStatic() 默认返回 false(Shape.h:207),GetSupportingFace() 默认什么都不做(Shape.h:265)。

Shape.h 的基类接口清单
图 7-2Shape.h 的基类接口清单:纯虚项与带默认实现的可选项分成两列,看出一类形状必须实现什么、可以偷懒什么 @@ Jolt Physics v5.6.0 源码原文截取(MIT)
Shape 基类接口按用途分三组(几何 / 能力 / 元
图 7-3Shape 基类接口按用途分三组(几何 / 能力 / 元数据),并标注哪些是纯虚、哪些有默认实现,以及默认实现分别是什么 @@ 本系列绘制

这条清单本身就是一个设计声明:几何是必须的,能力可以不给。一个形状如果不实现 GetSupportingFace(),窄相在需要裁流形时就拿不到面,只能退化成单点接触。这条口子留给的是自定义形状。

十一个纯虚接口可以再分三层看。最底层是三件「必须报数」:GetLocalBounds()(包围盒,宽相要用)、GetInnerRadius()(内切球半径,窄相判断「再近也不可能接触」时要用)、GetVolume() / GetMassProperties()(质量属性,动力学要用)。这四件报不出来,这个形状就不可能在物理世界里活着。

中间一层是「查询能力」:CastRay()(两个重载,一条射线与一批射线)、CollidePoint()(点是否在内部)、GetTrianglesStart/Next()(把几何摊成三角形,给渲染或调试用)、GetSubmergedVolume()(浸入流体时的体积,浮力要用)。这些接口只在对应场景被调用,可一旦被调用就必须有答案,所以是纯虚。

最上面一层是「给窄相用的」:GetSurfaceNormal()(表面法线)与 GetSubShapeIDBitsRecursive()(位深)。前者决定接触法线,后者决定这个形状能不能被塞进复合。

剩下二十来个带默认实现的接口,大致是三类:可选的几何能力(GetSupportingFace()、GetCenterOfMass())、元数据(GetMaterial() 之外的 GetLeafShape()、GetSubShapeUserData()、GetUserData())、序列化与统计(SaveBinaryState()、GetStats()、Draw())。默认实现一律是「我不知道」或「我没有」:GetSupportingFace() 空实现,MustBeStatic() 返回 false,GetCenterOfMass() 返回零向量。

0.2 三段式创建:settings → Create() → result

OK,形状不直接 new,走的是「设置(settings)→ 构造(Create())→ 结果(ShapeResult)」三段式。这套流程在官方文档的 ### Creating Shapes 一节(Architecture.md 第 223 至 248 行)里有完整说明,它解决的是一个很实际的问题:构造会失败。

形状创建的三段式流程
图 7-4形状创建的三段式流程:ShapeSettings 收集参数,Create() 内部做合法性校验与几何构建(凸包可能报错、网格可能报错),失败时通过 ShapeResult 返回错误字符串而不是抛异常 @@ 本系列绘制

凸包点数不足、复合形状只有一个子形状、嵌套太深、缩放矩阵含零,这些情况都会让 Create() 返回一个无效结果,并且带一条人能读懂的报错原文。本机实测把这些报错逐条打了出来(见 §2.4、§3.4、§4.5),它们比文档更能说明边界在哪。

还有一条容易被忽略的工程设计:Create() 的结果会被缓存,官方给了 ClearCachedResult() 来清掉它(Architecture.md 第 248 行)。所以同一个 ShapeSettings 反复 Create() 不会反复构建几何。这对「一个设置生成一百个相同形状的体」的场景是必要的。

把这三段写成一个最小例子,参数一目了然:

// 【1】收集参数:所有参数都放在 settings 里,settings 不参与物理
BoxShapeSettings settings(Vec3(0.5f, 0.5f, 0.5f));   // 半长;凸半径不写就是 cDefaultConvexRadius
// 【2】构造:内部做校验与几何构建,失败不抛异常,而是返回带错误的 Result
ShapeSettings::ShapeResult result = settings.Create();
// 【3】取用:只有 IsValid() 为真时 Get() 才有效
if (!result.IsValid())
{
    Trace("构造失败:%s", result.GetError().c_str());   // 人能读懂的报错原文
    return;
}
RefConst<Shape> shape = result.Get();

其中:settings 是「意图」,Create() 是「构建」,ShapeResult 是「回执」。这条分工的原理很直白:物理世界需要一个已经建好的形状,而形状的构建可能失败,所以构建与使用必须分成两步,失败信息必须能带回来。

0.3 谁在消费 SubShapeID

SubShapeID 由窄相产生,可它真正的消费者在回调那一侧。官方文档 ### Sub Shape IDs 一节(Architecture.md 第 207 至 209 行)列了三个消费口:

  • GetLeafShape(sub_shape_id, out_remainder):把 ID 解成叶子的形状指针,剩下的位从 out_remainder 出来;
  • GetMaterial(sub_shape_id):拿到这个叶子用的物理材质;
  • GetSubShapeUserData(sub_shape_id):拿到这个叶子的用户数据。

这三个接口都定义在 Shape.h 上(第 246、249、268 行)。基类的默认实现很有意思:GetLeafShape 先解一层,再把剩下的位转发给内层继续解;GetSubShapeUserData 则直接返回自己的 mUserData,看起来什么都没做。可复合形状会把这两个都重写(CompoundShape.h:90),「叶子」这个概念正是靠这一层重写才立住的:基类默认实现服务的是叶子形状,复合形状的职责是把它一路传下去。

SubShapeID 的生产者与消费者
图 7-5SubShapeID 的生产者与消费者:生产端在窄相(碰撞、射线、形状扫掠三种查询都会给出 ID),消费端在接触回调、射线命中结果与哑元查询过滤器三处,中间靠 GetLeafShape / GetMaterial / GetSubShapeUserData 三个接口解引用 @@ 本系列绘制

0.4 本篇的三条主线与边界

OK,三条主线各自的落点写清:

  1. 谱系的代价:形状的复杂度不是学术分类。GetVolume() 分三档(解析式 / 数值 / 恒 0), 恒 0 的那一族正好是「非封闭实体」;「必须 static」是一条契约,谈不上建议。落点 §二。
  2. 编码的代价:SubShapeID 是一条路径压进 32 位,位从最低位起逐层压入,根级不占位, 没用到的高位一律补 1。落点 §五,本机把位串逐位打出来与预测比对。
  3. 边界的代价:位宽是硬上限,撞墙时 Create() 会吐出一条明确的报错; MutableCompoundShape 一改子形状,已有 ID 全部失效。落点 §六。

边界声明(本篇不管什么):本篇只管形状本身怎么被表示、怎么被组织、怎么被指名。 形状之间的算法(GJK / EPA / 裁剪)在上一章,约束与求解在下一章。 另外,SoftBodyShape 只作为枚举里的一个类型被登记(它属于 GPU 那条线的入口),本篇不展开。

一、两张表:12 个类型、34 个子类型

先看谱系被「冻」成的样子。Jolt 用两张枚举描述全部形状,一张粗、一张细。

1.1 EShapeType:12 项,按组织方式分

Shape.h 第 54 至 72 行定义了 EShapeType,取值从 0 起:

值 名称 含义
0 Convex 凸形状(球、盒、胶囊、圆柱、锥形胶囊、锥形圆柱、凸包、三角形)
1 Compound 复合形状(静态复合、可变复合)
2 Decorated 装饰器(缩放、旋转平移、质心偏移)
3 Mesh 三角网格
4 HeightField 高度场
5 SoftBody 软体(本篇只登记)
6–9 User1 – User4 引擎侧自定义类型
10 Plane 平面
11 Empty 空形状

这张表读起来有点意思:Plane 排在四个用户类型之后,Empty 排在最后。这是追加式演进的化石层,顺序反映的是加入时间,不是分类偏好;新类型只能往尾巴上加。平面和空形状显然比用户类型「基础」得多,可它们是后加进引擎的,于是只能排在后面。

Convex 这一档还值得多说一句:球、盒、三角形、胶囊、锥形胶囊、圆柱、凸包,七种形状共用同一个 EShapeType。原因在 ConvexShape.h 第 41 行的构造函数:

explicit ConvexShape(EShapeSubType inSubType) : Shape(EShapeType::Convex, inSubType) { }

所有凸形状的 GetType() 都是 Convex(ConvexShape.h 第 41 行),各自的区别只在 GetSubType() 里。这样分的好处是窄相可以只写一套凸-凸算法,七种形状共用同一批代码路径(靠支持函数区分);代价是这一层必须有子类型,否则统计表里「球对圆柱」和「盒对圆柱」就分不开了,也就是 1.3 节那条索引耦合的由来。

1.2 EShapeSubType:34 项,顺序同样是化石

OK,细表在 Shape.h 第 74 至 124 行,EShapeSubType 一共 34 项:

值段 名称 备注
0–6 Sphere / Box / Triangle / Capsule / TaperedCapsule / Cylinder / ConvexHull 七种基础凸形状
7–8 StaticCompound / MutableCompound 复合两支
9–11 RotatedTranslated / Scaled / OffsetCenterOfMass 三种装饰器
12–14 Mesh / HeightField / SoftBody 三种「大几何」
15–22 User1 – User8 引擎侧自定义子类型
23–30 UserConvex1 – UserConvex8 引擎侧自定义凸形状
31–33 Plane / TaperedCylinder / Empty 三个后加的

把两张表并起来看,能看出 Jolt 对「自定义形状」的开放策略:类型层面留了 4 个位子,子类型层面留了 8 个通用位子加 8 个专门给凸形状的位子。为什么要分两组?因为窄相里有一条快速通道只对凸形状有效,自定义凸形状需要单独一档才能在统计表里被单独记账。

这两组位子的用法在官方文档 ### Creating Custom Shapes 一节(Architecture.md 第 380 至 396 行)里有说明:EShapeType::User1 到 User4 配 EShapeSubType::User1 到 User8 负责「新形状」,UserConvex1 到 UserConvex8 负责「新的凸形状」;后者还必须额外注册到两张表里(sRegister 那一路),因为它要参与窄相的凸-凸快速通道。扩谱系的代价是接进两套查询分发,加一个枚举值远远不够。

顺带说一个与本篇主题直接相关的后果:自定义形状必须自己回答 GetSubShapeIDBitsRecursive()。如果它内部有子形状,这个值要按 §5.3 那条公式算对;算小了,子形状在复合里就会互相覆盖 ID,而这类错误不会报错,只会在某次射线命中时给出错误的叶子。

EShapeSubType 的 34 项按值段分组
图 7-6EShapeSubType 的 34 项按值段分组:基础凸形状 0-6、复合 7-8、装饰器 9-11、大几何 12-14、用户类型 15-22、用户凸形状 23-30、后加的三项 31-33,并标出每一项对应的头文件行号 @@ Jolt Physics v5.6.0 源码原文截取(MIT)
两张枚举的化石层
图 7-7两张枚举的化石层:EShapeType 把 Plane 与 Empty 排在四个用户类型之后,EShapeSubType 把 Plane / TaperedCylinder / Empty 排在最后三个。顺序反映的是追加时间,不是分类逻辑 @@ 本系列绘制

Shape.h 第 127 至 137 行还给了一张全量类型数组 sAllSubShapeTypes[]、一个常量 NumSubShapeTypes = 34,以及一张名字表 sSubShapeTypeNames[],最后用 static_assert(std::size(sSubShapeTypeNames) == NumSubShapeTypes) 把两张表的长度锁死。这条断言看着琐碎,可它是「加类型忘了加名字」这类低级错误唯一的防线。

1.3 顺序为什么不能重排

OK,既然顺序是追加式的,那能不能趁重构把顺序理顺?不能。原因在上一章那套官方 instrumentation 里。

NarrowPhaseStats.h 第 42 至 43 行定义了两张按子类型索引的二维表:

static NarrowPhaseStat  sCollideShape[NumSubShapeTypes][NumSubShapeTypes];
static NarrowPhaseStat  sCastShape[NumSubShapeTypes][NumSubShapeTypes];

表的行列下标就是 EShapeSubType 的取值。上一章我们用它量出了「最贵与最便宜的形状对差 53.5 倍」,那张表的坐标轴正是这两张枚举。一旦重排枚举,所有历史统计数据的坐标含义全部改变,而且这种错位不会报错,只会让读数悄悄错掉。

枚举顺序与统计表的索引耦合
图 7-8枚举顺序与统计表的索引耦合:EShapeSubType 的取值同时是 sCollideShape 二维表的行列下标,重排枚举会让历史 CSV 的坐标含义整体错位且不报错 @@ 本系列绘制

Shape.h 第 199 至 200 行还有一个更细的细节:GetType() 与 GetSubType() 是非虚的内联函数,直接返回成员变量:

EShapeType          GetType() const                                     { return mShapeType; }
EShapeSubType       GetSubType() const                                  { return mShapeSubType; }

这两个值在构造时就写死进对象(Shape.h:186 附近的构造函数),运行期不可变。官方在 v5.1 之前用的是 RTTI,后来改成了这两个枚举(APIChanges.md 第 158 行记着这次改动)。换成枚举的原因很实在:跨 DLL 的 RTTI 匹配在 Windows 上不可靠,而两个字节的枚举在任何编译单元里都稳定。

1.4 一个「查无落点」的说明

OK,网上有些讲 Jolt 形状的文章会提到 MakeSubShapeID 这类接口。本机把整个仓库搜了一遍:没有这个函数。产生 SubShapeID 的唯一公开途径是 SubShapeIDCreator::PushID(SubShapeID.h:111),而「一个复合形状需要多少位」是各形状私有的 GetSubShapeIDBits() 现算的(CompoundShape.h:329)。

同样查无落点的还有 cBitsPerSubShapeID(一个固定位宽常量)与 SubShapeIDCreator::Add(一个加法式接口)。真实的实现里没有固定位宽这个常量,位数是按每个形状实际有多少子形状算出来的,这正是本篇 §5.3 要讲的东西。

二、复杂度阶梯:谁有体积、谁必须静止

形状谱系最容易被当成一张「有什么类型」的清单。可清单没有回答工程上真正要问的两个问题:这个形状能干什么、不能干什么。这一节用两个切口把阶梯量出来:体积怎么算,以及谁能动。

Shape 基类里与复杂度直接相关的四个接口声明
图 7-9Shape 基类里与复杂度直接相关的四个接口声明:GetVolume 与 GetMassProperties 是纯虚,MustBeStatic 默认 false,GetInnerRadius 纯虚;逐个标注行号 @@ Jolt Physics v5.6.0 源码原文截取(MIT)

2.1 GetVolume() 的三档实现

OK,先看基类的声明与两种实现的对比,差异一眼就能看出来:

// Shape.h:424,基类只声明,不给实现(纯虚)
virtual float GetVolume() const = 0;

// MeshShape.h:159,「非封闭实体」一族直接返回 0
virtual float GetVolume() const override { return 0.0f; }

// SphereShape.h:99,解析式:写死一个公式(三种实现的完整对照见各自的头文件)
virtual float GetVolume() const override { return 4.0f / 3.0f * JPH_PI * Cubed(mRadius); }

其中:SphereShape.h 那一行里的 Cubed(mRadius) 是 r³ 的宏(SphereShape.h)。这条分工的原理是「能用公式算的就别攒数据」:解析式形状的体积是零成本,凸包要额外存一个浮点数,而网格与高度场连存都不存,直接承认自己没有定值。

GetVolume() 在基类里是纯虚(Shape.h:424),但各形状的实现方式分成三档,这一档比类型名更能说明复杂度:

档位 形状 实现 源码证据 本机实测
解析式 Sphere 4/3πr³ SphereShape.h:99 4.188790(期望 4.188790,比值 1.0000)
Capsule 4/3πr³ + 2πhr² CapsuleShape.h:106 2.094395(比值 1.0000)
Cylinder 2πhr² CylinderShape.h:99 1.570796(比值 1.0000)
TaperedCylinder 积分式 TaperedCylinderShape.cpp:666-670 1.654572(比值 1.0000)
Box AABB 体积 BoxShape.h:95 8.000000(比值 1.0000)
数值 ConvexHull 构建期算出并缓存 ConvexHullShape.cpp:83 / .h:108 1.333333(八面体 r=1,正是 4/3)
近似 TaperedCapsule 用 AABB 体积顶替,注释直接写 Volume is approximate! TaperedCapsuleShape.h:100 5.880000
恒 0 Triangle / Plane / Empty / Mesh / HeightField 直接 return 0 TriangleShape.h(第 108 行)、PlaneShape.h(第 115 行)、EmptyShape.h(第 65 行)、MeshShape.h(第 159 行)、HeightFieldShape.h(第 267 行) 五个全部为 0.000000
本机实测的体积对照
图 7-10本机实测的体积对照:五种解析式形状的实测值与解析期望逐位吻合(比值全部 1.0000),凸包是构建期数值积分,锥形胶囊是 AABB 近似(实测 5.880000),三角形/平面/空/网格/高度场五项恒为 0 @@ 本机基准实测(原创)

那张「恒 0」的名单值得单独看一眼:Triangle、Plane、Empty、Mesh、HeightField。它们有什么共同点?

2.2 恒 0 那一族,正是「非封闭实体」

OK,答案是:它们都不构成封闭实体。三角形是一张纸,平面是一个无界半空间的边界,空形状没有几何,三角网格是一堆散片,高度场是一张起伏的曲面。对它们而言「体积」这个量要么无意义,要么无定值。

MeshShape.h 第 83 行的类注释把话说得很直:

…you need to provide a mass yourself as mesh shapes don't need to form a closed hull so don't have a well defined volume…

Shape.h 第 423 行还给复合形状留了一条免责说明:复合形状的体积可能不准,因为子形状可以互相重叠,而这条没被扣掉。

MeshShape.h 第 81 至 94 行的原文
图 7-11MeshShape.h 第 81 至 94 行的原文:注释说网格主要用于静态几何、可用于动态但不得与其它网格或高度场相撞、并且需要自己提供质量,因为网格不构成封闭外壳、没有良定义的体积 @@ Jolt Physics v5.6.0 源码原文截取(MIT)

这条「非封闭 → 没体积」的推论有个直接工程后果:你不能对网格形状调 GetMassProperties() 指望它给你合理的惯性张量。官方给的路是显式指定质量属性,Architecture.md 第 217 行说要设 mOverrideMassProperties = MassAndInertiaProvided。

这里还要把两个容易混的概念分开。「恒 0 的体积」与「是不是叶子」是两件事:

形状 GetVolume() GetSubShapeIDBitsRecursive() 是叶子吗
Sphere / Box / Capsule / Cylinder / TaperedCapsule / ConvexHull / Triangle 解析式 / 数值 0 是(凸形状没有子形状)
Plane / Empty 0 0 是
Mesh / HeightField 0 自己的位数(不是 0) 不是(三角形与格子都是子形状)
两个容易混的概念分开
图 7-12两个容易混的概念分开:体积恒 0 的形状里,三角形/平面/空形状是叶子(位深 0),而网格与高度场不是叶子(位深等于自己那一层)。说明「有没有体积」与「有没有子形状」是两套独立的口径 @@ 本系列绘制

这张表说明为什么 §5.3 那条位深公式里会出现两种「0」:一类是真的没有子形状(凸形状与空形状),另一类是只有一层平铺的子形状(网格与高度场)。前者返回 0 位,后者返回自己那一层的 $\lceil \log_2 n \rceil$ 位。

2.3 「必须 static」是契约,不是拦截

OK,三个形状在源码里把 MustBeStatic() 重写成 true(HeightFieldShape.h、MeshShape.h、PlaneShape.h):

  • HeightFieldShape.h:132:virtual bool MustBeStatic() const override { return true; }
  • MeshShape.h:94:同上
  • PlaneShape.h:59:同上

配套的注释和文档也都写了:

形状 原文 出处
HeightField A height field shape. Cannot be used as a dynamic object. HeightFieldShape.h:113
HeightField …very suitable for representing hilly terrain. Any body that uses this shape needs to be static. Architecture.md:182
Plane The negative half space is considered solid. Planes cannot be dynamic objects, only static or kinematic. PlaneShape.h:39
Mesh They can be used by dynamic or kinematic objects but only if they don't collide with other mesh or heightfield shapes as those collisions are currently not supported. MeshShape.h:81-83
Mesh(动态时) …an assert will trigger when this happens and the collision will be ignored. Architecture.md:215
HeightFieldShape.h 第 113 行与第
图 7-13HeightFieldShape.h 第 113 行与第 132 行的原文:类注释写着不能当动态对象用,MustBeStatic 被重写为返回 true。旁边并排放 PlaneShape.h 与 MeshShape.h 的同类重写 @@ Jolt Physics v5.6.0 源码原文截取(MIT)

本机要在这里记一条诚实的发现:全仓库对 MustBeStatic() 的引用,只剩声明本身与两处转发(CompoundShape 与 DecoratedShape 各自把它转发给内层),PhysicsSystem.cpp 里没有任何调用点。所以这条约束在执行层面靠的是「使用者自觉 + 出问题时触发断言」,创建期并没有这么一道校验。

我们顺手把「不遵守会怎样」的落点也找到了:CollisionDispatch.cpp 第 23 与 29 行是两个默认的占位碰撞函数,函数体只有一句 JPH_ASSERT(false, "Unsupported shape pair");。网格撞网格、网格撞高度场这类不支持的对,就是被这两个函数兜住并忽略的。

「必须 static」这条约束的真实执行路径
图 7-14「必须 static」这条约束的真实执行路径:源码里只有 MustBeStatic 返回 true 与两处转发,PhysicsSystem 无调用点;实际兜底是靠 CollisionDispatch 里两个默认占位函数里的 JPH_ASSERT(false),Release 构建下这条约束就是静默忽略 @@ 本系列绘制

2.4 凸包的两条点数边界

OK,凸包是唯一一个「你给它一堆点、它自己算出几何」的形状,所以它的边界都是构造期的检查。本机把这几条报错逐条跑了出来:

触发条件 源码位置 报错原文
点数少于 3 ConvexHullBuilder.cpp:307-310 Need at least 3 points to make a hull
初始三角形面积过小(退化) ConvexHullBuilder.cpp:362-365 Could not find a suitable initial triangle because its area was too small
点数超过上限 ConvexHullShape.cpp:182-184 Too many points in hull (%u), max allowed %d

上限常量在 ConvexHullShape.h 第 47 行:static constexpr int cMaxPointsInHull = 256;。第三方工程口径可以互相印证:EZ Engine 的导入文档写「凸包要求顶点加三角形数小于 250」,说的就是这条。

还有一条来自源码注释的细节(ConvexHullShape.h:33):你给的点不需要是凸包的顶点,中间点、面上的点、边上的点都能喂进去,构建器会自己筛。这让「先随便采样、再交给引擎收紧」成为一种合法做法。

2.5 两种「大几何」的存储账

OK,谱系的另一头是网格与高度场。它们和凸形状的差别不只是三角形多,而是表示方式换了一整套:凸形状用解析参数或一堆顶点,网格与高度场用的是分块索引 + 量化坐标。

MeshShape 一侧,两个版本条目能说明它在权衡什么:

  • 构建质量可以选(v5.3.0,ReleaseNotes.md:122):MeshShapeSettings::mBuildQuality 让你在「构建快」与「运行快」之间选。默认取的是运行快,代价是构建时多花时间。
  • 每三角形 user data 要付内存(v5.2.0,ReleaseNotes.md:186):打开 mPerTriangleUserData 之后,每个三角形都能带一份用户数据,官方给的代价量级是内存增加约 25%。

HeightFieldShape 一侧,两个参数决定了它的精度与内存:

  • 样本位宽(APIChanges.md:12):mBitsPerSample 原本上限 8 位,v5.5.0 之后放宽到 16 位(ReleaseNotes.md:19 写的是「提高到 16」),代价是 > 8 会让二进制格式多一个字节。这是一条典型的「精度换内存 + 换格式兼容」。
  • 块细分(APIChanges.md:155):mBlockSize 在运行期还会再细分一次,所以这个参数的实际含义与直觉不同:你设的值不是最终值。
网格与高度场的两组参数原文
图 7-15网格与高度场的两组参数原文:MeshShapeSettings 的 mBuildQuality(构建快 vs 运行快)与 mPerTriangleUserData(约 25% 内存代价);HeightFieldShapeSettings 的 mBitsPerSample(8 位升到 16 位、二进制多一字节)与 mBlockSize(运行期再细分) @@ Jolt Physics v5.6.0 官方文档原文(MIT)

两种形状还有一个共同的「结构特征」值得单记:它们的 GetSubShapeIDBitsRecursive() 返回自己那一层的位数,而不是 0(MeshShape.cpp:475-477、HeightFieldShape.h:144)。对引擎来说,网格的每个三角形、高度场的每个格子都算「子形状」,只不过它们是一层平铺,谈不上嵌套的树。

最后回到 §2.3 那条契约上。网格与高度场之所以被要求 static,根因就在这里:它们的子形状数量是量级级别的(几万到上百万),而动力学每一步都要更新变换、重算包围盒。让这类形状动起来,代价是把整棵结构每帧重建一遍,远不止多花几毫秒。

外部真图
图 7-16外部真图:网格模型的实物渲染。同一份几何,三角网格表示(MeshShape,几万个三角形、GetVolume 恒 0、必须 static)与凸包表示(ConvexHullShape,256 点上限、数值体积)是两种截然不同的形状,选哪一种决定了后面全部代价 @@ Wikimedia Commons Penang island.stl(CC BY-SA 4.0,Cmglee;由 Commons 缩略图接口渲染为 PNG)

三、复合三支:树 / 列表 / 空

一对一的形状讲完了,接下来是「一堆形状凑成一个」。Jolt 给了三支,它们的功能看起来一样(都是「一块几何由多个子形状拼成」),差别全在组织方式换来的代价:查询快不快、改得动改不动、以及没有几何时拿什么占位。先把三支并排看一眼,再逐个拆它们各自买到了什么。

复合形状的三支对照
图 7-17复合形状的三支对照:StaticCompound 用四叉树组织、构造后不可改、查询快;MutableCompound 用扁平列表组织、可增删、查询慢;Empty 没有几何,用来占位 @@ 本系列绘制

3.1 StaticCompoundShape:一次构造,之后是树

OK,类注释(StaticCompoundShape.h 第 30 至 32 行)三句话把定位说完了:子形状可以旋转平移、构造后不能修改、以及会把所有子对象整体平移使它们围绕质心居中。

组织方式是四叉树,节点结构在 StaticCompoundShape.h:119-133:

struct Node
{
    HalfFloat   mBoundsMinX[4];
    HalfFloat   mBoundsMinY[4];
    HalfFloat   mBoundsMinZ[4];
    ...
    uint32      mNodeProperties[4];
};
static_assert(sizeof(Node) == 64, "...");

两个细节值得记住。第一,包围盒用半精度浮点存(HalfFloat),四个子节点一组,节点正好 64 字节,一个缓存行刚好装得下。第二,mNodeProperties 里塞的是「这个槽位是子节点还是子形状」的标记,标记位是 IS_SUBSHAPE = 0x80000000(StaticCompoundShape.h:114),这也是 §3.4 那条「节点太多」报错的来源。

还有三个与「树」有关的常量与约束值得记下来,它们一起决定了这棵树的形状:

常量 / 约束 值 含义
cStackSize 128(StaticCompoundShape.h:106) 查询时的固定栈深。用定长栈而不是动态分配,说明引擎假定「树的深度有上界」,而这条上界由位深给出
子形状数下限 2(StaticCompoundShape.cpp:184-187) 少于 2 个直接报错,并提示改用 RotatedTranslatedShape
位深上限 32(StaticCompoundShape.cpp:349-351) 树有多深不是自由选的,它被 SubShapeID 的位宽反推出来

最后这条是本节最值得记住的一点:树的形状受编码的约束。先定了 32 位,树就只能长这么深,不存在「先设计树、再想怎么编码 ID」这回事。§6.2 会把这条上界量出来。

StaticCompoundShape 的 64 字节节
图 7-18StaticCompoundShape 的 64 字节节点布局:四组半精度包围盒(Min/Max 各三个轴)加四个属性字节,属性字节的最高位是「子形状」标记位 IS_SUBSHAPE = 0x80000000,其余位存索引 @@ 本系列绘制

3.2 MutableCompoundShape:为了改得动,把树摊成列表

好了,类注释(MutableCompoundShape.h 第 23 至 25 行)写得很坦白:这个形状是为增删和改旋转平移优化的,代价是查询效率更低。

它的组织方式是把包围盒摊成结构体数组的数组形式(SOA),四个一组(MutableCompoundShape.h:163-173 的 Array<Bounds> mSubShapeBounds,块数按 (size + 3) >> 2 算)。改一个子形状不需要重建树,只要改对应的那格数据;查询时因为少了层次结构,得多做几次排除。

它对外提供了完整的运行期编辑接口(CompoundShape.h:77-105):AddShape、RemoveShape、ModifyShape(两个重载:只改变换、连形状一起换)、ModifyShapes(批量)、AdjustCenterOfMass。

「列表比树查得慢」这句话具体慢在哪,值得算一笔。树在一次查询里可以整块跳过:包围盒不相交的子树直接不进,代价是对数级的;而列表只能逐个试每个子形状的包围盒,代价是线性的。一个两百块碎片的复合形状,树可能只需要试十来个包围盒,列表要试两百个。

这笔代价换来的是改动的力度。树在构造期定了层次结构,改一个子形状的位置可能触发节点包围盒的向上更新甚至重建;列表改一格数据,代价是常数级的。官方在类注释里那句话(less efficient in querying)说的就是这条取舍:树买查询,列表买编辑。

树与列表的查询代价对比
图 7-19树与列表的查询代价对比:四叉树能在一次查询里整块跳过不相交的子树,代价是对数级;扁平列表必须逐个试每个子形状的包围盒,代价是线性级。改动力度上刚好相反 @@ 本系列绘制

这里有一条官方明确承认的副作用,写在 Architecture.md 第 211 行:改动 MutableCompoundShape 会让已有的 SubShapeID 失效。这条是本篇 §6.3 的核心,本机专门量了它。

3.3 EmptyShape:没有几何的形状

EmptyShape.h 的类注释说它的用途是「占位」:给一个体挂上约束或某种标记,但完全不参与碰撞。它的几个关键接口都很干脆:GetVolume() 返回 0(:65)、IsValidScale() 恒 true(:66)、GetSubShapeIDBitsRecursive() 返回 0(:45)。

最后这条尤其干净:没有子形状的形状,需要 0 位。这正好是下一节那条位深公式的边界条件。

3.4 三条来自源码的硬报错

好了,复合形状的边界全部落在构造期,本机把三条报错原文都取了出来:

条件 源码位置 报错原文
子形状少于 2 个 StaticCompoundShape.cpp:184-187 Compound needs at least 2 sub shapes, otherwise you should use a RotatedTranslatedShape!
节点数撞上标记位 StaticCompoundShape.cpp:341-346 Compound hierarchy has too many nodes
位深超出 32 位 StaticCompoundShape.cpp:349-351、MutableCompoundShape.cpp:48-50 Compound hierarchy is too deep and exceeds the amount of available sub shape ID bits
复合形状的三条构造期报错原文卡片
图 7-20复合形状的三条构造期报错原文卡片:子形状少于 2 个(该改用 RotatedTranslatedShape)、节点数撞上 IS_SUBSHAPE 标记位、位深超出 32 位。每条都标出源码文件与行号 @@ 本系列绘制

第一条报错带着一句「你该改用 RotatedTranslatedShape」,这个提示不是客套:一个子形状的复合没有「复合」可言,用装饰器反而更省一次查表。第三条是本篇后面要撞的那面墙。

最后看一个数据结构的细节。CompoundShape.h:238-246 定义了复合形状里每个子形状的存储:

struct SubShape
{
    RefConst<Shape> mShape;
    Float3          mPositionCOM;   ///< Note: Position of center of mass of sub shape!
    Float3          mRotation;      ///< Note: X, Y, Z of rotation quaternion
    uint32          mUserData;      ///< User data value (put here because it falls in padding bytes)
    bool            mIsRotationIdentity;
};
static_assert(sizeof(SubShape) == (sizeof(void *) == 8? 40 : 36), "Compiler added unexpected padding");
CompoundShape.h 第 238 至 246
图 7-21CompoundShape.h 第 238 至 246 行的 SubShape 结构体:形状引用、子形状质心位置(注释强调是质心不是枢轴)、旋转四元数的 XYZ 三个分量、塞进 padding 的用户数据、是否单位旋转的布尔,最后用 static_assert 锁住 40 字节 @@ Jolt Physics v5.6.0 源码原文截取(MIT)

两处注释值得停下来说。mPositionCOM 存的是子形状的质心位置,不是它的原点位置,所以摆放子形状时要减掉内层质心,这个坑第 §4.3 节还会遇到。mUserData 被塞进填充字节里,注释直接说明了原因(不占额外空间)。static_assert 把结构体大小锁在 40 字节(8 字节指针平台),顺带挡住了编译器可能的意外填充。

四、装饰器三件套:不改几何能改什么

装饰器(decorator)是三种「包一层」的形状:ScaledShape、RotatedTranslatedShape、OffsetCenterOfMassShape。它们有一个共同点:不复制内层的几何,只在外层记录一份变换或偏移,所以额外的内存开销几乎为零,代价是查询时多一次转发。三者的差别在于各自多加了哪一层语义,而它们共享的那一层转发关系值得先看一眼。

装饰器叠层的转发关系
图 7-22装饰器叠层的转发关系:DecoratedShape 基类持有内层形状引用,并把它自己的 MustBeStatic、GetSubShapeIDBitsRecursive、IsValidScale 三个接口转发给内层。三件套各自再加一层语义:缩放、旋转平移、质心偏移 @@ 本系列绘制

4.1 基类只做三件转发

DecoratedShape.h 里的基类很短,核心是三个转发:

const Shape *GetInnerShape() const                      { return mInnerShape; }            // :40
virtual bool MustBeStatic() const override              { return mInnerShape->MustBeStatic(); }              // :43
virtual uint GetSubShapeIDBitsRecursive() const override{ return mInnerShape->GetSubShapeIDBitsRecursive(); } // :49
virtual bool IsValidScale(Vec3Arg inScale) const override { return mInnerShape->IsValidScale(inScale); }    // :71

第一条转发意味着「内层必须 static,包一层也还是必须 static」;第二条意味着装饰器不增加位深(这条对 §5.3 的位深公式很关键);第三条意味着缩放合法性由内层说了算。

4.2 ScaledShape 与那件不被支持的事

ScaledShape 表达「三个轴的独立缩放」。它的麻烦在于:先旋转后缩放会引入剪切(shearing),而库不支持剪切。官方原文在 Architecture.md 第 187 行。

源码里有一套完整的判定与补救,都在 ScaleHelpers.h(ScaledShape.cpp:40-44 的零缩放拦截也在同一路):

// ScaleHelpers.h:51-69  CanScaleBeRotated
// ChildScale = mRotation^T * Mat44::sScale(inScale) * mRotation
Mat44 child_scale = r.Multiply3x3LeftTransposed(r.PostScaled(inScale));
// 只要非对角元素非零,就说明缩放与旋转不兼容
ScaleHelpers.h 第 51 至 69 行的
图 7-23ScaleHelpers.h 第 51 至 69 行的 CanScaleBeRotated:注释给出推导 ChildScale = R^T · S · R,实现里算出子空间缩放矩阵后检查非对角元素是否小于 1e-6,非零即判定不兼容(会引入剪切) @@ Jolt Physics v5.6.0 源码原文截取(MIT)

推导写出来就是:内层变换是「先缩放再旋转」,那子空间看到的缩放是 $S' = R^{\mathsf T} S R$。如果 $S$ 是各向异性的、而 $R$ 不是单位阵,$S'$ 的非对角元素就会非零,几何上的表现就是直角被推成了斜角。

补救办法是 RotateScale(ScaleHelpers.h:75-80):把 $S'$ 的对角线取出来当成子形状的局部缩放,丢掉非对角残差。引擎给的是一个「最接近的合法缩放」,并不会拒绝执行。

零缩放则被明确挡住:ScaledShape.cpp:40-44 报 Can't use zero scale!,构造函数里还有一条断言(ScaledShape.h:47)。

剪切为什么会出现
图 7-24剪切为什么会出现:内层先旋转再缩放时,父空间看到的缩放矩阵 R^T·S·R 的非对角元素非零;取对角线得到的是最接近的合法各向异性缩放,非对角残差被丢弃 @@ 本系列绘制
外部真图
图 7-25外部真图:斜切与旋转的静帧对照,直观展示直角被推斜之后几何不再是一个轴对齐缩放能表达的 @@ Wikimedia Commons SVG skew and rotation.svg(CC BY 4.0,Watchduck)

4.3 RotatedTranslatedShape:给它一个位置和姿态

RotatedTranslatedShape 是「把内层形状摆到某个位置、转成某个姿态」。类注释(RotatedTranslatedShape.h:36-37)同样强调它会围绕质心居中。

它有一个容易看错的接口:

Vec3 GetPosition() const { return mCenterOfMass - mRotation * mInnerShape->GetCenterOfMass(); }  // :52

GetPosition() 返回的是摆放原点,不是质心位置;真正的质心由 GetCenterOfMass() 直接返回(:55)。这两个值差一个「内层质心转过旋转」的偏移,摆子形状时算错的就是这一项。它的体积直接透传内层(:122),本机实测 8.000000 对 8.000000。

4.4 OffsetCenterOfMassShape:只挪质心,不动几何

好了,三件套里最有意思的是这个。类注释(OffsetCenterOfMassShape.h 第 34 行)给了使用场合:把不稳的物体(比如船)的质心往下压,让它稳。

实现只有三行关键代码:

Vec3 GetCenterOfMass() const { return mInnerShape->GetCenterOfMass() + mOffset; }        // :49
MassProperties mp = mInnerShape->GetMassProperties(); mp.Translate(mOffset); return mp;   // :62-67
float GetVolume() const { return mInnerShape->GetVolume(); }                             // :121
质心下移的几何含义
图 7-26质心下移的几何含义:几何一点没变,只是旋转中心从几何中心挪到更低的位置。姿态回正力矩因此变大,船就不容易翻。图里同一条船画两遍,只标出旋转中心的不同 @@ 本系列绘制

体积不变(本机实测 8.000000)、几何不变、包围盒不变,变的只有「绕哪里转」。这条设计把「稳定性」从一个几何问题变成一个可调参数,代价是物理直觉上要多想一步:同一个形状,质心不同,行为就不同。

4.5 缩放合法性矩阵(本机实测)

好了,最后把「哪些缩放合法」量成一张表。本机把十种形状 × 五种缩放逐格跑了一遍 IsValidScale:

形状 均匀 (2,2,2) 单轴 (2,1,1) XZ 均匀 (2,3,2) 含零 (2,0,2) 含负 (2,−1,2)
Sphere 合法 不合法 不合法 不合法 不合法
Box 合法 合法 合法 不合法 合法
Capsule 合法 不合法 不合法 不合法 不合法
Cylinder 合法 不合法 合法 不合法 合法
ConvexHull 合法 合法 合法 不合法 合法
Mesh 合法 合法 合法 不合法 合法
HeightField 合法 合法 合法 不合法 合法
Plane 合法 合法 合法 不合法 合法
Empty 合法 合法 合法 合法 合法
StaticCompound 合法 合法 合法 不合法 合法

这张表与 Shape.h 第 429 至 437 行的文档规则逐条吻合。把那几行原文摊开,规则一共六条:

形状 允许的缩放 原文要点
Sphere 只能均匀 球被非均匀缩放就不再是球
Box 任意 盒子缩放之后还是盒子
Triangle 凸半径为零时任意,否则只能均匀 凸半径把三角形变成一个圆角三角形
Capsule / TaperedCapsule 只能均匀 锥形胶囊允许 Y 轴符号翻转
Cylinder XZ 两轴必须相等 圆柱被拉成椭圆柱就不再是圆柱
RotatedTranslated / Compound 任意,但不得产生剪切 这一条就是 §4.2 那套判定的服务对象
Shape.h 第 429 至 437 行的缩放规则原文
图 7-27Shape.h 第 429 至 437 行的缩放规则原文整理成表:球与胶囊只能均匀缩放、盒任意、三角形看凸半径、圆柱要求 XZ 相等、复合与旋转平移不得产生剪切。与本机实测的合法性矩阵逐条对应 @@ 本系列绘制

唯一的例外格是 Empty 含零缩放仍然合法,因为它压根没有几何可缩。

缩放合法性矩阵(本机实测)
图 7-28缩放合法性矩阵(本机实测):十种形状 × 五种缩放,与 Shape.h 第 429 至 437 行的文档规则逐条吻合,唯一例外是 Empty 在含零缩放下仍合法 @@ 本机基准实测(原创)

最锋利的一格在表外。本机另外测了「先旋转后缩放」:内层是绕 Z 轴转了 45° 的 Box,父空间给 (2,1,1):

RotatedTranslatedShape::IsValidScale(2,1,1) = 不合法
ScaleShape(2,1,1) 结果 = 成功,产物 RotatedTranslated

判定它不合法,但照样给你一个结果。ScaleShape() 的语义是「尽量返回一个匹配该缩放的形状」(Shape.h:342 的注释原文),不是「原样保真」。这一对行为放在一起看,才是这套 API 的真实契约:合法性查询用于决策,ScaleShape 用于兜底。

IsValidScale 与 ScaleShape 的行
图 7-29IsValidScale 与 ScaleShape 的行为对照:前者是决策接口(不合法就告知),后者是兜底接口(即使不合法也返回最接近的合法产物,并换成 RotatedTranslated 或 Scaled 子类型) @@ 本系列绘制

五、命中的代价:32 位路径编码

前面四节讲的是「有哪些形状、怎么组织」。这一节换一个问法:一堆形状拼在一起之后,怎么指出是哪一块被打中了。

先看别家的答案。PhysX 的射线扫掠结果里有一个 shapeIndex,它就是 actor 形状数组的下标;Bullet 的复合形状用 child index,也是数组下标。这两个答案的共同点是一层扁平索引,能表达「第几块」,表达不了「哪条路径上的第几块」。

Jolt 的答案不一样。它把整条路径压进一个整数。

三派「命中哪一块」的编码对照
图 7-30三派「命中哪一块」的编码对照:PhysX 用扁平的 shapeIndex(数组下标)、Bullet 用 child index(子形状下标)、Jolt 用 32 位路径压位。前两者只能表达「第几块」,后者能表达「嵌套复合里哪条路径的哪片叶子」 @@ 本系列绘制

5.1 先把位语义读清

SubShapeID.h 只有 140 行左右,但把位语义讲得很完整。核心几条:

using Type = uint32;                                             // :28
using BiggerType = uint64;                                       // :31
static constexpr uint MaxBits = 8 * sizeof(Type);                // :36  → 32
static constexpr Type cEmpty = ~Type(0);                         // :86  → 0xFFFFFFFF

写位的实现(:93-100):

void PushID(Type inValue, uint inFirstBit, uint inBits)
{
    // 先清掉这一段位,再写进去
    mValue &= ~(Type((BiggerType(1) << inBits) - 1) << inFirstBit);
    mValue |= inValue << inFirstBit;
}

读位的实现(:42-49)从最低位端取:

Type mask_bits = Type((BiggerType(1) << inBits) - 1);
Type v = mValue & mask_bits;
outRemainder = SubShapeID(Type(BiggerType(mValue) >> inBits) | fill_bits);   // 高位补 1

把这三段连起来读,位布局就说清了:

  • 位从最低位(LSB)起,逐层向上压:第一层的选择占 bit0,第二层占 bit1 起,以此类推;
  • 根级不占位:SubShapeIDCreator 的游标从 0 开始(:133-135 的 uint mCurrentBit = 0;),因为「根形状是谁」由 BodyID 回答,不需要 SubShapeID 再答一遍;
  • 没用到的高位补 1:初始值是 cEmpty(全 1),PushID 只清掉它写入的那一段,所以从没被写过的位一直是 1。空 ID 就是「一位都没写过」的那个全 1 值。
32 位 SubShapeID 的位布局与写入过程
图 7-3132 位 SubShapeID 的位布局与写入过程:初始值全 1(cEmpty),PushID 从低位起逐段清位再写入,游标 mCurrentBit 从 0 递增并受 MaxBits 约束;未被写入的高位保持 1 @@ 本系列绘制

最后一个语义在 PopID 里体现得更清楚:右移之后用 | fill_bits 把腾出来的高位重新填 1(:42-49)。这条「补 1」不是随手写的,它让任何深度的一段位串都能唯一地拼回原路径:只要知道前面用掉了多少位,剩下的一看是不是全 1 就知道到头了。

趁这里把「为什么是 32 位」也交代一句。SubShapeID.h 第 28 与 31 行并排放着两个类型:

using Type = uint32;        // 实际存储用 32 位
using BiggerType = uint64;  // 中间运算用 64 位

第二个类型的存在说明了一件事:位运算本身需要更宽的中间量(左移 32 位、算掩码都可能溢出 32 位),所以实现里凡是移位都先转到 BiggerType。而真正存储用的 Type 之所以定在 32 位,从代码留下的痕迹看是一个内存决定:SubShapeID 出现在每一个 ContactManifold、每一个 RayCastResult、每一个形状扫掠结果里,而这个值通常只需要几层路径。换成 64 位,这些结构会普遍变宽,换来的深度提升在绝大多数场景里用不到。这一段属于对设计意图的推断(源码没有留下取舍记录),只作为理解方向的参考。

SubShapeID.h 第 14 至 18 行的文件头注释自带一个例子:位串从低到高读作 AAA(3 位)、BB(2 位)、CCCCCCC(7 位),整体写作 CCCCCCCBBAAA。这与官方文档第 197 至 199 行那个「A 有 5 个形状占 3 位、B 有 3 个占 2 位、C 是网格占 7 位」的示例是同一件事。

5.2 位串实测:把 ID 逐位打出来(本机实测)

好了,上面全是读源码读出来的。本机做了一组实验,把引擎真正给出的 ID 与预测逐位比对。

场景是两层复合形状:

根 StaticCompoundShape{
    [0] Box      @ (-2.0, 0, 0)          ← 位:根 1 位 = 0
    [1] inner    @ (+2.0, 0, 0)          ← 位:根 1 位 = 1
}
inner = StaticCompoundShape{
    [0] Sphere   @ (-1.2, 0, 0)
    [1] Capsule  @ ( 0.0, 0, 0)
    [2] Cylinder @ (+1.2, 0, 0)
}

根有 2 个子形状,占 1 位;内层有 3 个,占 2 位;合计 GetSubShapeIDBitsRecursive() = 3 位。四片叶子按「根位 + 内层两位、LSB 优先」推算出来的 ID 应该是:

叶子 根位 内层两位 预测 ID(高位补 1)
Box 0 无 0xFFFFFFFE
Sphere 1 00 0xFFFFFFF9
Capsule 1 01 0xFFFFFFFB
Cylinder 1 10 0xFFFFFFFD

然后用四条竖直射线分别打中这四片叶子,读 RayCastResult::mSubShapeID2(CastResult.h:34)。实测结果:

叶子 射线 x   引擎给的 ID   32 位二进制(左=bit31,右=bit0)  GetLeafShape→  remainder
0    -2.0     0xFFFFFFFE   11111111111111111111111111111110  Box            空(全1)
1     0.8     0xFFFFFFF9   11111111111111111111111111111001  Sphere         空(全1)
2     2.0     0xFFFFFFFB   11111111111111111111111111111011  Capsule        空(全1)
3     3.2     0xFFFFFFFD   11111111111111111111111111111101  Cylinder       空(全1)
→ 与「根 1 位 + 内层 2 位、LSB 优先」的预测比对:4/4 完全一致
本机实测的位串对照表(本篇核心图)
图 7-32本机实测的位串对照表(本篇核心图):四个叶子位置各给一列:引擎回报的 ID 十六进制、对应的 32 位二进制串、GetLeafShape 解引用得到的叶子子类型、以及 remainder 是否为空。四行与「根 1 位 + 内层 2 位、LSB 优先、高位补 1」的预测逐位吻合 @@ 本机基准实测(原创)

看那四行二进制:从右往左数,第一位(bit0)就是根的选择,Box 是 0,另外三个是 1;第 2、3 位(bit1、bit2)是内层的选择,Sphere 是 00、Capsule 是 01、Cylinder 是 10;bit3 往左全是 1,因为从没被写过。

再补一组反向验证:不用射线,直接用 SubShapeIDCreator::PushID 手工把路径压出来,再交给 GetLeafShape 解引用,看它解析回哪片叶子。

SubShapeIDCreator c;
if (i == 0) c = c.PushID(0, 1);                    // 根位 = 0 → Box
else        c = c.PushID(1, 1).PushID(i - 1, 2);   // 根位 = 1,内层两位 = i-1
const Shape *leaf = compound->GetLeafShape(c.GetID(), remainder);
手工 ID 0xFFFFFFFE → 解析为 Box       (预期 Box       )OK
手工 ID 0xFFFFFFF9 → 解析为 Sphere    (预期 Sphere    )OK
手工 ID 0xFFFFFFFB → 解析为 Capsule   (预期 Capsule   )OK
手工 ID 0xFFFFFFFD → 解析为 Cylinder  (预期 Cylinder  )OK

两个方向都对上了:引擎产生的 ID 能被预测,手工构造的 ID 能被引擎正确解析。这不是单向自证。

顺手记一个边界值:

SubShapeID 位宽 = 32 位;默认构造的空 ID = 0xFFFFFFFF(IsEmpty=1)

空 ID 全 1,这个值在别处也有回响。有第三方绑定文档写 getSubShapeId2() 的返回值「typically negative」(jolt-jni 文档),原因就在这:路径越深、高位保留下来的 1 越少,但低几层之外的位几乎全是 1,当作有符号 32 位整数读就是个负数。把它当正序号用的人,会在这里第一次撞墙。

5.3 位数从哪来

SubShapeID 是定长 32 位,但每个形状需要多少位是现算的。接口定义在 Shape.h:215-216:

/// Get the max number of sub shape ID bits that are needed to be able to address any leaf shape in this shape.
/// Used mainly for checking that it is smaller or equal than SubShapeID::MaxBits.
virtual uint GetSubShapeIDBitsRecursive() const = 0;

三个实现各有代表性:

  • 凸形状返回 0(ConvexShape.h:46),注释写得很直接:Convex shapes don't have sub shapes。球、盒、胶囊、圆柱、凸包、三角形都属于这一档,它们是叶子;
  • 复合形状累加(CompoundShape.cpp:117-124):取所有子形状里的最大值,再加上自己这一层需要的位数;
  • 网格与高度场返回自己的位数(MeshShape.cpp 第 475 至 477 行、HeightFieldShape.h 第 144 行):它们的「子形状」是三角形或格子,一层就到底。
GetSubShapeIDBitsRecursive 的
图 7-33GetSubShapeIDBitsRecursive 的三个实现:凸形状直接返回 0(没有子形状)、复合形状取子形状最大值再加自己这一层、网格与高度场返回自己的一层位数 @@ Jolt Physics v5.6.0 源码原文截取(MIT)

自己这一层的位数怎么算?CompoundShape.h:329-334:

uint32 GetSubShapeIDBits() const
{
    uint32 n = uint32(mSubShapes.size()) - 1;
    return 32 - CountLeadingZeros(n);
}

32 - CountLeadingZeros(n) 就是 $\lceil \log_2 n \rceil$:n 个子形状占 ⌈log₂n⌉ 位。这条式子解释了 §5.2 里那两个数字:根有 2 个子形状占 1 位,内层有 3 个占 2 位。它也解释了 EmptyShape 为什么返回 0 位(它连一个子形状都没有)。

把这条式子代入最朴素的两层场景:一个复合形状挂了 2 个子形状、每个子形状又是一个 8 子形状的复合,总位数就是 1 + 3 = 4 位。32 位能装多深,取决于每层的分支数,这是 §6.1 要量的东西。

这里要强调一个容易搞反的地方:32 位是一份「总预算」,不是「每层预算」。怎么花由使用者决定,广度优先和深度优先的极端情况差得很远:

组织方式 每层位数 层数 合计位数 能表达多少叶子
一层平铺 100 个子形状 7 1 7 100
一层平铺 4,294,967,296 个子形状 32 1 32 42.9 亿
两层各 16 个 4 + 4 2 8 256
三层各 256 个 8 + 8 + 8 3 24 1677 万
二元嵌套链 1 × 32 32 32 33 片叶子
32 位的预算分配表
图 7-3432 位的预算分配表:同样的 32 位,一层平铺能装 42.9 亿个子形状,三层各 256 个能装 1677 万片叶子,可是二元嵌套链只能装 33 片叶子。说明位宽是总预算、深度与广度互相挤占 @@ 本系列绘制

最后一行是本章的伏笔:二元嵌套链把 32 位全花在深度上,结果只能表达 33 片叶子。深嵌套是一种昂贵的组织方式,它买到的是层级语义,买不到容量。

5.4 怎么用:解引用与一个反直觉点

好了,拿到 ID 之后怎么用?官方给了标准姿势:GetLeafShape(id, remainder) 加一个 remainder。CompoundShape.cpp:152-162 是这个姿势的实现:

uint64 CompoundShape::GetSubShapeUserData(const SubShapeID &inSubShapeID) const
{
    SubShapeID remainder;
    uint32 index = GetSubShapeIndexFromID(inSubShapeID, remainder);
    if (index >= mSubShapes.size())
        return 0;                                              // 索引已经失效
    return mSubShapes[index].mShape->GetSubShapeUserData(remainder);   // 转发给子形状
}

读最后一行:它把 remainder 交给子形状,让子形状自己继续解。也就是说,一次 GetSubShapeUserData 会沿着路径一路转发到叶子。

GetSubShapeUserData 的转发链
图 7-35GetSubShapeUserData 的转发链:解出本层索引后把 remainder 交给子形状、子形状再解一层,一路递归到叶子才返回。因此返回值是叶子形状自己的 user data,而不是复合形状在 settings 里给这一块指定的值 @@ 本系列绘制

这条链带来了本机实测到的一个反直觉点。在 §6.3 那组实验里,本机给 MutableCompoundShape 的三个子形状分别传了 user data 11、12、13,然后问引擎:

GetCompoundUserData(0) = 12(删掉一个之后剩下的第一个)
GetCompoundUserData(1) = 13
但 GetSubShapeUserData(id) 全部返回 0

settings 里传的值拿得到(走 GetCompoundUserData(index)),可按 ID 查却拿到 0(走 GetSubShapeUserData(id)),因为后者返回的是叶子形状自己的 user data,而叶子是裸的 SphereShape,从没设过。

这不是引擎的 bug,注释里写着。CompoundShape.h:41 那行原文:

Note this value can be retrieved through GetSubShape(...).mUserData, not through GetSubShapeUserData(...) as that returns Shape::GetUserData() of the leaf shape.

CompoundShape.h 第 41 行的注释原文与
图 7-36CompoundShape.h 第 41 行的注释原文与 CompoundShape.cpp 第 152 至 162 行的转发实现并排:注释明确说这个值要走 GetSubShape(...).mUserData 拿,GetSubShapeUserData 返回的是叶子形状自己的 user data @@ Jolt Physics v5.6.0 源码原文截取(MIT)

社区里那条有名的困惑正好对得上。Jolt 的讨论区里有人问:拿复合形状的 sub shape id 去 GetSubShapeUserData,得到的是复合内部某一块的 user data,看着像随机的;把 AddShape 的顺序对调,被识别的子形状就变了。这三件事(注释、实现、社区困惑)说的是同一件事:ID 解引用要按路径走,不能按直觉取。第三方绑定文档那句 subshape IDs are assigned in a non-obvious fashion 也是同一个提醒。

5.5 在接触回调里用 SubShapeID:一个真实的坑

好了,§5.4 讲的是 API 姿势,这一节讲它在实战里怎么用错。

接触回调(ContactListener::OnContactAdded / OnContactPersisted / OnContactRemoved)拿到的是 ContactManifold,里面有两个 SubShapeID 字段:mSubShapeID1 与 mSubShapeID2,分别指向两个体上被命中的子形状。想实现「撞到轮子就冒烟、撞到车身就掉血」这类逻辑,第一步就是拿这两个 ID 去 GetLeafShape 解引用。

接触回调里的 SubShapeID 用法
图 7-37接触回调里的 SubShapeID 用法:OnContactAdded 拿到 ContactManifold,用 mSubShapeID1 / mSubShapeID2 去 GetLeafShape 解出两侧叶子形状,再按叶子形状或 user data 分发游戏逻辑 @@ 本系列绘制

坑在「解引用之前」。这里要接上一章的结论:相似法线的流形会被引擎合并(官方文档 Architecture.md 第 959 行那条,上一章引过)。如果你按子形状区分逻辑,而引擎把不同子形状的接触点合并进同一个流形,那么回调里拿到的 ID 与你预期的分块对不上,表现就是「明明撞的是前轮,触发的却是车身的逻辑」,再往下就是抖动与穿透。

社区里有一条实践记录正好讲这件事:用 SubShapeID 区分命中子形状时,需要把体的 mUseManifoldReduction 关掉,关掉之后再按 ID 分发,行为才对得上(GitCode 上的中文实践记录)。

流形合并与 SubShapeID 的冲突
图 7-38流形合并与 SubShapeID 的冲突:引擎按相似法线合并流形(上一章的官方口径),而按子形状区分逻辑的前提是「一块子形状一个流形」,两者冲突时表现为 ID 与预期不符、抖动与穿透。处置是把体的 mUseManifoldReduction 关掉 @@ 本系列绘制

顺带把另一条容易踩的口径也说清:OnContactPersisted 里的 ID 与 OnContactAdded 里的不一定相同。上一章讲过,无锁双缓存里「被判定为持久接触的接触点」会从读缓存复制进写缓存;如果这期间子形状的列表变了(比如 MutableCompoundShape 被改过),旧 ID 就会指向错误的位置。这条与 §6.3 是同一个根因。

六、位宽边界:什么时候会崩

32 位看着很宽,可它是硬上限,而且这道墙比想象的近。

6.1 位深怎么长

好了,本机把位深随「子形状数」与「嵌套深度」的增长各量了一遍。

先看单层复合:n 个子形状需要 ⌈log₂n⌉ 位。实测与公式逐条吻合:

n 实测位数 ⌈log₂n⌉
2 1 1.00
3 2 2.00
4 2 2.00
5 3 3.00
8 3 3.00
9 4 4.00
16 4 4.00
17 5 5.00
33 6 6.00
256 8 8.00
257 9 9.00

再看二元嵌套链:每层放 2 个子形状(一个叶子加下一层),深度 d 的位数就是 d:

深度 实测位数 总叶子数
1 1 2
2 2 3
3 3 4
4 4 5
5 5 6
6 6 7
7 7 8
8 8 9
位深的实测增长
图 7-39位深的实测增长:左图单层复合的 n 子形状对应位数(与 ceil(log2 n) 逐条吻合),右图二元嵌套链的深度对应位数(深度 d 就是 d 位)。两条线都是台阶式上升 @@ 本机基准实测(原创)

注意二元嵌套那一列:深度每加一,位数加一,而叶子总数只加一。当嵌套很深时,位数增长是线性的,而能表达的叶子数是线性的,唯独 32 位这个上限是固定的。

把这两张表放在一起,能算出一份自己场景的位深预算。做法只有两步:

  1. 把复合形状的层级画出来,标出每层有几个子形状;
  2. 每层取 $\lceil \log_2 n \rceil$ 位,从根往下累加;这个和就是 GetSubShapeIDBitsRecursive() 的期望值,拿它和 32 比。

举三个真实的量级:一辆车(车身 + 4 轮 + 若干附件,两层、每层十来个)大约需要 4 + 4 = 8 位;一栋楼(楼层 → 房间 → 家具,三层、每层几十个)大约 6 + 6 + 6 = 18 位;而如果场景里有人手写了「每层都只包两个子形状」的深链,深度一过 32 就构造不出来。真正危险的量级是深度,不是广度。

6.2 撞墙点:深度 33

好了,把二元嵌套一路加深,看它什么时候崩。实测输出:

depth = 33 时 Create() 失败
报错原文:Compound hierarchy is too deep and exceeds the amount of available sub shape ID bits
可构造的最大二元嵌套深度 = 32(位数 = 32,上限 32)

墙正好立在第 33 层,而第 32 层的位数正好等于 SubShapeID::MaxBits(32)。这条报错原文与 §3.4 里从 StaticCompoundShape.cpp 第 349 至 351 行读到的字符串逐字一致,也就是源码里那条检查真的在构造期拦住了。

撞墙点实测
图 7-40撞墙点实测:二元嵌套从深度 1 到 32 都能构造,深度 33 时 Create() 报错并吐出源码里那条「Compound hierarchy is too deep」。可构造的最大深度恰好等于 SubShapeID::MaxBits @@ 本机基准实测(原创)

这里要把话说清:32 层不是「一般达不到」的量级。一个由 33 层嵌套复合拼出来的物体本身就极不寻常。可如果换成兄弟很多的形态,墙会提前出现:StaticCompoundShape.cpp:341-346 那条 Compound hierarchy has too many nodes 是节点数撞上 IS_SUBSHAPE = 0x80000000 这个标记位,也就是树的节点索引用掉了 31 位。

两条墙的分工是:位深墙管深度,节点数墙管广度。

6.3 MutableCompoundShape 的 ID 不稳定

说白了,最后一条边界既不撞墙也不报错,它只是会变。官方文档第 211 行明确写了:改动 MutableCompoundShape 会让已有的 SubShapeID 失效。

本机把这句话量了出来。场景是一个装了 3 个子形状的可变复合(球、胶囊、圆柱,沿 x 排列),先用射线读出三个位置上的 ID,然后删掉最左边那个球:

初始(3 子:Sphere / Capsule / Cylinder):
    x=-1.2 → ID 0xFFFFFFFC | 解析为 Sphere
    x=+0.0 → ID 0xFFFFFFFD | 解析为 Capsule
    x=+1.2 → ID 0xFFFFFFFE | 解析为 Cylinder
RemoveShape(0) 之后(几何位置完全未动,只是列表左移):
    x=-1.2 未命中(球被删了)
    x=+0.0 → ID 0xFFFFFFFE | 解析为 Capsule
    x=+1.2 → ID 0xFFFFFFFF | 解析为 Cylinder

几何一点没动,ID 全变了(RemoveShape 的实现见 MutableCompoundShape.cpp):胶囊从 0xFFFFFFFD 变成 0xFFFFFFFE,圆柱从 0xFFFFFFFE 变成 0xFFFFFFFF。原因在 §5.1 那条位语义里:ID 装的是列表下标,删掉一个,后面所有下标左移一位。

MutableCompoundShape 删子形状前后的
图 7-41MutableCompoundShape 删子形状前后的 ID 对照:几何位置完全未动,胶囊与圆柱的 ID 各自减一位(因为列表左移)。说明 SubShapeID 是位置相关的,不能当稳定标识符长期保存 @@ 本机基准实测(原创)

这条实测给工程上留了两条可以直接用的结论。第一,不要把 SubShapeID 存起来当长期标识:它只在「当前这份形状没被改过」的前提下有效。第二,如果要跨帧记住「上一次碰到的是哪一块」,正确做法是每次重新查,或者自己在子形状上挂一份稳定的 user data 并对号入座。

顺带说一句社区侧的检索结论:本机专门搜过「位宽不足」「路径深度上限报错」「跨版本序列化不稳定」这三类抱怨,没有找到公开案例。所以本篇只写两件有落点的事:源码里有明确的深度报错(§6.2),以及官方文档承认的可变形状 ID 失效(本节)。至于「32 位够不够用」,本篇不给结论。

七、本机实测:形状阶梯与三条纪律

前面几节的读数都来自源码。这一节换一条通道:把形状真的放进物理世界跑起来,看它值多少钱。方法是官方自带的那套窄相 instrumentation(NarrowPhaseStats.h 的 sCollideShape 二维表),上一章已经用它量过形状对;这一章用它量形状本身。

7.1 实验设计与三条纪律

说白了,编译口径先说清:新建了一个构建目录,只打开官方那一个开关:

cmake -S <jolt>\Build -B <jolt>\Build\VS2022_CL_ep07 -G "Visual Studio 17 2022" -A x64 `
      -DTRACK_NARROWPHASE_STATS=ON

这一篇不给引擎打补丁。上一章为了数 GJK 的四类出口,往源码里插过计数器;这一章的六个实验全部只用公开 API(SubShapeIDCreator、GetLeafShape、GetSubShapeIDBitsRecursive、GetVolume、IsValidScale、ScaleShape、MutableCompoundShape::RemoveShape),所以源码树是干净的,任何人拉一份官方代码就能复跑。

场景设计:每一轮只放一种静态地面,地面上摆 6 种动态形状各 3 个,互相间隔 3.6 米,跑 600 步。

这套量法的原理,是把窄相的开销按子类型分开记账。要读的那个量写成式子就是:

$$\text{每次查询 tick} = \frac{\text{mTotalTicks}}{\text{mNumQueries}}$$

其中:mTotalTicks 是该格累计的处理器 tick,mNumQueries 是该格被调用的次数,另有 mChildTicks 单列子形状的自耗。所以下面所有读数都是「每次查询」的口径,不是「整步」的口径。

为什么用官方 instrumentation 而不是自己掐表,这里也说一句:自己计时只能量到「整步耗时」,而整步里混了积分、约束迭代、碰撞收集等一大堆工作;上一章实测过,窄相在稳态只占端到端的一小段(缓存开与关的总耗时差只有 16%,而调用数差 123 倍)。官方那套二维表把窄相按子类型分开记账,才让「形状与形状对之间的差异」变得可见。自己掐表量不到这张表,这不是精度问题,是分辨率问题。

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

  1. 一律禁止睡眠(mAllowSleeping = false)。上一章已经踩过:体睡着之后窄相根本不被调用,稳态量到的是「什么都没做」。
  2. 必须让体一直动。这一条是本章新踩的:第一版把体直接摆在离地 0.2 米处,结果盒子、圆柱、凸包很快静止,接触对被体对缓存吃掉,600 步只剩下 36 次查询。给所有动态体加一个绕斜轴的角速度之后,每格的样本才够用。缓存压制本身也成了本章的一条结论(见 §7.3)。
  3. 只允许「静态 × 动态」这一种配对。第一版把动态之间的碰撞也打开了,于是同类动态体互相碰,表格里混进了 TaperedCapsule × TaperedCapsule 这种与地面无关的格子。把 MOVING × MOVING 从对过滤器里关掉之后,每个非空格子都严格等于「地面 × 动态形状」。
E0 的实验设计
图 7-42E0 的实验设计:三轮独立实验,每轮只放一种静态地面(盒 / 网格 / 高度场),地面上摆六种动态形状各三个、间隔 3.6 米并带斜轴角速度,跑 600 步后读官方 instrumentation 的二维表格子 @@ 本系列绘制

7.2 阶梯结果

三个地面的轮次跑完,合计 18 个非空格子 / 15,792 次查询。按「每次查询处理器 tick」排序(列序是动态形状 × 静态地面,与官方二维表的下标顺序一致):

动态形状 静态地面 查询数 每次查询 tick 相对倍率
Sphere Mesh 1728 1401.1 1.00×
Sphere HeightField 1740 1531.7 1.09×
Sphere Box 1728 2350.6 1.68×
Box Box 36 2640.0 1.88×
Capsule Box 1143 2778.7 1.98×
Capsule HeightField 1728 3969.3 2.83×
Capsule Mesh 1737 3991.1 2.85×
TaperedCapsule Box 1692 4082.7 2.91×
Cylinder Box 84 4492.2 3.21×
ConvexHull Box 117 4888.4 3.49×
ConvexHull HeightField 137 4945.1 3.53×
TaperedCapsule Mesh 1746 4965.5 3.54×
TaperedCapsule HeightField 1764 5152.5 3.68×
ConvexHull Mesh 112 5406.0 3.86×
Box Mesh 50 6239.4 4.45×
Cylinder Mesh 84 7437.3 5.31×
Box HeightField 67 10708.5 7.64×
Cylinder HeightField 99 16260.5 11.61×
本机实测的形状阶梯
图 7-43本机实测的形状阶梯:6 种动态形状 × 3 种静态地面共 18 个格子的每次查询耗时,从最便宜的球对网格 1401.1 tick 到最贵的圆柱对高度场 16260.5 tick,首尾相差 11.61 倍。柱状按倍率排序,并标注每格的查询数以便看出样本是否被缓存压制 @@ 本机基准实测(原创)

这张表里最直接的一条结论:最便宜与最贵的格子差 11.61 倍(球对网格 1401.1 tick,圆柱对高度场 16260.5 tick)。同一种动态形状换地面,代价也在变:球落在网格上是 1401.1,落在盒地面上是 2350.6,差 1.68 倍。

至于顺序,粗看符合直觉(球最便宜),但也有反例:盒子与圆柱都不便宜,它们落在高度场上是最贵的两格。原因在上一章讲过的那处细节:盒与平面相压时裁出来的是 4 点流形,而球只有一个点。接触点数是几何的输出,代价也跟着它走。

7.2a 先说清楚这份读数能读到什么精度

上面每个格子都是一个数。可单次运行的逐格读数是有抖动的,这一点必须交代,否则会把 1.09× 和 1.68× 之间的差别当真。

本机把同一套参数连跑了三次(bench_shape_run_20261004.txt 是正式档,另两份 _rep2 / _rep3 留档):

项 结果
查询数 三次完全一致(例如 Box × Box 三次都是 36 次、Cylinder × HeightField 三次都是 99 次)
每次查询 tick 的抖动(最大减最小 ÷ 最小) 18 个格子里从 4.3% 到 35.2%,中位数约 13%
最稳的格 ConvexHull × Mesh 4.3%、Cylinder × HeightField 4.4%(查询数 99 以上)
最抖的格 Box × Box 35.2%、ConvexHull × Box 26.3%、Box × Mesh 25.8%(查询数 36–117)
E0 的可重复性
图 7-44E0 的可重复性:同一套参数连跑三次,查询数三次完全一致,而每次查询 tick 的抖动从 4.3% 到 35.2%。查询数越小的格子越抖(Box × Box 36 次查询抖 35.2%,Cylinder × HeightField 99 次查询只抖 4.4%) @@ 本机基准实测(原创)

这条给出一条读表纪律:这份阶梯要按「量级与排序」读,不要按精确倍数读。11.61 倍这种首尾差是稳的(两个格子的抖动分别是 25.8% 与 4.4%,且方向一致),而相邻两格差 9% 的对比(比如 1.00× 与 1.09×)在单次运行里不足以立论。要压抖动只有两条路:把样本量做大,或者多跑几次取中位。

下表的横切视角同样按正式档的数字读。表头与正文都按「动态形状 × 静态地面」写,这是官方 instrumentation 二维表的下标顺序,不是我的行文选择。

把同一张表横着切(同一种动态形状换三种地面):

动态形状 × Box 地面 × Mesh 地面 × HeightField 地面 最贵/最便宜
Sphere 2350.6 1401.1 1531.7 1.68×
Capsule 2778.7 3991.1 3969.3 1.44×
TaperedCapsule 4082.7 4965.5 5152.5 1.26×
ConvexHull 4888.4 5406.0 4945.1 1.11×
Cylinder 4492.2 7437.3 16260.5 3.62×
Box 2640.0 6239.4 10708.5 4.06×
横切视角
图 7-45横切视角:同一种动态形状分别落在盒、网格、高度场三种地面上的每次查询耗时,并给出最贵与最便宜的比值。球的对比是 1.68 倍,而盒达到 4.06 倍,说明「对接触类型敏感的动形状」换地面的代价最大 @@ 本机基准实测(原创)

这张横切表里有两条可以直接拿去用的观察。第一,没有一种地面是普适最优的:球与凸包在网格/高度场上更便宜,胶囊与锥形胶囊在盒地面上更便宜(2778.7 与 4082.7)。第二,对接触类型最敏感的是盒与圆柱(4.06× 与 3.62×):它们落在平面上是 4 点流形,落在网格或高度场的三角形上还要处理边与角。

所以「选哪种地面」这件事没有一句通吃的答案。真正可操作的做法还是那条:先量,再改。

7.3 怎么读这张表:先说样本

说白了,看那张表的「查询数」一列,会发现差异很大:球和胶囊那几行在 1700 上下,而 Box × Box 只有 36、Cylinder × Box 只有 84。

这不是实验失误,是缓存压制:盒子和圆柱落下去之后很快进入「姿态不再变」的状态,体对缓存直接把这一对挡在窄相之外(上一章 §6.2 量过这件事,缓存开与关的调用数差 123 倍)。球、胶囊、锥形胶囊一直滚,接触对每步都要重算,所以样本充足。

样本量的两种状态
图 7-46样本量的两种状态:一直滚动的形状(球、胶囊)每格有 1700+ 次查询,而很快静止的形状(盒、圆柱)只剩 36 到 117 次,被体对缓存挡在窄相之外。读表时必须把样本量一起看,否则会把「样本少」误读成「很便宜」 @@ 本机基准实测(原创)

这条现象反过来给了一个读表纪律:单看「每次查询耗时」会低估静止形状的真实开销,因为被缓存挡掉的那些查询根本没发生。要横向比形状,得把「样本量」和「缓存是否生效」一起看,或者干脆像本章这样给体加角速度让缓存失效。

7.4 一处自制缺陷

说白了,本章代码里最值得记的一处缺陷不在 Jolt,在实验脚本自己身上(网格构造见 MeshShape.h 关于三角形列表的接口):第一版网格的三角形绕序写成了 (a, b, c),而 Jolt 的三角形法线是 (b - a) × (c - a),算出来指向 −Y。后果是静态体直接从网格里穿下去,600 步之后 18 个动态体全在 y ≈ −417 的地方自由落体。

为了让这条结论可复现,实验脚本留了一个复现开关(bench_shape.exe 600 buggywind)。两版输出放在一起看:

绕序错(buggywind):
  静态地面 = Mesh → 6 格 / 合计 235 次查询 | 收尾时 0/18 个体仍在地面高度带(最高 -417.34 / 最低 -417.58)

绕序对(正式档):
  静态地面 = Mesh → 6 格 / 合计 5457 次查询 | 收尾时 18/18 个体仍在地面高度带(最高 0.41 / 最低 0.28)

这条缺陷留了两个可以直接用的教训。第一,动态体和静态网格不接触时,先怀疑绕序,不要先怀疑引擎。第二,判据要选对:如果只看「查询数够不够」会以为只是样本少,可只要额外打印一句「收尾时几个体还在地面高度带」,穿模这件事立刻暴露。实验脚本里那句 onGround 统计就是为这个加的。

八、横向对照与处方

8.1 三派对照

说白了,把「形状怎么组织」与「命中怎么表示」两件事拉成一张表:

维度 Jolt PhysX Bullet
复合形状是不是一等类型 是,StaticCompoundShape / MutableCompoundShape 显式两类 没有复合类:一个 actor 挂多个 shape 就是复合 是,btCompoundShape
命中「哪一块」怎么表示 SubShapeID:整条路径压进 32 位 shapeIndex:形状数组的扁平下标 child index:子形状数组的扁平下标
能表达嵌套吗 能,路径深度受位深约束 一层(actor 的形状列表) 一层(child 列表)
可变组织 Mutable 用扁平列表换运行期可改 挂/摘形状即可(代价在别处) child 列表可改
静态组织的加速结构 四叉树,节点 64 字节、用半精度存包围盒 无(形状列表) 可选 BVH(btCompoundShape 的子类)
缩放与剪切的处理 ScaledShape 表达轴缩放,剪切被明确拒绝 无对应包装,靠体变换 无对应包装
三派横向对照表
图 7-47三派横向对照表:Jolt 把复合形状做成显式类型并用 32 位路径压位表达命中;PhysX 没有复合类、命中报扁平 shapeIndex;Bullet 用 btCompoundShape 与 child index。差异集中在「能不能表达嵌套路径」这一列 @@ 本系列绘制

这张表最值得记住的是第三行。PhysX 与 Bullet 的答案都是「第几块」,一层就到底;只有 Jolt 编码了路径。这在嵌套复合的场景里不是小事:一个由零件装成部件、部件装成车、车装进场景的模型,用扁平下标回答「打中了哪个零件」是不够的。

评价标准声明。上表的六行不是按「谁更好」排的,它们回答的是两个不同的问题:能不能表达嵌套(能力)与代价落在哪(成本)。Jolt 的路径编码多买到的是表达能力,多付的是位深这项上限;PhysX 的扁平下标少付了位深,代价是表达不了嵌套。哪一边更合适,取决于场景里有没有真正的层级结构,而不是取决于哪个引擎更「先进」。

还有两个方向可以一并放进对照里:

  • 2D 侧:Box2D 只有五种形状(对照见 Box2D 形状枚举、Bullet 的复合形状)(圆、胶囊、线段、多边形、链段),复合靠 b2PolygonShape 里带多个顶点的实心多边形近似,形状谱系比 3D 短得多。它的启发是:形状谱系的长短由几何维度决定,3D 里那些「锥形胶囊」「锥形圆柱」在 2D 根本没有对应物。
  • 绑定侧:Godot 用 Jolt 时,Shape3D 的 margin 在 Godot Physics 里是「向外扩」,而在 Jolt 里是「先内缩再等量外扩」,不增加外形尺寸(Godot 官方文档)。这与上一章讲过的 cDefaultConvexRadius = 0.05 是同一件事的两个说法。

8.2 十条反模式

# 反模式 代价 正确做法
1 拿 SubShapeID 当长期标识存起来 MutableCompoundShape 一改就失效(§6.3 实测:几何没动,ID 全变) 每帧重查;要跨帧记住就自己在子形状上挂稳定 user data
2 用 GetSubShapeUserData(id) 取 settings 里给的值 拿到的是叶子的 user data,不是那个值(§5.4) 走 GetSubShape(...).mUserData 或 GetCompoundUserData(index)
3 把 SubShapeID 当正序号打印或排序 它是「高位补 1」的位串,当有符号数读通常是负数 只当不透明令牌用;要显示就转十六进制
4 用网格或高度场做动态体 契约要求 MustBeStatic,撞上其它网格/高度场会被静默忽略(§2.3) 动态体用凸形状;凹几何改用凸分解出的复合形状
5 指望对网格或高度场调 GetVolume() / GetMassProperties() 它们恒返回 0(§2.1 实测),质量会是 0 用 mOverrideMassProperties 显式给质量属性
6 一个复合形状只放一个子形状 构造期直接报错(Compound needs at least 2 sub shapes…) 改用 RotatedTranslatedShape 或直接给形状加变换
7 让复合嵌套无限加深 深度撞 32 位就构造失败(§6.2 实测 depth 33) 把层级压平,或改用网格/凸分解
8 用「先旋转、再各向异性缩放」表达形状 会引入剪切,IsValidScale 判不合法(§4.5) 用轴对齐的缩放;确需斜切就烘进顶点
9 以为 ScaleShape() 会保真 它返回「最接近的合法产物」,子类型都可能变(§4.5 实测不合法也成功) 先用 IsValidScale 决策,再决定要不要缩放
10 假设「形状类型一样,窄相代价就一样」 同一动态形状换地面,代价最多差 4.06 倍;最贵与最便宜格子差 11.61 倍(§7.2) 用官方 instrumentation 先量形状对矩阵,再谈优化

8.3 三条处方

处方一,把形状类型当成性能预算的一部分来选,而不是当成建模结果。

同一种动态形状换一种静态地面,每次查询耗时最多差 4.06 倍;最贵与最便宜的格子差 11.61 倍。这个量级足够改变一个场景的帧率分布。做法很简单:先打开 JPH_TRACK_NARROWPHASE_STATS,跑一遍真实场景,看哪几个格子最贵,再回头调形状构成。

处方二,SubShapeID 只当不透明令牌;要长期标识就自己挂。

引擎给的 ID 是位置相关的:删一个子形状,后面全部左移。要跨帧记住「上次碰到的是哪一块」,正确做法是在创建时给每块子形状挂一份自己的稳定编号(走 GetCompoundUserData 那一路,或直接改用子形状自己的 user data),然后每次用 GetLeafShape 重新解引用。

处方三,遇到「形状明明在那儿却不碰」,先查三件事。

按概率排序:绕序(网格法线朝下会导致动态体穿下去,§7.4 实测)、MustBeStatic(网格或高度场被当成动态体,撞上同类会被静默忽略)、支撑面(没实现 GetSupportingFace() 的形状在裁流形时只拿到单点)。这三条都在本篇有落点,查完再动引擎。写成一段可以直接抄进项目的预防性检查:

// 【1】绕序:静态网格的法线必须朝向「会碰到它的一侧」,否则动态体会穿过去(§7.4 实测)
//     判据不是看代码,而是看行为:跑 300 步,检查动态体还在不在网格上方
// 【2】MustBeStatic:网格、高度场、平面不能当动态体(§2.3)
JPH_ASSERT(!(dynamic_body && (shape->MustBeStatic())), "这个形状必须挂在静态体上");
// 【3】缩放合法性:先问再改,不要指望 ScaleShape 保真(§4.5)
if (!shape->IsValidScale(scale)) { /* 决策:拒绝、改成均匀缩放,或把缩放烘进顶点 */ }

其中:第 【2】 条断言是本篇建议补上的(引擎自己没有这道校验,见 §2.3)。这三条的原理是同一条:形状的约束分三类,几何侧(绕序)、契约侧(必须 static)、参数侧(缩放合法),查的顺序就按这个来。

拿 Box2D 的谱系对照一下还能看出维度差异(Box2D 形状文档、碰撞总览):它一共只有五种形状,2D 里根本没有「锥形胶囊」这种需要旋转对称性的东西。

九、边界、演进与清单

9.1 本篇的边界

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

方向 本篇已给出 归谁
形状的表示 两张枚举(12 / 34)、GetVolume() 三档、复合三支、装饰器三件套 已闭环
形状的约束 三个「必须 static」的形状与它们的执行路径(查无运行时强制) 已闭环
命中的编码 32 位位语义、位串双向实测、位数公式 ⌈log₂n⌉、撞墙点 depth 33 已闭环
形状的算法 只登记「形状是支持函数提供者」 窄相那一章(GJK / EPA / 裁剪)
形状的序列化 只登记「有 SaveBinaryState 与 ClearCachedResult」 后续专篇(字节布局不在本篇范围)
自定义形状 只登记「类型层留 4 个位子、子类型层留 8+8 个位子」 后续专篇(实现教程)
软体与 GPU 只登记 EShapeType::SoftBody 这一项存在 GPU 那篇(Jolt 的 GPU 通路只服务发束头发)

这张表里已经给出了什么那一列有四行,对应本篇的四个可复算结论。读者想自己验的话只需要三样东西:官方源码、本篇的实验脚本、一台能编译 Jolt 的机器。

# 可复算的数 怎么复算
1 两层复合的 4 片叶子 ID = 0xFFFFFFFE / F9 / FB / FD,与「LSB 优先 + 高位补 1」的预测 4/4 一致 跑 bench_shape.exe,看 E1 段
2 位深 = ⌈log₂n⌉;二元嵌套 depth 33 时构造失败,报错原文与源码逐字一致 看 E2 段
3 删一个子形状后余下的 ID 全部改变(胶囊 FD→FE、圆柱 FE→FF),且 GetSubShapeUserData 返回 0 看 E3 段
4 形状阶梯 18 格,最贵与最便宜差 11.61 倍(圆柱对高度场 16260.5 tick 对球对网格 1401.1 tick);查询数三次一致、耗时抖动 4.3%–35.2% 看 E0 段与 shape_stats.csv
本篇四个可复算结论的清单
图 7-48本篇四个可复算结论的清单:位串双向验证、位深公式与撞墙点、Mutable 的 ID 漂移、形状阶梯首尾差 11.61 倍(另附三次重复的抖动),每条都给出复算入口(实验脚本的段落与官方 CSV) @@ 本系列绘制

9.2 演进:与形状有关的版本变更

说白了,把 Docs/ReleaseNotes.md 与 Docs/APIChanges.md 里与形状有关的条目按月历排开,能看出一条很清楚的规律:变化集中在两处,一是有没有 RTTI,二是子形状 ID 能表达什么。

版本 条目 出处
v1.0.0(2021-10-04) 移除 Shape 的 RTTI,改用 GetType / GetSubType APIChanges.md:158
v1.0.0 HeightFieldShapeSettings::mBlockSize 改为运行期再细分一次 APIChanges.md:155
v2.0.0(2022-05-02) ContactConstraintManager::CombineFunction 增加两侧的 SubShapeID 参数 APIChanges.md:136
v3.0.0 ShapeFilter 签名变化,不再对网格与高度场内部的三角形调用 APIChanges.md:105
v4.0.0 修复 RotatedTranslatedShape::GetPosition 返回错误值 ReleaseNotes.md:416
v5.0.0 新增 ConvexShape::ESupportMode::Default APIChanges.md:62
v5.1.0 新增 Shape::MakeScaleValid(把任意缩放收成合法缩放) ReleaseNotes.md:255
v5.2.0(2024-08-23) 新增虚函数 Shape::GetLeafShape,本篇 §5.4 那个标准解引用姿势就是它 APIChanges.md:43、ReleaseNotes.md:187
v5.2.0 新增 HeightFieldShape::GetSubShapeCoordinates(按 ID 取三角形坐标) ReleaseNotes.md:188
v5.3.0 新增 MeshShapeSettings::mBuildQuality(构建快 vs 运行快) ReleaseNotes.md:122
v5.3.0 MutableCompoundShape 支持在指定索引插入子形状 ReleaseNotes.md:148
v5.4.0 修复 ConvexHullShape::CollideSoftBodyVertices 报错最近边的问题 ReleaseNotes.md:89
v5.5.0 修复 TaperedCylinderShapeSettings 非法参数导致的内存泄漏 ReleaseNotes.md:71
v5.5.0 之后(未发布) HeightFieldShapeSettings::mBitsPerSample > 8 会让二进制格式多 1 字节 APIChanges.md:12
与形状有关的版本演进时间线
图 7-49与形状有关的版本演进时间线:从 v1.0.0 移除 RTTI、改用两个枚举,到 v5.2.0 新增 GetLeafShape 虚函数,再到 v5.3 的网格构建质量与可变复合的指定索引插入。主线是「类型标识」与「子形状寻址」两件事 @@ 本系列绘制

这张表里最值得记住的两格加粗了。v1.0.0 那次移除 RTTI 是本章 §1.3 那个「枚举顺序不能重排」的历史起点;v5.2.0 新增 GetLeafShape 说明「按路径解引用」这件事在 2024 年才被正式做成虚接口。在那之前,自定义形状要自己处理 remainder。

9.3 资源清单

官方文档

  • Jolt Docs/Architecture.md: ## Shapes(第 166–189 行)、### Sub Shape IDs(191–211)、### Dynamic Mesh Shapes(213–221)、 ### Creating Shapes(223–248)、### Saving Shapes(250–307)、### Convex Radius(309–313)、 ### Center of Mass(315–358)、### Scaling Shapes(360–378)、### Creating Custom Shapes(380–396)
  • Jolt Docs/ReleaseNotes.md/Docs/APIChanges.md

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

社区与横向对照

本机一手产物

  • evidence/bench_shape.cpp(六组实验,含三条纪律的源码级注释)、evidence/build_bench_shape.ps1(复现五步)
  • evidence/bench_shape_run_20261004.txt(原始输出)、evidence/shape_stats.csv(官方 instrumentation 的 19 行 CSV)

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

下一篇往形状的下一层走:约束(Constraint)家族。 形状回答「几何是什么」,可两个体碰在一起之后要怎么动,是另一套东西:接触约束、距离约束、铰链、滑轨、 锥形摇摆、六自由度,一共十几类,它们背后共用同一套求解原语。 那条路径上也有它自己的代价故事:约束的数量与结构决定了求解的形态。

With Best Wishes.