Appearance
快递员与会变形的护盾
背景:碰撞检测每帧大量 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、复用一个数组、读一下骨骼尺寸——三五行代码,就能让软件好一点点。一点点加起来,就是质感。