天天看點

NGUI所見即所得之UIPanel

轉載:http://www.tuicool.com/articles/b26ZNz

 之前在 NGUI所見即所得之UIWidget , UIGeometry & UIDrawCall  文中就這樣用過這樣的一個例子:

                UIGeometry好比為煮菜準備食材,UIDrawCall好比是煮菜的工具(鍋,爐子等),UIPanel就是大廚了決定着什麼時候該煮菜,UIWidget(UILabel,UISprite和UITexture)是這道菜怎麼樣的最終呈現。

         本來不打算繼續寫UIPanel的内容的,因為沒有這麼深刻的需求,後面自己根據FastGUI生成的UI發現DrawCall數很多: 

NGUI所見即所得之UIPanel

       一個很簡單的界面竟然用了9個DrawCall,相同的material沒有進行DrawCall完全的合并,相對于以前一個Material一個DrawCall是不可接受的,是以這樣就很必要去看下UIPanel都做了哪些事情,看下了NGUI的更新日志有這樣的一句話:

       - 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三者的關系的,是以文中的組織邏輯比較混亂,條理不強,加上本文也是建立者三者之上的,作為行為的結構的流暢性和完整性,是以還是在簡要交代下。 

NGUI所見即所得之UIPanel

       上圖是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有一個靜态變量:

/// <summary>
  /// All draw calls created by the panels.
  /// </summary>
  static public BetterList<UIDrawCall> list = new BetterList<UIDrawCall>();      

      也就是說所有的UIDrawCall都會儲存在list中,都說“大蛇要打七寸”,隻要找到哪裡有 list.add 的調用就知道生成增加了一個UIDrawCall,這樣就找到GetDrawCall函數(也可以通過MonoBehaviour的調試功能打斷點進行函數跟蹤):

/// <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()的調用:

/// <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)等進行更新:

/// <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()函數調用棧圖(不光文筆不好,畫圖也不行,硬傷呀,就這樣也是琢磨很久畫的): 

NGUI所見即所得之UIPanel

DrawCall數量優化

         言歸正傳,本文的話題就是對于NGUI3.0.4的版本(目前最新版)如何減少DrawCall, 先回到文中的第一幅圖,發現兩個以New atlas圖集為material的DrawCall夾着一個以font為字型集的DrawCall間隔,然後使用MonoBehaviour的斷點調試功能進行跟蹤得到UIWidget.list隊列:

NGUI所見即所得之UIPanel

        發現一個規律:使用相同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方法。

/// <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立馬減少一個了,說明這個方法是可行的: 

NGUI所見即所得之UIPanel

( sandwiching issues!)

NGUI所見即所得之UIPanel

        同理,重寫UIWidget的CompareFunc也是可以的。

還是夾層問題(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的“前面”,這樣就應該可以實作夾層的效果了(由于時間的關系,下次再補上測試案例)。 

NGUI所見即所得之UIPanel

        NGUI更新的很快,之前一直也沒有仔細研究,最近開始慢慢看了些,也寫了些部落格,主要有3點收獲:1)NGUI的渲染機制,2)NGUI相關“元件”(Font,Atlas,UIWidget等)實作方法,3)NGUI的設計模式。當然D.S.Qiu覺得NGUI作為一個大的系統一定會有備援和诟病,使用了很多“緩存”的思想,很多細節都沒有處理好,是以我們都可以再努力完善,争取做“站在巨人的肩膀上”的那個人。

       如果您對D.S.Qiu有任何建議或意見可以在文章後面評論,或者發郵件([email protected])交流,您的鼓勵和支援是我前進的動力,希望能有更多更好的分享。

        轉載請在文首注明出處:http://dsqiu.iteye.com/blog/1973651

更多精彩請關注D.S.Qiu的部落格和微網誌(ID:靜水逐風)

① Indie 之路:  http://ravenw.com/blog/2011/10/14/unity-optimization-of-draw-call/

繼續閱讀