Final Project의 클래스 선택 화면에서는 플레이어가 클래스를 고를 때 해당 캐릭터를 UI 안에서 미리 보여줄 필요가 있었다.
단순한 캐릭터 이미지 한 장을 보여주는 방식이 아니라, 실제 캐릭터의 Idle Animation과 장착 무기, 클래스 변경 및 선택 확정 Niagara Effect까지 함께 보여주는 실시간 프리뷰를 구현하고자 했다.
문제는 실제 게임 월드에 존재하는 캐릭터를 그대로 UI에 넣을 수는 없다는 점이다. UI는 2D 이미지를 표시하지만 캐릭터와 무기는 3D Actor다. 따라서 별도의 Preview Pawn을 Spawn하고, SceneCaptureComponent2D로 이 Pawn을 촬영한 뒤 그 결과를 TextureRenderTarget2D에 저장하여 UI Material에서 사용하는 구조를 만들었다.
전체 흐름을 단순화하면 다음과 같다.
PreviewPawn / Weapon / Niagara
↓
SceneCaptureComponent2D
↓
TextureRenderTarget2D
↓
MaterialInstanceDynamic
↓
Class UI
처음에는 단순히 “SceneCapture로 캐릭터를 찍어서 UI에 보여준다” 정도로 생각했지만 실제 구현에서는 그보다 많은 책임을 구분해야 했다.
RenderTarget을 누가 소유할 것인지, 어떤 오브젝트만 캡처할 것인지, 투명 배경을 어떻게 만들 것인지, 런타임에 생성한 UObject는 언제 사라지는지, 별도로 Spawn한 Pawn과 Weapon은 누가 정리해야 하는지까지 하나의 프리뷰 시스템 안에서 연결되어 있었다.
SceneCaptureComponent2D는 월드의 장면을 하나의 카메라처럼 렌더링하고, 그 결과를 지정된 Texture에 출력할 수 있는 Component다.
여기서 SceneCapture가 촬영하는 역할이라면 TextureRenderTarget2D는 촬영 결과가 들어갈 출력 대상이다.
현재 PreviewStage에서는 BeginPlay()에서 RenderTarget을 직접 생성한다.
RenderTarget = NewObject<UTextureRenderTarget2D>(this);
RenderTarget->RenderTargetFormat = RTF_RGBA16f;
RenderTarget->InitAutoFormat(256, 512);
SceneCapture->TextureTarget = RenderTarget;
처음에는 Content Browser에 RenderTarget Asset 하나를 만들어 여러 PreviewStage가 사용하는 방식도 생각할 수 있다.
하지만 하나의 RenderTarget을 여러 SceneCapture가 동시에 출력 대상으로 사용하면 결국 모두 같은 Texture에 결과를 쓰게 된다. 한 SceneCapture의 결과가 다른 SceneCapture의 결과로 덮일 수 있고, 여러 PreviewStage를 독립적인 출력 대상으로 다루기도 어렵다.
그래서 현재 구현에서는 PreviewStage 인스턴스마다 런타임 RenderTarget을 하나씩 만든다.
PreviewStage A
└─ SceneCapture A → RenderTarget A
PreviewStage B
└─ SceneCapture B → RenderTarget B
이 구조에서는 각 Stage가 자신의 SceneCapture와 자신의 RenderTarget을 가진다.
NewObject<UTextureRenderTarget2D>(this)의 this는 생성되는 RenderTarget의 Outer를 현재 PreviewStage로 지정한다. 다만 이것을 단순히 “Outer가 Stage니까 Stage가 사라지면 RenderTarget도 자동으로 삭제된다”고 이해하면 안 된다.
Outer는 UObject의 소속과 이름 공간 등의 관계를 표현하고, 실제 GC에서 RenderTarget이 살아 있어야 한다는 참조는 별도로 추적된다. 현재 코드에서는 UPROPERTY로 선언된 TObjectPtr이 그 역할을 한다.
현재 RenderTarget은 다음 설정을 사용한다.
RenderTarget->RenderTargetFormat = RTF_RGBA16f;
RenderTarget->InitAutoFormat(256, 512);
RTF_RGBA16f는 R, G, B, A 네 채널을 각각 16-bit Floating Point로 저장한다.
즉 한 픽셀의 기본 데이터 크기는 다음과 같다.
16 bit × 4 channel
= 64 bit
= 8 byte
256×512 RenderTarget이라면 픽셀 데이터만 단순 계산했을 때 약 1 MiB다.
256 × 512 × 8 byte
= 1,048,576 byte
≈ 1 MiB
물론 이것은 픽셀 데이터의 단순 계산이며 실제 GPU 리소스가 정확히 1 MiB만 사용한다는 뜻은 아니다. GPU 리소스의 실제 할당량에는 내부 정렬이나 렌더링에 필요한 추가 요소가 존재할 수 있다.
또 하나 주의할 점은 Alpha가 필요해서 반드시 RGBA16f를 써야 하는 것은 아니라는 것이다.
RGBA8도 Alpha Channel을 가지고 있다. 현재 설정에서 16f를 사용하는 이유는 SceneCapture의 HDR SceneColor 범위를 유지하면서 Alpha도 함께 사용할 수 있기 때문이다.
처음 문제를 확인했을 당시에는 512×1024 RenderTarget을 사용하고 있었고, 이후 256×512로 낮췄다. 가로와 세로를 각각 절반으로 줄였으므로 전체 픽셀 수는 1/4이 된다.
하지만 이것을 곧바로 “GPU 비용도 정확히 1/4이 되었다”고 말할 수는 없다.
RenderTarget의 픽셀 수는 명확하게 1/4이지만 SceneCapture의 전체 GPU 시간에는 캐릭터 Mesh, Groom, Material, Niagara 등 다른 렌더링 비용도 함께 들어가기 때문이다. 실제 성능 차이를 말하려면 동일 조건의 stat gpu, stat unit 같은 측정 결과가 필요하다.
현재 확인된 사실은 256×512로 해상도를 낮춘 이후 체감되던 성능 문제가 해소되었다는 것까지다. 정량적인 개선 폭은 측정하지 않았다.
SceneCapture가 RenderTarget에 그림을 그려도 UMG가 그것을 곧바로 사용하는 것은 아니다.
그래서 PreviewStage에서는 Material Template을 기반으로 MaterialInstanceDynamic을 만들고, 생성한 RenderTarget을 PreviewRT Parameter로 전달한다.
PreviewMID = UMaterialInstanceDynamic::Create(PreviewMaterialTemplate, this);
PreviewMID->SetTextureParameterValue(TEXT("PreviewRT"), RenderTarget);
결국 역할이 다음과 같이 나뉜다.
SceneCapture
→ 장면을 촬영한다.
RenderTarget
→ 촬영 결과를 저장한다.
Material
→ RenderTarget을 UI에서 사용할 형태로 합성한다.
UMG
→ 최종 Material을 화면에 표시한다.
하나의 클래스 프리뷰처럼 보여도 내부에서는 촬영, 저장, 합성, 표시가 서로 다른 객체의 책임으로 나뉘어 있다.
PreviewStage는 게임 월드 안에 존재한다.
SceneCapture를 아무 설정 없이 사용한다면 프리뷰용 Pawn뿐 아니라 주변 월드의 Mesh나 Effect까지 함께 캡처될 수 있다. 클래스 선택 화면에는 Preview Pawn과 관련된 오브젝트만 필요하기 때문에 캡처 대상을 제한해야 한다.
현재 구현에서는 다음 설정을 사용한다.
SceneCapture->PrimitiveRenderMode =
ESceneCapturePrimitiveRenderMode::PRM_UseShowOnlyList;
PRM_UseShowOnlyList를 사용하면 SceneCapture는 ShowOnly 목록에 등록된 Component만 렌더링한다.
Preview Pawn이 생성된 뒤에는 다음과 같이 필요한 대상만 등록한다.
SceneCapture->ShowOnlyActorComponents(PreviewPawn);
for (const TWeakObjectPtr<AWeaponBase>& WeaponPtr : PreviewWeapons)
{
if (AWeaponBase* Weapon = WeaponPtr.Get())
{
SceneCapture->ShowOnlyActorComponents(Weapon);
}
}
SceneCapture->ShowOnlyComponent(ChangeEffect);
SceneCapture->ShowOnlyComponent(LockInEffect);
따라서 SceneCapture 입장에서는 “월드 전체에서 무엇을 제외할 것인가”를 찾는 대신 “프리뷰에 필요한 것만 명시적으로 허용한다”는 구조가 된다.
여기에는 Preview Pawn, Weapon, 클래스 변경 Effect, 선택 확정 Effect가 포함된다.
현재 Preview Pawn은 정적인 캐릭터 이미지가 아니다.
Idle Animation이 계속 움직이고 Niagara Effect도 시간에 따라 변화하기 때문에 첫 프레임 한 번만 캡처해서는 실시간 프리뷰가 만들어지지 않는다.
그래서 다음과 같이 설정되어 있다.
SceneCapture->bCaptureEveryFrame = true;
SceneCapture->bCaptureOnMovement = false;
bCaptureEveryFrame = true이므로 SceneCapture는 매 프레임 새로운 결과를 RenderTarget에 기록한다.
이 상태에서는 SceneCapture가 이동했는지를 기준으로 별도의 캡처를 요청할 필요가 없기 때문에 bCaptureOnMovement는 꺼두었다.
즉 두 옵션은 서로 모순되는 설정이 아니다.
CaptureEveryFrame
→ 매 프레임 갱신한다.
CaptureOnMovement
→ Component가 움직였을 때 별도의 캡처를 요청한다.
현재 프리뷰는 첫 번째 방식만으로 충분하다.
Capture Source는 다음과 같이 설정되어 있다.
SceneCapture->CaptureSource =
ESceneCaptureSource::SCS_SceneColorHDR;
현재 프로젝트의 이 설정에서는 RGB에 HDR SceneColor를 받고 Alpha에는 반전된 Opacity 값을 사용한다.
따라서 Material에서 그대로 Alpha를 사용하면 원하는 캐릭터 마스크와 방향이 반대가 된다.
M_ClassPreview에서는 OneMinus를 사용해 이를 다시 뒤집는다.
SceneCapture Alpha
↓
Inv Opacity
↓
OneMinus
↓
1 - Alpha
↓
UI에서 사용할 Opacity
중요한 점은 Alpha를 뒤집는 것이 SceneCapture의 책임이 아니라 합성 Material의 책임이라는 것이다.
SceneCapture는 정해진 형식으로 데이터를 출력하고, 그 데이터를 최종 UI에서 어떤 방식으로 사용할지는 Material이 결정한다.
처음에는 ShowOnlyComponent(ChangeEffect) 같은 코드가 Niagara Effect를 보여주는 코드처럼 느껴질 수 있다.
하지만 ShowOnly 등록과 Effect 재생은 완전히 다른 책임이다.
SceneCapture->ShowOnlyComponent(ChangeEffect);
이 코드는 Niagara Component를 SceneCapture가 렌더링할 수 있는 대상 목록에 추가할 뿐이다.
실제로 Effect를 처음부터 다시 재생하는 것은 다음 코드다.
void RestartEffect(UNiagaraComponent* Effect)
{
if (!IsValid(Effect))
{
return;
}
Effect->Deactivate();
Effect->Activate(true);
}
즉 다음 둘을 구분해야 한다.
ShowOnlyComponent()
→ SceneCapture가 이 Component를 볼 수 있게 한다.
Activate()
→ Niagara System 자체를 실제로 실행한다.
RegisterPreviewComponents()에서 ClearShowOnlyComponents()를 호출하는 것도 같은 이유로 주의해야 한다.
목록을 비우면 Preview Pawn과 Weapon뿐 아니라 기존에 등록했던 ChangeEffect, LockInEffect도 함께 제거된다. 두 Niagara Component는 Stage가 계속 소유하고 있어도 SceneCapture의 ShowOnly 목록에서는 사라진다.
그래서 목록을 다시 구성한 마지막에 두 Effect도 다시 등록한다.
객체가 존재하는 것, Effect가 실행 중인 것, SceneCapture가 그것을 볼 수 있는 것은 서로 다른 상태다.
PreviewStage는 런타임에 두 UObject를 생성한다.
UPROPERTY(Transient)
TObjectPtr<UTextureRenderTarget2D> RenderTarget;
UPROPERTY(Transient)
TObjectPtr<UMaterialInstanceDynamic> PreviewMID;
둘 모두 일반적인 C++ new/delete 방식으로 관리하는 객체가 아니라 Unreal의 UObject와 Garbage Collection 시스템에 속한다.
여기서 UPROPERTY, TObjectPtr, Transient, Outer의 역할을 하나로 뭉쳐서 이해하면 혼동하기 쉽다.
UPROPERTY로 노출된 UObject 참조는 Unreal의 GC가 객체 간 참조 관계를 추적할 수 있게 한다. 현재 PreviewStage가 살아 있고 RenderTarget과 PreviewMID를 참조하고 있다면 GC는 이 관계를 알 수 있다.
TObjectPtr은 UObject를 가리키기 위한 Unreal의 Object Pointer 타입이며 현재 코드에서는 UPROPERTY와 함께 사용되고 있다.
반면 Transient는 객체를 살려두기 위한 옵션이 아니다.
UPROPERTY(Transient)
TObjectPtr<UTextureRenderTarget2D> RenderTarget;
여기서 Transient가 의미하는 것은 이 Property가 저장되는 영속 데이터가 아니라 런타임에만 사용하는 임시 상태라는 것이다.
따라서 다음과 같이 생각하면 안 된다.
Transient
→ EndPlay에서 자동 nullptr 처리
→ 즉시 객체 파괴
Transient는 저장 규칙이고 객체 파괴 명령이 아니다.
Outer 역시 GC 생존 참조와 같은 개념은 아니다.
NewObject<UTextureRenderTarget2D>(this);
여기서 this를 Outer로 지정했다고 해서 “Stage와 RenderTarget이 C++ 부모·자식 소유 관계가 되었으니 Stage가 없어지는 순간 RenderTarget도 즉시 삭제된다”고 단순화할 수 없다.
현재 구조에서는 Stage가 UObject Property로 RenderTarget과 MID를 참조하고 있다. Stage가 더 이상 사용되지 않고 GC의 도달 가능한 참조 그래프에서 벗어나면, 다른 강한 참조가 없는 런타임 UObject들도 이후 GC 대상이 될 수 있다.
그래서 오직 메모리 회수만을 목적으로 EndPlay()에서 다음과 같은 코드를 반드시 작성할 필요는 없다.
RenderTarget = nullptr;
PreviewMID = nullptr;
다만 객체를 재사용하는 시스템에서 “다음 사용 때 이전 상태가 남아서는 안 된다”는 요구가 있다면 메모리 회수와 별개로 상태 초기화가 필요할 수 있다.
이 차이가 중요하다.
GC를 위한 수명 관리와 재사용을 위한 상태 초기화는 같은 문제가 아니다.
PreviewPawn과 Weapon은 RenderTarget이나 MID와 상황이 다르다.
이들은 NewObject로 생성한 단순 런타임 UObject가 아니라 월드에 각각 SpawnActor된 Actor다.
RenderTarget / PreviewMID
→ 런타임 UObject
→ UObject 참조 그래프와 GC의 관리 대상
PreviewPawn / PreviewWeapons
→ World에 Spawn된 Actor
→ 현재 PreviewStage 코드가 명시적으로 Destroy
Stage가 PreviewPawn을 가리키고 있다고 해서 PreviewPawn이 Stage 내부에 포함된 자식 UObject가 되는 것은 아니다.
Weapon도 마찬가지다. Pawn이 Weapon을 가지고 있다고 해서 PreviewStage를 파괴하는 것만으로 이 Actor들이 현재 코드의 의도대로 함께 정리된다고 가정해서는 안 된다.
그래서 EndPlay()에서는 명시적으로 프리뷰 Actor 정리 경로를 호출한다.
void ADominationClassPreviewStage::EndPlay(
const EEndPlayReason::Type EndPlayReason)
{
DestroyPreviewPawn();
Super::EndPlay(EndPlayReason);
}
그리고 실제 정리는 다음 순서로 이루어진다.
for (const TWeakObjectPtr<AWeaponBase>& WeaponPtr : PreviewWeapons)
{
if (AWeaponBase* Weapon = WeaponPtr.Get())
{
Weapon->Destroy();
}
}
PreviewWeapons.Reset();
if (IsValid(PreviewPawn))
{
PreviewPawn->Destroy();
}
PreviewPawn = nullptr;
Weapon Actor를 먼저 정리하고 목록을 비운 뒤 Preview Pawn을 제거한다.
여기서 PreviewPawn은 UPROPERTY의 TObjectPtr로 가지고 있지만 Weapon 목록은 다음처럼 TWeakObjectPtr을 사용한다.
TArray<TWeakObjectPtr<AWeaponBase>> PreviewWeapons;
Weak Pointer는 대상 Actor의 생존을 강제로 유지하기 위한 참조가 아니다. 이미 파괴되었거나 유효하지 않은 Actor인지 확인하면서 접근하기 위한 관찰용 참조에 가깝다.
그래서 반복할 때도 바로 역참조하지 않고 Get()으로 유효한 Actor인지 확인한다.
중요한 것은 TWeakObjectPtr을 사용한다고 Weapon이 자동으로 파괴되는 것이 아니라는 점이다.
TWeakObjectPtr
→ 대상의 생존을 소유하지 않는다.
→ 유효한 대상을 안전하게 추적한다.
Weapon->Destroy()
→ 실제 Actor 제거를 요청한다.
Preview Pawn은 일반적인 SpawnActor()가 아니라 SpawnActorDeferred()로 생성한다.
PreviewPawn = World->SpawnActorDeferred<AShooterCharacter>(
PawnClass,
StageTransform,
nullptr,
nullptr,
ESpawnActorCollisionHandlingMethod::AlwaysSpawn);
Deferred Spawn을 사용하면 Actor 생성 과정 중 FinishSpawning()이 호출되기 전에 필요한 값을 설정할 수 있다.
현재 프리뷰 캐릭터는 실제 플레이 캐릭터처럼 네트워크에 복제될 필요가 없고 충돌도 필요하지 않다.
그래서 다음 설정을 먼저 적용한다.
PreviewPawn->SetReplicates(false);
PreviewPawn->SetActorEnableCollision(false);
PreviewPawn->FinishSpawning(StageTransform);
그 후 FinishSpawning()을 호출하여 Spawn을 완료하고 BeginPlay()까지 진행시킨다.
즉 현재 코드에서 Deferred Spawn을 사용하는 목적은 단순히 Spawn 방식을 다르게 쓰기 위한 것이 아니라 일반적인 Actor 초기화가 끝나기 전에 프리뷰 전용 상태를 먼저 설정하기 위해서다.
Movement 설정은 오히려 FinishSpawning() 이후에 한다.
if (UCharacterMovementComponent* Movement =
PreviewPawn->GetCharacterMovement())
{
Movement->GravityScale = 0.0f;
Movement->SetMovementMode(MOVE_None);
}
프로젝트의 Character BeginPlay()에서 GravityScale에 추가 계산이 들어가기 때문에 먼저 0으로 설정하면 이후 BeginPlay에서 값이 다시 변할 수 있다.
그래서 BeginPlay가 실행된 뒤 최종 프리뷰 상태를 적용한다.
또한 Movement Component 자체를 Deactivate()하지는 않는다. 그렇게 하면 AnimBP 동작까지 영향을 받아 캐릭터가 T-Pose로 떨어졌기 때문이다.
프리뷰라고 해서 Actor의 모든 기능을 무작정 끄는 것이 아니라 필요 없는 동작만 제한하면서 애니메이션에 필요한 시스템은 유지해야 한다.
PreviewPawn에서 다음 코드를 호출했다.
PreviewPawn->SetReplicates(false);
그러나 이것만으로 Preview Weapon의 Replication까지 자동으로 차단되는 것은 아니다.
Weapon은 PreviewPawn 내부의 단순 UObject가 아니라 별도로 Spawn된 AWeaponBase Actor다.
따라서 Weapon도 각각 설정해야 한다.
for (const TWeakObjectPtr<AWeaponBase>& WeaponPtr : PreviewWeapons)
{
if (AWeaponBase* Weapon = WeaponPtr.Get())
{
Weapon->SetReplicates(false);
}
}
여기에서도 같은 원리가 반복된다.
어떤 Actor를 가리키거나 장착하고 있다는 관계와 그 Actor의 수명·복제 설정을 소유한다는 것은 별개의 문제다.
PreviewStage는 다음 세 슬롯을 모두 검사한다.
static const EWeaponSlot PreviewSlots[] = {
EWeaponSlot::Primary,
EWeaponSlot::Secondary,
EWeaponSlot::Melee
};
처음 보면 캐릭터 프리뷰에서 Primary, Secondary, Melee 무기를 모두 동시에 화면에 보여주기 위한 코드처럼 보일 수 있다.
하지만 목적은 화면 표시 자체가 아니다.
생성된 Weapon Actor들을 PreviewStage가 관리할 수 있도록 한곳에 수집하는 것이다.
수집된 Weapon에는 다음 세 가지 처리가 필요하다.
Replication 차단
ShowOnly 목록 등록
Preview 종료 시 Destroy
따라서 현재 눈에 보이는 무기 하나만 관리해서는 부족하다. 화면에 당장 보이지 않는 무기도 별도 Actor로 존재한다면 네트워크 복제를 막아야 하고 Preview가 끝날 때 정리해야 한다.
그래서 지원하는 슬롯 전체에서 실제 생성된 Weapon만 가져온다.
if (AWeaponBase* Weapon = Combat->GetWeaponInSlot(Slot))
{
PreviewWeapons.AddUnique(Weapon);
}
즉 세 슬롯을 검사한다고 세 Weapon이 항상 존재한다는 뜻은 아니다.
각 슬롯에서 실제 Actor가 존재할 때만 목록에 추가한다.
또한 CurrentWeapon이 어떤 이유로 세 슬롯 수집 결과에 포함되지 않는 경우도 보완한다.
if (AWeaponBase* CurrentWeapon = Combat->GetCurrentWeapon())
{
PreviewWeapons.AddUnique(CurrentWeapon);
}
AddUnique()를 사용하기 때문에 이미 슬롯에서 수집된 CurrentWeapon이라면 중복 추가되지 않는다.
결국 PreviewWeapons는 화면에 보여줄 무기 목록이 아니라 PreviewStage가 책임지고 관리해야 할 Weapon Actor 목록이라고 보는 것이 더 정확하다.
이번 클래스 프리뷰를 구현하면서 가장 크게 정리된 것은 하나의 기능 안에서도 객체마다 책임이 다르다는 점이었다.
SceneCapture
→ 무엇을 촬영할지 결정한다.
RenderTarget
→ 촬영 결과를 저장한다.
Material
→ RenderTarget을 UI에 필요한 형태로 합성한다.
ShowOnly
→ SceneCapture가 볼 수 있는 대상을 정한다.
Niagara Activate
→ Effect 자체를 실행한다.
UPROPERTY / TObjectPtr
→ UObject 참조를 GC가 추적할 수 있게 한다.
Transient
→ 런타임 Property를 영속 데이터로 저장하지 않게 한다.
TWeakObjectPtr
→ 다른 객체의 생존을 소유하지 않고 유효성을 확인하며 추적한다.
Destroy()
→ Spawn된 Actor를 명시적으로 제거한다.
처음에는 모두 “프리뷰를 보여주기 위한 코드”로 보였지만, 실제로는 촬영, 출력, 합성, 가시성, 실행, 참조, Actor 정리라는 서로 다른 책임이 연결되어 하나의 프리뷰 시스템을 이루고 있었다.
특히 수명 관리에서는 Outer, UPROPERTY, Transient, TObjectPtr, Destroy()를 모두 “객체를 자동으로 정리해 주는 것”으로 뭉뚱그리면 안 된다.
각각이 해결하는 문제가 다르기 때문이다.