拒绝CPU瓶颈:在Vulkan中实现现代GPU Driven的Hi-Z遮挡剔除
在传统的渲染管线中,遮挡剔除(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(双路)剔除策略:
- First Pass:使用上一帧的Hi-Z金字塔,剔除当前帧的所有物体。将通过测试的物体立刻绘制,同时输出一个新的深度图。
- 构建新Hi-Z:基于这个新的深度图,构建当前帧的Hi-Z金字塔。
- 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_BUFFER 和 INDIRECT_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。
我们必须在 Dispatch 和 Draw 之间插入一个管线屏障(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. 常见痛点与高级优化
亚像素抖动与保守光栅化(Conservative Rasterization)
当物体投影到屏幕上非常小(比如不足一个像素)时,常规的光栅化可能会漏掉它。如果你用普通渲染去生成Hi-Z,可能会导致后面的大物体被错误地剔除。因此,在深度预通(Depth Prepass)时,开启保守光栅化(Vulkan中通过VK_EXT_conservative_rasterization启用)可以确保深度缓冲是保守且正确的。多线程/多队列异步计算(Async Compute)
Hi-Z 生成和 Culling Compute Shader 可以放在 Vulkan 的 **Async Compute Queue(异步计算队列)**中执行。通过将计算任务和上一帧的图形渲染任务重叠(Overlap),能够进一步压榨GPU,提升大约 10%~15% 的帧率。包围盒膨胀 (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 提交的开销,为千亿级多边形的场景渲染铺平了道路。