UGUI的Mask、RectMask2D分析


Mask

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

模板缓冲

Mask是利用了GPU的模板缓冲来实现的,关于模板,打个简单的比方,就像一个面具,可以挡住一部分“脸”的显示一样。Mask的关键代码其实只有一行,如下(为方便理解,对代码做了简化处理):

var maskMaterial = StencilMaterial.Add(baseMaterial, 1, StencilOp.Replace, CompareFunction.Always);

它的作用是为Mask对象生成一个特殊的材质,这个材质会将StencilBuffer的值置为1。同样的,在Image,Text和RawImage的基类 MaskableGraphic 中,有这样一行关键代码(为方便理解,对代码做了简化处理):

var maskMat = StencilMaterial.Add(baseMaterial, 1, StencilOp.Keep, CompareFunction.Equal, 1, 0);

它的作用是为MaskableGraphic生成一个特殊的材质,这个材质在渲染时会取出StencilBuffer的值,判断是否为1,如果是才进行渲染。

详细可参考这里

Mask的实现(源码)

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方法具体实现如下所示,主要是对材质设置一些传入的参数。

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

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

MaskableGraphic对象的实现

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

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范围内的元素将能够通过模板测试,最终显示出来,未通过的将被裁剪无法显示出来

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

多层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模板测试方程为:

(ref & readMask) comparisonFunction (stencilBufferValue & readMask)

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

  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));

RectMask2D

RectMask2D部分转载自 【UGUI源码分析】Unity遮罩之RectMask2D详细解读 (个别地方进行了个人理解的补充)

什么是RectMask2D

RectMask2D 是一个类似于(Mask) 控件的遮罩控件。遮罩将子元素限制为父元素的矩形。与标准的遮罩控件不同,这种控件有一些限制,但也有许多性能优势。

工作流大致如下

  1. C#:找出父物体中所有RectMask2D覆盖区域的交集
  2. C#:所有继承MaskGraphic的子物体组件调用方法设置裁剪区域(SetClipRect)传递给Shader
  3. Shader:接收到矩形区域,片元着色器中判断像素是否在矩形区域内,不在则透明度设置为0
  4. Shader:丢弃掉alpha小于0.001的元素

RectMask2D的实现原理,概括起来就是先将那些不在其矩形范围内的元素透明度设置为0,然后通过Shader丢弃掉透明度小于0.001的元素。接下来我们通过阅读源码来查看它是如何实现这一流程的。

源码

UGUI中定义了两个接口,IClipper和IClippable,分别表示裁剪对象和被裁剪对象。RectMask2D实现了IClipper接口,MaskableGraphic则实现了IClippable接口。

/// <summary>
/// Interface that can be used to recieve clipping callbacks as part of the canvas update loop.
/// </summary>
public interface IClipper
{
    void PerformClipping();
}

/// <summary>
/// Interface for elements that can be clipped if they are under an IClipper
/// </summary>
public interface IClippable
{
    GameObject gameObject { get; }
    void RecalculateClipping();
    RectTransform rectTransform { get; }
    void Cull(Rect clipRect, bool validRect);
    void SetClipRect(Rect value, bool validRect);
    void SetClipSoftness(Vector2 clipSoftness);
}

其中IClipper的PerformClipping就是用来设置裁剪矩形的方法。在探讨它的具体实现前,我们先来看下这个方法是何时被调用的。

  1. CanvasUpdateRegistry是UI控件注册自己需要重建的地方,在每次画布开始绘制前会调用CanvasUpdateRegistry的PerformUpdate方法来重建所有注册的控件。
  2. 在这之中也会触发ClipperRegistry的Cull方法,ClipperRegistry是所有IClipper注册的地方,在ClipperRegistry的Cull方法中会调用所有注册者的PerformClipping方法。
public class ClipperRegistry
{
    // ...

    readonly IndexedSet<IClipper> m_Clippers = new IndexedSet<IClipper>();

    /// <summary>
    /// Perform the clipping on all registered IClipper
    /// </summary>
    public void Cull()
    {
        for (var i = 0; i < m_Clippers.Count; ++i)
        {
            m_Clippers[i].PerformClipping();
        }
    }

