uGUI的Mask原理及UI特效裁剪

本文深入探讨了Unity UI系统中Mask组件的工作原理,详细解释了如何通过模板测试实现UI组件的裁剪效果。此外,还介绍了如何利用Mask组件原理自定义特效裁剪组件。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

uGUI的Mask组件会对子节点的UI组件进行裁剪

查看相关文档源码可知其裁剪过程是通过模板测试实现的,下面来看下具体的实现细节

 

1.首先从Unity官网下载对应版本的Unity内置shader源码

UI组件的默认材质使用的shader为UI-Default.shader

其中不算参数定义部分,模板测试相关的shader代码只有下面这部分,代码具体含义参见Unity文档

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

参数定义部分

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
}

 

2.下图是一个简单的Mask示例,只有一个带黑色底的Mask和一个白色圆形Image,Image超出Mask的部分被裁剪掉了

打开Unity自带的FrameDebugger,可以看到这两个UI节点产生了3个DrawCall

  • 第1个绘制了Mask的黑色底并把该区域像素点的模板缓冲区的值置为1

         

  • 第2个绘制Image,绘制时逐像素以模板值1和模板缓冲区中的值进行对比,相等则绘制像素点,不相等则不绘制,所以达到了区域裁剪的效果​

        

  • 第3个把Mask的底又绘制了一遍并把该区域的模板缓冲区的值重置为0,不过设置了ColorMask为0,所以没有绘制出黑色底,相当于只对模板缓冲区进行了重置

        

 

3.Mask组件和UI组件父类MaskableGraphic都实现了IMaterialModifier接口,该接口中的函数GetModifiedMaterial在Graphic.materialForRendering中被调用,即它们都是在这个阶段对材质进行了修改

/// <summary>
/// The material that will be sent for Rendering (Read only).
/// </summary>
/// <remarks>
/// This is the material that actually gets sent to the CanvasRenderer. By default it's the same as [[Graphic.material]]. When extending Graphic you can override this to send a different material to the CanvasRenderer than the one set by Graphic.material. This is useful if you want to modify the user set material in a non destructive manner.
/// </remarks>
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;
    }
}

MaskableGraphic中的GetModifiedMaterial实现如下

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

Mask中的GetModifiedMaterial实现如下


/// Stencil calculation time!
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;

    // if we are at the first level...
    // we want to destroy what is there
    if (desiredStencilBit == 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;
        graphic.canvasRenderer.popMaterialCount = 1;
        graphic.canvasRenderer.SetPopMaterial(m_UnmaskMaterial, 0);

        return m_MaskMaterial;
    }

    //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;
}

两段代码的主要逻辑都是根据UI层级上的Mask和Canvas确定当前UI的模板值,并调用StencilMaterial.Add函数来获取带有指定模板相关参数的材质

 

4.比较有意思的是Mask中多出来的对canvasRenderer中pop材质的设置

CanvasRenderer虽然有源码,但是只是一个底层实现的包装类,看不到实现细节。不过根据上面FrameDebugger的结果看,设置canvasRenderer中的pop材质后,这个canvasRenderer会在所有子节点渲染完成后使用设置的pop材质把当前节点上的UI再渲染一遍,从上面的示例可以看到Mask在所有子节点UI渲染完成后使用这个pop材质来重置模板缓冲区(感觉在某些特定的情况下可以使用canvasRenderer的pop材质实现一些有意思的效果)

不过在这里如果把Mask中设置pop材质的逻辑去掉,即不重置模板缓冲区是否会对Mask效果产生影响呢

下面的代码实现了一个不重置模板缓冲区的OilsMask组件

using System;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.UI;

// 不回写StencilBuffer值的Mask
public class OilsMask : Mask
{
    [NonSerialized]
    private Material myMaskMaterial;

    protected override void OnDisable()
    {
        StencilMaterial.Remove(myMaskMaterial);
        myMaskMaterial = null;
        base.OnDisable();
    }

    // 拷贝自ugui源码,去除了回写StencilBuffer值的逻辑
    public override Material GetModifiedMaterial(Material baseMaterial)
    {
        if (!MaskEnabled())
            return baseMaterial;

        var rootSortCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
        var stencilDepth = MaskUtilities.GetStencilDepth(transform, rootSortCanvas);
        if (stencilDepth >= 8)
        {
            Debug.LogError("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)
        {
            var maskMaterial = StencilMaterial.Add(baseMaterial, 1, StencilOp.Replace, CompareFunction.Always, showMaskGraphic ? ColorWriteMask.All : 0);
            StencilMaterial.Remove(myMaskMaterial);
            myMaskMaterial = maskMaterial;
            return myMaskMaterial;
        }

        //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, showMaskGraphic ? ColorWriteMask.All : 0, desiredStencilBit - 1, desiredStencilBit | (desiredStencilBit - 1));
        StencilMaterial.Remove(myMaskMaterial);
        myMaskMaterial = maskMaterial2;
        graphic.canvasRenderer.hasPopInstruction = false;
        return myMaskMaterial;
    }
}

一般情况下OilsMask和Mask基本效果一致,还比Mask少个DrawCall,但在下图的情况下两者的裁剪效果会有点不一样,由于OilsMask没有重置模板缓冲区,且UI层级上同级的节点获取到的模板值是一样的,所以下图中的OilsMask2节点下的Image在OilsMask1的区域中也会显示(感觉可以用于实现特定的裁剪效果)

上图中如果想得到和Mask一样的裁剪效果,可以实现一个重置指定区域模板缓冲区的组件,比如下面的StencilClearer组件

using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.UI;

public class StencilClearer : Graphic,IMaterialModifier
{
    private Material myMaskMaterial;

    protected override void OnDisable()
    {
        base.OnDisable();
        StencilMaterial.Remove(myMaskMaterial);
        myMaskMaterial = null;
    }

    public Material GetModifiedMaterial(Material baseMaterial)
    {
        var maskMat = StencilMaterial.Add(baseMaterial, 0, StencilOp.Zero, CompareFunction.Always, 0);
        StencilMaterial.Remove(myMaskMaterial);
        myMaskMaterial = maskMat;

        return maskMat;
    }
}

在OilsMask1后面插入带有StencilClearer组件且区域和OilsMask1一致的节点,重置OilsMask1区域的模板缓冲区,就得到和Mask一样的裁剪效果

 

 

5.使用OilsMask保留了模板缓冲区中的值,就可以使用模板测试对后续渲染的UI特效或模型进行裁剪了

参照uGUI的Mask实现原理,使用模板测试对UI特效进行裁剪,首先需要特效使用到的shader支持模板测试

让一个shader支持模板测试很简单,比如想让Unity的Standerd材质支持模板测试,可以从上面下载的Unity内置shader源码中找到Standard.shader,把shader名字改为Standard Maskable,在Properties段中加入指定参数定义,然后在两个SubShader段中都加入Stencil段,或者用一个Category段将两个SubShader段包起来后在Category段中加入Stencil段,就得到一个支持模板测试的Standard材质shader了,修改后的shader代码大致如下

Shader "Standard Maskable"
{
    Properties
    {
        ..............
		
		_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
    }

    ...........

    Category
    {
		Stencil
		{
			Ref [_Stencil]
			Comp [_StencilComp]
			Pass [_StencilOp]
			ReadMask [_StencilReadMask]
			WriteMask [_StencilWriteMask]
		}
		

		SubShader
		{
			.........
        }
		
		SubShader
		{
			........
		}
	}

    FallBack "VertexLit"
    CustomEditor "StandardShaderGUI"
}

后续要用到源码中的StencilMaterial.Add函数,实现大致如下,可以看到函数要求shader中必须要有上面Properties段中这些参数定义(虽然这里的_ColorMask参数实际并没有用到)

/// <summary>
/// Add a new material using the specified base and stencil ID.
/// </summary>
public static Material Add(Material baseMat, int stencilID, StencilOp operation, CompareFunction compareFunction, ColorWriteMask colorWriteMask, int readMask, int writeMask)
{
    if ((stencilID <= 0 && colorWriteMask == ColorWriteMask.All) || baseMat == null)
        return baseMat;

    if (!baseMat.HasProperty("_Stencil"))
    {
        Debug.LogWarning("Material " + baseMat.name + " doesn't have _Stencil property", baseMat);
        return baseMat;
    }
    if (!baseMat.HasProperty("_StencilOp"))
    {
        Debug.LogWarning("Material " + baseMat.name + " doesn't have _StencilOp property", baseMat);
        return baseMat;
    }
    if (!baseMat.HasProperty("_StencilComp"))
    {
        Debug.LogWarning("Material " + baseMat.name + " doesn't have _StencilComp property", baseMat);
        return baseMat;
    }
    if (!baseMat.HasProperty("_StencilReadMask"))
    {
        Debug.LogWarning("Material " + baseMat.name + " doesn't have _StencilReadMask property", baseMat);
        return baseMat;
    }
    if (!baseMat.HasProperty("_StencilWriteMask"))
    {
        Debug.LogWarning("Material " + baseMat.name + " doesn't have _StencilWriteMask property", baseMat);
        return baseMat;
    }
    if (!baseMat.HasProperty("_ColorMask"))
    {
        Debug.LogWarning("Material " + baseMat.name + " doesn't have _ColorMask property", baseMat);
        return baseMat;
    }

    
    ...........
    
    return newEnt.customMat;
}

 

6.shader支持模板测试后,需要在运行时给材质赋予正确的模板值和模板相关参数,让特效能够被正确的裁剪,下面的代码参照MaskableGraphic实现了一个MaskableEffect组件用于修改材质,将MaskableEffect组件挂载到所有带渲染器的特效节点上或模型节点上就可以了

using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.Rendering;
using UnityEngine.UI;

[RequireComponent(typeof(Renderer))]
public class MaskableEffect : UIBehaviour, IMaskable
{
    //public bool myUseSharedMat = true;
    Renderer myRenderer;
    int myMatCount;
    Material[] mySharedMats;
    Material[] myMaskMats;
    bool myIsDirty;

    void TryInit()
    {
        if (myRenderer == null)
        {
            myRenderer = GetComponent<Renderer>();
            mySharedMats = myRenderer.sharedMaterials;
            myMatCount = mySharedMats.Length;
            myMaskMats = new Material[myMatCount];
        }
    }

    protected override void OnEnable()
    {
        base.OnEnable();
        myIsDirty = true;
    }

    protected override void OnDestroy()
    {
        base.OnDestroy();
        for (int i = 0; i < myMatCount; i++)
        {
            StencilMaterial.Remove(myMaskMats[i]);
            myMaskMats[i] = null;
        }
    }

    protected override void OnTransformParentChanged()
    {
        //Debug.Log("OnTransformParentChanged");
        base.OnTransformParentChanged();
        if (!isActiveAndEnabled)
            return;
        myIsDirty = true;
    }

    protected override void OnCanvasHierarchyChanged()
    {
        //Debug.Log("OnCanvasHierarchyChanged");
        base.OnCanvasHierarchyChanged();
        if (!isActiveAndEnabled)
            return;
        myIsDirty = true;
    }

    public void RecalculateMasking()
    {
        if (!isActiveAndEnabled)
            return;
        myIsDirty = true;
    }

    void OnWillRenderObject()
    {
        if (myIsDirty)
        {
            myIsDirty = false;
            SetMaskMat();
        }
    }

    void SetMaskMat()
    {
        if (!Application.isPlaying)
            return;

        TryInit();
        if (myRenderer == null || myMatCount == 0)
            return;

        var rootCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
        var stencilValue = MaskUtilities.GetStencilDepth(transform, rootCanvas);
        for (int i = 0; i < myMatCount; i++)
        {
            var maskMat = StencilMaterial.Add(mySharedMats[i], (1 << stencilValue) - 1, StencilOp.Keep, CompareFunction.Equal, ColorWriteMask.All, (1 << stencilValue) - 1, 0);
            StencilMaterial.Remove(myMaskMats[i]);
            myMaskMats[i] = maskMat;
        }
        myRenderer.materials = myMaskMats;
    }
}

 

7.下面是简单的效果示例

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值