为什么要做这个
写 Three.js 项目时被 InstancedMesh 折腾。改 transform 的时候需要重复 setMatrixAt 再 needsUpdate = 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 不变