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操作优化》