Skip to content

Latest commit

 

History

History
387 lines (262 loc) · 36.1 KB

9 - 图形管线.md

File metadata and controls

387 lines (262 loc) · 36.1 KB

[!note] 123 (Vertex Shader) => (Geometry Shader) => Clip Space => (透视除法) => NDC => (视口变换) => Window Space => (Fragment Shader)


9 - 图形管线

前面几章已经建立了数学脚手架,我们需要看看渲染的第二个主要方法(光栅渲染):将对象一个接一个地绘制到屏幕上或对象顺序渲染。 在光线追踪中,我们依次考虑每个像素并找到影响其颜色的对象,而在光栅渲染中,我们将依次考虑每个几何对象并找到可能对其产生影响的像素。 寻找图像中被几何原语占用的所有像素的过程称为光栅化,因此对象顺序渲染也可以称为光栅化渲染。所需的操作序列,从对象开始,以更新图像中的像素结束,被称为图形管线。

基于光栅化渲染的渲染器也叫扫描线渲染器。

对象顺序渲染由于其高效率而获得了巨大的成功。对于大型场景,数据访问模式的管理对性能至关重要,并且相比于重复检索场景以获得像素所指向的物体,对整个场景做一次扫描所有几何体显然更有优势。

本章的标题表明,只有一种方法可以实现对象顺序显然。当然,这是不正确的——两个有着不同目标的完全不同的图形管道的例子是:

  1. 通过 OpenGL 和 Direct3D 等 api 支持交互渲染的硬件管线。硬件管道必须运行得足够快,以便对游戏、可视化和用户界面做出实时反应。
  2. 用于电影制作的支持 RenderMan 等 api 的软件管线。制作流程必须尽可能渲染最高质量的动画和视觉效果,并按比例缩放到巨大的场景,但可能会花费很多时间。

对象顺序渲染中需要完成的工作可以依据光栅化任务本身来整理:光栅化之前对几何图形的操作,以及光栅化后对像素的操作。 最常见的几何操作是应用前两章中讨论的矩阵变换,以将定义几何图形的点从对象空间映射到屏幕空间,因此,光栅化器的输入是以像素坐标或屏幕空间来表示的。 最常见的像素化操作是隐藏面去除,将更接近观察者的表面布置为远离观察者的表面的前方。在每个阶段也可以包括许多其他操作,从而使用相同的通用过程来实现各种不同的渲染效果。 几何对象从交互应用程序或场景描述文件被馈入流水线,在顶点处理阶段对顶点进行操作,然后将使用这些顶点的基本体发送到光栅化阶段。光栅化器将每个原语几何分解成多个片元,每个片元对应于几何原语覆盖的每个像素。片元在片元处理阶段被处理,然后与每个像素相对应的各个片段在片段混合阶段被合并。

9.1 光栅化

光栅化是对象顺序图形中的核心操作,而光栅化器是任何图形流水线的核心。对于进入的每个基元,光栅化器有两个工作:

  1. 枚举基元覆盖的像素
  2. 为基元覆盖的每个像素插值出它所对应的属性值

这些属性的用途将在后面的示例中解释。光栅化的输出是一组片元,每个片元代表一个特定的像素,并带有它自己的一组属性值。

在本章中,我们将介绍光栅化。同样的光栅化方法用于绘制线段和 2D 形状——尽管使用 3D 图形系统“在幕后”完成所有 2D 绘制已经变得越来越普遍。

9.1.1 绘制线段——使用隐式直线方程

大多数图形包都包含一个线条绘制命令,它在屏幕坐标中获取两个端点,并在它们之间绘制一条线。对于一般的屏幕坐标端点(x0, y0)和(x1, y1),例程应该绘制一些“合理”的像素集,以近似于它们之间的一条线。绘制这样的直线是基于直线方程的,我们有两种类型的方程可供选择:隐式方程和参数方程。本节描述使用隐式方程的方法。

使用隐式方程绘制直线的最常用方法是中点算法。中点算法最终画出与 Bresenham 算法相同的线,但它在某种程度上更直接。直线的隐式方程: $$f(x,y)=(y_0-y_1)x+(x_1-x_0)y+x_0y_1-x_1y_0\quad and \quad x_0\le x_1$$ 直线的斜率为:$$m=\frac{y_1-y_0}{x_1-x_0}$$ 接下来假设$m\in(0,1]$,因为其他情况可以推导出类似的结论。

