Skip to content

织布机的垃圾

背景:布料每帧 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 不找你麻烦找谁麻烦?