深入淺出聊Unity3D專案優化:從Draw Calls到GC

發表於2014-07-29

前言:

剛開始寫這篇文章的時候選了一個很土的題目。。。《Unity3D優化全解析》。因為這是一篇臨時起意才寫的文章,而且陳述的都是既有的事實,因而給自己“文(dou)學(bi)”加工留下的餘地就少了很多。但又覺得這塊是不得不提的一個地方,平時見到很多人對此處也給予了忽略了事,需要時才去網上扒一些隻言片語的資料。也恰逢年前,尋思著週末認真寫點東西遇到節假日沒準也沒什麼人讀,所以索性就寫了這篇臨時的文章。題目很土,因為用了指向性很明確的“Unity3D”,讓人少了遐(瞎)想的空間,同時用了“高大全”這樣的構詞法,也讓匹夫有成為眾矢之的的可能。。。所以最後還是改成了現在各位看到的題目。話不多說,下面就開始正文~正所謂“草蛇灰線,伏脈千里”。那我們們首先~~~~~~

看看優化需要從哪裡著手?

匹夫印象裡遇到的童靴,提Unity3D專案優化則必提DrawCall,這自然沒錯,但也有很不好影響。因為這會給人一個錯誤的認識:所謂的優化就是把DrawCall弄的比較低就對了。

對優化有這種第一印象的人不在少數,drawcall的確是一個很重要的指標,但絕非全部。為了讓各位和匹夫能達成儘可能多的共識,匹夫首先介紹一下本文可能會涉及到的幾個概念,之後會提出優化所涉及的三大方面:

  • drawcall是啥?其實就是對底層圖形程式(比如:OpenGL ES)介面的呼叫,以在螢幕上畫出東西。所以,是誰去呼叫這些介面呢?CPU。
  • fragment是啥?經常有人說vf啥的,vertex我們都知道是頂點,那fragment是啥呢?說它之前需要先說一下畫素,畫素各位應該都知道吧?畫素是構成數碼影像的基本單元呀。那fragment呢?是有可能成為畫素的東西。啥叫有可能?就是最終會不會被畫出來不一定,是潛在的畫素。這會涉及到誰呢?GPU。
  • batching是啥?都知道批處理是幹嘛的吧?沒錯,將批處理之前需要很多次呼叫(drawcall)的物體合併,之後只需要呼叫一次底層圖形程式的介面就行。聽上去這簡直就是優化的終極方案啊!但是,理想是美好的,世界是殘酷的,一些不足之後我們再細聊。
  • 記憶體的分配:記住,除了Unity3D自己的記憶體損耗。我們可是還帶著Mono呢啊,還有託管的那一套東西呢。更別說你一激動,又引入了自己的幾個dll。這些都是記憶體開銷上需要考慮到的。

好啦,文中的幾個概念提前講清楚了,其實各位也能看的出來匹夫接下來要說的匹夫關注的優化時需要注意的方面:

  • CPU方面
  • GPU方面
  • 記憶體方面

所以,這篇文章也會按照CPU—->GPU—->記憶體的順序進行。

CPU的方面的優化:

上文中說了,drawcall影響的是CPU的效率,而且也是最知名的一個優化點。但是除了drawcall之外,還有哪些因素也會影響到CPU的效率呢?讓我們一一列出暫時能想得到的:

  • DrawCalls
  • 物理元件(Physics)
  • GC(什麼?GC不是處理記憶體問題的嘛?匹夫你不要騙我啊!不過,匹夫也要提醒一句,GC是用來處理記憶體的,但是是誰使用GC去處理記憶體的呢?)
  • 當然,還有程式碼質量

DrawCalls:

前面說過了,DrawCall是CPU呼叫底層圖形介面。比如有上千個物體,每一個的渲染都需要去呼叫一次底層介面,而每一次的呼叫CPU都需要做很多工作,那麼CPU必然不堪重負。但是對於GPU來說,圖形處理的工作量是一樣的。所以對DrawCall的優化,主要就是為了儘量解放CPU在呼叫圖形介面上的開銷。所以針對drawcall我們主要的思路就是每個物體儘量減少渲染次數,多個物體最好一起渲染。所以,按照這個思路就有了以下幾個方案:

  1. 使用Draw Call Batching,也就是描繪呼叫批處理。Unity在執行時可以將一些物體進行合併,從而用一個描繪呼叫來渲染他們。具體下面會介紹。
  2. 通過把紋理打包成圖集來儘量減少材質的使用。
  3. 儘量少的使用反光啦,陰影啦之類的,因為那會使物體多次渲染。

Draw Call Batching

首先我們要先理解為何2個沒有使用相同材質的物體即使使用批處理,也無法實現Draw Call數量的下降和效能上的提升。

因為被“批處理”的2個物體的網格模型需要使用相同材質的目的,在於其紋理是相同的,這樣才可以實現同時渲染的目的。因而保證材質相同,是為了保證被渲染的紋理相同。

因此,為了將2個紋理不同的材質合二為一,我們就需要進行上面列出的第二步,將紋理打包成圖集。具體到合二為一這種情況,就是將2個紋理合成一個紋理。這樣我們就可以只用一個材質來代替之前的2個材質了。

而Draw Call Batching本身,也還會細分為2種。

Static Batching 靜態批處理

看名字,猜使用的情景。

靜態?那就是不動的咯。還有呢?額,聽上去狀態也不會改變,沒有“生命”,比如山山石石,樓房校舍啥的。那和什麼比較類似呢?嗯,聰明的各位一定覺得和場景的屬性很像吧!所以我們的場景似乎就可以採用這種方式來減少draw call了。

那麼寫個定義:只要這些物體不移動,並且擁有相同的材質,靜態批處理就允許引擎對任意大小的幾何物體進行批處理操作來降低描繪呼叫。

那要如何使用靜態批來減少Draw Call呢?你只需要明確指出哪些物體是靜止的,並且在遊戲中永遠不會移動、旋轉和縮放。想完成這一步,你只需要在檢測器(Inspector)中將Static核取方塊打勾即可,如下圖所示:

至於效果如何呢?

舉個例子:新建4個物體,分別是Cube,Sphere, Capsule, Cylinder,它們有不同的網格模型,但是也有相同的材質(Default-Diffuse)。

首先,我們不指定它們是static的。Draw Call的次數是4次,如圖:

我們現在將它們4個物體都設為static,在來執行一下:

如圖,Draw Call的次數變成了1,而Saved by batching的次數變成了3。

靜態批處理的好處很多,其中之一就是與下面要說的動態批處理相比,約束要少很多。所以一般推薦的是draw call的靜態批處理來減少draw call的次數。那麼接下來,我們就繼續聊聊draw call的動態批處理。

Dynamic Batching 動態批處理

有陰就有陽,有靜就有動,所以聊完了靜態批處理,肯定跟著就要說說動態批處理了。首先要明確一點,Unity3D的draw call動態批處理機制是引擎自動進行的,無需像靜態批處理那樣手動設定static。我們舉一個動態例項化prefab的例子,如果動態物體共享相同的材質,則引擎會自動對draw call優化,也就是使用批處理。首先,我們將一個cube做成prefab,然後再例項化500次,看看draw call的數量。

draw call的數量:

可以看到draw call的數量為1,而 saved by batching的數量是499。而這個過程中,我們除了例項化建立物體之外什麼都沒做。不錯,unity3d引擎為我們自動處理了這種情況。

但是有很多童靴也遇到這種情況,就是我也是從prefab例項化建立的物體,為何我的draw call依然很高呢?這就是匹夫上文說的,draw call的動態批處理存在著很多約束。下面匹夫就演示一下,針對cube這樣一個簡單的物體的建立,如果稍有不慎就會造成draw call飛漲的情況吧。

我們同樣是建立500個物體,不同的是其中的100個物體,每個物體的大小都不同,也就是Scale不同。

draw call的數量:

我們看到draw call的數量上升到了101次,而saved by batching的數量也下降到了399。各位看官可以看到,僅僅是一個簡單的cube的建立,如果scale不同,竟然也不會去做批處理優化。這僅僅是動態批處理機制的一種約束,那我們總結一下動態批處理的約束,各位也許也能從中找到為何動態批處理在自己的專案中不起作用的原因:

  1. 批處理動態物體需要在每個頂點上進行一定的開銷,所以動態批處理僅支援小於900頂點的網格物體。
  2. 如果你的著色器使用頂點位置,法線和UV值三種屬性,那麼你只能批處理300頂點以下的物體;如果你的著色器需要使用頂點位置,法線,UV0,UV1和切向量,那你只能批處理180頂點以下的物體。
  3. 不要使用縮放。分別擁有縮放大小(1,1,1) 和(2,2,2)的兩個物體將不會進行批處理。
  4. 統一縮放的物體不會與非統一縮放的物體進行批處理。
  5. 使用縮放尺度(1,1,1) 和 (1,2,1)的兩個物體將不會進行批處理,但是使用縮放尺度(1,2,1) 和(1,3,1)的兩個物體將可以進行批處理。
  6. 使用不同材質的例項化物體(instance)將會導致批處理失敗。
  7. 擁有lightmap的物體含有額外(隱藏)的材質屬性,比如:lightmap的偏移和縮放係數等。所以,擁有lightmap的物體將不會進行批處理(除非他們指向lightmap的同一部分)。
  8. 多通道的shader會妨礙批處理操作。比如,幾乎unity中所有的著色器在前向渲染中都支援多個光源,併為它們有效地開闢多個通道。
  9. 預設體的例項會自動地使用相同的網格模型和材質。

所以,儘量使用靜態的批處理。

物理元件

曾幾何時,匹夫在做一個策略類遊戲的時候需要在單元格上排兵佈陣,而要偵測到哪個兵站在哪個格子匹夫選擇使用了射線,由於士兵單位很多,而且為了精確每一幀都會執行檢測,那時候CPU的負擔叫一個慘不忍睹。後來匹夫果斷放棄了這種做法,並且對物理元件產生了心理的陰影。

這裡匹夫只提2點匹夫感覺比較重要的優化措施:

1.設定一個合適的Fixed Timestep。設定的位置如圖:

那何謂“合適”呢?首先我們要搞明白Fixed Timestep和物理元件的關係。物理元件,或者說遊戲中模擬各種物理效果的元件,最重要的是什麼呢?計算啊。對,需要通過計算才能將真實的物理效果展現在虛擬的遊戲中。那麼Fixed Timestep這貨就是和物理計算有關的啦。所以,若計算的頻率太高,自然會影響到CPU的開銷。同時,若計算頻率達不到遊戲設計時的要求,有會影響到功能的實現,所以如何抉擇需要各位具體分析,選擇一個合適的值。

2.就是不要使用網格碰撞器(mesh collider):為啥?因為實在是太複雜了。網格碰撞器利用一個網格資源並在其上構建碰撞器。對於複雜網狀模型上的碰撞檢測,它要比應用原型碰撞器精確的多。標記為凸起的(Convex )的網格碰撞器才能夠和其他網格碰撞器發生碰撞。各位上網搜一下mesh collider的圖片,自然就會明白了。我們的手機遊戲自然無需這種價效比不高的東西。

當然,從效能優化的角度考慮,物理元件能少用還是少用為好。

處理記憶體,卻讓CPU受傷的GC

在CPU的部分聊GC,感覺是不是怪怪的?其實小匹夫不這麼覺得,雖然GC是用來處理記憶體的,但的確增加的是CPU的開銷。因此它的確能達到釋放記憶體的效果,但代價更加沉重,會加重CPU的負擔,因此對於GC的優化目標就是儘量少的觸發GC。

