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(垃圾回收)卡顿。
  • 动态资源加载:使用 AddressablesAssetBundle 系统按需加载和卸载资源,避免场景开始时内存占用过高。
  • 纹理与音频压缩:根据目标平台(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: 模块详细信息面板:显示当前选中帧的详细数据,如调用堆栈。
  • 真机调试(推荐):为了获得最准确的分析结果,避免编辑器自身开销的干扰,你应该连接真机进行测试。
    1. Build Settings中勾选 Development BuildAutoconnect Profiler
    2. 将游戏安装到目标设备(手机或平板)上并运行。
    3. 在编辑器Profiler窗口的 Attach to Player下拉菜单中选择你的设备或手动输入其IP地址。

高级分析技巧

  1. 使用深度分析(Deep Profile)

    默认情况下,Profiler只记录部分核心函数的耗时。开启深度分析后,它会记录所有C#方法调用的耗时,帮你精确定位到是哪个具体函数出了问题。此模式会显著增加内存和性能开销,可能导致游戏变慢,因此仅适合短时间分析特定问题。

  2. 添加自定义性能分析标记

    可以在自己的代码中插入标记,以便在Profiler时间轴中清晰看到特定代码块的执行情况,这比深度分析的开销小得多。

    1
    2
    3
    4
    5
    void Update () {
    using (new ProfilerScope("MyCustomLogic")) {
    // 这里是你需要重点分析的代码块
    }
    }
  3. 利用内存快照对比排查泄漏

    对于内存问题,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[])、StackallocSpan<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.anchoredPositionrectTransform.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闲聊)上使用状态同步,以兼顾核心体验和服务器压力。

关键帧,混合同步

服务器不会在每一帧都同步状态,而是周期性地设定一个关键帧。在关键帧时刻,服务器会收集所有玩家在该关键帧的操作指令,计算出一段确定性的未来(直到下一个关键帧),并将这个“未来剧本”打包发给所有客户端。客户端在关键帧之间,则按照这个“剧本”独立运行,从而保证所有客户端表现一致。

image-20251016223242538

客户端逻辑(按帧执行)

客户端的逻辑是一个循环过程,如上图所示。它不断判断当前帧是否为关键帧,并据此决定是等待服务器指令还是执行本地逻辑。

服务器逻辑(按关键帧响应)

服务器的逻辑由客户端的请求触发,如上图右侧所示。它只在关键帧时刻进行核心运算,其关键职责如下:

  1. 收集与等待:服务器等待,直到收集齐所有客户端在关键帧K1发来的控制数据(CTRL-K1)。
  2. 权威计算与广播:服务器根据收集到的所有玩家在K1帧的操作,权威性地计算出从K1到下一个关键帧K2之间,所有玩家的输入数据(图中用 l表示)。然后,它将一个UPDATE数据包广播给所有客户端。这个包包含:FrameNumber: 当前关键帧号K1。NextKeyFrame: 下一个关键帧号K2。Control1...Control4: 从当前帧K1到下一关键帧K2之间,所有玩家每一帧的输入指令列表 l

方案的优势与分类

这种方案巧妙地结合了帧同步和状态同步的优点:

  • 确定性一致:所有客户端收到相同的输入序列 l,从而保证从K1到K2的过程完全一致。
  • 抗延迟:即使某个客户端网络不稳定,只要它能在下一个关键帧K2之前将K1的CTRL数据送达服务器,游戏进程就不会被阻塞,因为其他客户端已经在按预定的输入序列 l运行。

图片中还提到了两种确定关键帧的方法:

  • 固定法:简单可靠,如每5帧一个关键帧。
  • 测算法:更灵活,服务器根据所有客户端的最大PING值动态计算下一个关键帧的位置,以适配不同的网络环境。