文章

TinyRenderer(二):三角形光栅化、Z-Buffer 与背面剔除

TinyRenderer(二):三角形光栅化、Z-Buffer 与背面剔除

上一篇介绍了如何在像素网格上画一条线。现在,我们面对下一个问题:如何在屏幕上填充一个三角形

三角形是 3D 渲染中最基本的图元。无论多么复杂的模型,最终都会被分解为三角形网格。所以”填充三角形”这一步,是整个软渲染器的核心通道。

tr 的这篇文章给了一个由易到难、层层递进的介绍。本文依照这个思路,先介绍古老的扫描线方法,再介绍现代的包围盒加重心坐标方法,并结合代码讲解重心坐标的求解与应用,最后讨论背面剔除与 z-buffer 的关系。

什么是”好”的三角形填充算法

tr 给出了三条标准:

  1. 简单快速
  2. 对称性:结果不能依赖于顶点传入的顺序。
  3. 无缝拼接:两个共享边的三角形之间,不能因为光栅化舍入产生缝隙。

第三条尤其重要。现实中一个 mesh 的每条内部边都被两个三角形共享,如果两个三角形对同一条边的光栅化结果不一致,就会出现看得见的黑缝。

扫描线渲染(Scanline Rendering)

扫描线是一种古老但直观的方法,思路来自 CRT 显示器逐行扫描的硬件特性:逐行(scanline)确定填充范围,再水平填色

顶点排序

给定三角形的三个顶点 $a, b, c$,首先按 $y$ 坐标从小到大排序($a.y \le b.y \le c.y$)。用冒泡排序对三个元素排序最为直接:

1
2
3
if a.y > b.y: swap(a, b)
if b.y > c.y: swap(b, c)
if a.y > b.y: swap(a, b)

排序后,三角形被两条边”切割”成可以分开处理的两半:

  • 长边(从 $a$ 到 $c$,对应整个高度范围)
  • 短边 B:从 $a$ 到 $b$(下半段),再从 $b$ 到 $c$(上半段)

边界光栅化与填充

对于每一行 $y$(从 $a.y$ 到 $c.y$),分别在长边和当前短边上用线性插值求出左右边界的 $x$ 坐标,然后水平连线填充:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def triangle(a, b, c):
    # 按 y 坐标排序
    pts = sorted([a, b, c], key=lambda p: p.y)
    a, b, c = pts

    total_h = c.y - a.y

    # 下半段:a -> b
    for y in range(a.y, b.y + 1):
        seg_h = b.y - a.y + 1
        t1 = (y - a.y) / total_h   # 长边进度
        t2 = (y - a.y) / seg_h     # 短边进度
        xa = a.x + (c.x - a.x) * t1
        xb = a.x + (b.x - a.x) * t2
        draw_horizontal(min(xa, xb), max(xa, xb), y)

    # 上半段:b -> c
    for y in range(b.y, c.y + 1):
        seg_h = c.y - b.y + 1
        t1 = (y - a.y) / total_h   # 长边进度
        t2 = (y - b.y) / seg_h     # 短边进度
        xa = a.x + (c.x - a.x) * t1
        xb = b.x + (c.x - b.x) * t2
        draw_horizontal(min(xa, xb), max(xa, xb), y)

这个方法在 CPU 单线程环境下相当高效。但扫描线方法的代码并不简洁——需要处理上下两半,还要特别注意共线退化情况(比如三个顶点的 $y$ 坐标有相等的情况)。更重要的是,它本质上是串行的:每行必须在上一行的基础上推进,无法并行


现代方法:包围盒 + 重心坐标

tr 给出的第二种方法可以说是更”现代”的思路,其伪代码极为简洁:

1
2
3
4
5
6
7
8
triangle(vec2 points[3]) {
    vec2 bbox[2] = find_bounding_box(points);
    for (each pixel in the bounding box) {
        if (inside(points, pixel)) {
            put_pixel(pixel);
        }
    }
}

这种写法对初学者非常友好,在图形学专家眼里也不陌生——GPU 的光栅化硬件本质上就是这样工作的:包围盒内的所有像素由成千上万个线程并行处理,每个线程独立判断自己是否在三角形内。

轴对齐包围盒(AABB)

对三个顶点取 $x, y$ 的最小值和最大值,即可确定 AABB:

\[x_{min} = \min(x_0, x_1, x_2), \quad x_{max} = \max(x_0, x_1, x_2)\] \[y_{min} = \min(y_0, y_1, y_2), \quad y_{max} = \max(y_0, y_1, y_2)\]

遍历包围盒内所有整数像素坐标 $(x, y)$,对每个点判断其是否在三角形内。

重心坐标(Barycentric Coordinates)

判断点是否在三角形内,最优雅的方法是使用重心坐标

对于三角形 $ABC$ 和一个点 $P$,我们可以将 $P$ 表示为三顶点的线性组合:

\[P = \alpha A + \beta B + \gamma C\]

其中 $\alpha + \beta + \gamma = 1$。这三个系数 $(\alpha, \beta, \gamma)$ 就是 $P$ 关于 $\triangle ABC$ 的重心坐标。它们有直观的几何含义:

\[\alpha = \frac{\text{Area}(\triangle PBC)}{\text{Area}(\triangle ABC)}, \quad \beta = \frac{\text{Area}(\triangle PCA)}{\text{Area}(\triangle ABC)}, \quad \gamma = \frac{\text{Area}(\triangle PAB)}{\text{Area}(\triangle ABC)}\]

即各坐标分量等于以 $P$ 为顶点、对边为底边的子三角形面积占总面积的比例。利用这一性质可以直接得出内外判断:

  • 当 $\alpha, \beta, \gamma$ 全部 $\geq 0$ 时,$P$ 在三角形内(或边上)。
  • 只要有一个分量 $< 0$,$P$ 在三角形外。

求解重心坐标

将 $P = \alpha A + \beta B + \gamma C$ 结合 $\alpha + \beta + \gamma = 1$ 展开,可以得到:

\[P - C = \alpha (A - C) + \beta (B - C)\]

令 $\vec{CA} = A - C$,$\vec{CB} = B - C$,$\vec{CP} = P - C$,则方程变为:

\[\alpha \cdot \vec{CA} + \beta \cdot \vec{CB} = \vec{CP}\]

展开为分量形式,是一个 $2 \times 2$ 的线性方程组:

\[\begin{bmatrix} CA_x & CB_x \\ CA_y & CB_y \end{bmatrix} \begin{bmatrix} \alpha \\ \beta \end{bmatrix} = \begin{bmatrix} CP_x \\ CP_y \end{bmatrix}\]

解出 $\alpha, \beta$,再由 $\gamma = 1 - \alpha - \beta$ 得到第三个分量。


我们的 C++ 实现

InsideTriangle:叉积符号法

判断点是否在三角形内,还有一种更直接的方法:逐条边检查点在哪一侧。

对有向三角形 $ABC$,从 $A$ 到 $B$,再从 $B$ 到 $C$,再从 $C$ 到 $A$ 依次绕行。对于三角形内的任意点 $P$,向量 $\overrightarrow{AP}$、$\overrightarrow{BP}$、$\overrightarrow{CP}$ 分别与对应边向量的叉积 $z$ 分量,符号全部相同(全为正或全为负)。一旦有符号不同,点就在三角形外。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bool TinyRender::InsideTriangle(math::Triangle3f& tri, int x, int y) const
{
    math::Point3f p(x, y, 1);
    math::Point3f a(tri[0]);
    math::Point3f b(tri[1]);
    math::Point3f c(tri[2]);

    auto pa = p - a;
    auto pb = p - b;
    auto pc = p - c;

    // 分别计算 PA×PB、PB×PC、PC×PA 的 z 分量
    auto z1 = math::CrossProduct(pa, pb).z;
    auto z2 = math::CrossProduct(pb, pc).z;
    auto z3 = math::CrossProduct(pc, pa).z;

    // 三个叉积同号,则点在三角形内
    return z1 * z2 > 0 && z2 * z3 > 0;
}

这里计算的是 $\overrightarrow{PA} \times \overrightarrow{PB}$(即以 $P$ 为原点的局部叉积),其 $z$ 分量反映了 $P$ 在有向边 $AB$ 的哪一侧。三个侧面方向一致,则 $P$ 在三角形内部。

ComputeBarycentric:矩阵求逆法

