Blog·Studio
文章系列日历归档关于搜索
Blog·Studio

一个记录思考、笔记与作品的技术博客。

Connect

© 2026 · Blog Studio

鄂ICP备19019526号

crafted with care

stay curious ✦

  1. 文章
  2. ›深度金字塔与跨帧判定 2026:Hi-Z 遮挡查询取舍

Index

  • 一、可见性判定不是一个布尔值
  • 本文的边界
  • 二、深度金字塔:把一帧深度缓冲压成层级结构
  • 保守测试:证明遮挡,而不是怀疑遮挡
  • 三、GPU 遮挡查询:API 契约比直觉更微妙
  • 四、跨帧延迟:为什么「等一等」是唯一出路
  • 五、Godot 的 CPU 方案:Embree 光栅化 + 抖动 + 延迟消失
  • 六、Unity 的双帧深度纹理方案:另一种保守性组织方式
  • 七、失效模式清单:把错误分成两类来管理
  • 八、落地顺序与工程建议
  • 九、参考文献

深度金字塔与跨帧判定 2026:Hi-Z 遮挡查询取舍

遮挡剔除是一套带延迟的可见性判定系统,不是一个布尔开关。本文从 Vulkan 与 Direct3D 的查询契约出发,拆解深度金字塔如何用保守规则证明被挡住,GPU 遮挡查询如何拿到通过采样的真实计数,以及跨帧延迟为什么必须靠上一帧猜测、本帧验证、可见性上拉这套机制兜住正确性。文中以 Godot 的 Embree 光栅化与 Unity HDRP 的双帧深度纹理方案作对照,并给出推进顺序与失效清单。

2026年10月27日·约 36 分钟阅读·10,519 字·3 次阅读·博主
#游戏引擎与渲染
深度金字塔与跨帧判定 2026:Hi-Z 遮挡查询取舍

Index

  • 一、可见性判定不是一个布尔值
  • 本文的边界
  • 二、深度金字塔:把一帧深度缓冲压成层级结构
  • 保守测试:证明遮挡,而不是怀疑遮挡
  • 三、GPU 遮挡查询:API 契约比直觉更微妙
  • 四、跨帧延迟:为什么「等一等」是唯一出路
  • 五、Godot 的 CPU 方案:Embree 光栅化 + 抖动 + 延迟消失
  • 六、Unity 的双帧深度纹理方案:另一种保守性组织方式
  • 七、失效模式清单:把错误分成两类来管理
  • 八、落地顺序与工程建议
  • 九、参考文献

深度金字塔与跨帧判定 2026:Hi-Z 遮挡查询取舍

一、可见性判定不是一个布尔值

在实时渲染里,「这个物体可见吗」这句话听起来像一个布尔量,但只要把它写进引擎里,它就会立刻退化成一串必须逐帧维护、逐帧可能出错的状态。视锥剔除(frustum culling)是这类判定里最仁慈的一个:相机位置与朝向一确定,一个包围盒与视锥的相交关系就是一个纯几何的闭式解,不依赖任何历史帧,也不依赖任何上一帧渲染出的产物。遮挡剔除则不然。它要回答的是「这个物体是否被别的东西挡住」,而「别的东西」是什么、挡到什么程度,全部依赖于场景中其它物体的渲染结果;更麻烦的是,为了知道遮挡关系,你必须先把遮挡物画一遍——于是形成了一个天然的循环依赖。

打破循环只有两条路。第一条是把遮挡关系在编辑器或离线阶段预先算好,运行时直接查表;第二条是接受「上一帧的结果」,把遮挡判定变成一个带延迟的近似。这篇文章要讲的就是第二条路上的全部工程细节:深度金字塔(Hi-Z)如何把一帧的深度缓冲压缩成可查询的层级结构,GPU 遮挡查询(occlusion query)在 Vulkan 与 Direct3D 里的确切语义是什么,以及跨帧判定必然带来的延迟、滞后与闪烁该如何被工程化地管理。

先说清楚结论:遮挡剔除在任何现代渲染管线里都不是一个「优化开关」,而是一套需要被当成正确性代码来对待的子系统。它有两个方向完全不对称的失败模式——少画一个物体(漏剔除导致可见物体消失,画面穿帮)和多画一个物体(过度剔除导致性能浪费)代价差了几个数量级。NVIDIA 在 GPU Gems 2 第 6 章里用一句话概括了这种不对称性:如果我们猜某个节点可见但它实际被遮挡,只是浪费一点时间,图像仍然正确;但如果我们猜它被遮挡而它实际可见,我们就不会处理它,于是「一些物体会从图像里消失——这是我们必须避免的」。整个领域的工程设计,几乎都是在围绕这一句话展开。

本文的边界

本文只谈几何体的遮挡剔除(occlusion culling),不涉及环境光遮蔽(ambient occlusion)。这两个词在中文里都带「遮挡」,但它们是完全不同的两件事:环境光遮蔽是一种着色效果,衡量一个点被周围几何体环围的程度,不改变可见性判定;遮挡剔除改变的是「画不画」。Unity Manual 在这一点上分得很清楚——frustum culling 会排除不在相机视锥内的 Renderer,但「frustum culling 并不检查一个 Renderer 是否被其它 GameObject 遮挡」,于是 Unity 仍然可能在最终画面里看不见的 Renderer 上浪费 CPU 与 GPU 时间。

同样,本文也不谈屏幕空间的可见性效果(HBAO、GTAO 那一类),那是基于深度缓冲反推环境光遮蔽的屏幕空间后处理,与「哪些几何体需要被提交」无关。


二、深度金字塔:把一帧深度缓冲压成层级结构

Hi-Z(Hierarchical Z-buffer,层级 Z 缓冲)的思路朴素得近乎显然:既然单像素的深度信息不足以回答「这块区域被挡住了吗」,那就为深度缓冲建一棵 mip 金字塔,让查询可以从一个粗糙的层级开始,只在必要时才下潜到精细层级。代价是查询不再是 O(1),而是要付出一段「选层 + 采样」的成本。

金字塔的构建规则只有一条,但方向必须选对。构建时每一级取 2×2 邻域的极值(取最大还是取最小,取决于深度值的约定),从而保证:一个粗层级的 texel 覆盖的那块区域,其代表的深度是这个区域里的极端值,而不是平均值。平均值是绝对不行的——平均深度会同时低估和高估遮挡关系,导致两类错误同时发生。

Godot 引擎把这条规则实现得非常干净。其 RendererSceneOcclusionCull::HZBuffer 的 update_mips() 逐级向上折叠,每级取 2×2(奇数边长时补齐到 3×3)的最大值:

#define CHECK_OFFSET(xx, yy) \
    max_depth = MAX(max_depth, mips[mip - 1][MIN(prev_h - 1, prev_y + (yy)) * prev_w \
                                          + MIN(prev_w - 1, prev_x + (xx))])

float max_depth = mips[mip - 1][prev_y * sizes[mip - 1].x + prev_x];
CHECK_OFFSET(0, 1);
CHECK_OFFSET(1, 0);
CHECK_OFFSET(1, 1);
// 奇数宽高时补上 (2,0) (2,1) (0,2) (1,2) (2,2)

这里的 max_depth 是每个 texel 覆盖区域内最远的深度。选择 max 而不是 min,理由是保守性:如果某个 texel 的代表值是它区域内最远的深度,那么「这个 texel 的深度比物体最近点还远」并不意味着整块区域都远——但反过来「这个 texel 的深度比物体最近点还近」则一定意味着这块区域里有东西比物体近。取 max 之后,只有当所有采样 texel 都满足「不比物体近」时,才能证明物体被完全遮挡。任何一块 texel 取到了比物体更近的深度,查询就必须下潜到更精细的层级去进一步分辨。

保守测试:证明遮挡,而不是怀疑遮挡

理解了 max 的语义,测试规则就唯一确定了。以 Godot 的实现为例(renderer_scene_occlusion_cull.h 的 _is_occluded()),一次查询的完整流程是:

  1. 求相机到被测 AABB 的最近点,若相机在 AABB 内部,直接判定不可见(不可能被自己挡住);若最近点位于近裁剪面之后,同样直接返回。
  2. 用双精度计算这个最近点到相机的欧氏距离,作为 min_depth。Godot 的注释写明了原因:强制距离计算使用双精度,以避免远处物体发生浮点溢出。这一条在近远平面跨度极大的场景里是实打实的 bug 来源。
  3. 把 AABB 的 8 个角点投影到屏幕空间,取其屏幕矩形的并集,并把它钳制到 [0, 1]。
  4. 用屏幕矩形的对角线长度选择初始 mip 层级:size = max(diag.x, diag.y),lod = clamp(ceil(log2(size)), 0, mip_count - 1)。
  5. 从这个层级开始向下(向更精细方向)逐层采样:只要某次采样的深度值大于 min_depth,就说明该 texel 覆盖区域内存在比物体更近的东西,需要下潜;反之,如果某个层级上整个矩形内的采样值都不大于 min_depth,则证明该矩形被完全遮挡。

这里有两个容易写错的点值得单独强调。

第一,采样预算必须存在,且超预算时必须回退到「不可见」。 Godot 的实现里有一个 max_samples = 512 的硬上限,累加的采样数一旦超过它,函数立刻 return false——也就是判定为未被遮挡。这个方向是刻意的:预算耗尽意味着我们没有足够信息做证明,此时唯一安全的结论就是「无法证明被遮挡」,于是照画。同样的保守逻辑体现在 rect_min / rect_max 的 1 像素外扩(rect_min.x * w - 1 与 rect_max.x * w + 1)上:外扩让采样覆盖略大于物体投影,宁可多采样一点,也不要在边缘漏掉一个近处的像素。

第二,深度金字塔的分辨率不是越高越好。 Godot 的做法是在 CPU 上用 Embree 软件光栅化一个低分辨率缓冲,而不是直接用 GPU 的深度缓冲。按其内部渲染架构文档的说明,这个缓冲的分辨率取决于系统的 CPU 线程数,因为光栅化是在 CPU 上并行完成的。这条取舍很值得玩味:GPU 深度缓冲的精度是「完美」的,但获取它需要读回 GPU 内存,带宽与同步代价都高;CPU 光栅化的低分辨率缓冲虽然精度粗糙,却完全在 CPU 侧闭环,没有回读延迟。


三、GPU 遮挡查询:API 契约比直觉更微妙

GPU 侧有硬件加速的遮挡查询。它们的语义在 Vulkan 与 Direct3D 里的描述高度一致,但有几处细节如果按直觉理解会写错。

Vulkan 规范第 23.3 节「Occlusion Queries」定义得很直接:遮挡查询追踪一组绘制命令中通过逐片元测试的采样数量。因此它只对支持图形操作的队列族可用。查询通过 vkCmdBeginQuery 与 vkCmdEndQuery 开始与结束;查询开始时通过计数总是从零开始。规范同时给出了一个关键的行为差异:若 flags 不包含 VK_QUERY_CONTROL_PRECISE_BIT,那么当通过数为非零时,实现可以返回任意非零值;规范的 Note 进一步指出,有些实现在这种模式下只返回 0 或 1,无论真实通过数是多少。

也就是说:默认情况下,一个 Vulkan 遮挡查询是布尔的,不是计数的。想要精确计数,必须显式请求。vkCmdBeginQuery 的文档把这条写成了硬性要求:若查询池的 queryType 是 VK_QUERY_TYPE_OCCLUSION 且 flags 含 VK_QUERY_CONTROL_PRECISE_BIT,实现必须返回与实际通过采样数相符的结果。

而请求精确计数是有前置条件的。Vulkan 规范的 features 章节里,occlusionQueryPrecise 特性被描述为「指定是否支持返回实际采样计数的遮挡查询」。vkCmdBeginQuery 的 VUID 列表里有一条对等的约束:若 occlusionQueryPrecise 特性未启用,或者查询类型不是 VK_QUERY_TYPE_OCCLUSION,则 flags 不得包含 VK_QUERY_CONTROL_PRECISE_BIT。换句话说,PRECISE_BIT 不是免费的精度提升,而是一张需要设备能力配合的门票。

