找回密码
 立即注册
查看: 468|回复: 16

Unity3D性能优化——CPU篇

[复制链接]
发表于 2022-5-11 08:15 | 显示全部楼层 |阅读模式
本篇难度:★★★☆☆

请注意,单独观看本文是不太容易吸收的。正确的食用方式,是手边打开一个具体的项目,然后结合项目参考文章看看是否有能改进的地方,再对症下药。
大噶好,咱们又见面了。



  • 我们在前一篇文章中讲到了Unity性能分析工具的用法,以及在我们实际项目中所用到性能分析的思路。从这篇文章开始,我们从Unity性能优化的几个方面来逐步讲解unity中具体的优化方法和作用。
  • 就当前的游戏优化而言,主要是围绕CPU、渲染、内存,三大方面来进行,而这三大方面又可以细分很多模块,比如在CPU中有渲染、 物理、 脚本、 GC、UI、垂直同步以及全局光照等模块。
  • 本系列之后的文章,我们从以上三个大方向,来具体的介绍。
<hr/>CPU性能优化

CPU 优化主要以性能分析为引,根据分析所得的数据,找到性能问题,以便快速并定向的优化项目。在我们之前的文章中有提到,CPU usage profiler会统计渲染、 物理、 脚本、 GC、UI、垂直同步以及全局光照等模块的 CPU 使用情况。
首先我们再了解一个工具,在我们的项目中,可以在Game视图点击stats开启Statistics窗口(渲染统计窗口),该窗口显示游戏运行时,渲染、声音、网络状况等多种统计信息,帮助我们分析游戏性能。




  • 首先我们来了解几个概念性的问题


  • 垂直同步
    关于垂直同步的问题,我们在之前的工具篇中就有提到,想要了解的读者可以看看之前的文章,如果要深入了解,可能需要参考一些教程,在本文中就不做过多的阐述。


  • 渲染
    在unity中GPU和CPU渲染也是一个很大的话题,在之后的文章中,会提到渲染问题,在这里我们只需要大致的了解什么是Batches和Draw Call。  
    在我们进行游戏优化时,常会听到要减少Draw Call。Draw Call实际上就是一个命令,它的发起方是CPU,接收方是GPU,这个命令仅仅会指向一个需要被渲染的图元列表,而不会再包含任何材质信息。 当给定一个Draw Call时,GPU就会根据渲染状态和所有输入的顶点数据来进行计算,最终输出成屏幕上显示的像素。  
    引擎每对一个物体进行一次DrawCall,就会产生一个Batch,这个Batch里包含着该物体所有的网格和顶点数据,当渲染另一个相同的物体时,引擎会直接调用Batch里的信息,将相关顶点数据直接送到GPU,从而让渲染过程更加高效,即Batching技术是将所有材质相近的物体进行合并渲染。  
    Batches其实就是Unity内置的Draw Call Batching


  • GC(垃圾回收)
    在提起这个问题时,我们首先要了解一下GC。
    而想要了解什么是GC我们就要考虑到unity的内存管理机制。
Unity主要采用自动内存管理的机制,开发者不需要详细地告诉unity如何进行内存管理,unity内部自身会进行内存管理。

Unity内部有两个内存管理池:堆内存和栈内存。

Unity中的变量只会在栈或堆内存上进行内存分配。只要变量处于激活状态,则其占用的内存会被标记为使用状态,则该部分的内存处于被分配的状态。一旦变量不再激活,则其所占用的内存不再需要,该部分内存可以被回收到内存池中被再次使用,这样的操作就是内存回收。处于栈上的内存回收及其快速,处于堆上的内存并不是及时回收的,此时其对应的内存依然会被标记为使用状态。垃圾回收主要是指堆上的内存分配和回收,unity中会定时对堆内存进行GC操作。

