3.1 命令编码器与渲染通道
上一章我们把 GPU 上的「东西」抽象成了资源对象:buffer、texture、pipeline、bind group。但资源本身不会动,一帧真正要发生的事——清屏、绑管线、画三角形——必须被录下来再一次性交给 GPU。这层录制机制就是 RHI 的命令模型:CommandEncoder 生产命令缓冲,RenderPassEncoder 在其内部圈出一段「正在渲染」的区间,queue.submit 把命令缓冲交给 GPU 执行。WebGPU 与 WebGL2 在这里的分野最明显。WebGL2 是「立即模式」:你调 gl.drawArrays(),驱动当场把命令写进隐式缓冲。WebGPU 是「录制模式」:所有调用只是往一块内存里追加记录,直到 finish() 产出一个不可变的 GPUCommandBuffer。代价是顺序约束变多了——很多错误不再是「这一行报错」,而是「提交后 GPU 校验失败」,甚至在 release 里静默画错。
本章与站内既有内容的分工
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/graphics-render-graph/ | 帧图的原理,以及 Unreal RDG / Unity SRP 的案例 | 不推帧图算法,只写「在 helio RHI 上录命令、开通道」这一层 |
/graphics-gpu-profiling-debugging/ | GPU 计时、抓帧与调试工具链 | 不展开工具链,只把「录制正确性」做成可断言的 CPU 状态机 |
/posts/saas/game-engine/ | 现成引擎的渲染架构综述与选型 | 不综述别人怎么分层,只交付 helio 自己的命令录制器 |
一句话:别人讲过的「是什么、怎么选」本节全部外链,本节只交付「怎么从零搭出来、跑出什么结果」。
命令录制是一台状态机
把 CommandEncoder 想象成一台只有一个游标的打字机,RenderPassEncoder 是它吐出来的一支「当前通道」笔。合法的录制序列只有一种骨架:
beginRenderPass(desc)开一条通道,清屏参数在此时定死;- 通道内先
setPipeline(pipe),再(可选)setBindGroup(i, bg),然后才能draw(n); end()关闭通道,之后才能开下一条;- 全部录完调
encoder.finish()拿到命令缓冲。
违反任意一条,WebGPU 要么在提交时抛校验错误,要么画出一片空白。更麻烦的是:draw 之前没 setPipeline 这类错误,往往在 GPU 侧才爆,堆栈指不到你写错的那一行。所以我们自己先实现一个只做顺序校验的录制器,把这类问题在 CPU 侧、在开发期就拦住。
下面 src/render/rhi/command-recorder.ts 是完整可编译文件,层级 L1(纯 TypeScript,Node 直跑),它不碰 GPU,只维护状态并记录命令表。
// file: src/render/rhi/command-recorder.ts
// tier: L1
// verify: npx vitest run src/render/rhi/command-recorder.test.ts
//
// helio 渲染后端:命令录制器的顺序约束校验。
// 它不碰 GPU,只在 CPU 侧模拟 CommandEncoder -> RenderPassEncoder 的
// 状态机:beginPass 后必须 setPipeline 才能 draw,pass 未关不能开新 pass,
// finish 时不能有未闭合的 pass。把 WebGPU 会「静默出错或直接校验失败」
// 的顺序问题提前变成一条可断言的异常。
export type CmdKind = 'beginPass' | 'setPipeline' | 'setBindGroup' | 'draw' | 'endPass';
export interface RecordedCommand {
readonly kind: CmdKind;
readonly arg: string | number;
}
export class CommandRecordError extends Error {}
/** 记录一条命令序列,并在录制期校验顺序约束。 */
export class CommandListRecorder {
readonly #commands: RecordedCommand[] = [];
#passOpen = false;
#pipeline: string | null = null;
#finished = false;
/** 开始一条渲染通道。此时清屏参数已定,但管线尚未绑定。 */
beginRenderPass(label: string): void {
if (this.#finished) throw new CommandRecordError('encoder 已 finish,不能再录');
if (this.#passOpen) throw new CommandRecordError('已有一条渲染通道未关闭');
this.#passOpen = true;
this.#pipeline = null;
this.#commands.push({ kind: 'beginPass', arg: label });
}
/** 绑定渲染管线。之后 draw 才合法。 */
setPipeline(name: string): void {
this.#requirePass('setPipeline');
this.#pipeline = name;
this.#commands.push({ kind: 'setPipeline', arg: name });
}
/** 绑定 bind group。可选的,但必须在 draw 之前。 */
setBindGroup(index: number, name: string): void {
this.#requirePass('setBindGroup');
this.#commands.push({ kind: 'setBindGroup', arg: `${index}:${name}` });
}
/** 画一批顶点。没有管线时立刻报错,而不是等 GPU 校验。 */
draw(vertexCount: number): void {
this.#requirePass('draw');
if (this.#pipeline === null) {
throw new CommandRecordError('draw 之前必须先 setPipeline');
}
if (vertexCount <= 0) throw new CommandRecordError(`非法的 vertexCount: ${vertexCount}`);
this.#commands.push({ kind: 'draw', arg: vertexCount });
}
/** 关闭当前渲染通道。 */
endRenderPass(): void {
if (!this.#passOpen) throw new CommandRecordError('endRenderPass 之前没有 beginRenderPass');
this.#passOpen = false;
this.#pipeline = null;
this.#commands.push({ kind: 'endPass', arg: '-' });
}
/** 结束录制并返回只读命令表。有未闭合的 pass 直接报错。 */
finish(): readonly RecordedCommand[] {
if (this.#finished) throw new CommandRecordError('encoder 已经 finish 过');
if (this.#passOpen) throw new CommandRecordError('finish 时仍有未关闭的渲染通道');
this.#finished = true;
return this.#commands;
}
#requirePass(op: string): void {
if (!this.#passOpen) throw new CommandRecordError(`${op} 必须发生在 beginRenderPass 与 endRenderPass 之间`);
}
}
可运行产物:把顺序约束断言出来
光有状态机不够,得证明它真能拦住错误。下面 command-recorder.test.ts 既录一条合法的「清屏 + 画三角形」,也逐条触发每一种非法顺序。
// file: src/render/rhi/command-recorder.test.ts
// tier: L1
// verify: npx vitest run src/render/rhi/command-recorder.test.ts
import { describe, expect, it } from 'vitest';
import { CommandListRecorder, CommandRecordError } from './command-recorder.ts';
/** 录一条完整的「清屏 + 画三角形」命令序列。 */
function recordTriangle(): readonly { kind: string; arg: string | number }[] {
const rec = new CommandListRecorder();
rec.beginRenderPass('main');
rec.setPipeline('triangle');
rec.setBindGroup(0, 'frame');
rec.draw(3);
rec.endRenderPass();
return rec.finish();
}
describe('CommandListRecorder', () => {
it('录制顺序与 WebGPU 调用序一致', () => {
expect(recordTriangle().map((c) => c.kind)).toEqual([
'beginPass', 'setPipeline', 'setBindGroup', 'draw', 'endPass',
]);
});
it('draw 之前必须先 setPipeline', () => {
const rec = new CommandListRecorder();
rec.beginRenderPass('main');
expect(() => rec.draw(3)).toThrow(/必须先 setPipeline/);
});
it('setPipeline / draw 必须在 pass 内', () => {
const rec = new CommandListRecorder();
expect(() => rec.setPipeline('x')).toThrow(CommandRecordError);
expect(() => rec.draw(3)).toThrow(/beginRenderPass/);
});
it('不能同时开两条渲染通道', () => {
const rec = new CommandListRecorder();
rec.beginRenderPass('a');
expect(() => rec.beginRenderPass('b')).toThrow(/未关闭/);
});
it('endRenderPass 之前必须有 beginRenderPass', () => {
const rec = new CommandListRecorder();
expect(() => rec.endRenderPass()).toThrow(/没有 beginRenderPass/);
});
it('finish 时有未闭合的 pass 会报错', () => {
const rec = new CommandListRecorder();
rec.beginRenderPass('main');
rec.setPipeline('p');
rec.draw(3);
expect(() => rec.finish()).toThrow(/未关闭的渲染通道/);
});
it('finish 之后不能再录', () => {
const rec = new CommandListRecorder();
rec.beginRenderPass('main');
rec.setPipeline('p');
rec.draw(3);
rec.endRenderPass();
rec.finish();
expect(() => rec.beginRenderPass('again')).toThrow(/已 finish/);
});
it('非法的 vertexCount 会被拒绝', () => {
const rec = new CommandListRecorder();
rec.beginRenderPass('main');
rec.setPipeline('p');
expect(() => rec.draw(0)).toThrow(/非法的 vertexCount/);
});
});
本机实跑:npx vitest run src/render/rhi/command-recorder.test.ts 全绿,这一节贡献 8 条断言,npx tsc --noEmit 零错误。核心那条「draw 之前必须先 setPipeline」,把一个只会在 GPU 侧炸的约束挪到了纯 CPU 的单元测试里。
在真实设备上录一条渲染通道
CPU 侧的顺序对了,下面把它翻译成真正的 WebGPU 调用。先看 shaders/triangle.wgsl:顶点阶段按 vertex_index 从内建数组里取三个位置,片元阶段直接返回绿色。
// file: shaders/triangle.wgsl
// tier: L2
@vertex
fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
var pos = array<vec2<f32>, 3>(
vec2<f32>(-0.8, -0.8),
vec2<f32>( 0.8, -0.8),
vec2<f32>( 0.0, 0.8),
);
return vec4<f32>(pos[i], 0.0, 1.0);
}
@fragment
fn fs() -> @location(0) vec4<f32> {
return vec4<f32>(0.0, 1.0, 0.0, 1.0);
}
src/render/rhi/webgpu-render-pass.ts 是 L2 模块:它开一条渲染通道,清屏为深蓝、画三角形,再用 copyTextureToBuffer 把颜色附件拷回可映射 buffer 读回像素。注意 copyTextureToBuffer 要求 bytesPerRow 是 256 的倍数,所以 64×64 的图也得按每行 256 字节对齐。
// file: src/render/rhi/webgpu-render-pass.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/rhi/webgpu-render-pass.ts
//
// helio 渲染后端:在真实 WebGPU 设备上录一条渲染通道。
// 清屏为深蓝、画一个绿色三角形,再把 color attachment 拷回可映射
// buffer 读回像素,用中心/角落两点的实际字节值验证录制是否生效。
export interface L2Check {
readonly name: string;
readonly ok: boolean;
readonly detail: string;
}
export interface L2Result {
readonly checks: L2Check[];
readonly data: Record<string, unknown>;
}
const TRIANGLE_WGSL = `
// file: shaders/triangle.wgsl
// tier: L2
@vertex
fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
var pos = array<vec2<f32>, 3>(
vec2<f32>(-0.8, -0.8),
vec2<f32>( 0.8, -0.8),
vec2<f32>( 0.0, 0.8),
);
return vec4<f32>(pos[i], 0.0, 1.0);
}
@fragment
fn fs() -> @location(0) vec4<f32> {
return vec4<f32>(0.0, 1.0, 0.0, 1.0);
}
`;
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 width = 64;
const height = 64;
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) throw new Error('requestAdapter() returned null');
const device = await adapter.requestDevice();
const format = navigator.gpu.getPreferredCanvasFormat();
const module = device.createShaderModule({ code: TRIANGLE_WGSL });
const info = await module.getCompilationInfo();
const wgslErrors = info.messages
.filter((m) => m.type === 'error')
.map((m) => `${m.lineNum}:${m.linePos} ${m.message}`);
push('wgsl 编译 0 error', wgslErrors.length === 0, wgslErrors.join('; ') || 'no diagnostics');
const pipeline = device.createRenderPipeline({
layout: 'auto',
vertex: { module, entryPoint: 'vs' },
fragment: { module, entryPoint: 'fs', targets: [{ format }] },
primitive: { topology: 'triangle-list' },
});
// copyTextureToBuffer 要求 bytesPerRow 是 256 的倍数。
const bytesPerRow = 256;
const texture = device.createTexture({
size: [width, height],
format,
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
});
const readback = device.createBuffer({
size: bytesPerRow * height,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: texture.createView(),
clearValue: { r: 0.1, g: 0.1, b: 0.2, a: 1 },
loadOp: 'clear',
storeOp: 'store',
},
],
});
pass.setPipeline(pipeline);
pass.draw(3);
pass.end();
encoder.copyTextureToBuffer({ texture }, { buffer: readback, bytesPerRow }, [width, height]);
device.queue.submit([encoder.finish()]);
await readback.mapAsync(GPUMapMode.READ);
const pixels = new Uint8Array(readback.getMappedRange().slice(0));
readback.unmap();
readback.destroy();
device.destroy();
const at = (x: number, y: number): number[] =>
Array.from(pixels.slice(y * bytesPerRow + x * 4, y * bytesPerRow + x * 4 + 4));
// bgra8unorm 的显存字节序是 B,G,R,A;读回后按格式还原成逻辑 RGBA 再断言。
const toRgba = (px: number[]): number[] =>
format === 'bgra8unorm' ? [px[2], px[1], px[0], px[3]] : px;
const center = toRgba(at(width / 2, height / 2));
const corner = toRgba(at(2, 2));
data.format = format;
data.rawCornerBytes = at(2, 2);
data.center = center;
data.corner = corner;
push('中心像素=三角形绿色 [0,255,0,255]',
center[0] === 0 && center[1] === 255 && center[2] === 0 && center[3] === 255,
JSON.stringify(center));
// 清屏色 (0.1,0.1,0.2) 的逻辑 RGBA 8bit 期望:R/G=26、B=51(容差 ±1 覆盖舍入差异)。
push('角落像素=清屏色 (R/G≈26,B≈51)',
Math.abs(corner[0] - 26) <= 1 && Math.abs(corner[1] - 26) <= 1 &&
Math.abs(corner[2] - 51) <= 1 && corner[3] === 255,
JSON.stringify(corner));
return { checks, data };
}
本机实跑(headless Chromium 经本地 http 服务):wgsl 编译 0 error、中心像素 [0,255,0,255]、角落逻辑 RGBA [26,26,51,255]。设备首选格式是 bgra8unorm,所以角落的原始字节其实是 [51,26,26,255]——先按 B,G,R,A 解释、再还原成 RGBA,才不会把红蓝读反。这一条正是「回读断言」最容易翻车的地方。
小结
- 命令模型是「录制模式」:
CommandEncoder产命令缓冲,RenderPassEncoder圈出一段渲染区间,合法骨架只有一种beginPass → setPipeline →(setBindGroup)→ draw → end → finish,任何越界都该在 CPU 侧被拦。 - 真机回读要按像素格式解码字节序:
bgra8unorm的原始字节是 B,G,R,A。
阅读导航:上一节:回到目录 · 下一节:3.2 队列提交、帧同步与 fence 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。