《游戏引擎实现原理》5.1 WebGPU 后端实现

helio 的 RHI 抽象在 WebGPU 上的落地:把资源、管线、命令、绑定四组接口逐条翻译成 WebGPU 调用,用自增整数句柄隔离后端细节,并真跑一次创建资源、录制渲染通道、提交队列、回读像素的完整流程,断言中心像素与清屏角像素,作为后端正确性的最小证据。

5.1 WebGPU 后端实现

第 2–4 章把渲染硬件拆成了四组接口:资源(缓冲、纹理)、管线(着色器模块、管线状态)、命令(帧、渲染通道、绘制)、绑定(bind group)。这些接口只是契约,真正让画面出现的是它们在某个具体图形 API 上的实现。本节把契约落到 WebGPU 上,写成一个可运行的 WebGPURHI。

站内 graphics/ 专题已经把「渲染器该长什么样」讲透了,本节不重复那些算法与选型,只回答一个问题:把 RHI 的每个概念翻译成 WebGPU 调用,最短的实现是什么,跑出来对不对。

本章与站内既有内容的分工

「渲染后端」这个话题在站内已有覆盖,先划清边界:

既有文章它负责什么本节只做什么
/graphics-render-graph/帧图的原理与 Unreal RDG / Unity SRP 案例把 RHI 的命令接口翻译成 WebGPU 的编码器调用
/graphics-shader-compilation-pipeline/着色器从源码到 ISA 的编译流水线用 createShaderModule + getCompilationInfo 兜住 WGSL 错误
/graphics-gpu-profiling-debugging/GPU 性能剖析的方法论与工具只做后端正确性验收,不测性能

一句话:别人讲原理与工具,本节讲「在 helio 的 RHI 上把后端从零写出来、跑出真实像素」。

接口要翻译成的 WebGPU 概念

后端无关的接口长这样。它只出现句柄与描述符,不出现任何 GPU* 类型:

// snippet: IRhiBackend 的骨架(完整定义见 src/render/rhi/rhi-types.ts)
export interface IRhiBackend {
  readonly name: string;
  createShaderModule(code: string): RhiShaderModule;
  createBuffer(desc: BufferDesc): RhiBuffer;
  createTexture(desc: TextureDesc): RhiTexture;
  createRenderPipeline(desc: RenderPipelineDesc): RhiRenderPipeline;
  createBindGroup(entries: readonly BindGroupEntry[]): RhiBindGroup;
  writeBuffer(buffer: RhiBuffer, data: ArrayBufferView | ArrayBuffer, offset?: number): void;
  beginFrame(): void;
  beginRenderPass(attachment: ColorAttachment): void;
  draw(call: DrawCall): void;
  endRenderPass(): void;
  submit(): void;
  readTexture(texture: RhiTexture): Promise<Uint8Array>;
}

翻译到 WebGPU 时,每个概念都有一一对应的落点:

RHI 概念WebGPU 对应
设备GPUDevice(由 requestAdapter 拿到)
缓冲 / 纹理createBuffer / createTexture,用途是位标志
着色器模块createShaderModule,源码是 WGSL
渲染管线createRenderPipeline,layout: "auto"
bind groupcreateBindGroup + setBindGroup(0, ...)
渲染通道beginRenderPass + GPURenderPassEncoder
提交queue.submit
回读copyTextureToBuffer + mapAsync

有两个坑必须点出来。第一,RHI 的用途位标志是后端无关的,数值和 GPUBufferUsage 并不相同,不能直接透传,必须显式映射;第二,回读时 bytesPerRow 必须 256 字节对齐,否则 copyTextureToBuffer 直接报错。

WebGPURHI:完整实现

下面是完整的后端实现。句柄用后端自增的整数,资源存在 Map 里;layout: "auto" 的管线会推导出 bind group 布局,createBindGroup 复用它,避免布局不匹配的校验错误。

// file: src/render/rhi/webgpu-rhi.ts
// tier: L2
// verify: node tools/run-l2.mjs src/render/rhi/webgpu-rhi.ts
//
// 第 2–4 章定义的 IRhiBackend 在 WebGPU 上的具体实现。
// 每个 RHI 概念翻译成一条 WebGPU 调用;句柄是后端自增整数,
// 上层只看到不透明编号,换后端时上层代码一行不改。
import { RhiBufferUsage, RhiTextureUsage, bytesPerPixel } from './rhi-types.ts';
import type {
  BindGroupEntry, BufferDesc, ColorAttachment, DrawCall, IRhiBackend, RenderPipelineDesc,
  RhiBindGroup, RhiBuffer, RhiRenderPipeline, RhiShaderModule, RhiTexture, TextureDesc,
} from './rhi-types.ts';
import { TRIANGLE_WGSL, buildTriangleScene, renderTriangleScene } from './scene.ts';

interface TextureRecord { gpu: GPUTexture; width: number; height: number; format: string; }
const alignTo = (n: number, a: number): number => Math.ceil(n / a) * a;

// RHI 的用途位标志是后端无关的,数值和 GPUBufferUsage 并不相同,必须显式翻译。
const BUFFER_USAGE: readonly (readonly [number, number])[] = [
  [RhiBufferUsage.Vertex, GPUBufferUsage.VERTEX],
  [RhiBufferUsage.Index, GPUBufferUsage.INDEX],
  [RhiBufferUsage.Uniform, GPUBufferUsage.UNIFORM],
  [RhiBufferUsage.Storage, GPUBufferUsage.STORAGE],
  [RhiBufferUsage.CopySrc, GPUBufferUsage.COPY_SRC],
  [RhiBufferUsage.CopyDst, GPUBufferUsage.COPY_DST],
  [RhiBufferUsage.MapRead, GPUBufferUsage.MAP_READ],
];
const TEXTURE_USAGE: readonly (readonly [number, number])[] = [
  [RhiTextureUsage.Sampled, GPUTextureUsage.TEXTURE_BINDING],
  [RhiTextureUsage.RenderTarget, GPUTextureUsage.RENDER_ATTACHMENT],
  [RhiTextureUsage.CopySrc, GPUTextureUsage.COPY_SRC],
  [RhiTextureUsage.CopyDst, GPUTextureUsage.COPY_DST],
];
const mapUsage = (u: number, table: readonly (readonly [number, number])[]): number =>
  table.reduce((acc, [from, to]) => acc | ((u & from) !== 0 ? to : 0), 0);

export class WebGPURHI implements IRhiBackend {
  readonly name = 'WebGPU';
  #device: GPUDevice;
  #next = 1;
  #buffers = new Map<number, GPUBuffer>();
  #textures = new Map<number, TextureRecord>();
  #modules = new Map<number, GPUShaderModule>();
  #pipelines = new Map<number, GPURenderPipeline>();
  #bindGroups = new Map<number, GPUBindGroup>();
  #group0: GPUBindGroupLayout | null = null;
  #encoder: GPUCommandEncoder | null = null;
  #pass: GPURenderPassEncoder | null = null;

  constructor(device: GPUDevice) {
    this.#device = device;
  }

  /** 拿 adapter + device。必须在安全上下文里调用(本地 http 即可)。 */
  static async create(): Promise<WebGPURHI> {
    const adapter = await navigator.gpu.requestAdapter();
    if (!adapter) throw new Error('no WebGPU adapter(缺 --enable-unsafe-webgpu?)');
    return new WebGPURHI(await adapter.requestDevice());
  }

  /** 编译一段 WGSL,返回 error 级诊断。tsc 管不到模板字符串里的 WGSL,靠它兜住。 */
  async compileErrors(code: string): Promise<string[]> {
    const info = await this.#device.createShaderModule({ code }).getCompilationInfo();
    return info.messages.filter((m) => m.type === 'error').map((m) => `${m.lineNum}:${m.linePos} ${m.message}`);
  }

  createShaderModule(code: string): RhiShaderModule {
    const id = this.#next++;
    this.#modules.set(id, this.#device.createShaderModule({ code }));
    return id;
  }

  createBuffer(d: BufferDesc): RhiBuffer {
    const id = this.#next++;
    this.#buffers.set(id, this.#device.createBuffer({ label: d.label, size: d.size, usage: mapUsage(d.usage, BUFFER_USAGE) }));
    return id;
  }

  createTexture(d: TextureDesc): RhiTexture {
    const id = this.#next++;
    const gpu = this.#device.createTexture({ label: d.label, size: [d.width, d.height], format: d.format as GPUTextureFormat, usage: mapUsage(d.usage, TEXTURE_USAGE) });
    this.#textures.set(id, { gpu, width: d.width, height: d.height, format: d.format });
    return id;
  }

  createRenderPipeline(d: RenderPipelineDesc): RhiRenderPipeline {
    const id = this.#next++;
    const vm = this.#modules.get(d.vertexModule);
    const fm = this.#modules.get(d.fragmentModule);
    if (!vm || !fm) throw new Error('pipeline: 着色器模块句柄无效');
    const pipeline = this.#device.createRenderPipeline({
      label: d.label,
      layout: 'auto',
      vertex: {
        module: vm,
        entryPoint: d.vertexEntry,
        buffers: d.vertexBuffers.map((b) => ({
          arrayStride: b.arrayStride,
          attributes: b.attributes.map((a) => ({ shaderLocation: a.shaderLocation, offset: a.offset, format: a.format as GPUVertexFormat })),
        })),
      },
      fragment: { module: fm, entryPoint: d.fragmentEntry, targets: [{ format: d.colorFormat as GPUTextureFormat }] },
      primitive: { topology: d.topology ?? 'triangle-list' },
    });
    this.#pipelines.set(id, pipeline);
    this.#group0 = pipeline.getBindGroupLayout(0); // layout:'auto' 的 group 0 布局,绑定组复用它
    return id;
  }

  createBindGroup(entries: readonly BindGroupEntry[]): RhiBindGroup {
    if (!this.#group0) throw new Error('createBindGroup: 需先 createRenderPipeline');
    const id = this.#next++;
    const resources = entries.map((e) => {
      if (e.buffer !== undefined) return { binding: e.binding, resource: { buffer: this.#buffers.get(e.buffer)! } };
      const tex = e.texture !== undefined ? this.#textures.get(e.texture) : undefined;
      return { binding: e.binding, resource: tex!.gpu.createView() };
    });
    this.#bindGroups.set(id, this.#device.createBindGroup({ layout: this.#group0, entries: resources }));
    return id;
  }

  writeBuffer(buffer: RhiBuffer, data: ArrayBufferView | ArrayBuffer, offset = 0): void {
    this.#device.queue.writeBuffer(this.#buffers.get(buffer)!, offset, data);
  }

  beginFrame(): void {
    this.#encoder = this.#device.createCommandEncoder();
  }

  beginRenderPass(att: ColorAttachment): void {
    const tex = this.#textures.get(att.texture)!;
    const clearValue = att.clearValue ?? { r: 0, g: 0, b: 0, a: 1 };
    this.#pass = this.#encoder!.beginRenderPass({
      colorAttachments: [{ view: tex.gpu.createView(), clearValue, loadOp: att.loadOp, storeOp: 'store' }],
    });
  }

  draw(call: DrawCall): void {
    this.#pass!.setPipeline(this.#pipelines.get(call.pipeline)!);
    this.#pass!.setBindGroup(0, this.#bindGroups.get(call.bindGroup)!);
    this.#pass!.draw(call.vertexCount);
  }

  endRenderPass(): void {
    this.#pass!.end();
    this.#pass = null;
  }

  submit(): void {
    this.#device.queue.submit([this.#encoder!.finish()]);
    this.#encoder = null;
  }

  /** copyTextureToBuffer 回读;bytesPerRow 必须 256 对齐,这里去掉行尾 padding。 */
  async readTexture(texture: RhiTexture): Promise<Uint8Array> {
    const rec = this.#textures.get(texture)!;
    const rowBytes = rec.width * bytesPerPixel(rec.format as never);
    const bytesPerRow = alignTo(rowBytes, 256);
    const staging = this.#device.createBuffer({
      size: bytesPerRow * rec.height,
      usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
    });
    const enc = this.#device.createCommandEncoder();
    enc.copyTextureToBuffer({ texture: rec.gpu }, { buffer: staging, bytesPerRow }, { width: rec.width, height: rec.height });
    this.#device.queue.submit([enc.finish()]);
    await staging.mapAsync(GPUMapMode.READ);
    const mapped = new Uint8Array(staging.getMappedRange());
    const out = new Uint8Array(rowBytes * rec.height);
    for (let y = 0; y < rec.height; y++) out.set(mapped.subarray(y * bytesPerRow, y * bytesPerRow + rowBytes), y * rowBytes);
    staging.unmap();
    staging.destroy();
    return out;
  }
}

