
1. 项目现状与初步瓶颈定位1.1 项目背景和运行环境先交代下项目背景。这是一个给 PICO Neo3 开发的开园式 VR 互动 demoUnity 版本用的是 LTS 2020.3渲染管线一开始是内置管线。美术走的是风格化卡渲路线大色块、色阶阴影、轮廓描边、大面积天空和云层。场景规模不算大也没有重量级物理系统但内容密度很高——地面铺了大量低模树木、石头、花花草草空中飘着发光粒子群主角和 NPC 都带骨骼动画。第一次装机实测帧率直接掉到 8 FPS画面卡到根本没法在头显里停留超过几秒眩晕感瞬间上头。PICO Neo3 这台设备的底子需要先说清楚高通 XR2 平台核心GPU 是 Adreno 650内存 6GB刷新率支持到 72Hz。也就是说官方推荐目标就是稳定跑满 72 FPS。对风格化卡渲这种“看起来轻巧、实际开销很重”的渲染风格来说这种移动级 GPU 的像素填充吞吐、Shader 计算能力和 CPU 提交效率远没有想象中那么宽裕。很多做惯了 PC VR 的团队把各种酷炫 Shader、全屏后处理、高分辨率贴图直接搬过来几乎都会在真机上被性能按在地上摩擦。1.2 8 FPS 到 72 FPS 之间到底隔着多少开销8 FPS 对应的单帧时间是 125ms而 72 FPS 对应的单帧时间是 13.9ms。这个数学关系必须刻在脑子里整个渲染加逻辑帧循环要提速接近 9 倍才算是真正达到及格线。我优化期间一直盯着这组数字每当自己觉得“该砍的都砍了应该差不多了吧”打开 Profiler 一看离目标还差很远。VR 和普通手机游戏还有一个本质区别所有 Draw Call 和 Shader 开销都要为左右眼各付一次账。如果不用高效的立体渲染机制每一个模型、每一个 Pass 都是双份计算。PICO Neo3 支持 Single Pass Instanced这个特性可以说是后来帧率能稳住的关键基础但前提是项目必须正确配置启用它。1.3 第一件事永远是 Profiler 定位而不是瞎猜我见过太多人一拿到低帧率项目就到处搜“Unity 优化技巧大全”把能关的全部关一遍结果画面糊了、帧率没怎么动最后也不知道瓶颈在哪。我的做法是先连真机跑一遍 Unity Profiler把 PICO Neo3 通过无线连接挂到 Profiler 上记录三段时间——CPU Main、CPU Render Thread、GPU Time。这里有一个很容易踩的坑移动端 Profiler 如果开 Deep Profile很多内联方法能看到但 Deep Profile 本身会拖低帧率导致数据失真。所以我只看几个关键模块WaitForTargetFPS、PlayerLoop、Render Thread、Gfx.WaitForPresent。如果后者占比很高瓶颈基本在 GPU如果 Render Thread 高而 GPU 不忙问题在 Draw Call 提交和合批失败如果 Main Thread 里 Update 等逻辑耗时大那是业务代码的问题。我当时的 Profiler 快照大概是这样模块耗时说明PlayerLoopMain Thread28.4 ms逻辑更新、动画、粒子 UpdateRender Thread62.3 msDraw Call 提交、合批失败、材质参数传递GPU Time83.5 ms逐片元光照、Overdraw、后处理合计含等待约 125 ms对应 8 FPS三层都超标但 GPU 侧最重。所以优化计划也定了Shader 和渲染相关主攻 GPUDraw Call 和脚本逻辑主攻 CPU两条线同时推进最终才有机会逼近 72 FPS。2. 渲染管线和 Shader 层的减法改造2.1 为什么我从内置管线迁到 URP项目一开始美术提了不少效果需求Ramp 色阶阴影、Rim Light、描边、云层噪声扰动……这些在 PC 上用内置管线加一个魔改版 Standard Shader 都能实现但到了移动端 VR 上内置管线的 CPU 侧提交效率很低。内置管线要为每个材质单独准备命令缓冲同样一批三角形URP 配合 SRP Batcher 能省掉大量逐材质的设置调用。所以我做了两步先将管线切换到 URP 12跟 Unity 2020.3 LTS 配套再在 ForwardRenderer 配置里去掉了多余的额外 Pass。这个操作有风险——如果项目里大量使用内置 Shader 或依赖内置管线的自定义 Shader切换后必须逐个重写或替换。我们的做法是先用 Shader.Find 盘点所有 Material 用到的 Shader能替换的直接统一走新写的移动版 NPR Shader不能替换的单独处理。从数据来看管线切换后 Draw Call 从全局 3000 多稳步降到 800 到 1200 左右Render Thread 从 62ms 降到了 35ms 上下。但这一步还不是质的飞跃真正的杀手锏在后面的 Shader 重写。2.2 移动端 NPR Shader 的关键写法风格化项目最容易踩的坑是把 PC 上拿来的卡通 Shader 原封不动地挪到移动端。什么震动效果、多层高光、三面贴花、边缘光、二次元头发的高光……每个功能在 PC 显卡上都是小菜一碟在 Adreno 650 上每帧都像在拆楼。我重写核心卡通 Shader 时定了几个硬规则。第一能用 half 绝不用 float。移动端 GPU 对 half 的吞吐远高于 float代价是精度下降。颜色、UV、权重这类数据都塞进 half只有位置坐标和世界坐标这类需要精度的数据保持 float。第二所有逐顶点能算的都放到顶点阶段逐片元计算能砍就砍。比如 Ramp 阴影过渡在顶点阶段算好 NdotL交给插值器传进来片元阶段只做一次 Ramp 贴图采样。第三采样次数极致压缩。一个材质球在片元阶段控制在两次纹理采样以内。风格化项目经常要主贴图、法线贴图、Ramp 贴图、Matcap 贴图一起上我把 Matcap 直接去掉法线在很多草地、石头、装饰物上也换成了纯色版本。第四避免片元阶段的动态分支。移动 GPU 的 Branch 指令经常两边分支都会执行等于没有省反而更慢。下面是角色主材质简化后的核心结构截取关键部分说明逻辑// 移动端 NPR 主 Pass 简化片段 struct v2f { float4 pos : SV_POSITION; half3 worldNormal : TEXCOORD0; half3 worldPos : TEXCOORD1; half2 uv : TEXCOORD2; }; half4 frag(v2f i) : SV_Target { // 主色贴图 half4 albedo tex2D(_MainTex, i.uv) * _Color; // 项目固定使用单方向光方向预先传入 half ndl dot(i.worldNormal, _LightDir); // 用 Ramp 贴图把渐变映射成卡通色阶 half ramp tex2D(_RampTex, half2(ndl * 0.5 0.5, 0.5)).x; half3 diffuse albedo.rgb * _LightColor0.rgb * (ramp * _ShadowIntensity _Ambient); // 去掉多层高光和 Rim保留基本风格特征 return half4(diffuse, albedo.a); }这段代码在实际项目里比原来省了一大截。原来的角色 Shader 有 Base Pass、Additional Pass、Outline Pass、ShadowCaster Pass 四个 Pass重写后主 Pass 只保留最简单的光照加采样描边单独处理阴影 Pass 也精简掉了。要提醒一句风格化不等于无光照。那些名作的卡渲效果背后有复杂的光照模型但在移动端 VR 上我们要做的是在风格化视觉目标不变的前提下把光照模型的数学复杂度降下来。我的取舍办法是保留关键视觉特性其余逐项砍掉保留 Ramp 色阶映射放弃多层高光保留描边放弃屏幕空间像素级描边。2.3 描边方案的三次迭代描边必须单独拿出来说因为风格化项目对描边执念很深而描边又是最容易摧毁移动端 VR 性能的效果之一。我们一开始用后处理描边渲染完场景之后在屏幕空间做边缘检测依赖深度纹理和法线纹理。这个方案在 PC 上效果很好但在 PICO Neo3 上读深度和法线本身有带宽开销全屏 Pass 对像素填充压力大一次下来占掉每帧 6 到 9ms完全不可接受。后来改成多 Pass 描边在模型外面放大一圈顶点渲染纯色背面作为描边。这个方案把 DC 直接翻倍因为每个描边物体多一个 Pass而 VR 又是双份渲染。实测几百个描边物体时仅仅描边 Pass 就从 0.5ms 涨到了十几 ms。最终落地方案是双轨组合主角和关键交互物使用背面膨胀描边但只对少数几个重要 Mesh 开启场景树木、建筑、NPC 这类大物体直接用 Shader 里的顶点法线偏移加固定宽度替代额外 Pass远景草丛、远处的云干脆不做描边。这样描边的视觉特征保住了性能开销却从后处理的 6 到 9ms 加多 Pass 的十几 ms降到了顶点偏移方案的大约每帧 1ms 左右。2.4 Shader 变体清理和管线渲染设置Shader 重写之后我又撞上一个隐藏杀手材质球上的着色器变体数量。URP 里同一个 Shader如果开启了 Directional Shadows、接收阴影、雾效、实时反射等选项编译时会长出大量变体。角色主 Shader 一度有 30 多个变体实际项目里只用其中两三个剩下的全部白编译进包体还让加载时产生明显卡顿。我用ShaderVariantCollection配合构建脚本把没用到的变体从构建里剔除。具体做了三件事建立一个关键 Pass 的ShaderVariantCollection在编辑器中完整扫描场景收集所有 Shader、Pass、Keyword 组合构建时通过IPreprocessShaders回调剔除多余变体。清理完成后首帧编译卡顿消失包体积也减小了一些。3. 资源、Draw Call 与 Overdraw 的集中治理3.1 贴图压缩和 Mipmap 设置PICO Neo3 的 Adreno 650 支持 ASTC 压缩格式。这不是包体变小这么简单更深层的影响在内存带宽。VR 渲染中纹理采样是双份的带宽占用足够直接把 GPU 拖垮。我把项目里大量 RGBA32、RGBA16 贴图转成 ASTC 6x6 或 4x4法线贴图单独转 8x8。这里有个经验分级大场景地形地面、建筑墙面ASTC 8x8角色和 UI 细节ASTC 4x4保留更多细节法线贴图ASTC 8x8关闭 sRGB 采样小尺寸 UI 图标ASTC 6x6关闭 Mipmap。Mipmap 是最容易被忽略的选项。3D 场景里的地面、建筑、环境贴图开 Mipmap 能减少远距离采样开销但 UI 贴图、RenderTexture、不参与缩放的 2D 贴图开 Mipmap 反而每帧都在浪费采样带宽。项目里那些 Screen Space 的 UI 贴图开着 Mipmap 等于白白多出一倍采样压力。各向异性过滤建议全局调到 2 或者直接关闭VR 双目渲染对这个参数特别敏感。压缩之后最直观的变化内存占用从 3.1G 降到 1.7G 左右帧时间也挤出了几毫秒的余量。3.2 Draw Call 合并和 GPU Instancing前面提到管线切换把 DC 压下去了但场景里那么多树、石头、花花草草如果不做合批DC 依然会涨回几百甚至上千。移动端 VR 的好习惯是能 Instancing 就 Instancing能静态合批就静态合批动态合批是最后没办法才用的手段。静态合批方面我把场景中的静态地面、石头、建筑用StaticBatchingUtility.Combine合并成少数几个批次。但要注意合并网格本身会改变顶点缓冲布局合并后必须人工校验碰撞网格和包围盒是否还在正确位置否则会出现物体明明看不到了却还在渲染的情况。动态的树木和草使用 GPU Instancing。原来 300 棵树加 600 簇草每个都当成独立 MeshRendererDC 超过 900。我把同一种 Mesh 和 Material 归并用Graphics.DrawMeshInstanced每帧一次调用DC 直接降到个位数。大致写法是public class GrassInstancer : MonoBehaviour { public Mesh mesh; public Material material; private Matrix4x4[] mats; void Update() { // mats 预先缓存的实例矩阵数组 Graphics.DrawMeshInstanced(mesh, 0, material, mats, mats.Length); } }操作的收益很大场景总 DC 从 2800 左右降到约 350。需要注意DrawMeshInstanced的实例数上限会受 GPU 限制超过上限时分批调用但分批会导致额外状态切换所以真到 1000 以上时还得从模型面数和 LOD 入手。3.3 模型面数、骨骼和动画管理VR 项目不能像普通手游那样堆面数因为立体渲染会放大一切顶点处理压力。我做了几个动作主角从 2 万面压到 1.2 万面NPC 从 8000 面压到 4000 面Skinned Mesh 的 Skin Weights 设置为 2骨骼数量控制在 14 根以下关闭不必要的 Root Motion动画采样率从 60FPS 降到 30FPS用 Animation Clip 的压缩选项实现。场景中用 LOD但没用 LOD Crossfade。VR 中交叉淡入会让远近两个 LOD 同时参与渲染等于顶点负载和 Draw Call 瞬间翻倍帧率最容易在这个瞬间崩掉。直接用力切 LOD配合必要的 FadeMesh 简化视觉上几乎分辨不出切换过程。3.4 粒子特效的隐藏开销风格化项目的粒子量一般都很大光点、星尘、落叶、樱花、浮动烟雾……这些透明混合粒子在 VR 里的 Overdraw 极其夸张。粒子本身是一个半透明 Quad每个粒子都做一次混合渲染多个粒子叠加时像素填充率很快触顶。我在这块的经验是能用 Shader UV 动画替代的粒子坚决不用粒子系统。飘浮尘埃和发光烟雾这类效果用一个带透明纹理的 Quad 在 Shader 里做 UV 扰动视觉上几乎一样开销小一个量级。粒子数量上限必须设。很多默认粒子系统的 Max Particles 没改过场景一复杂CPU 和 GPU 一起崩。将粒子的maxParticles按视觉需求封顶比如光点 300、星尘 200、落叶 80。不使用粒子 Collision 模块。粒子的碰撞检测在移动 VR 上是灾难这种细节用静态贴花代替玩家基本注意不到。粒子材质倾向 Unlit。Lit 甚至 PBR 的粒子在移动端完全没有必要。治理完粒子后Overdraw 从平均 2.7 降到了 1.3 左右这一步直接决定了 GPU 的像素处理时间能否回到安全线内。4. 光源、阴影、后处理与设备特性的妥协4.1 实时光源从多路并行变成单光主推加烘焙风格化渲染并不天然需要很多实时光。项目最初有 1 个平行光、3 个点光源、1 张反射探针角色身上还有自发光和 Rim 光。这些光源会在 Shader 里生成大量动态光照计算在移动端 VR 上几乎是翻车源头。我的方案是全局只保留 1 个方向光作为主要光照源关闭实时阴影场景静态光照全部烘焙进 Lightmap风格化大色块场景天生适合烘焙点光源特效比如水边光斑、林间光束全部换成贴花或者自发光叠加不用真正的 Point Light 去照亮动态物体反射探针全局只留 1 个静态的动态物体不接收实时反射。烘焙完成后动态物体的光照计算只剩平行光加 LightmapGPU 每帧少算一大堆光照。这个改动让 GPU 时间降低了大约 18%。4.2 阴影尽量用“假”的实时阴影在移动 VR 里是非常奢侈的效果。PICO Neo3 上即使把 Shadow Distance 调到 15 米、ShadowMap 分辨率调到 512角色头部的动态阴影还是要吃掉相当多的填充率。风格化项目里我直接关闭全部实时阴影改成两类替代静态场景用 Lightmap 自带的自阴影动态人物和交互物用一张阴影贴花 Flat Shadow 贴在脚下或者用顶点偏移的假阴影投影面。这和风格化的大平光很搭视觉上不会给人生硬消失的感觉。玩家的注意力会被描边和大色块吸引根本没空在意影子的柔和度。4.3 后处理必须做减法这是最粗暴的提速手段PICO Neo3 上的移动 GPU 对全屏后处理非常敏感。项目的 PC 版开着 Bloom、SSAO、Color Grading、Motion Blur、Depth of Field在 PC 上都很美到了 PICO 上这些全开后每帧多出 25ms 以上的开销完全崩盘。我只保留了一个轻量的 Color Grading 用于风格化色调统一其余全部关闭。如果团队实在想要 Bloom我的建议是用预烘焙伪 Bloom在强光源或发光材质的模型上加 Emission 贴图再让贴图的 UV 或强度做简单动画不经过全屏后处理。这个效果在卡通场景里接近原版性能开销几乎为零。4.4 自适应分辨率和固定注视点渲染才是真正的“大招”做完 Shader、DC、资源的治理后帧率一般能到 40 到 50 FPS 之间离 72 还有一线差距。这时该上平台专用加速了降低渲染分辨率和开启固定注视点渲染。PICO Neo3 的默认渲染分辨率会偏保守地往上顶带一定超采样压力。我通过 SDK 把 Render Scale 调到 0.8 左右。别小看这个 0.8它意味着两个眼睛的总像素量变成了原来的 0.64 倍GPU 像素填充压力直接下降三成多。实测视觉清晰度确实有下降但风格化大色块加描边的画面本身比较“抗糊”玩家普遍反馈可以接受。随后开启 PICO Neo3 的固定注视点渲染 FFR。这个功能把注视中心区域保持高分辨率边缘区域用低分辨率渲染同时显著降低渲染负载。PICO XR SDK 提供等级配置我调到中间等级后收益非常明显配置项数值帧时间变化RenderScale 从 1.0 调到 0.8像素量 ×0.64GPU 节省约 8 到 11ms开启 FFR 等级 2注视点边缘分辨率下降GPU 再节省约 6 到 8ms关闭后处理和实时阴影前面步骤的积累累计达到 72 FPS 稳定区间有些团队担心 FFR 会让边缘物体变形或闪烁我的经验是不要依赖默认参数必须在真机上反复测。如果 UI 在边缘模糊可以把 UI 单独用 Overlay 层绘制或者强制把 UI 布局放在注视点中央区域。5. CPU 侧和内存侧的二次优化5.1 严防 GC Alloc 和运行时创建 GameObject移动 VR 上主线程的任何 GC 和实例化都可能直接变成掉帧。项目初期有个逻辑为了做 NPC 挥手互动每帧用FindObjectOfType找管理器还用了大量 LINQ。这种写法在 PC 上无所谓在 PICO 上每帧几十次查找就已经消耗好几 ms。我做了三件事删掉 Update 里的FindObjectOfType和GetComponent重复调用全部改到 Awake/Start 里缓存字符串拼接改成StringBuilder日志输出全部加条件编译运行时所有动态物体使用对象池Instantiate后不再销毁而是回收进池里。用 Unity Memory Profiler 对比GC Alloc 从每帧约 5.2MB 降到了 0.1MB 左右GC 发生频率肉眼可见降低。5.2 Job System 加 Burst 拯救主线程项目里有大量带动画的 NPC 和飞行物。动画系统本身会占用主线程的 Update 时间如果走全局 UpdateCPU 的 Main Thread 很容易被打满。我后来把 NPC 的移动和朝向计算从 MonoBehaviour 里抽出来用IJobParallelFor批量处理再用 Burst 编译。原来 100 个 NPC 的 Update 逻辑每帧要 2.7ms改成 Job 后降到约 0.3ms视觉表现完全没变。如果对 Job System 不熟最简单的方式是把那些逻辑简单、只读一部分数据、互相无依赖的 For 循环直接转成NativeArray加IJobParallelFor写完用 Burst 编译即可。要注意回写 Transform 必须在主线程做或者直接用Graphics.DrawMeshInstanced跳过 Transform 回写。5.3 Animator、物理和视锥剔除的细节Animator 有几个默认设置在移动 VR 里要格外小心。Update Mode保持 Normal不要用Unscaled TimeCulling Mode选BasedOnRenderers并且确保 Renderer 有正确的包围体。不在摄像机视野内的动画可以用OnBecameInvisible跳过animator.Update这是移动端常见的省钱手段。物理方面VR 场景如果不做多人实时交互物理步长保持默认的固定 50Hz 即可然后逐帧检查是否有脚本还在高频调物理接口。我在一个 NPC 预警系统里踩过坑每帧发射 4 次 Raycast光物理查询就吃掉 1.5ms。后来改成定时器每 0.2 秒检查一次开销直接忽略。5.4 帧调度是 VR 的最后一公里VR 项目的帧率控制不能像普通手游那样随意。正确做法是设置Application.targetFrameRate 72并把QualitySettings.vSyncCount设为 0把等待交给 XR SDK 管理。乱开垂直同步或者让 Unity 自己调度帧速率会出现渲染和头显刷新错位视觉上表现为卡顿和撕裂。另外如果在开发中看到 36 FPS 或 45 FPS 这类稳定数值先别急着庆祝。要检查是否开启了平台自身的插帧或锁频策略这类机制会掩盖真实性能问题后面单独讲。6. 真机调试中的典型问题与排查速查表6.1 一张表解决 80% 的“玄学”问题优化过程中我遇到过很多看起来像 Bug 的性能问题最后基本都是配置或平台特性造成的。整理成速查表现象根因解决方式锁定在 36/45 FPS设备启用插帧或刷新率降档在平台设置里确认插帧策略关闭强制 72Hz画面边缘模糊开启 FFR 后 UI 显示在边缘调整 FFR 等级或把 UI 放到注视点中央区域Release 包偶尔卡顿Shader 在设备上首次编译构建时用 ShaderVariantCollection 剔除不需要的变体贴图出现彩色噪点或马赛克块纹理压缩格式在真机不受支持统一转 ASTC确认 OpenGLES3/Vulkan 下都支持远处出现闪烁撕裂实时阴影关闭后网格 Z-Fighting拉开相邻面间距、加 Bias、合并网格DC 降了一半但帧率没变化瓶颈在 GPU 像素填充或 Overdraw继续查 Overdraw 和 RenderScale帧率稳定但转头时明显卡顿渲染等待和头显刷新错位检查设备 vSync 和 Unity 帧调度使用 XR 管理帧这张表看着简单实际每个问题我都花了一天以上的排查时间。6.2 案例PICO Neo3 上愚蠢的“36 FPS 真香”这是我在调完 FFR 和分辨率后遇到的。帧率稳定在 36 FPS心里还挺满意然后实测发现场景其实有能力跑满 72。原因是平台侧的应用空间插帧策略自动介入当渲染略微超时设备把帧率强制降到一半再用算法插值补帧。虽然画面看起来“流畅”了一些但转头时有明显拖影而且真实性能瓶颈被掩盖了。最后处理方式是在初始化时显式关闭插帧策略让它不能自动切入低帧率模式这样渲染必须真正达标。关闭之后帧率数据变成了真实的 72 或真实的掉帧排查问题一下子清晰多了。6.3 案例开启 FFR 后 UI 边缘模糊FFR 开启之后最直观的问题就是边缘 UI 文字变虚仿佛中间清晰、两边没戴眼镜。项目里的背包列表在边缘区域测试时玩家会反映看不清字。解法有三个把 FFR 等级从 2 降到 1边缘清晰度提升一些但性能收益也缩水把 UI 渲染成单独的 Overlay 层让 UI 不参与 3D 场景的 FFR 处理把 UI 布局限制在 45 度视角范围内。我最终选了方案二UI 边缘清晰度无损3D 场景继续享受 FFR 的性能红利。6.4 案例太阳光下山体闪烁这一类问题在场景优化后期很常见。关闭实时阴影并烘焙后太阳角度一变化山脊远距离网格因为 Z-Fighting 闪烁得让人头晕。最终解决办法不是加 Bias而是把山体网格做了一次合并去掉冗余面再为 Lightmap 单独生成一套低分辨率 UV。处理完闪烁完全消失。6.5 经验总结优化顺序比技巧重要回头看整个过程真正让我少走弯路的不是某一个技术点而是优化顺序先确定设备性能目标和渲染分辨率基线关闭或开启关键特效后立刻看 Profiler 数据按 Shader、资源、DC、光效的顺序逐个清理大开销项再用 FFR 和 Render Scale 做最后的兜底最后处理 CPU 侧的脚本和内存问题。如果一开始就去调 UI 和脚本效果会很差因为 8 FPS 的时候瓶颈还在 GPU 侧。所以拿到低帧率项目先问一句是 Shader 在烧 GPU还是 Draw Call 在烧 CPU还是 Overdraw 在烧填充率搞清楚再动手。整个优化做完后的最终数据是PICO Neo3 上稳定 72 FPS热点区域没有明显掉帧画面依然是完整的风格化卡渲风格。这个过程中最有价值的经验不是某个 Shader 怎么写而是一套“压抑住对画质的贪念先保住帧率底线”的思维方式。如果你想把这套流程用在自己的项目上我建议从今天开始就养成记录帧时间分布的习惯无论优化到什么阶段都把 CPU Main、Render Thread、GPU Time 三个数字写下来再动刀。没有数字依据的优化最后一定会变成靠运气的玄学调参。最后再分享一个小技巧每次改完一项内容不要立刻只看视觉变化先在真机 Profiler 里跑一遍记录帧时间再打开画面截图做比对。这样视觉和性能的因果关系才不会在记忆里互相污染。