Unity3D Shader系列之渲染流水线

Unity3D Shader系列之渲染流水线

2022年3月19日 0 作者 老王

1 引言

硬盘上的一个3D模型模型是怎么渲染到我们的屏幕上来的呢?
答案是经过渲染流水线。完整的渲染流水线包括3个大阶段,应用阶段→几何阶段→光栅化阶段。每个大阶段又分为几个小阶段。下面我们来看看整个渲染流水线过程。
渲染流水线3个阶段

2 应用阶段

应用阶段分为3个小步骤:
①加载模型数据到显存
②设置渲染状态
③调用Draw Call

2.1 加载模型数据到显存

先是CPU将模型数据加载到内存中,模型数据包括模型的网格数据(包括顶点的局部坐标、顶点的法线、顶点颜色以及顶点的纹理坐标等)、法线、材质、贴图等。
然后GPU将这些模型数据加载到显存中。
为什么需要将模型数据加载到显存呢?
①是GPU压根没有权限访问内存,只能访问显存。
②是GPU访问显存速度最快,可达100GB/s。(CPU访问内存速度为10GB/s,GPU与CPU通信速度为1GB/s)

2.2 设置渲染状态

设置渲染状态,简单地说就是CPU告诉GPU后面渲染某个网格的时候,使用哪一个材质、使用哪几张纹理。
但是设置完渲染状态后,GPU还没真正的开始渲染,而是等待CPU发号施令。

2.3 调用Draw Call

CPU告诉GPU可以渲染了,然后GPU就按照上一步的渲染状态开始渲染模型。CPU告诉GPU开始渲染的这个过程或者这个命令,就叫Draw Call。
在代码层面来说,就是CPU调用OpenGl或DirectX的绘制命令的时候,就会产生一个Draw Call。
这里有几个需要注意的点:
①CPU调用Draw Call,其实是将这个绘制命令添加到一个渲染命令列表中,GPU依次从这个渲染命令列表中取出绘制命令,然后按照绘制命令渲染模型,这样设计可以实现CPU与GPU的并行运行,有点类似设计模式中的生产消费者模式。
②Draw Call不是性能瓶颈的真正原因。
需要注意的是,过多的Draw Call会造成性能瓶颈,但Draw Call本身(仅仅是一个命令而已)其实基本没有什么性能消耗,主要的消耗是在前两个步骤,即加载模型数据和设置渲染状态,这两个步骤非常耗时,耗时的主要地方在于从磁盘加载数据然后再加载到显存。
③批处理是减少Draw Call的最常使用的方法
理论上,我们要渲染一个物体,都要经过上面这几个步骤,即加载模型数据到显存→设置渲染状态→调用Draw Call。也就是说,如果我们要渲染1000个物体,那么就要加载1000次数据到显存,设置1000次渲染状态,调用1000次Draw Call。那么有什么方法能够减少Draw Call呢?答案是批处理。
批处理就是将使用相同纹理、相同材质的网格合并为一个大网格,然后直接加载一次模型数据到显存,设置1次渲染状态,调用1次Draw Call。比如我们这个例子中,假设这1000个物体用到的材质和纹理是一样的,那么我们先将这1000个物体合并成一个大的网格,然后只需调用一次Draw Call即可将这1000个物体同时渲染出来。
这个过程可以类比拷贝文件,我们直接拷贝一个1GB的文件绝对是比拷贝1024个1MB的文件快的。
但是有同学会问,合并网格需要时间啊,合并网格的性能消耗不用考虑吗?这是个好问题。
就像拷贝1024个1MB的文件,我们有两种方法,一是将1024个1MB的文件压缩成一个文件,然后再拷贝;二是直接拷贝1024个1MB的文件。但是这两种方法从时间上来说,可能还真的差不多。
所以,我们一般是事先就把场景中的网格合并好,这就是静态批处理。当然Unity3D也是支持动态批处理的,但是限制很大,原因很可能就是因为动态合并网格花的时间可能比动态处理节约的时间还多。
关于静态批处理和动态批处理,可以参考之间的博客《Unity3D客户端项目优化总结之Stats统计面板》《Unity3D客户端项目优化总结之静态批处理Static Batching》。
④关闭实时灯光及其阴影也能大大减少Draw Call
原因在于实时灯光生成阴影时,会将相机放在灯光的位置,然后重新渲染一遍受灯光影响的物体,从而生成阴影映射纹理。(这个涉及到阴影的生成的方法了,有兴趣的同学可以去深入了解下)
应用阶段完成后,CPU的工作就完成了,下面就进入到GPU流水线,GPU流水线包括几何阶段和光栅化阶段。
渲染流水线

3 几何阶段

几何阶段分为5个小步骤,顶点着色器→曲面细分器→几何着色器→裁剪→屏幕映射。

3.1 顶点着色器

顶点着色器是GPU开始渲染网格的第一个步骤。每个顶点都会调用一次顶点着色器。
顶点着色器的输入是网格的顶点数据,顶点数据中一定是需要包括该顶点的模型坐标系下的坐标位置的,其他如该顶点的法线方向、纹理坐标、切线方向是可选的,根据需要来设置。
顶点着色器一个最最重要也是必须的工作就是将顶点从模型空间变换到齐次裁剪空间,也就是进行MVP变换。(变换到齐次裁剪空间的作用就是为了方便后面步骤的裁剪)
另外一些工作是可选的,按自己的需求来,比如计算世界坐标系下的法线方向、世界坐标系下的视线方向等,这些工作的目的是为后面进行光照计算或实现特殊效果准备数据。

3.2 曲面细分器

顶点着色器完成后,就进入曲面细分器。曲面细分器在Shader中不是必须经过的步骤,是可选的。按照自己的需求来选择。
曲面细分器的作用就是细分网格。
曲面细分作用
不是所有GPU都支持曲面细分器的。
这个我工作中基本没使用过,就不多说了。

3.3 几何着色器

然后进入几何着色器,这一步也不是必需的,按自己需求来选择。
顶点着色器不能够创建和销毁任何顶点,也不知道此顶点与其他顶点的关系(比如这个顶点与其他顶点是否在同一个三角形里面)。
而几何着色器以完整的图元(Primitive)作为输入数据,输出经过我们处理后的图元。我们可以在几何着色器里面去创建或销毁顶点,完全控制输出的图元个数与类型。几何着色器的输入图元和输出图元都可以为点、线、面任一种。
这里解释一下什么是图元,所谓图元就是由这些顶点构成的图形。如两个顶点构成一条线段,三个顶点构成一个三角形,这里的线段或三角形就叫图元。

3.4 裁剪

几何着色器完成后,就进入裁剪这个步骤。
什么是裁剪呢?比如某一个三角形有可能有两个点在摄像机视线内,而另外一个点在摄像机视线外,那么就需要将视线外的顶点给移除掉,然后重新生成两个交点。如下图。
裁剪
注意,裁剪一般是在裁剪空间进行,裁剪完成后在进行透视除法
为什么要在顶点着色器中将顶点从模型空间变换到齐次裁剪空间呢,原因有二,①是统一透视相机与正交相机的裁剪操作,②是简化裁剪操作,在齐次裁剪空间下,判断一个顶点(x,y,z,w)是否在相机的视野内就只需判断顶点的xyz是否在[-w, w]范围即可。
一般使用Sutherland-Hodgeman裁剪算法,该算法又被称为逐边裁剪算法。在二维平面下的原理是遍历每条裁剪边,生成顶点重新构成多边形后再作为下一条边的输入。如下图。
Sutherland-Hodgeman裁剪算法过程
只不过在齐次裁剪空间下,裁剪平面变为了6个平面(而不是四条线),使用点到平面的距离来判断在平面的内外和插值。
在裁剪完成之后,硬件会进行透视除法(将齐次裁剪空间下的坐标的xyz值除以w分量)得到NDC坐标(归一化后的设备坐标)。NDC坐标其实就是将相机的视锥体变换为了一个单位立方体。
为什么要在齐次裁剪空间进行裁剪,而不再NDC坐标系下进行裁剪呢?

因为当MVP之后,这时候顶点的w分量保存的是 (负的相机空间z值,是不是负的由投影矩阵决定,这里只是举例未来方便说明),如果在NDC下就要进行透视除法除w分量,这时候如果再去做剔除已经为时已晚。因为如果顶点的z分量是负的,那么没事,如果是正的,本应该我们看不到的(右手系),这时候除w分量,会导致该点的xyz全部反转,本不应该出现在视野内的点却出现了。所以要提前把不会出现在是视野内的片元干掉。
不在NDC坐标进行裁剪的原因

既然有除法,就要注意0的情况。齐次除法的w不会存在为0的情况,原因如下:在齐次裁剪空间,对于透视摄像机,w的值的范围为[Near, Far],Near、Far分别为摄像机的近裁剪平面与原裁剪平面的距离;而对于正交摄像机,w=1。

参考这篇文章《一篇文章彻底弄懂齐次裁剪》。

裁剪以及透视除法完全是由硬件完成的,我们不能有任何的改变。

3.5 屏幕映射

