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.下面是简单的效果示例