您的第一个 WebGPU 应用

1. 简介

WebGPU 徽标由几个蓝色三角形构成,这些三角形形成风格化的“W”

上次更新时间:2023 年 8 月 28 日

什么是 WebGPU?

WebGPU 是一种新的现代 API,用于在 Web 应用中访问 GPU 的功能。

现代 API

在 WebGPU 出现之前,有一个 WebGL,它提供了 WebGPU 的部分功能。它开创了一种新的富媒体 Web 内容类别,并且开发者也利用它构建了许多精彩的内容。不过,它基于 2007 年发布的 OpenGL ES 2.0 API,而该 API 基于更旧的 OpenGL API。在那段时间里,GPU 取得了显著进步,用来与 GPU 进行交互的原生 API 也在与 Direct3D 12MetalVulkan 一起发展。

WebGPU 将这些现代 API 的进展带到了 Web 平台。它专注于以跨平台的方式启用 GPU 功能,同时提供在 Web 上感觉自然并且比它所基于的一些原生 API 更简洁的 API。

渲染

GPU 通常与快速渲染详细图形相关,WebGPU 也不例外。它具有支持当今桌面和移动 GPU 上许多最流行的渲染技术所需的功能,并为未来随着硬件功能的不断发展添加新功能提供了途径。

计算

除了渲染之外,WebGPU 还可以释放 GPU 用于执行通用、高度并行工作负载的潜力。这些计算着色器可以独立使用(无需任何渲染组件),也可以作为渲染流水线紧密集成的一部分使用。

在今天的 Codelab 中,您将学习如何利用 WebGPU 的渲染和计算功能来创建一个简单的入门级项目!

构建内容

在此 Codelab 中,您将使用 WebGPU 构建 Conway 的《Game of Life》。您的应用将:

  • 使用 WebGPU 的渲染功能绘制简单的 2D 图形。
  • 使用 WebGPU 的计算功能执行模拟。

此 Codelab 的最终成品的屏幕截图

《生命的游戏》就是所谓的单元格自动机,即由单元格组成的网格会根据某组规则随时间改变状态。在《Game of Life》中,单元格变得活跃或不活跃取决于有多少相邻单元格处于活跃状态,这会产生在您观看时波动的有趣模式。

学习内容

  • 如何设置 WebGPU 和配置画布。
  • 如何绘制简单的 2D 几何图形。
  • 如何使用顶点和 Fragment 着色器来修改绘制的内容。
  • 如何使用计算着色器执行简单的模拟。

此 Codelab 将重点介绍 WebGPU 背后的基本概念,而不是对 API 进行全面探讨,也不涵盖(或需要)3D 矩阵数学等常见的相关主题。

所需条件

  • 适用于 ChromeOS、macOS 或 Windows 的最新版 Chrome(113 或更高版本)。WebGPU 是一种跨浏览器、跨平台的 API,但尚未面向所有地区推出。
  • 了解 HTML、JavaScript 和 Chrome 开发者工具

不要求熟悉其他图形 API(例如 WebGL、Metal、Vulkan 或 Direct3D),但如果您有任何相关经验,很可能会发现这些 API 与 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 中所做的那样),则需要画布。因此,不妨从这里入手!

创建一个新的 HTML 文档,其中包含一个 <canvas> 元素,以及用于查询画布元素的 <script> 标记。(或者使用 Glitch 的 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 可能需要一段时间才能传播到整个 Web 生态系统。因此,建议首先检查用户的浏览器是否可以使用 WebGPU。

  1. 如需检查是否存在用作 WebGPU 入口点的 navigator.gpu 对象,请添加以下代码:

index.html

if (!navigator.gpu) {
  throw new Error("WebGPU not supported on this browser.");
}

理想情况下,如果 WebGPU 不可用,则您需要通过让页面回退到不使用 WebGPU 的模式来通知用户。(也许可以改用 WebGL?)不过,在此 Codelab 中,您只需抛出一个错误即可阻止代码进一步执行。

确定浏览器支持 WebGPU 后,为应用初始化 WebGPU 的第一步就是请求 GPUAdapter。您可以将适配器视为 WebGPU 代表设备中特定的 GPU 硬件的表示形式。

  1. 如需获取适配器,请使用 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 的设备(例如某些笔记本电脑)上使用低功耗或高性能硬件。

有了适配器之后,在开始使用 GPU 前的最后一步就是请求一个 GPUDevice。设备是与 GPU 进行大部分交互的主接口。

  1. 通过调用 adapter.requestDevice() 获取设备,该方法也会返回一个 promise。

index.html

const device = await adapter.requestDevice();

requestAdapter() 一样,有一些可传递的选项用于更高级的用途,例如启用特定硬件功能或请求更高的限制,但就您的目的而言,默认值可以正常使用。

配置画布

现在,您已经拥有了设备,如果要使用该设备在页面上显示任何内容,还需要执行另一操作:配置画布,使其与您刚刚创建的设备搭配使用。

  • 为此,请先通过调用 canvas.getContext("webgpu") 从画布请求 GPUCanvasContext。(这与分别使用 2dwebgl 上下文类型初始化画布 2D 或 WebGL 上下文的调用相同。)然后,它返回的 context 必须使用 configure() 方法与设备相关联,如下所示:

index.html

const context = canvas.getContext("webgpu");
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
  device: device,
  format: canvasFormat,
});

有几个选项可在此处传递,但最重要的是要与上下文搭配使用的 deviceformat,即上下文应使用的纹理格式

纹理是 WebGPU 用来存储图片数据的对象,每种纹理都有相应的格式,可以告诉 GPU 这些数据在内存中的布局方式。纹理内存工作原理的详细信息不在此 Codelab 的讨论范围内。重要的是,画布上下文会提供代码绘制到的纹理,而您使用的格式会影响画布显示这些图片的效率。使用不同的纹理格式时,不同类型的设备效果最佳,而且如果不使用设备的首选格式,这可能会导致系统在后台进行额外的内存复制,从而将图片显示为页面的一部分。

幸运的是,您无需操心这方面的问题,因为 WebGPU 会告知您画布使用哪种格式!几乎在所有情况下,您都需要传递通过调用 navigator.gpu.getPreferredCanvasFormat() 返回的值,如上所示。

清除画布

现在,您已经拥有了设备并配置了画布,可以开始使用该设备更改画布的内容了。首先,使用纯色进行清除。

为了执行该操作(或者 WebGPU 中的几乎任何其他操作),您需要向 GPU 提供一些命令,指示其执行什么操作。

  1. 为此,请让设备创建一个 GPUCommandEncoder,以提供用于记录 GPU 命令的接口。

index.html

const encoder = device.createCommandEncoder();

要发送到 GPU 的命令与渲染(在本例中为清除画布)相关,因此下一步是使用 encoder 启动渲染通道。

