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 group | createBindGroup + 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 后端实现与能力降级 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。