Unity UGui优化


UGUI的优化核心是draw calls和batching花费的平衡

术语

画布(Canvas) 是以原生代码编写的Unity组件,它给Unity的渲染系统提供按层划分的几何系统,可以在其内部或其上层绘制其他几何形状。

画布负责将其内部的几何形状合并到批处理、生成合适的渲染指令并发送到Unity图形系统。这些操作都由原生C 代码完成,这被称为 重新批处理(rebatch) 或 批处理构建(batch build) 。当一个画布被标记为含有需要重新批处理的几何形状时,称这个画布为 脏(dirty) 画布。

由 CanvasRenderer 组件向画布提供几何形状。

子画布(Sub-canvas) 是嵌套在其他画布组件内部的画布组件。子画布能够将其孩子节点与其父画布隔离开,一个被标记为脏的子节点不会迫使其父画布重新构建几何内容,反之亦然。有几种特殊情况会使上述情形失效,比如,改变父画布导致子画布改变尺寸。

Graphic 类是由Unity UI系统的C#库提供的基类,所有的向画布系统提供可绘制几何内容的UI系统C#类都继承它。大多数内置的UI系统绘图类都是通过 MaskableGraphic 子类实现的,这个子类实现了 IMaskable 接口,可以被遮罩。Drawable类的主要子类是 Image 和 Text ,它们能提供与其名称相对应的内容。

Layout 组件控制RectTransform的尺寸和位置,它通常用于创建具有复杂布局并且内部组件需要相对尺寸或者相对位置的UI。Layout组件只依赖RectTransform并且只影响与其关联的RectTransform的属性。他们不依赖Graphic类,并且可以独立于UI系统的Graphic类使用。

Graphic和Layout组件都依赖 CanvasUpdateRegistry 类,该类没有在Unity编辑器中公开。这个类跟踪那些需要进行更新的Layout组件和Graphic组件集合,并在与其相关的画布调用 willRenderCanvases 事件时根据需要触发更新(update)。

Layout和Graphic组件的更新称为 重建(rebuild) 。关于重建过程的进一步详述会在下文中出现。

渲染细节

当使用UI系统构建用户界面时,要时刻注意——所有的几何形状都会在透明队列(Transparent queue)中绘制。也就是说,由UI系统生成的几何形状都带有Alpha混合,从后向前地绘制。站在性能地角度上,需要记住地一件重要的事情是,从多边形栅格化而得到的每个像素都会被采样,即使它们被其他不透明多边形完全遮盖。在移动设备上,这种高层重绘(overdraw)会迅速超出GPU的填充率(fill-rate)容量。

批处理构建(batch building)过程(画布)

在批处理构建过程中,画布合并用于表示UI元素的网格(mesh),生成合适的渲染指令发送到Unity的绘图管线。这一过程的结果会被缓存并重用,直到画布被标记为脏画布。脏画布会在画布的任一网格构成成员发送改变时产生。

画布所使用的网格是从附加到画布的CanvasRenderer组件集合中获取的,但其中不会包括子画布中的组件。

计算批处理需要根据深度(depth)对网格进行排序、检查网格的重叠、共享材质等情况。这个操作是多线程的,因此在不用的CPU架构上性能差异很大,尤其是在移动版Soc芯片(通常CPU核心数少)和现代桌面CPU(通常有4个或更多核心)之间。

重建过程(Graphics)

重建过程中进行了Graphic组件中的Layout和网格的重新计算,这一过程在 CanvasUpdateRegistry 类中执行。CanvasUpdateRegistry是一个C#类,它的源代码可以在Unity’s Bitbucket上查看。

在 CanvasUpdateRegistry 值得注意的方法是 PerformUpdate 。这个方法会在画布组件调用 WillRenderCanvases 事件时被调用。这个事件每帧调用一次。

PerformUpdate 会进行3步处理:

  • 脏Layout组件需要通过 ICanvasElement.Rebuild 方法重建它们的布局(layout)。
  • 所有已注册的裁剪组件(例如Mask)都需要剔除全部被裁减的组件,由ClippingRegistry.Cull方法完成。
  • 脏的Graphic组件需要重建它们的图形元素。

Layout和Graphic的重建过程会被拆分成多个部分。Layout重建分3步完成(PreLayout,Layout和PostLayout),Graphic重建分2步完成(PreRender和LatePreRender)。

