게임에서 열심히 부순 펜스가 구역을 한 바퀴 돌고 왔더니 멀쩡하게 되살아나 있다면 어떨까요? 플레이어 입장에선 "내 노동 돌려내!" 싶은 순간입니다. 오늘은 이 문제를 오브젝트 풀링과 GameInstanceSubsystem으로 풀어 본 이야기입니다.
아주 큰 레벨에 구역이 여러 개 있고, 펜스는 딱 5개만 만들어서 돌려 쓰고 싶었습니다. 구상은 이렇습니다.


그림에서 1번 구역의 X는 부서진 펜스, O는 멀쩡한 펜스입니다. 화살표는 1구역에서 쓰던 펜스를 다른 구역으로 가져가 재배치하는 흐름이고, 가져간 펜스는 새 구역에서 멀쩡한 모습으로 나옵니다. 부서진 기록은 펜스 몸체가 아니라 다른 곳에 남아 있어야 한다는 뜻이죠.
풀 안의 펜스는 어느 구역 소속도 아닙니다. 1구역에서 쓰던 놈이 2구역에서 재사용될 수 있으니까요. 그래서 펜스 액터에 "나 부서졌음"을 적어 두면 안 됩니다. 기록은 서브시스템의 맵에 두고, 풀에서 꺼낼 때마다 그 값을 덮어씌웁니다.
[원본] ZoneStates["Zone1"] = [X, O, X, O, X] ← 서브시스템이 들고 있음
[껍데기] 풀에서 꺼낸 AFence ← 꺼낼 때마다 위 값을 입힘
"펜스 풀"과 "구역별 상태"를 한 서브시스템에 몰아넣을 수도 있습니다. 그런데 수명이 다릅니다.
| 서브시스템 | 수명 | 맡는 일 |
|---|---|---|
UFenceStateSubsystem (GameInstance) | 게임 내내 | 구역별 파괴 상태 저장 (원본) |
UFencePoolSubsystem (World) | 월드마다 | 펜스 액터 풀링 (스폰, 반납, 재배치) |
액터 포인터는 월드가 사라지면 무효가 됩니다. 상태 데이터는 GameInstance 쪽에 두고, 액터를 다루는 풀은 World 쪽에 두면 서로 수명이 맞습니다. 나중에 세이브를 붙일 때도 상태 쪽만 직렬화하면 됩니다. (레벨 전환이 아예 없다고 확신하면 합쳐도 되긴 합니다.)
구역마다 UForestPoolSubsystem, UDesertPoolSubsystem 식으로 서브클래스를 따로 만드는 방법도 기술적으로는 됩니다. 그런데 구역이 늘수록 클래스와 중복 코드가 늘고, 풀이 나뉘어서 서로 재사용도 못 합니다. 그래서 서브시스템은 하나, 구역은 FName ID를 키로 하는 TMap으로 구분했습니다.
UPROPERTY는 TMap<FName, TArray<bool>>처럼 컨테이너 안에 컨테이너를 넣을 수 없어서 배열을 구조체로 한 번 감쌌습니다.
USTRUCT()
struct FZoneFenceState
{
GENERATED_BODY()
UPROPERTY()
TArray<bool> bDestroyed; // 인덱스 0~4 = 펜스 5개, true면 파괴
};
UCLASS()
class UFenceStateSubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
bool IsFenceDestroyed(FName ZoneId, int32 FenceIndex) const;
void SetFenceDestroyed(FName ZoneId, int32 FenceIndex, bool bDestroyed);
private:
static constexpr int32 FencesPerZone = 5;
UPROPERTY()
TMap<FName, FZoneFenceState> ZoneStates;
};
bool UFenceStateSubsystem::IsFenceDestroyed(FName ZoneId, int32 FenceIndex) const
{
const FZoneFenceState* State = ZoneStates.Find(ZoneId);
// 기록이 없으면 처음 보는 구역이니까 멀쩡
if (State == nullptr || !State->bDestroyed.IsValidIndex(FenceIndex))
{
return false;
}
return State->bDestroyed[FenceIndex];
}
void UFenceStateSubsystem::SetFenceDestroyed(FName ZoneId, int32 FenceIndex, bool bDestroyed)
{
FZoneFenceState& State = ZoneStates.FindOrAdd(ZoneId);
if (State.bDestroyed.Num() < FencesPerZone)
{
State.bDestroyed.SetNumZeroed(FencesPerZone); // 전부 false로 채움
}
if (State.bDestroyed.IsValidIndex(FenceIndex))
{
State.bDestroyed[FenceIndex] = bDestroyed;
}
}
펜스는 자기가 몇 구역 몇 번인지(ZoneId, FenceIndex)만 알고 있으면 됩니다. 부서질 때는 그 순간 바로 상태 서브시스템에 기록합니다.
UCLASS()
class AFence : public AActor
{
GENERATED_BODY()
public:
// 풀에서 꺼낼 때: 위치를 잡고 저장된 상태를 즉시 입힌다
void Activate(FName InZoneId, int32 InFenceIndex, const FTransform& SlotTransform, bool bDestroyed);
// 풀에 반납할 때: 숨기고 꺼 둔다
void Deactivate();
// 플레이어가 부술 때: 연출을 재생하고 상태를 기록한다
void Break();
private:
void ShowIntact(); // 멀쩡한 모습 (연출 없음)
void ShowBroken(); // 부서진 최종 모습 (연출 없음)
FName ZoneId;
int32 FenceIndex = INDEX_NONE;
bool bBroken = false;
};
void AFence::Activate(FName InZoneId, int32 InFenceIndex, const FTransform& SlotTransform, bool bDestroyed)
{
ZoneId = InZoneId;
FenceIndex = InFenceIndex;
bBroken = bDestroyed;
SetActorTransform(SlotTransform);
if (bDestroyed)
{
ShowBroken();
}
else
{
ShowIntact();
}
SetActorHiddenInGame(false);
SetActorTickEnabled(true);
}
void AFence::Deactivate()
{
SetActorHiddenInGame(true);
SetActorEnableCollision(false);
SetActorTickEnabled(false);
ZoneId = NAME_None;
FenceIndex = INDEX_NONE;
}
void AFence::Break()
{
if (bBroken)
{
return;
}
bBroken = true;
// 여기서 부서지는 연출(파편 등)을 재생
// 부서지는 그 순간 바로 원본에 기록
if (UGameInstance* GameInstance = GetGameInstance())
{
if (UFenceStateSubsystem* State = GameInstance->GetSubsystem<UFenceStateSubsystem>())
{
State->SetFenceDestroyed(ZoneId, FenceIndex, true);
}
}
}
Break()는 연출을 재생하고, ShowBroken()은 이미 부서진 최종 모습을 즉시 보여 줍니다. 이 둘을 꼭 따로 둡니다. 복원 때 Break()를 불러 버리면 구역에 들어설 때마다 펜스가 눈앞에서 다시 폭발하겠죠?
USTRUCT()
struct FFenceList
{
GENERATED_BODY()
UPROPERTY()
TArray<TObjectPtr<AFence>> Fences;
};
UCLASS()
class UFencePoolSubsystem : public UWorldSubsystem
{
GENERATED_BODY()
public:
void ActivateZone(FName ZoneId, const TArray<FTransform>& Slots);
void DeactivateZone(FName ZoneId);
private:
AFence* Acquire();
void Release(AFence* Fence);
UPROPERTY()
TSubclassOf<AFence> FenceClass; // 어디서 받아올지는 따로 정함 (GameMode 등)
UPROPERTY()
TArray<TObjectPtr<AFence>> FreeFences; // 놀고 있는 펜스
UPROPERTY()
TMap<FName, FFenceList> ActiveZones; // 지금 구역에 나가 있는 펜스
};
void UFencePoolSubsystem::ActivateZone(FName ZoneId, const TArray<FTransform>& Slots)
{
UGameInstance* GameInstance = GetWorld()->GetGameInstance();
UFenceStateSubsystem* State = GameInstance ? GameInstance->GetSubsystem<UFenceStateSubsystem>() : nullptr;
if (State == nullptr)
{
return;
}
FFenceList& List = ActiveZones.FindOrAdd(ZoneId);
// 이미 켜진 구역이면 중복으로 꺼내지 않음
if (List.Fences.Num() > 0)
{
return;
}
for (int32 Index = 0; Index < Slots.Num(); ++Index)
{
AFence* Fence = Acquire();
if (Fence == nullptr)
{
break;
}
// N번 슬롯 위치에 놓고, N번 상태를 입힌다
Fence->Activate(ZoneId, Index, Slots[Index], State->IsFenceDestroyed(ZoneId, Index));
List.Fences.Add(Fence);
}
}
void UFencePoolSubsystem::DeactivateZone(FName ZoneId)
{
FFenceList* List = ActiveZones.Find(ZoneId);
if (List == nullptr)
{
return;
}
for (AFence* Fence : List->Fences)
{
Release(Fence);
}
ActiveZones.Remove(ZoneId); // 상태는 FenceStateSubsystem에 그대로 남아 있음
}
AFence* UFencePoolSubsystem::Acquire()
{
if (FreeFences.Num() > 0)
{
return FreeFences.Pop();
}
// 풀이 비었으면 하나 더 만든다
return GetWorld()->SpawnActor<AFence>(FenceClass);
}
void UFencePoolSubsystem::Release(AFence* Fence)
{
if (IsValid(Fence))
{
Fence->Deactivate();
FreeFences.Add(Fence);
}
}
ActivateZone에 슬롯 위치를 Slots 배열로 넘기는 방식을 썼습니다. 구역 박스 볼륨이 자기 슬롯 위치를 들고 있다가 켜질 때 넘겨주면 됩니다.
Break()가 불리는 순간 ZoneStates["Zone1"]의 해당 인덱스가 true가 됩니다. 나중에 한꺼번에 저장하려고 하면 놓치기 쉬워서 그때그때 기록합니다.DeactivateZone("Zone1")이 펜스 5개를 숨기고 풀에 돌려줍니다. 상태는 맵에 그대로 남습니다. 이어서 ActivateZone("Zone2")가 같은 펜스를 꺼내 2구역에 놓습니다.ZoneStates["Zone1"]을 읽어 인덱스별로 X 모양, O 모양을 입힙니다.구역을 켜고 끄는 시점은 이런 방법들이 있습니다.
BeginPlay/EndPlay 시점이 곧 켜고 끄는 시점입니다.Break()와 ShowBroken() 분리입니다.FenceIndex를 슬롯 순서대로 고정해서 넣어 줍니다. 풀 안의 액터 자체는 구역이나 번호를 기억하지 않습니다.Acquire()가 풀이 비면 새로 스폰하게 해 뒀습니다.UPROPERTY로는 안 되니 구조체로 감쌉니다. (위 코드의 FZoneFenceState, FFenceList)고민했던 부분인데, 둘 다 쓰되 역할이 다릅니다.
UFenceStateSubsystem의 TMap. 게임을 켜 둔 동안은 이것만으로 충분합니다.ZoneStates를 USaveGame으로 직렬화합니다. 나중에 붙여도 구조를 바꿀 일이 없습니다.UCLASS()
class UFenceSaveGame : public USaveGame
{
GENERATED_BODY()
public:
UPROPERTY()
TMap<FName, FZoneFenceState> ZoneStates;
};
// 저장
UGameplayStatics::SaveGameToSlot(SaveData, TEXT("Slot0"), 0);
// 불러오기
UFenceSaveGame* Loaded = Cast<UFenceSaveGame>(
UGameplayStatics::LoadGameFromSlot(TEXT("Slot0"), 0));
이번 구상은 레벨 전환 없이 큰 레벨 하나 안에서 도는 걸 전제로 합니다. 이 경우 액터가 계속 살아 있으니 풀링과 재배치가 그대로 됩니다.
OpenLevel을 하면 월드가 통째로 파괴되고 풀 안의 액터도 같이 사라집니다. 이때는 액터를 포기하고 상태만 GameInstance 쪽에 남깁니다. 새 레벨의 UFencePoolSubsystem이 새로 만들어져서 그 레벨 안에서 다시 풀링하면 됩니다. 월드 파티션 스트리밍으로 구역이 언로드될 때도 액터가 파괴될 수 있어서, 파괴 시점에 상태를 바로 기록해 둔 게 여기서도 안전장치가 됩니다.
펜스 5개에 풀링이라니, 모기 잡는 데 대포를 꺼낸 셈인 건 인정합니다(?). 그래도 "상태는 원본에, 액터는 껍데기로" 분리하는 구조를 한 번에 연습해 보기엔 꽤 좋은 소재였습니다.
위 구현의 바탕이 된 수업 내용입니다.
BeginPlay() 자리에 Init(), 정리는 Shutdown()을 씁니다. UObject는 0으로 초기화되지만 0이 항상 원하는 기본값은 아니라서, 의미 있는 기본값은 Init()에서 직접 정합니다.NewObject<>() 없이 언리얼이 자동으로 만들고, GetSubsystem<>()으로 꺼내 씁니다. Initialize()와 Deinitialize()에서 부모 함수 호출을 잊지 않습니다.TMap<FName, ...>으로 ID 구분을 합니다. (펜스가 바로 그 사례입니다.)| 종류 | Outer | 수명 |
|---|---|---|
UEngineSubsystem | 엔진 | 엔진 실행 내내 |
UGameInstanceSubsystem | GameInstance | 게임 실행 내내 |
UWorldSubsystem | World | 월드(레벨)마다 |
ULocalPlayerSubsystem | LocalPlayer | 로컬 플레이어마다 |
USaveGame으로 하고 기획 단계에서 Runtime과 Save를 구분해야 합니다.PlayerController의 SetInputMode()로 설정하고, 입력 모드, 커서 표시, 카메라 시점은 각각 따로 설정해야 합니다.
| 모드 | 입력 대상 | 쓰임 |
|---|---|---|
FInputModeGameOnly | 게임만 | 일반 플레이 |
FInputModeUIOnly | UI만 | 메인 메뉴, 옵션, 일시정지 |
FInputModeGameAndUI | UI 먼저, 처리 안 된 입력은 게임으로 | 인벤토리 |
저번에 겪은 "Reverse Superman" 버그가 생각났습니다. AddActorLocalOffset과 AddActorWorldOffset을 헷갈려서 기준 좌표계가 틀어졌던 사고였죠. 이번 주제도 구조가 똑같더라고요. 이 데이터의 기준이 GameInstance 수명인지, World 수명인지를 헷갈리면 조용히 사라지거나 엉뚱하게 남습니다. 기준을 먼저 정하고 코드를 짜자는 교훈은 둘이 같네요.
수업은 손 튜터님 덕분에, 정리는 다솔님 덕분에 한결 편하게 따라왔습니다. 다음에는 숙제로 받은 로고 화면에서 게임 화면까지 이어지는 흐름과 입력 모드 구현을 직접 해 보겠습니다.
숙제 목록
1. GameInstance와 GameInstanceSubsystem으로 전반적인 데이터 관리 구조 설계
2. 기획서 완성
3. 화면에 따른 입력 모드 기획 + 구현
4. 수업 내용 복습