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(部分代码):
public class CanvasUpdateRegistry { //...略 //保存待重建布局元素(如:RectTransform变化) private readonly IndexedSet<ICanvasElement> m_LayoutRebuildQueue = new IndexedSet<ICanvasElement>(); //保存待重建渲染元素(如:Image变化) private readonly IndexedSet<ICanvasElement> m_GraphicRebuildQueue = new IndexedSet<ICanvasElement>(); }接着我们来看看待重建布局元素和待重建渲染元素是如何被缓存起来的。如果某个Graphic发生布局位置或者渲染变化会分别加入这两个不同的渲染队列,等待下一次UI的重建。
Graphic cs(部分代码):
public abstract class Graphic: UIBehaviour,ICanvasElement { //...略 protected override void OnBeforeTransformParentChanged() { GraphicRegistry.UnregisterGraphicForCanvas(canvas, this); //布局发生变化 LayoutRebuilder.MarkLayoutForRebuild(rectTransform); //LayoutRebuilder.MarkLayoutForRebuild方法内部实现 //private static void MarkLayoutRootForRebuild(RectTransform controller) //{ // if (controller == null) // return; // var rebuilder = s_Rebuilders.Get(); // rebuilder.Initialize(controller); // 局部发生变化,会通过TryRegisterCanvasElementForLayoutRebuild()将自己加入待布局重建队列 // if (!CanvasUpdateRegistry.TryRegisterCanvasElementForLayoutRebuild(rebuilder)) // s_Rebuilders.Release(rebuilder); //} } public virtual void SetMaterialDirty() { if (!IsActive()) return; m_MaterialDirty = true; //渲染发生变化,会通过RegisterCanvasElementForGraphicRebuild()将自己加入待渲染队列 CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this); if (m_OnDirtyMaterialCallback != null) m_OnDirtyMaterialCallback(); } }所以我们只需要在外面将这两个对象捞出来遍历一下就能知道到底是哪个界面下的哪个元素引起了网格重建。
using System.Collections.Generic; using System.Reflection; using UnityEngine; using UnityEngine.UI; public class NewBehaviourScript : MonoBehaviour { IList<ICanvasElement> m_LayoutRebuildQueue; IList<ICanvasElement> m_GraphicRebuildQueue; private void Awake() { System.Type type = typeof(CanvasUpdateRegistry); FieldInfo field = type.GetField("m_LayoutRebuildQueue", BindingFlags.NonPublic | BindingFlags.Instance); m_LayoutRebuildQueue = (IList<ICanvasElement>)field.GetValue(CanvasUpdateRegistry.instance); field = type.GetField("m_GraphicRebuildQueue", BindingFlags.NonPublic | BindingFlags.Instance); m_GraphicRebuildQueue = (IList<ICanvasElement>)field.GetValue(CanvasUpdateRegistry.instance); } private void Update() { for (int j = 0; j < m_LayoutRebuildQueue.Count; j++) { var rebuild = m_LayoutRebuildQueue[j]; if (ObjectValidForUpdate(rebuild)) { //Debug.LogFormat("{0}引起网格重建", rebuild.transform.name,); } } for (int j = 0; j < m_GraphicRebuildQueue.Count; j++) { var element = m_GraphicRebuildQueue[j]; if (ObjectValidForUpdate(element)) { Debug.LogFormat("{0}引起{1}网格重建", element.transform.name, element.transform.GetComponent<Graphic>().canvas.name); } } } private bool ObjectValidForUpdate(ICanvasElement element) { var valid = element != null; var isUnityObject = element is Object; //Here we make use of the overloaded UnityEngine.Object == null, that checks if the native object is alive. if (isUnityObject) valid = (element as Object) != null; return valid; } }如图3-2所示,当Canvas下某个元素引起了网格重建,我们可以知道具体是哪个UI元素。
3-2网格重建
可以参考之前我写过的文章
4丨网格重建源码解读
Canvas.SendWillRenderCanvases()方法到底干了些什么?到底卡在那里?观察代码可以发现它需要调用每个ICanvasElement接口下的Rebuild()方法。UGUI的Image和Text组件都派生自Graphics类,并且都实现了ICanvasElement接口。
如下代码所示,Rebuild()方法就是 UpdateGeometry(更新几何网格)和 UpdateMaterial (更新材质),看来这和我们文章一开始讲的UI绘制原理是一模一样的。
Graphic.cs(部分代码):
public abstract class Graphic : UIBehaviour, ICanvasElement { //...略 //开始网格重建 public virtual void Rebuild(CanvasUpdate update) { if (canvasRenderer.cull) return; switch (update) { case CanvasUpdate.PreRender: if (m_VertsDirty) { //更新网格顶点信息 UpdateGeometry(); m_VertsDirty = false; } if (m_MaterialDirty) { //更新渲染信息 UpdateMaterial(); m_MaterialDirty = false; } break; } } }UpdateGeometry(更新几何网格),就是确定每一个UI元素Mesh的信息,包括顶点数据、三角形数据、UV数据、顶点色数据。如下代码所示,无论Image还是Text数据都会在OnPopulateMesh函数中进行收集,它是一个虚函数会在各自的类中实现。
protected virtual void UpdateGeometry() { //Image RawImage Text 在构造函数都给 useLegacyMeshGeneration 赋值为false if (useLegacyMeshGeneration) DoLegacyMeshGeneration(); else DoMeshGeneration(); //更新Image RawImage Text 元素网格信息 } private void DoMeshGeneration() { if (rectTransform != null && rectTransform.rect.width >= 0 && rectTransform.rect.height >= 0) OnPopulateMesh(s_VertexHelper);//在继承类中实现具体的元素信息 else s_VertexHelper.Clear(); var components = ListPool<Component>.Get(); //获取当前对象是否有IMeshModifier接口 //Text的描边和阴影都是通过IMeshModifier的ModifyMesh()实现出来的 GetComponents(typeof(IMeshModifier), components); for (var i = 0; i < components.Count; i++) ((IMeshModifier)components[i]).ModifyMesh(s_VertexHelper); ListPool<Component>.Release(components); s_VertexHelper.FillMesh(workerMesh); canvasRenderer.SetMesh(workerMesh);//提交网格信息,开始合并网格 }顶点数据准备完毕后会调用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元素加入“待重建队列”等待重建。
public abstract class Graphic : UIBehaviour,ICanvasElement { //...略 //更新全部 public virtual void SetAllDirty() { //更新布局 SetLayoutDirty(); //更新顶点 SetVerticesDirty(); //更新材质 SetMaterialDirty(); } public virtual void SetLayoutDirty() { if (!IsActive()) return; //加入待布局队列 LayoutRebuilder.MarkLayoutForRebuild(rectTransform); if (m_OnDirtyLayoutCallback != null) m_OnDirtyLayoutCallback(); } public virtual void SetVerticesDirty() { if (!IsActive()) return; m_VertsDirty = true; //加入待渲染列 CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this); if (m_OnDirtyVertsCallback != null) m_OnDirtyVertsCallback(); } public virtual void SetMaterialDirty() { if (!IsActive()) return; m_MaterialDirty = true; //加入待渲染列 CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this); if (m_OnDirtyMaterialCallback != null) m_OnDirtyMaterialCallback(); } }由于元素的改变可分为布局变化、顶点变化、材质变化,所以分别提供了三个方法SetLayoutDirty();SetVerticesDirty();SetMaterialDirty();供选择。举个例子,在UI中如果调整了元素在Hierarchy中的父节点,如下代码所示,在OnTransformParentChanged()方法中监听,通过SetAllDirty();方法将该UI加入“待重建队列”。
为什么UI发生变化一定要加入待重建队列中呢?其实这个不难想象,一个UI界面同一帧可能有N个对象发生变化,任意一个发生变化都需要重建UI那么肯定会卡死。所以我们先把需要重建的UI加入队列,等待一个统一的时机来合并。
Graphic.cs(部分代码):
public abstract class Graphic : UIBehaviour,ICanvasElement { //...略 protected override void OnTransformParentChanged() { base.OnTransformParentChanged(); m_Canvas = null; if (!IsActive()) return; CacheCanvas(); GraphicRegistry.RegisterGraphicForCanvas(canvas, this); //加入重建队列 SetAllDirty(); } }再比如需要修改Text文本的字体,由于字体大小的变化只会影响布局信息和顶点信息,那么就调用SetVerticesDirty();SetLayoutDirty();方法即可。
public int fontSize { get { return m_FontData.fontSize; } set { if (m_FontData.fontSize == value) return; m_FontData.fontSize = value; //加入重建队列 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(部分代码):
public class Image : MaskableGraphic, ISerializationCallbackReceiver, ILayoutElement, ICanvasRaycastFilter { //...略 protected override void OnPopulateMesh(VertexHelper toFill) { if (activeSprite == null) { base.OnPopulateMesh(toFill); return; } //Image有4种显示模式,正常、九宫、平铺、填充 //分别生成对应的顶点信息 switch (type) { case Type.Simple: if (!useSpriteMesh) GenerateSimpleSprite(toFill, m_PreserveAspect); else GenerateSprite(toFill, m_PreserveAspect); break; case Type.Sliced: GenerateSlicedSprite(toFill); break; case Type.Tiled: GenerateTiledSprite(toFill); break; case Type.Filled: GenerateFilledSprite(toFill, m_PreserveAspect); break; } } void GenerateSimpleSprite(VertexHelper vh, bool lPreserveAspect) { Vector4 v = GetDrawingDimensions(lPreserveAspect); //获取根据Sprite获取正确的UV信息 var uv = (activeSprite != null) ? Sprites.DataUtility.GetOuterUV(activeSprite) : Vector4.zero; var color32 = color; vh.Clear(); //填充顶点、颜色、uv vh.AddVert(new Vector3(v.x, v.y), color32, new Vector2(uv.x, uv.y)); vh.AddVert(new Vector3(v.x, v.w), color32, new Vector2(uv.x, uv.w)); vh.AddVert(new Vector3(v.z, v.w), color32, new Vector2(uv.z, uv.w)); vh.AddVert(new Vector3(v.z, v.y), color32, new Vector2(uv.z, uv.y)); //填充三角形顶点序列 vh.AddTriangle(0, 1, 2); vh.AddTriangle(2, 3, 0); } }最终,不同的UI使用相同的材质、Shader、贴图,只是它们拥有不同的UV信息,这符合Draw Call合并的规则,所以就能合批。
6丨Text源码解读
UGUI的Text就是位图字体,先通过TTF字体将字体形状生成在位图中,接着就是将正确的UV设置给字体的Mesh,这和前面介绍的Image组件几乎一样了。如下代码所示,首先需要根据文本的区域、字体、填充文字调用GetGenerationSettings()创建文本生成器,顶点、uv信息都会被填充好,由于每个文本都是一个Quad,所以还需要设置它们的位置。
Text.cs(部分代码):
public class Text : MaskableGraphic, ILayoutElement { //...略 //字体生成器 public TextGenerator cachedTextGenerator { get { return m_TextCache ?? (m_TextCache = (m_Text.Length != 0 ? new TextGenerator(m_Text.Length) : new TextGenerator())); } } readonly UIVertex[] m_TempVerts = new UIVertex[4]; protected override void OnPopulateMesh(VertexHelper toFill) { if (font == null) return; m_DisableFontTextureRebuiltCallback = true; Vector2 extents = rectTransform.rect.size; //获取字体的生成规则设置 var settings = GetGenerationSettings(extents); //根据待填充字体、生成规则,生成顶点信息 cachedTextGenerator.PopulateWithErrors(text, settings, gameObject); // IList<UIVertex> verts = cachedTextGenerator.verts; float unitsPerPixel = 1 / pixelsPerUnit; int vertCount = verts.Count - 4; if (vertCount <= 0) { toFill.Clear(); return; } Vector2 roundingOffset = new Vector2(verts[0].position.x, verts[0].position.y) * unitsPerPixel; roundingOffset = PixelAdjustPoint(roundingOffset) - roundingOffset; toFill.Clear(); if (roundingOffset != Vector2.zero) { for (int i = 0; i < vertCount; ++i) { int tempVertsIndex = i & 3; //填充顶点信息 m_TempVerts[tempVertsIndex] = verts[i]; //设置字体偏移 m_TempVerts[tempVertsIndex].position *= unitsPerPixel; m_TempVerts[tempVertsIndex].position.x += roundingOffset.x; m_TempVerts[tempVertsIndex].position.y += roundingOffset.y; if (tempVertsIndex == 3) toFill.AddUIVertexQuad(m_TempVerts); //填充UI顶点面片 } } else { for (int i = 0; i < vertCount; ++i) { int tempVertsIndex = i & 3; //填充顶点信息 m_TempVerts[tempVertsIndex] = verts[i]; //设置字体偏移 m_TempVerts[tempVertsIndex].position *= unitsPerPixel; if (tempVertsIndex == 3) toFill.AddUIVertexQuad(m_TempVerts);//填充UI顶点面片 } } m_DisableFontTextureRebuiltCallback = false; } public TextGenerationSettings GetGenerationSettings(Vector2 extents) { //字体设置信息 var settings = new TextGenerationSettings(); //下面对Text信息进行提取 settings.generationExtents = extents; if (font != null && font.dynamic) { settings.fontSize = m_FontData.fontSize; settings.resizeTextMinSize = m_FontData.minSize; settings.resizeTextMaxSize = m_FontData.maxSize; } settings.textAnchor = m_FontData.alignment; settings.alignByGeometry = m_FontData.alignByGeometry; settings.scaleFactor = pixelsPerUnit; settings.color = color; settings.font = font; settings.pivot = rectTransform.pivot; settings.richText = m_FontData.richText; settings.lineSpacing = m_FontData.lineSpacing; settings.fontStyle = m_FontData.fontStyle; settings.resizeTextForBestFit = m_FontData.bestFit; settings.updateBounds = false; settings.horizontalOverflow = m_FontData.horizontalOverflow; settings.verticalOverflow = m_FontData.verticalOverflow; return settings; } }如下代码所示, 字体的贴图保存在Font.material.mainTexture中,Mesh信息准备好后将字体的材质贴上就可以将文字渲染出来了,最终字体和Image绘制完全一样,通过Graphic.UpdateMaterial()将材质贴上。
public override Texture mainTexture { get { if (font != null && font.material != null && font.material.mainTexture != null) return font.material.mainTexture; if (m_Material != null) return m_Material.mainTexture; return base.mainTexture; } }7丨Text隐患分析
UGUI的Text Effect包含了字体的描边和阴影,它是如何实现的呢?如下代码所示,在OnPopulateMesh()填充Mesh信息以后,会遍历IMeshModifier接口,如果找到了执行它的ModifyMesh()方法来继续修改Mesh。由于Outline和Shadow都实现了IMeshModifier接口,所以TextEffect就是可以重新修改Mesh接口了。
Graphic.cs(部分代码):
public abstract class Graphic : UIBehaviour, ICanvasElement { //...略 protected static Mesh workerMesh { get { //Image RawImage Text 的最终网格信息 if (s_Mesh == null) { s_Mesh = new Mesh(); s_Mesh.name = "Shared UI Mesh"; s_Mesh.hideFlags = HideFlags.HideAndDontSave; } return s_Mesh; } } private void DoMeshGeneration() { if (rectTransform != null && rectTransform.rect.width >= 0 && rectTransform.rect.height >= 0) OnPopulateMesh(s_VertexHelper);//在继承类中实现具体的元素信息 else s_VertexHelper.Clear(); var components = ListPool<Component>.Get(); //获取当前对象是否有IMeshModifier接口 //Text的描边和阴影都是通过IMeshModifier的ModifyMesh()实现出来的 GetComponents(typeof(IMeshModifier), components); //将Text的网格信息传入ModifyMesh()进行修改 for (var i = 0; i < components.Count; i++) ((IMeshModifier)components[i]).ModifyMesh(s_VertexHelper); ListPool<Component>.Release(components); //最终Text的Mesh信息保存在s_VertexHelper对象中。 //通过FillMesh()方法将网格构建出来 s_VertexHelper.FillMesh(workerMesh); //提交网格信息,开始合并网格 canvasRenderer.SetMesh(workerMesh); } }如图7-1所示,字体一旦添加描边以后会在原有基础上多绘制8遍,顶点数也会多8份,所以在UGUI中最好使用阴影来代替描边。毕竟阴影只会多画一遍。
图7-1 描边
Text隐患
UGUI的字体是位图,它会根据图片字的使用情况慢慢地扩充位图,如果字体图片变了而记录图片字的位置信息没有更新,那么就会出现花屏的情况。UGUI提供了位图更新的监听,保险起见重新刷新一下每个文本即可,如下代码所示。
using UnityEngine; using System.Collections; using UnityEngine.UI; public class UIFontDirty : MonoBehaviour { bool isDirty = false; Font dirtyFont = null; void Awake() { Font.textureRebuilt += delegate(Font font1) { isDirty = true; dirtyFont = font1; }; } void LateUpdate() { if (isDirty) { isDirty = false; foreach (Text text in GameObject.FindObjectsOfType<Text>()) { if (text.font == dirtyFont) { text.FontTextureChanged(); } } print("textureRebuilt " + dirtyFont.name); dirtyFont = null; } } }位图字体也会带来另外一个问题,如图7-2所示,比如游戏中有两个字体,一个是20号字体,另一个是21号字体,即使这两个字的内容完全一致也需要生成两份在位图中。
图7-2 位图字体
对比这个问题SDF字体会更好,最新版本的Text Mesh Pro已经支持了动态字体,如果是新项目可以尝试使用。
8丨顶点辅助类VertexHelper
前面我们介绍的Image和Text 合并网格都用到了VertexHelper类,如下代码所示,它只是个普通的类对象,里面只保存了生成Mesh的基本信息并非Mesh对象,最后通过这些基本信息就可以生成Mesh网格了。
VertexHelper.cs(部分代码):
public class VertexHelper : IDisposable { //保存每个顶点的位置、颜色、UV、法线、切线 private List<Vector3> m_Positions; private List<Color32> m_Colors; private List<Vector2> m_Uv0S; private List<Vector2> m_Uv1S; private List<Vector2> m_Uv2S; private List<Vector2> m_Uv3S; private List<Vector3> m_Normals; private List<Vector4> m_Tangents; //记录三角形的索引 private List<int> m_Indices; //开始添加顶点的位置、颜色、UV、法线、切线数据 public void AddVert(Vector3 position, Color32 color, Vector2 uv0, Vector2 uv1, Vector3 normal, Vector4 tangent) { InitializeListIfRequired(); m_Positions.Add(position); m_Colors.Add(color); m_Uv0S.Add(uv0); m_Uv1S.Add(uv1); m_Uv2S.Add(Vector2.zero); m_Uv3S.Add(Vector2.zero); m_Normals.Add(normal); m_Tangents.Add(tangent); } //添加三角形的索引 public void AddTriangle(int idx0, int idx1, int idx2) { InitializeListIfRequired(); m_Indices.Add(idx0); m_Indices.Add(idx1); m_Indices.Add(idx2); } }Graphic中有个静态对象s_VertexHelper保存每次生成的网格信息,使用完后会立即清理掉等待下个Graphic对象使用。
Graphic.cs(部分代码):
public abstract class Graphic : UIBehaviour, ICanvasElement { //...略 [NonSerialized] private static readonly VertexHelper s_VertexHelper = new VertexHelper(); private void DoMeshGeneration() { //...略 //s_VertexHelper中的数据信息,调用FillMesh()方法生成真正的网格信息。 s_VertexHelper.FillMesh(workerMesh); //s_VertexHelper.FillMesh内部实现 //就是Unity自己生成Mesh的API而已 //public void FillMesh(Mesh mesh) //{ // InitializeListIfRequired(); // mesh.Clear(); // if (m_Positions.Count >= 65000) // throw new ArgumentException("Mesh can not have more than 65000 vertices"); // mesh.SetVertices(m_Positions); // mesh.SetColors(m_Colors); // mesh.SetUVs(0, m_Uv0S); // mesh.SetUVs(1, m_Uv1S); // mesh.SetUVs(2, m_Uv2S); // mesh.SetUVs(3, m_Uv3S); // mesh.SetNormals(m_Normals); // mesh.SetTangents(m_Tangents); // mesh.SetTriangles(m_Indices, 0); // mesh.RecalculateBounds(); //} //最终提交网格信息,在C++底层中合并网格 canvasRenderer.SetMesh(workerMesh); } }9丨Layout源码解读
UGUI的布局功能确实很强大,只要挂在节点下就可以设置HorizontalLayoutGroup(横向)、VerticalLayoutGroup(纵向)、GridLayoutGroup(表格)的布局了。虽然使用方便,但是效率是不高的,这里我们以纵向来举例。无论横向还是纵向排列,首先得计算出每个子对象的区域才行。如下代码所示,在GetChildSizes()方法中拿到每个元素的区域。
HorizontalOrVerticalLayoutGroup.cs(部分代码):
public abstract class HorizontalOrVerticalLayoutGroup : LayoutGroup { //...略 private void GetChildSizes(RectTransform child, int axis, bool controlSize, bool childForceExpand, out float min, out float preferred, out float flexible) { //获取每个子元素的区域,min最小区域、preferred准确区域、flexible弹性区域 if (!controlSize) { min = child.sizeDelta[axis]; preferred = min; flexible = 0; } else { min = LayoutUtility.GetMinSize(child, axis); preferred = LayoutUtility.GetPreferredSize(child, axis); flexible = LayoutUtility.GetFlexibleSize(child, axis); } if (childForceExpand) flexible = Mathf.Max(flexible, 1); } }如下代码所示,最核心的计算在LayoutUtility. GetLayoutProperty()方法中,把每个实现ILayoutElement接口的对象的信息取出来。
LayoutUtility.cs(部分代码):
public static class LayoutUtility { //...略 public static float GetMinWidth(RectTransform rect) { //计算最小宽度 return GetLayoutProperty(rect, e => e.minWidth, 0); } public static float GetLayoutProperty(RectTransform rect, System.Func<ILayoutElement, float> property, float defaultValue, out ILayoutElement source) { source = null; if (rect == null) return 0; float min = defaultValue; int maxPriority = System.Int32.MinValue; var components = ListPool<Component>.Get(); rect.GetComponents(typeof(ILayoutElement), components); //遍历每一个实现ILayoutElement接口的子对象(Image和Text都实现了ILayoutElement接口) //或者绑定了LayoutElement对象的脚本也实现了ILayoutElement接口 for (int i = 0; i < components.Count; i++) { //确保layoutComp对象有效 var layoutComp = components[i] as ILayoutElement; if (layoutComp is Behaviour && !((Behaviour)layoutComp).isActiveAndEnabled) continue; //确保当前优先级小于最大优先级 int priority = layoutComp.layoutPriority; if (priority < maxPriority) continue; float prop = property(layoutComp); if (prop < 0) continue; //如果有更高的优先级,那么就覆盖最小数值,并且覆盖最大优先级数值 if (priority > maxPriority) { min = prop; maxPriority = priority; source = layoutComp; } //如果组件有相同的优先级,取较大的值 else if (prop > min) { min = prop; source = layoutComp; } } ListPool<Component>.Release(components); //返回最小值 return min; } }如下代码所示,由于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(部分代码):
public abstract class MaskableGraphic : Graphic, IClippable, IMaskable, IMaterialModifier { //...略 public virtual Material GetModifiedMaterial(Material baseMaterial) { var toUse = baseMaterial; //获取模板缓冲值 if (m_ShouldRecalculateStencil) { var rootCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform); m_StencilValue = maskable ? MaskUtilities.GetStencilDepth(transform, rootCanvas) : 0; m_ShouldRecalculateStencil = false; } //确保Mask组件有效 Mask maskComponent = GetComponent<Mask>(); if (m_StencilValue > 0 && (maskComponent == null || !maskComponent.IsActive())) { //设置模板缓冲值,并且设置在该区域内的显示,不在的裁切掉 var maskMat = StencilMaterial.Add(toUse, (1 << m_StencilValue) - 1, StencilOp.Keep, CompareFunction.Equal, ColorWriteMask.All, (1 << m_StencilValue) - 1, 0); StencilMaterial.Remove(m_MaskMaterial); m_MaskMaterial = maskMat; //并且更换新的材质 toUse = m_MaskMaterial; } return toUse; } }如下代码所示,Image对象在进行Rebuild()时,UpdateMaterial()方法中会获取需要渲染的材质,并且判断当前对象的组件是否有继承IMaterialModifier接口,如果有那么它就是绑定了Mask脚本,接着调用上面提到的GetModifiedMaterial方法修改材质上Shader的参数。
Graphic.cs(部分代码):
public abstract class Graphic : UIBehaviour,ICanvasElement { //...略 public virtual void Rebuild(CanvasUpdate update) { if (canvasRenderer.cull) return; switch (update) { case CanvasUpdate.PreRender: if (m_VertsDirty) { //开始更新网格 UpdateGeometry(); m_VertsDirty = false; } if (m_MaterialDirty) { //开始更新材质 UpdateMaterial(); m_MaterialDirty = false; } break; } } public virtual Material materialForRendering { get { //遍历UI中的每个Mask组件 var components = ListPool<Component>.Get(); GetComponents(typeof(IMaterialModifier), components); //并且更新每个Mask组件的模板缓冲材质 var currentMat = material; for (var i = 0; i < components.Count; i++) currentMat = (components[i] as IMaterialModifier).GetModifiedMaterial(currentMat); ListPool<Component>.Release(components); //返回新的材质,用于裁切 return currentMat; } } protected virtual void UpdateMaterial() { if (!IsActive()) return; //更新刚刚替换的新的模板缓冲的材质 canvasRenderer.materialCount = 1; canvasRenderer.SetMaterial(materialForRendering, 0); canvasRenderer.SetTexture(mainTexture); } }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裁切。
private void PerformUpdate() { UISystemProfilerApi.BeginSample(UISystemProfilerApi.SampleType.Layout); //...略 // 开始裁切Mask2D ClipperRegistry.instance.Cull(); //...略 UISystemProfilerApi.EndSample(UISystemProfilerApi.SampleType.Layout); }ClipperRegistry.instance.Cull();的原理就是遍历界面中的所有Mask2D组件,并且调用每个组件的PerformClipping();方法。
public void Cull() { for (var i = 0; i < m_Clippers.Count; ++i) { m_Clippers[i].PerformClipping(); } }如下代码所示,Mask2D会在OnEnable()方法中,将当前组件注册ClipperRegistry.Register(this);这样在上面ClipperRegistry.instance.Cull();方法时就可以遍历所有Mask2D组件并且调用它们的PerformClipping()方法了。
PerformClipping()方法,需要找到所有需要裁切的UI元素,因为Image和Text都继承了IClippable接口,最终将调用Cull()进行裁切。
RectMask2D.cs(部分代码):
public class RectMask2D : UIBehaviour, IClipper, ICanvasRaycastFilter { //...略 protected override void OnEnable() { //注册当前RectMask2D裁切对象,保证下次Rebuild时可进行裁切。 base.OnEnable(); m_ShouldRecalculateClipRects = true; ClipperRegistry.Register(this); MaskUtilities.Notify2DMaskStateChanged(this); } public virtual void PerformClipping() { if (ReferenceEquals(Canvas, null)) { return; } //重新计算裁切区域 if (m_ShouldRecalculateClipRects) { MaskUtilities.GetRectMasksForClip(this, m_Clippers); m_ShouldRecalculateClipRects = false; } //由于裁切可能有多个区域,这里会计算出正确包含重复的一个区域 bool validRect = true; Rect clipRect = Clipping.FindCullAndClipWorldRect(m_Clippers, out validRect); RenderMode renderMode = Canvas.rootCanvas.renderMode; bool maskIsCulled = (renderMode == RenderMode.ScreenSpaceCamera || renderMode == RenderMode.ScreenSpaceOverlay) && !clipRect.Overlaps(rootCanvasRect, true); bool clipRectChanged = clipRect != m_LastClipRectCanvasSpace; bool forceClip = m_ForceClip; // Avoid looping multiple times. foreach (IClippable clipTarget in m_ClipTargets) { if (clipRectChanged || forceClip) { //准备把裁切区域传到每个UI元素的Shader中 clipTarget.SetClipRect(clipRect, validRect); } //确保裁切可用 var maskable = clipTarget as MaskableGraphic; if (maskable != null && !maskable.canvasRenderer.hasMoved && !clipRectChanged) continue; //准备开始裁切,准备重建裁切的UI clipTarget.Cull( maskIsCulled ? Rect.zero : clipRect, maskIsCulled ? false : validRect); } m_LastClipRectCanvasSpace = clipRect; m_ForceClip = false; } }如图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(部分代码):
Shader "UI/Default" { //...略 fixed4 frag(v2f IN) : SV_Target { half4 color = (tex2D(_MainTex, IN.texcoord) + _TextureSampleAdd) * IN.color; //根据_ClipRect比较当前像素是否在裁切区域中,如果不在颜色将设置成透明 #ifdef UNITY_UI_CLIP_RECT color.a *= UnityGet2DClipping(IN.worldPosition.xy, _ClipRect); #endif #ifdef UNITY_UI_ALPHACLIP clip (color.a - 0.001); #endif return color; } }11丨UI点击事件
UGUI的事件本质上就是发送射线,由于UI的操作有一些复杂的手势,所以UGUI帮我们又封装了一层。创建任意UI时都会自动创建EventSystem对象,并且绑定EventSystem.cs和StandaloneInputModule.cs如下代码所示,EventSystem会将该对象绑定的所有InputModule脚本收集起来保存在SystemInputModules对象中。
public void UpdateModules() { GetComponents(m_SystemInputModules); for (int i = m_SystemInputModules.Count - 1; i >= 0; i--) { if (m_SystemInputModules[i] && m_SystemInputModules[i].IsActive()) continue; m_SystemInputModules.RemoveAt(i); } }然后在EventSystem的Update()方法中更新它们,通常情况下我们只需要一个StandaloneInputModule即可。
private void TickModules() { for (var i = 0; i < m_SystemInputModules.Count; i++) { if (m_SystemInputModules[i] != null) m_SystemInputModules[i].UpdateModule(); } }当存在可执行的Module 会调用它的m_CurrentInputModule.Process();方法。那么UI是如何确定出点击到那个元素上的呢?如下代码所示,在EventSystem中遍历所有module.Raycast()方法。
EventSystem.cs(部分代码)
public class EventSystem : UIBehaviour { //...略 public void RaycastAll(PointerEventData eventData, List<RaycastResult> raycastResults) { raycastResults.Clear(); //获得当前激活状态下每个Canvas上绑定的GraphicRaycaster的对象 var modules = RaycasterManager.GetRaycasters(); for (int i = 0; i < modules.Count; ++i) { var module = modules[i]; if (module == null || !module.IsActive()) continue; //开始发送射线 module.Raycast(eventData, raycastResults); } //对发送射线的结果进行排序,保证在前面的UI优先处理 raycastResults.Sort(s_RaycastComparer); } private static readonly Comparison<RaycastResult> s_RaycastComparer = RaycastComparer; private static int RaycastComparer(RaycastResult lhs, RaycastResult rhs) { if (lhs.module != rhs.module) { var lhsEventCamera = lhs.module.eventCamera; var rhsEventCamera = rhs.module.eventCamera; if (lhsEventCamera != null && rhsEventCamera != null && lhsEventCamera.depth != rhsEventCamera.depth) { // 比较camera的深度 if (lhsEventCamera.depth < rhsEventCamera.depth) return 1; if (lhsEventCamera.depth == rhsEventCamera.depth) return 0; return -1; } //比较射线结果的排序优先级 if (lhs.module.sortOrderPriority != rhs.module.sortOrderPriority) return rhs.module.sortOrderPriority.CompareTo(lhs.module.sortOrderPriority); //比较射线结果的渲染优先级 if (lhs.module.renderOrderPriority != rhs.module.renderOrderPriority) return rhs.module.renderOrderPriority.CompareTo(lhs.module.renderOrderPriority); } if (lhs.sortingLayer != rhs.sortingLayer) { // 比较SortingLayer var rid = SortingLayer.GetLayerValueFromID(rhs.sortingLayer); var lid = SortingLayer.GetLayerValueFromID(lhs.sortingLayer); return rid.CompareTo(lid); } //比较sortOrder if (lhs.sortingOrder != rhs.sortingOrder) return rhs.sortingOrder.CompareTo(lhs.sortingOrder); //比较深度 if (lhs.depth != rhs.depth) return rhs.depth.CompareTo(lhs.depth); //比较距离 if (lhs.distance != rhs.distance) return lhs.distance.CompareTo(rhs.distance); //最后比较index return lhs.index.CompareTo(rhs.index); } }还记得每个Canvas要想监听点击事件必须绑定GraphicRaycaster脚本吗?上面代码中的RaycasterManager.GetRaycasters();方法就是获取当前到底有多少个绑定GraphicRaycaster脚本的对象,那么同时参与点击事件的Canvas越多效率也就越低了,游戏中有很多界面是叠在一起的,最上面的界面已经挡住了所有界面,但是由于下面的界面还有GraphicRaycaster对象,那么必然产生额外的计算开销,所以这种情况可以DeActive不需要参与点击事件的Canvas。
最后我们来看看到底如何判断点击的事件的,如下代码所示,首先遍历Canvas下每一个参与渲染的Graphic对象,如果勾选了raycastTarget并且点击射线与它们相交,此时先存起来。
由于多个UI有相交的情况,但由于Mesh都合批了第一个与射线相交的对象是没有意义的,但是我们只需要响应在最上面的UI元素,这里只能根据depth来做个排序了,找到最上面的UI元素,最后再抛出正确的点击事件。
GraphicRaycaster.cs(部分代码)
public class GraphicRaycaster : BaseRaycaster { //...略 [NonSerialized] static readonly List<Graphic> s_SortedGraphics = new List<Graphic>(); private static void Raycast(Canvas canvas, Camera eventCamera, Vector2 pointerPosition, IList<Graphic> foundGraphics, List<Graphic> results) { //遍历,将每个参与渲染的UI添加到s_SortedGraphics对象中。 int totalCount = foundGraphics.Count; for (int i = 0; i < totalCount; ++i) { Graphic graphic = foundGraphics[i]; if (graphic.depth == -1 || !graphic.raycastTarget || graphic.canvasRenderer.cull) continue; if (!RectTransformUtility.RectangleContainsScreenPoint(graphic.rectTransform, pointerPosition, eventCamera)) continue; if (eventCamera != null && eventCamera.WorldToScreenPoint(graphic.rectTransform.position).z > eventCamera.farClipPlane) continue; if (graphic.Raycast(pointerPosition, eventCamera)) { s_SortedGraphics.Add(graphic); } } //根据depth开始对它进行排序 s_SortedGraphics.Sort((g1, g2) => g2.depth.CompareTo(g1.depth)); totalCount = s_SortedGraphics.Count; //最后将排序过的正确顺序保存起来 for (int i = 0; i < totalCount; ++i) results.Add(s_SortedGraphics[i]); s_SortedGraphics.Clear(); } }
所以说GraphicRaycaster组件越多越卡,raycastTarget勾选的越多越卡,其实开发中很多UI是不需要响应点击事件的,但是却被无意地勾选上了。这里我提供一个我开发的经验,如下图11-1所示,我会在Scene界面中将所有勾选过raycastTarget的对象用蓝色矩形框标记出来,这样做UI的人可以很方便地看到,如果有不需要参与点击的UI元素,那么就及时取消勾选吧。图11-1 裁切测试
#if UNITY_EDITOR using UnityEngine; using System.Collections; using UnityEngine.UI; public class DebugUILine : MonoBehaviour { static Vector3[] fourCorners = new Vector3[4]; void OnDrawGizmos() { foreach (MaskableGraphic g in GameObject.FindObjectsOfType<MaskableGraphic>()) { if (g.raycastTarget) { RectTransform rectTransform = g.transform as RectTransform; rectTransform.GetWorldCorners(fourCorners); Gizmos.color = Color.blue; for (int i = 0; i < 4; i++) Gizmos.DrawLine(fourCorners[i], fourCorners[(i + 1) % 4]); } } } } #endif原文地址:https://www.xuanyusong.com/archives/4291
相关课程推荐:
《UGUI深度研究之优化技巧》
《Unity手游UI框架一站式解决方案》
《详解UGUI DrawCall计算和Rebuild操作优化》