概述

PoseAsset 是 UE 提供的一种基于曲线驱动动画的方式 1。传统动画使用关键帧来控制,关键帧之间的状态计算使用前后关键帧状态插值来实现,而 PoseAsset 则是通过定义动画的极值,然后对这些极值进行加权来组合出动画。比如先定义眼睛睁开到最大和闭合的状态,然后,通过曲线控制它们的权重,实现眨眼的效果。一个常见的应用场景就是基于 FACS 2 实现面部表情。而这个能力也非常适合用于进行游戏中常见的捏人操作,在本文中我们将会讨论如何在 UE 中使用 PoseAsset 实现捏人功能,并进一步优化工作流。

基础使用

在美术制作好 PoseAsset 资源后,将其以动画骨骼动画的形式导出为 FBX 文件,然后导入 UE 之中,打开这个动画资产,我们大致能看到这样无意义的动画:

一般来说,这里动画的第一帧是参考体型,之后每一帧都有具体的含义,代表某一个捏人参数的极值,因此我们可以看到上图中模型各个部位会逐个发生形状变化。在导入了这个动画后,右键点击该动画资产,选择「Create」-「Create PoseAsset」创建一个 PoseAsset:

此时 UE 会弹出创建 PoseAsset 的提示,窗口上面选择使用的动画,下面则填入这个动画序列每一帧代表的含义。如果不填的话,引擎就只能给它默认的无意义的名字,不直观也不方便后续操作。一般来说这个名字列表需要由 PoseAsset 资源的制作者提供:

创建好 PoseAsset 后,一般我们还会将其模式改为叠加模式,以方便它和其他动画组合使用。双击这个资产打开编辑窗口,找到「Asset Details」面板,将这里的「Additive」选项勾上,然后点「Convert to Additive Pose」按钮即可:

在这个资产编辑窗口中我们可以看到刚刚填入的每一帧对应的名字,此时可以随意调整其数值预览它产生的效果,例如下图中将「upperLegStrong_L」的值改到 1,就看到模型左腿上部变粗了:

这些 pose 的名字也绑定于对应的动画曲线名,在「Anim Curves」面板中我们可以看到这些曲线。显然,由于动画曲线和骨架绑定,因此 pose 名需要在这个骨架内保证唯一:

使用 PoseAsset 很简单,只需要在动画蓝图的输入节点和输出节点之间插入「Modify Curve」节点 3 和对应的 PoseAsset 节点即可,如下图所示:

其中,「Modify Curve」节点就相当于刚刚在编辑窗口中手动调节每个曲线的值,这里的曲线值可以通过自行定义对应的动画蓝图变量来赋值:

接下来我们新建一个蓝图类,在上面挂载「USkeletalMeshComponent」,并使用前面的动画蓝图和对应的模型,并将「Animation Class」设定为前面的动画蓝图:

为了暴露动画蓝图的参数给游戏侧控制,我们还要在该蓝图中定义一组和动画蓝图中一一对应的变量:

回到动画蓝图的编辑界面,打开它的「Event Graph」,在其中对每一个动画蓝图中的变量进行赋值,变量值的来源就是刚刚在蓝图中定义的对应变量:

此时,我们就可以在场景中通过蓝图里的变量控制曲线值,进而进行捏人了:

使用优化

UE 自带的 PoseAsset 能力足够实现捏人的能力,但在实际应用的过程中还是不够方便。从上面的案例中也能看出,我们需要在蓝图和动画蓝图中定义一堆对应的变量,而且还需要手动连接非常多的引脚,这不仅麻烦而且没法配置化,我们在实际应用的时候一般希望能通过一个配置文件指定有哪些曲线可以编辑,然后在代码中按名字修改其数据,而不是在蓝图中连接一堆引脚。

为了实现这个功能,我们需要实现一个批量修改曲线的 anim node,这一块的文档不多,比较简单的方式是参考 UE 自己的实现,既然我们也是要修改曲线,那直接参考 UE 的 FAnimNode_ModifyCurve 类型 4 即可,大致声明如下:

// 用于批量修改一组 pose 曲线的 anim node。
USTRUCT(BlueprintInternalUseOnly)
struct MY_API FAnimNode_BatchModifyPoseCurve : public FAnimNode_Base {
    GENERATED_USTRUCT_BODY()

    UPROPERTY(EditAnywhere, EditFixedSize, BlueprintReadWrite, Category = Links)
    FPoseLink SourcePose;

    virtual void Evaluate_AnyThread(FPoseContext& Output) override;

    /** ... **/

private:
    // FNamedCurveValue 定义在 Animation/CurveSourceInterface.h 中
    TArray<FNamedCurveValue> CurveModifyData;
};

其中,主要逻辑实现在 Evaluate_AnyThread 中,同样,设置曲线值的逻辑也可以参考 FAnimNode_ModifyCurve::Evaluate_AnyThread 的实现,大致如下:

USkeleton const* Skeleton = Output.AnimInstanceProxy->GetSkeleton();
for (auto const& ModifyItem : CurveModifyData) {
    SmartName::UID_Type const NameUID = Skeleton->GetUIDByName(USkeleton::AnimCurveMappingName, ModifyItem.Name);
    float const CurrentValue = Output.Curve.Get(NameUID);
    Output.Curve.Set(NameUID, CurrentValue + ModifyItem.Value);
}

问题是这里的 CurveModifyData 中的值从哪来,一个做法是自己实现一个 anim instance 作为游戏逻辑和 anim node 之间的中介,在 anim node 中可以这样获取 anim instance:

auto AnimInst = Cast<UMyAnimInstance>(Output.AnimInstanceProxy->GetAnimInstanceObject());
if (AnimInst != nullptr && AnimInst->IsDirty()) {
    CurveModifyData = AnimInst->GetPoseCurves();
}

至于 UMyAnimInstance 则非常简单,主要功能就是设置和获取曲线数据,唯一一个需要注意的点是多线程操作数据记得加锁:

class MY_API UMyAnimInstance : public UAnimInstance {
public:
    // 设置 pose 曲线数据。
    UFUNCTION(BlueprintCallable)
    void SetPoseCurves(TArray<FNamedCurveValue> Data);
    // 数据是否被修改过。
    bool IsDirty() const;
    // 获取 pose 曲线数据。
    TArray<FNamedCurveValue> GetPoseCurves();

private:
    TArray<FNamedCurveValue> PoseCurves;
    bool bDirty = false;
    mutable FCriticalSection CriticalSection;
};

在 UE 中,为了给动画蓝图编辑器提供节点信息,每个 anim node 还需要一个配套的 anim graph node,类似地,我们参考 UAnimGraphNode_ModifyCurve 来实现一个,这里的功能比 UAnimGraphNode_ModifyCurve 要简单很多,仅仅提供一些名字之类的信息,大致如下:

UCLASS(MinimalAPI)
class UAnimGraphNode_BatchModifyPoseCurve : public UAnimGraphNode_Base {
    GENERATED_UCLASS_BODY()
    
    UPROPERTY(EditAnywhere, Category = Settings)
    FAnimNode_BatchModifyPoseCurve BlendNode;
    
    virtual FText GetTooltipText() const override;
    virtual FText GetNodeTitle(ENodeTitleType::Type TitleType) const override;
    virtual FString GetNodeCategory() const override;
};

需要注意这个类型需要依赖一些 editor only 的模块,但是这个类所属的模块类型只能是只能是 UncookedOnly 而不可以是 Editor ,否则会有如下的报错:

The node ’ Batch Modify Pose Curve ’ is from an Editor Only module, but is placed in a runtime blueprint! K2 Nodes should only be defined in a Developer or UncookedOnly module.

错误信息里提到的 Developer 类型已经废弃了,因此我们选用 UncookedOnly 类型,即在配置中对其所在的模块进行类似这样的声明:

{
    "FileVersion": 3,
    "Modules": [
        {
            "Name": "DemoEditor",
            "Type": "UncookedOnly",
            "LoadingPhase": "Default"
        },
        ...
    ],
    ...
}

在实现了 anim node 和配套的 anim graph node 之后,我们就可以移除之前定义在动画蓝图中的所有变量,并将其 AnimGraph 改为这样:

另外需要注意此时动画蓝图的父类需要设置为我们自己实现的 anim instance:

这样一来我们就可以在代码中调用 UMyAnimInstance::SetPoseCurves 接口将数据批量设置进 anim node 中了,例如这样:

void ApplyPoseCurvesToAnim(USkeletalMeshComponent* SkeMeshComp, TArray<FNamedCurveValue> Curves) {
    UMyAnimInstance* AnimInstance = Cast<UMyAnimInstance>(SkeMeshComp->GetAnimInstance());
    if (AnimInstance != nullptr) {
        AnimInstance->SetPoseCurves(std::move(Curves));
    }
}

与 ControlRig 结合使用

ControlRig 5 是 UE 提供的一种约束骨骼移动的方案,也常被用于实现捏脸功能,相比直接使用 PoseAsset,使用 ControlRig 可以添加一些约束信息来实现避免表情穿帮的效果。虽然连接的节点和 PoseAsset 不同,但在用户控制界面上,基于 ControlRig 进行捏脸其实还是操作曲线,因此也可以复用前面我们开发的逻辑。类似这样连接节点即可: