4、光栅化阶段:屏幕映射、三角形遍历、片元生成、像素覆盖测试

好,咱们接着往下走。顶点处理完了,裁剪也做完了,接下来就是把那些抽象的三维三角形,变成屏幕上实实在在的像素点。这个过程,就是光栅化。

说实话,我早年刚接触图形学的时候,觉得光栅化不就是画三角形嘛,有啥难的?后来自己动手写软渲染器,才发现这里面的门道比想象中深得多。你想想看,一个三角形在三维空间里是连续的,但屏幕上的像素是离散的网格。怎么把连续的东西映射到离散的网格上?这就是光栅化要解决的核心问题。

光栅化的本质:将连续的几何图元(三角形)离散化为屏幕上的像素点,并为每个像素生成对应的片元(Fragment)。

4.1 屏幕映射

屏幕映射,说白了就是把裁剪空间里的坐标,变成屏幕上的像素坐标。这一步其实挺直接的,但容易出错。

裁剪空间里的坐标范围是 [-1, 1] × [-1, 1],而屏幕坐标通常是 [0, width] × [0, height]。映射公式很简单:

// 屏幕映射的核心公式
screenX = (clipX + 1) * 0.5 * viewportWidth
screenY = (1 - clipY) * 0.5 * viewportHeight  // 注意Y轴翻转

为什么要翻转Y轴?因为裁剪空间里Y轴向上,而屏幕坐标Y轴向下。这个细节我当年踩过坑——第一次写渲染器时忘了翻转,结果所有三角形都是倒着的,找了半天bug才发现。

另外,还有个深度值要处理。裁剪空间的Z范围是 [-1, 1],映射到深度缓冲区的 [0, 1]。这个映射不是线性的,而是非线性的——近平面精度高,远平面精度低。嗯,这里要注意,深度精度问题在后续的遮挡测试中会直接影响渲染结果。

坐标空间 X范围 Y范围 Z范围
裁剪空间 [-1, 1] [-1, 1] [-1, 1]
屏幕空间 [0, width] [0, height] [0, 1]

4.2 三角形遍历

屏幕映射做完后,我们有了三个顶点的屏幕坐标。接下来要找出哪些像素被这个三角形覆盖。这个过程叫三角形遍历,也叫光栅化扫描转换。

常用的方法有好几种,我挑最经典的讲:

  • 扫描线算法:从上到下逐行扫描,每行计算与三角形边的交点,填充中间区域。效率高,但实现稍复杂。
  • 半平面法:对每个像素,判断它是否在三角形三条边的内侧。简单直观,适合并行计算。
  • 包围盒法:先算出三角形的包围盒,然后只遍历包围盒内的像素,用半平面法判断。这是GPU最常用的方法。

我个人习惯用包围盒+半平面法。为什么呢?因为它的计算量可控,而且天然适合并行——每个像素的判断是独立的,GPU可以同时处理成千上万个像素。

// 包围盒+半平面法伪代码
// 1. 计算三角形包围盒
minX = floor(min(v0.x, v1.x, v2.x))
maxX = ceil(max(v0.x, v1.x, v2.x))
minY = floor(min(v0.y, v1.y, v2.y))
maxY = ceil(max(v0.y, v1.y, v2.y))

// 2. 遍历包围盒内的每个像素
for (y = minY; y <= maxY; y++) {
    for (x = minX; x <= maxX; x++) {
        // 3. 用半平面法判断像素是否在三角形内
        if (isInsideTriangle(x + 0.5, y + 0.5, v0, v1, v2)) {
            generateFragment(x, y)
        }
    }
}

小技巧:判断像素是否在三角形内时,通常取像素中心点 (x+0.5, y+0.5) 进行测试。这样可以避免边界上的歧义。

4.3 片元生成

当一个像素被判定为在三角形内部时,我们就为它生成一个片元。片元是什么?你可以把它理解成「潜在的像素」——它包含了像素的位置、深度、颜色、法线、纹理坐标等信息,但还没最终确定要不要画到屏幕上。

片元生成的过程中,需要做一件重要的事:属性插值。三角形的三个顶点有各自的属性(颜色、纹理坐标、法线等),但三角形内部的像素没有这些属性,得靠插值算出来。

常用的插值方法是重心坐标插值。说白了,就是用像素在三角形内的位置权重,去混合三个顶点的属性。

// 重心坐标插值
// 已知三角形三个顶点的属性值 A0, A1, A2
// 以及像素的重心坐标 (α, β, γ),其中 α+β+γ=1
pixelAttribute = α * A0 + β * A1 + γ * A2

这里有个坑要注意:透视投影下,简单的线性插值是不对的。因为透视投影会改变空间中的距离关系。正确的做法是做透视校正插值——先对深度倒数做线性插值,再恢复出正确的属性值。

我曾经在一个项目中直接用线性插值做纹理映射,结果远处的纹理出现了明显的扭曲。后来查了半天资料才发现是透视校正的问题。所以,如果你在做纹理映射,一定要用透视校正插值。

4.4 像素覆盖测试

片元生成之后,还不能直接画到屏幕上。得先做几个测试,看看这个片元到底能不能覆盖最终的像素。

常见的测试包括:

  • 深度测试:比较片元的深度值和深度缓冲区中已有的深度值。如果片元更近,就覆盖;否则丢弃。
  • 模板测试:用模板缓冲区中的值做比较,常用于实现阴影、轮廓等效果。
  • 裁剪测试:判断片元是否在裁剪矩形内。
  • Alpha测试:根据片元的透明度决定是否丢弃(现在多用Alpha混合替代)。

这些测试的顺序是有讲究的。一般来说,先做裁剪测试,再做模板测试,然后做深度测试。为什么?因为深度测试的计算量相对较大,如果能提前丢弃掉不可见的片元,就能省下不少计算资源。

你想想看,如果一个片元在裁剪测试就被干掉了,那后面的深度测试就不用做了。这就是所谓的Early-Z技术——在片元着色器执行之前就做深度测试,提前丢弃被遮挡的片元。

核心要点:光栅化阶段的核心产出是片元。每个片元都包含了位置、深度、插值后的属性等信息,等待后续的片元着色器处理。而像素覆盖测试则决定了哪些片元能真正进入下一阶段。

知识体系总览

下面这张图展示了光栅化阶段的完整流程,我把它画成了流程图,方便你理解各个步骤之间的关系:

光栅化阶段流程 屏幕映射 三角形遍历 片元生成 像素覆盖测试 片元输出 裁剪空间 → 屏幕空间 包围盒 + 半平面法 重心坐标 + 透视校正 深度/模板/裁剪测试

这张图把整个流程串起来了。从屏幕映射开始,到三角形遍历找出覆盖的像素,再到片元生成时做属性插值,最后经过像素覆盖测试的筛选,合格的片元才能进入下一阶段。

说实话,光栅化是整个渲染管线里最「硬核」的部分之一。它不像顶点处理那样有明确的数学公式,也不像片元着色那样充满创意空间。它更像是一个精细的工程问题——如何在有限的计算资源下,高效、准确地完成从连续到离散的转换。

我在做移动端渲染优化时,经常需要调整光栅化相关的参数。比如降低分辨率可以减少光栅化的像素数量,但会牺牲画质。又比如调整深度测试的精度,可以在性能和效果之间做权衡。这些经验,都是在实际项目中一点点积累起来的。

避坑指南:如果你在实现自己的渲染器,建议先做对再做快。先用最简单的扫描线算法跑通流程,再逐步优化成包围盒+半平面法。我当年就是太着急优化,结果bug一堆,调试了整整一周。


专注资料整理