首页 / 资讯中心 / 文章详情

3D游戏遮挡剔除优化:原理、方案选型与工程实践指南

3D游戏遮挡剔除优化:原理、方案选型与工程实践指南 ★ FEATURED ARTICLE
1. 遮挡剔除到底在解决什么问题做过3D游戏的人都有一个共同的痛场景一大帧率就崩。你辛辛苦苦搭了一个几百米见方的开放世界美术资源堆得满满当当结果一进游戏GPU占用直接拉满帧数从120掉到30。玩家骂优化差策划催你赶紧解决你打开Profiler一看——DrawCall高得离谱三角形数量几百万其中一大半是玩家根本看不到的东西。这就是遮挡剔除要解决的核心问题把那些被挡住、根本不可能出现在屏幕上的物体在渲染之前就剔除掉不让它们进入渲染管线。说白了玩家视野里看不到的东西凭什么还要花算力去画它遮挡剔除就是帮你把这些“隐形”的渲染开销砍掉。我在实际项目中遇到过最夸张的情况一个室内场景玩家站在走廊里面前是一堵墙但墙后面有整整三个房间的家具、装饰、灯光全都在渲染。帧率直接腰斩。加上遮挡剔除之后帧率翻了一倍还多。这不是理论上的优化这是实打实的性能提升。这篇文章适合谁看如果你正在做3D游戏开发不管是Unity、Unreal还是自研引擎只要你的场景复杂度上来了遮挡剔除就是你绕不过去的一道坎。我会从原理讲到实操从方案选型讲到踩坑经验尽量把这件事说透。2. 遮挡剔除的核心原理与方案选型2.1 为什么GPU不知道什么东西被挡住了要理解遮挡剔除首先得理解一个事实GPU是非常“老实”的你让它画什么它就画什么它不会主动判断这个东西最终能不能被看到。传统的渲染管线是这样的CPU把场景里所有需要渲染的物体整理成DrawCall提交给GPUGPU逐个处理顶点、光栅化、着色、输出像素。整个过程里GPU只负责“画”不负责“判断该不该画”。那为什么GPU不自己判断呢因为判断本身也要花时间。如果每个物体都让GPU去检测它是否被遮挡那检测的开销可能比直接画还大。所以这件事必须由CPU在提交渲染命令之前就做好或者由专门的硬件单元来加速。遮挡剔除的本质就是在CPU端或者GPU端通过某种算法提前判断出哪些物体被遮挡了然后把它们从渲染队列里去掉。这样GPU收到的DrawCall就少了需要处理的三角形也少了帧率自然就上去了。2.2 视锥剔除和遮挡剔除的区别很多人会把视锥剔除和遮挡剔除搞混这里必须说清楚。视锥剔除解决的是“在不在视野范围内”的问题。摄像机有一个视锥体只有在这个视锥体内的物体才需要渲染。这个判断非常简单只需要用物体的包围盒和视锥体的六个平面做相交测试计算量很小。引擎默认就会做这件事你基本不用操心。遮挡剔除解决的是“在视野范围内但被挡住了”的问题。一个物体可能在视锥体内但它前面有一堵墙玩家根本看不到它。视锥剔除管不了这种情况必须用遮挡剔除来处理。两者的关系是视锥剔除是第一道粗筛遮挡剔除是第二道精筛。先筛掉视野外的再筛掉被挡住的剩下的才是真正需要渲染的。2.3 主流遮挡剔除方案对比目前业界常用的遮挡剔除方案主要有这么几种我做一个对比表格方便你选型方案原理优点缺点适用场景CPU端射线检测从摄像机向物体发射射线检测是否被遮挡实现简单精度高性能开销大不适合大量物体物体数量少的场景软件光栅化遮挡查询CPU端用低分辨率光栅化深度图判断物体可见性精度较高可控性强实现复杂CPU占用高中小规模场景硬件遮挡查询利用GPU的Occlusion Query功能精度高GPU加速异步回读有延迟可能造成闪烁中大规模场景层次Z缓冲维护场景的深度层次结构快速判断遮挡效率高适合大规模场景实现复杂需要预处理大规模静态场景门户剔除通过房间之间的门户判断可见性室内场景效率极高只适合室内需要手动标记室内关卡预计算遮挡数据离线计算可见性信息运行时查询运行时开销极低不适用于动态物体静态场景为主选哪个方案取决于你的场景特点。室内场景用门户剔除效果最好开放世界用层次Z缓冲更合适动态物体多用硬件遮挡查询比较灵活。3. 硬件遮挡查询的实操细节3.1 硬件遮挡查询的工作原理硬件遮挡查询是我在实际项目里用得最多的方案这里重点讲一下。它的核心思路是这样的你先让GPU渲染一个物体的包围盒但不写入颜色缓冲和深度缓冲只统计有多少像素通过了深度测试。如果没有任何像素通过深度测试说明这个物体完全被遮挡了下一帧就可以跳过它。具体流程分三步发起查询在渲染物体之前先渲染它的包围盒同时启动一个遮挡查询。获取结果查询结果不是立刻能拿到的GPU需要时间处理。通常要等一两帧之后才能回读结果。决定是否渲染根据上一帧的查询结果决定这一帧是否渲染该物体。这里有个关键点查询结果是异步的有延迟。这意味着你用的是“上一帧的可见性”来判断“这一帧要不要渲染”。大多数情况下没问题但在摄像机快速移动或者物体突然出现时可能会出现闪烁或者漏渲染。3.2 查询对象的粒度怎么选一个很实际的问题你是给每个物体单独发一个查询还是把一组物体合并成一个查询如果每个物体单独查询精度最高但查询数量太多CPU和GPU之间的通信开销会很大。如果合并查询开销小了但精度下降可能一个组里有一个物体可见整组都要渲染。我的经验是按空间位置分组每组控制在10到30个物体之间。同一组物体在空间上要靠近这样它们的可见性比较一致。比如一个房间里的家具可以分成一组一棵树和它周围的灌木可以分成一组。分组之后给每组生成一个合并的包围盒用这个包围盒去做遮挡查询。如果查询结果显示这个包围盒完全被遮挡那整组都不渲染。3.3 延迟补偿与防闪烁处理前面说了遮挡查询有延迟可能导致物体闪烁。怎么解决第一种方法是保守策略如果上一帧查询结果是“可见”这一帧就渲染如果上一帧是“被遮挡”这一帧也渲染但只渲染一个简化版本。这样即使判断错了玩家看到的也是一个低模不会出现物体突然消失的情况。第二种方法是多帧确认连续两帧查询结果都是“被遮挡”才真正剔除。这样牺牲了一点性能但换来了稳定性。第三种方法是距离加权距离摄像机近的物体即使查询结果显示被遮挡也保守地渲染距离远的物体可以更激进地剔除。因为近处的物体如果突然消失玩家很容易察觉远处的就不太明显。我在项目里通常用的是第一种加第三种组合近处保守远处激进同时被遮挡的物体渲染一个极低精度的替代模型。实测下来既保证了帧率又几乎没有可见的闪烁。4. 层次Z缓冲的工程实现4.1 什么是层次Z缓冲层次Z缓冲是另一种非常高效的遮挡剔除方案特别适合大规模静态场景。它的核心思想是把上一帧的深度缓冲拿过来构建一个多级分辨率的深度金字塔。最底层是原始分辨率往上一层分辨率减半再往上再减半直到最顶层只有一个像素。每一层的每个像素存储的是对应区域的最大深度值。判断一个物体是否被遮挡时用它的包围盒投影到屏幕上然后根据包围盒的大小选择合适层级的深度图进行测试。如果包围盒的最近深度大于深度图中对应区域的最大深度说明这个物体完全被遮挡了。这个方案的好处是一次构建多次查询查询速度极快。而且不需要GPU回读完全在CPU端就能完成。4.2 深度金字塔的构建过程构建深度金字塔的步骤从GPU回读上一帧的深度缓冲分辨率通常是屏幕分辨率。把深度缓冲降采样到一半分辨率每个像素取2x2区域的最大值。继续降采样直到1x1。把每一层都保存下来形成一个金字塔结构。降采样的过程可以用Compute Shader在GPU端完成速度很快。回读到CPU端的数据量也不大因为最顶层只有几个像素。这里有个细节深度值要用最大深度不是最小深度。因为我们要判断的是“物体是否被遮挡”如果某个区域的最大深度都比物体近那物体肯定被挡住了。4.3 查询时的层级选择策略查询时选哪一层深度图直接影响判断的准确性和速度。选太底层高分辨率判断精确但查询慢选太顶层低分辨率查询快但容易误判。我的做法是根据物体包围盒在屏幕上的投影大小来选层。如果包围盒投影后占屏幕的1/4就选第2层1/4分辨率占1/16就选第4层。这样保证每个物体查询时深度图上覆盖它的像素数量在一个合理范围内既不会太多导致慢也不会太少导致误判。具体公式是层级 log2(屏幕面积 / 包围盒投影面积) / 2然后取整。这个公式不是绝对的可以根据实际效果微调。5. 门户剔除在室内场景的应用5.1 门户剔除的基本概念门户剔除是室内场景的利器。它的思路非常直观室内场景是由一个个房间组成的房间之间通过门、窗、走廊连接。玩家在某个房间里只能看到通过门户能看到的其他房间看不到的房间就可以整个剔除掉。实现上你需要把场景划分成若干个单元格每个单元格是一个封闭空间。单元格之间的连接处就是门户。运行时从玩家所在的单元格出发通过门户进行可见性传播判断哪些单元格可见。5.2 单元格与门户的标记方法标记单元格和门户是个体力活但也有一些技巧。单元格的划分要尽量规整一个房间一个单元格走廊可以单独作为一个单元格。门户要放在房间的连接处大小要刚好覆盖门洞或者窗户。在Unity里你可以用空物体来标记单元格和门户然后写一个编辑器脚本自动生成数据。在Unreal里可以用Volume来标记。自研引擎的话通常是在场景编辑器里手动标记。有个坑要注意门户的方向性。门户是有正反面的从A房间通过门户能看到B房间但从B房间通过同一个门户不一定能看到A房间。标记的时候要确保门户的法线方向正确。5.3 可见性传播算法从玩家所在单元格开始做一次广度优先搜索。对于每个可见的单元格检查它所有的门户如果门户在视锥体内且门户对面的单元格还没被访问过就把它加入可见集合。这个过程可以用递归实现也可以用队列实现。递归写起来简单但深度大了可能爆栈。队列实现更稳妥。传播过程中要做视锥剔除只考虑在视锥体内的门户。还要做遮挡测试如果门户被其他物体挡住了也不能传播过去。实测下来一个中等规模的室内场景门户剔除可以把DrawCall降低60%到80%。效果非常显著。6. 预计算遮挡数据的思路6.1 什么时候需要预计算如果你的场景是静态的或者动态物体很少预计算遮挡数据是一个非常好的选择。它的运行时开销几乎为零因为所有计算都在离线阶段完成了。预计算的核心思路是把场景空间划分成一个个小格子对于每个格子预先计算出从该格子出发能看到哪些物体。运行时根据玩家所在的格子直接查表得到可见物体列表。6.2 空间划分与可见性采样空间划分可以用均匀网格也可以用八叉树。均匀网格实现简单但格子大小不好选。八叉树自适应性强但实现复杂。我的建议是先用均匀网格格子大小根据场景尺度来定。比如一个房间5米见方格子可以取1米。如果场景很大格子可以取2米或5米。对于每个格子从格子中心向四面八方发射射线采样可见性。射线的数量和分布决定了预计算的精度。通常每个格子发射几百到几千条射线覆盖整个球面方向。每条射线打到的第一个物体标记为可见。射线继续传播如果穿过了透明物体后面的物体也标记为可见。6.3 数据存储与运行时查询预计算的结果是一个巨大的可见性矩阵格子数量乘以物体数量。如果格子有10000个物体有5000个那就是5000万个布尔值。存储量不小需要压缩。压缩方法有几种用位图存储每个物体一个bit用行程编码连续可见或不可见的物体合并存储用层次结构先存大组再存小组。运行时查询就很简单了根据玩家位置找到对应格子取出可见物体列表只渲染这些物体。如果玩家在两个格子之间可以取两个格子的并集或者做插值。这个方案最大的问题是动态物体处理不了。如果场景里有会移动的物体预计算的数据就不准了。解决办法是把动态物体单独处理用其他遮挡剔除方案来管理。7. 常见问题与排查技巧实录7.1 物体闪烁或突然消失这是遮挡剔除最常见的问题原因通常是查询延迟或者判断过于激进。排查思路检查查询结果的回读延迟是几帧如果超过2帧考虑减少查询数量或者优化回读流程。检查剔除阈值是否过于激进适当放宽阈值让更多物体保守地渲染。检查是否有物体在帧间快速移动导致上一帧的查询结果不适用。我的经验是给每个物体加一个“可见性置信度”连续多帧被判定为遮挡才真正剔除一旦判定为可见就立即恢复渲染。这样能大幅减少闪烁。7.2 剔除后帧率反而下降听起来很反直觉但确实会发生。原因是遮挡查询本身也有开销如果查询数量太多开销可能超过剔除节省的渲染开销。排查思路统计查询数量和渲染物体数量的比例如果查询数量接近甚至超过渲染数量说明分组粒度太细了。检查查询的包围盒是否过大过大的包围盒会导致查询结果不准确同时增加GPU负担。考虑用层次Z缓冲替代硬件查询前者在物体数量多时更有优势。7.3 动态物体处理不当动态物体是遮挡剔除的难点因为它们的可见性变化很快预计算数据用不了硬件查询又有延迟。我的处理方案是动态物体不参与遮挡剔除或者只做视锥剔除。如果动态物体数量多给它们单独分组用更保守的策略。对于玩家角色附近的动态物体完全不剔除保证视觉正确性。7.4 透明物体和半透明物体的处理透明物体不能简单地用深度测试来判断遮挡因为它们本身不写入深度也不应该被深度遮挡。处理方法是透明物体单独一个渲染队列不参与遮挡剔除。或者只做视锥剔除不做遮挡剔除。因为透明物体通常数量不多对性能影响有限。7.5 多摄像机场景的注意事项如果有多个摄像机比如主视角、小地图、反射摄像机每个摄像机都需要独立的遮挡剔除计算。这时候要注意不要共用查询结果。每个摄像机的可见性是不一样的共用会导致错误。但可以共用预计算的遮挡数据因为那是与摄像机无关的。8. 性能对比与实测数据8.1 测试场景说明我在一个中等规模的室内场景里做了对比测试。场景包含约200个房间5000个静态物体200个动态物体。摄像机在场景中沿固定路径移动记录平均帧率和DrawCall数量。测试平台主流桌面级CPU和GPU1080p分辨率。8.2 各方案性能数据方案平均帧率平均DrawCallCPU占用GPU占用无遮挡剔除45320060%95%硬件遮挡查询78110075%65%层次Z缓冲8595065%60%门户剔除9270055%55%预计算遮挡9565050%52%从数据可以看出门户剔除和预计算遮挡的效果最好但适用场景有限。硬件遮挡查询和层次Z缓冲更通用效果也不错。8.3 不同场景规模的选型建议小型场景物体少于500不用遮挡剔除视锥剔除就够了。中型场景物体500到5000硬件遮挡查询或层次Z缓冲。大型室内场景门户剔除。大型室外场景层次Z缓冲加预计算遮挡。动态物体多的场景硬件遮挡查询配合保守策略。9. 实操中的经验与避坑指南9.1 不要过早优化我见过很多项目场景还没搭完就开始搞遮挡剔除结果场景一改所有剔除数据都要重做。先把场景搭好性能问题暴露出来之后再针对性地做遮挡剔除。9.2 遮挡剔除不是万能的遮挡剔除能解决“看不到的物体还在渲染”的问题但解决不了“看得到的物体太复杂”的问题。如果玩家面前就是一个几百万面的模型遮挡剔除帮不了你你需要的是LOD和模型优化。9.3 可视化调试非常重要遮挡剔除的逻辑很复杂出了问题很难直观地看出来。一定要做可视化调试工具把剔除结果用颜色标记出来。可见的物体绿色被剔除的物体红色被视锥剔除的黄色。这样一眼就能看出问题。9.4 不同平台策略不同PC平台CPU性能强可以用复杂一点的算法。移动平台CPU弱GPU也弱要尽量用预计算数据减少运行时计算。主机平台介于两者之间可以灵活选择。9.5 版本迭代要留余量遮挡剔除的参数不是一次调好的需要反复迭代。每次场景大改都要重新调参。所以代码里要把参数暴露出来方便快速调整。10. 遮挡剔除与其它优化手段的配合遮挡剔除从来不是单独使用的它需要和其它优化手段配合才能发挥最大效果。和LOD配合远处物体用低模近处用高模。遮挡剔除决定“画不画”LOD决定“画多细”。两者结合效果翻倍。和批处理配合遮挡剔除减少了DrawCall数量批处理减少了每个DrawCall的开销。两者一起用CPU和GPU都能受益。和光照贴图配合静态物体用光照贴图动态物体用实时光照。遮挡剔除剔除掉的物体光照计算也可以跳过。和流式加载配合遮挡剔除判断为不可见的区域可以考虑卸载资源节省内存。但要注意卸载后如果玩家突然转头可能会看到加载延迟。我在项目里的做法是遮挡剔除负责运行时渲染优化预计算遮挡数据负责流式加载的可见性判断两者共用一套空间划分数据。这样既省了计算又保证了逻辑一致。11. 从零实现一个简易遮挡剔除系统11.1 系统架构设计如果你要自己实现一个遮挡剔除系统我建议从最简单的硬件遮挡查询开始。架构分三层数据层管理场景物体的包围盒、分组信息、查询对象。计算层发起查询、回读结果、更新可见性状态。应用层根据可见性状态决定哪些物体进入渲染队列。11.2 核心代码结构class OcclusionCullingSystem { public: void Initialize(); void Update(const Camera camera); void AddObject(GameObject* obj); void RemoveObject(GameObject* obj); private: struct QueryGroup { BoundingBox bounds; std::vectorGameObject* objects; OcclusionQuery query; bool visible; int frameDelay; }; std::vectorQueryGroup groups; void BuildGroups(); void IssueQueries(const Camera camera); void ReadbackResults(); void UpdateVisibility(); };11.3 关键步骤实现初始化时遍历所有物体按空间位置分组。每组生成一个合并包围盒创建一个查询对象。每帧更新时先回读上一帧的查询结果更新每组的可见性状态。然后对可见的组发起新的查询。注意查询要在渲染之前发起但结果要等渲染之后才能回读。渲染时只渲染可见性状态为true的组里的物体。对于状态为false的组跳过渲染。11.4 调试与验证实现完之后一定要做验证。最简单的验证方法是把剔除结果和实际渲染结果做对比。如果剔除后画面和剔除前完全一样说明剔除是正确的。如果有差异说明有物体被错误剔除了。我通常会写一个调试模式把所有被剔除的物体用红色线框画出来。这样一眼就能看出哪些物体被剔除了以及剔除是否合理。12. 动态遮挡剔除的进阶思路12.1 动态物体的可见性更新动态物体的可见性变化很快用传统的查询方式跟不上。我的做法是对动态物体做保守估计只做视锥剔除不做遮挡剔除。如果动态物体数量多可以给它们单独分组用更频繁的查询。12.2 时间相干性的利用相邻帧之间物体的可见性通常变化不大。可以利用这个特性减少查询频率。比如每两帧查询一次中间帧用上一帧的结果。这样能减少一半的查询开销。12.3 基于GPU的可见性缓冲更高级的方案是用GPU的可见性缓冲。在GPU端维护一个可见性缓冲区每个物体一个bit。渲染时GPU自己判断物体是否可见并更新缓冲区。CPU只需要读取缓冲区来决定是否提交DrawCall。这个方案延迟更低但实现复杂需要Compute Shader支持。13. 不同引擎的遮挡剔除配置要点13.1 Unity的遮挡剔除配置Unity内置了遮挡剔除功能在Window菜单下找到Occlusion Culling面板。配置步骤把所有静态物体标记为Occluder Static和Occludee Static。打开Occlusion Culling面板点击Bake。调整参数Smallest Occluder决定最小的遮挡物尺寸Smallest Hole决定最小的缝隙尺寸。烘焙完成后在Scene视图里可以预览剔除效果。Unity的遮挡剔除是基于预计算的只对静态物体有效。动态物体需要额外处理。13.2 Unreal的遮挡剔除配置Unreal的遮挡剔除更自动化默认就开启了硬件遮挡查询。你可以在项目设置里调整相关参数Occlusion Query Pool Size查询池大小根据场景复杂度调整。Min Screen Radius for Occluders遮挡物的最小屏幕半径小于这个值的不参与遮挡。Min Screen Radius for Occludees被遮挡物的最小屏幕半径。Unreal还支持预计算可见性在Lightmass设置里可以开启。13.3 自研引擎的注意事项自研引擎实现遮挡剔除最大的坑是跨平台兼容性。不同GPU厂商的遮挡查询实现有差异有的快有的慢有的支持异步回读有的不支持。一定要在目标平台上充分测试。另外自研引擎通常没有现成的可视化工具需要自己写。这个投入是值得的因为遮挡剔除的问题没有可视化工具很难排查。14. 遮挡剔除的未来趋势14.1 硬件光追与遮挡剔除硬件光追普及之后遮挡剔除可能会被光线追踪自然取代。因为光追本身就是按需计算可见性不需要额外的剔除步骤。但目前光追性能还不够遮挡剔除仍然是必要的优化手段。14.2 机器学习辅助的可见性预测有研究在用机器学习预测物体可见性根据历史数据训练模型运行时用模型快速判断。这个方向还在早期但潜力很大。14.3 云游戏的遮挡剔除云游戏场景下渲染在服务器端完成遮挡剔除可以帮助减少服务器端的渲染开销降低带宽需求。但云游戏的延迟特性对遮挡剔除提出了新的要求。我在实际项目里踩过的坑远不止这些遮挡剔除这件事理论看着简单实操起来细节非常多。每个场景都有自己的特点没有一套参数能打天下。多测试多调试多观察慢慢就能找到适合自己项目的方案。
阅读完成 · 觉得有帮助?
咨询建站