☰
Unity移动VR性能优化实战:从Draw Call到烘焙光照的完整调优
2026/10/1 1:30:01 网站建设 项目流程

前面几篇我一直围着场景搭建和风格化视觉打转,模型摆好了、材质调完了、村庄的调性也出来了。但真正的“折腾”其实从打包上机才开始——第一次把项目装进 PICO Neo3,帧率直接掉到 30 以下,画面动起来抖动明显,跑几分钟头显就烫得吓人。当时第一反应是“处理器不行”,但冷静下来看过 Profiler 数据之后,发现问题全在自己手里:场景里塞了太多不该塞的东西,渲染管线的每一步都在替我的偷懒买单。

这篇是系列的第五篇,我就把整个优化过程拆开来讲。不是列一堆“应该这样设置”的条目,而是记录我实际操作中怎么一步步定位瓶颈、怎么取舍、以及改完之后到底改了什么。如果你也在做移动 VR 级别的 Unity 项目,这篇应该能省下你不少回头路。

1. 首版打包上机:帧数惨烈,先从数据找靶子

1.1 首轮实测暴露的问题

先说环境:Unity 2021.3 LTS,渲染管线用的 URP,目标平台是 PICO Neo3。这颗骁龙 XR2 虽然比手机上常见的 8 系列强一些,但毕竟是移动芯片,带宽、填充率、CPU 频率都有限。首版场景我塞了大概 260 万顶点,2300 多个物体,Draw Call 峰值到了 1100 左右,材质用了 40 多套,其中一半是带透明混合的植被和粒子。

上机实测结果是这样的:

  • 帧率:平均 31 fps,波动剧烈,转动头部时最低掉到 24 fps
  • 渲染耗时:单帧 CPU 侧 draw call 提交约 14.3 ms,GPU 侧光栅化约 12.8 ms
  • 加载耗时:场景从应用启动到出现画面,花了将近 9 秒
  • 内存:PSS 常驻内存 2.1 GB,接近设备可用上限的三分之二

坦白说,这个数据是“能跑但不行”的状态。风格化村庄本身地势不小,房屋、栅栏、树木、石头、草地把整个场景塞得很满。第一眼看上去确实唬人,但转动视角时那种轻微迟滞和白边抖动,会直接毁掉 VR 的沉浸感,更别说长时间玩的发热问题。

1.2 判断瓶颈在哪里:CPU、GPU 还是带宽

拿到 Profiler 数据后,先不要急着降画质。优化最怕的就是“凭感觉改”,必须分清楚瓶颈在哪一层。

我在 PICO Neo3 上用 Unity Profiler 接了 Android 端数据,方法不复杂:

  1. 项目设置为 Development Build,勾选 Autoconnect Profiler
  2. 使用 adb 连接头显,跑完场景后导出 PlayMode 日志
  3. 重点看 PlayerLoop 里Renderer.BeginFrameRendering、BatchRendererGroup和 GPU 的RenderLoop.GPU时间

首轮数据的判断是典型的“两边都堵”:CPU 端的 Draw Call 提交时间太高,这是物体数量和材质种类太多导致的;GPU 端的耗时主要花在实时阴影、Overdraw 和多层后效上。两个问题要分开处理,否则你降了 CPU,GPU 依旧卡;降了 GPU,CPU 又会成为新瓶颈。

提示:在移动 VR 上,我习惯先抓 CPU 的Script、Render、Physics三块时间和 GPU 的Render Pipeline时间。如果 Script 本身就占了近一半,代码问题优先;如果 Render 和 GPU 都高,那就是场景资源问题。

2. 场景几何体瘦身:看不见的部分先付代价

2.1 遮挡剔除加对参数,比盲目减面更划算

村庄场景不像开放世界那样有大片开阔地形,房屋、山体、灌木彼此遮挡非常多。这种场景恰恰最适合做遮挡剔除(Occlusion Culling)。

Unity 自带的 Occlusion Culling 在移动项目里其实口碑一般,因为烘焙数据会占用内存,运行时还有 CPU 查询开销。但对我们这个中等密度的村庄场景,收益远大于开销。操作方式不复杂:

  1. 给场景里主要遮挡物,也就是房屋、山体、大树,挂上Occluder Static
  2. 给所有小物件,如草、石头、栅栏,勾选Occludee Static
  3. 在 Window > Rendering > Occlusion Culling 里设置烘焙参数,我用的Smallest Occluder是 0.05,Smallest Hole是 0.03

这里有个很容易踩的坑:如果所有物体都设为 Occludee Static,而真正能挡视线的房屋没有设为 Occluder,那么剔除基本不生效。我在第一批测试时就犯了这个错,烘焙出来剔除率只有 5% 左右,后来把房屋和大树单独设为 Occluder,剔除率直接跳到 35%。

