ほげたつブログ

プログラムとアニメーションをかじって生きてる

慣性ベースドなアニメーションブレンド(実装編)

慣性ベースドなアニメーションブレンドの実装編です。前回の解説編は以下のリンクから。一番下にGitHubへのリンクもあります。


hogetatu.hatenablog.com


## 追記(2020/3/18) ##

UE4.24 で標準機能として実装されたので自前で実装する必要は無くなりました。補間アルゴリズムは全く同じです。



アニメーションノードの作成

今回は AnimGraph で使えるノードとして実装します。AnimNode_InertialBlend は2つのポーズを入力し、フラグによって切り替える仕様です。

f:id:hogetatu:20180610190725p:plain


アニメーションノード自作の具体的なやり方は、以下のページが参考になります。

qiita.com


各ボーンの Transform

アニメーションノードがポーズを評価する時は Evaluate_AnyThread が呼ばれます。こちらの関数は FPoseContext が引数となっており、ここから現在ポーズにおける各ボーンの Transform 情報が取得できます。

void FAnimNode_InertialBlend::Evaluate_AnyThread(FPoseContext& Output)
{
    if (bAIsRelevant)
    {
        // Aポーズを評価
        A.Evaluate(Output);
    }
    else
    {
        // Bポーズを評価
        B.Evaluate(Output);
    }

    // 評価したポーズの各ボーンのTransformリスト
    const auto& CurrentTransforms = Output.Pose.GetBones();

    ~
}


また、各ボーンの Transform は前回、前々回フレームのものをキャッシュとして残しておきます。

struct FPoseCache
{
    bool bEnabled;
    TArray<FTransform> Transforms;
    float DeltaSeconds;
};

FPoseCache Caches[2];
FPoseCache* PrimaryCache;
FPoseCache* SecondaryCache;

~

/**
 * ポーズキャッシュ更新
 * @param PoseContext 現在ポーズ情報
 * @param DeltaSeconds 経過時間
 */
void FAnimNode_InertialBlend::UpdatePoseCache(FPoseContext& PoseContext, float DeltaSeconds)
{
    FPoseCache* Temp = PrimaryCache;
    PrimaryCache = SecondaryCache;
    SecondaryCache = Temp;

    PrimaryCache->bEnabled = true;
    PoseContext.Pose.CopyBonesTo(PrimaryCache->Transforms);
    PrimaryCache->DeltaSeconds = DeltaSeconds;
}

 

遷移情報を初期化

アニメーション遷移を始める時に遷移情報の初期化を行います。解説編で導いた式を元に関数化します。


 a_0 = \frac{-8v_0t_1 - 20x_0}{t_1^2}

 t_1 = \min(t_1, - \frac{5x_0}{v_0})

 x(t) = At^5 + Bt^4 + Ct^3 + \frac{a_0}{2}t^2 + v_0t + x_0

 A = -\frac{a_0t_1^2 + 6v_0t_1 + 12x_0}{2t_1^5}

 B = \frac{3a_0t_1^2 + 16v_0t_1 + 30x_0}{2t_1^4}

 C = -\frac{3a_0t_1^2 + 12v_0t_1 + 20x_0}{2t_1^3}


struct FTransitionBase
{
    float X0;
    float V0;
    float T1;
    float A0;
    float A;
    float B;
    float C;
};

~

/**
 * 遷移情報を初期化
 * @param Transition 遷移情報
 */
void FAnimNode_InertialBlend::InitTransition(FTransitionBase& Transition)
{
    if (FMath::IsNearlyZero(Transition.X0))
    {
        Transition.T1 = 0.f;
        Transition.A0 = 0.f;
        Transition.A = 0.f;
        Transition.B = 0.f;
        Transition.C = 0.f;
    }
    else
    {
        if (Transition.V0 != 0.f && (Transition.X0 / Transition.V0) < 0.f)
        {
            Transition.T1 = FMath::Min(BlendTime, -5.f * Transition.X0 / Transition.V0);
        }
        else
        {
            Transition.T1 = BlendTime;
        }

        Transition.A0 = ((-8.f * Transition.V0 * Transition.T1) + (-20.f * Transition.X0)) / FMath::Pow(Transition.T1, 2.f);

        Transition.A  = -((1.f * Transition.A0 * Transition.T1 * Transition.T1) + ( 6.f * Transition.V0 * Transition.T1) + (12.f * Transition.X0)) / (2.f * FMath::Pow(Transition.T1, 5.f));
        Transition.B  =  ((3.f * Transition.A0 * Transition.T1 * Transition.T1) + (16.f * Transition.V0 * Transition.T1) + (30.f * Transition.X0)) / (2.f * FMath::Pow(Transition.T1, 4.f));
        Transition.C  = -((3.f * Transition.A0 * Transition.T1 * Transition.T1) + (12.f * Transition.V0 * Transition.T1) + (20.f * Transition.X0)) / (2.f * FMath::Pow(Transition.T1, 3.f));
    }
}

 

ベクトルとクォータニオン

上記初期化はスカラー値に対して行っていますが、Transform を補間する場合はベクトルとクォータニオンに対して行う必要があります。

ベクトル

ベクトル補間は各アトリビュートに対してそれぞれ行うと良い結果が得られません。そのため、ベクトルを方向と長さに分けて考えた上で、[現在フレーム -1 ポーズ] の方向を基準とした長さについて補間を行います。

struct FVectorTransition : public FTransitionBase
{
    FVector NormalizedVecX0;
};

~

/**
 * 遷移情報を初期化(ベクトル)
 * @param Transition 遷移情報
 * @param RequestedValue 遷移先ポーズ値
 * @param PrimaryCacheValue 現在フレーム -1 ポーズ値
 * @param SecondaryCacheValue 現在フレーム -2 ポーズ値
 * @param PrimaryToSecondaryDeltaSeconds 現在フレーム -2 から 現在フレーム -1 の差分時間
 */
void FAnimNode_InertialBlend::InitTransition(
    FVectorTransition& Transition,
    const FVector& RequestedValue,
    const FVector& PrimaryCacheValue,
    const FVector& SecondaryCacheValue,
    float PrimaryToSecondaryDeltaSeconds)
{
    FVector VecX0 = PrimaryCacheValue - RequestedValue;
    Transition.X0 = VecX0.Size();

    if (Transition.X0 >= SMALL_NUMBER)
    {
        Transition.NormalizedVecX0 = VecX0 / Transition.X0;

        FVector VecXn1 = SecondaryCacheValue - RequestedValue;
        float Xn1 = FVector::DotProduct(VecXn1, Transition.NormalizedVecX0);

        Transition.V0 = (Transition.X0 - Xn1) / PrimaryToSecondaryDeltaSeconds;
    }
    else
    {
        Transition.NormalizedVecX0 = FVector::ZeroVector;
        Transition.V0 = 0.f;
    }

    InitTransition(Transition);
}

 

クォータニオン

クォータニオンもベクトルと同様に、軸と回転角に分けて考えた上で、[現在フレーム -1 ポーズ] の軸を基準とした回転について補間を行います。

struct FQuatTransition : public FTransitionBase
{
    FVector AxisX0;
};

~

/**
 * 遷移情報を初期化(クォータニオン)
 * @param Transition 遷移情報
 * @param RequestedValue 遷移先ポーズ値
 * @param PrimaryCacheValue 現在フレーム -1 ポーズ値
 * @param SecondaryCacheValue 現在フレーム -2 ポーズ値
 * @param PrimaryToSecondaryDeltaSeconds 現在フレーム -2 から 現在フレーム -1 の差分時間
 */
void FAnimNode_InertialBlend::InitTransition(
    FQuatTransition& Transition,
    const FQuat& RequestedValue,
    const FQuat& PrimaryCacheValue,
    const FQuat& SecondaryCacheValue,
    float PrimaryToSecondaryDeltaSeconds)
{
    FQuat InvRequestedValue = RequestedValue.Inverse();

    FQuat Q0 = PrimaryCacheValue * InvRequestedValue;
    Q0.ToAxisAndAngle(Transition.AxisX0, Transition.X0);
    NormalizeAngle(Transition.X0);

    FQuat Qn1 = SecondaryCacheValue * InvRequestedValue;
    float Xn1 = PI;

    if (!FMath::IsNearlyZero(Qn1.W))
    {
        FVector Qxyz = FVector(Qn1.X, Qn1.Y, Qn1.Z);
        Xn1 = 2.f * FMath::Atan(FVector::DotProduct(Qxyz, Transition.AxisX0) / Qn1.W);
        NormalizeAngle(Xn1);
    }

    float DeltaAngle = Transition.X0 - Xn1;
    NormalizeAngle(DeltaAngle);

    Transition.V0 = DeltaAngle / PrimaryToSecondaryDeltaSeconds;

    InitTransition(Transition);
}

 

差分の更新

初期化が完了したら、あとは時間 t を更新してその時の差分 Xt を現在ポーズに加算するだけです。Xt の式を元に関数化します。


 x(t) = At^5 + Bt^4 + Ct^3 + \frac{a_0}{2}t^2 + v_0t + x_0


/**
 * ブレンド値を取得(スカラー)
 * @param Transition 遷移情報
 * @param T 現在時間
 */
float FAnimNode_InertialBlend::CalcBlendValue(const FTransitionBase& Transition, float T) const
{
    float T_1 = FMath::Min(T, Transition.T1);
    float T_2 = T_1 * T_1;
    float T_3 = T_1 * T_2;
    float T_4 = T_1 * T_3;
    float T_5 = T_1 * T_4;

    return
        (Transition.A * T_5) +
        (Transition.B * T_4) +
        (Transition.C * T_3) +
        (0.5f * Transition.A0 * T_2) +
        (Transition.V0 * T_1) +
        (Transition.X0);
}

 

