Unity填坑笔记(三)—ugui中针对单独图片的Mask优化

Unity填坑笔记(三)—ugui中针对单独图片的Mask优化

Unity里提供了不少功能都有种“磨人的小妖精”的感觉——在你做Demo或者UI需要效果的时候,它扭着妖娆的腰,唱着诱人的歌吸引着你,“来吧~来享用我吧~你可以快速地实现美美的效果,而简单方便,毫无痛楚。”而直到Profiling的时候才发现,正是这些小妖精默默地榨干了你的CPU/GPU/带宽,让你的游戏在设备上变得一步三卡……

ugui中的Shadow是一个,Mask也算一个。

最近填了一个针对单独图片Mask使用的小坑,稍微总结一下。

1. 已有方案对比

Mask在UI的设计和制作中属于非常通用的需求,比如在小地图、可滚动的列表、头像等地方有着广泛的应用。Unity默认提供了两种方式来提供Mask:

  • 一种是基于Image的Mask组件,在父节点上添加一张图片和Mask组件,然后所有的孩子节点都可以被这张图Mask,实现原理是基于蒙版(Stencil)。UI的shader中对应的部分大致包括:
_StencilComp("Stencil Comparison", Float) = 8
_Stencil("Stencil ID", Float) = 0
_StencilOp("Stencil Operation", Float) = 0
_StencilWriteMask("Stencil Write Mask", Float) = 255
_StencilReadMask("Stencil Read Mask", Float) = 255

Stencil
{
	Ref[_Stencil]
	Comp[_StencilComp]
	Pass[_StencilOp]
	ReadMask[_StencilReadMask]
	WriteMask[_StencilWriteMask]
}
  • 第二种是只支持矩形的Rect Mask 2D,它根据所述的GameObject的RectTransfrom属性生成一个矩形区域,供其他的组件通过Alpha来进行Clip处理,对应的Shader代码大致如下:
//vert中
OUT.vertex = UnityObjectToClipPos(OUT.worldPosition);
//frag中
color.a *= UnityGet2DClipping(IN.worldPosition.xy, _ClipRect);
#ifdef UNITY_UI_ALPHACLIP
        clip(color.a - 0.001);
#endif

细节不展开聊了,前者基于Mask的方法属于更通用,功能更强,消耗也更大的方案,集中在两个方面:

  1. Draw Call,Mask要单独一个Draw call来绘制,额外增加Draw Call,然后它还可能会打断ugui合并draw call的过程,额外增加一些draw call;
  2. Over Draw,在小地图这种地方比较明显,mask下的图还会有绘制过程,只是蒙版对比不过不会写入buffer而已。

Rect Mask 2D的局限性比较明显,只支持矩形,然后因为用的是clip所以没有overdraw的过程,drawcall也友好一些,我猜是ugui里直接处理成参数 float4 _ClipRect 传递给所有孩子节点。

2. 问题

你看,鱼和熊掌果然不能兼得,功能强大的消耗就大……从通用性角度来说这也无可厚非,只是我们在优化时发现UI同学在头像这样的地方大量使用圆形的Mask,而只是Mask一个头像贴图。这导致额外多一个Draw Call。是的,为了处理这一个Draw Call,我想优化一下它……

思路很简单,理论上这种需求提供一个单独的材质,把Mask贴图传给shader,然后使用它的一个通道来做alpha的处理就好了,虽然也有overdraw的问题,但是对于头像这种需求OverDraw的部分并不是很大,还好接受,使用Clip感觉好不到哪里去,还会有边缘的精度问题。

然而写了一个简单的Shader测试的时候发现比我想象中的复杂,因为uv信息在shader处理的时候已经是atlas上的偏移过的uv信息了mask贴图有点难获取到正确的uv信息,需要传递给Shader额外的数据。这让准备把这个功能秒掉我的稍微有点尴尬。。。

于是上网搜了下,发现了两篇博客《丢掉Mask遮罩,更好的圆形Image组件》和《画地为Mask,随心所欲的高效遮罩组件》。是同一作者的,记得之前也看到过,然后决定从github上拉拉下学习一下。

3. 基于网格修改的方案

首先是使用了上述博客里对应github的方案:Unity-MeshMask

