Skip to main content

Chapter 50: Hardware Occlusion

遮挡剔除把“看得见什么”提前变成一个可验证的渲染决策。本章围绕一帧城市街区渲染展开:相机站在街角,近处建筑已经写入深度,远处几组建筑、广告牌、车辆和树木在视锥内,但其中一部分被街角建筑完全挡住。读完本章后,读者应能追踪这些候选物体从 CPU 可见性列表进入 GPU 深度测试、查询结果、延迟回读、条件绘制和最终提交管线的路径。

Hardware Occlusion 指利用 GPU 已有的深度测试、粗粒度深度层级、查询计数和条件执行能力判断候选物体是否被遮挡。它服务的对象是渲染负载,输入是候选物体的保守代理几何、上一帧或当前帧深度、相机参数和场景分组,输出是“绘制、跳过、延后验证或保守保留”的决策。这个决策的价值取决于它节省的 draw、vertex work、fragment work、材质绑定和后续 pass 成本是否超过查询本身的额外开销。

本章的主线场景是一组 OcclusionCandidate:每个候选项包含包围盒、对象列表、LOD 信息和上一次可见状态。管线先用 frustum、distance、LOD 和 portal 得到候选集合,再用硬件遮挡测试判断哪些候选项进入主渲染。这个顺序可以把遮挡剔除放回整个 frame,而非把 occlusion query 当成单独技巧。

NVIDIA GPU Gems 2 Chapter 6 对硬件遮挡查询的经典问题给出过清晰归纳:查询可以通过包围盒判断复杂对象是否有像素通过深度测试,但朴素等待查询结果会引入 CPU stall 和 GPU starvation。Microsoft Direct3D 12 Predication Queries 展示了现代 API 中使用 query heap、binary occlusion query、结果 buffer 和 predication 做条件绘制的具体路径。本章把这些材料整理成工程上可迁移的判断顺序。

50.1 硬件级遮挡测试的原理与实现

硬件级遮挡测试的核心对象是 depth buffer。depth buffer 保存当前已渲染表面的深度信息,深度测试把新片元的深度与已有深度比较,只有通过测试的片元继续进入后续写入。遮挡剔除利用这个事实:如果一个候选物体的保守包围体在屏幕上没有任何 sample 通过深度测试,那么被包围的真实几何也可以从本轮主渲染中移除。

贯穿场景中,街角建筑先以 depth prepass 或主 pass 前段写入深度。远处的办公楼组位于视锥内,frustum culling 会保留它;distance culling 也会保留它;LOD 会给它选择较低细节层级。遮挡测试继续问一个更具体的问题:这个办公楼组的屏幕投影是否完全落在近处建筑已写入的深度后方。如果答案成立,主 pass 中办公楼组的材质 shader、纹理采样、阴影查询和后处理输入都可以减少。

一次 query-based 硬件遮挡测试通常包含五个动作。第一,准备 depth buffer,让主要遮挡体已经写入可信深度。第二,为候选物体提交一个保守代理,常见代理是 AABB、OBB 或简化凸包。第三,关闭颜色写入,并在常见实现中关闭深度写入,让查询代理只参与深度测试计数。第四,开启 query,绘制代理,再结束 query。第五,把可见 sample 计数或二值结果写入结果 buffer,并在后续 frame 或后续阶段使用。

下面的图只描述本章讨论的遮挡路径。它省略阴影、透明物、后处理和资源上传,因为这些对象属于下游 pass,对当前可见性判断没有直接贡献。

图中的“保守代理”必须覆盖真实物体可能出现的屏幕范围。代理偏小会把真实可见的物体误判为遮挡;代理偏大通常带来更多 false visible,也就是物体被保留下来继续渲染。工程上宁可接受 false visible 的额外开销,也要控制 false occluded 造成的物体缺失。这个边界决定了遮挡测试常用包围盒,而非随意缩小的近似网格。

Early-Z 是硬件在片元 shader 前尽早执行深度拒绝的路径。它对主渲染的意义是减少被遮挡片元进入昂贵 shader 的数量;对 occlusion query 的意义是让代理几何以较低 shader 成本完成深度测试。很多查询 pass 会使用极简 shader、关闭颜色写入、关闭深度写入,并把 rasterization 和 depth test 作为主要工作。

