虚幻引擎 动画节点(AnimNode)详解

1

主题

1

帖子

3

积分

新手上路

Rank: 1

积分
3
发表于 2023-3-25 20:12:13 | 显示全部楼层
这是一篇关于动画节点的详细解读。由于内容比较多且杂,所以排版有点乱,最近会不定期优化本篇文章的结构和内容。如果有任何错误和疏漏请评论或者私信告知,请不吝指教
本文所有内容解读自虚幻引擎源码,图片是4.27版本截取的。对于5.0版本,原理也是一致的。
动画逻辑节点(AnimNode)

AnimNode是动画节点的纯逻辑类,用于运行时执行。实际上是一个Struct。
如图里的两个节点,左边是一个普通的AnimNode,右面是SkeletalControlNode。他们间的区别是左边的节点工作于本地空间,右边的节点工作于模型空间。


更新(Update)

节点的Update用于根据Weight计算动画的各种权重。因为Weight会在下一阶段清空。
如果按照Epic的编写习惯,我们应该在Update里面拿到所有外部数据并且预计算,保证Evaluate可以直接使用。
评估(Evaluate)

根据上一个节点的Pose,计算出输出到下个节点的Pose。
这是动画节点最重要的部分。正常来说我们应该把骨骼计算部分都放在这里。
注意Update和Evaluate都有可能运行在子线程上,除了读写AnimInstanceProxy外,操作其他东西都不是线程安全的,尽可能不要碰外部的UObject。
根节点(Root)

也叫Output Pose节点。
根节点是最重要的节点。对于用户来说,他是所有动画逻辑的输出节点。但是对于蓝图来说,他是整个蓝图节点的开始。AnimInstance将从这里开始建立整个动画节点的树状联系。


动画节点的属性

AnimNode通过他们的属性从外部获取信息。尽可能通过属性拿到想要的数据,而不是在运行时一层层往上Cast然后获取。
AnimNode里面的EditAnywhere的UProperty都会在动画蓝图里暴露出来。有几个特殊的Meta

  • AlwaysAsPin
  • NeverAsPin
  • PinShownByDefault
  • PinHiddenByDefault
他们可以控制属性要不要作为pin暴露出去。
变成Pin后可以直接从变量连接过去(图中的Translation),也可以直接绑定属性(图中的Alpha)。
如果连接变量的话,要注意节点是否仍然保持着FastGraph(闪电图标)。


动画节点的函数

我们创建一个AnimNode的时候,首先要确定他需要参与计算的空间系。在本地空间里,所有骨骼的Transform都是以父节点为原点记录的,而模型空间里,所有的骨骼的Transform都是以root点为原点记录的。
Local Space
如果我们需要操作本地空间,那我们需要继承FAnimNode_Base,需要重写几个关键的函数

  • Initialize_AnyThread 初始化节点信息,在状态机内可能会被调用多次。
  • Update_AnyThread  更新节点,计算所需权重(Alpha)等。
  • Evaluate_AnyThread 用于在本地空间计算最终的骨骼。在某些EVisibilityBasedAnimTickOption 的设置下,可能会忽略掉
  • CacheBones_AnyThread 一般出现于Lod被修改,原来的BoneIndex失效,需要重新初始化缓存的信息
  • GatherDebugData  用于往Showdebug窗口输出节点的信息,方便调试
在Update需要执行GetEvaluateGraphExposedInputs().Execute(Context); 将传入的pin读取到节点的属性里。不然你会看到节点的属性一直是默认值。
Mesh Space
如果我们需要操作模型空间,那就要继承FAnimNode_SkeletalControlBase ,传入的Pose已经是模型空间了。这个节点会预先帮你计算Alpha,以及将Pin的数据传入节点属性里。
需要重写几个关键的函数

  • UpdateInternal 因为update_anythread被标记成final,所以需要重写这个函数
  • EvaluateSkeletalControl_AnyThread 计算骨骼输出,计算结果扔到outbonetransform里
  • IsValidToEvaluate 返回节点是否需要执行
  • InitializeBoneReferences 计算BoneRefrence基于当前lod的compactIndex
动画节点的生成与获取

我们需要知道动画节点是如何创建出来的,这样我们才可以找到他们。
所有的AnimNode是作为AnimInstance的Property保存着的。这样的好处是可以通过Property拿到各个Node的Class,可以正确地从引用转化成Node。
最终保存Node的引用的地方在UAnimBlueprintGeneratedClass,他们保存着AnimNode的Property,通过GetAnimNodeProperties方法获取,你可以安全地查看他们的Class并且拿到最终的AnimNode的Struct部分。
在创建AnimInstance的时候,也会一起创建所有Node。
我们知道他是怎么来的,那获取的话就很简单了。
可以参考AnimInstanceProxy里面包装好的方法FAnimInstanceProxy::GetCheckedNodeFromIndexUntyped
从AnimClassInterface里面获取Property,然后转化成需要的Struct,从而拿到节点。
调试

动画蓝图的调试最简单是直接在动画蓝图节点上面Watch This Value,不过这样只能看到输入信息。
想看更多信息,可以GatherDebugData方法里面增加调试输出,然后在运行时输入ShowDebug Animation来看实时调试信息。
不过文字信息一般不够直观。如果需要在ViewPort里面绘制图像信息,有两种选择。
如果在动画蓝图的界面绘制信息,可以在AnimGraphNode的Draw方法在Pdi里面画。可以参考UAnimGraphNode_ApplyLimits::Draw 实现。
如果需要在World上画东西,要注意线程问题。我们的动画节点一般运行在子线程,直接DrawDebugXXX会导致编辑器崩溃。我们需要等回到主线程再画。
动画蓝图节点(AnimGraphNode)

AnimGraphNode主要是负责在编辑器里面的显示部分,以及Debug时候通过传入的AnimInstance找到对应的Node。
通过修改AnimGraphNode,你可以创造出千奇百怪的动画节点。
AnimGraphNode与AnimNode的联系

也许会有人一直很好奇,AnimGraphNode是怎么跟AnimNode联系的。
其实在UAnimBlueprintGeneratedClass里面,会保存了一个FAnimBlueprintDebugData,里面保存着所有的AnimGraphNode(ProcessAnimationNode)。AnimGraphNode在这里的Index跟AnimNode的Index是一样的,所有可以通过Index去找到自己在对应的蓝图对应的Node(来显示)。
AnimGraphNode的函数

除此以外,AnimGraphNode还提供了输出标题,分类,颜色等实用功能,大部分人只需要复写这些函数即可。

  • GetNodeTitle               获取节点标题
  • GetNodeTitleColor      获取节点颜色
  • GetTooltipText             获取节点提示
  • GetNodeCategory       获取节点分类
骨骼索引(BoneIndex)

BoneIndex有三种,我们一一解释。
SkeletonBoneIndex

首先Skeleton的骨骼包含着所有Mesh的所有骨骼。每当新增骨骼,只要名字不重复,就可以插入到Skeleton里面。Skeleton主要保存着他们的父子关系,所以不同网格体之间的同名骨骼的父子关系一定要正确。
在Skeleton里面,所有的骨骼都有一个唯一的Name和唯一的ParentIndex,然后保存在BoneTree里面。骨骼在这里面的Index,就属于SkeletonBoneIndex了。
SkeletonBoneIndex主要用于查找父骨骼,当然很多地方已经直接帮你缓存下来了,实际应用中很少需要自己去Skeleton里面查找。
MeshBoneIndex

MeshBoneIndex是当前的骨骼网格体用到的骨骼的Index。
用于显示骨架网格体,所以输出的话是输出MeshBoneIndex。
CompactPoseBoneIndex

当前Lod用到的骨骼的Index,在RequiredBone里面保存。
通过XXBone.Initialize(RequiredBones) 进行初始化。
我们在动画节点里面一般通过FBoneReference来操作骨骼。BoneRefrence会通过BoneName获得3个骨骼索引。如果传入的是BoneContainer,那BoneIndex保存的是MeshIndex,如果传入的是Skeleton的话,那BoneIndex保存的是SkeletonIndex。
Epic网站上也有一篇文章作为参考[1]。
骨骼互相转换

获取SkeletonBoneIndex
BoneContainer.GetPoseToSkeletonBoneIndexArray()[MeshBoneIndex]
RequiredBones.GetSkeletonIndex(CompactPoseBoneIndex)获取MeshBoneIndex
RequiredBones->MakeMeshPoseIndex(CompactPoseBoneIndex)
RequiredBones.GetPoseBoneIndexForBoneName(BoneName)
RequiredBones.GetBoneIndicesArray()[CompactBoneIndex];
RequiredBones.GetSkeletonToPoseBoneIndexArray()[SkeletonBoneIndex]获取CompactPoseBoneIndex
RequiredBones.GetCompactPoseIndexFromSkeletonIndex(SkeletonBoneIndex);
BoneContainer.MakeCompactPoseIndex(MeshPoseBoneIndex)获取BoneName
RequiredBones.GetReferenceSkeleton().GetBoneName(MeshPoseBoneIndex); 姿势(Pose)

Pose

当前姿势,只是用当前Lod的骨骼,一般分为本地空间或者模型空间两种,由transform数组构成。
分为Local Space Pose和Mesh Space Pose。分别工作于本地空间和模型空间。可以想象成动画蓝图执行到这里时候骨骼的样子。
Pose Link

两个节点间的连接。在蓝图里表现为一条线。
图里是两种link,连接着前后两个Pose。本地空间无法于模型空间的连接在一起。


模型空间的姿势和本地空间的姿势互转

FCSPose<FCompactPose>::ConvertComponentPosesToLocalPoses
Pose.LocalBlendCSBoneTransforms从动画里面获取Pose

// 首先我们要构造一个BoneSpace 的Pose
FCompactPose AnimPose;
// 这里也可以SetBoneContainer
AnimPose.InitFrom(XXX);
// 然后我们包装一个FAnimationPoseData
FBlendedCurve AnimCurve;
FStackCustomAttributes AnimAttributes;
FAnimationPoseData AnimationPoseData(AnimPose, AnimCurve, AnimAttributes);
// 包装一个FAnimExtractContext
const FAnimExtractContext ExtractionContext(Position, false);
// 获取Localspace的pose
Sequence->GetAnimationPose(AnimationPoseData, ExtractionContext);
// 获取ComponentSpace的Pose
FCSPose<FCompactPose> CSPose;
CSPose.InitPose(AnimationPoseData.GetPose());动画节点的运作

最后,有必要了解动画节点的运作流程。一个由动画节点构成的蓝图的代码是如何执行的呢?我们简单讲解一下。


我们按照这个简单的蓝图举例。

  • 在Update的时候,执行Root的Update
  • Root找到连接到他的上一个节点,BlendPosesByBool节点,执行他的Update
  • BlendPosesByBool节点的Update首先会读取所有Pin的值,这个节点来说主要是Active Value
  • 然后根据Value,执行对应节点的Update,也就是上一个节点,BlendSpace Player节点的Update
  • BlendSpacePlayer的Update会读取Speed的值到属性里
  • 然后是Evaluate,沿着同样的线路
  • 首先执行Output的Evaluate,他会先执行上一个节点的Evaluate
  • 也就是BlendPosesByBool的Evaluate,这里他也会先执行当前激活的节点的Evaluate,也就是BlendSpacePlayer的Evaluate
  • BlendSpacePlayer的Evaluate很简单,根据输入的参数,从BlendSpace里面输出一个混合的Pose。
  • 然后回到BlendPosesByBool,如果有混合时间而且处于混合状态,他会把两个Pose按照比例混合再输出,如果没有,则直接原样输出其中一个Pose,当前是BlendSpace的输出。
  • 最后的Output节点把前面的Pose输出。大功告成。
从这个例子可以看出来,因为涉及到姿势混合,大部分节点的Evaluate都需要先执行上一级的Evaluate再执行自己的Evaluate逻辑,保证顺序。而Update则要求没那么严格,只需要保证有执行到前面的Update即可(因为自己也只是处理一下参数,没有什么顺序逻辑)。
参考


  • ^骨架节点索引揭秘 https://www.unrealengine.com/zh-CN/tech-blog/demystifying-bone-indices
回复

举报 使用道具

2

主题

7

帖子

12

积分

新手上路

Rank: 1

积分
12
发表于 2023-3-25 20:12:51 | 显示全部楼层
很好 很详细
回复

举报 使用道具

0

主题

5

帖子

0

积分

新手上路

Rank: 1

积分
0
发表于 2023-3-25 20:13:50 | 显示全部楼层
感谢分享
回复

举报 使用道具

1

主题

5

帖子

3

积分

新手上路

Rank: 1

积分
3
发表于 2023-3-25 20:14:20 | 显示全部楼层
动画节点能不能输出整体Pose以外的数据, 比如具体到某一个骨骼的旋转值?
回复

举报 使用道具

1

主题

4

帖子

4

积分

新手上路

Rank: 1

积分
4
发表于 2023-3-25 20:15:10 | 显示全部楼层
一般来说,要保存到某根虚拟骨骼上面。这样后面的节点读取这根虚拟骨骼的数据来获取旋转。
回复

举报 使用道具

您需要登录后才可以回帖 登录 | 立即注册
快速回复 返回顶部 返回列表