1. 개요
오늘은 언리얼 엔진의 구조를 바탕으로 FBX에서 애니메이션 정보를 추출하고 애니메이션을 재생하는 과정에 대해서
알아보도록 하겠습니다.
FBX파일에는 애니메이션 정보가 포함되어 있습니다.
우리는 이 정보를 적절히 추출하고 데이터를 알맞은 형태로 저장한뒤 애니메이션의 각 프레임을 Interpolation하며
자연스러운 애니메이션을 띄워야하는 것이 목적입니다.
2. Unreal Engine에서의 구조
기본적인 애니메이션을 저장하는 틀은 Unreal Engine을 참고하여 제작하고 그 틀에 데이터를 저장하는 방식을 차용할것입니다.
우선 애니메이션에 대한 핵심정보를 저장하는 클래스는 바로 UAnimDataModel입니다.
해당 클래스에는 아래와 같은 정보가 담겨있습니다.
TArray<FBoneAnimationTrack> BoneAnimationTracks;
float PlayLength;
FFrameRate FrameRate;
int32 NumberOfFrames;
int32 NumberOfKeys;
FBoneAnimationTrack 구조체와 FRawAnimSequenceTrack에 대한 구조체입니다.
이 구조체들은 각 프레임의 Position, Rotation, Scale이 포함되어 있습니다.
struct FRawAnimSequenceTrack
{
TArray<FVector> PosKeys;
TArray<FQuat> RotKeys;
TArray<FVector> ScaleKeys;
};
struct FBoneAnimationTrack
{
FRawAnimSequenceTrack InternalTrackData;
int32 BoneTreeIndex = INDEX_NONE;
FName Name;
};
FBX Importer를 통해서 FBX파일에서 정보를 뽑아서 위의 구조체에 적절히 저장해주어야 합니다.
그리고 USkeletalMeshComponent가 AnimInstance를 가지고 있고
AnimInstance를 통해서 애니메이션을 다양하게 조작합니다.
3. FBX에서 정보 Import과정
우리에게 핵심적인 데이터는 UAnimDataModel입니다.
아래 코드는 UAnimDataModel구조체에 FBX 데이터들을 저장하는 과정입니다.
bool FFbxLoader::LoadFBXAnimationAsset(const FString& filePathName, UAnimDataModel* OutAnimDataModel)
{
if (filePathName.IsEmpty() || !OutAnimDataModel)
{
return false;
}
// FBX 씬 로드 - 기존 LoadFBXSkeletalMeshAsset 코드 재활용
FbxManager* manager = FbxManager::Create();
FbxIOSettings* ios = FbxIOSettings::Create(manager, IOSROOT);
manager->SetIOSettings(ios);
FbxImporter* importer = FbxImporter::Create(manager, "");
if (!importer->Initialize(*filePathName, -1, manager->GetIOSettings()))
{
importer->Destroy();
manager->Destroy();
return false;
}
FbxScene* scene = FbxScene::Create(manager, "AnimationScene");
importer->Import(scene);
importer->Destroy();
// 애니메이션 스택(Take) 가져오기
FbxAnimStack* AnimStack = scene->GetCurrentAnimationStack();
if (!AnimStack)
{
UE_LOG(LogLevel::Warning, TEXT("애니메이션 스택이 없습니다."));
scene->Destroy();
manager->Destroy();
return false;
}
// 애니메이션 시간 범위 가져오기
FbxTimeSpan TimeSpan = AnimStack->GetLocalTimeSpan();
FbxTime Start = TimeSpan.GetStart();
FbxTime End = TimeSpan.GetStop();
// 프레임 레이트 설정
FbxGlobalSettings& GlobalSettings = scene->GetGlobalSettings();
FbxTime::EMode TimeMode = GlobalSettings.GetTimeMode();
double FrameRate = FbxTime::GetFrameRate(TimeMode);
// 애니메이션 정보 설정
OutAnimDataModel->SetPlayLength((float)(End - Start).GetSecondDouble());
OutAnimDataModel->SetFrameRate(FFrameRate(FrameRate, 1.0));
OutAnimDataModel->SetNumberOfFrames((int32)(End - Start).GetFrameCount(TimeMode));
OutAnimDataModel->SetNumberOfKeys(OutAnimDataModel->GetNumberOfKeys());
// 본 목록 수집
TArray<FbxNode*> BoneNodes;
CollectSkeletonNodesRecursive(scene->GetRootNode(), BoneNodes);
if (BoneNodes.Num() == 0)
{
UE_LOG(LogLevel::Warning, TEXT("스켈레톤 노드가 없습니다: %s"), *filePathName);
scene->Destroy();
manager->Destroy();
return false;
}
// 각 본에 대한 애니메이션 트랙 추출
for (FbxNode* BoneNode : BoneNodes)
{
FBoneAnimationTrack BoneTrack;
BoneTrack.Name = FName(BoneNode->GetName());
// 키프레임 데이터 공간 할당
BoneTrack.InternalTrackData.PosKeys.SetNum(OutAnimDataModel->GetNumberOfFrames());
BoneTrack.InternalTrackData.RotKeys.SetNum(OutAnimDataModel->GetNumberOfFrames());
BoneTrack.InternalTrackData.ScaleKeys.SetNum(OutAnimDataModel->GetNumberOfFrames());
// 프레임 간격 계산
FbxTime FrameTime;
FrameTime.SetSecondDouble(1.0 / FrameRate); // 한 프레임당 시간 계산
// 각 프레임에 대한 트랜스폼 추출
for (int32 FrameIndex = 0; FrameIndex < OutAnimDataModel->GetNumberOfFrames(); ++FrameIndex)
{
// 현재 프레임 시간 계산
FbxTime CurrentTime = Start + FrameTime * FrameIndex;
// 글로벌 트랜스폼 가져오기
FbxAMatrix GlobalTransform = BoneNode->EvaluateGlobalTransform(CurrentTime);
// 로컬 트랜스폼 계산 (부모가 있는 경우)
FbxAMatrix LocalTransform;
if (BoneNode->GetParent())
{
FbxAMatrix ParentGlobal = BoneNode->GetParent()->EvaluateGlobalTransform(CurrentTime);
LocalTransform = ParentGlobal.Inverse() * GlobalTransform;
}
else
{
LocalTransform = GlobalTransform;
}
// FBX 행렬에서 위치, 회전, 스케일 추출
FMatrix LocalTransforMyMatrix = FMatrix::FromFbxMatrix(LocalTransform);
// 키프레임 데이터 설정
BoneTrack.InternalTrackData.PosKeys[FrameIndex] = LocalTransforMyMatrix.GetTranslationVector();
BoneTrack.InternalTrackData.RotKeys[FrameIndex] = LocalTransforMyMatrix.GetMatrixWithoutScale().ToQuat();
BoneTrack.InternalTrackData.ScaleKeys[FrameIndex] = LocalTransforMyMatrix.GetScaleVector();
}
// 애니메이션 트랙 추가
OutAnimDataModel->AddBoneTrack(BoneTrack);
}
// FBX 객체 정리
scene->Destroy();
manager->Destroy();
return OutAnimDataModel->GetBoneAnimationTracks().Num() > 0;
}
지금은 해결한 부분이지만 원래 개발했던 FQuat가 w,x,y,z의 순서로 되어있는것을 인지 하지 못했습니다.
그래서 FBX에서 뽑아온 Quaternion값을 0,1,2,3의 순서대로 차례대로 넣어주니
기괴한 형태의 애니메이션이 재생된 경험이있습니다.
아래와 같이 회전이 뒤틀려서 기괴한 형태의 Mesh가 생성됩니다.
FBX에서 뽑은 Quternion은 순서는 x,y,z,w임으로 이것을 참고하시길 바랍니다.
이렇게 얻은 UAnimDataModel을 AnimSequence에 넣어주고 그렇게 만들어진 AnimSequence를
AnimInstance가 가지고있는 형태로 만들어주면 애니메이션을 재생할 준비가 끝나게 됩니다.
4. 애니메이션 Interpolation 과정
USkeletalMeshComponent의 Component Tick에서 AnimInstnace역시 함께 Update됩니다.
그리고 AnimInstance가 들고있는 UAnimSequence역시 매 프레임 업데이트 됩니다.
아래는 보간을 통해서 현재 포즈를 구하는 과정입니다.
간략하게 설명하자면 LocalTime이라고 하는 애니메이션의 진행도를 담고있는 변수가 있습니다.
이 변수를 통해서 LocalTime이 어느 키프레임의 사이에 있는지 계산합니다.
그리고 두 키프레임 사이의 위치, 회전, 스케일값을 읽어서 적절하게 보간합니다.
주의 깊게 보아야할 점은 위치, 스케일은 Linear Interpolation, 회전은 Spherical linear interpolation를
이용하는 차이가 있습니다.
이렇게 보간되어서 결정된 Bone의 위치를 기반으로 Skinning을 진행하여 자연스러운 애니메이션을 표현하게 됩니다.
void UAnimSequence::GetAnimationPose(USkeletalMesh* SkeletalMesh,
TArray<FBonePose>& OutBoneTransforms) const
{
if (!SkeletalMesh)
{
OutBoneTransforms.Empty();
return;
}
const FSkeleton* Skeleton = SkeletalMesh->GetSkeleton();
int32 BoneCount = Skeleton->BoneCount;
FSkeletonPose* SkeletonPose = SkeletalMesh->GetSkeletonPose();
OutBoneTransforms = SkeletonPose->LocalTransforms;
const TArray<FBoneAnimationTrack>& Tracks = GetDataModel()->GetBoneAnimationTracks();
float PlayLength = GetUnScaledPlayLength();
int32 NumFrames = GetNumberOfFrames();
float NormalizedTime = FMath::Clamp(LocalTime / PlayLength, 0.0f, 1.0f);
float FrameTime = NormalizedTime * (NumFrames - 1);
int32 Frame1 = FMath::FloorToInt(FrameTime);
int32 Frame2 = FMath::Min(Frame1 + 1, NumFrames - 1);
float Alpha = FrameTime - Frame1;
TMap<FName, int32> BoneIndexMap;
for (int32 i = 0; i < BoneCount; ++i)
{
BoneIndexMap.Add(Skeleton->Bones[i].Name, i);
}
for (const FBoneAnimationTrack& Track : Tracks)
{
const int32* IndexPtr = BoneIndexMap.Find(Track.Name);
if (!IndexPtr) continue;
int32 BoneIndex = *IndexPtr;
const FRawAnimSequenceTrack& Raw = Track.InternalTrackData;
FVector Position = FVector::ZeroVector;
FQuat Rotation = FQuat::Identity;
FVector Scale = FVector::OneVector;
InterpolateKeyframe(Raw, Frame1, Frame2, Alpha, Position, Rotation, Scale);
Rotation.Normalize();
OutBoneTransforms[BoneIndex] = FBonePose(Rotation, Position, Scale);
}
}
void UAnimSequence::InterpolateKeyframe(const FRawAnimSequenceTrack& Track, int32 Frame1, int32 Frame2, float Alpha,
FVector& OutPosition, FQuat& OutRotation, FVector& OutScale) const
{
// 위치 보간
if (Track.PosKeys.Num() > 0)
{
int32 PosFrame1 = FMath::Min(Frame1, Track.PosKeys.Num() - 1);
int32 PosFrame2 = FMath::Min(Frame2, Track.PosKeys.Num() - 1);
OutPosition = FMath::Lerp(Track.PosKeys[PosFrame1], Track.PosKeys[PosFrame2], Alpha);
}
// 회전 보간
if (Track.RotKeys.Num() > 0)
{
int32 RotFrame1 = FMath::Min(Frame1, Track.RotKeys.Num() - 1);
int32 RotFrame2 = FMath::Min(Frame2, Track.RotKeys.Num() - 1);
if (RotFrame1 == RotFrame2 || Alpha < KINDA_SMALL_NUMBER)
{
OutRotation = Track.RotKeys[RotFrame1];
}
else
{
OutRotation = FQuat::Slerp(Track.RotKeys[RotFrame1], Track.RotKeys[RotFrame2], Alpha);
}
}
// 스케일 보간
if (Track.ScaleKeys.Num() > 0)
{
int32 ScaleFrame1 = FMath::Min(Frame1, Track.ScaleKeys.Num() - 1);
int32 ScaleFrame2 = FMath::Min(Frame2, Track.ScaleKeys.Num() - 1);
OutScale = FMath::Lerp(Track.ScaleKeys[ScaleFrame1], Track.ScaleKeys[ScaleFrame2], Alpha);
}
}
5. Animation Notify
애니메이션에는 Notify라는 기능이 있습니다.
영단어 뜻 그대로 무언가를 알리거나 통지하는것입니다.
애니메이션은 과연 무엇을 알리고 통지하는 것일까요?
예를 들면 공격하는 애니메이션이 있다고 가정하면 공격이 끝날때 캐릭터가 강한 기합소리 효과음을 실행하고 싶다고 하면
이러한 기능을 Notify를 통해서 구현하는 것이 가능합니다.
Notify 구현영상
이 부분이 어떻게 구현했는지 알아보도록 하겠습니다.
우선 알아야 하는 정보가 2가지가 있습니다.
UAnimSequenceBase -> Notifies
UAnimInstance -> NotifyQueue
어째서 2개나 있는지 의문이 들지도 모릅니다.
결론적으로 말하자면 Notifies는 이 애니메이션이 들고있는 모든 Notify이고
NotifyQueue는 이번 프레임에 실행해야할 Notify입니다.
- UAnimSequenceBase의 Notifies
- 애니메이션 에셋에 저장된 모든 Notify Event입니다.
- 애니메이션 전체 재생 구간에 걸쳐 있는 모든 노티파이를 포함합니다.
- UAnimInstance의 NotifyQueue
- 현재 프레임에서 실행 대기 중인 Notify들의 임시 저장소입니다.
- 매 프레임마다 갱신되며, 해당 프레임에 처리해야 할 노티파이만 포함합니다.
- 예: "현재 프레임에 발생한 Notify만 모아둔 저장소"
우선 Notifies에 Notify정보를 저장합니다.
Notify이벤트를 저장하는 구조체는 다음과 같이 되어 있습니다.
struct FAnimNotifyEvent
{
float TriggerTime; // 언제 실행될지 (0~1사이)
float Duration; // 지속시간
FName NotifyName; // 이벤트 이름
};
우선 예시로 Attack이벤트를 넣어줍니다.
FAnimNotifyEvent NotifyEvent;
NotifyEvent.NotifyName = "Attack";
NotifyEvent.TriggerTime = .3328f;
Notifies.Add(NotifyEvent);
매 프레임 돌며 아래 두 함수를 실행하게 됩니다.
CheckAnimNotifyQueue(); // 현재 프레임에 있는 Notify 수집
TriggerAnimNotifies(); // 수집한 Notify 실행
Notify는 현재 시간과 이전프레임 시간사이에 있는 NotifyEvent를 등록한 TriggerTime을 기준으로 찾게 됩니다.
RateScale, 역방향 재생인지 아닌지등 다양한 조건을 고려하여 측정하게 됩니다.
또한 Loop하는 경우도 고려해서 현재 프레임 시간이 이전 프레임 시간보다 작은 경우도 처리해주게 됩니다.
void UAnimInstance::CheckAnimNotifyQueue()
{
// 큐 초기화
NotifyQueue.Reset();
// 노티파이 수집
if (!CurrentSequence || CurrentSequence->Notifies.Num() == 0)
return;
// 재생 방향 확인 (중요!)
float PlayRate = CurrentSequence->GetRateScale();
bool bIsPlayingBackwards = PlayRate < 0.0f;
// 시간 정규화
float SequenceLength = CurrentSequence->GetUnScaledPlayLength();
if (SequenceLength <= 0.0f)
return;
float NormalizedPrevTime = PreviousSequenceTime / SequenceLength;
float NormalizedCurrTime = CurrentSequence->LocalTime / SequenceLength;
// 루핑 확인 (방향에 따라 다름)
bool bLoopedThisFrame = false;
if (!bIsPlayingBackwards)
{
// 정방향 재생 시 루핑: 현재 < 이전
bLoopedThisFrame = NormalizedCurrTime < NormalizedPrevTime;
}
else
{
// 역방향 재생 시 루핑: 현재 > 이전
bLoopedThisFrame = NormalizedCurrTime > NormalizedPrevTime;
}
// 각 노티파이 검사
for (const FAnimNotifyEvent& Notify : CurrentSequence->Notifies)
{
bool bShouldTrigger = false;
if (!bIsPlayingBackwards)
{
// 정방향 재생
if (bLoopedThisFrame)
{
// 루핑 케이스: 두 부분 확인 (이전~1.0 또는 0.0~현재)
bShouldTrigger = (Notify.TriggerTime > NormalizedPrevTime && Notify.TriggerTime <= 1.0f) ||
(Notify.TriggerTime >= 0.0f && Notify.TriggerTime <= NormalizedCurrTime);
}
else
{
// 일반 케이스: 이전 < 트리거 <= 현재
bShouldTrigger = (Notify.TriggerTime > NormalizedPrevTime &&
Notify.TriggerTime <= NormalizedCurrTime);
}
}
else
{
// 역방향 재생 (조건 반전)
if (bLoopedThisFrame)
{
// 루핑 케이스: 두 부분 확인 (이전~0.0 또는 1.0~현재)
bShouldTrigger = (Notify.TriggerTime < NormalizedPrevTime && Notify.TriggerTime >= 0.0f) ||
(Notify.TriggerTime <= 1.0f && Notify.TriggerTime >= NormalizedCurrTime);
}
else
{
// 일반 케이스: 이전 > 트리거 >= 현재
bShouldTrigger = (Notify.TriggerTime < NormalizedPrevTime &&
Notify.TriggerTime >= NormalizedCurrTime);
}
}
if (bShouldTrigger)
{
NotifyQueue.AddAnimNotify(&Notify);
}
}
}
그 후엔 수집한 NotifyQueue를 돌며 전부 실행시켜줍니다.
최종적으로 Character에서 Notify의 이름에 따라서 적절한 함수를 실행시켜주게 되는 방식입니다.
void UAnimInstance::TriggerAnimNotifies()
{
if (!OwningComponent)
return;
// 수집된 모든 노티파이 처리
for (const FAnimNotifyEvent* Notify : NotifyQueue.AnimNotifies)
{
if (Notify)
{
OwningComponent->HandleAnimNotify(Notify);
}
}
}
void ACharacter::HandleAnimNotify(const FAnimNotifyEvent* Notify)
{
if (Notify->NotifyName == TEXT("Attack"))
{
FSoundManager::GetInstance().PlaySound("shout");
}
}
'DirectX11' 카테고리의 다른 글
[DirectX11] CPU Skinning, GPU Skinning 비교 (0) | 2025.05.16 |
---|---|
[DirectX11] FBX, CPU Skinning (0) | 2025.05.09 |
[DirectX11] FBX SDK적용, 컴파일 옵션 /Md /Mt (0) | 2025.05.07 |
[DX11] Lua 스크립트 연동 + SOL2 (0) | 2025.04.27 |
[DX11] PCF (Percentage Closer Filtering) (0) | 2025.04.24 |