Skip to content

光栅化(深度测试与抗锯齿)

在上一章中,我们已经看到光栅化器如何把一个连续的三角形转换为屏幕网格上的离散片元,并通过重心坐标为每个片元插值得到颜色、法线、纹理坐标与深度等属性。

但片元并不等同于最终屏幕像素。一个像素位置可能被多个三角形覆盖:前方墙面、后方地板、远处物体的边缘,都可能在同一个屏幕采样点上生成片元。此时,渲染管线必须回答两个问题:

  1. 哪一个片元真正可见?
  2. 当几何边界落在像素网格之间时,如何避免锯齿状失真?

前者对应深度测试 (Depth Test)Z-Buffer,后者对应走样 (Aliasing)抗锯齿 (Anti-Aliasing)。它们共同决定了光栅化图像最终是否具有正确的遮挡关系与稳定的边缘质量。


在三维世界中,物体之间天然存在前后遮挡。但屏幕是一张二维平面,经过投影后,许多不同深度的表面会落在同一个像素位置上。渲染器必须在写入帧缓冲 (Framebuffer) 之前判断:当前片元是否比已经存在的片元更靠近相机。

一种直观但脆弱的办法是画家算法 (Painter’s Algorithm):先绘制远处物体,再绘制近处物体,让后画的颜色覆盖先画的颜色,就像油画中先铺背景再画前景一样。

这种方法在简单 2D 场景中有效,但在一般三维场景中会迅速失效。原因是三角形之间未必存在全局一致的前后顺序:一个三角形可能在左侧挡住另一个三角形,却在右侧被对方挡住。若存在互相穿插或循环遮挡,单纯排序无法得到正确结果。

因此,现代实时渲染采用更局部、更稳定的方案:为每个屏幕采样点记录当前最靠近相机的深度值,并在新片元到达时执行比较。这就是 Z-Buffer 算法


Z-Buffer (Depth Buffer) 是一张与屏幕分辨率对应的二维缓冲区。颜色缓冲记录像素最终显示的颜色,而深度缓冲记录当前像素位置已经写入的最近深度。

在标准渲染流程中,每个片元会携带一个经过投影、透视除法与视口变换后的深度值 zz。深度测试的核心规则可以概括为:

if zfragment<zbuffer(x,y) then update\text{if } z_{fragment} < z_{buffer}(x, y) \text{ then update}

其中 zfragmentz_{fragment} 表示当前片元的深度,zbuffer(x,y)z_{buffer}(x, y) 表示屏幕位置 (x,y)(x, y) 已存储的最近深度。若当前片元更近,则它通过测试,更新颜色缓冲与深度缓冲;否则被丢弃。

假设屏幕上某个像素位置 (x,y)(x, y) 当前已经记录了深度 0.420.42,现在有一个新的片元到达,其深度为 0.350.35。若当前管线采用“值越小越近”的约定,则 0.350.350.420.42 更靠近相机,因此该片元应覆盖旧结果。

  1. 初始化深度缓冲:在每一帧开始时,将所有深度值清空为最远值,例如 1.01.0
  2. 生成片元:光栅化器对每个三角形执行覆盖测试,并为覆盖采样点生成候选片元。
  3. 插值深度:根据上一章的重心坐标与透视校正机制,得到当前片元在屏幕位置上的深度值 zfragmentz_{fragment}
  4. 执行比较:将 zfragmentz_{fragment} 与深度缓冲中已有的 zbuffer(x,y)z_{buffer}(x, y) 进行比较。
  5. 更新结果:若当前片元更近,则写入颜色缓冲,并把 zbuffer(x,y)z_{buffer}(x, y) 更新为当前深度;否则丢弃当前片元。

这套机制的优势在于,它不要求渲染器提前对所有三角形进行复杂排序。只要每个片元都能在自己的屏幕位置上完成深度比较,最终留下的自然就是最靠近相机的可见表面。

当前采样点
红色表面深度0.000000
蓝色表面深度0.000000
量化后差值0
深度测试稳定
调整两个表面的间距、近平面和远平面,观察同一屏幕采样点处的深度值是否还能被缓冲区区分。间距越小、远近裁剪范围越夸张,越容易进入 Z-Fighting 风险区。

Z-Buffer 的一个重要价值是降低绘制顺序对最终遮挡结果的影响。只要物体是不透明的,并且正确写入深度缓冲,那么先画前景、后画背景,或先画背景、后画前景,理论上都能得到相同的可见性结果。

但这并不意味着绘制顺序完全不重要。在真实 GPU 中,绘制顺序仍会影响性能。例如,如果先绘制近处的大面积物体,远处片元会在深度测试阶段更早被剔除,从而减少后续片元着色器的计算开销。这类优化通常被称为早期深度测试 (Early-Z)


片元的深度并不是物体在世界空间中的原始 ZZ 坐标。一个顶点从模型空间出发,经过模型、视图、投影变换后,会进入裁剪空间 (Clip Space):

vclip=PVMvlocal\mathbf{v}_{clip} = \mathbf{P} \cdot \mathbf{V} \cdot \mathbf{M} \cdot \mathbf{v}_{local}

随后,硬件执行透视除法 (Perspective Divide)

vndc=(xclipwclip,yclipwclip,zclipwclip)\mathbf{v}_{ndc} = \left(\frac{x_{clip}}{w_{clip}}, \frac{y_{clip}}{w_{clip}}, \frac{z_{clip}}{w_{clip}}\right)

其中 zndcz_{ndc} 会再被映射到 API 约定的深度范围中,并最终成为深度缓冲中用于比较的数值。

关键在于:透视投影并不会让深度值在线性空间中均匀分布。由于透视矩阵中包含与 1/z1/z 有关的项,深度缓冲中的数值密度通常集中在近平面附近,而远处空间被压缩得更严重。

在透视投影中,相机前方的深度经常近似呈现如下关系:

zbuffera+bzviewz_{buffer} \approx a + \frac{b}{z_{view}}

这里 zviewz_{view} 是观察空间中的深度,aabb 由近平面 nn 与远平面 ff 决定。这个式子不需要死记,真正重要的是它揭示了一个事实:深度缓冲对近处更敏感,对远处更粗糙

这解释了第三章中提到的一个现象:当两个远处表面距离非常接近时,即使它们在真实三维空间中并不完全重合,投影到深度缓冲后也可能落入同一个或相邻的量化深度值,从而难以区分前后。


Z-Fighting 是深度缓冲精度不足时最常见的视觉问题之一。它表现为两个几乎共面的表面在屏幕上互相抢占可见性,产生闪烁、斑驳、条纹或不稳定的颜色跳变。

这种问题并不是光栅化器“随机出错”,而是有限精度数值比较的自然结果。深度缓冲通常是 24 位或 32 位格式,虽然看起来已经很精细,但在透视投影的非线性压缩下,远处深度精度会显著下降。

  1. 近平面设置过近:例如把 near plane 设置为 0.0010.001,会极大浪费深度精度,使大部分可用精度挤在相机极近处。
  2. 远平面设置过远:例如为了方便观察,把 far plane 设置为 100000100000,会让远距离深度值被压缩得更加严重。
  3. 几何面重叠:例如地面上叠放一张完全贴合的贴花平面,两个表面深度几乎一致。
  4. 浮点精度不足:大型开放世界中,物体距离世界原点过远时,顶点位置本身也可能出现精度损失。

缓解 Z-Fighting 的思路是让深度比较重新获得可区分性:

  • 合理设置近平面:在不影响视角需求的前提下,让 near plane 尽可能远一些。
  • 缩小远近裁剪范围:不要为小场景设置过大的 far plane。
  • 避免完全共面几何:贴花、阴影或覆盖层应使用偏移、专门的 decal 技术或深度偏置。
  • 使用深度偏置 (Depth Bias):在阴影映射、贴花等场景中,对深度值施加小的偏移,避免两个表面争夺同一深度。
  • 采用反向 Z (Reversed-Z):现代引擎常配合浮点深度缓冲,将近平面映射到较大深度、远平面映射到较小深度,以获得更好的远距离精度分布。

这些技术并不改变 Z-Buffer 的核心思想,而是在有限数值精度下尽量维持稳定、可预测的深度比较。


走样:连续信号的离散采样问题

Section titled “走样:连续信号的离散采样问题”

深度测试解决的是“谁在前面”。但即使可见性完全正确,图像边缘仍然可能出现锯齿。这是因为光栅化本质上是在用有限像素网格采样连续几何。

当一条斜线、三角形边缘或高频纹理穿过像素网格时,单个像素中心只能给出二值判断:被覆盖或未被覆盖。于是连续边界被迫贴合离散网格,形成阶梯状轮廓。这种由采样频率不足造成的失真称为走样 (Aliasing)

在图形学中,走样不仅表现为边缘锯齿,还可能表现为:

  • 远处细线闪烁;
  • 高频纹理产生摩尔纹;
  • 物体移动时边缘出现爬行感;
  • 小尺寸几何在帧与帧之间忽隐忽现。

从信号处理角度看,屏幕图像可以被视作一个二维信号,而像素网格是对这个信号的离散采样。如果信号变化频率超过采样网格能够表达的上限,高频信息就会被错误地解释为低频模式,形成视觉伪影。

这就是图形学中的采样问题:几何边界和纹理细节可能比像素网格更细。若只在每个像素中心采样一次,渲染器便无法知道该像素内部究竟有多少区域被三角形覆盖。

拖动边缘位置或切换采样方式,观察像素颜色如何从“全有或全无”的阶梯边缘,变成基于覆盖比例的平滑过渡。

