整体流程
应用阶段:粗粒度剔除、进行渲染设置、准备基本数据、输出到几何阶段
几何阶段:顶点着色器、曲面细分、几何着色器、顶点裁剪、屏幕映射
光栅化阶段:三角形(点/线)设置、三角形(点/线)遍历、片段着色器
逐片元操作:裁剪测试、透明度测试、深度测试、模板测试、混合
后处理
CPU阶段
①应用阶段:
准备基本场景数据->加速算法、粗颗粒度剔除->设置渲染状态、准备渲染参数->调用drawcall、输出渲染图元到显存
GPU阶段
②几何阶段:
顶点着色器->几何着色器->曲面细分着色器->投影->裁剪->屏幕映射
③光栅化阶段
三角形设置->三角形遍历->片段着色器
④逐片元操作
裁剪测试、透明度测试、模板测试、深度测试、像素着色、颜色混合、目标缓冲区
⑤后处理
-------------------------------CPU阶段---------------------------------
应用阶段
这一阶段开发者主要有3个任务:
①准备好场景数据,例如摄像机的位置、视锥体场景中包含了哪些模型、使用了哪些光源等等;
②为了提高渲染性能,需要进行粗粒度剔除(culling),把看不见的物体剔除处理,这样就不需要移交给几何阶段进行处理。
③设置好每个模型的渲染状态。这些渲染状态包括但不限于它们使用的材质(漫反射颜色、高光反射颜色)、使用的纹理、shader等。
这一阶段最重要的输出是渲染所需的几何信息,即渲染图元(rendering primitives)。简单说就是把渲染图元(点、线、三角面)传递给下一个阶段——几何阶段。
直观点就是说开发者自己在游戏引擎中进行的各种操作,摆模型、相机位置、赋值材质等等。
准备基本场景数据->加速算法、粗颗粒度剔除->设置渲染状态、准备渲染参数->调用drawcall、输出渲染图元到显存
-------------------------------------详细步骤参考《Unity Shader入门精要》------------------------------------
CPU和GPU之间的通信
渲染流水线的起点是CPU,即应用阶段。应用阶段大致可以分为下面3个阶段:
(1)把数据加载到显存中
(2)设置渲染状态
(3)调用Draw Call
把数据加载到显存中
渲染所需要的数据都要从硬盘(Hard Disk Drive ,HHD)中加载到系统内存(Random Access Memory,RAM)中。然后网格和纹理等数据又被加载到显卡的存储空间——显存(Video Rnadom Access Memory,VRAM)中。
硬盘(HHD)->系统内存(RAM)->显存(VRAM)
除上图中展示的纹理及网格外还有:顶点位置信息、发现方向、定点颜色、纹理坐标等等。
当把数据加载到显存后,RAM中的数据就可以移除了。但有些数据后续仍需访问它们(例如,CPU可以访问网格数据来进行碰撞检测),那么就不需要移除,因为从硬盘加载到RAM的过程是十分耗时的。
设置渲染状态
顾名思义,设置一些渲染相关的参数。例如,使用哪个顶点着色器(Vertex Shader)/片元着色器(Fragment Shader)、光源属性、材质等。
准备好上述所有工作后(数据准备好),CPU就可以调用一个渲染命令来告诉GPU开始渲染,这个渲染命令就是DrawCall。
调用DrawCall
DrawCall实际上就是一个命令,它的发起方是CPU,接收方是GPU。这个命令仅仅会指向一个需要被渲染的图元列表,而不会在包含任何材质信息(这是因为我们已经在上一阶段中完成了)
中间的具体渲染流程,则是GPU流水线里的东西。
-------------------------------GPU阶段---------------------------------
几何阶段和光栅化阶段,开发者无法拥有绝对的控制权,其实现载体是GPU。不过会开放部分权限给开发者,供其操作。
完全可编程控制:顶点着色器、曲面细分着色器(Tessellation Shader)、几何着色器(Geometry Shader)、片元着色器(Fragment Shader)
可配置不可编程:裁剪(Clipping)、逐片元操作(Per-Fragment Operations)
GPU固定实现,开发者无任何控制权:屏幕映射(Screen Mapping)、三角形设置(Triangle Setup)、三角形遍历(Triangle Traversal)
顶点着色器(Vertex Shader):它通常用于实现顶点空间变换、顶点着色等功能。
曲面细分着色器(Tessellation Shader):是一个可选着色器,用于细分图元,例如制作雪地会用到,平滑模型也会用到。
几何着色器(Geometry Shader):可选着色器,它可以被用于执行逐图元(Per-Primitive)的着色操作,或者被用于产生更多的图元。
裁剪(Clipping):这一阶段的目的是将那些不在摄像机视野内的顶点裁剪掉,并剔除某些三角图元的面片。例如,可以使用自定义裁剪平面来配置裁剪区域,也可以通过指令控制裁剪三角图元的正面还是背面,CutOff功能就用了此步骤。
屏幕映射(Screen Mapping):它负责把每个图元的坐标转换到屏幕坐标系中。
三角形设置(Triangle Setup)、三角形遍历(Triangle Traversal):将顶点拼成三角形,对三角形进行拼接成片。
逐片元操作(Per-Fragment Operations):负责更重要的操作,例如修改颜色、深度缓冲、颜色混合等。
几何阶段
几何阶段负责和每个渲染图元打交道,进行逐顶点、逐多边形的操作。几何阶段一个重要的任务就是把顶点坐标转换到屏幕空间中,再交给光栅器进行处理。
通过对输入的渲染图元进行多步处理后,这一阶段会将输出屏幕空间的二维顶点坐标、每个顶点对应的深度值、着色等相关信息,并传递给下一阶段。
换句话说,这一阶段就是决定需要绘制的图元是什么、怎样绘制它们,在哪里绘制它们。
顶点着色器(Vertex Shader)
输入来自于CPU。顶点着色器本身不可以创建或者销毁任何顶点,且无法得到顶点与顶点之间的关系。例如无法得知两个顶点是否属于同一个三角网格。正因这样的相互独立性,GPU可以利用本身的特性并行化处理每个顶点,这意味着这一阶段的处理速度会很快。
主要工作:坐标变换和逐顶点光照。
次要功能:还可以输出后续阶段所需要的数据。
坐标变换
对顶点的坐标(即位置)进行某种变换。顶点着色器可以在这一步改变顶点位置,顶点动画中非常有用。例如,改变顶点位置模拟水平面、布料等。
不论如何改变顶点位置,一个最基本的顶点着色器必须完成一个工作是:把顶点坐标从模型空间转换到齐次裁剪空间。例如代码中的
o.pos = mul(UNITY_MVP,v.position);
此代码就是将顶点坐标转换到齐次裁剪坐标系下,接着通常再由硬件做透视除法,最终得到归一化的设备坐标(Normalized Device Coordinates,NDC)。
裁剪(Clipping)
将不在摄像机视野范的物体剔除。
一个图元与相机视野的关系有3种:完全在视野内、部分在视野内、完全在视野外。
完全在是业内的图元就继续传给下一个流水线阶段,完全在视野外的则不会继续向下传递。而那些部分再是业内的图元则需要进行处理,这就是裁剪。
屏幕映射(Screen Mapping)
这一输入的坐标仍然是三维坐标系下的坐标(范围在单位立方体内)。
主要任务:把每个图元的x和y轴坐标转换到屏幕坐标系(Screen Coordinates)下。
屏幕坐标系是一个二维坐标系。
OpenGL与DirectX坐标系有所区别。
屏幕映射只会对x、y轴进行转换,z轴则不作任何处理。屏幕坐标系和z坐标一起构成了一个新的坐标系,叫做窗口坐标系(Window Coordinates)。
这些值会一起被传递到光栅化阶段。
光栅化阶段
上一阶段输出的信息是屏幕空间坐标系下的顶点位置以及和它们相关的额外信息,入深度值(z坐标)、法线方向、视角方向等。
主要目标:①计算每个图元覆盖了哪些像素 ②为这些像素计算它们的颜色
三角形设置(Triangle Setup)
这一阶段计算光栅化一个三角网格所需的信息。
上个阶段输出的饿都是三角形网格的顶点,即我们得到的是三角形网格每条边的两个端点。如果要得到整个三角形网格对像素的覆盖情况,就需要计算每条边上的像素坐标。为了能够计算边界像素的坐标信息,就需要的倒三角形边界的表示方式。这个过程就叫做三角形设置。
简单说就是将单独的顶点连接成三角面。
三角形遍历(Triangle Traversal)
检查每个像素是否被一个三角网格所覆盖。如果被覆盖的话,就会生成一个片元(fragment)。这样一个找到那些像素被三角网格覆盖的过程就是三角形遍历,这个阶段也被成为扫描变换(Scan Conversion)。
这一步输出就是得到一个片元序列。需要注意的是,一个片元并不是真正意义上的像素,而是包含了很多状态的集合,这些状态用于计算每个像素的最终颜色。这些状态包括了(但不限于)它的屏幕坐标、深度信息,以及其他集合阶段输出的顶点信息,例如法线、纹理坐标等。
片元着色器(Fragment Shader)
在DirectX中被称为像素着色器(Pixel Shader),但片元着色器是一个更合适的名字,因为此时片元并不是一个真正意义上的像素。
片元着色器的输入是上一个阶段对顶点信息插值得到的结果,具体来说,是根据那些从顶点着色器中输入的数据差值得到的。而它的输出是一个或者多个颜色值。
主要任务:纹理采样。
片元着色器进行纹理采样,通常会在顶点着色器阶段输出每个顶点对应的纹理坐标,然后经过光栅化阶段对三角形网格的3个顶点对应的纹理坐标进行插值,就可以得到覆盖片元的纹理坐标了。
虽然片元着色器可以完成很多重要效果,但它局限在于,它仅可以影响单个片元。也就是说,当执行片元着色器是,它不可以将自己的任何结果直接发给它的邻居们。有一个情况例外,就是片元着色器可以访问导数信息(gradient,或者说说是derivative)。
逐片元操作
渲染流水线最后一步,逐片元操作(PerFragment Operations)是OpenGL中的说法,在DirectX中叫做输出合并阶段(Output-Merger)。
主要任务:
①决定每个片元的可见性。这涉及了很多测试工作,例如深度测试、模板测试等。
②如果一个片元通过了所有测试,就需要把这个片元的颜色值和已经存储在颜色缓冲区中的颜色进行合并,或者说是混合。
透明度测试在模板测试前面。
一个片元只有通过了所有测试才能和颜色缓冲区进行合并。否则就会被舍弃掉,之前为了产生这个片元做的所有工作都是白费的。
模板测试(Stencil Test)
与模板测试相关的是模板缓冲(Stencil Buffer)。实际上模板缓冲和颜色缓冲、深度缓冲几乎是一类东西。
如果开启了模板测试,GPU会首先读取(使用读取掩码)模板缓冲区中该片元位置的模板值,然后将该值和读取(使用读取掩码)到的参考值(reference value)进行比较,这个比较函数可以是由开发者指定的,例如小于时舍弃该片元。如果这个片元没有通过这个测试,该片元就会被舍弃。不管一个片元有没有通过模板测试,我们都可以根据模板测试和下面的深度测试来修改模板缓冲区,这个操作也是由开发者指定的。开发者可以设置不同结果下的修改操作。例如,在失败时模板缓冲区保持不变,通过时将模板缓冲区对应位置的值加一等。
模板测试通常用于限制渲染区域。另外模板测试还有一些高级的用,入渲染阴影、轮廓渲染等。
深度测试(Depth Test)
GPU会把该片元的深度值和已经存在与深度缓冲区中的深度值进行比较。这个比较函数也是可由开发者设置的,例如小于时舍弃该片元,或者大于等于时舍弃该片元。通常这个函数是小于等于的关系,即如果这个片元深度值大于等于当前深度缓冲区中的值,那么就会舍弃他。因为我们总想只显示离摄像机近的物体,而那些被其他物体遮挡的就不需要出现在屏幕上。
和模板测试有些不同的是,如果一个片元没有通过深度测试,他就没有权利更改深度缓冲区中的值。如果它通过了测试,开发者还可以指定是否要用这个片元的深度值覆盖掉原有的深度值,这是通过开启/关闭深度写入来做到的。透明效果和深度测试以及深度写入关系十分密切。
混合(Blend)
解决每次渲染时颜色缓冲中的颜色与新渲染进来的颜色取舍问题。
对于不透明物体,可以关闭混合(Blend)操作。这样片元着色器计算得到的颜色值就会直接覆盖掉颜色缓冲区中的像素值。
对于半透明物体,就需要开启混合(Blend)操作。
开启混合后,就会使用一个混合函数来进行混合操作。这个混合函数和透明通道息息相关,例如根据透明通道的值进行想加、相减、相乘等。
上面给出的测试顺序并不是唯一的,而且虽然从逻辑上来说这些测试试在片元着色器之后进行的,但对于大多数GPU来说,他们会尽可能在执行片元着色器之前就进行这些测试。防止在片元着色器器将颜色计算完毕后,发现这些片元没有通过测试被舍弃,造成计算成本的浪费。
Unity给出的渲染流水中Early-Z技术,可以将深度测试提前执行。
提前测试出现的问题:如果将这些测试提前的话,其检验结果可能会与片元着色器中的一些操作冲突。例如,如果在片元着色器进行了透明度测试,而这个片元没有通过透明度测试,我们会在着色器中调用API(例如clip函数)来手动将其舍弃。这就导致GPU无法提前执行各种测试。这就导致GPU无法提前执行各种测试。
解决方法:现代在GPU会判断片元着色器中的操作是否和提前测试发生冲突,如果有冲突,就会禁用提前测试。但是,这样也会造成性能上的下降,因为有更多片元需要被处理了。这也是透明度测试会导致性能下降的原因。
目标缓冲区
当模型的图元经过了上面层层计算和测试后,就会显示到我们的屏幕上。我们的屏幕显示的就是颜色缓冲区中的颜色值。但是避免我们看到那些正在进行光栅化、逐片元操作的图元,GPU会使用双重缓冲(Double Buffering)的策略。
对场景的渲染发生在幕后,即后置缓冲(Back Buffer)中。当渲染好后则将后置缓冲区与前置缓冲(Front Buffer)中的内容交换,由此来保证我们看到的图像总是连续的。