系列前两篇我们聊完了怎么把风格化村庄的模型、贴图和光照从PC端迁到PICO Neo3,场景是塞进去了,效果在编辑器里看着也还行。但拿到真机上跑一圈,问题全冒出来了——转身掉帧、场景一复杂就发热、渲染帧率上不到72Hz,更有意思的是有些地方明明模型不多,帧时间却高得离谱。
这次我彻底不绕弯子了,直接把项目接上Profiler,把村庄场景从头到尾扒了一遍,把拖累帧时间的几个大头全揪了出来。这篇不聊虚的,直接讲我在优化风格化村庄渲染时做的几件实事:怎么定位CPU和GPU瓶颈、怎么在不丢掉风格化观感的前提下砍掉过度绘制、怎么让合批和遮挡剔除真正生效,以及最后从33ms优化到15ms左右的完整过程。整个过程都是在PICO Neo3真机上验证过的,Unity版本用的2021.3 LTS,渲染管线是URP。
1. 量化问题先行:PICO Neo3的硬件底子与帧时间预算
1.1 先搞清楚PICO Neo3能干什么
PICO Neo3搭载的是骁龙XR2平台,这是一颗为XR设备准备的SoC,理论性能不弱,但散热和功耗限制摆在那里,实际持续性能远没有纸面数据那么漂亮。屏幕刷新率支持72Hz和90Hz,我在VR项目里一般直接锁定72Hz。为什么不用90Hz?72Hz下帧时间预算是13.9ms,90Hz下只有11.1ms,而一体机跑风格化场景时往往连13.9ms都压得很吃力,90Hz只会让压力翻倍。
这里要特别注意:13.9ms不是全程归你用的。XR设备在渲染左右眼之外,还有系统级的ATW、透视、追踪等环节,实际游戏逻辑和渲染只能分到其中的一部分。我在PICO Neo3上通过Unity XR插件查看,留给Unity的帧窗口大约在11ms到12ms之间,再减去脚本和物理逻辑的时间,渲染预算大概只有8ms到9ms。很多人发现画质调完还是很卡,就是因为没把预算算清楚,实际可用的渲染窗口其实非常窄。
判断设备到底剩多少窗口,可以用Unity的Frame Timing Stats,也可以在Profiler里直接看WaitForTargetFPS和Present相关的时间。我习惯在开发早期就把这个数字打出来显示在角落,这样后续写性能优化的每一步都能有个硬性参照,而不是等掉帧了才开始猜硬件到底吃了几碗饭。
1.2 用Profiler定位瓶颈在CPU还是GPU
在Unity里开Profiler后,第一步不是急着看哪个模块高,而是先切到性能分析模式。我习惯把Profiler的Target Platform设为PICO Neo3对应的窗口,然后跑一段固定的场景巡视路径,一边走一边记录。看CPU Usage和GPU Usage的比值。如果CPU耗时高,说明Draw Call、脚本、物理、动画、粒子这些系统在抢时间;如果GPU耗时高,说明Shader复杂度、填充率、纹理带宽顶不住。
我当时的Profile数据非常有代表性:CPU平均耗时9ms,GPU平均耗时22ms。其实不用想,瓶颈在GPU。这时候如果我去合并网格、砍Draw Call,效果会非常有限,因为CPU根本不是主要瓶颈。真正要动的是Shader、Overdraw、Render Scale、纹理尺寸这些会压满像素的东西。如果你看到CPU和GPU都高,一般先处理CPU,因为CPU瓶颈往往会造成明显的卡顿,而GPU瓶颈的表现是慢慢掉帧到十几帧,更隐蔽。
还有一个容易忽略的点:把Profiler里的Present和VSync相关的时间剔除后再看真正的CPU/GPU时间。一体机上VSync和画面输出经常被系统同步卡住,有些人看到帧时间高就开始优化,结果优化半天发现是Present同步的问题,白忙一场。
1.3 记录三组数据的基准原则
优化最忌讳凭感觉。我给自己定的规矩:每次优化前必须记录三组数据——平均帧时间、最高帧时间、Draw Call加SetPass Call。然后优化一轮,再跑同样的路径,看看各降了多少。如果一轮优化改了好几个变量,后面发现问题就没法判断到底哪一步起了作用,这属于自找麻烦。
这里专门说下峰值帧时间。平均值好看但偶尔飙出一条尖峰,说明某些帧在做特别重的操作,常见来源是GC Alloc、动态合批失败、Shader编译或者遮挡剔除的更新。这些尖峰在VR里比平均值更要命,你可能察觉不到明显的掉帧,但头部转动时会有一瞬间的画面迟滞,眩晕感就是这么来的。所以我每次看数据都要打开Timeline视图,把帧时间曲线拉出来看,专挑那些尖峰帧去查原因。
实际上我建议把“记录基准”这一步也自动化,直接在工程里写一个简单的性能记录脚本,跑固定路径时按快捷键记录平均帧时间、Max帧时间、DrawCall和SetPass Call,输出成CSV。这样改一版数据存一版,后面整体对比时一目了然,比自己拿本子记靠谱得多。
2. GPU侧的釜底抽薪:风格化村庄的Overdraw与像素填充率
2.1 为什么风格化场景尤其容易喂饱GPU
风格化村庄看起来简单——低多边形、色块、卡通材质——但把它喂给GPU时反而很容易爆填充率。原因有三个:第一,风格化场景通常喜欢用半透明白模做植物、草丛、树叶,这些材质虽然单个看着面积不大,但堆叠起来每个像素会被渲染好多遍;第二,低多边形模型经常使用硬边和大量锐角,渲染时需要更频繁地切换多边形状态;第三,为了让颜色明快,风格化材质经常在Shader里做颜色偏移、边缘光、淡入淡出之类的效果,这些效果都要在像素着色器里跑,每一滴都有成本。
我当时把场景里所有半透明物体列了一遍,光是草、花、水面、烟雾加一起就占了场景物体的四分之一。这些半透明物体会让渲染器从后往前排,Overdraw直接翻倍。移动端的GPU内存带宽本来就不富裕,风格化村庄这种又满又密的构图,填像素的速度很快就跑不赢帧时间预算。
有一个比较直观的验证方法:在Unity里使用RenderDoc抓一帧,用Overdraw可视化着色器查看像素重复渲染次数。我在村庄中心区域的Overdraw数值高到离谱,某些草丛茂盛的位置同一像素被写了七八次,这几乎等于把分辨率翻了几倍在跑。看到这个数据之后,我就知道必须从渲染机制上砍掉重复写入。
2.2 砍Overdraw但不破坏视觉密度:透明改不透明,靠贴图取胜
Overdraw的典型解法是减少半透明物体的实际绘制。我的做法很直接:把草、灌木、树叶这些“本来能做半透明”的材质,全部改成不透明材质。你可能担心这样会失去通透感,其实风格化的草在真实感画面上本来就不靠透明来体现,靠的是交叉面片和贴图。把Alpha Blend改成Alpha Test(更准确地说是Alpha Clip),面片中央画密集草叶,周围画透明缝隙,这样既能渲染出棵棵分明的草,又不用反复写后台缓冲。
改完材质后的Shader关键部分大概是这样的:
// 半透明草改Alpha Clip草 Tags { "Queue" = "Geometry" "RenderType" = "Opaque" } Pass { // ... ClipMask(i.uv); // 原来这里的Blend SrcAlpha OneMinusSrcAlpha 直接移除 // 把alpha低于阈值的光照片段剔除 }你可能会觉得Alpha Clip仍然会产生一些额外指令,但GPU处理Alpha Clip比处理Blend要快得多,因为Blend必须读回后台缓冲再混合,而Clip直接丢弃片元。实测同一片草地在改完材质后,帧时间降低明显,而视觉上草叶边缘仍然有锯齿,配合风格化画风反而更“卡通风”。
改完草之后我又做了一件事:把水面材质里的折射计算去掉。移动端的水面没法做屏幕空间反射,折射更不可能,我用一个简单的半透明水色叠加波纹法线贴图,效果在村庄场景里完全够用。这样一处调整,Overdraw又降了一截,而且视觉上的差异几乎看不出来。
2.3 Shader优化:从Full Precision到Medium Precision的实战
风格化村庄的Shader一般不会特别复杂,但如果每个材质球用了很多浮点运算,叠加起来还是能压垮GPU。优化Shader的关键就一条:移动端能用半精度就用半精度,能用贴图就不用计算。
在URP里,最简单粗暴的优化是把Shader里的float改成half。我排查了所有自写Shader,把没必要的float精度全部下调到half,涉及颜色混合、UV扰动、顶点位移这类操作,视觉上没有任何区别,但中等精度指令在部分移动GPU上速度优势明显。另一些常见的坑包括:在像素着色器里写pow、sin、cos、smoothstep的连乘,这些数学函数对移动GPU很不友好,能放到顶点着色器就放到顶点着色器,或者直接用贴图采样代替。
然后是光照部分。风格化村庄不需要物理正确的多光源实时光照,我把光源数量压到最少,只保留一个实时方向光,其他光源全部用烘焙光照贴图。实时阴影直接关掉,在URP里把Shadow Cascades设为No Cascades,Shadow Type设为None。烘焙光照和阴影足够村庄场景使用,还能给风格化画面增加手绘质感的软阴影效果。
如果你一定要保留实时阴影,我建议把阴影距离砍到10米以内,阴影分辨率降到512,或者只对主角周围的几棵建筑开阴影。村庄里那些远景的房屋阴影,人眼根本不会注意到,砍掉之后能省下一大块GPU时间。
3. CPU侧的减负艺术:Draw Call、合批与网格重组
3.1 CPU时间去哪了:Draw Call只是冰山一角
很多人一说优化就提减少Draw Call,但Draw Call本身并不是CPU消耗的大头。真正的大头是SetPass Call和数据上传——每次切换Shader Pass、切换材质球,CPU都要做一堆状态验证和传输工作。在Profiler里能看到SetPass Call往往比Draw Call少,但对CPU时间的贡献却更明显。
我在PICO Neo3上看自己的数据,一开始Draw Call有320个,SetPass Call有150个,平均每个SetPass要状态切换两到三次。这些切换大部分来自材质球之间的差异:有的用Alpha Clip,有的用Alpha Blend,有的贴图大小不一致,有的Tiling不一样。想让合批真正生效,第一步不是合并网格,而是让同类型物体用同一套材质球,统一Shader参数,减少状态切换。
用Frame Debugger逐帧看的时候尤其明显,同一个房屋模型,因为材质球里Color参数差一点点,就被拆成两个Draw Call。这种零碎状态切换是CPU侧最隐蔽的浪费。我后来把所有房屋材质做成预设体,统一颜色、统一Tiling、统一Metallic和Smoothness参数,Draw Call数字立刻下去了一截。
3.2 静态合批、动态合批与GPU Instancing的取舍
三种方式各有适用场景。静态合批适合纹理朝向固定、不会动的场景物体,它把多个网格合并成一个,代价是内存和构建时间上升;动态合批适合顶点数很少的小物体,但有900个顶点的限制,一旦物体顶点数超了就只能放弃合批;GPU Instancing适合大量使用相同网格和相同材质的物体,它把一个Draw Call对应一堆实例,非常适合村庄里大量重复的树、石头、围栏。
我的选择是三者混用:把房屋、墙面、屋顶这类大体量网格做静态合批,因为它们不会动也不需要变形;把散落的小石头、小木箱、矮篱笆做动态合批,顶点数基本都在900以内;把大量重复的树木和草丛用GPU Instancing,同一棵树一个Draw Call就能渲染几十棵。
三种方式的具体取舍可以参考这个表格:
| 技术 | 适用场景 | 顶点限制 | 内存开销 | 我的使用对象 |
|---|---|---|---|---|
| 静态合批 | 固定不动的网格 | 无 | 较高(合并网格会复制顶点数据) | 房屋、墙面、屋顶 |
| 动态合批 | 顶点数很少的小物体 | 900顶点内 | 较低 | 石头、木箱、篱笆 |
| GPU Instancing | 大量相同网格+相同材质 | 无硬性限制但有实例数上限 | 较低 | 树木、草丛、灌木 |
需要特别提醒的是,GPU Instancing在URP里和Lightmap兼容性要检查一下。如果实例化的物体用的是光照贴图,可能要调整材质球的Keyword才能让Instancing生效。我当时用了一个小技巧:给实例化物体单独建一套不带光照贴图的材质,视觉效果差别不大,但优化效果立竿见影。
3.3 LOD与遮挡剔除的配置细节
LOD组很简单:给主要物体(房子、树、凉亭、水塔)做三档模型,LOD0是高模、LOD1是中模、LOD2是低模,然后按照距离切换。PICO Neo3的屏幕像素密度高,但VR设备对远距离物体的细节其实不太敏感,我把切到LOD1的距离压到12米左右,切到LOD2的距离压到25米左右,比PC端激进很多,视觉上几乎感觉不到差别,但每帧要渲染的三角形数和填充面积少了很多。
LOD切换时要小心“文件闪烁”现象,两级模型如果在细节上差距太大,玩家会看到物体突然“瘦一圈”。我给风格化村庄的LOD1和LOD2保留了基本造型轮廓,只是删减法线细节和小的附着物(比如屋顶上的烟囱、围栏边的木桩),差距控制在一个合理的范围内。实践中可以先在编辑器里把摄像头拉远,逐级观察切换效果,再回去调整距离阈值。
遮挡剔除是另一个容易被忽略的大头。Unity的遮挡剔除需要提前烘焙,我一开始图省事没做,结果场景里远处的房子和近处的高墙互相重叠,每一帧都在渲染大量被挡住的东西。后来给场景添加了Occlusion Area,把村庄的多个区域包进去,烘焙数据后,帧时间明显降了一截。尤其是站在村庄中心的时候,视线被建筑群挡住,旁边那些根本看不到的房子都不渲染了,帧时间能再少两三毫秒。
还要特别注意:遮挡剔除对动态物体并不友好,动态物体默认不会被静态遮挡剔除。如果场景里有跑来跑去的NPC或者飘动的粒子,它们依然会被渲染。所以遮挡剔除主要用于静态建筑、树木和地形,动态物体还是得依赖其他优化手段。
4. 实操记录:一次从33ms到15ms的完整优化过程
4.1 建立基准:先记录很差的数据
优化之前我先记录一组完整的基准数据,这组数据代表“很差但真实”的起点,后面每一步优化的效果都会拿它做对比。
场景状态:PICO Neo3,72Hz模式,Render Scale为1.0,实时阴影开启,没有做任何LOD和遮挡剔除,所有材质都还保持着PC端的参数。我沿着村庄主街道从南到北走了一遍,用Profiler记录平均帧时间。结果相当难看:平均帧时间33ms,Draw Call 320,SetPass Call 150,部分转弯处帧时间甚至飙到50ms以上。这个数据虽然烂,但它足够真实,也说明优化空间大。
刻意记录这种数据有一个额外好处:如果后面优化过程中出现“越改越卡”的情况,能第一时间回退到初始场景重新对比。我通常给初始状态单独存一个场景版本,命名比如“Baseline_Bad”,后续所有改动都从它衍生出去,出了问题直接切回来就行。
4.2 每一轮只动一个变量,记录一次对比
我把整个优化过程拆成五轮,每轮只动一个主要变量,然后记录数据。
| 优化轮次 | 主要操作 | 平均帧时间 | Draw Call | SetPass Call | 备注 |
|---|---|---|---|---|---|
| 1 | 初始状态 | 33ms | 320 | 150 | 全阴影开 |
| 2 | Render Scale 0.8 | 28ms | 320 | 150 | 画质略降 |
| 3 | 关闭实时阴影 | 19ms | 320 | 150 | 效果巨大 |
| 4 | 静态合批+网格合并 | 17ms | 158 | 62 | CPU明显减负 |
| 5 | LOD组+遮挡剔除 | 15ms | 121 | 48 | 接近预算上限 |
这五轮做完,平均帧时间从33ms降到15ms,虽然还不到72Hz的13.9ms预算线,但已经非常接近,配合系统级的Reprojection,实际体验顺畅了非常多。每轮改动我都另存了一个场景版本,万一一轮改崩了可以快速回退。
为什么第二轮要把Render Scale先降到0.8?因为真机上很多性能问题在低分辨率下跑一遍之后会暴露得更清楚。Render Scale降下来如果帧时间没有明显改善,说明瓶颈不在填充率,可能在Shader或者CPU;如果帧时间一下子就降下来了,那就顺着GPU方向继续查。我这次降到0.8发现帧时间只降了5ms,说明像素填充虽然是瓶颈之一,但还压着另一个更大的问题——实时阴影。
关掉实时阴影之后帧时间直接降到19ms,这一步是全场收益最大的一次操作。实时阴影在移动端几乎是灾难,尤其在低多边形场景里,阴影贴图的分辨率稍微调高一点,带宽和GPU时间就成倍往上走。我的结论是:风格化村庄优先用烘焙光照贴图,哪怕要花几分钟离线烘焙,也是完全值得的。
4.3 纹理、图集与压缩格式的最终调整
在前四轮把渲染大头压下来之后,我又对纹理做了一次整理。风格化村庄里的颜色贴图往往不讲究分辨率,有些素材直接从素材商店拿过来就是2048分辨率,但放在PICO Neo3这种一体机上看根本用不上。
我把所有大尺寸贴图统一压缩到1024或512,并设置成ASTC格式。ASTC 6x6的块大小在画质和性能之间最平衡,8x8块能进一步省显存但画质损失看得见。村庄里大量重复的木屋、石头、草垛用几张图集合并,减少材质切换带来的状态变化。Mipmap对降低放大屏幕时的带宽压力非常有帮助,我全部打开,再配合适当的各向异性过滤,远看近看都不会闪。
这里有一个贴图压缩的坑:ASTC不是所有引擎格式都默认支持,PICO Neo3的GPU是支持ASTC的,但你在Unity里构建时必须确认Texture Importer里选对了Android格式。如果格式选错了,Unity会默认转成RGBA32未压缩格式,那显存占用和带宽消耗会直接爆炸,帧时间比优化前还难看。
纹理尺寸也不是越小越好。我在村庄入口做了一面比较大的酒馆招牌,结果降到512后,近看糊得不行。最终解决方案是招牌保留1024,但周围墙面压到512,远处山体压到256。这个思路是“给玩家眼前的东西留足够细节,远处的东西能省就省”,整体画面观感几乎没有损失。
5. 常见问题与排查技巧实录
5.1 合批失效的十大原因速查
优化过程中最容易遇到的问题就是合批失效。我自己整理了一张速查表,排查顺序按出现频率排:
| 原因 | 表现 | 对策 |
|---|---|---|
| 多个材质球共享同一份Shader但参数不同 | 同一模型被拆分多次 | 合并材质球参数,统一Material |
| 材质ID不同,但Unity按ID合批 | 无法合批 | 使用同一个Material实例 |
| 物体不是静态 | 静态合批无法生效 | 勾选Static |
| Lightmap index不同 | 合批被打断 | 统一光照贴图UV |
| 顶点数超过动态合批上限 | Draw Call飙高 | 拆网格或改GPU Instancing |
| 使用了不可合批的Shader(内置粒子、雾特效等) | 无法合批 | 换自定义Shader |
| 物体在不同Layer上 | 合批容易失效 | 放同Layer |
| 顶点属性过于复杂 | 无法合批 | 简化Mesh |
| 使用多Pass Shader | 每个Pass单独Draw | 减少Pass数量 |
| 材质启用了GPU Instancing但不满足实例化条件 | 反而增加Draw Call | 检查材质Keyword和网格 |
实话说,合批失败场景里最常见的还是材质球不统一、顶点数超上限这两条。我排查时一般先选中一个高Draw Call的物体,看它周围有没有同模型,如果同模型却分成了多份,那多半就是材质球或者Lightmap的问题。使用Frame Debugger能看到每个Draw Call的具体原因,Unity会标注合批失败的原因,照着提示改就行。
5.2 Shader变体膨胀与包体失控
移动端项目里Shader变体膨胀很容易把人坑了。URP会根据材质启用的Keyword编译大量变体,比如你场景里用了某个带Shadows的Lit材质,哪怕场景不启用阴影,编译器可能依然把阴影相关的变体编进去。构建一次动不动出来上千个变体,包体直接变大,启动时Shader编译的卡顿也跟着来。
解决方法是去Project Settings里配Shader Control,把用不到的Keyword全部关掉。我自己用了一套“最小变体集合”:只保留Alpha Clip、Lightmap、GPU Instancing这几个Keyword的变体,阴影、雾效、粒子、HDR相关的全部排除。配置完之后,构建速度提升了一截,真机进场景的首帧卡顿也少了很多。
如果你不想在全局设置里改,也可以用ShaderVariantCollection手动收集需要的变体。做法是先用ShaderVariantCollection记录当前场景中实际用到的变体,然后构建时只打包这组变体。这个方案更精准,但维护成本高一些,适合变体已经稳定之后再用。
5.3 遮挡剔除漏剔导致帧时间骤升
遮挡剔除调试的一个常见现象是:站在某个角落帧时间正常,走几步突然掉帧严重,一查发现一堆远处的房屋没被剔除。排查方法是打开Occlusion Culling窗口的Visualize面板,逐个Region查看遮挡数据是否覆盖了所有可能产生遮挡的建筑群。
如果漏剔的区域正好在Occlusion Area外,Unity就不会做剔除计算。我给村庄追加了多个Occlusion Area,覆盖主街道两侧、广场、河岸几个视线交叉区域。注意Occlusion Area不要放得太大,太大的Occlusion Area反而会导致烘焙时间变长和更新开销变大,所以拆成一组小区域是最合适的。
另外一个容易踩的坑是:静态物体生成Mesh Collider时,遮挡物只认Occluder Static标志。如果你勾了Static但没勾Occluder Static,Unity会认为它是被遮挡物而不是遮挡物,可能造成漏剔。我排查时会在Occlusion窗口里把IsOccluder列调出来,确保主要建筑都勾上了。
5.4 GC Alloc与随机性掉帧
最后说一个特别容易被忽视的坑:GC Alloc。脚本里如果每次Update都new一个List、数组,或者用LINQ、字符串拼接,就会产生垃圾内存。Unity这边GC回收时会卡顿,尤其在一体机上更容易造成随机性掉帧。
我优化时的做法是给所有可能频繁调用的逻辑做对象池,预先分配数组,尽量用for循环代替LINQ。比如给场景里散落的树叶粒子做独立池复用,初始一帧只分配一批叶子实例,后面每帧在池里取和放回,不再产生GC。还有一个细节是避免在Update里读取Transform组件,因为每一次Read/Write Transform都会产生同步开销。把这些代码级小坑解决之后,Profiler里的GC Alloc曲线终于能从一条毛刺变成一条直线。
如果你用的是Unity 2021以上的版本,还可以在代码里多用Span,或者用NativeArray配合Burst编译,把内存分配频率降到最低。移动端VR项目里很少需要复杂的热更新逻辑,纯计算部分用Burst能省出一大截CPU时间。
这套流程跑完,我的最大体会是移动端VR优化真不是靠几个技巧就能一劳永逸的,每个场景都有自己的瓶颈点。关键是让优化变成一种可测量的习惯:先记录数据,再动一个变量,跑一遍,再记录,对比,循环。最终我不光把帧时间从33ms拉到15ms左右,更重要的是学会了怎么在动手之前就知道该往哪边使劲。
下一步我打算把优化完的场景拿去做更多极限测试,比如同时出现大量角色、加一些动态天气系统,看看哪些地方还会冒出新瓶颈。如果你也在搞类似的移动端风格化场景,遇到掉帧时先别急着堆优化技巧,先把你自己的Profiler数据贴出来,看看CPU和GPU到底谁在拖后腿。这一招真的能帮你省下好几个晚上的瞎折腾。