Chapter 06. Animation
6.1 Animation Instance
6.1-1 애니메이션 인스턴스
- 실습 준비
이전에 열심히 만든 시점 변환 관련 코드는 모두 지우고자 함.
시점은 고정한 상태로 앞으로의 실습을 진행. 시점 변환 코드가 쓸데없이 너무 많음.
필요한 시점이 있다면 앞서 배운 내용을 토대로 제작해보자.
<hide/>
// SPlayerCharacter.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "InputActionValue.h"
#include "SPlayerCharacter.generated.h"
UCLASS()
class STUDYPROJECT_API ASPlayerCharacter : public ACharacter
{
GENERATED_BODY()
public:
ASPlayerCharacter();
protected:
virtual void SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) override;
private:
void Move(const FInputActionValue& InValue);
void Look(const FInputActionValue& InValue);
private:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, meta = (AllowPrivateAccess))
TObjectPtr<class USpringArmComponent> SpringArmComponent;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, meta = (AllowPrivateAccess))
TObjectPtr<class UCameraComponent> CameraComponent;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, meta = (AllowPrivateAccess))
TObjectPtr<class USInputConfigData> PlayerCharacterInputConfigData;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, meta = (AllowPrivateAccess))
TObjectPtr<class UInputMappingContext> PlayerCharacterInputMappingContext;
};
<hide/>
// SPlayerCharacter.h
#include "Characters/SPlayerCharacter.h"
#include "Components/CapsuleComponent.h"
#include "GameFramework/SpringArmComponent.h"
#include "Camera/CameraComponent.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "Components/InputComponent.h"
#include "GameFramework/Controller.h"
#include "EnhancedInputComponent.h"
#include "EnhancedInputSubsystems.h"
#include "Inputs/SInputConfigData.h"
ASPlayerCharacter::ASPlayerCharacter()
{
PrimaryActorTick.bCanEverTick = true;
float CharacterHalfHeight = 90.f;
float CharacterRadius = 40.f;
GetCapsuleComponent()->InitCapsuleSize(CharacterRadius, CharacterHalfHeight);
#pragma region InitializeSkeletalMesh
FVector PivotPosition(0.f, 0.f, -CharacterHalfHeight);
FRotator PivotRotation(0.f, -90.f, 0.f);
GetMesh()->SetRelativeLocationAndRotation(PivotPosition, PivotRotation);
#pragma endregion
#pragma region InitializeSpringArm
SpringArmComponent = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArmComponent"));
SpringArmComponent->SetupAttachment(RootComponent);
SpringArmComponent->TargetArmLength = 400.f;
bUseControllerRotationPitch = false;
bUseControllerRotationYaw = false;
bUseControllerRotationRoll = false;
SpringArmComponent->bUsePawnControlRotation = true;
SpringArmComponent->bDoCollisionTest = true;
SpringArmComponent->bInheritPitch = true;
SpringArmComponent->bInheritYaw = true;
SpringArmComponent->bInheritRoll = false;
#pragma endregion
#pragma region InitializeCamera
CameraComponent = CreateDefaultSubobject<UCameraComponent>(TEXT("CameraComponent"));
CameraComponent->SetupAttachment(SpringArmComponent);
CameraComponent->SetRelativeLocation(FVector(0.f, 100.f, 0.f)); // TPS 방식의 슈팅 게임 특징.
#pragma endregion
GetCharacterMovement()->MaxWalkSpeed = 500.f;
GetCharacterMovement()->MinAnalogWalkSpeed = 20.f;
GetCharacterMovement()->JumpZVelocity = 700.f;
GetCharacterMovement()->AirControl = 0.35f;
GetCharacterMovement()->BrakingDecelerationWalking = 2000.f;
GetCharacterMovement()->bOrientRotationToMovement = false;
GetCharacterMovement()->bUseControllerDesiredRotation = true;
GetCharacterMovement()->RotationRate = FRotator(0.f, 480.f, 0.f);
}
void ASPlayerCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
Super::SetupPlayerInputComponent(PlayerInputComponent);
if (APlayerController* PlayerController = Cast<APlayerController>(GetController()))
{
UEnhancedInputLocalPlayerSubsystem* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(PlayerController->GetLocalPlayer());
if (nullptr != Subsystem)
{
Subsystem->AddMappingContext(PlayerCharacterInputMappingContext, 0);
}
}
if (UEnhancedInputComponent* EnhancedInputComponent = Cast<UEnhancedInputComponent>(PlayerInputComponent))
{
EnhancedInputComponent->BindAction(PlayerCharacterInputConfigData->MoveAction, ETriggerEvent::Triggered, this, &ASPlayerCharacter::Move);
EnhancedInputComponent->BindAction(PlayerCharacterInputConfigData->LookAction, ETriggerEvent::Triggered, this, &ASPlayerCharacter::Look);
}
}
void ASPlayerCharacter::Move(const FInputActionValue& InValue)
{
if (nullptr != GetController())
{
FVector2D MovementVector = InValue.Get<FVector2D>();
const FRotator ControlRotation = GetController()->GetControlRotation();
const FRotator ControlRotationYaw(0.f, ControlRotation.Yaw, 0.f);
const FVector ForwardVector = FRotationMatrix(ControlRotationYaw).GetUnitAxis(EAxis::X);
const FVector RightVector = FRotationMatrix(ControlRotationYaw).GetUnitAxis(EAxis::Y);
AddMovementInput(ForwardVector, MovementVector.X);
AddMovementInput(RightVector, MovementVector.Y);
}
}
void ASPlayerCharacter::Look(const FInputActionValue& InValue)
{
if (nullptr != GetController())
{
FVector2D LookVector = InValue.Get<FVector2D>();
AddControllerYawInput(LookVector.X);
AddControllerYawInput(LookVector.Y);
}
}
- 애니메이션 준비
AnimStarterPack에는 걷기 뛰기 점프 등이 없음.
포트폴리오를 제작하는데 있어서 애니메이션이 부족하다면 마켓 플레이스의 다른 애셋팩들에서 꺼내와야함.
그 방법이 애니메이션 리타깃임.
- 애니메이션 리타깃(Animation Retarget)
다른 스켈레톤 구성을 가진 캐릭터의 애니메이션 교환은 불가능함.
다만 언리얼 엔진은 인간형 캐릭터의 경우 스켈레톤의 구성이 달라도
애니메이션을 교환할 수 있도록 애니메이션 리타깃 기능을 제공함.
ex. Paragon 캐릭터 스켈레톤 전용 점프 애니메이션을 UE4_Mannequin 캐릭터 스켈레톤 기반으로 변환.
궁금하다면 여기를 참고.
- 스켈레탈 애셋 애니메이션 리타깃 준비
애니메이션 리타깃이 정상 동작하려면 애니메이션을 교환할 스켈레톤에 세팅이 필요함.
1. Content Browser > AnimStarterPack > UE4_Mannequin > Mesh > UE4_Mannequin_Skeleton 더블클릭.
Toolbar > Retarget Manager > Manage Retarget Source > Add New > SK_Mannequin 추가.
2. Content Browser > Characters > Mannequins > Meshs > SK_Mannequin 더블클릭
Toolbar > Retarget Manager > Manage Retarget Source > Add New > SKM_Manny_Simple 추가.
- IK Rig 파일과 IK Retageter 파일 생성
1. Content Browser > StudyProject 우클릭 > 새 폴더 "Retarget"
Retaget > 새 Animation 애셋 > IK Rig > 소스 스켈레탈 매시(SK_Manny) 선택 > "IKR_Manny"
새 애니메이션 애셋 > IK Rig > 타겟 스켈레탈 매시(KwangManbun) 선택 > "IKR_Mannequin"
IKR_Manny 더블클릭 > IK Retargeting > Add New Chain을 통해 아래와 같이 작성.
Hierachy > pelvis 노드 우클릭 > Set Retarget Root 클릭.
IKR_Mannequin도 동일한 작업 해줘야 함.

2. Content Browser > StudyProject > Retarget
새 Animation 애셋 > IK Retargetor > 소스 IK Rig으로 IKR_Manny 지정. > "IKT_FromMannyToMannequin"
IKT_FromMannyToMannequin 더블클릭 하면 경고창이 뜸. Apply Offset 클릭.
Details > Target IKRig Asset에 IKR_Mannequin 지정. 경고창 Apply Offset 클릭.
Asset Browser > MM_Idle, MM_Jump, MM_Fall_Loop, MM_Land, MM_Walk_Fwd, MM_Run_Fwd 블럭지정.
Export Selected
3. Content Browser > Retarget 우클릭 새폴더 > "Animations"
IKT_FromMannyToMannequin 더블클릭 > Asset Browser > MM_Fall_Loop / MM_Jump / MM_Land
Export Selected Animations 클릭 > Retarget/Animations 경로 선택.
- 앞으로도 여러 애니메이션 애셋을 사서 리타깃 기능을 이용하면 좋음.
Content Browser > Blueprints > 새 애니메이션 애셋 > Animation Blueprint
Kwang_Skeleton/SAnimIntance 지정 후 생성 > "ABP_SPlayerCharacter"
ABP_SPawn을 참고해서 똑같이 만들어 보자.
- 플레이어 캐릭터 애셋 변경
Content Browser > BP_SPlayerCharacter > Details
Anim Class에 ABP_SPlayerCharacter 지정
Skeletal Mesh Asset에 KwangManbun 지정
플레이 해서 테스트 해보기.
/*
믹사모 사이트에서 아래 이름의 애니메이션들을 다운로드.
다운로드 받은 애니메이션은 Content Browser > Mixamo > Animations > AnimationSequences에 임포트.
Idle, Jump(달리기 중에 점프 1개, 가만히 서 있는 중에 점프 1개), Run,
Knife Idle, Stable Sword Inward/Outward Slash, Sword And Shield Slash
이름도 모두 변경.
Idle_NoWeapon, Walk_NoWeapon, Jump_OnIdle, Jump_OnRunning, Run_NoWeapon
Idle_Knife, Attack_Knife1, Attack_Knife2, Attack_Knife3
*/
- 애니메이션 시스템
애님 인스턴스(Anim Instance)
스켈레탈 메시를 소유한 폰의 정보를 받아,
애님 그래프가 참조할 데이터를 제공함. 블루프린트와 C++로 구현 가능.
애님 인스턴스에는 두 영역이 있음. 이벤트 그래프와 애님 그래프.
이벤트 그래프(Event Graph)
이벤트로부터 상태를 파악할 수 있는 주요 변수를 업데이트 하는 영역.
주요 이벤트로는 NativeInitializeAnimation()과 NativeUpdateAnimation() 함수.
애님 그래프(Anim Graph)
업데이트된 변수 값에 따라 지정된 애니메이션을 재생하는 영역.
상황에 따라 적합한 애니메이션을 체계적으로 재생할 수 있도록 스테이트 머신 기능을 제공.
블루프린트로만 제작할 수 있음.
- 애님 인스턴스 생성
새 C++ 클래스 > AnimInstance 부모 클래스 > "SAnimInstance"
Path > Animations
폰의 속도에 따라 다른애니메이션을 재생하기 위해서는
프레임마다 폰의 속력과 애님 인스턴스의 CurrentSpeed 값을 연동해야 함.
1. 폰의 Tick() 이벤트 함수에서 애님 인스턴스의 CurrentSpeed를 업데이트
2. 애님 인스턴스의 Tick() 이벤트 함수에서 폰의 속도 정보를 가져온 후,
이를 CurrentSpeed에 업데이트
개체지향 프로그래밍에서는 2번의 형태로 개발할 때가 많음.
폰에는 너무 많은 코드를 작성되기 때문에 유지보수성을 떨어뜨릴 수도 있으니 2번으로 구현.
애님 인스턴스 클래스는 프레임마다 호출되는 NativeUpdateAnimation() 함수를
가상 함수로 제공함.
<hide/>
// SAnimInstance.h
#pragma once
#include "CoreMinimal.h"
#include "Animation/AnimInstance.h"
#include "SAnimInstance.generated.h"
/**
*
*/
UCLASS()
class STUDYPROJECT_API USAnimInstance : public UAnimInstance
{
GENERATED_BODY()
public:
USAnimInstance();
virtual void NativeInitializeAnimation() override;
virtual void NativeUpdateAnimation(float DeltaSeconds) override;
private:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = USTPSAnimInstance, meta = (AllowPrivateAccess))
TObjectPtr<class ACharacter> OwnerCharacter;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = USTPSAnimInstance, meta = (AllowPrivateAccess))
TObjectPtr<class UCharacterMovementComponent> MovementComponent;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = USTPSAnimInstance, meta = (AllowPrivateAccess))
float CurrentSpeed;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = USTPSAnimInstance, meta = (AllowPrivateAccess))
uint8 bIsFalling : 1;
};
<hide/>
// SAnimInstance.cpp
#include "Animations/SAnimInstance.h"
#include "GameFramework/Character.h"
#include "GameFramework/CharacterMovementComponent.h"
USAnimInstance::USAnimInstance()
{
}
void USAnimInstance::NativeInitializeAnimation()
{
Super::NativeInitializeAnimation();
OwnerCharacter = Cast<ACharacter>(GetOwningActor());
check(nullptr != OwnerCharacter);
MovementComponent = OwnerCharacter->GetCharacterMovement();
check(nullptr != MovementComponent);
}
void USAnimInstance::NativeUpdateAnimation(float DeltaSeconds)
{
CurrentSpeed = MovementComponent->GetLastUpdateVelocity().Size();
bIsFalling = MovementComponent->IsFalling();
}
- 프레임마다의 이벤트 함수 실행 흐름
입력 시스템 > 게임 로직 > 애니메이션 시스템
이는 플레이어의 의지인 입력 값을 받은 후 그것을 해석해 폰을 움직이게 만들고
폰의 최종 움직임과 맞는 애니메이션을 재생시키는 것이 자연스럽기 때문.
만일 게임 로직 단계에서 폰이 제거 당하면, 애니메이션 시스템에서는
유효하지 않은 폰 개체를 참조하게 됨. 이를 검사하는 것이 TryGetPawnOwner() 함수임.
- 코드 리팩토링.
위 코드는 게임 플레이 도중에 캐릭터 사망시 잘못된 포인터를 가르킬 확률이 있음.
<hide/>
// SAnimInstance.h
...
class STUDYPROJECT_API USAnimInstance : public UAnimInstance
{
GENERATED_BODY()
public:
virtual void NativeUpdateAnimation(float DeltaSeconds) override;
private:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = USTPSAnimInstance, meta = (AllowPrivateAccess))
float CurrentSpeed;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = USTPSAnimInstance, meta = (AllowPrivateAccess))
uint8 bIsFalling : 1;
};
<hide/>
// SAnimInstance.cpp
#include "Animations/SAnimInstance.h"
#include "GameFramework/Character.h"
#include "GameFramework/CharacterMovementComponent.h"
void USAnimInstance::NativeUpdateAnimation(float DeltaSeconds)
{
Super::NativeUpdateAnimation(DeltaSeconds);
ACharacter* OwnerCharacter = Cast<ACharacter>(TryGetPawnOwner());
if (true == ::IsValid(OwnerCharacter))
{
UCharacterMovementComponent* CharacterMovementComponent = OwnerCharacter->GetCharacterMovement();
if (true == ::IsValid(CharacterMovementComponent))
{
CurrentSpeed = CharacterMovementComponent->GetLastUpdateVelocity().Size();
bIsFalling = CharacterMovementComponent->IsFalling();
}
}
}
- 애니메이션 블루프린트 애셋 생성
Content Browser > StudyProject > Animations > 새 Animation 애셋 > Animation Blueprint
Steve Skeleton > SAnimInstance 부모 클래스 > "ABP_PlayerCharacter"
ABP_PlayerCharacter > My Blueprint > 우측 톱니바퀴 버튼 클릭 > Show Inherited Variables 클릭
- Idle과 Run 모션 중 하나를 재생하는 노드 만들기
1. CurrentSpeed 변수를 드래그해서 작업 공간에 드랍.
Get 메뉴 선택하면 노드가 생성됨.
BlueprintReadOnly 키워드라, Set 메뉴는 비활성되어 있음.
2. 변수의 Get 노드로부터 마우스를 드래그 한 후 빈 공간에 드랍하면
float 관련 명령어 목록이 나옴. 검색창에 > 부등호 기호를 치면
float 간의 크기를 비교하는 float > float 노드를 선택할 수 있음.
3. 부등호 노드의 결과 핀을 다시 한 번 빈 공간에 드래그 드랍한 후 blend라고 검색.
두 애니메이션 중 하나를 선택해 재생하는 "Blend Poses by bool" 선택.
Alt 키 + 해당 핀을 누르면 기존의 노드 연결을 끊을 수 있음.
4.애셋 브라우저 창에서 IDLE 애니메이션을 드래그해서 작업 공간에 드랍.
True 핀에는 Run 애니메이션을 연결하고,
False 핀에는 IDLE 애니메이션을 연결해서 애님 그래프를 완성.
Idle 애니메이션과 Run 애니메이션의 Details에서 Loop Animation 체크.
컴파일 후 저장

- 애님 프리뷰 에디터 윈도우
위에서 만든 애님 그래프가 설계 대로 잘 동작하는지 테스트해보자.
애니메이션 블루프린트에서 이를 시뮬레이션 할 수 있는 곳은 애님 프리뷰 에디터임.
Anim Preview Editor > GroundSpeed 값을 바꾸면 실험 가능.
- 왜 AnimInstance에 GroundSpeed를 따로 만들까?
그냥 OwnerCharacter에 접근해서 가져오면 안될까?
AnimIntance 관련 로직은 다른 스레드에서 동작함.
즉, 같은 변수를 참조하게되면 동기화 문제가 발생함.
나중에 ThreadSafe 키워드가 달린 블루프린트 코드나 C++ 코드를 보게되면
이와 관련된 내용이란걸 알 수 있음.
- GetAnimInstance() 함수
폰에서 애님 인스턴스에 접근할 수 있게 해주는 함수. 결국 이전 방식과 결과는 같음.
하지만 대부분의 프로젝트에선 이전 방식을 사용함.
이전 방법으로 설계하면 애니메이션 로직과 폰의 로직을 분리할 수 있기 때문.
때문에 애니메이션이 필요 없는 서버 코드에도 문제없이 동작 가능.
<hide/>
USAnimInstance* CharacterAnimInstance = Cast<USAnimInstance>(GetMesh()->GetAnimInstance());
check(nullptr != CharacterAnimInstance);
CharacterAnimInstance->SetCurrentPawnSpeed(GetVelocity().Size());
6.1-2 State Machine
- 여러 애니메이션 재생을 위한 속성 값 준비
점프한 캐릭터가 언제 땅에 떨어질지 모르기 때문에 어려움.
먼저 애니메이션 블루프린트는 현재 캐릭터가 점프 중인지, 땅 위에 있는지 파악해야 함.
폰의 무브먼트 컴포넌트가 제공하는 함수들로 파악 가능.
IsFalling()
현재 공중에 떠있는지
IsSwimming()
현재 수영 중인지
IsCrouching()
현재 쭈그리고 앉아 있는지
IsMoveOnGround()
땅 위에서 이동 중인지
다만, 이게 폰의 무브먼트 컴포넌트에 정의 되어 있지만
이 기능들을 제대로 구현한 컴포넌트는 캐릭터 무브먼트 컴포넌트 뿐.
FloatingPawnMovement 컴포넌트는 위 함수들에 대해 모두 false 값을 반환함.
<hide/>
// SAnimInstance.h
...
class STUDYPROJECT_API USAnimInstance : public UAnimInstance
{
...
public:
virtual void NativeInitializeAnimation() override;
virtual void NativeUpdateAnimation(float DeltaSeconds) override;
private:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = USAnimInstance, meta = (AllowPrivateAccess))
FVector Velocity;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = USAnimInstance, meta = (AllowPrivateAccess))
float CurrentSpeed;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = USAnimInstance, meta = (AllowPrivateAccess))
uint8 bIsIdle : 1;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = USAnimInstance, meta = (AllowPrivateAccess))
float ThresholdForWalk;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = USAnimInstance, meta = (AllowPrivateAccess))
float ThresholdForRun;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = USAnimInstance, meta = (AllowPrivateAccess))
uint8 bIsFalling : 1;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = USAnimInstance, meta = (AllowPrivateAccess))
uint8 bIsCrouching : 1;
};
<hide/>
// SAnimInstance.cpp
...
void USAnimInstance::NativeInitializeAnimation()
{
Velocity = FVector::ZeroVector;
CurrentSpeed = 0.f;
bIsIdle = true;
ThresholdForWalk = 5.f;
ThresholdForRun = 500.f;
bIsFalling = false;
bIsCrouching = false;
}
void USAnimInstance::NativeUpdateAnimation(float DeltaSeconds)
{
Super::NativeUpdateAnimation(DeltaSeconds);
ACharacter* OwnerCharacter = Cast<ACharacter>(TryGetPawnOwner());
if (true == ::IsValid(OwnerCharacter))
{
UCharacterMovementComponent* CharacterMovementComponent = OwnerCharacter->GetCharacterMovement();
if (true == ::IsValid(CharacterMovementComponent))
{
Velocity = CharacterMovementComponent->GetLastUpdateVelocity();
CurrentSpeed = Velocity.Size();
bIsIdle = CurrentSpeed < ThresholdForWalk;
bIsFalling = CharacterMovementComponent->IsFalling();
bIsCrouching = CharacterMovementComponent->IsCrouching();
}
}
}
- 스테이트 머신
유한 상태 기계. 스테이트 머신은 기계가 반복 수행해야하는 동작 단위인 스테이트들을 정의하고
그 중 하나의 스테이트만 지정해 해당 스테이트에서 지정한 동작을 반복 수행함.
- 스테이트 머신 실습
ABP_PlayerCharacter > 애님 그래프 빈공간 우클릭 > "State Machine" 검색 > "BaseAction"
Output Pose에 연결된 모든 노드 블럭 지정 후 Ctrl + X. BaseAction 노드와 Output Pose 연결.
스테이트 머신을 더블 클릭하면 스테이트 머신 편집 화면이 나옴.
빈공간 우클릭 > Add State > "Ground"
Entry 노드 클릭 후 드래그 > Ground 스테이트 노드에 연결.
Ground 스테이트 노드 더블클릭 > Ctrl + V
컴파일 후 플레이.
- 룰(Rule)
스테이트 머신을 설계할 때, 하나의 스테이트에서 다른 스테이트로
이동하기 위한 조건이 필요함. 스테이트 머신에선 이를 트랜지션(Transition)이라
불리는 단방향 화살표로 표현함. 언리얼 엔진에서는 트랜지션을 룰이라는 용어로 표시.
ex. 시작 지점인 Entry 노드로부터 Ground 스테이트까지 드래그하면 룰이 생성됨.
- 시작 스테이트
시작 지점인 Entry와 연결된 스테이트를 특별히 시작 스테이트라고도 함.
- 스테이트의 애니메이션 설정으로 들어가기
Ground 스테이트 더블클릭 하면 해당 스테이트의 애니메이션 설정으로 들어가짐.
여기에는 애님 그래프와 동일하게 출력 애니메이션 포즈 노드가 존재함.
이전에 그렸던 애님 그래프를 그대로 그려서 출력 애니메이션 포즈에 연결
Ground 스테이트의 작업을 완료하면 컴파일과 저장 후 테스트 해보면 똑같음.
애님 그래프의 최종 애니메이션 포즈에 연결된게 BaseAction 스테이트 머신인데,
이 스테이트 머신은 시작할 때 Ground 스테이트를 실행하기 때문. 그래서 같음.
6.1-3 점프 구현
- 점프 구현 전 준비 사항
Content Browser > InputActions > 새 Input 애셋 > Input Action 부모 클래스 > "IA_Jump"
InputConfigs > IC_PlayerCharacter에도 IA_Jump을 추가하기 위해서 SInputConfigData 클래스 파일 수정.
IMC_PlayerCharacter에도 아래 그림과 같이 수정.
<hide/>
// SInputConfigData.h
...
class STUDYPROJECT_API USInputConfigData : public UDataAsset
{
...
public:
...
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TObjectPtr<class UInputAction> JumpAction;
};

- ACharacter::Jump() 멤버 함수를 그대로 이용한 점프 구현
<hide/>
// SPlayerCharacter.h
...
void ASPlayerCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
...
if (UEnhancedInputComponent* EnhancedInputComponent = Cast<UEnhancedInputComponent>(PlayerInputComponent))
{
...
EnhancedInputComponent->BindAction(PlayerCharacterInputConfigData->JumpAction, ETriggerEvent::Started, this, &ACharacter::Jump);
}
}
...
- 점프 애니메이션 구현 실습
ABP_PlayerPawn > BaseAction 스테이트 머신 > Add State > "Jump"
Jump 스테이트와 Ground 스테이트 사이에 양방향 트랜지션 연결.
Ground 스테이트에서 Jump 스테이트 사이의 동그란 기호 더블클릭
IsJumping 속성을 드래그 드랍 후 Result 노드에 연결
Jump 스테이트에서 Ground 스테이트 사이의 동그란 기호 더블클릭
IsFalling 속성을 드래그 드랍 후 Not Boolean 처리 후 Result 노드에 연결




- 숙제
가만히 서 있다가 점프 하는걸 구현해 보았음.
이번에는 달리다가 점프 하는걸 구현해보자. 단, 가만히 서있다가 점프하는건 그대로 유지되어야함.
- 점프 애니메이션 개선
지금의 점프는 상당히 어색함.
좀 더 부드러운 점프를 위해서는 잘게 나눠야함. 점프 시작 > 점프 중 > 점프 끝으로 나누고자 함.
또한 체공 시간이 더 길어지면(Character 클래스의 ZVelocity를 키워보자.) 공중인데도
바닥에 닿은 애니메이션이 재생됨. 그래서 애니메이션에 대한 기획을
점프 시작시엔 무릎을 구부렸다 펴는 애니메이션 한 번만,
점프 중에는 둥둥 떠있는 애니메이션 반복,
점프 마지막엔 발을 딛는 애니메이션 한 번만 재생하게끔 함.
- 점프 애니메이션 개선을 위한 준비
지금은 점프 애니메이션이 하나의 애니메이션임.
이 애니메이션을 자르고자 함.
Jump_OnIdle 복사 붙혀넣기 > Jump_OnIdle_2 더블클릭
타임 테이블에 마우스를 올려두고 Ctrl + 휠하면 프레임을 정할 수 있음.
적당한 부분을 우클릭 > Remove Frame from 0 to Frame 5 클릭
Jump_OnIdle_2 클릭 > 복사 붙혀넣기 > Jump_OnIdle_Start
Remove Frame from 30 to Frame 67
Jump_OnIdle_2 클릭 > 복사 붙혀넣기 > Jump_OnIdle_Loop
Remove Frame from 0 to Frame 29 클릭
Jump_OnIdle_2 클릭 > 복사 붙혀넣기 > Jump_OnIdle_End
Remove Frame from 0 to Frame 30 클릭 후 Remove Frame from 34 to Frame 42 클릭.
Jump_OnIdle_2 애셋은 삭제.
- 스테이트 머신의 설계
ABP_SPawn > BaseAction 스테이트 머신으로 이동
Jump 스테이트를 제거하고 대신 그 자리에
JumpStart, JumpLoop 스테이트 생성
JumpStart 스테이트 더블클릭 > Asset Browser > MM_Jump 애니메이션을
Output Animation Pose에 연결.
JumpLoop 스테이트는 각각 MM_Fall_Loop를 Output Animation Pose에 연결.
MM_Jump는 Details > Loop Animation 체크 해제.
MM_Fall_Loop는 Loop Animation 체크 해야함.
Ground -> JumpStart 트랜지션은 IsFalling
JumpLoop -> Ground 트랜지션은 IsFalling에 Not.







