zhen's blog

编译思想,编译人生

Wgpu图文详解(02)渲染管线与着色器

在本系列的第一篇文章中(《Wgpu图文详解(01)窗口与基本渲染》),我们介绍了如何基于0.30+版本的winit搭建Wgpu的桌面环境,同时也讲解了关于Wgpu一些基本的概念、模块以及架构思路,并基于wgpu库实现了一个能展示有颜色背景的窗体。而在本篇文章中,我们将开始介绍Wgpu中的渲染管线以及着色器,并通过这两个基本要素,在原有窗口的基础上,渲染一个三角形。

⚠️这章的内容很多,相比上一章来说,需要读者具备更多关于图形学的理论知识,否则看起来还是会一头雾水,不过笔者尽可能的将一些内容讲的细一点,特别是着色器代码与代码中的某些配置的关系,旨在让读者能够不那么“头晕”。当然,作者的能力有限,所以针对图形学的内容,读者可以自行了解熟悉后,后再看本文,当然这里也毛遂自荐下自己的另一篇文章《关于计算机图形学的一些介绍(01)基本要素与空间变换》(知乎博客

⚠️本章节开始的wgpu使用版本为23.0.0,为2024年的最后一个major升级:release/tab/v23.0.0,该版本有break change,请读者确保版本一致。

基本概念导入

首先,让我们先简单的介绍一下什么是管线Pipeline。从实际应用的角度看,管线类似于工厂内的生产线:从一端开始,接收基础原料,随后,生产线上各工序节点依次对这些原料进行加工处理,逐步形成最终产品。同样,计算机图形学工程中的管线的形式也十分类似,我们把一些有关最终要渲染的图像的必要数据作为输入,通过管线的层层作业,最终得到能够渲染到屏幕设备上的图形、颜色。此外,管线还有一个比较有价值的作用就是能够将处理数据的分工变的更加明确,同时,每一个步骤也能具备独立配置、编程的能力。

当然,在图形学工程中的管线是有很多种类的,比如渲染管线RenderPipeline、计算管线ComputePipeline。不同种类的管线负责了不同的工作,但其本质是一样的:流程化的处理图形数据。为了通过Wgpu渲染一个三角形,我们至少需要构建一个渲染管线,来达到最终的目的。

在介绍渲染管线的同时,我们就不得不介绍另一个重要的东⻄:着色器Shader。正如上面所说的,渲染管线的本质是一条包含多个环节的作业流水线。为了让我们能够更加方便的通过程序来控制每一个作业环节,图形学工程引入了着色器这一概念。需要强调的是,着色器并不是某种类似上色的功能,而是一段可编程的处理程序,能够让我们在渲染管线中的某些环节通过程序去控制结果。所以,整体结合来看,我们可以将渲染管线与着色器的关系用一张图来表达:

000

上面这张图只是一个概念上的简单的关系图。在实际的图形学工程中,远远要比这个复杂的多,不过为了让读者有一个感性的认知,可以暂时按照上图的关系来理解管线和着色器的关系。

既然着色器本质是一段程序,那我们不可避免的需要编写这样的程序。在Wgpu中,我们使用wgsl(Web GPU Shading Language)编写着色器程序。当然,就如同C/C++、Rust等高级程序语言一样,我们编写的wgsl只是源代码,因此,我们还需要将这些源代码编译为着色器的二进制程序,这个过程几乎不用我们操心,因为Wgpu在运行过程中会去编译并调用这些着色器代码。

好了,到目前为止我们对管线与着色器有了一个大体的认识,当然,光有理论知识是不够了,接下来我们就开始从代码工程出发,编写构建渲染管线的相关代码以及着色器程序。

准备阶段

本章的代码工程项目将会在第一篇文章搭建结果的基础上进行修改。因此在继续后面的讲解前,请确保你已经充分理解了第一章的内容并搭建好了环境。

首先,让我们在WgpuCtx这个结构中添加一个新的字段render_pipeline,其类型为wgpu::RenderPipeline。接着,让我们准备一个结构无关的方法,其签名为:

1
fn create_pipeline() -> wgpu::RenderPipeline;

最后,让我们在WgpuCtx的new_async方法中的指定位置调用上述的create_pipeline方法,并将得到的RenderPipeline交给WgpuCtx存放。

005

010

接下来,让我们编写一段着色器程序。在项目目录下创建一个名为shader.wgsl的文件,并在其中添加如下wgsl代码:

1
2
3
4
5
6
7
8
9
10
11
@vertex
fn vs_main(@builtin(vertex_index) in_vertex_index: u32) -> @builtin(position) vec4<f32> {
let x = f32(i32(in_vertex_index) - 1);
let y = f32(i32(in_vertex_index & 1u) * 2 - 1);
return vec4<f32>(x, y, 0.0, 1.0);
}

@fragment
fn fs_main() -> @location(0) vec4<f32> {
return vec4<f32>(1.0, 0.0, 0.0, 1.0);
}

020

至于这段wgsl代码的含义我们先不着急说明,后面我们会详细的解释,此时就简单理解为我们编写了一份着色器程序源代码,并让其在渲染管线中发挥作用。

接下来让我们修改一下 create_pipeline 的方法签名,增加两个入参:

1
2
3
4
5
6
fn create_pipeline(
device: &wgpu::Device, // <--- 参数1
swap_chain_format: wgpu::TextureFormat, // 参数2
) -> wgpu::RenderPipeline {
//...
}

对于第一个参数wgpu::Device,看过第一章的读者应该知道,这个实例是通过adapter调用request_device得到的,是对逻辑设备的抽象的实例:

030

对于第二个参数,wgpu::TextureFormat,则来源于完成配置后的surface_config的format字段。所以,在调用点我们需要做出适当的修改:

040

在准备工作完成以后,我们的项目现在大概长这样:

050

至此,我们已经准备好了一个创建管线的环境了。接下来就让我们开始关注于create_pipeline这个方法的具体实现,开始真正的创建渲染管线、着色器以及理解它们。

创建渲染管线

对于create_pipeline的方法体,我们填入如下的内容:

060

通过代码注释,我们可以了解到创建一个基础的渲染管线至少有以下两步:

  1. 通过wgpu::Device提供的APIcreate_shader_module加载着色器程序模块;
  2. 通过wgpu::Device提供的APIcreate_render_pipeline,结合步骤1中得到的着色器模块实例创建渲染管线。

对于第一步来说,读者可以直接参考上述代码即可,其含义理解起来并不困难,核心就是从加载着色器源代码内容,并通过一系列构造过程得到一个ShaderModule(着色器程序模块)。

wgpu的很多结构体都会有一个名为label的字段,这个字段对于运行时没有什么影响,仅仅是作为Debug调试阶段时方便定位数据的。

对于第二步调用create_render_pipeline,其具体的内容如下所示:

070

笔者在上图代码中将其标记为了5个部分的配置。其中,第1个和第5个配置本章暂不涉及,按上图示例代码传入相关默认值即可,这些参数我们会在后续的文章中逐步讲解,本文咱不赘述。让我们重点关注上图中的第2、3、4个部分。

⚠️接下来的内容,除了有关wgpu本身使用内容以外,还会涉及到计算机图形学中的一些重要概念。什么是顶点vertex,什么是片元fragment,什么是图元primitive,这些都是学习计算机图形学必不可少的知识点。由于本系列文章重点是从工程的角度介绍如何使用wgpu,所以关于图形学的知识点不会特别介绍,需要读者自行学习,本文假设读者已经具备了相关的知识

再次自荐《关于计算机图形学的一些介绍(01)基本要素与空间变换》(博客地址知乎地址)。

顶点着色器

让我们先聚焦第一个部分:

1
2
3
4
5
6
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: Default::default(),
},

