ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity VR草地场景优化:Draw Call与LOD合批实战

Unity VR草地场景优化:Draw Call与LOD合批实战 做 Unity VR 的朋友应该都有体会场景在 PC 上跑得飞起一戴上头显就开始掉帧。尤其是草地这种大面积、高密度的场景简直是帧率杀手。项目里的草地如果有几万根草你数数每一根草要画几次——反正我第一次在 Frame Debugger 里看到跳动的 Draw Call 数字时是真的头皮发麻。这篇文章是 Unity VR 优化系列的第三篇专门聊草地场景的性能优化。核心就三件事怎么用 LOD 把远处不配消耗算力的东西省掉怎么把 Renderer 合并起来减少 Draw Call以及如何把合并过程中的各种坑一个个填平。内容基于我自己在多个 VR 项目里踩过的坑和实测记录不是理论推演拿回去可以直接用。不管你是做 PC VR 还是 Quest 这类一体机只要场景里有大面积的草地、植被或类似的高密度物体这篇的经验基本都适用。下面从最基础的问题开始聊。1. Draw Call 和 VR 到底有多大的关系1.1 一次 Draw Call 真正花的时间在哪很多刚入行的美术同学以为 Draw Call 是 GPU 的负担其实不完全是。Draw Call 最大的开销在 CPU 侧CPU 要帮 GPU 准备好渲染状态、绑定贴图、更新常量缓冲再把指令塞进命令缓冲区这个过程本身就要消耗不少指令周期。假设一个 Draw Call 在 PC 上平均消耗 0.05ms 到 0.1ms听起来不多但如果一帧有 1000 个 Draw Call光提交这笔账就要吃 50ms 以上的 CPU 时间帧率直接掉到 20fps 以下。GPU 本身反而没那么怕 Draw Call它更怕的是切状态换 shader、换贴图、换渲染管线状态这些都会打断 GPU 的流水线。所以减少 Draw Call 的本质是减少 CPU 侧的开销和 GPU 侧的频繁状态切换。1.2 VR 的双重渲染和三重苛刻要求VR 和普通桌面渲染最大的区别是每个物体理论上要画两遍左右眼各一遍。如果你的 Unity 项目用的是 Multiview 或 Single Pass InstancedGPU 可以在一个 Draw Call 里处理两只眼睛但如果项目停留在旧的 Single Pass 甚至 Multi Pass 模式那就等于所有优化都要打对折。更麻烦的是 VR 对帧率的硬性要求。桌面游戏 30fps 都能忍PC VR 至少 72fpsQuest 2 甚至要求保持 72Hz 或 90Hz并且帧时间要非常稳定不能一阵快一阵慢否则用户会直接眩晕。想想看假设目标 72fps每帧预算只有 13.9ms结果 Draw Call 提交就吃了 8ms剩下 5.9ms 要留给整个场景的渲染和后处理这根本不够用。1.3 如何判断项目真的被 Draw Call 卡住了优化之前先确认问题不然很容易白忙一场。我的习惯是看两样东西Profiler 的 CPU 耗时和 GPU 耗时还有 Frame Debugger 里的 Draw Call 总数。如果你的 Profiler 里 CPU渲染线程曲线明显高于 GPU 曲线且 Draw Call 数量在四位数以上那基本可以断定是 Draw Call 或状态切换造成的 CPU 瓶颈。如果 GPU 曲线反而是最粗的那条那你要去看是不是 shader 太复杂、三角形太多或者 overdraw 太严重这时候合并 Renderer 就不是首要任务了。我在项目里遇到的情况很典型PC 上 GPU 时间 12msCPU 时间只有 4ms一到 Quest 2CPU 主线程与渲染线程飙升到 9ms 以上而 GPU 反而只有 7ms 左右。这种情况说明瓶颈已经从 GPU 转移到了 CPU 提交上Draw Call 优化就成了一件必须做的事。2. 草地 LOD分档渲染是第一道闸门2.1 草地的 LOD 分几档每档该画什么单从性能角度讲草地是很容易出现人均三角形过多的物件。一个理想状态的草地策略是按距离分三档渲染让每一帧的草都有意义。LOD 0近距离通常 0 到 10 米画完整的草模型。这一档可以带顶点动画模拟风吹效果也允许更密的网格。因为 VR 里玩家近距离看草的机会并不多但一旦凑近看精度不够会非常出戏。LOD 1中距离通常 10 到 30 米画简化网格或半密度草从。网格数可以砍到 LOD 0 的三分之一顶点动画可以不播或者播得很弱。这个距离上的草已经不太值得做精细动画人眼也捕捉不到细微的摆动。LOD 2远距离30 米以外画 Billboard 或十字交叉面片。这档的草就是一个带透明贴图的面片甚至可以直接用 unlit shader。草从在这个距离上本来就是一片绿色噪点Billboard 的渲染成本和真实网格相差巨大效果却几乎一样。2.2 LOD Group 配置实操记录Unity 里最直接的 LOD 方案是给物体挂 LOD Group 组件然后在组件里分配三个 Renderer 分别对应 LOD 0、1、2。配置时选好每个 LOD 对应的物体再调整屏幕占比阈值。但这里有个小技巧屏幕占比是按整个画面来算的对草地这种面积大但高度矮的对象并不友好。我建议不用默认的百分比而是直接在代码里按世界坐标距离强制切换 LOD这样更可控。using UnityEngine; public class GrassLODSwitcher : MonoBehaviour { public LODGroup lodGroup; public float lod0Distance 10f; public float lod1Distance 30f; private Transform cameraTransform; void Start() { cameraTransform Camera.main.transform; } void Update() { float dist Vector3.Distance(cameraTransform.position, transform.position); if (dist lod0Distance) lodGroup.ForceLOD(0); else if (dist lod1Distance) lodGroup.ForceLOD(1); else lodGroup.ForceLOD(2); } }还可以用 CullingGroup 做同样的事CullingGroup 对大量物体会更高效它可以一次管理成百上千个对象的可见性和 LOD 距离不用每个对象都跑一个 Update 脚本。using UnityEngine; public class GrassCullingGroup : MonoBehaviour { public LODGroup[] lodGroups; private CullingGroup cullingGroup; private BoundingSphere[] spheres; void Start() { spheres new BoundingSphere[lodGroups.Length]; for (int i 0; i lodGroups.Length; i) spheres[i] new BoundingSphere(lodGroups[i].transform.position, 40f); cullingGroup new CullingGroup(); cullingGroup.targetCamera Camera.main; cullingGroup.SetBoundingSpheres(spheres); cullingGroup.SetBoundingSphereCount(spheres.Length); } }CullingGroup 的好处是它把维护一堆对象的开销收敛到一个系统里对 VR 这种每一毫秒 CPU 时间都很宝贵的场景来说这笔账算得很值。2.3 LOD 切换时防止跳动和弹入LOD 做得最尴尬的事情是切换时草的形状突然变了一个样子玩家一眼就能看穿。尤其带着 VR 头显视角一偏近处的草突然从完整网格跳成 Billboard画面非常出戏。解决办法有三个我一般同时用LOD 切换距离之间留出重叠区渐变过渡启用 LOD Group 的 Animate Cross-fading在切换时交叉淡化一段时间再完成替换把 LOD 2 的 Billboard 贴图做好颜色和明度的匹配别让远处草的绿色跳出一个完全不同的色调还有一个被很多人忽略的问题如果你在编辑场景时直接手动调整 LOD Group 的阈值没有把fadeMode设置成 CrossFade那切换时会出现明显的 popping。用代码统一设置一次能省很多麻烦。void ConfigureFade(LODGroup group) { group.fadeMode LODFadeMode.CrossFade; group.animateCrossFading true; group.crossFadeAnimationDuration 0.3f; }CrossFade 会额外消耗一点渲染资源因为过渡期间两个 LOD 都要画时间很短VR 里换来的观感提升完全值得那点开销。3. Renderer 合并把数量压下来3.1 三条合批路线的原理与选择LOD 做完了草地上单个物体的 Draw Call 还剩不少。下一步就是把 Renderer 合并起来减少 Draw Call 数量。Unity 里合批路线主要有三条每一条都有自己的适应场景静态批处理Static Batching引擎自动把标记为 Static 的物体网格合并到一块提交一个大的 Draw Call。优点是省心缺点是合并不够灵活如果场景里大量物体动了动态部分还是各自为战。GPU Instancing适合大量重复的网格和材质。一次 Draw Call 可以绘制几十甚至上百个实例对草这种重复度极高的物体是天然合适的选择。手动合并用脚本自己生成合并后的 Mesh再挂一个新的 MeshRenderer。可控性最强同时也会产生一个明显副作用合并前的每个物体无法再单独控制材质。草地场景我的选择是近处用手动合并中远距离用 GPU Instancing Billboard这样既能保证细节又不至于把 CPU 拖垮。3.2 手动合并的实操步骤手动合并需要做的是收集所有草地块的 MeshFilter再调用 Unity 的 CombineMeshes 把多个子网格合并成一个网格。这里有一个关键点合并前必须把所有草地块的材质统一成同一个材质否则合并后的网格依然会产生多个子网格。using UnityEngine; using System.Collections.Generic; public class GrassMeshCombiner : MonoBehaviour { public GameObject grassContainer; // 所有草地块的父对象 [ContextMenu(Combine Grass Meshes)] public void CombineGrass() { MeshFilter[] filters grassContainer.GetComponentsInChildrenMeshFilter(); CombineInstance[] combine new CombineInstance[filters.Length]; for (int i 0; i filters.Length; i) { combine[i].mesh filters[i].sharedMesh; combine[i].transform filters[i].transform.localToWorldMatrix; } Mesh combinedMesh new Mesh(); combinedMesh.CombineMeshes(combine, true, true, false); MeshRenderer renderer grassContainer.GetComponentMeshRenderer(); if (renderer null) renderer grassContainer.AddComponentMeshRenderer(); grassContainer.AddComponentMeshFilter().sharedMesh combinedMesh; } }合并完之后原来的草地块就全部被一个 MeshRenderer 替代Draw Call 会从几百笔直接掉到个位数。但我建议不要把所有草地合并成一个超大的 Mesh要按区块切分。比如一张 100x100 米的草地切成 10x10 米的区块每个区块单独合并这样有利于遮挡剔除和碰撞检测。切分区块的方法很简单在编辑器里把每一块草地放进一个空的父 GameObject然后对每个父对象独立执行上面的合并脚本。每次合并的对象数量控制在 200 到 400 个左右Clinching 的效率会高很多。3.3 材质和贴图图集的前置要求合并的前提是材质必须一致。如果草地里混了几个不同颜色的草种直接合并会出现颜色混杂而且切到 Frame Debugger 里看到的总 Draw Call 数并不会明显下降。解决办法是把不同草种的贴图合并进同一张纹理图集Texture Atlas然后用网格 UV 偏移来选择图集中的不同区域。这样多个草种共享同一个材质、同一个 shader合批才能被完整执行。具体操作上Unity 的 Terrain 系统自带 Splatmap 纹理合并能力但纯流程上手写过 UV 图集处理的同学都知道边缘采样稍有不慎就会出现很丑的拉伸和色偏。我的建议是如果只是颜色差异干脆把颜色烘焙进顶点色或者用 shader 的实例化参数来差异这样比改 UV 图集更不容易出问题。GPU Instancing 本身就允许你通过 MaterialPropertyBlock 给每个实例传递不同的颜色、缩放等参数这种差异是不打断合批的。对于 VR 里需要大量重复草种的需求这个方案比合并图集划算得多。4. Draw Call 优化实践清单4.1 用 Frame Debugger 看每一笔渲染开销合批做完之后还需要用 Frame Debugger 确认哪些物体依然在独立渲染。打开 Frame Debugger 的方式Window Analysis Frame Debugger然后在 Game 视图里逐帧回放左侧每一条事件就是一次 Draw Call。Frame Debugger 会明确标出每个 Draw Call 是正常渲染、阴影、还是包含透明混合。我建议按下面的方式逐个排查先把阴影相关的 Draw Call 遮掉看剩下多少再把透明物体遮掉再看剩下多少最后把草地相关的批次单独标记出来。正常情况下优化后的简单场景里 Draw Call 总数在 100 到 300 是比较理想的。如果分开看时发现某一部分仍然偏高那就是这一部分没有合批成功。最常见的两大原因是材质实例不同或 Mesh 没有进入同一个静态批次。4.2 一个可以直接抄的优化清单我把自己在 VR 项目中反复使用的 Draw Call 优化清单贴出来遇到性能问题可以直接按这个顺序走确认 Player Settings XR Plug-in Management 里启用了 Single Pass Instanced把草地、石头、围栏这类静态物体切成区块后合并 Mesh共享同一份材质打开 GPU Instancing 选项在材质 Inspector 面板里勾选 Enable GPU Instancing用 Frame Debugger 检查并修正导致批次中断的材质差异调低 Directional Shadow 的距离和级联数影子距离能少则少关闭场景中不必要的后处理特效尤其是 AO 和 Bloom它们在 VR 里的开销会被双眼渲染放大检查相机设置去掉不必要的 Layer Culling 或遮挡剔除的漏检区域如果开启了 MSAA适当降档VR 里 2x MSAA 对很多一体机来说已经很吃力有些模块需要改代码比如它的 ForwardRenderer 设置里可以禁用额外的 Render Feature。但你如果直接从 Frame Debugger 看到某一条 shader pass 是边缘光或轮廓基本可以断定开了额外特效关掉即可。4.3 两个最容易翻车的优化方向优化做过头比不优化还难受我见过太多项目栽在这两个方向上。第一个是过度合并静态网格。把大面积地形、静态灌木、草块全部塞进一个 Mesh 会让遮挡剔除彻底失效因为整个 Mesh 的包围盒覆盖了整片区域。即使在 VR 里你只盯着一个角落引擎也会把整个合并 Mesh 提上来渲染。正确做法是按区块合并同时给每个区块设置可以参与剔除的垂直结构让相机看不到的地方真的不渲染。第二个是不看场景只看数量。有些朋友追求 Draw Call 压到最低结果把批次合得又大又密导致顶点数上涨、GPU 渲染负担瞬间翻倍帧率反而更差。Draw Call 是 CPU 的负担三角形和 overdraw 是 GPU 的负担这两个要均衡。VR 里最怕的就是 CPU 和 GPU 同时都到临界任何一个卡住都会头晕。5. 实测全过程与性能对比5.1 优化前的基线数据我做这个优化实验用的场景是一个 50x50 米的沙盒草地里面手工放置了大约 1800 个草地块每块 5 到 10 根草外加几十块灌木和石头。测试硬件是 Quest 2 配合 PC Link目标帧率 72Hz。优化前打开 Profiler数据相当难看指标数值Draw Call1124CPU 主线程耗时7.2ms渲染线程耗时8.5msGPU 耗时9.1ms三角形数量约 86 万帧率稳定 45fps偶尔骤降这个数据说明 CPU 和 GPU 都在极限边缘但 CPU 已经完全跟不上帧率需求了属于典型的双重瓶颈。5.2 逐步实施的优化与数据对比我按顺序做优化每一步都记录一次数据方便对比每一步的真实收益。第一步启用 Single Pass Instanced。 这一步在 Player Settings 里改完Draw Call 总数直接减半因为左右眼渲染共享了批次。数据变成 Draw Call 662CPU 主线程 5.1msGPU 6.8ms。单人做好这一项性能立刻上了一个台阶。第二步给草地加 LOD。 把每块草地分成近、中、远三档远一点的替换成 Billboard。这次改动让 Draw Call 掉到 398GPU 耗时降到 5.2ms纬度数也砍掉了将近 30 万。这一步立竿见影但画面里已经能看到远处细微的跳变。第三步按区块手动合并 Mesh。 我把 50x50 米的草地切成 4 个区块每个区块选择性地合并同材质物体自写脚本运行了几分钟。合并后的 Draw Call 降到 176CPU 主线程耗时降到 3.1msGPU 耗时降到 4.2ms。第四步清理阴影和后处理。 把方向光阴影距离从 100 米缩到 30 米级联数从 4 降到 2关掉 AO 和 Bloom。最终数据如下指标优化前优化后Draw Call112498CPU 主线程耗时7.2ms2.4msGPU 耗时9.1ms3.5ms三角形数量约 86 万约 42 万帧率45fps 不稳72fps 全程稳定5.3 最终效果和稳定性说明优化完成后戴上头显实际体验了 20 分钟。帧率稳定在 72fps中间没有观察到明显的掉落。画面效果方面近处草的形态保留了足够的细节风吹动画依然可见中距离用简化网格不仔细看很难分辨远处 Billboard 在转动视角时会有轻微十字架感但整体观感完全可以接受。最终这套方案在场景中新增了 200 多个小型装饰物石头、木桩、花丛Draw Call 也只多出 60 左右总帧耗时依然在 72Hz 的预算之内。这说明单体优化做完之后场景的可扩展性大大提升。6. 常见问题与避坑记录6.1 我踩过的最痛的五次坑坑一合并后 Mesh 碰撞体消失。合并草地块时把碰撞体一并合并了导致玩家直接穿过草丛。解决办法是在合并脚本里跳过 Collider或者保留最简单的一两个 Box Collider 作为地面阻挡草丛本身用透明忽略碰撞。坑二LOD 切换在 VR 里特别明显。桌面端看着没问题的 LOD 弹入在头显里因为刷新率高、视角移动快会看得一清二楚。后来给切换加了 0.3 秒的 CrossFade并把 LOD 1 的距离阈值向后推了 15%这个问题才彻底解决。坑三合并 Mesh 后出现紫红色材质。这是最常见的渲染问题之一。原因多半是 shader 不兼容或图集 UV 写入失败大多数和合并时renderer.sharedMaterial没有统一设置有关。合并前务必重新分配材质并确认 shader 支持你所用的所有纹理属性。坑四阴影批次把 Draw Call 吃回去。我优化了主场景渲染但没管影子结果 Frame Debugger 里阴影批次占了 40%。后来通过调低 shadow distance、关闭物体级阴影覆盖这一块立刻清干净。坑五CullingGroup 初始化没释放。这是在代码里没有正确销毁 CullingGroup 导致的。频繁热重载或切场景后CullingGroup 对象越来越多内存泄漏到项目崩掉。记得在 OnDestroy 或场景切换时调用cullingGroup.Dispose()。6.2 新手最容易忽略的细节除了上面那些大坑还有几个不起眼的小细节容易被忽略但影响很大。首先是纹理格式。VR 一体机上纹理格式直接决定带宽占用同样的贴图用 ASTC 6x6 比 RGBA32 能省掉 70% 以上的带宽Loading 速度也快得多。如果你做的是 Quest 项目把主贴图、法线贴图全部转成 ASTC性能能再提一截。其次是材质属性是否打断合批。直接改 MeshRenderer 的 material 会创建出一个材质实例一旦有多个实例存在GPU Instancing 和合批就会被打断。想要改颜色或缩放用 MaterialPropertyBlock 传参不要直接 new 材质。最后是 Unity 版本差异。Unity 2022 以后 SRP Batcher 的合批能力比内置管线强很多如果你还在老版本建议找个时间把渲染管线升级到 URP 或 HDRP同一套优化手段在新管线里效果更稳定。写在最后我个人的体会是Unity VR 优化的核心不是某一个单项技术而是 CPU 和 GPU 之间的平衡。Draw Call 压得低固然重要但如果因此把 GPU 拖垮、把画面画质削到没法看那就本末倒置了。LOD、Renderer 合并、Draw Call 压减这些手段要组合起来用每一刀都切在痛点上最终才能换来流畅稳帧的 VR 体验。如果你也在做类似项目建议先把 Profiler 和 Frame Debugger 的数据摸透再动手优化。数据永远比直觉可靠尤其是在头显里一点点卡顿都会被无限放大。
返回列表