Skip to main content

Chapter 48: Occlusion Queries

一帧大型城市场景中,相机站在街角,近处一排建筑遮住了后方商店、车辆和广告牌。视锥剔除已经移除了相机背后的对象,后方仍有大量对象落在视锥内;它们经过顶点处理、光栅化和深度测试后,多数样本被近处建筑挡住。这类对象在数学上“可投影到屏幕”,在最终图像上却没有贡献。Occlusion Query 要解决的主问题是:如何让 GPU 把“某个对象或代理体是否有样本通过深度测试”转成下一帧可用的可见性证据。

本章读完后,读者应能追踪一次 Occlusion Query 从代理几何绘制、深度测试统计、结果返回到下一帧剔除决策的完整路径;能区分 OpenGL、Vulkan、DirectX 中查询对象、查询池、查询堆和可用性标记的职责;能判断查询带来的同步开销是否超过被剔除对象节省的 draw、shader 和带宽成本。

贯穿本章的材料是一帧“街区遮挡场景”:相机前方有一栋大楼,楼后有 200 个可渲染对象。引擎为每个建筑组、车辆组或道具组准备一个 proxy bounds,用低成本包围盒向深度缓冲发起查询。查询结果延迟一到两帧读取,用上一轮可见性决定本帧是否提交真实材质渲染。这个材料覆盖了 Occlusion Query 的收益、延迟、误判、API 映射和大型场景落地边界。

48.1 Occlusion Query 的工作机制

Occlusion Query 的工作机制可以定义为一次由 GPU 记录的可见样本统计。应用在命令流中标记查询开始和结束,期间绘制一个对象或代理几何;GPU 在片元进入 per-fragment tests 后,根据深度和模板等测试结果统计通过的样本数量,或记录是否至少有一个样本通过。查询结果随后进入 API 管理的查询对象、查询池或查询堆,由 CPU 在之后读取,或由 GPU 复制到缓冲区继续参与条件渲染。

这个机制的关键位置在深度测试之后。代理盒进入顶点处理和光栅化后会产生候选片元;候选片元和当前 depth buffer 比较,只有通过测试的样本才增加计数。OpenGL 的 GL_SAMPLES_PASSED 会记录通过深度测试的样本数,GL_ANY_SAMPLES_PASSED 会记录布尔结果;Vulkan 的 Occlusion Queries 记录 passing samples,并通过 VK_QUERY_CONTROL_PRECISE_BIT 控制是否要求精确样本数;Direct3D 12 提供计数型 occlusion query,也提供 binary occlusion query,把结果压缩成零或一。相关行为可以从 OpenGL glBeginQueryVulkan QueriesDirect3D 12 Queries 对照得到。

在街区遮挡场景中,查询的输入通常采用低成本代理体,直接使用带材质的真实对象会把原本要节省的工作重新放回管线。真实对象可能包含复杂顶点、透明材质、alpha test、昂贵 pixel shader 和多张纹理。Occlusion Query 的目标是判断“这个对象组是否值得渲染”,因此输入通常是一组保守 proxy geometry,例如 AABB 包围盒、OBB 包围盒、建筑 cell bounds 或 cluster bounds。代理几何用极简 pipeline 绘制:关闭颜色写入,保留深度测试,通常关闭或限制深度写入,让查询只观察当前 depth buffer 对代理体的遮挡关系。

一次典型查询路径如下图。图中把真实渲染和查询路径分开,是为了说明查询结果服务下一次提交决策,通常不服务同一批命令的立即分支。

这个图的核心路径是 depth buffer → proxy bounds → query result → future draw decision。查询发生在 GPU 时间线上,CPU 读取发生在更晚的应用时间线上。只要读取动作强行等待当前帧尚未完成的 GPU 工作,Occlusion Query 就会从“剔除工具”变成“同步点”。因此它的正确使用方式要求把结果当成延迟证据,服务下一次 draw decision。

