1. 从 8 FPS 起步:这个项目到底难在哪
先说结论:这个项目能在 PICO Neo3 上从 8 FPS 干到 72 FPS,靠的不是某一个神级操作,而是一整套“先找瓶颈、再逐层拆解”的组合拳。如果你手头也有一个在 VR 一体机上怎么优化都提不起帧率的 Unity 项目,这篇文章的排查思路应该能帮你省下不少弯路。
项目本身是个风格化(NPR/Toon)的 VR 互动场景,目标设备是 PICO Neo3——这是一台骁龙 XR2 平台的一体机,屏幕刷新率最高支持 72Hz,也就是说单眼 72 FPS(双眼渲染)就是我们要够到的天花板。有一点必须先说清楚:一体机 VR 的“72 FPS”和 PC 端“144 FPS”不是一个概念。一体机是双眼同时渲染两张图,GPU 要在一帧内把左眼和右眼的画面都画完,所以实际负担相当于两个普通视角的渲染量。更直白一点:你在 PC 上做一个 2D 场景跑 200 FPS,放到 VR 一体机上可能连 20 FPS 都到不了,因为多了一整套畸变、异步时间扭曲(ATW)和头动补偿的流程。
第一次把项目 Build 到真机上,我看到的数值是 8 FPS——不是 28,不是 18,是 8。画面基本处于“看一眼就想吐”的状态,转头的时候整个画面像幻灯片一样一帧一帧地跳。这个帧率其实已经低于 PICO Neo3 的 ASW/ATW 兜底线了,系统就算做帧合成也救不回来,因为原始帧太稀疏。
第一反应当然是怀疑设备性能不够,或者是不是哪里配置没开对。但冷静下来以后,我给自己定了一个死规矩:不靠猜,先量化。所有优化决策都必须建立在 Profiler 数据上,每一步改动都要有前后对比。这篇文章就是把完整的排查链路和每一层怎么压榨性能的过程记录下来,给同样在做 Unity VR 项目的人做个参考。
2. 定位瓶颈:渲染管线的三个“耗电大户”
在动任何代码之前,我先把项目的性能基线完整采了一遍。这一步非常关键——如果你不知道瓶颈在 GPU 还是 CPU,不知道是顶点处理慢还是片元着色器算不动,后面所有优化都是盲人摸象。
2.1 用 Stats 面板和 Profiler 拿到第一手数据
第一步是在真机上连接 Unity Profiler,把 Player Settings 里的 Autoconnect Profiler 打开。在 PICO Neo3 上跑 Profiler 有一件事要特别注意:不要用 Wi-Fi 连接调试,必须用 USB 数据线。一体机的 Wi-Fi 带宽撑不住 Profiler 的数据量,开了反而会把性能再拖低一些,采出来的数据就不准了。
拿到 Profiler 数据以后,我同时按下 Game 视图里的 Stats 面板快捷键,看了三个关键数字:Draw Call 数、SetPass Call 数、Triangle 数。当时场景的表现是:
| 指标 | 数值 |
|---|---|
| Draw Call | 将近 1400 |
| SetPass Call | 超过 300 |
| Triangle | 约 120 万 |
| FPS | 8 |
这个数据一出来,第一反应是顶点数有点高但也不算离谱,120 万三角面在 PC 上根本不是问题,但在 XR2 的 GPU 上确实有压力。不过真正的“致命伤”是 SetPass Call 超过 300——这意味着每一帧 GPU 要频繁切换渲染状态(换 Shader、换纹理、换混合模式),这一项带来的开销往往比顶点计算还要大。
2.2 CPU 侧和 GPU 侧到底谁拖了后腿
接着我把 Profiler 的 CPU/GPU 时间掰开看。在 Unity Profiler 里,CPU 的 Gfx.WaitForPresent 时间如果特别长,说明 CPU 在等 GPU 画完;反过来,如果 GPU 的 RenderThread 时间很长,说明瓶颈在 GPU 侧。
这组数据出来以后,情况很明确:CPU 主线程耗时约 20ms,渲染线程约 15ms,两者都不健康。但更深一层的问题是——CPU 主线程的时间都花在哪了?
展开 Profiler 的 Hierarchy 视图,我发现了两个意想不到的“隐形杀手”。第一个是 Canvas 的 Batch 重建:UI 上有不少 Image 组件,其中几个在动画中持续改变颜色和尺寸,导致 Canvas 每一帧都重新做一次顶点重建和合并,单这一项吃了 CPU 主线程近 4ms。第二个是 AI 的 NavMesh 寻路:场景里有几个漫游 NPC 在持续做路径计算,每帧都在更新路径和避障,占用约 3ms。
这两项和“风格化渲染”看起来毫无关系,却是 CPU 侧掉帧的重要源头。这个经历提醒了我:很多 VR 项目的卡顿并不是渲染本身造成的,而是场景里那些不经意的“业务逻辑”在抢 CPU 时间。先清 CPU 侧,再去看 GPU,顺序很关键。
3. CPU 侧的减负:把“隐形开销”按在地上摩擦
既然 Profiler 已经指出了 Canvas 重建和 NavMesh 寻路这两个 CPU 消耗大户,下一步就是针对性处理。这一步纯粹是挪时间,不改变画面表现,但为后续 GPU 优化腾出了宝贵的 CPU 余量。
3.1 Canvas 重建为什么贵,以及怎么治
UI 在 VR 里是高频刚需——信息面板、交互提示、菜单都要用。但 Canvas 重建的代价往往被低估。Unity 的 Canvas 是合批渲染的,如果你在同一块 Canvas 下改动任何一个元素的属性,整个 Canvas 都要重新做一次 Geometry 重建。这就好比你在一张纸上改了某个角落的字,但印刷厂决定把整张纸重新排版一遍。
排查下来,罪魁祸首是 VR 互动中常见的“注视高亮”效果:眼睛看向某个物体时,物体的 UI 名称和描述会动态变色、放大。我直接在 UI 动画里用 DoTween 改了 Text 的颜色和 RectTransform 的尺寸,每一帧都触发 Canvas dirty。
治标方案是把动态 UI 和静态 UI 拆成不同的 Canvas:静态信息面板一个 Canvas(永不变化),动态提示单独的 Canvas(只放会动的元素),这样静态部分永远不参与重建。同时,颜色变化改为在材质层面做出柔和过渡,避免依赖 UI Mesh 每帧重建,这一项让 CPU 主线程时间从 20ms 降到了 15ms 左右。
如果项目里 UI 比较复杂,还可以考虑使用 UI 纹理图集统一批次,减少切换纹理的状态开销。不过那个项目里 UI 元素不多,拆 Canvas 已经够用了。
3.2 NavMesh 和“不必要”的动画逻辑收口
AI 寻路这块,原来导航网格是基于原始场景烘焙的,NPC 数量虽然只有 4 个,但因为寻路目标点和障碍避让经常变化,造成路径迭代频率过高。最直接的办法是调整 NavMesh Agent 的更新参数:把Path Update Interval调大、把Avoidance Prediction Time调到 0.5 秒以上,甚至可以在 NPC 只做漫游时直接让它走预定义路径点,而不是实时规划路径。
动画方面的开销也检查了一遍:场景里有不少 Idle 动画,用的都是完整骨骼的 Humanoid 动画,每一帧都在计算骨骼变换。对于远处的小装饰 NPC,我把它们的动画从 Animator 换成了AnimatePhysics关闭状态下的简单旋转/位移动画,或者直接把动画采样烘焙成顶点动画贴图——这个做法适合重复元素,效果很显著。CPU 主线程最终降到了 12ms 左右,虽然还不到理想值,但已经不再是最短的板。
4. 渲染管线的“截流”:URP 配置才是 VR 性能的隐藏开关
CPU 侧松绑以后,帧率从 8 FPS 提升到了大约 13-15 FPS,正常计算一下,瓶颈确实转移到了 GPU。接下来,我开始对渲染管线下手。URP(通用渲染管线)项目在 VR 一体机上跑的坑,比内置渲染管线要多不少,因为 URP 的很多“开箱即用”特性在 VR 里都是性能陷阱。
4.1 渲染分辨率、MSAA 和 后期效果的取舍
在 URP Asset 里,最先要确认的是渲染分辨率。PICO Neo3 单眼实际渲染分辨率建议在 1216x1216 左右(这个值取决于系统的 FFR 设置,后面细说)。当时项目里 URP Asset 配的是 1440x1440,还开了 4x MSAA,抗锯齿开销非常大。
很多人会纠结:VR 不开 MSAA 会不会有严重的锯齿?答案是:在风格化渲染下,锯齿问题可以用其他方式掩盖,而 MSAA 的开销是实打实吃掉 GPU 每帧的。我直接把 MSAA 关掉,同时打开了 PICO 系统层的固定注视点渲染(Fixed Foveated Rendering,FFR)——X2 平台对边缘区域降低分辨率渲染,对画面中心保持全分辨率。风格化项目最大的好处是:边缘本来就有描边和色块,降分辨率几乎不可感知,但 GPU 像素处理压力直接下降一截。
后期处理全链路也砍了一刀。原来场景里挂了 Bloom、Vignette、Color Grading,这些在 PC 上都是毫秒级开销,但在 VR 的双眼渲染下,每一个屏幕后效都会翻倍计算。最终只保留了一个非常轻量的 Color Grading(用于风格化的色彩明快感),Bloom 直接扔掉,Vignette 剪掉(VR 里四角变暗反而容易引起视野压抑感)。
4.2 URP 数据资产设置:在清晰和开销之间找平衡点
URP 里还有一个常被忽略的参数:Main Light的阴影设置。场景里有一个平行光作为主光源,它的阴影分辨率默认是 2048。在 VR 场景里,这个阴影分辨率的开销不是 2048x2048 一次性贴图,而是要为每只眼睛分别渲染一张 Shadow Map,等于双倍开销。
我把主光源阴影分辨率降到了 1024,并且把阴影距离从 50 米缩短到 20 米——VR 场景里玩家能看到的最远交互范围也就这么远,20 米外没有阴影在风格化画面里根本不会被注意。这两个改动叠加,GPU 渲染时长降了大约 20%。
另外,URP Asset 的Additional Lights设置要小心。场景里如果用了任意区域光或多盏点光源,URP 默认的 Forward+(在 Unity 2022 里叫 Forward Rendering Path 的 Additional Lights 部分)也会带来额外开销。最理想的做法是:风格化场景里尽量保持“单主光源 + 烘焙光照”,全部实时光源数量控制在 2 盏以内,把 Addition Lights 的 Per Pixel 模式改成 Per Vertex 或不支持。改完这一项,GPU 负载又降了一档。
到这里,帧率往上走到了 22-25 FPS,画面还算能看,但距离 72 FPS 还差着十万八千里。
5. Shader 层的“抠门艺术”:风格化渲染的每一行都要算钱
到了这一步,普通项目的优化基本可以收工了——从 8 FPS 到 25 FPS,已经算是质变。但作为风格化项目,大头还在后面:Shader。风格化渲染(Toon Shading)听起来只是“色块+描边”,看起来很简单,但实现方式不当,能让 GPU 比跑 PBR 还累。
5.1 主光源和卡通光照计算:从逐像素降到逐顶点
先看主 Shader 的光照计算。项目最初的 Toon Shader 是在片元着色器里做逐像素光照,计算了 NdotL(法线点积光方向)、半兰伯特过渡、三档色阶(明、暗、阴影)的插值——这是标准做法。但在 XR2 的 GPU(Adreno 650)上,逐像素光照的 Fillrate 占用非常高,尤其在全屏像素都跑一遍这种计算的时候。
我把光照计算的 NdotL 从v2f里直接移到顶点着色器计算,即“逐顶点光照”,然后在片元里只做色阶映射和颜色混合。这个改动对画面效果的影响极小——因为风格化的色阶本身就是低精度、色块化的输出,逐顶点光照的线性插值在过渡上反而让色阶看起来更自然(减少了高光闪烁)。从 GPU 侧看,片元着色器的 ALU 指令数和纹理采样数量都有下降,Fillrate 压力缓解了一大截。
这是一个值得所有风格化项目采纳的原则:只要视觉允许,能算一次的就不要算一屏。尤其 VR 里双眼渲染,一屏像素两次计算,省下来的毫秒数是实打实的。
5.2 Alpha Test 和 Overdraw:藏在风格化边缘里的黑洞
风格化项目第二个暗坑是描边和轮廓线。当时描边用的是“Back Face 挤出”方案:在顶点着色器里把模型沿法线方向挤出一点,再用后置面本来就是背面的面片来画黑色边缘。这个方案的问题在于——每个物体的描边会额外渲染一整个模型的背侧面,相当于所有模型多画了一遍,Overdraw 极高。
替换方案选用了屏幕空间描边(基于深度法线纹理),只在轮廓边缘处才有额外的片元计算。这个改动让场景里每个带描边的物体的填充开销从“2x 模型面数”降到了“边缘像素级”。
另外,风格化项目常见的“高光色块”和“阴影渐变”如果用 Alpha Test(剪裁)做,会在半透明边缘产生严重的锯齿和渲染失效。我把所有需要透明过渡的效果改成Alpha Blend配合渲染队列调整,同时避免使用clip()函数,因为clip()在 GPU 上会破坏 Early-Z 优化,强制片元着色器跑完整段代码再做丢弃。
改完 Shader 层,帧率上探到 35-38 FPS。这个阶段最明显的变化是:虽然帧率还不到目标值,但帧时间的稳定性好多了,不再出现一卡一卡的脉冲式掉帧。
6. 几何层的“瘦身运动”:Draw Call 与三角形管理
就当帧率在 35 FPS 附近卡住的时候,我意识到光靠 Shader 的“抠门”已经到一个边界了,要跨过 72 FPS 这道坎,必须从场景整体几何上再动刀。这一刀动的是 Draw Call 和三角形数量。
6.1 合批、图集和静态化:一个都不能少
场景初始 1400 个 Draw Call,在 VR 一体机上无疑是天花板级别的负担。Draw Call 消耗的不只是 GPU 的状态切换,还包括 CPU 端的 渲染状态设置。我做了三件事:
第一件,是把所有共享同一套贴图的物体合并为同一个 Atlas 图集。风格化项目有个天然优势——贴图很多是纯色块、色带和共用纹理,我把分布在不同物体上的 20 多张小纹理合成了 4 张 2048 图集,从根上减少了纹理切换。
第二件,把所有不动的物体(地面、建筑外壳、大块道具)标记为Static并启用 Static Batching。静态合批能在不增加额外 Draw Call 的情况下将多个 Static 物体合并成一个大网格,对减少 SetPass Call 效果显著。
第三件,对于场景中重复出现的小物件(路灯、花坛、箱子这些),统一做成 Prefab,并开启 GPU Instancing。Instancing 的威力在于:只要材质相同、网格相同,GPU 可以一次绘制几十个实例。之前散落场景里的上百个小物件都是独立网格,现在变成了一两次 Instanced Draw。
合批做完后,Draw Call 从 1400 降到了 300 左右,SetPass Call 降到了 80 以下。这一刀的质量很高,帧率一下跳到了 50 FPS 以上。
6.2 场景 LOD 和“看不见的三角形也是钱”
VR 一体机上最容易被忽略的三角形杀手是“微面数”的累积。一个场景中可能藏了几百个高模的植物、书本、杂物——它们单个体积不大,但加起来就是百万级三角形。
我给主要大件都配置了 LOD Group:高模(原始)、中模(删除倒角边缘和内部不可见面)、低模(用简化为盒体的碰撞体替代)。对远处的小物件直接删除渲染或替换为 Imposter(多层次冒牌模型——用几张静态贴图替代三维模型)。另外用了 Mesh Baker 工具把模型上多余的 UV 和 BlendShape 数据清理掉,减小了几何数据的带宽压力。
到这一步,三角形的渲染负载降了 40% 左右,场景的 GPU 帧时间进一步压缩,帧率稳定在 55-58 FPS。这个数字已经很接近目标了,但距离 72 FPS 还有 15ms 的一层窗户纸——这个窗户纸,是需要从系统层去捅破的。
7. 系统层的“合作”:PICO Neo3 的投影设置和插帧策略
如果你以为优化到此结束,那就错了。真正把项目从 58 FPS 推到 72 FPS 的,是我开始“利用”设备本身的系统能力。
7.1 Fixed Foveated Rendering 和渲染分辨率的联动
前面提过 FFR 已经在系统层开启,但“开了”和“开对了”是两件事。PICO 的 Unity SDK 里提供了FoveationLevel的 API,可以从低到高调整 FFR 的强度。我做了个简单测试:FFR 级别从 Low 到 High,分别截图对比边缘画质。对于风格化场景,High 级别下边缘区域的像素模糊几乎不可感知,但 GPU 负载能进一步降低 15-20%。
同时我注意到一个细节:PICO Neo3 在“省电模式”和“性能模式”下的频率策略完全不同。项目最初运行在系统默认的混合频率下,CPU/GPU 频率不够稳定。我通过 SDK 请求了性能模式,把 GPU 频率固定在较高档位,代价是功耗和发热上升,但换来了稳定的 72 FPS 空间。这个操作要注意——持续高频运行会导致设备温度过高,进而触发降频,反而得不偿失,所以后来又设计了场景内动态负载控制(比如大场景切换时空闲时降频),在保证帧率的同时避免过热。
7.2 与系统垂直同步的最终博弈
理论上,72 FPS 需要每帧耗时低于 13.9ms。把所有优化做完以后,项目帧时间已经压到了 14.5ms 左右。就差最后的 0.5ms 多——我反复测试发现,这个 0.5ms 来自渲染管线最后的畸变合成和线程同步等待。
PICO 的 Unity XR 插件有一个SetCPUAndGPULevel或类似的性能等级设置,可以把 CPU/GPU 的工作负载在系统调度上进行倾斜。我试了以下组合:
| 等级组合 | FPS 结果 | 发热表现 |
|---|---|---|
| CPU 低,GPU 低 | 55 FPS | 低 |
| CPU 中,GPU 中 | 62 FPS | 可控 |
| CPU 高,GPU 高 | 72 FPS 稳定 | 较高 |
| CPU 高,GPU 低 | 58 FPS | 中 |
最终选择了 CPU 高、GPU 高的组合,因为场景的瓶颈已经跑到 CPU 侧提交上(Draw Call 虽然降了,但 VR 多视图渲染的提交开销在那里),GPU 反而还有余量。这个“最终一搏”把 FPS 顶到了 72,并且在小范围压力测试的 3 分钟里保持稳定。
8. 收尾阶段的验证和几个真实有用的技巧
项目上线的最终版本稳定跑在 72 FPS,头显上的体验和最初 8 FPS 完全是两个世界。但在我心里,优化的最后一步不是“帧率达标”,而是“验证达标”和“保持达标”。这里有几个基于整个过程的实用技巧,说给做同类项目的人听。
8.1 真机发热和降频:永远要给帧率留冗余
我在上面提到过,CPU/GPU 双高等级会带来明显发热。PICO Neo3 连续跑 20 分钟以后,机身会明显发烫,系统可能会自动降低频率。如果用户在玩到第 15 分钟时突然开始掉帧,那前面所有帧率达标都是假的。
所以真机验证必须包含“ 15 分钟连续运行 + 场景切换 + 头动剧烈”的完整流程。我的做法是在测试脚本里构建了一个 5 分钟的自动巡逻路线(让头显按照预先记录的轨迹运动),连续跑三遍,记录帧率曲线。任何优化改动如果在这一环节出现帧率下滑超过 5%,我都会重新评估配置。
8.2 每次只改一个变量:优化工程化的铁律
在项目优化过程中,我自始至终遵守一条铁律:每次构建只变一个变量。换句话说,改动 Shader 时不要去同时调整 LOD 和 Canvas;调整渲染分辨率时不要顺手把阴影距离也改了。原因很简单——如果两个变量同时改动,帧率提升了 5 FPS,你根本不知道这 5 FPS 来自哪一步,也就无法复用经验。
实际操作中,我会在每次改动前用 Git 打一个 tag,然后记录三组数据:FPS 均值、FPS 1% Low、GPU 帧时间。几轮下来,你会有一张清晰的“性能收益表”,下次做类似项目直接照着表查最优解。
8.3 风格化项目特有的“视觉可接受度”验证
性能优化过程中,几乎每一步都在权衡画质。风格化项目的好处是画质损失容易被接受,但坏处也很明显——一旦改过头,整个画面的“质感”会崩掉。
我给画面设置了 5 个固定观察点(玩家视角和几个关键交互视角),每次 Build 完都在这 5 个点截图和录像,并在 VR 头显里肉眼确认。尤其验证了 FFR High 级别的边缘模糊是否影响 UI 文字清晰度。最终发现 UI 如果放在屏幕极边缘,在 FFR High 下会有一点轻微模糊,于是把 UI 的安全区往中心收了一点。这种“从视觉出发的验证”比单纯看帧率数字重要得多。
整个项目优化到这里算是画上了一个阶段性的句号。从最初打开项目那 8 FPS 的绝望感,到最终稳定 72 FPS 的丝滑体验,最大体会是:VR 项目优化从来不是玄学,Profile、数据、系统能力的正确组合,每一步都有迹可循。这个系列后面应该还会继续分享光照烘焙配置、多场景切换的内存管理等问题——等我把后续项目的坑踩完,再来填。