21. 애니메이션기반 관절 움직임 추출 구현(1)
- date
- 4월 10, 2026
- status
- 완료
- tags
- unrealRL
1. 소개#
앞 단계에서는 기존 애니메이션을 강화학습 기준 데이터로 사용하기 위해 어떤 데이터를 추출해야 하는지 정리하였다.
이번 단계에서는 실제로 Unreal C++ 클래스를 생성하고, Editor Utility에서 실행할 수 있는 애니메이션 Motion Data 추출 함수를 구현해보자
이번 구현의 목표는 다음과 같다.
A_ISO_Mech_Idle_01
→ 기준 자세 데이터 추출
A_ISO_Mech_WalkForward_01
→ 전진 보행 중 Body / Joint / Foot Motion Data 추출
추출 결과
→ Saved/SpiderAnimExtract/walk_forward_motion.json 저장움직임을 데이터로도 추정할 수있는 데이터를 그룹별로 나누어서 저장해보도록 해보기로 하였다
| 그룹 | 대상 | 목적 |
|---|---|---|
| Body | Root_M, Top_M | 몸체 위치, 높이, 기울기 분석 |
| Joint | 24개 다리 관절 | 관절 목표값, ActionScale 후보 분석 |
| Foot | 6개 발 끝 Bone | 발 궤적, 발 높이, Swing/Stance 분석 |
| Phase | time, phase | 보행 주기 기준 생성 |
| Summary | Min / Max / Dominant Axis | DA_SpiderJointConfig 반영 후보 |
2. 모션 추출 블루프린트 클래스 생성#
2.1. 새 C++ 클래스 생성#
Unreal Editor에서 새 C++ 클래스를 생성해주자(상단 메뉴→ 툴→ 새 C++ 클래스)
부모 클래스는 다음으로 선택한다.
Blueprint Function Library클래스 이름은 다음과 같이 지정해주자(SpiderAnimationMotionDataLibrary)
생성 후 다음 파일이 만들어진다.
Source/SpiderRL/SpiderAnimationMotionDataLibrary.h Source/SpiderRL/SpiderAnimationMotionDataLibrary.cpp이 클래스는 게임 실행 중 사용하는 클래스가 아니라, 에디터에서 애니메이션 데이터를 추출하기 위한 유틸리티 함수 모음이다.
2.2. Build.cs 모듈 설정#
JSON 파일을 생성해야 하므로 SpiderRL.Build.cs에 Json 모듈을 추가해주자
SpiderRL.Build.cs는 다음과 같이 수정해주자
// Fill out your copyright notice in the Description page of Project Settings.
using UnrealBuildTool;
public class SpiderRL : ModuleRules
{
public SpiderRL(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[] {
"Core",
"CoreUObject",
"Engine",
"InputCore",
"Json"
});
PrivateDependencyModuleNames.AddRange(new string[] { });
}
}Json 모듈은 FJsonObject, FJsonSerializer, TJsonWriter 등을 사용하기 위해 필요하다.
2.3. Header 파일 작성#
SpiderAnimationMotionDataLibrary.h 파일을 열고 다음 코드로 교체해주자
SpiderAnimationMotionDataLibrary.h
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "Kismet/BlueprintFunctionLibrary.h"
#include "Animation/AnimSequence.h"
#include "SpiderAnimationMotionDataLibrary.generated.h"
UCLASS()
class SPIDERRL_API USpiderAnimationMotionDataLibrary : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable, CallInEditor, Category = "Spider|Animation Extraction")
static bool ExportSpiderAnimationMotionData(
UAnimSequence* IdleAnimation,
UAnimSequence* TargetAnimation,
const FString& OutputRelativePath,
int32 SampleCount
);
};함수 입력값의 의미는 다음과 같다.
| 입력값 | 의미 |
|---|---|
IdleAnimation | 기준 자세로 사용할 Idle 애니메이션 |
TargetAnimation | 샘플링할 대상 애니메이션 |
OutputRelativePath | Saved 폴더 기준 JSON 저장 경로 |
SampleCount | 애니메이션을 나눌 샘플 개수 |
2.4. CPP 파일 작성#
SpiderAnimationMotionDataLibrary.cpp 파일을 열고 다음 코드로 교체해주자
SpiderAnimationMotionDataLibrary.cpp
// Fill out your copyright notice in the Description page of Project Settings.
#include "SpiderAnimationMotionDataLibrary.h"
#include "AnimationRuntime.h"
#include "Animation/AnimSequence.h"
#include "Animation/Skeleton.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "Serialization/JsonSerializer.h"
#include "Serialization/JsonWriter.h"
namespace SpiderMotionExtract
{
static TSharedRef<FJsonObject> VectorToJson(const FVector& Value)
{
TSharedRef<FJsonObject> Object = MakeShared<FJsonObject>();
Object->SetNumberField(TEXT("x"), Value.X);
Object->SetNumberField(TEXT("y"), Value.Y);
Object->SetNumberField(TEXT("z"), Value.Z);
return Object;
}
static TSharedRef<FJsonObject> RotatorToJson(const FRotator& Value)
{
TSharedRef<FJsonObject> Object = MakeShared<FJsonObject>();
Object->SetNumberField(TEXT("roll"), Value.Roll);
Object->SetNumberField(TEXT("pitch"), Value.Pitch);
Object->SetNumberField(TEXT("yaw"), Value.Yaw);
return Object;
}
static TSharedRef<FJsonObject> TransformToJson(const FTransform& Value)
{
TSharedRef<FJsonObject> Object = MakeShared<FJsonObject>();
Object->SetObjectField(TEXT("location"), VectorToJson(Value.GetLocation()));
Object->SetObjectField(TEXT("rotation"), RotatorToJson(Value.Rotator()));
Object->SetObjectField(TEXT("scale"), VectorToJson(Value.GetScale3D()));
return Object;
}
static FRotator DeltaRotator(const FTransform& Target, const FTransform& Base)
{
const FQuat DeltaQuat = Target.GetRotation() * Base.GetRotation().Inverse();
return DeltaQuat.Rotator().GetNormalized();
}
static FVector DeltaLocation(const FTransform& Target, const FTransform& Base)
{
return Target.GetLocation() - Base.GetLocation();
}
static TSharedRef<FJsonObject> DeltaTransformToJson(const FTransform& Target, const FTransform& Base)
{
TSharedRef<FJsonObject> Object = MakeShared<FJsonObject>();
Object->SetObjectField(TEXT("location"), VectorToJson(DeltaLocation(Target, Base)));
Object->SetObjectField(TEXT("rotation"), RotatorToJson(DeltaRotator(Target, Base)));
return Object;
}
static TArray<TSharedPtr<FJsonValue>> StringArrayToJsonArray(const TArray<FString>& Values)
{
TArray<TSharedPtr<FJsonValue>> Result;
for (const FString& Value : Values)
{
Result.Add(MakeShared<FJsonValueString>(Value));
}
return Result;
}
static void InitPoseFromRefSkeleton(const FReferenceSkeleton& RefSkeleton, TArray<FTransform>& OutPose)
{
OutPose.Empty();
OutPose.Reserve(RefSkeleton.GetNum());
for (int32 BoneIndex = 0; BoneIndex < RefSkeleton.GetNum(); ++BoneIndex)
{
OutPose.Add(RefSkeleton.GetRefBonePose()[BoneIndex]);
}
}
static bool ExtractLocalPoseAtTime(
UAnimSequence* Animation,
const FReferenceSkeleton& RefSkeleton,
float Time,
TArray<FTransform>& OutLocalPose
)
{
if (!Animation)
{
return false;
}
InitPoseFromRefSkeleton(RefSkeleton, OutLocalPose);
FAnimExtractContext ExtractContext(Time, false);
Animation->GetAnimationPose(OutLocalPose, ExtractContext);
return OutLocalPose.Num() == RefSkeleton.GetNum();
}
static FTransform GetLocalTransformByName(
const FString& BoneName,
const FReferenceSkeleton& RefSkeleton,
const TArray<FTransform>& LocalPose
)
{
const int32 BoneIndex = RefSkeleton.FindBoneIndex(FName(*BoneName));
if (BoneIndex == INDEX_NONE || !LocalPose.IsValidIndex(BoneIndex))
{
return FTransform::Identity;
}
return LocalPose[BoneIndex];
}
static FTransform BuildComponentTransformByName(
const FString& BoneName,
const FReferenceSkeleton& RefSkeleton,
const TArray<FTransform>& LocalPose
)
{
const int32 BoneIndex = RefSkeleton.FindBoneIndex(FName(*BoneName));
if (BoneIndex == INDEX_NONE)
{
return FTransform::Identity;
}
TArray<int32> Chain;
int32 CurrentIndex = BoneIndex;
while (CurrentIndex != INDEX_NONE)
{
Chain.Add(CurrentIndex);
CurrentIndex = RefSkeleton.GetParentIndex(CurrentIndex);
}
Algo::Reverse(Chain);
FTransform ComponentTransform = FTransform::Identity;
for (const int32 ChainIndex : Chain)
{
if (LocalPose.IsValidIndex(ChainIndex))
{
ComponentTransform = LocalPose[ChainIndex] * ComponentTransform;
}
}
return ComponentTransform;
}
static void UpdateMinMaxVector(FVector& MinValue, FVector& MaxValue, const FVector& Value)
{
MinValue.X = FMath::Min(MinValue.X, Value.X);
MinValue.Y = FMath::Min(MinValue.Y, Value.Y);
MinValue.Z = FMath::Min(MinValue.Z, Value.Z);
MaxValue.X = FMath::Max(MaxValue.X, Value.X);
MaxValue.Y = FMath::Max(MaxValue.Y, Value.Y);
MaxValue.Z = FMath::Max(MaxValue.Z, Value.Z);
}
static void UpdateMinMaxRotator(FRotator& MinValue, FRotator& MaxValue, const FRotator& Value)
{
MinValue.Roll = FMath::Min(MinValue.Roll, Value.Roll);
MinValue.Pitch = FMath::Min(MinValue.Pitch, Value.Pitch);
MinValue.Yaw = FMath::Min(MinValue.Yaw, Value.Yaw);
MaxValue.Roll = FMath::Max(MaxValue.Roll, Value.Roll);
MaxValue.Pitch = FMath::Max(MaxValue.Pitch, Value.Pitch);
MaxValue.Yaw = FMath::Max(MaxValue.Yaw, Value.Yaw);
}
static FString DominantAxisFromRotatorRange(const FRotator& MinValue, const FRotator& MaxValue)
{
const float RollRange = FMath::Abs(MaxValue.Roll - MinValue.Roll);
const float PitchRange = FMath::Abs(MaxValue.Pitch - MinValue.Pitch);
const float YawRange = FMath::Abs(MaxValue.Yaw - MinValue.Yaw);
if (PitchRange >= RollRange && PitchRange >= YawRange)
{
return TEXT("pitch");
}
if (YawRange >= RollRange && YawRange >= PitchRange)
{
return TEXT("yaw");
}
return TEXT("roll");
}
}
bool USpiderAnimationMotionDataLibrary::ExportSpiderAnimationMotionData(
UAnimSequence* IdleAnimation,
UAnimSequence* TargetAnimation,
const FString& OutputRelativePath,
int32 SampleCount
)
{
using namespace SpiderMotionExtract;
if (!IdleAnimation)
{
UE_LOG(LogTemp, Error, TEXT("[SpiderMotionExtract] IdleAnimation is null."));
return false;
}
if (!TargetAnimation)
{
UE_LOG(LogTemp, Error, TEXT("[SpiderMotionExtract] TargetAnimation is null."));
return false;
}
if (SampleCount <= 0)
{
UE_LOG(LogTemp, Error, TEXT("[SpiderMotionExtract] SampleCount must be greater than 0."));
return false;
}
USkeleton* Skeleton = TargetAnimation->GetSkeleton();
if (!Skeleton)
{
UE_LOG(LogTemp, Error, TEXT("[SpiderMotionExtract] TargetAnimation has no skeleton."));
return false;
}
if (IdleAnimation->GetSkeleton() != Skeleton)
{
UE_LOG(LogTemp, Error, TEXT("[SpiderMotionExtract] IdleAnimation and TargetAnimation use different skeletons."));
return false;
}
const FReferenceSkeleton& RefSkeleton = Skeleton->GetReferenceSkeleton();
const TArray<FString> BodyBones = {
TEXT("Root_M"),
TEXT("Top_M")
};
const TArray<FString> JointBones = {
TEXT("FrontLeg1_L"),
TEXT("FrontLeg2_L"),
TEXT("FrontLeg3_L"),
TEXT("FrontLeg4_L"),
TEXT("MiddleLeg1_L"),
TEXT("MiddleLeg2_L"),
TEXT("MiddleLeg3_L"),
TEXT("MiddleLeg4_L"),
TEXT("BackLeg1_L"),
TEXT("BackLeg2_L"),
TEXT("BackLeg3_L"),
TEXT("BackLeg4_L"),
TEXT("FrontLeg1_R"),
TEXT("FrontLeg2_R"),
TEXT("FrontLeg3_R"),
TEXT("FrontLeg4_R"),
TEXT("MiddleLeg1_R"),
TEXT("MiddleLeg2_R"),
TEXT("MiddleLeg3_R"),
TEXT("MiddleLeg4_R"),
TEXT("BackLeg1_R"),
TEXT("BackLeg2_R"),
TEXT("BackLeg3_R"),
TEXT("BackLeg4_R")
};
const TArray<FString> FootBones = {
TEXT("FrontLeg4_L"),
TEXT("MiddleLeg4_L"),
TEXT("BackLeg4_L"),
TEXT("FrontLeg4_R"),
TEXT("MiddleLeg4_R"),
TEXT("BackLeg4_R")
};
TArray<FTransform> IdleLocalPose;
if (!ExtractLocalPoseAtTime(IdleAnimation, RefSkeleton, 0.0f, IdleLocalPose))
{
UE_LOG(LogTemp, Error, TEXT("[SpiderMotionExtract] Failed to extract idle pose."));
return false;
}
TMap<FString, FRotator> JointMinDeltaRotation;
TMap<FString, FRotator> JointMaxDeltaRotation;
TMap<FString, FVector> JointMinDeltaLocation;
TMap<FString, FVector> JointMaxDeltaLocation;
for (const FString& JointBone : JointBones)
{
JointMinDeltaRotation.Add(JointBone, FRotator(999999.0, 999999.0, 999999.0));
JointMaxDeltaRotation.Add(JointBone, FRotator(-999999.0, -999999.0, -999999.0));
JointMinDeltaLocation.Add(JointBone, FVector(999999.0, 999999.0, 999999.0));
JointMaxDeltaLocation.Add(JointBone, FVector(-999999.0, -999999.0, -999999.0));
}
TMap<FString, float> FootMinHeight;
TMap<FString, float> FootMaxHeight;
TMap<FString, FVector> PreviousFootLocation;
for (const FString& FootBone : FootBones)
{
FootMinHeight.Add(FootBone, TNumericLimits<float>::Max());
FootMaxHeight.Add(FootBone, TNumericLimits<float>::Lowest());
PreviousFootLocation.Add(FootBone, FVector::ZeroVector);
}
const float AnimationLength = TargetAnimation->GetPlayLength();
TSharedRef<FJsonObject> RootObject = MakeShared<FJsonObject>();
TSharedRef<FJsonObject> MetaObject = MakeShared<FJsonObject>();
MetaObject->SetStringField(TEXT("idle_animation"), IdleAnimation->GetName());
MetaObject->SetStringField(TEXT("target_animation"), TargetAnimation->GetName());
MetaObject->SetNumberField(TEXT("sample_count"), SampleCount);
MetaObject->SetNumberField(TEXT("animation_length"), AnimationLength);
RootObject->SetObjectField(TEXT("meta"), MetaObject);
TSharedRef<FJsonObject> BoneGroupsObject = MakeShared<FJsonObject>();
BoneGroupsObject->SetArrayField(TEXT("body"), StringArrayToJsonArray(BodyBones));
BoneGroupsObject->SetArrayField(TEXT("joints"), StringArrayToJsonArray(JointBones));
BoneGroupsObject->SetArrayField(TEXT("feet"), StringArrayToJsonArray(FootBones));
RootObject->SetObjectField(TEXT("bone_groups"), BoneGroupsObject);
TArray<TSharedPtr<FJsonValue>> SamplesArray;
for (int32 SampleIndex = 0; SampleIndex < SampleCount; ++SampleIndex)
{
const float Phase = SampleCount == 1
? 0.0f
: static_cast<float>(SampleIndex) / static_cast<float>(SampleCount - 1);
const float SampleTime = AnimationLength * Phase;
TArray<FTransform> TargetLocalPose;
if (!ExtractLocalPoseAtTime(TargetAnimation, RefSkeleton, SampleTime, TargetLocalPose))
{
UE_LOG(LogTemp, Error, TEXT("[SpiderMotionExtract] Failed to extract target pose. Time=%.3f"), SampleTime);
return false;
}
TSharedRef<FJsonObject> SampleObject = MakeShared<FJsonObject>();
SampleObject->SetNumberField(TEXT("sample_index"), SampleIndex);
SampleObject->SetNumberField(TEXT("time"), SampleTime);
SampleObject->SetNumberField(TEXT("phase"), Phase);
TSharedRef<FJsonObject> BodyObject = MakeShared<FJsonObject>();
for (const FString& BodyBone : BodyBones)
{
const FTransform TargetLocal = GetLocalTransformByName(BodyBone, RefSkeleton, TargetLocalPose);
const FTransform IdleLocal = GetLocalTransformByName(BodyBone, RefSkeleton, IdleLocalPose);
const FTransform TargetComponent = BuildComponentTransformByName(BodyBone, RefSkeleton, TargetLocalPose);
const FTransform IdleComponent = BuildComponentTransformByName(BodyBone, RefSkeleton, IdleLocalPose);
TSharedRef<FJsonObject> BoneObject = MakeShared<FJsonObject>();
BoneObject->SetObjectField(TEXT("local"), TransformToJson(TargetLocal));
BoneObject->SetObjectField(TEXT("component"), TransformToJson(TargetComponent));
BoneObject->SetObjectField(TEXT("delta_from_idle_local"), DeltaTransformToJson(TargetLocal, IdleLocal));
BoneObject->SetObjectField(TEXT("delta_from_idle_component"), DeltaTransformToJson(TargetComponent, IdleComponent));
BoneObject->SetNumberField(TEXT("height"), TargetComponent.GetLocation().Z);
BodyObject->SetObjectField(BodyBone, BoneObject);
}
SampleObject->SetObjectField(TEXT("body"), BodyObject);
TSharedRef<FJsonObject> JointsObject = MakeShared<FJsonObject>();
for (const FString& JointBone : JointBones)
{
const FTransform TargetLocal = GetLocalTransformByName(JointBone, RefSkeleton, TargetLocalPose);
const FTransform IdleLocal = GetLocalTransformByName(JointBone, RefSkeleton, IdleLocalPose);
const FTransform TargetComponent = BuildComponentTransformByName(JointBone, RefSkeleton, TargetLocalPose);
const FTransform IdleComponent = BuildComponentTransformByName(JointBone, RefSkeleton, IdleLocalPose);
const FVector DeltaLocalLocation = DeltaLocation(TargetLocal, IdleLocal);
const FRotator DeltaLocalRotation = DeltaRotator(TargetLocal, IdleLocal);
UpdateMinMaxVector(JointMinDeltaLocation[JointBone], JointMaxDeltaLocation[JointBone], DeltaLocalLocation);
UpdateMinMaxRotator(JointMinDeltaRotation[JointBone], JointMaxDeltaRotation[JointBone], DeltaLocalRotation);
TSharedRef<FJsonObject> BoneObject = MakeShared<FJsonObject>();
BoneObject->SetObjectField(TEXT("local"), TransformToJson(TargetLocal));
BoneObject->SetObjectField(TEXT("component"), TransformToJson(TargetComponent));
BoneObject->SetObjectField(TEXT("delta_from_idle_local"), DeltaTransformToJson(TargetLocal, IdleLocal));
BoneObject->SetObjectField(TEXT("delta_from_idle_component"), DeltaTransformToJson(TargetComponent, IdleComponent));
JointsObject->SetObjectField(JointBone, BoneObject);
}
SampleObject->SetObjectField(TEXT("joints"), JointsObject);
TSharedRef<FJsonObject> FeetObject = MakeShared<FJsonObject>();
for (const FString& FootBone : FootBones)
{
const FTransform TargetComponent = BuildComponentTransformByName(FootBone, RefSkeleton, TargetLocalPose);
const FTransform IdleComponent = BuildComponentTransformByName(FootBone, RefSkeleton, IdleLocalPose);
const FVector CurrentLocation = TargetComponent.GetLocation();
const FVector IdleLocation = IdleComponent.GetLocation();
const FVector DeltaFromIdleLocation = CurrentLocation - IdleLocation;
const float Height = CurrentLocation.Z;
FootMinHeight[FootBone] = FMath::Min(FootMinHeight[FootBone], Height);
FootMaxHeight[FootBone] = FMath::Max(FootMaxHeight[FootBone], Height);
FVector Velocity = FVector::ZeroVector;
if (SampleIndex > 0)
{
const float PreviousPhase = static_cast<float>(SampleIndex - 1) / static_cast<float>(SampleCount - 1);
const float PreviousTime = AnimationLength * PreviousPhase;
const float DeltaTime = FMath::Max(SampleTime - PreviousTime, KINDA_SMALL_NUMBER);
Velocity = (CurrentLocation - PreviousFootLocation[FootBone]) / DeltaTime;
}
PreviousFootLocation[FootBone] = CurrentLocation;
TSharedRef<FJsonObject> FootObject = MakeShared<FJsonObject>();
FootObject->SetObjectField(TEXT("component_location"), VectorToJson(CurrentLocation));
FootObject->SetObjectField(TEXT("delta_from_idle_location"), VectorToJson(DeltaFromIdleLocation));
FootObject->SetObjectField(TEXT("velocity"), VectorToJson(Velocity));
FootObject->SetNumberField(TEXT("height"), Height);
FeetObject->SetObjectField(FootBone, FootObject);
}
SampleObject->SetObjectField(TEXT("feet"), FeetObject);
SamplesArray.Add(MakeShared<FJsonValueObject>(SampleObject));
}
RootObject->SetArrayField(TEXT("samples"), SamplesArray);
TSharedRef<FJsonObject> SummaryObject = MakeShared<FJsonObject>();
TSharedRef<FJsonObject> JointSummaryObject = MakeShared<FJsonObject>();
for (const FString& JointBone : JointBones)
{
TSharedRef<FJsonObject> JointObject = MakeShared<FJsonObject>();
JointObject->SetStringField(
TEXT("dominant_rotation_axis"),
DominantAxisFromRotatorRange(JointMinDeltaRotation[JointBone], JointMaxDeltaRotation[JointBone])
);
JointObject->SetObjectField(TEXT("min_delta_rotation"), RotatorToJson(JointMinDeltaRotation[JointBone]));
JointObject->SetObjectField(TEXT("max_delta_rotation"), RotatorToJson(JointMaxDeltaRotation[JointBone]));
JointObject->SetObjectField(TEXT("min_delta_location"), VectorToJson(JointMinDeltaLocation[JointBone]));
JointObject->SetObjectField(TEXT("max_delta_location"), VectorToJson(JointMaxDeltaLocation[JointBone]));
JointSummaryObject->SetObjectField(JointBone, JointObject);
}
SummaryObject->SetObjectField(TEXT("joints"), JointSummaryObject);
TSharedRef<FJsonObject> FootSummaryObject = MakeShared<FJsonObject>();
for (const FString& FootBone : FootBones)
{
TSharedRef<FJsonObject> FootObject = MakeShared<FJsonObject>();
FootObject->SetNumberField(TEXT("min_height"), FootMinHeight[FootBone]);
FootObject->SetNumberField(TEXT("max_height"), FootMaxHeight[FootBone]);
FootObject->SetNumberField(TEXT("height_range"), FootMaxHeight[FootBone] - FootMinHeight[FootBone]);
FootSummaryObject->SetObjectField(FootBone, FootObject);
}
SummaryObject->SetObjectField(TEXT("feet"), FootSummaryObject);
RootObject->SetObjectField(TEXT("summary"), SummaryObject);
FString OutputString;
const TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&OutputString);
if (!FJsonSerializer::Serialize(RootObject, Writer))
{
UE_LOG(LogTemp, Error, TEXT("[SpiderMotionExtract] Failed to serialize JSON."));
return false;
}
const FString FinalOutputPath = FPaths::ProjectSavedDir() / OutputRelativePath;
const FString OutputDirectory = FPaths::GetPath(FinalOutputPath);
IFileManager::Get().MakeDirectory(*OutputDirectory, true);
if (!FFileHelper::SaveStringToFile(OutputString, *FinalOutputPath))
{
UE_LOG(LogTemp, Error, TEXT("[SpiderMotionExtract] Failed to save file: %s"), *FinalOutputPath);
return false;
}
UE_LOG(LogTemp, Display, TEXT("[SpiderMotionExtract] Exported motion data JSON: %s"), *FinalOutputPath);
return true;
}Live Coding으로 처리하면 새 Blueprint Function Library 노출이나 UHT 반영이 꼬일 수 있으니 언리얼 에디터를 끄고 빌드 후 다시 켜주자
2.5. Editor Utility Blueprint 생성#
Unreal Editor를 다시 실행한 뒤 Scripts 폴더에 Editor Utility Blueprint를 만들어주자
Content Browser
→ Content/Scripts
→ 우클릭
→ Editor Utilities
→ Editor Utility Blueprint부모 클래스는 다음으로 선택하자(EditorUtilityObject)
에셋 이름은 다음으로 지정한다.(EUO_ExportSpiderAnimationData)
이 Editor Utility는 게임 실행 중 사용하는 Blueprint가 아니라, 에디터에서 수동으로 애니메이션 데이터를 추출하기 위한 도구라고 생각하면 된다.
2.6. RunExport 함수 구성#
EUO_ExportSpiderAnimationData를 열고 새 함수를 만들어보자
Functions
→ + Function
→ 이름: RunExportRunExport 그래프에서 아까 추가했던 노드를 검색해서 연결해주자.

Export Spider Animation Motion Data노드를 추가한 뒤 입력값을 다음과 같이 설정한다.
| 입력핀 | 값 |
|---|---|
Idle Animation | A_ISO_Mech_Idle_01 |
Target Animation | A_ISO_Mech_WalkForward_01 |
Output Relative Path | SpiderAnimExtract/walk_forward_motion.json |
Sample Count | 20 |
Blueprint 연결은 다음과 같이 구성한다.
RunExport / 실행 출력핀
→ ExportSpiderAnimationMotionData / 실행 입력핀반환값은 Branch와 Print String으로 확인해보자
ExportSpiderAnimationMotionData / Return Value
→ Branch / Condition
2.7. Editor Utility 실행#
EUO_ExportSpiderAnimationData를 컴파일하고 저장한다.
이후 Content Browser에서 해당 에셋을 우클릭하여 실행해주자

실행 후 Output Log에서 다음 로그를 확인한다.
[SpiderMotionExtract] Exported motion data JSON: .../Saved/SpiderAnimExtract/walk_forward_motion.json결과 파일은 다음 위치에 생성된다.
Saved/SpiderAnimExtract/walk_forward_motion.json2.8. JSON 결과 확인#
생성된 JSON 파일을 열어 다음 항목이 포함되어 있는지 확인해보자
meta
bone_groups
samples
summarysamples 안에는 각 phase별로 다음 데이터가 들어 있어야 한다.
body
joints
feet확인할 핵심 항목은 다음과 같다.
| 확인 항목 | 기준 |
|---|---|
| Body 데이터 | Root_M, Top_M 출력 |
| Joint 데이터 | 24개 다리 관절 출력 |
| Foot 데이터 | 6개 발 끝 Bone 출력 |
| Sample 개수 | SampleCount = 20 기준 20개 샘플 출력 |
| Phase 값 | 0.0 ~ 1.0 범위 |
| Summary | 관절별 Min / Max Delta Rotation 출력 |
| Foot Summary | 발별 Min / Max Height 출력 |
이 결과를 통해 애니메이션 기반 보행 패턴을 숫자 데이터로 확인할 수 있다.
3. 추출된 모션 JSON 분석 방향#
3.1. JSON을 추출하는 이유#
앞 단계에서는 A_ISO_Mech_WalkForward_01 애니메이션을 샘플링하여 walk_forward_motion.json 파일로 저장했다.
이 JSON 파일을 생성하는 목적은 단순히 애니메이션 데이터를 외부로 저장하기 위해서가 아니다. 기존 애니메이션은 Unreal Editor 안에서는 눈으로 확인할 수 있는 동작이지만, 강화학습에서는 이 동작을 직접 이해할 수 없다.
따라서 애니메이션을 다음과 같은 수치 데이터로 변환할 필요가 있다.
어느 관절이 어떤 축으로 많이 움직이는가?
각 발은 어느 phase에서 들리고 내려가는가?
몸통은 보행 중 어느 정도 높이와 기울기를 가지는가?
기준 Idle 자세에서 각 관절이 얼마나 벗어나는가?
전진 보행에서 발끝의 높이 변화는 어느 정도인가?즉, JSON 추출의 궁극적인 목적은 애니메이션을 강화학습 설계에 사용할 수 있는 기준 데이터로 바꾸는 것이다.
3.2. 현재 JSON의 역할#
현재 생성된 walk_forward_motion.json은 최종 학습 데이터라기보다는 기준 모션 데이터에 가깝다.
이 파일에는 각 phase별로 다음 정보가 포함되어 있다.
body : Root_M, Top_M의 위치 / 회전 / 높이
joints : 24개 다리 관절의 local / component transform
feet : 6개 발끝 bone의 위치 / 속도 / 높이
summary: 관절별 회전 범위와 발 높이 범위이를 통해 기존 애니메이션의 보행 패턴을 수치적으로 확인할 수 있다.
예를 들어 발끝 bone의 height 값을 보면 해당 발이 어느 구간에서 들리는지 확인할 수 있고, 관절의 delta_from_idle_local.rotation 값을 보면 해당 관절이 Idle 기준에서 어느 축으로 많이 움직이는지 확인할 수 있다.
3.3. JSON을 바로 학습에 사용하는 것은 아닌 이유#
이번 단계에서 생성한 JSON은 학습 루프에서 매 step마다 직접 읽기 위한 파일은 아니다.
강화학습 학습 중에는 매 tick 또는 매 step마다 Observation, Action, Reward가 빠르게 처리되어야 한다. 이때 JSON 파일을 계속 읽거나 파싱하면 불필요한 비용이 커진다.
따라서 JSON은 다음과 같은 중간 분석 파일로 사용한다.
애니메이션
→ JSON 추출
→ 발 phase / 관절 축 / action scale 분석
→ Reward / Observation / DataAsset 설계에 반영즉, JSON은 학습용 대용량 로그가 아니라, 애니메이션을 강화학습 설계에 반영하기 위한 해석 자료이다.
3.4. 다음 분석 목표#
이제 walk_forward_motion.json에서 다음 정보를 계산해야 한다.
| 분석 항목 | 목적 |
|---|---|
| 발별 높이 변화 | 어느 발이 언제 들리는지 확인 |
| 발별 swing / stance phase | 발 접지 패턴 보상 설계 |
| Tripod gait 후보 | 6족 보행의 기본 보행 그룹 추정 |
| 관절별 주요 회전축 | Action 축 설정 또는 ActionScale 보정 |
| 관절별 회전 범위 | PPO가 탐색할 관절 범위 제한 |
| 몸통 높이 / 기울기 범위 | 자세 안정 보상 기준 설정 |
이 분석을 통해 단순히 전방 이동만 보상하는 방식에서 벗어나, 애니메이션 기반의 보행 패턴을 참고하는 보상 구조를 만들 수 있다.
3.5. 발 phase 분석#
먼저 발끝 bone 6개의 높이 변화를 분석한다.
대상 bone은 다음과 같다.
FrontLeg4_L
MiddleLeg4_L
BackLeg4_L
FrontLeg4_R
MiddleLeg4_R
BackLeg4_R각 발의 height 값을 phase별로 비교하면 발이 들리는 구간과 지면에 가까운 구간을 구분할 수 있다.
height가 낮은 구간 → stance phase
height가 높은 구간 → swing phase이 정보를 이용하면 다음과 같은 보상을 설계할 수 있다.
swing phase에서 발이 충분히 들리면 보상
stance phase에서 발이 지면 근처에 있으면 보상
stance phase에서 발이 과도하게 미끄러지면 패널티현재 PPO 학습에서는 전방 이동 보상만으로는 다리 움직임이 자연스러운 보행 패턴으로 정렬되기 어렵다. 따라서 발 phase 정보를 reward shaping에 활용하면 보행 안정성을 높일 수 있다.
3.6. Tripod gait 후보 계산#
6족 로봇의 보행에서는 일반적으로 서로 번갈아 움직이는 발 그룹이 중요하다.
예를 들어 다음과 같은 두 그룹을 만들 수 있다.
Tripod A:
FrontLeg4_L
MiddleLeg4_R
BackLeg4_L
Tripod B:
FrontLeg4_R
MiddleLeg4_L
BackLeg4_R하지만 이 그룹을 처음부터 고정하기보다는, 추출된 애니메이션 데이터에서 실제로 어느 발들이 비슷한 phase에서 들리는지 확인하는 것이 좋다.
따라서 각 발의 height peak phase를 계산하고, 비슷한 phase에서 높이가 증가하는 발들을 같은 그룹 후보로 묶는다.
이렇게 하면 애니메이션의 실제 보행 패턴을 기반으로 Tripod gait 후보를 얻을 수 있다.
3.7. 관절별 주요 회전축 분석#
다음으로 24개 관절의 회전 범위를 분석한다.
각 관절은 delta_from_idle_local.rotation 값을 가지고 있으며, 이 값은 Idle 자세를 기준으로 해당 관절이 얼마나 회전했는지를 나타낸다.
분석 기준은 다음과 같다.
Roll 변화량이 가장 크면 → roll 중심 관절
Pitch 변화량이 가장 크면 → pitch 중심 관절
Yaw 변화량이 가장 크면 → yaw 중심 관절이 정보는 DA_SpiderJointConfig의 ActionScale을 조정할 때 사용할 수 있다.
예를 들어 어떤 관절이 애니메이션에서 yaw 방향으로 크게 움직인다면, 해당 관절은 yaw 축을 중심으로 action scale을 잡는 것이 자연스럽다.
반대로 거의 움직이지 않는 축까지 크게 열어두면 PPO가 불필요한 방향으로 탐색하게 되므로 학습이 불안정해질 수 있다.
3.8. Reward 설계에 활용할 기준값#
분석된 JSON 데이터는 이후 Reward 설계에 다음과 같이 활용할 수 있다.
발 phase 기준 보상
관절 pose tracking 보상
몸통 자세 안정 보상
발 높이 보상
action smoothness 패널티예를 들어 보행 phase가 주어졌을 때, 현재 발의 상태가 기준 애니메이션과 비슷하면 보상을 줄 수 있다.
현재 phase에서 기준 애니메이션은 FrontLeg4_L이 swing 상태
→ 실제 로봇도 FrontLeg4_L을 들고 있으면 보상
→ 실제 로봇이 해당 발로 지면을 강하게 누르고 있으면 패널티또한 관절 pose tracking을 너무 강하게 주면 로봇이 애니메이션을 그대로 따라 하려고만 하고, 강화학습의 탐색 여지가 줄어들 수 있다.
따라서 애니메이션 데이터는 정답이라기보다는 보행을 유도하는 참고 기준으로 사용하는 것이 좋다.
3.9. 다음 구현 방향#
다음 단계에서는 walk_forward_motion.json을 읽고 분석 결과를 추가한 JSON 파일을 생성한다.
새 분석 파일은 다음과 같이 저장한다.
Saved/SpiderAnimExtract/walk_forward_motion_analyzed.json분석 결과에는 다음 항목을 추가할 예정이다.
{
"rl_analysis": {
"foot_phase": {},
"tripod_groups": {},
"joint_action_recommendation": {},
"reward_reference": {}
}
}이를 위해 Python 분석 스크립트를 작성한다.
analyze_spider_motion_json.py이 스크립트의 역할은 다음과 같다.
walk_forward_motion.json 읽기
발별 min / max height 계산
발별 swing / stance phase 계산
Tripod group 후보 계산
관절별 dominant axis 계산
관절별 action scale 후보 계산
walk_forward_motion_analyzed.json 저장이 과정을 거치면 이후 Reward와 Observation을 감으로 수정하는 것이 아니라, 기존 애니메이션에서 추출한 수치 데이터를 기반으로 설계할 수 있다.