关于前端:WebGPU应用开发快速入门

59次阅读

共计 18189 个字符,预计需要花费 46 分钟才能阅读完成。

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 的幂的大小。(这使得稍后的一些数学计算变得更容易。)咱们最终心愿将其变得更大,但对于本节的其余部分,将网格大小设置为 4×4,因为这样能够更轻松地演示本节中应用的一些数学。

首先,咱们通过在 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;

正文完
 0