Skip to content

物理之躯

背景:第二波审计覆盖 physics/xpbd-* 全家桶——布料能跑,但碰撞器在原点、摩擦不生效、子步积分形同虚设。系统在假装工作。 过程:三层面修复——鬼魂(碰撞胶囊矩阵从未更新)+ 哑炮(摩擦被速度重算覆盖)+ 空心砖(子步未渗透外力积分层)。修约 30+ 项。


第一波审计结束时已是黄昏。外交官合上写满 55 项标记的笔记本,以为今天的工作到此为止。

他错了。

八座城的城门再次打开——不是因为有人下令,而是因为第一波审计惊醒了更多的城邦。它们主动敞开门,像是在说:"顺便也看一眼我。"

外交官叹了口气,站起来,走向第二波。


前奏:四城速写

第二波的前四座城,外交官走得很快。不是因为它们不重要,而是因为这些问题的模式他已经熟悉了——系统在假装工作。

光照之城scene-lighting.ts)的 shadowBias 滑块就是这样。用户拖动滑块,UI 反馈正常,数值在变化,但阴影的偏差纹丝不动——因为 setLightState 只在重建 ShadowGenerator 时才读取这个值,而滑块拖动时根本不会重建。

还有那个 120 次销毁重建的过渡动画——两秒的灯光渐变,每帧都销毁整个阴影生成器再重建。大场景里足以把浏览器打到假死。外交官加了一个跳过集合,动画期间不重建,最后一帧一次性应用。

口型之城scene-lipsync.ts)的问题更隐蔽。modelRegistryconfig.ts 导入,但 ModelManager 创建之前它就是个空 Map。时序依赖像一根细丝——平时没事,哪天加载顺序变了,LipSync 就静默失效。还有切换模型时 morph 名缓存不重置的问题——新模型也叫"あ",就驱动到旧模型上去了。

加载之城scene-lopader.ts)复制了所有老问题:注册时机错误导致双重销毁、重复检测用了裸 modelRegistry 迭代、加载锁静默丢弃请求。外交官一一修补,边改边想——这些问题怎么和第一波的道具之城、程序化动作之城这么像?

材质之城scene-material.ts)的 as StandardMaterial 强转是另一种假装。代码假设所有材质都是标准材质,访问 diffuseColor 时从不检查。虽然现在项目里确实只有标准材质,但这是一颗埋着的雷——哪天混进一个 PBR 材质,整个材质面板直接炸。

四座城,十几个问题。外交官边记边想:这些问题都有一个共同点。

它们都不报错。它们都看起来正常。它们都是——系统在假装工作。

shadowBias 滑块假装在调阴影,as StandardMaterial 假装做了类型检查,加载锁假装在保护并发。每一个都有模有样,每一个都缺了最关键的那一步。

而物理引擎,才是这场假装的重灾区。


最后一页

黄昏再次降临时,外交官以为审计结束了。他在石阶上坐下,翻开笔记本,看到了最后一页——在八城审计期间他匆忙写下的一行字:

"physics/ 目录也需要看。"

这个目录里住着联邦最年轻的城邦:一个纯 TypeScript 的 XPBD 布料物理引擎。它不依赖 WASM Bullet,独立运行,有自己的求解器、碰撞器、布料生成器和调试渲染器。它是一周前埋下的种子。

外交官深吸一口气,打开了第一扇子城门。

他决定先看一眼效果。


撕裂的裙子

广场中央,模型站在夕阳里。

外交官给她套上了布料——一条简单的裙子,20×20 的粒子网格,锚定在腰部骨骼上。重力拉着它往下垂,距离约束撑着它不撕裂,弯曲约束让它有褶皱的质感。

看起来还行。布料在动,有起伏,有重力感。如果不是专门盯着看,你会觉得"物理效果不错"。

但外交官是来审计的。他不会"觉得不错"就走。

他拖动时间轴,让模型做了一个快速转身——90度,用时0.3秒。

啪。

裙子的上半部分跟着腰部转过去了,但下半部分像被什么拉住了似的,慢了半拍。然后——距离约束被拉到极限——粒子网格沿着第三排的位置撕开了一道口子。布料像被撕裂的纸,一半挂在角色身上,一半飘在身后。

"这不对,"外交官喃喃道,"XPBD 的约束应该很稳才对。"

他把速度放慢,让模型再转一次。这一次他盯着锚定粒子看。

他看到了。

锚定粒子的位置在瞬移——上一帧还在左边,下一帧就跳到了右边。它们的速度是零,因为 prevPp 被同时设到了同一个位置。而下层粒子的 prevP 还记录着上一帧基于旧锚点的物理运动。

距离约束看到一个锚点瞬间偏移了一大段距离,而相邻粒子还在原地。校正力急剧增大。

