在一座热闹的影视基地里,导演正指挥一场宏大戏份:摄影机架在轨道上,镜头对准主街道,身后是层层叠叠的古建筑和大量待命群演。如果副导演傻乎乎地把所有布景和群演都“请”到镜头前,现场只会一团乱麻。聪明的导演只问一个问题:这一刻,摄影机到底能拍到谁? 拍不到的,不用精心准备;被挡住的,也可以直接撤下。这个朴素的取舍,正是图形学中“遮挡剔除”(Occlusion Culling)的日常——显卡渲染每一帧时,同样要把“镜头外”和“被挡住”的物体从绘制清单里划掉,把算力留给真正露脸的物体。本文就跟着剧组的“出镜名单”制定流程,把遮挡剔除的原理、方案与工程技巧完整走一遍。

岗位关联

对渲染与AI从业者来说,遮挡剔除是大型场景性能优化最锋利的刀。掌握它,你能一眼识别渲染瓶颈并做出GPU/CPU两侧的取舍,是游戏、数字孪生、云渲染等方向的核心基本功。

目录
  1. 可视化演示
  2. 剔除概念:先圈出“拍得到”的,再划掉“被挡住”的
  3. 包围体测试:用快递纸箱代替精装礼盒
  4. 遮挡查询:副导演举着空箱子问 GPU
  5. 层次Z缓冲:先扫剪影,再抠细节
  6. GPU驱动剔除:让显卡自己给自己安排
  7. 误剔除处理:别让裙摆凭空消失
  8. 场景优化策略:剧组的分工手册
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

交互展示遮挡剔除原理、层次Z缓冲方法和性能优化效果,理解如何跳过被遮挡物体提升渲染性能

本节配有交互动效(进入视口后自动加载,约 3KB)。

遮挡场景 摄像机视锥体 场景中的物体 检测哪些物体被遮挡

剔除概念:先圈出“拍得到”的,再划掉“被挡住”的

开机前,导演的第一件事是清场。清场分两步:第一步,把所有根本不在取景框里的布景撤走——这叫“视锥剔除”;第二步,即使物体在取景框内,如果被前面的建筑或演员严严实实挡住,画面里看不到它的一根头发,也可以不安排上场——这就是“遮挡剔除”。两者合起来,才是一份完整的“出镜名单”。

在图形学里,视锥剔除是渲染优化的第一道闸门。每个机位有一个取景范围(视锥体),由远近上下左右六个裁剪面包围而成。判断一个物体是否出画,最常用的办法是给物体套一个包围球,然后把球心往每个裁剪面的法向量方向投影:

回到剧组,第一轮清场把“出画”的布景全部撤走,现场立刻清爽。但导演的目光马上转向第二轮:取景框内那些露不出来的“隐藏演员”,才是遮挡剔除真正要啃的骨头。

cull=⋁i=05(n⃗i⋅c⃗+di<−r)\displaystyle \text{cull} = \bigvee_{i=0}^{5} \left( \vec{n}_i \cdot \vec{c} + d_i < -r \right)
六个裁剪面中,只要任何一个面将球心推到“半径范围之外”,物体就完全出画,可以直接从绘制名单中划掉。
  1. 裁剪面选取取视锥体的第 \(i\) 个裁剪面,每个面由指向视锥内部的法向量 \(\vec{n}_i\) 和到相机的偏移 \(d_i\) 定义。
    n⃗i=(nx,ny,nz),di=面到相机的带符号距离\displaystyle \vec{n}_i = (n_x, n_y, n_z), \quad d_i = \text{面到相机的带符号距离}
  2. 球心距离投影把包围球球心 \(\vec{c}\) 对法向量做点乘,得到球心到该平面的带符号距离。
    disti=n⃗i⋅c⃗+di\displaystyle \text{dist}_i = \vec{n}_i \cdot \vec{c} + d_i
  3. 半径比较若距离小于 \(-r\),说明整个球都彻底位于该平面的外侧。
    n⃗i⋅c⃗+di<−r\displaystyle \vec{n}_i \cdot \vec{c} + d_i < -r
  4. 汇聚判定六个面做“或”运算,任一成立则剔除;全部不成立才真正需要绘制。
    draw=⋀i=05(n⃗i⋅c⃗+di≥−r)\displaystyle \text{draw} = \bigwedge_{i=0}^{5} \left( \vec{n}_i \cdot \vec{c} + d_i \ge -r \right)

