在一座热闹的影视基地里,导演正指挥一场宏大戏份:摄影机架在轨道上,镜头对准主街道,身后是层层叠叠的古建筑和大量待命群演。如果副导演傻乎乎地把所有布景和群演都“请”到镜头前,现场只会一团乱麻。聪明的导演只问一个问题:这一刻,摄影机到底能拍到谁? 拍不到的,不用精心准备;被挡住的,也可以直接撤下。这个朴素的取舍,正是图形学中“遮挡剔除”(Occlusion Culling)的日常——显卡渲染每一帧时,同样要把“镜头外”和“被挡住”的物体从绘制清单里划掉,把算力留给真正露脸的物体。本文就跟着剧组的“出镜名单”制定流程,把遮挡剔除的原理、方案与工程技巧完整走一遍。
对渲染与AI从业者来说,遮挡剔除是大型场景性能优化最锋利的刀。掌握它,你能一眼识别渲染瓶颈并做出GPU/CPU两侧的取舍,是游戏、数字孪生、云渲染等方向的核心基本功。
目录
可视化演示
交互展示遮挡剔除原理、层次Z缓冲方法和性能优化效果,理解如何跳过被遮挡物体提升渲染性能
本节配有交互动效(进入视口后自动加载,约 3KB)。
遮挡场景 摄像机视锥体 场景中的物体 检测哪些物体被遮挡
剔除概念:先圈出“拍得到”的,再划掉“被挡住”的
开机前,导演的第一件事是清场。清场分两步:第一步,把所有根本不在取景框里的布景撤走——这叫“视锥剔除”;第二步,即使物体在取景框内,如果被前面的建筑或演员严严实实挡住,画面里看不到它的一根头发,也可以不安排上场——这就是“遮挡剔除”。两者合起来,才是一份完整的“出镜名单”。
在图形学里,视锥剔除是渲染优化的第一道闸门。每个机位有一个取景范围(视锥体),由远近上下左右六个裁剪面包围而成。判断一个物体是否出画,最常用的办法是给物体套一个包围球,然后把球心往每个裁剪面的法向量方向投影:
回到剧组,第一轮清场把“出画”的布景全部撤走,现场立刻清爽。但导演的目光马上转向第二轮:取景框内那些露不出来的“隐藏演员”,才是遮挡剔除真正要啃的骨头。
- 裁剪面选取取视锥体的第 \(i\) 个裁剪面,每个面由指向视锥内部的法向量 \(\vec{n}_i\) 和到相机的偏移 \(d_i\) 定义。
- 球心距离投影把包围球球心 \(\vec{c}\) 对法向量做点乘,得到球心到该平面的带符号距离。
- 半径比较若距离小于 \(-r\),说明整个球都彻底位于该平面的外侧。
- 汇聚判定六个面做“或”运算,任一成立则剔除;全部不成立才真正需要绘制。
包围体测试:用快递纸箱代替精装礼盒
道具组给每位演员发了一个标准规格的纸箱。演员的戏服可能有飘带、头饰、复杂轮廓,直接拿真实体型去比划遮挡关系,得逐顶点计算,成本高得吓人。聪明的做法是:先用一个简单的纸箱(AABB 包围盒)或充气球(包围球)框住整个物体,再用这个廉价代理做快速测试。
一个包围球由球心 \(\vec{c}\) 和半径 \(r\) 唯一确定,任何复杂物体都能用一个粗球框住。用包裹体测试视锥时,我们还能进一步收紧判定:对 AABB,设中心为 \(\vec{c}\),半边长向量为 \(\vec{h} = (h_x, h_y, h_z)\),则它到某个裁剪面的最近有符号距离为:
“纸箱法则”粗糙但极快,是现代渲染中最依赖的粗筛工具。副导演用纸箱先划掉绝大多数不需要关心的布景,剩下少数“可疑人员”,再动用更精细的手段——也就是下一节的主角:遮挡查询。
- AABB 表示包围盒用中心 \(\vec{c}\) 和三个轴向的半边长 \(h_x, h_y, h_z\) 描述,等价于最小最大坐标表示。
- 中心到平面距离计算盒中心到裁剪面的带符号距离。
- 半径投影把盒子在半轴方向的宽度沿法向量做投影,累加得到“盒子在平面法线方向的覆盖深度”。
- 最远边界比较中心距离减去覆盖深度仍大于 0,说明整体在平面外侧,必然剔除。
遮挡查询:副导演举着空箱子问 GPU
副导演从道具箱里抱起一个空纸箱走到镜头前:如果这个箱子在取景框里完全被前面的建筑挡住,那箱子里本该装着的昂贵布景(复杂网格)肯定也看不见。为什么不直接拿真实布景测?因为真实网格顶点太多,测一次贵得离谱;空箱子只有 12 个三角形,几乎不花成本。
这正是 GPU 遮挡查询(Occlusion Query)的思路:用足够保守的廉价几何体(通常就是包围盒)代替真实网格提交给 GPU 做深度测试,同时开启查询。GPU 返回“通过深度测试的像素样本数” samplesPassed:
工程上还有一个关键技巧——时序复用。查询结果不能立刻同步回 CPU,否则 GPU 管线要停下来等待,反而拖慢帧率。实际做法是“上一帧预提交查询,这一帧用其结果决定是否画网格”,相当于导演的经验本:上一帧没露脸,这一帧大概率还不露脸,先照抄名单,隔几帧再复查。
- 提交代理几何把真实网格替换为简单包围盒,面数从数万降到十几个三角形。
- 深度测试计数GPU 按正常深度流程绘制包围盒,统计通过深度测试的像素样本数量。
- 判定可见性样本数大于零即认为物体露头,等于零则完全被遮挡。
层次Z缓冲:先扫剪影,再抠细节
导演的监控屏旁挂着一块模糊的剪影屏幕。整张画面缩成粗颗粒色块:凡是“黑压压一片”的区域,里面藏着的背景可以整片划掉;凡是边缘不明、疑似有内容的区域,才把画面放大细看。这套“先粗后细”的读图法,就是层次Z缓冲(Hierarchical Z-Buffer,HZB)。
深度缓冲记录了每个像素上离相机最近的表面深度。HZB 对它做 mipmap 式下采样,每层保存区域内深度值的最大值——也就是该区域里“最深的可见表面”。判定物体是否被遮挡时,先把它投影到屏幕得到覆盖区域 \(R\),再到 HZB 中挑一个合适层级,让单个纹素恰好覆盖整个区域,然后比较物体最近深度与纹素深度:
导演扫一眼缩略图就能批量划掉几十个布景,只有当物体被判断为“可能可见”时才下采样到精细层级。HZB 也因此成为 GPU 驱动剔除和现代引擎遮挡剔除的“标配底图”。
- 屏幕投影将物体包围盒的顶点投影到屏幕空间,得到覆盖矩形区域 \(R\)。
- 选择层级从 HZB 金字塔中选一级,使该层一个纹素正好覆盖区域 \(R\),层数越高一个纹素对应的屏幕区域越大。
- 读取深度并比较读取该纹素中的最大深度 \(d_{\text{max}}\),与物体最近深度 \(d_{\text{obj}}\) 比较。
GPU驱动剔除:让显卡自己给自己安排
前几节里,CPU 仍像个大导演,必须逐帧、逐物体地跑剔除逻辑、提交绘制命令。可当场景里有几十万个物体时,导演光是核对名单就已累得说不出话——即使最后只画出 5% 的物体,CPU 遍历所有物体的开销也纹丝不动。
于是导演把名单往调度台一扔:整个演员花名册交给舞台调度系统(GPU),GPU 用计算着色器并行遍历所有候选物体,直接在显存里完成视锥剔除与遮挡剔除,把可见物体写进紧凑列表;随后一次间接绘制调用(IndirectDraw)就能批量上场。提交的绘制命令数简化为:
从“逐个点名”到“整册委托”,CPU 从繁琐的名单核对中解放出来,GPU 则用自己的并行能力完成了自我筛选。这是现代大规模渲染中 CPU 端瓶颈的终极解药。
- 候选列表入显存把场景中所有物体的包围体、变换矩阵、材质索引预先上传到 GPU 缓冲区。
- 并行剔除计算每个物体由一个线程负责,依次执行视锥测试与遮挡测试。
- 原子计数与落榜可见物体通过原子操作累加计数器,并把自身索引写入命令缓冲区。
- 间接绘制提交GPU 读取命令缓冲区中的绘制参数,一次性渲染全部可见物体,CPU 完全脱身。
误剔除处理:别让裙摆凭空消失
回放画面里,导演发现一位演员的裙摆明明露出了一角,画面上却什么都没有——人凭空消失了。这是剧组严重的事故:误剔除(false occlusion)。渲染中,误剔除最典型的表现是物体闪烁或短暂消失,因为本应可见的物体被错误跳过了一帧。
误剔除通常有三个来源:包围盒比真实几何体小(代理不保守)、深度缓冲在远距离精度不足、时序复用把过时结果带到了剧烈变化的帧。工程上有三件“防护服”:保守剔除(用偏大的包围体)、深度偏移(给遮挡体深度加一个小的偏移量,偏向于不剔除)、物体膨胀(把被测试物体的判定半径向外扩一点)。其中最直观的是膨胀:
膨胀量不能无限大,否则剔除效果会被稀释。实际引擎里常取物体尺寸的 5% 到 20%,并在不同距离等级上动态调整。这是“演员裙摆”与“画面效率”之间的平衡艺术。
- 取原始半径物体真实包围球半径为 \(r\)。
- 叠加膨胀量人为增加一个正的小量 \(\varepsilon\),得到更大的判定半径。
- 用膨胀半径做测试所有遮挡判定都改用 \(r'\) 而非 \(r\),让“险些露出”的物体留在名单里。
场景优化策略:剧组的分工手册
老导演的场记本上写满了上次的站位:主场景的固定布景不必每帧重新排查,因为遮挡关系几乎不变——搭建那天就可以做一次“预烘焙”,把静态遮挡数据存下来,拍摄时直接翻手册。临时道具和群演走位则属于动态物体,需要实时查询。
一个成熟引擎的遮挡剔除系统通常这样分工:静态与动态物体分开组织;动态物体按空间树(BVH / 八叉树)分组,支持“整块剔除”而非逐个剔除;上一帧的查询结果会复用若干帧,以摊销查询成本。整个策略的代价模型可以写成:
至此,导演终于有了完整的分工手册:静态布景靠预烘焙,动态入镜靠实时查询,经验笔记负责连拍时的查漏补缺。遮挡剔除从“每帧重头算”变成了“按需、分层、可复用”的高效流程。
- 查询成本分摊遮挡查询不是每帧都重新发起,而是每 \(N_{\text{reuse}}\) 帧执行一次,单帧查询成本被摊薄。
- 绘制成本压缩剔除比例 \(c\) 越高,真正绘制的物体比例 \(1-c\) 越低。
- 预算叠加两部分相加得到单帧总成本,也回答了“这个场景值不值得做遮挡剔除”。
AI 应用场景
游戏引擎的开放世界渲染
阶段:渲染优化 / 场景管理
解决的问题:开放世界游戏有数万栋建筑、车辆和道具,若把所有物体一次性提交给 GPU,显存带宽和绘制开销会直接爆掉。传统做法是 CPU 逐个做视锥剔除,但物体数量上涨后 CPU 本身成为瓶颈。
具体案例:Unity 的 Occlusion Culling 系统将静态场景预烘焙为遮挡数据,运行时直接查表;UE5 的 Nanite 与 HZB 剔除结合,让“一栋楼一个 DrawCall”成为现实。玩家在楼宇间穿梭时,背后的建筑被批量剔除,帧率保持稳定。
BIM 与数字孪生场景漫游
阶段:几何处理与交互渲染
解决的问题:一个工厂级 BIM 模型可能有上亿三角形,普通笔记本无法流畅旋转视角。遮挡剔除让“走在走廊里”的视角只绘制当前房间与走廊的构件,而不是整栋楼。
具体案例:某数字孪生平台接入地铁站 BIM 模型,设备管道密集区域使用 HZB 层次剔除做粗粒度扫描,漫游帧率从 12fps 提升到 60fps,同时保证了纵深感。
云游戏的分布式渲染
阶段:服务端渲染调度
解决的问题:云游戏把渲染放在云端 GPU,多路玩家共享一块显卡。若不做遮挡剔除,显卡算力被无意义物体吃满,直接影响同时在线路数与成本。
具体案例:某云游戏平台在大规模场景中接入 GPU 驱动剔除,计算着色器并行生成可见物体列表,单路渲染占用降低约 35%,单卡并发路数显著提升。
Python 演示
import mathimport random class Camera: def __init__(self, x=0.0, y=0.0, fov_deg=60.0, near=0.5, far=200.0): self.x = x self.y = y self.half_tan = math.tan(math.radians(fov_deg) / 2.0) self.near = near self.far = far def in_view(self, cx, cy, r): dx = cx - self.x if dx < self.near or dx > self.far: return False limit = dx * self.half_tan + r return abs(cy - self.y) <= limit class Wall: def __init__(self, x, y_min, y_max): self.x = x self.y_min = y_min self.y_max = y_max def blocks(self, cam, cx, cy, r): dx = cx - cam.x if dx <= 0 or cx <= self.x: return False t = (self.x - cam.x) / dx if t <= 0 or t >= 1: return False y_at = cam.y + t * (cy - cam.y) return self.y_min + r <= y_at <= self.y_max - r def cull_scene(cam, wall, spheres, inflate): culled_frustum = 0 culled_occlusion = 0 visible = [] for (cx, cy, r) in spheres: r2 = r + inflate if not cam.in_view(cx, cy, r2): culled_frustum += 1 continue if wall.blocks(cam, cx, cy, r2): culled_occlusion += 1 continue visible.append((cx, cy, r)) return visible, culled_frustum, culled_occlusion def build_scene(n, seed=42): rng = random.Random(seed) spheres = [] for _ in range(n): cx = rng.uniform(10.0, 180.0) cy = rng.uniform(-70.0, 70.0) r = rng.uniform(1.0, 4.0) spheres.append((cx, cy, r)) return spheres def main(): cam = Camera() wall = Wall(x=60.0, y_min=-25.0, y_max=25.0) spheres = build_scene(300) for inflate in (0.0, 0.6): visible, cf, co = cull_scene(cam, wall, spheres, inflate) total = len(spheres) saved = 100.0 * (cf + co) / total kept = 100.0 * len(visible) / total print(f"[inflate {inflate:.1f}] total {total}, " f"view-cull {cf}, occ-cull {co}, visible {len(visible)}") print(f" saved {saved:.1f}%, kept {kept:.1f}%") if __name__ == "__main__": main()
self.half_tan = math.tan(math.radians(fov_deg) / 2.0)预计算视锥半角正切值,避免逐物体重复三角函数计算。if dx < self.near or dx > self.far:深度超出近远裁剪面直接判不可见,这是视锥剔除的距离部分。return abs(cy - self.y) <= limit判断球心是否在视锥上下边界内,limit已包含半径余量。t = (self.x - cam.x) / dx计算视线到达墙所在深度的参数 t,用于求交点高度。return self.y_min + r <= y_at <= self.y_max - r膨胀后墙体判定区间向内收缩,使“擦边可见”的物体更不容易被误剔除。if not cam.in_view(...)第一轮剔除:不在视野中的物体直接跳过。if wall.blocks(...)第二轮剔除:在视野中但被墙挡住的物体跳过。r2 = r + inflate对半径做膨胀处理,防误剔除。for inflate in (0.0, 0.6):对比无膨胀与有膨胀两种策略,观察剔除比例的差异。
常见误区
误区“我打开了遮挡剔除,帧率应该立刻翻倍;如果没提升,就是引擎有问题。”
正解剔除收益取决于场景中被遮挡物体的比例。空旷草原、全屏粒子特效这类场景几乎不存在遮挡,剔除计算本身就是额外负担。剧组里只有一个演员时,再精心设计“出镜名单”也省不出多少妆发成本。工程上应先测量遮挡率,再决定是否值得做遮挡剔除。
为何常见:性能优化教程常聚焦“省了多少渲染量”,让人误以为剔除是免费的万能药。
误区“物体不在镜头里就不画,我已经做了视锥剔除,遮挡剔除可有可无。”
正解视锥剔除只解决“出画”,不解决“被挡”。城市街道里绝大多数建筑都在镜头内但彼此遮挡严重,只做视锥剔除仍会提交巨量不可见三角形。两者是搭班干活:视锥剔除负责大范围出画物体,遮挡剔除负责镜头内看不见的物体。
为何常见:视锥剔除概念简单、实现直观,新手很容易把两类剔除混为一谈。
误区“我发起遮挡查询,GPU 应该马上告诉我结果,然后我立刻决定画不画。”
正解GPU 是异步流水线,查询结果通常要等一帧甚至几帧才可用。工程上普遍采用“上一帧结果决定本帧绘制”的时序复用,否则 CPU 会被迫同步等待 GPU,管线停顿的损失远超剔除收益。
为何常见:人们习惯 CPU 的同步函数调用,不理解 GPU 并行异步的本质。
误区“为了不误剔除,我把物体包围盒放到最大——结果剔除效果没了,帧率还更低了。”
正解过大的包围盒会让很多本可剔除的物体被判定可见,同时视锥剔除也会失效大半。包围盒应尽量紧贴物体,只留少量保守余量。纸箱太大,里面装着什么都能塞进去,副导演就永远测不出遮挡了。
为何常见:前面强调“保守”时,新手容易矫枉过正,把“保守”理解成无脑放大。
误区“误剔除偶尔闪一下,不影响整体体验,先上线再说。”
正解物体闪烁直接破坏画面可信度,在医疗可视化、自动驾驶仿真等严肃场景中是不可接受的事故。正确做法是在开发期就做深度偏移、膨胀处理和保守包围体,从源头避免误剔除。
为何常见:误剔除是概率性问题,不总复现,容易被当成“偶发小 bug”而延误处理。
面试题
Q1. 遮挡剔除的主要目标是什么?
- A.减少绘制被完全遮挡的物体← 正确答案
- B.减少绘制视野外的物体
- C.提高模型纹理的分辨率
- D.降低光照计算的阴影质量
答案:A。遮挡剔除专门处理“在镜头内但被其他物体完全挡住”的物体。B 是视锥剔除的目标;C 和 D 与是否绘制无关,属于资源与光影层面的优化。
Q2. 视锥剔除与遮挡剔除的正确关系是?
- A.两者处理完全相同的物体
- B.视锥剔除是遮挡剔除的子集
- C.先视锥剔除再遮挡剔除← 正确答案
- D.遮挡剔除是视锥剔除的前提
答案:C。标准管线先做视锥剔除划掉出画物体,再对剩余物体做遮挡剔除。两者筛选对象不同:视锥剔除管范围外,遮挡剔除管范围内被挡住的,并非子集关系。
Q3. 遮挡查询为什么常用包围盒代替真实网格?
- A.包围盒更美观且节省显存
- B.包围盒面数少查询开销低← 正确答案
- C.网格模型无法用于深度测试
- D.包围盒会自动跟随物体移动
答案:B。包围盒只有十几个三角形,而真实网格可能数万甚至数百万顶点。用包围盒做遮挡查询能大幅降低 GPU 端的测试开销;网格同样能做深度测试,只是代价太高。包围盒也不会自动移动,需要随物体更新。
Q4. GPU 遮挡查询返回 samplesPassed = 0 表示什么?
- A.物体完全位于视锥之外
- B.物体完全被其他物体遮挡← 正确答案
- C.物体材质是透明的材质
- D.物体不需要光照计算
答案:B。samplesPassed 统计通过深度测试的像素样本数,为 0 说明包围盒没有一个像素通过深度测试,物体被挡得严严实实。视锥之外由视锥剔除处理,透明材质和光照与深度测试无关。
Q5. 下列哪些是防止误剔除的常用方法?
- A.使用偏大的保守包围体← 正确答案
- B.对遮挡深度增加偏移量← 正确答案
- C.对测试物体增加膨胀量← 正确答案
- D.把物体移出场景再提交
答案:ABC。保守包围体、深度偏移、物体膨胀都是让判定“偏向可见”的工程措施,避免本应露出的物体被误杀。把物体移出场景是无意义操作,反而导致画面错误。
Q6. 时序复用遮挡查询结果(沿用上一帧)可能带来哪些问题?
- A.物体出现延迟一帧← 正确答案
- B.快速移动物体闪烁← 正确答案
- C.静态物体被错误剔除
- D.查询开销成倍增加
答案:AB。复用旧结果必然导致可见性通知延迟一帧,快速移动的物体容易闪进闪出;静态物体遮挡关系稳定,反而安全;复用是用时间换成本,不会让查询开销增加。
Q7. 层次Z缓冲的每一层保存什么值最安全?
- A.区域内深度的平均值
- B.区域内可见像素的数量
- C.区域内离相机最远的深度← 正确答案
- D.区域内离相机最近的深度
答案:C。保存区域内的最大深度(最远可见表面),再用物体最近深度与之比较:只有当物体比所有可见表面都远时才剔除,这是一个安全保守的判定。存最小深度会激进剔除,容易把可见物体误杀。
Q8. GPU 驱动剔除中,批量提交多个物体绘制命令依赖哪项能力?
- A.实例化绘制 Instancing
- B.间接绘制 IndirectDraw← 正确答案
- C.遮挡查询 OcclusionQuery
- D.延迟渲染 DeferredShading
答案:B。GPU 端剔除完成后,可见物体列表写入显存命令缓冲区,IndirectDraw 直接读取缓冲区参数批量绘制。Instancing 用于复用同一网格多份实例,不是剔除后批量提交的核心机制。
Q9. 以下场景哪种最适合遮挡剔除?
- A.城市街区建筑密集区域← 正确答案
- B.一望无际的空旷草原
- C.全屏粒子特效动画
- D.单物体填充整个屏幕
答案:A。遮挡剔除的收益来自“大量物体被挡住”。城市楼宇遮挡密集,剔除收益巨大;空旷草原几乎无遮挡,粒子与单物体场景也没有可剔除的冗余几何。
Q10. 遮挡剔除工程实践中,哪些做法是合理的?
- A.静态场景烘焙遮挡数据← 正确答案
- B.查询结果延迟一帧使用← 正确答案
- C.所有物体每帧重新查询
- D.动态物体合并区域查询← 正确答案
答案:ABD。静态烘焙、延迟一帧、动态分区合并都是降低查询开销的成熟实践。所有物体每帧重新查询会带来巨大 GPU 开销和 CPU 同步压力,并不合理。