Unity3D性能優(yōu)化——CPU篇

作者:朔宇
本篇難度:★★★☆☆
請注意,單獨觀看本文是不太容易吸收的。正確的食用方式,是手邊打開一個具體的項目,然后結(jié)合項目參考文章看看是否有能改進的地方,再對癥下藥。
大噶好,咱們又見面了。

我們在前一篇文章中講到了Unity性能分析工具的用法,以及在我們實際項目中所用到性能分析的思路。從這篇文章開始,我們從Unity性能優(yōu)化的幾個方面來逐步講解unity中具體的優(yōu)化方法和作用。
就當前的游戲優(yōu)化而言,主要是圍繞CPU、渲染、內(nèi)存,三大方面來進行,而這三大方面又可以細分很多模塊,比如在CPU中有渲染、 物理、 腳本、 GC、UI、垂直同步以及全局光照等模塊。
本系列之后的文章,我們從以上三個大方向,來具體的介紹。
CPU性能優(yōu)化
CPU 優(yōu)化主要以性能分析為引,根據(jù)分析所得的數(shù)據(jù),找到性能問題,以便快速并定向的優(yōu)化項目。在我們之前的文章中有提到,CPU usage profiler會統(tǒng)計渲染、 物理、 腳本、 GC、UI、垂直同步以及全局光照等模塊的 CPU 使用情況。
首先我們再了解一個工具,在我們的項目中,可以在Game視圖點擊stats開啟Statistics窗口(渲染統(tǒng)計窗口),該窗口顯示游戲運行時,渲染、聲音、網(wǎng)絡狀況等多種統(tǒng)計信息,幫助我們分析游戲性能。

