运行时纹理双缓冲:CPU 写入与 GPU 上传的所有权交接

游戏中的迷你地图、伤害热区或运行时笔刷会持续生成像素。难点不在 RGBA8 数组怎样填色,而在上传函数返回后,GPU 是否已经停止读取这块内存。本文把一次更新定义为 CPU 暂存槽从可写到在途、再由完成通知释放的所有权转移。

// .tmp/daily-labs/2026-07-25-1930/runtime-texture.js
// 符号:RuntimeTexture.constructor
export class TextureTransferError extends Error {}

export class RuntimeTexture {
  constructor(width, height) {
    if (width <= 0 || height <= 0) {
      throw new TextureTransferError("invalid dimensions");
    }
    this.width = width;
    this.height = height;
    this.byteLength = width * height * 4;
    this.slots = [
      { pixels: new Uint8Array(this.byteLength), state: "free", version: 0 },
      { pixels: new Uint8Array(this.byteLength), state: "free", version: 0 },
    ];
    this.nextVersion = 1;
    this.visibleVersion = 0;
  }
}

运行时纹理双缓冲主视觉

所有权模型

状态所有者是 RuntimeTexture,CPU 填色器与 GPU 适配器都不能直接改槽位状态。系统边界止于 upload 提交和完成回调,不假定底层图形 API 同步复制。两个不变量必须保持:in-flight 数组不可写;只有版本匹配的完成通知能推进 visibleVersion 并释放槽位。free → writing → ready → in-flight → free 因而既是生命周期,也是访问权限表。

这里的 ready 仍归传输协议管理,表示内容已经封闭但尚未交给 GPU;writing 则只归当前 CPU 写入者临时持有。调用者拿到的是像素视图与槽位编号,而不是任意改变生命周期的能力。这种窄接口把内容生产和资源调度分开,使渲染后端替换时不需要改写上层纹理生成逻辑。

// .tmp/daily-labs/2026-07-25-1930/runtime-texture.js
// 符号:beginWrite、commitWrite
beginWrite() {
  const index = this.slots.findIndex((slot) => slot.state === "free");
  if (index < 0) {
    throw new TextureTransferError("no writable staging slot");
  }
  this.slots[index].state = "writing";
  return { index, pixels: this.slots[index].pixels };
}

commitWrite(index) {
  const slot = this.#slot(index, "writing");
  slot.state = "ready";
  slot.version = this.nextVersion++;
  return slot.version;
}

提交协议

提交只挑选 ready 槽位,并在调用 GPU 前先标记为 in-flight。这个顺序防止重入代码在上传适配器内部再次取得同一数组。正常时序是 CPU 提交版本 1 后立刻转写槽 1;GPU 完成版本 1,槽 0 才重新可用。显示版本表示已确认可见的下界,不等同于最新 CPU 版本。

// .tmp/daily-labs/2026-07-25-1930/runtime-texture.js
// 符号:submitNext、complete
submitNext(gpu) {
  const index = this.slots.findIndex((slot) => slot.state === "ready");
  if (index < 0) return null;
  const slot = this.slots[index];
  slot.state = "in-flight";
  gpu.upload(slot.pixels, slot.version);
  return { index, version: slot.version, bytes: slot.pixels.byteLength };
}

complete(index, version) {
  const slot = this.#slot(index, "in-flight");
  if (slot.version !== version) {
    throw new TextureTransferError("stale GPU completion");
  }
  this.visibleVersion = Math.max(this.visibleVersion, version);
  slot.state = "free";
  return this.snapshot();
}

双缓冲槽位状态与完成通知

背压分支

双缓冲不是无等待承诺。槽 0 在途且槽 1 已就绪时,第三次 beginWrite 必须失败;覆盖较旧槽位看似能保持生产者流畅,却会让 GPU 读取到半新半旧的纹理。此探针选择显式拒绝,把丢帧、降采样或等待的决策留给调用者。过期完成通知同样只报错,不释放任何状态,避免旧回调误伤已经复用的新版本。

// .tmp/daily-labs/2026-07-25-1930/runtime-texture.test.js
// 符号:背压与过期完成回归
test("两个槽位都被占用时拒绝覆盖 GPU 正在读取的内存", () => {
  const texture = new RuntimeTexture(2, 2);
  const first = texture.beginWrite();
  texture.commitWrite(first.index);
  texture.submitNext({ upload() {} });
  const second = texture.beginWrite();
  texture.commitWrite(second.index);
  assert.throws(
    () => texture.beginWrite(),
    new TextureTransferError("no writable staging slot"),
  );
  assert.deepEqual(texture.snapshot().states, ["in-flight", "ready"]);
});

test("过期完成通知不能释放新版本槽位", () => {
  const texture = new RuntimeTexture(1, 1);
  const write = texture.beginWrite();
  texture.commitWrite(write.index);
  texture.submitNext({ upload() {} });
  const before = texture.snapshot();
  assert.throws(
    () => texture.complete(write.index, 99),
    new TextureTransferError("stale GPU completion"),
  );
  assert.deepEqual(texture.snapshot(), before);
});

容量约束

固定输入为 256 × 128 RGBA8,单槽容量按 width × height × 4 复算为 131072 字节,双槽 CPU 暂存为 262144 字节。该数字不包含驱动内部副本与最终 GPU 纹理,因此不能冒充显存占用。容量与像素数线性增长;若扩到 2048 × 2048,双槽仅像素数组就达到 32 MiB,届时分块更新或脏矩形比盲目增加槽位更重要。

槽位数也不是吞吐量的替代指标。增加第三个槽位只会延后背压出现,并按整张纹理继续增加驻留内存;如果消费者长期慢于生产者,任何有限槽位最终都会耗尽。监控应同时记录槽位占用时间、拒绝次数和待提交版本数,不能只看平均上传次数。

项目 固定值 推导
单槽像素 32768 256 × 128
单槽字节 131072 32768 × 4
双槽字节 262144 131072 × 2
完成后状态 free, ready 版本 1 完成,版本 2 待提交

运行证据

三条测试分别锁定正常重叠、槽位耗尽和过期通知。当天执行全部通过,未做吞吐或 GPU 时延测量,因此本文只主张状态正确性与容量上界,不声称帧率提升。固定探针提交槽 0 的版本 1 与 131072 字节;完成后可见版本为 1,而版本 2 仍保持 ready

$ cd $WORK_DIR/.tmp/daily-labs/2026-07-25-1930
$ npm test
✔ 双缓冲允许 CPU 写下一帧,同时 GPU 上传上一帧
✔ 两个槽位都被占用时拒绝覆盖 GPU 正在读取的内存
✔ 过期完成通知不能释放新版本槽位
tests 3; pass 3; fail 0; skipped 0
duration_ms 40.047792
exit_code 0; wall_time 0.17s

$ npm run probe
input: 256x128 RGBA8; fills: 17,31
submitted: slot=0 version=1 bytes=131072
completed: visibleVersion=1 states=free,ready versions=1,2
gpuUploads: 1
exit_code 0; wall_time 0.10s

演进条件

双缓冲适合最多一个上传在途、生产者允许背压的纵向切片。若监测显示连续槽位耗尽,应先区分上传完成时延过长与 CPU 更新频率过高;前者考虑异步传输队列,后者考虑脏矩形、降频或合并。只有确实需要多个在途版本时才扩成有界环形缓冲,并同时定义最大字节数和淘汰策略。架构判断是:像素内容由 CPU 生成,但槽位生命周期必须由跨 CPU/GPU 的传输协议拥有。