2.2 LOD 分级不要一刀切,树和房子策略不同

几何数量这块,最直接的办法是 LOD(Level of Detail)。但风格化场景的 LOD 不能像写实项目那样只换模型精度。因为风格化植被的低模一旦差了,轮廓会被一眼看穿——树冠圆润的剪影变成一堆棱角,反而比高模更刺眼。

我的方案是给大型树木做三级 LOD:

  • LOD0:保留原始模型,约 1800 面,近处细看仍然完整
  • LOD1:删掉内部枝条和部分树叶,保留轮廓,约 700 面
  • LOD2:一个交叉十字面片加树冠贴图,约 120 面,距离 15 米以上替换
  • 距离切换点分别是 8 米和 18 米

房子的 LOD 策略不同,因为房子的视觉主体是墙面和屋顶的轮廓线,我保留了结构线,只简化了烟囱、窗框、屋脊装饰这些附加物。LOD2 级距离切得比较远,因为玩家在 VR 里会凑近看,如果 5 米外就切掉细节,穿帮很明显。

灌木和草地这类密集小物件,我直接放弃了 LOD,而是用组合实例化。一簇灌木压成一个模型,再用 GPU Instancing 批量渲染。这种方法对风格化场景特别有效,因为植被颜色、材质一致,非常适合合并。

实测做完这两步,场景顶点数从 260 万降到 140 万左右,Draw Call 从 1100 降到 780。但这只是开胃菜,真正咬人的是光照和材质,下一章说。

3. 重排渲染管线:光照、阴影和材质是最大头

3.1 实时阴影砍掉之后,画面反而更“透气”

首版场景里我用了主方向光加一副质量管理里的实时阴影。在电脑屏幕上看着还行,但 PICO Neo3 移动 GPU 上,实时阴影是致命的:阴影渲染一个额外 Depth Pass,等于整个场景多画一遍;而且阴影贴图分辨率有限,远处树影会闪烁抖动,VR 里头一动就更明显。

我的处理方式是把场景完全改为烘焙光照:

  • 主方向光只保留一个轻量旋转,不再产生实时阴影,渲染路径改为不透明+烘焙
  • 房屋、地面、大石头、道路烘焙进光照贴图(Lightmap)
  • 人物、动态物体关掉静态烘焙,只受一个简单环境光和轻度阴影

烘焙后画面确实和实时阴影不一样,但我发现风格化场景有个好处:大色块和手绘贴图本身就对阴影细节不敏感。原来担心“没有实时阴影会发飘”,实际上烘焙出的柔和 AO 感反而更贴合低多边形的手绘气质。

我记得有一次我把树影的 Lightmap 分辨率从 1 像素/单位升到 2 像素/单位,内存涨了大约 40 MB,但画面边缘明显干净了。移动端烘焙贴图不要贪大,村庄范围在 256x256 到 512x512 之间已经够用。

3.2 Shader 换血:风格化不是靠复杂材质堆出来的

顺手清掉了大部分多余 Shader。原场景里房屋窗户用了带透明混合的玻璃材质,植物用了双面渲染,木桶顶上有法线贴图和高光,这些东西每个单独看都不重,但 40 多套材质串在一起,渲染状态切换成本巨大。

整理之后我把材质收敛成五套:

材质类别之前数量优化后数量处理思路
不透明建筑/地形166合并不透明材质,全部走同一套 Lit Shader
植被/草122统一为带 Alpha Clip 的植被 Shader,不用透明混合
水/透明材质51只保留水面一套,其余改成不透明或贴图模拟
特效/粒子42压缩粒子 Shader,去掉逐像素雾
其他用途40直接删除或并入主材质

这里有个移动端非常重要但容易被忽略的点:Alpha Clip 比 Alpha Blend 成本低很多。风格化植被用 Alpha Clip 做树叶边缘,不会进入半透明排序逻辑,渲染顺序也更稳定。代价是锯齿,所以我会配合 2x MSAA 扛一下边缘。

另外我还把 URP 的后处理管线砍了大半。默认 URP 的 Bloom 和 Tone Mapping 在移动 VR 上很贵,尤其 Bloom 需要做多次降采样和模糊,GPU 压力不小。最终我只保留了非常轻量的颜色校正,通过一种廉价的 LUT 方案实现,效果是风格化场景特有的那种“糖果色”饱和度,而不是靠 Bloom 闪光堆出来的“泛白”。

做完材质和后处理的清理,Draw Call 降到了 420 左右,GPU 渲染时间从 12.8 ms 压到 7.2 ms。

提示:移动 VR 项目里尽量减少材质数量,而不是单纯合并 Mesh。GPU 根据材质切换渲染状态,材质越多,批处理越容易断,哪怕模型合到一起也救不回来。

4. 压力测试后的第二轮优化:内存和 GC 也要管

4.1 Debug.Log 一删,卡顿少了一半

首轮性能数据里发现一个很反常的现象:帧率波动很大,有些场景明明画面内容不多,还是突然卡一下。后来用 Profiler 仔细盯 CPU 耗时,发现问题竟然是自己埋的雷:场景里有一批风吹草动的逻辑,每个草叶实例每帧都在计算风力偏移,而且持续输出Debug.Log。

Unity 里的Debug.Log在 Development Build 下是非常昂贵的内存在移动设备上做字符串格式化,再同步输出到日志系统,一个高频打印就能吃掉大量 CPU。我把所有运行时打印全部删掉或只保留计数型开关,卡顿频率瞬间降了一半。

此外,代码里有一堆反复执行的字符串拼接和 LINQ 操作。

这是优化脚本 GC 压力的常规操作:

  • 每个物体用StringBuilder拼接对象名和状态,而不是+累加
  • 涉及查找的地方用数组遍历代替LINQ Where/Select
  • 每帧频繁调用的函数避免创建新的Vector3、Quaternion、匿名对象
  • 避免在Update里反复GetComponent

这些改完,CPU 的Script耗时从 4.6 ms 降到 2.1 ms。虽然听起来不多,但在 72 Hz 的 VR 渲染下,每帧的预算本来是 13.8 ms,这 2.5 ms 的差异直接是“流畅”和“想吐”的分界线。

4.2 对象池化与场景分块加载

村庄里有飘落的树叶、蝴蝶、巡逻的 NPC,以及一些互动道具。第一版我是直接 Instantiate 和 Destroy 的,运行时频繁创建销毁对手机 GPU 和内存都不友好,还容易造成 GC 压力。

我优先后把动态物体全部改成对象池:

  • 树叶粒子系统固定 200 个实例,循环复用
  • 蝴蝶的移动路径放场景分区,不再全局寻找
  • NPC 用简单状态机从当前区域容器里取目标点,避免遍历全场景物件

通过对象池管理,动态物体的 Thread CPU 峰值下降了约 1.2 ms,而且自适应质量稳定了很多。

场景加载这块,我用了 Addressables 做分块加载:把村庄按地形片区拆成四个子场景,玩家出生在中心区域时,只加载当前周围两个区块;进入边缘的时候再异步加载相邻区域并卸载远处区域。实测下来首帧加载从 9 秒降到 2 秒左右,持续运行内存也从 2.1 GB 降到 1.5 GB。

这里的取舍经验是:如果你也在做偏线性的 VR 体验,区块没必要切太多太细,会增加加载管理复杂度。我切四块已经是极限了,如果切八块,加载间的接缝问题会更难处理。

5. 最终数据对比和一段排查小记

5.1 优化前后核心指标

第二轮结束后,我把所有数据重新跑了一遍。下面是同一台 PICO Neo3、同一场景、同一行走路径的对比:

指标首版优化后变化
帧率31~40 fps稳定 72 fps提升约 55%
Draw Call1100260减少 76%
顶点数260 万86 万减少 67%
渲染总耗时27.1 ms11.2 ms减少 59%
内存 PSS2.1 GB1.4 GB减少 33%
场景加载时间9 秒2.1 秒减少 77%
头显发热明显烫手温热体感改善

不要把这组数字当作“标准答案”,它只是我这个特定项目的表现。但有一点我可以肯定:移动 VR 优化的优先级排序——先砍多余 Draw Call,再调光照和材质,最后优化 CPU 脚本和内存,顺序反了会很痛苦。

5.2 保留的可扩展方向

目前这套版本只做到 72 fps 稳定,如果后续内容量继续增加,我给自己预留了几个方向:

  • 把部分场景物件从 MeshRenderer 转为使用 GPU Instancing 的合批绘制,减少 VBO 切换
  • 进一步烘焙”光照探针”和反射探针,减少移动光源
  • 对 UI 和人物交互做基于帧预算的异步加载
  • 用场景中的”静态空间”做空间查询剪裁,减少 NPC 寻路计算

5.3 一个小经验

如果你也在做这类移动 VR 优化,我建议每一步优化都只改一个变量,然后上机实测。比如这轮我先只关实时阴影,跑一帧数据;再只合批材质,再跑一帧数据。不要同时改十个东西,否则出了问题你根本不知道是哪一步引入了新的卡顿。

另外,头部转动时看画面边缘是否有拖影,是判断帧率之外是否掉帧的好指标;如果眼睛余光扫过草地时感觉闪烁,多半是阴影贴图或 Alpha Clip 的问题,不是帧率问题。这个判断方式,在实际调试中比盯着 Profiler 数字更直观。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询