1. 程式人生 > >Unity遊戲項目性能優化總結 (難度3 推薦4)

Unity遊戲項目性能優化總結 (難度3 推薦4)

節點 alloc debug.log 系統 form 都是 開發 變量聲明 oid

原文地址:

https://zhuanlan.zhihu.com/p/24392681

本文就Unity遊戲項目性能優化作出了總結。包括Profile工具、Unity使用、機制設計、腳本編寫等方面內容。
本文的測試機型皆為iPhone6。為方便找出瓶頸目標幀率先提高為60fps,後面再看實際情況是否限幀30fps。
本文的Unity版本為5.5.0f3或更新版本。

本文將持續更新。

Profiler工具

在Unity項目中,可能使用到的Profiler工具分3種:

  • 長期性能數據監控工具
  • Unity Profiler
  • XCode和Instruments

長期性能數據監控工具會至少每天都對遊戲單局、或遊戲資源進行自動化性能測試,並上報結果到服務器。能從“整體”去對比不同時段、不同版本間的性能差別。

技術分享遊戲資源長期性能監控工具報表


Unity Profiler能定量地找到C#的GC Alloc問題;其Timeline視圖也能從地整體(但不太定量)找到CPU瓶頸。

技術分享Unity Timeline Profiler


XCode的GPU Report視圖能從整體(但不太定量)找到遊戲的瓶頸階段。當Frame Time中CPU大於GPU時,表示CPU是瓶頸,否則表示GPU是瓶頸;當Utilization中的TILER比RENDERER高時,表示頂點處理是GPU的瓶頸,否則表示像素處理是GPU的瓶頸。
幀率受限於瓶頸,應優先優化瓶頸階段,非瓶頸階段優化得再快都無法提高幀率。

技術分享GPU Report

XCode的Capture GPU frame功能能高效且定量

地定位到GPU中shader的消耗。

技術分享XCode Capture GPU frame


Instruments的TimeProfiler能高效且定量地定位C#腳本(IL2CPP後的C++代碼)的CPU占用,甚至包括部分Unity引擎代碼的CPU占用函數消耗,而不必麻煩地添加BeginSample()、EndSample()。

技術分享Instruments Time Profiler

Unity使用/機制優化小結

GameObject的SpawnPool應支持“移出屏幕”功能

GameObject(比如特效)可能會被頻繁的在“使用中”、“不使用”的狀態間切換。我們的SpawnPool不應過快地把“剛剛不使用”的GameObject立刻Deactivate掉,否則會引起不必要的Deactivate/Activate的性能消耗。應有一個“從熱變冷”的過程:“剛剛不使用”只是移出屏幕;只有“不使用一段時間”的GameObject,才會得以Deactivate。可能的實現方式如下:

/// On each timer, we try to make parts of "hot" items to be "cool" by deactivating them.
internal void OnTimer()
{
    if(teleportCache.items.Count > 0) {
        if(Time.realtimeSinceStartup - teleportCache.lastSpawnTime < SpawnPool.TeleportThenDeactivateDuration &&
            teleportCache.items.Count <= 3) {
            this.MoreLogInfo("this prefab is recently spawned, and the teleport cache is not too large, we don‘t deactivate these remaining items");
        }
        else {
            int deactivateCount = Mathf.Max(1, teleportCache.items.Count / 3);

            SpawnIdentity oneId;
            while(deactivateCount > 0) {
                --deactivateCount;
                oneId = teleportCache.items.Pop();
                if(null != oneId) {
                    this.MoreLogInfo("Deactivate from teleportCache:" + oneId);
                    oneId.gameObject.MoreSetActive(false, this);
                    oneId.gameObject.transform.SetParent(null, true);
                    deactivateCache.Push(oneId);

                    SpawnPoolProfiler.AddDeactivateCount(oneId.gameObject);
                }
            }
        }
    }
}

Transform的孩子不應過多

當Transform包含不該有的孩子Transform或其他組件時,為該Transform進行position、rotation賦值,會引起消耗,特別是包含粒子系統的時候。

技術分享對Transform進行rotation賦值時,由於其孩子包含粒子系統所產生的消耗

但考慮到切換Transform的parent本身也會有消耗,因此,我們對此也應有“從熱變冷”的過程:“剛剛不使用”依然保留在父親Transform裏;只有“不使用一段時間”的GameObject,才從父親Transform移出。

應減少粒子系統的Play()的調用次數

每次調用ParticleSystem.Play()都會有消耗,如果粒子系統本身沒有明顯“前搖”階段,應先檢查ParticleSystem.isPlaying,例子如下:

ParticleSystem ps;
for (int i = 0; i < num; ++i) {
    //m_particleSystemLst[i].Stop();
    ps = m_particleSystemLst[i];
    /// CAUTION! WE SHOULD CHECK isPlaying before calling Play()! OR, IT WILL AFFECT PERFORMANCE!
    if(!ps.isPlaying) {
        ps.Play();
    }
}

應減少每幀Material.GetXX()/Material.SetXX()的次數

每次調用Material.GetXX()或Material.SetXX()都會有消耗,應減少調用該API的頻率。比如使用C#對象變量來記錄Material的變量狀態,從而規避Material.GetXX();在Shader裏把多個uniform half變量合並為uniform half 4,從而把4個Material.SetXX()調用合並為1個Material.SetXX()。

應使用支持Conditional的日誌輸出機制

簡單使用Debug.Log(ToString() + "hello " + "world");,其實參會造成CPU消耗及GC。使用支持Conditional的日誌輸出機制,則無此問題,只需在構建時,取消對應的編譯參數即可。

/// MoreDebug.cs,帶Conditional條件編譯的日誌輸出機制
[Conditional("MORE_DEBUG_INFO")]
public static void MoreLogInfo(this object caller) { DoMoreLog(MoreLogLevel.Info, false, caller); }

