#unity/日常积累 ## 前言 > UGUI的裁切分为Mask和Mask2D两种 ## 目录 - Mask原理分析 - RectMask2D原理分析 - RectMask2D和Mask的性能区分 ## 一、Mask原理分析 > Mask:IMaskable,IMaterialModifier 我们先来看Mask。它可以给Mask指定一张裁切图裁切子元素。我们给Mask指定了一张圆形图片,那么子节点下的元素都会被裁切在这个圆形区域中。 ![[Pasted image 20240104145650.png]] **Mask的实现原理:** > 1. Mask会赋予Image一个特殊的材质,这个材质会给Image的每个像素点进行标记,将标记结果存放在一个缓存内(这个缓存叫做 **Stencil Buffer**) > 2. 当子级UI进行渲染的时候会去检查这个 Stencil Buffer内的标记,如果当前覆盖的区域存在标记(即该区域在Image的覆盖范围内),进行渲染,否则不渲染 ### 1.1 StencilBuffer 看起来好像挺简单的,那么背后的功臣——StencilBuffer,究竟是何方神圣呢? 简单来说,GPU为每个像素点分配一个称之为**StencilBuffer的1字节大小的内存区域**,这个区域可以用于保存或丢弃像素的目的。 我们举个简单的例子来说明这个缓冲区的本质。 ![[Pasted image 20240104145658.png]] 如上图所示,我们的场景中有1个红色图片和1个绿色图片,黑框范围内是它们重叠部分。一帧渲染开始,首先绿色图片将它覆盖范围的每个像素颜色“画”在屏幕上,然后红色图片也将自己的颜色画在屏幕上,就是图中的效果了。 这种情况下,重叠区域内红色完全覆盖了绿色。接下来,我们为绿色图片添加Mask组件。于是变成了这样: ![[Pasted image 20240104145705.png]] 此时一帧渲染开始,首先绿色图片将它覆盖范围都涂上绿色,同时将每个像素的stencil buffer值设置为1,此时屏幕的stencil buffer分布如下: ![[Pasted image 20240104145713.png]] 然后轮到红色图片“绘画”,它在涂上红色前,会先取出这个点的stencil buffer值判断,在黑框范围内,这个值是1,于是继续画红色;在黑框范围外,这个值是0,于是不再画红色,最终达到了图中的效果。 所以从本质上来讲,stencil buffer是为了实现多个“绘画者”之间互相通信而存在的。由于gpu是流水线作业,它们之间无法直接通信,所以通过这种**共享数据区**的方式来传递消息,从而达到一些“不可告人”的目的。 ### 1.2 Unity Shader 理解了stencil的原理,我们再来看下它的语法。在unity shader中定义的语法格式如下 **(中括号内是可以修改的值,其余都是关键字)**: ```csharp Stencil { Ref [_Stencil]//Ref表示要比较的值;0-255 Comp [_StencilComp]//Comp表示比较方法(等于/不等于/大于/小于等); Pass [_StencilOp]// Pass/Fail表示当比较通过/不通过时对stencil buffer做什么操作 // Keep(保留) // Replace(替换) // Zero(置0) // IncrementSaturate(增加) // DecrementSaturate(减少) ReadMask [_StencilReadMask]//ReadMask/WriteMask表示取stencil buffer的值时用的mask(即可以忽略某些位); WriteMask [_StencilWriteMask] } ``` 翻译一下就是:将stencil buffer的值与ReadMask与运算,然后与Ref值进行Comp比较,结果为true时进行Pass操作,否则进行Fail操作,操作值写入stencil buffer前先与WriteMask与运算。 **1.2.1 UI/Default** 最后,我们来看下Unity渲染UI组件时默认使用的Shader——UI/Default(略去了一些不相关内容): ```csharp Shader "UI/Default" { 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 } ··· } ``` ### 1.3 mask的源码实现 了解了stencil,我们再来看mask的源码实现 由于裁切需要同时裁切图片和文本,所以Image和Text都会派生自MaskableGraphic。 **如果要让Mask节点下的元素裁切,那么它需要占一个DrawCall,因为这些元素需要一个新的Shader参数来渲染。** 如下代码所示,MaskableGraphic实现了IMaterialModifier接口, 而StencilMaterial.Add()就是设置Shader中的裁切参数。 ```csharp MaskableGraphic.cs 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 optimisation // it adds some coupling between components though :( // 如果我们用了Mask,它会生成一个mask材质, Mask maskComponent = GetComponent(); if (m_StencilValue > 0 && (maskComponent == null || !maskComponent.IsActive())) { //设置模板缓冲值,并且设置在该区域内的显示,不在的裁切掉 var maskMat = StencilMaterial.Add(toUse, // Material baseMat (1 << m_StencilValue) - 1, // 参考值 StencilOp.Keep, // 不修改模板缓存 CompareFunction.Equal, // 相等通过测试 ColorWriteMask.All, // ColorMask (1 << m_StencilValue) - 1, // Readmask 0); // WriteMas StencilMaterial.Remove(m_MaskMaterial); //并且更换新的材质 m_MaskMaterial = maskMat; toUse = m_MaskMaterial; } return toUse; } ``` Image对象在进行Rebuild()时,UpdateMaterial()方法中会获取需要渲染的材质,并且判断当前对象的组件是否有继承IMaterialModifier接口, 如果有那么它就是绑定了Mask脚本,接着调用上面提到的GetModifiedMaterial方法修改材质上Shader的参数。 ```csharp Image.cs protected virtual void UpdateMaterial() { if (!IsActive()) return; //更新刚刚替换的新的模板缓冲的材质 canvasRenderer.materialCount = 1; canvasRenderer.SetMaterial(materialForRendering, 0); canvasRenderer.SetTexture(mainTexture); } public virtual Material materialForRendering { get { //遍历UI中的每个Mask组件 var components = ListPool.Get(); GetComponents(typeof(IMaterialModifier), components); //并且更新每个Mask组件的模板缓冲材质 var currentMat = material; for (var i = 0; i < components.Count; i++) currentMat = (components[i] as IMaterialModifier).GetModifiedMaterial(currentMat); ListPool.Release(components); //返回新的材质,用于裁切 return currentMat; } } ``` 因为模板缓冲可以提供模板的区域,也就是前面设置的圆形图片,所以最终会将元素裁切到这个圆心图片中。 **1.3.1 Mask.GetModifiedMaterial** ```csharp Mask.cs /// Stencil calculation time! public virtual Material GetModifiedMaterial(Material baseMaterial) { if (!MaskEnabled()) return baseMaterial; var rootSortCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform); var stencilDepth = MaskUtilities.GetStencilDepth(transform, rootSortCanvas); // stencil只支持最大深度为8的遮罩 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, 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; } ``` Mask 组件调用了模板材质球构建了一个自己的材质球,因此它使用了实时渲染中的模板方法来裁切不需要显示的部分,所有在 Mask 组件的子节点都会进行裁切。 ### **我们可以说 Mask 是在 GPU 中做的裁切,使用的方法是着色器中的模板方法。** --- ## 二、RectMask2D原理分析 > RectMask2D:**IClippable** **RectMask2D的工作流大致如下:** > ①C#层:找出父物体中所有RectMask2D覆盖区域的交集(**FindCullAndClipWorldRect**) > ②C#层:所有继承MaskGraphic的子物体组件调用方法设置剪裁区域(**SetClipRect**)传递给Shader > ③Shader层:接收到矩形区域_ClipRect,片元着色器中判断像素是否在矩形区域内,不在则透明度设置为0(**UnityGet2DClipping** ) > ④Shader层:丢弃掉alpha小于0.001的元素(**clip (color.a - 0.001)**) 接着我们再来细究RectMask2D的原理,在前面介绍Canvas.willRenderCanvases()时在PerformUpdate方法中会调用ClipperRegistry.instance.Cull();来处理界面中所有的Mask2D裁切。 ```csharp CanvasUpdateRegistry.cs protected CanvasUpdateRegistry() { Canvas.willRenderCanvases += PerformUpdate; } private void PerformUpdate() { //...略 // 开始裁切Mask2D ClipperRegistry.instance.Cull(); //...略 } ClipperRegistry.cs public void Cull() { for (var i = 0; i < m_Clippers.Count; ++i) { m_Clippers[i].PerformClipping(); } } ``` RectMask2D会在OnEnable()方法中,将当前组件注册ClipperRegistry.Register(this); 这样在上面ClipperRegistry.instance.Cull();方法时就可以遍历所有Mask2D组件并且调用它们的PerformClipping()方法了。 PerformClipping()方法,需要找到所有需要裁切的UI元素,因为Image和Text都继承了IClippable接口,最终将调用Cull()进行裁切。 ```csharp RectMask2D.cs protected override void OnEnable() { //注册当前RectMask2D裁切对象,保证下次Rebuild时可进行裁切。 base.OnEnable(); m_ShouldRecalculateClipRects = true; ClipperRegistry.Register(this); MaskUtilities.Notify2DMaskStateChanged(this); } public virtual void PerformClipping() { //...略 bool validRect = true; Rect clipRect = Clipping.FindCullAndClipWorldRect(m_Clippers, out validRect); bool clipRectChanged = clipRect != m_LastClipRectCanvasSpace; if (clipRectChanged || m_ForceClip) { foreach (IClippable clipTarget in m_ClipTargets) //把裁切区域传到每个UI元素的Shader中[划重点!!!] clipTarget.SetClipRect(clipRect, validRect); m_LastClipRectCanvasSpace = clipRect; m_LastValidClipRect = validRect; } foreach (IClippable clipTarget in m_ClipTargets) { var maskable = clipTarget as MaskableGraphic; if (maskable != null && !maskable.canvasRenderer.hasMoved && !clipRectChanged) continue; // 调用所有继承IClippable的Cull方法 clipTarget.Cull(m_LastClipRectCanvasSpace, m_LastValidClipRect); } } MaskableGraphic.cs public virtual void Cull(Rect clipRect, bool validRect) { var cull = !validRect || !clipRect.Overlaps(rootCanvasRect, true); UpdateCull(cull); } private void UpdateCull(bool cull) { var cullingChanged = canvasRenderer.cull != cull; canvasRenderer.cull = cull; if (cullingChanged) { UISystemProfilerApi.AddMarker("MaskableGraphic.cullingChanged", this); m_OnCullStateChanged.Invoke(cull); SetVerticesDirty(); } } ``` RectMask2D会将RectTransform的区域作为**_ClipRect**传入Shader中。 Stencil Ref 的值是0 表示它并没有使用模板缓冲比较 ![[Pasted image 20240104145728.png]] 在Shader的Frag处理像素中,被裁切掉的区域是通过UnityGet2DClipping()根据_ClipRect比较当前像素是否在裁切区域中,如果不在,将Color.a变成了透明。 ```csharp Shader "UI/Default" { //...略 fixed4 frag(v2f IN) : SV_Target { half4 color = (tex2D(_MainTex, IN.texcoord) + _TextureSampleAdd) * IN.color; //根据_ClipRect比较当前像素是否在裁切区域中,如果不在颜色将设置成透明 #ifdef UNITY_UI_CLIP_RECT color.a *= UnityGet2DClipping(IN.worldPosition.xy, _ClipRect); #endif #ifdef UNITY_UI_ALPHACLIP clip (color.a - 0.001); #endif return color; } } ``` ## 三、RectMask2D和Mask的性能区分 ## 3.1 RectMask2D > Mask2D不需要依赖一个Image组件,其裁剪区域就是它的RectTransform的rect大小。 - **性质1:RectMask2D节点下的所有孩子都不能与外界UI节点合批且多个RectMask2D之间不能合批。【亲测不能合批】** - **性质2:计算depth的时候,所有的RectMask2D都按一般UI节点看待,只是它没有CanvasRenderer组件,不能看做任何UI控件的bottomUI。** ## 3.2 Mask > Mask组件需要依赖一个Image组件,裁剪区域就是Image的大小。 - **性质1:Mask会在首尾(首=Mask节点,尾=Mask节点下的孩子遍历完后)多出两个drawcall,多个Mask间如果符合合批条件这两个drawcall可以对应合批(mask1 的首 和 mask2 的首合;mask1 的尾 和 mask2 的尾合。首尾不能合)** - **性质2:计算depth的时候,当遍历到一个Mask的首,把它当做一个不可合批的UI节点看待,但注意可以作为其孩子UI节点的bottomUI。** - **性质3:Mask内的UI节点和非Mask外的UI节点不能合批,但多个Mask内的UI节点间如果符合合批条件,可以合批。** 从Mask的性质3可以看出,并不是Mask越多越不好,因为Mask间是可以合批的。得出以下结论: - 当一个界面只有一个mask,那么,RectMask2D 优于 Mask - 当有两个mask,那么,两者差不多。 - 当大于两个mask,那么,Mask 优于 RectMask2D。 - 如果只是矩形裁切,RectMask2D不需要重新创建了材质,每帧都使用新材质再次渲染,所以**RectMask2D的效率会比Mask要高**。 ## 参考 [Unity mask的分析与理解](https://link.zhihu.com/?target=https%3A//indienova.com/u/jnereus/blogread/4833) [源码探析Mask、Rect Mask2D与Sprite Mask的异同](https://link.zhihu.com/?target=http%3A//learnu3d.group/article/mask) [有关RectMask2D和Mask的性能简易区分_咩咩正在努力中的博客-CSDN博客](https://link.zhihu.com/?target=https%3A//blog.csdn.net/qq_40093529/article/details/85230487)