《游戏引擎实现原理》17.2 OffscreenCanvas 与并行命令录制

上一节把命令缓冲做成了一条跨线程通道,但录制本身还是串行的。本节用 OffscreenCanvas 把渲染表面搬离主线程,再把一帧的命令拆成多条独立编码器分别录制、按提交顺序合并,在真实 WebGPU 设备上验证「两条命令缓冲」与「单编码器两趟」逐字节一致。

17.2 OffscreenCanvas 与并行命令录制

17.1 节搭好了主线程到渲染线程的命令缓冲通道,但那条通道只是把「录好的命令」搬过去,录制本身还是在一个线程上串行完成。要让录制也并行,得先解决两个前提:渲染表面得能离开主线程,一帧的命令得能被拆成互相独立的几段。前者靠 OffscreenCanvas,后者靠「独立命令编码器 + 按提交顺序合并」。

本节把这两件事各跑通一次:在 OffscreenCanvas 上开 WebGPU 上下文、渲染、回读像素;再用两条互不相干的 GPUCommandEncoder 各录一半画面,按顺序提交,验证结果与单编码器录两趟逐字节相同。这两步合起来,就是「多线程并行录制」在 WebGPU 上的最小可行模型。

之所以要先把这两步单独验掉,是因为它们各自都可能悄悄出错:OffscreenCanvas 的像素可能因为格式字节序对不上而「看起来对了其实错了」;两条命令缓冲合并后也可能因为顺序或 loadOp 写反而画出一张错误的图。把这两件事分别钉死成断言,17.3 节往上接 Job System 时才有可信的地基。

OffscreenCanvas:把渲染表面搬离主线程

<canvas> 是 DOM 节点,只能在主线程上取上下文、只能在主线程上呈现。这本身就把渲染绑在了主线程上——哪怕你把命令录制搬到了别的线程,最后一笔「画到屏幕上」还是得回主线程。OffscreenCanvas 解决的就是这一环:它是一块不挂在 DOM 上的画布,可以在任意线程上创建、拿上下文、绘制,也可以用 transferControlToOffscreen() 把一个已有 <canvas> 的控制权整个交给 Worker。

关键点在于:OffscreenCanvas.getContext('webgpu') 返回的还是那个 GPUCanvasContext,配置方式、getCurrentTexture()、copyTextureToBuffer 的用法与普通 canvas 完全一致。也就是说,渲染器只要认 GPUCanvasContext 这个接口,它跑在主线程还是 Worker 上是透明的——这正是把渲染整条链路搬进 Worker 的前提。

两个容易踩的坑值得先记下。其一,transferControlToOffscreen() 是一次性的:一块 canvas 一旦被转交出去,主线程就再也拿不到它的 2D 或 WebGPU 上下文,转交后所有操作都得在接收线程上做。其二,OffscreenCanvas 在 Worker 里能不能拿到 WebGPU,取决于该 Worker 是否处在安全上下文;跨源隔离没配好时(缺少 COOP/COEP 头),navigator.gpu 会是 undefined,这和 17.1 节里 SharedArrayBuffer 的前提是同一条——两者都要求页面被跨源隔离。本节的验收在页面上直接开 OffscreenCanvas,省掉了 Worker 的启动与消息往返,验证的是同一套 GPUCanvasContext 接口。

并行命令录制:独立编码器按序合并

录制能并行,是因为 GPUCommandEncoder 之间互不依赖:每个编码器只描述「往哪个 attachment 写、用什么管线、画几个顶点」,它不持有设备级的全局状态。于是可以把一帧拆成几段,每段交给一个线程用各自的编码器录,最后由主线程把它们的 finish() 结果按顺序塞进一次 queue.submit([...])。

顺序为什么必须保留?因为 GPU 执行命令缓冲的次序就是数组次序,而绘制次序直接决定混合与深度测试的结果。两段命令谁先谁后,画面可能完全不同。所以并行录制的产出不是「一个无序集合」,而是「一组带序号的命令缓冲」,合并时按序号排好——这与卷一 helio-kernel-15-2-task-graph-frame 里任务图的拓扑序是同一件事,只是这里的「依赖」退化成了一条全序。

两种录制方式的对照可以列成一张表:

