站点链接:技术博客页 | 公众号:溯光的图形笔记
一条试车跑道被反复碾过,路面的起伏没有变过,测试车一圈又一圈地跑,把每一次的悬挂长度、每一个轮子的触地次数都记下来。只有当场地的可重复性成立,改掉一档参数之后读数变了多少,才是一件说得清的事。这一篇要谈的「成品件」,正是从这块场地开始的:引擎把角色(character)与载具(vehicle)这两件事各自做成了可以直接拿起来用的组件,组件好不好用,先得有一块能量它的场地。
先看题图。
US Army Small Unit Support Vehicle testing at CRTC.jpg(Public domain)题图里是一辆履带式小型支援车在试验场上越过起伏地形(题图出自 Commons 的试车场文件页)。照片留住的是一圈里的一瞬,而调参需要的恰恰是它背后那一圈又一圈的重复。角色侧的量法也一样:同一个胶囊、同一段路、同一套输入,反复跑,再把某一档参数单独改掉,看读数往哪边挪。
本篇的两条主线,一条在角色侧,一条在载具侧。角色侧问的是「一个不放进刚体求解里的角色,凭什么能贴着墙走、能上台阶、能在下坡时贴住地面」;载具侧问的是「一辆车到底由几个可数的部件拼出来,每个部件的读数落在哪一段」。角色侧的落点是 §零至§五,载具侧的落点是 §六至§九,横向对照与边界收在 §十至§十一。这一段只把角色侧的前半讲透,落到 §零至§三。
与上一篇的接口要先交代。本系列的第十一篇把连续碰撞检测(continuous collision detection,CCD)讲到了边界,那里留了一句话:角色控制器(character controller)不在宽相(broadphase)里,也就不走那条 CCD 路径。上一篇留下的这句话,本篇要写全:官方原文的准确口径是「默认没有仿真刚体、常规射线打不到它」,而给它配上内层刚体(inner body)之后,高速的 LinearCast 物体就穿不过去了。全系列目录里,本系列这一篇排在 CCD 与确定性之后、柔体与发束之前。
版本口径写清一次,本篇给的所有行号都按它取。本机快照是 v5.5.0-183-g765845d6,它是 tag v5.6.0(e77f1755)的祖先,两者只差 2 个提交、只动了 4 个文件。本篇要引的角色与载具文件在这两点之间逐字节相同,所以文中的行号对 raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/… 这套链接同样成立;版本条目本身见 官方 ReleaseNotes。
这份口径对「成品件」这一篇尤其重要,因为角色控制器与载具约束的行为高度依赖参数的当前值:同一段代码换一档留白、换一种探测器,读数就会变。把版本钉死、把参数逐项报出,别人才能把同一条读数复现出来。本机实测的十一组实验(E0 至 E10)都按这条口径跑,正式档与两次复跑在剔除耗时字段之后逐行一致。
边界也先划一道。刚体与形状(body、shape)的设置在更早的篇目,约束(constraint)与求解(solver)在第八篇,CCD 与状态录放在第十一篇,柔体与发束(soft body、hair)在第十三篇。本篇只碰角色侧的两条路、三件套、四把旋钮、内层刚体,以及载具侧的约束、探测器、悬挂与控制器。
OK,把本系列的范围收好,从「成品件到底指哪几件事」这个入口进去。
入口:成品件是什么意思
一句话说清这一节:前十一篇拆的是「一步怎么解」,这一篇换一个问法,看引擎自带的成品件各自管哪一段、边界又划在哪里。
0.1 引擎已经把角色和载具做成了组件
先来看看,前十一篇在解的是哪一类题。那十一篇反复围绕一组刚体(body)与一组约束(constraint),问求解器(solver)怎么把它们从这一步推到下一步。这一节的问法变了,问的是引擎已经把哪些角色与载具做成了可以直接接入的组件,以及这些组件各自把边界划在哪里。
官方的角色控制器(character controller)有两条路,一条在 Character 里,一条在 CharacterVirtual 里;载具(vehicle)这一侧是一套约束加若干部件,装在一个叫 VehicleConstraint 的类里。这两组东西合起来就是本篇要盘的「成品件」:它们已经脱离了单一机制的层面,是引擎替使用者装配好、可以直接接入的东西。
官方在 Architecture.md 的角色段把 CharacterVirtual 比最简一档多出来的能力列成六条:贴墙滑动、与电梯和移动平台互动、增强的陡坡判定(站在漏斗形的地形里不会被误判为可站)、ExtendedUpdate 里的上台阶、ExtendedUpdate 里的下坡贴地、以及可指定本地坐标系(于是可以在飞船里走动)。紧接着又列了它必须付的四笔账:碰撞回调走 CharacterContactListener、与传感器的接触也只走这个监听器、角色与角色之间的碰撞要自己接 CharacterVsCharacterCollision、以及常规的碰撞检查打不到它。六件事与四笔账合起来,就是「成品件」这个词在这一篇里的具体含义。
把这六件事与四笔账摆在一起看,这套机制的设计取向很一致:能省掉的开销都省掉,需要世界配合的地方则全部留成可选项。
0.2 两条主线的落点
好了,两条主线的落点在正文里分成两段。角色侧从这一节走到 §五,依次是两条路的定义与实测、三件套的编排、四把旋钮的读数、内层刚体把角色接回世界、以及角色互撞与角色的确定性。载具侧从 §六走到 §九,依次是一辆车的部件分解、三种探测器的几何差别、悬挂三长度与轮胎力、以及轮式、履带、摩托三个控制器。横向对照与边界收在 §十至§十一。
这一段把角色侧的前半摆开,也就是两条路、三件套与四把旋钮这三件,它们恰好是本节边界栏里落在最前面的三格。
0.3 本篇的边界与实验口径
说白了,边界声明写进正文,供后面的交叉检验用。
实验口径一并交代,供复现。睡眠(sleeping)一律关闭,避免睡眠把本该测的体从活跃集合里摘走。角色是运动学(kinematic)物体,它不会自己受力运动,必须由外部每帧喂一次速度,本篇所有角色实验都把这一步写在同一处、口径一致。每一轮只改一个变量,并把该变量的所有相关设置值一并报出。编译沿用前面几篇的 Jolt.lib 与同一套开关集,零补丁、零插桩(instrumentation)。一处口径必须写清:本机库带着 /DJPH_USE_FMADD,所以本机的实测只覆盖同一份二进制内的确定性,跨平台那一档不在本机读数里。
实验场景的公共取值也在这里交代:角色实验多以 60 步、4 m/s 的输入跑完,上台阶那一组把台阶高扫 0.10、0.20、0.30、0.40 m 四档,下坡贴地那一组把坡度扫 10、30、50 度,贴墙那一组走 2 s、输入 3 m/s。凡是照官方单元测试场景取值的实验(本篇是 E1、E2、E3、E6),正文与图注都会写明「这是官方用例场景的本机复现,不是官方数值」。
源码落点:角色两条路的定义在 Architecture.md 的角色段,官方对角色与载具样例的说明在 Docs/Samples.md,载具那一侧的约束定义在 VehicleConstraint.h。其中:Architecture.md 给出 CharacterVirtual 的六件事与四笔账,Samples.md 写明载具这一类用射线或形状扫掠探地、并模拟引擎、变速箱、差速器与悬挂,VehicleConstraint.h 是载具成品件本身的入口。
一、两条路:刚体式与碰撞查询式
言归正传,一句话说清这一节:同一个胶囊形状的角色,官方给两条实现路,一条把角色做成配了滑动的刚体,一条只用碰撞查询把它挪动,两条路的差别落在谁进宽相、谁能被射线打到、谁决定它什么时候动。
1.1 官方给的两条定义
先读官方给出的两条定义。Architecture.md 第 705 行是刚体式那一条:The Character class is the simplest controller and is essentially a rigid body that has been configured to only allow sliding movement.(Character 是最简单的一档,本质是一个被配置成只允许滑动运动的刚体。)第 709 行是虚拟式那一条:The CharacterVirtual class is much more advanced. It is implemented using collision detection functionality only (there is no rigid body).(CharacterVirtual 高级得多,它只用碰撞检测功能实现,没有刚体。)
Architecture.md:703–709):Character 是「配成只允许平移、不允许旋转」的刚体,随其它刚体一起被仿真;CharacterVirtual 只用碰撞检测功能实现、不在世界里,因此对刚体不可见@ Jolt Physics v5.6.0 官方文档原文(MIT)刚体式那一侧的另一半口径写在 Character.h 第 43 至 45 行:它使用一个单独的刚体,通常配一个胶囊(capsule)形状,来模拟角色的移动与碰撞;这个刚体是 keyframed(关键帧驱动)的,由应用设置速度来驱动它,求解器(solver)负责把它与其它刚体一起推进。定义里的两个短语决定了后面所有差别,一个是「只允许滑动运动」,一个是「没有刚体」。
Character.h:42–47 + 63–66):用一个单独的刚体(通常配胶囊)模拟移动与碰撞,是 keyframed 物体,由应用设置速度驱动;加入与摘出是显式两步@ Jolt Physics v5.6.0 源码原文截取(MIT)这两句注释里还有一个容易滑过去的词:keyframed。它说的是这个刚体的运动学(kinematics)由外部给定,而不是由求解器算出速度再积分。角色走多快、往哪走,都由应用每帧写进去,求解器只负责让这段位移守法。这个分工把角色与普通动态刚体区分开,也解释了为什么刚体式的角色在爆炸与载具旁边会被自然地推动:那些外来的冲量改的是同一个刚体的速度,而速度恰好就是应用每一帧要覆写的那一格。
两条路的取舍对应两种使用场合。角色的移动如果希望完全由游戏逻辑把控,不希望物理求解给角色带来侧向的推挤与抖动,虚拟式更合适。角色如果希望被爆炸、被载具、被其它刚体自然地推动,刚体式更省事,因为它本来就活在物理世界里,别的体撞到它时会得到正常的接触(contact)响应。
1.2 E0 两组读数
下面开始,把两条路换成两组读数。E0 用的是同一个胶囊(capsule)、同一段路程、60 步、4 m/s 的输入,路径上放着一个 5 kg 的箱子,看角色走到哪里、箱子被推出多远。
刚体式那一行的末位是 1957.1007 mm,虚拟式那一行的末位是 1776.8433 mm;沿途推出箱子的位移,前者 1537.1006 mm,后者 1396.8434 mm。两行还各带一组附带读数:刚体式的角色质量 80 kg、整段 60 步的仿真耗时 15.494 ms;虚拟式是 70 kg 与 2.172 ms。耗时一类读数随时机浮动,这里只看量级;位置与位移类读数在三次复跑里逐行一致。
两组读数合起来回答两件事。第一件,虚拟式确实能推动一个动态刚体,说明它虽然不进求解器,接触与受力仍然被完整地算了一遍。第二件,虚拟式比刚体式少走了一截,这一截的来处是它贴近几何体时固定留下的那道缝,§三会把这层留白量出来。
E0 这一组是本篇自己搭的场景,没有对照官方数值,两条路跑的是同一段路与同一套输入,读数取自本机正式档。位置与位移类读数在两次复跑里逐行相同,说明这组差别来自两条路本身,运行抖动这一因素可以排除。
1.3 两条路的三处差别
依然是惯例,把两条路的差别归成三处。第一处是谁在宽相(broadphase)里:刚体式的角色是一个真正的刚体,它被登记进物理世界、参与宽相,会被求解器当作可碰撞对象;虚拟式默认没有独立登记的形状,常规的碰撞查询扫不到它。第二处是谁能被射线(ray)打到:刚体式的角色是一个普通刚体,CastRay 自然打得到;虚拟式默认打不到,要打就得给它配内层刚体(inner body),这一段在 §四展开。第三处是谁决定它什么时候动:刚体式由求解器在每一步里推进,除了速度还会受重力与接触的合力;虚拟式完全由应用每帧喂速度,引擎只负责在给定速度下把它挪到一个不穿模的位置。
正餐开始,三处差别还能反过来读一次:谁在宽相里,决定了谁能被别人撞到;谁能被射线打到,决定了谁会被玩家的准星瞄中;谁决定什么时候动,决定了谁能在网络同步里被直接校正位置。把这三问对齐到接口,刚体式那一侧靠一个被登记进世界的刚体回答了全部三问,虚拟式那一侧则需要应用逐个补齐,§四的内层刚体补的正是其中第二问。
这里要补一处官方样例里必须写对的地方。本机首跑崩在刚体式角色的析构上,根因是它只调了一次销毁刚体,而移除刚体有一条硬前提:这个体必须已经不在宽相、并且处于非激活状态。官方的处置写在 Samples/Tests/Character/CharacterTest.cpp 第 20 行,先把角色从物理系统里摘出来,再让它出作用域。这是官方样例的写法,不是本篇自创的用法。
源码落点:两条路的定义在 Architecture.md 第 705 与 709 行,刚体式的定义在 Character.h 第 43 至 45 行,先摘出再析构的官方写法在 CharacterTest.cpp 第 20 行,两条路各自的公开接口在官方 CharacterVirtual 类页。其中:文档两行给出分界,Character.h 给出刚体式那一半口径,CharacterTest.cpp 给出官方样例里必须补的那一步,类页给出两条路各自的公开接口。
二、三件套:Update、StickToFloor、WalkStairs
我们正式开始,一句话说清这一节:CharacterVirtual 的主更新只管接触扫描、沿墙滑动与穿透恢复,上台阶与下坡贴地是另外两个函数,三个调用由 ExtendedUpdate 按顺序编排,每个调用前面都压着一句条件判断。
2.1 编排写在 ExtendedUpdate 里
先读编排本身。CharacterVirtual.cpp 第 1796 行是 ExtendedUpdate 的入口,它按固定顺序串起三个调用,并在每个调用前面写一句条件。
ExtendedUpdate 的编排原文(CharacterVirtual.cpp:1796–1800 + 1808–1828 + 1879–1883):Update 无条件跑;StickToFloor 只在「更新前站得住、更新后落空」且没有向上速度时才调;WalkStairs 只在「想走却没走够」且 CanWalkStairs 为真时才调@ Jolt Physics v5.6.0 源码原文截取(MIT)编排里的分工可以逐句读:Update 是主体,负责接触扫描、沿墙滑动与穿透恢复。StickToFloor 只在「更新前站得住、更新后落空」并且竖直速度落在一个极小阈值以下时才被调用。WalkStairs 只在「想要的水平位移没走够」并且前置判据 CanWalkStairs 为真时才被调用,它的方向从当前这一批接触里挑一个最挡路的陡坡接触算出来。
这套机制的写法值得留意:ExtendedUpdate 自己不实现上台阶或贴地,它只负责判断「这一步要不要做这件事」,把具体动作留给对应的函数。判断写在外面、动作写在里面,使用者可以在不调 ExtendedUpdate 的情况下只用 Update,代价是上台阶与下坡贴地这两件事都没有,E1 的 update_only 那一组量的就是这个代价。
2.2 三个函数各自的触发条件
开始正文,把三件套各自的触发条件摆开。主更新 Update(CharacterVirtual.cpp 第 1442 行)每一步都会跑,它做的事是扫出这一步可能碰到的接触(contact)、把角色沿墙面滑动(trace along wall)到位、并处理已经发生的穿透(penetration)。StickToFloor(第 1768 行)管的是下坡:角色这一步原本站得住,下一步的支撑面(supporting surface)因为坡度下移而落空,如果竖直速度已经小到接近零,就把它往下贴回地面,避免出现一步一蹦。WalkStairs(第 1611 行)管的是上台阶(walk stairs):先抬脚(mWalkStairsStepUp)、再向前探(mWalkStairsStepForwardTest)、最后向下落,用一次三段扫掠替代一般人抬脚的动作。
先看主更新的入口。它短得出意料,真正干活的是一句「把形状沿速度滑过世界」。
Update 的入口原文(CharacterVirtual.cpp:1442–1446 + 1454–1461):第一句是零步长直接返回,随后开一次接触变化跟踪,再把形状沿速度「滑」过世界。上台阶与贴地都不在这里,它们排在它之后@ Jolt Physics v5.6.0 源码原文截取(MIT)这一段解释了为什么上台阶与贴地必须另开两个函数。主更新只负责「按速度滑过去」这一件事,滑不过去的坡它不会替你抬脚,滑下去的坡它也不会替你贴住。三件套的分工从这十几行代码就定下来了:滑是默认行为,抬脚与贴地是额外的两个动作,各自需要一次独立的扫掠。
走台阶那一段这里也提前看一眼,它的第一段是「往上抬」。
WalkStairs 第一段的原文(CharacterVirtual.cpp:1611–1614 + 1616–1623):三段扫掠的第一段是「往上抬」,注释写明这一段的校验故意放宽,忽略初始碰撞与角色侧面的留白@ Jolt Physics v5.6.0 源码原文截取(MIT)那句注释交代了一个不显眼的取舍:抬起来那一段的碰撞校验故意放宽,忽略初始就重叠的接触与角色侧面的留白。放宽的理由很实在,角色在上台阶前往往已经和台阶侧面贴着了,如果按常规校验,抬起的第一步就会被这段贴着的关系判成失败。放宽之后,抬脚才真的抬得起来,后面两段(向前探、向下落)再做严格的落地判定。
三个函数之所以这样排,是因为贴地与走台阶都依赖主更新先算出这一步的接触与状态:贴地要读更新后的支撑状态,走台阶要从更新后的接触表里挑方向。编排参数一共六项,装在 ExtendedUpdateSettings 里,包括贴地下探的 mStickToFloorStepDown{0,-0.5,0}(下探 0.5 m)、抬脚高度的 mWalkStairsStepUp{0,0.4,0}(抬脚 0.4 m)、前探下限 mWalkStairsMinStepForward=0.02 m、前探测试距离 mWalkStairsStepForwardTest=0.15 m、判定前向接触的角度余弦 mWalkStairsCosAngleForwardContact(对应 75 度)与额外的下落补偿 mWalkStairsStepDownExtra=0。
CharacterVirtual.h:428–437):下探 0.5 m、抬脚 0.4 m、最小前移 0.02 m、前探测试 0.15 m、水平夹角余弦 75 度、额外下探为零。抬脚那一项默认就能上一级四十厘米的台@ Jolt Physics v5.6.0 源码原文截取(MIT)下面开始,把六个默认值的量级逐个对一遍,它们决定了「成品件」开箱即用的行为。下探 0.5 m 对应一个人身高 1.35 m 的胶囊能容忍的落差,比一级普通台阶深得多。抬脚 0.4 m 是这套默认值里最宽松的一项,意味着默认就能上一级四十厘米的台。前探下限 0.02 m 与测试距离 0.15 m 这一对是为高频更新准备的,E1b 里 240 Hz 与 360 Hz 两档靠的正是它们。75 度那个余弦把「还算得上地面」的法线范围收在一个锥里,比坡限的 50 度宽,因为它的用途是判断前探方向而不是判断能不能站。
2.3 E1 四档台阶高
正餐开始,把上台阶换成四档读数。E1 对齐官方的 TestWalkStairs 用例,那份用例的台阶(stairs)高 0.3 m 共十级,四个更新频率 60、120、240、360 Hz,官方还留了一处对照:把 mWalkStairsStepUp 设成零就卡在台阶口。本机把台阶高另扫成 0.10、0.20、0.30、0.40 m 四档。
CharacterVirtualTests.cpp:412–471):台阶高 0.3 m 共十级,四个更新频率 60、120、240、360 Hz;用例自己留了一处对照,把 mWalkStairsStepUp 设成零就卡在台阶口、位置等于「半径加留白」@ Jolt Physics v5.6.0 源码原文截取(MIT)开 ExtendedUpdate 时,四档全部上得去,climbed=1,到达步号分别是 49、65、80、93,末位 z 是 13630.6787、13452.1523、13349.5703、13360.0381 mm。只调 Update、不调后面两个函数时,四档全部 climbed=0、reached_step=-1,末位 z 落在 -170.6809 到 -320.0000 mm 之间,也就是一直没能跨过第一级。这是官方用例场景的本机复现,不是官方数值,官方用例只断言布尔与容差。
ExtendedUpdate 时四档全部上得去(到达步号 49、65、80、93),只调 Update 时四档全部卡在第一级;频率越高,过程中的最高点越贴台阶顶面。这是官方用例场景的本机复现,不是官方数值@ 本机基准实测(原创)四档读数回答的是同一件事:上不上得去,差在有没有那一次上、前、下的三段扫掠,与输入速度的高低关系不大。台阶这个实物能帮上忙的地方,在于它把「上」这个动作拆得很清楚。
Escaliers forme radoub Rochefort.jpg(CC BY-SA 4.0,Myrabella)Commons 的石阶照片里一排台阶的级高是均匀的,这正是 WalkStairs 需要的条件:每一级的高度差有上限,抬脚高度不够就跨不过去。E1 与官方用例两处读数都把这条上限的位置点了出来。官方用例还留了频率这一维:台阶高固定,更新频率从 60 抬到 360 Hz,判据不变、读数却更细,这一段在 2.4 接着量。
2.4 时序收束、四个更新频率与下坡贴地
三件套的时序在图上收一遍:贴地只在落地那一刻动作,走台阶只在坡太陡时才被调用,两个动作都排在主更新之后。
Update 每步都跑,贴地与走台阶都排在它之后,因为它们要读更新后的接触与支撑状态。两个后置动作都由编排函数按条件决定要不要做@ 本系列绘制OK,把台阶那一组再往频率上推一档。E1b 固定台阶高 0.30 m,按官方用例的四个更新频率(update frequency)60、120、240、360 Hz 各跑一遍:到达步号 80、160、311、451,换算成时间都落在 1.25 到 1.33 s 之间;角色过程中的最高点从 3005.8755 mm 收到 3000.1838 mm,频率越高越贴台阶顶面。三段扫掠的用时与频率基本无关,而抬脚后落回的高度会随频率变密而更精确。抬脚高度是一个固定值 0.4 m,频率变密不会让抬脚变高,只会让落回更准,四档最高点的差距一直收在个位毫米。
下坡贴地那一组是 E2,坡度扫 10、30、50 度,横穿 1.5 s、10 m/s。开 StickToFloor 时,三档都是 90 步全程 OnGround、in_air=0,纵向位移 -2644.9055、-8660.2588、-17876.3203 mm,与解析值 -2644.9045、-8660.2539、-17876.3047 mm 基本重合。关掉之后,三档在地面上的步数只剩 4、1、0 步,其余全在 InAir。这同样是官方用例场景的本机复现,不是官方数值。
三件事的分工也能从参数上读出来:抬脚高度管能不能跨上去,前探测试距离管跨过去之后脚下有没有落点,前向接触的角度余弦管「哪一个接触算挡路」。这三者同时成立,三段扫掠才会真的发生,E1 的四档读数正是在这三者同时成立时才全部上得去。
源码落点:编排在 CharacterVirtual.cpp 第 1796 行 ExtendedUpdate,官方上台阶用例在 CharacterVirtualTests.cpp 第 412 行 TestWalkStairs,官方角色样例在 CharacterVirtualTest.cpp,三件套的参数表在 CharacterVirtual.h,台阶实物在 Commons 的 Rochefort 石阶文件页。其中:CharacterVirtual.cpp 给出三个调用的先后与条件,用例文件给出官方台阶场景,CharacterVirtualTest.cpp 给出样例里的驱动方式,参数表给出 ExtendedUpdateSettings 的六项默认值。
三、四把旋钮:贴着墙走的那 20 mm
先来看看,一句话说清这一节:角色贴着墙走时留下的那道缝,宽度逐位等于四把旋钮里的 mCharacterPadding,另外三把管的是预测接触、碰撞判定与穿透恢复,各自有各自的读数。
3.1 四把旋钮的原文与默认值
CharacterVirtual.h 第 47 行是预测接触距离(predictive contact distance)mPredictiveContactDistance,默认 0.1 m,它的作用是让角色在真正接触之前就先按这个距离预判接触,提前把速度收住。第 51 行是碰撞容差(collision tolerance)mCollisionTolerance,默认 0.001 m,它决定一个接触要近到什么程度才算撞上。第 52 行是角色留白(character padding)mCharacterPadding,默认 0.02 m,注释写的是「我们试图与几何体(geometry)保持多远」,也就是角色贴墙时固定留出的那道缝。第 55 行是穿透恢复速度(penetration recovery speed)mPenetrationRecoverySpeed,默认 1.0,它管的是已经陷进几何体之后,每一步把角色往外推多快。
CharacterVirtual.h:45–47 + 51–52 + 55):预测接触距离 0.1 m、角色留白 0.02 m、碰撞容差 0.001 m、穿透恢复速度 1.0。前两把是提前量,后两把管细判与快速纠错@ Jolt Physics v5.6.0 源码原文截取(MIT)这四把旋钮的分工可以用一句判据概括:预测接触距离决定「多早收速」,碰撞容差决定「算不算撞上」,角色留白决定「贴到多近就停」,穿透恢复速度决定「陷进去之后多快爬出来」。
四把旋钮的默认值放在一起看,能看出这套角色的取向:留白 0.02 m 与预测接触 0.1 m 都是提前量,宁可离墙远一点、宁可早一点收速,也不要频繁发生真接触;碰撞容差 0.001 m 与恢复速度 1.0 则偏向把判定做细、把已经发生的错误快速修掉。
四把旋钮之外,同一张表里还压着另外六项设置,它们不常被调,但决定了这套角色在极端场景下的下限。
CharacterVirtual.h:46 + 48–50 + 53–54):背面模式、最大碰撞迭代 5 次、最大约束迭代 15 次、最小剩余时间 1e-4 秒、最大命中数 256、命中归并余弦 0.999。前三项防卡死,后三项控成本@ Jolt Physics v5.6.0 源码原文截取(MIT)开始正文,这六项可以按「防卡死」与「控成本」两类读。防卡死那一类是背面模式(默认与背面碰撞)、最大碰撞迭代 5 次与最大约束迭代 15 次:前一个让单面三角网(triangle mesh)的背面也能挡住角色,后两个给出每步在碰撞与约束上最多循环几轮,防止角色在墙角里反复推挤不收敛。控成本那一类是最大命中数 256、命中归并余弦 0.999 与最小剩余时间 1e-4 秒:命中数封顶后超出的接触会被按距离丢掉,归并余弦把夹角小于约 2.5 度的两个命中合成一个,最小剩余时间则是一个提前退出的门槛。
3.2 贴墙的那 20 mm
好了,先看留白这一把。E4 让角色朝一堵墙走 2 s、输入 3 m/s,把 mCharacterPadding 扫成 0、0.02、0.2 m 三档,量到的贴墙留白是 0.0000、20.0000、200.0000 mm。三档读数与参数值逐位对应,角色与墙之间那道缝,正是这把旋钮直接决定的。
mCharacterPadding(0、20.0000、200.0000 mm);预测接触距离与碰撞容差对这个留白没有可测影响(预测接触取 0 时只多出 0.0179 mm 零头);起点重叠 0.10 m 时,恢复速度 0 永不解决、0.5 用三步、1.0 用一步@ 本机基准实测(原创)另外两把旋钮对这层留白没有可测影响。把 mPredictiveContactDistance 扫成 0、0.1、0.5 m,留白是 20.0179、20.0000、20.0000 mm,只有第一档多出 0.0179 mm 的零头;把 mCollisionTolerance 扫成 0.001 与 0.05 m,留白都是 20.0000 mm。预测接触与碰撞容差影响的是「什么时候收速」与「算不算撞上」,它们不改角色最终停在离墙多远的地方。
留白这一档读数还有一个直接用法:如果一次贴墙的实测留白与设定的 mCharacterPadding 对不上,说明场景里还有第二个接触在起作用,因为只有这一把旋钮能改这个量。
3.3 穿透恢复的步数
说白了,留白管的是正常贴墙,穿透恢复(penetration recovery)管的是已经出事的场合。E4b 把起点安排成与墙重叠 0.10 m、速度置零,只看恢复项:启动值 gap0_mm 是 -100.0001 mm。恢复速度取 0 时,角色永远解决不了这次重叠,resolved_step=-1,末位留白仍是 -100.0001 mm;取 0.5 时,第 3 步解决,末位留白 4.9999 mm;取 1.0 时,第 1 步就解决,末位留白回到 20.0000 mm。
这里的原理并不复杂:恢复速度是一个乘在穿透深度上的比例,0.5 用三步、1.0 用一步,0 则一步都不用。步数越少,每一步往外推得越果断,代价是对接触的判定要求更硬。
恢复这一档读数也解释了穿透为什么通常不会积累:只要恢复速度不为零,每一步都会按比例把重叠往外减,重叠越深、被推得越多,几个步之内就会回到留白的位置。
3.4 四把旋钮的分工与一处误读
下面开始,把四把旋钮放回整条流程里看。一次贴墙的完整过程是:角色前进时先按预测接触距离提前收速,进入碰撞容差的判定范围后算作撞上,停在离墙 mCharacterPadding 的位置;如果起步就重叠了,则由穿透恢复速度按比例把它推出来。四把各管一段,互相不越界,实测里对留白有影响的只有角色留白这一把。
这里要收一句对上一篇的补全。第十一篇把角色控制器写在 CCD 路径之外,按官方原文的准确口径,虚拟角色默认没有仿真刚体、常规射线打不到它,而配上内层刚体之后,高速的 LinearCast 物体就穿不过去了。角色侧这四把旋钮里没有一把管高速穿透,那一层由 §四的内层刚体负责。
给应用侧留的余地也在这里。四把旋钮的默认值恰好是「保守加快速纠错」的组合,动作类游戏若希望角色更贴墙,可以把留白调小;若希望角色不抖,可以把碰撞容差调大。这些都属于应用层能自己决定的事,引擎只把旋钮交出来。
言归正传,角色侧的前半到这里摆完了:两条路、三件套、四把旋钮。后面从内层刚体开始,把角色接回世界,再回答两个虚拟角色会不会互相穿过、它的状态又由谁来存。
源码落点:四把旋钮在 CharacterVirtual.h 第 47、51、52、55 行,坡限与支撑体积在 CharacterBase.h,角色接触的可记录监听器在 LoggingCharacterContactListener.h。其中:CharacterVirtual.h 给出四把旋钮的原文与默认值,CharacterBase.h 给出坡限与支撑面的判定口径,监听器文件给出角色接触在测试里怎么被逐条记下。
3.5 站不站得住:坡限与支撑体积
依然是惯例,把同属角色设置、但不属于这四把旋钮的两项交代完。上一节末尾提到的 GetGroundState() 有两种取值,一种是站得住,一种是坡太陡,判出这个结果的不靠某个距离参数,靠的是基类里的两个字段加一个判据函数。
CharacterBase.h:33–42 + 77–82):上方向、支撑体积(在它背后的接触才算支撑)、最大坡角 50 度三个字段,加一个把法线与上方向做点积的 IsSlopeTooSteep 判据。E3 那一列状态就是它判出来的@ Jolt Physics v5.6.0 源码原文截取(MIT)读这三行能看出「支撑」是怎么定义的。支撑体积(supporting volume)是一个平面,写在角色本地坐标里,只有在它背后的接触才被算作能托住角色,在它前面的接触只算撞上;最大坡角默认 50 度,判据把它换算成余弦存起来,每帧拿接触法线与上方向做一次点积,小于这个余弦就判太陡。E3 的九行读数量的正是这条线:45 度坡配 50 度上限是 OnGround,配 40 度上限立刻变成 OnSteepGround。
坡限与角色控制器里的最后一处硬线有关。角色站不站得住由 mMaxSlopeAngle 管,载具翻不翻车由 mMaxPitchRollAngle 管,两个名字不同、单位都是角度,判的却都是「法线偏离竖直多少算过分」。第 3.3 节里那个坡太陡就往下滑的行为,根就在这个点积上。
四、内层刚体:把角色接回世界
本章回答一个具体问题:一个默认连形状(shape)都没有的虚拟角色(virtual character),凭什么能被外界的射线打到,又凭什么能拦住一颗高速飞来的子弹。这个问题在第十一篇《CCD 与确定性》里留了个口子,本篇用一组读数把它收口。
依然是惯例,先把这一层的用途原文摆出来。CharacterVirtual.h 第 57 至 61 行的注释一共三句:角色可以选择性地挂一个内层刚体(inner body),这个刚体用来让角色在世界里有了存在感。后面接三条具体作用,逐条是「常规碰撞检查,比如 NarrowPhaseQuery::CastRay,会与这个刚体相撞,而打不到虚拟角色」,第二条是「常规接触回调(contact callback)会经由 ContactListener 走一遍」,第三条是「运动质量(motion quality)为 LinearCast 的高速物体,在一个时间步(time step)内穿不过这个角色」。
4.1 三条作用,各自对着一条管线
第一句管的是查询管线。窄相位查询(narrow phase query)是引擎给外部用的射线与形状投射入口,它只认世界里登记过的形状。虚拟角色本体没有形状,所以默认查不到它;挂上内层刚体之后,它成了一个真正登记在案的形状,射线就能打到。
第二句管的是事件管线。角色本体的接触事件走的是专门的 CharacterContactListener,常规的 ContactListener 收不到。挂上内层刚体之后,接触事件会额外走一遍常规回调,于是应用里原有的接触处理逻辑也能看见这个角色。
第三句管的是连续碰撞检测(continuous collision detection,CCD)。一个运动质量为 LinearCast 的物体,在一个时间步内会做一次线性投射(linear cast)来找碰撞;角色本体不在宽相位(broad phase)里,投射自然扫不到它。挂上内层刚体之后,投射扫得到它,这一步就穿不过去。
CharacterVirtual.h:57–61):常规碰撞检查(如窄相查询的 CastRay)能打到它;常规接触回调会走 ContactListener;运动质量为 LinearCast 的高速物体一个时间步内穿不过去@ Jolt Physics v5.6.0 源码原文截取(MIT)这张面板把三句注释连同次序一起摆出来,重点是三句各自限定了一条管线:查询、事件、连续碰撞检测。三条作用指向同一个开关,也就是 mInnerBodyShape 这个可选的形状字段。
4.2 E5:四行对照读数
OK,把注释放到读数里看。E5 的场景很干净:角色站在原点的一块地板上,自身不带速度,只观察外部查询与外部子弹能不能感知到它。读数分两组。
不设内层刚体这一组有三行。宽相位射线(ray_narrowphase)给出 hit=0,打不到;把查询目标换成角色自己的变换形状(TransformedShape),给出 hit=1、fraction=0.450000,一打就中;高速子弹走线性投射,末位 final_x_mm=26615.6035、passed=1,直接穿了过去。
第二行那个做法不是本机自创的,它写在官方架构文档的注意事项里。
Architecture.md:719–725):常规射线打不到虚拟角色,要先用窄相查询打一次,再用角色自己的变换形状打第二次才能把角色算进结果里;第 725 行是内层刚体这条替代路@ Jolt Physics v5.6.0 官方文档原文(MIT)这段原文给的是一条两步走的配方。第一步走常规的窄相查询(narrow phase query),这一步只覆盖宽相里登记着的东西,虚拟角色自然不在其中;第二步把角色自己的变换形状拿出来再打一次,把它的几何手工补进这次查询的结果里。两句话合起来说明一件事:虚拟角色并不是没有几何,它只是不在这套查询的索引里,缺的那一环由调用者补。
设了内层刚体这一组有两行。宽相位射线 hit=1、fraction=0.450000;子弹末位 final_x_mm=-337.5000、passed=0,停住了。两组里角色的质心高度都是 com_y_mm=994.9999,说明这个内层刚体没有挪动角色本体,只是给世界补了一份可见的几何。
三行读数与三条注释一一对上。默认打不到,对应第一条的前提;TransformedShape 能打到,说明角色本体确实有几何,只是没登记;子弹那一档把第三条量成了从 26615.6035 mm 到 -337.5000 mm 的一段差,差值超过 26000 mm。
这张图的重点在那两处 fraction=0.450000。一处是手工拿变换形状去查,一处是挂了内层刚体之后由引擎查,两条路给出同一个命中比例,说明内层刚体用的就是角色本体那份几何,没有另造一个更大的包围体。
4.3 与第十一篇的接口,与三句自述
好了,这里把第十一篇留的那句话写全。第十一篇的边界句是「角色控制器不在宽相里,也就不走本篇的 CCD 路径」。按官方原文更准确的写法是:默认没有仿真刚体、CastRay 打不到它;一旦设了 mInnerBodyShape,高速的 LinearCast 物体就穿不过去了。本篇不重写已上线的第十一篇,在这里把口径收紧:那句边界句说的是默认配置下的角色,挂上内层刚体之后,角色重新回到了 CCD 的管辖范围。
顺带三句自述来自 CharacterVirtual.h 第 262 至 267 行的类注释:虚拟角色不在宽相位里;它的变换不被引擎跟踪;PhysicsSystem::SaveState 保存的快照(snapshot)里没有它。前两句解释了为什么默认打不到,第三句是下一章的引子。
三句话在源码里是连着的三行,值得整段读一遍,因为本节与 §五 的全部结论都从它派生。
CharacterVirtual.h:262–267):它不用刚体、只做碰撞检测;默认对刚体不可见,要可见就设内层刚体;它不被 PhysicsSystem 跟踪,所以 SaveState 不会保存它的状态,得自己调 CharacterVirtual::SaveState@ Jolt Physics v5.6.0 源码原文截取(MIT)读这三句能看出官方把边界摆得很清楚:不可见与不被跟踪是同一个设计决定的两面。既然它不在世界里,就不该期待别人能看见它,也不该期待系统的存档会替它保存状态。第三句甚至直接给出了补救办法,让调用者自己调 CharacterVirtual::SaveState。E7b 量的正是这一句的后果。
这张图把三种交互路径并排:射线走窄相位查询、接触事件走回调、高速物体走线性投射。三条路都指向同一个内层刚体,而角色本体始终留在物理系统之外。
源码落点
[CharacterVirtual.h 的内层刚体段](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Jolt/Physics/Character/CharacterVirtual.h)第 57–65 行:其中: 第 57 至 61 行是三条作用的完整原文,第 63 至 65 行的mInnerBodyIDOverride注释写明可以指定内层刚体的 ID,为的是让客户端与服务器各自复现同一场仿真。[CharacterVirtual.h 的类注释](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Jolt/Physics/Character/CharacterVirtual.h)第 262–267 行:其中: 三句自述分别交代角色不在宽相位、不被跟踪、SaveState不保存它。[CharacterVirtual.h 的 GetTransformedShape](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Jolt/Physics/Character/CharacterVirtual.h)第 475 行:其中: 它是 E5 第二行读数的入口,手工取角色自己的变换形状再做一次查询。[Architecture.md 的角色段](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Docs/Architecture.md)第 711–723 行:其中: 第 713 行的Collision checks (e.g. CastRay) do not collide with CharacterVirtual是本节的另一半口径,第 722 行是互撞要自接接口的那一句。[第十一篇的 LinearCast 定义](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Jolt/Physics/Body/MotionQuality.h)第 16–22 行:其中: 那里的LinearCast定义正是 E5 子弹那一档走的路径,两篇在这里咬合。[官方刚体式角色用例](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Samples/Tests/Character/CharacterTest.cpp)第 20 行:其中: 刚体式角色的移除要先把它摘出再析构(RemoveBodyInternal要求它已不在宽相且未激活),这是官方样例的写法,本机首跑崩在这里。
五、角色之间,与角色的确定性
本章回答两个问题:两个虚拟角色正面碰上,会不会互相穿过去;角色的状态到底由谁保存,保存得下来吗。
5.1 互撞要自己接接口(E6)
说白了,虚拟角色之间的碰撞不在默认路径上。官方在 Architecture.md 第 722 行的四笔账里写清:角色与角色之间的碰撞,要靠应用自己接上 CharacterVsCharacterCollision 接口。接口声明在 CharacterVirtual.h 第 219 至 260 行。不接这个接口,两个角色彼此之间没有任何碰撞概念,因为它们的本体都不在世界里,宽相位里谁也看不见谁。
接口本身的原文很短,两句注释加两个纯虚函数,把「这件事为什么要应用自己做」讲清楚了。
CharacterVirtual.h:216–240):接口注释写明虚拟角色没有登记在任何地方,要检测角色与角色的碰撞得由应用自己负责;两个纯虚函数一个做形状重叠、一个做形状扫掠@ Jolt Physics v5.6.0 源码原文截取(MIT)读这两句注释能看出官方的取舍:接口提供的是协议而不是实现。真正的遍历逻辑留给应用,因为角色可能成百上千,把它们放在数组里逐个比对还是放进一棵树里按空间剔除,是应用才知道的事。官方在同一个头文件里给了一个简单实现 CharacterVsCharacterCollisionSimple,作用是在测试与样例里够用,注释里也写明它是暴力遍历、并且不是线程安全的。
E6 把这件事量了两行。两个角色对向而行,速度都是 0.5 m/s,跑 240 步。不接接口(vs_char=0)这一行:两个角色分别停在 x0_mm=1499.9988 与 x1_mm=-1499.9988,间距 gap_mm=-2999.9976,crossed=1,也就是各自穿过了对方,跑到了对方那一侧。接上接口(vs_char=1)这一行:位置变成 -323.3277 与 325.0056,间距 gap_mm=648.3333,crossed=0,两个角色在相距 648.3333 mm 的地方停住。
这里有一处口径必须交代:这是官方用例场景的本机复现,不是官方数值。 场景对齐官方 TestCharacterVsCharacter,步速与步数取自本机实验设置,读数只代表本机这一份二进制。
CharacterVsCharacterCollision 时两个角色对穿(间距 −2999.9976 mm),接上之后停在 648.3333 mm;SaveState 往返后动态刚体漂移 0.000000 mm、虚拟角色漂移 999.999084 mm。这是官方用例场景的本机复现,不是官方数值@ 本机基准实测(原创)这张图左半是 E6 的两行,右半是 E7b 的两行。两件事的结论合成一句:角色之间的碰撞、角色自身的状态,都要应用自己接管。
5.2 CharacterID:给应用排序的键
言归正传,既然碰撞顺序要应用接,引擎就得给每个角色一个可比较的键。官方给的是 CharacterID。CharacterID.h 第 11 行的注释写明它的用途:识别被删除的角色,以及做确定性排序(deterministic sort)。
计数器本身在第 74 至 88 行。sNextCharacterID() 是一个全局原子计数器(global atomic counter),第 78 行用 fetch_add 配 memory_order_relaxed 自增;sSetNextCharacterID() 在第 85 行,注释写明它可以在销毁全部角色之后把计数设回去,为的是跑第二次确定性运行。
ReleaseNotes.md 的 v5.3.0 Character 段第 127 与 128 行是另一条旁证:第 127 行说每个 CharacterVirtual 现在都有 CharacterID,用来在一个角色同时碰到多个虚拟角色时让仿真保持确定性;第 128 行说支持覆盖内层刚体的 ID,为的是客户端与服务器这类场景的确定性。
CharacterID.h:74–88):sNextCharacterID 是一个全局原子计数器,两个世界在同一进程里会拿到不同的一段编号;sSetNextCharacterID 的注释写明它是为「第二次确定性运行」准备的@ Jolt Physics v5.6.0 源码原文截取(MIT)面板里那行 fetch_add 是这条机制的根。编号不是应用分配的,是全局计数器按创建顺序发的,谁先创建谁的号小,两个世界里同一个角色可能拿到不同的号。
5.3 E7:五个变体的逐位比对
OK,把这套机制放到两个独立世界上比一遍。E7 建两个世界,各放三个会互相碰撞的角色,逐步逐位比对 300 步。五个变体分别是默认编号、重置编号、反序插入、反序插入加重置编号、反序创建加重置编号。
五个变体全部给出 first_mismatch=-1(300 步无一处不一致)、equal=1,末态哈希(hash)都是 7509146118703772105。编号本身确实在漂:不重置时第二个世界拿到 ids_b=4/5/6,重置(sSetNextCharacterID(1))之后是 ids_b=1/2/3,反序创建那一档是 ids_b=3/2/1。
这两组放在一起才完整。编号是全局计数器发的,会随创建顺序漂到 4、5、6;引擎不靠编号的数值本身,靠的是「按编号排序」这个动作。排序把编号的影响抹平,所以编号漂了,逐步逐位的结果仍然一致。
这张图把两条线并在一张画布上:上面一行是编号从 1、2、3 漂到 4、5、6,下面一行是逐位比对的首个不一致步号停在 -1。漂移与一致同时成立,正是「排序而非编号的数值」这句话的意思。
5.4 状态不在快照里(E7b)
好了,这一节回答状态由谁存。官方给物理系统的存档接口是 SaveState 与 RestoreState,声明在 PhysicsSystem.h 第 165 与 168 行。E7b 的流程是存一次、跑 60 步、再放回来,看两样东西各回到哪里。
两个接口的签名本身就把「存什么」的主动权交回调用者:一个带状态范围枚举与过滤器,另一个返回成功与否。
PhysicsSystem.h:163–168):SaveState 带一个状态范围枚举与一个过滤器,RestoreState 返回成功与否。E7b 量的就是这一对@ Jolt Physics v5.6.0 源码原文截取(MIT)签名里那个状态范围参数是这条边界上一个容易漏的细节。它允许调用者只保存被仿真改过的那一部分状态,而不是全部对象;对角色来说,CharacterVirtual 既不是被仿真改过的量,也不在这个枚举的覆盖范围里,所以无论传哪个取值,快照里都不会出现它。这解释了 E7b 那一行读数,也解释了为什么官方在类注释里直接要求调用者自己存。
读数:restore_ok=1,动态刚体(dynamic body)的漂移是 0.000000 mm、body_restored=1;角色的漂移是 999.999084 mm、char_restored=0。这一段里刚体走了 444.0726 mm,角色走了 999.9991 mm。
结论清楚:快照里有刚体,没有角色。存档把刚体那半边完整地放了回去,误差落在浮点最低位上;角色的位置继续停在跑完那 60 步的地方,一步都没有退。要保住角色的状态,应用得自己存它的速度、位置与状态机,引擎只管刚体那半边。内层刚体如果挂了,它的刚体身份仍在物理系统里,但角色的虚拟部分不在其中。
PhysicsSystem 之外:不进宽相、不被跟踪、不进快照;编号由全局计数器发放,要确定得应用自己按编号排序@ 本系列绘制这张图把两条时间线并行画:刚体那条在放回快照后回到原位,角色那条继续往前。两条线在同一段时间里走了不同的距离,因为它们根本不在同一份存档里。
源码落点
[CharacterID.h](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Jolt/Physics/Character/CharacterID.h)第 11、74–88 行:其中: 第 11 行是用途注释,第 78 行是fetch_add自增,第 85 行的sSetNextCharacterID注释写明「第二次确定性运行」。[CharacterVirtual.h 的互撞接口](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Jolt/Physics/Character/CharacterVirtual.h)第 219–260 行:其中:CharacterVsCharacterCollision相关声明与注释,是 E6 第二行读数的接口出处。[PhysicsSystem.h 的存档接口](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Jolt/Physics/PhysicsSystem.h)第 165、168 行:其中:SaveState与RestoreState是 E7b 的两端。[Architecture.md 的角色段](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Docs/Architecture.md)第 722 行:其中: 角色与角色碰撞要自接接口的那一句。[ReleaseNotes.md 的 v5.3.0 段](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Docs/ReleaseNotes.md)第 124–128 行:其中: 第 127 行的CharacterID与确定性排序,第 128 行的内层刚体 ID 覆盖。[官方互撞用例](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/UnitTests/Physics/CharacterVirtualTests.cpp)的TestCharacterVsCharacter(第 827 行):其中: E6 的场景原型,本机复现照着它取值。
六、载具:一个约束 + 每轮一份碰撞测试 + 四个部件
本章回答一个问题:一辆车到底由几个可数的部件拼出来。
6.1 类注释里的使用纪律与一处已知问题
说白了,载具这一侧的入口与角色不同。角色是一个形状,载具是一个约束(constraint)。VehicleConstraint.h 第 43 至 64 行的类注释里,第一句就是使用纪律:Note: Don't forget to register the constraint as a StepListener with the PhysicsSystem!,中文是「别忘了把这个约束登记成物理系统的步监听器(StepListener)」。它同时继承 Constraint 与 PhysicsStepListener(第 65 行),两条身份都要用上,少登记一次这个约束就不会被求解。
出处写在第 21 至 22 行:这个约束的参数大体来自 Marco Monster 的《Car Physics for Games》。这句话不是客套,它解释了后面为什么是悬挂、轮胎、引擎、变速箱、差速器这一套。
注释里还有一段已知问题,官方用了整整十九行交代:车辆碾过很轻的碎块(rubble)时,车身会往下陷(dip down)。三条缓解分别是提高速度迭代步数(mNumVelocityStepsOverride,官方举例从 10 步提到 30 步)、缩小车体与碎块之间的质量差、以及干脆过滤掉车辆碰撞测试与碎块之间的碰撞(官方说明这样碎块会被穿过,可以另做假轮子把它推开)。
VehicleConstraint.h:43–64):第一句是使用纪律,别忘了把约束登记成 StepListener;后面是官方自己标出的已知问题(碾过碎石时车身下沉)与三条缓解@ Jolt Physics v5.6.0 源码原文截取(MIT)面板里能直接读到那句 Don't forget,以及三条缓解的原文。它把「载具是一个约束」这句话落到了管线上:约束要登记,参数要调,坑也要自己避。
类注释交代完纪律,约束对外还留了四个接口,它们正好对应本章与后面两章要讲的三件事。
VehicleConstraint.h:75–90):防翻车的俯仰横滚角、每轮的碰撞测试器、摩擦合成函数,以及默认取几何均值这一处注释@ Jolt Physics v5.6.0 源码原文截取(MIT)下面开始,四个接口可以按「定边界」「定探测」「定摩擦」三组读。俯仰横滚角把车身的上方向锁在一个锥里,越界就不再让它翻过去。碰撞测试器是每轮探地的入口,下一节按它分三种。摩擦合成函数默认取几何均值,也就是说轮胎与地面的摩擦系数按两者乘积开平方来合,想换成别的规则要自己替换这个函数。三组里只有第一组是纯数值,后两组都要交出一个对象或函数,这也解释了为什么载具的初始化比角色长得多。
6.2 官方建车配方逐行读
下面开始看官方怎么把一辆车搭起来。WheeledVehicleTests.cpp 第 44 至 137 行是一份完整的建车配方,逐行读下来有五件事。
第一件是底盘(chassis)与质心(center of mass)。配方用 OffsetCenterOfMassShape 把底盘形状套一层,把质心往下压。质心越低,车越不容易翻,这是整辆车稳定性的来源。这一步排在形状处理的最前面,因为质心位置会决定后面每一个轮上力的力矩臂,放到收尾再改等于把前面的力全部重算。
第二件是四个轮位。每个轮子给出一个挂点位置与一个轮半径,四个挂点定下轴距(wheelbase)与轮距(track width)。
先来看看第三件与第四件。第三件是每个轮子上的悬挂(suspension)与轮胎(tire)参数,弹簧刚度与预压都挂在这里。第四件是差速器(differential),把引擎扭矩分给左右轮。
第五件是横向稳定杆(anti-roll bar),按前后轴各配一对,用来抑制车身侧倾。五件事合起来,正好对上前一节那张部件分解图:约束与底盘、四个轮位、悬挂与轮胎、差速器、横向稳定杆。配方里没有引擎与变速箱,是因为单元测试只验几何与轮胎力这两块,传动那两块留给控制器去接。
WheeledVehicleTests.cpp:44–131):用 OffsetCenterOfMassShape 把质心压低、四个轮位与轮属性、差速器、横向稳定杆,最后是约束与碰撞测试器的创建。中段用 // ... 标出省略@ Jolt Physics v5.6.0 源码原文截取(MIT)面板里那几行位置参数是这辆车的骨架:四个挂点定轴距与轮距,OffsetCenterOfMassShape 定重心高度,两者合起来决定这辆车翻不翻。
6.3 八个可数部件
言归正传,把上面这些收成一个可数的清单。一辆车在 Jolt 里可以数出八样东西:一个约束;每轮一份碰撞测试器(collision tester);悬挂;轮胎;引擎(VehicleEngine);变速箱(VehicleTransmission);差速器;横向稳定杆。
这八样东西在代码里的落点只有六个字段,它们全部写在车辆约束的设置结构体里。
VehicleConstraint.h:31–36):上方向、前方向、最大俯仰横滚角、轮设置数组、横向稳定杆数组、控制器。八个部件对应六个字段,传动链三件被收在控制器这一格里@ Jolt Physics v5.6.0 源码原文截取(MIT)六项可以按「定方向」「定边界」「装部件」三组读。上方向与前方向定的是整车坐标系的朝向,也就是「哪边是上」与「哪边是前」。最大俯仰横滚角定的是翻车边界,默认是圆周率,等于关掉。后三组是装部件的地方,轮设置是一个数组(每个元素是一个悬挂加一个轮胎),横向稳定杆也是数组,控制器对象则把引擎、变速箱、差速器三件收在自己内部。八样东西对应六个字段,是因为传动链那三件被收进了控制器这一格。
这个数法在官方样例文档里对得上。Docs/Samples.md 的 Vehicles 段写明,这些车用射线或形状投射来探地,并模拟引擎、变速箱、差速器与悬挂。一句话把八件里最关键的四个点了名。
这套数法与「一辆车是四个各自受力的独立轮子」这种直觉不同。Jolt 把整车放在一个刚体底盘上,四个轮子不各自变成刚体,只通过碰撞测试器与地面打交道,轮胎上的力再折回底盘。下一节 E8 用的就是本机基准里那辆车,轮半径 300 mm、跑 240 步,同一段搓板路只换探测器,八个部件里被换动的只有一个。
分工可以一层层拆开。约束在最外层,把这一车东西绑成一个整体并登记进步进管线。碰撞测试器在最内层,每轮向下探一次地;悬挂把地面反馈折成弹簧力;轮胎把滑移折成摩擦力。引擎、变速箱、差速器一路把油门折成轮上的扭矩;横向稳定杆在左右轮之间传力,压住侧倾。
这张图是本节的总图:一个约束在最外层,每轮一份碰撞测试在最内层,中间挂着悬挂、轮胎、引擎、变速箱、差速器、横向稳定杆。下一节从最内层的碰撞测试器讲起。
源码落点
[VehicleConstraint.h](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Jolt/Physics/Vehicle/VehicleConstraint.h)第 21–22、43–65、76、80、89 行:其中: 第 21–22 行是 Marco Monster 出处,第 43–64 行是 StepListener 纪律与碾碎石的三条缓解,第 65 行是双重继承,第 76 行的SetMaxPitchRollAngle防翻车,第 80 行的SetVehicleCollisionTester是每轮探测器接口,第 89 行的SetCombineFriction注释写明默认取几何均值。[WheeledVehicleTests.cpp](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/UnitTests/Physics/WheeledVehicleTests.cpp)第 44–137 行:其中: 第 44 行起是建车配方的起点,OffsetCenterOfMassShape降质心、四个轮位、差速器与横向稳定杆都在这段里。[Docs/Samples.md 的 Vehicles 段](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Docs/Samples.md)第 32–34 行:其中: 「ray- or shape casts to detect collision with the ground and simulate a vehicle with an engine, gearbox, differentials and suspension」是八件数与实际建模范围的官方对照。[VehicleEngine.h](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Jolt/Physics/Vehicle/VehicleEngine.h)、[VehicleTransmission.h](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Jolt/Physics/Vehicle/VehicleTransmission.h)、[VehicleDifferential.h](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Jolt/Physics/Vehicle/VehicleDifferential.h)、[VehicleAntiRollBar.h](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Jolt/Physics/Vehicle/VehicleAntiRollBar.h):其中: 四个机械部件的定义,分别对应引擎扭矩曲线、变速箱、差速器与横向稳定杆。[VehicleConstraint.h 的轮子访问](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Jolt/Physics/Vehicle/VehicleConstraint.h)第 138–145 行:其中:GetWheel一族接口,是下一节按轮取悬挂读数的入口。
七、三个碰撞测试器:射线、球、圆柱
本章回答一个容易被当成「精度参数」的问题:把探测器从射线换成球或圆柱,改的到底是什么。
7.1 三种探测器的几何
我们正式开始看接口。VehicleCollisionTester.h 里基类在第 19 行,接口在第 47 至 59 行,三个入参出参把这件事说清了:inOrigin 是悬挂挂点(suspension mount)的世界坐标,outSuspensionLength 是量出来的悬挂长度,outContactPosition 与 outContactNormal 是接触点与接触法线(contact normal)。三个子类分别是 VehicleCollisionTesterRay(第 84 行)、VehicleCollisionTesterCastSphere(第 105 行)、VehicleCollisionTesterCastCylinder(第 128 行)。
开始正文,几何差别就是三种形状的差别。射线(ray)从挂点向下扫一条线,只回答「这条线在多远处碰到东西」,碰到一个尖角就报一个点。球(sphere)按半个轮宽作半径做一次扫掠,量的是球面与地面的最近距离,遇到一段凸起时它会提前报出接触。圆柱(cylinder)按轮胎宽度立一根柱体,把柱的凸半径(convex radius)按比例收一下,得到一个圆角柱,接触面比球更宽。
三个子类的构造函数各自只有两三个参数,默认值把最常改的那两项预先定好了。
VehicleCollisionTester.h:84–93 + 105–115 + 128–136):射线与球各带默认上方向与 80 度最大坡角;球的半径没有默认值、必须由调用者给出(官方用例给半个轮宽);圆柱只有凸半径比例一个参数,默认 0.1@ Jolt Physics v5.6.0 源码原文截取(MIT)读构造函数能看出三处设计意图。射线与球都带一个默认的上方向(沿 Y 轴)与一个默认的 80 度最大坡角,也就是说这两个探测器自己也带一道坡限,超过这个角度的面不算地面。球的构造里那个半径参数没有默认值,必须由调用者给出,官方单元测试里给的是半个轮宽。圆柱的构造只有一个凸半径比例参数,默认 0.1,也就是把柱的边角按半径的十分之一磨圆。三个构造放在一起看,被要求显式给出的只有球半径一项,其余都给了能直接开跑的默认值。
三条实现的差别落在「接触点落在轮子下面的哪个位置」上。射线只给一个点,球给一个沿半径扩展的面,圆柱给一个贴着轮宽的面。这个差别在平路上几乎看不出来,在起伏路面上会被放大。
接口这一层还有一个细节值得单记:碰撞测试器的输入只有挂点一个位置,输出只有悬挂长度、接触点与接触法线三样。它不算力,只回答「地在哪里」。把「地在哪里」与「力是多少」这两件事分开,正是探测器可以整体替换的前提,也是悬挂与轮胎参数能在三个探测器之间通用的原因。
这张图把三种探测器的包络画在同一根悬挂上:一条线、一个球、一根圆角柱。三者都从同一个挂点出发,扫过的体积不同。
7.2 E8:同一段搓板路上的三组悬挂序列
开始正文,用一组读数把这个差别量出来。E8 用同一辆车、同一段搓板路(washboard)、240 步,只换探测器。
射线这一档:悬挂最小长度 susp_min_mm=317.0897、最大 492.7953、均值 379.8121,末位 final_y_mm=829.9033、final_z_mm=12634.1416。球这一档:338.7630、492.7954、382.0960,末位 851.2178、11385.4873。圆柱这一档:348.1577、492.7950、382.7251,末位 839.0928、11395.2197。三档的接触帧都是 236,硬点帧(hard point frame)都是 0。236 与 240 之间只差 4 帧,意思是这辆车几乎每一步都至少有一个轮子踩在地面上,那段起伏被完整地采样到了;硬点帧为 0,说明这一档里悬挂一次都没有被压到最小长度的硬限位。这两条背景让后面那组最小长度的差别更干净:三档都能全程踩地、都不触硬点,剩下的差别就只来自探测器本身的几何。
正餐开始,把三档并排看。最小长度按射线、球、圆柱依次抬高:317.0897 mm、338.7630 mm、348.1577 mm。差别的来路正是几何:球与圆柱比射线更早接触到凸起,在悬挂被压得更短之前就先报出接触,于是最小长度抬高。末位 z 的差别更大:射线跑了 12634.1416 mm,球与圆柱分别只有 11385.4873 mm 与 11395.2197 mm,差了 1200 mm 以上。同一段路、同一辆车,只换探测器,车走出的距离差出了一大截。
这张图是本节的核心:三组悬挂序列叠在同一条时间轴上。最小长度那条线依次抬高,末位那条线分成了两组。换探测器改的是接触几何,不只是一个精度参数。
这里还落出一条工程口径:悬挂调参要把探测器钉死。只换探测器,悬挂最小长度就动了 31 mm,末位就差了 1200 mm 以上;同一辆车、同一段路,两次读数已经不可比。反过来,换探测器时把悬挂参数一起重调,才是这三档读数真正的用法。
源码落点
[VehicleCollisionTester.h](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Jolt/Physics/Vehicle/VehicleCollisionTester.h)第 19、47–59、84、105、128 行:其中: 第 19 行是基类,第 47–59 行是inOrigin与三个出参的接口定义,第 84、105、128 行分别是射线、球、圆柱三个子类。[VehicleConstraint.h](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Jolt/Physics/Vehicle/VehicleConstraint.h)第 80、138–145 行:其中: 第 80 行SetVehicleCollisionTester是换探测器的入口,第 138–145 行的GetWheel一族是按轮取悬挂读数的出口。[WheeledVehicleTests.cpp](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/UnitTests/Physics/WheeledVehicleTests.cpp)第 44–137 行:其中: 建车配方里给每个轮子挂探测器的那一段,是 E8 场景的来源。[Docs/Samples.md 的 Vehicles 段](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Docs/Samples.md)第 32–34 行:其中: 「ray- or shape casts」这句写明探测器有两种形态,与三个子类的划分一致。
八、悬挂三长度与轮胎力
8.1 三个长度各管一段:最小、最大与预压
OK,前面七节把一辆车拆成了一个约束(constraint)加每轮一份碰撞测试(collision test),又在同一段搓板路上比过三个探测器(collision tester)给出的悬挂读数。这一节再往里走一层,看悬挂(suspension)自身是被哪几个量约束住的。官方把这三个量放在轮设置(wheel settings)里,名字分别是悬挂最小长度(suspension minimum length)、悬挂最大长度(suspension maximum length)与悬挂预压长度(suspension preload length);前两个管行程(travel)的两端,最后一个管弹簧(spring)在自由状态下被预先压进去多少。
Wheel.h:29–38 + 100–103):最小长度管上跳限位、最大长度管下探限位、预压长度把自然长度往外推(自然长度 = 最大长度 + 预压长度);GetSuspensionLength 与 HasHitHardPoint 各管一个读数@ Jolt Physics v5.6.0 源码原文截取(MIT)这三个量在几何上的分工可以直接读出来。最小长度是悬挂被压到的最短长度,落到实物上就是上跳限位(bump stop),车轮再往上顶,弹簧就压不动了,多出来的那部分冲量会顺着底盘传上去。最大长度是悬挂能伸到的最长长度,管的是下探限位(droop stop),车轮悬空时它把轮子拉住,不让轮子掉出车身的行程范围。预压长度不参与限位,它把自然长度(natural length)整体往外推一截。前两个量划出的是行程的边界,第三个量划出的是行程的起点,三者一起把一根悬挂的自由度收成一条直线上的一个区间。
8.2 自然长度:把最大长度往外推的那一截
先来看看那个被推出来的量。自然长度在本篇里写成一条公式:自然长度等于最大长度加预压长度。这条公式看着简单,它决定了车身在静止时停在多高。车一压上去,弹簧从自然长度开始压缩,压到与车重平衡的那个位置就停住,这个位置就是静置悬挂长度(rest suspension length),它反过来决定车身高(body height)。E9 用球探测器(cast sphere)、静置 3 s,把三个长度各扫了几档,读数里能把这三者的分工逐条分开。
正餐开始,把六档并排读。最小长度取 0.3 m 与 0.2 m,静置悬挂长度都是 364.3097 mm,车身高 844.3098 mm,说明这两档下最小长度根本没有被碰到,车重压下去只到 364.3097 mm,离 300 mm 的限位还有一段余量。最小长度提到 0.4 m,静置长度变成 399.9994 mm、车身高 879.9994 mm,这个数正好等于 0.4 m,也就是悬挂被压到了最小长度上,车重已经把它顶死在限位,读到这里就能明白一件事:最小长度并不是越小越无所谓,它一旦高于重压下的静置位置,就会直接改写车身高。最大长度从 0.5 m 提到 0.6 m,静置长度变成 464.3099 mm、车身高 944.3100 mm;预压长度取 0.05 m,静置长度 414.3098 mm,取 0.10 m,静置长度 464.3097 mm;六档对应的自然长度依次是 500、500、500、600、550、600 mm。这里有一处可以对照的巧合:最大长度 0.6 m 配预压 0 与最大长度 0.5 m 配预压 0.10 m,写法不同却落在同一个自然长度 600 mm 上,静置悬挂长度与车身高都落在同一档上,差别只出现在读数的最末几位。决定静置高度的是自然长度这个总量,这一截由最大长度出还是由预压长度出,读数上分不出来。
性能那一档的走向也要交代。E9b 在同一进程、4 线程、200 步下把车辆数取 1、4、16,每步耗时的走向是随车辆数上涨,而平摊到每辆车的成本从 28.8480 µs/车步 降到 4.6376 µs/车步。这说明多辆车共用同一次场景查询(scene query)的摊薄效应确实存在。这一档只写走向、不写绝对毫秒,三次复跑的毫秒读数有约 ±15% 的浮动,绝对值不稳。
把三个长度的几何再并到一起看:最小长度与最大长度是同一根轴上的两个端点,预压长度把自然长度这个起点往外挪,车身最终停在哪由自然长度与车重一起决定,硬点是这个区间下界被触发的那一刻。这三者在读数上各管一段:最小长度只在被压到时改写车身高,最大长度与预压长度都作用在自然长度上,前者改的是可伸到的上限,后者改的是弹簧被预先压进去多少。
8.3 硬点:HasHitHardPoint 是一个布尔
说白了,行程的边界最终要能被外面读到。官方给了两个读数入口,一个是 GetSuspensionLength,它回报当前的悬挂长度,E8 与 E9 用的都是它;另一个是 HasHitHardPoint,它回报的是一个布尔(boolean),意思是这一帧悬挂有没有撞到硬点(hard point)。硬点不是一个新的几何,它就是最小长度那一端的限位:轮子在某个时刻被压到了最小长度,HasHitHardPoint 就翻真,外面据此知道这片轮位已经把行程用尽。
E8 那一段 240 步的搓板路上,三个探测器的 hardpoint_frames 都是 0,也就是那一段路还没有把任何一根悬挂压到限位上;contact_frames 是 236,说明绝大多数步里轮子都贴着地面。把这两个数并排看,能读出一件事:搓板路给的是频繁的接触(contact),但还没有到硬点。硬的冲击要等更大的落差才会出现,这也解释了为什么官方把硬点单独做成一个布尔而不是塞进悬挂长度里。悬挂长度是一个连续量,硬点是一个事件,读法不同。这里补一处定义:硬点是悬挂行程的下界被触发的那个状态,最小长度决定它在哪,HasHitHardPoint 决定它对外怎么说。
8.4 轮胎力:两条滑移与一次几何均值
依然是惯例,把悬挂和轮胎分开看。悬挂管的是轮子相对车身的位置,轮胎(tire)管的是轮子相对地面的力。轮运行时对象(wheel runtime)对外露四个量:纵向滑移(longitudinal slip)、侧向滑移(lateral slip)、合成纵向摩擦(combined longitudinal friction)与合成侧向摩擦(combined lateral friction)。前两个描述轮子相对地面滑了多少,后两个是把路面摩擦与轮胎摩擦合起来之后的结果。滑移是输入,摩擦是输出,控制器拿到的正是这一对输出。
这四个量的原文就写在轮运行时对象里,一共五行,其中三行是上一段说的那两对,另外两行是刹车与手刹能给出的冲量。
WheeledVehicleController.h:54–65):纵向滑移、横向滑移、两个组合摩擦系数与刹车这一帧能给出的冲量。注释专门写明刹车冲量这一项不含摩擦@ Jolt Physics v5.6.0 源码原文截取(MIT)五行里有一个量容易被忽略:mBrakeImpulse,也就是刹车这一帧能从地面拿到多少冲量。它把「摩擦力」与「刹车力」在账上分开,注释专门写明这一项不含摩擦。分开记的好处是排查问题时有据可依:车停不下来,先看摩擦系数,再看刹车冲量,两个量各自有读数,不必去猜是轮胎的问题还是刹车的问题。
摩擦怎么合,官方在约束那一侧给了一条默认口径:几何均值(geometric mean),写成 sqrt(friction1 * friction2)。这条口径的意思是,路面很滑或者轮胎很滑,任何一边小,合成出来的摩擦都会被拉下去,取值落在两者之间且偏向小的一侧,所以冰面上换再好的胎也拉不住。轮设置里还有几个与轮胎手感直接相关的默认值:转动惯量(inertia)0.9、角阻尼(angular damping)0.2、最大转向角(max steer angle)70 度、最大刹车力矩(max brake torque)1500、最大手刹力矩(max handbrake torque)4000,差速器的限滑比(limited slip ratio)1.4。
BikeSuspensionFork FoxFloatRLC150 1.jpg(CC BY-SA 2.0,Owen Richard)实物层面的对照是一根避震前叉(suspension fork)。一根前叉里就装着弹簧与阻尼这一对,弹簧决定压下去多软,阻尼(damping)决定回弹多快。官方轮设置里的悬挂弹簧(suspension spring)正是这一对,两个参数分别是频率(frequency)与阻尼,前者换算成刚度,后者决定能量被吃掉多少。三个长度加这一对参数,就是一根悬挂在引擎里的全部可调项;把这几项调好,车身在起伏路上的姿态才谈得上稳定。
源码落点:三个长度、GetSuspensionLength 与 HasHitHardPoint 的原文见 Wheel.h,摩擦合成默认取几何均值的说明与轮子访问接口见 VehicleConstraint.h,控制器暴露的滑移量与四个默认值见 WheeledVehicleController.h,官方建车用例里三长度与弹簧的实际取值见 建车用例;其中:最小长度与最大长度是行程的两端限位,预压长度只推自然长度,HasHitHardPoint 把最小长度那一端是否被撞到翻成一个布尔,而 E8 那段路上它 240 步全为假。
九、三个控制器:轮式、履带、摩托
9.1 轮式:引擎、变速箱、差速器三件传动
下面开始看控制器(controller)那一层。约束负责把车撑起来,控制器负责回答司机踩了多少、车该怎么动。轮式控制器(wheeled vehicle controller)的输入是一行四元组:油门(forward)、转向(right)、刹车(brake)与手刹(handBrake),它们被塞进同一个 SetDriverInput 调用里。这一行输入往下走,要经过三件传动件才变成轮上的力矩:引擎(engine)给扭矩曲线(torque curve),变速箱(transmission)决定用哪个齿比(gear ratio),差速器(differential)把扭矩分到左右轮。差速器还有一个限滑比,默认 1.4,它描述的是左右轮转速差被允许到多大,值越小越接近一把锁死的轴,值越大越接近完全开放。
这四件的边界值得划一下:控制器不做悬挂,也不做轮胎力,它只把司机的动作翻译成轮上的力矩与转向角;悬挂与轮胎在上一节,探地与接触测试在上一节之前。官方在这一侧同样标注了出处,轮式控制器的参数大多来自 Marco Monster 的《Car Physics for Games》,与整个约束的来历一致。理解了这条链,调车的顺序就清楚了,先让车立住,再让车走起来,最后才是手感。
那个四元组输入的接口本体只有一行,四个量并排写在一起。
WheeledVehicleController.h:103–107):前、右、刹车、手刹四个量写在一行里。三个控制器共用同一个输入形状@ Jolt Physics v5.6.0 源码原文截取(MIT)一行四个赋值把一个设计意图说清了:司机这一侧的接口刻意做得很窄。上层的输入法、手柄、AI 决策都不进物理层,它们最后都要压成这四个量再交进来。三个控制器共用同一个输入形状,差别只在各自怎么解释它,这也让「换一个控制器」在接口层面变成一次替换而不是一次改写。
9.2 履带:左右两条轨道的比率转向
好了,把四个轮换成两条履带,控制器的输入形状立刻变了。履带控制器(tracked vehicle controller)的输入是(forward, leftRatio, rightRatio, brake)四元组,转向不再是一个角度,而是左右两条轨道各自分到多少比例。比率相等时车直行,一高一低时车转向,甚至可以让左右反向,于是原地转向(pivot turn)成为可能。这一套参数被分成两处:轮设置管履带轮的摩擦,轨道设置(track settings)管整条轨道的动力学。
轮设置里纵向摩擦(longitudinal friction)取 4.0、侧向摩擦(lateral friction)取 2.0,纵向明显大于侧向,履带才能顺着走、横着抓;轨道设置里五个参数分别是驱动轮(driven wheel)索引、轨道转动惯量(inertia)10.0、角阻尼(angular damping)0.5、最大刹车力矩 15000 与差速比(differential ratio)6.0。官方给这组默认值写了一句来历:它们是照着能找到的 M1 Abrams 坦克资料配的;紧接着还有一句提醒,为了避免很重的物体对上很轻的物体时出问题,坦克的质量应当设得比真实低不少。把这两句放在一起读,能看出一处工程口径:履带的默认值是从实物借来的,但质量这一项必须为求解器的稳定性让步。这也解释了为什么履带侧要单独给一条轨道一份转动惯量,而不是像轮式那样只给每个轮子一份。接地段越长、轨道转动惯量越大,转向就越迟钝,这一对量与轮式侧的转向角是完全不同的手感来源。
轨道设置结构体一共六个字段,比上面点到的多一个「这条轨道覆盖哪些轮」的列表。
VehicleTrack.h:23–41):驱动轮、覆盖的轮列表、转动惯量 10.0、角阻尼 0.5、最大刹车扭矩 15000 与相对变速箱的传动比 6.0@ Jolt Physics v5.6.0 源码原文截取(MIT)六个字段可以按「归属」「动力学」「传动」三组读。归属那一组是驱动轮与轮列表,它们把「哪几个轮算在同一条轨道上」说清楚,这也是履带比轮式多出来的一层结构;动力学那一组是转动惯量与角阻尼,默认 10.0 与 0.5;传动那一组是最大刹车扭矩 15000 与相对变速箱的传动比 6.0。官方在同一份头文件里还给了一个运行时对象与一个两元素数组,左右各一条轨道,与前面那句「比率转向」正好对上。
履带这一侧还有一处值得单独看:控制器的摩擦默认值与那句「照 M1 Abrams 配的」都写在源码里,还带一句质量提醒。
TrackedVehicleController.h:27–28 + 55–58):履带轮纵向摩擦 4.0、侧向摩擦 2.0;类注释写明默认值照 M1 Abrams 配,并提醒车身质量要按真车低一个数量级@ Jolt Physics v5.6.0 源码原文截取(MIT)那句质量提醒是这一节最实用的一条经验。它说的不是精度,是求解器的稳定性:很重的物体压在很轻的物体上时,迭代求解器传不动足够的力,于是车身会下沉。官方的建议是把坦克质量按真车的十分之一来设,扭矩与刹车也同步降十倍,量纲上的比例保住了,数值上的量级则落回求解器舒服的区间。这条经验对轮式同样成立,只是履带侧的默认值把它写在了注释里。
9.3 摩托:倾斜弹簧与官方自认的坑
言归正传,第三个控制器换了一整套思路。摩托只有两个轮子,静止时它站不住,官方为此加了一根弹簧去平衡车身,倾斜弹簧(lean spring)就是这么来的。摩托控制器(motorcycle controller)的参数里有最大倾斜角(max lean angle)45 度、倾斜弹簧刚度 5000、倾斜弹簧阻尼 1000,以及一个倾斜平滑系数(lean smoothing factor)0.8。前三个描述车身能歪到哪、歪下去被多猛地拉回来,最后一个描述的是倾斜角被平滑的方式。
MotorcycleController.h:38–40):官方明写它是帧率相关的,并给出公式,smoothing_factor 乘上一次值再加上一减它乘当前值@ Jolt Physics v5.6.0 源码原文截取(MIT)那句注释里给了一条公式:平滑后的值等于平滑系数乘上一次的值,加上一减平滑系数乘当前的值。这条公式是逐帧(per frame)递推的,注释由此明写它是帧率相关(frame rate dependent)的:同一个平滑系数,帧率一变,每秒钟被叠加的次数就变了,平滑出来的曲线自然不同。官方在类注释的另一行还写了一句自述,摩托控制器仍在开发中,可能需要不少调参(tweak)才能正常。这两句话合起来,把这个控制器在引擎里的位置说清楚了:它能用,但它的稳定性依赖调用者保持一个固定的步长。
E10 把这条帧率相关量成了数字。前排驱动 1.0、右转 1.0、跑 4 s,三档步长分别取 1/60、1/120 与 1/240。最大倾角随帧率升高,从 18.8487 度升到 19.1385 度、再到 19.2777 度,末态倾角反而从 9.3013 度降到 9.2072 度、再到 9.0515 度;位置侧的分歧更直观,末位 x 从 -1399.9133 mm 走到 -2064.6221 mm、再到 -2349.1138 mm,末位 z 从 9315.9492 mm 走到 9507.7529 mm、再到 9582.3984 mm。同一段 4 s、同一组输入,只改步长,车的位置就分开了,这条读数正好印证了注释里那句帧率相关:把摩托放进一个固定步长的服务端(server),再让客户端用另一个步长去预测,两边会慢慢分开。
9.4 三个控制器的分工
说白了,三个控制器是同一个约束挂上三套不同的输入解释。轮式控制器管引擎、变速箱与差速器这三件传动,把油门、转向、刹车、手刹翻译成四轮的力矩;履带控制器管左右两条轨道的比率转向,把输入翻译成两条接地段的转速差;摩托控制器管倾斜弹簧与车头几何,把输入翻译成倾角与转向。三者共享同一套悬挂与轮胎,差别只在往上那一层怎么把司机的动作变成轮上的量。
把三者放一张表更清楚:轮式与履带都带完整的传动链,摩托只有两个轮子、传动被压到最小;轮式与摩托靠转向角改变方向,履带靠左右比率;轮式与履带有独立的刹车与手刹力矩,摩托把平衡交回给倾斜弹簧。Jolt 的位置是这三种都内置,而多数竞品只给轮式一种,这条差别在下一节的横向对照里会展开。
Kette T-34 85 IWM.jpg(CC BY-SA 4.0,HH58)实物层面的对照是一条坦克履带。两条轨道各自接地,转向靠左右两侧的转速差完成,履带控制器的驱动轮索引(driven wheel)与转动惯量(inertia)就落在这条接地段上。接地段的长短直接改变转向的难易,这也解释了为什么履带侧要单独给一条轨道一份转动惯量,而不是像轮式那样只给每个轮子一份。
源码落点:三项输入签名、滑移量与差速器限滑比见 WheeledVehicleController.h,履带侧的两套设置与 M1 默认值见 TrackedVehicleController.h,轨道的五个参数见 VehicleTrack.h,倾斜弹簧与帧率相关注释见 MotorcycleController.h,官方摩托样例见 摩托样例,载具样例的说明文字见 样例文档;其中:摩托的平滑系数 0.8 是逐帧递推的,注释自己写明它帧率相关,E10 把三档步长下的末位 x 量成了 -1399.9133、-2064.6221 与 -2349.1138 mm。
十、横向对照:六家角色控制器、五家载具
10.1 六个问题:谁在宽相里、谁移动真实形状
我们正式开始横向对照。两家放在一起看,最省事的办法是把它们压成同一个问题:角色(character)这个东西,到底是不是世界里的一个形状。先给一个定义,角色控制器(character controller)是把使用者的移动意图翻译成一段受碰撞约束的位移的那一层,它本身不参与力学求解。把六家引擎的官方口径按这条线排开,答案从完全不在到它本身就是形状排成一列。Jolt 给了两条路可选,刚体式(rigid body style)用一个配成只允许滑动的刚体,虚拟式(virtual style)只用碰撞检测功能实现、默认没有刚体;Box2D v3.1 的 character mover 与 Jolt 的口径最接近,官方原文写它不是刚体、也不存在于仿真世界里,移动靠一次自定义的 b2World_CastMover 形状扫掠(shape cast),并把这个功能标为实验性(experimental)。
Collider,Unreal 走独立的移动组件@ 本系列绘制下面开始,往右一格是形状还在世界里的两家。Bullet 的 btKinematicCharacterController 用幽灵物体(ghost object)加凸体扫掠(convex sweep)做碰撞测试,幽灵物体被登记进宽相(broad phase)并刷新配对缓存(pair cache),所以它虽然不参与力学的求解,却仍然占据宽相的一份索引。Rapier 的 KinematicCharacterController 是一个更高层的工具,它自己发出射线投射(ray cast)与形状扫掠,按用户给的轨迹做滑移(move and slide),官方明说它不持有受控刚体与碰撞体的引用,但移动的确实是一个真实存在于世界的碰撞体(collider),并且内建控制器不支持旋转(rotational movement),只做平移(translation)。
再往右是角色本身就是形状。Unity 的 CharacterController 直接继承自 Collider,官方写它让人不必碰刚体就能做受碰撞约束的移动,本身不受力、由 Move() 驱动,滑动、上台阶与坡限三件事各由 Step Offset 与 Slope Limit 两个参数管着。Unreal 的 UCharacterMovementComponent 停在行为层:官方只写它处理行走、下落、游泳、飞行、自定义几种移动模式,并写明网络侧实现了服务端更正与预测,至于底层是不是扫掠、进不进宽相,官方文档并没有给实现层口径。对照只能写到各家文档写过的地方,替谁补一句实现层结论都不合适。Jolt 这一侧的做法不同,它把两条路都做成默认可见的设置项,角色侧的坡限还给了 50 度的默认值,调用者要改的是数值而不是机制。
把这六家按三个统一维度排成一张表,一列是「角色是不是世界里的形状」,二列是「进不进宽相」,三列是「移动的到底是什么」。
| 引擎 | 角色是不是世界里的形状 | 进不进宽相 | 移动的到底是什么 | 官方口径出处 |
|---|---|---|---|---|
| Jolt 刚体式 | 是一个 keyframed 刚体 | 进 | 刚体自己 | 架构文档 |
| Jolt 虚拟式 | 不是,只用碰撞检测 | 不进,可另建内层刚体 | 一次扫掠算出来的位移 | CharacterVirtual.h |
| Box2D v3.1 | 不是,也不在仿真世界里 | 不进 | 自定义 mover 的形状扫掠 | Box2D 角色文档 |
| Bullet | 不是,用幽灵物体代位 | 进(幽灵物体占宽相一份索引) | 幽灵物体的位移 | Bullet 角色控制器源码 |
| Rapier | 是,移动的是真实碰撞体 | 进 | 真实碰撞体,只平移不旋转 | Rapier 角色控制器 |
| Unity | 是,CharacterController 继承自 Collider |
进 | Move() 驱动力 |
Unity 角色控制器 |
| Unreal | 文档未给实现层口径 | 文档未明 | 由移动组件处理 | UCharacterMovementComponent |
这张表里有一格是空的,Unreal 那一行的后两列。官方文档只写到行为层,底层是扫掠还是刚体、进不进宽相都没有交代,把空格留着比替它填一个猜测更合适。六家的分布形状本身也有信息量:从「完全不在世界里」到「本身就是形状」排成一列,Jolt 是唯一一家把这两端都做成可选设置项的。
10.2 五家载具:全在同一族里
开始正文的对照。先来看看载具(vehicle)这一侧,它的分野比角色侧小得多。把五家的官方口径排开,会发现它们全落在同一族里:一条单刚体(single rigid body)底盘,加逐轮探地、弹簧悬挂与轮胎力。Bullet 的 btRaycastVehicle 官方称它是一个把刚体变成载具的特殊约束,轮胎调参落在 btWheelInfo 里,m_suspensionStiffness 与 m_frictionSlip 各管一条曲线,探测器只有射线(ray)一种,也没有引擎、变速箱与差速器的建模。PhysX 的载具把车建模成一串簧上质量(sprung mass),每条悬挂线(suspension line)各带一套轮与轮胎数据,官方写明每次更新都从为每条悬挂做一次场景查询(scene query)开始,轮胎模型可以从低保真换成 Pacejka。
再往下列。Unity 的 WheelCollider 官方写它内建碰撞检测、轮物理与基于滑移(slip)的轮胎摩擦模型,悬挂只能沿竖直的 Y 轴,轮子有一个永远想回去的目标位置,这个限制换来的是实现简单。Rapier 的载具控制器官方直接承认,当前实现移植自 Bullet 的 btRaycastVehicle,轮子只由射线投射表示,射线沿着一条像弹簧一样的悬挂把车身推出去,且没有引擎、变速箱与差速器建模。Unreal 的 Chaos 载具官方写悬挂力一般作用在 Z 轴,用射线去找地面,再把接触点与悬挂长度喂给悬挂约束,并且这套东西无论造汽车还是造摩托都一样。五家里唯一把传动链做全、又同时内置履带与摩托两个专用控制器的,是 Jolt 这一侧。
同样按统一维度排一张表,五列分别是底盘、探测方式、悬挂自由度、传动建模与专用控制器。
| 引擎 | 底盘 | 探测方式 | 悬挂自由度 | 传动建模 | 专用控制器 |
|---|---|---|---|---|---|
| Jolt | 单刚体 | 射线、球、圆柱三种可选 | 沿挂点给出的任意方向 | 引擎、变速箱、差速器、横向稳定杆四件齐全 | 轮式、履带、摩托三个 |
| Bullet | 单刚体 | 只有射线 | 沿悬挂方向 | 无 | 无 |
| PhysX | 一串簧上质量 | 每条悬挂线各一次场景查询 | 沿悬挂线 | 扭矩曲线与轮胎模型可换 | 无 |
| Unity | 单刚体 | 由 WheelCollider 内建 |
只允许竖直 Y 轴 | 无 | 无 |
| Rapier | 单刚体 | 只有射线 | 沿射线,像一根弹簧 | 无 | 无 |
| Unreal | 单刚体 | 射线找地面 | 力一般作用在 Z 轴 | 有扭矩曲线 | 汽车与摩托共用一套资产 |
读这张表可以省掉一次误判:五家的差别不在要不要底盘刚体,那一层已经统一;差别全落在探测方式、悬挂自由度与传动建模这三列上。
10.3 一处共同的分野:传动建模的多少
说白了,五家载具的差别不在要不要底盘刚体,那一层已经统一;差别落在三件事上:探测方式、悬挂自由度与传动建模的多少。探测方式这一侧,多数家只有射线一种,Jolt 给了射线、球与圆柱三种,E8 用半径 300 mm 的轮子、240 步跑同一段搓板路,量出三者给出的悬挂最小长度分别是 317.0897、338.7630 与 348.1577 mm。悬挂自由度这一侧,Unity 只允许竖直 Y 轴,Jolt 的悬挂可以沿挂点给出的任意方向。传动建模这一侧,Bullet 与 Rapier 停在射线加弹簧,PhysX 与 Unreal 给到簧上质量与扭矩曲线,Jolt 把引擎、变速箱、差速器与横向稳定杆四件都做成独立对象,另外单独给了履带与摩托两个控制器。
有一点六家里是一致的:没有一家用逐轮真实刚体加铰链(hinge)的做法。Rapier 官方在载具文档里解释了原因,那样做很难控制,所以五家都退回到刚体加虚拟轮这一族。这条共同选择反过来也解释了 Jolt 的类注释为什么要写一句使用纪律:把约束登记成 StepListener,轮子的探测与力矩需要在每个物理步(physics step)里被显式跑一遍,而不是靠求解器顺手带过去。
源码落点:两条路的官方定义见 架构文档,Box2D 的角色移动器口径见 Box2D 角色文档 与 Box2D 3D 角色文档,Bullet 的幽灵物体实现见 Bullet 角色控制器源码,PhysX 的 collide and slide 见 PhysX 角色控制器,Rapier 的两页见 Rapier 角色控制器 与 Rapier 载具控制器,Unity 的两页见 Unity 角色控制器 与 Unity 轮碰撞体,Unreal 的两页见 UCharacterMovementComponent 与 混沌模块化载具,Bullet 载具与轮胎调参见 btRaycastVehicle 源码 与 btWheelInfo.h,PhysX 的簧上质量见 PhysX 载具;其中:六家角色控制器按形状在不在世界里排成一列,Box2D 与 Jolt 在不那一端,五家载具则全部落在单刚体底盘加逐轮探地这一族,没有一家走逐轮真实刚体加铰链。
十一、边界、咬合处与版本时间线
11.1 两处硬线:下坡贴地与坡限
正餐开始,先把两条线各自最容易踩的边界收一收。角色侧有两条硬线,一条是下坡贴地(stick to floor),一条是坡限(slope limit)。第一条机制的原理是:角色走下坡时,它的水平速度会把身体带得比地面更快,下一帧落点就在地面之上,于是身体开始一帧一帧地弹起来;贴地那一步在每帧把角色往下按,按到地面为止。第二条机制的原理是:坡面角超过某个阈值之后,角色不再算站在地面上,官方把这个判据叫 IsSlopeTooSteep,阈值的默认值是 50 度。
pred_steep 与实测状态全部对上,45 度坡配 30 与 40 度上限是 OnSteepGround、配 50 与 60 度上限是 OnGround,翻转点落在 45 到 50 度之间。这是官方用例场景的本机复现,不是官方数值@ 本机基准实测(原创)E2 与 E3 是官方用例场景的本机复现,不是官方数值。E2 取 10、30、50 三档坡,横穿 1.5 s:贴地打开时三档都是 90 步全在地面、0 步离地,竖直位移分别是 -2644.9055、-8660.2588 与 -17876.3203 mm,与解析值 -2644.9045、-8660.2539 与 -17876.3047 mm 逐档对得上;贴地关掉时,三档的在地面步数掉到 4、1、0,离地步数升到 86、89、90。E3 固定 45 度坡去扫坡限,上限取 30 度与 40 度时角色是 OnSteepGround,水平位移 -5702.1074 mm,也就是滑回去了;上限取 50 度与 60 度时角色是 OnGround,水平位移 +1377.3798 mm,也就是爬上去了;反过来固定 45 度上限去扫坡面角,35、40、45 度三档都是 OnGround(位移 +1897.8029、+1639.7113、+1377.3798 mm),50 度与 55 度两档翻成 OnSteepGround(-5615.4629、-5358.2061 mm)。九行的预判与实测状态全部对上,翻转点落在 45 到 50 度之间。
Winchester Center station track 1 wheelchair ramp August 2025.jpg(CC BY 4.0,4300streetcar)实物层面的对照是一条无障碍坡道。同一条坡,人能走上去、车却可能要换挡,坡角在这里是一条硬线。角色侧的那条硬线是坡限,载具侧也有一条同性质的量,叫最大俯仰横滚角(max pitch roll angle),它约束的是车身在坡上的姿态,两者都写在设置里而不是写在求解器里,改它们等于直接改哪些地形算可通行。
11.2 与前后篇的三处咬合
先来看看这一篇在系列里的三个接口。第一处接第十一篇:第十一篇收尾时留了一句角色控制器不在宽相里、也就不走那一篇的 CCD 路径,本篇把这句话写全了。更准确的说法是,虚拟角色默认没有仿真刚体、常规射线打不到它;一旦给它设了内层刚体(inner body),常规碰撞检查就能打到它,高速的 LinearCast 物体一个时间步内也穿不过去,而角色的本体仍然不在 PhysicsSystem 里、不被跟踪、也不进快照。第十一篇那句边界句因此要收窄成默认没有刚体,加上内层刚体之后它能被射线看到、也能拦住高速体。
第二处接第八篇:载具(vehicle constraint)本身就是一个约束,同时还是一个步监听器(StepListener),官方在类注释里专门写了一句使用纪律,提醒别忘了把它登记进物理系统。第八篇讲的那套约束与求解在这里收口:车的悬挂与轮胎是在每个物理步里被显式跑一遍的,约束求解器负责的是把底盘按约束解出来。第三处预告第十三篇:柔体(soft body)与发束(strand)不在本篇的角色与载具路径里,本篇只登记边界,把这一侧留给下一篇。
11.3 版本演进与官方配图的空缺
依然是惯例,把这一篇用到的机制按版本铺开。角色侧的确定性那件事是分两步补齐的:v5.3.0 给每个虚拟角色发了一个 CharacterID,官方注释写明它的用途是在角色被移除之后仍能识别,以及在多个虚拟角色互撞时让仿真是确定的。同一条版本记录里还加了内层刚体 ID 的覆盖(override),用途是客户端与服务端各自复现。再往后是 v5.4.0 对角色段的更新。载具侧的接口相对稳定,四个传动件与三个控制器的形状在这些版本里没有大改。
Docs/ReleaseNotes.md):v5.3.0 引入角色编号与确定性排序,v5.4.0 更新角色段,本篇快照为 v5.6.0 口径@ 本系列绘制这里还有一处与配图有关的空缺要写清。官方 Docs/Images 一共 14 张图,逐张清点之后,与角色相关的 0 张、与载具相关的 0 张,MotorDamping 与 MotorFrequency 讲的是约束马达而不是车。两条主线都没有官方配图,机制图只能自绘,证明件只能靠本机真图,也就是源码原文截取与基准数据图。这条空缺与第十一篇确定性那一侧官方 0 图是同一情形,本系列把这类情况当作一条可复核的结论写进正文,而不是当成一句找不到素材的托辞。
11.4 边界清单与收束
OK,把边界一次说全。角色侧:常规射线打不到虚拟角色,除非设了内层刚体。角色与角色之间不会自动互撞,要应用自己接 CharacterVsCharacterCollision 接口,并在自己的排序键上排好队。角色状态不在物理系统快照里,要用角色自己的录放接口。本机库带着 JPH_USE_FMADD,与跨平台确定互斥,所以角色的确定性只覆盖同一份二进制那一档。载具侧:碾过很轻的碎石时车身会下沉,官方给了三条缓解,分别是提高速度迭代步数、缩小质量差、过滤掉车辆测试与碎石之间的碰撞。摩托控制器官方自认仍在开发、平滑系数帧率相关,固定步长两边不一致时会慢慢分开。悬挂有最小长度这一端的硬点,HasHitHardPoint 是把这一端翻成布尔的读数口。还有一处用法边界是必须在写代码时记住的:刚体式角色要先从物理系统里摘出来再让它析构,这是官方样例 Samples/Tests/Character/CharacterTest.cpp 第 20 行的写法,本机首跑正是崩在这条上。
好了,回到这一篇的开头那句,成品件的调参要从一块可重复的场地开始。两条主线走下来,角色侧的三件套与四把旋钮回答的是一步怎么走稳,载具侧的约束、探测器、悬挂与控制器回答的是一辆车怎么被几个可数部件拼起来,两边的确定性与边界则在 CharacterID 和 StepListener 这两处收口。E7 在 300 步里五个变体的首个不一致步号全是 -1、末态哈希同为 7509146118703772105,E9 的六档静置悬挂长度落在 364.3097 到 464.3099 mm 之间,E10 的末位 x 从 -1399.9133 mm 走到 -2349.1138 mm,这三组数各管一段机制,也都落在一个可复跑的场景里。把每一组读数落回一个可复跑的场景,才谈得上调参。
源码落点:坡限与 IsSlopeTooSteep 见 CharacterBase.h,贴地与走台阶的编排见 CharacterVirtual.cpp,内层刚体的三条作用与状态不存快照的注释见 CharacterVirtual.h,官方贴地用例与上台阶用例见 角色用例,版本条目见 版本发布说明 与 接口变更记录,样例说明见 样例文档;其中:E2 与 E3 是官方用例场景的本机复现、不是官方数值,九行坡限预判全部对上、翻转点落在 45 到 50 度之间,而角色侧与载具侧的边界合起来,就是本篇划出的适用域。
资源清单
源码与官方文档(v5.6.0 raw,行号口径见研究笔记):CharacterVirtual.h、CharacterVirtual.cpp、CharacterBase.h、Character.h、CharacterID.h、VehicleConstraint.h、Wheel.h、VehicleCollisionTester.h、WheeledVehicleController.h、TrackedVehicleController.h、MotorcycleController.h、VehicleTrack.h、VehicleEngine.h、VehicleTransmission.h、VehicleDifferential.h、VehicleAntiRollBar.h、PhysicsSystem.h、Architecture.md、Samples.md、ReleaseNotes.md、APIChanges.md、CharacterVirtualTests.cpp、WheeledVehicleTests.cpp、LoggingCharacterContactListener.h。
官方样例(本篇用到的四个入口):角色样例、虚拟角色样例、飞船局部坐标系样例、载具样例、摩托样例、载具压力样例。三个控制器的公共基类在 VehicleController.h。
相关链接(作者一手与竞品官方):官方仓库、采用该引擎的项目清单、多核扩展论文、Box2D 角色文档、Box2D 3D 角色文档、Box2D 3.1 发布说明、Bullet 角色控制器源码、Bullet 角色控制器头、Bullet 载具源码、btWheelInfo.h、PhysX 角色控制器、PhysX 载具、Rapier 角色控制器、Rapier 载具控制器、Unity 角色控制器、Unity 轮碰撞体、Unreal 角色移动组件、混沌模块化载具。
素材来源(Wikimedia Commons,实物图):试车场、石阶、避震前叉、坦克履带、无障碍坡道。
把上面这些按来源做一次归类,共五类,便于复查:官方源码(CharacterVirtual.h/.cpp、CharacterBase.h、CharacterID.h、Wheel.h、VehicleConstraint.h、三个控制器头文件等);官方文档(Architecture.md、Samples.md、ReleaseNotes.md、APIChanges.md);官方测试与样例(CharacterVirtualTests.cpp、WheeledVehicleTests.cpp、LoggingCharacterContactListener.h、MotorcycleTest.cpp);作者一手与竞品官方(多核扩展论文、Box2D、Bullet、PhysX、Rapier、Unity、Unreal);素材来源(Commons 的四张实物照片与题图)。五类里前四类指向同一套出处体系,即官方仓库的 v5.6.0 标签;最后一类提供的是实物类比,不承担机制证据。
下篇预告
下一篇进入《Jolt Physics 3D》第十三篇,谈柔体与发束:柔体(soft body)怎么被压进一个以刚体为主的求解器,头发与布料这类细长约束(strand)又是怎么在大变形下保持稳定。本篇登记的那条边界会在下一篇展开,看看不参与角色与载具路径的柔体,是怎么和同一个世界里的刚体相互作用的。
With Best Wishes.