【WebGPU Unleashed】1.1 绘制三角形

一部2024新的WebGPU教程,作者Shi Yan。内容很好,翻译过来与大家共享,内容上会有改动,加上自己的理解。更多精彩内容尽在
dt.sim3d.cn ,关注公众号【sky的数孪技术】,技术交流、源码下载请添加微信号:digital_twin123

在 3D 渲染领域,三角形是最基本的绘制元素。在这里,我们将学习如何绘制单个三角形。接下来我们将制作一个简单的着色器来定义三角形内的像素颜色,以及了解如何建立一个图形管线,然后利用该三角形并使用着色器将其渲染在屏幕上。就像传统编程中的“Hello World”程序一样,绘制三角形基本算是任何图形API的初始介绍。

着色器介绍

在前面的示例中,我们没有创建任何着色器。着色器程序是在GPU上执行的程序,一般来说,着色器程序主要分为三种类型:顶点着色器、片段着色器和计算着色器。计算着色器用于通用计算,而顶点和片段着色器与渲染相关。顶点着色器处理几何体的每个顶点,确定其在屏幕上的最终位置。然后,片段着色器确定由这些顶点定义的形状内每个像素的颜色。这些着色器共同将几何图元(例如点或三角形)转换为我们在屏幕上看到的像素。

<script id="shader" type="wgsl">
  ...
</script>

现在我们了解了着色器的作用,接下来我们将它们添加到我们的项目中。首先,我们将在 HTML 中创建另一个脚本标签来保存着色器代码,我们将其类型设置为 wgsl,代表 WebGPU 着色器语言。除了类型之外,我们还需要给它一个着色器的 id,因为我们稍后需要读取它的内容。此处注意我们不需要将着色器代码放入脚本标记中,我们可以选择将着色器代码分配给 JavaScript 字符串,也可以将它们写入外部文件并将其提取到代码中。

struct VertexOutput {
  @builtin(position) clip_position: vec4<f32>,
};

@vertex
  fn vs_main(
  @builtin(vertex_index) in_vertex_index: u32,
) -> VertexOutput {
  var out: VertexOutput;
  let x = f32(1 - i32(in_vertex_index)) * 0.5;
  let y = f32(i32(in_vertex_index & 1u) * 2 - 1) * 0.5;
  out.clip_position = vec4<f32>(x, y, 0.0, 1.0);
  return out;
}

@fragment
  fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
  return vec4<f32>(0.3, 0.2, 0.1, 1.0);
}

我们的第一个着色器将以纯色渲染三角形。尽管听起来很简单,但代码可能看起来很复杂。接下来我们剖析代码以更好地理解它的组成部分。

着色器代码剖析

着色器程序定义 GPU 管线的行为。 GPU 管线的工作方式就像一个小工厂,包含一系列阶段或车间。典型的 GPU 管线由两个主要阶段组成:

  1. 顶点阶段:处理几何数据并生成画布对齐的几何图形。
  2. 片段阶段:GPU 将顶点阶段的输出转换为片段后,片段着色器为它们分配颜色。

在我们的着色器代码中,有两个入口函数:

  • vs_main:代表顶点阶段,用@vertex注解
  • fs_main:代表fragment阶段,用@fragment注解

虽然 vs_main 函数 @builtin(vertex_index) in_vertex_index: u32 的输入看起来与 C 类型语言中的函数参数类似,但它是不同的。这里,in_vertex_index是变量名,u32是类型(32位无符号整数)。 @builtin(vertex_index) 是一个特殊的装饰器。

在 WGSL 中,着色器输入并不是真正的函数参数。我们可以想象一个包含多个字段的预定义表单,每个字段都有一个标签,@builtin(vertex_index) 就是这样的标签之一。对于管线阶段的输入,我们不能随意地提供数据,而是必须从这个预定义的集中选择字段。在这种情况下,@builtin(vertex_index)是实际的参数名称,而in_vertex_index只是我们给它的别名。

@builtin 装饰器表示一组预定义字段。我们还会遇到其他装饰器,例如@location,稍后我们将讨论它们以了解它们的差异。

着色器阶段输出遵循类似的原则。我们不能输出任意数据,只能填充一些预定义的字段。在我们的示例中,我们输出了一个 struct VertexOutput。虽然它看起来像是自定义的,但是它包含一个预定义字段 @builtin(position),我们将在其中写入结果。

屏幕空间坐标系

顶点着色器

顶点着色器的内容乍一看可能令人费解。在我们深入研究之前,我先解释一下顶点着色器的主要目标。顶点着色器接收几何图形作为单独的顶点,在这个阶段,我们缺乏几何之间的连接信息,也就是不知道哪些顶点连接形成三角形,所以我们处理各个顶点的目的就只是转换它们的位置以与画布对齐。

如果没有顶点转换,那么我们也就无法正确看见顶点。由顶点着色器接收的顶点位置通常在其自己的坐标系中定义,为了将它们显示在画布上,我们必须将输入顶点使用的坐标系统一到画布的坐标系中。此外,顶点可以存在于 3D 空间中,而画布始终是 2D,将 3D 坐标转换为 2D 的过程称为投影。

现在,让我们了解一下画布的坐标系,该坐标系通常称为屏幕空间或裁剪空间(注:按照常理来说其实是NDC空间,裁剪空间一般是没有进行透视除法,而屏幕空间是画布像素坐标,按NDC理解就好)。尽管在 WebGPU 中我们通常渲染到画布而不是直接渲染到屏幕,但术语“屏幕空间坐标系”是从其他本机 3D API 继承的。屏幕空间坐标系的原点位于中心,x 和 y 坐标都限制在 [-1, 1] 范围内。无论屏幕或画布大小如何,该坐标系都保持不变。

回想一下之前的教程,我们可以定义视口大小,但不会影响坐标系。无论视口定义如何,屏幕空间坐标系都保持不变。只要顶点的坐标落在 [-1, 1] 范围内,顶点就是可见的。渲染管道会自动拉伸屏幕空间坐标系以匹配我们定义的视口。例如,如果我们的视口为 640x480,即使纵横比为 4:3,画布坐标系的 x 和 y 区间仍为 [-1, 1]。如果在位置 (1, 1) 处绘制顶点,它将出现在右上角。当呈现在画布上时,位置 (1, 1) 将被拉伸到 (640, 0)。

在上面的代码中,我们的输入是顶点索引而不是顶点位置。三角形有三个顶点,我们可以把索引设为 0、1 和 2。在没有顶点位置作为输入的情况下,我们可以根据这些索引来生成它们的位置。我们的目标是为每个索引生成唯一的位置,同时确保该位置落在 [-1, 1] 范围内,使整个三角形可见。如果我们用 0, 1, 2 代替 vertex_index,我们将分别得到位置 (0.5, -0.5)、(0, 0.5) 和 (-0.5, -0.5)。

let x = f32(1 - i32(in_vertex_index)) * 0.5;
let y = f32(i32(in_vertex_index & 1u) * 2 - 1) * 0.5;

剪辑位置(剪辑空间中的位置)由 4 位浮点向量表示。对于屏幕空间中的 2D 三角形,第三个分量始终为零。最后一个值设置为 1.0。当我们稍后探索相机和矩阵转换时,我们将深入研究最后两个值的细节。

如前所述,顶点阶段的输出经过光栅化,生成带有插值顶点值的片段。在我们的简单示例中,唯一的插值就是顶点位置。

片段着色器

片段着色器的输出由另一个名为@location(0) 的预定义字段定义。每个位置最多可以存储 16 个字节的数据,相当于四个 32 位浮点数,可用位置的总数由特定的 WebGPU 实现决定。

对于locationbuiltin之间的区别,我们可以将location视为非结构化自定义数据,除了索引之外,它们没有任何标签。这个概念与 HTTP 协议类似,其中我们有一个结构化消息头(类似于builtin),后面跟着可以包含任意数据的正文或有效负载(类似于location)。如果你熟悉解码二进制文件,它相当于具有带有元数据的结构化标头,后面跟着一块数据作为有效负载。在我们的上下文中,builtin函数和location共享这个概念结构。

本例中的片段着色器非常简单:它只是将纯色输出到@location(0)

let code = document.getElementById('shader').innerText;
const shaderDesc = {
    
     code: code };
let shaderModule = device.createShaderModule(shaderDesc);

编写着色器代码只是渲染简单三角形的一部分。现在让我们研究一下如何修改管线以合并此着色器代码。该过程涉及几个步骤:

  1. 我们从第一个脚本标签中检索着色器代码字符串,这就是为什么要给标签加 id='shader' 属性。
  2. 我们构建一个包含源代码的着色器描述对象。
  3. 我们通过向 WebGPU API 提供着色器描述来创建着色器模块。

需要注意的是,在此示例中我们没有实现错误处理。如果发生编译错误,我们最终会得到一个无效的着色器模块。在这种情况下,浏览器的控制台消息对于调试就会非常有帮助。通常,着色器代码是由开发人员在开发阶段定义的,并且所有着色器问题很可能都会在部署代码之前得到解决。因此,我们在这个基本示例中省略了错误处理。但是,如果是在生产环境中,那么还是建议实施严密的错误检查。

const pipelineLayoutDesc = {
    
     bindGroupLayouts: [] };
const layout = device.createPipelineLayout(pipelineLayoutDesc);

接下来,我们开始定义管线布局。那么管线布局到底是什么呢?它指的是我们打算提供给管线的常量结构。每个布局layout代表一组我们想要输入管线的常量。

一个管线可以有多组常量,这就是为什么bindGroupLayouts被定义为一个列表。这些常量在管线的整个执行过程中都保持其数值。

在我们当前的示例中,不需要提供任何常量。因此,我们的管线布局是空的。

const colorState = {
    
    
    format: 'bgra8unorm'
};

我们管线配置的下一步是指定输出像素格式。在本例中,我们使用 bgra8unorm。这种格式定义了我们如何填充渲染目标。详细来说,bgra8unorm 各分量的意思:

  • ‘b’、‘g’、‘r’、‘a’:蓝色、绿色、红色、Alpha 通道
  • ‘8’:每个通道使用8位
  • ‘unorm’:值是无符号且标准化的(范围从 0 到 1)
const pipelineDesc = {
    
    
  layout,
  vertex: {
    
    
    module: shaderModule,
    entryPoint: 'vs_main',
    buffers: []
  },
  fragment: {
    
    
    module: shaderModule,
    entryPoint: 'fs_main',
    targets: [colorState]
  },
  primitive: {
    
    
    topology: 'triangle-list',
    frontFace: 'ccw',
    cullMode: 'back'
  }
};

pipeline = device.createRenderPipeline(pipelineDesc);

定义管线

组装完所有必要的组件后,现在终于可以创建管线了。 GPU 管线类似于真实的工厂管线,由输入、一系列处理阶段和最终输出组成。其中,layoutprimitive描述了输入数据格式,layout指的是常量,而primitive指定应如何提供几何基元。

基本上实际输入数据是通过缓冲区提供的,一般都包含顶点数据,包括顶点位置和其他属性,例如顶点颜色和纹理坐标。但是在我们当前的示例中,我们不使用任何缓冲区。我们不是直接输入顶点位置,而是在顶点着色器阶段从顶点索引中导出它们,这些索引由 GPU 管线自动提供给顶点着色器。

通常我们提供的都是没有显式连接信息的顶点列表,而不是作为完整的 3D 图形元素(例如三角形),管线会根据拓扑场从这些顶点重建出三角形。例如,如果topology字段设置为triangle-list,则表示顶点列表以逆时针或顺时针顺序表示三角形顶点。每个三角形都有一个正面和一个背面,frontFace:'ccw' 定义了三角形顶点顺序为正面。

cullMode 参数决定我们是否要消除三角形特定面的渲染。将其设置为back就表示我们选择不渲染三角形的背面。在大多数情况下不应渲染三角形的背面,省略背面的绘制可以节省计算资源。

使用triangle-list拓扑是表示三角形的最直接的方法,但它并不总是最有效的方法。如下图所示,当我们想要渲染一条由连接的三角形组成的条带时,它的许多顶点会被多个三角形共享。

两个三角形形成一个三角形带。在右手系统中,正顶点顺序围绕三角形法线逆时针方向

在这种情况下,我们希望重用多个三角形的顶点位置,而不是为不同的三角形多次发送相同的位置。这就是triangle-strip拓扑成为更好选择的地方。它使我们能够更有效地定义一系列连接的三角形,减少数据冗余并有可能提高渲染性能。我们将在以后的章节中探讨其他拓扑类型。

commandEncoder = device.createCommandEncoder();
 
passEncoder = commandEncoder.beginRenderPass(renderPassDesc);
passEncoder.setViewport(0, 0, canvas.width, canvas.height, 0, 1);
passEncoder.setPipeline(pipeline);
passEncoder.draw(3, 1);
passEncoder.end();
 
device.queue.submit([commandEncoder.finish()]);

定义管线后,我们需要创建 colorAttachment,这与我们在第一个教程中介绍的类似,因此我将在此处省略详细信息。最后一步是命令创建和提交。这个过程与我们之前所做的几乎相同,主要区别在于新创建的管线的使用和draw()函数的调用。

draw() 函数触发渲染过程。第一个参数指定我们要渲染的顶点数,第二个参数表示实例数。由于我们渲染的是单个三角形,因此顶点总数为 3。顶点索引是为顶点着色器自动生成的。实例数决定了我们要复制三角形的次数。当我们需要渲染大量相同的几何图形(例如视频游戏中的草或树叶)时,该技术可以加快渲染速度。在此示例中,我们指定单个实例,因为我们只需要绘制一个三角形。

猜你喜欢

转载自blog.csdn.net/u013929284/article/details/142055758