    // ...
}
  1. 每个RectMask2D都会在OnEnable中将自己注册到ClipperRegistry中。
protected override void OnEnable()
{
    base.OnEnable();
    m_ShouldRecalculateClipRects = true;
    ClipperRegistry.Register(this);  // 注册自己
    MaskUtilities.Notify2DMaskStateChanged(this);
}

然后我们来看RectMask2D的PerformClipping具体实现:

public virtual void PerformClipping()
{
    // ...
    
    // if the parents are changed
    // or something similar we
    // do a recalculate here
    if (m_ShouldRecalculateClipRects)
    {
        MaskUtilities.GetRectMasksForClip(this, m_Clippers);
        m_ShouldRecalculateClipRects = false;
    }
    
    // get the compound rects from
    // the clippers that are valid
    bool validRect = true;
    Rect clipRect = Clipping.FindCullAndClipWorldRect(m_Clippers, out validRect);

    // If the mask is in ScreenSpaceOverlay/Camera render mode, its content is only rendered when its rect
    // overlaps that of the root canvas.
    RenderMode renderMode = Canvas.rootCanvas.renderMode;
    bool maskIsCulled =
        (renderMode == RenderMode.ScreenSpaceCamera || renderMode == RenderMode.ScreenSpaceOverlay) &&
        !clipRect.Overlaps(rootCanvasRect, true);

    if (maskIsCulled)
    {
        // Children are only displayed when inside the mask. If the mask is culled, then the children
        // inside the mask are also culled. In that situation, we pass an invalid rect to allow callees
        // to avoid some processing.
        clipRect = Rect.zero;
        validRect = false;
    }

    if (clipRect != m_LastClipRectCanvasSpace)
    {
        foreach (IClippable clipTarget in m_ClipTargets)
        {
            clipTarget.SetClipRect(clipRect, validRect);
        }

        foreach (MaskableGraphic maskableTarget in m_MaskableTargets)
        {
            maskableTarget.SetClipRect(clipRect, validRect);
            maskableTarget.Cull(clipRect, validRect);
        }
    }
    // ...
    UpdateClipSoftness();
}
  1. 通过MaskUtilities.GetRectMasksForClip沿着层级结构往上找到所有的RectMask2D,然后利用Clipping.FindCullAndClipWorldRect计算这些RectMask2D所表示的矩形的交集,求出一个重叠矩形。

  2. 遍历所有的被裁减/被遮掩对象,通过SetClipRect为它们设置裁剪矩形。这些被裁剪对象是通过RectMask2D的AddClippable方法注册进来的。

  3. 值得一提的是,在方法的末尾还调用了UpdateClipSoftness,这个方法比较简单,就是再遍历所有的被裁减/被遮掩对象一遍,调用它们的SetClipSoftness方法。

    public virtual void UpdateClipSoftness()
    {
        // ...
        foreach (IClippable clipTarget in m_ClipTargets)
        {
            clipTarget.SetClipSoftness(m_Softness);
        }
    
        foreach (MaskableGraphic maskableTarget in m_MaskableTargets)
        {
            maskableTarget.SetClipSoftness(m_Softness);
        }
    }

MaskableGraphic 的 SetClipRect 和 SetClipSoftness

实现裁剪的关键就在于SetClipRect和SetClipSoftness的实现了,对于MaskableGraphic,它默认实现的SetClipRect和SetClipSoftness方法如下所示。

public virtual void SetClipRect(Rect clipRect, bool validRect)
{
    if (validRect)
        canvasRenderer.EnableRectClipping(clipRect);
    else
        canvasRenderer.DisableRectClipping();
}

public virtual void SetClipSoftness(Vector2 clipSoftness)
{
    canvasRenderer.clippingSoftness = clipSoftness;
}

UI-Default Shader

其中canvasRenderer是挂在对象上的CanvasRenderer组件。由于Unity并未将CanvasRenderer开源,所以其内部实现我们无从知晓。根据Unity API文档可知,EnableRectClipping的作用是启用矩形裁剪。将对位于指定矩形外的几何形状进行裁剪(不渲染)。DisableRectClipping对应的就是禁用该裁剪。说明了功能,但没有解释原理。通过查阅资料,得知是使用Shader实现的矩形裁剪。查看UI默认使用的Shader是UI/Default,这是Unity的内置Shader,源码可以在Unity官网下载,下载时选择”Built in shaders”。

UI-Default Shader

UI-Default.shader的部分源码如下所示

Shader "UI/Default"
{
    Properties
    {
        // ...
        [Toggle(UNITY_UI_ALPHACLIP)] _UseUIAlphaClip ("Use Alpha Clip", Float) = 0
    }

    SubShader
    {
        Pass
        {
            Name "Default"
        CGPROGRAM
            // ...
            struct v2f
            {
                float4 vertex   : SV_POSITION;
                fixed4 color    : COLOR;
                float2 texcoord  : TEXCOORD0;
                float4 worldPosition : TEXCOORD1;
                half4  mask : TEXCOORD2;
                UNITY_VERTEX_OUTPUT_STEREO
            };

            sampler2D _MainTex;
            fixed4 _TextureSampleAdd;
            float4 _ClipRect;
            float _UIMaskSoftnessX;
            float _UIMaskSoftnessY;

            v2f vert(appdata_t v)
            {
                v2f OUT;
                UNITY_SETUP_INSTANCE_ID(v);
                UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(OUT);
                float4 vPosition = UnityObjectToClipPos(v.vertex);
                OUT.worldPosition = v.vertex;
                OUT.vertex = vPosition;

                float2 pixelSize = vPosition.w;
                pixelSize /= float2(1, 1) * abs(mul((float2x2)UNITY_MATRIX_P, _ScreenParams.xy));

                float4 clampedRect = clamp(_ClipRect, -2e10, 2e10);
                float2 maskUV = (v.vertex.xy - clampedRect.xy) / (clampedRect.zw - clampedRect.xy);
                OUT.texcoord = TRANSFORM_TEX(v.texcoord.xy, _MainTex);
                OUT.mask = half4(v.vertex.xy * 2 - clampedRect.xy - clampedRect.zw, 0.25 / (0.25 * half2(_UIMaskSoftnessX, _UIMaskSoftnessY) + abs(pixelSize.xy)));

                OUT.color = v.color * _Color;
                return OUT;
            }

            fixed4 frag(v2f IN) : SV_Target
            {
                half4 color = IN.color * (tex2D(_MainTex, IN.texcoord) + _TextureSampleAdd);

                #ifdef UNITY_UI_CLIP_RECT
                half2 m = saturate((_ClipRect.zw - _ClipRect.xy - abs(IN.mask.xy)) * IN.mask.zw);
                color.a *= m.x * m.y;
                #endif

                #ifdef UNITY_UI_ALPHACLIP
                clip (color.a - 0.001);
                #endif

                return color;
            }
        ENDCG
        }
    }
}
  1. _ClipRect就是用来接收CanvasRenderer传递进来的裁剪矩形的。
  2. UNITY_UI_CLIP_RECT是控制是否开启矩形裁剪的宏,经过测试验证,EnableRectClipping会定义宏,而DisableRectClipping会禁用该宏的定义。

有些同学可能会有疑惑,上面的代码和现在网上搜索到的同样讲解遮罩的文章所展示的的代码有些出入,一般都如下所示。这是老版本Unity所采用的代码,主要逻辑就是通过UnityGet2DClipping判断片元是否在矩形内,如果不在则返回0,否则返回1。不在矩形内的片元透明度将被设置为0。然后通过clip将透明度小于0.001的片元丢弃掉。

#ifdef UNITY_UI_CLIP_RECT
color.a *= UnityGet2DClipping(IN.worldPosition.xy, _ClipRect);
#endif

#ifdef UNITY_UI_ALPHACLIP
clip (color.a - 0.001);
#endif

inline float UnityGet2DClipping (in float2 position, in float4 clipRect)
{
    float2 inside = step(clipRect.xy, position.xy) * step(position.xy, clipRect.zw);
    return inside.x * inside.y;
}

