【UGUI源码分析】Unity遮罩之Mask详细解读

遮罩,顾名思义是一种可以掩盖其它元素的控件。常用于修改其它元素的外观,或限制元素的形状。比如ScrollView或者圆头像效果都有用到遮罩功能。本系列文章希望通过阅读UGUI源码的方式,来探究遮罩的实现原理,以及通过Unity不同遮罩之间实现方式的对比,找到每一种遮罩的最佳使用场合。

Unity UGUI主要提供两种遮罩,分别是MaskRect Mask 2D。在2D游戏开发中,可能还会用到Sprite Mask,虽然不是本文的重点,但后面也会提到。原本是希望将对各个遮罩的分析与对比整合在一篇文章中,但在书写过程中发现篇幅过长,因此只好拆分为三个部分。本篇文章是第一部分,专门解读Mask遮罩。另外两篇分别是

本文使用的源码与内置资源均基于Unity2019.4版本

Mask

查阅Unity的官方文档,对Mask有如下定义

遮罩不是可见的 UI 控件,而是一种修改控件子元素外观的方法。遮罩将子元素限制(即“掩盖”)为父元素的形状。因此,如果子项比父项大,则子项仅包含在父项以内的部分才可见。

也有简单提到Mask的实现原理

使用 GPU 的模板缓冲区来实现遮罩。第一个遮罩元素将 1 写入模板缓冲区。遮罩下面的所有元素在渲染时进行检查,仅渲染到模板缓冲区中有 1 的区域。 嵌套的遮罩会将增量位掩码写入缓冲区,这意味着可渲染的子项需要具有要渲染的逻辑和模板值。

是不是有些晦涩难懂?没关系,接下来的分析就是对这个实现原理的展开,每句话都会有对应的解读

模板缓冲?

要搞懂模板缓冲,先要了解模板测试。在渲染流水线的逐片元操作阶段,会有一个模板测试,可以作为一种丢弃片元的辅助方法(这里的片元可以简单理解为对应着一个像素),而要进行模板测试就要用到模板缓冲。每个像素/片段都可以有一个与之对应的模板值,就存储在模板缓冲中。

如果开启了模板测试,GPU会首先读取(使用读取掩码)模板缓冲区中该片元位置的模板值,然后将该值和读取到(使用读取掩码)的参考值进行比较,这个比较函数可以是由开发者指定的,例如小于时舍弃该片元,或者大于等于时舍弃。如果这个片元没有通过这个测试,该片元就会被舍弃。不管一个片元有没有通过模板测试,我们都可以根据模板测试和下面的深度测试结果来修改模板缓冲区,这个修改操作也是由开发者指定的。开发者可以设置不同结果下的修改操作,例如,在失败时模板缓冲区保持不变,通过时将模板缓冲区中对应位置的值加1等。

而Mask就是通过在渲染时,将其对应位置像素的模板值都置为特定值(不一定是1),然后当遮罩下的子元素渲染时,逐像素判断模板值是否为特定值,如果是特定值,就表示在遮罩范围内,可以显示。如果不是,则表示不在遮罩范围内,不显示。借用一张网上的图,很形象的描述了这种方式。

绿色矩形是遮罩区域,模板值都被写入为1,当渲染横着的红色矩形时,只有模板值为1的区域才会显示,非1的会被丢弃不会显示。从而实现了裁剪效果

源码

在了解了Mask的基本实现原理后,再来通过源码看看具体的实现方式

UGUI中所有可显示的图形都有一个基类,Graphic。比如Image和Text就是间接继承于Graphic的。Graphic定义了一个materialForRendering属性。它表示传递给CanvasRenderer,实际被用于渲染的材质。从这个属性的get访问器可以发现,在获取最终被用于渲染的材质时,会先依次调用这个GameObject上所有实现了IMaterialModifier接口组件的GetModifiedMaterial方法来修改最后返回的材质。

public virtual Material materialForRendering
{
    get
    {
        var components = ListPool<Component>.Get();
        GetComponents(typeof(IMaterialModifier), components);

        var currentMat = material;
        for (var i = 0; i < components.Count; i++)
            currentMat = (components[i] as IMaterialModifier).GetModifiedMaterial(currentMat);
        ListPool<Component>.Release(components);
        return currentMat;
    }
}

IMaterialModifier定义如下所示,也就是说其它组件可以通过实现IMaterialModifier接口来达到修改最终渲染所使用的材质的目的

public interface IMaterialModifier
{
    /// <summary>
    /// Perform material modification in this function.
    /// </summary>
    /// <param name="baseMaterial">The material that is to be modified</param>
    /// <returns>The modified material.</returns>
    Material GetModifiedMaterial(Material baseMaterial);
}

Mask组件就实现了IMaterialModifier接口,并通过这个接口返回了一个新材质,并通过这个新材质设置修改模板缓冲值

/// Stencil calculation time!
public virtual Material GetModifiedMaterial(Material baseMaterial)
{
    // ...
    var rootSortCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
    var stencilDepth = MaskUtilities.GetStencilDepth(transform, rootSortCanvas);
    if (stencilDepth >= 8)
    {
        Debug.LogWarning("Attempting to use a stencil mask with depth > 8", gameObject);
        return baseMaterial;
    }

    int desiredStencilBit = 1 << stencilDepth;
    
    // 第一部分
    // if we are at the first level...
    // we want to destroy what is there
    if (desiredStencilBit == 1)
    {
        // CompareFunction.Always,始终通过,执行StencilOp.Replace操作,将模板缓冲中的值替换为(1 & 255)= 1
        var maskMaterial = StencilMaterial.Add(baseMaterial, 1, StencilOp.Replace, CompareFunction.Always, m_ShowMaskGraphic ? ColorWriteMask.All : 0);
        StencilMaterial.Remove(m_MaskMaterial);
        m_MaskMaterial = maskMaterial;

        var unmaskMaterial = StencilMaterial.Add(baseMaterial, 1, StencilOp.Zero, CompareFunction.Always, 0);
        StencilMaterial.Remove(m_UnmaskMaterial);
        m_UnmaskMaterial = unmaskMaterial;
        // 设置渲染器可使用的材质数量为1
        graphic.canvasRenderer.popMaterialCount = 1;
        // 设置渲染器使用的材质
        graphic.canvasRenderer.SetPopMaterial(m_UnmaskMaterial, 0);

        return m_MaskMaterial;
    }
    // 第二部分
    // ...
}

GetModifiedMaterial的实现可以分两部分来看,上面的代码只列出了第一部分。简单起见,我们先只看第一部分,主要是if (desiredStencilBit == 1)语句块内代码,它是用于处理只有自身有Mask的简单情况的

  • 代码中的stencilDepth表示自身到Canvas之间Mask的个数,如果每层有多个Mask则只计一个。如果除了自身的Mask,再往上没有Mask了,则stencilDepth为0,如果再往上找到1个,stencilDepth为1,找到2个,stencilDepth为2,以此类推。
  • desiredStencilBit表示实际要写入模板缓冲的参考值。desiredStencilBit = 1 << stencilDepth。当stencilDepth >= 8时会打印警告,是因为模板值一般是8位的,desiredStencilBit将超出这个范围无法写入
  • 如果只是自身有Mask,再往上没有了。那stencilDepth就是0,desiredStencilBit就是1,此时通过StencilMaterial.Add获得一个新材质,并将这个材质返回,从而达到修改最终渲染使用材质的目的。StencilMaterial.Add方法具体实现如下所示,主要是对材质设置一些传入的参数。
