全文解析圆形Image组件的实现原理,取关键代码介绍算法细节,源码已经上传Github下载地址,欢迎下载试用。git
许多游戏项目里免不了有不少图片是以圆形形式展现的,如头像,技能Icon等,通常作法是使用Image组件,再加上一个圆形的Mask。实现很是简单,但由于影响效率,许多关于ui方面的Unity效率优化文章,都会建议开发者少用Mask。github
Image+Mask的实现的圆形,点击判断不精确,点击到圆形外的四个边角仍会触发点击,虽然能够经过另外设置eventAlphaThreshold实现像素级判断,但这个方法有天生缺陷,并非好的选择。算法
了解了原有作法的缺陷后,咱们但愿自制圆形Image组件,解决这些问题,而且尽可能简单易用。c#
虽然说少用Mask,但游戏项目里总免不了有些图片要以圆形形式显示,不得不用,怎么办?转而从渲染层面思考,Image组件默认以矩形形式渲染,若是有办法定制一个特殊Image组件,从新写入圆形形状的渲染顶点、三角面片信息,根本不须要Mask就能渲染出圆形Image。框架
咱们看到的屏幕显示,是经过GPU渲染出来的,而GPU渲染以三角面片为最小单元。全部的图形画面,本质是由无数三角面片组成的,例如矩形是由两个直角三角面片组成的;圆形能够由若干个相同的以圆心为顶点的等腰三角面片组成正多边形,近似模拟出来。三角面片分得多了,多边形的边越多,夹角越大,就越近似圆形。ide
绿色圆圈由60个等腰三角面片构成,黄色圆圈由10个等腰三角形面片构成函数
组件再也不以像素Alpha值判断是否点击,而是用Ray-Crossing算法计算点击点是否在落多边形内,来实现精确点击。优化
Unity引擎并不开源,好在其中ugui框架是开源的,简单看下Image代码:ui
public class Image : MaskableGraphic, ISerializationCallbackReceiver, ILayoutElement, ICanvasRaycastFilter
Image类继承自MaskableGraphic,实现了ISerializationCallbackReceiver, ILayoutElement, ICanvasRaycastFilter这三个接口。最关键的是MaskableGraphic类,MaskableGraphic负责绘制逻辑,MaskableGraphic继承自Graphic,Graphic里有个OnPopulateMesh函数,这正是咱们须要的函数。code
当UI元素生成顶点数据时会调用OnPopulateMesh(VertexHelper vh)函数,咱们只要继承改写OnPopulateMesh函数,将原先的矩形顶点数据清除,改写入圆形顶点数据,这样渲染出来的天然是圆形图片。
咱们但愿这个圆形Image组件,可以自定义某些参数,好比自定义圆形等分面数(即由多少个三角形组成这个圆形),自定义圆形填充比例等。
因为Unity的限制,继承UnityEngine基类的派生类不能在Inspector里显示自定义参数。为了解决这点,咱们再造个小轮子,新建BaseImage类来代替Image类。原Image源码有近千行代码,BaseImage对其进行了部分精简,只支持Simple Image Type,并去掉了eventAlphaThreshold的相关代码。通过删减,获得一个百行代码的BaseImage类,精简版Image就完成了。
接着,新建CircleImage类继承BaseImage,重写OnPopulateMesh方法。
protected override void OnPopulateMesh(VertexHelper vh)
OnPopulateMesh方法的VertexHelper参数,保存着原来的顶点信息,由于要从新传入顶点信息,需先调用Clear方法,清除VertexHelper原有顶点信息。在计算顶点前,经过DataUtility.GetOuterUV(overrideSprite)获取贴图uv信息,简单计算得到中心点,缩放等信息。
protected override void OnPopulateMesh(VertexHelper vh) { vh.Clear(); Vector4 uv = overrideSprite != null ? DataUtility.GetOuterUV(overrideSprite) : Vector4.zero; float uvCenterX = (uv.x + uv.z) * 0.5f; float uvCenterY = (uv.y + uv.w) * 0.5f; float uvScaleX = (uv.z - uv.x) / tw; float uvScaleY = (uv.w - uv.y) / th; ... }
知道了等分面片数segements,咱们能够算出每一个面片的顶点夹角,面片数segements与填充比例fillPercent相乘,就知道要用多少个面片来显示圆形/扇形
float degreeDelta = (float)(2 * Mathf.PI / segements); int curSegements = (int)(segements * fillPercent);
经过RectTransform获取矩形宽高,计算出半径
float tw = rectTransform.rect.width; float th = rectTransform.rect.height; float outerRadius = rectTransform.pivot.x * tw;
已经有了半径,夹角信息,根据圆形点坐标公式(radius * cosA,radius * sinA)能够算出顶点坐标,每次迭代新建UIVertex,将求出的坐标,color,uv等参数传入,再将UIVertex传给VertexHelper。重复迭代n次,VertexHelper就得到了多边形顶点及圆心点信息了。
计算顶点、指定三角形
float curDegree = 0; UIVertex uiVertex; int verticeCount; int triangleCount; Vector2 curVertice; curVertice = Vector2.zero; verticeCount = curSegements + 1; uiVertex = new UIVertex(); uiVertex.color = color; uiVertex.position = curVertice; uiVertex.uv0 = new Vector2(curVertice.x * uvScaleX + uvCenterX, curVertice.y * uvScaleY + uvCenterY); vh.AddVert(uiVertex); for (int i = 1; i < verticeCount; i++) { float cosA = Mathf.Cos(curDegree); float sinA = Mathf.Sin(curDegree); curVertice = new Vector2(cosA * outerRadius, sinA * outerRadius); curDegree += degreeDelta; uiVertex = new UIVertex(); uiVertex.color = color; uiVertex.position = curVertice; uiVertex.uv0 = new Vector2(curVertice.x * uvScaleX + uvCenterX, curVertice.y * uvScaleY + uvCenterY); vh.AddVert(uiVertex); outterVertices.Add(curVertice); }
知道了全部顶点信息,仍不足以渲染图形,由于GPU还不知道顶点之间的关系,不知道这些顶点分红了多少个三角面片,因此还须要把全部三角形信息一一告诉GPU。VertexHelper是经过AddTriangle接口接受三角形信息:
public void AddTriangle(int idx0, int idx1, int idx2)
接口的传入参数并非UIVertex类型,而是int类型的索引值。哪来的索引?还记得以前往VertexHelper传入了一堆顶点吗?按照传入顺序,第一个顶点,索引记为0,依次类推。每次传入三个顶点的索引,就记录下了一个三角形。
须要注意,GPU 默认是作backface culling(背面剔除)的,GPU只渲染正对屏幕的三角面片,当GPU认为某个三角面片是背对屏幕时,直接丢弃该三角面片,不作渲染。那么GPU怎么判断咱们传入的某个三角形是正对屏幕,仍是背对屏幕?答案是经过三个顶点的时针顺序,当三个顶点是呈顺时针时,断定为正对屏幕;呈逆时针时,断定为背对屏幕。
左边的图中指定顶点的顺序是顺时针的,右边是逆时针的
VertexHelper收到的第一个顶点是圆心,且算法是按逆时针方向,迭代计算出的多边形顶点,并依次传给VertexHelper。所以按(i, 0, i+1)(i>=1)的规律取索引,就能够保证顶点顺序是顺时针的。
triangleCount = curSegements*3; for (int i = 0, vIdx = 1; i < triangleCount - 3; i += 3, vIdx++) { vh.AddTriangle(vIdx, 0, vIdx+1); } if (fillPercent == 1) { //首尾顶点相连 vh.AddTriangle(verticeCount - 1, 0, 1); }
到这里为止,咱们已经完成了绘制圆形的工做了。
考虑还有可能要以圆环形式显示,组件也作了支持。圆环的状况稍微复杂:顶点集没有圆心顶点了,只有内环、外环顶点;三角形集也不是简单的切饼式分割,采用一种比较直观的三角形划分,让内外环相邻的顶点相似一根鞋带那样互相链接,来划分三角形。
定义fill、thickness变量肯定是否填充图形、圆环宽度
[Tooltip("是否填充圆形")] public bool fill = true; [Tooltip("圆环宽度")] public float thickness = 5;
计算顶点、指定三角形
float tw = rectTransform.rect.width; float th = rectTransform.rect.height; float outerRadius = rectTransform.pivot.x * tw; float innerRadius = rectTransform.pivot.x * tw - thickness; float curDegree = 0; UIVertex uiVertex; int verticeCount; int triangleCount; Vector2 curVertice; verticeCount = curSegements*2; for (int i = 0; i < verticeCount; i += 2) { float cosA = Mathf.Cos(curDegree); float sinA = Mathf.Sin(curDegree); curDegree += degreeDelta; curVertice = new Vector3(cosA * innerRadius, sinA * innerRadius); uiVertex = new UIVertex(); uiVertex.color = color; uiVertex.position = curVertice; uiVertex.uv0 = new Vector2(curVertice.x * uvScaleX + uvCenterX, curVertice.y * uvScaleY + uvCenterY); vh.AddVert(uiVertex); innerVertices.Add(curVertice); curVertice = new Vector3(cosA * outerRadius, sinA * outerRadius); uiVertex = new UIVertex(); uiVertex.color = color; uiVertex.position = curVertice; uiVertex.uv0 = new Vector2(curVertice.x * uvScaleX + uvCenterX, curVertice.y * uvScaleY + uvCenterY); vh.AddVert(uiVertex); outterVertices.Add(curVertice); } triangleCount = curSegements*3*2; for (int i = 0, vIdx = 0; i < triangleCount - 6; i += 6, vIdx += 2) { vh.AddTriangle(vIdx+1, vIdx, vIdx+3); vh.AddTriangle(vIdx, vIdx + 2, vIdx + 3); } if (fillPercent == 1) { //首尾顶点相连 vh.AddTriangle(verticeCount - 1, verticeCount - 2, 1); vh.AddTriangle(verticeCount - 2, 0, 1); }
虽然咱们完成了圆形Image的绘制,但Unity仍是以图片矩形包围盒来判断点击。点击圆形以外4个边角区域,仍会断定点击,在要求精确点击的场景下就有问题了。
Unity自己提供了像素级点击判断方案,经过设置eventAlphaThreshold属性(在5.4以上版本中改成alphaHitTestMinimumThreshold),根据点击像素点是否已超过Alpha阈值来断定是否触发点击。然而这个美好的方案却有天生缺陷,要求传入图片Texture Type不能为默认的Sprite,需设置为Advanced,且需勾选上Read/Write Enabled,这样会致使图片占用双倍内存,且不能合并入图集。
综合效率和易用性,设置eventAlphaThreshold都不是一个合适的方案,那么有没有别的办法实现精确的点击判断?有的,换个角度思考,咱们只须要考虑点击区域是在多边形以内,仍是以外就能够了。这个问题早有人研究,抽象严谨地说,这个问题能够描述为“如何断定一点是否在给定顶点的不规则封闭区域内”,知乎上有相关回答。拾前人牙慧,咱们选用Ray-Crossing算法来断定屏幕点击是否落在多边形内。
Ray-Crossing算法大概思路是从指定点p发出一条射线,与多边形相交,倘若交点个数是奇数,说明点p落在多边形内,交点个数为偶数说明点p在多边形外。算法结论乍看难以理解,但在逻辑上是可证的。假设有条射线,从起始点向无穷远处延伸,无穷远处一定处于多边形以外;而射线从起始点出发与多边形相交的过程当中,射线尾端状态是呈二态性交替变化的,即在“多边形外<->多边形内”两种状态里交替变化,已知延长线的状态,经过交点个数就能够倒推出起始点的状态。
射线选取哪一个方向并无限制,但为了实现起来方便,考虑屏幕点击点为点p,向水平方向右侧发出射线的状况,那么顶点v1,v2组成的线段与射线如有交点q,则点q一定知足两个条件:
- v2.y < q.y = p.y > v1.y
- p.x < q.x
咱们根据这两个条件,逐一跟多边形线段求交点,并统计交点个数,最后判断奇偶便可得知点击点是否在圆形内。
public override bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera) { Sprite sprite = overrideSprite; if (sprite == null) return true; Vector2 local; RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, screenPoint, eventCamera, out local); return Contains(local, outterVertices, innerVertices); } private bool Contains(Vector2 p, List<Vector3> outterVertices, List<Vector3> innerVertices) { var crossNumber = 0; RayCrossing(p, innerVertices, ref crossNumber);//检测内环 RayCrossing(p, outterVertices, ref crossNumber);//检测外环 return (crossNumber & 1) == 1; } /// <summary> /// 使用RayCrossing算法判断点击点是否落在多边形里 /// </summary> /// <param name="p"></param> /// <param name="vertices"></param> /// <param name="crossNumber"></param> private void RayCrossing(Vector2 p, List<Vector3> vertices, ref int crossNumber) { for (int i = 0, count = vertices.Count; i < count; i++) { var v1 = vertices[i]; var v2 = vertices[(i + 1) % count]; //点击点水平线必须与两顶点线段相交 if (((v1.y <= p.y) && (v2.y > p.y)) || ((v1.y > p.y) && (v2.y <= p.y))) { //只考虑点击点右侧方向,点击点水平线与线段相交,且交点x > 点击点x,则crossNumber+1 if (p.x < v1.x + (p.y - v1.y) / (v2.y - v1.y) * (v2.x - v1.x)) { crossNumber += 1; } } } }
至此,一个可以灵活地以圆形,扇形,圆环形式展示图片的CircleImage组件就完成了,无须使用Mask,无须消耗额外Drawcall,不影响图集合并效率,且能实现精确点击。从新设置顶点,点击判断等逻辑的时间复杂度为O(n),与设置面片数相关,面片数最大支持设置到100,这个量级对运算效率几乎无影响,实际上,面片数设置为30已能达到较好效果。