详解Unity中Mask和RectMask2D组件的对比与测试

组件用法

Mask组件可以实现遮罩的效果,将一个图像设为拥有mask组件图像的子物体,最后就会隐藏掉子图像和mask图像不重合的部分。例如:

(蓝色的圆形名为mask,数字图片名为image)

在“mask”图片上添加mask组件后的结果(可以选择是否隐藏mask图像):

RectMask2D的基本用法

RectMask2D的用法和mask大致相同,不过RectMask2D只能裁剪一个矩形区域,同时RectMask2D可以选择边缘虚化

原理分析

Mask的原理分析

  1. Mask会赋予Image一个特殊的材质,这个材质会给Image的每个像素点进行标记,将标记结果存放在一个缓存内(这个缓存叫做 Stencil Buffer)
  2. 当子级UI进行渲染的时候会去检查这个 Stencil Buffer内的标记,如果当前覆盖的区域存在标记(即该区域在Image的覆盖范围内),进行渲染,否则不渲染

那么,Stencil Buffer 究竟是什么呢?

1 StencilBuffer

简单来说,GPU为每个像素点分配一个称之为StencilBuffer的1字节大小的内存区域,这个区域可以用于保存或丢弃像素的目的。

我们举个简单的例子来说明这个缓冲区的本质。

如上图所示,我们的场景中有1个红色图片和1个绿色图片,黑框范围内是它们重叠部分。一帧渲染开始,首先绿色图片将它覆盖范围的每个像素颜色“画”在屏幕上,然后红色图片也将自己的颜色画在屏幕上,就是图中的效果了。

这种情况下,重叠区域内红色完全覆盖了绿色。接下来,我们为绿色图片添加Mask组件。于是变成了这样:

此时一帧渲染开始,首先绿色图片将它覆盖范围都涂上绿色,同时将每个像素的stencil buffer值设置为1,此时屏幕的stencil buffer分布如下:

然后轮到红色图片“绘画”,它在涂上红色前,会先取出这个点的stencil buffer值判断,在黑框范围内,这个值是1,于是继续画红色;在黑框范围外,这个值是0,于是不再画红色,最终达到了图中的效果。

所以从本质上来讲,stencil buffer是为了实现多个“绘画者”之间互相通信而存在的。由于gpu是流水线作业,它们之间无法直接通信,所以通过这种共享数据区的方式来传递消息。

理解了stencil的原理,我们再来看下它的语法。在unity shader中定义的语法格式如下
(中括号内是可以修改的值,其余都是关键字):

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与运算。

2 mask的源码实现

了解了stencil,我们再来看mask的源码实现

由于裁切需要同时裁切图片和文本,所以Image和Text都会派生自MaskableGraphic。

如果要让Mask节点下的元素裁切,那么它需要占一个DrawCall,因为这些元素需要一个新的Shader参数来渲染。

如下代码所示,MaskableGraphic实现了IMaterialModifier接口, 而StencilMaterial.Add()就是设置Shader中的裁切参数。

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;
            }
            // 如果我们用了Mask,它会生成一个mask材质,
            Mask maskComponent = GetComponent<Mask>();
            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的参数。

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<Component>.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<Component>.Release(components);
           //返回新的材质,用于裁切
           return currentMat;
       }
   }

因为模板缓冲可以提供模板的区域,也就是前面设置的圆形图片,所以最终会将元素裁切到这个圆心图片中。

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的工作流大致如下:

①C#层:找出父物体中所有RectMask2D覆盖区域的交集(FindCullAndClipWorldRect)
②C#层:所有继承MaskGraphic的子物体组件调用方法设置剪裁区域(SetClipRect)传递给Shader
③Shader层:接收到矩形区域_ClipRect,片元着色器中判断像素是否在矩形区域内,不在则透明度设置为0(UnityGet2DClipping )
④Shader层:丢弃掉alpha小于0.001的元素(clip (color.a - 0.001))

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()进行裁切。

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

性能区分

Mask组件需要依赖一个Image组件,裁剪区域就是Image的大小。

Mask会在首尾(首=Mask节点,尾=Mask节点下的孩子遍历完后)drawcall,多个Mask间如果符合合批条件这两个drawcall可以对应合批(mask1 的首 和 mask2 的首合;mask1 的尾 和 mask2 的尾合。首尾不能合)
Mask内的UI节点和非Mask外的UI节点不能合批,但多个Mask内的UI节点间如果符合合批条件,可以合批。
具体来说:
新建一个场景,默认drawcall是2个;
现在添加一个mask,

drawcall+3,Mask导致2个drawcall(第1个和第3个,一头一尾),Mask下的子节点Image导致1个drawcall(中间的)
再看下RectMask2D的情况