包围体测试:用快递纸箱代替精装礼盒

道具组给每位演员发了一个标准规格的纸箱。演员的戏服可能有飘带、头饰、复杂轮廓,直接拿真实体型去比划遮挡关系,得逐顶点计算,成本高得吓人。聪明的做法是:先用一个简单的纸箱(AABB 包围盒)或充气球(包围球)框住整个物体,再用这个廉价代理做快速测试。

一个包围球由球心 \(\vec{c}\) 和半径 \(r\) 唯一确定,任何复杂物体都能用一个粗球框住。用包裹体测试视锥时,我们还能进一步收紧判定:对 AABB,设中心为 \(\vec{c}\),半边长向量为 \(\vec{h} = (h_x, h_y, h_z)\),则它到某个裁剪面的最近有符号距离为:

“纸箱法则”粗糙但极快,是现代渲染中最依赖的粗筛工具。副导演用纸箱先划掉绝大多数不需要关心的布景,剩下少数“可疑人员”,再动用更精细的手段——也就是下一节的主角:遮挡查询。

smin⁡=n⃗⋅c⃗+d−(∣nx∣hx+∣ny∣hy+∣nz∣hz)\displaystyle s_{\min} = \vec{n} \cdot \vec{c} + d - \left( |n_x| h_x + |n_y| h_y + |n_z| h_z \right)
若 \(s_{\min} > 0\),整个盒子都彻底在平面外侧,物体必然出画。这个公式把“盒子有多大”折算成了法向量方向的投影宽度。
  1. AABB 表示包围盒用中心 \(\vec{c}\) 和三个轴向的半边长 \(h_x, h_y, h_z\) 描述,等价于最小最大坐标表示。
    c⃗=(xmin⁡+xmax⁡2,ymin⁡+ymax⁡2,zmin⁡+zmax⁡2)\displaystyle \vec{c} = \left( \frac{x_{\min}+x_{\max}}{2}, \frac{y_{\min}+y_{\max}}{2}, \frac{z_{\min}+z_{\max}}{2} \right)
  2. 中心到平面距离计算盒中心到裁剪面的带符号距离。
    distcenter=n⃗⋅c⃗+d\displaystyle \text{dist}_{\text{center}} = \vec{n} \cdot \vec{c} + d
  3. 半径投影把盒子在半轴方向的宽度沿法向量做投影,累加得到“盒子在平面法线方向的覆盖深度”。
    proj=∣nx∣hx+∣ny∣hy+∣nz∣hz\displaystyle \text{proj} = |n_x| h_x + |n_y| h_y + |n_z| h_z
  4. 最远边界比较中心距离减去覆盖深度仍大于 0,说明整体在平面外侧,必然剔除。
    smin⁡=distcenter−proj>0⇒cull\displaystyle s_{\min} = \text{dist}_{\text{center}} - \text{proj} > 0 \Rightarrow \text{cull}

遮挡查询:副导演举着空箱子问 GPU

副导演从道具箱里抱起一个空纸箱走到镜头前:如果这个箱子在取景框里完全被前面的建筑挡住,那箱子里本该装着的昂贵布景(复杂网格)肯定也看不见。为什么不直接拿真实布景测?因为真实网格顶点太多,测一次贵得离谱;空箱子只有 12 个三角形,几乎不花成本。

这正是 GPU 遮挡查询(Occlusion Query)的思路:用足够保守的廉价几何体(通常就是包围盒)代替真实网格提交给 GPU 做深度测试,同时开启查询。GPU 返回“通过深度测试的像素样本数” samplesPassed:

工程上还有一个关键技巧——时序复用。查询结果不能立刻同步回 CPU,否则 GPU 管线要停下来等待,反而拖慢帧率。实际做法是“上一帧预提交查询,这一帧用其结果决定是否画网格”,相当于导演的经验本:上一帧没露脸,这一帧大概率还不露脸,先照抄名单,隔几帧再复查。

visible={true,samplesPassed>0false,samplesPassed=0\displaystyle \text{visible} = \begin{cases} \text{true}, & \text{samplesPassed} > 0 \\ \text{false}, & \text{samplesPassed} = 0 \end{cases}
只要有一个像素通过了深度测试,就说明包围盒至少露出一个点,真实网格才值得绘制。若一个像素都没通过,说明它被挡得严严实实。
  1. 提交代理几何把真实网格替换为简单包围盒,面数从数万降到十几个三角形。
    proxy=AABB(mesh)\displaystyle \text{proxy} = \text{AABB}(mesh)
  2. 深度测试计数GPU 按正常深度流程绘制包围盒,统计通过深度测试的像素样本数量。
    samplesPassed=∑pixel∈proxy1[depthproxy<depthbuffer]\displaystyle \text{samplesPassed} = \sum_{\text{pixel} \in \text{proxy}} \mathbf{1} \left[ \text{depth}_{\text{proxy}} < \text{depth}_{\text{buffer}} \right]
  3. 判定可见性样本数大于零即认为物体露头,等于零则完全被遮挡。
    visible  ⟺  samplesPassed>0\displaystyle \text{visible} \iff \text{samplesPassed} > 0

层次Z缓冲:先扫剪影,再抠细节

导演的监控屏旁挂着一块模糊的剪影屏幕。整张画面缩成粗颗粒色块:凡是“黑压压一片”的区域,里面藏着的背景可以整片划掉;凡是边缘不明、疑似有内容的区域,才把画面放大细看。这套“先粗后细”的读图法,就是层次Z缓冲(Hierarchical Z-Buffer,HZB)。

深度缓冲记录了每个像素上离相机最近的表面深度。HZB 对它做 mipmap 式下采样,每层保存区域内深度值的最大值——也就是该区域里“最深的可见表面”。判定物体是否被遮挡时,先把它投影到屏幕得到覆盖区域 \(R\),再到 HZB 中挑一个合适层级,让单个纹素恰好覆盖整个区域,然后比较物体最近深度与纹素深度:

导演扫一眼缩略图就能批量划掉几十个布景,只有当物体被判断为“可能可见”时才下采样到精细层级。HZB 也因此成为 GPU 驱动剔除和现代引擎遮挡剔除的“标配底图”。

occluded=(dobj>dmax)\displaystyle \text{occluded} = \left( d_{\text{obj}} > d_{\text{max}} \right)
物体的最近深度如果比区域里最深的可见表面还要远,说明它完全藏在所有可见表面之后,可以放心划掉。
  1. 屏幕投影将物体包围盒的顶点投影到屏幕空间,得到覆盖矩形区域 \(R\)。
    R=project(bounds)\displaystyle R = \text{project}(bounds)
  2. 选择层级从 HZB 金字塔中选一级,使该层一个纹素正好覆盖区域 \(R\),层数越高一个纹素对应的屏幕区域越大。
    L=⌈log⁡2(regionSizetexelSize)⌉\displaystyle L = \left\lceil \log_2 \left( \frac{\text{regionSize}}{\text{texelSize}} \right) \right\rceil
  3. 读取深度并比较读取该纹素中的最大深度 \(d_{\text{max}}\),与物体最近深度 \(d_{\text{obj}}\) 比较。
    dobj>dmax⇒occluded\displaystyle d_{\text{obj}} > d_{\text{max}} \Rightarrow \text{occluded}

GPU驱动剔除:让显卡自己给自己安排

前几节里,CPU 仍像个大导演,必须逐帧、逐物体地跑剔除逻辑、提交绘制命令。可当场景里有几十万个物体时,导演光是核对名单就已累得说不出话——即使最后只画出 5% 的物体,CPU 遍历所有物体的开销也纹丝不动。

于是导演把名单往调度台一扔:整个演员花名册交给舞台调度系统(GPU),GPU 用计算着色器并行遍历所有候选物体,直接在显存里完成视锥剔除与遮挡剔除,把可见物体写进紧凑列表;随后一次间接绘制调用(IndirectDraw)就能批量上场。提交的绘制命令数简化为:

从“逐个点名”到“整册委托”,CPU 从繁琐的名单核对中解放出来,GPU 则用自己的并行能力完成了自我筛选。这是现代大规模渲染中 CPU 端瓶颈的终极解药。

#draws=∑i=1N1[visiblei]\displaystyle \#\text{draws} = \sum_{i=1}^{N} \mathbf{1}[\text{visible}_i]
每个物体对应一个可见性标志位,把所有标志位相加,就是真正要画的物体数量。GPU 端原子计数器负责完成这个累加。
  1. 候选列表入显存把场景中所有物体的包围体、变换矩阵、材质索引预先上传到 GPU 缓冲区。
    O={o1,o2,…,oN}\displaystyle \mathcal{O} = \{ o_1, o_2, \dots, o_N \}
  2. 并行剔除计算每个物体由一个线程负责,依次执行视锥测试与遮挡测试。
    visiblei=inView(oi)∧notOccluded(oi)\displaystyle \text{visible}_i = \text{inView}(o_i) \land \text{notOccluded}(o_i)
  3. 原子计数与落榜可见物体通过原子操作累加计数器,并把自身索引写入命令缓冲区。
    counter←counter+1\displaystyle \text{counter} \leftarrow \text{counter} + 1
  4. 间接绘制提交GPU 读取命令缓冲区中的绘制参数,一次性渲染全部可见物体,CPU 完全脱身。
    DrawIndirect(cmdBuffer)\displaystyle \text{DrawIndirect}(\text{cmdBuffer})

误剔除处理:别让裙摆凭空消失

回放画面里,导演发现一位演员的裙摆明明露出了一角,画面上却什么都没有——人凭空消失了。这是剧组严重的事故:误剔除(false occlusion)。渲染中,误剔除最典型的表现是物体闪烁或短暂消失,因为本应可见的物体被错误跳过了一帧。

误剔除通常有三个来源:包围盒比真实几何体小(代理不保守)、深度缓冲在远距离精度不足、时序复用把过时结果带到了剧烈变化的帧。工程上有三件“防护服”:保守剔除(用偏大的包围体)、深度偏移(给遮挡体深度加一个小的偏移量,偏向于不剔除)、物体膨胀(把被测试物体的判定半径向外扩一点)。其中最直观的是膨胀:

膨胀量不能无限大,否则剔除效果会被稀释。实际引擎里常取物体尺寸的 5% 到 20%,并在不同距离等级上动态调整。这是“演员裙摆”与“画面效率”之间的平衡艺术。

r′=r+ε,ε>0\displaystyle r' = r + \varepsilon, \quad \varepsilon > 0
判定时使用膨胀后的半径 \(r'\),相当于给物体边缘留出“露出一点也算可见”的余量,避免擦边可见的物体被误杀。
  1. 取原始半径物体真实包围球半径为 \(r\)。
  2. 叠加膨胀量人为增加一个正的小量 \(\varepsilon\),得到更大的判定半径。
    r′=r+ε\displaystyle r' = r + \varepsilon
  3. 用膨胀半径做测试所有遮挡判定都改用 \(r'\) 而非 \(r\),让“险些露出”的物体留在名单里。
    occluded(r′)=occluded(r+ε)\displaystyle \text{occluded}(r') = \text{occluded}(r + \varepsilon)

场景优化策略:剧组的分工手册

老导演的场记本上写满了上次的站位:主场景的固定布景不必每帧重新排查,因为遮挡关系几乎不变——搭建那天就可以做一次“预烘焙”,把静态遮挡数据存下来,拍摄时直接翻手册。临时道具和群演走位则属于动态物体,需要实时查询。

一个成熟引擎的遮挡剔除系统通常这样分工:静态与动态物体分开组织;动态物体按空间树(BVH / 八叉树)分组,支持“整块剔除”而非逐个剔除;上一帧的查询结果会复用若干帧,以摊销查询成本。整个策略的代价模型可以写成:

至此,导演终于有了完整的分工手册:静态布景靠预烘焙,动态入镜靠实时查询,经验笔记负责连拍时的查漏补缺。遮挡剔除从“每帧重头算”变成了“按需、分层、可复用”的高效流程。

frameCost=CqueryNreuse+Cdraw⋅(1−c)\displaystyle \text{frameCost} = \frac{C_{\text{query}}}{N_{\text{reuse}}} + C_{\text{draw}} \cdot (1 - c)
查询成本被复用帧数 \(N_{\text{reuse}}\) 摊薄,绘制成本则随剔除比例 \(c\) 线性下降。两者相加,就是做遮挡剔除的真实代价账本。
  1. 查询成本分摊遮挡查询不是每帧都重新发起,而是每 \(N_{\text{reuse}}\) 帧执行一次,单帧查询成本被摊薄。
    CqueryPerFrame=CqueryNreuse\displaystyle C_{\text{queryPerFrame}} = \frac{C_{\text{query}}}{N_{\text{reuse}}}
  2. 绘制成本压缩剔除比例 \(c\) 越高,真正绘制的物体比例 \(1-c\) 越低。
    CdrawPerFrame=Cdraw⋅(1−c)\displaystyle C_{\text{drawPerFrame}} = C_{\text{draw}} \cdot (1 - c)
  3. 预算叠加两部分相加得到单帧总成本,也回答了“这个场景值不值得做遮挡剔除”。
    frameCost=CqueryNreuse+Cdraw⋅(1−c)\displaystyle \text{frameCost} = \frac{C_{\text{query}}}{N_{\text{reuse}}} + C_{\text{draw}} \cdot (1 - c)

AI 应用场景

游戏引擎的开放世界渲染

阶段:渲染优化 / 场景管理

解决的问题:开放世界游戏有数万栋建筑、车辆和道具,若把所有物体一次性提交给 GPU,显存带宽和绘制开销会直接爆掉。传统做法是 CPU 逐个做视锥剔除,但物体数量上涨后 CPU 本身成为瓶颈。

具体案例:Unity 的 Occlusion Culling 系统将静态场景预烘焙为遮挡数据,运行时直接查表;UE5 的 Nanite 与 HZB 剔除结合,让“一栋楼一个 DrawCall”成为现实。玩家在楼宇间穿梭时,背后的建筑被批量剔除,帧率保持稳定。

BIM 与数字孪生场景漫游

阶段:几何处理与交互渲染

解决的问题:一个工厂级 BIM 模型可能有上亿三角形,普通笔记本无法流畅旋转视角。遮挡剔除让“走在走廊里”的视角只绘制当前房间与走廊的构件,而不是整栋楼。

具体案例:某数字孪生平台接入地铁站 BIM 模型,设备管道密集区域使用 HZB 层次剔除做粗粒度扫描,漫游帧率从 12fps 提升到 60fps,同时保证了纵深感。

云游戏的分布式渲染

阶段:服务端渲染调度

解决的问题:云游戏把渲染放在云端 GPU,多路玩家共享一块显卡。若不做遮挡剔除,显卡算力被无意义物体吃满,直接影响同时在线路数与成本。

具体案例:某云游戏平台在大规模场景中接入 GPU 驱动剔除,计算着色器并行生成可见物体列表,单路渲染占用降低约 35%,单卡并发路数显著提升。

Python 演示

python80 行
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()
目的:在 2D 侧视场景中模拟“视锥剔除 + 遮挡剔除”组合流程,并对比不同膨胀量对误剔除的抑制效果。
  1. self.half_tan = math.tan(math.radians(fov_deg) / 2.0)预计算视锥半角正切值,避免逐物体重复三角函数计算。
  2. if dx < self.near or dx > self.far:深度超出近远裁剪面直接判不可见,这是视锥剔除的距离部分。
  3. return abs(cy - self.y) <= limit判断球心是否在视锥上下边界内,limit 已包含半径余量。
  4. t = (self.x - cam.x) / dx计算视线到达墙所在深度的参数 t,用于求交点高度。
  5. return self.y_min + r <= y_at <= self.y_max - r膨胀后墙体判定区间向内收缩,使“擦边可见”的物体更不容易被误剔除。
  6. if not cam.in_view(...)第一轮剔除:不在视野中的物体直接跳过。
  7. if wall.blocks(...)第二轮剔除:在视野中但被墙挡住的物体跳过。
  8. r2 = r + inflate对半径做膨胀处理,防误剔除。
  9. for inflate in (0.0, 0.6):对比无膨胀与有膨胀两种策略,观察剔除比例的差异。

常见误区

误区“我打开了遮挡剔除,帧率应该立刻翻倍;如果没提升,就是引擎有问题。”

正解剔除收益取决于场景中被遮挡物体的比例。空旷草原、全屏粒子特效这类场景几乎不存在遮挡,剔除计算本身就是额外负担。剧组里只有一个演员时,再精心设计“出镜名单”也省不出多少妆发成本。工程上应先测量遮挡率,再决定是否值得做遮挡剔除。

为何常见:性能优化教程常聚焦“省了多少渲染量”,让人误以为剔除是免费的万能药。

误区“物体不在镜头里就不画,我已经做了视锥剔除,遮挡剔除可有可无。”

正解视锥剔除只解决“出画”,不解决“被挡”。城市街道里绝大多数建筑都在镜头内但彼此遮挡严重,只做视锥剔除仍会提交巨量不可见三角形。两者是搭班干活:视锥剔除负责大范围出画物体,遮挡剔除负责镜头内看不见的物体。

为何常见:视锥剔除概念简单、实现直观,新手很容易把两类剔除混为一谈。

误区“我发起遮挡查询,GPU 应该马上告诉我结果,然后我立刻决定画不画。”

正解GPU 是异步流水线,查询结果通常要等一帧甚至几帧才可用。工程上普遍采用“上一帧结果决定本帧绘制”的时序复用,否则 CPU 会被迫同步等待 GPU,管线停顿的损失远超剔除收益。

为何常见:人们习惯 CPU 的同步函数调用,不理解 GPU 并行异步的本质。

误区“为了不误剔除,我把物体包围盒放到最大——结果剔除效果没了,帧率还更低了。”

正解过大的包围盒会让很多本可剔除的物体被判定可见,同时视锥剔除也会失效大半。包围盒应尽量紧贴物体,只留少量保守余量。纸箱太大,里面装着什么都能塞进去,副导演就永远测不出遮挡了。

为何常见:前面强调“保守”时,新手容易矫枉过正,把“保守”理解成无脑放大。

误区“误剔除偶尔闪一下,不影响整体体验,先上线再说。”

正解物体闪烁直接破坏画面可信度,在医疗可视化、自动驾驶仿真等严肃场景中是不可接受的事故。正确做法是在开发期就做深度偏移、膨胀处理和保守包围体,从源头避免误剔除。

为何常见:误剔除是概率性问题,不总复现,容易被当成“偶发小 bug”而延误处理。

面试题

Q1. 遮挡剔除的主要目标是什么?

单选题 · easy

  • A.减少绘制被完全遮挡的物体← 正确答案
  • B.减少绘制视野外的物体
  • C.提高模型纹理的分辨率
  • D.降低光照计算的阴影质量

答案:A。遮挡剔除专门处理“在镜头内但被其他物体完全挡住”的物体。B 是视锥剔除的目标;C 和 D 与是否绘制无关,属于资源与光影层面的优化。

Q2. 视锥剔除与遮挡剔除的正确关系是?

单选题 · easy

  • A.两者处理完全相同的物体
  • B.视锥剔除是遮挡剔除的子集
  • C.先视锥剔除再遮挡剔除← 正确答案
  • D.遮挡剔除是视锥剔除的前提

答案:C。标准管线先做视锥剔除划掉出画物体,再对剩余物体做遮挡剔除。两者筛选对象不同:视锥剔除管范围外,遮挡剔除管范围内被挡住的,并非子集关系。

Q3. 遮挡查询为什么常用包围盒代替真实网格?

单选题 · mid

  • A.包围盒更美观且节省显存
  • B.包围盒面数少查询开销低← 正确答案
  • C.网格模型无法用于深度测试
  • D.包围盒会自动跟随物体移动

答案:B。包围盒只有十几个三角形,而真实网格可能数万甚至数百万顶点。用包围盒做遮挡查询能大幅降低 GPU 端的测试开销;网格同样能做深度测试,只是代价太高。包围盒也不会自动移动,需要随物体更新。

Q4. GPU 遮挡查询返回 samplesPassed = 0 表示什么?

单选题 · mid

  • A.物体完全位于视锥之外
  • B.物体完全被其他物体遮挡← 正确答案
  • C.物体材质是透明的材质
  • D.物体不需要光照计算

答案:B。samplesPassed 统计通过深度测试的像素样本数,为 0 说明包围盒没有一个像素通过深度测试,物体被挡得严严实实。视锥之外由视锥剔除处理,透明材质和光照与深度测试无关。

Q5. 下列哪些是防止误剔除的常用方法?

多选题 · mid

  • A.使用偏大的保守包围体← 正确答案
  • B.对遮挡深度增加偏移量← 正确答案
  • C.对测试物体增加膨胀量← 正确答案
  • D.把物体移出场景再提交

答案:ABC。保守包围体、深度偏移、物体膨胀都是让判定“偏向可见”的工程措施,避免本应露出的物体被误杀。把物体移出场景是无意义操作,反而导致画面错误。

Q6. 时序复用遮挡查询结果(沿用上一帧)可能带来哪些问题?

多选题 · mid

  • A.物体出现延迟一帧← 正确答案
  • B.快速移动物体闪烁← 正确答案
  • C.静态物体被错误剔除
  • D.查询开销成倍增加

答案:AB。复用旧结果必然导致可见性通知延迟一帧,快速移动的物体容易闪进闪出;静态物体遮挡关系稳定,反而安全;复用是用时间换成本,不会让查询开销增加。

Q7. 层次Z缓冲的每一层保存什么值最安全?

单选题 · single

  • A.区域内深度的平均值
  • B.区域内可见像素的数量
  • C.区域内离相机最远的深度← 正确答案
  • D.区域内离相机最近的深度

答案:C。保存区域内的最大深度(最远可见表面),再用物体最近深度与之比较:只有当物体比所有可见表面都远时才剔除,这是一个安全保守的判定。存最小深度会激进剔除,容易把可见物体误杀。

Q8. GPU 驱动剔除中,批量提交多个物体绘制命令依赖哪项能力?

单选题 · hard

  • A.实例化绘制 Instancing
  • B.间接绘制 IndirectDraw← 正确答案
  • C.遮挡查询 OcclusionQuery
  • D.延迟渲染 DeferredShading

答案:B。GPU 端剔除完成后,可见物体列表写入显存命令缓冲区,IndirectDraw 直接读取缓冲区参数批量绘制。Instancing 用于复用同一网格多份实例,不是剔除后批量提交的核心机制。

Q9. 以下场景哪种最适合遮挡剔除?

单选题 · mid

  • A.城市街区建筑密集区域← 正确答案
  • B.一望无际的空旷草原
  • C.全屏粒子特效动画
  • D.单物体填充整个屏幕

答案:A。遮挡剔除的收益来自“大量物体被挡住”。城市楼宇遮挡密集,剔除收益巨大;空旷草原几乎无遮挡,粒子与单物体场景也没有可剔除的冗余几何。

Q10. 遮挡剔除工程实践中,哪些做法是合理的?

多选题 · hard

  • A.静态场景烘焙遮挡数据← 正确答案
  • B.查询结果延迟一帧使用← 正确答案
  • C.所有物体每帧重新查询
  • D.动态物体合并区域查询← 正确答案

答案:ABD。静态烘焙、延迟一帧、动态分区合并都是降低查询开销的成熟实践。所有物体每帧重新查询会带来巨大 GPU 开销和 CPU 同步压力,并不合理。