最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • Oasis 2D 之 SpriteMask

    正文概述 掘金(蚂蚁RichLab前端团队)   2021-07-31   471

    作者 - Oasis 团队 - 诚空

    前言

    过去大家对 Oasis 的认知一直停留在 3D 领域,过去我们支撑了很多 3D 互动项目的落地,随着我们服务的业务数量越来越多,复杂度越来越高,仅仅提供 3D 的能力已经不能完全满足业务需求了,所以今年我们开始扩展 2D 能力。2D 中最基础的就是 SpriteRenderer 和 SpriteMask,在引擎版本 0.3 中,我们已经完成了 SpriteRenderer 的重构,而本篇文章主要分享下 SpriteMask 的研发历程,最终的效果如下(左图为内遮罩 VisibleInsideMask,右图为外遮罩 VisibleOutsideMask):
    Oasis 2D 之 SpriteMask

    调研

    SpriteMask 的主要作用就是和 SpriteRenderer 协作,实现精灵遮罩的效果。在进入正式开发前,我们先从两方面进行调研:开发者使用层面业界一些引擎是如何使用遮罩的、底层实现层面遮罩实现都有哪些技术方案。

    使用方式

    从开发者使用层面来看,行业内遮罩的使用方式大致分为 2 种:基于节点树层次结构和基于渲染顺序。

    基于节点树层次结构

    基于节点树层次结构的使用方式大致如下图:
    Oasis 2D 之 SpriteMask
    mask 会对其子节点中所有的渲染组件生效,这种使用方式,比较依赖节点树的层次结构,当一个 sprite 需要多个遮罩的时候,就需要嵌套多层 mask 了,而且一旦某个遮罩需要动态改变,整个节点树的结构可能也需要跟着一起调整。

    基于渲染顺序

    基于渲染顺序的使用方式,mask 会通过一些参数设置最后得到两个遮罩影响的渲染范围 [front, back),结合 sprite 的渲染顺序来看 (以屏幕往外作为 Z 的正方向来说,当两个精灵有重叠的时候,Z 更大的会渲染在更上面,也就是会覆盖 Z 更小的),大致如下:
    Oasis 2D 之 SpriteMask
    可以看出,mask 和渲染顺序比较强相关,实现起来会比较自然,就是不够灵活,比如上图中,我们希望 mask 对 Z 为 0 的 sprite 遮罩生效,其他保持不变就无法做到了。

    Oasis:基于遮罩层

    无论是基于节点树层次结构或基于渲染顺序,都不够灵活,SpriteMask 对 SpriteRenderer 的遮罩都会受到一些外部因素影响,如节点树层次结构或者渲染顺序等,我们希望 SpriteMask 可以快速和 SpriteRenderer 进行匹配 (匹配:一个 SpriteMask 可以对 SpriteRenderer 产生遮罩称为匹配),并且不受外部因素的影响,为此我们在使用方式上设计了遮罩层的概念,当 SpriteMask 影响的遮罩层和 SpriteRenderer 所处的遮罩层有交集的时候即可匹配,如下:
    Oasis 2D 之 SpriteMask

    技术选型

    业界实现的遮罩能力主要有:矩形遮罩、矩形旋转遮罩、图片遮罩、几何多边形遮罩、内外遮罩。而 Oasis 是移动优先的 web 图形引擎,所以我们可以基于 webgl 来实现各种遮罩效果,主要有以下几种方案:stencil、framebuffer、scissor、shader。接下来我们从功能完备和性能两方面来进行考虑。

    功能完备

    从功能完备的角度来进行分析对比,如下表:
    Oasis 2D 之 SpriteMask

    性能

    从功能完备的角度分析,可以排除 scissor、shader 方案,接下来我们需要从性能角度来对比下 stencil 和 framebuffer。我们使用 webgl 分别实现 stencil 和 framebuffer 方案,不断增加遮罩数量,计算 100 帧平均每帧时间 (单位:ms),结果如下:
    Oasis 2D 之 SpriteMask

    测试示例详见:
    stencil:codepen.io/chengkong/p…
    framebuffer:codepen.io/chengkong/p…

    结论

    通过两个维度的对比分析,从功能完备的角度来看,我们可以排出其他方案了,只剩下 stencil 和 framebuffer。再从性能角度来看,framebuffer 方案的性能比 stencil 的性能慢差不多 10 倍的数量级,因此我们最终决定采用 stencil 的方案来实现遮罩。

    关键设计与实现

    调研完成后,使用方式与技术方案已经明确,接下来就是核心类的设计了。这里先简单介绍下需要了解的几个核心概念:遮罩层、遮罩区域、遮罩类型。

    遮罩层是我们抽象出来的概念,作为 SpriteMask 和 SpriteRenderer 如何匹配的纽带,遮罩区域表示的是我们对一个特定区域要进行遮罩处理,遮罩类型表示的是遮罩处理的方案(内遮罩,外遮罩)。
    Oasis 2D 之 SpriteMask

    设计

    最终开发者使用的方式如下:

    const sprEntity = rootEntity.createChild("Sprite");
    // 1.1 添加一个 SpriteRenderer
    const renderer = sprEntity.addComponent(SpriteRenderer);
    renderer.sprite = sprite;
    // 1.2 设置遮罩类型
    renderer.maskInteraction = SpriteMaskInteraction.VisibleInsideMask;
    // 1.3 设置精灵所属遮罩层
    renderer.maskLayer =  SpriteMaskLayer.Layer0;
    
    const maskEntity = rootEntity.createChild("Mask");
    // 2.1 添加一个 SpriteMask
    const mask = maskEntity.addComponent(SpriteMask);
    // 2.2 设置遮罩区域
    mask.sprite = maskSprite;
    // 2.3 设置影响的遮罩层,和精灵所属遮罩层进行匹配用
    mask.influenceLayers = SpriteMaskLayer.Layer0;
    

    相关类的关系图如下:

    Oasis 2D 之 SpriteMask

    遮罩层

    遮罩层决定着 SpriteMask 和 SpriteRenderer 如何进行快速匹配,我们先来定义所有的遮罩层,如下:

    /**
     * Sprite mask layer.
     */
    export enum SpriteMaskLayer {
        /** Mask layer 0. */
      Layer0 = 0x1,
      /** Mask layer 1. */
      Layer1 = 0x2,
      .
      .
      .
      /** Mask layer 31. */
      Layer31 = 0x80000000,
      /** All mask layers. */
      Everything = 0xffffffff
    }
    

    遮罩层一共 32 个,why ??? 主要是 Number 类型虽然是 64 位,但是所有按位运算都是在 32 位二进制数上执行的,每位可以代表一层,这样我们在做匹配的时候可以通过位运算快速筛选,并且一个场景中预留 32 个遮罩层应该是可以满足所有需求了 (反正我是没遇到过啥项目里面同时使用这么多遮罩的 ^-^)。接下来就是给 SpriteRenderer 和 SpriteMask 添加遮罩层相关属性,如下:

    class SpriteRenderer extends Renderer {
      /**
       * The mask layer the sprite renderer belongs to.
       */
      get maskLayer(): number;
      set maskLayer(value: number);
    }
    
    class SpriteMask extends Renderer {
      /** The mask layers the sprite mask influence to. */
      influenceLayers: number = SpriteMaskLayer.Everything;
    }
    

    遮罩区域

    当前版本我们计划先实现图片遮罩,也就是遮罩的区域由遮罩设置的图片来决定,所以在 SpriteMask 添加一个属性来设置遮罩图片,如下:

    class SpriteMask extends Renderer {
      /** The mask layers the sprite mask influence to. */
      influenceLayers: number = SpriteMaskLayer.Everything;
      
      /**
       * The Sprite used to define the mask.
       */
      get sprite(): Sprite;
      set sprite(value: Sprite);
    }
    

    遮罩类型

    遮罩层设计完后,明确了 SpriteMask 和 SpriteRenderer 如何进行快速匹配,接下来一个比较重要的设计就是被遮罩的精灵,是显示遮罩区域内还是区域外的内容呢?首先我们定义遮罩类型的枚举,如下:

    /**
     * Sprite mask interaction.
     */
    export enum SpriteMaskInteraction {
      /** The sprite will not interact with the masking system. */
      None,
      /** The sprite will be visible only in areas where a mask is present. */
      VisibleInsideMask,
      /** The sprite will be visible only in areas where no mask is present. */
      VisibleOutsideMask
    }
    

    遮罩类型的选择应该由 SpriteRenderer 来决定,所以我们在 SpriteRenderer 里添加一个属性来标记,如下:

    class SpriteRenderer extends Renderer {  
      /**
       * Interacts with the masks.
       */
      get maskInteraction(): SpriteMaskInteraction;
      set maskInteraction(value: SpriteMaskInteraction);
      
      /**
       * The mask layer the sprite renderer belongs to.
       */
      get maskLayer(): number;
      set maskLayer(value: number);
    }
    

    实现

    我们先来看看最终实现在整个渲染管线中的流程图如下:
    Oasis 2D 之 SpriteMask

    遮罩层匹配

    基本原理

    虽然 SpriteMask 继承于 Renderer,但是在每帧调用到 _render 的时候,我们并不是直接把 SpriteMask 送入渲染队列,而是在渲染管线中缓存住,如下:

    export class SpriteMask extends Renderer {
      _render(camera: Camera): void {
        // ...
        
        // 如果是 SpriteMask 渲染组件,直接在渲染管线中缓存
        camera._renderPipeline._allSpriteMasks.add(this);
        
        // ...
      }
    }
    

    为什么要这么设计呢,解答这个问题之前,我们需要先了解一下 Oasis 现在是如何把需要渲染的内容送入最终渲染的,如下:
    一般情况,渲染组件将自己丢入渲染队列之后,对于整个渲染管线来说,只是一堆渲染元素,渲染队列排好序之后,会逐个渲染 (流程图中的绿色部分)。至此,我们还是无法解释上述的疑问,不急,再来看看如何使用 stencil 实现遮罩的流程,我们始终设置模版测试的参考值为 1 ,如下:

    1. 把对精灵有影响的 SpriteMask 全部送入 GPU 进行模版测试,并更新模版缓冲的值
    2. 渲染精灵的时候,根据遮罩类型选择比较函数 (gl.stencilFunc)
    3. 通过 stencil test 的像素即可渲染出来

    是不是发现问题了呢?第一步需要把有影响的 SpriteMask 全部送入 GPU,假设有一个 SpriteMask 对两个不同的精灵都有影响,那么必然需要送入 2 次,按照现有的渲染流程,显然无法做到,所以我们需要把 SpriteMask 单独缓存 (流程图中的蓝色部分),当渲染到某个精灵的时候,把所有匹配的 SpriteMask 找出来进行模版缓冲区的更新。
    Oasis 2D 之 SpriteMask

    优化技巧

    这里有一个问题需要思考,假设我们连续渲染两个精灵,但是两个精灵匹配的 SpriteMask 只相差一个,那么这个时候模版缓冲区完全没必要一个个更新,只需要两个精灵所属遮罩层之间做个 diff 就好了,这样可以有效的减少和 GPU 的交互,基于此,我们添加 SpriteMaskManager 来专门处理这部分逻辑,核心思想就是记录上一个精灵 (称为 preSprite) 的遮罩层,当渲染新的精灵 (称为 curSprite) 时,找出两个精灵遮罩层的差异,分为 3 种情况:commonLayer、addLayer、reduceLayer。commonLayer 是两个精灵重叠的层,addLayer 是 curSprite 比 preSprite 多的层,reduceLayer 是 curSprite 比 preSprite 少的层,关系如下:
    Oasis 2D 之 SpriteMask
    找出遮罩层差异的核心代码如下:

    const commonLayer = preMaskLayer & curMaskLayer;
    const addLayer = curMaskLayer & ~preMaskLayer;
    const reduceLayer = preMaskLayer & ~curMaskLayer;
    

    接下来,需要通过遮罩层差异,找出对应的 SpriteMask,然后进行相应的操作,SpriteMask 是通过 influenceLayers 来标识自己会影响哪些遮罩层,因此只需要和上面的 3 个层做简单位运算即可,核心代码如下:

    // Traverse masks.
    for (let i = 0, n = allMasks.length; i < n; i++) {
      const mask = allMaskElements[i];
      const influenceLayers = mask.influenceLayers;
    
      // Do nothing for commonLayer.
      if (influenceLayers & commonLayer) {
        continue;
      }
    
      // Stencil value +1 for mask influence to addLayer.
      if (influenceLayers & addLayer) {
        const maskRenderElement = mask._maskElement;
        maskRenderElement.isAdd = true;
        this._batcher.drawElement(maskRenderElement);
        continue;
      }
    
      // Stencil value +1 for mask influence to reduceLayer.
      if (influenceLayers & reduceLayer) {
        const maskRenderElement = mask._maskElement;
        maskRenderElement.isAdd = false; 
        this._batcher.drawElement(maskRenderElement);
      }
    }
    

    遮罩区域

    当一个 SpriteMask 匹配后,就需要去更新 stencil 缓冲区,对于 addLayer 的我们需要给缓冲区中对应的位置 +1,对于 reduceLayer 的我们需要给缓冲区中对应的位置 -1,核心代码如下:

    // Set the op that the stencil test passed.
    const stencilState = material.renderState.stencilState;
    const op = spriteMaskElement.isAdd ? StencilOperation.IncrementSaturate : StencilOperation.DecrementSaturate;
    stencilState.passOperationFront = op;
    stencilState.passOperationBack = op;
    

    遮罩类型

    当通过遮罩层的匹配找出所有 SpriteMask 并将 stencil 缓冲区数据更新后,我们就需要根据设置的遮罩类型来设置模版测试函数,核心代码如下:

    if (maskInteraction === SpriteMaskInteraction.None) {
      // When the mask is not needed, the stencil test always passed.
      stencilState.enabled = false;
      stencilState.writeMask = 0xff;
      stencilState.referenceValue = 0;
      stencilState.compareFunctionFront = stencilState.compareFunctionBack = CompareFunction.Always;
    } else {
      stencilState.enabled = true;
      stencilState.writeMask = 0x00;
      // When a mask is needed, set ref to 1, inside mask ref <= stencil, outside mask ref> stencil.
      stencilState.referenceValue = 1;
      const compare =
            maskInteraction === SpriteMaskInteraction.VisibleInsideMask
      ? CompareFunction.LessEqual
      : CompareFunction.Greater;
      stencilState.compareFunctionFront = compare;
      stencilState.compareFunctionBack = compare;
    }
    

    总结

    最终我们实现了 SpriteMask 的基础版本 (支持图片遮罩),详见:oasisengine.cn/0.4/docs/sp…。
    并且可以通过我们的示例查看详细用法,详见:oasisengine.cn/0.4/example…。

    目前我们的 SpriteMask 只实现了图片遮罩的能力,已经能够满足大部分的需求了,后续也会根据开发者的实际需求,考虑是否支持矩形遮罩、椭圆遮罩、自定义图形遮罩等。并且之后遮罩会支持整个 2D 的生态,而不仅仅局限于 SpriteRenderer。

    最后

    欢迎大家 star 我们的 github 仓库,也可以随时关注我们后续 v0.5 的规划,也可以在 issues 里给我们提需求和问题。开发者可以加入到我们的钉钉群里来跟我们吐槽和探讨一些问题,钉钉搜索 31360432

    无论你是渲染、TA 、Web 前端或是游戏方向,只要你和我们一样,渴望实现心中的绿洲,欢迎投递简历到 chenmo.gl@antgroup.com。岗位描述详见:www.yuque.com/oasis-engin…。


    起源地下载网 » Oasis 2D 之 SpriteMask

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元