Two-Pass HZB遮挡剔除实战:GPU原理与代码,大幅降低Draw Call
上个月我把项目里一块一直没动的硬骨头啃下来了给场景加了一套 Two-Pass HZB Occlusion Culling。这套东西做完之后5000 多个物件的城市场景视锥剔除之后还能剩两千多的 draw call被压到九百以内帧耗时降了差不多三分之一。这篇就把这套方案的原理、两个 Pass 的细节、GPU 端的实现代码以及我踩过的坑全部写出来给后面想自己做遮挡剔除的朋友一条能直接走的路。先说清楚一件事我这里说的 Two-Pass是指每一帧都先用本帧的深度数据生成一张 HZB层级 Z 缓冲再用这张 HZB 去剔除场景物体而不是复用上一帧的 HZB。为什么强调这个后面会详细说。这种方案适合那种“大量小物体被少数大物体挡住”的场景比如城市、野外资源点密集的项目也适合遮挡关系每帧都在剧烈变化、上一帧信息不可靠的动态场景。1. 为什么场景里会剩这么多看不见的物体很多项目跑到中期发现帧率掉下去第一反应是“模型面数太高”“特效太多”。查了一圈发现真正的问题往往是相机看向一条街道视锥里明明只剩 2000 个格子结果这 2000 个里面有一千三都被楼挡着根本没上屏但还是按顺序全部提交了。就算模型面数压得再低draw call 和顶点处理的固定开销也扛不住这种浪费。1.1 视锥剔除和包围体粗剔除的盲区常规优化三板斧视锥剔除、距离剔除、包围体剔除都没有办法回答一个最基本的问题——“这个物体在相机方向上是不是被别的东西完全挡住了”举个例子角色站在一堵墙后面墙的另一侧有一排装饰柱。装饰柱完全在视锥内距离也近包围体的标记是可见但它一个像素都不会出现在屏幕上。相机不动的时候你甚至可以在 Culling Stats 面板里看到这些柱子的包围体画出来然后调了半天相机发现这些包围体确实放进渲染列表了。这就是典型的遮挡盲区。1.2 Occlusion Query 的延迟和 CPU 回读是个老大难有人马上会想到 GPU Occlusion Query。这个方案思路直接把物体的包围体画一遍看看有没有任何 fragment 通过深度测试。问题出在两点。第一Query 的结果需要 GPU 回读通常要等一帧甚至几帧。这一帧里物体可能已经移动了遮挡关系也跟着变。回读的那一帧如果刚好是“从遮挡中露出来”的瞬间结果会是“被遮挡”物体就不画了画面闪一下下一帧又好了。很多老项目里所谓的“闪烁 bug”就是查不出个所以然最后骂到美术头上。第二Query 本身要消耗 GPU逐物体做 Query 在大场景里开销并不比直接画物体少多少。如果按批次合并遮挡关系又会失真。Occlusion Query 不是不能做它适合那种遮挡体很少、物体切换不频繁的场景。但在我这个项目里它的延迟问题直接否决了它。1.3 Two-Pass 能解决什么为什么不用上一帧的 HZB后来我查了一圈资料看到 HZB 的做法。思路很简单先把深度缓冲下采样成一个金字塔每个 mip 记录这个区域里最远的表面深度然后用这个金字塔逐个物体做判断。这个方案一次生成可以测成千上万个物体而且全程在 GPU 上做不需要 CPU 回读。但这里有个选择HZB 是拿上一帧的深度生成还是拿这一帧的深度生成上一帧方案叫 Single-Pass 或者 Reuse-Previous-Frame HZB。它的成本很低第一帧先渲染一次生成 HZB第二帧直接用这个 HZB 测试所有物体再渲染可见物体。问题在于这一帧刚进入视锥的物体、刚解除遮挡的物体在上一帧的 HZB 里根本没有它们的信息测试结果就会把它们判断成“被遮挡”严重的时候会整帧消失。想要避免就得做一堆特判比如强制保留新物体一帧复杂度反而上去了。Two-Pass 的做法是第一遍先把候选遮挡物渲染进深度立刻生成当前帧的 HZB第二遍用这个 HZB 测试所有物体。这样测试用的就是“当前这一刻”的遮挡关系不存在时序滞后。代价是多花一遍渲染深度的时间但这个深度 Pass 是很廉价的下面会说GPU 的时间节省远大于多出来的开销。两种情况要取舍如果你的场景遮挡关系很稳定上一帧 HZB 就够用省心省力。如果遮挡关系变化快、物体频繁进出视锥或者场景里有大量运动物体请直接上 Two-Pass。我项目里两种都试过最后留下的是 Two-Pass。2. HZB 到底是什么一张用 max 下采样的深度金字塔HZB 本质上就是一张 mipmap 链但它跟普通的纹理 mipmap 有本质区别。普通 mipmap 用平均值滤波HZB 用的是 max 滤波每个 2×2 像素块取这四个像素里深度值最大的那个写入下一级。2.1 为什么存的是 max 而不是 min这个很多人一开始都搞反了。我先约定一下深度值的含义线性深度数值越大代表离相机越远。假设屏幕上某个 2×2 区域里有四个像素分别看到了地面、前方的墙壁、后方的天空、还有一个小小的木箱。这四个深度值可能分别是 10、50、1000、60。对这块区域取 max结果是 1000代表“这块区域里目前最远的可见表面是 1000 米处的东西”。遮挡判断的逻辑是这样的如果一个物体它的最近点到相机的距离是 80 米而 HZB 这块区域的 max 深度只有 50 米说明这个区域里所有已经画上去的表面最远的也比 80 米近——也就是说从相机到物体近端之间每一根射线都被挡住了这个物体必然是看不见的可以剔除。反过来说如果 HZB 该区域的 max 是 1000 米大于这个物体的 80 米那就不能剔除。因为区域里有缝隙有像素能透过缝隙看到远处的天空说不定也能透过缝隙看到这个物体。这种时候剔除它就是肉眼可见的闪烁错误。所以 max 是保守的宁可保留不可见物体也不误杀一个可见物体min 是激进的会快但错剔率你受不了。游戏引擎的遮挡剔除第一步是保证正确性其次才是效率。2.2 为什么不能直接用纹理的 GenerateMipmaps很多人图省事直接调一下 mipmap 生成想要一张 HZB。Stop。纹理 mipmap 生成用的是盒式滤波平均你拿平均深度去测试等于把前面的墙平均到中间的空气里结果一团浆糊。而且默认 mipmap 生成是无法自定义 reduce 函数的最多用一下 hardware autogen 的 max不存在的各家 API 都没这个选项。HZB 必须自己写一个 compute shader 做 reduction一次 dispatch 生成一级。2.3 用 compute shader 构建 HZB 的代码假设你已经有了一张 R32_FLOAT 的深度图线性深度数值越大越远。下面的代码就是从 mip 0 逐级生成 mip 1、mip 2……直到 1×1// HZBReduceCS.hlsl // srcView 和 dstView 是同一张纹理的不同 mip 层 [numthreads(8, 8, 1)] void HZBReduceCS(uint3 dtid : SV_DispatchThreadID) { uint2 srcSize; srcView.GetDimensions(srcSize.x, srcSize.y); int2 baseCoord dtid.xy * 2; float d0 srcView[baseCoord int2(0, 0)].r; float d1 srcView[baseCoord int2(1, 0)].r; float d2 srcView[baseCoord int2(0, 1)].r; float d3 srcView[baseCoord int2(1, 1)].r; float hzbValue max(max(d0, d1), max(d2, d3)); // 处理奇数尺寸的边界 if (baseCoord.x 1 srcSize.x || baseCoord.y 1 srcSize.y) { // 例如只有一列/一行时直接从已有样本中取 hzbValue max(d0, d2); if (baseCoord.y 1 srcSize.y) hzbValue max(hzbValue, d1); } dstView[dtid.xy] hzbValue; }Dispatch 的时候每一级都用上一级作为输入目标尺寸减半。注意HZB 必须用 Point/Nearest 采样不能用双线性。双线性会在线程边界插值出新的深度插出来的值可能比真实遮挡深度更浅一旦用于遮挡判定就会产生误剔。如果你的深度缓冲是反向 ZReverse-Z那下采样就要取 min判断条件也要跟着反向。我建议做新项目的时候直接全链路统一线性深度 非反向 Z或者反过来全统一 Reverse-Z二选一别混着用。混用是后期调参掉头发的根源。3. Pass 1 不是随便画一遍深度遮挡物集合与自挡问题Two-Pass 的第一个 Pass是拿一部分物体去渲染深度生成一张足够好的 HZB。这里的关键词是“一部分”。你完全可以把所有物体都画进 Depth生成一张最完整的 HZB但那样的话这个 Pass 的开销已经跟直接渲染整个场景差不多了优化半天等于没优化。所以 Pass 1 的核心工作选出一批“值得作为遮挡物”的物体尽量便宜的画出可靠的深度。3.1 候选遮挡物集合怎么选遮挡物候选集合的筛选我用的经验公式是上一帧可见集合里屏幕投影面积大于某个阈值的非动态物体强制进入候选。再叠加一部分动态大物体比如载具、巨人这种直接排进去。这个集合数量级通常在几百个不会上千。千万不要第一帧把所有物体都丢进去。项目刚启动的时候没有上一帧信息可以用一个兜底策略当前帧视锥剔除之后按屏幕面积排序取前 N 个物体作为候选遮挡物。我这边 N 取 512跑下来足够而且只在第一帧或者切换大场景时触发一次。3.2 AABB 代理是坑只能用来兜底最省事的 Pass 1 写法是把所有候选物体的 AABB 盒当成 mesh直接渲染进深度。这个做法有一个致命问题AABB 是物体的凸包围盒一个倾斜的木板它的 AABB 是一个巨大的长方体这个长方体可能会把周围一大片空间全部填满。用这个 AABB 生成 HZB会把这个区域当成“被完全遮挡的区域”背后真实可见的物体全会被你误剔掉。所以如果只是用 AABB 兜底你还得在后面加一层“AABB 膨胀惩罚”渲染 AABB 时把深度稍微往远推一点bias减弱它的遮挡能力。这个方法能让情况变好但不能根治。最稳的做法是给大型遮挡物准备简化网格或者直接用碰撞体网格。碰撞体网格面数低形状接近真实物体几乎不会产生虚假遮挡。这个方法建议项目美术接受。3.3 自遮挡为什么物体可能被自己挡掉Pass 1 渲染了一个建筑这个建筑的深度写进了 HZB。Pass 2 测试到这个建筑自身的时候HZB 里的深度是这个建筑投影区域内的最远表面而物体近端深度是建筑离相机最近的那个表面的深度。理论上 max 深度应该大于等于近端深度所以不会剔除自己。但实际光栅化时你写进 HZB 的是简化网格的深度。这个简化网格可能比真实网格小了那么一点点或者加了深度 bias导致 HZB 里的 max 深度比真实近端深度还小于是……建筑把建筑自己剔了。场景里表现为建筑时隐时现。解决自遮挡我这里是三个措施配合第一Pass 1 渲染遮挡物代理网格时给深度一个正 bias把代理网格往远推一点。D3D 里就是 DepthBias SlopeScaledDepthBiasOpenGL 里对应 polygonOffset。bias 的幅度不能拍脑袋需要根据场景尺度调我用的线性深度bias 初始值是物体 AABB 对角线长度的 0.5%再加一个固定斜率项。第二Pass 2 测试物体最近深度的时候同样往远处挪一个安全区间。等于两边都退一步中间留出缓冲带自遮挡就不容易触发了。第三对刚进入视锥的物体设置一帧的“强可见”标记强制不过遮挡测试直接渲染。这样就算偶尔计算出错最多是多画一帧画面不会闪烁。3.4 Pass 1 的渲染方式Pass 1 不需要输出颜色只需要深度。所以可以用一个最简的 depth-only vertex/fragment shader// DepthOnlyVS float4 DepthOnlyVS(float3 positionOS : POSITION) : SV_Position { return mul(ViewProj, float4(positionOS, 1.0)); } // DepthOnlyPS: 只需要填一个颜色值来满足管线 float DepthOnlyPS() : SV_Target { return 0.0; }渲染完这层深度立刻跑 HZB 下采样。到这里本帧的 HZB 就绪可以进入 Pass 2 了。4. Pass 2 的判定细节屏幕矩形、mip 档位与保守采样这是整个方案里最考验细节的地方。写对了效率高且稳定写错了要么误剔闪烁要么剔除率低得跟没做一样。4.1 物体的最近深度和屏幕包围盒Pass 2 的第一步是把每个物体的世界空间 AABB 变换到屏幕空间。这一步要注意AABB 的 8 个角点都要投并且视锥剔除要先做掉不然有角点在相机后方时除以 w 会出现负数坐标后面全乱。// 计算 AABB 8 角点在屏幕空间的投影范围 float2 GetAABBScreenRect(ObjectData obj, out float nearDepth) { float minX 1e9, maxX -1e9; float minY 1e9, maxY -1e9; float maxViewZ -1e9; // View space 的 z 是负数越接近相机 z 越大 for (int i 0; i 8; i) { float4 cornerWS float4(obj.center obj.extent * cornerSign[i], 1.0); float4 viewPos mul(View, cornerWS); maxViewZ max(maxViewZ, viewPos.z); viewPos.xy / -viewPos.z; // 视锥内z 0 float4 clipPos mul(Proj, viewPos); float2 ndc clipPos.xy / clipPos.w; float2 uv ndc * 0.5 0.5; // Y 方向根据引擎翻转 minX min(minX, uv.x); maxX max(maxX, uv.x); minY min(minY, uv.y); maxY max(maxY, uv.y); } nearDepth -maxViewZ; // 最近距离正值 return float2(maxX - minX, maxY - minY); }如果遇到一些很薄的物体比如广告牌投影矩形可能宽度极小。这种物体做 HZB 测试很容易因为采样点在矩形边缘而误剔所以对长宽比过大的物体我建议直接跳过剔除。4.2 mip 档位选择的公式拿到屏幕包围盒像素宽高之后要决定在 HZB 的哪一级 mip 上做测试。太低要采样的 texel 多开销大太高一个 texel 覆盖太大区域max 深度容易被远处背景带偏剔除率暴跌。我的经验公式int maxSizePx max(rectWidthPx, rectHeightPx); int mipLevel clamp((int)ceil(log2(maxSizePx)) - 1, 0, maxMip);原理让这个物体的屏幕包围盒在选定的 mip 上覆盖大约 1 到 2 个 texel。举个例子一个包围盒在屏幕上占 100 像素ceil(log2(100)) 7mipLevel 6该 mip 的 texel 对应 2^6 64 像素所以包围盒覆盖约 1.56 个 texel。这样采样时最多取 2×2 的 texel就能覆盖整个矩形开销极小同时 max 值不会掺杂太多无关背景。我测试过一组数据贴在下面方便你对照理解物体屏幕宽度px计算的 mip该 mip texel 对应像素包围盒覆盖 texel 数82423241626453221006641.5630082561.1719201010241.875如果你的物体在屏幕上特别大比如一座山、一整面墙这个公式会把它的 mip 推到很高此时它作为遮挡物的效力会下降因为高层 texel 会把山的深度和天空背景混在一起。这种大物体建议不要用单一 mip 测试而是用两阶段先在高 mip 上做一次粗测如果判定“可能可见”再降到 mip‑2 或 mip‑3 上做精测。这一步能显著找回大物体的遮挡效率成本也不高。4.3 保守采样四个 corner texel 取 max确定 mip 之后在目标 mip 上读取该矩形覆盖的 texel。我这里的做法是把屏幕矩形四个角映射到目标 mip 的 UV 坐标分别采样取这四个采样值的最大值也就是最远深度。因为矩形在目标 mip 上大约也就覆盖 1~2 texel取四角可以覆盖到边界安全且高效。绝对不要在这一步使用纹理过滤。双线性过滤在 max reduction 的数据上做插值插出来的深度可能比实际更浅一旦更浅遮挡判断就会进入“激进”区间误剔就来了。必须 Point Clamp 采样。完整判定伪代码// OcclusionCS.hlsl StructuredBufferObjectData objects; RWStructuredBufferuint visibleList; RWStructuredBufferuint visibleCounter; Texture2Dfloat hzbTexture; // 绑到目标 mip 层 SamplerState pointClampSampler; float bias; // 安全偏移线性距离单位 [numthreads(64, 1, 1)] void OcclusionCS(uint3 id : SV_DispatchThreadID) { if (id.x objectCount) return; ObjectData obj objects[id.x]; // 先排除长宽比过大的 float2 screenRect GetAABBScreenRect(obj, out float nearDepth); float aspect max(screenRect.x, screenRect.y) / max(1e-5, min(screenRect.x, screenRect.y)); if (aspect 8.0) { AppendVisible(id.x); return; } int maxSizePx max(screenRect.x, screenRect.y); int mipLevel clamp((int)ceil(log2(maxSizePx)) - 1, 0, maxMip); float2 uvCenter (rectCenterPx 0.5) * exp2(-mipLevel); // 转换为该 mip 的 UV float t max(screenRect.x, screenRect.y) * 0.5 * exp2(-mipLevel); float hzbMaxDepth 0; // 采样覆盖矩形的最小/最大 UV取最远 for (int i 0; i 4; i) { float2 offset i 0 ? float2(-t, -t) : (i 1 ? float2(t, -t) : (i 2 ? float2(-t, t) : float2(t, t))); float2 sampleUV uvCenter offset; // 坐标不需要靠 center 减 t 的写法实际要按包围盒四个角的 UV 算 hzbMaxDepth max(hzbMaxDepth, hzbTexture.SampleLevel(pointClampSampler, sampleUV, 0).r); } if (hzbMaxDepth bias nearDepth) { // 被遮挡 return; } AppendVisible(id.x); }解释一下那个判断式hzbMaxDepth bias nearDepth成立说明这个屏幕矩形里最远的已渲染表面都比物体最近表面要近物体无路可走必看不见剔除。bias 是前面说的安全偏移宁可少剔不可错剔。4.4 生成可见列表并直接用 Indirect Draw 渲染Pass 2 的输出不是“一个 bool”而是紧凑的、连续排列的可见物体索引列表。做法很简单visibleCounter 初始为 0每发现一个可见物体就InterlockedAdd(visibleCounter[0], 1, slot)然后把物体原始索引写入 visibleList[slot]。之后用这个 counter 作为 IndirectDraw 的参数。同类物体用 InstanceCount 批次渲染不同类物体用 Multi-Draw-Indirect全程 GPU 零回读。CPU 压根不知道这帧画了谁它只需要把命令发出去。5. 落地时最容易翻车的五个场景处理跑通 Demo 只是开始。真正放进游戏项目里几个特定场景会让你调到头秃。我把踩过的坑整理成五类。5.1 半透明物体别往 HZB 里塞HZB 是拿不透明深度生成的。半透明物体本身不写入深度除非你做了特殊的半透明深度排序但它也不能作为遮挡物。如果你把半透明物体混进 Pass 1它的半透明表面会挡住后面的物体——玩家会看到物体隔着玻璃窗消失了谁都不能接受。处理方式半透明物体一律不参与 HZB 测试正常走渲染管线最多做一层距离剔除。反正半透明物体数量不会太大省不掉多少。5.2 大物体被自己挡掉第一帧必检我项目刚开始切 Two-Pass 的时候出现过一个诡异现象城市里最高的那座塔在玩家转向的时候偶尔闪一下。查了很久才发现塔的第一帧进入视锥时上一帧没有它的深度信息Pass 2 测试时 HZB 里塔的区域还是空的只有远处天空的 max 深度结果判定被剔除。第二帧 HZB 有了塔自己的深度测试又通过了画面就闪了那一下。修法就是我前面说的新进入视锥的物体强制保留渲染一帧同时 Pass 1 的候选遮挡物池子里确保大物体一定在里面不要等上一帧可见集合来发现它。5.3 数值精度bias 要用线性距离别用 NDC如果你直接用 NDC 深度做 HZB离相机越远的地方深度的数值间隔越小bias 很难调。近处刚好、远处误剔或者反过来远处一堆漏剔。我的做法是 Pass 1 直接输出线性 view-space 深度R32F整个测试全在线性距离下比较。bias 的单位就是“米”你可以直接按场景尺度推室内场景 0.1~0.3 米室外场景 0.5~2 米就很稳。5.4 粒子、飘字、特效更不能漏粒子系统的 AABB 通常巨大且随生命周期跳动放进 HZB 测试会生出一堆误剔。飘字和 UI 的 AABB 更是不靠谱。这类东西直接排除在 HZB 系统之外用自己的一套视锥距离管理。HZB 只服务于“静态网格 大体积动态物”这种主体场景元素。5.5 遮挡物集合的动态更新要跟 LOD 联动如果一个大型建筑在 LOD 切换时代理网格换成另一个低模它的深度会发生轻微变化。如果代理网格之间形状差异太大自遮挡和误剔就来了。我这边强制规定LOD 0 和 LOD 1 的遮挡物网格必须用同一个简化代理不能一个用碰撞体一个用 AABB 凑合。这个约束要写进资产规范里不然美术换一个低模你的遮挡系统就莫名其妙抽一下。6. 一个 5000 物体场景的实测和调参顺序最后放一组我这边项目里的实测数据供你对比。场景是一个半开放城市街区共 5000 个静态网格物体建筑、路灯、长椅、车辆、花坛硬件是 RTX 3070 级别1080p 分辨率。指标启用前启用后视锥剔除后的候选物体数约 2400约 2400最终可见物体数全部提交约 700Draw Call / 批次2200约 850HZB 构建耗时-0.18 msPass 2 Culling 耗时-0.32 msGPU 帧总耗时8.6 ms6.3 ms注意上面 HZB 构建 0.18ms 是包含了 Pass 1 深度渲染和 compute reduction 的总和。这个数字在这个场景下是划算的因为它把 1500 个不可见物体的渲染开销全部干掉了。调参的顺序我建议严格按这个优先级来第一先调 mip 选择公式。如果 mip 偏高剔除率低得离谱偏低闪烁不断。多写一个可视化工具把每个物体实际用的 mip 画出来一眼就能看出问题。第二再调遮挡物候选集合的策略。剔除率上不去通常是候选遮挡物太少或者太小。把屏幕面积阈值从 64px 往下降到 32px能明显看到剔除率上升但也别降太多Pass 1 会变贵。第三最后才碰 bias。bias 是用来消稳定性问题的不是用来冲成绩的。如果误剔闪烁已经出现优先怀疑 mip 和采样方式再怀疑 bias。结尾处补一句个人经验我把遮挡测试写在一个跑在 GPU 上的 compute shader 里所有调试都靠把 HZB 各层级颜色化显示、把被剔除物体用红色包围盒框出来这两招撑过来的。没有这两个可视化你根本分不清是“HZB 错了”还是“测试代码错了”。做这套方案之前先去把你的深度输出和可视化工具准备好比什么都重要。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →