天天看點

優化Unity遊戲項目的腳本(下)

金秋9月,我們祝所有的老師們:教師節快樂 !

今天,我們繼續分享來自捷克的開發工程師Ondřej Kofroň,分享C#腳本的一系列優化方法。

優化Unity遊戲項目的腳本(上) 中,我們介紹了如何查找C#腳本中的問題,以及垃圾回收的處理。本文我們将介紹如何減少C#腳本的執行時間。
優化Unity遊戲項目的腳本(下)

第二部分:減少腳本的執行時間

如果代碼不經常調用,這部分提到的一些規則可能不會産生明顯的作用。在我們的項目中,我們有一個每幀執行的大型循環,是以在該代碼中,即使做很小的改動,也會産生很明顯的作用。

如果使用方法不當或在不合适的情形下使用,部分改動可能會産生更差的執行時間。每次對代碼進行優化方面的改動後,要記得檢視性能分析器,確定改動有理想的效果。

這部分的一些規則可能會導緻代碼變得難以了解,甚至可能會破壞最佳程式設計實踐。這部分的許多規則和第一部分的規則有所重複。和不配置設定垃圾的代碼相比,垃圾配置設定代碼通常有更差的執行效果,建議在閱讀這部分内容之前,先仔細閱讀本文的第一部分。

規則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也有這樣的運作效果。

本文的部分規則針對我們的用例而使用,但我認為在進行程式設計或代碼評審時,開發者應該考慮這些規則,進而避免在後期階段出現問題。在考慮性能因素的同時編寫代碼會比之後重構大量代碼更簡單。