Occlusion Query 统计的是 sample,可见性判断通常转换成布尔值。对于没有 MSAA 的 render target,一个通过深度测试的 fragment 可近似看成一个通过样本;对于 MSAA,一个像素包含多个 sample,计数会随 coverage 和实现细节变化。工程上常见判断是 visible = passedSamples > threshold。阈值可以取 0,也可以取小的面积门槛,例如 8、16 或 64 个 samples,用来压低一两个边缘样本导致的闪烁。阈值越高,越倾向于把小面积露出的对象暂时视为不可见;阈值越低,越倾向于保守提交真实渲染。

查询结果与最终视觉结果之间存在两个边界。第一,代理几何和真实几何不同,代理盒可能比真实对象更大,因此会产生保守可见。代理盒只要有一个角落露出,查询就可能返回可见,真实对象却可能完全被挡住。第二,透明物、alpha test、decal、粒子和后处理路径通常不适合作为查询依赖对象。Occlusion Query 依赖 depth buffer 形成遮挡关系;大量透明对象位于透明 pass,深度写入策略不同,把它们纳入查询决策容易让结果失去稳定含义。

一个最小伪代码可以把机制压缩成三步:先填充主要遮挡物的 depth,再查询代理体,最后用较晚帧的结果决定真实对象组。

// Simplified pseudocode: query visibility for an object group.
renderDepthForLargeOccluders(frame.depthBuffer);

QueryId query = queryPool.allocate(group.id, frame.index);
beginOcclusionQuery(query);
drawProxyBounds(group.bounds, DepthTest::LessEqual, ColorWrite::Disabled);
endOcclusionQuery(query);

VisibilityResult previous = queryPool.tryRead(group.id, frame.index - 1);
if (previous.available && previous.passedSamples > visibilityThreshold) {
drawRealObjects(group.objects, materialPipeline);
} else if (!previous.available) {
drawRealObjects(group.objects, conservativeFallbackPipeline);
}

这段伪代码的判断点在 tryRead。查询的统计发生在当前帧命令流里,真实对象提交使用上一帧或更早结果。previous.available 为假时,稳定做法是提交保守 fallback,保住画面正确性,再用后续帧的结果收敛。这样会牺牲一小段潜在剔除收益,但能控制等待 GPU 的同步成本。

48.2 Occlusion Query 的 API 适配

不同图形 API 都提供 Occlusion Query,但它们把“查询生命周期”和“结果读取”放在不同对象中。API 适配时应抓住四个对象:查询存储位置、命令标记方式、结果可用性、结果传输路径。只要这四个对象对应清楚,OpenGL、Vulkan 和 DirectX 的差异就能落到工程封装。

OpenGL 使用 query object。应用通过 glGenQueriesglCreateQueries 创建 query id,在命令流中调用 glBeginQuery(target, id)glEndQuery(target) 标记范围。target 决定语义,例如 GL_SAMPLES_PASSED 返回通过样本计数,GL_ANY_SAMPLES_PASSED 返回布尔结果,GL_ANY_SAMPLES_PASSED_CONSERVATIVE 允许实现用更保守的快速路径。读取时,GL_QUERY_RESULT_AVAILABLE 用来判断结果是否立即可用,GL_QUERY_RESULT 会取得结果;根据 OpenGL 文档,直接查询 GL_QUERY_RESULT 可能隐式 flush 并等待查询范围内渲染完成,因此高频当前帧读取会制造 CPU-GPU 同步。

Vulkan 使用 VkQueryPool 管理一组同类型查询。应用创建 queryType = VK_QUERY_TYPE_OCCLUSION 的 query pool,在 command buffer 中使用 vkCmdBeginQueryvkCmdEndQuery。读取可以走 vkGetQueryPoolResults 到 host,也可以用 vkCmdCopyQueryPoolResults 把结果复制到 device buffer。Vulkan 查询本身是异步操作,规范明确结果和 availability 存储在 Query Pool 中;VK_QUERY_RESULT_WITH_AVAILABILITY_BIT 可以让结果旁边附带可用性标记,VK_QUERY_RESULT_WAIT_BIT 会等待结果可用。封装层应把 WAIT 作为显式调试或离线统计选项,常规渲染路径优先读取 availability。