比较简单,原理博客也说得比较清楚,我只用了最为简单的圆形方案,加上我们自己的需求增加了一个上方下圆的功能,也修改了一下圆形的顶点生成顺序,又担心基于顶点的射线检测有额外消耗(纯猜想,没测试),也给干掉了,简化一个版本的代码直接贴出来吧。顺便感谢作者,思路是很不错的~~

using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Sprites;

/// <summary>
/// 通过修改Mesh数据来实现Mask效果
/// 基于 https://github.com/leoin2012/Unity-MeshMask 的思路
/// 添加上方下圆的支持。
/// </summary>
[AddComponentMenu("UI/Custom/Circle Image")]
[RequireComponent(typeof(Image))]
public class CircleImage : BaseMeshEffect
{
    [Tooltip("圆形或扇形填充比例")]
    [Range(0, 1)]
    public float fillPercent = 1f;
    [Tooltip("圆形")]
    [Range(3, 60)]
    public int segements = 30;
    [Tooltip("是否上方下圆")]
    public bool IsTopRect = false;

    protected Image image;
    
    private float uvCenterX = 0;
    private float uvCenterY = 0;
    private float uvScaleX = 0;
    private float uvScaleY = 0;

    private float vertexCenterX = 0;
    private float vertexCenterY = 0;

    // Use this for initialization
    protected override void Awake ()
    {
        image = this.GetComponent<Image>();
    }

    private void AddVertex(VertexHelper vh, float x, float y)
    {
        UIVertex uiVertex = new UIVertex();
        uiVertex.color = image.color;
        uiVertex.position.x = vertexCenterX + x;
        uiVertex.position.y = vertexCenterY + y;
        uiVertex.uv0 = new Vector2(x * uvScaleX + uvCenterX, y * uvScaleY + uvCenterY);
        vh.AddVert(uiVertex);
    }

    public override void ModifyMesh(VertexHelper vh)
    {
        if (!IsActive())
            return;
        vh.Clear();

        float degreeDelta = (float)(2 * Mathf.PI / segements);
        float tempFillPercent = IsTopRect ? 0.5f : fillPercent;

        int curSegements = (int)(segements * tempFillPercent);
        //为了保证上方是方形时半圆完整,强制增加一个单位弧度的顶点数量
        if (IsTopRect)
        {
            curSegements += 1;
        }

        float tw = image.rectTransform.rect.width;
        float th = image.rectTransform.rect.height;
        
        float outerRadius = 0.5f * tw;

        vertexCenterX = (0.5f - image.rectTransform.pivot.x) * tw;
        vertexCenterY = (0.5f - image.rectTransform.pivot.y) * th;

        Vector4 uv = image.overrideSprite != null ? DataUtility.GetOuterUV(image.overrideSprite) : Vector4.zero;

        uvCenterX = (uv.x + uv.z) * 0.5f;
        uvCenterY = (uv.y + uv.w) * 0.5f;
        uvScaleX = (uv.z - uv.x) / tw;
        uvScaleY = (uv.w - uv.y) / th;

        float curDegree = 0;
        int verticeCount;
        int triangleCount;

        verticeCount = curSegements + 1;
        AddVertex(vh, 0, 0);    //添加中心点

        //添加圆弧顶点
        for (int i = 1; i < verticeCount; i++)
        {
            float cosA = Mathf.Cos(curDegree);
            float sinA = Mathf.Sin(curDegree);
            curDegree -= degreeDelta;

            AddVertex(vh, cosA * outerRadius, sinA * outerRadius);
        }

        //如果上方下园则添加方形的顶点
        if (IsTopRect)
        {
            curSegements += 2;
            verticeCount += 2;
            
            AddVertex(vh, -outerRadius, outerRadius);
            AddVertex(vh, outerRadius, outerRadius);
        }

        triangleCount = curSegements*3;
        for (int i = 0, vIdx = 1; i < triangleCount - 3; i += 3, vIdx++)
        {
            vh.AddTriangle(vIdx+1, 0, vIdx);
        }
        if (fillPercent == 1 || IsTopRect)
        {
            //首尾顶点相连
            vh.AddTriangle(1, 0, verticeCount - 1);
        }
    }

}
两种方式生成的网格图

上面截图是两种效果的截图对比,圆形和上方下圆,用于头像应该够用了。最后的效果图如下:

使用圆形Mask的效果图

最后的结果可以看到为什么要提供上方下圆的方案了,头顶的部分会被圆形Mask掉,这并不是UI想要的效果。如果和Mask的方案放在一起对比的话,你会发现它的优点和缺点:

两种方案的效果对比图
两种方案的OverDraw对比图

左侧为基于Mask的方案,右侧为修改Mesh的方案,可以看出,效果方面,Mask的方案边缘有马赛克的现象,因为蒙版是针对像素做过滤的,所以有这种问题还比较正常,也有点难处理,毕竟非0即1;修改Mesh的方案在面数大约30-40左右的时候表现还不错,因为是斜线,所以会有一点锐利的感觉,但是已经好一些了。Overdraw方面也是后者完胜~

然而,这个小孩子变胖了。。。对比Mesh可以看出,原始图中不确定是不是ugui还是合图Atlas的优化,虽然Image的尺寸设置为方形,但是其实是一个长方形的效果,OverDraw从宽度上会小一些,而优化后的方案中Mesh生成和uv映射的计算是基于Image的宽度来计算的:

//Github上的原始代码
float tw = image.rectTransform.rect.width;
float th = image.rectTransform.rect.height;
float outerRadius = image.rectTransform.pivot.x * tw;

这里有另外一个bug我在自己的代码中已经修复了,outerRadius的计算根据image.rectTransform.pivot.x的值来计算的,这只能支持按照中心点布局的情况,当按照左上角做布局对齐的时候,这里的outerRadius就变成了0,右侧对齐的话就被放大的一倍……不知道原作者在使用中有没有发现这个问题。

回来说图中的小伙子变胖的问题,由于代码在开始的时候就抛弃掉了ugui生成的VertexHelper数据,而这里ugui做的优化或者处理就被改变了,因此如果要处理这个问题,我的思路是把VertexHelper中的顶点数据遍历一遍,找到两个方向的最大值和最小值,然后按照比例和uv进行重新映射。注意这里映射时需要比较小心,虽然在这个例子中只要图像的宽度取到正确的值就可以,但是这只是个例,还有ugui优化后宽度大于高度的情况,想要不变形,用一个圆形去Mask一个矩形,还可能出现不想被Mask的区域被Mask的情况。

反正这里有个坑,有使用的朋友可以填一下,考虑周全应该不麻烦,停靠点支持的坑我已经填了,需要的可以直接参考贴出的代码。

我没有去填这个坑的一个原因是心中有了一个其他想法~

4. 基于uv1的修改方案

想要做到较好的Mask的效果,Alpha Blend的支持还是很重要的,Mask方案也好,Rect 2D Mask的方案也好,修改Mesh的方案也好,边界都是非0即1的处理,而最理想的是用渐变的Alpha来处理。

修改Mesh的方案也给了我一个新思路——ModifyMesh的修改让方式让我不需要去继承Image就可以实现对于网格的修改。而我最早的方案中,缺少的只是一个Mask的uv信息,其他的都没有问题,那么,思路就逐渐清晰起来了——在ModifyMesh函数中为设置第二套uv为Mask需要的uv信息。

代码如下:

using UnityEngine;
using UnityEngine.UI;
using UnityEngine.Sprites;


/// <summary>
/// 修改Mesh,通过UV2的设置,让材质的mask贴图可以获得正确的uv数据。
/// 支持共用材质,从而支持合批。
/// </summary>
[AddComponentMenu("UI/Custom/Mask Image")]
[RequireComponent(typeof(Image))]
public class MaskImage : BaseMeshEffect
{
    protected Image image;

    protected override void Awake ()
    {
        image = this.GetComponent<Image>();
    }

    public override void ModifyMesh(VertexHelper vh)
    {
        if (!IsActive())
            return;

        Vector4 uv = image.overrideSprite != null ? DataUtility.GetOuterUV(image.overrideSprite) : Vector4.zero;
        float uvWidth = uv.z - uv.x;
        float uvHeight = uv.w - uv.y;
        if (uvWidth == 0 || uvHeight == 0)
        {
            return;
        }

        int vertCount = vh.currentVertCount;
        var vert = new UIVertex();
        for (int i = 0; i < vertCount; ++i)
        {
            vh.PopulateUIVertex(ref vert, i);

            vert.uv1.x = (vert.uv0.x - uv.x) / uvWidth;
            vert.uv1.y = (vert.uv0.y - uv.y) / uvHeight;
            vh.SetUIVertex(vert, i);
        }
    }
}

Shader代码比较简单,只贴一下frag好了

fixed4 frag(v2f IN) : SV_Target
{
	half4 color = (tex2D(_MainTex, IN.texcoord) + _TextureSampleAdd) * IN.color;
	half4 maskColor = tex2D(_MaskTex, IN.texcoord1);
        //这里其实使用rgb通道中的一个就够了
	color.a *= maskColor.a;

	color.a *= UnityGet2DClipping(IN.worldPosition.xy, _ClipRect);
#ifdef UNITY_UI_ALPHACLIP
	clip(color.a - 0.001);
#endif

	return color;
}

看下最终效果:

三种方案的效果对比图
Overdraw的对比图
网格对比图

截图的效果比较渣,单独截取一张效果图对比,可以看到边缘的alpha渐变效果,过渡区域的范围和方式UI可以直接在Mask的通道里控制。

放大的效果图

这种方式的面数最少,Overdraw也只有一层,当然每个像素在ps阶段多了一次采样过程,Shader的消耗要大一些。

但是效果正确且好啊~~~

当然,注意让ui复用材质,就是比如圆形的Mask只需要制作一个材质,这样才能够合批,对于不同的Mask贴图,目前的方案是不能合批的,好在一个ui中这样的情况并不常见。如果你想改进,可以考虑把Mask贴图也做成atlas,然后在ImageMask这个Component里把atlas的uv偏移值设置进去(或者提供自动计算的功能),然后计算正确的uv1的值给shader用即可。目前没做这一步,有需求再搞。

5. 大结局

大结局当然是程序提升了效率,UI提升了效果,都很开心啦~从此快快乐乐地在一起……继续做游戏喽……

6. 补

经过和钱康来的讨论,最后又实现了一个基于继承Image的方法,效率更加高一点,不需要生成一遍网格之后再修改(针对Image来说只有四个顶点所以消耗也不会很大)。但是目前的版本只支持做了SimpleType类型的支持。

[AddComponentMenu("UI/Custom/Maskable Image")]
public class MaskableImage : ThorImage
{
    private static readonly Vector4 s_DefaultTangent = new Vector4(1.0f, 0.0f, 0.0f, -1.0f);
    private static readonly Vector3 s_DefaultNormal = Vector3.back;

    protected override void GenerateSimpleSprite(VertexHelper vh, bool lPreserveAspect)
    {
        Vector4 v = GetDrawingDimensions(lPreserveAspect);
        var uv = (overrideSprite != null) ? DataUtility.GetOuterUV(overrideSprite) : Vector4.zero;

        float uvWidth = uv.z - uv.x;
        float uvHeight = uv.w - uv.y;
        bool setUV1 = !(uvWidth == 0 || uvHeight == 0);

        var color32 = color;
        vh.Clear();
        vh.AddVert(new Vector3(v.x, v.y), color32, new Vector2(uv.x, uv.y), setUV1 ? new Vector2((uv.x - uv.x) / uvWidth, (uv.y - uv.y) / uvHeight) : Vector2.zero, s_DefaultNormal, s_DefaultTangent);
        vh.AddVert(new Vector3(v.x, v.w), color32, new Vector2(uv.x, uv.w), setUV1 ? new Vector2((uv.x - uv.x) / uvWidth, (uv.w - uv.y) / uvHeight) : Vector2.zero, s_DefaultNormal, s_DefaultTangent);
        vh.AddVert(new Vector3(v.z, v.w), color32, new Vector2(uv.z, uv.w), setUV1 ? new Vector2((uv.z - uv.x) / uvWidth, (uv.w - uv.y) / uvHeight) : Vector2.zero, s_DefaultNormal, s_DefaultTangent);
        vh.AddVert(new Vector3(v.z, v.y), color32, new Vector2(uv.z, uv.y), setUV1 ? new Vector2((uv.z - uv.x) / uvWidth, (uv.y - uv.y) / uvHeight) : Vector2.zero, s_DefaultNormal, s_DefaultTangent);

        vh.AddTriangle(0, 1, 2);
        vh.AddTriangle(2, 3, 0);
    }
}

ThorImage是我拷贝的Image.cs,开放了部分接口,比如这里需要的GetDrawingDimensions,还有让GenerateSimpleSprite可以被override,需要的自己弄一下就好。


PS:如果你有更好的方法,欢迎反馈给我~~

(2018年1月2日于杭州滨江海创园)

编辑于 2018-01-03

文章被以下专栏收录