GameAnimationSampleProject
다사다난했던 멀티플레이 팀프로젝트가 종료되고 어느덧 새해도 밝아와 최종프로젝트의 시작까지 살짝 텀이 있어 사실상 마지막 프로젝트를 위한 공부를 할 수 있는 주간이 시작되었다. 최종 프로젝트의 팀이 확정되고 개발하기로 한 게임의 장르가 RPG이며 거기서 캐릭터 부분을 담당하게 되었기에 오늘부터 예전 사전캠프에서 살짝 맛보다 말았던 언리얼엔진의 샘플 프로젝트 중 하나인 GameAnimationSampleProject 줄여서 GASP를 분석하고 실제로 구현하면서 이를 최종 프로젝트에 적용시킬 수 있게끔 공부해보려고 한다.
GASP는 무엇일까?
기존의 언리얼엔진에서의 애니메이션은 단순히 스테이트머신을 활용하여 캐릭터의 현재 상태를 확인하고 그 상태에 따라 애니메이션을 재생하는 방식으로 구현되어 있다. 이는 필연적으로 캐릭터의 모션간의 전환이 자유롭지 못하게 되며 자유롭게 하기 위해서는 다양한 애니메이션을 욱여넣어야 하고 이는 노드가 스파게티처럼 꼬이게 되어 유지보수가 까다롭게 될 수도 있다.
GASP는 이러한 방식에서 탈피하여 캐릭터의 움직임을 예측하여 해당하는 애니메이션을 실시간으로 검색한뒤 적합한 애니메이션을 재생하는 방식을 채택한다. 이를 위해서 Pose Search Database라는 데이터 에셋에 수백 개의 애니메이션 클립을 넣어야 하지만 단지 애니메이션만 넣고 빼면 되고 재생 여부는 내부적인 코드로 해결되기에 유지보수가 간단하고 코드의 가시성도 좋아지게 된다. 여기서 구현이 복잡하게 이루어지는 파쿠르와 같은 세부적인 동작들도 쉽게 구현할 수 있게 해주는 것이 GASP의 핵심이라 할 수 있다. 요약하자면 GASP는 캐릭터의 애니메이션 로직을 코딩하지 않고 데이터화 한 다음 캐릭터의 움직임을 통해 미래의 동선을 예측해서 해당하는 애니메이션을 검색하여 재생하는 느낌으로 받아들이면 된다.
GASP를 구현해보자!
사실 단순하게 최종 프로젝트에 GASP를 사용하고 싶다면 샘플 프로젝트 자체를 최종 프로젝트에 마이그레이션 하면 될 일이다. 하지만 그렇게 해서는 나중에 변경점이 생기면 이를 수정하기 위해 상당히 애를 먹거나 심하면 건들지도 못하는 상태가 될 것이다. 그렇기에 여유로운 시간이 주어진 지금 직접 GASP를 구현해 보면서 어떠한 구조로 작동하는지 파악해보려고 한다.
일단 새로운 언리얼 프로젝트를 만들어 준다. 5.5.4 버전의 C++기반의 프로젝트를 생성하고 Third Person 프로젝트를 포함한 상태로 새 프로젝트를 생성해 준다음 GASP에 필요한 플러그인들을 켜주면 되는데 GASP에 필요한 필수 플러그인은 다음과 같다.
1. Pose Search
2. Chooser
3. Animation Locomotion Library
4. Control Rig와 IK Rig
5. Animation Warping
해당 플러그인들을 활성화해준 뒤 에디터를 재시작해준다. 이제 우리는 캐릭터의 움직임을 보고 예측한 동선 즉 궤적에 대한 정의를 담을 클래스를 생성해 준다. 프로젝트 이름은 ProjectA 클래스 이름은 MotionTypes로 할 것이기에 PAMotionTypes라는 이름의 클래스로 부모 클래스는 None으로 지정한 뒤 생성해 준다. 이 클래스에는 FPATrajectorySample이라는 구조체를 정의하여 시간, 위치, 회전, 속도를 담아둘 것이다.
//PAMotionTypes.h
#pragma once
#include "CoreMinimal.h"
#include "PAMotionTypes.generated.h"
USTRUCT(BlueprintType)
struct FPATrajectorySample
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Trajectory")
float AccumulatedSeconds = 0.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Trajectory")
FVector Position = FVector::ZeroVector;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Trajectory")
FQuat Rotation = FQuat::Identity;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Trajectory")
FVector LinearVelocity = FVector::ZeroVector;
};
위에서부터 순서대로 AccumulatedSeconds는 누적 시간을 Position은 예상 위치를, Rotation은 예상 회전을 LinearVelocity는 예상 속도를 담아둘 변수이다. 예를 들어 누적시간이 1초일 때의 예상 위치와 회전, 속도를 담아두는 것이다.
자 이제 예측한 궤적을 담아둘 구조체를 만들었으니 실제로 동선을 예측하는 코드를 짜주면 될 것이다. 물론 PlayerCharacter클래스에 바로 작성해 줘도 되지만 이렇게 되면 해당 GASP를 다른 클래스의 Pawn이나 AI를 위시한 몬스터클래스에 적용해 주려면 다시 코드를 복사 붙여 넣기 하는 등의 번거로움이 있으니 우리는 PATrajectoryComponent라는 액터컴포넌트 클래스를 새로 만들어줘서 해당 클래스에서 계산을 구현해보려고 한다.
// PATrajectoryComponent.h
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "PAMotionTypes.h"
#include "PATrajectoryComponent.generated.h"
UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class PROJECTA_API UPATrajectoryComponent : public UActorComponent
{
GENERATED_BODY()
public:
UPATrajectoryComponent();
protected:
virtual void BeginPlay() override;
public:
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
void UpdateInput(FVector NewInputVector);
UPROPERTY(BlueprintReadOnly, Category = "Motion Matching")
TArray<FPATrajectorySample> TrajectorySamples;
UPROPERTY(EditAnywhere, Category ="Debug")
bool bShowDebugTrajectory = true;
private:
FVector CurrentInputVector;
class ACharacter* OwnerCharacter;
};
일단 헤더 파일이다. 현재 플레이어의 입력 값을 저장할 CurrentInputVector와 계산한 데이터를 담아줄 PATrajectorySample 배열 TrajectorySamples 변수들을 추가해 주었다.
// PATrajectoryComponent.cpp
#include "PATrajectoryComponent.h"
#include "GameFramework/Character.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "DrawDebugHelpers.h"
UPATrajectoryComponent::UPATrajectoryComponent()
{
PrimaryComponentTick.bCanEverTick = true;
TrajectorySamples.SetNum(4);
}
void UPATrajectoryComponent::BeginPlay()
{
Super::BeginPlay();
OwnerCharacter = Cast<ACharacter>(GetOwner());
}
void UPATrajectoryComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
if (!OwnerCharacter) return;
FVector CurrentPos = OwnerCharacter->GetActorLocation();
FVector CurrentVel = OwnerCharacter->GetVelocity();
FQuat CurrentRot = OwnerCharacter->GetActorQuat();
TrajectorySamples[0].Position = CurrentPos;
TrajectorySamples[0].Rotation = CurrentRot;
TrajectorySamples[0].LinearVelocity = CurrentVel;
TrajectorySamples[0].AccumulatedSeconds = 0.0f;
float PredictionTimes[] = { 0.5f, 1.0f, 1.5f };
float MaxSpeed = OwnerCharacter->GetCharacterMovement()->GetMaxSpeed();
for (int32 i = 0; i < 3; ++i)
{
int32 SampleIndex = i + 1;
float TargetTime = PredictionTimes[i];
FVector SimPos = CurrentPos;
FVector SimVel = CurrentVel;
float SimTime = 0.0f;
float StepDelta = 0.033f;
while (SimTime < TargetTime)
{
FVector TargetVel = CurrentInputVector.IsZero() ? FVector::ZeroVector : (CurrentInputVector * MaxSpeed);
SimVel = FMath::VInterpTo(SimVel, TargetVel, StepDelta, 10.0f);
SimPos += SimVel * StepDelta;
SimTime += StepDelta;
}
TrajectorySamples[SampleIndex].Position = SimPos;
TrajectorySamples[SampleIndex].AccumulatedSeconds = TargetTime;
TrajectorySamples[SampleIndex].LinearVelocity = SimVel;
if (!SimVel.IsNearlyZero())
{
TrajectorySamples[SampleIndex].Rotation = SimVel.ToOrientationQuat();
}
else
{
TrajectorySamples[SampleIndex].Rotation = TrajectorySamples[SampleIndex - 1].Rotation;
}
}
if (bShowDebugTrajectory)
{
for (const auto& Sample : TrajectorySamples)
{
DrawDebugSphere(GetWorld(), Sample.Position, 10.0f, 0, FColor::Red, false, -1.0f);
}
for (int32 i = 0; i < TrajectorySamples.Num() - 1; ++i)
{
DrawDebugLine(GetWorld(), TrajectorySamples[i].Position, TrajectorySamples[i + 1].Position, FColor::Yellow, false, -1.0f, 0, 2.0f);
}
}
CurrentInputVector = FVector::ZeroVector;
}
void UPATrajectoryComponent::UpdateInput(FVector NewInputVector)
{
CurrentInputVector = NewInputVector;
}
소스코드에서는 매 Tick마다 캐릭터의 움직임을 계산해주어야 하기에 PrimaryComponentTick.bCanEverTick을 true로 해주고 샘플 개수를 확보하기 위해서 TrajectorySamples.SetNum(4)를 통해 4개 정도의 개수를 확보해 준다. BeginPlay가 호출되면 해당 컴포넌트의 주인이 누구인지 확인하고 OwnerCharacter에 저장한다. UpdateInput은 캐릭터에서 입력을 받으면 호출되어 CurrentInputVector에 그 입력 값을 저장하는 역할을 한다.
이제 대망의 메인 로직이 담겨있는 Tick의 차례이다. OwnerCharacter의 위치, 속도, 회전값을 변수에 담은 뒤 해당 값을 TrajectorySamples배열의 인덱스 0번에 저장을 한다. 이제 0.5초 간격으로 궤적을 계산해 줄 것이다.

30 fps마다 TargetTime(0.5초, 1초, 1.5초)가 될 때까지 while문을 통해서 입력이 있을 때만 목표 속도를 계산해 준다. 해당 속도를 기반으로 시뮬레이션된 위치를 갱신해 주고 TargetTime에 도달하게 되면 해당 값을 각각 해당하는 배열에 대입해 준다.
이제 기존의 PlayerCharacter의 Move함수에 다음처럼 로직을 추가해 주면 준비는 끝이다.
void APAPlayerCharacter::Move(const FInputActionValue& Value)
{
FVector2D MovementVector = Value.Get<FVector2D>();
if (Controller != nullptr)
{
const FRotator Rotation = Controller->GetControlRotation();
const FRotator YawRotation(0, Rotation.Yaw, 0);
const FVector ForwardDirection = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);
const FVector RightDirection = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y);
FVector WorldInputDirection = (ForwardDirection * MovementVector.X) + (RightDirection * MovementVector.Y);
WorldInputDirection.Normalize();
AddMovementInput(ForwardDirection, MovementVector.X);
AddMovementInput(RightDirection, MovementVector.Y);
if (TrajectoryComponent)
{
TrajectoryComponent->UpdateInput(WorldInputDirection);
}
}
}
캐릭터의 입력 방향을 계산하여 WorldInputDirection에 담아준 뒤 정규화 해서 해당 방향을 TrajectoryComponent의 UpdateInput의 인자로 전달해 준다. 이렇게 세팅해 준 뒤 빌드 후 에디터를 실행해 주면 다음과 같이 캐릭터의 앞으로의 이동 궤적이 보이게 된다.
'unreal 5기' 카테고리의 다른 글
| 260114 언리얼엔진 본캠프 108일차 GASP(3) (0) | 2026.01.14 |
|---|---|
| 260113 언리얼엔진 본캠프 107일차 GASP(2) (0) | 2026.01.13 |
| 251226 언리얼엔진 본캠프 96일차 멀티플레이 팀프로젝트8 (0) | 2025.12.26 |
| 251222 언리얼엔진 본캠프 93일차 멀티플레이 팀프로젝트7 (0) | 2025.12.22 |
| 251218 언리얼엔진 본캠프 91일차 멀티플레이 팀프로젝트6 (1) | 2025.12.18 |