每次运行GC的时候,会检查堆内存上的每个存储变量,然后对每个变量会检测其引用是否处于激活状态,如果变量的引用不再处于激活状态,则会被标记为可回收,被标记的变量会被移除,其所占有的内存会被回收到堆内存上。GC操作是一个极其耗费的操作,堆内存上的变量或者引用越多则其运行的操作会更多,耗费的时间越长。  
在了解GC在unity内存管理中的作用后,我们需要考虑其带来的问题。最明显的问题是GC操作会需要大量的时间来运行,如果堆内存上有大量的变量或者引用需要检查,则检查的操作会十分缓慢,这就会使得游戏运行缓慢。其次GC可能会在关键时候运行,例如在CPU处于游戏的性能运行关键时刻,此时任何一个额外的操作都可能会带来极大的影响,使得游戏帧率下降。

另外一个GC带来的问题是堆内存的碎片化。当一个内存单元从堆内存上分配出来,其大小取决于其存储的变量的大小。当该内存被回收到堆内存上的时候,有可能使得堆内存被分割成碎片化的单元。也就是说堆内存总体可以使用的内存单元较大,但是单独的内存单元较小,在下次内存分配的时候不能找到合适大小的存储单元,这也会触发GC操作或者堆内存扩展操作。

堆内存碎片会造成两个结果,一个是游戏占用的内存会越来越大,一个是GC会更加频繁地被触发。  
在了解了以上的几个概念后,我们就可以从实际运用中来探索,如何对unity进行相关操作时引起的性能问题进行优化。

1.缓存

我们可以把一些必要的对象缓存起来,在Unity中,类似于GameObject.Find , GetComponent,transform这类的函数,会产生较大的消耗,比如下列代码:
void Update(){
    this.transform.Translate(0, 1, 0);
    this.GetComponent<Rigidbody>().AddForce(Vector3.forward);
}在update中,每帧都去访问这些函数是非常耗时的,我们可以在 Awake 或者 Start 函数中,获取一次组件引用,把引用缓存在当前 class 中,供 Update 等函数使用 这样可以减少每帧获取组件带来的开销。
Rigidbody mRigi;
private Transform mTransform;

void Awake(){
    myRigi = this.GetComponent<Rigidbody>();
    myTransform = this.transform;
}

void Update(){
    myRigidbody.AddForce(Vector3.forward);
    myTransform.Translate(0, 1f, 0f);
}在 Awake 函数中获取刚体组件和 transform 组件的引用,它们缓存在当前的 class 的字段中,然后在 update 函数中通过私有字段,来使用刚体和 transform 组件。

  • 注意,GetComponent如果返回空值会调用GC如下所示



在这里,cube中并没有添加刚体,我们在update中调用如下代码:
this.GetComponent<Rigidbody>().AddForce(Vector3.forward);
可以看到在Profiler中,BehaviourUpdate有近16k的GC



所以,我们要避免出现空的组件获取。

  • 同时我们在项目中可能会用到,unityEngine.Object == null这样的比较方法,它和GetComponent()的情况类似,也会出现cpu消耗以及GC,这里都涉及到引擎方面的调用机制。对于大多数的测试功能,我们都应该少做null比较,而是使用断言(Assert)来代替。

2. 对象池


  • 对象池是我们在实际的游戏开发中,运用比较广泛的重要技术。
在项目中频繁地使用 Instantiate 和 Destroy 函数,会为脚本执行和垃圾回收带来很大的性能开销。如下图所示,我们使用Instantiate函数大量的创建物体,并使用Destroy销毁,这些代码占用了大量的cpu时间。



我们可以使用对象池优化游戏对象的创建和销毁
对象池的含义很简单,我们将对象储存在一个“池”中,当需要它时可以重复使用,而不是创建一个新的对象,尽可能的复用内存中已经驻留的资源来减少频繁的IO耗时操作。有经验的开发者在程序设计时就会做一个规范,其中包含了角色池,怪物池,特效池,经验池等。
我在这篇文章中有详细的描述对象池的写法, 大家可以借鉴、参考

  • 注意:使用对象池时,应当可以支持把物体移出屏幕,连续使用的物体可以只是移出屏幕,只有长时间不使用的物体才隐藏;因为频繁的Activate使用,也会引起不必要的性能消耗。

3.冗余代码

删除不必要的函数、无用的代码段及测试代码
在我们的项目开发中,可能会增加很多测试代码,或者有一些空的回调函数或许还会有一些不需要的继承。
比如说一些控制台输出的的测试或者空的start、update,这些都会消耗CPU性能。
所以在项目开始时,应该在编辑器中设置不要自动继承MonoBehaviour,在需要使用时自行添加。同时测试代码要做好标记,不再需要其时一点要删除。

4.CPU峰值

避免实例化对象时造成cpu峰值
如果我们在某一个特定时间,集中创建很多对象,会造成这一时间段的cpu峰值,我们的游戏就会在这一时间段就会出现卡顿,这里可以使用协程做一些间隔。
打个比方,我们开发一个rpg游戏,当我们切换到一个新的地图,地图中有大量的怪物,如果我们在进入地图时集中创建所有的怪物,那么在初始进入地图的瞬间,必然会非常的卡顿。我们可以做的就是在加载进度或进入地图后,使用协程,如每一帧创建一个怪物,这样可以在我们完成其他动作(如行走)的每帧同时(之后),逐渐创建游戏对象,这样我们基本不会感觉到游戏的卡顿,并且同样实现了我们创建游戏对象的需求。
Unity中的协程方法通过yield这个特殊的属性,可以在任何位置、任意时刻暂停。也可以在指定的时间或事件后继续执行,而不影响上一次执行的就结果,提供了极大地便利性和实用性。

协程在每次执行时都会新建一个(伪)新线程来执行,而不会影响主线程的执行情况。   
private int instanceCount;
void Start(){
    insCount = 10;
    //启动协程
    StartCoroutine(SpawnInstance());
}

IEnumerator SpawnInstance(){
    while(insCount > 0){
        //这里创建游戏物体
        insCount --;
        //协程返回,在固定时间后继续执行后面的代码
        yield return new WaitForSeconds(1f);
    }
}

  • 注意:在使用协程时,yield本不会产生堆内存分配,但是如果yield带有参数返回,则会造成不必要的内存垃圾,例如yield return 0;由于需要返回0,引发了装箱操作,所以会产生内存垃圾。这种情况下,为了避免内存垃圾,我们可以这样返回:yield return null;如果我们每次返回时,都会new一个相同的变量, 例如我们上边的代码yield return new WaitForSeconds(1f);我们可以采用缓存来避免这样的内存垃圾产生:
WaitForSeconds spawnWait = new WaiForSeconds(1f);

IEnumerator SpawnInstance(){
    while(insCount > 0){
        //这里创建游戏物体
        insCount --;
        //协程返回,在spawnWait时间后继续执行后面的代码
        yield return spawnWait;
    }
}
5.GC(垃圾回收)

