three-instance-batch 开发笔记

为什么要做这个

写 Three.js 项目时被 InstancedMesh 折腾。改 transform 的时候需要重复 setMatrixAtneedsUpdate = true,删实例的时候需要手动标记尾部搬过来补坑。写三个场景就得写三套差不多的管理代码。

所以抽了这一层:上面像操作普通 Mesh 一样改 transform,下面自动追踪增量刷 GPU

逐个说

1. 怎么追踪变更

最初想过 Proxy 整包 Vector3,但 Three 内部大量直接 vec.x = 5,Proxy 会打断引擎自己的路径,而且每个实例多一层代理对象,内存翻倍。

最后用了最笨的办法------一个方法一个方法地换。

ts 复制代码
function wrapMethod(obj, method, onChange) {
  const orig = obj[method]
  obj[method] = function (...args) {
    const r = orig.apply(this, args)
    onChange()
    return r
  }
}

36 个方法分了四组------position/scale 7 个、rotation 7 个、quaternion 8 个、color 6 个------各自挂 onChange。构造一个 Instance 大约 35 次闭包调用,比 Proxy 方案多 1KB 左右的函数开销。

代价是属性赋值不管用。 position.x = 5 不触发任何东西。x/y/z 是普通数字属性,要想追踪得整成 getter/setter,会和 Three 的内部冲突。所以文档里说清楚,单轴改动用 setX()

2. 分组依据

InstancedMesh 一个 Mesh 只能绑一组 geometry + material。所以不同 geometry 铁定分开,但不同 material 什么时候能共用要考虑:

  • color 不同 → 可以共用,instanceColor 缓冲区搞定
  • map 不同 → 必须分,shader uniform 绑的纹理不一样
  • transparent 不同 → 必须分,影响渲染队列

最后筛了 13 个属性进签名。目前 MeshStandardMaterial + MeshPhongMaterial 常用组合没出过问题。目前的键的格式是:

ini 复制代码
geo.uuid | map=tex:xxx;transparent=false;... | 00
                                              ↑ castShadow receiveShadow

shadow 单独抠出来是因为运行时改 shadow 开关要换 Mesh------InstancedMesh 的 castShadow 是整个 Mesh 级别设的,不能逐实例控制。

改 shadow → key 变了 → 塞进 _needsGroupRebuild → 下一个 update 在旧组删掉、在新组重建。

3. 只刷新脏的数据

每一帧假设 5000 个实例里 50 个在动。全量刷 5000 行矩阵就是 5000 × 64B = 320KB 往 GPU 塞。

脏标记分两层:

typescript 复制代码
Instance._localDirty / _worldDirty / _dirtyColor
    ↓ (通过 subscriber 推)
BatchGroup.dirtyIndices: Set<number>
BatchGroup.dirtyColors: Set<number>

update 时只遍历这两个 Set,哪行脏刷哪行。

颜色脏和矩阵脏分开记,因为动 transform 不代表变色了。以前试过统一一个 dirty flag,结果每帧多写 5KB 没变的颜色数据。

4. 父子层级的去重

parent.position 一动,子的 worldMatrix 全脏。这个是基于setter 自动往下传播 _worldDirty = true

但计算时:A → B → C 三层,C 计算 world 时递归进 B,B 又递归进 A。如果还有 D → B → C,A 被算两次。层级深了就是指数级重复。

ts 复制代码
let frameVersion = 0  // 每帧 ++

recomputeWorldMatrix() {
  if (this._frameVersion === frameVersion) return  // 本帧算过了
  this._frameVersion = frameVersion
  // ...
}

5. 删除怎么处理

数组中间掏个洞,我的做法是:把最后一个搬到空位,count--

ini 复制代码
删 idx=2,末位 lastIdx=5:
  [A B C D E F]  →  [A B F D E _]  count=6→5

F 的 indexMap 改 2,idx=2 推进 dirtyIndices。一轮 update 就同步到 GPU 了。

空出来的 lastIdx 推进 emptySlots 栈,下次 add 优先弹。这个栈有个小 bug------连续删多个时同一个索引会重复进栈------但正常使用场景下加删混合跑,很难堆起来。真要修的话弹栈前查个 Set 就行。

6. 扩容

InstancedMesh 容量是死的。超了就新 new 一个更大的,旧的数据拷过去,旧的 dispose。

over-allocation 默认 0.2,也就是说容量 16 → 扩容到 16 + ceil(4) = 20,不是 32。试过 2x,1000 实例浪费 1000 × 64B = 64KB 的 GPU Buffer 空槽。

7. Instance 跟 Batcher 的通信

Instance 不该知道 Batcher 的存在,反过来 Batcher 又要感知 Instance 变更。就一个接口:

ts 复制代码
interface BatcherSubscriber {
  markDirty(inst, type)   // matrix 还是 color
  markShadowChange(inst)  // 换组
  removeInstance(inst)    // 清理
}

Batcher 实现它,addInstance 时 inst._subscribe(this)。这样以后有其他"观察者"(比如统计面板想监听变更频率)不用改 Instance 的代码。

测试

56 条 vitest。写的时候反复纠结要不要 mock Three.js------最后决定不 mock,因为整个库的价值就在跟 InstancedMesh 的交互,mock 了等于没测。

最值钱的 test case:

  • 空槽复用:add 4 → remove 2 → add 2,断言 count 和 groupCount 不变,新实例在旧槽位上
  • swap-and-pop 索引正确:remove 非末位后,末位实例的 getMatrixAt 位置对
  • 扩容保留自定义材质:capacity=2,塞 3 个,检查 depthMaterial 还在
  • shadow 迁移:改 castShadow 后 update,groupCount 从 1 变 2,count 不变

相关推荐
王林不想说话1 小时前
TypeScript 进阶知识总结:从 extends、泛型到 infer,一篇打通 TS 类型系统
前端·javascript·typescript
罗超驿1 小时前
15.JavaScript 函数与作用域完全指南:语法、参数、表达式与作用域链实战
开发语言·前端·javascript
一念&2 小时前
油猴脚本教程——元数据块
javascript·浏览器·脚本·油猴
谭光志2 小时前
如何从零开始实现一个 AI Agent CLI
前端·javascript·ai编程
丷丩3 小时前
MapLibre GL JS第25课:添加栅格瓦片源
开发语言·javascript·gis·mapbox·maplibre gl js
半个落月3 小时前
彻底搞懂 JavaScript 变量提升(Hoisting)—— 从现象到底层原理
前端·javascript
天蓝色的鱼鱼4 小时前
画1万个图形就卡成PPT?试试这款国产高性能2D引擎
前端·javascript
wuxia21184 小时前
用Node.js为网站首页绑定数据
javascript·node.js
云水一下4 小时前
JavaScript 从零基础到精通系列:异步编程与网络请求
前端·javascript