《Fixing Performance Problems》阅读笔记·一

这是一篇关于Unity代码优化的文章,有一定英语阅读能力的同学建议直接去阅读原文:

Fixing Performance Problems - Unity Learn

下边是我自己加上翻译加上理解的一些记录摘要,不是全文。

当我们的游戏运行时,实际上是CPU在执行各种各样的指令。如果CPU在同一时间有太多的指令需要执行,我们的游戏速度就会下降、掉帧、甚至直接卡屏。这些问题可能来自于我们的苛刻的渲染代码任务,复杂的物理模拟或者太多的动画回调等。这篇文章主要着重于我们写的代码对CPU性能的影响。

我们可以用Unity的Profiler去排查性能问题,用过profiler的同学可能知道,profiler可以观察到每一帧CPU的执行时间,具体可以到每个函数的执行时间。这样我们就能定位到哪些地方消耗了太多的CPU计算。当你某一帧的CPU计算时间甚至已经超出了你的帧时间,那掉帧就是一定的了。

我们首先来简单聊一下,Unity是如何构建并运行我们的游戏的。

当我们构建游戏时,Unity需要将所有内容打包为一个可以直接被目标机器所运行的程序。因为CPU只能运行机器码或本地代码(处理器所支持的特定指令集代码),像C#这种高级语言是不能被CPU直接运行的。因此C#会通过一个转换过程来转化为底层代码,这个转化过程,就叫做编译。

Unity首先会将我们的代码转换为CIL(Common Intermediate Language公共中间语言),CIL能够被相对容易的转化为各个平台的本地代码。然后,CIL会在构建时通过AOT编译(ahead of time compilation 提前编译)或 在目标机器上在代码运行前通过JIT(Just In Time Compilation 即时编译)来完成第二步的处理。通过AOT还是JIT通常取决于目标硬件。

还没有经过编译的代码叫做源代码,很多情况下来说,我们的源代码写的越有效率,越结构良好,那么编译出来的代码也越有效率。不同的源代码在底层所需要执行的指令条数和效率是不同的。比如都是一行代码,乘法要比开平方运算速度要快的多。简单来说就是,一些指令是要比另一些指令要快的。这点说起来就几句话,但深挖起来还是涉及到一些计算机知识的,你看很多时候,我们会用到编程语言提供的基础类库,我们会调用List的插入函数等等,调用底层的快速排序,如果我们不知道它们底层大概的实现原理,时间复杂度是多少,那我们的就可能无限制的调用从而导致代码变得效率低下, 所以我们上学要学算法分析,要学数据结构。在真正的工作中,也许很少有机会让你自己去实现一个数据结构,但是你不知道底层原理,你就没法做扩展和优化。

这里我举个简单的例子,比如在一个List中,删除掉值为0的数据,用来模拟怪物血量为0时,需要从列表中移除已经死亡的怪物。一个简单的写法是

for (int i = monsters.Count - 1; i >= 0; --i)
{
    if (monsters[i].Hp <= 0)
    {
        monsters.RemoveAt(i);
    }
}

你看,这么一个简单的例子已经暗藏玄机了,比如你必须要逆序去遍历整个列表,因为列表删除后,后边的元素会前移,从后往前遍历不会让序号错乱。其次,这个算法的复杂度是多少?假设有M个死亡的怪物,那复杂度其实是O(M*N)的。因为线性表的删除是O(N)的,调用M次就是M*N,实际上通过一次遍历就可以删除所有血量为0的怪物。

int startIndex = 0;
for (int i = 0; i < monsters.Count; ++i)
{
    if (monsters[i].Hp > 0)
    {
        monsters[startIndex++] = monsters[i];
    }
}
monsters.RemoveRange(index, monsters.Count - startIndex);

因为List的Count不可写,所以最后调用系统函数去删除掉最后多余的元素,如果是自己实现的数据结构,或者自己记录了有多少个真实怪物,则最后一步就不需要

接着说回文章,我们的源代码编译为CIL以后,也被叫做托管代码,当我们的托管代码被编译为本地代码时,实现了一个叫做托管运行时的模块(Managed Runtime),它用来管理我们托管代码的内存分配,比如垃圾回收机制,以及在遇到问题时抛出异常,而不是直接让设备挂掉。

因此CPU会在托管代码和本地代码之间做一些信息传递。比如当我们从托管代码块传递数据给本地代码块时,CPU需要额外的工作将数据从托管代码识别的格式转化为本地代码所识别的格式。这个转化的过程就叫做封送(Marshalling),虽然封送的开销通常不大,但知道它仍然是有代价的就对了。

在了解了代码执行的流程之后,我们再回来看代码的性能表现问题。简单点说就是我们的代码给CPU分配的太多的工作要做,我们举几个不同的情形:

1、我们的代码单纯就是逊诶。比如我们明明只需要调用一次函数就可以解决问题,结果我们调用了很多次。后边也会对这些问题进行一些扩展讨论。(基础不足)

2、我们的代码看似结构良好,实际上做了很多不必要的对其他代码的调用。例如一些在托管代码和本地代码之间不必要的调用。(对引和框架擎不熟)

3、第三种情况就是我们的代码也很有效率,但有一些设计仍然可以优化。比如怪物检测问题,在视野外的怪物是不需要检测的,如果我们仍然做了,就会浪费掉一些不必要的计算。(经验不足)

4、第四种情况就是计算量太大了。我们要模拟的东西数量多,又非常真实,或者运行复杂的AI,就是本身计算量就巨大,代码上真实计算就没有操作空间,得改设计。(真实)

那么如何来优化我们代码的性能呢?

有一个关键的点就是,我们要认清问题的本质,该文章并不是给你一个步骤,按照步骤123执行后,代码就会得到飞跃,而是说我们列举了一些情形,并说明问题。当你了解了这些问题,配合游戏内的Profiler等系统,就可以去对自己的项目进行分析,然后列出利弊,最终选择如何去优化你的项目。文章也提到了,改善了CPU性能,可能会增加对内存的使用,所以做优化还是那句话,要把握一个度,和一个时空转换问题,过犹不及。

有一个例子是这样的

void Update()
{
    for (int i = 0; i < myArray.Length; i++)
    {
        if (exampleBool)
        {
            ExampleFunction(myArray[i]);
        }
    }
}
//优化后
void Update()
{
    if (exampleBool)
    {
        for (int i = 0; i < myArray.Length; i++)
        {
            ExampleFunction(myArray[i]);
        }
    }
}

很多同学会考虑,嗯,这么写确实让循环内容执行更紧凑,可能是考虑到CPU缓存的问题。实际上我做了测试,当exampleBool为true的时候二者并没有性能上的明显差异。问题是那个example,当它为false的时候,第二种写法循环就不用执行了,是这个意思,不要想太多。

另外就是,Update尽量少调用,能一次计算出结果的就不要在Update里计算。毕竟每帧运算消耗还是比较大的,除非你在做一个均摊优化,把一个运算很大的任务分散到每帧中做一些。这个又是另说。再次强调,优化没有规则,没有教条,优化是选择。

接下来的例子也很简单,但也很说明问题

private int score;

public void IncrementScore(int incrementBy)
{
    score += incrementBy;
}

void Update()
{
    DisplayScore(score);
}

这种是我们经常经常用到的例子,比如在主界面显示玩家的金币,经验值等等,在游戏里更新玩家的血量。我们通常会在Update里去做这种更新。

但实际上,这些信息并不总是每帧都在变的,因此我们可以让信息的更新发生在信息的变化时。

private int score;

public void IncrementScore(int incrementBy)
{
    score += incrementBy;
    DisplayScore(score);
}

从一个被动轮询,到一个主动推送,效率就得到了提升。

其实这个例子看似简单,但我们要做发散。不然我们就没有学习的必要了,我也不没必要再读完以后再做什么笔记,我们看了这个就要想,它代表的是哪一类问题,而不是哪一个问题。

比如你看我们做战斗,会涉及到属性的计算。通常来说每次计算伤害,我们都会从新计算一下属性(因为属性受很多东西影响,Buff,装备),但很多时候,属性的来源是没有变化的。那我们就可以用一个标记为dirty(非常常用的概念),当有影响到属性变化的事件发生时,将属性标记为dirty,再计算属性时,如果检测到dirty为true,则重新计算一遍属性,否则就读取上一次计算出来的值。

很多用到缓存的地方都和这个例子有所关联,有缓存就会有失效,失效=变化,变化=更新,和这个例子是同样的概念。

比如后边文章提到的利用缓存:

void Update()
{
    Renderer myRenderer = GetComponent<Renderer>();
    ExampleFunction(myRenderer);
}

//修改后

private Renderer myRenderer;

void Start()
{
    myRenderer = GetComponent<Renderer>();
}

void Update()
{
    ExampleFunction(myRenderer);
}

说白了,核心四个字:隔离变化

文中还提到了优化技术,就是每隔X帧执行一次代码。

例子如下:

void Update()
{
    ExampleExpensiveFunction();
}

//优化后

private int interval = 3;

void Update()
{
    if (Time.frameCount % interval == 0)
    {
        ExampleExpensiveFunction();
    }
}


//优化第二版

private int interval = 3;

void Update()
{
    if (Time.frameCount % interval == 0)
    {
        ExampleExpensiveFunction();
    }
    else if (Time.frameCount % interval == 1)
    {
        AnotherExampleExpensiveFunction();
    }
}

其中涉及到了均摊的思想。你看unity首先说,能一次做的不要做多次,后来一次做的又拆成多次,不是自相矛盾吗?其实一点都不矛盾,二者的概念是不同的,前者是说不要做多次重复的事。而后者是说,把一个很重的计算工作,分成多次计算。这都需要具体情况具体分析的。

因为大部分游戏都是锁定帧率为30帧或者60帧的,以60帧为例子,每帧的时间约为16.6ms,如果这一帧里你CPU的工作做完了,剩下的时间也是在等待下一帧的计算量到来,闲着也是闲着,干嘛不利用起来。

但你能说这样就没有问题吗?也不一定,CPU跑的太满还是会容易发烫,例子2的优化更像是,忙的时候忙点,然后好好歇歇。例子三就是,每天都做一点点,见仁见智。

文章非常长,先记录到这吧,剩下的后续补完

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值