首先贴出官方文档。
一、基础概念
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组件都继承自这个接口。
二、UI Batching
Batching是指Canvas通过合并UI元素的网格,生成合适的渲染命令发送给Unity图形渲染流水线。Batch的结果被缓存复用,直到这个Canvas被标为dirty,当Canvas中某一个构成的网格改变的时候就会被标记为dirty。
从CPU把数据发送到显卡相对较慢,合批是为了一次性发送尽可能多的数据。
batch build、batching、rebatch等都是同一个概念。
计算批次需要按深度对网格进行排序,并检查它们是否有重叠、以及材质和纹理贴图是否相同等。
首先进行深度排序:按照Hierarchy窗口从上往下的顺序
- 不渲染的UI元素Depth为 -1(setactive为false,canvasgroup.alpha为0,disable),UI下没有和其他UI相交时,该UI的Depth为0。(相交指网格有重叠)
- 当前UI下面有一个UI与其相交,若两者贴图和材质相同时,它们Depth相同,否则上面的UI的Depth是下面UI的Depth+1。
- 当前UI下面与多个UI相交,则取多个UI中Depth最高的元素(Max)与当前UI比较,若两者贴图和材质相同,则它们Depth相同,否则Depth = Max + 1。
排序完成后对Depth,材质,贴图都相同的UI进行合批。(C++实现,未开放源码)
常见的打断合批的原因:
- 同一深度 UI 元素使用了不同的材质或贴图,比如不同的图集或者字体。
- 使用了Unity的默认图片或默认字体,本质上和上面一条相同。
- 原本能够被合批的UI在Hierarchy层级相邻,即使Z轴不同,也能被合批。但是原本可以合批的UI的Hierarchy层级之间或下方插入了其他UI,此时如果有UI的Z坐标不为0可能会打断合批。
- UI使用了Mask,其本身和子节点不参与外部合批。同深度、同材质、同贴图的Mask之间可以合批,不同Mask下的子物体也可以合批。
- UI使用了RectMask2D,其子节点不参与外部合批。UI本身参与外部合批,不同RectMask2D下的子物体不能合批。
调试工具
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的合批情况、打断合批的原因以及每个批次绘制了哪些内容:
合批优化策略
- UI设计的时候应尽量保持UI使用相同的材质并处于同一深度(使用图集、注意UI的遮挡关系);
- 不要使用默认图片和默认字体;
- 特殊情况可以使用艺术字代替文本参数合批(bmfont);
- UI的Z轴统一设置为0;
- 如果需要使用遮罩,仅需要使用一个的时候用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的执行过程
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);
}
2. UI是怎么加入重建队列的
查看源码发现,主要通过以下两个函数将待重建的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重建,但是会直接触发Batching。
反射查看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对象使用。
我们可以看到,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。
3.2 布局重建过程:
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进行排序,层级太深影响排序消耗。
四、OverDraw
Overdraw是指一帧当中,同一个像素被重复绘制的次数。Fill Rate(填充率)是指显卡每帧每秒能够渲染的像素数。在每帧绘制中,如果一个像素被反复绘制的次数越多,那么它占用的资源也必然更多。Overdraw与Fill Rate成正比,目前在移动设备上,FillRate的压力主要来自半透明物体。因为多数情况下,半透明物体需要开启 Alpha Blend 且关闭 ZTest和 ZWrite,同时如果我们绘制像 alpha=0 这种实际上不会产生效果的颜色上去,也同样有 Blend 操作,这是一种极大的浪费。
不幸的是,Canvas绘制的所有几何体都在透明队列中绘制。也就是说,Unity UI生成的几何体将始终使用 Alpha 混合从前向后绘制。从多边形栅格化后的每个像素都将被采样,即使它完全由其他不透明多边形覆盖。在移动设备上,这种高水平的透支可以快速超过GPU的填充率容量。
在场景【scene】下拉列表中选择overdraw就能看见,越亮的地方就是overdraw最多的部分。
OverDraw优化策略
- 减少UI重叠层级,隐藏处于底下被完全覆盖的UI面板。
- 对于需要暂时隐藏的UI,不要直接把Color属性的Alpha值改为0,UGUI中这样设置后仍然会渲染,应该用CanvasGroup组件把Alpha值置零。
- 需要响应Raycast事件时,不要使用空Image,可以自定义组件继承自MaskableGraphic,重写OnPopulateMesh把网格清空,这样可以响应Raycast而又不需要绘制Mesh。
- 打开全屏界面,关闭场景摄像机。对于一些非全屏但覆盖率较高的界面,在对场景动态表现要求不高的情况下,可以记录下打开UI时的画面,作为UI背景,然后关掉场景摄像机。
- 裁掉无用区域,镂空,对于 Sliced 类型的 Image 可以看情况取消 Fill Center。
- 保持UI上的粒子特效简单,尽量不要发生重叠。
五、其他优化
- 所有可点击组件例如 Image、Text 在创建时默认开启 RaycastTarget。当进行点击操作时,会对所有开启RaycastTarget的组件进行遍历检测和排序。实际上大部分的组件是不需要响应点击事件的,对于这些组件我们应该取消RaycastTarget属性,最好的方式是监听组件创建,在创建时直接赋值为 false,对于需要响应事件的组件再手动开启。
- Text 尽量不要使用 outline 或者 shadow 组件,会使顶点数量成倍增加。字体效果考虑 Shader实现,或者直接让美术同学把阴影和描边做到字体里。
六、总结
常见UI性能问题:
- DrawCall过高,合批和提交批次(CPU --> GPU )花费大量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的(即Canvas.BuildBatch操作,同时也会有Canvas.SendWillRenderCanvases的开销)。而通过修改顶点色来实现UI元素变色的好处在于,修改顶点色可以保证其材质不变,因此不会产生额外的DrawCall。
在UI的默认Shader中存在一个Tint Color的变量,正常情况下,该值为常数(1,1,1),且并不会被修改。如果是用脚本访问Material,并修改其Tint Color属性时,对UI元素产生的网格信息并没有影响,因此就不会引起网格的Rebuild。但这样做因为修改了材质,所以会增加一个Draw。
这时候就得权衡一下是要更少的DrawCall,还是减少UI的重建更合适。文章来源:https://www.toymoban.com/news/detail-461827.html
七、参考文章
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详解文章来源地址https://www.toymoban.com/news/detail-461827.html
到了这里,关于Unity 性能优化基础的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!