Unity3D UGUI系列之合批

Unity3D UGUI系列之合批

2021年1月16日 0 作者 老王

1. 什么是UGUI的合批

1.1 准备工作

在正式开始之前,咱们先做个准备工作:创建一个新场景,然后把自带的平行光给删除,讲相机的Clear Flags改为Solid Color。
初始场景
此时打开Game视图中的Stats面板,可以看到Batches数为1。
(Stats面板上的参数怎么看,以及什么是Batches,可参考之前写的博客《Unity3D客户端项目优化总结之Stats统计面板》《Unity3D客户端项目优化总结之静态批处理Static Batching》)
初始场景Stats面板

1.2 批处理

再说UGUI的合批之前,先看看什么是批处理。
在说批处理之前我们先看看一个普通的3D模型是怎么渲染出来的。
①首先是CPU这边先准备好这个模型的网格、用到的贴图和Shader,然后GPU将网格、贴图、Shader加载到显存里面。
②然后是CPU设置渲染状态
什么是设置渲染状态呢?就是CPU设置渲染这个网格的时候使用哪个Shader,使用哪几张贴图。第①步中我们可能会准备好很多的Shader(如Shader1、Shader2、Shader3),很多张贴图(贴图1、贴图2、贴图3),设置渲染状态这一步的作用就是告诉GPU,接下来你渲染这个网格的时候使用的Shader是Shader1,不是Shader2、Shader3,使用的贴图是贴图1而不是贴图2、贴图3。
也就是说CPU是老大,GPU是小弟,老大说你下次渲染这个模型的时候用这个Shader和这张贴图,那么小弟开始干活的时候就按老大的要求来。
③CPU设置完毕渲染状态后,GPU还没正式开始渲染这个模型,而是等CPU发号施令,CPU告诉GPU说"你可以渲染这个模型了”,然后GPU才开始按照②中设置的Shader和贴图真正渲染这个模型,并把渲染后的结果层递到屏幕上。CPU告诉GPU“可以渲染这个模型”的过程或者说这个命令叫做Draw Call(我们在Stats面板上看到的Batches其实就是Draw Call的调用次数)。
从上面的流程可以看出,每一个3D模型要被渲染都应该会走完一个完整的步骤①②③。也就是说一个模型要被渲染,按理说就应该调用一次Draw Call。比方说我们场景中有3000个模型,那么Draw Call应该是3000,但是我们看Stats面板会发现Draw Call(Stats面板上的Batches值)并没有那么多。为什么会这样呢?因为Unity进行了批处理。
批处理就是把渲染时使用相同材质(Shader)、相同贴图的3D模型的网格合并在一起,成为一个大网格,然后再调用一次Draw Call,直接渲染这一个大网格。
所以需要注意的是,一定要使用相同材质和相同贴图的模型才可以批处理,一个模型使用的材质或贴图与其他模型不同,那么CPU就得单独进行步骤②设置渲染状态,紧接着也就得单独进行步骤③调用Draw Call。

1.3 批处理的意义

从上面的分析可以看出,批处理的意义在于减少了Draw Call的调用。
因为CPU调用Draw Call之前,需要准备好数据,设置渲染状态,而准备数据和设置渲染状态特别耗时!如果Draw Call过多,那么CPU就会把大量的时间花在准备数据和设置渲染状态上,而造成性能问题。
举个例子,我们移动一个包含1024个1kb小文件的文件夹比移动一个1Mb的文件慢很多。因为计算机在移动文件的时候会有很多额外的操作,所以移动多个小文件比移动一个大文件更耗时。
那么渲染也可以这么理解,两个选项:
①CPU叫GPU渲染1000个小三角形
②CPU先把这1000个小三角形合并为一个大的网格,然后再叫GPU渲染这个大的网格
哪个更快?当然是②。因为①CPU要通知GPU1000次,而且每次都要花时间准备数据,设置渲染状态,而②CPU只需要通知GPU一次,也只需准备一次数据,设置一次渲染状态。即①的Draw Call是1000,②的Draw Call是1。
对于普通的3D模型,Unity内部做了静态批处理和动态批处理。
静态批处理和动态批处理的优缺点和限制可查看之前的博客,《Unity3D客户端项目优化总结之Stats统计面板》《Unity3D客户端项目优化总结之静态批处理Static Batching》。

1.4 UGUI的合批

从上面我们知道了一个3D模型是怎么被渲染出来的。那UGUI的渲染和3D模型的渲染有什么不同的吗?
答案是没有什么不同。
UGUI控件本质上也是网格,与3D模型不同的地方仅仅3D模型的网格是我们在3D Max或者Maya中建模建出来的,而UGUI控件的网格是控件代码代码里面去自动创建网格的。
比如我们创建一个Image和一个Text,将Scene视图的渲染选择为线框,可以看到其实Image和Text都是网格。
Image和Text的网格
你可能会好奇Text的网格仅仅是一个矩形,怎么渲染出那么复杂的字呢?其实我们在Text上用的字体本质是个图集,渲染某个字就是把这个字对应图集上的图片渲染出来罢了,和普通的Image渲染本质其实没多大区别,区别在于有额外的模块去处理Text的字体图集与字对应的问题。
那既然UGUI的控件都是网格,那应该可以进行批处理吧?对的!对UGUI控件进行批处理就叫做UGUI的合批。
UGUI的合批就是把某个Canvas下满足合批规则的UI控件的网格合并为一个大的网格,然后将这些网格合并在一起,调用一次Draw Call,然后提交个GPU进行绘制。
那怎样才算满足合批规则呢?根据批处理的定义,只要两个网格使用的材质和贴图是一样的就可以进行批处理。
但是UGUI的合批还有其他规则,光满足材质和贴图相同还不行,具体是怎样的规则,我们后面会有一节专门讲这个事情。

2 分析工具的使用

2.1 Frame Debugger的使用

那么问题来了,我们创建的默认Image和Text能否进行合批呢?
合批的基本条件是什么?材质(Shader)和贴图要相同,那我们来看看刚创建的默认Image和Text是否满足。
两者用的Shader都是默认的UI/Default。
Image和Text的默认Shader
那贴图是否一样呢?我们这里的Image没有指定贴图,直接在Inspector面板看不出来,得去Frame Debugger里面看看。
Frame Debugger的作用就是方便我们查看点击Enable时那一帧屏幕是如何一步一步绘制出来的,也就是说通过Frame Debugger我们可以知道先绘制了什么再绘制了什么最后绘制了什么。
Frame Debugger面板路径位于Window 》Analysis 》Frame Debugger。(我用的Unity版本是2019.3.15,低版本的Unity路径上可能会有所不同)
Frame Debugger和Profiler面板路径
打开Frame Debugger的窗口。
Frame Debugger窗口
然后点击Enable,Frame Debugger会展示出当前屏幕是怎么一步一步绘制出来的。
Frame Debugger的基本使用
Frame Debugger的左侧是树状结构的,从上到下表示绘制内容的先后顺序,在上面的先绘制,下面的后绘制。树的根节点一般是Camera.Render,表示某个相机看到的画面是如何一步一步绘制出来的。
由于我们这里UGUI的Canvas的Render Mode选择的是Screen Space – Overlay模式,此模式是在所有相机绘制完成后再绘制UGUI的内容,所以在Camera.Render下还有个UGUI.Rendering.RenderOverlays。UGUI.Rendering.RenderOverlays表示UGUI是如何一步一一步绘制的。
FrameDebugger基本树状结构
选择某一项,Game视图就会展示当前选中项此时的画面。
我们点击Camera.Rendering下的Drawing,可以看到Game视图变成纯色,为什么会这样呢?我们按照树状结构依次看下去,发现其实最后执行了Clear (color+Z+stencil),Clear就是清除的意思,括号里面的内容是颜色缓冲区、深度缓冲区和模板缓冲区。也就是说Drawing这个步骤下面执行了清除颜色缓冲区、深度缓冲区、模板缓冲区。由于它清除了颜色缓冲区,所以整个画面就变成我们相机设置的颜色。
清除画面
Clear完毕后,接着执行了Camera.ImageEffects,表示相机的屏幕后处理,就是相机看到的内容全部绘制完毕后,再把这相机的画面来进行处理,屏幕后处理可以实现一些特殊的效果。
我们创建一个相机,其默认是开启了HDR和MSAA效果的,所以这里会多一个Camera.ImageEffects步骤。(HDR和MSAA具体是什么,这里就不展开说了)
在这里插入图片描述
如果我们在Camera那里关闭HDR和MSAA,Camera.ImageEffects就不会再调用了。如果没有用到HDR和MSAA可以把它们都关闭了,这也是个优化的地方。
HDR MSAA关闭前后效果对比
相机绘制完毕后,就接着绘制UGUI了(UGUI.Rendering.RenderOverlays)。
绘制UGUI的时候,首先进行了清除模板缓冲区,上面我们看到Camera.Rener已经清除了依次模板缓冲区,为什么UGUI这里又再一次清除呢?是因为UGUI的Mask(遮罩)控件会利用模板缓冲来实现遮罩的效果。(当然我们是不建议使用Mask来实现遮罩的,因为它至少会增加两个Draw Call,后面我们会讲到这个问题。)
UGUI的绘制过程
清除模板缓冲后,就开始绘制我们的UGUI控件啦,还记得我们上面说的UGUI的本质是网格吗?所以绘制我们这里的Image和Text其实就是两个Draw Mesh(绘制网格)。渲染引擎才不管你是图片还是文字,在渲染引擎看来,所有UGUI控件通通都是网格。
我们点击第一个Draw Mesh,可以看到先绘制的是Image,右侧展示了绘制此Image使用的Shader及其贴图等。我们可以按住Ctrl然后点击_MainTex后的贴图框预览此Image使用的贴图。
FrameDebugger先绘制了Image
然后我们点击第二个Draw Mesh,可以看到绘制的Text。但是为什么从Frame Debugger看到的字体贴图大小为0×0,说实话我也没整明白。
UGUI第二次绘制的是Text
Text绘制完成后,我们的整个场景都绘制完毕了。
细心的同学可能会问,场景中,我们的Text不是在Image上面吗,是不是应该先绘制Text再绘制Image,Frame Debugger里面怎么是先绘制的Image呢?这个涉及到UGUI的合批规则了,先别急下面我们会专门说这个问题。
从Frame Debugger可以看出,Image和Text是分别绘制的,也就是说它们没有进行合批。原因也很简单,因为Image用的贴图时Unity White,而Text用的贴图是Font Texture。

2.2 Profiler-UI的使用

Frame Debugger展示了我们看到的画面是怎样一步一步绘制出来,我们可以间接了解咱们制作的UI是否进行了合批。当然,除了通过Frame Debugger外,我们还可以通过Profier中UI模块更直观的了解咱们的UI是否进行了合批。
打开Profier的快捷键为Ctrl+7,菜单路径和Frame Debugger的路径一样,都是Windows》Analysis》Profiler。
具体操作如下。(ps:我用的Unity版本是Unity2019.3.15,Unity5的Profiler中好像没有UI这个模块)
Profier-UI的基本使用
下面我们来具体看看分析结果该怎么看。
我们这里主要看以下这几栏:Objcet、Batch Breaking Reason、GameObjects以及预览视图。
Object栏展示了批处理的顺序,每次合批都会有个编号,编号从小到大,编号越小的越先绘制。如Batch 0就比Batch 1先绘制,至于这个编号是怎么来的,等会儿说合批规则的时候咱们再讲。
Batch Breaking Reason展示了合批被打断的原因;GameObjects展示了每次合批合批的物体分别是哪些。从这里可看到,第一次合批(Batch 0)只有Image,第二次合批(Batch 1)只有Text,那为什么Image和Text为什么不能在同一个批次处理呢?看Batch Breaking Reason,可以知道原因是Differnt Textrure,就是说贴图不同导致了Image和Text不能合批,这与Frame Debugger的分析是一样的。
Profiler-UI的整体框架
Frame Debugger与Profier UI模块的基本使用掌握了后,咱们就来看看UGUI合批的规则到底是什么。

3 UGUI合批规则

3.1 UGUI合批初体验

我们先来直观的感受一下UGUI的合批。如图,在1的准备工作之上(新建一个新场景,然后删除灯光,设置相机的Clear Flags为Solid Color)新建3个默认的Image,其中Image和Imge (1)有重叠部分)。
UGUI合批规则初体验
然后我们看Stats面板,Batches值为2(相机的MSAA没关闭的话会自带一个Batches),说明3个Image只调用了一次Draw Call,即这3个Image进行了合批。
UGUI合批初体验Stats
然后我们再看看Profier-UI,可以看到,3个Image进行了合批,一次性把3个Image给绘制出来了。这就是UGUI的合批。
UGUI合批初体验Profiler-UI

3.2 UGUI合批被打断初体验

然后,我们在Image和Image (1)之间创建一个Text,该Text与Image (1)有重叠部分。
UGUI合批初体验创建Text
此时我们再看Stats面板,会发现Batches值变成了4。
UGUI合批被打断初体验Stats面板
我们上面知道,因为Text控件和Image控件的渲染时使用的贴图不同,所以两者不能合批。但是在上面这个场景中,Image、Image (1)和Image (2)是能合批的,另外加了个Text,Batches值也应该为3才对(相机自带的一个+3个Image的+一个Text的),但Stats面板为啥显示为4呢,比我们分析的多了一个?
然后我们去看看Profier-UI。
可以看到,Image、Image (1)、Image (2)三者并没有合批,只有Image、Image (1)合批了,Image (2)是单独的一个批次。
也就是说,原本Image、Image (1)、Image (2)三者原本能够合批,但是由于Image (1)下多了个Text,就导致Image (1)不能和Image、Image (2)合批了。
换句话说就是,Text将Image、Image (1)、Image (2)三者的合批给打断了!
UGUI合批被打断初体验
那这个Text为什么会打断它们的合批,以及我们该怎么去解决合批被打断的问题呢?
要回答这两个问题,我们就得先弄清楚UGUI的合批规则了。

3.3 UGUI合批规则详解

3.3.1 合批规则

两个UI控件能合批的基本条件是这两个控件使用的材质球(Shader)和贴图要完全相同。比如上面看到的,虽然Text和Image默认使用的材质球都是UI/Default,但是两者使用的贴图不同,所以注定Text和Image无法合批。材质和贴图相同这只是基本条件,还有其他规则。UGUI中完整的合批流程(规则)如下。
首先我们要明确UGUI中Canvas下可以嵌套子Canvas,但是合批是以Canvas(不包含子Canvas)为单位的(子Canvas会是另外一个批次了)。除此之外,合批的操作是在子线程完成的。
①既然合批是以Canvas为单位,第一步自然就是把所有Canvas给找出来,然后剔除掉不必渲染的Canvas(透明度为0,长宽为0,在RectMask2D控件下,且在RectMask2D的区域外)
②然后计算Canvas下各UI控件的深度值Depth(需要注意的是Image的属性里面也有个depth,两者不是同一个东西)
③Depth的计算规则如下:

  • 按照Hierarchy中从上往下的顺序依次遍历Canvas下所有UI元素
  • 对于当前的UI元素CurrentUI
    i.如果CurrentUI不渲染,则Depth = -1
    ii.如果CurrentUI要渲染,但CurrentUI下面没有其他UI元素与其相交,则Depth = 0
    iii.如果CurrentUI要渲染,下面只有一个UI元素(LowerUI)与其相交,且CurrentUI与LowerUI可以合批(材质和贴图完全相同),则CurrentUI.Depth = LowerUI.Depth;如果两者不能合批,CurrentUI.Depth= LowerUI.Depth + 1
    iv.如果CurrentUI要渲染,下面有n个元素与其相交,则按照步骤iii,分别计算出n个Depth(Depth_1、Depth_2、Depth_3……),然后CurrentUI.Depth取其最大值,即CurrentUI.Depth = max(Depth_1, Depth_2, Depth_3,……)
    上面步骤中的“下面”和“相交”要明确下意思,这两个概念很重要。
    CurrentUI下面的UI,指Hierarchy面板中,在CurrentUI之上的元素。
    CurrentUI下面的UI的意思
    两个UI元素相交,是指这两个元素的网格有相交(有重叠部分),一定要注意不是两个元素的Rect区域相交。
    两元素相交的意思

