Unity3D-性能优化最佳实践(一)分析(Profiling)

作者: Ian    翻译Kelvin Lo / 海龟

原文地址:http://gad.qq.com/article/detail/7192224


Profiling - 分析 

说到优化,不得不说所有优化的源头都是从发现问题开始,第一步是分析,根据项目技术和资源结构的分析报告结果来划出项目问题的可能范围。 
注意:本文里使用的一些追踪程序代码是基于Unity 5.3版本,在未来这些功能有可能会变动。 

注意:这个章节讨论的原生(Native)方法(Method)名称是由 Unity 5.3 的执行档撷取出来的,这些方法名称可能在未来的版本有所改变。


Tools - 工具 

关于分析,有许多能帮助 Unity 开发者分析项目的工具,而 Unity 本身有一套内建工具像是 CPU Profiler、Memory Profiler、或是 5.3 才有的 Memory Analyzer。 

但是最好的分析报告通常来自于该平台的特有工具,像是: 

l  iOS:Instruments 和 Xcode Frame Debugger 

l  Android:SnapdragonProfiler 

l  采用 Intel CPU/GPU的平台:VTune 和 Intel GPA 

l  PS4:Razorsuite 

l  Xbox:Pixtool 

这些工具大部分都能够分析IL2CPP所包出来的C++项目,这些原生程序能执行原本在Mono下无法执行的高精度计时(high-resolution method timings)和更透明的堆栈调用(callstacks)。 

要充分利用这些工具通常需要在这些平台上启用 IL2CPP 然后使用转换成 C++ 版本的项目。在原生程序代码状态下可以透过原生工具得到完整的调用堆栈还有高精度计时,这些在透过 Mono 执行的时候都无法取得。 
Unity 
已经发表过一份关于在 iOS 平台使用 Instruments 分析的教学指南,点这里


解析启动轨迹 

当查看启动时间的轨迹时,有两个关键的方法来检视,分别是从项目设定、资源和程序可能影响启动时间的可能范围。 

请注意:启动时间在不同的平台上会有差距,大多都是用启动到显示 Unity Logo(splash screen)的时间来判定。

注意 Unity 的启动时间在不同平台上可能表现会不同,大部分的平台的行为是会让用户会在静态的 Unity Logo(Splash screen)等待得时间变长。

Unity3D-性能优化最佳实践(一)分析(Profiling)

上图为 iOS 上用 Instruments 抓的启动时间追踪,可以看到 iOS 平台特有的 startUnity 方法呼叫了UnityInitApplicationGraphics 和 UnityLoadApplication 方法。

 

UnityInitApplicationGraphics 执行很多内部工作,像是图形装置的设定工作和初始化很多Unity的内部系统。不但如此,他还负责初始化 Unity 的资源系统(Resources system)。所以它需要先加载资源系统所含的所有档案索引。 

所有目录名为 Resources 里面的资源文件(在 Assets 目录下的 Resources 目录,还有 Resources 目录下的目录)都会被算在资源系统里。因此初始化资源系统所需要的时间将会随着 Resources 目录里的档案数量增加而变久。

 

UnityLoadApplication 的工作包含加载和初始化项目最开始的场景。这包含反串行化(deserializing)和实例化(Instantiating)展示第一个场景需要的数据,例如编译着色器(compiling Shaders)、上传贴图和实例化游戏对象(GameObject)等等所有为了显示初始场景所需要的资料。此外,第一个场景中所有的 MonoBehaviours 都会在这个时间点执行 Awake 回呼(Callback)。 

 

这样的调用结构代表如果你在项目的第一个场景里的某个 Awake 回调执行了非常耗时的程序,会拖慢整个项目的启动时间。要解决这样问题当然是要修改拖慢的程序代码或是移到别的地方来执行。 

 

解析执行轨迹 启动初始化之后主要是 PlayerLoop 的追踪。这是 Unity 主要周期循环,里面的程序每帧会执行一次(注一)

Unity3D-性能优化最佳实践(一)分析(Profiling)

上图是一个 Unity 5.4 项目的分析报告,显示了几个 PlayerLoop 会回调的最有趣的方法。请注意,PlayerLoop 里方法的名称可能会因不同版本的 Unity 而有所不同。 

 

PlayerRender 是执行 Unity 渲染系统的方法。也负责剔除不显示的对象、计算动态批次计算(Dynamic batches)和对GPU 送出绘图指令。所有的影像后制(Image Effects)或是基于渲染的程序回调(例如 OnWillRenderObject)也都在此处理。一般来说,当项目跟玩家互动时 CPU 大部分时间都会花在这里。 

 

BaseBehaviourManager 会呼叫三种不同版本的 CommonUpdate,它们各自会调用场景里被启动(Active)的GameObject 上的 MonoBehaviours 的特定回调。 