HZB(Hierarchical Z-Buffer)是深度 buffer 的层级表达。它把全分辨率深度逐级降采样成更粗的 mip,每个层级保存一个 tile 内的保守深度值。候选包围体投影到屏幕后,可以选择与其屏幕尺寸匹配的 mip 层级,用少量采样判断整个投影区域是否被近处深度覆盖。HZB 的优势是结果可以停留在 GPU 上,适合 compute culling、GPU-driven rendering 和 indirect draw;代价是需要构建深度金字塔,并处理 reversed-Z、深度比较方向、近远平面精度和保守规则。

Occlusion query 与 conditional rendering 之间的关系是“测量”和“消费”。query 负责统计代理几何有多少 sample 通过深度测试,conditional rendering 或 predication 负责根据结果决定后续 draw 是否执行。Direct3D 12 的示例把 query heap、binary occlusion query、ResolveQueryDataSetPredication 串起来,说明查询结果可以进入 GPU 侧条件绘制路径。其它 API 中同类功能的名称、扩展状态和可用边界不同,工程上应把它们抽象成三类对象:查询对象、结果存储和条件执行点。

50.2 GPU Occlusion 的实现方式对比

GPU Occlusion 的实现方式可以按“谁生成遮挡证据、谁消费结果、结果是否回到 CPU”来比较。query-based occlusion、HZB compute culling、software raster occlusion 和 cluster occlusion 都回答同一个问题:候选物体是否在已有遮挡体后方。差异在于它们使用的资源路径和延迟模型。

Query-based occlusion 依赖图形管线的深度测试和 API query。它适合已有传统 draw-call 管线、候选对象数量中等、场景有大块遮挡、对象组收益明显的场景。它的输入是代理几何,输出是 query result buffer。它的主要风险是查询数量、代理 raster cost、结果可用时间和 CPU/GPU 同步。GPU Gems 的经典结论可以转成工程判断:逐物体立即查询并等待结果会把可见性判断变成同步点;把对象组织成层级、复用上一帧结果、把可见节点立即渲染,才能把查询延迟隐藏在其它 GPU 工作中。

HZB compute culling 把深度 buffer 变成 shader 可读纹理,再用 compute shader 读取 depth pyramid 判断候选包围体。它适合 GPU-driven 场景:物体列表、包围体、LOD、instance data 和 indirect argument buffer 已经在 GPU 上,剔除结果直接写入 compacted visible list 或 indirect draw 参数。它的主要成本来自 HZB 构建、候选投影计算、buffer 写入与同步 barrier。它的收益出现在候选数量大、CPU 提交成本受限、主 draw 可以通过 indirect 或 multi-draw 聚合提交的场景。

Software raster occlusion 指在 CPU 或 GPU compute 中使用软件光栅化维护一个低分辨率深度表示,再测试候选包围体。CPU 版本常用于引擎可见性系统或大型开放世界的 coarse culling,优势是结果直接服务 CPU 场景遍历;成本是 CPU raster 与场景更新会竞争主线程或 job 系统预算。GPU compute 版本更接近 HZB,但可以使用自定义 tile、mask 和保守规则。它适合对平台行为需要高度可控、候选层级较粗、低分辨率遮挡足以支撑决策的场景。

Cluster occlusion 把对象进一步拆成 cluster、meshlet 或 chunk,并在 cluster 粒度上测试可见性。它常与 mesh shader、GPU culling、LOD 和 indirect draw 组合使用。它的收益来自粒度更细:一个建筑整体可见时,背面房间、内侧墙体或远处小块 cluster 仍可被移除。它的成本也来自粒度更细:更多包围体、更多状态更新、更多 compaction 和更复杂的调试证据。

实现方式主要输入结果消费位置适合场景主要风险
Query-based代理几何、depth buffer、query pool / query heapCPU 下一帧、GPU predication、条件绘制传统渲染器、对象组较大、遮挡明显查询延迟、额外 draw、等待结果
HZB computedepth pyramid、候选包围体、GPU bufferGPU visible list、indirect argsGPU-driven、大量实例、compute cullingHZB 构建、barrier、保守深度规则
Software raster低分辨率深度、遮挡体、候选包围体CPU 场景系统或 GPU buffer粗粒度可见性、跨平台控制raster 成本、精度、维护复杂度
Cluster occlusioncluster bounds、meshlet metadata、depth / HZBcluster list、mesh shader / indirect draw高密度几何、细粒度 streamingmetadata 成本、调试难度、false visible

