1. 程式人生 > >Unity3D優化技巧系列一

Unity3D優化技巧系列一

筆者介紹:姜雪偉,IT公司技術合夥人,IT高階講師,CSDN社群專家,特邀編輯,暢銷書作者,國家專利發明人;已出版書籍:《手把手教你架構3D遊戲引擎》電子工業出版社和《Unity3D實戰核心技術詳解》電子工業出版社等。

最近給讀者分享一下關於Unity3D的優化,這個問題對於開發者來說都是比較頭疼的問題,這裡先介紹一下關於專案開發通常的做法。開發專案前期由於趕進度,不停的堆積功能和資源,這樣專案完成後,包體非常龐大,程式碼寫的也很亂,後期專案進入優化階段,包括程式碼的重構,各種BUG的修復,從而導致版本開始變的不可控制,專案研發週期也是不停的延期延期,這種情況在大部分公司都是常見的問題。其實作為老闆來說,他肯定要著急看到專案成果,所以就不停的督促開發人員加班加點的搞,站在老闆的角度考慮問題,情有可原。

但是,作為開發者來說,我們不能按照老闆的節奏走,這樣後面不僅坑的是自己,也把公司坑掉了,後期專案由於各種問題很容易夭折,這種局面是不可控制的,因為專案開發完成了,後期還會加入很多功能,如果前期沒有規劃好,就會出現前一發而動全身的情況,這樣也預示著專案即將死掉。如何避免這種情況發生,在這些系列文章中給讀者分享一下筆者關於這些問題的解決方案。

用Unity引擎開發,我們就要了解它們的工作機制,這樣在專案進行優化時也會有針對性,關於優化方面,筆者也做過一些視訊講座,當然這些系列文章是視訊中沒有的,Unity首先遇到的問題就是效率問題,很多專案由於效率問題夭折了,所以這個問題的解決非常重要,如何優化效率也就是執行幀率,針對於Unity就是減少Draw Call的數量。

 網上關於Draw Call的介紹非常多,這裡再給讀者向細的方向講一下,其實Draw Call的執行就是CPU與GPU之間的通訊,這就涉及到渲染流水線的概念,渲染流水線的起點是CPU,主要分三個階段:

1、CPU把資料載入到視訊記憶體中

2、設定渲染狀態

3、呼叫Draw Call

根據上面提到的三個步驟,再詳細介紹一下,所有遊戲中渲染所需要的資料都需要從硬碟中載入到記憶體中,然後網格和紋理等資料被載入到顯示卡上的儲存空間也就是我們所說的視訊記憶體。這是因為顯示卡對於視訊記憶體的訪問速度更快,效果如下:

實際專案開發中,真實渲染中需要載入到視訊記憶體中的資料比圖中顯示的更復雜,舉例說明一下:頂點的位置資訊、法線方向、頂點顏色、紋理座標等等。

當把資料載入到視訊記憶體後,在記憶體中的資料部分可以移除,因為對於一些資料來說,CPU仍然要訪問它們,從硬碟載入到記憶體的過程是十分耗時的,這個也要考慮的。在這之後,開發者還需要通過CPU來設定渲染狀態,從而“指導”GPU如何進行渲染工作。

接下來介紹設定渲染狀態了,渲染狀態定義了場景中的網格是如何被渲染的,如果我們程式設計沒有更改渲染狀態,所有的網格都將使用同一種渲染狀態。如下圖所示效果演示:

以上圖片只是表達這個意思,同樣的渲染狀態,材質是一樣的。準備好上述工作後,CPU就需要呼叫一個渲染命令來告訴GPU:資料準備好了,可以按照我的設定開始渲染,這個渲染命令就是Draw Call,講了這麼多終於回來了。

 實際上,Draw Call就是一個命令,它的發起方是CPU,接收方是GPU。當給定了一個Draw Call時,GPU就會根據渲染狀態(比如材質,紋理,著色器等)和所有輸入的頂點資料來進行計算,最終輸出成螢幕上顯示的那些畫素,這個過程也會涉及到GPU流水線。如果有讀者對此不清楚可以看看筆者以前的部落格有詳細的介紹。接下來我們繼續解釋Draw Call ,給開發者造成的誤區認為 造成Draw Call問題的主要原因是GPU,認為GPU上的狀態切換是耗時的,其實不是的,真正的罪魁禍手是CPU。

下面我們就介紹CPU和GPU工作原理,大家先想一下,如果沒有流水線,那麼CPU需要等到GPU完成上一個渲染任務才能再次傳送渲染命令。但這種方法顯然會造成效率低下。我們需要讓CPU和GPU可以並行工作,解決方法是使用一個命令緩衝區(Command Buffer)。命令緩衝區包含了一個命令佇列,它是由CPU向其中新增命令,而由GPU從中讀取命令,新增和讀取的過程是相互獨立的。命令緩衝區使得CPU和GPU可以相互獨立工作。當CPU需要渲染一些物件時,他可以向命令緩衝區中新增命令,而當GPU完成了上一次的渲染任務後,它就可以從命令佇列中再取出一個命令並執行它。