布料撕裂了。

外交官的心跳快了一点。这不是一个小问题——这是锚定粒子的速度计算从根上就是错的。

但他没有立刻动手改。因为他注意到了另一件更奇怪的事。

模型转身之后,裙子应该因为惯性继续摆动几秒,然后慢慢停下。但它没有——它停下的速度快得不正常,像是被一只无形的手按住了。

不,不是被按住。是——

它根本就没有撞到身体。

裙子穿过了大腿,穿过了躯干,像幽灵一样。布料在重力和约束下运动,但身体对它来说不存在。

外交官的手在键盘上停了很久。


第一层:鬼魂

SDF 碰撞器xpbd-collider.ts)是布料与身体交互的核心。十三个胶囊体——头、颈、胸、腰、上臂、下臂、大腿、小腿——组成一个简化的身体轮廓。布料粒子碰到胶囊,就被推出来。

至少理论上是这样。

外交官打开 buildClothUpdateFn——布料每帧更新的闭包函数。他一行一行往下读。

collider.solve(solver) 被调用了。碰撞求解在执行。

collider.updateMatrices() 呢?

不在那里。这一行不存在。

每一个胶囊体——头、颈、胸、腰、手臂、腿——它们的初始位置在原点 (0, 0, 0)。之后,从来没有人更新过它们。角色走到广场的另一端,她的碰撞体还站在原点。布料穿过了身体,但物理引擎认为身体在广场入口,所以它什么都不做。

布料与身体碰撞从未生效过

外交官靠在椅背上。

这是鬼魂

它存在——十三个胶囊体,代码写在那里,solve() 每帧都在调用。但它又不存在——胶囊永远站在原点,和角色的身体不在同一个地方。布料穿过身体,就像穿过一个幽灵。

你看不见它。你只能通过"布料为什么总是穿模"这个结果,反推它不在那里。

大多数用户不会反推。他们只会觉得"这个角色有点怪",但说不上来为什么。

外交官在笔记本上重重地写下:碰撞胶囊矩阵从未更新。

然后他加了一行:这是一个消防栓式的问题。

消防栓——你平时注意不到它,你甚至可能忘了它的存在。但着火的时候,它就是唯一重要的东西。碰撞也是一样——布料穿模你可能不在意,但你潜意识里会觉得"这个角色怪怪的"。为什么怪?你说不上来。因为碰撞体就像消防栓——它在的时候你看不见它,它不在的时候,你才知道缺了什么。


第二层:哑炮

修复了碰撞体更新,外交官让模型又转了一次。

这一次,裙子撞到了腿。它被推出来,弹了一下,然后——

然后它滑走了。

滑得比丝绸还顺。布料碰到大腿,被弹开,切线方向没有任何阻力。裙子像抹了油一样,从腿上滑下来,飘在空中。

"摩擦呢?"外交官皱起眉。

他打开 _solveCapsule 函数——碰撞求解的核心。里面有摩擦的代码:

typescript
p.v[0] -= vtx * this.friction;

直接修改粒子的速度数组 p.v。看起来没问题。拉线、点火,动作一应俱全。

但外交官的目光移到了求解器的 step() 函数末尾。他的瞳孔微微收缩。

那里有一行:

typescript
p.v = (p.p - p.prevP) / dt;

XPBD 求解器在每一步的最后,会用位置差重新计算所有粒子的速度——完全覆盖摩擦对 v 的修改。

摩擦从未生效过

这是哑炮

引线拉了,火花闪了,甚至还有一声轻响——但炮管里没有炮弹。代码在执行,函数在调用,变量在修改,但最后一步被人悄悄清空了。你以为它在工作,它也表现得像在工作,可实际效果是零。

外交官愣住了。不是因为这个 bug 有多难修——修起来很简单,把摩擦改到 prevP 上就行了。让他愣住的是这个模式。

而且它的本质是——函数契约的错位。

_solveCapsule 以为它修改的 p.v 是"最终速度"。但求解器的 step() 以为 p.v 只是"中间变量",随时可以覆写。两边对同一件事的理解不一样。它们各干各的,代码能编译,能运行,但结果是错的。

这让他想起了第一波审计里的某个问题——那个函数名是"停止",实际做的是"清数据"的命名错位。本质上是一回事:接口的两端说的不是同一种语言。

外交官把摩擦改为基于 prevP 的实现:从隐含速度 p.p - p.prevP 中提取切线分量,衰减后写回 prevP。这一次,摩擦不会被覆盖。

他还加了一个 _frictionApplied 集合——多个胶囊(胸、腰)可能对同一粒子重复施加摩擦,导致布料像黏在身体上一样。每个粒子每帧最多摩擦一次。

