作者 - Oasis 团队 - 诚空
前言
过去大家对 Oasis 的认知一直停留在 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 遮罩生效,其他保持不变就无法做到了。
Oasis:基于遮罩层
无论是基于节点树层次结构或基于渲染顺序,都不够灵活,SpriteMask 对 SpriteRenderer 的遮罩都会受到一些外部因素影响,如节点树层次结构或者渲染顺序等,我们希望 SpriteMask 可以快速和 SpriteRenderer 进行匹配 (匹配:一个 SpriteMask 可以对 SpriteRenderer 产生遮罩称为匹配),并且不受外部因素的影响,为此我们在使用方式上设计了遮罩层的概念,当 SpriteMask 影响的遮罩层和 SpriteRenderer 所处的遮罩层有交集的时候即可匹配,如下:
技术选型
业界实现的遮罩能力主要有:矩形遮罩、矩形旋转遮罩、图片遮罩、几何多边形遮罩、内外遮罩。而 Oasis 是移动优先的 web 图形引擎,所以我们可以基于 webgl 来实现各种遮罩效果,主要有以下几种方案:stencil、framebuffer、scissor、shader。接下来我们从功能完备和性能两方面来进行考虑。
功能完备
从功能完备的角度来进行分析对比,如下表:
性能
从功能完备的角度分析,可以排除 scissor、shader 方案,接下来我们需要从性能角度来对比下 stencil 和 framebuffer。我们使用 webgl 分别实现 stencil 和 framebuffer 方案,不断增加遮罩数量,计算 100 帧平均每帧时间 (单位:ms),结果如下:
测试示例详见:
stencil:codepen.io/chengkong/p…
framebuffer:codepen.io/chengkong/p…
结论
通过两个维度的对比分析,从功能完备的角度来看,我们可以排出其他方案了,只剩下 stencil 和 framebuffer。再从性能角度来看,framebuffer 方案的性能比 stencil 的性能慢差不多 10 倍的数量级,因此我们最终决定采用 stencil 的方案来实现遮罩。
关键设计与实现
调研完成后,使用方式与技术方案已经明确,接下来就是核心类的设计了。这里先简单介绍下需要了解的几个核心概念:遮罩层、遮罩区域、遮罩类型。
遮罩层是我们抽象出来的概念,作为 SpriteMask 和 SpriteRenderer 如何匹配的纽带,遮罩区域表示的是我们对一个特定区域要进行遮罩处理,遮罩类型表示的是遮罩处理的方案(内遮罩,外遮罩)。
设计
最终开发者使用的方式如下:
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);
// ...
}
}
为什么要这么设计呢,解答这个问题之前,我们需要先了解一下 Oasis 现在是如何把需要渲染的内容送入最终渲染的,如下:
一般情况,渲染组件将自己丢入渲染队列之后,对于整个渲染管线来说,只是一堆渲染元素,渲染队列排好序之后,会逐个渲染 (流程图中的绿色部分)。至此,我们还是无法解释上述的疑问,不急,再来看看如何使用 stencil 实现遮罩的流程,我们始终设置模版测试的参考值为 1 ,如下:
- 把对精灵有影响的 SpriteMask 全部送入 GPU 进行模版测试,并更新模版缓冲的值
- 渲染精灵的时候,根据遮罩类型选择比较函数 (gl.stencilFunc)
- 通过 stencil test 的像素即可渲染出来
是不是发现问题了呢?第一步需要把有影响的 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 的基础版本 (支持图片遮罩),详见: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…。
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!