首页 / 博客 / 性能
发布: 2026年4月16日 约 10 分钟 性能
移动游戏优化实战:通过测量draw call与overdraw把中端安卓机上的塔防场景提升17帧的完整记录

移动游戏优化实战:通过测量draw call与overdraw把中端安卓机上的塔防场景提升17帧的完整记录

在一台中端安卓手机上,我的塔防场景一直卡在41帧。这篇文章记录了我如何在完全不减面的前提下,在真机上测量draw call、材质数量与overdraw,一步步把同一个场景推到58帧、帧时间从24.3毫秒降到16.4毫秒,以及每一处改动究竟换回了多少毫秒,还有我建议的完整排查顺序。

卡在41帧的塔防场景

去年冬天,我接手了一个塔防项目的最后一轮优化。在Redmi Note 10上——Snapdragon 678、Adreno 612——推进到第12波时,帧时间涨到24.3毫秒,平均只有41帧。我最初的判断很常见:屏幕上敌人太多,那多半是AI和物理开销大。我花了两天时间给任务调度器做性能分析,却什么都没查到,游戏逻辑线程6.1毫秒就跑完了。

当Unity Profiler显示渲染线程14.8毫秒、GPU 26毫秒时,情况才清晰起来。Frame Debugger数出了780多个draw call,其中大约400个来自塔的射程圈、伤害数字、地面贴花和烟雾粒子。也就是说,屏幕的大部分被层层叠加的半透明图层覆盖,同一块区域被反复重画。我把敌人数量砍掉一半,帧时间却只少了1.2毫秒,问题根本不在那里。

真正的错误出在我自己对移动游戏优化的理解上:多年来我一直把它当成“减少面数”。整个场景一共21万个三角形,Adreno 612画这些几何体毫不吃力。压力来自两条完全不同的战线:每个draw call的CPU准备开销,以及每个像素写往主内存的带宽。下面记录的,就是我如何把这两条战线拆开,以及每一处改动到底换回了多少毫秒。

各种batching究竟什么时候生效

有三套机制,它们做的并不是同一件事。SRP Batcher 不会减少draw call的数量,它把共享同一个shader变体的绘制所用的材质常量放进一块常驻的GPU缓冲区,让每次调用的CPU准备工作变得更便宜。所以780个调用还是780个,只是每一个都明显更轻。关键在于shader变体的数量,而不是材质数量,我正是漏掉了这一点,才长期在合并错误的东西。

真正能把数量降下来的是GPU instancing:同一个mesh、同一个材质,一次调用最多可以画1023个实例。陷阱在于——在URP里,如果shader兼容SRP Batcher,instancing这条路径根本不会走,因为SRP Batcher的优先级更高。直到我用 Graphics.DrawMeshInstanced 手动绘制塔基时才发现这一点;此前我在Frame Debugger里看到一排“SRP Batch”,就以为instancing一直在起作用。

Static batching在打包时把多个mesh合并进一个大的顶点缓冲区。收益是实打实的,但也有代价:在我们的场景里多出了38MB的mesh内存,还失去了剔除粒度,因为合并后的一组要么整体绘制,要么完全不画。我只在那些从不移动、在镜头里本来也总是一起出现的地面构件上保留了它。给树木和岩石关掉之后,内存和实际提交的三角形数量都降了下来。

还有一个安静的batch杀手:MaterialPropertyBlock。为了用颜色区分塔的等级,我们给每个renderer都挂了一个,仅这一项就让那些物体彻底失去了SRP Batcher兼容性。把颜色变化改成走实例数据和顶点色之后,渲染线程从14.8毫秒降到11.0毫秒——没有改动任何一行shader代码。

BatchingSetup.cs
using UnityEngine; using UnityEngine.Rendering; // Tower bases share one atlas material, so they can be submitted as one // instanced call. URP prefers the SRP Batcher over instancing here. public sealed class BatchingSetup : MonoBehaviour { private const int MaxPerBatch = 1023; [SerializeField] private Mesh _baseMesh; [SerializeField] private Material _atlasMaterial; [SerializeField] private Transform[] _slots; private readonly Matrix4x4[] _matrices = new Matrix4x4[MaxPerBatch]; private int _count; private void Awake() { _count = Mathf.Min(_slots.Length, MaxPerBatch); for (int i = 0; i < _count; i++) _matrices[i] = _slots[i].localToWorldMatrix; } private void Update() { // One draw call for up to 1023 bases instead of one per renderer. Graphics.DrawMeshInstanced(_baseMesh, 0, _atlasMaterial, _matrices, _count, null, ShadowCastingMode.Off, receiveShadows: false); } }
csharpBatchingSetup.cs

材质数量与texture atlas的排布

真正把draw call数量顶上去的是材质数量。场景里有34个材质,其中大多数的差别只是一张512x512的贴图。我把它们打包进三张2048x2048的texture atlas:环境、塔,以及特效加UI。共享同一个材质的物体越多,留给instancing和batching的池子就越大。

