[UGUI源码剖析]—Rebuild 网格重建(画布刷新)系统

2023-05-16

几个比较重要的类和接口:

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移除注册元素。

2.3 PerformUpdate

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方法中

  1. 从两个序列中删除不可用的元素 CleanInvalidItems();

  2. 重建布局(Layout Rebuild)开始

  3. 对m_LayoutRebuildQueue(被标记为Dirty状态的Layout对象)依据父对象的数量进行排序,父transform少的在前

  4. 分别以PreLayout,Layout,PostLayout的参数顺序调用每一个元素的Rebuild方法

  5. 调用所有元素的LayoutComplete方法

  6. 清除布局重建序列中的所有元素

  7. 重建布局结束

  8. 完成布局后,调用组件的裁剪方法ClippingRegistry.Cull()

  9. 重建图形(Graphic Rebuild)开始

  10. 对m_GraphicRebuildQueue(被标记了Dirty状态的Graphic对象)以PreRender,LatePreRender的参数顺序调用每一个元素(无序)的Rebulid方法

  11. 调用所有元素的GraphicUpdateComplete方法

  12. 清除图形重建序列中的所有元素

  13. 重建图形结束

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发生改变时,如:

  • SetActive

  • Transform属性变化

  • Graphic的Color属性变化(改Mesh顶点色)

  • Text文本内容变化

  • Depth发生变化

触发Rebuild的条件:

  • Layout修改RectTransform部分影响布局的属性

  • Graphic的Mesh或Material发生变化

  • Mask裁剪内容变化

图源: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性能优化总结 | 无境

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

[UGUI源码剖析]—Rebuild 网格重建(画布刷新)系统 的相关文章

随机推荐