InstancedMesh 更新预算:实例矩阵不该每帧全量上传
开放世界小地图上的植被、掉落物与标记物适合由 InstancedMesh 合批,但 Draw Call 降低不等于更新免费。若 8 个实例中只有 1、2、6 号变化,仍把整块矩阵缓冲标成更新,就会让表现层替玩法状态支付固定上传成本。本文把矩阵生产与 GPU 提交拆成一条有预算的调用链。
// .tmp/daily-labs/2026-07-25-2000/instanced-upload.js
// InstanceUploadQueue
const MATRIX_FLOATS = 16;
const BYTES_PER_MATRIX = MATRIX_FLOATS * Float32Array.BYTES_PER_ELEMENT;
export class InstanceUploadQueue {
#committed;
#pending = new Map();
#capacity;
constructor(capacity) {
if (!Number.isInteger(capacity) || capacity < 1) {
throw new RangeError("capacity must be a positive integer");
}
this.#capacity = capacity;
this.#committed = new Float32Array(capacity * MATRIX_FLOATS);
}
stage(index, matrix) {
if (!Number.isInteger(index) || index < 0 || index >= this.#capacity) {
throw new RangeError("instance index is out of range");
}
if (matrix.length !== MATRIX_FLOATS ||
matrix.some((value) => !Number.isFinite(value))) {
throw new TypeError("matrix must contain 16 finite numbers");
}
this.#pending.set(index, Float32Array.from(matrix));
}
}

所有权边界
玩法或动画系统拥有“下一个变换”的计算权,却不应拥有上传时机;更新队列独占 pending 与 committed,渲染适配器只接收连续范围。由此得到两个不变量:非法矩阵不能污染已提交状态;一次提交选中的矩阵数不能超过帧预算。stage 复制输入,阻断调用者随后改写同一数组造成的隐式共享。
这里的 committed 是 CPU 侧可上传快照,不等同于 GPU 已完成消费。适配器若采用异步映射或持久映射缓冲,还必须在自身边界维护围栏;队列只保证交给适配器的数据在本次回调期间稳定,不越权声明显存生命周期已经结束。
| 状态 | 所有者 | 允许操作 |
|---|---|---|
| next matrix | 玩法/动画 | 计算并提交 |
| pending | 更新队列 | 覆盖同索引的新值 |
| committed | 更新队列 | 只在 commit 内修改 |
| GPU range | 渲染适配器 | 消费连续 Float32 区间 |
范围合并
提交先按实例索引排序,再按预算截断,最后把相邻索引合成范围。正常时序是 stage → commit → coalesce → uploadRange。固定输入 1、2、6 在预算为 2 时,第一帧只生成 [1, 2],6 号保持待提交;这比把预算应用在合并之后更严格,因为任何范围都不会偷偷携带预算外实例。
// .tmp/daily-labs/2026-07-25-2000/instanced-upload.js
// InstanceUploadQueue.commit
commit(maxMatrices, uploadRange) {
if (!Number.isInteger(maxMatrices) || maxMatrices < 1) {
throw new RangeError("maxMatrices must be a positive integer");
}
const selected = [...this.#pending.keys()]
.sort((a, b) => a - b)
.slice(0, maxMatrices);
const ranges = coalesce(selected);
for (const index of selected) {
this.#committed.set(
this.#pending.get(index),
index * MATRIX_FLOATS
);
}
for (const range of ranges) {
const start = range.start * MATRIX_FLOATS;
const end = (range.start + range.count) * MATRIX_FLOATS;
uploadRange(
range,
this.#committed.subarray(start, end)
);
}
selected.forEach((index) => this.#pending.delete(index));
return {
ranges,
uploadedMatrices: selected.length,
uploadedBytes: selected.length * BYTES_PER_MATRIX,
pending: this.#pending.size
};
}

失败保持
矩阵含 NaN 时,最危险的做法是先写 committed 再报错;一次坏动画采样便会进入渲染缓冲。当前边界在 stage 完成长度与有限数校验,失败时既不加入 pending,也不触碰 committed。回归先提交实例 3 的合法平移,再注入坏矩阵并逐值比较快照。
// .tmp/daily-labs/2026-07-25-2000/instanced-upload.test.js
// rejects an invalid matrix without mutation
test("rejects an invalid matrix without mutation", () => {
const queue = new InstanceUploadQueue(8);
queue.stage(3, matrixWithTranslation(30, 0, 0));
queue.commit(8, () => {});
const before = queue.value(3);
assert.throws(
() => queue.stage(
3,
matrixWithTranslation(Number.NaN, 0, 0)
),
/16 finite numbers/
);
assert.deepEqual(queue.value(3), before);
});
test("keeps excess instances pending", () => {
const queue = new InstanceUploadQueue(8);
[1, 2, 6].forEach((index) =>
queue.stage(index, matrixWithTranslation(index, 0, 0))
);
const first = queue.commit(2, () => {});
const second = queue.commit(2, () => {});
assert.equal(first.pending, 1);
assert.deepEqual(second.ranges, [{ start: 6, count: 1 }]);
});
固定结果
探针容量为 8,每矩阵 16 个 Float32,即 64 字节。三处变化共提交 192 字节;512 字节是同一固定输入的全量缓冲大小,320 字节只是本次没有上传的差值,不外推为帧耗时或 GPU 性能收益。测试覆盖相邻合并、预算延期与失败保持。
$ cd $WORK_DIR/.tmp/daily-labs/2026-07-25-2000
$ npm test && npm run probe
tests 3
pass 3
fail 0
skipped 0
duration_ms 39.107125
test_exit=0
probe_exit=0
capacity=8
dirtyInstances=[1,2,6]
frameBudgetMatrices=2
frame1 ranges=[{"start":1,"count":2}] uploadedBytes=128 pending=1
frame2 ranges=[{"start":6,"count":1}] uploadedBytes=64 pending=0
totalUploadedBytes=192
fullUploadBytes=512
avoidedBytes=320
translations=[{"index":1,"x":10},{"index":2,"x":20},{"index":6,"x":60}]
复杂度边界
设脏实例数为 D,当前排序成本为 O(D log D),提交内存为 64D 字节,上传调用数等于连续区间数 R。只追求最小字节会让稀疏更新产生过多调用;只追求单次调用又会把未变化矩阵带入区间。当前取舍优先遵守矩阵预算,不跨空洞合并。若 R 的调用成本成为已测瓶颈,可引入“允许携带 K 个干净实例”的合并阈值,但 K 必须进入字节预算。
| 失效信号 | 演进动作 |
|---|---|
| D 长期接近实例容量 | 切换全量上传路径 |
| R 长期接近 D | 基于实测引入空洞合并 |
| 多线程同时生产矩阵 | 增加版本化双缓冲 |
| 更新优先级不同 | pending 改为稳定优先队列 |
架构判断
实例化渲染解决的是重复几何的提交批次,不自动解决动态矩阵的更新策略。把 needsUpdate 直接交给每个玩法对象,状态边界会退化成“谁最后写谁负责整块上传”。更稳健的接口是让对象只 stage(index, matrix),由帧末唯一队列决定范围、预算与失败语义。当前实现适用于稀疏变化且允许跨帧延迟的表现对象;关键角色或碰撞权威状态不应通过延期上传获得一致性。下一阶段只有在 R、D 与实际驱动调用耗时被观测后,才值得在字节量和调用数之间重新定权。