UE动画蓝图和动画节点拆解

以下是学习UE动画蓝图和其实现的一些技术细节。

学习参考

动画节点的组织

动画蓝图中的主要数据结构:

AnimNode

动画蓝图(AnimInstanceProxy)中的所有节点以树状结构组织,它的第一个节点一定是FAnimNode_Root,初始化时将它赋值给RootNodeRootNode虽然存放在数组中第一个,但却是动画蓝图的输出节点(Output Pose),其他节点都以输出逆序的方式通过FPoseLink(在蓝图编辑器中表现为有Pose连线)链接成一颗树,执行时采用前序递归遍历。 所有节点大致分为:

  1. 资源播放器(继承自FAnimNode_AssetPlayerBase),直接输出动画资源。
  2. 混合类节点(FAnimNode_.*Blend.*)负责确认具体的混合方式与动画结果。
  3. 动画状态和状态机。
  4. 其他功能节点。

对于所有节点来说,具体依赖的参数的计算在函数Update_AnyThread中确定(Time, Weight),依赖的DeltaTime和动画实例等参数在FAnimationUpdateContext中传递。然后在函数Evaluate_AnyThread中求解,结果保存在FPoseContext中,语义是到当前这个节点时,应该输出怎样的动画姿势、曲线、属性等。

总的来说,UE的动画系统是把所有操作都抽象成对Pose的处理,每个操作都是确定如何输出当前角色的Pose。如果要跟Unity对比的话,那么只有后来的PlayableBlend 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;
    }
}

重点是PreviousStateNextState权重是相加,其他中间过程是相乘。

动画蓝图中的性能问题

动画蓝图主要有两个更新逻辑,一个是事件更新,一个是动画实例更新。其中事件更新跟普通蓝图一样,走蓝图虚拟机求解。而动画实例更新则完全按照预先编译好的节点组织成一颗树,通过递归的方式直接调用本地逻辑完成的,因此单纯的动画节点会非常高效。 另一方面,这也要求在制作中尽量避免添加太多蓝图事件,更要避免逻辑非常重的Update蓝图事件,不然就上UAnimInstance::NativeUpdateAnimation函数覆写。 当然,对于状态机中条件跳转,还是使用蓝图来求解的。虽然UE提供了条件的本地委托来接管状态之间的转移逻辑,但是仍然不合适:

  1. 本地代码太不容易读:语法复杂,定义与声明繁琐。手动输入各个名字容易发生错误。
  2. 破坏数据与功能定义的一致性和局部性。对状态机进行增删查改操作时引入不必要的复杂度和心智负担
  3. 本地条件转移逻辑有bug。

动画蓝图中的各节点数据结构关系:

Editor:                Deserialize:         Runtime:
-----------------     ----------------     ------------
|AnimGraphNode_*| ==> |BackedMachine*| ==> |AnimNode_*|
-----------------     ----------------     ------------