首先贴出官方文档。
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组件都继承自这个接口。
Batching是指Canvas通过合并UI元素的网格,生成合适的渲染命令发送给Unity图形渲染流水线。Batch的结果被缓存复用,直到这个Canvas被标为dirty,当Canvas中某一个构成的网格改变的时候就会被标记为dirty。
从CPU把数据发送到显卡相对较慢,合批是为了一次性发送尽可能多的数据。
batch build、batching、rebatch等都是同一个概念。
计算批次需要按深度对网格进行排序,并检查它们是否有重叠、以及材质和纹理贴图是否相同等。
首先进行深度排序:按照Hierarchy窗口从上往下的顺序
排序完成后对Depth,材质,贴图都相同的UI进行合批。(C++实现,未开放源码)
1)通过 Frame Debug 查看每个DrawCall的绘制:
注意: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的合批情况、打断合批的原因以及每个批次绘制了哪些内容:
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。
Canvas每帧执行。
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);
}
查看源码发现,主要通过以下两个函数将待重建的ICanvasElement加入重建队列中:
一般通过脏标记来实现,以Graphic为例:
通过 SetLayoutDirty() 触发 LayoutRebuilder.MarkLayoutForRebuild(rectTransform) 将UI加入m_LayoutRebuildQueue 重建队列中。
通过 SetVerticesDirty()、SetMaterialDirty()、以及 OnCullingChanged() 的调用将UI加入m_GraphicRebuildQueue 重建队列中。
通过查看源码中哪些地方调用了这几个函数,就能知道什么情况下会触发UI的Rebuild了。
常见触发Rebuild的操作:
注意:改变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;
}
}
以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的大小。
Overdraw是指一帧当中,同一个像素被重复绘制的次数。Fill Rate(填充率)是指显卡每帧每秒能够渲染的像素数。在每帧绘制中,如果一个像素被反复绘制的次数越多,那么它占用的资源也必然更多。Overdraw与Fill Rate成正比,目前在移动设备上,FillRate的压力主要来自半透明物体。因为多数情况下,半透明物体需要开启 Alpha Blend 且关闭 ZTest和 ZWrite,同时如果我们绘制像 alpha=0 这种实际上不会产生效果的颜色上去,也同样有 Blend 操作,这是一种极大的浪费。
不幸的是,Canvas绘制的所有几何体都在透明队列中绘制。也就是说,Unity UI生成的几何体将始终使用 Alpha 混合从前向后绘制。从多边形栅格化后的每个像素都将被采样,即使它完全由其他不透明多边形覆盖。在移动设备上,这种高水平的透支可以快速超过GPU的填充率容量。
在场景【scene】下拉列表中选择overdraw就能看见,越亮的地方就是overdraw最多的部分。
常见UI性能问题:
上面针对这些问题提出了一些通用的优化策略。但正如官方文档所说:
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详解