找回密码
 立即注册
查看: 530|回复: 3

UE4动画蓝图自定义节点插件的编写

[复制链接]
发表于 2022-4-29 14:10 | 显示全部楼层 |阅读模式
前言

UE4的动画蓝图作为目前几乎是唯一的开源高度可扩展动画中间件选项,一直深受希望开发强动画表现力的开发者的信赖(这高考作文一般的开头是怎么回事),但存在的一个小问题就是,这一模块的功能添加一直没有非常成型的文档,UE官方的最后一篇文章还是UE4 Wiki时代的遗留。笔者在大概大半年以前写过一次动画蓝图插件,但最近重新捡起来这件事的时候又发现有很多细节有模糊的地方,所以也就整理成文记录一下,同时帮助其他有这个需要的朋友不用再到搜索引擎里去大浪淘沙。本文的主要内容都是整理自数篇其他文档和一些个人的总结,力求能让读者在一个地方搞懂动画蓝图的插件创建和骨骼操作,所以我也不会过多地去讨论动画系统本身的实践,而是让大家更清楚怎么能拿到动画处理中最为必要的数据,怎么操作这些数据以及进行debug。
预备知识

在正式开始讨论动画蓝图之前,我们需要先明白动画蓝图相关的一些基础知识,以辅助我们去更好地编写动画蓝图节点,这里面主要涉及包括UE4处理动画时的各个空间;骨骼资产的概念;骨骼的Index等。
骨骼相关的各个空间

其实空间这个,已经算是一个老生常谈的话题了,因为我们对Mesh进行导入时以及创建角色蓝图时进行的各种操作,使得角色的本地空间有时候不会与Mesh原本的空间重合,也可能不会与Component的空间重合,而它们又经常分享Local这个词用于描述一个[相对于世界]的本地的概念,所以刚刚接触的时候确实比较容易把人绕晕,这里我们只讨论几个骨骼相关的空间,也即EBoneControlSpace这个枚举类型中的以下几个,因为这几个空间允许在动画节地中方便地互相转换
BCS_WorldSpace世界空间
BCS_ComponentSpaceSkeletal Mesh Component空间
BCS_ParentBoneSpace父级骨骼空间
BCS_BoneSpace骨骼的原本参考空间
这几个里面大部分都很好理解,其中需要思考一下的就是这个Component Space到底指的是以什么为基准, 从FCSPose<PoseType>::CalculateComponentSpaceTransform(BoneIndexType BoneIndex)这个函数可以看出来,这个Component Space就是一级一级网上计算直到算到root的component space,那现在问题来了,我们对Skeletal Mesh的修改会不会影响这个Component Space呢,这里做一个简单的实验。
首先我们在角色类中,直接对Skeletal Mesh Component加一个Transform(一个轴旋转,一个轴位移),然后到动画蓝图中去获取root节点的Component Space Transform
FTransform NewBoneTM = Output.Pose.GetComponentSpaceTransform(RootBone.GetCompactPoseIndex(BoneContainer));
UE_LOG(LogTemp,Warning,TEXT("root: %s"), *NewBoneTM.GetRotation().Rotator().ToString())
获得的结果是
LogTemp: Warning: root: P=0.000000 Y=0.000000 R=0.000000结论显而易见,Component Space与Mesh Space重合。
骨骼的Index

如果有看过DX龙书,或者看过一些保存蒙皮骨骼的mesh文件的文件结构的话(比如Mod作者们很喜欢的Valve SMD),应该会知道,骨骼数组的组织方式通常都是数组序数是当前骨骼的排序,而数组的内容是父级骨骼的序数。不过在UE4中,引擎提供了三种Bone Indices: Mesh Bone Index, Skeleton Bone Index和FCompactPoseBoneIndex三种。
首先是Mesh Bone Index,这是由单一Skeletal Mesh提供的所有骨骼序列,每一个Skeletal Mesh会提供一组Mesh Bone Index,很好理解。
然后要提到的是Skeleton Bone Index,需要先明确的一点是,每次我们导入模型的时候,都会询问我们是否要指定一个Skeleton Asset,或者创建一个Skeleton Asset,当和指定已有的Skeleton Asset骨骼结构不同时,Skeleton Asset中的骨骼结构就会和新导入的骨骼结构求并集。
在Skeleton Asset中存储了一个ReferenceSkeleton,它包含了这一个Skeleton Asset中所有关节的Reference Pose,而Skeleton Bone Index就是这一个ReferenceSkeleton的index,在每一个AnimSequence中都保存了该AnimSequence到这一Skeleton Asset内部骨骼的映射表,只要骨骼发生了变化,动画资源里面的这一部分就都需要被修改。所以有时候修改了Skeleton Asset,就会发现动画uasset也需要保存。也就是说,Skeleton Bone Index是Mesh Bone Index的一个超集。
理解了以上两个概念以后,我们再来解释FCompactPoseBoneIndex,在模型的LOD变化的时候,我们需要进行计算的骨骼只有当前LOD的骨骼,为了避免计算资源的浪费这是非常可以理解的,而FCompactPoseBoneIndex就管理了当前LOD下的骨骼列表,当目前的骨骼不存在FCompactPoseBoneIndex时,我们可以直接跳过动画节点的计算部分。当进行动画计算的时候,我们只需要思考FCompactPoseBoneIndex就够了。
插件框架创建

在了解了各项前置内容以后,接下来就可以开始进行节点的添加了。考虑到很多时候我们制作动画蓝图节点的一大目的都是能够在多个地方复用一个功能,接下来的介绍也会以将动画蓝图节点添加到一个自定义插件中来举例。
首先我们创建一个空的插件,当然自己建立目录和uplugin文件也没问题,不过我们还是怎么简单怎么来。所以直接通过UE4内置的插件管理工具,添加一个空插件,在这里添加插件主要就只是为了创建模板uplugin文件,名字啥的随意取一个都是可以的。


然后进入UE4为我们创建的插件目录,将Source下面的部分拆分为Editor和Runtime两部分,这部分道理很好理解,那就是我们一边通过对应的Editor模块提供在动画蓝图编辑器中的节点图显示,一边通过Runtime模块在运行时修改骨骼的transform,特别是cook以后的内容都应该是属于Runtime模块的。注意在拆分的时候,需要进去将各个文件夹内的文件都重新命名一下,包括给Unreal Build Tool提供模块参考的.Build.cs和插件默认带的与模块同名的,管理加载和卸载插件时行为的*.cpp/*.h文件。当我们完成文件名修改的时候,得到的Editor下面的文件包括了
Private/AnimExtraNodeEditor.h
Public/AnimExtraNodeEditor.cpp
AnimExtraNodeEditor.Build.cs
这几个文件,同理去修改Runtime下的文件


然后就开始修改各种Unreal Build Tool的代码文件,使得我们添加的模块可以获取到它需要依赖的各种内置模块。首先我们参考Source/Editor/AnimGraph.Build.cs来修改我们AnimExtraNodeEditor.Build.cs,给它的Public加两个模块,其他部分按需添加
PublicDependencyModuleNames.AddRange(
                        new string[] {
                                "Core",
                                "CoreUObject",
                                "Engine",
                                "Slate",
                                "AnimGraphRuntime",
                                "BlueprintGraph",
                        }
                );
注意*.Build.cs的类名也需要修改
using UnrealBuildTool;
public class AnimExtraNodeEditor : ModuleRules
{
    public AnimExtraNodeEditor(ReadOnlyTargetRules Target) : base(Target)
    {
        …
}
}同理我们将Runtime的文件也修改掉,然后去修改UE4为我们生成的uplugin文件,使其存在两个模块,分别是Editor和Runtime模块,需要注意的是这里的Editor模块需要设置为UncookedOnly。
"Modules": [
                {
                        "Name": "AnimExtraNodeRuntime",
                        "Type": "Runtime",
                        "LoadingPhase": "Default"
                },{
                        "Name": "AnimExtraNodeEditor",
                        "Type": "UncookedOnly",
                        "LoadingPhase": "Default"
}到这里为止,我们可以进行一次编译,让UBT帮我们生成一个带有这些新的代码文件结构的IDE项目文件。方便接下来的工作。
动画蓝图节点创建

然后就可以进行动画蓝图节点的添加了,首先我们先理清楚一件事,就是动画蓝图节点的代码结构。正如我们上面所区分的那样,每一个动画节点都包含了Runtime和Editor两部分,其中Runtime的计算部分继承自FAnimNode_Base,而Editor的是继承自UAnimGraphNode_Base,顾名思义,前者是一个结构而后者是一个UObject。其实也很好理解,UAnimGraphNode_Base是单个动画节点在节点图编辑器中的表现,是一个UE4中的节点元素,而FAnimNode_Base主要承载计算功能,作为一个结构,它在多线程间传递会更加方便。
所以我们需要创建的就分别是这两个部分了,这里可以手工创建,也可以用UE4的File->New C++ Class菜单,前面编译正常的话,这里就可以在创建时看到我们刚刚添加的插件模块。有一点需要注意的是,在动画蓝图中,有两种不同的节点,分别是对Local Space进行操作的,继承自UAnimGraphNode_Base和FAnimNode_Base的节点,典型代表FAnimNode_ApplyAdditive;和对Component Space进行操作的,继承自UAnimGraphNode_SkeletalControlBase和FAnimNode_SkeletalControlBase的节点,典型代表是FAnimNode_ModifyBone,两者的区别也很简单,就是输入的值是Component Space还是Local Space,下面的举例就根据后者来创建一个新的动画节点。
首先我们直接通过UE4的添加新C++类为我们的Editor和Runtime分别添加一个空的新类,然后进入填空题,首先是FAnimNode
USTRUCT(BlueprintInternalUseOnly)
struct ANIMEXTRANODERUNTIME_API FAnimNode_AnimNodeName : public FAnimNode_SkeletalControlBase
{
        GENERATED_USTRUCT_BODY()

        // FAnimNode_Base interface
        virtual void GatherDebugData(FNodeDebugData& DebugData) override;
        // End of FAnimNode_Base interface

        // FAnimNode_SkeletalControlBase interface
        virtual void EvaluateSkeletalControl_AnyThread(FComponentSpacePoseContext& Output, TArray<FBoneTransform>& OutBoneTransforms) override;
};
然后是UAnimGraphNode
UCLASS(meta = (Keywords = "Custom Node"))
class ANIMEXTRANODERUNTIME_API UAnimGraphNode_AnimNodeName : public UAnimGraphNode_SkeletalControlBase
{
        GENERATED_BODY()

                UPROPERTY(EditAnywhere, Category = Settings)

                FAnimNode_AnimNodeName Node; // 这里填写了我们上面的FAnimNode的名字
       
public:
        // UEdGraphNode interface
        virtual FText GetNodeTitle(ENodeTitleType::Type TitleType) const override;
        virtual FText GetTooltipText() const override;
        // End of UEdGraphNode interface

        virtual const FAnimNode_SkeletalControlBase* GetNode() const override { return &Node; }
        // End of UAnimGraphNode_SkeletalControlBase interface
};
把这些新建类和结构中的函数都创建一下实现,我们的空动画节点就创建完成啦,如果现在的UE4的动画编辑器中还是找不到这个节点,也可以重启一下引擎,就会有了。
动画蓝图节点执行逻辑

接下来我们将重心放到FAnimNode上,因为这里管理了我们所有的用户输入和计算过程。首先要说的是,虽然上面有提到UAnimGraphNode管理了显示相关的东西,但主要还是显示的颜色、标题、类别等等,暴露出来参与计算的pin还是在FAnimNode上管理的。它们的定义方式和一般情况的UPROPERTY一样,可以设置Category等各种UE4的特性。
然后就是各种函数了,这里面有一个执行的流程,就像是渲染管线中对一系列输入进行各种变换一样,只是现在我们操作的目标变成了骨骼数据。每一个函数都有不同的用法,它们的用法取决于两点,一是输入提供的参数,二是被调用的时机,
Initialize_AnyThread(const FAnimationInitializeContext& Context)这个函数用于处理各种初始化,比如数值初始化等等,对于非Component Space的节点,可以在这里对BasePose进行初始化,也可以对各种数值进行Initialize,相比起后面的Update,在这里初始化会便宜很多,因为这个函数只会在编译时,游戏启动时和LOD改变时被调用。
CacheBones_AnyThread(const FAnimationCacheBonesContext& Context)用于缓存输入姿态,对于Local Space节点而言,可以在这里对Local Space的Pose进行cache,对于要操作目标骨骼的值的话,可以使用下面这个
InitializeBoneReferences(const FBoneContainer& RequiredBones)这一个函数同样是在Mesh的LOD改变或初始化的时候执行,它的输入参数是const FBoneContainer& RequiredBones,方便我们用于初始化用户输入的Bone Reference,在经过对输入骨骼进行FBoneReference::Initialize(RequiredBones);后我们的FBoneReference里面就会保存有接下来在进行操作时需要的Index了
IsValidToEvaluate(const USkeleton* Skeleton, const FBoneContainer& RequiredBones)检查是否继续进行Evaluate,主要是可以对即将操作的Bone检查有没有拿到它的CompactIndex(使用IsValidToExecute进行检查),或者检查必要的用户输入是否合法,通过才会执行下面两个每帧都执行的函数,这样就可以让某个作用于可能被LOD不覆盖的骨骼的节点提前退出。
Update_AnyThread(const FAnimationUpdateContext& Context)这一个函数基本上和`UpdateInternal(const FAnimationUpdateContext& Context)`是等价的,可以进行准备的数学运算,包括对输入参数进行预处理,执行连接的所有pin上的逻辑,更新ScaleBiasClamp的Field到实际运算的数字,使用上一帧的数据计算等等,输入的FAnimationUpdateContext中有DeltaTime,可以用于进行与时间有关的计算。
EvaluateSkeletalControl_AnyThread(FComponentSpacePoseContext& Output, TArray<FBoneTransform>& OutBoneTransforms)然后就是最后这个直接修改骨骼的函数了,这里的FComponentSpacePoseContext& Output提供了可以用于获取当前骨骼的Transform或层级结构的方法,而TArray<FBoneTransform>& OutBoneTransforms则是一个空数组,它的值类型FBoneTransform标注了一个骨骼的Index和新的Transform,在每个动画节点执行结束后,如果这个数组有值,那引擎就会根据Alpha将这个新值与对应index的骨骼进行混合。至此,一个动画节点的初始化、判断、计算、骨骼修改流程就结束了。
技巧和踩坑

这里也稍微提一下我个人在动画蓝图开发当中遇到的一些小小的坑点以及技巧。

  • 可能有朋友会留意到,在UE4原版的动画蓝图中,可能存在有一种很特别的float输入,它允许用户对输入的值进行Scale Bias,甚至是帧间插值,所有的Alpha默认都是这种类型,如果你也想提供这一功能可以自定义一个类型为InputScaleBias的UPROPERTY。



alpha默认就是scale bias


  • 如果对Initialize函数进行了重载,你看需要手动对输入pin的pose进行一下initialize,如果想避免麻烦的话,可以直接FAnimNode_SkeletalControlBase::Initialize_AnyThread(Context);调用一下基类函数的初始化函数。
  • 上文中没有谈及到FAnimNode_RootMotionScaler::GatherDebugData(FNodeDebugData& DebugData)这个函数,但它其实也非常有效,一个简单的例子是:
void FAnimNode_AnimNodeName::GatherDebugData(FNodeDebugData& DebugData)
{
        FAnimNode_SkeletalControlBase::GatherDebugData(DebugData);
        FString DebugLine = DebugData.GetNodeName(this);

        DebugLine += "(";
        AddDebugNodeData(DebugLine);
        DebugLine += FString::Printf(TEXT(" Offset: %f)"), Offset);
        DebugData.AddDebugItem(DebugLine);

        ComponentPose.GatherDebugData(DebugData);
}这样可以使Debug信息中输出某一个变量的值,而如果要看到这个Debug信息,可以通过命令行输入 ShowDebug Animation来得到,猜测Animation Insight应该也会记录相关的信息。

  • 虽然似乎是符合直观的,但在动画蓝图中使用节点来修改root骨骼并不会让角色的root motion产生作用,及时选择了[Root Motion From Everythin],UE的处理中,Everything只包含了动画和蒙太奇,但这一情况在UE5中被改变了,UE5的正式版引入的新类Root Motion Manager可以让用户在动画节点中修改root motion。
Acknowledgement

文章到这里就结束了,在个人学习和编写本文的时候,我得到了下面这些信息来源的帮助,它们也都非常有用:
Creating New Anim Nodes Pt. 2
UE4 C++ Part 1 - Trying to add the structure to make a custom anim node (UE4.23)
有兴趣的也可以去看一下他们的内容。
结束

有那么一段时间没有发这种比较长的文章了,笔者两天前核酸检测为阳性,本文就是在方舱医院中忙里偷闲写完的,因为睡眠不足,精神有些昏昏沉沉,如有什么纰漏的地方,还请各位不吝赐教。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
发表于 2022-4-29 14:16 | 显示全部楼层
不愧是啾啾老师 虽然没看懂但是觉得很厉害
发表于 2022-4-29 14:19 | 显示全部楼层
太棒了!!解开了很多我的疑惑~
发表于 2022-4-29 14:24 | 显示全部楼层
虾饺老师教我[可怜]
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2025-1-22 21:00 , Processed in 0.097266 second(s), 26 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表