NGUI所見即所得之Uipanel
之前在NGUI所見即所得之UIWidget , UIGeometry & UIDrawCall 文中就這樣用過這樣的一個例子:
UIGeometry好比為煮菜準備食材,UIDrawCall好比是煮菜的工具(鍋,爐子等),UIPanel就是大廚了決定著什么時候該煮菜,UIWidget(UILabel,UISPRite和UITexture)是這道菜怎么樣的最終呈現。
本來不打算繼續寫UIPanel的內容的,因為沒有這么深刻的需求,后面自己根據FastGUI生成的UI發現DrawCall數很多:
一個很簡單的界面竟然用了9個DrawCall,相同的material沒有進行DrawCall完全的合并,相對于以前一個Material一個DrawCall是不可接受的,所以這樣就很必要去看下UIPanel都做了哪些事情,看下了NGUI的更新日志有這樣的一句話:
3.0.0:
- NEW: Changed the way widgets get batched, properly fixing all remaining Z/depth issues.
- NEW: Draw calls are now automatically split up as needed (no more sandwiching issues!)
NGUI之前的版本關于組件的顯示跟Z周,depth以及圖集和UIPanel的關系一直都受到大家吐槽和詬病(尤其夾層問題),所以NGUI3.0.3就徹底解決這個問題:使用DrawCall切割,然后由depth完全決定組件顯示的前后。
也就是說,NGUI對DrawCall進行了分割處理,導致DrawCall數量“劇烈”增加,所以要解決DrawCall數量增加,就要UIPanel產生一個UIDrawCall的原理,然后減少UIDrawCall的生成或進行合并。
從上圖,可以發現NGUI還是對部分組件進行了UIDrawCall合并——多個UIWidget使用同一個UIDrawCall,所以要想做到同一個Material使用一個UIDrawCall理論上是完全可行的。
再說UIWidget,UIGeometry&UIDrawCall
雖然已經有NGUI所見即所得之UIWidget , UIGeometry & UIDrawCall 一文,但是由于之前是在幾乎忽略UIPanel的情況下理順UIWiget,UIGeometry&UIDrawCall三者的關系的,所以文中的組織邏輯比較混亂,條理不強,加上本文也是建立者三者之上的,作為行為的結構的流暢性和完整性,所以還是在簡要交代下。
上圖是UIWidget,UIGeometry&UIDrawCall的關系圖,UIWidget用于UIDrawcall mDrawCall和UIGeometry mGeo兩個成員變量,其中UIGeometry就是對UIWidget的頂點vertices,uvs和color進行存儲和更新,UIDrawCall就是根據提供的數據(統一在UIPanel指派)進行渲染繪制。
UIGeometry完全由UIWidget維護,首先UILabel,UISprite,UITexture對UIWidget的OnFill進行重寫——初始化mGeo的verts,uvs,cols的BetterList。然后UIWidget的UpdateGeometry函數對UIGeometry的ApplyTransform()和WriteToBuffer()調用進行更新。
每一個UIWidget都有一個UIGeometry,但是并不都有一個UIDrawCall,而是要通過Batch合并達到減少DrawCall的數量,UIDrawCall是由UIPanel生成的。至于什么是DrawCall,因為沒有3D引擎經驗,只能從只言片語中拾獲一點理解:
“Unity(或者說基本所有圖形引擎)生成一幀畫面的處理過程大致可以這樣簡化描述:引擎首先經過簡單的可見性測試,確定攝像機可以看到的物體,然后把這些物體的頂點(包括本地位置、法線、UV等), 索引(頂點如何組成三角形),變換(就是物體的位置、旋轉、縮放、以及攝像機位置等),相關光源,紋理,渲染方式(由材質/Shader決定)等數據準備好,然后通知圖形API——或者就簡單地看作是通知GPU ——開始繪制,GPU基于這些數據,經過一系列運算,在屏幕上畫出成千上萬的三角形,最終構成一幅圖像。 在Unity中,每次引擎準備數據并通知GPU的過程稱為一次Draw Call。這一過程是逐個物體進行的,對 于每個物體,不只GPU的渲染,引擎重新設置材質/Shader也是一項非常耗時的操作。因此每幀的Draw Call次數是一項非常重要的性能指標。”
NGUI被說的最多的優點就是:減少DrawCall數量。但現在為了解決sandwiching issues和Z/depth issues,對DrawCall進行split。
NGUI指派DrawCall的原理
前面說到,UIDrawCall是由UIPanel生成指派的,哪些UIWiget共用(也就是Batch)一個DrawCall在UIPanel中決定的。UIDrawCall有一個靜態變量:
C#代碼
/// <summary> /// All draw calls created by the panels. /// </summary> static public BetterList<UIDrawCall> list = new BetterList<UIDrawCall>(); 也就是說所有的UIDrawCall都會保存在list中,都說“大蛇要打七寸”,只要找到哪里有 list.add 的調用就知道生成增加了一個UIDrawCall,這樣就找到GetDrawCall函數(也可以通過MonoBehaviour的調試功能打斷點進行函數跟蹤):
C#代碼
/// <summary> /// Get a draw call at the specified index position. /// </summary> UIDrawCall GetDrawCall (int index, Material mat) { if (index < UIDrawCall.list.size) { UIDrawCall dc = UIDrawCall.list.buffer[index]; // If the material and texture match, keep using the same draw call if (dc != null && dc.panel == this && dc.baseMaterial == mat && dc.mainTexture == mat.mainTexture) return dc; // Otherwise we need to destroy all the draw calls that follow for (int i = UIDrawCall.list.size; i > index; ) { UIDrawCall rem = UIDrawCall.list.buffer[--i]; DestroyDrawCall(rem, i); } } #if UNITY_EDITOR // If we're in the editor, create the game object with hide flags set right away GameObject go = UnityEditor.EditorUtility.CreateGameObjectWithHideFlags("_UIDrawCall [" + mat.name + "]", //HideFlags.DontSave | HideFlags.NotEditable); HideFlags.HideAndDontSave); #else GameObject go = new GameObject("_UIDrawCall [" + mat.name + "]"); DontDestroyOnLoad(go); #endif go.layer = cachedGameObject.layer; // Create the draw call UIDrawCall drawCall = go.AddComponent<UIDrawCall>(); drawCall.baseMaterial = mat; drawCall.renderQueue = UIDrawCall.list.size; drawCall.panel = this; //Debug.Log("Added DC " + mat.name + " as " + UIDrawCall.list.size); UIDrawCall.list.Add(drawCall); return drawCall; } 進一步找到Fill()的調用:
C#代碼
/// <summary> /// Fill the geometry fully, processing all widgets and re-creating all draw calls. /// </summary> static void Fill () { for (int i = UIDrawCall.list.size; i > 0; ) DestroyDrawCall(UIDrawCall.list[--i], i); int index = 0; UIPanel pan = null; Material mat = null; UIDrawCall dc = null; for (int i = 0; i < UIWidget.list.size; ) { UIWidget w = UIWidget.list[i]; if (w == null) { UIWidget.list.RemoveAt(i); continue; } if (w.isVisible && w.hasVertices) { if (pan != w.panel || mat != w.material) //a) { if (pan != null && mat != null && mVerts.size != 0) { pan.SubmitDrawCall(dc); dc = null; } pan = w.panel; mat = w.material; } if (pan != null && mat != null) //b) { if (dc == null) dc = pan.GetDrawCall(index++, mat); w.drawCall = dc; if (pan.generateNormals) w.WriteToBuffers(mVerts, mUvs, mCols, mNorms, mTans); else w.WriteToBuffers(mVerts, mUvs, mCols, null, null); } } else w.drawCall = null; ++i; } if (mVerts.size != 0) pan.SubmitDrawCall(dc); } 整理Fill函數的原理如下r:
(1) 獲取UIWidget的隊列UIWidget.list(已經根據depth排好序),聲明一個UIPanel pan,Material mat和UIDrawCall dc,pan,mat和dc都是保存上一次循環的UIPanel,Material和UIDrawCall。
(2) 遍歷UIWidget.list,循環體中對 當前UIWiget w的panel和material是否和當前pan,mat是否相同 進行判斷,分為兩種情況:
a)如果有一種不相同,調用SubmitDrawCall函數,SubmitDrawCall函數其實就是使用pan的mVerts, mUvs, mCols數據,調用UIDrawCall的set函數對Mesh,MeshRender,MeshFilter等進行“設置組裝”。
b)如果相同,通過調用GetDrawCall獲取當前pan和mat的DrawCall,然后將UIWidget w的UIGeometry數據放入mVerts, mUvs, mCols(通過調用函數w.WriteToBuffers(mVerts, mUvs, mCols, mNorms, mTans))
小結:UIPanel的mVerts,mUVs,mCols只是要將要傳給UIDrawCall數據的一個“積蓄”過渡的一個概念,也就是說,Fill函數式這么操作的:先將UIWidget w的中UIGeometry的數據緩存在UIPanel的mVerts,mUVs,mCols,只有當不能再pan或mat與當前的w.panel或w.material不同時就不能再緩存了,然后通過SubmitDrawCall,生成UIDrawCall的工作才完成,然后再重新 new 一個新的UIDrawCall繼續緩存數據。
UIPanel完整工作流程——LateUpdate
前面介紹UIDrawCall的產生過程,當然這是UIPanel最重要的工作之一,在對UIDrawCall進行更新是要對UIPanel的其他信息(transform,layer,widget)等進行更新:
C#代碼
/// <summary> /// Main update function /// </summary> void LateUpdate () { // Only the very first panel should be doing the update logic if (list[0] != this) return; // Update all panels for (int i = 0; i < list.size; ++i) { UIPanel panel = list[i]; panel.mUpdateTime = RealTime.time; panel.UpdateTransformMatrix(); panel.UpdateLayers(); panel.UpdateWidgets(); } // Fill the draw calls for all of the changed materials if (mFullRebuild) { UIWidget.list.Sort(UIWidget.CompareFunc); Fill(); } else { for (int i = 0; i < UIDrawCall.list.size; ) { UIDrawCall dc = UIDrawCall.list[i]; if (dc.isDirty) { if (!Fill(dc)) { DestroyDrawCall(dc, i); continue; } } ++i; } } // Update the clipping rects for (int i = 0; i < list.size; ++i) { UIPanel panel = list[i]; panel.UpdateDrawcalls(); } mFullRebuild = false; } 就不進行文字描述了,貼一張自己的畫的LateUpdate()函數調用棧圖(不光文筆不好,畫圖也不行,硬傷呀,就這樣也是琢磨很久畫的):
DrawCall數量優化
言歸正傳,本文的話題就是對于NGUI3.0.4的版本(目前最新版)如何減少DrawCall, 先回到文中的第一幅圖,發現兩個以New atlas圖集為material的DrawCall夾著一個以font為字體集的DrawCall間隔,然后使用MonoBehaviour的斷點調試功能進行跟蹤得到UIWidget.list隊列:
發現一個規律:使用相同material的連續UIWidget(UILabel,UISprite)共用一個UIDrawCall。這樣就給了一個解決策略:對UIWidget.list進行排序,使得使用相同的material的UIWidget在UIWidget.list相連,而UIWidget.list是根據UIWidget的depth進行排序的。所以可以有如下兩種方法:
1)修改UIWidget(UILabel,UISprite)的depth,限定好UIWidget.list的排序
2)重寫UIWidget的CompareFunc方法。
C#代碼
/// <summary> /// Static widget comparison function used for depth sorting. /// </summary> static public int CompareFunc (UIWidget left, UIWidget right) { int val = UIPanel.CompareFunc(left.mPanel, right.mPanel); if (val == 0) { if (left.mDepth < right.mDepth) return -1; if (left.mDepth > right.mDepth) return 1; Material leftMat = left.material; Material rightMat = right.material; if (leftMat == rightMat) return 0; if (leftMat != null) return -1; if (rightMat != null) return 1; return (leftMat.GetInstanceID() < rightMat.GetInstanceID()) ? -1 : 1; } return val; } 下面對原來對屬于第三個DrawCall的兩個UILabel增大他們的depth,發現DrawCall立馬減少一個了,說明這個方法是可行的:
同理,重寫UIWidget的CompareFunc也是可以的,按照Material的name優先排序,只有當material一樣是才考慮depth進行排序:
C#代碼
/// <summary> /// Static widget comparison function used for depth sorting. /// </summary> static public int CompareFunc (UIWidget left, UIWidget right) { int val = UIPanel.CompareFunc(left.mPanel, right.mPanel); if (val == 0) { //原理排序的方法 /*if (left.mDepth < right.mDepth) return -1; if (left.mDepth > right.mDepth) return 1; Material leftMat = left.material; Material rightMat = right.material; if (leftMat == rightMat) return 0; if (leftMat != null) return -1; if (rightMat != null) return 1; return (leftMat.GetInstanceID() < rightMat.GetInstanceID()) ? -1 : 1;*/ Material leftMat = left.material; Material rightMat = right.material; if (leftMat == rightMat) { if (left.mDepth < right.mDepth) return -1; else if (left.mDepth > right.mDepth) return 1; else return 0; } if(leftMat !=null & rightMat != null) return string.Compare(leftMat.name,rightMat.name); if (leftMat != null) return -1; if (rightMat != null) return 1; return (leftMat.GetInstanceID() < rightMat.GetInstanceID()) ? -1 : 1; } return val; } 最終的DrawCall數量一定是等于使用的Material的數量:
還是夾層問題(sandwiching issues!)
現在我們完全可以實現一個Material一個DrawCall,但是這樣還是沒有解決夾層的難題,NGUI給我們解決方法就是多一個DrawCall,這個其實跟3.0之前的版本多用一個UIPanel或者UIAtlas是一樣的道理。這樣還是感覺沒有從本質上解決這個問題,只是換了一種方式權衡了一下。
記得NGUI3.0之前的版本還是有Z軸的概念的,現在Z軸完全是形同虛設,但是3D引擎的圖形一定是跟Z軸是密切的關系的,而最終的圖形顯示的位置關系是由Mesh的頂點決定的,所以可以考慮Z軸來解決夾層問題:DrawCall控制的渲染隊列的次序renderQueue,Mesh控制的是實際繪制的“地理位置”,如下圖所示,A和C使用相同的圖集有相同的material,B單獨使用一個圖集,可以通過material來排序或者定制好depth,讓A和C使用一個DrawCall,但是C的Mesh(參考transform的Z軸)會在B的“前面”,這樣就應該可以實現夾層的效果了。
做了下測試,修改Mesh的Z軸沒有什么變化,然后早上向同事請教了,因為Material使用的Shader使用了透明,這樣就不能做深度測試,也就是Mesh的“深度”是不影響的,這樣最終的顯示就跟Shader的renderQueue有關了,即renderQueue越大,顯示的越靠前面(重疊的圖層,renderQueue越大,越靠前)。當然現在只有增加一個DrawCall(如多使用一個UIPanel或用另外一個Material)來做到Material的夾層效果。
小結:
NGUI更新的很快,之前一直也沒有仔細研究,最近開始慢慢看了些,也寫了些博客,主要有3點收獲:1)NGUI的渲染機制,2)NGUI相關“組件”(Font,Atlas,UIWidget等)實現方法,3)NGUI的設計模式。當然D.S.Qiu覺得NGUI作為一個大的系統一定會有冗余和詬病,使用了很多“緩存”的思想,很多細節都沒有處理好,所以我們都可以再努力完善,爭取做“站在巨人的肩膀上”的那個人。
發現沒有3D引擎以及圖形渲染的基礎,做點事情還是很蹩腳的,GPU的處理輸出,顯存的大小,CPU與GPU的交互都是要考慮的,有空的時候還是要找到這方面的書來惡補下……
如果您對D.S.Qiu有任何建議或意見可以在文章后面評論,或者發郵件(gd.s.qiu@Gmail.com)交流,您的鼓勵和支持是我前進的動力,希望能有更多更好的分享。
轉載請在文首注明出處:http://dsqiu.iteye.com/blog/1973651
更多精彩請關注D.S.Qiu的博客和微博(ID:靜水逐風)
新聞熱點
疑難解答