Opening

The previous article covered the engine’s initial architecture, basic rendering, physics simulation, and parallelization. I had originally planned to wrap things up and focus on my thesis and spring recruitment, but one day I stumbled across Krystallos Engine from a fellow dev. Something about it lit a fire — couldn’t sleep, so I grinded for a week and came out with this big overhaul.

The last post got the engine’s basic framework in place along with a working editor. But honestly, that interface was still too rough — fixed-size windows, no drag-and-drop, no project management. For a modern engine editor, these basics really have to be there. This post is all about polishing the editor’s overall interface so it actually looks and feels like an engine.

img

Window Docking

Introduction

The previous interface had hardcoded positions and sizes, which was a pain to use. Commercial engines like Unity and UE all support free-form window dragging and dockable layouts — that’s what a modern editor should look like. Luckily ImGui’s Docking branch already provides this, so we can use it directly.

Implementing Docking

First, enable Docking support when initializing ImGui:

1
2
ImGuiIO& io = ImGui::GetIO();
io.ConfigFlags |= ImGuiConfigFlags_DockingEnable;

Then create a full-screen DockSpace as the docking area:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void Editor::SetupDocking()
{
ImGuiIO& io = ImGui::GetIO();
float menuBarHeight = ImGui::GetFrameHeight();
ImVec2 displaySize = io.DisplaySize;

ImGui::SetNextWindowPos(ImVec2(0, menuBarHeight));
ImGui::SetNextWindowSize(ImVec2(displaySize.x, displaySize.y - menuBarHeight));

ImGuiWindowFlags window_flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse |
ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus |
ImGuiWindowFlags_NoBackground;

ImGui::Begin("DockSpace", nullptr, window_flags);
ImGuiID dockspace_id = ImGui::GetID("MainDockSpace");
ImGui::DockSpace(dockspace_id, ImVec2(0.0f, 0.0f), ImGuiDockNodeFlags_None);
}

Default Layout

The first time the editor launches, it needs a default layout — otherwise all the windows pile up on top of each other. My layout puts Hierarchy and Scene on the left 30%, Game and Project in the middle 40%, and Inspector on the right 30%:

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
if (!dockingInitialized)
{
dockingInitialized = true;

ImGui::DockBuilderRemoveNode(dockspace_id);
ImGui::DockBuilderAddNode(dockspace_id, ImGuiDockNodeFlags_None);

ImGuiID dock_id_left, dock_id_right, dock_id_center;

// Left 30%
ImGui::DockBuilderSplitNode(dockspace_id, ImGuiDir_Left, 0.3f, &dock_id_left, &dock_id_center);
// Right 30%
ImGui::DockBuilderSplitNode(dock_id_center, ImGuiDir_Right, 0.42f, &dock_id_right, &dock_id_center);

// Split left top/bottom
ImGuiID dock_id_left_top, dock_id_left_bottom;
ImGui::DockBuilderSplitNode(dock_id_left, ImGuiDir_Up, 0.5f, &dock_id_left_top, &dock_id_left_bottom);

// Split center top/bottom
ImGuiID dock_id_center_top, dock_id_center_bottom;
ImGui::DockBuilderSplitNode(dock_id_center, ImGuiDir_Down, 0.5f, &dock_id_center_bottom, &dock_id_center_top);

ImGui::DockBuilderDockWindow("Scene", dock_id_left_top);
ImGui::DockBuilderDockWindow("Hierarchy", dock_id_left_bottom);
ImGui::DockBuilderDockWindow("Game", dock_id_center_top);
ImGui::DockBuilderDockWindow("Project", dock_id_center_bottom);
ImGui::DockBuilderDockWindow("Console", dock_id_center_bottom);
ImGui::DockBuilderDockWindow("Inspector", dock_id_right);

ImGui::DockBuilderFinish(dockspace_id);
}

Saving and Loading Layouts

Of course, once the user customizes a layout, they expect it to be the same the next time they open the editor. ImGui already has built-in INI file saving, so we just need to wrap it:

1
2
3
4
5
6
7
8
9
10
11
12
13
void Editor::SaveCurrentLayout()
{
LayoutManager::GetInstance().SaveLayout(layoutNameBuffer);
}

void Editor::LoadLayout(const std::string& layoutName)
{
if (LayoutManager::GetInstance().LoadLayout(layoutName))
{
ImGui::DockContextClearNodes(GImGui, 0, true);
dockingInitialized = false; // Rebuild layout
}
}

The layout manager is essentially a wrapper around ImGui’s INI file, saved to the Settings directory — each layout is a separate .ini file.

Project Management

img

Introduction