public static Material Add(Material baseMat, int stencilID, StencilOp operation, CompareFunction compareFunction, ColorWriteMask colorWriteMask, int readMask, int writeMask)
{
    // ...
    var newEnt = new MatEntry();
    newEnt.count = 1;
    newEnt.baseMat = baseMat;
    newEnt.customMat = new Material(baseMat);
    newEnt.customMat.hideFlags = HideFlags.HideAndDontSave;
    newEnt.stencilId = stencilID;
    newEnt.operation = operation;
    newEnt.compareFunction = compareFunction;
    newEnt.readMask = readMask;
    newEnt.writeMask = writeMask;
    newEnt.colorMask = colorWriteMask;
    newEnt.useAlphaClip = operation != StencilOp.Keep && writeMask > 0;

    newEnt.customMat.name = string.Format("Stencil Id:{0}, Op:{1}, Comp:{2}, WriteMask:{3}, ReadMask:{4}, ColorMask:{5} AlphaClip:{6} ({7})", stencilID, operation, compareFunction, writeMask, readMask, colorWriteMask, newEnt.useAlphaClip, baseMat.name);

    newEnt.customMat.SetInt("_Stencil", stencilID);
    newEnt.customMat.SetInt("_StencilOp", (int)operation);
    newEnt.customMat.SetInt("_StencilComp", (int)compareFunction);
    newEnt.customMat.SetInt("_StencilReadMask", readMask);
    newEnt.customMat.SetInt("_StencilWriteMask", writeMask);
    newEnt.customMat.SetInt("_ColorMask", (int)colorWriteMask);
    newEnt.customMat.SetInt("_UseUIAlphaClip", newEnt.useAlphaClip ? 1 : 0);

    if (newEnt.useAlphaClip)
        newEnt.customMat.EnableKeyword("UNITY_UI_ALPHACLIP");
    else
        newEnt.customMat.DisableKeyword("UNITY_UI_ALPHACLIP");

    m_List.Add(newEnt);
    return newEnt.customMat;
}

StencilMaterial本质上只是缓存材质的一个工具类,主要作用就是提供一个新的材质。再结合下面这句代码传入的参数。这个新材质起到的作用是始终通过模板测试(CompareFunction.Always),替换模板缓冲中的模板值(StencilOp.Replace)为1

var maskMaterial = StencilMaterial.Add(baseMaterial, 1, StencilOp.Replace, CompareFunction.Always, m_ShowMaskGraphic ? ColorWriteMask.All : 0);

对材质设置的参数,实际上是设置给Shader的,查看UI默认使用的Shader是UI/Default,这是Unity的内置Shader,源码可以在Unity官网下载,下载时选择"Built in shaders"

UI-Default.shader的部分源码如下所示,可以看到主要是利用Unity ShaderLab的模板语句来实现对模板缓冲区的一些操作,详细介绍可以点击这里查看,就不再赘述了

// Unity built-in shader source. Copyright (c) 2016 Unity Technologies. MIT license (see license.txt)

Shader "UI/Default"
{
    Properties
    {
        [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {}
        _Color ("Tint", Color) = (1,1,1,1)

        _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

        _ColorMask ("Color Mask", Float) = 15

        [Toggle(UNITY_UI_ALPHACLIP)] _UseUIAlphaClip ("Use Alpha Clip", Float) = 0
    }

    SubShader
    {
        // ...
        Stencil
        {
            Ref [_Stencil]
            Comp [_StencilComp]
            Pass [_StencilOp]
            ReadMask [_StencilReadMask]
            WriteMask [_StencilWriteMask]
        }
        // ...
    }
}

到这里不难发现,Unity文档Mask原理描述中的第一句话就是对上面过程的一个概括

使用 GPU 的模板缓冲区来实现遮罩。第一个遮罩元素将 1 写入模板缓冲区。

接下来我们再来看被遮掩的对象,是怎样利用模板缓冲实现遮罩效果的

UGUI中所有可被遮掩的图形都有一个基类,MaskableGraphic,同样MaskableGraphic是继承于Graphic的。比如Image和Text就是继承于MaskableGraphic的。同理,MaskableGraphic也实现了IMaterialModifier接口来修改最终渲染使用的材质

public virtual Material GetModifiedMaterial(Material baseMaterial)
{
    var toUse = baseMaterial;

    if (m_ShouldRecalculateStencil)
    {
        var rootCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
        m_StencilValue = maskable ? MaskUtilities.GetStencilDepth(transform, rootCanvas) : 0;
        m_ShouldRecalculateStencil = false;
    }

    // if we have a enabled Mask component then it will
    // generate the mask material. This is an optimization
    // it adds some coupling between components though :(
    if (m_StencilValue > 0 && !isMaskingGraphic)
    {
        var maskMat = StencilMaterial.Add(toUse, (1 << m_StencilValue) - 1, StencilOp.Keep, CompareFunction.Equal, ColorWriteMask.All, (1 << m_StencilValue) - 1, 0);
        StencilMaterial.Remove(m_MaskMaterial);
        m_MaskMaterial = maskMat;
        toUse = m_MaskMaterial;
    }
    return toUse;
}
  • 代码中的m_StencilValue表示在自身层级之上有多少个Mask,如果只有父节点有Mask组件,则m_StencilValue值为1
  • 可以看到它返回的新材质主要作用是,比较传入的参考值((1 << m_StencilValue) - 1)与模板缓冲中的值,如果相等就通过(CompareFunction.Equal),即使通过了模板测试也仍保留模板缓冲中的值(StencilOp.Keep)。
var maskMat = StencilMaterial.Add(toUse, (1 << m_StencilValue) - 1, StencilOp.Keep, CompareFunction.Equal, ColorWriteMask.All, (1 << m_StencilValue) - 1, 0);
  • 当只有父节点有Mask组件时,(1 << m_StencilValue) - 1值即为1,与前面Mask组件提前设置的模板缓冲区的值相同,所以在Mask范围内的元素将能够通过模板测试,最终显示出来,未通过的将被裁剪无法显示出来

这里就对应了Unity文档Mask原理描述中的中间部分

遮罩下面的所有元素在渲染时进行检查,仅渲染到模板缓冲区中有 1 的区域。

实际上到这里,一个简单的,只有父节点有Mask的图形是怎样实现遮罩效果的,我们已经彻底搞清楚了,接下来,让我们来看看复杂点的情况

如果大家还没忘记的话,让我们回到Mask的GetModifiedMaterial实现(注意是Mask的哦~),查看它的第二部分,即if语句块后面的代码,他们是被用来处理嵌套Mask的

public virtual Material GetModifiedMaterial(Material baseMaterial)
{
    if (!MaskEnabled())
        return baseMaterial;

    var rootSortCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
    var stencilDepth = MaskUtilities.GetStencilDepth(transform, rootSortCanvas);
    if (stencilDepth >= 8)
    {
        Debug.LogWarning("Attempting to use a stencil mask with depth > 8", gameObject);
        return baseMaterial;
    }

    int desiredStencilBit = 1 << stencilDepth;

    // 第一部分
    // ...

    // 第二部分
    //otherwise we need to be a bit smarter and set some read / write masks
    var maskMaterial2 = StencilMaterial.Add(baseMaterial, desiredStencilBit | (desiredStencilBit - 1), StencilOp.Replace, CompareFunction.Equal, m_ShowMaskGraphic ? ColorWriteMask.All : 0, desiredStencilBit - 1, desiredStencilBit | (desiredStencilBit - 1));
    StencilMaterial.Remove(m_MaskMaterial);
    m_MaskMaterial = maskMaterial2;

    graphic.canvasRenderer.hasPopInstruction = true;
    var unmaskMaterial2 = StencilMaterial.Add(baseMaterial, desiredStencilBit - 1, StencilOp.Replace, CompareFunction.Equal, 0, desiredStencilBit - 1, desiredStencilBit | (desiredStencilBit - 1));
    StencilMaterial.Remove(m_UnmaskMaterial);
    m_UnmaskMaterial = unmaskMaterial2;
    graphic.canvasRenderer.popMaterialCount = 1;
    graphic.canvasRenderer.SetPopMaterial(m_UnmaskMaterial, 0);

    return m_MaskMaterial;
}
  • 与第一部分不同的是,StencilMaterial.Add传入的参数不同,而这些不同就是处理嵌套Mask的关键。嵌套Mask是指除了自身Mask,层级再往上还有Mask。针对这种情况,传入的参考值是desiredStencilBit | (desiredStencilBit - 1),而不再固定是1了。这个值的实际含义是利用每一位是否是1来表示每一层是否有Mask。举个栗子,如果除了自身,再往上还能找到两个Mask,则stencilDepth为2,desiredStencilBit为8,二进制形式为100,经过计算传入的参考值是111,用每个1来分别表示,自身有Mask,第一层有,第二层有。这个参考值被Unity称之为增量位掩码
  • 这个增量位掩码正好可以与MaskableGraphic部分判断模板值是否相等时用到的(1 << m_StencilValue) - 1对应上
// Mask处理嵌套遮罩所用的新材质
var maskMaterial2 = StencilMaterial.Add(baseMaterial, desiredStencilBit | (desiredStencilBit - 1), StencilOp.Replace, CompareFunction.Equal, m_ShowMaskGraphic ? ColorWriteMask.All : 0, desiredStencilBit - 1, desiredStencilBit | (desiredStencilBit - 1));

// MaskableGraphic判断是否在遮罩内所用的新材质
var maskMat = StencilMaterial.Add(toUse, (1 << m_StencilValue) - 1, StencilOp.Keep, CompareFunction.Equal, ColorWriteMask.All, (1 << m_StencilValue) - 1, 0);

实际上这部分就对应了Unity文档Mask原理描述中的后两句话

嵌套的遮罩会将增量位掩码写入缓冲区,这意味着可渲染的子项需要具有要渲染的逻辑和模板值。

补充

最后还有几处地方觉得值得提一下

  1. StencilMaterial.Add传入参数的最后两个分别是readMask读取掩码和writeMask写入掩码,读取掩码不仅是在读取模板缓冲中的值时会与其相与,对于要比较的参考值也会相与

  2. 细心的同学可能会发现,Mask在获取新材质的时候,会多获取一个。这个材质实际是用来清除模板缓冲区的。以避免不要影响后续的渲染

    // 第一部分
    var unmaskMaterial = StencilMaterial.Add(baseMaterial, 1, StencilOp.Zero, CompareFunction.Always, 0);
    
    // 第二部分
    var unmaskMaterial2 = StencilMaterial.Add(baseMaterial, desiredStencilBit - 1, StencilOp.Replace, CompareFunction.Equal, 0, desiredStencilBit - 1, desiredStencilBit | (desiredStencilBit - 1));
    

    利用Unity的帧调试器也可以看到这个清除过程

  3. 为什么Mask可以实现圆形遮罩效果?

    众所周知,圆头像效果可以使用Mask实现,具体方式是使用一张只显示圆形,非圆形区域是透明像素的切图实现的。但这张切图实际上还是矩形的,根据上面的原理解读,矩形区域对应的模板值都会被Mask设置为特定值,从而使其下的子元素都能通过模板测试,是无法实现圆形裁剪的

    关键代码还是在UI-Default.shader中,它通过clip指令,将透明度低于0.001的片元都裁剪掉了,因此被裁剪的片元也就不会再设置对应的模板值了。UNITY_UI_ALPHACLIP宏定义是通过Shader参数_UseUIAlphaClip控制的,Mask获取的新材质会将该参数设置为true

    #ifdef UNITY_UI_ALPHACLIP
    clip (color.a - 0.001);
    #endif
    
  4. 关于SpriteMask

    Sprite Mask不属于UGUI的范围,Unity官方并没有将它开源,不过通过官方论坛我们可以了解到其实现原理也是利用了模版缓冲。
    不像Mask,只实现了Visible Inside Mask功能,SpriteMask不仅实现了Visible Inside Mask功能,也实现了Visible Outside Mask功能。在经过对Mask的原理分析以后,我们知道通过修改模板缓冲的比较函数是可以轻易的实现这种效果的,感兴趣的同学赶快动手试一下吧


参考

posted @ 2021-08-12 10:31  iwiniwin  阅读(6992)  评论(2编辑  收藏  举报