光线追踪的基于像素的渲染方法已经初步介绍完毕,本章开始介绍以物体为基础的渲染(object-order rendering)。从物体开始到画像素结束的全部过程被称为图形管线。以物体为基础的渲染有较好的效率,仅仅一次遍历所有物体即可完成,而不是反复遍历相同的的物体来画不同的像素。图形管线分为需要高速的硬件管线(hardware pipeline)以及高质量的软件管线(software pipeline)。前者主要用于游戏与用户交互等,后者用于制作影片或者动画。下图包含了一个典型的图形管线流程,其中紫色标志为本文涵盖的部分。推荐知乎的细说图形学渲染管线来查看整体的轮廓介绍。
1.顶点处理(vertex processing)
顶点处理读入一系列顶点信息,例如坐标位置、颜色、法向量等信息,再将其投影转换至屏幕空间的像素上,并对数值进行必要的修改。至此每一个图元的顶点以及顶点上的信息都已经准备好了。一个常见的方法是先将顶点的法向量,光线方向,视线方向在世界坐标系或相机坐标系下进行反射光等计算着色,得出颜色等信息。这种先计算顶点颜色再后续混合的叫做Gouraud Shading,好处是较为轻松与快捷,坏处是因为锁定图元的顶点进行插值,所以无法在图元内的地方进行细节化的调整。当然,如果图元足够小的话这种方法也是可行的。
一个另外要谈到的是纹理映射(Texture Mapping),即额外运用一个图像来储存模型的颜色或者法向量,而顶点可以基于纹理坐标(texture coordinate)来在这张图内找到对应的值。其中normal map以及纹理颜色经常作为模型的皮肤出现,给模型增加额外的细节。
2.光栅化(Rasterization)
经过顶点处理我们得到了一系列的图元(primitive),这些图元通常以正常的几何图形来出现,例如圆、三角形或者线。这些连续的图元需要经过光栅化的处理变为离散的像素点。光栅化需要确认图元所覆盖的像素(enumerate pixels covered by the primitive),并对与覆盖的每一个像素通过顶点插值的方法计算属性(interpolates values)。每一个图元会给其覆盖的像素一个值,称为片段(fragment)。类推可以得知若一个像素被多个图元覆盖则其拥有多个片段。这些片段会被送往片段着色器(fragment shader)或者本文所称的片段处理部分(fragment processing),来决定一个像素最终拥有的一个值是什么。
2.1 画线
假设有两点\((x_0,y_0),(x_1,y_1)\),需要选择适当的像素来平滑地表示这两点之间的直线。通常用隐式函数或者参数函数来进行计算。本书介绍的是利用隐式函数的中点算法(midpoint algorithm),延伸还有圆的中点算法。另一个利用隐式函数的方法是Bresenham algorithm。
2.1.1 直线中点算法
中点算法首先根据斜率来判断线在哪一侧上升的较慢,并遍历上升较慢的一侧,例如\(k=0.5\)则代表\(x\)轴上升较慢并遍历\([x_0,x_1]\)。遍历时要么保持\(y\)不变要么上升\(1\),判断条件可以通过考察两个可能点的中点在直线的上方还是下方来决定,见下图。
本文使用的是计算隐式函数\(f(x,y)\)的正负来判断点在直线的上方还是下方,因此相同的\(f(x,y)\)可能会被反复计算,故采用如下的优化。
2.1.2 直线Bresenham算法
与中点算法类似,取\(k\in [0,1]\)的情况,遍历\(x\)轴。Bresenham算法的原理是维持一个直线与现在\(y\)值之差的误差值\(\Delta\)并计算斜率\(k\)。每移动一格\(x\)则修改对应的\(\Delta\),如果\(\Delta\)达到边界值则改变\(y\)(加一代表移动了一个像素)并重新修正\(\Delta\)(减一代表像素格追上了直线一个单位)。实际操作时利用隐式函数将直线写作\(f(x,y)=Ax+By+C\)的如下形式
将\(D\)设置成下两个点的中点与现在点的\(f(x,y)\)差值如下,与中点算法一致选取点并更新D即可。注意这里的wiki采用的是斜率在\([0,1]\)之间的八分之一个平面,全平面的方法需要考虑到边界情况,具体可参考Wiki链接。
2.2 画三角
以前介绍了三角形的重心坐标插值(Barycentric coordinate)利用顶点信息混合出边或三角形面内任意点信息的方法。因为三角形存在共顶点或共边的情况,如果两个不同颜色的三角形相邻,则最终的图形取决于哪一个三角形先被绘制,拥有了更多的不确定性。为了避免这个问题,一个方法是仅绘制中心在三角形内的像素,除了像素中心恰好在三角形边上的边界情况外便可以避免共边带来的顺序问题,这种情况下操作如下。
对于像素中心恰好在三角形边上的情况来说,如果两个三角形共享了这一条边,我们需要决定要将像素分给哪一个三角形。首先不能不画像素否则会有一个背景色的空像素在三角形的边上,其次最好也不要两个三角形都画在像素上,否则会有重叠的情况也不平滑自然。有一个方法是挑屏幕外的一个点,并总选择更靠近此点的三角形画在像素上。除了正好两个三角形围绕该点对称,即共享边恰好过屏幕外点的时候,这个方法都十分可行。如下图所示,我们挑选左上角的屏幕外点,以\(a,b\)以及共享边组成的两个三角形中,顶点为\(a\)的三角形显然与屏幕外点更接近,故选择靠上的此三角形。
此方法的代数方法即利用隐式函数\(f(x,y)\)的正负值代表是在直线左侧还是右侧。如上图则有\(a\)与屏幕外的点隐式函数值为同号的情况(他们都在对边的左侧)。因此在画像素时额外增加检查条件如下。当\(\alpha=0\)时,我们要求点\(a\)与屏幕外的点\((-1,-1)\)同号,其余的\(\beta,\gamma\)以此类推。注意这个方法也可以被翻译成,对于靠近屏幕外点的三角形画像素中心在边上和在里面的部分,对于远离屏幕外点的三角形画像素中心在三角形内的部分。
2.3 剪裁(clipping)
回顾透视视角的转换矩阵,我们可以发现当\(z\)为负值的时候在摄像机后的点反而被投影到了前方,也就是透视视角矩阵仅对\(z\in[n,f]\)之间的点保证了转换的正确性,这一点在推导过程中也是一致的。因此,在进行转换以前,我们就需要就\(z\in[n,f]\)进行一次剪裁操作,只绘画图元primitive的对应部分。
在实际操作的时候,我们很显然可以选择在homogenous divide,也就是z投影到z\'之前或者之后进行转换。如果在之前转换,则等同于将图元在全局坐标系内按照投影截面体的六个面进行剪裁。如果在之后转换,则是直接选择\(z\'\in[n,f]\)的部分。伪代码如下。
2.3.1 之前转换,六面体剪裁方法
因为在摄像机不变的时候,透视截面体是固定的,可反复用于所有的图元计算。故可以基于八个顶点硬算出六个面的表达式来进行剪裁。这个方法在每次摄像机移动的时候要重新计算。
2.3.2 之后转换,在homogenous divide之前
在四维空间内我们三个面变成了与\(w\)相关的hyperplane,如下图所示。这样平面便更加容易计算,因此实际操作时也通常采取这个方法。
2.3.3 实际的剪裁
之前提到过,对于过点P的平面可以表示为\(f(p)=n\cdot(p-q)=n\cdot p+D=0\)的形式,其中n代表normal。给定三角形或者图元中的一条直线,我们可以选择其两个端点\(a,b\)并判断\(f(p)\)的正负号,如果异号则代表两点在平面的不同侧,则代表直线过平面。将直线的parametric form即\(p=a+t(b-a)\)代入平面解析式可得\(n\cdot(a+t(b-a))+D=0\),得出\(t=\frac{n\cdot a+D}{n\cdot(a-b)}\)并可以算出交点。我们可以依据交点将三角形进行剪裁,可能会分成更多的三角形。
3. 片段着色器(Fragment Shader)
片段着色器输入一个图元排好了的若干个像素以及顶点信息,输出为每一个像素拥有的该图元信息,例如该像素上属于A图元的颜色,法向量等。常见的包括通过线性插值得到每个点的颜色,或者稍微复杂一点的光照或者更为复杂一些的变形。与顶点着色器计算着色相反,在片段着色器中我们可以使用已经被插值好了的片段属性进行着色公式计算。这种后计算的着色又称为Phong Shading,注意这个和Blinn-Phong光照模型是两个东西。因为Phong着色可以应用于图元内法向量有变化的情况,在有法向量参与的光照模型中会有更好的表现(与其相反,Gouraud无法利用到图元内变动法向量的信息,是简单的颜色插值)。
4. 混合
混合步将不同图元在像素上的颜色进行混合,例如只显示最前方的物体,又或者透明度Alpha叠加等操作。单纯的先画背景再画前段的画家算法是一个较为简单选择图元排序的方法,但是无法处理循环覆盖的情况,排序也存在时间复杂度的问题。实际操作时排序通常使用z-buffer的方法,即每个fragment记录该图元在像素上的深度,最后混合片段的时候选择深度最小的fragment,具体方法是选择深度最小值来更新像素的z-buffer与color buffer。我们通常使用整数的\([0,B-1]\)来储存Z-buffer而不是消耗更多空间的float,这样每一个正整数对应了\(\Delta z = (f-n)/B\),很显然减少B可以省空间,但B太少则有可能产生Z-fight的问题,即两个图元存在前后且非重叠关系,但投影到z-buffer后深度一致,因为排序或其他原因导致两个图元交叉绘画而不是只画其中的一个。根据透视矩阵可得\(z=n+f-\frac{fn}{z_w}\),其中\(z_w\)指的是原点在世界坐标系的z值,而\(z\)指的是二维空间的值。取两边微积分可得\(\Delta z\approx \frac{fn}{(z_w)^2}\Delta z_w\),即\(\Delta z_w\approx \frac{(z_w)^2\Delta z}{fn}\)。根据透视截面体的要求,我们知道\(z_w\leq f\),因此\(\Delta z_w\leq \frac{f\Delta z}{n}=\frac{f(f-n)}{nB}\)。当两个图元之间距离在这个\(\Delta z_w\)内的时候,其对应的z-buffer则小于1,可能会产生z-fight的情况。若想提高精度则需要减小\(\Delta z_w\),即增加n,增加B或减小f。
5. 优化 - 抗锯齿(antialiasing)
很多种方法来解决锯齿状渲染的问题,第一个是叫做box filtering,即将屏幕尺寸放大n倍,再在浓缩时将像素的颜色记录为nxn的平均值,这个方法消耗很大,通常采用一些其他的方法,可见知乎此链接。
6. 优化 - 剔除 (culling)
剔除主要是希望在构造世界的时候只渲染在我们眼前的世界从而节约计算资源。常用的有三种
6.1 View Volume Culling 视野剔除
我们仅对摄像机视野内的物体进行渲染,需注意剔除测试的时间应该小于直接绘画图元的时间,否则得不偿失。通常这个方法被用于很多个三角形组成的物体object来进行判断,如果整个object在视野外,那么移除object则省去了许多个个三角形的绘画时间。
6.2 Occlusion culling 遮挡剔除
如果A物体阻挡了B物体,则我们在视角内其实看不到B物体,遮挡剔除让我们省去B物体的绘画时间。在光线追踪时这个可以很轻松地做到,但是在以物体为序的光栅化中则需要优化来实施。
6.3 backface culling 反面剔除
这个方法将图元的法向量侧视为可见侧(无论是否被画,背景色总在那里),这么一来图元其实只有一面是可以被看到的。如此一来我们将图元的法向量与落点到光线的向量进行点乘判断是否同侧,如果同侧说明光无法通过图元反射到摄像机,那么不进行绘制。这个方法一般被用在封闭的多边体模型中,这样的多边体一般默认所有的法向量朝外,符合一般世界中的观察方式。