Direct3D 12 使用 query heap。应用创建 D3D12_QUERY_HEAP_TYPE_OCCLUSION 的查询堆,在 command list 中用 BeginQueryEndQuery 包住代理绘制,再用 ResolveQueryData 把结果解析到 buffer。D3D12 的文档还给出 D3D12_QUERY_TYPE_BINARY_OCCLUSION,它返回 0 或 1,适合只关心“是否有样本通过”的 predication 策略。D3D12 将 query 写内存与 predication 读内存拆开,封装层需要把结果解析 buffer 的生命周期、resource state 和 fence 时序纳入 frame graph。

三个 API 的对照可以压缩成下面这张表。表格只覆盖本章需要的遮挡查询路径,不覆盖 timestamp、pipeline statistics 或 transform feedback 查询。

维度OpenGLVulkanDirect3D 12
存储对象query objectVkQueryPoolquery heap
标记范围glBeginQuery / glEndQueryvkCmdBeginQuery / vkCmdEndQueryBeginQuery / EndQuery
计数语义samples passed 或 any samplespassing samples,可选 preciseocclusion count 或 binary occlusion
可用性读取GL_QUERY_RESULT_AVAILABLEavailability bit 或返回 VK_NOT_READY通过 resolved buffer 与 fence 时序判断
常见风险GL_QUERY_RESULT 触发等待WAIT_BIT 触发等待,pool reset 时序复杂resolve buffer 和 fence 管理错误

API 封装可以定义统一接口,同时内部需要保留差异。一个合适的抽象应暴露 BeginVisibilityQuery(groupId)EndVisibilityQuery(groupId)TryReadVisibility(groupId, frameLag)ResetQueries(frameIndex)。OpenGL 后端在 TryReadVisibility 中检查 GL_QUERY_RESULT_AVAILABLE;Vulkan 后端读取带 availability 的结果或处理 VK_NOT_READY;D3D12 后端检查对应 frame fence 是否完成,再读取 resolved buffer。这个抽象的输出是统一的 availablepassedSamplesbinaryVisiblesourceFrame,由上层 visibility system 决定提交真实 draw 或采用 fallback。

查询结果的精确性也需要在 API 层保持边界。Vulkan 在未设置 VK_QUERY_CONTROL_PRECISE_BIT 时,允许实现对非零通过样本返回任意非零值;OpenGL 的 conservative any-samples 查询允许 false positive;D3D12 binary occlusion 直接放弃样本数,只保留是否通过。大型场景剔除通常只需要布尔可见性,精确计数对最终决策贡献有限。只有当系统用样本数估计屏幕面积、LOD 或 streaming 优先级时,才需要计数型查询与阈值策略。

API 适配还要处理命令范围。OpenGL query object 同一 target 同时只能有一个活跃查询;Vulkan 查询要求 query pool、command buffer 和 queue family 满足使用规则;D3D12 中需要 begin 和 end 成对出现在命令列表范围内。封装层应以 frame allocator 管理查询 id,按 frame index 轮转 query storage,并在提交前集中 reset。这样做的收益是把 query 生命周期从单个对象的渲染函数中抽离,交给 frame graph 或 visibility pass 统一调度。

48.3 降低 Occlusion Query 同步开销

Occlusion Query 的主要成本来自同步和额外绘制。额外绘制是代理几何产生的顶点、光栅化和深度测试成本;同步成本来自 CPU 在结果尚未准备好时读取,或 GPU 在条件渲染中等待查询结果。降低开销的核心策略是延迟读取、分层查询、利用时间连续性、降低代理成本,并给结果未就绪时准备保守 fallback。

延迟读取是第一条规则。当前帧发出的查询,通常在下一帧或下下帧读取。引擎可以维护 N 组 query pool 或 query heap,按照 frame index 轮转:第 f 帧写入 pool f mod N,第 f 帧读取 pool (f - lag) mod N。这样 CPU 读取时,GPU 已经有更高概率完成对应 frame 的命令。Vulkan 规范也明确应用可以对 query pool 做 double-buffer 使用并在读取帧末尾 reset;工程上常见做法是双缓冲或三缓冲查询池。

时间连续性是第二条规则。相机在连续帧中移动幅度有限,上一帧被大楼遮住的对象,本帧大概率仍被遮住;上一帧可见的对象,本帧大概率仍可见。系统可以把可见性状态分成 VisibleOccludedUnknownRecentlyVisibleRecentlyVisible 让对象在短时间内继续渲染,直到多个查询都显示不可见;Unknown 采用保守渲染并发出查询。这样做会减少单帧查询结果抖动带来的闪现,也能削弱查询延迟对画面的影响。

分层查询用于控制查询数量。街区场景中对 200 个对象逐个查询,会产生 200 次 begin/end、200 次代理绘制和 200 个结果读取。更好的路径是先查询 building block、street cell、BVH node 或 cluster group。父节点结果为不可见时,子对象整组跳过;父节点结果为可见时,再对高成本子节点继续查询或直接渲染。分层结构把查询数量和可见性收益绑定到空间层级,减少“低价值小物件大量查询”的开销。

下面的状态流可以作为查询调度的工程模板。它把查询发起、结果读取和真实渲染拆成跨帧状态,服务延迟决策。

这个状态机的核心是 UnknownRecentlyVisible。它们把不确定状态导向保守可见,保证画面连续。Occluded 也需要周期性复查;当相机移动、遮挡物移动、门打开、动态对象改变深度关系或过了固定间隔后,应重新发起查询。周期性复查会消耗少量额外代理绘制,但能让对象从遮挡状态恢复。

代理几何成本需要单独预算。AABB 盒子虽然顶点少,但在屏幕上可能覆盖大片区域,导致大量 depth test。细长物体用粗 AABB 会让查询面积过大,返回可见概率升高;复杂建筑用过细的多代理体会增加查询数。稳定做法是对对象按渲染成本和遮挡收益分级:大型、高 shader 成本、远距离且常被遮挡的对象优先查询;小物件、廉价材质、总是可见的前景对象直接渲染;透明对象和粒子系统用其它可见性规则处理。

读取策略需要把可用性作为第一条件。OpenGL 中先读 GL_QUERY_RESULT_AVAILABLE,可用后再读 GL_QUERY_RESULT;Vulkan 中优先带 availability 读取或处理 VK_NOT_READY,常规路径少用 VK_QUERY_RESULT_WAIT_BIT;D3D12 中等对应 fence 已完成后再读取 resolved buffer。调试模式可以强制 wait,用来验证剔除逻辑和统计收益;发布路径应让 wait 成为异常事件,并把次数、等待时长和触发对象写入 profiler marker。

Occlusion Query 的同步开销也可以通过批量化下降。查询 pass 集中处理所有 proxy bounds,使用同一套 pipeline state、同一 depth buffer、关闭颜色写入、按空间顺序或材质状态批量提交。批量查询让 pipeline 切换和 descriptor 切换减少,也让工具中更容易观察“visibility query pass”的 GPU 时间。RenderDoc、Nsight 或 Xcode GPU tools 可以作为可选观察入口,关注 query pass 的 draw count、depth-only cost、GPU duration、CPU wait、结果读取时序和真实 pass 的 draw 减少量。

收益判断应使用差值,评估被剔除数量之外的真实成本变化。一个对象组被查询剔除后的收益是:减少真实 draw submit、顶点处理、fragment shader、纹理采样、render target 写入和后续 pass 的对象参与。查询成本是:代理绘制、query begin/end、结果存储、结果读取、状态管理和可能同步等待。只有当节省的真实渲染成本持续高于这些成本时,Occlusion Query 才提升帧率。对移动 tile-based GPU 或带复杂 deferred/tile memory 行为的平台,还要单独测试 depth-only query pass 是否破坏 tile 局部性。

48.4 利用 Occlusion Query 优化大型场景