D3D 的对应物分成两个枚举值,恰好对应 Vulkan 的两种模式。D3D11_QUERY_OCCLUSION 获取的是 Begin 与 End 之间通过深度和模板测试的采样数,GetData 返回 UINT64;D3D11_QUERY_OCCLUSION_PREDICATE 则只返回一个布尔值——TRUE 表示至少有一个采样通过,FALSE 表示没有采样通过。Direct3D 12 走的是同一套语义,只是接口换成了 ID3D12GraphicsCommandList::BeginQuery(pQueryHeap, Type, Index),官方样例名为 D3D12PredicationQueries。

这里有一个容易被忽略的行为差异,它直接决定了「计数」到底在数什么。Direct3D 11 的文档明确写着:如果深度测试或模板测试被禁用,那么这些测试中每一个都按「通过」计数。Vulkan 规范第 35.12 节「Sample Counting」给出了更完整的口径:遮挡查询的采样计数器,对每一个「存活」于所有逐片元测试之后的、覆盖值为 1 的采样递增一次——这里的逐片元测试清单包括 scissor、exclusive scissor、sample mask、alpha to coverage、stencil 与 depth 测试。理解这条口径对调试极有价值:如果你的遮挡查询莫名其妙地总是返回非零,第一个要检查的就是深度测试是否被禁用、或者 scissor 是否把你的几何体裁掉了。

另外,VK_QUERY_TYPE_PIPELINE_STATISTICS 是一类独立于遮挡的查询,它的统计位覆盖了输入装配、顶点着色器、几何着色器、裁剪、片元着色器、计算着色器等阶段,甚至包括 VK_QUERY_PIPELINE_STATISTIC_TASK_SHADER_INVOCATIONS_BIT_EXT、VK_QUERY_PIPELINE_SHADER_INVOCATIONS_BIT_EXT 与 VK_QUERY_PIPELINE_STATISTIC_CLUSTER_CULLING_SHADER_INVOCATIONS_BIT_HUAWEI 这些扩展位。做管线级分析时,它比遮挡查询能回答更多问题。


四、跨帧延迟:为什么「等一等」是唯一出路

至此我们已经能拿到遮挡信息了,但还有一个绕不过去的问题:它是上一帧的。

GPU Gems 2 第 6 章把这件事讲得非常清楚。朴素的做法是:对每个物体发起一次遮挡查询,等结果回来再决定画不画。这个做法有两个致命问题——一是发起查询本身的开销(每次查询都额外增加一个 draw call),二是等待查询结果的延迟。Direct3D 允许驱动缓存多达三帧的渲染命令,于是等待一次查询结果就可能带来相当可观的延迟。

规范里给出了两条缓解路径,但两条都有明确代价。第一条是延迟使用:只在下一帧检查查询结果。这在「图像正确性不重要」时可以接受,代价是可见物体会被漏掉——漏剔除,这是所有失败模式里最不能容忍的那一种。第二条是 NVIDIA 提出的 coherent hierarchical culling(连贯分层剔除),它的核心思想是:拿上一帧的分类结果当猜测,但必须验证并纠正这个猜测。

猜测与纠正的规则被写得很清楚。上一帧可见的内部节点,本帧直接下潜处理,不发查询——因为上一帧可见的内部节点,其可见性完全可以从子节点的分类里「拉上来」,这被称为 pull up(可见性上拉)。而上一帧被遮挡的内部节点则必须发查询来验证,因为一旦猜错就会漏剔除。查询的发出范围因此被收敛为:上一帧可见的叶节点,加上层级中尽可能大的被遮挡节点。结果是发出的查询数量与可见物体的数量成线性关系,而不是与场景物体总数成线性关系。

算法实现上需要一个「查询队列」来记住已发出的查询,并在遍历过程中随时检查结果是否已经就绪。GPU Gems 2 给出的伪代码结构是这样的:

// 遍历栈与查询队列任一非空则继续
while (not TraversalStack.Empty() or not QueryQueue.Empty()) {
    // PART 1: 处理已完成的遮挡查询
    while (not QueryQueue.Empty() and
           (ResultAvailable(QueryQueue.Front()) or TraversalStack.Empty())) {
        node = QueryQueue.Dequeue();
        visiblePixels = GetOcclusionQueryResult(node);
        if (visiblePixels > VisibilityThreshold) {
            PullUpVisibility(node);   // 可见性沿父链上拉
            TraverseNode(node);
        }
    }
    // PART 2: 分层遍历
    node = TraversalStack.Pop();
    if (InsideViewFrustum(node)) {
        wasVisible = node.visible and (node.lastVisited == frameID - 1);
        leafOrWasInvisible = not wasVisible or IsLeaf(node);
        node.visible = false;
        node.lastVisited = frameID;
        if (leafOrWasInvisible) {
            IssueOcclusionQuery(node);
            QueryQueue.Enqueue(node);
        }
        if (wasVisible) { TraverseNode(node); }
    }
}

