【WebGPU Unleashed】1.1 绘制具有定义顶点的三角形
一部2024新的WebGPU教程,作者Shi Yan。内容很好,翻译过来与大家共享,内容上会有改动,加上自己的理解。更多精彩内容尽在 dt.sim3d.cn ,关注公众号【sky的数孪技术】,技术交流、源码下载请添加微信号:digital_twin123
在我们之前的教程中,我们在没有提供显式顶点数据的情况下绘制了一个三角形,绘制原理是在着色器中计算顶点位置。虽然这种方法适用于简单的几何形状,但对于大多数现实场景来说是不切实际的。在本教程中,我们将使用显式定义的顶点数据绘制三角形,这是一种更适合复杂几何形状的方法。在线示例。
着色器变化
@vertex
fn vs_main(@location(0) inPos: vec3<f32>,
) -> VertexOutput {var out: VertexOutput;out.clip_position = vec4<f32>(inPos, 1.0);return out;
}
首先,让我们检查一下着色器的变化,此处省略了与以前相同的内容。vs_main
的输入已从 @builtin(vertex_index) in_vertex_index: u32
更改为 @location(0) inPos: vec3<f32>
。回想一下,@builtin(vertex_index)
是包含当前顶点索引的预定义输入字段,而 @location(0)
类似于指向存储位置的指针,其中包含我们输入管线的任意数据。在这个特定的教程中,我们将把顶点位置放在这个存储位置,该存储位置的数据格式是 3 个浮点数的向量。
在函数体中,我们不再需要导出顶点位置,因为我们要将它们发送到着色器。在这里,我们简单地创建一个由 4 个浮点数组成的向量,并将其 xyz 分量分配给输入的位置,将 w 分量分配给 1.0。着色器的其余部分保持不变。
属性描述符和布局描述符
const positionAttribDesc = {shaderLocation: 0, // @location(0)offset: 0,format: 'float32x3'
};
现在,让我们看看采用新着色器代码的管线更改。首先,我们需要创建一个位置属性描述。属性指的是着色器函数 @location(0) inPos: vec3<f32>
的输入。与 @builtins
不同,属性没有预定义的含义,其含义由开发者决定,它可以表示顶点位置、顶点颜色或纹理坐标。
首先,我们指定属性的位置shaderLocation
,它对应于@location(0)
。其次,我们告诉管线相对于包含顶点数据的数据缓冲区开头的偏移量,以查找该属性的第一个元素。这是因为我们可以在一块缓冲区中混合多个属性。最后,format
字段定义了格式,对应于着色器中的vec3<f32>
。
const positionBufferLayoutDesc = {attributes: [positionAttribDesc],arrayStride: 4 * 3, // sizeof(float) * 3stepMode: 'vertex'
};
我们的下一个任务是创建缓冲区布局描述符。此步骤对于帮助我们的 GPU 管线在提交缓冲区时理解缓冲区的格式至关重要。对于那些刚接触图形编程的人来说,这些步骤可能看起来很冗长,并且通常很难掌握属性描述符和布局描述符之间的区别,以及为什么需要它们来描述 GPU 缓冲区。
当向 GPU 提交顶点数据时,我们通常会发送一个包含大量顶点数据的大型缓冲区。正如第一章所介绍的,将少量数据从 CPU 内存传输到 GPU 内存效率很低,因此最佳实践是大批量提交数据。如前所述,顶点数据可以包含混合多个属性,例如顶点位置、颜色和纹理坐标。或者,你可以选择单独为每个属性使用专用缓冲区。然而,当我们到达顶点着色器的入口点时,我们需要单独处理每个顶点。在此阶段,我们不再能看到整个属性缓冲区。每个着色器调用独立地作用于一个顶点,这使得着色器程序能够从 GPU 的并行架构中受益。
为了从在 CPU 端提交单个缓冲区块过渡到在 GPU 端进行逐顶点处理,我们需要剖析输入缓冲区以提取每个单独顶点的信息。 GPU 管线可以在布局描述的帮助下自动完成此操作。区分属性描述符和布局描述符的方法是:属性描述符描述属性本身,例如其位置和格式;而布局描述符重点关注如何将许多顶点的多个属性列表分解为每个单独顶点的数据。
在这个布局描述符结构中,我们可以找到一个属性列表attributes
(在我们当前的示例中,因为仅需要处理位置数据,所以列表仅包含位置属性描述符)。在更复杂的场景中,我们会在此列表中包含更多属性。接下来,我们来定义 arrayStride
,该参数表示我们为每个顶点推进缓冲区指针的步长。例如,对于第一个顶点(顶点 0),其数据驻留在缓冲区内的偏移量零处。对于后续顶点(顶点 1),我们将其数据定位在偏移量 0 加上 arrayStride 处,该数组将从第 12 个字节开始(1 个4字节浮点数乘以 3)。
最后,我们指定步骤模式,有两个选项:顶点vertex
和实例instance
。通过选择其中之一,我们指示 GPU 管线为每个顶点或每个实例推进此缓冲区的指针。我们将在以后的章节中探讨实例化的概念,对于大多数情况,vertex
选项就足够了。
const positions = new Float32Array([1.0, -1.0, 0.0, -1.0, -1.0, 0.0, 0.0, 1.0, 0.0
]);
现在,让我们继续准备实际的缓冲区,这是一个相对简单的步骤。在这里,我们创建一个 32 位浮点数组并用三个顶点的坐标填充它,总共包含九个值。
为了更好地理解这些坐标值,请回忆一下我们之前介绍的NDC空间坐标。每组三个值代表 3D 空间中的一个顶点位置。第一个顶点 (1.0, -1.0, 0.0) 位于NDC空间的的右下角。第二个顶点 (-1.0, -1.0, 0.0) 位于NDC空间的左下角,第三个顶点 (0.0, 1.0, 0.0) 位于NDC空间的顶部中心。它们按顺时针顺序排列。
这些坐标是经过精心选择的,以形成一个横跨渲染表面可见区域的三角形。所有顶点的 z 坐标均设置为 0.0,这么做是为了将它们放置在垂直于观察方向的同一平面上。这种排列将产生一个覆盖屏幕一半的三角形,其底边沿底部边缘,顶点位于顶部中心。
在此阶段,我们创建的数据驻留在 CPU 内存中。要在 GPU 管线中使用它,我们必须将此数据传输到 GPU 内存,这就需要去创建 GPU 缓冲区。
const positionBufferDesc = {size: positions.byteLength,usage: GPUBufferUsage.VERTEX,mappedAtCreation: true
};let positionBuffer = device.createBuffer(positionBufferDesc);
const writeArray =new Float32Array(positionBuffer.getMappedRange());
writeArray.set(positions);
positionBuffer.unmap();
我们通过创建一个缓冲区描述符来开始这个过程。描述符的第一个字段size
指定缓冲区的大小,后面是使用标志usage
。在我们的例子中,由于我们打算使用此缓冲区来提供顶点数据,因此我们设置了 VERTEX
标志。最后,我们确定是否要在创建时映射此缓冲区。
缓冲区映射
映射是一项关键操作,必须先于 CPU 和 GPU 之间的任何数据传输。它本质上是在CPU端为GPU缓冲区创建一个镜像缓冲区。这个镜像缓冲区充当我们写入 CPU 数据的暂存区域。完成数据写入后,我们调用 unmap
将数据刷新到 GPU。
mappedAtCreation
标志提供了一个方便的快捷方式。通过设置此标志,缓冲区会在创建时自动映射,使其立即可用于数据复制。
定义描述符结构后,我们在后续行中根据该描述符创建缓冲区。由于此时缓冲区已经映射,我们可以继续写入数据。
我们的方法需要创建一个临时 32 位浮点数组 writeArray
,直接链接到映射的 GPU 缓冲区。然后我们只需将 CPU 缓冲区复制到这个临时数组即可。取消映射缓冲区后,我们可以确信数据已成功传输到 GPU 并可供着色器使用。
const pipelineDesc = {layout,vertex: {module: shaderModule,entryPoint: 'vs_main',buffers: [positionBufferLayoutDesc]},fragment: {module: shaderModule,entryPoint: 'fs_main',targets: [colorState]},primitive: {topology: 'triangle-list',frontFace: 'cw',cullMode: 'back'}
};.....commandEncoder = device.createCommandEncoder();passEncoder = commandEncoder.beginRenderPass(renderPassDesc);
passEncoder.setViewport(0, 0, canvas.width, canvas.height, 0, 1);
passEncoder.setPipeline(pipeline);
passEncoder.setVertexBuffer(0, positionBuffer);
passEncoder.draw(3, 1);
passEncoder.end();device.queue.submit([commandEncoder.finish()]);
代码的其余部分与上一个教程非常相似,只有一些关键区别。管线描述符定义中出现了一项显着变化。在顶点阶段,我们现在在缓冲区字段中提供缓冲区布局描述符,如果需要,该字段可以容纳多个缓冲区描述符。
另一个重要的变化是管线描述符的原始部分。我们指定 frontFace: cw
表示顺时针方向,它对应于顶点缓冲区中顶点的顺序。此设置告知 GPU 关于三角形的缠绕顺序,这对于正确的面剔除至关重要。
使用更新后的描述符创建新管线后,我们需要在使用此管线制作命令时设置顶点缓冲区。我们使用 setVertexBuffer
函数来完成此操作。第一个参数表示索引,对应定义管线时buffers
字段的缓冲区布局索引。在本例中,我们指定驻留在 GPU 上的positionBuffer
应该用作顶点数据的源。
绘制命令与我们之前的示例类似,指示 GPU 将三个顶点渲染为单个三角形。跟之前的关键区别在于这些顶点来自我们显式定义的缓冲区,而不是在着色器中生成。
提交此命令后,我们就会在屏幕上看到一个实心三角形。这种显式定义顶点数据的方法为我们渲染的几何体提供了更大的灵活性和控制力,为未来教程中更复杂的形状和模型铺平了道路。