5.1 性能优化与对象池:帧率值班日志 本节摘要:优化是查账不是猜谜:用 Profiler 读出每毫秒的去向,再用对象池、批处理、LOD 三把工具销账。本节给出定位方法与一份按收益排序的优化清单,并把对象池写成可直接落地的模板。 优化是查账不是猜谜 "感觉有点卡,要不要把阴影关了?"——这类对话每天在无数团队里发生,而正确流程只有三步:先测量(Profiler 挂上,跑同一段玩法,记录 CPU 与 GPU 的耗时分布)、再归因(热点在脚本逻辑、物理、渲染批次还是内存回收)、后动手(只改归因指向的地方,改完复测对比)。跳过测量直接动手的优化,一半在浪费工时,另一半在制造新问题。
本节摘要:优化是查账不是猜谜:用 Profiler 读出每毫秒的去向,再用对象池、批处理、LOD 三把工具销账。本节给出定位方法与一份按收益排序的优化清单,并把对象池写成可直接落地的模板。
"感觉有点卡,要不要把阴影关了?"——这类对话每天在无数团队里发生,而正确流程只有三步:先测量(Profiler 挂上,跑同一段玩法,记录 CPU 与 GPU 的耗时分布)、再归因(热点在脚本逻辑、物理、渲染批次还是内存回收)、后动手(只改归因指向的地方,改完复测对比)。跳过测量直接动手的优化,一半在浪费工时,另一半在制造新问题。
Profiler(Window 菜单里的性能分析工具)按帧给出时间轴:CPU 区能看到每个函数的耗时排行,一眼锁定"谁的值班拖长了整帧";渲染区能看到批次、三角形与显存;内存页能看到驻留资产排行。真机数据与编辑器数据差异巨大,查真机问题必须连真机采集,编辑器结论只能当线索。
新手最容易归因错的一类:把 GC 尖峰当成"代码太慢"。每帧大量 Instantiate、Destroy、字符串拼接、Linq 查询都会在托管堆里制造垃圾,等垃圾回收器出清时帧率瞬间跳水——账单不在"慢"而在"频繁分配"。对象池要解决的正是这类问题里最大的一笔。
对象池:把"反复创建销毁"改成"借了还"。子弹、金币、飘字、特效这类高频短命对象, Instantiate 与 Destroy 每次都要分配内存、初始化组件、触发 GC——池子提前造一批,用时借、用完还,运行期零分配。
批处理与合批:渲染批次是 CPU 给 GPU 下订单的次数,同材质的物体能拼成一单。静态批处理(标记后合并网格)适合永远不动的场景物,动态合批适合少量小物件,URP 下的 GPU 合批又能覆盖一批中间场景。降低批次的原则是"共用材质"——一百个不同颜色的箱子是一百批,改成贴图区分颜色就是一个批次的量级。
LOD 与剔除:远处的东西不值得精雕细刻。LOD Group 让同一物体按相机距离切换精细度档位;视锥剔除引擎自动做,遮挡剔除(Occlusion Culling)要手动烘焙,适合室内与巷道结构。性价比极高的还有"相机远裁剪面",把默认一千米的远裁面砍到玩法需要的距离,天空盒补背景,立竿见影。

背景:关卡里有持续刷出的金币与拾取特效,运行十分钟出现周期性卡顿,Profiler 显示 GC 尖峰与 Instantiate 热点重合。
操作:写一个通用对象池,替换原来的 Instantiate 与 Destroy:
using UnityEngine; using System.Collections.Generic; public class ObjectPool : MonoBehaviour { public static ObjectPool Instance { get; private set; } // 全局唯一入口 // 每种预制体一个池:键是模板,值是待借列表 Dictionary<GameObject, List<GameObject>> pools = new Dictionary<GameObject, List<GameObject>>(); void Awake() { Instance = this; } public GameObject Get(GameObject prefab, Vector3 pos, Quaternion rot) { if (!pools.TryGetValue(prefab, out var list)) { list = new List<GameObject>(); pools[prefab] = list; } GameObject obj; if (list.Count > 0) { // 有库存:从池里取出复用 int last = list.Count - 1; obj = list[last]; list.RemoveAt(last); obj.SetActive(true); obj.transform.SetPositionAndRotation(pos, rot); } else { // 无库存:首次制造,之后终生复用 obj = Instantiate(prefab, pos, rot); } return obj; } public void Release(GameObject prefab, GameObject obj) { // 归还:停用并入池,不销毁 obj.SetActive(false); pools[prefab].Add(obj); } }
金币侧改造成"借还制":
public class CoinPoolable : MonoBehaviour { public GameObject prefabTemplate; // 指向自己的预制体 void OnTriggerEnter(Collider other) { if (!other.CompareTag("Player")) return; CoinAnnouncer.totalScore += 10; CoinEvents.CoinPicked(transform.position, CoinAnnouncer.totalScore); // 归还而不是销毁;拾取特效也从池里借 ObjectPool.Instance.Release(prefabTemplate, gameObject); } }
结果:复测同一段玩法,Instantiate 热点消失,GC 尖峰基本抹平,十分钟压力测试帧率曲线平稳。
解读:池子的设计要点有三。其一,归还时用 SetActive(false) 而非 Destroy,对象组件全部保留,下次激活省去初始化;其二,借用方要在 OnEnable 里重置自身状态(计分、血量、特效进度),因为池化对象的"复活"不等于"初始化"——靠 Awake 与 Start 做一次性初始化的代码在池化后必然出 bug;其三,池无上限时首次高峰仍会制造一批 Instantiate,对帧率敏感的场景在加载期预热(开局先造满峰值数量再入池)。
变式:给池加"借出超时回收"——借用超过三十秒未归还自动收回,防止调用方遗漏 Release 造成池干涸。这层保险在多人协作项目里几乎是必需品,因为你不了解所有队友的调用习惯。
最后压一份实操清单:先消 GC 与对象池(收益最大、风险最低),再合并材质降批次,然后 LOD 加远裁面,最后才考虑代码微优化(缓存 GetComponent、避免空 transform 查找等)。顺序错了的常见后果:在微优化上耗掉一周,帧率纹丝不动,因为真正的账单在渲染批次上。
账单销得差不多了,但资源仓库还是"全量进货"。下一节让仓库按需供货。