/** L2 验收:完整跑一次「创建 → 录制 → 提交 → 回读」,并断言像素。 */
export async function runL2(): Promise<{ checks: { ok: boolean; name: string; detail: string }[]; data: unknown }> {
  const W = 32;
  const H = 32;
  const rhi = await WebGPURHI.create();
  const scene = buildTriangleScene(rhi, W, H, [0, 1, 0, 1]);
  const pixels = await renderTriangleScene(rhi, scene);
  const at = (x: number, y: number): number[] => Array.from(pixels.subarray((y * W + x) * 4, (y * W + x) * 4 + 4));
  const near = (got: number[], want: number[], tol: number): boolean => got.every((v, i) => Math.abs(v - want[i]) <= tol);
  const center = at(16, 16);
  const corner = at(1, 1);
  const wgslErrors = await rhi.compileErrors(TRIANGLE_WGSL);
  return {
    checks: [
      { ok: wgslErrors.length === 0, name: 'wgsl.0error', detail: `errors=${wgslErrors.length}` },
      { ok: near(center, [0, 255, 0, 255], 2), name: 'center=green', detail: JSON.stringify(center) },
      { ok: near(corner, [26, 26, 51, 255], 2), name: 'corner=clear', detail: JSON.stringify(corner) },
      { ok: pixels.length === W * H * 4, name: 'readback.size', detail: `${pixels.length} bytes` },
    ],
    data: { backend: rhi.name, size: [W, H], center, corner },
  };
}

readTexture 是唯一带 async 的接口:它把纹理拷到一块 MAP_READ 缓冲,mapAsync 之后按 bytesPerRow 去掉行尾 padding,得到紧凑的 RGBA 字节。runL2 用同一套 IRhiBackend 建场景、录命令、提交、回读,并断言像素。

可运行产物:创建 → 录制 → 提交 → 回读

L2 验收必须跑在真实浏览器里。运行器 tools/run-l2.mjs 用 rolldown 把入口 .ts 打成 IIFE,经一个带 COOP/COEP 头的本地 http 服务(http://127.0.0.1:<port>/)喂给 headless Chromium,再调用导出的 runL2()。之所以不能停在 about:blank:那是 opaque origin,navigator.gpu 恒为 undefined。

$ node tools/run-l2.mjs src/render/rhi/webgpu-rhi.ts
PASS  wgsl.0error  errors=0
PASS  center=green  [0,255,0,255]
PASS  corner=clear  [26,26,51,255]
PASS  readback.size  4096 bytes
=== run-l2 src/render/rhi/webgpu-rhi.ts: ALL PASS ===

wgsl.0error 说明 WGSL 过了 WebGPU 自己的编译器(tsc 管不到模板字符串里的 WGSL);center 是三角形覆盖的绿色,corner 是清屏色 (0.1, 0.1, 0.2) 量化到 8 位的结果。两者都是逐字节可复现的:本机实测硬件后端与 SwiftShader 结果一致。

阅读导航:上一节:回到目录 · 下一节:5.2 WebGL2 后端实现与能力降级 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「engine」更多文章

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