前言 Unity游戏开发过程中,2D UI上常常会通过添加Mask或者RectMask2D组件进行UI遮罩显示。本章是为了深入学习理解Mask和RectMask2D原理相关知识,同时学习了解3D粒子和UI层级的混合显示方案 。
Mask Mask是指通过遮罩控制可见显示区域的一种遮罩显示。
UGUI里常见的遮罩有两种:
Mask
Rect2DMask
虽然都是实现Mask效果,但两个组件的底层遮罩实现原理却大不相同,接下来让我们深入Mask和Rect2DMask底层实现,深入理解Mask底层的相关知识,会后续3D和2D混合显示遮罩打下基础。
Mask组件 在深入了解Mask组件之前,我们需要了解一下什么是Stencil Buffer(模板缓存),Stencil Buffer是实现遮罩的底层渲染原理。
Stencil Buffer The stencil buffer stores an 8-bit integer value for each pixel in the frame buffer. Before executing the fragment shader for a given pixel, the GPU can compare the current value in the stencil buffer against a given reference value. This is called a stencil test. If the stencil test passes, the GPU performs the depth test. If the stencil test fails, the GPU skips the rest of the processing for that pixel. This means that you can use the stencil buffer as a mask to tell the GPU which pixels to draw, and which pixels to discard.
从上面的介绍可以看出,Stencil Buffer(模板缓存)不同于深度Buffer和颜色Buffer,不是用来存储深度或颜色信息的,而是一个每个像素分配8bit(0-255)用来存储Stencil比较值的地方。如果一个pixel通不过Stencil Test(类似Depth Test,但是是用来比较Stencil Buffer值的方式),那么这个pixel将不会再走后续渲染流程(Depth Test)。
Unity Stencil有很多参数,这里调几个重要的参数介绍:
comparisonOperation(Stencil值比较方式)
Stencil Test比较公式:
(ref & readMask) comparisonFunction (stencilBufferValue & readMask)
passOperation(Stencil值通过替换方式)
ref(引用参考值)
用于Stencil值比较或Stencil Pass后值替换等
ReadMask(Stencil比较掩码)
用于Stencil Test时值比较过滤,从上面的Stencil Test公式可以看到Ref和Stencil Buffer Value值都与ReadMask与之后在进行比较的
WriteMask(Stencil写掩码)
用于Stencil Test通过后passOperation值写入过滤,即与passOperation后的值进行比较过滤再写入Stencil Buffer
更多参数参考官网:
ShaderLab command: Stencil
Note:
Stencil Test发生Depth Test之前,Aplha Test和fragment shader之后。顺序是:Alpha Test -> fragment shader -> Stencil Test -> depth test
模板缓冲可以用来制作物体的遮罩、轮廓描边、阴影、遮挡显示等等效果
Mask+UI组件 先让我们尝试一下Mask组件的使用:
使用Mask组件前:
使用Mask组件后:
可以看到通过使用一个圆形遮罩我们成功的将√实现了圆形的遮罩显示,当同时右上角Status数据也可以看到,激活Mask给我们增加了1个Batches和2个SetPass calls,至于原因后续会讲到。
那么第一个疑问,我们想知道的是Mask组件式如何实现遮罩的了?
首先我打开FrameDebug看了下Image是实际绘制流程:
可以看到1个Mask和1张图绘制总共分为3步,绘制了3次Mesh。
打开Mask.cs源码,我们会看到两个跟材质挂钩的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 public class Mask : UIBehaviour , ICanvasRaycastFilter , IMaterialModifier { ****** [NonSerialized ] private Material m_MaskMaterial; [NonSerialized ] private Material m_UnmaskMaterial; 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 (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; } 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; } ****** }
从源码可以看到,Mask组件实现了IMaterialModifier接口。
IMaterialModifier is Interface which allows for the modification of the Material used to render a Graphic before they are passed to the CanvasRenderer.
UGUI我们是通过Canvas统一进行绘制渲染的,从上面的介绍可以看出要实现IMaterialModifier允许我们在传递给CanvasRenderer绘制渲染UI(Graphic)之前进行材质处理,而IMaterialModifier.GetModifiedMaterial()正是这么个接口。
打开StencilMaterial.cs查看源码StencilMaterials.Add()接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 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; } for (int i = 0 ; i < m_List.Count; ++i) { MatEntry ent = m_List[i]; if (ent.baseMat == baseMat && ent.stencilId == stencilID && ent.operation == operation && ent.compareFunction == compareFunction && ent.readMask == readMask && ent.writeMask == writeMask && ent.colorMask == colorWriteMask) { ++ent.count; return ent.customMat; } } 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; }
可以看到Mask在IMaterialModifies.GetModifiedMaterial()接口里是对UI的原始材质进行复制后,对Stencil(模板缓存)进行修改实现的遮罩效果。
结合首层Mask逻辑,我们来理一下遮罩原理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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; }
如果是第一层,stencilDepth值为0,目标模版缓存值(desiredStencilBit)为1。
Unity Mask先添加了基于baseMaterial(原始配置的材质球)新裁剪材质球maskMaterial,将新材质球的Stencile(模版参考比较值)值改为1 ,将StencilOp的模板操作设置成Replace(模版缓存比较通过则替换成新的模板参考比较值) ,CompareFunction(模版比较方式)设置成Always(模板测试总是通过) ,根据是否显示Mask图形将ColorWriteMask(颜色写入FrameBuffer的Mask值)设置成255(所有颜色值都写入)或0(所有颜色值都不写入) ,至于Stencile的ReadMask和WriteMask(第一层默认传255)
通过第1步的新增裁剪材质球maskMaterial处理,当我们渲染Mask图形的时候,ColorWriteMask会决定将Mask图形颜色写入,反之不写入(这一步决定了mask图形是否显示)。
通过第1步的新材裁剪质球maskMaterial处理,因为模板比较是Always(总是通过),结合模版参考值1和模板操作方式Replace,此时渲染完Mask图形后Stencil Ref值应该为1
接着创建unmaskMaterial材质球,并将其设置成遮罩Graphic.canvasRendererd的SetPopMaterial(个人理解是后续用于渲染完所有子对象后的额外材质球处理)用于取消Mask的相关设置,这一步发生在Mask图形所有子对象渲染完成时(从结果额外绘制了一个Mesh触发了unmaskMaterial逻辑来猜测的) ,后续详细介绍
接下来渲染遮罩图形的子对象tickImg
可以看到遮罩图形的子对象tickImg的模板相关设置是Stencil Ref(模版参考比较值)为1 ,StencilOp的模板操作是Keep(保持原模板缓存值),StencilComp的模板比较操作是Equal(模版值和模板参考值相同才通过模板测试) ,Stencil Write Mask(模版缓存值写入时的Mask值)值为0(不允许写入模板缓存值) ,Stencil Read Mask(模版缓存读取时的Mask值)值为1(只允许读取第一位的模板值) ,ColorMask(颜色写入Mask值,8bit标识,15为所有颜色值写入)值为15 。
我这里使用的是Unity自带的UI/Default Shader,这些模板值设置是发生在我给UI挂载对象时或者增删Mask组件时 。
因此子对象渲染时发现模版缓存值为1,且自身设置模板参考比较值也为1,同时模板比较方法是Equal,从而实现只有在Mask图形区域类模板缓存值为1的部分满足显示,从而只有Mask图形区域内的子对象显示部分通过了模板缓存测试,同时通过后因为StencilOp的模板操作是Keep所以模板缓存值不变依然是1
子对象渲染完成后,我们接着看第4步里unmaskMaterial的后续逻辑,unmaskMaterial的Stencil Ref(模版参考比较值)为1,StencilOp的模板操作是Zero(设置模板值到0),StencilComp的模板比较操作是Always(总是通过模板缓存测试),ColorMask为0(不写入任何颜色)
因为maskMaterial和子对象渲染完成后,模版缓存里的值为1,此时unmaskMaterial的StencilComp是Always,StencilOp的模板操作是Zero ,所以会将模板缓存里的值更新到0 ,同时因为ColorMask为0所以这一次unmaskMaterial的额外Mesh渲染绘制不会显示任何东西。
Note:
Mask实现遮罩效果底层原理是通过Stencil(模板缓存)
Mask挖洞+UI组件 在了解了Mask的底层原理后,让我们来看看游戏开发过程中还有一种最常见的需求,Mask挖洞。Mask和Mask挖洞字面意思,前者是在范围内的才显示,后者是不在范围内的才显示。
新手引导里需要挖洞显示下层按钮,需求上需要支持不同形状的挖洞,同时挖出来的洞要支持点击响应。
考虑到为了支持原生Mask组件流程,所以不继承去修改Mask的流程,既然Mask已经通过maskMaterial修改了stencil值,那么我们要做的就是将子对象Graph渲染时实现反向Stencil比较的方式实现反向显示,而Mask组件只作为挖洞形状的Image。
Graphic实现遮罩(不管是Mask还是RectMask2D)原理的核心逻辑是继承MaskableGraphic.cs(Image继承至MaskableGraphic),而针对Mask模版缓存实现遮罩的核心逻辑是接口IMaterialModifier的GetModifiedMaterial()方法
MaskableGraphi.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 public abstract class MaskableGraphic : Graphic , IClippable , IMaskable , 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 ; } Mask maskComponent = GetComponent<Mask>(); if (m_StencilValue > 0 && (maskComponent == null || !maskComponent.IsActive())) { 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; } ****** }
IMaterialModifier.GetModifiedMaterial()这个接口方法会在Graphic渲染之前允许对材质球进行计算操作。
可以看MaskableGraphic.cs的GetModifiedMaterial()方法对没有Mask组件的Image进行了Stencil相关的Shader参数设置,这里最重要的就是StencilID设置成(1 << m_StencilValue) - 1,Stencil的CompareFunction设置成Equal(模版缓存比较采取相等才通过)和Stencil的Operation设置成Keep(保持原始Stencil值)
因为如果上层有Mask组件,mask的字Graphic渲染时Stencil Value已经为1了,这里StencilID最终计算就会是1<<1 - 1=1
也就是说子Graphic要满足Stencil ID=1的时候才通过模板缓存且不改变Stencil Value值。
那么这个时候如果我们的Mask子Graphic要想实现反向遮罩,我们只需要将子Graphic的材质Stencil的CompareFunction设置成NotEqual即能实现挖洞反向显示的效果。
接下来我们通过继承Image,实现一个CustomImage.cs,然后通过重写IMaterialModifier的GetModifiedMaterial()方法来实现反向遮罩的Image组件。
CustomImage.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 public class CustomImage : Image { [Header("是否开启反向遮罩" ) ] public bool EnableInvertMask = false ; public override Material GetModifiedMaterial (Material baseMaterial ) { if (!EnableInvertMask) { return base .GetModifiedMaterial(baseMaterial); } return GetInvertMaskModifiedMaterial(baseMaterial); } protected Material GetInvertMaskModifiedMaterial (Material baseMaterial ) { var toUse = baseMaterial; if (m_ShouldRecalculateStencil) { var rootCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform); m_StencilValue = maskable ? MaskUtilities.GetStencilDepth(transform, rootCanvas) : 0 ; m_ShouldRecalculateStencil = false ; } Mask maskComponent = GetComponent<Mask>(); if (m_StencilValue > 0 && (maskComponent == null || !maskComponent.IsActive())) { var maskMat = StencilMaterial.Add(toUse, (1 << m_StencilValue) - 1 , StencilOp.Keep, CompareFunction.NotEqual, ColorWriteMask.All, (1 << m_StencilValue) - 1 , 0 ); StencilMaterial.Remove(m_MaskMaterial); m_MaskMaterial = maskMat; toUse = m_MaskMaterial; } return toUse; } }
CustomImageEditor.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 [CustomEditor(typeof(CustomImage)) ] [CanEditMultipleObjects ] public class CustomImageEditor : ImageEditor { private SerializedProperty mEnableInvertMask; protected override void OnEnable () { base .OnEnable(); mEnableInvertMask = serializedObject.FindProperty("EnableInvertMask" ); } public override void OnInspectorGUI () { base .OnInspectorGUI(); serializedObject.Update(); EditorGUILayout.PropertyField(mEnableInvertMask); serializedObject.ApplyModifiedProperties(); } }
核心代码是开启反向遮罩后我将CompareFunction.Equal修改成了CompareFunction.NotEqual
为了测试被遮挡按钮同步遮罩效果以及被遮挡按钮模拟遮挡点击效果,编写了MainUIInvertMask.cs脚本。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 public class MainUIInvertMask : MonoBehaviour { [Header("被遮挡的真正UI组件" ) ] public Button BtnMasked; [Header("反向遮罩按钮组件" ) ] public Button BtnInvertMask; void Start () { AddAllListeners(); } private void AddAllListeners () { BtnMasked.onClick.AddListener(OnBtnMaskedClick); BtnInvertMask.onClick.AddListener(OnBtnInvertMaskClick); } void Update () { SyncInvertMaskImagePos(); } private void SyncInvertMaskImagePos () { if (BtnMasked == null || BtnInvertMask == null ) { return ; } BtnInvertMask.transform.position = BtnMasked.transform.position; } private void OnBtnMaskedClick () { Debug.Log($"响应被遮挡按钮点击" ); } private void OnBtnInvertMaskClick () { Debug.Log($"响应遮挡按钮点击" ); BtnMasked?.onClick.Invoke(); } }
这里带Mask组件的是遮罩挖洞按钮,背景遮罩是全屏很大的Image(Mask的子对象),被遮挡的按钮在最底层。
效果如下:
可以看到我们拖动被遮罩的按钮时会发现遮挡按钮同步位置后实时实现了挖洞效果。
对于RawImage组件的挖洞原理相同(因为RawImage也继承了MaskableGraphic) ,我们也通过定义开关和重写GetModifiedMaterial()方法即可实现挖洞效果。
CustomRawImage.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 public class CustomRawImage : RawImage , ICanvasRaycastFilter { [Header("是否开启反向遮罩" ) ] public bool EnableInvertMask = false ; public override Material GetModifiedMaterial (Material baseMaterial ) { if (!EnableInvertMask) { return base .GetModifiedMaterial(baseMaterial); } return GetInvertMaskModifiedMaterial(baseMaterial); } protected Material GetInvertMaskModifiedMaterial (Material baseMaterial ) { var toUse = baseMaterial; if (m_ShouldRecalculateStencil) { var rootCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform); m_StencilValue = maskable ? MaskUtilities.GetStencilDepth(transform, rootCanvas) : 0 ; m_ShouldRecalculateStencil = false ; } Mask maskComponent = GetComponent<Mask>(); if (m_StencilValue > 0 && (maskComponent == null || !maskComponent.IsActive())) { var maskMat = StencilMaterial.Add(toUse, (1 << m_StencilValue) - 1 , StencilOp.Keep, CompareFunction.NotEqual, ColorWriteMask.All, (1 << m_StencilValue) - 1 , 0 ); StencilMaterial.Remove(m_MaskMaterial); m_MaskMaterial = maskMat; toUse = m_MaskMaterial; } return toUse; } }
CustomRawImageEditor.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 [CustomEditor(typeof(CustomRawImage), true) ] [CanEditMultipleObjects ] public class CustomRawImageEditor : RawImageEditor { private SerializedProperty mEnableInvertMask; protected override void OnEnable () { base .OnEnable(); mEnableInvertMask = serializedObject.FindProperty("EnableInvertMask" ); } public override void OnInspectorGUI () { base .OnInspectorGUI(); serializedObject.Update(); EditorGUILayout.PropertyField(mEnableInvertMask); serializedObject.ApplyModifiedProperties(); } }
可以看到通过自定义CustomRawImage我们也成功实现了RawImage组件的反向挖洞显示。
Note:
此方案Unity升级需要跟着同步MaskableGraphic的GetModifiedMaterial()的相关代码
Mask透明区域是否可点击+UI组件 前面的Mask挖洞点击圆形周边透明区域时会发现点击依然生效了,但这种情况我们只希望挖洞的区域响应点击,此时我们需要自定义CustomImage.cs用于支持透明区域穿透设置,Image的透明度穿透核心是通过修改alphaHitTestMinimumThreshold值(<=0表示全部可点击,>=1表示全不可点击,其他值表示小于该值不可点击)实现的 。
CustomImage.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 public class CustomImage : Image { [Header("是否开启反向遮罩" ) ] public bool EnableInvertMask = false ; [Header("是否激活透明Alpha透明可点击阈值" ) ] public bool EnableAlphaHitTestMinimusThreshold = false ; [Header("透明Alpha可点击阈值" ) ] public float AlphaHitTestMinimumThreshold = 0.1f ; protected override void Start () { base .Start(); UpdateAlphaHitTestMinimumThreshold(); } public void UpdateAlphaHitTestMinimumThreshold () { if (EnableAlphaHitTestMinimusThreshold) { alphaHitTestMinimumThreshold = AlphaHitTestMinimumThreshold; } } ****** }
CustomImageEditor.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 [CustomEditor(typeof(CustomImage)) ] [CanEditMultipleObjects ] public class CustomImageEditor : ImageEditor { private SerializedProperty mEnableInvertMask; private SerializedProperty mEnableAlphaHitTestMinimusThreshold; private SerializedProperty mAlphaHitTestMinimumThreshold; protected override void OnEnable () { base .OnEnable(); mEnableInvertMask = serializedObject.FindProperty("EnableInvertMask" ); mEnableAlphaHitTestMinimusThreshold = serializedObject.FindProperty("EnableAlphaHitTestMinimusThreshold" ); mAlphaHitTestMinimumThreshold = serializedObject.FindProperty("AlphaHitTestMinimumThreshold" ); } public override void OnInspectorGUI () { base .OnInspectorGUI(); serializedObject.Update(); EditorGUILayout.PropertyField(mEnableInvertMask); EditorGUI.BeginChangeCheck(); EditorGUILayout.PropertyField(mEnableAlphaHitTestMinimusThreshold); bool enableChanged = EditorGUI.EndChangeCheck(); EditorGUI.BeginChangeCheck(); EditorGUILayout.PropertyField(mAlphaHitTestMinimumThreshold); bool thresholdChanged = EditorGUI.EndChangeCheck(); serializedObject.ApplyModifiedProperties(); if (enableChanged || thresholdChanged) { UpdateAllAlphaHitTextMinimusThresholdoTargets(); } } private void UpdateAllAlphaHitTextMinimusThresholdoTargets () { if (targets == null ) { return ; } foreach (var targetObj in targets) { var penetrateImage = targetObj as CustomImage; if (penetrateImage != null ) { penetrateImage.UpdateAlphaHitTestMinimumThreshold(); EditorUtility.SetDirty(targetObj); } } } }
此时再去点圆形周边透明区域就会发现不会触发btnMask的点击事件了。
Note:
代码手动修改EnableAlphaHitTestMinimusThreshold或AlphaHitTestMinimumThreshold值时请手动调用UpdateAlphaHitTestMinimumThreshold()方法确保生效
修改透明可点击阈值时的Sprite必须勾选Read/Write
设置alphaHitTestMinimumThreshold实现透明点击穿透在RawImage这里是行不通的,因为alphaHitTextMinimusThreshold是Image组件的属性,但穿透原理还是一样的,自定义RawImage想实现透明点击穿透控制,核心是实现ICanvasRaycastFilter的IsRaycastLocationValid()方法(告知Canvas射线检测时是否参与检测),判定逻辑是通过将点击位置转换到自身Rectransform的相对位置后,基于显示Rect信息将点击相对位置转换成UV坐标去读取对应UV坐标的像素颜色信息比较Alpha值。
CustomRawImage.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 public class CustomRawImage : RawImage , ICanvasRaycastFilter { [Header("是否开启反向遮罩" ) ] public bool EnableInvertMask = false ; [Header("是否激活透明Alpha透明可点击阈值" ) ] public bool EnableAlphaHitTestMinimusThreshold = false ; [Header("透明Alpha可点击阈值" ) ] [Tooltip("<=0表示全部可点击,>1表示全不可点击,其他值表示小于该值不可点击" ) ] public float AlphaHitTestMinimumThreshold = 0.1f ; ****** public bool IsRaycastLocationValid (Vector2 screenPoint, Camera eventCamera ) { if (!EnableAlphaHitTestMinimusThreshold) { return true ; } if (AlphaHitTestMinimumThreshold <= 0f ) return true ; if (AlphaHitTestMinimumThreshold > 1f ) return false ; Texture tex = texture; if (tex == null ) return true ; Texture2D tex2D = tex as Texture2D; if (tex2D == null ) return true ; if (!tex2D.isReadable) { Debug.LogError($"TRawImage Alpha穿透检测需要纹理开启Read/Write Enabled: {tex2D.name} " , this ); return true ; } if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, screenPoint, eventCamera, out Vector2 localPoint)) { return false ; } Rect rect = GetPixelAdjustedRect(); float u = (localPoint.x - rect.x) / rect.width; float v = (localPoint.y - rect.y) / rect.height; Rect uv = uvRect; u = uv.x + u * uv.width; v = uv.y + v * uv.height; u = Mathf.Clamp01(u); v = Mathf.Clamp01(v); try { return tex2D.GetPixelBilinear(u, v).a >= AlphaHitTestMinimumThreshold; } catch (Exception ex) { Debug.LogError( $"TRawImage Alpha穿透检测读取纹理像素失败: {ex.Message} " , this ); return true ; } } }
CustomRawImageEditor.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 [CustomEditor(typeof(CustomRawImage), true) ] [CanEditMultipleObjects ] public class CustomRawImageEditor : RawImageEditor { ****** private SerializedProperty mEnableAlphaHitTestMinimusThreshold; private SerializedProperty mAlphaHitTestMinimumThreshold; ****** public override void OnInspectorGUI () { base .OnInspectorGUI(); serializedObject.Update(); EditorGUILayout.PropertyField(mEnableInvertMask); EditorGUILayout.PropertyField(mEnableAlphaHitTestMinimusThreshold); EditorGUILayout.PropertyField(mAlphaHitTestMinimumThreshold); DrawClearTextureButton(); serializedObject.ApplyModifiedProperties(); } ****** }
此时再去点圆形周边透明区域就会发现不会触发btnMask的点击事件了。
Note:
参与CustomRawImage反向挖洞功能开启的Texture需要开启Read/Write设置
Mask+粒子特效 Mask+自带ParticleAdditive Shader 在了解完Mask组件的常规UI遮罩原理后,接下来让我们尝试下UI上放入粒子特效
可以看到我将Unity自带的Particle Additive的Shader 导入工程并创建了一个材质球使用到了粒子系统身上,同时为了确保粒子特效显示层级在UI上,还专门设置了Sorting Layer ID为Default,Order in Layer为101(因为MainUI节点上挂了Canvas且设置的Sorting Layer为Default,Order in Layer为100) 。
让我们看下Unity自带的Particle Additive的Shader代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 Shader "Custom/Legacy Shaders/Particles/Additive" { Properties { _TintColor ("Tint Color" , Color) = (0.5 ,0.5 ,0.5 ,0.5 ) _MainTex ("Particle Texture" , 2 D) = "white" {} _InvFade ("Soft Particles Factor" , Range(0.01 ,3.0 )) = 1.0 } Category { Tags { "Queue" ="Transparent" "IgnoreProjector" ="True" "RenderType" ="Transparent" "PreviewType" ="Plane" } Blend SrcAlpha One ColorMask RGB Cull Off Lighting Off ZWrite Off SubShader { Pass { ****** } } }
可以看到默认的Particle Additive的Shader是没有带模板缓存相关的Shader代码支持的。渲染结果如下:
不出意外Particle Additive的Shader的粒子特效超出了Mask显示范围。
Mask+修改ParticleAdditive Mask Shader 接下来我们尝试改造Particle Additive的Shader,复制一份改名Particle Additive Mask同时创建一个对应的材质球(ParticleAdditiveMaskMat),赋值到particleSystem上。
为了匹配Mask的模版缓存判定,我将Stencil ID设置成1(模版缓存参考比较值),Stencil Comparison设置成3(Equal),Stencil Operation设置成0(Keep),ColorMask设置成15(所有颜色写入),这些设置是为了在Mask下去比较模板缓存值1的才通过,从而实现超出Mask区域部分不通过不绘制 。
让我们看看改造后的显示结果:
结果发现粒子特效啥都没显示,让我们看看FrameDebugger是怎么回事。
从FrameDebugger可以看到粒子特效的渲染和常规UI的渲染流程不一样,因为粒子特效是当做3D物体渲染的,所以他的渲染顺序不想UI子节点图形没有在Canvas的渲染流程里,而是Canvas渲染完成后才通过Draw Dynamic的方式绘制的。
还记得我们前面说的Mask组件在绘制完成后通过unmaskMaterial将模板缓存里的值已经修改成0了吗?
这个模版缓存值0也就是粒子特效不显示的原因,因为粒子特效Particle Additive Mask我们设置的Stencil ID(模版参考比较值)为1,Stencil Comparion为3(Equal),也就意味着粒子特效通不过模板缓存测试所以什么都不显示了。
仅使用自带UI Default Shader unmaskMaterial这个流程是Mask流程里自带的,不修改源码是无法修改的,所以目前看来想通过粒子特效支持模板缓存功能从而通过Mask遮罩显示的方案是行不通的。
既然我们已经知道模板缓存是Mask实现遮罩的核心原理,那么我们如果不使用Mask组件,直接导入自带的UI Default Shader,通过直接创建一个非Mask的UI Default材质,一个负责Mask的UI Default Mask材质和一个支持被UI Default Mask遮罩后显示的UI Default Masked材质来实现模版遮罩功。
可以看到三份材质球都是使用功能的UI Default Shader,但为了Mask不同情况下正确显示,分别设置了不同的Stencil ID,Stencil Comparison,Stencil Operation值。UIDefaultMat用于不需要管遮罩时,UIDefaultMaskMat用在当做遮罩(相当于Mask挂载的情况)时,UIDefaultMaskedMat用在在遮罩里需要被遮罩时。
为了方便查看层级显示和遮罩效果,我创建了一个红色的使用UI Default Mat的最底层图和一个黄色的使用UI Default Mat的最顶层图(同时添加了Canvas设置了order高于特效显示order)
来看看图片和特效相关的层级:
来看看运行时的效果:
可以看到无论是普通背景和前景图还是被遮罩的图还是粒子特效,通过赋予对应的UI Default Shader的材质球成功实现了Mask遮罩效果。
让我们来看看Frame Debugger是什么情况:
可以看到带遮罩的相关图形显示在第一个Canvas.RenderSubBatch里,因为使用Mask组件,所以没有产生unmaskMaterial的流程,所以第一个Canvas.RenderSubBatch最后一个Draw Mesh是被遮罩的那个勾选图形,因为设置的是UI Default Masked Mat(Stencil ID为1,Stencil Comparison为3(Equal),Stencil Operation为0(Keep))所以成功通过了模板缓存并遮罩显示。
接着看看粒子特效显示是什么情况:
粒子特效的显示和被遮罩的勾选图形原理和参数一致,紧接着第一个Canvas.RenderSubBatch之后渲染,所以也成功遮罩显示了。
接着看看defaultFrontImg前景图是什么情况:
可以看到因为我给defaultFrontImg添加了额外的Canvas并设置了比特效Order还高的Order,所以单独进行了一次Canvas.RenderSubBatch绘制,即第二个Canvas.RenderSubBatch绘制通过Stencil Ref值为0和Stencil Comparison为8(Always)成功将第二个Canvas渲染绘制的模板缓存值修改成0了。
可以看到我给defaultFrontImg设置的UI Default Mat(Stencil ID为0,Stencile Comparison为8(Always),Stencile Operation为0(Keep))完美符合了第二个Canvas.RenderSubBatch将模板值修改成0后的显示逻辑,所以成功绘制出来了。
总结:
虽然通过制作不同的UI Default材质球可以实现遮罩不同的使用情况,但游戏开发过程中遮罩情况可能是动态变化,无法提前预知哪些是用于遮罩,哪些是在遮罩内,哪些是在遮罩外,所以这种方案虽然能实现遮罩效果但并没有实战使用价值。
ParticleEffectForUGUI 接下来让我们看看Github上最强特效和UI显示层级解决方案:
ParticleEffectForUGUI
特点介绍:
Sortable: Sort particle effects and other UI elements by sibling index.
Maskable: Supports Mask or RectMask2D.
No extra components required: No need for an additional Camera, RenderTexture, or Canvas.
Trail module support: Fully supports the Trail module.
CanvasGroup alpha support: Integrates with CanvasGroup alpha.
No allocations: Efficiently renders particles without allocations.
Any canvas render mode support: Works with overlay, camera space, and world space.
Any Render pipeline support: Compatible with Universal Render Pipeline (URP) and High Definition Render Pipeline (HDRP).
省略
从上面介绍可以看到ParticleEffectForUGUI能将特效在UI排序显示,无需任何Camera,RenderTexture或者Canvas。支持CanvasGroup的Alpha,支持Canvas的各种Render Mode。兼容URP,HDRP等。
ParticleEffectForUGUI导入 我们通过Package Manager选择Git地址(注意#后面的数字是版本号 )导入:
1 https://github.com/mob-sakai/ParticleEffectForUGUI.git#4 .11.4
Note:
导入完成后如果想直接使用源码版本,可以直接从Package下拷贝到Asset目录下使用,然后删除Package Manager里的导入库即可。
扩展粒子特效Shader支持Stencil和Clip 粒子特效的Shader要像UI Default Shader一样支持UI的Mask和Clip功能。
Particle Add UI Mask.Shader
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 Shader "Custom/Legacy Shaders/Particles/Additive UI Mask" { 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 [Toggle(UNITY_UI_ALPHACLIP) ] _UseUIAlphaClip ("Use Alpha Clip" , Float) = 0 } Category { Tags { "Queue" ="Transparent" "IgnoreProjector" ="True" "RenderType" ="Transparent" "PreviewType" ="Plane" } Stencil { Ref [_Stencil] Comp [_StencilComp] Pass [_StencilOp] ReadMask [_StencilReadMask] WriteMask [_StencilWriteMask] } Blend SrcAlpha One ColorMask [_ColorMask] Cull Off Lighting Off ZWrite Off SubShader { Pass { ****** #include "UnityCG.cginc" #include "UnityUI.cginc" #pragma multi_compile __ UNITY_UI_CLIP_RECT #pragma multi_compile __ UNITY_UI_ALPHACLIP ****** struct v2f { ****** float4 worldPosition : TEXCOORD1; UNITY_FOG_COORDS(2 ) #ifdef SOFTPARTICLES_ON float4 projPos : TEXCOORD3; #endif UNITY_VERTEX_OUTPUT_STEREO }; float4 _MainTex_ST; float4 _ClipRect; v2f vert (appdata_t v ) { ****** o.worldPosition = v.vertex; ****** return o; } UNITY_DECLARE_DEPTH_TEXTURE(_CameraDepthTexture); float _InvFade; fixed4 frag (v2f i ) : SV_Target { ****** #ifdef UNITY_UI_CLIP_RECT col.a *= UnityGet2DClipping(i.worldPosition.xy, _ClipRect); #endif #ifdef UNITY_UI_ALPHACLIP clip(col.a - 0.001 ); #endif col.rgb *= col.a; return col; } ENDCG } } } }
核心是像UI Default Shader里加入了UNITY_UI_CLIP_RECT和UNITY_UI_ALPHACLIP以及Stencil模板相关的定义。
Note:
注意定义v2f的worldPosition : TEXCOORD1时会占用TEXCOORD1,后续UNITY_FOG_COORDs(2)和projPos:TEXCOORD3都得顺势往后延
制作粒子特效GameObject 第1种情况,从0制作粒子特效:
创建UIParticle GameObject
在UIParticle GameObject节点下只做粒子特效
做完粒子特效后点击UIParticle面板上的Refresh按钮
保存UIParticle GameObject当做特效使用
第二种情况,在已有的特效GameObject上制作粒子特效:
对已有粒子特效GameObject右键创建UIParticle
保存UIParticle GameObject当做特效使用
Note:
通过ParticleEffectForUGUI制作的粒子特效我们不再需要手动调整Order,我们可以像调整UI节点一样放到对应位置即可
要想UIParticle制作的特效支持Mask需要勾选UIParticle的Maskable
UIParticle缩放 UIParticle的显示大小根据UIParticle的AutoScalingMode设置不同而决定因素不同。UIParticle.scale是UIParticle提供控制粒子特效显示大小的一个参数。
粒子最终渲染尺寸的计算公式如下:
最终尺寸 = Scale3D值 × Canvas缩放因子 × 粒子系统自身缩放 × 父级UI元素缩放
UIParticle.AutoScalingMode是解决Canvas分辨率适配的核心参数 ,有三种模式:
看网上的说法Position Mode是Absolute或者Relative对于AutoScalingMode.Transform还是AutoScalingMode.UIParticle选择有影响,目前未做测试。
个人目前想法结论如下:
AutoScalingMode推荐UIParticle,制作粒子特效的Simulation Space推荐Local,UIParticle.scale创建时默认是10所有特效制作时基于scale修改成1的标准来做,这样我们设置UIParticle.scale缩放时就是相对1的比例来考虑(这样更符合类似Transform Scale缩放理念的设置),同时这种方式也支持我们去修改UIParticle自身Transform的localScale
Note:
UIParticle.scale无论在任何模式下都会对粒子特效的最终显示大小都有影响
UIParticle遮罩 Mask遮罩,就像常规Mask遮罩UI对象一样,将制作的UIParticle GameObject放到对应节点位置即可。
我们结合Mask遮罩来看看UIParticle是如何实现粒子特效支持Mask显示的。
首先看看粒子特效在FrameDebugger里是如何绘制的:
可以看到粒子特效这一次没有在被当做3D对象单独绘制而是在Canvas.RenderSubBatch流程里绘制且绘制时机是在unmaskMaterial清除模板缓存值之前,我想这就就是为什么UIParticle绘制的粒子特效为什么能支持UI Mask和RectMask2D的原因。
那么UIParticle是如何实现将UI粒子特效当做UI对象在Canvas里绘制的了?
首先UIParticle是取消了ParticleSystem的Renderer组件激活(避免3D粒子特效的渲染)
然后我们来看看UIParticle.cs的代码定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 [Icon("Packages/com.coffee.ui-particle/Editor/UIParticleIcon.png" ) ] [ExecuteAlways ] [RequireComponent(typeof(RectTransform)) ] [RequireComponent(typeof(CanvasRenderer)) ] public class UIParticle : MaskableGraphic , ISerializationCallbackReceiver { ****** private readonly List<UIParticleRenderer> _renderers = new List<UIParticleRenderer>(); private Camera _bakeCamera; protected override void UpdateMaterial () { } protected override void UpdateGeometry () { } ****** }
可以看到UIParticle组件添加时强制添加了CanvasRenderer 组件的,同时UIParticle继承了MaskGraphic 。
CanvasRenderer组件是UIParticle融入到Canvas渲染流程里的必要组件
MaskGraphic是UIParticle获取Canvas UI渲染能力和遮罩支持的关键
UIParticle通过重写MaskGraphic.UpdateMaterial和MaskGraphic.UpdateGeometry方法避免任何渲染和材质相关的逻辑。UIParticle真正的渲染逻辑是通过**UIParticleRenderer(也继承至MaskableGraphic)**类完成的,也就是UIParticle._renders这个成员对象。
核心渲染流程方法是UIParticle.UpdateRenderers(),UIParticle.UpdateRenderers()方法是在UIParticleUpdater.cs里通过注入Canvas.onAfterCanvasRebuild(在Canvas渲染前调用)流程调用的
UIParticle.UpdateRenderers()会先收集之前通过UIParticle面板Refresh按钮搜集到的所有粒子特效,创建相关UIParticleRenderer对象(这里就是UIParticle._renders构建的地方)
然后UIParticle通过GetBakeCamera()方法获取到当前绘制Canvas的UICamera,然后UICamera传递调用UIParticleRenderer.UpdateMesh(BakeCamera)方法
UIParticleRenderer.UpdateMesh()方法里,通过调用ParticleSystemRenderer.BakeMesh()方法传入指定摄像机,将通过该UICamera渲染的Mesh保存到CombineInstance类数据里
然后将所有烘焙得到的CombineInstance的Mesh数据合并到一个mesh里(workerMesh.CombineMeshes())
最后将烘焙的bakeMesh数据设置到UIParticleRenderer.canvasRenderer.SetMesh()的Mesh里,然后UIParticleRenderer通过canvasRenderer参与到Canvas的绘制流程里,从而实现了粒子特效转换成UI Mesh参与到Canvas绘制流程里的过程。
那么UIParticleRenderer是如何实现支持UGUI的Mask和RectMask2D遮罩功能的了?
UIParticleRenderer.GetModifiedMaterial()里调用了base.GetModifiedMaterial(),接下来让我们看看内部的代码实现:
MaskableGraphic.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 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 ; } Mask maskComponent = GetComponent<Mask>(); if (m_StencilValue > 0 && (maskComponent == null || !maskComponent.IsActive())) { 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; }
可以看到,通过获取当前节点的最上层Canvas获取到最近的m_StencilValue值,然后通过判定是否有Mask组件决定是否要触发StencilMaterial的材质球效果(Stencil ID值为(1<< m_StencilValue) - 1),StencilOp值为0(Keep),StencilComparison值为3(Equal),Stencil ReadMask值为(1 << m_StencilValue) - 1),Stencil WriteMask值为0)。也就是与模板Stencil缓存值相比一致就通过模板测试的maskMat。个人认为这也就是UIParticleRenderer支持Mask的原因。
那么UIParticle又是如何实现RectMask2D的遮罩支持的了?
核心是RectMask2D会在RectMask2D.PerformClipping()里遍历访问MaskableGraphic,并调用MaskableGraphic.SetClipRect(),让我们来看看MaskableGraphic.SetClipRect()的代码:
1 2 3 4 5 6 7 8 9 10 public virtual void SetClipRect (Rect clipRect, bool validRect ){ if (validRect) canvasRenderer.EnableRectClipping(clipRect); else canvasRenderer.DisableRectClipping(); }
可以看到这里应该是通过canvasRenderer.EnableRectClipping()成功将Shader里的 UNITY_UI_CLIP_RECT的Shader关键词激活了,后续应该是设置并传入了Shader里_ClipRect变量的裁剪区域值。
RectMask2D遮罩,就像常规RectMask2D遮罩UI对象一样,将制作的UIParticle GameObject放到对应节点位置即可。
至此我们成功通关ParticleEffectForUGUI插件实现了特效在UI里的Mask和RectMask2D遮罩显示。
UIParticle层级 通过ParticleEffectForUGUI制作的特效,在处理UI层级问题时可以直接向处理UI节点一样,只要保证节点顺序就能保证显示层级。
可以看到我制作的白色粒子特效夹在UI中间显示,绿色粒子特效在最顶层显示正确显示了
UIParticleAttractor UIParticleAttractor脚本是ParticleEffectForUGUI提供的一个队粒子做额外动画的组件。
比如指定粒子按半径的方式移动到目标UIParticleAttrctor所在为止。
可以看到粒子在运行指定延迟时间后按目标终点以指定路线方式(e.g. 直线,曲线等)移动。
效果挺神奇的,可以看出ParticleEffectForUGUI对粒子的控制做到了单个粒子级别。
RectMask2D组件 RectMask2D是通过CanvasRenderer的裁剪矩形(ClipRect)实现,底层是CPU裁剪顶点。
标准ParticleSystem渲染在Camera空间,走的是Renderer,不是UGUI的CanvasRenderer。
从上面的介绍可以看到正常流程粒子特效没走CanvasRenderer,是无法被RectMask2D裁剪的,如果想支持粒子被RectMask2D裁剪,有两种方案:
将粒子特效重定向到CanvasRenderer里去渲染
手动写代码将RectMask2D的裁剪区域传递到粒子特效Shader里去,然后写Shader代码去判定裁剪显示
将粒子特效重定向到CanvasRenderer里去渲染 此方案直接学习ParticleEffectForUGUI 组件,参考前面的ParticleEffectForUGUI学习
手动传递RectMask2D裁剪区域到粒子Shader 第二个方案参考:
Rect Mask2D遮住特效
Particle Add Mask.shader
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 Shader "Custom/Legacy Shaders/Particles/Additive Mask" { Properties { ****** _ClipRect ("ClipRect" , Vector) = (0 ,0 ,0 ,0 ) _UNITY_UI_CLIP_RECT("UNITY_UI_CLIP_RECT" , float ) = 0 } Category { ****** SubShader { Pass { ****** #include "UnityUI.cginc" float4 _ClipRect; float _UNITY_UI_CLIP_RECT; sampler2D _MainTex; fixed4 _TintColor; struct appdata_t { float4 vertex : POSITION; fixed4 color : COLOR; float2 texcoord : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID }; struct v2f { ****** float4 worldPosition : TEXCOORD1; UNITY_FOG_COORDS(2 ) #ifdef SOFTPARTICLES_ON float4 projPos : TEXCOORD3; #endif UNITY_VERTEX_OUTPUT_STEREO }; float4 _MainTex_ST; v2f vert (appdata_t v ) { ****** o.worldPosition = mul(unity_ObjectToWorld, v.vertex); #ifdef SOFTPARTICLES_ON o.projPos = ComputeScreenPos (o.vertex); COMPUTE_EYEDEPTH(o.projPos.z); #endif o.color = v.color; o.texcoord = TRANSFORM_TEX(v.texcoord,_MainTex); UNITY_TRANSFER_FOG(o,o.vertex); return o; } UNITY_DECLARE_DEPTH_TEXTURE(_CameraDepthTexture); float _InvFade; fixed4 frag (v2f i ) : SV_Target { ****** col.a *= pow(UnityGet2DClipping(i.worldPosition.xy, _ClipRect) + 1 , _UNITY_UI_CLIP_RECT) - _UNITY_UI_CLIP_RECT; return col; } ENDCG } } } }
Note:
注意定义v2f的worldPosition : TEXCOORD1时会占用TEXCOORD1,后续UNITY_FOG_COORDs(2)和projPos:TEXCOORD3都得顺势往后延
因为粒子特效默认不是在UI的CanvasRenderer模式下渲染的,所以__ UNITY_UI_CLIP_RECT和__ UNITY_UI_ALPHACLIP宏默认是不是起作用的
ParticleClip.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 public class ParticleClip : MonoBehaviour { [Header("RectMask2D裁剪区域" ) ] public Vector4 ClipRect; private Renderer[] mChildRenderers; void Start () { var mask = GetComponentInParent<UnityEngine.UI.RectMask2D>(); if (mask == null ) { return ; } mChildRenderers = GetComponentsInChildren<Renderer>(); if (mChildRenderers == null || mChildRenderers.Length == 0 ) { return ; } Vector3[] vector3s = new Vector3[4 ]; mask.rectTransform.GetWorldCorners(vector3s); ClipRect = new Vector4(vector3s[0 ].x, vector3s[0 ].y, vector3s[2 ].x, vector3s[2 ].y); foreach (var renderer in mChildRenderers) { var rendererMaterial = renderer.material; if (rendererMaterial == null ) { continue ; } if (rendererMaterial.HasVector("_ClipRect" )) { rendererMaterial.SetVector("_ClipRect" , ClipRect); } if (rendererMaterial.HasFloat("_UNITY_UI_CLIP_RECT" )) { rendererMaterial.SetFloat("_UNITY_UI_CLIP_RECT" , 1 ); } } } }
可以看到通过ParticleClip.cs脚本,我成功将RectMask2D的裁剪区域数据和是否启用UNITY_UI_CLIP_RECT的数据传递到了Particle Add Mask.shader,让我们看看最终效果:
可以看到通过传递自定义RectMask2D裁剪区域以及粒子特效Shader支持裁剪区域显示判定,我们成功实现了支持RectMask2D的粒子特效遮罩显示
总结:
此方案虽然能实现粒子特效的RectMask2D的遮罩效果,但节点挂载情况无时无刻都在变化,此方案不适合放到实战里去运用,只做为学习理解RectMask2D的裁剪原理
学习总结
Mask的底层遮罩原理是使用了模板缓存机制
RectMask2D的底层遮罩原理是利用C#层计算裁剪区域,然后传递给Shader开启UNITY_UI_CLIP_RECT进行裁剪区域显示判定
粒子特效默认是当做3D物体渲染,不参与Canvas Renderer流程,ParticleEffectForUGUI是通过将粒子特效自定义绘制到MaskableGraphic的UIParticleRenderer组件从而实现的粒子特效参与到Canvas Renderer以及支持Mask和RectMask2D遮罩的
Github ParticleEffectForUGUI
RenderOrderAndUIMask
Reference 走进 Stencil Buffer 系列 0 : 模板缓冲和模板测试是什么?
模板测试(Stencil Test)基本概念
ShaderLab: Stencil Buffer 的理解和应用
Unity 粒子特效在模型前面显示 unity ui粒子特效
UI上的特效的裁剪问题
解决Unity UI粒子缩放异常:ParticleEffectForUGUI的Scale参数完全指南