UGUI最佳实践(3)-填充率、画布、输入

在本文中将介绍 ugui 优化过程中,涉及的三个非常重要的概念,分别是填充率(Fillrate)、画布(Canvas)、输入(Input)。

解决填充率问题

第一篇中介绍了填充率以及过度绘制,过度绘制会造成GPU片段管线处理压力,主要有两种方式来减少这种压力(片段管线主要是负责像素输出):

  1. 减少 shader 片段的复杂度,详细参考下面 “UI Shader和低配设备” 章节内容。
  2. 减少需要采样的像素数量,比较常用的办法是采用多边形来替代四边形网格。

由于内置的UI Shader都是标准通用的,一个很大问题就是填充率过高。这通常是因为大量的 UI 元素重叠,或者多个 UI 元素大面积占用屏幕,所有这些问题都会造成填充率过高。为了降低填充率,可以考虑以下的解决办法。

禁用不可见的 UI

对已有 UI 设计影响最小的是禁用不可见的元素。很常见的问题是在一个不透明的背景后面放置的 UI 元素,这些元素因为被前面的不透明背景遮挡,所以是不可见的,但是 UGUI 还是会傻傻的去计算渲染它。解决这一问题最简单的方法就是将这些物体禁用,其他解决办法参考下面的“禁用画布”部分内容。
最后,对于希望隐藏的物体,不要通过设置 alpha = 0来实现,原因和上面一样,alpha = 0时 GPU 还是需要对这些物体进行计算,占用宝贵的渲染时间。节制使用 Graphic 组件,如果 UI 元素不需要 Graphic 组件,可以将之删除。

简化 UI 结构

为了减少重构和渲染时间,非常重要的一点是减少 UI 元素数量,不要使用图形混合的方式来改变 UI 元素的色调,而是使用改变材质属性来代替,同样,不要创建一堆像文件夹一样的空物体来组织场景。

采用多边形网格

当渲染透明、镂空的UI时,采用多边形网格来替代四边形,原因是对于全透明的部分,UGUI的渲染系统依然会进行计算,并且回合当前缓冲区进行混合,这就会造成overdraw,避免这种情况的唯一方式就是采用多边形网格来显示UI。具体实现方式可以参考这里

禁用输出不可见的相机

如果显示一个不透明的全屏 UI,三维场景的渲染相机仍然会在 UI 之后进行渲染,渲染器并不知道全屏 UI 是否会遮挡整个三维场景。因此,如果显示一个完全不透明的全屏 UI,禁用所有被遮挡的三维渲染相机,消除这种无用的渲染,能够减轻 GPU 的渲染压力。如果 UI 没有遮挡住整个三维场景,你可能会想到将三维场景渲染到 RenderTexture,通过这样一次渲染代替持续不停地渲染,这样会产生一个问题,那就是不能显示场景动画内容,但是这个问题在大多数时候是可以接受的。

如果一个 Canvas 设置为 Screen Space - Overlay,无论场景有多少个相机,它都会渲染到屏幕。

大部分内容被遮挡的相机

许多全屏的 UI 并没有完全的将三维场景遮挡,而是留出一小块可见的区域,在这种情况下,一个更理想的方法是,只将可见的部分渲染到 RenderTexture,渲染到 RenderTexture 之后,将三维渲染相机禁用掉,最后将 RenderTexture 放到 UI 之后进行显示,来充当三维场景显示效果。

基于组合的 UI

通常采用组合的方式来搭建 UI ,虽然这么做相对简单,而且具有良好可迭代性,但是由于Unity 采用透明渲染队列渲染 UI ,所以性能上并不高。考虑这样一个简单的UI:一个背景、一个Button、Button上有一些Text,因为透明渲染队列中的对象是按照深度从后向前进行排序,对于 Text 覆盖的像素,GPU必须首先采样背景贴图,然后采样 Button 的贴图,最后采样 Text 的图集贴图,总共需要进行三次采样。当 UI 变得越来越复杂的时候,越来越多的元素叠加到背景之上,采样数量增长将非常快。

如果发现一个大的 UI 被填充率限制,那么最好的办法就是创建 UI 精灵,尽可能的将 UI 元素合并到背景图中,这样能够减少叠加在背景上的 UI 元素,但是这样会增加相应的工作量和增加项目图集的尺寸。

这种优化策略也可以用到 UI 元素的创建上面,考虑一个商城的 UI 设计,它包含一个滚动的商品面板,每个商品的 UI元素都有一个边框、一个背景、以及一些图标和价格标签、名称、信息等。商店的 UI 还需要一个背景,但是由于商品 UI 需要再背景上面进行滚动,所以没有办法将 商品元素合并到背景图中,但是,商品元素的边界、图标、名称等可以合并到商品元素的背景图中,根据商品图标的大小和数量,可以节省很多填充率。

这种组合策略也有几个缺点,这些进行合并的元素不能够进行重复利用,需要设计师进行额外的创建,这样会增加贴图的内存占用,尤其是在没有按需加载、卸载的时候。

UI Shader和低配设备

UGUI 内置Shader支持遮罩、裁剪以及其他复杂的操作,由于这增加了复杂性,需要进行许多额外的计算,所以在一些低配设备上性能不如简单的 2D Shader,例如iPhone 4.

对于一些低配设备,如果不需要遮罩、裁剪等一些特性,那么可以自定义一个简单的 Shader 来提高渲染性能,例如下面这个就是最简单的一个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77

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
}
}
}

Canvas 重构

为了展示 UI ,系统必须为它构建网格,这个过程包括执行动态布局、生成多边形、合并网格以减少 drawcall,这是一个多步骤处理过程,在前文有更详细的描述。

Canvas 重构会产生性能影响主要是两方面的原因:

  1. 如果需要绘制的 UI 元素数量很多,批处理计算将会非常耗时,这主要是因为元素排序和分析的消耗增长,与元素数量的增长是非线性的。
  2. 如果 Canvas 经常变脏,那么过多的时间可能被消耗用来刷新变化很少的 Canvas。

随着一个 Canvas 的 UI 元素数量增长,这两个问题变得更加严重。

重要提示:只要 Canvas 下的元素发生变化,Canvas 就必须重新进行批处理,这个过程需要重新分析 Canvas 下的每个 UI 元素,无论这个元素是否发生变化。这里说的变化,是指任何会对UI显示产生影响的变化,包括为SpriteRenderer指定Sprite、位移、缩放、旋转、Text组件文本内容变化等等。

UI 元素排序

UI的构建是按照从后向前进的顺序,这个顺序是由物体在层级视图的排序决定的,在层级视图越靠上的元素排序越靠后。批处理就是按照层级视图从上到下的遍历物体,然后找到所有使用相同贴图、相同材质、并且这些物体之间没有被夹层分开(这里的夹层是指与这些物体材质不相同的物体,但是会覆盖这些物体)。夹层的出现将会强制中断批处理。

就像在UGUI性能分析工具章节中提到的,可以使用 UI Profiler 和 Frame Debugger 来检查夹层。

这个问题最经常出现是当 Text 和 Sprite一个挨一个的放置:Text 组件的包围盒(这里是图形的包围盒,并不是 RectTransform 的边框)能够无形之中覆盖紧邻的 Sprite,因为多数 字符轮廓多边形是透明的。有两种方法可以解决这个为题:

  1. 对 UI 元素进行重新排序,将夹层从可批处理的两个对象之间移走。
  2. 移动物体的位置,使物体之间不再交叉覆盖。

在UnityEditor中,通过启用 UnityFrameDebuger可以检测到这两种操作。通过在 UnityFrameDebugger 中观察 drawcall 数量,有可能找到一个 drawcall 数量最少的排序方式和位置。

分割画布

除了一些非常简单的情况之外,通常将一个Canvas 分割开来是比较好的办法,将子元素移到子画布或者兄弟画布。

兄弟画布最佳用处是在,UI存在一个区域必须与其他区域的深度进行分离,如果在所有层级之上、或者之下。(例如新手指引的箭头)
在大多数其他情况下,使用子画布更加方便,因为子画布的设置直接从父画布进行继承。
看上去将 UI 分割成多个 子画布是一种不错的做法,但是请记住,批处理是不能跨 Canvas 进行的,一个良好的UI设计需要再 最小化重构消耗 和 最小化drawcall 进行平衡。

通用准则

因为当 Canvas 下的元素发生变化时,Canvas就需要重新批处理,所以通常最好是将 Canvas 分成至少两部分,此外,如果元素需要同时进行变化,最好将这些物体放到同一个Canvas下面,一个例子就是进度条和计时器,这些都依赖于相同的底层数据,需要同时进行变化,因此他们应该放到同一个画布上。

分成两部分的UI分别用两个Canvas 进行处理,一个 Canvas 用来放着静态的、不需要变化的物体,例如背景、Label,这样这个Canvas只需要在第一次显示时批处理一次,而后就不需要在进行重新批处理。另个 Canvas 用来放置 动态的、经常需要变化的物体,这可以确保通过这个Canvas 可以 重新批处理大部分的脏元素,如果动态元素数量变得非常多,可能需要将动态元素进行一步分组,例如不断变化的元素、偶尔变化的元素。

实际上在实践中是相当困难的,尤其是将UI控件封装到 预制体时,许多UI通过将比较耗性能的控件拆分到子画布上来细分Canvas。

Unity5.2 和 优化批处理

在Unity5.2版本中,批处理代码被大量重写,与Unity4.6、5.0、5.1版本相比,在性能上有了很大的提升,此外,在多核设备上,Unity将大部分处理移到工作线程上,总的来说,Unity5.2减少了将UI分割成大量子画布的需要,现在,在移动设备上许多 UI 可以只使用2、3个画布就可以实现性能要求。

更多关于5.2的优化内容,可以参考这篇文章.

UGUI 的输入和射线检测

默认情况下,UGUI 使用 Graphic Raycaster 组件来处理射线检测,输入信息是通过 Standalone Input Manager 来处理,该组件可以处理鼠标和触摸输入。

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

在Unity5.4之前,每个活动的 Canvas 上都挂载一个 Graphic Raycaster组件,并且每帧都需要检测一次光标的位置,即使没有有效的触摸输入。无论什么平台都会发生这种情况,没有鼠标的 IOS 和 Android 平台,仍然会检测鼠标位置以及鼠标之下的 UI 元素,并确定是否需要发送 Hover 事件。

这是非常浪费 CPU 的,已经被证实浪费了 5%或者更多的 CPU 时间。

这个问题在 Unity5.4 之后已经得到解决,在5.4之后,没有鼠标的设备将不再查询鼠标的位置,从而减少了不必要的射线检测。

如果使用5.4之前的版本,强烈建议移动端开发者创建自己的 Input Manager 来替代 Standard Input System,实现方法也很简单,只需要将 Standard Input Manager 源码进行拷贝,然后将 ProcessMouseEvent 方法注释掉,同时将该方法的调用也注释掉即可。

射线检测优化

Graphic Raycaster 组件的实现比较简单,检测所有 Raycast Target 设置为 true 的 Graphic 组件,对于每个射线检测目标,Raycaster会执行一系列测试,如果检测的目标通过所有的测试,则将该目标添加到 列表中(保存射线击中的物体)。

Raycast 实现细节,上面说的一系列测试包括:

  1. 如果目标是活动的、可以绘制的(包括 Graphic 组件)。
  2. 如果输入位置在目标的 RectTransform 组件范围之内。
  3. 如果检测目标包含 ICanvasRaycastFilter 组件,或者是该组件的子物体,那么需要 ICanvasRaycastFilter 允许进行射线检测。
  4. 当所有的物体检测完毕之后,在击中列表中的目标按照深度进行排序,过滤掉反向的目标,过滤掉在摄像机之后的目标。
  5. 如果设置了 blockingObjects选项,Graphic Raycaster 就可以检测 3D、2D的物理对象(Collider、2DCollider)。
  6. 如果2D、3D blockingObjects 启用了,位于 raycast blocking 指定的层中的2D、3D物体,将会从击中列表中移除。

最终确定下射线检测结果。

射线检测优化技巧

由于所有的射线检测必须由 Graphic Raycaster,所以最好的做法是只有那些必须接收UI事件的物体才开启 “Raycast Target”选项,需要进行射线检测的物体越少,层级也嵌套越少,则射线检测的速度越快。

对于必须相应 UI 事件并且拥有多个可绘制对象的复合型 UI 控件,例如 Button 包括一个IMage、一个Text,更好的做法是在复合控件的根物体上添加一个 Raycast Target,当根物体收到 UI 事件之后,可以转发到各个感兴趣的组件。

嵌套深度和射线过滤

在搜索射线过滤器的时候,每个 Graphic Raycaster 都会遍历 Transform 层次结构直到根物体。这种操作的成本与 Transform 的层次深度成正比,每个物体上的所有组件都必须进行测试,看看是否实现了 ICanvasRaycastFilter 接口,所以这不是一个便宜的操作。

实现 ICanvasRaycastFilter 接口的标准组件有几个,比如 CanvasGroup、Image、Mask、RectMask2D,所以不能简单的消除遍历。

子画布和 OverrideSorting 属性

子画布的 overrideSorting 属性将导致图形射线检测停止往深层次遍历,如果可以在不引起排序或者射线检测问题的情况下启用它,那么可以使用它来降低射线检测层次结构遍历成本。