1. 애니메이션 몽타주
몽타주는 프랑스어로 '잘라 붙이다' 라는 의미. 애니메이션도 오려서 다른 애니메이션에 붙이기 때문에 쓰인다. 간단히 말해서 애니메이션 몽타주는 애니메이션을 만드는 기법이라 할 수 있다.
캐릭터의 스켈레탈 메쉬를 에디터로 열어 왼쪽에서 네 번째를 보면 에셋 생성이 있고 누르면 애님 몽타주를 선택해 만들 수 있다.
새로 Animations라는 폴더를 만들고 생성해보자.
예를들어 액션게임에서는 여러 액션 애니메이션들을 합쳐서 연타 애니메이션을 만들어 사용하면 좋을 수 있다.
몽타주에 여러 애니메이션 시퀀스를 배치하고 시간도 조절하고 해서 저장할 수 있다.
액션맵핑으로 Attack이라는 이름으로 마우스 좌클릭을 입력에 추가하고
MyCharacter.cpp의 SetupPlayerInputComponent에 Attack함수를 만들고 추가하자.
PlayerInputComponent->BindAction(TEXT("Attack"), EInputEvent::IE_Pressed, this, &MyCharacter::Attack);
BindAxis로 연결하는 움직임을 조절하는 함수는 정도를 받아야해서 인자가 있는데,
BindAction으로 묶는 일시적으로 들어오는 입력에 대해 처리하는 함수는 인자가 없다. 입력이 있는지 없는지만 확인한다.
void AMyCharacter::Attack()
{
}
애니메이션 관련된 함수들은 애님 인스턴스 클래스에서 (UMyAnimInstance) 애니메이션에 쓸 변수를 만들어 여기에서 관리하고 NativeUpdateAnimation에서 매 틱마다 검사해서 변수의 값을 바꿔주면 애니메이션 상태를 바꿔주는식으로 하고 있었는데 (Speed와 IsFalling) Attack은 어떤 키를 눌렀을때 바로 발동하는 것이라 애님 인스턴스에서 꺼내오기 보다는 거꾸로 MyCharacter에서 애님인스턴스에 접근해서 호출하는게 더 낫다.
/* MyAnimInstance.h */
class UMyAnimInstance : public UAnimInstance
{
// ...
public:
UMyAnimMontage();
// ... NativeUpdateAnimation 메소드
void PlayAttackMontage();
private:
// ... Speed, IsFalling
UPROPERTY(EditAnywhre, BlueprintReadOnly, ...)
UAnimMontage AttackMontage;
}
/* MyAnimInstance.cpp */
UMyAnimInstance::UMyAnimInstance()
{
static ConstructorHelpers::FObjectFinder<UAnimMontage> AM(TEXT("[프로퍼티경로]"));
if(AM.Succeeded())
{
AttackMontage = AM.Object;
}
}
void UMyAnimInstance::PlayAttackMontage()
{
if(!Montage_IsPlaying(AttackMontage))
{
Montage_Play(AttackMontage, 1.f);
}
}
몽타쥬를 사용하는 간단한 방법을 위와 같이 정리했다.
기존까지는 NativeUpdateAnimation에서 매 틱마다 폰에 접근해 검사해서 설정했다면
클릭한 순간에 이루어져야하므로 역으로 PlayAttackMontage라는 애님인스턴스에 정의한 함수를 캐릭터에서 호출한다는 것이 중요하다.
/* MyCharacter.cpp */
void AMyCharacter::Attack()
{
auto AnimInstance = Cast<UMyAnimInstance>(GetMesh()->GetAnimInstance());
if(AnimInstance)
{
AnimInstance->PlayAttackMontage();
}
}
따라치면서 어떻게 했는지가 중요하기보단 어떤 흐름으로 진행됐는지를 기억하자!
PlayerInputSetup머시기 함수에서 BindAction으로 키입력 받아서 입력받으면 호출할 함수에서 애님인스턴스클래스의 몽타주 재생시키는 함수 호출한다.
간단하게 어떤 상횡에서도 이 몽타주를 재생시키기 위해 MyAnimInstance클래스를 상속받은 ABP_MyAnim에서 Action스테이트 머신에서 Result사이에 Default 슬롯노드를 추가해 노드 안에서 몽타주를 지정하고 넣어주었다.
만약 이 몽타주가 Ground상태에서만 재생되어야한다면 Action스테이트 머신의 Ground상태 내에서 넣어줘야겟지..?
그리고 왼쪽 마우스를 입력으로 Attack에 저장
결국 캐릭터 메쉬에서 시작해 애니메이션 몽타주만들기로 들어가 여러 애니메이션 시퀀스를 이어붙이고 시간같은것도 저장해 만들었다.
그 다음에 코드에서 애니메이션 몽타주를 재생시키기 위해 입력이 들어왔을 때 연동시키는 함수를 애님 인스턴스 안에서 만들고
마지막으로 애니메이션을 관리하고 있는 애님 그래프에서 디폴트 슬롯 그룹을 끼워 넣어 이 안에서 애님 몽타주를 재생시키도록 했다. 참고로 이 슬롯은 하나만 있어야 하는게 아니고 여럿있어도 되는데 상하체 나뉘어져 있어도 된다.
2. 델리게이트
void AMyCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
Super::SetupPlayerInputComponent(PlayerInputComponent);
PlayerInputComponent->BindAction(TEXT("Jump"), EInputEvent::IE_Pressed, this, &AMyCharacter::Jump);
PlayerInputComponent->BindAction(TEXT("Attack"), EInputEvent::IE_Pressed, this, &AMyCharacter::Attack);
PlayerInputComponent->BindAxis(TEXT("UpDown"), this, &AMyCharacter::UpDown);
PlayerInputComponent->BindAxis(TEXT("LeftRight"), this, &AMyCharacter::LeftRight);
PlayerInputComponent->BindAxis(TEXT("Yaw"), this, &AMyCharacter::Yaw);
}
위와 같은 방식으로 입력을 받고 있는데 사실 이는 델리게이트를 받는 방법이기도 하다.
C#에서는 델리게이트가 기본문법으로 들어가 있는데 C++에서는 없어서 언리얼에서 자체적으로 BindAction, BindAxis 만들어서 쓰고있다. 그리고 인자로 어떤 입력이 들어오고 어디에 보내줄지를 같이 묶어준다.
지난시간에 만든 AttackMontage를 실행하는 방법을 보면 마우스 좌클릭 입력이 들어오면 AMyCharacter::Attack에서 AnimInstance에 있는 PlayAttackMontage에서 실행중이 아니라면 몽타주를 실행하는 방법으로 만들었었다.
그런데 이 방법을 더 관리할수 있는 방법이 있다면 지금 공격중인지 여부를 MyCharacter에서 "bool IsAttacking" 변수를 만들어서 AMyCharacter::Attack에서 애님인스턴스로 보내기 전에 이미 공격중이면 보내지 않도록 할 수 있다. 다른 곳에서도 공격중인지 여부 확인이 후에 필요할 수도 있고..! (이미 공격중이면 다른 공격 스킬패킷을 보내지 않는데 필요하다든가!)
IsAttacking은 초기에 false였다가 공격을 하면 true로 하고 다시 false로 꺼주면 되는데 문제는 우리가 언제 공격이 끝나는지 알지 못한다는 것이다.
그럼 tick에서 체크하는거면 효율이 떨어지므로 조삼모사인데 다른 방법으로는 Attack이 끝났을 때 알려주는 즉, 델리게이트 기능이 있으면 좋다. 만약에 AttackMontage의 재생이 끝나면 공격이 끝난것이므로 이때 IsAttacking의 값을 바꿔주도록 해보자.
// MyCharacter.h
public:
UFUNCTION()
void OnAttackMontageEnded(UAnimMontage* Montage, bool bInterrupted);
private:
UPROPERTY(VisibleAnywhere, Category=Pawn)
bool IsAttacking = false;
UPROPERTY()
class UMyAnimInstance* AnimInstance;
// MyCharacter.cpp
void AMyCharacter::BeginPlay()
{
Super::BeginPlay();
AnimInstance = Cast<UMyAnimInstance>(GetMesh()->GetAnimInstance());
AnimInstance->OnMontageEnded.AddDynamic(this, &AMyCharacter::OnAttackMontageEnded);
}
void AMyCharacter::Attack()
{
if (IsAttacking)
return;
AnimInstance->PlayAttackMontage();
IsAttacking = true;
}
void AMyCharacter::OnAttackMontageEnded(UAnimMontage* Montage, bool bInterrupted)
{
IsAttacking = false;
}
UMyAnimInstance 애님인스턴스를 MyCharacter의 멤버로 만들고 BeginPlay에서 받아와서 보면 이미 애님인스턴스에선 OnMontageEnded라는게 있다. 타입은 FOnMontageEndedMCDelegate 인데 델리게이트로 미리 정의를 해둔 것이다. 여기에 또 미리 정의되어있는 매크로 AddDynamic을 이용해 만든 함수를 this와 함께 넣어주었다. 이때 만드는 함수는 UAnimMontage와 bool타입을 인자로 받는 함수여야한다. 그래서 OnAttackMontageEnded 함수도 그렇게 만들어서 이 안에서 애님 몽타주의 재생이 끝나면 IsAttacking이 false로 바뀌도록 호출되도록 하였다. 매프레임마다 공격키를 눌렀음에도 IsAttacking을 확인해 알아서 공격을 두 번 확인할 필요 없어진다.
void UMyAnimInstance::PlayAttackMontage()
{
if(!Montage_IsPlaying(AttackMontage))
{
Montage_Play(AttackMontage, 1.f);
}
}
🔽
🔽
void UMyAnimInstance::PlayAttackMontage()
{
Montage_Play(AttackMontage, 1.f);
}
애님인스턴스 내에서 PlayAttackMontage에서 크로스 체킹이 불필요해졌다.
위처럼 델리게이트로 묶을 함수들은 인자타입이 이미 정해져있는데 BindAxis로 묶는 함수들은 float타입을 받아야하고 BintAction으로 묶는 함수들은 인자가 없엇다.
참고로 BeginPlay에서 AnimInstance를 받아와도 되긴 하지만 언리얼의 생명주기가 복잡한데
Actor Lifecycle
What actually happens when an Actor is loaded or spawned, and eventually dies.
docs.unrealengine.com
이걸보면 BeginPlay전에 호출되는 곳인 '' 에서 컴포넌트를 초기화하므로 여기에서 AnimInstance를 받아와도 괜찮다.
어쨌든 이번엔 AddDynamic으로 델리게이트에 함수를 추가하는 것만 기억하도록 하자.
3. 애니메이션 노티파이.
걸음에 맞춰 걷는 소리를, 공격시 무기가 가장 머리 가 있을때 피격에 맞는 소리를 넣어보자.
만약 상용 엔진이 아니라 DX로 만든다고 하면 애니메이션을 실행시키고 시간을 재서 원하는 애니메이션 시간이 되면 소리를 재생시키는 방식으로 해야할 것이다. 시점을 맞추는게 매우 어렵다..
언리얼 애니메이션에서는 원하는 시점에 노티파이를 추가해 알림을 주는 기능이 있다.
AttackAnimMontage를 에디터로 열어서 노티파이를 동작에 맞에 AttackHit이라는 이름으로 세 곳 추가해주었다.
애니메이션 관련된 것은 애님인스턴스에서 다 받아주고 있었는데 MyAnimInstance에 서 노티파이가 오면 받아주는 함수를 만들어보자. 함수이름은 노티파이이름 앞에 AnimNotify_'노티파이이름' 으로 지어줘야한다.
// MyAnimInstance.h
private:
void AnimNotify_AttackHit();
// MyAnimInstance.cpp
void UMyAnimInstance::AnimNotify_AttackHit()
{
//UE_LOG(LogTemp, Log, TEXT("AnimNotify_AttackHit"));
}
어떤 애니메이션의 특정 시점에 무언갈 맞춰야한다면 틱마다 계산하기보단 노타파이 기능을 이용하는게 훨씬 깔끔하다는걸 알 수있다.
만약 애니메이션 몽타주로 한번에 여러 애니메이션 시퀀스를 섞어서 만든 몽타주를 재생시킨게 아니라 몽타주 내에서 여러 동작을 각 다른 애니메이션 시퀀스처럼 번갈아 가면서 재생시키도록 만들고 싶다고 가정하자. Attack입력이 있을때마다 세 개의 공격 모션을 돌아가면서 재생시키고 싶다. 이럴 때 사용하는 방법이 여러가지가 있을 텐데 애니메이션마다 섹션을 주는 방법이 있다.
기존에 있는 Attack을 Attack0으로 바꾸고 우클릭으로 새 몽타주 섹션을 추가해 애니메이션 시퀀스가 끝날 때마다 Attack1, Attack2 몽타주 섹션을 추가해줫다. 드래그드롭으로 이 섹션의 위치도 바꿀수 있다. 우선 구간별로 섹션을 구별해줬는데 오른쪽의 몽타주 섹션에서 섹션들의 연결을 끊거나 순서를 바꿀수도 있는데 만약 끊으면 그 섹션이 끝나면 다음 섹션으로 넘어가지 않고 반복한다. 즉, 코드상에서도 Attack0, 1, 2 중 어떤 섹션을 재생시킬지 지정하면 그 섹션만 재생한다.
MyCharacter클래스에서 몇번째 Attack섹션을 재생하는지 기억하기 위해 int32 AttackIndex 를 추가하였다.
// MyCharacter.h
private:
int32 AttackIndex = 0; // 섹션 Attack0, Attack1, Attack2를 번갈아가며 재생시킬 인덱스.
// MyCharacter.cpp
void AMyCharacter::Attack()
{
if (IsAttacking)
return;
//AnimInstance->PlayAttackMontage();
AttackIndex = (AttackIndex +1) % 3;
AnimInstance->JumpToSection(AttackIndex);
IsAttacking = true;
}
// MyAnimInstance.h
public:
FName GetAttackMontageName(int32 SectionIndex);
void JumpToSection(int32 SectionIndex);
// MyAnimInstance.cpp
FName MyAnimInstance::GetAttackMontageName(int32 SectionIndex)
{
// 0,1,2중 들어오면 Attack0, Attack1, Attack2를 각각 재생시킨다.
return FName(*FString::Printf(TEXT("Attack%d"),SectoinIndex));
}
void MyAnimInstance::JumpToSection(int32 SectionIndex)
{
FName Name = GetAttackMontageName(SectionIndex);
Montage_JumpToSection(Name, AttackMontage); // AttackMontage(이미 MyAnimInstance의 멤버)에서 Name섹션을 재생시킨다.
}
이러면 초기에는 0으로 되어있던 AttackIndex가 공격입력키가 들어올 때마다 바뀌면서 다른 애니메이션 섹션을 재생시킨다.
3. 블렌드 스페이스.
애니메이션 블루프린트> AnimGraph> Action 스테이트머신에서 Ground에 가면 멈춰있을때는 Idle을 재생하고 움직일때는 Jog_Fwd 애니메이션을 재생시켰다. 그런데 이미 가지고 있는 애셋브라우저를 보면 움직이는 방향에 따라 Jog_Bwd, Left, Right등의 애니메이션 시퀀스가 있는것을 알 수 있다. 이들을 방향입력에 따라 다르게 재생시켜야하는데 if로 모두 조사하는것은 복잡하다. ㅁㅇㅁㄴㅇ
Animations에서 우클릭으로 애니메이션> 블렌드스페이스를 선택해보자 스켈레톤을 선택하고 이름을 BS_Move로 지어줬다.
공간을 섞는다는 의미처럼 애니메이션을 섞는 기능을 제공한다. 만들어진 BS_Move를 열어보면 왼쪽의 애셋 디테일에 가로축, 새로축이라 써있는 Axis Settings가 있다.
가로축의 이름을 Horizontal, 새로축의 이름을 Vertical로 지어줬다.
그리고 기본값 minimum, maximum을 -1, 1들로 해주었다.
그러면 설정한대로 가운데 아래에 그래프가 생기게 된다.
그럼 그 그래프에 원하는 애니메이션을 배치할수 있게된다. Vertical을 앞뒤, Horizontal을 왼오라고 가정하고
Jog_Bwd를 Horizontal 0, Vertical -1(가움데에서 뒤로). Jog_Left를 Horizontal -1, Vertical0(앞뒤아닌 왼쪽으로만) 이런식으로 4 방향에 마저 Fwd, Right를 배치한다.
애니메이션을 배치하고 그래프 위에서 쉬프트를 누르면서 마우스를 움직여보면 위치 정도에 따라 애니메이션을 섞어서 미리보기로 볼 수있다.
이렇게 저장하여 만든 블렌드 스페이스를 하나의 애니메이션마냥 사용할 수 있다.
만들었던 애니메이션 블루프린트에서 ABP_MyAnim 애님그래프로 들어가 Action 스테이트머신에서 Ground스테이트에서 Jog_Fwd대신 BS_Move를 배치하고 Horizontal, Vertical정보를 넣어줘보자.
MyCharecter.h에
// MyCharacter.h
UPROPERTY()
float UpDownValue = 0;
UPROPERTY()
float LeftRightValue = 0;
// MyCharacter.cpp
void AMyCharacter::UpDown(float Value)
{
//if (Value == 0.f)
// return;
//UE_LOG(LogTemp, Warning, TEXT("UpDown %f"), Value);
UpDownValue = Value;
AddMovementInput(GetActorForwardVector(), Value);
}
void AMyCharacter::LeftRight(float Value)
{
//if (Value == 0.f)
// return;
//UE_LOG(LogTemp, Warning, TEXT("LeftRight %f"), Value);
LeftRightValue = Value;
AddMovementInput(GetActorRightVector(), Value);
}
추가하고 애니메이션에서 위 값들을 받아와 블루프린트에서 쓸 것이므로
// MyAnimInstance.h에도 아래 둘을 추가하여 NativeUpdateAnimation에서 MyCharacter에서
// 값을 얻어온다.
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Pawn, Meta=(AllowPrivateAccess=true))
float Horizontal;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Pawn, Meta=(AllowPrivateAccess=true))
float Vertical;
기존 위 를 아래처럼 바꾼다.
void UMyAnimInstance::NativeUpdateAnimation(float DeltaSeconds)
{
Super::NativeUpdateAnimation(DeltaSeconds);
auto Pawn = TryGetPawnOwner();
if (IsValid(Pawn))
{
Speed = Pawn->GetVelocity().Size();
auto Character = Cast<ACharacter>(Pawn);
if (Character)
{
IsFalling = Character->GetMovementComponent()->IsFalling();
}
}
}
// 위 NaticeUpdateAnimation이 호출되는 순간에 ACharacter를 받던걸
// AMyCharacter를 받도록 바꾼다.(#include "MyCharacter.h")
// 🔻
void UMyAnimInstance::NativeUpdateAnimation(float DeltaSeconds)
{
Super::NativeUpdateAnimation(DeltaSeconds);
auto Pawn = TryGetPawnOwner();
if (IsValid(Pawn))
{
Speed = Pawn->GetVelocity().Size();
auto Character = Cast<AMyCharacter>(Pawn);
if (Character)
{
IsFalling = Character->GetMovementComponent()->IsFalling();
Vertical = Character->UpDownValue;
Horizontal = Characger->LeftRightValue;
}
}
}
그리로 ABP_MyAnim에서 Ground스테이트머신안에서 우클릭으로 추가했던 Horizontal, vertical을 가져와
이렇게 움직이는 포즈로 묶어준다. 컴파일하고 실행해보면 대각선으로 움직일 때도 자연스러운 움직임을 하는걸 볼 수 있다.
만약 이 움직임을 빨리했을때 버벅거리는게 거슬리면 다시 ABP_MyAnim으로 가서 Interpolation Time을 0.1초로 줘서 좀 더 부드럽게 하면 된다.
'[Unreal]' 카테고리의 다른 글
[Unreal 4] 충돌과 UI #2 - 스탯 매니저, UI 실습 (0) | 2023.06.25 |
---|---|
[Unreal 4] 충돌과 UI #1 - 충돌 기초, 소켓 실습, 아이템 줍기 (0) | 2023.05.22 |
[Unreal 4] 애니메이션 - 애니메이션 기초, 스테이트 머신 (0) | 2023.03.24 |
[Unreal 4] 기초 #2 - 캐릭터 생성, 블루프린트 클래스 (0) | 2023.03.20 |
[Unreal 4] 기초 #1 - 인터페이스 분석, 유니티 vs. 언리얼, 로그와 디버깅, 게임플레이 프레임워크 (0) | 2023.02.22 |