大家好,这里是七七,今天继续来介绍几个Unity脚本优化策略
一、更快的GameObject空引用检查
事实证明,对GameObject执行空引用检查会导致一些不必要的开销。与典型的C#对象相比,GameObject和MonoBehaviour是特殊对象,因为它们在内存中有两个表示:一个表示存在于管理C#代码的相同系统管理的内存中,C#代码是用户编写的(代码托管),而另一个表示存在于另一个单独处理的内存空间中(本机代码)。数据可以在这两个内存空间之间移动,但是每次这种移动都会导致额外的CPU开销和可能的额外内存分配。
这种效果通常称为跨越本机-托管的桥接。如果发生这种情况,就可能会为对象的数据生成额外的内存分配,以便垮桥复制,这需要垃圾收集器最终执行一些内存自动清理操作。本文不细讲。目前只要知道,有许多微妙的方式会意外地触发这种额外的开销,对GameObject的简单空引用检查就是其中之一:
cs
if(gameObject != null){
//对gameObject做一些事情
}
另一种方法是System.Object.ReferenceEquals(),它生成功能相当的输出,起运行速度大约是原来的两倍(尽管它确实稍微混淆了代码的用途)。
cs
if (!System.Object.ReferenceEquals(gameObject, null))
{
//对gameObject做一些事情
}
这既适用于GameObject,也适用于MonoBehaviour,还适用于其他Unity对象,这些对象既有原生的也有托管的表现形式,比如WWW类。然而,一些基本测试显示任何一个空引用检查方法仍只消耗纳秒。因此,除非执行大量的空引用检查,否则最多只能获得很少的好处。然而,这是一个值得在未来记住的警告,因为它会经常出现。
二、避免从GameObject取出字符串属性
通常,从对象中检索字符串属性与检索C#中的任何其他引用类型属性是相同的;这种检索应该不增加内存成本。然而,从GameObject中检索字符串属性是另一种意外跨越本机-托管桥接的微妙方式。
GameObject中受此行为影响的两个属性是tag和name。因此,在游戏过程中使用者两种属性是不明智的,应该只在性能无关紧要的地方使用它们,比如编辑器脚本。然而,Tag系统通常用于对象的运行时标识,这对于某些团队来说是一个重要问题。
例如,下面的代码会在循环的每次迭代中导致额外的内存分配:
cs
for(int i= 0; i < listOfObjects.Count; i++)
{
if (listOfObjects[i].tag == "player")
{
//对这个对象做一些事
}
}
根据对象的组件和类类型来标识对象,以及标识不涉及字符串对象的值,这通常是一种更好的实践,但有时会陷入困境。也许刚开始时并不知道,我们继承了别人的代码库,或者把它当作一种变通方法。假设出于某种原因,标记系统出了问题,我们希望避免本地-托管桥接的开销成本。
幸运的是,tag属性最常用于比较,而GameObject提供了**CompareTag()**方法,这是比较tag属性的另一种方法,它完全避免了本机-托管的桥接。
用CompareTag()方法来代替上面的直接比较方法,通过profiler分析得到结论:处理时间减少了一半,且由于不会导致内存分配,因此也不会导致垃圾回收。
这说明,必须尽可能避免访问name和tag属性。如果需要对标记进行比较,应该使用CompareTag()。但是,name属性没有对应的方法,因此尽可能使用tag属性。
提示:向CompareTag()传递字符串不会导致运行时内存分配,因此应用程序在初始化期间分配这样的硬编码字符串,在运行时只是引用它们。
三、使用合适的数据结构
C#System.Collections名称空间中提供了许多不同的数据结构,我们不应该反复使用相同的名称空间。软件开发中一个常见的性能问题是简单地为了便利而使用不适当的数据结构来解决问题。最常见的两种数据结构是List<T>和Dictionary<K,V>。
如果想遍历一边对象,最好用列表,因为它实际上是一个动态数组,对象、引用在内存中彼此相邻,因此迭代导致的缓存丢失最小。如果两个对象相互关联,且希望快速获取、插入或删除这些关联,最好使用字典。例如,可以将一个关卡编号与特定的场景文件相关联,或者将一个代表角色不同身体部分的enum与这些身体部分的Collider组件相关联。
然而,数据结构通常需要同时处理两种情况:快速找出哪个对象映射到另一个对象,同时还能遍历组。通常,该系统的开发人员使用字典,然后对其进行迭代。然而,与遍历列表相比,这个过程非常慢,因为它必须检查字典中每个可能的散列,才能对其进行完全遍历。
在这些情况下,最好在列表和字典中存储数据,以便更好支持这种行为。这需要额外的内存开销来维护多个数据结构,插入和删除操作需要每次从数据结构中添加和删除对象,但迭代列表的好处和迭代字典形成鲜明的对比。
四、避免运行时修改Transform的父节点
在Unity的早期版本中,Transform组件的引用通常是在内存中随机排序的。这意味着在多个Transform上的迭代是相当慢的,因为存在缓存丢失的可能性。这样做的好处是,修改GameObject的父节点为另一个对象并不会造成显著的性能下降,因为Transform操作起来很想堆数据结构,插入和删除的速度相对较快。这种行为是我们无法控制的,所以只能接受。
但是,在Unity5.4以后,Transform组件的内存分布发生了很大变化。从那时起,Transform组件的父子关系操作起来更像动态数组,因此Unity尝试将所有共享相同元素的Transform按顺序存储在预先分配的内存缓冲区的内存中,并在Hierarchy窗口中根据父元素下面的深度进行排序。这种数据结构允许在整个组中进行更快的迭代,这对物理和动画等多个子系统特别有利。这种变化的缺点是,如果将一个GameObject的父物体重新指定为一个对象,父对象必须将新的子对象放入预先分配的内存缓冲区中,并根据新的深度对这些Transform排序,另外,如果父对象没有预先分配足够的空间来容纳新的子对象,就必须扩展缓冲区,以便以深度优先的顺序容纳新的子对象及其所有的子对象。对于较深、复杂的GameObject结构,这可能需要一些时间来完成。
通过GameObject.Instantiate()实例化新的GameObject时,它的一个参数是希望将GameObject设置为其父节点的Transform,默认值是null,把Transform放在Hierarchy窗口的根元素下。在Hierarchy窗口根元素下的所有Transform都需要分配一个缓冲区来存储它当前的子元素以及以后可能添加的子元素(子Transform元素不需要这样做)。但是,如果在实例化之后立即将Transform的父元素重新修改为另一个元素,它将丢弃刚才分配的缓冲区!为了避免这种情况,应该将父Transform参数提供给GameObject.Instantiate()调用,它跳过了这个缓冲区分配步骤。
另一种降低这个过程成本的方法是让根Transform在需要之前就预先分配一个更大的缓冲区,这样就不需要在同一帧中扩展缓冲区,给它重新制定另一个GameObject导缓冲区中。这可以通过修改Transform的HierarchyCapacity属性来实现。如果能够估计父元素包含的子Transform的数量,就可以节省大量不必要的内存分配。