《游戏引擎实现原理》3.1 命令编码器与渲染通道

命令编码器把一帧的 GPU 工作录成命令缓冲:beginRenderPass 开通道、setPipeline 绑管线、draw 提交绘制、end 收尾。本节先用纯 TypeScript 把这套顺序约束做成可断言的录制器状态机,再在真实 WebGPU 设备上录一条渲染通道,清屏后画三角形并把像素回读出来验证。

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 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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