개요
Unreal 을 사용하고, 협업하면서 자주 겪었던 문제는 초기화 시점 문제이다. 단순하게 특정 컴포넌
트가 초기화 되기 위해서, 다른 컴포넌트나 액터 가 먼저 초기화 되어야 하는 그런 상황이 많다.
이런 직접적인 의존성을 줄이고, Character의 BeginPlay 안에서 AComponent→Init(),
BComponent→Init() …. 이런 수동적인 초기화 코드들을 더 좋은 방향으로 바꿀 수 있을지 고민했다.
1. 전통적인 초기화 방식의 한계
// 수동 순서 관리 void AMyCharacter::BeginPlay() { Super::BeginPlay(); HealthComponent->Initialize(); InputComponent->Initialize(); } // Tick에서 반복 체크 void UHealthComponent::TickComponent(float DeltaTime, ...) { // 성능 문제는... // 언제 초기화 되는지 알수 있나?? if (!bInitialized) { if (AbilitySystem != nullptr) { Initialize(); bInitialized = true; } } } // Timer로 실행 void UHealthComponent::BeginPlay() { // 이것도 마찬가지... // 타이머도 관리해줘야 하네?? GetWorld()->GetTimerManager().SetTimer( TimerHandle, this, &UHealthComponent::TryInitialize, 0.1f ); }
위 코드와 유사한 초기화 방식을 안 짜본 사람은 없을 것이다.
간단한 소규모 게임에서는 효과적인 코드가 될 수도 있다.
하지만 규모가 커질 수록 다음과 같은 문제가 발생할 수 있다.
개인적으로는 협업에서의 문제점이 가장 크다고 생각한다.
- 타이밍 이슈 :
- 컴포넌트에서 ASC 가 필요하다면? 지금 초기화 된건지? 어디서 볼 수 있지?
- Health 와 InputComponent 의 순서가 중요하다면? 한 줄만 실수로 바꾸면 큰일…
- 비동기 데이터 로딩 - PrimaryAsset 이 로딩 중일 수 있음
- 멀티 플레이 이슈 :
- 만약 멀티 플레이 상황에서는 PlayerState 가 지연 복사될 경우는? 아예 컴포넌트들이 초기화되지 않을 수도 있는데?
- 유지 보수 :
- 컴포넌트가 추가되면 계속 수동 관리해줘야 한다.
- 컴포넌트들의 Initialize 함수를 호출하는 것을 까먹으면? (이 경우도 생각보다 많다)
- 초기화 순서가 바뀌면?
- 협업 위험 :
- 컴다른 개발자가 컴포넌트를 추가하려는데 순서를 모른다..
- 이 컴포넌트는 초기화 시 뭐가 필요한지 알기 어렵다
2. 초기화 스테이트 시스템 설명
간단하게 설명하면 다음과 같다.
1. 컴포넌트는 자기의 상태를 정해진 순서대로 변경 시작
2. 특정 상태에서 어떤 컴포넌트에 의존성을 가지는 지 정의
3. 의존성이 준비되었을 때 해당 클래스에서 다음 상태로 이동
4. 상태가 변화 했을 때, 어떤 처리를 할 것인지 정의
이전에 이에 대해 구체적으로 다룬 적이 있어서 링크를 첨부한다.
https://taehunkim0.github.io/unreal-gameframeworkcomponentmanager/
설명보다는 Lyra 에서 사용한 방식과 현재 내 프로젝트에 적용한 방식을 설명한다.
3. Lyra 에서 사용한 초기화 스테이트 시스템에 대한 분석
(Initialize State System = ISS 로 줄여서 적겠음.)
- Lyra 는 많은 컴포넌트가 있지만, ISS 는 두 컴포넌트에만 적용했다.
- LyraHeroComponent, LyraPawnExtensionComponent
- LyraHealthComponent의 Initialize는 Character의 함수에서 수동으로 호출하고 있다.
- 왜 이거만 이렇게 수동으로 호출한건지 의문이 들었다.
- HealthComponent는 상대적으로 단순해서 ISS가 과하다고 판단?
- 레거시 코드?
- ISS를 모든 곳에 적용하는 건 비용이 크다고 판단?
- 또한, 초기화 로직을 어디서 호출하는 지 찾아야 하는 번거로움과 수동 초기화 로직의 문제점이 발생할 가능성이 있다고 생각했다.
- 일관성이 떨어져서 분석하는 데도 상당히 귀찮았다.
void ALyraCharacter::OnAbilitySystemInitialized() { ULyraAbilitySystemComponent* LyraASC = GetLyraAbilitySystemComponent(); check(LyraASC); HealthComponent->InitializeWithAbilitySystem(LyraASC); InitializeGameplayTags(); }
4. 좀 더 개선된 ISS 사용하기 위한 고민
Lyra 의 ISS 는 좋은 시도였지만, 몇 가지 아쉬운 부분을 내 프로젝트에 적용해보려고 한다.
1. ISS 를 사용한다면, 모든 컴포넌트에 적용하기 (수동 초기화 로직 피하기)
- 어딘 쓰고 어딘 안쓰고 같은 일 벌어지지 않기
- 그 컴포넌트의 CanChangeState 와 HandleStageChange 함수만 보면 초기화 시 어떤 의존성이 필요한지, 어떤 초기화를 하는지 알 수 있게
2. Pawn의 모든 컴포넌트들의 상태를 한 곳에서 조율하는 중앙 관리자 클래스 추가하기
- PawnStateManager 의 상태가 현재 이 Pawn 이 가진 Component 들의 상태라고 정의
- PawnStateManager 만 보면 Pawn 의 컴포넌트들의 초기화 순서와 진행 상황을 알 수 있도록 하기
5. 실제로 적용한 결과
일단 Character의 BeginPlay 에 초기화 관련 코드가 없다!
// ===== BSCharacter.cpp ===== void ABSCharacter::BeginPlay() { Super::BeginPlay(); // 아무것도 안해도 됨! // ISS가 모든 컴포넌트를 자동으로 초기화 }
컴포넌트 추가해도 Character 코드 수정 불필요
Git 충돌 위험 감소
1. 모든 컴포넌트에 ISS 적용
모든 컴포넌트들에게 ISS 를 적용한다. 밑은 HealthComponent, PawnInputComponent 를 예로 들었다.
AbilitySystemComponent 도 PlayerState 의 PostInitializeComponent 함수에서
InitAbilityActorInfo 를 호출했던 것을 개선한 코드에서는 ASC 클래스 안에서 초기화 한다.void UBSAbilitySystemComponent::HandleChangeInitState(UGameFrameworkComponentManager* Manager, FGameplayTag CurrentState, FGameplayTag DesiredState) { if (DesiredState == BSGamePlayTags::InitState_DataInitialized) { InitAbilityActorInfo(GetOwner(), Cast<APlayerState>(GetOwner())->GetPawn()); } }
초기화에 필요한 의존성이 직관적으로 작성되어 있다.
HealthComponent - PlayerState 랑 AbilitySystemComponenet 필요
bool UBSHealthComponent::CanChangeInitState(UGameFrameworkComponentManager* Manager, FGameplayTag CurrentState, FGameplayTag DesiredState) const { if (DesiredState == BSGamePlayTags::InitState_DataInitialized) { const auto PS = Cast<APawn>(GetOwningActor())->GetPlayerState(); if (!PS) return false; // 이 부분 if (Manager->HasFeatureReachedInitState( PS, ABSPlayerState::NAME_PLAYERSTATE, BSGamePlayTags::InitState_DataInitialized) && Manager->HasFeatureReachedInitState( PS, UBSAbilitySystemComponent::NAME_ABILITYSYSTEMCOMPONENT, BSGamePlayTags::InitState_DataInitialized)) { return true; } return false; } return true; }
PawnInputComponet - PlayerState 필요
bool UBSPawnInputComponent::CanChangeInitState(UGameFrameworkComponentManager* Manager, FGameplayTag CurrentState, FGameplayTag DesiredState) const { if (DesiredState == BSGamePlayTags::InitState_DataInitialized) { const auto PS = Cast<APawn>(GetOwningActor())->GetPlayerState(); if (!PS) return false; // 이 부분 if (Manager->HasFeatureReachedInitState( PS, ABSPlayerState::NAME_PLAYERSTATE, BSGamePlayTags::InitState_DataInitialized)) return true; return false; } return true; }
2. Pawn 상태 관리자 클래스 추가하기
상태 관리자 컴포넌트의 상태가 DataInitialized 라면, Pawn의 컴포넌트들이 최소 DataInitialized 상태를 보장한다.
관리자의 상태가 GameplayReady 가 되면, 델리게이트로 특정 이벤트를 발생시키는 것도 가능해진다.
실제로 위 코드를 실행했을 때의 로그이다.
후기
장점만 나열되어 있지만, 단점은 생각보다 처음 이 시스템을 이해하는데 비용이 많이 들었다.
또한 정말 간단한 컴포넌트도 이걸 사용한다면 충분히 과하다고 느낄 수 있다.
하지만 초기화 로직이 다른 클래스 코드에 들어갈 일도 없고, 협업 시에 해당 클래스만 보면 되기 때문에
리뷰도 용이하다고 생각된다. 갑자기 Character 클래스에 가서 Initialize 한 줄 적다가 Git 에서
충돌나는 그런 일들도 줄일 수 있다.
게임에서 정말 많은 오브젝트 또는 컴포넌트가 생성되는데, 생성 즉시 초기화가 완료되어
바로 사용할 수 있도록 하는 것이 베스트 라고 생각하지만,
언리얼에서는 생성자를 커스텀하고 만들 수도 없고, 시점 문제도 있기 때문에 이 ISS 로 불편한 부분
들을 많이 해소할 수 있다.