Unity性能优化 – 脚本篇
最近開始進行Unity性能優化的工作,主要分為三類:CPU、GPU和內存。由于我們游戲的核心戰斗是計算密集型,所以主要是受限于CPU。CPU的優化又分為渲染和腳本,本文將著重于腳本優化。
一般來說,優化必須要知道性能熱點在哪里,而知道性能熱點則需要在目標設備去進行深度的profile。如果不進行profile,而是靠臆測去進行優化,往往會事倍功半,甚至適得其反。
本文所講述的是已經經過驗證的,通用的優化方法和思路,可以為大家節約一些profile時間。以下將從Unity API、C#、Lua、數據結構和算法等方面來詳細闡述優化建議。
Unity API
GameObject.GetComponent
Unity是基于組合的開發方式,所以GetComponent是一個高頻使用的函數。每次調用GetComponent時,Unity都要去遍歷所有的組件來找到目標組件。每次都去查找是不必要的耗費,我們可以通過緩存的方式來避免這些不必要的開銷。
其中Transform是我們用到最多的組件,GameObject內部提供了一個.transform來獲取此組件。然而經過測試(2017.2.1p1)我們發現緩存的效率依然是最高的。所以若要經常訪問一個特定組件,將其緩存。
?
GameObject.Find
GameObject.Find會遍歷當前所有的GameObject來返回名字相符的對象。所以當游戲內對象很多時,這個函數將很耗時。
可以通過緩存的方法,在Start或Awake時緩存一次找到的對象,在后續使用中使用緩存的對象而非繼續調用GameObject.Find。
或者采用GameObject.FindWithTag來尋找特定標簽的對象。如果能在一開始就確定好對象,可以通過Inspector注入的方式,將對象直接拖到Inspector中,從而避免了運行時的查找。
Camera.main
Camera.main用來返回場景中的主相機,Unity內部是通過GameObject.FindWithTag來查找tag為MainCamera的相機。
當需要頻繁訪問主相機時,我們可以將其緩存以獲得性能提升。
?
GameObject.tag
GameObject.tag常用來比較對象的tag,但是直接采用.tag ==來進行對比的話,每一幀會產生GC Alloc。通過GameObject.CompareTag來進行比較則可以避免掉這些GC,但是前提是比較的tag需在Tag Manager中定義。
?
MonoBehaviour
MonoBehaviour提供了很多內部的調用方法,諸如Update、Start和Awake等等,它們使用起來很方便,只要在一個繼承了MonoBehaviour的腳本中定義了Update函數,Unity便會在每一幀去執行這個函數,具體的執行順序見:Execution Order of Event Functions。
然而當有大量的MonoBehaviour的Update需要執行時,在profiler中可以看到它們的耗時很高。因為在MonoBehaviour內部調用Update時需要做一系列檢查,如下圖所示:
我們可以自建一個MonoBehaviour管理器,里面維護一個List,然后將這些需要調用Update的MonoBehaviour扔進List中,并將它們的Update函數改成其他名字,比如MonoUpdate。然后在這個管理器的Update函數中循環遍歷所有的MonoBehaviour調用它們的MonoUpdate。結果可以獲得數量級上的提升,如下所示:
?
詳細原理請閱讀:10000 Update() calls。
Transform.SetPositionAndRotation
每次調用Transform.SetPosition或Transform.SetRotation時,Unity都會通知一遍所有的子節點。
當位置和角度信息都可以預先知道時,我們可以通過Transform.SetPositionAndRotation一次調用來同時設置位置和角度,從而避免兩次調用導致的性能開銷。
Animator.Set…
Animator提供了一系列類似于SetTrigger、SetFloat等方法來控制動畫狀態機。例如:m_animator.SetTrigger(“Attack”)是用來觸發攻擊動畫。然而在這個函數內部,“Attack”字符串會被hash成一個整數。如果我們需要頻繁觸發攻擊動畫,我們可以通過Animator.StringToHash來提前進行hash,來避免每次的hash運算。
?
Material.Set…
與Animator類似,Material也提供了一系列的設置方法用于改變Shader。例如:m_mat.SetFloat(“Hue”, 0.5f)是用來設置材質的名為Hue的浮點數。同樣的我們可以通過Shader.PropertyToID來提前進行hash。
?
Vector Math
如果需要比較距離,而非計算距離,用SqrMagnitude來替代Magnitude可以避免一次耗時的開方運算。
在進行向量乘法時,有一點需要注意的是乘法的順序,因為向量乘比較耗時,所以我們應該盡可能的減少向量乘法運算。
?
可以看出上述的向量乘法的結果完全一致,但是卻有顯著的耗時差異,因為后者比前者少了一次向量乘法。所以,應該盡可能合并數字乘法,最后再進行向量乘。
Coroutine
Coroutine是Unity用來實現異步調用的機制,如果對其不夠了解可以參考我之前寫的文章:對Unity中Coroutines的理解。
當需要實現一些定時操作時,有些同學可能會在Update中每幀進行一次判斷,假設幀率是60幀,需要定時1秒調用一次,則會導致59次無效的Update調用。
用Coroutine則可以避免掉這些無效的調用,只需要yield return new WaitForSeconds(1f);即可。當然這里的最佳實踐還是用一個變量緩存一下new WaitForSeconds(1f),這樣省去了每次都new的開銷。
SendMessage
SendMessage用來調用MonoBehaviour的方法,然而其內部采用了反射的實現機制,時間開銷異常大,需要盡量避免使用。
可以用事件機制來取代它。
Debug.Log
眾所周知,輸出Log是一件異常耗時,而且玩家感知不到的事情。所以應該在正式發布版本時,將其關閉。
Unity的Log輸出并不會在Release模式下被自動禁用掉,所以需要我們手動來禁用。我們可以在運行時用一行代碼來禁用Log的輸出:Debug.logger.logEnabled = false;。
不過最好采用條件編譯標簽Conditional封裝一層自己的Log輸出,來直接避免掉Log輸出的編譯,還可以省去Log函數參數傳遞和調用的開銷。具體可以參見:Unity3D研究院之在發布版本屏蔽Debug.log輸出的Log。
C#
反射
反射是一項異常耗時的操作,因為其需要大量的有效性驗證而且無法被編譯器優化。
而且反射在iOS下還可能存在不能通過AOT的情況,所以我們應該盡量避免使用反射。
我們可以自己建立一個字符串-類型的字典來代替反射,或者采用delegate的方式來避免反射。
內存分配(棧和堆)
在C#中,內存分配有兩種策略,一種是分配在棧(Stack)上,另一種是分配在堆(Heap)上。
在棧上分配的對象都是擁有固定大小的類型,在棧上分配內存十分高效。
在堆上分配的對象都是不能確定其大小的類型,由于其內存大小不固定,所以經常容易產生內存碎片,導致其內存分配相對于棧來說更為低效。
值類型和引用類型
在C#中,數據可以分為兩種類型:值類型(Value Type)和引用類型(Reference Type)。
值類型包括所有數字類型、Bool、Char、Date、所有Struct類型和枚舉類型。其類型的大小都是固定,它們都在棧上進行內存分配。
引用類型包括字符串、所有類型的數組、所有Class以及Delegate,它們都在堆上進行內存分配。
?
裝箱
裝箱(Boxing)指的是將值類型轉換為引用類型,而拆箱(UnBoxing)的是將引用類型轉換為值類型。 Stack & Heap
?
從上圖我們可以發現裝箱和拆箱存在著從棧到堆的轉移和內存開辟,所以它們本質是一項非常耗時的操作,我們應該盡量避免之。
Mono之前的foreach導致每幀的GC Alloc,本質也是因為裝箱和拆箱導致的,此問題已經在Unity5.6后被修復。
垃圾回收
我們在堆上分配的內存,其實是由垃圾回收器(Garbage Collector)來負責回收的。垃圾回收算法異常耗時,因為它需要遍歷所有的對象,然后找到沒有引用的孤島,將它們標記為「垃圾」,然后將其內存回收掉。
頻繁的垃圾回收不僅很耗時,還會導致內存碎片的產生,使得下一次的內存分配變得更加困難或者干脆無法分配有效內存,此時堆內存上限會往上翻一倍,而且無法回落,造成內存吃緊。
所以我們應該極力避免GC Alloc,即需要控制堆內存的分配。
字符串
字符串連接會導致GC Alloc,例如string gcalloc = "GC" + "Alloc"會導致"GC"變成垃圾,從而產生GC Alloc。又比如:string c = string.Format("one is {0}", 1),也會因為一次裝箱操作(數字1被裝箱成字符串"1")而產生額外的GC Alloc。
所以如果字符串連接是高頻操作,應該盡量避免使用+來進行字符串連接。C#提供了StringBuilder類來專門進行字符串的連接。
虛函數
虛函數的調用會比直接調用開銷更大,我們可以用sealed修飾符來修飾掉那些確保不會被繼承的類或函數。
具體詳情可以參考:IL2CPP Optimizations: Devirtualization。
Lua
我之前寫過一篇有關于純Lua性能優化的文章:編寫高性能的Lua代碼,以下是一些摘抄和補充。
local
Lua的默認變量都是全局變量,必須要加上local修飾才能變成局部變量。
局部變量相對于全部變量有以下幾點好處: 1. 讀寫更快 2. 可以避免不經意的全局變量名污染 3. 在作用域結束時,會被自動標記為垃圾,避免了內存泄漏
所以,雖然Lua的默認變量聲明都是全局變量,我們還是應該將其用local修飾為局部變量。
table
Lua中的表內部分為兩部分:hash部分和array部分。當創建一個空表時,這兩個部分都會默認初始化空間為0。隨著內容的不斷填充,會不斷觸發rehash。rehash是一次非常耗時的操作,所以應盡量避免之。
如果同時需要創建較多的小表,我們可以通過預先填充表以避免rehash。
string
與C#類似,在Lua中的字符串連接的代價也很高昂,但是與C#提供了StringBuilder不同,Lua沒有提供類似的原生解決方案。
不過我們可以用table來作為一個buffer,然后使用table.concat(buffer, '')來返回最終連接的字符串。
與C#交互
關于與C#的交互,不同的Lua解決方案有不同的策略,但是有些基本的點都是一樣的。
首先,關于MonoBehaviour的三大Update的橋接,最佳策略是通過一個管理器繼承MonoBehaviour的Update,然后將其派發給Lua端,然后Lua端所有的Update都注冊于這個管理器當中。這樣可以避免了多次Lua與C#的橋接交互,可以大量節省時間。
其次,需要考慮GC問題,默認的struct比如Vector3傳遞到Lua中都需要經歷一次裝箱操作,會帶來額外的GC Alloc,可以采用特殊配置的方式將其避免。XLua的方案可以參考:XLua復雜值類型(struct)gc優化指南。
最后,通用的優化思路可以參考用好Lua+Unity,讓性能飛起來——Lua與C#交互篇,作者針對實例做了較為詳盡的分析。
數據結構
容器類型
容器應該針對不同的使用場合進行選擇,主要看使用場合哪種操作的頻率較高。例如:
?
- 經常需要進行隨機下標訪問的場合,優先選擇數組(Array)或列表(List)
- 經常需要進行查找的場合,優先選擇字典(Dictionary)
- 經常需要插入或刪除的場合,優先選擇鏈表(LinkedList)
還有一些特殊的數據結構,適用于特殊的使用場合。例如:
?
- 不能存在相同元素的,可以選擇HashSet
- 需要后進先出的,用來優化遞歸函數調用的,可以選擇Stack
- 需要先進先出的,可以選擇Queue
對象池
對象池(Object Pool)可以避免頻繁的對象生成和銷毀。二手手機號碼轉讓平臺游戲對象的生成,首先需要開辟內存,其次還可能會引起GC Alloc,最后還可能會引發磁盤I/O。頻繁的銷毀對象會引發嚴重的內存碎片,使得堆內存的分配更加困難。
所以在有大量對象需要重復生成和銷毀時,一定要采用對象池來緩存好創建的對象,等到它們無需使用時,不需要將其銷毀,而是將其放入對象池中,可以免去下次的生成。
空間劃分
在計算空間碰撞或者尋找最近鄰居時,如果空間很龐大,需要參與計算的對象太多的情況下,用兩層循環逐個遍歷去計算的復雜度為平方級。
我們可以借助于空間劃分的數據結構來使復雜度降低到N*Log(N)。四叉樹一般用來劃分2D空間,八叉樹一般用來劃分3D空間,而KD樹則是不限空間維度。
我之前寫過一篇介紹KD樹的原理和優化的文章:KD樹的應用與優化,內容比較詳盡,大家可以去讀一讀。
算法
循環
循環的使用非常常見,也非常容易成為性能熱點。我們應該盡量避免在循環內進行耗時或無效操作,尤其是這個循環在每幀的Update調用中時。
?
以上的循環遍歷中,無論condition為真或者為假,循環都會執行count次,若condition為假,則相當于白跑了count次。
?
將判斷條件提出循環外,則可以避免白跑了的問題。
另一個需要注意的是小心多重循環的順序問題,應該盡量把遍歷次數較多的循環放在內層。
?
當內外層循環數有較多數量級上的差別時,將忙的循環放在內層性能更高,因為其避免了更多次內層循環計數器初始化的調用。
數學運算
開方運算,三角函數這些都是耗時的數學運算,應盡量避免之。
像之前提到的,如果只是單純比較距離而不是計算距離的話,就可以用距離的平方來表示,可以節約掉一次耗時的開方運算。
三角運算可以通過簡單的向量運算來規避之,具體可以參考我之前寫的文章:向量運算在游戲開發中的應用和思考。
又比如如果經常需要除一個常數,比如用萬分位整數來表示小數需要經常除10000,可以改成乘0.0001f,可以規避掉較乘法更為耗時的除法運算。?大霧,實際驗算證明,現代的編譯器會對此進行優化,所以沒有必要為此犧牲可讀性。很多時候還是要先測算再去寫代碼會比較好。
緩存
我最喜歡的一種優化思路就是緩存。緩存的本質就是用空間換時間。例如之前在Unity API中提到的很多耗時的函數,都可以用緩存來提升性能。
包括對象池,也是緩存技術的一種。針對于需要依賴復雜運算而且后續要經常用到值,我們便可將其緩存起來,以避免后續的計算,從而獲取性能提升。
總結
以上是生活随笔為你收集整理的Unity性能优化 – 脚本篇的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 网易自动化UI测试解决方案Airtest
- 下一篇: 基于Unity的弹幕游戏多人联机尝试