BulletAnt 개발일지 (9) - Destroy크래시, TargetPriority

김펭귄·6일 전

Today What I Learned (TIL)

목록 보기
124/139

게임 종료 중 Destroy 호출로 인한 크래시 문제

자폭 몹인 Egg가 폭발 후 사망 모션을 재생하는 도중 게임을 종료하면,
ActorComponent::OnUnregister()에서 중단점이 걸리는 문제가 발생했다.

흥미로웠던 점은:

  • 완전히 죽은 뒤 종료하면 문제 없음
  • 사망 처리 도중 종료할 때만 발생

했다는 것이다.

호출 스택을 따라가며 흐름을 분석해보니 원인은 공격 Ability였다.

Egg는 공격 시:

  • GA_MeleeAttack
  • 공격 몽타주 재생
  • 몽타주 종료/캔슬 콜백 실행

구조로 되어 있었다.

문제는 몽타주가 취소되었을 때도, 정상 종료와 동일한 콜백을 사용하고 있었다는 점이었다.
즉 게임 종료로 몽타주가 캔슬되었지만, 콜백함수가 호출되었고 Destroy가 호출되었다.
이후 게임 종료 과정에서:

EndPlayMap()
-> UnregisterAllComponents()

흐름이 다시 실행되면서 이미 제거 중인 객체를 다시 정리하려고 접근하게 된 것이다.

아마 Destroy() 이후 GC가 완전히 정리하기 전에
게임 종료 루틴이 겹치면서 발생한 문제로 보였다.

처음에는:

  • Tick 비활성화
  • Component 제거
  • Collision 제거

등 여러 방식으로 해결하려 했지만 근본적인 해결이 되지 않았다.

최종적으로는:

if (!IsValid(this) || GetWorld()->bIsTearingDown)	// 게임 강제 종료시 발생하는 에러 예방 코드
{
	return;
}

Destroy();

처럼 게임 종료 중에는 Destroy()를 수행하지 않도록 처리해 문제를 해결했다.


우선순위 기반 타겟 변경 구조

기획 요구사항 중 하나가 적이 우선도가 더 높은 타겟으로 변경할 수 있어야 한다 는 것이었다.
그래서 요구사항과 성능을 챙기기 위해 타겟후보들을 우선도를 기준으로 priority_queue로 관리하려 했다.
가장 우선도가 높은 타겟이 맨앞으로 정렬되고 이 타겟만 확인하면 되니 성능상으로 좋다고 판단하였다.

하지만 언리얼에는 STL의 priority_queue 같은 컨테이너가 없었고,
대신 TArray 기반의 HeapPush, HeapPop 정도만 제공했다.

물론 Heap 구조를 직접 사용할 수도 있었지만, 이후 어그로 시스템이 추가되면 문제가 생긴다고 판단했다.
어그로 수치는 실시간으로 변하므로 내부 우선순위가 계속 바뀌며 재정렬이 계속 필요해진다.
결국 성능 상의 이점이 사라지는 것이었다.

그래서 우선순위별로 Actor 배열을 관리하는 TMap 방식으로 변경하고 모든 타겟후보를 순회하며 체크하기로 하였다.

TMap<ETargetPriorityType, TArray<AActor*>>

형태를 생각했는데, 여기서 두 가지 문제가 발생했다.

발생한 문제

첫 번째는 TMap이 정렬되지 않는다는 점이었다.

언리얼의 TMap은 STL의 map이 아니라
unordered_map처럼 해시 기반 컨테이너다.

따라서 우선순위대로 순회를 하기 위해 이전에 정의한 Enum을 기반으로 접근하였다.
현재 타겟하고 있는 대상의 우선순위보다 높은 대상만을 순회하도록 구현하였다.

// TargetPriorityType.h
UENUM(BlueprintType)
enum class ETargetPriorityType : uint8
{
    Ignore  UMETA(DisplayName = "Ignore (0)"),
    High    UMETA(DisplayName = "High (1)"),
    Medium  UMETA(DisplayName = "Medium (2)"),
    Low     UMETA(DisplayName = "Low (3)")
};

// BaseEnemyCharacter::SenseNearbyActors()
for (uint8 i = 1; i < static_cast<uint8>(TargetActorPriority); i++)
{
	ETargetPriorityType Key = static_cast<ETargetPriorityType>(i);
	if (FActorArrayWrapper* Value = NearbyActors.Find(Key))
	{
		for (AActor* NearbyActor : Value->Actors)
		{
			// ... //

두 번째 문제는 UHT(Unreal Header Tool)였다.

UPROPERTY() 내부에서는 TMap<Key, TArray<Value>> 같은 중첩 템플릿을 제대로 처리하지 못한다.

일반 C++ 컴파일러는 문제없지만, UHT는 리플렉션과 GC 추적 때문에 복잡한 Nested Template을 제한한다.

그래서 해결을 위해 Wrapper Struct를 만들고 사용하였다.

USTRUCT()
struct FTargetActorArray
{
    TArray<TObjectPtr<AActor>> Actors;
};

TMap<ETargetPriorityType, FTargetActorArray>

형태로 변경했다.

이 방식은:

  • UHT 파싱 안정성
  • GC 추적 안정성
  • 직렬화 안정성

까지 해결할 수 있었다.

추가로 TMap 접근 방식도 수정했다.
[] 접근은 Key가 없으면 Value를 생성하기에 의도치 않은 삽입이 일어날 수 있었다.
그래서 대상을 추가할 때는 FindOrAdd() 를 사용했고, 탐색 시에는 Find()를 사용했다.

결과

최종적으로는:

  • 높은 우선순위부터 탐색
  • 새로운 타겟 발견 시 즉시 변경 및 StateTree에 이벤트 전달 후 재추격
  • 타겟이 전부 사라지면 Core 추적

흐름으로 구성했다.

profile
반갑습니다

0개의 댓글