这家公司太牛逼了,要重新造轮子!骨骼动画

共 4732字,需浏览 10分钟

 ·

2021-01-15 17:07

最近迷上的一首好歌,周深的《大鱼》,分享给大家!



01

造轮子播放骨骼动画


今天要介绍的是骨骼动画的基本原理和一些常用优化手段,本文也不涉及任何骨骼动画API的使用, 只涉及底层原理机制。


在写这篇文章的时候,为了保证讲的内容并不是纸上谈兵,我专门花时间造了个轮子: spine-player


轮子代码在这里:


https://github.com/laomoi/spine-player


这次造的轮子还是跟以前一样,不使用任何渲染引擎(Unity,Cocos), 使用Typescript编写骨骼动画的核心代码, 渲染层通过自己封装webgl的接口进行渲染。


Spine骨骼动画是2D的骨骼动画软件,   本文里提到的骨骼动画原理, 不仅仅适用于像Spine这样的2D骨骼,也同样适用于3D骨骼, 主要的区别仅仅是顶点坐标多了个z维度, mesh顶点数量多出好多倍而已。


02

为什么我们需要骨骼动画


先看下面这个动画:



如果不使用骨骼动画,那么美术同学当然也可以通过mesh deform(网格变形)来做出这个动画, 但是一旦人的动作需要调整,所有的mesh都要调整一遍, 我估计美术同学会骂街。


而且这种不用骨骼做出来的动画, 在存储动画数据的时候,每个顶点都有关键帧的信息需要存储,导致存储量巨大


所以骨骼动画是应运而生, 它把这样的动画拆解成2部分:


骨骼动画 = 骨架动画(Rigging) + 蒙皮(Skinning)


怎么理解这个意思呢?


我们先假设角色内部是由一个骨架构成的,头发,皮肤这些就是蒙在对应的骨骼上面, 当骨骼动起来的时候, 蒙在上面的皮也会对应动起来。


像图中这个妹子的骨骼可能就只有20多根, 美术调动作只需要调整这20多根骨骼的位置就可以了,存储的也是这20根骨骼的动画信息,需要存储的信息量大大减少


而对于某些局部的细节, 比如乳摇这样的效果, 可以通过多定义几根骨骼,用权重蒙皮来实现效果, 也可以用最原始的非骨骼方法,直接对这个局部mesh做deform动画。


所以, 骨骼动画和 非骨骼的mesh 变形动画都是美术需要的,2者其实是可以互相结合的。而Spine软件也支持这2种做法。



下面这个哥布林是Spine官方提供的demo, 图片中的效果是使用本文造的轮子代码播放出来的动画效果:

可以看到造的轮子实现了以下功能:

骨骼动画+权重蒙皮+mesh deform + 动态网格替换(眼睛部分)+ 多种插值方式


如果想对骨骼动画的实现原理有进一步理解,建议把仓库clone下来看代码,  因为只实现了spine的核心功能,更容易看懂。



03

骨骼动画的拆解


我们先看一下像下图这样的动画是怎么拆解成骨骼动画的:


首先我们在非动画状态(也就是setup pos下)下进行骨架的创建和蒙皮的绑定

下图是我们创建好的骨架:

在骨架上蒙好皮之后是这样的:

接着我们让骨架子动起来,会看到对应的蒙皮也会一起动:

下面我们针对骨架动画 和 蒙皮实现分别进行深入阐述。


04

骨架动画(Rigging)


1.构成骨架的骨骼之间存在父子关系, 父骨骼动起来的时候会带动子骨骼运动




2.单根骨骼内部是使用关键帧进行插值的

下面看2个骨骼的关键帧动画例子

这个hip是骨盆骨骼,它的属性关键帧只包含了移动,也就是随着动画播放,它的xy会发生变化,变化的值使用关键帧之间插值得来。


这个torsor是躯干骨骼, 它的属性关键帧只包含了旋转(变形暂且忽略),但是因为它的父骨骼是hip骨骼,也就是随着动画播放,它不仅在做旋转,也在跟着父骨骼做上下移动。


3.不同的关键帧插值方式

关键帧插值主要包括线性插值和贝塞尔曲线插值


1)先来看最简单的插值线性插值

线性插值的公式很简单, 开始值是P1, 结束值是P2, 中间某帧的值P, 假设P1到P2变化使用了1秒时间,中间该帧处于t秒(0<=t<=1)

那么P = P0 (1-t)+ P1*t

P的值可以是rotation, scale, xy 等


2)接着是三阶贝塞尔曲线插值

曲线插值是应用比较广的插值方式,它不像线性插值在开始结束的地方比较生硬, 三阶的贝塞尔曲线多了2个控制点,很方便美术去控制曲线的形状


我们假设2个控制点的坐标是 (c1, c2), (c3, c4),

那么P = 贝塞尔插值(P1, P2, t, c1, c2, c3, c4)


这个求值函数应该怎么实现呢?在实际实现中常用的方法是用多根首尾相接的折线来模拟这根曲线, 我们先判断t落在哪根折线上, 在线段内部做线性插值即可。

如上图所示,我们把0到1分别均分成10个值,


然后根据三阶贝塞尔曲线公式:



把T=0.1,0.2,0.3....0.9代入公式, 即可求得图中这9个红点的坐标


然后判断t落在哪2个点坐标之间, 接着在这2红点之间线段内进行线性插值即可


具体代码可以参考仓库中的 SpineBezierUtils.ts


5. 骨架动画的计算目标


我们计算骨架动画,最终的目的, 是要算出在某个时刻, 所有骨骼在根节点下的变换矩阵, 我们暂且称之为 世界变换矩阵


计算方法如下

1)对所有的骨骼, 根据当前时间, 和动画关键帧, 插值得到属性(xy, rotation等)

2)计算所有骨骼的本地变换矩阵(local transformation)

3)从根骨骼开始, 从上到下计算所有骨骼在骨骼根节点空间上的变换矩阵(world transformation)


第1步里需要计算插值, 前面已经讲到了,不再冗述。

第2步需要计算所有骨骼的本地变换矩阵, 这个其实不难, 一般骨骼的属性有 xy, rotation, scale, shear(变形),

我们用一个4x4的矩阵来存储这个变换信息,

通常2D里只会使用到 a, b, c, d, x, y 这些项

如果 旋转角度A + 斜切角度(shearX, shearY) + 缩放(scaleX, scaleY) + 平移(x, y)

我们可以得到最终的本地变换矩阵:

第3步, 我们需要从根骨骼开始计算每根骨骼的最终世界变换矩阵

假设从上到下4根骨骼: 根->爷->父->我

依次计算如下:(w表示世界矩阵, l表示本地矩阵)

计算顺序很重要,所以一开始需要对骨骼做好排序,然后依次计算即可。

到这里为止,我们已经得到了某根骨骼最终的世界变换矩阵, 接下来进入蒙皮计算。


05

蒙皮(Skinning)


1. 蒙皮是什么?

mesh的顶点跟某根骨骼绑定, 可以想象成mesh的父节点就是这根骨骼


我们假设mesh中某个顶点相对这根骨骼的坐标是x1, y1, 那么这个顶点在骨骼空间里的最终坐标为:


2. 蒙皮的过程

我们先来考虑最简单的蒙皮, 也就是mesh只绑定在一根骨骼上的情况

如图所示, 我们有2根骨骼, 上面蒙了一层mesh, 每个顶点只绑定了一根骨骼,我们假设2根骨骼接缝处这个绿色的顶点绑定的是左边1号红色骨骼。

那么当骨骼转动如下的时候:

根据蒙皮的计算, 绿色这个顶点是跟着骨骼1走的, 所以会得到如下的效果:

这种情况下看上去就不太自然,关节处感觉凸出了一个角。


所以一般来说,在这种关节处,顶点一般会绑定多根骨骼,

像图中这种情况我们可以让绿色顶点绑定左右2根骨骼,并分别给与50%的权重W。

然后我们使用LBS(linear blend skinning)的方法,来计算这个绿色顶点的最终位置


计算方法如下

首先我们假设绿色顶点如果只绑定了骨骼1, 它的实际坐标会是P1,

然后我们假设绿色顶点如果只绑定了骨骼2, 它的实际坐标会是P2,

然后我们把2个坐标进行线性混合, P = P1w1 + p2w2,   (w1+w2=1)

P就是最终的坐标, 如下图所示

橙色点坐标就是最终混合后的坐标。


为了让这个关节处更平滑,如果我们在这个关节处左右两边再增加一些顶点,靠左边的顶点,给与骨骼1的权重大一些,

靠右边的顶点,给与骨骼2的权重大一些,最后我们混合完所有顶点坐标后,就能得到如下比较平滑的效果:

可以看到这种情况下关节处会比较平滑不再那么生硬。


3. 蒙皮的计算公式

假设某个顶点绑定了 2根骨骼

Bone1:  x1, y1, w1 

Bone2:  x2, y2, w2


下图是哥布林手上拿的木棍的mesh的蒙皮设置:

图中该点绑定了上下两根骨骼, 权重大约是一半一半, 我们把骨骼的动作幅度故意调大一点可以看到骨骼动起来后,该点坐标的变化情况:

可以看到该点由于权重混合的原因,位置始终介于2根骨骼的中间位置。



06

更多细节


1. 网格变形(Mesh Deform)

前面提到过,对于局部的mesh, 美术可以自由调整mesh的顶点位置,从而实现一些非骨骼动画,

这种mesh顶点通过本身的xy关键帧插值产生的动画叫 mesh deform

