丢掉Mask遮罩,更好的圆形Image组件[Unity]

写在前面#

全文解析圆形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已能达到较好效果。

posted @ 2017-02-22 13:15  立航  阅读(36693)  评论(16编辑  收藏  举报