Unity UI 官方优化指南(施工中)

Unity官网原文链接

自己学习时顺带翻译过来做为笔记,英语捉急,错误疏漏之处欢迎指正!

前言

UnityUI的优化是一门艺术。简单粗暴的原则是不存在的,相反,每种具体情况下都必须仔细的在头脑中模拟系统的行为来做出评估。不论优化任何UnityUI,核心问题都在于平衡DrawCall和Batching的开销。一些常见技术可以减少其中一项的开销,而对于某些复杂的UI则需要做出一些取舍。

优化UI离不开性能分析器,在试图对UI系统做出优化之前,最重要的事情是针对一个可观测的表现问题精确的定位其原因。UI开发者常遇到的问题大概有如下四类:

  1. GPU片元着色器开销过大 (如:填充率过度使用)
  2. 重建Canvas batch 的CPU时间开销过大
  3. 需要重建的Canvas batches 数量过大 (over-dirtying)(dirty标记过多?)
  4. CPU 产生顶点的时间开销过大(常常是因为text)

大体上,创建UI时的表现往往取决于发往GPU的DrawCall数量的陡峭程度。不过基本上任何项目在DrawCall方面过度使用GPU都很有可能是受过度使用填充率的影响。

这个教程会讲到一些基本的概念,算法和UI相关代码,以及一些常见的问题和解决方案。教程将分为五个部分来讲:

  1. UI基础定义了一些UI基本术语,以及UI渲染过程的实施细节,如建造批处理的几何体。强烈建议读者从此章节开始阅读
  2. UI性能分析工具 讨论了对于开发者来说,收集性能分析数据的好用工具。
  3. 填充率,画布,输入 讨论UI Canvas及输入组件表现的优化
  4. UI控件 讨论 Text, ScrollViews, 及其他组件的优化,及一些除此之外别处不那么适用的技术
  5. 其他技术及小贴士 讨论了一些别处不适用的问题,包括一些基本的小贴士以及UI系统中的 workarounds for "gotchas"(什么鬼) 。

UI基础

理解组成Unity UI系统的各部分是非常重要的。它们由一些基础类和组件组成。本章定义了该系列教程中会用到的一些基本术语,然后讨论了一些UI关键系统的底层行为。

术语

Canvas是Unity的原生组件,在Unity渲染系统里它是用来作为绘制容器或位于游戏世界控件之上的层级几何体。

Canvas负责将构成他们的几何体合并成批处理,生成匹配的渲染指令并发送到Unity的图形绘制系统,这些都是C++原生代码完成的,被叫做rebatch或一个batch build。当一个Canvas被标记为包含有需要rebatching的几何体,那么这个Canvas就被认为是脏的(dirty)。

几何体通过CanvasRender组件提供给Canvas。

一个子Canvas仅仅是一个Canvas组件嵌入到另一个Canvas组件。子Canvas将它们的子对象和父对象隔绝开来;一个脏的子对象将不会强制它的父对象重建自己的几何体,反过来也一样。(这里的父子对象那个似乎都是指Canvas)当然在一些边界情况下上面所说的并不成立,比如更改了一个父Canvas导致了子Canvas重新计算尺寸。

Graphic是Unity UI库提供的一个基类。它是Canvas系统中所有提供了可绘制几何体的Unity类的基类。大部分的Unity内建UI图形类都继承自MaskableGraphic子类,通过IMaskable接口实现遮罩功能。最常见的可绘制子类如Image和Text。

Layout组件控制RectTransform的尺寸和位置,一般用来创建需要依赖其内容的相对尺寸和位置的复杂布局。Layout组件只依赖于RectTransform,且只影响与其关联的RectTransform。他们不依赖于Graphic类,可以独立于UI的各种Graphic组件使用。

Graphic和Layout组件都依赖于CanvasUpadateRegistry类,不过它并没有在Unity编辑器的接口中暴露出来。这个类会追踪必须被更新的Layout和Graphic组件的集合,当它们关联的Canvas调用willRenderCanvases事件时就会触发更新。

对Layout和Graphic组件的更新被叫做重建(rebuild)。重建的细节后面会进一步讨论。

自己补充一个,过度绘制(Overdraw)描述的是屏幕上的某个像素在同一帧的时间内被绘制了多次。在多层次重叠的 UI 结构里面,如果不可见的 UI 也在做绘制的操作,会导致某些像素区域被绘制了多次,同时也会浪费大量的 CPU 以及 GPU 资源。

渲染细节

当在UnityUI系统中搭建UI时,记得某个Canvas绘制的所有几何体都会在一个透明队列中绘制。就是说,UnityUI创建的几何体都会按照从远到近的顺序用透明度混合的方式来绘制。应当牢记的是,在一个多边形光栅化过程中每一个像素点都会被采样,即使完全被其他不透明多边形覆盖的也一样。在移动设备上,这个开销会迅速爆掉GPU的填充率(fill-rate)容量。(wiki:像素填充率是指图形处理单元在每秒内所渲染的像素数量)

Canvas的批处理建造过程

批处理的建造过程:在此过程中,一个Canvas合并它所拥有的UI元素的网格,并产生相应的渲染指令发往Unity的图形管线。这个过程的结果会被缓存起来并且被反复使用,直到该Canvas被标记为脏的,而这会在组成它的一个网格变化后发生。

Canvas使用的网格来自于其自身添加的一套CanvasRender组件,但不包含任何子Canvas。

批处理的计算需要对网格按深度(depth)进行排序并检测其重叠情况、共享材质等等。这个操作是多线程的,所以其行为在不同的CPU架构上往往很不一样,特别是移动端SoC(核心数很少)和主流桌面CPU之间(一般4核起步)。

重建过程(绘图)

重建过程其实就是UI的Layout组件和Graphic组件的网格的重新计算。这是在CanvasUpdateRegistry类中执行的。记得这是个C#类且源码可以在Unity’s Bitbucket这看到。

在CanvasUpdateRegistry中,最有用的方法当属PerformUpdate。只要Canvas组件调用到了 WillRenderCanvases事件,该方法就会同时被调用。

PerformUPdate分三个步骤执行:

  • 标记为脏的Layout组件请求重建它们的Layout,通过 ICanvasElement.Rebuild 方法。
  • 任何注册了的裁剪组件(例如Masks)会请求去裁剪任何可裁剪的组件。通过 ClippingRegistry.Cull 方法。
  • 标记为脏的Graphic组件请求重建它们的可绘制元素。

布局重建

为了重计算一个或多个Layout组件所包含的组件的合适的位置(及潜在尺寸),按合适的层级顺序去应用这些Layout是很有必要的。在GameObject层级中最接近于根节点的Layout会潜在的改变任何嵌入其中的子Layout的位置和尺寸,所以会最先被计算。

UI系统会将标记为脏的Layout组件按它们在层级中的深度(depth)排序。层级较高(拥有较少的父Transform)的会排在前面。

之后排好序的Layout组件队列就会请求重建他们的布局;这里就是被Layout组件所控制的那些UI元素的位置和尺寸实际发生改变的地方了。想知道Layout是怎么影响每个单独UI元素的位置,可以看看Unity手册的这个章节 UI Auto Layout。

绘图重建

当Graphic组件重建时,UI系统将控制权交给 ICanvasElement接口的Rebuild方法。
Graphic重建过程的预渲染环节中,分两步实施此工作。

  • 如果顶点数据被标记为脏(如:当组件的RectTransform尺寸改变时),则网格被重建。
  • 当材质数据标记为脏(如:当组件的材质或贴图改变时),则附加到CanvasRender的材质就会被更新。
    绘图重建不会要求按特定的顺序对一系列Graphic组件进行处理,因此也不需要什么排序操作。

Unity UI性能分析工具

这里将会讲到几个在分析UI表现时很有用的工具,它们是:

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

这些外部工具针对方法层提供了毫秒级(method-level)的CPU性能分析解决方案,以及对Draw-Call细节和shader的性能分析。上面几个工具的设置及使用本教程会讲到。注意Xcode Frame Debugger 和 Instruments 只对苹果平台的 IL2CPP builds 有效,因此目前只能用于分析 iOS builds。

Unity Profiler

Unity Profiler最主要的功能是用来进行性能比较分析:当Unity Profiler正在运行的时候激活(enable)或禁用(disable)某个UI元素可以迅速的缩小导致问题的UI层级的范围。

对于下面这张图的分析,可以看看分析器输出中 Canvas.BuildBatch 和
Canvas.SendWillRenderCanvases 这两行。


Unity UI 官方优化指南(施工中)_第1张图片
image

Canvas.BuildBath 前面提到过,是执行Canvas批处理建造过程的原生计算代码。
Canvas.SendWillRenderCanvases 包含了注册到Canvas组件的willRenderCanvases事件上的C#脚本。如前所述,UI系统的CanvasUpdateRegistry类接收该事件并且用它来运行重建过程。可见任何标记为脏的UI组件都会在此时更新它们的CanvsRenderer。

注意:为了更容易看出UI表现的差别,通常建议禁用除了“Rendering", "Scripts", "UI"之外的所有追踪类别。在CPU性能分析器左手边的追踪类别中勾选彩色方块就行了。同时上下拖动类别名称也可以更改它们在CPU性能分析器中的排序。


Unity UI 官方优化指南(施工中)_第2张图片
image

其中"UI"类别是在Unity2017.1及之后的版本中新加入的。不幸的是,部分UI更新过程的分类还有问题,所以在看UI曲线的时候要仔细点,因为它可能并没有包括所有UI相关的调用。举个栗子,Canvas.SendWillRenderCanvases被分类到了"UI"中,而Canvas.BuildBatch却被分类到"Others"和"Rendering"。

在 2017l.1及更新版本中,同样加入了一个新的 UI Profiler。在profiler的窗口中它被默认放到最后一个。它由两个时间线和一个批处理视图组成:

Unity UI 官方优化指南(施工中)_第3张图片
image

第一个时间线展示了两个类别的CPU时间开销,分别计算布局和渲染。记得这儿也有前面提到的那个问题:一些UI函数没有被正确的分类。

第二个时间线展示了批处理的总数,以及顶点和事件标记(event marker)。在上一个截图中,你可以看到几个事件点击按钮。这些标记可以帮助你判断是什么导致了CPU的突刺。

最后,UI Profiler最有用的的特性是底部的批处理视图。在左边是一个由所有Canvas组成的树形图,以及在每个Canvas下是由它产生的批处理的列表。关于每个Canvas或批处理这个列提供了一些有趣细节,不过为了更好地理解如何去优化你的UI,有一个地方尤其关键,那就是 Batch Breaking Reason。

这一列展示出了为何选中的批处理不会被合并到之前的一个。而减少批处理的数量是优化UI表现的最有效的手段之一,所以搞清楚批处理为何会中断是很重要的。

一个常见的原因是,如截图所示,一个UI元素使用了一个不同的贴图或者材质。通常来讲,这个问题会通过使用图集来解决。最后一列显示了该批处理关联到的GameObject的名字(name)。双击这个名字就可以在编辑器中选中这个对象了(当你有不少同名GameObject时这个功能真的很爽)。

到了Unity2017.3版本,batch viewer只能在编辑器模式下跑了。不过批处理过程应该跟在设备上运行是一样的,所以这玩意是真的很有用。如果你怀疑批处理在设备上会有不同,那你可以用用我们接下来要讲到的 Frame Debugger。

Unity Frame Debugger

在减少UnityUI产生的draw call这方面,Unity Frame Debugger是非常有用的工具。这个内建工具可以从编辑器的windows菜单内打开。当它打开时,它会显示出所有Unity产生的draw call,包括UnityUI产生的。

特别值得注意的是,frame debugger会随着编辑器Game视图中显示的内容产生的draw call来动态更新,也就是说可以在不进入运行模式(Play mode)的情况下测试不同的UI配置方案。

UnityUI draw call的位置取决于待绘制的Canvas组件中的渲染模式(Render Mode):

  • Screen Space - Overlay 会显示在 Canvas.RenderOverlays 组中
  • Screen Space - Camera 会显示在选中的渲染相机的 Camera.Render 组,作为 Render.TransparentGeometry(什么东东?)的子组
  • WorldSpace 会做为Render.TransparentGeometry 的子组显示出来,对于每一个该Canvas在其中可见的世界坐标系摄像机(真绕)。

所有UI在组或draw call的细节栏中都会有一行标注:"Shader:UI/Default"(假设UI shader没有被替换为自定义的shader)。如下图截图红框所示。


Unity UI 官方优化指南(施工中)_第4张图片
image

通过在调试UI的过程中观察这些信息,可以更容易的最大化Canvas的能力从让UI元素可以合并到批处理中去。最常见的打断批处理的设计问题就是非刻意的重叠。

所有的Unity UI组件绘制其几何体的过程都是产生一系列的四边形。然而,很多UI sprite或 text 都只占用了这个四边形的一小部分,其他的都是空白。所以很常见的情况是UI设计者会使得一些使用了不同材质的四边形互相重叠,从而使得它们不能被批处理。

由于Unity UI的操作完全是在一个透明队列中,任何四边形,如果其上方有不能被批处理的四边形,这个四边形(下面的)肯定会先绘制,而它也因此就不能与不能被批处理的四边形上方的其他四边形一起批处理了。

考虑一种3个四边形的情况,A,B和C。假设这三个四边形一个盖住一个,同时假设四边形A和C使用了相同的材质,而B使用了另外不同的材质。那么B就不能与A和C一起批处理了。

如果在层级结构中(按从上到下的顺序)是A,B,C,则A和C也不能被放在一起批处理,因为B必须A之前和C之后绘制。而如果B放在所有可以批处理的四边形的前边或后边, 那么这些本可以批处理的四边形就真的会被批处理了。而B也不会插在中间打断它们。

关于这个问题的进一步讨论,参见Child order章节。

Instruments & VTune

Xcode Frame Debugger & Intel GPA

Using the Xcode Frame Debugger

没弄过IOS

分析性能分析器的输出结果

在收集到性能分析器的数据后, 就可以做出一些结论了。如果 Canvas.BuildBatch 或者 Canvas::UpdateBatches 看起来是消耗了过多的CPU时间,那么问题可能在于在一个单独的Canvas上有过多的CanvasRender组件。参见Splitting Canvases章节。

如果是GPU在绘制UI时消耗了过多的时间,同时frame debugger明确指出片元着色器管线是平静,那么UI很有可能超过了GPU所能承受的的像素填充率。很可能的原因是UI开销过大。参见Remediating fill-rate issues章节。

如果绘制重建导致了过多的CPU开销,那么你会看到在CPU时间的很大一部分会用在 Canvas.SendWillRenderCanvases或Canvas::SendWillRenderCanvases中,这就需要进一步分析了。绘制重建过程中的某些部分可能有重大嫌疑。

如果在IndexedSet_Sort或CanvasUpdateRegistry_SortLayoutList中 WillRenderCanvase占了大比重的开销,那说明在排序标记为脏的layout组件列表时开销过大。考虑减少Canvas上的Layout组件的数量。参见 Replacing layouts with RectTransforms 和 Splitting Canvases章节。

如果大量的时间消耗在Text_OnPopulateMesh,那么很简单就是产生text网格的锅。参阅Best Fit 和 Disabling Canvases章节,还可以考虑下 Splitting Canvases里的建议,是否有一些文本并没有改变的text参与了重建。

如果时间开销花费在了 Shadow_ModifyMesh 或 Outline_ModifyMesh(或其他的ModifyMesh任务中),那么问题就是在计算网格改变消耗太大。考虑移除这些组件并使用静态image代替之。

如果在Canvas.SendWillRenderCanvases中没有特别的热点(啥意思?),或者看起来每帧在执行,那么问题可能是动态元素跟静态元素放到一组里了,然后就强制整个Canvas频繁重建。参见 Splitting Canvases 章节。

填充率,Canvas和输入

本章会讨论关于UI结构的更广泛的问题。

解决填充率的问题

关于减轻GPU片元管线的压力,这里有两方面的问题讨论:

  • 减少片元着色器的复杂度。更多细节参见"UI着色器和低端设备“ 章节。
  • 减少必须被采样的像素的数量。

由于UI shader通常都会标准化(啥意思),最常见的问题往往只是填充率的过度使用。这个问题最常见的原因是大量的重叠UI元素或有很多UI元素占据了屏幕的大部分位置。这两个问题都会导致超高的过度绘制(overdraw)。

为了减轻填充率的过度使用以及减少过度绘制,考虑下面的方案。

简化UI结构

为了减少重建和渲染UI所需的时间,尽可能的保证UI数量越低越好。能烘焙就烘焙。举例来说,不要使用混合GameObject只是为了改变一个元素的颜色,使用材质属性
会更好。同样的,不要创建GameObject当文件夹用只是为了组织场景。

禁用不可见的相机输出

假如打开了一个有着不透明背景的全屏UI,世界空间的相机仍然会在UI背后一直渲染标准的3D场景。renderer不会意识到全屏UI已经把整个3D场景都挡住了。

因此,如果整个全屏UI被打开了,禁用所有被挡住的世界空间相机有助于减少GPU压力,渲染3D世界的无用功被省掉了。

如果UI没有覆盖整个3D场景,你可以把场景渲染(一次)到一个texture上然后用它来代替持续的渲染场景。当然你的3D场景就不会动了,不过大多数情况下这都是可以接受的。

注意:如果一个Canvas被设置为"Screen Space - Overlay",它就会被绘制为不考虑此场景中的所有摄像机。

大部分被遮挡的相机(Majority-obscured)

相当多的全屏UI实际上并没有遮挡住整个3D世界,只留下了一小部分可见。在这些情况下,将世界的这些部分显示到一个render texture似乎是更佳的选择。如果世界的可见部分被"缓存"到了一个render texture上,那么真实的世界空间相机则可以被禁用了,然后这个用来缓存的render texture可以放在UI的背后用来代替3D世界。
(这跟上面一条有啥区别?)

基于组合的UI(Composition-based)

对于设计者来说通过组合的方式来创建一个UI是很常见的了,将背景和元素合并及分层来创建最终的UI。这么干相对简单,并且易于迭代,而其性能好坏则取决于UI使用透明渲染队列的方式。

假设有一个简单的UI,它有一个背景,一个按钮,按钮上有一些文本。因为在透明队列中对象是从远到近排序的,如果有一个像素在text字形的范围内,那么GPU会采样背景的贴图,然后是按钮的贴图,最后是text图集的贴图,一共有3个采样。随着UI复杂度的增涨,更多装饰性的元素被放在背景之上,采样树木也会急剧飙升。

如果发现一个巨大的UI快达到填充率边界了(fill-rate bound),最好的办法是把那些装饰性的不变的UI直接整合到背景贴图上。这样会减少铺陈在彼此之上的UI元素的数量同时能达到期望的设计,不过会花费人工(labor-intensive)且增加项目贴图图集的体积。

这个法则也可以用于UI元素和子UI元素。考虑通过一个通过滚动的面片来展示产品的仓库UI。每一个产品UI元素都有一个边界,一个背景,和一些图标来表示价格,名称及其他信息。

这个仓库UI应该会需要一个背景,但是由于它的产品在背景上滚动,产品元素不能被合并到仓库UI的背景贴图里。不过,产品元素的边框、价格、名字及其他元素可以被合并到产品的背景中。根据图标的尺寸和数量,节约的填充率是可以估计的。

当然合并层叠元素也有一些弊端。特别是元素将不能被复用,同时需要创建额外的美术资源。新增的大贴图会显著增加内存,特别是UI贴图没有根据需求加载和卸载。

UI shader 和低端设备

内建shaderb被UnityUI用来合并支持(incorporate support)遮罩,裁剪等数种复杂操作。因为增加了复杂度,这些UI shader在如iphone4之类低端机上的表现很难和简单的Unity2D shader比较。

如果遮罩,裁剪等其他华丽的特性在低端机上不是必须的,则完全可以考虑使用一些自定义的shader忽略掉一些不需要的操作,例如这个迷你版的UI shader:

Shader "UI/Fast-Default"
{
    Properties
    {
        [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {}
        _Color ("Tint", Color) = (1,1,1,1)
    }

    SubShader
    {
        Tags
        { 
            "Queue"="Transparent" 
            "IgnoreProjector"="True" 
            "RenderType"="Transparent" 
            "PreviewType"="Plane"
            "CanUseSpriteAtlas"="True"
        }

        Cull Off
        Lighting Off
        ZWrite Off
        ZTest [unity_GUIZTestMode]
        Blend SrcAlpha OneMinusSrcAlpha

        Pass
        {
        CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"
            #include "UnityUI.cginc"
            
            struct appdata_t
            {
                float4 vertex   : POSITION;
                float4 color    : COLOR;
                float2 texcoord : TEXCOORD0;
            };

            struct v2f
            {
                float4 vertex   : SV_POSITION;
                fixed4 color    : COLOR;
                half2 texcoord  : TEXCOORD0;
                float4 worldPosition : TEXCOORD1;
            };
            
            fixed4 _Color;
            fixed4 _TextureSampleAdd;
            v2f vert(appdata_t IN)
            {
                v2f OUT;
                OUT.worldPosition = IN.vertex;
                OUT.vertex = mul(UNITY_MATRIX_MVP, OUT.worldPosition);

                OUT.texcoord = IN.texcoord;
                
                #ifdef UNITY_HALF_TEXEL_OFFSET
                OUT.vertex.xy += (_ScreenParams.zw-1.0)*float2(-1,1);
                #endif
                
                OUT.color = IN.color * _Color;
                return OUT;
            }

            sampler2D _MainTex;
            fixed4 frag(v2f IN) : SV_Target
            {
                return (tex2D(_MainTex, IN.texcoord) + _TextureSampleAdd) * IN.color;
            }
        ENDCG
        }
    }
}

UI Canvas重建

要显示任何UI,UI系统必须为每个屏幕上显示的UI组件构建几何体。这包括运行中的动态布局代码,生成多边形来显示UI Text字符串里的字符,以及尽可能的将多个几何体合并成一个网格以减少draw call。这是一个多步骤的过程,其细节在本指引开头的章节Fundamentals有详细讲述。

Canvas重建形成的表现问题可能有两方面的主要原因:

  • 如果在一个Canvas中可绘制的UI元素数量过大,那么计算批处理的过程本身开销就很大。这是因为对于这些元素的排序和分析的开销相对于其数量来说是非线性的。
  • 如果一个Canvas频繁的被标记为脏的,那么刷新该Canvas的时间开销也会远远超出相对来说可能并不大的改变。

这两个问题都会随着Canvas里的元素数量增加而变得严重。

重要提示:不论何时,任何一个可绘制的UI元素发生改变,包含它的Canvase必须被重新执行批处理建造过程。这个过程会重新分析每一个Canvas中的可绘制的UI元素,不论它是否有改变。记住一个"改变"可以是任何一个影响到UI对象外观的改变,包括sprite被赋值给了一个sprite renderer,transform的位置和缩放,text网格中包含的文本,等等。

子对象顺序

UnityUI是从后向前构建的,同时层级结构里的对象顺序也会决定它们的排序。层级里较早(earlier)的对象被认为应该排在较晚(later)的对象后面。批处理会在层级中按找从上到下的方式遍历搜集所有使用相同材质、相同贴图且没有被中间层分隔开的对象。"中间层"指的是使用了不同材质的可绘制对象,它的包围盒覆盖了两个本可以做批处理的对象,且它的位置在层级结构里还位于两者之间。中间层导致批处理被中断。

就像在 Unity UI Profiling Tools 章节中讨论的那样,UI profiler和 flame debugger可以用来侦测中间层UI。就是上面说的那种情况。

这种问题常常发生在text和sprite过于靠近时:text的包围盒不可见的覆盖了附近的sprites,因为绝大部分的text字符多边形是透明的。这可以用两种方式来解决:

  • 将不可批处理的对象放到可以批处理的对象们之上或之下。
  • 微调对象的位置,消灭那些不可见的覆盖空间

这两个操作都可以在Unity Frame Debugger打开并激活的情况下执行。仅仅通过观察Unity Frame Debugger中draw call的数量,就能找到一个顺序和位置使得draw call和覆盖的耗费最小化。

分割Canvases

在一些很琐碎的情况下,拆分Canvas往往是个好主意,即把一些元素放到子Canvas或兄弟Canvas中。

兄弟Canvas是最好的做法,在这种情况下一个UIde某个部分必须使得其绘制深度和其他部分不同,始终在其他层 的上方或下方(如:教程箭头?)

在大部分其他情况下,子Canvas会更加方便,因为他们会继承父Canvas的现实设置。

虽然咋一看拆分UI到子Canvas中是个好办法,但是别忘了Canvas系统同样无法将不同Canvas合并成一个批处理。好的UI设计需要在最小化重建的开销和最小化drawcall开销之间取得一个平衡。

通用准则

因为一个Canvas会在组成它的任意一个可绘制的子组件发生改变时重新执行批处理,通常最好是将那些不一般(non-trivial)的Canvas拆成至少两部分。进一步的,如果某些元素的改变都是同步的,那么最好把它们放在同一个Canvas里。一个例子:一个进度条和一个的倒计时计时器。它们都依赖于同样的数据源因此也会在同一时刻更新,所以它们应该被放到同一个Canvas中。

在一个Canvas中,放入所有静态且不会改变的元素,例如背景和标签。这样他们只会在这个Canvas第一次显示时做一次批处理,之后再不会做批处理了。

在第二个Canvas中,放入所有动态的会频繁改变的元素。这会确保这个Canvas会批处理主要的标记为脏的元素。如果动态元素的数量增长到很大了,就有必要进一步将这些动态元素分组:持续改变的元素集合(如:进度条,计时器,动画),和偶尔改变的元素集合。

在实践中这其实有点难度,特别是当UI控件被打包到预设中。很多UI选择拆分一些消耗较大的空间到子控件中。很多UI在移动设备上

Unity 5.2和优化批处理

在Unity5.2中,批处理代码基本上被重写了,所以相比4.6,5.0, 5.1更有效率了。进一步的,在多于1核的设备上,Unity UI系统将会将大部分处理过程放到工作线程中。大体上,Unity5.2减少了主动拆分UI到子Canvas中的需求。很多UI在移动设备上可以表现的不错,当只有2-3个Canvas的时候。

关于Unity5.2的性能优化可以在这里看看this blog post.

Unity UI中的输入和射线

Unity UI默认使用Graphic Raycaster组件来处理输入事件,诸如触碰事件和指针悬停事件。这些一般是由电脑的InputManager组件处理的。尽管从名字来看,电脑的Input Manager意味着通用的输入管理系统,可以处理指针和触碰。

在移动端上不正确的鼠标输入检测(5.3)

在Unity5.4之前,每个附加了GraphicRacaster的激活的Canvas都会在每帧执行一次射线来检查指针所在位置,只要当前没有有效的触碰输入。不管在什么平台都是这么干的;没有鼠标的iOS和安卓设备仍然会查询鼠标的位置,并且试图去发现在其下方的UI元素以便判断是否有什么悬停事件需要发送。

这是对CPU时间的浪费,且被证实大约会占用了5%或更多的应用程序CPU时间开销。

这个问题已经在Unity5.4中被解决。在5.4之后,没有鼠标的设备将不再查询鼠标的位置,也不会再执行没有必要的射线。

如果使用比5.4更老的版本,强烈建议移动端开发者建立自己的输入系统类。可以直接从UnityUI源码中把Unity的标准输入系统拷贝出来,然后把ProcessMouseEvent方法注释掉就行了。

射线优化

图形射线是一种相对直线的实施方式,对每个将Raycast Target设置为true的Graphic组件迭代处理。对每个射线目标,射线会执行一组测试。如果一个射线目标通过了所有的测试,那么它就会被添加到碰撞列表中。

射线实施细节

测试如下:

  • 如果射线目标已激活,可用且已经被绘制(例:拥有几何体)(啥意思)
  • 如果输入点位于射线目标附加的RectTransform之内。
  • 如果射线目标拥有任意 ICanvasRaycastFilter 组件,或是一个拥有ICanvasRaycastFilter 组件的对象的子对象(任意深度),则RaycastFilter组件就会允许这个射线。

射线目标列表按深度排列,过滤则按照反向的目标,同时过滤会保证那些相机后(不可见)的元素被移除。

GraphicRaycast也会投射一个射线到3D或2D物理系统中,如果在GraphicRacaster的BlockingObjects属性中各自的标识被设置。在脚本中,这个属性名为 blokingObjects。(不太懂)

如果2D或3D阻挡对象是可用的,那么在射线阻挡物理层的2D或3D对象下绘制的任意射线目标也会被从碰撞(hit)列表中剔除。

然后最终的碰撞列表就会被返回。

射线优化小贴士

假定所有的射线目标都必须被GraphicRaycaster所测试,只对那些必须接受点击事件的UI组件使得"射线目标"可用是个好做法。射线目标的列表越小,需要遍历的层级越浅,则每个射线测试越快。

对于那些由数个可绘制可以相应鼠标指针事件的UI对象组成的UI控件,比如一个文本和背景都能改变颜色的按钮,则最好将单个射线目标放在UI空间的根节点上。当那个单个射线目标接受到了一个指针事件,咱们就可以把这个事件转发到控件里的每个对象及组件上。

层级深度和射线过滤器

每个图形射线都会遍历Transform层级中能到达根节点的所有路径来实现(for)射线过滤。此操作的开销随着层级深度线性增加。附加在层级里的每个Transform上的所有的组件,只要它们实现了ICanvasRaycastFilter都会进行此测试,所以这个操作还是有点消耗的。

有一些标准的UI组件使用了 ICanvsRaycastFilter, 例如 CanvasGroup, Image, Mask和 RectMask2D,所以这个遍历不能简单的移除。

子Canvas和OverrideSorting属性

一个子Canvas的OverrideSorting属性可以让一个GraphicRaycast组件尝试去停止
遍历Transform层级结构(climbing the transform hierarchy)。如果它在没有导致排序或射线检测问题的情况下被激活,那么它就可以用来减少射线层级遍历所导致的消耗。

优化UI控件

这一章节的UI优化教程集中在某些特定类型的UI上。而其实大部分UI空间都在性能瓶颈上有一定程度的相似性,在游戏中遇到的大部分的性能问题都会有两个突出的原因。

UI text

Unity的内建Text组件对于显示UI中的光栅化的文本符号是很方便的。然而还有相当数量的行为是不那么为人熟知的,但同时出现性能瓶颈的频率还挺高的。当添加一个text到UI,务必要记得文本符号实际上是作为一个单独的四边形渲染的,每个字符都有一个。这些四边形在字符周围一般都会有大量的空白,这是为了便于定位文本,而同时无意中就中断了其他UI元素的批处理。

文本网格的重建(rebuilds)

一个主要的问题是重建UIText网格。不论UIText组件是否发生了改变,text组件必须重新计算来显示实际文本的多边形。这种重计算也会在一个Text组件或者任何它的父对象disable/enable状态改变时发生。
对于任何显示了大量文本控件的UI来说这种行为都是个问题,如大多数的通用记分牌或静态显示屏。由于大多数显示隐藏一个UnityUI的方式都是disable/enable包含UI的GameObject,而包含有大量text组件的UI在显示的时候往往会导致出乎意料的帧率卡顿。

作为一个本问题的可能存在环境,参考下章节的 Disabling Canvases 部分

动态字体和字体图集

当整个需要显示的字符集非常大或者在运行时之前并不可知时,动态字体是一种便捷的显示文本的方式。在Unity的实现机制里,这些字体在运行时基于UIText组件遇到的字符建立成一个glyph图集。
每一个清晰的字体对象加载都需要维护它的贴图集,即使它和其他的字体在同一个字符家族里(font family)。举个例子,在一个控件里使用Arial bloded,同时在另一个控件里使用 Arial Bold,输出的内容是完全一样的,但Unity会维护两份完全独立的贴图集,一份用于 Arial 另一份用于 Arial Bold。
从性能的视角来说,最终要的事情就是理解Unity UI的动态字体对于每一个单独的 [字号-样式-字体】组合都会在字体贴图中维护一个glyph。这就是说,如果UI上包含了两个Text空间,都显示了一个字符A,那么:

  • 如果两个Text组件共享相同的字号,那么字体图集就会拥有一个 glyph
  • 如果两个Text组件没有共享相同的字号,那么字体图集就会持有字符A的两份不同字号的拷贝
  • 如果一个Text组件是粗体而另一个不是,那么字体图集就会持有一个粗体的A和一个常规的A

你可能感兴趣的:(Unity UI 官方优化指南(施工中))