光栅化是将图元转换为二维图像的过程。 该图像的每个点都包含颜色和深度等信息。 因此,对图元进行光栅化由两部分组成。 第一个是确定窗口坐标中整数网格的哪些方格被图元占据。 第二个是为每个这样的方块分配颜色和深度值。 (OpenGL 规范)
NSDT工具推荐: Three.js AI纹理开发包 - YOLO合成数据生成器 - GLTF/GLB在线编辑 - 3D模型格式在线转换 - 可编程3D场景编辑器 - REVIT导出3D模型插件 - 3D模型语义搜索引擎
在上一章中,我们学习了如何以某种方式执行光栅化算法的第一步,即将三角形从 3D 空间投影到画布上。 这个定义实际上并不完全准确,因为我们所做的是将三角形从相机空间转换到屏幕空间,正如上一章提到的,屏幕空间也是一个三维空间。 然而,屏幕空间中顶点的 x 和 y 坐标对应于画布上三角形顶点的位置,并且通过将它们从屏幕空间转换到 NDC 空间,然后最终从 NDC 空间转换到光栅空间, 我们最终得到的是光栅空间中顶点的二维坐标。 最后,我们还知道屏幕空间中顶点的 z 坐标保留相机空间中顶点的原始 z 坐标(反转以便我们处理正数而不是负数)。
图1:通过测试,如果图像中的像素与三角形重叠,我们就可以绘制出该三角形的图像。 这就是光栅化算法的原理
接下来我们需要做的是循环图像中的像素,并找出这些像素是否与“三角形的投影图像”重叠(图 1)。 在图形 API 规范中,此测试有时称为内部-外部测试(inside-outside test)或覆盖测试(coverage test)。 如果是,我们将图像中的像素设置为三角形的颜色。 这个想法很简单,但当然,我们现在需要想出一种方法来查找给定像素是否与三角形重叠。 这本质上就是我们在本章中要研究的内容。
我们将了解光栅化中通常使用的方法来解决这个问题。 它使用了一种称为边缘函数(edge function)的技术,我们现在将描述和研究该技术。 该边缘函数还将提供有关三角形投影图像内像素位置(称为重心坐标)的有价值的信息。 重心坐标在计算像素重叠的三角形表面上的点的实际深度(或 z 坐标)中起着至关重要的作用。 我们还将在本章中解释什么是重心坐标以及它们是如何计算的。
在本章结束时,你将能够生成一个非常基本的光栅化器(rasterizer)。 在下一章中,我们将研究这种非常简单的光栅化算法实现可能存在的问题。 我们将列出这些问题是什么,并研究它们通常是如何解决的。
为了优化算法,人们进行了大量的研究。 本课程的目标不是教你如何编写或开发基于光栅化算法的优化且高效的渲染器。 本课程的目标是教授渲染技术的基本原理。 不要认为我们在这些章节中介绍的技术没有被使用。 它们在某种程度上被使用,但它们在 GPU 或生产渲染器的 CPU 版本上的实现方式很可能是同一想法的高度优化版本。 真正重要的是理解其原理及其一般工作原理。 基于此,你可以自己研究用于加速算法的不同技术。 但本课程中介绍的技术是通用的,构成了任何光栅化器的基础。
请记住,绘制三角形(因为三角形是基元,我们将在本例中使用它)是一个两步问题:
- 我们首先需要找到哪些像素与三角形重叠。
- 然后,我们需要定义与三角形重叠的像素应设置为哪种颜色,这个过程称为着色
光栅化阶段主要涉及第一步。 我们说本质上而不是排他的原因是,在光栅化阶段,我们还将计算称为重心坐标(barycentric coordinates)的东西,在某种程度上,它在第二步中使用。
1、边缘函数
如上所述,有几种可能的方法来查找像素是否与三角形重叠。 最好记录较旧的技术,但在本课中,将仅介绍当今普遍使用的方法。 该方法由 Juan Pineda 在 1988 年提出,论文名为“A Parallel Algorithm for Polygon Rasterization”。
图2:Pineda方法的原理是找到一个函数,这样当我们测试给定点在这条线的哪一侧时,函数在该点位于该线的左侧时返回正数,在该线的左侧时返回负数 它位于该线的右侧,当该点恰好在线上时为零。
在我们研究Pineda的技术之前,我们首先描述一下他的方法的原理。 假设三角形的边可以看作将 2D 平面(图像平面)一分为二的线(如图 2 所示)。 Pineda方法的原理是找到一个他称之为边缘函数(edge function)的函数,这样当我们测试给定的点(图2中的点P)在这条线的哪一侧时,该函数返回一个负数。 在线左侧,当该点在线右侧时为正数,当该点恰好在线上时为零。
在图 2 中,我们将此方法应用于三角形的第一条边(由顶点 v0-v1 定义。请注意,顺序很重要)。 如果我们现在对另外两条边(v1-v2 和 v2-v0)应用相同的方法,我们可以看到有一个区域(白色三角形),其中所有点都为正(图 3):
图 3:白色区域内包含的点均位于三角形所有三个边的右侧
如果我们在这个区域内取一个点,那么我们会发现这个点位于三角形所有三个边的右侧。 如果 P 是像素中心的点,我们就可以使用此方法来查找该像素是否与三角形重叠。 如果对于这一点,我们发现边缘函数对于所有三个边缘都返回正数,则该像素包含在三角形中(或者可能位于其边缘之一)。 Pinada 使用的函数也恰好是线性的,这意味着它可以增量计算,但我们稍后会回到这一点。
明白了原理之后,我们就来看看这个函数是什么。 边函数定义为(对于由顶点 V0 和 V1 定义的边):
正如论文中提到的,该函数具有一个有用的属性,即它的值与点 (x,y) 相对于点 V0 和 V1 定义的边缘的位置有关:
- 如果 P 位于“右侧”,则 E(P) > 0
- 如果 P 正好在线上,则 E(P) = 0
- 如果 P 位于“左侧”,则 E(P) < 0
该函数在数学上相当于向量 (v1-v0) 和 (P-v0) 之间叉积的大小。 我们还可以将这些向量写成矩阵形式(将其呈现为矩阵除了整齐地呈现两个向量之外没有其他兴趣):
如果我们设 A=(P-V0)且B=(V1-v0),那么也可以将向量 A 和 B 写成 2x2 矩阵:
该矩阵的行列式可以计算为:
如果现在再次用向量 (P-V0) 和 (V1-V0) 替换向量 A 和 B,将得到:
正如你所看到的,它与我们上面定义的边缘函数类似。 换句话说,边缘函数可以被视为由 2D 向量 (P-v0) 和 (v1-v0) 的分量定义的 2x2 矩阵的行列式,也可以被视为向量 ( P-V0) 和 (V1-V0)。 两个向量的叉积的行列式和大小都具有相同的几何解释。 让我们解释一下。
当我们查看两个 3D 向量之间的叉积结果时,会更容易理解正在发生的情况(图 4):
图 4:向量 B(蓝色)和 A(红色)的叉积得出向量 C(绿色),该向量垂直于 A 和 B 定义的平面(假设采用右手定则约定)。 矢量 C 的大小取决于 A 和 B 之间的角度。它可以是正值,也可以是负值
在 3D 中,叉积返回与两个原始向量垂直(或正交)的另一个 3D 向量。 但正如你在图 4 中看到的,该正交向量的大小也会随着两个向量相对于彼此的方向而变化。 在图 4 中,我们假设右手坐标系。 当两个向量 A(红色)和 B(蓝色)完全指向相同方向或相反方向时,第三个向量 C(绿色)的大小为零。 矢量 A 的坐标为 (1,0,0) 并且是固定的。 当向量B的坐标为(0,0,-1)时,则绿色向量、向量C的坐标为(0,-1,0)。 如果我们要找到它的“有符号”大小,我们会发现它等于-1。 另一方面,当向量 B 的坐标为 (0,0,1) 时,C 的坐标为 (0,1,0),且其有符号量值等于 1。在一种情况下,“有符号”量值为负,并且在第二种情况下,有符号的幅度为正。
事实上,在 3D 中,向量的大小可以解释为以 A 和 B 为边的平行四边形的面积:
如图 5 所示:
图5:平行四边形的面积是向量A和B形成的矩阵的行列式的绝对值
尽管上式的符号指示了向量 A 和 B 相对于彼此的方向,但面积应始终为正。 当相对于A,B位于向量A和与A正交的向量(我们称这个向量D;注意A和D形成二维笛卡尔坐标系)定义的半平面内时,则方程的结果为正的。 当 B 在相对的半平面内时,方程的结果为负(图 6):
图6:平行四边形的面积是向量A和B构成的矩阵的行列式的绝对值。
解释该结果的另一种方式是,当角度theta是在范围[0,pi]内时为正,在范围[pi, 2*pi]内为负。请注意,当theta正好等于 0 或pi时,那么叉积或边缘函数返回 0。
如果角度theta小于pi,那么“有符号”面积就是正数。 如果角度大于pi,那么“有符号”面积就是负数。 该角度是相对于由向量 A 和 D 定义的笛卡尔坐标计算的。可以看出它们将平面分成两半。
为了确定一个点是否在三角形内,我们真正关心的是用于计算平行四边形面积的函数的符号。 然而,区域本身在光栅化算法中也起着重要作用; 它用于计算三角形中点的重心坐标,这是我们接下来将研究的技术。 3D 和 2D 中的叉积具有相同的几何解释,因此两个 2D 向量之间的叉积也返回由这两个向量定义的平行四边形的“有符号”面积。 唯一的区别是,在 3D 中,要计算平行四边形的面积,你需要使用以下方程:
而在 2D 中,该面积由叉积本身给出(如前所述,也可以解释为 2x2 矩阵的行列式):
从实际角度来看,我们现在需要做的就是测试为三角形的每条边计算的边函数的符号以及由点和边的第一个顶点定义的另一个向量(图 7):
图 7:如果边函数对于三个指示的向量对返回正数,则 P 包含在三角形中
公式如下:
如果所有三个测试均为正或等于 0,则该点位于三角形内部(或位于三角形的一条边上)。 如果任何一项测试为负,则该点位于三角形之外。 在代码中我们得到:
bool edgeFunction(const Vec2f &a, const Vec3f &b, const Vec2f &c)
{
return ((c.x - a.x) * (b.y - a.y) - (c.y - a.y) * (b.x - a.x) >= 0);
}
bool inside = true;
inside &= edgeFunction(V0, V1, p);
inside &= edgeFunction(V1, V2, p);
inside &= edgeFunction(V2, V0, p);
if (inside == true) {
// point p is inside triangles defined by vertices v0, v1, v2
...
}
2、边缘函数的替代方案
除了边缘函数方法之外,还有其他方法来查找像素是否与三角形重叠,但是正如本章介绍中提到的,我们在本课中不会研究它们。 但仅供参考,另一种常见技术称为扫描线光栅化(scanline rasterization)。 它基于通常用于画线的Brenseham算法。 GPU 使用边缘方法主要是因为它比扫描线方法更通用,扫描线方法也比边缘方法更难并行运行,但我们不会在本课中提供有关此主题的更多信息。
3、当心! 边缘顺序问题
图8:顺时针和逆时针环绕
我们一直在讨论但在 CG 中非常重要的事情之一是声明构成三角形的顶点的顺序。 它们是两种可能的约定,如图 8 所示:顺时针或逆时针排序或环绕。 顺序很重要,因为它本质上定义了三角形的一个重要属性,即法线的方向。 请记住,三角形的法线可以通过两个向量 A=(V2-V0) 和 B=(V1-V0) 的叉积来计算。 假设 V0={0,0,0}、V1={1,0,0} 且 V2={0,-1,0},则 (V1-V0)={1,0,0} 且 (V2 -V0)={0,-1,0}。 现在让我们计算这两个向量的叉积:
但是,如果按逆时针顺序声明顶点,则 V0={0,0,0}、V1={0,-1,0} 且 V2={1,0,0}、(V1-V0)= {0,-1,0} 且 (V2-V0)={1,0,0}。 让我们再次计算这两个向量的叉积:
图 9:顺序定义了法线的方向
图 10:顺序定义三角形内的点是正值还是负值
正如预期的那样,两条法线指向相反的方向。 由于许多不同的原因,法线的方向非常重要,但最重要的原因之一是面部剔除。 大多数光栅化器甚至光线追踪器都可能无法渲染法线背向相机的三角形。 这称为背面剔除(backface culling)。 大多数渲染 API(例如 OpenGL 或 DirectX)都提供关闭背面剔除的选项,但是,你仍然应该意识到顶点排序在渲染内容等方面发挥着重要作用。 毫不奇怪,边缘函数是其中之一。
在我们解释为什么它在我们的特定情况下很重要之前,我们先假设在选择顺序时没有特定的规则。 实际上,渲染器实现中的许多细节可能会改变法线的方向,因此不能假设通过按特定顺序声明顶点,你将得到法线将以某种方式定向的保证。 例如,可以使用 (V0-V1) 和 (V2-V1) 来代替叉积中的向量 (V1-V0) 和 (V2-V0)。 它会产生相同的法线,但会翻转。 即使你使用向量 (V1-V0) 和 (V2-V0),请记住叉积中向量的顺序会改变法线的符号:A x B = - B x A。因此法线的方向也取决于叉积中向量的顺序。 出于所有这些原因,不要试图假设以一种顺序而不是另一种顺序声明顶点会给你一个结果或另一个结果。 但重要的是,一旦你坚持了你所选择的约定。 通常,OpenGL 和 DirectX 等图形 API 期望以逆时针顺序声明三角形。 我们还将使用逆时针环绕。 现在让我们看看排序如何影响边缘函数。
为什么环绕书讯对于边缘函数很重要? 你可能已经注意到,从本章开始,在所有图中我们都是按顺时针顺序绘制三角形顶点的。 我们还将边缘函数定义为:
如果我们遵守这个约定,那么顶点 A 和 B 定义的线右侧的点将为正。 例如,V0V1、V1V2 或 V2V0 右侧的点将为正。 然而,如果我们以逆时针顺序声明顶点,则顶点 A 和 B 定义的边右侧的点仍为正,但它们将位于三角形之外。 换句话说,与三角形重叠的点不是正值而是负值(图 10)。 你仍然可以对边缘函数稍加修改即可使代码处理正数:
总之,根据你使用的边缘排序约定,可能需要使用边缘函数的一个或另一个版本。
4、重心坐标
图11:平行四边形的面积是三角形面积的两倍
无需计算重心坐标(barycentric coordinates)即可使光栅化算法正常工作。 对于渲染技术的简单实现,你所需要的只是投影顶点并使用我们上面描述的边缘函数之类的技术来查找像素是否在三角形内部。 这是生成图像唯一必要的两个步骤。
然而,正如我们上面所解释的,边缘函数的结果可以解释为由向量 A 和 B 定义的平行四边形的面积,可以直接用于计算这些重心坐标。 因此,同时研究边缘函数和重心坐标是有意义的。
在我们进一步讨论之前,让我们解释一下这些重心坐标是什么。 首先,它们以一组三个浮点数形式出现,在本课中,我们将表示为λ0、λ1和λ2。存在许多不同的约定,但维基百科也使用希腊字母 lambda (λ),同时也被其他作者使用(希腊字母 omega有时也使用)。 这并不重要,你可以按照你想要的方式称呼它们。 简而言之,坐标可用于按以下方式定义三角形上的任意点:
通常,V0、V1 和 V2 是三角形的顶点。 这些坐标可以采用任何值,但对于三角形内部(或位于其边缘之一)的点,它们只能在 [0,1] 范围内,并且三个坐标之和等于 1。 也就是说:
如果你愿意的话,这是一种插值形式。 它们有时也被定义为三角形顶点的权重(这就是为什么在代码中我们将用字母 w 表示它们)。 与三角形重叠的点可以定义为“一点点V0加一点点V1加一点点V2”。 请注意,当任何坐标为 1(这意味着在这种情况下其他坐标必然为 0)时,点 P 等于三角形的一个顶点。 例如如果
那么P等于V2。
图12:我们如何找到P的颜色?
插值三角形的顶点来查找三角形内点的位置并不是很有用。 但该方法也可用于在三角形表面上插值在三角形顶点处定义的任何量或变量。 例如,假设你在三角形的每个顶点定义了一种颜色。 假设 V0 为红色,V1 为绿色,V2 为蓝色(图 12)。 你想要做的是找出这三种颜色如何在三角形表面上插值。 如果你知道三角形上点 P 的重心坐标,那么它的颜色Cp(三角形顶点颜色的组合)定义为:
这是一种非常方便的技术,对于给三角形着色非常有用。 与三角形顶点相关的数据称为顶点属性(vertex attribute)。 这是CG中非常常见也非常重要的技术。 最常见的顶点属性是颜色、法线和纹理坐标。 这在实践中意味着,通常当你定义三角形时,不仅将三角形顶点传递给渲染器,而且还将其关联的顶点属性传递给渲染器。 例如,如果要对三角形进行着色,则可能需要颜色和法线顶点属性,这意味着每个三角形将由 3 个点(三角形顶点位置)、3 种颜色(三角形顶点的颜色)和 3 个点定义。 法线(三角形顶点的法线)。 法线也可以在三角形的表面上进行插值。 插值法线用于一种称为平滑着色(smooth shading)的技术,该技术由 Henri Gouraud 首次提出。 稍后我们将在讨论着色时解释这项技术。
我们如何找到这些重心坐标? 事实证明很简单。 如上所述,当我们提出边缘函数时,边缘函数的结果可以解释为由向量 A 和 B 定义的平行四边形的面积。如果你看图 8,你可以很容易地看到三角形的面积 由顶点 V0、V1 和 V2 定义的,只是由向量 A 和 B 定义的平行四边形面积的一半。因此,三角形的面积是平行四边形面积的一半,我们知道可以通过交叉计算 -两个 2D 向量 A 和 B 的乘积:
图13:将P连接到三角形的每个顶点形成三个子三角形
如果点 P 在三角形内部,那么通过图 13 可以看出,我们可以绘制三个子三角形:V0-V1-P(绿色)、V1-V2-P(洋红色)和 V2-V0 -P(青色)。 很明显,这三个子三角形面积之和等于三角形 V0-V1-V2 的面积:
图 14:λ0、λ1 和 λ2 的值取决于 P 在三角形上的位置
让我们首先尝试直观地了解它们是如何工作的。 如果你查看图 14,这将变得更容易。该系列中的每个图像都显示了当点 P(最初位于由顶点 V1-V2 定义的边缘上)向 V0 移动时,子三角形会发生什么情况。 一开始,P 正好位于边 V1-V2 上。 在某种程度上,这类似于两点之间的基本线性插值。 换句话说,我们可以这样写:
由于 λ1+λ2=1,因此 λ2=1-λ1。 在这种特殊情况下更有趣的是,如果使用重心坐标计算 P 位置的通用方程为:
因此,它清楚地表明,在这种特殊情况下,λ0 等于 0:
这很简单。 另请注意,在第一张图像中,红色三角形不可见。 另请注意,P 与 V1 的距离比与 V2 的距离更近。 因此,不知何故,λ1 必然大于 λ2。 另请注意,在第一张图像中,绿色三角形比蓝色三角形大。 因此,如果我们总结一下:当红色三角形不可见时,λ0 等于 0。λ1 大于 λ2,绿色三角形大于蓝色三角形。 因此,在某种程度上,三角形的面积和重心坐标之间似乎存在某种关系。 此外,红色三角形似乎与 λ0 相关,绿色三角形与 λ1 相关,蓝色三角形与 λ2 相关:
图 15:要计算重心坐标之一,请使用由 P 定义的三角形面积以及与需要计算重心坐标的顶点相对的边
还要注意,在这种特殊情况下,蓝色和绿色三角形消失了,并且三角形 V0-V1-V2 的面积与红色三角形的面积相同。 这证实了我们的直觉,即子三角形的面积和重心坐标之间存在关系。 最后,根据上述观察,我们还可以说每个重心坐标与由与重心坐标关联的顶点直接相对的边和点 P 定义的子三角形的面积有某种关系。换句话说( 图15):
- λ0 与 V0 相关。 V0 相对的边是 V1-V2。 V1-V2-P 定义红色三角形。
- λ1 与 V1 相关。 V1 相对的边是 V2-V0。 V2-V0-P 定义绿色三角形。
- λ2 与V2 相关。 V2 相对的边是 V0-V1。 V0-V1-P 定义蓝色三角形。
如果你还没有注意到,红色、绿色和蓝色三角形的面积是由我们之前用来确定 P 是否在三角形内部的相应边缘函数给出的,除以 2(记住边缘函数 本身给出由两个向量 A 和 B 定义的平行四边形的“有符号”区域,其中 A 和 B 可以是三角形的三个边中的任何一个):
重心坐标可以计算为子三角形面积与三角形面积 V0V1V2 之间的比率:
除以三角形面积的作用本质上是标准化坐标。 例如,当 P 与 V0 位置相同时,则三角形 V2V1P(红色三角形)的面积与三角形 V0V1V2 的面积相同。 因此,1 除以 over 得到 1,即坐标 λ0 的值。 由于在这种情况下,绿色和蓝色三角形的面积为 0,因此 λ1 和 λ2 等于 0,我们得到:
这正是我们所期望的。
为了计算三角形的面积,我们可以使用前面提到的边函数。 这适用于子三角形以及主三角形 V0V1V2。 然而,边缘函数返回平行四边形的面积而不是三角形的面积(图 8),但由于重心坐标是根据子三角形面积与主三角形面积之比计算的,因此我们可以忽略除以 2 (分子和分母中的除法相互抵消):
注意:
让我们看看它在代码中的样子。 我们之前已经计算了边函数来测试点是否在三角形内。 只是,在我们之前的实现中,我们只是根据函数的结果是正还是负来返回 true 或 false。 为了计算重心坐标,我们需要边缘函数的实际结果。 我们还可以使用边函数来计算三角形的面积(乘以 2)。 下面是一个实现版本,用于测试点 P 是否在三角形内,如果是,则计算其重心坐标:
float edgeFunction(const Vec2f &a, const Vec3f &b, const Vec2f &c)
{
return (c.x - a.x) * (b.y - a.y) - (c.y - a.y) * (b.x - a.x);
}
float area = edgeFunction(v0, v1, v2); // area of the triangle multiplied by 2
float w0 = edgeFunction(v1, v2, p); // signed area of the triangle v1v2p multiplied by 2
float w1 = edgeFunction(v2, v0, p); // signed area of the triangle v2v0p multiplied by 2
float w2 = edgeFunction(v0, v1, p); // signed area of the triangle v0v1p multiplied by 2
// if point p is inside triangles defined by vertices v0, v1, v2
if (w0 >= 0 && w1 >= 0 && w2 >= 0) {
// barycentric coordinates are the areas of the sub-triangles divided by the area of the main triangle
w0 /= area;
w1 /= area;
w2 /= area;
}
让我们尝试使用此代码来生成实际图像。
5、插值与外推
图 16:内插法与外推法
值得注意的一件事是,重心坐标的计算与其相对于三角形的位置无关。 换句话说,如果该点位于三角形外部,则坐标有效。 当点在内部时,使用重心坐标来评估顶点属性的值称为插值(interpolation),当点在外部时,我们称为外插(extrapolation)。 这是一个重要的细节,因为在某些情况下,我们必须评估可能不与三角形重叠的点的给定顶点属性的值。 更具体地说,例如,需要计算三角形纹理坐标的导数。 这些导数用于正确过滤纹理。 如果你有兴趣了解有关此特定主题的更多信息,可以阅读有关纹理映射的课程。 同时,你需要记住的是,即使该点没有覆盖三角形,重心坐标也是有效的。 你还需要了解顶点属性外插和插值之间的区别。
6、光栅化规则
在某些特殊情况下,一个像素可能与多个三角形重叠。 当像素恰好位于两个三角形共享的边缘上时,就会发生这种情况,如图 17 所示:
图 17:像素可能覆盖两个三角形共享的边缘
这样的像素将通过两个三角形的覆盖测试。 如果它们是半透明的,由于半透明对象的组合方式,像素与两个三角形重叠的地方可能会出现暗边缘(想象一下两个叠加的半透明塑料片。表面更加不透明,并且 看起来比单张纸更暗)。 您将得到类似于图 18 中看到的内容,这是一条较暗的线,两个三角形共享一条边:
图 18:如果几何体是半透明的,则像素与两个三角形重叠的位置可能会出现暗边
这个问题的解决方案是提出某种规则,保证一个像素永远不会与共享一条边的两个三角形重叠两次。 我们该怎么做呢? 大多数图形 API(例如 OpenGL 和 DirectX)都定义了所谓的左上角规则。 我们已经知道,如果一个点位于三角形内部或位于任何三角形边上,则覆盖测试将返回 true。 不过,左上角规则所说的是,如果像素或点位于三角形内部或位于三角形的顶边或任何被视为左边的边上,则该像素或点被视为与三角形重叠。 什么是顶边(top edge)和左边(left edge)? 如果查看图 19,你可以轻松明白顶部和左侧边缘的含义:
图 19:顶部和左侧边缘
- 顶边是完全水平的边,其定义顶点位于第三个顶点之上。 从技术上讲,这意味着向量 V[(X+1)%3]-V[X] 的 y 坐标等于 0,并且其 x 坐标为正(大于 0)。
- 左边缘本质上是向上的边缘。 请记住,在我们的例子中,顶点是按顺时针顺序定义的。 如果边缘各自的向量 V[(X+1)%3]-V[X](其中 X 可以是 0、1、2)具有正 y 坐标,则认为边缘向上。
在伪代码中我们有:
// Does it pass the top-left rule?
Vec2f v0 = { ... };
Vec2f v1 = { ... };
Vec2f v2 = { ... };
float w0 = edgeFunction(v1, v2, p);
float w1 = edgeFunction(v2, v0, p);
float w2 = edgeFunction(v0, v1, p);
Vec2f edge0 = v2 - v1;
Vec2f edge1 = v0 - v2;
Vec2f edge2 = v1 - v0;
bool overlaps = true;
// If the point is on the edge, test if it is a top or left edge,
// otherwise test if the edge function is positive
overlaps &= (w0 == 0 ? ((edge0.y == 0 && edge0.x > 0) || edge0.y > 0) : (w0 > 0));
overlaps &= (w1 == 0 ? ((edge1.y == 0 && edge1.x > 0) || edge1.y > 0) : (w1 > 0));
overlaps &= (w1 == 0 ? ((edge2.y == 0 && edge2.x > 0) || edge2.y > 0) : (w2 > 0));
if (overlaps) {
// pixel overlap the triangle
...
}
该版本作为概念证明是有效的,但高度未优化。 关键思想是首先检查返回函数返回的值是否等于 0,这意味着该点位于边缘上。 在本例中,我们测试相关边缘是否是左上角边缘。 如果是,则返回 true。 如果edge函数返回的值不等于0,那么如果该值大于0,我们就返回true。本课提供的程序中我们不会实现左上角规则。
7、整合:查找像素是否与三角形重叠
让我们在生成实际图像的程序中测试本章中学到的不同技术。 假设我们已经投影了三角形(查看本课的最后一章以了解光栅化算法的完整实现)。 我们还将为三角形的每个顶点分配一种颜色。 以下是图像的形成方式。 我们将循环图像中的所有像素,并使用边缘函数方法测试它们是否与三角形重叠。 三角形的所有三个边都根据像素的当前位置进行测试,如果边缘函数为所有边返回正数,则像素与三角形重叠。 然后,我们可以计算像素的重心坐标,并通过对三角形每个顶点定义的颜色进行插值,使用这些坐标来对像素进行着色。 帧缓冲区的结果保存到 PPM 文件(您可以使用 Photoshop 读取该文件)。 程序的输出如图 20 所示:
图 20:使用重心坐标进行顶点属性线性插值的示例
请注意,该程序的一种可能的优化是循环遍历三角形边界框中包含的像素。 我们没有在此版本的程序中进行此优化,但如果你愿意,可以自己进行优化。
另请注意,在此版本的程序中,我们将点 P 移动到每个像素的中心。你也可以使用像素整数坐标。 我们将在下一章中介绍有关此主题的更多详细信息。
// c++ -o raster2d raster2d.cpp
// (c) www.scratchapixel.com
#include <cstdio>
#include <cstdlib>
#include <fstream>
typedef float Vec2[2];
typedef float Vec3[3];
typedef unsigned char Rgb[3];
inline
float edgeFunction(const Vec2 &a, const Vec2 &b, const Vec2 &c)
{ return (c[0] - a[0]) * (b[1] - a[1]) - (c[1] - a[1]) * (b[0] - a[0]); }
int main(int argc, char **argv)
{
Vec2 v0 = {491.407, 411.407};
Vec2 v1 = {148.593, 68.5928};
Vec2 v2 = {148.593, 411.407};
Vec3 c0 = {1, 0, 0};
Vec3 c1 = {0, 1, 0};
Vec3 c2 = {0, 0, 1};
const uint32_t w = 512;
const uint32_t h = 512;
Rgb *framebuffer = new Rgb[w * h];
memset(framebuffer, 0x0, w * h * 3);
float area = edgeFunction(v0, v1, v2);
for (uint32_t j = 0; j < h; ++j) {
for (uint32_t i = 0; i < w; ++i) {
Vec2 p = {i + 0.5f, j + 0.5f};
float w0 = edgeFunction(v1, v2, p);
float w1 = edgeFunction(v2, v0, p);
float w2 = edgeFunction(v0, v1, p);
if (w0 >= 0 && w1 >= 0 && w2 >= 0) {
w0 /= area;
w1 /= area;
w2 /= area;
float r = w0 * c0[0] + w1 * c1[0] + w2 * c2[0];
float g = w0 * c0[1] + w1 * c1[1] + w2 * c2[1];
float b = w0 * c0[2] + w1 * c1[2] + w2 * c2[2];
framebuffer[j * w + i][0] = (unsigned char)(r * 255);
framebuffer[j * w + i][1] = (unsigned char)(g * 255);
framebuffer[j * w + i][2] = (unsigned char)(b * 255);
}
}
}
std::ofstream ofs;
ofs.open("./raster2d.ppm");
ofs << "P6\n" << w << " " << h << "\n255\n";
ofs.write((char*)framebuffer, w * h * 3);
ofs.close();
delete [] framebuffer;
return 0;
}
正如你所看到的,我们可以说光栅化算法本身非常简单(并且该技术的基本实现也非常简单)。
8、下一步是什么?
有许多与重心坐标主题相关的有趣技术和琐事,但本课只是光栅化算法的介绍,因此我们不会再进一步。 不过,有一个有趣的细节是,重心坐标沿着平行于边缘的线是恒定的(如图 21 所示):
图 21:沿着平行于边缘的线,重心坐标是恒定的
在本课中,我们学习了两个重要的方法和各种概念。
首先,我们了解了边缘函数以及如何使用它来查找点 P 是否与三角形重叠。 为三角形的每条边计算边函数,并由边的第一个顶点和另一个点 P 定义第二个向量。如果对于所有三个边,函数均为正,则点 P 与三角形重叠。
此外,我们还了解到,边函数的结果还可以用于计算点 P 的重心坐标。这些坐标可用于在三角形表面上插值顶点数据或顶点属性。 它们可以被解释为各个顶点的权重。 最常见的顶点属性是颜色、法线和纹理坐标。
原文链接:光栅化算法实现 - BimAnt