Unity主程之路
Unity性能优化
三个阶段:预言阶段,开发阶段,测试阶段:
| 优化阶段 | 核心目标 | 关键优化方向 |
|---|---|---|
| 预言阶段(规划与设计) | 确立性能标准,规避设计层面的性能隐患 | 制定性能预算、规划场景分割、确定资源规范 |
| 开发阶段(实施与集成) | 高效实现并集成资源,确保运行流畅 | 模型与资源优化、渲染优化、内存管理 |
| 测试阶段(分析与调优) | 定位并修复具体性能瓶颈 | 使用Profiler等工具分析,针对性优化 |
预言阶段:规划与设计
这个阶段的核心是 “前置规划” ,在代码编写和资源制作之前就建立起性能意识,从源头上避免许多问题。
- 制定性能预算(Performance Budget):为关键指标设定明确上限,例如Draw Calls数量、每帧内存分配(GC Alloc)、纹理内存占用、目标帧率等。这为团队提供了清晰的量化标准。
- 场景与关卡设计规划:
- 场景分块(Scene Splitting):对于大型开放世界,提前将大地图划分为多个区块,并设计流式加载(Streaming) 方案,实现玩家在场景中的无缝漫游,避免一次性加载所有资源。
- 视线阻挡设计:在关卡中合理设计墙壁、山体等障碍物,自然地将场景分割,减少同屏可见的物体数量。
- 资源规范制定:与美术团队共同确立资源制作标准,包括模型的最大面数、纹理的分辨率和压缩格式、动画骨骼数量等。例如,明确要求移动端角色模型面数不超过3万面,主要纹理使用ASTC 4x4压缩等。
🛠️ 开发阶段:实施与集成
这是优化工作的主战场,需要将规划落地,并在集成过程中持续监控。
场景模型优化
- 模型精简:删除模型上不可见的内部面或背面。使用尽可能少的顶点和三角形来表达模型。
- LOD(Level of Detail):为重要的3D模型(如建筑、角色)配置LOD Group组件,根据摄像机距离自动切换高、中、低精度模型,显著降低远景渲染压力。
- 合并网格(Mesh Combining):将场景中静态的、小范围的、使用相同材质的物件(如一堆石头)的网格合并成一个,可以大幅减少Draw Calls。可使用
Mesh.CombineMeshes方法或相关插件实现。
渲染优化
- 降低Draw Calls:
- 静态批处理(Static Batching):将不会移动的物体标记为 “Static”,Unity会自动将它们合并批次。
- GPU Instancing:对于大量重复的物体(如花草、树木),启用GPU Instancing,能极大提升渲染效率。
- 使用纹理图集(Texture Atlas):将多个小纹理合并到一张大纹理中,使得多个物体可以共享同一个材质,这是合批的前提。
- 光照与阴影优化:
- 光照烘焙(Lightmap Baking):对静态场景和光源使用光照烘焙,将光影效果提前计算并存入贴图,运行时几乎无性能消耗。
- 减少实时光源和实时阴影:实时光照,尤其是像素光(Pixel Lights)和实时阴影开销极大,应严格控制数量和使用范围。
- 遮挡剔除(Occlusion Culling):对复杂的室内场景或存在大量遮挡物的场景,烘焙遮挡剔除数据,避免渲染被完全挡住的物体。
内存与资源管理
- 使用对象池(Object Pooling):对频繁创建和销毁的对象(如子弹、特效),使用对象池进行复用,避免频繁实例化产生的GC(垃圾回收)卡顿。
- 动态资源加载:使用 Addressables 或 AssetBundle 系统按需加载和卸载资源,避免场景开始时内存占用过高。
- 纹理与音频压缩:根据目标平台(Android/iOS)选择合适的纹理压缩格式(如ETC2, ASTC),对音频文件进行压缩(如OGG, MP3)。
🔍 测试阶段:分析与调优
此阶段依赖于工具,精准定位瓶颈并进行最终打磨。
- 核心工具:Unity Profiler:这是最重要的性能分析工具。你需要重点关注:
- CPU Usage:查看主线程哪些函数最耗时(如脚本、物理、渲染线程等待)。
- GPU Usage:分析顶点处理、片元着色、渲染通道等的耗时。
- Memory:查看纹理、网格、音频等资源的内存占用,警惕内存泄漏。
- Frame Debugger:此工具可以逐帧分解所有的渲染命令(Draw Calls),让你清晰地看到每一帧到底画了什么,以及为什么没有合批,是解决渲染问题的利器。
- Memory Profiler:通过对比不同时间点的内存快照,可以精准定位哪些资源没有被正确卸载,从而解决内存泄漏问题。
什么是DrawCall与SetPassCall?
DrawCall是CPU向GPU发出的单次绘制命令,用于渲染一个网格(Mesh),而SetPassCall是GPU在绘制前切换渲染状态(主要是材质和Shader)的指令SetPassCall的开销通常远大于DrawCall,因为每次状态切换都涉及复杂的GPU设置。因此,优化时不仅要减少DrawCall的数量,更要重点关注如何减少材质切换带来的SetPassCall
场景模型优化的N种方法
场景优化的核心目标:稳定帧率与降低资源占用。优化的本质是平衡视觉效果与性能开销,确保游戏在各种目标设备上都能流畅运行
模型与资源优化
这是优化工作的基础,直接影响内存和渲染效率。
- 模型减面与LOD:对模型进行减面处理,移除对造型没有影响的面(如物件背面、内部不可见的面)。为建筑、复杂物件或地形配置LOD (Level of Detail) 系统,根据摄像机距离切换不同精度的模型,可显著降低同屏面数
- 合并与批处理:将小范围内非交互的静态小物件(如一组石头、花草)合并成一个模型,并合并其贴图,能有效减少Draw Call数量。同时,利用Unity的静态合批 (Static Batching) 或GPU Instancing技术对重复使用的模型进行批量渲染,能大幅提升渲染效率
| 批处理 (Batching) | 将相同材质的物体合并渲染 | 通用 | 减少Draw Call的核心手段 |
|---|---|---|---|
| ↳ 静态批处理 | 将静态(Static)物体在运行前合并网格 |
场景中不动的物体(建筑、地形) | 大幅提升性能,但会增加内存占用 |
| ↳ 动态批处理 | Unity运行时自动合并小型、移动的物体 | 顶点数很少的移动物体(如方块、精灵) | 限制极多,效果有限 |
| GPU Instancing | 1个DrawCall绘制多个完全相同的网格/材质 | 大量重复物体(树木、草、石头) | 性能极高,比静态批处理更省内存 |
| 图集 (Atlas) | 将多个小纹理打包到一张大纹理中 | UI、2D精灵、GUI | 使用同一张纹理=使用同一材质=可以合批 |
| 合并材质 | 让多个物体共享同一个材质实例 | 所有场景 |
- 贴图优化:贴图尺寸应控制在合理范围,移动端建议以1024或512为主。采用合适的压缩格式(如Android用ETC2,iOS用PVRTC)。非必要情况下关闭贴图的
Read/Write Enabled选项以节省内存。考虑使用Substance等材质系统来压缩贴图数据
**静态合批能优化性能是因为Drawcall数量减少(x),静态合批优化是因为不需要再次执行顶点变换操作,SubMesh共享材质,Drawcall调用之间并没有渲染状态(SetPassCall)的切换,缺点会导致包体增大,运行内存占用增大 **
| 对比项 | 常见误区:归因于DrawCall减少 | 正确原理:归因于渲染状态优化 |
|---|---|---|
| 核心观点 | 静态合批通过减少DrawCall的数量来优化性能。 | 静态合批通过优化DrawCall之间的渲染状态切换来提升效率,DrawCall数量本身未必减少。 |
| 工作原理 | 将多个小网格合并成一个大网格,从而只需一次DrawCall。 | 1. 预变换顶点:在合批时(构建或加载时)将所有顶点的坐标从模型空间转换到世界空间。运行时,CPU无需再为每个物体执行顶点变换操作。 2. 共享材质状态:合并到同一批次的子网格(SubMesh)共享相同的材质和渲染状态。 3. 减少SetPass调用:由于渲染状态一致,在连续提交这些SubMesh的DrawCall时,GPU无需在调用间进行昂贵的渲染状态切换(主要是SetPass Call)。 |
| 为何更优 | 这种解释过于简化,且在某些情况下与事实不符。 | 更准确地揭示了性能瓶颈所在:CPU准备渲染命令的效率和GPU渲染状态的切换开销,而非仅仅是DrawCall的绝对数量。即使DrawCall数量未变,消除了状态切换也能极大提升CPU到GPU的通信效率。 |
字符串0GC的实现
Profiler调试信息
| 指标类别 | 关键指标 | 优化意义 |
|---|---|---|
| CPU | GC.Alloc(垃圾回收分配) |
脚本优化核心:数值过高意味着频繁产生内存垃圾,会导致GC卡顿。目标是尽量减少或消除。 |
MonoBehaviour.Update等生命周期函数耗时 |
定位具体是哪个脚本的逻辑消耗了大量CPU时间。 | |
Physics.Simulate(物理模拟) |
物理计算开销过大时,需简化碰撞体、减少刚体数量。 | |
| 渲染 (Rendering) | Batches/ Draw Calls(批次数/绘制调用) |
合并渲染指令:数值越高,CPU准备渲染数据的压力越大。可通过静态/动态合批、GPU Instancing降低。 |
SetPass Calls(设置通道调用) |
减少材质切换:代表着色器切换次数。应尽可能让使用相同材质的物体连续渲染。 | |
Tris(三角形面数) / Verts(顶点数) |
控制渲染负载:单帧处理的几何体复杂度。需控制在目标平台能承受的范围内。 | |
| 内存 (Memory) | Used Heap(已使用堆内存) / Allocated Heap(已分配堆内存) |
监控内存增长:若Used Heap持续增长且不释放,可能存在内存泄漏。Allocated Heap过大表示内存预留过多。 |
| 内存快照对比 | 定位泄漏源:通过抓取不同时间点的内存快照并对比,可精确定位未预期驻留的纹理、GameObject等。 |
如何访问与连接Profiler
- 打开窗口:在Unity编辑器中,通过顶部菜单
Window > Analysis > Profiler或快捷键Ctrl+7(Windows) /Command+7(macOS) 即可打开Profiler窗口。 - 窗口布局:Profiler窗口主要包含四个区域:
- A: 分析模块列表:如CPU、GPU、内存、渲染等。
- B: 控制工具栏:用于连接设备、开始/停止记录、帧导航等。
- C: 帧图表区域:以图表形式显示各模块随时间变化的性能数据。
- D: 模块详细信息面板:显示当前选中帧的详细数据,如调用堆栈。
- 真机调试(推荐):为了获得最准确的分析结果,避免编辑器自身开销的干扰,你应该连接真机进行测试。
- 在
Build Settings中勾选Development Build和Autoconnect Profiler。 - 将游戏安装到目标设备(手机或平板)上并运行。
- 在编辑器Profiler窗口的
Attach to Player下拉菜单中选择你的设备或手动输入其IP地址。
- 在
高级分析技巧
使用深度分析(Deep Profile)
默认情况下,Profiler只记录部分核心函数的耗时。开启深度分析后,它会记录所有C#方法调用的耗时,帮你精确定位到是哪个具体函数出了问题。此模式会显著增加内存和性能开销,可能导致游戏变慢,因此仅适合短时间分析特定问题。
添加自定义性能分析标记
可以在自己的代码中插入标记,以便在Profiler时间轴中清晰看到特定代码块的执行情况,这比深度分析的开销小得多。
1
2
3
4
5void Update () {
using (new ProfilerScope("MyCustomLogic")) {
// 这里是你需要重点分析的代码块
}
}利用内存快照对比排查泄漏
对于内存问题,Unity的Memory Profiler包非常强大。
- 操作流程:在怀疑有内存泄漏的场景,先抓取第一份内存快照;然后进行一系列操作(如打开关闭界面);最后在相同场景下抓取第二份快照。对比两份快照,查看哪些对象意外地增加了且没有被释放,从而定位泄漏源。
GC Alloc
- 触发机制:当在托管堆上分配新对象(如使用
new创建引用类型、字符串拼接等)时,如果可用内存不足,Unity 的垃圾回收器(GC)就会被触发。 - 性能影响:GC 运行时需要暂停主线程(Stop-the-World)来标记和清理不再使用的内存。如果单帧内分配的内存过多,GC 会被频繁触发,导致明显的帧率下降和卡顿。
- 性能目标:为保证游戏流畅(如维持 60 FPS),建议将单帧的 GC Alloc 控制在 30B 以下,理想情况下应力争为 0,并且单次瞬时分配最好不超过 200KB。
| GC Alloc 来源类别 | 具体例子 | 核心优化策略 |
|---|---|---|
| 对象创建 | 在Update等高频方法中new引用类型对象(如List, 类实例) |
对象池、将对象提升为成员变量或静态变量复用 |
| 装箱操作 | 将值类型(如int, struct)赋值给object类型变量 |
使用泛型约束(where T : IEquatable<T>)、避免使用非泛型集合(如ArrayList) |
| 字符串操作 | 使用 +或 $""进行字符串拼接 |
使用 StringBuilder(需预设容量)并复用 |
| 委托与Lambda | 在Lambda表达式中捕获局部变量或成员变量 | 尽量引用静态方法/变量,或重构设计避免高频创建委托 |
| LINQ 查询 | 使用Where, Select等方法会产生迭代器对象分配 |
在性能关键代码中避免使用LINQ,用手动循环代替 |
| 数组/集合操作 | 创建数组、集合扩容 | 使用结构体数组(struct[])、Stackalloc或Span<T> |
| 异步编程 | 使用async/await会产生状态机分配 |
对已完成的异步操作,使用 ValueTask 代替 Task |
🔧 核心优化策略详解
表格中的策略是优化的核心方向,以下是几点关键的深入说明:
- 对象池的意义:对于需要频繁创建和销毁的对象(如子弹、敌人),对象池通过预先创建一批对象并在需要时取出、用完时放回,彻底避免了运行时频繁的内存分配与垃圾回收。
- 结构体(struct)的权衡:结构体通常在栈上分配,能避免GC压力。但需注意,大型结构体在作为方法参数传递时会发生复制,其性能开销可能反而超过使用类。此时,可通过
ref关键字进行引用传递来优化。 - foreach循环的误区:对于
List<T>,foreach循环会生成枚举器,带来微小的分配。在极致优化场景下,使用for循环并在循环外缓存Count是更高效的选择。但值得注意的是,遍历数组时,foreach会被优化为与for循环近似的模式,效率很高。
🔍 使用Profiler定位问题
盲目优化效率低下,首先需要准确定位分配源头。
- Unity Profiler:在Unity中,Profiler是分析性能的利器。打开
Window > Analysis > Profiler,重点关注CPU模块。在层级视图中,你可以清晰看到每一帧中每个函数调用所产生的GC Alloc具体数值,从而快速定位分配大户。 - 深度分析模式:对于复杂情况,可以开启Profiler的
Deep Profile模式,它能追踪到每一行代码的分配和耗时,虽然会带来较大性能开销,但非常适合定位疑难杂症。
UI控件优化
| 特性 | Draw Call (绘制调用) | SetPass Call (渲染通道设置调用) |
|---|---|---|
| 本质 | CPU命令GPU绘制一个特定网格(Mesh) | GPU在绘制前切换渲染状态(如着色器、纹理等)的指令 |
| 主要开销 | CPU准备和提交绘制命令的数据(如顶点、矩阵) | CPU设置GPU的渲染状态(材质/着色器属性) |
| 优化关键 | 减少调用次数,即批处理(Batching) | 减少状态切换次数,即材质合并与渲染排序 |
| 关系 | 一次Draw Call之前通常至少需要一次SetPassCall来设置状态 | SetPassCall的次数是衡量材质切换频繁程度的关键指标 |
- Draw Call:你可以将其理解为一次“绘制请求”。每当Unity需要将场景中的一个网格(比如一个角色、一块石头)画到屏幕上时,CPU就需要向GPU发送一次Draw Call指令。这个指令包含了“画什么”(网格数据)的信息。
- SetPass Call:这个调用可以理解为“告诉GPU怎么画”。在真正绘制之前,GPU需要知道使用什么颜色、什么纹理、是否有透明效果等。这些规则由材质(Material)和着色器(Shader)定义,设置这些规则的过程就是一次SetPassCall。如果两个物体使用完全相同的材质,那么渲染第二个物体时可能不需要新的SetPassCall;但如果材质不同,就必须进行一次新的SetpassCall来切换状态。
它们的核心关系是:SetPassCall的开销通常远大于一次单纯的Draw Call。因为切换渲染状态(尤其是复杂的着色器)是非常耗时的操作。因此,优化的首要目标往往是减少SetPassCall的次数。
UI性能优化要点
谨慎使用 GraphicsRaycaster:对于不需要使用点击事件的物体不要挂GraphicsRaycaster。
GraphicsRaycaster是Unity事件系统的一部分,用于检测UI上的点击、悬停等操作。它会遍历其下的所有UI元素,判断输入事件是否发生在它们身上。- 如果一个复杂的UI界面(如包含大量元素的整屏背景)不需要交互,却挂载了此组件,那么每一次点击事件都会对它进行不必要的检测计算,浪费CPU资源。
- 只为真正需要交互的UI元素(如按钮、滑块)所在的顶层Canvas挂载
GraphicsRaycaster。对于纯背景或静态显示面板,应移除该组件。
避免滥用自动布局组件:少用 LayoutGroup(布局组,如HorizontalLayoutGroup)或 Content Size Fitter(内容尺寸适配器)
- 消耗大量计算时间:当UI层级、尺寸或内容发生变化时(即“用户操作→重绘”),自动布局系统需要递归地遍历所有被标记为“脏”(
SetDirty)的布局元素,重新计算它们的位置和大小。对于动态内容频繁变化的UI(如滚动列表),这会带来巨大的CPU开销,导致卡顿。 - Layout原理:遍历SetDirty对象会消耗性能:这正是问题的根源。任何变化都会引发一轮布局重建(Rebuild)过程
- 对于性能要求高的动态UI(如游戏中的虚拟摇杆、大型背包/商店列表),放弃自动布局,转而使用脚本控制。在脚本中(例如在
Start方法或数据更新时)通过代码直接计算并设置每个UI元素的rectTransform.anchoredPosition和rectTransform.sizeDelta。虽然增加了代码量,但性能远超自动布局,因为你可以精确控制何时计算,避免了每帧的递归遍历开销
帧同步和状态同步
问题1:状态同步和帧同步分别发送什么数据到服务器?
问题2:对于帧同步和状态同步,服务器要做什么运算?
问题3:服务器会给客户端发送什么数据包,该数据包多大?
| 特性维度 | 帧同步 (Frame Synchronization) | 状态同步 (State Synchronization) |
|---|---|---|
| 客户端发送至服务器的数据 | 玩家的操作指令(如移动、攻击、释放技能等)及当前逻辑帧索引。 | 玩家的操作请求(如“移动到某坐标”、“使用某技能”)。 |
| 服务器核心运算 | 1. 收集与转发:按逻辑帧收集所有客户端的操作指令,并广播给所有客户端。 2. 抗延迟处理:可能计算指令生效的目标帧(乐观帧锁定)。 不执行核心游戏逻辑计算。 | 1. 权威计算:作为游戏世界的唯一权威,执行所有核心游戏逻辑(如技能判定、伤害计算、物理交互等)。 2. 验证与广播:验证客户端操作的合法性,然后计算游戏行为的结果,并将状态变化(如新的位置、血量)广播给客户端。 |
| 服务器发送至客户端的数据包 | 数据内容:所有客户端的操作指令集合。 包大小:通常较小。例如,有分析指出《王者荣耀》每秒15帧,假设每个玩家操作指令为16字节,10名玩家一帧的数据量约为160字节。 | 数据内容:游戏状态的变化结果(如角色新坐标、血量变化、技能生效结果等)。可采用增量同步方式,只发送发生变化的状态。 包大小:通常比帧同步大,具体大小取决于同步对象的数量和状态复杂性。 |
| 特性维度 | 帧同步 (Frame Synchronization) | 状态同步 (State Synchronization) |
|---|---|---|
| 核心原理 | 同步操作指令。所有客户端在相同逻辑帧执行相同的输入,通过确定性计算得出相同结果。 | 同步游戏状态。服务器作为权威方计算全局状态,将结果(快照)广播给客户端。 |
| 逻辑位置 | 主要在客户端 | 主要在服务器 |
| 设计目标 | 绝对的确定性和一致性,保证公平竞技。 | 最终一致性,优先保证流畅体验和服务器权威。 |
| 数据流量 | 较低,通常只传输经过压缩的玩家操作指令。 | 较高,需要传输大量状态变化数据,但可通过增量同步、压缩等技术优化。 |
| 网络延迟敏感性 | 高。任何客户端的延迟或丢包都可能阻塞整个游戏进程。 | 相对较低。可通过客户端预测和插值技术掩盖网络延迟,提升流畅度。 |
| 反外挂能力 | 较弱。因核心逻辑在客户端,易被修改(如作弊器)。 | 较强。关键逻辑和数值校验在服务器,客户端难以直接篡改。 |
| 典型应用 | MOBA(《王者荣耀》)、RTS(《星际争霸2》)等强调操作公平和精确判定的游戏。 | MMORPG(《魔兽世界》)、FPS(《绝地求生》)、大型开放世界游戏。 |
💡 如何选择同步方案
- 选择帧同步的情况:如果你的游戏是实时竞技类(如MOBA、RTS),单位数量多,对战斗的精确性和公平性有极高要求,且能接受因网络问题导致的集体卡顿风险,帧同步是经典选择。
- 选择状态同步的情况:如果你的游戏是大型多人在线角色扮演游戏(MMORPG)或第一人称射击游戏(FPS),世界复杂,安全性是首要考虑,需要支持玩家随时加入/退出,那么状态同步更适合。
- 混合同步方案:一些游戏会采用混合方案。例如,在关键战斗逻辑上使用帧同步保证确定性,而在非关键的状态(如环境特效、NPC闲聊)上使用状态同步,以兼顾核心体验和服务器压力。
关键帧,混合同步
服务器不会在每一帧都同步状态,而是周期性地设定一个关键帧。在关键帧时刻,服务器会收集所有玩家在该关键帧的操作指令,计算出一段确定性的未来(直到下一个关键帧),并将这个“未来剧本”打包发给所有客户端。客户端在关键帧之间,则按照这个“剧本”独立运行,从而保证所有客户端表现一致。

客户端逻辑(按帧执行)
客户端的逻辑是一个循环过程,如上图所示。它不断判断当前帧是否为关键帧,并据此决定是等待服务器指令还是执行本地逻辑。
服务器逻辑(按关键帧响应)
服务器的逻辑由客户端的请求触发,如上图右侧所示。它只在关键帧时刻进行核心运算,其关键职责如下:
- 收集与等待:服务器等待,直到收集齐所有客户端在关键帧K1发来的控制数据(CTRL-K1)。
- 权威计算与广播:服务器根据收集到的所有玩家在K1帧的操作,权威性地计算出从K1到下一个关键帧K2之间,所有玩家的输入数据(图中用
l表示)。然后,它将一个UPDATE数据包广播给所有客户端。这个包包含:FrameNumber: 当前关键帧号K1。NextKeyFrame: 下一个关键帧号K2。Control1...Control4: 从当前帧K1到下一关键帧K2之间,所有玩家每一帧的输入指令列表l。
方案的优势与分类
这种方案巧妙地结合了帧同步和状态同步的优点:
- 确定性一致:所有客户端收到相同的输入序列
l,从而保证从K1到K2的过程完全一致。 - 抗延迟:即使某个客户端网络不稳定,只要它能在下一个关键帧K2之前将K1的CTRL数据送达服务器,游戏进程就不会被阻塞,因为其他客户端已经在按预定的输入序列
l运行。
图片中还提到了两种确定关键帧的方法:
- 固定法:简单可靠,如每5帧一个关键帧。
- 测算法:更灵活,服务器根据所有客户端的最大PING值动态计算下一个关键帧的位置,以适配不同的网络环境。
