BulletAnt 개발일지 (23) - Box 기반 스폰, 네트워크 동기화 거리 문제

김펭귄·2026년 5월 17일

Today What I Learned (TIL)

목록 보기
138/139

Trigger Box 기반 스폰 시스템

기존에는 랜덤 거리와 방향을 기반으로 적을 스폰했다.
다만, 아무리 라인트레이스와 캡슐 Sweep으로 검사하더라도 가끔 지형이나 구조물에 끼는 문제가 발생했다.

그래서 구조 자체를 바꿔, 안전한 위치에서만 스폰하도록 설계했다.

Trigger Box 기반 스폰 구역 관리

이를 위해 레벨에 직접 배치한 Trigger Box 기반의 스폰 시스템으로 변경했다.

SpawnLocationManager 액터를 만들어 레벨 내 스폰 구역들을 관리하고, BeginPlay에서 자신을 SpawnManagerSubsystem에 등록하도록 설계했다.

스폰 구역 정보 캐싱

Manager는 등록된 Trigger Box들의 정보를 TArray로 캐싱한다.

struct BULLETANT_API FSpawnBoxEntry
{
	UPROPERTY(EditAnywhere)
	TObjectPtr<ATriggerBox> SpawnBox;

	UPROPERTY(VisibleAnywhere)
	FVector Origin;

	UPROPERTY(VisibleAnywhere)
	FVector2D Extent;

	UPROPERTY(VisibleAnywhere)
	FRotator Direction;

	UPROPERTY(VisibleAnywhere)
	float Weight;
};

UCLASS()
class BULLETANT_API ASpawnLocationManager : public AActor
{
	UPROPERTY(EditAnywhere, Category = "Spawning")
	TArray<FSpawnBoxEntry> SpawnBoxes;
};

각 Box마다 다음 정보를 저장한다.

  • 중심 위치
  • 크기(Extent)
  • 회전값
  • Weight

크기나 회전값은 매번 CollisionComponent를 캐스팅해 가져오지 않도록 BeginPlay에서 미리 캐싱했다.
적 스폰은 웨이브 동안 매우 자주 호출되므로, 반복적으로 Component 접근 및 캐스팅 비용을 발생시키지 않도록 한 것이다.

자료구조로 TArray를 사용한 이유는, 단순 순회 성능이 가장 좋기 때문이다.

void ASpawnLocationManager::BeginPlay()
{
	for (FSpawnBoxEntry& Entry : SpawnBoxes)
	{
		ATriggerBox* Box = Entry.SpawnBox;

		UShapeComponent* ShapeComp = Box->GetCollisionComponent();
		UBoxComponent* BoxComponent = Cast<UBoxComponent>(ShapeComp);

		Entry.Origin = BoxComponent->GetComponentLocation();
		FVector BoxExtent = BoxComponent->GetScaledBoxExtent();
		Entry.Extent = FVector2D(BoxExtent.X, BoxExtent.Y);
		Entry.Direction = BoxComponent->GetComponentRotation();
		Entry.Weight = BoxExtent.X * BoxExtent.Y;
		TotalWeight += Entry.Weight;
	}

	UWorld* World = GetWorld();
	if (IsValid(World))
	{
		USpawnManagerSubsystem* SpawnManager = GetWorld()->GetSubsystem<USpawnManagerSubsystem>();
		SpawnManager->SetCachedSpawnLocationManager(this);
	}	
}

Weight 기반 랜덤 스폰

각 Box에는 Weight 값을 둬서 스폰 확률을 조절했다.

Weight는 Box의 넓이(X * Y)를 기반으로 계산하여, 넓은 지역일수록 더 자주 선택되게 만들었다.

적 스폰 시에는:

  1. 전체 Weight 합산
  2. 랜덤 값 생성
  3. 해당 Weight 구간의 Box 선택
  4. 선택된 Box 내부에서 랜덤 위치 계산

과정을 통해 최종 스폰 위치를 결정한다.

