Blog

Technical thoughts, troubleshooting, and development notes.

Blog/UE Development

The Keys to Runtime Animation Recording: Correct Bone Data and Saving

The two most important issues in runtime animation recording in UE5.4 are obtaining correct bone data and saving AnimSequence correctly.

Back
This page's table of contents

UE: The Key to Runtime Animation Recording—Obtaining Correct Bone Data and Saving It

The Core Problems

When implementing runtime animation recording in UE5.4, the two most critical questions are:

  1. How to obtain correct bone data - Avoid crashes and data mismatches
  2. How to save an AnimSequence correctly - Use the engine’s standard APIs

This article goes straight to the essentials and provides the most concise, effective solution.


1. Obtaining Correct Bone Data

Core Principle: Use Three Key APIs Correctly

// ✅ 正确:使用 SkeletalMesh 的 RefSkeleton
USkeletalMesh* SkeletalMesh = SkeletalMeshComponent->GetSkeletalMeshAsset();
const FReferenceSkeleton& RefSkeleton = SkeletalMesh->GetRefSkeleton();

// ✅ 正确:使用 GetRawBoneNum() 获取实际骨骼数
const int32 NumBones = RefSkeleton.GetRawBoneNum();

// ✅ 正确:运行时变换数据
const TArray<FTransform>& BoneSpaceTransforms = SkeletalMeshComponent->GetBoneSpaceTransforms();

Incorrect Examples and Their Consequences

Incorrect codeProblemConsequence
Skeleton->GetReferenceSkeleton()Includes ControlRig virtual bonesCrash: FKControlRig.cpp:126
RefSkeleton.GetNum()Returns all bones (including virtual bones)Array out of bounds
GetBoneNames() followed by iterating RefSkeletonBone names added twiceInconsistent data

Comparison of Key APIs

Data sourceMethodExample return valueCorrect?
Skeleton->GetReferenceSkeleton().GetNum()All bones161 (89 actual + 72 virtual)❌
SkeletalMesh->GetRefSkeleton().GetNum()All bones161❌
SkeletalMesh->GetRefSkeleton().GetRawBoneNum()Actual bones89✅
SkeletalMeshComponent->GetBoneSpaceTransforms().Num()Runtime transforms89✅ For comparison and validation

Evidence from the Engine Source

AnimSequence.cpp, lines 2674–2678 (official implementation):

const int32 NumBones = RefSkeleton.GetRawBoneNum();  // ← 引擎使用 GetRawBoneNum()
const TArray<FTransform> BoneSpaceTransforms = MeshComponent->GetBoneSpaceTransforms();

check(BoneSpaceTransforms.Num() >= NumBones);  // ← 引擎期望这个条件成立

Complete Initialization Code

void ATestActor::StartRecording(int _FrameSize, ACharacter* _TargetActor)
{
    TargetActor = _TargetActor;
    SkeletalMeshComponent = TargetActor->GetMesh();

    // 1. 获取正确的 RefSkeleton(SkeletalMesh 的,不是 Skeleton 的)
    USkeletalMesh* SkeletalMesh = SkeletalMeshComponent->GetSkeletalMeshAsset();
    const FReferenceSkeleton& RefSkeleton = SkeletalMesh->GetRefSkeleton();

    // 2. 使用 GetRawBoneNum() 获取实际骨骼数
    const int32 NumBones = RefSkeleton.GetRawBoneNum();

    // 3. 获取运行时变换数据(用于验证)
    const TArray<FTransform>& BoneSpaceTransforms = SkeletalMeshComponent->GetBoneSpaceTransforms();

    // 4. 安全性检查(引擎期望这个条件)
    if (BoneSpaceTransforms.Num() < NumBones)
    {
        UE_LOG(LogTemp, Error, TEXT("骨骼数据不匹配:BoneSpaceTransforms(%d) < RawBoneNum(%d)"),
            BoneSpaceTransforms.Num(), NumBones);
        return;
    }

    // 5. 填充骨骼名称(单次遍历)
    BoneNames.Empty();
    for (int32 BoneIndex = 0; BoneIndex < NumBones; ++BoneIndex)
    {
        BoneNames.Add(RefSkeleton.GetBoneName(BoneIndex));
    }

    // 6. 初始化数据数组
    TrackDataArray.SetNum(NumBones);
    IsRecording = true;
}

