Appearance
织布机的垃圾
背景:布料每帧 new 三个 Float32Array(positions/normals/uvs),高频分配导致 GC 频繁停顿。 过程:三数组改为实例级缓存复用,粒子数不变时零 GC 分配。uvs 不变,连写入都省了。
布料物理之城的织布机,每帧都在织布。
288 个粒子,每粒子 3 个坐标(x, y, z)——positions 数组就是 864 个浮点数。再加 normals 数组(同样 864 个),再加 uvs 数组(576 个)。
每帧,织布机把这三个数组织出来,交给 GPU。然后——把它们扔掉。
下一帧,再织三块新的。
看不见的垃圾
"每帧分配三个 Float32Array,有问题吗?"AI 同行者问。
"你觉得呢?"外交官反问。
"288 个粒子的话……positions 是 288×3×4 字节 = 3456 字节。三个加起来不到 10KB。每帧 10KB,60 帧就是 600KB 每秒。听起来不多啊。"
"听起来不多,"外交官点头,"但垃圾回收不是看你分配了多少,是看你分配了多少次。"
他在纸上画了一条线:
时间 →
帧 1: new Float32Array → 用了 → 扔掉
帧 2: new Float32Array → 用了 → 扔掉
帧 3: new Float32Array → 用了 → 扔掉
...
帧 60: new Float32Array → 用了 → 扔掉"每帧三次分配,60 帧就是 180 次分配。一分钟就是一万多次。每一次分配,GC 都要记一笔账。等到账攒得够多了——GC 就来一次大扫除。"
"大扫除会怎么样?"
"卡一下,"外交官说,"10ms、20ms、甚至 50ms。用户不知道为什么,就觉得'有时候画面会顿一下'。他们不会说'GC 停顿',他们只会说'这个软件有点卡'。"
"就因为每帧 10KB?"
"就因为每帧 10KB,"外交官确认,"GC 停顿和分配大小没关系,和分配次数有关系。小对象、高频次——这是 GC 最喜欢的食物。吃得越多,打扫得越频繁。"
为什么会这样
"但是……为什么要每帧 new 一次?"AI 不解,"直接复用不行吗?"
"问得好,"外交官翻开 xpbd-cloth.ts,找到 _updateClothMesh 函数。
旧代码是这样的:
typescript
function _updateClothMesh(cloth: ClothInstance): void {
const positions = new Float32Array(particleCount * 3);
const normals = new Float32Array(particleCount * 3);
const uvs = new Float32Array(particleCount * 2);
for (let i = 0; i < particleCount; i++) {
// 填数据
}
cloth.mesh.updateVerticesData(...);
}"为什么不存起来复用?"
"因为写这段代码的时候,作者想的是'我需要一个数组来放数据'——就像你写个 for 循环需要一个 i 变量一样自然。他没有想过这个数组的生命周期。"
"这是……思维盲区?"
"是前端开发者的常见盲区,"外交官说,"前端开发习惯了'分配 → 使用 → 丢弃'的模式。反正有 GC 兜底。大多数时候也确实没问题——页面几秒钟就刷新一次,GC 还没来得及找麻烦,页面已经走了。"
"但这个不一样。"
"不一样,"外交官点头,"3D 应用是长驻的。用户可能打开看十分钟、半小时。十分钟里,布料要分配三万次数组。GC 再能忍,也有忍不住的时候。"
缓存复用
"那怎么改?"
"三个数组,挂到 cloth 实例上,"外交官说,"创建布料的时候分配一次,后面每帧复用。粒子数不变的话,永远不重新分配。"
他在 ClothInstance 接口上加了三个字段:
typescript
interface ClothInstance {
// ... 已有字段
_positionCache?: Float32Array;
_normalCache?: Float32Array;
_uvCache?: Float32Array;
}然后在创建布料的时候初始化:
typescript
const positionCache = new Float32Array(particleCount * 3);
const normalCache = new Float32Array(particleCount * 3);
const uvCache = new Float32Array(particleCount * 2);最后在更新的时候检查:
typescript
let positions = cloth._positionCache;
if (!positions || positions.length !== particleCount * 3) {
positions = new Float32Array(particleCount * 3);
cloth._positionCache = positions;
}"粒子数变了才重新分配,"外交官解释,"不变的话,就用旧的。往里面填新数据,然后交给 GPU。"
"数组里旧的数据怎么办?"
"全部覆盖,"外交官说,"循环里每个位置都写一遍新值。旧数据会被新数据完全冲掉。不用担心残留——for 循环从 0 写到 particleCount,一个不落。"
"为什么不创建的时候就分配好,还要判断?"
"因为防御性编程,"外交官说,"万一有人直接 new 了一个 ClothInstance 没有初始化缓存呢?万一未来加了一个功能可以动态增减粒子呢?判断一下,代码更健壮。多一行判断,少一次崩溃。"
三个数组的故事
"你知道最有意思的是什么吗?"外交官忽然说。
"什么?"
"这三个数组——positions、normals、uvs——它们的生命周期完全不一样。"
他掰着手指头数:
positions:每帧都变。 粒子在物理模拟中不断运动,x、y、z 每帧都不一样。所以 positions 必须每帧重写。缓存的是数组本身,不是数据。
normals:每帧都变。 法线跟着顶点走,顶点动了法线就动。同样每帧重写。
uvs:永远不变。 纹理坐标在布料创建的时候就定了——哪个粒子对应纹理的哪个位置——之后再也不会变。
"uvs 永远不变?"AI 瞪大眼,"那 uvs 数组为什么每帧都 new?"
"因为写代码的时候没多想,"外交官耸耸肩,"positions 要 new,normals 要 new,顺手 uvs 也 new 了。反正三个都 new,写起来整齐。"
"但 uvs 根本不需要更新啊!"
"是不需要,"外交官说,"所以缓存了之后,uvs 只分配一次,之后每帧连写都不用写——直接把缓存的数组传给 GPU 就行。"
"省了一次分配,还省了一次写入。"
"对,"外交官笑了,"这就是优化的乐趣——你以为只是减少分配,结果还顺便发现了冗余计算。"
看不见的节省
改完之后,外交官跑了一段性能测试。
"怎么样?"AI 问。
"帧率没变化,"外交官说,"还是 60 帧。"
"那……改了个寂寞?"
"不,"外交官摇摇头,"帧率没变化,是因为本来就没到瓶颈。但 GC 压力降了。"
他指着 DevTools 的 Memory 面板:
"你看这个锯齿——每帧分配一点点,内存慢慢涨,涨上去之后 GC 一刀砍下来,又掉回去。之前锯齿很密,现在稀了。"
"肉眼看不出区别?"
"短时间看不出,"外交官确认,"跑一分钟、五分钟,也看不出区别。但跑半小时、一小时——之前可能会有几次明显的卡顿,现在就没了。"
"所以这是一种……长尾优化?"
"长尾优化,"外交官点头,"用户不会说'哇,优化完好流畅'。但他们也不会说'怎么有时候会顿一下'。不被骂,就是这种优化的胜利。"
织布机的新习惯
改完的那天,布料之城的织布机换了个新习惯。
以前是:每帧拿三块新布,织完就扔。 现在是:三块旧布反复用,旧图案被新图案覆盖。
织布机还是那台织布机,织出来的布还是一样的布。 只是——垃圾堆里,少了三万块碎布。
第三颗黄石子落进了"已处理"的堆里。
第一颗:空白的一秒——看不见的体验。 第二颗:黑黑的缩略图——看不见的等待。 第三颗:织布机的垃圾——看不见的 GC。
都是小事。 都不影响功能。 但每一件,都让软件在长时间运行的时候,更稳一点点。
就像给一台老机器拧紧了几颗螺丝——机器还是那台机器,声音还是那个声音。 但你知道,它里面不晃了。
附录:GC 友好代码原则
| 原则 | 说明 |
|---|---|
| 热路径不分配 | 每帧执行的代码里,尽量不要 new 对象/数组。能复用就复用 |
| 缓存按实例存 | 缓存挂在实例上(而非全局),实例销毁时一起释放,不泄漏 |
| 长度检查兜底 | 复用时检查长度是否匹配,粒子数变化时才重新分配 |
| 区分可变与不变 | uvs 这种不变的数据,连写入都省了。不要顺手把所有东西都重写一遍 |
| 优化要有证据 | 不要想当然优化。先看 profile,确认 GC 真的有压力,再动手 |
教训:前端开发者最容易忽略的性能问题,不是算法复杂度,而是 GC 压力。大对象、低频次——没事。小对象、高频次——事大了。每帧 new 三个数组,看起来不多,一小时就是十万次分配。GC 不找你麻烦找谁麻烦?