金秋9月,我們祝所有的老師們:教師節快樂 !
今天,我們繼續分享來自捷克的開發工程師Ondřej Kofroň,分享C#腳本的一系列優化方法。
在
優化Unity遊戲項目的腳本(上) 中,我們介紹了如何查找C#腳本中的問題,以及垃圾回收的處理。本文我們将介紹如何減少C#腳本的執行時間。
第二部分:減少腳本的執行時間
如果代碼不經常調用,這部分提到的一些規則可能不會産生明顯的作用。在我們的項目中,我們有一個每幀執行的大型循環,是以在該代碼中,即使做很小的改動,也會産生很明顯的作用。
如果使用方法不當或在不合适的情形下使用,部分改動可能會産生更差的執行時間。每次對代碼進行優化方面的改動後,要記得檢視性能分析器,確定改動有理想的效果。
這部分的一些規則可能會導緻代碼變得難以了解,甚至可能會破壞最佳程式設計實踐。這部分的許多規則和第一部分的規則有所重複。和不配置設定垃圾的代碼相比,垃圾配置設定代碼通常有更差的執行效果,建議在閱讀這部分内容之前,先仔細閱讀本文的第一部分。
規則1:使用合适的執行順序
将代碼從FixedUpdate,Update和LateUpdate方法轉移到Start和Awake方法中。雖然這聽起來不現實,但如果深入分析代碼,你會發現有數百行代碼可以移動到僅執行一次的方法中。
在我們的項目中,這類代碼通常和下面操作有關:
GetComponent<> Calls
每幀傳回相同結果的計算過程
重複執行個體化相同的對象,通常這類對象為List清單
尋找某些遊戲對象
擷取對Transform的引用,使用其它通路器
下面是我們從Update方法移動到Start方法的代碼。
複制代碼
//将GetComponent留在Update方法必須有很好的理由
gameObject.GetComponent();
//每幀傳回相同結果的計算過程示例
Mathf.FloorToInt(Screen.width / 2);
var width = 2f mainCamera.orthographicSize mainCamera.aspect;
var castRadius = circleCollider.radius * transform.lossyScale.x;
var halfSize = GetComponent().bounds.size.x / 2f;
//尋找對象
var levelObstacles = FindObjectsOfType();
var levelCollectibles = FindGameObjectsWithTag("COLLECTIBLE");
//引用
objectTransform = gameObject.transform;
mainCamera = Camera.main;
規則2:僅在需要時運作代碼
在我們的項目中,這種情況大多和更新UI的腳本有關。下面是我們修改的代碼實作,它們作用是顯示關卡中“可收集物品”的目前狀态。
Text text;
GameState gameState;void Start()
{
gameState = StoreProvider.Get<GameState>();
text = GetComponent<Text>();
}void Update()
{
text.text = gameState.CollectedCollectibles.ToString();
}
因為我們在每關中隻有少量可收集物品,每幀都修改UI文本不會産生很大作用,是以我們僅在實際數字變化時改變文本。
Text text;
GameState gameState;
int collectiblesCount;
void Start()
{
gameState = StoreProvider.Get<GameState>();
text = GetComponent<Text>();
collectiblesCount = gameState.CollectedCollectibles;
}
void Update()
{
if(collectiblesCount != gameState.CollectedCollectibles) {
//該代碼每關隻運作5次
collectiblesCount = gameState.CollectedCollectibles;
text.text = collectiblesCount.ToString();
}
}
這樣的的代碼性能會更好,特别在代碼作用不隻是簡單的UI變化時,會有更好的效果。如果想要更複雜的解決方法,建議通過使用C#事件實作觀察者設計模式。
但對于對我們而言還不夠好,我們希望實作完全通用的解決方案,是以我們建立了在Unity實作Flux架構的代碼庫。
這樣可以實作非常簡單的解決方案,我們把所有遊戲狀态都存儲到“Store”對象,在任何狀态發生改變時,所有UI元素和其它元件都會得到通知,然後它們會對改變做出反應,整個過程不需要在Update方法使用任何代碼。
了解C#事件:
https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/events/了解觀察者設計模式:
https://en.wikipedia.org/wiki/Observer_pattern了解Flux架構:
https://facebook.github.io/flux/規則3:小心循環代碼
這條規則和第一部分的第9條規則相同。如果代碼中有循環會疊代大量元素,請使用本文第二個部分提到的所有規則,來改進循環代碼的性能。
規則4:使用For循環代替Foreach循環
Foreach循環很容易編寫,但是執行起來卻很複雜。Foreach循環在内部會使用枚舉器來疊代特定資料集,并傳回數值。
這比在For循環疊代索引複雜很多。是以在我們的項目中,隻要可以的話,我們都會把Foreach循環改為For循環,如下所示。
foreach (GameObject obstacle in obstacles)
var count = obstacles.Count;
for (int i = 0; i < count; i++) {
obstacles[i];
}
在我們的大型For循環中,這項改動的效果非常明顯。通過使用簡單的For循環,我們實作了速度比原來快2倍的代碼。
規則5:使用Array數組代替List清單
在代碼中,我們發現大多數清單有固定的長度,或是可以計算出最大成員數量。是以我們使用數組重新實作了這些清單,在特定情況下,可以在疊代資料的時候得到原先2倍的速度。
在某些情況下,我們無法避免使用清單或其它複雜的資料結構。常見的情況是:需要經常添加或移除元素的時候,使用清單的效果更好的時候。通常來說,我們會對固定大小的清單使用數組。
規則6:使用Float運算替代Vector運算
Float運算和Vector運算的差別不是很明顯,除非像我們一樣進行上千次運算,是以對我們來說,這項改動的性能提升效果非常明顯。
改動如下所示。
Vector3 pos1 = new Vector3(1,2,3);
Vector3 pos2 = new Vector3(4,5,6);
var pos3 = pos1 + pos2;
var pos3 = new Vector3(pos1.x + pos2.x, pos1.y + pos2.y, ......);
Vector3 pos1 = new Vector3(1,2,3);
//未優化的代碼
var pos2 = pos1 * 2f;
//優化後的代碼
var pos2 = new Vector3(pos1.x 2f, pos1.y 2f, ......);
規則7:尋找對象屬性
一定要考慮是否必須使用GameObject.Find()方法。該方法會産生很大開銷,占據大量時間。最好不在Update方法中使用GameObject.Find()方法。
我們發現大多數GameObject.Find()調用可以替換為編輯器中直接引用的關聯,這是更好的方法。
//在編輯器中把該引用指定給玩家對象
[SerializeField]
GameObject player;
void Start()
{
}
如果無法這樣處理,開發者至少應該考慮使用Tag标簽,通過使用GameObject.FindWithTag,根據标簽來找到對象。
總體上說,三種方法的優先級為:直接引用 > GameObject.FindWithTag() > GameObject.Find()。
規則8:隻處理相關對象
在我們的項目中,該規則對使用RayCasts和CircleCasts等函數的碰撞檢查功能有明顯的效果。我們不必在代碼中檢測所有碰撞并決定哪些對象是相關的,隻需把遊戲對象都移動到合适的圖層即可,這樣我們可以隻對相關對象計算碰撞。
下面是示例代碼。
void DetectCollision()
{
var count = Physics2D.CircleCastNonAlloc(
position, radius, direction, results, distance);
for (int i = 0; i < count; i++) {
var obj = results[i].collider.transform.gameObject;
if(obj.CompareTag("FOO")) {
ProcessCollision(results[i]);
}
}
}
//我們把所有帶有标簽FOO的對象都放入了相同的圖層
void DetectCollision()
{
//8是相應圖層的編号
var mask = 1 << 8;
var count = Physics2D.CircleCastNonAlloc(
position, radius, direction, results, distance, mask);
for (int i = 0; i < count; i++) {
ProcessCollision(results[i]);
}
}
規則9:使用标簽屬性
标簽非常實用,可以提升代碼的性能,但請記住:隻有一種正确方法适合比較對象标簽。
gameObject.Tag == "MyTag";
gameObject.CompareTag("MyTag");
規則10:小心棘手的錄影機
使用Camera.main很簡單,但是這種操作的性能非常糟糕。這是因為在每個Camera.main調用背後,Unity其實會執行FindGameObjectsWithTag()來擷取結果,是以頻繁調用Camera.main并不好。
最好的解決方法是在Start或Awake方法中緩存Camera.main的引用。
void Update()
{
Camera.main.orthographicSize //對錄影機的一些操作
}
private Camera cam;
void Start()
cam = Camera.main;
cam.orthographicSize //對錄影機的一些操作
規則11:使用LocalPosition替代Position
在代碼允許的位置,為擷取函數(Getter)和設定函數(Setter)使用Transform.LocalPosition替代Transform.Position。
這樣的原因是:每次Transform.Position調用的背後,都會有更多操作要執行,包括在調用擷取函數時計算全局位置,或是在調用設定函數時從全局位置計算出本地位置。
在項目中,我們發現在出現Transform.Position的幾乎所有情況中都可以用LocalPosition替代Transform.Position,無需在代碼中做其它改動。
規則12:不要使用LINQ
這條規則已經第一部分講解,别使用LINQ就好。
規則13:不要害怕破壞最佳實踐
有時候,即使是一個簡單的函數調用,都可能造成過多的開銷。在這種情況下,我們應該考慮使用代碼内聯。
代碼内聯是什麼?代碼内聯意味着,我們可以把代碼從函數中取出,把這些代碼直接複制到打算使用函數的位置,進而避免額外的方法調用。
因為代碼内聯會在編譯時自動完成,是以在大多數情況下,這樣不會産生太明顯的效果。在編譯器決定代碼是否内聯時,會使用特定的規則。例如:Virtual方法從不會内聯,更多詳細資訊,請通路:
https://docs.unity3d.com/Manual/BestPracticeUnderstandingPerformanceInUnity8.html。
我們需要打開性能分析器,在實際裝置上運作遊戲,進而了解是否仍有改進的空間。
在項目中,我們發現一些函數可以通過代碼内聯實作更好的性能,特别是遊戲的大型For循環中的函數。
小結
通過使用本文介紹的規則,我們在iOS遊戲中輕松實作了穩定的60fps,即使在iPhone 5S也有這樣的運作效果。
本文的部分規則針對我們的用例而使用,但我認為在進行程式設計或代碼評審時,開發者應該考慮這些規則,進而避免在後期階段出現問題。在考慮性能因素的同時編寫代碼會比之後重構大量代碼更簡單。