WebGPU 是一种全新的现代API,用于在 Web 应用程序中访问 GPU 的功能。在 WebGPU 之前,有 一种WebGL技术,它提供了 WebGPU 功能的子集。 而WebGPU启用了新一类丰富的网络内容,开发人员可以用它构建了令人惊叹的应用。其历史可以追溯到2007 年发布的 OpenGL ES 2.0 API,而该 API 又基于更旧的 OpenGL API。 WebGPU 将这些现代 API 的进步带到了 Web 平台。 它专注于以跨平台的方式启用 GPU 功能,同时提供一个在网络上感觉自然的 API,并且比它所构建的一些本机 API 更简洁。
GPU 通常与渲染快速、详细的图形联系在一起,WebGPU 也不例外。 它具有支持当今桌面和移动 GPU 上许多最流行的渲染技术所需的功能,并为未来随着硬件功能的不断发展添加新功能提供了途径。除了渲染之外,WebGPU 还可以释放 GPU 执行通用、高度并行工作负载的潜力。 这些计算着色器可以独立使用,无需任何渲染组件,也可以作为渲染管道的紧密集成部分。
一、初始化WebGPU
如果你只想使用 WebGPU 进行计算,那么则无需在屏幕上显示任何内容就可以体验WebGPU。 但是,如果你想要再屏幕上渲染内容,就像我们将在 Codelab 中所做的那样,你需要准备一个画布。
1.1 从canvas开始
首先,我们创建一个新的 HTML 文档,然后准备一个canvas元素,用于绘制具体的内容,代码如下:
<!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>
1.2 GPU设备
由于WebGPU是一项新的技术,所以第一步是检查用户的浏览器是否可以运行WebGPU。要检查充当 WebGPU 入口点的 navigator.gpu 对象是否存在,请添加以下代码:
if (!navigator.gpu) {
throw new Error("WebGPU not supported on this browser.");
}
如果 浏览器的WebGPU 不可用,那么可以让页面回退到不使用 WebGPU 的模式来通知用户。
如果浏览器支持 WebGPU,那么初始化 WebGPU 的第一步就是请求 GPUAdapter。 可以将适配器视为设备中特定 GPU 硬件的 WebGPU 表示。
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
throw new Error("No appropriate GPUAdapter found.");
}
在上面的代码中,如果找不到合适的适配器,则返回的适配器值可能为 null,因此你需要处理这种可能性。 如果用户的浏览器支持 WebGPU,但他们的 GPU 硬件不具备使用 WebGPU 所需的所有功能就可能会发生这种情况。
大多数时候,只需让浏览器选择一个默认适配器就可以了。但是对于更高级的需求,可以将参数传递给 requestAdapter() 来指定是要使用低功耗还是高功耗,不过这种只能针对有多个 GPU 的设备(如某些笔记本电脑)上的性能硬件。
拥有适配器后,开始使用 GPU 之前的最后一步是请求 GPUDevice,设备是与 GPU 进行大多数交互的主要接口。通过调用adapter.requestDevice()获取设备,它也会返回一个Promise对象。
const device = await adapter.requestDevice();
与 requestAdapter()的用法一样,可以在此处传递一些选项以实现更高级的用途。例如启用特定的硬件功能或请求更高的限制。
1.3 配置canvas
现在已经有了一个设备,如果想使用它来显示页面上的任何内容,还需要做一件事:将画布配置为与刚刚创建的设备一起使用。为此,首先通过调用 canvas.getContext("webgpu") 从画布请求 GPUCanvasContext。 这与您用来初始化 Canvas 2D 或 WebGL 上下文的调用相同,分别使用 2d 和 webgl 上下文类型。然后,它返回的上下文必须使用 configure() 方法与设备关联,例如 :
const context = canvas.getContext("webgpu");
const canvasFormat = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device: device,
format: canvasFormat,
});
在上面的代码中,我们可以传递一些选项,但最重要的是你要使用上下文的设备和格式,即上下文应使用的纹理格式。
纹理是 WebGPU 用于存储图像数据的对象,每个纹理都有一种格式,可以让 GPU 了解数据在内存中的布局方式。 纹理内存工作原理的详细信息超出了本 Codelab 的范围。 需要了解的重要一点是,canvas上下文为你的代码提供了要绘制的纹理,并且你使用的格式可能会影响画布显示这些图像的效率。 不同类型的设备在使用不同的纹理格式时性能最佳,如果你不使用设备的首选格式,则可能会导致在图像作为页面的一部分显示之前在幕后发生额外的内存复制。
幸运的是,你不必太担心这些,因为 WebGPU 会告诉你画布使用哪种格式。 几乎在所有情况下,我们都可以通过调用以下方法的返回的值来确定使用哪种格式。
navigator.gpu.getPreferredCanvasFormat()
1.4 清除canvas
在使用canvas之前,我们每次都需要用纯色来清除它。为了实现这一目标,我们需要先创建一个 GPUCommandEncoder,它提供用于记录 GPU 命令的接口。
const encoder = device.createCommandEncoder();
接着,我们就可以发送给GPU渲染相关的命令,然后是使用编码器开始渲染通道。渲染通道是指 WebGPU 中的所有绘图操作发生时。
每个都以 beginRenderPass() 调用开始,该调用定义接收所执行的任何绘图命令的输出的纹理。 更高级的用途可以提供多种纹理(称为附件),具有各种用途,例如存储渲染几何体的深度或提供抗锯齿。 以下是一个使用示例:
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
loadOp: "clear",
storeOp: "store",
}]
});
在上面的代码中,纹理作为 colorAttachment 的 view 属性给出。 渲染通道要求你提供 GPUTextureView 而不是 GPUTexture,它告诉它要渲染到纹理的哪些部分。 这仅对于更高级的用例才真正重要,因此在这里我们调用 createView() 时不需要带纹理参数,表明希望渲染通道使用整个纹理。同时,还必须指定渲染通道在开始和结束时对纹理执行的操作:
- loadOp 值为“clear”,表示我们希望在渲染通道开始时清除纹理。
- storeOp 值为“store”,表示渲染通道完成后,我们希望将渲染通道期间完成的任何绘制的结果保存到纹理中。
需要说明的是,仅仅进行这些调用并不会导致GPU实际上执行任何操作,它们只是记录命令供 GPU 稍后执行。为了创建 GPUCommandBuffer,我们需要在命令编码器上调用 finish()。
const commandBuffer = encoder.finish();
使用GPUDevice的队列将命令缓冲区提交给GPU, 队列执行所有 GPU 命令,确保它们的执行有序且正确同步,队列的submit()方法会接受一组命令到缓冲区。
device.queue.submit([commandBuffer]);
一旦提交命令到缓冲区,就无法再次使用它,因此也无需在保留任何命令。 如果要提交更多命令,则需要构建另一个命令缓冲区。
// Finish the command buffer and immediately submit it.
device.queue.submit([encoder.finish()]);
将命令提交给 GPU 后,JavaScript会将控制权返回给浏览器。 此时,浏览器会发现我们已更改上下文的当前纹理,并更新画布以将该纹理显示为图像。 如果之后想再次更新画布内容,则需要记录并提交新的命令到缓冲区,然后再次调用 context.getCurrentTexture() 来获取渲染通道的新纹理。
重新加载页面, 此时画布充满了黑色,如下图。
1.5 更换颜色
在 device.beginRenderPass() 调用中,向 colorAttachment 添加一个带有clearValue 的新行,如下所示:
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 指示渲染通道在通道开始时执行清除操作时应使用哪种颜色。 传递给它的字典包含四个值:r 代表红色,g 代表绿色,b 代表蓝色,a 代表 alpha(透明度)。 每个值的范围为 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中的示例代码和屏幕截图使用深蓝色,但你可以随意选择想要的任何颜色。
二、绘制几何图形
2.1 如何绘图
与 Canvas 2D 等具有大量形状和选项可供选择的API 不同, GPU 实际上只能处理几种不同类型的形状,比如点、线和三角形。
GPU 几乎专门只处理三角形,因为三角形具有许多良好的数学属性,使它们易于以可预测且高效的方式进行处理。 几乎所有用 GPU 绘制的东西都需要先被分割成三角形,然后 GPU 才能绘制它,并且这些三角形必须由它们的角点定义。
这些点或顶点以 X、Y 和(对于 3D 内容)Z 值的形式给出,这些值定义了由 WebGPU 或类似 API 定义的笛卡尔坐标系上的点。 坐标系的结构最容易考虑它与页面上画布的关系,无论画布有多宽或多高,左边缘始终位于 X 轴上的 -1 处,右边缘始终位于 X 轴上的 +1 处。 同样,Y 轴上的底部边缘始终为 -1,Y 轴上的顶部边缘始终为 +1。 这意味着 (0, 0) 始终是画布的中心,(-1, -1) 始终是左下角,(1, 1) 始终是右上角,这称为剪辑空间(Clip Space)。
顶点最初很少在此坐标系中定义,因此 GPU 依靠称为顶点着色器(Vertex Shader)的程序来执行将顶点转换为剪辑空间所需的任何数学运算,以及绘制顶点所需的任何其他计算。 例如,着色器可以应用一些动画或计算从顶点到光源的方向。
然后,GPU 获取由这些变换后的顶点组成的所有三角形,并确定需要屏幕上的哪些像素来绘制它们。 然后它运行我们编写的另一个程序,称为片段着色器(fragment shader),用于计算每个像素应该是什么颜色。 该计算可以像返回绿色一样简单,也可以像计算表面相对于从附近其他表面反射的阳光的角度一样复杂,通过雾过滤,并根据表面的金属程度进行修改。
2.2 定义顶点
如前所述,生命游戏模拟显示为单元格网格。 我们的应用程序需要一种可视化网格的方法,区分活动单元格和非活动单元格。 此Codelab使用的方法是在活动单元格中绘制彩色方块,并将非活动单元格留空。
这意味着我们需要为 GPU 提供四个不同的点,每个点对应正方形的四个角。 例如,在画布中心绘制的正方形,从边缘拉入一定距离,其角坐标如下:
为了将这些坐标提供给 GPU,我们需要将这些值放入 TypedArray 中。事实上,TypedArray 是一组 JavaScript 对象,允许我们分配连续的内存块并将系列中的每个元素解释为特定的数据类型。 例如,在 Uint8Array 中,数组中的每个元素都是单个无符号字节。 TypedArray 非常适合使用对内存布局敏感的 API 来回发送数据,例如 WebAssembly、WebAudio 和WebGPU。
对于平方示例,因为值是小数,所以 Float32Array 是合适的。通过在代码中放置以下数组声明来创建一个包含图中所有顶点位置的数组,例如:
const vertices = new Float32Array([
// X, Y,
-0.8, -0.8,
0.8, -0.8,
0.8, 0.8,
-0.8, 0.8,
]);
但有一个问题: GPU 按照三角形工作, 因此,这意味着我们必须以三个为一组提供顶点。解决方案是重复两个顶点以创建两个三角形,它们共享穿过正方形中间的一条边。
要从图中形成正方形,我们必须列出 (-0.8, -0.8) 和 (0.8, 0.8) 顶点两次,一次用于蓝色三角形,一次用于红色三角形。 也可以选择将正方形与其他两个角分开;所以拆分后的顶点数组如下。
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 渲染它们时没有间隙。 它将呈现为单个实心正方形。
2.3 创建顶点缓冲区
GPU 无法使用 JavaScript 数组中的数据绘制顶点,GPU 通常拥有针对渲染进行高度优化的自己的内存,因我们希望 GPU 在绘制时使用的任何数据都需要放置在该内存中。
对于许多值(包括顶点数据),GPU 端内存是通过 GPUBuffer 对象进行管理的。 缓冲区是 GPU 可以轻松访问并标记用于某些目的的内存块,我们可以将其想象为有点像 GPU 可见的 TypedArray。
要创建缓冲区来保存顶点,请在顶点数组的定义之后添加对 device.createBuffer() 的以下调用,如下所示。
const vertexBuffer = device.createBuffer({
label: "Cell vertices",
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
首先要注意的是我们需要给缓冲区设置一个标签,这样,创建的每个 WebGPU 对象都可以被赋予一个可选标签。标签可以是任何类型的字符串,只要它能帮助你识别对象是什么。 如果遇到任何问题,WebGPU 生成的错误消息中会使用这些标签来帮助我们跟进问题。
接下来,给出缓冲区的大小(以字节为单位)。 我们需要一个 48 字节的缓冲区,可以通过将 32 位浮点数(4 字节)的大小乘以顶点数组中的浮点数 (12) 来确定。 并且,TypedArrays 已经为我们计算了它们的 byteLength,因此可以在创建缓冲区时使用它。
最后,我们需要指定缓冲区的使用情况。 这是一个或多个 GPUBufferUsage 标志,多个标志按位或组合在一起。 在这种情况下,我们指定希望缓冲区用于顶点数据 (GPUBufferUsage.VERTEX),并且还希望能够将数据复制到其中 (GPUBufferUsage.COPY_DST)。
同时,返回给我们的缓冲区对象是不透明的 ,即无法(轻松)检查它保存的数据。 此外,它的大多数属性都是不可变的:创建 GPUBuffer 后我们无法调整其大小,也无法更改使用标志。 可以更改的是其内存的内容。
当缓冲区最初创建时,它包含的内存将被初始化为零。 有多种方法可以更改其内容,但最简单的方法是使用要复制的 TypedArray 调用 device.queue.writeBuffer() ,如下所示。
device.queue.writeBuffer(vertexBuffer, /*bufferOffset=*/0, vertices);
2.4 定义顶点布局
现在,我们有了一个包含顶点数据的缓冲区,但就 GPU 而言,它只是一个字节块。 如果想用它绘制任何东西,需要提供更多的信息。 我们需要能够告诉 WebGPU 有关顶点数据结构的更多信息。比如,使用 GPUVertexBufferLayout 字典定义顶点数据结构:
const vertexBufferLayout = {
arrayStride: 8,
attributes: [{
format: "float32x2",
offset: 0,
shaderLocation: 0, // Position, see vertex shader
}],
};
arrayStride属性是 GPU 在查找下一个顶点时需要在缓冲区中向前跳过的字节数。 正方形的每个顶点都由两个 32 位浮点数组成。 前面提到,一个 32 位浮点数是 4 个字节,所以两个浮点数是 8 个字节。
接下来是attributes 属性,它是一个数组。 属性是编码到每个顶点中的单独信息, 我们的顶点仅包含一个属性(顶点位置),但更高级的用例经常包含具有多个属性的顶点,例如顶点的颜色或几何表面指向的方向。
2.5 从着色器开始
着色器是我们编写并在 GPU 上执行的小程序。 每个着色器在不同的数据阶段上运行:顶点处理、片段处理或一般计算。 因为它们位于 GPU 上,所以它们的结构比普通 JavaScript 更严格。 但这种结构使它们能够非常快速地执行,而且最重要的是,可以并行执行!
WebGPU 中的着色器是用称为 WGSL(WebGPU 着色语言)的着色语言编写的。 从语法上讲,WGSL 有点像 Rust,其功能旨在使常见类型的 GPU 工作(如向量和矩阵数学)更容易、更快。 教授整个着色语言远远超出了本 Codelab 的范围,但希望您在完成一些简单示例时能够掌握一些基础知识。
着色器本身作为字符串传递到 WebGPU。通过将以下内容复制到 vertexBufferLayout 下方的代码中,创建一个用于输入着色器代码的位置:
const cellShaderModule = device.createShaderModule({
label: "Cell shader",
code: `
// Your shader code will go here
`
});
创建着色器需要调用 device.createShaderModule(),向其提供可选标签和 WGSL 代码作为字符串。 需要注意的是,添加一些有效的 WGSL 代码后,该函数将返回一个包含编译结果的 GPUShaderModule 对象。
2.6 定义顶点着色器
顶点着色器被定义为一个函数,GPU 为 vertexBuffer 中的每个顶点调用该函数一次。 由于我们的 vertexBuffer 有六个位置(顶点),因此定义的函数将被调用六次。 每次调用它时,vertexBuffer 中的不同位置都会作为参数传递给函数,而顶点着色器函数的工作就是返回剪辑空间中的相应位置。
重要的是要了解它们也不一定会按顺序调用。 相反,GPU 擅长并行运行此类着色器,有可能同时处理数百(甚至数千)个顶点! 这是 GPU 实现令人难以置信的速度的重要原因,但它也有局限性。 为了确保极端并行化,顶点着色器之间不能进行通信。 每个着色器调用一次只能查看单个顶点的数据,并且只能输出单个顶点的值。
在 WGSL 中,顶点着色器函数可以随意命名,但它前面必须有 @vertex 属性,以指示它代表哪个着色器阶段。 WGSL 表示带有 fn 关键字的函数,使用括号声明任何参数,并使用花括号定义范围。创建一个空的 @vertex 函数,如下所示:
@vertex
fn vertexMain() {
}
但这是无效的,因为顶点着色器必须至少返回剪辑空间中正在处理的顶点的最终位置。 它始终以 4 维向量的形式给出。 向量在着色器中使用非常常见,因此它们被视为语言中的一流基元,具有自己的类型,例如 4 维向量的 vec4f。 2D 向量 (vec2f) 和 3D 向量 (vec3f) 也有类似的类型!
要指示返回的值是所需的位置,请使用 @builtin(position) 属性对其进行标记。 -> 符号用于指示这是函数返回的内容。
@vertex
fn vertexMain() -> @builtin(position) vec4f {
}
当然,如果函数有返回类型,则需要在函数体内实际返回一个值。 可以使用语法 vec4f(x, y, z, w) 构造一个新的 vec4f 来返回。 x、y 和 z 值都是浮点数,它们在返回值中指示顶点在剪辑空间中的位置。
可以返回静态值 (0, 0, 0, 1),从技术上讲,我们就拥有了一个有效的顶点着色器,尽管该着色器从不显示任何内容,因为 GPU 识别出它生成的三角形只是一个点,然后将其丢弃。
@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 这样的名称似乎很自然。将着色器函数更改为以下代码:
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(0, 0, 0, 1);
}
现在我们需要返回该位置。 由于位置是 2D 向量并且返回类型是 4D 向量,因此必须对其进行一些更改。 我们想要做的是将位置参数中的两个分量放入返回向量的前两个分量中,将最后两个分量分别保留为 0 和 1。通过明确说明要使用的位置组件来返回正确的位置:
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos.x, pos.y, 0, 1);
}
但是,由于这些类型的映射在着色器中非常常见,因此还可以以方便的速记方式将位置向量作为第一个参数传递,这意味着同样的事情。使用以下代码重写 return 语句:
@vertex
fn vertexMain(@location(0) pos: vec2f) ->
@builtin(position) vec4f {
return vec4f(pos, 0, 1);
}
这就是我们最初的顶点着色器! 这非常简单,只需将位置有效地传递出去,但它足以开始使用。
2.7 定义片段着色器
片段着色器总是在顶点着色器之后调用。 GPU 获取顶点着色器的输出并对它进行三角测量,从三个点的集合中创建三角形。 然后,它通过确定输出颜色附件的哪些像素包含在该三角形中来光栅化每个三角形,然后为每个像素调用一次片段着色器。 片段着色器返回一种颜色,通常根据从顶点着色器发送到它的值和 GPU 写入颜色附件的纹理等资源来计算。
就像顶点着色器一样,片段着色器以大规模并行方式执行。 它们在输入和输出方面比顶点着色器更灵活,但可以认为它们只是为每个三角形的每个像素返回一种颜色。
WGSL 片段着色器函数用 @fragment 属性表示,并且它还返回 vec4f。 但在这种情况下,矢量代表颜色,而不是位置。 需要为返回值提供 @location 属性,以便指示返回的颜色写入 beginRenderPass 调用中的哪个 colorAttachment。 由于我们只有一个附件,因此位置为 0。
创建一个空的 @fragment 函数,如下所示:
@fragment
fn fragmentMain() -> @location(0) vec4f {
}
返回向量的四个分量是红色、绿色、蓝色和 alpha 颜色值,它们的解释方式与我们之前在 beginRenderPass 中设置的clearValue 完全相同。 所以 vec4f(1, 0, 0, 1) 是亮红色,这对于我们的正方形来说似乎是一个不错的颜色。 不过,可以随意将其设置为你想要的任何颜色。接下来,我们设置返回的颜色向量,如下所示:
@fragment
fn fragmentMain() -> @location(0) vec4f {
return vec4f(1, 0, 0, 1); // (Red, Green, Blue, Alpha)
}
不过,这就是一个完整的片段着色器, 它只是将每个三角形的每个像素设置为红色,但现在就足够了。回顾一下,添加上面详细介绍的着色器代码后,我们的 createShaderModule 调用现在如下所示:
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);
}
`
});
2.8 创建渲染管道
着色器模块不能单独用于渲染。 相反,我们必须将它作为使用device.createRenderPipeline() 创建的 GPURenderPipeline 的一部分。 渲染管道控制几何图形的绘制方式,包括使用哪些着色器、如何解释顶点缓冲区中的数据、应渲染哪种几何图形(线、点、三角形...)等等。
渲染管道是整个 API 中最复杂的对象,但不用担心! 可以传递给它的大多数值都是可选的,我们只需提供一些必须的参数即可。创建渲染管道的方式如下所示:
const cellPipeline = device.createRenderPipeline({
label: "Cell pipeline",
layout: "auto",
vertex: {
module: cellShaderModule,
entryPoint: "vertexMain",
buffers: [vertexBufferLayout]
},
fragment: {
module: cellShaderModule,
entryPoint: "fragmentMain",
targets: [{
format: canvasFormat
}]
}
});
每个管道都需要一个布局来描述管道需要什么类型的输入(顶点缓冲区除外),但实际上没有任何输入。 幸运的是,我们现在可以传递“auto”,管道会从着色器构建自己的布局。
接下来,我们必须提供有关顶点阶段的详细信息。 该模块是包含顶点着色器的 GPUShaderModule,entryPoint 给出着色器代码中为每个顶点调用调用的函数的名称,缓冲区是 GPUVertexBufferLayout 对象的数组,用于描述如何将数据打包到与此管道一起使用的顶点缓冲区中。
最后,我们将了解有关片段阶段的详细信息。 这还包括着色器模块和入口点,就像顶点阶段一样。 最后一位是定义该管道使用的目标。 这是一个字典数组,提供管道输出到的颜色附件的详细信息(例如纹理格式)。 这些细节需要与该管道所使用的任何渲染通道的 colorAttachments 中给出的纹理相匹配。 我们的渲染通道使用画布上下文中的纹理,并使用我们在 canvasFormat 中保存的值作为其格式,因此可以在此处传递相同的格式。
2.9 绘制正方形
要绘制正方形,请跳回到encoder.beginRenderPass() 和 pass.end() 对调用,然后在它们之间添加这些新命令:
// After encoder.beginRenderPass()
pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(vertices.length / 2); // 6 vertices
// before pass.end()
首先,我们使用 setPipeline() 来指示应使用哪个管道进行绘制。 这包括所使用的着色器、顶点数据的布局以及其他相关状态数据。
接下来,我们使用包含正方形顶点的缓冲区调用 setVertexBuffer()。 可以使用 0 来调用它,因为该缓冲区对应于当前管道的 vertex.buffers 定义中的第 0 个元素。
最后,进行draw() 调用,在完成之前的所有设置之后,这似乎非常简单。 我们唯一需要传入的是它应该渲染的顶点数量,它从当前设置的顶点缓冲区中提取并使用当前设置的管道进行解释。 可以将其硬编码为 6,但是从顶点数组(每个顶点 12 个浮点/2 个坐标 == 6 个顶点)计算它意味着,如果我们决定用圆形等替换正方形,则数量会更少 手动更新。
三、绘制栅格
3.1 定义栅格
为了渲染栅格,我们需要了解有关它的非常基本的信息。 它包含多少个单元格(宽度和高度)? 这取决于开发人员,但为了让事情变得更简单,请将栅格视为正方形(相同的宽度和高度)并使用 2 的幂的大小。 (这使得稍后的一些数学计算变得更容易。)我们最终希望将其变得更大,但对于本节的其余部分,将网格大小设置为 4x4,因为这样可以更轻松地演示本节中使用的一些数学。
首先,我们通过在 JavaScript 代码顶部添加常量来定义网格大小。
const GRID_SIZE = 4;
接下来,我们需要更新渲染正方形的方式,以便可以在画布上容纳 GRID_SIZE 乘以 GRID_SIZE 的正方形。 这意味着正方形需要小得多,而且需要有很多。
现在,解决这个问题的一种方法是使顶点缓冲区变得更大,并在其中以正确的大小和位置定义 GRID_SIZE 乘以 GRID_SIZE 的正方形。 事实上, 只需几个 for 循环和一些数学知识就可以实现。 但这也没有充分利用 GPU,并且使用了超出实现效果所需的内存。 本节探讨一种对 GPU 更友好的方法。
3.2 创建uniform缓冲区
首先,我们需要将选择的栅格大小传达给着色器,因为它可以更改事物的显示方式。 可以将大小硬编码到着色器中,但这意味着如果我们想要更改网格大小时,都必须重新创建着色器和渲染管道,这是昂贵的。 更好的方法是将栅格大小作为uniform提供给着色器。
我们之前了解到,顶点缓冲区中的不同值会传递给顶点着色器的每次调用。 uniform是来自缓冲区的值,对于每次调用都是相同的。 它们对于传达几何图形(例如其位置)、完整动画帧(例如当前时间)甚至应用程序的整个生命周期(例如用户首选项)的常见值非常有用。
通过添加以下代码创建uniform缓冲区:
// 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 通信的,主要区别在于这次的使用包括 GPUBufferUsage.UNIFORM 而不是 GPUBufferUsage.VERTEX。
3.3 在着色器中访问uniform
通过添加以下代码来定义uniform:
// 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 着色器中非常常见,因为许多渲染和计算技术都依赖于它们。
3.4 创建绑定组
不过,在着色器中声明uniform并不会将其与我们创建的缓冲区连接起来。 为此,需要创建并设置一个绑定组。
绑定组是我们希望着色器可以同时访问的资源的集合。 它可以包含多种类型的缓冲区(例如统一缓冲区)以及其他资源(例如此处未介绍的纹理和采样器),但它们是 WebGPU 渲染技术的常见部分。
通过在创建uniform缓冲区和渲染管道后添加以下代码,使用uniform缓冲区创建绑定组:
const bindGroup = device.createBindGroup({
label: "Cell renderer bind group",
layout: cellPipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: uniformBuffer }
}],
});
除了现在的标准标签之外,我们还需要一个布局来描述此绑定组包含哪些类型的资源。 这是在以后的步骤中进一步深入研究的内容,但目前可以愉快地向管道询问绑定组布局,因为我们使用布局创建了管道:“auto”。 这会导致管道根据我们在着色器代码本身中声明的绑定自动创建绑定组布局。 在本例中,我们要求它 getBindGroupLayout(0),其中 0 对应于我们在着色器中键入的 @group(0)。
指定布局后,我们提供一个条目数组。 每个条目都是一个至少包含以下值的字典:
- binding:它与您在着色器中输入的 @binding() 值相对应。 在这种情况下,0。
- resource:这是想要向指定绑定索引处的变量公开的实际资源。 在这种情况下,你的uniform缓冲区。
该函数返回一个 GPUBindGroup,它是一个不透明、不可变的句柄。 创建绑定组后,我们将无法更改其指向的资源,但可以更改这些资源的内容。 例如,如果更改uniform缓冲区以包含新的栅格大小,则使用此绑定组的未来绘制调用会反映这一点。
3.5 绑定绑定组
现在绑定组已创建,我们仍然需要告诉 WebGPU 在绘图时使用它,跳回渲染通道并在draw() 方法之前添加此新行:
pass.setPipeline(cellPipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.setBindGroup(0, bindGroup); // New line!
pass.draw(vertices.length / 2);
作为第一个参数传递的 0 对应于着色器代码中的 @group(0)。 意思是说属于 @group(0) 一部分的每个 @binding 都使用此绑定组中的资源。现在uniform缓冲区已暴露给我们的着色器,刷新页面,然后应该看到类似这样的内容:
3.6 操纵几何体
现在我们可以在着色器中引用栅格大小,可以开始做一些工作来操纵正在渲染的几何体以适合我们所需的网格图案。 接着,我们需要从概念上将画布划分为各个单元。 为了保持 X 轴随着向右移动而增加、Y 轴随着向上移动而增加的惯例,假设第一个单元格位于画布的左下角。 这会给你一个看起来像这样的布局,当前的方形几何图形位于中间:
我们面临的挑战是在着色器中找到一种方法,可以在给定单元坐标的任何单元中定位方形几何体。
首先,可以看到我们的正方形与任何单元格都没有很好地对齐,因为它被定义为围绕画布的中心。 我们希望将正方形移动半个单元格,以便它在它们内部很好地对齐。
解决此问题的一种方法是更新正方形的顶点缓冲区。 例如,通过移动顶点使右下角位于 (0.1, 0.1) 而不是 (-0.8, -0.8),可以移动该正方形以更好地与单元格边界对齐。 但是,由于我们可以完全控制着色器中顶点的处理方式,因此使用着色器代码将它们推到位也同样容易。
@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(请记住,这是剪辑空间的一半),然后将其除以栅格大小, 结果是一个与原点完全对齐的正方形。
接下来,因为画布的坐标系将 (0, 0) 放置在中心,将 (-1, -1) 放置在左下角,并且我们希望 (0, 0) 位于左下角,所以需要平移几何体的 除以网格大小后将位置除以 (-1, -1),以便将其移动到该角落。平移几何体的位置,如下所示:
@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) 中!
如果想将其放置在不同的单元格中怎么办? 通过在着色器中声明一个单元向量并用静态值填充它来解决这个问题,例如 let cell = vec2f(1, 1)。
如果将其添加到 gridPos 中,它将撤消算法中的 - 1,因此这不是我们想要的。 相反,我们只想为每个单元格将正方形移动一个网格单位(画布的四分之一)。 听起来需要再按网格除一次
,如下所示:
@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);
}
如果现在刷新,会看到以下内容:
3.7 绘制实例
现在可以通过一些数学运算将正方形放置在我们想要的位置,下一步是在栅格的每个单元格中渲染一个正方形。
实现它的一种方法是将单元格坐标写入统一缓冲区,然后为网格中的每个方块调用一次绘制,每次统一更新。 然而,这会非常慢,因为 GPU 每次都必须等待 JavaScript 写入新坐标。 从 GPU 获得良好性能的关键之一是最大限度地减少 GPU 等待系统其他部分的时间!
相反,可以使用一种称为实例化的技术, 实例化是一种告诉 GPU 通过一次调用绘制同一几何图形的多个副本的方法,这比为每个副本调用一次绘制要快得多。 几何体的每个副本都称为一个实例。
要告诉 GPU 需要足够的正方形实例来填充网格,请向现有绘制调用添加一个参数:
pass.draw(vertices.length / 2, GRID_SIZE * GRID_SIZE);
这告诉系统我们希望它绘制正方形的六个 (vertices.length / 2) 顶点 16 (GRID_SIZE * GRID_SIZE) 次。但如果刷新页面,仍然会看到以下内容,并没有任何改变。
为什么? 嗯,这是因为我们将所有 16 个正方形绘制在同一个位置。 需要在着色器中添加一些额外的逻辑,以根据每个实例重新定位几何体。
在着色器中,除了来自顶点缓冲区的 pos 等顶点属性之外,还可以访问所谓的 WGSL 内置值。 这些是由 WebGPU 计算的值,其中一个值是 instance_index。 instance_index 是一个无符号 32 位数字,范围为 0 到实例数 - 1,可以将其用作着色器逻辑的一部分。 对于属于同一实例的每个已处理顶点,其值是相同的。 这意味着我们的顶点着色器将被调用六次,instance_index 为 0,对于顶点缓冲区中的每个位置调用一次。 然后,再进行六次,instance_index 为 1,然后再进行六次,instance_index 为 2,依此类推。
要查看其实际效果,必须将内置的instance_index 添加到着色器输入中。 以与位置相同的方式执行此操作,但不要使用 @location 属性标记它,而是使用 @builtin(instance_index),然后将参数命名为想要的任何名称。 (可以将其称为实例以匹配示例代码。)然后将其用作着色器逻辑的一部分。使用实例代替单元格坐标:
@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() 函数来做到这一点。更改计算,如下所示:
@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);
}
更新代码后,终于得到了期待已久的正方形网格。
现在它可以工作了,返回并增大栅格大小。
const GRID_SIZE = 32;
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。