来源( 来源:unity官方 Optimizing Unity UI )
官方链接:
[1] https://unity3d.com/cn/learn/tutorials/temas/best-practices/guide-optimizing-unity-ui
[2] https://unity3d.com/cn/learn/tutorials/topics/best-practices/fundamentals-unity-ui
[3] https://unity3d.com/cn/learn/tutorials/temas/best-practices/unity-ui-profiling-tools?playlist=30089
[4] https://unity3d.com/learn/tutorials/topics/best-practices/fill-rate-canvases-and-input
[5] https://unity3d.com/cn/learn/tutorials/topics/best-practices/optimizing-ui-controls
Unity 官方优化实践 https://unity3d.com/cn/learn/tutorials/s/best-practices
Unity Profiler的主要用途是执行性能比较分析:当Unity Profiler运行的时候进行enabling和disabling的操作,它可以迅速的缩小定位到性能问题最大的UI层级。查看profiler输出结果的“Canvas.BuildBatch”和“Canvas.SendWillRenderCanvases”。 Canvas.BuildBatch是执行Canvas的Batch build过程的底层代码计算量。Canvas.SendWillRenderCanvases包含了C#脚本对Canvas组件的willRenderCanvases事件的订阅的调用。UGUI的CanvasUpdateRegistry类接收这个事件并且通过它来执行前文所描述的rebuild过程。预计所有被标dirty的UI组件都会在这个时候更新他们的Canvas Renderer。
注意:为了更容易地看到UI性能的差异,通常建议禁用除了Rendering和Scripts以外所有trace category。这可以通过点击CPU Usage profiler左侧的名叫trace category旁边的彩色方块来完成。
还要注意,category可以在CPU profiler中重新排列,可以点击或者拖拽category向上或者向下来对他们进行重新排列。
Unity Frame Debugger是一个减少UGUI的draw call的实用工具。这个内置的工具可以通过Unity Editor中的Window菜单来访问。当它运行的时候,它将显示包括UGUI在内的所有Unity产生的draw call。特别要注意的是,Unity Frame Debugger 在Unity Editor界面就可以更新游戏视口产生的draw call信息,因此可以用来尝试不同的UI配置而无需进入游戏模式。
UGUI的drawcall产生的位置取决于Canvas组件上被设置的渲染模式:
1.Screen Space – Overlay将出现在Canvas.RenderOverlays组中。
2.Screen Space – Camera将出现在Render.TransparentGeometry子项,所选渲染相机的Camera.Render组中。
3.World Space将出现在Render.TransparentGeometry子项,每个可以看见Canvas的World Space的摄像机中。
如果UI的shader没有被自定义的shader替换的话,那么所有UI都可以被 “Shader: UI/Default”识别,列出在哪个组和drawcall的细节。在下图中请看高亮红框标注的地方。
在调整UI的时候观察Unity Frame Debugger所显示的信息,这就相对比较简单的使Canvas中的UI元素最优的合成batch。最常见的与设计相关的打断批次的原因是UI元素间不小心造成的重叠。
所有的UGUI组件将它们的几何体生成成一系列的 quad。然而,很多sprite和text只占用用于显示它们的 quad的一小部分,留下了大量的剩余空间。这样的结果就是,UI开发者无意中使多个不同的 quad互相覆盖,它们的texture来自不同的 material,不能合成batch。
由于UGUI的操作完全在透明队列中,任何有不能合batch的quad在它上边的quad必须在不能合batch的quad之前绘制,因此它不能与放在不能合batch的quad上的quad合batch。(翻译这段我尽力了,但是估计还是不清楚。我讲一下大意:就是两个能合batch的quad中间夹了一个不能合batch的quad,造成这两个quad也不能合batch了)
考虑一个情景,有三个quadA、B、C。假设这三个quad彼此覆盖,并且A和C使用了相同的Material,B使用了单独的Material。B不能和A、C合成batch。
如果在层级结构中从上到下的是A、B、C,那么A、C也不能合batch,因为B必须绘制在A的上面,C的下面。然而,如果B被放在可被合batch的quad前面或者后面,那么可以被合batch的quad就能构成batch。B只需要在batch的quad之前或者之后绘制,而不会介入其中。
关于这个问题更深入的探讨,请看Canvas章节的Child order部分。
Instruments & VTune
XCode的Instruments和Intel的VTune各自可以非常深入的分析UGUI的rebuild和Canvas的batch计算在Apple设备和Intel CPU上的性能。方法名称几乎和我们之前介绍过的Unity Profiler的标签完全相同。它们是:
Canvas::SendWillRenderCanvases是一个C++父类调用C#中的Canvas.SendWillRenderCanvases方法,并控制 Unity Profiler中该行显示。它包含了用于进行rebuild过程的代码,这已经在上一章节详细介绍了。
Canvas::UpdateBatches几乎和Canvas.BuildBatch完全相同,但是增加了Unity Profiler页面并不包括的代码引用。它运行上文描述的Canvas的batch建立的实际过程。
当通过IL2CPP构建一个Unity APP时,这些工具可以被用于更深入的查看C#中Canvas::SendWillRenderCanvases的编译。(注意:编译的方法的名字是近似的。)
IndexedSet_Sort和CanvasUpdateRegistry_SortLayoutList是用于排序显示在标为dirty的Layout组件被重新计算之前的一个列表。如上文所述,这包括了计算每个Layout组件的父transform数量。
ClipperRegistry.Cull调用所有IClipRegion接口注册的实现者。内置的实现者包括使用IClipRegion接口的RectMask2D组件。当ClipperRegistry.Cull被调用时,RectMask2D组件将遍历在它层级下的所有要被裁剪的UI元素,更新他们的剔除信息。
所有可嵌套元素,并要求它们更新其剔除信息。
Graphic_Rebuild包含所有要显示的Image,Text或其他Graphic派生的组件所需要的网格的实际计算性能开销。在这之下有其他一些方法,如Graphic_UpdateGeometry,最值得注意的是Text_OnPopulateMesh。
-当Best Fit勾选时,Text_OnPopulateMesh通常是一个热点。这将在本指南后面详细讨论。
-网格修饰符,比如Shadow_ModifyMesh和Outline_ModifyMesh也在这里运行。通过这些方法可以看到shadow, outline和其他特殊效果组件的计算性能开销。
Xcode Frame Debugger和Intel GPA
底层的Frame Debugger对监测UI不同独立部分的batch性能开销和UI过度绘制开销非常重要
“Tiler”是对GPU生成几何体(包括在顶点着色器中的花费时间)过程中压力的衡量。
——一般来讲,“Tiler”值高表明顶点着色器计算过慢或者是绘制的顶点过多。
“Renderer”是对GPU的像素流水线压力的衡量。
——一般来讲,“Renderer”值高表明应用程序超过了GPU的最大填充率,或是片段着色器效率低下。
“Device” 是GPU使用的综合衡量标准,包括“Tiler”和“Renderer”的性能分析。它通常可以被忽略,因为它大体上跟踪监测“Tiler”和“Renderer”的较高者。
有关Xcode GPU Profiler的更多信息,请参阅此文档(链接见原网页)。
Xcode’s Frame Debugger可以通过点击隐藏在GPU Profiler底部的小“相机”图标来打开。在下面的屏幕截图中,通过箭头和红色框突出显示。(截图见原网页)
暂停一下之后,Frame Debugger的摘要视图就会出现,如下所示(截图见原网页):
在使用默认UI着色器时,假设默认UI着色器没有被自定义着色器替换,那么由UGUI系统生成的渲染几何图形的开销将显示在“UI / Default”着色器通道下。在上面的截图中可以看到这个渲染管线的默认的UI着色器是“UI / Default”。
UGUI只产生quad,所以顶点着色器不太可能给GPU Tiler流水线产生压力。出现在这个着色器中的任何问题都应归结于填充率问题。
分析分析器结果
1- 如果Canvas.BuildBatch或Canvas :: UpdateBatches占用了过多的CPU时间,则可能的问题是单个Canvas上的Canvas Renderer组件数量过多。请参阅“Canvas”一章的“Splitting Canvases”章节。
2- 如果GPU过度的时间花费在绘制UI上,并且frame debugger表明片段着色器流水线是瓶颈,那么应该是UI的像素填充率超过了GPU的能力,最可能的原因是UI的过渡绘制。请参考Fill-rate, Canvases and input章节的Remediating fill-rate issues部分。
3- 如果Graphic的rebuild占用了过多的CPU,如在Canvas.SendWillRenderCanvases或者Canvas::SendWillRenderCanvases中看到了大量的CPU时间占用,那么就需要进行深层分析,应该与Graphic的rebuil过程中的一些部分有关。
4- 如果大量的WillRenderCanvas花费在IndexedSet_Sort或是CanvasUpdateRegistry_SortLayoutList上,时间花费在对dirty的layout组件列表进行排序,那么就要考虑减少Canvas中的Layout组件数量。请在Replacing layouts with RectTransforms和Splitting Canvases部分中也许会找到补救措施。
5- 如果过多的时间花在Text_OnPopulateMesh上,那么Text网格的生成就是罪魁祸首。请参阅Best Fit和 Disabling Canvas Renderers部分,也许会找到补救措施。并考虑Splitting Canvases中的建议,如果正在重建的大部分文本实际上并未更改其基础字符串数据,text大量rebuild实际上并没有改变其基础的字符串数据。
6- 如果时间花在内置的Shadow_ModifyMesh或Outline_ModifyMesh(或任何其他使用的ModifyMesh),则问题在于花费在计算修饰性网格过多的时间。考虑删除这些组件,并通过静态图像实现其视觉效果。
7- 如果Canvas.SendWillRenderCanvas中没有特定的热点,或者它看起来每帧都在运行,那么问题可能是动态元素与静态元素混合在一起,致使整个Canvas过于频繁地重建。参见Splitting Canvases部分。
对于减轻GPU片段流水线上的压力有两种行动方案:
1.减少片段着色器的复杂性。
——有关更多详细信息,请参阅“UI着色器和低规格设备”部分
2.减少必须采样的像素数量。
由于UI着色器通常是标准化的,最常见的问题就是填充率的过度使用。导致这个问题最普遍的原因是UI元素大量重叠,或是有多个UI元素占据大部分的屏幕。这些问题都会导致高等级的过度绘制。
消除看不见的UI
简单的禁用玩家看不见的元素是对现有UI元素重新设计要求最小的方法,对于这种方法最常见的情况是打开了一个具有不透明背景的全屏UI。此时,在全屏UI下的任何UI元素都可以被禁用。最简单的方法是禁用根GameObject或是包含UI元素的GameObject。有关替代解决方案,请参阅Disabling Canvas Renderers部分。
禁用不可见的摄像机输出
如果在UGUI中打开了一个拥有不透明背景的全屏UI,world-space摄像机仍然会对在UI后面的独立的3D 场景进行渲染。渲染器并不知道全屏UGUI会遮挡整个3D场景。因此如果打开了一个不透明的全屏UI,禁用任何或者是全部的world-space摄像机将减少渲染3D世界的无用工作,从而减少 GPU的压力。注意:如果Canvas被设置为Screen Space – Overlay,不管场景中可用的摄像机有多少,Canvas都将被绘制。
大部分被遮挡的摄像机
许多“全屏”UI并不实际上遮挡整个3D世界,而是留下了一个小的部分可以看到3D世界。在这种情况下,使用一个渲染的纹理来拍摄这部分3D世界可能更为理想。如果这部分可见的3D世界被缓存在渲染纹理中,那么实际的world-space摄像机就可以被禁用,此时被缓存的渲染纹理就作为3D 世界的冒充版本显示在UI屏幕后面。
基于构图的UI
在设计者中,基于构图来合并与层叠独立的背景与UI元素来构成最终的UI是非常普遍的。虽然这样做相对简单,而且易于迭代,但是由于UGUI使用的是透明渲染队列,所以无法高效工作。
考虑一个简单的UI,有一个背景、一个按钮和一些文字在按钮上。在像素显示文字的情况下,GPU必须先采样背景纹理,然后是按钮的纹理,最后是字体的纹理,这三层全部都要采样。当UI的复杂性增加时,更多装饰性的元素将被层叠在背景之上,需要采样的数量将迅速增加。
如果发现一个大的UI被填充率所束缚,最好的解决方案就是创建一个单独的UI Sprite,它融合了许多装饰性的或者是不变的UI元素在它的背景纹理之上。这样做减少了为了达到设计目的而必须重叠防止的元素数量,但是这样做也耗费劳动力并且也增加了项目图集的大小。
这种将创建给定UI的需要重叠的元素合并到特定的UI Sprite上的做法也适用于子元素。考虑一个商店UI带有产品滚动的窗格,每个产品UI元素有一个边框、一个背景和一些图标来表示价格、名字和其他信息。
这个商店UI需要一个背景,但是由于产品要在背景上滑动,产品UI元素无法融合到商店UI的背景纹理之上。然而,边框、价格、名字和产品UI元素的其他元素可以融合到产品的背景上。根据图标的大小和数量,填充率的节省相当可观。
合并分层元素有一些缺点。特殊的元素不能再重复利用,这就需要额外的艺术家人力资源来创建。增加大的新纹理可能会显著增加需要来存储UI纹理的内存数量,特别是UI纹理未能按需求加载和卸载的情况下。
UI着色器和低规格设备
UGUI使用的内置着色器包含了对隐藏、裁剪和许多其他复杂操作的支持。由于这种复杂性的增加,在iPhone4这种较低端设备上,UI着色器的表现较简单的Unity2D着色器相比表现较差。
如果一个针对低端设备的应用程序不需要隐藏、裁剪和其他奇特的功能,那么就可以创建一个自定义的着色器来省略没有使用的操作,比如下面这个最简单的UI着色器:(着色器代码见原网页) [https://unity3d.com/learn/tutorials/topics/best-practices/fill-rate-canvases-and-input]
UI Canvas rebuild
要显示任何UI,UI系统必须要为显示在屏幕上的每个UI组件构建几何体。这包括了运行动态布局代码,生成多边形来变现UI文本中字符串的字符,还有融合尽可能多的几何体到单个网格中来最小化draw call。这个过程有很多步骤,在本指南开始的基础基础概念部分有详细介绍。
Canvas rebuild成为性能问题有两个主要原因:
1.如果一个Canvas上有大量要绘制的UI元素,那么计算batch本身就变的非常昂贵。这是因为在排列和分析这些元素上的花费比在Canvas上绘制这些UI元素的增长更多。
2.如果Canvas的dirty特别频繁,那么就有可能花费更多的时间在刷新一个Canvas相对较小的改变上。
随着一个Canvas上元素数量的增加,上面两个问题会越来越严重。
重要提示:在给定的Canvas上任何要绘制的UI元素改变,这个Canvas必须重新进行batch的build过程。该过程重新分析Canvas上的每个可绘制UI元素,而不管它是否已经改变。请注意,“改变”是指影响UI对象外观的任何改变,包括Sprite Renderer中指定的Sprite、transform的position和scale变化、包含在文本网格中的文本等等。
子物体排序
UGUI的建立是从后至前的,子对象在层级中的排序决定了它们的建立顺序。在层级循序中靠前的物体将被建立在层级顺序中靠后物体的后面。batch的build是从层级顺序的上走到下,并收集具有相同材质的游戏物体,即有相同纹理且没有中间层的对象(“中间层”是具有不同材质的图形对 象,其边界框与另外可合batch的对象重叠,并放置在两个可batch对象之间的层次结构中)。中间层的存在导致batch被打断。
正如Unity Frame Debugger部分所述, Frame Debugger可以用来检查中间层的UI。就是上述这种情况,一个要绘制的对象插入到另外两个要绘制的原本可batch的对象之间。
这个问题最为常发生于当text和sprite位于彼此靠近时:text的边界框可能不可见地重叠附近的sprite,因为text字形的多边形大多数都是透明的。这个问题可以通过两种方式解决:
1.对要绘制的对象进行重新排序,以确保两个可以合batch的对象不会被不能合batch的对象打破。也就是说,移动不可合batch的对象到可合batch的对象的上方或者下方。
2.调整各个对象的位置来消除不可见空间的重叠。
上述两个操作都可以在Unity Frame Debugger打开并可用的情况下在Untiy Editor中执行。通过简单地观察Unity Frame Debugger中可见的drawcall次数,就可以找到一个最合适的顺序和位置来使由于UI元素重叠而导致的drawcall浪费减少到最小。
拆分Canvas
除了一些特殊的情况,将Canvas拆分通常是一个好主意。可以将元素移动到子Canvas或者是同级Canvas中。
同级Canvas最常适用于UI中的某一部分必须与其他部分区分绘制深度,经常在其他层的上面或者下面。(例如教程中的箭头)
在其他大多数情况下,子Canvas可以更方便的从父Canvas继承显示设置。
乍看之下,将整个UI拆分为多个子Canvas是一种最佳做法,但要知道,Canvas系统也不会在分离的Canvas之间合成batch。高性能的UI设计要求在最小化rebuild和最小化drawcall浪费中取得一个平衡。
一般准则
由于Canvas的rebatch过程在任何时候都会包含所有要绘制的子组件的改变,所以最好将那些不是特殊情况的Canvas拆分成至少两部分。另外,如果一些元素可能会同时改变,最好将他们放到同一个Canvas中。比如这里有一个进度条和一个倒数计时器,它们俩依赖同样的底层数据,并且将同时被更新,所以它们应该被放在同一个Canvas上。
在一个Canvas上,放置所有静态的不改变的元素,比如背景和标签。当Canvas一开始显示时它们将会被batch一次,然后它们就不会再需要被rebatch了。
在第二个Canvas上,放置所有的动态的、频繁变化的元素。这个Canvas主要是用来rebatch被标为dirty的元素的。如果动态元素的数量变得非常多,那就要对所有动态元素进行更细的拆分,一些是经常会改变的(例如进度条、计时器显示、所有动画),还有一些是偶尔改变的。
事实上这些在实际使用中是非常困难的,尤其是将UI控件封装成prefab的时候。许多UI转而选择拆分Canvas,将更消耗性能的控件拆分到子Canvas上。
Raycast优化
Graphic Raycaster是一个相对直接的实现,它迭代所有Raycast Target设为true的Graphic组件。对于每一个Raycast Target设为true的Graphic组件,Graphic Raycaster会执行一系列测试。如果该组件通过了所有测试,则会被添加到命中的列表中。
Raycast实现细节
上述的测试是:
1.如果检测到的目标的GameObject是激活的、UI组件是可用的,那就绘制(即具有几何体)。
2.如果输入点在被检测到的UI元素的RectTransform范围内。
3.如果被检测到的目标拥有,或者其任意深度的子物体拥有任何实现ICanvasRaycastFilter的组件,并且这个组件允许进行射线检测。
接着检测目标列表会对元素按照深度排序,调整顺序不对的目标,并确认要在摄像机后面渲染的元素(即在屏幕中不可见)被移除了。
如果3D或者2D的物理系统各自的Graphic Raycaster的“Blocking Objects”属性被标记,那么Graphic Raycaster也会向它们投射射线(在脚本中,该属性被命名为blockingObjects)。
如果3D或者2D的的“Blocking Objects”被启动,那么任何绘制在一个射线遮挡物理层上的2D或是3D物体下的被检测到的目标将会被从列表中移除。
返回最终的列表。
射线优化技巧
鉴于所有射线检测目标都必须由Graphic Raycaster进行测试,因此最好的做法是仅在必须接收点击事件的UI组件上启用“Raycast Target”设置。检测目标列表越小,必须遍历的层级越浅,每次射线检测的速度越快。
对于那些有多个必须对点击事件响应的UI物体的复合UI控件,比如一个按钮它希望同时改变它的Text和背景颜色,这种情况一般最好在复合UI控件的根物体上设置一个单独的检测目标。当单个的检测目标接收到了点击事件,那么它可以将这个事件发送给这个复合控件中要响应的组件。
层级深度与raycast filter
当寻找raycast filter的时候,每个Graphic Raycast都会对根物体层级进行从头至尾的遍历。这个操作的性能消耗与层级的深度呈线性增长关系。层级中所有拥有Transform的组件必须经过检查,看它们是否实现了ICanvasRaycastFilter,所以这个操作的性能耗费并不廉价。
有一些独立的UGUI组件实现了ICanvasRaycastFilter,比如CanvasGroup, Image, Mask和RectMask2D,所以这个遍历不会简单的结束。
子Canvas和OverrideSorting属性
子Canvas中的OverrideSorting属性将会造成Graphic Raycast测试停止遍历Transform层级。如果启动它不会带来排序或者射线检测的问题,那么就应该使用它来降低射线进行层级遍历的性能成本
其他UI优化技术和提示
很多时候并没有一个简洁的方法来优化UI。本章节包含了一些可能会提高UI性能的建议,但是一些是在结构上不简洁的,或是难于维护,或是有一些不好的边际效应。其他的是一些使UI初始开发变成简单的行为的解决方案,但是也会更容易造成一些性能问题。
基于RectTransform的布局
Layout组件性能消耗相对昂贵,因为它们必须在其每次标记dirty时重新计算其子元素的大小和位置(有关详细信息,请参阅Fundamentals章节的Graphic rebuild部分)。如果给定Layout中的元素数量相对较少且数量固定,并且Layout结构相对简单,则可以使用基于RectTransform的Layout替换Layout。
通过分配一个RectTransform的锚点,RectTransform的位置和大小就能基于其父级进行缩放。例如,两个RectTransform就能实现一个简单的两列的布局:
左列的锚点应该是X:(0,0.5)和Y:(0,1)(覆盖左边屏幕)
右列的锚点应该是X:(0.5,1)和Y:(0,1)(覆盖右边屏幕)
RectTransform的大小和位置的计算将由Transform系统本身在本机代码中驱动。这通常比依靠Layout系统更高效。编写基于RectTransform的布局的MonoBehaviour脚本也是可以的。但是,这是一项相对复杂的工作,也超出了本指南的范围。
禁用Canvas Renderer
当显示或隐藏一个UI分立的部分时,通常会启用或者禁用这个UI的根游戏物体。这确保了在这个禁用的UI中没有组件接收输入或是执行Unity的回调函数。
然而,这也会导致Canvas抛弃其VBO(顶点缓冲对象)数据。重新启用Canvas会使Canvas(包括所有的子Canvas)强制进行rebuild和rebatch进程。如果这种情况发生的非常频繁,增加的CPU使用会造成应用程序的帧率卡顿。
一种可能的方法是将UI的显示和隐藏控制在其Canvas和子Canvas中,仅仅是启用或者禁用关联到Canvas或是子Canvas的Canvas Renderer组件(并不是指真的Canvas Renderer组件,而是指依赖Canvas Renderer组件的image,text等组件)。
这将UI的网格就不会被绘制,它们将会保持驻留在内存中,它们的原始batch将会被保存。此外,在UI的层级中将不会有OnEnable或是OnDisable回调函数执行。
但请注意,这样的方法将不会消除GraphicRegistry中UI的图形,所以它们仍然会出现在Graphic Raycast要检查的组件列表中。这种方法不会禁用任何隐藏UI中的MonoBehaviour脚本,所以这些MonoBehaviour脚本将会接收Unity的生命周期回调,比如说Update。
为了避免这个问题,将以这种方式禁用的UI上的MonoBehaviour脚本不应该直接来实现Unity的生命周期回调函数,而是应该通过挂载到UI根游戏物体上的“Callback Manager” MonoBehaviour脚本来接收回调函数。每当UI被显示和隐藏的时候,就会通知Callback Manager,这会确保生命周期事件根据需要传播或是不传播。关于Callback Manager模式的进一步解释超越了本指南的范围。
分配事件摄像机
如果使用Unity的内置Input Manager并且Canvas的渲染模式设置为World Space或是Screen Space – Camera模式,始终分别的设置事件摄像机和渲染摄像机非常重要。在脚本中,它始终作为worldCamera属性公开。
如果这个属性没有设置,那么UGUI将会在挂有摄像机的游戏物体中通过主摄像机标签来寻找主摄像机,至少每个World Space或是Camera Space的Canvas都会发生这种查找。GameObject.FindWithTag众所周知非常慢,所以强烈建议所有的World Space和Camera Space的Canvas都在设计时或初始化时设置其Camera属性。
这个问题不会发生在Overlay的Canvas上。