DIY系列:在C++中自己实现动画系统(第二话:状态机与混合树与节点式编辑)
DIY系列:在C++中自己实现动画系统(第二话:状态机与混合树与节点式编辑)
在前一篇文章《DIY系列:在C++中自己实现动画系统(第一话:骨骼动画与编辑器)》中,讲述了如何实现骨骼动画的简单播放,以及介绍了一些编辑器开发的要点。在本文中,将接着上一篇的话题,详细介绍如何咋实现一套包含动画状态机、混合树的高级动画系统,以及如何利用以前实现过的可视化脚本系统来进行节点式的动画状态机和混合树编辑。
首先来了解一下动画状态机和混合树:
动画状态机
在一些简单的游戏系统中,所有动画资源都是离散而未经组织的,角色逻辑控制器直接决定什么时候应当播放哪一个动画。这样的系统在架构上很简洁,适合一些动画量较少、动作简单、动画之间关联不大的游戏(比如早期的一些RPG、ARPG类)。然而随着动画资源逐渐增多、游戏系统对动画之间逻辑关联逐渐增强,老的系统就面临一个难题——角色逻辑控制器需要控制的细节太多了,诸如Idle动画持续多少秒之后应当切入随机动画,以及起跳后什么时候需要切入循环下落动画等细节,事实上和角色的行为逻辑没有太大关系。于是,提出了将角色的行为逻辑(比如AI)和动画逻辑拆分开的方案,行为逻辑专注于处理角色的物理、运动、数值等方面,而动画逻辑专注于处理动画的组合和转换,两个层次间的耦合尽量减少。在诸多方案中,最常见的方案就是使用状态机的形式来管理角色的动画逻辑,以状态的形式来描述动画的分类(如站立、移动、跳跃等),以状态间转换的形式来描述动画之间的过渡,既直观又高效。
动画混合树
动画混合树用于解决另外一个方面的问题,即随着游戏所需的动画表现越来越多、越来越细,如何充分利用有限的动画资源来实现更多的动画效果?骨骼动画的混合给解决这个问题提供了思路,通过将多个动画按照需求混合到一起,就可以组合出大量的中间状态动画。例如,跑步和走路中间任意速度的动画可以通过将走和跑两个动画以一定权重混合而得到;射击游戏中,角色持枪的任意角度,可以通过混合最大角度抬枪口、水平持枪和最大角度压枪口三个动画而得到。甚至,可以将一次动画混合的结果动画作为另一次混合的输入,最终形成树状结构,这就是动画混合树。而现今的动画混合树技术经由发展,已经从单纯地对动画进行混合,进化到可以进行多种类型的动画后期处理(诸如IK)。
动画状态机与混合树的搭配,基本是现在游戏引擎和动画中间件的主流选择。但就算是具备了动画状态机和混合树的系统,在功能上也有高下之分——例如Unity虽然具备了动画状态机和混合树,但其实都是半成品,首先状态机未采用真正的层次状态机,而是伪层次状态机,导致了状态与状态之间耦合过重,给状态机的设计带来了很大的困难;另一方面,混合树功能过于单薄,仅仅包含了最简单的混合功能,无法插入其他类型的节点,能做的事情很少。相比之下,Morpheme动画中间件则在两方面上都更为强大,Morpheme不仅实现了真正的层次状态机,还能够让状态机与混合树相互嵌套——状态机输出的动画可以作为混合树的输入,而混合树的输出则可以作为状态机的一个状态,这给动画带来了很大的灵活度;另一方面,Morpheme的混合树不仅仅是单纯的动画混合,而是包含了很多的功能节点,诸如IK、部位混合、静态帧、骨骼适配之类的功能都可以直接插入混合树中使用,同时节点的输出类型也由动画扩展到数值,可以直接在混合树中进行数学和逻辑运算,将混合树升级成了类似动画后处理可视化脚本的概念。
是不是感觉之前的文章《DIY系列:在C++中自己实现可视化脚本系统》和Morpheme在一些方面的设计有点像?没错,我正是准备充分利用之前的工作,在可视化脚本系统的基础上构建出类似Morpheme的动画状态机和混合树系统。参照Morpheme列举了一下目标系统的特性:
层次状态机
动画混合树
丰富的混合树节点
层次状态机与动画混合树相互嵌套
1、动画混合树的实现
由于相比动画混合树,动画状态机的功能和结构相对来说简单(况且之前也已经基本实现了),所以我们先来讨论混合树的实现。不难想到,动画混合树从本质上来说也是一个节点式结构——上一个节点的输出作为下一个节点的输入。那么节点输出的类型是什么呢?模糊来说就是动画,但具体来说,最终输出的数据其实就是渲染器和角色逻辑控制器所接受的数据——Pose、RootMotion信息等,是不是很眼熟?没错,这就是上一篇文章中定义过的AnimationSampleResult。那么,既然是节点式结构,那么能不能直接利用已经开发好的可视化脚本功能,让混合树的节点继承FlowGraphNode,放到流程图中来执行?原理上来说是可行的(我最初也是这么设想的),不过面临几个问题:
- 这无法处理状态机转换的动画过渡(CrossFade)。不论在Unity、UE4还是Morpheme中,动画状态机的状态发生转换都不是瞬时的,这意味着上一个状态输出的动画要在一个可控的时间中逐渐混合到下一个状态的输出动画,这也就意味着同时存在两个激活状态。而对于纯粹的逻辑功能来说,两个激活状态是多余且容易造成歧义的,因而之前的状态机实现只有一个激活状态。
- 我希望动画混合树能够不依赖于可视化编辑器,而给程序提供更大的动态特性——也就是说,程序可以不通过可视化脚本系统直接构建混合树并控制它的行为,而程序控制又需要足够简单,不用每帧去维护它的运行状态,混合树最好能够在创建后自动运行。
显然,混合树在运行时得是独立于可视化脚本的另一套系统。基于以上的思考,我定义了一种名为AnimationHub的结构来实现混合树的功能(叫Hub是为了防止和可视化脚本的Node冲突):
一个AnimationHub是一个动态的节点,它可以有多个子节点和一个父节点(也是AnimationHub)
AnimationHub每次更新(GetOutput方法)都会从所有子节点获取AnimationSampleResult类型的输入,经过处理后,返回AnimationSampleResult类型的输出
AnimationHub的子节点数量会随着更新而自动发生变化
AnimationHub还有两个方法:SetPlaySpeed与SetPlayPosition,用于动画混合、过渡的同步
AnimationHub是抽象基类,继承它来实现不同节点的具体逻辑
在具体介绍和各种AnimationHub的实现之前,先看一下作为基类的AnimationHub的定义:
下面以一个TPS类游戏角色某个时刻的混合树为例,看一下AnimationHub是怎么运作的:
图 1 一个TPS游戏角色的混合树,由若干AnimationHub构成
上图中,每一个圆角矩形代表一个AnimationHub,它们属于不同的类型:
AnimationClip:对动画资源进行采样输出,并自动从头播放到尾
Blend 2 / Blend 3:对子节点的输出进行加权混合,并可以自动同步两个不同长度子节点的播放进度
CCD IK:通过IK算法将指定的骨骼及其若干层父节点调整到合适位置
Cross Fade:输出动画在指定时间内,从第一个子节点的输出混合过渡到第二个子节点的输出;与Blend 2不同的是:第一,混合权重随着时间逐渐向第二子节点转移,第二,当过渡时间结束后,第一个子节点将被自动移除,从此只输出原本第二个子节点的动画
Feather Blend:根据指定的Mask,将两个子节点输出的每根骨骼的变换信息,以Mask中指定的相互独立的权重进行混合,也称为部位混合
除了上图所展现的之外,还有其他一些非常有用的AnimationHub节点:
SingleFrame:对动画资源进行单帧采样输出,可由程序来控制帧的位置
Subtract/Add:求两个Pose的差量/叠加量并输出,可以用于进行叠加混合(AdditiveBlend)
Retarget:根据指定的Rig适配参数,将适用于其它Rig的动画进行调整以应用当当前Rig上,使得动画资源复用度提升
Mirror:根据指定的镜像配置参数,将动画进行镜像处理并输出,使得动画资源复用度提升
……
接下来根据几个简单Hub来介绍一下Hub的实现:
首先是最基本的AnimationClipHub的实现,它不接受子节点的输入,而是直接采样动画,输出结果
再来看看同样常用的Blend2Node,它接受两个子节点的输入,然后产生一个输出
对两个动画进行混合为什么要进行同步呢?因为当我们将两个长度不同但节奏相同的动画(诸如跑步和走路,走路的动画会长一些、慢一些,但是节奏是一样的)进行混合的时候,如果两个动画采用相同的速度进行播放,则长度长的动画播放进度就会落后(跑步已经跑完一步,走路只走了半步),这样混合出来的动画结果就是错误的。因而,在这种情况下面,我们就需要对参与混合的两个动画的速度根据一定规则进行控制,也就是让两个动画的播放达成同步。
图 2 动画混合的同步
同步公式其实很简单,假设两个动画混合的权重为w,动画1的长度为l1,动画2的长度为l2,
计算混合长度 bl = l2 * w + l1 * (1 - w),则
动画1的播放速度 s1 = l1 / bl
动画2的播放速度 s2 = l2 / bl
另外再简单提一下CrossFadeHub与FeatherBlendHub的实现,两者其实都是Blend2的变种,只是逻辑上有一些差别:
CrossFadeHub:
比Blend2Hub多了个weight在指定时间内自动从0变化到1的功能,以实现两个动画的自动过渡。当weight=1以后,第一个子节点所在分支将被自动删除
此外,动画过渡出了速度同步,还需要进行起始点(动画开始播放的时间点)的同步——即根据过渡开始时动画1的播放进度,来设置动画2的播放进度。这需要使用SetPlayPosition接口。
同步情况下,假设动画1播放进度为p1,同步区间偏移为o,同步区间长度比例为s,则动画2起始点p2 = p1 * s + o
FeatherBlendHub:
用于部位混合,对于Pose的每根骨骼采用用户预先设定的独立权重进行混合,实现Pose的一部分来自一个动画,另一部分来自另一个动画的效果(诸如角色下半身跑跳的同时上半身进行攻击或防御)
当实现了足够使用的Hub类型以后,我们就可以在代码中使用Hub来构建混合树了。以下是一个搭建图1所示的混合树的示例代码:
2、将动画混合树套用到可视化脚本系统
现在我们已经有了一套可以用程序来构建的动画混合树系统了,接下来该考虑如何运用之前开发的可视化脚本系统来创建混合树了。回想一下,在流程图中,什么情形与混合树的输入输出结构比较类似呢?AnimationHub接受多个参数输入,产生一个参数输出,正好可以通过FlowGraphNode的ParamPin连接来模拟。通过实现这么一类节点,每个节点提供一个AnimationHub类型的输出(用于创建不同的AnimationHub),同时可以接受多个AnimationHub的输入(作为创建的AnimationHub的子Hub),我们就能通过可视化脚本来搭建一个动画混合树。
图 3 可视化脚本节点构建动画混合树
以Blend2节点的代码为例,看一下如何由可视化脚本节点来创建AnimationHub
通过将这些节点放置在状态机的Update流程图中,就可以实现每帧更新对应AnimationHub的参数,以方便可视化脚本实时控制混合树行为。只有第一帧会创建新的hub,而往后的每一帧只是更新hub的参数而已。
那么混合树该如何与状态机结合呢?参考其他的动画状态机+混合树系统,可以发现,当动画状态机发生状态变化的时候,无一不伴随着动画的过渡切换——没错,这正是CrossFadeHub所实现的行为。同时,通过上图可发现,各种创建AnimationHub的节点都有一个共通点,那就是只有参数输入和输出,没有执行输入和输出,因而最终会需要连接到一个有执行输入的节点来保证这些节点会被调用到。基于这些特点,我们将创建CrossFadeHub的节点进行特殊化,作为一个带有执行输入输出的节点而存在。加入CrossFade节点后,图3的可视化脚本变为如下形态:
图 4 加入了CrossFade节点以后的可视化脚本
注意,虽然CrossFadeHub会在动画过渡完成后销毁第一个子分支,但是由于此时创建第一个子分支的状态机已经处于非激活状态,所以也不会有逻辑引用到那个分支,销毁是安全的。
注意CrossFade节点和其他节点一样,只有第一帧会创建Hub;与其他节点不同的是,它不会将构建好的子树作为参数输出,而是直接输出给AnimationComponent,让动画系统执行。
由于基于节点的可视化脚本的灵活性很高,我们甚至可以根据实际情况,在一个状态中创建不同的混合树,以及通过数学节点来计算AnimationHub的输入值。如此一来,我们就轻松实现了动画状态机+混合树的这套系统!
图 5 一个完整的动画状态机+混合树
3、高级特性:层次状态机与混合树相互包含
文章开头提到,Morpheme的动画状态机与混合树系统有两个亮点:
第一就是它的状态机是真正的层次状态机系统。所谓“真正”的层次状态机,是指父状态可以作为一个对象整体而存在(而非子状态的集合),状态转换可以直接转到父状态而不需要指定具体转入哪个子状态,同时每一个状态机都可以独立输出自己的动画。
第二就是状态机可以与混合树相互嵌套,状态机的动画输出可以作为混合树的输入,而混合树的输出又可以作为状态机的输入。
以上两个特性给用户带来了极大的自由度,使得一些复杂的动画设计成为可能——例如,身体的不同部位由不同的状态机逻辑来控制,最终通过部位混合整合到一起。
要实现以上功能,我们需要让每个状态机能够独立输出动画,同时又能够引用别的状态机输出的动画:
1、让每个状态分别记录自己的动画混合树和最后一次动画输出
2、引入ReferenceHub,该Hub会直接输出外部给它传入的AnimationSampleResult
3、创建ReferenceHub相对应的可视化脚本节点ReferenceHubNode,加入一个FSMComponent的引用ParamPin,若该Pin值不为NULL,则给ReferenceHub传入指定状态机输出的动画;否则传入子状态机输出的动画(如果有的话)。注意子状态机需要在父状态机之前更新
4、FSMComponent需要分主次,只有主状态机可以向AnimationComponent输出,而所有被引用的其他状态机和子状态机都为次状态机
如此,我们已经实现了一套可用且自由的动画状态机与混合树系统。