实时渲染管线:(三)逻辑管线[转]

作者:木头骨头石头
原文地址:https://zhuanlan.zhihu.com/p/440593877

书接上文:实时渲染管线:(二)CPU/GPU 体系结构简介。介绍了 CPU/GPU 的体系结构,事实上图形计算才是 GPU 的拿手好戏。为了控制 GPU 完成图形渲染,也为了方便开发者理解,图形 API 层面会把渲染算法(光栅化算法和光线追踪算法)拆分成一些可并行的阶段,这些阶段具体在 GPU 中是如何调度和执行的我们不得而知,但开发者可以通过图形接口操作这些阶段,实现各种渲染效果。这些阶段组合在一起,就是逻辑管线,目前图形 API 制定了四种管线:

  1. 光栅化渲染管线
  2. 计算管线
  3. 光线追踪渲染管线
  4. 网格渲染管线

另外,并不是所有 GPU 都支持这四种管线,详见:实时渲染管线:(一)基本概念与发展简史

1 光栅化图形渲染管线

光栅化图形渲染管线是最常见的实时渲染管线,早期 GPU 硬件设计都是服务于光栅化管线。参考《Real-Time Rendering Fourth Edition》,将光栅化算法的拆分为如下四个阶段:

其中,应用程序阶段在 CPU 端执行,其行为完全由开发者决定。在应用程序阶段主要工作是准备场景数据:光源、几何模型、摄像机…… 然后调用图形 API ,将这些数据发送到显存,同时设置 GPU 状态,命令 GPU 开始渲染。从几何阶段开始,在 GPU 端执行,才是真正的进入光栅化图形管线,也称为 GPU 逻辑管线。另外,在应用程序阶段通常会完成一些非图形渲染的任务,比如:

硬件输入:应用阶段需要处理诸如键盘、鼠标、手柄等外部设备的输入。硬件输入都是操作系统提供的功能,应用阶段需要处理这些输入,生成相应的反馈。比如鼠标点击,图形旋转。

排序(Sorting):在光栅化算法中就已经介绍了渲染顺序的重要性,也即在光栅化算法前,我们需要决定场景中每一个物体绘制的先后顺序,这是我们可以掌控的。

碰撞检测(Collision Detection):碰撞检测也就是几何体的相交测试,这是物理模拟和加速算法的重要基础。为了简化几何之间的碰撞检测,我们通常会基于几何模型生产包围体(Bounding Volume)作近似处理。

动画相关:生成动画有两种方式,一是在 DCC 软件中制作,然后实时播放,比如蒙皮动画。另一种是基于物理实时模拟。

批处理(Batch):是由 CPU/GPU 异构体系本身而带来的概念。对于顺序执行的光栅化算法本身,绘制一个 1000 个三角形所需要的时间是固定的。但 CPU/GPU 是两个并行的处理器,CPU 和 GPU 之间又互相独立,绘制工作需要它们配合完成,因此不同的绘制策略自然会带来不同的耗时。一个场景的三角形数据本身是存储在硬盘中的,CPU 需要把它们从硬盘中读取出来放到内存中,再经系统总线发送到显存中,然后发送绘制指令(Draw Call)命令 GPU 绘制三角形。GPU 是一个高度平行的处理器,它同时可以绘制大量的三角形。如果 CPU 将这 1000 个三角形一个一个提交,那么自然会有大量的时间浪费在总线上,而且 GPU 的计算核心大多也会处于闲置,此外绘制指令也会占用 CPU 时间,这些无意义的消耗对实时渲染来说应该尽可能避免。因此考虑花费一点 CPU 时间将这 1000 个三角形(带宽允许的情况下)同时发送给 GPU,然后发送一个(或少数几个)绘制指令,让 GPU 同时绘制这些三角形。

此外对于纹理我们也可以作类似的批处理,也即为了避免将一些小图一个个的提交到 GPU 端,我们将这些小图合成一张大图,一次提交给 GPU。如果这张小图是用在模型上的,还需重新设置顶点的 UV 坐标

加速算法(Acceleration Algorithm):根据场景本身的特点和观察角度,我们并不需要将场景如实绘制,我们可以在渲染管线之前,花费一点额外的计算时间减少场景需要绘制的元素,那么可以显著的减少渲染时间。加速算法分三类:

  1. 空间数据结构(Spatial Data Structure):空间数据结构虽然没有减少物体或三角形的数量,但它却是所有加速算法的基础。常见的空间数据结构会将空间划分成一些包围体,并用一个树结构组织起来,以加速物体的查询。
  2. 剔除算法(Culling):场景中的有的部分对摄像机来说并不可见,有的可能是因为在视锥体之外,有的可能是因为被其他物体遮挡。如果在绘制之前花费一点时间将这些不见的物体全部剔除,只绘制我们看得见的物体,那么可以极大的缩短渲染时间。与之相似的是光栅化算法的视口裁剪算法、背面剔除算法和提前深度/模板测试,它们的结果都是避免场景中不可见部分的绘制。这里的剔除算法是更加粗粒度的,物体级别的剔除。
  3. 细节层级(Level of Details,LoD):对于透视投影来说,距离视点较远的模型最后可能只占屏幕空间很小一块区域。如果仍以原始的模型精度进行渲染无疑会产生很多小三角形,而且模型太小很多细节根本看不清。因此根据模型距离视点的距离动态调整模型的面数和纹理的质量可以有效的提升渲染效率。