An engine has to support multiple project switching — you can’t just load scenes from a fixed path every time. So we need a project management system that can create, open, delete, and rename projects. The project structure follows the Unity convention: each project is a folder containing Assets, Scenes, Scripts, and other subfolders, plus a project.json config file.

Project Selector

When the editor launches, it first shows the project selector, listing all available projects:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
void Editor::DrawProjectSelector()
{
ImGui::SetNextWindowPos(ImVec2(0, 0), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(ImGui::GetIO().DisplaySize.x, ImGui::GetIO().DisplaySize.y));

ImGui::Begin("ProjectSelector", nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse);

float windowWidth = ImGui::GetIO().DisplaySize.x;
float windowHeight = ImGui::GetIO().DisplaySize.y;

ImGui::SetCursorPosX((windowWidth - 200) * 0.5f);
ImGui::SetCursorPosY(windowHeight * 0.15f);
ImGui::SetWindowFontScale(2.0f);
ImGui::Text("Ditto Engine");
ImGui::SetWindowFontScale(1.0f);

ProjectManager& pm = ProjectManager::GetInstance();
auto projects = pm.GetAllProjects();

float listWidth = glm::clamp(windowWidth * 0.4f, 300.0f, 600.0f);
float listHeight = windowHeight * 0.4f;

ImGui::SetCursorPosX((windowWidth - listWidth) * 0.5f);
ImGui::SetCursorPosY(windowHeight * 0.3f);
ImGui::BeginChild("ProjectList", ImVec2(listWidth, listHeight), true);

static int selectedProject = -1;
for (int i = 0; i < projects.size(); i++)
{
if (ImGui::Selectable(projects[i].name.c_str(), selectedProject == i))
selectedProject = i;
}

ImGui::EndChild();

// Create, Delete, Rename, Open buttons
float buttonWidth = listWidth / 4 - 10;
float buttonsStartX = (windowWidth - listWidth) * 0.5f;

ImGui::SetCursorPosX(buttonsStartX);
ImGui::SetCursorPosY(windowHeight * 0.75f);
if (ImGui::Button("Create", ImVec2(buttonWidth, 35)))
showNewProjectPopup = true;

// ... other buttons

ImGui::End();
}

Project Structure

Each project’s directory structure looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
MyProject/
├── Assets/
│ ├── Materials/
│ ├── Models/
│ ├── Prefabs/
│ ├── Scenes/
│ ├── Scripts/
│ ├── Shaders/
│ └── Textures/
├── Temp/ # Temporary files (compiled DLLs, etc.)
├── Build/ # Build output
└── project.json # Project configuration

project.json records the project name, engine version, and last opened scene:

1
2
3
4
5
6
{
"name": "MyProject",
"version": "1.0",
"engineVersion": "1.0",
"lastScene": "Assets/Scenes/Default.bin"
}

File Icons

Introduction

The Project window full of plain filenames looks pretty dry. Adding file icons makes it much easier to scan. Different file types (.cs, .scene, .mat, .shader, etc.) get different icons, and folders get their own dedicated icon.

Loading Icons

Icon PNGs for each file type live in Assets/Icon, and are loaded to the GPU at startup:

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
void Editor::InitFileIcons()
{
if (m_fileIconsInitialized) return;

m_assetsPath = FindEditorAssetsPath(); // Locate the Assets directory

// Load file type icons
m_icons[0] = LoadIcon(m_assetsPath + "/Icon/Default.png");
m_icons[1] = LoadIcon(m_assetsPath + "/Icon/CsScript.png");
m_icons[2] = LoadIcon(m_assetsPath + "/Icon/Model.png");
m_icons[3] = LoadIcon(m_assetsPath + "/Icon/Prefab.png");
m_icons[4] = LoadIcon(m_assetsPath + "/Icon/Shader.png");
m_icons[5] = LoadIcon(m_assetsPath + "/Icon/Scene.png");
m_icons[6] = LoadIcon(m_assetsPath + "/Icon/Texture2D.png");
m_icons[7] = LoadIcon(m_assetsPath + "/Icon/Material.png");

// Load folder icons
m_folderIcon = LoadIcon(m_assetsPath + "/Icon/Folder.png");
m_folderEmptyIcon = LoadIcon(m_assetsPath + "/Icon/FolderEmpty.png");
m_folderOpenedIcon = LoadIcon(m_assetsPath + "/Icon/FolderOpened.png");

// ... other icons

m_fileIconsInitialized = true;
}

LoadIcon uses the engine’s renderer to load a texture, returns a TextureHandle, and finally converts it to an ImGui texture ID:

1
2
3
4
5
6
7
8
9
10
11
Ditto::TextureHandle Editor::LoadIcon(const std::string& iconPath)
{
if (!engine || !engine->renderer) return {};
return engine->renderer->LoadTexture(iconPath.c_str());
}

