《Fundamentals of Computer Graphics》5th(计算机图形学基础/虎书),中文翻译。
第 9 章 The Graphics Pipeline 图形管线
前面几章我们已经学习了一些数学基础,目的就是用来研究第二种常用的渲染方法:将三维物体一个一个地绘制到屏幕上,或者说是物体顺序渲染 (object-order rendering)。在光线追踪中,我们依次考虑每个像素并找到影响其颜色的物体,与此不同的是,现在我们将依次考虑每个几何物体,并找到它可能影响的像素。一个图像中有若干个像素,找到图像中被一个几何基元占据的所有像素的过程被称为光栅化 (rasterization),所以 object-order rendering 也可以称为光栅化渲染。从创建三维对象开始,到更新图像中的像素结束,这个操作序列被称为图形管线 (graphics pipeline)。
任何图形系统都有一种或多种可以直接处理的 "基元对象 (primitive object)",而更复杂的对象则被转换成这些 "基元"。三角形是最经常使用的基元。
基于光栅化的系统也被称为扫描线渲染器 (scanline renderers)。
物体顺序渲染的广泛使用得益于它的高效率。对于大型场景来说,数据访问模式的管理对性能至关重要,遍历场景并且只对每个几何体进行一次访问,比反复搜索场景以检索每个像素所需的对象有显著优势。
本章的标题暗示只有一种方法可以进行物体顺序的渲染。当然,事实并非如此 —— 图形管线的两个完全不同的例子,其目标也非常不同,一个是用于支持通过 OpenGL 和 Direct3D 等 API 进行交互式渲染的硬件管线 (hardware pipelines),一个是用于电影制作的软件管线 (software pipelines),支持 RenderMan 等 API。硬件管线的运行速度必须足够快,以便对游戏、可视化和用户界面做出实时反应。软件管线必须尽可能渲染最高质量的动画和视觉效果,并扩展到巨大的场景,但可能需要更多的时间才能做到这一点。尽管这些不同的目标导致了不同的设计决策,但大多数(如果不是全部)管线都有一个显著的共同点,本章试图把重点放在这些共同的基本知识上,稍微倾向于遵循硬件管线的做法。
在物序渲染中需要做的工作可以被组织为光栅化本身的任务,包括在光栅化之前对几何体进行的操作、以及在光栅化之后对像素进行的操作。最常见的几何操作是应用矩阵变换,正如前两章所讨论的,将定义几何的点从物体空间映射到屏幕空间,这样光栅化器 (rasterizer) 的输入就可以用像素坐标表示,或者说屏幕空间 (screen space)。最常见的像素化操作是移除隐藏的面 (hidden surface removal),安排离观看者较近的面出现在离观看者较远的面前面。在每个阶段还可以包括许多其他的操作,从而利用相同的一般过程实现广泛的不同渲染效果。
为了本章的目的,我们将以四个阶段来讨论图形管线(图 9.1)。
图 9.1. 图形管线的各个阶段。
我们将首先讨论光栅化,然后通过一系列的例子来讨论几何对象和像素处理阶段 (pixel-wise stages)。
光栅化是物序渲染的核心步骤,而 rasterizer 是任何图形管线的核心。对于进入的每个基元,rasterizer 有两项工作:枚举 (enumerates) 基元覆盖的像素,并在基元上 插值 (interpolates),称为属性 —— 这些属性的目的在后面的例子中会很清楚。rasterizer 的输出是一组 片段 (fragments),基元覆盖的每个像素都有一个片段。每个片段都 "生活 "在一个特定的像素上,并带有自己的一组属性值 (set of attribute values)。
在本章中,我们将介绍光栅化,用它来渲染三维场景。同样的光栅化方法也被用来在二维中绘制线条和形状,尽管使用三维图形系统“暗箱操作” (under the covers) 来完成所有的二维绘图已经变得越来越普遍。
大多数图形包都包含一个画线命令,它在屏幕坐标中获取两个端点(图 3.10),并在它们之间画出一条线。例如,对端点 和 的调用将打开像素 和 并在它们之间填充一个像素。对于一般的屏幕坐标端点 和 ,该例程应该绘制一些“合理的”像素集,使它们之间接近于一条线。绘制这样的线条是基于线条方程 (line equations) 的,我们有两种类型的方程可供选择:隐式和参数式。本节介绍使用隐式线的方法。
尽管我们经常使用整数值的端点来举例,但适当地支持任意端点也很重要。
使用隐式方程 (Implicit Line Equations) 画线的最常见方法是 中点 算法(Pitteway, 1967; van Aken & Novak, 1985)。中点算法最终绘制的线条与 Bresenham 算法(Bresenham, 1965)相同,但它在某种程度上更直接。
首先要做的是找到 2.7.2 节中 讨论的直线的隐含方程。
我们假设 。如果这不是真的,我们交换点,使之成为真的。线条的斜率 由以下公式给出
下面的讨论假设 。对于 ,,以及 ,可以得出类似的讨论。这四种情况涵盖了所有的可能性。
对于 的情况,"运行 "多于 "上升";也就是说,线在 中的移动速度比在 中的移动速度快。如果我们有一个 API,y 轴指向下方,我们可能会担心这是否会使过程更难,但是,事实上,我们可以忽略这个细节。我们可以忽略 "向上 "和 "向下 "的几何概念,因为这两种情况的代数是完全一样的。谨慎的读者可以确认,所得到的算法对 Y 轴向下的情况是有效的。中点算法的关键假设是,我们尽可能画出最细的线,不添加任何空隙。两个像素之间的对角线连接不被视为间隙。
当线条从左端点向右推进时,只有两种可能:在与其左边的像素相同的高度画一个像素,或者画一个高一点的像素。在端点之间的每一列像素中,总会有一个确切的像素。零意味着有间隙,而两个则意味着线太粗。对于我们所考虑的情况,同一行中可能有两个像素;线条的水平方向多于垂直方向,所以有时会向右走,有时会向上走。这个概念显示在图 9.2 中,图中显示了三条 "合理 "的线,每条线在水平方向的推进都比在垂直方向的推进多。
图 9.2. 三条 "合理 "的线,水平方向为 7 个像素,垂直方向为 3 个像素。
的中点算法首先建立最左边的像素和最右边的像素的列号(x 值),然后水平循环建立每个像素的行(y 值)。该算法的基本形式是
y = y0 for x = x0 to x1 do draw(x, y) if (some condition) then y = y +1
注意,x 和 y 是整数。换句话说,这表示 "不断地从左到右画出像素,有时在 Y 方向向上移动,而这样做。"关键是在 if 语句中建立有效的方法来做决定。
进行选择的有效方法是查看两个潜在像素中心之间的线段的中点。更具体地说,刚刚绘制的像素是坐标为 的像素,其在实际屏幕坐标中的中心为 。右侧候选像素是像素 和 。两个候选像素中心之间的中点是 。如果线段低于此中点,则绘制底部像素;否则,绘制顶部像素(图 9.3)。
图 9.3. 顶部:线条在中点以上,所以绘制了顶部的像素。底部:线条在中点以下,所以底部像素被画出来。
为了确定直线是否通过点 (x+1, y+0.5) 的上方或下方,在公式(9.1)中计算 。回忆一下第 2.7.1 节中所述,对于在直线上的点 ,有 ;对于直线某侧的点,;对于直线另一侧的点,。因为 和 都是表示直线的完全合法的公式,所以不清楚 为正数时, 是否位于直线上方或下方。但我们可以弄清楚:公式(9.1)中的关键项是 项,即 。注意到 肯定是正数,因为 。这意味着随着 增加,项 变得更大(即更正或更少负)。因此,当 时肯定为正数,并且在直线上方,暗示位于直线上方的点全部为正数。
另一种看法是,梯度向量的 y 分量是正的。所以在直线以上,y 可以任意增加的地方, 一定是正的。这意味着我们可以通过填入 if 语句使我们的代码更加具体。
if f (x + 1,y + 0.5) < 0 then y = y + 1
上述代码适用于适当斜率(即在零和一之间)的线条。读者可以计算其他三种情况,它们仅在细节上略有不同。
如果需要更高的效率,则可以使用增量方法来提高效率。增量方法通过重用上一步的计算来尝试使循环更加高效。在中点算法中,主要计算是 的评估。请注意,在循环内部,在第一次迭代之后,我们已经计算了 或 (图 9.4)。此外,请注意以下关系:
图 9.4。当使用显示在两个橙色像素之间的决策点时,我们只画出了蓝色像素,因此在图中显示了两个左侧点之一。
这使我们能够编写一个增量版本的代码:
y = y0 d = f (x0 + 1,y0 + 0.5) for x = x0 to x1 do draw(x, y) if d < 0 then y = y + 1 d = d + (x1 – x0) + (y0 – y1) else d = d + (y0 – y1)。
与非增量版本相比,该代码的设置成本很小(对于增量算法并非总是如此),因此应该可以运行得更快。然而,执行 计算可能会由于对于长线条而言包含了很多加法操作而积累更多的数值误差。然而,由于线条很少超过几千个像素点,因此这种误差不太可能是关键性的。通过将 和 存储为变量,可以稍微增加设置成本,但加速循环执行。我们可能希望一个好的编译器会为我们执行这些操作,但是如果代码非常关键,最好检查编译结果以确保这些操作已经执行了。
我们经常需要使用屏幕坐标下的 2D 点 , 和 来绘制 2D 三角形。这类似于画线的问题,但它有其自身的复杂性。与画线一样,我们可能希望从顶点值中插值颜色或其他属性。如果我们有重心坐标(第 2.9 节),这就很简单了。例如,如果顶点具有颜色 , 和 ,则三角形上具有重心坐标 的点的颜色为
这种颜色插值的类型在图形学中称为 Gouraud 插值,以其发明者(Gouraud,1971)命名。
在光栅化三角形时的另一个微妙之处是我们通常光栅化共享顶点和边的三角形,这意味着我们希望光栅化相邻的三角形,以避免出现空心。我们可以使用中点算法绘制每个三角形的轮廓,然后填充内部像素。这意味着相邻的三角形沿每条边绘制相同的像素。如果相邻的三角形具有不同的颜色,则图像将取决于两个三角形的绘制顺序。避免顺序问题和消除洞的最常见方法是使用惯例,即仅当像素的中心在三角形内时才绘制该像素;即像素中心的重心坐标均在区间 内。这引出了一个问题,即如果中心恰好在三角形的边缘上,该怎么办。后面的小节将讨论几种处理这个问题的方法。关键观察是,如果我们从顶点插值颜色,重心坐标可以让我们决定是否绘制像素以及该像素的颜色。因此,我们光栅化三角形的问题归结为有效地找到像素中心的重心坐标 (Pineda,1988)。暴力光栅化算法是
for all x do for all y do compute (α, β, γ) for (x, y) if (α ∈ [0, 1] and β ∈ [0, 1] and γ ∈ [0, 1]) then c = αc0 + βc1 + γc2 drawpixel (x, y) with color c
算法的剩余部分将外循环限制为一组较小的候选像素,并且提高了重心计算的效率。
我们可以通过找到三个顶点的边界矩形,并只在该矩形内循环以找到要绘制的候选像素,来进一步提高算法的效率。我们可以使用方程式 (2.32) 计算重心坐标。得到的算法如下:
这里, 是方程 (9.1) 中给定的带有相应顶点的线:
请注意,我们已经将测试 与 等进行了交换,因为如果 , 和 都是正的,则我们知道它们都小于 ,因为 。我们也可以仅计算三个重心变量中的两个,并从那个方程式中得到第三个,但是在增量测试中,这是否节省计算并不明确,这是可能的,就像在线绘图算法中一样;每个 、 和 的计算都执行形式为 的评估。在内层循环中,只有 x 发生变化,它每次增量为 。请注意,。这是增量算法的基础。在外层循环中, 的评估会更改为 f(x,y+1),因此可以实现类似的效率。因为 , 和 在循环中通过恒定增量发生变化,所以颜色 c 也是如此。因此,它也可以成为增量算法。例如,像素 的红色值与像素 的红色值不同,差值数量可以预先计算。图 9.5 展示了一个颜色插值的三角形的例子。
图 9.5. 一个带有 arycentric 插值的彩色三角形。请注意,颜色成分的变化在每一行和每一列以及沿着每一条边都是线性的。事实上,沿每条线,如对角线,也是恒定的。
我们仍然没有讨论当像素中心正好在三角形边缘时应该怎么办。如果一个像素正好在三角形的边缘上,那么如果有相邻的三角形,它也会在相邻三角形的边缘上。没有明显的方法将像素分配给一个三角形或另一个三角形。最糟糕的决定是不绘制像素,因为这将在两个三角形之间形成一个空洞。更好但仍然不够好的方法是让两个三角形都绘制像素。如果三角形是透明的,这将导致重叠颜色。我们真正想要的是把像素分配给其中一个三角形,而且我们希望这个过程很简单;只要选择是明确定义的,选择哪个三角形并不重要。
一种方法是注意到任何离屏点肯定仅位于共享边缘的一侧,我们将在其上绘制。对于两个不重叠的三角形,不位于边缘上的顶点位于互相对立的边缘两侧。这些顶点中恰好有一个将与离屏点在边缘同侧(如图 9.6 所示)。这是测试的基础。测试两个数 和 是否具有相同符号可实现为测试 ,大多数环境下非常高效。
图 9.6。屏幕外点将位于三角形边缘的一侧或另一侧。非共享顶点 a 和 b 中的一个将位于相同的一侧。
需要注意的是,这个测试不是完美的,因为通过边缘的直线也可能经过屏幕外的点,但是我们至少大大减少了有问题的情况的数量。使用哪个屏幕外点是任意的,而 是一个和其他点一样好的选择。我们需要添加一个检查,以检查点是否位于边缘上。我们希望对于常见的情况,即完全位于内部或外部的测试,无需进行此项检查。这表明:
如果我们在两个三角形的绘制调用中使用的共享顶点的顺序不同,则期望上述代码仅适用于消除洞和重复绘制。实际上,只有在每个三角形的绘制调用中共享的两个顶点以相同的顺序排列时,线性方程才是相同的。否则,方程可能会翻转符号,这可能会取决于编译器更改运算顺序的方式。因此,如果需要进行稳健的实现,则可能需要检查编译器和算术单元的详细信息。上面伪代码中的前四行必须小心编写,以处理边缘恰好命中像素中心的情况。
除了适合增量实现外,还有几个可能的早期退出点。例如,如果 为负,则无需计算 或 。虽然这可能会带来速度的提高,但总是建议对代码进行分析;额外的分支可能会减少流水线或并发性,并可能减慢代码。因此,如果代码是关键部分,请像往常一样测试任何看起来有吸引力的优化。
上述代码的另一个细节是,对于退化的三角形,即 ,则可能会出现除零除法。浮点误差条件应该得到恰当考虑,或者需要进行另一种测试。
在插值某些量(如纹理坐标或 3D 位置)以使其在 3D 三角形中线性变化时,实现正确的透视效果存在一些微妙之处。我们将使用纹理坐标作为需要进行透视校正的量的示例,但相同的考虑也适用于空间中的任何线性属性。
事情并不简单的原因是,仅在屏幕空间中插值纹理坐标会导致不正确的图像,如图 9.7 中网格纹理所示。由于透视变形会使距离观察者越远的东西缩小,因此在 2D 图像空间中均匀间隔的线条应该压缩。需要更仔细地插值纹理坐标才能实现这一点。
图 9.7。左:正确的透视效果。右:在屏幕空间内插值。
我们可以通过插值 坐标来实现三角形上的纹理映射,修改第 9.1.2 节的光栅化方法,但这会导致图 9.7 右侧所示的问题。如果像以下光栅化代码中使用屏幕空间重心坐标,则三角形会出现类似的问题:
这段代码将生成图像,但存在问题。为了解决基本问题,让我们考虑从世界空间 到齐次点 到归一化点 的过程:
纹理坐标插值问题的最简形式是,当我们有与两个点 和 相关联的纹理坐标 ,并且我们需要在连接 和 的线上生成纹理坐标时。如果在线上 和 之间的世界空间点 投影到在线性 和 上的屏幕空间点 ,则两个点应具有相同的纹理坐标。
朴素的屏幕空间方法,即上述算法,说在点 处,应该使用纹理坐标 和 。这种方法无法正确工作,因为转换为 的世界空间点 并不是 。
然而,根据第 8.4 节的知识,我们知道连接 和 的线段上的点最终会落到线段 和 上的某一点;实际上,在该节中我们表明:
插值参数 t 和 α 不同,但我们可以从一个参数计算出另一个参数。
这些方程提供了对屏幕空间插值思想的一种潜在修复方法。要获取屏幕空间点 的纹理坐标,请计算 和 。这些是映射到 的点 的坐标,因此这将有效。但是,每个片段计算 的速度很慢,有一种更简单的方法。
关键观察结果是,因为,我们知道透视转换保持线和平面,所以我们可以安全地线性插值我们想要的任何属性跨越三角形,但前提是它们必须与点一起通过透视变换(如图 9.8 所示)。为了获得几何直观,减少维度,使我们有齐次点 和一个需要插值的单个属性 。属性 应该是 和 的线性函数,因此如果我们将 作为 的高度场绘制,则结果是一个平面。现在,如果将 视为第三个空间坐标(称其为 ,以强调其与其他坐标的相同处理方式)并将整个 3D 齐次点 通过透视变换,结果 仍会生成落在一个平面上的点。平面内会存在一些扭曲,但平面仍然保持平坦。这意味着 是 的线性函数--也就是说,我们可以根据坐标 使用基于线性插值来计算任何位置的 。
图 9.8。用于屏幕空间插值的几何推理。上部:需要将 作为 的线性函数进行插值。下部:在透视变换从 到 后, 是 的线性函数。
回到整个问题,我们需要插值纹理坐标 ,这些坐标是世界空间坐标 的线性函数。在将点转换为屏幕空间后,将纹理坐标添加为额外的坐标,我们有
从方程(7.6)中自己推导这些函数是值得的;按该章节的符号,。
上一个段落的实际含义是,我们可以根据 的值插值所有这些量,包括 缓存区中使用的 值。简单方法的问题在于我们会不一致地选择相应的要素来进行插值——只要这些值是在透视变换之前或之后选择的,一切就都没问题了。
剩下的一个问题是 在直接查找纹理数据方面不太有用;我们需要 。这就解释了我们将一个其值始终为 的额外参数插入到(9.3)中的目的:一旦我们有了 , 和 ,我们就可以通过除法轻松恢复 。
为了验证所有这些都是正确的,让我们检查在屏幕空间中插值量 是否确实产生了世界空间中插值 的倒数。为了证明这一点,请执行以下操作(练习 2):
记住, 和 t 是由 9.2 式相关联的。
这种能够在变换空间中无误地进行 线性插值的能力使我们能够正确地为三角形贴图。我们可以利用这些事实来修改我们的扫描转换代码,针对已经通过观察矩阵传递但尚未进行齐次化的三个点 ,并配有纹理坐标 。
当然,出现在这个伪代码中的许多表达式会在循环外预先计算以提高速度。
实际上,除非有特别的要求,现代系统都会以透视校正的方式插值所有属性。
仅将基元转换为屏幕空间并对其进行光栅化并不能完全解决问题。这是因为位于视图体积之外的基元(特别是在眼睛后面的基元)可能最终会被光栅化,导致结果不正确。例如,请参考图 9.9 所示的三角形。其中两个顶点在视图体积内,但第三个在眼睛后面。投影变换将这个顶点映射到远平面后面的一个无意义的位置,如果允许这种情况发生,三角形将被错误地光栅化。因此,在进行光栅化之前必须进行裁剪操作,以删除可能延伸到眼睛后面的基元部分。
图 9.9 深度 通过透视变换转换为深度 。
请注意,当 从正数变为负数时, 会从负数变为正数。因此,在眼睛后面的顶点将被移动到超出 前面的眼睛。这将导致错误的结果,这就是为什么首先要裁剪三角形以确保所有顶点都在眼睛前面。
裁剪是图形学中的常见操作,需要在一个几何实体“切割”另一个实体时进行。例如,如果您对平面 对一个三角形进行裁剪,如果顶点的 坐标的符号不全相同,则平面将把三角形切成两部分。在大多数裁剪应用程序中,“错误”面的三角形部分将被丢弃。单个平面的此操作示例如图 9.10 所示。
图 9.10。一个多边形被截取,只保留平面“内部”的部分。
在进行光栅化前的截取过程中,“错误”的一侧是视野体积之外的一侧。将所有在视野体积之外的几何图形都截取掉是安全的,也就是说,可以对视野体积的六个面进行截取,但很多系统只截取近裁剪面。
本节讨论了实现截取模块的基本方法。有兴趣实现工业级速度的截取器的读者可以参考本章末尾提到的 Blinn 的书籍。
实现截取最常见的两种方法是:
任意一种可能性都可以通过以下方式有效地实现(J.Blinn,1996)每个三角形:确定该三角形是否完全在或完全外于截取体积之内。如果完全在内,则保留该三角形进行光栅化。如果完全在外,则丢弃该三角形。如果它与截取体积相交,则针对每个截取平面对其进行截取,直到得到一组完全在或完全外于截取体积之内的新三角形集合为止。保留其中的内部三角形进行光栅化。
选项 1 有一个直接的实现方法。唯一的问题是,“这六个平面方程式是什么?”由于这些方程式对于在单个图像中渲染的所有三角形都相同,我们不需要非常高效地计算它们。因此,我们只需要反转图 7.12 中所示的变换并将其应用于转换后视野体积的 8 个顶点:
可以从中推断出平面方程式。或者,我们可以使用向量几何从观察参数直接获取平面。
令人惊讶的是,通常实现的选项是在除法之前的齐次坐标中进行截取。在这里,视野体积是 4D 的,并且由 3D 体积(超平面)限定。它们分别是:
这些平面很简单,因此效率比选项 1 更高。它们可以通过将视野体积 转换为 来进一步提高效率。事实证明,对三角形的剪裁不比在 3D 中更加复杂。
无论我们选择哪个选项,都必须针对平面进行剪裁。从第 2.7.5 节可以回忆到,通过点 q 并带有法向量 n 的平面的隐式方程是:
这通常被写成
有趣的是,这个方程不仅描述了 3D 平面,还描述了 2D 中的线和 4D 中平面的类比体积。在适当的维度中,所有这些实体通常都被称为平面。
如果我们有两点 和 之间的线段,我们可以使用 BSP 树程序中描述的技术来“剪裁”它们。这里,通过检查 和 是否具有不同的符号来确定它们是否在平面 的两侧,以判断两点 和 。通常, 被定义为“内部”,而 则为“外部”。如果平面确实分开了该线,则我们可以通过将参数线的方程
代入方程(9.5)的平面中来求解交点,得到
解出 t 得到
然后,我们可以找到交点并“缩短”该线。
要剪裁一个三角形,我们可以再次遵循第 12.4.3 节的步骤,生成一个或两个三角形。
在原始图元进行栅格化之前,定义图元的顶点必须处于屏幕坐标系中,并且应该被插值的颜色或其他属性信息需要被确认。这些数据的准备工作由流水线的顶点处理阶段负责。在这个阶段,输入的顶点通过建模、观察和投影变换进行变换,将它们从原始坐标映射到屏幕空间中(在那里,需回忆一下,位置以像素为单位进行度量)。同时,其他信息,例如颜色、表面法线或纹理坐标等也会根据需要进行转换;我们将在下面的示例中讨论这些额外的属性。
栅格化后,进一步的处理是为每一个片段计算颜色和深度。这个处理可以很简单,只需传递一个插值的颜色并使用被栅格化器计算出的深度;或者它可以涉及到复杂的着色操作。最后,混合阶段会将每个像素重合的(可能是几个)原始图元的片段合并,以计算最终的颜色。最常见的混合方法是选择最小深度(最靠近眼睛的深度)的片段的颜色。
不同阶段的目的最好通过示例来说明。
最简单的管线在顶点和片段阶段什么都不做,在混合阶段中,每个片段的颜色仅覆盖之前的值。应用直接在像素坐标下提供原始图元,而栅格化器负责所有工作。这种基本排列方式是许多简单、旧 API 用于绘制用户界面、绘图、图表和其他 2D 内容的本质。可以通过为每个原始图元的所有顶点指定相同的颜色来绘制实心颜色形状,而我们的模型管线也支持使用插值实现平滑变化的颜色。
要在 3D 中绘制对象,对 2D 绘图管线所需的唯一更改是进行一个单一的矩阵变换:顶点处理阶段将输入的顶点位置与建模、相机、投影和视口矩阵的乘积相乘,产生屏幕空间三角形,然后以与直接在 2D 中指定的方式绘制它们。
使用最小化的 3D 管线的一个问题是,为了正确获取遮挡关系,即让近的物体在远的物体前面,必须以反向的顺序绘制原始图元。这被称为靠近者遮挡的画家算法,类比于先绘制画的背景,然后在其上绘制前景。画家算法是一种完全有效的去除隐藏表面的方法,但它有几个缺点。它无法处理相互交叉的三角形,因为不存在绘制它们的正确顺序。同样地,即使它们不相交,几个三角形仍然可以按照遮挡周期排列,如图 9.11 所示,这是另一种不存在反向排列的情况。最重要的是,按深度排序原始图元很慢,特别是在大场景下,这会干扰使对象顺序渲染如此快速的高效数据流。图 9.12 展示了没有按深度排序对象的处理结果。
图 9.11. 两个遮挡周期,无法按照反向顺序进行绘制。
图 9.12. 使用最小管线绘制两个相同大小的球体的结果。看起来较小的球体更远,但是由于后绘制,因此错误地覆盖了较近的球体。
在实践中,绘制者算法很少使用;相反,通常使用一种简单而有效的隐藏表面移除算法,称为 z-buffer 算法。该方法非常简单:在每个像素处,我们跟踪到目前为止已经绘制的最近表面的距离,并丢弃远于该距离的片段。最近的距离通过为每个像素分配一个额外值来存储,除了红色、绿色和蓝色颜色值之外,这个值也被称为深度或 z-values 。深度缓冲区或 z-buffer 是存储深度值的网格的名称。
z-buffer 算法在片段混合阶段中实现,通过将每个片段的深度与 z-buffer 中当前存储的值进行比较来完成。如果片段的深度更近,则其颜色和深度值都将覆盖当前颜色和深度缓冲器中的值。如果片段的深度更远,则将其丢弃。为确保第一个片段能够通过深度测试,z-buffer 被初始化为最大深度(即远平面的深度)。无论以任何顺序绘制表面,同一个片段将赢得深度测试,并且图像将保持一致。
当然,深度测试可能存在平局情况,这时绘制顺序可能是有影响的。
Z 缓冲算法要求每个片段携带一个深度信息。这只需要将 z 坐标作为顶点属性进行插值,就可以像对颜色或其他属性进行插值一样简单地实现。
在对象顺序渲染中,z-buffer 是处理隐藏面的一种简单而实用的方法,远远超过其他方法。它比将表面分割成可以按深度排序的片段的几何方法要简单得多,因为它避免了需要解决的任何问题。深度顺序仅需要在像素位置处确定,这是 z-buffer 所做的一切。它受到硬件图形管线的普遍支持,也是软件管线中最常用的方法。图 9.13 和 9.14 显示了示例结果。
图 9.13. 使用 z-buffer 绘制相同的两个球体的结果。
图 9.14. 将两个三角形使用 z-buffer 分别以两种可能的顺序栅格化的结果。第一个三角形完全被栅格化。第二个三角形计算了每个像素,但由于像素的深度相等,其中三个像素的深度竞赛失败,导致这些像素未被绘制。最终图像却是相同的。
实际上,缓冲区中存储的 z-values 为非负整数。这比真实的浮点数更可取,因为 z 缓冲所需的快速内存有些昂贵,要尽量减少。
使用整数可能会导致一些精度问题。如果我们使用一个由 个值 组成的整数范围,我们可以将 映射为近裁剪平面 ,将 映射为远裁剪平面 。请注意,对于这次讨论,假设 , 和 均为正数。这将产生与负值情况下相同的结果,但是更易于理解论证的细节。我们将每个 z-values 发送到具有深度 的“存储桶”中。如果内存不是问题,我们将不会使用整数 z-buffer ,因此将 尽可能小是有用的。
如果我们分配 位来存储 z-values ,则 。我们需要足够的位数来确保任何三角形前面的三角形的深度映射到不同的深度单元中。
例如,如果您渲染的场景中,三角形之间的距离至少为一米,则 应产生没有伪影的图像。有两种方法可以使 更小:将 和 靠近一起或增加 。如果 固定不变,如在 API 或特定硬件平台上,调整 和 是唯一的选择。
当创建透视图像时,必须非常小心处理 z-buffer 的精度问题。上面的 是在透视除法之后使用的。回想一下第 8.3 节中的透视除法结果为:
实际的深度值与世界深度 相关,而不是后透视除法深度 。我们可以通过求导两边来近似计算存储桶大小:
深度单元大小因深度而异。世界空间中的深度单元大小为
请注意, 的数量与先前讨论的相同。最大 bin 深度将出现在 处,
请注意,如果我们不想失去眼前的物体,则选择 ,这是一个自然的选择,将导致一个无限大的 bin,这是非常糟糕的。为了使 尽可能小,我们希望最小化 并最大化 。因此,始终选择 和 很重要。
到目前为止,将三角形传送到管道中的应用程序负责设置颜色;栅格化器只是插值颜色,并直接将它们写入输出图像。对于某些应用程序,这已经足够了,但在许多情况下,我们希望使用相同的照明方程来用着色绘制 3D 对象,这些照明方程是我们在第 4 章中用于图像顺序渲染的。请记住,这些方程需要光线方向,视线方向和表面法向量来计算表面的颜色。
处理着色计算的一种方法是在顶点阶段执行它们。应用程序在顶点处提供法向量,灯光的位置和颜色分别提供(它们不在表面上变化,因此不需要为每个顶点指定)。对于每个顶点,基于相机,灯光和顶点的位置,计算出看向观察者和每个灯光的方向。计算所需的着色方程以计算颜色,然后将其作为顶点颜色传递给栅格化器。每顶点着色有时称为 Gouraud 着色。
需要做出的一个决策是进行着色计算的坐标系。世界空间或眼空间都是不错的选择。选择一个在世界空间中看起来正交的坐标系非常重要,因为着色方程依赖于向量之间的角度,这些角度不会被保留在非均匀缩放之类的操作中,这些操作通常用于建模变换,或者投影到规范视图体中常用的透视投影。在眼空间中进行着色具有优点,我们不需要跟踪相机位置,因为在投影时相机始终位于眼空间的原点处,在正交投影中视线方向始终为 。
每顶点着色的缺点是它不能产生任何比用于绘制表面的基元更小的细节着色,因为它仅为每个顶点计算一次着色,并且不在顶点之间进行着色。例如,在使用两个大三角形绘制地板并在房间中央放置光源的房间中,着色仅在房间的角落处计算,并且插值值在中心很可能过度黑暗。此外,使用镜面反射高光进行着色的曲面必须使用足够小的基元绘制,以便可以解决高光问题。
图 9.15 展示了我们使用每顶点着色绘制的两个球体。
图 9.15 展示了使用每顶点着色(Gouraud Shading)绘制的两个球体。由于三角形太大,出现了插值伪影。
为了避免与每顶点着色相关的插值伪影,我们可以通过在片段阶段执行着色计算来避免颜色的插值。在每片段着色中,将计算相同的着色方程,但是使用插值向量为每个片段计算,而不是使用应用程序中的向量为每个顶点计算。
在每片段着色中,用于着色的几何信息作为属性通过光栅化器传递,因此,顶点阶段必须与片段阶段协调以适当地准备数据。一种方法是插值眼空间表面法线和眼空间顶点位置,然后将它们像在每顶点着色时那样使用。
图 9.16 显示了使用每片段着色绘制的两个球体。
图 9.16 展示了使用每片段着色(Phong Shading)绘制的两个球体。由于三角形太大,出现了插值伪影。
每片段着色有时被称为 Phong 着色,这有些令人困惑,因为相同的名称也与 Phong 光照模型相关联。
纹理(在第 11 章中讨论)是用于增加表面着色细节的图像,否则这些表面看起来会过于均一和不真实。这个想法很简单:每次进行着色计算时,我们从纹理中读取着色计算中使用的某个值,例如漫反射颜色,而不是使用附加到要呈现的几何形状上的属性值。这个操作被称为纹理查找(texture lookup):着色代码指定纹理坐标,即纹理域中的一个点,而纹理映射系统则查找纹理图像中该点的值并返回它。该纹理值随后用于着色计算。
定义纹理坐标最常见的方法是简单地使纹理坐标成为另一顶点属性。然后,每个基元都知道它在纹理中的位置。
关于在哪里放置着色计算的决策取决于颜色变化的速度 ——正在计算的细节尺度。在大尺度特征的着色中(例如在曲面上的漫反射着色),可以相对较少地计算,并进行插值:它可以使用较低的着色频率进行计算。而对于产生细小尺度特征的着色,例如尖锐亮点或详细的纹理,则需要以较高的着色频率进行计算。对于在图像中需要看起来清晰而清晰的细节,着色频率至少需要每像素一次的着色样本。
因此,即使定义了许多像素的原语顶点之间存在距离,大尺度效果也可以在顶点阶段安全地计算,并进行插值。需要高着色频率的效果也可以在顶点阶段计算,只要顶点在图像中靠得很近;或者,当基元大于一个像素时,它们也可以在片段阶段进行计算。
例如,用于计算机游戏中的硬件流水线通常使用覆盖多个像素的基元,以确保高效率,通常大多数着色计算都在每个片段上进行。另一方面,PhotoRealistic RenderMan 系统将所有着色计算都放在每个顶点上进行,首先将所有表面细分或切割成称为微多边形的小四边形,它们的大小约为像素大小。由于原语很小,在该系统中进行每顶点着色会实现高着色频率,适用于详细着色。
就像射线追踪一样,如果我们对每个像素是否在基元内进行全是或全不是的判断,光栅化将产生锯齿状的线和三角形边缘。实际上,本章描述的简单三角形光栅化算法生成的片元集合有时被称为标准光栅化或锯齿光栅化,它与通过向每个像素中心发送一条射线的射线跟踪器映射到该三角形的像素集合完全相同。同样,就像在射线追踪中一样,解决方法是允许像素被基元部分覆盖(Crow,1978 年)。在实践中,这种模糊形式可以提高视觉质量,特别是在动画中。这在图 9.17 的顶部行中显示。
图 9.17。以近距离查看的抗锯齿和锯齿线,因此单个像素可见。
在光栅化应用程序中,有许多不同的抗锯齿方法。就像射线跟踪器一样,我们可以通过将每个像素值设置为像素所属正方形区域内图像颜色的平均值来生成抗锯齿图像,这被称为盒式滤波。这意味着我们必须将所有可绘制实体视为具有明确定义的区域。例如,图 9.17 中的线可以视为近似于一个像素宽的矩形。
有比盒式滤波更好的滤波器,但除了最苛刻的应用程序外,盒式滤波会足够使用。
实现盒式滤波抗锯齿最简单的方法是超采样:以非常高的分辨率创建图像,然后降采样。例如,如果我们想要一个 256×256 像素的图像,其中一条线的宽度为 1.2 像素,我们可以在 1024×1024 的屏幕上光栅化宽度为 4.8 像素的线的矩形版本,然后对 4×4 像素组进行平均,以获得“缩小”图像中每个 256×256 像素的颜色。这是实际盒式过滤图像的一种近似,但当对象相对于像素之间的距离不是非常小的时候,效果可行。
然而,超采样成本很高。通常导致走样的非常锐利的边缘通常是由基元的边缘引起的,而不是在基元中的阴影突然变化。一种广泛使用的优化是对可见性进行比着色更高的速率采样。如果为每个像素内存储有关覆盖和深度的多个点的信息,则即使仅计算一个颜色,也可以实现非常好的抗锯齿效果。在使用每顶点着色的 RenderMan 等系统中,通过高分辨率光栅化来实现这一点:这样做很廉价,因为阴影只是插值以产生多个片段或可见性采样的颜色。在使用每片段着色的例如硬件流水线中,多采样抗锯齿通过为每个片段存储单个颜色加覆盖掩码和一组深度值来实现。
物体排序渲染器的优点在于它只需要在场景中遍历一次几何体,但这也是在复杂场景中的一个弱点。例如,在整个城市的模型中,任何时候都只有少数几栋建筑物是可见的。可以通过绘制场景中的所有基元来获得正确的图像,但会浪费大量的计算时间用于处理在可见的建筑物后面或观察者后面的几何体,并因此不对最终图像做出贡献。
为了节省处理不可见几何体的时间,需要进行剔除。三种常用的剔除策略(通常同时使用)包括:
视图体剔除 - 移除视图体之外的几何体;
遮挡剔除 - 移除可能在视图体内但被更靠近摄像机的其他几何体遮挡的几何体;
背面剔除 - 移除面向摄像机背面的基元。
我们将简要讨论视图体剔除和背面剔除,但在高性能系统中进行剔除是一个复杂的话题;可以参考 Akenine-Möller,Haines 和 Hoffman(2008)的相关资料,了解关于遮挡剔除和完整讨论的内容。
当完整基元位于视图体之外时,可以剔除它,因为在光栅化时不会产生任何片段。如果我们可以通过快速测试剔除许多基元,那么我们可能可以大大加速绘图。但是,为了决定确切需要绘制哪些基元,逐个测试基元可能比让光栅化器消除它们更耗费成本。
视图体剔除,也称为视景体剔除,在许多三角形被分组为带有关联边界体的对象时特别有用。如果边界体位于视图体之外,则构成对象的所有三角形也都位于视图体之外。例如,如果我们有由单个中心在 、半径为 的球面边界限定的 1000 个三角形,则可以检查球是否在裁剪平面外部,即
其中 是平面上的一个点, 是一个变量。这相当于检查从球心 到平面的符号距离是否大于 。这意味着检查
请注意,即使在所有三角形都在平面外面的情况下,球体可能会重叠平面。因此,这是一个保守的测试。测试的保守程度取决于球体边界对象的界定。
如果场景组织在第 12 章中描述的空间数据结构之一中,则可以应用同样的层次结构思想。
当多边形模型是封闭的,即它们限定了没有孔的封闭空间时,通常假设它们具有指向外部的法向量,如第 5 章所讨论的那样。对于这种模型,朝向远离眼睛的多边形一定会被朝向眼睛的多边形遮挡。因此,在管道开始之前,这些多边形就可以被剔除。
我经常看到对剪裁进行详细讨论,这是比本章描述的更复杂的过程。这里发生了什么?
本章描述的剪裁工作,但缺少一个工业强度剪裁器所具有的优化。这些优化在章节注释中列出的 Blinn 的权威工作中进行了详细讨论。
非三角形的多边形如何进行栅格化?
这些可以直接逐行进行扫描线处理,也可以分解成三角形。后一种技术似乎更受欢迎。
抗锯齿总是更好吗?
不总是。一些图像没有抗锯齿看起来更清晰。许多程序使用未经抗锯齿处理的"屏幕字体",因为它们更易于阅读。
我的 API 的文档说到“场景图”和“矩阵堆栈”,它们是图形管线的一部分吗?
图形管线当然是考虑了这些因素的,将它们定义为管线的一部分是个人品味的问题。本书将在第 12 章讨论它们。
统一距离 z-buffer 是否比包含透视矩阵非线性的标准距离 z-buffer 更好?
这要看情况。非线性的一个“特点”是 z-buffer 在眼睛附近具有更高的分辨率,在远处分辨率较低。如果使用级别细节系统,则远距离的几何图形更粗糙,而 z-buffer 的“不公平性”可能是一件好事。
软件 z-buffer 有用过吗?
有。大多数使用 3D 计算机图形的电影都使用由 Pixar 开发的软件 z-buffer 的变体。 (Cook, Carpenter, & Catmull, 1987)。
《Jim Blinn's Corner: A Trip Down the Graphics Pipeline》(作者 Jim Blinn, 1996) 是一本关于设计图形渲染管道的精彩书籍。关于管道和剔除技术的不少细节可以在《3D 游戏引擎设计》(作者 Eberly, 2000) 和《实时渲染》(作者 Akenine-Möller 等,2008) 中找到。
本文作者:青波
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!