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 不变

相关推荐
To_OC6 小时前
别再串行写 await 了,Promise.all 才是并行请求的正确打开方式
前端·javascript·promise
To_OC6 小时前
LC 17 电话号码的字母组合:我的回溯算法,就是从这道题开窍的
javascript·算法·leetcode
你怎么知道我是队长8 小时前
JavaScript的变量和数据类型介绍
开发语言·javascript·ecmascript
名字还没想好☜9 小时前
Next.js 中间件实战:鉴权、重定向与 A/B 分流
开发语言·前端·javascript·中间件·react·next.js
广州灵眸科技有限公司9 小时前
瑞芯微RV1126B开发板(EASY-EAI-PI2) INI文件操作
java·前端·javascript·网络·人工智能
心中有国也有家10 小时前
AtomGit Flutter 鸿蒙客户端:ModalBottomSheet 实战
android·javascript·学习·flutter·华为·harmonyos
kaiking_g10 小时前
vue项目中,静态导入 vs 动态导入 详解
前端·javascript·vue.js
仙人球部落12 小时前
-python-LangGraph框架(3-31-LangGraph 「合并式状态管理」的原理与实践)
开发语言·javascript·python
sunfdf13 小时前
Next.js 新手从零部署到首跑实战指南
开发语言·javascript·ecmascript
SmartBoyW13 小时前
前端死磕:一文彻底搞懂 JS 事件循环 (Event Loop) 与宏微任务
前端·javascript