在城市街区场景中,传统 query-based 路径适合整栋楼组、地下通道段、大型室内房间这类“一个 query 可以覆盖很多 draw”的对象。HZB compute 适合上千个路灯、车辆、窗台模块和植被实例,因为它们的剔除结果可以直接写入 GPU instance list。Cluster occlusion 适合高密度建筑外立面,把楼体分成若干 cluster 后,街角遮挡会移除背街面的细节。software raster 适合 CPU streaming 系统提前判断哪些街区 chunk 进入资源加载队列。

选择实现方式时,先看结果消费端。如果结果最终要回 CPU 控制场景树,query 或 CPU software raster 更容易接入。如果渲染器已经使用 indirect draw,HZB compute 和 cluster occlusion 更容易把结果留在 GPU。再看候选粒度:对象组越大,query-based 越容易回本;候选越多越细,compute 路径越容易摊平固定成本。最后看调试证据:query-based 可以直接记录每个代理的 sample count;HZB 和 cluster 路径需要可视化深度 mip、候选投影矩形、选用 mip 层级和剔除原因。

50.3 最小化查询延迟与开销

遮挡测试的最大工程风险来自等待。CPU 发出 query 后,GPU 需要等前面的命令执行到该点,再 rasterize 代理,再写出结果;CPU 若立即读取,会把命令队列中的并行性压成串行。GPU Gems 把这个问题分成 CPU stall 和 GPU starvation:CPU 等结果时不再提交新工作,GPU 随后也可能因为命令不足而空闲。现代显式 API 让同步点更可见,问题仍然存在。

减少延迟的第一条原则是使用 depth prepass 或可靠的早期深度来源。遮挡测试依赖“遮挡体已经写入深度”这个前提。城市街区中,近处建筑、地面高墙和大型静态遮挡体应先写深度;远处小物体和透明物体不适合作为主要遮挡证据。depth prepass 的成本要与收益配对评估:如果场景 fragment shader 昂贵且 overdraw 高,depth prepass 能同时服务 early-z 和 occlusion;如果场景很开阔,prepass 可能只增加一次几何提交。

第二条原则是使用 proxy bounds,并控制代理成本。代理几何应保守覆盖对象,顶点数要少,shader 要简单,颜色写入关闭。Direct3D 12 的 predication 示例中,查询状态关闭 render target 写入和 depth 写入,让查询本身不改变可见输出。这条路径可以迁移成通用做法:查询 pass 只回答“有多少 sample 通过深度测试”,其它输出都应从状态上关闭。

第三条原则是把查询结果延迟一帧或多帧使用。frame-lagged result 利用时间连续性:上一帧可见的对象在当前帧很可能仍然可见,上一帧被遮挡的对象也可能继续被遮挡。工程上常用的安全策略是“上一帧可见则先绘制并继续验证,上一帧遮挡则先发起查询并等待后续修正”。这样可见对象不因等待而阻塞主渲染,被遮挡对象则通过查询验证其状态是否仍成立。

第四条原则是 batching。每个 query 都带来命令、状态切换和结果管理成本。把候选物体按空间层级、材质提交组、cell、chunk 或 LOD group 聚合,可以让一个查询覆盖多个 draw。聚合过粗会留下很多 false visible,聚合过细会产生大量 query。可复用判断是:一个 query 至少应有机会节省多次真实 draw、昂贵 shader 或大量 fragment;如果它只节省一个很便宜的小对象,通常把对象保留更稳定。

第五条原则是使用 conservative fallback。查询结果过期、结果 buffer 尚不可用、相机瞬移、遮挡体大幅移动、门状态变化或 streaming chunk 刚加载时,应把候选项临时标记为可见或待验证。保守保留会带来额外渲染成本,代价可控;错误移除会造成画面缺失,用户能直接看到。相机快速转身时尤其要扩大保守窗口,例如将上一帧遮挡结果失效,改用 frustum、distance 和 portal 结果先提交一帧。