最直接的抗锯齿方法是超采样抗锯齿 (Super-Sampling Anti-Aliasing, SSAA)。它的思想非常朴素:如果一个像素中心采样不够,就在一个像素内部采样更多次。

例如,对于每个像素,不只取中心一个采样点,而是取 2×22 \times 2 共四个子采样点。每个子采样点独立执行覆盖测试、深度测试与着色计算,最后把这些子样本颜色平均为最终像素颜色:

Colorpixel=1Ni=1NColoriColor_{pixel} = \frac{1}{N}\sum_{i=1}^{N} Color_i

当三角形边缘只覆盖像素的一部分时,一部分子样本落在三角形内,另一部分落在外部。平均后得到的颜色不再是简单的“全有或全无”,而是能表达边缘的部分覆盖,从而显著减轻锯齿。

  1. 单采样情况:一个像素中心若落在三角形外部,整个像素被判定为未覆盖,即使三角形实际覆盖了该像素的一角。
  2. 增加子采样点:把像素划分为 2×22 \times 24×44 \times 4 的子区域,在每个子区域中心执行覆盖测试。
  3. 统计覆盖比例:若 44 个子样本中有 22 个被三角形覆盖,则该像素可近似认为被覆盖了 50%50\%
  4. 颜色平均:将被覆盖子样本的三角形颜色与未覆盖子样本的背景颜色平均,得到更平滑的边缘过渡。

SSAA 的优点是概念简单、质量高,并且能同时改善几何边缘、纹理走样和着色高频噪声。缺点也非常明显:它几乎按采样数线性增加渲染成本。4×4\times SSAA 意味着大约四倍的片元处理量、深度存储和颜色采样压力。


为了降低 SSAA 的开销,实时渲染中更常见的是多重采样抗锯齿 (Multi-Sampling Anti-Aliasing, MSAA)

MSAA 的核心观察是:许多锯齿主要发生在几何边界处,而同一个像素内部的多个子采样点往往共享相同的片元着色结果。因此,MSAA 将“覆盖与深度采样”和“片元着色”部分拆开:

  • 覆盖测试与深度测试在多个子采样点上执行;
  • 片元着色器通常只执行一次;
  • 最终根据通过测试的子样本数量与颜色结果进行解析 (Resolve)。

这样,MSAA 能以较低成本改善多边形边缘,但它并不能完整解决所有走样问题。

可以把二者的差异理解为:

  • SSAA:多个子样本都完整执行着色,再平均颜色,质量高但代价大。
  • MSAA:多个子样本主要用于覆盖和深度判断,着色次数较少,适合实时几何边缘抗锯齿。

这就是为什么许多游戏引擎曾长期提供 2x MSAA4x MSAA8x MSAA 等选项:它们在边缘质量与性能之间提供了较好的折中。


随着延迟渲染、复杂着色器和高频材质大量使用,仅靠 MSAA 已经不足以覆盖所有走样来源。因此现代实时渲染还广泛使用基于图像后处理的抗锯齿方法。

FXAA (Fast Approximate Anti-Aliasing) 这类方法直接分析最终颜色图像,在屏幕空间中寻找高对比度边缘,并对边缘附近颜色进行局部平滑。它成本低、接入方便,但容易让图像变软,也难以处理时间维度上的闪烁。

TAA (Temporal Anti-Aliasing) 则利用多帧信息。它在每一帧对采样位置施加轻微抖动 (Jitter),再将当前帧与历史帧进行重投影与融合。由于不同帧采集到了略有差异的子像素信息,时间累积可以逐渐逼近更高采样率的结果。

TAA 的优势是能有效抑制几何边缘、纹理细节和远处闪烁;代价是需要处理运动物体、遮挡变化与历史颜色失效,否则可能产生拖影、模糊或鬼影。


本章把光栅化后的两个关键问题连接了起来:深度测试解决可见性,抗锯齿解决离散采样质量

  1. Z-Buffer 为每个屏幕采样点记录最近深度,使不透明物体能够在无需全局排序的情况下得到正确遮挡关系。
  2. 透视投影会造成非线性深度分布,深度精度集中在近平面附近,因此 near/far 的设置会直接影响 Z-Fighting 风险。
  3. 走样来自连续几何与高频图像信号被有限像素网格离散采样,典型表现为边缘锯齿、纹理摩尔纹和运动闪烁。
  4. SSAA 通过增加完整采样数提高质量,MSAA 通过多覆盖采样改善几何边缘,后处理与时间抗锯齿则进一步处理复杂材质和时间维度上的闪烁。

至此,我们已经完成了光栅化阶段最重要的几何离散化、属性插值、可见性判定与采样质量控制。下一部分将进入着色模型,讨论片元一旦通过深度测试后,如何根据光照、表面法线与材质参数计算最终颜色。