中点算法的关键假设是我们尽可能画出没有“gap”的最细的线。两个像素之间的对角连接不被认为是间隙。 当这条线从左端点向右前进时,只有两种可能性:将一个像素绘制在其左侧(相同的高度),或者将像素绘制得更高(对角位置,但不可以绘制在上方)。 端点之间的每列像素中始终恰好有一个像素。零表示间隙,两个像素太粗。 同一行中可能有两个像素:这条线可能更倾向于 x 轴,所以有时它是向右的,有时是向上的。

中点算法首先建立最左边的像素和最右边的像素的列号(x 值),然后循环水平地建立每个像素的行(y 值)。 关键是在 if 语句中建立有效的方法来做出决定。做出选择的有效方法是查看两个潜在像素中心之间的直线的中点。两个候选像素的中心之间的中点是(+1,y+1)。Y+0.5)。如果直线低于该中点,则绘制底部像素,否则,绘制顶部像素。 为了确定直线通过(x+1,+0.5)的上方还是下方,我们计算 f(x+1,+0.5)。公式中 y 项地系数是正的,意味着当 f(x,y)>0 时,点位于直线的上方。

if (f(x + 1, y + 0.5) < 0) {  //f<0时,测试点在直线下方,意味着要向上提升一格
	y++;
}

上面的代码可以很好地处理适当斜率的线(即,在 0 和 1 之间)。读者可以解决其他三种情况,它们只有在小细节上有所不同。

如果想要更高的效率,可以使用增量方法。增量方法试图通过重复使用前一步的计算来使循环更有效。在所给出的中点算法中,主要计算是对 f(+1,y+0.5)的求值。在循环内,在第一次迭代之后,我们已经计算了 f(x-1,y+0.5)或 f(x-1,y+0.5,Y-0.5),下步计算的增量关系为: $$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)$$ 代码如下: 这个代码应该运行得更快,因为它与非增量版本相比几乎没有额外的设置成本(对于增量算法来说并不总是正确的),但是它可能累积更多的数值错误,因为对于一些长的线段,f(x,y+0.5)的求值可能由许多加法组成。然而,考虑到线段很少比千几像素还要长,这样的错误不太可能是严重的。 通过将(x1-xo)+(yo-y1)和(yo-y1)存储为变量,可以实现稍微高一些的设置成本,但更快的循环执行。

9.1.2 三角形光栅化

我们想用三个点$p_0p_1p_2$在屏幕坐标上画一个三角形。这和绘制直线的问题类似,但是有它独有的子问题。在绘制时,我们希望利用图元端点插值计算出像素的属性(例如颜色)。如果利用中心公式,这个问题便迎刃而解了。 假使三角形图元内的 p 点拥有重心坐标$(\alpha,\beta,\gamma)$,那么 p 点的属性 c 为: $$c=\alpha c_0+\beta c_1+\gamma c_2$$ 这种类型的颜色插值在图形中被称为 Gouraud 插值,以其发明者命名(Gouraud, 1971)。

栅格化三角形的另一个微妙之处是,我们经常会栅格化那些共享边线和顶点的三角形。这意味着我们想要栅格化相邻的三角形,并且邻接的部分没有孔洞。 我们可以通过使用中点算法绘制每个三角形的轮廓,然后填充内部像素来做到这一点。这意味着相邻三角形沿每条边绘制相同的像素。如果相邻三角形的颜色不同,则图像将取决于绘制这两个三角形的顺序。

避免顺序问题和消除孔洞的栅格化三角形最常见的方法是使用当且仅当像素的中心位于三角形内部时才绘制像素的惯例。也就是说,像素中心的重心坐标都在(0,1)区间内。 这就产生了一个问题,如果中心正好在三角形的边缘上该怎么办。有几种方法可以处理这个问题,本节稍后将对此进行讨论。 关键的观察是,质心坐标允许我们决定是否绘制像素,以及如果我们从顶点插值颜色,像素应该是什么颜色。 因此,我们对三角形光栅化的问题归结为有效地找到像素中心的质心坐标。蛮力光栅化算法是: 对算法的改进主要有

  1. 将外部循环限制在较小的候选像素集上:确定三角形的矩形边界(最小外接矩形),然后只考虑矩形区域内部的候选点。
  2. 使以重心为中心的计算变得高效:应用 2 章提到的重心坐标求解公式。

接下来讨论了关于算法的增量形式: 大致的意思是:$(\alpha,\beta,\gamma)$的计算可以用增量式,因为在内部循环中只有 x 在变换(x++;),显然$(\alpha,\beta,\gamma)$每一次增加一个固定的量。 如果$(\alpha,\beta,\gamma)$是增量型的,那么像素的属性 c 也应该是增量型的。 再看外部循环,由于内部循环的$(\alpha,\beta,\gamma)$以恒定增量变换,外部循环也可以写成增量型的。


接下来考虑在三角形的边缘绘制像素

我们仍然没有讨论中心恰好在三角形边缘的像素做什么。如果一个像素恰好位于三角形的边缘,那么它一定落于其邻接三角形的边线上(如果有的话)。没有明显的方法可以将像素授予一个三角形或另一个三角形。最差的决定是不绘制像素,这样会导致两个三角形之间产生一个洞。更好但仍然不好的是,两个三角形都绘制像素。如果三角形是透明的,这将导致双色。 我们真的希望将像素授予恰好其中一个三角形,我们希望这个过程很简单;只要很好地定义选择,选择哪个三角形并不重要。

一种方法是:任何屏幕外点肯定在我们将要绘制边线的一侧。对于非共享边上的三角形顶点,他们一定位于共享边的两侧(下图中 a、b 两点在共享边的两侧),其中有一个顶点与屏幕外点位于边线的同侧(下图中 a 与外点同侧)。我们可以直接计算下列式子。如果这个式子成立,说明 a 与外点同侧: $$f(a)\cdot f(外点)&gt;0$$ 请注意这个方法并不完美,因为通过边缘的线也可能穿过屏幕外点,但我们至少大大减少了有问题的案例的数量。使用哪个屏幕外点是任意的。我们只需要对恰好在边缘上的外点的情况添加额外的检查。我们希望对于常见的情况,即完全内部或外部测试,不会达到此检查:

//我的写法和他不太一样
//完全内部和完全外部的情况远大于在线上的情况
//应该先判完全内部和完全外部
calculate(\alpha, \beta, \gamma)
if(\alpha>0 and \beta>0 and \gamma>0){  //完全内部
	drawPixel();
}else if(\alpha<0 or \beta<0 or \gamma<0){  //完全外部
	continue;
}else{  //在边线上
	if(和某条线关于外点同侧){
		drawPixel();
	}
}

我们可能期望只有当我们对两个三角形使用完全相同的线方程时,上述代码才会努力消除孔洞和双绘制。事实上,只有当两个共享顶点在绘图调用中具有相同的顺序时,线方程才相同。否则,方程可能会翻转符号。 这可能是一个问题,具体取决于编译器是否改变了操作的顺序。因此,如果需要健壮的实现,可能需要检查编译器和算术单元的细节。上述伪代码中的前四行必须仔细编码,以处理边缘恰好命中像素中心的情况。

除了适应增量实现之外,还有几个潜在的早期退出点。例如,如果 α 为负,则不需要计算 β 或 γ。虽然这可能会导致速度的提高。但是额外的分支可能会减少流水线或并发性,并可能减慢代码的速度。因此,如果代码位于系统的关键部位,那么可以多试试几种可能的优化方法。

上述代码的另一个细节是退化三角形的分母可能为零,即如果 fγ=0。要么考虑如何适应浮点数的误差,要么需要另一个测试。

9.1.3 透视矫正插值

在插值时实现正确的透视效果,这个过程中有一些子问题。我们将用纹理坐标作为例子告诉大家,插值时作透视矫正很重要,这样的考虑方式同样适用于 3D 空间中的任何属性。

原因很简单,只是在屏幕空间中插值纹理坐标会导致不正确的图像,如图 9.7 中的网格纹理所示。由于透视中的事物随着与观看者的距离的增加而变小,因此在 3D 中均匀间隔的行应该在 2D 图像空间中随距离的增加而压缩。 我们可以通过插值(u, v)坐标来实现三角形的纹理映射,但这导致了上图右侧所示的问题。如果使用屏幕空间重心坐标,则三角形会出现类似的问题。

为了解开基本问题,让我们考虑从世界空间 q 到齐次点 r 到齐次点 s 的进展:

世界坐标下的 q 点,做透视投影变换到齐次点 r,在求出与齐次点等价的 3 维点 s。

纹理坐标插值问题的最简单形式是我们有两个 3 维空间中的点 q 和 Q,他们的投影分别时 s 和 S,另外,在 qQ 直线上存在一点 q’,它的投影称 s',也应当在 sS 直线上。现在,q'和 s‘应当有相同的纹理坐标。

原始的屏幕空间的方法(上述算法中体现),称点 s’:$s'=s+\alpha(S-s)$,应当有纹理坐标(u,v):$(u_s',v_s')=(u_s+\alpha(u_S-u_s),v_s+\alpha(v_S-v_s))$。然而实际上是错误的,因为屏幕空间上的 s',它所对应的世界空间 q',并非$q+\alpha(Q-q)$。