在计算相交时,由于要遍历所有UI元素和已计算的底层UI元素(平方复杂度),源码中使用分组计算包围盒矩形的方法加快计算,即16个UI元素为一组计算Group 网格Rect,检查是否与底层UI元素相交时,先计算是否与底层Group相交,如果相交再与Group中的元素做判定。
④各个UI的Depth计算完毕后,依次按照Depth、material ID、texture ID、RendererOrder(即UI层级队列顺序,即Hierarchy面板上的顺序)排序(条件的优先级依次递减,且均为从小到大排序)。然后剔除Depth = -1的UI元素,得到Batch前的UI 元素队列,这个队列被称之为VisiableList
上面这段话有些地方可能没太说清楚,解释一下排序:

  • 先按Depth从小到大的顺序排序
  • Depth排完之后,Depth相同的元素再按material ID从小到大排序
  • material ID排完之后,material ID相同的元素再按texture ID从小到大排序
  • textrure ID排完之后,textrure ID相同的元素最后再按在Hierarchy上的顺序排序(Hierarchy越上面的越在队列前面)

⑤得到VisiableList之后,判断VisiableList中相邻的元素是否能够合批(相同的材质和贴图)。需要注意这里不再考虑Depth是否相同,只要两个元素相邻然后材质和贴图相同,即使两个元素的Depth不相同,这两个元素也能合批。然后一个批次一个批次的合并网格,提交GPU进行渲染。
除此之外,需要注意的是,合批是将同一Canvas下多个UI的网格合并在一起,如果其中任何一个元素的材质、网格顶点、位置(Transform)甚至颜色或者在该Canvas下动态创建或删除UI元素都将导致该Canvas重新计算合批(需要注意的是仅仅会影响这一个Canvas,子Canvas或父Canvas以及其他Canvas不会重新计算),重新生成新的网格,这个重新计算生成网格的过程被称为rebuild。所以,这也是为什么做UI提倡动静分离(动态部分和静态部分分别用不同的Canvas),层级尽量减少(层级多了,重新计算更耗时)的原因。

合批的规则搞清楚了,但彻底弄懂还需要练习一下。我这里专门挑选了几个例子,跟着做一遍应该能大大加深理解了。
在开始之前,我们得先知道material Id和texture Id怎么获取到,其实很简单,直接GetInstanceID()就行了。

// materialId
image.material.GetInstanceID()
// textureId
image.mainTexture.GetInstanceID()

3.3.2 合批规则示例1

合批规则示例1
如图,三张Image使用的材质都是UI/Default,Image1和Imge3使用的贴图的texture Id = 13188,Image2的texture Id = -1136。
现在,请分析它们三者的合批情况和渲染顺序(先渲染哪张图,再渲染哪张图)?
①首先,先分别计算三张图片的Depth(还记得Depth是怎么计算的吗?忘了再去上面看看)。

  • Image1下面没有其他UI,所以Image1的Depth = 0
    (这里我再提醒一下,image有个depth字段,我们计算出的合批的Depth和image的depth字段不是同一个东西,虽然使用的变量名一样但不是同一个东西,千万不要搞混了)
  • Image2下面也没有其他UI,所以Image2的Depth = 0
  • Imge3下面有Imag1和Image2,分别计算Image3下只有其中一个元素时Imge3的Depth,然后取其最大值。
    Image3与Imge1不相交,所以Image3的Depth = Image1的Depth = 0;
    Image3与Imge2相交,所以Image3的Depth = Image2的Depth + 1 = 1;
    然后取其最大值,所以Image3的Depth = 1。
UI 合批的Depth
Image1 0
Image2 0
Image3 1

