从导师那里听说了 WebGPU,做了点调查发现这个 API 还挺新的,正好之前只零零碎碎学了一点 OpenGL,相比于不适合人类学的 Vulkan,还是 WebGPU 显得友好一点。


🚩 0x00 To begin with

这篇文章将会包含以下内容:

  • 什么是 WebGPU
  • 配置 WebGPU 开发环境
  • 第一个 WebGPU 项目:HelloTriangle!

For reference👇:


0x01 认识 WebGPU

WebGPU 是一个由 W3C GPU 工作组制定的新的现代图形 API,用于在 Web 应用中访问 GPU 功能。

在 WebGPU 出现之前,Web 端的图形编程应用主要采用 WebGL 和 WebGL 2,虽然它们有着跨平台能力和强大的社区支持,但架构过于古老,最新的 WebGL 2 只是封装了 2009 年 OpenGL 3.2 的实现。Khronos 也指出 WebGL 后续更新中不再引入现代 GPU 的新特性。

WebGL 发展历史,Orillusion

近 10 年来,GPU 取得了飞速发展,也同时带来了与其交互的现代图形 API 的发展,例如 Metal、D3D 和 Vulkan。WebGPU 继承了这些发展,并将其带到了 Web 平台,可以在浏览器中高效运行 GPU 的通用计算能力。

作为 WebGL 的继任者,WebGPU 不只是一个能画图 API,还具备强大的并行计算能力,这使得 WebGPU 可以利用 ComputeShader 承担更广泛的计算任务,例如粒子模拟、双调排序等。


0x02 创建 WebGPU 项目

创建基本 Vite 项目

首先,确保电脑上有如下配置:

  • Node.js
  • 支持 WebGPU 的浏览器,如 Chrome 和 Edge(版本 113 以上)
  • 代码编辑器,如 VSCode

进入项目根目录所在文件夹,在终端运行:

1npm create vite@latest webgpu-render-lab -- --template vanilla-ts

这里的 webgpu-render-lab 是项目名,可任取

按照指示选择开发模板和变体,这里选择 Vanilla 和 TypeScript,即原生 HTML + TypeScript 的模板。

然后按照下方提示依次运行:

1cd webgpu-render-lab
2npm install
3npm run dev

在浏览器访问对应的端口即可看到初始的模板网页。

目前的项目结构如下:

1my-webgpu-app/
2├── node_modules        # dependencies
3├── public              # static assets
4├── index.html          # initial html
5├── src/
6│   └── main.ts         # main TypeScript file
7├── tsconfig.json       # TypeScript configure
8├── package.json        # Node.js configure
9└── ...others

配置 WebGPU 项目

项目目录下,终端运行:

1npm install -D @webgpu/types

安装 WebGPU 的 Ts 类型定义文件,然后在 tsconfig.json 的 compilerOptions 中增加一条:

1// tsconfig.json
2"compilerOptions": {
3	 ...
4	"types": ["vite/client","@webgpu/types"]
5}

以启用 Vite 和 WebGPU 类型的智能提示和类型检查。

tsconfig.json 中还有一个字段 include,用于告诉 TypeScript 编译器只处理/类型检查所列出路径下的 Ts 文件,以提升速度。

1// tsconfig.json
2	"include": ["src"]

所以如果有 Ts 文件路径不在 src 下,需要在这里包含其所属路径。


0x03 HelloTriangle

要画世界,先画三角形!

WebGPU 渲染流程

在正式写程序之前,简单介绍一下 WebGPU 的架构和渲染流程。

WebGPU 的架构(Chrome),Orillusion

实际上,WebGPU 并非直接操纵硬件,而是运行在浏览器内部的一个抽象层。Web 应用的一个页面处于一个独立的渲染进程中,由 JS 的 API 负责与浏览器进行沟通,后者通过进程间通讯(IPC)将前者传递给 Native Module,再由 Native Module 根据操作系统映射到不同的原生图形 API(Metal、D3D、Vulkan),完成最终的渲染和计算任务。

Native Module(原生模组):指由本地系统代码(如 C/C++、Rust)编写、经过编译后供高级语言(如 JavaScript、Python)调用的模块,直接与操作系统或底层硬件交互。Chrome 中的 Dawn 就是一个 Native Module

WebGPU 的工作流程大致可以分成:初始化、配置管线和录制命令队列三个阶段:

  • 初始化:获取可供 JS 操作的 GPU 逻辑实例,配置上下文
  • 配置管线:创建管线,创建 Shader 模块,创建 GPU 变量(Buffers、BindGroups)
  • 录制命令队列:开始 Pass,绘制顶点,提交队列

WebGPU 工作流,Orillusion

个人感觉,其实也可以把这三个过程合并起来当作一个初始化过程,毕竟在 TS 里写的只是整个渲染逻辑,真正执行绘制行为是由 Native 进程完成的。

初始化 WebGPU

因为 WebGPU 将渲染结果绘制到 GPUCanvasContext 上,而这个 context 是通过 HTML 的 <canvas> 获取的,所以确保网页的 HTML 文件中至少有一个 canvas 元素,作为 GPU 的渲染目标。

1<canvas></canvas>

首先检查一下目前的浏览器是否支持 WebGPU,可以用 navigator.gpu 判断,它是 WebGPU 提供给 JS 的入口对象,如果浏览器不支持 WebGPU,则其为 undefined,需要更换合适的浏览器或者开启某个 flag。

1  if (!navigator.gpu) {
2    throw new Error("WebGPU is not supported in this browser.");
3  }
4  else{
5    console.log("Hello, WebGPU!");
6  }

获得了 WebGPU 支持后,则可以调用 navigator.gpu 的 requestAdapter 获得一个 GPU 的逻辑适配器(adapter),再由 adapter 调用 requestDevice 获得一个实际使用 JS 进行交互的 GPU 设备(device)。

1const adapter = await navigator.gpu.requestAdapter();
2const device = await gpuAdapter.requestDevice();

adapter 和 device:GPUAdapter 封装了一个适配器,并描述其特性(features)、限制(limits)及适配器信息(Info),是浏览器对 GPU 的抽象,可以用它选择多个 GPU(集显,独显);GPUDevice 封装了一个设备,并暴露该设备的功能(只读),代表与 GPU 的逻辑连接,可以用 device 调用 WebGPU 接口。

正如开头所说,GPUCanvasContext 是通过 HTMLCanvasElement 实例调用 getContext 方法创建的,对于 WebGPU 创建一个 WebGPU 专用的画布上下文,传递 webgpu 作为其 contextType 参数。

1const canvas = document.querySelector('canvas') as HTMLCanvasElement;
2const context = canvas.getContext('webgpu');

canvas 和 context:类似于 adapter 和 device,canvas 只是一个 HTML 的 DOM 元素,需要通过 getContext 创建一个 WebGPU 可以控制的逻辑画布,在 context 上进行绘制。创建 context 之后,可以通过 configure 方法配置画布上下文,至少包括 device 和 format(GPUTextureFormat),后者一般为 bgra8unorm。

requestAdapter 和 requestDevice 都是异步方法,返回一个 Promise,需要使用 await 获取实际返回对象,而 await 必须在 async 函数内部使用,所以可以在外面包裹一个 async function:

 1async function initWebGPU() {
 2  // check webgpu support ...
 3  // request GPU adapter
 4  const adapter = await navigator.gpu.requestAdapter();
 5  if (!adapter) {
 6    throw new Error("Cannot get GPU adapter.");
 7  }
 8
 9  // request a logical device
10  const device = await adapter.requestDevice();
11  
12  // get canvas and configure webgpu context
13  const canvas = document.querySelector('canvas') as HTMLCanvasElement;
14  const context = canvas.getContext('webgpu')!;
15  const presentationFormat = navigator.gpu.getPreferredCanvasFormat();
16  
17  context.configure({
18    device,
19    format: presentationFormat,
20    alphaMode: 'premultiplied',
21  });
22  // other code ...
23}

至此,WebGPU 初始化完毕。

配置管线

WebGPU 一共有两种管线:GPUComputePipeline(计算管线) 和 GPURenderPipeline(渲染管线),无论哪种管线都规定了 GPU 的任务流程,包括从 bindings 或者 buffers 获取输入,并产生输出(例如渲染纹理)的过程。

从结构上看,管线由一系列可编程阶段(shaders)和固定功能状态(混合模式)组成,一个渲染管线一般包括顶点着色器(vertex shader)、片元着色器(fragment shader)等,计算管线则包括计算着色器(compute shader)。

对于一个渲染管线,可以通过 createRenderPipeline 方法立即创建并返回一个对象,也可通过 createRenderPipelineAsync 方法异步创建。createRenderPipeline 方法接受一个字典对象 GPURenderPipelineDescriptor,包含如下成员:

  • vertex(GPUVertexState):描述管线顶点着色器入口及其输入缓冲区布局
  • primitive(GPUPrimitiveState):描述管线中原始图元相关属性
  • depthStencil(GPUDepthStencilState):可选的深度模板属性
  • multisample(GPUMultisampleState):多重采样属性
  • fragment(GPUFragmentState):描述管线片元着色器入口及其输出颜色

对于此次绘制三角形的任务,我们必须要提供的是 vertex,primitive 和 fragment。其中 vertex 和 fragment 都继承 GPUProgrammableStage,后者描述用户提供的 GPUShaderModule 中的入口点,即着色器的入口函数。

顶点着色器接受来自顶点缓冲区的输入数据,所以需要先定义需要传输到 GPU 的顶点数据,然后通过 GPUBuffer 描述如何解析这些数据。例如,对于三角形的每个顶点,定义其由位置和颜色两个属性组成:

1// data preparation
2const triangleData = new Float32Array([
3// position      color
40.0, 0.5, 0.0,   1.0, 0.0, 0.0, 1.0,
5-0.5, -0.5, 0.0,  0.0, 1.0, 0.0, 1.0,
60.5, -0.5, 0.0,   0.0, 0.0, 1.0, 1.0,
7]);

然后可以通过 createBuffer 方法创建一个 GPUbuffer,该方法接受一个 GPUBufferDescriptor 类型的字典对象,至少需要提供两个参数:

  • size:缓冲区以字节为单位的大小
  • usage:缓冲区允许的使用方式(例如 Vertex,Uniform,Storage)

可以通过上述定义的顶点数组的字节长度获取 size;为了能够拷贝给 GPU,并传递给 VertexBuffer,在 usage 中需要同时启用 VERTEX 和 COPY_DST。

GPUBufferUsage,WebGPU Spec

1// create and write gpu buffer
2const vertexBuffer = device.createBuffer({
3size: triangleData.byteLength,
4usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
5});
6
7device.queue.writeBuffer(vertexBuffer, 0, triangleData);

定义好了顶点着色器的输入,就可以开始编写 shader 了。正如 OpenGL 有 GLSL,D3D 有 HLSL,WebGPU 的开发组也为其带来了一套新的着色器语言——WGSL。绘制一个三角形所需的最简单的 shader 如下:

 1// shader code
 2const shaderCode = `
 3struct VertexOutput {
 4  @builtin(position) position: vec4<f32>,
 5  @location(0) color: vec4<f32>,
 6};
 7
 8@vertex
 9fn vs_main(
10  @location(0) pos: vec3<f32>,
11  @location(1) color: vec4<f32>
12) -> VertexOutput {
13  var output: VertexOutput;
14  output.position = vec4<f32>(pos, 1.0);
15  output.color = color;
16  return output;
17}
18
19@fragment
20fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
21  return input.color;
22}
23`;
24
25// create shader module
26const shaderModule = device.createShaderModule({
27code: shaderCode,
28});

shader 也可以写在 .wgsl 文件中,通过 import 导入

至此,可以调用 createRenderPipeline 方法,依次填入 GPURenderPipelineDescriptor 的各个字段:

  • layout:使用 ‘auto’,让管线自动推断资源绑定布局
  • vertex:shaderModule 及顶点着色器入口函数,顶点缓冲区布局和格式
  • fragment:shaderModule 及片元着色器入口函数,输出格式(必须与 context 的 format 匹配)
  • primitive:其中的 topology 规定图元类型,由于我们是画三角形所以使用 ’triangle-list’
 1  // create render pipeline
 2  const pipeline = device.createRenderPipeline({
 3    layout: 'auto',
 4    vertex: {
 5      module: shaderModule,
 6      entryPoint: 'vs_main',
 7      buffers: [
 8        {
 9          arrayStride: 7 * 4, // position: 3 + color: 4
10          attributes: [
11            { // position
12              shaderLocation: 0,
13              offset: 0,
14              format: 'float32x3',
15            },
16            { // color
17              shaderLocation: 1,
18              offset: 3 * 4,
19              format: 'float32x4',
20            },
21          ],
22        },
23      ],
24    },
25    fragment: {
26      module: shaderModule,
27      entryPoint: 'fs_main',
28      targets: [
29        {
30          format: presentationFormat,
31        },
32      ],
33    },
34    primitive: {
35      topology: 'triangle-list',
36    },
37  });

至此,管线配置结束。

录制渲染队列

熟悉 Unity 的同学可能会知道,当调用渲染命令时,GPU 并非立即执行,而是把一系列命令写入一个命令缓冲区(CommandBuffer),然后再将这个 CommandBuffer 注入到管线的特定阶段(例如不透明物体渲染后或者阴影渲染前)。当渲染循环执行到注入点时,才会依次执行缓冲区中的命令。

WebGPU 中的 CommandEncoder 正是对应了写入渲染缓冲区的录制操作,直到 submit 前的内容都是在构建命令列表:

1const commandEncoder = device.createCommandEncoder();
2// record command here
3// ...
4const commandBuffer = commandEncoder.finish();
5device.queue.submit([commandBuffer]);

如果说 GPUCommandEncoder 是一整个录制器,那么 GPURenderPassEncoder 就是其中的一个子录制器,录制一个 Pass 过程中的命令。简单来说,一个 Pass 就是从 GPU 输入数据到输出渲染目标的过程。一个 CommandEncoder 可以创建多个 Pass。

在 WebGPU 中创建一个 Pass 可以使用 beginRenderPass 方法,接受一个 GPURenderPassDescripter 字典对象,至少需要包含 colorAttachments 字段。colorAttachments 定义了当前 Pass 输出的所有颜色附件,每个 colorAttachment 是一个字典,必须提供以下三个字段:

  • view(GPUTextureView):描述输出此 colorAttachment 的纹理子资源
  • loadOp(GPULoadOp):执行 Pass 前对 view 的加载操作(一般为 clear)
  • storeOp(GPUStoreOp):执行 Pass 后对 view 的存储操作

创建 GPUPassEncoder 后,就可以录制渲染命令了,具体分为三个阶段:

  1. 设置管线:设置该 Pass 使用的管线、渲染状态
  2. 绑定资源:设置顶点缓冲、索引缓冲和 BindingGroup
  3. 执行绘制:调用 draw 方法绘制顶点

