franciscochonge 发表于 2022-11-18 07:49

Unity性能优化之脚本策略(2)

大纲


[*]缓存组件引用
[*]共享计算输出
[*]Update、Coroutines和InvokeRepeating
缓存组件引用

在Unity开发过程中,反复计算一个值是非常常见的错误,特别是在使用GetComponent方法时。例如,下面的脚本试图检查一个health值,如果该值低于0,则触发死亡动画
void CheckDamage(){
RigidBody rigidbody = GetComponent<RigidBody>();
Collider collider = GetComponent<Collider>();
AIControllerComponent ai = GetComponent<AIControllerComponent>();
Animator anim = GetComponent<Animator>();
if(GetComponent<HealthComponent>().health < 0){
    rigidbody.enable = false;
    collider.enable = false;
    ai.enable = false;
    anim.SetTrigger("death");
}
}
每次执行这个优化不良的方法的时候,都将重新获得5个不同组件的引用。这对CPU的使用而言很不友好。如果是在Update()期间调用,将是个非常严重的问题。这种编码风格看起来是无害的,但会导致很多长期问题,运行时的工作也不少,几乎是没有什么好处。
除了它会消耗CPU,它还会消耗少量的内存空间(根据Unity的版本、平台和段申请,每次只有32位或64位),来缓存这些组件,长期或者频繁使用,肯定是有可能触发GC的。所以,强烈建议在初始化或者变动的时候获取引用,并保存起来,直到不需要使用它们为止:
RigidBody _rigidbody;
Collider _collider;
AIControllerComponent _ai;
Animator _anim;
HealthComponent _health;

void Start(){
_rigidbody = GetComponent<RigidBody>();
_collider = GetComponent<Collider>();
_ai = GetComponent<AIControllerComponent>();
_anim = GetComponent<Animator>();
_health = GetComponent<HealthComponent>();
}
void CheckDamage(){
if(_health.health < 0){
    _rigidbody.enable = false;
    _collider.enable = false;
    _ai.enable = false;
    _anim.SetTrigger("death");
}
}
共享计算输出

让多个对象共享计算结果,显然可以节省性能开销,当然,只有这些计算都生成相同的结果,才有效。这种情形通常比较容易发现,但是重构起来很困难,因此利用这种情况将非常依赖于实现方案。
这些情况包括:

[*]从文件中读取数据
[*]解析数据(XML、Json、Proto、Obj等)
[*]在大列表或者深层的信息字典中查找对象
[*]利用AI计算路径
[*]复杂的数学轨迹
[*]光线追踪等
每当你执行或计算一个昂贵的操作的时候,一定要考虑是否会从多个位置调用它,是否都会得到相同的结果。如果是这样,那么重构就是明智的,仅计算一次结果,然后将其发送给所有需要的对象,以最小化重新计算的量。最大成本也就是牺牲了一些代码的整洁性和传递值的消耗。
还有一种情况需要注意。大家通常很容易养成在基类里隐藏大型复杂函数的习惯,然后定义使用该函数的派生类,完全忘记了该函数的开销,因为我们很少会再次查看里面的代码。这种情况,可以使用UnityProfile来查看该函数的调用次数,如果这是一个性能问题,就马上优化吧。
Update、Coroutines和InvokeRepeating

