在 3D 渲染中,如何正确且高效地处理物体的遮挡关系是核心问题之一。本文将深入探讨 OpenGL 中的**传统深度测试(Depth Test)**以及现代 GPU 硬件优化技术——**Early-Z 拒绝(Early Depth Test)**的工作原理、管线位置、常见问题与优化方案。
一、 深度测试(Depth Test)基础
1. 渲染管线中的位置
传统深度测试发生在光栅化(Rasterization)之后、混合(Blending)之前。在片段操作阶段,其具体顺序如下:
光栅化 (Rasterization) ↓ [ 早期片段测试 (Early-Z) ] ← (GPU 硬件优化阶段,可选) ↓ 片段着色器 (Fragment Shader) ↓ 裁剪测试 (Scissor Test) ↓ 多重采样操作 (Multisample) ↓ 模板测试 (Stencil Test) ← (必须在传统深度测试之前) ↓ 传统深度测试 (Depth Test) ← (常规执行阶段) ↓ 混合 (Blending) ↓ 抖动与逻辑操作 (Dithering & Logical Ops) ↓ 写入帧缓冲 (Framebuffer Write)关键机制解析:
- 模板测试在深度测试之前:若启用了模板测试,片段会首先进行模板比较。若模板测试失败,片段直接被丢弃,不再执行深度测试。
- 传统测试在片段着色器之后:因为传统的深度测试(Late-Z)在片段着色器(FS)执行完毕后才进行,这意味着即便片段最终因被遮挡而被深度测试丢弃,GPU 也已经为其运行了片段着色器。这造成了严重的计算资源浪费(即 Overdraw 重绘)。
2. 深度测试工作原理
(1) 深度缓冲(Depth Buffer)
- 深度缓冲是一个与颜色缓冲(Color Buffer)等大的一维/二维数组。
- 每个像素点对应存储一个深度值(常用格式为 24-bit 整数或 32-bit 浮点数)。
- 深度值范围通常被归一化在
[0.0, 1.0]之间,0.0代表近裁剪面(Near Plane),1.0代表远裁剪面(Far Plane)。
(2) 深度测试比较
当光栅化生成的新片段到达深度测试阶段时,GPU 会将**当前片段的深度值(Z_new)与深度缓冲中对应位置已有的深度值(Z_buffer)**进行比较。
比较规则由glDepthFunc()函数设置:
| 比较函数 | 说明 |
|---|---|
GL_LESS | 新深度值 < 缓冲深度值时通过(默认值) |
GL_LEQUAL | 新深度值 ≤ 缓冲深度值时通过 |
GL_GREATER | 新深度值 > 缓冲深度值时通过 |
GL_GEQUAL | 新深度值 ≥ 缓冲深度值时通过 |
GL_EQUAL | 新深度值 == 缓冲深度值时通过 |
GL_NOTEQUAL | 新深度值 ≠ 缓冲深度值时通过 |
GL_ALWAYS | 总是通过测试 |
GL_NEVER | 从不通过测试 |
(3) 决策流程图
┌───────────────────────────┐ │ 新片段到达测试阶段 │ └─────────────┬─────────────┘ ▼ ┌───────────────────────────┐ │ 进行模板测试 │ └─────────────┬─────────────┘ ├────────── 失败 ──────────┐ ▼ 通过 │ ┌───────────────────────────┐ │ │ 执行 glDepthFunc 比较 │ │ │ (Z_new vs Z_buffer) │ │ └─────────────┬─────────────┘ │ ├────────── 失败 ──────────┼──► 丢弃片段 ▼ 通过 │ (依据规则更新模板缓冲) ┌───────────────────────────┐ │ │ 1. 写入/更新颜色缓冲区 │ │ │ 2. 更新深度缓冲区(若允许)│ │ └───────────────────────────┘ ▼3. 启用与配置
在 OpenGL 中,深度测试的生命周期控制如下:
// 1. 开启深度测试glEnable(GL_DEPTH_TEST);// 2. 设置比较函数(默认是 GL_LESS)glDepthFunc(GL_LESS);// 3. 控制深度写入权限glDepthMask(GL_TRUE);// 允许写入深度缓冲区(不透明物体)glDepthMask(GL_FALSE);// 禁止写入深度缓冲区,只进行只读比较(半透明物体)// 4. 设置深度范围映射(默认 0.0 到 1.0)glDepthRange(0.0f,1.0f);4. 深度缓冲区的格式与精度
(1) 常见深度格式
GL_DEPTH_COMPONENT16:16位非线性正规化整数,精度较低。GL_DEPTH_COMPONENT24:24位非线性正规化整数,最常用的高性价比格式。GL_DEPTH_COMPONENT32F:32位浮点数,高精度,常配合反向Z(Reversed-Z)使用。
(2) 深度值的非线性映射
在透视投影下,视空间中的深度Z v i e w Z_{view}Zview并不是线性映射到屏幕空间的Z w i n d o w Z_{window}Zwindow。映射公式如下:
F d e p t h = 1 / z − 1 / n e a r 1 / f a r − 1 / n e a r F_{depth} = \frac{1/z - 1/near}{1/far - 1/near}Fdepth=1/far−1/near1/z−1/near
这种非线性映射使得靠近近裁剪面(Near Plane)的区域精度极高,而靠近远裁剪面(Far Plane)的区域精度极低。这也是导致深度冲突(Z-Fighting)的主要原因。
5. 深度冲突(Z-Fighting)
当两个表面距离非常近,且位于深度缓冲区精度较差的区域(如远处)时,GPU 无法区分两个片段的深度值大小,导致渲染画面中两个表面的图案相互闪烁穿插。
解决方案详解:
拉远近裁剪面(Near Plane)
- 原理:根据透视投影的深度公式F d e p t h = f f − n ⋅ ( 1 − n z ) F_{depth} = \frac{f}{f - n} \cdot (1 - \frac{n}{z})Fdepth=f−nf⋅(1−zn),深度值的变化率(即导数)与n z 2 \frac{n}{z^2}z2n成正比。
- 示例分析:
- 若设置近裁剪面n = 0.1 n = 0.1n=0.1,远裁剪面f = 1000 f = 1000f=1000。当物体从真实深度z = 0.1 z = 0.1z=0.1移动到z = 1.0 z = 1.0z=1.0时,深度值F d e p t h F_{depth}Fdepth会从
0.0暴增到约0.9。这意味着前 1 米的空间消耗了整个深度缓冲90 % 90\%90%的精度,而剩下的999 999999米空间只能共享可怜的10 % 10\%10%精度。 - 若将近裁剪面拉远到n = 1.0 n = 1.0n=1.0,则同样的1.0 ∼ 10.0 1.0 \sim 10.01.0∼10.0米区间消耗90 % 90\%90%精度。原先在1.0 ∼ 1000 1.0 \sim 10001.0∼1000米区间的深度分辨率因此被放大了数倍,大大降低了中远景发生 Z-Fighting 的概率。
- 若设置近裁剪面n = 0.1 n = 0.1n=0.1,远裁剪面f = 1000 f = 1000f=1000。当物体从真实深度z = 0.1 z = 0.1z=0.1移动到z = 1.0 z = 1.0z=1.0时,深度值F d e p t h F_{depth}Fdepth会从
- 最佳实践:尽量避免将 Near Plane 设为极小值(如
0.001),通常设为0.1或1.0为宜。
提高深度缓冲区精度
- 使用 24 位(
GL_DEPTH_COMPONENT24)甚至 32 位浮点型(GL_DEPTH_COMPONENT32F)深度缓冲区,从而增加可表达的深度数值范围和密度。
- 使用 24 位(
Polygon Offset(多边形偏移)
- 原理:在光栅化阶段,通过为片段的深度值手动附加一个与多边形斜率相关的偏移量(Offset),强制拉开共面或极度贴合的多边形深度差。
- 代码示例:
glEnable(GL_POLYGON_OFFSET_FILL);// 参数1: factor (缩放因子,针对多边形的最大深度斜率)// 参数2: units (单位因子,针对深度缓冲的最小可分辨值)glPolygonOffset(1.0f,1.0f);
使用反向Z(Reversed-Z)技术(配合浮点深度缓冲,极力推荐)
- 原理解析:
- 传统的精度错配:非线性深度投影公式在近裁剪面(即F d e p t h ≈ 0.0 F_{depth} \approx 0.0Fdepth≈0.0附近)拥有极高几何精度;而浮点数(IEEE 754 标准)的存储特性也是越靠近 0.0 精度越高,靠近 1.0 精度越低。两者的最高精度点都堆叠在近裁剪面处,造成了浪费,而远裁剪面处(F d e p t h ≈ 1.0 F_{depth} \approx 1.0Fdepth≈1.0)则两项精度都最差。
- 反向对消:反向 Z 将近裁剪面映射为
1.0,远裁剪面映射为0.0。- 近裁剪面(z 较小):几何精度高,但由于映射为
1.0,浮点存储精度低。 - 远裁剪面(z 较大):几何精度极低,但由于映射为
0.0,浮点存储拥有极其庞大的可用精度空间。
- 近裁剪面(z 较小):几何精度高,但由于映射为
- 效果:这种“对向抵消”使得在整个视锥体空间中,可用的深度精度分布几乎达到了完美的线性均匀状态。配合 32-bit Float 深度缓冲,甚至能将远裁剪面设为无穷大(Infinity)而不会在任何距离发生 Z-Fighting。
- 实现步骤:
- 清空深度值设为
0.0:glClearDepth(0.0f); - 将深度比较函数修改为大于通过:
glDepthFunc(GL_GREATER); - 投影矩阵修改:在生成透视投影矩阵时,反转n nn和f ff的映射输出。
- 清空深度值设为
- 原理解析:
6. 实战渲染流程与常见问题
(1) 经典渲染循环框架
// 初始化阶段glEnable(GL_DEPTH_TEST);glDepthFunc(GL_LESS);// 渲染主循环while(!glfwWindowShouldClose(window)){// 必须清空颜色缓冲与深度缓冲glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT);// 1. 先绘制所有不透明物体 (开启深度测试、开启深度写入)glDepthMask(GL_TRUE);drawOpaqueObjects();// 2. 后绘制所有半透明物体 (开启深度测试、关闭深度写入)// 注意:半透明物体需要从远到近进行排序渲染glEnable(GL_BLEND);glBlendFunc(GL_SRC_ALPHA,GL_ONE_MINUS_SRC_ALPHA);glDepthMask(GL_FALSE);// 只做深度测试,不写入深度,避免遮挡后面的透明像素drawTransparentObjects();glDisable(GL_BLEND);}(2) 常见排错表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 画面重叠,后绘制的物体总是覆盖先绘制的物体 | 未启用深度测试,或者每帧未清空深度缓冲。 | 检查是否调用glEnable(GL_DEPTH_TEST);每帧使用glClear(GL_DEPTH_BUFFER_BIT)。 |
| 透明物体相互遮挡关系混乱,或者直接把背景抠空 | 渲染透明物体时未关闭深度写入,导致其透明区域写入了较浅的深度。 | 渲染透明物体前调用glDepthMask(GL_FALSE),且确保排序正确。 |
| 远处的山体或建筑表面出现严重的黑色锯齿闪烁 | 发生了 Z-Fighting。 | 拉远近裁剪面,或使用glPolygonOffset。 |
二、 Early-Z 优化详解
1. 基本概念
Early-Z是现代 GPU 的一种硬件层面优化技术。
- Late-Z(传统管线):光栅化 → 片段着色器 → 深度/模板测试。
- Early-Z(优化管线):光栅化 →Early-Z / Stencil 测试→ 片段着色器。
通过在片段着色器(FS)执行之前先一步进行深度和模板测试,凡是未通过测试的片段会被直接丢弃,从而完全跳过该片段的片段着色器计算。这对于复杂着色(多光源、重度光照计算)的场景能带来成倍的性能提升。
2. 硬件实现原理(Hi-Z)
为了在硬件层面更快速地过滤大面积被遮挡的区域,AMD 和 NVIDIA 引入了Hi-Z(Hierarchical Z,层次化 Z-Cache)机制。
(1) Z-Cache 金字塔结构
GPU 会在片上(On-Chip)维护一个低分辨率的深度金字塔。与纹理的 Mipmap 类似:
- Mip 0(最精细):对应完整的逐像素深度缓冲(存于显存中)。
- Mip 1:将 Mip 0 按2 × 2 2\times22×2区域合并,记录该区域内的最大深度值(Z m a x Z_{max}Zmax,假设使用
GL_LESS)。 - Mip 2(更粗糙):将 Mip 1 再按2 × 2 2\times22×2合并,记录该区域的最大深度值。
【 Hi-Z 层次化深度金字塔 】 [ Mip 2: 极粗糙 ] [ Mip 1: 中等 ] [ Mip 0: 逐像素 ] ┌─────────────────┐ ┌────────┬────────┐ ┌──┬──┬──┬──┐ │ │ │ │ │ ├──┼──┼──┼──┤ │ 1/16 尺寸 │ ──► ├────────┼────────┤ ──► ├──┼──┼──┼──┤ │ (4×4最大深度) │ │ │ │ ├──┼──┼──┼──┤ └─────────────────┘ └────────┴────────┘ └──┴──┴──┴──┘(2) 快速剔除(Z-Cull / Hi-Z Test)
- 当一个三角形光栅化时,GPU 首先将其包围盒与较粗糙层级(如 Mip 2)的Z m a x Z_{max}Zmax进行比较。
- 如果该三角形在 Mip 2 对应区域的最小深度值仍大于Z m a x Z_{max}Zmax,则说明整个三角形在屏幕上该区域被完全遮挡。
- GPU 会直接整块丢弃(Z-Cull),无需访问耗时的逐像素显存深度缓冲。
- 如果无法整块剔除,再逐级向下检查,直至在 Mip 0 级别进行精确 of Early-Z 测试。
3. 管线工作流程
下图展示了现代 GPU 在处理片段时的完整分支路径。
光栅化生成片段 │ ▼ ┌───────────────────────┐ │ Hi-Z / Z-Cull 测试 │ └───────────┬───────────┘ ├────────── 失败 ──► 丢弃片段 (不执行 FS) ▼ 通过 ┌───────────────────────┐ │ Early-Z / Stencil │ └───────────┬───────────┘ ├────────── 失败 ──► 丢弃片段 (不执行 FS) ▼ 通过 ┌───────────────────────┐ │ 片段着色器 (FS 执行) │ └───────────┬───────────┘ │ ┌─────────────┴─────────────┐ │ 是否修改过深度/包含 discard? │ └─────────────┬─────────────┘ ├─ 是 (FS修改了深度) │ │ │ ▼ │ ┌───────────────────────┐ │ │ Late-Z 深度测试 │ ← 必须在此阶段重新测试 │ └──────────┬────────────┘ │ ├────── 失败 ──► 丢弃片段 │ ▼ 通过 ▼ ▼ ┌──────────────────┐ │ 混合与写入帧缓冲 │ └──────────────────┘4. Early-Z 失效的场景
尽管 Early-Z 性能优异,但在编写片段着色器或配置管线时,以下操作会使得 GPU 无法在 FS 之前预知或确定深度,从而迫使 GPU禁用 Early-Z(退化为 Late-Z):
- 片段着色器中修改了深度值:
- 写入
gl_FragDepth。 - 此时,GPU 无法在 FS 执行前得知最终深度,只能在 FS 执行完后进行 Late-Z。
- 写入
- 使用了丢弃操作(
discard):- 即使没有修改深度,但如果 FS 中有
discard,GPU 就不能提前进行深度写入。因为如果提前写了深度,而 FS 随后执行了discard,深度缓冲区就会被污染。 - 注:部分现代 GPU 可以在有
discard时执行 Early-Z 测试(Cull),但会禁用早期深度写入,待 FS 执行完确定不丢弃后才写入,这仍会损失一部分性能。
- 即使没有修改深度,但如果 FS 中有
- 启用了 Alpha-to-Coverage或手工进行的 Alpha 测试:
- 它们会在 FS 运行中动态改变片段的 Coverage(覆盖率),导致深度无法提前确定。
- 混合状态或深度比较函数频繁切换:
- 频繁更改
glDepthFunc会导致 Hi-Z 缓存数据失效,导致硬件保守地关闭优化。
- 频繁更改
5. 显式开启与优化方案
方案 1:Pre-Z Pass(深度预通道,应用层控制)
这是大中型 3D 引擎最常用的方案,分两个 Pass 渲染:
// ===== Pass 1: 仅填充深度缓冲 (Pre-Z Pass) =====glColorMask(GL_FALSE,GL_FALSE,GL_FALSE,GL_FALSE);// 禁用颜色写入glDepthMask(GL_TRUE);// 开启深度写入glEnable(GL_DEPTH_TEST);glDepthFunc(GL_LESS);// 渲染所有不透明物体(此时 FS 可以是极简的空着色器,避免计算开销)drawAllOpaqueObjectsWithSimpleShader();// ===== Pass 2: 正常光照着色渲染 (Main Pass) =====glColorMask(GL_TRUE,GL_TRUE,GL_TRUE,GL_TRUE);// 恢复颜色写入glDepthMask(GL_FALSE);// 关闭深度写入 (因为深度已在 Pass 1 确定)glDepthFunc(GL_EQUAL);// 仅渲染深度完全相等的片段// 使用复杂的 Shader 渲染物体(所有被遮挡的片段会在此瞬间被 Early-Z 剔除)drawAllOpaqueObjectsWithComplexShader();方案 2:GLSL 显式声明(强制 Early-Z,Shader 层控制)
在GLSL 4.2+(或使用ARB_shader_image_load_store扩展)中,你可以使用布局修饰符显式地告诉 GPU 强制进行 Early-Z 测试:
#version 420 core // 强制开启早期片段测试 layout(early_fragment_tests) in; out vec4 FragColor; in vec2 TexCoords; uniform sampler2D alphaTex; void main() { // 即使我们使用了 discard 操作,GPU 依然会在 FS 执行之前进行深度/模板测试 // 如果测试失败,FS 直接不执行。如果测试通过,FS 执行并在通过后写入深度。 if (texture(alphaTex, TexCoords).r < 0.1) { discard; } FragColor = vec4(1.0, 0.0, 0.0, 1.0); }方案 3:保守深度修改(Conservative Depth)
如果你的片段着色器必须修改gl_FragDepth,但你知道修改的趋势(如:深度只会变大或变小),可以使用保守深度布局修饰符(GLSL 4.2+),允许 GPU 在特定比较下保留 Early-Z 优化:
#version 420 core // 声明我们写入的深度只会比原插值深度大(即往远剪裁面方向推) layout (depth_greater) out float gl_FragDepth; void main() { float customDepth = calculateDepth(); // 计算出比插值深度更大的值 gl_FragDepth = customDepth; // 依然能够享受部分 Early-Z / Hi-Z 优化 }6. Early-Z vs Late-Z 对比表
| 特性 | Early-Z (早期深度测试) | Late-Z (传统深度测试) |
|---|---|---|
| 执行时机 | 片段着色器(FS)执行之前。 | 片段着色器(FS)执行之后。 |
| 硬件要求 | 现代 GPU 硬件支持,默认自动尝试开启。 | 任何硬件皆支持。 |
| 深度缓存写入 | 可以在 FS 执行前写入,若含discard则需延迟写入。 | 仅在 FS 执行完毕并通过测试后写入。 |
| 主要性能优势 | 避免了被遮挡片段的 FS 计算,大幅降低重绘(Overdraw)开销。 | 无性能优势,为 FS 修改深度提供保底正确性。 |
| 触发失效的条件 | FS 写入gl_FragDepth、启用 Alpha 测试等。 | 无限制。 |
相关官方规范参考:
- OpenGL Wiki - Early Fragment Test
- LearnOpenGL - Depth testing