WEBKT

拒绝CPU瓶颈:在Vulkan中实现现代GPU Driven的Hi-Z遮挡剔除

27 0 0 0

在传统的渲染管线中,遮挡剔除(Occlusion Culling)通常在CPU端进行(如使用软件光栅化或包围盒相交测试),或者利用GPU的查询对象(Occlusion Query)。然而,这些方法要么消耗宝贵的CPU算力,要么因为GPU回传数据给CPU带来严重的延迟(Frame Latency)。

随着现代GPU功能的演进,**GPU Driven Rendering(GPU驱动渲染)成为了主流。我们不再需要CPU来逐个提交Draw Call,而是将所有物体的包围盒数据送入GPU,在Compute Shader中进行视锥体剔除和Hi-Z(Hierarchical Z-Buffer,层级深度缓冲)**遮挡剔除,最后直接在GPU端生成绘制指令,通过 vkCmdDrawIndexedIndirect 实现单次提交成千上万个物体的极致性能。

下面,我们来完整拆解如何在Vulkan中落地一套工业级的基于GPU Driven的Hi-Z遮挡剔除系统。


1. 核心管线流程

要实现基于Hi-Z的GPU自驱动剔除,渲染管线需要按照以下步骤重构:

[步骤 1: 深度预通 (Depth Prepass)] ➔ 写入深度缓冲 (Depth Buffer)
                              ↓
[步骤 2: 构建 Hi-Z 金字塔] ➔ 逐级生成 Mipmap (取 Max 深度)
                              ↓
[步骤 3: 剔除 Compute Shader] ➔ 读取 Hi-Z,对所有 Mesh 实例进行 AABB 测试
                              ↓ (输出可见的 Draw Call 数据)
[步骤 4: 间接绘制] ➔ 调度 vkCmdDrawIndexedIndirect 绘制可见物体

在实际工程中,为了解决“用当前帧深度去剔除当前帧物体”带来的时序矛盾,我们通常采用 Two-Pass(双路)剔除策略

  1. First Pass:使用上一帧的Hi-Z金字塔,剔除当前帧的所有物体。将通过测试的物体立刻绘制,同时输出一个新的深度图。
  2. 构建新Hi-Z:基于这个新的深度图,构建当前帧的Hi-Z金字塔。
  3. Second Pass:对于在First Pass中被剔除的物体,使用当前帧最新的Hi-Z再做一次剔除。这是为了找出那些上一帧被遮挡、但这一帧由于相机移动或物体运动而暴露出来的物体(即重构遮挡关系)。将通过测试的物体进行补漏绘制。

2. 构建 Hi-Z 深度金字塔

Hi-Z金字塔其实就是一张带有Mipmap的深度图,但它的生成规则和普通的颜色Mipmap不同:普通Mipmap是下采样求平均,而 Hi-Z下采样需要取对应4个像素的最大值(或最小值,取决于你的Depth Range是 [0, 1] 还是 Reversed-Z)

在Reversed-Z(近平面为1,远平面为0)配置下,我们应该取 $2 \times 2$ 像素中的**最小值(Minimum)**作为上一级的深度。

Compute Shader 生成 Hi-Z 单级 Mipmap

在Vulkan中,我们可以使用单次Dispatch多个Barrier,或者使用单个Compute Shader配合 imageStore 写入不同的Mip Level。以下是生成单个Mip Level的典型Compute Shader实现:

#version 450
layout(local_size_x = 16, local_size_y = 16) in;

layout(binding = 0) uniform sampler2D InDepthImage; // 上一级 Mip
layout(binding = 1, r32f) writeonly uniform image2D OutHiZImage; // 当前要写入的 Mip

layout(push_constant) uniform PushConstants {
    vec2 u_InTexelSize; // 上一级像素的尺寸 (1/width, 1/height)
} pcs;

void main() {
    ivec2 outCoords = ivec2(gl_GlobalInvocationID.xy);
    ivec2 inSize = textureSize(InDepthImage, 0);
    
    // 边界保护
    if (outCoords.x >= (inSize.x >> 1) || outCoords.y >= (inSize.y >> 1)) {
        return;
    }

    // 采集上一级 2x2 的深度值
    vec2 uv = (vec2(outCoords) * 2.0 + vec2(0.5)) * pcs.u_InTexelSize;
    
    float d0 = textureOffset(InDepthImage, uv, ivec2(0, 0)).r;
    float d1 = textureOffset(InDepthImage, uv, ivec2(1, 0)).r;
    float d2 = textureOffset(InDepthImage, uv, ivec2(0, 1)).r;
    float d3 = textureOffset(InDepthImage, uv, ivec2(1, 1)).r;

    // 在 Reversed-Z 下,取最小值表示最远的距离(作为保守遮挡物)
    float minDepth = min(min(d0, d1), min(d2, d3));

    imageStore(OutHiZImage, outCoords, vec4(minDepth));
}

3. 核心:Hi-Z 剔除 Compute Shader

剔除Shader的输入是所有待渲染物体的包围盒(AABB)、实例数据以及Hi-Z金字塔;输出是一个写入了 VkDrawIndexedIndirectCommand 结构体的数据缓冲区。

GLSL 剔除与间接指令生成

#version 450
layout(local_size_x = 64) in;

struct DrawIndexedIndirectCommand {
    uint indexCount;
    uint instanceCount;
    uint firstIndex;
    int  vertexOffset;
    uint firstInstance;
};

struct InstanceData {
    mat4 modelMatrix;
    vec3 bboxMin;
    vec3 bboxMax;
    uint indexCount;
    uint firstIndex;
    int  vertexOffset;
};

// 所有的输入实例数据
layout(binding = 0, std430) readonly buffer InstanceBuffer {
    InstanceData instances[];
};

// GPU 自动生成的间接绘制指令缓存
layout(binding = 1, std430) writeonly buffer IndirectDrawBuffer {
    DrawIndexedIndirectCommand drawCommands[];
};

// 用于原子计数,动态累加可见实例的索引
layout(binding = 2) buffer DrawCounter {
    uint globalDrawCount;
};

layout(binding = 3) uniform sampler2D HiZTexture;

layout(push_constant) uniform Uniforms {
    mat4 viewProjMatrix;
    vec4 cameraPos;
    float viewWidth;
    float viewHeight;
} ubo;

// 将 AABB 变换到裁剪空间并执行 Hi-Z 测试
bool IsVisible(InstanceData instance) {
    // 1. 视锥体剔除 (Frustum Culling)
    // 篇幅原因省略标准视锥体测试,假设已通过,下面进入 Hi-Z 剔除核心逻辑
    
    // 计算 AABB 在投影空间下的 8 个顶点
    vec3 minBounds = instance.bboxMin;
    vec3 maxBounds = instance.bboxMax;
    vec3 corners[8] = vec3[](
        vec3(minBounds.x, minBounds.y, minBounds.z),
        vec3(maxBounds.x, minBounds.y, minBounds.z),
        vec3(minBounds.x, maxBounds.y, minBounds.z),
        vec3(maxBounds.x, maxBounds.y, minBounds.z),
        vec3(minBounds.x, minBounds.y, maxBounds.z),
        vec3(maxBounds.x, minBounds.y, maxBounds.z),
        vec3(minBounds.x, maxBounds.y, maxBounds.z),
        vec3(maxBounds.x, maxBounds.y, maxBounds.z)
    );

    vec4 clipMin = vec4(1.0, 1.0, 1.0, 1.0);
    vec4 clipMax = vec4(-1.0, -1.0, -1.0, -1.0);
    
    for(int i = 0; i < 8; ++i) {
        vec4 projected = ubo.viewProjMatrix * instance.modelMatrix * vec4(corners[i], 1.0);
        vec3 ndc = projected.xyz / projected.w; // 转换为 NDC 空间
        
        // 映射到 [0, 1] 的 UV 坐标空间
        ndc.xy = ndc.xy * 0.5 + 0.5;
        // Reversed-Z 的深度本身就在 [0, 1] 内
        
        clipMin.xyz = min(clipMin.xyz, ndc);
        clipMax.xyz = max(clipMax.xyz, ndc);
    }

    // 2. 计算包围盒在屏幕空间的像素尺寸,决定读取哪一级 Hi-Z Mipmap
    float width = (clipMax.x - clipMin.x) * ubo.viewWidth;
    float height = (clipMax.y - clipMin.y) * ubo.viewHeight;
    float maxDimension = max(width, height);
    
    // 计算 Mip Level: log2(maxDimension)
    float mip = ceil(log2(maxDimension));
    float hizMipLevels = float(textureQueryLevels(HiZTexture));
    mip = clamp(mip, 0.0, hizMipLevels - 1.0);

    // 3. 对包围盒在屏幕投影区域的 4 个角进行深度采样
    vec2 uv0 = clipMin.xy;
    vec2 uv1 = clipMax.xy;
    
    // 取 4 个角在对应 Mip Level 下的 Z 值(Reversed-Z 逻辑下取最大值,表示场景中最靠近相机的物理遮挡)
    float h0 = textureLod(HiZTexture, vec2(uv0.x, uv0.y), mip).r;
    float h1 = textureLod(HiZTexture, vec2(uv1.x, uv0.y), mip).r;
    float h2 = textureLod(HiZTexture, vec2(uv0.x, uv1.y), mip).r;
    float h3 = textureLod(HiZTexture, vec2(uv1.x, uv1.y), mip).r;
    
    float maxHiZDepth = max(max(h0, h1), max(h2, h3));

    // 4. 深度对比
    // 在 Reversed-Z 下,如果物体的最大深度(最靠近相机的点 clipMin.z)比 Hi-Z 中的最大深度还要小,
    // 说明该物体完全被挡在遮挡物后面,返回 false 剔除
    if (clipMin.z < maxHiZDepth) {
        return false; 
    }

    return true;
}