维度单线程顺序录制多编码器并行录制
命令缓冲数量1N(每段一个)
提交一次 submit([buf])一次 submit([b0, b1, …]),按序
结果基准必须与基准逐字节一致
并行度1min(N, 可用线程数)
代价无状态冗余 + 提交开销 + 顺序约束

表里最关键的一行是「结果必须与基准逐字节一致」:并行录制是等价变换,不是近似。任何让最终画面发生变化的「优化」都不是优化,是 bug。本节后面那条 same=true 的断言,守的就是这一行。

可运行产物:在 OffscreenCanvas 上渲染并核对像素

着色器 shaders/offscreen-half.wgsl 是一个全屏三角形,片元返回 uniform 里的常量颜色。它足够小,却能验证两件事:像素确实落进了目标、格式的字节序也对。

// file: shaders/offscreen-half.wgsl
// tier: L2
//
// helio 渲染卷 17.2:全屏三角形,片元返回 uniform 里的常量颜色。
// 用它把同一张图分两半录进两条独立的命令缓冲(各配一个 scissor 矩形),
// 提交时按顺序合并——这正是「多条命令缓冲并行录制、主线程合并提交」的最小模型。

struct U { color: vec4<f32>, };
@group(0) @binding(0) var<uniform> u: U;

struct VSOut { @builtin(position) pos: vec4<f32>, };

@vertex
fn vs(@builtin(vertex_index) i: u32) -> VSOut {
  var tri = array<vec2<f32>, 3>(
    vec2<f32>(-1.0, -1.0), vec2<f32>(3.0, -1.0), vec2<f32>(-1.0, 3.0));
  var o: VSOut;
  o.pos = vec4<f32>(tri[i], 0.0, 1.0);
  return o;
}

@fragment
fn fs() -> @location(0) vec4<f32> {
  return u.color;
}

下面的 src/render/thread/offscreen-record.ts 是完整可编译文件,层级 L2。它先在一个 16×16 的 OffscreenCanvas 上画满整屏,回读中心像素;再建两条纹理各录两半——第一条用一条编码器录左半(清屏 + 红),第二条用另一条编码器录右半(loadOp: 'load' + 绿),一起提交;最后用「单编码器两趟 pass」录一份参考,逐字节比对。

画布取 16×16 是有意的:copyTextureToBuffer 要求 bytesPerRow 是 256 的倍数,而 16×4=64 不满足,所以回读缓冲按 256 字节/行来开,每行前 64 字节有效。取中心像素时用的偏移也据此计算,而不是简单的 (y*W + x)*4——这一点在分辨率不是 64 的整数倍时最容易算错。

// file: src/render/thread/offscreen-record.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/thread/offscreen-record.ts
//
// helio 渲染卷 17.2:OffscreenCanvas 上的 WebGPU 渲染 + 并行命令录制。
// 断言:(1) OffscreenCanvas 能开 WebGPU 上下文并回读像素;(2) 两条独立
// CommandEncoder 各录一半、按顺序提交,与「单编码器两趟」逐字节一致。
// 需在浏览器中经本地 http 服务运行,命令行不可复现。
export interface L2Check { readonly name: string; readonly ok: boolean; readonly detail: string }
export interface L2Result { readonly checks: readonly L2Check[]; readonly data: Record<string, unknown> }

const W = 16;
const H = 16;
const HALF = W / 2;
const BPR = 256; // bytesPerRow 必须是 256 的倍数(16*4=64 会被拒绝)
const RED: readonly number[] = [1, 0, 0, 1];
const GREEN: readonly number[] = [0, 1, 0, 1];

/** 把线性颜色转成落字节;bgra8unorm 的显存字节序是 B,G,R,A。 */
function expectedBytes(rgba: readonly number[], format: string): number[] {
  const q = (x: number): number => Math.round(Math.min(1, Math.max(0, x)) * 255);
  const [r, g, b, a] = rgba;
  const v = [q(r!), q(g!), q(b!), q(a!)];
  return format === 'bgra8unorm' ? [v[2]!, v[1]!, v[0]!, v[3]!] : v;
}
const pixelAt = (px: Uint8Array, x: number, y: number): number[] =>
  Array.from(px.slice(y * BPR + x * 4, y * BPR + x * 4 + 4));

export async function runL2(): Promise<L2Result> {
  const checks: L2Check[] = [];
  const data: Record<string, unknown> = {};
  const push = (name: string, ok: boolean, detail: unknown): void => {
    checks.push({ name, ok, detail: String(detail) });
  };
  const adapter = await navigator.gpu.requestAdapter();
  if (!adapter) throw new Error('requestAdapter() returned null');
  const device = await adapter.requestDevice();

  const wgsl = await (await fetch('/shaders/offscreen-half.wgsl')).text();
  const module = device.createShaderModule({ code: wgsl });
  const info = await module.getCompilationInfo();
  const errors = info.messages.filter((m) => m.type === 'error');
  push('wgsl 0 error', errors.length === 0, errors.length);

  const pipelineFor = (format: GPUTextureFormat): GPURenderPipeline => device.createRenderPipeline({
    layout: 'auto', vertex: { module, entryPoint: 'vs' },
    fragment: { module, entryPoint: 'fs', targets: [{ format }] }, primitive: { topology: 'triangle-list' },
  });
  const bindFor = (pipe: GPURenderPipeline, rgba: readonly number[]): GPUBindGroup => {
    const b = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
    device.queue.writeBuffer(b, 0, new Float32Array(rgba));
    return device.createBindGroup({ layout: pipe.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: b } }] });
  };
  const readback = device.createBuffer({ size: BPR * H, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
  const readTex = async (enc: GPUCommandEncoder, tex: GPUTexture): Promise<Uint8Array> => {
    enc.copyTextureToBuffer({ texture: tex }, { buffer: readback, bytesPerRow: BPR }, [W, H]);
    device.queue.submit([enc.finish()]);
    await readback.mapAsync(GPUMapMode.READ);
    const px = new Uint8Array(readback.getMappedRange().slice(0));
    readback.unmap();
    return px;
  };
  const newTex = (): GPUTexture => device.createTexture({
    size: [W, H], format: 'rgba8unorm', usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
  });

  // ---- 断言 1:OffscreenCanvas 上跑 WebGPU 并回读 ----
  const canvasFmt = navigator.gpu.getPreferredCanvasFormat();
  const ctx = new OffscreenCanvas(W, H).getContext('webgpu');
  if (!ctx) throw new Error('OffscreenCanvas 拿不到 webgpu 上下文');
  ctx.configure({ device, format: canvasFmt, usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC });
  const canvasTex = ctx.getCurrentTexture();
  const canvasPipe = pipelineFor(canvasFmt);
  const encC = device.createCommandEncoder();
  const cp = encC.beginRenderPass({
    colorAttachments: [{ view: canvasTex.createView(), clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store' }],
  });
  cp.setPipeline(canvasPipe); cp.setBindGroup(0, bindFor(canvasPipe, [0.2, 0.4, 0.8, 1])); cp.draw(3); cp.end();
  const canvasCenter = pixelAt(await readTex(encC, canvasTex), W / 2, H / 2);
  const canvasExpect = expectedBytes([0.2, 0.4, 0.8, 1], canvasFmt);
  data.canvas = { fmt: canvasFmt, center: canvasCenter, expect: canvasExpect };
  push('OffscreenCanvas 像素与期望一致', JSON.stringify(canvasCenter) === JSON.stringify(canvasExpect),
    `fmt=${canvasFmt} got=${canvasCenter} want=${canvasExpect}`);

  // ---- 断言 2:两条独立编码器各录一半,提交顺序 e1 -> e2 ----
  const pipe = pipelineFor('rgba8unorm');
  const redBg = bindFor(pipe, RED);
  const greenBg = bindFor(pipe, GREEN);
  const twoTex = newTex();
  const e1 = device.createCommandEncoder();
  const p1 = e1.beginRenderPass({
    colorAttachments: [{ view: twoTex.createView(), clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store' }],
  });
  p1.setScissorRect(0, 0, HALF, H); p1.setPipeline(pipe); p1.setBindGroup(0, redBg); p1.draw(3); p1.end();
  const e2 = device.createCommandEncoder();
  const p2 = e2.beginRenderPass({
    colorAttachments: [{ view: twoTex.createView(), loadOp: 'load', storeOp: 'store' }],
  });
  p2.setScissorRect(HALF, 0, HALF, H); p2.setPipeline(pipe); p2.setBindGroup(0, greenBg); p2.draw(3); p2.end();
  e2.copyTextureToBuffer({ texture: twoTex }, { buffer: readback, bytesPerRow: BPR }, [W, H]);
  device.queue.submit([e1.finish(), e2.finish()]); // 合并提交:e1 清屏画左半,e2 画右半并回读
  await readback.mapAsync(GPUMapMode.READ);
  const twoPx = new Uint8Array(readback.getMappedRange().slice(0));
  readback.unmap();

  // 单编码器参考:同一个纹理,两趟 pass。
  const refTex = newTex();
  const ref = device.createCommandEncoder();
  const r1 = ref.beginRenderPass({
    colorAttachments: [{ view: refTex.createView(), clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store' }],
  });
  r1.setScissorRect(0, 0, HALF, H); r1.setPipeline(pipe); r1.setBindGroup(0, redBg); r1.draw(3); r1.end();
  const r2 = ref.beginRenderPass({
    colorAttachments: [{ view: refTex.createView(), loadOp: 'load', storeOp: 'store' }],
  });
  r2.setScissorRect(HALF, 0, HALF, H); r2.setPipeline(pipe); r2.setBindGroup(0, greenBg); r2.draw(3); r2.end();
  const refPx = await readTex(ref, refTex);

  const left = pixelAt(twoPx, 4, 8);
  const right = pixelAt(twoPx, 12, 8);
  data.parallel = { left, right, refLeft: pixelAt(refPx, 4, 8), refRight: pixelAt(refPx, 12, 8) };
  push('并行录制:左半红、右半绿',
    JSON.stringify(left) === JSON.stringify([255, 0, 0, 255]) && JSON.stringify(right) === JSON.stringify([0, 255, 0, 255]),
    `left=${left} right=${right}`);
  let same = twoPx.length === refPx.length;
  for (let i = 0; i < twoPx.length && same; i++) if (twoPx[i] !== refPx[i]) same = false;
  push('两条命令缓冲 == 单编码器参考(逐字节)', same, `same=${same}`);

  device.destroy();
  return { checks, data };
}

在 headless Chromium(本地 http 服务、带 --enable-unsafe-webgpu)里跑 node tools/run-l2.mjs src/render/thread/offscreen-record.ts,本机实测:

PASS  wgsl 0 error  0
PASS  OffscreenCanvas 像素与期望一致  fmt=bgra8unorm got=204,102,51,255 want=204,102,51,255
PASS  并行录制:左半红、右半绿  left=255,0,0,255 right=0,255,0,255
PASS  两条命令缓冲 == 单编码器参考(逐字节)  same=true

三点值得注意。第一,画布首选格式是 bgra8unorm,显存字节序是 B、G、R、A,所以颜色 (0.2, 0.4, 0.8) 落成 204, 102, 51(B=0.8×255 在前),expectedBytes 里的分支就是在处理这个。第二,两条独立编码器合并提交后,左半是纯红、右半是纯绿,loadOp: 'load' 保住了前一条编码器写下的左半。第三,也是最重要的:两条命令缓冲的结果与单编码器录两趟逐字节相同(same=true)——这说明「拆成多条编码器并行录、再按顺序合并」在语义上与串行录制完全等价。

还有一点是确定性:同一组命令、同一台设备,两次运行的字节结果必须相同。本节把两条路径都落到同一块 16×16 纹理上做逐字节比对,就是为了让「等价」这件事可断言,而不是靠肉眼看图——硬件与 SwiftShader 的逐字节一致,是这套验收能成立的前提(见附录 C)。

(本节所有数字均来自本机实跑,运行环境见附录 C;src/render/thread/offscreen-record.ts 需在浏览器中运行,命令行不可复现。真实引擎里把 OffscreenCanvas 用 transferControlToOffscreen() 交给 Worker、让录制整个跑在子线程上,属于交互式调试环境,命令行不可复现。)

合并的代价:顺序、状态与 scissor

并行录制不是免费的。第一笔代价是顺序:编码器之间必须能被排成一个全序,凡是存在「后一段依赖前一段结果」的地方(比如后一段要 loadOp: 'load' 前一段的产物),就不能真并行,只能串起来。本节用 scissor 把左右两半划开,正是为了让两段互不重叠、可以各录各的;如果两段画在同一批像素上,顺序就成了正确性的一部分,不能打乱。

scissor 在这里不是唯一手段,viewport 也能把绘制限制在子矩形内,但两者语义不同:scissor 是硬裁剪,矩形外的片元根本不生成;viewport 只做坐标映射,矩形外的几何照样会被光栅化。要做「各录一半、互不干扰」,用 scissor 更贴切——它从片元阶段就把另一半挡在外面。真实引擎里做分块渲染(比如把屏幕切成 tile 交给不同线程)用的也是同一套裁剪思路。

第二笔代价是状态冗余。每条编码器都从「无绑定状态」开始,所以分块边界处往往要重复 setPipeline——上一段末尾刚设过的管线,下一段开头又设一遍。分得越细,冗余越多;这也是 17.3 节要权衡的点:块数太少并行度不够,块数太多状态冗余吃掉收益。

第三笔是提交开销。一次 queue.submit 里的命令缓冲数量有上限,拆得太碎反而多出提交与校验的成本。实践中通常按「材质相近、绘制项连续」来切块,让每块既够大又能独立录制。

把这三笔代价放一起,就能看出分块粒度怎么选:块数太少,并行度不够,多出来的线程空转;块数太多,状态冗余与提交开销上升,还会因为切得太碎而让每块的绘制项不足以摊薄编码器的启动成本。一个稳妥的起点是「按材质分组后,让每组至少几十个绘制项」,再按可用线程数把组摊到各块上——这样每块内部材质变化少、块之间又不重叠。

还有一条容易被忽略的顺序细节:copyTextureToBuffer 这类「拷贝」命令必须排在它要拷贝的那一通道之后。本节把它放在第二条编码器的末尾,正是因为它要读的右半是在同一编码器的 pass 里刚写完的;如果把它挪到第一条编码器里,拷贝就会早于右半的绘制执行,回读到的是尚未绘制的黑区。

最后要提醒的是:并行录制的收益只有在「录制真的占用了可观 CPU 时间」时才体现得出来。如果一帧只有几十个绘制项,把命令拆到多线程反而被线程启动与消息往返的成本吃掉。判断该不该并行,靠的是先量出「单线程录制占帧时间的比例」,而不是先上多线程再回头找理由。

小结

  • OffscreenCanvas 提供了一块不挂 DOM 的渲染表面,GPUCanvasContext 的用法与普通 canvas 一致,是渲染链路进 Worker 的前提。
  • 一帧的命令可以拆成多条互不依赖的 GPUCommandEncoder 分别录制,主线程按顺序合并提交。
  • 提交顺序即执行顺序,绘制顺序不能打乱;分块只在「互不重叠」的前提下才真正并行。
  • scissor 是硬裁剪、viewport 只是坐标映射,要「各录一半互不干扰」用 scissor。
  • 本机实测:两条命令缓冲与单编码器两趟逐字节一致,证明拆分-合并与串行录制语义等价。

回到本节的起点:并行录制的收益,全押在「能把一帧切成足够多、又互不重叠的块」上。切块本身是 CPU 上的纯计算,不碰 GPU——它天然适合交给 Job System 的 worker 去分头做。所以下一步不是继续堆 WebGPU 技巧,而是把「分块 → 并行录 → 合并」这条流水线接到卷一那套线程池上,让块的划分与调度由任务图统一管理。

一个常见的误解是「把录制丢到多线程,帧率就一定涨」。事实上 WebGPU 的编码器创建与 finish() 本身有成本,命令缓冲的数量也有上限;如果一帧本来只录了几百条命令,多线程带来的并行收益很可能被编码器的固定开销抵消。判断标准不是「有没有多线程」,而是「单线程录制占整帧 CPU 时间的比例」——这个比例不够大时,优化别处更划算。

表面和命令都能拆了,剩下的问题是谁来拆、谁来合并——这就轮到卷一那套 Job System 出场了。

阅读导航:上一节:17.1 渲染线程模型与命令缓冲 · 下一节:17.3 与 Job System 的对接 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「engine」更多文章

  1. 《游戏引擎实现原理》工程与实战 目录
  2. 《游戏引擎实现原理》9.3 选型与自研的判断标准
  3. 《游戏引擎实现原理》9.2 同一个特性在不同引擎里的实现路径