下面的伪代码展示一个 frame-lagged query 的最小控制路径。它没有绑定具体 API,只表达状态流向:上一帧结果驱动当前帧提交,当前帧继续写出下一帧可用的查询结果。

struct OcclusionCandidate {
Bounds proxy_bounds;
bool visible_last_frame;
bool query_ready;
bool query_visible;
DrawGroup draw_group;
};

void submit_frame(vector<OcclusionCandidate>& candidates, DepthBuffer depth) {
build_depth_for_major_occluders(depth);

for (OcclusionCandidate& item : candidates) {
bool conservatively_visible = item.visible_last_frame || !item.query_ready;
if (conservatively_visible || item.query_visible) {
submit_draw_group(item.draw_group);
}
submit_occlusion_query(item.proxy_bounds, depth);
}

resolve_queries_for_next_frame(candidates);
}

这段代码对应三个判断。visible_last_frame 让当前帧先保持连续输出,减少等待;query_ready 保护结果尚未写回的边界;submit_occlusion_query 每帧持续更新状态,让后续帧逐步收敛。真实引擎还会加入层级节点、屏幕尺寸阈值、相机速度阈值和门状态版本号,但主干关系仍然是“保守提交当前帧,异步修正未来帧”。

查询开销还要放进 profiler 里验证。可观察证据包括 query pass draw 数量、代理顶点数、代理覆盖像素数、depth prepass 时间、main pass 被减少的 draw 数、被减少的 fragment 时间、CPU 等待时间和 GPU bubble。若 query pass 本身占用明显时间,且 main pass 没有对应下降,说明遮挡粒度、场景遮挡率或使用时机需要重设。

50.4 复杂场景下 Occlusion 的调优实践

复杂场景调优的起点是选遮挡体。遮挡体应稳定、大、靠近相机、能覆盖后方大量候选对象。城市街区中,建筑外墙、高架桥、山体、隧道入口和大型车辆可以提供有效深度;细树枝、透明玻璃、铁丝网、粒子和 alpha-tested 植被通常不适合作为主遮挡证据。原因在于它们的深度覆盖不连续,且视觉上可能允许后方物体透出。

城市街区的主要问题是遮挡强度随相机高度变化。街面视角下,近处建筑形成稳定遮挡,整条背街的车辆和立面细节可以按 chunk 查询;无人机视角下,俯视能看到大量屋顶,遮挡剔除收益下降。调优时应把“街面移动”和“高空飞行”分成不同 profile:街面启用更积极的 occlusion,飞行视角降低查询数量,更多依赖 frustum、distance 和 LOD。

森林场景的主要问题是遮挡体分散。单棵树的树冠和树干很难稳定遮挡大量对象,大量 alpha-tested 叶片又会削弱深度证据。较稳的做法是用地形起伏、岩石、山坡、密林 chunk 的粗包围体做 coarse occlusion,再配合 distance LOD 和 impostor。森林里逐树 query 成本通常高于收益;按林区 cell 或 terrain tile 测试更容易形成可观察收益。

室内关卡的主要问题是结构化遮挡已经很强。房间、墙、门、走廊天然适合 portal 和 cell culling,hardware occlusion 应作为动态验证层。门关闭时,portal 结果可以直接减少可见 cell;门打开、爆炸破墙或大型动态遮挡物移动时,occlusion query 可以验证某个 cell 或 object group 是否真的被当前深度覆盖。这样 portal 提供拓扑约束,hardware occlusion 提供图像空间证据。

遮挡粒度的选择应跟资源成本绑定。大型建筑组的粒度可以是 block 或 facade chunk,因为一个决策会影响几十到上百个 draw。车辆可以按停车场行、交通队列或 instance batch 分组,因为单车 query 很难回收成本。室内小道具可以跟 room cell 绑定,而非每个杯子、椅子单独测试。粒度越细,越要有 GPU-driven compaction 或 indirect draw 支撑,否则 CPU 提交管理会抵消收益。

