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一堆,调试了整整一周。