我们用矩阵求逆直接解方程组。按照上面推导的方程 $M \cdot (\alpha, \beta)^T = \vec{CP}$,代码构造系数矩阵并求其逆:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
std::tuple<float, float, float> TinyRender::ComputeBarycentric(math::Triangle3f& tri, int x, int y)
{
    // 计算向量 CA, CB, CP
    math::Vec2f CA(tri[0].x - tri[2].x, tri[0].y - tri[2].y);
    math::Vec2f CB(tri[1].x - tri[2].x, tri[1].y - tri[2].y);
    math::Vec2f CP(x - tri[2].x, y - tri[2].y);

    // 构造 2x2 矩阵(注意:内部以行向量方式存储 CA, CB)
    math::Matrix2f m(CA.x, CA.y, CB.x, CB.y);
    auto inv = m.Inverse();

    // 通过矩阵逆解出 α, β
    float u = CP.x * inv.a + CP.y * inv.c;  // α
    float v = CP.x * inv.b + CP.y * inv.d;  // β
    return { u, v, 1 - u - v };             // (α, β, γ)
}

这里有一个值得注意的小细节:矩阵以行优先(Row-major)的方式存储 $\vec{CA}$ 和 $\vec{CB}$,即 m 的布局是:

\[M = \begin{bmatrix} CA_x & CA_y \\ CB_x & CB_y \end{bmatrix}\]

这是列系数矩阵的转置。用这个矩阵的逆直接”点乘” $\vec{CP}$ 的分量(而非标准矩阵-向量乘),恰好等价于求解正确的方程组——本质上是用了 Cramer 法则,只是绕了一下代数弯路。可以验证结果与直接用 Cramer 法则展开完全一致:

\[\alpha = \frac{CP_x \cdot CB_y - CP_y \cdot CB_x}{CA_x \cdot CB_y - CA_y \cdot CB_x}, \quad \beta = \frac{CP_y \cdot CA_x - CP_x \cdot CA_y}{CA_x \cdot CB_y - CA_y \cdot CB_x}\]

分母 $CA_x \cdot CB_y - CA_y \cdot CB_x$ 正是 $\vec{CA} \times \vec{CB}$ 的 $z$ 分量,即三角形面积的两倍(带符号)。

DrawTriangle:包围盒遍历 + z-buffer

有了上面两个工具,填充三角形的核心函数就非常清晰了。

关键细节:为什么使用 3D 顶点与深度插值(Depth Interpolation)
在代码中,送进 DrawTriangle 的三角形类型是 math::Triangle3f(即携带 $(x,y,z)$ 分量的 3D 顶点)。这就引出一个问题:屏幕已经是 2D 的了,为什么还有 $z$?
事实上,现代光栅化不仅要负责点亮屏幕像素,还要计算这个像素的深度与材质信息。顶点的 $x, y$ 坐标已经被世界转换管线映射成了屏幕像素网格点,但它们原始的 $z$ 坐标却被坚决保留了下来,代表它在 3D 空间里的“原原本本的远近”。
当像素通过测试后(代码由于重心坐标 [alpha, beta, gamma] 此前已被算出),紧接着一句看起来平平无奇的代码就是精华所在:inteZ = alpha * tri[0].z + beta * tri[1].z + gamma * tri[2].z;
这展示了重心坐标的另一个杀手级能力:重心坐标插值不仅能用于边界判定,更能直接作为加权系数,在三角形内部对任意顶点属性(如深度、法线、纹理 UV 等)进行平滑插值。这个被插值算出来的 $z$(即像素的深度)就是随后用于 z-buffer 测试的核心素材。
注:在正交投影下,这种基于屏幕空间重心坐标的直接线性插值是精确的;但在透视投影下,由于近大远小的深度非线性压缩,直接插值会产生由于几何形变带来的错误透视关系,必须要引入透视校正插值(Perspective-Correct Interpolation)(即对 $1/z$ 进行插值),这留作以后再谈。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
void TinyRender::DrawTriangle(math::Triangle3f& tri, int width, int height, math::Color&& color)
{
    // 1. 计算 AABB
    math::Vec2f bounding_min, bounding_max;
    bounding_min.x = std::min(tri[0].x, std::min(tri[1].x, tri[2].x));
    bounding_min.y = std::min(tri[0].y, std::min(tri[1].y, tri[2].y));
    bounding_max.x = std::max(tri[0].x, std::max(tri[1].x, tri[2].x));
    bounding_max.y = std::max(tri[0].y, std::max(tri[1].y, tri[2].y));

    // 2. 遍历包围盒内所有像素
    for (int x = std::floor(bounding_min.x); x <= std::ceil(bounding_max.x); x++)
    {
        for (int y = std::floor(bounding_min.y); y <= std::ceil(bounding_max.y); y++)
        {
            if (x >= width || y >= height || x < 0 || y < 0) continue;

            if (InsideTriangle(tri, x, y))
            {
                // 3. 插值深度,进行 z-buffer 测试
                auto [alpha, beta, gamma] = ComputeBarycentric(tri, x, y);
                auto inteZ = alpha * tri[0].z + beta * tri[1].z + gamma * tri[2].z;
                int offset = y * width + x;
                if (m_zBuffer[offset] < inteZ)
                {
                    m_zBuffer[offset] = inteZ;
                    offset *= 3;
                    m_frameBuffer[offset]     = color.r;
                    m_frameBuffer[offset + 1] = color.g;
                    m_frameBuffer[offset + 2] = color.b;
                }
            }
        }
    }
}

