[译]原始WebGPU - Raw WebGPU
作者: Alain Galvan
一份如何编写 WebGPU 应用程序的概述, 学习 WebGPU 在绘制中所需的关键数据结构和类型。
WebGPU 是一种用于 Web 的新的图形 API,它遵循现代计算图形 API (例如 Mircrosoft DirectX 12、 Vulkan 和 Apple Metal) 的框架。 Web 图形 API 的这种范式转变使用户能够享受与本机原生图形 API 相同的好处: 更快的应用程序归功于充分利用 GPU,更少的特定图形驱动程序错误,基于未来实现新功能的潜力。
WebGPU 可能是 Web 上所有渲染 API 中最复杂的,尽管这种复杂性可以通过 API 提供的性能提示和未来支持的保证来抵消。本文旨在揭秘该 API,使其更容易理解使用该 API 编写 Web 应用程序。
注意: 本文基于 2023 年 5 月 11 日的 WebGPU API,如果有任何变化,请在原文评论区或者 Twitter 上告诉作者,作者会尽快更新。 (也可以在本评论区留言,或者联系译者,译者将会代为联系作者)
作者已经准备了一个 Github 仓库,里面包含了你开始的所有所需内容。我们将一起编写使用 Typescript 编写的 WebGPU Hello Triangle 应用。
请查看作者在 WebGL 上的另一篇博文,该文介绍如何使用一种较旧但广泛支持的 Web 图形 API 来编写图形应用程序。
设定
WebGPU 在广泛的平台上得到支持:
- Google Chrome: WebGPU 在 Chrome 113 中可用,并且目前作为初始试用版本提供。目前,Chrome Canary 的 Android 版不支持 WebGPU。
- Mozilla Firefox: 你必须使用 Nightly 版,Firefox Nightly 的 Android 版支持 WebGPU。
- Microsoft Edge: 目前可以通过 Canary 版本使用,但其功能与 Chrome 相同。
- Apple Safari: Safari 团队目前正在致力于桌面端支持 WebGPU,但就目前而言,移动端还没有消息。
当你拥有了支持 WebGPU 的浏览器后安装以下内容:
- 任何现代浏览器的 alpha 版本,例如:
- Chrome 或者任何 基于 Chromium 的浏览器的 Canary 版本, 然后访问
about:flags
去启用unsafe-webgpu
。 - FireFox,访问
about:config
去启用dom.webgpu.enabled
。
- Chrome 或者任何 基于 Chromium 的浏览器的 Canary 版本, 然后访问
- Git
- Node.js
- 一个文本编辑器,例如: Visual Studio Code、Vim、Emacs
然后在任意终端命令行输入以下内容,例如 VS Code’s 内置终端:
# 🐑 克隆该仓库
git clone https://github.com/alaingalvan/webgpu-seed
# 💿 进入项目文件夹
cd webgpu-seed
# 🔨 启动构建项目
npm start
引用一个文章介绍node.js, packages
项目结构
├─ 📂 node_modules/ # 👶 依赖
│ ├─ 📁 gl-matrix # ➕ 线性代数
│ └─ 📁 ... # 🕚 其他依赖 (TypeScript, Webpack, etc.)
├─ 📂 src/ # 🌟 源文件
│ ├─ 📄 renderer.ts # 🔺 三角形渲染器
│ └─ 📄 main.ts # 🏁 应用程序主入口
├─ 📄 .gitignore # 👁️ 在 Git 仓库中忽略特定的文件
├─ 📄 package.json # 📦 Node 包配置文件
├─ 📄 license.md # ⚖️ 你的 License (Unlicense)
└─ 📃readme.md # 📖 请阅读它!
依赖
- gl-matrix - 一个 JavaScript 库,允许用户像编写 JavaScript 代码一样编写
glsl
,包括向量、矩阵等类型。虽然在这个示例中没有使用,但是它对于编写更高级的内容非常有用。 - TypeScript - 带有类型的 JavaScript,通过实时自动补全和类型检查使得编写 Web 应用程序变得非常容易。
- Webpack - 一个JavaScript 编译工具,用于构建最小化的输出和更快地测试我们的应用程序。
概述
在这个应用程序中我们将要做以下几件事:
- 初始化 API - 检查
Navigator.gpu
是否存在,如果存在,则请求一个GPUAdapter
, 然后请求一个GPUDevice
,并获取该设备的默认GPUQueue
。 - 设置帧支持 - 创建一个
GPUCanvasContext
并将其配置为接收当前帧的GPUTexture
,以及您可能需要的任何其他附件(例如深度模板纹理等)。为这些纹理创建GPUTextureView
。 - 初始化资源 - 创建您的顶点和索引
GPUBuffers
, 将 WebGPU Shading Language 着色器加载为GPUShaderModules
,通过描述图形管线的每个阶段创建GPURenderPipeline
。最后,使用您打算运行的渲染通道构建GPUCommandEncoder
,然后使用渲染通道执行所有的绘图调用构建GPURenderPassEncoder
。 - 渲染 - 通过调用
.finish()
提交您的GPUCommandEncoder
到GPUQueue
。通过调用requestAnimationFrame
刷新你画布上下文。 - 销毁 - 在使用 API 完成后销毁所有数据结构。
初始化 API
入口
为了访问 WebGPU API,你需要检查全局变量navigator
对象中是否存在一个gpu
对象。
// 🏭 WebGPU 入口
const entry: GPU = navigator.gpu;
if (!entry) {
throw new Error('WebGPU is not supported on this browser.');
}
适配器
适配器 描述了特定 GPU 的物理属性,例如它的名称、扩展和设备限制。
// ✋ 定义适配器句柄
let adapter: GPUAdapter = null;
// 🙏 内置的异步获取...
// 🔌 物理设备适配器
adapter = await entry.requestAdapter();
设备
设备 是您访问 WebGPU API 的核心方式,它将允许您创建所需的数据结构。
// ✋ 定义设备句柄
let device: GPUDevice = null;
// 🙏 内置的异步获取...
// 💻 逻辑设备
device = await adapter.requestDevice();
队列
队列 允许您将工作异步的发送到 GPU。在原文撰写时,您只能从给定的 GPUDevice
访问一个默认的队列。
// ✋ 定义队列句柄
let queue: GPUQueue = null;
// 📦 获取队列
queue = device.queue;
帧支持
Canvas 上下文
为了看到你所绘制的内容,您需要一个 HTMLCanvasElement
canvas/画布 元素, 并且需要从该画布获得一个 Canvas 上下文, Canvas 上下文管理一系列纹理,您将使用这些纹理来呈现最终的渲染输出到你的 canvas 元素。
// ✋ 定义上下文句柄
const context: GPUCanvasContext = null;
// ⚪ 创建上下文
context = canvas.getContext('webgpu');
// ⛓️ 配置上下文
const canvasConfig: GPUCanvasConfiguration = {
device: this.device,
format: 'bgra8unorm',
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
alphaMode: 'opaque'
};
context.configure(canvasConfig);
帧缓冲附件
// ✋ 定义附件句柄
let depthTexture: GPUTexture = null;
let depthTextureView: GPUTextureView = null;
// 🤔 创建深度支持
const depthTextureDesc: GPUTextureDescriptor = {
size: [canvas.width, canvas.height, 1],
dimension: '2d',
format: 'depth24plus-stencil8',
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC
};
depthTexture = device.createTexture(depthTextureDesc);
depthTextureView = depthTexture.createView();
// ✋ 定义 Canvas 上下文图像句柄
let colorTexture: GPUTexture = null;
let colorTextureView: GPUTextureView = null;
colorTexture = context.getCurrentTexture();
colorTextureView = colorTexture.createView();
初始化资源
顶点和索引缓冲区
缓冲区 是一个数组数据,比如网格的位置数据、颜色数据、索引数据等。在只用基于光栅的图形管线渲染三角形的时候,你需要将一个或者多个顶点数据缓冲区「通常称为顶点缓冲对象」,以及一个包含与要绘制的每个三角形顶点相对应的索引的缓冲区「也称为索引缓冲对象」
// 📈 位置顶点缓冲数据
const positions = new Float32Array([
1.0, -1.0, 0.0, -1.0, -1.0, 0.0, 0.0, 1.0, 0.0
]);
// 🎨 颜色顶点缓冲数据
const colors = new Float32Array([
1.0,
0.0,
0.0, // 🔴
0.0,
1.0,
0.0, // 🟢
0.0,
0.0,
1.0 // 🔵
]);
// 📇 索引缓冲数据
const indices = new Uint16Array([0, 1, 2]);
// ✋ 定义缓冲区句柄
let positionBuffer: GPUBuffer = null;
let colorBuffer: GPUBuffer = null;
let indexBuffer: GPUBuffer = null;
// 👋 辅助函数便于其他类型数组转化为 GPUBuffer(s)
const createBuffer = (arr: Float32Array | Uint16Array, usage: number) => {
// 📏 对齐到 4 字节 (感谢 @chrimsonite)
let desc = {
size: (arr.byteLength + 3) & ~3,
usage,
mappedAtCreation: true
};
let buffer = device.createBuffer(desc);
const writeArray =
arr instanceof Uint16Array
? new Uint16Array(buffer.getMappedRange())
: new Float32Array(buffer.getMappedRange());
writeArray.set(arr);
buffer.unmap();
return buffer;
};
positionBuffer = createBuffer(positions, GPUBufferUsage.VERTEX);
colorBuffer = createBuffer(colors, GPUBufferUsage.VERTEX);
indexBuffer = createBuffer(indices, GPUBufferUsage.INDEX);
着色器
伴随着 WebGPU 来的还有一个新的着色器语言: WebGPU Shading Language。
WebGPU 着色器语言类似于其他语言,如 Rust 、 Metal 着色器语言和 DirectX 高级着色器语言,具有 JavaScript 风格的装饰器, 例如 @location(0)
, 采用 蛇形命名法 的
函数/成员,
驼峰命名法的结构体,以及函数遵循 fn my_func() -> i32
的 Rust 代码格式。
以下是顶点着色器的源代码:
struct VSOut {
@builtin(position) nds_position: vec4<f32>,
@location(0) color: vec3<f32>,
};
@vertex
fn main(@location(0) in_pos: vec3<f32>,
@location(1) in_color: vec3<f32>) -> VSOut {
var vs_out: VSOut;
vs_out.nds_position = vec4<f32>(in_pos, 1.0);
vs_out.color = inColor;
return vsOut;
}
以下是片段着色器的源代码:
@fragment
fn main(@location(0) in_color: vec3<f32>) -> @location(0) vec4<f32> {
return vec4<f32>(in_color, 1.0);
}
使用像 Mozilla Naga 或者 Google Tint 这样的转换器可以将其他着色器语(例如 HLSL 或 GLSL)转换为 WGSL,但是这个过程有点复杂。
着色器模块
着色器模块 是一种普通的文本 WGSL 文件, 在 GPU 上使用给定的图形管道执行。
// 📄 引入或者在行内定义您的WGSL代码:
import vertShaderCode from './shaders/triangle.vert.wgsl';
import fragShaderCode from './shaders/triangle.frag.wgsl';
// ✋ 定义着色器模块句柄
let vertModule: GPUShaderModule = null;
let fragModule: GPUShaderModule = null;
const vsmDesc = { code: vertShaderCode };
vertModule = device.createShaderModule(vsmDesc);
const fsmDesc = { code: fragShaderCode };
fragModule = device.createShaderModule(fsmDesc);
Uniform 缓冲区
您通常需要直接向着色器模块提供数据,并且为此需要指定一个统一变量。要在着色器中创建 Uniform 缓冲区,请在主函数之前声明以下内容:
struct UBO {
modelViewProj: mat4x4<f32>,
primaryColor: vec4<f32>,
accentColor: vec4<f32>
};
@group(0) @binding(0)
var<uniform> uniforms: UBO;
// ❗在您的顶点着色器的主文件中 ,
// 将倒数第四行替换为:
vsOut.Position = uniforms.modelViewProj * vec4<f32>(inPos, 1.0);
在您的 JavaScript 代码中,创建一个 Uniform 缓冲区,就像使用索引/顶点缓冲区一样。
为了更好地管理线性代数计算(例如矩阵乘法),您可能需要使用像 gl-Matrix 这样的库。
// 👔 uniform数据
const uniformData = new Float32Array([
// ♟️ 模型视图投影矩阵 (单位矩阵)
1.0, 0.0, 0.0, 0.0
0.0, 1.0, 0.0, 0.0
0.0, 0.0, 1.0, 0.0
0.0, 0.0, 0.0, 1.0
// 🔴 主颜色
0.9, 0.1, 0.3, 1.0
// 🟣 强调色
0.8, 0.2, 0.8, 1.0
]);
// ✋ 定义缓冲区句柄
let uniformBuffer: GPUBuffer = null;
uniformBuffer = createBuffer(uniformData, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST);
管线布局
译者注: 管线布局 本质上是一个着色器阶段的资源绑定布局描述,它定义了着色器需要的资源如何被绑定到管线上。它在创建渲染管线时被指定,确保着色器能够正确地访问所需的资源。
一旦您有了一个统一的 uniform ,您就可以创建一个 管线布局 来描述在执行图形管线时该 uniform 的位置。
let bindGroupLayout: GPUBindGroupLayout = null;
let uniformBindGroup: GPUBindGroup = null;
// 👨🔧 创建您的图形管线...
// 🧙♂️ 获取您的隐式管线布局:
bindGroupLayout = pipeline.getBindGroupLayout(0);
// 🗄️ 绑定组
// ✍ 这将用于 *编码指令*
uniformBindGroup = device.createBindGroup({
layout: bindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: uniformBuffer
}
}
]
});
或者您事先知道你的布局, 你可以自己在管线创建的时候创建并使用它。
// ✋ 定义句柄
let uniformBindGroupLayout: GPUBindGroupLayout = null;
let uniformBindGroup: GPUBindGroup = null;
let layout: GPUPipelineLayout = null;
// 📁 绑定组layout
uniformBindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.VERTEX,
buffer: {}
}
]
});
// 🗄️ 绑定组
// ✍ 这将用于 *编码指令*
uniformBindGroup = device.createBindGroup({
layout: uniformBindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: uniformBuffer
}
}
]
});
// 🗂️ Pipeline Layout
// 👩🔧 这将用作 GPUPipelineDescriptor 的成员,在创建管线的时候使用
const pipelineLayoutDesc = { bindGroupLayouts: [uniformBindGroupLayout] };
layout = device.createPipelineLayout(pipelineLayoutDesc);
当进行指令编码的时候,您可以使用 setBindGroup
:
// ✍ 在您指令编码后:
passEncoder.setBindGroup(0, uniformBindGroup);
图形管线
图形管线 描述了所有要输入到光栅绘图管线执行中的数据。包括以下:
- 🔣 输入组件 - 每个顶点是什么样子?哪些属性在哪里,以及他们在内存中是如何对齐的?
- 🖍️ 着色器模块 - 在执行此图形管线的时候,您将使用哪些着色器模块?
- ✏️ Depth/Stencil State - 您应该执行深度测试吗?如果是这样,您应该使用什么函数来测试深度?
- 🍥 混合状态 - 在先前的颜色和当前的颜色之间应该如何混合颜色?
- 🔺 光栅化 - 在执行图形管线时,光纤化器的行为是如何的?它是否剔除面?应该剔除哪个方向的面?
- 💾 Uniform 数据 - 您的着色器应该需要什么样格式的统一数据?在 WebGPU 中,这是通过描述管道布局来实现的。
WebGPU 为图形管线状态提供了只能默认设置,所以大多数时候您甚至不需要设置它,下面是一部分:
// ✋ Declare pipeline handle
let pipeline: GPURenderPipeline = null;
// ⚗️ Graphics Pipeline
// 🔣 Input Assembly
const positionAttribDesc: GPUVertexAttribute = {
shaderLocation: 0, // @location(0)
offset: 0,
format: 'float32x3'
};
const colorAttribDesc: GPUVertexAttribute = {
shaderLocation: 1, // @location(1)
offset: 0,
format: 'float32x3'
};
const positionBufferDesc: GPUVertexBufferLayout = {
attributes: [positionAttribDesc],
arrayStride: 4 * 3, // sizeof(float) * 3
stepMode: 'vertex'
};
const colorBufferDesc: GPUVertexBufferLayout = {
attributes: [colorAttribDesc],
arrayStride: 4 * 3, // sizeof(float) * 3
stepMode: 'vertex'
};
// 🌑 Depth
const depthStencil: GPUDepthStencilState = {
depthWriteEnabled: true,
depthCompare: 'less',
format: 'depth24plus-stencil8'
};
// 🦄 Uniform Data
const pipelineLayoutDesc = { bindGroupLayouts: [] };
const layout = device.createPipelineLayout(pipelineLayoutDesc);
// 🎭 Shader Stages
const vertex: GPUVertexState = {
module: vertModule,
entryPoint: 'main',
buffers: [positionBufferDesc, colorBufferDesc]
};
// 🌀 Color/Blend State
const colorState: GPUColorTargetState = {
format: 'bgra8unorm'
};
const fragment: GPUFragmentState = {
module: fragModule,
entryPoint: 'main',
targets: [colorState]
};
// 🟨 Rasterization
const primitive: GPUPrimitiveState = {
frontFace: 'cw',
cullMode: 'none',
topology: 'triangle-list'
};
const pipelineDesc: GPURenderPipelineDescriptor = {
layout,
vertex,
fragment,
primitive,
depthStencil
};
pipeline = device.createRenderPipeline(pipelineDesc);
指令编码器
指令编码器 会将您打算在 渲染通道编码器 组中执行的所有的绘制指令进行编码。一旦完成了指令的编码,您将收到一个指令缓冲区,可以将其提交到队列中。
这个意义在于指令缓冲区类似于在 GPU 提交到队列后执行绘图函数的 回调。
// ✋ Declare command handles
let commandEncoder: GPUCommandEncoder = null;
let passEncoder: GPURenderPassEncoder = null;
// ✍️ Write commands to send to the GPU
const encodeCommands = () => {
let colorAttachment: GPURenderPassColorAttachment = {
view: this.colorTextureView,
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: 'clear',
storeOp: 'store'
};
const depthAttachment: GPURenderPassDepthStencilAttachment = {
view: this.depthTextureView,
depthClearValue: 1,
depthLoadOp: 'clear',
depthStoreOp: 'store',
stencilClearValue: 0,
stencilLoadOp: 'clear',
stencilStoreOp: 'store'
};
const renderPassDesc: GPURenderPassDescriptor = {
colorAttachments: [colorAttachment],
depthStencilAttachment: depthAttachment
};
commandEncoder = device.createCommandEncoder();
// 🖌️ Encode drawing commands
passEncoder = commandEncoder.beginRenderPass(renderPassDesc);
passEncoder.setPipeline(pipeline);
passEncoder.setViewport(0, 0, canvas.width, canvas.height, 0, 1);
passEncoder.setScissorRect(0, 0, canvas.width, canvas.height);
passEncoder.setVertexBuffer(0, positionBuffer);
passEncoder.setVertexBuffer(1, colorBuffer);
passEncoder.setIndexBuffer(indexBuffer, 'uint16');
passEncoder.drawIndexed(3);
passEncoder.endPass();
queue.submit([commandEncoder.finish()]);
};
渲染
渲染 是在 WebGPU 中简单地更新任务你打算更新的 uniform, 从你的上下文或者下一个附件,提交你的指令编码器以执行,然后使用 requestAnimationFrame
回调再次执行所有这些操作。
const render = () => {
// ⏭ Acquire next image from context
colorTexture = context.getCurrentTexture();
colorTextureView = colorTexture.createView();
// 📦 Write and submit commands to queue
encodeCommands();
// ➿ Refresh canvas
requestAnimationFrame(render);
};
结束语
WebGPU 可能比其他图形 API 更难,但是它的API 设计的与现在显卡相结合的更紧密,因此,不仅可以让应用程序更快,并且更长效。
有些事情作者没有在这篇文章中涉及,因为它们超出了这篇文章的范围,比如:
- 矩阵 不管是用于视角还是场景中的物体转换。 gl-Matrix 是一种无价的资源。
- WebGPU 类型定义 详细概述了绘图管线的每种可能状态,是非常有帮助的。
- 混合模式 Anders Riggelsen 在这里编写了一个工具,可以帮助您以可视化的方式查看 OpenGL 混合模式的行为。
- 计算管线 如果您想尝试的化,请查看下面的说明或者一些示例。
- 加载纹理 这可能有点复杂,下面的示例很好的介绍了如何加载纹理。
其他资源
下面是一些关于 WebGPU 的文章/项目,顺序不分先后:
- William Usher (@_wusher)’s article, updated for the Chrome 113 release of WebGPU: From 0 to glTF with WebGPU.
- Dzmitry Malyshau wrote an article similar to this one introducing WebGPU in Mozilla FireFox.
- Warren Moore (@warrenm) wrote an article to help folks transition from the Metal API to WebGPU.
- Brandon Jones (@Tojiro) wrote an article describing how to write a GLTF renderer in WebGPU.
- Learn WGPU is an introduction to writing WebGPU applications with Rust.
- Learn WebGPU for native graphics in C++.
这有许多开源项目:
- Austin Eng’s WebGPU Samples
- Tarek Sherif (@tsherif)’s WebGPU Examples
- RedGPU by @RedCamel15, a series of examples written for WebGPU.
- Three.js’ WebGPU Source
- BabylonJS’s WebGPU Source
- WebGPU’s Type Definitions
- WebGPU’s Conformance Tests
- Dawn - A C++ Implementation of WebGPU used to power Chromium’s implementation of WebGPU. Carl Woffenden released a Hello Triangle example with WebGPU and Dawn.
- wgpu-native is a native WebGPU implementation in Rust, which can easily be used in all sorts of native projects.
WebGPU 和 WebGPU 着色器语言的规范也值得看一看: