Unity UGUI(四)性能优化

文章目录

  • 一、基础概念
  • 二、UI Batching
    • 常见的打断合批的原因:
    • 调试工具
    • 合批优化策略
  • 三、UI Rebuild
    • 源码分析
      • 1. Rebuild的执行过程
      • 2. UI是怎么加入重建队列的
      • 3. Rebuild具体做了些什么
        • 3.1 图形重建过程:
        • 3.2 布局重建过程:
    • UI重建优化策略
  • 四、OverDraw
    • OverDraw优化策略
  • 五、其他优化
  • 六、总结
  • 七、参考文章


首先贴出官方文档。

一、基础概念

DrawCall
即绘制调用命令,CPU在准备好渲染数据并设置渲染状态后,会通过Drawcall命令通知GPU进行渲染。

Canvas
Canvas是一个 Native 层实现的Unity组件,被 Unity 渲染系统用于在游戏世界空间中渲染分层几何体(layered geometry)。
Canvas 负责把它们包含的Mesh合批,生成合适的渲染命令发送给 Unity 图形系统。以上行为都是在Native C++代码中完成,我们称之为 Rebatch 或者Batch Build,当一个 Canvas 中包含的几何体需要Rebacth时,这个Canvas就会被标记为Dirty状态。
Canvas 组件可以嵌套在另一个 Canvas 组件下,我们称为子Canvas,子 Canvas 可以把它的子物体与父Canvas分离,使得当子Canvas被标记为Dirty时,并不会强制让父 Canvas 也强制 Rebuild,反之亦然。但在某些特殊情况下,使用子Canvas进行分离的方法可能会失效,例如当对父Canvas的更改导致子Canvas的大小发生变化时。
可以在Profiler中通过查看标志性函数 Canvas.BuildBatch 的耗时,来了解 Rebatch 的性能消耗。

Canvas Renderer
几何体(layered geometry)数据是通过 Canvas Renderer 组件被提交到 Canvas 中。

VertexHelper
顶点辅助类,用于保存UI的顶点、颜色、法线、uv、三角形索引等信息。

Graphic
Graphic 是UGUI的C#库提供的一个基类。它是为Canvas提供可绘制几何图形的所有UGUI的C#类的基类。大多数Unity内置的继承 Graphic 的类都是通过继承一个叫 MaskableGraphic 的子类来实现,这使得他们可以通过IMaskable 接口来被隐藏。Drawable 类的子类主要是Image和Text,且UGUI已提供了同名组件。

Layout
Layout控制着RectTransform的大小和位置,通常用于创建复杂的布局,这些布局需要对其内容进行相对大小调整或相对位置调整。Layout仅依赖于RectTransforms,并且仅影响其关联RectTransforms的属性。这些Layout类不依赖于Graphic类,可以独立于UGUI的Graphic类之外使用。

CanvasUpdateRegistry
这个单例类维护了 m_LayoutRebuildQueue 和 m_GraphicRebuildQueue 两个重建队列,在构造函数中监听了Canvas的 willRenderCanvases 事件,这个事件会在渲染前进行每帧调用。在回调函数 PerformUpdate() 函数中,遍历两个重建队列进行UI重建,并执行ClipperRegistry的Cull方法。

Rebuild
Rebuild是指 Layout 和 Graphic 组件的网格被重新计算,这个过程在 CanvasUpdateRegistry 中执行。
可以在Profiler中通过查看标志性函数 Canvas.SendWillRenderCanvas 的耗时,来了解Mesh重建的性能消耗。

ICanvasElement
ICanvasElement接口,重建的时候会调用它的Rebuild方法,继承它的类都会对这个函数进行重写,Unity中几乎所有的UI组件都继承自这个接口。
Unity UGUI(四)性能优化_第1张图片

二、UI Batching

Batching是指Canvas通过合并UI元素的网格,生成合适的渲染命令发送给Unity图形渲染流水线。Batch的结果被缓存复用,直到这个Canvas被标为dirty,当Canvas中某一个构成的网格改变的时候就会被标记为dirty。
从CPU把数据发送到显卡相对较慢,合批是为了一次性发送尽可能多的数据。
batch build、batching、rebatch等都是同一个概念。
计算批次需要按深度对网格进行排序,并检查它们是否有重叠、以及材质和纹理贴图是否相同等。

首先进行深度排序:按照Hierarchy窗口从上往下的顺序

  1. 不渲染的UI元素Depth为 -1(setactive为false,canvasgroup.alpha为0,disable),UI下没有和其他UI相交时,该UI的Depth为0。(相交指网格有重叠)
  2. 当前UI下面有一个UI与其相交,若两者贴图和材质相同时,它们Depth相同,否则上面的UI的Depth是下面UI的Depth+1。
  3. 当前UI下面与多个UI相交,则取多个UI中Depth最高的元素(Max)与当前UI比较,若两者贴图和材质相同,则它们Depth相同,否则Depth = Max + 1。

排序完成后对Depth,材质,贴图都相同的UI进行合批。(C++实现,未开放源码)

常见的打断合批的原因:

  1. 同一深度 UI 元素使用了不同的材质或贴图,比如不同的图集或者字体。
  2. 使用了Unity的默认图片或默认字体,本质上和上面一条相同。
  3. 原本能够被合批的UI在Hierarchy层级相邻,即使Z轴不同,也能被合批。但是原本可以合批的UI的Hierarchy层级之间或下方插入了其他UI,此时如果有UI的Z坐标不为0可能会打断合批。
  4. UI使用了Mask,其本身和子节点不参与外部合批。同深度、同材质、同贴图的Mask之间可以合批,不同Mask下的子物体也可以合批。
  5. UI使用了RectMask2D,其子节点不参与外部合批。UI本身参与外部合批,不同RectMask2D下的子物体不能合批。

调试工具

1)通过 Frame Debug 查看每个DrawCall的绘制:
Unity UGUI(四)性能优化_第2张图片
注意:UGUI的 drawcall 根据Canvas渲染模式的不同,所在的位置也有所不同:
Screen Space - Overlay 模式时,将会出现在 Canvas.RenderOverlays 分组。
Screen Space - Camera 模式时,将会出现在所选相机的 Camera.Render 分组,作为一个 Render.TransparentGeometry 子组。
World Space 渲染模式时,将会作为一个 Render.TransparentGeometry 子组,出现在每个可以观察到该 Canvas 的相机下。

2)通过 Profiler 的 UI Details 栏目查看所有Canvas的合批情况、打断合批的原因以及每个批次绘制了哪些内容:
Unity UGUI(四)性能优化_第3张图片

合批优化策略

  1. UI设计的时候应尽量保持UI使用相同的材质并处于同一深度(使用图集、注意UI的遮挡关系);
  2. 不要使用默认图片和默认字体;
  3. 特殊情况可以使用艺术字代替文本参数合批(bmfont);
  4. UI的Z轴统一设置为0;
  5. 如果需要使用遮罩,仅需要使用一个的时候用RectMask2D(Mask多两个DrawCall),需要使用多个的时候使用Mask(不同Mask的子节点参与合批);

三、UI Rebuild

Rebuild分为Layout Rebuild 和Graphic Rebuild。
Layout Rebuild
要重新计算一个或者多个Layout组件所包含的UI组件的适当位置(以及可能的大小),有必要对Layout应用层次进行排序。在GameObject的hierarchy中靠近root的Layout可能会影响改变嵌套在它里面的其他Layout的位置和大小,所以必须首先计算。 为此,UGUI根据层次结构中的深度对dirty的Layout组件列表进行排序。层次结构中较高的Layout(即拥有较少的父transform)将被移到列表的前面。然后,排序好的Layout组件的列表将被rebuild,在这个步骤Layout组件控制的UI元素的位置和大小将被实际改变。关于独立的UI元素如何受Layout组件影响的详细细节,请参阅Unity Manual的UI Auto Layout章节。 [ 这就是为什么unity的布局组件一旦形成嵌套,套内组件将失效的原因 , unity也暂时未开放布局执行层级顺序的接口 , 仅在UGUI代码中可见但未公开 ]
Graphic Rebuild
当Graphic组件被rebuild的时候,UGUI将控制传递给ICanvasElement接口的Rebuild方法。Graphic执行了这一步,并在rebuild过程中的PreRender阶段运行了两个不同的rebuild步骤:1.如果顶点数据已经被标为Dirty(例如组件的RectTransform已经改变大小),则重建网格。2.如果材质数据已经被标为Dirty(例如组件的material或者texture已经被改变),则关联的Canvas Renderer的材质将被更新。Graphic的Rebuild不会按照Graphic组件的特殊顺序进行,也不会进行任何的排序操作。

Rebuild 通常会触发 Batching。

源码分析

1. Rebuild的执行过程

Unity UGUI(四)性能优化_第4张图片
Canvas每帧执行。
Unity UGUI(四)性能优化_第5张图片
CanvasUpdateRegistry在构造函数中监听并注册回调函数 PerformUpdate。

下面是 PerformUpdate() 源码:

		private void PerformUpdate()
        {
            UISystemProfilerApi.BeginSample(UISystemProfilerApi.SampleType.Layout);
            //清理Queue中值为null或者被销毁的元素
            CleanInvalidItems();

            m_PerformingLayoutUpdate = true;
             //根据父节点多少排序(层级)
            m_LayoutRebuildQueue.Sort(s_SortLayoutFunction);

            for (int i = 0; i <= (int)CanvasUpdate.PostLayout; i++)
            {
                UnityEngine.Profiling.Profiler.BeginSample(m_CanvasUpdateProfilerStrings[i]);

                for (int j = 0; j < m_LayoutRebuildQueue.Count; j++)
                {
                    var rebuild = m_LayoutRebuildQueue[j];
                    try
                    {
                        //布局重建
                        if (ObjectValidForUpdate(rebuild))
                            rebuild.Rebuild((CanvasUpdate)i);
                    }
                    catch (Exception e)
                    {
                        Debug.LogException(e, rebuild.transform);
                    }
                }
                UnityEngine.Profiling.Profiler.EndSample();
            }

            //通知布局重建完成
            for (int i = 0; i < m_LayoutRebuildQueue.Count; ++i)
                m_LayoutRebuildQueue[i].LayoutComplete();

            m_LayoutRebuildQueue.Clear();
            m_PerformingLayoutUpdate = false;
            
            UISystemProfilerApi.EndSample(UISystemProfilerApi.SampleType.Layout);
            UISystemProfilerApi.BeginSample(UISystemProfilerApi.SampleType.Render);

            // now layout is complete do culling...
            UnityEngine.Profiling.Profiler.BeginSample(m_CullingUpdateProfilerString);
            
            //执行裁剪(cull)操作
            ClipperRegistry.instance.Cull();
            UnityEngine.Profiling.Profiler.EndSample();

            m_PerformingGraphicUpdate = true;

            for (var i = (int)CanvasUpdate.PreRender; i < (int)CanvasUpdate.MaxUpdateValue; i++)
            {
                UnityEngine.Profiling.Profiler.BeginSample(m_CanvasUpdateProfilerStrings[i]);
                for (var k = 0; k < m_GraphicRebuildQueue.Count; k++)
                {
                    try
                    {
                        var element = m_GraphicRebuildQueue[k];
                         //图形重建
                        if (ObjectValidForUpdate(element))
                            element.Rebuild((CanvasUpdate)i);
                    }
                    catch (Exception e)
                    {
                        Debug.LogException(e, m_GraphicRebuildQueue[k].transform);
                    }
                }
                UnityEngine.Profiling.Profiler.EndSample();
            }
            
            //通知图形重建完成
            for (int i = 0; i < m_GraphicRebuildQueue.Count; ++i)
                m_GraphicRebuildQueue[i].GraphicUpdateComplete();

            m_GraphicRebuildQueue.Clear();
            m_PerformingGraphicUpdate = false;
            UISystemProfilerApi.EndSample(UISystemProfilerApi.SampleType.Render);
        }

2. UI是怎么加入重建队列的

查看源码发现,主要通过以下两个函数将待重建的ICanvasElement加入重建队列中:
Unity UGUI(四)性能优化_第6张图片
Unity UGUI(四)性能优化_第7张图片
一般通过脏标记来实现,以Graphic为例:
通过 SetLayoutDirty() 触发 LayoutRebuilder.MarkLayoutForRebuild(rectTransform) 将UI加入m_LayoutRebuildQueue 重建队列中。
通过 SetVerticesDirty()、SetMaterialDirty()、以及 OnCullingChanged() 的调用将UI加入m_GraphicRebuildQueue 重建队列中。

通过查看源码中哪些地方调用了这几个函数,就能知道什么情况下会触发UI的Rebuild了。

常见触发Rebuild的操作:

  1. RectTransform 的 Width,Height,Anchor,Pivot改变。
  2. Text 的内容及颜色变化、设置是否支持富文本、更改对齐方式、设置字体大小等。
  3. Image 组件颜色变化、更换Sprite。
  4. Slider 组件每次滑动时。
  5. ScrollBar 组价每次滑动时。
  6. SetActive、Enable为true时。
  7. Mask 勾选/取消勾选 Show Mask Graphic。
  8. Material改变。等等…

注意:改变Position,Rotation,Scale不会引起UI重建。

反射查看Rebuild队列:
可以在运行时查看哪些元素引起UI重建。

using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
using UnityEngine.UI;

public class LogRebuildInfo : 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 element = m_LayoutRebuildQueue[j];
            if (ObjectValidForUpdate(element))
            {
                Debug.LogErrorFormat("{0} 引起 {1} 网格布局重建", element.transform.name, element.transform.GetComponentInParent<Canvas>().name);
            }
        }

        for (int j = 0; j < m_GraphicRebuildQueue.Count; j++)
        {
            var element = m_GraphicRebuildQueue[j];
            if (ObjectValidForUpdate(element))
            {
                Debug.LogErrorFormat("{0} 引起 {1} 网格图形重建", element.transform.name, element.transform.GetComponentInParent<Canvas>().name);
            }
        }
    }
    
    private bool ObjectValidForUpdate(ICanvasElement element)
    {
        var valid = element != null;

        var isUnityObject = element is Object;
        if (isUnityObject)
            valid = (element as Object) != null; //Here we make use of the overloaded UnityEngine.Object == null, that checks if the native object is alive.

        return valid;
    }
}

3. Rebuild具体做了些什么

以Graphic为例。

3.1 图形重建过程:

        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;
            }
        }
  • UpdateGeometry()
    Graphic中有个静态对象s_VertexHelper保存每次生成的Mesh信息(包括顶点,三角形索引,UV,顶点色等数据),使用完后会立即清理掉等待下个Graphic对象使用。
    Unity UGUI(四)性能优化_第8张图片
    我们可以看到,s_VertexHelper中的数据通过OnPopulateMesh函数,进行填充,它是一个虚函数会在各自的类中实现,我们可以在自己的UI类中,重写OnPopulateMesh方法,实现自定义的UI。
    s_VertexHelper数据填充之后,调用FillMesh() 方法生成真正的Mesh,然后调用 canvasRenderer.SetMesh() 方法来提交。SetMesh() 方法最终在C++中实现,这也是UGUI的效率比NGUI高一些的原因,因为NGUI的Mesh合并是在C#中完成的,而UGUI的Mesh合并是在C++中底层完成的。

  • UpdateMaterial()
    UpdateMaterial() 方法会通过canvasRenderer来更新Material与Texture。
    Unity UGUI(四)性能优化_第9张图片

3.2 布局重建过程:

LayoutRebuilder 的 Rebuild() 方法:
Unity UGUI(四)性能优化_第10张图片
PerformLayoutCalculation() 方法会递归计算UI元素的宽高(先计算子元素,然后计算自身元素)。
ILayoutElement.CalculateLayoutInputXXXXXX() 在具体的实现类中计算该UI的大小。
PerformLayoutControl() 方法会递归设置UI元素的宽高(先设置自身元素,然后设置子元素)。
ILayoutController.SetLayoutXXXXX() 在具体的实现类中设置该UI的大小。

UI重建优化策略

  1. 动静分离:细分Canvas,把相对静态的、不会变动的UI放在一个Canvas里,而相对变化比较频繁的UI就放在另一个Canvas里。注意:新增Canvas会打断合批,增加DrawCall。
  2. 隐藏界面时,可用CanvasGroup.Alpha=0,或者从Camera渲染层级里移除等方法隐藏,代替SetActive。
  3. 对于血条、角色头顶名称、小地图标记等频繁更新位置的UI,可尽量减低更新频率,如隔帧更新,并设定更新阈值,当位移大于一定数值时再赋值(重复赋相同的值,也会SetDirty触发重建)。
  4. 注意合理设计UI的层级,由于布局重建需要对UI进行排序,层级太深影响排序消耗。

四、OverDraw

Overdraw是指一帧当中,同一个像素被重复绘制的次数。Fill Rate(填充率)是指显卡每帧每秒能够渲染的像素数。在每帧绘制中,如果一个像素被反复绘制的次数越多,那么它占用的资源也必然更多。Overdraw与Fill Rate成正比,目前在移动设备上,FillRate的压力主要来自半透明物体。因为多数情况下,半透明物体需要开启 Alpha Blend 且关闭 ZTest和 ZWrite,同时如果我们绘制像 alpha=0 这种实际上不会产生效果的颜色上去,也同样有 Blend 操作,这是一种极大的浪费。
不幸的是,Canvas绘制的所有几何体都在透明队列中绘制。也就是说,Unity UI生成的几何体将始终使用 Alpha 混合从前向后绘制。从多边形栅格化后的每个像素都将被采样,即使它完全由其他不透明多边形覆盖。在移动设备上,这种高水平的透支可以快速超过GPU的填充率容量。
在场景【scene】下拉列表中选择overdraw就能看见,越亮的地方就是overdraw最多的部分。

OverDraw优化策略

  1. 减少UI重叠层级,隐藏处于底下被完全覆盖的UI面板。
  2. 对于需要暂时隐藏的UI,不要直接把Color属性的Alpha值改为0,UGUI中这样设置后仍然会渲染,应该用CanvasGroup组件把Alpha值置零。
  3. 需要响应Raycast事件时,不要使用空Image,可以自定义组件继承自MaskableGraphic,重写OnPopulateMesh把网格清空,这样可以响应Raycast而又不需要绘制Mesh。
  4. 打开全屏界面,关闭场景摄像机。对于一些非全屏但覆盖率较高的界面,在对场景动态表现要求不高的情况下,可以记录下打开UI时的画面,作为UI背景,然后关掉场景摄像机。
  5. 裁掉无用区域,镂空,对于 Sliced 类型的 Image 可以看情况取消 Fill Center。
  6. 保持UI上的粒子特效简单,尽量不要发生重叠。

五、其他优化

  1. 所有可点击组件例如 Image、Text 在创建时默认开启 RaycastTarget。当进行点击操作时,会对所有开启RaycastTarget的组件进行遍历检测和排序。实际上大部分的组件是不需要响应点击事件的,对于这些组件我们应该取消RaycastTarget属性,最好的方式是监听组件创建,在创建时直接赋值为 false,对于需要响应事件的组件再手动开启。
  2. Text 尽量不要使用 outline 或者 shadow 组件,会使顶点数量成倍增加。字体效果考虑 Shader实现,或者直接让美术同学把阴影和描边做到字体里。

六、总结

常见UI性能问题:

  1. DrawCall过高,合批和提交批次(CPU --> GPU )花费大量CPU时间(Rebatch)。
  2. UI重建花费大量CPU时间(Rebuild)。
  3. 填充率过高,导致GPU渲染压力过大(overdraw)。
  4. 生成顶点花费大量CPU时间(通常来自文本)。

上面针对这些问题提出了一些通用的优化策略。但正如官方文档所说:
The core tension when optimizing any Unity UI is the balancing of draw calls with batching costs. While some common-sense techniques can be used to reduce one or the other, complex UIs must make trade-offs.
UI优化的核心是DrawCalls和Batching开销的平衡。可以使用一些常识性技术来减少其中之一,但复杂的UI必须在两者间进行权衡。

举例:
在这里插入图片描述
修改 Graphic 的Color属性,其原理是修改顶点色,因此是会引起网格的Rebuild的(即Canvas.BuildBatch操作,同时也会有Canvas.SendWillRenderCanvases的开销)。而通过修改顶点色来实现UI元素变色的好处在于,修改顶点色可以保证其材质不变,因此不会产生额外的DrawCall。
在UI的默认Shader中存在一个Tint Color的变量,正常情况下,该值为常数(1,1,1),且并不会被修改。如果是用脚本访问Material,并修改其Tint Color属性时,对UI元素产生的网格信息并没有影响,因此就不会引起网格的Rebuild。但这样做因为修改了材质,所以会增加一个Draw。
这时候就得权衡一下是要更少的DrawCall,还是减少UI的重建更合适。

七、参考文章

https://edu.uwa4d.com/lesson-detail/126/482/0?isPreview=false uwa drawcall rebatch rebuild particle 等
https://www.jianshu.com/p/5a39cfa74232 UI Rebuild过程详解
https://blog.csdn.net/gaojinjingg/article/details/103565840?spm=1001.2101.3001.6650.3 Unity UGUI优化与原理
https://www.drflower.top/posts/aad79bf1/ UGUI性能优化总结
https://zhuanlan.zhihu.com/p/350778355 OverDraw详解

你可能感兴趣的:(Unity,Unity实用技巧,源码学习,unity,游戏引擎,性能优化)