[Unity脚本优化] Unity magic methods

https://blog.unity.com/technology/1k-update-calls

10000 Update() calls

Unity 有所谓的消息传递系统,它允许您在脚本中定义一堆魔术方法,这些方法将在游戏运行时的特定事件中被调用。 这是一个非常简单易懂的概念,特别适合新用户。 只需像这样定义一个 Update 方法,它就会每帧调用一次!

void Update() {
    transform.Translate(0, 0, Time.deltaTime);
}

对于有经验的开发人员来说,这段代码有点奇怪。

  1. 不清楚这个方法具体是怎么调用的。
  2. 如果场景中有多个对象,则不清楚这些方法的调用顺序。
  3. 这种代码风格不适用于智能提示。

How Update is called

不,Unity 不会在每次需要调用时使用 System.Reflection 来查找magic method。
相反,第一次访问给定类型的 MonoBehaviour 时,会通过脚本运行时(Mono 或 IL2CPP)检查底层脚本是否定义了任何魔术方法以及是否缓存了此信息。 如果 MonoBehaviour 具有特定方法,则将其添加到适当的列表中,例如,如果脚本定义了 Update 方法,则将其添加到需要每帧更新的脚本列表中。
在游戏过程中,Unity 只是遍历这些列表并从中执行方法——就这么简单。 此外,这就是为什么您的 Update 方法是公共的还是私有的并不重要。

In what order Updates are executed

该顺序由脚本执行顺序设置(菜单:编辑 > 项目设置 > 脚本执行顺序)指定。 手动设置 1000 个脚本的顺序可能不是最好的方法,但如果您希望一个脚本在所有其他脚本之后执行,这种方式是可以接受的。 当然,未来我们希望有一种更方便的方式来指定执行顺序,例如在代码中使用属性。

It doesn’t work with intellisense

我们都使用某种 IDE 在 Unity 中编辑我们的 C# 脚本,他们中的大多数人不喜欢magic method,因为他们无法弄清楚它们在哪里被调用,如果有的话。 这会导致警告并使代码更难导航。
有时开发人员会添加一个扩展 MonoBehaviour 的抽象类,将其称为 BaseMonoBehaviour 或类似名称,并使项目中的每个脚本都扩展该类。 他们在其中添加了一些基本有用的功能以及一堆虚拟魔术方法,如下所示:

public abstract class BaseMonobehaviour : MonoBehaviour {
    
    
    protected virtual void Awake() {
    
    }
    protected virtual void Start() {
    
    }
    protected virtual void OnEnable() {
    
    }
    protected virtual void OnDisable() {
    
    }
    protected virtual void Update() {
    
    }
    protected virtual void LateUpdate() {
    
    }
    protected virtual void FixedUpdate() {
    
    }
}

这种结构使得在您的代码中使用 MonoBehaviours 更加合乎逻辑,但有一个小缺陷。 我敢打赌你已经想通了…
你所有的 MonoBehaviours 都将在 Unity 内部使用的所有更新列表中,所有这些方法都将在你的所有脚本的每一帧中调用,大多数情况下什么都不做!
有人可能会问,为什么有人要关心空方法? 问题是这些是从本地 C++ 领域到托管 C# 领域的调用,它们是有成本的。 让我们看看这个成本是多少。

Calling 10000 Updates

在这里插入图片描述

Interface calls, virtual calls and array access

如果您还没有阅读过this great series of posts about IL2CPP internals文章,那么您应该在阅读完这篇文章后立即阅读!
事实证明,如果您想每帧遍历 10000 个元素的列表,您最好使用数组而不是列表,因为在这种情况下生成的 C++ 代码更简单,数组访问也更快。
In the next test I changed List<ManagedUpdateBehavior> to ManagedUpdateBehavior[].
在这里插入图片描述

Instruments to the rescue!

我们发现从 C++ 到 C# 调用函数并不快,但是让我们看看 Unity 在对所有这些对象调用 Updates 时实际上在做什么。 最简单的方法是使用 Apple Instruments 的 Time Profiler。

Note that this is not a Mono vs. IL2CPP test — most of the things described further are also true for a Mono iOS build.

