前言 笔者截止到写本篇博客为止,有两年在游戏公司开发Unity客户端的经历。工作内容偶尔会涉及到UI相关的业务逻辑,由于UI是游戏必不可少的部分,所以也会经常接触到UI的优化,以下简述一些UI优化的常用方法。
内容 01 | raycast 勾选过多 通过UGUI的源码我们可以知道,UI事件会在EventSystem中Update的Process触发。UGUI会遍历屏幕中的RaycastTarget是true的UI,接着就会发射线,并且排序找到玩家最先触发的那个UI,再抛出事件给逻辑层去响应。
团队多人在开发游戏界面,很多时候都是复制黏贴,比如上一个图片是需要响应RaycastTarget,然后ctrl+d以后复制出来的也就带了这个属性,很可能新复制出来的图片是不需要响应的,开发人员又没有取消勾选掉,这就出问题了。
所以RaycastTarget如果被勾选的过多的话, 效率必然会低。所以我们应该想一个办法去解决它。
把下面代码挂在游戏中的任意GameObject上,原理其实很简单就是绘制辅助线,当UI中RaycastTarget发生变化,SceneView中的蓝色辅助线也会刷新,还是挺方便的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 #if UNITY_EDITOR using UnityEngine;using System.Collections;using UnityEngine.UI;public class DebugUILine : MonoBehaviour { static Vector3[] fourCorners = new Vector3[4 ]; public bool showLine = false ; void OnDrawGizmos () { foreach (MaskableGraphic g in GameObject.FindObjectsOfType<MaskableGraphic>()) { if (g.raycastTarget && showLine) { RectTransform rectTransform = g.transform as RectTransform; rectTransform.GetWorldCorners(fourCorners); Gizmos.color = Color.blue; for (int i = 0 ; i < 4 ; i++) Gizmos.DrawLine(fourCorners[i], fourCorners[(i + 1 ) % 4 ]); } } } } #endif
如下图所示,加上上面的脚本后,可以在scene试图中直接看到,蓝色框表示的就是勾选过RaycastTarget的UI。有了辅助框后,如下图所示,就可以很方便的把不需要响应的RaycastTarget去掉即可。
02 | 避免频繁调用GameObject.SetActive 如果你隐藏一个较大的UI界面,下面有很多子节点,也会有很大的开销.解决办法可以对频繁切换激活状态的UI采用平移出屏幕、修改Layer 等方式来替换。另外也不要对Canvas做频繁的Enable操作,重新激活Canvas以及它的子Canvas,会执行重新构建(Rebuild) 以及重新批处理(Rebatch)操作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 function Control:set_optimized_active (on) self .optimized_active = on end function Control:set_active (active) if self .optimized_active then if not active then self .ori_pos = self .game_object.transform.localPosition self .game_object.transform.localPosition = Vector3(-3000 , 0 , 0 ) else if self .ori_pos then self .game_object.transform.localPosition = self .ori_pos end end return true elseif isValidObject(self .game_object) then self .game_object:SetActive(active) return true end return false end
03 | UI的布局 UI的布局层级不要太深入,要动静分离 ,可活动的元素放在一个Canvas下,不可活动的元素放在另一个Canvas下。虽然两个Canvas打断了合批,但是却减少了网格的重建时间,总体上是有优化的,所以一般建议每个较复杂的UI界面,都自成一个Canvas(可以是子Canvas),在UI界面很复杂时,甚至要划分更多的子Canvas。但是,Canvas又不能细分的太多,因为会导致Draw Call的上升。
很多人在创建button时用很多不可见的Image作为交互响应的控件,这些image虽然被alpha被设置为0不可见,但是DrawCall依然存在 解决办法实现一个只在逻辑上响应Raycast但是不参与绘制的组件 即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 using UnityEngine;using System.Collections;namespace UnityEngine.UI { public class Empty4Raycast : MaskableGraphic { protected Empty4Raycast () { useLegacyMeshGeneration = false ; } protected override void OnPopulateMesh (VertexHelper toFill ) { toFill.Clear(); } } }
05 | 关于图集 可以采用以下的几种方向来缩小图集的大小,降低加载解析的时间。
合理的按照模块分开打包图集
压缩单个图片大小或者质量
更紧密的打包图集方式
重用素材
06 | 镜像复制
以上是皇室战争的一个场景,如果你对UI的素材敏感,你可能一眼就知道最下面的红色丝带将是你最不想见到的素材。尺寸大、纹理变化大(渐变)、曲线设计。这就表示了,这个红色丝带图不能使用压缩尺寸、压缩颜色丰富度和使用九宫缩放来减小图片大小。
可是你再认真看看,你会发觉,它是左右对称的。对的,在游戏开发的时候,经常会遇到这种对称的UI素材。一般这种情况,我们只需要用其中一部分(1/2,1/4),然后使用镜像复制等,我们就可以把整个UI拼出来了。
当然,这样做唯一的缺点就是三角面和顶点数的增加,不过大多数的情况下,增加不了多少,所以对性能影响并不大。
首先,我们拿到我们的素材。然后进行1/2、1/4的裁切。
接下来就开始考虑用什么方法做镜像复制了。
两个Image组件组合
重写/修改Image或者MaskableGraphic
使用BaseMeshEffect扩展
第一种方案最容易,不用修改任何代码,只需要使用两个Image,然后利用RectTransform进行位置和缩放就可以达到效果,然而这种做法每次修改时都需要繁琐的修改参数。
第二种方案需要改动大量的代码,而且需要对Image的逻辑要有一定的理解。
第三种方案,UI里面的Outline和Shadow组件就是使用BaseMeshEffect进行扩展的,BaseMeshEffect是对Graphic的顶点进行修改,而且可以实现动态添加和拆除。是UGUI原框架提供给我们扩展功能的方法。
综合上面的优缺点,最后选择使用第三种的方案进行扩展是比较方便的。 首先是创建一个Mirror类,继承BaseMeshEffect,然后创建镜像类型的枚举值和相应的字段和变量。
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 [AddComponentMenu("UI/Effects/Mirror" , 20) ] [RequireComponent(typeof(Graphic)) ] public class Mirror : BaseMeshEffect { public enum MirrorType { Horizontal, Vertical, Quarter, } [SerializeField ] private MirrorType m_MirrorType = MirrorType.Horizontal; public MirrorType mirrorType { get { return m_MirrorType; } set { if (m_MirrorType != value ) { m_MirrorType = value ; if (graphic != null ){ graphic.SetVerticesDirty(); } } } } [NonSerialized ] private RectTransform m_RectTransform; public RectTransform rectTransform { get { return m_RectTransform ?? (m_RectTransform = GetComponent<RectTransform>()); } } }
接下来是创建一个SetNativeSize方法,这个方法跟Image.SetNativeSize()是一样的,都是根据原始的Sprite尺寸变更Image尺寸,只是这里会有一点不同,就是素材是裁切了一半的,我们要相应的把尺寸翻倍。
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 public void SetNativeSize (){ if (graphic != null && graphic is Image) { Sprite overrideSprite = (graphic as Image).overrideSprite; if (overrideSprite != null ){ float w = overrideSprite.rect.width / (graphic as Image).pixelsPerUnit; float h = overrideSprite.rect.height / (graphic as Image).pixelsPerUnit; rectTransform.anchorMax = rectTransform.anchorMin; switch (m_MirrorType) { case MirrorType.Horizontal: rectTransform.sizeDelta = new Vector2(w * 2 , h); break ; case MirrorType.Vertical: rectTransform.sizeDelta = new Vector2(w, h * 2 ); break ; case MirrorType.Quarter: rectTransform.sizeDelta = new Vector2(w * 2 , h * 2 ); break ; } graphic.SetVerticesDirty(); } } }
接下来是BaseMeshEffect的最重要的方法了————ModifyMesh()。这个方法会获得Image里面的顶点数据VertexHelper。 然后我们就可以对这个VertexHelper进行加工了。 首先使用VertexHelper.GetUIVertexStream()方法获取所有的顶点数据,然后调用相应的镜像处理函数,最后使用AddUIVertexTriangleStream()方法把顶点数据写入。 然后我们就开始对Simple类型的图像进行镜像操作。
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 public override void ModifyMesh (VertexHelper vh ){ if (!IsActive()) { return ; } var output = ListPool<UIVertex>.Get(); vh.GetUIVertexStream(output); int count = output.Count; if (graphic is Image) { Image.Type type = (graphic as Image).type; switch (type) { case Image.Type.Simple: DrawSimple(output, count); break ; case Image.Type.Sliced: break ; case Image.Type.Tiled: break ; case Image.Type.Filled: break ; } } else { DrawSimple(output, count); } vh.Clear(); vh.AddUIVertexTriangleStream(output); ListPool<UIVertex>.Recycle(output); }
这里引用了一个数组对象池的类:ListPool。由于可能会大量使用到List,所以使用对象池,有效减少内存分配 。你可以替换成自己的对象池,也可以使用本实例里面的ListPool。 本实例的ListPool与UGUI源码的ListPool逻辑是一样的,只是修改了部分命名。 然后是镜像的主要逻辑DrawSimple。 我们先来看看下面的图解,了解镜像的过程。
使用SetNativeSize()后,得到拉伸的图片。(步骤1)
接着调用graphic.GetPixelAdjustedRect()方法,获取当前Graphic矩形绘制范围,这个很重要,后面用来做顶点的偏移。
然后会调用SimpleScale()方法,将原始顶点进行缩放。(步骤2)
然后根据相应的镜像类型,进行不同的镜像操作。
镜像前先调用ExtendCapacity()对List进行扩容,例如水平镜像和垂直镜像是原来顶点数的两倍,四分之一镜像的顶点数是原来的四倍。
最后就是调用MirrorVerts(),这是对顶点进行复制和水平(垂直)翻转的函数。(步骤3)
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 protected void DrawSimple (List<UIVertex> output, int count ){ Rect rect = graphic.GetPixelAdjustedRect(); SimpleScale(rect, output, count); switch (m_MirrorType) { case MirrorType.Horizontal: ExtendCapacity(output, count); MirrorVerts(rect, output, count, true ); break ; case MirrorType.Vertical: ExtendCapacity(output, count); MirrorVerts(rect, output, count, false ); break ; case MirrorType.Quarter: ExtendCapacity(output, count * 3 ); MirrorVerts(rect, output, count, true ); MirrorVerts(rect, output, count * 2 , false ); break ; } }
List扩容只是判断一下容器大小,然后赋值就行了。这是减少多次List.Add()可能出现的性能消耗。
1 2 3 4 5 6 7 8 protected void ExtendCapacity (List<UIVertex> verts, int addCount ){ var neededCapacity = verts.Count + addCount; if (verts.Capacity < neededCapacity) { verts.Capacity = neededCapacity; } }
然后是原始顶点的缩放了,看了上面的步骤图,步骤2是把所有顶点往左边挤,其实就是顶点横坐标相对于绘制区最左边宽度减半。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 protected void SimpleScale (Rect rect, List<UIVertex> verts, int count ){ for (int i = 0 ; i < count; i++) { UIVertex vertex = verts[i]; Vector3 position = vertex.position; if (m_MirrorType == MirrorType.Horizontal || m_MirrorType == MirrorType.Quarter) { position.x = (position.x + rect.x) * 0.5f ; } if (m_MirrorType == MirrorType.Vertical || m_MirrorType == MirrorType.Quarter) { position.y = (position.y + rect.y) * 0.5f ; } vertex.position = position; verts[i] = vertex; } }
减半的核心算法就是这两条
$position.x = (position.x + rect.x) * 0.5f;$
$position.y = (position.y + rect.y) * 0.5f;$
然后把顶点复制一份,以rect.center为对称轴,进行翻转,就可以把右边的部分绘制出来了。 根据参数isHorizontal判断是水平还是垂直翻转。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 protected void MirrorVerts (Rect rect, List<UIVertex> verts, int count, bool isHorizontal = true ){ for (int i = 0 ; i < count; i++) { UIVertex vertex = verts[i]; Vector3 position = vertex.position; if (isHorizontal) { position.x = rect.center.x * 2 - position.x; } else { position.y = rect.center.y * 2 - position.y; } vertex.position = position; verts.Add(vertex); } }
水平(垂直)翻转的核心算法就是这两条
$position.x = rect.center.x * 2 - postion.x;$
$position.y = rect.center.y * 2 - postion.y;$
根据上面的代码,垂直和1/4镜像也是没问题的。 1/4镜像的做法就是先垂直(水平)镜像复制,然后再水平(垂直)复制完成。
好了,Simple的镜像类就完成了。