动画系统是引擎核心功能之一,之前用Unity开发,只是用用编辑器,一直没太深入去看原理。最近看了看UE的功能和源码,收获很多,对动画是怎么跑起来的,有了更深的理解,同时看的时候也遇到很多问题。
关于动画的实现,资料并不多,很多只是大致说一下流程,有些又说的太高级,省略了很多细节,只提出一个方向,所以又到处查源码,总算弄清楚了一些问题。
一是自己对动画系统的理解,通过遇到的一些问题,结合源码,加上用Unity的经验,有一些解答,不一定对,毕竟没自己实现过,有一定局限性,欢迎大家留言讨论。
二是UE的实现,熟悉底层逻辑,也能更好地使用和扩展。对于实现来说,不止一种方式,UE的代码也经过很多次迭代,更重要的是理解背后的设计思路,这也是看代码的乐趣所在,并不是只看个流程,重要的是这个思考的过程。
二、动画系统设计
首先要实现一套动画系统,需要先分解功能,有个基本的思路。对于动画系统来说,简单的可以分为两层来做,一是实现核心功能,二是基于核心功能,针对特定问题,给出支持的方案。
核心功能分解
首先,最核心的,是让模型动起来,基于动画管线实现。这部分在扩展动画的时候几乎不用改,是动画的最底层。
然后,是怎么组织多个动作,动作的选择和控制(计算顺序、骨骼控制等)。控制的复杂度,源于让玩家感觉整体动作是流畅的这样一个需求。
对游戏引擎来说,需要一个中间类,去和游戏逻辑交互,以及驱动动画系统运行。也就是处理输入和输出,加上驱动动画播放。
这样,就实现了一个游戏的基础动画系统。但是,仅仅这些还不够,还需要针对一些问题,做扩展,才是个完整的系统。
解决特定问题
对游戏引擎来说,能播放动画,只是动画系统的核心功能,而不是个完整的系统。因为动画和游戏逻辑是有很多交互的,引擎要针对这些特定问题,或者说是需求,给出解决方案。
这样就大致做了功能模块分解,到了实现代码的部分。那写代码有一些基本原则,在UE的实现上是怎么体现的呢,大概想到以下几点:
代码结构:高内聚,低耦合的原则。体现为分层设计,加上模块化功能。
1.功能模块:资源(动画序列、混合空间),曲线,蒙太奇,通知,插槽,实现特定功能。
2.中间层,由节点组成。节点分类型,连接功能模块和控制层,单向依赖基础层。
3.控制层,核心是蓝图。蓝图依赖节点,将节点组成流程。USkeletalMeshComponent,驱动动画系统运行,作为动画系统和外部的中间类。
扩展性:在实际开发中,如果对动画的要求较高,那扩展是必不可少的,UE主要有两种方式
1.通过接口的形式,表现为节点,通过节点实现不同逻辑,嵌入到动画流程中。基于依赖倒置原则,节点实现接口,动画流程依赖这些接口。
2.继承,组件,蓝图实例都可以继承,自定义运行流程。
性能:体现在资源和计算量上,动画在这两方面消耗都不低。
2.计算骨骼数据,只是为了显示,不影响逻辑,分到多线程去做。
想清楚这些问题,看代码的时候更容易理解现有的代码结构。当然还有很多我想不到的问题,UE的代码也是一个一个版本迭代上来的,中间很多设计很难从结果上看出来,尽量理解就好。
三、动画系统实现结构
功能模块结构图
UE提供的功能分四部分,但实际项目,数据层一般是自己写C++代码,性能好些,所以重点是前面的三个部分。
核心层
目的是实现动画管线,动画管线本身是个抽象的概念。UE通过节点,根据数据和骨骼操作,调用核心层提供的接口,实现管线。
1.采样:通过资源的封装实现。
2.混合:都是通过几种模式对应的函数实现,一般是FAnimationRuntime实现。
3.后处理:对现有姿势做调整,按一定算法和条件,一般节点自己实现,用于扩展自定义效果。
混合和后处理的区别,混合的目的是处理动画间的过渡,算法相对固定。后处理是对动作做调整,为了和场景更好的匹配,一般是IK。
控制层:分编辑和逻辑两部分
编辑:通过节点,提供数据和骨骼操作,给核心层,驱动动画管线运行。
逻辑:用于执行节点。
游戏逻辑交互层
以上模块,只能实现一个静态的动画,而不是一个可交互的系统,这一层是接收玩家的输入,驱动动画系统运行,并将结果展示给玩家。
控制层和交互层,实现了游戏逻辑和显示效果解耦,游戏逻辑只关心发生了什么,提供数据,具体表现效果由动画系统决定。
动画执行流程
画了一个大致的流程,算是动画执行的主线流程吧,一些细节和分支没画,避免结构太复杂。对照着代码实现看,会方便一些。
四、动画管线实现
什么是动画管线
动画管线指一系列运算,把输入(动画资源、混合设置),变换成输出(局部及全局姿势、渲染用的矩阵调色板)。
定义有些抽象,简单理解,就是把生成一个动作的处理,分三个逻辑阶段,输入一些数据,得到一个姿势。
逻辑上分三部分,采样、融合、后处理。大致流程是,采样需要的多个动画,加上各种参数、条件做融合,之后后处理,输出的一个姿势。
UE实现机制
不同于渲染管线,动画管线是个抽象概念,UE里通过节点实现。对应管线的三部分逻辑,UE实现上也是分别实现这三个逻辑。
采样
分两部分,一是采样数据,由资源提供接口。二是外部驱动流程。
采样接口
流程
融合
融合的效果是将多个动画,按一定算法,生成一个动画。
基础是变换运算
1.基于权重的覆盖:目标变换=源变换*权重
2.叠加:目标变换=目标变换+(源变换*权重)
姿势混合有3种方式
1.线性插值:两个姿势的中间姿势,各有权重,用于动画过渡。权重的算法,可以实现不同的曲线,对应不同融合效果。
2.加法混合:用于叠加,一般是基础动画,加上一个特殊状态,比如在基础的移动上,叠加上受伤、拿武器等。优点是可以用组合的方式减少资源。
3.骨骼分部混合:不同骨骼分别播动画,按部位分离,也是可以减少资源,有些动画控制特定骨骼就可以了,比如实现上半身攻击下半身移动的效果。
融合一个作用是动画过渡
1.采样资源时直接做融合,由扩展的资源实现。
2.通过指定的节点,输入多个pose做融合。
核心算法实现,在FAnimationRuntime类,节点和资源会调用这个类的方法。
后处理
作用是对动画姿势做校正,主要是各部位的IK,因为做动画的时候,生成的是和环境无关的姿势,而实际运行中,动作要和周围的环境有一定的匹配,这样才显得更真实。
具体算法由FAnimNode_SkeletalControlBase子类实现,UE实现了多种IK算法,之后在细看看每种算法的实现逻辑。
五、节点机制
节点理解
节点可以说是UE实现灵活编辑动画流程的基础,在蓝图上自由关联节点、关联蓝图,离不开节点的支持。
用树的方式来理解的话,OutPutPose是根节点,那些动画资源播放节点是叶子节点,姿势混合节点是中间节点。然后通过控制节点关联到一起。
节点机制用到了策略模式和组合模式。策略模式,体现为节点可以互相替换,这样也支持了扩展。组合模式体现为节点可以通过PoseLink互相连接,也就实现了自由编辑流程的效果,PoseLink这名字,也说明了节点的最终功能是计算pose。
UE通过节点,将对动作的操作,抽象为对输入输出的数据的操作,这样不管加了什么逻辑,只要输入的数据和输出的数据结构相同,就可以互相连接,也是基于这样的原理,支持的自定义扩展。
节点分类
节点主要有三个功能:
1.实现特定功能
2.控制流程,连接到节点,以及连接到其他蓝图
3.扩展,自定义节点,将逻辑插入动画流程中
根节点:FAnimNode_Root,对应蓝图中最后用于输出的OutPut节点。赋值到FAnimInstanceProxy,作为蓝图运行的节点的起点。
TArrayPreUpdateNodeProperties;TArrayDynamicResetNodeProperties;TArrayStateMachineNodeProperties;TArrayInitializationNodeProperties;
实现特定功能
控制流程
节点的执行
1.从最后一个节点FAnimNode_Root开始,调用保存的FPoseLink::Evaluate,执行link关联的节点,Evaluate_AnyThread方法。反序递归,通过link连接到依赖的节点,从而依次执行。
2.节点关联的linkpose,在子节点定义需要一个或多个,没有定义,表示不依赖其他节点的输入,是递归的终结点。
节点的同步
这里的同步,并不是多线程之间的同步。而是用于确保一些节点逻辑只执行一次。
同步的基础是FGraphTraversalCounter结构体,主要是记录执行次数和执行时的帧数。
实现方法
FGraphTraversalCounterInitializationCounter;FGraphTraversalCounterCachedBonesCounter;FGraphTraversalCounterUpdateCounter;FGraphTraversalCounterEvaluationCounter;FGraphTraversalCounterSlotNodeInitializationCounter;
同步实现逻辑不复杂,就是刚看名字的时候容易想歪,既不是多线程同步,和URO也没关系,看代码的时候在这迷惑了半天。
六、蓝图实现
蓝图逻辑看起来很复杂,实际核心功能就是驱动节点运行,加上处理一些可以在主线程处理的逻辑,以及保存数据。流程理清楚就可以了。
实现上分两个类,AnimInstance和AnimInstanceProxy,目的是让动画系统高效运行,将逻辑数据和表现数据分别计算,逻辑数据在主线程,表现数据分到其他线程。
UAnimInstance
蓝图的父类。对内封装动画流程,对外和组件交互。可以继承,实现自定义逻辑。
几个主要功能,体现为一些被组件调用的函数。
1.原因是game线程和工作线程,不能同时访问这个数据。
2.如果是game线程访问,如果task运行中则等待task完成,否则直接获取。
3.工作线程访问,如果当前是game线程,应该会报错。
4.调用组件的HandleExistingParallelEvaluationTask方法,执行完当前异步操作。
UpdateAnimation处理逻辑数据
1.preupdate主要是做初始化,重置数据等,也会调用proxy的preupdate
ParallelEvaluateAnimation处理显示数据
多线程下被工作线程调用,可设置为主线程。根据UpdateAnimation计算后产生的控制变量,通过节点计算修改骨骼。
用FParallelEvaluationData保存计算后的骨骼、曲线和属性数据。
EvaluateAnimation函数,调用保存的根节点,开始执行各个节点。
SkeletalMeshComponent逻辑
这部分代码不少,一些判断条件较多,执行流程可以结合上边发的图来看,具体逻辑就不写了,打个断点看一下,基本了解流程也就可以了,核心的逻辑还是依靠AnimInstance和AnimInstanceProxy实现。
七、蒙太奇
实现的功能
表现上,蒙太奇是种动画资源,但是实际上,只是引用了资源,本身可以看做一条逻辑线,用于连接动画和游戏逻辑。
1.从资源的继承体系,可以看到,蒙太奇是对基本资源做的扩展
2.可以将多个动画序列合并为单个资产并通过蓝图和C++播放。简化了动画资源的管理,将多个相互关联的动画当做一个处理。
1.可以创建多个蒙太奇分段,在运行时,按一定逻辑以任何顺序动态播放。可以在蒙太奇分段面板中控制分段之间的过渡,也可以使用蓝图(Blueprints)在分段之间设置更复杂的过渡行为。
2.在顺序播放的基础上支持跳转,以及正向和反向播放。
3.编辑结果相当于创建一条支持跳转的逻辑线,这条逻辑线可以驱动关联的动作的播放,也可以只用来触发动画通知或控制曲线。
1.可以通过节点,播放蒙太奇后,指定要覆盖的骨骼,这样就可以和现有动作很好的结合,又减少了资源数量。
2.关联到动画资源,但本身不处理动画资源,只提供进度数据。
1.和游戏逻辑交互,通过动画通知的形式。在不需要显示动画的地方,可以单独更新逻辑,比如服务器,或客户端不可见的模型。
2.也有特定的蒙太奇通知,支持立即触发,更精确的控制动画。
1)这种情况,如果按Unity的处理,就是增加个子状态机。
2)而用蒙太奇的机制,就可以当做一个普通动作,简化了状态机,因为动作没挂在状态机上,加载时的内存也减少了。加载时也比加载多个动作方便。
解决什么问题
提供蒙太奇的功能,是为了简化使用,即使没有蒙太奇,动画功能也完全能实现,只是麻烦很多。可以想象UE也是不断遇到类似的需求,然后才抽出这样一个模块,和我们平时重构系统,提出公共模块一样的道理。
按我的理解,蒙太奇的核心想法,是将动画系统分为纯表现和表现+逻辑两部分,对应两种播放方式。每部分职责更明确,简化游戏逻辑。
另一方面,可以简化状态机,状态机上的动画是预先放好的,加载时要占内存。而有些动画,不经常播,动态加载,对内存比较好,也降低了状态机的复杂度。这时就可以通过slot+蒙太奇动画,在状态机上预留一个位置,运行时替换,这样新的动画,就可以结合状态机原本的状态,实现IK等效果。蒙太奇本身扩展了动画的逻辑,相当于也实现了一部分状态机的功能。
实现方式
逻辑上可以分三部分:
逻辑线,play状态下每次更新计算一个进度。
采样接口,通过蓝图节点FAnimNode_Slot使用。
蒙太奇的播放,其实很简单,蒙太奇本身逻辑只计算一个进度,然后slot节点通过proxy找到蒙太奇对应的动画资源,调用资源的采样方法,给节点提供骨骼数据。
游戏逻辑交互,包括播放接口,开始和结束的回调,以及特殊的蒙太奇通知。
八、URO(UpdateRateOptimization)
降低更新频率,是一种很常见的优化思路,实现逻辑并不是简单的隔几帧更新一次,而是在中间插入了一些插值的帧,一定程度上避免动作显示跳帧的问题。这种优化方式,会影响动画效果,但性能提升也很明显,UE还提供了一个预算分配器插件,可以更精细的控制频率。
实现上分三个步骤,首先计算更新频率,判断当前帧是否需要更新。然后将更新分为两步,update和evaluate,其中evaluate频率一定是update的整数倍,因为evaluate执行时需要update计算的数据,要确保update先执行过。
步骤一:计算更新频率
计算入口在TickUpdateRate,最终调用AnimUpdateRateSetParams函数计算。
步骤二:update动画
逻辑很简单,基于上一步计算的数据,如果不更新,整个蓝图节点都不会执行。
判断的地方有两个,一个是TickPose函数。一个是DispatchParallelTickPose,用在AlwaysTickPose模式下。
步骤三:骨骼计算
九、动画系统总结
以上这些,就是动画最核心的实现了,是个层层封装的结构,节点实现动画管线,蓝图管理节点,组件驱动动画系统运行。流程上一些细节,看看代码都好理解。
对比Unity,UE可能对和游戏逻辑的交互支持的更好一些,本身提供的功能也要更多一些,比如Unity一般IK都要通过插件去做,而UE基本实现了常见的IK算法。Unity的状态机也比较简单,但是这种简单带来了使用上的复杂,一般游戏如果动作多的话,状态机连的十分复杂,没有像UE这样更清晰的分层。
蒙太奇和URO,不是动画系统的必要逻辑,但十分实用,这也能看出UE是在开发游戏的,知道开发的痛点在哪,并能给出很好的方案。
第一次看动画系统的实现,收获很多,但也可能有些地方理解的不对,欢迎大家留言讨论,一起探索UE的各个功能。