UGUI笔记——UI Mesh Rebuild
1.0 UGUI原理
在讲解UI网格重建之前,我们先简单了解一下UGUI实现原理,首先要显示UI,就要生成显示UI用的Mesh,如图所示,一个矩形的Mesh,由4个顶点,2个三角形组成,每个顶点都包含UV坐标,如果需要调整颜色,还需要提供顶点色。例如,调节Image或者Text的颜色,其实就是改变它们的顶点色而已。
网格
然后将网格和纹理信息发送给GUI中,进行渲染,这样一个简单的UI元素就显示出来了,其实这个流程与渲染一个普通的Cube,是类似的。可以简单的理解为,所谓的UI其实就是用一个正交的Camera看着若干的平面网格。不过,只是单单的显示出来还远远不够,例如DrawCall需要合并,Button需要点击等等,因此就诞生了UGUI系统,下面我们通过源码来具体看看UGUI是如何绘制UI Mesh的。
2.0 顶点辅助类VertexHelper
我们在UGUI的原理中说过想要显示一个UI就需要对应的Mesh信息,那么UI的Mesh信息(顶点,三角形等)是保存到了哪里呢?这个就是VertexHelper的作用,它只是一个普通的类对象,保存了生成Mesh的基本信息,如每个顶点的位置,颜色,UV,法线,切线,三角形索引及其生成Mesh的方法,而并非Mesh对象,我们可以通过这些信息生成对应的Mesh网格。
VertexHelper.cs部分源码如下:
public class VertexHelper : IDisposable
{
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;
private static readonly Vector4 s_DefaultTangent = new Vector4(1.0f, 0.0f, 0.0f, -1.0f);
private static readonly Vector3 s_DefaultNormal = Vector3.back;
private bool m_ListsInitalized = false;
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();
}
internal void AddVert(Vector3 position, Color32 color, Vector2 uv0, Vector2 uv1, Vector2 uv2, Vector2 uv3, Vector3 normal, Vector4 tangent)
{
InitializeListIfRequired();
m_Positions.Add(position);
m_Colors.Add(color);
m_Uv0S.Add(uv0);
m_Uv1S.Add(uv1);
m_Uv2S.Add(uv2);
m_Uv3S.Add(uv3);
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);
}
}
3.0 Canvas.WillRenderCanvases事件
通过官方文档,我们可以了解到,当Canvas需要重绘时会调用Canvas.SendWillRenderCanvases()方法,遗憾的是UGUI并没有公开Canvas的源码,通过反编译我们可以看到Canvas的部分源码如下:
Canvas部分源码如下:
[NativeClass("UI::Canvas")]
[NativeHeader("Runtime/UI/Canvas.h")]
[NativeHeader("Runtime/UI/UIStructs.h")]
public sealed class Canvas : Behaviour
{
public delegate void WillRenderCanvases();
public static event Canvas.WillRenderCanvases willRenderCanvases;
public static void ForceUpdateCanvases() => Canvas.SendWillRenderCanvases();
[RequiredByNativeCode]
private static void SendWillRenderCanvases()
{
if (Canvas.willRenderCanvases == null)
return;
Canvas.willRenderCanvases();
}
}
SendWillRenderCanvas()方法中调用Canvas.willRenderCanvases()事件,因此我们可以监听该事件,来刷新我们自己的UI系统,那么我们就来看看UGUI是如何做的。
4.0 CanvasUpdateRegistry
在UGUI中,在CanvasUpdateRegistry的构建函数中,可以看到Canvas.willRenderCanvases事件添加到了PerformUdpate()方法中,然后重新绘制m_LayoutRebuildQueue和m_GraphicRebuildQueue这俩个集合中的UI元素。
CanvasUpdateRegistry.cs部分源码如下:
public class CanvasUpdateRegistry
{
private readonly IndexedSet<ICanvasElement> m_LayoutRebuildQueue = new IndexedSet<ICanvasElement>();
private readonly IndexedSet<ICanvasElement> m_GraphicRebuildQueue = new IndexedSet<ICanvasElement>();
protected CanvasUpdateRegistry()
{
Canvas.willRenderCanvases += PerformUpdate;
}
private static readonly Comparison<ICanvasElement> s_SortLayoutFunction = SortLayoutList;
private void PerformUpdate()
{
UISystemProfilerApi.BeginSample(UISystemProfilerApi.SampleType.Layout);
CleanInvalidItems();
m_PerformingLayoutUpdate = true;
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;
// now layout is complete do culling...
ClipperRegistry.instance.Cull();
m_PerformingGraphicUpdate = true;
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);
}
catch (Exception e)
{
Debug.LogException(e, instance.m_GraphicRebuildQueue[k].transform);
}
}
}
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);
}
}
- m_LayoutRebuildQueue:保存着需要重建的布局元素(一般是通过LayoutGroup布局改变的UI)
- m_GraphicRebuildQueue:需要重建的Graphics元素(如Image,Text,RawIamge的贴图,材质,宽高发生变化)
- UISystemProfilerApi.BeginSample(UISystemProfilerApi.SampleType.Layout);
UISystemProfilerApi.EndSample(UISystemProfilerApi.SampleType.Layout);
我们可以在Profiler中通过查看标志性函数Canvas.willRenderCanvases的耗时,来了解Mesh重建的性能消耗。- ClipperRegistry.instance.Cull();
布局重建结束后,开始进行Mask2D裁切,这个将在其他文章中介绍。
5.0 ICanvasElement.Rebuild()
通过上面的源码,我们可以看到Canvas.willRenderCanvases事件实际撒花姑娘是调用了每个ICanvasElement接口下的Rebuild方法。
UGUI的布局系统是通过LayoutRebuilder类管理的,LayoutRebuilder实现了ICanvasElement接口,用于管理layout的rebuilding,实现自动布局。除此之外,UGUI的Image和Text组件都是派生自Graphics类,并且实现了ICanvasElement接口,用于最终更新Mesh。这也分别对应了上面的m_LayoutRebuildQueue和m_GraphicRebuildQueue俩个集合中的元素。
5.1 Graphics的Rebuild()方法
我们先来看看Graphics的Rebuild()方法,Rebuild()方法会调用UpdateGeometry()用于更新几何网格,调用UpdateMaterial()用于更新材质,这与我们讲的UI绘制原来一样。
Graphics.cs部分源码如下;
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;
}
}
5.1.1 UpdateGeometry()
Graphic中有个静态对象s_VertexHelper保存每次生成的Mesh信息(包括顶点,三角形索引,UV,顶点色等数据),使用完后会立即清理掉等待下个Graphic对象使用。
Graphic.cs部分源码如下:
public abstract class Graphic: UIBehaviour,ICanvasElement
{
[NonSerialized] protected static Mesh s_Mesh;
[NonSerialized] private static readonly VertexHelper s_VertexHelper = new VertexHelper();
protected static Mesh workerMesh
{
get
{
if (s_Mesh == null)
{
s_Mesh = new Mesh();
s_Mesh.name = "Shared UI Mesh";
s_Mesh.hideFlags = HideFlags.HideAndDontSave;
}
return s_Mesh;
}
}
/// <summary>
/// Call to update the geometry of the Graphic onto the CanvasRenderer.
/// </summary>
protected virtual void UpdateGeometry()
{
if (useLegacyMeshGeneration)
DoLegacyMeshGeneration();
else
DoMeshGeneration();
}
private void DoMeshGeneration()
{
if (rectTransform != null && rectTransform.rect.width >= 0 && rectTransform.rect.height >= 0)
OnPopulateMesh(s_VertexHelper);
else
s_VertexHelper.Clear(); // clear the vertex helper so invalid graphics dont draw.
var components = ListPool<Component>.Get();
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);
}
}
我们可以看到,s_VertexHelper中的数据通过OnPopulateMesh函数,进行填充,它是一个虚函数会在各自的类中实现,如下是默认的网格数据,我们可以在自己的UI类中,重写OnPopulateMesh方法,实现自定义的UI。
protected virtual void OnPopulateMesh(VertexHelper vh)
{
var r = GetPixelAdjustedRect();
var v = new Vector4(r.x, r.y, r.x + r.width, r.y + r.height);
Color32 color32 = color;
vh.Clear();
vh.AddVert(new Vector3(v.x, v.y), color32, new Vector2(0f, 0f));
vh.AddVert(new Vector3(v.x, v.w), color32, new Vector2(0f, 1f));
vh.AddVert(new Vector3(v.z, v.w), color32, new Vector2(1f, 1f));
vh.AddVert(new Vector3(v.z, v.y), color32, new Vector2(1f, 0f));
vh.AddTriangle(0, 1, 2);
vh.AddTriangle(2, 3, 0);
}
s_VertexHelper数据填充之后,调用FillMesh()方法生成真正的Mesh,然后调用 canvasRenderer.SetMesh()方法来提交。很遗憾CanvasRenderer.cs并没有开源,通过反编译看到如下代码,SetMesh()方法最终在C++中实现,这也是UGUI的效率比NGUI高一些的原因,因为NGUI的Mesh合并是在C#中完成的,而UGUI的Mesh合并是在C++中底层完成的。
CanvasRenderer.cs 部分源码如下:
[MethodImpl(MethodImplOptions.InternalCall)]
public extern void SetMesh(Mesh mesh);
5.1.2 UpdateMaterial()
UpdateMaterial()方法会通过canvasRenderer来更新Material与Texture。
/// <summary>
/// Call to update the Material of the graphic onto the CanvasRenderer.
/// </summary>
protected virtual void UpdateMaterial()
{
if (!IsActive())
return;
canvasRenderer.materialCount = 1;
canvasRenderer.SetMaterial(materialForRendering, 0);
canvasRenderer.SetTexture(mainTexture);
}
很遗憾CanvasRenderer.cs并没有开源,通过反编译看到如下代码。
CanvasRenderer.cs 部分源码如下:
[MethodImpl(MethodImplOptions.InternalCall)]
public extern void SetMaterial(Material material, int index);
[MethodImpl(MethodImplOptions.InternalCall)]
public extern void SetTexture(Texture texture);
5.2 LayoutRebuilder的Rebuild()方法
LayoutRebuilder涉及到了UGUI的布局系统,我们在这里简单了解一下,元素的自动布局,会在布局系统的文章中介绍。
- PerformLayoutCalculation()方法会递归计算UI元素的宽高(先计算子元素,在计算自身元素)
ILayoutElement.CalculateLayoutInputXXXXXX()在具体的实现类中计算该UI的大小 - PerformLayoutControl()方法会递归设置UI元素的宽高(先设置自身元素,在设置子元素)
ILayoutController.SetLayoutXXXXX()在具体的实现类中设置该UI的大小
public void Rebuild(CanvasUpdate executing)
{
switch (executing)
{
case CanvasUpdate.Layout:
// It's unfortunate that we'll perform the same GetComponents querys for the tree 2 times,
// but each tree have to be fully iterated before going to the next action,
// so reusing the results would entail storing results in a Dictionary or similar,
// which is probably a bigger overhead than performing GetComponents multiple times.
PerformLayoutCalculation(m_ToRebuild, e => (e as ILayoutElement).CalculateLayoutInputHorizontal());
PerformLayoutControl(m_ToRebuild, e => (e as ILayoutController).SetLayoutHorizontal());
PerformLayoutCalculation(m_ToRebuild, e => (e as ILayoutElement).CalculateLayoutInputVertical());
PerformLayoutControl(m_ToRebuild, e => (e as ILayoutController).SetLayoutVertical());
break;
}
}
private void PerformLayoutControl(RectTransform rect, UnityAction<Component> action)
{
if (rect == null)
return;
var components = ListPool<Component>.Get();
rect.GetComponents(typeof(ILayoutController), components);
StripDisabledBehavioursFromList(components);
// If there are no controllers on this rect we can skip this entire sub-tree
// We don't need to consider controllers on children deeper in the sub-tree either,
// since they will be their own roots.
if (components.Count > 0)
{
// Layout control needs to executed top down with parents being done before their children,
// because the children rely on the sizes of the parents.
// First call layout controllers that may change their own RectTransform
for (int i = 0; i < components.Count; i++)
if (components[i] is ILayoutSelfController)
action(components[i]);
// Then call the remaining, such as layout groups that change their children, taking their own RectTransform size into account.
for (int i = 0; i < components.Count; i++)
if (!(components[i] is ILayoutSelfController))
action(components[i]);
for (int i = 0; i < rect.childCount; i++)
PerformLayoutControl(rect.GetChild(i) as RectTransform, action);
}
ListPool<Component>.Release(components);
}
private void PerformLayoutCalculation(RectTransform rect, UnityAction<Component> action)
{
if (rect == null)
return;
var components = ListPool<Component>.Get();
rect.GetComponents(typeof(ILayoutElement), components);
StripDisabledBehavioursFromList(components);
// If there are no controllers on this rect we can skip this entire sub-tree
// We don't need to consider controllers on children deeper in the sub-tree either,
// since they will be their own roots.
if (components.Count > 0 || rect.GetComponent(typeof(ILayoutGroup)))
{
// Layout calculations needs to executed bottom up with children being done before their parents,
// because the parent calculated sizes rely on the sizes of the children.
for (int i = 0; i < rect.childCount; i++)
PerformLayoutCalculation(rect.GetChild(i) as RectTransform, action);
for (int i = 0; i < components.Count; i++)
action(components[i]);
}
ListPool<Component>.Release(components);
}
6.0 Rebuild()元素加入“待重建队列”
我们回到Canvas.willRenderCanvases事件,当Mesh需要重建时,Unity底层会自动调用,那么如果某个UI需要重建,我们只需要将它加入到“待重建队列”中,等待下一次Untiy系统回调Canvas.willRenderCanvases事件时,一起Rebuild即可。那么UI元素什么情况下会添加到“待重建队列”中的呢?
由于元素对的改变分为布局变化,顶点变化,材质变化,所以分别提供了三个方法SetLayoutDirty()更新布局,SetVerticesDirty()更新顶点,SetMaterialDirty()更新材质。
Graphic.cs部分源码如下:
/// <summary>
/// Set all properties of the Graphic dirty and needing rebuilt.
/// Dirties Layout, Vertices, and Materials.
/// </summary>
public virtual void SetAllDirty()
{
SetLayoutDirty();
SetVerticesDirty();
SetMaterialDirty();
}
/// <summary>
/// Mark the layout as dirty and needing rebuilt.
/// </summary>
/// <remarks>
/// Send a OnDirtyLayoutCallback notification if any elements are registered. See RegisterDirtyLayoutCallback
/// </remarks>
public virtual void SetLayoutDirty()
{
if (!IsActive())
return;
LayoutRebuilder.MarkLayoutForRebuild(rectTransform);
if (m_OnDirtyLayoutCallback != null)
m_OnDirtyLayoutCallback();
}
/// <summary>
/// Mark the vertices as dirty and needing rebuilt.
/// </summary>
/// <remarks>
/// Send a OnDirtyVertsCallback notification if any elements are registered. See RegisterDirtyVerticesCallback
/// </remarks>
public virtual void SetVerticesDirty()
{
if (!IsActive())
return;
m_VertsDirty = true;
CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this);
if (m_OnDirtyVertsCallback != null)
m_OnDirtyVertsCallback();
}
/// <summary>
/// Mark the material as dirty and needing rebuilt.
/// </summary>
/// <remarks>
/// Send a OnDirtyMaterialCallback notification if any elements are registered. See RegisterDirtyMaterialCallback
/// </remarks>
public virtual void SetMaterialDirty()
{
if (!IsActive())
return;
m_MaterialDirty = true;
CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this);
if (m_OnDirtyMaterialCallback != null)
m_OnDirtyMaterialCallback();
}
6.1 SetVerticesDirty(),SetMaterialDirty()
可以发现“待重建队列(m_GraphicRebuildQueue)”的待重建元素通过CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this)方法添加。
CanvasUpdateRegistry.cs部分源码如下:
public static void RegisterCanvasElementForGraphicRebuild(ICanvasElement element)
{
instance.InternalRegisterCanvasElementForGraphicRebuild(element);
}
private bool InternalRegisterCanvasElementForGraphicRebuild(ICanvasElement element)
{
if (m_PerformingGraphicUpdate)
{
Debug.LogError(string.Format("Trying to add {0} for graphic rebuild while we are already inside a graphic rebuild loop. This is not supported.", element));
return false;
}
return m_GraphicRebuildQueue.AddUnique(element);
}
6.2 SetLayoutDirty()
“待重建队列(m_LayoutRebuildQueue)”的待重建元素通过LayoutRebuilder.MarkLayoutForRebuild(rectTransform)方法,进而通过CanvasUpdateRegistry.TryRegisterCanvasElementForLayoutRebuild(rebuilder)方法添加。
LayoutRebuilder.cs部分源码如下:
/// <summary>
/// Mark the given RectTransform as needing it's layout to be recalculated during the next layout pass.
/// </summary>
/// <param name="rect">Rect to rebuild.</param>
public static void MarkLayoutForRebuild(RectTransform rect)
{
if (rect == null || rect.gameObject == null)
return;
var comps = ListPool<Component>.Get();
bool validLayoutGroup = true;
RectTransform layoutRoot = rect;
var parent = layoutRoot.parent as RectTransform;
while (validLayoutGroup && !(parent == null || parent.gameObject == null))
{
validLayoutGroup = false;
parent.GetComponents(typeof(ILayoutGroup), comps);
for (int i = 0; i < comps.Count; ++i)
{
var cur = comps[i];
if (cur != null && cur is Behaviour && ((Behaviour)cur).isActiveAndEnabled)
{
validLayoutGroup = true;
layoutRoot = parent;
break;
}
}
parent = parent.parent as RectTransform;
}
// We know the layout root is valid if it's not the same as the rect,
// since we checked that above. But if they're the same we still need to check.
if (layoutRoot == rect && !ValidController(layoutRoot, comps))
{
ListPool<Component>.Release(comps);
return;
}
MarkLayoutRootForRebuild(layoutRoot);
ListPool<Component>.Release(comps);
}
private static void MarkLayoutRootForRebuild(RectTransform controller)
{
if (controller == null)
return;
var rebuilder = s_Rebuilders.Get();
rebuilder.Initialize(controller);
if (!CanvasUpdateRegistry.TryRegisterCanvasElementForLayoutRebuild(rebuilder))
s_Rebuilders.Release(rebuilder);
}
CanvasUpdateRegistry.cs部分源码如下:
/// <summary>
/// Try and add the given element to the layout rebuild list.
/// </summary>
/// <param name="element">The element that is needing rebuilt.</param>
/// <returns>
/// True if the element was successfully added to the rebuilt list.
/// False if either already inside a Graphic Update loop OR has already been added to the list.
/// </returns>
public static bool TryRegisterCanvasElementForLayoutRebuild(ICanvasElement element)
{
return instance.InternalRegisterCanvasElementForLayoutRebuild(element);
}
private bool InternalRegisterCanvasElementForLayoutRebuild(ICanvasElement element)
{
if (m_LayoutRebuildQueue.Contains(element))
return false;
/* TODO: this likely should be here but causes the error to show just resizing the game view (case 739376)
if (m_PerformingLayoutUpdate)
{
Debug.LogError(string.Format("Trying to add {0} for layout rebuild while we are already inside a layout rebuild loop. This is not supported.", element));
return false;
}*/
return m_LayoutRebuildQueue.AddUnique(element);
}
6.3 方法调用
为什么UI发生变化一定要加入“待重建队列”中呢?其实这个不难想象,一个UI界面同一帧可能有N个对象发生变化,任意一个变化都需要重建UI那么肯定会卡死,所以我们先把需要重建的UI加入到队列中,等待一个统一的时机来合并。
我们已经知道了元素是如何加入到“待重建队列”中的,那么我们只需要看下对应的方法是在哪里调用的就可以啦,调用的地方较多,请自行通过IDE查看SetAllDirty(),SetLayoutDirty(),SetVerticesDirty(),SetMaterialDirty()方法的引用信息。这里简单举几个例子:
Graphic.cs部分源码如下:
- RectTransform的Anchor,Width,Height,Anchor,Pivot改变时调用,注意改变Position,Rotation,Scale不会调用。
protected override void OnRectTransformDimensionsChange()
{
if (gameObject.activeInHierarchy)
{
// prevent double dirtying...
if (CanvasUpdateRegistry.IsRebuildingLayout())
SetVerticesDirty();
else
{
SetVerticesDirty();
SetLayoutDirty();
}
}
}
- 父物体改变时调用
protected override void OnTransformParentChanged()
{
base.OnTransformParentChanged();
m_Canvas = null;
if (!IsActive())
return;
CacheCanvas();
GraphicRegistry.RegisterGraphicForCanvas(canvas, this);
SetAllDirty();
}
- Material改变时调用
/// <summary>
/// The Material set by the user
/// </summary>
public virtual Material material
{
get
{
return (m_Material != null) ? m_Material : defaultMaterial;
}
set
{
if (m_Material == value)
return;
m_Material = value;
SetMaterialDirty();
}
}
UI的网格我们都已经合并到了同一个Mesh中,这时我们只需要保证贴图(使用Atals图集),材质,Shader相同就可以真正合并成一个DrawCall了。