1. 简介
上次更新日期:2023 年 4 月 13 日
什么是 WebGPU?
WebGPU 是一种新型的现代 API,可用于在 Web 应用中访问 GPU 的功能。
现代 API
在 WebGPU 之前,WebGL 提供了 WebGPU 的部分功能。它促成了一类新的富媒体内容,开发者利用它打造了许多精彩内容。不过,此 API 基于 2007 年发布的 OpenGL ES 2.0 API,后者基于更早的 OpenGL API。在此期间,GPU 发生了显著变化,用于与它们进行交互的原生 API 也随着 Direct3D 12、Metal 和 Vulkan 不断完善。
WebGPU 为网络平台带来了这些现代 API 的进步。该 API 专注于跨平台启用 GPU 功能,同时呈现了在网络上操作的自然体验,并且比基于该 API 构建的一些原生 API 更详细。
渲染
GPU 通常与快速、详细的图形渲染有关,WebGPU 也不例外。它具备多种功能,能够支持桌面设备和移动设备 GPU 中当前最流行的许多呈现技术,并且为未来随着硬件功能不断发展而添加的新功能提供了途径。
计算
除了渲染,WebGPU 还可以发挥 GPU 执行通用、高度并行的工作负载的潜力。这些计算着色器可独立使用,没有任何渲染组件,也可用作渲染流水线的紧密集成部分。
在今天的 Codelab 中,您将学习如何利用 WebGPU 的渲染和计算功能来创建简单的入门项目!
构建内容
在此 Codelab 中,您将使用 WebGPU 构建 Conway 的 Game of Life。您的应用将:
- 使用 WebGPU 的渲染功能绘制简单的 2D 图形。
- 使用 WebGPU 的计算功能来执行模拟。
“Game of Life”就是所谓的单元格自动程序,即单元格网格网格根据一组规则随时间变化。在《Game of Life》中,细胞会变为活跃状态或非活跃状态,具体取决于其相邻的单元格数量,从而形成值得注意的波动模式。
学习内容
- 如何设置 WebGPU 并配置画布。
- 如何绘制简单的 2D 几何图形。
- 如何使用顶点和片段着色器来修改所绘制的内容。
- 如何使用计算着色器来执行简单的模拟。
此 Codelab 将重点介绍 WebGPU 背后的基本概念。其中并未涵盖 API 的全面审核内容,也不涵盖(或要求)经常相关的主题(例如 3D 矩阵)。
所需条件
- 适用于 ChromeOS、macOS 或 Windows 的最新版 Chrome(113 或更高版本)。WebGPU 是一种跨浏览器、跨平台的 API,但尚未在所有地方推出。
- 了解 HTML、JavaScript 和 Chrome 开发者工具。
不要求熟悉其他图形 API(例如 WebGL、金属、Vulkan 或 Direct3D),但如果您有这些方面的经验,可能会发现它与 WebGPU 有许多相似之处,可帮助您快速上手!
2. 进行设置
获取代码
此 Codelab 没有任何依赖项,它会引导您完成创建 WebGPU 应用所需的每个步骤,因此您无需编写任何代码即可开始使用。不过,https://glitch.com/edit/#!/your-first-webgpu-app 上提供了一些可以用作检查点的有效示例。如果您遇到问题,可以立即查看并参考它们。
使用开发者控制台!
WebGPU 是一个相当复杂的 API,很多规则都规定了正确使用。更糟糕的是,由于此 API 的工作原理,它无法针对许多错误引发典型的 JavaScript 异常,因此更难查明问题的来源。
使用 WebGPU 进行开发(尤其是新手)时会遇到问题,这没关系!该 API 背后的开发者深知在 GPU 开发方面所面临的挑战,并且一直努力确保每当您的 WebGPU 代码引发错误时,您会在控制台中收到非常详细且有用的消息,这有助于您发现和解决问题。
无论在使用哪 Web 应用时,让控制台保持打开状态都非常有用,但在这里它尤其适用!
3. 初始化 WebGPU
以“<canvas>
”开头
如果您只希望在 WebGPU 上进行计算,可以使用 WebGPU,而无需在屏幕上显示任何内容。但是,如果您想渲染任何内容,就像我们在此 Codelab 中要做的那样,您需要一个画布。最好从此处开始!
创建一个仅包含一个 <canvas>
元素的新 HTML 文档,以及一个用于查询画布元素的 <script>
标记。(或者,从故障或使用 00-starter-page.html)。
- 创建一个
index.html
文件,其中包含以下代码:
index.html
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>WebGPU Life</title>
</head>
<body>
<canvas width="512" height="512"></canvas>
<script type="module">
const canvas = document.querySelector("canvas");
// Your WebGPU code will begin here!
</script>
</body>
</html>
请求适配器和设备
现在,您可以深入了解 WebGPU 位了!首先,您应考虑 WebGPU 等 API 可能需要一段时间才能传播到整个网络生态系统。因此,一个预防措施良好的第一步是检查用户的浏览器是否可以使用 WebGPU。
- 如需检查用作 WebGPU 入口点的
navigator.gpu
对象,请添加以下代码:
index.html
if (!navigator.gpu) {
throw new Error("WebGPU not supported on this browser.");
}
理想情况下,如果页面 GPU 不可用,您需要让用户回退到不使用 WebGPU 的模式,从而通知用户。(也许它可以改用 WebGL?)不过,在此 Codelab 中,您只是抛出了一个错误来阻止代码进一步执行。
确定浏览器支持 WebGPU 后,为应用初始化 WebGPU 的第一步是请求 GPUAdapter
。您可以将适配器视为 WebGPU 代表设备中的特定 GPU 硬件。
- 如需获取适配器,请使用
navigator.gpu.requestAdapter()
方法。它会返回 promise,因此用await
调用它最为方便。
index.html
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
throw new Error("No appropriate GPUAdapter found.");
}
如果未找到合适的适配器,返回的 adapter
值可能是 null
,因此您需要处理这种可能性。如果用户的浏览器支持 WebGPU,但其 GPU 硬件不具备使用 WebGPU 所需的所有功能,则可能会发生这种情况。
大多数时候,您可以只是让浏览器像选择此处一样选择默认适配器,但为了满足更高级的需求,可以将参数传递给 requestAdapter()
,用于指定您是否要在具有多个 GPU 的设备(例如某些笔记本电脑)上使用低功耗或高性能硬件。
有了适配器后,最后一步就是开始请求 GPUDevice,然后才能开始使用 GPU。设备是与 GPU 进行交互的主要界面。
- 通过调用
adapter.requestDevice()
获取设备,该方法也会返回一个 promise。
index.html
const device = await adapter.requestDevice();
与 requestAdapter()
一样,这里有一些选项可以传递,用于更高级的用法,例如启用特定的硬件功能或请求更高的限制,但对于您的用途,默认设置没有问题。
配置画布
现在,您已拥有设备,接下来还需要完成一个操作:如果您想使用该设备来显示网页上的任何内容,请将画布配置为与您刚刚创建的设备搭配使用。
- 为此,请先通过调用
canvas.getContext("webgpu")
从画布请求GPUCanvasContext
。(这与用于分别初始化2d
和webgl
上下文类型且用于初始化 Canvas 2D 或 WebGL 上下文的调用相同。)然后,它必须使用configure()
方法将返回的context
与设备相关联,如下所示:
index.html
const context = canvas.getContext("webgpu");
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device: device,
format: canvasFormat,
});
此处有几个选项可以传递,但最重要的选项是您要用于使用上下文的 device
以及 format
,这是上下文应使用的纹理格式。
纹理是 WebGPU 用来存储图片数据的对象,每个纹理都有一种格式,可让 GPU 知道这些数据在内存中的布局方式。纹理内存的工作原理详情不在此 Codelab 的范围内。重要的是,画布上下文会提供代码的纹理供您绘制,而您使用的格式可能会影响画布显示这些图像的效率。不同类型的设备在使用不同纹理格式时效果最佳,如果您不使用设备的首选格式,就可能会导致后台产生额外的内存副本,然后图片才能作为页面的一部分显示。
幸运的是,您不必费心担心任何这些原因,因为 WebGPU 会告知您画布使用哪种格式!几乎在所有情况下,您都需要传递通过调用 navigator.gpu.getPreferredCanvasFormat()
返回的值,如上所示。
清空画布
现在,您已经有了一台设备,并且配置了画布,接下来就可以开始使用该设备更改画布内容了。首先,请以纯色将其清除。
为此,您需要向 GPU 提供一些命令,指示它执行什么操作(或者 WebWeb 中的大多数其他操作)。
- 为此,请让设备创建
GPUCommandEncoder
,以提供用于记录 GPU 命令的接口。
index.html
const encoder = device.createCommandEncoder();
您要向 GPU 发送的命令与渲染相关(在本例中是清除画布),因此下一步是使用 encoder
开始渲染通道。
渲染通道是指 WebGPU 中的所有绘制操作都发生的时间。每个 activity 都以 beginRenderPass()
调用开头,该调用定义了接收所执行的任何绘制命令的输出的纹理。更高级的使用场景可以提供多种纹理(称为“附件”),其具体用途包括存储渲染几何图形的深度或提供抗锯齿功能。不过,对于此应用,您只需要 1 个。
- 通过调用
context.getCurrentTexture()
从您之前创建的画布上下文获取纹理,这将返回一个纹理,该像素的宽度和高度匹配画布的width
和height
属性,以及您调用context.configure()
时指定的format
。
index.html
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
storeOp: "store",
}]
});
纹理作为 colorAttachment
的 view
属性提供。渲染通道要求您提供 GPUTextureView
而不是 GPUTexture
,后者会告知渲染纹理的哪些部分。这对于更高级的用例来说真正重要,因此在这里,您调用 createView()
时不带纹理参数,这表示您希望渲染通道使用整个纹理。
此外,您还必须指定渲染通道在纹理启动时和结束时间对纹理所做的处理:
loadOp
值为"clear"
,表示您希望在渲染通道启动时清除纹理。storeOp
的"store"
值表示渲染渲染完成后,您需要将渲染过程中完成的任何绘制的结果保存到纹理中。
渲染通道开始后,您什么都没做!至少目前是这样。使用 loadOp: "clear"
启动渲染通道的操作足以清除纹理视图和画布。
- 在
beginRenderPass()
之后立即添加以下调用来结束渲染通道:
index.html
pass.end();
需要注意的是,仅进行这些调用并不会使 GPU 实际上执行任何操作。它们只是记录 GPU 后续需要执行的命令。
- 为了创建
GPUCommandBuffer
,请在命令编码器上调用finish()
。命令缓冲区是记录的命令的不透明句柄。
index.html
const commandBuffer = encoder.finish();
- 使用
GPUDevice
的queue
将命令缓冲区提交到 GPU。该队列执行所有 GPU 命令,确保其执行顺序合理且同步正确。队列的submit()
方法可接收命令缓冲区数组,但在本例中,您只有一个。
index.html
device.queue.submit([commandBuffer]);
提交命令缓冲区后,便无法再次使用它,因此无需一直使用。如果要提交更多命令,您需要再构建一个命令缓冲区。因此,将这两个步骤合并为一个是比较常见的情况,如本 Codelab 的示例网页所示:
index.html
// Finish the command buffer and immediately submit it.
device.queue.submit([encoder.finish()]);
将命令提交到 GPU 后,让 JavaScript 将控制权交还给浏览器。此时,浏览器会看到您已更改上下文的当前纹理,并更新画布以将该纹理显示为图片。在此之后,如果您想再次更新画布内容,则需要记录并提交一个新的命令缓冲区,并再次调用 context.getCurrentTexture()
以获取渲染通道的新纹理。
- 重新加载页面。请注意,画布上填充了黑色。恭喜!这意味着您已成功创建了您的第一个 WebGPU 应用。
选择一种颜色!
但说实话,黑方形太无聊了。所以,请花一点时间转到下一部分,对它进行一些个性化设置。
- 在
device.beginRenderPass()
调用中,向colorAttachment
添加包含clearValue
的新代码行,如下所示:
index.html
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
clearValue: { r: 0, g: 0, b: 0.4, a: 1 }, // New line
storeOp: "store",
}],
});
clearValue
指示渲染通道在卡券开始执行 clear
操作时应使用该颜色。传入它的字典包含四个值:红色、r
表示绿色、b
表示蓝色,a
表示 alpha(透明度)。g
每个值的范围都介于 0
到 1
之间,共同描述该颜色通道的值。例如:
{ r: 1, g: 0, b: 0, a: 1 }
为亮红色。{ r: 1, g: 0, b: 1, a: 1 }
是亮紫色。{ r: 0, g: 0.3, b: 0, a: 1 }
为深绿色。{ r: 0.5, g: 0.5, b: 0.5, a: 1 }
为中灰色。{ r: 0, g: 0, b: 0, a: 0 }
是默认的透明黑色。
此 Codelab 中的示例代码和屏幕截图使用的是深蓝色,但您可以随意选择想要的颜色!
- 选择颜色后,重新加载页面。您应该会在画布中看到所选颜色。
4. 绘制几何图形
完成本部分后,您的应用将在画布上绘制一些简单的几何图形:一个彩色方形。需要注意的是,现在显示这么简单的输出似乎需要大量工作,这是因为 WebGPU 旨在高效地渲染大量的几何图形。这种效率的副作用是,执行相对简单的操作可能让人感觉异常困难,但如果您转向 WebGPU 之类的 API,这是预料之中的。您需要执行的操作稍微复杂一些。
了解 GPU 的绘制方式
在对代码做出进一步更改之前,最好快速概括介绍一下 GPU 如何创建在屏幕上显示的形状。(如果您已熟悉 GPU 渲染的工作原理,可以直接跳到“定义顶点”部分。)
与包含大量形状和选项(例如 Canvas 2D)的 API 不同,您的 GPU 实际上仅处理几种不同类型的形状(或 WebGPU 所指的基元):点、线和三角形。在本 Codelab 中,您将仅使用三角形。
GPU 几乎只使用三角形,因为三角形具有非常多的数学属性,使其能够以可预测且高效的方式进行处理。几乎所有通过 GPU 绘制的三角形都需要拆分成三角形,GPU 才能绘制到这些三角形,而且这些三角形必须由它们的角点定义。
这些点(或顶点)以 X、Y 和 3D 内容(对于 3D 内容)中的 Z 值表示,这些值定义由 WebGPU 或类似 API 定义的笛卡尔坐标系。关于坐标系与页面上画布的关系,我们很容易想到它。无论您的画布宽度或高度是多少,左侧边缘的 X 轴始终为 -1,而右边缘的 X 轴始终为 +1。同样,Y 轴上的底边始终是 -1,上边的 Y 轴始终是 +1。这意味着 (0, 0) 始终是画布中心,(-1, -1) 始终位于左下角,而 (1, 1) 始终位于右上角。这称为“剪辑空间”。
最初,在这种坐标系中很少定义顶点,因此 GPU 依靠名为“顶点着色器”的小程序来执行将数学转换顶点空间所需的任何数学运算,以及绘制顶点所需的任何其他计算。例如,着色器可能会应用一些动画或计算从顶点到光源的方向。这些着色器由您作为 WebGPU 开发者编写,它们可让您显著控制 GPU 的工作方式。
然后,GPU 会提取这些转换顶点构成的所有三角形,并确定需要绘制屏幕上的像素。然后,它会运行您编写的另一个小程序,称为 fragment 着色器,用于计算每个像素应该是什么颜色。这种计算既可以很简单,也可以像返回绿色一样简单;也可以计算表面相对于其他附近表面的日光反射的角度,通过雾化进行过滤,并根据表面的金属度进行修改。这完全由您掌控,既有强大的力量,又有压迫感。
然后,这些像素颜色的结果会叠加到纹理中,然后可以在屏幕上显示。
定义顶点
如前所述,The Game of Life 模拟以单元格网格形式显示。您的应用需要一种直观呈现网格来区分活动单元格和无效单元格的方法。此 Codelab 中使用的方法是绘制活跃单元格中的彩色方块,并将闲置单元格留空。
这意味着需要为 GPU 提供四个不同的点,每个方形的四个角各对应一个点。例如,在画布中心绘制的方形从边缘插入的方式有如下角坐标:
为了将这些坐标馈送到 GPU,您需要将这些值放在 TypedArray 中。如果您还不熟悉此方法,TypedArray 就是一组 JavaScript 对象,让您能够分配连续的内存块,并将系列中的每个元素解读为特定的数据类型。例如,在 Uint8Array
中,数组中的每个元素都是一个无符号字节。TypedArray 非常适合使用对内存布局敏感的 API(例如 WebAssembly、WebAudio 和(当然)WebGPU)来回发送数据。
对于方形示例,由于值是小数,因此适合使用 Float32Array
。
- 在您的代码中添加以下数组声明,创建一个用于存储图中所有顶点位置的数组。建议将其放置在顶部附近,就在
context.configure()
调用下方。
index.html
const vertices = new Float32Array([
// X, Y,
-0.8, -0.8,
0.8, -0.8,
0.8, 0.8,
-0.8, 0.8,
]);
请注意,空格和注释不会影响这些值;它们只是为了方便您使用,使其更易于阅读。这有助于您了解每对值组成一个顶点的 X 坐标和 Y 坐标。
但有一个问题!GPU 的三角形的运作方式是这样吗?这意味着,您需要以 3 个群组为单位提供该顶点。你有四组。解决方案是重复两个顶点,以形成两个三角形共用,这些边缘穿过正方形的中心位置。
为了用图表形成方形,您必须列出两个 (-0.8, -0.8) 和 (0.8, 0.8) 顶点,分别代表蓝色三角形和红色三角形。(您也可以选择将边角与其他两个角分开;这不会产生任何影响。)
- 更新之前的
vertices
数组,如下所示:
index.html
const vertices = new Float32Array([
// X, Y,
-0.8, -0.8, // Triangle 1 (Blue)
0.8, -0.8,
0.8, 0.8,
-0.8, -0.8, // Triangle 2 (Red)
0.8, 0.8,
-0.8, 0.8,
]);
为清楚起见,此图显示两个三角形之间的间隔,顶点位置则完全相同,而 GPU 渲染这些三角形时没有间隙。它将以单个纯方形呈现。
创建顶点缓冲区
GPU 无法使用 JavaScript 数组中的数据绘制顶点。GPU 通常有自己的内存,该内存经过高度优化,因此需要您在 GPU 绘制过程中使用的所有数据。
对于很多值(包括顶点数据),可通过 GPUBuffer
对象管理 GPU 端内存。缓冲区是 GPU 可以轻松访问的一种内存块,并会针对特定用途进行标记。它有点像 GPU 可见的 TypedArray。
- 若要创建用于保存顶点的缓冲区,请在
vertices
数组定义后添加以下对device.createBuffer()
的调用。
index.html
const vertexBuffer = device.createBuffer({
label: "Cell vertices",
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
首先要注意的是,您为缓冲区提供标签。您创建的每个 WebGPU 对象都可以有一个可选标签,您当然是想要这样做的!标签可以是您期望的任何字符串,只要有助于您识别对象是什么。如果您遇到任何问题,这些标签就会用在 WebGPU 生成的错误消息中,以帮助您了解哪里出了问题。
接下来,为缓冲区指定一个大小(以字节为单位)。您需要一个 48 字节的缓冲区,该缓冲区由 32 位浮点数(4 字节)乘以 vertices
数组(12)的浮点数来确定。值得高兴的是,TypedArrays 已经为您计算了其 byteLength,因此您可以在创建缓冲区时使用它。
最后,您需要指定缓冲区的用法。这是一个或多个 GPUBufferUsage
标志,其中多个标志与 |
(按位 OR)运算符结合使用。在本例中,您指定希望将缓冲区用于顶点数据 (GPUBufferUsage.VERTEX
),并且希望能够将数据复制到其中 (GPUBufferUsage.COPY_DST
)。
返回的缓冲区对象是不透明的,您无法(轻松)检查其包含的数据。此外,它的大部分属性是不可变的,您无法在创建 GPUBuffer
后调整其大小,也无法更改用法标志。您可以更改其内存内容。
最初创建缓冲区时,它包含的内存将初始化为零。有多种方式可以更改其内容,但最简单的方法是使用要复制的 TypedArray 调用 device.queue.writeBuffer()
。
- 如需将顶点数据复制到缓冲区的内存中,请添加以下代码:
index.html
device.queue.writeBuffer(vertexBuffer, /*bufferOffset=*/0, vertices);
定义顶点布局
现在,您有了一个包含顶点数据的缓冲区,但就 GPU 而言,它只是一个 blob 字节。如果您要使用此类内容进行绘制,则需要再多提供一些信息。您需要能够让 WebGPU 更详细地了解顶点数据的结构。
- 使用
GPUVertexBufferLayout
字典定义顶点数据结构:
index.html
const vertexBufferLayout = {
arrayStride: 8,
attributes: [{
format: "float32x2",
offset: 0,
shaderLocation: 0, // Position, see vertex shader
}],
};
乍一看,这有点令人迷惑,但相对容易细分。
您要提供的第一项内容是 arrayStride
。这是 GPU 在查找下一个顶点时需要在缓冲区中向前跳过的字节数。方形的每个顶点由两个 32 位浮点数组成。如前所述,32 位浮点数为 4 个字节,因此两个浮点数为 8 个字节。
接下来是 attributes
属性,它是一个数组。属性是编码到每个顶点中的各个信息。您的顶点仅包含一个属性(顶点位置),但更高级的用例经常会包含包含多个属性的顶点,例如顶点的颜色或几何图形表面的朝向。不过,这不属于此 Codelab 的讨论范围。
在您的单个属性中,您首先要定义数据的 format
。此值来自 GPUVertexFormat
类型的列表,用于描述 GPU 可以理解的每种顶点数据。您的顶点有两个 32 位浮点数,因此请使用 float32x2
格式。例如,如果您的顶点数据由四个 16 位无符号整数组成,则应该改用 uint16x4
。看到图案了吗?
接下来,offset
描述此特定属性开始顶点的字节数。仅当您的缓冲区中包含多个属性时,您才需要担心这一点,在本 Codelab 中,您不会遇到这种情况。
最后,您将拥有 shaderLocation
。这是一个介于 0 到 15 之间的任意数字,对您定义的每个属性而言必须是唯一的。它将此属性与顶点着色器中的特定输入相关联,我们将在下一部分中对此进行介绍。
请注意,虽然您现在定义了这些值,但它们实际上还没有传入到 WebGPU API 的任何位置。这个值即将出现,但最容易在定义顶点时考虑这些值,因此您现在需要对它们进行设置,以供日后使用。
开始使用着色器
现在,您已拥有要渲染的数据,但仍需要告知 GPU 如何处理具体数据。这在很大程度上取决于着色器。
着色器是指您编写并在 GPU 上执行的小程序。每个着色器在数据的不同阶段运行:Vertex 处理、Fragment 处理或常规计算。由于这些容器位于 GPU 上,因此其结构比普通 JavaScript 更加严格。但是,这种结构使它们能够非常快速地并行执行!
WebGPU 中的着色器采用名为 WGSL(WebGPU 着色语言)的着色语言编写。WGSL 在语法上有些与 Rust 类似,其中包含旨在更轻松快捷地实现常见 GPU 工作类型(例如矢量和矩阵数学)的功能。教授整个着色语言的过程远远不在本 Codelab 的讲解范围内,但我们希望您在学习一些简单示例时能够掌握一些基础知识。
着色器本身会作为字符串传入 WebGPU。
- 通过将以下代码复制到
vertexBufferLayout
下的代码中,创建一个用于输入着色器代码的位置:
index.html
const cellShaderModule = device.createShaderModule({
label: "Cell shader",
code: `
// Your shader code will go here
`
});
如要创建着色器,您需要调用 device.createShaderModule()
,并在其中以字符串形式提供 label
和 WGSL code
(可选)。(请注意,此处使用反引号允许多行字符串!)添加一些有效的 WGSL 代码后,该函数会返回包含编译结果的 GPUShaderModule
对象。
定义顶点着色器
从顶点着色器开始,因为 GPU 也将从这里开始!
顶点着色器被定义为函数,GPU 会针对 vertexBuffer
中的每个顶点调用一次该函数。由于 vertexBuffer
包含六个位置(顶点),因此您定义的函数会被调用六次。每次调用该方法时,都会将 vertexBuffer
中的其他位置作为参数传递给该函数,顶点着色器函数的作用是在裁剪空间中返回对应的位置。
也务必要理解,系统不一定会按顺序调用它们。相反,GPU 擅长并行运行上述着色器,这可能会同时处理数百(甚至数千!)顶点!GPU 拥有不可思议的速度,但这存在很大限制,存在一定限制。为了确保极端并行化,顶点着色器不能相互通信。每次着色器调用时只能查看单个顶点的数据,并且只能输出单个顶点的值。
在 WGSL 中,您可以为顶点着色器函数指定任意名称,但必须在前面显示 @vertex
属性,以指明它代表的着色器阶段。WGSL 表示带有 fn
关键字的函数,使用圆括号声明任何参数,并使用大括号定义范围。
- 创建一个如下所示的空
@vertex
函数:
index.html(createShaderModule 代码)
@vertex
fn vertexMain() {
}
但这无效,因为顶点着色器必须返回至少在裁剪空间中处理的顶点的最终位置。始终将此值指定为 4 维矢量。矢量是着色器中常用的标记,在语言中,它们被视为一级基元,具有自己的类型,例如用于四维矢量的 vec4f
。2D 矢量 (vec2f
) 和 3D 矢量 (vec3f
) 也有类似类型!
- 为了指明返回的值为所需位置,请使用
@builtin(position)
属性进行标记。->
符号用于指明这是函数返回的内容。
index.html (createShaderModule 代码)
@vertex
fn vertexMain() -> @builtin(position) vec4f {
}
当然,如果函数具有返回值类型,则实际上需要在函数正文中返回值。您可以使用语法 vec4f(x, y, z, w)
构造要返回的新 vec4f
。x
、y
和 z
值都是浮点数,在返回值中表示顶点在裁剪空间中的位置。
- 返回
(0, 0, 0, 1)
的静态值,从技术上来说,您具有有效的顶点着色器,尽管该 GPU 永远不会显示任何内容,因为 GPU 发现它生成的三角形只是一个点,然后会舍弃它。
index.html (createShaderModule 代码)
@vertex
fn vertexMain() -> @builtin(position) vec4f {
return vec4f(0, 0, 0, 1); // (X, Y, Z, W)
}
您只是想使用您创建的缓冲区中的数据,为此,您需要使用 @location()
属性和类型(与您在 vertexBufferLayout
中描述的相匹配)声明函数的参数。您指定的 shaderLocation
为 0
,因此在 WGSL 代码中,请使用 @location(0)
标记该参数。您还将格式定义为 float32x2
(2D 矢量),因此在 WGSL 中,您的参数为 vec2f
。您可以随意为其命名,但由于这些位置代表您的顶点位置,因此 pos 这类名称似乎很自然。
- 将着色器函数更改为以下代码:
index.html (createShaderModule 代码)
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(0, 0, 0, 1);
}
现在,您需要返回该位置。由于位置是 2D 矢量,而返回类型是 4D 矢量,因此您必须稍微更改它。您要从位置参数中获取两个组件,并将其置于返回矢量的前两个组件中,使最后两个组件分别保持为 0
和 1
。
- 通过明确说明要使用的位置组成部分,返回正确的位置:
index.html (createShaderModule 代码)
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos.x, pos.y, 0, 1);
}
不过,由于此类映射在着色器中很常见,因此您可以轻松地将位置矢量作为第一个参数传入到方便的位置,表示含义相同。
- 使用以下代码重写
return
语句:
index.html (createShaderModule 代码)
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
这是您的初始顶点着色器!这非常简单,只是在一开始有效传递位置信息,但这样做是很好的切入点。
定义 fragment 着色器
接下来是 fragment 着色器。片段着色器的运作方式与顶点着色器的运作方式非常相似,但系统会为绘制的每个像素调用这些着色器,而不是为每个顶点调用。
总是在顶点着色器之后调用 Fragment 着色器。GPU 接受顶点着色器的输出并对其进行三角化,基于三点集创建三角形。然后,它会计算出该三角形中包含哪些输出颜色附件的像素,并为每个像素调用一次 fragment 着色器,从而对每个三角形进行光栅化。片段着色器会返回一种颜色(通常从顶点着色器发送给它的值以及纹理等资源计算得出),GPU 会写入这些颜色。
与顶点着色器一样,片段着色器以极其并行的方式执行。它们在输入和输出方面比顶点着色器更灵活,但您可以认为他们只需为每个三角形的每个像素返回一种颜色。
WGSL 片段着色器函数以 @fragment
属性表示,还会返回 vec4f
。但在这种情况下,矢量代表颜色,而不是位置。您需要为返回值赋予 @location
属性,以指明返回的颜色要写入 beginRenderPass
中的哪个 colorAttachment
。由于您只有 1 个连接,因此位置为 0。
- 创建一个如下所示的空
@fragment
函数:
index.html (createShaderModule 代码)
@fragment
fn fragmentMain() -> @location(0) vec4f {
}
返回的矢量的四个组成部分是红色、绿色、蓝色和 Alpha 值,其解释方式与您之前在 beginRenderPass
中设置的 clearValue
完全相同。因此,vec4f(1, 0, 0, 1)
为亮红色,看似正合适您的方形颜色。不过,您可以随意设置自己喜欢的颜色!
- 设置如下所示的返回矢量:
index.html (createShaderModule 代码)
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1); // (Red, Green, Blue, Alpha)
}
这就是完整的 fragment 着色器!这并不是很有趣;它只是将每个三角形的每一个像素都设为红色,但目前还足以满足需求。
重复一下,添加上述着色器代码后,您的 createShaderModule
调用现在如下所示:
index.html
const cellShaderModule = device.createShaderModule({
label: 'Cell shader',
code: `
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1);
}
`
});
创建渲染流水线
着色器模块不能用于自行渲染。相反,您必须将其用作通过调用 device.createRenderPipeline() 创建的 GPURenderPipeline
的一部分来使用。渲染管道可控制如何绘制几何图形,包括如何使用着色器、如何解读顶点缓冲区中的数据、应渲染的几何图形类型(线条、点、三角形等)!
渲染管道是整个 API 中最复杂的对象,但是不用担心!您可以向其传递的大多数值都是可选的,您只需提供几个值即可开始。
- 创建一个渲染流水线,如下所示:
index.html
const cellPipeline = device.createRenderPipeline({
label: "Cell pipeline",
layout: "auto",
vertex: {
module: cellShaderModule,
entryPoint: "vertexMain",
buffers: [vertexBufferLayout]
},
fragment: {
module: cellShaderModule,
entryPoint: "fragmentMain",
targets: [{
format: canvasFormat
}]
}
});
每个流水线都需要一个 layout
来描述流水线需要的输入类型(顶点缓冲区除外),但您没有这样的输入。幸运的是,您现在可以传递 "auto"
,流水线会从着色器构建自己的布局。
接下来,您必须提供有关 vertex
阶段的详细信息。module
是包含您的顶点着色器的 GPUShader 模块,entryPoint
是在着色器代码中为每次顶点调用调用的函数的名称。(单个着色器模块中可包含多个 @vertex
和 @fragment
函数!)buffers 是一个 GPUVertexBufferLayout
对象数组,用于描述如何将数据打包到您与此流水线使用的顶点缓冲区中。幸运的是,您之前在 vertexBufferLayout
中定义了此内容!您将该代码传递到此函数。
最后是 fragment
阶段的详细信息。它还包括着色器模块和 entryPoint,就像顶点阶段一样。最后一步是定义此流水线一起使用的 targets
。这是一个字典数组,可提供流水线输出的颜色附件的详细信息(例如纹理 format
)。这些详细信息需要匹配此流水线一起使用的任何渲染通道的 colorAttachments
中提供的纹理。渲染通道使用画布上下文中的纹理,并使用您在 canvasFormat
中保存的值作为其格式,因此此处传递的是相同的格式。
这还没有接近您在创建渲染管道时可以指定的所有选项,但足以满足此 Codelab 的需求!
绘制方形
至此,您已经有了绘制方形所需的一切!
- 若要绘制方块,请回退到
encoder.beginRenderPass()
和pass.end()
对的调用,然后在它们之间添加以下新命令:
index.html
// After encoder.beginRenderPass()
pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2); // 6 vertices
// before pass.end()
这会为 WebGPU 提供绘制方形所需的全部信息。首先,您需要使用 setPipeline()
来指明应使用哪个流水线进行绘制。其中包括使用的着色器、顶点数据的布局和其他相关状态数据。
接下来,使用包含方形的顶点的缓冲区调用 setVertexBuffer()
。您使用 0
调用它,因为此缓冲区对应于当前流水线的 vertex.buffers
定义中的第 0 个元素。
最后,您调用了 draw()
,在进行之前的所有设置后,这似乎很奇怪。您只需要传入应该渲染的顶点数,该顶点会从当前设置的顶点缓冲区中提取,并使用当前设置的流水线进行解释。您只需将其硬编码为 6
,但从顶点数组(12 个浮点数 / 每个顶点 2 个坐标 == 6 个顶点)中计算得出,这意味着,如果您决定将正方形替换成圆形,手动更新的次数会变少。
- 刷新屏幕,并最终看到所有辛勤工作的结果:一个彩色大方形。
5. 绘制网格
首先,请花点时间祝贺您!对大多数 GPU API 来说,在屏幕上获取几何图形的前几个部分通常是最难的措施之一。您在这一步所做的所有步骤都能用更小的步骤完成,让您更轻松地验证进度。
在本部分中,您将了解:
- 如何通过 JavaScript 将变量(称为“uniform”)传递到着色器。
- 如何使用 uniform 更改渲染行为。
- 如何使用示例来绘制同一几何图形的许多不同变体。
定义网格
若要渲染网格,您需要掌握一些关于该网格的非常基础的信息。其中包含多少个单元格,宽度和高度均包含?这由开发者决定,但为简单起见,请将网格视为方形(宽度和高度相同),并使用二的幂。(这使得部分计算更加轻松。)您最终会想要将图表放大一些,但在本部分的其余部分,将网格大小设置为 4x4,因为这样可以更轻松地演示此部分使用的一些数学概念。扩大规模!
- 通过在 JavaScript 代码顶部添加一个常量,定义网格大小。
index.html
const GRID_SIZE = 4;
接下来,您需要更新方形的渲染方式,以便在画布上放置 GRID_SIZE
次GRID_SIZE
。这意味着方形需要小得多,需要包含大量方形。
现在,您可以方法之一是让顶点缓冲区明显变大,并定义大小合适的 GRID_SIZE
倍数GRID_SIZE
方块。事实上,这方面的代码并不是很糟糕!只需要添加几个循环和一些数学概念即可。但这也无法充分利用 GPU,占用的内存也过多,无法实现此效果。本部分介绍了一种更适合 GPU 的方法。
创建统一缓冲区
首先,您需要将所选网格大小传达给着色器,因为该着色器会更改着色器的显示方式。只需将尺寸硬编码到着色器,但这意味着每当想要更改网格大小时,您都必须重新创建着色器并渲染管道,但开销非常高。更好的方法是以制服的形式向着色器提供网格大小。
您在前面已经了解到,顶点缓冲区中会有一个不同的值传递给每次顶点着色器。uniform 是指缓冲区中每次调用时都使用的值。它们对于传达几何形状(例如其位置)、完整动画帧(例如当前时间)甚至是应用的整个生命周期(例如用户偏好设置)很有用。
- 通过添加以下代码创建一个统一缓冲区:
index.html
// Create a uniform buffer that describes the grid.
const uniformArray = new Float32Array([GRID_SIZE, GRID_SIZE]);
const uniformBuffer = device.createBuffer({
label: "Grid Uniforms",
size: uniformArray.byteLength,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(uniformBuffer, 0, uniformArray);
这个代码应该非常熟悉,因为它与您之前创建顶点缓冲区时所用的代码完全相同!这是因为 uniform 通过顶点相同的 GPUBuffer 对象传达给 WebGPU API,主要区别在于这次的 usage
包含 GPUBufferUsage.UNIFORM
,而不是 GPUBufferUsage.VERTEX
。
在着色器中使用 uniform
- 通过添加以下代码来定义 uniform:
index.html(createShaderModule 调用)
// At the top of the `code` string in the createShaderModule() call
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos / grid, 0, 1);
}
// ...fragmentMain is unchanged
这样会在着色器中定义一个名为 grid
的 uniform,它是一个 2D 浮点矢量,与您刚刚复制到 uniform 缓冲区的数组匹配。该图还规定,制服在 @group(0)
和 @binding(0)
处绑定。稍后您将了解这些值的含义。
然后,在着色器代码的其他位置,您可以根据需要使用网格矢量。在此代码中,您将顶点位置除以网格矢量。由于 pos
是一个 2D 矢量,而 grid
是一个 2D 矢量,WGSL 会执行组件级除法。换句话说,结果与显示 vec2f(pos.x / grid.x, pos.y / grid.y)
相同。
这些类型的矢量操作在 GPU 着色器中很常见,因为许多渲染和计算技术都依赖于它们。
这表示您所使用的方形大小为原始大小的四分之一(如果您使用网格大小为 4 的方形)。如果您希望将 4 个行放入一行或一列,则非常适合这样做!
创建绑定组
不过,在着色器中声明 uniform 并不会将其与您创建的缓冲区连接起来。为此,您需要创建并设置绑定群组。
绑定组是您希望同时可供着色器访问的资源的集合。它可以包含多种类型的缓冲区(例如统一缓冲区),以及其他资源(如纹理和采样器),但本文未涵盖但它们是 WebGPU 渲染技术的常见部分。
- 在创建统一缓冲区和渲染管道后,添加以下代码,使用统一缓冲区创建绑定组:
index.html
const bindGroup = device.createBindGroup({
label: "Cell renderer bind group",
layout: cellPipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}],
});
除了现在标准的 label
之外,您还需要一个 layout
来描述此绑定组包含的资源类型。我们会在后续步骤中深入探讨这一问题,但现在您可以愉快地询问流水线是否提供了绑定组布局,因为您是使用 layout: "auto"
创建的流水线。这会使流水线根据您在着色器代码本身中声明的绑定自动创建绑定组布局。在本例中,您需要向 getBindGroupLayout(0)
发出请求,其中 0
对应于您在着色器中输入的 @group(0)
。
指定布局后,您需要提供 entries
数组。每个条目都是一个包含至少以下值的字典:
binding
,对应于您在着色器中输入的@binding()
值。在此示例中为0
。resource
,即要在指定绑定索引处向变量公开的实际资源。在此例中是统一缓冲区。
该函数会返回一个 GPUBindGroup
,这是一个不透明的不可变句柄。绑定群组一经创建,您便无法更改所指向的资源,但您可以更改这些资源的内容。例如,如果您将 uniform 缓冲区更改为包含新的网格大小,那么今后使用此绑定组进行绘制时都会体现这一点。
绑定绑定组
现在,绑定组已创建,您仍需要指示 WebGPU 在绘图时使用它。幸运的是,这非常简单。
- 返回到呈现通道,并在
draw()
方法之前添加下面这行新代码:
index.html
pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.setBindGroup(0, bindGroup); // New line!
pass.draw(vertices.length / 2);
作为第一个参数传递的 0
对应于着色器代码中的 @group(0)
。您是说,@group(0)
中的每个 @binding
都会使用此绑定组中的资源。
现在,统一缓冲区已公开给着色器!
- 刷新页面,然后您应该会看到如下内容:
太棒了!您的面积现在是原来的 1/4!这并不多,但可以看出您的 uniform 实际上已应用,且着色器现在可以访问网格的大小。
在着色器中操纵几何图形
现在,您可以在着色器中引用网格大小,接下来可以开始做一些工作来操纵您渲染的几何形状,使其适合网格样式。为此,请认真考虑你想要达成的目标。
您需要从概念上将画布拆分为多个单元格。为遵循惯例,X 轴向右移动,而 Y 轴向上移动,那么假设第一个单元格位于画布左下角。这会得到如下所示的布局,其中您的当前方形几何图形位于中间:
您的挑战是在着色器中寻找一种方法,让您在给定单元格坐标的任何单元格中放置方形几何图形。
首先,您可以看到方形没有与任何单元格对齐良好,因为它被定义为围绕画布中心。您需要使方形的单元格偏移 0.5 个单元格,以使其在单元格内居中对齐。
解决此问题的一种方法是更新方形的顶点缓冲区。例如,通过移动顶点,使右下角位于 (0.1, 0.1) 而非 (-0.8, -0.8),这样就能更好地将这个方块与单元格边界对齐。但是,由于您可以在着色器中完全控制顶点的处理方式,因此使用着色器代码将这些顶点微移到位就非常容易!
- 使用以下代码修改顶点着色器模块:
index.html(createShaderModule 调用)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
// Add 1 to the position before dividing by the grid size.
let gridPos = (pos + 1) / grid;
return vec4f(gridPos, 0, 1);
}
这样一来,先将每个顶点向上和向左移动(记住,裁剪空间的一半),然后再将其除以网格大小。结果就是一个离网格原点非常对齐的方形。
接下来,由于您画布的坐标系将中心 (0, 0) 放在左下角,并将 (-1, -1) 放在左下角,并且您希望 (0, 0) 位于左下角,因此在将网格位置移到角落之后,您需要将其转换为 (-1, -1) 。
- 翻译您的几何图形位置,如下所示:
index.html(createShaderModule 调用)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
// Subtract 1 after dividing by the grid size.
let gridPos = (pos + 1) / grid - 1;
return vec4f(gridPos, 0, 1);
}
现在,您的方块已在单元格 (0, 0) 中完美放置!
如果您想将其放入其他单元格,该怎么办?通过在着色器中声明 cell
矢量并使用 let cell = vec2f(1, 1)
等静态值填充该数字,就可以做到这一点。
如果您将该参数添加到 gridPos
中,则会撤消算法中的 - 1
,那么这不是您想要的。相反,您在每个单元格中只能移动一个网格单元(画布的四分之一)。看来您需要再做一次除以 grid
的操作!
- 更改网格位置,如下所示:
index.html(createShaderModule 调用)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
let cell = vec2f(1, 1); // Cell(1,1) in the image above
let cellOffset = cell / grid; // Compute the offset to cell
let gridPos = (pos + 1) / grid - 1 + cellOffset; // Add it here!
return vec4f(gridPos, 0, 1);
}
如果您现在刷新,会看到以下内容:
嗯,完全不符合您的预期。
这是因为画布坐标从 -1 到 +1,实际上是跨 2 个单位。这意味着,如果要将顶点的四分之一处移动到画布上,则必须将画布移动 0.5 个单位。在使用 GPU 坐标进行推理时,这是一个很容易犯的错误!幸运的是,解决方法同样简单。
- 用偏移值乘以 2,如下所示:
index.html(createShaderModule 调用)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
let cell = vec2f(1, 1);
let cellOffset = cell / grid * 2; // Updated
let gridPos = (pos + 1) / grid - 1 + cellOffset;
return vec4f(gridPos, 0, 1);
}
这为您提供了所需的功能。
屏幕截图如下所示:
此外,您现在可以将 cell
设置为网格边界内的任何值,然后刷新以查看在所需位置呈现方形。
绘制实例
现在,只要用数学题将其放在所需位置,下一步就是在网格的每个单元格中呈现一个方形。
其中一种方式是将单元格坐标写入统一缓冲区,然后为网格中的每个方块调用一次绘制,每次都更新制服。不过,这样做会非常慢,因为 GPU 每次都需要等待 JavaScript 写入新坐标。尽可能减少 GPU 等待系统其他部分的时间,是利用 GPU 获得良好性能的关键之一!
您可以改为使用称作“技巧”的技术。重新排列是让 GPU 只需调用一次 draw
即可绘制具有相同几何图形的多个副本的方法,这比每个副本调用一次 draw
快得多。几何图形的每个副本都称为“实例”。
- 如需告知 GPU 您希望方块足够实例填充网格,请在现有绘制调用中添加一个参数:
index.html
pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);
这将告知系统您要绘制方形 16 (GRID_SIZE * GRID_SIZE
) 的六个 (vertices.length / 2
) 顶点。但是,刷新页面后,您仍然会看到以下内容:
为什么?这是因为,您在同一个地点绘制了所有 16 个方块。您需要在着色器中具有一些其他逻辑,以便按实例重新定位几何图形。
在着色器中,除了来自顶点缓冲区的 pos
等顶点属性之外,您还可以访问 WGSL 的内置值。这些是 WebGPU 计算的值,其中一个值就是 instance_index
。instance_index
是从 0
到 number of instances - 1
的未签名 32 位编号,可用作着色器逻辑的一部分。对于属于同一实例的每个已处理顶点,其值都相同。这意味着您的顶点着色器会调用六次,instance_index
为 0
,每个顶点缓冲区中的每个位置都会调用一次。接着,instance_index
为 1
,然后是 6 次;instance_index
为 2
,依此类推。
若要查看其实际效果,您必须将 instance_index
内置到着色器输入中。以与位置相同的方式执行此操作,但不要使用 @location
属性来标记,而应使用 @builtin(instance_index)
,然后根据需要命名参数。(您可以将其命名为 instance
以匹配示例代码。)然后,将其用作着色器逻辑的一部分!
- 使用
instance
代替单元格坐标:
index.html
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) ->
@builtin(position) vec4f {
let i = f32(instance); // Save the instance_index as a float
let cell = vec2f(i, i);
let cellOffset = cell / grid * 2; // Updated
let gridPos = (pos + 1) / grid - 1 + cellOffset;
return vec4f(gridPos, 0, 1);
}
如果现在刷新,你确实会看到多个方形!但无法查看全部 16 张。
这是因为您生成的单元格坐标为 (0, 0)、(1, 1)、(2, 2)...一直到 (15, 15),但只有前四个坐标适合画布。若要创建所需的网格,您需要转换 instance_index
,使每个索引映射到网格中的唯一单元格,如下所示:
这项计算非常直观。对于每个单元格的 X 值,您需要 instance_index
的模数和网格宽度(您可以使用 %
运算符在 WGSL 中执行)。对于每个单元格的 Y 值,您需要将 instance_index
除以网格宽度,并舍弃所有小数部分。您可以使用 WGSL 的 floor()
函数来实现此目的。
- 更改计算方式,如下所示:
index.html
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) ->
@builtin(position) vec4f {
let i = f32(instance);
// Compute the cell coordinate from the instance_index
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (pos + 1) / grid - 1 + cellOffset;
return vec4f(gridPos, 0, 1);
}
对代码进行更新后,您终于有了期待已久的方块网格!
- 现在可以正常运行了,请返回并增加网格大小!
index.html
const GRID_SIZE = 32;
塔达!实际上,现在您可以将此网格设为非常大,您的平均 GPU 可以很好地处理网格。在遇到任何 GPU 性能瓶颈之前,您将不会再看到各个方块。
6. 额外的功劳:使其更加绚丽多彩!
此时,您可以轻松跳到下一部分,因为您已经为本 Codelab 的其余部分奠定了基础。然而,虽然方形网格网格颜色相同,但也很令人兴奋,不是很有趣。幸运的是,你可以通过更多的数学和着色器代码让亮度变高!
在着色器中使用结构体
到目前为止,您已从顶点着色器中传递了一段数据:转换后的位置。但实际上,您可以从顶点着色器返回更多数据,然后在 Fragment 着色器中使用。
向顶点着色器传递数据的唯一方法是返回这些数据。若要返回位置,必须始终使用顶点着色器。因此,如果您要返回任何其他数据,还需要将其存储在结构体中。WGSL 中的结构体是包含一个或多个对象属性的已命名对象类型。您可以使用 @builtin
和 @location
等属性标记属性。您可以在任何函数之外声明它们,然后可以根据需要传入和传出函数的实例。以当前的顶点着色器为例:
index.html(createShaderModule 调用)
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) ->
@builtin(position) vec4f {
let i = f32(instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (pos + 1) / grid - 1 + cellOffset;
return vec4f(gridPos, 0, 1);
}
- 使用结构体为函数输入和输出表达相同的内容:
index.html(createShaderModule 调用)
struct VertexInput {
@location(0) pos: vec2f,
@builtin(instance_index) instance: u32,
};
struct VertexOutput {
@builtin(position) pos: vec4f,
};
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(input: VertexInput) -> VertexOutput {
let i = f32(input.instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
var output: VertexOutput;
output.pos = vec4f(gridPos, 0, 1);
return output;
}
请注意,这需要您使用 input
来引用输入位置和实例索引,并且首次返回的结构体必须声明为变量并设置其单个属性。在此示例中,它并没有太大的区别,实际上会让着色器函数变得更长,但随着着色器越来越复杂,使用结构体可以帮助整理数据。
在顶点和 Fragment 函数之间传递数据
温馨提示:您的 @fragment
函数要尽可能简单:
index.html(createShaderModule 调用)
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1);
}
您不会接受任何输入,而是以纯色(红色)作为输出。不过,如果着色器对它正在着色的几何图形有更多了解,您就可以使用额外的数据来让它更有趣。例如,如果您想根据方形的每个单元格坐标更改颜色,该怎么办?@vertex
阶段知道哪个单元格正在渲染;您只需将其传递给 @fragment
阶段。
若要在顶点和 fragment 阶段之间传递任何数据,您需要将其包含在我们选择的 @location
的输出结构体中。由于您要传递单元格坐标,因此请将其添加到前面的 VertexOutput
结构体中,然后在返回函数之前在 @vertex
函数中进行设置。
- 更改顶点着色器的返回值,如下所示:
index.html(createShaderModule 调用)
struct VertexInput {
@location(0) pos: vec2f,
@builtin(instance_index) instance: u32,
};
struct VertexOutput {
@builtin(position) pos: vec4f,
@location(0) cell: vec2f, // New line!
};
@group(0) @binding(0) var<uniform> grid: vec2f;
@vertex
fn vertexMain(input: VertexInput) -> VertexOutput {
let i = f32(input.instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let cellOffset = cell / grid * 2;
let gridPos = (input.pos + 1) / grid - 1 + cellOffset;
var output: VertexOutput;
output.pos = vec4f(gridPos, 0, 1);
output.cell = cell; // New line!
return output;
}
- 在
@fragment
函数中,通过添加具有相同@location
的参数来接收值。(名称不一定一致,但如果一致的话,更容易记录!)
index.html(createShaderModule 调用)
@fragment
fn fragmentMain(@location(0) cell: vec2f) -> @location(0) vec4f {
// Remember, fragment return values are (Red, Green, Blue, Alpha)
// and since cell is a 2D vector, this is equivalent to:
// (Red = cell.x, Green = cell.y, Blue = 0, Alpha = 1)
return vec4f(cell, 0, 1);
}
- 或者,您也可以改用结构体:
index.html(createShaderModule 调用)
struct FragInput {
@location(0) cell: vec2f,
};
@fragment
fn fragmentMain(input: FragInput) -> @location(0) vec4f {
return vec4f(input.cell, 0, 1);
}
- 另一个替代方案**,** 在代码中,这两个函数都是在同一个着色器模块中定义的,就是重复使用
@vertex
阶段的输出结构!这样就能轻松传递值,因为名称和位置自然一致。
index.html(createShaderModule 调用)
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
return vec4f(input.cell, 0, 1);
}
无论您选择哪种模式,结果都是您可以访问 @fragment
函数中的单元格编号,并且能够使用它来影响颜色。使用上述任何代码,输出如下所示:
现在确实有更多颜色可用了,但颜色看起来不太好。您可能想知道为什么只有左侧和底部的行不同。这是因为,从 @fragment
函数返回的颜色值预计每个通道的范围在 0 到 1 之间,超出该范围的值都将被强制设为下限值。而单元格的值为 0 到 32 之间的每个轴值。在这里,您可以看到,第一行和此列在红色或绿色通道上立即达到 1 的完整值,并且其后的每个单元格都限制到相同的值。
如果希望不同颜色之间的转换更顺畅,您需要为每个颜色通道返回一个小数值,最好从零开始,沿每个轴结束一个,这意味着又除以 grid
!
- 更改 fragment 着色器,如下所示:
index.html(createShaderModule 调用)
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
return vec4f(input.cell/grid, 0, 1);
}
刷新页面,您就可以看到新代码为您的整个网格提供更加美观的渐变效果。
这无疑是一项改进,但遗憾的是,左下角有一个很暗的角落,其网格变成了黑色。当您开始进行《Game of Life》模拟游戏时,难以看清的网格部分将掩盖正在发生的事情。最好让它变亮。
幸运的是,您可以使用一个完全未使用的颜色通道(蓝色)。理想效果是,让其他颜色最暗的蓝色调亮,然后随着其他颜色的强度逐渐变淡。最简单的方法是将渠道的起始值设为 1,再减去一个单元格的值。可为 c.x
或 c.y
。请尝试以下两种方法,然后选择一种!
- 向 fragment 着色器添加更亮的颜色,如下所示:
createShaderModule 调用
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
let c = input.cell / grid;
return vec4f(c, 1-c.x, 1);
}
结果看起来很不错!
这不是关键步骤!但由于其外观更美观,因此已包含在相应的检查点源文件中,而此 Codelab 中的其他屏幕截图反映了这个更丰富多彩的网格。
7. 管理单元格状态
接下来,您需要根据存储在 GPU 上的某种状态来控制网格渲染哪些单元格。这对于最终的模拟非常重要!
您只需要每个单元格的开启信号,因此任何允许您存储几乎任何值类型的选项都适用。您可能会认为,这是统一缓冲区的另一个用例!虽然您可以做到这一点,但这样做会更困难,因为统一缓冲区的大小有限,不支持动态大小的数组(您必须在着色器中指定数组大小),并且计算着色器无法写入这些数据。最后一项是最有问题的,因为您希望在计算着色器上对 GPU 进行生命游戏模拟。
幸运的是,还有一种缓冲区选项可以避免所有这些限制。
创建存储缓冲区
存储缓冲区是可以在缓冲区着色器中和在顶点着色器中读取和写入的通用缓冲区。它们可能很大,并且在着色器中不需要声明特定的大小,这使得它们更像是一般内存。此设置用于存储单元格状态。
- 如需为单元格状态创建存储缓冲区,请使用(到目前为止)已经开始熟悉的缓冲区创建代码段:
index.html
// Create an array representing the active state of each cell.
const cellStateArray = new Uint32Array(GRID_SIZE * GRID_SIZE);
// Create a storage buffer to hold the cell state.
const cellStateStorage = device.createBuffer({
label: "Cell State",
size: cellStateArray.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
与顶点和统一缓冲区一样,请使用适当的大小调用 device.createBuffer()
,然后务必指定 GPUBufferUsage.STORAGE
的用法。
您可以像以前一样填充该缓冲区,只需填入相同大小的 TypedArray 并调用 device.queue.writeBuffer()
即可。由于您想查看缓冲区对网格的效果,请先为缓冲区填充一些可预测的内容。
- 使用以下代码激活第三个单元格:
index.html
// Mark every third cell of the grid as active.
for (let i = 0; i < cellStateArray.length; i += 3) {
cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage, 0, cellStateArray);
在着色器中读取存储缓冲区
接下来,更新着色器,以便在渲染网格之前查看存储缓冲区的内容。这看起来与之前添加制服的方式非常相似。
- 使用以下代码更新着色器:
index.html
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellState: array<u32>; // New!
首先,添加绑定点,其紧贴着网格制服下方。您希望保留与 grid
统一相同的 @group
,但 @binding
数值需要不同。var
类型为 storage
,以便反映不同类型的缓冲区,而不是单个矢量,您为 cellState
提供的类型是一个 u32
数组(以便在 JavaScript 中匹配 Uint32Array
)。
接下来,在 @vertex
函数的正文中,查询单元格的状态。由于状态存储在存储缓冲区的平面数组中,因此您可以使用 instance_index
查找当前单元格的值!
如果状态显示某个单元格处于非活动状态,如何将其关闭?由于从数组中获取的活跃状态和非活跃状态为 1 或 0,因此您可以按活跃状态缩放几何图形!将大小缩放 1 会使几何图形保持不变,而将大小缩减 0 会使几何图形合拢为单点,然后 GPU 便会舍弃该点。
- 更新着色器代码,以根据单元格的活跃状态缩放位置。状态值必须将状态转换为
f32
,以满足 WGSL 的类型安全要求:
index.html
@vertex
fn vertexMain(@location(0) pos: vec2f,
@builtin(instance_index) instance: u32) -> VertexOutput {
let i = f32(instance);
let cell = vec2f(i % grid.x, floor(i / grid.x));
let state = f32(cellState[instance]); // New line!
let cellOffset = cell / grid * 2;
// New: Scale the position by the cell's active state.
let gridPos = (pos*state+1) / grid - 1 + cellOffset;
var output: VertexOutput;
output.pos = vec4f(gridPos, 0, 1);
output.cell = cell;
return output;
}
将存储缓冲区添加到绑定组
在将单元格状态生效之前,请将存储缓冲区添加到绑定组中。由于 JavaScript 与统一缓冲区属于同一 @group
,因此请将其添加到 JavaScript 代码中的同一绑定组中。
- 添加存储缓冲区,如下所示:
index.html
const bindGroup = device.createBindGroup({
label: "Cell renderer bind group",
layout: cellPipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
},
// New entry!
{
binding: 1,
resource: { buffer: cellStateStorage }
}],
});
确保新条目的 binding
与着色器中相应值的 @binding()
匹配!
设置好后,您应该能够刷新并查看模式显示在网格中了。
使用乒乓球缓冲区模式
大多数模拟(例如您正在构建的模拟)通常使用其状态的至少两个副本。在模拟的每个步骤中,它们会从状态的一个副本读取,并向另一个状态写入数据。然后,在下一步中将其翻转并从之前写入的状态读取。这通常称为“乒乓球”模式,因为最新版本的状态会在每个步骤之间来回切换状态。
为什么需要这么做?我们来看一个简化的示例:假设您要编写一个非常简单的模拟,在这一步中,您在每个步骤中移动一个块中的任何活跃块。为便于理解,您可以使用 JavaScript 定义数据和模拟:
// Example simulation. Don't copy into the project!
const state = [1, 0, 0, 0, 0, 0, 0, 0];
function simulate() {
for (let i = 0; i < state.length-1; ++i) {
if (state[i] == 1) {
state[i] = 0;
state[i+1] = 1;
}
}
}
simulate(); // Run the simulation for one step.
但是,如果您运行该代码,活跃单元格会一直移至数组末尾!为什么?由于您始终在原位更新状态,因此会将活动单元格向右移动,然后查看下一个单元格,嘿!处于有效状态!最好再次将其向右移动。如果您在更改数据的同时观察这些数据,就会破坏结果。
使用 ping 模式,您可以确保始终仅使用最后一步的结果来执行下一步操作。
// Example simulation. Don't copy into the project!
const stateA = [1, 0, 0, 0, 0, 0, 0, 0];
const stateB = [0, 0, 0, 0, 0, 0, 0, 0];
function simulate(in, out) {
out[0] = 0;
for (let i = 1; i < in.length; ++i) {
out[i] = in[i-1];
}
}
// Run the simulation for two step.
simulate(stateA, stateB);
simulate(stateB, stateA);
- 您可以通过更新存储缓冲区分配在您自己的代码中使用此模式,以便创建两个完全相同的缓冲区:
index.html
// Create two storage buffers to hold the cell state.
const cellStateStorage = [
device.createBuffer({
label: "Cell State A",
size: cellStateArray.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
}),
device.createBuffer({
label: "Cell State B",
size: cellStateArray.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
})
];
- 为直观呈现两个缓冲区之间的差异,请使用不同的数据填充这两个缓冲区:
index.html
// Mark every third cell of the first grid as active.
for (let i = 0; i < cellStateArray.length; i+=3) {
cellStateArray[i] = 1;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);
// Mark every other cell of the second grid as active.
for (let i = 0; i < cellStateArray.length; i++) {
cellStateArray[i] = i % 2;
}
device.queue.writeBuffer(cellStateStorage[1], 0, cellStateArray);
- 如需在渲染中显示不同的存储缓冲区,请更新绑定组以使其也具有两个不同的变体:
index.html
const bindGroups = [
device.createBindGroup({
label: "Cell renderer bind group A",
layout: cellPipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}, {
binding: 1,
resource: { buffer: cellStateStorage[0] }
}],
}),
device.createBindGroup({
label: "Cell renderer bind group B",
layout: cellPipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}, {
binding: 1,
resource: { buffer: cellStateStorage[1] }
}],
})
];
设置渲染循环
到目前为止,您每次页面刷新只执行了一次绘制,但现在您想显示一段时间内的数据更新。为此,您需要一个简单的渲染循环。
渲染循环是无限的重复循环,以特定间隔将您的内容绘制到画布。许多游戏和其他需要添加动画效果的内容可以使用 requestAnimationFrame()
函数以与屏幕刷新相同的速率安排回调(每秒 60 次)。
此应用同样可以使用该功能,但在这种情况下,您可能需要在较长的步骤中进行更新,以便您可以更轻松地模拟模拟的操作。请改为自行管理循环,以便控制模拟的更新频率。
- 首先,为我们的模拟选择一个更新速率(200 毫秒比较好,但您也可以根据需要放慢速度或更快),然后跟踪模拟的完成步骤。
index.html
const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
- 然后,将您当前用于渲染的所有代码移至一个新函数中。使用
setInterval()
安排该函数在所需的时间间隔内重复执行。请确保该函数还更新了步数,并使用它来选择要绑定的两个绑定组中的哪一个。
index.html
// Move all of our rendering code into a function
function updateGrid() {
step++; // Increment the step count
// Start a render pass
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
clearValue: { r: 0, g: 0, b: 0.4, a: 1.0 },
storeOp: "store",
}]
});
// Draw the grid.
pass.setPipeline(cellPipeline);
pass.setBindGroup(0, bindGroups[step % 2]); // Updated!
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);
// End the render pass and submit the command buffer
pass.end();
device.queue.submit([encoder.finish()]);
}
// Schedule updateGrid() to run repeatedly
setInterval(updateGrid, UPDATE_INTERVAL);
现在,运行应用时,您会看到画布会在显示您创建的两个状态缓冲区之间来回切换。
至此,您已经完成了渲染方面的许多工作!您已准备就绪,可以显示下一步中构建的 Game of Life 模拟输出,即最终开始使用计算着色器。
显然,WebGPU 的渲染功能远不止您在此处探索的那一小块切片,但其余部分不在本 Codelab 的讨论范围之内。不过,它希望能让您大致了解 WebGPU 渲染技术的运作方式,帮助您更轻松地掌握 3D 渲染等更高级的技术。
8. 运行模拟
现在,我们要讨论的最后一个难题:在计算着色器中执行“人生游戏”模拟!
最后使用计算着色器!
在此 Codelab 中,您已经了解了计算着色器的抽象概念,但究竟是什么?
计算着色器与顶点和 fragment 着色器类似,因为它们在 GPU 上以极高并行性运行,但与其他两个着色器阶段不同,它们没有一组特定的输入和输出。您正在专门从您选择的来源(例如存储缓冲区)读取和写入数据。这意味着,您必须告知自己所需的着色器函数调用次数,而不必为每个顶点、实例或像素执行一次。然后,当您运行着色器时,系统会告知系统正在处理哪个调用,您可以决定要访问哪些数据,以及从何处执行哪些操作。
必须在着色器模块中创建计算着色器,就像顶点和片段着色器一样,因此请先将其添加到代码中。您可能已经猜到,鉴于您已实现的其他着色器的结构,需要使用 @compute
属性为计算着色器的主函数添加标记。
- 使用以下代码创建计算着色器:
index.html
// Create the compute shader that will process the simulation.
const simulationShaderModule = device.createShaderModule({
label: "Game of Life simulation shader",
code: `
@compute
fn computeMain() {
}`
});
由于 GPU 经常用于 3D 图形,因此计算着色器时,您需要请求将着色器沿 X 轴、Y 轴和 Z 轴被调用特定次数。这样,您就可以非常轻松地分派符合 2D 或 3D 网格的工作,这非常适合您的用例!您希望将此模拟着色器调用 GRID_SIZE
次 GRID_SIZE
一次,每个模拟单元格调用一次。
由于 GPU 硬件架构的性质,此网格可分为多个工作组。工作组具有 X、Y 和 Z 尺寸,虽然每个尺寸可以 1,但扩大工作组通常有一定的性能优势。对于着色器,选择任意大小的 8 倍工作组(8 倍)即可。这对于跟踪 JavaScript 代码很有用。
- 为工作组大小定义常量,如下所示:
index.html
const WORKGROUP_SIZE = 8;
此外,您还需要将工作组大小添加到着色器函数本身,您将使用 JavaScript 模板字面量执行此操作,以便您轻松使用刚刚定义的常量。
- 向着色器函数添加工作组大小,如下所示:
index.html(计算 createShaderModule 调用)
@compute
@workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}) // New line
fn computeMain() {
}
这将告知着色器使用此函数完成的操作是在 (8 x 8 x 1) 组完成的。(您忽略的任何轴均默认为 1,尽管您必须至少指定 X 轴。)
与其他着色器阶段一样,您可以接受各种 @builtin
值作为计算着色器函数的输入,以便告知您正在进行的调用并确定您需要执行的操作。
- 添加一个
@builtin
值,如下所示:
index.html(计算 createShaderModule 调用)
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
您将传入 global_invocation_id
内置值,这是三维无符号整数向量,可让您了解自己在着色器调用网格中的什么位置。您可以为网格中的每个单元格运行此着色器一次。您会看到 (0, 0, 0)
、(1, 0, 0)
、(1, 1, 0)
等数字,一直到 (31, 31, 0)
,这意味着,您可以将其视为要处理的单元格索引!
计算着色器也可以使用 uniform,就像在顶点和 fragment 着色器中一样。
- 将 uniform 与计算着色器告知您网格大小,如下所示:
index.html(计算 createShaderModule 调用)
@group(0) @binding(0) var<uniform> grid: vec2f; // New line
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
与顶点着色器中一样,您也要将单元格状态公开为存储缓冲区。但在这种情况下,您需要两个变量!由于计算着色器没有必需的输出(例如顶点位置或 Fragment 颜色),因此将值写入存储缓冲区或纹理是从计算着色器获得结果的唯一方式。请使用您之前学到的 ping 乒乓球方法;您将一个存储缓冲区馈送到网格的当前状态,将另一个缓冲区写出网格的新状态。
- 将单元格输入和输出状态作为存储缓冲区公开,如下所示:
index.html(计算 createShaderModule 调用)
@group(0) @binding(0) var<uniform> grid: vec2f;
// New lines
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
请注意,第一个存储缓冲区是使用 var<storage>
声明的,这使其变为只读,但第二个存储缓冲区是使用 var<storage, read_write>
声明的。这样您就能对缓冲区执行读写操作,使用该缓冲区作为计算着色器的输出。WebGPU 中没有只写存储模式。
接下来,您需要通过某种方式将单元格索引映射到线性存储数组中。这基本上与您在顶点着色器中执行的操作相反,在顶点着色器中,您获取了线性 instance_index
并将其映射到 2D 网格单元格。(请注意,您的算法是 vec2f(i % grid.x, floor(i / grid.x))
)。
- 编写一个函数朝另一个方向前进。也就是将单元格的 Y 值乘以网格宽度,然后加上单元格的 X 值。
index.html(计算 createShaderModule 调用)
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
// New function
fn cellIndex(cell: vec2u) -> u32 {
return cell.y * u32(grid.x) + cell.x;
}
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
}
最后,为了了解它是否有效,请实施一个非常简单的算法:如果单元格当前处于开启状态,它会关闭,反之亦然。虽然这还不是 Game of Life,但足以证明计算着色器在发挥作用。
- 添加简单算法,如下所示:
index.html(计算 createShaderModule 调用)
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
fn cellIndex(cell: vec2u) -> u32 {
return cell.y * u32(grid.x) + cell.x;
}
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
// New lines. Flip the cell state every step.
if (cellStateIn[cellIndex(cell.xy)] == 1) {
cellStateOut[cellIndex(cell.xy)] = 0;
} else {
cellStateOut[cellIndex(cell.xy)] = 1;
}
}
您的计算着色器就介绍到这里!不过,在看到结果之前,您还需要做出几项更改。
使用绑定组和流水线布局
您可能会注意到,上述着色器大量使用了与渲染管道相同的输入(统一和存储缓冲区)。您可能会认为,只需使用相同的绑定群组,然后就大功告成了。好消息是,您可以做到!为此,您需要进行更多的手动设置。
每当创建绑定组时,您都需要提供 GPUBindGroupLayout
。以前,您可以通过在渲染管道上调用 getBindGroupLayout()
来获取该布局,而渲染管道反过来又是自动创建的,因为您在创建它时提供了 layout: "auto"
。如果您只使用一条流水线,这种方法非常有效,但如果您有多个希望共享资源的流水线,则需要明确创建布局,然后将其提供给绑定组和流水线。
为便于理解原因,请考虑这一点:在渲染流水线中,您使用单个统一缓冲区和单个存储缓冲区,但在您刚刚编写的计算着色器中,您需要第二个存储缓冲区。由于两个着色器对统一和第一个存储缓冲区使用相同的 @binding
值,因此您可以在流水线之间共享这些值,而渲染流水线会忽略不使用的第二个存储缓冲区。您想创建一个布局来描述绑定组中存在的所有资源,而不仅仅是特定流水线使用的资源。
- 如需创建该布局,请调用
device.createBindGroupLayout()
:
index.html
// Create the bind group layout and pipeline layout.
const bindGroupLayout = device.createBindGroupLayout({
label: "Cell Bind Group Layout",
entries: [{
binding: 0,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
buffer: {} // Grid uniform buffer
}, {
binding: 1,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.COMPUTE,
buffer: { type: "read-only-storage"} // Cell state input buffer
}, {
binding: 2,
visibility: GPUShaderStage.COMPUTE,
buffer: { type: "storage"} // Cell state output buffer
}]
});
这的结构类似于创建绑定组本身,也就是描述 entries
的列表。不同之处在于,您需要描述条目的类型以及使用方式,而不是提供资源本身。
在每个条目中,您将为资源提供 binding
编号,在您创建绑定组时,该数字与着色器中的 @binding
值一致。您还提供 visibility
,它是 GPUShaderStage
标志,指示哪些着色器阶段可以使用资源。您希望统一且第一个存储缓冲区可在顶点和计算着色器中访问,但第二个存储缓冲区只需要在计算着色器中可访问。您还可以使用这些标志让 fragment 着色器可以访问资源,但您在此处无需执行此操作。
最后,您需要指出正在使用的资源类型。这是一个不同的字典键,具体取决于您需要公开哪些内容。这里的三个资源都是缓冲区,因此您可以使用 buffer
键为每个资源定义选项。其他选项包括 texture
或 sampler
等,但此处并不需要这些选项。
在缓冲区字典中,您可以设置一些选项,例如使用缓冲区的 type
。默认值为 "uniform"
,因此您可以将字典留空以绑定 0。(但您必须至少设置 buffer: {}
,以便该条目被标识为缓冲区。)绑定 1 具有 "read-only-storage"
类型,因为您不会在着色器中通过 read_write
访问权限使用它,而绑定 2 具有 "storage"
类型,因为您使用它通过 read_write
访问权限!
创建 bindGroupLayout
后,您可以在创建绑定组时传入它,而不是在流水线中查询绑定组。这样做意味着,您需要向每个绑定组添加新的存储缓冲区条目,以便与您刚刚定义的布局相匹配。
- 更新绑定组创建,如下所示:
index.html
// Create a bind group to pass the grid uniforms into the pipeline
const bindGroups = [
device.createBindGroup({
label: "Cell renderer bind group A",
layout: bindGroupLayout, // Updated Line
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}, {
binding: 1,
resource: { buffer: cellStateStorage[0] }
}, {
binding: 2, // New Entry
resource: { buffer: cellStateStorage[1] }
}],
}),
device.createBindGroup({
label: "Cell renderer bind group B",
layout: bindGroupLayout, // Updated Line
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}, {
binding: 1,
resource: { buffer: cellStateStorage[1] }
}, {
binding: 2, // New Entry
resource: { buffer: cellStateStorage[0] }
}],
}),
];
现在,绑定组已更新为使用此明确绑定组布局,因此您需要更新渲染管道以使用相同功能。
index.html
const pipelineLayout = device.createPipelineLayout({
label: "Cell Pipeline Layout",
bindGroupLayouts: [ bindGroupLayout ],
});
流水线布局是一个或多个流水线使用的绑定组布局(在本例中为绑定布局)的列表。数组中绑定组布局的顺序需要与着色器中的 @group
属性相对应。(这意味着,bindGroupLayout
与 @group(0)
相关联。)
- 获取流水线布局后,请更新渲染流水线以使用它,而不是使用
"auto"
。
index.html
const cellPipeline = device.createRenderPipeline({
label: "Cell pipeline",
layout: pipelineLayout, // Updated!
vertex: {
module: cellShaderModule,
entryPoint: "vertexMain",
buffers: [vertexBufferLayout]
},
fragment: {
module: cellShaderModule,
entryPoint: "fragmentMain",
targets: [{
format: canvasFormat
}]
}
});
创建计算流水线
就像需要渲染流水线才能使用顶点和片段着色器一样,也需要使用计算流水线才能使用计算着色器。幸运的是,计算流水线比渲染流水线复杂得多,因为它们没有可设置的任何状态,只有着色器和布局。
- 使用以下代码创建计算流水线:
index.html
// Create a compute pipeline that updates the game state.
const simulationPipeline = device.createComputePipeline({
label: "Simulation pipeline",
layout: pipelineLayout,
compute: {
module: simulationShaderModule,
entryPoint: "computeMain",
}
});
请注意,与更新后的渲染管线一样,传入新的 pipelineLayout
而不是 "auto"
,这样可以确保渲染管线和计算流水线可以使用相同的绑定组。
计算卡券
这就引出了您实际使用计算流水线的情形。假设您在渲染通道中进行渲染,或许可以认为您需要在计算通道中执行计算工作。计算和渲染工作都可以在同一命令编码器中进行,因此您需要稍微打乱 updateGrid
函数。
- 将编码器创建移至函数顶部,然后开始计算(在
step++
之前)。
index.html
// In updateGrid()
// Move the encoder creation to the top of the function.
const encoder = device.createCommandEncoder();
const computePass = computeEncoder.beginComputePass();
// Compute work will go here...
computePass.end();
// Existing lines
step++; // Increment the step count
// Start a render pass...
与计算流水线一样,计算卡券比对应的渲染作业要简单得多,因为您无需担心任何连接。
您想在渲染通道之前执行计算通道,因为它允许渲染通道立即使用计算通道中的最新结果。这也是您在两次传递之间增加 step
计数的原因,因此计算流水线的输出缓冲区会成为渲染管道的输入缓冲区。
- 接下来,在计算通道内设置流水线和绑定组,使用与呈现通道相同的绑定模式在绑定组之间切换。
index.html
const computePass = computeEncoder.beginComputePass();
// New lines
computePass.setPipeline(simulationPipeline),
computePass.setBindGroup(0, bindGroups[step % 2]);
computePass.end();
- 最后,不要像渲染通道那样进行绘制,而是将工作分派到计算着色器,告诉它每个轴上要执行多少个工作组。
index.html
const computePass = computeEncoder.beginComputePass();
computePass.setPipeline(simulationPipeline),
computePass.setBindGroup(0, bindGroups[step % 2]);
// New lines
const workgroupCount = Math.ceil(GRID_SIZE / WORKGROUP_SIZE);
computePass.dispatchWorkgroups(workgroupCount, workgroupCount);
computePass.end();
需要注意的是,这里需要特别注意您传递到 dispatchWorkgroups()
的数字不是调用次数!而是要执行的工作组数量,由着色器中的 @workgroup_size
定义。
如果您希望着色器执行 32x32 次以覆盖整个网格,并且工作组的大小为 8x8,则需要分派 4x4 工作组 (4 * 8 = 32)。因此,您将网格大小除以工作组大小,然后将该值传入 dispatchWorkgroups()
。
现在,您可以再次刷新页面,您应该会看到网格在每次更新时反转。
实现 Game of Life 算法
在更新计算着色器以实现最终算法之前,您需要返回到初始化存储缓冲区内容的代码,对其进行更新,以便在每次加载网页时生成随机缓冲区。(常规模式无法用于生成非常有趣的生命游戏起点)。您可以随意对值进行随机化处理,但有一种简单的方式可以开始提供合理的结果。
- 如需以随机状态启动每个单元格,请将
cellStateArray
初始化更新为以下代码:
index.html
// Set each cell to a random state, then copy the JavaScript array
// into the storage buffer.
for (let i = 0; i < cellStateArray.length; ++i) {
cellStateArray[i] = Math.random() > 0.6 ? 1 : 0;
}
device.queue.writeBuffer(cellStateStorage[0], 0, cellStateArray);
现在,终于可以实现游戏模拟人生了。完成上述所有操作后,着色器代码可能会令人感到极为简单!
首先,您需要知道任意给定单元格的邻里数量是多少。您只关心数量,不关心它们是否处于活动状态。
- 为了更轻松地获取相邻单元格的数据,请添加
cellActive
函数,该函数会返回给定坐标的cellStateIn
值。
index.html(计算 createShaderModule 调用)
fn cellActive(x: u32, y: u32) -> u32 {
return cellStateIn[cellIndex(vec2(x, y))];
}
如果该单元格处于活动状态,则 cellActive
函数会返回一个函数,因此如果为所有八个单元格加上调用 cellActive
的返回值,可向您显示相邻单元格中有多少处于活动状态。
- 找到活跃的邻居数量,如下所示:
index.html(计算 createShaderModule 调用)
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
// New lines:
// Determine how many active neighbors this cell has.
let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
cellActive(cell.x+1, cell.y) +
cellActive(cell.x+1, cell.y-1) +
cellActive(cell.x, cell.y-1) +
cellActive(cell.x-1, cell.y-1) +
cellActive(cell.x-1, cell.y) +
cellActive(cell.x-1, cell.y+1) +
cellActive(cell.x, cell.y+1);
但这会带来一个小问题:如果您正在检查的单元格位于图板边缘之外,会发生什么情况?根据当前 cellIndex()
逻辑,它要么溢出到下一行,要么溢出到缓冲区边缘!
对于生命游戏来说,解决这一问题的一种常见方式是,让网格边缘的单元格将相邻的网格中的单元格视为相邻,从而形成一种环绕式效果。
- 通过对
cellIndex()
函数稍作更改,支持网格环绕。
index.html(计算 createShaderModule 调用)
fn cellIndex(cell: vec2u) -> u32 {
return (cell.y % u32(grid.y)) * u32(grid.x) +
(cell.x % u32(grid.x));
}
使用 %
运算符可在单元格 X 和 Y 超出网格大小时封装这些单元格,以确保您永远不会超出存储缓冲区边界。因此,您可以放心,activeNeighbors
的数量是可以预测的。
然后,您可以应用以下 4 条规则之一:
- 任何相邻于两个相邻的单元格都将变为非活动状态。
- 具有两个或三个邻居的所有活动单元格都会保持活跃状态。
- 只有三个邻居的任何活跃单元格都会变为活动状态。
- 任何具有超过三个邻居的单元格都会变为非活动状态。
您可以使用一系列 if 语句来实现此目的,但 WGSL 还支持 Switch 语句,这些语句非常适合此逻辑。
- 实现游戏生命逻辑,如下所示:
index.html(计算 createShaderModule 调用)
let i = cellIndex(cell.xy);
// Conway's game of life rules:
switch activeNeighbors {
case 2: { // Active cells with 2 neighbors stay active.
cellStateOut[i] = cellStateIn[i];
}
case 3: { // Cells with 3 neighbors become or stay active.
cellStateOut[i] = 1;
}
default: { // Cells with < 2 or > 3 neighbors become inactive.
cellStateOut[i] = 0;
}
}
作为参考,最终的计算着色器模块调用现在如下所示:
index.html
const simulationShaderModule = device.createShaderModule({
label: "Life simulation shader",
code: `
@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellStateIn: array<u32>;
@group(0) @binding(2) var<storage, read_write> cellStateOut: array<u32>;
fn cellIndex(cell: vec2u) -> u32 {
return (cell.y % u32(grid.y)) * u32(grid.x) +
(cell.x % u32(grid.x));
}
fn cellActive(x: u32, y: u32) -> u32 {
return cellStateIn[cellIndex(vec2(x, y))];
}
@compute @workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE})
fn computeMain(@builtin(global_invocation_id) cell: vec3u) {
// Determine how many active neighbors this cell has.
let activeNeighbors = cellActive(cell.x+1, cell.y+1) +
cellActive(cell.x+1, cell.y) +
cellActive(cell.x+1, cell.y-1) +
cellActive(cell.x, cell.y-1) +
cellActive(cell.x-1, cell.y-1) +
cellActive(cell.x-1, cell.y) +
cellActive(cell.x-1, cell.y+1) +
cellActive(cell.x, cell.y+1);
let i = cellIndex(cell.xy);
// Conway's game of life rules:
switch activeNeighbors {
case 2: {
cellStateOut[i] = cellStateIn[i];
}
case 3: {
cellStateOut[i] = 1;
}
default: {
cellStateOut[i] = 0;
}
}
}
`
});
大功告成!大功告成!刷新页面,看看新构建的移动网络自动机是什么样的!
9. 恭喜!
您使用 WebGPU API 创建了传统版 Conway 的“人生游戏”模拟版本,它完全在您的 GPU 上运行!
后续操作
- 查看 WebGPU 示例