Explanation of the Key Points

Why Must We Use the SkeletalMesh’s RefSkeleton?
// ❌ 错误:Skeleton 的 RefSkeleton 包含 ControlRig 虚拟骨骼
const FReferenceSkeleton& RefSkeleton =
    SkeletalMesh->GetSkeleton()->GetReferenceSkeleton();  // 161个骨骼

// ✅ 正确:SkeletalMesh 的 RefSkeleton 是实际骨骼层级
const FReferenceSkeleton& RefSkeleton =
    SkeletalMesh->GetRefSkeleton();  // 配合 GetRawBoneNum() 使用

Examples of virtual bones in a Skeleton:

  • root_CONTROL - ControlRig root controller
  • ik_hand_l_CONTROL - IK hand controller
  • These bones do not exist in BoneSpaceTransforms!
Why Must We Use GetRawBoneNum()?
// GetNum() vs GetRawBoneNum() 的区别
RefSkeleton.GetNum();         // 返回全部:RawBones + VirtualBones = 161
RefSkeleton.GetRawBoneNum();  // 返回实际:RawBones = 89

Validation method:

UE_LOG(LogTemp, Warning, TEXT("RefSkeleton.GetNum() = %d"), RefSkeleton.GetNum());
UE_LOG(LogTemp, Warning, TEXT("RefSkeleton.GetRawBoneNum() = %d"), RefSkeleton.GetRawBoneNum());
UE_LOG(LogTemp, Warning, TEXT("BoneSpaceTransforms.Num() = %d"), BoneSpaceTransforms.Num());

// 正确结果应该是:
// GetNum() = 161
// GetRawBoneNum() = 89
// BoneSpaceTransforms.Num() = 89

2. Saving an AnimSequence Correctly

Core Principle: Use IAnimationDataController

UE5 deprecated modifying AnimSequence directly. You must use the IAnimationDataController interface.

Standard Saving Procedure (5 Key Steps)

void ATestActor::SpawnAnimAsset()
{
    // === 1. 创建资产对象 ===
    UPackage* Package = CreatePackage(*AssetPath);
    UAnimSequence* AnimSeq = NewObject<UAnimSequence>(Package, UAnimSequence::StaticClass(),
        *AssetName, RF_Public | RF_Standalone | RF_MarkAsRootSet);

    // 绑定 Skeleton
    USkeletalMesh* SkeletalMesh = SkeletalMeshComponent->GetSkeletalMeshAsset();
    AnimSeq->SetSkeleton(SkeletalMesh->GetSkeleton());

    // === 2. 获取 Controller(唯一修改接口)===
    IAnimationDataModel* Model = AnimSeq->GetDataModel();
    TScriptInterface<IAnimationDataController> Controller = Model->GetController();

    // === 3. 初始化模型(使用 FScopedBracket)===
    Controller->InitializeModel();
    IAnimationDataController::FScopedBracket ScopedBracket(Controller,
        LOCTEXT("RecordAnimation", "Recording Animation"));

    // === 4. 设置帧率和帧数 ===
    // 计算实际帧率(关键:不能用固定值)
    float RecordingDuration = GetWorld()->GetTimeSeconds() - RecordingStartTime;
    int32 NumFrames = TrackDataArray[0].PosKeys.Num();
    float ActualFrameRate = (float)NumFrames / RecordingDuration;
    ActualFrameRate = FMath::Clamp(ActualFrameRate, 24.0f, 120.0f);

    Controller->SetFrameRate(FFrameRate(FMath::RoundToInt(ActualFrameRate), 1));
    Controller->SetNumberOfFrames(NumFrames);

    // === 5. 添加骨骼轨道和关键帧 ===
    for (int32 BoneIndex = 0; BoneIndex < BoneNames.Num(); ++BoneIndex)
    {
        const FName& BoneName = BoneNames[BoneIndex];
        const FRawAnimSequenceTrack& Track = TrackDataArray[BoneIndex];

        // 5.1 添加骨骼曲线
        Controller->AddBoneCurve(BoneName);

        // 5.2 准备 Scale 数据(引擎要求 Pos/Rot/Scale 数量一致)
        TArray<FVector3f> ScaleKeys;
        ScaleKeys.Init(FVector3f(1.0f, 1.0f, 1.0f), Track.PosKeys.Num());

        // 5.3 设置关键帧
        Controller->SetBoneTrackKeys(BoneName, Track.PosKeys, Track.RotKeys, ScaleKeys);
    }

    // 5.4 通知数据填充完成(必须调用)
    Controller->NotifyPopulated();

    // === 6. 启用根骨骼运动(可选)===
    AnimSeq->bEnableRootMotion = true;
    AnimSeq->RootMotionRootLock = ERootMotionRootLock::RefPose;

    // === 7. 保存资产 ===
    FAssetRegistryModule::AssetCreated(AnimSeq);
    Package->MarkPackageDirty();

    FString PackageFileName = FPackageName::LongPackageNameToFilename(
        AssetPath, FPackageName::GetAssetPackageExtension());
    FSavePackageArgs SaveArgs;
    bool bSaved = UPackage::SavePackage(Package, AnimSeq, *PackageFileName, SaveArgs);
}

