写在前面#

全文解析圆形Image组件的实现原理,取关键代码介绍算法细节,源码已经上传Github下载地址,欢迎下载试用。

一、Unity原生Image组件实现圆形图片的缺陷#

Mask渲染消耗##

许多游戏项目里免不了有很多图片是以圆形形式展示的,如头像,技能Icon等,一般做法是使用Image组件,再加上一个圆形的Mask。实现非常简单,但因为影响效率,许多关于ui方面的Unity效率优化文章,都会建议开发者少用Mask。

  1. 使用Mask会额外消耗多一个Drawcall来创建Mask,做像素剔除。
  2. Mask不利于层级合并。原本同一图集里的ui可以合并层级,仅需一个Drawcall渲染,如果加入Mask,就会将一个ui整体分割成了Mask下的子ui与其他ui,两者只能各自进行层级合并,至少要两个Drawcall。Mask用得多了,一个ui整体会被分割得四分五裂,就会严重影响层次合并的效率了。

无法精确点击##

Image+Mask的实现的圆形,点击判断不精确,点击到圆形外的四个边角仍会触发点击,虽然可以通过另外设置eventAlphaThreshold实现像素级判断,但这个方法有天生缺陷,并不是好的选择。

二、应运而生的CircleImage组件#

了解了原有做法的缺陷后,我们希望自制圆形Image组件,解决这些问题,并且尽量简单易用。

干掉Mask##

虽说少用Mask,但游戏项目里总免不了有些图片要以圆形形式显示,不得不用,怎么办?转而从渲染层面思考,Image组件默认以矩形形式渲染,如果有办法定制一个特殊Image组件,重新写入圆形形状的渲染顶点、三角面片信息,根本不需要Mask就能渲染出圆形Image。

我们看到的屏幕显示,是通过GPU渲染出来的,而GPU渲染以三角面片为最小单元。所有的图形画面,本质是由无数三角面片组成的,例如矩形是由两个直角三角面片组成的;圆形可以由若干个相同的以圆心为顶点的等腰三角面片组成正多边形,近似模拟出来。三角面片分得多了,多边形的边越多,夹角越大,就越近似圆形。



绿色圆圈由60个等腰三角面片构成,黄色圆圈由10个等腰三角形面片构成

另一种精确点击方案##

组件不再以像素Alpha值判断是否点击,而是用Ray-Crossing算法计算点击点是否在落多边形内,来实现精确点击。

三、组件实现#

绘制圆形##

Unity引擎并不开源,好在其中ugui框架是开源的,简单看下Image代码:

  1. public class Image : MaskableGraphic, ISerializationCallbackReceiver, ILayoutElement, ICanvasRaycastFilter

Image类继承自MaskableGraphic,实现了ISerializationCallbackReceiver, ILayoutElement, ICanvasRaycastFilter这三个接口。最关键的是MaskableGraphic类,MaskableGraphic负责绘制逻辑,MaskableGraphic继承自Graphic,Graphic里有个OnPopulateMesh函数,这正是我们需要的函数。

当UI元素生成顶点数据时会调用OnPopulateMesh(VertexHelper vh)函数,我们只要继承改写OnPopulateMesh函数,将原先的矩形顶点数据清除,改写入圆形顶点数据,这样渲染出来的自然是圆形图片。

我们希望这个圆形Image组件,能够自定义某些参数,比如自定义圆形等分面数(即由多少个三角形组成这个圆形),自定义圆形填充比例等。

由于Unity的限制,继承UnityEngine基类的派生类不能在Inspector里显示自定义参数。为了解决这点,我们再造个小轮子,新建BaseImage类来代替Image类。原Image源码有近千行代码,BaseImage对其进行了部分精简,只支持Simple Image Type,并去掉了eventAlphaThreshold的相关代码。经过删减,得到一个百行代码的BaseImage类,精简版Image就完成了。

接着,新建CircleImage类继承BaseImage,重写OnPopulateMesh方法。

  1. protected override void OnPopulateMesh(VertexHelper vh)

OnPopulateMesh方法的VertexHelper参数,保存着原来的顶点信息,因为要重新传入顶点信息,需先调用Clear方法,清除VertexHelper原有顶点信息。在计算顶点前,通过DataUtility.GetOuterUV(overrideSprite)获取贴图uv信息,简单计算获得中心点,缩放等信息。

  1. protected override void OnPopulateMesh(VertexHelper vh)
  2. {
  3. vh.Clear();
  4. Vector4 uv = overrideSprite != null ? DataUtility.GetOuterUV(overrideSprite) : Vector4.zero;
  5. float uvCenterX = (uv.x + uv.z) * 0.5f;
  6. float uvCenterY = (uv.y + uv.w) * 0.5f;
  7. float uvScaleX = (uv.z - uv.x) / tw;
  8. float uvScaleY = (uv.w - uv.y) / th;
  9. ...
  10. }

知道了等分面片数segements,我们可以算出每个面片的顶点夹角,面片数segements与填充比例fillPercent相乘,就知道要用多少个面片来显示圆形/扇形

  1. float degreeDelta = (float)(2 * Mathf.PI / segements);
  2. int curSegements = (int)(segements * fillPercent);

通过RectTransform获取矩形宽高,计算出半径

  1. float tw = rectTransform.rect.width;
  2. float th = rectTransform.rect.height;
  3. float outerRadius = rectTransform.pivot.x * tw;

已经有了半径,夹角信息,根据圆形点坐标公式(radius * cosA,radius * sinA)可以算出顶点坐标,每次迭代新建UIVertex,将求出的坐标,color,uv等参数传入,再将UIVertex传给VertexHelper。重复迭代n次,VertexHelper就获得了多边形顶点及圆心点信息了。

计算顶点、指定三角形

  1. float curDegree = 0;
  2. UIVertex uiVertex;
  3. int verticeCount;
  4. int triangleCount;
  5. Vector2 curVertice;
  6. curVertice = Vector2.zero;
  7. verticeCount = curSegements + 1;
  8. uiVertex = new UIVertex();
  9. uiVertex.color = color;
  10. uiVertex.position = curVertice;
  11. uiVertex.uv0 = new Vector2(curVertice.x * uvScaleX + uvCenterX, curVertice.y * uvScaleY + uvCenterY);
  12. vh.AddVert(uiVertex);
  13. for (int i = 1; i < verticeCount; i++)
  14. {
  15. float cosA = Mathf.Cos(curDegree);
  16. float sinA = Mathf.Sin(curDegree);
  17. curVertice = new Vector2(cosA * outerRadius, sinA * outerRadius);
  18. curDegree += degreeDelta;
  19. uiVertex = new UIVertex();
  20. uiVertex.color = color;
  21. uiVertex.position = curVertice;
  22. uiVertex.uv0 = new Vector2(curVertice.x * uvScaleX + uvCenterX, curVertice.y * uvScaleY + uvCenterY);
  23. vh.AddVert(uiVertex);
  24. outterVertices.Add(curVertice);
  25. }

知道了所有顶点信息,仍不足以渲染图形,因为GPU还不知道顶点之间的关系,不知道这些顶点分成了多少个三角面片,所以还需要把所有三角形信息一一告诉GPU。VertexHelper是通过AddTriangle接口接受三角形信息:

  1. 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)的规律取索引,就可以保证顶点顺序是顺时针的。

  1. triangleCount = curSegements*3;
  2. for (int i = 0, vIdx = 1; i < triangleCount - 3; i += 3, vIdx++)
  3. {
  4. vh.AddTriangle(vIdx, 0, vIdx+1);
  5. }
  6. if (fillPercent == 1)
  7. {
  8. //首尾顶点相连
  9. vh.AddTriangle(verticeCount - 1, 0, 1);
  10. }

到这里为止,我们已经完成了绘制圆形的工作了。

绘制圆环##

考虑还有可能要以圆环形式显示,组件也做了支持。圆环的情况稍微复杂:顶点集没有圆心顶点了,只有内环、外环顶点;三角形集也不是简单的切饼式分割,采用一种比较直观的三角形划分,让内外环相邻的顶点类似一根鞋带那样互相连接,来划分三角形。

定义fill、thickness变量确定是否填充图形、圆环宽度

  1. [Tooltip("是否填充圆形")]
  2. public bool fill = true;
  3. [Tooltip("圆环宽度")]
  4. public float thickness = 5;

计算顶点、指定三角形

  1. float tw = rectTransform.rect.width;
  2. float th = rectTransform.rect.height;
  3. float outerRadius = rectTransform.pivot.x * tw;
  4. float innerRadius = rectTransform.pivot.x * tw - thickness;
  5. float curDegree = 0;
  6. UIVertex uiVertex;
  7. int verticeCount;
  8. int triangleCount;
  9. Vector2 curVertice;
  10. verticeCount = curSegements*2;
  11. for (int i = 0; i < verticeCount; i += 2)
  12. {
  13. float cosA = Mathf.Cos(curDegree);
  14. float sinA = Mathf.Sin(curDegree);
  15. curDegree += degreeDelta;
  16. curVertice = new Vector3(cosA * innerRadius, sinA * innerRadius);
  17. uiVertex = new UIVertex();
  18. uiVertex.color = color;
  19. uiVertex.position = curVertice;
  20. uiVertex.uv0 = new Vector2(curVertice.x * uvScaleX + uvCenterX, curVertice.y * uvScaleY + uvCenterY);
  21. vh.AddVert(uiVertex);
  22. innerVertices.Add(curVertice);
  23. curVertice = new Vector3(cosA * outerRadius, sinA * outerRadius);
  24. uiVertex = new UIVertex();
  25. uiVertex.color = color;
  26. uiVertex.position = curVertice;
  27. uiVertex.uv0 = new Vector2(curVertice.x * uvScaleX + uvCenterX, curVertice.y * uvScaleY + uvCenterY);
  28. vh.AddVert(uiVertex);
  29. outterVertices.Add(curVertice);
  30. }
  31. triangleCount = curSegements*3*2;
  32. for (int i = 0, vIdx = 0; i < triangleCount - 6; i += 6, vIdx += 2)
  33. {
  34. vh.AddTriangle(vIdx+1, vIdx, vIdx+3);
  35. vh.AddTriangle(vIdx, vIdx + 2, vIdx + 3);
  36. }
  37. if (fillPercent == 1)
  38. {
  39. //首尾顶点相连
  40. vh.AddTriangle(verticeCount - 1, verticeCount - 2, 1);
  41. vh.AddTriangle(verticeCount - 2, 0, 1);
  42. }

圆形Image的像素级点击判断##

虽然我们完成了圆形Image的绘制,但Unity还是以图片矩形包围盒来判断点击。点击圆形之外4个边角区域,仍会判定点击,在要求精确点击的场景下就有问题了。

Unity本身提供了像素级点击判断方案,通过设置eventAlphaThreshold属性(在5.4以上版本中改为alphaHitTestMinimumThreshold),根据点击像素点是否已超过Alpha阈值来判定是否触发点击。然而这个美好的方案却有天生缺陷,要求传入图片Texture Type不能为默认的Sprite,需设置为Advanced,且需勾选上Read/Write Enabled,这样会导致图片占用双倍内存,且不能合并入图集。



综合效率和易用性,设置eventAlphaThreshold都不是一个合适的方案,那么有没有别的办法实现精确的点击判断?有的,换个角度思考,我们只需要考虑点击区域是在多边形之内,还是之外就可以了。这个问题早有人研究,抽象严谨地说,这个问题可以描述为“如何判定一点是否在给定顶点的不规则封闭区域内”,知乎上有相关回答。拾前人牙慧,我们选用Ray-Crossing算法来判定屏幕点击是否落在多边形内。

Ray-Crossing算法###

Ray-Crossing算法大概思路是从指定点p发出一条射线,与多边形相交,假若交点个数是奇数,说明点p落在多边形内,交点个数为偶数说明点p在多边形外。算法结论乍看难以理解,但在逻辑上是可证的。假设有条射线,从起始点向无穷远处延伸,无穷远处必定处于多边形之外;而射线从起始点出发与多边形相交的过程中,射线尾端状态是呈二态性交替变化的,即在“多边形外<->多边形内”两种状态里交替变化,已知延长线的状态,通过交点个数就可以倒推出起始点的状态。

射线选取哪个方向并没有限制,但为了实现起来方便,考虑屏幕点击点为点p,向水平方向右侧发出射线的情况,那么顶点v1,v2组成的线段与射线若有交点q,则点q必定满足两个条件:

  1. v2.y < q.y = p.y > v1.y
  2. p.x < q.x

我们根据这两个条件,逐一跟多边形线段求交点,并统计交点个数,最后判断奇偶即可得知点击点是否在圆形内。

  1. public override bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera)
  2. {
  3. Sprite sprite = overrideSprite;
  4. if (sprite == null)
  5. return true;
  6. Vector2 local;
  7. RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, screenPoint, eventCamera, out local);
  8. return Contains(local, outterVertices, innerVertices);
  9. }
  10. private bool Contains(Vector2 p, List<Vector3> outterVertices, List<Vector3> innerVertices)
  11. {
  12. var crossNumber = 0;
  13. RayCrossing(p, innerVertices, ref crossNumber);//检测内环
  14. RayCrossing(p, outterVertices, ref crossNumber);//检测外环
  15. return (crossNumber & 1) == 1;
  16. }
  17. /// <summary>
  18. /// 使用RayCrossing算法判断点击点是否落在多边形里
  19. /// </summary>
  20. /// <param name="p"></param>
  21. /// <param name="vertices"></param>
  22. /// <param name="crossNumber"></param>
  23. private void RayCrossing(Vector2 p, List<Vector3> vertices, ref int crossNumber)
  24. {
  25. for (int i = 0, count = vertices.Count; i < count; i++)
  26. {
  27. var v1 = vertices[i];
  28. var v2 = vertices[(i + 1) % count];
  29. //点击点水平线必须与两顶点线段相交
  30. if (((v1.y <= p.y) && (v2.y > p.y))
  31. || ((v1.y > p.y) && (v2.y <= p.y)))
  32. {
  33. //只考虑点击点右侧方向,点击点水平线与线段相交,且交点x > 点击点x,则crossNumber+1
  34. if (p.x < v1.x + (p.y - v1.y) / (v2.y - v1.y) * (v2.x - v1.x))
  35. {
  36. crossNumber += 1;
  37. }
  38. }
  39. }
  40. }

至此,一个能够灵活地以圆形,扇形,圆环形式展现图片的CircleImage组件就完成了,无须使用Mask,无须消耗额外Drawcall,不影响图集合并效率,且能实现精确点击。重新设置顶点,点击判断等逻辑的时间复杂度为O(n),与设置面片数相关,面片数最大支持设置到100,这个量级对运算效率几乎无影响,实际上,面片数设置为30已能达到较好效果。

丢掉Mask遮罩,更好的圆形Image组件[Unity]的更多相关文章

  1. 画地为Mask,随心所欲的高效遮罩组件[Unity]

    在上一篇博文"扔掉遮罩,更好的圆形Image组件"中,笔者改变Image的顶点数据,使得Image呈圆形显示,避免了Mask的使用,从而节省Drawcall消耗,提高渲染效率了.这 ...

  2. vux 中popup 组件 Mask 遮罩在最上层问题的解决

    1. 问题描述:popup弹出层在遮罩层下面的 2.原因:因为滚动元素和mask遮罩层在同一级,vux框架默认把遮罩层放在body标签下的 3.解决方法:更改一下源码,把mask遮罩层放在popup同 ...

  3. UV纹理+修改器:VertexWeightEdit+修改器:Mask遮罩

    UV纹理+修改器: VertexWeightEdit+修改器: Mask遮罩 基本流程, 如下图,准备地图一份, 黑白色即可. 纹理使用颜色绘制权重. 白色为1, 黑色为0. 新增球体, 细分多次, ...

  4. css案例 - mask遮罩层的华丽写法

    mask遮罩蒙层使用通常的写法的bug 通常写法pug .mask 通常写法css .mask{ position: absolute; top: 0; right: 0; bottom: 0; le ...

  5. 用于mask遮罩效果的图片配合resizableImage使用

    用于mask遮罩效果的图片配合resizableImage使用 效果: 作为素材用的图片: 源码: // // ViewController.m // Rect // // Created by Yo ...

  6. vue----子组件引用vux popup mask遮罩在最上层解决办法 z-index问题

    在一个页面的子组件中引用vux的popup组件时,出现mask遮罩在最上层的问题,百度了一下发现有两种解决办法,现提供第三种. popup在子组件引用时,vux将vux-popup-mask默认添加到 ...

  7. Android 遮罩层效果--制作圆形头像

    (用别人的代码进行分析) 不知道在开发中有没有经常使用到这种效果,所谓的遮罩层就是给一张图片不是我们想要的形状,这个时候我们就可以使用遮罩效果把这个图片变成我们想要的形状,一般使用最多就是圆形的效果, ...

  8. CSS3 mask 遮罩蒙版效果

    mask demo效果演示:http://dtdxrk.github.io/game/css3-demo/mask.html mask 的属性: -webkit-mask-image:url | gr ...

  9. python 基于detectron或mask_rcnn的mask遮罩区域进行图片截取

    基于示例infer_simple.py 修改165行vis_utils.vis_one_image为vis_utils.vis_one_image_opencv 在detectron.utils.vi ...

随机推荐

  1. TSP问题(旅行商问题)[分支限界法]

    问题: 旅行商从 a 开始周游下图所有的城市一次,然后回到 a,城市之间的旅行代价在图中标明. 请选择一个最优的行走顺序使得周游所有城市的代价最小. 思路: 随便怎么周游,对于一个城市来说,一定有一条 ...

  2. 云脉推出表格识别API接口可以自助接入

    针对如今市场上对于海量票据信息的录入需求,近期厦门云脉技术有限公司推出票据识别相关的产品与服务,更是在云脉OCR SDK开发者平台上上线表格识别API接口,供广大开发者和集成商自助接入.为了降低财务系 ...

  3. wildfly 如何设置外网访问

    wildfly的默认配置是不支持外网访问的, 要想实现外网访问需要修改standalone.xml配置文件. 配置文件所在路径:wildfly/standalone/configuration/sta ...

  4. 对js原型对象的拓展和原型对象的重指向的区别的研究

    我写了如下两段代码: function Person(){} var p1 = new Person(); Person.prototype = { constructor: Person, name ...

  5. file_get_contents无法请求https连接的解决方法

    PHP.ini默认配置下,用file_get_contents读取https的链接,就会如下错误: Warning: fopen() [function.fopen]: Unable to find ...

  6. spring mvc 注解入门示例

    web.xml <?xml version="1.0" encoding="UTF-8"?> <web-app xmlns:xsi=" ...

  7. php之 有点复杂的 流程管理

    1.流程管理的用法是什么样的? 2.怎么发起想要的流程? 3.审批的人要是怎么审批通过? 4.流程审核是不是要挨个走过? 一.还是要有数据库的内容的 肯定会有表的,首先就是用户表了,然后就是流程表,用 ...

  8. python 的日志相关应用

    python日志主要用logging模块; 示例代码如下: #coding:utf-8 import logging class logger(): ''' %(asctime)s %(filenam ...

  9. linux脚本错误: line *: [: missing `]'

    错误: line *: [: missing `]' 写脚本时,我碰到这个问题是因为if [ ]; ...else...fi语句 解决方法: if后面的[] (test) 和条件要有空格,如: 对于语 ...

  10. 在ubuntu下编写python(python入门)

    在ubuntu下编写python 一般情况下,ubuntu已经安装了python,打开终端,直接输入python,即可进行python编写. 默认为python2 如果想写python3,在终端输入p ...