渲染通道是指在 WebGPU 中执行所有绘制操作的时间。每个通道都以 beginRenderPass() 调用开始,后者用于定义接收所执行的任何绘制命令的输出的纹理。更高级的用途可以提供多种纹理(称为“附加”),来实现各种用途,例如存储所渲染几何图形的深度或提供抗锯齿效果。不过,对于此应用,您只需要一个。

  1. 通过调用 context.getCurrentTexture() 从您之前创建的画布上下文中获取纹理,该方法会返回一个像素宽度和高度与画布的 widthheight 属性以及调用 context.configure() 时指定的 format 相匹配的纹理。

index.html

const pass = encoder.beginRenderPass({
  colorAttachments: [{
     view: context.getCurrentTexture().createView(),
     loadOp: "clear",
     storeOp: "store",
  }]
});

纹理作为 colorAttachmentview 属性指定。渲染通道要求您提供 GPUTextureView(而非 GPUTexture)来告知它要渲染到纹理的哪些部分。这对于更高级的用例才真正重要,因此在这里您要对纹理调用 createView()(不添加任何参数),这表示您希望渲染通道使用整个纹理。

您还必须指定渲染通道在开始和结束时对纹理执行的操作:

  • loadOp 值为 "clear" 表示您希望在渲染通道开始时清除纹理。
  • storeOp 值为 "store" 表示,在渲染通道完成后,您希望将渲染通道期间完成的所有绘制结果保存到纹理。

渲染通道开始后,您无需执行任何操作!至少现在是这样。使用 loadOp: "clear" 启动渲染通道就足以清除纹理视图和画布。

  1. 通过在 beginRenderPass() 之后立即添加以下调用来结束渲染通道:

index.html

pass.end();

需要注意的是,仅仅进行这些调用并不会使 GPU 实际执行任何操作。它们只是记录供 GPU 稍后执行的命令。

  1. 如需创建 GPUCommandBuffer,请在命令编码器上调用 finish()。命令缓冲区是所记录命令的不透明句柄。

index.html

const commandBuffer = encoder.finish();
  1. 使用 GPUDevicequeue 将命令缓冲区提交到 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() 以获取用于渲染通道的新纹理。

  1. 重新加载页面。 请注意,画布以黑色填充。恭喜!这意味着您已成功创建了第一个 WebGPU 应用。

表明 WebGPU 已成功用于清除画布内容的黑色画布。

选取颜色!

不过说实话,黑色方形非常无聊。因此,在进入下一部分之前请花点时间对其进行个性化设置。

  1. 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 表示红色g 表示绿色b 表示蓝色a 表示 alpha(透明度)。每个值的范围介于 01 之间,共同说明该颜色通道的值。例如:

  • { 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 中的示例代码和屏幕截图使用深蓝色,但您可以随意选择想要的任何颜色!

  1. 选好颜色后,重新加载页面。您应该会在画布中看到您选择的颜色。

将画布清除为深蓝色,以演示如何更改默认透明颜色。

4. 绘制几何图形

完成本部分后,您的应用将在画布上绘制一些简单的几何图形:彩色正方形。请注意,要实现这种简单的输出似乎需要完成大量工作,这是因为 WebGPU 设计用于非常高效地渲染大量几何图形。这种效率的一个副作用是,做相对简单的事情可能会感觉异常困难,但如果您转向像 WebGPU 这样的 API(您想做一些更复杂的事情),这就是预期的情况。

了解 GPU 如何绘制

在更改其他任何代码之前,我们先来快速、简化、简要概述 GPU 是如何创建屏幕上显示的形状的。(如果您已熟悉 GPU 渲染的基本原理,请直接跳到“定义顶点”部分。)

与 Canvas 2D 这样具有大量形状和选项供您使用的 API 不同,GPU 实际上仅处理几种不同类型的形状(或 WebGPU 所指的基元):点、线和三角形。在此 Codelab 中,您将只使用三角形。

GPU 几乎专门处理三角形,因为三角形具有许多不错的数学属性,可以轻松地以可预测且高效的方式对其进行处理。几乎所有使用 GPU 绘制的内容都需要拆分成三角形,之后 GPU 才能进行绘制,并且这些三角形必须使用其角点来定义。

这些点(即顶点)以 X、Y 和(对于 3D 内容)Z 值的形式提供,这些值用于定义由 WebGPU 或类似 API 定义的笛卡尔坐标系上的一个点。坐标系的结构与页面上的画布之间的关系是最容易考虑的。无论画布有多宽或多高,左边缘始终位于 X 轴上的 -1 处,右边缘始终位于 X 轴上的 +1 处。同样,下边缘在 Y 轴上始终为 -1,上边缘在 Y 轴上始终为 +1。也就是说,(0, 0) 始终是画布的中心,(-1, -1) 始终是左下角,而 (1, 1) 始终是右上角。这称为“裁剪空间”。

直观呈现标准化设备坐标空间的简单图表。

顶点最初很少在此坐标系中定义,因此 GPU 依靠称为顶点着色器的小程序来执行将顶点转换为裁剪空间所需的任何数学运算,以及绘制顶点所需的任何其他计算。例如,着色器可能会应用一些动画或计算从顶点到光源的方向。这些着色器由您(WebGPU 开发者)编写,可以对 GPU 的工作方式进行大量控制。

从那里,GPU 获取由这些变换后的顶点组成的所有三角形,并确定需要屏幕上的哪些像素来绘制它们。然后,它会运行您编写的另一个名为“fragment 着色器”的小程序,用于计算每个像素应该是什么颜色。该计算可以像返回绿色一样简单,也可以像计算表面相对于从附近其他表面反射的阳光的角度一样复杂,通过雾过滤,并根据表面的金属程度进行修改。一切都在您的掌控之下,可能会让您感到无所适从。

这些像素颜色的结果随后会累积成纹理,从而能够显示在屏幕上。

定义顶点

如前所述,《Game of Life》模拟显示为一个由单元格组成的网格。您的应用需要一种可视化网格的方式,区分活跃单元格和非活跃单元格。此 Codelab 使用的方法是在活跃单元格中绘制彩色方形,并将非活跃单元格留空。

也就是说,您需要为 GPU 提供四个不同的点,分别对应方形的四个角。例如,在画布中心绘制的正方形,以某种方式从边缘拉入,其角坐标如下所示:

显示正方形角坐标的标准化设备坐标图

为了将这些坐标馈送到 GPU,您需要将这些值放在 TypedArray 中。如果您还不熟悉它,TypedArray 是一组 JavaScript 对象,可让您分配连续的内存块,并将系列中的每个元素解释为特定数据类型。例如,在 Uint8Array 中,数组中的每个元素都是一个无符号字节。TypedArray 非常适合与对内存布局敏感的 API(例如 WebAssembly、WebAudio 和 WebGPU)来回发送数据。

对于方形示例来说,由于值是小数,因此 Float32Array 是合适的。

  1. 在代码中放置以下数组声明,创建一个包含图中所有顶点位置的数组。非常适合放在顶部附近,就在 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 以三角形的形式工作,还记得吗?也就是说,您必须以三个为一组提供顶点。您的一个组有四个顶点。解决方案是重复两个顶点以创建两个三角形,它们共享穿过正方形中间的一条边。

展示如何使用正方形的四个顶点组成两个三角形的示意图。

为了在图中构建方形,您必须列出 (-0.8, -0.8) 和 (0.8, 0.8) 顶点两次,一次用于蓝色三角形,一次用于红色三角形。(您也可以选择将正方形拆分为另外两个角;这不会有任何影响。)

  1. 更新您之前的 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 在绘制时使用的任何数据都需要放在该内存中。

对于大量值(包括顶点数据),GPU 端内存通过 GPUBuffer 对象进行管理。缓冲区是 GPU 可以轻松访问的内存块,并且是被标记用于特定用途的内存块。您可以将其视为一个 GPU 可见的 TypedArray。

  1. 如需创建缓冲区来保存顶点,请在 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) 来确定该值。值得高兴的是,TypedArray 已经为您计算了其 byteLength,所以您可以在创建缓冲区时使用它。

