Shader学习03【GPU流水线】
承接:当GPU从CPU那里得到渲染命令后,就会进行一系列流水线操作,最终把图元渲染到屏幕上
简述:对于前面单个渲染流程的三个阶段的后两个阶段,即几何阶段和光栅化阶段,开发者无法拥有绝对的控制权,其实现的Carrier是GPU,GPU通过实现渲染流水线化,提高了渲染效率,虽然无法完全控制这两个阶段的实现细节,但是GPU向开发者开放了很多控制权
1:从图中可以看出,GPU的渲染流水线接收顶点数据作为输入,这些顶点数据由应用阶段加载到显存中,再由DrawCall指定的,这些数据随后被传递给顶点着色器。DrawCall指定的意思是指,DrawCall只会单纯的指向一个渲染图元列表,这个图元列表即顶点数据
这里有必要详细说明一下顶点,图元,片元,像素之间的关系,对于理解GPU渲染流程有重大帮助:
首先三者的先后顺序:顶点>图元>片元>像素
@1:图元是由顶点组成的,一个顶点,一条线段,一个三角形或者多边形都可以成为图元
@2:片元是在图元经过光栅化阶段后,被分割成一个个像素大小的基本单位,片元其实已经很接近像素了,但是它还不是像素,片元包含了比RGBA更多的信息,比如可能有深度值,法线,纹理坐标等等信息,片元需要在通过一些测试(如深度测试)后才会最终成为像素,可能会有多个片元竞争同一像素,而这些测试会最终筛选出一个合适的片元,丢弃法线和纹理坐标等不需要的信息后,成为像素
@3:像素就很好理解了,最终呈现在屏幕上包含RGBA值的图像最小单元就是像素了
2:顶点着色器
顶点着色器(Vertex shader)是流水线的第一个阶段,它的输入来自于GPU,顶点着色器的处理单位是顶点,也就是说,输入进来的每个顶点都会调用一次顶点着色器,顶点着色器本身不可以创建或者销毁任何顶点,而且无法得到顶点与顶点之间的关系,例如,我们无法得知两个顶点是否属于同一个三角网格,但正是因为这样的相互独立性,GPU可以利用本身的特性并行化处理每一个顶点,这意味着这一阶段的处理速度会很快
顶点着色器需要完成的工作主要有:坐标变换和逐顶点光照,当然,除了这两个主要任务外,顶点着色器还可以输出后续阶段所需的数据,下图展示了定点着色器中队顶点位置进行坐标变换并计算顶点颜色的过程
@1:坐标变换:就是对顶点坐标(即位置Position)进行某种变换,顶点着色器可以在这一步中改变顶点的位置,这在顶点动画中是非常有用的,例如我们可以通过改变顶点位置来模拟水面,布料等,但需要注意的是,无论我们在顶点着色器中怎样改变顶点位置,一个基本的顶点着色器必须完成的一个工作是,把顶点坐标从模型空间转换到齐次裁剪空间,所以我们经常会在顶点着色器中看到类似下面的代码:
o.pos=mul(UNITY_MVP,v.postion);
上面这句代码的功能,就是把顶点坐标转换到齐次裁剪坐标系下,接着通常再由硬件做透视除法后,最终得到归一化的设备坐标(Normalized Device Coordinates,NDC),具体实现细节可以参考《Shader入门精要》 第四章,下图展示了坐标系的转换过程:
需要注意的是,上图给出的坐标范围是OpenGL同时也是Unity使用的NDC,它的Z分量范围在【-1,1】之间,而在DirectX中,NDC的Z分量范围是【0,1】,顶点着色器可以有不同的输出方式,最常见的输出路径是经光栅化后交给片元朝色器进行处理。而在现在的shader model中,它还可以把数据发送给曲面细分着色器或几何着色器
3:裁剪
我们的摄像机视野范围(FOV)很有可能不会覆盖所有的场景物体,一个很自然的想法就是,那些不在摄像机视野范围的物体不需要被处理,而裁剪(Cipping)就是为了完成这个目的而被提出来的,
由于我们已知下NDC下的顶点位置,即顶点位置在一个立方体内,因此裁剪就变得简单,这也是引入NDC的原因之一,只需要将图元裁剪到单位立方体内
下图展示了裁剪过程
和顶点着色器不同,这一步是不可编程的,即我们无法通过编程来控制裁剪的过程,而是通过硬件上的固定操作,但我们可以自定义一个裁剪操作来对这一步进行配置
4:屏幕映射
这一步输入的坐标仍然是三维坐标系下的坐标(范围在单位立方体内)屏幕映射(Screen Mapping)的任务是把每个图元的x和y坐标转换到屏幕坐标系(Screen Coordinates)下。屏幕坐标系是一个二维坐标系,它和我们用于显示画面的分辨率有很大关系,假设,我们需要把场景渲染到一个窗口上,窗口的范围是从最小的窗口坐标(x1,y1)到最大的窗口坐标(x2,y2)由于我们输入的坐标范围在-1,1(NDC下的坐标),因此可以想象到,这个过程实际是一个缩放的过程,如图2.10所示,你可能会问,那么输入的z坐标会怎么样呢,屏幕映射不会对输入的z坐标做任何处理,实际上,屏幕坐标系和z坐标系一起构成了一个坐标系,叫做窗口坐标系(Windows Coordinates),这些值会一起被传递到光栅化阶段
屏幕映射得到的屏幕坐标决定了这个顶点对应屏幕上哪个像素以及距离这个像素有多远
有一个需要注意的点是,屏幕坐标系在OpenGL和DirectX之间的差异问题,OpenGL把屏幕的左下角当成最小的窗口坐标值,而DirectX则定义了屏幕的左上角为最小的窗口坐标值
如果你发现你得到的图像是倒转的,那么很有可能就是这个原因造成的
5:三角形设置
这一步开始就进入了光栅化阶段,从上一个阶段输出的信息是屏幕坐标系下的顶点位置以及和他们相关的信息,如深度值(Z坐标),法线方向,光栅化阶段有两个最重要的目标,计算每个图元覆盖了哪些像素,以及为这些像素计算他们的颜色
光栅化的第一个流水线阶段是三角形设置(Triangle Setup),这个阶段会计算光栅化一个三角网格所需的信息,具体来说,上一个阶段输出的都是三角网格的顶点,即我们得到的是三角网格每条边的两个端点,但如果要得到整个三角网格对像素的覆盖情况,就必须计算每条边上的像素坐标,为了能够计算边界像素的坐标信息,我们就需要得到三角形边界的表示方式,这样一个计算三角网格表示数据的过程就叫做三角形设置,它的输出是为了给下一个阶段做准备
6:三角形遍历
三角形便利(Triangle Traversal) 阶段将会检查每个像素是否被一个三角网格所覆盖,如果被覆盖的话,就会生成一个片元(fragment)。而这样一个找到哪些像素被三角网格所覆盖的过程就是三角形遍历,这个阶段也被称为扫描变换(Scan Conversion)。
三角形遍历阶段会根据上一个阶段的计算结果来判断一个三角网格覆盖了哪些像素,并使用三角网格的3个顶点信息对整个覆盖区域的像素进行插值,下图展示了三角形遍历阶段的简化计算过程
这一步的输出就是得到一个片元序列,需要注意的是,一个片元并不是真正意义上的像素,而是包含了很多状态的集合,这些状态用于计算每个像素的最终颜色,这些状态包括了(但不限于)它的屏幕坐标,深度信息,以及其他从几何阶段输出的顶点信息,例如法线,纹理坐标。
7:片元着色器
片元着色器(Fragment Shader)是另一个非常重要的可编程着色器阶段,在DirectX中,片元着色器被称为像素着色器(Pixel Shader)
前面的光栅化阶段实际上并不会影响屏幕上每个像素的的颜色值,而是会产生一系列的数据信息(记住,三角形设置和三角形遍历的输出一个片元序列),用来表述一个三角网格是怎么样覆盖每个像素的。而每个片元就负责存储这样一系列数据,真正会对像素产生影响的阶段是下一个流水线阶段——逐片元操作(Per-Fragment Operations)
片元着色器的输入是上一个阶段对顶点信息插值得到的结果,它的输出是一个或者多个颜色值,下图展示了这个过程
这一阶段可以完成很多重要的技术,其中对重要的技术之一就是纹理采样,为了在片元着色器中进行纹理采样,我们通常会在定点着色器阶段输出每个顶点对应的纹理坐标,然后经过着色中进行纹理采样,所谓的纹理采样,在这里给出解释:
既然纹理是一张图片,那么自然就有分辨率的存在,纹理采样便是从纹理图片中采集一个像素颜色的操作。例如在下图中,我们采样(0,1)这个位置的像素,获得了浅蓝色。在纹理采样中,我们使用的坐标值应是整数。
我们通常会在顶点着色器阶段输出每个顶点对应的纹理坐标,然后经过光栅化阶段对三角网格的三个顶点对应的纹理坐标进行插值后,就可以得到其覆盖的片元的纹理坐标了。
片元着色器的作用是处理由光栅化阶段生成的每个片元,最终计算出每个像素的最终颜色。归根结底,实际上就是数据的集合。这个数据集合包含每一个像素的各个颜色分量和像素透明度的值,即片元着色器会输出片元序列中每个像素的颜色信息
虽然片元着色器可以完成很多重要效果,但它的局限在于,它仅可以影响单个片元,也就是说,当执行片元着色器时,它不可以将自己的任何结果直接发送给它的邻居们,有一个特殊情况,就是片元着色器可以访问到导数信息(gradient,或者说是derivative)
8:逐片元操作
到了渲染流水线的最后一步,逐片元操作(Per-Fragment Operations )是OpenGL中的说法,在DirectX中,这一阶段被称为输出合并阶段(OutPut-Merger)。
这一阶段的主要任务:
@1:决定每个片元的可见性,这涉及了很多测试工作,例如深度测试,模板测试等
@2:如果一个片元通过了所有测试,就需要把这个片元的颜色值和已经存储在颜色缓冲区中的颜色进行合并,或者说是混合。这个涉及到一个缓冲区的概念,在此给出解释
在日常的CPU开发中,缓冲区也就是堆内存区,其主要作用是存储数据后提供对外访问,在图形渲染流程中,就存在三个缓冲区
(1)模板缓冲区(2)颜色缓冲区(3)深度缓冲区,在此先给出颜色缓冲区的解释
颜色缓冲区就是最终显示屏硬件上显示颜色的GPU显存区域了,这个缓冲区储存了每帧更新后的最终颜色值,图形流水线经过一系列测试,包括片段丢弃,颜色混合等,最终生成的像素颜色值就储存在这里,然后提交给硬件显示
需要说明的是,逐片元操作阶段是高度可配置性的,即我们可以设置每一步的操作细节
这个阶段需要解决每个片元的可见性问题,这需要进行一系列测试,只有指定片元通过了测试,才能最终获得和GPU谈判的资格,这个资格指的是它可以和颜色缓冲区进行合并,如果它没有通过其中的某一个测试,之前为了产生这个片元的所有工作都是白费的,因为这个片元会被舍弃掉,Poor fragment! 下图展示了简化后的逐片元操作所做的操作
这个测试过程实际上是个比较复杂的过程,而且不同的图形接口(OpenGL和DirectX)的实现细节也不尽相同,这里给出两个最基本的测试——深度测试和模板测试,能否理解这些测试关乎你是否可以理解本书后面章节提到的渲染队列,尤其是处理透明效果时出现的问题,下图是深度测试和模板测试的简化流程图:
(1)模板测试: 先看模板测试(Stencil Test)。与之相关的是模板缓冲(Stencil Buffer)。实际上,模板缓冲和我们经常听到的颜色缓冲,深度缓冲几乎是一类东西,如果开启了模板测试,GPU会首先读取(使用读取掩码)模板缓冲区中该片元位置的模板值,然后将该值和读取(使用读取掩码)到的参考值进行比较,这个比较函数可以是由开发者指定的,例如小于时舍弃该片元,或者大于等于时舍弃该片元,如果这个偏远没有通过这个测试,该片元就会被舍弃,不管一个片元有没有通过模板测试,我们都可以根据模板测试和下面的深度测试结果来修改模板缓冲区,这个修改操作也是由开发者指定的,开发者可以设置不同结果下的修改操作,例如,在失败时模板缓冲区保持不变,通过时将模板缓冲区中对应位置的值加1等,模板测试通常用于限制渲染的区域,另外,模板测试还有一些更高级的用法,如渲染阴影,轮廓渲染等
(2)深度测试:如果一个片元幸运地通过了模板测试,那么它会进行下一个测试——深度测试(Depth Test),这个测试同样可以高度配置,如果开启了深度测试,GPU会把该片元的深度值和已经存在于深度缓冲区中的深度值进行比较,这个比较函数也是可由开发者设置的,例如小于时舍弃该片元,通常这个比较函数是小于等于的关系,即如果这个片元的深度值大于等于当前深度缓冲区中的值,那么就会舍弃它,这是因为,我们总想只显示处理摄像机最近的物体,而那些被其他物体遮挡的就不需要出现在屏幕上,如果这个片元没有通过这个测试,该片元就会被舍弃,和模板测试有些不同的是,如果一个片元没有通过深度测试,他就没有权利更改深度缓冲区中的值, 而如果它通过了测试,开发者还可以指定是否要用这个片元的深度值覆盖掉原有的深度值,这是通过开启、关闭深度写入来做到的,我们在后面对shader详细了解后可以发现,透明效果和深度测试以及深度写入的关系非常密切
(3)合并:如果一个幸运的片元通过了上面的所有测试,那么它就可以进行下一步操作——合并
为什么需要合并这个步骤?我们知道,这里所讨论的渲染过程是一个物体接着一个物体画到屏幕上的,而每个像素的颜色信息被存储在一个名为颜色缓冲的地方,因此,当我们执行这次渲染时,颜色缓冲中往往已经有了上次渲染之后的颜色结果,那么。我们是使用这次渲染得到的颜色完全覆盖掉之前的结果,还是进行其他处理?这是合并需要解决的问题。
对于不透明物体,开发者可以关闭混合(Blend)操作,这样这样片元着色器计算得到的颜色值就会直接覆盖掉颜色缓冲区中的像素值,但对于半透明物体,我们就需要使用混合操作来让这个物体看起来是透明的,下图展示一个简化版的缓和操作流程
从上述流程图可以发现,混合操作也是可以高度配置的,开发者可以选择开启/关闭混合功能,如果没有混合功能,就会直接使用片元的颜色覆盖掉颜色缓冲区中的颜色,而这也是很多初学者发现无法得到透明效果的原因(没有开启混合功能)。如果开启了混合,GPU回去出源颜色和目标颜色,将两种颜色进行混合,源颜色指的是片元着色器得到的颜色值,而目标颜色则是已经存在于颜色缓冲区中的颜色值,之后,就会使用一个混合函数来进行混合操作,这个混合函数通常和透明通道息息相关,例如,根据透明通道的值进行相加,相减,相乘等。混合很像PhotoShop中对图层的操作,每一层图层可以选择混合模式,混合模式决定了该图层和下层图层的混合结果,而我们看到的图片就是混合后的图片。
注意:上面给出的测试顺序并不是唯一的,而且虽然从逻辑上来说这些测试是在片元着色器之后进行的,但对于大多数GPU来说,他们会尽可能在执行片元着色器之前就进行这些测试,这和之前说的片元浪费有关系,作为一个想充分提高性能的GU,它会尽可能早知道哪些片元是会被舍弃的,对于这些片元就不需要在使用片元着色器来计算他们的颜色,在Unity给出的渲染流水线中,我们也可以发现他给出的深度测试实在片元着色器之前,这种将深度测试提前的技术通常被称为:Early-Z技术,后续会被提到这个技术
当模型的图元经过了上面的层层计算和测试后,就会显示到我们的屏幕上,我们的屏幕显示的就是颜色缓冲区中的颜色值,但是,为了避免我们看到那些正在进行光栅化的图元,GPU会使用双重缓冲(Double Buffering)的策略,这意味着,对场景的渲染是在幕后发生的,即在后置缓冲(Back Buffer)中,一旦场景已经被渲染到了后置缓冲中,GPU就会交换后置缓冲区和前置缓冲区中的内容,而前置缓冲区是之前显示在屏幕上的图像,由此,保证我们看到的图像是连续的
总结:虽然渲染流水线比较复杂,但Unity作为一个非常出色的平台为我们封装了很多功能,更多时候,我们只需要在一个Unity Shader设置一些输入,编写顶点着色器和片元着色器,设置一些状态就可以达到大部分常见的效果,这样的缺点在于,封装性会导致变成自由度下降。