API Comparison: UE4 vs. UE5

FunctionUE4 (deprecated)UE5 (standard)
Add tracksAddNewRawTrack()Controller->AddBoneCurve()
Set keyframesModify RawAnimationData directlyController->SetBoneTrackKeys()
Scope managementOpenBracket() / CloseBracket()FScopedBracket (RAII)
Completion notificationNoneController->NotifyPopulated()

Key Steps Explained

Step 3: Why Use FScopedBracket?
// ❌ UE4 旧方式(容易忘记 CloseBracket)
Controller->OpenBracket(LOCTEXT("AddTracks", "Adding Tracks"));
// ... 添加数据
Controller->CloseBracket();  // 如果中途 return 会泄漏

// ✅ UE5 标准方式(RAII 自动管理)
{
    IAnimationDataController::FScopedBracket ScopedBracket(Controller, LOCTEXT(...));
    // ... 添加数据
}  // 离开作用域自动调用 CloseBracket
Step 4: Why Can’t We Use a Fixed Frame Rate?
// ❌ 错误:固定 30fps
Controller->SetFrameRate(FFrameRate(30, 1));
// 如果游戏运行在 60fps,录制的动画会以 2 倍速播放!

// ✅ 正确:动态计算
float ActualFrameRate = (float)NumFrames / RecordingDuration;
Controller->SetFrameRate(FFrameRate(FMath::RoundToInt(ActualFrameRate), 1));
Step 5.4: What NotifyPopulated() Does
Controller->NotifyPopulated();  // ← 必须调用!

Purpose:

  • Notify the engine that data population is complete
  • Trigger internal cache updates
  • Validate data integrity
  • The animation will not play if this is not called

3. Complete Code Example

Header File (TestActor.h)

#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "TestActor.generated.h"

UCLASS()
class TEST03_API ATestActor : public AActor
{
    GENERATED_BODY()

public:
    UFUNCTION(BlueprintCallable)
    void StartRecording(int _FrameSize, ACharacter* _TargetActor = nullptr);

    UFUNCTION(BlueprintCallable)
    void EndRecording();

private:
    virtual void Tick(float DeltaTime) override;
    void SpawnAnimAsset();

    UPROPERTY()
    ACharacter* TargetActor;

    UPROPERTY()
    USkeletalMeshComponent* SkeletalMeshComponent;

    TArray<FName> BoneNames;
    TArray<FRawAnimSequenceTrack> TrackDataArray;

    bool IsRecording = false;
    float RecordingStartTime;
    FTransform InitialActorTransform;
};

Recording Logic in Tick