/// 用戶代碼.cs,調用方簡單正常調用即可。正式構建時,取消MORE_DEBUG_INFO編譯參數。
this.MoreLogInfo("writerSize=" + writer.Position, "channelId=" + channelId);

腳本優化小結

依然需要減少GetComponent()的頻率

即使在Unity5.5中,GetComponent()會有一定的GC產生,有少量的CPU消耗。如有可能,我們依然需要規避冗余的GetComponent()。另,自Unity5起,Unity已就.transform進行了cache,我們不需再為.transform擔心,見《UNITY 5: API CHANGES & AUTOMATIC SCRIPT UPDATING》最後一段。

應減少UnityEngine.Object的null比較

因為Unity overwrite掉了Object.Equals(),《CUSTOM == OPERATOR, SHOULD WE KEEP IT?》也說過unityEngineObject==null事實上和GetComponent()的消耗類似,都涉及到Engine層面的機制調用,所以UnityEngine.Object的null比較,都會有少許的性能消耗。對於基礎功能、調用棧葉子節點邏輯、高頻功能,我們應少null比較,使用assertion來處理。只有在調用棧根節點邏輯,有必要的時候,才進行null比較。


技術分享技術分享上面C#代碼對應的IL2CPP代碼


而且,從代碼質量來看,無腦的null保護也是不值得推崇的,因為其將錯誤隱藏到了更偏離錯誤根源的邏輯。理論上,當錯誤發生了,應盡早報錯,從而幫助開發者能更快速地定位錯誤根源。所以,多用assertion,少用null保護,無論是對代碼質量,還是代碼性能,都是不錯的實踐。

應減少不必要的Transform.position/rotation等訪問

每次訪問Transform.position/rotation都有相應的消耗。應能cache就cache其返回結果。

應盡量減少創建C#堆內存對象

建議使用成員變量,或者Pool來規避高頻創建C#堆內存對象的創建。而且堆內存對象創建本身就是個相對較慢的過程。

應為struct對象重載所有object函數

為了普適性,C#的struct的默認Equals()、GetHashCode()和ToString()都是較慢實現,甚至涉及反射。用戶自定義的struct,都應重載上述3個函數,手動實現,比如:

public struct NetworkPredictId{
    int m_value;
    public override int GetHashCode(){
        return m_value;
    }

    public override bool Equals(object obj){
        return obj is NetworkPredictId && this == (NetworkPredictId)obj;
    }

    public override string ToString(){
        return m_value.ToString();
    }

    public static bool operator ==(NetworkPredictId c1, NetworkPredictId c2){
        return c1.m_value == c2.m_value;
    }

    public static bool operator !=(NetworkPredictId c1, NetworkPredictId c2){
        return c1.m_value != c2.m_value;
    }
}

如果可能,盡量用Queue/Stack來代替List

我們會習慣用List來實現數據集合的需求。但好一些情況下,我們事實上是不需對其進行隨機訪問,而僅僅是“增加”、“刪除”操作。此時,我們應該使用增刪復雜度都是O(1)的Queue或者Stack。

註意List常用接口復雜度

Add()常為O(1)復雜度,但超過Capacity時,為O(n)復雜度。故我們應註意合理地設置容器的初始化Capacity。
Insert()為O(n)復雜度。
Remove()為O(n)復雜度。RemoveAt(index)為O(n)復雜度,n=(Count - index)。故建議移除時應優先從尾部移除。當批量移除時,miloyip亦指出RemoveRange提高移除效率。

/// remove not exsiting items in O(n)
int oldCount = m_items.Count;
int newCount = 0;
Item oneItem;
for(int i = 0; i < oldCount; ++i){
    oneItem = m_items[i];
    if(CheckExisting(oneItem)){
        m_items[newCount] = oneItem;
        ++newCount;
    }
}
m_items.RemoveRange(newCount, removeCount);

應註意容器的初始化capacity

同理如上條目。另,Capacity增長時,除了O(n)的復雜度,也有GC消耗。

應盡量為類或函數聲明為sealed

IL2CPP就sealed的類或函數會有優化,變虛函數調用為直接函數調用。詳見《IL2CPP OPTIMIZATIONS: DEVIRTUALIZATION》。

C#/CPP interop時,不需為blittable的變量聲明為MarshalAs

某些數值類型,托管代碼和原生代碼的二進制表達方式一致,這些稱為blittable數值類型。blittable數值類型在interop時為高效的簡單內存拷貝,故應值得推崇。C#中的blittable數值類型為byte、int、float等,但註意不包括常用的bool、string。僅有blittable數值類型組成的數組或struct,也為blittable。

blittable的變量不應聲明MarshalAs。
比如下面代碼,

[DllImport(ApolloCommon.PluginName, CallingConvention = CallingConvention.Cdecl)]
private static extern ApolloResult apollo_connector_readUdpData(UInt64 objId, /*[MarshalAs(UnmanagedType.LPArray)]*/ byte[] buff, ref int size);

註釋前後的IL2CPP代碼如下圖,右側明顯避免了marhal的產生。

技術分享


詳見《IL2CPP INTERNALS: P/INVOKE WRAPPERS》。

減少Dictionary的冗余訪問

我們常習慣編寫這樣的代碼:

if(myDictionary.Contains(oneKey))
{
    MyValue myValue = myDictionary[oneKey];
   // ...
}

但其可減少冗余的哈希次數,優化為:

MyValue myValue;
if(myDictionary.TryGetValue(oneKey, out myValue))
{
    // ...
}

(TO BE CONTINUED...)

Unity遊戲項目性能優化總結 (難度3 推薦4)