在上图中哥布林的头部的耳朵部分,主要使用了这种mesh动画来实现。


这种mesh deform公式也很简单,先对mesh顶点在时间轴根据关键帧做插值计算,  算出的值再代入蒙皮公式:



公式跟以前是一样的,只是x1, y1, x2, y2是根据动画时间做动态计算。


2.动作融合(Cross Fading)

当骨骼正在播放某个动作到一半时,如果需要切换到另外一个动作, 如果硬切, 就会产生生硬的效果

通常我们可以使用动作融合的方式,让2个动作在某个短时间内做混合,最终过渡到第2个动作


07

可能的优化手段


经过上面的讲解,我们可以总结一下, 骨骼动画播放的全过程,其实就是:


Rigging (+Deform) + Skinning + Rendering


这里面每一个步骤都可能有优化的空间,每种优化的手段都是根据性能瓶颈原因和优化目的来做的。


1.Rigging阶段的优化

我们发现骨架动画这块, 有3步计算, 骨骼越多计算量越大, 对于那些循环播放的骨骼动画,有很多的计算总是重复在计算。

所以我们其实可以在骨骼动画初始化的时候,就把每一帧,每一个骨骼的世界变换矩阵先计算好存起来,在后面使用到的时候, 直接取相应矩阵就可以了。

这其实也是一种空间换时间的方法。

如果我们把这个计算再提前到程序运行之前,那就有点类似于离线烘焙动画了(baking animation)

计算完的世界矩阵,我们可以放到内存里,也可以序列化到磁盘上。

如果在下一步优化里需要使用到gpu skinning, 我们甚至可以把这个世界矩阵序列化到一个临时的纹理上, 然后传入着色器。


2.Skinning阶段的优化

如果我们发现模型的mesh顶点数太多,做skinning消耗了太多CPU时, 我们可以考虑对这个模型做GPU Skinning

我们先把相关所有骨骼的动画数据先烘焙成一张纹理,当做uniform传入顶点着色器,

然后把每个顶点相关的骨骼索引,权重, 相对xy信息,当做顶点数据的属性传入, 这样就可以在顶点着色器里做skinning.


具体思路大概如下:


1)动画烘焙成纹理

我们假设动画一共30帧,骨骼一共4根,每根骨骼在每一帧有一个世界矩阵要存储, 而这个矩阵,在2D里,最下面2行都是常数

如下图:

我们把上面2行一共8个数值存储到上下2个相邻像素里即可(32位纹理)

30帧,4根骨骼, 可以输出宽度30, 高度8的纹理图片。


然后大家发现单通道是1个字节时,存储矩阵的浮点数可能存在精度丢失问题, 我们可以考虑把纹理改成 单通道4字节的纹理, 又或者是用4倍的像素数量来存储这个矩阵(但是这样会造成顶点着色器采样次数过多的情况)。


2)顶点着色器做skinning

假设有4根骨骼,  b1, b2, b3, b4

每根骨骼的蒙皮需要 bone索引, x, y , w

我们可以使用4x4的矩阵来存储这16个数值,当做顶点属性,直接传入顶点着色器就可以了。


顶点着色器VS拿到了上面baking出来的骨骼数据,又知道了蒙皮信息,那在VS里做蒙皮计算就只是几个乘法加法而已。


3.Rendering阶段的优化

如果有这样的场景, 有大量的草丛(1000个)需要绘制, 单个草丛本身是一个如下的spine动画:


默认的渲染,会造成大量的drawcall和大量的带宽开销,



如果草丛之间仅仅是xy/rotation/scale等不同, 可以考虑使用GPU instancing来解决。


每个草丛之间仅仅只有transformation matrix不一样,我们可以把每个草丛的matrix拼接起来放到一个buffer里面作为instancing时的顶点属性集合。



在实际绘制的时候,传入GPU只有一个Spine mesh的顶点数据,draw call命令也只有一条, 额外需要做的仅仅是告诉GPU, 需要把这个mesh画1000次,每一次实例绘制,从之前的buffer里取出不同的变换矩阵即可。


不同的图形API都有相应的方法来实现instancing, 不过要注意目前已知是opengles 3.1才比较好的支持了这个特性。


instancing画出来的结果是这样的, 满帧60帧在跑,压力全在GPU这。

观察spector.js记录的渲染过程:

只做了一次drawcall, 通过instancing可以画出1000个实例。


--------------------------------------------


乐府互娱 是9102年成立的的一家非常年轻的游戏公司, 位于上海目前游戏新秀扎堆的漕河泾开发区, 有兴趣进一步了解的可以点开我们的官网:http://www.lovengame.com。


目前我们正在热招的技术岗位有:

  • unity资深开发 

  • cocos资深开发

  • 图形程序员

  • golang游戏开发

  • golang平台开发

浏览 75
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报