然而实际上,sS 上的 s'确实能在 qQ 上找到其对应点(只不过比例比一定一样罢了)。我们称: $$q+t(Q-q) \to s+\alpha(S-s)$$ 事实上我们能从一个参数求出另一个参数(例如求 t):

这个方程为在屏幕空间做插值提供了一思想。为了获得屏幕空间点 S‘=S+a(S-S)的纹理坐标,计算 us+t(A)(us-us)和 vs+t(A)(vs-vs)。这个坐标是 s'对应世界空间下 q'得坐标,因此这是可行的。然而,对于每个片元求值很慢,并且有一种更简单的方法。

[!warning] 透视矫正插值专题 透视矫正插值专题:关于透视矫正插值

关键点是,透视变换保留线和面,所以在三角形内线性内插任何我们想要的属性是可以的,但只有当它们(属性)与点一起经历透视变换

[!NOTE] 让属性与点一起经历透视变换时 让属性与点一起经历透视变换时 > $$s_{homo}=M\cdot p_{homo}=\begin{bmatrix} 1&0&0&0&0\0&n&0&0&0\0&0&n&0&0\0&0&0&n+f&-nf\0&0&0&1&0 \end{bmatrix}\cdot\begin{bmatrix} Attribute\x\y\z\1 \end{bmatrix}=\begin{bmatrix} Attribute\nx\ny\z(n+f)-nf\z \end{bmatrix}$$ 将属性 attr 加到变换后的齐次坐标中(非空间相关的属性在投影时不变): $$s_{homo}=\begin{bmatrix} Attribute\nx\ny\z(n+f)-nf\z \end{bmatrix}\to\begin{bmatrix} \frac{Attribute}{z}\ \frac{nx}{z}\ \frac{ny}{z}\ (n+f)- \frac{nf}{z}\ 1 \end{bmatrix}$$

为了更直观得理解这种几何推理,我们在 2 维空间上分析,我们有齐次点(xr,yr,wr)和一个属性 u。属性 u 被假定为 xr 和 yr 的线性函数,因此如果我们将 u 绘制为(xr,yr)上的高度场,结果会是一个平面的。现在,如果我们将 u 视为第三个空间坐标(称为 u,强调它与其他点一样对待),并让整个 3D 齐次点(xr,yr,ur,w)通过透视变换,结果(xs,ys,us)仍然生成位于平面上的点。平面内会有一些翘曲,但保持平坦。这意味着 us 是(xs,ys)的线性函数。也就是说,我们可以在任何地方通过基于坐标(xs,ys)的线性插值法计算 us。

回到完整的问题,我们需要对纹理坐标(u,v)进行内插,这些纹理坐标是世界空间坐标(x,y,z)的线性函数。在将这些点变换到屏幕空间之后,将纹理坐标添加为附加坐标,我们就得到了: 上一段的实际含义是我们可以根据屏幕空间的坐标插值出原空间所有属性(包括 z 缓冲区中会使用到的深度信息)。原始方法(直接使用屏幕空间的比例插值)的问题是,我们正在对选择不一致的组件进行插值——只要涉及的属性来自透视划分前后,所有组件都将很好。

这句话的意思是:插值属性时,要么都使用变换前的属性和比例,要么都使用变换后的属性和比例。这样插值出来的属性一定不会错。 原来直接使用屏幕空间比例进行插值的方法错在:它使用变换后的比例,和变换前的属性。

剩下的一个问题是 (u/wr, v/wr ) 不能直接用于查找纹理数据;我们需要 (u, v)。这解释了我们加入 (9.3) 的额外参数的目的,其值始终为 1:一旦我们有 u/wr,v/wr 和 1/wr,我们可以通过划分轻松恢复 (u, v)。

它在(u, v)坐标下加了一位变成(u, v, 1),在透视除法下,1 会变成(1 / wr),这个参数存储了 wr,用来恢复 u 和 v 的原始值。

这种在变换空间中以无误差线性插值 1/wr 的能力使我们能够正确纹理三角形。我们可以使用这些理论来修改扫描转换代码: 当然,这个伪代码中出现的许多表达式将在循环之外预先计算以加快速度。

在实践中,现代系统以透视正确的方式插入所有属性,除非特别要求其他一些方法。

9.1.4 裁切

简单地将图元转换为屏幕空间并栅格化它们并不是很有效。这是因为视图体积之外的图元——特别是视图体积后面的图元——最终可以被栅格化,从而导致不正确的结果。 例如,两个顶点在视图体积中,但第三个顶点在视图体积后面的图元。投影变换将此顶点映射到远平面后面的无意义位置,如果允许发生这种情况,三角形将被错误地栅格化。出于这个原因,光栅化必须在裁剪操作之后,这删除可以延伸到眼睛后面的部分图元。

意思是:上图中(摄像机朝向 z+方向,n 和 f 为正)c 点位于近平面之前,甚至伸到了 z 轴负半轴的位置,做完透视变换,c 点的坐标会大于 n+f,这使得 c 点处于场上所有几何体之后(z 深度比场上所有的几何体都要大)。

在裁剪时,“错误”部分是视图体积之外的部分。裁剪视图体积之外的所有几何图形总是安全的——也就是说,裁剪到体积的所有六个面——但许多系统仅裁剪到近平面。 实现裁剪的两种最常见的方法是:

  1. 在世界坐标中,使用视图截锥体的六个平面。
  2. 在透视除法之前的 4D 变换空间中裁切。

对于每个三角形:

for each of six planes do {
	if((triangle entirely outside of plane){
		break;
	}else if (triangle spans plane){
		clip triangle;
		if (quadrilateral is left){
			break into two triangles;
		}
	}
}

1 - 在透视变换之前裁切

直接对视图体积的 8 个顶点($[l,r]\times[b,t]\times[n,f]$)做逆透视变换,再求解出 6 个平面的方程。

2 - 在变换后的齐次坐标中裁切

常见的实现手段是在透视除法前在齐次坐标中裁剪。在这里,视图体积为 4D,受 3D 体积(hyperplanes)的限制。他们是: $$\begin{cases} -x+lw=0\x-rw=0\-y+bw=0\y-tw=0\-z+nw=0\z-fw=0 \end{cases}$$ 这些平面非常简单,因此效率优于选项 1。通过将视图体积 [l, r] × [b, t] × [f, n] 转换为 [0, 1]^3 仍然可以改进它们。事实证明,三角形的裁剪在 3D 中并没有更复杂多少。

对平面进行裁切

无论我们选择哪种选项,我们必须针对平面进行裁剪。回忆起平面的隐式方程: $$f(p)=n\cdot (p-q)=n\cdot p+D=0$$ 有趣的是,这个方程不仅描述了 3D 平面,而且还描述了 2D 中的一条线和 4D 中平面的体积模拟。所有这些实体通常在其适当的维度中称为平面。

这个是 3D 平面的表达式,但长得像 2D 直线的表达式(形如 kx+b),并且某种意义上是所有满足 4D 齐次坐标点乘为 0 的齐次点 p。

如果我们在点 a 和 b 之间有一个线段,我们可以使用第 12 节中描述的 BSP 树程序中切割 3D 三角形边缘的技术将其“裁切”到平面上。 在这里,测试点 a 和 b 以确定它们是否在平面 f (p) = 0 的相反两侧,通过检查 f (a) 和 f (b) 是否有不同的符号: $$f(a)\cdot f(b)&lt;0$$ 通常,f (p)<=0 定义为平面的“内部”,f (p)>0 是平面的“外部”。如果平面确实分裂了线,那么我们可以通过代入参数线方程来求解交点: $$f(p)=n\cdot (p-q)=n\cdot p+D=0\quad and \quad p=a+t(b-a)$$ 解得: $$t=\frac{n\cdot a+D}{n\cdot(a-b)}$$

9.2 在光栅化前后的操作

在栅格化图元之前,定义它的顶点必须位于屏幕坐标中,并且必须知道在原语之间进行插值的颜色或其他属性。 准备这些数据是管线顶点处理阶段的工作。在这个阶段,传入的顶点通过模型变换、视变换、投影变换和视图变换,将它们从原始坐标映射到屏幕空间。 同时,根据需要转换其他信息,如颜色、表面法线或纹理坐标。我们将在下面的例子中讨论这些附加属性。

在光栅化之后,进行进一步处理以计算每个片段的颜色和深度。这种处理可以像仅通过插值颜色并使用光栅化器计算的深度一样简单;或者它也可以涉及复杂的着色操作。最后,混合阶段将重叠每个像素的图元(可能是几个)产生的片元组合起来计算最终的颜色。

最常见的混合方法是选择深度最小的碎片的颜色(最接近眼睛)。

9.2.1 简单 2D 绘制

最简单的管道在顶点或片元阶段什么都不做,在混合阶段,每个片元的颜色覆盖前一个片元的值。应用程序直接在像素坐标中提供图元,而光栅化程序完成所有工作。

这种基本排列是许多用于绘制用户界面、绘图、图形和其他 2D 内容的简单、较旧的 API 调用。通过为每个基元的所有顶点指定相同的颜色,可以绘制纯色形状,我们的模型管道还支持使用插值法平滑地变色。

9.2.2 一个小型的 3D 流水线

为了在 3D 中绘制对象,对 2D 绘制流水线的唯一改变是一个变换矩阵:顶点处理阶段将输入的顶点位置乘以模型变换、视变换、投影变换和视窗变换的乘积,产生屏幕空间三角形,然后以与在 2D 中直接绘制它们相同的方式来绘制这些三角形。

最小 3D 流水线的一个问题是,为了获得正确的遮挡关系——图元必须以自后而前的顺序绘制。这样的算法被称为画家算法,用来移除被隐藏的面,类似于首先绘制绘画的背景,然后在上面绘制前景。

画家的算法是一种有效的删除隐藏表面的方法,但它有几个缺点:

  1. 它不能处理彼此相交的三角形,因为没有正确的绘制顺序。类似地,多个三角形,即使它们不相交,仍然可以排列在一个遮挡循环中,
  2. 另一种情况是不存在从后到前的顺序。

最重要的是,按深度对基元进行排序速度很慢,特别是对于大型场景,并扰乱了使对象顺序渲染如此之快的高效数据流。

9.2.3 使用 z-缓冲绘制物体阻挡

在实践中,很少使用画家的算法。相反,使用了一种简单有效的隐藏表面去除算法,称为 z 缓冲区算法。 该方法非常简单:在每个像素上,我们记录到到目前为止绘制过的最近表面的距离,我们丢弃那些离距离更远的片元。除了红色、绿色和蓝色值之外,通过为每个像素分配一个额外的值来存储最近的距离,称为深度或 z-缓冲。

z-buffer 算法是在片元混合阶段实现的,通过将每个片元的深度与存储在 z-buffer 中的当前值进行比较。如果片元的深度更接近,其颜色及其深度值都会覆盖当前颜色和深度缓冲区中的值。如果片元的深度更远,则丢弃。为了确保第一个片元将通过深度测试,zbuffer 被初始化为最大深度(远平面的深度)。无论绘制表面的顺序如何,相同的片段将赢得深度测试,图像将是相同的。

z-buffer 算法要求每个片元携带深度。这是通过将 z 坐标插值为顶点属性来完成的,这与颜色或其他属性进行插值的方式相同。

z-buffer 是一种简单且实用的方法来处理对象顺序渲染中的隐藏表面,直到现在它都是主要的方法。它比几何方法简单得多,几何法将表面切割成可以按深度排序的片段。而深度顺序只需要在像素的位置确定。它普遍支持硬件图形管道,也是软件管道最常用的方法。

精度问题

在实践中,存储在缓冲区中的 z 值是非负的整数。这比真正的浮点数更可取,因为 z 缓冲区所需的快速内存有些昂贵。

整数的使用可能会导致一些精度问题。如果我们使用一个有 B 个值的整数范围{0,1,…, B−1},我们可以将 0 映射到近裁剪平面 z = n,将 B−1 映射到远裁剪平面 z = f(这里的 n、z、f 都是正的)。我们将每个 z 值送到宽度为 Δz = (f−n)/B 的“桶”。如果内存不是很重要,我们就不会使用整数 z 缓冲区,因此使 B 尽可能小是有用的。 如果我们分配 b bit 来存储 z 值,那么$B=b^2$。我们需要足够的比特位来确保在一个三角形前面的任何三角形都能映射到不同的桶中。

例如,如果你正在渲染一个场景,其中三角形的间距至少为一米,那么 Δz < 1 应该产生没有伪影的图像。有两种方法可以使 Δz 变小:将 n 和 f 移得更近,或者增加 b。 如果 b 是固定的,就像在 api 或特定硬件平台上一样,调整 n 和 f 是唯一的选择。

在创建透视图像时,必须非常小心地处理 z 缓冲区的精度。上面的值 Δz 在透视除法之后使用。透视除法的结果是: $$z'=(n+f)- \frac{nf}{z}$$ 实际的分箱宽度应当与世界坐标下的 z 值有关,而不是透视变换后的 z 值。 $$dz'=fn,\frac{ dz}{z^2}$$ $$\triangle z'\approx fn, \frac{\triangle z}{z^2}$$ 世界空间中的分箱间距为: $$\triangle z\approx \frac{z^2,\triangle z'}{fn}$$ 正如上面讨论的那样,最大的箱子由 z=f 产生: $$\triangle z^{max}\approx \frac{f}{n},\triangle z'$$ 上述公式表示:如果我们希望让 n 尽可能地接近 0(这样能在摄像机前捕获尽可能多的物体),会导致最大分箱的宽度趋向正无穷,这意味着会有大量物体无法区分它们的前后关系。 一般地,我们会希望最大分箱宽度尽可能地小,使我们能更好地区分所有物体地远近关系。因此,仔细选择 n 和 f 总是很重要的。

