【乐府】使用2种方式实现动画的动态蒙版

【本文参与征文活动】

###一. 目的

本文的目的是介绍如何在场景(可能含有多个spine动画)上实现动画蒙版(也就是遮罩mask会动会变形), 根据实现方式的不一样, 会有如下的效果:

二. 阅读完本文你可以收获

  1. 了解到什么是模板测试
  2. 了解到矩阵变换
  3. 了解cocos creator基础的渲染流
  4. 了解cocos creator里如何写一个shader并传递uinform参数

三. 本文涉及到的素材和代码

一个spine动画(从spine官方示例中取得)

一个圆形蒙版图片(ps里随便画一个)

本文中涉及到的所有代码都可以在这里找到

cocos creator 工程在2.3.1下测试通过。

四.模板测试实现动画蒙版

1. 什么是模板测试(stencil test)

在opengl渲染管线中, 当片元着色器处理完着色之后, 着色结果在实际写入颜色缓冲之前, 可以进行模板测试从而可以丢弃一些片元的着色结果。

而gpu是如何知道改丢弃那些片元呢? 主要取决于模板缓冲中的值。

模板缓冲区跟颜色缓冲区类似, 它也是一块画布, 我们假设它的分辨率跟屏幕分辨率一样,只是它每个像素的精度是8位。

当我们开启了模板测试之后, 某个坐标(x,y)的片元颜色在实际写入颜色缓冲之前, gpu会从模板缓冲区同样的坐标(x,y)取到该点的模板值进行后继判断:

当这个值符合我们的设定规则时(比如:该模板值>0时不丢弃片元), 则让片元生效, 否则丢弃该片元。

所以我们可以把模板缓冲区理解为一个筛沙子的竹篮子, 值为0的地方我们可以认为是镂空的,

当开启了模板测试之后,后继所有的图元绘制都需要在竹篮子里筛一遍, 直到关闭模板测试为止。

要实现我们上文中的图形效果, 我们可以脑补一下模板缓冲中每一帧的变化:

通过这样的模板缓冲变化, 就可以不断的让每帧多显示一些动画内容, 从而实现我们要的效果。

