Unity PlayerLoop 包含与游戏引擎核心交互的函数。这种树状结构包括许多处理初始化和每帧更新的系统。所有脚本都将依赖该 PlayerLoop 来创建游戏玩法。
在进行性能分析时,可以看到项目的所有用户代码都位于 PlayerLoop 下(编辑器组件位于 EditorLoop 下)。
自定义脚本、设置和图形会显著影响每一帧的计算和在屏幕上渲染的时间。
1.了解Unity Playerloop
确保了解 Unity 的帧循环的执行顺序。每个 Unity 脚本都将按预定顺序运行多个事件函数。您应该了解 Awake、Start、Update 及其他创建脚本生命周期的函数之间的区别。
有关事件函数的具体执行顺序,请参阅脚本生命周期流程图。
了解 PlayerLoop 和脚本的生命周期。
2.代码优化常见方法
2.1 尽可能减少每帧运行的代码
考虑代码是否必须每一帧都运行。将不必要的逻辑移出 Update、 LateUpdate 和 FixedUpdate。可在这些事件函数中方便地放置必须每帧更新的代码,但应提取出任何不需要以这种频率更新的逻辑。尽可能只在情况发生改变时才执行逻辑。
如果确实 需要使用 Update,可以考虑每 n 帧运行一次代码。这是一种应用时间切片 (将繁重的工作负载分布到多个帧的常用技术)的方法。在下面的示例中,我们每三帧运行一次 ExampleExpensiveFunction:
private int interval = 3;
void Update()
{
if (Time.frameCount % interval == 0)
{
ExampleExpensiveFunction();
}
}
2.2 避免在 Start/Awake 中处理复杂逻辑
加载第一个场景时,将为每个对象调用以下函数 :
-
Awake
-
OnEnable
-
Start
在应用程序渲染其第一帧之前,避免在这些函数中处理代价高昂的逻辑。否则,可能会增加不必要的加载时间。
有关加载第一个场景的详细信息,请参阅事件函数的执行顺序。
2.3 避免空Unity 事件
避免空Unity 事件
即使是空的 MonoBehaviour 也需要资源,因此应删除空的 Update 或 LateUpdate 方法。
如果使用这些方法进行测试,请使用预处理器指令 :
#if UNITY_EDITOR
void Update()
{
}
#endif
这样,您可以在编辑器中随意使用 Update 进行测试,而不会在构建版本中遗留不必要的开销。
2.4 删除调试日志语句
日志语句(尤其是在 Update、LateUpdate 或 FixedUpdate 中)可能会降低性能。在进行构建之前,请禁用日志语句。
为了更轻松地执行该操作,可以考虑配合预处理指令创建一个 Conditional 属性。例如,创建如下所示的自定义类 :
public static class Logging
{
[System.Diagnostics.Conditional(“ENABLE_LOG”)]
static public void Log(object message)
{
UnityEngine.Debug.Log(message);
}
}
添加自定义预处理器指令可对脚本进行分区。
使用自定义类生成日志消息。如果在 Player Settings 中禁用 ENABLE_LOG 预处理器,您的所有 Log 语句都会立即消失。
2.6 使用哈希值而不是字符串参数
Unity 不使用字符串名称对 Animator、Material 和 Shader 属性进行内部寻址。为了加快速度,所有属性名称都经过哈希处理为属性 ID,实际上是这些 ID 用于寻址属性。
每当在 Animator、Material 或 Shader 上使用 Set 或 Get 方法时,请使用整数值方法而非字符串值方法。字符串方法只执行字符串哈希处理,然后将经过哈希处理的 ID 转发给整数值方法。
对于 Animator 属性名称,使用Animator.StringToHash,对于 Material 和 Shader 属性名称,使用 Shader.PropertyToID。
2.7 选择正确的数据结构
随着每一帧迭代成千上万次,所选择的数据结构可能存在累积效应会导致高效或低效。是否使用 List、Array 或 Dictionary 来处理集合会更合理?在 C# 中,请以MSDN 数据结构指南作为常规指南来选择正确的数据结构。
2.8 避免在运行时添加组件
在运行时调用 AddComponent 需要一些开销。每当在运行时添加组件时,Unity 都必须检查是否有重复项或是否需要其他组件。
对设置好所需组建的预制件进行实例化,通常性能表现更优异。
2.9 缓存游戏对象和组件
GameObject.Find、GameObject.GetComponent 和 Camera.main( 在 2020.2之前的版本中)可能开销较大,应避免在 Update 方法中调用它们。而应在 Start 中调用它们,并且缓存相应结果。
例如,下面演示低效使用重复的 GetComponent 调用 :
void Update()
{
Renderer myRenderer = GetComponent<Renderer>();
ExampleFunction(myRenderer);
}
如果函数结果已缓存,就可以仅调用 GetComponent 一次。缓存的结果可以在 Update 中重用,无需再调用 GetComponent。
private Renderer myRenderer;
void Start()
{
myRenderer = GetComponent<Renderer>();
}
void Update()
{
ExampleFunction(myRenderer);
}
2.10 使用对象池
Instantiate 和 Destroy 可能生成垃圾和垃圾收集 (GC) 尖峰,通常是一个缓慢的过程。请不要频繁初始化和销毁游戏对象(例如,从枪射出子弹),而应使用预分配的对象池,这样可以重用和回收。
在本示例中,ObjectPool 创建 20 个 PlayerLaser 实例以供重用。
PlayerLaser 对象的池处于不活动状态,已准备好可以发射。
在 CPU 尖峰不那么明显的时候,在游戏中某个点(如在菜单屏幕出现时)创建可重用的实例。通过集合跟踪对象“池”。在游戏过程中,只是在需要时启用下一个可用实例,禁用对象而不是销毁对象,然后将其返回池。
这样可减少项目中托管分配的数量,可以防止垃圾收集问题。
在此了解如何在 Unity 中创建简单对象池系统。
2.11 使用ScriptableObject
在 ScriptableObject 中而不是 MonoBehaviour 中存储不变的值或设置。ScriptableObject 这种资源只需设置一次就可以在项目中一直使用。它不能直接附加到游戏对象。
在 ScriptableObject 中创建字段来存储值或设置,然后在 Monobehaviour 中引用该 ScriptableObject。
在此示例中,名为 Inventory 的 ScriptableObject 为各种游戏对象保存设置。
使用 ScriptableObject 的这些字段可以防止每次使用该 Monobehaviour 实例化对象时出现不必要的数据重复。
观看此 ScriptableObject 简介教程了解 ScriptableObject 可以如何帮助项目开发。也可以在此查找文档
2.12 变换一次,而非两次
另外,移动变换 (Transform) 时,使用Transform.SetPositionAndRotation 可以一次就同时更新位置和旋转。这样可以避免两次修改变换(Transform)的开销。
如果需要在运行时初始化游戏对象,一项简单的优化是在初始化过程中父子化和重新定位 :
GameObject.Instantiate(prefab, parent);
GameObject.Instantiate(prefab, parent, position, rotation);
有关 Object.Instantiate 的更多详细信息,请参阅脚本 API。
2.13 RayCast射线
-
Unity物理中RayCast与Overlap都有NoAlloc版本的函数,在代码中调用时尽量用NoAlloc版本,这样可以避免不必要的GC开销
-
尽量调用RayCast与Overlap时要指定对象图层进行对象过滤,并且RayCast要还可以指定距离来减少一些太远的对象查询
-
此外如果是大量的RayCast操作还可以通过RaycastCommand的方式批量处理,充分利用JobSystem来分摊到多核多线程计算。
public class ExampleClass : MonoBehaviour { //数组的大小决定了会发生多少射线 RaycastHit[] m_Results = new RaycastHit[5]; void Update() { // 设置layerMask为所有图层 var layerMask = ~0; if (Physics.RaycastNonAlloc(transform.position, transform.forward, m_Results, Mathf.Infinity, layerMask) > 0) { foreach (var result in m_Results) { if (result.collider != null) { Debug.Log("Hit " + result.collider.gameObject.name); } } } else { Debug.Log("Did not hit"); } } }
3. Lua
GOT Online Lua模式提供的分析Lua造成的CPU耗时工具可视化程度高,堆栈清晰明了,还提供了实用且特色的倒序调用分析功能。以下结合一个Lua报告Demo简单介绍使用该工具分析Lua耗时的方法。
重申:Lua报告中出现的函数名称格式为:函数名称@文件名:行号。
可以通过报告提供的Lua文件名/行号/函数名来定位CPU耗时的瓶颈函数和CPU耗时峰值的具体原因。Lua函数的命名格式为X@Y:Z,其中X是其函数名,在无法获取时,X会变为默认的unknown;Y是该函数定义的文件位置;Z则是该函数被定义的行号。需要注意的是,当Lua脚本以字节码运行时,该值将始终为0,因此建议在测试时尽可能使用Lua源码来运行。
(1)正序调用分析——总表(曲线图+列表) 曲线图:
曲线选取了选取总体Lua代码耗时和按照耗时均值正向排序的前五个函数耗时组成耗时曲线图,每一个数据点代表了该函数在当前帧(横坐标)的耗时(纵坐标),有助于定位耗时瓶颈函数。
列表:
列表默认按照耗时均值从高到低对Lua函数进行了排序,粗略展示了函数名、总CPU耗时、场景CPU耗时、耗时均值等数据。通过点击函数,可以进入对应的单个函数分析页面。
(2)正序调用分析——单个函数页(截图+曲线图+堆栈信息) 截图:
项目运行时截图与使用者选中的帧大致对应,有助于定位问题。
曲线图:
曲线图包括了CPU耗时曲线图和调用次数曲线图;也可以使用下方条缩放曲线观察局部耗时情况。
从曲线图中可以观察到:函数是否存在持续性高耗时;函数是否存在短暂的大量耗时,导致卡顿;某些函数单次耗时并不高,但因为被大量的调用,导致函数总耗时较高。
函数XXXX堆栈信息 (列表):
其中,可以在右上角选定列表数据的时间范围:总体堆栈信息时,时间范围为全部测试时间;指定场景堆栈信息时,时间范围为指定场景的开启时间;指定帧堆栈信息时,时间范围为当前在曲线图中选中的指定帧。
列表中各项指标含义是:总体占比,以根节点函数的总耗时为100%,当前节点函数总耗时相对根节点函数的总耗时占比;自身占比,以根节点函数的总耗时为100%,当前节点函数自身耗时相对根节点函数的总耗时占比;总耗时,时间范围内执行该函数的耗时;自身耗时,时间范围内去除子节点函数(该函数调用的函数)耗时剩余的耗时;调用次数,时间范围内该函数被调用的次数;单次耗时,总耗时/调用次数,表示每次执行该函数的平均耗时;显著调用帧数,该函数自身耗时大于3ms的帧数。
(3)倒序调用分析——总表(曲线图+列表) 曲线图:与正序调用分析不同的是,选取了自身耗时正向排序的前五个函数,每一个数据点代表了该函数在当前帧(横坐标)的自身耗时(纵坐标)。
列表:与上同理。
(4)倒序调用分析——单个函数页(截图+曲线图+堆栈信息)
函数XXXX堆栈信息 (列表):
各项指标含义(与正序相比有所不同)变为了:自身占比,以选定函数的自身耗时总和为100%,这条调用路径下选定函数的自身耗时相对选定节点函数总自身耗时的占比;自身耗时,时间范围内,这条调用路径下,选定函数自身耗时的总和;调用次数,这条调用路径的调用次数;单次耗时,代表这条路调用路径下,选定函数的平均耗时。
在通过以上界面定位到自身耗时较高的函数后,常见的优化手段有:优化该函数的函数体,减少该函数自身的耗时;定位调用次数较多的调用路径,减少调用次数。
(5)注意事项 Lua CPU耗时中暂不包括GC耗时;Lua 函数耗时相当于在进出函数时打点,统计耗时。所以如果Lua脚本运行时调用了C#函数,这部分C#函数是会被统计进去的,所以需要关注和C#穿插调用的情况,尽量控制在50次以内。
参考文献: