1. 개요
우리가 게임 엔진을 보게 된다면 PIE 모드 (Play In Editor) 모드가 존재합니다.
PIE 모드의 존재로 인하여 개발자는 굳이 빌드를 하지 않더라도
게임 환경 안에서 빠르게 테스트를 할 수 있는 장점이 있습니다.
이번엔 DX11을 이용한 엔진 개발에서 PIE 모드를 구현하며 얻은 지식을 공유하고자 합니다.
2. Unreal Engine 구조
우선 언리얼 엔진을 카피하는 방식으로 개발을 진행하고 있기 때문에 언리얼 엔진의 계층 구조를 알아야 합니다.
사실 World는 WorldContext라는 Class에 의해서 한 번 더 Wrapping 됩니다.
(World의 상태를 나타내는 Enum값 소유 PIE,Editor)
언리얼 엔진에는 World가 있고 World는 Level을 가지고 있고,
Level은 언리얼 엔진에 생성되는 오브젝트의 기본단위인 Actor들을 가지고 있습니다.
Actor들은 Actor들의 세부사항을 담당하는 Component들이 있습니다.
이러한 계층 구조를 설명한 이유는 PIE 모드를 구현하기 위해서는 이러한 계층 구조를 이해하는 것이 중요하기 때문입니다.
3. 계층 구조를 알아야 하는 이유 + Shallow, Deep Copy
기본적으로 PIE 모드의 특성에 대해서 이해를 해야 합니다.
저는 Unity 개발자였기 때문에 Unity를 예시로 들어 설명하도록 하겠습니다.
Unity Editor에서 게임 플레이 버튼을 누른 뒤 오브젝트를 움직이는 것과 같은 작업을 한 뒤에
플레이 버튼을 중지하게 되면 플레이 모드에서 작업했던 내용들은 전부 초기화됩니다.
(PIE 모드인 것 확인할 수 있도록 색 바꾸는 기능 존재)
그렇다면 PIE 모드(Unity 기준 플레이 버튼 누른 상태)로 들어가게 된다면 누른 순간에 해당 게임 월드에 대한 정보를 전부
저장해서 가지고 있어야 합니다. 그렇게 해야만 PIE 모드가 끝났을 때 저장해둔 월드의 정보로 돌아올 수 있기 때문입니다.
그렇다면 여기서 말하는 저장은 어떻게 해야 할까요?
이때 바로 Shallow/Deep Copy의 개념이 필요하게 됩니다.
단순히 생각하면 그냥 World 객체만 복사하면 되는 것이 아니냐고 생각할지도 모릅니다.
이처럼 단순히 EditorWorld = SourceWorld 이런 식으로 복사하게 된다면 큰 문제가 생기게 됩니다.
4. Shallow Copy시 발생하는 문제, 해결방안
문제
- 포인터 문제: World 내부의 Actor들은 서로를 포인터로 참조합니다. 단순 복사 시 이 포인터들이 원본 객체를 계속 가리키게 됩니다.
- 식별자 고유성: 각 Actor와 Component는 고유한 ID를 가지며, 단순 복사하면 ID 충돌이 발생합니다.
Shallow Copy가 발생하게 되고 이렇게 되면 PIE 모드에서 Actor를 조작한 뒤 다시 Editor 모드로 돌아오게 되면
실제 Editor 모드에도 영향을 주게 됩니다.
왜냐하면 Shallow Copy 되어서 같은 주솟값을 가지고 있었기 때문이죠
이러한 문제 때문에 모든 계층 구조를 돌며 깊은 복사를 구현해 주어야 합니다 .
- World 객체 자체를 복사
- World 내부의 모든 Level을 복사
- 각 Level 내의 모든 Actor를 복사
- 각 Actor 내의 모든 Component를 복사
- 복사된 모든 객체들 간의 참조 관계를 새로 설정
이 과정을 통해 완전히 독립된 게임 월드를 만들어 PIE 모드에서 안전하게 게임을 실행할 수 있게 됩니다.
단순한 shallow copy로는 이런 독립성을 보장할 수 없기 때문에, 복잡한 deep copy 과정이 필요한 것입니다.
한마디로 모든 새로운 Actor, Component 클래스 별로 각각의 Duplicate 클래스를 전부 하드코딩해주어야 하는 것이죠..
현재 모든 클래스들은 최상위 클래스인 UObject를 상속받고 있기 때문에
최상위 클래스에 가상 함수를 만들어 둔 뒤
이런 식으로 Override 해서 복사 로직을 손수 구현해 줍니다.
그렇게 모든 것을 예쁘게 Deep Copy 하여 복사하고 나면 PIE 모드에서 무슨 짓을 해도 원본 월드에는 어떠한 영향을
주지 않은 우리가 원하는 상태가 발생합니다.
참고로 Return Type은 다르게 하여 Override 하는 것이 가능합니다~
UWorld* UEditorEngine::DuplicateWorldForPIE(UWorld* SourceWorld)
{
if (!SourceWorld)
return nullptr;
if (EditorWorld == nullptr)
{
EditorWorld = SourceWorld;
}
//기존 월드 백업 (원본 보호)
TSet<AActor*> BackupActors = SourceWorld->PersistentLevel->GetActors();
//새로운 월드 생성(PIE)
FWorldContext PIEWorldContext = FWorldContext(EWorldType::PIE);
UWorld* PIEWorld = PIEWorldContext.World;
WorldContexts.Add(PIEWorldContext);
//새로운 월드에 복사할 액터 목록
for (AActor* SourceActor : BackupActors)
{
if (!SourceActor)
continue;
if (SourceActor->IsA<UTransformGizmo>())
{
continue;
}
if (SourceActor->GetClass()->IsChildOf<AStaticMeshActor>()) {
AStaticMeshActor* SourceStaticActor = Cast<AStaticMeshActor>(SourceActor);
AStaticMeshActor* ClonedActor = SourceStaticActor->Duplicate();
if (ClonedActor)
{
PIEWorld->PersistentLevel->AddActor(ClonedActor);
}
continue;
}
//새로운 액터 복제 (깊은 복사)
AActor* ClonedActor = SourceActor->Duplicate();
if (ClonedActor)
{
PIEWorld->PersistentLevel->AddActor(ClonedActor);
}
}
//복제된 액터들을 PIE 월드에 추가
PIEWorld->SetPickedActor(nullptr);
PIEWorld->Initialize(EWorldType::PIE);
return PIEWorld;
}
void UEditorEngine::StartPIEMode()
{
if (GWorld && GWorld->WorldType == EWorldType::Editor)
{
UWorld* EditorWorld = GWorld;
UWorld* PIEWorld = DuplicateWorldForPIE(EditorWorld);
GWorld = PIEWorld;
}
}
void UEditorEngine::EndPIEMode()
{
if (GWorld && GWorld->WorldType == EWorldType::PIE)
{
GWorld->Release();
delete GWorld;
}
GWorld = EditorWorld;
}
전체적인 로직은 이러한 느낌입니다.
StartPIEMode를 실행하면 GWorld를 Duplicate를 통해 복사해온 월드로 바꾸어주고(깊은 복사해와서 반환해 줌)
EndPIEMode를 하게 되면 GWorld Release 해주고 (메모리 관리) 우리가 백업해두었던 EditorWorld를 다시 가져옵니다.
여기서 한가지 의문!
DuplicateWorldForPIE 함수를 보면
EditorWorld = SourceWorld;
단순히 이러한 방식으로 EditWorld를 백업해두는데..
어째서 Deep Copy를 하지 않은 것일까요?
백업의 경우, 다음과 같은 특징이 있습니다:
- 임시 참조용: 이 백업은 PIE 모드 종료 후 에디터 상태를 복원하기 위한 참조점으로만 사용됩니다.
- 수정 불가: 백업된 World는 직접 수정되지 않고, 오직 읽기 전용(read-only) 참조로만 사용됩니다.
- 최적화: Deep copy는 매우 비용이 큰 작업입니다. 백업은 단지 임시 참조용이므로, 불필요한 성능 저하를 피하기 위해 shallow copy를 사용합니다.
반면, PIE World를 생성할 때는:
- 독립적 실행: 생성된 World는 독립적으로 게임 로직을 실행해야 합니다.
- 독립적인 월드: 게임 플레이 중 객체들이 수정될 수 있고 PIE 모드가 종료되면 전부 사라지고 Editor 모드에 영향을 주면 안 됩니다.
따라서 백업은 단순히 상태를 임시로 기억하기 위한 것이므로 shallow copy로 충분하지만
PIE World는 실제로 게임을 실행하는 독립 환경이므로 deep copy가 필요한 것입니다.
4. 개발하면서 어려웠던 점
4-1. 어떤 것을 어떻게 복사해야 할지 감잡기 어려움
정말 많은 Actor와 Component들이 존재하고 그들이 가진 값들도 정말 많이 있는데 그중에서 어떠한 것을 복사해와야
Pipeline에 정확히 들어가서 정상적으로 렌더링이 될지 예상하기 힘들었습니다.
하나씩 복사해 보고 어떤 것은 복사에서 빼기도 하면서 Rendering이 정상적으로 되는 상황을 찾기 위해 정말 다양한 시도를
하였습니다.
Code Base가 처음부터 제가 짠 코드가 아니었기 때문에 파악하는데 더 많은 어려움이 있었습니다.
4-2. Shallow Copy 되는 부분 추적의 어려움
결론적으로 Shadllow Copy 문제라는 것을 확인했지만
복사를 하다 보면 복사 관련 코드가 정말 길어지게 되는데 그중에서 어디에서 얕은 복사가 일어났고 그것이 정말 문제를
발생시키는지에 대한 연관성을 찾기가 정말 어려웠습니다.
결국 의심이 가는 부분을 전부 다르게 고쳐보면서 문제를 해결하였습니다.
4-3. 정말 모든 클래스 별로 별도의 Duplicate 함수를 구현하는 것이 옳은 방법인지 고민
프로그래머는 귀찮음이 많아야 한다는 마인드를 가지고 있었기에 이렇게 Hard Coding 하는 방식이 정말
최선인지에 대한 의문이 있어서 효율적인 방법을 고민하는 데에도 시간이 많이 소비되었습니다.
결국 이 방법밖에 떠오르지 않아서 설명한 방식으로 구현하게 되었습니다.
'DirectX11' 카테고리의 다른 글
[DX11] Obj 파일 Parsing후 Pipe line Binding (2) | 2025.04.07 |
---|---|
[DX11] 안개 구현: Render Path 분리 (Full Quad Rendering) (0) | 2025.04.05 |
[DX11] Matarial Sorting을 통한 최적화 (0) | 2025.03.29 |
[DX11] Constant Buffer에 값 넘길 때 주의할 점(Shader로 Matarial값 넘길 때 주의할점) (0) | 2025.03.26 |
[DX11] Shader에 Vertex넘길 때 주의 해야 할 점!! (Shader 디버깅 Tip) (0) | 2025.03.25 |