搜索Unity

使用C#数据结构和Unity API进行最佳工作

Last updated: December 2018

本页内容简介:利用Unity API和数据结构优化工作的解决方案。涵盖的挑战包括利用粒子系统API进行递归迭代、频繁分配内存、返回数组的API、选择不正确的数据结构以及误用字典造成的大量开销。

这些技巧中的大部分会带来额外的复杂性,进而导致更高的维护开销,还有可能造成更多错误。因此,在运用这些解决方案之前,请首先进行代码性能分析。

所有这些技巧都来自Unite演讲,深入挖掘Unity:提高性能的技巧。要了解Ian讲述的面向数据的设计(考虑了Unity架构演化)的更新技巧,请参阅不断演化的最佳实践

Unity API

问题:利用粒子系统API进行递归迭代:

当您调用粒子系统的某个主要API时 - 如Start、Stop、IsAlive(),默认情况下,它会进行递归迭代,遍历粒子系统层级视图中的所有子对象:

  • API查找粒子系统变换中的所有子对象,对每一次变换调用GetComponent,如果存在粒子系统,则调用相应的内部方法。
  • 如果这些子变换有自己的子对象,则会一直递归到变换层级视图底部 – 逐一处理每一个子对象、孙对象等。如果变换层级视图很深,这会产生问题。

解决方案:

这些API带有withChildren参数(默认值为true)。将其设置为false可消除递归行为。将withChildren设置为false时,则只会更改直接调用的粒子系统。

问题:同时开始和停止多个粒子系统

美术师经常制作要分布到一系列子变换中的粒子系统的视觉效果,您可能希望同时开始和停止所有这些粒子系统。

解决方案:

通过在初始化时调用列表GetComponent创建缓存粒子系统列表的MonoBehaviour。然后,当需要更改粒子系统时,依次对每个粒子系统调用Start、Stop等,并确保将false作为withChildren参数值传递。

问题:频繁分配内存

当C#闭包隐藏本地变量时,C#运行时必须在堆上分配一个引用来记录变量。在Unity版本5.4到2017.1中,当调用某几个粒子系统API时,它们会在内部使用闭包。在这些Unity版本中,对Stop和Simulate的所有调用都会引发内存分配,即便粒子系统已停止。

解决方案:

所有粒子系统API(就像大部分Unity公共API一样)只不过是位于执行API实际工作的内部函数外围的C#包装器函数。这些内部函数中有一部分是纯C#函数,但大部分最终会调用Unity引擎的C++底层核心函数。对于粒子系统API,为了方便起见,所有内部API的名称开头为“Internal_”,后跟公共函数的名称,如:

work-optimally-with-Unity-APIs-table-graph

...等等。

最好不要使用Unity的公共API调用其内部helper函数,您可以编写一个扩展方法,或通过Reflection寻址内部函数并缓存引用,以供随后使用。下面是相关的函数签名:

work-optimally-with-Unity-APIs-functions-signatures

除第一个参数外,其他所有参数的含义与公共API的参数含义相同。第一个参数用于引用要停止或模拟的粒子系统。

问题:返回数组的API

无论何时,只要您访问的Unity API返回数组,它就会为该数组分配一个新副本。不管是使用函数,还是使用属性,只要返回值是数组,都会发生这种情况。常见的示例有Mesh.vertices:如果访问此属性,它就会产生网格顶点的新副本。还有Input.touches,它会产生用户在当前帧中执行的所有触控的副本。

解决方案:

现在,许多Unity API都提供了非分配版本,您应该使用这些版本。例如,最好不要使用Input.touches,而应该使用Input.GetTouch和Input.touchCount。在版本5.3及之前的版本中,已经引入了所有物理查询API的非分配版本。对于2D应用程序,所有Physics2D查询API也有非分配版本。要阅读关于Unity物理API的非分配版本的详细信息,请点击这里。

最后,如果您使用GetComponentsGetComponentsInChildren,现在提供了接受通用的模板化列表的版本。这些API将使用GetComponents调用的结果填写列表。这并不是纯粹的非分配版本:如果GetComponents调用的结果超出列表容量,列表会调整大小。但是,如果您重用或池化列表,那么至少在应用程序生命周期过程中,分配频率会降低。

数据结构的优化利用

问题:选择不正确的数据结构

不要为了方便而选择不合适的数据结构,您应该选择在性能特征方面与编写的算法和游戏系统最匹配的数据结构。

解决方案:

在数组或列表中索引非常高效:它只需要在底层执行以固定时间简单的加法运算。因此,在数据或列表中进行随机索引或迭代的开销极低。因此,如果您要在每个帧中对一系列对象进行迭代,最好使用数据或列表。

如果要执行固定时间的加减法,您可能更愿意使用字典或哈希集(下文有详细介绍)。

如果以键/值方式关联数据,也就是其中某一段数据单向地与另一段数据相关联,那么应该使用字典。

哈希集和字典的详细信息:这些数据结构都需要利用哈希表。请注意,哈希表有许多存储桶,每一个存储桶基本上就是一个列表,其中保存具有特定哈希代码的所有值。在C#中,这种哈希代码由GetHashCode方法提供。一般来说,由于一个指定存储桶中的值的数量大大少于哈希集或字典的总大小,所以,与从列表或数组中添加或删除值相比,从存储桶中添加或删除值更接近于固定时间。精确区别取决于哈希表的容量与其中存储的数据项的数量。

同理,检查哈希集和字典中是否存在某个指定值需要的成本也很低:如果数量相对较小,核对存储桶中值的数量(代表值的哈希代码)的速度非常快。

问题:误用字典造成大量开销

如果要遍历每个帧的数据项对,为了方便,用户通常会使用字典。这样做的问题是您需要遍历哈希表。这意味着要遍历哈希表中的每一个存储桶,而不管它是否包含值。所以,这会造成相当大的开销,特别是当要遍历的哈希表中只存储了少量值时。

解决方案:

您应该创建一个结构或元组,然后存储这些结构或元祖(其中包含数据关系)的列表或数组,最后遍历这些列表/数组,而不要遍历字典。

在Ian大约14:25的讲解中,他透露了关于如何以及何时使用InstanceID键字典来降低开销的小技巧。

问题:面对多方面的问题时怎么办?

当然,在实际情况下,很多问题会产生多方重叠的要求,一种数据结构难以满足所有需求。

一个常见的示例是Update Manager。这是一种架构编程模式,其中一个对象(通常是MonoBehaviour)将Update回调分发给游戏中的不同系统。当系统希望接收Update时,它们会通过Update Manager 对象进行订阅。对于此类系统,您可能需要低消耗迭代、固定时间插入以及固定时间重复校验。

解决方案:使用两种数据结构

  • 维护一个用于迭代的列表或数组。
  • 在更改列表之前,使用哈希集(或另一种索引集)来确保添加或删除的项目实际存在。
  • 如果关心删除,请考虑使用链接列表或侵入式链接列表实现(注意,这会消耗较多内存,且迭代成本也略高)。
更多资源

您喜欢本文吗?请告诉我们!

喜欢。继续发送 还行。有待改进
明白了

我们使用Cookie来确保在我们的网站上为您提供最佳体验。有关更多信息,请访问我们的Cookie政策页面