动画逻辑深入分析

前言

之前虽然写过一些动画节点和AnimInstance相关的逻辑, 但是对整体动画模块的逻辑路线还是不是很熟, 所以本文大致研究一下

动画逻辑2

关系梳理

开始研究之前需要先梳理一下动画逻辑中的各个类的关系

首先是SkeletalMeshComponent, 这东西继承自USkinnedMeshComponent, 就是我们一般播放动画依赖的模型组件,

SkeletalMeshComponent一般会有一个AnimInstance对象, 这个就是动画蓝图的Cpp类, 不记得从什么版本开始, AnimInstance的逻辑慢慢的开始迁往AnimInstanceProxy, 目的是动画系统的多线程优化, 他可以分担动画蓝图的更新工作,将部分动画蓝图的任务分配到其他线程去做。

一般而言,不能从动画图形节点(Update/Evaluate calls)访问或修改UAnimInstance,因为它们可以在其他线程上运行。 有一些锁定封装器(GetProxyOnAnyThread 和GetProxyOnGameThread )可以在任务运行期间阻止访问FAnimInstanceProxy。

初始化

动画蓝图的初始化要从SkeletalMeshComponent的注册开始, 会调用到InitAnim()函数

InitAnim中做了下面几件事

  1. ClearAnimScriptInstance(); 清理之前的AnimInstance对象

  2. RecalcRequiredBones(); 根据Lod计算特定的骨骼信息, 数据来自FSkeletalMeshRenderData, 然后如果有物理资产PhysicsAsset会刷新PhysAssetBones

  3. InitializeAnimScriptInstance(); 这里主要是创建指定的动画蓝图实例对象, 也就是我们GetAnimInstance()返回的对象, 这里需要注意一点, 动画蓝图的骨架与MeshComponent的骨架需要匹配,否则这个对象就是空. 然后执行AnimInstance对象的InitializeAnimation(), 这里也会重置链接的子动画蓝图.另外还有要分几个情况,

    如果是选用了播放单个动画的AnimMode, 那么就会创建一个UAnimSingleNodeInstance来播放动画序列

    另外, 如果指定了PostAnimInstance类型, 视情况也会创建

  4. 如果符合条件, 会先执行一次TickAnimation()RefreshBoneTransforms()

TickAnimation()RefreshBoneTransforms() 是本文重点, 下面会讲到

InitializeAnimation

这里按顺序主要调用了

1
2
3
4
5
6
RecalcRequiredBones(); //重新刷新骨架信息
GetProxyOnGameThread<FAnimInstanceProxy>().Initialize(this); //调用Proxy的初始化函数
NativeInitializeAnimation();
BlueprintInitializeAnimation();//蓝图的初始化Impl函数
GetProxyOnGameThread<FAnimInstanceProxy>().InitializeRootNode(bInDeferRootNodeInitialization);//初始化Node
BlueprintLinkedAnimationLayersInitialized(); //动画层的初始化

动画更新

首先我们需要知道, 动画更新默认是多线程的, 在项目设置中有相关设置

image-20220111101708427

同时在动画蓝图中也有选项

image-20220111101850615

以下讨论都基于多线程动画更新

动画更新要从SkeletalMeshComponent::TickComponent()开始, SkeletalMeshComponent相对于其父类USkinnedMeshComponent, 在Tick中额外多了一些布料和物理相关的一部分逻辑

接下来讨论Tick流程, 因为类比较多, 下面会区分每个类进行讨论

Tick中主要执行了 TickPose () RefreshBoneTransforms()

TickPose()有一些前置判定, 首先模型的刷新模式VisibilityBasedAnimTickOption必须是AlwaysTickPoseAndRefreshBones或者AlwaysTickPose

然后如果有帧率优化那就需要符合对应的条件(如果是3帧跑一次,那么就是其中一帧会执行)

然后判断是否刷新Transform信息

image-20220110111219588

image-20220110111241906

我们可以看到, 如果正在渲染或者设定了AlwaysTickPoseAndRefreshBones 就会通过判定

一般情况下(用到MasterPoseComponent的情况除外), 我们就执行到了RefreshBoneTransforms()

如果上面没通过判定, 如果刷新模式设置了AlwaysTickPose, 那么就会执行并行任务DispatchParallelTickPose()

最后一种情况就是只执行RefreshMorphTargets()

SkeletalMeshComponent/SkinnedMeshComponent

TickPose

TickPose()主要是调用TickAnimation()来执行RecalcRequiredCurves()刷新曲线, 调用TickAnimInstances()来执行AnimInstanceUpdateAnimation()

TickPose的主要作用就是刷新动画蓝图和相关Node的数据, 为后面的骨骼更新做准备

RefreshBoneTransforms

RefreshBoneTransforms也接受动画帧优化RateOptimization

如果在之前的步骤里没有正确计算骨架和曲线信息, 这里也会补充计算一次

接下来的主要任务就是 填充 AnimEvaluationContext 数据

然后执行动画蓝图对象AnimInstance以及子动画蓝图的PreEvaluateAnimation()

然后开始并行任务ParallelAnimationEvaluation()

EvaluationContext在组件上是有非常多的缓存,存储着主要的数据。我们把AnimEvaluationContext上的缓存进行交换。根据bDoParallelEvaluation的结果(经过一系列判定)决定是否分发并行任务, 如果为true就执行DispatchParallelEvaluationTasks()

image-20220111103106848

在这个函数里面调用SwapEvaluationContextBuffers()将我们在上面操作的AnimEvaluationContext与本地缓存进行交换,然后创建FParallelAnimationEvaluationTask执行任务和FParallelAnimationCompletionTask完成任务。

如果为false就主动在game线程执行下图函数, 这里先调用SwapEvaluationContextBuffers()交换,然后调用ParallelAnimationEvaluation(),最后再调用SwapEvaluationContextBuffers()交换回来。最后回到上层调用PostAnimEvaluation()

image-20220110113011014

并行任务会一路执行经过PerformAnimationProcessing(), 然后到下图

image-20220110142956958

这里先执行动画蓝图实例的ParallelUpdateAnimation(),即并行Update 任务

然后再通过EvaluateAnimation执行动画蓝图实例的ParallelEvaluateAnimation()即并行Evalute任务,

RefreshBoneTransforms的作用就是刷新骨骼数据, 然后提供给骨骼模型做最终的渲染

AnimInstance

UpdateAnimation

这里会再次用到VisibilityBasedAnimTickOption, 如果是OnlyTickMontagesWhenNotRendered 同时模型正在被渲染, 那么只跑蒙太奇相关的逻辑, 否则继续往下看

然后开始PreUpdateAnimation(), 再是蒙太奇相关的

1
2
3
UpdateMontage(DeltaSeconds);
UpdateMontageSyncGroup();
UpdateMontageEvaluationData();

再是执行到几个虚函数

1
2
3
NativeUpdateAnimation(DeltaSeconds);
NativeUpdateAnimation_WorkerThread(DeltaSeconds);
BlueprintUpdateAnimation(DeltaSeconds);

我们蓝图动画蓝图的Update函数就是上面的BlueprintUpdateAnimation

到最后执行了最重要的函数 ParallelUpdateAnimation() 以及PostUpdateAnimation()

PreUpdateAnimation

image-20220110151205848

更新动画通知数据以及调用Proxy的PreUpdate

UpdateMontage

蒙太奇的数据主要是存在于动画实例中MontageInstances。首先更新他的权重,并且计算其对应的骨骼和curve的比重。

image-20220110105559089

蒙太奇的Instance对象的操作很多是在这里执行的, 包括RootMotionParams(包含RootMotion位移信息)的赋值等等, 以及Blend,Stop, Section跳转等等

UpdateMontageSyncGroup

同步组 使相关的动画相互保持同步

UpdateMontageEvaluationData

刷新蒙太奇动画的Eval数据, 包含了当前位置, 混合数据等等

PostUpdateAnimation

主要调用子动画蓝图的PostUpdateAnimation, 对Proxy对象执行PostUpdate, 最后是对ExtractedRootMotion数据进行一些写入操作


ParallelUpdateAnimation

image-20220110143145829

调用了Proxy的UpdateAnimation

PreEvaluateAnimation

调用Proxy的PreEvaluateAnimation()

ParallelEvaluateAnimation

image-20220110114554230

Instance做的不多,就是直接获取当前游戏线程的Proxy对象, 执行了他的EvaluateAnimation

AnimInstanceProxy

PreUpdate

这里主要做的是在更新前的准备工作,诸如时间的计算,lod的切换,整体的Transform 的移动,通知事件的重置,所有的权重重置等等

当然, AnimInstanceProxy内保存了相关的AnimNode的信息, 这里也会对Node进行PreUpdate操作

image-20220110104656477

UpdateAnimation

image-20220110143158226

这里就是调用AnimNode的Update_AnyThread的入口

EvaluateAnimation

image-20220110150653179

这里就是大名鼎鼎的EvaluateAnimation_WithRoot()的入口

AnimNode

每个AnimGraphNode实际上都是一个FAnimNode_Base

FAnimNode_Base的刷新主要来自AnimInstanceProxy的XX_WithRoot函数, 是从Root开始递归的倒退这找到最末端的Node, 然后把Pose数据一路传递下来的

这里主要看一下FAnimNode_Base的几个多线程虚函数

1
2
3
4
5
virtual void Initialize_AnyThread(const FAnimationInitializeContext& Context);
virtual void CacheBones_AnyThread(const FAnimationCacheBonesContext& Context);
virtual void Update_AnyThread(const FAnimationUpdateContext& Context);
virtual void Evaluate_AnyThread(FPoseContext& Output);
virtual void EvaluateComponentSpace_AnyThread(FComponentSpacePoseContext& Output);

Initialize_AnyThread

会在很多地方调用到, 比如编译以后也会调用一次, 在USkeletalMeshComponent::OnRegister时也会通过Proxy调用InitializeRootNode_WithRoot()一路初始化, 还有状态机SetState()时一路通过LinkedNode找到每个节点进行初始化等等

总而言之, 一般在运行时会调用一次, 如果在状态机内会调用多次

CacheBones_AnyThread

在骨骼信息发生变换的时候引用到, 比如LOD变化时就会调用到. 主要用于刷新该节点所引用的骨骼索引。

Update_AnyThread

这个调用就比较频繁了, 在TickPose和RefreshBoneTransform的过程中都可能被调用

这个函数通常用来对刷新骨骼位置所需要的变量进行计算

Evaluate_AnyThread

用来计算并刷新Local空间的骨骼数据的函数, 一般我们自定义动画节点通常会重写这个函数大作文章

EvaluateComponentSpace_AnyThread

同上, 但是是在组件空间的, 两者只选其一即可

总结

流程分三部分, 本文主要研究了前两部分

  1. 初始化: 在Game线程中执行。在游戏启动时,需要先注册USkeletalMeshComponent组件,然后调用执行注册事件。注册事件做了以下内容:初始化Anim、更新Animation、刷新骨骼变换、创建渲染状态并分配MeshObject、创建物理状态

  2. 动画更新:

    1. 刷新动画蓝图和相关Node的数据, 为后面的骨骼更新做准备.判断是否需要执行蒙太奇更新优化;进行预更新——重置动画通知队列和RootMotionBlendQueue,赋值;蒙太奇更新、蒙太奇同步组更新、蓝图更新。
    2. 分发并行任务. 先更Proxy和其他骨骼更新所需的数据,后刷新骨骼数据, 再把骨骼数据提供给后面的渲染线程。
  3. 渲染:游戏线程创建渲染命令,渲染线程执行渲染命令,最后更新骨骼。