本文主要是介绍【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>,
};@vertexfn 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;
}@fragmentfn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {return vec4<f32>(0.3, 0.2, 0.1, 1.0);
}
我们的第一个着色器将以纯色渲染三角形。尽管听起来很简单,但代码可能看起来很复杂。接下来我们剖析代码以更好地理解它的组成部分。
着色器代码剖析
着色器程序定义 GPU 管线的行为。 GPU 管线的工作方式就像一个小工厂,包含一系列阶段或车间。典型的 GPU 管线由两个主要阶段组成:
- 顶点阶段:处理几何数据并生成画布对齐的几何图形。
- 片段阶段: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 实现决定。
对于location
和builtin
之间的区别,我们可以将location
视为非结构化自定义数据,除了索引之外,它们没有任何标签。这个概念与 HTTP 协议类似,其中我们有一个结构化消息头(类似于builtin
),后面跟着可以包含任意数据的正文或有效负载(类似于location
)。如果你熟悉解码二进制文件,它相当于具有带有元数据的结构化标头,后面跟着一块数据作为有效负载。在我们的上下文中,builtin
函数和location
共享这个概念结构。
本例中的片段着色器非常简单:它只是将纯色输出到@location(0)
。
let code = document.getElementById('shader').innerText;
const shaderDesc = { code: code };
let shaderModule = device.createShaderModule(shaderDesc);
编写着色器代码只是渲染简单三角形的一部分。现在让我们研究一下如何修改管线以合并此着色器代码。该过程涉及几个步骤:
- 我们从第一个脚本标签中检索着色器代码字符串,这就是为什么要给标签加
id='shader'
属性。 - 我们构建一个包含源代码的着色器描述对象。
- 我们通过向 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 管线类似于真实的工厂管线,由输入、一系列处理阶段和最终输出组成。其中,layout
和primitive
描述了输入数据格式,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。顶点索引是为顶点着色器自动生成的。实例数决定了我们要复制三角形的次数。当我们需要渲染大量相同的几何图形(例如视频游戏中的草或树叶)时,该技术可以加快渲染速度。在此示例中,我们指定单个实例,因为我们只需要绘制一个三角形。
这篇关于【WebGPU Unleashed】1.1 绘制三角形的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!