1. 项目概述:为什么我们需要重心坐标?
在图形学里,我们经常要处理三角形。无论是渲染一个3D模型,还是计算一个像素的颜色,三角形都是最基本的图元。但问题来了:给你一个三角形和三角形内部的一个点P,你怎么精确地描述P的位置?用屏幕的x, y坐标吗?这显然不行,因为三角形可能旋转、缩放、变形,屏幕坐标是“外部”的,无法描述点与三角形三个顶点的相对关系。
这就是重心坐标要解决的核心问题。它提供了一种在三角形内部定义位置的“内部坐标系”。简单来说,它用三个数字(α, β, γ)来表示点P,这三个数字分别代表了三个顶点(A, B, C)对点P位置的“贡献”或“权重”。并且,这三个权重之和永远为1(α + β + γ = 1)。
我第一次接触这个概念是在写一个软件光栅化器的时候。我需要判断一个像素中心是否在三角形内,如果在,还需要根据三个顶点的颜色(或纹理坐标、法线等属性)来插值出这个像素的颜色。如果不用重心坐标,你几乎无法优雅且高效地完成这件事。你会陷入复杂的面积比计算或者解线性方程组的麻烦中。重心坐标就像一把瑞士军刀,它把“点是否在三角形内”、“点的属性如何从顶点插值”这两个图形学中的高频问题,统一到了一个简洁的数学框架下。
所以,无论你是刚入门图形编程的新手,还是正在优化渲染管线的老手,透彻理解重心坐标都是绕不开的一步。它不仅是基础,更是连接几何、着色与光栅化各个环节的关键桥梁。接下来,我们就从最根本的原理开始,拆解这把“瑞士军刀”的每一个零件。
1.1 核心需求解析:从几何直觉到数学定义
我们从一个最简单的几何问题开始:已知三角形ABC,以及其内部一点P。如何用A, B, C三个点来表示P?
最直观的想法是面积。连接P点和三个顶点,会把大三角形ABC分割成三个小三角形:△PBC, △PCA, △PAB。
- 点P越靠近顶点A,那么顶点A对面的小三角形△PBC的面积就越大。
- 如果点P就在顶点A上,那么△PBC的面积就等于整个大三角形ABC的面积,而△PCA和△PAB的面积则为0。
这个观察直接引出了重心坐标最经典的面积定义法。我们定义:
- α = 面积(△PBC) / 面积(△ABC)
- β = 面积(△PCA) / 面积(△ABC)
- γ = 面积(△PAB) / 面积(△ABC)
显然,α + β + γ = (面积(△PBC)+面积(△PCA)+面积(△PAB)) / 面积(△ABC) = 1。并且,当P在三角形内部时,α, β, γ 都是非负的(因为面积非负)。如果有一个坐标为负,比如α < 0,那就意味着点P在BC边的“外侧”,与顶点A位于BC边的异侧。
因此,重心坐标(α, β, γ)完美地描述了点P相对于三角形ABC的位置:
- 三个坐标都在[0, 1]区间内:点P在三角形内部(包括边和顶点)。
- 有一个坐标为负:点P在三角形外部。
- 坐标(1, 0, 0)对应顶点A,(0, 1, 0)对应B,(0, 0, 1)对应C。
- 坐标(1/3, 1/3, 1/3)对应三角形的几何重心(三条中线的交点)。
注意:这里有一个非常重要的细节。我们通常说的“重心”在物理学和几何学里是同一个概念(质心),对于密度均匀的三角形,它就是三条中线的交点,其重心坐标正是(1/3, 1/3, 1/3)。这也是“重心坐标”这个名字的由来。但在图形学中,我们更多地是利用它的“权重”和“插值”特性。
从面积定义出发,我们可以推导出更便于计算的解析形式。假设我们在二维平面上,点A, B, C, P的坐标分别为(x_A, y_A), (x_B, y_B), (x_C, y_C), (x, y)。三角形面积可以用二维向量的叉积模长来计算(叉积的模长等于两向量所围平行四边形面积的一半)。
以α为例,α = Area(PBC) / Area(ABC)。向量PB = (x_B - x, y_B - y),向量PC = (x_C - x, y_C - y)。在二维中,叉积 PB × PC 的结果是一个标量(实际上是z分量),其值为 (x_B - x)(y_C - y) - (y_B - y)(x_C - x)。这个值的绝对值就是平行四边形PBPC的面积,一半就是三角形PBC的面积。
为了避免绝对值带来的符号判断麻烦,我们可以使用有向面积。定义整个三角形ABC的有向面积为:Area_ABC = ( (x_B - x_A)*(y_C - y_A) - (y_B - y_A)*(x_C - x_A) ) / 2同理,三角形PBC的有向面积为:Area_PBC = ( (x_B - x)*(y_C - y) - (y_B - y)*(x_C - x) ) / 2
那么,α = Area_PBC / Area_ABC。神奇的是,这样计算出来的α表达式,恰好可以整理成关于x, y的一次线性函数。β和γ同理。最终,我们可以得到重心坐标的计算公式:
α = ((y_B - y_C)*(x - x_C) + (x_C - x_B)*(y - y_C)) / ((y_B - y_C)*(x_A - x_C) + (x_C - x_B)*(y_A - y_C)) β = ((y_C - y_A)*(x - x_C) + (x_A - x_C)*(y - y_C)) / ((y_B - y_C)*(x_A - x_C) + (x_C - x_B)*(y_A - y_C)) γ = 1 - α - β分母是三角形ABC有向面积的两倍,是一个常数。分子分别是三角形PBC, PCA, PAB有向面积的两倍。这个公式是许多图形API和教科书中的标准形式,它直接给出了通过点坐标计算重心坐标的方法,无需先显式计算面积。
2. 核心原理与数学推导
理解了面积定义的直观意义后,我们需要深入到数学层面,看看重心坐标更一般化的定义和性质。这对于理解它在三维空间、甚至更高维空间,以及在透视投影下的行为至关重要。
2.1 从面积比到线性组合
让我们换一个角度。既然点P可以用三个顶点来表示,我们不妨假设存在一组权重(α, β, γ),使得:P = α * A + β * B + γ * C并且满足α + β + γ = 1。
这就是重心坐标的向量形式定义。它把点P表示为顶点A, B, C的仿射组合(带权重和为1的线性组合)。如果我们把A, B, C, P都看作位置向量,这个方程在二维和三维空间都成立。
为什么权重和必须为1?我们可以从几何和代数两个角度理解。
- 几何角度:考虑一个一维情况,线段AB上的点P可以表示为 P = (1-t)A + tB,这里(1-t) + t = 1。推广到二维的三角形,这个“权重和为1”的条件保证了点P位于由A, B, C张成的平面(或直线)上,而不是在这个平面之外。如果权重和不为1,得到的点会沿着从原点出发的方向进行缩放,脱离了ABC所在的仿射空间。
- 代数角度:我们将P = αA + βB + γC 改写为 P - A = β(B - A) + γ(C - A)。这表示向量AP可以用平面上的两个基向量AB和AC的线性组合来表示。这里α消失了,因为它被约束条件 α = 1 - β - γ 代换了。这个形式清晰地表明,点P位于以A为原点,以AB和AC为基向量的平面上。
现在,我们把向量形式定义P = αA + βB + γC和约束α + β + γ = 1写在一起。这是一个包含α, β, γ三个未知数的方程组(在二维是2个坐标方程+1个约束,共3个方程;在三维是3个坐标方程+1个约束,共4个方程,但其中只有3个是独立的)。求解这个方程组,就能得到α, β, γ的解析表达式,而这个表达式经过整理,恰好就是我们之前通过面积比推导出来的公式!这证明了面积定义和向量定义是等价的。
2.2 重心坐标的几何与代数性质
重心坐标拥有一系列优美而实用的性质,这些性质是它在图形学中得以广泛应用的基础。
仿射不变性(Affine Invariance):这是最重要的性质之一。如果对空间进行一个仿射变换(线性变换+平移),例如旋转、缩放、剪切或平移,点P的新坐标可以通过对顶点A, B, C施加同样的变换后,再用相同的(α, β, γ)权重组合得到。也就是说,重心坐标在仿射变换下保持不变。
- 公式表达:设T是一个仿射变换,T(P) = M * P + t,其中M是线性变换矩阵,t是平移向量。那么有:
T(P) = T(αA + βB + γC) = α * T(A) + β * T(B) + γ * T(C)因为仿射变换保持线性组合和权重和为1的性质。 - 应用意义:这意味着我们可以在模型空间(局部坐标)计算好重心坐标,然后对顶点进行变换(到世界空间、观察空间),最后用同样的重心坐标去插值变换后的顶点属性,结果依然是正确的。这为顶点着色器中的属性插值提供了理论依据。
- 公式表达:设T是一个仿射变换,T(P) = M * P + t,其中M是线性变换矩阵,t是平移向量。那么有:
插值属性(Attribute Interpolation):这是重心坐标在渲染中的核心用途。假设三角形的三个顶点拥有某些属性,比如颜色
C_A,C_B,C_C,或者纹理坐标U_A,U_B,U_C,或者法线向量N_A,N_B,N_C。那么三角形内部任意一点P的该属性值,可以通过重心坐标进行线性插值得到:Attr_P = α * Attr_A + β * Attr_B + γ * Attr_C这个插值对于颜色、纹理坐标等标量或向量属性是直接可用的。对于法线等需要在插值后重新归一化的向量,也通常先插值再归一化。范围与符号(Range and Sign):
- 如果点P在三角形内部(包括边),则 α, β, γ ≥ 0。
- 如果点P恰好位于一条边上,例如边BC上,则 α = 0。
- 如果点P在三角形外部,则至少有一个坐标为负。坐标的符号可以用来快速进行点-三角形包含性测试。
归一化(Normalization):α + β + γ = 1 是一个天然的归一化条件。这使得重心坐标构成一个“重心坐标系”,三个坐标并非独立,知道其中两个就能确定第三个。
2.3 三维空间与透视校正插值
在三维图形渲染中,我们面临一个更复杂的情况:透视投影。相机模型模拟了人眼“近大远小”的效果。当一个三角形在三维空间中远离相机时,它在屏幕上的投影会变小。如果我们简单地将屏幕空间(经过投影和视口变换后的2D坐标)计算出的重心坐标,直接用于插值三维空间中的属性(如纹理坐标、颜色),就会发生错误。最典型的例子就是纹理贴图在透视投影下出现扭曲,而不是正确的透视效果。
问题的根源在于:屏幕空间(Screen Space)的线性插值,并不对应着三维世界空间(World Space)或相机空间(View Space)的线性插值。
假设我们在三维空间中有一条线段,其两个端点的深度值(Z值)不同。将这条线段投影到屏幕上,屏幕上的像素位置是均匀变化的,但线段上对应点的三维深度值并不是均匀变化的。由于投影变换是非线性的(涉及除以Z),在屏幕上均匀移动,对应在三维空间中是在一条双曲线上移动。
因此,对于需要在三维空间中保持正确关系的属性(最典型的是纹理坐标),我们必须进行透视校正插值(Perspective-Correct Interpolation)。
透视校正插值的推导涉及齐次坐标和投影变换。这里给出结论和实际操作步骤:
核心思想:在投影空间中,需要被线性插值的不是属性
Attr本身,而是Attr / w和1 / w,其中w是顶点变换到裁剪空间后的齐次坐标的w分量(在标准透视投影中,w分量就是观察空间中的深度值-Z_view)。操作步骤: a. 在顶点着色器中,将顶点属性
Attr和变换后的裁剪空间坐标的w分量(通常就是gl_Position.w)传递给光栅化阶段。 b. 在光栅化阶段,对每个像素,先用屏幕空间坐标计算重心坐标 (α, β, γ)。 c. 使用这个重心坐标,对Attr / w和1 / w进行插值:interpolated_Attr_div_w = α*(Attr_A/w_A) + β*(Attr_B/w_B) + γ*(Attr_C/w_C)interpolated_one_div_w = α*(1/w_A) + β*(1/w_B) + γ*(1/w_C)d. 最后,在片段着色器中,恢复正确的属性值:corrected_Attr = interpolated_Attr_div_w / interpolated_one_div_w为什么有效:数学上可以证明,经过
Attr/w和1/w的线性插值后再相除,等价于在三维空间中对Attr进行正确的线性插值。现代GPU的光栅化硬件自动完成了这个过程。作为开发者,我们只需要确保在顶点着色器中输出正确的w分量(通常由投影矩阵保证),并在片段着色器中直接使用插值后的属性即可,硬件会帮我们做好透视校正。
实操心得:在编写自己的软件光栅化器时,透视校正插值是必须实现的功能,否则渲染的纹理会严重失真。一个常见的错误是只对颜色做了透视校正,却忘了对纹理坐标做。记住,所有在三维空间中定义的、需要从顶点插值到片段的属性,都必须进行透视校正。这包括纹理坐标、颜色(如果是在世界空间计算的光照)、法线(虽然法线插值后需要归一化,但插值过程本身也需要透视校正)等。
3. 核心应用场景与实现细节
重心坐标的理论很优美,但它的价值最终体现在解决实际图形学问题上。下面我们深入几个最核心的应用场景,看看如何将理论转化为代码。
3.1 场景一:三角形光栅化与像素包含性测试
光栅化的第一步,就是确定屏幕上的哪些像素被一个三角形覆盖。对于每个候选像素(通常取其中心点),我们需要判断它是否在三角形内。重心坐标提供了最优雅高效的解决方案。
实现步骤:
预处理:给定屏幕空间中的三角形顶点
v0,v1,v2(已经是2D坐标),计算整个三角形的有向面积的两倍(作为分母):float area = edgeFunction(v0, v1, v2); // 实际是2*面积这里edgeFunction(p, a, b)通常实现为二维叉积:(b.x - a.x)*(p.y - a.y) - (b.y - a.y)*(p.x - a.x)。这个函数的值正比于点p到边ab的有向距离。遍历像素:确定三角形的屏幕包围盒(AABB),遍历包围盒内的每个像素
P。计算重心坐标:对于每个像素中心
P,计算三个“子三角形”的有向面积:float w0 = edgeFunction(P, v1, v2); // 对应顶点v0的权重(即α的分母部分) float w1 = edgeFunction(P, v2, v0); // 对应顶点v1的权重(β) float w2 = edgeFunction(P, v0, v1); // 对应顶点v2的权重(γ)注意,这里的
w0, w1, w2并不是最终的归一化重心坐标,它们与面积成正比,且w0 + w1 + w2 = area(整个三角形的有向面积)。包含性测试与坐标归一化:
- 测试:如果
area为正(假设我们约定顶点顺序为逆时针,正面朝向),那么当w0 >= 0 && w1 >= 0 && w2 >= 0时,像素P在三角形内(或边上)。这个测试同时完成了背面剔除(如果area为负)和点包含性判断。 - 归一化:如果像素在三角形内,则计算归一化的重心坐标:
float alpha = w0 / area;float beta = w1 / area;float gamma = w2 / area; // 或者 1.0 - alpha - beta
- 测试:如果
属性插值:使用计算出的
alpha, beta, gamma对顶点属性(如颜色、纹理坐标、深度)进行插值。PixelColor = alpha * v0.color + beta * v1.color + gamma * v2.color;
优化技巧:
- 增量计算:在遍历像素时,
w0, w1, w2可以增量更新。当像素P沿x轴移动一个像素(Δx = 1)时,edgeFunction(P, v1, v2)的值变化是常数(v1.y - v2.y)。同理,沿y轴移动时变化是常数(v2.x - v1.x)。这可以避免每个像素都重新计算三个叉积,显著提升性能。这是许多高效光栅化算法的基础。 - 提前深度测试(Early-Z):在插值计算颜色之前,先插值得到像素的深度值(Z-Buffer),并与深度缓冲区中的值比较。如果被遮挡,则跳过后续昂贵的着色计算。
3.2 场景二:纹理映射与属性插值
纹理映射是重心坐标最直观的应用。三角形的每个顶点关联一个纹理坐标 (u, v)。我们需要为三角形内的每个像素找到对应的纹理颜色。
基本流程: a. 在顶点着色器阶段,将纹理坐标作为顶点属性输出。 b. 在光栅化阶段,如3.1所述,计算每个像素的重心坐标
(alpha, beta, gamma)。 c. 使用重心坐标插值纹理坐标:float u = alpha * u0 + beta * u1 + gamma * u2;float v = alpha * v0 + beta * v1 + gamma * v2;d. 在片段着色器中,使用插值得到的(u, v)对纹理进行采样。透视校正的重要性:这里插值的
u, v必须是经过透视校正的。如2.3节所述,现代GPU管线自动完成了这一步。在软件渲染中,你需要手动实现:插值u/w,v/w和1/w,然后在片段中相除得到正确的u和v。纹理过滤与Mipmapping:直接使用浮点纹理坐标采样会导致锯齿。实际应用中,还需要根据像素在纹理空间中的覆盖范围(通过计算纹理坐标在屏幕空间中的导数
dFdx,dFdy)来选择合适的Mipmap层级并进行双线性/三线性过滤。这些操作都建立在正确插值得到的纹理坐标基础上。
3.3 场景三:射线与三角形求交(Ray-Triangle Intersection)
在光线追踪(Ray Tracing)中,我们需要判断一条射线是否与一个三角形相交,并求出交点坐标。重心坐标再次提供了完美的工具。
最常用的算法是Möller–Trumbore算法。它将射线方程和三角形的重心坐标表示相结合。
- 射线方程:
R(t) = O + t * D,其中O是原点,D是单位方向向量,t是距离。 - 三角形平面方程(用重心坐标表示):
P = (1 - u - v) * V0 + u * V1 + v * V2,其中(u, v)是重心坐标(这里α = 1-u-v,β = u,γ = v)。 - 联立方程:
O + t * D = (1 - u - v) * V0 + u * V1 + v * V2
整理后,可以得到一个关于t, u, v的线性方程组。Möller-Trumbore算法通过克莱姆法则(Cramer‘s rule)直接给出解:
vec3 e1 = V1 - V0; vec3 e2 = V2 - V0; vec3 pvec = cross(D, e2); float det = dot(e1, pvec); // 如果行列式接近0,射线与三角形平面平行 if (abs(det) < EPSILON) return false; float inv_det = 1.0 / det; vec3 tvec = O - V0; float u = dot(tvec, pvec) * inv_det; if (u < 0.0 || u > 1.0) return false; // 交点重心坐标u超出范围 vec3 qvec = cross(tvec, e1); float v = dot(D, qvec) * inv_det; if (v < 0.0 || u + v > 1.0) return false; // 交点重心坐标v超出范围,或α=1-u-v为负 float t = dot(e2, qvec) * inv_det; if (t < T_MIN || t > T_MAX) return false; // 交点在射线有效区间外 // 相交成功!交点坐标为 O + t * D,重心坐标为 (1-u-v, u, v)算法优势:
- 直接计算出交点参数
t和重心坐标(u, v),无需先求平面交点再判断是否在三角形内。 - 计算量相对较小,只涉及向量加减、点积和叉积。
- 得到的重心坐标
(u, v)可以直接用于插值顶点的法线、纹理坐标等属性,为后续的光照计算提供便利。
注意事项:实现时要注意数值精度问题。
EPSILON的选择要合适,过大可能漏交,过小可能因浮点误差误判。通常选择一个与场景尺度相关的较小值,如1e-6或1e-7。另外,det > 0和det < 0可以用于区分射线是从三角形正面还是背面相交,这在需要区分材质双面时有用。
3.4 场景四:几何处理与变形
重心坐标在几何处理中也大有用武之地。例如,在网格变形、蒙皮或形状插值中,我们经常需要将一点从一个三角形“绑定”到另一个三角形。
应用示例:网格变形传递假设我们有一个源三角形SrcTri(A_s, B_s, C_s)和一个目标三角形DstTri(A_d, B_d, C_d)。已知源三角形内部一点P_s的重心坐标为(α, β, γ)。现在想要找到目标三角形上“对应”的点P_d。 利用重心坐标的仿射不变性,我们可以直接使用相同的重心坐标在目标三角形上进行重建:P_d = α * A_d + β * B_d + γ * C_d这样得到的P_d在目标三角形内的相对位置与P_s在源三角形内的相对位置是一致的。这种方法常用于将细节从高模传递到低模,或者在不同姿态的模型间传递纹理或颜色信息。
4. 常见问题、优化与深度探讨
即使理解了原理和基础应用,在实际编码和优化中,仍然会遇到不少坑。下面分享一些实践中积累的经验和需要深入思考的问题。
4.1 精度问题与稳健的实现
重心坐标计算涉及浮点数运算,尤其是叉积,容易受到数值误差影响。在光栅化或光线求交中,这些误差可能导致像素闪烁(Z-fighting)或错误地判断点在边上。
问题1:点在边上的处理当像素中心恰好落在三角形边上时,计算出的w0, w1, w2可能有一个为0,或者由于浮点误差,一个本应为正的小数变成了一个负的极小值(如-1e-7)。这会导致本应被两个三角形共享的像素,可能被两个三角形都渲染,也可能都不渲染,造成缝隙或重叠。
解决方案:使用“上左”规则(Top-Left Rule)或约定严格的边界条件。
- 上左规则:定义三角形的边,如果它是水平边,则只属于其位于下方的三角形;如果是非水平边,则只属于其位于左侧的三角形。在计算
edgeFunction时,将“点在边上”(结果==0)的情况视为“正”(inside)。更简单的实现方式是,在判断w0 >= 0时,使用一个很小的正数epsilon作为容差:w0 > -epsilon。这能确保共享边的像素被确定性地分配给其中一个三角形。
问题2:透视除法后的深度缓冲精度在透视投影中,经过投影变换和透视除法后,深度值(Z-Buffer)的分布是非线性的,靠近近裁剪面的精度高,远离近裁剪面的精度低。这会导致远处的物体更容易出现Z-fighting。
解决方案:
- 尽量让近裁剪面(Near Plane)设置得远一些(当然不能裁剪掉需要的内容),可以改善深度值分布的均匀性。
- 使用反转的Z-Buffer(Reversed-Z),即用
1.0 - depth或far / (far - near) * (1 - near/z)等形式存储深度,能更好地利用深度缓冲的浮点数精度范围(特别是在使用浮点深度纹理时)。 - 对于必须重叠的物体(如贴花Decal),可以使用轻微的深度偏移(Depth Bias)或修改渲染顺序来规避。
4.2 重心坐标在非三角形图元中的应用
重心坐标的概念可以推广到其他多边形,但最自然和最常用的还是三角形。对于四边形,可以将其分割为两个三角形,然后分别处理。对于更复杂的凸多边形,可以使用重心坐标的广义形式(称为“重心坐标”或“重心插值”),但计算更复杂,且不具有三角形重心坐标的所有优美性质(如非负性仅在凸多边形内保证)。
在曲面细分(Tessellation)中,重心坐标用于在参数化域(如一个三角形面片)内生成新的顶点位置。例如,对于三角形域,新的顶点位置可以用其重心坐标(u, v, w)来表示,然后通过插值控制点来计算出实际的三维坐标。
4.3 软件实现中的优化技巧
如果你正在实现一个软件光栅化器,以下优化技巧能显著提升性能:
- 固定点运算(Fixed-Point Arithmetic):屏幕坐标和重心坐标的增量计算可以转换为整数或固定点数运算,避免昂贵的浮点乘除。将坐标缩放一个大的常数(如2^16),用整数存储和运算,最后再除以常数得到结果。
- 扫描线算法:结合重心坐标的增量性质,可以实现经典的扫描线光栅化。对于三角形的每一条扫描线,计算其与三角形左右边的交点,然后在这条水平线段上从左到右遍历像素,并增量更新重心坐标和深度值。
- 层次化遍历:不要总是逐个像素测试。可以先使用粗糙的网格(如8x8的瓦片)测试三角形与瓦片的包围盒是否相交,只对相交的瓦片进行精细的像素级遍历。
- SIMD优化:现代CPU支持SIMD指令(如SSE, AVX)。可以同时对多个像素(例如4个或8个)计算
edgeFunction,进行并行包含性测试和属性插值。
4.4 重心坐标的局限性
尽管强大,重心坐标也有其局限性:
- 仅适用于三角形:这是其定义决定的。对于其他多边形,需要先三角化。
- 线性插值的局限:重心坐标提供的是线性插值。如果顶点属性在三角形面上的真实变化是非线性的(例如,由高光引起的颜色剧烈变化),线性插值会产生不准确的结果。这就是为什么复杂的光照计算(如Phong着色)需要在每个像素上进行(Phong Shading),而不是在每个顶点上计算再插值颜色(Gouraud Shading)。
- 投影变形:如之前强调的,屏幕空间的线性插值不等于三维空间的线性插值,必须进行透视校正。这是新手最容易忽略的错误之一。
5. 从理论到实践:一个简单的软件光栅化器示例
为了将以上所有知识点串联起来,我们勾勒一个极简的软件光栅化器核心流程,重点关注重心坐标的应用。假设我们渲染一个彩色三角形到帧缓冲区。
// 数据结构 struct Vertex { Vec2 position; // 屏幕空间坐标 Vec3 color; // RGB颜色 float w; // 裁剪空间w分量,用于透视校正 }; struct Triangle { Vertex v0, v1, v2; }; // 边缘函数,计算有向面积的2倍 float edgeFunction(const Vec2& p, const Vec2& a, const Vec2& b) { return (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x); } void rasterizeTriangle(const Triangle& tri, FrameBuffer& fb, DepthBuffer& db) { // 1. 计算三角形包围盒 int minX = floor(min(tri.v0.position.x, tri.v1.position.x, tri.v2.position.x)); int maxX = ceil(max(tri.v0.position.x, tri.v1.position.x, tri.v2.position.x)); int minY = floor(min(tri.v0.position.y, tri.v1.position.y, tri.v2.position.y)); int maxY = ceil(max(tri.v0.position.y, tri.v1.position.y, tri.v2.position.y)); // 2. 计算整个三角形面积(2倍) float area = edgeFunction(tri.v0.position, tri.v1.position, tri.v2.position); if (area <= 0) return; // 背面剔除或退化三角形 // 3. 预计算顶点属性的 1/w Vec3 color0_div_w = tri.v0.color / tri.v0.w; Vec3 color1_div_w = tri.v1.color / tri.v1.w; Vec3 color2_div_w = tri.v2.color / tri.v2.w; float inv_w0 = 1.0f / tri.v0.w; float inv_w1 = 1.0f / tri.v1.w; float inv_w2 = 1.0f / tri.v2.w; // 4. 遍历包围盒内像素 for (int y = minY; y <= maxY; ++y) { for (int x = minX; x <= maxX; ++x) { Vec2 pixelCenter(x + 0.5f, y + 0.5f); // 5. 计算重心坐标权重(未归一化) float w0 = edgeFunction(pixelCenter, tri.v1.position, tri.v2.position); float w1 = edgeFunction(pixelCenter, tri.v2.position, tri.v0.position); float w2 = edgeFunction(pixelCenter, tri.v0.position, tri.v1.position); // 6. 包含性测试(使用小容差epsilon处理边上情况) const float epsilon = 1e-6f; if (w0 >= -epsilon && w1 >= -epsilon && w2 >= -epsilon) { // 7. 归一化重心坐标 float alpha = w0 / area; float beta = w1 / area; float gamma = 1.0f - alpha - beta; // 或 w2 / area // 8. 透视校正插值:插值 1/w 和 color/w float interpolated_inv_w = alpha * inv_w0 + beta * inv_w1 + gamma * inv_w2; float z = 1.0f / interpolated_inv_w; // 恢复深度值 (view space z) // 深度测试 if (z < db.get(x, y)) { db.set(x, y, z); Vec3 interpolated_color_div_w = alpha * color0_div_w + beta * color1_div_w + gamma * color2_div_w; Vec3 final_color = interpolated_color_div_w / interpolated_inv_w; // 9. 写入帧缓冲区(简单clamp) fb.setPixel(x, y, clamp(final_color, 0.0f, 1.0f)); } } } } }这个示例省略了纹理、法线、光照等复杂因素,但清晰地展示了重心坐标在光栅化中的核心作用:包含性测试、属性插值,并与透视校正深度测试紧密结合。
最后的体会:重心坐标远不止是一个数学概念。它是图形学管线中数据流动的“粘合剂”,将顶点的信息平滑、正确地传递到每一个像素。理解它,不仅能帮你写出正确的渲染代码,更能让你洞悉GPU光栅化阶段内部的工作原理。下次当你调试一个奇怪的纹理扭曲或插值错误时,不妨先检查一下:你的重心坐标计算对了吗?透视校正做了吗?