Unity游戏优化(2)——脚本策略

一、使用最快的方法获取组件

GetComponent<T>( )方法只比GetComponent(typeof(T))方法快一点点,而GetComponent(string)方法明显比其他两个方法慢得多。

二、移除空的回调定义

在Unity中创建新的MonoBehaviour脚本文件时,它都会自动为Start()和Update()生成两个样板回调存根。

MonoBehaviour在场景中第一次实例化时,Unity会将任何定义好的回调添加到一个函数指针列表中,它会在关键时刻调用这个列表。然而,重要的是要认识到,即使函数体是空的,Unity也会挂接到这些回调中。核心Unity引擎没有意识到这些函数体可能是空的,它只知道方法已经定义,因此,它必须获取方法,然后在必要时调用它。因此,如果将这些回调的空定义分散在整个代码库中,那么由于引擎调用它们的开销,它们将浪费少量的CPU。

空的Start()定义会导致对象的初始化无形中变慢。

在所有的Update()回调(包括场景中的所有MonoBehaviour)完成之前,渲染管线是不允许呈现新帧的。

如果场景包含数千个带有这些空Update()定义的MonoBehaviour,就会在每一帧上浪费大量的CPU周期,这可能会严重降低帧率。

删除空的回调定义。Unity将没有什么可以挂接了,也不会调用什么函数。在可扩展的代码库中查找这样的空定义可能比较困难,但如果使用一些基本的正则表达式(称为regex),应该能够相对容易地找到空的回调定义。

这个regex检查Update()回调的标准方法定义,同时包含可能分布在整个方法定义中的多余空白和换行符。


也许在Unity脚本中,性能问题的最常见来源是执行以下一个或多个操作,而误用Update()回调: 反复计算很少或从不改变的值。 太多的组件计算一个可以共享的结果。 执行工作的频率远超必要值。

要达到60FPS,每帧应在16.667毫秒内完成所有Update()回调中的所有工作。

三、缓存组件的引用

以这种方式缓存组件引用,就不必在每次需要它们时重新获取,每次都会节省一些CPU开销。代价是少量的额外内存消耗,这通常是值得的。

四、共享计算的输出

每次执行一个昂贵的操作时,考虑是否从多个位置调用它,但总是得到相同的输出。如果是这样,重构就是明智的,这样就计算一次结果,然后将结果分发给需要它的每个对象,以最小化重新计算的量。最大的成本通常只是牺牲了一点代码的简洁性,尽管传递值可能会造成一些额外的开销。

五、Update,Coroutines和InvokeRepeating

1、update:

如果这个活动占用了太多的帧率预算,且任务完成的频率低于没有明显缺陷的每一帧,那么提高性能的一个好方法就是简单地减少ProcessAI()的调用频率:

2、Coroutines

可以将它转换成协同程序,来利用其延迟的调用属性。如前所述,协程通常用于编写短事件序列的脚本,可以是一次性的,也可以是重复的操作。它们不应该与线程混淆,线程以并发方式在完全不同的CPU内核上运行,而且多个线程可以同时运行。相反,协程以顺序的方式在主线程上运行,这样在任何给定时刻都只处理一个协程,每个协程通过yield语句决定何时暂停和继续。下面的代码说明可以协程形式重写以上的Update()回调:

首先,与标准函数调用相比,启动协程会带来额外的开销成本(大约是标准函数调用的三倍),还会分配一些内存,将当前状态存储在内存中,直到下一次调用它。这种额外的开销也不是一次性的成本,因为协程经常不断地调用yield,这会一次又一次地造成相同的开销成本,所以需要确保降低频率的好处大于此成本。

在对1000个带有空Update()回调的对象的测试中,处理时间为1.1毫秒,而在WaitForEndOfFrame上生成的1000个协程(与Update()回调的频率相同)耗时2.9毫秒。所以,相对成本几乎是3倍。

其次,一旦初始化,协程的运行独立于MonoBehaviour组件中Update()回调的触发,不管组件是否禁用,都将继续调用协程。如果执行大量的GameObject构建和析构操作,协程可能会显得很笨拙。 再次,协程会在包含它的GameObject变成不活动的那一刻自动停止,不管出于什么原因(无论它被设置为不活动的还是它的一个父对象被设置为不活动的)。如果GameObject再次设置为活动的,协程不会自动重新启动。

