Unity 2017 Game Optimizaiton簡單翻譯和總結(二):腳本編寫策略

腳本的開發佔用我們的開發的大部分時間,所以學習一些技巧有很大的好處。腳本涉及的範圍很廣,這一章就只介紹unity腳本相關的,關於一些圍繞MonoBehaviours, Gameobjects和相關的函數的問題。
這一章,在一下幾個方面來探討性能提高的方法:

  • 訪問組件
  • 組件的回調(update(), Awake()等)
  • 協程
  • 內部object的通信
  • 數學的運算
  • 場景和prefab載入

使用最快的方法獲取components
GetComponnet()方法有三個重載, 每個都有不同的開銷, 因此在選擇版本的時候要謹慎, 三個方法爲:
1.Getcomponnet(string)
2.GetComponents();
3.GetComponnets(typeof(T))
最快的版本依賴於你正在使用的unity版本,不過從unity5以後, 最好使用GetComponnet()方法。
它只比GetComponnet(typeof(T))快一點,但是GetComponent(string)是非常慢的。

移除空的調用的定義
unity中的腳本的最初的方法是在類中定義繼承自MonoBehaviour的回調函數,這些函數會在必要的時候由unity調用。四種最常用的方法是Awake(), Start(), Update(), FixedUpdate()。
Awake()將在MonoBehaviour第一次創建的時候調用,不管是在場景初始化或者新的包含Mono的Gameobject在運行時從prefab實例化的時候。Start()將會在Awake()後調用,不過在第一次的Update()之前。
初始化後, update()將會重複調用,每次渲染通道提供新的畫面。當mono的父物體Gameobject是激活狀態,update()就會持續的調用。
FixedUpdate()在物體引擎update之前調用,並在yield waitFixedupdate()結束,爲一個物理循環。 它不會和渲染幀率直接相關,它是基於時間調用的。物理的循環可能在一幀調用多次, 如果fixed時間小於實際的幀的update時間。
關於調用順序的官方說明:

官方文檔
當場景中的MonoBehaviour被第一次實例化,unity將腳本中定義的回調添加到函數指針列表中,在特殊時刻調用。我們需要意識到不管這個回調函數是否是空的,untiy都會調用。unity引擎不知道這個方法是否是空的,它只知道是否被定義。當場景中有上千個腳本都只是個空的定義,就會引起場景初始化的緩慢和浪費CPU時間。尤其是執行關鍵遊戲事件發生這用空的調用,比如創建粒子特效,傷害飄字等,出現實例化的。突然請求CPU進行大量的變換,但是隻有有限的時間來處理。
同時,如果有空的update,將會在每一幀都浪費大量的CPU資源。
解決辦法就是刪除空的回調定義,但是找到空的定義比較困難,不過可以利用正則表達式來查找。
當然了,其他的回調也會造成這種問題,比如OnGUI(), OnEnable(), OnDestory()等,要特別注意OnGUI(),它可能在一幀中調用多次。
可能最多出現的性能問題的原因都是unity腳本的update()回調中的錯誤使用:
1.對一些極少變換的值的重複計算
2.對一些可能共享的結果在多個組件中計算。
3.不必要的處理
在upate()編寫每一行代碼時,要養成習慣來考慮這些問題。

緩存component的引用
在unity編寫腳本時,重複的計算某個值是常見的錯誤,特別是涉及到GetComponent()方法。
在這裏插入圖片描述

這個方法每次執行,都要重新獲取5個不同組件的引用,這對於CPU的使用時非常不友好的。特別是當這個方法在update()中調用時。
將這些引用保存起來等待將來使用,只會銷燬很少的內存空間,因此,除非處在內存的極端瓶頸, 最好的方法就是在初始化時獲取這些引用並保存,雖然佔用了少量內存,但是是值得的。

共享計算的結果
每次進行開銷比較大的操作時,就要思考是否正在被多個地方調用,但是得到的結果是一致的,如果是這種情況,就要重構代碼,達到只計算一次,然後將結果分發給需要的object,減少重新計算的次數。
養成在基類中隱藏複雜的函數,並定義派生類來利用這個函數的習慣。最好使用unity Profiler來統計這個開銷函數可能被調用了多少次,不預優化函數,除非已經證明了它是性能問題。

Update, 協程,InvokeRepeating
另一個容易犯錯的習慣就是在update()中調用某個函數超過必要的次數。
比如

void Update() 
{
    ProcessAI();
}

在update()的每一幀都調用AI的處理函數,這函數可能是非常複雜的,需要AI系統來計算尋路的網格或者一些其他的需求。如果這個調用會驗證影響到幀率,並且這個調用可以更低頻率的計算但是不會造成顯著的問題,最好的優化方法就是減少ProcessAI()的調用次數。可以通過Time.deltatime來計算時間,控制在一秒中調用5次。代價就是代碼看起來不太直觀,很小的內存佔用來保存float變量

private float delay = 0.2f;
private flaot timer = 0.0f;
void Update()
{
    timer += Time.deltaTime;
    if(timer > delay)
    {
        ProcessAI();
        timer -= delay;
    }
}

這個例子可以轉換成協程,利用攜程的延遲調用的特性。協程主要被用來執行短期的序列事件一次,或者多次。我們要和線程區分開,線程是可以以併發的方式運行在完全不同的CPU上, 多個線程可以同時運行。但是協程只能在主線程中以順序執行的方式,在一個時刻只能有一個協程執行,每個線程都通過yield狀態來決定何時繼續和掛起。

void Start() 
{
    StartCoroutine(ProcessAICoroutine());
}
IEnumerator ProcessAICoroutine() 
{
    while(true)
    {
        ProcessAI();
        yield return new WatiForSeconds(delay);
    }
}

在協程中,通過yield來暫停,可以控制調用的次數。
不過使用協程也會帶來額外的開銷,需要一些內存分配來保存當前的狀態。因此我們要權衡協程帶來的利與弊。而且, 一旦初始化,協程的運行就獨立於MonoBehaviour組件的update(), 它將會繼續的執行而不管腳本是否可用,如果我們會使用大量gameobject的創建和銷燬時使用就非常不明智。協程會自動停止因爲它綁定的gameobject會關閉,但是gameobject重新激活時,卻不會自動重新開始。
我們通過將方法轉換爲協程實現,我們可以從整體上減少性能問題,但是如果是調用的方法本身影響幀率,比如不管我們調用方法次數有多少,仍然會超出範圍。因此這個方法最好的使用情景就是,由於方法調用次數問題而導致的瓶頸,而不是這個方法本身消耗太大。
接下來介紹一些yield的類型:

  1. waitForSeconds , 暫停給定的秒數
  2. waitForSecondsRealtime , 它不同於waitForSeonds, 它使用非縮放時間, WaitForSconds是受全局的Time.timeScale的影響。因此在我們修改了TimeScale時,使用yield要小心。
  3. waitForEndOfFrame, 將會在下一個update之後繼續執行。waitForFixedUpate,將會在下一個FixedUpdate()結束後繼續。

我們在一些update() 中的實現,可以用協程更簡潔的使用yield類型來實現。但是我們應該意識到上面提到的可能的問題。協程觸發在調用堆棧中是找不到調用者,因此如果協程處理複雜的任務並和其他的子系統有交互,可能會導致嚴重的bug,可能是難以復現的。所以,當我們想要使用協程時,最好的方式就是保持協程獨立於其他的複雜子系統。

如果我們的協程很簡單,我們可以將它替換爲InvokeRepeating()調用,它非常簡單,並且有更少的消耗。InvokeRepeating()是完全獨立於monobehaviour和Gameobject的狀態。只有兩個方法能停止invokeRepeating調用。
1.調用CancelInvoke()停止所有的InvokeRepeating調用
2.摧毀相關的MonoBehaviour或者它的父Gameobject ; 禁用mono或gameobject是沒用的。

更快的gameobject空引用檢查
進行gameobjet的null檢查時,會導致不必要的性能問題。GameObjects和MonoBehaviours與C#的典型object比起來,是特殊的對象,以爲它們在內存中有兩個表示。一個存在於我們編寫的C#代碼(託管代碼)管理的內存中,另一個存在於本地代碼(Native code)分隔處理的不同內存空間中。數據可以在兩個內存空間中移動,但是每一次都會造成額外的CPU使用和內存分配。這種現象可以引用爲穿過Native-Managed橋。如果發生了,可能會有額外的內存分配來支持object數據通過橋實現拷貝,這將會引起GC問題。詳細的介紹將在第八章中給出,我們暫且認爲有很多的小問題可以突然導致這個問題,簡單的null引用檢查,就是其中的一個:
if(gameobject != null)
一個可以替代且快兩倍的函數式的方法是system.object.ReferenceEquals()
這個方法可以應用到Gameobjects和MonoBehaviours, 和unity的其他objects, 比如www類。然而一些測試證明了null檢查,僅僅消耗幾納秒的時間, 因爲,除非你使用了大量的null檢查,這個增益可能是最低優先級考慮。但是,我們要記住這個特性,它可能會有很多。

