光栅化:一种实际的实现
关键词:光栅化器,光栅化器,光栅化,光栅化,可见性,帧缓冲,图像缓冲,深度缓冲,z缓冲,像素,光栅元素,透视投影,NDC空间,内外测试,覆盖测试,边缘功能,重心坐标、逆时针排序、缠绕、面剔除、顶点属性、光栅化规则、左上角规则、隐藏表面去除、插值、透视正确插值、抗锯齿。
你想知道的关于光栅化算法的一切(但不敢问!)
光栅化渲染技术无疑是渲染 3D 场景图像最常用的技术,然而,这可能是所有技术中最不被理解和最不被正确记录的技术(尤其是与光线追踪相比)。
为什么会这样,取决于不同的因素。首先,这是一种过去的技术。我们并不是说该技术已经过时,恰恰相反,但大多数用于使用该算法生成图像的技术都是在 1960 年代和 1980 年代初期之间开发的。在计算机图形学的世界里,这是中年时期,关于开发这些技术的论文的知识往往会丢失。光栅化也是 GPU 用于生成 3D 图形的技术。自 GPU 首次发明以来,硬件技术发生了很大变化,但它们用于生成图像的基本技术自 1980 年代初以来并没有太大变化(硬件发生了变化,但形成图像的底层管道却没有)。
无论如何,我们认为纠正这种情况是紧迫而重要的。通过本课程,我们相信这是第一个提供清晰完整的算法图片以及该技术的完整和完整实际实现的资源。如果您在本课中找到了您一直在其他任何地方拼命寻找的答案,请考虑捐款!这项工作是免费提供给您的,需要很多小时的辛勤工作。
介绍
光栅化和光线追踪试图以不同的顺序解决可见性或隐藏表面问题(可见性问题在“渲染 3D 场景的图像”一课中介绍过)。这两种算法的共同点是它们基本上使用几何技术来解决该问题。在本课中,我们将简要描述光栅化(如果您更喜欢英国英语而不是美国英语,您可以编写光栅化)算法的工作原理。理解这个原理很简单,但实现它需要使用一系列技术,尤其是几何领域的技术,你也会在本课中找到解释。
我们将在本课中开发的用于演示光栅化在实践中如何工作的程序很重要,因为我们将在下一课中再次使用它来实现光线追踪算法。在同一个程序中实现这两种算法将使我们能够更轻松地比较两种渲染技术产生的输出(至少在应用着色之前它们都应该产生相同的结果)和性能。这将是更好地理解这两种算法的优缺点的好方法。
光栅化算法
事实上,光栅化算法不止一种,而是多种,但直截了当地说,所有这些不同的算法虽然都基于相同的总体原理。换句话说,所有这些算法都只是同一个想法的变体。正是这个想法或原则,我们将在本课中真正谈到光栅化时参考。
那是什么想法?在之前的课程中,我们已经讨论了光栅化和光线追踪之间的区别。我们还建议渲染过程基本上可以分解为两个主要任务:可见性和着色。光栅化说的很快,本质上是一种解决可见性问题的方法。可见性包括能够判断 3D 对象的哪些部分对相机可见。这些对象的某些部分可以出价,因为它们要么在相机的可见区域之外,要么被其他对象隐藏。
解决这个问题基本上可以通过两种方式完成。您可以通过图像中的每个像素追踪光线,以找出相机与该光线相交的任何对象(如果有)之间的距离。通过该像素可见的对象是具有最小相交距离(通常表示为 t)的对象。这是光线追踪中使用的技术。请注意,在这种特殊情况下,您通过遍历图像中的所有像素来创建图像,为这些像素中的每一个追踪一条射线,然后确定这些射线是否与场景中的任何对象相交。换句话说,该算法需要两个主循环。外循环遍历图像中的像素,内循环遍历场景中的对象:
002
003
004
005
006
007
008
009
010
011
012
013
014
015
016
Ray R = computeRayPassingThroughPixel(x,y);
浮动tclosest = 无穷大;
三角形triangleClosest = NULL;
for (场景中的每个三角形) {
float thit;
if (intersect(R, object,thit)) {
if (thit <最接近) {
triangleClosest = triangle;
}
}
}
if (triangleClosest) {
imageAtPixel(x,y) = triangleColorAtHitPoint(triangle, tclosest);
}
}
请注意,在此示例中,对象实际上被视为由三角形(且仅三角形)组成。我们不迭代其他对象,而是将对象视为三角形池,然后迭代其他三角形。由于我们在之前的课程中已经解释过的原因,三角形经常被用作光线追踪和光栅化中的基本渲染图元(GPU 需要对几何图形进行三角剖分)。
光线追踪是解决可见性问题的第一种可能的方法。我们说该技术以图像为中心,因为我们将来自相机的光线射入场景(我们从图像开始),而不是相反,这是我们将在光栅化中使用的方法。
光栅化采用相反的方法。为了解决可见性问题,它实际上将三角形“投影”到屏幕上,换句话说,我们使用透视投影从该三角形的 3D 表示变为 2D 表示。这可以通过将构成三角形的顶点投影到屏幕上来轻松完成(使用我们刚刚解释的透视投影)。该算法的下一步是使用某种技术来填充该二维三角形覆盖的图像的所有像素。这两个步骤如图 2 所示。从技术角度来看,它们执行起来非常简单。投影步骤只需要透视分割和将生成的坐标从图像空间重新映射到光栅空间,我们在前面的课程中已经介绍了这个过程。
与光线追踪方法相比,该算法是什么样的?首先,请注意,在光栅化中,在外循环中,我们需要迭代场景中的所有三角形,而不是首先迭代图像中的所有像素。然后,在内部循环中,我们遍历图像中的所有像素,并找出当前像素是否“包含”在当前三角形的“投影图像”中(图 2)。换句话说,两种算法的内循环和外循环互换了。
002
003
004
005
006
007
008
009
010
011
012
013
// (场景中的每个三角形)的光栅化算法{
// 第 1 步:使用透视投影投影三角形的顶点
Vec2f v0 = perspectiveProject(triangle[i].v0);
Vec2f v1 = perspectiveProject(triangle[i].v1);
Vec2f v2 = perspectiveProject(triangle[i].v2);
for (each pixel in image) {
// STEP 2: 这个像素是否包含在三角形的投影图像中?
if (pixelContainedIn2DTriangle(v0, v1, v2, x, y)) {
image(x,y) = triangle[i].color;
}
}
}
这个算法是以对象为中心的,因为我们实际上是从几何图形开始走回到图像的,而不是光线追踪中使用的方法,我们从图像开始走回到场景中。
这两种算法的原理都很简单,但在实际实现它们和为需要解决的不同问题找到解决方案时,它们的复杂性略有不同。在光线追踪中,实际生成光线很简单,但要找到光线与几何体的交点可能会很困难(取决于您处理的几何体类型),而且计算成本也可能很高。但现在让我们忽略光线追踪。在光栅化算法中,我们需要将顶点投影到屏幕上,这既简单又快速,我们将看到需要实际找出像素是否包含在三角形的 2D 表示中的第二步具有同样简单的几何解. 换句话说,使用光栅化方法计算图像依赖于两种非常简单且快速的技术(透视过程和确定像素是否位于二维三角形内)。光栅化是“优雅”算法的一个很好的例子。它所依赖的技术有简单的解决方案;它们也易于实施并产生可预测的结果。由于所有这些原因,该算法非常适合 GPU,实际上是 GPU 用于生成 3D 对象图像的渲染技术(它也可以轻松并行运行)。
总之:
- 将几何图形转换为三角形使过程更简单。如果所有图元都转换为三角形图元,我们可以编写快速高效的函数将三角形投影到屏幕上并检查像素是否位于这些 2D 三角形内
- 光栅化是以对象为中心的。我们将几何图形投影到屏幕上,并通过遍历图像中的所有像素来确定它们的可见性。
- 它主要依赖于两种技术:将顶点投影到屏幕上并确定给定像素是否位于二维三角形内。
- 在 GPU 上运行的渲染管线基于光栅化算法。
术语光栅化来自这样一个事实,即多边形(在这种情况下为三角形)以某种方式分解为像素,并且我们知道由像素组成的图像称为光栅图像。从技术上讲,这个过程被称为将三角形光栅化为帧缓冲区的图像。
希望在本课的这一点上,您已经了解了使用光栅化方法生成 3D 场景(由三角形组成)图像的方式。当然,到目前为止我们描述的是算法的最简单形式。首先它可以大大优化,但是我们还没有解释当投影到屏幕上的两个三角形重叠图像中的相同像素时会发生什么。发生这种情况时,我们如何定义这两个(或更多)三角形中的哪一个对相机可见。我们现在来回答这两个问题。
优化:2D 三角形边界框
到目前为止,我们给出的光栅化算法的幼稚实现的问题在于,它需要在内部循环中迭代图像中的所有像素,即使三角形中可能只包含少量像素(如图所示)在图 3) 中。当然,这取决于屏幕中三角形的大小。但是考虑到我们对渲染一个三角形不感兴趣,而是对可能由几百到几百万个三角形组成的对象感兴趣,在典型的生产示例中,这些三角形不太可能在图像中非常大。
有多种方法可以最小化测试像素的数量,但最常见的一种是计算投影三角形的 2D 边界框,并迭代包含在该 2D 边界框中的像素而不是整个图像的像素。虽然其中一些像素可能仍位于三角形之外,但至少平均而言,它已经可以显着提高算法的性能。这个想法如图 3 所示。
计算三角形的 2D 边界框实际上非常简单。我们只需要在光栅空间中找到组成三角形的三个顶点的最小和最大 x 和 y 坐标。这在以下伪代码中进行了说明:
002
003
004
005
006
007
008
009
010
011
012
013
014
Vec2f bbmin = INFINITY, bbmax = -INFINITY;
vec2f vproj[3];
for ( int i = 0; i < 3; ++i) {
vproj[i] = projectAndConvertToNDC(triangle[i].v[i]);
// 坐标在光栅空间中,但仍然浮动而不是整数
vproj[i].x *= imageWidth;
vproj[i].y *= imageHeight;
如果(vproj[i].x < bbmin.x) bbmin.x = vproj[i].x);
如果(vproj[i].y < bbmin.y) bbmin.y = vproj[i].y);
如果(vproj[i].x > bbmax.x) bbmax.x = vproj[i].x);
如果(vproj[i].y > bbmax.y) bbmax.y = vproj[i].y);
}
一旦我们计算了三角形的 2D 边界框(在光栅空间中),我们只需要遍历该框定义的像素。但是您需要非常小心转换栅格坐标的方式,在我们的代码中,栅格坐标被定义为浮点数而不是整数。首先请注意,一个或两个顶点可能会投影到画布边界之外。因此,它们的光栅坐标可能小于 0 或大于图像大小。我们通过将像素坐标限制在 x 坐标范围 [0, Image Width - 1] 和 y 坐标范围 [0, Image Height - 1] 来解决这个问题。此外,我们需要将边界框的最小和最大坐标四舍五入到最接近的整数值(请注意,当我们迭代循环中的像素时,这可以正常工作,因为我们将变量初始化为 xmim 或 ymin 并在变量 x 或 y 小于或等于 xmas 或 ymax 时退出循环)。在使用循环中的最终固定点(或整数)边界框坐标之前,需要应用所有这些测试。这是伪代码:
002
003
004
005
006
007
008
009
010
011
012
013
uint xmin = std::max(0, std:min(imageWidth - 1, std::floor(min.x)));
uint ymin = std::max(0, std:min(imageHeight - 1, std::floor(min.y)));
uint xmax = std::max(0, std:min(imageWidth - 1, std::floor(max.x)));
uint ymax = std::max(0, std:min(imageHeight - 1, std::floor(max.y)));
for (y = ymin; y <= ymin; ++y) {
for (x = xmin; x <= xmax; ++x) {
// 检查当前像素是否在三角形中
if (pixelContainedIn2DTriangle(v0, v1, v2, x, y)) {
image(x,y) = triangle[i].color;
}
}
}
图像或帧缓冲区
我们的目标是生成场景的图像。我们有两种可视化程序结果的方法,或者将渲染图像直接显示到屏幕上,或者将图像保存到磁盘,然后使用 Photoshop 等程序来预览图像。但是在这两种情况下,我们都需要在渲染时存储正在渲染的图像,为此,我们使用我们在 CG 中称为图像或帧缓冲区的东西. 它只不过是具有图像大小的二维颜色数组。在渲染过程开始之前,会创建帧缓冲区并将像素全部设置为黑色。在渲染时,当三角形被光栅化时,如果给定的像素与给定的三角形重叠,那么我们将该三角形的颜色存储在该像素位置的帧缓冲区中。当所有三角形都被光栅化后,帧缓冲区将包含场景的图像。剩下要做的就是将缓冲区的内容显示到屏幕上,或者将其内容保存到文件中。在本课中,我们将选择后一种选项。
当两个三角形重叠相同像素时:深度缓冲区(或 Z 缓冲区)
请记住,光栅化算法的目标是解决可见性问题。为了显示 3D 对象,有必要确定哪些表面是可见的。在计算机图形学的早期,有两种方法用于解决“隐藏表面”问题(可见性问题的另一个名称):Newell 算法和z-buffer。由于历史原因,我们只提到了 Newell 算法,但我们不会在本课中学习它,因为它实际上已经不再使用了。我们将只研究 GPU 使用的 z-buffer 方法。
为了让基本的光栅化器正常工作,我们还需要做最后一件事。我们需要考虑一个事实,即多个三角形可能与图像中的同一像素重叠(如图 5 所示)。当这种情况发生时,我们如何确定哪个三角形是可见的?这个问题的解决方法其实很简单。我们将使用我们所说的z 缓冲区,也称为深度缓冲区,您可能已经经常听到或读到的两个术语。z 缓冲区只不过是另一个与图像具有相同维度的二维数组,但是它不是颜色数组,它只是一个浮点数数组。在我们开始渲染图像之前,我们将这个数组中的每个像素初始化为一个非常大的数字。当一个像素与三角形重叠时,我们还会读取存储在该像素位置的 z 缓冲区中的值。正如您可能猜到的那样,该数组用于存储从相机到图像中任何像素重叠的最近三角形的距离。由于这个值最初设置为无穷大(或任何非常大的数字),那么当然,我们第一次发现给定像素 X 与三角形 T1 重叠时,从相机到该三角形的距离必然低于存储在 z 缓冲区中的值。然后我们要做的是用到 T1 的距离替换为该像素存储的值。接下来,当测试相同的像素 X 并且我们发现它与另一个三角形 T2 重叠时,我们将相机到这个新三角形的距离与存储在 z 缓冲区中的距离进行比较(此时,存储到到第一个三角形 T1) 的距离。如果到第二个三角形的距离小于到第一个三角形的距离,则 T2 可见,而 T1 被 T2 隐藏。否则 T1 被 T2 隐藏,而 T2 可见。在第一种情况下,我们用到 T2 的距离更新 z-buffer 中的值,在第二种情况下,z-buffer 没有 t 需要更新,因为第一个三角形 T1 仍然是迄今为止我们为该像素找到的最接近的三角形。如你看到的z-buffer 用于存储每个像素到场景中最近物体的距离(我们并没有真正使用距离,但我们将在课程中进一步详细说明)。在图 5 中,我们可以看到红色三角形在 3D 空间中位于绿色三角形后面。如果我们首先渲染红色三角形,然后渲染绿色三角形,对于一个与两个三角形重叠的像素,我们必须在该像素位置的 z-buffer 中存储一个非常大的数字(当z-buffer 被初始化),然后是到红色三角形的距离,最后是到绿色三角形的距离。
您可能想知道我们如何找到从相机到三角形的距离。让我们首先看一下这个算法在伪代码中的实现,稍后我们会回到这一点(现在让我们假设函数pixelContainedIn2DTriangle为我们计算距离):
002
003
004
005
006
007
008
009
010
011
012
013
014
015
016
017
018
019
020
021
022
023
024
025
026
027
float buffer = new float [imageWidth * imageHeight];
// 将每个像素的距离初始化为一个非常大的数字
for (uint32_t i = 0; i < imageWidth * imageHeight; ++i)
buffer[i] = INFINITY;
for (场景中的每个三角形) {
// 投影顶点
...
// 计算投影三角形的 bbox
...
for (y = ymin; y <= ymin; ++y) {
for (x = xmin; x < = xmax; ++x) {
// 检查当前像素是否在三角形中
float z; // 相机到三角形的距离
if(pixelContainedIn2DTriangle(v0, v1, v2, x, y, z)) {
// 如果到该三角形的距离小于存储在
z-buffer 中的距离,则更新 z-buffer 并更新像素处的图像location (x,y)
// 使用该三角形的颜色
if (z < zbuffer(x,y)) {
zbuffer(x,y) = z;
图像(x,y)=三角形[i].color;
}
}
}
}
}
下一步是什么?
显然,这只是算法的一个非常高级的描述(图 6),但希望这已经让您了解我们在程序中需要什么来生成图像。我们会需要:
- 一个图像缓冲区(一个二维颜色数组),
- 一个深度缓冲区(一个二维浮点数组),
- 三角形(构成场景的几何图形),
- 将三角形的顶点投影到画布上的函数,
- 栅格化投影三角形的函数,
- 一些将图像缓冲区的内容保存到磁盘的代码。