DIY系列:在C++中自己实现动画系统(第一话:骨骼动画与编辑器)

发表于2015-12-15
评论5 6.7k浏览

DIY系列:在C++中自己实现动画系统(第一话:骨骼动画与编辑器)

每套游戏引擎都会包含动画系统:一些游戏引擎会采用最简单的直播动画的形式(如Unity的Animation方案)仅支持简单动画播放和CrossFade;一些引擎会采用更高级的动画方案,具备动画状态机和混合树的方案在如今基本是标配,但是不同引擎的实现也会有功能上的差别,例如Unity的Mecanim系统就比较原始,采用了假的层次状态机(很垃圾)和功能简单混合树(无法插入功能性节点),而UE4的Animation Blueprint就高级很多,可以在混合树插入很多功能性节点(如IK),同时让可视化脚本能够很方便地控制动画行为。在动画状态机和混合树方案中,我比较推崇Morpheme——一个相当成熟完善的动画中间件,之前我也写过介绍Morpheme的文章(链接)。由于之前的工作内容接触动画系统比较频繁,久而久之我就寻思着自己从头实现一套动画系统来熟悉其底层和Pipeline的方方面面。

我的计划中,包含了动画从制作到播放的完整Pipeline:

1、骨骼动画的定义和储存

2、骨骼动画的简单播放

3、RootMotion

4、骨骼动画编辑器

5、动画状态机与动画混合树的实现与编辑

6、简单IK的实现

7、骨骼动画的导入(FBX格式)

由于篇幅原因,决定拆成两到三篇文章来讲述这个完整的话题。在本文中,将会包含一到五章的内容。

图 1 一个循环骨骼动画的多个帧

1、骨骼动画的定义和储存

骨骼动画是大家耳熟能详的概念,其本质上是记录了以树形结构存储的一系列对象的位置、旋转、缩放随着时间变化的动态,其中的每一个对象就是一根骨骼。我们先来了解几个概念:

         骨架信息(Rig)
骨骼动画通常是依赖着特定的骨架而存在的,骨架通常称为Rig,其描述了一套骨架有哪些骨骼,每根骨骼的父子关系,每根骨骼默认的Transform(也就是所谓BindPose),以及其他的一些额外信息

         姿态(Pose)
一个姿态描述了一个骨架中每一个节点Transform的一个静态,诸如站立、奔跑的一帧。每个Rig都会储存一个BindPose,就是制作这个骨架时候的默认姿态(通常是T-Pose)。Pose一般不储存骨架的层次关系,而是用一个数组来依次储存每一个节点Transform,因而无法脱离Rig单独使用。另外, Pose是骨骼动画采样结果的一部分。

         骨骼动画(Skeletal Animation)
一个骨骼动画描述了骨架中每一个节点Transoform随着时间改变的动态,通常使用关键帧的形式来进行储存和表达。像其他关键帧动画一样,通常具有FPS概念,即一秒钟包含多少帧。与Pose一样,骨骼动画无法脱离Rig存在,否则就无法驱动具体的对象,也无法实现蒙皮动画(因为缺乏Rig)。

 

在游戏引擎中,骨骼动画通常以两个形态存在,原始数据和转换后数据:

         原始数据储存了最适合用户进行动画编辑制作的数据,比如3DMax导出的FBX格式动画,或是Unity中使用动画编辑工具制作的动画。这类数据格式简单,并且不做压缩,比如使用单独的Track来储存位移、旋转、缩放的XYZ三个通道。原始数据可以直接用于编辑和播放,但是内存开销和CPU开销通常较大,所以一般不这么做。

         转换后数据储存了对原始数据进行加工后的版本,通常在游戏引擎的导入资源阶段会进行数据转换处理。转换后的动画数据通常经过压缩,使其内存开销和CPU开销降低。转换后数据可以直接用于动画播放。

 

前面也提到,我希望包含动画制作到播放的完整Pipeline,当然无法忽略原始数据。动画的数据通常采用Animation-Track-KeyFrame的形式储存:

         关键帧(KeyFrame
关键帧表示数据位于特定时间点的一个关键值,这个类型可以是float、Vector3之类可插值的类型。关键帧的除了包含特定时刻的数据值以外,在原始动画数据中,往往还需要包含值改变趋势——比如说斜率、切线等信息,方便在关键帧上运用Spline插值。注意关键帧与帧的区别,动画数据中只储存关键帧,其他帧都是由关键帧按时间插值的结果。

         时间轴(Track
时间轴是关键帧的集合,其储存了多个关键帧的时间(对其到帧)和值,对Track在某个时间进行采样就能得到关键帧的插值结果。一条时间轴通常被用来描述完整动画的一个方面,比如某个骨骼的旋转。而在原始动画数据中,为了能够做到对动态的更精细控制,Track所表达内容往往需要更加精细,比如位置X/Y/Z三个通道分别控制。

         动画(Animation
动画是关键帧的集合,包含了描述一整个骨骼所有部分的Track数据。动画具有长度(对其到帧)、FPS(帧率)、是否循环等属性。对动画进行采样会得到多种信息,其中最重要的就是Pose,也就是对所有Track依次采样得到的帧信息的集合。Pose可用来改变对象的Transform,或者输出到渲染器进行蒙皮渲染。

1.     原始动画数据

我们可以很轻易地定义出原始动画数据所需要的数据结构和基本方法:

实现动画播放,最核心部分莫过于对动画的采样插值的算法。由于动画数据是由独立的多个Track构成的,所以最终其实是分解到对Track中的关键帧进行插值。

在3DMax或者Unity的动画编辑器中,对关键帧插值的形式通常有几种:不插值(取前一个关键帧值)、线性插值、样条线插值,其中以样条线插值最为平滑可控。

图 2 三种插值形式

当我们提到样条线,我们通常指的是贝塞尔曲线(Bézier curve)这样的三次样条线,这样的曲线可以保证二次导数平滑,计算起来也相对简单。而事实上,另外两种插值形式完全可以三次样条线插值表现出来。在我的方案中,使用的是贝塞尔曲线的一个修改形式,贝塞尔曲线中,通过对两个点之间引入控制点来控制样条线的形状(样条线不一定经过控制点),而修改过后变为通过两点的斜率(左边右边斜率分开)来控制样条线形状,更加符合常规曲线编辑器的使用习惯。

图 3 三次样条线的控制点与斜率

图 4 用三次样条线表现出不同插值方式

修改过的三次样条线插值算法如下

值得注意的一点,是当动画是循环动画的时候,对第一个关键帧之前与最后一个关键帧之后的区域进行插值的时候,要将动画的最后一个关键帧作为前一个关键帧,而动画的第一个关键帧作为后一个关键帧。

图 5 循环动画与非循环动画

 

如上,我们就完成了对原始动画数据格式的开发。

2.     转换后的动画数据

原始动画数据最为精细,适用于动画编辑阶段,但内存和CPU开销较大。实际使用中,通常会对动画数据进行转换和压缩,常规的方法有这么几种:

         压缩位置
骨骼动画中,节点的位置通常不需要float这么高的精度,可以将其限定在某个范围内以使用更少的位数来表达。

         压缩旋转
原始动画数据中通常使用欧拉角来描述骨骼的旋转,这么做是为了对三个不同维度的旋转速度变化有更自由的控制;而转换后通常直接四元数来储存旋转量。一方面,大多数游戏引擎中正式使用四元数来描述物体的旋转,关键帧之间直接使用Slerp插值便可得到最终数据,无需再从欧拉角转成四元数;另一方面,针对四元数有比较成熟压缩方法,例如RiotGames的这篇文章《Character Animation Compression》(链接)中,就提到了利用单位四元数的特性,忽略掉X/Y/Z/W四个元素中值最大的一个不储存,从而把原本需要128位储存的四元数压缩到48位。

         重采样(Resampling)、减少关键帧(Key Frame Reduction)、曲线适配(Curve Fitting
对原始动画数据进行重采样有助于防止原始动画关键帧冗余的情况,重新采样的过程中,通过运用特定的算法,可以使用更少的关键帧通过线性或是样条线插值来对源动画进行拟近,只要最终误差保持在可接受的范围内就行。

         忽略骨骼的移动与缩放(如果可以的话):
这涉及到一个统计规律,对于大多数动画而言,基本所有的骨骼都会发生旋转,部分骨骼会发生位移变化(相对父节点),而只有极少的骨骼会有缩放的变化,因而我们可以在合适的时候忽略掉骨骼的移动与缩放(这样它们的值就跟BindPose一样),从而减少了动画所需要储存的数据量。事实上Morpheme所使用的XMD动画压缩格式就忽略掉了骨骼的缩放,而通过把缩放Bake到位移中来进行一些适配。

 

骨骼动画压缩的具体算法要写出来可以写很多,本文就不做具体介绍了(以后可能另起一篇)。需要注意的是,当采用了转换后的动画数据时,Animation-Track-KeyFrame的机制仍然起作用,但是关键帧的类型就需要扩展到除了float之外的Vector3与Quaternion,并支持更广泛的插值算法。

 

2、骨骼动画的简单播放

动画的播放,根本上来说就是依照时间对动画进行采样,将Pose等采样结果输出。因而,要想对动画进行播放,我们首先得定义动画的采样结果。

从上面的代码可看出,动画的采样结果包含如下几个部分的信息:

         Pose信息
位置、旋转和缩放,以骨骼ID为顺序储存。其中旋转信息采用的是可直接使用的四元数。注意此时的Pose不能直接用于Skinning渲染,需要先转换到BindPose空间。

         RootMotion信息
包含了当前帧与上一帧之间位置和旋转的差异,具体细节下面会解释

         动画播放状态
包含了被采样动画的长度信息和播放进度(单位化到0~1之间),用于动画状态机和混合树系统进行动画同步。

         Rig标识
Rig标识取自被采样的动画,标明了该采样结果适用于哪个Rig

 

动画采样结果是可以进行混合的,混合操作即传入一个0到1之间的权重,对Pose、RootMotion信息和动画播放状态进行线性插值,其中Pose的混合其实就是在父空间下对每个骨骼的位置旋转和缩放做线性插值,骨骼与骨骼之间互不干扰。混合的结果创建了一个新的AnimationSampleResult,动画的Blending、CrossFade等常用功能都来自于对采样结果的混合。

不难想到,在有动画状态机和混合树的情况下,AnimationSampleResult的混合、创建、销毁和复制是非常常见的情况,同时对于同一个Rig的动画采样结果来说,其内存结构和大小是完全一样的,这就意味着非常适合使用对象池来进行优化。

 

定义好了动画的采样结果,我们就能够编写代码让动画对各个Track进行采样,最终输出该结果。Track中关键帧是以时间顺序排序好的,而关键帧插值需要根据时间定位到前后两个关键帧的位置,这其实就是一个寻址问题。从头开始寻找明显是低效的,一个常用的方法是使用二分法来查找,另一个常用的方法是记录上一次关键帧位置作为寻址的起点(前提是动画正向播放的)。

此时我们可以编写一个AnimationComponent,传入Rig与动画,随着时间的流逝从前往后对动画进行采样,并将结果输出给渲染器,这就实现了最简单骨骼动画播放。

3、RootMotion

什么是RootMotion?RootMotion从字面上来说,就是“根节点的运动”,事实上它表示的是动画驱动游戏对象运动这么一回事。在不使用RootMotion的情况下,角色对象的运动与对象身上播放的动画是无关的——运动由程序逻辑控制,而动画则自顾自的播放。这在大多数对角色运动要求不高的游戏中还可以接受,但是一些对角色运动品质要求高的游戏来说,则会出现一些不能接受的问题,比如说角色滑步。角色滑步指的是游戏角色的移动速度与角色动画脚步运动速度不一致,让人感觉角色的脚在打滑,而从根本原因上来说,是程序无法完美地拟合动画所表现出来的运动趋势。解决这个问题的一个好方法,就是让动画来驱动角色的运动,以达到角色运动和动画的完美同步。

不同的动画系统对于RootMotion的实现方式略有不同:Unity引擎通过分析人体骨骼动画来计算角色的运动,UE4采用角色根骨骼运动作为角色运动参考,Morpheme采用额外的Trajectory骨骼来作为角色运动的参考。而对于这些方案来说,相同点是,动画师在制作动画的时候,需要制作出实际的位移,以及在游戏引擎中最终播放的动画中这部分位移将会被从动画中自动消除,而叠加到对象的位移中。

图 6 RootMotion

前文提到,RootMotion的信息是从采样动画中获得的,那么如何采样出RootMotion信息呢?首先,我们采用角色动画的根骨格运动作为角色对象运动(也就是UE4的方式),由于每一根骨骼采样出来的变换信息都是相对于父节点的,而根骨格的变换则是相对于世界原点(0,0,0),因而非常容易地就能够把根骨格的运动和角色其他骨骼运动分离开,只需要将当帧根骨格的变换减去上一帧的变换,即可得到RootMotion所需的差量。其次,需要注意的一个问题是处理动画的循环,当动画发生循环时,由于根骨格位置会回归,此时求出来的插值也会将角色拉回原位,而事实上角色是应该继续往前走的。正确的处理方法是在发生循环的瞬间,将输出的差量叠加上第一帧到最后一帧的完整差量。

采样动画获得了RootMotion信息后,可以直接输出到角色对象,也可以经过物理等运算的处理后再输出,由开发者自己决定。

4、骨骼动画编辑器

与前一篇文章一样,我使用Qt4来开发骨骼动画的编辑器,以支持对游戏中的一些简单对象制作简单的骨骼动画(不支持外部导入的动画的编辑),保存为原始动画数据。这部分不会展开细谈,只是说明其中的一些要点。

骨骼动画编辑器由三个部分构成:

         场景编辑器:就是游戏引擎编辑器的最主要部分,与骨骼动画编辑器相关的部分就是对象的选择、移动、旋转、缩放等操作,以及TransformComponent组件

         曲线编辑器:用于编辑原始动画数据中各个Track的关键帧

         PoseHelper组件:为编辑骨骼动画提供便利,提供Pose的复制、粘贴、混合和镜像功能,类似于3DMax中Biped骨骼提供的一些辅助编辑功能

         IKSolver组件:基于CCD(Cyclic Coordinate Descent )的简单IK实现,帮助编辑手脚等肢体的动画,具体会在第六章介绍

图 7 骨骼动画编辑器

 

与Unity的动画编辑器一样,曲线编辑器监听场景中选定的对象身上各级骨骼Transform的改变,作为当前选定时间上关键帧的值,这其中有几个要点:

         从TransformComponent中获得的新欧拉角需要与动画中当前采样的值对齐(保证角度差别在±Pi/2之间),否则由于Quaternion转EulerAngles结果的不确定性,会造成骨骼旋转错误

         添加关键帧分为自动模式和手动模式,自动模式会监听TransformComponent的改变消息,把改变过的值插入当前时间的关键帧中;手动模式则会将所有骨骼的Transform与动画当前各个Track的采样值做对比,仅当改变大于误差范围时才记录成关键帧

         当关键帧插入或是被修改以后,需要新关键帧以及前后两个关键帧进行自动平滑,这样能够使骨骼的平移、旋转更加自然流畅。自动平滑算法核心是,将前一个关键帧到当前关键帧连线的斜率以及当前关键帧到下一关键帧连线的斜率做加权平均,最终作为当前关键帧的斜率。

图 8 自动平滑算法

 

对于动画编辑而言,另一个必须的功能就是Pose的辅助功能。前文已经提到,Pose有复制、混合和销毁的方法,这些功能可以直接让PoseHelper组件的UI提供出来。比较麻烦的是Pose的镜像功能。在3DMax中,当使用Biped骨骼来进行Pose编辑的时候,由于骨骼是自己创建的,3DMax内部可以明确地知道对于每根骨骼应当怎么实现左右镜像,但是我们则面临更复杂的情况。以下是我的解决方案:

1)         约定每根骨骼总是以Z轴负方向指向其父节点,Z轴正方向作为自己的朝向

2)         要求用户手动将骨骼录入两个列表中——轴骨骼列表与对称骨骼列表:
              其中轴骨骼是那些位于骨架中轴的骨骼,如脊椎、脖子、头,当Pose镜像时,这些骨骼的朝向要沿着 X轴(右方向)反转;
              对称骨骼是那些两两配对的骨骼,如手、脚,当Pose镜像时,这些骨骼除了沿着X轴反转外,还需要交换配对骨骼的姿态;
              其他不列入这些列表的骨骼,则表明在镜像时不需要做处理,比如只有一边的肢体、体侧挂件等。

3)         先将所有列表中骨骼的原位置、旋转缓存下来,然后统一进行沿X轴翻转的计算(X = -X,全部转换到骨骼根节点空间中)。
              由于旋转量(不论是欧拉角还是四元数),都不大方便描述“沿着X轴反转”的行为,我们可以通过分别将骨骼Transform的Up和Forward方向沿X轴反转来实现。注意将对称骨骼的位移和旋转交换。

如此一来,虽然付出了一些用户编辑工足量,但是我们获得了一套非常通用的镜像方法,不仅可以处理中心对称的角色,非中心对称的角色(如只有半边骨骼)也能很好地将该镜像的部位进行镜像。

图 9 (左)对Pose进行镜像的结果 (右)轴骨骼与对称骨骼

 

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引