
这是《折腾一个优化》系列的第五篇离上一篇已经隔了一段日子。前四轮我把 XR 工程配置、场景结构拆分、实时阴影这些基础问题挨个过了一遍帧率从最初只能跑 20 多帧拉回到了 70 帧左右但一进这个风格化村庄的核心区域CPU 渲染线程的耗时还是会突然顶到 8 毫秒以上GPU 端却不怎么吃紧。这种 CPU 和 GPU 明显失衡的状态基本可以断定问题出在场景内容本身物体数量爆炸、材质不统一、批次完全没收敛。这一轮优化的目标很简单让 Neo3 上这个村庄在长时间游玩时稳定不抖、不晕。我希望用这篇记录把“怎么定位问题—怎么拆解优化—怎么填坑”的完整过程讲清楚尤其是给同样在移动 VR 上做风格化场景的朋友一些可抄作业的参考。整篇文章会围绕批次、光照烘焙、纹理内存、LOD 与剔除这几块展开每一步都会说明为什么这么做以及实测下来的数据变化。如果你手头也有一个怎么调都显得“笨重”的场景这篇应该能帮你找到一个明确的切入点。1. 先给 Neo3 算算老账90Hz 的帧预算怎么分1.1 11 毫秒的硬指标留多少余量才安全PICO Neo3 的屏幕刷新率是 90Hz这就意味着每一帧留给 CPU 和 GPU 的总时间只有 11.1 毫秒左右。很多刚从 PC VR 转过来的朋友会觉得“11 毫秒也不少”但在移动 VR 上这个数字比你想象中要紧张得多——因为双眼渲染意味着同样的场景要画两遍处理器还要承担瞳距校正、畸变矫正、时间扭曲这些看不见的额外开销。单眼分辨率如果按 1600×1440 算双眼加上畸变余量后实际填充的像素数远超屏幕物理分辨率。GPU 要在 11 毫秒内完成这些填充CPU 还要在同一时间里把一整套绘制指令准备好两边任何一个超时掉的那一帧会直接反映在用户眼前表现为画面突然卡一下这在 VR 里的体感非常糟糕轻则眩晕重则直接想吐。我的习惯是不要把 11.1 毫秒当成满打满算的目标。长期运行的 VR 应用尤其是需要稳定交互的场景建议把每帧目标压在 9.5 毫秒以内。剩下 1.5 毫秒是给系统 GC、音频混音、手柄状态同步这些“偶发闹腾”留的缓冲区。如果你跑下来发现帧时间长期在 10 毫秒以上徘徊哪怕看着没掉帧也建议把它当掉帧处理因为系统只要多做一点事就会触发卡顿。1.2 用 Profiler 钉死瓶颈这次的问题不在 GPU 端拿到一个慢场景不要一上来就怀疑 Shader 复杂或者纹理太大第一步永远是看 Profiler 数据。我在 Unity Profiler 里跑了核心区域的 2 分钟采样把 CPU 侧的两条关键线程拆出来看Player Loop 的耗时在 3 毫秒左右而渲染线程Render Thread平均耗时会冲到 8 毫秒甚至更高。GPU 时间反而只有 3.5 毫秒上下说明当下的画面负载对 GPU 来说并不算极限。CPU 渲染线程偏高最经典的成因就是 Draw Call 数量过多或者每个 Draw Call 的提交成本过大。为了进一步确认我打开了 Frame Debugger统计这一帧的绘制调用总数——结果让我倒吸一口气在村庄中心区域一帧居然有 2100 多次 Draw CallBatch 数量也才 900 多。对于移动端 VR 来说这个数字如果不在百量级以内CPU 渲染线程很难有好的表现。顺手还看了一下场景里的物体总览运行时激活的 GameObject 有 4600 多个其中静态网格超过 3900 个非合并状态下每个网格都对应至少一次 Draw Call。场景里有大量树、篱笆、小石块、屋顶瓦片它们单个三角形数量不大但数量极多是典型的“小物海啸”。这也解释了为什么 GPU 不忙、CPU 却累死——GPU 画不动的是大三角形和大纹理而 CPU 累是累在一次次状态切换和提交指令。找到了核心瓶颈接下来的优化次序也就很清晰了先压 Draw Call再整理光照与阴影最后处理纹理内存和可见性剔除。这三步没有严格的先后依赖但我建议按这个顺序来因为 Draw Call 是先决条件这一项做不好后面的光照和纹理优化会被冲淡。2. Batch 战把 Draw Call 拉回一个量级2.1 先确认渲染管线的 VR 开关单通道实例化必须开着在动手合并网格之前先确认一个很多项目会忽略的底板配置Unity 的 XR 渲染模式。移动 VR 里必须使用 Single Pass Instanced单通道实例化也就是一次渲染指令同时画左右眼而不是左眼一遍、右眼再来一遍。如果项目还停在 Multi Pass那你实际上是在把整份 Draw Call 翻倍无论后面怎么优化都会矮人一截。检查路径是 Project Settings → XR Plug-in Management选择 PICO 设备配置把 Stereo Rendering Mode 设为 Single Pass Instanced。同样重要的是确认 URP 管线资产里的 Renderer 设置没有强制修改这项配置。我遇到过项目里两处设置不一致的情况——一个改了、一个没改结果帧数没提升查了半天才发现是后者的覆盖了前者。基础配置确认之后我还顺手把 Fixed Foveated Rendering固定注视点渲染打开了。这个功能让屏幕边缘的渲染分辨率低于中心利用人眼对周边视觉不敏感的特性来大幅减少 GPU 像素填充。对风格化场景来说边缘轻微变糊几乎看不出来但 GPU 时间能节省 20% 上下。如果你的项目还没开建议立刻去打开这个优化几乎零成本。2.2 SRP Batcher 与材质统一Batch 数量比 Draw Call 更能说明问题Draw Call 低并不等于 Batch 一定低。在 URP 里真正影响 CPU 渲染线程的是 SetPass Call 和 Batch 数量。SRP Batcher 是 URP 内建的批处理机制它的核心逻辑是只要绘制同一份 Shader 变体和材质参数就会把这些物体合并成一个 Batch大幅降低状态切换开销。但这个机制有一个硬性前提着色器必须兼容 SRP Batcher。场景里我用的风格化 Shader 是团队自写的 Toon Shader在 Asset 里显示“不兼容 SRP Batcher”。不兼容意味着 SRP Batcher 会直接绕过这些物体退化成以前标准的逐对象提交方式。我处理的办法是把 Shader 逐个并到 URP 的 SRP Batcher 兼容结构里。具体来说就是属性声明改成CBUFFER_START(UnityPerMaterial)包裹材质属性顶点光照计算里使用 URP 内置的GetVertexLightInputs、GetMainLight这些函数。改造完以后Frame Debugger 里可以看到大量物体被标记为 “SRP Batch”批处理生效后渲染线程明显松了口气。材质统一也是很重要的一环。场景里二十几棵树的材质颜色各不相同但底层 Shader 其实是同一个。只要把颜色拨成 HDR 颜色属性而不是拆成二十份物理材质SRP Batcher 就能把它们合并得很好。很多美术同学的习惯是“一种树一个材质”这个习惯在非批处理环境下没问题但到了移动 VR 里不做合并等于亲手把批处理的机会全部浪费掉。2.3 预合并小网格从 2100 次到 600 次的关键一跃SRP Batcher 能合并同一 Shader 的物体但场景里还躺着大量无法被合起来的独立网格篱笆桩、小石块、屋顶瓦片它们有的 Shader 不同有的材质参数每个都不同SRP Batcher 帮不上忙。针对这类纯静态物体最直接的手段就是预合并Pre-Combine。我写了一个编辑器工具把场景按区域框选同一个区域内、材质相同的静态网格导出来后在外部工具里合并成一个网格再导回。合并后的网格共享同一份顶点和索引缓冲Unity 提交一次 Draw Call 就能把一整片区域画完。这个过程等价于“主动的静态批处理”但比 Unity 内置静态批处理更可控——它让我能决定哪些合并、哪些保留也能避免内置批处理带来的内存爆炸。合并过程中踩到一个坑由于合并网格的顶点可能覆盖到原查看区域的边缘距离较远的物体也会被画出来造成无效填充。于是我按照村庄道路的 20 米半径进行区域分块逐块合并。这样每个合并网格只覆盖一个局部片区配合后面的遮挡剔除使用效果才最好。做完这批预合并后Draw Call 从 2100 多次降到了 600 次左右Batch 数量收敛到 350 上下CPU 渲染线程耗时从 8 毫秒降到了 4.2 毫秒。这个数据已经接近移动 VR 的安全线但离我的目标还差一点因为光照和阴影还没动。3. 把光照搬到烘焙时间风格化视觉的零性价比来源3.1 实时光照的位置成本和性能曲线风格化村庄往往有其他类型场景没有的光照难题大面积的定向光、高对比度色块、明显的轮廓光。这些视觉效果如果全部交给实时渲染每一个光源都会产生额外的着色计算和阴影绘制Neo3 扛不住尤其是村庄里有几十个小型火把和灯笼它们如果全都是实时点光源每多一个就是几百次额外着色器变体提交。查看 Profiler 时发现场景里居然有 86 个 Light 组件。虽然 Unity 对实时光源数量有限制不会全部生效但光是每个可见光源的阴影计算和实时光照混合就让 GPU 特征不再那么轻松同时 CPU 侧的状态切换也被这些光源拉高了不少。后来我做了核查真正对画面有显著贡献的光源只有 1 个主方向光和几个局部补光其余多数光源其实在 Shader 里根本没有被正确启用纯粹是美术摆上去的“费电装饰”。这个问题的解法不是简单删光源而是合理规划光照模式。村庄建筑、地面、篱笆这些静态物体应该全部走入烘焙光照用预计算的光照贴图来呈现最终颜色只有真的需要响应的动态光源比如玩家手持火把才保留成实时点光源。这样静态部分的光照颜色是固定的不再随帧重新计算性能自然就上去了。3.2 烘焙与 Lightmap 参数调优风格化颜色能不能保住烘焙之前的第一个疑虑是“风格化漫反射在光照贴图里会被烤糊”。很多团队对烘焙的抵触都来自这里他们觉得 Lightmap 会把低多边形干净的颜色变成一个油腻脏脏的样子。实际用下来只要把控好烘焙时的 Lightmap 分辨率与压缩设置风格化的干净感是可以保住的。我在 PICO 上把 Lightmap 分辨率设为每像素 20 TexelTexels Per Unit场景 60 米见方的区域烘出来的光照贴图尺寸在 1024×1024 左右。不要盲目加大 TPU移动端纹理内存紧贴图分得太大内存和读取时间都不划算。烘焙后我在 Shader 里把 Lightmap 的平铺乘数和偏移调成与实时底色一致色块间的冷暖过渡比纯实时还更自然整体观感并没有劣化。关于烘焙的关键配置用 Baked Indirect 混合模式也就是静态物体全部走全烘焙动态物体的阴影部分才走实时。这样整套场景只有一小块动态区域需要实时计算GPU 的压力大幅降低。3.3 阴影策略宁可舍弃也不容忍一片噪点阴影是移动 VR 里最贵的视觉功能之一。实时的方向光阴影贴图 1024 分辨率在 Neo3 上就已经是一笔不小的开销何况村庄里树木和篱笆把阴影切碎成无数小片贴图采样完全不够用就会出现树影闪动、远近错位的现象。我在第一轮就给它设了 30 米的实时阴影距离再远的全部由烘焙光照贴图提供假影子。这里要特别注意一个坑URP 的 Shadow Distance 是全局配置不能单独给不同光源设置。如果场景里有任何实时光源它的阴影距离会共同影响所有光源。于是我在配置里只保留了主方向光的实时阴影其余光源全部关闭阴影功能需要投影时在美术端用半透明平面手工做了“假影子”。这种假影子在风格化场景里不但成本极低视觉效果还异常协调因为风格化本来就不追求物理真实一个模糊的深色圆片反而比实时阴影更有卡通感。烘焙和阴影调整后GPU 时间从 3.5 毫秒降到了 2.2 毫秒CPU 渲染线程虽然没再明显变化但整体负载匀称了很多。接下来的一步是把内存和加载时间这两块“看不见的成本”也理一理。4. 纹理内存与图集场景占用的另一个维度4.1 纹理内存不是无限量的请对移动端坦诚一点很多 PC 游戏团队在移植 VR 时最容易犯的错误就是不管纹理尺寸。一棵树的树皮在 PC 上用 2K 纹理没有问题但 Neo3 内存总共就那么多系统、导航、音频、网格数据都在抢占分配。纹理尺寸每大一级内存占用就翻四倍——这可不是一张两张的问题而是整个风格化村庄几百张贴图叠加起来。我做的第一件事是在 Asset 里批量检查纹理导入设置。把大部分基础色贴图从 1K 降到 512法线贴图保留 512 就够高光金属度那些针对 PBR 的贴图对纯风格化来说甚至可以直接删掉用 Shader 里的常量颜色代替。实测村庄里绝大多数表面都不需要法线细节因为它们本来就是个色块法线贴图反而会引入不必要的细节噪声。另一个常被忽视的设置是 Mipmap。给靠近摄像机的表面用全分辨率远处的表面自动切到低分辨率这个机制对 VR 尤为重要——因为 VR 视野会高速旋转物体从视野边缘移动到中心纹理加载频率很高。如果不开 Mipmap就会出现明显的纹理闪烁和抖动。但在场景中一些永远不在远处出现的UI表面我会手动关掉 Mipmap 以省内存。VR 场景里远处物体饿分辨率不佳依然会惊吓到眩晕所以这个开关千万别一刀切。4.2 图集与 Texture Sheet 打包把纹理张数降下来Draw Call 的数量与纹理数量并不完全线性相关但纹理切换次数确实会直接影响 SetPass Call 的峰值。材质 A 用纹理 A材质 B 用纹理 B即使 Shader 相同也会因为纹理绑定不同而被打成两个批次。解决这个问题的经典做法就是图集化把所有同类纹理合成一张大的 Texture Sheet不同物体在采样时通过 UV 偏移去取各自的部分。场景里的篱笆、屋顶瓦片、小工具这些物件我用 TexturePacker 把二十多张 512×512 的贴图合并进一张 2048×2048 的图集里。UV 偏移由模型本身的 UV 坐标自动对应不需要额外代码。这样材质数量就从几十个降到了几个每类物件只需一次绘制提交。图集化有个绕不开的坑图集一旦固定下来想要更换其中某一个物件贴图就必须重新打包整张图集迭代效率会降低。所以我只把那些已经定稿不打算频繁改动的物件做图集化处理还在调试中的主要建筑和角色则单独保留小纹理避免每次修改都触发全场景重导。4.3 ASTC 压缩格式是平台默认但别盲目默认Android 平台上纹理压缩格式基本无脑选 ASTC。Unity 导入设置里给 Android 平台选择 ASTC 6×6 是相对均衡的配置内存占用低、画质损失不明显、兼容性广。但不是所有贴图都适合同一种压缩率。建筑外墙这种面积大、梯度平滑的纹理用 6×6 没问题但 UI 文字、细线条纹理如果也用 6×6会出现明显的压缩色块和锯齿。我的做法是分门别类模型基础色统一用 ASTC 6×6UI 和需要精细线条的贴图用 ASTC 4×4完全不重要的模糊贴图用 8×8 或 10×10。压缩格式不仅影响内存还影响加载耗时尤其是在 PICO 这种独立设备上纹理加载常常会成为进入场景时的卡顿点。我用 AssetBundle 时发现图集从 ASTC 6×6 降到 8×8 之后加载时间能缩短近三成而画质在 6 倍以上距离几乎无感。纹理这块整理完内存占用从原来的 1.1GB 降到了 650MB启动加载时间也缩短了 150 毫秒左右。虽然内存到不了“宽松”但至少应用跑到后期不会再因为内存紧张被系统杀掉。5. 用 LOD 与剔除清掉不需要的物体5.1 LOD 不是“大模型专属”风格化场景更要会用很多美术会觉得低多边形风格化模型的三角形数量已经很少LOD 完全没必要。这个观念是错的。LOD 的价值不在于省几个三角形而在于省下多少 CPU 提交成本。尤其是我提过的预合并网格合并了一个大片区域后你不可能在远处还把它全部画出来——LOD 能让远处只画一两个简化版的大物体视觉上没有区别但渲染成本大幅下降。我给建筑、树木、篱笆段分别做了 LOD0 和 LOD1 两个级别。LOD0 就是当前完整模型LOD1 把模型顶点减半、纹理降采样用于距离 10 米以上的观察。施工时我特意给 LOD1 加了很小的过渡距离避免切换时有非常显眼的跳变。VR 中的 LOD 切换距离要比 PC 调得近一些因为头显会快速转动太远的远端 LOD 切换容易给用户造成闪烁感。做法是给每类物件建一个 LOD Group 组件按距离数组0-5 米用 LOD05-15 米用 LOD115 米以外直接用 Culled 空物体。发现 15 米外基本已经分不清细节这个距离判断和图形分辨率的取舍刚好。5.2 遮挡剔除设置区域块化场景的收益最大风格化村庄的建筑和植被密集天然适合做遮挡剔除。Unity 自带的遮挡剔除工作流是在 Occlusion Culling 窗口里生成数据的它先烘焙出一个遮挡体运行时会依据相机视角判断哪些物体不可见直接跳过绘制。对于这种由建筑和树木构成的封闭村庄收益非常显著。我在 60 米见方的核心区里划分了 80 多个遮挡单元格每个对象按所在网格分配。烘焙完成后运行入场景时大概有 30% 到 35% 的静态网格会被剔除。这样即使远处的建筑通过 LOD 已经显示得很粗略近处的遮挡物后面不必要绘制的物体也能被正确跳过对系统请求的资源做出了真正的释放。遮挡剔除也比较讲究烘焙参数网格单元太大会导致剔除不精准太小又会增加运行时的查询开销。我调了几次后选定了 2 米的小单元格整体烘焙时间和运行时性能达到了平衡。如果烘焙时的 Occluder 选择不准确公共剔除效果就会大打折扣——这点我在下一节里专门聊聊。6. 优化实录常见坑与排查手段6.1 批处理白做了SRP Batcher 压根没生效这一轮最先遇到的坑是做完 Shader 适配后Frame Debugger 里依然显示大量 “Draw Call” 而非 “SRP Batch”。排查之后发现Shader 里我用了_BaseMap这个 URP 自带的纹理属性名但材质里的 Tiling 和 Offset 被改成了非默认值。SRP Batcher 要求材质属性必须位于 UnityPerMaterial CBUFFER 内部而 Tiling/Offset 如果不在 CBUFFER 中就会被排除在批处理之外。解决方式是把_BaseMap_ST这个 Vector4 属性也放进 UnityPerMaterial 的 CBUFFER。同样的问题也可能出现在_Color、_BaseColor这类常见属性上只要属性声明里缺少 CBUFFER 包裹批处理就会直接失效。查这个问题的快捷方法是在 Frame Debugger 里点击某个 Draw Call看右侧 Properties 是否完整显示全部材质属性。如果只显示部分属性那问题就出在 CBUFFER 没有完整包裹属性上。6.2 图集化之后的“串贴图”问题图集化最让人头疼的副作用是 UV 坐标错位相邻贴图之间会出现采样“串色”现象。尤其是材质的纹理过滤设置为 Bilinear 或 Trilinear 时图集里相邻贴图的边缘像素会被模糊采样插值进来让物体边缘出现明显颜色渗漏。排查时用 Frame Debugger 看某一物体时会发现它的贴图区域颜色与预期完全不同。规避办法有两个一是给图集类的 UV 收缩一点边距在打包图集时给每块内容加上 4 至 8 个像素的 Padding这是 TexturePacker 默认提供的选项二是在 Shader 里使用 Clamp UV 采样模式防止超出边界去采样旁边纹理。两者配合后串色问题基本消失。其实这类问题在很多团队第一次做图集时都会遇到提前想到会比事后排查省出大量时间。6.3 遮挡剔除“裁剪太凶”导致室内物体穿帮还有一个坑出现在遮挡剔除烘焙之后的视觉排查阶段从室外看窗户时室内的家具和墙面物体被剔除导致从缝隙里看进去是空的穿帮很明显。这个问题的根源是因为我把室内 Interior 物体也放进了遮挡剔除的 Occludee 列表但在烘焙时没有把它们标注为 Occluder。被错误剔除的物体在视觉上就会造成“透空”效果。修正办法是把建筑的外壳墙壁、屋顶标记为 Occluder而室内物体只标记为 Occludee同时把室内物体的缩放和位置稳定性调好确保它们正确归属到独立的单元格里。如果你要保留从窗口观察室内的可能性最好的选择是不把室内物体加入剔除列表或者把窗户的物理遮挡切开让视线无法穿透。这个小细节在 PC 上不敏感但在 VR 里一旦穿帮玩家凑近看就会非常出戏。补充说明我这一轮的所有性能数据都是基于 PICO Neo3 在 Unity 2021.3 LTS URP 环境下跑出来的。不同设备、不同管线版本之间会有一些差异但核心优化思路完全通用。最深的体会是移动 VR 性能优化要从整体出发批次、光照、纹理、剔除是一个互相牵连的系统工程只盯住一个维度很难解决根本问题。如果下一轮还有空余时间我会再尝试用 GPU 事件分析把那个动态角色和特效也纳进来让整个村庄在交互状态下的性能表现也稳下来。