【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!
一、想写的内容有哪些
动画系统是引擎核心功能之一,之前用Unity开发,只是用用编辑器,一直没太深入去看原理。最近看了看UE的功能和源码,收获很多,对动画是怎么跑起来的,有了更深的理解,同时看的时候也遇到很多问题。
关于动画的实现,资料并不多,很多只是大致说一下流程,有些又说的太高级,省略了很多细节,只提出一个方向,所以又到处查源码,总算弄清楚了一些问题。
这篇博客,想分享两方面内容:
一是自己对动画系统的理解,通过遇到的一些问题,结合源码,加上用Unity的经验,有一些解答,不一定对,毕竟没自己实现过,有一定局限性,欢迎大家留言讨论。
二是UE的实现,熟悉底层逻辑,也能更好地使用和扩展。对于实现来说,不止一种方式,UE的代码也经过很多次迭代,更重要的是理解背后的设计思路,这也是看代码的乐趣所在,并不是只看个流程,重要的是这个思考的过程。
动画从最底层来说,是骨骼的旋转、平移、缩放,加上蒙皮,这篇博客更关注整个动画系统的逻辑,这些数学计算,算法比较成熟,对于我来说,就是一些固定公式,相信伟大的数学家们说的都是对的,暂时不会细看这部分。
二、动画系统设计
首先要实现一套动画系统,需要先分解功能,有个基本的思路。对于动画系统来说,简单的可以分为两层来做,一是实现核心功能,二是基于核心功能,针对特定问题,给出支持的方案。
核心功能分解
首先,最核心的,是让模型动起来,基于动画管线实现。这部分在扩展动画的时候几乎不用改,是动画的最底层。
然后,是怎么组织多个动作,动作的选择和控制(计算顺序、骨骼控制等)。控制的复杂度,源于让玩家感觉整体动作是流畅的这样一个需求。
对游戏引擎来说,需要一个中间类,去和游戏逻辑交互,以及驱动动画系统运行。也就是处理输入和输出,加上驱动动画播放。
这样,就实现了一个游戏的基础动画系统。但是,仅仅这些还不够,还需要针对一些问题,做扩展,才是个完整的系统。
解决特定问题
对游戏引擎来说,能播放动画,只是动画系统的核心功能,而不是个完整的系统。因为动画和游戏逻辑是有很多交互的,引擎要针对这些特定问题,或者说是需求,给出解决方案。
这样就大致做了功能模块分解,到了实现代码的部分。那写代码有一些基本原则,在UE的实现上是怎么体现的呢,大概想到以下几点:
代码结构:高内聚,低耦合的原则。体现为分层设计,加上模块化功能。
扩展性:在实际开发中,如果对动画的要求较高,那扩展是必不可少的,UE主要有两种方式
性能:体现在资源和计算量上,动画在这两方面消耗都不低。
想清楚这些问题,看代码的时候更容易理解现有的代码结构。当然还有很多我想不到的问题,UE的代码也是一个一个版本迭代上来的,中间很多设计很难从结果上看出来,尽量理解就好。
三、动画系统实现结构
功能模块结构图
UE提供的功能分四部分,但实际项目,数据层一般是自己写C++代码,性能好些,所以重点是前面的三个部分。
从使用上,可以分为这几个模块,但是代码实现上,实际比这更复杂。下面主要关注代码的实现。
核心层
目的是实现动画管线,动画管线本身是个抽象的概念。UE通过节点,根据数据和骨骼操作,调用核心层提供的接口,实现管线。
混合和后处理的区别,混合的目的是处理动画间的过渡,算法相对固定。后处理是对动作做调整,为了和场景更好的匹配,一般是IK。
控制层:分编辑和逻辑两部分
编辑:通过节点,提供数据和骨骼操作,给核心层,驱动动画管线运行。
逻辑:用于执行节点。
游戏逻辑交互层
以上模块,只能实现一个静态的动画,而不是一个可交互的系统,这一层是接收玩家的输入,驱动动画系统运行,并将结果展示给玩家。
对内,驱动系统运行,调用UAnimInstance,处理逻辑相关数据、URO等。
对外,触发动画相关事件,提供各种获取数据的接口。
控制层和交互层,实现了游戏逻辑和显示效果解耦,游戏逻辑只关心发生了什么,提供数据,具体表现效果由动画系统决定。
动画执行流程
画了一个大致的流程,算是动画执行的主线流程吧,一些细节和分支没画,避免结构太复杂。对照着代码实现看,会方便一些。
四、动画管线实现
什么是动画管线
动画管线指一系列运算,把输入(动画资源、混合设置),变换成输出(局部及全局姿势、渲染用的矩阵调色板)。
定义有些抽象,简单理解,就是把生成一个动作的处理,分三个逻辑阶段,输入一些数据,得到一个姿势。
逻辑上分三部分,采样、融合、后处理。大致流程是,采样需要的多个动画,加上各种参数、条件做融合,之后后处理,输出的一个姿势。
UE实现机制
不同于渲染管线,动画管线是个抽象概念,UE里通过节点实现。对应管线的三部分逻辑,UE实现上也是分别实现这三个逻辑。
采样
分两部分,一是采样数据,由资源提供接口。二是外部驱动流程。
采样接口
流程
融合
融合的效果是将多个动画,按一定算法,生成一个动画。
基础是变换运算
姿势混合有3种方式
融合一个作用是动画过渡
融合触发方式
核心算法实现,在FAnimationRuntime类,节点和资源会调用这个类的方法。
后处理
作用是对动画姿势做校正,主要是各部位的IK,因为做动画的时候,生成的是和环境无关的姿势,而实际运行中,动作要和周围的环境有一定的匹配,这样才显得更真实。
具体算法由FAnimNode_SkeletalControlBase子类实现,UE实现了多种IK算法,之后在细看看每种算法的实现逻辑。
五、节点机制
节点理解
节点可以说是UE实现灵活编辑动画流程的基础,在蓝图上自由关联节点、关联蓝图,离不开节点的支持。
用树的方式来理解的话,OutPut Pose是根节点,那些动画资源播放节点是叶子节点,姿势混合节点是中间节点。然后通过控制节点关联到一起。
节点机制用到了策略模式和组合模式。策略模式,体现为节点可以互相替换,这样也支持了扩展。组合模式体现为节点可以通过PoseLink互相连接,也就实现了自由编辑流程的效果,PoseLink这名字,也说明了节点的最终功能是计算pose。
UE通过节点,将对动作的操作,抽象为对输入输出的数据的操作,这样不管加了什么逻辑,只要输入的数据和输出的数据结构相同,就可以互相连接,也是基于这样的原理,支持的自定义扩展。
节点分类
节点主要有三个功能:
节点基类:FAnimNode_Base,不存储数据,提供虚函数,在指定的时间点被蓝图调用,子类实现具体逻辑。核心函数,包括Initialize_AnyThread、CacheBones_AnyThread、Update_AnyThread、Evaluate_AnyThread。
根节点:FAnimNode_Root,对应蓝图中最后用于输出的OutPut节点。赋值到FAnimInstanceProxy,作为蓝图运行的节点的起点。
要处理特殊生命周期的节点,保存在UAnimBlueprintGeneratedClass。存下来是为了在调用函数的时候更快,而不用真的遍历所有节点,因为只有少数几个几点,需要在这几个时间点处理逻辑。
TArray PreUpdateNodeProperties;
TArray DynamicResetNodeProperties;
TArray StateMachineNodeProperties;
TArray InitializationNodeProperties;
实现特定功能
控制流程
节点的执行
节点的同步
这里的同步,并不是多线程之间的同步。而是用于确保一些节点逻辑只执行一次。
同步的基础是FGraphTraversalCounter结构体,主要是记录执行次数和执行时的帧数。
实现方法
FGraphTraversalCounter InitializationCounter;
FGraphTraversalCounter CachedBonesCounter;
FGraphTraversalCounter UpdateCounter;
FGraphTraversalCounter EvaluationCounter;
FGraphTraversalCounter SlotNodeInitializationCounter;
同步实现逻辑不复杂,就是刚看名字的时候容易想歪,既不是多线程同步,和URO也没关系,看代码的时候在这迷惑了半天。
六、蓝图实现
蓝图逻辑看起来很复杂,实际核心功能就是驱动节点运行,加上处理一些可以在主线程处理的逻辑,以及保存数据。流程理清楚就可以了。
实现上分两个类,AnimInstance和AnimInstanceProxy,目的是让动画系统高效运行,将逻辑数据和表现数据分别计算,逻辑数据在主线程,表现数据分到其他线程。
UAnimInstance
蓝图的父类。对内封装动画流程,对外和组件交互。可以继承,实现自定义逻辑。
几个主要功能,体现为一些被组件调用的函数。
更新相关接口
核心逻辑有两个,一个是inst实现的,更新动画相关逻辑数据。一个是proxy实现的更新动画相关的显示数据。
UpdateAnimation处理逻辑数据
ParallelEvaluateAnimation处理显示数据
多线程下被工作线程调用,可设置为主线程。根据UpdateAnimation计算后产生的控制变量,通过节点计算修改骨骼。
用FParallelEvaluationData保存计算后的骨骼、曲线和属性数据。
EvaluateAnimation函数,调用保存的根节点,开始执行各个节点。
SkeletalMeshComponent逻辑
用于处理动画系统和游戏逻辑的交互,对外,处理游戏相关逻辑。对内,封装动画系统,通过inst驱动系统运行。
处理游戏相关逻辑,一个是对玩家操作动画的影响,一个是从动画取数据,反馈给游戏逻辑。
驱动动画系统更新。分三步,更新逻辑(和游戏逻辑相关),异步计算骨骼位置,提交渲染。
这部分代码不少,一些判断条件较多,执行流程可以结合上边发的图来看,具体逻辑就不写了,打个断点看一下,基本了解流程也就可以了,核心的逻辑还是依靠AnimInstance和AnimInstanceProxy实现。
七、蒙太奇
实现的功能
表现上,蒙太奇是种动画资源,但是实际上,只是引用了资源,本身可以看做一条逻辑线,用于连接动画和游戏逻辑。
解决什么问题
提供蒙太奇的功能,是为了简化使用,即使没有蒙太奇,动画功能也完全能实现,只是麻烦很多。可以想象UE也是不断遇到类似的需求,然后才抽出这样一个模块,和我们平时重构系统,提出公共模块一样的道理。
按我的理解,蒙太奇的核心想法,是将动画系统分为纯表现和表现+逻辑两部分,对应两种播放方式。每部分职责更明确,简化游戏逻辑。
另一方面,可以简化状态机,状态机上的动画是预先放好的,加载时要占内存。而有些动画,不经常播,动态加载,对内存比较好,也降低了状态机的复杂度。这时就可以通过slot+蒙太奇动画,在状态机上预留一个位置,运行时替换,这样新的动画,就可以结合状态机原本的状态,实现IK等效果。蒙太奇本身扩展了动画的逻辑,相当于也实现了一部分状态机的功能。
实现方式
逻辑上可以分三部分:
逻辑线,play状态下每次更新计算一个进度。
采样接口,通过蓝图节点FAnimNode_Slot使用。
蒙太奇的播放,其实很简单,蒙太奇本身逻辑只计算一个进度,然后slot节点通过proxy找到蒙太奇对应的动画资源,调用资源的采样方法,给节点提供骨骼数据。
游戏逻辑交互,包括播放接口,开始和结束的回调,以及特殊的蒙太奇通知。
八、URO(Update Rate Optimization)
降低更新频率,是一种很常见的优化思路,实现逻辑并不是简单的隔几帧更新一次,而是在中间插入了一些插值的帧,一定程度上避免动作显示跳帧的问题。这种优化方式,会影响动画效果,但性能提升也很明显,UE还提供了一个预算分配器插件,可以更精细的控制频率。
频率的选择,会根据距离计算一个LOD等级,从这个角度来说,远处的模型动画更新频率低点影响也不大,本身就不会特别关注远处的目标,一些特殊情况下,比如当前场景角色很少,或是远处是个大型boss,可以单独处理,优化总是要和实际情况结合才能有更好的效果。
实现上分三个步骤,首先计算更新频率,判断当前帧是否需要更新。然后将更新分为两步,update和evaluate,其中evaluate频率一定是update的整数倍,因为evaluate执行时需要update计算的数据,要确保update先执行过。
步骤一:计算更新频率
更新频率相关参数,封装了结构体FAnimUpdateRateParameters,分为两个模式Trail和LookAhead,LookAhead用于处理Root Motion。分别记录update和evaluate在当前帧是否需要更新,以及跳过了多少帧等数据。
FAnimUpdateRateManager命名空间用于封装一些方法,计算更新频率相关数据。
计算入口在TickUpdateRate,最终调用AnimUpdateRateSetParams函数计算。
步骤二:update动画
逻辑很简单,基于上一步计算的数据,如果不更新,整个蓝图节点都不会执行。
判断的地方有两个,一个是TickPose函数。一个是DispatchParallelTickPose,用在AlwaysTickPose模式下。
步骤三:骨骼计算
九、动画系统总结
以上这些,就是动画最核心的实现了,是个层层封装的结构,节点实现动画管线,蓝图管理节点,组件驱动动画系统运行。流程上一些细节,看看代码都好理解。
整个动画系统,还包括IK、表情、Motion Matching等应用,以后看到了再来分享。
对比Unity,UE可能对和游戏逻辑的交互支持的更好一些,本身提供的功能也要更多一些,比如Unity一般IK都要通过插件去做,而UE基本实现了常见的IK算法。Unity的状态机也比较简单,但是这种简单带来了使用上的复杂,一般游戏如果动作多的话,状态机连的十分复杂,没有像UE这样更清晰的分层。
蒙太奇和URO,不是动画系统的必要逻辑,但十分实用,这也能看出UE是在开发游戏的,知道开发的痛点在哪,并能给出很好的方案。
第一次看动画系统的实现,收获很多,但也可能有些地方理解的不对,欢迎大家留言讨论,一起探索UE的各个功能。
这是侑虎科技第1532篇文章,感谢作者星辰大海供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。
作者主页:星辰大海 - 知乎
再次感谢星辰大海的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。