过去大家对 Galacean Engine 的认知一直停留在 3D 领域,过去我们支撑了很多 3D 互动项目的落地,随着我们服务的业务数量越来越多,复杂度越来越高,仅仅提供 3D 的能力已经不能完全满足业务需求了,所以今年我们开始扩展 2D 能力。2D 中最基础的就是 SpriteRenderer 和 SpriteMask,在引擎版本 0.3 中,我们已经完成了 SpriteRenderer 的重构,而本篇文章主要分享下 SpriteMask 的研发历程,最终的效果如下(左图为内遮罩 VisibleInsideMask,右图为外遮罩 VisibleOutsideMask):
SpriteMask 的主要作用就是和 SpriteRenderer 协作,实现精灵遮罩的效果。在进入正式开发前,我们先从两方面进行调研:开发者使用层面业界一些引擎是如何使用遮罩的、底层实现层面遮罩实现都有哪些技术方案。
从开发者使用层面来看,行业内遮罩的使用方式大致分为 2 种:基于节点树层次结构和基于渲染顺序。
基于节点树层次结构的使用方式大致如下图:
mask 会对其子节点中所有的渲染组件生效,这种使用方式,比较依赖节点树的层次结构,当一个 sprite 需要多个遮罩的时候,就需要嵌套多层 mask 了,而且一旦某个遮罩需要动态改变,整个节点树的结构可能也需要跟着一起调整。
基于渲染顺序的使用方式,mask 会通过一些参数设置最后得到两个遮罩影响的渲染范围 [front, back),结合 sprite 的渲染顺序来看 (以屏幕往外作为 Z 的正方向来说,当两个精灵有重叠的时候,Z 更大的会渲染在更上面,也就是会覆盖 Z 更小的),大致如下:
可以看出,mask 和渲染顺序比较强相关,实现起来会比较自然,就是不够灵活,比如上图中,我们希望 mask 对 Z 为 0 的 sprite 遮罩生效,其他保持不变就无法做到了。
无论是基于节点树层次结构或基于渲染顺序,都不够灵活,SpriteMask 对 SpriteRenderer 的遮罩都会受到一些外部因素影响,如节点树层次结构或者渲染顺序等,我们希望 SpriteMask 可以快速和 SpriteRenderer 进行匹配 (匹配:一个 SpriteMask 可以对 SpriteRenderer 产生遮罩称为匹配),并且不受外部因素的影响,为此我们在使用方式上设计了遮罩层的概念,当 SpriteMask 影响的遮罩层和 SpriteRenderer 所处的遮罩层有交集的时候即可匹配,如下:
业界实现的遮罩能力主要有:矩形遮罩、矩形旋转遮罩、图片遮罩、几何多边形遮罩、内外遮罩。而 Galacean Engine 是移动优先的 web 图形引擎,所以我们可以基于 webgl 来实现各种遮罩效果,主要有以下几种方案:stencil、framebuffer、scissor、shader。接下来我们从功能完备和性能两方面来进行考虑。
从功能完备的角度来进行分析对比,如下表:
| | 矩形遮罩 | 矩形旋转遮罩 | 图片遮罩 | 几何多边形遮罩 | 内外遮罩 | | --- | --- | --- | --- | --- | --- | | stencil |
|
|
|
|
| | framebuffer |
|
|
|
|
| | scissor |
| | | | | | shader |
|
|
| |
从功能完备的角度分析,可以排除 scissor、shader 方案,接下来我们需要从性能角度来对比下 stencil 和 framebuffer。我们使用 webgl 分别实现 stencil 和 framebuffer 方案,不断增加遮罩数量,计算 100 帧平均每帧时间 (单位:ms),结果如下:
测试环境 设备:MacBook Pro 处理器:2.4 GHz 四核 Intel Core i5 浏览器:Chrome 90.0.4430.212
测试示例详见: stencil:https://codepen.io/chengkong/pen/poPerVy framebuffer:https://codepen.io/chengkong/pen/xxdYNro
通过两个维度的对比分析,从功能完备的角度来看,我们可以排出其他方案了,只剩下 stencil 和 framebuffer。再从性能角度来看,framebuffer 方案的性能比 stencil 的性能慢差不多 10 倍的数量级,因此我们最终决定采用 stencil 的方案来实现遮罩。
调研完成后,使用方式与技术方案已经明确,接下来就是核心类的设计了。这里先简单介绍下需要了解的几个核心概念:遮罩层、遮罩区域、遮罩类型。
最终开发者使用的方式如下:
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;
相关类的关系图如下:
遮罩层决定着 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);
}
我们先来看看最终实现在整个渲染管线中的流程图如下:
虽然 SpriteMask 继承于 Renderer,但是在每帧调用到 _render 的时候,我们并不是直接把 SpriteMask 送入渲染队列,而是在渲染管线中缓存住,如下:
export class SpriteMask extends Renderer {
_render(camera: Camera): void {
// ...
// 如果是 SpriteMask 渲染组件,直接在渲染管线中缓存
camera._renderPipeline._allSpriteMasks.add(this);
// ...
}
}
为什么要这么设计呢,解答这个问题之前,我们需要先了解一下 Galacean Engine 是如何把需要渲染的内容送入最终渲染的,如下: 一般情况,渲染组件将自己丢入渲染队列之后,对于整个渲染管线来说,只是一堆渲染元素,渲染队列排好序之后,会逐个渲染 (流程图中的绿色部分)。至此,我们还是无法解释上述的疑问,不急,再来看看如何使用 stencil 实现遮罩的流程,我们始终设置模版测试的参考值为 1 ,如下:
是不是发现问题了呢?第一步需要把有影响的 SpriteMask 全部送入 GPU,假设有一个 SpriteMask 对两个不同的精灵都有影响,那么必然需要送入 2 次,按照现有的渲染流程,显然无法做到,所以我们需要把 SpriteMask 单独缓存 (流程图中的蓝色部分),当渲染到某个精灵的时候,把所有匹配的 SpriteMask 找出来进行模版缓冲区的更新。
这里有一个问题需要思考,假设我们连续渲染两个精灵,但是两个精灵匹配的 SpriteMask 只相差一个,那么这个时候模版缓冲区完全没必要一个个更新,只需要两个精灵所属遮罩层之间做个 diff 就好了,这样可以有效的减少和 GPU 的交互,基于此,我们添加 SpriteMaskManager
来专门处理这部分逻辑,核心思想就是记录上一个精灵 (称为 preSprite) 的遮罩层,当渲染新的精灵 (称为 curSprite) 时,找出两个精灵遮罩层的差异,分为 3 种情况:commonLayer、addLayer、reduceLayer。commonLayer 是两个精灵重叠的层,addLayer 是 curSprite 比 preSprite 多的层,reduceLayer 是 curSprite 比 preSprite 少的层,关系如下:
找出遮罩层差异的核心代码如下:
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 只实现了图片遮罩的能力,已经能够满足大部分的需求了,后续也会根据开发者的实际需求,考虑是否支持矩形遮罩、椭圆遮罩、自定义图形遮罩等。并且之后遮罩会支持整个 2D 的生态,而不仅仅局限于 SpriteRenderer。