9.2.4 逐顶点着色

到目前为止,将三角形发送到管线的应用程序负责设置颜色。光栅化器只是插值颜色,并且直接写入输出图像。但在许多情况下,我们希望利用我们在之前提及的渲染方程来对 3D 对象着色。 回想一下,这些方程需要光方向、摄像机方向和表面法线来计算表面的颜色。

处理阴影计算的一种方法是在顶点阶段执行它们。应用程序在顶点处提供法向量,并且分别提供光的位置和颜色(对于三角面上的光照信息、面法线,面上的顶点共享这些信息)。对于每个顶点,到观察者的方向和到每个光源的方向是基于相机、光源和顶点的位置计算的。利用阴影方程计算颜色,然后将其作为顶点颜色传递给光栅器。

顶点着色有时被称为 Gouraud 着色。

要做的一个决定是进行着色计算的坐标系统。世界空间或视空间都是不错的选择。当在世界空间中查看时,选择一个标准正交的坐标系是很重要的,因为阴影方程依赖于向量之间的角度,而这些角度不能通过非均匀尺度或透视投影等操作来保存。在视空间中着色有一个优点,我们不需要跟踪相机的位置,因为相机总是在视空间的原。

逐顶点着色的缺点是它不能在着色中产生任何比图元更小的细节,因为它只对每个顶点计算一次着色,而不会计算顶点之间的着色。 例如,在一个房间里,地板是用两个大三角形绘制的,并由房间中间的光源照亮,阴影只会在房间的角落进行评估,而插值值可能会在中心太暗。同样,拥有镜面高光的曲面必须使用足够小的图元来绘制,以便在逐顶点着色阶段计算出足够精确的高光。

9.2.5 逐片元着色

为了避免与逐顶点着色相关的插值伪影,我们可以在片元阶段通过在插值之后执行着色计算来避免插值颜色。在每个片元着色中,评估相同的着色方程,但它们是使用插值向量对每个片段进行评估,而不是使用来自应用程序的向量对每个顶点进行评估。

在逐片段着色中,着色所需的几何信息作为属性通过光栅器传递,因此顶点阶段必须与片段阶段协调以适当地准备数据。一种方法是插值视空间表面法线和视空间顶点位置,然后可以像在逐顶点着色中一样使用它们。

9.2.6 纹理映射

纹理用来为表面的着色添加额外细节。这个想法很简单:每次计算着色时,我们从纹理中读取着色计算中使用的值,例如漫反射颜色,而不是使用附加到正在渲染的几何体的属性。 这个操作被称为纹理查找:指定一个纹理坐标(这是纹理域中的一个点),纹理映射系统在纹理图像中找到该点的值并返回它。纹理值随后用于着色的计算。

定义纹理坐标最常见的方法是简单地将纹理坐标作为另一个顶点属性。然后每个图元都知道自己在纹理中的位置。

9.2.7 着色频率

决定在哪里进行着色计算取决于颜色变化的速度—— Detail 细节的尺度。 具有大规模特征的着色,例如曲面上的漫射着色,可以相当不频繁地进行评估,然后进行插值——它可以使用低频率进行着色计算。 产生小尺度特征的着色,如尖锐的高光或详细的纹理,需要在高着色频率下进行评估。对于需要在图像中看起来清晰的细节,着色频率至少要为每像素一个着色样本。

所以大规模的效果可以安全地在顶点阶段计算,即使图元包含许多像素。 需要高着色频率的效果也可以在顶点阶段计算,只要顶点在图像中足够接近。当图元大于一个像素时,它们可以考虑在片元阶段计算。

例如,在电脑游戏中使用的硬件管线,通常使用覆盖几个像素的图元来确保高效率,这些硬件管线通常在逐片元着色执行大多数着色计算。另一方面,写实的 RenderMan 系统在首次细分有表面后,对每个顶点进行所有着色计算,这些细分的四边形表面被称为微多边形,大约是像素大小。由于图元很小,该系统中的逐顶点着色实现了高着色频率,适合于细节的着色。