命令緩衝區中的命令有很多種,而Draw Call是其中一種,其它命令還有改變渲染狀態等。效果如下圖所示:

圖中顯示的是CPU與GPU通過緩衝區進行互動,Draw Call執行的是圖中灰色的顯示的,而白色的是改變渲染狀態的,這個相對來說比較耗時間。

為什麼Draw Call 多了會影響幀率?讀者可以做一個實驗,比如你複製1000個文字檔案到另一個資料夾中,每個檔案大小是100K,總計大小是10MB,這個要花費很長時間。我們再來建立一個單獨的檔案,它的大小是10M,然後也把它從一個資料夾複製到另一個資料夾。這次複製的時間少很多。主要原因在於,每一個複製動作需要很多額外的操作,比如分配記憶體等,如果複製1000個檔案,他要開闢記憶體1000次,這個開銷將會很大。

渲染過程跟這個類似,在每次呼叫Draw Call之前,CPU需要向GPU傳送很多內容,包括資料、狀態和命令等。在這一階段,CPU要完成很多工作。一旦CPU完成了這些準備工作,GPU就可以開始本次的渲染。GPU渲染能力是很強的,渲染200個還是2000個三角網格沒啥區別,因此渲染速度往往快於CPU提交命令的速度。如果需要執行的資料很多,也就是說Draw Call 數量很多,CPU就會把大量的時間花費在提交Draw Call上,造成CPU的過載,這個是需要我們避免的。

如何減少Draw Call的數量,這個在Unity開發中常見,減少Draw Call的方法很多,介紹一下批處理的方法。在前面提到過,複製檔案的問題,CPU的時間都花費在準備Draw Call的工作上了。優化的想法就是把很多小的DrawCall合併成一個大的Draw Call,這就是批處理思想。

需要注意的是,合併網格是需要CPU在記憶體中執行的,合併的過程是需要消耗時間的。因此,批處理技術更加適合於哪些靜態的物體,當然動態的如果合併的話,也需要在CPU中執行,我在以前的部落格中也有介紹。要注意的是不要每一幀都去重新進行合併再發送給GPU,這對空間和時間都會造成一定的影響。

要做到減少Draw Call需要注意以下兩點:

1、避免使用大量很小的網格,如果不可避免需要使用很小的網格結構,可以考慮合併,當然,模型的材質也可以考慮通用。

2、避免使用過多的材質,儘量在不同的網格之間共用同一個材質。

3、遇到有相同動作的物體,可以使用合併動態網格的方式進行優化,效果還不錯。

在本文最後把動態網格合併的程式碼給讀者展示如下:

public class CombineOpMesh : MonoBehaviour {


    void Start()
    {
        CombineToMesh(this.gameObject);
    }


    public void CombineToMesh(GameObject _go)
    {
        SkinnedMeshRenderer[] smr = _go.GetComponentsInChildren<SkinnedMeshRenderer>();
        List<CombineInstance> lcom = new List<CombineInstance>();


        List<Material> lmat = new List<Material>();
        List<Transform> ltra = new List<Transform>();


        for (int i = 0; i < smr.Length; i++ )
        {
            lmat.AddRange(smr[i].materials);
            ltra.AddRange(smr[i].bones);


            for (int sub = 0; sub < smr[i].sharedMesh.subMeshCount; sub++ )
            {
                CombineInstance ci = new CombineInstance();
                ci.mesh = smr[i].sharedMesh;
                ci.subMeshIndex = sub;
                lcom.Add(ci);
            }
            Destroy(smr[i].gameObject);
        }


        SkinnedMeshRenderer _r = _go.GetComponent<SkinnedMeshRenderer>();
        if (_r == null)
            _r = _go.AddComponent<SkinnedMeshRenderer>();


        _r.sharedMesh = new Mesh();
        _r.bones = ltra.ToArray();
        _r.materials = new Material[] { lmat[0] };
        _r.rootBone = _go.transform;
        _r.sharedMesh.CombineMeshes(lcom.ToArray(), true, false);
    }
}
實現原理是:首先去遍歷每個物件的SkinnderMeshRenderer,然後將其所有的動態物件組合成一個大的物件並且將骨骼動畫賦值給他,這樣,我們就實現了動態物件的優化。

實現效果圖對比如下,首先展示的是沒有合併的動態動畫的Draw Call數量:


然後再掛上文提到的合併指令碼後的效果如下所示:


具體操作方式如下所示: