写在前面#

全文解析圆形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代码:

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方法。

    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的像素级点击判断##

虽然我们完成了圆形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

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

    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已能达到较好效果。

丢掉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. Uber广州车主官网本周将暂关闭

    Uber广州车主官网本周将暂关闭 http://news.southcn.com/g/2015-05/04/content_123509931.htm

  2. 解析xml的问题未解决

    工作上需要解析xml,目前的逻辑是:解析xml到对象中,然后对象再插入数据库.但这存在内存溢出的风险. 今天做的另外一件事是将循环用到工作上,暂时还没有测试,是否能保证程序的重启.有待观察 ##### ...

  3. Myeclipse SVN 修改用户名和密码

    转自:http://blog.csdn.net/chow__zh/article/details/7731497 解决方案: 在Eclipse使用SVN的过程中大多数人往往习惯把访问SVN的用户名密码 ...

  4. 使用nodejs爬取和讯网高管增减持数据

    为了抓取和讯网高管增减持的数据,首先得分析一下数据的来源: 网址: http://stockdata.stock.hexun.com/ggzjc/history.shtml 使用chrome开发者工具 ...

  5. UVa 103 - Stacking Boxes

    题目大意:矩阵嵌套,不过维数是多维的.有两个个k维的盒子A(a1, a1...ak), B(b1, b2...bk),若能找到(a1...ak)的一个排列使得ai < bi,则盒子A可嵌套在盒子 ...

  6. redhat5安装phantomjs

    Linux 64-bit Download phantomjs-2.1.1-linux-x86_64.tar.bz2 (22.3 MB) and extract the content. Note: ...

  7. Delphi-IP地址的隐藏

    IP地址的隐藏 一.前言 本文主要介绍如何在程序中实现IP地址的隐藏.其实这篇东西不算我写的.其中<IP头结构>部分我懒得打字,故复制.粘贴了孤独剑客的文章,先说声谢谢!代码部分参考了外国 ...

  8. js原生设计模式——12装饰者模式

    1.面向对象模式装饰者 <!DOCTYPE html><html lang="en"><head>    <meta charset=&q ...

  9. Struts2 属性驱动、模型驱动、异常机制

    模型驱动使用单独的VO(值对象)来封装请求参数和处理结果,属性驱动则使用Action实例来封装请求参数和处理结果. 一.使用模型驱动 1.login.action采用模型驱动模式实现,采用模型驱动时必 ...

  10. C# Unity游戏开发——Excel中的数据是如何到游戏中的 (一)

    引言 现在做游戏开发的没有几个不用Excel的,用的最多的就是策划.尤其是数值策划,Excel为用户提供强大的工具,各种快捷键,各种插件,各种函数.但是作为程序来说其实关注的不是Excel而是它最终形 ...