WebGPU 入门:适配器、设备与渲染管线全流程

原文标题:WebGPU Fundamentals
原文作者:webgpufundamentals Contributors
原始链接:https://webgpufundamentals.org/webgpu/lessons/webgpu-fundamentals.html


Getting Started

写在前面

本文试图带你掌握 WebGPU 最核心的基础知识。在开始之前,请确保你已熟悉 JavaScript。文章中会大量使用数组映射、解构赋值、扩展运算符、async/await、ES6 模块等概念。如果你还不了解 JavaScript,可以先参考 JavaScript.info、Eloquent JavaScript 或 CodeCademy。

如果你已经熟悉 WebGL,可以点击这里快速对照迁移。

WebGPU 本质上只做两件事:

  1. 将三角形、点、线绘制到纹理上
  2. 在 GPU 上执行计算

仅此而已。剩下的所有内容都需要你自己灵活组织。WebGPU 是一个非常底层的 API,虽然可以写一些小示例,但对于大多数应用而言,你需要编写大量代码并认真组织数据。例如,支持 WebGPU 的 three.js 基础库就有约 550 KB 压缩后的 JavaScript 代码,这还不包括加载器、控制器、后处理等功能。TensorFlow 的 WebGPU 后端同样也是约 600 KB。因此,如果你只是想快速在屏幕上显示内容,选择一个成熟库会更高效。但如果你有特殊需求、想修改现有库、或者单纯想了解底层原理,那么请继续阅读。


Getting Started

起步

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]);

接下来我们分别看两个小例子:绘制三角形和执行计算。


Drawing triangles to textures

绘制三角形到纹理

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 个顶点光栅化为一个三角形,判断哪些像素中心在三角形内部,然后为这些像素调用片元着色器。


Drawing triangles to textures

在 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],所有数字都翻倍了。


Run computations on the GPU

画布尺寸调整

在进入更复杂的主题之前,先为三角形示例添加基本的画布尺寸调整支持。

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 个核心优化要点(不仅限于性能优化,也包括开发效率优化):

  1. 为所有 WebGPU 对象设置 label
    在开发阶段,为缓冲区、纹理、着色器模块、管线、绑定组等所有对象添加有意义的 label。当发生错误时,WebGPU 实现会输出包含标签的错误信息,极大加速问题定位。例如 "WGSL syntax error in shaderModule('our hardcoded red triangle shaders') at line 10" 远比 "shaderModule at line 10" 有用。

  2. 合理设计缓冲区用途与映射策略
    WebGPU 缓冲区一旦创建,其 usage 不可更改,且可映射的缓冲区不能用作 STORAGE。因此需要根据数据流向来设计多个缓冲区:计算用的 STORAGE 缓冲区不能直接读取,需要配合 COPY_DST | MAP_READ 的结果缓冲区,通过 copyBufferToBuffer 将数据导出。这样做虽然多一步拷贝,但避免了频繁映射带来的性能开销,并符合 GPU 内存访问模式。

  3. 处理设备丢失并限制画布尺寸
    对于长期运行的应用,必须监听 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 的工作方式有一个整体认识,并为进一步学习打下基础。