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

本文將由捷克獨立遊戲工做室Lonely Vertex的開發工程師Ondřej Kofroň,分享C#腳本的一系列優化方法,並提供改進Unity遊戲性能的最佳實踐。數組

在開發遊戲時,咱們遇到了遊戲過程當中偶爾出現延遲的問題。在使用Unity性能分析器進行分析後,咱們問題主要來源於:未優化的着色器和未優化的C#腳本。緩存

本文將主要介紹如何優化Unity遊戲項目的C#腳本的方法。編輯器

54608-20191009170224294-1350579942.png

尋找問題ide

Unity性能分析器是尋找形成卡頓腳本的最佳方法。我強烈建議直接在設備上對遊戲進行性能分析,而不是在編輯器中進行性能分析。函數

本文中分享的遊戲項目面向iOS,因此咱們須要鏈接設備。以下圖所示,設置Build Settings,而後性能分析器就會自動鏈接。性能

54608-20191009170250979-799306433.png

若是咱們在網上搜索「Unity中的偶發卡頓」或相似關鍵詞,你可能會發現大多數人建議重點處理垃圾回收。優化

每當中止使用某些對象即類的實例後,便會產生垃圾,而後會不定時運行Unity垃圾回收器來清理垃圾,取消分配相關的內存,這樣的工做會佔據大量時間,形成幀率下降現象。ui

如何在性能分析器中找到致使垃圾分配的腳本?spa

咱們選中CPU Usage部分,以下選擇Hierarchy視圖,單擊GC Alloc,從而根據GC Alloc的狀況進行排序。3d

下圖爲垃圾回收的Profiler設置。
54608-20191009170303375-2133619569.png

咱們的目標是在遊戲場景中,使GC Alloc欄的全部數值都爲0。一個不錯的作法是根據Time ms(即執行時間)排序記錄,而後優化腳本,使其儘量使用較少時間。

這對咱們而言極爲重要,由於遊戲中有一個組件包含一個大型for循環,該循環要使用不少時間來執行,並且咱們目前沒法處理好這個循環,因此優化全部腳本的執行時間很是必要,咱們須要爲耗時較長的for循環節省一些執行時間,同時保持60fps。

根據性能分析的結果,咱們將優化過程分爲兩個部分:

處理垃圾回收

減小執行時間

第一部分:處理垃圾回收

咱們將關注處理垃圾回收的過程。這些內容是每位開發者都應該掌握的基礎知識,也是咱們平時對同步和合並請求,進行代碼評審的重要部分。

規則1:不在Update方法中建立新對象

理想狀況下,開發者在Update、FixedUpdate或LateUpdate方法中不該該使用New關鍵字,而是應該使用已有對象。

有時新對象建立過程會隱藏在一些Unity內部方法中,因此過程並不明顯。

規則2:一次建立,屢次重用

這條規則的意思是:要在Start方法和Awake方法中分配全部內容。這條規則和第一條相似,其實只是從Update方法移除new關鍵字的另外一種方式。

開發者應該從Update方法移除有如下行爲的代碼:

建立新實例

尋找任意遊戲對象

而後,將這些代碼移動到Start方法或Awake方法中。

下面是咱們進行的改動示例。

咱們能夠在Start方法分配List列表,在須要時使用Clear函數清空列表,並在任何位置重用這些內容。

//未優化的代碼

`private List<GameObject> objectsList;void Update()
{

objectsList = new List<GameObject>();
objectsList.Add(......)

}`
  

//優化後的代碼

`private List<GameObject> objectsList;void Start()
{

objectsList = new List<GameObject>();

}
  `

`void Update()
{

objectsList.Clear();
objectsList.Add(......)

}
`

進行存儲和重用引用的過程以下面代碼所示。

//未優化的代碼

`void Update()
{

var levelObstacles = FindObjectsOfType<Obstacle>();
foreach(var obstacle in levelObstacles) { ....... }

}`
  

//優化後的代碼

`private Object[] levelObstacles;

void Start()
{

levelObstacles = FindObjectsOfType<Obstacle>();

}

void Update()
{

foreach(var obstacle in levelObstacles) { ....... }

}
  `

相同的規則也適用於FindGameObjectsWithTag等返回新數組的其它方法。

規則3:當心處理字符串,避免字符串鏈接

在涉及到垃圾分配的時候,字符串要特別注意。即便是基本的字符串操做,也可能產生大量垃圾。這是爲何呢?

由於字符串是沒法改變的數組。這意味着,若是要把兩個字符串鏈接起來,咱們會建立新數組,而舊數組會成爲垃圾。因此咱們可使用StringBuilder避免或最小化這類垃圾分配。

下面是改進該過程的示例。

//未優化的代碼

`void Start()
{

text = GetComponent<Text>();

}

void Update()
{

text.text = "Player " + name + " has score " + score.toString();

}`
  

//優化後的代碼

`void Start()
{

text = GetComponent<Text>();
builder = new StringBuilder(50);

}`

`void Update()
{

//StringBuilder爲全部類型重載了Append方法
builder.Length = 0;
builder.Append("Player ");
builder.Append(name);
builder.Append(" has score ");
builder.Append(score);
text.text = builder.ToString();

}`
  

示例中的原代碼沒什麼問題,但仍有很大的改進空間。咱們發現,幾乎整個字符串均可以視爲靜態,因此咱們把字符串分爲兩個部分,放到兩個UI.Text對象中。

第一個對象只包含靜態文字「Player 「 + name + 「 has score 「 ,能夠在Start方法中指定。第二個對象包含每幀更新的Score數值,咱們要使靜態字符串徹底是靜態的,在Start方法或Awake方法中生成這類字符串。

