图形绘制流水线梳理
作为一个游戏开发从业者,虽然现在主要在做服务器端了,但对客户端开发依然充满兴趣。个人一直认为游戏开发,应该前后端都学,都做。只是公司总是区分服务器和客户端开发。。。好吧,那就有机会都学一学,做一做。以前做客户端开发的时候,由于需求繁忙,很难挤出时间细细研究,只能是现学现用,很多东西还是没有完全搞明白。现在终于有一点个人时间,可以更系统的学习了。先从回顾一下图形学的相关知识开始吧。
现代计算机的图形绘制,都交由gpu完成并输出到显示设备。从而减轻cpu的计算压力,获得更好的绘制效率。为了让cpu和gpu很好的协作,gpu都需要单独的驱动程序,为了能更通用的使用显卡驱动,便有了通用的图形API,常见的即DirectX与OpenGL,DirectX只能在Windows平台使用,OpenGL则可以跨很多平台,他们的绘制过程大体相似。本文不是要介绍相应的图形API,而是梳理普遍的三维图形绘制过程。绘制过程可以分为以下几个常见阶段,又叫绘制流水线(Graphics pipeline)。
图形输入
要把三维模型绘制到屏幕上,首先需要定义模型,并将定义的相关数据作为输入,存放到显存,再在绘制提交执行的时候直接从显存读取数据。模型由一个个图元拼接而成,常见的图元有点、线、三角形及四边形等。比如一个正方形就可以由两个三角形组成。为了描述一个三角形,我们需要三个顶点数据,每个顶点不仅仅包含该点在三维空间中的位置,还可以包含该顶点的颜色、顶点的法线方向以及顶点对应的纹理坐标等等,根据需要加入相关的顶点属性。
为了绘制一个模型,除了顶点外,我们还需要定义顶点的拓扑结构,从而用顶点组成图元,再由一个个图元组成整个模型。常见的拓扑类型有TriangleStrip,TriangleList等,当然还有PointList,LineList等,这里我们主要讨论三角形面片。TriangleStrip是按约定的顶点顺序组建三角形,除了前三个顶点,后续的顶点总是和前面两个顶点组成一个三角形。TriangleList(顶点列表)类型每三个顶点描述一个三角形,显然前者更省空间,但是后者的描述更为通用。
大多数模型都有很多相邻的三角形,利用上述TriangleList描述,很多顶点需要重复多遍。于是有了索引描述方式,即定义顶点列表后,按照顺序用一个uint16或uint32数组从0开始索引顶点列表中的顶点。每三个索引所对应的顶点组成一个三角形。一个正方形,用顶点列表描述的话,两个三角形总共6个顶点,但用索引的方式,则只需要4个顶点加一个长度为6的short int数组。注意一个顶点数据结构的大小,一般都比额外索引半个int要大多了。实际应用中一般都是用索引描述的方式,对应到绘制的时候,则需要调相应的Indexed接口。
顶点着色器
对每一个输入的顶点,都会执行一次顶点着色器。顶点着色器主要处理顶点坐标系的变换,当然也可以进行逐个顶点的光照计算、蒙皮计算等。一般而言,我们获得的模型都是在它自己的坐标系中的,就是以该模型自己为中心(这样美术在制作模型时才能更好的合作,模型自身旋转缩放时也不会遇到问题)。模型位于场景中某个位置,我们通过一个世界变换矩阵(WorldMatrix)将模型变换到世界坐标系下。然后再从某个点用相机看整个场景,将世界坐标系中的物体变换到相机坐标系中(ViewMatrix)。最后由于最终显示的屏幕是是个平面,为了表现眼睛看物体近大远小,通过一个视锥体来表示相机坐标系中看到的区域,然后通过投影变换(ProjectionMatrix)把相机坐标系中的视锥体变换成立方体区域,也称为齐次裁剪空间,注意这里的立方体还不是最终屏幕上视口的大小,而是一个xy(-1,1),z(0,1)的区域。
于是一个顶点的变换可以表示为 ouput = input * WorldMatrix * ViewMatrix * ProjectionMatrix。根据矩阵的结合律,这三个矩阵也可以合并为一个矩阵。关于为何可以使用矩阵对顶点进行变换以及三个变换矩阵的计算,需要一些线性代数基础,可以参考DX龙书或OpenGL红宝书,都有基本讲解。现在普遍使用的旋转表示方法,四元数,却是很少有讲解的很清楚的,我们只需要知道通过四元数的两次乘法,能够实现三维空间的旋转即可。
对于逐个顶点的光照计算,我们在顶点着色阶段就可以根据该顶点的法向和光源方向计算出一个光照颜色,并记录在顶点输出属性中,向后传输插值后给片段着色器使用。
对于基于gpu的蒙皮动画,我们也可以在这个阶段根据骨骼的当前动作和顶点的绑定矩阵计算顶点的最终动作位置。
图元组装
该阶段根据描述的图元类型,传入的顶点及索引缓存,生成图元数据结构,为后续的几何着色器及裁剪做准备。
细分曲面着色器、几何着色器
在组装好图元后,我们还有机会对图元进行修改,进行曲面细分、图元修改等从而实现一些几何变化效果。
裁剪
顶点变换最终变换到一个xy轴(-1,1),z轴(0,1)的一个立方体区域,区域外的三角形不可见,可以直接剔除,而对于部分在立方体内的三角形,则需要切割,并把外面的剔除,注意此时坐标还在齐次空间以保留线性关系。此阶段还会做的工作是背面消隐。由于一般物体我们都只能看到外面,看不到内部,因此会指定三角形的顺时针或逆时针为正向,对于反向面向相机的面片则可以直接剔除。由于我们已经变换到立方体区域,此时只要判断法向的z值大于0或小于0即可。
视口变换
投影变换后,绘制的模型区域是在一个立方体区域内,与最终绘制的屏幕视口还有平移和缩放。此阶段将立方体映射到屏幕的视口中铺满。
栅格化
图形输入是描述性的几何定义,而屏幕则是散列点阵。简单来说,将几何图形离散为点阵表示,就是栅格化的过程。通过前面的变换,我们要绘制的三角形已经限制在视口以内,视口以外的三角形被剔除。对视口以内的每个三角形,判断该三角形所覆盖的离散像素点,对每个像素点利用三角形的顶点进行双线性插值生成一个片段属性数据,注意由于三角形可能互相覆盖穿插,实际上同一个像素点可能有多个片段,可能在前可能在后,在后续的z-test中可以选择性的得到该点最终的颜色。在栅格化阶段,也会指定后续使用的像素(片段)着色器。对生成的每个片段属性,都会执行一遍片段着色器,获得一个颜色输出。
栅格化阶段也可以处理反锯齿,上面说到屏幕上显示的是散列点阵,那么如果三角形的边缘正好穿过某个像素点,就产生了歧义。此时该像素点实际上只有部分被三角形覆盖,但片段着色器是对整个像素计算的。因此边缘穿过的像素覆盖多的计算颜色,覆盖少的不计算颜色,就产生了锯齿。在理论层面,由于采样的频率必须大于信号最大频率的两倍才能保持原有的信息(奈奎斯特定理)。对于三角形边缘,是一个不连续的跳变,离散的采样需要无限大的采样频率才能保持边缘信息。由于屏幕是离散的点,我们只能通过提高采样频率来减少锯齿的视觉影响。最简单的方法是超采样(SSAA),就是原先的一个像素,采样两个、四个像素,最后做个平均或者其他滤波得到屏幕的颜色值。由于SSAA采样数翻倍,计算量也是翻倍的增长,显然对性能消耗是巨大的。于是诞生了多重采样(MSAA),对原先的一个像素,栅格化时同样采样多次,并且每个采样点单独进行深度测试,但是对于三角形覆盖的区域,只执行一次片段着色器计算,并把该结果复制给其他采样点。最后根据覆盖率计算最终的颜色值。如果一个像素采样了4个点,有3个点被该三角形覆盖,那么最终该片段贡献3/4的颜色值。这样得到的结果也能大大改善边缘锯齿,但是省去了为每个采样点执行片段着色。
像素(片段)着色器
栅格化输出的片段数据,就是该着色器的输入,对于每一个片段数据,片段着色器都执行一遍代码计算该片段的颜色值。逐像素的光照计算、纹理采样、一些风格化的处理都可以在片段着色器中完成。在栅格化的过程中我们提到,屏幕上的每个点可能有多个片段数据。因此这里计算的结果还不是最终屏幕上看到的颜色,实际上很多结果还会丢掉。
屏幕剪裁
在视口中,我们可以指定一个剪裁区域,超出区域的部分不绘制。
深度和模板测试
这里的深度测试就是上文说到的z-test,由于每个位置可能有多个片段计算的结果,一般来说只有位于最前方靠近相机的面片才能被看到,所以每个片段在写入结果时,同时写入自己的深度到z-buffer,后面再有片段对应到这个位置时,先用深度属性和z-buffer中的深度值比较,小于z-buffer中的深度才计算并更新颜色值。模板测试与深度测试类似,只有stencil-buffer的值满足特定条件才计算并写入结果。
混合
有时物体表面带有半透明的特性,在写入颜色值时需要与已有的颜色进行混合,注意此时需要把深度检测关掉,否则位于半透明表面后面的面片可能通不过深度测试而不绘制出来。另外如果采用了MSAA等抗锯齿技术,这里也要把不同采样的结果做一个混个,得到最终的颜色。
绘制目标
绘制目标不一定是屏幕,即使是屏幕,在这里一般也不是直接写入用于显示的内存块,而是写入一个交换链的准备缓存,等这一次绘制全部完成后再用一次交换操作整块拷贝或翻转用于显示。很多情况下,绘制的结果不用于直接的屏幕显示,而是写入一个帧缓存,该缓存可以被应用程序读取并用于后续的操作。
以上是常见的图形绘制流水线的简单梳理,可能有的阶段在某些文档里是合并到一起的,并不影响理解。图形绘制是一个很复杂的过程,每一个阶段展开都有很多相关的专题可以讨论,这里只做一个概念性的总结,以帮助理解引擎绘制过程以及学习相关shader语言。