一、什么是渲染流水线
渲染流水线的工作在与有一个三维场景出发,生成或者说渲染一张二维图像。
即计算机从一些列的顶点数据和纹理等信息出发,将这些信息转换成一张人眼可以看到的图像。
《Real-Time Rendering》一书将渲染流程分为三个阶段:应用阶段 Application Stage,几何阶段 Geometry Stage,光栅化阶段 Rasterizer Stage。
应用阶段
这个阶段是由我们的应用主导的, 因此通常由CPU负责实现。在这一阶段中,开发者有3个主要任务。
①准备好场景数据, 例如摄像机的位置、视锥体、模型、些光源等等。
②做一个粗粒度剔除工作,把那些不可见的物体剔除出去,可以提高渲染性能。
③设置每个模型的渲染状态。 渲染状态包括但不限于它使用的材质、纹理和Shader等。
这一阶段最重要的输出是渲染所需的几何信息, 即渲染图元(rendering primitives)。
通俗来讲, 渲染图元可以是点、线、三角面等。这些渲染图元将会被传递给下一个阶段——几何阶段。
几何阶段
几何阶段处理所有和我们要绘制的几何相关的事情。
例如决定需要绘制的图元是什么,怎样绘制它们,在哪里绘制他们。这一阶段通常在GPU上进行。
几何阶段和每个渲染图元打交道,进行逐顶点、逐多边形的操作。该阶段可进一步被分解为更小的流水线阶段。
几何阶段的另一个重要的任务是把顶点坐标变换道屏幕空间中,再交给光栅器进行处理。
这一阶段会将输出屏幕空间的二维顶点坐标、每个顶点对应的深度值、着色等相关信息传递给下个阶段。
光栅化阶段
这一阶段会使用上一阶段传递的数据来产生屏幕上的像素,然后渲染出最终的图像,这一阶段在GPU上运行。
光栅化阶段的任务主要是决定每个渲染图元中的那些像素应该被绘制在屏幕上。
他需要对上阶段的到的逐顶点数据进行插值,然后再进行逐像素处理。
与几何阶段类似,光栅化阶段又可以分为更小的流水线阶段。
二、CPU和GPU之间的通信
渲染流水线的起点是应用阶段,其可分为以下三个阶段:
①把数据加载到显存中
②设置渲染状态
③调用Draw Call
把数据加载到显存中
渲染所需的数据都要从硬盘加载到系统内存,然后网格和纹理等数据又被加载到显存。
因为显卡对RAM没有直接的访问权利,而且显存更快。
设置渲染状态
这些状态定义了场景中网格是怎么被渲染的,比如使用那个shader,light,material等。
如果没有更改渲染状态,那么所有网格都使用同一种。
准备好以上的工作后,CPU调用一个渲染命令告诉GUP,这个渲染命令就是Draw Call
调用 Draw Call
Draw Call就是一个命令。他的发起方是CPU,接收方是GPU。
这个命令仅仅会指向一个需要被渲染的图元列表,而且不会再包含任何材质信息,上个阶段已经完成了。
给定一个 Draw Call 后GPU根据渲染状态,如材质纹理着色器等和所有输入的顶点数据来进行计算,最终输出成屏幕上显示的像素。
这个计算过程就是GPU流水线。
GPU流水线
GPU从CPU得到渲染命令后就进行一系列流水线操作,将图元渲染到屏幕上。
概述
GPU对开发者开放了很多控制权。
从图中可以看出GPU渲染流水线接受顶点数据作为输入。
这些数据是应用阶段加载到显存中,再由Draw Call指定的。这些数据随后被传给shader。
顶点着色器 Vertex Shader
顶点着色器是完全可编程的,通常用于实现顶点的空间变换、顶点着色等功能。
曲面细分着色器 Tessellation Shader
曲面细分着色器是一个可选的着色器,用于细分图元。
几何着色器 Geometry Shader
几何着色器是一个可选的着色器,用于执行逐图元着色操作,或产生更多图元。
裁剪 Clipping
是将那些不在摄像机视野内的顶点剪裁掉,并剔除某些三角图元的面片。
这个阶段可以配置,我们可以使用自定义的裁剪平面来配置裁剪区域,也可通过指令控制裁剪三角图元的正面或背面。
屏幕映射 Screen Mapping
屏幕映射是不可配置和编程的,负责把每个图元的坐标转换到屏幕坐标系。
顶点着色器
顶点着色器的处理单位
顶点着色器是流水线的第一个阶段,其输入来自CPU。
顶点着色器的处理单位是顶点,每个进入的顶点都会调用一次顶点着色器。
其本身不能创建或者销毁任何顶点,因此无法得到顶点之间的关系。比如无法得知两顶点是否属于同一个三角网格。
这种独立性的好处是GPU可以利用本身特性并行优化每一个顶点,这一阶段处理速度会非常快。
顶点着色器计算顶点颜色
顶点着色器的工作还有坐标变换和逐顶点光照,以及输出后续阶段所需的数据。
如下图演示顶点着色器对顶点位置进行坐标变换并计算顶点颜色的过程。
顶点着色器对顶点坐标进行坐标变换
顾名思义就是对顶点的坐标进行某种变换,顶点着色器可以在这一步改变顶点的位置。无论怎样改变顶点的位置,都必须把顶点坐标从模型空间转换到其次裁剪空间。
o.pos = mul(UNITY_MVP, v.position);
类似上面这句代码的作用就是把顶点坐标转换到齐次裁剪坐标系下,接着通常再由硬件做透视除法后,最终得到归一化的设备坐标NDC。
需要注意的是上图给出的坐标范围是OpenGL也是Unity使用的NDC,其z分量在[-1,1]。而DirectX中,NDC的z分量范围是[0,1]。
顶点着色器的输出方式
顶点着色器可以有不同的输出方式,最常见的是经光栅化后交给片元着色器进行处理,现代Shader Model中还能把数据发送给曲面细分着色器。
裁剪
摄像机的视野范围不会覆盖所有的场景物体,而不在视野范围的物体应该被裁剪处理掉。
图元和摄像机的视野关系
完全在视野内、部分在视野内、完全在视野外。
完全在视野内的图元就传递给一下一阶段,完全在视野外的图元不会被传递,部分在视野内的就需要进行裁剪了。
如何裁剪
视野外部顶点应该使用一个新的顶点来代替,新顶点位于这条线段和视野边界的交点处。
因为一直在NDC下的顶点位置,顶点位置在一个立方体中,所以裁剪就非常简单了,将图元裁剪到单位立方体内即可。
这一步是不可编程的,但我们可以自定义一个裁剪操作对这一步进行配置。
屏幕映射
这一步输入的坐标仍然是三维坐标下的坐标,是齐次裁剪空间下的坐标。
屏幕映射的任务是把每个图元的x和y坐标转换到屏幕坐标系下。
屏幕坐标系是一个二维坐标系,她和我们用于显示画面的分辨率有很大的关系。
实际上,屏幕映射不会对输入的z坐标做任何处理,屏幕坐标和z坐标一起构成了一个坐标系,叫做窗口坐标系。这些值会一起被传到光栅化阶段。
屏幕映射得到的屏幕坐标决定了这个顶点对应屏幕上哪个像素以及距离这个像素有多远。
注意,OpenGL和DirectX,在屏幕坐标系上有差异,前者将屏幕左下角当作最小的窗口坐标值,后者则是定义为左上角。
三角形设置
这一步开始进入了光栅化阶段。上阶段输出的信息是屏幕坐标系下的顶点位置以及和他相关的额外信息,如深度值z坐标、法线方向、视角方向等。
光栅化阶段第一个流水线阶段是三角形设置,该阶段会计算光栅化一个三角形网格所需的信息。
具体来说,上一个阶段输出的都是三角形网格的顶点,也就是三角形网格每条边的两个端点。
如果要得到整个三角网格对像素的覆盖情况,我们就必须计算每条边上的像素坐标。
为了能够计算边界像素的坐标信息,就需要得到三角形边界的表示方式。这样的计算三角形网格表示数据的过程就叫做三角形设置。
三角形遍历
该阶段会将检查每个像素是否被一个三角网格所覆盖。如果覆盖的话就会生成一个片元。
而这样一个找到哪些像素被三角网格覆盖的过程就是三角形遍历,也称为扫描变换。
三角形遍历阶段会根据上一个阶段的计算结果判断一个三角形网格覆盖了哪些像素,并使用三角网格3顶点的顶点信息对整个覆盖区域进行插值。
这一步的输出就是得到一个片元序列。
一个片元并不是真正意义上的像素,而是包含了很多状态的集合,这些状态用于计算每个像素的最终颜色。
这些状态包括了他的屏幕坐标、深度信息、以及其他几何阶段输出的顶点信息,如法线、纹理坐标等。
片元着色器
片元着色器是另一个非常重要的可编程着色器阶段。
DirectX中被称为像素着色器,但是片元更合适,因为此时片元不是一个真正的像素。
前面的光栅化阶段实际不会影响屏幕上每个像素的颜色值,而是产生一系列的数据信息,来表述一个三角网格怎样覆盖每个像素。
每个片元就负责存储这些数据。真正对像素产生影响的阶段是逐片元操作。
片元着色器的输入是上阶段对顶点信息插值的结果,具体来说就是从顶点着色器中输出的数据插值得到的。而他输出的是一个或者多个颜色值。
纹理采样
这阶段可以完成很多重要的渲染技术,比如纹理采样。
为了在片元着色器中进行纹理采样,先在顶点着色器阶段输出每个顶点对应的纹理坐标,
然后经过光栅化阶段对三角形网格的三个顶点对应的纹理坐标进行插值后,就可以得到其覆盖的片元的纹理坐标了。
其局限在于仅可以影响单个片元。即执行片元着色器时,不能将结果直接发给旁边的邻居,除了导数信息。
逐片元操作
这是OpenGL中的说法,在DirectX中,这阶段被称为输出合并阶段,Output-Merger。
该阶段是对每一片片元进行操作,主要任务有:
①决定每个片元的可见性,如深度测试、模板测试。
②如果一个片元通过了所有测试,就把这个片元的颜色值和已经存储在颜色缓冲区的颜色进行合并,混合。
该阶段是高度可配置的,我们可以设置每一步的操作细节。该阶段首先解决的是,每个片元的可见性问题。
这需要一系列测试,通过了才能和颜色缓冲区进行合并。没通过任何一个测试,片元都会被丢弃。
测试过程是很复杂的,不同接口实现细节也不同。
模板测试
与之相关的是模板缓冲Stencil Buffer。实际上模板缓冲和颜色换成深度缓冲几乎是一类东西。
开启了模板测试,GPU就会使用读取掩码读取模板缓冲区中该片元的模板值,将该值和读取到的参考值进行比较。
这个比较函数可以是开发者指定的,例如小于时舍弃该片元或者大于时舍弃该片元。
不管一个片元有没有通过模板测试都可以根据模板测试和下面的深度测试结果来修改模板缓冲区。
这个修改操作也是由开发者指定的。模板测试通常用于限制渲染的区域,高级用法有渲染阴影、轮廓渲染等。
深度测试
通过模板测试后,片元就会进行深度测试。其同样是高度可配置的。
开启后,GPU会把该片元深度值和已存在与深度缓冲区的深度值进行比较,这个比较函数也是开发者设置的。
例如我们总想只显示里摄像机最近的物体,而其他被遮挡的的物体就不需要显示在屏幕上。
如果一个片元没有通过这个测试他就没有权利更改深度缓冲区中的值。
通过之后开发者还能指定是否用该片元的深度值覆盖原来的深度值。这是通过开启/关闭深度写入做到的。
合并操作
通过了所有测试后,片元就来到了合并。
每个像素的信息被存储在一个名为颜色缓冲的地方,因此执行此次渲染时,颜色缓冲中往往已经有了上次渲染之后的结果。
合并就是要决定到底是使用这次渲染得到的颜色完全覆盖之前的还是进行其他处理。
对于不透明物体,开发者可以关闭混合操作。这样片元着色器计算得到的颜色值就会直接覆盖颜色缓冲区中的像素值。
对于半透明物体,我们需要使用混合操作来让这个物体看起来是透明的。
混合操作也是可以高度配置的。开启了混合,GPU会取出源颜色和目标颜色将两者混合。
源颜色是片元着色器得到的颜色,目标颜色是已经存在于颜色缓冲区中的颜色值。
之后就会使用一个混合函数进行混合操作。该函数和透明通道息息相关,例如根据透明通道的值进行相加减乘等。
提前测试
虽然逻辑上这些测试是在片元着色器之后进行的,但对于大多数GPU来说,他们会尽可能在执行片元着色器之前进行这些测试。
尽可能早知道哪些片元会被舍弃可以提高性能,比如unity的渲染流水线中其深度测试就在片元着色器之前。
这种将深度测试提前的技术被称为Early-Z技术。
但将这些测试提前其检验结果可能会与片元着色器中一些操作产生冲突。
双重缓存策略
当模型的图元经过了上面的层层计算和测试后就会显示到屏幕上。屏幕显示的就是颜色缓冲区中的颜色值。
为了避免我们看到那些光栅化的图元,GPU会使用双重缓冲策略。
即对场景的渲染是在幕后发生的,在后置缓冲中,一旦已经被渲染到后置缓冲中,GPU就会交换后置缓冲取和前置缓冲区中的内容。