1. 场景管理的性能瓶颈到底卡在哪
做过3D项目的人大概都有过这种体验:场景里物件一多,帧率就开始往下掉,打开性能分析工具一看,渲染线程的耗时高得离谱。很多人第一反应是模型面数太高、材质太复杂,于是拼命做减面、换低模,结果发现帧率提升有限。问题往往不在单个模型上,而在于渲染管线里塞进了太多根本看不见的东西。
遮挡剔除(Occlusion Culling)要解决的就是这件事。它的核心思路非常朴素:如果一个物体被前面的墙完全挡住了,那它就不该被送去渲染。听起来像是废话,但真要在运行时高效地判断"谁挡住了谁",里面牵扯到的空间划分、可见性判定、硬件查询机制,足够写好几篇文章。这一篇是场景管理系列的第四部分,前面聊过空间划分、层级组织、剔除基础,这次专门把遮挡剔除这一块掰开揉碎讲清楚。
这篇文章适合谁看?如果你正在做中大型3D场景、开放世界、室内漫游、或者任何同屏物件数量上千的项目,并且已经感受到Draw Call和三角形数量带来的压力,那这篇内容应该能帮到你。我会从原理讲到实操,从离线烘焙讲到运行时查询,尽量把每个参数背后的"为什么"说清楚,而不是只丢一堆API让你自己猜。
需要提前说明的是,遮挡剔除不是银弹。它有明确的适用场景,也有明显的代价。用错了地方,可能性能不升反降。所以我会花不少篇幅讲清楚什么时候该用、什么时候不该用,这比单纯教你怎么开这个功能更重要。
2. 遮挡剔除的核心原理与方案选型
2.1 从"视锥剔除"到"遮挡剔除"的认知升级
很多人第一次接触剔除概念,学的是视锥剔除(Frustum Culling)。它的逻辑很简单:摄像机有个视野范围,视野外的物体直接不渲染。这个判断非常廉价,一个物体的包围盒和六个视锥平面做测试,几个点积运算就搞定了。
但视锥剔除有个致命盲区。想象你站在一条走廊里,走廊尽头有一扇门,门后面是一整个大厅,大厅里有几百个物件。这些物件全都在你的视锥范围内,视锥剔除一个都剔不掉,可实际上它们被那面墙挡得严严实实,你一个像素都看不到。这就是遮挡剔除要补的位。
两者的关系不是替代,而是叠加。视锥剔除是第一道粗筛,遮挡剔除是第二道精筛。顺序不能反,因为遮挡剔除的判定成本远高于视锥剔除,先用便宜的筛掉大部分,再用贵的处理剩下的,这个思路在性能优化里是通用的。
2.2 主流方案对比:离线烘焙 vs 运行时查询
遮挡剔除的实现路线大致分两派,我在不同项目里都用过,各有各的脾气。
| 方案类型 | 代表机制 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 离线烘焙 | 预计算可见性数据 | 运行时开销极低 | 烘焙耗时、动态物体失效 | 静态场景、室内 |
| 硬件查询 | GPU遮挡查询 | 支持动态物体 | 有延迟、需回读 | 动态场景、室外 |
| 软件光栅 | CPU端深度测试 | 无GPU依赖 | CPU占用高 | 小规模场景 |
| 层级Z缓冲 | 深度金字塔 | 精度可调 | 实现复杂 | 大规模室外 |
离线烘焙的思路是:在编辑器里就把场景切成很多小格子,对每个格子预先算好"站在这里能看到哪些物体",把结果存成一张可见性表。运行时只要知道摄像机在哪个格子,直接查表就行,几乎零开销。代价是场景一旦改动就得重新烘焙,而且动态物体没法处理。
硬件遮挡查询则是另一条路。它利用GPU的深度测试能力,先渲染一些大的遮挡物(比如墙壁)到深度缓冲,然后对每个待测物体发起一个查询:"如果我现在渲染你,会有像素通过深度测试吗?"如果没有,说明被挡住了。这个查询是异步的,结果要等一两帧才能拿到,所以会有延迟问题。
实际项目里,我见过最多的做法是两者结合:静态大物件用烘焙数据,动态物件用硬件查询兜底。这样既保证了静态部分的效率,又不至于让会动的角色穿帮。
2.3 为什么"遮挡物"的选择比"被遮挡物"更关键
这是很多新手会忽略的点。遮挡剔除的效果好坏,很大程度上取决于你选了哪些物体当"遮挡物"(Occluder)。遮挡物是那些用来挡住别人的物体,被遮挡物(Occludee)是可能被剔掉的物体。
一个理想的遮挡物应该满足几个条件:它本身足够大,能挡住后面一大片;它的形状相对简单,渲染到深度缓冲的成本低;它最好是静态的,这样烘焙数据才有效。墙、地板、天花板、大型建筑体块,这些都是天然的遮挡物。而那些小物件、透明物体、植被,通常不适合当遮挡物。
我踩过的一个坑是:早期图省事,把所有物体都标记成遮挡物,结果运行时为了生成深度信息,反而多渲染了一堆东西,性能比不开遮挡剔除还差。后来学乖了,只挑那些真正起遮挡作用的大块头,效果立竿见影。
提示:遮挡物的数量要克制。每多一个遮挡物,就多一份深度渲染成本。经验值是场景中遮挡物占比控制在5%到15%之间比较合理,具体要看场景结构。
3. 离线烘焙的实操细节与参数调优
3.1 烘焙前的场景准备:哪些该标记,哪些该排除
动手烘焙之前,先把场景过一遍,这一步偷懒后面全是坑。
首先要做的是清理场景。把那些永远不会被看到的物体删掉,比如埋在地下的管道、封在墙里的结构。这些东西留着只会增加烘焙时间和数据体积,运行时也占内存。
然后是标记遮挡物。在主流引擎里,通常有个Occluder Static或者类似的标记。我的习惯是只给以下几类物体打标:建筑外墙、室内隔断、大型家具、地形起伏。小到桌椅板凳、装饰摆件,一律不标。透明物体绝对不能标,因为透明物体不写深度,标了也没用,反而可能出错。
被遮挡物这边,标记Occludee Static。这里有个细节:不是所有静态物体都适合当被遮挡物。如果一个物体本身就在场景边缘,很少被挡住,标记它反而增加查询开销。我一般只标记那些"大概率会被挡住"的物体,比如房间内部的陈设。
3.2 单元格尺寸怎么定:精度与内存的博弈
离线烘焙会把场景空间切成一个个单元格(Cell),每个单元格存一份可见性数据。单元格尺寸是这里最关键的参数,直接决定精度和内存占用。
单元格越小,可见性判断越精确,但数据量呈立方级增长。假设场景是100米见方、10米高,单元格边长1米,那就是100×100×10等于十万个单元格。每个单元格存一份可见物体列表,内存直接爆炸。单元格边长改成5米,数量降到8000个,内存就合理多了。
那怎么定这个值?我的经验是看场景的空间尺度和遮挡复杂度。室内场景,房间通常几米到十几米,单元格边长取1到2米比较合适,能区分出不同房间的可见性差异。室外大场景,单元格边长可以放宽到5到10米,因为室外遮挡关系本来就比较松散。
还有一个技巧:自适应单元格。有些引擎支持根据场景复杂度自动调整单元格大小,在遮挡密集的区域用小格子,在开阔区域用大格子。这个功能如果有,尽量开,能省不少内存。
3.3 烘焙参数详解:最小遮挡物尺寸与背面阈值
除了单元格尺寸,还有两个参数经常被忽略,但它们对结果影响很大。
最小遮挡物尺寸(Minimum Occluder Size)决定了多小的物体才有资格当遮挡物。设得太小,一堆小物件都来当遮挡物,深度渲染成本飙升;设得太大,一些本该起遮挡作用的物体被排除,剔除效果打折。我一般从场景中位数物体尺寸的2到3倍开始试,然后根据烘焙结果微调。
背面阈值(Backface Threshold)是另一个容易踩坑的地方。烘焙时,引擎会从单元格的多个方向发射射线来测试可见性。如果某个物体只有背面朝向单元格,它算不算可见?这个阈值就是控制这个的。设得太严格,一些薄墙可能因为法线朝向问题被误判为不可见,导致穿帮;设得太宽松,又会让剔除效果变差。默认值通常能用,但如果场景里有大量单面墙体,需要手动调低这个值。
3.4 烘焙耗时优化:分块烘焙与增量更新
大场景一次烘焙动辄几十分钟甚至几个小时,这个时间成本很劝退。有几个办法可以缓解。
分块烘焙是首选。把场景按区域切成几块,每次只烘焙改动的那一块。引擎通常支持这种局部烘焙,前提是你在场景组织上就做好了分区。我习惯按楼层或者按功能区域来分,这样改动时影响范围可控。
增量更新是另一个思路。如果只是移动了一个小物件,没必要全量重烘。有些工具链支持只更新受影响的单元格,这个功能能省大量时间。不过要注意,增量更新有时会引入数据不一致,烘焙完最好抽查几个关键位置。
注意:烘焙数据是跟场景版本绑定的。如果场景结构大改,比如拆了一面墙,一定要重新烘焙,否则会出现"明明墙没了,后面的东西还是不渲染"的诡异现象。这个坑我见过不止一个团队踩。
4. 运行时遮挡查询的实现与延迟处理
4.1 硬件遮挡查询的工作流程
硬件遮挡查询的流程可以拆成几步。第一步,渲染遮挡物到深度缓冲,这一步只写深度不写颜色,成本相对低。第二步,对每个待测物体发起一个查询,查询的内容是"这个物体的包围盒如果渲染,有多少像素能通过深度测试"。第三步,等一两帧后回读查询结果,如果通过像素数为零,说明被完全挡住,可以剔除。
这个流程里最麻烦的是延迟。查询结果不是立刻能拿到的,因为GPU和CPU是异步工作的。如果这一帧发起查询,这一帧就等结果,那CPU会空转,反而更慢。所以标准做法是:这一帧发起查询,用上一帧的结果来决定这一帧剔不剔。
延迟带来的问题是穿帮。想象摄像机快速旋转,一个物体上一帧还被挡住,这一帧已经露出来了,但因为你用的是上一帧的结果,它这一帧还是被剔掉,于是你会看到物体"闪现"出来。这个现象在快速移动时特别明显。
4.2 延迟补偿:保守剔除与多帧验证
解决延迟穿帮,有几个常用手段。
保守剔除是最简单的:只有当物体连续N帧都被判定为不可见时,才真正剔除它。N取2到3通常够用。这样即使有一帧判断滞后,也不会立刻穿帮。代价是剔除响应变慢,但对于大多数场景,这个延迟感知不到。
扩大包围盒是另一个技巧。发起查询时,不用物体的精确包围盒,而是稍微放大一点。这样即使摄像机移动导致物体边缘露出来,放大的包围盒也能覆盖到,减少误剔。放大的比例看场景,一般10%到20%。
多帧验证更严格一些:对同一个物体发起多次查询,只有全部返回不可见才剔除。这个成本高,一般只用在特别重要的物体上,比如主角附近的物件。
4.3 查询批次管理:别让查询本身成为瓶颈
每个遮挡查询都是一次GPU-CPU交互,次数多了,光是管理这些查询的开销就很可观。所以批处理很重要。
我的做法是把待测物体按空间位置分组,同一组的物体合并成一个查询。比如一个房间里所有物件,用一个查询覆盖它们的整体包围盒。如果整体被挡住,整个房间的物件一起剔掉。这样查询次数从几百次降到几十次,开销大幅下降。
还有一个细节是查询优先级。不是所有物体都值得查询。那些本来就很小的物体,即使不剔也占不了多少渲染成本,给它们发查询反而浪费。我一般只对屏幕投影面积超过某个阈值的物体发查询,小物件直接放行。
4.4 与烘焙数据的协同:分层剔除策略
前面说过,烘焙和硬件查询可以结合。具体怎么结合?我的策略是分三层。
第一层,视锥剔除,最便宜,先筛掉视野外的。第二层,烘焙数据查询,对静态物体用预计算的可见性表,几乎零成本。第三层,硬件查询,只对动态物体和烘焙覆盖不到的物体使用。
这个分层策略的关键是判断物体属于哪一层。静态且被烘焙覆盖的,走第二层;动态的,走第三层;两者都沾边的,比如一个静态物体被移动了,那就临时降级到第三层。这套逻辑实现起来不复杂,但效果比单用任何一种方案都好。
5. 常见问题排查与避坑经验实录
5.1 物体闪烁与穿帮:从现象反推原因
遮挡剔除最让人头疼的问题就是闪烁。物体一会儿出现一会儿消失,或者该出现的时候不出现。这个现象背后通常有几个原因,可以按下面的表来排查。
| 现象 | 可能原因 | 排查方法 | 解决手段 |
|---|---|---|---|
| 快速旋转时物体闪现 | 查询延迟 | 录屏逐帧看 | 增加保守剔除帧数 |
| 物体边缘被切掉 | 包围盒过小 | 显示包围盒 | 扩大包围盒10%-20% |
| 整片区域突然消失 | 烘焙数据过期 | 对比场景版本 | 重新烘焙 |
| 透明物体异常消失 | 透明物被标为遮挡物 | 检查标记 | 取消透明物遮挡标记 |
| 动态物体穿墙可见 | 未走硬件查询 | 检查分层逻辑 | 动态物体强制走查询 |
我遇到最多的是第一种和第二种。第一种在摄像机快速平移时特别明显,解决办法就是前面说的保守剔除。第二种往往是因为包围盒没更新,比如物体做了缩放动画,包围盒还是旧的,这时候要么每帧更新包围盒,要么把包围盒放大到能覆盖最大缩放状态。
5.2 性能不升反降:什么时候该关掉遮挡剔除
这是个反直觉但很重要的话题。遮挡剔除不是免费的,它有固定开销:深度渲染、查询管理、数据查询。如果场景本身遮挡关系很弱,比如一片开阔的草原,物件之间互相挡不住,那这些开销就是纯浪费。
判断标准很简单:看剔除率。如果开启遮挡剔除后,被剔掉的物体占比低于20%,那基本可以判定不划算。我一般会在性能分析工具里看两个数:剔除前后渲染的三角形数量对比,以及剔除系统自身的耗时。如果省下的渲染时间抵不过剔除开销,果断关掉。
另一个该关掉的场景是物件数量本来就少。同屏物件不到一两百个,视锥剔除已经够用,加遮挡剔除纯属画蛇添足。遮挡剔除的收益是随物件数量增长而增长的,规模不够时没必要上。
5.3 动态场景的特殊处理:可破坏物与角色
动态场景是遮挡剔除的难点。可破坏的墙体、会移动的平台、大量角色,这些都没法用烘焙数据。
对可破坏物,我的做法是破坏后重新标记。墙没破之前是遮挡物,破了之后立刻取消标记,同时把后面的物体从烘焙数据里"释放"出来。这个逻辑需要在破坏事件里挂钩子,稍微麻烦但必须做,否则墙破了后面还是黑的。
对角色,一般走硬件查询。但角色数量多的时候,查询开销也大。一个优化是只对主角附近的角色做查询,远处的角色用简化的距离剔除代替。玩家注意力有限,远处的角色即使穿帮也不容易察觉。
5.4 移动端与低端设备的降级方案
移动端GPU的遮挡查询支持参差不齐,有些设备根本不支持硬件查询,或者支持但性能很差。这种情况下得有降级方案。
最简单的降级是只用烘焙数据,动态物体不做遮挡剔除。虽然动态物体可能穿帮,但至少静态部分能省下不少。另一个方案是降低查询频率,比如每三帧才查询一次,用更长的延迟换更低的开销。
还有一个思路是预判剔除。根据摄像机的移动方向,提前把即将进入视野的物体标记为可见,把即将离开的标记为不可见。这个方案不需要硬件查询,纯CPU逻辑,适合低端设备。精度差一些,但胜在稳定。
6. 一套可复用的遮挡剔除落地流程
把前面这些串起来,我给一套实际项目里用过的落地流程,你可以直接参考。
第一步,场景审计。统计场景物件数量、静态动态比例、遮挡关系密度。这一步决定了后面要不要上遮挡剔除,以及上哪种方案。
第二步,标记与分区。标记遮挡物和被遮挡物,按空间划分烘焙区域。这一步的细致程度直接决定烘焙质量。
第三步,烘焙与验证。先小范围烘焙测试,确认参数合理后再全量烘焙。烘焙完在场景里走一圈,重点看边缘区域和快速移动时的表现。
第四步,运行时接入。实现分层剔除逻辑,静态走烘焙,动态走查询,配置保守剔除帧数和包围盒放大比例。
第五步,性能验证与调优。用性能分析工具对比开关遮挡剔除的差异,看剔除率和系统开销。如果收益不明显,回到第一步重新评估。
第六步,持续维护。场景改动后及时重新烘焙,动态物体的标记随状态更新。这一步最容易被忽略,但恰恰是长期稳定的关键。
这套流程我在几个不同规模的项目里跑过,中小场景大概两三天能落地,大场景需要一周左右。时间主要花在烘焙和调优上,代码实现反而不复杂。
最后分享一个我个人的小习惯:每次改完遮挡剔除配置,我都会在场景里做一次"极限测试"——把摄像机贴着墙快速旋转,同时观察有没有穿帮。这个测试能暴露大部分延迟和包围盒问题,比正常游玩更容易发现问题。踩过几次坑之后,这个习惯帮我省了不少返工时间。