void* Editor::IconTexID(Ditto::TextureHandle h)
{
if (!engine || !engine->renderer) return nullptr;
return engine->renderer->GetImGuiTextureID(h);
}

Using Icons

When drawing files in the Project window, show the appropriate icon based on the file extension:

1
2
3
4
5
6
7
8
9
10
11
12
void* icon = nullptr;
if (entry.is_directory())
icon = GetFolderIcon();
else
icon = GetIconByExtension(entry.path().extension().string());

if (icon)
{
ImGui::Image(icon, ImVec2(64, 64));
ImGui::SameLine();
}
ImGui::Text("%s", entry.path().filename().string().c_str());

Undo/Redo

Introduction

An editor without Ctrl+Z/Ctrl+Y is basically unusable. There are many ways to implement undo/redo; I went with the Memento pattern — snapshot the entire scene before each operation, and restore the snapshot on undo. It uses more memory, but it’s simple to implement, and the scene serialization was already there anyway.

Snapshot Structure

Each snapshot stores the scene data, the path of the selected object, expanded object paths, and the index of the selected component:

1
2
3
4
5
6
7
8
struct EditorSnapshot
{
std::string sceneData; // Scene binary data
bool hasSelectedObject = false;
std::vector<int> selectedObjectPath; // Path of selected object in the tree
int selectedComponentOrdinal = -1; // Index of the selected component
std::vector<std::vector<int>> expandedObjectPaths; // Expanded object paths
};

Capturing a Snapshot

When capturing a snapshot, serialize the scene to a string and record the editor state at the same time:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
Editor::EditorSnapshot Editor::CaptureEditorSnapshot() const
{
EditorSnapshot snapshot;
if (!engine || !engine->scene) return snapshot;

snapshot.sceneData = engine->scene->CaptureSnapshot();

GameObject* root = engine->scene->rootGameObject.get();
GameObject* current = selectedObject ? selectedObject : activeSelection;

// Build the path of the selected object (sequence of child indices from root to the object)
auto buildPath = [root](GameObject* object) {
std::vector<int> path;
for (GameObject* node = object; node && node != root; node = node->parent)
{
auto& siblings = node->parent->children;
auto it = std::find_if(siblings.begin(), siblings.end(),
[node](const std::unique_ptr<GameObject>& child) { return child.get() == node; });
if (it == siblings.end()) break;
path.push_back(static_cast<int>(std::distance(siblings.begin(), it)));
}
std::reverse(path.begin(), path.end());
return path;
};

if (root && current)
{
snapshot.hasSelectedObject = true;
snapshot.selectedObjectPath = buildPath(current);
}

// Record expanded objects
for (GameObject* expanded : m_expandedGameObjects)
snapshot.expandedObjectPaths.push_back(buildPath(expanded));

// Record the selected component
if (selectedComponent && selectedComponent->gameObject == current)
{
const auto& components = current->components;
for (size_t i = 0; i < components.size(); ++i)
{
if (components[i].get() == selectedComponent)
{
snapshot.selectedComponentOrdinal = static_cast<int>(i);
break;
}
}
}

return snapshot;
}

Pushing to the Stack

Discrete operations (create object, delete object, etc.) call PushUndoSnapshot directly:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void Editor::PushUndoSnapshot()
{
if (!engine || !engine->scene) return;
if (engine->state == Engine::State::Play) return; // Don't record in Play mode

EditorSnapshot snap = CaptureEditorSnapshot();
if (snap.sceneData.empty()) return;

// Deduplicate
if (!m_undoStack.empty() && m_undoStack.back().sceneData == snap.sceneData)
return;

m_undoStack.push_back(std::move(snap));
if (m_undoStack.size() > kUndoDepth)
m_undoStack.erase(m_undoStack.begin());

m_redoStack.clear(); // A new operation clears the redo stack
}

Continuous operations (like dragging a Transform) are wrapped with BeginInspectorEdit/EndInspectorEdit, so the snapshot is only committed once at the end:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void Editor::BeginInspectorEdit()
{
if (!m_hasPendingEdit)
{
m_pendingPreEdit = CaptureEditorSnapshot();
m_hasPendingEdit = true;
}
}

void Editor::EndInspectorEdit()
{
if (!m_hasPendingEdit) return;
m_hasPendingEdit = false;

// Only commit if the value actually changed
if (engine->scene->CaptureSnapshot() != m_pendingPreEdit.sceneData)
{
m_undoStack.push_back(std::move(m_pendingPreEdit));
if (m_undoStack.size() > kUndoDepth)
m_undoStack.erase(m_undoStack.begin());
m_redoStack.clear();
}
}

Undo and Redo

