第五章 性能优化总论:从 Profiler 到上线
4176 字
21 分钟
第五章 性能优化总论:从 Profiler 到上线
第五章 性能优化总论:从 Profiler 到上线
一句话理解:优化的第一原则——先 Profile,再优化。你直觉认为”这里慢”的地方,90% 不是真正的瓶颈。Profiler 不会骗你,直觉会。
📋 前置知识:本系列全部章节——优化章是前四章知识的综合运用
5.1 优化的三条铁律
铁律一:没 Profile 不优化
人脑的直觉在性能问题上是系统性的错误:
你以为慢的:复杂的 AI 决策、物理计算实际上慢的:UI Canvas 重建、GC Alloc、意外的 FindObjectOfType
每一帧只有 16.67ms(60 FPS)的预算。Profiler 告诉你这 16.67ms 花在哪了——其它都是猜测。铁律二:优化瓶颈,不优化”看起来能优化”的
一个函数耗时 0.1ms,你花了三天优化到 0.05ms → 省了 0.05ms一个 DrawCall 合批问题耗时 4ms,修一下 → 省了 4ms
投入产出比:4ms / 0.05ms = 80 倍铁律三:在目标设备上 Profile
Editor 中的 Profiler 不等于真机:- Editor 有额外的调试开销- PC 的 CPU/GPU 性能可能比手机高 10-50 倍- PC 上不卡的场景手机上可能只有 15 FPS
必须在目标设备上运行 Profiler(Build 版本,Development Build 勾选)5.2 CPU 优化
Unity Profiler 的使用
Profiler 窗口的核心模块:
CPU Usage → 每帧各个函数的耗时(按时间排序)GPU Usage → 渲染耗时(DrawCall、Shader 等)Memory → 内存分配、泄漏、GCRendering → DrawCall 数量、合批统计、SetPass CallAudio → 音频开销Physics → 物理模拟耗时最常见的 CPU 瓶颈及修复
瓶颈一:GC Alloc——分配了临时内存
// ❌ GC Alloc 的常见制造者void Update() { // 1. 字符串拼接——每次拼接分配新 string string display = "HP: " + currentHealth + "/" + maxHealth; // 每帧分配 1 个 string ≈ 40 bytes → 60 FPS = 2.4KB/s
// 2. LINQ——背后有迭代器分配 var aliveEnemies = enemies.Where(e => e.IsAlive).ToList(); // Where 分配迭代器 + ToList 分配 List
// 3. foreach 在非泛型容器上 foreach (var item in oldArrayList) {} // 装箱分配
// 4. 闭包——每次 lambda 可能分配 button.onClick.AddListener(() => DoSomething(param)); // 捕获 param → 分配闭包对象}// ✅ 修复方案void Update() { // 1. 用 StringBuilder 或 TMP.SetText 的重载 tmpText.SetText("HP: {0}/{1}", currentHealth, maxHealth);
// 2. 不用 LINQ——手写循环 int aliveCount = 0; for (int i = 0; i < enemies.Count; i++) { if (enemies[i].IsAlive) aliveCount++; }
// 3. 用泛型容器 foreach (var item in genericList) {} // 无分配
// 4. 缓存委托 button.onClick.AddListener(cachedDoSomething); // 如果必须传参,用成员变量而非闭包捕获}
private System.Action cachedDoSomething;
void Awake() { cachedDoSomething = DoSomething;}瓶颈二:Update 中的重操作
// Profiler 中 Update 耗时 > 2ms 的常见原因:
// ❌ 1. GetComponent<T>() 在 Update 中void Update() { var rb = GetComponent<Rigidbody>(); var anim = GetComponent<Animator>(); // GetComponent 约 0.0001ms——但积少成多}
// ❌ 2. GameObject.Find / FindObjectOfTypevoid Update() { var manager = FindObjectOfType<GameManager>(); // 遍历整个场景!}
// ❌ 3. Camera.mainvoid Update() { var cam = Camera.main; // 内部是 FindGameObjectWithTag("MainCamera")}
// ❌ 4. 未缓存的 Transform 访问void Update() { transform.position += Vector3.forward; // transform 是 C# 属性——每次访问跨 C++/C# 边界 // 连续访问 10 次 = 10 次跨边界调用}// ✅ 缓存一切public class PlayerOptimized : MonoBehaviour { private Rigidbody rb; private Animator animator; private Camera mainCam; private Transform cachedTransform;
void Awake() { rb = GetComponent<Rigidbody>(); animator = GetComponent<Animator>(); mainCam = Camera.main; cachedTransform = transform; }
void Update() { // 使用缓存引用——零查找开销 cachedTransform.position += Vector3.forward * Time.deltaTime; }}瓶颈三:过度频繁的 Update
// ❌ 不需要每帧运行的逻辑也放 Update 里void Update() { CheckQuestCompletion(); // 任务完成状态——每秒检查一次足够 UpdateMinimap(); // 小地图——每秒 2 次足够 RefreshFriendList(); // 好友列表——10 秒一次足够}
// ✅ 用定时器降频——协程或计时器[SerializeField] private float questCheckInterval = 1f;private float questCheckTimer;
void Update() { questCheckTimer += Time.deltaTime; if (questCheckTimer >= questCheckInterval) { CheckQuestCompletion(); questCheckTimer = 0f; }}
// 或者用协程IEnumerator SlowUpdate() { var wait = new WaitForSeconds(1f); while (true) { CheckQuestCompletion(); yield return wait; }}5.3 GPU 优化
DrawCall 优化的四板斧
这是游戏性能优化中最重要的话题。四种合批方式,各有适用场景:
// ============ 1. Static Batching(静态合批)============// 适用:标记为 Static 的、永远不会动的物体// 原理:构建时把多个静态物体的 Mesh 合并为一个超大的 Mesh// 运行时一次 DrawCall 渲染全部// 条件:物体标记为 Batching Static//// 优点:运行时零开销(合并在构建时完成)// 缺点:合并后的 Mesh 占用额外内存(每个物体一份位置数据)// 运行时不能移动物体
// ============ 2. Dynamic Batching(动态合批)============// 适用:小 Mesh(顶点数 < 300)、同材质的小物体// 原理:运行时每帧尝试合批——CPU 端合并顶点// 条件:同材质 + 顶点属性一致 + 顶点数 < 900(Unity 的限制)//// 优点:不需要标记 Static,物体可以移动// 缺点:每帧 CPU 端合并顶点 → CPU 开销// 顶点限制严苛(大多数 3D 模型不符合条件)
// ============ 3. GPU Instancing(GPU 实例化)============// 适用:大量相同 Mesh 的物体(树、石头、子弹)// 原理:把"渲染 N 个相同 Mesh"的指令一次发给 GPU// 每个实例可以有不同位置/颜色/缩放(通过 Instance Buffer)// 条件:同 Mesh + 同 Material(Shader 需支持 Instancing)//// 优点:CPU 开销极低(一次 DrawCall 渲染 N 个实例)// 物体可以有不同的 Transform// 缺点:每个实例不能有不同的 Material Property Block(有限支持)
// ============ 4. SRP Batcher(可编程渲染管线合批)============// 适用:URP/HDRP 项目,同 Shader 但不同材质的物体// 原理:不是合并 Mesh,而是**复用 Shader 的常量缓冲区**// 同 Shader → 材质参数存在 GPU 缓存 → 切换材质零开销// 条件:URP 或 HDRP + Shader 兼容 SRP Batcher//// 优点:比传统 DrawCall 合批灵活得多// 不同材质但同 Shader 也可以高效渲染// 缺点:仅 URP/HDRP——Built-in RP 不支持
// ============ 选型指南 ============//// Built-in RP(旧项目):// 静态物体 → Static Batching// 小动态物体 → Dynamic Batching// 大量同类物体 → GPU Instancing//// URP / HDRP(新项目):// 优先 SRP Batcher(适用性最好)// 大量同 Mesh 物体 → GPU Instancing(与 SRP Batcher 互补)实战:从 93 个 DrawCall 到 4 个
// 场景:一个战斗场景,包含——// 20 棵树(同 Mesh,不同位置/缩放)// 15 块石头(静态)// 地编(多个静态 Mesh 拼接)// 3 个角色 + 武器特效(动态)// UI(独立 Canvas)
// ============ 优化前(93 DrawCall)============// 树:20(每棵一个 DrawCall——没开 Instancing)// 石头:15(没标记 Static——没合批)// 地编:25(多个 Mesh 拼接——没标记 Static)// 角色 + 武器:3// UI:15(全在一个 Canvas)// 天空盒 + 后处理 + 阴影:15
// ============ 优化后(4 DrawCall + 15 UI DrawCall)============//// 步骤 1:开启 GPU Instancing// → 树的 Material 勾选 Enable GPU Instancing// → 20 棵树 → 1 个 DrawCall//// 步骤 2:标记 Static Batching// → 石头 + 地编标记 Static// → 15 + 25 = 40 个物体 → 1 个合并后的 DrawCall//// 步骤 3:UI Canvas 拆分// → 静态 UI(背景/边框)→ Canvas_Static// → 动态 UI(血条/计时器)→ Canvas_Dynamic// → 合批条件改善 + 独立 Canvas 减少重建范围//// 步骤 4:SRP Batcher(如果切到 URP)// → 剩下的动态物体(角色/武器)——同 Shader 自动合批
// 最终:4 个主场景 DrawCall + 合理的 UI DrawCallOverdraw 优化
Overdraw = 同一个像素被多次绘制屏幕上一个像素先画了天空 → 再画地形 → 再画草 → 再画特效 → 最终颜色
正常的 Overdraw 是不可避免的(景深关系)但过度的 Overdraw 是性能杀手: - 多层 UI 叠加(Panel 套 Panel 套 Panel 套 Button) - 大量半透明粒子叠加 - 实心 UI 元素设置了不必要的透明度
移动端的填充率(Fill Rate)是主要瓶颈——屏幕分辨率越来越高,Overdraw 的代价越来越大// Unity 中检查 Overdraw// Scene 视图 → Shading Mode → Overdraw// 越亮 = Overdraw 越严重 = 越需要优化
// 常见修复:// 1. UI:把不透明的 Image 的 alpha = 0 改 alpha = 1(避免走透明渲染管线)// 2. 粒子:减少粒子数量 + 缩小粒子大小// 3. 地形:合理的遮挡剔除减少被挡住的 draw5.4 内存优化
内存的三种分配方式
Stack(栈): 分配:函数调用时自动分配,返回时自动释放 大小:通常 1-2 MB 特点:极快,无 GC,无碎片 内容:值类型(int、float、struct)、局部变量引用
Heap - Managed(托管堆): 分配:用 new 关键字,由 GC 自动回收 特点:方便但有 GC 开销 内容:所有 class 实例、string、数组、List<T>
Heap - Native(原生堆): 分配:通过 Allocator 手动分配,必须手动释放 特点:无 GC,适合大量数据(纹理、Mesh、音频) 内容:Texture2D、Mesh、AudioClip 的底层数据GC 是怎么工作的
GC 的简化流程:
1. 托管堆上分配内存2. 堆用完了 → GC 启动3. GC 标记所有"活着的对象"(从 Root Reference 出发遍历)4. GC 清理"死掉的对象"5. GC 压缩内存(移动存活对象,消除碎片) → 这一步可能暂停程序几十到上百毫秒! → 这就是游戏突然卡一下的常见原因
关键数字:- 零 GC Alloc = 零 GC 触发 = 零卡顿- 每帧 GC Alloc < 1KB 可以接受- GC Alloc > 10KB/帧 → 一定会有 GC 导致的卡顿消除 GC Alloc
// ============ 字符串操作 ============// ❌ 每帧分配新 stringvoid Update() { string msg = "Score: " + score + " / " + maxScore; // 分配 scoreText.text = msg;}
// ✅ 只在值变化时更新 + SetText 避免拼接private int lastScore = -1;void Update() { if (score != lastScore) { scoreText.SetText("Score: {0} / {1}", score, maxScore); lastScore = score; }}
// ============ LINQ ============// ❌ LINQ 内部有迭代器分配var alive = enemies.Where(e => e.health > 0).OrderBy(e => e.distance).ToList();// 分配:Where 迭代器 + OrderBy 内部结构 + ToList
// ✅ 手写循环——零分配List<Enemy> aliveList = GetCachedList(); // 复用 ListaliveList.Clear();for (int i = 0; i < enemies.Count; i++) { if (enemies[i].health > 0) { aliveList.Add(enemies[i]); }}aliveList.Sort((a, b) => a.distance.CompareTo(b.distance));
// ============ 装箱 ============// ❌ struct 转 object → 装箱(托管堆分配)int count = 42;object boxed = count; // 装箱!~24 bytes 分配
// ❌ 常见装箱场景Debug.Log("Count: " + count); // + 操作符对 int 用 object → 装箱string.Format("{0}", count); // object 参数 → 装箱
// ✅ 避免装箱Debug.Log($"Count: {count}"); // 字符串插值——无装箱
// ============ 容器扩容 ============// ❌ new List<T>() 默认 capacity = 0// 第一次 Add 扩容到 4 → 第 5 个扩容到 8 → 第 9 个扩容到 16 → ...// 每次扩容 = new T[newSize] + 复制旧数组 → GC Alloc
// ✅ 预分配容量List<Bullet> bullets = new List<Bullet>(500); // 提前知道最大值资源内存管理
// ============ 纹理压缩 ============// PC:DXT1/DXT5(BC1/BC3)// Android:ASTC 或 ETC2// iOS:ASTC 或 PVRTC//// 未压缩 2048×2048 RGBA = 2048 × 2048 × 4 = 16MB// ASTC 4×4 压缩后 ≈ 2048 × 2048 × 1 = 4MB// 节省 75%
// Unity 设置:Texture Import Settings → Format → 选择合适的压缩格式
// ============ 音频压缩 ============// PCM(未压缩):44100 × 16bit × 2ch = 176KB/s// Vorbis(压缩):质量 70% ≈ 50KB/s// 节省 70%
// ============ Resources 文件夹的问题 ============// ❌ Resources 下的所有资源——不管用不用——都打进包// 还都在游戏启动时索引(增加启动时间)//// ✅ 用 Addressables 或 AssetBundle 替代 Resources// 按需加载、按需释放
// ============ 场景卸载后内存不释放 ============// ❌ LoadScene 切换场景,旧场景的资源还在内存里// ✅ 调用 Resources.UnloadUnusedAssets()——但注意这会触发 GC
IEnumerator UnloadPreviousScene() { AsyncOperation op = SceneManager.UnloadSceneAsync(previousScene); yield return op;
// 清理未使用的资源 AsyncOperation unloadOp = Resources.UnloadUnusedAssets(); yield return unloadOp; // 注意:UnloadUnusedAssets 本身有开销,不要每帧调用}5.5 🎮 完整实战:从卡顿到流畅
初始场景
一个战斗 Demo: - 30 个敌人 + 1 个玩家 - 粒子特效(攻击火花、受击血迹) - UI 血条(每个敌人头顶一个 + 玩家 HUD) - 地形 + 装饰物
Profiler 数据(优化前): - FPS: 25-35(Target: 60) - CPU: 22ms(预算 16.67ms) - Scripts: 14ms(Update 占了 11ms) - Rendering: 6ms(93 DrawCall) - GC Alloc: 45KB/帧 - 内存: 780MB第一轮优化:CPU
步骤 1:打开 Profiler,展开 CPU Usage → Scripts
发现: EnemyUpdate: 6.2ms(30 个敌人 × ~0.2ms) HealthBarUpdate: 3.1ms(30 个血条,每帧 GetComponent<Slider>() + 字符串拼接) UIManager.Update: 1.2ms(每帧 FindObjectOfType)
修复: 1. Enemy.cs——缓存 Transform、Animator、Rigidbody 引用 EnemyUpdate: 6.2ms → 3.5ms
2. HealthBar.cs——Awake 缓存 Slider 引用,用 SetText 替代字符串拼接 HealthBarUpdate: 3.1ms → 0.8ms
3. UIManager.cs——Awake 时缓存引用,不再每帧 Find UIManager.Update: 1.2ms → 0.1ms
第一轮后:CPU 22ms → 14ms,FPS 35 → 55第二轮优化:GC Alloc
步骤 2:打开 Profiler → Memory → GC Alloc
发现: 每帧分配 45KB: - 字符串拼接: 15KB - LINQ: 12KB - foreach 装箱: 8KB - 其它: 10KB
修复: 1. 所有 Update 中的字符串拼接 → SetText / StringBuilder 2. 替换 LINQ 为手写循环 3. 用泛型 List<T> 替代 ArrayList
第二轮后:GC Alloc 45KB → 2KB/帧,消除了 GC 卡顿第三轮优化:GPU
步骤 3:打开 Frame Debugger,逐 DrawCall 分析
发现: 93 DrawCall: - 30 个敌人(30 DrawCall)——每个敌人的 Mesh 不同,没法 Instancing - 15 棵树(15 DrawCall)——没开 Instancing - 20 块石头(20 DrawCall)——没标 Static - 15 个头顶血条(15 DrawCall)——没合批 - 13 个其它(天空盒、阴影、后处理)
修复: 1. 树 → Material 勾选 Enable GPU Instancing 15 DrawCall → 1 DrawCall
2. 石头 → 标记 Static Batching 20 DrawCall → 1 DrawCall(合并后)
3. 头顶血条 → 独立 Canvas + 图集 + Canvas 拆分 15 DrawCall → 3 DrawCall
4. 敌人 → 不需要每帧 Instantiate 特效 → 用粒子对象池
第三轮后:93 DrawCall → 36 DrawCall优化前后对比
优化前 第一轮后 第二轮后 第三轮后FPS 25-35 50-55 55-58 60 稳定CPU (Scripts) 14ms 9ms 8ms 7msCPU (Rendering) 6ms 6ms 5ms 3msGC Alloc/frame 45KB 18KB 2KB 1.5KBDrawCall 93 93 85 36内存 780MB 780MB 780MB 740MB
总收益:FPS 翻倍,CPU 降低 41%,DrawCall 减少 61%,GC Alloc 减少 97%核心修复不超过 15 行代码。5.6 优化清单——上线前必查
CPU 优化:□ Profiler CPU Usage 无 > 1ms 的单帧尖刺□ 所有 Update 中的 GetComponent / FindObjectOfType 已缓存□ 无逐帧 GetComponent<T>()□ Camera.main 已缓存(内部是 FindGameObjectWithTag)□ 高频逻辑已降频(用计时器替代每帧执行)□ 输入检测在 Update,物理操作在 FixedUpdate
内存优化:□ GC Alloc 每帧 < 2KB□ 无 Update 中的字符串拼接(用 SetText/StringBuilder)□ 无 Update 中的 LINQ□ 容器已预分配容量(new List<T>(capacity))□ Resources 文件夹无冗余资源(或已迁移到 Addressables)□ 纹理已压缩(移动端 ASTC/ETC2,PC DXT/BC)□ 音频已压缩(Vorbis/MP3)
GPU 优化:□ 静态物体已标记 Static Batching□ 大量同 Mesh 物体已开启 GPU Instancing□ URP/HDRP 项目已启用 SRP Batcher□ UI Canvas 已按更新频率拆分(静态/动态/ScrollRect 分开)□ UI 使用了 Sprite Atlas□ LOD Group 已配置□ Occlusion Culling 已烘焙□ DrawCall < 200(移动端 < 100)
通用:□ 在所有目标设备上 Profiler 过(不只是 Editor)□ 无 Destroy 后未置空的引用□ 场景切换后无内存泄漏(Memory Profiler 确认)□ 长时间运行无持续内存增长(挂机 30 分钟测试)5.7 引擎系列终章回顾
五章,从引擎的心跳到优化清单:
Ch1 游戏循环 → 引擎的心跳——理解每一帧发生了什么Ch2 场景管理 → 世界的组织——空间划分决定了查询效率Ch3 UI 系统 → 界面的性能——Canvas 重建和 DrawCall 合批是最容易忽视的瓶颈Ch4 游戏 AI → 决策与导航——FSM→行为树→GOAP + A*→NavMeshCh5 性能优化 → 系统方法论——Profile → 定位瓶颈 → 修复 → 验证这个系列和设计模式系列的关系:
设计模式系列:教你"怎么写"——代码架构和组织方式引擎基础系列:教你"怎么跑"——引擎的运作原理和性能取舍
两者叠加: 引擎基础告诉你"Canvas 脏了就重建"(原理) 设计模式告诉你"用 MVVM + 观察者让数据驱动 UI 更新"(方案)
引擎基础告诉你"FixedUpdate 固定步长"(原理) 设计模式告诉你"命令模式 + 帧同步做确定性回放"(方案)全系列与 JD 的最终映射:
JD 任职要求 覆盖系列━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━数据结构/算法 数据结构 + 算法专题 ✅操作系统 OS 笔记 ✅计算机网络 网络笔记 ✅语言原理与底层细节 C++ 深入 ✅系统性编程思维 + 可扩展代码实现 设计模式 ✅AI / 3C / 战斗逻辑 / UI / 场景管理 引擎基础 ✅网络同步 / 内存优化 / 渲染效率 网络 + 引擎基础 ✅📖 全系列完结。每一章的目标都是一样的:让你在面试中不仅答得出”是什么”,还能答出”为什么这样设计”和”我踩过这个坑”。
📖 本系列全部文章均采用 CC BY-NC-SA 4.0 协议发布。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
第五章 性能优化总论:从 Profiler 到上线
https://firefly-7a0.pages.dev/posts/game_engine/05_performance_optimization/ 相关文章 智能推荐
1
游戏引擎与客户端基础:从游戏循环到性能优化
游戏引擎笔记 **游戏客户端开发 · 引擎基础全景导航。** 5 章覆盖游戏循环与时间管理、场景管理与空间划分、UI 系统设计、游戏 AI 基础与性能优化总论——以 Unity 为载体,从原理图解到工业实现,从性能分析到常见坑。
2
第三章 UI 系统设计:从 Canvas 到 DrawCall
游戏引擎笔记 **游戏引擎基础 · UI 系统设计。** 从 Canvas 的 Mesh 生成与合批原理到 DrawCall 优化策略,从事件系统的射线检测到底层坐标转换,从 Canvas 拆分原则到 ScrollRect 对象池实现——理解 UI 性能的本质,避开最常见的性能陷阱。
3
第四章 游戏 AI 基础:从 FSM 到行为树到 NavMesh
游戏引擎笔记 **游戏引擎基础 · 游戏 AI。** 从 FSM 的局限性出发,引出行为树的设计哲学与完整框架实现,对比 GOAP 的适用场景;从 A* 算法的工程版实现到 Unity NavMesh 系统的深入使用——覆盖巡逻/追击/攻击的完整 AI 行为。
4
第一章 游戏循环与时间管理
游戏引擎笔记 **游戏引擎基础 · 游戏循环与时间管理。** 从游戏循环的三种模型到 Unity 主循环的完整帧分解,从 MonoBehaviour 生命周期全景到 deltaTime 的精确语义,从帧率无关的跳跃实现到确定性回放系统——理解游戏引擎的心跳。
5
第二章 场景管理与空间划分
游戏引擎笔记 **游戏引擎基础 · 场景管理与空间划分。** 从 Transform 层级的矩阵传递到底层空间数据结构(四叉树/八叉树/BSP/空间哈希),从视锥剔除到遮挡剔除,从 Additive 场景流式加载到 LOD 系统——理解游戏世界如何被高效组织与检索。
随机文章 随机推荐