通過改進,代碼已經很好了,可是調用Int.ToString()、Float.ToString()等函數仍會有垃圾產生。

咱們經過生成和預分配全部可能的字符串來解決該問題。這樣可能聽起來不是好方法,並且會消耗不少內存,但它完美知足了咱們的需求,並完全解決了這個問題。

咱們最後獲得可使用索引直接訪問的靜態數組,從而獲取表示數字的請求字符串。

public static readonly string[] NUMBERS_THREE_DECIMAL = {

"000", "001", "002", "003", "004", "005", "006",..........

規則4:緩存訪問器返回的數值

這種方法可能很難使用,由於即便是簡單的訪問器也會產生垃圾。

//未優化的代碼

`void Update()
{

gameObject.tag;
//or

//或
gameObject.name;

}
`

嘗試避免在Update方法中使用訪問器,只在Start方法中調用一次訪問器,並緩存返回的數值。

一般,建議不在Update方法中調用任何字符串訪問器或數組訪問器。在多數狀況下,咱們只須要在Start方法中獲取一次引用。

下面是未優化訪問器代碼的兩個常見示例。

//未優化的代碼

`void Update()
{

//分配包含全部touches的新數組
Input.touches[0];

}`
  

//優化後的代碼

`void Update()
{

Input.GetTouch(0);

}`
  

//未優化的代碼

`void Update()
{

//返回新的字符串(垃圾),而後對比2個字符串
gameObject.Tag == "MyTag";

}`
  

//優化後的代碼

`void Update()
{

gameObject.CompareTag("MyTag");

}`

規則5:使用NonAlloc函數

對於特定Unity函數,咱們能夠找到不分配任何內存的替代函數。在咱們的項目中,這些函數都和物理功能有關。咱們在碰撞檢測使用的函數以下。

Physics2D. CircleCast();

對於該函數,咱們能夠找到不分配任何內存的版本。

Physics2D. CircleCastNonAlloc();

許多其它函數都有相似的替代函數,所以請記得查看文檔,瞭解函數是否有相應的NonAlloc版本。

規則6:不要使用LINQ

儘量不要使用LINQ。也就是說,不要在任何常常執行的代碼中使用LINQ。

雖然使用LINQ可使代碼更容易閱讀,但在不少狀況下,這類代碼的性能和內存分配都很是糟糕。

規則7:一次建立,屢次重用(續)

此次咱們要講的是對象池,若是你不瞭解對象池,請閱讀教程:

https://learn.unity.com/tutor...

在咱們的遊戲中,有一種狀況使用了對象池。咱們有一個生成的關卡,裏面充滿了只在限定時間存在的障礙物,障礙物在玩家經過相應關卡部分後會消失。

在知足特定條件時,障礙物會從預製件進行實例化。相應的代碼位於Update方法中,對於性能和執行時間而言,代碼是很是低效的。

咱們的解決方法是:生成40個障礙物組成的對象池,在須要時從對象池取用這些障礙物對象,在障礙物再也不須要時,把障礙物對象返回到對象池。

規則8:注意裝箱過程

裝箱過程會生成垃圾。什麼是裝箱過程呢?

最多見的裝箱過程是將數值類型,例如int,float,bool等傳遞到須要Object類型參數的函數時,所發生的過程。

下面是咱們要在項目中處理的裝箱過程。

咱們在項目實現了自定義通訊系統。每一個信息能夠包含數量不限的數據。該數據存儲在字典中,字典的定義以下。

Dictionary<string, object> data;

咱們有設置函數(Setter),用來將數值設置到該字典中。

public Action SetAttribute(string attribute, object value)
{

data[attribute] = value;

}

這裏的裝箱過程很明顯,咱們能夠這樣調用該函數。

SetAttribute("my_int_value", 12);

所以,這裏的數值12進行裝箱時,會產生垃圾。

咱們的解決方法是:爲每一個基本類型使用單獨的數據容器,以前的Object容器僅用於引用類型。

Dictionary<string, object> data;

Dictionary<string, bool> dataBool;
Dictionary<string, int> dataInt;
.......

爲每一個數據類型使用單獨的設置函數。

SetBoolAttribute(string attribute, bool value)
SetIntAttribute(string attribute, int value)

而後實現全部設置函數,使它們調用相同的通用函數:

SetAttribute<T>(ref Dictionary<string, T> dict, string attribute, T value)

這樣裝箱過程就被咱們移除了。若是你想了解更多細節,請閱讀下面的文檔:

https://docs.microsoft.com/zh...

規則9:當心循環代碼

這條規則相似第1,2條規則。儘量把全部沒必要要代碼從循環中去掉,從而取得更好的性能和內存分配。

咱們要嘗試避免在Update方法中使用循環,但若是有這個須要,咱們至少要在這種循環中避免出現內存分配。所以,不只是針對Update方法,咱們也要在循環代碼中遵循前8條規則。

規則10:確保外部代碼庫不產生垃圾

若是發現部分垃圾由Asset Store資源商店下載的代碼產生,咱們有多個解決方法。但在咱們進行逆向工程並調試前,請再次查看Asset Store資源商店的相應頁面,代碼庫是否有進行更新。

在咱們的項目中,咱們使用的全部資源一直由資源的開發者進行維護,他們一直在進行性能更新,從而解決了咱們的全部問題。

因此,必定要讓項目使用的依賴保持更新。若是遇到沒有維護的代碼庫,建議放棄這類代碼庫。

小結

因爲篇幅限制,本文介紹了優化Unity遊戲項目的C#腳本的第一部分處理垃圾回收,下篇咱們將分享如何將執行時間減小,敬請期待!

相關文章
相關標籤/搜索