Layout重建

必须根据Layout层级顺序计算那些包含在Layout中的组件的位置和尺寸。在Game Object层级中,离根节点近的Layout有可能会改变嵌套在在它里面的Layout的位置和尺寸,所以它需要被先计算。

为此,UI系统依据Layout在层级中的深度对脏Layout列表中的Layout进行排序,高层的(例如,父Transform更少)的项会被移动到列表的前面。

排序后的Layout组件列表接下来要重建布局。这时被Layout组件控制的UI元素的位置和尺寸会发生改变。有关Layout如何影响每个元素的位置的详细叙述,请查看Unity手册中的UI Auto Layout

Graphic重建

当Graphic组件重建时,UI系统将控制传递给ICanvasElement接口的Rebuild方法。Graphic类实现了这一方法并且在Rebuild过程的PreRender阶段执行两个不同的重建步骤。

  • 如果顶点数据被标记为脏数据(例如,组件的RectTransform改变尺寸),网格会重建。
  • 如果材质数据被标记为脏数据(例如,组件的材质或纹理改变),所附加的CanvasRenderer的材质会被更新。

Graphic重建不通过任何特定顺序的图形组件列表进行,也不需要进行任何排序操作。

        public virtual void Rebuild(CanvasUpdate update)
        {
            if (canvasRenderer.cull)
            {
                return;
            }

            switch (update)
            {
                case CanvasUpdate.PreRender:
                    if (m_VertsDirty)
                    {
                        UpdateGeometry();
                        m_VertsDirty = false;
                    }
                    if (m_MaterialDirty)
                    {
                        UpdateMaterial();
                        m_MaterialDirty = false;
                    }
                    break;
            }
        }

有几种分析工具可用于分析Unity UI的性能。 主要工具是:

  • Unity Profiler
  • Unity Frame Debugger
  • Xcode’s Instruments or Intel VTune
  • Xcode’s Frame Debugger or Intel GPA

外部工具提供毫秒(或更好)级的方法级CPU分析,以及详细的draw call和着色器分析。 设置和使用上述工具的说明超出了本指南的范围。 请注意,XCode Frame Debugger和Instruments仅适用于Apple平台的IL2CPP版本,因此目前只能用于配置iOS版本。

Unity Profiler

Unity Profiler的主要用途是执行比较分析:在Unity Profiler运行时启用和禁用UI元素可以快速定位对性能问题影响最大的UI部分。

Alt Text

要分析这一点,请查看Profiler输出中的Canvas.BuildBatch和Canvas.SendWillRenderCanvasases行。

如前所述,Canvas.BuildBatch是执行Canvas Batch构建过程的本地代码计算。

Canvas.SendWillRenderCanvases包含对注册了Canvas组件的willRenderCanvases事件的C#脚本的调用。 如前所述,Unity UI的CanvasUpdateRegistry类接收此事件并使用它来运行重建过程。 任何脏UI组件都会在这个时候更新他们的Canvas Renderer。

Alt Text

注意:为了更容易看到UI性能的差异,通常建议禁用除“Rendering”,“Scripts”和“UI”之外的所有跟踪类别。 这可以通过单击CPU使用情况分析器左侧的跟踪类别名称旁边的彩色框来完成。 也可以通过单击并向上或向下拖动类别的名称,在CPU分析器中对类别进行重新排序。

UI分类在Unity 2017.1及更高版本中是新增的。 不幸的是,部分UI更新过程没有正确分类,所以在查看UI曲线时要小心,因为它可能不包含所有与UI相关的调用。 例如,Canvas.SendWillRenderCanvas被分类为“UI”,但Canvas.BuildBatch被分类为“Others”和“Rendering”。

Alt Text

在2017.1及以后,还有一个新的UI Profiler。 默认情况下,该分析器是Profiler窗口中的最后一个。 它由两个时间线和批次查看器组成:

第一个时间线显示CPU花在两个类别上的时间,分别计算布局和渲染。请注意,它受到之前描述的相同问题的困扰,可能不会考虑某些UI功能。

第二个时间线显示批次,顶点的总数并显示事件标记。在之前的屏幕截图中,您可以看到几个按钮点击事件。这些标记可以帮助您确定导致CPU峰值的原因。

最后,UI Profiler最有用的功能是底部的批次查看器。在左边,所有Canvas都有一个树形视图,每个Canvas下面都有它们生成的批次列表。这些列提供了有关每个Canvas或批处理的有趣详细信息,但是有一个特别的信息Batch Breaking Reason,对于更好地理解如何优化UI.

该栏将显示为什么选定的批次不能与前一个合并。减少批次数是提高UI性能的最有效方法之一,因此了解什么是中断批次是很重要的。

如屏幕截图所示,最常见的原因之一是使用不同纹理或材质的UI元素。在很多情况下,这可以通过使用sprite图集很容易地解决。最后一列显示与该批次关联的游戏对象的名称。您可以双击名称以在编辑器中选择游戏对象(当您有多个具有相同名称的对象时,这非常有用)。

从Unity 2017.3开始,批次查看器只能在编辑器中运行。设备上批次通常应该是一样的,所以这仍然非常有用。如果您怀疑设备上的批次可能不同,那么您可以使用下面将要介绍的帧调试器。

Unity Frame Debugger

Unity Frame Debugger是一个有用的工具,用于减少Unity UI生成的Draw call次数。该内置工具可以通过Unity Editor中的Window菜单进行访问。启用后,它将显示Unity生成的Draw call调用,包括Unity UI生成的Draw call。

值得注意的是,帧调试器将使用生成的Draw call进行更新,以在Unity编辑器中显示游戏视图,因此可用于尝试不同的UI配置,而无需进入Play模式。

Unity UI Draw call的位置取决于要绘制的Canvas组件上选择的渲染模式:

  • Screen Space – Overlay将出现在Canvas.RenderOverlays组内
  • Screen Space – Camera将出现在所选渲染相机的Camera.Render组中,作为Render.TransparentGeometry的子组
  • World Space将作为Render.TransparentGeometry的一个子组出现在每个Canvas可见的World Space相机中
Alt Text

所有UI都可以通过“Shader:UI/Default”行来识别(假设UI着色器没有被自定义着色器替代)。查看下面屏幕截图中突出显示的红色框。

通过在调整用户界面的同时观看这组线,可以相对简单的方式来最大限度地提高Canvas将UI元素组合成批次的能力。中断批次合并最常见的原因是无意间重叠。

所有Unity UI组件都将其几何图形生成为一系列四边形。然而,许多UI Sprite或UI Text字形只占用用于表示它们的四边形的一小部分,其余为空白空间。因此,发现用户界面的设计者无意间重叠了多个不同的四边形,其纹理来自不同的材质,因此无法批量处理,这是很常见的。

由于Unity UI完全在透明队列中操作,因此任何四边形上只要覆盖这具有无法合批的四边形,它都必须在无法合批的四边形之前绘制,因此无法与放置在无法合批四边形上的其他四边形进行合批处理。

考虑三个四边形A,B和C的情况。假设所有三个四边形都相互重叠,并且还假定四边形A和C使用相同的材料,而四边形B使用单独的材料。 Quad B因此不能与A或C合批。

如果层次结构中的顺序(从上到下)是A,B,C,则A和C不能被分批处理,因为B必须绘制在A上方并在C下方。但是,如果将B置于可配合的四边形之前或之后,那么可以批量生产的四元组实际上可以合批 – B只需要在合批的四元组之前或之后绘制,而不需要插入它们。

有关此问题的进一步讨论,请参阅Canvas章节的子订单部分。

Xcode Frame Debugger & Intel GPA

      底层的Frame Debugger对监测UI不同独立部分的batch性能开销和UI过度绘制开销非常重要。在后面章节我们将详细的对UI过度绘制进行讨论。

Xcode Frame Debugger的使用

        为了测试一个给定的UI是否过度榨取GPU资源,可以使用Xcode内置的GPU诊断工具。首先将项目配置为使用Metal或OpenGLES3,然后进行构建并打开生成的Xcode项目工程。如果Unity在OpenGLES 2下运行,则Xcode不能对Unity进行分析,因此这些技术不能用于较旧的设备。

        注意:在某些版本的Xcode中,为了使图形分析器工作,有必要在Build Scheme中选择适当的Graphics API。为此,请转到Xcode中的Product菜单,展开Scheme菜单项,然后选择Edit Scheme ….选择Run target并转到Options页面。更改GPU Frame Capture选项来使API适配您的工程。假设Unity工程设置了自动选择图形API,则大多数新一代的iPad将默认选择Metal。如果有疑问,请启动项目并查看Xcode中的调试日志,前面的几行应该会指出哪个渲染路径(Metal,GLES3或GLES2)正在被初始化。

        注意:上述调整在Xcode 7.4中应该不是必需的,但在Xcode 7.3.1和更旧的版本中仍然偶尔会被发现是必须的。

        在iOS设备上构建并运行项目。GPU profiler显示在Xcode的Navigator边栏中,点击FPS条目。(图请参见原网页)

        GPU分析器中第一个重要的是屏幕中的三个条目:“Tiler”、“Renderer”、“Device”。这些表示:

        “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流水线产生压力。出现在这个着色器中的任何问题都应归结于填充率问题

Analyzing profiler results

收集分析数据后,可能会得出几个结论。如果Canvas.BuildBatch或Canvas::UpdateBatches使用了过多的CPU时间,那么可能的问题是单个Canvas上的Canvas Renderer组件数量过多。请参阅Canvas章节的“拆分Canvas”部分。

如果花费在GPU上绘制UI过量的时间,并且帧调试器指示片元着色器管道是瓶颈,则UI可能超过GPU能够达到的像素填充速率。最可能的原因是 过度的UI overdraw。请参阅填 Fill-rate, Canvases and input章节的补救填充率问题部分。

如果Graphic Rebuilds使用过多的CPU,如Canvas.SendWillRenderCanvas或Canvas::SendWillRenderCanvas使用了大部分CPU时间,则需要进行更深入的分析。图形重建过程的某些部分可能要对此负责。

在大部分的WillRenderCanvas花费在IndexedSet_Sort或CanvasUpdateRegistry_SortLayoutList中的情况下,时间花费在了对脏Layout组件的列表进行排序。考虑减少Canvas上Layout组件的数量。请参阅使用RectTransforms和Splitting Canvases部分替换布局可能修复。

如果过多的时间似乎花费在Text_OnPopulateMesh中,那么罪魁祸首就是文本网格的生成。请参阅Best Fit and Disabling Canvases 部分以了解可能的补救措施,如果大部分重新构建的文本实际上并未更改其基础字符串数据,请考虑分割Canvas中的建议。

如果在Shadow_ModifyMesh或Outline_ModifyMesh(或ModifyMesh的任何其他实现)内使用了时间,则问题是计算网格修改器花费的时间过长。考虑删除这些组件并通过静态图像实现其视觉效果。

如果Canvas.SendWillRenderCanvas中没有特定的热点,或者它看起来每帧都在运行,那么问题可能是动态元素与静态元素组合在一起,并且迫使整个Canvas过于频繁地重建。请参阅拆分Canvas部分。

修复填充率问题

        对于减轻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着色器:(着色器代码见原网页)


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上。


Unity5.2和优化Batch

        在Unity5.2中,进行batch的代码被充分重构,与Unity4.6、5.0、5.1版本相比性能更加高效。而且,在多核设备(一个核心以上)上,UGUI系统将处理工作移动到工作线程中。一般来说,Unity5.2减少了将UI拆分成几十个子Canvas的偏激需求。移动设备上的许多UI现在可以只用2、3个Canvas就可以提高性能。

        有关Unity 5.2优化的更多信息可以在这篇博客文章中找到。(链接见原网页)

UGUI中的输入和射线

        默认情况下,UGUI使用Graphic Raycaster组件来处理输入事件,例如触摸事件和点击保持事件。这通常由Standalone Input Manager组件来处理。尽管Standalone Input Manager叫这个名字,但它是一个通用的输入管理系统,点击和触摸它都会处理。


移动设备上的错误的鼠标检测(5.3)

        在Unity 5.4之前,只要当前没有正确的触摸输入,每个拥有Graphic Raycaster的激活的Canvas每帧都将进行一次射线检测来确定点击点的位置。不论任何平台,这都将进行。无鼠标的iOS和Android设备仍然会确定鼠标位置,并尝试发现该位置下有哪些UI元素。(这种情况的发生是为了确定是否有任何鼠标悬停事件需要触发)

        这浪费了CPU的时间,至少浪费了Unity应用程序的5%或者更多的CPU帧执行时间。

        Unity 5.4版本解决了这个问题。从5.4以后,没有鼠标的设备将不会查询鼠标位置,也不会发射不必​​要的射线。

        如果使用的是早于5.4的Unity版本,强烈建议移动开发人员创建自己的输入管理类。这非常简单,从UGUI开源代码中拷贝Unity的Standard Input Manager代码,注释掉ProcessMouseEvent方法和所有对这个方法的调用。


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控件

        UGUI优化指南的这一部分关注于一些特殊类型UI的问题。虽然说大多数UI控件在性能方面都比较类似,但有两个组件会使游戏在接近发布状态时造成许多性能问题。


UI Text

        Unity内置的Text组件是在UI内显示栅格化字形的一种很便捷的方式。然而有一些并不被广泛了解的特性,作为性能热点高频率的出现。每当给UI增加text的时候,要知道字形事实上是作为独立的四边形渲染的,每个字符一个。这些四边形周围通常有大量的多余闲置空间,这取决于它们的形状,text这种位置的特点造成了一不小心就会打断其他UI元素的batch。


Text的网格rebuild

         一个主要的问题是重建UI text网格,每当text组件被改变时,文本组件都必须重新计算多边形来显示实际的text。这个重新计算也发生在这个text组件或是它的父游戏物体被禁用或者启用时,虽然这个text没有改变。

        这个行为对于任何要显示大量文本标签的UI都是一个问题,比如说大部分的排行榜或是统计界面。由于隐藏和显示UGUI最简单的方式是启用和禁用包含UI的游戏物体,当拥有大量text组件的UI显示时将经常会出现不期望的帧率卡顿。

        有关这个问题的潜在解决方案,请看下一章的Disabling Canvas Renderers部分。(链接见原文)


动态字体和字体图集

        当要显示的字符集合非常大或者在运行前并不知道有多少时,动态字体是一个非常方便的方式来显示文本。Unity实现时,这些字体基于UI Text组件中遇到的字符,在运行是构建一个字符图集。

        加载每个字体对象将会保持自己纹理图集,即使它与另一个字体在相同的字体体系中。例如,使用Arial字体的加粗的文本在一个控件上,同时使用Arial Bold字体在另外一个控件上将会产生一样的显示输出。但是Unity将会保存两份独立的纹理图集,一个是Arial字体另一个是Arial Bold字体。

         从一个优化的角度来讲,要理解的最重要的事情是UGUI的动态字体图集保存了每个单独的由尺寸、风格、字符排列组合而成的字形。也就是说,如果一个UI包含两个text组件,都显示字母“A”,则:

        1.如果两个Text组件拥有相同的大小,则字体图集中将包含一个字形。

         2.如果两个text组件不拥有相同的大小(例如,一个是16号,另一个是24号),则字体图集将包含两个不同大小的字母“A”的副本。

         3.如果一个text组件是粗体而另一个不是,则字体图集将同时包含粗体的“A”和常规的“A”。

        无论何时一个拥有动态字体的UI text对象遇到了一个没有被栅格化到字体纹理图集的字形时,字体纹理图集就必须被重建。如果新的字形可以适配进当前的图集,它将被添加进去,并且图形设备会重新加载这个图集。然而,如果目前的图集太小了,系统将会尝试重建图集,分为两阶段。

         首先,图集会以相同的大小重建,只会用到当前激活的UI text组件(包括其父级Canvas启用,但是禁用了Canvas Renderer组件的的UI Text组件)中要显示的字形,如果系统成功的将所有现在要用到的字形适配进新图集中,图集的栅格化不会继续进入第二步。

        其次,如果这些现在使用到的字形不行被适配进当前大小的图集,一个更大的图集将会被创建出来,将这个图集较短的尺寸放大二倍。比如,512×512的图集将会被扩大到512×1024。

        由于上述算法,一个动态字体图集只会在被创建后增长尺寸。考虑到重建纹理图集的性能消耗,使重建最小化是非常必要的。有两种方式。

        如果有可能,使用非静态字体,并预先配置好对所需字形的支持。这通常适用于对字符有非常好限制的UI,比如说只有拉丁文/ASCII字符,或者其他一个较小的范围。

         如果必须支持一个非常大范围的字符,比如全部的万国码,字体必须被设为动态字体。为避免可预见的性能问题,主要的字体字形图集在一开始就要通过Font.RequestCharactersInTexture设置适当的字符。

        请记住,字体图集的重建是是在每个UI Text组件被改变时才会被单独触发。当生成极多text组件时,在text组件内容和主要的字体图集中收集所有独特的字符是有好处的。这将会确认字形图集只需要被重建一次而不是每次新字形生成时都会要重建。

        另外请注意,当一个字体图集的重建被触发时,任何目前不包含在激活的UI Text组件中的字符将不会被添加到新的图集中,虽然他们一开始调用Font.RequestCharactersInTexture被添加到图集中。要解决此限制,请订阅Font.textureRebuilt这个委托并且查询Font.characterInfo来确保所有期望的字符保持在图集中。

        Font.textureRebuilt委托目前没有公开,它是一个单参数的Unity事件,参数是纹理图集要被重建的字体,此事件的订阅者应当遵循以下签名:public void TextureRebuiltCallback(Font rebuiltFont) { /* … */ }


专门的字形渲染器

由于字形众所周知的情况,在每个字形位置相对固定的情况下,为这些要显示的sprite写一个用户自定义的组件来显示这些字形明显是更有益的,比分显示就是一个例子。

对于比分,要显示的字符就是从众所周知的数字0-9中绘制,不会改变范围,并且以彼此固定的距离显示。将一个整数分解为数码并且显示合适的数码sprite是相对来说无关紧要的。这种专门的数字显示系统以一种无需配置和非常快的计算、动画和显示速度来构建,比Canvas的UI Text组件强很多。

后备字体和内存使用

对于必须支持大字符集的应用程序,很容易在字体导入器的“Font Names”字段中列出大量的字体。列在 “Font Names” 字段内的任何字体将会作为后备字体,在字形不能在主要字体中找到的时候。后备字体的排列顺序决定于它们列在“Font Names” 字段内的顺序。

然而,为了支持这种行为,Unity会将“Font Names”字段中列出的所有字体加载到内存中。如果一个字体的字符集非常大,则后备字体的内存消耗将过多。这种情况经常发生在包括象形文字的字体,比如日本汉字或者是中文字符。

Best Fit和性能

一般来说,永远不要使用UI text组件的Best Fit设置。

Best Fit动态的适配一个字体的最大整数大小,使text组件能够显示在框体中不会超出框体,并限定一个可配置的最大/最小的字体尺寸。然而,由于Unity会把每个要显示的独立的字形的每个单独的尺寸渲染进字体图集,所以使用Best Fit的话字体图集将会被迅速的被不同的尺寸的字形所填满。

从Unity5.3开始,Best Fit使用的尺寸检测是非最佳的。它将每一个测试的增加尺寸的字形生成到字体图集中,这远远增加了生成字体图集所要花费的时间。这也倾向于导致图集溢出,旧字形被踢出图集。由于Best Fit计算所需大量测试,这会经常将别的text组件使用的字形驱逐,在合适的字体大小被计算出来以后,字体图集的重建将会被强制执行至少一次。这个特别的问题在Unity5.4中被改正,并且Best Fit不会不必要的增加字体纹理图集,但它仍旧比静态大小的text要慢的多。

频繁的字体图集重建会迅速降低运行时性能,并导致内存碎片。设置为Best Fit的text组件的数量越多,这个问题就越严重。

Scroll View

在填充率问题之后,UGUI的Scroll View是第二个运行时的最常见性能问题的源头。Scroll View通常需要大量的UI元素来表现其内容。Scroll View的填充有两个基本的方法:

1.用Scroll View内容的所有要显示的必要的元素来填充。

2.将元素存储起来,当它们需要表现可视内容的时候重新给他们设定合适的位置。

这两种解决方案都有问题。

第一个解决方案需要更多的时间来生成所有UI元素,而且随着要显示的元素的增加,Scroll View的rebuilt的所需时间也会跟着增加。如果Scroll View中只有少量的元素,比如Scroll View只需显示一些Text组件,那么使用这个解决方案简单易行,最为合适。

第二个解决方案需要大量的代码来实现正确的当前要显示的UI和layout系统。下面将进一步详细讨论两种可行的解决方案。对于那些大量的复杂的要滚动的UI来说,通常需要使用某些对象池的方法来避免性能问题。

尽管存在这些问题,所有的方法都能够通过给Scroll View添加RectMask2D组件来得到改进。这个组件可以确保Scroll View的viewport之外的元素不包括在要绘制的元素列表中,在列表中的元素在Canvas的rebuild的时候必须生成它们的几何体,排列,并且进行分析。

简单的Scroll View元素池

实现Scroll View对象池最简单同时保留Unity内置的Scroll View组件的大多数原生便捷性的方法是使用一种混合的方法:

在UI中布局元素,这将允许布局系统来正确计算Scroll View的content大小,并且允许scrollbar正常工作,使用挂载Layout Element组件的游戏物体来作为可见UI元素的“placeholders(占位符)”。

然后,实例化足以填充Scroll View的可视区域的可视部分的可见UI元素,并且设定父物体为定位好的占位符。当Scroll View滚动时,重用UI元素来显示滚动到视口中的内容。

这将大大减少必须要batch的UI元素的数量,因为batch的开销增长只基于Canvas内的Canvas Renderer数量,而不是Rect Transform的数量。

解决问题的简单方法

目前,当任何UI元素重新设置父级,或者层级排序变换的时候,该元素及其所有的子元素都将被标“Dirty”,并且会强制rebuild其Canvas。

这个问题的原因是Unity没有分离重新设置父级和改变其层级排序的回调。这些事件都会触发一个叫OnTransformParentChanged的回调。在UGUI源码的Graphic类中(见源码中的Graphic.cs脚本),这个回调实现并执行了SetAllDirty方法。通过将Graphic标Dirty,系统可以确保Graphic将在下一帧渲染前重建其布局和顶点。

可以将Canvas分配到Scroll View中每一个元素的根RectTransform上,这将会限制重建发生的范围只在重新设置父级的元素上而不是Scroll View的整个content。但是这也会增加需要渲染Scroll View的drawcall数量。此外,如果Scroll View内独立的元素是复杂的并且包含了十多个Graphic组件,特别是每个元素上包含了大量的Layout组件,其rebuild的开销也经常可以会高到显著的降低低端设备的帧率。

如果一个Scroll View的UI元素的大小是不可变的,那么完全重新计算布局和顶点就是不必要的。然而,避免这种问题的解决方案需要实现的对象池是基于位置的改变而不是重新设置父级或是更改层级顺序。

基于位置改变的Scroll View池

为了避免上述问题,可以创建一个仅仅是移动其包含的UI元素的RectTransform的对象存储池。这样就通过移动RectTransform避免了rebuild尺寸未改变的内容,显著的提高了Scroll View的性能。

要做到这一点,通常最好是要写一个用户自定义的Scroll View子类和写一个用户自定义的Layout Group组件。后者通常是更简单的解决方案,可以通过实现UGUI的LayoutGroup的抽象基类来完成。

用户自定义的LayoutGroup可以分析底层的元数据来检查要显示多少数据元素,并且可以重新设置Scroll View的content的RectTransform为合适的。也可以通过订阅Scroll View change events(onValueChanged)并相应的使用来重新设置可见元素的位置。

其他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上。

drawcall

  • default ui material 算是一个批次
  • 所有的font会合成一个批次
  • altas会合成一个批次…
  • 有个有意思的地方就是..unity优化了font的批次位置..font会默认在第二的位置上..但是当所有font都是在同一层(一般是所有批次的最上面)的时候..font的批次会移动到最上层
  • 如果他们之间有 中间层会强制破坏批处理。
    “中间层”是指带有不同材质的绘图对象,它的边界与两个可另行批处理(otherwise-batchable)对象重叠并且位于两个可批处理对象之间。[?2] 
默认所有字体合成一个批次.在没有任何中间层的时候.font层排在第二层
将其中的一段文字移动到altas上..看到会多出一个批次.绿色的文字在第二层…红色的文字在第四层
将绿色的文字移动到altas上.这个时候所有的文字都在最顶层,看到批次中第二层的没有了..只剩下最顶层的了

发表评论