深入解析:Early-Z、Z-Culling、Hi-Z 与 Z-Prepass——现代渲染中的深度优化四件套
前言
在实时渲染(Real-Time Rendering)中,深度测试(Depth Test)是避免绘制不可见像素、提升帧率的核心机制。但默认的深度测试有一个致命问题:它发生在着色之后,意味着 GPU 会为大量最终被丢弃的像素浪费大量计算资源。
Early-Z、Z-Culling、Hi-Z、Z-Prepass 这四项技术,本质上都是在深度测试的时序和粒度上做文章,目标是:越早扔掉无用的像素越好。
下面逐一拆解。
1. Early-Z(提前深度测试)
原理
Early-Z 是硬件层面的一项优化。传统管线中,深度测试在像素着色器(Fragment Shader)执行之后进行。Early-Z 则允许 GPU 在执行像素着色器之前,先做一次深度测试,如果该像素本来就会被后面更近的物体遮挡,就直接丢弃,跳过昂贵的着色计算。
渲染顺序(默认管线):
像素着色 → 深度测试 → 丢弃 / 写入
启用 Early-Z 后:
深度测试(早) → 通过 → 像素着色 → 深度测试(晚) → 写入
↓ 失败
直接丢弃(跳过着色)
关键点:Early-Z 并不是绕过深度测试,而是把测试时机提前。 硬件仍会在着色后做一次”晚期深度测试”(Late-Z)来保证正确性。
代码示例
Early-Z 的启用通常由驱动和 API 自动管理,但开发者需要避免主动破坏它:
// ✅ 正确的做法:保持 Early-Z 生效
void main() {
vec3 albedo = texture(uAlbedoMap, vUv).rgb;
gl_FragColor = vec4(albedo, 1.0);
// 不使用 discard / clip,Early-Z 正常工作
}
// ❌ 错误的做法:破坏 Early-Z
void main() {
float alpha = texture(uAlphaMap, vUv).a;
if (alpha < 0.5) discard; // discard 会导致 Early-Z 自动失效
gl_FragColor = vec4(1.0);
}
在 DirectX 12 中,可通过 PSODesc.PSOPatchTopologyType 和渲染状态配置来控制:
// 启用 Early-Z(深度测试在像素着色前)
D3D12_GRAPHICS_PIPELINE_STATE_DESC psoDesc = {};
psoDesc.DepthStencilState.DepthEnable = TRUE;
psoDesc.DepthStencilState.DepthFunc = D3D12_COMPARISON_FUNC_LESS;
psoDesc.DepthStencilState.DepthWriteMask = D3D12_DEPTH_WRITE_MASK_ALL;
// Early-Z 由硬件自动应用,无需额外 API
使用场景
- 大量 OverDraw 的场景:比如人物站在复杂背景前,大面积背景像素会被人物遮挡
- 着色计算复杂的场景:PBR 着色、体积光、毛发等,越早跳过越好
- 几乎所有现代 GPU 都默认启用
局限性
- 如果像素着色器修改了深度值(
discard、clip、深度写入),Early-Z 会自动失效 - Alpha 混合和透明度会导致深度冲突,需要小心处理
2. Z-Culling(深度剔除)
原理
Z-Culling 是一个更宽泛的概念,本质上就是“用深度信息来剔除不可见的几何体或像素”。狭义上,它指 Screen-Space Tiled Z-Culling,即把屏幕空间划分为小块(Tile),基于 Tile 内的深度范围批量拒绝像素。
常见的实现方式:
– Hierarchical Z-Culling:多级 Z-Buffer,从粗到细逐层剔除
– Tile-Based Z-Culling:每个 Tile 保存 min/max 深度,块内像素可以快速跳过
这与 Early-Z 的区别在于:Early-Z 是硬件自动行为,Z-Culling 更多指软件/架构层面的剔除策略。
代码示例
一个简化的 Tile-Based Z-Culling 实现思路:
// 假设屏幕划分为 16x16 的 Tile
const int TILE_SIZE = 16;
const int TILE_COUNT_X = screenWidth / TILE_SIZE;
const int TILE_COUNT_Y = screenHeight / TILE_SIZE;
struct Tile {
float minDepth; // Tile 内最小深度(最近)
float maxDepth; // Tile 内最大深度(最远)
};
// 对每个 Tile 进行深度范围剔除
void cullTiles(Tile tiles[TILE_COUNT_X * TILE_COUNT_Y], const Camera& cam) {
for (int ty = 0; ty < TILE_COUNT_Y; ++ty) {
for (int tx = 0; tx < TILE_COUNT_X; ++tx) {
Tile& tile = tiles[ty * TILE_COUNT_X + tx];
// 如果 Tile 内最远的像素都比近裁剪面还远,整块剔除
if (tile.maxDepth < cam.nearPlane) {
// 整块 Tile 的像素都不需要渲染
markTileCulled(tx, ty);
continue;
}
// 如果 Tile 内最近的像素比远裁剪面还远,部分剔除
if (tile.minDepth > cam.farPlane) {
markTileCulled(tx, ty);
continue;
}
// 否则需要渲染(但可能还有视锥体剔除等进一步优化)
}
}
}
使用场景
- 移动端 GPU(Adreno、Mali):Tile-Based Deferred Rendering (TBDR) 架构下,Z-Culling 是性能关键
- 大规模场景:如开放世界游戏,地形、植被的远景剔除
- VR 渲染:每帧要渲染双视角,剔除优化尤为重要
3. Hi-Z(分层深度缓冲,Hierarchical Z-Buffer)
原理
Hi-Z 是一种数据结构和剔除策略的组合。它在 Z-Buffer 基础上构建了多层级(mipmap)的深度图:
Level 0: 原始分辨率,每个像素一个深度值
Level 1: 2×2 块合并,取最大深度(对于深度缓冲,通常存最远深度)
Level 2: 4×4 块合并
...
Level N: 最小分辨率
构建好 Hi-Z 后,剔除时可以从最粗层级开始:
- 用视锥 frustum 与 Level N 层比较
- 如果某个 Tile 的最远深度仍然比摄像机近裁剪面还远 → 整块剔除
- 否则向下细化,直到 Level 0 逐像素测试
代码示例
Hi-Z 的构建(从原始深度图生成多级 Mipmap):
// 计算 Hi-Z 的 Mipmap Level(GPU 自动完成,但理解原理很重要)
// 在着色器中手动构建 Hi-Z:
void main() {
ivec2 texCoord = ivec2(gl_FragCoord.xy);
// Level 0:直接读取原始深度
float depth00 = texelFetch(uDepthTexture, texCoord, 0).r;
// Level 1:2x2 块的最大深度
float depth10 = texelFetch(uDepthTexture, texCoord / 2, 0).r;
float depth11 = texelFetch(uDepthTexture, texCoord / 2 + ivec2(1, 0), 0).r;
float depth12 = texelFetch(uDepthTexture, texCoord / 2 + ivec2(0, 1), 0).r;
float depth13 = texelFetch(uDepthTexture, texCoord / 2 + ivec2(1, 1), 0).r;
float level1Depth = max(max(depth10, depth11), max(depth12, depth13));
// 实际上硬件会自动生成这些 Mip,开发者只需要使用正确的纹理参数
gl_FragColor = vec4(level1Depth, 0.0, 0.0, 1.0);
}
在 OpenGL 中利用硬件自动生成的 Hi-Z Mipmap:
// 创建支持多级纹理的深度图
glGenTextures(1, &depthTexture);
glBindTexture(GL_TEXTURE_2D, depthTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT24, width, height, 0,
GL_DEPTH_COMPONENT, GL_UNSIGNED_INT, NULL);
// 启用自动生成 Mipmap(硬件自动构建 Hi-Z)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, maxMipLevel);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glGenerateMipmap(GL_TEXTURE_2D); // 这一步生成所有层级的 Hi-Z
使用 Hi-Z 做视锥体剔除查询:
// 根据上一帧 Hi-Z 判断本帧是否需要渲染某个物体
bool isOccludedByHiZ(vec4 worldPos) {
vec4 clipPos = uViewProjection * worldPos;
clipPos /= clipPos.w;
// 计算在 Hi-Z Mip 中的位置(Mip 层级根据物体大小自适应)
float mipLevel = computeMipLevel(clipPos, uHiZTextureSize);
// 读取 Hi-Z 中对应的深度范围
float tileMinDepth = textureLod(uHiZTexture,
(clipPos.xy * 0.5 + 0.5) / uHiZTextureSize, mipLevel).r;
// 如果物体最远深度比 Hi-Z 中记录的最小深度(最近)还远,说明被遮挡
return clipPos.z > tileMinDepth;
}
使用场景
- 遮挡剔除(Occlusion Culling):在 CPU 端判断物体是否被遮挡,决定是否送入 GPU 绘制
- 硬件级 Hi-Z:NVIDIA 的 ZFocal、AMD 的 Hi-Z 引擎,支持在硬件中自动完成多级剔除
- 光线追踪(Ray Tracing):DXR / RTX 中用 Hi-Z 加速光线-场景求交
4. Z-Prepass(深度预通道,Depth Pre-Pass)
原理
Z-Prepass 是一种渲染管线策略,而非硬件机制。其核心思想是:
第一步:只渲染场景中所有几何体的深度信息(关闭颜色写入、关闭光照等昂贵计算),生成一张干净的深度图。
第二步:以这张深度图作为基准,执行真正的渲染。对于透明物体,再单独处理。
普通管线:
几何体 → 着色(贵) → 深度测试 → 丢弃/写入
Z-Prepass:
第一遍: 几何体 → 只写深度 → 生成干净深度图
第二遍: 几何体 → Early-Z 生效(利用上一步深度) → 着色 → 写入
Z-Prepass 本质上是用额外的 draw call 换取 Early-Z 的命中率。
代码示例
Unity 中的 Z-Prepass 设置(URP):
// 在 URP 中启用 Z-Prepass
UniversalRenderPipelineAsset urpAsset =
(UniversalRenderPipelineAsset)GraphicsSettings.currentRenderPipeline;
urpAsset.supportsLightLayers = true;
// 或者通过渲染器的 Forward Renderer 配置
ForwardRendererData forwardRenderer =
UnityEngine.Rendering.Universal.ForwardRendererData;
forwardRenderer.depthPrimingMode = DepthPrimingMode.Auto;
// Auto 模式下引擎会自动判断是否使用 Z-Prepass
Unreal Engine 5 中的 Z-Prepass:
// 在 Engine.ini 中配置全局 Z-Prepass
[Console]
r.EarlyZPass = 3 // 0=关闭, 1=只有不透明物体, 2=只有透明物体, 3=全部
r.EarlyZPassMovable = 1 // 1=允许移动物体也参与 Z-Prepass
// 在自定义着色器中使用 Prepass 结果
class FEarlyZPassInterface {
// Unreal 会自动在 Z-Prepass 后使用结果
// 开发者只需确保着色器不破坏 Early-Z
};
OpenGL 手动实现 Z-Prepass:
// ============ 第一遍:只渲染深度 ============
glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); // 关闭颜色写入
glDepthMask(GL_TRUE); // 开启深度写入
glEnable(GL_DEPTH_TEST);
// 渲染所有不透明几何体(只写入深度)
for (auto& mesh : opaqueMeshes) {
drawMesh(mesh);
}
// ============ 第二遍:正常渲染(Early-Z 自动生效) ============
glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); // 开启颜色写入
glDepthFunc(GL_EQUAL); // 使用 GL_EQUAL 而非默认的 GL_LESS
// 这样只写入深度值不变的像素
for (auto& mesh : opaqueMeshes) {
// 着色 + 渲染(Early-Z 利用已有的干净深度图)
bindShaders(mesh.shader);
bindTextures(mesh);
drawMesh(mesh);
}
// 透明物体单独处理(必须用 GL_LESS,否则会被错误剔除)
glDepthFunc(GL_LESS);
for (auto& mesh : transparentMeshes) {
drawMesh(mesh);
}
使用场景
- 复杂场景 + 昂贵着色器:如毛发渲染(每根毛发都要着色)、PBR 场景
- 透明物体少或可分开处理的场景
- Unreal Engine / Unity 的内置渲染器中均可手动启用
局限性
- 需要两倍的几何体提交成本(Draw Calls),对合批不友好的场景反而更慢
- 透明物体必须放在 Z-Prepass 之后用单独通道,否则会被错误剔除
- 如果场景中 OverDraw 本来就不严重,Z-Prepass 可能得不偿失
横评对比
| 技术 | 层级 | 谁来做 | 粒度 | 改变渲染流程? |
|---|---|---|---|---|
| Early-Z | 硬件 | GPU 自动 | 像素级 | 否(自动插入) |
| Z-Culling | 软硬结合 | 硬件/驱动 | Tile / 像素 | 否 |
| Hi-Z | 软件/数据结构 | 两者都有 | 块级(多层级) | 否(只是查询结构) |
| Z-Prepass | 应用层 | 开发者主动 | 整个几何体通道 | 是(多遍渲染) |
实际应用中的组合策略
渲染优化管线(现代 3A 游戏常见):
1. CPU 端: 遮挡剔除 (Hi-Z 查询)
↓
2. GPU 第一遍: Z-Prepass(只写深度)
↓
3. GPU 第二遍: 着色渲染
├── Early-Z 自动生效(利用 Z-Prepass 结果)
└── Z-Culling / Hi-Z 在硬件层兜底
Z-Prepass 提供干净的深度图 → Early-Z 命中率大幅提升 → 着色器只处理真正可见的像素。
总结
- Early-Z:硬件自动提前深度测试,省掉不必要的着色
- Z-Culling:用深度范围信息做像素/块级剔除,Tile-Based GPU 的核心优化
- Hi-Z:构建多层级深度图,支持从粗到精的快速剔除查询
- Z-Prepass:主动多遍渲染,用额外几何体成本换取 Early-Z 效率,是应用层的策略选择
四者并非互斥,而是从硬件到软件、从细粒度到粗粒度的互补关系。理解它们的定位,才能在具体项目中做出正确的渲染优化决策。