大家好,这里是七七,今天这期是脚本优化的最后一期了。下期的主题是批处理的优势,感兴趣的小伙伴们可以收藏本专题,七七会持续更新。
话不多说,开始今天的内容。
目录
一、最小化反序列化行为
Unity的序列化系统主要用于场景、预制件、ScriptableObjects和各种资源类型(往往派生自ScriptableObject)。当其中一种对象类型保存到磁盘时,就使用YAML(另一种标记语言)格式将其转换为文本文件,稍后可以将其反序列化为原始对象类型。所有的GameObject及其属性都会在序列化预制件或场景时序列化,包括私有的和哦玊保护的字段,它们的所有组建,及其子GameObjects和组件等。
构建应用程序时,这些序列化的数据会捆绑在大型二进制数据文件中,这些文件在Unity内部被称为序列化文件。在运行时从磁盘读取和反序列化数据是一个非常慢的过程(相对而言),因此所有的反序列化活动都伴随着显著的性能成本。
这种反序列化在 调用Resources.load()时发生,用于在名为Resources的文件夹中查找文件路径。一旦数据从磁盘加载到内存中,以后重新加载相同的引用会快得多,但在第一次访问时总是需要磁盘活动。当然需要反序列化的数据集越大,此过程所需的时间就越长。由于预制组件的每个组件都是序列化的,因此层次结构越深,需要反序列化的数据就越多。这对于具有很深层次结构的预制块,带有许多空GameObject对象的预制块来说是一个问题,对于用户界面(UI)预制块来说尤其是个问题,因为他们往往比典型的预制块容纳更多的组件。
像这样加载大型序列化数据集可能在第一次加载时造成CPU的显著峰值,如果在场景开始时立即需要它们,则会增加加载时间。更重要的是,如果在运行时加载它们,可能会导致掉帧。可以用几种方法来最小化反序列化的成本。
1.1减小序列化对象
我们的目标应该是使序列化的对象尽可能小,或者将它们分割成更小的数据块,然后一块一块地组合在一起,这样它们就可以一次加载一块。这对于预制版来说是很棘手的,因为Unity本身并不支持嵌套的预制版,所以我们将自己实现这样一个系统,这在Unity中是一个非常难解决的问题。UI预制块很适合分割成更小的块,因为通常在任何时候都不需要整个UI,所以通常可以一次加载一个。
1.2异步加载序列化对象
可以通过Resources.LoadAsync()以异步方式加载预制块和其他序列化的内容,这将把从磁盘读取的任务转移到工作的线程上,从而减轻主线程的负担。将序列化的对象变为可用需要一些时间,可以通过检查前面的方法调用返回的ResourceRequest对象的isDone属性,判断是否完成序列化对象加载。
这对于游戏开始时立即需要的预制组件来说并不理想,但如果愿意创建管理这种行为的系统,那么所有未来的预制组件都是异步加载的良好候选对象
1.3在内存中保存之前加载过的序列化对象
如前所述,一旦序列化对象加载到内存中,它就会保留在内存中,如果以后需要,可以复制它,例如实例化更多的预制副本。通过显式地调用Resource.Unload()可以释放这些数据,这将释放内存空间,供以后重用。但是,如果在应用程序的预算中有很多剩余的内存,就可以选择将这些数据保存在内存中,这将减少以后从磁盘重新加载数据的需要。这自然会消耗大量内存来保存越来越多的序列化数据,使其成为内存管理的一种风险策略,因此应该只在必要时才这样做。
1.4将公共数据移入ScriptableObject
如果有许多不同的预制件,其中的组件包含许多倾向于共享数据的属性,例如游戏设计值,如命中率、攻击力、双爆等,那么所有这些数据都将序列化到使用它们的每个预制件中。更好的方法是将这些公共数据序列化到ScriptableObject中,然后加载并使用它。这减少了存储在预制文件中的序列化数据量,并可以避免过多的重复工作,显著减少场景的加载时间。
二、叠加、异步地加载场景
可以加载场景来替换当前场景,也可以添加内容到当前场景中,而不写在前一个场景。这可以通过SceneManager.LoadScene()函数家族的LoadSceneMode参数进行切换。
另一种场景加载模式是同步或异步完成,两者各有千秋。同步加载是通过调用SceneManager.LoadScene()加载场景的典型方法,其中主线程将阻塞,直到给定的场景完成加载。者通常会导致糟糕的用户体验,因为游戏在加载内容时似乎会卡住(无论是替换还是附加方式)。如果想让玩家尽快进行后续操作,或没有时间等待场景对象出现,最好使用同步加载模式。如果在游戏的第一关加载或者返回到主菜单,通常会使用这种模式。
然而,对于未来的场景加载,可能希望减少性能影响,让玩家继续操作下去。加载场景需要很多工作,场景越大,加载时间越长。然而,异步叠加式加载选项提供了巨大优势:可以让场景逐渐加载到背景中,而不会对用户体验造成明显的影响。为此,可以使用SceneManager.LoadSceneAsync()并传递LoadSceneMode.Additive,以加载模式函数。
重要的是要意识到,场景并不严格遵循游戏关卡的概念。在大多数游戏中,玩家通常被限制在一个关卡中,但Unity可以通过叠加式加载,支持多个场景同时加载,允许每个场景代表一个关卡的一小块。因此,可以为关卡初始化第一个场景,并在玩家接近下一章节时,异步叠加式加载下一章节,并在玩家穿越关卡时不断重复这一过程。
利用这一功能需要一个系统不断检查玩家在关卡中的位置,知道玩家接近,或者用Trigger Volumes广播"玩家将进入下一章节"的消息,并在适当的时候开始异步加载。另一个重要的考虑是场景的内容不会立即出现,因为异步加载可以有效地将加载分散到几个帧上,从而使可见影响尽可能减小。需要却吧出发场景的异步加载有足够的时间,以便玩家不会看到对象弹出到游戏中。
场景可以卸载,从内存中清除出来。这将删除任何不再需要的Update()的组件,节省一些内存或提升一些运行时性能。同时,这可以通过SceneManager.UnloadScene()和SceneManager.UnloadSceneAsync()同步或异步地完成。这是一个巨大的性能优势,因为根据玩家在关卡中的位置只使用需要的内容,但请注意,不可能卸载单一场景的小块。如果原始场景文件很大,那么卸载它将卸载所有内容。原来的场景必须分解成更小的场景,然后根据需要加载和卸载。同样,应该只在玩家不再能看到场景的组件对象时才开始卸载场景,否则玩家将看到物体凭空消失。最后要考虑的是,场景卸载会导致许多对象被销毁,这可能是放大量内存并触发垃圾回收。在使用这个技巧时,有效地使用内存也很重要。
这种方法需要大量的场景重新设计、脚本编写、测试和调试工作,这是不可低估的,但是改进用户体验的好处是非常多的。在游戏中拥有区域间的无缝过渡是一种经常受到玩家称赞的优点,因为它不会打断玩家的操作。如果适当地使用它,就可以显著提升运行时的性能,进一步改善用户体验。
三、创建自定义的Update()层
在前面我们讨论了使用这些Unity Engine特性来避免在大多数帧中出现过多CPU工作负载的优缺点。不管采用哪种方法,都存在一个额外的风险,即需要编写大量的MonoBehaviour来定期调用某个函数,这意味着在同一帧中同时触发了太多的方法。
想象一下,成千上万的MonoBehaviour在场景开始时一起初始化,每个MonoBehaviour同时启动一个协程,每500毫秒处理一次AI任务。它们极有可能在同一帧内触发,导致CPU使用率在一段时间内出现一个巨大的峰值,接着会临时下降,然后在处理下一轮AI时再次出现峰值。理想情况下,我们希望随时间分散这些 调用,下面是这个问题的可能解决方案:
- 每次计时器过期或协程触发时,生成一个随机等待时间
- 将协程的初始化分散到每个帧中,这样每个帧中只会启动少量的协程初始化
- 将调用更新的指责传递给某个God类,该类对每个帧的调用数量进行了限制
前两个选项很有吸引力,因为它们相对简单,而且协程可以潜在地减少大量不必要的开销。然而,如前所述,这种剧烈的设计更改会带来许多危险和意想不到的副作用。
优化更新的一个更好的方法是根本不使用Update(),或者更准确地说,只使用一次。当Unity调用Update()时,实际上是调用它的任何回调,都要经过前文提到的本机-托管的桥接,这可能是一个代价高昂的任务。换句话说,执行1000个单独的Update()回调的处理成本比执行一个Update()回调要高,后者调用1000个常规函数。调用Update()数千次的工作量不是CPU很容易承担的,这主要是因为桥接。因此,让一个God类的MonoBehaviour使用它自己的Update()回调来调用自定义组件使用的自定义更新样式的系统,可以最小化Unity需要跨越桥接的频率。
事实上,许多Unity开发人员更喜欢从项目一开始就实现这个设计,因为它可以让他们更好地控制更新何时以如何在整个系统中传播;这可以用于菜单暂停、冷却时间操作效果,或对重要任务进行优先级排序,以及如果发现即将达到当前帧的CPU预算,就暂停低优先级任务。
所有想要与这样一个系统集成的对象必须有一个公共的入口点,为此,可以使用interface关键字的接口类。接口类本质上建立了一个契约,任何实现接口类的类都必须提供一系列特定的方法。换句话说,如果知道对象实现了一个接口类,就可以确定哪些方法是可用的。在C#中,类只能从单个基类派生,但可以实现任意数量的接口类。
下面的接口类定义就足够了,它只需要实现类定义一个名为OnUpdate()的方法:
cs
public interface IUpdateable
{
void OnUpdate(float dt);
}
提示:通常的做法是用大写的I来开始接口类定义,以清楚地表明我们正在处理的事接口类。接口类的优点在于它们改善了代码库的解藕能力,允许替换大型子系统,只要坚持使用接口类,它就能继续按预期工作。
接下来定义一个实现该接口类的MonoBehaviour类型:
cs
public class UpdateableComponent : MonoBehaviour, IUpdateable
{
public virtual void OnUpdate(float dt) { }
}
注意将方法命名为OnUpdate()而不是Update()。我们定义了相同概念的自定义版本,但要避免和内建Update()回调的命名冲突。
UpdateableComponent类的OnUpdate()方法检索当前的时间增量(dt),节省了大量不必要的Time.deltaTime调用。该调用通常用于Update()回调。还将该函数设为虚函数,以允许派生类对其进行自定义。
这个函数将永远不会被调用,因为目前正在写此函数。Unity会自动获取并调用用Update()名称定义的方法,但没有OnUpdate()函数的概念,所以需要实现一些功能,在适当的时候调用这个方法。例如,某种GameLogicGod类可以用于此目的。
在这个组件的初始化期间,应该执行一些操作,来通知GameLogic对象它的存在和销毁,这样它就知道什么时候开始和停止调用其OnUpdate()函数。
在下面的示例中,假设GameLogic类是一个SingletonComponent,且具有为注册和注销而定义的适当的静态函数。记住,可以很容易地使用消息传递系统,来通知GameLogic它的创建/销毁。
为了MonoBehaviour挂载进此系统,最合适的地方是在它们的Start()和OnDestroy()回调中处理:
cs
void Start()
{
GameLogic.Instance.RegisterUpdateableObject(this);
}
void OnDestroy()
{
if (GameLogic.Instance.IsAlive)
{
GmeLogic.Instance.DeregisterUpdateableObject(this);
}
}
最好使用Start()方法来完成注册任务,因为使用Start()意味着可以确定所有其他已经存在的组件至少在此以前已经调用了Awake()方法。这样,在开始调用对象的更新之前,任何关键的初始化工作都已经在对象上完成了。
注意,因为在MonoBehaviour基类中使用Start(),如果在派生类中定义Start()方法,它将有效地覆盖基类定义,而Unity将获取派生的Start()方法作为回调。因此,明智的做法是实现一个Initialize()虚方法,这样派生类就可以覆盖它来定制初始化行为,而不会影响基类通知GameLogic对象组件存在的任务。
下面代码演示了如何实现Initialize()虚方法:
cs
void Start()
{
GameLogic.Instance.RegisterUpdateableObject(this);
Initialize();
}
protected virtual void Initialize()
{
}
最后,需要实现GameLogic类。不管它是SingletonComponent还是MonoBehaviour,不管它是否使用消息传递系统,实现代码实际上都是相同的。不管怎样,UpdateableComponent类必须注册并注销为IUpdateable对象,而GameLogic类必须使用它自己的Update()回调,来遍历每个组册的对象,并调用其OnUpdate()函数。
这时GameLogic类的定义:
cs
public class GameLogicSingletonComponent:
SingletonComponent<GameLogicSingletonComponent>
{
public static GameLogicSingletonComponent Instance
{
get { return ((GameLogicSingletonComponent)_Instance); }
set { _Instance = value; }
}
List<IUpdateable> _updateableObjects = new List<IUpdateable>();
public void RegisterUpdateableObject(IUpdateable obj)
{
if (!_updateableObjects.Contains(obj){
_updateableObjects.Add(obj);
}
}
public void DeregisterUpdateableObject(IUpdateable obj)
{
if (_updateableObjects.Contains(obj){
_updateableObjects.Remove(obj);
}
}
void Update()
{
float dt = Time.deltaTime;
for(int i=0;i< _updateableObjects.Count; i++)
{
_updateableObjects[i].OnUpdate(dt);
}
}
}
如果确保所有自定义组件都继承自UpdateableComponent类,那么实际上用一个Update()回调和N个虚函数调用替换了Update()回调的N次调用。这可以节省大量的性能卡小,因为虽然调用虚函数(开销比非虚函数调用掠夺,因为它需哟调用重定向到正确的地方),仍然将更新行为的绝大多数放在托管代码中,尽可能避免Native-Managed桥。这个类甚至可以扩展为提供优先级系统,如果它检测到当前帧花费的时间太长,就可以跳过低优先级任务,还有许多其他的可能性。
根据对当前项目的深入程度,这样的更改可能令人生畏、耗时,并且可能会在更新子系统以利用一组完全不同的依赖项时引入大量bug。然而,如果时间充裕,耗时就会大于风险。明智的做法是对场景中的一组对象进行测试,这些对象与当前场景文件的设计类似,以验证收益大于成本。