Oasis 2D 之 SpriteMask

avatar
花呗借呗前端团队 @蚂蚁集团

作者 - Oasis 团队 - 诚空

前言

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

调研

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

使用方式

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

基于节点树层次结构

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

基于渲染顺序

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

Oasis:基于遮罩层

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

技术选型

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

功能完备

从功能完备的角度来进行分析对比,如下表:
image.png

性能

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

测试环境 设备:MacBook Pro 处理器:2.4 GHz 四核 Intel Core i5 浏览器:Chrome 90.0.4430.212

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

结论

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

关键设计与实现

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

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

设计

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

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;

相关类的关系图如下:

image.png

遮罩层

遮罩层决定着 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);
}

实现

我们先来看看最终实现在整个渲染管线中的流程图如下:
image.png

遮罩层匹配

基本原理

虽然 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 找出来进行模版缓冲区的更新。
image.png

优化技巧

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

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…