Unity优化——脚本优化策略4

大家好,这里是七七,今天这期是脚本优化的最后一期了。下期的主题是批处理的优势,感兴趣的小伙伴们可以收藏本专题,七七会持续更新。

话不多说,开始今天的内容。

目录

一、最小化反序列化行为

1.1减小序列化对象

1.2异步加载序列化对象

1.3在内存中保存之前加载过的序列化对象

1.4将公共数据移入ScriptableObject

二、叠加、异步地加载场景

三、创建自定义的Update()层


一、最小化反序列化行为

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。然而,如果时间充裕,耗时就会大于风险。明智的做法是对场景中的一组对象进行测试,这些对象与当前场景文件的设计类似,以验证收益大于成本。

相关推荐
速易达网络1 小时前
无人机远程操控仿真
游戏引擎·cocos2d
郝学胜-神的一滴2 小时前
游戏引擎原理与实践 02:揭开3A游戏背后的技术面纱
开发语言·计算机网络·程序人生·游戏·游戏引擎
警醒与鞭策10 小时前
【无标题】
android·unity·性能优化·游戏引擎·perforce
zhchyun200812 小时前
【Unity UI 进阶】仿 Element UI 打造企业级 Unity UI 组件库(10)
ui·unity·游戏引擎
地狱为王16 小时前
Unity使用NVIDIA的Audio2Face驱动口型
unity·游戏引擎
ebiobiz18 小时前
STM32F4 Unity 单元测试与多次测试失败率统计详细步骤
stm32·unity·单元测试
一线灵1 天前
跨平台游戏引擎 Axmol-2.11.5 发布
游戏引擎·lua
淡海水1 天前
16-01-C#数据结构源码-附录A-术语表
unity·c#·游戏引擎·il2cpp
淡海水1 天前
16-03-C#数据结构源码-附录C-参考资源
unity·c#·游戏引擎·il2cpp
天神哥哥啊1 天前
unity联调注意事项-安卓
android·unity·游戏引擎