尽量少的使用会调用GC的代码
首先我们来看一下,在我们写代码时,哪些操作会调用GC:
1) 在堆内存进行内存分配操作,而内存不够时便会触发垃圾回收来利用闲置内存;
2) GC会自动的触发,不同平台频率不同;
3) GC可以手动执行。
其中最主要的就是第一点,如果GC造成性能问题,我们就需要知道哪部分代码会造成GC,内存垃圾在变量不再激活时产生,所以首先我们需要知道堆内存上分配的是什么变量。
即使是初学者也应该了解在C#中,值类型变量都在栈上进行内存分配(如int = 7 其对应的变量在函数调用完后会立即回收),引用类型都在堆内存上分配(如List myList = new List() 其对应的变量在GC的时候才回收)。
在了解了这些后,我们可以配合之前所学的profier工具来定位造成大量内存分配的函数,一旦定位该函数,我们就可以分析解决其造成问题的原因从而减少内存垃圾的产生。
我们在之前已经提到了部分会调用GC的操作,并且了解了GC的基本原理,这里我们再说一些会调用GC的操作。

  • 比如String的相加操作, 会频繁申请内存并释放,导致GC频繁(在c#中,字符串是引用类型,也就意味着,每次我们操纵一个字符串(比如这里所说的相加操作),Unity将创建一个包含更新值的新字符串,我们创建和销毁字符串都会产生垃圾。),我们可以使用System.Text.StringBuilder。


  • 在unity中,很多函数调用也会造成内存垃圾,比如之前所说到的GetComponent,还有GameObject.name或GameObject.tag,Input.touches,Physics.SphereCastAll()等。
    我们可以如上文第一点描述的方法,先对其进行缓存。而且unity也提供了相关的函数来替换他们,比如GameObject.tag我们可以使GameObject.CompareTag()来替代,Input.touches可以使用Input.GetTouch(),或者用Physics.SphereCastNonAlloc()来代替Physics.SphereCastAll()。
<hr/>以上是我们在实际的unity项目中进行cpu优化的主要注意方向,当然,在实际项目的优化中,还有更多的方法和细节需要我们注意,例如
1)减少对粒子系统Play()的调用;
2)处理Rigidbody时,使用FixedUpdate,设置Fixed timestep,减少物理计算次数;
3)如果可以,尽量不用MeshCollider,如果不能避免的话,尽量用减少Mesh的面片数,或用较少面片的物体来代替;
4)使用内建数组如使用Vector3.zero而不是new Vector(0,0,0);
5)每次访问Transform.position/rotation都有相应的消耗。能cache就cache其返回结果。
6)如果可能,尽量用Queue/Stack来代替List
7) 同一脚本中频繁使用的变量建议声明其为全局变量,脚本之间频繁调用的变量或方法建议声明为全局静态变量或方法;
在本文中,只能用一些简单的例子来讲述一些常见的性能问题及优化方法,而更多的优化问题,需要读者在具体的项目中发现,并根据实际的应用场景来选择合适的优化方法。
OK,CPU篇的相关内容就到这里。下一篇文章中,我们会针对Unity中渲染分析及优化技术进行详细的讲解。

·如果有想进一步系统地学习游戏开发的,欢迎强势插入http://levelpp.com/。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
发表于 2022-5-11 08:16 | 显示全部楼层
和上一期连着看效果很好,作者总结的很全面。
发表于 2022-5-11 08:18 | 显示全部楼层
反例:
做好了新版本的小游戏,打个包在手机上玩玩——嗯,主板的香气。
发表于 2022-5-11 08:25 | 显示全部楼层
多用Assert?断言失败会引发异常的吧……
发表于 2022-5-11 08:32 | 显示全部楼层
请问使用Queue/Stack来代替List的原因是什么呢
发表于 2022-5-11 08:40 | 显示全部楼层
关于断言能详细说说吗,我只知道那东西可以调试时用的,据说默认会在打包的时候自动去掉
发表于 2022-5-11 08:41 | 显示全部楼层
首先感谢你提出问题。在文中我不是说多用assert,而是在可以代替“==null”的情况下使用assert,可能是文章中我的描述还是有一些误导。断言是一种安全编程方法,主要是用于保证代码正确性。谢谢
发表于 2022-5-11 08:41 | 显示全部楼层
Queue/Stack 增删复杂度都是O(1),在不需对数据进行随机访问,而仅仅是“增加”、“删除”操作,我们使用Queue/Stack会降低性能损耗。
在List中,Add为O(1)复杂度,但超过Capacity时,为O(n)复杂度。(这里也涉及到一个代码优化问题,我们需要合理地设置List的初始化Capacity。)。
Insert复杂度为O(n)。
Remove()复杂度为O(n)。
发表于 2022-5-11 08:44 | 显示全部楼层
对的, 断言是被用来检查非法情况,即在程序正常工作时绝不应该发生的非法情况,用来帮助开发人员对问题的快速定位。断言其实就是一个安全编程的工具,为了确保代码的正确性,新手可能很容易和异常弄混。
发表于 2022-5-11 08:46 | 显示全部楼层
看不懂说的都什么玩意
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-9-22 11:19 , Processed in 0.100969 second(s), 26 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表