- Time Remaining 노드
"time remaining (ratio)" 검색 후 노드 생성.
이전 스테이트에서 사용한 애니메이션 재생의 남은 시간 비율을 얻어오는 노드.
이 값이 0.1이면 재생 시간이 10% 남은 상황인 것.
이는 현재 사용하고 있는 도약 애니메이션이 90% 진행되었다는 의미.
남은 시간의 비율이 0.1보다 작으면 다음 스테이트로 이동하도록 설계.
- Automatic Rule Based on Sequence Player in Slate 옵션
애니메이션 종료 시 자동으로 스테이트가 전환되는 옵션.
Time Remaining (ratio) 노드를 활용한 트랜지션은 이 옵션을 체크해도됨.
- 그래도 애니메이션이 이상함.
점프 시작 애니메이션과 점프 끝 애니메이션이 너무 김.
JumpStart > Jump_OnIdle_Start 애니메이션 노드 클릭 > Details > Play Rate를 4.0 설정.
JumpEnd > Jump_OnIdle_End 애니메이션 노드 클릭 > Details > Play Rate를 4.0 설정.
6.2 Animation Montage and Notify
6.2-1 Animation Montage
- 스테이트 머신의 단점
다음 실습으로 공격을 구현해 보고자 함. 특히 콤보 공격도 구현해 볼 예정.
스테이트 머신을 사용해 콤보 공격의 모든 공격 스테이트를 생성하고
트랜지션을 연결해서 콤보 공격을 구현할 순 있음.
그러나 만약 콤보 동작이 추가된다면 스테이트를 계속 추가해야 하므로,
스테이트 머신의 설계가 복잡해진다는 단점이 있음.
- 애니메이션 몽타주(Animation Montage)
스테이트 머신의 확장 없이 특정 상황에서 원하는 애니메이션을 발동 시키는 기능.
본래 몽타주는 이미 촬영된 화면을 떼서 새로운 장면을 만드는 기법.
애니메이션 몽타주도 유사함. 여러 애니메이션 클립들의 일부를 떼고 붙여서
새로운 애니메이션을 생성하는 기법.
이때 섹션 단위로 애니메이션들을 자르고 붙이는 작업을 진행함.
- 실습 준비
콤보 공격을 위해서 Attack_Knife1 애니메이션 시퀀스를 적절하게 자르고자 함.
칼을 한번 벤 상태에서 되돌아오는 동작이라, 0번 프레임부터 30번 프레임은 자름.
- 공격에 사용할 애니메이션 몽타주 애셋 생성
1. Content Browser > StudyProject > Animations 우클릭 > 새 폴더 "AnimationMontages"
AnimationMontages 우클릭 > 새 Animation 애셋 > Animation Montage > Steve_Skeleton
"AM_Attack_Knife" 생성. 더블클릭 하여 열기.
2. Window > Anim Slot Manager 클릭
Anim Slot Manager > Add Group > "SGroup" 추가
SGroup 클릭 후 Add Group > "FullBody" 추가
3. 몽타주 섹션 > DefaultGroup.DefaultSlot 우측 역삼각형 클릭
Slot Name > SGroup.FullBody로 지정.
4. Asset Browser > Attack_Knife0~2까지를 SGroup.FullBody 슬롯에
지그재그로 드래그 드랍 후 플레이 버튼을 누르면 결과를 볼 수 있음.
5. 하단의 타임 테이블에 Default라는 이름의 섹션이 주어짐.
Default 섹션 클릭 > Details > Section Name에 "Attack1" 작성
섹션 타임 라인에 우클릭 > New Montage Section > "Attack2", "Attack3"
Attack_Knife0과 Attack_Knife1 사이에 Attack2,
Attack_Knife1과 Attack_Knife2 사이에 Attack3를 배치.
- 공격 후 제자리로 돌아오는 움직임 제거하기
각 공격 모션이 끝나는 시간을 파악하고 애니메이션 클립을 클릭 한 후
Attack_Knife0 > Details > End Time을 수정해주면 됨. 예제에선 1.5로 수정.
- Attack 입력 추가
Content Browser > InputActions > 새 Input 애셋 > Input Action 부모 클래스 > "IA_Attack"
InputConfigs > IC_PlayerCharacter에도 IA_Attack을 추가하기 위해서 SInputConfigData 클래스 파일 수정.
IMC_PlayerCharacter > IA_Attack을 마우스 좌클릭에 매핑.
<hide/>
// SInputConfigData.h
...
class STUDYPROJECT_API USInputConfigData : public UDataAsset
{
...
public:
...
UPROPERTY(EditAnywhere, BlueprintReadOnly)
TObjectPtr<class UInputAction> AttackAction;
};
- Attack 관련 입력 처리
<hide/>
// SPlayerCharacter.h
...
class STUDYPROJECT_API ASPlayerCharacter : public ACharacter
{
...
private:
...
void Attack(const FInputActionValue& InValue);
private:
...
};
<hide/>
// SPlayerCharacter.h
...
void ASPlayerCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
...
if (UEnhancedInputComponent* EnhancedInputComponent = Cast<UEnhancedInputComponent>(PlayerInputComponent))
{
...
EnhancedInputComponent->BindAction(PlayerCharacterInputConfigData->AttackAction, ETriggerEvent::Started, this, &ThisClass::Attack);
}
}
...
void ASPlayerCharacter::Attack(const FInputActionValue& InValue)
{
UE_LOG(LogTemp, Log, TEXT("Attack() has been called."));
}
- Montage_IsPlaying() 함수와 Montage_Play() 함수
Montage_IsPlaying() 함수를 사용하면 현재 몽타주가 재생 되고 있는지 파악 가능.
재생 중이 아니라면 Montage_Play() 함수를 사용해서 재생할 수도 있음.
컴파일 후 Content Browser > ABP_PlayerCharacter > Details > AttackAnimMontage에 AM_Attack_Knife 지정.
<hide/>
// SAnimInstance.h
...
class STUDYPROJECT_API USAnimInstance : public UAnimInstance
{
...
public:
...
private:
void PlayAttackAnimMontage();
private:
...
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = USAnimInstance, meta = (AllowPrivateAccess))
TObjectPtr<class UAnimMontage> AttackAnimMontage;
};
<hide/>
// SAnimInstance.cpp
...
void USAnimInstance::PlayAttackAnimMontage()
{
if (false == Montage_IsPlaying(AttackAnimMontage))
{
Montage_Play(AttackAnimMontage);
}
}
- SGroup.FullBody 슬롯 노드
ABP_PlayerCharacter > AnimGraph 빈공간 우클릭 > Slot 'DefaultSlot' 검색 후 생성
Default Slot 클릭 > Details > Slot Name에 SGroup.FullBody 슬롯 지정.
몽타주를 재생하려면 애님 그래프에 몽타주 재생 노드를 넣어야 함.
모든 상황에 공격 몽타주를 재생하기 위해서 BaseAction과
최종 애니메이션 포즈 사이에 FullBody 슬롯을 끼워넣음.
SPlayerCharacter 클래스는 아래와 같이 수정.

<hide/>
// SAnimInstance.h
...
class STUDYPROJECT_API USAnimInstance : public UAnimInstance
{
GENERATED_BODY()
friend class ASPlayerCharacter; // PlayAttackAnimMontage() 함수 호출을 위해.
public:
...
};
<hide/>
// SPlayerCharacter.h
...
class STUDYPROJECT_API ASPlayerCharacter : public ACharacter
{
...
public:
...
virtual void PostInitializeComponents() override;
protected:
...
private:
...
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, meta = (AllowPrivateAccess))
TObjectPtr<class USAnimInstance> AnimInstance;
};
<hide/>
// SPlayerCharacter.h
...
#include "Animations/SAnimInstance.h"
...
void ASPlayerCharacter::PostInitializeComponents()
{
Super::PostInitializeComponents();
AnimInstance = Cast<USAnimInstance>(GetMesh()->GetAnimInstance());
}
...
void ASPlayerCharacter::Attack(const FInputActionValue& InValue)
{
if (true == ::IsValid(AnimInstance))
{
AnimInstance->PlayAttackAnimMontage();
}
}
- OnMontageEnded() 델리게이트
애님 인스턴스에는 애니메이션 몽타주 재생이 끝나면 발동하는
OnMontageEnded라는 델리게이트를 제공함.
어떤 언리얼 오브젝트라도 UAnimMontage* 인자와 bool 인자를 가진
멤버 함수를 가지고 있다면, 이를 OnMontageEnded 델리게이트에
등록해 몽타주 재생이 끝나는 타이밍을 파악할 수 있음.
캐릭터 클래스에서 애님 인스턴스는 자주 사용할 예정.
멤버 변수로 선언해 런타임에서 이를 활용하도록 구조를 변경.
폰 로직에서 입력이 들어오면 애님 인스턴스의 PlayAttack()을 호출하도록 추가.
델리게이트에 의해 공격의 시작과 종료가 감지되므로,
AnimInstance에서 사용한 Montage_IsPlaying() 함수는 필요 없어짐.
컴파일 후 플레이해서 GWGhost 액터의 IsAttacking 속성을 검색해
공격이 시작될 때와 끝날 때 해당 속성 값이 변화되는지 살펴봐야함.
<hide/>
// SPlayerCharacter.h
...
class STUDYPROJECT_API ASPlayerCharacter : public ACharacter
{
...
public:
...
UFUNCTION()
void OnAttackMontageEnded(UAnimMontage* Montage, bool bInterrupted);
protected:
...
private:
...
uint8 bIsAttacking : 1;
};
<hide/>
// SPlayerCharacter.h
...
ASPlayerCharacter::ASPlayerCharacter()
: bIsAttacking(false)
{
...
}
void ASPlayerCharacter::PostInitializeComponents()
{
...
AnimInstance = Cast<USAnimInstance>(GetMesh()->GetAnimInstance());
if (true == ::IsValid(AnimInstance))
{
AnimInstance->OnMontageEnded.AddDynamic(this, &ThisClass::OnAttackMontageEnded);
}
}
void ASPlayerCharacter::OnAttackMontageEnded(UAnimMontage* Montage, bool bInterrupted)
{
bIsAttacking = false;
}
...
void ASPlayerCharacter::Attack(const FInputActionValue& InValue)
{
if (true == ::IsValid(AnimInstance))
{
bIsAttacking = true;
AnimInstance->PlayAttackAnimMontage();
}
}
- 애님 인스턴스 헤더에 선언된 OnMontageEnded가 사용하는 델리게이트 정의 코드
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnMontageEndedMCDelegate, UAnimMontage*, Montage, bool, bInterrupted);
델리게이트의 선언은 언리얼이 제공하는 매크로를 통해 정의됨.
이렇게 정의된 델리게이트의 형식을 시그니처(Signature)라고 함.
6.2-2 Animation Notify
- 애니메이션 노티파이
애니메이션을 재생하는 동안 특정 타이밍에 애님 인스턴스에게 신호를 보내는 기능.
애니메이션 노티파이는 일반 애니메이션과 몽타주 모두 사용 가능.
- 애니메이션 노티파이의 활용
만약 공격을 구현한다면, 특정 애니메이션 프레임에 충돌 검사를 해야함.
이때 애니메이션 노티파이를 활용하면 특정 프레임에 특정 함수가 호출됨.
- 노티파이 추가
AM_Attack_Knife > Notifies 1번 타임 라인 빈공간에 우클릭
Add Notifiy > New Notify 클릭 > "CheckHit"
적절한 위치에 CheckHit 노티파이 3개 배치.
이제 몽타주 애니메이션을 재생동안 재생 구간에 위치한 노티파이를 호출하게 됨.
노티파이가 호출되면 애님 인스턴스 클래스의 "AnimNotify_노티파이명"의
멤버 함수를 찾아서 호출하게됨.
해당 멤버 함수는 언리얼 실행 환경이 찾을 수 있도록 UFUNCTION 매크로 선언 필수.
앞서 이야기한 다이나믹 델리게이트와 연동되는 함수 이유와 동일함.

