
浅析Unity Stencil Buffer和UGUI Mask
(至于他是怎么做到渲染完所有子物体再弹出这个材质球的你别问,问就是我是菜鸡我也不知道)
最近研究了下Shader里的StencilBuffer,之前一直是知道个大概,没有认真的研究过。
关于StencilBuffer的基础说明
StencilBuffer:模板缓存,是用于模板测试的一个专用缓冲区,每个像素点有八位的存储。
模板测试的位置大概在片元着色器中的最后几步,在深度测试和混合之前,引用一个别人的流程图
简单来说,希望遮罩别人的东西设置这个地方的StencilBuffer,然后后边绘制的东西通过条件比较(等于,不等于,大于小于等等)再加上各种参数来进行测试,测试失败就不能绘制,就没法进行后边的混合操作,也就是说这个物体在这个像素点上贡献不了颜色(进不了颜色缓冲区),从而达到了遮罩的效果。
关于stencil 的各种参数,网上有很多,我就不再详细一一介绍了,就大概说一下:
stencil
{
Ref StencilID//当前片元的参考值(0-255)
ReadMask readMask//读掩码
WriteMask writeMask//写掩码
Comp comprisonFunction//比较操作函数
Pass stencilOperation//测试通过之后进行操作
Fail stencilOperation//测试未通过进行的操作
ZFail stencilOperation//模板测试通过,深度测试未通过执行的操作
}
我来手动实现一个UI的Mask来简单说明一下这些参数的用法
unity里结构大概是这样
很简单,新建两个Image, 一个父物体做遮罩本身,一个子物体当被遮罩的东西
实际我们用遮罩的时候直接在parentGo上挂一个Mask组件就好了,但是为了学习和演示,我们不这么做。
我们新建两个材质球mat1和mat2 . 然后分别新建两个shader shader1和shader2 分别挂到两个材质球上。
ParentGo用mat1 , ChildGo用mat2
shader部分,我们从网上找到UIdefault的shader源码,复制一份,然后对其中stencil部分进行修改
shader1(遮罩部分的shader)
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 1
Comp Always
Pass Replace
ReadMask 255
WriteMask 255
}
shader2(被遮罩部分的shader)
Stencil
{
Ref 1
Comp Equal
Pass Keep
ReadMask 255
WriteMask 255
}
列出了stencil的改动部分,这样就实现了mask一样的效果,(alphaclip要勾选上)
过程大概是这样
按照UGUI从上到下的渲染顺序(简单的这么说一下)
先进行父物体的渲染
1.先看他的Comp(测试条件) 是always那就是永远都通过,好,再加上我们这个图片选了alphacilp,就是说这个图片中透明度不是0的像素,全都通过了模板测试,进行下一步
2.通过了之后怎么办呢,看这个Pass(通过之后的操作) 是Replace ,就是把当前的stencilbuffer换成我自己的id(就是ref 1),当然,在换之前要先把你的id 跟我们的writemask(写入遮罩)来做一个&的位运算,其实就是为了对这个id写入做了一些掩码限制,不能说id是什么就完全替换成什么,有些情况可能有很多层遮罩的时候我们希望一个遮罩只能写入影响stencilbuffer的其中一位,那就把这个WriteMask设置成那一位。我这里是255(即1111 1111)所有位都中门大开,好了,那现在这个黑色箭头有像素的部分的stencilbuffer 全都变成 1 了。注意stencilbuffer是屏幕空间上的变量哦,不是我这张箭头的image上所拥有的,是这个屏幕上,这个箭头所占的位置,stencilbuffer都变成1了,这样才会影响别的图片渲染。
然后到子物体
3.还是先看Comp(测试条件)Equal ,哦就是说我的id要和此时stencilBuffer里的相等才能通过测试,但是在比较之前,我们同时也要把我们的id先经过ReadMask来过滤一下,这样只比较我们想要的一位,之后再比较。
4.之后就如我们所见,蓝色按钮只有在黑色箭头内部才有显示,因为那里面通过了模板测试得以渲染,而外边的没通过的像素就被丢弃了。
5.在之后,通过了之后还要有什么操作呢,我们还要看Pass(通过之后还对stencilBuffer有什么操作) 是Keep ,哦就是保持不变,子物体不会影响stencilbuffer的值。
一个最简单的遮罩流程就完了,如果说要实现不显示遮罩本身的图片的话,那么我们在mat1的colormask属性直接设置为0,这样他本身的颜色就被阻挡住了。
这样就实现了 Mask里 这个效果
怎么样, 我讲的简单吧~哈哈哈。
关于UGUI的Mask浅析
好 简单的模板测试原理捋清楚了之后我们来直接研究Mask源码,先贴一下吧
using System;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.Rendering;
using UnityEngine.Serialization;
namespace UnityEngine.UI
{
[AddComponentMenu("UI/Mask", 13)]
[ExecuteAlways]
[RequireComponent(typeof(RectTransform))]
[DisallowMultipleComponent]
/// <summary>
/// A component for masking children elements.
/// </summary>
/// <remarks>
/// By using this element any children elements that have masking enabled will mask where a sibling Graphic would write 0 to the stencil buffer.
/// </remarks>
public class Mask : UIBehaviour, ICanvasRaycastFilter, IMaterialModifier
{
[NonSerialized]
private RectTransform m_RectTransform;
public RectTransform rectTransform
{
get { return m_RectTransform ?? (m_RectTransform = GetComponent<RectTransform>()); }
}
[SerializeField]
private bool m_ShowMaskGraphic = true;
/// <summary>
/// Show the graphic that is associated with the Mask render area.
/// </summary>
public bool showMaskGraphic
{
get { return m_ShowMaskGraphic; }
set
{
if (m_ShowMaskGraphic == value)
return;
m_ShowMaskGraphic = value;
if (graphic != null)
graphic.SetMaterialDirty();
}
}
[NonSerialized]
private Graphic m_Graphic;
/// <summary>
/// The graphic associated with the Mask.
/// </summary>
public Graphic graphic
{
get { return m_Graphic ?? (m_Graphic = GetComponent<Graphic>()); }
}
[NonSerialized]
private Material m_MaskMaterial;
[NonSerialized]
private Material m_UnmaskMaterial;
protected Mask()
{}
public virtual bool MaskEnabled() { return IsActive() && graphic != null; }
[Obsolete("Not used anymore.")]
public virtual void OnSiblingGraphicEnabledDisabled() {}
protected override void OnEnable()
{
base.OnEnable();
if (graphic != null)
{
graphic.canvasRenderer.hasPopInstruction = true;
graphic.SetMaterialDirty();
// Default the graphic to being the maskable graphic if its found.
if (graphic is MaskableGraphic)
(graphic as MaskableGraphic).isMaskingGraphic = true;
}
MaskUtilities.NotifyStencilStateChanged(this);
}
protected override void OnDisable()
{
// we call base OnDisable first here
// as we need to have the IsActive return the
// correct value when we notify the children
// that the mask state has changed.
base.OnDisable();
if (graphic != null)
{
graphic.SetMaterialDirty();
graphic.canvasRenderer.hasPopInstruction = false;
graphic.canvasRenderer.popMaterialCount = 0;
if (graphic is MaskableGraphic)
(graphic as MaskableGraphic).isMaskingGraphic = false;
}
StencilMaterial.Remove(m_MaskMaterial);
m_MaskMaterial = null;
StencilMaterial.Remove(m_UnmaskMaterial);
m_UnmaskMaterial = null;
MaskUtilities.NotifyStencilStateChanged(this);
}
#if UNITY_EDITOR
protected override void OnValidate()
{
base.OnValidate();
if (!IsActive())
return;
if (graphic != null)
{
// Default the graphic to being the maskable graphic if its found.
if (graphic is MaskableGraphic)
(graphic as MaskableGraphic).isMaskingGraphic = true;
graphic.SetMaterialDirty();
}
MaskUtilities.NotifyStencilStateChanged(this);
}
#endif
public virtual bool IsRaycastLocationValid(Vector2 sp, Camera eventCamera)
{
if (!isActiveAndEnabled)
return true;
return RectTransformUtility.RectangleContainsScreenPoint(rectTransform, sp, eventCamera);
}
/// 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;
}
}
}
想要全部弄懂是有些难度的,因为里面涉及的东西很多,尤其是MaskUtilities.NotifyStencilStateChanged相关的,我就浅析几个我觉得学到了的地方吧
1.核心函数
public virtual Material GetModifiedMaterial(Material baseMaterial)
这是接口
IMaterialModifier
的函数,简单来说就是修改当前组件所用材质球的函数,Mask这个类的核心操作都在这里,传入参数是一个baseMateril,然后我们通过种种设置和修改,最后返回了m_MaskMaterial,也就是把当前材质baseMateril–>m_MaskMaterial的过程。
2.计算遮罩深度
var rootSortCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);
var stencilDepth = MaskUtilities.GetStencilDepth(transform, rootSortCanvas);
注意这里要通过MaskUtilities.MaskUtilities.FindRootSortOverrideCanvas()来找到父级里最近的一个“根Canvas”,因为我们渲染是以canvas为单位渲染的,比如这样的结构
如果说,MaskC物体上挂了一个Canvas(并且选中了override sorting) 那比如我从maskD来找“跟Canvas”就会找到的是MaskC,就不会再往上边找了,换言之,在上边的遮罩就不会对item产生作用。
下边的函数用来查找遮罩的深度,从0开始的,假如大家都没有挂canvas的情况下,那么MaskA的depth是0 ,MaskB是1, 以此类推。
3.两个材质
[NonSerialized]
private Material m_MaskMaterial;
[NonSerialized]
private Material m_UnmaskMaterial;
首先,这两个材质都是通过这样设置的
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;
这里的StencilMaterial就是一个工具类,这里的Add()和Remove都是维护里面材质池的方法,不用太关心(主要我也看不懂),就知道他是通过后边那一长串参数来设置了新的材质球就行了。
这两个材质,我当时看了许久也没懂这个UnMaskMatrial到底是干嘛的,因为我看上边的MaskMatrial应该就是作为Mask的材质,给他设置stencil信息了,但是UnMaskMatrial是什么时候用到的呢?代码里只有graphic.canvasRenderer.SetPopMaterial(m_UnmaskMaterial, 0);这样操作,这个SetPopMaterial()里面的代码也不给看了。 经过查询和询问AI,我分析出来了,这个UnMaskMaterial是记录了这是模板缓冲之前的材质球。而graphic.canvasRenderer.SetPopMaterial(m_UnmaskMaterial, 0)这个函数很厉害,这里相当于是把这个材质球存到了里面,在渲染该物体以及其所有子物体之后,再次弹出他,再用他渲染一遍。
那为什么要这么做呢,你想想啊,模板缓冲区只有一份,你这个MaskMaterial渲染完之后,给stencilBuffer一顿霍霍,对于你的子物体来说,是正确的。但是总有不是你子物体的吧?我一个其他层级上的东西,只要在渲染顺序在你之后,都会受到现在被霍霍的stencilBuffer的影响,也给一顿裁剪,这对吗?这不对,我们Mask的初心肯定是只影响自己的子物体。于是,这个UnMaskMaterial就发挥作用了,他最后会用原先的材质球,给stencilBuffer恢复现场(至于他是怎么做到渲染完所有子物体再弹出这个材质球的你别问,问就是我是菜鸡我也不知道)
4.关于材质球的参数设置
仔细看代码,我们发现在计算遮罩信息的时候,他分情况写的,一种是如果是如果他是第一层遮罩(即他是“根”遮罩),一种是他不是第一层,他上边还有遮罩。我们直接看第二种复杂的(因为其实第一种根遮罩也可以套到第二种的算法里来,只是一种特殊情况)
var maskMaterial2 = StencilMaterial.Add(baseMaterial, desiredStencilBit | (desiredStencilBit - 1), StencilOp.Replace, CompareFunction.Equal, m_ShowMaskGraphic ? ColorWriteMask.All : 0, desiredStencilBit - 1, desiredStencilBit | (desiredStencilBit - 1));
这样看不直观,我们直接把他的参数整理竖着列出来
stencilId: desiredStencilBit | (desiredStencilBit - 1)
stencil Op: Replace
CompareFunction: Equal
ColorMask: m_ShowMaskGraphic ? ColorWriteMask.All : 0
ReadMask: desiredStencilBit - 1
WriteMask: desiredStencilBit | (desiredStencilBit - 1)
这样是不是清晰多了? 我们来配合例子讲解,这样清楚一些。假设这个mask是第三层mask,也就是说他父级以及父级以上还有两个Mask
那么此时这个desiredStencilBit 根据前文int desiredStencilBit = 1 << stencilDepth;这里第三层mask,stencilDepth是从0开始的,也就是说stencilDepth得出的应该是2。从而 1<<2 ,是向左移动两位 就是0000 0100(即十进制的4)
好接下来进行参数设置,我们先看CompareFunction (测试条件)不是always那么简单了,变成equal了,那就是说我本身作为遮罩,还需要和stencilbuffer里的id进行比较相等才能继续操作了,这很合理,因为你上边还有别的遮罩。比较之前,我们先看看我们的stencil id
desiredStencilBit | (desiredStencilBit - 1) 说实话,一开始看到这种写法,我反应是 ”当前遮罩和当前的上层遮罩“这种感觉。但后来一想不对,这样不还得整递归了吗?肯定不是。我看不出来,但是对位运算敏感的同学肯定一眼看出来,这是什么操作
desiredStencilBit 0000 0100
desiredStencilBit - 1 0000 0011
desiredStencilBit | (desiredStencilBit - 1) 0000 0111
哈哈,其实就是相当于把当前位以及右侧的位数全部变成1的一个操作,当然仅限于“二进制里的整数”才可以有这种效果
这么做的目的是什么呢,我所理解的就是,你是第三层遮罩么,意味着在你区域内能显示的东西,肯定也要经过了前两层遮罩的考验,所以右边两位也设置成1也是合理的。
而ReadMask的 desiredStencilBit - 1也很好理解了,0000 0011 这个就是只看你父级的遮罩部分,即右侧两个,就是说,我只是比较父级的两层 ,经过这个过滤 ,我将以 0000 0011 的形态进行接下来的模板测试.用一个公式来说就是这样
(referenceValue & readMask) comparisonFunction (stencilBufferValue & readMask)
即 缓冲区的后两位和当前物体的后两位相等,就通过测试。
想象一下,就是这个意思,如果该像素在父级的两个遮罩内,我们去操作,如果都不在父级的遮罩内,那不管在不在我本级的遮罩里,他都不会显示了,也就不用考虑了。
通过测试后的像素,一定是在父级和本级的遮罩内了 ,所以直接把 stencilId 0000 0111后三位写进去就行了。
核心逻辑大概就是这样~希望我讲清楚啦!
哦对了还有一个关键问题就是,这些遮罩是如何把子物体的stencil信息设置好的,这个应该就比较复杂了,涉及到好几个UI系统比较关键的类,我们以后有时间再说~
更多推荐
所有评论(0)