9.3 简单抗锯齿

就像光线跟踪一样,如果我们对每个像素是否在图元内部做出全有或全无的判断,光栅化将产生锯齿线和三角形边缘。事实上,简单光栅化算法生成的片元集合,有时称为标准光栅化或混叠光栅化。它与光线跟踪器映射到三角形的像素集完全相同,光线跟踪器通过每个像素的中心发射一条光线。因此正如在光线跟踪中,解决方案是允许像素被不同的图元部分覆盖。 在实践中,这种形式的模糊有助于视觉质量,特别是在动画中。 在光栅化应用程序中有许多不同的抗锯齿方法。就像在光线跟踪器中一样,我们可以通过将每个像素值设置为以像素为中心的方形区域上颜色的平均值,来产生抗锯齿图像,称为方框过滤。

实现框滤波抗锯齿最简单的方法是通过超采样:创建极高分辨率的图像,然后进行下采样。例如,如果我们的目标是宽度为 1.2 像素的线条的 256 x 256 像素的图像,我们可以在 1024 x 1024 屏幕上栅格化宽度为 4.8 像素的矩形版本的线条,然后平均 4 x 4 组像素以获得‘缩小’图像中 256 x 256 像素中每一个的颜色。 这是实际框过滤图像的近似值。但只有当对象相对于像素之间的距离不是非常小时,效果才会好。

然而,超采样是相当昂贵的。 由于导致混叠伪影的非常尖锐的边缘通常是由图元的边线引起的,而非图元中着色的突然变化。因此一种广泛使用的优化是在图元边线上使用更高的可视性采样率。

如果在每个像素内存储几个点的覆盖率和深度信息,即使只计算一种颜色也可以实现非常好的抗锯齿。在像 RenderMan 这样使用逐顶点着色的系统中,这是通过高分辨率的栅格化来实现的:这样做是不昂贵的,因为着色只是简单地插值片元产生来颜色。在每个片元着色的系统中,如硬件管道,多样本抗锯齿是通过为每个片元存储一个颜色加上一个遮罩和一组深度值来实现的。

9.4 剔除图元以提高效率

对象顺序渲染的优势在于,它需要对场景中的所有几何体进行一次传递(光追需要反复查询场景几何),这也是它面对复杂场景的弱点。例如,在整个城市的模型中,只有少数建筑物可能是可见的。通过绘制场景中的所有原语可以获得正确的图像,但是在处理可见建筑物后面或观看者后面的几何图形时将浪费大量的精力,而且对最终图像没有任何贡献。

识别和丢弃不可见的几何图形,以节省处理它所花费的时间,被称为剔除。三种常用的筛选策略(通常同时使用)是:

  1. 视体积剔除:移除视图体外的几何体。
  2. 遮蔽剔除:移除可能在视图体内,但被更近的几何体遮挡的几何体。
  3. 背面剔除:移除背对相机的图元。

我们将简要讨论视图体积剔除和背面剔除,但高性能系统中的剔除是一个复杂的主题。

9.4.1 视图体积剔除

当整个图元位于视图体积之外时,它可以被剔除,因为它在光栅化时不会产生片元。如果我们可以通过快速测试剔除许多图元,我们可能能够显著加快绘制速度。 另一方面,单独测试图元(以确定需要绘制哪些图元)可能比让光栅化器消除它更费时。

视图体积剔除,也称为视图截锥体剔除,在许多三角面归属于一个几何体,并且这个几何体有一个明确的边界体积时,视图体积剔除会很有用。如果边界体位于视图体之外,那么构成对象的所有三角形也位于视图体之外。例如,如果我们有 1000 个三角形以一个圆心为 c,半径为 r 的球体为界,我们可以检查球体是否位于剪切平面之外: $$\frac{(c-a)\cdot n}{\Vert n \Vert}&gt;r$$ c 和 r 是球体的中心-和半径,n 是面法向,a 是平面上的一点。

请注意,即使在所有三角形都位于平面外的情况下,球体也可能与平面重叠。因此,这是一个保守的测试。测试的保守程度取决于球体对物体的约束程度。

如果场景是在第 12 章中描述的空间数据结构中组织的,那么同样的想法也可以分层应用。

9.4.2 “背面”剔除

当多边形模型是封闭的,即它们约束一个没有孔的封闭空间时,通常假设它们具有面向外的法向量。 对于这样的模型,背向摄像机的多边形肯定会被朝向摄像机的多边形所覆盖。因此,这些多边形可以在图形管线开始之前被剔除。