WaitForSeconds与缩放的时间进行比较,后者受到全局Time.timeScale属性的影响。而WaitForSecondsRealTime则不是,因此,如果要调整时间缩放值(例如,对于慢动作效果),请注意使用哪种yield类型。

Unity 5.3引入了WaitUntil和WaitWhile,在这两个函数中,提供了一个委托函数,协程根据给定的委托返回true或false分别暂停或继续。

3、InvokeRepeating

InvokeRepeating()和协程之间的一个重要区别是,InvokeRepeating()完全独立于MonoBehaviour和GameObject的状态。停止InvokeRepeating()调用的两种方法:第一种方法是调用CancelInvoke(),它停止由给定的MonoBehaviour(注意它们不能单独取消)发起的所有InvokeRepeating()回调;第二种方法是销毁关联的MonoBehaviour或它的父GameObject。禁用MonoBehaviour或GameObject都不会停止InvokeRepeating()。

处理包含1000个InvokeRepeating()调用的测试大约需要2.6毫秒,略快于1000个同等的协程yield调用,它需要2.9毫秒。

六、更快的GameObject空引用检查

与典型的C#对象相比,GameObject和MonoBehaviour是特殊对象,因为它们在内存中有两个表示:一个表示存在于管理C#代码的相同系统管理的内存中,C#代码是用户编写的(托管代码),而另一个表示存在于另一个单独处理的内存空间中(本机代码)。数据可以在这两个内存空间之间移动,但是每次这种移动都会导致额外的CPU开销和可能的额外内存分配。

System.Object.ReferenceEquals(),它生成功能相当的输出,其运行速度大约是原来的两倍(尽管它确实稍微混淆了代码的用途)。

这种效果通常称为跨越本机-托管的桥接。如果发生这种情况,就可能会为对象的数据生成额外的内存分配,以便跨桥复制,这需要垃圾收集器最终执行一些内存自动清理操作。

七、避免从GameObeject取出字符串属性

从GameObject中检索字符串属性是另一种意外跨越本机-托管桥接的微妙方式。 GameObject中受此行为影响的两个属性是tag和name。

tag属性最常用于比较,而GameObject提供了CompareTag()方法,这是比较tag属性的另一种方法,它完全避免了本机-托管的桥接。

在时间轴视图中,两个峰值的高度相同,但是一个操作花费的时间是另一个操作的两倍。当超过15FPS标记时,Profiler没有必要有垂直分辨率来生成相对准确的峰值。无论如何,这两种方法都会导致糟糕的游戏体验,所以精度并不重要。

使用CompareTag() 1000万次需要约1000毫秒来处理,不会导致内存分配,因此也不会导致垃圾回收。这一点可以从内存区域的GC Allocated元素中缺少峰值看出。这应该非常清楚地表明,必须尽可能避免访问name和tag属性。如果需要对标记进行比较,应该使用CompareTag()。但是,name属性没有对应的方法,因此应该尽可能使用tag属性。

向CompareTag()传递字符串字面量(如"Player")不会导致运行时内存分配,因为应用程序在初始化期间分配这样的硬编码字符串,在运行时只是引用它们。

八、使用适合的数据结构

如果希望遍历一组对象,最好使用列表,因为它实际上是一个动态数组,对象和/或引用在内存中彼此相邻,因此迭代导致的缓存丢失最小。如果两个对象相互关联,且希望快速获取、插入或删除这些关联,最好使用字典。

然而,数据结构通常需要同时处理两种情况:快速找出哪个对象映射到另一个对象,同时还能遍历组。

在这些情况下,通常最好在列表和字典中存储数据,以便更好地支持这种行为。

九、避免运行时修改Transform的父节点

Transform组件的父-子关系操作起来更像动态数组,因此Unity尝试将所有共享相同父元素的Transform按顺序存储在预先分配的内存缓冲区内的内存中,并在Hierarchy窗口中根据父元素下面的深度进行排序。这种数据结构允许在整个组中进行更快的迭代,这对物理和动画等多个子系统特别有利。这种变化的缺点是,如果将一个GameObject的父对象重新指定为另一个对象,父对象必须将新的子对象放入预先分配的内存缓冲区中,并根据新的深度对所有这些Transform排序。另外,如果父对象没有预先分配足够的空间来容纳新的子对象,就必须扩展缓冲区,以便以深度优先的顺序容纳新的子对象及其所有的子对象。

通过GameObject.Instantiate()实例化新的GameObject时,它的一个参数是希望将GameObject设置为其父节点的Transform,它的默认值是null,把Transform放在Hierarchy窗口的根元素下。在Hierarchy窗口根元素下的所有Transform都需要分配一个缓冲区来存储它当前的子元素以及以后可能添加的子元素(子Transform元素不需要这样做)。但是,如果在实例化之后立即将Transform的父元素重新修改为另一个元素,它将丢弃刚才分配的缓冲区!为了避免这种情况,应该将父Transform参数提供给GameObject.Instantiate()调用,它跳过了这个缓冲区分配步骤。

十、注意缓存Transform的变化

Transform组件只存储与其父组件相关的数据。这意味着访问和修改Transform组件的position、rotation和/或scale属性会导致大量未预料到的矩阵乘法计算,从而通过其父Transform为对象生成正确的Transform表示。对象在Hierarchy窗口中的位置越深,确定最终结果需要进行的计算就越多。 然而,这也意味着使用localPosition、localRotation和localScale的相关成本相对较小,因为这些值直接存储在给定的Transform中,可以进行检索,不需要任何额外的矩阵乘法。因此,应该尽可能使用这些本地属性值。

不断更改Transform组件属性的另一个问题是,也会向组件(如Collider、Rigidbody、Light和Camera)发送内部通知,这些组件也必须进行处理,因为物理和渲染系统都需要知道Transform的新值,在复杂的事件链中,在同一帧中多次替换Transform组件的属性是很常见的(尽管这可能是过度工程设计的警告信号)。每次发生这种情况时,都会触发内部消息,即使它们发生在同一帧甚至同一个函数调用期间。因此,应该尽量减少修改Transform属性的次数,方法是将它们缓存在一个成员变量中,只在帧的末尾提交它们,如下所示:

这段代码仅在下一个FixedUpdate()方法中提交对position的更改。

十一、避免运行时使用Find()和SendMessage()方法

众所周知,SendMessage()方法和GameObject.Find()方法非常昂贵,应该不惜一切代价避免使用。

在场景初始化期间调用Find()有时是可以原谅的,例如在Awake()或Start()回调期间。

可以做的一个改进是将Find()调用移出for循环,并将结果缓存到一个局部变量中,这样就不需要不断地重新获取EnemyManager对象。

2.11.1 将引用分配给预先存在的对象

使用[SerializeField]属性将其显示给Inspector窗口。对于Inspector窗口,该值现在将表现为一个公共字段,允许通过编辑器界面方便地更改它,但将数据安全地封装在代码库的其他部分中。

2.11.2 静态类

单例设计模式是确保某个对象类型的实例在内存中只存在一个的常用方法。这个设计模式是通过给类提供一个私有构造函数来实现的,维护一个静态变量来跟踪对象实例,类只能通过它提供的静态属性来访问。单例模式对于管理共享资源或繁重的数据流量(如文件访问、下载、数据解析和消息传递)非常有用。单例模式确保了有一个入口点来进行这些活动,而不是让大量不同的子系统来竞争共享资源,并可能造成彼此的瓶颈。

静态类中的每个方法、属性和字段都必须附加static关键字,这意味着在内存中永远只驻留该对象的一个实例。这也意味着它的公共方法和字段可以从任何地方访问。根据定义,静态类不允许定义任何非静态字段。

静态类很难和与Unity相关的功能交互,不能直接利用MonoBehaviour特性,如事件回调、协程、分层设计和预制块。另外,由于在Inspector窗口中没有选择的对象,无法在运行时通过Inspector窗口检查静态类的数据,因此很难调试。我们可能希望在全局类中使用这些特性。

下面是SingletonComponent类的定义:这个类的工作方式是在第一次被访问时创建一个包含组件的GameObject。因为希望它是一个全局的、持久的对象,所以需要在创建GameObject后不久调用DontDestroyOnLoad()。这是一个特殊的函数,它告诉Unity,只要应用程序在运行,就希望对象在场景之间持久存在。从那时起,加载新的场景时,对象不会被破坏,并保留它的所有数据。

 

为了处理消息,消息传递系统应维护一个入站消息对象队列,以便按它们广播的顺序处理:

QueueMessage()方法只是在将给定的消息类型添加到队列之前,检查它是否出现在字典中。这有效地测试了一个对象在将其排队等待稍后处理之前,是否真正想要监听消息。

若基于时间的保护措施到位,可以确保不超过处理时间的限制阈值。如果消息传递系统压入的消息太多太快,这可以防止游戏卡顿。如果超过总时间限制,则所有消息处理将停止,剩下的消息将在下一帧中处理。

有时,希望组件或GameObject在不可见时禁用。解决这个问题的一个好方法是使用OnBecameVisible()和OnBecameInvisible()回调。顾名思义,这些回调方法是在可渲染对象对于场景中的任何相机变得可见或不可见时调用的。此外,当一个场景中有多个摄像机(例如,本地的多人游戏)时,只有当对象对任何一个摄像机可见,以及对所有摄像机不可见时,才会分别调用这两个回调。这意味着上述回调将在期望的正确时间调用;如果没有人可以看到它,就调用OnBecameInvisible(),如果至少有一个玩家可以看到它,就调用OnBecameVisible()。

 

2.12.2 通过距离禁用对象

2.13 使用距离平方而不是距离

可以肯定地说,CPU比较擅长将浮点数相乘,但是不擅长计算它们的平方根。每次使用magnitude属性或Distance()方法要求Vector3计算距离时,都要求它执行平方根计算(根据勾股定理),与许多其他类型的向量数学计算相比,这会消耗大量的CPU开销。

Vector3类也提供了sqrMagnitude属性,它提供了同样可作为距离的结果,只是该值是平方。

2.14 最小化反序列化行为

Unity的序列化系统主要用于场景、预制件、ScriptableObjects和各种资产类型(往往派生自ScriptableObject)。当其中一种对象类型保存到磁盘时,就使用YAML (Yet Another Markup Language,另一种标记语言)格式将其转换为文本文件,稍后可以将其反序列化为原始对象类型。所有的GameObject及其属性都会在序列化预制件或者场景时序列化,包括私有的和受保护的字段,它们的所有组件,及其子GameObjects和组件等。

构建应用程序时,这些序列化的数据会捆绑在大型二进制数据文件中,这些文件在Unity内部被称为序列化文件。在运行时从磁盘读取和反序列化数据是一个非常慢的过程(相对而言),因此所有的反序列化活动都伴随着显著的性能成本。 这种反序列化在调用Resources.load()时发生,用于在名为Resources的文件夹中查找文件路径。

2.14.2 异步加载序列化对象 可以通过Resources.LoadAsync()以异步方式加载预制块和其他序列化的内容,这将把从磁盘读取的任务转移到工作线程上,从而减轻主线程的负担。将序列化的对象变为可用需要一些时间,可以通过检查前面方异步并叠加式加载下一章节(场景1-1b),并在玩家穿越关卡时不断重复这一过程。

2.16 创建自定义的Update()层

优化更新的一个可能更好的方法是根本不使用Update(),或者更准确地说,只使用一次。当Unity调用Update()时,实际上是调用它的任何回调,都要经过前面提到的本机-托管的桥接,这可能是一个代价高昂的任务。换句话说,执行1000个单独的Update()回调的处理成本比执行一个Update()回调要高,后者调用1000个常规函数。调用Update()数千次的工作量并不是CPU很容易承担的,这主要是因为桥接。

UpdateableComponent类的OnUpdate()方法检索当前的时间增量(dt),节省了大量不必要的Time.deltaTime调用。该调用通常用于Update()回调。还将该函数设为虚函数,以允许派生类对其进行自定义。

UpdateableComponent类必须注册并注销为IUpdateable对象,而GameLogic类必须使用它自己的Update()回调,来遍历每个注册的对象,并调用其OnUpdate()函数。如果确保所有自定义组件都继承自UpdateableComponent类,那么实际上用一个Update()回调和N个虚函数调用替换了Update()回调的N次调用。这可以节省大量的性能开销,因为虽然调用虚函数(开销比非虚拟函数调用略多,因为它需要调用重定向到正确的地方),仍然将更新行为的绝大多数放在托管代码中,尽可能避免Native-Managed桥。


 

 

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值