一句话:遮挡剔除就是在渲染前判断"哪些物体被墙挡住了",被挡住的直接跳过不画,从而减少 Draw Call,降低 CPU 和 GPU 的压力。
一、生活里的例子:站在客厅里
你站在自家客厅:
- 身后的东西看不到 → 不用画(这是视锥剔除)
- 卧室里的床在你前方,但被墙挡住了 → 也不用画(这是遮挡剔除)
- 卧室门开着,从门缝能看到一角 → 这时才需要画
如果不做遮挡剔除,GPU 会把整栋楼的家具都画一遍,再被墙盖住。画了,但白画了。
二、Unity 里的三道"过滤网"
每一帧,物体要经过几层筛选才会真正被绘制:
场景里所有物体(比如 5000 个) │ ▼ ① 视锥剔除(自动,总是开启) 不在摄像机视野范围内的 → 去掉 剩 2000 个 │ ▼ ② 遮挡剔除(需要手动设置和烘焙) 在视野内,但被墙挡住的 → 去掉 剩 300 个 │ ▼ ③ 提交渲染(合批、LOD、Draw Call) 真正画出来的 300 个| 剔除方式 | 判断依据 | 是否默认开启 |
|---|---|---|
| 视锥剔除 | 在不在摄像机的"视野锥体"里 | ✅ 自动 |
| 遮挡剔除 | 有没有被别的东西挡住 | ❌ 需要烘焙 |
| 距离剔除 | 离得太远就不画(camera.layerCullDistances) | ❌ 需要代码设置 |
三、Unity 内置遮挡剔除的核心思路:提前算好
Unity 内置方案使用第三方库Umbra,思路是:
在编辑器里提前把"从哪里能看到哪里"算好存起来(烘焙),游戏运行时直接查表。
为什么要提前算?因为每帧实时精确计算"谁挡住了谁"太贵;提前算好后,运行时只需要很少的 CPU 开销。
代价:只有不会移动的物体才能当"挡板"。
四、全流程:七个步骤
1. 标记物体 谁是挡板?谁能被挡? 2. 划定区域 摄像机会去哪些地方? 3. 设置门 哪些通道会开关? 4. 调整参数 格子多细? 5. 烘焙 生成可见性数据 6. 可视化检查 剔除得对不对? 7. 性能验证 真的变快了吗?第 1 步:标记物体(Static 标记)
选中物体,在 Inspector 右上角Static下拉菜单里勾选:
| 标记 | 意思 | 适合什么 |
|---|---|---|
| Occluder Static | 我是挡板,能挡住别人 | 大墙、地板、山体、大建筑 |
| Occludee Static | 我是被挡的,被挡住时可以不画 | 桌椅、箱子、装饰物 |
一个物体可以两个都勾(比如一栋房子:既挡住后面的东西,自己也会被前面的山挡住)。
怎么选:
| 物体 | Occluder | Occludee | 原因 |
|---|---|---|---|
| 大面墙 | ✅ | ✅ | 又大又不透明 |
| 小茶杯 | ❌ | ✅ | 太小,挡不住什么 |
| 玻璃窗 | ❌ | ✅ | 透明的,挡不住视线 |
| 铁丝网、树叶 | ❌ | ✅ | 有很多镂空 |
| 会移动的箱子 | ❌ | ❌(用下面的动态选项) | 不是静态物体 |
动态物体(会移动的)怎么办?
在它的 Mesh Renderer 上勾选Dynamic Occlusion(默认已勾选)。这样它可以被静态墙挡住而剔除,但不能挡住别人。
所以敌人、玩家、子弹走到墙后面时,依然会被剔除。
第 2 步:划定摄像机活动区域(Occlusion Area)
GameObject → 创建空物体 → Add Component → Occlusion Area
| 情况 | 结果 |
|---|---|
| 不放 Occlusion Area | Unity 对整个场景都计算,数据大、烘焙慢 |
| 放了,并勾选Is View Volume | 只在摄像机会去的地方计算精细数据 |
射击游戏例子:玩家只在走廊和房间里走动,就只在这些地方放 Occlusion Area,天空、山后面这些摄像机去不了的地方不用算。
第 3 步:设置可开关的门(Occlusion Portal)
门关上时,门后的东西应该被剔除;门打开时,又要能看到。
做法:在门的位置放一个物体,加Occlusion Portal组件,调整大小正好盖住门洞。
usingUnityEngine;[RequireComponent(typeof(OcclusionPortal))]publicclassDoor:MonoBehaviour{OcclusionPortalportal;voidAwake(){portal=GetComponent<OcclusionPortal>();portal.open=false;// 一开始门是关的,门后的东西被剔除}publicvoidOpen(){portal.open=true;// 开门:门后可以被看到了// 播放开门动画……}publicvoidClose(){portal.open=false;// 关门:门后又可以被剔除}}注意:
- 门本身不要标记 Occluder Static。会动的门如果当挡板烘焙进数据,门打开以后,门后的东西还是被当成"被挡住",就看不见了。
- Portal 的大小要略大于门洞,不然边缘可能漏剔或误剔。
- 门开着时,Portal 不挡任何东西;关上时,它就像一面临时的墙。
第 4 步:调整烘焙参数
打开Window → Rendering → Occlusion Culling,切到Bake页签:
| 参数 | 意思 | 调大的效果 | 调小的效果 |
|---|---|---|---|
| Smallest Occluder | 多大的物体才算挡板(米) | 烘焙快、数据小,但小墙挡不住东西 | 剔除更多,但数据大、烘焙慢 |
| Smallest Hole | 多小的缝隙算"能看穿" | 小缝被当成堵死,可能误剔(物体突然消失) | 更准确,但数据更大 |
| Backface Threshold | 背面检测阈值(%) | 100 = 不做额外优化 | 调低可以去掉"在墙里面才能看到"的无效数据,减小体积 |
射击游戏推荐的起点:
| 场景类型 | Smallest Occluder | Smallest Hole |
|---|---|---|
| 室内走廊、房间 | 1~2 | 0.1~0.25 |
| 城市街道 | 3~5 | 0.25 |
| 大型户外 | 5~10 | 0.5 |
调参的原则:先用默认值烘焙一次,看到问题再调。物体"闪烁消失"就调小 Smallest Hole;数据太大就调大 Smallest Occluder。
第 5 步:烘焙
点击窗口右下角的Bake。
Unity 会做这些事:
1. 收集所有 Occluder Static 和 Occludee Static 物体 2. 把场景空间切成很多小格子(Cell) 3. 计算:站在每个格子里,能看到哪些格子 4. 识别门(Portal)的位置,记录"门开 / 门关"时的连通关系 5. 把结果压缩,存成一份数据文件烘焙结果保存在和场景同名的文件夹里(OcclusionCullingData.asset),随场景一起打包。
多场景叠加加载(Additive)的注意事项:
如果游戏是"主场景 + 多个子场景叠加",要把这些场景同时打开再烘焙,Unity 会生成一份共享数据。分开烘焙的话,场景之间的遮挡关系不会被计算。
第 6 步:可视化检查
还是在 Occlusion Culling 窗口,切到Visualization页签,然后在 Scene 视图里选中摄像机:
| 显示选项 | 用途 |
|---|---|
| Camera Volumes | 看摄像机能去的区域(格子划分) |
| Visibility Lines | 看摄像机的视线投射到哪里 |
| Portals | 看门的位置和连通关系 |
拖动摄像机走一圈,被剔除的物体会直接从 Scene 视图里消失。
重点检查:
- 墙后面的东西有没有消失?(没消失 → 墙没标 Occluder,或者太小)
- 视野里的东西有没有不该消失却消失了?(→ Smallest Hole 太大,或者透明物体被当成了挡板)
- 走到门口、拐角时,有没有物体**“突然出现”**?
还有一个常被忘记的开关:摄像机组件上的Occlusion Culling复选框必须勾上(默认是勾上的),否则烘焙了也不生效。
第 7 步:性能验证
做了遮挡剔除,不代表一定变快。要用数据说话:
| 工具 | 看什么 |
|---|---|
| Game 视图 Stats | Batches(批次数)、Tris(三角形数)有没有明显下降 |
| Frame Debugger | 一帧里到底画了哪些物体 |
| Profiler → CPU | 搜索Culling相关的耗时,看剔除本身花了多少时间 |
| Profiler → Rendering | Draw Call 和 SetPass Call 数量 |
判断标准:
省下的渲染时间 > 剔除本身的耗时 → 值得 省下的渲染时间 ≤ 剔除本身的耗时 → 不值得,可能反而变慢典型情况:开阔的户外场景,大部分东西本来就互相看得见,剔除几乎剔不掉什么,反而白白多了查询开销。
五、运行时每一帧发生了什么
每一帧,摄像机开始渲染前: 1. 拿到摄像机的位置和朝向 2. 查烘焙数据:摄像机现在在哪个格子里? 3. 从这个格子出发,沿着"能看到的格子"往外找 ├─ 遇到开着的门(Portal.open = true)→ 继续往门后找 └─ 遇到关着的门 → 停下,门后不再找 4. 同时结合视锥:不在视野范围内的格子直接跳过 5. 得到"可见格子"列表 ├─ 静态物体:在可见格子里的 → 保留,否则剔除 └─ 动态物体:用包围盒检查是否被静态挡板挡住 → 挡住就剔除 6. 剩下的物体交给渲染管线:合批、LOD、提交 Draw Call关键点:运行时没有实时计算"谁挡住了谁",只是查表 + 沿格子走一走,所以 CPU 开销很小。这就是"烘焙"的价值:把贵的计算挪到了开发阶段。
六、射击游戏的实际案例
假设做一个室内 CQB(近距离战斗)射击游戏:一栋三层建筑,每层 10 个房间。
| 设置 | 做法 |
|---|---|
| 墙、地板、天花板 | Occluder + Occludee Static |
| 桌椅、柜子、装饰 | Occludee Static |
| 窗户玻璃 | 只勾 Occludee |
| 可破坏的墙 | 不要当 Occluder(破坏后会看错),可以放一个 Portal,墙被打破时open = true |
| 房间门 | Occlusion Portal + 上面的 Door 脚本 |
| 敌人、玩家、子弹、弹壳 | 动态物体,保持 Dynamic Occlusion 勾选 |
| Occlusion Area | 只覆盖建筑内部和玩家能走到的室外区域 |
效果(示意数量级):
不开遮挡剔除:玩家站在一楼房间里,Batches ≈ 1200(全楼的家具都在画) 开启遮挡剔除:同一个位置,Batches ≈ 150(只画当前房间和门口能看到的部分)七、Unity 内置方案的局限与其他方案
内置的 Umbra 方案很好用,但不是万能的:
| 局限 | 说明 |
|---|---|
| 只有静态物体能当挡板 | 会移动的大卡车挡不住后面的东西 |
| 需要烘焙 | 程序化生成的关卡(随机地图)无法提前烘焙 |
| 场景改动就要重新烘焙 | 摆了一面新墙,忘记重新烘焙,剔除就不准 |
| 大型开放世界效果有限 | 数据量巨大,且遮挡关系少 |
其他可选方案:
| 方案 | 思路 | 适合 |
|---|---|---|
| GPU 遮挡剔除(Unity 6 起,URP/HDRP) | 配合 GPU Resident Drawer,用上一帧的深度图在 GPU 上判断遮挡,不需要烘焙,动态物体也能当挡板 | 物体数量多、支持 Compute Shader 的平台 |
| 手动分区开关 | 按房间/区域分组,玩家进入某区域时用代码SetActive打开或关闭其他区域 | 关卡结构简单、房间分明的游戏 |
| 距离剔除 | camera.layerCullDistances给不同 Layer 设不同的最远绘制距离 | 小装饰物、草、碎石 |
| LOD + 远处简化 | 远处不剔除,但用低面数模型 | 开放世界 |
| 自定义 CullingGroup | CullingGroupAPI 查询包围球的可见性和距离档位,用于决定 AI、特效、动画是否更新 | 控制逻辑开销(看不见的敌人不跑动画) |
实际项目常常组合使用:内置遮挡剔除处理室内,距离剔除处理小物件,LOD 处理远景,CullingGroup 处理看不见的特效和动画。
CullingGroup 小例子:看不见的敌人不播动画,省 CPU。
usingUnityEngine;publicclassEnemyVisibility:MonoBehaviour{publicAnimator[]enemies;CullingGroupgroup;BoundingSphere[]spheres;voidStart(){group=newCullingGroup();group.targetCamera=Camera.main;spheres=newBoundingSphere[enemies.Length];for(inti=0;i<enemies.Length;i++)spheres[i]=newBoundingSphere(enemies[i].transform.position,1.5f);group.SetBoundingSpheres(spheres);group.SetBoundingSphereCount(enemies.Length);group.onStateChanged=OnStateChanged;}voidUpdate(){// 敌人会移动,每帧更新包围球位置for(inti=0;i<enemies.Length;i++)spheres[i].position=enemies[i].transform.position;}voidOnStateChanged(CullingGroupEvente){// 变得可见 → 打开动画;变得不可见 → 关闭动画if(e.hasBecomeVisible)enemies[e.index].enabled=true;if(e.hasBecomeInvisible)enemies[e.index].enabled=false;}voidOnDestroy(){group.Dispose();// 必须释放,否则内存泄漏group=null;}}这里的"可见"同样会考虑烘焙好的遮挡数据:敌人在墙后面时,
hasBecomeInvisible也会触发。
八、常见的坑
| 坑 | 现象 | 解决 |
|---|---|---|
| 摄像机没勾 Occlusion Culling | 烘焙了但完全没效果 | 检查 Camera 组件的复选框 |
| 透明物体当挡板 | 玻璃后面的东西消失了 | 玻璃、栅栏、树叶不要勾 Occluder |
| 会动的门当挡板 | 开门后门后一片空 | 门用 Portal,不要标 Occluder |
| 改了场景没重新烘焙 | 新墙不挡东西,或删掉的墙还在"挡" | 关卡定稿后再烘焙,改动后记得重烘 |
| Smallest Hole 太大 | 物体在拐角处"闪烁"或突然出现 | 调小 Smallest Hole |
| 没放 Occlusion Area | 烘焙极慢,数据文件很大 | 只覆盖摄像机能去的区域 |
| 多场景分开烘焙 | 场景之间互相不剔除 | 同时打开所有场景一起烘焙 |
| 摄像机跑到墙里面 | 大片物体错误消失 | 防止摄像机穿墙(摄像机碰撞) |
| 开阔场景硬上遮挡剔除 | 帧率没提升,甚至下降 | 先用 Profiler 确认瓶颈在渲染 |
| 以为剔除也会停掉脚本 | 看不见的敌人还在跑 AI 和动画 | 遮挡剔除只管渲染,逻辑要用 CullingGroup 自己控制 |
九、什么时候值得用?
| 场景 | 是否推荐 |
|---|---|
| 室内、走廊、地下城、楼房 | ✅ 非常适合,效果明显 |
| 城市街道(高楼挡视线) | ✅ 适合 |
| 俯视角 2D/2.5D 射击游戏 | ❌ 几乎没东西会被挡,视锥剔除就够了 |
| 大型开阔户外 | ⚠️ 效果有限,优先用 LOD + 距离剔除 |
| 随机生成的关卡 | ❌ 无法烘焙,考虑 GPU 遮挡剔除或手动分区 |
| 移动端、低端设备的 3D 室内游戏 | ✅ 特别值得,Draw Call 是移动端常见瓶颈 |
一句话总结
Unity 遮挡剔除 = 标记挡板和被挡物 → 划定摄像机区域 → 用 Portal 处理门 → 调参烘焙 → 可视化检查 → 用 Profiler 验证。
它的本质是把"谁挡住谁"的计算提前到编辑器里做完,运行时只查表,所以开销很小;但只有静态物体能当挡板,最适合室内、结构封闭的场景。是否真的变快,以 Profiler 的数据为准。