UGUI基础理论和性能优化
前言
这里将从讨论UGUI的基础理论,并从中得到可能影响性能的因素并针对这些因素进行优化这三个方面展开。
UGUI基础
这里将讲述简单说明几个基础知识要点。核心内容来自Unity官方文档,翻译参考了花卷。
Canvas基本功能逻辑
- Canvas被Unity渲染系统用于渲染几何体在游戏世界空间中。负责把它包含的Mesh(网格)合成批处理(合批),生成适当的渲染命令(Draw Call)并将它们发送到 Unity 的图形系统。这个过程称为ReBatch(重建批次)或者Batch Build(批处理构建)。
当前Canvas被标记为包含需要Rebatch的几何体时候,该Canvas会被标记为Dirty。 - Canvas中几何体数据的通过Canvas Renderer组件进行传递。
- 父Canvas和其子Canvas除了父Canvas的改变导致子Canvas的大小发生变化的情况,不同的Canvas之间是互不影响的。
Graphic基本功能逻辑
Graphic类是 Unity UI C# 库提供的基类(Base Class)。它是所有为 Canvas 系统提供可绘制几何图形的 Unity UI C# 类的基类。大多数内置 Unity UI Graphics 都是通过 MaskableGraphic 子类实现的,这允许它们通过IMaskable接口进行遮罩。
后面说的Graphiczyć就是指继承自Graphic类的组件(Graphic Components)
Layout基本功能逻辑
Layout Component(布局组件) 仅依赖于 RectTransofrm,通过控制其属性,来实现错综复杂的布局。也因此它不依赖于Graphic类,可以独立于 Unity UI 的 Graphic 组件使用。
后面说的Layout便是Layout Component。
CanvasUpdateRegistry
Graphic 和 Layout 都依赖于CanvasUpdateRegistry类。该类会去跟踪必须更新的Graphic类和Layout组件,并在其关联的Canvas触发事件willRenderCanvases时进行更新。(这个事件每帧都会被调用一次)
Grahpic类和Layout组件的更新称为Rebuild(重建),这里的Rebuild指的是重新计算Layout布局以及继承Graphic类的组件的网格数据。
在Canvas触发事件willRenderCanvases时,CanvasUpdateRegistry接收事件并执行方法PerformUpdate,此方法主要执行内容为:
- 被标记为Dirty状态的Layout请求重建其布局(Rebuild)。
- 所有的已注册的裁剪组件(比如Masks) 会请求裁剪任何需要被裁剪的组件。
- 被标记为Dirty状态的继承Graphic类的组件请求重建其图形元素(Rebuild)。
渲染方式和细节(OverDraw和fill-rate填充率)
渲染中,Canvas所绘制的几何体都在透明队列中,通过alpha blending从后往前进行绘制。值得注意的是从多边形栅格化的每一个像素都将被采样,即使当前像素点被其他不透明多边形完全覆盖。这种情况中,同一个像素在一帧中被重复绘制的次数叫做overdraw。而fill-rate(填充率)是指GPU的每秒可处理的像素数量。过高的OverDraw会迅速增加GPU的fill-rate。
Batch Build(Rebatch)过程(Canvas)
在Canvas组合其代表UI元素的Mesh(网格)合成批处理,生成适当的渲染命令并将它们发送到 Unity 的图形系统的这个过程中,需要对网格进行深度排序,并检查它们是否重叠,是否共享材质等,这个过程是通过多线程进行的,因此其性能受到不同CPU结构影响。
而此过程的结果会被缓存复用,直到Canvas中因为任意一个网格发生变化而被标记为Dirty状态为止。
Rebuid过程(Graphic)
为了重新计算包含一个或者多个Layout的组件的正确位置,就必须先按照适当的层级顺序去计算,因为Hierarchy中靠近根节点GameObject的Layout的修改很可能嵌套于其中的子物体的Layout位置和消息。所以越靠近根节点的Layout越优先被计算。
为此,UGUI会把被标记为Dirty状态的Layout按照它们在Hierarchy中的层级进行排序,层次结构较高的项,也就是父Transform较少的项,会被放在列表的前面,被优先计算。
之后,在CanvasUpdateRegistry的PerformUpdate方法中,这些被排序好的Layout会请求Rebuild(重建)它们的布局,这也是被Layout控制的UI元素真正改变位置和大小的过程。
Rebuild过程(Layout)
当Graphic进行Rebuild的时候,UGUI会把控制权交给实现ICanvasElement接口的Rebuild方法。在此Rebuild过程中,Graphic会执行两个不同的重建步骤:
- 如果顶点数据已经被标记为Dirty状态,比如此组件的RectTransoform的大小发生变化的时候,那么就会重建网格数据。
- 如果材质数据已经被标记为Dirty状态,比如此组件的材质或纹理发生变化的时候,那么就会更新附着在Canvas Renderer的材质数据。
Graphic的Rebuild不需要按照特定的顺序进行,自然也不需要进行任何排序。
合批
类似于3D模型的批处理,UGUI的合批是一种优化手段,它通过把某个Canvas下面满足合批规则的UI控件的Mesh(网格)合并成一个大的网格,这些合并在一起的网格只会调用一次Draw Call,然后提交给GPU进行绘制。
这种方式的目的便是为了减少Draw Call的数量,提高渲染效率。因为在每执行一次Draw Call的过程中,都需要CPU先执行准备数据并加载到显存,设置渲染状态信息这两个步骤,而这两步相当耗时间!因此如果Draw Call的数量过多,那么CPU就会把大量的时间花在准备数据和设置渲染状态上,从而造成性能问题。
值得一提的是,合批的操作是在子线程完成的。
合批规则
UI控件之间的合批是有条件的。
- 合批范围:合批以Canvas为单位,不同的Canvas之间的控件无法进行合批以及透明度和长宽为0以及active为false的Canvas也无法进行合批。
- 基本条件:拥有完全相同的贴图和材质球(Shader)。
- Depth计算:确定Canvas下各个UI控件的深度值Depth。
Depth计算规则
从Hierachy中从上网一下依次遍历Canvas中的所有元素,对于当前UI元素。
- 如果当前元素不参与渲染,则Depth为-1。
- 当前元素下面没有与其他要渲染的UI相交,则Depth为0。
- 当前UI下面有且只有一个UI元素与其相交,且两者材质和贴图完全一致,则两者Depth相同,否则上面的是下面的Depth+1。
- 当前UI下面有多个UI元素与其相交,按照步骤3计算出当前UI下面每个元素的Depth,得到最高的MaxDepth,则当前UI的Depth就为DepthMax。
这里的相交指的是两个UI元素之间的Mesh网格有重叠部分,而不是RectTransform的区域有重叠。
合批过程
建立在Depth计算完成的基础上。
- 按照 Depth,Material ID,Texture ID,RendererOrder(即Hierarchy面板上的顺序) 的优先级(从大到小)进行排序。然后剔除Depth为-1的元素。得到Batch前的UI元素队列,这个队列被称之为VisiableList。
- 判断VisiableList中相邻的元素之间的材质和贴图是否完全一致,如果一致就能够进行合批,这与Depth是否相同无关。最终一个又一个批次地合并成对应的网格,提交给GPU进行绘制。
在了解了这一点后,就能理解为什么一个Canvas中任意一个元素的材质,网格顶点,位置,颜色或者在Canvas下面动态创建或者删除UI元素等都将导致该Canvas重新计算合批去生成新的网格了,毕竟任意一个网格的变化都有可能影响到最终合批的结果。
而网格发生变化的话就需要重新计算生成新的网格,这个过程就是上面所说的ReBuild,这也是说Rebuild后会引起Canvas的batch build的原因。
特别要注意的一点是,postion.z不为0的UI元素视为3D UI,其子物体全部不参与合批,且打断前后合批。
影响性能的因素和优化
想必通过上面的介绍,脑中应该可以构建出一个大致的模型图。通过这个模型图,我们可以联想到几个可能影响性能的地方:
- GPU片段着色器利用率过高。也就是过高的OverDraw导致GPU的fill-rate(填充率)容量不足。
- Canvas的batch build花费过多的CPU时间。这里面同样包含合批批次太多,产生了过多的Draw Call。
- Canvas的batch build过于频繁。
以及官方提供的生成顶点(通常来自文本)花费CPU过多时间。
原则上,创建一个UGUI其性能是受到发送给GPU的draw calls的数量(也就是合批的数量)的所限制的。但实际中,任何使得GPU超载的项目中,比起draw calls的数量,更像是受到fill-rate的限制。
因此,接下来就只需要搞清楚哪些行为导致了这些问题的发生,然后针对性的进行优化即可。
引起Canvas的batch build过于频繁的因素
Canvas中会因为任意一个Mesh(网格)发生变化就会被标记为Dirty,并进行batch build。过多的Mesh变动会导致Canvas频繁的batch build,因此为了减少batch build的次数,就需要尽量减少Canvas中Mesh改变的次数。
这一点可以从两个方面进行理解:
- 减少对Canvas中UI元素的改动。但实际上UI的改动多是根据需求进行的,如果需求需要的话,这些改动是无法避免的。
- 修改进行“改动”的方式,使用不会触发Mesh发生变化的改动方式。比如使用CanvasGroup.alpha来修改透明度控制UI的显示和隐藏来替代使用SetActive,两种方式都可以实现效果,但前者不会触发Mesh的改变,而SetActive则会。
类似SetActive会导致Mesh发生改变的条件有:
- Transform的属性变化
- Text文本内容发生变化
- Graphic的Color属性发生变化
- UI组件的添加和移除
- Image内容发生变化
除了上面举例的这些,先看一下会触发Rebuild的条件有:
- Layout修改了RectTransform的影响布局的属性
- Graphic的Mesh或者Material发生了变化
- Mask裁剪的内容发生了变化
Rebuild通常引起Canvas的batch build。
rebatch过于频繁优化方式
这一部分的优化主要从第二点进行——尽量实现UI控件改动的同时不出发Mesh的改变。
- 使用CanvasGroup.alpha来修改透明度控制UI的显示和隐藏。
- 通过Material来修改颜色而不是color属性。当前,前者会增加draw call 后者会导致Mesh改变引起Rebuild。这个需要根据实际进行权衡。
引起Canvas的batch build时间过长的因素
- UI元素数目过多且层次结构过于复杂,导致一次合批排序和分析计算的消耗过大。
- 产生了过多或者说冗余的Draw Call。比如:
- 使用不当的UI组件。比如能通过Mask2D来实现的效果,使用了Mask组件,这就需要至少2个Draw Call。
- 一个页面中使用了太多的不同的材质球或者纹理等,导致无法合批,从而产生额外的Draw Call。
优化这一部分可以通过上面两个方面入手。
动静分离
这个手段目的是减少合批排序和分析计算的消耗,但根据情况,可能会产生额外的Draw Call,因此需要权衡。
上面已经多次说过了,一个Canvas里面任何可绘制的UI元素网格发生变化的时候,Canvas都必须重新进行batch build,去重新分析和排序其包含的每个UI元素无论它们是否发生了变化。我们注意到即使一些UI元素长年不动或者说相对静止,也会被卷入到batch build的计算中,再考虑到各个Canvas之间的batch build是互不影响的,因此我们可以得到动静分离的优化手段————把经常需要发生变化的UI元素这一动态部分放在一个Canvas里面,把相对静止的静态部分放入另一个Canvas里面,从而实现静态部分不会因为动态部分的改变而收到“牵连”,进而实现性能优化。
这里的优化方式便是优化Canvas一次batch build的耗时,但实际上我们根据子Canvas或者同级Canvas进行分割的时候,因为Canvas并不会跨越单独的Canvas进行合批,收到位置和层级的影响的时候,便有可能会导致额外的Draw Call。这也是为什么开头就说要权衡的原因。
这在实际开发中不一定好控制,除了这一点之外,还有一个要点就是Unity 5.2优化了批处理代码,显著提高了性能。UGUI在一个核心以上的设备上,会把大部分批处理放在子线程进行计算,从而缓解主线程的压力。动静分离可能也因此收到的关注并没有那么高了。至少在本人目前的经历中,还没有采用这种形式的项目。
图集
这是用于减少Draw Call的手段。
前面已经说到了,合批需要有相同的材质和贴图,而我们通过把多个图片合在同一张贴图上面,便可以让使用这些图片的UI元素共享同一张贴图,从而在材质相同的情况就可以进行合批,从而减少Draw Call。
打图集的规则:
- 过大的图尽量不要打进图集里面,而是按照Texture的形式进行使用,一般为512以下的图片才允许进入图集。
- 通用的图片放在一个通用图集里面。
- 尽量把同一个页面,同一个功能的图放在一个图集里面。
- 不要把重复的图打到不同的图集里面。
引起GPU的fill-rate过高的因素
在前面渲染细节中已经讲过overdraw和fill-rate的概念,这里不再赘述。此外,Unity可以在Scene试图切换Overdraw模式,查看填充率情况。
fill-rate过高的情况通常是由于大量的重叠UI元素或者屏幕包含了过多的UI元素导致的,因为这两种情况都会造成过多的Overdraw。
可以采取两种方式来减轻GPU的渲染压力:
- 减少必须采样的像素数量。
- 降低片段着色器的复杂性(fragment shaders)。
这里主要围绕第一种方式来讲。优化方式主要可以分为如下几点:
- 关闭玩家看不到的UI元素。让UI元素被完全覆盖的时候,最理想的情况就是让它不参与渲染,比如把它回收关闭或者隐藏。比如我们可能永远只允许一个全屏页面显示在屏幕上,此全屏页面上只允许类似弹窗性质的UI元素显示。
- 关闭不可见的相机输出。比如我们渲染3D场景的相机和我们渲染UI的相机在不相同的情况,当我们打开的页面完全覆盖了原本的3D场景,就可以关闭完全被遮挡的摄像机,从而减少GPU需要渲染的工作量。而如果没有完全覆盖,则根据情况可以选择将场景渲染为texture(纹理)并使用,而不是让它连续进行渲染。
- 需要相应Raycast事件时,不要使用空的Image,可以自定义组件继承自MaskableGraphic,重写OnPopluateMesh,并把Mesh清空,这样可以响应Raycast又不需要绘制Mesh。
- 慎用Mask组件和Outline,Shadow组件。
除此之外,还有许多地方可以优化,这里就不一一列举了。
如若有误,欢迎指正。