上文代码里的 inteZ 让我们顺利拿到了每个着色像素精确的深度。但拿到了深度之后,具体该如何使用它呢?这就引出了光栅化之后不可回避的可见性冲突问题。


z-buffer:解决深度排序

通过遍历包围盒并使用重心坐标测试,我们解决了”如何填充一个单独的三角形”且算出了它的原始深度,但还没有解决”多个三角形叠在一起时,谁应该挡住谁”的全局排序问题。

最朴素的做法是画家算法:先画远处的三角形,再画近处的,近处的自然覆盖远处的。但画家算法有一个致命缺陷——如果两个多边形互相交错穿插,就不存在一个全局有效的绘制顺序。

z-buffer(深度缓冲)彻底解决了这个问题。有了上文精确插值出的 inteZ,z-buffer 的设计思路就变得极为直白:为每个屏幕像素维护一个当前已渲染的”最近深度值”,每次尝试写入一个新片段时,只有新片段更近才允许覆盖颜色,并更新这个记录值。

1
2
3
4
5
6
7
auto inteZ = alpha * tri[0].z + beta * tri[1].z + gamma * tri[2].z;
int offset = y * width + x;
if (m_zBuffer[offset] < inteZ)   // 新片段更近
{
    m_zBuffer[offset] = inteZ;   // 更新深度
    // 写入 framebuffer...
}

这里 $z$ 值越大表示越靠近相机(我们使用右手坐标系,相机看向 $-z$ 方向,近处 $z$ 值较大)。整个算法的正确性不依赖于三角形的绘制顺序,每个像素都独立做出最优决策——这也是它天然适合并行化的原因。

z-buffer 的空间开销是一张与 framebuffer 同等分辨率的浮点数组。时间上,每个像素只需一次比较和一次写入,几乎没有额外负担。这就是为什么现代 GPU 将 z-buffer 作为标准硬件功能深度集成,而不是用软件实现。


背面剔除(Back-face Culling)

如果想进一步提升渲染性能,我们可以利用一个简单的几何事实:对于像正方体、球体这样封闭且不透明的模型,无论你从哪个角度看,处于背面的那些面注定会被前面的面挡住。如果把这些肯定看不见的背面三角形也送去光栅化和 z-buffer 测试,会白白浪费大量算力。

三角形的“正”与“反”是怎么定义的? 当我们构建 3D 网格(Mesh)时,无论读取 OBJ 模型还是手动填数据,本质上都是在构建一个只有极薄外壳的“空心蒙皮”。计算机没有物理常识,不知道茶壶哪里是“外面”、哪里是“里面”。 为此,图形学界定下了一个契约:建模软件(如 Blender、Maya)在导出模型时,必须保证从模型外部看过去,构成外壳的每一个三角形顶点,都是按逆时针(CCW)顺序排列的。 这个顶点顺序(绕序)决定的就是三角形的正反。结合右手定则:当四指顺着 $v_0 \to v_1 \to v_2$ 的方向弯曲时,两边向量的叉乘 $(v_1 - v_0) \times (v_2 - v_0)$ 会生成一个垂直于该平面的法线向量 $\vec{n}$。逆时针排列完美地确保了外壳上所有三角形的面法线都“朝外”生长,就像刺猬的刺一样,齐刷刷指向模型外部。