复杂场景还需要调试可见性错误。推荐每帧记录四类证据:候选代理的屏幕矩形、使用的深度来源、查询结果或 HZB 判定值、最终绘制状态。可视化时用固定颜色区分 visible、occluded、fallback visible 和 result pending。若用户看到物体闪烁,优先检查相机速度阈值、上一帧结果失效、代理是否偏小、near plane 变化、depth pyramid 比较方向和透明遮挡体参与情况。

下面是一个场景调优表。它把“看起来像遮挡问题”的现象落到可检查对象上。

现象优先检查对象常见原因稳定处理
转身时物体短暂消失上一帧结果、相机速度、fallback 标记旧结果仍被用于新视角相机大角速度时保守保留一帧
远处建筑一直未被剔除代理屏幕范围、遮挡体深度、HZB mip代理过大或深度未覆盖分组重设,检查 depth prepass
查询 pass 时间升高query 数量、代理覆盖像素、状态切换粒度过细或大代理过多合并候选,跳过小屏幕对象
树林剔除收益低alpha 深度、地形遮挡、chunk 划分遮挡体不稳定使用地形和 chunk coarse occlusion
室内房间漏绘portal 状态、门状态、cell bounds拓扑结果与深度结果版本不一致给 cell/door 状态加版本失效

调优完成后,应回到 frame 时间分解,而非只看剔除了多少对象。有效的遮挡剔除会让 main pass draw 数、visible instance 数、fragment shader 时间、overdraw heatmap 或后续 G-buffer 写入下降。若剔除数量很大但 frame 时间无变化,说明被剔除对象原本不在瓶颈路径上;此时应把优化预算转到材质 shader、shadow pass、post process、streaming 或 CPU 提交。

50.5 与传统剔除策略结合的优化方案

遮挡剔除应放在完整可见性管线中。传统剔除策略各自回答不同层级的问题:frustum culling 判断对象是否在相机视锥内;distance culling 判断对象是否超过有效观察距离;LOD 判断对象使用哪个细节层级;portal 判断室内 cell 是否通过门洞或窗口连通;occlusion 判断视锥内对象是否被当前深度覆盖;GPU-driven culling 把这些判断尽量搬到 GPU 侧生成最终可绘制列表。

合理顺序通常从便宜、确定、粗粒度的判断开始,再进入昂贵、依赖 frame 状态的判断。城市街区可以使用这样的顺序:先按 streaming chunk 和 cell 得到可能集合,再做 frustum 和 distance,接着选择 LOD,然后对大组对象做 portal 或 occlusion,最后把可见结果写成 draw list 或 indirect argument。这个顺序让 occlusion 处理已经缩小过的候选集合,查询数量自然下降。

图中的 occlusion 位置不是固定的。室内场景可以先 portal,再 occlusion 验证动态遮挡;开放城市可以先 frustum 和 distance,再对街区 chunk 做 occlusion;GPU-driven renderer 可以在 compute 中合并 frustum、LOD 和 HZB 测试,然后直接写 indirect args。可迁移的原则是:每一步都要减少后续更贵步骤的输入规模,并保留足够证据解释某个对象为何被绘制或跳过。

与 LOD 结合时,遮挡结果可以影响细节预算。一个对象被判定为可见后仍要根据屏幕尺寸和距离选择 LOD;一个对象被判定为遮挡时,可以延后材质、动画和骨骼更新中的一部分工作。这里要区分渲染可见性与系统更新可见性:某个角色当前被墙挡住,渲染 mesh 可以跳过,但 AI、物理或网络状态仍可能需要更新。引擎应让 occlusion 结果只影响明确绑定到渲染路径的工作。

与 portal 结合时,portal 负责拓扑过滤,hardware occlusion 负责图像空间验证。房间系统先根据相机所在 cell 和门洞连通性找出可能可见 cell,再对大房间、长走廊、动态遮挡后的 cell group 做 occlusion。这样可以减少 query 数量,也能处理门打开后仍被大型物体遮挡的情况。若门关闭,portal 结果已经足够强,occlusion 查询可以直接跳过。