另外一个很容易养成的习惯是在 Update() 函数中以超出需要的频率重复调用某段代码。例如:
void Update(){
ProcessAI();
}
上面的例子在每一帧调用 ProcessAI() 这个方法,这可能是一个复杂的任务,如果这个方法占用了太多的帧率预算,且任务完成的频率低于没有明显缺陷的每一帧,那么提高性能的一个最好方法就是降低 ProcessAI() 的调用频率:
private float _delay = 0.2f;
private float _timer = 0;
void Update(){
_timer += Time.deltaTime;
if(_timer > _delay){
    ProcessAI();
    _timer = 0;
}
}
这样每秒仅调用 ProcessAI() 方法5次,减少了Update的回调成本,虽然,除了真实调用 ProcessAI() 的时候,Unity都会调用一个“空的Update回调函数”。
我们还可以将其通过协同程序来实现,利用协程的延迟调用属性。协程,通常用于编写短事件序列的脚本,可以是一次性的,也可以是重复的。协程和线程不能混淆,线程是以并发的方式在完全不同的CPU内核上运行,而且多个线程可以同时运行。相反,协程以顺序的方式在主线程上运行,这样在任何时刻都只处理一个协程,每个协程通过yield语句决定何时暂停和继续。下面是协程的实现:
void Start(){
<span class="n">StartCoroutine(ProcessAICoroutine());
}
IEnumerator ProcessAICoroutine(){
while(true){
    ProcessAI();
    yield return new WaitForSceonds(_delay);
}
}
这种写法的好处是,这个协程只调用指定的次数,在调用之前,它一直处于空闲的状态,从而减少对大多数帧的影响。
然而,这种方法也是有缺点的。
首先,与标准函数相比,启动协程会带来额外的开销成本(大概是标准函数的3倍),还会分配一堆内存,将当前的状态存储在内存中,直到下次调用。这种开销并不是一次性的,因为协程会经常不断的调用 yield,这会一次又一次的造成相同的开销成本,所以需要确保降低频率的好处(标准函数)大于此成本。
其次,一旦初始化,协程的运行是独立于 Update() 的,不管组件是否禁用,都将继续调用协程。如果执行大量的 GameObject 构建和析构操作,协程可能会变得很重,很笨拙。
再次,协程会在包含它的 GameObject 变成不活动的那一刻自动停止,不管出于什么原因(无论是它被设置为不活动的还是它的父节点被设置为不活动的)。如果 GameObject 又被重新激活,协程也不会自动重新启动。
最后,将方法转换为协程,看上去可以减少大部分帧中的性能损失,但如果方法中单次计算已经突破了帧率预算,无论怎么减少频率,都将超过预算。
在生成协程时,有几种可用的 yield 类型。WaitForSeconds 容易理解,但要注意,它并不是一个精准的计时器,所以当这个 yield 类型重复执行的时候,可能会存在少量的时间变化。
WaitForSecondsRealTime 与 WaitForSeconds的唯一区别是,它使用的是未缩放的时间。WaitForSeconds会受到Time.timeScale属性的影响,而WaitForSecondsRealTime则不会。所以在项目开发中,要注意选用的类型。
还有 WaitForEndOfFrame,它将在下一个Update结束时继续,还有WaitForFixedUpdate,它在下一个FixedUpdate结束时继续。在Unity5.3之后,还引入了WaitUntil和WaitWhile,在这两个函数中,提供了一个委托函数,协程会根据委托函数的返回(true或false)来暂停和继续。要注意的是,它们会在每个Update结束后进行判断,类似于WaitForEndOfFrame,所以得确保判断的工作执行起来不会太昂贵。
还有,协程总是在调用 yield,所以导致协程很难调试,因为他们不遵循正常的执行流程,在debug调用栈上也没有调用者。如果协程执行复杂的任务,和非常多的子系统进行交互,就会导致一些很难察觉的问题,因为你无法判断他们的触发时机和调用来源,当然,有时候很难重现。如果需要使用协程,最好让它的工作尽可能的简单,且独立于其他的系统。
事实上,如果只是想简单实现一个循环定时调用的功能,可以使用 InvokeRepeating() ,它的建立更简单,开销更小。下面是示例:
void Start(){
InvokeRepeating("ProcessAI", 0, _delay);
}
InvokeRepeating 和协程的一个重要区别是,InvokeRepeating 完全独立于 MonoBehavior 和 GameObject的状态。停止调用InvokeRepeating的两种方法:第一种是调用 CancelInvoke() ,它停止由给定的 MonoBehavior 发起的所有 InvokeRepeating 回调(注意,不能单独停止某一个);第二种是销毁关联的 MonoBehavior 或它的父GameObject。禁止 MonoBehavior 和 GameObject 都不会停止 InvokeRepeating() 。

Baste 发表于 2022-11-18 07:57

Unity的InvokeRepeating尽量不要用,一来它是用字符串传递方法名的,效率很低,但可以用C#的nameof()表达式解决;二来它是用反射实现的,效率也很低。凡是想用InvokeRepeating的时候都尽量用协程来代替,或者在Update()里面用开关+计时器隔一段时间执行一次,效率也比它高。

Mecanim 发表于 2022-11-18 08:06

嗯 请先实际测一下

kyuskoj 发表于 2022-11-18 08:07

携程开销大多数是来自new xxxx的对象,全局缓存

Ylisar 发表于 2022-11-18 08:10

Invoke()效率是不是很低,在有很多个item上面都有Invoke()时会变得非常低下。

franciscochonge 发表于 2022-11-18 08:12

[赞同]学习

闲鱼技术01 发表于 2022-11-18 08:14

“Update()里面用开关+计时器隔”什么意思?我用几十万个对象测试,空的Update()帧数都会下降30%。另外两个帧数都没有察觉到变化。
页: [1]
查看完整版本: Unity性能优化之脚本策略(2)