动画逻辑深入分析
前言
之前虽然写过一些动画节点和AnimInstance相关的逻辑, 但是对整体动画模块的逻辑路线还是不是很熟, 所以本文大致研究一下
关系梳理
开始研究之前需要先梳理一下动画逻辑中的各个类的关系
首先是SkeletalMeshComponent, 这东西继承自USkinnedMeshComponent, 就是我们一般播放动画依赖的模型组件,
SkeletalMeshComponent一般会有一个AnimInstance对象, 这个就是动画蓝图的Cpp类, 不记得从什么版本开始, AnimInstance的逻辑慢慢的开始迁往AnimInstanceProxy, 目的是动画系统的多线程优化, 他可以分担动画蓝图的更新工作,将部分动画蓝图的任务分配到其他线程去做。
一般而言,不能从动画图形节点(Update/Evaluate calls)访问或修改UAnimInstance
,因为它们可以在其他线程上运行。 有一些锁定封装器(GetProxyOnAnyThread 和GetProxyOnGameThread )可以在任务运行期间阻止访问FAnimInstanceProxy。
初始化
动画蓝图的初始化要从SkeletalMeshComponent的注册开始, 会调用到InitAnim()
函数
InitAnim中做了下面几件事
ClearAnimScriptInstance(); 清理之前的AnimInstance对象
RecalcRequiredBones(); 根据Lod计算特定的骨骼信息, 数据来自
FSkeletalMeshRenderData
, 然后如果有物理资产PhysicsAsset会刷新PhysAssetBones
InitializeAnimScriptInstance(); 这里主要是创建指定的动画蓝图实例对象, 也就是我们
GetAnimInstance()
返回的对象, 这里需要注意一点, 动画蓝图的骨架与MeshComponent的骨架需要匹配,否则这个对象就是空. 然后执行AnimInstance
对象的InitializeAnimation()
, 这里也会重置链接的子动画蓝图.另外还有要分几个情况,如果是选用了播放单个动画的AnimMode, 那么就会创建一个
UAnimSingleNodeInstance
来播放动画序列另外, 如果指定了PostAnimInstance类型, 视情况也会创建
如果符合条件, 会先执行一次
TickAnimation()
和RefreshBoneTransforms()
TickAnimation()
和RefreshBoneTransforms()
是本文重点, 下面会讲到
InitializeAnimation
这里按顺序主要调用了
1 | RecalcRequiredBones(); //重新刷新骨架信息 |
动画更新
首先我们需要知道, 动画更新默认是多线程的, 在项目设置中有相关设置
同时在动画蓝图中也有选项
以下讨论都基于多线程动画更新
动画更新要从SkeletalMeshComponent::TickComponent()
开始, SkeletalMeshComponent相对于其父类USkinnedMeshComponent, 在Tick中额外多了一些布料和物理相关的一部分逻辑
接下来讨论Tick流程, 因为类比较多, 下面会区分每个类进行讨论
Tick中主要执行了 TickPose ()
和 RefreshBoneTransforms()
TickPose()
有一些前置判定, 首先模型的刷新模式VisibilityBasedAnimTickOption
必须是AlwaysTickPoseAndRefreshBones或者AlwaysTickPose
然后如果有帧率优化那就需要符合对应的条件(如果是3帧跑一次,那么就是其中一帧会执行)
然后判断是否刷新Transform信息
我们可以看到, 如果正在渲染或者设定了AlwaysTickPoseAndRefreshBones 就会通过判定
一般情况下(用到MasterPoseComponent的情况除外), 我们就执行到了RefreshBoneTransforms()
如果上面没通过判定, 如果刷新模式设置了AlwaysTickPose
, 那么就会执行并行任务DispatchParallelTickPose()
最后一种情况就是只执行RefreshMorphTargets()
SkeletalMeshComponent/SkinnedMeshComponent
TickPose
TickPose()
主要是调用TickAnimation()
来执行RecalcRequiredCurves()
刷新曲线, 调用TickAnimInstances()
来执行AnimInstance
的UpdateAnimation()
TickPose的主要作用就是刷新动画蓝图和相关Node的数据, 为后面的骨骼更新做准备
RefreshBoneTransforms
RefreshBoneTransforms也接受动画帧优化RateOptimization
如果在之前的步骤里没有正确计算骨架和曲线信息, 这里也会补充计算一次
接下来的主要任务就是 填充 AnimEvaluationContext
数据
然后执行动画蓝图对象AnimInstance以及子动画蓝图的PreEvaluateAnimation()
然后开始并行任务ParallelAnimationEvaluation()
EvaluationContext
在组件上是有非常多的缓存,存储着主要的数据。我们把AnimEvaluationContext上的缓存进行交换。根据bDoParallelEvaluation
的结果(经过一系列判定)决定是否分发并行任务, 如果为true就执行DispatchParallelEvaluationTasks()
在这个函数里面调用SwapEvaluationContextBuffers()
将我们在上面操作的AnimEvaluationContext
与本地缓存进行交换,然后创建FParallelAnimationEvaluationTask
执行任务和FParallelAnimationCompletionTask
完成任务。
如果为false就主动在game线程执行下图函数, 这里先调用SwapEvaluationContextBuffers()
交换,然后调用ParallelAnimationEvaluation()
,最后再调用SwapEvaluationContextBuffers()
交换回来。最后回到上层调用PostAnimEvaluation()
。
并行任务会一路执行经过PerformAnimationProcessing()
, 然后到下图
这里先执行动画蓝图实例的ParallelUpdateAnimation()
,即并行Update 任务
然后再通过EvaluateAnimation
执行动画蓝图实例的ParallelEvaluateAnimation()
即并行Evalute任务,
RefreshBoneTransforms的作用就是刷新骨骼数据, 然后提供给骨骼模型做最终的渲染
AnimInstance
UpdateAnimation
这里会再次用到VisibilityBasedAnimTickOption
, 如果是OnlyTickMontagesWhenNotRendered
同时模型正在被渲染, 那么只跑蒙太奇相关的逻辑, 否则继续往下看
然后开始PreUpdateAnimation()
, 再是蒙太奇相关的
1 | UpdateMontage(DeltaSeconds); |
再是执行到几个虚函数
1 | NativeUpdateAnimation(DeltaSeconds); |
我们蓝图动画蓝图的Update函数就是上面的
BlueprintUpdateAnimation
到最后执行了最重要的函数 ParallelUpdateAnimation()
以及PostUpdateAnimation()
PreUpdateAnimation
更新动画通知数据以及调用Proxy的PreUpdate
UpdateMontage
蒙太奇的数据主要是存在于动画实例中MontageInstances。首先更新他的权重,并且计算其对应的骨骼和curve的比重。
蒙太奇的Instance对象的操作很多是在这里执行的, 包括RootMotionParams(包含RootMotion位移信息)的赋值等等, 以及Blend,Stop, Section跳转等等
UpdateMontageSyncGroup
同步组 使相关的动画相互保持同步
UpdateMontageEvaluationData
刷新蒙太奇动画的Eval数据, 包含了当前位置, 混合数据等等
PostUpdateAnimation
主要调用子动画蓝图的PostUpdateAnimation
, 对Proxy对象执行PostUpdate
, 最后是对ExtractedRootMotion
数据进行一些写入操作
ParallelUpdateAnimation
调用了Proxy的UpdateAnimation
PreEvaluateAnimation
调用Proxy的PreEvaluateAnimation()
ParallelEvaluateAnimation
Instance做的不多,就是直接获取当前游戏线程的Proxy对象, 执行了他的EvaluateAnimation
AnimInstanceProxy
PreUpdate
这里主要做的是在更新前的准备工作,诸如时间的计算,lod的切换,整体的Transform 的移动,通知事件的重置,所有的权重重置等等
当然, AnimInstanceProxy内保存了相关的AnimNode的信息, 这里也会对Node进行PreUpdate操作
UpdateAnimation
这里就是调用AnimNode的Update_AnyThread
的入口
EvaluateAnimation
这里就是大名鼎鼎的EvaluateAnimation_WithRoot()
的入口
AnimNode
每个AnimGraphNode实际上都是一个FAnimNode_Base
FAnimNode_Base的刷新主要来自AnimInstanceProxy的XX_WithRoot函数, 是从Root开始递归的倒退这找到最末端的Node, 然后把Pose数据一路传递下来的
这里主要看一下FAnimNode_Base的几个多线程虚函数
1 | virtual void Initialize_AnyThread(const FAnimationInitializeContext& Context); |
Initialize_AnyThread
会在很多地方调用到, 比如编译以后也会调用一次, 在USkeletalMeshComponent::OnRegister时也会通过Proxy调用InitializeRootNode_WithRoot()
一路初始化, 还有状态机SetState()
时一路通过LinkedNode找到每个节点进行初始化等等
总而言之, 一般在运行时会调用一次, 如果在状态机内会调用多次
CacheBones_AnyThread
在骨骼信息发生变换的时候引用到, 比如LOD变化时就会调用到. 主要用于刷新该节点所引用的骨骼索引。
Update_AnyThread
这个调用就比较频繁了, 在TickPose和RefreshBoneTransform的过程中都可能被调用
这个函数通常用来对刷新骨骼位置所需要的变量进行计算
Evaluate_AnyThread
用来计算并刷新Local空间的骨骼数据的函数, 一般我们自定义动画节点通常会重写这个函数大作文章
EvaluateComponentSpace_AnyThread
同上, 但是是在组件空间的, 两者只选其一即可
总结
流程分三部分, 本文主要研究了前两部分
初始化: 在Game线程中执行。在游戏启动时,需要先注册USkeletalMeshComponent组件,然后调用执行注册事件。注册事件做了以下内容:初始化Anim、更新Animation、刷新骨骼变换、创建渲染状态并分配MeshObject、创建物理状态。
动画更新:
- 刷新动画蓝图和相关Node的数据, 为后面的骨骼更新做准备.判断是否需要执行蒙太奇更新优化;进行预更新——重置动画通知队列和RootMotionBlendQueue,赋值;蒙太奇更新、蒙太奇同步组更新、蓝图更新。
- 分发并行任务. 先更Proxy和其他骨骼更新所需的数据,后刷新骨骼数据, 再把骨骼数据提供给后面的渲染线程。
渲染:游戏线程创建渲染命令,渲染线程执行渲染命令,最后更新骨骼。