如何剔除背面? 既然法线代表了三角形的朝向,我们只需要计算法向量相机视线方向(教学代码中为了简化,假定光源在相机位置并使用了光照方向 $\vec{l}$)的夹角 $\theta$。利用点积可快速判断:

\[\vec{n} = (\vec{v_2} - \vec{v_0}) \times (\vec{v_1} - \vec{v_0})\] \[\text{intensity} = \vec{n} \cdot \vec{l} = |\vec{n}| |\vec{l}| \cos\theta\]
  • 保留(正面):当夹角 $< 90^\circ$ 时,$\cos\theta > 0$,即 intensity > 0。说明面迎着相机或稍倾斜侧向相机。
  • 剔除(背面):当夹角 $\ge 90^\circ$ 时,$\cos\theta \le 0$,即 intensity \le 0。说明法线和视线方向背道而驰,这片三角形背对我们。直接将它扔进垃圾桶,不用光栅化。

仅靠这一步极低代价的点积判断,就能在光栅化之前直接砍掉约 50% 的三角形渲染工作量

1
2
3
4
5
6
7
8
9
10
// Cross product to get surface normal
math::Vec3f normal = ((v2 - v0) ^ (v1 - v0)).normalize();
auto intensity = normal * lightDir;

// 背面剔除:法向量与光照方向点积 <= 0 时跳过
if (intensity > 0)
{
    DrawTriangle(tri, width, height,
        math::Color(intensity * 255, intensity * 255, intensity * 255));
}

这里同时完成了两件事:

  1. 背面剔除intensity > 0 的条件筛掉了背面三角形。
  2. 漫反射光照(Lambertian shading):用 $\cos\theta$(即法向量与光方向的点积)调制颜色亮度,表面法向量越正对光源,颜色越亮。

这也解释了游戏中为什么“穿模”时内部是透明的: 理解了法线朝外和背面剔除,也就解释了 3D 游戏中常见的一个现象:当摄像机意外卡进了一面墙或角色身体内部(穿模)时,常常会发现模型变成了透明的躯壳,什么都看不见。 因为此时相机处于“空心蒙皮”的里面,模型外壳所有的法线都是“朝外”生长的,背离了相机的视线。这就导致从内部看过去,所有三角形的面法线与视线的夹角统统 $\ge 90^\circ$(点积 $\le 0$)。背面剔除算法会尽职尽责地把模型 100% 的三角形都判定为“背对相机的背面”,并在光栅化前全部扔进垃圾桶,因此原本的墙壁和外壳直接就“隐形”了。

需要注意,背面剔除并不能完全解决深度排序问题。tr 指出:正面三角形之间仍然可能发生互相遮挡(比如嘴巴内部的三角形画在嘴唇上面),彻底的解决方案是 z-buffer。我们的代码已经有了 m_zBuffer,在 DrawTriangle 中进行深度测试,能够正确处理正面三角形之间的遮挡关系。


小结

本文从两个方向讨论了三角形光栅化:

方法优点缺点
扫描线精细控制每行,效率高(CPU 单线程)代码复杂,串行,边界情况多
包围盒 + 重心坐标代码简洁,天然并行,易于扩展包围盒内有大量无效像素(细长三角形时浪费大)

我们的实现选择了第二种方案,并深入探讨了几个图形学核心概念:

  1. 重心坐标:不仅是判断像素是否在三角形内的利器,更是深度(Z值)、纹理坐标(UV)、法向量、颜色等任意顶点属性在三角形内部进行平滑插值的通用机制。
  2. Z-Buffer(深度缓冲):干净利落地解决了像素级别的遮挡和深度排序问题,是现代 GPU 渲染管道的基石。
  3. 背面剔除与绕序契约:通过逆时针顶点排列的业界约定与右手定则,赋予了空心模型确定的“外壳法线方向”。这不仅在光栅化前砍掉了近一半的无效片段开销,也顺带解释了游戏中玩家“卡穿模”时内部透明的有趣现象。

至此,我们已经拥有了一个能够正确处理空间遮挡关系并高效剔除背面的基础软渲染光栅化通道。

下一篇讨论如何移动相机,推导 MVP 变换中的 View 和 Projection 矩阵。

本文由作者按照 CC BY 4.0 进行授权