
官方文档:https://learn.unity.com/tutorial/optimizing-unity-ui
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 的性能消耗。
几何体(layered geometry)数据是通过 Canvas Renderer 组件被提交到 Canvas 中。
顶点辅助类,用于保存 UI 的顶点、颜色、法线、uv、三角形索引等信息。
Graphic 是 UGUI 的 C#库提供的一个基类。它是为 Canvas 提供可绘制几何图形的所有 UGUI 的 C#类的基类。大多数 Unity 内置的继承 Graphic 的类都是通过继承一个叫 MaskableGraphic 的子类来实现,这使得他们可以通过 IMaskable 接口来被隐藏。Drawable 类的子类主要是 Image 和 Text,且 UGUI 已提供了同名组件。
Layout 控制着 RectTransform 的大小和位置,通常用于创建复杂的布局,这些布局需要对其内容进行相对大小调整或相对位置调整。Layout 仅依赖于 RectTransforms,并且仅影响其关联 RectTransforms 的属性。这些 Layout 类不依赖于 Graphic 类,可以独立于 UGUI 的 Graphic 类之外使用。
这个单例类维护了 m_LayoutRebuildQueue 和 m_GraphicRebuildQueue 两个重建队列,在构造函数中监听了 Canvas 的 willRenderCanvases 事件,这个事件会在渲染前进行每帧调用。在回调函数 PerformUpdate() 函数中,遍历两个重建队列进行 UI 重建,并执行 ClipperRegistry 的 Cull 方法。
Rebuild 是指 Layout 和 Graphic 组件的网格被重新计算,这个过程在 CanvasUpdateRegistry 中执行。 可以在 Profiler 中通过查看标志性函数 Canvas.SendWillRenderCanvas 的耗时,来了解 Mesh 重建的性能消耗。
重建队列中维护的元素都继承自 ICanvasElement 接口。UI 重建的时候会调用它的 Rebuild 方法,继承它的类都会对这个函数进行重写,Unity 中几乎所有的 UI 组件都继承自这个接口。
Batching 是指 Canvas 通过合并 UI 元素的网格,生成合适的渲染命令发送给 Unity 图形渲染流水线。Batch 的结果被缓存复用,直到这个 Canvas 被标为 dirty,当 Canvas 中某一个构成的网格改变的时候就会被标记为 dirty。
从 CPU 把数据发送到显卡相对较慢,合批是为了一次性发送尽可能多的数据。
batch build、batching、rebatch 等都是同一个概念。
计算批次需要按深度对网格进行排序,并检查它们是否有重叠、以及材质和纹理贴图是否相同等。
首先进行深度排序:按照 Hierarchy 窗口从上往下的顺序
注意:UGUI 的 drawcall 根据 Canvas 渲染模式的不同,所在的位置也有所不同: Screen Space - Overlay 模式时,将会出现在 Canvas.RenderOverlays 分组。 Screen Space - Camera 模式时,将会出现在所选相机的 Camera.Render 分组,作为一个 Render.TransparentGeometry 子组。 World Space 渲染模式时,将会作为一个 Render.TransparentGeometry 子组,出现在每个可以观察到该 Canvas 的相机下。
Rebuild 分为 Layout Rebuild 和 Graphic Rebuild。
Canvas 每帧执行
CanvasUpdateRegistry 在构造函数中监听并注册回调函数 PerformUpdate。 下面是源码:
private void PerformUpdate()
{
UISystemProfilerApi.BeginSample(UISystemProfilerApi.SampleType.Layout);
//清理队列中值为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);
}查看源码发现,主要通过以下两个函数将待重建的 ICanvasElement 加入重建队列中:
一般通过脏标记来实现,以 Graphic 为例: 通过 SetLayoutDirty() 触发 LayoutRebuilder.MarkLayoutForRebuild(rectTransform) 将 UI 加入 m_LayoutRebuildQueue 重建队列中。 通过 SetVerticesDirty()、SetMaterialDirty()、以及 OnCullingChanged() 的调用将 UI 加入 m_GraphicRebuildQueue 重建队列中。 通过查看源码中哪些地方调用了这几个函数,就能知道什么情况下会触发 UI 的 Rebuild 了。 常见触发 Rebuild 的操作: RectTransform 的 Width,Height,Anchor,Pivot 改变。 Text 的内容及颜色变化、设置是否支持富文本、更改对齐方式、设置字体大小等。 Image 组件颜色变化、更换 Sprite。 Slider 组件每次滑动时。 ScrollBar 组件每次滑动时。 SetActive、Enable 为 true 时。 Mask 勾选/取消勾选 Show Mask Graphic。 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 为例。 图形重建过程:
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 对象使用。
我们可以看到,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。
布局重建过程: LayoutRebuilder 的 Rebuild() 方法:
PerformLayoutCalculation() 方法会递归计算 UI 元素的宽高(先计算子元素,然后计算自身元素) ILayoutElement.CalculateLayoutInputXXXXXX() 在具体的实现类中计算该 UI 的大小。 PerformLayoutControl() 方法会递归设置 UI 元素的宽高(先设置自身元素,然后设置子元素) ILayoutController.SetLayoutXXXXX() 在具体的实现类中设置该 UI 的大小。 UI 重建优化策略 动静分离:细分 Canvas,把相对静态的、不会变动的 UI 放在一个 Canvas 里,而相对变化比较频繁的 UI 就放在另一个 Canvas 里。注意:新增 Canvas 会打断合批,增加 DrawCall。 隐藏界面时,可用 CanvasGroup.Alpha=0,或者从 Camera 渲染层级里移除等方法隐藏,代替 SetActive。 对于血条、角色头顶名称、小地图标记等频繁更新位置的 UI,可尽量减低更新频率,如隔帧更新,并设定更新阈值,当位移大于一定数值时再赋值(重复赋相同的值,也会 SetDirty 触发重建)。 注意合理设计 UI 的层级,由于布局重建需要对 UI 进行排序,层级太深影响排序消耗。 4.OverDraw Overdraw 是指一帧当中,同一个像素被重复绘制的次数。Fill Rate(填充率)是指显卡每帧每秒能够渲染的像素数。在每帧绘制中,如果一个像素被反复绘制的次数越多,那么它占用的资源也必然更多。Overdraw 与 Fill Rate 成正比,目前在移动设备上,FillRate 的压力主要来自半透明物体。因为多数情况下,半透明物体需要开启 Alpha Blend 且关闭 ZTest 和 ZWrite,同时如果我们绘制像 alpha=0 这种实际上不会产生效果的颜色上去,也同样有 Blend 操作,这是一种极大的浪费。 不幸的是,Canvas 绘制的所有几何体都在透明队列中绘制。也就是说,Unity UI 生成的几何体将始终使用 Alpha 混合从前向后绘制。从多边形栅格化后的每个像素都将被采样,即使它完全由其他不透明多边形覆盖。在移动设备上,这种高水平的透支可以快速超过 GPU 的填充率容量。 在场景【scene】下拉列表中选择 overdraw 就能看见,越亮的地方就是 overdraw 最多的部分。
常见 UI 性能问题: DrawCall 过高,合并和提交批次花费大量 CPU 时间(Rebatch)。 UI 重建花费大量 CPU 时间(Rebuild)。 填充率过高,导致 GPU 渲染压力过大(overdraw)。 生成顶点花费大量 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。而直接修改顶点色的好处是可以保证其材质不变,因此不会产生额外的 DrawCall。 在 UI 的默认 Shader 中存在一个 Tint Color 的变量,正常情况下,该值为常数(1,1,1),且并不会被修改。如果是用脚本访问 Material,并修改其 Tint Color 属性时,对 UI 元素产生的网格信息并没有影响,因此就不会引起网格的 Rebuild。但这样做因为修改了材质,所以会增加一个 DrawCall。 这时候就得权衡一下是要更少的 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/ https://zhuanlan.zhihu.com/p/350778355