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));
  }
}

八个实例中仅三个矩阵进入上传链

所有权边界

玩法或动画系统拥有“下一个变换”的计算权,却不应拥有上传时机;更新队列独占 pendingcommitted,渲染适配器只接收连续范围。由此得到两个不变量:非法矩阵不能污染已提交状态;一次提交选中的矩阵数不能超过帧预算。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 与实际驱动调用耗时被观测后,才值得在字节量和调用数之间重新定权。