three-instance-batch 开发笔记

为什么要做这个

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

相关推荐
半个落月4 小时前
LangSmith 全链路观测(下):用 Dataset 与 LLM Judge 量化评估 RA
javascript·langchain
半个落月4 小时前
LangSmith 全链路观测(上):用 LangGraph 与 Milvus 构建可追踪的 RAG 客服
javascript·langchain
半个落月5 小时前
LangChain.js Agent 中间件实战:生命周期、动态工具与文件权限
javascript·langchain
志尊宝6 小时前
Vue3 零基础每日笔记(082):文件上传实战——el-upload 校验、自定义上传、头像预览
前端·javascript·vue.js·笔记·#javascript·#vue.js
bmxy小明同学12 小时前
2026-10-11-幻觉治理
服务器·前端·javascript
m0_5873830013 小时前
无人自助拼豆系统落地开发实战指南 核心逻辑实现与部署经验分享
前端·javascript·vue.js·人工智能·经验分享·需求分析
zhaiyy13 小时前
HTML Popover API 实践
前端·javascript·html
OpsEye13 小时前
企业 AI 管理新需求:不仅要接入模型,还要看懂每一次调用
javascript·ai编程
牧艺13 小时前
SPA 也能像原生 App:View Transitions API 从原理到可上线实战
前端·javascript·css
Dovis(誓平步青云)13 小时前
现场问题先关闭还是先验收,建筑任务怎样处理依赖
前端·javascript·数据库·mysql·音视频