深入解析:Early-Z、Z-Culling、Hi-Z 与 Z-Prepass——现代渲染中的深度优化四件套

深入解析: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 都默认启用

局限性

  • 如果像素着色器修改了深度值(discardclip、深度写入),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 后,剔除时可以从最粗层级开始:

  1. 用视锥 frustum 与 Level N 层比较
  2. 如果某个 Tile 的最远深度仍然比摄像机近裁剪面还远 → 整块剔除
  3. 否则向下细化,直到 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 效率,是应用层的策略选择

四者并非互斥,而是从硬件到软件、从细粒度到粗粒度的互补关系。理解它们的定位,才能在具体项目中做出正确的渲染优化决策。

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部