版本:Unity 6 (6000.0)
语言:英语
通用优化
资源加载指标

特殊优化

虽然上一节描述了适用于所有项目的优化,但本节详细介绍了在收集性能分析数据之前不应应用的优化。这可能是因为优化实施起来很费力,可能会为了性能而损害代码的整洁性或可维护性,或者可能会解决仅在特定规模下才会出现的问题。

多维数组与锯齿数组

正如这篇文章所述StackOverflow 文章,与多维数组相比,迭代锯齿数组通常效率更高,因为多维数组需要函数调用。

注意

  • 这些是数组的数组,声明为type[x][y],而不是type[x,y].)

  • 这可以通过使用 ILSpy 或类似工具检查访问多维数组生成的 IL 来发现。)

在 Unity 5.3 中进行性能分析时,对 100x100x100 的三维数组进行 100 次完全顺序迭代,产生了以下计时,这些计时是在测试运行 10 次后取的平均值。

数组类型 总时间(100 次迭代)
一维数组 660 毫秒
锯齿数组 730 毫秒
多维数组 3470 毫秒

访问多维数组与一维数组的成本差异可以看出额外函数调用的成本,而访问锯齿数组与一维数组的成本差异可以看出迭代非紧凑内存结构的成本。

如上所示,额外函数调用的成本远大于使用非紧凑内存结构带来的成本。

对于对性能要求很高的操作,建议使用一维数组。对于所有其他需要多维数组的情况,使用锯齿数组。不应使用多维数组。

粒子系统池

对粒子系统进行池化时,请注意它们至少消耗 3500 字节的内存。内存消耗会根据粒子系统一个组件,通过在场景中生成和动画处理大量小的 2D 图像来模拟液体、云和火焰等流体实体。 更多信息
参见 词汇表
上激活的模块数量而增加。当粒子系统被停用时,此内存不会被释放;它只会在它们被销毁时释放。

从 Unity 5.3 开始,现在可以在运行时操作大多数粒子系统设置。对于必须池化大量不同粒子效果的项目,将粒子系统的配置参数提取到数据载体类或结构中可能更有效。

当需要粒子效果时,一池“通用”粒子效果可以提供所需的粒子效果对象。然后可以将配置数据应用于该对象以实现所需的图形效果。

这比尝试池化给定场景场景包含游戏环境和菜单。将每个唯一的场景文件视为一个独特关卡。在每个场景中,您放置环境、障碍物和装饰,基本上是设计和构建游戏的各个部分。 更多信息
参见 词汇表
中使用的所有可能的粒子系统变体和配置要节省很多内存,但需要大量的工程工作才能实现。

更新管理器

在内部,Unity 会跟踪对回调感兴趣的对象列表,例如UpdateFixedUpdateLateUpdate。这些被维护为侵入式链接列表以确保列表更新以恒定时间发生。当 MonoBehaviours 分别被启用或禁用时,它们会被添加到这些列表中或从这些列表中移除。

虽然将适当的回调添加到需要它们的 MonoBehaviours 很方便,但这会随着回调数量的增加而变得越来越低效。从原生代码调用托管代码回调存在很小的但很显著的开销。这会导致在调用大量每帧方法时帧速率下降,以及在实例化包含大量 MonoBehaviours 的预制件一种资产类型,允许您存储一个完整的 GameObject,包括组件和属性。预制件充当模板,您可以从中在场景中创建新的对象实例。 更多信息
参见 词汇表
时实例化时间下降(注意:实例化成本是由于在预制件中的每个组件上调用 Awake 和 OnEnable 回调的性能开销)。

当具有每帧回调的 MonoBehaviours 的数量增长到数百或数千时,最好移除这些回调,而是让 MonoBehaviours(甚至标准 C# 对象)附加到全局管理器单例。然后,全局管理器单例可以将UpdateLateUpdate 和其他回调分发给感兴趣的对象。这还有助于允许代码在原本会无操作的情况下智能地取消订阅回调,从而减少每帧必须调用的函数总数。

通常通过消除很少执行的回调来实现最大的节省。考虑以下伪代码

void Update() {
    if(!someVeryRareCondition) { return; }
// … some operation …
}

如果存在大量具有类似上述 Update 回调的 MonoBehaviours,那么运行 Update 回调所消耗的大部分时间都花在了为立即退出的 MonoBehaviour 执行在原生代码域和托管代码域之间切换上。如果这些类改为仅在someVeryRareCondition 为真时订阅全局 Update Manager,并在之后取消订阅,那么将在代码域切换和罕见条件的评估上节省时间。

在更新管理器中使用 C# 委托

使用纯 C# 委托来实现这些回调很诱人。但是,C# 的委托实现针对低订阅和取消订阅率以及低回调数量进行了优化。C# 委托在每次添加或删除回调时都会完全深拷贝回调列表。大量的回调列表,或者在单帧期间订阅/取消订阅的大量回调会导致内部Delegate.Combine 方法中的性能峰值。

对于添加/删除频繁发生的情况,请考虑使用为快速插入/删除而设计的 数据结构,而不是委托。

加载线程控制

Unity 允许开发人员控制用于加载数据的后台线程的优先级。这在尝试将 AssetBundles 流式传输到磁盘后台时尤其重要。

主线程和图形线程的优先级均为ThreadPriority.Normal - 任何具有更高优先级的线程都会抢占主线程/图形线程并导致帧速率出现卡顿,而优先级较低的线程则不会。如果线程具有与主线程相同的优先级,则 CPU 会尝试平均分配线程时间,如果多个后台线程执行繁重的操作(例如 AssetBundle 解压缩),这通常会导致帧速率卡顿。

目前,此优先级可以在三个地方进行控制。

首先,资源加载调用的默认优先级(例如Resources.LoadAsyncAssetBundle.LoadAssetAsync)取自Application.backgroundLoadingPriority 设置。如文档所述,此调用还会限制主线程花费在集成资源上的时间(注意:大多数类型的 Unity 资源必须“集成”到主线程上。在集成期间,资源初始化完成,并执行某些线程安全操作。这包括脚本回调调用,例如 Awake 回调。有关更多详细信息,请参见“资源管理”指南),以限制资源加载对帧速率的影响。

其次,每个异步资源加载操作以及每个 UnityWebRequest 请求都返回一个AsyncOperation 对象来监控和管理该操作。此AsyncOperation 对象公开了一个priority 属性,该属性可用于调整单个操作的优先级。

最后,WWW 对象(例如从调用WWW.LoadFromCacheOrDownload 返回的那些对象)公开了一个threadPriority 属性。重要的是要注意,WWW 对象不会自动将Application.backgroundLoadingPriority 设置用作它们的默认值 - WWW 对象始终默认为ThreadPriority.Normal

重要的是要注意,用于解压缩和加载数据的底层系统在这些 API 之间有所不同。Resources.LoadAsyncAssetBundle.LoadAssetAsync 由 Unity 的内部 PreloadManager 系统操作,该系统管理自己的加载线程并执行自己的速率限制。UnityWebRequest 使用自己的专用线程池。WWW 每次创建请求时都会生成一个全新的线程。

虽然所有其他加载机制都有一个内置的排队系统,但 WWW 没有。在大量压缩的 AssetBundles 上调用WWW.LoadFromCacheOrDownload 会生成相同数量的线程,然后这些线程会与主线程争夺 CPU 时间。这很容易导致帧速率卡顿。

因此,当使用 WWW 加载和解压缩 AssetBundles 时,建议的做法是为创建的每个 WWW 对象设置一个合适的threadPriority 值。

大量对象移动和剔除组

如变换操作部分所述,移动大型变换层次结构的 CPU 成本相对较高,因为更改消息的传播。但是,在实际开发环境中,通常无法将层次结构折叠为少量游戏对象Unity 场景中的基本对象,可以代表角色、道具、场景、摄像机、路标等等。游戏对象的功能由附加到它的组件定义。 更多信息
参见 词汇表
.

同时,良好的开发实践是只运行足以保持游戏世界可信度的行为,同时消除用户不会注意到的行为 - 例如,在一个包含大量角色的场景中,始终最佳的做法是只运行屏幕上角色的网格蒙皮和动画驱动的变换移动。没有理由浪费 CPU 时间来计算屏幕外角色的纯粹视觉元素视觉树的节点,它实例化或派生自 C# VisualElement 类。您可以设置外观,定义行为,并将其作为 UI 的一部分显示在屏幕上。 更多信息
参见 词汇表
模拟。

这两个问题都可以通过 Unity 5.1 中首次引入的 API 来巧妙地解决:剔除组.

与其直接操作场景中的大量游戏对象,不如将系统改为操作 CullingGroup 中一组 BoundingSpheres 的 Vector3 参数。每个 BoundingSphere 作为单个游戏逻辑实体的世界空间位置的权威存储库,并在实体移动到 CullingGroup 的主 摄像机一个组件,它会在您的场景中创建特定视角的图像。输出结果会绘制到屏幕上或作为纹理捕获。 更多信息
参见 词汇表
的视锥体附近/内部时收到回调。然后,可以使用这些回调来激活/停用代码或组件(例如动画器),这些代码或组件控制仅在实体可见时应运行的行为。

减少方法调用开销

C# 的字符串库为在简单的库代码中添加额外的方法调用所带来的成本提供了一个很好的案例研究。在关于内置字符串 API String.StartsWithString.EndsWith 的部分中,提到了即使在抑制了不需要的区域性强制转换的情况下,手工编码的替换也比内置方法快 10-100 倍。

造成这种性能差异的关键原因仅仅是在紧密的内部循环中添加额外方法调用所带来的成本。调用的每个方法都必须定位内存中方法的地址并将另一个帧推送到堆栈中。这两项操作都不是免费的,但在大多数代码中,它们都很小,可以忽略。

但是,在紧密的循环中运行小方法时,引入额外方法调用所带来的开销可能会变得很大,甚至占主导地位。

考虑以下两个简单方法。

示例 1

int Accum { get; set; }
Accum = 0;

for(int i = 0; i < myList.Count; i++) {
    Accum += myList[i];
}

示例 2

int accum = 0;
int len = myList.Count;

for(int i = 0; i < len; i++) {
    accum += myList[i];
}

这两个方法都计算 C# 通用 List<int> 中所有整数的总和。第一个示例更像是“现代 C#”,因为它使用自动生成的属性来保存其数据值。

从表面上看,这两段代码似乎等效,但当分析代码以查找方法调用时,差异就显而易见了。

示例 1

int Accum { get; set; }
Accum = 0;

for(int i = 0;
       i < myList.Count;    // call to List::getCount
       i++) {
    Accum       // call to set_Accum
+=      // call to get_Accum
myList[i];  // call to List::get_Value
}

因此,每次循环执行时,都会进行四次方法调用

  • myList.Count 调用 Count 属性的 get 方法
  • 必须调用 Accum 属性的 getset 方法
  • get 用于检索 Accum 的当前值,以便将其传递给加法运算
  • set 用于将加法运算的结果分配给 Accum
  • [] 运算符调用列表的 get_Value 方法来检索列表中特定索引处项目的 value。

示例 2

int accum = 0;
int len = myList.Count;

for(int i = 0;
    i < len; 
    i++) {
    accum += myList[i]; // call to List::get_Value
}

在这个第二个示例中,对 get_Value 的调用仍然存在,但所有其他方法要么已消除,要么不再每次循环迭代都执行一次。

  • 由于 accum 现在是一个原始值而不是属性,因此不需要进行方法调用来设置或检索其值。

  • 由于 myList.Count 在循环运行期间被认为不会发生变化,因此对其访问已移出循环的条件语句,因此它不再在每次循环迭代的开始处执行。

这两个版本的计时揭示了从这段特定代码中删除 75% 的方法调用开销的真正好处。在现代台式机上运行 100,000 次时

  • 示例 1 需要 324 毫秒来执行
  • 示例 2 需要 128 毫秒来执行

这里的主要问题是,Unity 几乎不执行任何方法内联,如果有的话。即使在 IL2CPPUnity 开发的一种脚本后端,您可以在构建某些平台的项目时将其用作 Mono 的替代方案。 更多信息
参见 词汇表
中,许多方法目前也无法正确内联。对于属性尤其如此。此外,virtual 和接口方法根本无法内联。

因此,在源 C# 中声明的方法调用很可能最终会在最终的二进制应用程序中生成方法调用。

微不足道的属性

Unity 在其数据类型上提供了许多“简单”常量,以方便开发人员使用。但是,鉴于上述情况,需要注意的是,这些常量通常实现为返回常量值的属性。

Vector3.zero 的属性主体如下所示

get { return new Vector3(0,0,0); }

Quaternion.identity 非常类似

get { return new Quaternion(0,0,0,1); }

虽然访问这些属性的成本通常与它们周围的实际代码相比微不足道,但当它们每帧执行数千次(甚至更多次)时,它们会造成很小的差异。

对于简单的原始类型,请使用 const 值。Const 值在编译时内联——对 const 变量的引用将被其值替换。

注意:由于对 const 变量的每个引用都被其值替换,因此不建议将长字符串或其他大型数据类型声明为 const。这会导致最终二进制文件的大小因最终指令代码中的所有重复数据而无谓地膨胀。

const 不适用的地方,请使用 static readonly 变量。在一些项目中,甚至 Unity 的内置微不足道的属性也被 static readonly 变量取代,从而提高了性能。

微不足道的方法

微不足道的方法比较棘手。能够一次声明功能并在其他地方重复使用它非常有用。但是,在紧密的内部循环中,可能需要偏离良好的编码实践,而是“手动内联”某些代码。

一些方法可以完全消除。考虑 Quaternion.SetTransform.TranslateVector3.Scale。这些方法执行非常简单的操作,可以替换为简单的赋值语句。

对于更复杂的方法,请权衡手动内联的性能证据与维护更高性能代码的长期成本。

通用优化
资源加载指标