InstancedMesh 只改三个实例,为何可能上传 898 个矩阵

一个场景有 1024 棵实例化树木,本帧只有索引 3、4 和 900 的世界矩阵发生变化。如果更新代码只记住最小索引 3 与最大索引 900,最后形成的连续区间会覆盖 898 个矩阵。每个 mat4 由 16 个 32 位浮点数组成,占 64 字节,于是这次提交不是直觉中的 3 × 64 = 192 字节,而是 898 × 64 = 57,472 字节。

这不是在讨论一次上传究竟耗时多少。没有浏览器、驱动与设备实测,不能把字节差直接写成帧率收益。真正需要先解决的是接口含义:InstancedMesh 的实例矩阵最终位于一段连续属性缓冲中,但“哪些矩阵本帧变脏”不应该被压缩成一个首尾范围。脏集合属于 CPU 侧上传规划器;渲染对象只消费已经验证、满足调用预算的区间。

稀疏实例矩阵更新的主视觉

一个区间把空间距离误当成数据连续性

索引相距很远,只说明它们在实例数组中的地址相距很远,并不说明中间实例也需要传输。单包围区间实现简单,只需一次调用;代价则随首尾距离增长。在固定样本中,索引 3 到 900 的跨度把 895 个未修改矩阵一起纳入候选。

相反,每个脏实例各发一次又会把问题推向另一端:最坏情况下调用数等于脏实例数。这里采用一个明确但可替换的约束——每帧最多两个上传区间。规划器先排序、去重,每个索引形成长度为一的区间;若区间数超过预算,就合并间隔最小的一对,直到满足预算。这样 3 与 4 先合并,900 保持独立。

核心实现位于 instance-upload-planner.mjsInstanceUploadPlanner

export const MATRIX_BYTES = 64;

export class InstanceUploadPlanner {
  #instanceCount;
  #maxCalls;
  #committed = [];

  constructor(instanceCount, maxCalls) {
    if (!Number.isInteger(instanceCount) || instanceCount <= 0) {
      throw new RangeError("instanceCount must be a positive integer");
    }
    if (!Number.isInteger(maxCalls) || maxCalls <= 0) {
      throw new RangeError("maxCalls must be a positive integer");
    }
    this.#instanceCount = instanceCount;
    this.#maxCalls = maxCalls;
  }

  get committedRanges() {
    return this.#committed.map((range) => ({ ...range }));
  }

  plan(dirtyIndices) {
    const unique = [...new Set(dirtyIndices)].sort((a, b) => a - b);
    if (unique.some((index) =>
      !Number.isInteger(index) || index < 0 || index >= this.#instanceCount)) {
      return {
        accepted: false,
        reason: "IndexOutOfRange",
        ranges: this.committedRanges,
      };
    }

    const candidate = unique.map((index) => ({ start: index, count: 1 }));
    while (candidate.length > this.#maxCalls) {
      const mergeAt = this.#smallestGap(candidate);
      const left = candidate[mergeAt];
      const right = candidate[mergeAt + 1];
      candidate.splice(mergeAt, 2, {
        start: left.start,
        count: right.start + right.count - left.start,
      });
    }

    this.#committed = candidate;
    return {
      accepted: true,
      reason: "Committed",
      ranges: this.committedRanges,
      uploadBytes: candidate.reduce(
        (sum, range) => sum + range.count * MATRIX_BYTES,
        0,
      ),
    };
  }

  #smallestGap(ranges) {
    let selected = 0;
    let smallest = Number.POSITIVE_INFINITY;
    for (let index = 0; index < ranges.length - 1; index += 1) {
      const gap = ranges[index + 1].start
        - (ranges[index].start + ranges[index].count);
      if (gap < smallest) {
        smallest = gap;
        selected = index;
      }
    }
    return selected;
  }
}

算法刻意没有把 Three.js 对象放进规划器。玩法或变换系统写实例矩阵并提交脏索引,规划器产出 { start, count };渲染适配层再把矩阵索引换算成底层属性所需的元素偏移。这样数据选择、调用预算与具体渲染 API 分开,升级渲染库时不会迫使玩法系统理解缓冲上传细节。

调用预算决定合并,不等于固定选择两次调用

两次调用不是普适答案。若驱动调用成本高,合并一个小间隔可能比保留两个区间更合适;若更新点相距很远,单区间又会传输大量无关字节。因此规划器需要同时暴露调用数和上传字节数,让项目用目标设备数据选择预算。本文固定预算为 2,只验证在该约束下输出确定、可复算,并未声称它是性能最优值。

稀疏矩阵上传区间与失败保持

图中的两个候选来自同一组固定输入。两区间方案提交 [3, count 2][900, count 1],合计 192 字节;单区间方案提交 [3, count 898],合计 57,472 字节。二者差异来自区间形状,不包含 GPU 执行、同步或 JavaScript 调用时间,因此只能支持容量判断。

非法索引不能抹掉上一份可用计划

上传范围最终会转成缓冲偏移,索引 1024 对长度为 1024 的实例数组已经越界。若规划器边扫描边改共享区间,扫描到非法索引时可能留下半份候选,后续上传就无法判断哪些范围仍可信。

实现先在局部 candidate 中完成校验与合并,全部成立后才替换 #committed。回归样本先提交 3、4、900,再尝试 4、1024;第二次返回 IndexOutOfRange,同时仍返回上一份 [3, count 2][900, count 1]。失败只拒绝新计划,不反向污染已经可用的渲染提交。

当天在 Node.js 运行探针:

$ cd $WORK_DIR/.tmp/daily-labs/2026-08-20-1930
$ node test.mjs
{"passed":8,"failed":0,"skipped":0,"elapsedMs":3.153}
exit=0, wall=0.13s

$ node run.mjs
dirty=[3,4,900], callBudget=2
committed=[[3,2],[900,1]], uploadBytes=192
boundingRangeBytes=57472
rejected=IndexOutOfRange, committed=[[3,2],[900,1]]
exit=0, wall=0.12s

八个用例还覆盖重复索引去重、单实例 64 字节、相邻索引优先合并,以及调用预算降为 1 时确定退化为 898 个矩阵的包围区间。由此可以把一次看似简单的 needsUpdate 决策拆成两个独立问题:实例矩阵内容是否改变,以及哪些连续字节值得提交。

当前方案适用于实例数量固定、矩阵槽位稳定、CPU 能准确收集脏索引的渲染批次。若实例会频繁增删或重排,索引身份本身还需要代次或稳定句柄;若脏点规模持续接近总实例数,多区间规划的收益边界会消失,应切换为整缓冲提交。无论采用哪种策略,脏集合与已提交上传计划都应由同一个边界管理,不能让多个玩法调用者各自扩大一段共享范围。