第一个参数module,表明了我们需要从哪个ShaderModule实例来获取顶点着色器程序。在前面,我们曾编写了一份着色器代码,并通过create_shader_module创建了一个ShaderModule实例,这里作为该参数的值传入即可。

第二个参数entry_point,表明了顶点着色器程序入口点,这个所谓的入口点类似于我们常规程序中的main函数一样。不过需要注意的是,这里我们填入的是"vs_main",还记得我们之前编写的shader.wgsl代码吗?在其中有我们编写的这样一段代码:

080

在该段代码中,我们使用了一个注解@vertex来表明接下来的函数是一个顶点着色器有关的函数,然后,我们给这个方法命名为"vs_main"。相对应的,在上面的Rust代码中的entry_point字段我们对应填入的就是这个vs_main。所以,目前的情况如下:

090

请注意,本文使用的wgpu版本为23.x +,该版本与22.x以及0.2x版本的一个重要break change:关于VertexState以及接下后续介绍的FragmentState的entry_point字段的类型由旧版本的&'a str该为了Option<&'a str>。因此本文都传的Some(xxx)

在了解了这样的配置关系以后,我们还需要知道这段顶点着色器代码的意义。首先,该方法会在每一次处理顶点的时候被调用。假设现在我们场景中提供了n个顶点,那么渲染管线在顶点处理这一环节的时候,会调用n次这个顶点着色器程序。对于这个vs_main方法的参数,首先是入参@builtin(vertex_index) in_vertex_index: u32,每次调用该vs_main方法的时候,会传入一个u32类型的值,该值是wgsl内建的顶点索引值(如果是n个顶点的话,通常是0到n-1)。可以把这段流程想象成如下伪代码:

1
2
3
4
遍历:0 <= 顶点索引index n-1 {
顶点处理结果 = 执行vs_main(顶点索引index)
拿着顶点处理结果干其他事...
}

同时,该方法每一次调用完成以后,会返回一个vec<f32>,同时用@builtin(position),表明该方法返回的是一个内建的位置数据。可能读者对于这块还感觉到非常抽象。那让我们用一个更见实际的例子来解释。

假设现在有如下的一个三角形:

100

对于这个三角形的三个顶点,按照逆时针方向,其索引依次是0、1、2。在渲染管线的顶点着色器处理的环节,根据我们前面讲到的,每一个顶点都调用一次vs_main方法,那么结果如下:

110

值得注意的是,在代码中求y值时,代码使用的是将数据与1进行二进制按位与操作,因此,当index = 2时,2 & 1实际上是二进制10 & 二进制01,按位与的结果就是二进制00,即就是0。

对于每个顶点来说,我们求得了其位置坐标。但值得注意的是,返回的位置坐标是一个4维的,其中前两个分量分别对应x轴和y轴,同时也是我们根据顶点索引动态得到的;第三个分量是z轴,且均为0.0,表明所有的顶点都处在z轴等于0的平面上;最后一分量是w值,通常都是1.0(对于这个w分量,务必请读者自行仔细了解其数学意义,本文不做赘述)。

对上述结果整理一下,我们可以知道,三个顶点依次处理的结果就是生成了在同一个2维平面上(因为z均为0)的三个点,其坐标分别是:(-1.0, -1.0)(0.0, 1.0)(1.0, -1.0)。那么这些坐标在wgpu下的意义是什么呢?这里我们直接给一个结论。首先我们知道wgpu渲染的时候,对应物理屏幕上是存在一个视口viewport的(如果忘记了,请在阅读下本系列的第一章内容),对于这个视口来说,无论其宽高的绝对大小值是多少,总是以中心位原点,视口上下y范围为1.0到-1.0,以及视口左右x的范围为-1.0到1.0的坐标区域:

120

因此,上述坐标的结果就是我们能够渲染一个如下的顶点刚好顶满视口的三角形:

130

目前的代码进度还无法渲染出上图的结果,这里只是为了让读者更加直观了解坐标与最终渲染的关系

当然,如果我们适当的修改顶点着色器中代码,将x、y值分别再乘以0.5,就能看到一个缩小版的三角形了:

140

现在我们已经讲解了关于Vertex配置VertexStatemoduleentry_point字段了,对于剩余的bufferscompilation_options字段来说,本章暂时不进行讨论,只需要默认即可:

1
2
3
4
5
6
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[], // <--- 默认
compilation_options: Default::default(), // <--- 默认
},

图元配置

对于图片的配置如下:

1
2
3
4
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},

在本文中笔者仅展示一个核心的字段配置:topology。对于这个参数,有以下几个目前能够支持的配置:

  • PointList:顶点数据是一系列点。每个顶点都是一个新点。也就是说,像上面的我们提供的3个顶点,最终并不会渲染为一个三角形,而是三个独立的点。

  • LineList:顶点数据是一系列线条。每对顶点组成一条新线。顶点0 1 2 3创建两条线0 1和2 3。注意,这个枚举值配置下,我们提供的顶点必须要能够成对出现,像上面我们的3个顶点,最终只会渲染一条线,因为0、1构成一条线,而顶点2没办法构成另一条线了。

  • LineStrip:顶点数据是一条直线。每组两个相邻的顶点形成一条线。顶点0 1 2 3创建三条线0 1、1 2和2 3。也就是说上面的例子最终不会渲染一个填充了内容的三角形,而是只有边线的三角形。

  • TriangleList(默认):顶点数据是一系列三角形。每组3个顶点组成一个新的三角形。顶点0 1 2 3 4 5创建两个三角形0 1 2和3 4 5。这是我们的默认配置。

  • TriangleStrip:顶点数据是一个三角形条带。每组三个相邻顶点形成一个三角形。顶点0 1 2 3 4 5创建四个三角形0 1 2、2 1 3、2 3 4和4 3 5。

通过解释,相信读者应该能够理解上述配置的结果,当然读者会在后续的文章中用更多的例子来讲解这块的内容。

片元着色器

接下来让我们关注片元着色器的部分。要了解片元着色器,我们首先要知道什么是片元,片元是怎么来的。在前面的的顶点着色器部分我们知道输入三个顶点索引,能够通过顶点着色器来计算出三个顶点的坐标,再通过图元的拓扑配置,来表明这三个点构成的是一个三角面(而不是三个点或者三条直线),再通过顶点坐标进而控制一个三角面在空间中的位置大小。有了位置大小以后,渲染管线的处理过程中会进行一个步骤:光栅化。光栅化逻辑就是对于几何图形上每一个“点”,在屏幕设备上找到对应的像素点的过程。

150

对于光栅化的具体实现实现,就不在本文的讨论范围内了,对于这块感兴趣的同学可以自行查阅相关资料进行深入研究。

简单了解完光栅化基本形式和结果后,让我们回到本节的核心:片元fragment。片元实际上就是一个图形经过光栅化处理后的一个或多个像素的样本。在这有两点值得注意:

  1. 尽管叫做片元,但通常指的是一个或少许多个像素大小的单位。也就是说,一个几何图形,经过光栅化会被分解为多个片元。
  2. 光栅化后得到的片元只是接近像素点,但并不完全等于像素点。片元是与像素相关的、待处理的数据集合,包括颜色、深度、纹理坐标等信息(深度和纹理坐标等先简单理解为一些额外数据)。

片元并非像素点,它只是接近像素点,所以通常来说,我们还会有一个步骤来对片元进行进一步的处理,好让它最终转换为屏幕上的像素点来进行呈现(此时基本就是带有rgba颜色的点了)。那么这个步骤实际上就是调用片元着色器进行处理。这个过程则是,渲染管线在顶点着色器处理后的某个步骤中,计算得到m个片元;随后,渲染管线会调用片元着色器,并把片元上下文内容通过参数传入到片元着色器的入口方法中,并返回对应片元在屏幕上的色彩:

160

因此,我们先前在shader.wgsl中编写的片元着色器的代码其实就很容易理解了:

170

上述的代码中,首先我们使用@fragment注解标记了这个名为fs_main的方法为片元着色器的入口方法;其次,对于这个方法的实现,非常见简单,我们总是返回rgba为(1.0, 0.0, 0.0, 1.0)的红色颜色值。同时,配置方式如下:

180

这里我们需要关注一个点。在片元着色器中,我们最终返回的类型定义是:@location(0) vec4<f32>,这个vec4<f32>读者应该理解,就是一个表示rgba的颜色值。那这个@location(0)什么含义呢?其实上图的配置过程能够给到一定的提示。在配置fragment参数时候,我们配置了targets: &[Some(swap_chain_format.into())],这个targets是一个数组,我们传入了唯一一个元素Some(swap_chain_format.into()),而片元着色器的返回中配置的@location(0),其意义就是把片元着色器计算得到的颜色“放到”位置索引为0的颜色目标,而这个颜色目标在这里就是由swap_chain_format.into()转换得到的颜色目标ColorTargetState

190

至此,我们大体上了解了片元着色器中的基本使用方式。不过在本例中,我们的片元着色器并没有任何的入参,且始终返回的是一个固定的颜色值。不过在后续文章,我们会通过更多的示例来讲解片元着色器。

使用渲染管线

上面的代码中,我们仅仅是在构造Wgpu上下文的阶段创建了一个渲染管线并将它存放到了WgpuCtx的render_pipeline字段。那么我们应该在哪里去使用这个渲染管线呢?答案就是在之前我们编写的WgpuCtx的draw方法中去使用它:

200

对于新增的代码,第一步中set_pipeline(xxx)很好理解这里不再赘述;第二步对于调用渲染通道(render_pass)的draw方法的参数需要说明一下。该draw方法的第一个参数定义是:vertices: Range<u32>,在这里我们传入了一个0..3,其意义就是告诉渲染管线,我提供了0、1、2三个顶点。再回看我们的顶点着色器代码,我们在顶点着色器的入口方法定义的参数:@builtin(vertex_index) in_vertex_index: u32,这里的@builtin(vertex_index)就是想表达这样一个事实:在顶点着色器代码入口给我依次传入0、1、2的顶点索引,好让我们能够通过一些计算得到我期望的三角形的三个几何顶点的位置。

210对于第2个参数instances: Range<u32>在本章中情况下我们都传0..1,即只有一个渲染实例。当然,当你需要绘制多个相同或相似的对象时,可以使用实例化渲染。instances 参数指定了要绘制的实例数量。同时,我们还可以在顶点着色器中通过@builtin(instance_index)来得到当前的实例索引。举个例子,假设现在我们想要绘制两个三角形。一种方式是提供两个三角形的顶点(比如,我们传入0-5共计6个顶点)来表示2个三角形,我们也可以像之前一样,传入3个顶点索引,但构造两个实例:

220

然后,我们修改原先的顶点着色器入口参数,添加对实例索引的访问:

230

再次运行程序,我们会发现现在窗口中渲染了两个三角形:

240

写在最后

至此本章的内容就基本上接近尾声了,在本文中我们在第一章的基础上,进一步介绍了渲染管线以及着色器代码,并通过代码实践,希望让读者更加清晰了解整个过程。当然,目前为止我们仅仅在顶点着色器处理阶段消费了顶点的索引,以及在片元着色器处理阶段返回了固定的颜色值,而实际应用场景下远没有如此简单。因此在接下来的文章我们将介绍新的概念来实现如何更加动态的构建三角形。

本章的代码仓库在这里:

https://github.com/w4ngzhen/wgpu_winit_example/tree/main/ch02_render_a_triangle

后续文章的相关代码也会在该仓库中添加,所以感兴趣的读者可以点个star,谢谢你们的支持!