就是将NDC坐标变换到屏幕坐标系(一个二维的坐标系)。
这一步其实就是对NDC坐标的xy值进行一个缩放。注意,这一步中NDC坐标的z值保持不变。
屏幕映射
这一步需要注意OpenGL与DirectX的差异,OpenGL将屏幕的左下角单成窗口最小值,而DirectX则将屏幕的左上角为窗口的最小坐标值。
OpenGL DriectX屏幕坐标平台差异
另外,屏幕坐标加上z轴所构成的坐标系称为窗口坐标系。
屏幕映射完成后,几何阶段就完成了,接下来进入光栅化阶段。
所谓光栅化,其实就是像素化,其目的就是计算投影之后的三角形的各个像素的颜色

4 光栅化阶段

4.1 三角形设置

在屏幕映射后,我们得到了每一个顶点的屏幕坐标(即每个顶点对应的像素坐标值)。但是我们其实需要知道每一个三角形覆盖了哪些像素。那么三角形设置这一步的作用就是为找出一个三角形覆盖了哪些像素做准备。
其工作就是计算三角形的三条边的表达式。
透视校正插值过程
这一步是由硬件自己完成的,完全不可编程。

4.2 三角形遍历

三角形设置完成后,就进行三角形遍历。其作用就是找出一个三角形覆盖了哪些像素,同时对三角形覆盖的像素的各个属性进行线性插值
插值算法一般是使用三角形的重心坐标法插值。
同时需要注意的是,对各个属性进行插值需要进行透视校正,即对于一个属性I,其不是直接对I进行插值,而是对I/z进行插值后再除以1/z。关于透视校正可查看这两篇文章《【基础】透视校正插值(Perspective-Correct Interpolation)》《图形学基础之透视校正插值》。
这一步也是由硬件自己完成的,完全不可编程。

4.3 片元着色器

然后进入片元着色器,片元着色器可以控制三角形覆盖的每一个像素的颜色值。纹理的采样也是在这一步进行。
片元着色器就是由我们自己编程控制的,完全不受限制。

4.4 逐片元操作

逐片元操作其实就是对每一个像素点进行两项测试以及颜色混合。
两项测试为模板测试和深度测试,如果测试开启,但测试不通过,则该三角形对应的像素点颜色就不会被输出到屏幕上。
模板测试、深度测试、颜色混合是部分可编程的。
模板测试流程:
①GPU有一个模板缓冲区(Stencil Buffer),此缓冲区存储的是每一个像素的模板值。这个模板值是8位的,值范围为0 ~ 255。如假设屏幕大小为1920×1080,那么模板缓冲区的大小就为1920×1080字节,每一个字节对应一个像素的模板值。
②我们可以在Shader中开启模板测试,或者不开启模板测试。
③如果Shader中开启了模板测试,那么就需要比较模板缓冲区的值与我们Shader中的参考值,如果满足某个规则(这个规则也是我们在Shader中指定的,如等于),我们就认为模板测试通过。
④无论模板测试是否通过,我们都可以在Shader中选择是否写入新的模板值到模板缓冲区内。
深度测试流程:
①GPU也有一个深度缓冲区(Z Buffer),里面存储的是相机通过该像素点能看到的最近的物体的表面的深度值,所谓的深度值即NDC坐标的z值(透视校正插值之后的),深度值范围一般为0~1,位数为24位。深度值越小,表示距离相机越近。
②我们可以在Shader中选择是否开启深度测试。
③如果Shader中开启了深度测试,那么就需要比较深度缓冲区的值与该像素的深度值,如果满足某个规则(这个规则也是我们在Shader中指定的,如小于),我们就认为深度测试通过。
④只有深度测试通过了,才能选择是否进行深度写入(即将该像素的深度值写入到深度缓冲区中)。没有通过深度测试是没有权利进行深度写入的。
举个例子,假设物体A的顶点A1与物体B的顶点B1对应同一个像素,A1的深度值为0.2,B1的深度值为0.5。先渲染你物体A,再渲染物体B。物体A物体B的Shader中都开启了深度测试和深度写入,深度比较规则为小于。那么先渲染A时,由于此时深度缓冲区还没有值,所以A1顶点对应的像素深度测试通过,然后将0.2写入深度缓冲区。物体A渲染完成后再渲染物体B,此时发现B1顶点对应像素的深度缓冲区值为0.2,而0.5>0.2,测试不通过,B1顶点对应的像素颜色将不能输出到屏幕上。
注意:现代GPU可以将逐片元操作提前到片元着色器之前进行,即先进行模板与深度测试之后再进行片元着色器输出颜色,这样可以避免我们花了很多时间去计算像素的颜色,到后来却发现压根无法通过测试,这样我们片元着色器的工作就白做了。这种技术被称为Early-Z技术。

通过测试之后,就将该像素的颜色混合(可以是混合,也可是直接覆盖,这取决于我们Shader是怎么写的)到后置颜色缓冲区中,当所有物体绘制完毕后,再将后置颜色缓冲区与前置颜色缓冲区进行交换,从而将最新渲染的画面层现到屏幕上。

5 参考文章