首先我們要明確所謂的GC是Mono執行時的機制,而非Unity3D遊戲引擎的機制,所以GC也主要是針對Mono的物件來說的,而它管理的也是Mono的託管堆。 搞清楚這一點,你也就明白了GC不是用來處理引擎的assets(紋理啦,音效啦等等)的記憶體釋放的,因為U3D引擎也有自己的記憶體堆而不是和Mono一起使用所謂的託管堆。

其次我們要搞清楚什麼東西會被分配到託管堆上?不錯咯,就是引用型別咯。比如類的例項,字串,陣列等等。而作為int,float,包括結構體struct其實都是值型別,它們會被分配在堆疊上而非堆上。所以我們關注的物件無外乎就是類例項,字串,陣列這些了。

那麼GC什麼時候會觸發呢?兩種情況:

  1. 首先當然是我們的堆的記憶體不足時,會自動呼叫GC。
  2. 其次呢,作為程式設計人員,我們自己也可以手動的呼叫GC。

所以為了達到優化CPU的目的,我們就不能頻繁的觸發GC。而上文也說了GC處理的是託管堆,而不是Unity3D引擎的那些資源,所以GC的優化說白了也就是程式碼的優化。那麼匹夫覺得有以下幾點是需要注意的:

  1. 字串連線的處理。因為將兩個字串連線的過程,其實是生成一個新的字串的過程。而之前的舊的字串自然而然就成為了垃圾。而作為引用型別的字串,其空間是在堆上分配的,被棄置的舊的字串的空間會被GC當做垃圾回收。
  2. 儘量不要使用foreach,而是使用for。foreach其實會涉及到迭代器的使用,而據傳說每一次迴圈所產生的迭代器會帶來24 Bytes的垃圾。那麼迴圈10次就是240Bytes。
  3. 不要直接訪問gameobject的tag屬性。比如if (go.tag == “human”)最好換成if (go.CompareTag (“human”))。因為訪問物體的tag屬性會在堆上額外的分配空間。如果在迴圈中這麼處理,留下的垃圾就可想而知了。
  4. 使用“池”,以實現空間的重複利用。
  5. 最好不用LINQ的命令,因為它們會分配臨時的空間,同樣也是GC收集的目標。而且我很討厭LINQ的一點就是它有可能在某些情況下無法很好的進行AOT編譯。比如“OrderBy”會生成內部的泛型類“OrderedEnumerable”。這在AOT編譯時是無法進行的,因為它只是在OrderBy的方法中才使用。所以如果你使用了OrderBy,那麼在IOS平臺上也許會報錯。

程式碼?指令碼?

聊到程式碼這個話題,也許有人會覺得匹夫多此一舉。因為程式碼質量因人而異,很難像上面提到的幾點,有一個明確的評判標準。也是,公寫公有理,婆寫婆有理。但是匹夫這裡要提到的所謂程式碼質量是基於一個前提的:Unity3D是用C++寫的,而我們的程式碼是用C#作為指令碼來寫的,那麼問題就來了~指令碼和底層的互動開銷是否需要考慮呢?也就是說,我們用Unity3D寫遊戲的“遊戲指令碼語言”,也就是C#是由mono執行時託管的。而功能是底層引擎的C++實現的,“遊戲指令碼”中的功能實現都離不開對底層程式碼的呼叫。那麼這部分的開銷,我們應該如何優化呢?

1.以物體的Transform元件為例,我們應該只訪問一次,之後就將它的引用保留,而非每次使用都去訪問。這裡有人做過一個小實驗,就是對比通過方法GetComponent<Transform>()獲取Transform元件, 通過MonoBehavor的transform屬性去取,以及保留引用之後再去訪問所需要的時間:

  • GetComponent = 619ms
  • Monobehaviour = 60ms
  • CachedMB = 8ms
  • Manual Cache = 3ms

2.如上所述,最好不要頻繁使用GetComponent,尤其是在迴圈中。

3.善於使用OnBecameVisible()和OnBecameVisible(),來控制物體的update()函式的執行以減少開銷。

4.使用內建的陣列,比如用Vector3.zero而不是new Vector(0, 0, 0);

5.對於方法的引數的優化:善於使用ref關鍵字。值型別的引數,是通過將實參的值複製到形參,來實現按值傳遞到方法,也就是我們通常說的按值傳遞。複製嘛,總會讓人感覺很笨重。比如Matrix4x4這樣比較複雜的值型別,如果直接複製一份新的,反而不如將值型別的引用傳遞給方法作為引數。

好啦,CPU的部分匹夫覺得到此就介紹的差不多了。下面就簡單聊聊其實匹夫並不是十分熟悉的部分,GPU的優化。

GPU的優化

GPU與CPU不同,所以側重點自然也不一樣。GPU的瓶頸主要存在在如下的方面:

  1. 填充率,可以簡單的理解為圖形處理單元每秒渲染的畫素數量。
  2. 畫素的複雜度,比如動態陰影,光照,複雜的shader等等
  3. 幾何體的複雜度(頂點數量)
  4. 當然還有GPU的視訊記憶體頻寬

那麼針對以上4點,其實仔細分析我們就可以發現,影響的GPU效能的無非就是2大方面,一方面是頂點數量過多,畫素計算過於複雜。另一方面就是GPU的視訊記憶體頻寬。那麼針鋒相對的兩方面舉措也就十分明顯了。

  1. 少頂點數量,簡化計算複雜度。
  2. 縮圖片,以適應視訊記憶體頻寬。

減少繪製的數目

那麼第一個方面的優化也就是減少頂點數量,簡化複雜度,具體的舉措就總結如下了:

  • 保持材質的數目儘可能少。這使得Unity更容易進行批處理。
  • 使用紋理圖集(一張大貼圖裡包含了很多子貼圖)來代替一系列單獨的小貼圖。它們可以更快地被載入,具有很少的狀態轉換,而且批處理更友好。
  • 如果使用了紋理圖集和共享材質,使用Renderer.sharedMaterial 來代替Renderer.material 。
  • 使用光照紋理(lightmap)而非實時燈光。
  • 使用LOD,好處就是對那些離得遠,看不清的物體的細節可以忽略。
  • 遮擋剔除(Occlusion culling)
  • 使用mobile版的shader。因為簡單。

優化視訊記憶體頻寬

第二個方向呢?壓縮圖片,減小視訊記憶體頻寬的壓力。

  • OpenGL ES 2.0使用ETC1格式壓縮等等,在打包設定那裡都有。
  • 使用mipmap。

MipMap

這裡匹夫要著重介紹一下MipMap到底是啥。因為有人說過MipMap會佔用記憶體呀,但為何又會優化視訊記憶體頻寬呢?那就不得不從MipMap是什麼開始聊起。一張圖其實就能解決這個疑問。

上面是一個mipmap 如何儲存的例子,左邊的主圖伴有一系列逐層縮小的備份小圖

是不是很一目瞭然呢?Mipmap中每一個層級的小圖都是主圖的一個特定比例的縮小細節的複製品。因為存了主圖和它的那些縮小的複製品,所以記憶體佔用會比之前大。但是為何又優化了視訊記憶體頻寬呢?因為可以根據實際情況,選擇適合的小圖來渲染。所以,雖然會消耗一些記憶體,但是為了圖片渲染的質量(比壓縮要好),這種方式也是推薦的。

記憶體的優化

既然要聊Unity3D執行時候的記憶體優化,那我們自然首先要知道Unity3D遊戲引擎是如何分配記憶體的。大概可以分成三大部分:

  1. Unity3D內部的記憶體
  2. Mono的託管記憶體
  3. 若干我們自己引入的DLL或者第三方DLL所需要的記憶體。