void ATestActor::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    if (IsRecording)
    {
        const TArray<FTransform>& BoneSpaceTransforms = SkeletalMeshComponent->GetBoneSpaceTransforms();

        // 计算相对位移(用于根骨骼运动)
        FTransform CurrentActorTransform = TargetActor->GetActorTransform();
        FTransform RelativeActorTransform = CurrentActorTransform.GetRelativeTransform(InitialActorTransform);

        for (int i = 0; i < BoneNames.Num(); ++i)
        {
            FTransform FinalBoneTransform;

            if (i == 0)  // 根骨骼
            {
                // 烘焙世界位移到根骨骼
                FinalBoneTransform = BoneSpaceTransforms[0] * RelativeActorTransform;
            }
            else
            {
                FinalBoneTransform = BoneSpaceTransforms[i];
            }

            TrackDataArray[i].PosKeys.Add(FVector3f(FinalBoneTransform.GetLocation()));
            TrackDataArray[i].RotKeys.Add(FQuat4f(FinalBoneTransform.GetRotation()));
            TrackDataArray[i].ScaleKeys.Add(FVector3f(FinalBoneTransform.GetScale3D()));
        }
    }
}

4. Summary of Core Technical Points

Obtaining Bone Data (3 Requirements)

StepAPIConsequence of getting it wrong
1. Data sourceSkeletalMesh->GetRefSkeleton()Using Skeleton includes virtual bones
2. Bone countRefSkeleton.GetRawBoneNum()Using GetNum() causes an out-of-bounds access
3. ValidationBoneSpaceTransforms.Num() >= NumBonesFailing to check causes crashes

Saving an AnimSequence (5 Key Points)

StepAPIPurpose
1. Get the ControllerModel->GetController()The only modification interface
2. Scope managementFScopedBracketAutomatic RAII cleanup
3. Add tracksAddBoneCurve()Standard UE5 API
4. Set dataSetBoneTrackKeys()Pos/Rot/Scale counts must match
5. Completion notificationNotifyPopulated()Must be called

5. Debugging Checklist

Checks Before Recording

UE_LOG(LogTemp, Warning, TEXT("=== 骨骼数据诊断 ==="));
UE_LOG(LogTemp, Warning, TEXT("RefSkeleton.GetRawBoneNum() = %d"), RefSkeleton.GetRawBoneNum());
UE_LOG(LogTemp, Warning, TEXT("RefSkeleton.GetNum() = %d"), RefSkeleton.GetNum());
UE_LOG(LogTemp, Warning, TEXT("BoneSpaceTransforms.Num() = %d"), BoneSpaceTransforms.Num());

// 正确结果:RawBoneNum == BoneSpaceTransforms.Num()

Checks Before Saving

UE_LOG(LogTemp, Warning, TEXT("生成动画: 骨骼=%d, 帧数=%d, 帧率=%.2f fps"),
    BoneNames.Num(), NumFrames, ActualFrameRate);

// 检查数据完整性
for (int32 i = 0; i < BoneNames.Num(); ++i)
{
    if (TrackDataArray[i].PosKeys.Num() != NumFrames)
    {
        UE_LOG(LogTemp, Error, TEXT("骨骼 %s 数据不完整"), *BoneNames[i].ToString());
    }
}

6. Engine Source Reference Locations

Key Functions in AnimSequence.cpp

FunctionLinesPurpose
CreateAnimation(SkeletalMeshComponent*)2659-2692Standard bone data retrieval
CreateAnimation(SkeletalMesh*)2630-2657Uses the reference pose
GetBonePose()1598-1715Data extraction during playback

How to view it:

文件路径:Engine/Source/Runtime/Engine/Private/Animation/AnimSequence.cpp
关键行:const int32 NumBones = RefSkeleton.GetRawBoneNum();  // 第 2674 行

Conclusion

Runtime animation recording in UE5.4 comes down to:

  1. Obtaining correct bone data:
    • Use SkeletalMesh->GetRefSkeleton() rather than the Skeleton’s
    • Use GetRawBoneNum() rather than GetNum()
    • You must validate BoneSpaceTransforms.Num() >= NumBones
  2. Saving the AnimSequence correctly:
    • Operate through the IAnimationDataController interface
    • Use FScopedBracket to manage the scope
    • Call AddBoneCurve() rather than AddBoneTrack()
    • You must call NotifyPopulated()

Following these principles lets you implement a stable, reliable animation recording system.


Engine version: Unreal Engine 5.4
Source reference: AnimSequence.cpp (lines 2630–2692)


Thanks for reading. Feel free to like, follow, or bookmark this article, and discuss any questions in the comments.
If this article helped you, click here to donate and support the author.