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

在上一篇博文"扔掉遮罩,更好的圆形Image组件"中,笔者改变Image的顶点数据,使得Image呈圆形显示,避免了Mask的使用,从而节省Drawcall消耗,提升渲染效率了。这也启发了笔者,有没有可能经过一样原理实现Mask,作到在某些须要显示特定形状Icon的场景下,替代Unity原生Mask,且能保有节省Drawcall,减小渲染像素点,实现精确点击等优势?通过一番折腾,就有了MeshMask组件。php

组件效果

MeshMask遮罩效果图
MeshMask遮罩效果图git

能够看到不管Mask形状是凸边形仍是复杂的凹边形,都能准确地将Mask形状数据序列化成顶点,面片数据,
提供给须要Mask的图片修改渲染顶点,达到遮罩效果。组件用法相似于Unity Mask,且效率优于Unity Mask。插件已上传至Github[点击下载], 欢迎试用~github

效率对比

使用原生Mask,10个Icon占用了15个Drawcall
使用原生Mask,10个Icon占用了15个Drawcall算法

使用MeshMask,10个Icon仅占用1个Drawcall
使用MeshMask,10个Icon仅占用1个Drawcallide

Scene切换到Overdraw模式:红框为Mask的Overdraw;蓝框为MeshMask的Overdraw
Scene切换到Overdraw模式:红框为Mask的Overdraw;蓝框为MeshMask的Overdraw工具

从上面三张图能够看到MeshMask相比Unity的Mask,在减小Drawcall消耗、Overdraw消耗等两方面都是完胜的。性能

Drawcall消耗

这10个icon都打包在同一图集的,使用Unity Mask,没办法享受图层合并,消耗了15个Drawcall;使用MeshMask的状况下,看截图里Batches为2,除去摄像机占用的1个Batch,10个icon仅占用1个Batch,即1个Drawcall。在Drawcall资源如此昂贵的状况下(通常机器都会要求Drawcall在200如下),这种性能节省效果很是显著。ui

Overdraw消耗

而看图三的Overdraw,使用Unity Mask的红框部分,被Mask的图片所有绘制一次,Unity Mask再作像素剔除,被Mask的部分又绘制了一次,总共须要绘制两次,且有一次是绘制了彻底用不到的区域。使用MeshMask的蓝框部分,由于是靠改变顶点绘制出来的icon,所以仅有被Mask部分被绘制了一次。this

面片消耗

固然,使用MeshMask的Image须要消耗比普通Image多一些的顶点和面片,观察Stats面板,使用MeshMsk的10个icon多占用1.3K的顶点和面片,即1个icon占用130个顶点,面片。然而GPU渲染顶点,面片的效率很是高(市面手机GPU渲染多边形数基本上2000-10000+万多边形/每秒以上),这点消耗跟Drawcall比起来就微不足道了。pwa

小结

在渲染上,GPU、CPU二者的性能瓶颈每每是CPU;GPU的性能瓶颈每每是像素点填充率(Overdraw致使),CPU的性能瓶颈每每是Drawcall。因此,渲染性能排查,几项指标关注优先级应该是:Drawcall > Overdraw > 面片

组件使用

MeshMask插件目录结构
MeshMask插件目录结构

插件里有MeshMask、MeshImage、MeshButton三个UI组件

MeshMask组件Inspector面板
MeshMask组件Inspector面板

MeshMask组件做用相似Unity Mask,依赖了Image及PolygonCollider2D组件,带有[根据Image组件生成Mask]、[根据Collider组件生成Mask]两个菜单项,支持两种方式生成Mask数据。

被遮罩GameOjecct的Inspector面板
被遮罩GameOjecct的Inspector面板

MeshImage、MeshButton组件挂在须要被遮罩的GameObject上,设置好MeshMask对象,就能得到数据,实现遮罩或者精确点击。

组件实现

不一样于CircleImage,只须要简单的对圆形进行顶点,面片计算;MeshMask要考虑几个点:

  1. 须要能对全部可能的图形进行顶点,面片计算。
  2. 考虑顶点,面片计算须要读取Image,且有必定性能开销,因此不能在Run-time中实时计算数据,须要预先计算好vertices,triangle数据,并序列化存放在GameObject中,运行时读取。
  3. 保证MeshMask灵活性,除了根据Image进行顶点,面片计算,但愿像PS同样,提供路径工具,让开发能够可视化地新增、修改Mask形状。
  4. 对全部图形支持像素级点击判断

其中作顶点,面片计算这一步比较麻烦,涉及如下几个技术点:

图片处理流程
图片处理流程

边缘检测

边缘检测算法算是图形学应用最普遍最基础的算法了,主要原理是滤波器对图形进行滤波从而获得梯度图像,经过判断梯度图像的某像素点灰度值是否超过阈值,就能判断该点是否为边缘点。笔者采用了简单的Sobel算子边缘检测算法。

Sobel算子:3x3的矩形滤波器
Sobel算子:3x3的矩形滤波器

A表明原始图像,Gx及Gy分别表明经横向及纵向边缘检测的图像灰度值
A表明原始图像,Gx及Gy分别表明经横向及纵向边缘检测的图像灰度值

图像某像素点灰度值
图像某像素点灰度值

一般,为了提升效率 使用不开平方的近似值
一般,为了提升效率 使用不开平方的近似值

这里拿米老鼠图来作示例图,看看Sobel边缘检测的效果。
原图
原图

sobel边缘检测后的灰度图
sobel边缘检测后的灰度图

能够看到算法效果不错,但咱们并不须要这么多边缘“信息”,只须要最外围的边缘“信息”。所以将非透明区域都填充成统一的颜色,再作边缘检测。

最终效果:理想的外围边缘
最终效果:理想的外围边缘

离散化

得到了外围边缘信息后,下一步须要作离散化:剔除冗余信息,并将边缘信息以有序集合的形式表示。这个有序集合,就是渲染底层所须要的顶点数据。

冗余顶点:对于边缘的直线,除直线首尾两点外,其余点都是冗余可剔除的。
有序集合:集合点依次链接起来,就如同用笔按逆时针/顺时针方向画出来的边缘图形。

笔者挑选了边缘点集中x最小的点做为起始点,以顺时针顺序查找邻接点的方法来计算有序顶点集。

算法步骤:

  1. 选择边缘点集x最小的点为起始点,当前点
  2. 查找当前点周边8个像素点是否有边缘点,如都没有就继续向外围一圈,直到找到边缘点。
  3. 当找到多个边缘点状况下,比较当前点与各边缘点所呈夹角,选夹角最小的边缘点做为邻接点。
  4. 若邻接点即为起始点,则算法结束,不然继续
  5. 判断邻接点与有序顶点集最后一个点是否共边,若共边则删除最后一个点
  6. 将邻接点加入有序顶点集
  7. 设置邻接点为当前点,重复步骤2

删除共边顶点图示:当C即将加入顶点集中,发现ABC三点共边的状况,删除中间点B
删除共边顶点图示:当C即将加入顶点集中,发现ABC三点共边的状况,删除中间点B

三角化

三角化(Triangulation)也是图形学应用较多的算法了,特别是在3D建模、游戏领域。三角化是指从一组已知点集中,构建出三角形网格。随着构建条件不一样,三角化算法也不一样。像最近LowPoly绘画风格比较热门,一些滤镜软件会支持LowPoly转换。软件在将一张普通图像转换位LowPoly图像的过程当中,除了同样要作边缘检测,离散化外,在三角化这一步,须要生成显示质量较高的三角形,不能有过于狭长的三角形,就须要用Delaunay算法。在咱们这个场景下,对生成的三角形并无特殊要求,不须要用上复杂的Delaunay算法,Unity3d wiki社区上提供了一个简单的三角化算法,恰好适用。

算法原理
从点集中随机挑选三点组成三角形,而后遍历其余点,看是否有点落在三角形内,若是三角形内无点则为合格三角形。循环此过程直到全部点都被处理。

可视化编辑

通过前面处理,咱们已经拿到了顶点数据、面片数据。笔者但愿组件能将这些顶点数据可视化,以便让使用者直观了解处理结果。Unity自带的PolygonCollider2D组件,正好适用。

public sealed class PolygonCollider2D : Collider2D
{
      ....
      public void SetPath(int index, Vector2[] points);
}

经过SetPath接口将顶点数据传入PolygonCollider2D 组件,PolygonCollider2D完美地生成米老鼠的路径。在一开始实验中,笔者惊奇地发现组件居然也对顶点作了三角化处理。遗憾地是,组件并无提供接口获取三角化结果,Unity社区的技术人员也认可此点,说Unity的将来版本可能会考虑暴露此接口,并建议本身作三角化处理,就是前面所说的算法(汗.. = . = ||)。经过下图比较,能够看到组件跟算法的三角化结果仍是有所不一样的。

顶点数据传入PolygonCollider2D后的效果
顶点数据传入PolygonCollider2D后的效果

算法处理后的三角化效果
算法处理后的三角化效果

利用PolygonCollider2D组件除了让咱们能够看到顶点结果,还能够经过Inspector上的[Edit Collider]按钮微调,顶点的位置,作出更理想的Mask效果。
甚至,咱们能够直接利用PolygonCollider2D组件,从无到有地编辑Mask形状后,再三角化处理得到面片数据。

直接用PolygonCollider2D编辑出来的“爱心”
直接用PolygonCollider2D编辑出来的“爱心”

渲染

已经有了顶点数据,面片数据,终于到了最后的渲染步骤。笔者利用MeshMask组件存放这些数据,并不直接渲染MeshMask,而是在MeshMask子节点下添加MeshImage组件,进行修改顶点渲染。
在5.3版本里,Unity提供了BaseMeshEffect类,是Unity提供给开发者用于给Graphic进行二次修改绘制的类,咱们能够在ModifyMesh方法中修改VertexHelper携带的顶点,面片,uv等数据来改变渲染。(在5.3以前的版本,对应的类和接口是BaseVertexEffect、ModifyVertices)
MeshImage继承BaseMeshEffect,在ModifyMesh里先将VertexHelper的原有数据清空,获取MeshMask的顶点、面片数据,通过坐标转换后将再传给VertexHelper。

public abstract class BaseMeshEffect : UIBehaviour, IMeshModifier
{
      public abstract void ModifyMesh(VertexHelper vh);
}

public class MeshImage : BaseMeshEffect{
  
  ...
  public override void ModifyMesh(VertexHelper vh)
  {
    if (this.enabled)
    {
        vh.Clear();
        _uiVertices.Clear();
        if (mask)
        {
            if (mask.vertices != null && mask.triangles != null)
            {
                float tw = image.rectTransform.rect.width;
                float th = image.rectTransform.rect.height;
                Vector4 uv = image.overrideSprite != null ? DataUtility.GetOuterUV(image.overrideSprite) : Vector4.zero;
                float uvCenterX = (uv.x + uv.z) * image.rectTransform.pivot.x;
                float uvCenterY = (uv.y + uv.w) * image.rectTransform.pivot.y;
                float uvScaleX = (uv.z - uv.x) / tw;
                float uvScaleY = (uv.w - uv.y) / th;
                List<Vector3> vertices = this.mask.vertices.Select(
                    x => { return this.transform.InverseTransformPoint(this.mask.transform.TransformPoint(x)); }).ToList();

                for (int i = 0; i < mask.vertices.Count; i++)
                {
                    UIVertex v = new UIVertex();
                    v.color = image.color;
                    v.position = vertices[i];
                    v.uv0 = new Vector2(v.position.x * uvScaleX + uvCenterX, v.position.y * uvScaleY + uvCenterY);
                    _uiVertices.Add(v);
                }

                vh.AddUIVertexStream(_uiVertices, mask.triangles);
            }
        }
    }
  }
}

拖动MeshImage的位置,图片外显区域始终限定在米老鼠Mask内
拖动MeshImage的位置,图片外显区域始终限定在米老鼠Mask内

像素级精确点击

如上篇博文所讲,为了实现精确点击,Unity提供了eventAlphaThreshold字段,但有着Sprite占用双倍内存,没法合入图集等缺陷。而MeshButton组件正好解决了痛点。MeshButton实现ICanvasRaycastFilter接口类,实现IsRaycastLocationValid方法,在方法内获取MeshMask的顶点数据,经过Ray-Crossing算法就能够判断点击点是否在区域内。

public class MeshButton : UIBehaviour, ICanvasRaycastFilter
{
    public virtual bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera){

        //Stopwatch sw = new Stopwatch();
        //sw.Start();

        Sprite sprite = image.overrideSprite;
        if (sprite == null)
            return true;

        bool ret = true;
        if (this.mask != null && this.mask.vertices != null)
        {
            Vector2 local;
            RectTransformUtility.ScreenPointToLocalPointInRectangle(image.rectTransform, screenPoint, eventCamera, out local);

            List<Vector2> vertices = this.mask.vertices.Select(
                x =>
                {
                    Vector3 p = this.transform.InverseTransformPoint(this.mask.transform.TransformPoint(x));
                    return new Vector2(p.x, p.y);
                }).ToList();

            ret = ImageUtil.Contains(local, vertices);
        }

        //sw.Stop();
        //UnityEngine.Debug.Log("点击检测耗时:" + sw.ElapsedTicks + " tick");

        return ret;
    }
}

关于MeshMask

  1. MeshMask组件适合用来显示特殊形状的Icon。MeshMask并不能彻底取代Unity Mask,在须要显示特殊形状Icon时做为Unity Mask的替代方案,能达到提升渲染效率的目的,减小Unity Mask的没必要要使用。
  2. 被Mask的图片若是被移出Mask范围外,会由于Sprite Wrap mode而出现边缘像素拉伸,或者贴图重复的问题,这个问题暂时不能很好解决,由于Sprite Wrap mode必须设置为clamp或者repeat,就会出现这种问题。只能设置为clamp后,人为为贴图边缘留1px的透明边解决。好在,作特殊形状Icon的使用场景下,基本无须担忧这个问题。
相关文章
相关标签/搜索