第五章 性能优化总论:从 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 → 内存分配、泄漏、GC
Rendering → DrawCall 数量、合批统计、SetPass Call
Audio → 音频开销
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 / FindObjectOfType
void Update() {
var manager = FindObjectOfType<GameManager>(); // 遍历整个场景!
}
// ❌ 3. Camera.main
void 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 DrawCall

Overdraw 优化#

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. 地形:合理的遮挡剔除减少被挡住的 draw

5.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#

// ============ 字符串操作 ============
// ❌ 每帧分配新 string
void 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(); // 复用 List
aliveList.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 7ms
CPU (Rendering) 6ms 6ms 5ms 3ms
GC Alloc/frame 45KB 18KB 2KB 1.5KB
DrawCall 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*→NavMesh
Ch5 性能优化 → 系统方法论——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/
作者
lonelystar
发布于
2026-04-29
许可协议
CC BY-NC-SA 4.0
相关文章 智能推荐
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 系统——理解游戏世界如何被高效组织与检索。
随机文章 随机推荐

评论区

Profile Image of the Author
LonelyStar
Hello, I'm LonelyStar.
公告
欢迎来到我的博客!
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
119
分类
11
标签
346
总字数
226,548
运行时长
0
最后活动
0 天前

目录