不管是光栅化图形管线还是光线追踪图形管线,都有应用程序阶段,应用程序阶段的主要任务是准备好渲染所需的数据,设置管线的状态,处理输入输出…… 。随着 GPGPU 的发展,越来越多的应用程序阶段的任务可以交由 Computer Shader 完成,比如加速算法、动画模拟等。

在应用阶段,准备好的渲染数据,设置好管线状态,调用图形 API 的绘制函数(Draw Call)开始一趟 Pass 的绘制。离开应用程序阶段,剩下的工作都是在 GPU 中并行执行,下图来自:Life of a triangle – NVIDIA’s logical pipeline

在 GPU 内部有许多 Crossbar 用于管线各个阶段之间数据的传递

下图是 D3D11 的光栅化图形渲染管线,其中矩形代表固定功能阶段,圆角矩形代表可编程管线阶段,这一逻辑管线延续到 D3D12 :

下图是 OpenGL 4.6 的光栅化图形渲染管线,其中粉色方框代表固定功能阶段,黄色方框代表可编程管线阶段,蓝色方块表示管线资源(数据),图中还详细的画出了计算管线与图形管线的交互。

1.1 几何处理阶段

几何处理阶段是在 GPU 中执行的第一个阶段,它可以进一步细分为:输入装配、顶点着色、细分曲面、几何着色、流输出五个阶段。

顶点与索引:我们在应用阶段需要准备模型的顶点与索引数据,放到显存中。顶点数据包含了顶点坐标、纹理坐标、法线方向等,索引数据引用顶点构成图元。以 *.obj 格式:

v -0.500000 -0.500000 0.500000
v 0.500000 -0.500000 0.500000
...

vt 0.001992 0.001992
vt 0.998008 0.001992
vt 0.001992 0.998008
...

vn 0.000000 0.000000 1.000000
vn 0.000000 0.000000 1.000000
vn 0.000000 0.000000 1.000000
...

s 1
f 1/1/1 2/2/2 3/3/3
f 3/3/3 2/2/2 4/4/4
s 2
f 3/13/5 4/14/6 5/15/7
f 5/15/7 4/14/6 6/16/8
...

其中,以 v 开头的一行,表示一个顶点的位置坐标;以 vt 开头的一行,表示一个顶点的纹理坐标;以 vn 开头的一行,表示一个顶点的法线方向。这里顶点数据是按 SoA 布局存放的。以 f 开头的一行表示一个三角形图元三个顶点的索引,每个顶点的坐标、纹理、法线索引用 / 隔开。索引是可有可无的,但对于三角形网格模型来说,有的顶点会被相邻三角形公用,如果没有索引,这些公用的顶点将重复出现在不同三角形中,增加了存储和带宽开销,因此大多数情况下都会使用索引。

输入装配(Input Assembly Stage):是几何处理阶段中第一个固定功能阶段,在 OpenGL 中称为 Vertex Pull,它会根据输入布局装配顶点数据,根据图元类型和索引将顶点装配成图元,然后交给顶点着色阶段处理。

顶点着色阶段(Vertex Shading Stage):必选的可编程管线阶段。顶点着色阶段获取顶点(Fetch Vertex)有两种方式:一种是无索引,直接从顶点缓存中读取顶点处理;另一种是有索引,遍历所有索引,根据索引读取顶点。对于第二种情况,遍历索引取顶点处理会使那些多个图元公用的顶点被重复 Fetch,因此现代 GPU 都会有一个 Cache 去缓存已经处理过的顶点,避免重复处理。在 GPU 中,获取顶点的操作是由 Poly Morph Engine 中一个硬件专门完成,当取到 32 个顶点后,就启动线程。并行处理。如果没有细分曲面、几何着色等阶段,我们通常会在顶点着色器中把顶点坐标变换到齐次裁剪坐标下,供光栅化阶段使用。在光栅化之前,会对图元进行裁剪,硬件的裁剪算法为 guard-band 裁剪。这在光栅化算法一文中已经介绍过了。

细分曲面阶段(Tessellation Stage):可选的管线阶段,如果启用细分曲面阶段,需要将图元装配的模式设置为控制点块(Control Point Patch)。细分曲面阶段可进一步细分为三个子阶段。

  1. 外壳着色阶段(Hull Shading Stage):在 OpenGL 中称为 Tessellation Control Shadering Stage。该阶段会遍历模型的每一个 Patch,并且将 Patch 中所有的控制点输入开发者指定的外壳着色器程序。在外壳着色阶段需要完成两件事情:一是计算曲面细分因子,也即该曲面细分的程度,通常根据 Patch 与观察点的距离动态变化;二是根据需求变化每个控制点的坐标,甚至可以增加控制点。外壳着色阶段需要输出曲面细分因子和变化后的控制点
  2. 细分曲面阶段(Tessellator Stage):在 OpenGL 中称为 Tessellation Primitive Generation Stage。固定功能阶段,根据曲面细分因子和控制点细分曲面,输出产生的新顶点。该阶段是在 SM 中的 PolyMorph Engine 中执行的
  3. 域着色阶段(Domain Shading Stage):在 OpenGL 中称为 Tessellation Evaluation Shading Stage。Patch 细分之后会得到更多的顶点,该阶段会遍历 Patch 产生的所有新的顶点,将曲面细分因子、新顶点的域坐标(Domain Location)、Patch 控制点输入开发者指定的域着色器程序中。我们需要根据 Patch 控制点的属性插值新顶点的属性,插值的依据是顶点的域坐标(Domain Location),输出变化后的新顶点。

几何着色阶段(Geometry Shading Stage):可选的管线阶段。几何着色阶段会遍历每个图元,将图元输入到开发者指定的几何着色器中。几何着色器可以将一中图元变换成另一种图元,比如将一个点图元扩张成一个三角形图元。几何着色器也可以选择删除一个图元。因此几何着色阶段也会改变网格模型顶点的数量。

在几何处理阶段结束后会进行视口变换(Viewport Transform),将图元坐标变换到屏幕空间。然后 Work Distribution Crossbar 会把图元光栅化的任务根据其屏幕坐标分配到不同的 GPC 的光栅化引擎中进一步处理,同一个三角形可能被分配到不同的 GPC 中。因为光栅化和像素处理都是基于屏幕空间,NVIDIA GPU 的一个 GPC 会负责屏幕的一个区域,将图元分配到相应区域的 GPC 中既可以加快光栅化的速度又可以保证局部性原理。比如在片元着色器会采样纹理,位置相近的三角形可能属于同一个模型,同一个模型的会使用同一张纹理。那么就可以把这张纹理放到该 GPC 中 SM 的 L1 Cache 中,加快采样的速度。

几何处理阶段的工作繁琐且重复,因此在最新的网格渲染管线中,决定将几何处理阶段的任务简化为两个着色器 Task Shader、Mesh Shader 处理

1.2 光栅化阶段

几何处理阶段输出的图元是矢量图形,经过光栅化阶段,会将它们离散成像素图形。在《Real-Time Rendering Fourth Edition》中把光栅阶分为两个子阶段,以三角形图元为例:

  1. 三角形设置(Triangle Setup):计算三角形的 AABB、边界函数等数据,为三角形遍历作准备
  2. 三角形遍历(Triangle Traversal):遍历三角形,找到三角形的覆盖的像素(片元)。在没有开启反走样模式时,会以像素中心坐标采样三角形,找到三角形覆盖的像素;如果开启多重采样反走样(MSAA),那么每个像素的采样数将大于 1,采样点也不再是像素中心,而是在一个像素中按某种规律分布

1.2.1 三角形的边界函数

三角形遍历阶段的首要任务是判断平面空间中某一采样点是否在三角形内。在光栅化算法中,介绍了利用重心坐标判断一点是否在三角形内。这一算法并没有的利用 GPU SIMD 处理器的特性,下面介绍的是在光栅化引擎中使用的算法 —— 边界函数法(Edge Function)

边界函数:三角形的 [公式] 边可以表示为:

[公式]

其中, [公式] 是边 [公式] 的法线,指向三角形内; [公式] 表示该边上的任意一点坐标。三角形三个顶点 [公式] 是按照逆时针绕序遍历的,我们通过向量 [公式] 逆时针旋转 [公式] 就得到 [公式] 。假设 [公式] ,那么 [公式] 。

式 (1) 是一条直线方程,改写成一般式:

[公式]

设三角形任意一条边为 [公式] ,那么该三角形用边界方程表示为:

[公式]

我们在三角形设置阶段算出 [公式] 。

利用边界函数判断点与三角形的位置关系:由上图可以发现边 [公式] 将平面分成两个部分,一部分与 [公式] 同侧,另一部分与 [公式] 异侧。我们将采样点坐标 [公式] 代入 [公式] ,发现:

  • 如果该点与 [公式] 在同侧的区域,那么 [公式]
  • 如果该点与 [公式] 在异侧的区域,那么 [公式]

我们把这一采样点坐标代入三条边的边缘方程中,如果这三条边同时成立: [公式] 则说明 [公式] 在三角形内。

top-left 法则:下面讨论采样点刚好在边缘上的情况,也即 [公式],这条边可能是两个三角形的公共边,那么这一采样点应该属于哪个三角形呢?为了避免一个像素被光栅化两次,在 Direct3D 中,使用的是 top-left 法则,当一个像素点在一个三角形的左边或上边时,这个采样点就属于这个三角形。在 D3D 中,左边和上边的定义为:

  • “上边”的定义:这条边是水平的,另外两条边在均在这条下面。上边方程中 [公式]
  • “左边”的定义:这条边是非水平的,而且它在三角形的左侧。一个三角形最多有两条“左边”。左边的方程中 [公式]

