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 里任务图的拓扑序是同一件事,只是这里的「依赖」退化成了一条全序。
两种录制方式的对照可以列成一张表:
| 维度 | 单线程顺序录制 | 多编码器并行录制 |
|---|---|---|
| 命令缓冲数量 | 1 | N(每段一个) |
| 提交 | 一次 submit([buf]) | 一次 submit([b0, b1, …]),按序 |
| 结果 | 基准 | 必须与基准逐字节一致 |
| 并行度 | 1 | min(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 的对接 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。