WebGPU 入门:适配器、设备与渲染管线全流程
原文标题:WebGPU Fundamentals
原文作者:webgpufundamentals Contributors
原始链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-fundamentals.html
写在前面
本文试图带你掌握 WebGPU 最核心的基础知识。在开始之前,请确保你已熟悉 JavaScript。文章中会大量使用数组映射、解构赋值、扩展运算符、async/await、ES6 模块等概念。如果你还不了解 JavaScript,可以先参考 JavaScript.info、Eloquent JavaScript 或 CodeCademy。
如果你已经熟悉 WebGL,可以点击这里快速对照迁移。
WebGPU 本质上只做两件事:
仅此而已。剩下的所有内容都需要你自己灵活组织。WebGPU 是一个非常底层的 API,虽然可以写一些小示例,但对于大多数应用而言,你需要编写大量代码并认真组织数据。例如,支持 WebGPU 的 three.js 基础库就有约 550 KB 压缩后的 JavaScript 代码,这还不包括加载器、控制器、后处理等功能。TensorFlow 的 WebGPU 后端同样也是约 600 KB。因此,如果你只是想快速在屏幕上显示内容,选择一个成熟库会更高效。但如果你有特殊需求、想修改现有库、或者单纯想了解底层原理,那么请继续阅读。
起步
WebGPU 在某一层面上非常简单:它只在 GPU 上运行三种类型的函数——顶点着色器(Vertex Shaders)、片元着色器(Fragment Shaders) 和 计算着色器(Compute Shaders)。
- 顶点着色器计算顶点位置。每 3 个顶点组成一个三角形,由 GPU 光栅化。
- 片元着色器计算颜色(或其他数据)。当一个三角形被绘制时,GPU 会对每个需要绘制的像素调用片元着色器,返回颜色。
- 计算着色器更通用,它就是一个你指定执行次数的函数。GPU 每次调用时传入迭代编号,你可以利用这个编号做不同的事情。
可以近似的把这些函数理解成 JavaScript 中的 array.forEach 或 array.map 的回调函数。区别在于它们运行在 GPU 上,所以你需要把所有要访问的数据以缓冲区(buffers)和纹理(textures)的形式拷贝到 GPU,并且在着色器中显式指定数据绑定位置。之后从 JavaScript 端把缓冲区或纹理绑定到对应位置,最后告诉 GPU 执行函数。
下面是一个简化版的 WebGPU 绘制三角形流程示意图:
需要注意几个关键点:
- 管线(Pipeline) 包含 GPU 要运行的顶点着色器和片元着色器。计算着色器也可以有自己的管线。
- 着色器通过 绑定组(Bind Groups) 间接引用资源(缓冲区、纹理、采样器)。
- 管线通过内部状态定义属性,这些属性间接引用缓冲区。
- 属性从缓冲区中提取数据并传递给顶点着色器。
- 顶点着色器可以将数据传递给片元着色器。
- 片元着色器通过渲染通道描述(render pass description)间接写入纹理。
要执行着色器,你需要创建所有这些资源并设置状态。资源创建相对直接,但大部分 WebGPU 资源创建后无法改变其大小、用途、格式等。你只能修改其内容,若要改变那些元数据,只能新建资源并销毁旧的。
另外,命令的执行依赖命令缓冲区(Command Buffers)。你需要创建命令编码器(Command Encoder),编码命令,然后 finish 得到命令缓冲区,再 submit 提交给队列执行。
下面是一段伪代码,展示了编码命令缓冲区的过程:
encoder = device.createCommandEncoder()
// 绘制一些东西
{
pass = encoder.beginRenderPass(...)
pass.setPipeline(...)
pass.setVertexBuffer(0, …)
pass.setVertexBuffer(1, …)
pass.setIndexBuffer(...)
pass.setBindGroup(0, …)
pass.setBindGroup(1, …)
pass.draw(...)
pass.end()
}
// 再绘制一些东西
{
pass = encoder.beginRenderPass(...)
pass.setPipeline(...)
pass.setVertexBuffer(0, …)
pass.setBindGroup(0, …)
pass.draw(...)
pass.end()
}
// 执行计算
{
pass = encoder.beginComputePass(...)
pass.setBindGroup(0, …)
pass.setPipeline(...)
pass.dispatchWorkgroups(...)
pass.end()
}
commandBuffer = encoder.finish();
创建完成后,提交执行:
device.queue.submit([commandBuffer]);
接下来我们分别看两个小例子:绘制三角形和执行计算。
绘制三角形到纹理
WebGPU 可以将三角形绘制到纹理上。本文中,纹理就是一个二维像素矩形。网页中的 <canvas> 元素就代表一个纹理。我们可以向 Canvas 请求纹理,然后渲染到该纹理上。
要绘制三角形,需要提供两个着色器:顶点着色器和片元着色器。下面是一个最小 WebGPU 程序。
HTML 结构
<canvas></canvas>
<script type="module">
// ... JavaScript 代码 ...
</script>
获取设备和上下文
WebGPU 是异步 API,所以我们使用 async 函数:
async function main() {
const adapter = await navigator.gpu?.requestAdapter();
const device = await adapter?.requestDevice();
if (!device) {
fail('need a browser that supports WebGPU');
return;
}
// 获取 Canvas 的 WebGPU 上下文并配置
const canvas = document.querySelector('canvas');
const context = canvas.getContext('webgpu');
const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device,
format: presentationFormat,
});
// ... 后续代码
}
main();
requestAdapter 返回一个适配器(代表特定 GPU),再从适配器请求设备(device)。设备未获取到时通常说明浏览器过旧。getPreferredCanvasFormat 返回系统首选的画布格式("rgba8unorm" 或 "bgra8unorm"),将它传入 configure 可以提升性能。
创建着色器模块
着色器使用 WGSL(WebGPU Shading Language,发音近似 “wig-sil”)编写。下面是硬编码红色三角形的着色器模块:
const module = device.createShaderModule({
label: 'our hardcoded red triangle shaders',
code: /* wgsl */ `
@vertex fn vs(
@builtin(vertex_index) vertexIndex : u32
) -> @builtin(position) vec4f {
let pos = array(
vec2f( 0.0, 0.5), // 顶部中心
vec2f(-0.5, -0.5), // 左下
vec2f( 0.5, -0.5) // 右下
);
return vec4f(pos[vertexIndex], 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4f {
return vec4f(1.0, 0.0, 0.0, 1.0);
}
`,
});
顶点着色器 vs 使用内建变量 vertex_index 作为迭代编号,从硬编码数组中取出三个二维坐标,并扩展为 vec4f(设置 z=0,w=1)返回。位置必须在**裁剪空间(clip space)**中,X 从左 -1 到右 +1,Y 从底部 -1 到顶部 +1,与目标纹理大小无关。
片元着色器 fs 返回红色 vec4f(1.0, 0.0, 0.0, 1.0)。GPU 光栅化三角形时,会对每个像素调用片元着色器,返回颜色。
注意:本文中所有存储 WGSL 代码的字符串前都有
/* wgsl */注释,这是为了方便编辑器提供语法高亮和智能提示。
创建渲染管线
const pipeline = device.createRenderPipeline({
label: 'our hardcoded red triangle pipeline',
layout: 'auto',
vertex: {
module,
},
fragment: {
module,
targets: [{ format: presentationFormat }],
},
});
这里 layout: 'auto' 表示让 WebGPU 从着色器中推导布局。由于着色器模块中只有一个顶点函数和一个片元函数,可以省略 entryPoint。targets 数组的第 0 个元素对应片元着色器返回值中 @location(0) 指定的渲染目标,格式必须与画布格式一致。
准备渲染通道描述
const renderPassDescriptor = {
label: 'our basic canvas renderPass',
colorAttachments: [
{
// view: <- 渲染时再填充
clearValue: [0.3, 0.3, 0.3, 1],
loadOp: 'clear',
storeOp: 'store',
},
],
};
loadOp: 'clear' 表示绘制前将纹理清空为 clearValue 指定的颜色;storeOp: 'store' 表示保留绘制结果。另一种组合是 'load' 和 'discard',具体将在后续文章中讨论。
渲染函数
function render() {
// 获取当前画布纹理,并设置为渲染目标
renderPassDescriptor.colorAttachments[0].view =
context.getCurrentTexture().createView();
// 创建命令编码器
const encoder = device.createCommandEncoder({ label: 'our encoder' });
// 开始渲染通道
const pass = encoder.beginRenderPass(renderPassDescriptor);
pass.setPipeline(pipeline);
pass.draw(3); // 调用顶点着色器 3 次
pass.end();
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
}
render();
getCurrentTexture() 返回当前画布对应的纹理,createView() 创建该纹理的默认视图。draw(3) 会执行顶点着色器 3 次,每 3 次绘制一个三角形(默认 triangle-list 模式)。
当 draw 命令执行时,状态图如下:
最终显示结果:
需要强调:setPipeline、draw 等函数只是向命令缓冲区添加命令,实际执行发生在 device.queue.submit 之后。
WebGPU 会将顶点着色器返回的每 3 个顶点光栅化为一个三角形,判断哪些像素中心在三角形内部,然后为这些像素调用片元着色器。
在 GPU 上执行计算
我们再看一个基础计算示例。
获取设备
与前面相同:
async function main() {
const adapter = await navigator.gpu?.requestAdapter();
const device = await adapter?.requestDevice();
if (!device) {
fail('need a browser that supports WebGPU');
return;
}
// ...
}
创建计算着色器模块
const module = device.createShaderModule({
label: 'doubling compute module',
code: /* wgsl */ `
@group(0) @binding(0) `var<storage, read_write>` data: `array<f32>`;
@compute @workgroup_size(1) fn computeSomething(
@builtin(global_invocation_id) id: vec3u
) {
let i = id.x;
data[i] = data[i] * 2.0;
}
`,
});
这里声明了一个存储缓冲区变量 data,绑定在 @group(0) @binding(0)。@compute 表示计算着色器入口,@workgroup_size(1) 指定工作组大小为 1。global_invocation_id 是三维迭代编号,此处只用 x 分量。
可以近似的认为计算着色器按以下方式执行:
// 伪代码
function dispatchWorkgroups(width, height, depth) {
for (z = 0; z < depth; ++z) {
for (y = 0; y < height; ++y) {
for (x = 0; x < width; ++x) {
const workgroup_id = {x, y, z};
dispatchWorkgroup(workgroup_id)
}
}
}
}
function dispatchWorkgroup(workgroup_id) {
const workgroup_size = shaderCode.workgroup_size;
const {x: width, y: height, z: depth} = workgroup_size;
for (z = 0; z < depth; ++z) {
for (y = 0; y < height; ++y) {
for (x = 0; x < width; ++x) {
const local_invocation_id = {x, y, z};
const global_invocation_id =
workgroup_id * workgroup_size + local_invocation_id;
computeShader(global_invocation_id)
}
}
}
}
由于我们设置了 @workgroup_size(1),因此全局调用 ID 就等于工作组 ID。
创建计算管线
const pipeline = device.createComputePipeline({
label: 'doubling compute pipeline',
layout: 'auto',
compute: {
module,
},
});
准备数据缓冲区
const input = new Float32Array([1, 3, 5]);
// 创建 GPU 缓冲区,用于输入和输出
const workBuffer = device.createBuffer({
label: 'work buffer',
size: input.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
});
// 将输入数据拷贝到缓冲区
device.queue.writeBuffer(workBuffer, 0, input);
每个缓冲区必须指定 usage。这里指定了 STORAGE(用于着色器存储)、COPY_DST(允许拷贝数据到该缓冲区)、COPY_SRC(允许从该缓冲区拷贝数据)。
注意:JavaScript 无法直接读取 WebGPU 缓冲区的内容,必须通过“映射(map)”方式请求访问。但可映射的缓冲区不能用作 STORAGE,所以这里需要另一个缓冲区用于读取结果。
const resultBuffer = device.createBuffer({
label: 'result buffer',
size: input.byteLength,
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST
});
MAP_READ 表示该缓冲区可以被映射用于读取。
创建绑定组
const bindGroup = device.createBindGroup({
label: 'bindGroup for work buffer',
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: workBuffer },
],
});
pipeline.getBindGroupLayout(0) 对应着色器中的 @group(0),binding: 0 对应 @binding(0)。
编码计算命令
const encoder = device.createCommandEncoder({
label: 'doubling encoder',
});
const pass = encoder.beginComputePass({
label: 'doubling compute pass',
});
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.dispatchWorkgroups(input.length);
pass.end();
dispatchWorkgroups(3) 表示执行计算着色器 3 次。
拷贝结果并映射读取
encoder.copyBufferToBuffer(workBuffer, 0, resultBuffer, 0, resultBuffer.size);
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
// 读取结果
await resultBuffer.mapAsync(GPUMapMode.READ);
const result = new Float32Array(resultBuffer.getMappedRange());
console.log('input', input);
console.log('result', result);
resultBuffer.unmap();
执行后,控制台输出 [2, 6, 10],所有数字都翻倍了。
画布尺寸调整
在进入更复杂的主题之前,先为三角形示例添加基本的画布尺寸调整支持。
CSS 样式
<style>
html, body {
margin: 0;
height: 100%;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
</style>
仅靠 CSS 只会拉伸显示,不会改变 Canvas 的实际分辨率,导致边缘出现锯齿。我们需要使用 ResizeObserver 调整 Canvas 的宽高属性。
JavaScript 调整
// ...
render();
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
const canvas = entry.target;
const width = entry.contentBoxSize[0].inlineSize;
const height = entry.contentBoxSize[0].blockSize;
canvas.width = Math.max(1, Math.min(width, device.limits.maxTextureDimension2D));
canvas.height = Math.max(1, Math.min(height, device.limits.maxTextureDimension2D));
}
// 重新渲染
render();
});
observer.observe(canvas);
这里限制了画布尺寸不超过设备允许的最大纹理维度,同时避免为 0。ResizeObserver 会在开始观察时至少回调一次,所以可以移除原来单独的 render() 调用。
设备丢失处理
文中还提到了更健壮的设备获取方式,处理设备丢失(device.lost):
async function start() {
if (!navigator.gpu) {
fail('this browser does not support WebGPU');
return;
}
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
fail('this browser supports webgpu but it appears disabled');
return;
}
const device = await adapter.requestDevice();
device.lost.then((info) => {
console.error(`WebGPU device was lost: ${info.message}`);
// 如果是主动销毁,reason 会是 'destroyed'
if (info.reason !== 'destroyed') {
// 重试
start();
}
});
main(device);
}
start();
function main(device) {
// ... 使用 WebGPU ...
}
device.lost 是一个 Promise,在设备丢失时解析。设备丢失的原因很多,比如 GPU 崩溃、驱动更新、外接 GPU 拔出、浏览器回收后台标签页等。对于严肃应用,建议处理设备丢失。
核心优化要点提炼
从本文内容中,可以提炼以下 3 个核心优化要点(不仅限于性能优化,也包括开发效率优化):
-
为所有 WebGPU 对象设置
label
在开发阶段,为缓冲区、纹理、着色器模块、管线、绑定组等所有对象添加有意义的label。当发生错误时,WebGPU 实现会输出包含标签的错误信息,极大加速问题定位。例如"WGSL syntax error in shaderModule('our hardcoded red triangle shaders') at line 10"远比"shaderModule at line 10"有用。 -
合理设计缓冲区用途与映射策略
WebGPU 缓冲区一旦创建,其usage不可更改,且可映射的缓冲区不能用作STORAGE。因此需要根据数据流向来设计多个缓冲区:计算用的STORAGE缓冲区不能直接读取,需要配合COPY_DST | MAP_READ的结果缓冲区,通过copyBufferToBuffer将数据导出。这样做虽然多一步拷贝,但避免了频繁映射带来的性能开销,并符合 GPU 内存访问模式。 -
处理设备丢失并限制画布尺寸
对于长期运行的应用,必须监听device.lost,在设备意外丢失时恢复或重新初始化。这可以避免页面静默失效。同时,在调整画布尺寸时,应使用ResizeObserver并限制其大小不超过device.limits.maxTextureDimension2D,防止创建超大纹理导致 WebGPU 错误,保证应用稳定性。
GLSL/WebGL 专业术语检查
本文使用的是 WebGPU 和 WGSL(WebGPU Shading Language),并非 WebGL/GLSL。虽然两者都是 GPU API,但术语和概念有差异,读者需注意区分。
| 英文术语 | 中文翻译 | 说明 |
|---|---|---|
| Vertex Shader | 顶点着色器 | 对应 GLSL 中的 vertex shader |
| Fragment Shader | 片元着色器 | 对应 GLSL 中的 fragment shader,不称“像素着色器” |
| Compute Shader | 计算着色器 | WebGPU 新增,GLSL 中也有但 WebGL2 未完整支持 |
| Pipeline | 管线 | 渲染管线或计算管线,类似 WebGL 的程序对象但更完整 |
| Bind Group | 绑定组 | WebGPU 特有概念,用于管理资源绑定 |
| Render Pass | 渲染通道 | WebGPU 的渲染组织单元,类似 WebGL 的 draw call 序列 |
| Texture | 纹理 | 2D/3D 像素数据 |
| Buffer | 缓冲区 | 存放顶点、索引、uniform 等数据 |
| Storage Buffer | 存储缓冲区 | 可读写的缓冲区,类似 SSBO |
| Uniform Buffer | 统一缓冲区 | 只读的全局变量缓冲区,类似 UBO |
| Clip Space | 裁剪空间 | 顶点输出必须在裁剪空间,范围 -1 到 +1 |
| Workgroup | 工作组 | 计算着色器中的线程组概念 |
| Global Invocation ID | 全局调用 ID | 计算着色器中全局线程索引 |
| Rasterize | 光栅化 | 将几何体转换为像素 |
| Mipmap | 多级纹理 | 纹理细节层次 |
| Sampler | 采样器 | 定义纹理采样方式 |
注意:WebGPU 的着色器语言是 WGSL,语法与 GLSL 完全不同,但底层概念有相似之处。因此,学习 WebGPU 时应避免直接套用 WebGL/GLSL 的经验,特别是资源绑定模型和管线状态组织。
总结
本文介绍了 WebGPU 的最基础概念:它只做绘制三角形和运行计算两件事。我们通过两个小例子(红色三角形和数字翻倍)展示了如何创建着色器、管线、缓冲区、绑定组,并编码命令提交执行。还讨论了画布尺寸调整和设备丢失处理。后续文章将深入讲解如何向着色器传递数据、WGSL 语言基础、3D 数学、光照、计算着色器等。
希望本文能让你对 WebGPU 的工作方式有一个整体认识,并为进一步学习打下基础。
