Skip to content

快递员与会变形的护盾

背景:碰撞检测每帧大量 sqrt(归一化),且碰撞胶囊尺寸固定,大小模型不是穿模就是飘。 过程:方向向量接近单位向量时跳过 normalize(省 sqrt)+ 胶囊尺寸从骨骼动态读取(随模型缩放自适应)。


物理之城有两种工作者。

一种是快递员——每天在城市里跑,把粒子从这里送到那里,告诉它们"你离碰撞体还有多远"。

另一种是护盾匠——给身体的每个部位打造一副胶囊形的护盾,让布料撞上去的时候不会穿模。

这两个人,各有一个可以改进的小毛病。


快递员的地图

先讲快递员的故事。

碰撞检测里,有一个操作叫"求方向向量"——从粒子位置指向胶囊最近点,然后算距离。算距离之前,要先把方向向量归一化——除以它的长度,让它变成单位长度。

归一化的代价是一次 Math.sqrt

sqrt 不快。真的不快。在 288 个粒子 × 13 个胶囊 × 每帧的世界里,sqrt 的次数是一个不小的数字。

"但是归一化不是必须的吗?"AI 同行者问。

"大多数时候是,"外交官说,"但如果向量本身已经接近单位长度了呢?"

"什么意思?"

"上一帧的方向向量,这一帧可能只变了一丢丢。粒子只移动了 0.01 个单位,方向几乎没变。这时候方向向量的长度可能是 0.999 或者 1.001——几乎就是 1。"

"那……就不用归一化了?"

"不用了,"外交官点头,"误差很小,对碰撞结果几乎没有影响。但省了一次 sqrt。"

他翻出 xpbd-collider.ts 里的改动:

typescript
function fastNormalize(v: Vec3): void {
    const lenSq = v.x * v.x + v.y * v.y + v.z * v.z;
    if (Math.abs(lenSq - 1.0) < 1e-4) return; // 已经接近单位向量,跳过
    const len = Math.sqrt(lenSq);
    v.x /= len;
    v.y /= len;
    v.z /= len;
}

"容差 1e-4,"外交官解释,"长度平方和 1 的差在 0.0001 以内——也就是实际长度在 0.99995 到 1.00005 之间——就跳过。误差可以忽略不计。"

"能省多少次 sqrt?"

"看情况,"外交官说,"布料静止的时候,方向几乎不变——可能 90% 的情况都能跳过。布料剧烈运动的时候,可能一次都跳不过。但静止的时候才是常态——用户大多数时候是在看,布料只是微微晃动。这时候省的就多了。"

"这就像……快递员天天走同一条路,走熟了就不用每次都查地图了?"

"就是这个意思,"外交官笑了,"熟门熟路,直接走。路变了,再查地图。大多数时候路没变——省时间。"


为什么是 lenSq 而不是 len

"等等,"AI 注意到一个细节,"你比的是 lenSq - 1,不是 len - 1。为什么?"

"好眼力,"外交官说,"因为算 len 需要 sqrt,而我们的目标就是省 sqrt。如果先算 len 再比,那已经把 sqrt 花掉了,还有什么意义?"

"哦……对哦。"

"所以比的是长度平方。长度平方差 1e-4,对应的实际长度差多少呢?"

外交官在纸上写了个公式:

√(1 + ε) ≈ 1 + ε/2  (当 ε 很小时)

"所以 lenSq 差 1e-4,实际长度差大约 5e-5——万分之零点五。完全可以忽略。"

"这是数学小技巧。"

"是优化的常用手段,"外交官纠正,"能比平方就不比长度,能比距离平方就不比距离。sqrt 是奢侈品,能省一次是一次。"


会变形的护盾

接下来是护盾匠的故事。

碰撞胶囊——就是套在角色手臂、腿、躯干上的那些胶囊形状的碰撞体——尺寸是固定的。

角色 1.6 米高,胶囊就按 1.6 米做。 角色 1.8 米高,胶囊还是按 1.6 米做。

"这不对吧?"AI 皱眉。

"是不对,"外交官说,"模型有大有小,有的萝莉 1.4 米,有的御姐 1.7 米,还有的巨人模型两米多。碰撞胶囊是固定尺寸的话——小模型的胶囊太大,布料离身体老远就被挡住了;大模型的胶囊太小,布料会穿模。"

"那之前为什么是固定的?"

"因为做的时候图省事,"外交官耸耸肩,"先做出来能用,再考虑缩放的事。功能优先,优化后做。"

"那现在怎么做?"

"从骨骼上读尺寸,"外交官翻开 cloth-manager.ts,"每帧更新碰撞胶囊的时候,不是用固定的默认值,而是去读骨骼的当前位置——上骨骼到下骨骼的距离就是胶囊的高度,骨骼粗细按比例算。"

他指了指 updateCapsuleSizes 函数:

typescript
function updateCapsuleSizes(collider: SdfCollider, bones: Map<string, TransformNode>): void {
    for (const capsule of collider.capsules) {
        const topBone = bones.get(capsule.topBoneName);
        const bottomBone = bones.get(capsule.bottomBoneName);
        if (!topBone || !bottomBone) continue;
        const height = Vector3.Distance(topBone.position, bottomBone.position);
        capsule.height = height;
        capsule.radius = height * capsule.radiusRatio;
    }
}

"每个胶囊都有一个 radiusRatio——半径和高度的比例,"外交官解释,"比如上臂的半径大约是高度的 1/6。不管模型多大,这个比例差不多。所以按比例算出来的半径,适配各种身高。"

"这样就不会穿模了?"

"大大减少,"外交官纠正,"不能说完全不会——毕竟胶囊是简化的碰撞体,不可能完全贴合身体的每一寸。但至少不会出现'1.8 米的角色穿着 1.6 米的护甲'这种明显的 mismatch。"


为什么每帧都更新

"等等,"AI 又想到一个问题,"骨骼的尺寸——身高、臂长——不是固定的吗?为什么要每帧更新?"

"静态模型是固定的,"外交官说,"但有两种情况不固定。"

他竖起两根手指:

第一种:IK 和物理。 "角色的手臂在动的时候,手肘的位置会变。上臂的长度是固定的,但下臂的方向在变——不对,骨骼的长度是固定的啊……"AI 有点绕。

"骨骼的长度是固定的,"外交官点头,"但骨骼的缩放可能变。"

第二种:动画缩放。 "有的模型会做'变大变小'的动画——比如魔法少女变身,身体比例会变。或者有的场景里,角色被缩小了。这时候骨骼的缩放变了,碰撞胶囊也要跟着变。"

"这种情况少见吧?"

"少见,但有,"外交官说,"而且——每帧更新的成本很低,就是 13 次 Vector3.Distance。Distance 就是一次 sqrt,13 次而已。比起 288 粒子 × 13 胶囊的碰撞检测,这点开销可以忽略。"

"成本低,收益不一定高,但做了没坏处。"

"对,"外交官笑了,"这就是'顺手优化'——反正每帧都要更新胶囊的位置矩阵,顺便把尺寸也读了。多加三行代码的事。"


两个优化的对比

改完之后,外交官把两项优化放在一起对比:

归一化容差胶囊尺寸自适应
原理已经是单位向量就跳过 sqrt从骨骼读尺寸,不用固定值
收益静止时省 90% 的 sqrt各种体型的模型都不穿模
成本一次减法 + 一次 abs 比较13 次 Distance 计算
失败代价万分之几的误差,可忽略缩放不变的模型等于白算,但无害
属于哪种优化性能优化(省 CPU)质量优化(减少穿模)

"一个快,一个准,"AI 总结。

"一个让快递员少查地图,一个让护盾匠量身定做,"外交官补充,"都是小事,但都有用。"


小优化的哲学

第四颗黄石子落进"已处理"的堆里。

"这一章的两个优化,加起来也没多少代码,"AI 说,"每一个单独拿出来,都好像不值得专门写一章。"

"是的,"外交官说,"单独一个都不大。但加起来,就是软件的质感。"

他指着那堆黄石子:

"你看这一堆——加载占位、缩略图优化、布料缓存、归一化容差、胶囊自适应……每一个都是小优化。每一个单独拿出来,用户都不会说'哇,这个功能好棒'。"

"但放在一起呢?"

"放在一起,用户就会觉得——这个软件很'顺'。说不上哪里好,但就是用着舒服。没有明显的卡顿,没有奇怪的穿模,没有黑黑的缩略图,没有空白的弹窗。"

外交官拿起一颗黄石子,在手里掂了掂:

"大功能决定软件能做什么。小优化决定软件好不好用。两者缺一不可。"

窗外的阳光照进来,那一小堆黄石子泛着柔和的光。

不多,但每一颗都踏踏实实。


附录:性能优化的层次

层次说明例子
算法级换一种算法,复杂度降一档O(n²) → O(n log n)
架构级减少调用次数、缓存结果材质分类、避免重复计算
微优化级减少单次操作的开销跳过 sqrt、复用数组
经验级用经验值猜,赌大多数时候对双 rAF、归一化容差

优化优先级:算法级 > 架构级 > 微优化级 > 经验级 不要在微优化上花太多时间,先确认上层有没有更大的优化空间。


教训:优化不是只有"重写算法"一条路。有时候,跳过一次 sqrt、复用一个数组、读一下骨骼尺寸——三五行代码,就能让软件好一点点。一点点加起来,就是质感。