我使用 Time Profiler 在 iPhone 6 上启动了测试,记录了几分钟的数据并选择了一分钟的时间间隔进行检查。 我们对从这条线开始的一切感兴趣:
void BaseBehaviourManager::CommonUpdate<BehaviourManager>()
如果您以前没有使用过 Instruments,在右侧您会看到按执行时间排序的函数和它们调用的其他函数。 最左边的列是以毫秒为单位的 CPU 时间和这些函数和它们调用的函数的百分比,左第二列是函数的自执行时间。 请注意,由于在此实验期间 Unity 没有完全使用 CPU,我们看到在 60 秒的时间间隔内有 10 秒的 CPU 时间花在更新上。 显然,我们对花费最多时间执行的函数感兴趣。
在这里插入图片描述

UpdateBehavior.Update()

在中间,您可以看到我们的 Update 方法或 IL2CPP 如何调用它 — UpdateBehavior_Update_m18。 但是在到达那里之前,Unity 做了很多其他的事情。

Iterate over all Behaviours

Unity 会检查所有行为以更新它们。 特殊的迭代器类 SafeIterator 确保在有人决定删除列表中的下一项时不会中断。 仅遍历所有已注册的行为需要 1517 毫秒,而总时间为 9979 毫秒。

Check if the call is valid

接下来,Unity 会进行一系列检查,以确保它在已初始化且已调用其 Start 方法的活动游戏对象上调用有效的现有方法。 如果您在更新期间销毁游戏对象,您不希望您的游戏崩溃,是吗? 这些检查在总共 9979 毫秒中又花费了 2188 毫秒。

Prepare to invoke the method

Unity 与 ScriptingArguments 一起创建 ScriptingInvocationNoArgs 实例(表示从本机端到托管端的调用),并命令 IL2CPP 虚拟机调用该方法(scripting_method_invoke 函数)。 这一步花费了 9979 毫秒中的 2061 毫秒。

Call the method

scripting_method_invoke 函数检查传递的参数是否有效(900 毫秒),然后调用 IL2CPP 虚拟机的 Runtime::Invoke 方法(1520 毫秒)。 首先,Runtime::Invoke 检查这种方法是否存在(1018 毫秒)。 接下来,它为方法签名调用生成的 RuntimeInvoker 函数(283 毫秒)。 它依次调用我们的更新函数,根据 Time Profiler 执行该函数需要 42 毫秒。
在这里插入图片描述

Managed Updates

现在让我们将 Time Profiler 与manager 测试一起使用。 您可以在屏幕截图中看到有相同的方法(其中一些总共耗时不到 1 毫秒,因此甚至没有显示),但大部分执行时间实际上都用于 UpdateMe 函数(或 IL2CPP 如何调用它 - ManagedUpdateBehavior_UpdateMe_m14)。 另外,IL2CPP 插入了一个空检查,以确保我们正在迭代的数组不为空。
在这里插入图片描述

A few words about the test

老实说,这个测试并不完全公平。 Unity 可以很好地保护您和您的游戏免受意外行为和崩溃的影响:这个 GameObject 是否处于活动状态? 它不是在这个更新循环中被破坏了吗? 对象上是否存在 Update 方法? 如何处理在此更新循环期间创建的 MonoBehaviour? — 我的管理器脚本不处理任何这些,它只是遍历要更新的对象列表。
在现实世界中,管理器脚本可能会更复杂且执行速度更慢。 但在这种情况下,我是开发人员——我知道我的代码应该做什么,并且我构建我的管理器类,知道我的游戏中哪些行为是可能的,哪些是不可能的。 不幸的是,Unity 不具备这样的知识。

What should you do?

当然,这完全取决于您的项目,但在该领域中,经常会看到在场景中使用大量游戏对象的游戏,每个游戏对象每帧都执行一些逻辑。 通常它是一点点代码,似乎不会影响任何事情,但是当数量变得非常大时,调用数千个 Update 方法的开销开始变得明显。 在这一点上,改变游戏的架构并将所有这些对象重构为管理器模式可能已经太晚了。

你现在有了数据,在下一个项目开始时考虑一下。

猜你喜欢

转载自blog.csdn.net/fztfztfzt/article/details/122785683