에디터 구동 테스트 영상
안녕하세요! 자체엔진을 만들면서 에디터도 제작 중인데 꽤 예전부터 틈틈히 진행하다보니 블로그에 쓰기도 너무 애매한 분량이 몇년동안 지속되더라구요.
그러다가 슬슬 포트폴리오도 새로 갱신할 겸 에디터 제작에 대한 이야기를 여기에 정리해보고자 합니다.
구현된 순서대로 작성해보겠습니다.
에디터 만들어보자!
에디터를 만들어보고 싶어서 GUI를 뭐로 붙일까 고민하다가 imgui가 눈에 들어왔습니다. Static 함수들로 이루어져있고, 절차식으로 UI를 만들어가다보니 접근 난이도가 낮아서 이걸 채택하게 되었습니다.
무엇보다 크로스 플랫폼 기반의 예제소스도 풍부해서 쓰지 말아야 할 이유를 찾는게 더 힘들더군요.
아무튼 imgui를 기반으로 아래와 같은 작업을 시작하였습니다.
- 스크립트 직렬화 시스템 구현 시작
WindowBase
클래스로 에디터 윈도우 아키텍처 설계MainDocker
클래스로 도킹 시스템 기반 마련CustomComponent
확장으로 게임 오브젝트 시스템 강화
imgui에서의 docker 시스템을 이용한 DockerSpace 예제 이미지
전반적으로 WindowBase
를 기반으로 기능을 구현하려고 했습니다. 무엇보다 docker 브랜치에 구현된 도킹 시스템이 매우 마음에 들어서 이 클래스를 기반으로 설계했습니다.
1
2
3
4
5
6
7
8
9
10
11
12
// WindowBase.h - 모든 에디터 윈도우의 기반 클래스
namespace CSEditor {
class WindowBase {
public:
virtual ~WindowBase() = default;
virtual void SetUI() = 0; // 각 윈도우의 UI 렌더링
protected:
ImGuiViewport* m_mainViewport;
};
}
1
2
3
4
5
6
7
8
9
10
11
12
// MainDocker.cpp - 도킹 시스템과 윈도우 관리
class MainDocker : public WindowBase {
private:
std::vector<WindowBase*> m_windows;
ImGuiWindowFlags m_windowFlags = ImGuiWindowFlags_MenuBar | ImGuiWindowFlags_NoDocking;
public:
void SetUI() override {...}
private:
void GenerateWindows() {...}
};
이러한 클래스들을 총괄 관리하는 MainDocker
도 간단하게 작업했지만…
사실 초기 설계 단계에서는 추상 클래스만 구현해둔 상태라 에디터를 위해 컴포넌트들의 직렬화 작업을 추가로 진행해야 했습니다.
본격적으로 에디터 구조화하기
본격적인 에디터 구조화 작업은 2022년 10월부터 Windows 프로젝트로 시작했습니다.
1
2
3
4
5
6
7
8
Editor/platforms/Windows/
├── CMakeLists.txt # CMake 빌드 시스템
├── CSEditor.sln # Visual Studio 솔루션
├── CSEditor/
│ ├── main.cpp # 에디터 진입점
│ ├── imgui.ini # ImGui 설정
│ └── 리소스 파일들...
└── CMake/ # 빌드 유틸리티
기존에 구축해놨던 프로젝트 파일 트리를 기반으로 Editor 폴더를 독립적으로 분리했습니다.
당시 주요 작업 내용은 다음과 같습니다:
- CMake 기반 에디터 전용 빌드 시스템 구축
- Visual Studio 및 CMake 통합
- 멀티 플랫폼 아키텍처 준비 (
platforms/Windows/
구조)
여기까지는 ImGui의 GLFW 기반 예제를 활용했기 때문에 큰 문제는 없었습니다.
엔진 코어 분리
제목만 보면 “엔진 코어 분리”가 무슨 의미인지 이해하기 어려울 수 있습니다.
이를 명확히 설명하기 위해 기존 엔진 코어의 구조부터 살펴보겠습니다.
기존 EngineCore 클래스
1
2
3
4
5
6
7
8
9
10
11
12
13
14
namespace CSE {
class EngineCore {
public:
DECLARE_SINGLETON(EngineCore);
~EngineCore();
void Init(unsigned int width, unsigned int height);
void Update(float elapsedTime);
void LateUpdate(float elapsedTime);
void Render() const;
void Exterminate();
// ...생략...
};
}
보시다시피 DECLARE_SINGLETON
매크로로 싱글톤이 선언되어 있습니다. 엔진의 핵심이니까 싱글톤으로 설계하는 것이 당연하다고 생각했습니다. 에디터를 만들기 전까지는 말이죠.
에디터 개발 과정에서 엔진 코어를 동시에 두 개 실행해야 하는 상황이 발생했습니다.
- 에디터용 엔진 코어: 에디터 상에서 다양한 편집 작업을 처리
- 프리뷰용 엔진 코어: 에디터 내에서 게임 실행을 시뮬레이션
이렇게 해서 “세상에 절대라는 건 없다”는 교훈을 뼈저리게 느꼈습니다.
수정된 EngineCore
결국 EngineCore
를 인스턴스화가 가능하도록 리팩토링해야 했기 때문에 EngineCoreInstance
라는 부모 클래스를 새로 설계했습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
namespace CSE {
class EngineCoreInstance {
protected:
EngineCoreInstance();
virtual ~EngineCoreInstance();
public:
void Init(unsigned int width, unsigned int height);
void Update(float elapsedTime);
void LateUpdate(float elapsedTime);
void Render() const;
void Exterminate();
bool IsReady() const {
return m_isReady;
}
// ...생략...
protected:
bool m_isGenerated = false;
bool m_isReady = false;
// ...생략...
};
}
사실 기존의 EngineCore
와 기능적으로는 큰 차이가 없습니다. 다행히 EngineCore
가 내부적으로 모든 코어 시스템의 메모리 생명주기를 관리하고 있었기 때문입니다.
1
2
3
4
5
6
7
namespace CSE {
class EngineCore : public EngineCoreInstance {
public:
DECLARE_SINGLETON(EngineCore);
~EngineCore() override;
};
}
이렇게 빌드된 런타임용 EngineCore
는 기존의 싱글톤 형태를 유지하면서도 기존 엔진 로직은 전혀 손상시키지 않았습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// EEngineCore.cpp - 싱글톤 패턴과 프리뷰 모드 분기
namespace CSEditor {
class EEngineCore : public CSE::EngineCoreInstance {
public:
static EngineCoreInstance* getInstance() {
if (sInstance == nullptr) sInstance = new EEngineCore;
if (sInstance->IsPreview())
return sInstance->m_previewCore; // 프리뷰 모드일 때
return sInstance; // 에디터 모드일 때
}
void StartPreviewCore() {
if (m_previewCore == nullptr) {
m_previewCore = new EPreviewCore();
m_previewCore->Init(m_previewWidth, m_previewHeight);
}
}
void StopPreviewCore() {
if (m_previewCore != nullptr) {
m_previewCore->Exterminate();
delete m_previewCore;
m_previewCore = nullptr;
}
}
};
}
에디터에는 EEngineCore
라는 에디터 전용 엔진 코어가 적용됩니다. 위 코드에서 확인할 수 있듯이 이 클래스 내부에는 프리뷰 엔진 코어도 포함되어 있습니다. 주요 구성 요소는 다음과 같습니다.
EPreviewCore
: 별도의 렌더링 컨텍스트를 가진 프리뷰 전용 엔진EEngineCore
: 에디터 전용 엔진 코어로 기존EngineCoreInstance
확장- 프레임버퍼 분리: 메인 엔진과 완전히 독립적인 렌더링 파이프라인 구축
이 구조에서는 프리뷰 진행 여부에 따라 PreviewWindow
의 프레임버퍼를 동적으로 스위칭하며 렌더링을 수행합니다.
윈도우 시스템 기본 구현
엔진 코어 분리가 완료되어 본격적인 윈도우 시스템 구현에 착수했습니다. 초기에 구현한 핵심 윈도우들은 다음과 같습니다.
- HierarchyWindow: 씬에 배치된 오브젝트들의 계층구조 시각화
- ConsoleWindow: 로그 및 디버그 정보 출력
- PreviewWindow: 실시간 게임 화면 프리뷰
- InspectorWindow: 선택된 오브젝트의 컴포넌트 정보 편집
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// HierarchyWindow.cpp - 씬 오브젝트 트리 렌더링
void HierarchyWindow::SetUI() {
ImGui::Begin("Hierarchy");
RenderTrees(); // 씬의 오브젝트 계층구조 렌더링
// 키 입력 감지로 에디터 렌더링 트리거
if (!m_core->IsPreview() && (ImGui::IsWindowFocused() || ImGui::IsWindowHovered())) {
for (ImGuiKey key = static_cast<ImGuiKey>(0); key < ImGuiKey_COUNT; key = (ImGuiKey)(key + 1)) {
if (ImGui::IsKeyDown(key)) {
m_core->InvokeEditorRender(); // 실시간 업데이트
break;
}
}
}
ImGui::End();
}
void HierarchyWindow::RenderTrees() {
const auto& sceneMgr = CORE->GetSceneMgrCore();
const auto& scene = dynamic_cast<CSE::SScene*>(sceneMgr->GetCurrentScene());
if (scene == nullptr) return;
const auto& root = scene->GetRoot();
// 재귀적으로 오브젝트 트리 렌더링...
}
HierarchyWindow
는 Unity의 Hierarchy와 동일한 역할을 수행합니다. 여기서 중요한 점은 프리뷰가 진행 중이 아니면 화면 갱신이 매 tick마다 실행되지 않는다는 것입니다.
따라서 위 코드에서 볼 수 있듯이 키 입력 감지 시 현재 씬을 실시간으로 갱신하는 로직이 포함되어 있습니다.
이와 함께 ImGui의 도킹 시스템을 본격적으로 도입했습니다. 역시 도킹된 레이아웃이 훨씬 더 전문적인 에디터 같은 느낌을 줍니다.
에셋 브라우저 구현
1
2
3
4
5
6
7
8
9
10
11
12
class AssetWindow {
private:
std::unordered_map<std::string, AssetsVector> m_assets;
std::string m_currentPath;
std::queue<void*> m_previewAssetQueue;
std::vector<std::string> m_pathSelector;
public:
void RefreshAssets();
void OnDragDrop(const AssetReference& asset);
bool OnAssetClickEvent(const AssetReference& asset);
};
에셋 브라우저 역할을 하는 AssetWindow
클래스를 추가했습니다. 런타임 중 실시간 에셋 로딩을 구현하기 위해서는 리소스 관리의 핵심인 SResource
부모 클래스에서부터 직렬화 및 Reflection 시스템이 필요했습니다.
이를 위해 직렬화 담당 VariableBinder
와 Reflection 담당 ReflectionObject
를 추가로 상속받는 아키텍처로 설계했습니다.
에셋 브라우저 구현을 위해 다음과 같은 핵심 기능들을 추가로 개발했습니다.
- 폴더 트리 네비게이션: 직관적인 파일 시스템 탐색
- 에셋 프리뷰 시스템: 실시간 에셋 미리보기
- 드래그 앤 드롭 인터페이스: 직관적인 에셋 배치
- 동적 에셋 갱신: 런타임 중 실시간 에셋 관리
이렇게 에셋 브라우저가 구현되면서 단순히 씬 데이터만 확인하던 에디터가 본격적인 콘텐츠 제작 도구로 발전할 수 있었습니다.
아래는 에셋 브라우저 구현 과정에서 개발한 Reflection 시스템에 대한 간략한 설명입니다.
C++에서 Reflection 구현
이 주제는 별도의 포스트로 자세히 다룰 예정이라 간략하게만 소개하겠습니다.
Reflection 시스템 구현의 핵심은 모든 클래스의 헤더 파일 선언 시점에서 클래스 해시 등록이 이루어져야 한다는 것입니다. 이를 위해 Reflection 데이터를 담은 연결 리스트를 static으로 저장하여 컴파일 타임에 안전하게 클래스 등록이 가능하도록 설계했습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// SMaterial의 생성자 파트
RESOURCE_CONSTRUCTOR(SMaterial) {
m_lightMgr = CORE->GetCore(LightMgr);
}
// SMaterial의 생성자 파트의 매크로를 풀면 아래와 같은 코드로 나타남
namespace __REFELCTION_DUMP__ {
namespace SMaterial {
unsigned char* __CSE_REFLECTION_DUMP__ = CSE::ReflectionMgr::DefineWrapper::SetDefine(
"SMaterial", []() { return new CSE::SMaterial(); });
}
}
CSE::SMaterial() : CSE::SResource("SMaterial") {
m_lightMgr = CORE->GetCore(LightMgr);
}
이렇게 등록된 Reflection 오브젝트들은 문자열 기반 타입 추론을 통해 동적으로 생성할 수 있습니다:
1
2
3
4
5
6
7
8
9
// SGameObject.cpp의 컴포넌트를 생성하는 함수
SComponent* SGameObject::CreateComponent(const char* type) {
SComponent* component = static_cast<SComponent*>(ReflectionObject::NewObject(type));
component->SetGameObject(this);
AddComponent(component);
if (m_status == IDLE)
component->Init();
return component;
}
인스펙터의 Material 정보 표기
1
2
3
4
5
6
7
class MaterialLayer {
private:
// 머티리얼 파라미터들을 레이어 형태로 관리
public:
void RenderMaterialUI();
void UpdateMaterialProperties();
};
PBR 렌더링을 지원하는 머티리얼 에디터를 구현했습니다. 인스펙터의 주요 기능은 다음과 같습니다:
- 레이어 기반 머티리얼 시스템: 계층적 머티리얼 속성 관리
- 실시간 프리뷰 업데이트: 인스펙터 수정 시 즉시 프리뷰 반영
- 기존 로직 호환성: 람다 기반
SMaterial
uniform 선언 시스템과 완전 호환
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// MaterialLayer.cpp - 머티리얼 속성 에디터
void MaterialLayer::RenderUI() {
// 머티리얼 참조가 변경되었는지 확인
if(m_render->GetMaterialReference() != m_material_ref) {
m_material = m_render->GetMaterial();
m_material_ref = m_render->GetMaterialReference();
MaterialLayer::InitParams(); // 파라미터 재초기화
}
if (!ImGui::CollapsingHeader(m_name.c_str(), ImGuiTreeNodeFlags_DefaultOpen))
return;
// 드래그 앤 드롭 소스로 설정
if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_None)) {
ImGui::SetDragDropPayload("INSP_RES", m_material, sizeof(CSE::SResource));
ImGui::EndDragDropSource();
}
// 머티리얼 파라미터들을 테이블 형태로 렌더링
ImGui::BeginTable(m_material->GetHash().c_str(), 2, ImGuiTableFlags_None);
for (const auto& param: m_params) {
const auto& name = param->GetName();
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
ImGui::Text("%s", name.c_str());
ImGui::TableSetColumnIndex(1);
param->RenderUI(); // 각 파라미터 타입에 맞는 UI 렌더링
}
ImGui::EndTable();
}
이 구조를 통해 프리뷰 진행 중에도 인스턴스로 생성된 머티리얼들의 uniform 데이터를 실시간으로 수정할 수 있게 되었습니다.
에셋 브라우저의 에셋 프리뷰 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// EAssetPreviewMgr.cpp - 에셋 프리뷰 생성 및 관리
class EAssetPreviewMgr {
private:
std::unordered_map<std::string, CSE::SResource*> m_previews;
CSE::ResMgr* m_editorResMgr;
public:
CSE::STexture* GetPreview(std::string& hash) {
const auto& iter = m_previews.find(hash);
SResource* res = nullptr;
if (iter != m_previews.end())
res = iter->second; // 캐시된 프리뷰 사용
else
res = GeneratePreview(hash); // 새 프리뷰 생성
if(res->IsSameClass(STexture::GetClassStaticType())) {
return static_cast<STexture*>(res);
}
return nullptr;
}
SResource* GeneratePreview(std::string& hash) {
const auto& asset = m_editorResMgr->GetAssetReference(hash);
const auto& res = SResource::Create(asset, asset->class_type);
if (res->IsSameClass(STexture::GetClassStaticType())) {
// 프리뷰 캐시에 저장
m_previews.insert(std::pair<std::string, CSE::SResource*>(res->GetHash(), res));
return res;
}
}
};
에셋 브라우저의 프리뷰 이미지 로딩 로직을 구현했습니다. 단순해 보일 수 있지만 에디터용 엔진 코어와 프리뷰 엔진 코어 간의 명확한 데이터 분리가 핵심이기 때문에 매우 중요한 구현입니다.
1
2
3
4
5
6
EngineCoreInstance* EEngineCore::getInstance() {
if (sInstance == nullptr) sInstance = new EEngineCore;
if (sInstance->IsPreview())
return sInstance->m_previewCore;
return sInstance;
}
이 역시 프리뷰 진행 여부에 따라 동적으로 엔진 코어를 선택하는 로직으로 구현했습니다. 위의 getInstance()
함수가 핵심적인 분기 처리 로직의 대표적인 예시입니다.
정리
전반적으로 설명된 구현 내용을 테스트하는 영상
지금까지의 구현으로 에디터의 기본적인 모습은 갖춰졌지만 아직 부족한 부분이 많아 master
브랜치에는 머지하지 못한 상태입니다. 최종 목표는 씬 작업이 가능한 에디터가 WebGL 기반 웹 브라우저에서도 완벽하게 작동하는 것입니다.
현재는 웹 브라우저 실행 시 PreviewWindow
의 프레임버퍼 스위칭에서 문제가 발생하고 있어 이 부분을 우선적으로 해결해야 합니다.
그래도 오랫동안 진행해온 에디터 작업을 이렇게 정리할 수 있어서 뿌듯합니다. 생각보다 정말 많은 것들을 구현해냈네요!
긴 글 읽어주셔서 감사합니다!
자체엔진 Git 주소 : https://github.com/ounols/CSEngine