如果说 LOD 是为了解决“太远看不清”的问题,那么遮挡剔除(Occlusion Culling)就是为了解决“被挡住看不见”的问题。在复杂场景中(如城市、森林),摄像机视锥体(Frustum)内的物体可能只有 10% 是真正可见的,其余 90% 都被前面的物体遮挡。
如果不剔除这些物体,GPU 依然会执行顶点着色器(VS),并在光栅化阶段进行昂贵的深度测试(Z-Test)。对于 Cluster 架构而言,无效的计算是不可容忍的。
常见的遮挡剔除方法及其局限性
在vk_lod_clusters这种现代方案出现之前,业界主要使用以下几种方法,它们各有痛点:
1. 预计算可见性 (PVS - Potentially Visible Sets)
原理:在开发阶段将场景划分为格子(Cells),预先计算好“在格子 A 能看到哪些物体”,并将列表烘焙到数据中。经典游戏如《Quake》和《CS 1.6》大量使用。
局限:
仅限静态场景:墙壁塌了、门开了,PVS 就失效了。
存储压力:开放世界的 PVS 数据量巨大。
构建时间长:每次修改地图都要重新烘焙几个小时。
2. 硬件遮挡查询 (Hardware Occlusion Queries)
原理:CPU 告诉 GPU:“帮我画个不可见的包围盒,告诉我画了几个像素?”。如果 GPU 返回 0,说明物体被遮挡,CPU 就不提交该物体的绘制命令。
局限:
CPU-GPU 延迟(Latency):CPU 必须等待 GPU 返回结果才能决定是否绘制。这会打断流水线,产生“气泡(Bubbles)”。
“上一帧”策略的闪烁:为了不等待,常使用上一帧的结果,但这会导致新出现的物体闪烁(Pop-in)。
3. 软件遮挡剔除 (Software Occlusion Culling)
原理:在 CPU 端手写一个简易光栅化器(如 Intel 提出的方案),将粗糙的遮挡物绘制到 CPU 内存的深度缓冲中,然后直接在 CPU 端判断可见性。
局限:
CPU 负载高:现在的游戏 CPU 本来就忙着处理物理和 AI,还要分摊光栅化任务,容易成为瓶颈。
vk_lod_clusters 的核武器:GPU-Driven Hi-Z Culling
vk_lod_clusters(以及 Nanite)采用的是目前最先进的GPU 驱动的两阶段剔除(Two-Phase Occlusion Culling),配合Hi-Z(Hierarchical Z-Buffer)技术。
这种方法完全运行在 GPU 的 Compute Shader 上,无需 CPU 介入,且完美解决了“延迟”和“闪烁”问题。
2.1 什么是 Hi-Z (Hierarchical Z-Buffer)?
普通的深度缓冲(Z-Buffer)是一张全分辨率的纹理。Hi-Z 是一个深度纹理的金字塔(Mipmap Chain)。
Level 0:原始深度图。
Level 1:将 Level 0 每
个像素取最远深度(Max Depth)(对于反向 Z-Buffer 则是取 Min)。
Level N:以此类推,直到
。
核心优势:
当我们要判断一个 Cluster 的包围盒是否被遮挡时,不需要遍历全屏幕的深度像素。我们只需要根据包围盒在屏幕上的大小,选择一个合适的 Mipmap 层级,使得包围盒在该层级只覆盖约 4 个像素。读取这 4 个值,就能知道该区域的“最保守遮挡深度”。
公式逻辑:
如果(假设 0 是近,1 是远),说明 Cluster 比前面所有的遮挡物都要远,即被遮挡。
2.2 两阶段剔除算法 (The Two-Phase Algorithm)
这是vk_lod_clusters避免物体闪烁的精髓。因为我们在渲染当前帧时,还没有当前帧的深度图(还没画呢),所以必须分两步走:
第一阶段:主剔除 (Main Pass / Occlusion Pass 1)
输入:上一帧生成的 Hi-Z Buffer(经过重投影 Reprojection 处理,以适应相机移动)。
执行:Compute Shader 并行检查所有 Cluster 的包围盒。
判断:
如果 Cluster 被上一帧的 Hi-Z 遮挡 $\rightarrow$标记为不可见(暂时不画)。
如果 Cluster 可见 $\rightarrow$立即加入绘制列表,并标记为“Pass 1 已绘制”。
渲染:GPU 绘制这些通过测试的 Cluster,生成当前帧的 Depth Buffer。
构建 Hi-Z:基于当前帧的 Depth Buffer,生成当前帧的 Hi-Z。
问题:上一帧被遮挡的物体,这一帧可能因为遮挡物移开而变得可见。但在 Pass 1 中它被错误地剔除了(False Negative)。这会导致物体消失或闪烁。
第二阶段:修正剔除 (Post Pass / Correction Pass 2)
输入:当前帧刚刚生成的最新 Hi-Z Buffer。
执行:再次检查在 Pass 1 中被判定为不可见的那些 Cluster。
判断:
利用最新的 Hi-Z 再次测试。
如果这次测试发现它其实是可见的(即刚才 Pass 1 误判了) $\rightarrow$加入补充绘制列表。
补画:绘制这些漏网之鱼。
2.3 为什么这适合 Cluster?
这种两阶段剔除在传统的 Object 级别渲染中开销很大(因为 Draw Call 多)。但在 Cluster 架构下:
粒度完美:Cluster 的包围盒很小,遮挡测试非常精准。
并行度高:几百万个 Cluster 的遮挡测试可以在 Compute Shader 中瞬间完成。
零 CPU 开销:整个过程(剔除 $\rightarrow$ 生成 Indirect Draw Arguments $\rightarrow$ 绘制)全在 GPU 上通过 Buffer 传递,CPU 甚至不知道哪些 Cluster 被画出来了。
代码实现的逻辑流 (伪代码)
为了让读者更直观地理解,我们可以将其逻辑抽象为以下 Compute Shader 伪代码:
// Compute Shader: process_clusters.comp void main() { uint clusterID = gl_GlobalInvocationID.x; BoundingBox bounds = GetClusterBounds(clusterID); // 1. 视锥剔除 (Frustum Culling) if (!IsVisibleInFrustum(bounds)) return; // 2. 计算屏幕空间的包围盒大小 vec4 screenRect = ProjectToScreen(bounds); // 3. 选择 Hi-Z Mipmap 层级 // 确保包围盒在 Mipmap 上大约占 2x2 或 4x4 像素 float mipLevel = CalculateMipLevel(screenRect); // 4. 采样 Hi-Z 深度 // 这里的 sampler 是 "上一帧" (Pass 1) 或 "当前帧" (Pass 2) float occlusionDepth = textureLod(hiZTexture, screenRect.center, mipLevel).r; // 5. 深度比较 float clusterDepth = bounds.nearestDepth; bool isOccluded = (clusterDepth > occlusionDepth); // 假设远是大值 if (!isOccluded) { // 原子操作:将 Cluster ID 写入绘制缓冲 uint index = atomicAdd(drawCountBuffer, 1); visibleClusters[index] = clusterID; } }四、 总结
vk_lod_clusters使用的 Hi-Z 两阶段剔除方案,是目前实时渲染领域解决“过绘(Overdraw)”的终极方案之一。
它不再依赖美术手动放置遮挡板,不再依赖离线烘焙。它利用 Cluster 的微小粒度,像手术刀一样精准地剔除了每一块不可见的几何体,保证了无论场景多么复杂,显卡只渲染最终用户看得到的那一层像素。这就是为什么 Cluster 架构能支撑十亿级多边形的核心原因之一。