1 | UGUI原理簡述
1.1 原理
首先得生成顯示UI用的Mesh,如圖1-1所示,一個矩形的Mesh,由4個頂點,2個三角形組成,每個頂點都包含UV坐標,如果需要調整顏色,還需要提供頂點色。就像UGUI中調節圖片和文本的顏色,其實就是設置它們的頂點色而已。
圖1-1 網格
然后將網格和紋理信息送入GPU中渲染,如圖1-2所示一個最簡單的UI元素就渲染出來了。如果再繼續排列組合其他的UI元素,那么一個游戲界面就誕生了。所謂的UI其實就是用一個正交攝像機端端地看着若干個平面網格。
圖1-2渲染
以上只是UI的最基本的顯示原理,雖然這樣是可以拼出來UI的,但是我們是無法應用在游戲中的。比如,DrawCall需要合並,UI的點擊操作事件、UI基礎組件等等,所以就誕生了偉大的UGUI。帶着界面打開慢和界面操作慢的兩個問題,我們開始分析UGUI的源碼來尋找一些答案。由於部分源碼較長,為了不影響大家閱讀我會刪除一些無用的代碼段,僅保留重要的代碼段。
2丨WillRenderCanvases源碼解讀
使用UGUI都知道只要添加Canvas將會打斷和之前元素DrawCall的合並,每個Canvas都會開始一個全新的DrawCall,遺憾的是UGUI並沒有公開Canvas的源碼,通過反編譯DLL我們看到了Canvas中的部分C#源碼,如下代碼所示,當Canvas需要重繪的時候會調用SendWillRenderCanvases()方法。
Canvas.cs (部分代碼):
public sealed class Canvas : Behaviour { //...略 [RequiredByNativeCode] private static void SendWillRenderCanvases() { //顯然Canvas.willRenderCanvases委托需要有地方監聽 //它是公有委托,所以我們也可以在外部隨時監聽 //UGUI在自己內部(CanvasUpdateRegistry)也監聽了它 if (Canvas.willRenderCanvases == null) return; Canvas.willRenderCanvases(); } //強制刷新所有Canvas,由於它是共有方法所以我們可以隨時調用刷新 public static void ForceUpdateCanvases() { Canvas.SendWillRenderCanvases(); } public delegate void WillRenderCanvases(); }
在CanvasUpdateRegistry的構建函數中可以看到Canvas.willRenderCanvases事件添加到this.PerformUpdate()方法中,UI發生變化一般分兩種情況,一種是修改了寬高這樣會影響到頂點位置需要重建Mesh,還有一種僅僅只修改了顯示元素,這樣並不會影響頂點位置,此時unity會在代碼中區別對待。
CanvasUpdateRegistry.cs(部分代碼):
public class CanvasUpdateRegistry { //...略 protected CanvasUpdateRegistry() { //構造函數處委托函數到PerformUpdate()方法中 //每次Canvas.willRenderCanvases就會執行PerformUpdate()方法 Canvas.willRenderCanvases += PerformUpdate; } private void PerformUpdate() { //開始BeginSample() //在Profiler中看到的標志性函數Canvas.willRenderCanvases耗時就在這里了 //EndSample() UISystemProfilerApi.BeginSample(UISystemProfilerApi.SampleType.Layout); CleanInvalidItems(); m_PerformingLayoutUpdate = true; //需要重建的布局元素(RectTransform發生變化),首先需要根據子對象的數量對它進行排序。 m_LayoutRebuildQueue.Sort(s_SortLayoutFunction); //遍歷待重建布局元素隊列,開始重建 for (int i = 0; i <= (int)CanvasUpdate.PostLayout; i++) { for (int j = 0; j < m_LayoutRebuildQueue.Count; j++) { var rebuild = instance.m_LayoutRebuildQueue[j]; try { if (ObjectValidForUpdate(rebuild)) rebuild.Rebuild((CanvasUpdate)i);//重建布局元素 } catch (Exception e) { Debug.LogException(e, rebuild.transform); } } } for (int i = 0; i < m_LayoutRebuildQueue.Count; ++i) m_LayoutRebuildQueue[i].LayoutComplete(); //布局構建完成后清空隊列 instance.m_LayoutRebuildQueue.Clear(); m_PerformingLayoutUpdate = false; // 布局構建結束,開始進行Mask2D裁切(詳細內容下面會介紹) ClipperRegistry.instance.Cull(); m_PerformingGraphicUpdate = true; //需要重建的Graphics元素(Image Text RawImage 發生變化) for (var i = (int)CanvasUpdate.PreRender; i < (int)CanvasUpdate.MaxUpdateValue; i++) { for (var k = 0; k < instance.m_GraphicRebuildQueue.Count; k++) { try { var element = instance.m_GraphicRebuildQueue[k]; if (ObjectValidForUpdate(element)) element.Rebuild((CanvasUpdate)i);//重建UI元素 } catch (Exception e) { Debug.LogException(e, instance.m_GraphicRebuildQueue[k].transform); } } } //這里需要思考的是,有可能一個Image對象,RectTransform和Graphics同時發生了修改,它們的更新含義不同需要區分對待 //1.修改了Image的寬高,這樣Mesh的頂點會發生變化,此時該對象會加入m_LayoutRebuildQueue隊列 //2.修改了Image的Sprite,它並不會影響頂點位置信息,此時該對象會加入m_GraphicRebuildQueue隊列 //所以上面代碼在遍歷的時候會分層 //for (int i = 0; i <= (int)CanvasUpdate.PostLayout; i++) //for (var i = (int)CanvasUpdate.PreRender; i < (int)CanvasUpdate.MaxUpdateValue; i++) //Rebuild的時候會把層傳進去,保證Image知道現在是要更新布局,還是只更新渲染。 for (int i = 0; i < m_GraphicRebuildQueue.Count; ++i) m_GraphicRebuildQueue[i].GraphicUpdateComplete(); instance.m_GraphicRebuildQueue.Clear(); m_PerformingGraphicUpdate = false; UISystemProfilerApi.EndSample(UISystemProfilerApi.SampleType.Layout); } }
3丨UI重建觸發事件提取
如圖3-1所示,在Profiler中看到的UGUI標志性耗時函數,其實就是在PerformUpdate方法中加的檢測。
圖3-1合並
通常UGUI界面操作卡大概率都是Canvas.SendWillRenderCanvases()方法耗時,需要檢查界面是否存在多余或者無用的重建情況。由於界面很多我們無法定位到到底是哪個界面下的哪個元素引起了網格重建。通過觀察CanvasUpdateRegistry.cs源代碼,我們發現需要網格重建的元素都被緩存在這兩個對象中。
CanvasUpdateRegistry.cs(部分代碼):
接着我們來看看待重建布局元素和待重建渲染元素是如何被緩存起來的。如果某個Graphic發生布局位置或者渲染變化會分別加入這兩個不同的渲染隊列,等待下一次UI的重建。
Graphic cs(部分代碼):
所以我們只需要在外面將這兩個對象撈出來遍歷一下就能知道到底是哪個界面下的哪個元素引起了網格重建。
如圖3-2所示,當Canvas下某個元素引起了網格重建,我們可以知道具體是哪個UI元素。
3-2網格重建
可以參考之前我寫過的文章
4丨網格重建源碼解讀
Canvas.SendWillRenderCanvases()方法到底干了些什么?到底卡在那里?觀察代碼可以發現它需要調用每個ICanvasElement接口下的Rebuild()方法。UGUI的Image和Text組件都派生自Graphics類,並且都實現了ICanvasElement接口。
如下代碼所示,Rebuild()方法就是 UpdateGeometry(更新幾何網格)和 UpdateMaterial (更新材質),看來這和我們文章一開始講的UI繪制原理是一模一樣的。
Graphic.cs(部分代碼):
UpdateGeometry(更新幾何網格),就是確定每一個UI元素Mesh的信息,包括頂點數據、三角形數據、UV數據、頂點色數據。如下代碼所示,無論Image還是Text數據都會在OnPopulateMesh函數中進行收集,它是一個虛函數會在各自的類中實現。
頂點數據准備完畢后會調用canvasRenderer.SetMesh()方法來提交。很遺憾CanvasRenderer.cs並沒有開源,我們只能繼續反編譯看它的實現了,如下代碼所示,SetMesh()方法最終在C++中實現,畢竟由於UI的元素很多,同時參與合並頂點的信息也會很多,在C++中實現效率會更好。看到這里,我相信大家應該能明白UGUI為什么效率會比NGUI要高一些了,因為NGUI的網格Mesh合並都是在C#中完成的,而UGUI網格合並都是在C++中底層中完成的。
CanvasRenderer.cs(部分代碼)
總的來說 Profiler中看到Canvas.SendWillRenderCanvases()效率過低就是因為參數Rebuild()的元素過多,底層的代碼我們是無法修改,但是卻可以從策略上避免,文章后面我會講講我是如何避免它的。
再回到Canvas.SendWillRenderCanvases()方法,當網格需要重建時Unity底層會自行調用,在UGUI中只需要准備好需要參與Rebuild()的元素即可。
如果某個UI需要重建,首先需要將它加入“待重建隊列”,等到下一次Unity系統回調Canvas.SendWillRenderCanvases()方法時一起Rebuild()。如下代碼所示,只需要調用LayoutRebuilder.MarkLayoutForRebuild(rectTransform)方法就可以將該UI元素加入“待重建隊列”等待重建。
由於元素的改變可分為布局變化、頂點變化、材質變化,所以分別提供了三個方法SetLayoutDirty();SetVerticesDirty();SetMaterialDirty();供選擇。舉個例子,在UI中如果調整了元素在Hierarchy中的父節點,如下代碼所示,在OnTransformParentChanged()方法中監聽,通過SetAllDirty();方法將該UI加入“待重建隊列”。
為什么UI發生變化一定要加入待重建隊列中呢?其實這個不難想象,一個UI界面同一幀可能有N個對象發生變化,任意一個發生變化都需要重建UI那么肯定會卡死。所以我們先把需要重建的UI加入隊列,等待一個統一的時機來合並。
Graphic.cs(部分代碼):
再比如需要修改Text文本的字體,由於字體大小的變化只會影響布局信息和頂點信息,那么就調用SetVerticesDirty();SetLayoutDirty();方法即可。
UI的網格我們都已經合並到了相同Mesh中,還需要保證貼圖、材質、Shader相同才能真正合並成一個DrawCall。UGUI開發時使用的是Sprite對象,其實Sprite對象只是在Texture上又封裝的一層數據結構,它記錄的是Sprite大小以及九宮格的區域,還有Sprite保存在哪個Atals中。如果很多界面Prefab引用了這個Sprite,它只是通過GUID進行了關聯,它並不會影響到已經在Prefab中保存的Sprite,這對后期調整圖集和DrawCall方便太多了,這也是UGUI比NGUI更方便的一點。
5丨Image源碼解讀
接着我們來看看放在相同圖集中的Sprite是如何合並DrawCall的,從原理上來講,每個Mesh都需要給頂點設置UV信息,也就是說我們只需要將圖集上的某個區域一一摳出來貼到Mesh正確的區域即可。如下代碼所示,只要觀察GenerateSimpleSprite()方法,UGUI通過Sprites.DataUtility.GetOuterUV(activeSprite)方法將當前待顯示的Sprite的UV信息取出來,通過vh.AddVert()和vh.AddTriangle()來填充Mesh信息。
Image.cs(部分代碼):
最終,不同的UI使用相同的材質、Shader、貼圖,只是它們擁有不同的UV信息,這符合Draw Call合並的規則,所以就能合批。
6丨Text源碼解讀
UGUI的Text就是位圖字體,先通過TTF字體將字體形狀生成在位圖中,接着就是將正確的UV設置給字體的Mesh,這和前面介紹的Image組件幾乎一樣了。如下代碼所示,首先需要根據文本的區域、字體、填充文字調用GetGenerationSettings()創建文本生成器,頂點、uv信息都會被填充好,由於每個文本都是一個Quad,所以還需要設置它們的位置。
Text.cs(部分代碼):
如下代碼所示, 字體的貼圖保存在Font.material.mainTexture中,Mesh信息准備好后將字體的材質貼上就可以將文字渲染出來了,最終字體和Image繪制完全一樣,通過Graphic.UpdateMaterial()將材質貼上。
7丨Text隱患分析
UGUI的Text Effect包含了字體的描邊和陰影,它是如何實現的呢?如下代碼所示,在OnPopulateMesh()填充Mesh信息以后,會遍歷IMeshModifier接口,如果找到了執行它的ModifyMesh()方法來繼續修改Mesh。由於Outline和Shadow都實現了IMeshModifier接口,所以TextEffect就是可以重新修改Mesh接口了。
Graphic.cs(部分代碼):
如圖7-1所示,字體一旦添加描邊以后會在原有基礎上多繪制8遍,頂點數也會多8份,所以在UGUI中最好使用陰影來代替描邊。畢竟陰影只會多畫一遍。
圖7-1 描邊
Text隱患
UGUI的字體是位圖,它會根據圖片字的使用情況慢慢地擴充位圖,如果字體圖片變了而記錄圖片字的位置信息沒有更新,那么就會出現花屏的情況。UGUI提供了位圖更新的監聽,保險起見重新刷新一下每個文本即可,如下代碼所示。
位圖字體也會帶來另外一個問題,如圖7-2所示,比如游戲中有兩個字體,一個是20號字體,另一個是21號字體,即使這兩個字的內容完全一致也需要生成兩份在位圖中。
圖7-2 位圖字體
對比這個問題SDF字體會更好,最新版本的Text Mesh Pro已經支持了動態字體,如果是新項目可以嘗試使用。
8丨頂點輔助類VertexHelper
前面我們介紹的Image和Text 合並網格都用到了VertexHelper類,如下代碼所示,它只是個普通的類對象,里面只保存了生成Mesh的基本信息並非Mesh對象,最后通過這些基本信息就可以生成Mesh網格了。
VertexHelper.cs(部分代碼):
Graphic中有個靜態對象s_VertexHelper保存每次生成的網格信息,使用完后會立即清理掉等待下個Graphic對象使用。
Graphic.cs(部分代碼):
9丨Layout源碼解讀
UGUI的布局功能確實很強大,只要掛在節點下就可以設置HorizontalLayoutGroup(橫向)、VerticalLayoutGroup(縱向)、GridLayoutGroup(表格)的布局了。雖然使用方便,但是效率是不高的,這里我們以縱向來舉例。無論橫向還是縱向排列,首先得計算出每個子對象的區域才行。如下代碼所示,在GetChildSizes()方法中拿到每個元素的區域。
HorizontalOrVerticalLayoutGroup.cs(部分代碼):
如下代碼所示,最核心的計算在LayoutUtility. GetLayoutProperty()方法中,把每個實現ILayoutElement接口的對象的信息取出來。
LayoutUtility.cs(部分代碼):
如下代碼所示,由於Image和Text都實現了ILayoutElement接口,所以LayoutGroup下的Image和Text元素會自動布局,也可以綁定LayoutElement腳本主動設置區域。
但是Layout還有Min Wdith和Flexible Width可設置最小寬高和彈性寬高,這都需要進行額外的計算產生額外的開銷,如果對效率要求比較高的UI,最好可以考慮自行封裝一套布局組件。 如圖9-1所示,有時候希望布局以后自動計算RectTransform的區域,那么就不得不再掛上一個Content Size Fitter組件了,它是在LayoutRebuilder中等待Rebuild()時調用,那么勢必會再次造成Rebuild()
。
圖9-1 Content Size Fitter
不得不說 Content Size Fitter、VerticalLayoutGroup、HorizontalLayoutGroup、 AspectRatioFitter、GridLayoutGroup組件效率是很低的,它們勢必會導致所有元素的Rebuild()執行兩次。
1、界面第一次打開需要進行第一次Rebuild()
2、Layout組件要算位置或者大小會強制再執行一次Rebuild()
很有可能有些元素是不需要Rebuild的,但是Layout組件也會強制執行,那么勢必造成額外的開銷。
10丨遮罩:Mask與Mask2D
UGUI的裁切分為Mask和Mask2D兩種,我們先來看Mask。它可以給Mask指定一張裁切圖裁切子元素。如圖10-1所示,我們給Mask指定了一張圓形圖片,那么子節點下的元素都會被裁切在這個圓形區域中。
圖10-1 Mask
功能確實很強大,我們來看看它的效率如何呢?由於裁切需要同時裁切圖片和文本,所以Image和Text都會派生自MaskableGraphic。如果要讓Mask節點下的元素裁切,那么它需要占一個DrawCall,因為這些元素需要一個新的Shader參數來渲染。如下代碼所示,MaskableGraphic實現了IMaterialModifier接口, 而StencilMaterial.Add()就是設置Shader中的裁切參數。
MaskableGraphic.cs(部分代碼):
如下代碼所示,Image對象在進行Rebuild()時,UpdateMaterial()方法中會獲取需要渲染的材質,並且判斷當前對象的組件是否有繼承IMaterialModifier接口,如果有那么它就是綁定了Mask腳本,接着調用上面提到的GetModifiedMaterial方法修改材質上Shader的參數。
Graphic.cs(部分代碼):
Mask的原理就是利用了StencilBuffer(模板緩沖),它里面記錄了一個ID,被裁切元素也有StencilBuffer(模板緩沖)的ID,並且和Mask里的比較,相同才會被渲染。因為模板緩沖可以提供模板的區域,也就是前面設置的圓形圖片,所以最終會將元素裁切到這個圓心圖片中。 如圖10-2所示,在Mask外面放一個普通的圖片,默認情況下Stencil Ref的值是0,所以它不會被裁切,永遠會顯示出來。
圖10-2 裁切測試
如圖10-3所示,因為Mask的Stencil Ref 值是1,所需被裁切的元素它的Stencil Ref 值也應該是1就會被裁切。
圖10-3 裁切測試
接着我們再來看看Mask2D的原理,在前面介紹Canvas.willRenderCanvases()時在PerformUpdate方法中會調用ClipperRegistry.instance.Cull();來處理界面中所有的Mask2D裁切。
ClipperRegistry.instance.Cull();的原理就是遍歷界面中的所有Mask2D組件,並且調用每個組件的PerformClipping();方法。
如下代碼所示,Mask2D會在OnEnable()方法中,將當前組件注冊ClipperRegistry.Register(this);這樣在上面ClipperRegistry.instance.Cull();方法時就可以遍歷所有Mask2D組件並且調用它們的PerformClipping()方法了。
PerformClipping()方法,需要找到所有需要裁切的UI元素,因為Image和Text都繼承了IClippable接口,最終將調用Cull()進行裁切。
RectMask2D.cs(部分代碼):
如圖10-4所示,RectMask2D會將RectTransform的區域作為_ClipRect傳入Shader中,並且激活UNITY_UI_CLIP_RECT的Keywords。Stencil Ref 的值是0 表示它並沒有使用模板緩沖比較,如果只是矩形裁切,RectMask2D並且它不需要一個無效的渲染用於模板比較,所以RectMask2D的效率會比Mask要高。
圖10-4 裁切測試
如下代碼所示,在Shader的Frag處理像素中,被裁切掉的區域是通過UnityGet2DClipping()將Color.a變成了透明。
RectMask2D.cs(部分代碼):
11丨UI點擊事件
UGUI的事件本質上就是發送射線,由於UI的操作有一些復雜的手勢,所以UGUI幫我們又封裝了一層。創建任意UI時都會自動創建EventSystem對象,並且綁定EventSystem.cs和StandaloneInputModule.cs如下代碼所示,EventSystem會將該對象綁定的所有InputModule腳本收集起來保存在SystemInputModules對象中。
然后在EventSystem的Update()方法中更新它們,通常情況下我們只需要一個StandaloneInputModule即可。
當存在可執行的Module 會調用它的m_CurrentInputModule.Process();方法。那么UI是如何確定出點擊到那個元素上的呢?如下代碼所示,在EventSystem中遍歷所有module.Raycast()方法。
EventSystem.cs(部分代碼)
還記得每個Canvas要想監聽點擊事件必須綁定GraphicRaycaster腳本嗎?上面代碼中的RaycasterManager.GetRaycasters();方法就是獲取當前到底有多少個綁定GraphicRaycaster腳本的對象,那么同時參與點擊事件的Canvas越多效率也就越低了,游戲中有很多界面是疊在一起的,最上面的界面已經擋住了所有界面,但是由於下面的界面還有GraphicRaycaster對象,那么必然產生額外的計算開銷,所以這種情況可以DeActive不需要參與點擊事件的Canvas。
最后我們來看看到底如何判斷點擊的事件的,如下代碼所示,首先遍歷Canvas下每一個參與渲染的Graphic對象,如果勾選了raycastTarget並且點擊射線與它們相交,此時先存起來。
由於多個UI有相交的情況,但由於Mesh都合批了第一個與射線相交的對象是沒有意義的,但是我們只需要響應在最上面的UI元素,這里只能根據depth來做個排序了,找到最上面的UI元素,最后再拋出正確的點擊事件。
GraphicRaycaster.cs(部分代碼)
圖11-1 裁切測試
原文地址:https://www.xuanyusong.com/archives/4291
相關課程推薦:
《UGUI深度研究之優化技巧》
《Unity手游UI框架一站式解決方案》
《詳解UGUI DrawCall計算和Rebuild操作優化》