而Unity2019.4版本实现类似逻辑的代码如下所示,在实现矩形裁剪算法的同时,还新增了对Softness柔软度的处理。

// vs
OUT.mask = half4(v.vertex.xy * 2 - clampedRect.xy - clampedRect.zw, 0.25 / (0.25 * half2(_UIMaskSoftnessX, _UIMaskSoftnessY) + abs(pixelSize.xy)));

// fs
#ifdef UNITY_UI_CLIP_RECT
half2 m = saturate((_ClipRect.zw - _ClipRect.xy - abs(IN.mask.xy)) * IN.mask.zw);  
color.a *= m.x * m.y;
#endif

首先来看新的算法是如何实现矩形裁剪的。

  1. 判断点是否在矩形内,主要是依据 _ClipRectIN.mask.xy_ClipRect.xy是矩形左下角坐标, _ClipRect.zw是矩形右上角坐标, _ClipRect.zw - _ClipRect.xy就是一条从左下角指向右上角的向量,记为A。mask.xy经过如下所示代码进行转换,表示的是点到矩形左下角的向量B与点到矩形右上角的向量C之和,记为D。

    v.vertex.xy * 2 - clampedRect.xy - clampedRect.zw
    // 可以看做是
    (v.vertex.xy - clampedRect.xy) + (v.vertex.xy - clampedRect.zw)

    以点在矩形外为例,对应向量情况如下图所示。A - D得到的向量的xy分量,一定有一个是负值。像下图这种情况,A的x分量是小于D的x分量的。这很好理解,因为如果一个点在矩形外的话,它要么在整个矩形的左侧或右侧,要么在上侧或下侧,点到矩形左下角和右上角的向量在x或y方向上一定有一个是同向的。在矩形的左侧和右侧时,点到矩形左下角和右上角的向量x方向上距离之和一定是大于矩形的宽度的。在矩形的上侧和下侧时,点到矩形左下角和右上角的向量y方向上距离之和一定是大于矩形的高度的。

    矩形和向量演示

  2. 因此如果点在矩形外,saturate((_ClipRect.zw -_ClipRect.xy - abs(IN.mask.xy))得到的值一定小于0。saturate是把对应值限制到[0,1]之间。即小于0的值均为0,大于1的值均为1。从而将在矩形外的片元透明度设置为0,实现裁剪效果。

  3. 如果点在矩形内,点到矩形左下角和右上角的向量一定是反向的,两个向量相加得到的向量D,它的xy分量一定小于矩形的宽度和高度。所以saturate((_ClipRect.zw - _ClipRect.xy - abs(IN.mask.xy))得到的值一定是正值(注意这里计算的时候IN.mask.xy的坐标原点也是相对于_ClipRect的左下角的,所以在左侧和下侧的点会被abs函数对称到右侧和上侧,此时只要点在矩形的外侧,那么必然XY至少有一个是负值,最后经过saturate函数,负数会变成0,这样最后Alpha就会被设置为0),在矩形内的片元透明度大于0,可以显示出来。

    矩形和向量演示

Softness

Unity2019.4的矩形裁剪算法和老版本不同的一个原因应该就是为了能够对Softness进行处理,我们再来看看RectMask2D的Softness是起什么作用的,又是如何起作用的。

RectMask2D

  1. 代码中_UIMaskSoftnessX_UIMaskSoftnessY的值一定是大于0的,在RectMask2D的softness属性的set访问器中有做限制。因此在计算透明度的时候乘上IN.mask.zw不会改变结果的正负值,小于0的仍然是看不到,影响的只是能看到的片元的透明度。

    public Vector2Int softness
    {
        get { return m_Softness;  }
        set
        {
            m_Softness.x = Mathf.Max(0, value.x);
            m_Softness.y = Mathf.Max(0, value.y);
            MaskUtilities.Notify2DMaskStateChanged(this);
        }
    }
  2. _UIMaskSoftnessX_UIMaskSoftnessY的值越大,IN.mask.zw的值越小。当softness的值不为0时,会起到降低透明度的作用。

  3. 上面也提到,当点在矩形内时,点到矩形左下角和右上角的向量是反向的。而点越靠近矩形的中心,抵消的越彻底,两个向量之和的xy分量越小。saturate((_ClipRect.zw - _ClipRect.xy - abs(IN.mask.xy)) * IN.mask.zw)计算得到的透明度值越大。

矩形和向量演示

因此越靠近矩形中心的片元透明度越高,透明度由内到外逐渐递减,呈现一种缓慢变透明的遮罩效果,更加柔和。如下图所示,左侧是未设置softness的效果,右侧是设置softness为(10, 10)的效果。

softness效果

Mask、RectMask2D适用场景

Mask与RectMask2D对比

  1. Mask遮罩的大小与形状依赖于Graphic,而RectMask2D只需要依赖RectTransform
  • Mask是利用Graphic渲染时修改对应片元的模板值来确定遮罩的大小与形状的,Graphic的形状决定了Mask遮罩的形状。因此缺少Graphic组件,Mask遮罩将会失效。
  • RectMask2D是利用自己的RectTransform计算出裁剪矩形,然后降低不在矩形内的片元透明度来实现遮罩效果,因此不需要依赖Graphic组件。
  1. Mask支持圆形或其他形状遮罩, 而RectMask2D只支持矩形
  • Mask遮罩形状可以更加多样,由于Mask遮罩的形状由Graphic决定,所以利用不同的Graphic可以实现不同形状的遮罩。
  • 而RectMask2D通过RectTransform计算裁剪矩形的机制导致它只能支持矩形遮罩,仅在 2D 空间中有效,不能正确掩盖不共面的元素。
  1. Mask会增加drawcall
  • 除了绘制元素本身所需的1个drawcall以外,Mask还会额外增加2个drawcall。一个用来在绘制元素前修改模板缓冲的值,另一个用来在所有UI绘制完后将模板缓冲的值恢复原样。
  • 3次drawcall的区别主要在于模板参数的不同。第一次是总是通过(Stencil Comp:Always)模板测试,并将模板值替换(Stencil Pass:Replace)为1(Stencil Ref:1)。第二次是用于绘制Image的。第三次是总是通过(Stencil Comp:Always)模板测试,并将模板值设置为0(Stencil Pass:Zero),即起到擦除模板值的作用。
  • 多个Mask之间可以进行合批;Mask内外不能进行合批。
  1. RectMask2D可能会破坏合批

    网上查到的一些资料会得出“RectMask2D节点下的所有孩子都不能与外界UI节点合批且多个RectMask2D之间不能合批”的结论,实际上这是一种不严谨的说法,甚至是错误的。要搞清楚这个问题,需要先弄明白为什么RectMask2D会破坏合批?

    通过帧调试器可以发现,是RectMask2D传递裁剪矩形时,修改了Shader的参数,导致不能合批。从下图可以看到2次drawcall的区别就在于_ClipRect不同

    _ClipRect 1

    既然是裁剪矩形参数不同导致不能合批,那如果将两个裁剪矩形参数设置为一致是不是就能合批了呢?

    _ClipRect 2

    修改_ClipRect为相同的值后就能合批了。因此可以得出结论,RectMask2D确实由于裁剪矩形参数的设置会破坏合批,但不是一定的。在满足条件时,RectMask2D节点下的孩子也能与外界UI节点合批,多个RectMask2D之间也是能合批的。

Mask与RectMask2D用哪个?

Mask的实现利用了模板缓冲区,会增加2个drawcall,性能会受到一定影响。简单的UGUI界面,还是建议使用RectMask2D,相对来说性能更强,也无需额外的绘制调用。但由于RectMask2D也有可能破坏合批,在复杂的情况下,并没有确切的结论来判断哪个更优,只能利用工具实际测试找到最优者,具体问题具体分析才是正确做法。当然,诸如圆形遮罩等一些RectMask2D无法胜任的场景,还是要使用Mask。

参考

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

【详细解析版】Unity UGUI Mask组件实现原理

Mask源码(含注释)

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

Unity遮罩之Mask、RectMask2D与Sprite Mask适用场景分析


文章作者: 草莓多多
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 草莓多多 !
  目录