1. 项目现状与初步瓶颈定位
1.1 项目背景和运行环境
先交代下项目背景。这是一个给 PICO Neo3 开发的开园式 VR 互动 demo,Unity 版本用的是 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 快照大概是这样:
| 模块 | 耗时 | 说明 |
|---|---|---|
| PlayerLoop(Main Thread) | 28.4 ms | 逻辑更新、动画、粒子 Update |
| Render Thread | 62.3 ms | Draw Call 提交、合批失败、材质参数传递 |
| GPU Time | 83.5 ms | 逐片元光照、Overdraw、后处理 |
| 合计(含等待) | 约 125 ms | 对应 8 FPS |
三层都超标,但 GPU 侧最重。所以优化计划也定了:Shader 和渲染相关主攻 GPU,Draw 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 簇草,每个都当成独立 MeshRenderer,DC 超过 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 个静态的,动态物体不接收实时反射。
烘焙完成后,动态物体的光照计算只剩平行光加 Lightmap,GPU 每帧少算一大堆光照。这个改动让 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.64 | GPU 节省约 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 时间,如果走全局 Update,CPU 的 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 Time;Culling 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 里跑一遍,记录帧时间,再打开画面截图做比对。这样视觉和性能的因果关系,才不会在记忆里互相污染。