以下是学习UE动画蓝图和其实现的一些技术细节。
学习参考
动画节点的组织
动画蓝图中的主要数据结构:
动画蓝图(AnimInstanceProxy
)中的所有节点以树状结构组织,它的第一个节点一定是FAnimNode_Root
,初始化时将它赋值给RootNode
。
RootNode
虽然存放在数组中第一个,但却是动画蓝图的输出节点(Output Pose
),其他节点都以输出逆序的方式通过FPoseLink
(在蓝图编辑器中表现为有Pose
连线)链接成一颗树,执行时采用前序递归遍历。
所有节点大致分为:
- 资源播放器(继承自
FAnimNode_AssetPlayerBase
),直接输出动画资源。 - 混合类节点(
FAnimNode_.*Blend.*
)负责确认具体的混合方式与动画结果。 - 动画状态和状态机。
- 其他功能节点。
对于所有节点来说,具体依赖的参数的计算在函数Update_AnyThread
中确定(Time
, Weight
),依赖的DeltaTime
和动画实例等参数在FAnimationUpdateContext
中传递。然后在函数Evaluate_AnyThread
中求解,结果保存在FPoseContext
中,语义是到当前这个节点时,应该输出怎样的动画姿势、曲线、属性等。
总的来说,UE的动画系统是把所有操作都抽象成对Pose
的处理,每个操作都是确定如何输出当前角色的Pose
。如果要跟Unity对比的话,那么只有后来的Playable
和Blend Tree
才是等价的,而不是动画状态机。
为什么要把参数计算和求解分成两步操作呢?
理论上计算出当前参数以后就可以直接求解Pose
,但是在设计中,Update
是个相对轻量的计算,而Evaluate
是消耗非常大的过程。当把逻辑拆成两个独立的步骤后,更高层的逻辑可以灵活的调配具体的执行策略。
比如AnimInstance
可以选择跳过权重为0的动画节点Evaluate
过程来节省CPU资源。各个动画节点的实现可以专注于自身逻辑,无需了解上层模块的执行策略。
动画状态机和状态的实现
状态机
由上图可见,状态机与其他节点一样也是继承自FAnimNode_Base
,所以UE动画状态机天然可以树状分层组合起来,多个状态机可以通过各种混合节点来输出结果。动画状态机会在Update
阶段遍历合适的转移条件,当触发转移条件时,状态机立刻会切换到下一个状态,哪怕两个状态存在融合过程。在Evaluate
阶段,所有激活的状态都会参与求解与结果融合,具体的融合方式可以在条件跳转中定义,融合权重不仅受融合方式影响,也受当期所有激活状态数量影响。每个状态机都可以单独定义每Tick
最多处理几次状态跳转。
状态
状态机里的状态没有特殊定义,而是通过FPoseLink
数组来记录所有状态的根节点(FAnimNode_Root
),然后像动画蓝图一样树形链接起状态里的其他节点。理论上状态不能输入参数,只能输出Pose
,在具体状态实现中,状态除了能直接引用资源播放器(FAnimNode_AssetPlayerBase
)以外,还可以通过FAnimNode_UseCachedPose
引用FAnimNode_SaveCachedPose
中“保存”的Pose
。这个“保存”是语义上的,实现上它只是一个命名的链接,编译以后可以直接串起俩边的节点。
导管
导管(Conduit
)是一个特化的状态,它只参与条件跳转,不能参与求解Evaluate
过程。导管可以有效的简化状态之间的星型连接。
条件
条件用以标记状态之间的跳转逻辑和指定状态间的融合规则。每一个状态在更新阶段会遍历自身所有跳转到其他状态的条件,每个条件的跳转逻辑是一段蓝图表达式,也可以是本地转移委托NativeTransitionDelegate
。后者在代码中通过AnimInstance::AddNativeTransitionBinding
函数指定具体状态机和状态名字进行绑定。跳转条件一旦被本地委托绑定后,那么蓝图逻辑就会被忽略,但是这里有Bug,可能之前也没什么机会用所以没暴露。
每个刚达成跳转逻辑的条件会被加入已激活的条件数组中,每个激活的条件一旦融合完成,那么就会移出,这个已激活的数组顺序是关键的不能随便变更的。
状态融合
在求解阶段,所有已激活的状态会一并融合。融合公式为 \(State_0 \overset {AlphaT_1} \longrightarrow State_1 \overset {AlphaT_2} \longrightarrow State_2 \dots \overset {AlphaT_n} \longrightarrow State_n \\ \ \\ \begin{align*} Bland\ State_n &= AlphaT_n\\ Bland\ State_{n-1} &= AlphaT_{n-1} * (1 - AlphaT_n) \\ &= AlphaT_{n-1} * Bland\ State_n * (1 / AlphaT_n - 1) \\ Bland\ State_{n-2} &= AlphaT_{n-2} * (1 - AlphaT_{n-1}) * (1 - AlphaT_n)\\ &= AlphaT_{n-2} * Bland\ State_{n-1} * (1 / AlphaT_{n-1} - 1) \\ \dots\\ Bland\ State_0 &= (1 - AplahT_1) * (1 - AlphaT_2) * \dots * (1 - AlphaT_n)\\ &= 1 * Bland\ State_1 * (1 / AlphaT_1 - 1)\\ \end{align*}\)
说人话就是按照状态激活顺序,从前到后,前面的两个状态融合结果当作后续融合的前置结果:各个状态是非线性叠加的,而且最新进入的状态条件就算提前完成融合,那么也不会中断前序状态参与融合。 源码中仅通过一次遍历,就计算出当前状态融合权重:
float TotalWeight = 0;
for(int Index = 0; Index < Transitions; ++Index) {
if(Index > 0){
TotalWeight *= (1 - Transitions[Index].Alpha);
}else if(Transitions[Index].PreviousState == CurState){
TotalWeight += (1 - Transitions[Index].Alpha);
}
if(Transitions[Index].NextState == CurState){
TotalWeight += Transitions[Index].Alpha;
}
}
重点是PreviousState
和NextState
权重是相加,其他中间过程是相乘。
动画蓝图中的性能问题
动画蓝图主要有两个更新逻辑,一个是事件更新,一个是动画实例更新。其中事件更新跟普通蓝图一样,走蓝图虚拟机求解。而动画实例更新则完全按照预先编译好的节点组织成一颗树,通过递归的方式直接调用本地逻辑完成的,因此单纯的动画节点会非常高效。
另一方面,这也要求在制作中尽量避免添加太多蓝图事件,更要避免逻辑非常重的Update
蓝图事件,不然就上UAnimInstance::NativeUpdateAnimation
函数覆写。
当然,对于状态机中条件跳转,还是使用蓝图来求解的。虽然UE提供了条件的本地委托来接管状态之间的转移逻辑,但是仍然不合适:
- 本地代码太不容易读:语法复杂,定义与声明繁琐。手动输入各个名字容易发生错误。
- 破坏数据与功能定义的一致性和局部性。对状态机进行增删查改操作时引入不必要的复杂度和心智负担
- 本地条件转移逻辑有bug。
动画蓝图中的各节点数据结构关系:
Editor: Deserialize: Runtime:
----------------- ---------------- ------------
|AnimGraphNode_*| ==> |BackedMachine*| ==> |AnimNode_*|
----------------- ---------------- ------------