计算一个几何图元 (geometric primitive)在图像中所占据的像素的过程称为栅格化 (rasterization),因此物体序渲染 (object-order rendering)也称为栅格化渲染 (rendering by rasterization)。从场景中的物体开始,到更新图像中的像素结束,其间所需的一系列操作称为图形流水线 (graphics pipeline)。
基于栅格化的系统也称为扫描线渲染器 (scanline renderer)。
物体序渲染由于自身的高效取得了巨大的成功。当场景较大时,对几何体仅遍历一次明显优于反复搜索整个场景。
图形流水线可分为两种:
硬件流水线 (hardware pipeline),通过 OpenGL、Direct3D 等 API 支持交互式渲染。硬件流水线必须运行得足够快,以保证在游戏、可视化、用户界面(UI)等应用中实时响应。
软件流水线 (software pipeline),用于电影制作,支持 RenderMan 之类的 API。软件流水线必须尽可能地渲染出高质量动画和视觉效果并扩展到更大的场景中去,它可能需要更多的计算时间。
尽管如此,仍有大量内容是大多数图形流水线共通的。本章从更接近硬件流水线的角度,将注意力集中在这些共通的部分。
物体序渲染需要做的事情可分为:
栅格化前的几何操作 (geometric operation)
栅格化
栅格化后的像素操作 (pixelwise operation)
最常见的几何操作是通过矩阵变换将表示几何的点从物体空间 (object space)映射到屏幕空间 (screen space)。因此,栅格器 (rasterizer)的输入是屏幕空间的像素坐标。最常见的像素操作是隐面消除 (hidden surface removal)。此外,每个阶段还允许很多其它操作以实现不同的渲染效果。
如上图所示,图形流水线可分为四个阶段:
首先,应用层或场景描述文件 (scene description file)中的物体几何被送入流水线中,它们通常由一组顶点 (vertices)来描述。顶点处理阶段 (vertex processing stage)将操作这些顶点。
然后,使用这些顶点的图元进入到栅格化阶段 (rasterization stage)。每个图元都会被栅格器分解为许多片段 (fragments),每个片段对应图元所覆盖的一个像素。
接着,在片段处理阶段 (fragment processing stage),处理这些片段。
最后,在片段混合阶段 (fragment blending stage),将对应同一个像素的不同片段混合。
栅格化
栅格化 (rasterization)是物体序渲染的核心操作,而栅格器 (rasterizer)则是图形流水线的核心。栅格器对每个图元做两件事:
列举 (enumerates)图元所覆盖的那些像素
对图元属性做内插 (interpolates)
栅格器的输出是一组片段 (fragments),一个片段对应一个特定的像素,并携带一组属性值。
绘制线段
大多数图形软件包都包含画线命令,它接收两个端点的屏幕坐标,然后画出两点间的线段。直线有两种方程:隐式方程、参数方程。本节给出的是基于隐式方程的画线方法。
最常见的使用隐式方程画线的方法是中点法 (midpoint algorithm),它由 M. L. V. Pitteway 在 1967 年提出,并在 1985 年经 J. van Aken 和 M. Novak 改进。中点法画出的结果与 1965 年 J. E. Bresenham 提出的 Bresenham 算法 (Bresenham algorithm)完全一样,但前者更直接。两者都是选择线段穿过的那些像素,并尽可能地画出最细的无空隙的线 。对于斜率绝对值小于 1 1 1 的线段,如果有两个纵向相邻的像素都被穿过,则选择交集最多的那个像素,斜率绝对值大于 1 1 1 的情况类似。
这里约定,只有横、纵向像素间断会产生空隙,对角像素不产生空隙。
直线的隐式方程:
f ( x , y ) ≡ ( y 0 − y 1 ) x + ( x 1 − x 0 ) y + x 0 y 1 − x 1 y 0 = 0 f(x, y) \equiv (y_{0} - y_{1})x + (x_{1} - x_{0})y + x_{0}y_{1} - x_{1}y_{0} = 0 f ( x , y ) ≡ ( y 0 − y 1 ) x + ( x 1 − x 0 ) y + x 0 y 1 − x 1 y 0 = 0
假定 x 0 ⩽ x 1 x_{0}\leqslant x_{1} x 0 ⩽ x 1 ,若非如此,可交换起点和终点。直线的斜率为:
m = y 1 − y 0 x 1 − x 0 m = \frac{y_{1} - y_{0}}{x_{1} - x_{0}} m = x 1 − x 0 y 1 − y 0
下面仅考虑 m ∈ ( 0 , 1 ] m\in(0,1] m ∈ ( 0 , 1 ] 的情形,其余三种情况 m ∈ ( − ∞ , − 1 ] m\in(-\infty,-1] m ∈ ( − ∞ , − 1 ] 、m ∈ ( − 1 , 0 ] m\in(-1,0] m ∈ ( − 1 , 0 ] 、m ∈ ( 1 , + ∞ ) m\in(1,+\infty) m ∈ ( 1 , + ∞ ) 类似。当 m ∈ ( 0 , 1 ] m\in(0,1] m ∈ ( 0 , 1 ] 时,线段端点之间的每一列都恰有一个像素被选中。随着线段从左端点向右端点移动,下一个选中的像素要么和左边像素高度相同,要么高一个像素。
中点法首先确定最左边像素,以及最右边像素的列,然后横向循环,通过考察两个候选像素的中点与直线的关系,来决定选择哪个像素。例如,当前已经绘制像素 ( x , y ) (x,y) ( x , y ) ,两个候选像素就是 ( x + 1 , y ) (x+1,y) ( x + 1 , y ) 和 ( x + 1 , y + 1 ) (x+1,y+1) ( x + 1 , y + 1 ) ,它们的中点就是 ( x + 1 , y + 0.5 ) (x+1,y+0.5) ( x + 1 , y + 0.5 ) ,如果直线位于该点上方,则绘制像素 ( x + 1 , y + 1 ) (x+1,y+1) ( x + 1 , y + 1 ) ,否则绘制像素 ( x + 1 , y ) (x+1,y) ( x + 1 , y ) 。
根据
lim y → + ∞ f ( x , y ) = + ∞ \lim_{y\rightarrow+\infty}f(x,y)=+\infty y → + ∞ lim f ( x , y ) = + ∞
容易知道,f ( x , y ) > 0 f(x,y)>0 f ( x , y ) > 0 表示点 ( x , y ) (x,y) ( x , y ) 在直线上方。
使用梯度也可得出同一结论,梯度的 y y y 分量可以给出 f ( x , y ) f(x,y) f ( x , y ) 大于 0 0 0 的方向。
使用增量法 (incremental method)可以让程序更高效。增量法就是通过重复使用上一步的计算结果来提高循环的效率。结合下面的关系式:
f ( x + 1 , y ) = f ( x , y ) + ( y 0 − y 1 ) f ( x + 1 , y + 1 ) = f ( x , y ) + ( y 0 − y 1 ) + ( x 1 − x 0 ) \begin{aligned}
f(x + 1, y) &= f(x, y) + (y_{0} - y_{1}) \\
f(x + 1, y + 1) &= f(x, y) + (y_{0} - y_{1}) + (x_{1} - x_{0})
\end{aligned} f ( x + 1 , y ) f ( x + 1 , y + 1 ) = f ( x , y ) + ( y 0 − y 1 ) = f ( x , y ) + ( y 0 − y 1 ) + ( x 1 − x 0 )
中点法的增量版本如下:
y = y 0 d = f ( x 0 + 1 , y 0 + 0.5 ) for x = x 0 to x 1 do draw ( x , y ) if d < 0 then y = y + 1 d = d + ( y 0 − y 1 ) + ( x 1 − x 0 ) else d = d + ( y 0 − y 1 ) \begin{aligned}
&y = y_{0} \\
&d = f(x_{0} + 1, y_{0} + 0.5) \\
&\textbf{for}\ x = x_{0}\ \text{to}\ x_{1}\ \textbf{do} \\
&\quad\begin{aligned}
&\text{draw}(x, y) \\
&\textbf{if}\ d < 0\ \textbf{then} \\
&\quad\begin{aligned}
&y = y + 1 \\
&d = d + (y_{0} - y_{1}) + (x_{1} - x_{0})
\end{aligned} \\
&\textbf{else} \\
&\quad d = d + (y_{0} - y_{1})
\end{aligned}
\end{aligned} y = y 0 d = f ( x 0 + 1 , y 0 + 0.5 ) for x = x 0 to x 1 do draw ( x , y ) if d < 0 then y = y + 1 d = d + ( y 0 − y 1 ) + ( x 1 − x 0 ) else d = d + ( y 0 − y 1 )
这里 x x x 、y y y 均为整数,如果要推广到线段端点不是整数的情况,只需将变量初始化以及循环初始化中的 x 0 x_{0} x 0 、y 0 y_{0} y 0 、x 1 x_{1} x 1 稍作改动即可。
相比于非增量版本,由于启动损耗 (setup cost)几乎没有增加,上述程序应该运行得更快;但也可能积累更多的数值误差,因为 f ( x , y + 0.5 ) f(x,y+0.5) f ( x , y + 0.5 ) 的计算由许多次加法组成。考虑到很少有超过几千个像素的线段,这种数值误差并不重要。为了更快地执行循环,可以稍微增加一点启动损耗,将 ( y 0 − y 1 ) + ( x 1 − x 0 ) (y_{0} - y_{1}) + (x_{1} - x_{0}) ( y 0 − y 1 ) + ( x 1 − x 0 ) 和 ( y 0 − y 1 ) (y_{0} - y_{1}) ( y 0 − y 1 ) 存储起来;但这些最好由编译器来完成,如果这段程序比较重要,可能还要检查编译结果。
三角形栅格化
和线段类似,绘制三角形有时也需要根据顶点进行属性值内插,这可以用重心坐标来实现。比如,三角形内重心坐标为 ( α , β , γ ) (\alpha,\beta,\gamma) ( α , β , γ ) 的点的颜色:
c ⃗ = α c ⃗ 0 + β c ⃗ 1 + γ c ⃗ 2 \vec{c} = \alpha\vec{c}_{0} + \beta\vec{c}_{1} + \gamma\vec{c}_{2} c = α c 0 + β c 1 + γ c 2
其中,c ⃗ 0 \vec{c}_{0} c 0 、c ⃗ 1 \vec{c}_{1} c 1 、c ⃗ 2 \vec{c}_{2} c 2 是三个顶点的颜色。上面这种类型的颜色内插称为 Gouraud 内插 (Gouraud interpolation)。
和线段不同的是,绘制三角形必须考虑两个三角形相邻的情况。此时,邻边上像素的属性值可能依赖于绘制顺序 。为了避免出现顺序问题,同时还要消除空隙 ,最常用的三角形栅格化方法是约定:只绘制中心位于三角形内部的像素 。对于中心恰好在三角形边上的像素,如果直接将其归于该三角形,则可能会被相邻的两个三角形双重着色 (double coloring)。这里采用一种简单的处理方式,将边上的像素归属于唯一的三角形。
边上的像素属于哪个三角形并不重要,只要有一个完全明确的规则即可。
选择 ( x , y ) = ( − 1 , − 1 ) (x,y)=(-1,-1) ( x , y ) = ( − 1 , − 1 ) 作为屏外点 (off-screen point),只有当三角形和屏外点位于边的延长线的同一侧时,才认为边上的像素属于该三角形。由于边的延长线可能刚好穿过屏外点,所以这个方法并不完美,但至少可以解决绝大部分问题。
屏外点可以任意选取。
对于非交叠相邻三角形,上述做法可以避免空隙、绘制顺序、双重着色等问题;而交叠三角形本身需要混合,因此,即使将边上的像素同时归属于两个交叠三角形也是可行的。
将遍历范围限制到三角形的包围矩形 (bounding rectangle),可以让程序更高效。而重心坐标则有助于判断像素中心的位置,以及属性插值。将这些结合起来可得,绘制顶点为 p ⃗ 0 = ( x 0 , y 0 ) \vec{p}_{0}=(x_{0},y_{0}) p 0 = ( x 0 , y 0 ) 、p ⃗ 1 = ( x 1 , y 1 ) \vec{p}_{1}=(x_{1},y_{1}) p 1 = ( x 1 , y 1 ) 、p ⃗ 2 = ( x 2 , y 2 ) \vec{p}_{2}=(x_{2},y_{2}) p 2 = ( x 2 , y 2 ) 的三角形的伪代码:
x min = floor ( x i ) x max = ceiling ( x i ) y min = floor ( y i ) y max = ceiling ( y i ) f α = f 12 ( x 0 , y 0 ) f β = f 20 ( x 1 , y 1 ) f γ = f 01 ( x 2 , y 2 ) for y = y min to y max do for x = x min to x max do α = f 12 ( x , y ) / f α β = f 20 ( x , y ) / f β γ = f 01 ( x , y ) / f γ if ( α ⩾ 0 and β ⩾ 0 and γ ⩾ 0 ) then if ( α > 0 or f α f 12 ( − 1 , − 1 ) > 0 ) and ( β > 0 or f β f 20 ( − 1 , − 1 ) > 0 ) and ( γ > 0 or f γ f 01 ( − 1 , − 1 ) > 0 ) then c ⃗ = α c ⃗ 0 + β c ⃗ 1 + γ c ⃗ 2 drawpixel ( x , y ) with color c ⃗ \begin{aligned}
&x_{\text{min}} = \text{floor}(x_{i}) \\
&x_{\text{max}} = \text{ceiling}(x_{i}) \\
&y_{\text{min}} = \text{floor}(y_{i}) \\
&y_{\text{max}} = \text{ceiling}(y_{i}) \\
&f_{\alpha} = f_{12}(x_{0}, y_{0}) \\
&f_{\beta} = f_{20}(x_{1}, y_{1}) \\
&f_{\gamma} = f_{01}(x_{2}, y_{2}) \\
&\textbf{for}\ y = y_{\text{min}}\ \text{to}\ y_{\text{max}}\ \textbf{do} \\
&\quad\begin{aligned}
&\textbf{for}\ x = x_{\text{min}}\ \text{to}\ x_{\text{max}}\ \textbf{do} \\
&\quad\begin{aligned}
&\alpha = f_{12}(x, y)/f_{\alpha} \\
&\beta = f_{20}(x, y)/f_{\beta} \\
&\gamma = f_{01}(x, y)/f_{\gamma} \\
&\textbf{if}\ (\alpha\geqslant 0\ \text{and}\ \beta\geqslant 0\ \text{and}\ \gamma\geqslant 0)\ \textbf{then} \\
&\quad\begin{aligned}
&\textbf{if}\ (\alpha>0\ \text{or}\ f_{\alpha}f_{12}(-1,-1)>0)\ \text{and} \\
&\quad\begin{aligned}
&(\beta>0\ \text{or}\ f_{\beta}f_{20}(-1,-1)>0)\ \text{and} \\
&(\gamma>0\ \text{or}\ f_{\gamma}f_{01}(-1,-1)>0)\ \textbf{then} \\
&\vec{c} = \alpha\vec{c}_{0} + \beta\vec{c}_{1} + \gamma\vec{c}_{2} \\
&\text{drawpixel}\ (x, y)\ \text{with color}\ \vec{c}
\end{aligned}
\end{aligned}
\end{aligned}
\end{aligned}
\end{aligned} x min = floor ( x i ) x max = ceiling ( x i ) y min = floor ( y i ) y max = ceiling ( y i ) f α = f 12 ( x 0 , y 0 ) f β = f 20 ( x 1 , y 1 ) f γ = f 01 ( x 2 , y 2 ) for y = y min to y max do for x = x min to x max do α = f 12 ( x , y ) / f α β = f 20 ( x , y ) / f β γ = f 01 ( x , y ) / f γ if ( α ⩾ 0 and β ⩾ 0 and γ ⩾ 0 ) then if ( α > 0 or f α f 12 ( − 1 , − 1 ) > 0 ) and ( β > 0 or f β f 20 ( − 1 , − 1 ) > 0 ) and ( γ > 0 or f γ f 01 ( − 1 , − 1 ) > 0 ) then c = α c 0 + β c 1 + γ c 2 drawpixel ( x , y ) with color c
其中,f i j f_{ij} f ij 是过顶点 p ⃗ i \vec{p}_{i} p i 、p ⃗ j \vec{p}_{j} p j 的直线方程:
f 01 ( x , y ) = ( y 0 − y 1 ) x + ( x 1 − x 0 ) y + x 0 y 1 − x 1 y 0 f 12 ( x , y ) = ( y 1 − y 2 ) x + ( x 2 − x 1 ) y + x 1 y 2 − x 2 y 1 f 20 ( x , y ) = ( y 2 − y 0 ) x + ( x 0 − x 2 ) y + x 2 y 0 − x 0 y 2 f_{01}(x, y) = (y_{0} - y_{1})x + (x_{1} - x_{0})y + x_{0}y_{1} - x_{1}y_{0} \\
f_{12}(x, y) = (y_{1} - y_{2})x + (x_{2} - x_{1})y + x_{1}y_{2} - x_{2}y_{1} \\
f_{20}(x, y) = (y_{2} - y_{0})x + (x_{0} - x_{2})y + x_{2}y_{0} - x_{0}y_{2} f 01 ( x , y ) = ( y 0 − y 1 ) x + ( x 1 − x 0 ) y + x 0 y 1 − x 1 y 0 f 12 ( x , y ) = ( y 1 − y 2 ) x + ( x 2 − x 1 ) y + x 1 y 2 − x 2 y 1 f 20 ( x , y ) = ( y 2 − y 0 ) x + ( x 0 − x 2 ) y + x 2 y 0 − x 0 y 2
与绘制线段类似,上述伪代码也可以改为增量版本。由于内层循环每次对 x x x 加 1 1 1 ,外层循环每次对 y y y 加 1 1 1 ,因此 α \alpha α 、β \beta β 、γ \gamma γ 在循环体内每次增长一个固定值,颜色 c ⃗ \vec{c} c 也是如此,从而可以将上述算法改为增量版本。此时,再利用关系式 α + β + γ = 1 \alpha+\beta+\gamma=1 α + β + γ = 1 计算 γ \gamma γ 不一定有多少收益。除了改为增量版本之外,还有一些可能的提前退出的位置,比如 α < 0 \alpha<0 α < 0 时。提前退出程序可能会提升速度,但是额外的分支也可能降低流水线性能,因此,任何可能的优化都需要实际测试后才能下结论。
要想上述伪代码消除空隙并避免双重绘制,必须保证相邻三角形使用完全相同的公共边直线方程,即绘制三角形时公共顶点的输入顺序完全相同。这一问题可能还依赖于编译器是否改变操作顺序。如果代码实现必须足够健壮,则需检查编译器和算术单元。而上述伪代码的前四行必须仔细编写,以处理三角形的边刚好穿过像素中心的情况。对于退化三角形 (degenerate triangle),代码中被除数 f α f_{\alpha} f α 、f β f_{\beta} f β 、f γ f_{\gamma} f γ 等于 0 0 0 ,这种情况要么计入浮点数误差,要么进行额外检测。
透视校正插值(Perspective Correct Interpolation)
三维空间中线性变化的属性,如纹理坐标 (texture coordinates)、点的位置等,如果直接在屏幕空间中线性内插,会得出错误结果,原因是透视成像服从近大远小的规律。下面以纹理坐标为例来进行说明,结论适用于其它属性。
将上一小节中的栅格化算法稍加修改,对 ( u , v ) (u,v) ( u , v ) 坐标内插,便可以实现三角形的纹理映射。但直接使用屏幕空间的重心坐标,得到的结果类似下面右图,与实际情况(左图)不符。
属性内插的关键在于,点的属性在变换前后应保持不变 。而线段变换满足:
q ⃗ + t ( Q ⃗ − q ⃗ ) ↦ s ⃗ + α ( S ⃗ − s ⃗ ) 其中 { t ( α ) = w r α w R + α ( w r − w R ) α ( t ) = w R t w r + t ( w R − w r ) \vec{q} + t(\vec{Q} - \vec{q})\mapsto\vec{s} + \alpha(\vec{S} - \vec{s}) \\
其中\left\{\begin{aligned}
&t(\alpha) = \frac{w_{r}\alpha}{w_{R} + \alpha(w_{r} - w_{R})} \\
&\alpha(t) = \frac{w_{R}t}{w_{r} + t(w_{R} - w_{r})}
\end{aligned}
\right. q + t ( Q − q ) ↦ s + α ( S − s ) 其中 ⎩ ⎨ ⎧ t ( α ) = w R + α ( w r − w R ) w r α α ( t ) = w r + t ( w R − w r ) w R t
利用这些关系可以根据屏幕空间中的点计算出三维空间中的插值系数,进而得出正确的纹理坐标。然而,还有一种更简单的方式来实现透视校正。
容易知道,重心坐标可以推广到 n n n 维欧氏空间,即,给定 n n n 维空间中 n + 1 n+1 n + 1 个点 p ⃗ 1 、 p ⃗ 2 、 ⋯ 、 p ⃗ n + 1 \vec{p}_{1}、\vec{p}_{2}、\cdots、\vec{p}_{n+1} p 1 、 p 2 、 ⋯ 、 p n + 1 ,只要围成的多胞形 (polytope)体积不为零,任意点 p ⃗ \vec{p} p 均可表示为:
p ⃗ = ∑ i = 1 n + 1 x i p ⃗ i , ∑ i = 1 n + 1 x i = 1 \vec{p} = \sum_{i=1}^{n+1}x_{i}\vec{p}_{i},\quad \sum_{i=1}^{n+1}x_{i} = 1 p = i = 1 ∑ n + 1 x i p i , i = 1 ∑ n + 1 x i = 1
而且,n n n 维空间上的线性函数可由这 n + 1 n+1 n + 1 个点的函数值完全确定。又因为对这 n + 1 n+1 n + 1 个点的线性插值本身就是 n n n 维空间上的一次函数。因此,n n n 维空间的一次函数等价于线性插值 。
将上述内容应用到三角形上可知,重心坐标内插的属性就是平面上的一次函数,该函数可以扩展到全空间,成为整个三维空间上的一次函数,并在原平面上退化为属性值。一次函数可看作高一维空间中的超平面 (hyperplane)。由于透视变换保持了线段、三角形、平面,因此可以合理推测,一次函数经透视变换、齐次化后仍为一次函数,只不过函数值也要参与齐次化。
这里,对函数做变换是指对自变量变换,这种变换可以通过高一维空间中的透视变换来实现,多出那个维度表示函数值。
[ u v 1 x q y q z q 1 ] → transform [ u v 1 x r y r z r w r ] → homogenize [ u / w r v / w r 1 / w r x r / w r y r / w r z r / w r 1 ] ≡ [ u / w r v / w r 1 / w r x s y s z s 1 ] \begin{bmatrix}
u \\
v \\
1 \\
x_{q} \\
y_{q} \\
z_{q} \\
1
\end{bmatrix}
\xrightarrow{\text{transform}}
\begin{bmatrix}
u \\
v \\
1 \\
x_{r} \\
y_{r} \\
z_{r} \\
w_{r}
\end{bmatrix}
\xrightarrow{\text{homogenize}}
\begin{bmatrix}
u/w_{r} \\
v/w_{r} \\
1/w_{r} \\
x_{r}/w_{r} \\
y_{r}/w_{r} \\
z_{r}/w_{r} \\
1
\end{bmatrix}
\equiv
\begin{bmatrix}
u/w_{r} \\
v/w_{r} \\
1/w_{r} \\
x_{s} \\
y_{s} \\
z_{s} \\
1
\end{bmatrix} u v 1 x q y q z q 1 transform u v 1 x r y r z r w r homogenize u / w r v / w r 1/ w r x r / w r y r / w r z r / w r 1 ≡ u / w r v / w r 1/ w r x s y s z s 1
实际上,上述推测很容易证明:
f ( x 1 , x 2 , ⋯ , x n ) = ∑ i = 1 n k i x i + b = [ k 1 , ⋯ , k n , b ] [ x 1 ⋮ x n 1 ] = [ k 1 , ⋯ , k n , b ] M − 1 [ x 1 ′ ⋮ x n ′ 1 ′ ] = ∑ i = 1 n k i ′ x i ′ + b ′ 1 ′ ⇒ f 1 ′ = ∑ i = 1 n k i ′ x i ′ 1 ′ + b ′ \begin{aligned}
f(x_{1},x_{2},\cdots,x_{n})
&=\sum_{i=1}^{n}k_{i}x_{i} + b = [k_{1},\cdots,k_{n},b]
\begin{bmatrix}
x_{1} \\
\vdots \\
x_{n} \\
1
\end{bmatrix} \\
&=[k_{1},\cdots,k_{n},b]\mathbf{M}^{-1}
\begin{bmatrix}
x_{1}' \\
\vdots \\
x_{n}' \\
1'
\end{bmatrix} \\
&=\sum_{i=1}^{n}k_{i}'x_{i}' + b'1'
\end{aligned} \\
\Rightarrow \frac{f}{1'} = \sum_{i=1}^{n}k_{i}'\frac{x_{i}'}{1'} + b' f ( x 1 , x 2 , ⋯ , x n ) = i = 1 ∑ n k i x i + b = [ k 1 , ⋯ , k n , b ] x 1 ⋮ x n 1 = [ k 1 , ⋯ , k n , b ] M − 1 x 1 ′ ⋮ x n ′ 1 ′ = i = 1 ∑ n k i ′ x i ′ + b ′ 1 ′ ⇒ 1 ′ f = i = 1 ∑ n k i ′ 1 ′ x i ′ + b ′
其中,射影变换 M \mathbf{M} M 将 [ x 1 , ⋯ , x n , 1 ] T [x_{1},\cdots,x_{n},1]^{T} [ x 1 , ⋯ , x n , 1 ] T 变换为 [ x 1 ′ , ⋯ , x n ′ , 1 ′ ] T [x_{1}',\cdots,x_{n}',1']^{T} [ x 1 ′ , ⋯ , x n ′ , 1 ′ ] T 。
又因为平面上的一次函数经正投影到屏幕上之后仍为一次函数,因此齐次化后点的属性值可直接在屏幕上对三角形线性内插来计算。为了得到原始属性值,还需要知道用于齐次化的除数,这可以通过常函数 f ≡ 1 f\equiv 1 f ≡ 1 得到。
平面上的函数做正投影,指的是对自变量——平面上的点——做正投影变换。
将这些因素整合进观测变换后、齐次化前的代码中:
for all x s do for all y s do compute ( α , β , γ ) for ( x s , y s ) if ( α ∈ [ 0 , 1 ] and β ∈ [ 0 , 1 ] and γ ∈ [ 0 , 1 ] ) then u s = α ( u 0 / w 0 ) + β ( u 1 / w 1 ) + γ ( u 2 / w 2 ) v s = α ( v 0 / w 0 ) + β ( v 1 / w 1 ) + γ ( v 2 / w 2 ) 1 s = α ( 1 / w 0 ) + β ( 1 / w 1 ) + γ ( 1 / w 2 ) u = u s / 1 s v = v s / 1 s drawpixel ( x s , y s ) with color texture ( u , v ) \begin{aligned}
&\textbf{for}\ \text{all}\ x_{s}\ \textbf{do} \\
&\quad\begin{aligned}
&\textbf{for}\ \text{all}\ y_{s}\ \textbf{do} \\
&\quad\begin{aligned}
&\text{compute}\ (\alpha, \beta, \gamma)\ \text{for}\ (x_{s}, y_{s}) \\
&\textbf{if}\ (\alpha\in[0,1]\ \text{and}\ \beta\in[0,1]\ \text{and}\ \gamma\in[0,1])\ \textbf{then} \\
&\quad\begin{aligned}
&u_{s} = \alpha(u_{0}/w_{0}) + \beta(u_{1}/w_{1}) + \gamma(u_{2}/w_{2}) \\
&v_{s} = \alpha(v_{0}/w_{0}) + \beta(v_{1}/w_{1}) + \gamma(v_{2}/w_{2}) \\
&1_{s} = \alpha(1/w_{0}) + \beta(1/w_{1}) + \gamma(1/w_{2}) \\
&u = u_{s}/1_{s} \\
&v = v_{s}/1_{s} \\
&\text{drawpixel}\ (x_{s}, y_{s})\ \text{with color texture}\ (u, v)
\end{aligned}
\end{aligned}
\end{aligned}
\end{aligned} for all x s do for all y s do compute ( α , β , γ ) for ( x s , y s ) if ( α ∈ [ 0 , 1 ] and β ∈ [ 0 , 1 ] and γ ∈ [ 0 , 1 ]) then u s = α ( u 0 / w 0 ) + β ( u 1 / w 1 ) + γ ( u 2 / w 2 ) v s = α ( v 0 / w 0 ) + β ( v 1 / w 1 ) + γ ( v 2 / w 2 ) 1 s = α ( 1/ w 0 ) + β ( 1/ w 1 ) + γ ( 1/ w 2 ) u = u s / 1 s v = v s / 1 s drawpixel ( x s , y s ) with color texture ( u , v )
上述伪代码中有些计算可以提到循环外面以优化性能。
裁剪
栅格化之前必须裁剪 (clipping)图元,移除眼睛后面的部分,否则会产生错误结果。
裁剪是计算机图形学中的常见操作,常用于几何体互相切割的场景。
将所有溢出可视体 (view volume)的部分都裁掉总是安全的,但许多系统只做近平面裁剪。下面是两种最常见的裁剪方法:
在世界空间中,用相机可视体的六个平面裁剪;
齐次化之前,在齐次坐标构成的四维空间中裁剪。
它们都可以按如下流程实现:
for each of six planes do if (triangle entirely outside of plane) then break (triangle is not visible) else if (triangle spans plane) then clip triangle if (quadrilateral is left) then break into two triangles \begin{aligned}
&\text{\textbf{for} each of six planes \textbf{do}} \\
&\quad\begin{aligned}
&\text{\textbf{if} (triangle entirely outside of plane) \textbf{then}} \\
&\quad\text{break (triangle is not visible)} \\
&\text{\textbf{else if} (triangle spans plane) \textbf{then}} \\
&\quad\begin{aligned}
&\text{clip triangle} \\
&\text{\textbf{if} (quadrilateral is left) \textbf{then}} \\
&\quad\text{break into two triangles}
\end{aligned}
\end{aligned}
\end{aligned} for each of six planes do if (triangle entirely outside of plane) then break (triangle is not visible) else if (triangle spans plane) then clip triangle if (quadrilateral is left) then break into two triangles
变换前裁剪
在世界空间中裁剪需要知道围成可视体的 6 个平面的方程。由于所有三角形使用的都是这 6 个方程,因此无需非常高效地计算它们。一种计算方法是对变换后的可视体顶点做逆变换,然后再根据它们确定平面方程。比如,可以根据正交可视体的 8 个顶点来计算:
( x , y , z ) = ( l , b , n ) , ( r , b , n ) , ( l , t , n ) , ( r , t , n ) , ( l , b , f ) , ( r , b , f ) , ( l , t , f ) , ( r , t , f ) \begin{aligned}
(x,y,z) =\ &(l,b,n),\ (r,b,n),\ (l,t,n),\ (r,t,n), \\
&(l,b,f),\ (r,b,f),\ (l,t,f),\ (r,t,f)
\end{aligned} ( x , y , z ) = ( l , b , n ) , ( r , b , n ) , ( l , t , n ) , ( r , t , n ) , ( l , b , f ) , ( r , b , f ) , ( l , t , f ) , ( r , t , f )
当然,也可以根据观测参数直接计算平面方程。
齐次坐标裁剪
以正交可视体为例,6 个平面的齐次坐标方程为:
− x + l w = 0 x − r w = 0 − y + b w = 0 y − t w = 0 − z + n w = 0 z − f w = 0 \begin{aligned}
-x + lw &= 0 \\
x - rw &= 0 \\
-y + bw &= 0 \\
y - tw &= 0 \\
-z + nw &= 0 \\
z - fw &= 0
\end{aligned} − x + lw x − r w − y + b w y − tw − z + n w z − f w = 0 = 0 = 0 = 0 = 0 = 0
这些方程相当简单,效率也比第一种方法更高,而且还可以继续变换到规范可视体中进一步优化性能。正因为如此,裁剪通常用齐次坐标实现。
平面裁剪
平面方程具有如下形式:
f ( p ⃗ ) = n ⃗ ⋅ p ⃗ + D = 0 f(\vec{p}) = \vec{n}\cdot\vec{p} + D = 0 f ( p ) = n ⋅ p + D = 0
上述方程可用于表示任意维空间中的超平面。通常约定,f ( p ⃗ ) < 0 f(\vec{p})<0 f ( p ) < 0 表示平面内侧 (inside),f ( p ⃗ ) > 0 f(\vec{p})>0 f ( p ) > 0 表示平面外侧 (outside);也可以认为,通常选取函数 f ( p ⃗ ) f(\vec{p}) f ( p ) 使得负值表示想要的区域,正值表示要被裁掉的区域。对于点 a ⃗ \vec{a} a 、b ⃗ \vec{b} b 间的线段:
p ⃗ = a ⃗ + t ( b ⃗ − a ⃗ ) \vec{p} = \vec{a} + t(\vec{b} - \vec{a}) p = a + t ( b − a )
可以计算出与平面的交点:
t = n ⃗ ⋅ a ⃗ + D n ⃗ ⋅ ( a ⃗ − b ⃗ ) t = \frac{\vec{n}\cdot\vec{a} + D}{\vec{n}\cdot(\vec{a} - \vec{b})} t = n ⋅ ( a − b ) n ⋅ a + D
进而裁剪线段。三角形的裁剪方法将在后面章节描述。
栅格化前后的操作
在对图元栅格化之前,需要先把顶点变换到屏幕空间,并准备好所有属性,如颜色、纹理坐标等。这一阶段称为顶点处理阶段 (vertex-processing stage)。
栅格化之后,还需要进一步处理才能计算出每个片段的颜色和深度值。可以直接使用栅格器算出的颜色和深度值,也可以进行更复杂的着色操作。这一阶段称为片段处理阶段 (fragment-processing stage)。
最后,在片段混合阶段 (fragment blending stage)将不同图元生成的重叠的片段组合起来,计算出最终的颜色。最常用的混合方法是选择深度值最小的片段颜色。
简单 2D 绘制
最简单的图形流水线是,应用程序直接提供像素坐标,顶点处理阶段和片段处理阶段不做任何事情,片段混合阶段按先后顺序覆盖片段颜色,所有工作全部由栅格器完成。许多绘制 UI、图表、图形和其它 2D 内容的老式 API 都遵循这一流程。
极小 3D 流水线
在 2D 绘制流水线的顶点处理阶段中增加一个矩阵变换便可以画出 3D 物体。为了在这种 3D 流水线中保证遮挡关系正确,必须按从后到前的顺序绘制图元,这也称为隐面消除的画家算法 (painter's algorithm)。
然而这一算法有如下缺点:
处理不了三角形互相交叉,或循环遮挡的场景。
根据深度值排序很慢,尤其是大场景,这扰乱了物体序渲染中高效的数据流。
z-buffer
实际上,更常用的隐面消除方法是 z 缓冲算法 (z-buffer algorithm):记录每个像素到最近曲面的距离,在片段混合阶段,抛弃掉那些远于这一距离的片段。每个片段到曲面的距离称为深度值 (depth)或 z 值 (z-value),相应的缓冲称为深度缓冲 (depth buffer)或 z 缓冲 (z-buffer)。深度值可以通过对 z 坐标内插来得到,即,z 坐标作为顶点属性。
深度缓冲的初始值为最大深度,即远平面的深度。如果参与混合的片段深度值更小,就用它的颜色和深度覆盖当前缓冲值,否则就抛弃掉该片段。可以看出,这一算法的结果不依赖于绘制顺序。
由于简单实用,z-buffer 已经成为隐面消除的主流方法,而且被硬件图形流水线普遍支持,在软件流水线中也得到广泛应用。
为保证读写速度,深度值一般用非负整数存储,而且占据比特数尽可能少。b b b 个比特的深度对应取值范围 { 0 , 1 , . . . , 2 b − 1 } \{0,1,...,2^{b}-1\} { 0 , 1 , ... , 2 b − 1 } ,每个离散值都对应一个宽度为 Δ z = ( f − n ) / 2 b \Delta z=(f-n)/2^{b} Δ z = ( f − n ) / 2 b 的桶(bucket)。为分辨物体的前后顺序,Δ z \Delta z Δ z 应尽可能小:
让 n n n 和 f f f 尽可能接近;
增大 b b b 。
这里的 n n n 和 f f f 均为正数。
由于图形 API 和硬件平台可能已经固定了 b b b 的大小,因此调整 n n n 、f f f 是唯一的选择。
生成透视图像时,深度值的精度必须仔细处理。由透视变换 z = n + f − f n / z w z=n+f-fn/z_{w} z = n + f − f n / z w 可知:
Δ z w ≈ z w 2 Δ z f n \Delta z_{w} \approx \frac{z_{w}^{2}\Delta z}{fn} Δ z w ≈ f n z w 2 Δ z
其中,z w z_{w} z w 是世界空间深度 (world depth),z z z 是投影后深度 (post-perspective divide depth)。易知:
Δ z w max ≈ f Δ z n \Delta z_{w}^{\text{max}} \approx \frac{f\Delta z}{n} Δ z w max ≈ n f Δ z
根据上式可知,n = 0 n=0 n = 0 是一个非常差的选择,虽然它把眼睛前方的所有物体都展示出来,但却导致 Δ z w max \Delta z_{w}^{\text{max}} Δ z w max 趋于无穷大。为了让 Δ z w max \Delta z_{w}^{\text{max}} Δ z w max 尽可能小,需要减小 f f f 、增大 n n n 。
逐顶点着色(Per-vertex Shading)
为了在 3D 场景中引入光源,可以由应用层输入顶点法向量,并分别提供光源位置、颜色。在顶点处理阶段,每个顶点根据自身坐标、相机位置以及光源计算出视向和光线方向。然后再根据着色模型计算出顶点颜色,并传给栅格器。逐顶点着色也称为 Gouraud 着色 (Gouraud shading)。
着色计算可以在世界空间,也可以在相机空间,只要坐标系是正交的就可以。但是相机空间具有如下优势:无需记录相机位置或视方向,对于透视投影,相机始终位于原点,对于正交投影,视方向始终沿 + z +z + z 方向。
逐顶点着色无法处理比图元本身更精细的结构。由于镜面反射光的强度变化可能很快,因此光泽曲面必须使用足够小的图元才能分辨这种光强变化。
逐片段着色(Per-fragment Shading)
为避免颜色插值,可以在插值后的片段处理阶段进行着色计算。逐片段着色需要每个片段根据插值矢量计算着色模型。着色所需的几何信息作为属性传过栅格器。这需要顶点处理阶段和片段处理阶段配合起来,以准备合适的数据。比如,对相机空间中的表面法向量和顶点位置内插。
逐片段着色有时也称为 Phong 着色 (Phong shading),但可能会和同名着色模型混淆。
纹理映射(Texture Mapping)
纹理 (texture)就是用来为物体表面增加额外细节的图像。每次着色时,从纹理中读取一个值,用于着色计算。这一操作称为纹理查询 (texture lookup):着色代码指定一个纹理坐标 (texture coordinate),也就是纹理域中的一个点,然后纹理映射系统找到纹理图像中该点的值。
定义纹理坐标的最常用的方式就是将纹理坐标作为顶点属性,这样就知道每个图元在纹理中的位置。
着色频率(Shading Frequency)
在哪个阶段进行着色计算取决于颜色变化的速度,即模型的精细尺度 (scale of detail)。像漫反射这种大尺度特征,可以低频率计算着色;而锐利高光、细致纹理这种小尺度特征,可以高频率计算着色。对于那些即使在图像中也足够清晰锐利的细节,甚至需要逐像素着色。
因此,大尺度特征可以直接在顶点处理阶段计算。如果顶点在图像中足够接近,小尺度特征也可以在该阶段处理。如果图元覆盖多个像素,小尺度特征则需在片段处理阶段计算。例如,计算机游戏为保证高效,通常使用覆盖多个像素的图元,并逐片段计算着色;而 PhotoRealistic RenderMan 系统把所有曲面切割 (dicing)成像素大小的微多边形 (micropolygons)之后,逐顶点计算着色。
抗锯齿(Antialiasing)
不管是射线追踪还是栅格系统,只要采用 all-or-nothing 规则来确定图元内的像素,都会产生锯齿状图像。实际上,前面所描述的三角形栅格化算法有时也称为标准栅格化 (standard rasterization)或锯齿形栅格化 (aliased rasterization)。解决这一问题的方法是允许像素被图元部分覆盖 。这种模糊化 (blurring)有助于改善视觉效果,尤其是在动画中。
生成抗锯齿图像的一种方法是,设置像素值为所覆盖方形区域的平均颜色,这种方法称为箱式滤波 (box filtering)。这意味着每个需绘制的图形都应该占据明确的区域,比如,把线段当做一定宽度的矩形。
箱式滤波的最简单的实现方式是过采样 (supersampling,也称为超采样):以更高的分辨率生成图像,然后再降采样 (downsample)。然而,过采样非常耗时。由于锯齿一般产生于图元边界,一种常用的优化方法是,用比着色更高的速率对可见性 (visibility)采样。对于逐顶点着色的系统,比如 RenderMan,由于片段处理只做颜色内插,因此高分辨率栅格化可以有效地实现抗锯齿;对于逐片段着色的系统,比如硬件流水线,多重采样抗锯齿 (multisample antialiasing)可以通过为每个片段存储一个颜色值、一个覆盖率 (coverage)和一组深度值来实现。
图元剔除
物体序渲染可能浪费过多时间去处理那些看不见的几何体,比如被遮挡的物体、相机背后的物体等。所谓剔除 (culling),就是找出并抛弃那些看不见的几何体。下面是三种常用策略:
可视体剔除 (view volume culling),移除可视体之外的物体。
遮挡剔除 (occlusion culling),移除可视体内被其它物体遮挡的几何体。
背面剔除 (backface culling),移除背对相机的图元。
剔除图元的速度必须要快,否则所花时间可能比栅格器裁剪的还要长。
可视体剔除,也称为视锥剔除 (view frustum culling)。如果三角形组成的是一个带有包围体 (bounding volume)的物体,可视体剔除特别有用。但是包围体是一种保守的判别方式,具体效果取决于包围体与物体本身有多贴合。如果使用了某些空间数据结构来组织几何数据,还可以级联地应用包围体判别。
而背面剔除则适用于多边形围成的封闭模型。由于背对相机的多边形一定会被面朝相机的覆盖,因此可以在进入流水线之前剔除掉这些多边形。