大型场景使用 Occlusion Query 的目标是把“视锥内但被遮挡”的对象从昂贵渲染路径移出。适合场景通常具备三个条件:有稳定的大遮挡物,有大量被遮挡的高成本对象,遮挡关系在连续帧中变化相对平滑。城市街区、室内走廊、多房间建筑、山体遮挡的开放世界、复杂工业设施都符合这个模型;空旷平原、天空盒、透明粒子场、快速切换镜头和大量小动态物体的场景收益较低。

在城市街区中,visibility system 可以按空间分块组织查询。每个 block 保存 bounds、对象列表、上次查询帧、上次结果、最近可见帧和估算渲染成本。渲染一帧时,系统先执行 frustum culling,得到视锥内 block;再用上一轮 occlusion result 把部分 block 标成 hidden;对 hidden block 以较低频率发出 proxy query;对 visible block 根据成本决定是否继续查询其子对象。这样形成 frustum → occlusion group → object draw 的递进路径。

室内场景可以把 Occlusion Query 和 portal/cell 思路结合。cell 或房间先通过 portal traversal 得到候选可见集合,再对远处房间 bounds 发起遮挡查询。门、墙、转角和楼梯天然提供稳定遮挡物,查询数量也能按房间层级控制。动态门状态改变时,相关 cell 的可见性缓存应失效;角色穿过门洞时,查询 fallback 应短时间偏保守,防止门后对象突然出现时延迟加载。

大型场景中的误剔除通常来自代理不保守、结果过期或深度缓冲输入不完整。代理不保守指 bounds 小于真实几何,真实对象露出但代理盒完全被挡住;结果过期指相机或遮挡物移动后继续使用旧的 hidden 状态;深度输入不完整指查询前 depth buffer 尚未包含主要遮挡物,例如 depth prepass 漏掉了建筑外墙,或查询在错误 pass 顺序中执行。工程上应让 proxy bounds 覆盖真实对象可见范围,让查询结果带 source frame,并规定 query pass 依赖哪一个 depth buffer 版本。

一个可复用的大型场景策略可以按以下顺序执行:先用 CPU frustum culling 去掉确定视锥外对象;再渲染主要遮挡物的 depth 或使用已有 depth prepass;然后对高成本对象组发起 proxy occlusion query;接着读取延迟帧结果并更新可见性缓存;最后提交真实 draw,并把 query pass 成本和被剔除对象成本写入 profiler。这个顺序让数学剔除、GPU 查询、资源状态和性能证据各自处于清晰层级。

下面的简化配置展示了一个 object group 可以保存哪些字段。它是可见性系统需要维护的最小状态示意,省略了具体 API 句柄和资源状态字段。

struct VisibilityGroup {
Bounds proxyBounds;
uint32_t objectStart;
uint32_t objectCount;
uint32_t estimatedDrawCost;
uint64_t lastQueryFrame;
uint64_t lastVisibleFrame;
uint32_t lastPassedSamples;
VisibilityState state;
};

这里的 estimatedDrawCost 用来决定查询优先级。昂贵对象组更值得发起查询,因为跳过一次真实渲染带来的收益更高。lastVisibleFrame 用来实现时间连续性,防止对象刚被判定为遮挡就立刻从画面中消失。lastPassedSamples 可以配合阈值判断屏幕面积;当样本数很小但非零时,对象可能只在边缘露出,系统可以延迟切到更低 LOD 或保持短暂可见。

收益评估应同时看 CPU 和 GPU。CPU 侧关注 draw call 数量、command recording 时间、visibility pass 调度时间和查询读取等待次数。GPU 侧关注 depth-only query pass 时间、真实 geometry pass 时间、fragment pass 时间、overdraw、带宽和 render target 写入。一个成功的城市查询系统应该看到:query pass 增加少量 GPU 时间,真实材质 pass 减少更多 GPU 时间,CPU 没有因结果读取产生明显等待,画面没有明显 popping。

