几个比较重要的类和接口:
Canvas、CanvasUpdateRegistry、ClipperRegistry、LayoutRebuilder、LayoutGroup、Graphics、MaskableGraphic。
ICanvasElement、ILayoutElement。
刷新的大致过程: 由Canvas控制,通过 ICanvasElement 接口,使用脏标记方法SetDirty()来统一更新CanvasElement。
几个问题:脏标记法是什么?SetDirty()具体在哪些地方被调用?Rebuild的具体过程是什么?如何进行优化?这些问题将在下面解决。
目录
1 基础
1.1 脏标记方法
1.2 Canvas
1.3 子Canvas
1.4 Graphic
1.5 Layout
1.6 网格重建分为2部分:一个是Batch,一个是ReBuild
2 Canvas
2.1 Canvas.WillRenderCanvases事件
2.2 CanvasUpdateRegistry
2.3 PerformUpdate
3 Rebuild
3.1 Layout的Rebuild
3.2 Graphic的Rebuild
3.3 Rebatch和Rebuild的触发条件总结
4 优化
4.1 动静分离
4.2 其它
1 基础
1.1 脏标记方法
脏标识模式:脏标识模式 · Optimization Patterns · 游戏设计模式
将工作推迟到必要时进行,以免做没必要的工做,比如被销毁的物体的计算、父子关系的物体重复计算。
1.2 Canvas
Canvas是一个Native层实现的Unity组件,被Unity渲染系统用于在游戏世界空间中渲染分层几何体(layered geometry)。 Canvas负责把它们包含的Mesh合批,生成合适的渲染命令发送给Unity图形系统。以上行为都是在Native C++代码中完成,我们称之为Rebatch或者Batch Build,当一个Canvas中包含的几何体需要Rebacth时,这个Canvas就会被标记为Dirty状态。
几何图形由Canvas Renderer 组件提供给 Canvases 。
1.3 子Canvas
Canvas组件可以嵌套在另一个Canvas组件下,我们称为子Canvas,子Canvas可以把它的子物体与父Canvas分离,使得当子Canvas被标记为Dirty时,并不会强制让父Canvas也强制Rebuild,反之亦然。但在某些特殊情况下,使用子Canvas进行分离的方法可能会失效,例如当对父Canvas的更改导致子Canvas的大小发生变化时。
1.4 Graphic
Graphic是Image、RawImage、Text类的基类。大多数Unity内置的继承Graphic的类都是通过继承一个叫MaskableGraphic的子类来实现,这使得他们可以通过IMaskable接口来被隐藏。
1.5 Layout
Layout控制着RectTransform的大小和位置,通常用于创建复杂的布局,这些布局需要对其内容进行相对大小调整或相对位置调整。Layout仅依赖于RectTransforms,并且仅影响其关联RectTransforms的属性。这些Layout类不依赖于Graphic类,可以独立于UGUI的Graphic类之外使用。
1.6 网格重建分为2部分:一个是Batch,一个是ReBuild
Batch: 就是Canvas 负责将其子节点的 UI 元素网格合并,并生成相应的渲染命令再发送到 Unity 的图形管道的过程。
通俗来讲,Canvas 就是渲染 UI 的组件,所以当 UI 变化了,它就要执行一次 Batch,给 GPU 进行渲染。
Rebuild: Layout和Graphic的更新称为Rebuild,指重新计算布局和网格的过程,这个过程在CanvasUpdateRegistry中执行。 在CanvasUpdateRegistry中,最重要的方法是PerformUpdate。每当Canvas组件调用WillRenderCanvases事件时,就会调用此方法。此事件每帧调用一次。
rebuild是batch的子操作,一次batch需要各组件执行自己的rebuild操作。
2 Canvas
2.1 Canvas.WillRenderCanvases事件
当Canvas需要重绘时会调用Canvas.SendWillRenderCanvases()方法。
?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()事件。
2.2 CanvasUpdateRegistry
CanvasUpdateRegistry(画布更新注册处)是一个单例,它是UGUI与Canvas之间的中介,继承了ICanvasElement接口的组件都可以注册到它,它监听了Canvas即将渲染的事件,并调用已注册组件的Rebuild等方法。
CanvasUpdateRegistry的构造函数:
? ?//CanvasUpdateRegistry 被初始化时向Canvas中注册了更新函数(PerformUpdate),以用来响应重建。
protected CanvasUpdateRegistry()
? {
? ? ? ?Canvas.willRenderCanvases += PerformUpdate;
? }
willRenderCanvases是Canvas的静态事件,事件是一种特殊的委托,在渲染所有的Canvas之前,抛出willRenderCanvases事件,继而调用CanvasUpdateRegistry的PerformUpdate方法。
CanvasUpdateRegistry维护了两个索引集 (不会存放相同的元素):
? ?//IndexedSet是Unity中吸取了List和Dictionary各自优点的一种容器
private readonly IndexedSet<ICanvasElement> m_LayoutRebuildQueue = new IndexedSet<ICanvasElement>();
? ?private readonly IndexedSet<ICanvasElement> m_GraphicRebuildQueue = new IndexedSet<ICanvasElement>();
m_LayoutRebuildQueue: 保存着需要重建的布局元素(一般是通过LayoutGroup布局改变的UI)
m_GraphicRebuildQueue: 需要重建的Graphics元素(如Image,RawIamge,Text的贴图,材质,宽高发生变化)
m_LayoutRebuildQueue是通过RegisterCanvasElementForLayoutRebuild和TryRegisterCanvasElementForLayoutRebuild方法添加元素。
m_GraphicRebuildQueue是通过RegisterCanvasElementForGraphicRebuild和TryRegisterCanvasElementForGraphicRebuild方法添加元素。
二者通过UnRegisterCanvasElementForRebuild移除注册元素。
ICanvasElement接口:
public interface ICanvasElement
{
? ?void Rebuild(CanvasUpdate executing);//重建方法
? ?Transform transform{get;}
? ?void LayoutComplete();//布局重建完成
? ?void GraphicUpdateComplete();//图像重建完成
? ?bool IsDestroyed();//检查Element是否无效
}
CanvasUpdate枚举:
public enum CanvasUpdate
{
? ?Prelayout = 0,//预布局
? ?Layout = 1,//布局
? ?PostLayout = 2,//后期布局
? ?PreRender = 3,//预渲染
? ?LatePreRender = 4,//后期预渲染
? ?MaxUpdateValue = 5
}
除了最后一个枚举项,其他五个项分别代表了布局的三个阶段和渲染的两个阶段。
在PerformUpdate方法中
从两个序列中删除不可用的元素 CleanInvalidItems();
重建布局(Layout Rebuild) 开始
对m_LayoutRebuildQueue(被标记为Dirty状态的Layout对象)依据父对象的数量进行排序,父transform少的在前
分别以PreLayout,Layout,PostLayout的参数顺序调用每一个元素的Rebuild方法
调用所有元素的LayoutComplete方法
清除布局重建序列中的所有元素
重建布局结束
完成布局后,调用组件的裁剪方法ClippingRegistry.Cull()
重建图形(Graphic Rebuild) 开始
对m_GraphicRebuildQueue(被标记了Dirty状态的Graphic对象)以PreRender,LatePreRender的参数顺序调用每一个元素(无序)的Rebulid方法
调用所有元素的GraphicUpdateComplete方法
清除图形重建序列中的所有元素
重建图形结束
CanvasUpdateRegistry.cs中的PerformUpdate():
?
?private void PerformUpdate()
? {
? ? ? ?UISystemProfilerApi.BeginSample(UISystemProfilerApi.SampleType.Layout);
? ? ? ?//从两个序列中删除不可用的元素
? ? ? ?CleanInvalidItems();
? ? ? ?//重建布局(Layout Rebuild)开始
? ? ? ?m_PerformingLayoutUpdate = true;//这个bool值用来锁住Rebuild期间的remove、SetDirty等操作,下同
? ? ? ?
//依据父对象的数量进行排序,父transform少的在前,即从上到下进行Rebuild
? ? ? ?m_LayoutRebuildQueue.Sort(s_SortLayoutFunction);
? ? ? ?
? ? ? ?//分别以PreLayout,Layout,PostLayout的参数顺序调用每一个元素的Rebuild方法
? ? ? ?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))//元素存在且为Object
? ? ? ? ? ? ? ? ? ? ? ?rebuild.Rebuild((CanvasUpdate)i);//调用元素各自的Rebuild方法
? ? ? ? ? ? ? }
? ? ? ? ? ? ? ?catch (Exception e)
? ? ? ? ? ? ? {
? ? ? ? ? ? ? ? ? ?Debug.LogException(e, rebuild.transform);
? ? ? ? ? ? ? }
? ? ? ? ? }
? ? ? }
?
? ? ? ?//调用所有元素的LayoutComplete方法
? ? ? ?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();
?
? ? ? ?//重建图形(Graphic Rebuild)开始
? ? ? ?m_PerformingGraphicUpdate = true;//上锁
? ? ? ?
? ? ? ?//以PreRender,LatePreRender的参数顺序调用每一个元素的Rebulid方法,元素顺序无序
? ? ? ?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))//元素存在且为Object
? ? ? ? ? ? ? ? ? ? ? ?element.Rebuild((CanvasUpdate)i);//调用元素各自的Rebuild方法
? ? ? ? ? ? ? }
? ? ? ? ? ? ? ?catch (Exception e)
? ? ? ? ? ? ? {
? ? ? ? ? ? ? ? ? ?Debug.LogException(e, instance.m_GraphicRebuildQueue[k].transform);
? ? ? ? ? ? ? }
? ? ? ? ? }
? ? ? }
?
? ? ? ?//调用所有元素的LayoutComplete方法
? ? ? ?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);
? }
图源:两水先木示
【Unity】UGUI优化_两水先木示的博客-CSDN博客
3 Rebuild
Rebuild 分为 Layout Rebuild 和 Graphic Rebuild
3.1 Layout的Rebuild
Layout元素:HorizontalLayoutGroup、VerticalLayoutGroup、GridLayoutGroup、ScrollRect等。
重新计算一个 Layout 组件子节点的位置或大小。
LayoutGroup.cs中的SetDirty() 函数:
? ?protected void SetDirty()
? {
? ? ? ?if (!IsActive())
? ? ? ? ? ?return;
?
? ? ? ?if (!CanvasUpdateRegistry.IsRebuildingLayout())
? ? ? ? ? ?LayoutRebuilder.MarkLayoutForRebuild(rectTransform);
? ? ? ?else
? ? ? ? ? ?StartCoroutine(DelayedSetDirty(rectTransform));
? }
?
? ?IEnumerator DelayedSetDirty(RectTransform rectTransform)
? {
? ? ? ?yield return null;
? ? ? ?LayoutRebuilder.MarkLayoutForRebuild(rectTransform);
? }
接下来:LayoutRebuilder.MarkLayoutForRebuild→LayoutRebuilder.MarkLayoutRootForRebuild→CanvasUpdateRegistry.TryRegisterCanvasElementForLayoutRebuild→CanvasUpdateRegistry.InternalTryRegisterCanvasElementForLayoutRebuild→m_LayoutRebuildQueue.AddUnique
LayoutGroup中SetDirty()调用的具体情况:
SetProperty
OnEnable
OnDidApplyAnimationProperties (动画修改属性时)
OnRectTransformDimensionsChange(RectTransform的Anchor,Width,Height,Anchor,Pivot改变时调用,注意改变Position,Rotation,Scale不会调用。)
OnTransformChildrenChanged(子物体改变时)
当 LayoutGroup 的直接子节点,并且是 Graphic 类型的(Image、RawImage、Text),被 SetLayoutDirty 的时候,该 LayoutGroup 也会被加入到 Rebuild 的队列中。
编辑器模式下OnValidate时。
Layout重建时过程:
先自下而上地执行Layout元素的CalculateLayoutInputHorizontal/ CalculateLayoutInputHorizontal方法进行计算布局大小、行数、列数等内容。
布局计算需要自下而上执行,子在父之前完成,因为父计算的大小依赖于子的大小。
然后自上而下地执行Layout元素的SetLayoutHorizontal/ SetLayoutVertical方法进行调整子物体的位置或调整自身大小等事情。
布局控制需要自上而下执行,父在子之前完成, 因为子依赖于父的大小。
LayoutRebuilder.cs中的Rebuild():
? ?public void Rebuild(CanvasUpdate executing)
? {
? ? ? ?switch (executing)
? ? ? {
? ? ? ? ? ?case CanvasUpdate.Layout:
? ? ? ? ? ? ? ?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;
? ? ? }
? }
LayoutRebuilder.cs中的PerformLayoutCalculation()和PerformLayoutControl():
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);
}
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);
}
3.2 Graphic的Rebuild
Graphic元素:RawImage、Text、Image。
Graphic.cs中的SetDirty():
SetAllDirty()
SetVerticesDirty ()
SetMaterialDirty()
SetLayoutDirty()
Graphic.cs中的Rebuild()函数:
public virtual void Rebuild(CanvasUpdate update)
{
if (canvasRenderer == null || canvasRenderer.cull)
return;
switch (update)
{
case CanvasUpdate.PreRender:
if (m_VertsDirty)
{
UpdateGeometry();
m_VertsDirty = false;
}
if (m_MaterialDirty)
{
UpdateMaterial();
m_MaterialDirty = false;
}
break;
}
}
当Graphic进行Rebuild时,UGUI将控制权转交给ICanvasElement接口的Rebuild方法。Graphic类实现了这个接口。
如果顶点数据已标记为Dirty状态(如组件的矩形变换更改大小时),则重建网格。 如果材质数据已标记为Dirty状态(如组件的材质或纹理发生改变时),则将更新附着的画布渲染器的材质。 Graphic的Rebuild不需要按特定顺序遍历Graphic组件列表,也不需要任何排序操作。
接下来:CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild()→CanvasUpdateRegistry.InternalRegisterCanvasElementForGraphicRebuild→m_GraphicRebuildQueue.AddUnique
无论是 Layout,还是 Graphic 的改变,都会把本次的改变分别存储在对应的队列中,即m_LayoutRebuildQueue.AddUnique 和 m_GraphicRebuildQueue.AddUnique,殊途同归。
Graphic中SetDirty()调用的具体情况: Graphic的SetAllDirty(简称A)、SetVerticesDirty(简称V)、SetMaterialDirty(简称M)、SetLayoutDirty(简称L)
Graphic (Image、RawImage、Text的基类):OnEnable、Reset、OnDidApplyAnimationProperties、编辑器下OnValidate时调用A,OnRectTransformDimensionsChange调用V(如果不在重建layout则还会调用L),OnTransformParentChanged时调用A,设置Material 时调用M,设置Color 时调用V。
Image: Sprite改变时调用A,type、preserveAspect、fillCenter、fillMethod、fillAmount、fillClockwise、fillOrigin、useSpriteMesh改变时调用V,SetNativeSize时调用A,OnCanvasHierarchyChanged时调用V和L,OnDidApplyAnimationProperties时调用M和V。
RawImage: texture改变时调用V和M,uvRect改变时调用V,OnDidApplyAnimationProperties时调用V和M。
Text: FontTextureChanged、Font改变时调用A,text第一次写入时调用V(文本改变时还调用L)supportRichText、resizeTextForBestFit、resizeTextMinSize、resizeTextMaxSize、alignment、fontSize、horizontalOverflow、verticalOverflow、lineSpacing、fontStyle改变时调用V和L ,alignByGeometry改变时调用V。
BaseMeshEffect (Shadow的基类): OnEnable、OnDisable、OnDidApplyAnimationProperties、编辑器下OnValidate时调用V, (都是间接调用身上的Graphic的V,本身并不继承Graphic)
Shadow (Outline的基类): useGraphicAlpha、effectDistance、effectColor改变时调用V (都是间接调用Graphic的V,本身并不继承Graphic)
总结 :OnEnable、OnDisable、OnTransformParentChanged、OnDidApplyAnimationProperties、OnRectTransformDimensionsChange、OnCanvasHierarchyChanged、图集加载完成时,Text、Image、RawImage、Shadow属性改变时。
3.3 Rebatch和Rebuild的触发条件总结
触发Rebatch的条件:
当Canvas下有Mesh发生改变时,如:
触发Rebuild的条件:
图源:Unity高锦锦
Unity UGUI优化与原理【unity官方】_gaojinjingg的专栏-CSDN博客_unityugui优化
4 优化
转自UGUI性能优化总结 | 无境
4.1 动静分离
基于Rebatch是以Canvas为单位,当Canvas下UI元素发生变化时,会引起整个Canvas的重构,其中会包括网格合并,网格重叠检测,层级排序等操作。对于同一个界面,我们可以再细分Canvas,把相对静态的、不会变动的UI放在一个Canvas里,而相对变化比较频繁的UI就放在另一个Canvas里,使得频繁变化的Canvas里只对自己的Canvas下的元素进行Rebatch,而节省掉另一个Canvas中不需要变化的元素的Rebatch计算。
只有同一个Canvas下的UI元素才有可能合批,在中间新增Canvas会打断合批,动静分离优化本质是DrawCall换重构耗时的权衡。
Rebatch是在Canvas.BuildBatch函数中进行,而在Unity 5.2版本后,已经对Canvas.BuildBatch做了优化,优化后使用子线程进行计算,已经很大程度缓解了主线程的压力,目前来说动静分离并没有那么需要关注了。
4.2 其它
慎用自带组件Outlien和Shaow,都是通过重复绘制多个Mesh实现的,其中Showdow绘制为原文本Mesh的2倍,而Outline为5倍,对渲染面数、顶点数,BuildBatch和SendWillRenderCanvases的耗时,Overdraw都有影响。若对于某种字体每次出现都需要这两种效果,可以让美术同学直接把阴影和描边做到字体里。
Text组件的Best Fit属性若非必要不要使用,它会使字号随着文本框大小而自动适配,一方面是适配本身在调整文本框大小时有CPU耗时开销,另一方面每个字号下新生成的字都会在Font Texture上占用一个字的大小,容易导致Font Texture过大(这个类似图集,当Font Texture当前大小放不下时才会占用更多内存)。这个特定问题已在 Unity 5.4 中得到纠正,Best Fit 不会不必要地扩展字体的纹理图集,但仍然比静态大小的文本慢得多。
尽量少使用Layout组件,会增加Canvas.SendWillRenderCanvases函数耗时,利用好RectTransform同样可实现简单布局。
对于血条、飘字、小地图标记等频繁更新位置的UI,可尽量减低更新频率,如隔帧更新,并设定更新阈值,当位移大于一定数值时再赋值(一方面是位移小时可能表现上看不出位移,另一方面是就算是没有实际位移,重复赋相同的值,也会SetDirty触发重建),可减少BuildBatch耗时。
参考:
[Unity官方]Optimizing Unity UI - Unity Learn
[UWA 学堂]影响性能更大的元凶Rebuild
Unity UGUI优化与原理【unity官方】_gaojinjingg的专栏-CSDN博客_unityugui优化
(五)UGUI源码分析之Rebuild(布局重建、图形重绘)_两水先木示的博客-CSDN博客_ugui重建
UGUI性能优化总结 | 无境