只有新增1个子节点Image的drawcall, 而RectMask2D不会导致drawcall.

而这时增加一个mask,不要重叠:

还是5个drawcall, 没有变化.
Unity把2个Mask进行了网格合并, 3个drawcall, 分别为[2个Mask头]、[2个Image]、[2个Mask尾].

这里可以看出, Mask之间是可以进行合并的, 从而不额外增加drawcall

而如果放到一起,

**这是因为Unity的合批需要同渲染层级(depth), 同材质, 同图集, 如果重叠了, depth就不同了, 6个drawcall分别为Mask头、Mask的Image、Mask尾、Mask(1)头、Mask(1)的Image、Mask(1)尾.

Mask小结:

1.多个Mask之间可以进行合批(头和头合批, 子对象和子对象合批, 尾和尾合批),需要同渲染层级(depth), 同材质, 同图集.
2.Mask内外不能进行合批.
再试试RectMask2D
把RectMask2D复制一个出来, 然后把位置摆开.**

drawcall为4, 因为RectMask2D本身不会导致drawcall, 所以RectMask2D之间不能进行合批.

RectMask2D小结:

1.RectMask2D本身不产生drawcall.
2.不同RectMask2D的子对象不能合批.

对比测试

下面放上我在手机端做的一个简单的对比测试:

可以大致看出,在图像很大且cpu任务较重的的情况下,mask会对性能有明显的影响,而在图像数量较多时mask略好于RectMask2D
项目链接:https://git.woa.com/jnjnjnzhang/MaskVsRectmask2d

注:测试场景中自带约60个batches。每个mask测试加入同样的20个mask。图像数量少的场景每个mask下挂一个图像,面积大情况下mask大小不变图像边长放大1000倍,数量多情况下每个mask下挂同样的100个图像。瓶颈为drawcall时,每个物体仅有简单的渲染,在物体上挂载了需要进行复杂运算的脚本。瓶颈为gpu时,去掉脚本,在场景中挂载了后处理渲染提高gpu负载。

参考文章

https://zhuanlan.zhihu.com/p/136505882

以上就是Unity中Mask和RectMask2D组件的对比与测试的详细内容,更多关于Unity中Mask和RectMask2D的资料请关注我们其它相关文章!

(0)

相关推荐

  • 详解Unity地面检测方案

    1.普通射线 在角色坐标(一般是脚底),发射一根向下的射线(长度约0.2) 但是简单射线只适用于简单地形,实际使用中常常遇到以下问题 1.用collider去碰撞地面,某些时候会有一定的穿插,于是角色的最低点就可能穿透地面,你发射射线的点可能就到了地面以下,射线一直检测不到真正的地面,于是角色就一直悬空 2.角色走斜坡时,角色中点可能会离开地面一小段距离,这一小段距离往往就足够让判断机制误以为角色已经离地,如果你增加射线的长度,那么一定程度上能缓解斜坡问题,但是会降低跳跃判断的精度,精度过低就有

  • Unity连接MySQL并读取表格数据的实现代码

    表格如下: 在Unity读取并调用时的代码: 而如果想要查看该数据库中的另一个表,不是直接使用Table[1],而是需要更改SELECT * from <?>的表名 代码: using System.Collections; using System.Collections.Generic; using UnityEngine; using MySql.Data.MySqlClient; using System.Data; using System; public class getGame

  • 解决Unity urp级联阴影接缝问题

    试了一下unity自带的阴影,发现有接缝问题, 上网找了一些解决方案,都非常庞大, 基本是要自己处理级联阴影 看了urp的build-in之后,想了一个比较暗黑的方式,简单处理一下,运行对比图如下, 左边是unity自带的级联阴影效果,右边是平滑后的级联阴影 思路比较简单,从unity内部函数中抽几个出来改造 计算当前像素点(世界坐标)位于哪个裁切球,代码如下: 强制取某个裁切球的级联阴影映射,代码如下: 本案例只处理第一个裁切球与第二个裁切球过渡效果, 其它的裁切球离摄像机比较远,处不处理影响

  • 详解Unity中Mask和RectMask2D组件的对比与测试

    组件用法 Mask组件可以实现遮罩的效果,将一个图像设为拥有mask组件图像的子物体,最后就会隐藏掉子图像和mask图像不重合的部分.例如: (蓝色的圆形名为mask,数字图片名为image) 在"mask"图片上添加mask组件后的结果(可以选择是否隐藏mask图像): RectMask2D的基本用法 RectMask2D的用法和mask大致相同,不过RectMask2D只能裁剪一个矩形区域,同时RectMask2D可以选择边缘虚化 原理分析 Mask的原理分析 Mask会赋予Ima

  • 详解Unity中的ShaderGraph入门使用教程

    一,ShaderGraph 简介 简介: Unity2018版本之后推出了一个可编程渲染管线工具ShaderGraph,让我们可以通过可视化界面拖拽来实现着色器的创建和编辑. 官方话术: Shader Graph 使您能够直观地构建着色器.您无需编写代码,而是在图形框架中创建和连接节点.Shader Graph 提供反映您的更改的即时反馈,并且对于不熟悉着色器创建的用户来说非常简单. Shader Graph 仅与可编写脚本的渲染管线 (SRP) 兼容,即高清晰度渲染管线 (HDRP) 和通用渲

  • 详解vuex 中的 state 在组件中如何监听

    前言 不知道大家有没有遇到过这样一种情况? vuex中的state会在某一个组建中使用,而这个状态的初始化是通过异步加载完成的.组件在渲染过程中,获取的state状态为空.也就是说组件在异步完成之前就已经完成渲染了,导致组件的数据没有来得及渲染. 问题举例 举例说明如下: // topo.vue created() { this.getUserAndSysIcons(); }, methods: { getUserAndSysIcons() { const self = this; // 用户图

  • 详解vue3中的非父子组件传值

    目录 App.vue sub1.vue sub2.vue 总结 vue2中非父子组件的传值主要是通过事件总线,创建一个vue实例,通过在不同的组件中导入该实例来实现非父子组件之间的通信行为. vue3提供了**provide**和**inject**属性,可以实现非父子组件之间的通信: 假设有三个组件:App.vue(父级).sub1(子级).sub2(子级的子级): App.vue <template> <div style="border: 1px solid pink&q

  • 详解vuex中mapState,mapGetters,mapMutations,mapActions的作用

    在开始接触vuex框架的时候对那些state,action,mutation,getter等理解的还挺顺利的,然后突然出来一种加了一个前缀的mapState等,这样的就有点蒙圈了...特别是官方的文档并没有给除详细的说明跟例子...然后就自己慢慢理解了一下.其实也就是一个重命名而已...以下就是例子,希望能帮助理解: 在store中代码 import Vuex from 'vuex' import Vue from 'vue' Vue.use(Vuex); const store = new V

  • 详解vue中在父组件点击按钮触发子组件的事件

    我把这个实例分为几个步骤解读: 1.父组件的button元素绑定click事件,该事件指向notify方法 2.给子组件注册一个ref="child" 3.父组件的notify的方法在处理时,使用了$refs.child把事件传递给子组件的parentMsg方法,同时携带着父组件中的参数msg  4.子组件接收到父组件的事件后,调用了parentMsg方法,把接收到的msg放到message数组中 父组件 <template> <div id="app&qu

  • 详解IDEA中SpringBoot整合Servlet三大组件的过程

    Spring MVC整合 SpringBoot提供为整合MVC框架提供的功能特性 内置两个视图解析器:ContentNegotiatingViewResolver和BeanNameViewResolver 支持静态资源以及WebJars 自动注册了转换器和格式化器 支持Http消息转换器 自动注册了消息代码解析器 支持静态项目首页index.html 支持定制应用图标favicon.ico 自动初始化Web数据绑定器:ConfigurableWebBindingInitializer Sprin

  • 详解React中共享组件逻辑的三种方式

    废话少说,这三种方式分别是:render props.高阶组件和自定义Hook.下面依次演示 假设有一个TimeOnPage组件专门用来记录用户在当前页面停留时间,像这样: const TimeOnPage = () => { const [second, setSecond] = useState(0); useEffect(() => { setTimeout(() => { setSecond(second + 1); }, 1000); }, [second]); return

  • 详解vue3中组件的非兼容变更

    函数式组件 functional attribute 在单文件组件 (SFC) <template> 已被移除 { functional: true } 选项在通过函数创建组件已被移除 // 使用 <dynamic-heading> 组件,负责提供适当的标题 (即:h1,h2,h3,等等),在 2.x 中,这可能是作为单个文件组件编写的: // Vue 2 函数式组件示例 export default { functional: true, props: ['level'], re

  • 详解Flutter中StatefulBuilder组件的使用

    目录 例子 预览 编码 结论 本文是关于 Flutter 中的 StatefulBuilder 小部件.我将介绍小部件的基础知识,然后检查一个在实际中使用它的完整示例..StatefulBuilder 小部件可以在这些区域的状态发生变化时仅重建某些小区域而无需付出太多努力.这提高了应用程序的性能. StatefulBuilder({ Key? key, required StatefulWidgetBuilder builder r}) builder 函数有两个参数:context和一个用于在

随机推荐