개요

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 로 불편한 부분

들을 많이 해소할 수 있다.