第一章 渲染流水线
1.1渲染流水线
1.1.1现实中流水线
在工业上,流水线被广泛应用在装配线上。
假设,老王有一个生产洋娃娃的工厂,一个洋娃娃的生产流程可以分为4个步骤:
在流水线出现之前,只有在每个洋娃娃完成了所有这4个工序后才能开始制作下一个洋娃娃。
但后来人们发现了一个更加有效的方法,即使用流水线。
虽然制作一个洋娃娃仍然需要4个步骤,但不需要从头到尾完成全部步骤,
使用流水线的好处在于可以提高单位时间的生产量。
理想情况下,如果把一个非流水线系统分成n个流水线阶段,且每个阶段耗费时间相同的话,会使整个系统得到n倍的速度提升。
总而言之:流水线可以优化工作效率
1.1.2渲染概念流水线的过程
渲染流水线主要分成三个阶段:
应用阶段 —— 几何阶段 —— 光栅化阶段
应用阶段:
1.准备场景数据
摄像机位置,视椎体,场景中包含了那些模型
2.粗颗粒剔除工作
将不可见的模型剔除
3.设置模型渲染状态
材质,纹理,shader
几何阶段
几何阶段用于处理所有和我们要绘制的几何相关的事情。
例如,决定需要绘制的图元是什么,怎样绘制它们,在哪里绘制它们。这一阶段通常在GPU上进行。
光栅化阶段
这一阶段将会使用上个阶段传递的数据来产生屏幕上的像素,并渲染出最终的图像。
这一阶段也是在GPU上运行。光栅化的任务主要是决定每个渲染图元中的哪些像素应该被绘制在屏幕上。
它需要对上一个阶段得到的逐顶点数据(例如纹理坐标、顶点颜色等)进行插值,然后再进行逐像素处理。
这里的流水线均是概念流水线,是我们为了给一个渲染流程进行基本的功能划分而提出来的。
下面要介绍的GPU流水线,则是硬件真正用于实现上述概念的流水线。
1.2 CPU与GPU之间的通讯
渲染流水线的起点是CPU,即应用阶段
数据加载到显存中 —— 设置渲染状态 —— 调用Draw Call
1.2.1把数据加载到显存中
所有渲染所需的数据都需要从硬盘(Hard Disk Drive,HDD)中加载到系统内存(RandomAccess Memory,RAM)中。
然后,网格和纹理等数据又被加载到显卡上的存储空间——显存(Video Random Access Memory,VRAM)中。
这是因为,显卡对于显存的访问速度更快,而且大多数显卡对于RAM没有直接的访问权利。
PS:真实渲染中需要加载到显存中的数据要复杂许多。例如,顶点的位置信息、法线方向、顶点颜色、纹理坐标等。
1.2.2设置渲染状态
什么是渲染状态呢?一个通俗的解释就是,这些状态定义了场景中的网格是怎样被渲染的。
例如,使用哪个顶点着色器(Vertex Shader)/片元着色器(Fragment Shader)、光源属性、材质等。
如果我们没有更改渲染状态,那么所有的网格都将使用同一种渲染状态。
1.2.3调用Draw Call
Draw Call本身的含义很简单,就是CPU调用图像编程接口,
如OpenGL中的glDrawElements命令或者DirectX中的DrawIndexedPrimitive命令,以命令GPU进行渲染的操作。
在Draw Call上面有三个内容
(1)CPU和GPU是如何实现并行工作
如果没有流水线化,那么CPU需要等到GPU完成上一个渲染任务才能再次发送渲染命令。
但这种方法显然会造成效率低下。我们需要让CPU和GPU可以并行工作。而解决方法就是使用一个命令缓冲区(Command Buffer)。
命令缓冲区包含了一个命令队列,
由CPU向其中队头添加命令,而由GPU从中队尾读取命令,
添加和读取的过程是互相独立的。命令缓冲区使得CPU和GPU可以相互独立工作。
当CPU需要渲染一些对象时,它可以向命令缓冲区中添加命令,
而当GPU完成了上一次的渲染任务后,它就可以从命令队列中再取出一个命令并执行它。
命令缓冲区中的命令有很多种类
而Draw Call是其中一种,其他命令还有改变渲染状态等(例如改变使用的着色器,使用不同的纹理等)。
(2)为什么Draw Call多了会影响帧率
我们先来做一个实验:请创建10000个小文件,每个文件的大小为1KB,
然后把它们从一个文件夹复制到另一个文件夹。你会发现,尽管这些文件的空间总和不超过10MB,但要花费很长时间。
现在,我们再来创建一个单独的文件,它的大小是10MB,然后也把它从一个文件夹复制到另一个文件夹。而这次复制的时间却少很多!
这是为什么呢?明明它们所包含的内容大小是一样的。原因在于,每一个复制动作需要很多额外的操作,例如分配内存、创建各种元数据等。如你所见,这些操作将造成很多额外的性能开销,如果我们复制了很多小文件,那么这个开销将会很大。
渲染的过程虽然和上面的实验有很大不同,但从感性角度上是很类似的。
在每次调用DrawCall之前,CPU需要向GPU发送很多内容,包括数据、状态和命令等。
在这一阶段,CPU需要完成很多工作,例如检查渲染状态等。而一旦CPU完成了这些准备工作,GPU就可以开始本次的渲染。
GPU的渲染能力是很强的,渲染200个还是2000个三角网格通常没有什么区别,因此渲染速度往往快于CPU提交命令的速度。
如果Draw Call的数量太多,CPU就会把大量时间花费在提交Draw Call上,造成CPU的过载。
(3)问题三:如何减少Draw Call
尽管减少Draw Call的方法有很多,但我们这里仅讨论使用批处理(Batching)的方法。
提交大量很小的Draw Call会造成CPU的性能瓶颈,一个很显然的优化想法就是把很多小的DrawCall合并成一个大的Draw Call,这就是批处理的思想。
需要注意的是,由于我们需要在CPU的内存中合并网格,而合并的过程是需要消耗时间的。
因此,批处理技术更加适合于那些静态的物体,例如不会移动的大地、石头等,对于这些静态物体我们只需要合并一次即可。
当然,我们也可以对动态物体进行批处理。但是,由于这些物体是不断运动的,因此每一帧都需要重新进行合并然后再发送给GPU,这对空间和时间都会造成一定的影响。
1.3.1顶点着色器
顶点着色器(Vertex Shader)是流水线的第一个阶段,它的输入来自于CPU。
顶点着色器的处理单位是顶点,也就是说,输入进来的每个顶点都会调用一次顶点着色器。
顶点着色器本身不可以创建或者销毁任何顶点,而且无法得到顶点与顶点之间的关系。
顶点着色器需要完成的工作主要有:坐标变换和逐顶点光照。
除了这两个主要任务外,顶点着色器还可以输出后续阶段所需的数据。
PS: 坐标变换。顾名思义,就是对顶点的坐标(即位置)进行某种变换。
顶点着色器可以在这一步中改变顶点的位置,这在顶点动画中是非常有用的。
例如,我们可以通过改变顶点位置来模拟水面、布料等。
但需要注意的是,无论我们在顶点着色器中怎样改变顶点的位置,一个最基本的顶点着色器必须完成的一个工作是,把顶点坐标从模型空间转换到齐次裁剪空间。
想想看,我们在顶点着色器中是不是会看到类似下面的代码:
o. pos= mul(UNITY_MVP,v. position);
类似上面这句代码的功能,就是把顶点坐标转换到齐次裁剪坐标系下。
1.3.2裁剪
由于我们的场景可能会很大,而摄像机的视野范围很有可能不会覆盖所有的场景物体。
所以我们需要将那些不在摄像机视野范围的物体不需要被处理。
一个图元和摄像机视野的关系有3种:完全在视野内、部分在视野内、完全在视野外。
完全视野内的图元就继续传递给下一个流水线阶段,
完全在视野外的图元不会继续向下传递,因为们不需要被渲染。
部分在视野内的图元需要进行一个处理,这就是裁剪。
例如,一条线的一个顶点在视野内,而另一个顶点不在视野内,
那么在视野外部的顶点应该使用一个新的顶来代替,这个新的顶点位于这条线段和视野边界的交点处。
1.3.3屏幕映射
1.3.4三角形设置
1.3.5片元源着色器