做atlas没有看上去那么机械。重新排布UV时,我不得不排除所有依赖tiling的mesh,因为在atlas内部repeat的 wrap mode 不起作用,相邻小块的像素会渗过来。我给地砖单独保留了一个材质,改用instancing来画。为了避免mipmap边缘渗色,我给每个小块留了8像素的padding;用4像素时,远处会出现很细的彩色缝线。

压缩这边我从ETC2换成了ASTC 6x6,在同样的内存预算下画面明显更干净,尤其是atlas内部的渐变。做完atlas之后,材质从34个降到6个,draw call从780降到210,渲染线程从11.0毫秒降到7.4毫秒。这是整轮优化中收益最大的一步,而我一行shader都没写。

半透明图层在overdraw上的账单

overdraw指的是同一个像素在一帧里被写入多少次。半透明物体的 ZWrite 是关闭的,深度测试不会剔除任何东西,后面的和前面的一样要着色。在Rendering Debugger的overdraw视图里,场景中央读到了11倍,也就是说有些像素一帧要被涂十一遍。GPU那26毫秒里的大头就在这儿。

我把图层一层层数了一遍:射程圈、地面贴花、烟雾、火花、受击闪光,最上面还有一个全屏的暗角quad。我把暗角折进最终的后处理shader,删掉了那个独立的quad——它本身在1080p下就是完整的一层。射程圈改成只在选中塔时绘制。烟雾粒子从240个减到90个,同时把每个粒子放大:视觉密度不变,填充开销只剩三分之一。

不要把alpha test当成解法。在树叶和栅栏材质上用 clip(),会破坏基于tile的GPU上的early-z和隐面消除;我实测的两个例子里,改回alpha blend都快了0.6毫秒。同样的道理,把半透明物体从前往后排序也没有任何收益,因为它们都不写深度。收益只能来自减少图层的数量,以及它们覆盖的屏幕面积。

在tile-based GPU上真正的瓶颈是带宽

移动GPU会把一帧切成若干个tile,在片上一块又小又快的内存里处理每个tile,再把结果写回主内存。真正昂贵的不是shader里的数学运算,而是这些写入和读取的流量。在1080p下,一张RGBA32的目标每帧大约8MB,60帧就是500MB/s,而这还只是一个pass。设备发热时最先被限制的正是内存频率,所以带宽同时也是一个散热问题。

这也是为什么在一帧中途切换render target代价很高:每切换一次,都要强制把tile内存resolve回主内存。我们的后处理链上有三次独立的blit,把bloom的降采样和调色合并进一个pass之后,GPU时间少了1.7毫秒。同理,对那些之前内容无关紧要的目标传入 RenderBufferLoadAction.DontCare 是白捡的收益;一次多余的load意味着把整张目标从主内存读回tile。

depth prepass在移动端通常适得其反。这个在桌面端用来削减overdraw的技巧,在tile架构上会把几何体处理两遍并产生额外的深度流量,在我们的场景里反而多花了0.9毫秒。Adreno自己的低分辨率Z剔除早就免费做了类似的事。我试过、测过,然后回滚了——这是桌面端经验直接照搬会失效的地方之一。

针对半透明图层,真正的解法是半分辨率粒子渲染。我把粒子画到一张一半尺寸的render target上,再用带深度感知的上采样合回场景:需要着色的像素数降到四分之一,合成花掉0.4毫秒,净收益3.1毫秒。边缘会有轻微的锯齿,但在烟雾、尘土这类低频特效上根本看不出来。火花和弹道这种又细又锐利的特效,我保留在全分辨率。

在中端安卓机上实测到的结果

同一段60秒的第12波录像,同一台Redmi Note 10:帧时间从24.3毫秒降到16.4毫秒,平均帧率从41帧升到58帧。draw call从780降到190,材质从34个降到6个,实测峰值overdraw从11倍降到4倍。在15分钟的连续游玩中,电池温度稳定在39℃而不是44℃,设备始终没有降频,最后五分钟帧率往下掉的现象也消失了。

我很在意这份收益的分布,因为它决定了下个项目我该从哪儿动手:大约40%来自材质和atlas的整理,35%来自减少overdraw加上半分辨率粒子,15%来自带宽方面的调整,剩下的来自恢复SRP Batcher兼容性。减面在这份清单上一处都没有出现。我完全没有动过mesh,LOD设置也原封不动。

换成你自己的项目,我建议按这个顺序来:先在Frame Debugger里把材质数量和batch被打断的原因记下来,再到overdraw视图里找出最糟糕的三层,最后才去碰shader。每一步都要在真机上、同样的温度、同一段录制场景里测量;在编辑器里显示200帧、到手机上却慢了0.2毫秒的改动,我遇到过太多次。另外,不要只盯着一个数字——draw call、overdraw和带宽是彼此独立的上限,修好一个,另一个很容易就坏了。

← 全部文章