定点数坐标表示:在输入光栅化阶段的顶点坐标是用浮点(float-point)数表示的屏幕空间坐标,为了高效的光栅化,图形 API 会把它们转换成定点(fixed-point)数表示的坐标。比如将一个浮点坐标转换为 1.14.8 定点坐标:其中 1 个符号位;14 位表示屏幕坐标的整数部分;8 位表示一个像素内的分数部分,那么定点数坐标的每个分量的取值范围是: [公式] 。这种转换需要在计算边界函数之前进行。

边缘函数的自增性:因为屏幕空间采样点的坐标之间只相差一个整数, [公式] 表示某一像素中心坐标,那么其相邻像素中心坐标为: [公式] 。带入边缘函数 (3):

[公式]

该公式成立的条件是屏幕像素坐标是离散的,称为边界函数的自增性(incremental property)。利用这一性质,我们把屏幕像素以 [公式] 个像素为一个 tile,判断三角形的覆盖情况。只要计算 tile 中一个像素的边界函数值,利用边界函数的性质,可以同时得到其他 63 个像素的边界值。这里也体现了光栅化阶段数据使用顶点数表示的优势,因为边界函数值的计算在一个 tile 中只是简单加减法,定点数的加法器比浮点数计算器实现简单很多。

重心坐标:平面上任意一点 p 的重心坐标 [公式] 与三角形三个顶点的坐标的关系为:

[公式]

如果该点在三角形内,那么重心坐标还有一个性质:

[公式]

其中 [公式] 是 [公式] 的面积,其中 [公式] 是 [公式] 的面积,其中 [公式] 是 [公式] 的面积。因为三角形的面积是不变的,为了加快重心坐标的计算,可以在三角形设置阶段算出 [公式] 。

将 [公式] 代入边缘方程 [公式] :

[公式]

其中 [公式] 是由 [公式] 逆时针旋转 [公式] 得到的,所以 [公式] 刚好等于 [公式] 的长度。 [公式] 是向量 [公式] 在 [公式] 方向上的投影的长度。所以 [公式] 的结果刚好就是 [公式] 的两倍。

1.2.2 基于 tile 的三角形遍历

介绍了边界函数法,下面介绍在光栅化引擎中,三角形遍历的过程。三角形会根据其屏幕空间,会被分配到不同的 GPC 中的光栅化引擎中。一个光栅化引擎只会遍历该 GPC 中的三角形。

三角形遍历是并行执行,屏幕像素通常以 [公式] 个像素为一个 tile 遍历三角形。在三角形设置阶段,计算好 [公式] 这 64 个值,存储在寄存器中。现在判断 tile 该三角形覆盖覆盖的情况,先计算左上角像素的边界函数值,然后 tile 中每个像素加上提前计算好的 [公式] ,就得到每个像素的边界函数值。如果 tile 中有像素被三角形覆盖,则得到片元,插值出片元属性交给像素处理阶段。对于没有被三角形覆盖的像素,则会被遮掩掉。

为了加快基于 tile 的三角形遍历,可以先计算 tile 是否与三角形的 AABB 相交。或者基于层级的,用一个更大的 tile,比如 [公式] 判断是否与三角形 AABB 相交,然后再作 [公式] 个像素的 tile 与 AABB 相交。

以 tile 的方式遍历三角形还有利于后面的片元着色阶段。

1.2.3 多重采样

假设屏幕分辨率为 [公式] ,屏幕空间的像素可以看成一个个矩形,像素中心坐标为 [公式] ,其中 [公式] 。我们通常会用像素中心坐标采样矢量图元,如果图元片元没有覆盖采样点,采样点的颜色为 但这会得到一幅走样的图像。

MSAA 得到硬件支持,开启 MSAA 后,仍按 tile 遍历三角形。一个 tile 中相邻像素同位置采样点,依然满足边缘函数的可加性。假设一个像素有四个采样点,那么需要计算左上角像素四个像素的边缘函数值,然后根据可加性,同时得到其他所有像素每个像素的边缘函数值。

开启 MSAA 之后,需要更大的帧缓存。假设一个像素被两个三角形覆盖,上图中采样点 0,2,3 被红色三角形覆盖,以像素中心为着色点计算红色三角形的颜色,填入采样点的颜色缓存中。采样点 1 被蓝色三角形覆盖,同样以像素中心为着色点计算蓝色三角形的颜色。最后,把每个采样点的颜色加权平均得到该像素的实际的颜色,这一过程称为 MSAA Resolve。

对于 MSAA 光栅化阶段只会得到采样点的覆盖信息,还要经过深度模板测试,才能决定采样点究竟被那个三角形覆盖。可以在 D3D11 的官方文档中,查看更多的光栅化细节:D3D11 Rasterization Rules

1.2.4 保守光栅化

在 Direct3D 11 中引入了保守光栅化的新三角形遍历模式:保守光栅化(Conservative Rasterization,CR)。保守光栅化特性非常有用,常用于图像空间的碰撞检测、遮挡剔除、阴影计算和反走样。CR 有两种类型:

  1. 高估保守光栅化(overestimated CR,OCR):当像素与三角形重叠就算作三角形片元,如下图中蓝色像素
  2. 低估保守光栅化(underestimated CR,UCR):当像素被三角形完全覆盖才算作三角形片元,如下图中绿色像素

黄色像素是没有开启 MSAA 的,用像素中心像素采样三角形得到的片元,属于标准光栅化。

1.2.5 线段与点的遍历

直线的遍历:在光栅化算法中介绍了 Bresenham 算法光栅化一条直线。如果说让硬件实现 Bresenham 算法是非常麻烦的。实际上,在遍历一条直线时,是把这一条直线当成一个像素宽的矩形,然后再将这个矩形拆成两个三角形遍历。

点的遍历:在光栅化引擎中,把这个点当成一个像素宽的正方形,然后再将这个正方形拆成两个三角形进行光栅化。

这么做的目的自然是减轻硬件设计的负担,使用一套规则完成所有图元的光栅化。

1.3 像素处理阶段

图元经过光栅化阶段,从矢量图形变成像素图形,接下进行像素处理阶段,进一步可分为两个子阶段:片元着色阶段与合并阶段。

片元着色阶段(Fragment Shading Stage):必选的可编程管线阶段,在 OpenGL 中称为片元着色,在 D3D 中称为像素着色(Pixel Shading)。片元着色阶段会遍历光栅化产生的所有片元(像素),执行开发者指定的片元着色器程序。

在 GPU 的一个 Warp 中,片元着色是 [公式] 个像素为一个 quad,一个 Warp 8 个 quads 并行执行。因为光栅化阶段是按 tile 遍历三角形的,从 tile 中可以很容易拆分出 quad 。一个 quad 是片元着色线程分配的最小单元。之所以要按 [公式] 个像素为一组着色,是因为要在片元着色器中计算变量的偏导数,片元着色器虽然不能直接改变或获取向量片元的数据,但通过 ddx() 或 ddy() 函数可以得到某一数据的偏导数,间接获知向量像素的信息。比如计算片元纹理坐标(uv 坐标)的偏导数,从而确定纹理采样的 mip 层级。

以 quad 为一组作像素着色会有一个问题,对于只覆盖一个像素的三角形或者一个细长的三角形,会造成严重的性能浪费。比如一个三角形只覆盖 quad 中一个像素,但仍会启用 4 个线程,其中 3 个线程被浪费掉了。在下一篇文档中介绍如何剔除小三角形

片元着色阶段的输出可以是合并阶段,也可以是开发者指定的渲染目标(Render Target),也即一张纹理,这需要显卡支持 MRT 技术

合并阶段(Merging Stage):合并阶段会进行深度/模板测试(Stencil/Depth Test)和颜色混合(Blending)。属于可配置管线阶段。片元着色阶段输出的结果会经过 Crossbar 发送到 ROP(Render Output)单元中合并,并把结果输出到帧缓存(Frame Buffer,FB)中。合并阶段也是基于 Tile 的,提高缓存命中率。


2 计算管线

在 2011 年,CUDA 和 OpenCL 已经取得了广泛的应用。在图形开发者的要求下,DirectX 11 将 DirectX 10 的 DirectCompute API 升级为 Compute Shader Pipeline,作为 Direct3D 11 标准的一部分,从而更好的利用 GPU 通用计算特性,辅助图形渲染。计算管线只有一个着色器,称为 Compute Shader。计算管线能利用 GPU 并行计算能力,在传统的渲染管线之外,使用 GPU 加速完成非渲染任务,比如后处理、粒子模拟、物理模拟、剔除等。通过转换资源状态,Compute Shader 能轻松与渲染管线交互。

计算管线不涉及图像的绘制与提交,因此开始计算管线的命令是线程分配函数 Dispatch 而不是光栅化渲染管线的 Draw Call 函数。调用 Dispatch(x,y,z) 函数,会为任务分配线程组(Threads Group),同时指定线程组的布局(Layout)。

一个线程组内有多个线程,在着色器代码中,还需指定线程组中线程的布局。比如在 HLSL 中是用 numthreads(x,y,z) 属性指定线程的布局。线程的布局和线程组的布局都是一种三维网格布局,如果我们指定 z =0,那么布局也可以是二维的,这取决于用户的选择。

比如 Dispatch(5,3,2) 一共会分配 5 * 3 * 2 = 30 个线程组,构成一个 x=5, y = 3, z = 3 的线程组网格。numberthreads(10,8,3) 指定一个线程组中,一共有 10 * 8 * 3 = 240 个线程,一个线程组的线程构成一个 x=10, y = 8, z = 3 的线程网格。那么总计会给计算任务分配 30 * 240 = 7200 个线程。引用微软官方文档的配图:

在 Shader 代码中,通过工作组索引(Workgroup Index),可以知道该 Shader 代码是在那个线程组中的哪一个线程执行,比如在 HLSL 中用 SV_GroupThreadID、 SV_GroupID …… 等语义修饰的参数。在分配线程时,线程数量最好是 32 的整数倍,这正好是 NVIDIA GPU 中一个 Warp 的线程数。如果是 AMD GPU,则分配的线程数最好是 64 的整数倍,这是一个 Wavefront 的线程数。

Compute Shader 能随机读写显存资源,因此除了Workgroup Index,Compute Shader 没有任何输入。在计算管线中,一个线程组还有 32 KB 的共享内存。共享内存是在 L1 Cache 上的,供线程组内所有线程使用,加速读写效率。线程组内的线程也可以利用共享内存通信。因为 Compute Shader 可以写入资源,所以引入了屏障函数,用以在不同线程间同步。


3 光线追踪渲染管线

光线追踪管线是 2018 年之后,在最新的显卡和图形 API 上支持的图形渲染管线,这一块资料不如光栅化管线和计算管线那么丰富。在 NVIDIA Turing 架构的 SM 中,开始搭载加速光线与物体相交的硬件 —— RT Core。在 RT Core 中会遍历场景的 BVH,找到距离光线起点最近的一个交点。

光线追踪图形管线的简化流程如下,其中有五个可编程管线阶段、一个固定功能阶段:

  • Ray Generation Shader:光线追踪管线的入口,必选的可编程管线阶段。每 Pass 调用一次。在该着色器中调用 TraceRay() 函数,发射光线。
  • Acceleration Structure Traversal:固定功能阶段。调用 TraceRay() 函数后,光线会遍历场景的加速结构,寻找光线与场景的交点
  • Intersection Shader:可选的可编程管线阶段,编写光线与程序化几何模型的相交算法。如果是三角形网格模型,则不需要这一阶段,寻找光线与三角形的交点由了 RT Core 硬件加速
  • Any Hit Shader:可选的可编程管线阶段,用于验证交点是否有效,常用于 Alpha 测试
  • Closest Hit Shader:必选的可编程管线阶段。当光线遍历结束,找到了场景中距离光线起点最近的交点,调用该 Shader。该阶段可以再次调用 TraceRay(),发射光线,实现光线追踪算法的递归过程。
  • Miss Shader:必选的可编程管线阶段。当光线遍历结束,没有找到了场景中距离光线起点最近的交点,调用该 Shader。该阶段可以再次调用 TraceRay(),发射光线,实现光线追踪算法的递归过程。

下图是光线追踪管线更加详细的流程图:

首先在 Ray Generation Shader 中调用 TraceRay() 函数,生成一条光线,一条光线会携带一个自定义的 PayLoad 数据,用于在各个可编程光线阶段中通信。光线会在遍历加速结构,寻找与光线起点最近的交点。如果是三角形网格模型,相交算法有 RT Core 硬件支持;如果是程序化几何模型,需要编写 Intersection Shader,定义几何体与光线的相交算法。

在遍历的过程中,每找到一个交点,需要判断交点距离光线起点的距离是否比记录中的距离小,如果是则需要调用交点物体的 Any-Hit Shader 验证相交是否有效。如果相交无效则继续遍历场景,如果相交有效则更新记录。

遍历结束,如果有距离光线起点最近的交点,则调用交点物体的 Closet-Hit Shader。在 Close-Hit Shader 中可以编写物体的着色方式,也可以再调用 TraceRay() 函数,继续追踪得到间接光照。如果没有距离光线起点追踪的交点,则调用 Miss Shader,可以返回一个固定色,或者采样天空盒。在 Miss Shader 中也可以再 TraceRay() 函数,发射光线。

目前光线追踪管线主要还是辅助光栅化管线,它不能直接渲染到帧缓存,只能渲染到纹理,也即只支持 off-screen 渲染。如果希望把渲染的结果呈现到屏幕上,需要把纹理拷贝到后台缓存中。光线追踪管线和计算管线很像,开始光线追踪管线的 API 函数不是 Draw Call 而是 Dispatch Rays。

3.1 场景遍历与加速结构

光栅化算法会利用剔除技术,只需要将可见的物体放入显存就可以开始渲染了。但光线追踪不同,光线可以在场景中任意弹射,所以在渲染前需要把场景的所有数据放到显存中。寻找光线与场景交点是光线追踪算法中最费时的阶段,为了加速场景遍历,光线追踪管线把场景用两层加速结构组织起来,参考 DXR 文档:Geometry and acceleration structures

  • Bottom Level Acceleration Structure(BLAS):存储所有三角形网格模型或程序化几何模型。如果是三角形网格模型,则需要存储该模型的顶点数据和索引数据,还有一个 3*4 的仿射变化矩阵;如果是程序化几何模型,则只需要存储该模型的 AABB。如果一个动画会添加或删除模型的三角形,需要重建 BLAS
  • Top Level Acceleration Structure(TLAS):存储所有模型实例。一个 BLAS 中的模型可以创建多个实例,每个实例需要有一个独一无二的 Inst-ID。同一模型的不同实例可以有不同的“材质”,在光线追踪管线中,材质由 any hit shader、closet hit shader、intersection shader 和输入 Shader 的参数共同决定。另外实例中还需要有一个 3*4 的仿射变换矩阵,用于局部-世界坐标变换,可以每帧更新。

TLAS 最终会被组织成一个 BVH,叶子节点存储各个实例。在调用 TraceRay() 函数后,光线需要不断遍历这个 BVH,直到找到最近的交点。RT Core 遍历 BVH 的方式官方并没介绍,而硬件加速光线遍历在学术界已经研究了多年,参考:Ray Tracing 学习之 Traversal 了解更多的遍历细节。

3.2 着色器表与着色器记录

在光栅化管线中,一个物体的材质由 Vertex Shader、Fragment Shader …… 以及输入 Shader 的参数共同描述,在渲染该物体时,需要把相应的 Shader 绑定到渲染管线上。在光栅化管线中,这种绑定意味着管线状态的变换,开销较大。如果在渲染一帧画面的过程中频繁切换管线状态,那么大部分时间可能会浪费在管线状态切换上。因此在光栅化管线中,会把拥有相同 Shader 的物体合成一个批次(Batch)一起绘制或者连续绘制,避免管线状态的切换。

在光线追踪管线中,光线可能会与任何物体相交,因此我们需要提前把所有的 Shader 以及输入 Shader 的纹理、常量、采样器都绑定到管线上。为了更好的组织这些程序和数据,在 DXR 中定义了 Shader Table ,管理所有的 Shader 和 Shader 参数。

在 Shader Table 中,相同类型的 Shader 是连续存放的。Shader Table 中每个元素称为 Shader Record,每个 Shader Record 包含一个 Shader Identifier 指向一个 Shader 程序,另外还包含输入该 Shader 的参数。

在光线追踪管线中,与物体“材质”相关的 Shader 有三个,分别是 Intersection Shader、Any-Hit Shader 和 Closet-Hit Shader,它们统称为 HitGroup,一个 HitGroup 对应一个 Shader Identifier。在 TLAS 中,一个实例至少需要关联一个 HitGroup。如果一个实例关联多个 HitGroup,那么与该光线追踪 Pass 的光线类型有关。有多少种光线类型实例就要关联多少个 HitGroup。所谓光线的类型用于区别光线的行为,比如发射做阴影的光线和做间接光照的光线与物体相交后的行为肯定不一样,因此实例的 HitGroup 数量要与光线类型相同。

不同的实例也可以索引到同一个 HitGroup,实例和 HitGroup 是多对多的关系

3.3 纹理采样

在采样纹理时,我们需要指定在哪一层的 mipmap 上采样,避免走样。在光栅化管线中,片元着色器会以 2*2 个的 quad 执行,而且像素着色器是锁步执行的,因此可以通过函数 ddx(),ddy() 计算得到相邻像素某一数据的偏导数。而在顶点着色器,没有这样的机制,所以需要指定采样纹理的层级。

如果在光线追踪管线的中,每条光线相互独立,而且也不可能锁步执行,所以不可能有 ddx(), ddy() 这种函数。为了在光线追踪中正确的采样纹理,《RTR4》中介绍了两种方案:

  1. Tracing Ray Differentials
  2. Ray Tracing with Cones

4 混合图形渲染管线

目前业界通常采用结合光栅化管线、计算管线和光线追踪管线的混合管线(Hybrid Rendering Pipeline)。详见 EA 在 GDC 2018 的分享 Shiny Pixels and Beyond: Real-Time Raytracing at SEED。用其中的一副图概括:

首先是延迟着色的几何阶段,通过光栅管线生成不透明物体的 G-Buffer,G-Buffer 中包含可见片元的各种信息,如世界坐标、反照率、法线方向等。生成 G-Buffer 的过程相当于从相机发出 Primary Ray,寻找可见的着色点,解决物体的可见性问题。然后是延迟着色的光照阶段,混合管线将计算光照的任务进行拆分,分别交由合适的管线去完成:

  1. 直接阴影(Direct Shadow):直接阴影是图形渲染研究最成熟的课题。如果用光栅化管线去做,那么可以是传统的 Shadow Map,Shadow Volume 等;如果用光线追踪去做,那么从 G-Buffer 中的着色点发出 Shadow Ray,总之选择非常丰富,效果都还不错;
  2. 直接光照(Direct Lighting):直接光照传统的做法是在光栅化管线的片元着色器中完成,这里选择单开一条计算管线去做,本质是一样的,还能节约一个顶点着色阶段和光栅化阶段。如果在计算着色器中做直接光照,需要解决纹理过滤的 mipmap 层级。这不算困难,因为线程组之间,内存是共享的。还可以放到 Shared Memery 上,加速这一过程。
  3. 反射与全局光照(Reflections & Global Illumination):这都都属于间接光照任务,是光线追踪的强项,自然交给光线追踪管线去完成。从 G-Buffer 中的着色点发出散射光,计算间接光照。(在光栅化管线中作间接光照是过去 20 年实时渲染研究的难点,产生了大量的算法,现在有了硬件光线追踪,算是一个终极的解决方案。但并不意味着过去的研究没有价值,在性能孱弱的设备上,传统算法依然有用武之地)
  4. 环境光遮蔽(Ambient Occlusion):解决物体之间的 Contact Shadow ,也是光线追踪管线的强项。但传统的基于屏幕空间的环境光遮蔽效果也还不错,反正 G-Buffer 已经有了,所以用计算管线做也可以。
  5. 透明物体和半透明物体(Transparency & Translucency):用光线追踪算法解决光线在透明物体和半透明物体内部的散射与吸收,在离线渲染领域有大量的论文
  6. 后处理(Post-processing):实现各种风格化后期处理特效,用计算管线实现