Occlusion Query 和 HZB compute culling 的边界也需要清楚。Occlusion Query 由固定管线在深度测试后产生结果,易于接入传统渲染 pass,适合对象组级别的延迟可见性;HZB 路径把 depth pyramid 作为纹理输入,在 compute shader 中测试 bounds,结果更适合 GPU-driven indirect draw。Occlusion Query 的优势是 API 直接支持、实现路径短、验证直观;它的限制是结果延迟和大量小查询开销。大型现代引擎常把两者组合:早期或低复杂度路径使用 Occlusion Query,高密度场景或 GPU-driven pipeline 使用 HZB。

最终判断 Occlusion Query 是否适合某个大型场景,可以用一组可观察问题收束。场景是否有足够大的遮挡物写入 depth?被遮挡对象的真实渲染成本是否明显高于代理查询成本?查询结果是否可以延迟一帧使用?结果未就绪时是否有保守 fallback?profiler 是否显示 CPU wait 受控,真实 pass 时间下降?这些问题全部成立时,Occlusion Query 才从一个 API 功能变成稳定的可见性优化策略。

最小自检任务

给定一个室内商场场景:相机站在走廊中,前方墙体遮挡了一个大型商铺区域。商铺内有 80 个带 PBR 材质的商品模型,每个模型都在视锥内。引擎已经有 depth prepass,准备使用 Occlusion Query 判断整个商铺区域是否提交真实渲染。请设计一条查询路径,说明代理几何、深度状态、结果读取时机、fallback 和 profiler 观察点。

答案要点

代理几何应使用覆盖整个商铺区域的保守 bounds,优先按 room 或 shop group 查询;逐物体查询只适合少量高成本商品或需要精细控制的区域。查询 pass 应在 depth prepass 已经写入走廊墙体之后执行,使用 depth test,关闭颜色写入,通常不写入 depth,让代理体只统计通过样本。查询结果应延迟一帧或两帧读取,先检查 availability;结果未就绪时采用保守可见,继续提交真实商铺渲染并等待后续结果。结果为零且状态连续稳定时,可以跳过该商铺组的真实 PBR draw;结果为非零或超过阈值时提交真实对象。Profiler 需要观察 query pass GPU 时间、真实商铺 pass 节省的 GPU 时间、draw call 下降、CPU 读取等待次数、popping 或延迟恢复现象。关键边界是代理 bounds 必须覆盖真实商品可见范围,depth prepass 必须包含主要墙体遮挡物,动态门或相机快速移动时应让可见性缓存失效或转入保守状态。

本章知识点总结

  • 查询位置:Occlusion Query 在深度和模板等 per-fragment tests 之后统计通过样本,结果用于后续可见性判断。
  • 代理几何:查询输入通常使用保守 proxy bounds,降低真实材质、纹理采样和复杂 shader 的参与成本。
  • 样本语义:计数型查询返回通过样本数量,布尔查询返回是否存在通过样本,工程决策常用阈值转换成可见状态。
  • 延迟读取:当前帧写入的查询结果应在后续帧读取,降低 CPU 等待 GPU 的同步概率。
  • API 映射:OpenGL 使用 query object,Vulkan 使用 VkQueryPool,Direct3D 12 使用 query heap 和 resolved buffer。
  • 可用性优先:读取结果前应先判断 availability 或 fence 状态,常规渲染路径应减少强制 wait。
  • 分层查询:大型场景应先查询 cell、block、BVH node 或对象组,再决定是否下钻到子对象。
  • 时间连续性:上一帧可见性可作为当前帧的延迟证据,UnknownRecentlyVisible 状态应导向保守渲染。
  • 收益公式:真实 draw、shader、纹理和带宽节省必须高于代理绘制、查询存储、读取和同步成本。
  • 误剔除来源:代理 bounds 过小、结果过期和 depth 输入不完整都会让对象在应该可见时被跳过。
  • 场景适配:城市、室内和多遮挡物场景适合对象组级查询,空旷场景和大量透明对象收益较低。
  • 工具证据:RenderDoc、Nsight 或 Xcode GPU tools 可用于观察 query pass 时间、draw 减少、overdraw 变化和 CPU wait。
  • 工程边界:Occlusion Query 适合延迟可见性;GPU-driven 高密度剔除通常会进一步引入 HZB compute culling。