特別優(yōu)化
上一部分介紹了適用于所有項目的優(yōu)化,本節(jié)將詳細介紹在收集性能分析數(shù)據(jù)之前不應使用的優(yōu)化。可能的原因是這些優(yōu)化在實現(xiàn)時非常耗費精力,在提高性能的同時可能會損害代碼整潔性或可維護性,或者解決的可能僅僅是特定的范圍內(nèi)才存在的問題。
多維數(shù)組與交錯數(shù)組
如該?StackOverflow 文章所述,遍歷交錯數(shù)組通常比遍歷多維數(shù)組更高效,因為多維數(shù)組需要函數(shù)調(diào)用。
注意:
聲明為?
type[x][y]
?則為數(shù)組的數(shù)組而與?type[x,y
] 不同。使用 ILSpy 或類似工具檢查通過訪問多維數(shù)組生成的 IL 即可發(fā)現(xiàn)此情況。
在 Unity 5.3 中進行性能分析時,在三維 100x100x100 數(shù)組上進行 100 次完全順序的迭代得出了以下時間,這些值是通過 10 遍測試獲得的平均結(jié)果:
數(shù)組類型????????????????????????????????????????????總時間(100 次迭代)
一維數(shù)組????????????????????????????????????????????????????????660 ms
交錯數(shù)組????????????????????????????????????????????????????????730 ms
多維數(shù)組????????????????????????????????????????????????????????3470 ms
根據(jù)訪問多維數(shù)組與訪問一維數(shù)組的成本差異,可看出額外函數(shù)調(diào)用的成本,而根據(jù)訪問交錯數(shù)組與訪問一維數(shù)組的成本差異,可看出遍歷非緊湊內(nèi)存結(jié)構(gòu)的成本。
如上所述,額外函數(shù)調(diào)用的成本大大超過了使用非緊湊內(nèi)存結(jié)構(gòu)所帶來的成本。
如果操作對性能影響較大,建議使用一維數(shù)組。在任意其余情況下,如果需要一個具有多個維度的數(shù)組,請使用交錯數(shù)組。不應使用多維數(shù)組。
粒子系統(tǒng)池
對粒子系統(tǒng)建池時,請注意它們至少消耗 3500 字節(jié)的內(nèi)存。內(nèi)存消耗根據(jù)粒子系統(tǒng)上激活的模塊數(shù)量而增加。停用粒子系統(tǒng)時不會釋放此內(nèi)存;只有銷毀粒子系統(tǒng)時才會釋放。
從 Unity 5.3 開始,大多數(shù)粒子系統(tǒng)設置都可在運行時進行操作。對于必須匯集大量不同粒子效果的項目,將粒子系統(tǒng)的配置參數(shù)提取到數(shù)據(jù)載體類或結(jié)構(gòu)中可能更有效。
需要某種粒子效果時,“通用”粒子效果池即可提供必需的粒子效果對象。然后,可將配置數(shù)據(jù)應用于對象以實現(xiàn)期望的圖形效果。
這種方案比嘗試匯集給定場景中使用的粒子系統(tǒng)的所有可能變體和配置會更具內(nèi)存使用效率,但需要大量的工程努力才能實現(xiàn)。
更新管理器
在內(nèi)部,Unity 會跟蹤感興趣的列表中的對象的回調(diào)(例如?Update
、FixedUpdate
?和?LateUpdate
)。這些列表以侵入式鏈接列表的形式進行維護,從而確保在固定時間進行列表更新。在啟用或禁用 MonoBehaviour 時分別會在這些列表中添加/刪除 MonoBehaviour。
雖然直接將適當?shù)幕卣{(diào)添加到需要它們的 MonoBehaviour 十分方便,但隨著回調(diào)數(shù)量的增加,這種方式將變得越來越低效。從原生代碼調(diào)用托管代碼回調(diào)有一個很小但很明顯的開銷。這會導致在調(diào)用大量每幀都執(zhí)行的方法時延長幀時間,而且在實例化包含大量 MonoBehaviour 的預制件時延長實例化時間(注意: 實例化成本歸因于調(diào)用預制件中每個組件上的 Awake 和 OnEnable 回調(diào)時產(chǎn)生的性能開銷)。
當具有每幀回調(diào)的 MonoBehaviour 數(shù)量增長到數(shù)百或數(shù)千時,刪除這些回調(diào)并將 MonoBehaviour(甚至標準 C# 對象)連接到全局管理器單例可以優(yōu)化性能。然后,全局管理器單例可將?Update
、LateUpdate
?和其他回調(diào)分發(fā)給感興趣的對象。這種方式的另一個好處是允許代碼在回調(diào)沒有操作的情況下巧妙地將回調(diào)取消訂閱,從而減少每幀必須調(diào)用的大量函數(shù)。
性能上最大的節(jié)約來自于消除很少執(zhí)行的回調(diào)。請考慮以下偽代碼:
void Update() {
if(!someVeryRareCondition) {?
return;?
}?
// … 某種操作 …?
}
如果大量 MonoBehaviour 具有上述類似 Update 回調(diào),則運行 Update 回調(diào)所使用的大量時間會用于原生和托管代碼域之間的切換以便執(zhí)行 MonoBehaviour之后再立即退出。如果這些類僅在?someVeryRareCondition
?為 true 時訂閱了全局更新管理器 (Update Manager),隨后又取消了訂閱,則可節(jié)省代碼域切換和稀有條件評估所需的時間。
在更新管理器中使用 C# 委托
通常很容易想到使用普通的 C# 委托來實現(xiàn)這些回調(diào)。但是,C# 的委托實現(xiàn)方式適用于較低頻率的訂閱和取消訂閱以及少量的回調(diào)。每次添加或刪除回調(diào)時,C# 委托都會執(zhí)行回調(diào)列表的完整拷貝。在單個幀期間,大型回調(diào)列表或大量回調(diào)訂閱/取消訂閱會導致內(nèi)部?Delegate.Combine
?方法性能消耗達到峰值。
如果頻繁發(fā)生添加/刪除操作,請考慮使用專為快速插入/刪除(而非委托)設計的數(shù)據(jù)結(jié)構(gòu)。
加載線程控制
Unity 允許開發(fā)者控制用于加載數(shù)據(jù)的后臺線程的優(yōu)先級。這一點對于嘗試在后臺將 AssetBundle 流式傳輸?shù)酱疟P時尤為重要。
主線程和圖形線程的優(yōu)先級都是?ThreadPriority.Normal
;任何具有更高優(yōu)先級的線程都會搶占主線程/圖形線程的資源并導致幀率不穩(wěn),而優(yōu)先級較低的線程則不會。如果任何線程與主線程具有相同的優(yōu)先級,則 CPU 會嘗試為這些線程提供相同的時間,在多個后臺線程執(zhí)行繁重操作(例如 AssetBundle 解壓縮)的情況下,這通常會導致幀率卡頓。
目前,可在三個位置控制該優(yōu)先級。
首先,資源加載調(diào)用(如?Resources.LoadAsync
?和?AssetBundle.LoadAssetAsync
)的默認優(yōu)先級來自于?Application.backgroundLoadingPriority?設置。如文檔所述,此調(diào)用還限制了主線程用于集成資源的時間(注意: 大多數(shù)類型的 Unity 資源都必須“集成”到主線程上。集成期間將完成資源初始化并執(zhí)行某些線程安全操作。這包括編寫回調(diào)調(diào)用(例如 Awake 回調(diào))的腳本。請參閱“資源管理”指南以了解更多詳細信息,從而限制資源加載對幀時間的影響。
其次,每個異步資源加載操作以及每個 UnityWebRequest 請求都返回一個?AsyncOperation
?對象以監(jiān)控和管理該操作。此?AsyncOperation
?對象會顯示?priority?屬性,該屬性可用于調(diào)整各個操作的優(yōu)先級。
最后,WWW 對象(例如從?WWW.LoadFromCacheOrDownload
?調(diào)用返回的對象)會顯示threadPriority?屬性。請務必注意,WWW 對象不會自動使用?Application.backgroundLoadingPriority
?設置作為其默認值;WWW 對象總是被默認為?ThreadPriority.Normal
。
值得注意的是,用于底層系統(tǒng)在處理解壓縮和加載數(shù)據(jù)時,不同 API 之間存在差異。Resources.LoadAsync
?和?AssetBundle.LoadAssetAsync
?由 Unity 的內(nèi)部 PreloadManager 系統(tǒng)進行處理,該系統(tǒng)可管理自己的加載線程并執(zhí)行自己的速率限制。UnityWebRequest
?使用自己的專用線程池。WWW
?在每次創(chuàng)建請求時都會生成一個全新的線程。
雖然所有其他加載機制都有內(nèi)置的排隊系統(tǒng),但 WWW 卻沒有。在大量經(jīng)過壓縮的 AssetBundle 上調(diào)用?WWW.LoadFromCacheOrDownload
?會生成相同數(shù)量的線程,這些線程隨后會與主線程競爭 CPU 時間。這很容易導致幀率卡頓。
因此,使用 WWW 來加載和解壓縮 AssetBundle 時,最佳做法是為創(chuàng)建的每個 WWW 對象的?threadPriority
?設置適當?shù)闹怠?/p>
大批量對象移動和 CullingGroup
正如“變換操作”部分所述,由于需要傳播更改消息,移動大型變換層級視圖的 CPU 成本相對較高。但是,在實際開發(fā)環(huán)境中,通常無法將層級視圖精簡為少量的游戲?qū)ο蟆?/p>
同時,在開發(fā)中最好僅運行那些能維持游戲世界可信度的行為,并去掉那些用戶不會注意到的行為;例如,在具有大量角色的場景中,較好的做法是僅對屏幕上的角色執(zhí)行網(wǎng)格蒙皮和動畫驅(qū)動的變換運動。對于屏幕上看不到的角色,消耗 CPU 時間來計算模擬它們的純視覺元素是種浪費。
使用 Unity 5.1 中首次引入的?CullingGroup?API 可以很好地解決這兩個問題。
不要直接操作場景中的一大群游戲?qū)ο?,應該對系統(tǒng)進行更改以操作 CullingGroup 中的一群 BoundingSphere 的 Vector3 參數(shù)。每個 BoundingSphere 充當單個游戲邏輯實體的世界空間位置的表征,并在實體移動到 CullingGroup 主攝像機的視錐體附近/內(nèi)部時接收回調(diào)。然后,可使用這些回調(diào)來激活/停用特定代碼或組件(例如 Animator),從而控制那些僅應在實體可見時才需要運行的行為。
減少方法調(diào)用開銷
C# 的字符串庫提供了一個絕佳的案例研究,其中說明了向簡單庫代碼添加額外方法調(diào)用的成本。在有關(guān)內(nèi)置字符串 API?String.StartsWith
?和?String.EndsWith
?的部分中,提到了手工編碼的替換比內(nèi)置方法快 10–100 倍,即使關(guān)閉了不需要的區(qū)域設置強制轉(zhuǎn)換時也是如此。
這種性能差異的主要原因僅僅是向緊湊內(nèi)循環(huán)添加額外方法調(diào)用的成本不同。調(diào)用的每個方法都必須在內(nèi)存中找到該方法的地址,并將另一個幀推入棧。所有這些操作都是有成本的,但在大多數(shù)代碼中,它們都小到可以忽略不計。
但是,在緊湊循環(huán)中運行較小的方法時,因引入額外方法調(diào)用而增加的開銷可能會變得非常顯著,甚至占主導地位。
請考慮以下兩個簡單方法。
示例 1:
int Accum { get; set; }?
Accum = 0;?
for(int i = 0; i < myList.Count; i++) {
Accum += myList[i];?
}
示例 2:
int accum = 0;?
int len = myList.Count;?
for(int i = 0; i < len; i++) {
accum += myList[i];?
}
這兩個方法都在 C# 通用?List<int>
?中計算所有整數(shù)之和。第一個示例是更“現(xiàn)代的 C#”,因為它使用自動生成的屬性來保存其數(shù)據(jù)值。
雖然從表面上看這兩段代碼似乎是等效的,但通過分析代碼中的方法調(diào)用情況,可看出差異很明顯。
示例 1:
int Accum { get; set; }?
accum = 0;?
for(int i = 0;
i < myList.Count; // 調(diào)用 List::getCount?
i++) {
Accum ? ? ? // 調(diào)用 set_Accum?
+= ? ? ?// 調(diào)用 get_Accum?
myList[i]; ?// 調(diào)用 List::get_Value
}
每次循環(huán)執(zhí)行時都有四個方法調(diào)用:
myList.Count
?調(diào)用?Count
?屬性上的?get
?方法必須調(diào)用?
Accum
?屬性上的?get
?和?set
?方法通過?
get
?檢索 accum
?的當前值,以便將其傳遞給加法運算通過?
set
?將加法運算的結(jié)果分配給 accum
[]?運算符調(diào)用列表的?get_Value?方法來檢索列表特定索引位置的項值。
示例 2:
int accum = 0;?
int len = myList.Count;?
for(int i = 0;i < len;i++) {
accum += myList[i]; // 調(diào)用 List::get_Value?
}
在第二個示例中,get_Value
?調(diào)用仍然存在,但已刪除所有其他方法或不再是每個循環(huán)迭代便執(zhí)行一次。
由于?
accum
?現(xiàn)在是原始值而不是屬性,因此不需要進行方法調(diào)用來設置或檢索其值。由于假設?
myList.Count
?在循環(huán)運行期間不變化,其訪問權(quán)限已移出循環(huán)的條件語句,因此不再在每次循環(huán)迭代開始時執(zhí)行它。
這兩個版本的執(zhí)行時間顯示了從這一特定代碼片段中減少 75% 方法調(diào)用開銷的真正優(yōu)勢。在現(xiàn)代臺式機上運行 100,000 次的情況下:
示例 1 需要的執(zhí)行時間為 324 毫秒
示例 2 需要的執(zhí)行時間為 128 毫秒
這里的主要問題是 Unity 執(zhí)行非常少的方法內(nèi)聯(lián)(即使有)。即使在 IL2CPP 下,許多方法目前也不能正確內(nèi)聯(lián)。對于屬性尤其如此。此外,虛擬方法和接口方法根本無法內(nèi)聯(lián)。
因此,在源代碼 C# 中聲明的方法調(diào)用很可能最后在最終的二進制應用程序中產(chǎn)生方法調(diào)用。
簡單屬性
為了方便開發(fā)者,Unity 為數(shù)據(jù)類型提供了許多“簡單”常量。但是,鑒于上述情況,必須注意這些常量通常作為返回常量值的屬性。
Vector3.zero 的屬性內(nèi)容如下所示:
get { return new Vector3(0,0,0); }
Quaternion.identity 非常相似:
get { return new Quaternion(0,0,0,1); }
雖然訪問這些屬性的成本與它們周圍的執(zhí)行代碼相比小的多,但它們每幀執(zhí)行數(shù)千次(或更多次)時,可產(chǎn)生一定的影響。
對于簡單的原始類型,請改用?const
?值。Const
?值在編譯時內(nèi)聯(lián) - 對?const
?變量的引用將替換為其值。
注意:因為對?const
?變量的每個引用都替換為其值,所以不建議聲明長字符串或其他大型數(shù)據(jù)類型?const
。否則,由于最終二進制代碼中的所有重復數(shù)據(jù),將導致不必要地增加最終二進制文件的大小。
當?const
?不適合時,應使用?static readonly
?變量。在有些項目中,即使 Unity 的內(nèi)置簡單屬性也替換成了?static readonly
?變量,使性能略有改善。
簡單方法
簡單方法比較棘手。如果能夠在聲明一次功能后在其他地方重用該功能,將非常有用。但是,在緊湊內(nèi)部循環(huán)中,可能有必要打破美觀編碼規(guī)則,選擇“手動內(nèi)聯(lián)”某些代碼。
有些方法可能需要徹底刪除。例如,Quaternion.Set
、Transform.Translate
?或?Vector3.Scale
。這些方法執(zhí)行非常簡單的操作,可以用簡單的賦值語句替換。
對于更復雜的方法,應權(quán)衡手動內(nèi)聯(lián)的性能提升與維護性能更高代碼的長期成本之間的關(guān)系。