l  CommonUpdate<UpdateManager> 会调用 Update 回调。 

l  CommonUpdate<LateUpdateManager>会调用 LateUpdate 回调。 

l  CommonUpdate<FixedUpdateManager> 会调用 FixedUpdate(如果物理系统被触发) 


一般来说 BaseBehaviourManager::CommonUpdate<UpdateManager> 是最有意思的方法,因为它在 Unity 项目里大多数脚本的进入点。

 

还有几个有趣的方法: 

如果项目有用到 Unity UI 系统,UI::CanvasManager 会调用几个不同的回调。这行为包含了Unity UI 的批次运算和排版更新,这两个操作最常造成 CanvasManager 出现在分析报告里。 

 

DelayedCallManager::Update 执行协程(Coroutines)。在底下的 Coroutines 章节会有更详细的说明。

 

PhysicsManager::FixedUpdate 执行 PhysX 物理系统。涉及到执行 PhysX 的内部程序,并受到场景内有物理行为对象的数量影响(像是带有 Rigidbody 和各种 Collider 的物件)。然而,跟物理有关的回调也会出现在此,特别是OnTriggerStay 和 OnCollisionStay。

 

假如项目用的是2D物理(Box2D),类似的结构会出现在 Physics2DManager::FixedUpdate 之下。 

 

解析脚本方法

当使用 IL2CPP 跨平台编译时,可以找看看 ScriptingInvocation 这行包含的内容,这是从 Unity 内部原生程序进入到用户脚本的分界点。(注二)

Unity3D-性能优化最佳实践(一)分析(Profiling)

上图是一个 Unity 5.4 项目的另一个分析报告,附挂在 RuntimeInvoker_Void 底下的所有方法都是交叉编译过的 C# 脚本的一部分,每帧执行一次。 

 

这些追踪还蛮容易理解的,每一个命名规则都是「原始类别_原始方法」,例如上图范例范例能找到EventSystem.Update、PlayerShooting.Update 以及其他几个 Update 方法。这些都是在MonoBehaviours 里能找到的标准Unity Update 回调。 

 

透过展开这些方法,可以更精确的定位谁在消耗 CPU 时间。这包含了项目的脚本方法、Unity API 和 C# 函数库。 

 

上面显示出 StandaloneInputModule.Process 方法每帧会对整个 UI 进行一次 Raycasting,检查是否有任何 UI 事件被点击或滑过的动作触发。主要消耗是在逐一检查所有 UI 元素,并测试鼠标的位置是否在其范围内。(注三) 

 

资源载入

资源加载也能从 CPU 追踪里找到,加载资源的主要方法是 SerializedFile::ReadObject,它透过名为“Transfer”的方法将档案透过二进制(binary)的数据串流连接到 Unity 的串行化系统(Serialization system)。Transfer 方法可以在所有资源的加载过程中看到,例如材质、MonoBehaviours 和粒子系统。

Unity3D-性能优化最佳实践(一)分析(Profiling)

上图显示出一个场景正被加载,这会读取并反串行化(Deserialize)场景中所有资源,可以看到SerializedFile::ReadObject 之下有各种不同 Transfer 回调。

 

一般来说,如果在执行时遇到了效能问题,并追踪看到 SerializedFile::ReadObject 用掉大量的时间,代表FPS会下降是因为资源正在加载。要注意的是绝大部分的情况下,只有当透过 SceneManager、Resources 或 AssetBundle API 请求同步(Synchronous)载入时,才会在主线程上看到 SerializedFile::ReadObject。 

 

这种效能问题通常能简单解决:可以采用异步方式(Asynchronous)加载资源(将比较吃重的 ReadObject 回调放到工作线程(Worker thread)上),或预先加载那些较大的资源。 

 

请注意,当复制(Cloning)对象时也会产生 Transfer 回调(在追踪表的CloneObject方法里)。假如一个Transfer 调用出现在 CloneObject 下就代表它不是从硬盘加载而是从一个旧的对象数据转移到一个新的对象。Unity 做法是对旧的对象进行串行化之后将产生的数据反串行化成为新的对象。


注一:这只适用于在”Assets”底下的”Resources”目录以及底下所有名为”Resources”的子目录。

注二:技术上来说,执行IL2CPP之后,C#/JS脚本也会转为原生程序(Native code)。然而,这种交叉编译程序主要是透过IL2CPP的框架来执行,不会像手动写程序那么严谨。

注三:Unity 5.4之前,StandaloneInputModule在没有鼠标的装置上查询鼠标输入存在着一些缺陷,还好后面的版本已经修复,大大降低了StandaloneInputModuleCPU消耗