PullUpVisibility 的实现只有三行,但语义很重:只要子节点可见,就把整条祖先链标记为可见。配合 frameID 递增与 lastVisited == frameID - 1 的判定,可见性分类不需要每帧重置——这让跨帧状态的维护成本降到了常数级。

值得注意的是,GPU Gems 2 明确指出了分层遮挡剔除相对门户剔除(portal culling)的核心优势:它允许完全动态的环境。而且它也明确指出了这套方案的边界——如果场景里几乎没有遮挡(比如城市上空飞掠),遮挡剔除不仅没有收益,还会因为查询开销让渲染变慢;因此对于多种导航模式的游戏,应当在那些没有遮挡的模式里把遮挡剔除关掉。这是「遮挡剔除应该是运行时开关而不是编译期常量」的一个有力论据。


五、Godot 的 CPU 方案:Embree 光栅化 + 抖动 + 延迟消失

Godot 4 的遮挡剔除走的是一条与 GPU 查询完全不同的路,值得单独拆开看,因为它把「跨帧延迟」这个问题从根上消掉了。

按其官方文档的描述,Godot 的遮挡剔除工作方式是:把场景中的遮挡体(occluder)几何体在 CPU 上光栅化到一个低分辨率缓冲中,这一步用的是软件光线追踪库 Embree;然后用这个低分辨率缓冲去测试被遮挡体(occludee)的 AABB。这里有个术语要记牢——occluder 是挡视野的形状,occludee 是被藏起来的对象。判定条件很严格:occludee 的 AABB 必须被完全遮挡才能被剔除。由此可以推出两个实践结论:越小的物体越容易被有效剔除;越大的遮挡体(比如墙)远比小装饰道具有效。

这个「完全遮挡」的保守性与 GPU 方案是同一套哲学。Godot 官方文档还有一条容易被忽略的前提:只有不透明材质的表面会进入遮挡烘焙,透明材质的表面即使贴图完全不透明也不会被纳入——因为带 alpha 裁剪的表面在运行时可能不写深度,把它当遮挡物会导致误剔除。

抖动(jitter)是这套方案里另一个精巧的部件。按内部渲染架构文档的说明,遮挡缓冲「每帧都会被轻微抖动」,目的是减少欠采样伪影(under-sampling artifacts)——而欠采样伪影会导致假阳性,也就是本该可见的物体被误判为被遮挡。抖动把采样位置在时间轴上摊开,让假阳性在帧间分散,从而被后续的延迟逻辑吸收。

延迟逻辑的实现藏在 is_occluded() 这个薄包装里。当抖动开启时,一个刚刚变得可见的物体不会立刻被允许消失:其 occlusion_timeout 被设为 occlusion_frame + 9,也就是要等到九帧之后才真正允许遮挡剔除生效。这九帧的延迟是纯粹的保守性预算——它保证一个物体在遮挡关系发生翻转的边界上,不会因为一两帧的采样噪声而闪烁消失。值得注意的是它的方向性:延迟的是「消失」,而不是「出现」。物体出现得晚一点只是画面上迟一帧,物体消失得早一点则是穿帮,所以预算全部花在抑制消失上。

Godot 官方文档坦承了 CPU 方案的限制:「Godot 的遮挡剔除尚不支持动态遮挡体」,不过 OccluderInstance3D 的可见性开关与位置仍可改变,只是复杂遮挡体的运行时更新很慢,实践上建议只对四边形、长方体这类简单形状做运行时更新。这条限制在概念上很好理解:一个低分辨率的 CPU 光栅化缓冲是「快照」,动态遮挡体意味着每帧都要重新光栅化,成本随几何量线性上升。


六、Unity 的双帧深度纹理方案:另一种保守性组织方式

Unity HDRP 17.4 的 GPU 遮挡剔除采用了第三种组织方式,它既不是 CPU 光栅化,也不是 Vulkan 那种显式查询,而是用深度纹理做判断,并且同时参考当前帧与上一帧。

按其手册的表述:Unity 从相机和场景中灯光的视角生成深度纹理,GPU 随后使用当前帧和上一帧的深度纹理来剔除物体。Unity 只渲染「在两帧中至少有一帧未被遮挡」的物体,把「两帧都被遮挡」的物体剔除掉。

「或」而不是「且」是保守性的关键。如果只看当前帧,一个物体在当前帧被遮挡但上一帧可见,它就会被剔除,而它现在可能正在从遮挡物后面滑出来——穿帮。只看上一帧则相反,物体滑入遮挡物后方后仍会被渲染一帧,浪费但不穿帮。取两帧的并集,等价于在时间维度上做了一个长度为 2 的保守滤波:任何可能可见的物体都至少被渲染一帧。

Unity 文档还给出了一条相当具体的性能前提:GPU 遮挡剔除在多个对象共用同一 mesh(因而可以被合并成单个 draw call)的场景里最有效;在遮挡多且被遮挡对象顶点数高的场景里也有效。反之,如果场景里遮挡不多,「渲染时间反而可能因为 GPU 遮挡剔除的额外设置工作而增加」。这条警告与 GPU Gems 2 的结论一致:遮挡剔除的收益高度依赖场景结构。

Unity 手册关于传统(CPU)遮挡剔除的描述则提供了一个很好的对照。传统方案在编辑器里烘焙时,把场景划分成一系列 cell(格),为每个 cell 生成其中几何体的描述以及相邻 cell 之间的可见性数据,并在可能时合并 cell 以减小数据量;运行时加载这些烘焙数据,对每个启用了遮挡剔除的 Camera 做查询。Unity 也明确列出了 CPU 方案的适用边界:动态 GameObject 可以被遮挡,但不能遮挡别人;如果项目在运行时生成场景几何体,内置的遮挡剔除就不适用。此外,Unity 内置遮挡剔除在运行时于 CPU 上做计算,这可能抵消它省下的 CPU 时间——所以它最可能带来收益的场景是「因 overdraw 而 GPU 瓶颈」的项目。这句话反过来定义了一个准入条件:遮挡剔除主要省的是 GPU 的着色与顶点吞吐,不是 CPU 提交成本。


七、失效模式清单:把错误分成两类来管理

把前面几条线索合起来,可以列出一份可操作的失效模式清单。每一条都标注了它属于哪一类错误——漏剔除(穿帮,必须避免)还是过度剔除(浪费,可以容忍)。

漏剔除类:

  • 遮挡查询被放在了深度测试被禁用的管线里。此时按规范口径,深度测试按「通过」计数,查询永远返回非零,物体永远画不——但这不会漏剔除,反而是全部保留,属于过度剔除方向的误配。
  • 遮挡体的简化过度。Godot 文档明确指出,烘焙时过度激进的网格简化会导致本不该被剔除的物体被剔除。修正方式是调低 Bake > Simplification 后重新烘焙。
  • 遮挡烘焙之后又移动了场景几何体。文档把这条列为「物体被误剔除」的首要原因;修正方式是重新烘焙。
  • 动态物体被误纳入遮挡烘焙。修正方式是调整烘焙的 Cull Mask,把动态物体所在的可视层排除。
  • 透明材质被当作了遮挡体(当透明物体自己变成了遮挡物)。

过度剔除类:

  • 场景本身遮挡稀少时强行开启,查询开销超过收益。
  • 采样预算(Godot 的 512 次上限)被频繁触达,导致大量对象退化为「照画」。这类问题不会体现在画面上,只会体现在「遮挡剔除好像没起作用」的困惑里。
  • AABB 过大。Godot 文档建议 Extra Cull Margin 尽量低(通常 0.0),因为 AABB 必须被完全遮挡才剔除,包围盒每大一点,判定就难一点。
  • 使用了精确计数模式但只做布尔判断。PRECISE_BIT 要求实现返回真实通过数,在只需要布尔结果时用它可能额外付出实现成本;规范 Note 明确建议在这种场景下不要设该位。

还有一类是状态错误,它既不是漏剔除也不是过度剔除,而是数据本身错了:把 occludee 与 occluder 的角色搞反、把被遮挡体的投影矩形算成了视锥矩形而不是屏幕矩形、以及在多相机场景里把 A 相机的遮挡结论套用到 B 相机。


八、落地顺序与工程建议

综合上述,一个自研引擎如果要落地遮挡剔除,我建议按下面这个顺序推进,每一步都能独立产生价值,也都能独立停下来:

第一步,先做视锥剔除 + 屏幕空间误差剔除。 这两个都是无状态的、零延迟的,且是后续所有工作的地基。屏幕空间误差剔除(按包围盒在屏幕上的投影尺寸决定 LOD)在原理上与遮挡剔除共享大量代码。

第二步,做 CPU 侧的保守遮挡测试(Hi-Z)。 用一个低分辨率的深度来源(可以是 CPU 光栅化,也可以是 GPU 深度缓冲的稀疏回读)建金字塔,按第二章的保守规则做测试。因为它是软件查询,可以自由地跑多线程、可以随时改变层级粒度、没有 API 语义上的约束。

第三步,再考虑 GPU 硬件遮挡查询。 它的价值在于覆盖 CPU 方案难以覆盖的场景:动态遮挡体、以及需要精确计数做细粒度决策的场合。但要注意,它带来的是跨帧延迟,所以必须配套「猜测 + 验证 + 纠正」的机制(第四章的 coherent hierarchical culling),不能直接等结果。

第四步,把抖动与延迟预算当成一等公民写进设计。 Godot 的九帧消失延迟是一个很好的默认值起点:它足够长以吸收采样噪声,又足够短以不至于产生明显的响应迟滞。更重要的是方向——延迟只施加于「消失」,不施加于「出现」。

第五步,做统计。 至少需要三个指标:查询发出数、通过采样数、被判定为完全遮挡的物体比例。GPU Gems 2 已经指出查询数应当与可见物体数成线性关系;如果你的查询数随场景规模爆炸增长,说明分层逻辑没有生效。

关于工具链:RenderDoc 与 PIX 是观察这些行为的自然入口,而在 Vulkan 侧,VK_LAYER_KHRONOS_validation 会检查查询范围的合法性。

最后是几条「不要做」:不要在没有层级结构的情况下对每个物体发查询;不要在场景遮挡稀少时开着遮挡剔除不管;不要用精确计数模式去做纯布尔判断;不要把「上一帧可见」直接当成「本帧可见」而不做验证——那正是漏剔除的来源。


九、参考文献

  1. Vulkan Specification 1.4,第 23.3 节 Occlusion Queries 与第 35.12 节 Sample Counting,Khronos Group 在线规范,https://registry.khronos.org/vulkan/specs/latest/html/vkspec.html
  2. Vulkan vkCmdBeginQuery 参考页(含 VK_QUERY_CONTROL_PRECISE_BIT 与 VUID-vkCmdBeginQuery-queryType-00800 约束),https://registry.khronos.org/vulkan/specs/latest/man/html/vkCmdBeginQuery.html
  3. Vulkan VkQueryType 参考页(VK_QUERY_TYPE_OCCLUSION = 0),https://registry.khronos.org/vulkan/specs/latest/man/html/VkQueryType.html
  4. Vulkan VkQueryPipelineStatisticFlagBits 参考页(各阶段统计位与 CLUSTER_CULLING_SHADER_INVOCATIONS_BIT_HUAWEI),https://registry.khronos.org/vulkan/specs/latest/man/html/VkQueryPipelineStatisticFlagBits.html
  5. Vulkan 规范源码 chapters/features.adoc 中 occlusionQueryPrecise 特性定义,https://github.com/KhronosGroup/Vulkan-Docs/blob/main/chapters/features.adoc
  6. Microsoft Learn,D3D11_QUERY 枚举(D3D11_QUERY_OCCLUSION 与 D3D11_QUERY_OCCLUSION_PREDICATE 语义差异),https://learn.microsoft.com/en-us/windows/win32/api/d3d11/ne-d3d11-d3d11_query
  7. Microsoft Learn,ID3D12GraphicsCommandList::BeginQuery 方法说明与 D3D12PredicationQueries 样例引用,https://learn.microsoft.com/en-us/windows/win32/api/d3d12/nf-d3d12-id3d12graphicscommandlist-beginquery
  8. NVIDIA,GPU Gems 2 第 6 章 "Hardware Occlusion Queries Made Useful"(coherent hierarchical culling、pull up 可见性上拉、查询数线性化与失效边界分析),https://developer.nvidia.com/gpugems/gpugems2/part-i-geometric-complexity/chapter-6-hardware-occlusion-queries-made-useful
  9. Godot Engine 官方文档 "Occlusion culling" 教程(occluder/occludee 术语、Embree 低分辨率光栅化、完全遮挡判定条件、烘焙与排查清单),https://docs.godotengine.org/en/stable/tutorials/3d/occlusion_culling.html
  10. Godot Engine 官方文档 "Internal rendering architecture" 之 Occlusion culling 章节(CPU 并行光栅化、每帧抖动与欠采样假阳性、动态遮挡体限制、无帧延迟特性),https://docs.godotengine.org/en/stable/engine_details/architecture/internal_rendering_architecture.html
  11. Godot 引擎源码 servers/rendering/renderer_scene_occlusion_cull.h(HZBuffer::_is_occluded() 的双精度距离、512 采样预算、LOD 选择与九帧遮挡超时实现),https://github.com/godotengine/godot/blob/master/servers/rendering/renderer_scene_occlusion_cull.h
  12. Godot 引擎源码 servers/rendering/renderer_scene_occlusion_cull.cpp(update_mips() 的 max 折叠与奇数边长补齐逻辑),https://github.com/godotengine/godot/blob/master/servers/rendering/renderer_scene_occlusion_cull.cpp
  13. Godot 官方示例工程 "Occlusion Culling and Mesh LOD"(遮挡剔除与网格 LOD 联动的可运行参考),https://github.com/godotengine/godot-demo-projects/tree/master/3d/occlusion_culling_mesh_lod
  14. Unity Manual 6.6 "Occlusion culling"(CPU 烘焙 cell 与相邻 cell 可见性、动态物体不可充当遮挡体、GPU 瓶颈准入条件),https://docs.unity3d.com/Manual/OcclusionCulling.html
  15. Unity HDRP 17.4 "Use GPU occlusion culling"(相机与灯光深度纹理、当前帧与上一帧取并集的保守判定、共用 mesh 场景收益最大),https://docs.unity3d.com/Packages/com.unity.render-pipelines.high-definition@17.4/manual/gpu-culling.html
  16. GitHub REST API v3 实时仓库数据(2026-10-27 拉取):godotengine/godot 119129★(pushed 2026-10-26,MIT)、embree/embree 2773★(pushed 2026-10-26,Apache-2.0)、gfx-rs/wgpu 18397★(pushed 2026-10-27)、microsoft/DirectX-Graphics-Samples 6852★(含 D3D12PredicationQueries)、KhronosGroup/Vulkan-Samples 5409★、microsoft/DirectXShaderCompiler 3678★、zeux/meshoptimizer 8633★、KhronosGroup/glslang 3604★、KhronosGroup/SPIRV-Tools 1378★、google/filament 20656★(pushed 2026-10-27)

一句话摘要:遮挡剔除是一套带延迟的可见性判定系统——深度金字塔负责用保守规则证明「被挡住」,GPU 遮挡查询负责拿到硬件级的通过采样计数,而跨帧延迟必须靠「上一帧猜测 + 本帧验证 + 拉上可见性 + 只延迟消失不延迟出现」这一整套机制来兜住正确性,否则引擎就会在可见物体消失和性能浪费之间反复横跳。

←返回文章列表

Related

可能也会喜欢

  • 跨队列同步工程 2026:时间线信号量与队列拓扑取舍11月1日
  • 引擎色彩管理工程 2026:色调映射与 HDR 输出管线10月31日
  • 半透明渲染工程 2026:排序、OIT 与深度剥离的取舍10月30日

Conversation

0 条

留下你的想法

加载评论中…

New comment