ベクトルのブレンドされた値

/**
 * ブレンドされた値を取得(ベクトル)
 * @param CurrentValue 現在値
 * @param Transition 遷移情報
 * @param T 現在時間
 */
FVector FAnimNode_InertialBlend::CalcBlendedValue(const FVector& CurrentValue, const FVectorTransition& Transition, float T) const
{
    float Xt = CalcBlendValue(Transition, T);
    return Xt * Transition.NormalizedVecX0 + CurrentValue;
}

 

クォータニオンブレンドされた値

/**
 * ブレンドされた値を取得(クォータニオン)
 * @param CurrentValue 現在値
 * @param Transition 遷移情報
 * @param T 現在時間
 */
FQuat FAnimNode_InertialBlend::CalcBlendedValue(const FQuat& CurrentValue, const FQuatTransition& Transition, float T) const
{
    float Xt = CalcBlendValue(Transition, T);
    return FQuat(Transition.AxisX0, Xt) * CurrentValue;
}

 

Evaluate 関数(評価関数)

上記の関数を用いることで、慣性ベースドなアニメーションブレンドが実現できます。実際の評価関数は以下の様な実装になります。

struct FTransformTransition
{
    FVectorTransition Translation;
    FQuatTransition Rotation;
    FVectorTransition Scale;
};

struct FPoseTransition
{
    TArray<FTransformTransition> Transforms;
    float T;
};

TSharedPtr<FPoseTransition> PoseTransition;

~

void FAnimNode_InertialBlend::Evaluate_AnyThread(FPoseContext& Output)
{
    if (bAIsRelevant)
    {
        A.Evaluate(Output);
    }
    else
    {
        B.Evaluate(Output);
    }

    const auto& CurrentTransforms = Output.Pose.GetBones();
    float DeltaSeconds = Output.AnimInstanceProxy->GetDeltaSeconds();

    if (bRequestedTransition && PrimaryCache->bEnabled && SecondaryCache->bEnabled)
    {
        bRequestedTransition = false;

        PoseTransition = MakeShareable(new FPoseTransition);
        PoseTransition->Transforms.SetNumUninitialized(CurrentTransforms.Num());
        PoseTransition->T = 0.f;

        int32 BoneIndex = 0;
        for (FTransformTransition& TransformTransision : PoseTransition->Transforms)
        {
            const FTransform& CurrentTransform = CurrentTransforms[BoneIndex];
            const FTransform& PrimaryCacheTransform = PrimaryCache->Transforms[BoneIndex];
            const FTransform& SecondaryCacheTransform = SecondaryCache->Transforms[BoneIndex];

            InitTransition(
                TransformTransision.Translation,
                CurrentTransform.GetTranslation(),
                PrimaryCacheTransform.GetTranslation(),
                SecondaryCacheTransform.GetTranslation(),
                PrimaryCache->DeltaSeconds
            );

            InitTransition(
                TransformTransision.Rotation,
                CurrentTransform.GetRotation(),
                PrimaryCacheTransform.GetRotation(),
                SecondaryCacheTransform.GetRotation(),
                PrimaryCache->DeltaSeconds
            );

            InitTransition(
                TransformTransision.Scale,
                CurrentTransform.GetScale3D(),
                PrimaryCacheTransform.GetScale3D(),
                SecondaryCacheTransform.GetScale3D(),
                PrimaryCache->DeltaSeconds
            );

            ++BoneIndex;
        }
    }

    if (PoseTransition.IsValid())
    {
        PoseTransition->T += DeltaSeconds;

        if (PoseTransition->T < BlendTime)
        {
            int32 BoneIndex = 0;
            for (FTransformTransition& TransformTransition : PoseTransition->Transforms)
            {
                const FTransform& CurrentTransform = CurrentTransforms[BoneIndex];

                FVector Translation = CalcBlendedValue(CurrentTransform.GetTranslation(), TransformTransition.Translation, PoseTransition->T);
                FQuat Rotation = CalcBlendedValue(CurrentTransform.GetRotation(), TransformTransition.Rotation, PoseTransition->T);
                FVector Scale = CalcBlendedValue(CurrentTransform.GetScale3D(), TransformTransition.Scale, PoseTransition->T);

                FCompactPoseBoneIndex CPBoneIndex(BoneIndex);
                Output.Pose[CPBoneIndex] = FTransform(Rotation, Translation, Scale);

                ++BoneIndex;
            }

            Output.Pose.NormalizeRotations();
        }
        else
        {
            PoseTransition.Reset();
        }
    }

    UpdatePoseCache(Output, DeltaSeconds);
}

 

サンプルプロジェクト

GitHubに上げてます。

github.com

※ Readmeにも書かれていますが、サンプルにはマーケットプレイスで無料配布されている「Animation Starter Pack」を利用しています。こちらは配布物には含めていないので、プロジェクトを起動する前にマーケットプレイスからダウンロードしてプロジェクトに含めて下さい。



長かった・・・!