Undo is just popping the undo stack, restoring the scene, and pushing the current state to the redo stack:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void Editor::Undo()
{
if (!engine || !engine->scene) return;
if (m_undoStack.empty()) return;

EditorSnapshot current = CaptureEditorSnapshot();
EditorSnapshot prev = std::move(m_undoStack.back());
m_undoStack.pop_back();

if (engine->scene->RestoreSnapshot(prev.sceneData))
{
m_redoStack.push_back(std::move(current));
if (m_redoStack.size() > kUndoDepth)
m_redoStack.erase(m_redoStack.begin());
RestoreEditorSelection(prev);
sceneDirty = true;
}
else
{
m_undoStack.push_back(std::move(prev)); // Put it back if restore failed
}
}

Build System

img

Introduction

Once the engine is done, it has to be able to package and ship, right? The build system handles packaging the project into an executable: compiling scripts, copying assets, copying engine DLLs, etc. For now only Windows is supported, with other platforms to be added later.

Build Settings

The Build Settings window lets the user configure build parameters:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct BuildSettings
{
BuildPlatform platform = BuildPlatform::Windows;
BuildConfiguration configuration = BuildConfiguration::Release;
std::string productName;
std::string companyName = "Ditto";
std::string version = "1.0.0";
std::vector<std::string> scenes;
std::string startupScene;
std::string outputPath;
bool developmentBuild = false;
bool enableScriptDebugging = false;
};

The user can pick which scenes to include, set the startup scene, choose Debug/Release configuration, and so on.

Build Flow

Clicking the Build button kicks off the build:

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
bool BuildSystem::Build(const BuildSettings& settings, ProgressCallback progress)
{
progress("Preparing build...", 0.0f);

// 1. Create output directory
fs::create_directories(settings.outputPath);

// 2. Compile all C# scripts into GameScripts.dll
progress("Compiling scripts...", 0.2f);
if (!CompileAllScripts(projectPath, settings.outputPath))
return false;

// 3. Copy asset files
progress("Copying assets...", 0.5f);
CopyDirectory(projectPath + "/Assets", settings.outputPath);

// 4. Copy engine runtime DLLs
progress("Copying engine files...", 0.8f);
CopyEngineRuntime(settings.outputPath);

// 5. Generate launch configuration
progress("Generating config...", 0.9f);
GenerateLaunchConfig(settings);

progress("Build complete!", 1.0f);
return true;
}

Final output directory structure:

1
2
3
4
5
6
Build/Windows/
├── Ditto.exe # Engine executable
├── GameScripts.dll # Compiled game scripts
├── Assets/ # Project assets (scenes, materials, models, etc.)
├── 3rdParty/ # Third-party DLLs (Mono runtime, etc.)
└── launch.json # Launch config (product name, startup scene, etc.)

Other Improvements

Play/Pause/Stop

The toolbar’s Play/Pause/Stop buttons now have state-aware colors:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Play button: blue when in Play or Pause state
const bool playOn = (engine->state == Engine::Play || engine->state == Engine::Pause);
if (playOn)
ImGui::PushStyleColor(ImGuiCol_Button, blue);
else
ImGui::PushStyleColor(ImGuiCol_Button, grey);

if (ImGui::ImageButton("##PlayBtn", GetPlayIcon(), ImVec2(20, 20)))
{
if (engine->state == Engine::Edit)
{
// Snapshot before entering Play
m_playModeEntrySnapshot = CaptureEditorSnapshot();
engine->SetEngineState(Engine::Play);
}
else
{
// Clicking Play while in Play exits Play (i.e., Stop)
engine->SetEngineState(Engine::Stop);
StopAndRestoreScene();
}
}

When exiting Play, the pre-Play scene snapshot is restored, so changes made in Play mode don’t pollute the Edit mode scene.

Scene Dirty Marker

After modifying the scene, append a star to the scene name to indicate unsaved changes:

1
2
3
std::string displayName = obj->name;
if (isRoot && sceneDirty)
displayName += " *";

Ctrl+S saves the scene and clears the dirty flag.

Console Window

Display Debug.Log output from scripts in the Console window for easier debugging:

1
2
3
4
5
6
7
void CSharpScriptSystem::LogToConsole(const std::string& message)
{
Ditto::Logger::Get().Info(message);

if (s_editor)
static_cast<Editor*>(s_editor)->AddConsoleMessage(message);
}

The Console window shares a dock node with the Project window, displayed as two tabs.

Closing Notes

At this point, the editor’s interface is in pretty good shape. Dockable windows, project management, undo/redo, build packaging — everything you’d expect. It’s still a long way from commercial engines, but it’s plenty for a personal project. Next up is fleshing out the scripting system to make the engine actually programmable.