上述流程完成后,便可以通过 end 方法结束 Pass 的录制。对于本次的三角形,其渲染队列的录制过程如下:

 1function frame() {
 2    // cmdEncoder to record commands
 3    const commandEncoder = device.createCommandEncoder();
 4    
 5    // get current texture view
 6    const textureView = context.getCurrentTexture().createView();
 7
 8    // begin render pass
 9    const renderPassDescriptor: GPURenderPassDescriptor = {
10      colorAttachments: [
11        {
12          view: textureView,
13          clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 },
14          loadOp: 'clear',
15          storeOp: 'store',
16        },
17      ],
18    };
19
20    const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
21    
22    // set pipeline and vertex buffer
23    passEncoder.setPipeline(pipeline);
24    passEncoder.setVertexBuffer(0, vertexBuffer);
25    
26    // draw triangle: 3 vertices
27    passEncoder.draw(3); 
28    
29    // end render pass
30    passEncoder.end();
31    
32    // finish encode and submit 
33    device.queue.submit([commandEncoder.finish()]);
34
35    // request next frame
36    requestAnimationFrame(frame);
37  }
38
39  // start render loop
40  requestAnimationFrame(frame);

在终端运行 npm run dev,不出意外就能看到一个五彩斑斓的三角形:

CSS 自己写

GUI

目前我们参数是写死在 TS 中,更方便的方式是加入交互的 GUI,可以在浏览器中实时调整参数并查看结果。

OpenGL 的时候用的是 imgui,但不知为何下载不了其对 js 的封装,所以这里选择的是另一个轻量级的 lil-gui,在终端下载它的包:

1npm install lil-gui

然后在 TypeScript 文件中引入:

1import {GUI} from 'lil-gui'

初始化 GUI 并添加三个 color 参数控制三角形每个顶点的颜色:

 1const gui = new GUI();
 2
 3const params = {
 4colorA: [1, 0, 0, 1],
 5colorB: [0, 1, 0, 1],
 6colorC: [0, 0, 1, 1],
 7};
 8
 9gui.addColor(params, 'colorA');
10gui.addColor(params, 'colorB');
11gui.addColor(params, 'colorC');

创建一个 GPUBuffer,将其设置为 UNIFORM,并绑定到 bindGroup:

 1const colorBuffer = device.createBuffer({
 2  size: 4 * 3 * 4, // 3 colors, each with 4 floats
 3  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
 4})
 5
 6const bindGroup = device.createBindGroup({
 7  layout: pipeline.getBindGroupLayout(0),
 8  entries: [{
 9    binding: 0,
10    resource: {
11	  buffer: colorBuffer
12  }
13}],
14});

然后每帧更新这个 Buffer 并设置绑定组:

1function updateColors(){
2  device.queue.writeBuffer(colorBuffer, 0, new Float32Array([...params.colorA, ...params.colorB, ...params.colorC]));
3}
4
5function frame(){
6	// ...
7  updateColors();
8  passEncoder.setBindGroup(0, bindGroup);
9}

最后可以在 VertexShader 中解析这个 uniform:

 1struct ColorUniform {
 2  colorA: vec4<f32>,
 3  colorB: vec4<f32>,
 4  colorC: vec4<f32>,
 5};
 6
 7@group(0) @binding(0) var<uniform> uColors: ColorUniform;
 8
 9fn getColorByIndex(index: u32) -> vec4<f32> {
10  if (index == 0u) {
11	return uColors.colorA;
12  } else if (index == 1u) {
13	return uColors.colorB;
14  } else {
15	return uColors.colorC;
16  }
17}
18
19@vertex
20fn vs_main(
21  @builtin(vertex_index) vertexindex: u32,
22  @location(0) pos: vec3<f32>,
23  @location(1) color: vec4<f32>
24) -> VertexOutput {
25  var output: VertexOutput;
26  output.position = vec4<f32>(pos, 1.0);
27  output.color = getColorByIndex(vertexindex);
28  return output;
29}

回到浏览器,调整颜色,就可以看到变化了:


0x0F After words

好了,你已经学会如何使用 WebGPU 绘制三角形了,可以尝试挑战一下制作一个 3DGS 的实时渲染预览器了!

好吧不开玩笑了,WebGPU 还有极大部分内容是本文没有涵盖的,如果读者有更进一步的需求,文章开头也列出了一些更详细的文档和教程可供参考。如果有一些 OpenGL 或者 WebGL 基础,学 WebGPU 也会更轻松些吧……