②然后按照依次Depth、material Id、texture Id、hierarchy sort order进行升序排序,得到VisiableList。

  • 先按Depth排序,顺序为Image1》Image2》Imge3;
  • 再按material Id排序,由于三个Image的材质相同(即material Id相同),则顺序不变,仍然是Image1》Image2》Imge3;
  • 然后再按texture Id排序,由于Image2.textureId(-1136) < Image1.textureId(13188),所以Image1和Image2要进行交换,则顺序为Image2》Image1》Imge3;
    所以VisiableList = {Image2, Imgae1, Image3}。

③最后判断相邻元素是否能否合批,计算合批次数。
Image2和Image1材质相同但贴图不同,所以Image2和Image1不能合批;
Image1和Image3材质和贴图均相同,所以Image1和Image3可以合批(这里需要注意的是,虽然Image1和Image3的Depth不相同,但是到这一步不再考虑这个问题);
也就是说Image2单独绘制,Image1和Image3合批再绘制一次。
我们去Profiler UI看看咱们的分析是否正确。
示例1 Profiler UI
可以看到咱们的分析是正确的。

3.3.3 合批规则示例2

在这里插入图片描述
如图,三张Image使用的材质都是UI/Default,Image1和Imge3使用的贴图的texture Id = -1136,Image2的texture Id = 13188。
现在,请分析它们三者的合批情况和渲染顺序(先渲染哪张图,再渲染哪张图)?
①首先,先分别计算三张图片的Depth。

  • Image1下面没有其他UI,所以Image1的Depth = 0
  • Image2下面也没有其他UI,所以Image2的Depth = 0
  • Imge3下面有Imag1和Image2,分别计算Image3下只有其中一个元素时Imge3的Depth,然后取其最大值。
    Image3与Imge1不相交,所以Image3的Depth = Image1的Depth = 0;
    Image3与Imge2相交,所以Image3的Depth = Image2的Depth + 1 = 1;
    然后取其最大值,所以Image3的Depth = 1。
UI 合批的Depth
Image1 0
Image2 0
Image3 1

②然后按照依次Depth、material Id、texture Id、hierarchy sort order进行升序排序,得到VisiableList。

  • 先按Depth排序,顺序为Image1》Image2》Imge3;
  • 再按material Id排序,由于三个Image的材质相同(即material Id相同),则顺序不变,仍然是Image1》Image2》Imge3;
  • 然后再按texture Id排序,由于Image2.textureId(13188) > Image1.textureId(-1138),所以顺序不变,仍然是Image1》Image2》Imge3;
    所以VisiableList = {Image1, Imgae2, Image3}。

③最后判断相邻元素是否能否合批,计算合批次数。
Image1和Image2材质相同但贴图不同,所以Image1和Image2不能合批;
Image2和Image3材质相同但贴图不同,所以Image2和Image3也不能合批;
也就是说Image1、Image2、Image3都是单独绘制,共三个批次,绘制顺序为Image1、Image2、Image3。
我们去Profiler UI看看咱们的分析是否正确。
示例2 Profiler UI
可以看到咱们的分析是正确的。

4 优化

知道合批的原理之后,咱们就知道UI如何优化了。

  • 使用图集
  • 动静分离(动态部分和静态部分分别使用不同的Canvas)
  • Text如果可以用图片代替就用图片代替
  • 避免频繁删除/增加UI对象,UI层次结构变化会引起Canvas的更新(rebuild)
  • 避免UI元素数目过多和层次结构过于复杂影响Batch更新速度
  • 尽量不要使用Mask(其内部使用了模板缓冲,至少会造成增加2个Draw Call)

当然,上面这些只是一部分。
还有,尽量不要使用Outline、Tiled Sprite(这两者会多生成许多顶点),不需要响应点击事件的取消勾选Raycast等等,但是这些和UGUI合批的关系不大了,至于它们为什么可以优化,得从UGUI的源码入手了,有空我们再来说说吧。

最后,附上测试项目。
链接:https://pan.baidu.com/s/1Git6Qhr0Y8Lef8z7dtwddg
提取码:xfk3

5 参考文章

ps:前面3个文章内容其实有点问题的,大家可以和这篇文章对比一下然后实验一下看哪个是正确的。欢迎批评指正。