首先我們來了解幾個概念性的問題
垂直同步
關于垂直同步的問題,我們在之前的工具篇中就有提到,想要了解的讀者可以看看之前的文章,如果要深入了解,可能需要參考一些教程,在本文中就不做過多的闡述。-
渲染
在unity中GPU和CPU渲染也是一個很大的話題,在之后的文章中,會提到渲染問題,在這里我們只需要大致的了解什么是Batches和Draw Call。?
在我們進行游戲優(yōu)化時,常會聽到要減少Draw Call。Draw Call實際上就是一個命令,它的發(fā)起方是CPU,接收方是GPU,這個命令僅僅會指向一個需要被渲染的圖元列表,而不會再包含任何材質(zhì)信息。 當給定一個Draw Call時,GPU就會根據(jù)渲染狀態(tài)和所有輸入的頂點數(shù)據(jù)來進行計算,最終輸出成屏幕上顯示的像素。?
引擎每對一個物體進行一次DrawCall,就會產(chǎn)生一個Batch,這個Batch里包含著該物體所有的網(wǎng)格和頂點數(shù)據(jù),當渲染另一個相同的物體時,引擎會直接調(diào)用Batch里的信息,將相關頂點數(shù)據(jù)直接送到GPU,從而讓渲染過程更加高效,即Batching技術是將所有材質(zhì)相近的物體進行合并渲染。?
Batches其實就是Unity內(nèi)置的Draw Call BatchingGC(垃圾回收)
在提起這個問題時,我們首先要了解一下GC。
而想要了解什么是GC我們就要考慮到unity的內(nèi)存管理機制。
Unity主要采用自動內(nèi)存管理的機制,開發(fā)者不需要詳細地告訴unity如何進行內(nèi)存管理,unity內(nèi)部自身會進行內(nèi)存管理。
Unity內(nèi)部有兩個內(nèi)存管理池:堆內(nèi)存和棧內(nèi)存。
Unity中的變量只會在棧或堆內(nèi)存上進行內(nèi)存分配。只要變量處于激活狀態(tài),則其占用的內(nèi)存會被標記為使用狀態(tài),則該部分的內(nèi)存處于被分配的狀態(tài)。一旦變量不再激活,則其所占用的內(nèi)存不再需要,該部分內(nèi)存可以被回收到內(nèi)存池中被再次使用,這樣的操作就是內(nèi)存回收。處于棧上的內(nèi)存回收及其快速,處于堆上的內(nèi)存并不是及時回收的,此時其對應的內(nèi)存依然會被標記為使用狀態(tài)。垃圾回收主要是指堆上的內(nèi)存分配和回收,unity中會定時對堆內(nèi)存進行GC操作。
每次運行GC的時候,會檢查堆內(nèi)存上的每個存儲變量,然后對每個變量會檢測其引用是否處于激活狀態(tài),如果變量的引用不再處于激活狀態(tài),則會被標記為可回收,被標記的變量會被移除,其所占有的內(nèi)存會被回收到堆內(nèi)存上。GC操作是一個極其耗費的操作,堆內(nèi)存上的變量或者引用越多則其運行的操作會更多,耗費的時間越長。?
在了解GC在unity內(nèi)存管理中的作用后,我們需要考慮其帶來的問題。最明顯的問題是GC操作會需要大量的時間來運行,如果堆內(nèi)存上有大量的變量或者引用需要檢查,則檢查的操作會十分緩慢,這就會使得游戲運行緩慢。其次GC可能會在關鍵時候運行,例如在CPU處于游戲的性能運行關鍵時刻,此時任何一個額外的操作都可能會帶來極大的影響,使得游戲幀率下降。
另外一個GC帶來的問題是堆內(nèi)存的碎片化。當一個內(nèi)存單元從堆內(nèi)存上分配出來,其大小取決于其存儲的變量的大小。當該內(nèi)存被回收到堆內(nèi)存上的時候,有可能使得堆內(nèi)存被分割成碎片化的單元。也就是說堆內(nèi)存總體可以使用的內(nèi)存單元較大,但是單獨的內(nèi)存單元較小,在下次內(nèi)存分配的時候不能找到合適大小的存儲單元,這也會觸發(fā)GC操作或者堆內(nèi)存擴展操作。
堆內(nèi)存碎片會造成兩個結(jié)果,一個是游戲占用的內(nèi)存會越來越大,一個是GC會更加頻繁地被觸發(fā)。
在了解了以上的幾個概念后,我們就可以從實際運用中來探索,如何對unity進行相關操作時引起的性能問題進行優(yōu)化。
1.緩存
我們可以把一些必要的對象緩存起來,在Unity中,類似于GameObject.Find , GetComponent,transform這類的函數(shù),會產(chǎn)生較大的消耗,比如下列代碼:
void Update(){
??? this.transform.Translate(0, 1, 0);
??? this.GetComponent<Rigidbody>().AddForce(Vector3.forward);
}
?
在update中,每幀都去訪問這些函數(shù)是非常耗時的,我們可以在 Awake 或者 Start 函數(shù)中,獲取一次組件引用,把引用緩存在當前 class 中,供 Update 等函數(shù)使用 這樣可以減少每幀獲取組件帶來的開銷。
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 函數(shù)中獲取剛體組件和 transform 組件的引用,它們緩存在當前的 class 的字段中,然后在 update 函數(shù)中通過私有字段,來使用剛體和 transform 組件。
注意,GetComponent如果返回空值會調(diào)用GC如下所示:

在這里,cube中并沒有添加剛體,我們在update中調(diào)用如下代碼:
this.GetComponent<Rigidbody>().AddForce(Vector3.forward);
可以看到在Profiler中,BehaviourUpdate有近16k的GC

所以,我們要避免出現(xiàn)空的組件獲取。
同時我們在項目中可能會用到,
unityEngine.Object == null
這樣的比較方法,它和GetComponent()的情況類似,也會出現(xiàn)cpu消耗以及GC,這里都涉及到引擎方面的調(diào)用機制。對于大多數(shù)的測試功能,我們都應該少做null比較,而是使用斷言(Assert)來代替。
2. 對象池
對象池是我們在實際的游戲開發(fā)中,運用比較廣泛的重要技術。
在項目中頻繁地使用 Instantiate 和 Destroy 函數(shù),會為腳本執(zhí)行和垃圾回收帶來很大的性能開銷。如下圖所示,我們使用Instantiate函數(shù)大量的創(chuàng)建物體,并使用Destroy銷毀,這些代碼占用了大量的cpu時間。

