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.
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:
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%:
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:
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
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:
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:
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
structEditorSnapshot { 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:
voidEditor::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:
voidEditor::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:
voidEditor::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
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:
// Play button: blue when in Play or Pause state constbool 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:
Display Debug.Log output from scripts in the Console window for easier debugging:
1 2 3 4 5 6 7
voidCSharpScriptSystem::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.