<hide/>
// SAnimInstance.h
...
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnCheckHitDelegate);
...
class STUDYPROJECT_API USAnimInstance : public UAnimInstance
{
...
private:
...
UFUNCTION()
void AnimNotify_CheckHit();
private:
...
FOnCheckHitDelegate OnCheckHitDelegate;
};
<hide/>
// SAnimInstance.cpp
...
void USAnimInstance::AnimNotify_CheckHit()
{
if (true == OnCheckHitDelegate.IsBound())
{
OnCheckHitDelegate.Broadcast();
}
}
<hide/>
// SPlayerCharacter.h
...
class STUDYPROJECT_API ASPlayerCharacter : public ACharacter
{
...
private:
...
UFUNCTION()
void CheckHit();
private:
...
};
<hide/>
// SPlayerCharacter.h
...
void ASPlayerCharacter::PostInitializeComponents()
{
Super::PostInitializeComponents();
AnimInstance = Cast<USAnimInstance>(GetMesh()->GetAnimInstance());
if (true == ::IsValid(AnimInstance))
{
AnimInstance->OnMontageEnded.AddDynamic(this, &ThisClass::OnAttackMontageEnded);
AnimInstance->OnCheckHitDelegate.AddDynamic(this, &ThisClass::CheckHit);
}
}
...
void ASPlayerCharacter::CheckHit()
{
UE_LOG(LogTemp, Log, TEXT("CheckHit() has been called.")); // 다음 단원에서 Collision에 대해 배움.
}
6.2-3 콤보 공격
- 구현 준비
몽타주 섹션 나눠야함. 각 공격 동작을 섹션으로 분리한 후 일정 시간 내에 공격 명령을 내리면
다음 공격 동작으로 이동하는 콤보 공격을 구현해보고자 함.
지금 이대로 저장 후 빌드하면 각 공격 섹션이 모두 연결되어 있어서 안끊어짐.
우하단에 Montage Sections > 각 공격 섹션 사이의 화살표 클릭 > Remove Link


- 노티파이 작업영역 추가
다음 공격 동작을 할지 말지 체크도 해야함.
Notifies 우측 역삼각형 클릭 > Add Notify Track 클릭 > 2
Notifies 2번 타임 라인 우클릭 > Add Notify > New Notify > "CheckCanNextCombo"
이때 주의할 점은, CheckCanNextCombo 노티파이가 한 섹션의 마지막에 가까워질수록
노티파이 실행 후 다음 몽타주 섹션 실행을 하기도 전에 OnMontageEnded()가 실행될 확률이 높음.
다음 콤보 공격이 안될수도 있다는 것.
또한 마지막 공격에서는 CheckCanNextCombo 할 필요가 없음.

- Branching Point
애니메이션 노티파이를 설정한 후에는 해당 프레임에 즉각적으로 반응하는 방식인
Branching Point 값으로 몽타주 틱 타입(Montage Tick Type)을 변경하는 것이 좋음.
특정 노티파이 클릭 > Details > Category > Event > Montage Tick Type
기본 값인 Queued로 설정하게 되면 비동기 방식으로 신호를 받게 되서 적절한 타이밍에
신호를 받는 것을 놓치게 될 수 있음. Queued 값은 주로 타이밍에 민감하지 않은
사운드나 이펙트를 발생시킬 때 사용하는 것이 적합함.
공격 적중이나 콤보 확인은 BranchingPoint가 적절함.
- 콤보 공격 구현
<hide/>
// SAnimInstance.h
...
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnCheckHitDelegate);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnCheckCanNextComboDelegate);
...
class STUDYPROJECT_API USAnimInstance : public UAnimInstance
{
...
private:
...
UFUNCTION()
void AnimNotify_CheckCanNextCombo();
private:
...
FOnCheckCanNextComboDelegate OnCheckCanNextComboDelegate;
};
<hide/>
// SAnimInstance.cpp
...
void USAnimInstance::AnimNotify_CheckCanNextCombo()
{
if (true == OnCheckCanNextComboDelegate.IsBound())
{
OnCheckCanNextComboDelegate.Broadcast();
}
}
<hide/>
// SPlayerCharacter.h
...
class STUDYPROJECT_API ASPlayerCharacter : public ACharacter
{
...
private:
...
void BeginCombo();
UFUNCTION()
void CheckCanNextCombo();
UFUNCTION()
void EndCombo(class UAnimMontage* InAnimMontage, bool bInterrupted);
private:
...
FString AttackAnimMontageSectionName = FString(TEXT("Attack"));
int32 MaxComboCount = 2;
int32 CurrentComboCount = 0;
bool bIsAttackKeyPressed = false; // 에디터에서 관리되거나 시리얼라이즈 될 필요 없으므로 그냥 bool 자료형 사용.
};
<hide/>
// SPlayerCharacter.h
...
void ASPlayerCharacter::PostInitializeComponents()
{
...
if (true == ::IsValid(AnimInstance))
{
AnimInstance->OnMontageEnded.AddDynamic(this, &ThisClass::OnAttackMontageEnded);
AnimInstance->OnCheckHitDelegate.AddDynamic(this, &ThisClass::CheckHit);
AnimInstance->OnCheckCanNextComboDelegate.AddDynamic(this, &ThisClass::CheckCanNextCombo);
}
}
...
void ASPlayerCharacter::Attack(const FInputActionValue& InValue)
{
if (0 == CurrentComboCount)
{
BeginCombo();
return;
}
else
{
ensure(FMath::IsWithinInclusive<int32>(CurrentComboCount, 1, MaxComboCount));
bIsAttackKeyPressed = true;
}
}
void ASPlayerCharacter::CheckHit()
{
}
void ASPlayerCharacter::BeginCombo()
{
if (false == ::IsValid(AnimInstance))
{
return;
}
CurrentComboCount = 1;
GetCharacterMovement()->SetMovementMode(EMovementMode::MOVE_None);
AnimInstance->PlayAttackAnimMontage();
FOnMontageEnded OnMontageEndedDelegate;
OnMontageEndedDelegate.BindUObject(this, &ThisClass::EndCombo);
AnimInstance->Montage_SetEndDelegate(OnMontageEndedDelegate, AnimInstance->AttackAnimMontage);
}
void ASPlayerCharacter::CheckCanNextCombo()
{
if (false == ::IsValid(AnimInstance))
{
return;
}
if (true == bIsAttackKeyPressed)
{
CurrentComboCount = FMath::Clamp(CurrentComboCount + 1, 1, MaxComboCount);
FName NextSectionName = *FString::Printf(TEXT("%s%d"), *AttackAnimMontageSectionName, CurrentComboCount);
AnimInstance->Montage_JumpToSection(NextSectionName, AnimInstance->AttackAnimMontage);
bIsAttackKeyPressed = false;
}
}
void ASPlayerCharacter::EndCombo(UAnimMontage* InAnimMontage, bool bInterrupted)
{
ensure(0 != CurrentComboCount);
CurrentComboCount = 0;
bIsAttackKeyPressed = false;
GetCharacterMovement()->SetMovementMode(EMovementMode::MOVE_Walking);
}
