什么是遮挡剔除?
大多数图形算法告诉我们如何快速渲染物体或如何优化视觉效果。有些技术侧重性能,有些追求画质,而真正的挑战在于如何平衡二者。
遮挡剔除(Occlusion Culling) 通过不渲染视锥体外的几何体或被近处物体遮挡的几何体来提升渲染性能。两种常见的遮挡剔除技术是遮挡查询(Occlusion Query) 和 提前深度剔除(Early-Z Rejection)。
本章将探讨遮挡查询的正确使用方法,并通过两个示例验证其效率。由于其中一个示例高度依赖包围盒(Bounding Box),我们也会详细解释这一概念。
遮挡查询
从GeForce3 GPU开始,所有NVIDIA GPU(及多数其他厂商的GPU)均支持遮挡查询功能。通过跳过被遮挡物体的完整渲染流程,可显著降低GPU的渲染负载。
提前深度剔除
自GeForce3起,大多数GPU也支持提前深度剔除(Early-Z Rejection)。在渲染的光栅化阶段,Early-Z将待渲染片元的深度值与当前深度缓冲区中的值对比。若片段不可见(深度测试失败),则无需执行纹理采样和片段着色程序,从而节省内存带宽。
关键差异
- 层级不同:遮挡查询在几何层级剔除物体,而Early-Z在光栅化阶段处理片段。
- 控制权:遮挡剔除需要应用层主动控制,而Early-Z由GPU自动处理。
- 排序要求:
两者均需将物体按从前到后排序。但使用模板缓冲区可能影响Early-Z性能。
遮挡查询的工作原理
OpenGL通过ARB_occlusion_query扩展(基于NVIDIA的NV_occlusion_query)实现遮挡查询,DirectX 9也提供相同功能。其核心流程如下:
- 创建查询对象
- 禁用屏幕渲染(关闭颜色通道写入)
- 禁用深度写入(仅测试,不更新深度缓冲)
- 启动查询(重置可见像素计数器)
- 渲染物体的包围盒(仅深度测试,不实际渲染)
- 结束查询
- 启用屏幕渲染和深度写入
- 获取查询结果(可见像素数量)
- 若可见像素数>阈值,则渲染完整物体
通过用简单包围盒代替复杂几何体,可判断是否需要渲染物体本身。若包围盒被遮挡,则完全跳过物体渲染。
遮挡查询的陷阱
GPU管线停滞问题
通常CPU和GPU并行工作:CPU发送命令到GPU队列后即可处理其他任务。但遮挡查询要求CPU必须等待GPU完成所有前置渲染命令,导致性能瓶颈。
初级实现的问题
直接按“创建查询→渲染包围盒→获取结果”顺序操作会引发严重的管线停滞。即使大多数物体被剔除,性能反而可能下降。
正确使用遮挡查询
OpenGL和DirectX 9支持多查询并行处理,优化流程如下:
- 批量创建n个查询
- 禁用屏幕渲染和深度写入
- 对每个查询依次执行:
- 启动查询
- 渲染包围盒
- 结束查询
- 启用屏幕渲染和深度写入
- 延迟获取查询结果
- 若可见像素数>0,渲染完整物体
此方法通过异步处理减少CPU等待时间,但仍存在优化空间。
遮挡物与被遮挡物
基础实现的关键问题在于包围盒无法写入深度缓冲区,因为存在一种极端情况:一个包围盒可能完全遮挡另一个包围盒,但实际物体并未被遮挡(如图所示)。
因此,需要先渲染部分场景几何体作为遮挡物(Occluders),这些物体通常较大(如高墙或建筑)。随后再对其他小物体进行遮挡查询。然而,这会增加复杂度并降低剔除效率(甚至大物体自身也可能被遮挡)。为此,我们需要更优方案。
进阶方案
为确保GPU完成所有渲染任务,我们采用跨帧延迟查询策略:
游戏主循环流程(简化版)
创建查询对象
游戏循环:
处理游戏逻辑
处理物理
渲染:
检查上一帧的查询结果
启动新查询:
若物体上一帧可见:
启用颜色和深度写入
渲染物体本身
若物体上一帧不可见:
禁用颜色和深度写入
渲染包围盒
结束查询
交换缓冲区
关键设计
- 延迟验证:假设上一帧可见的物体在当前帧仍可见,减少查询频率。
- 动态优化:
- 对不可见物体每帧测试包围盒
- 对可见物体间隔多帧测试
- 剔除风险控制:
不可见物体仍需每帧测试,避免物体突然"闪现"。
视觉影响
- 帧延迟问题:物体可能在相邻帧间短暂不可见,但在30 FPS以上场景中难以察觉。
- 驱动兼容性:
某些驱动允许GPU延迟多帧渲染,可能引入约10%性能损耗,但仍在可接受范围。
自动分层机制
通过以下策略自动区分遮挡物与被遮挡物:
- 深度写入:所有可见物体通过写入深度缓冲区自然成为遮挡物。
- 物体排序:
- 不透明物体:从前到后排序,优先渲染。
- 透明物体:从后到前排序,最后渲染(仅作为被遮挡物)。
优势
- 无需手动指定遮挡物层级
- 不透明物体自动成为后续物体的遮挡物
包围盒过大的问题
当包围盒远大于实际物体时,可能导致:
- 包围盒可见但物体被遮挡
- 物体在帧间交替渲染(闪烁效应)
优化方案
- 动态调整测试频率
- 对近处的物体禁用测试(必然可见处理)
包围盒优化指南
静态物体
- 单包围盒:适用于紧凑物体(如图金字塔案例)。
- 多包围盒:对复杂几何体可分割测试,但需权衡性能开销。
动态物体
动态物体的处理更为复杂。如何计算动态物体的包围盒?这里给出几种方法:
1. 为每个动画的每一帧计算包围盒
这种方法会变得复杂且会消耗大量CPU资源,尤其是当物体的骨骼动画系统集成在GPU顶点着色器中时。通过CPU计算包围盒(需先将动画数据从GPU取回CPU)的开销可能远高于直接渲染物体本身。
2. 为每个动画的所有帧存储一个包围盒
这是最简单快速的方法,但结果可能不准确——包围盒可能远大于实际物体,导致物体实际上被遮挡时仍被误判为可见。
优化技巧:
通过"就地动画"(In-Place Animation)缩小包围盒。例如:
- 当角色被击飞时,动画本身不包含位移,而是在代码中处理实际位置变化。
- 如图所示,就地动画的包围盒(右)远小于传统逐帧包围盒(左)。
3. 为每个动画存储一个包围盒
这是最佳方案,但存在一个小问题:当混合多个动画时,需要取各动画包围盒的并集。不过由于单个动画的包围盒通常较小,这种计算代价也还可以接受。
推荐方案
结合方法2和3,根据物体特性选择最优解。对于需要多通道渲染的物体,可用首通道几何体(需简单)替代包围盒进行可见性测试,但仍需控制几何复杂度。
其他关键问题详解
CPU开销过高
问题本质
- 核心矛盾:向GPU发送渲染请求(如准备顶点数据、设置管线状态)的CPU开销可能超过实际渲染收益。
- 典型场景:
当物体几何网格结构简单(如300个三角形以下)时,渲染包围盒(约12个三角形)的CPU准备时间可能与直接渲染物体相当,导致性能提升有限。
优化策略
// 伪代码:CPU开销权衡逻辑
if 物体三角形数 < 300:
直接渲染物体
else:
执行遮挡查询测试
例外情况
- 复杂像素着色器:
即使几何简单,若物体覆盖屏幕大面积且使用复杂着色器(如大量纹理采样、光照计算),用包围盒进行遮挡剔除测试仍可节省GPU时间。 - 深度测试优化:
仅写入深度缓冲时,现代GPU(如GeForce FX系列)会启用高速渲染路径,性能显著优于完整渲染。
高分辨率渲染
风险点
- 填充率瓶颈:
在4K/8K等高分辨率下,渲染大包围盒可能比实际物体渲染消耗更多像素来填充资源。 - 包围盒过大的代价:
若包围盒尺寸远大于物体本身,深度测试所参与的像素数量也会激增,抵消遮挡查询的优化效果。
解决方案
- 禁用高分辨率测试:
对简单物体(无复杂片段程序)跳过遮挡查询。 - 近处物体豁免:
当物体距离相机过近时:- 必须渲染包围盒双面(避免背面剔除导致误判)
- 直接渲染物体(因其必然可见)
快速深度写入优化
硬件加速机制
- 深度专用管线:
现代GPU在关闭颜色写入时,将释放颜色混合单元资源,专注深度测试,有显著的性能提升。
视锥剔除的黄金法则
CPU vs GPU方案对比
方案 | 优点 | 缺点 |
---|---|---|
CPU视锥剔除 | 快速,直接剔除不可见面 | 需手动计算包围盒/球与视锥平面的相交检测 |
GPU遮挡查询 | 自动处理复杂遮挡关系 | 引入额外渲染调用和管线等待所带来的停滞风险 |
混合优化策略
- 初步球体测试:
- 快速计算物体包围球与视锥的位置关系。
- 若球体完全在视锥外,直接剔除。
- 细粒度包围盒测试:
- 对细长物体(如高柱体)进行精确包围盒测试(见图),检查该物体的包围盒是否某一维度显著大于其他两维。如果是这种情况,则包围球远大于物体本身!此时最好同时将包围盒与视锥体对比,因为它可能被剔除。
- 避免球体过大导致的误判。
实现代码示例
// 视锥剔除伪代码
bool isInFrustum(Bounding bounding) {
for (每个视锥平面) {
if (bounding在平面外) return false;
}
return true;
}
void cullObjects() {
foreach (物体 in 场景) {
if (!isInFrustum(物体.包围球)) continue;
if (细长物体 && !isInFrustum(细长物体.包围盒)) continue;
加入渲染队列;
}
}
关键总结
- 性能取舍:遮挡查询不是万能解,需结合场景复杂度、硬件特性动态选择。
- 分辨率敏感:4K+环境下需重新评估测试成本。
- 层级化剔除:CPU视锥剔除 → GPU遮挡查询 → Early-Z,形成三级优化流水线。
请记住,遮挡剔除在对象包含大量多边形时效果最佳。对于仅包含300个三角形的对象,即使屏幕上有数百个这样的对象,遮挡查询也难以产生显著效果。性能可能仅中等程度提升,但具体效果取决于CPU负载以及CPU与GPU的速度比。如果GPU速度很快而CPU较慢,应用程序会受到CPU性能限制,此时遮挡测试的帮助将非常有限。
另一方面,如果每个模型包含大量多边形(超过1000个三角形是一个不错的起点),遮挡剔除将展现其全部优势。性能提升可能超过两倍(当然具体取决于场景),而且几乎是零成本的。即使所有测试对象最终都可见,渲染速度也不会明显变慢!
应用案例:镜头光晕
遮挡查询技术的另一个实用应用是渲染镜头光晕(见下面两图)。镜头光晕是一种增强游戏真实感的绝佳效果,但尝试精确渲染它们时,很容易陷入大量问题。
问题在于,镜头光晕并不仅仅是光源周围的光晕效果:它们是相机镜头产生的效果,这意味着它们不能被场景中的物体部分遮挡。也就是说,镜头光晕要么完全可见,要么完全不可见,所以必须将镜头光晕叠加在已渲染的场景之上。
渲染镜头光晕本身并不复杂:你只需要渲染一个矩形,并用合适的镜头光晕发光或环形纹理填充即可。然而,问题出现在如何判断镜头光晕的可见性上。理论上,这似乎并不困难,因为“所有”你需要做的就是渲染整个场景,然后将每个持有镜头光晕的光源的深度值与深度缓冲区中存储的深度值进行比较。如果光源的深度值更接近观察者,则光晕可见。
不幸的是,读取深度缓冲区的值需要从深度缓冲区中读取——这对GPU来说并不友好。另一种选择是不读取深度缓冲区,而是从每个光源向观察者投射一条光线。如果光线能够到达观察者,则镜头光晕可见。即使这听起来相对简单,但当你需要通过带有alpha测试纹理的多边形(例如树的叶子)进行光线投射时,问题就会出现。由于alpha测试会在纹理的alpha通道值低于某个阈值(通常为0.5)的位置拒绝像素,因此纹理只会将深度缓冲区写入特定位置。要准确模拟这种效果,我们必须在CPU上进行完整的纹理映射,这是一项极其缓慢的操作。
那么,我们该如何高效地测试镜头光晕的可见性呢?
以传统方式渲染镜头光晕
在不使用遮挡查询的情况下,我们必须访问深度缓冲区以确定可见性。但读取深度缓冲区不可避免地会导致图形流水线停滞,因为GPU需要渲染队列中的所有多边形才能生成正确的深度缓冲区图像。更困难的是,Direct3D API本身并不允许直接读取深度缓冲区(尽管在某些罕见情况下可以实现,但在实际应用中完全不可用)。虽然有一种绕过这一限制的方法,但这种方法非常奇怪,且超出了本章的讨论范围。嗯——看来我们陷入了困境。
相比之下,OpenGL使得读取深度缓冲区变得简单,因此我们可以利用这一点来规避GPU停滞问题。我们可以将所有深度缓冲区的读取操作批处理到一帧的末尾,甚至在镜头光晕实际渲染之后。这会导致镜头光晕的可见性结果滞后一帧,但在实际场景中这是可以接受的。不幸的是,尽管这种优化带来了一些性能提升,但效果并不显著,我们还需要采取更激进的措施。
我们可以在调用SwapBuffers之后、下一帧渲染开始前检查深度缓冲区的值。这会为我们争取一些时间,因为GPU可能会在此期间完成上一帧的渲染,而CPU仍忙于处理当前帧的游戏逻辑和其他计算任务。因此,渲染一帧的算法大致如下:
- 执行游戏逻辑、物理模拟和碰撞检测。
- 处理其他非渲染任务。
- 读取镜头光晕的深度值(这些值来自上一帧)。
- 清除深度缓冲区。
- 执行渲染。
- 渲染镜头光晕。
- 交换缓冲区
尽管这种方法能减少大量GPU的阻塞,但仍存在一些问题。GPU阻塞的开销取决于GPU与CPU速度的比值。讽刺的是,这意味着在更快的GPU上,阻塞的成本反而更高。另一个更严重的问题是:没有人能保证SwapBuffers调用后深度缓冲区的内容仍然完整!在当今所有GPU上,深度缓冲区的内容确实会在SwapBuffers后保留,但你无法依赖这一点,因为未来可能会改变。更糟糕的是,多重采样抗锯齿(multisample antialiasing)在交换缓冲区后可能导致深度缓冲区内容出现不可预测的副作用。总之,这是一种高风险的方法,仅应作为最后手段使用。
总结一下:我们确实需要遮挡查询来解救这一困境!
以新方式渲染镜头光晕
你现在可能已经猜到我们要做什么了。我们可以利用遮挡查询功能,通过在镜头光晕光源的屏幕位置和对应深度“渲染”一个像素(启用深度测试,但禁用深度写入和颜色写入)来测试其可见性。随后,我们检查有多少像素通过了遮挡测试。如果有一个像素通过,则说明镜头光晕可见。为了最小化GPU阻塞的影响,我们在本帧中发出遮挡查询,并在下一帧中读取结果。
由于直接读取深度值的性能开销巨大,我们几乎没有空间进行更多实验,因此每帧读取多个像素显然不可行。但通过遮挡查询,我们可以精确判断镜头光晕的可见比例。与其仅检查一个像素来获得“是/否”的答案,不如渲染一个像素块(例如16x16),观察其中实际被渲染的像素占比。然后,我们可以用这个比例来调节镜头光晕的亮度。这样一来,我们就能实现更平滑的光晕效果,而无需依赖特殊渐显/渐隐逻辑来避免镜头光晕突然出现或消失。需要测试的像素数量可轻松根据镜头光晕在屏幕上的大小、光源与观察者的距离,或两者结合来确定。
这是一个非常适合实验的领域,但有一件事是确定的:结果将非常出色,且对GPU的性能影响微乎其微。
结论
乍一看,遮挡查询似乎是一个非常强大的工具——它的确如此。然而,与其他工具一样,有效使用它需要一定的技能和知识。你需要能够识别哪些场景下它能发挥作用。
在本章中,我们仅给出了两个高效使用该技术的例子。当然,你不必止步于此:还有许多其他用途等待探索。例如,遮挡查询可用于根据物体占用的像素数量来决定几何细节层次(LOD)。甚至可以将其集成到可见表面判定算法中。
与其他技术一样,遮挡查询也将不断演进。若需进一步优化,可结合空间分区和分层遮挡剔除(Hierarchical Z-Buffering),减少不必要的查询次数。起初,它可能仅用于简单的任务,但随着时间推移,程序员们会逐渐发现其令人惊叹的新应用,尤其是在各种限制被逐步解除之后。希望本章能为你提供一个良好的起点。现在,真正的探索就交给你了。