第3類不是我們關注的重點,所以接下來我們會分別來看一下Unity3D內部記憶體和Mono託管記憶體,最後還將分析一個官網上Assetbundle的案例來說明記憶體的管理。

Unity3D內部記憶體

Unity3D的內部記憶體都會存放一些什麼呢?各位想一想,除了用程式碼來驅動邏輯,一個遊戲還需要什麼呢?對,各種資源。所以簡單總結一下Unity3D內部記憶體存放的東西吧:

  • 資源:紋理、網格、音訊等等
  • GameObject和各種元件。
  • 引擎內部邏輯需要的記憶體:渲染器,物理系統,粒子系統等等

Mono託管記憶體

因為我們的遊戲指令碼是用C#寫的,同時還要跨平臺,所以帶著一個Mono的託管環境顯然必須的。那麼Mono的託管記憶體自然就不得不放到記憶體的優化範疇中進行考慮。那麼我們所說的Mono託管記憶體中存放的東西和Unity3D內部記憶體中存放的東西究竟有何不同呢?其實Mono的記憶體分配就是很傳統的執行時記憶體的分配了:

  • 值型別:int型啦,float型啦,結構體struct啦,bool啦之類的。它們都存放在堆疊上(注意額,不是堆所以不涉及GC)。
  • 引用型別:其實可以狹義的理解為各種類的例項。比如遊戲指令碼中對遊戲引擎各種控制元件的封裝。其實很好理解,C#中肯定要有對應的類去對應遊戲引擎中的控制元件。那麼這部分就是C#中的封裝。由於是在堆上分配,所以會涉及到GC。

而Mono託管堆中的那些封裝的物件,除了在在Mono託管堆上分配封裝類例項化之後所需要的記憶體之外,還會牽扯到其背後對應的遊戲引擎內部控制元件在Unity3D內部記憶體上的分配。

舉一個例子:

一個在.cs指令碼中宣告的WWW型別的物件www,Mono會在Mono託管堆上為www分配它所需要的記憶體。同時,這個例項物件背後的所代表的引擎資源所需要的記憶體也需要被分配。

一個WWW例項背後的資源:

  • 壓縮的檔案
  • 解壓縮所需的快取
  • 解壓縮之後的檔案

如圖:

那麼下面就舉一個AssetBundle的例子:

Assetbundle的記憶體處理

以下載Assetbundle為例子,聊一下記憶體的分配。匹夫從官網的手冊上找到了一個使用Assetbundle的情景如下:

記憶體分配的三個部分匹夫已經在程式碼中標識了出來:

  1. Web Stream:包括了壓縮的檔案,解壓所需的快取,以及解壓後的檔案。
  2. AssetBundle:Web Stream中的檔案的對映,或者說引用。
  3. 例項化之後的物件就是引擎的各種資原始檔了,會在記憶體中建立出來。

那就分別解析一下:

  1. 將壓縮的檔案讀入記憶體中
  2. 建立解壓所需的快取
  3. 將檔案解壓,解壓後的檔案進入記憶體
  4. 關閉掉為解壓建立的快取

  1. AssetBundle此時相當於一個橋樑,從Web Stream解壓後的檔案到最後例項化建立的物件之間的橋樑。
  2. 所以AssetBundle實質上是Web Stream解壓後的檔案中各個物件的對映。而非真實的物件。
  3. 實際的資源還存在Web Stream中,所以此時要保留Web Stream。

通過AssetBundle獲取資源,例項化物件

最後各位可能看到了官網中的這個例子使用了:

這種using的用法。這種用法其實就是為了在使用完Web Stream之後,將記憶體釋放掉的。因為WWW也繼承了idispose的介面,所以可以使用using的這種用法。其實相當於最後執行了:

OK,Web Stream被刪除掉了。那還有誰呢?對Assetbundle。那麼使用

ok,寫到這裡就先打住啦。寫的有點超了。有點趕也有點臨時,日後在補充編輯。

相關文章