特殊优化(官方)
发表于:2025-09-09 | 分类: unity
字数统计: 2.8k | 阅读时长: 10分钟 | 阅读量:

一般优化介绍了适用于所有项目的优化方法,而本节将详细介绍在收集性能分析数据之前不应应用的优化方法。这可能是因为这些优化实施起来需要耗费大量精力,可能为了性能而牺牲代码的整洁性或可维护性,或者可能解决的是仅在特定规模下才会出现的问题。

多维数组([,]) VS 锯齿数组([][])

迭代锯齿数组通常比迭代多维数组更有效,因为多维数组需要调用函数。

注意:

  • 它们是数组的数组,并且被声明为 Type[x][y] ,而不是 Type[x,y]
  • 这可以通过检查访问多维数组(使用 ILSpy 或类似工具)生成的 IL 来发现。

当在Unity 5.3中进行分析时,在一个三维100x100x100数组上进行100次完整的顺序迭代会产生以下时间,这是在10次测试运行中获得的平均值:

数组类型 总时长(100次迭代)
[]一维数组 660ms
[][]锯齿数组 730ms
[,]多维数组 3470ms

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

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

对于对性能非常敏感的操作,建议使用一维数组。对于需要多维数组的所有其他情况,请使用锯齿数组。不应该使用多维数组。

粒子系统池化

当池化粒子系统时,要注意它们至少消耗 3500 bytes 的内存。内存消耗根据粒子系统上激活的模块数量增加。当粒子系统停用时,这些内存不会被释放;只有当它们被摧毁时才会释放出来。

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

当需要一个粒子效果时,一个“通用”粒子效果池可以提供必要的粒子效果对象。然后可以将配置数据应用于对象,以实现所需的图形效果。这比尝试在给定场景中使用所有可能的粒子系统变体和配置更节省内存,但需要大量的工程努力才能实现。

Update管理器

在内部,Unity跟踪对其回调感兴趣的对象列表,如 Update fixeduupdateLateUpdate 。它们被维护为侵入式链表,以确保在固定时间内进行列表更新。当 monobehavior 被启用或禁用时,它们分别被添加到/从这些列表中删除。

虽然简单地将适当的回调添加到需要它们的 monobehaviour 中是很方便的,但随着回调数量的增加,这将变得越来越低效。从本机代码调用托管代码回调的开销很小,但很重要。这导致在调用大量的每帧方法时降低帧时间,并且在实例化包含大量 monobehaviour 的prefab时降低实例化时间(注意:实例化成本是由于在每个组件上调用Awake和OnEnable回调的性能开销)。

当每帧回调的monobehavior数量增加到数百或数千个时,删除这些回调并将 monobehavior(甚至是标准c#对象)附加到全局管理器单例是有利的。然后,全局管理器单例可以将Update、LateUpdate和其他回调函数分发给感兴趣的对象。这样做还有一个额外的好处,即允许代码在回调不执行操作时巧妙地退订回调,从而减少每帧必须调用的函数的绝对数量。

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

1
2
3
4
void Update() {
if(!someVeryRareCondition) { return; }
// … some operation …
}

如果有大量的带有类似于上面的Update回调的 MonoBehaviour ,那么运行Update回调所消耗的大量时间将用于在 MonoBehaviour 执行的 Native 代码域和 managed 代码域之间切换,然后立即退出。如果这些类只在someVeryRareCondition为真时才订阅全局Update Manager,然后取消订阅,那么代码域切换和罕见条件的计算都将节省时间。

使用C# Delegate

使用普通的c#委托来实现这些回调是很诱人的。然而,c#的委托实现针对低频订阅&退订以及低回调次数。C# delegate在每次添加/删除回调时执行回调列表的 full deep-copy。大的回调列表,或在单个帧中订阅/取消订阅的大量回调会导致内部Delegate的性能峰值(Delegate.Combine)。

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

加载线程控制

Unity允许开发者控制用于加载数据的后台线程的优先级。当试图在后台将AssetBundles流式传输到磁盘上时,这一点尤为重要。

主线程和图形线程的优先级都是 ThreadPriority.Normal - 任何具有较高优先级的线程都会抢占主/图形线程并导致帧率中断,而具有较低优先级的线程则不会。如果线程具有与主线程相同的优先级,CPU会尝试为线程提供相同的时间,如果多个后台线程正在执行繁重的操作,例如AssetBundle解压缩,则通常会导致帧率卡顿。

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

首先,Asset加载调用(如 Resources.LaodAsync AssetBundle.LoadAssetAsync)的默认 priority 采用来自 Application.backgroundLoadingPriority 设置。如文档所述,此调用还限制了主线程集成资产所花费的时间(注意:大多数类型的Unity资源必须“集成”到主线程上。在集成期间,资产初始化完成,并执行某些线程安全的操作。这包括脚本回调调用,比如Awake回调。详情请参阅“资源管理”指南。),以限制资产加载对帧时间的影响。

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

最后,WWWW 对象,例如调用 WWW.LoadFromCacheOrDownload 返回的对象,公开了一个 threedPriority 属性。值得注意的是,WWWW 对象不会自动使用 Application.backgroundLoadingPriority 作为默认值,而总是默认为 ThreadPriority.Normal

需要注意的是,底层系统使用这些api来 decompress 和 load 数据通常是不同的。Resources.LoadAsyncAssetBundle.LoadAssetAsync 由Unity内部的 PreloadManager 系统操作,该系统管理自己的加载线程并执行自己的速率限制。UnityWebRequest 使用自己的专用线程池。WWW 在每次创建请求时都会生成一个全新的线程。

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

因此,当使用WWW加载和解压缩AssetBundles时,为创建的每个WWW对象的threadPriority设置一个适当的值被认为是最佳实践。

大量对象移动 & Culing Group

如在 Transform 操作一节中提到的,由于改动消息的传播,移动大型Tansform层次结构具有相对较高的CPU成本。然而,在真实的开发环境中,通常不可能将层次结构分解为适当数量的gameobject。

与此同时,只运行足够的行为来维持游戏世界的可信度,同时消除用户不会注意到的行为,这是一个很好的开发实践。例如,在一个有大量角色的场景中,只运行 Mesh-skinningAnimation-driven Transform 运动对于屏幕上的角色总是更理想。我们没有理由浪费CPU时间去计算屏幕外角色模拟的纯粹视觉元素。

这两个问题都可以通过首先引入的API巧妙地解决: https://docs.unity3d.com/2019.4/Documentation/Manual/CullingGroupAPI.html

而不是直接操作场景中的一大组 GameObject,改变系统来操作 CullingGroup 中的一组 BoundingSpheres 的Vector3参数。每个 BoundingSphere 都是一个 GameObject 实体的世界空间位置的权威存储库,当实体移动到 CullingGroup 主摄像机的视锥内时,它会接收回调。这些回调可以用来激活/取消激活代码或组件(如Animators),这些代码或组件控制的行为应该只在实体可见时运行。

减少方法调用开销

c#的 string library 提供了一个很好的案例来研究在简单的库代码中添加额外的方法调用的代价。在关于内置 string api的部分中提到的 String.StartsWith String.EndsWith ,最后,有人提到手工编码的替换比内置方法快10-100倍,即使在抑制了不需要的本地化强制转换的情况下也是如此。

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

然而,当在紧密循环中运行小方法时,引入额外方法调用所增加的开销可能会变得非常大——甚至占主导地位。

例如:

1
2
3
4
5
6
7
// 324ms
int Accum { get; set; }
Accum = 0;

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

再如:

1
2
3
4
5
6
7
// 128ms
int accum = 0;
int len = myList.Count;

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

这两种方法都计算c#泛型 List<int> 中所有整数的和。第一个例子有点 “modern c#”,因为它使用自动生成的属性来保存其数据值。虽然从表面上看这两段代码是相等的,但是当分析代码的方法调用时,差异是明显的。

每次循环执行都有四个方法被调用:

1
2
3
4
5
6
7
8
9
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
}

这里的主要问题是,如果有的话,Unity执行的方法内联很少。即使在IL2CPP下,许多方法目前也不能正确内联。对于属性来说尤其如此。此外,虚方法和接口方法根本不能内联。因此,在c#源代码中声明的方法调用很可能最终在最终的二进制应用程序中产生一个方法调用。

上一篇:
异步编程
下一篇:
一般优化(官方)