从导师那里听说了 WebGPU,做了点调查发现这个 API 还挺新的,正好之前只零零碎碎学了一点 OpenGL,相比于不适合人类学的 Vulkan,还是 WebGPU 显得友好一点。
🚩 0x00 To begin with
这篇文章将会包含以下内容:
- 什么是 WebGPU
- 配置 WebGPU 开发环境
- 第一个 WebGPU 项目:HelloTriangle!
For reference👇:
- 🍪 WebGPU Samples
- 📖 WebGPU API Spec
- 📖 WebGPU Shading Language
- 📺 WebGPU小白入门(一):如何零基础创建第一个WebGPU项目!
- 📖 您的第一个 WebGPU 应用
- 📖 lil-gui 0.20.0
0x01 认识 WebGPU
WebGPU 是一个由 W3C GPU 工作组制定的新的现代图形 API,用于在 Web 应用中访问 GPU 功能。
在 WebGPU 出现之前,Web 端的图形编程应用主要采用 WebGL 和 WebGL 2,虽然它们有着跨平台能力和强大的社区支持,但架构过于古老,最新的 WebGL 2 只是封装了 2009 年 OpenGL 3.2 的实现。Khronos 也指出 WebGL 后续更新中不再引入现代 GPU 的新特性。

近 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 并非直接操纵硬件,而是运行在浏览器内部的一个抽象层。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,绘制顶点,提交队列

个人感觉,其实也可以把这三个过程合并起来当作一个初始化过程,毕竟在 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。

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 后,就可以录制渲染命令了,具体分为三个阶段:
- 设置管线:设置该 Pass 使用的管线、渲染状态
- 绑定资源:设置顶点缓冲、索引缓冲和 BindingGroup
- 执行绘制:调用
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 也会更轻松些吧……