1. 程式人生 > >深入淺出聊Unity3D項目優化:從Draw Calls到GC (難度2 推薦5)

深入淺出聊Unity3D項目優化:從Draw Calls到GC (難度2 推薦5)

分配 shade 觸發 比較 抉擇 share 關鍵字 兩個 聲明

原文出處: 慕容小匹夫的博客(@慕容小匹夫)

前言:

剛開始寫這篇文章的時候選了一個很土的題目。。。《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的數量。

1 2 3 4 5 for(int i = 0; i < 500; i++) { GameObject cube; cube = GameObject.Instantiate(prefab) as GameObject; }

draw call的數量:

技術分享

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

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

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

1 2 3 4 5 6 7 8 9 for(int i = 0; i < 500; i++) { GameObject cube; cube = GameObject.Instantiate(prefab) as GameObject; if(i / 100 == 0) { cube.transform.localScale = new Vector3(2 + i, 2 + i, 2 + i); } }

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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 IEnumerator DownloadAndCache (){ // Wait for the Caching system to be ready while (!Caching.ready) yield return null; // Load the AssetBundle file from Cache if it exists with the same version or download and store it in the cache using(WWW www = WWW.LoadFromCacheOrDownload (BundleURL, version)){ yield return www; //WWW是第1部分 if (www.error != null) throw new Exception(&quot;WWW download had an error:&quot; + www.error); AssetBundle bundle = www.assetBundle;//AssetBundle是第2部分 if (AssetName == &quot;&quot;) Instantiate(bundle.mainAsset);//實例化是第3部分 else Instantiate(bundle.Load(AssetName)); // Unload the AssetBundles compressed contents to conserve memory bundle.Unload(false); } // memory is freed from the web stream (www.Dispose() gets called implicitly) } }

內存分配的三個部分匹夫已經在代碼中標識了出來:

  1. Web Stream:包括了壓縮的文件,解壓所需的緩存,以及解壓後的文件。
  2. AssetBundle:Web Stream中的文件的映射,或者說引用。
  3. 實例化之後的對象就是引擎的各種資源文件了,會在內存中創建出來。

那就分別解析一下:

1 WWW www = WWW.LoadFromCacheOrDownload (BundleURL, version)

  1. 將壓縮的文件讀入內存中
  2. 創建解壓所需的緩存
  3. 將文件解壓,解壓後的文件進入內存
  4. 關閉掉為解壓創建的緩存

1 AssetBundle bundle = www.assetBundle;

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

1 Instantiate(bundle.mainAsset);

通過AssetBundle獲取資源,實例化對象

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

1 2 using(WWW www = WWW.LoadFromCacheOrDownload (BundleURL, version)){ }

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

1 2 //刪除Web Stream www.Dispose();

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

1 2 //刪除AssetBundle bundle.Unload(false);

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

深入淺出聊Unity3D項目優化:從Draw Calls到GC (難度2 推薦5)