与 GPU-driven culling 结合时,occlusion 的输出应变成 GPU buffer,而非回到 CPU 后再重新提交 draw。典型路径是:compute shader 读取对象 bounds、相机矩阵和 HZB,判断可见性后写 visible instance list,再用 prefix sum 或 append buffer 生成 compacted list,最后通过 indirect draw 绘制。这个路径需要额外关注 barrier:HZB 构建完成后才能被 culling 读取,visible list 写入完成后才能被 indirect draw 消费。

最终的可解释管线应能为任意对象给出一条决策记录:它属于哪个 chunk,是否通过 frustum,距离和 LOD 是什么,portal 或 cell 是否允许,occlusion 使用了哪个代理和哪份深度,结果来自当前帧还是上一帧,最终 draw 是否提交。这个记录比“剔除了多少对象”更有调试价值,因为它可以定位画面缺失、闪烁和性能无收益的具体原因。

本章建立的结论是:Hardware Occlusion 应被设计成一条围绕 depth、proxy、query/HZB、延迟消费和保守回退组织的 frame 内可见性路径。它在遮挡强、候选分组合理、结果消费路径顺畅时能减少真实渲染负载;在开阔场景、粒度过细或同步等待严重时会转化为额外成本。稳定方案来自顺序化判断:先用便宜剔除缩小候选,再用硬件遮挡验证高收益组,最后用工具证据确认节省发生在真正的瓶颈上。

最小自检任务

给定一个室内商场场景:相机在一楼走廊,近处墙体和扶梯已经完成 depth prepass。走廊尽头有一个大型中庭 cell,里面有 200 个独立商铺道具 draw。当前帧中庭入口被一块临时施工挡板完全遮住,但挡板每隔几秒会移动。请设计一个最小 hardware occlusion 方案,说明候选粒度、代理几何、查询时机、结果消费方式、fallback 规则和需要观察的性能证据。

答案要点

候选粒度应选择中庭 cell 或商铺道具组,而非 200 个道具逐个查询,因为一个 cell 级结果可以影响大量 draw。代理几何使用覆盖中庭入口和 cell bounds 的保守包围体,查询 pass 关闭颜色写入和深度写入,只使用已完成的 depth prepass 进行深度测试。查询结果采用延迟消费:上一帧中庭可见时当前帧先保守绘制并继续验证;上一帧中庭被遮挡时当前帧先发起查询,并在结果 ready 后更新下一帧状态。施工挡板移动、门洞状态变化或相机快速转向时,把中庭临时标记为 fallback visible,防止旧遮挡结果造成漏绘。性能证据应同时观察 query pass 成本、中庭 draw 数下降、main pass fragment 时间下降、CPU 等待时间和可见性调试颜色;只有主渲染瓶颈同步下降,cell 级遮挡方案才算有效。

本章知识点总结

  • 深度证据:硬件遮挡测试依赖已写入的 depth buffer,遮挡体深度必须先于候选代理参与测试。
  • 保守代理:代理几何应覆盖真实物体可能出现的屏幕范围,偏大的代理带来额外渲染,偏小的代理会造成漏绘。
  • Query 路径:occlusion query 通过代理几何的深度测试统计可见 sample,再把结果写入可消费的结果 buffer。
  • 条件绘制:conditional rendering 或 predication 把查询结果转成后续 draw 是否执行的 GPU 侧决策。
  • HZB 路径:HZB compute culling 使用 depth pyramid 和候选包围体在 GPU 上生成 visible list 或 indirect 参数。
  • 延迟风险:立即读取查询结果会制造 CPU stall 和 GPU starvation,延迟消费能保留命令队列并行性。
  • 分组收益:一个遮挡查询应有机会减少多个真实 draw、昂贵 shader 或大量 fragment 工作。
  • Depth Prepass:depth prepass 同时服务 early-z 和 occlusion,但收益必须通过主 pass 时间下降验证。
  • 场景差异:城市街面、森林和室内关卡的遮挡体稳定性不同,剔除粒度和启用 profile 应分别设置。
  • Portal 组合:portal 提供室内拓扑过滤,hardware occlusion 提供当前深度图像空间验证。
  • GPU Driven:GPU-driven 管线应让 occlusion 结果留在 GPU buffer,并通过 indirect draw 消费。
  • 可解释记录:稳定可见性系统需要记录 chunk、frustum、distance、LOD、portal、occlusion 和最终 draw 决策。