const FSpawnBoxEntry& ASpawnLocationManager::GetRandomSpawnBox() const
{
	float RandomWeight = FMath::FRandRange(0.0f, TotalWeight);
	float AccumulatedWeight = 0.0f;

	for (const FSpawnBoxEntry& Entry : SpawnBoxes)
	{
		AccumulatedWeight += Entry.Weight;
		if (RandomWeight <= AccumulatedWeight)
		{
			return Entry;
		}
	}

	return SpawnBoxes[0];
}

FVector ASpawnLocationManager::GetRandomSpawnLocation() const
{
	const FSpawnBoxEntry& RandomSpawnBoxEntry = GetRandomSpawnBox();
	ATriggerBox* Box = RandomSpawnBoxEntry.SpawnBox;

	FVector2D BoxExtent = RandomSpawnBoxEntry.Extent;
	FVector RandomPoint = FVector(FMath::FRandRange(-BoxExtent.X, BoxExtent.X),
								  FMath::FRandRange(-BoxExtent.Y, BoxExtent.Y), 
								  0);

	return RandomSpawnBoxEntry.Origin + RandomSpawnBoxEntry.Direction.RotateVector(RandomPoint);
}

결과

이전에는 스폰 전에:

  • 라인트레이스
  • 캡슐 Sweep
  • 장애물 검사
  • 바닥 검사

등을 수행하는 CanSpawnEnemy() 함수를 사용했었다.

하지만 이제는 애초에 안전한 위치만을 관리하기 때문에 이런 검증 과정 자체가 필요 없어졌다.

결과적으로:

  • 스폰 안정성 향상
  • 스폰 실패 감소
  • 불필요한 물리 검사 제거
  • 스폰 로직 단순화

를 동시에 얻을 수 있었다.


네트워크 동기화 거리 문제

멀리 있는 적이 다시 가까워졌을 때, 포탈 이펙트가 또 생성되는 문제가 발생했다.
처음에는 리스폰 문제인가 했는데, 확인해보니 적 액터가 다시 동기화되면서 BeginPlay가 재실행되는 것처럼 보였다.

실제로:

  • 적이 멀리 있을 때는 포탈이 보이지 않다가
  • 플레이어가 가까워지면 갑자기 포탈이 다시 생성되었다.

즉, 네트워크 relevancy 범위를 벗어나 액터 동기화가 끊겼다가, 다시 relevancy 안으로 들어오며 재동기화되는 과정에서 초기화 로직이 다시 실행되는 상황이었다.

NetCullDistance 설정 시도

처음에는 동기화가 끊기지 않도록 NetCullDistanceSquared 값을 맵 전체를 덮을 정도로 크게 설정했다.

NetCullDistanceSquared = 50000.f * 50000.f;

다만 이렇게 극단적으로 크게 설정하자 오히려 동기화 자체가 제대로 이루어지지 않았다.
찾아보니 지나치게 큰 값은 엔진 내부 relevancy 계산에 문제가 생길 수 있다고 한다.

Always Relevant로 변경

모든 적은 코어를 향해 다가오므로, 디펜스 게임 특성상 항상 동기화되는 것이 맞다고 판단했다.

그래서 최종적으로는:

bAlwaysRelevant = true;

로 설정하여 relevancy 거리 계산 자체를 사용하지 않도록 변경했다.

이후에는 멀어졌다 가까워져도 포탈이 다시 생성되지 않았고, 동기화도 안정적으로 유지되었다.

Net Update Frequency 조정

추가로 네트워크 업데이트 빈도도 조정했다.

처음에는 최소 업데이트 빈도를 너무 낮게 설정해두었는데, 거리 멀어졌을 때 동기화가 지나치게 끊기는 문제가 있었다.

그래서 값을 다시 조정했다.

// BaseEnemeyCharacter.cpp
SetMinNetUpdateFrequency(30.f);

이후 확인해보니 net.UseAdaptiveNetUpdateFrequency을 사용하면, 엔진에서 내부적으로 거리 기반 동기화 최적화를 해준다고 함.

// DefaultEnegine.ini
[SystemSettings] 
net.UseAdaptiveNetUpdateFrequency=1

결과적으로:

  • 적 동기화 안정성 개선
  • 포탈 재생성 문제 해결
  • 거리 기반 끊김 현상 완화

를 해결할 수 있었다.

profile
반갑습니다

0개의 댓글