(想更全面理解模板测试的话, 可以看learnOpengl这篇文章

2. 如何在每一帧里正确的填充模板缓冲区来实现我们的目的。

我们只需要按照下面的步骤来通知webgl/opengl, 即可往模板缓冲区里写入我们要的形状:

  1. 先清空模板缓冲区, 也就是所有像素的模板值都置为0
  2. 开启模板缓冲的写入
  3. 按照正常的图元绘制方式画圆形遮罩图
  4. 圆形遮罩图的片元着色器中开启alpha test, 比如我们设定一个alpha阈值0.1, 如果片元的alpha度大于该阈值, 就把1写入模板缓冲区, 否则丢弃该片元(相当于该位置的模板缓冲值保留原0的取值)

我们只需要每一帧都这么执行这几步,只是每一帧里不断把圆形遮罩图的y坐标不断的往上增加即可实现我们要的效果。

所幸的是我们不需要自己操作webgl/opengl的api来做这几步, cocos creator的 cc.Mask组件已经帮我们做了这几件事:

我们只需要 把我们的圆形遮罩图(shadow.png)拖入mask组件的sprite frame即可。

不过接下来我们遇到的问题是, cc.Mask目前的设计是比较死板的, 它的模板测试只能针对它下面的子节点, 在我们这个例子中,

节点是这样的关系:

我们希望hero-mask这个节点的y坐标每帧往上增加一些,这样模板缓冲里的圆形才能往上升, 但是我们又希望保持spine节点(hero-pro)在屏幕上的坐标不动,但是目前cc.Mask的设计导致了我们无法同时做到这2点, 因为移动hero-mask的y坐标会导致下面的spine节点也一起移动。

看上去我们只能祭出魔改引擎这招大杀器了, 在打开引擎底层的cc.Mask代码阅读之前, 我们需要先补习一下cocos creator v2.x之后的渲染流机制。

cocos creator v2.x 的渲染流(render-flow)本质上其实跟以前版本并没有太大的区别,只是使用了一个单独的数字renderFlag来记录单个节点的所有dirty状态

这个renderFlag的每一位(bit)记录了一个dirtyFLag, 如图所示.

比如说第3位设置为1, 则表示节点的本地坐标或者缩放等局部属性发生了变化, 在绘制这个节点的时候,需要调用updateLocalMatrix()来进行更新, 本质上其实也是if判断。

每个节点绘制的时候,是按照上图表示的顺序, 从上到下进行判断, 本文中仅关注以下几个dirtyFlag的变化:

  • LOCAL_TRANSFORM(本地坐标等属性)
  • WORLD_TRANSFORM(世界坐标等属性)
  • UPDATE_RENDER_DATA(顶点数据属性)
  • RENDER(渲染本身,以及其他操作比如开启模板测试)
  • CHILDREN(遍历子节点)
  • POST_RENDER(完成所有子节点遍历后的渲染函数,比如用于关闭模板测试)

我们忽略掉其他不关心的dirtyFlag, 用一个比较简单的伪代码函数来描述整套渲染的流程(跟实际代码并不完全一致, 只是可以大体描述):

(想深入学习的可以阅读cocos源码中的render-flow.js)

上图红框中标记的代码是我们目前最关心的2行代码, 在updateRenderData()中会生成顶点数据, 在fillBuffers()会把这些顶点数据写入顶点缓冲区.

我们这里不考虑修改fillBuffers(), 我们只需要魔改cc.Mask.updateRenderData()函数的实现,让(圆形遮罩图片)的顶点数据每帧向上移动即可, 只要没有修改节点本身的本地矩阵和世界矩阵, 也就不会影响下面的2个 spine子节点。

2.1 第1种魔改方法:修改assembler.updateWorldVetex()

打开引擎的mask-assembler.js和2d/simple.js, assembler-2d.js 可以看到如下代码


hero-mask这个节点在绘制本身的时候, 从updateRenderData()最终会调用到updateWorldVertex()函数, 这个函数的内容是我们需要魔改的重点。

首先我们需要先看懂updateWorldVertex()里面的这些代码究竟在做什么事情。

这里先简单补习一下矩阵变换的意义。

在图形渲染中, 我们通常使用一个 4x4的矩阵来表示点的仿射变换(缩放, 旋转, 斜切, 移动), 虽然在2D世界里其实我们用一个3x3的矩阵也够用了, 不过为了兼容3D世界的坐标系, 所以我们统一使用的是4x4。

重新看一下我们的节点树结构:

我们使用M1矩阵来表示 节点hero-mask节点的本地变换矩阵, M2来表示场景根节点的世界变换矩阵, 那么

hero-mask的世界变换矩阵就是 M3 = M2 x M1, 有了这个M3之后, 我们就可以很方便的把hero-mask的任意一个本地坐标换算到世界坐标:

顶点的世界坐标 = M3 x (hero-mask顶点的本地坐标)

那么这个矩阵和坐标的乘法具体是怎么计算的呢, 如下图所示:

回头再来看updateWorldVertex()里面的代码, this._local是圆形遮罩4个顶点的本地坐标,
com.node._worldMatrix 就是我们上面提到的M3, 也就是hero-mask的世界变换矩阵,

我们把M3 x 顶点本地坐标 就会得到4个顶点的世界坐标, 然后存入renderData的vDatas数组中。

我们现在魔改的方法就是,我们需要在M3 x 顶点本地坐标之前,先对顶点本地坐标做一个临时变换,让顶点的本地y坐标往上增加之后,再去跟M3相乘即可。

在这个例子中我们可以简单的修改local.y += distance 来达到目的,不过如果以后我们想对圆形遮罩想做一些更复杂的变换,比如缩放旋转之类的,那么就得用一个矩阵变换来做了:

更详细的细节可以看我上面提供的源码。

这种修改JS层的updateWorldVertex方法在原生平台下并不是总是生效,如果hero-mask的父节点每帧都在移动或者变形导致hero-mask每帧都会触发原生层重新计算顶点的世界坐标。

因为在native层的渲染跟js层并不完全一致, 比如maskAssembler在原生层是在fillBuffers()里会判断world transform是否dirty, 从而去重新计算顶点的世界坐标。

这样即使在js层 updateRenderData()的时候你修改了顶点的世界坐标数据, 但是原生层的fillBuffers()是在后面执行的,会导致这个修改失效。

###2.2 第2种魔改方法:把mask节点拆成2个节点

根据引擎大佬提供的另外一种思路,我实现了第2种魔改方法。

我们通过上述描述应该知道了整个绘制是这样的流程:

  1. hero-mask节点打开模板测试开关
  2. 打开模板缓冲写入,绘制圆形遮罩到模板缓冲上, 关闭模板缓冲写入
  3. 绘制hero-mask节点下的2个 spine子节点
  4. hero-mask节点关闭模板测试开关

我们把整个显示结构改成下图所示:

begin-mask和end-mask节点都挂载了自定义的mask组件:

只是一个开启了fillBuffers()用来开启模板测试, 另外一个开启了postBuffers()用来关闭模板测试

同样的我们在代码里让beginMask这个节点做一个向上运动动画即可, 因为这次spine节点不再是beginMask节点的子节点,

所以beginMask的移动不会影响到spine节点。

整个显示效果跟第一个魔改方法一样。

注意这种修改方法,在原生层要生效的话需要修改C++代码, 因为原生层的fillBuffers和PostFillBuffers()是否调用跟JS层无关。

在上面提供的源码里我同时也修改了C++代码里的MaskAssembler.cpp代码,增加了setBeginMask()和setEndMask()方法, 有兴趣可以看一下。

五. 自定义材质实现动画蒙版

上面使用模板测试的方法来实现遮罩, 它的优点是实现简单, 而且支持多层遮罩嵌套(最多8层), 缺点是遮罩边缘比较生硬。

如果大家使用过photoshop等美术工具里面的图层蒙版, 就会发现这些美术工具里的蒙版是支持边缘羽化的, 我猜测他们实际上就是用了一张带透明度的蒙版纹理,覆盖在原图层上面。

原图层某像素的颜色.alpha *=蒙版纹理在在该像素点上的alpha

通过这种方式就可以实现类似片元剔除和羽化的功能。

实现原理非常简单, 缺点是它需要修改需要被遮罩的所有子节点的片元着色器, 也就是修改他们的材质, 在新的片元着色器中传入这张遮罩图的纹理, 进行纹理采样之后 再乘上原来片元的颜色即可。

也就是对片元着色器做如下修改即可:

红色框是我们在片元着色器中新加的代码,

texture2是那张半透明的白色遮罩图的纹理, mask_uv是一个uv坐标,是一个vec2的结构,表示对应在渲染该片元时,应该去白色遮罩图的哪个坐标上进行采样从而得到叠加的alpha度。

(关于什么是uv坐标这里不阐述)

如何在片元着色器中得到一个正确的mask_uv是我们接下来要重点解决的问题。

如下图所示:

当我们在渲染这个spine节点的头发上这个蓝色点时,如果我们能知道这个蓝色点的世界坐标,那我们就可以反推计算这个点在这个圆形白色遮罩图上的本地坐标, 得到这个本地坐标之后, 再进行坐标映射,就可以得到这个坐标对应的mask_uv值。

我们在上面的模板测试中提到, 一个本地坐标localPt乘以一个世界变换矩阵M就可以得到世界坐标 worldPt, 同理我们可以知道, 把一个世界坐标worldPt乘以这个M的逆矩阵就可以得到对应的localPt, 也就是:

worldPt = M x localPt

localPt = M的逆矩阵 x worldPt

(计算一个矩阵的逆矩阵, cocos creator已经给我们提供了方法: mat4.invert())

接下来要做一个本地坐标到uv坐标的映射变换,因为我们得到的是图片内部的本地坐标(x,y), 图片的中心点坐标是(0,0), 最终我们要得到uv坐标范围是[0,1]的值, 已知图片的宽度w, 高度h:

很容易可以算出来:

u = (x + w/2) / w = x/w + 0.5

v = (y + h/2) /h = y/h + 0.5

我们可以把这个纹理坐标的映射放入顶点着色器里去算, 但是这样需要把w和h当作uniform传入顶点着色器

所以我们干脆再对原来计算好的逆矩阵把纹理坐标引射的计算叠加上去, 整个代码如下:

顶点着色器里代码如下:

有几点需要注意的

  1. 我们在传递一个矩阵数据给片元着色器时,需要把这个4x4的矩阵 转换为一个float32的数组

  2. 圆形白色遮罩这张纹理,在编辑器里必须关掉Packable的属性,如果这个纹理被动态合入了大纹理集,那uv坐标就没法正确计算。

  3. cocos creator的默认顶点着色器里有一个CC_USE_MODEL的宏, 当定义了这个宏的时候,意味着顶点数据里的顶点数据并不是世界坐标, 我们需要额外再乘上一个 cc_matWorld 才能得到我们要的世界坐标。

  4. spine的着色器跟上图代码中贴出来的略有不同, 有兴趣的可以看下上面我提供的源码。

到这里为止我们已经提供了2种不同方式实现动画的动态蒙版, 在实际项目开发时, 我们大部分情况并不会用代码的方式来控制这个圆形遮罩的运动, 而是让美术在编辑器里把整套动画都做好.

当然现在这套编辑器目前仅是公司内使用, 等以后或许有机会把编辑器放出来给大家试用。

在后面的时间里,我们会陆续给大家分享一些我们项目中用到的一些魔改技巧或者图形效果之类的, 希望可以给cocos社区贡献一份绵薄之力。

42赞

神秘组织:乐府

4赞

哈哈,【乐府】【上海漕河泾】招聘初级&高级Cocos开发 风里雨里 <乐府>等你

欢迎加入乐府~

第一个改 mask 的需求提供一个思路,把 mask 拆开成 begin mask 和 end mask,只要匹配 begin 和 end,中间的层级可以自由组织

乐府的小伙伴高产啊

对的,也可以,不过改动量比较大, 得定义2个自定义renderComponent, 一个负责enable mask, 一个负责disable mask,

不过这种工作量是值得的,灵活度更大, 原来的cc.Mask只支持一个spriteFrame来做模板形状, 这样改了之后,挂载在enable mask 节点下可以挂任意个节点做更灵活的组合。

回头我试试。

hero-mask做上移100像素动画,两个hero-pro同时做下移100像素的动画,这样效果也是一样的吧?

支持,可以多出点教程给新人

理论上是可以的, 可以把里面的spine节点都放到一个空的dummy节点上, 当mask节点做了一定的矩阵变换时,dummy节点做相应的逆矩阵变化。

不过这种做法看上去略怪异,毕竟改变了一些本来不需要修改属性的节点, 而且dumy节点的逆矩阵变换会导致下面所有子节点都需要同时更新矩阵变换,从性能上也会多出一点开销。

已经按照你的思路实现了 新的模板测试的方法,把一个mask拆成了2个mask节点,文章和代码已经更新 :D

遮罩和被遮罩节点在层级结构上完全分离!学到了,感谢分享!

针对模板测试的第2种修改方法,源码里增加了c++原生层的修改,使到这个修改也可以兼容原生。

乐府的技术都这么强啊

mark一下

MARK.

不错,有好多关于cc渲染的知识点,mark一下好好消化下,感谢!

那把需要遮罩的呢些节点通过一个单独的摄像机渲染到一张图上,再通过shader的片元着色器来控制蒙版的显示和移动会不会操作更方便些

类似使用蒙版实现刮刮乐呢种效果

这样不只是动画,理论上所有可被摄像机捕捉的都可以动态设置遮罩蒙版

还是建议在3.x下这么搞,2.x实现后,因为api实现不太利索,要自己魔改东西比较烦