站点链接:技术博客页 | 公众号:溯光的图形笔记
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)回答「哪些体的包围盒重叠了」,窄相回答「碰上了没有、碰得有多深、接触面在哪」。这两层都有一个共同前提:体身上挂着一个形状。宽相算的是这个形状的包围盒,窄相要的是这个形状的支持函数、体积、质心、以及「打到哪一块」的分辨能力。
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)。
这条清单本身就是一个设计声明:几何是必须的,能力可以不给。一个形状如果不实现 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 行)里有完整说明,它解决的是一个很实际的问题:构造会失败。
凸包点数不足、复合形状只有一个子形状、嵌套太深、缩放矩阵含零,这些情况都会让 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),「叶子」这个概念正是靠这一层重写才立住的:基类默认实现服务的是叶子形状,复合形状的职责是把它一路传下去。
0.4 本篇的三条主线与边界
OK,三条主线各自的落点写清:
- 谱系的代价:形状的复杂度不是学术分类。
GetVolume()分三档(解析式 / 数值 / 恒 0), 恒 0 的那一族正好是「非封闭实体」;「必须 static」是一条契约,谈不上建议。落点 §二。 - 编码的代价:
SubShapeID是一条路径压进 32 位,位从最低位起逐层压入,根级不占位, 没用到的高位一律补 1。落点 §五,本机把位串逐位打出来与预测比对。 - 边界的代价:位宽是硬上限,撞墙时
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,而这类错误不会报错,只会在某次射线命中时给出错误的叶子。
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 倍」,那张表的坐标轴正是这两张枚举。一旦重排枚举,所有历史统计数据的坐标含义全部改变,而且这种错位不会报错,只会让读数悄悄错掉。
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 要讲的东西。
二、复杂度阶梯:谁有体积、谁必须静止
形状谱系最容易被当成一张「有什么类型」的清单。可清单没有回答工程上真正要问的两个问题:这个形状能干什么、不能干什么。这一节用两个切口把阶梯量出来:体积怎么算,以及谁能动。
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 |
那张「恒 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 行还给复合形状留了一条免责说明:复合形状的体积可能不准,因为子形状可以互相重叠,而这条没被扣掉。
这条「非封闭 → 没体积」的推论有个直接工程后果:你不能对网格形状调 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) | 不是(三角形与格子都是子形状) |
这张表说明为什么 §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 |
本机要在这里记一条诚实的发现:全仓库对 MustBeStatic() 的引用,只剩声明本身与两处转发(CompoundShape 与 DecoratedShape 各自把它转发给内层),PhysicsSystem.cpp 里没有任何调用点。所以这条约束在执行层面靠的是「使用者自觉 + 出问题时触发断言」,创建期并没有这么一道校验。
我们顺手把「不遵守会怎样」的落点也找到了:CollisionDispatch.cpp 第 23 与 29 行是两个默认的占位碰撞函数,函数体只有一句 JPH_ASSERT(false, "Unsupported shape pair");。网格撞网格、网格撞高度场这类不支持的对,就是被这两个函数兜住并忽略的。
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在运行期还会再细分一次,所以这个参数的实际含义与直觉不同:你设的值不是最终值。
两种形状还有一个共同的「结构特征」值得单记:它们的 GetSubShapeIDBitsRecursive() 返回自己那一层的位数,而不是 0(MeshShape.cpp:475-477、HeightFieldShape.h:144)。对引擎来说,网格的每个三角形、高度场的每个格子都算「子形状」,只不过它们是一层平铺,谈不上嵌套的树。
最后回到 §2.3 那条契约上。网格与高度场之所以被要求 static,根因就在这里:它们的子形状数量是量级级别的(几万到上百万),而动力学每一步都要更新变换、重算包围盒。让这类形状动起来,代价是把整棵结构每帧重建一遍,远不止多花几毫秒。
Penang island.stl(CC BY-SA 4.0,Cmglee;由 Commons 缩略图接口渲染为 PNG)三、复合三支:树 / 列表 / 空
一对一的形状讲完了,接下来是「一堆形状凑成一个」。Jolt 给了三支,它们的功能看起来一样(都是「一块几何由多个子形状拼成」),差别全在组织方式换来的代价:查询快不快、改得动改不动、以及没有几何时拿什么占位。先把三支并排看一眼,再逐个拆它们各自买到了什么。
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 会把这条上界量出来。
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)说的就是这条取舍:树买查询,列表买编辑。
这里有一条官方明确承认的副作用,写在 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 |
第一条报错带着一句「你该改用 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");
两处注释值得停下来说。mPositionCOM 存的是子形状的质心位置,不是它的原点位置,所以摆放子形状时要减掉内层质心,这个坑第 §4.3 节还会遇到。mUserData 被塞进填充字节里,注释直接说明了原因(不占额外空间)。static_assert 把结构体大小锁在 40 字节(8 字节指针平台),顺带挡住了编译器可能的意外填充。
四、装饰器三件套:不改几何能改什么
装饰器(decorator)是三种「包一层」的形状:ScaledShape、RotatedTranslatedShape、OffsetCenterOfMassShape。它们有一个共同点:不复制内层的几何,只在外层记录一份变换或偏移,所以额外的内存开销几乎为零,代价是查询时多一次转发。三者的差别在于各自多加了哪一层语义,而它们共享的那一层转发关系值得先看一眼。
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));
// 只要非对角元素非零,就说明缩放与旋转不兼容
推导写出来就是:内层变换是「先缩放再旋转」,那子空间看到的缩放是 $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)。
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
体积不变(本机实测 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 那套判定的服务对象 |
唯一的例外格是 Empty 含零缩放仍然合法,因为它压根没有几何可缩。
最锋利的一格在表外。本机另外测了「先旋转后缩放」:内层是绕 Z 轴转了 45° 的 Box,父空间给 (2,1,1):
RotatedTranslatedShape::IsValidScale(2,1,1) = 不合法
ScaleShape(2,1,1) 结果 = 成功,产物 RotatedTranslated
判定它不合法,但照样给你一个结果。ScaleShape() 的语义是「尽量返回一个匹配该缩放的形状」(Shape.h:342 的注释原文),不是「原样保真」。这一对行为放在一起看,才是这套 API 的真实契约:合法性查询用于决策,ScaleShape 用于兜底。
五、命中的代价:32 位路径编码
前面四节讲的是「有哪些形状、怎么组织」。这一节换一个问法:一堆形状拼在一起之后,怎么指出是哪一块被打中了。
先看别家的答案。PhysX 的射线扫掠结果里有一个 shapeIndex,它就是 actor 形状数组的下标;Bullet 的复合形状用 child index,也是数组下标。这两个答案的共同点是一层扁平索引,能表达「第几块」,表达不了「哪条路径上的第几块」。
Jolt 的答案不一样。它把整条路径压进一个整数。
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 值。
最后一个语义在 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 完全一致
看那四行二进制:从右往左数,第一位(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 行):它们的「子形状」是三角形或格子,一层就到底。
自己这一层的位数怎么算?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 位全花在深度上,结果只能表达 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 会沿着路径一路转发到叶子。
这条链带来了本机实测到的一个反直觉点。在 §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.
社区里那条有名的困惑正好对得上。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 解引用。
坑在「解引用之前」。这里要接上一章的结论:相似法线的流形会被引擎合并(官方文档 Architecture.md 第 959 行那条,上一章引过)。如果你按子形状区分逻辑,而引擎把不同子形状的接触点合并进同一个流形,那么回调里拿到的 ID 与你预期的分块对不上,表现就是「明明撞的是前轮,触发的却是车身的逻辑」,再往下就是抖动与穿透。
社区里有一条实践记录正好讲这件事:用 SubShapeID 区分命中子形状时,需要把体的 mUseManifoldReduction 关掉,关掉之后再按 ID 分发,行为才对得上(GitCode 上的中文实践记录)。
顺带把另一条容易踩的口径也说清: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 |
注意二元嵌套那一列:深度每加一,位数加一,而叶子总数只加一。当嵌套很深时,位数增长是线性的,而能表达的叶子数是线性的,唯独 32 位这个上限是固定的。
把这两张表放在一起,能算出一份自己场景的位深预算。做法只有两步:
- 把复合形状的层级画出来,标出每层有几个子形状;
- 每层取 $\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 行读到的字符串逐字一致,也就是源码里那条检查真的在构造期拦住了。
这里要把话说清: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 装的是列表下标,删掉一个,后面所有下标左移一位。
这条实测给工程上留了两条可以直接用的结论。第一,不要把 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 倍)。官方那套二维表把窄相按子类型分开记账,才让「形状与形状对之间的差异」变得可见。自己掐表量不到这张表,这不是精度问题,是分辨率问题。
三条纪律都在实验里踩出来过,值得单独记:
- 一律禁止睡眠(
mAllowSleeping = false)。上一章已经踩过:体睡着之后窄相根本不被调用,稳态量到的是「什么都没做」。 - 必须让体一直动。这一条是本章新踩的:第一版把体直接摆在离地 0.2 米处,结果盒子、圆柱、凸包很快静止,接触对被体对缓存吃掉,600 步只剩下 36 次查询。给所有动态体加一个绕斜轴的角速度之后,每格的样本才够用。缓存压制本身也成了本章的一条结论(见 §7.3)。
- 只允许「静态 × 动态」这一种配对。第一版把动态之间的碰撞也打开了,于是同类动态体互相碰,表格里混进了
TaperedCapsule × TaperedCapsule这种与地面无关的格子。把MOVING × MOVING从对过滤器里关掉之后,每个非空格子都严格等于「地面 × 动态形状」。
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× |
这张表里最直接的一条结论:最便宜与最贵的格子差 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) |
这条给出一条读表纪律:这份阶梯要按「量级与排序」读,不要按精确倍数读。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× |
这张横切表里有两条可以直接拿去用的观察。第一,没有一种地面是普适最优的:球与凸包在网格/高度场上更便宜,胶囊与锥形胶囊在盒地面上更便宜(2778.7 与 4082.7)。第二,对接触类型最敏感的是盒与圆柱(4.06× 与 3.62×):它们落在平面上是 4 点流形,落在网格或高度场的三角形上还要处理边与角。
所以「选哪种地面」这件事没有一句通吃的答案。真正可操作的做法还是那条:先量,再改。
7.3 怎么读这张表:先说样本
说白了,看那张表的「查询数」一列,会发现差异很大:球和胶囊那几行在 1700 上下,而 Box × Box 只有 36、Cylinder × Box 只有 84。
这不是实验失误,是缓存压制:盒子和圆柱落下去之后很快进入「姿态不再变」的状态,体对缓存直接把这一对挡在窄相之外(上一章 §6.2 量过这件事,缓存开与关的调用数差 123 倍)。球、胶囊、锥形胶囊一直滚,接触对每步都要重算,所以样本充足。
这条现象反过来给了一个读表纪律:单看「每次查询耗时」会低估静止形状的真实开销,因为被缓存挡掉的那些查询根本没发生。要横向比形状,得把「样本量」和「缓存是否生效」一起看,或者干脆像本章这样给体加角速度让缓存失效。
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 表达轴缩放,剪切被明确拒绝 |
无对应包装,靠体变换 | 无对应包装 |
这张表最值得记住的是第三行。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 |
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 |
这张表里最值得记住的两格加粗了。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
官方源码(逐条核过行号)
Jolt/Physics/Collision/Shape/Shape.h: 两张枚举(第 54–72 / 74–124 行)、类型表与名字表(127–137)、基类接口(199–445)Jolt/Physics/Collision/Shape/SubShapeID.h: 位宽(28 / 31 / 36)、取位与补 1(42–49)、空值(86)、写位(93–100)、SubShapeIDCreator(111 / 133–135)Jolt/Physics/Collision/Shape/CompoundShape.h:SubShape结构体(238–246)、GetCompoundUserData(260)、位数计算(329–334)、user data 注释(41)…/CompoundShape.cpp: 位深累加(117–124)、GetSubShapeUserData转发(152–162)…/StaticCompoundShape.cpp: 三条构造期报错(184–187 / 341–346 / 349–351)…/ScaleHelpers.h:CanScaleBeRotated(51–69)、RotateScale(75–80)…/HeightFieldShape.h: 类注释(113)、MustBeStatic(132)
社区与横向对照
- jrouwe 讨论区 #473 · Shape filters and compound shapes: 作者本人讲位编码(「每层从右边压入若干位」)与那个「拿到随机子形状 user data」的困惑
- jolt-jni 文档 · Managing collisions:
subshape IDs are assigned in a non-obvious fashion与getLeafShape+ remainder 的标准姿势 - Godot 官方文档 · Using Jolt Physics: margin 语义差异(Godot Physics 外扩、Jolt 先缩后膨)
- EZ Engine · Jolt Collision Meshes: 凸包 250 顶点约束与「凹网格先凸分解」的工程实践
- PhysX
PxRigidBodyExt(shapeIndex)/ Bullet 经 Minie 转述的CompoundCollisionShape(child index)
本机一手产物
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.