这家公司太牛逼了,要重新造轮子!骨骼动画
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平台开发