我們可以使用對象池優(yōu)化游戲?qū)ο蟮膭?chuàng)建和銷毀
對象池的含義很簡單,我們將對象儲存在一個“池”中,當需要它時可以重復使用,而不是創(chuàng)建一個新的對象,盡可能的復用內(nèi)存中已經(jīng)駐留的資源來減少頻繁的IO耗時操作。有經(jīng)驗的開發(fā)者在程序設計時就會做一個規(guī)范,其中包含了角色池,怪物池,特效池,經(jīng)驗池等。
我在這篇文章中有詳細的描述對象池的寫法, 大家可以借鑒、參考
注意:使用對象池時,應當可以支持把物體移出屏幕,連續(xù)使用的物體可以只是移出屏幕,只有長時間不使用的物體才隱藏;因為頻繁的Activate使用,也會引起不必要的性能消耗。
3.冗余代碼
刪除不必要的函數(shù)、無用的代碼段及測試代碼
在我們的項目開發(fā)中,可能會增加很多測試代碼,或者有一些空的回調(diào)函數(shù)或許還會有一些不需要的繼承。?
比如說一些控制臺輸出的的測試或者空的start、update,這些都會消耗CPU性能。
所以在項目開始時,應該在編輯器中設置不要自動繼承MonoBehaviour,在需要使用時自行添加。同時測試代碼要做好標記,不再需要其時一點要刪除。
4.CPU峰值
避免實例化對象時造成cpu峰值
如果我們在某一個特定時間,集中創(chuàng)建很多對象,會造成這一時間段的cpu峰值,我們的游戲就會在這一時間段就會出現(xiàn)卡頓,這里可以使用協(xié)程做一些間隔。
打個比方,我們開發(fā)一個rpg游戲,當我們切換到一個新的地圖,地圖中有大量的怪物,如果我們在進入地圖時集中創(chuàng)建所有的怪物,那么在初始進入地圖的瞬間,必然會非常的卡頓。我們可以做的就是在加載進度或進入地圖后,使用協(xié)程,如每一幀創(chuàng)建一個怪物,這樣可以在我們完成其他動作(如行走)的每幀同時(之后),逐漸創(chuàng)建游戲?qū)ο?,這樣我們基本不會感覺到游戲的卡頓,并且同樣實現(xiàn)了我們創(chuàng)建游戲?qū)ο蟮男枨蟆?/p>
Unity中的協(xié)程方法通過yield這個特殊的屬性,可以在任何位置、任意時刻暫停。也可以在指定的時間或事件后繼續(xù)執(zhí)行,而不影響上一次執(zhí)行的就結(jié)果,提供了極大地便利性和實用性。?
協(xié)程在每次執(zhí)行時都會新建一個(偽)新線程來執(zhí)行,而不會影響主線程的執(zhí)行情況。
private int instanceCount;
void Start(){
??? insCount = 10;
??? //啟動協(xié)程
??? StartCoroutine(SpawnInstance());
}
?
IEnumerator SpawnInstance(){
??? while(insCount > 0){
??????? //這里創(chuàng)建游戲物體
??????? insCount --;
??????? //協(xié)程返回,在固定時間后繼續(xù)執(zhí)行后面的代碼
??????? yield return new WaitForSeconds(1f);
??? }
}
?
注意:在使用協(xié)程時,yield本不會產(chǎn)生堆內(nèi)存分配,但是如果yield帶有參數(shù)返回,則會造成不必要的內(nèi)存垃圾,例如:yield return 0;
由于需要返回0,引發(fā)了裝箱操作,所以會產(chǎn)生內(nèi)存垃圾。這種情況下,為了避免內(nèi)存垃圾,我們可以這樣返回:yield return null;
如果我們每次返回時,都會new一個相同的變量, 例如我們上邊的代碼yield return new WaitForSeconds(1f);
我們可以采用緩存來避免這樣的內(nèi)存垃圾產(chǎn)生:
WaitForSeconds spawnWait = new WaiForSeconds(1f);
?
IEnumerator SpawnInstance(){
??? while(insCount > 0){
??????? //這里創(chuàng)建游戲物體
??????? insCount --;
??????? //協(xié)程返回,在spawnWait時間后繼續(xù)執(zhí)行后面的代碼
??????? yield return spawnWait;
??? }
}
?
5.GC(垃圾回收)
盡量少的使用會調(diào)用GC的代碼
首先我們來看一下,在我們寫代碼時,哪些操作會調(diào)用GC:
1) 在堆內(nèi)存進行內(nèi)存分配操作,而內(nèi)存不夠時便會觸發(fā)垃圾回收來利用閑置內(nèi)存;
2) GC會自動的觸發(fā),不同平臺頻率不同;
3) GC可以手動執(zhí)行。
其中最主要的就是第一點,如果GC造成性能問題,我們就需要知道哪部分代碼會造成GC,內(nèi)存垃圾在變量不再激活時產(chǎn)生,所以首先我們需要知道堆內(nèi)存上分配的是什么變量。
即使是初學者也應該了解在C#中,值類型變量都在棧上進行內(nèi)存分配(如int = 7 其對應的變量在函數(shù)調(diào)用完后會立即回收),引用類型都在堆內(nèi)存上分配(如List myList = new List() 其對應的變量在GC的時候才回收)。
在了解了這些后,我們可以配合之前所學的profier工具來定位造成大量內(nèi)存分配的函數(shù),一旦定位該函數(shù),我們就可以分析解決其造成問題的原因從而減少內(nèi)存垃圾的產(chǎn)生。
我們在之前已經(jīng)提到了部分會調(diào)用GC的操作,并且了解了GC的基本原理,這里我們再說一些會調(diào)用GC的操作。
比如String的相加操作, 會頻繁申請內(nèi)存并釋放,導致GC頻繁(在c#中,字符串是引用類型,也就意味著,每次我們操縱一個字符串(比如這里所說的相加操作),Unity將創(chuàng)建一個包含更新值的新字符串,我們創(chuàng)建和銷毀字符串都會產(chǎn)生垃圾。),我們可以使用System.Text.StringBuilder。
在unity中,很多函數(shù)調(diào)用也會造成內(nèi)存垃圾,比如之前所說到的GetComponent,還有GameObject.name或GameObject.tag,Input.touches,Physics.SphereCastAll()等。
我們可以如上文第一點描述的方法,先對其進行緩存。而且unity也提供了相關的函數(shù)來替換他們,比如GameObject.tag我們可以使GameObject.CompareTag()來替代,Input.touches可以使用Input.GetTouch(),或者用Physics.SphereCastNonAlloc()來代替Physics.SphereCastAll()。
以上是我們在實際的unity項目中進行cpu優(yōu)化的主要注意方向,當然,在實際項目的優(yōu)化中,還有更多的方法和細節(jié)需要我們注意,例如
1)減少對粒子系統(tǒng)Play()的調(diào)用;
2)處理Rigidbody時,使用FixedUpdate,設置Fixed timestep,減少物理計算次數(shù);
3)如果可以,盡量不用MeshCollider,如果不能避免的話,盡量用減少Mesh的面片數(shù),或用較少面片的物體來代替;
4)使用內(nèi)建數(shù)組如使用Vector3.zero而不是new Vector(0,0,0);
5)每次訪問Transform.position/rotation都有相應的消耗。能cache就cache其返回結(jié)果。
6)如果可能,盡量用Queue/Stack來代替List
7) 同一腳本中頻繁使用的變量建議聲明其為全局變量,腳本之間頻繁調(diào)用的變量或方法建議聲明為全局靜態(tài)變量或方法;
在本文中,只能用一些簡單的例子來講述一些常見的性能問題及優(yōu)化方法,而更多的優(yōu)化問題,需要讀者在具體的項目中發(fā)現(xiàn),并根據(jù)實際的應用場景來選擇合適的優(yōu)化方法。
OK,CPU篇的相關內(nèi)容就到這里。下一篇文章中,我們會針對Unity中渲染分析及優(yōu)化技術進行詳細的講解。
最后想系統(tǒng)學習游戲開發(fā)的童鞋,歡迎訪問?http://www.levelpp.com/
游戲開發(fā)攪基QQ群:869551769??
微信公眾號:皮皮關