避免檢索gameobject的string類型的屬性
通常情況下在c#中,從對象中檢索或訪問string屬性和檢索其他屬性一樣, 沒有額外的內存消耗。但是從GameObjects中檢索string屬性卻是另一種偶然引起本地-託管交互的方式。
Gameobject中受影響的特效是tag和name,因此在遊戲邏輯中使用這兩個特性是不明智的,可以在一些性能不重要的地方,比如編輯器下。但是tag系統經常被用來運行識別對象。
我們想要避免這種本地-託管交互的消耗,幸運的時,tag屬性經常被用於比較,gameobject提供了CompareTag()方法來替代來tag屬性的比較,可以完全避免本地-託管內存的交互。
在upate中進行測試,分別進行10000000次的比較,可以看到tag屬性導致了360多的內存分配, 我們可以在timelines上看到峯值的GC分配。整個過程花費了3000多ms, 一旦string對象不再需要,進行的垃圾回收消耗了360多ms。
同時,可以看到compareTag方法,整個過程的消耗將近1000ms, 在內存上分配幾乎沒有。通過對比可以知道,我們必須避免使用name和tag屬性,如果必要要使用tag比較,就使用comapreTag。但是name屬性沒有替代品,我們儘量使用tag來替代name。

tag

cmopareTag
使用合適的數據結構
C#的system.Collections命名空間中提供了很多不同的數據結構,我們不應該習慣於每次都使用一樣的數據結構。在軟件開發過程中,比較常見的性能問題就是數據結構的使用不當。兩個最常用的結構時List和Dictionary<K, V>。
如果想迭代一系列的objects, list是最好的選擇,因爲list實際上是動態數組, objects在內存中是連續的,因此會產生很小的緩存未命中問題。
Dictionary用在兩個有聯繫的objects, 我們希望能夠快速獲取,插入或刪除這種聯繫。不過有時候,我們要結合這兩種數據結構,一起使用來解決問題。
什麼是緩存未命中?
由於CPU和內存和工作頻率不一致,如果CPU直接從內存中讀取數據,CPU會花費很長時間來等待數據,所以在CPU和內存之間加入了三級緩存,L1,L2, L3。越靠近CPU,讀取速度越快。CPU讀取數據時先查找L1緩存, 找到就返回,如果L1中不存在,再去L2中查找,然後L3, 如果緩存中未找到,最後纔去內存中查找。如果在緩存中未找到,就是屬於緩存未命中。不過CPU在從內存中讀取數據時,就將相鄰的數據一併讀取到緩存中,以緩存行(cache line)的形式保存在緩存中;對數組的遍歷時, 當遍歷完第一個元素後,相鄰的元素會被加載到緩存中,後續的遍歷時,CPU就可以直接在緩存中找到,不需要到內存中查找。
這樣的讀取機制,能夠使CPU讀取緩存的命中率非常高,在90%左右。

避免在運行時再次設置Transform的父節點
在更早的unity版本中(5.3前), Transform組件的引用在內存中一般是隨機排列的。這意味這由於可能的緩存未命中,導致遍歷多個Transform時比較慢。好處就是重置GameObject的父節點時,不會造成嚴重的性能問題, 因爲對Transforms的操作像堆結構一樣, 在插入和刪除上有很快的速度。
然而,從unity5.4開始,Transform組件的內存放置反生了很大的變化。Transform的父子關係更像動態數組,unity將具有共同父節點的Transform保存在預先分配的內存緩衝中, 並根據transform在Hierarchy中的深度來排序。這樣的數據結構,會有非常快的迭代速度。但是缺點就是,當你修改GameObject的父節點時, 這個父節點必須根據新的子節點來調整預分配的內存緩衝,並根據新的深度信息來重新排序。如果父節點沒有足夠的內存來保存新節點,就需要去擴展內存緩衝。對於比較複雜的Gameobject結構,將會花費一些時間去完成。
當使用GameObject.Instantiate()來實例化新的GameObject時,其中一個參數是想要設置爲Gameobject的父Transfform,默認是null, 如果爲null, Transform將會被放置到Hierarchy 窗口的根節點下。所有的這些根節點下的Transforms需要分配一個緩衝來保存它當前的子節點或者以後將要添加的(子Transform就不需要分配),但是,如果你在Instantiate後,直接重置了Transform的父節點,就會丟棄剛分配的內存。爲了避免這樣,我們應該在調用GameObject.Instantiate()時, 提供了父Transform參數,這樣可以跳過緩衝分配這一步。
另外一個減少這個過程開銷的方法是在我們需要擴展之前,讓根Trasnform預分配更大的緩衝,這樣可以避免在同一幀擴展緩衝同時,又重置Gameobject父節點到這個buffer中。可以通過修改Transform組件的hierarchyCapacity屬性來解決。如果可以預估父節點可能容納的子節點數量,我們可以節省很多不必要的內存分配。
(未完待續。。。)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章