void main() {
    uint gIdx = gl_GlobalInvocationID.x;
    // 假设绑定了总物体数量的常数
    if (gIdx >= instances.length()) return;

    InstanceData instance = instances[gIdx];

    if (IsVisible(instance)) {
        // 分配可见物体的绘制位置
        uint drawIndex = atomicAdd(globalDrawCount, 1);

        // 填充 VkDrawIndexedIndirectCommand
        drawCommands[drawIndex].indexCount    = instance.indexCount;
        drawCommands[drawIndex].instanceCount = 1; // 逐实例动态合并
        drawCommands[drawIndex].firstIndex    = instance.firstIndex;
        drawCommands[drawIndex].vertexOffset  = instance.vertexOffset;
        drawCommands[drawIndex].firstInstance = gIdx; // 将实例 ID 传给顶点着色器做重定位
    }
}

4. Vulkan 端的关键实现细节

编写完Shader,在Vulkan API端我们需要精准配置缓冲区和同步机制,否则GPU会产生未定义行为。

Buffer 创建与标记

用于存储间接命令的 IndirectDrawBuffer 必须同时具备 STORAGE_BUFFERINDIRECT_BUFFER 的属性。

VkBufferCreateInfo bufferInfo{};
bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
bufferInfo.size = sizeof(VkDrawIndexedIndirectCommand) * maxInstances;
bufferInfo.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT;
bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

vkCreateBuffer(device, &bufferInfo, nullptr, &indirectDrawBuffer);

关键:同步屏障 (Synchronization)

这是许多开发者最容易翻车的地方。Compute Shader 写入了 indirectDrawBuffer,而接下来的绘制指令 vkCmdDrawIndexedIndirect 需要读取这个 Buffer。

我们必须在 DispatchDraw 之间插入一个管线屏障(Pipeline Barrier),确保写入彻底完成:

VkBufferMemoryBarrier barrier{};
barrier.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER;
barrier.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; // Compute Shader 写入
barrier.dstAccessMask = VK_ACCESS_INDIRECT_COMMAND_READ_BIT; // Indirect 命令读取
barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
barrier.buffer = indirectDrawBuffer;
barrier.offset = 0;
barrier.size = VK_WHOLE_SIZE;

vkCmdPipelineBarrier(
    commandBuffer,
    VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,   // 源阶段
    VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT,    // 目标阶段
    0,
    0, nullptr,
    1, &barrier,
    0, nullptr
);

绘制提交

当屏障建立后,使用 vkCmdDrawIndexedIndirect 进行绘制。这里的 drawCount 如果是固定的,可以直接传入;如果是动态生成的,可以配合 vkCmdDrawIndexedIndirectCount(需要启用 VK_KHR_draw_indirect_count 扩展)来实现完全由GPU计数驱动的动态绘制。

// 绑定材质、管线、顶点/索引缓冲...
vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, renderingPipeline);

// 如果不支持 count 扩展,也可以把 drawCount 设为一个安全的最大值,
// 但利用 counter buffer 在 Shader 中使未通过测试物体的 instanceCount = 0。
vkCmdDrawIndexedIndirect(
    commandBuffer,
    indirectDrawBuffer,
    0,                  // 偏移量
    maxInstances,       // 最大绘制数
    sizeof(VkDrawIndexedIndirectCommand)
);

5. 常见痛点与高级优化

  1. 亚像素抖动与保守光栅化(Conservative Rasterization)
    当物体投影到屏幕上非常小(比如不足一个像素)时,常规的光栅化可能会漏掉它。如果你用普通渲染去生成Hi-Z,可能会导致后面的大物体被错误地剔除。因此,在深度预通(Depth Prepass)时,开启保守光栅化(Vulkan中通过 VK_EXT_conservative_rasterization 启用)可以确保深度缓冲是保守且正确的。

  2. 多线程/多队列异步计算(Async Compute)
    Hi-Z 生成和 Culling Compute Shader 可以放在 Vulkan 的 **Async Compute Queue(异步计算队列)**中执行。通过将计算任务和上一帧的图形渲染任务重叠(Overlap),能够进一步压榨GPU,提升大约 10%~15% 的帧率。

  3. 包围盒膨胀 (Bounding Box Inflation)
    由于物体动画或相机微小抖动,投影后的 AABB 可能会产生轻微漂移。在 Compute Shader 测试前,将包围盒在世界空间或视空间微微向外膨胀(Inflation)约 1%~2%,可以极大地避免由于精度导致的画面边缘“物体闪烁/漏光”问题。

总结

基于 GPU Driven Rendering 的 Hi-Z 遮挡剔除是现代 3D 渲染引擎(如 Unreal Engine 5 的 Nanite)的底层基石。通过在 Vulkan 中合理运用 Compute Shader、Storage Buffer 与 Indirect Draw,我们把原本繁重的剔除计算从 CPU 彻底解放,交由高并发的 GPU 处理,极大降低了 Draw Call 提交的开销,为千亿级多边形的场景渲染铺平了道路。

渲染架构师 VulkanGPU Driven遮挡剔除

评论点评