写完这一段,他停下来,在笔记本上又记了一笔:

函数契约问题是传染的。一个函数误解了参数的含义,调用它的所有函数都会被传染。


第三层:空心砖

两个 bug 修完了。布料现在能撞到身体,摩擦力也开始工作。裙子转身后会摆几下,然后慢慢停下。

看起来好多了。

但外交官没有走。他盯着 XPBD 求解器的 step() 函数,看了很久。

三部分结构:

  1. 第 1 步:Verlet 积分(速度、阻尼、重力、位置预测)
  2. 第 2 步:子步约束求解(4 次迭代,每次遍历所有约束)
  3. 第 3 步:最终速度更新

看起来很标准。很完整。像一堵砌得整整齐齐的墙。

但他注意到一件事。

第 1 步——外力积分——使用了全局时间步长 dt。第 2 步——约束求解——用 subDt = dt / substeps。它们的时间尺度不一致。

这意味着:重力只在整帧上施加一次,约束只在子步上精细求解。增加子步数不会提升物理精度——布料的下垂速度、惯性完全由全局 dt 决定。子步只是在同一个"快进"位置上反复拉伸约束。

子步划分形同虚设。

外交官坐直了身体。

这是空心砖

墙看起来是墙——有砖、有缝、有整齐的排列。但砖的里面是空的。子步是一个空壳——有循环、有迭代、有 subDt 变量,但时间积分根本没进去。你增加子步数,以为布料会更稳定,实际上什么都没变。它骗过了你的眼睛,也骗过了你的代码审查。

这不是一个实现 bug。这是设计缺陷。是 XPBD 引擎从第一天起就长错了的骨架。

标准做法是:每个子步内都完成一次完整的 Verlet 积分(re-predict positions + re-apply gravity),然后求解约束,然后下一个子步。重力应该在每个子步上施加,约束应该在每个子步上修正,两者在同一时间尺度上交互。

他把整个 Verlet 积分移入了子步循环内。现在每个子步:

  1. prevPp 计算速度(带阻尼)
  2. gravity * subDt
  3. 保存 prevP,更新 p += v * subDt
  4. 求解所有约束
  5. 地面碰撞(加了弹性系数 restitution,粒子现在可以反弹了)

修复后,他把子步数从 4 调到 8,再调到 12。

布料的行为真的变了。更稳定的约束、更平滑的运动、更少的拉伸。子步重新有了意义。

外交官靠在椅背上,长长地出了一口气。

三个bug。一层比一层深。

鬼魂——碰撞体存在又不存在,像幽灵一样穿过身体。 哑炮——摩擦代码在运行但没有效果,引线拉了炮没响。 空心砖——子步结构看起来完整但内核是空的,墙砌好了但砖里没料。

它们有一个共同点:都不会报错。都看起来正常。都需要你真正理解系统在做什么,才能发现不对劲。


没有神经系统的身体

深夜。笔记本密密麻麻。

第一波:55 项。第二波:30+。

外交官看着 physics/ 目录里的五个文件。这个年轻的城邦在一周前被植入联邦,像一个没有神经系统的新生儿——它有肌肉(求解器),有骨骼(碰撞器),有皮肤(渲染器),但这些部件没有真正连接在一起。

碰撞胶囊从未更新位置,摩擦力从未生效,子步从未真正划分时间,布料在骨骼快速移动时被撕裂。

他想起今天下午在光照之城修的那个 shadowBias——用户拖动滑块,系统假装在工作。那和鬼魂是亲戚。

他又想起材质之城的 as StandardMaterial——代码假设所有材质都是标准材质,从不检查。那和哑炮是表亲。

还有加载之城的静默返回 null——调用方以为"不存在",实际上是"被锁了"。那和空心砖是一类货色。

鬼魂、哑炮、空心砖——换了个壳,骨子里都是假装。

外交官在笔记本的最后一页写下一行字:

审计的真正工作,不是找 bug。是找那些"看起来在工作"的谎言。

然后他想了想,在下面加了一行小字:

这些谎言该怎么分级?有些用户一眼就能看见,有些用户永远看不见。但哪些更重要?

他不知道答案。这个问题要等明天,等所有问题都摆到桌面上,用红黄绿三种石子来分。

现在,他只想睡觉。

窗外,天色从墨黑变成了灰蓝。


教训:一个物理引擎能运行和它能正确模拟物理是两个完全不同的概念。


聚合悖论记录:XPBD 物理引擎收编完毕。代价——它引入了四条新的边界线:显式速度与隐含速度的冲突、碰撞体的空间同步必须与渲染帧对齐、子步划分必须渗透到外力积分层、调试可视化不能依赖全局变量。每一条边界都是未来修补的锚点。