站点链接:技术博客页 | 公众号:溯光的图形笔记
一帧里最惊险的一瞬,往往短到没有被任何一次采样看见。一颗半径 100 mm 的小球以 60 m/s 冲向一块 20 mm 厚的薄板,在 1/60 秒的时间步(time step)里,它的单步位移是 1000 mm,薄板的整个厚度在这个位移面前只占 2%。如果引擎只在这一步的起点和终点各看一眼,小球在两次采样之间根本没有和薄板同时出现过,它就那样从板中穿了过去,仿佛什么都没碰到。连续碰撞检测(continuous collision detection,CCD)要处理的正是这一幕:把一瞬切开,看清它到底撞上了什么。
先看题图。
Bullet Time, Time Slicing.JPG(CC BY 3.0)照片里一排相机围成半圈,同一次起跳被拆成许多个略微错开的机位(题图出自 Commons 的时间切片文件页)。单看一张,只有一个瞬间;把整圈拼起来,动作的连续性才显出来。物理引擎碰到的难题几乎是同一件事:它没有连续的眼睛,只有一格一格的时间。离散步进(discrete stepping)像只摆一两台相机,运动快过采样间隔,中间发生的一切就落在两次快门之间。CCD 的做法是沿着这一格里的路径再扫一遍,找出路径上第一次碰到东西的位置,把物体放到那里。
本篇是 T06《Jolt Physics 3D》系列的第十一篇。上一篇把一帧里的 body 切成了一座座仿真岛(simulation island),并发只改求值顺序、不改结果,这是它留下的线索。本篇把这条线索往两头拉:往物理这一头,追问高速体为什么会被「拦下」、拦下它的那一步到底动了什么;往工程这一头,追问同一份输入凭什么跑出同一份世界。官方把「只复制输入就能远端复现」写成了卖点,同一句里又把「限制」推给另一节(见 README 的确定性条目),这份承诺与它的边界,正是本篇后半要摆开的东西。全系列目录里,本系列这一篇排在岛与睡眠之后、角色与载具之前,是「一步怎么解」讲完之后转向「高速与复现」的那一篇。
三条主线的落点先摆清。触发与「偷时间」:高速体为什么会被拦下、被拦下的那一步少走了多少,落点 §零至§三。两个旋钮与「取小」:允许残留多少穿透、两个旋钮谁说了算,落点 §四至§七。确定性与校验回路:同样的输入凭什么给出同样的世界、这份确定性又怎么被检验,落点 §八至§十。两条主线的咬合处、四家引擎的横向对照与边界,落在 §十一。
边界也先划一道。求解迭代(iteration)的数学在第九篇,岛与睡眠在第十篇,角色与载具(character、vehicle)成品件在第十二篇,柔体与发束(soft body、hair)在第十三篇。本篇只碰两档运动质量、触发与「偷时间」、两个旋钮与两个闸门,以及确定性与校验回路这几块。
版本口径写清一次,本篇给的所有行号都按它取。本机快照是 v5.5.0-183-g765845d6,它是 tag v5.6.0(e77f1755)的祖先,两者只差 2 个提交、只动了 4 个文件。本篇要引的 25 个源码与文档文件在这两点之间逐字节相同,所以文中的行号对 raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/… 这套链接同样成立。唯一会错行的是 Build/CMakeLists.txt,它在插入点之后整体偏移 6 行,本篇只引插入点之前的行。
OK,把范围收好,从「一次 step 里什么被拦下」这个入口进去。
入口:一次 step 里,什么被「拦下」了
一句话说清这一节:物理的一步里,真正要拦的只有那些一步就跨过了别人的 body,拦不拦由一个叫运动质量(motion quality)的档位决定。这一节把入口摆出来,并交代本篇的边界与实验口径。
0.1 高速体为什么会穿过去
先来看看,离散检测(discrete collision detection)的处境。引擎把连续的时间切成固定的一段一段,每一步只在一个位置检查一次碰撞,然后按速度把 body 直接挪到下一个位置。位移越大、物体越薄,两步之间的空隙就越容易盖住整个物体。Architecture.md 第 671 行有一段原文把这个现象讲得很直白:Fast and small objects can easily 'tunnel' through thin objects because they can completely move through them in a single time step(又小又快的物体很容易「隧穿」过薄物体,因为它们能在单个时间步里完整地穿过对方)。
把这句换算成本篇入口的那个例子:球半径 100 mm,薄板厚 20 mm,速度 60 m/s,步长 1/60 秒,单步位移 1000 mm。板厚只占单步位移的 2%,球在这一步的位移里从头到尾把板跨了过去。两次采样一个在板前、一个在板后,离散检测看不到任何重叠,crossed 这一格自然就被打上了 1。
隧穿(tunneling)的根源是一个采样密度(sampling density)问题。要让它不发生,检查点必须比物体走完自身尺寸所需的距离更密。提高帧率(frame rate)是一种办法,代价是全局变慢;只在可能出事的物体上加密检查是另一种办法,代价小得多。Jolt 走的是后一条路。
把「检查点要比物体尺寸更密」这条判据落到数字上,能看出为什么高速小体最危险。一颗半径 100 mm 的球,直径是 200 mm;要保证它不漏检,相邻两个检查点的间距就不能超过 200 mm。单步位移 1000 mm 时,一步里至少要布 5 个检查点才够密,而离散检测只布了一个,漏洞就出现在剩下的 4 段里。速度再翻一倍,所需检查点数也跟着翻倍,靠加密采样追上速度,成本会一路涨上去。
0.2 「拦不拦」的分水岭
好了,把这条后路落成一个可切换的档位,就是运动质量。官方在 MotionQuality.h 里给了两档,Discrete 与 LinearCast,两者在同一个场景里的行为差别,正是本篇反复要用的分水岭。Discrete 的做法是一步直接落位,位移积分算多少就走多少,碰撞只在落点上检查一次;LinearCast 的做法是在落位之前,先把碰撞形状沿这一步的位移投出去扫一遍,找出路径上最早的命中点。
这个档位是逐 body 设置的,同一帧里两档可以并存。一个场景里可以让大量普通物体保持 Discrete,只把几颗子弹设成 LinearCast,代价就落在那几颗上。两档的正式定义在 §一逐字读,这里先记住分水岭的位置:跨步位移一旦超过「内半径(inner radius)的一个比例」,LinearCast 这一档就会在落位前多扫一遍。
档位之所以做成逐 body 的属性,是为了把成本压在少数几个物体上。一个场景里可能有上百个 body 常年在做慢速运动,把它们全部换成 LinearCast,等于让每一个都多付一次扫掠;只把几颗子弹、几块高速碎片设成 LinearCast,多出来的活就落在那几颗上。这也是本篇后面反复要量的一件事:同一档位,作用范围决定它值不值。
0.3 本篇的边界与实验口径
说白了,边界声明写进正文,供后面的交叉检验用。
实验口径一并交代,供复现。睡眠(sleeping)一律关闭,避免睡眠把本该测的体从活跃集合里摘走;CCD 类实验里的体负责低速也不睡,保证每一步都真被检查。编译复用前面几篇的 Jolt.lib,四篇的编译开关集完全一致,零补丁、零 instrumentation。一处口径必须写清:本机库带着 /DJPH_USE_FMADD,所以本机的实测只覆盖同一份二进制内的确定性,跨平台那一档不在本机读数里。
源码落点:两档定义的原文在 MotionQuality.h 第 9 至 27 行,隧穿现象的描述在 Architecture.md 第 669 至 677 行。其中:前者给出 Discrete 与 LinearCast 两档的一手定义与各自的代价,后者用一句话解释了「又小又快」为什么最容易穿。
一、两档运动质量:Discrete 与 LinearCast
OK,一句话说清这一节:Discrete 与 LinearCast 的差别只在落位前多不多扫一遍,定义、代价与残余风险全写在同一个头文件的 11 行里。这一节读这段定义原文、看官方对照图,再把两档在同一帧里的分叉讲清。
1.1 官方给的两档定义
言归正传,先读 MotionQuality.h 的原文。第 9 行是总纲:Motion quality, or how well it detects collisions when it has a high velocity(运动质量,也就是一个 body 在高速下检测碰撞的能力)。这个词量的是高速下的检测能力,与物体的质量本身无关。
Discrete 的定义在第 12 至 13 行:Update the body in discrete steps. Body will tunnel through thin objects if its velocity is high enough.(以离散步进更新 body,速度够高时它会穿薄物体。)紧接着一句 This is the cheapest way of simulating a body.(这是模拟一个 body 最便宜的方式。)两句话把这一档的定位交代完:最省,代价是会穿。
LinearCast 的定义在第 16 至 22 行,整段给出了能力、代价与两处残余风险(residual risk)。能力是:The body will not be able to tunnel through thin objects at high velocity(高速下它不会穿薄物体)。代价是:Time is stolen from the object (which means it will move up to the first collision and will not bounce off the surface until the next integration step). This will make the body appear to go slower when it collides with high velocity.(时间被从物体上「偷走」,它只走到第一次碰撞处,下一步才从表面弹开;高速碰撞时它看起来会变慢。)这一档的做法是以起始旋转(starting rotation)把碰撞形状(collision shape)从起点投到终点。残余风险有两条,一条是细长且角速度(angular velocity)高时仍可能穿,另一条是为了不卡死而始终允许它移动内半径的一小段,这一点最终可能让它穿过几何体。
MotionQuality.h:9–28):Discrete 会以高速穿过薄物体;LinearCast 用起始旋转把碰撞形状从起点扫到终点,但「又长又薄 + 高角速度」仍可能穿,并且会从物体身上偷走时间@ Jolt Physics v5.6.0 源码原文截取(MIT)这段定义里有一个词后面会反复出现,就是「偷走」(stolen)。它在官方文档里还有第二个落点,Architecture.md:671 与 MotionQuality.h:19 是同一个词的官方双落点,§四会把它的解析表达补上。
还有一处误读要点。第 24 至 27 行提醒:用碰撞监听器(collision listener)时,LinearCast 之间会先记下接触再撤回,调用方可能收到最终并未发生的接触回调。这是「开了 CCD 就只多拦几个」这种想法的第一处反例。
1.2 官方对照图
下面开始,看官方自己画的对照。
Discrete 让高速体穿过板面,LinearCast 把它拦在板面前@ Jolt Physics v5.6.0 官方文档配图 Docs/Images/MotionQuality.jpg(MIT,Jorrit Rouwe)这张图出自 Architecture.md:673,是官方为 CCD 段配的主图。同一个高速体、同一块板,左边 Discrete 一步跨过去,右边 LinearCast 在板前停下。图本身没有数字,但它把两档的差别压成了一组并排的落点,后面的实测读数(§三的 E0)正好是这张图的量化版。官方另配了一张 细长体的对照图,指出细长物体在高角速度下旋转仍可能穿过去,§五会展开这条残余风险。
两档的成本差别也能从这张图读出来:Discrete 每个体只做一次落点检测,LinearCast 要多做一次形状扫掠(shape cast)。默认阈值 0.75 乘内半径这道门槛,正是为了让这次额外扫掠只在「这一步确实走得够远」的体上发生;门槛之下的体,走的还是和 Discrete 一样便宜的那条路。
1.3 同一帧里的分叉
先来看看,两档的差别究竟发生在帧里的哪一步。位移积分(integration)算完之后,两档走的路才开始不同。Discrete 拿到位移,直接把它加到位置上,这一帧就结束了。LinearCast 拿到位移,先算一件事:这段位移的长度平方,有没有超过「阈值(threshold)乘内半径」的平方。没超过,它照旧直接落位;超过了,它才被登记成一个 CCD 体,留给后面的扫掠工序处理。
delta_pos 之后,Discrete 直接加位移落位;LinearCast 先比一次平方,超过「阈值 × 内半径」就登记成 CCD 体、本步先不落位@ 本系列绘制这条分叉的判据是一行代码,§三会把它逐字读出来。这里先记住一个概念:两档共用同一条位移积分,分歧只发生在「落下之前要不要多看一眼」。
这里还要补一处容易踩的坑。LinearCast 体之间会先互相记下接触,再在收口时撤回,官方在第 24 至 27 行特意提醒,用碰撞监听器的代码可能收到最终并未发生的接触回调(collision callback)。写事件驱动的逻辑时,回调不等于真的撞上了,这一点要在使用时单独处理。
官方把两档混用的情形单独做了一组用例,落在 MotionQualityLinearCastTests.cpp,8 个用例分别覆盖 LinearCast 体撞慢速 Discrete 体、撞快速 Discrete 体、斜撞、撞静止体等情形。这说明两档共处一帧是本层的真实边界,不是边角情况。作者在多核扩展论文里也把两档的命名口径讲死了:Discrete 就是 CCD 关、LinearCast 就是 CCD 开(见 JoltPhysicsMulticoreScaling.pdf)。
源码落点:两档定义在 MotionQuality.h 第 9 至 27 行,对照图在 Docs/Images/MotionQuality.jpg,两档混用的官方用例在 MotionQualityLinearCastTests.cpp,命名口径在作者的多核扩展论文。其中:前两者给出定义与直观对照,用例文件给出两档混用的覆盖范围,论文给出两档命名与 CCD 开关的对应关系。
二、CCD 在一帧里的位置:五段 job
好了,一句话说清这一节:CCD 在引擎的一次更新里被拆成五段作业(job),从准备缓冲到按命中比例收口,谁登记、谁扫掠、谁把体推到位,分工写得很死。这一节按作业链的先后把这条流程走完。
2.1 五段 job 链
依然是惯例,先读官方给的链条。Architecture.md 第 985 至 1005 行把 CCD 在一步里的位置列成五段标题,顺序本身就是正确顺序:Pre Integrate → Integrate & Clamp Velocities → Post Integrate → Find CCD Contacts → Resolve CCD Contacts。
Architecture.md:985–1005):Pre Integrate → Integrate & Clamp Velocities → Post Integrate → Find CCD Contacts → Resolve CCD Contacts@ Jolt Physics v5.6.0 官方文档原文(MIT)每一段在链条里的职责都有一句官方注释。第 987 行说 Pre Integrate 这一段的活是 This job prepares the CCD buffers.(准备 CCD 的缓冲区)。第 993 行说积分段会在 body 的移动超过某个基于内半径的阈值时,把它安排进连续碰撞检测。第 997 行说 Post Integrate 这一段的活是按实际登记到的 CCD 体数,临时创建 Find CCD Contacts 作业。第 1001 行说扫掠段会对每个 CCD 体做一次线性投射,找出第一次碰撞。第 1005 行说收口段会把体的时间「偷走」,让它比按速度应走的位置少走一段。
这条链把一个容易混淆的概念摊开了:CCD 是一个嵌在常规积分流程里的一串附加段。大部分 body 走完前三段就结束了,只有被登记成 CCD 体的那部分才会进到后两段。
五段的先后不是随手排的。前面两段先把速度与位置算出来、并准备 CCD 缓冲,中间一段才知道这一步到底有哪些体需要扫,后面两段才动手扫与收口。缓冲先备、名单后定、扫掠再开,这个次序让「要扫多少个」这件事在派活之前就有了确定答案。
2.2 分流与登记
开始正文,看谁有资格进这条附加链。PhysicsSystem.cpp 第 1656 至 1676 行是一段 switch,按 body 的运动质量分流。Discrete 分支里只有一句注释 // No additional collision checking to be done(没有额外的碰撞检查要做),直接结束。
LinearCast 分支的进门条件写在第 1656 至 1657 行:if (body.IsDynamic() // Kinematic bodies cannot be stopped 与 && !body.IsSensor()) // We don't support CCD sensors(要求是动态体,因为运动学体停不下来;且不能是传感器,因为不支持 CCD 传感器)。这两行是两条硬边界:运动学体(kinematic body)的轨迹由代码直接指定,扫掠拦不住它;传感器(sensor)只报告重叠、不产生物理响应,也不进这条链。
进门之后先取内半径,第 1659 行的 float inner_radius = body.GetShape()->GetInnerRadius(); 后面紧跟一条断言(assertion):形状必须有内半径,理由是无法在没有内半径的形状上安全地移动,否则就有穿模风险。球体与胶囊体有内半径,薄板这类形状没有,也就无法用 LinearCast。接着第 1664 至 1665 行算阈值并与位移比较,命中阈值的体才在第 1668 至 1673 行被登记进 ioStep->mCCDBodies,写入 update_position = false,把这一步的位置更新先挂起。
PhysicsSystem.cpp:1655–1673):只有动态体且非传感器才进 LinearCast 分支;判据是一行平方比较;命中后把体登记进 mCCDBodies,并把本步的位置更新推迟到收口@ Jolt Physics v5.6.0 源码原文截取(MIT)登记动作本身很轻,一个自增下标加一次就地构造,但它决定了后面有多少活要干。没有被登记的体,后两段作业根本不认识它。
2.3 读-写时序
OK,把五段连起来看读写关系。Post Integrate 这一段的写动作是登记 CCD 体,它往 mCCDBodies 缓冲区里一个一个填;填了多少,Find CCD Contacts 就派多少个作业,官方口径是 on the fly(按需即时创建)。派活数由登记到的 CCD 体数除以每作业容量再向上取整、并与最大并发数取小得到,所以 CCD 作业的数量是随场景浮动的,不是一个固定分片。
派活的写法值得单独看一眼。作业数由 ioStep->mNumCCDBodies 与每作业容量算出来,向上取整后再与 GetMaxConcurrency() 取小,官方把这一段描述成按需创建。取小这一步是必要的:登记的 CCD 体再多,也存在一个并发上限(concurrency limit),超过了只是把每个作业塞得更满。与固定分片相比,这套做法让 CCD 的作业数跟着实际命中名单走,名单为空时后两段几乎不产生开销。
Find CCD Contacts 这一段只读不写位置,它并行地对每个 CCD 体做线性投射,算出命中比例(fraction),写进各自的记录项。真正改位置的动作留到最后一段:Resolve CCD Contacts 先把所有命中按 fraction 排序,再按顺序收口,把每个体推到命中点。
每个 CCD 体在扫掠与收口之间要用一张字段表来传递信息,这张表在 PhysicsUpdateContext.h 的 Step::CCDBody 里,§四会逐字段读。收口段之所以要先排序,官方在 PhysicsSystem.cpp 第 2204 至 2207 行给了动机:为了让模拟确定,也为了在发现更早命中时能停止后续接触处理。这一句是本篇两条主线咬合的第一处证据,§十一会再回到它。
源码落点:作业链在 Architecture.md 第 985 至 1005 行,分流与登记在 PhysicsSystem.cpp 第 1656 至 1676 行,字段表在 PhysicsUpdateContext.h 第 99 至 115 行。其中:文档给出五段的先后与各自职责,源码给出进门条件与登记写法,字段表是扫掠与收口之间传递命中的载体。
三、触发:多快才算「需要扫」
正餐开始,一句话说清这一节:一个 LinearCast 体要不要真的被扫一遍,取决于它的单步位移有没有超过「阈值乘内半径」,判据是一行比较,阈值与内半径是两个输入。这一节把判据、两组实测读数与一个实物类比摆在一起。
3.1 判据写在一行里
我们正式开始,先把判据写出来。
PhysicsSettings.h:47–59 + 104–105):投机接触距离与穿透余量是求解侧的余量,两个 CCD 旋钮都写成「内半径的比例」,最后一行是确定性总开关@ Jolt Physics v5.6.0 源码原文截取(MIT)触发条件的公式是 delta_pos.LengthSq() > Square(mLinearCastThreshold * inner_radius)。左边是这一步位移的长度平方,右边是「阈值乘内半径」的平方(square),两边都是平方,避免开方。阈值来自 PhysicsSettings.h 第 55 至 56 行的 float mLinearCastThreshold = 0.75f;,注释写明 Fraction of its inner radius a body must move per step to enable casting for the LinearCast motion quality(一个 body 每步必须移动的内半径比例,达到它才为 LinearCast 启用投射)。同一个文件的第 58 至 59 行是第二个旋钮 float mLinearCastMaxPenetration = 0.25f;,注释写明它管的是「允许穿透的内半径比例」。
这里要分清两个旋钮的分工,这是本节最容易被读混的一处概念。mLinearCastThreshold 管的是「拦不拦」,是触发门槛(trigger threshold);mLinearCastMaxPenetration 管的是「拦住之后允许残留多少」,是残留上限(penetration cap),与前者是两个不同的量。默认值 0.75 与 0.25 都是「乘在内半径上」的系数,不能当成绝对长度读。
判据里那个内半径,几何含义是形状能内切进去的最大球半径,官方为此专门配了一张 ConvexRadius.jpg 讲它的来处。凸形(convex shape)在原始几何外面裹了一层等厚外壳,这层外壳的厚度就是内半径。它同时出现在触发门槛与残留上限里,所以一个形状能不能用 LinearCast、能用得多保守,先看它的内半径有多大。薄板与细长条的内半径很小,触发门槛随之变小、几乎每一步都被扫,代价也就上来了。
判据里的内半径是形状自己的属性。球半径 10 mm 时,默认阈值对应的门槛是 7.5 mm;单步位移 100 mm 时,位移远超 7.5 mm,投射必然启用。触发门槛低到这一步意味着:LinearCast 体几乎每一步都会被扫,除非它慢到每步走不出内半径的 0.75 倍。
这一节牵到的四个全局设置,各自的默认值、单位与职责列成一张表,后面每一节量到谁就回来对一次:
| 全局设置 | 默认值 | 职责 | 本篇量到它的读数 |
|---|---|---|---|
mLinearCastThreshold |
0.75 | 触发门槛,乘在内半径上,超过才扫 | E2 扫 1.00 至 12.00,分界压在比值 10 |
mLinearCastMaxPenetration |
0.25 | 残留上限,乘在内半径上,与穿透余量取小 | E3 与 E10 钉住内半径 0.08 m 的翻转点 |
mPenetrationSlop |
0.02 m | 求解器允许的穿透余量,同时是残留上限的上界 | E10 内半径 0.08 m 以上封顶 20 mm |
mSpeculativeContactDistance |
0.02 m | 投机接触的检测半径,命中之前就把体停在接触点 | E11 从 20 mm 收到 1 mm,贴面读数变成 5 mm |
3.2 E0 与 E2 两组读数
好了,把判据换成两组实测。
Discrete 走到 29000 mm 穿过、LinearCast 停在 −90 mm;六档阈值里 9.90 与 10.00 被拦下、10.10 与 12.00 穿过去,分界正压在「步位移 ÷ 内半径 = 10」这一格@ 本机基准实测(原创)第一组是 E0,球半径 100 mm、初速 60 m/s、单步位移 1000 mm、薄板厚 20 mm,两档跑同一场景。Discrete 的末位是 29000 mm、crossed=1、stop_step=-1,意味着它一步不停地穿了过去,全程没有停过。LinearCast 的末位是 -90 mm、crossed=0、stop_step=2,它在第 2 步停住,球心与起始点 -1000 mm 相比只走了一小段,被稳稳地拦在板前。
第二组是 E2,把球缩小到半径 10 mm,单步位移仍是 100 mm,于是「步位移除以内半径」等于 10.00。六档阈值扫过去:9.90、10.00 两档都被拦下(crossed=0),10.10、12.00 两档穿了过去(crossed=1,末位 3399.9990 mm)。分界正压在比值 10 这一格上:阈值乘内半径小于步位移才触发,比值跨过 10 的那一档立刻从拦下翻成穿过。10.00 那一格是靠浮点最低位才拦下的,它正好落在等号的边界上。
这两组读数合起来回答本节的问题「多快才算需要扫」。触发对应的是比值(ratio),不是绝对速度:同样的 100 mm 单步位移,内半径越小,比值越大,越容易触发;内半径越大,比值越小,同样速度可能根本不触发。阈值把这条比值的门槛从 1 调到 12,实测的翻转点与公式给出的分界完全对得上。
3.3 保龄球撞瓶:高速接触事件群
把镜头从公式挪到实物。
20200127 Bowling ball and pins for strike - front view.png(CC BY-SA 4.0,RCraig09)这张保龄球撞瓶照片是保龄球刚撞进瓶堆的一刻。球与十只瓶在极短的时间里发生一连串接触,事件密集成群。离散采样在这里最吃亏:采样间隔一旦大于两次接触之间的时间,那些「球撞倒一只、那只又撞倒下一只」的连锁相撞就会被整段跳过。CCD 要做的是沿路径找最早的那次命中,把连锁的第一环先接住,剩下的交给常规求解。
把这件事说得再具体一点。一次开球里,球在极短的时间里穿过整片瓶区,接触事件在时间轴上挤成一团;采样间隔(sampling interval)只要比相邻两次接触的间隔大,中间那几次相撞就会既没被看到、也没被记录。触发判据在这里的作用,是先判出「这一步有没有可能撞上」,只有判出可能的体才进入扫掠,扫掠再沿路径找第一次命中,保证接住的是链条最前面那一环。
这套触发机制也有官方的对照场景。作者在 Samples/Tests/General/ 下放了 HighSpeedTest.cpp,它的头文件把场景描述成「生成一批高速物体,检查它们不会穿过几何体」,同一测试里两档并置。§零 的入口例子与 §三 的 E0,就是照这个官方场景做的,这样本机读数与官方测试场景能对上。
源码落点:两个旋钮在 PhysicsSettings.h 第 55 至 59 行,触发判据与登记在 PhysicsSystem.cpp 第 1664 至 1673 行,官方高速测试在 HighSpeedTest.cpp。其中:mLinearCastThreshold 是触发门槛、mLinearCastMaxPenetration 是残留上限,触发判据把阈值乘内半径与单步位移做平方比较,官方高速测试正是 E0 场景的原型。
四、偷时间:mFractionPlusSlop 的解析表达
本节回答一个具体问题:高速体被 CCD 拦下之后,它这一步到底走了多远,少走的那一段记在哪个字段里,下一次 step 又是怎么把它还回来。
OK,先把「偷时间」(time stealing)这个词的两个官方落点钉住。类型定义侧在 MotionQuality.h:19,原文是 Time is stolen from the object;job 链说明侧在 Architecture.md:1005,原文是 its time will be 'stolen'。两处说的是同一件事,一处交代它是什么,一处交代它落在哪一段。这里的「时间」不是引擎的挂钟时间,是物体在这一步里本该走完的那段位移(displacement)。
4.1 CCDBody 的五个字段
CCD 体(CCD body)不是一种新的体类型,它是 PhysicsUpdateContext 里一个按登记顺序排开的数组,单条记录的字段表在 PhysicsUpdateContext.h:99–115。从定义(definition)上看,它要装五样东西。
第一个是 mDeltaPosition,本步位移向量的副本,也就是速度乘步长算出来的那个 delta_pos。第二个是 mFraction,第一次命中(hit)落在本步的第几成位置上,初值 1.0,含义是「没命中就走完整步」。第三个是 mFractionPlusSlop,在命中位置之后又放过一小段允许残留穿透(residual penetration),合成出来的比例,初值同样是 1.0。第四个是 mLinearCastThresholdSq,触发门槛的平方,由内半径(inner radius)算出来。第五个是 mMaxPenetration,本步最多允许残留的穿透深度,也由内半径算出来。
有一处细节要如实标出:mDeltaPosition 的注释原文写的是 Desired rotation step。字段名明明是位置(position),注释却写成「旋转步长」(rotation step),这是源码里的一处笔误。读到它时不必怀疑自己看错,也不影响字段的实际用途,它装的就是本步的线性位移。
真正定义「偷时间」的是中间那一对。mFraction 记的是第一次命中该停的位置,mFractionPlusSlop 记的是体实际被推到的位置。体最终落在哪,由后一个决定;前一个只负责在多次命中之间做取舍,谁的比例更小谁留下,这一点在 §4.2 说清。被偷掉的那一段因此能写成一条公式(formula):被偷位移 = (1 − mFractionPlusSlop) × 本步位移。
Step::CCDBody 的字段表原文(PhysicsUpdateContext.h:99–118):mDeltaPosition / mFraction / mFractionPlusSlop / mLinearCastThresholdSq / mMaxPenetration,这五个就是 CCD 的全部状态@ Jolt Physics v5.6.0 源码原文截取(MIT)这张面板把五个字段连注释一起摆出来,重点是 mFractionPlusSlop 那行的注释:命中比例再加上一段额外增量,让体允许穿透 mMaxPenetration。它就是「偷时间」的解析表达,也是后面 E1 那 10 mm 的出处。
4.2 AddHit:命中怎么折算成 fraction_plus_slop
好了,命中是从哪儿来的。FindCCDContacts 那一段对每个 CCD 体做一次线性投射(linear cast),拿到一个 0 到 1 之间的命中比例,再交给 AddHit 记进 CCDBody。折算这一步写在 PhysicsSystem.cpp:1839–1857。
折算的公式不复杂:把「允许残留穿透」从长度单位换成比例单位,除的是法线(normal)与本步位移向量的点积(dot product)。写成式子就是 比例增量 = mMaxPenetration ÷ (normal · delta_pos)。分母之所以是点积,是因为穿透是沿着法线方向量的,被偷的是沿位移方向的那一段,两个方向之间的夹角要靠点积换算。
AddHit 里还有一道取舍:只有当新的命中比例小于当前的 mFractionPlusSlop 时才改写记录(PhysicsSystem.cpp:1843)。一个 CCD 体在这一步里撞到多个面,留下的是最早那一次。mFractionPlusSlop 的初值 1.0 顺带成了另一件事的判据:命中落在步边界时比例正好 1.0,判据为假,这一帧的命中被丢掉。这条副作用留到 §六再展开。
AddHit 的折算原文(PhysicsSystem.cpp:1841–1857):分母是法线与本步位移的点积,只有分母大于允许穿透时才放宽,放宽的量正好把残留穿透填到允许值@ Jolt Physics v5.6.0 源码原文截取(MIT)面板里能直接读到分母那一项:法线与 delta_pos 的点积。那一段注释连同代码一并交代了折算的全过程,把它与前面的字段表对上,mFractionPlusSlop 的来路就完整了。
4.3 E1:那 10 mm 去哪了
言归正传,公式摆出来还不够,要看它在读数里落成什么样。E1 用一颗内半径 100 mm 的球,速度 6 m/s,按 1/60 s 的步长折下来每步该走 100 mm,恢复系数(restitution)0.9。
第 11 步命中。该走 100 mm,实际只走了 90 mm,少走的 10 mm 就是被偷掉的那一段,命中前后线速度都是 -5.4000 m/s。这里有一处容易被误读的地方要讲清:CCD 不改速度,只改位置。命中那一步速度分毫未动,体只是没走完;到下一步才反弹,反向走 90 mm(-5.4000 m/s 乘 1/60 s 正好是 0.09 m)。把这两步并排看,物体先顿一下,再照常弹回。
被偷的 10 mm 折成比例是本步的 10%。把允许穿透代进去算一遍:默认 mPenetrationSlop 是 0.02 m,内半径 100 mm 乘 0.25 是 0.025 m,两者取小得 0.02 m,也就是 20 mm;位移 100 mm 正面撞墙,点积是 0.1 m,20 mm 折成比例是 0.2;命中比例加上 0.2 之后合成落在 0.9,体只走 90 mm,剩下的 10 mm 记账到下一步。公式、默认值、读数三样能对上,这条链就闭合了。
这张读数把 §4.2 的公式从纸面拉到了台面上:20 mm 的允许穿透、0.2 的比例增量、90 mm 的实际位移,三个数互相印证,少走的 10 mm 就是它们的余数。
4.4 位移-时间曲线上的顿点
把 E1 那一段画成位移-时间曲线,前面是一段斜率恒定的直线,每步 100 mm;第 11 步缺了一个小口,只走到 90 mm;第 12 步反向 90 mm。这个口子就是「顿点」(notch)。
原理(principle)上说,这条曲线本该是一条直线,速度不变、步长不变,位移就该线性增长。实测里每个命中帧都会缺一小段,缺的这一段与 mMaxPenetration 成正比。穿透上限设得小,口子就浅;设得大,口子就深。官方在 Architecture.md:671 把这件事的观感写了一句:物体在单步里看起来慢了很多,下一步又提速回来,这正是顿点的视觉表达。
把这张图摊到整段轨迹上看,它是一条被反复切掉小段的直线:解析解每一步等距推进,数值解在每个命中帧欠一笔,欠账的深度与 mMaxPenetration 挂钩。命中稀疏时顿点稀,命中密集时轨迹整体被压低,E1 那一步 90 mm 就是密集命中里被切得最深的一格。
官方也交代了为什么不做回退。同一段写着,回退整个模拟在计算上太重,没有实现。代价因此落在物体身上,用一步的顿挫换不穿墙的自由。E1 把这段官方说法落成了 10 mm 的实测差额,把「看起来变慢」换成了一个能复算的数。
顿点的深度由穿透上限决定,穿透上限由两个旋钮取小得来,这就是下一节要拆的账。
源码落点
- [CCDBody 字段表](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Jolt/Physics/PhysicsUpdateContext.h) 第 99–115 行:其中: 第 99 行是结构体用途注释,第 104 行是 mDeltaPosition(注释笔误处),第 110 行是 mFraction,第 111 行是 mFractionPlusSlop,第 112–113 行是 mLinearCastThresholdSq 与 mMaxPenetration,后两者的注释都写明由内半径决定。
- [PhysicsSystem.cpp 的 AddHit](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Jolt/Physics/PhysicsSystem.cpp) 第 1839–1857 行:其中: 第 1853 行的 denominator = normal.Dot(mDeltaPosition) 是折算比例增量的分母,第 1854 行拦住分母过小的除法,第 1856 行是 fraction_plus_slop = fraction + mMaxPenetration / denominator,第 1857 行是多次命中的取舍判据。
- [Architecture.md 的 CCD 段](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Docs/Architecture.md) 第 669–677 行与第 1005 行:其中: 第 671 行是「偷时间」整段与不做回退的理由,第 1005 行是 job 链里的同一措辞。
五、两个旋钮与「取小」
本节回答:允许残留多少穿透,这个数由谁定,两个旋钮在什么条件下谁说了算,两者交界落在哪个内半径上。
说白了,穿透上限不由一个旋钮定,而是两个旋钮取小。两个旋钮都摆在 PhysicsSettings.h 里:mLinearCastThreshold(第 56 行,默认 0.75)管触发门槛(trigger threshold),mLinearCastMaxPenetration(第 59 行,默认 0.25)管穿透上限。前者乘在内半径上决定「多快才算需要扫」,后者乘在内半径上决定「最多允许残留多少穿透」。
5.1 取小的公式
把 CCDBody 的构造参数拆开看,第 4 个参数就是允许残留穿透,表达式是 min(mPenetrationSlop, mLinearCastMaxPenetration × inner_radius),出处 PhysicsSystem.cpp:1671。这是一个取小(min)的公式(formula),式子里有三个量:全局穿透滑移 mPenetrationSlop(第 53 行,默认 0.02 m)、旋钮 mLinearCastMaxPenetration(默认 0.25)、形状的内半径 inner_radius。
代默认值进去,后一项是 0.25 × inner_radius,前一项是常数 0.02 m。两者相等的内半径是 0.08 m,这个数是 0.02 ÷ 0.25 算出来的交界值。于是出现两段行为:内半径小于 0.08 m 时,0.25 那个旋钮说了算,残留穿透随内半径线性上升;内半径大于 0.08 m 时,mPenetrationSlop 封顶,残留穿透被压在 20 mm。
这里还有一条容易忽略的单位口径:mPenetrationSlop 与 mLinearCastMaxPenetration × inner_radius 都是长度(length),单位是米,取小之后再折成比例(fraction)时才会与 delta_pos 相除。两个旋钮的量纲一致,取小才有意义;若把比例旋钮与长度量放在一起比,就会在 0.08 m 这个交界附近出错。E3 与 E10 的读数正是沿着这条量纲线一处一处复核出来的。
为什么设计成取小,这里有一条动机。内半径大的形状本身就不容易卡死,用 20 mm 的保守上限就够防;内半径小的形状容易被自己卡住,官方把上限放宽到「内半径的一成」,代价是允许更深地嵌进对面。取小意味着两种考虑同时生效:谁更保守听谁的。mPenetrationSlop 这个全局穿透滑移(slop)之所以常驻,是为了给所有形状留一条统一的防卡死底线,让内半径这条路不至于把口径放得太宽。
mLinearCastMaxPenetration 乘内半径决定(成正比);内半径大于等于 0.08 m 时被 mPenetrationSlop 封顶在 20 mm,与内半径无关@ 本系列绘制上图画的就是这条折线:左段从原点出发,斜率等于 maxPen;右段是一条 20 mm 的水平线;两段在 0.08 m 处相接。它把两个旋钮的分工画在一张图上。
5.2 E3 与 E10:翻转点被实测钉住
下面开始对读数。E3 换了三种形状(球 r=100 mm、胶囊 r=50 mm、球 r=30 mm),把 maxPen 扫过 0.00 / 0.125 / 0.25 / 0.50 / 1.00,起点错开(gap 630 mm)。残留穿透逐档是:球 r=100 mm 给 0.0000 / 12.5000 / 20.0000 / 20.0000 / 20.0000(mm);胶囊 r=50 mm 给 0.0000 / 6.2500 / 12.5000 / 20.0000 / 20.0000(mm);球 r=30 mm 给 0.0000 / 3.7500 / 7.4999 / 15.0000 / 20.0000(mm)。三组读数都能用 min(mPenetrationSlop, maxPen × inner_radius) 复算出来,误差落在浮点最低位上。
内半径扫描见 E10:固定 maxPen=0.25,把内半径从 20 mm 扫到 200 mm。内半径 40 mm 给 10.0000、60 mm 给 15.0000、80 mm 给 20.0000、100 mm 给 20.0000、150 mm 给 20.0001、200 mm 给 20.0003(mm)。40 与 60 这两档落在左段,残留穿透随内半径线性上升;80 mm 是翻转点,0.25 × 80 mm 正好等于 20 mm;再往上进入右段,读数被 mPenetrationSlop 压在 20 mm 附近。翻转点因此被实测钉在 0.08 m 这一格上,与公式算出的交界值一致。
mLinearCastMaxPenetration = 0.25 扫内半径,残留穿透在内半径 80 mm 处翻转;小于它时与内半径成正比(40 → 10.0000、60 → 15.0000 mm),大于它时封顶在穿透余量 20 mm@ 本机基准实测(原创)E3 里还有一组对照值得单独记一笔:起点对齐(align=1)时,胶囊 r=50 mm 的五档仍全部落在预测值上(6.2500 / 12.5000 / 20.0000 / 20.0000 mm),球 r=100 mm 却五档全部贴面。同样挂着「命中落在步边界」的分组,两种形状给出两种结果,说明对齐只是接近步边界的近似标签,真正决定贴面与否的是命中比例是否恰好等于 1.0。这条线索留到 §六。
E10 里还有两档不按折线走:内半径 20 mm 与 120 mm 都给出 0.0000,贴面停住。它们不是噪声,原因是另一套机制在 CCD 之前就出手了,留到 §六。
5.3 取小管不了的那一半
先来看看这条公式的适用半径。它管的是「线性投射命中之后允许残留多少穿透」,管的是一维的、沿位移方向的量。官方在 MotionQuality.h:17–18 标了另一半:物体细长且角速度(angular velocity)高的时候,LinearCast 仍可能穿过去。那里穿透的成因在于形状在一步里转过了目标,投射用的又是起始旋转,中间那一段没有被扫到,残留穿透这个量在这里帮不上忙。§六 的 E4 会把这句话量成 52.519531 mm。
官方把这条风险单独配了图,Docs/Images/LongAndThin.jpg 画的就是细长物体在高角速度下穿过去的场面。E4 的场景与它同源:形状内切半径(inscribed radius)只有 10 mm 量级,一步转过几十度,投射方向与实际朝向对不上,扫掠扫了个空。取小公式与这张图说的是两件事:前者管「停多深」,后者管「根本停不住」。
Docs/Images/LongAndThin.jpg(MIT,Jorrit Rouwe)这张官方图是 §五 与 §七 的接点:取小公式能算的部分它先算完,算不了的那一半从这里交给风险清单。
源码落点
- [PhysicsSettings.h 的两个旋钮](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Jolt/Physics/PhysicsSettings.h) 第 55–59 行:其中: 第 56 行是 mLinearCastThreshold 与注释,第 59 行是 mLinearCastMaxPenetration 与注释,前者是触发门槛,后者是穿透上限,第 53 行的 mPenetrationSlop 是取小式里的常数项。
- [PhysicsSystem.cpp 的登记处](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Jolt/Physics/PhysicsSystem.cpp) 第 1668–1673 行:其中: 第 1671 行第 4 个参数就是 min(mPenetrationSlop, mLinearCastMaxPenetration × inner_radius),取小关系在这里落地。
- [MotionQuality.h](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Jolt/Physics/Body/MotionQuality.h) 第 16–22 行:其中: 第 17–18 行是细长高角速度风险,第 21–22 行是允许移动内半径一小段带来的穿模风险。
- [官方 LongAndThin.jpg](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Docs/Images/LongAndThin.jpg):细长物体旋转仍会穿的官方对照图,配图位见 Architecture.md:677。
六、两个闸门与三处例外
本节回答:E3 与 E10 里那几档贴面停住(残穿 0.0000 到 0.0001 mm)是噪声还是机制,以及 CCD 在哪些地方干脆不出手。
依然是惯例,先把这两档放回场景。E11 把起点每 0.05 m 扫一格,专门去撞「命中落在哪」这件事。命中比例(fraction)是否恰好等于 1.0,成了读数分道扬镳的分水岭。
6.1 闸门①:命中落在步边界,这一帧被丢掉
OK,E11 的第一组:起点 gap 为 500 / 600 / 700 mm,命中比例估到 1.000(100%),三档全部给出残留穿透 0.0001 mm、flush=1(贴面停住)。第二组把起点错开半个格,gap 为 550 / 650 mm,命中比例估到 0.500(50%),两档全部吃到 20.0000 mm 的允许穿透。
对齐到步边界就贴面的原因,按 §4.2 的取舍判据能直接推出来:命中比例 1.0 时它并不小于 mFractionPlusSlop 的初值 1.0,这一次命中根本没有被记进 CCDBody;体的 mFractionPlusSlop 停在 1.0,于是整整走完一步,恰好落在接触点上,残留穿透只剩浮点尾数 0.0001 mm。命中没丢,只是没被提前收口,走完一步正好贴上。
E11 还有一条对照把闸门①从投机接触里摘出来:gap=600 mm 那一条把 mSpeculativeContactDistance 关到 0,读数仍是 0.0001 mm、仍贴面(aligned_spec0)。说明闸门①与投机接触无关,它是 AddHit 判据的副作用。
6.2 闸门②:投机接触先一步把体停住
E11 的另一组换小内半径(20 mm)与 gap=630 mm。默认 mSpeculativeContactDistance 是 20 mm 时给出 0.0000 mm、贴面;把这条参数从 20 mm 关到 1 mm,读数立刻变成 5.0000 mm,与公式预测一致。
原理(principle)在这里:该步开始时,物体表面离墙已经在 20 mm 之内,常规窄相位(narrow phase)先建起一个投机接触(speculative contact),求解器按这个接触把体正好停在接触点上,CCD 还没轮到出手,它那一步的账就没机会记。把投机距离压到 1 mm,投机接触不再覆盖,CCD 接管,按公式放出 5 mm 的残留穿透。E3 里那几档贴面(球 r=100 mm 对齐组、球 r=30 mm 错开组)与 E10 的 20 mm、120 mm 两档,多数能归到这道闸门。
这组读数把两道闸门分开钉住:步边界那一组与投机距离无关,投机那一组与命中比例无关,两道闸门各自独立成立。
6.3 旋转不由 CCD 兜住(E4)
我们正式开始讲第三处例外。E4 用一只细长盒,绕 Y 轴 60 rad/s,内半径 10 mm,触发门槛 7.5 mm。跑两种情形。
只转不飞(spin_only,线速度 0):两档逐位相同,identical=1、hash_equal=1、位移差 0.000000 mm。原因在触发判据:登记只看 delta_pos(线性位移)够不够大,纯旋转不动位置,delta_pos 为零,体压根没进 CCD,两档走的是同一条路径。这也反过来说明,CCD 的触发问的是「这步挪了多远」,不问「这步转了多少」。
边飞边转(spin_plus_vx,线速度 -1 m/s,单步 16.67 mm 大于 7.50 mm):两档分叉,identical=0、hash_equal=0、位移差 52.519531 mm。CCD 这次被触发了,但它扫的是起始旋转下的线性位移,形状在一步里转过去的那一段没有被扫到,旋转带来的穿透它兜不住。52.519531 mm 就是「CCD 只管线性、不管旋转」这条边界的量化。
6.4 改档即失效,但不回溯(E5)
开始正文,第二处例外是运动质量(motion quality)可以在中途改。E5 用一颗内半径 10 mm 的球、40 m/s、单步 666.67 mm,起点 -2000 mm,三档各跑一遍。
全程 Discrete:末位 37999.9961 mm、crossed=1,穿过去了。第 2 步切到 LinearCast(此时还在墙前,越墙发生在第 3 步):末位 -17.5000 mm、crossed=0,拦住了。第 6 步才切(此时已经越墙):末位与全程 Discrete 逐位相同,hash_equal=1、final_x_equal=1,等于这道改档没有发生。
结论有两条:改档从改的那一步起立刻生效(第 2 步切就拦得住);改档不回溯(第 6 步切,前面每一步已经照 Discrete 走完,历史不重算,末态与全程 Discrete 一模一样)。这与官方把运动质量当成体自身的配置来对待是一致的,配置改了只影响以后,不追溯以前。
配置改了不追溯,这条性质在实战里意味着改档要发生在你希望它生效的那一步之前;晚一步,历史已经按旧档写进轨迹,后面的结果会与全程使用旧档逐位重合。E5 的第 6 步那一档给出了 37999.9961 mm 的末位,与全程 Discrete 一个数,正是这条性质最直接的读数。
hash_equal=1、位移差 0.000000 mm),加进 1 m/s 线速度后立刻分叉 52.5195 mm;改档在第 2 步(越墙前)拦得住,第 6 步(越墙后)与全程 Discrete 哈希逐位相同@ 本机基准实测(原创)把这几处并排放,CCD 的边界就清楚了:命中落在步边界会被丢掉,投机接触会先一步把体停住,旋转与改档都不由 CCD 兜住。前两条是防线提前生效或命中被跳过,后一条是防线本身覆盖面不够。三处例外合起来,就是「开了 CCD 不等于万能」这句话在读数上的样子。
三处例外说的是同一件事的三面:命中可能被跳过,防线可能被别人抢先,覆盖范围也有限度,三者分别对应 E11、E11 与 E4/E5 三组读数。
源码落点
- [PhysicsSystem.cpp 的分流与登记](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Jolt/Physics/PhysicsSystem.cpp) 第 1655–1673 行与第 1839–1857 行:其中: 第 1664–1665 行是阈值判定,第 1671 行是允许穿透,第 1843 行的 AddHit 判据是闸门①的出处。
- [PhysicsSettings.h](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Jolt/Physics/PhysicsSettings.h) 第 47–50 行:其中: mSpeculativeContactDistance 默认 0.02 m 正是闸门②的默认值出处,关到 1 mm 的对照由 E11 提供。
- [MotionQualityLinearCastTests.cpp](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/UnitTests/Physics/MotionQualityLinearCastTests.cpp):其中: 官方把 LinearCast 体撞慢、快、斜、静止离散体四种情形各立用例,说明两档混用是真实边界。
- [ChangeMotionQualityTest.h](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Samples/Tests/General/ChangeMotionQualityTest.h):其中: 官方改档用例,用来对照 E5 的即时不回溯。
七、残余风险与排除项
本节回答:开了 LinearCast 是不是就万事大吉,官方自己承认哪些风险还在,哪些体干脆不在 CCD 的管辖范围内,代价有多大。
正餐开始,先把官方自己标的风险原样摆出来。
7.1 官方标出的两条残余风险
两条风险的原文都在 MotionQuality.h:16–22 的 LinearCast 定义(definition)里,同一个段落里既有能力也有代价。第一条是:高速下不能穿薄物,但物体细长且角速度高的时候仍可能穿(第 17–18 行)。第二条是:为了避免卡死,引擎总允许体移动内半径的一小段,这一点最终可能让它穿过几何体(第 21–22 行)。
说白了,这两条不是措辞上的谦虚。Architecture.md:675 把第一条又展开了一遍:LinearCast 在步首先把物体整个转到位,再用那个朝向做刮擦,物体有机会在旋转过程中错过碰撞。第二条的机制与 §五 的取小公式接得上:允许残留穿透是防卡死的代价,穿透上限放宽到哪里,穿模的窗口就开到哪里。
还有一条官方在 MotionQuality.h:24–27 补的:用碰撞监听器(contact listener)时,可能收到最终并没有真正发生的接触回调(幽灵接触回调)。它是投射体之间先记接触再撤回的副产物,处理接触事件时要把这一点算进去。
把这三条摆在一起,官方的态度很明确:LinearCast 把「穿薄墙」这一类最常见的穿透挡住了,代价是两条由几何与旋转带来的残余风险,外加一类会污染接触回调的幽灵事件。要不要开这一档,取决于场景里更怕哪一种。
Docs/Images/ConvexRadius.jpg(MIT,Jorrit Rouwe)内半径(convex radius)在官方图谱里单独有一张,Docs/Images/ConvexRadius.jpg。看一眼它的几何含义就能明白为什么两个旋钮都乘在它身上:凸包外形里那个最小的内切半径,既是「形状多薄」的度量,也是「允许移动多少才不卡死」的自然尺度。
7.2 被明确排除的两类体
言归正传,CCD 的适用域在源码里写得比文档更清楚。PhysicsSystem.cpp:1656–1657 的两行注释直接划了界:if (body.IsDynamic() // Kinematic bodies cannot be stopped 与 && !body.IsSensor()) // We don't support CCD sensors。
第一条排除运动学体(kinematic body)。注释的理由是这类体「停不下来」:它的位置由使用者每步写死,CCD 就算算出了命中,也没有权限把它拦下。第二条排除传感器体(sensor)。传感器只报告重叠,不参与求解,没有「停下」这个概念,CCD 对它没有意义。
还有一条几何前提:形状必须有内半径(PhysicsSystem.cpp:1659–1661 的断言)。没有内半径的形状不适合 LinearCast,因为引擎无法在不冒穿模风险的前提下移动它。球与胶囊(capsule)有内半径,薄板这类形状没有,也就进不了这条路径。这条断言与 §五 的取小公式是同一件事的两面:整条 CCD 路径的几何尺度都锚在内半径上。
Highway road crash barrier in blind curve WTK20150913-DSC 3930.jpg(CC BY-SA 4.0,schwiki)这张护栏的类比说的是 CCD 在管线里的位置:它是穿透真正发生之前、「被拦下」的最后一道。护栏挡不住所有事故,CCD 也拦不住 §7.1 里的全部情形,两者的边界落在同一个意思上。
7.3 性能代价:E9
好了,代价这一侧给一组读数。E9 用 100 个半径 10 mm 的小球、40 m/s、4 线程、跑 120 步。
Discrete 一档:每步 0.0670 ms,held_at_wall=0,past_wall=100,100 个全穿过去。LinearCast 一档:每步 0.1456 ms,held_at_wall=100,past_wall=0,100 个全被拦下。两档的每步耗时之比约 2.2 倍,也就是说在这个密度下,把 100 个小球全部拦住要多花一倍多的求解时间。
作者在 multicore 扩展那篇论文里给过一句定性:开与不开 CCD,系统的多核扩展性一样好,只是开了会明显慢一点。E9 把这个「慢一点」量成了 0.0670 ms 到 0.1456 ms 的一段。把它和官方那句「Discrete 是最便宜的一档」(MotionQuality.h:12–13)放在一起看,两档的取舍就很直白:便宜的一档会漏,拦住的一档要付钱。
Discrete 过去 100 个、LinearCast 拦下 100 个,每步耗时从 0.0670 ms 涨到 0.1456 ms(约 2.2 倍)@ 本机基准实测(原创)E9 与 §三 的 E0 是同一套场景的两个面:E0 只有一颗球,量的是拦不拦得住;E9 有 100 颗球,量的是拦住的价钱。两组合起来,才构成「这一档值得开吗」这个问题在读数上的完整答案。
还有一处实验口径要交代:E9 与 E0 一样把睡眠(sleeping)关掉了。若睡眠开着,被拦下的球会在低速时入睡,读数里会混进睡眠的效应,拦下与否就不纯粹由 CCD 决定。关掉睡眠,等于让两档都一直活跃,把代价量在纯求解上。
源码落点
- [MotionQuality.h](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Jolt/Physics/Body/MotionQuality.h) 第 12–27 行:其中: 第 12–13 行是 Discrete 的定义与「最便宜」,第 16–22 行是 LinearCast 的能力与两条残余风险,第 24–27 行是幽灵接触回调。
- [PhysicsSystem.cpp 的分流](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Jolt/Physics/PhysicsSystem.cpp) 第 1655–1661 行:其中: 第 1656–1657 行排除运动学体与传感器体,第 1659–1661 行断言形状必须有内半径。
- [Architecture.md 的 CCD 段](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Docs/Architecture.md) 第 669–677 行:其中: 第 671 行讲偷时间与不做回退,第 675 行讲旋转错过碰撞的风险,第 673 行与 677 分别是 MotionQuality.jpg 与 LongAndThin.jpg 的配图位。
- [官方 ConvexRadius.jpg](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Docs/Images/ConvexRadius.jpg) 与 [官方 MotionQuality.jpg](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Docs/Images/MotionQuality.jpg):其中: 前者是内半径的几何依据,后者是两档穿模对照图。
- [作者一手论文](https://jrouwe.nl/jolt/JoltPhysicsMulticoreScaling.pdf) 与 [官方 HighSpeedTest.cpp](https://raw.githubusercontent.com/jrouwe/JoltPhysics/v5.6.0/Samples/Tests/General/HighSpeedTest.cpp):其中: 论文给出「开了 CCD 扩展性一样、只是慢一点」与「Jolt 用真实形状、Bullet 用球」两句定性,高速测试是 E0 与 E9 场景的官方原型。
八、确定性:两条前提与「多线程不破坏它」
8.1 前提一与前提二
OK,前面七节谈的是「拦不拦」与「怎么拦」,这一节换一个问法:同一份输入为什么能跑出同一份世界。Jolt 官方在文档里把确定性(determinism)压成两条前提,一条管程序的输入,一条管运行的程序本身。前提一是「修改模拟状态的 API 必须按完全相同的顺序被调用」,增删刚体(body)与增删约束(constraint)的先后都算状态的一部分;前提二是「跑模拟的二进制代码(binary)必须相同」,它约束的是同一平台上同一份可执行文件,至于这颗 CPU 是 AMD 还是 Intel,官方明说无所谓。
把两条前提合起来读,确定性在 Jolt 里是一份关于「调用顺序(call order)+机器码(machine code)」的契约(contract),并非引擎在任何条件下都会给出的默认性质。官方 README 把这份契约写成卖点:只要把输入复制到远端客户端,就能复现整个模拟;同一句话的后半段把「限制」推给了确定性章节。承诺与边界被并排摆出,这是本篇反复用到的一种读法。文档在两条前提之后紧接着列了一串「无所谓」的维度:编译器从 MSVC2022 到 clang 到 gcc 到 emscripten,构建配置从 Debug 到 Release,操作系统从 Windows 到 macOS 到 Linux,架构从 x86 到 ARM 到 RISC-V 到 PowerPC 到 LoongArch,字长从 32 位到 64 位,这些差异在跨平台那一档打开之后都不该改变结果,而换来这一切的代价是大约 8% 的减速。
Architecture.md:785–798):API 调用顺序完全相同 + 使用同一份二进制;跨平台确定要打开 CMake 选项,官方写的代价是「大约慢 8%」@ Jolt Physics v5.6.0 官方文档原文(MIT)这段引文的落点在官方架构文档的确定性章节,原文把两条前提写成加粗短句,紧跟着就是跨平台开关那一段。到这里可以先把「定义」这一格补上:本篇说的确定性(determinism),指的是「给定相同的调用序列与相同的可执行文件,逐体逐位(bit-for-bit)的结果可复现」。它排除了「结果看起来差不多」与「统计意义上接近」这两种宽松读法,落到硬指标上就是位置、旋转、线速度、角速度与包围盒(bounds)在每一步都要求精确相等(exact equality),容不下哪怕一个最低位的偏差。把定义收得这么紧,后面谈「多线程不破坏它」才站得住脚;也可以反过来讲,任何一处实现层面的顺序不定,都会在这套逐位比对的尺子下现形。
8.2 官方单元测试:0 worker 与 15 worker
好了,前提说完了,看官方自己怎么验证这份契约。仓库里有一个专门的确定性测试文件(PhysicsDeterminismTests.cpp),它新建两个彼此独立的物理系统(PhysicsSystem)实例,参数上唯一的区别是工作线程(worker thread)数:一个用 0,一个用 15。测试在每一步都对全部刚体逐体比对位置、旋转、线速度、角速度与包围盒,用的是精确相等比较,跑满 5 秒也就是 300 步。
第三个用例还额外叠了一层 GroupFilterTable 与 SwingTwistConstraint 链,说明约束(constraint)参与之后依然逐位一致。这个测试的价值在于它把「多线程是否破坏确定性」变成了一个可以在仓库里复跑的问题,而不是一句口头保证;测试构造函数的前三个参数口径是步长、碰撞步数(collision steps)、工作线程数,所以 0 与 15 这两个数落在第三个位置上,指的是线程数而不是迭代遍数。
PhysicsDeterminismTests.cpp:100–136):两个独立世界分别用 0 与 15 个 worker,同一套创建序列,最后逐体比对 5 秒@ Jolt Physics v5.6.0 源码原文截取(MIT)8.3 本机读数:E6 与 E7
言归正传,官方测的是它自己的场景,本机照同样的做法独立搭了一套。E6 建两个独立世界,逐步逐位比对 300 步:grid_discrete 26 体的首个不一致步号是 -1,grid_linearcast 26 体同样是 -1,pile_linearcast 145 体还是 -1,三组末态哈希(state hash)两两相等。E7 固定 pile_linearcast 场景,把工作线程取 0、1、4、8 四档,末态哈希全部等于 eeccdf691a134b10,四档之间没有任何一位不同。
eeccdf691a134b10@ 本机基准实测(原创)说白了,多线程不破坏确定性,靠的是引擎把并发读写的边界事先划好,接触(contact)的解算顺序在进入并行之前就被固定下来,而不是指望几路线程刚好踩到一样的时序。线程数从 0 到 8,末态哈希一个字符都没变,这份读数与官方测试的方向是一致的。
Domino 02.JPG(CC BY-SA 3.0,Hide-sp)实物层面的类比是多米诺:第一块朝哪个方向倒、用多大劲推,后面全由物理决定;初始条件给得一模一样,整排的倒下顺序就一模一样。确定性做的正是这件事,把「初始条件相同则全局相同」当成一条可检验的性质,而不是一个关于「运气好」的说法。
源码落点:两条前提与跨平台档的完整表述见 确定性章节,官方承诺与「请理解限制」的并置见 项目说明,0 与 15 两个 worker 的逐体比对见 确定性单元测试,构造函数的参数口径见 测试上下文,确定性开关的默认值与注释见 PhysicsSettings.h;其中:「同样的 API 调用顺序」与「同样的二进制」是两道门槛,「跨平台确定」是额外一档、代价约 8%,而「0 vs 15 worker」测的是多线程会不会破坏确定性,本机 E6 与 E7 把这条落在 300 步、四档线程的读数上。
九、录放与校验回路
9.1 录放接口的两个函数
下面开始看第二套机制,也就是把「确定性」从一句话变成一条可执行回路的那套工具。Jolt 的状态录制器(StateRecorder)提供一对函数:SaveState 把当前模拟状态写进录制器,RestoreState 把录制器里的状态读回来。官方注释写得很清楚,录进去的只有「被模拟改过的量」,像摩擦系数、恢复系数(restitution)、运动质量(motion quality)这些配置并不在快照(snapshot)里,需要用户另外保存;这句话把「状态」的边界划得比直觉窄。
StateRecorder.h:106–122):只录被仿真改过的状态,摩擦与运动质量这类配置要用户自己存;校验模式能定位到是哪个字节不一致@ Jolt Physics v5.6.0 源码原文截取(MIT)9.2 校验模式
先来看看校验模式(validating mode)。把录制器设为 SetValidating(true) 之后,ReadBytes 在读取每个字节(byte)之前会先跟当前状态比一比,流程就变成「步进 → 存档 → 回退 → 再步进」,一旦不一致,能定位到具体是哪个字节不对。官方还标注了一个前提:校验只在录全量状态(EStateRecorderState::All)且过滤器(filter)为空时有效,而且校验模式与分片恢复不兼容。这套机制回答的是「这一步与下一步之间,状态有没有被改动」,粒度细到字节,成本也相应更高。
9.3 两套机制:逐字节比对与末态哈希
依然是惯例,先把两条路的区别摆出来。第一条路是 IsEqual 的逐字节比对(byte-wise comparison),由状态录制器的实现体完成,比的是同一份数据两次录制是否完全一致,报错信息会指出首次分歧的字节;第二条路是 -validate_hash 的末态哈希,哈希由 HashBytes 逐字节滚出来,算法是 Fowler–Noll–Vo,起始种子是 0xcbf29ce484222325,把全部刚体的位置与旋转滚进一个 64 位整数,最后打印成 0x... 字符串。两条路一个管「逐步」,一个管「末态」,覆盖的粒度不同,也各有各的适用场景:前者用来定位分歧发生在哪一步、哪一个字段,后者用来在性能测试里一行比对整段模拟。
两条路的区别并排看更清楚:
| 维度 | StateRecorderImpl::IsEqual 逐字节比对 |
-validate_hash 末态哈希 |
|---|---|---|
| 比什么 | 同一份数据两次录制出的字节流 | 全部刚体的位置与旋转滚出的 64 位整数 |
| 粒度 | 逐步,报错指到首次分歧的字节 | 只比末态,一行给出结论 |
| 怎么触发 | 录制器设 SetValidating(true) |
命令行加 -validate_hash |
| 适合用来做 | 定位分歧发生在哪一步、哪一个字段 | 性能测试里整段模拟的一次性比对 |
SaveState 存档 → 跑一段 → RestoreState 回档 → IsEqual 逐字节比对并报出首次分歧;另一条路是 -validate_hash 的末态哈希,一个管逐步、一个管末态@ 本系列绘制还有一条旁路值得记一笔:确定性日志(determinism log)会把每步状态写进文本文件,默认文件名是 detlog.txt,用途是「跨平台做 diff 找问题」,宏 JPH_DET_LOG 默认不展开,需要手动在文件头打开开关。它与前两条路的分工是,日志给的是可读的逐步记录,而 IsEqual 给的是字节级的判决。
9.4 快照里没有什么
把「录放」这一侧收口之前,有一处边界要交代:运动质量不在快照里。这里牵出一个概念上的区分,状态录制器录的是被模拟改过的量,而运动质量、摩擦、恢复系数这类属于配置(configuration),需要用户自己保存。说白了,快照管的是「世界跑成什么样」,配置管的是「世界被设成什么样」,两件事的保存责任并不落在同一个地方。这一点与确定性总开关是两回事,前者管「我录了什么」,后者管「引擎跑得确不确定」。
mDeterministicSimulation = true 时末态哈希三跑全等(14fe513bcdf4f516),关掉之后三跑三个哈希;而每步耗时三次的差都落在 ±2% 内且符号不定,本机这个规模上量不出代价@ 本机基准实测(原创)E8 是本机这套读数里最直白的一组:257 体、4 线程、300 步,mDeterministicSimulation = 1 时三跑哈希全是 14fe513bcdf4f516,mDeterministicSimulation = 0 时三跑哈希分别是 60fbe43295f2c8fd、0c46912b2b4cf79b、8fac95565ffacfb6。三跑一样与三跑三样,是同一个程序在同一台机器上跑出来的结果,不是推断。
需要如实写的一处负面结果:官方只说关掉这个开关会「更快」,没有给数字,本机三次实测的每步耗时分别落在 0.1618 / 0.1491、0.1630 / 0.1607、0.1611 / 0.1646 ms/步,差值都在 ±2% 以内且符号不定,也就是在本机这个规模上量不出这个开关的耗时差。正文不为官方补数字,跨平台那一档的 +8% 属于另一个开关,出处是架构文档的确定性章节。这也顺带说明一件事:确定性开关的收益主要落在「可复现」上,它省不省时间要看场景,不能拿「更快」当成选择它的理由。
源码落点:录放接口与校验模式的原文见 StateRecorder.h,逐字节比对与报错见 StateRecorderImpl.cpp,SaveState 与 RestoreState 的签名见 PhysicsSystem.h,detlog.txt 的用途见 DeterminismLog.h,FNV 哈希的实现见 HashCombine.h,-validate_hash 与 -vs 的官方说明见 性能测试文档;其中:IsEqual 与 -validate_hash 是两套机制,一个逐步逐字节、一个只比末态哈希,而快照里不含运动质量这类配置,需要用户各自保存。
十、跨平台那一步:关掉 FMA
10.1 开关改了什么:关掉融合乘加
我们正式开始聊跨平台。CROSS_PLATFORM_DETERMINISTIC 这个 CMake 选项到底改了什么,官方文档用一句「大约慢 8%」概括,要看清代价的来源得进源码。构建系统的选项声明把它的默认值设成 OFF;打开之后会做几件具体的事:给 MSVC 加 /fp:precise、给 clang 与 gcc 加 -ffp-contract=off。最实质的一步在核心头文件里,那里有一句注释「FMA is not compatible with cross platform determinism」,意思是融合乘加(fused multiply-add,FMA)会在一条指令里把乘与加合起来只做一次舍入(rounding),不同平台的指令实现不一样,所以跨平台确定那一档必须把它关掉。
Build/CMakeLists.txt:19–21 + 205–210 + 261–264,只引插入点之前的行):选项默认关闭;打开后给 MSVC 加 /fp:precise,并把 -ffp-contract 关掉,因为融合乘加与跨平台确定互斥@ Jolt Physics v5.6.0 源码原文截取(MIT)10.2 那 8% 的出处
开始正文的这一段把出处交代清楚。那 8% 来自官方架构文档的确定性章节,原文写的是 approximately 8% slower,量的是打开跨平台开关之后的整体减速。构建说明里补了一句更直白的:这个开关一旦打开,JPH_USE_FMADD 就被忽略。构建脚本里则是把这个 CMake 选项映射成编译宏 JPH_CROSS_PLATFORM_DETERMINISTIC,源码里所有与它互斥的路径都由这个宏来分岔。把这条链走完就能看清,「跨平台确定」在实现层面最实质的动作是「放弃融合乘加换舍入一致」,那 8% 的账单,落点是把一条硬件级的加速指令(instruction)让出去。
10.3 本机能覆盖到哪一档
先讲清这一档背后的原理:跨平台确定与融合乘加互斥,所以能覆盖到哪一档,取决于本地库的编译开关。有一处口径必须写清,本机复用的 Jolt.lib 带着 /DJPH_USE_FMADD,也就是开着融合乘加,而官方明说它与跨平台确定互斥。所以本机实测只覆盖「同二进制内的确定性」这一档,也就是两个独立实例、不同线程数、不同创建顺序,跨平台那一档只用官方原文与源码作结论,不拿本机读数硬套。
除了浮点收缩,官方还列了七条注意事项:用精确浮点(precise floating point)模式编译,关闭浮点收缩,统一 FPU 舍入与 DAZ/FTZ 行为,不用 sin/cos 而用 Jolt 自己的 Sin/Cos,把 std::sort 换成 QuickSort,把 std::push_heap 与 std::pop_heap 换成 BinaryHeapPush 与 BinaryHeapPop,把 std::hash 换成 Hash。每一条都注明「不同平台实现不同」,这七条合起来说明跨平台确定性是一整套工程口径,而不是一个编译开关就能包圆的。
10.4 四家引擎的横向对照
enhanced-determinism,PhysX 分投机与扫掠两条路;未核到官方声明的两格如实留白@ 本系列绘制四家的差异可以按三个问题来读。第一个问题是「用什么形状扫」:Jolt 与 PhysX 用真实形状做线性投射(linear cast),Bullet 把形状简化成球,这一点来自作者本人的多核扩展论文,属于作者口径而非 Bullet 官方文档的表述。第二个问题是「做几趟」:Jolt 只做一趟、首次命中即停,PhysX 是 multi-pass best-effort,官方原句写「跑完既定遍数后,剩余时间被丢弃」。第三个问题是「适用域」:Box2D v3 默认只在动态体与静态体之间做连续碰撞检测(continuous collision detection,CCD),动态对动态默认不做,而 Jolt 的分支是「动态体且非传感器」,两家的边界并不重合。至于确定性,Box2D 官方 FAQ 把「跨平台确定、多线程确定、无回滚确定」三条一次讲清,Rapier 用一个 enhanced-determinism 特性来换并与 SIMD 八路互斥,Jolt 用 CMake 开关来换、代价约 8%,三家给出的「跨平台」都不是默认。
源码落点:跨平台选项与浮点开关的原文见 Build/CMakeLists.txt,两个编译开关的官方描述见 构建说明,CMake 选项到编译宏的映射见 Jolt.cmake,融合乘加与跨平台确定互斥的注释见 Core.h,作者对 Bullet 与 PhysX 的定性对照见 多核扩展论文;其中:开关默认关闭,打开后放弃融合乘加并强制精确浮点,换来的是跨平台逐位一致,约 8% 的减速就来自这里,本机因为带着 JPH_USE_FMADD 只能覆盖同二进制那一档。
十一、咬合处、横向对照与边界
11.1 咬合处:CCD 为什么按 fraction 排序
正餐开始,先看两条主线的咬合处。前一条主线讲高速体怎么被拦下,后一条主条讲同一份输入怎么跑出同一份世界,两条线在代码里交汇的地方是 CCD 接触的排序。PhysicsSystem.cpp 的收口阶段有一段注释,先写「按命中比例(fraction)排序,让更早的碰撞先被处理」,再写这句排序是「为了让模拟确定」,还写它是为了「在某一对刚体上找到更早的命中时,能停掉这对的后续接触处理」。一段注释里同时出现了确定性与 CCD 两个词,两条主线在这里合上:CCD 不只是一个把物体拦下来的几何动作,为了让「谁先撞」这件事与遍历顺序无关,它必须在收口前把接触按命中比例排一遍。
PhysicsSystem.cpp:2200–2212):官方注释把两个动机写在一起,排序既是为了让仿真可确定,也是为了能在更早命中出现时停掉后续接触处理@ Jolt Physics v5.6.0 源码原文截取(MIT)这里可以补一处「推导」:命中比例越大,说明接触发生得越晚,把小的排前面就等于把时间上更早的碰撞先解掉;当同一对刚体被多个 CCD 体先后命中时,先到的那个先改位置,后来者要重新看一遍,结果才不依赖线程调度与遍历次序。ep10 留的线索是「求值顺序会变」,本篇把这条线索收口:在 Jolt 里顺序是被刻意管起来的,CCD 按 fraction 排序、确定性要求 API 同序调用,两者指向同一件事。
11.2 版本演进
正餐开始,最后收在版本与边界上。把本篇用到的机制按时间铺开,能看到一条清晰的生长线:运动质量(motion quality)先被引入并分成 Discrete 与 LinearCast 两档;随后状态录放与末态哈希回归(hash regression)加入,确定性从一句话变成一套可复跑的测试;再往后是确定性总开关与跨平台档形成,CROSS_PLATFORM_DETERMINISTIC 与 mDeterministicSimulation 各管一段;最后是 CCD 接触按 fraction 排序的收口,把并发下的顺序钉死。版本发布说明与 API 变更记录里能查到对应的条目,其中也记录了几处破坏性改动,例如运动质量的设置接口被调整。
IsSensor 且 32/64 位一致;v4.0.1 修 CCD 体质量缩放;v5.1.0 Emscripten WASM 跨平台确定;v5.3.0 修掉排堆在不同平台上的差异@ 本系列绘制11.3 边界与下一篇接口
把边界一次说全。CCD 这一侧:运动学体(kinematic body)不能参与 CCD,因为它的位置由动画驱动、引擎停不下来;传感器(sensor)体不支持 CCD;形状必须有内半径(inner radius),球体与胶囊有,薄板没有;CCD 只按起始旋转扫掠,旋转本身不被它兜住;中途改档即时生效,但不会回溯到改档之前的步。确定性这一侧:跨平台那一档要开开关、代价约 8%;快照里不含运动质量这类配置;关掉确定性开关会直接改变末态哈希。这些边界合起来,构成本篇的适用范围。把几组可核的数字并在一起看:E6 在 300 步里三组场景的首个不一致步号都是 -1,E7 的 0、1、4、8 四档线程末态哈希全部是 eeccdf691a134b10,E8 的 257 体、4 线程、300 步三跑哈希全等,跨平台那一档的代价是约 8%。
至于与后续篇目的接口,第十二篇谈角色与载具,这里只登记一条:角色控制器(CharacterVirtual)不在宽相(broad phase)里,也就不走本篇的 CCD 路径;它带来的是一套自成一体的移动与碰撞处理。把这条边界留清楚,下一篇从「角色为什么不用物理体直接驱动」切入就有了接口。
OK,回到本篇自身做一次总览。把两条主线放在一起看,高速体不再穿墙靠的是一套「触发、扫掠、收口」的几何机制,同一份输入跑出同一份世界靠的是一套「录放、校验、哈希回归」的回路,而让两者在并发下仍然成立的,是「顺序被事先钉死」这条共同前提。CCD 接触按命中比例排序,与确定性要求 API 同序调用,指向的正是同一件事:把顺序从「碰运气」变成「可复现」。
源码落点:CCD 接触按命中比例排序的原文见 PhysicsSystem.cpp,版本条目见 版本发布说明 与 API 变更记录,Box2D 的三条确定性声明见 Box2D 官方 FAQ 与 Box2D 仿真文档,Rapier 的确定性立场见 Rapier 确定性指南,PhysX 的两条 CCD 路线见 PhysX 高级碰撞检测;其中:CCD 按 fraction 排序的动机同时写着「确定」与「可提前停止」,这就是两条主线的咬合处,而运动学体、传感器、内半径缺失、旋转与改档这五处边界,构成本篇划出的适用域。
资源清单
源码与官方文档(v5.6.0 raw,行号口径见研究笔记):两档运动质量定义、PhysicsSettings.h、PhysicsSystem.cpp、PhysicsUpdateContext.h、StateRecorder.h、StateRecorderImpl.cpp、DeterminismLog.h、HashCombine.h、PhysicsSystem.h、Core.h、Jolt.cmake、Build/CMakeLists.txt、Build/README.md、Architecture.md、PerformanceTest.md、ReleaseNotes.md、APIChanges.md、README.md、PerformanceTest.cpp、PhysicsDeterminismTests.cpp、MotionQualityLinearCastTests.cpp、PhysicsTestContext.h。
相关链接(作者一手与竞品官方):Jolt 多核扩展论文、Box2D 官方 FAQ、Box2D 仿真文档、Box2D v3.1 发布说明、Rapier 确定性指南、Rapier 刚体指南、PhysX 高级碰撞检测、PxSceneDesc。
素材来源(Wikimedia Commons,题图与实物图):时间切片、保龄球撞瓶、公路护栏、多米诺。
把上面这些按来源做一次归类,共五类,便于复查:官方源码(MotionQuality.h、PhysicsSettings.h、PhysicsSystem.cpp、PhysicsUpdateContext.h、Core.h 等);官方文档(Architecture.md、PerformanceTest.md、ReleaseNotes.md、APIChanges.md、README.md);官方测试与样例(PhysicsDeterminismTests.cpp、MotionQualityLinearCastTests.cpp、PhysicsTestContext.h、HighSpeedTest、ChangeMotionQualityTest);作者一手与竞品官方(多核扩展论文、Box2D、Rapier、PhysX);素材来源(Commons 的四张实物照片与官方 Docs/Images 的三张 CCD 配图)。五类里前四类指向同一套出处体系,即官方仓库的 v5.6.0 标签;最后一类提供的是实物类比,不承担机制证据。
下篇预告
下一篇进入《Jolt Physics 3D》第十二篇,谈角色与载具:角色控制器(CharacterVirtual)如何绕开宽相自成一套移动与碰撞处理,载具(VehicleConstraint)的轮胎与悬挂又是怎么把物理约束变成可驾驶的手感。本篇登记的那条边界会在下一篇展开,看看「不被物理体直接驱动的角色」是怎么在同一个世界里走动的。
With Best Wishes.