最后,您需要指定缓冲区的用法。这是一个或多个 GPUBufferUsage 标志,其中多个标志通过 |按位 OR)运算符合并。在本例中,您要指定将该缓冲区用于顶点数据 (GPUBufferUsage.VERTEX),还希望能够将数据复制到其中 (GPUBufferUsage.COPY_DST)。

返回给您的缓冲区对象是不透明的,您无法(轻松地)检查其存储的数据。此外,它的大多数属性都是不可变的,因此您无法在创建 GPUBuffer 后调整其大小,也无法更改用法标志。您可以更改其内存的内容。

最初创建缓冲区时,其包含的内存将初始化为零。您可以通过多种方式更改其内容,但最简单的方法是使用要复制的 TypedArray 调用 device.queue.writeBuffer()

  1. 如需将顶点数据复制到缓冲区的内存中,请添加以下代码:

index.html

device.queue.writeBuffer(vertexBuffer, /*bufferOffset=*/0, vertices);

定义顶点布局

现在,您有一个包含顶点数据的缓冲区,但对 GPU 而言,它只是一个字节 blob。如果您要使用它绘制任何内容,则需要提供更多信息。您需要能够让 WebGPU 更多地了解顶点数据的结构。

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 上执行的小程序。每个着色器都在数据的不同阶段运行:顶点处理、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 中有六个位置(顶点),因此您定义的函数会被调用 6 次。每次调用该函数时,vertexBuffer 中的不同位置都会以参数形式传递给该函数,而顶点着色器函数的任务是返回裁剪空间中的相应位置。

请务必注意,系统不一定会按顺序调用这些函数。相反,GPU 擅长并行运行这类着色器,有可能同时处理数百个(甚至数千个)顶点!这是 GPU 实现惊人速度的重要一环,但也存在局限性。为了确保实现极致的并行处理,顶点着色器不能相互通信。着色器调用一次只能查看一个顶点的数据,并且只能输出单个顶点的值。

在 WGSL 中,顶点着色器函数可以随意命名,但它前面必须有 @vertex 属性,以便指明它所代表的着色器阶段。WGSL 使用 fn 关键字表示函数,使用圆括号声明任何参数,并使用大括号定义范围。

  1. 创建一个空的 @vertex 函数,如下所示:

index.html(createShaderModule 代码)

@vertex
fn vertexMain() {

}

但这种做法无效,因为顶点着色器必须至少返回在裁剪空间中处理的顶点的最终位置。始终以四维矢量的形式指定。矢量在着色器中非常常用,因此在语言中被视为一流基元,具有自己的类型,例如四维矢量的 vec4f。2D 矢量 (vec2f) 和 3D 矢量 (vec3f) 也有类似的类型!

  1. 如需指明返回的值是必需位置,请使用 @builtin(position) 属性对其进行标记。-> 符号用于表示这是函数返回的内容。

index.html(createShaderModule 代码)

@vertex
fn vertexMain() -> @builtin(position) vec4f {

}

当然,如果函数具有返回值类型,则实际上需要在函数正文中返回一个值。您可以使用语法 vec4f(x, y, z, w) 构造要返回的新 vec4fxyz 值都是浮点数,在返回值中表示顶点在裁剪空间内的位置。

  1. 返回静态值 (0, 0, 0, 1),从技术层面而言,您具有一个有效的顶点着色器,尽管该着色器根本不会显示任何内容,因为 GPU 识别出它生成的三角形只是一个点,然后便会将其舍弃。

index.html(createShaderModule 代码)

@vertex
fn vertexMain() -> @builtin(position) vec4f {
  return vec4f(0, 0, 0, 1); // (X, Y, Z, W)
}

相反,您想要的是利用您创建的缓冲区中的数据,并通过使用 @location() 属性以及与您在 vertexBufferLayout 中描述的类型匹配的类型声明函数的参数来实现这一点。您指定的 shaderLocation0,因此在 WGSL 代码中,请使用 @location(0) 标记参数。您还将格式定义为 float32x2,这是一个 2D 矢量,因此在 WGSL 中,您的参数为 vec2f。您可以随意为其命名,但由于这些矢量代表您的顶点位置,因此诸如 pos 之类的名称看起来很自然。

  1. 将您的着色器函数更改为以下代码:

index.html(createShaderModule 代码)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(0, 0, 0, 1);
}

现在,您需要返回该位置。由于位置是 2D 矢量,而返回类型是 4D 矢量,因此您必须对其进行修改。您要做的就是从位置参数中获取两个组成部分,并将其放在返回矢量的前两个组成部分中,将最后两个组成部分分别保留为 01

  1. 明确说明要使用的位置组成部分,以返回正确的位置:

index.html(createShaderModule 代码)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos.x, pos.y, 0, 1);
}

不过,由于这些类型的映射在着色器中非常常见,因此您还可以用方便的简写形式将位置矢量作为第一个参数传入,其含义相同。

  1. 使用以下代码重写 return 语句:

index.html(createShaderModule 代码)

@vertex
fn vertexMain(@location(0) pos: vec2f) ->
  @builtin(position) vec4f {
  return vec4f(pos, 0, 1);
}

这是您的初始顶点着色器!这非常简单,只需原样传递位置即可,但已足够入门。

定义 fragment 着色器

接下来是 fragment 着色器。Fragment 着色器的运行方式与顶点着色器非常相似,但不是为每个顶点调用 Fragment 着色器,而是针对要绘制的每个像素调用 Fragment 着色器。

Fragment 着色器始终在顶点着色器之后调用。GPU 获取顶点着色器的输出并对其进行三角形处理,从而基于三个点的集合创建三角形。然后,它会计算出每个三角形都包含输出颜色附件的哪些像素,从而对每个三角形进行光栅化,然后针对每个像素调用一次 fragment 着色器。fragment 着色器会返回一种颜色,通常根据从顶点着色器发送到它的值以及 GPU 将其写入颜色附件的纹理等资源计算得出。

与顶点着色器一样,fragment 着色器也是以大规模并行方式执行的。在输入和输出方面,它们比顶点着色器更灵活,但您可以认为它们只是为每个三角形的每个像素返回一种颜色。

WGSL fragment 着色器函数由 @fragment 属性表示,并且它也会返回 vec4f。但在这种情况下,矢量表示颜色,而非位置。需要为返回值提供 @location 属性,以指示要将返回的颜色写入 beginRenderPass 调用中的哪个 colorAttachment。由于您只有一个附件,因此位置为 0。

  1. 创建一个空的 @fragment 函数,如下所示:

index.html(createShaderModule 代码)

@fragment
fn fragmentMain() -> @location(0) vec4f {

}

返回的矢量的四个组成部分是红色、绿色、蓝色和 Alpha 颜色值,其解读方式与您之前在 beginRenderPass 中设置的 clearValue 完全相同。因此,vec4f(1, 0, 0, 1) 是亮红色,这对于您的方形来说看起来很合适。不过,您可以随意将其设置为您喜欢的任何颜色!

  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);
    }
  `
});

创建渲染流水线

着色器模块不能用于独立渲染。相反,您必须将其用作 GPURenderPipeline(通过调用 device.createRenderPipeline() 创建)的一部分。渲染流水线可以控制几何图形的绘制方式,包括使用哪些着色器、如何解读顶点缓冲区中的数据、应渲染哪种类型的几何图形(线、点、三角形…),等等!

渲染流水线是整个 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 是包含顶点着色器的 GPUShaderModule,而 entryPoint 提供了着色器代码中为每个顶点调用的函数的名称。(您可以在单个着色器模块中包含多个 @vertex@fragment 函数!)缓冲区是 GPUVertexBufferLayout 对象的数组,用于描述数据如何打包到用于此流水线的顶点缓冲区中。幸运的是,您之前已在 vertexBufferLayout 中定义了此属性!您可以在此处传递它。

最后,您还有关于 fragment 阶段的详细信息。这还包括着色器模块entryPoint,例如顶点阶段。最后一位用于定义此流水线搭配使用的 targets。这是一个字典数组,可为流水线输出的颜色附件提供详细信息(例如纹理 format)。这些详细信息需要与使用此流水线的所有渲染通道的 colorAttachments 中提供的纹理一致。您的渲染通道使用画布上下文中的纹理,并采用您在 canvasFormat 中保存的值作为其格式,因此您要在此处传递相同的格式。

您在创建渲染流水线时可以指定的所有选项还远远不够,但足以满足此 Codelab 的需求!

绘制正方形

这样,您就拥有了绘制方形所需的一切!

  1. 如需绘制方形,请跳回 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 个顶点)进行计算意味着,如果您决定将正方形替换为圆形之类的情况,手动更新的内容更少。

  1. 刷新屏幕,并(最终)查看所有辛苦工作的成果:一个彩色大方形。

使用 WebGPU 渲染的单个红色方形

5. 绘制网格

首先,花点时间祝贺自己!对大多数 GPU API 而言,在屏幕上获得第一批几何图形通常是最难的步骤之一。您从这里所做的一切都可以通过更小的步骤完成,从而更轻松地验证您的进度。

在这一部分中,您将了解:

  • 如何从 JavaScript 将变量(称为 uniform)传递给着色器。
  • 如何使用 uniform 更改渲染行为。
  • 如何使用实例化功能绘制同一几何图形的多个不同变体。

定义网格

为了渲染网格,您需要了解关于网格的最基本信息。它包含多少个单元格(宽度和高度均包括在内)?这要由作为开发者的您来决定,但为了简化一点,请将网格视为方形(宽度和高度相同),并使用 2 的幂的大小。(这样可让一些数学运算变得更轻松。)您最终希望将其放大,但在本部分的其余部分中,请将网格大小设置为 4x4,这样更便于演示本部分中使用的一些数学运算。后续再扩大规模!

  • 通过在 JavaScript 代码顶部添加一个常量来定义网格大小,

index.html

const GRID_SIZE = 4;

接下来,您需要更新渲染正方形的方式,以便可以在画布上容纳 GRID_SIZE 乘以 GRID_SIZE 的正方形。也就是说,这个正方形需要变小很多,并且需要大量展示。

现在,解决这个问题的一种方法是使顶点缓冲区变得更大,并在其中以正确的大小和位置定义 GRID_SIZE 乘以 GRID_SIZE 的正方形。实际上,这样的代码不会太糟糕!只需几个 for 循环和一些数学运算即可。但这同样没有充分利用 GPU,并且为实现该效果而使用了超出必要的内存。本部分介绍了对 GPU 更友好的方法。

创建 uniform 缓冲区

首先,您需要将所选的网格大小传达给着色器,因为它使用该网格大小来更改内容的显示方式。您只需将大小硬编码到着色器中,但这意味着每次想要更改网格大小时,您都必须重新创建着色器和渲染流水线,而这会占用大量资源。更好的方法是以 uniform 的形式为着色器提供网格大小。

您之前了解到,顶点缓冲区中的不同值会传递给顶点着色器的每次调用。uniform 是缓冲区中的值,该值对每次调用都相同。它们非常适合传递一个几何图形(例如其位置)、整个动画(例如当前时间)甚至应用的整个生命周期(例如用户偏好设置)的常用值。

  • 添加以下代码来创建 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 缓冲区的数组匹配。它还指定 uniform 绑定在 @group(0)@binding(0) 处。稍后您将了解这些值的含义。

然后,在着色器代码中的其他位置,您可以根据需要使用网格矢量。在此代码中,您要用网格矢量除以顶点位置。由于 pos 是 2D 矢量,grid 是 2D 矢量,因此 WGSL 会执行逐分除法。换句话说,结果与 vec2f(pos.x / grid.x, pos.y / grid.y) 相同。

这些类型的矢量运算在 GPU 着色器中非常常见,因为许多渲染和计算技术依赖于它们!

这意味着(如果您使用的网格大小为 4),所渲染的方形将是原始大小的四分之一。如果您想将其中 4 个元素放到一行或一列中,这正是您的理想选择!

创建 Bind 组

不过,在着色器中声明 uniform 不会将其与您创建的缓冲区连接。为此,您需要创建并设置一个 bind 组。

bind 组是您希望同时可供着色器访问的资源集合。它可以包含多种类型的缓冲区(例如 uniform 缓冲区)和其他资源(例如此处未涵盖但属于 WebGPU 渲染技术常见部分的纹理和采样器)。

  • 创建 uniform 缓冲区和渲染流水线后,通过添加以下代码来创建使用 uniform 缓冲区的 bind 组:

index.html

const bindGroup = device.createBindGroup({
  label: "Cell renderer bind group",
  layout: cellPipeline.getBindGroupLayout(0),
  entries: [{
    binding: 0,
    resource: { buffer: uniformBuffer }
  }],
});

除了现在的标准 label 之外,您还需要一个 layout 来描述此 bind 组包含的资源类型。您将在后续步骤中进一步深入了解这一点,但目前,您可以放心地请求流水线提供 bind 组布局,因为您使用 layout: "auto" 创建了流水线。这会使流水线根据您在着色器代码本身中声明的绑定自动创建 bind 组布局。在本例中,您请求 getBindGroupLayout(0),其中 0 对应于您在着色器中输入的 @group(0)

指定布局后,您需要提供一个 entries 数组。每个条目都是一个至少包含以下值的字典:

  • binding,对应于您在着色器中输入的 @binding() 值。在本示例中,该值为 0
  • resource:您要向指定绑定索引处的变量公开的实际资源。在本示例中,该资源为 uniform 缓冲区。

该函数会返回 GPUBindGroup,这是一个不透明的不可变句柄。创建 bind 组后,您便无法更改其指向的资源,但您可以更改这些资源的内容。例如,如果您将 uniform 缓冲区更改为包含新的网格大小,则使用此 bind 组的未来绘制调用会反映这一点。

绑定 bind 组

现在,bind 组已创建完毕,您仍然需要告知 WebGPU 在绘制时使用它。幸运的是,这很简单。

  1. 返回到渲染通道,在 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 都会使用此 bind 组中的资源。

现在,uniform 缓冲区已向着色器公开!

  1. 刷新页面,然后您应该会看到如下内容:

深蓝色背景中央有一个红色小方形。

太棒了!现在,您的方形大小是之前的四分之一!这并不多,但显示出您的 uniform 实际已应用,并且着色器现在可以访问网格的大小。

在着色器中操纵几何图形

现在,您可以在着色器中引用网格大小,接下来可以开始处理要渲染的几何图形,使之适合所需的网格图案。要做到这一点,请认真考虑您想要达到的目的。

您需要在概念上将画布划分为单独的单元格。为了保持 X 轴随着向右移动而增加、Y 轴随着向上移动而增加的惯例,假设第一个单元格位于画布的左下角。这将为您提供如下所示的布局,当前正方形几何图形位于中间:

概念网格的插图,当可视化每个单元格时,标准化设备坐标空间将被划分,当前渲染的方形几何图形位于其中心。

您面临的挑战是在着色器中寻找一种方法,让您可以在给定单元格坐标的任何单元格中放置方形几何图形。

首先,您会发现方形没有与任何单元格很好地对齐,因为它被定义为围绕画布中心。您需要将正方形移动半个单元格,这样它就会恰好在它们内部对齐。

解决此问题的一种方法是更新方形的顶点缓冲区。通过移动顶点,使右下角位于 (0.1, 0.1) 处而不是 (-0.8, -0.8),您可以移动此方形,使其与单元格的边界更好地对齐。但是,由于您可以在着色器中完全控制顶点的处理方式,因此使用着色器代码将顶点微移就位也一样简单!

  1. 使用以下代码更改顶点着色器模块:

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

这会将每个顶点向上和向右移动 1(请记住,这是裁剪空间的一半),然后再将其除以网格大小。其结果就是一个离原点正好相邻的网格对齐的正方形。

画布的可视化效果从概念上分为 4x4 网格,单元格 (2, 2) 中有一个红色方形

接下来,由于画布坐标系的中心位置是 (0,0),左下方是 (-1, -1),而您希望 (0,0) 位于左下角,因此除以网格大小后,您需要将几何图形的位置平移 (-1, -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) 中也找到了恰当的位置!

画布的可视化效果从概念上分为 4x4 网格,单元格 (0, 0) 中有一个红色方形

如果要将其放入其他单元格,该怎么办?在着色器中声明一个 cell 矢量,并用 let cell = vec2f(1, 1) 等静态值填充该矢量,即可解决此问题。

如果将其添加到 gridPos,它会撤消算法中的 - 1,因此这并不是您想要的结果。相反,您需要针对每个单元格仅按一个网格单位(画布四分之一)移动正方形。您似乎需要再除以 grid

  1. 请按如下方式更改网格位置:

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

如果现在刷新,您会看到以下内容:

画布的可视化效果从概念上分为 4x4 网格,在单元格 (0, 0)、单元格 (0, 1)、单元格 (1, 0) 和单元格 (1, 1) 中间有一个红色正方形

嗯,这可不是您所期望的情况。

这是因为画布坐标从 -1 变为 +1,所以实际上是移动了 2 个单位。也就是说,如果要将顶点移过画布的四分之一,则必须将其移动 0.5 个单位。在使用 GPU 坐标进行推理时,很容易犯这个错误!幸运的是,解决方法也很简单。

  1. 将偏移量乘以 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);
}

这正是您想要的结果。

画布的可视化效果从概念上分为 4x4 网格,单元格 (1, 1) 中有一个红色方形

屏幕截图如下所示:

深蓝色背景上的红色方形的屏幕截图。与上图所示的相同位置绘制的红色方形,但没有网格叠加层。

此外,您现在可以将 cell 设置为网格边界内的任意值,然后进行刷新,即可在所需位置查看方形的渲染效果。

绘制实例

现在,您可以通过一些数学运算将方形放置在所需位置,下一步是在网格的每个单元格中渲染一个方形。

一种方法是将单元格坐标写入 uniform 缓冲区,然后对网格中的每个方形调用 draw 一次,每次都更新 uniform。不过,这会非常慢,因为 GPU 每次都必须等待 JavaScript 写入新坐标。使 GPU 获得良好性能的一个关键点是最大限度减少 GPU 在等待系统其他部分上所花的时间!

不过,您可以使用一种称为“实例化”的技术。实例化是一种告知 GPU 通过单次调用 draw 绘制同一几何图形的多个副本的方式,这种方式比针对每个副本调用一次 draw 快得多。几何图形的每个副本都称为一个实例。

  1. 如需告知 GPU 您需要足够的方形实例来填充网格,请向现有绘制调用添加一个参数:

index.html

pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);

这会告知系统您希望它绘制正方形的六 (vertices.length / 2) 个顶点 16 (GRID_SIZE * GRID_SIZE) 次。但是,如果刷新页面,您仍然会看到以下内容:

与上一个图表相同的图片,表示没有发生任何变化。

原因何在呢?这是因为您将 16 个方形全都画在同一位置。您需要在着色器中添加一些额外的逻辑,以便按实例调整几何图形的位置。

在着色器中,除了来自顶点缓冲区的 pos 等顶点属性外,您还可以访问所谓的 WGSL 内置值。这些值由 WebGPU 计算,其中一个值就是 instance_indexinstance_index 是一个介于 0number of instances - 1 之间的无符号 32 位数字,您可以将其用作着色器逻辑的一部分。对于属于同一实例的每个所处理的顶点,其值都相同。这意味着,系统会在 instance_index0 的情况下调用 6 次顶点着色器,分别针对顶点缓冲区中的每个位置。然后再调用 6 次(instance_index1),接着再调用 6 次(instance_index2),依此类推。

如需实际查看该过程,您必须将内置的 instance_index 添加到着色器输入中。执行此操作的方式与指定位置相同,但不要使用 @location 属性对其进行标记,而是使用 @builtin(instance_index),然后根据自己的喜好为参数命名。(您可以将其命名为 instance,以与示例代码匹配。)然后,将其用作着色器逻辑的一部分!

  1. 使用 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,使每个索引都映射到网格中的唯一单元格,如下所示:

画布的可视化效果从概念上分为 4x4 网格,每个单元格也对应一个线性实例索引。

其数学运算非常简单。对于每个单元格的 X 值,您需要 instance_index模数和网格宽度,您可以在 WGSL 中使用 % 运算符对其执行运算。对于每个单元格的 Y 值,您需要 instance_index 除以网格宽度,并舍弃任何小数余数。您可以使用 WGSL 的 floor() 函数来实现此目的。

  1. 更改计算方式,如下所示:

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

对代码进行上述更新后,您终于有了期待已久的方形网格!

深蓝色背景上有四行四列红色正方形。

  1. 现在它可以正常运行了,请返回并增加网格大小!

index.html

const GRID_SIZE = 32;

深蓝色背景上有 32 行 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 函数中设置该坐标,然后再返回。

  1. 更改顶点着色器的返回值,如下所示:

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;
}
  1. @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);
}
  1. 或者,您也可以改用结构体:

index.html(createShaderModule 调用)

struct FragInput {
  @location(0) cell: vec2f,
};

@fragment
fn fragmentMain(input: FragInput) -> @location(0) vec4f {
  return vec4f(input.cell, 0, 1);
}
  1. 在您的代码中,这两个函数都是在同一着色器模块中定义的,因此另一种替代方案是重复使用 @vertex 阶段的输出结构体!这使得值传递变得简单,因为名称和位置自然一致。

index.html(createShaderModule 调用)

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  return vec4f(input.cell, 0, 1);
}

无论您选择哪种模式,结果都是您可以访问 @fragment 函数中的单元格编号,并且能够使用该编号来影响颜色。使用上述任何代码,输出结果将如下所示:

一个方格网格,最左边的列是绿色,最后一行是红色,所有其他方形是黄色。

现在当然还有更多颜色了,不过看起来不是很漂亮。您可能想知道为什么只有左列和底行有所不同。这是因为,从 @fragment 函数返回的颜色值希望每个通道都介于 0 到 1 之间,并且该范围以外的任何值都会限制在 0 到 1 的范围内。另一方面,每个轴的单元格值介于 0 到 32 之间。因此,您在这里可以看到,第一行和第一列立即匹配红色或绿色通道上的 1 个完整值,并且其后面的每个单元格都限定为相同的值。

如果您希望颜色之间的过渡更流畅,则需要为每个颜色通道返回一个小数值,最好从 0 开始,并沿每个轴 1 结束,这意味着再除以 grid

  1. 更改 fragment 着色器,如下所示:

index.html(createShaderModule 调用)

@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
  return vec4f(input.cell/grid, 0, 1);
}

刷新页面,您会发现新代码确实在整个网格中呈现了更好的颜色渐变。

一个由不同角落从黑色、红色、绿色再到黄色过渡的正方形网格。

虽然这确实是一个进步,但现在左下角有一个难看的黑暗角落,网格变黑了。当您开始进行《Game of Life》模拟时,网格上很难看清的部分会使正在发生的变化情况变得模糊,把它提亮就好了。

幸运的是,您有一个未使用的颜色通道(蓝色),可以使用它。理想情况下,您想要的效果是让蓝色在其他颜色最暗的地方最亮,然后随着其他颜色的强度的增加而淡出。最简单的方法是将通道的起点设为 1,然后减去其中一个单元格的值。可为 c.xc.y。请尝试这两项,然后选择您喜欢的一项!

  1. 向 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 上存储的某种状态来控制网格上的哪些单元格渲染。这对于最终模拟非常重要!

您只需要为每个单元格提供一个开启/关闭信号,因此任何可让您存储几乎任何值类型的大型数组的选项都可以。您可能会认为,这是 uniform 缓冲区的另一个用例!虽然您可以做到这一点,但它更加困难,因为 uniform 缓冲区的大小有限,无法支持动态大小的数组(您必须在着色器中指定数组大小),并且无法由计算着色器写入。最后一项是最有问题的,因为您想要在计算着色器中的 GPU 上执行《Game of Life》模拟。

幸运的是,还有一个缓冲区选项可以避免所有这些限制。

创建存储缓冲区

存储缓冲区是通用缓冲区,可以在计算着色器中读取和写入,并在顶点着色器中读取。它们可能非常大,并且不需要在着色器中声明特定大小,因此它们更类似于常规内存。这就是您用来存储单元格状态的内容。

  1. 要为单元格状态创建存储缓冲区,请使用目前可能开始使用的缓冲区创建代码段,看上去十分熟悉:

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,
});

就像顶点和 uniform 缓冲区一样,使用适当的大小调用 device.createBuffer(),然后确保这次指定 GPUBufferUsage.STORAGE 的用法。

您可以采用与之前相同的方式填充缓冲区,方法是使用值填充相同大小的 TypedArray,然后调用 device.queue.writeBuffer()。由于您想在网格中查看缓冲区的效果,因此请先用可预测的内容填充它。

  1. 使用以下代码激活每个第三个单元格:

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

在着色器中读取存储缓冲区

接下来,更新着色器以在渲染网格之前查看存储缓冲区的内容。这与之前添加 uniform 的方式非常相似。

  1. 使用以下代码更新着色器:

index.html

@group(0) @binding(0) var<uniform> grid: vec2f;
@group(0) @binding(1) var<storage> cellState: array<u32>; // New!

首先,添加绑定点,该绑定点隐藏在网格 uniform 正下方。您希望保持 @groupgrid uniform 相同,但 @binding 编号必须不同。var 类型为 storage,以便反映不同类型的缓冲区,并且您为 cellState 指定的类型是一个由 u32 值组成的数组(而不是单个矢量),以便匹配 JavaScript 中的 Uint32Array

接下来,在 @vertex 函数的正文中,查询单元格的状态。由于状态存储在存储缓冲区内的平面数组中,因此您可以使用 instance_index 来查找当前单元格的值!

如果状态表明某个单元格处于非活跃状态,您如何关闭该单元格?由于您从数组获取的活跃和非活跃状态是 1 或 0,因此您可以按活跃状态缩放几何图形!如果缩放比例为 1,则将只保留几何图形;而将其缩放到 0 会使几何图形聚合为一个点,然后 GPU 会舍弃该点。

  1. 更新着色器代码,以根据单元格的活跃状态缩放位置。状态值必须转换为 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;
}

将存储缓冲区添加到 bind 组

将存储缓冲区添加到 bind 组,然后单元格状态才会生效。由于它与 uniform 缓冲区属于同一 @group,因此也应将其添加到 JavaScript 代码中的同一 bind 组。

  • 添加存储缓冲区,如下所示:

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.

但是,如果您运行该代码,活跃单元格一步到位会一直移动到数组的末尾!原因何在呢?因为您一直在原地更新状态,所以将活跃单元格向右移动,然后查看下一个单元格,嘿!它是活跃单元格!最好再向右移动一次。如果您在发现数据的同时更改了数据,就会损坏结果。

使用乒乓球模式,可以确保始终只使用上一步的结果来执行模拟的下一步。

// 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); 
  1. 在您自己的代码中使用此模式,可以通过更新存储缓冲区分配来创建两个相同的缓冲区:

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,
  })
];
  1. 为了直观呈现两个缓冲区之间的差异,请以不同的数据填充它们:

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);
  1. 如需在渲染中显示不同的存储缓冲区,请更新 bind 组,使其也具有两种不同的变体:

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 次)安排回调。

此应用也可以使用此功能,但在本例中,您可能需要以较长的步骤进行更新,以便更轻松地按照模拟的进度进行操作。请改为自行管理循环,以便控制模拟的更新频率。

  1. 首先,为我们的模拟选择一个更新速率(200 毫秒即可,但如果您愿意,可以放慢或加快速度),然后跟踪已完成的模拟步数。

index.html

const UPDATE_INTERVAL = 200; // Update every 200ms (5 times/sec)
let step = 0; // Track how many simulation steps have been run
  1. 然后,将您当前用于渲染的所有代码移至一个新函数中。使用 setInterval() 让该函数以所需的间隔时间重复运行。确保该函数也会更新步数,并用它来选择要绑定两个 bind 组中的哪一个。

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. 运行模拟

现在,我们想要了解的最后一个重要方面:在计算着色器中执行《Game of Life》模拟!

终于使用计算着色器了!

在此 Codelab 中,您已经以抽象的方式学习了计算着色器,但它们到底是什么呢?

计算着色器与顶点和 fragment 着色器类似,因为它们都设计为在 GPU 上以极端并行性运行,但与其他两个着色器阶段不同,它们没有一组特定的输入和输出。您只从所选来源(例如存储缓冲区)读取数据以及将数据写入所选来源。这意味着,您不必为每个顶点、实例或像素执行一次,而是必须告知您想要调用着色器函数的次数。然后,当您运行着色器时,系统会告知您正在处理哪个调用,并且可以决定要访问哪些数据以及从中执行哪些操作。

计算着色器必须在着色器模块中创建,就像顶点和 fragment 着色器一样,因此请将其添加到代码中以开始使用。您可能已经猜到,根据您实现的其他着色器的结构,计算着色器的主函数需要使用 @compute 属性来标记。

  1. 使用以下代码创建计算着色器:

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 x GRID_SIZE 次,每个模拟单元格分别调用一次。

鉴于 GPU 硬件架构的性质,此网格分为多个工作组。工作组的大小有 X、Y 和 Z,虽然每个组的大小可以为 1,但扩大工作组的大小通常会有性能方面的优势。对于着色器,可以选择 8 x 8 的任意工作组大小。这对于在 JavaScript 代码中执行跟踪非常有用。

  1. 为工作组大小定义一个常量,如下所示:

index.html

const WORKGROUP_SIZE = 8;

您还需要使用 JavaScript 的模板字面量将工作组大小添加到着色器函数本身中,以便您可以轻松使用刚刚定义的常量。

  1. 将工作组大小添加到着色器函数中,如下所示:

index.html(计算 createShaderModule 调用)

@compute
@workgroup_size(${WORKGROUP_SIZE}, ${WORKGROUP_SIZE}) // New line
fn computeMain() {

}

这会告知着色器使用此函数完成的工作是在 (8 x 8 x 1) 组中完成的。(您保留的任何轴都默认为 1,但至少需要指定 X 轴。)

与其他着色器阶段一样,您可以接受各种 @builtin 值作为计算着色器函数的输入,以告知您正在进行哪个调用并决定需要执行哪些工作。

  1. 添加 @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。

  1. 对计算着色器使用 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 颜色),因此将值写入存储缓冲区或纹理是从计算着色器中获取结果的唯一方式。使用您之前学习的乒乓球方法;您有一个用于馈送网格当前状态的存储缓冲区,以及一个用于写入网格新状态的存储缓冲区。

  1. 将单元格输入和输出状态作为存储缓冲区公开,如下所示:

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))。)

  1. 编写一个函数,使其指向另一个方向。它会获取单元格的 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》,但足以表明计算着色器正在运行。

  1. 添加简单的算法,如下所示:

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;
  }
}

计算着色器到这里就结束了!不过,在看到结果之前,您还需要再进行一些更改。

使用 bind 组和流水线布局

您可能注意到上述着色器在很大程度上使用的是与渲染流水线相同的输入(uniform 和存储缓冲区)。因此,您可能会认为,您可以直接使用相同的 bind 组,然后完成相应操作,对吗?好消息是,您可以这么做!为此,您需要进行更多手动设置。

每次创建 bind 组时,您都需要提供 GPUBindGroupLayout。以前,您可以通过对渲染流水线调用 getBindGroupLayout() 来获取该布局,由于您在创建该流水线时提供了 layout: "auto",因此该流水线反过来是自动创建的。如果您只使用单个流水线,那么这种方法非常有效,但如果您有多个需要共享资源的流水线,则需要显式创建布局,然后将其提供给 bind 组和流水线。

为帮助理解原因,请考虑以下情形:在渲染流水线中,您使用单个 uniform 缓冲区和单个存储缓冲区,但在您刚刚编写的计算着色器中,还需要另一个存储缓冲区。由于这两个着色器为 uniform 存储缓冲区和第一个存储缓冲区使用相同的 @binding 值,因此您可以在流水线之间共享这些值,而渲染流水线会忽略它不使用的第二个存储缓冲区。您需要创建一个布局,用于描述 bind 组中存在的所有资源,而不仅仅是特定流水线使用的资源。

  1. 如需创建该布局,请调用 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
  }]
});

这与创建 bind 组本身的结构类似,因为您需要描述 entries 的列表。不同之处在于,您需要描述条目必须属于何种资源类型及其使用方式,而不是提供资源本身。

在每个条目中,您都需要提供资源的 binding 编号,该编号(您在创建 bind 组时学到的)与着色器中的 @binding 值匹配。您还可以提供 visibility,这些标志是 GPUShaderStage 标记,用于指示哪些着色器阶段可以使用相应资源。您希望 uniform 和第一个存储缓冲区都可以在顶点和计算着色器中访问,但第二个存储缓冲区只需要可以在计算着色器中访问。

最后,指明所使用的资源类型。这是一个不同的字典键,具体取决于您需要公开的内容。此处,所有三个资源都是缓冲区,因此您使用 buffer 键为每个资源定义选项。其他选项包括 texturesampler 等,但此处不需要这些选项。

在缓冲区字典中,您可以设置相关选项,例如使用缓冲区的 type。默认值为 "uniform",因此对于绑定 0,您可以将字典留空。(不过,您必须至少设置 buffer: {},以便条目被标识为缓冲区。)绑定 1 被赋予 "read-only-storage" 类型,因为您不将其与着色器中的 read_write 访问权限一起使用,而绑定 2 具有 "storage" 类型,因为您确实将其与 read_write 访问权限一起使用!

创建 bindGroupLayout 后,您可以在创建 bind 组时传入它,而无需从流水线查询 bind 组。这样做意味着您需要向每个 bind 组添加新的存储缓冲区条目,以便匹配您刚刚定义的布局。

  1. 更新 bind 组的创建,如下所示:

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

现在,bind 组已更新为使用此显式 bind 组布局,您需要更新渲染流水线以使用相同的功能。

  1. 创建 GPUPipelineLayout

index.html

const pipelineLayout = device.createPipelineLayout({
  label: "Cell Pipeline Layout",
  bindGroupLayouts: [ bindGroupLayout ],
});

流水线布局是一个或多个流水线使用的 bind 组布局(在本例中,您有一个)的列表。数组中 bind 组布局的顺序需要与着色器中的 @group 属性相对应。(这意味着 bindGroupLayout@group(0) 相关联。)

  1. 创建流水线布局后,请更新渲染流水线以使用它,而不是 "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
    }]
  }
});

创建计算流水线

就像您需要渲染流水线来使用顶点和 fragment 着色器一样,您需要计算流水线来使用计算着色器。幸运的是,计算流水线比渲染流水线简单得多,因为计算流水线无需设置任何状态,而只需着色器和布局。

  • 使用以下代码创建计算流水线:

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",就像更新后的渲染流水线一样,这可确保渲染流水线和计算流水线使用相同的 bind 组。

计算通道

至此,您就能够实际使用计算流水线了!鉴于您在渲染通道中进行渲染,您可能会猜到需要通过计算通道执行计算工作。计算和渲染工作可以在同一个命令编码器中进行,因此您需要稍微打乱 updateGrid 函数。

  1. 将创建编码器的操作移到函数顶部,然后使用该函数开始计算遍历(在 step++ 之前)。

index.html

// In updateGrid()
// Move the encoder creation to the top of the function.
const encoder = device.createCommandEncoder();

const computePass = encoder.beginComputePass();

// Compute work will go here...

computePass.end();

// Existing lines
step++; // Increment the step count
  
// Start a render pass...

就像计算流水线一样,计算通道比渲染流水线更容易启动,因为您无需担心任何附件。

您应在渲染通道之前执行计算通道,因为这可以让渲染通道立即使用计算通道中的最新结果。这也是您在两次传递之间递增 step 计数的原因,使计算流水线的输出缓冲区成为渲染流水线的输入缓冲区。

  1. 接下来,在计算通道内设置流水线和 bind 组,使用与渲染通道相同的模式在 bind 组之间切换。

index.html

const computePass = encoder.beginComputePass();

// New lines
computePass.setPipeline(simulationPipeline);
computePass.setBindGroup(0, bindGroups[step % 2]);

computePass.end();
  1. 最后,您不像在渲染通道中那样绘制,而是将工作分派给计算着色器,告知它您想要在每个轴上执行多少个工作组。

index.html

const computePass = encoder.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》的算法

在更新计算着色器以实现最终算法之前,您需要回到初始化存储缓冲区内容的代码,并更新该代码以在每次加载页面时生成随机缓冲区。(规则模式无法作为非常有趣的《Game of Life》起点。)您可以根据需要对值进行随机化处理,但有一种简单的方法可以带来合理的结果。

  1. 如需以随机状态启动每个单元格,请将 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);

现在,您终于可以实现《Game of Life》模拟所需的逻辑了。经过一切努力,着色器代码可能简单得令人失望了!

首先,您需要了解对于任何给定单元格,有多少相邻单元格处于活跃状态。您不在意哪些单元格处于活跃状态,只关心数量。

  1. 为了更轻松地获取相邻单元格数据,请添加一个 cellActive 函数,用于返回给定坐标的 cellStateIn 值。

index.html(计算 createShaderModule 调用)

fn cellActive(x: u32, y: u32) -> u32 {
  return cellStateIn[cellIndex(vec2(x, y))];
}

如果单元格处于活跃状态,则 cellActive 函数会返回 1。因此,如果对周围全部 8 个单元格调用 cellActive,返回值就会得出处于活跃状态的相邻单元格数量。

  1. 查找活跃相邻项的数量,如下所示:

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() 逻辑,它要么溢出到下一行或上一行,要么离开缓冲区的边缘!

在《Game of Life》中,一种常见的简单解决方法是让位于网格边缘的单元格将位于网格对面的单元格作为相邻单元格,从而创造一种环绕效果。

  1. 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 计数是可预测的。

然后应用以下四条规则之一:

  • 任何相邻项少于两个的单元格都会变为非活跃状态。
  • 任何具有两个或三个相邻单元格的活跃单元格都会保持活跃状态。
  • 任何正好有三个相邻项的非活跃单元格都会变为活跃状态。
  • 任何具有三个以上相邻项的单元格都将变为非活跃状态。

您可以使用一系列 if 语句执行此操作,但 WGSL 也支持 switch 语句,这些语句非常适合此逻辑。

  1. 实现《Game of Life》逻辑,如下所示:

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;
        }
      }
    }
  `
});

大功告成!大功告成!刷新您的页面并观察您新构建的单元格自动机的成长!

《Game of Life》模拟示例状态的屏幕截图,其中彩色小区以深蓝色背景呈现。

9. 恭喜!

您已使用 WebGPU API 创建了传统版 Conway 的《Game of Life》模拟版本,该版本完全在 GPU 上运行!

后续操作

深入阅读

参考文档