5 网格渲染管线

在光栅化图形渲染管线,与几何处理相关的阶段有:输入装配、顶点着色、图元装配、外壳着色、细分曲面、域着色、几何着色、流输出、视口变换与裁剪。其中有可编程管线阶段也有固定功能阶段。比如输入装配、图元装配、细分曲面、流输出、视口变换是固定功能阶段,由 GPU 中特定硬件执行;其他阶段都是可编程管线阶段,在流处理器中执行。

可以看到光栅化管线的几何处理非常繁琐且重复,这其中有如下几个问题:

  • 裁剪是在几何处理完之后才进行,不可见的图元依然走完了完整的几何处理流程
  • 输入装配和图元装配是固定功能阶段,因此顶点缓存和索引缓存的输入非常不灵活,需要额外的数据指定顶点的输入布局
  • 几何处理流程繁琐,所有可编程管线阶段都是在流处理器上完成,徒增任务切换的消耗
  • 几何处理流程重复,比如域着色中会处理每个细分之后的顶点,然在细分曲面阶段开始之前,仍然会经历一个什么也不做的顶点着色阶段

光栅化渲染管线有许多历史包袱,因此在 2019 年,NVIDIA 与 Microsoft 推出 Mesh Shading Pipeline,将几何阶段的工作交由 Amplification Shader 和 Mesh Shader 完成。Mesh Shading Pipeline 的设计更接近 Compute Pipeline,利用 GPU 通用计算特性完成几何处理阶段。

在 Vulkan 中,称 Amplification Shader 为 Task Shader

Mesh Shading Pipeline 调用 DispatchMesh(x,y,z) 函数分配线程组开始工作,而不是光栅化渲染管线的 Draw Call 函数;在 Amplification/Mesh Shader 代码中需要通过 NumThreads(x,y,z) 属性指定一个线程组中线程的数量与布局。目前,Mesh Shading Pipeline 一个线程组中最多可以分配 128 个线程,一个线程组的共享内存不超过 28 KB,一个线程组可以共享不超过 28 KB 的共享内存。Amplification/Mesh Shader 和 Compute Shader 一样,都可以可以读写显存,因此除了标记该 Shader 的 Workgroup Index,没有其他任何输入。

模型数据的输入:因为 Mesh Shader 和 Compute Shader 一样,可以读写显存,所以输入 Mesh Shading Pipeline 的数据可以更加灵活。我们依然需要把模型的顶点和索引数据放到显存中,另外 Mesh Shading Pipeline 引入了 Meshlet 的概念,明确 Mesh Shader 需要处理的任务。

Meshlet:是网格模型的一个子集,一个网格模型可以划分出若干个 Meshlet。一个 Meshlet 可以包含 32-200 个顶点,以及由这些顶点构成的若干个图元。Meshlet 的划分是在渲染前预计算的,通过遍历模型的索引,每遍历 64 个顶点或者 126 个三角形,就划分出一个 Meshlet,Meshlet 中实际存储的是顶点/索引数和顶点/索引在顶点/索引缓存中的偏移。下图中不同的颜色块就是一个 Meshlet。

Mesh Shader:必选的可编程管线阶段,用以替代 VS+GS 或者 DS+GS。如果没有 Amplification Shader,调用 DispatchMesh 函数后,分配线程组开始执行。通常,我们让一个线程组处理一个 Meshlet,线程组中一个线程处理一个图元。我们可以在 Mesh Shader 中作视锥剔除、小三角形剔除、背面剔除等加速算法。Mesh Shader 需要输出处理后的 Meshlet 的顶点和索引,并且指定图元拓扑。之后的过程和光栅化渲染管线一样,图元装配,光栅化、像素(片元)着色器 ……

Amplification Shader:可选的可编程管线阶段,用以替代 VS+HS。细分曲面会增加或减少模型的面数,因此 Meshlet 的数量也会变化。在 Mesh Shader 之前引入一个可选阶段,称为 Amplification Shader(Vulkan 中称 Task Shader)。Amplification Shader 的作用与 HS 类似,需要决定曲面细分的程度,并根据细分程度分配相应数量的 Mesh Shader 处理顶点和图元,因此在 Amplification Shader 最后需要再次调用 DispatchMesh 分配线程组执行 Mesh Shader。DispatchMesh 函数需要传入自定义的 Payload 数据,这与光线追踪管线类似,这个 Payload 数据用于在 Amplification Shader 和 Mesh Shader 之间传递信息。