Introduction

Previous articles covered the engine’s initial architecture, basic rendering, physics simulation, and parallelization. I was planning to wrap up and focus on my thesis and job hunting, but one day I stumbled upon Krystallos Engine next door. Something just clicked—I couldn’t sleep, hacked away for a week, and here we are with this major overhaul.

Project Management

Written Before

The engine now supports multiple projects, so the first step when opening the engine is to select a project. The project management interface displays at startup, allowing you to create new projects or open existing ones.

img

Startup

At startup, check whether a project exists. If not, show the project selector:

1
2
3
4
5
Editor::Editor(void* window, bool gameMode, const std::string& projectPath)
{
showProjectSelector = !projectLoaded;
if (showProjectSelector) DrawProjectSelector();
}

Project Selector

The project selector displays all available projects and supports create, open, and rename operations:

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
void Editor::DrawProjectSelector()
{
ImGui::SetNextWindowPos(ImVec2(0, 0), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2((float)viewportSize.x, (float)viewportSize.y));

ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBackground;
ImGui::Begin("ProjectSelector", &open, flags);

// Left side: project list
ImGui::BeginChild("ProjectList", ImVec2(300, 0));
ImGui::Text("My Projects");
ImGui::Separator();

// Iterate through all projects in the Projects directory
std::vector<std::string> projects = GetAllProjects();
for (const auto& project : projects)
{
if (ImGui::Selectable(project.name.c_str(), selected == project.name))
{
selected = project.name;
OpenProject(project.path);
}
}

if (ImGui::Button("New Project", ImVec2(-1, 0)))
showNewProjectPopup = true;

ImGui::EndChild();

// Right side: project preview
ImGui::SameLine();
ImGui::BeginChild("ProjectPreview");
ImGui::Text("Select a project to open");
ImGui::EndChild();

ImGui::End();
}

Written Behind

Project management involves project path resolution, JSON read/write, scene auto-loading, and othermiscellaneous tasks. Thankfully, it all works now.

Docking System

Written Before

After selecting a project, you enter the main interface. The previous interface used the most primitive “manual region division” approach: Hierarchy takes left 1/8, Scene takes middle 1/2, Inspector takes right 1/4. While functional, extensibility was terrible—you couldn’t freely drag, resize, or create multi-view layouts like Unity. Fortunately, ImGui itself supports Docking, so here’s the new system.

img

Enable Docking

Enable Docking when initializing ImGui, along with Viewport for multi-window support:

1
2
3
4
5
6
// Enable Docking
io.ConfigFlags |= ImGuiConfigFlags_DockingEnable;

// Enable fullscreen transparent background - works with Docking system
io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable;
ImGui::GetStyle().Colors[ImGuiCol_DockingEmptyBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.0f);

Setup DockSpace

The core of the docking system is creating a fullscreen DockSpace where all sub-windows can dock:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void Editor::SetupDocking()
{
ImGuiWindowClass window_class;
window_class.DockNodeFlagsOverrideSet = ImGuiDockNodeFlags_NoTabBar;

ImGui::SetNextWindowClass(&window_class);
ImGuiWindowFlags window_flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse |
ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus |
ImGuiWindowFlags_NoNavFocus | ImGuiWindowFlags_NoBackground;

ImGui::Begin("DockSpace", &open, window_flags);

// Get unique DockSpace ID
ImGuiID dockspace_id = ImGui::GetID("MainDockSpace");
dockSpaceID = dockspace_id;

// Create DockSpace
ImGui::DockSpace(dockspace_id, ImVec2(0.0f, 0.0f), ImGuiDockNodeFlags_PassthruCentralNode);

ImGui::End();
}

Initialize Layout

On first run, manually split window regions. Code uses a classic five-way layout: left top/bottom (Hierarchy + Project), center top/bottom (Scene + Game), right (Inspector):

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
if (!dockingInitialized)
{
// Check if INI file needs corresponding (then construct Dock)
LayoutManager& lm = LayoutManager::GetInstance();
if (lm.GetNeedsReloadDock())
{
ImGui::DockBuilderRemoveNode(dockspace_id);
ImGuiDockNode* node = ImGui::DockBuilderAddNode(dockspace_id, ImGuiDockNodeFlags_PassthruCentralNode);
ImGui::DockBuilderSetNodeSize(dockspace_id, ImGui::GetMainViewport()->Size);

// Rebuild DockSpace - allows free resizing
ImGuiID dock_id_left, dock_id_right, dock_id_center;
ImGui::DockBuilderSplitNode(dockspace_id, ImGuiDir_Left, 0.2f, &dock_id_left, &dock_id_center);
ImGui::DockBuilderSplitNode(dockspace_id, ImGuiDir_Right, 0.25f, &dock_id_right, &dock_id_center);

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

// Split center into top/bottom (Scene and Game)
ImGuiID dock_id_center_top, dock_id_center_bottom;
ImGui::DockBuilderSplitNode(dock_id_center, ImGuiDir_Down, 0.6f, &dock_id_center_top, &dock_id_center_bottom);

// Assign windows to Dock nodes
ImGui::DockBuilderDockWindow("Scene", dock_id_center_top);
ImGui::DockBuilderDockWindow("Hierarchy", dock_id_left_top);
ImGui::DockBuilderDockWindow("Game", dock_id_center_bottom);
ImGui::DockBuilderDockWindow("Project", dock_id_left_bottom);
ImGui::DockBuilderDockWindow("Inspector", dock_id_right);

ImGui::DockBuilderFinish(dockspace_id);
}

dockingInitialized = true;
}

Layout Management

Engine supports saving and loading UI layouts using ImGui’s built-in INI mechanism:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void Editor::DrawLayoutMenu()
{
if (ImGui::BeginMenu("Layout"))
{
if (ImGui::MenuItem("Save Layout..."))
showSaveLayoutPopup = true;

if (ImGui::BeginMenu("Load Layout"))
{
std::vector<std::string> layouts = GetSavedLayouts();
for (const auto& layoutName : layouts)
{
if (ImGui::MenuItem(layoutName.c_str()))
LoadLayout(layoutName);
}
ImGui::EndMenu();
}
ImGui::EndMenu();
}
}

Written Behind

The docking system elevated the engine’s UI from “stone age” to a modern editor standard. You can now freely drag windows, resize them, and save layouts. The tradeoff is that ImGui’s INI file becomes important—once corrupted, the interface becomes… memorable.

Scene Interaction

Written Before

The Scene view was view-only before. Now it finally has interactive features: axis indicator, transform tools (Gizmo), and camera controls.

img

Axis Indicator

Top-right corner displays a small axis indicator showing real-time camera orientation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void SceneWindow::DrawAxisGizmo()
{
// Draw in top-right corner
ImVec2 center(windowPos.x + windowSize.x - gizmoSize * 0.5f - padding, ...);

// Get camera basis vectors
glm::vec3 camRight = camera->right;
glm::vec3 camUp = camera->up;
glm::vec3 camForward = camera->forward;

// Project world axes to view space
// Sort by depth, draw back first
drawList->AddLine(center, endPoint, axis.color, lineThickness);
}

Similar to Unity’s top-right axis indicator: red=X, green=Y, blue=Z.

Transform Tools

Press W/E/R to switch between three transform modes:

Key Mode Color
W Translate Red X, Green Y, Blue Z
E Rotate Red X, Green Y, Blue Z
R Scale Red X, Green Y, Blue Z

Gizmo Rendering

Each tool has its own Gizmo drawing:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void SceneWindow::DrawTranslateGizmo()
{
// Draw three axes + arrows
drawList->AddLine(center, xEnd, xCol, 3.0f);
drawList->AddTriangleFilled(...); // Arrow
}

void SceneWindow::DrawRotateGizmo()
{
// Draw three rings (only back half based on camera)
for (int i = 0; i <= segments; i++) {
// YZ plane (X axis), XZ plane (Y axis), XY plane (Z axis)
}
}

void SceneWindow::DrawScaleGizmo()
{
// Draw three axes + end boxes
drawList->AddRectFilled(...);
}

Mouse Interaction

  • Left-drag Gizmo: Transform selected object
  • Right-drag: Rotate camera view
  • Arrow keys: Move camera forward/backward/left/right
  • Page Up/Down: Move camera up/down
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void SceneWindow::HandleMouseInput()
{
// Raycast to detect selected axis
m_highlightedAxis = RaycastGizmos(mousePos);

// Calculate mouse movement projected to screen space
float projectedDelta = mouseDelta.x * screenAxisDir.x + mouseDelta.y * screenAxisDir.y;

// Apply transform based on current tool mode
switch (m_toolMode) {
case ToolMode::Translate:
transform->position = newPos;
break;
case ToolMode::Rotate:
transform->rotation = newRot;
break;
case ToolMode::Scale:
transform->scale = newScale;
break;
}
}

Written Behind

Finally, you can happily drag objects around in the Scene view. While the Gizmo raycasting and coordinate projection still have room for optimization, it works. Next steps: grid plane snapping and multi-select batch transforms—those are for later.

File Browser

Written Before

After selecting a project, you see the Project window. The previous Project window was basically just a texture viewer with no folder navigation. This time, it’s a complete file browser.

img

Basic Structure

The file browser maintains current path, expanded folder list, file icon mappings, and other states:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ProjectWindow
{
private:
Editor* m_editor = nullptr;
std::string m_currentFolder = "Assets/Scenes";
float m_splitterPos = 150.0f;

// Folder expansion state
std::set<std::string> m_expandedFolders;

// Single/double click detection
std::string m_lastClickedFilePath;
double m_lastClickTime = 0.0;
static constexpr double DOUBLE_CLICK_THRESHOLD = 0.5;
};

Draw Flow

Uses left-right split layout: left shows folder tree, right shows file list:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void ProjectWindow::Draw()
{
if (ImGui::BeginTabBar("ProjectTabs"))
{
if (ImGui::BeginTabItem("Assets"))
{
DrawAssetBrowser();
ImGui::EndTabItem();
}
if (ImGui::BeginTabItem("Console"))
{
DrawConsole();
ImGui::EndTabItem();
}
ImGui::EndTabBar();
}

DrawPopups(); // Draw various popups
}

Folder Tree Display

Left side shows tree structure with expand/collapse:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void ProjectWindow::DrawFolderTree(const std::string& folderPath, int depth)
{
std::string displayName = GetFileName(folderPath);
ImGui::Indent(depth * 20.0f);

if (HasSubfolders(folderPath))
{
if (ImGui::TreeNodeEx(displayName.c_str(), ImGuiTreeNodeFlags_SpanFullWidth))
{
if (ImGui::IsItemClicked())
ToggleFolderExpanded(folderPath);
DrawFolderContent(folderPath, depth + 1);
ImGui::TreePop();
}
}
else
{
ImGui::Selectable(displayName.c_str(), false, ImGuiSelectableFlags_SpanAllColumns);
}

ImGui::Unindent(depth * 20.0f);
}

File List Display

Right side shows all files in current folder with icons based on extension, supports single/double click:

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
void ProjectWindow::DrawFileGrid()
{
for (const auto& file : files)
{
unsigned int icon = m_editor->GetIconByExtension(file.extension);

if (ImGui::Selectable(file.name.c_str(), false,
ImGuiSelectableFlags_AllowDoubleClick | ImGuiSelectableFlags_SpanAllColumns))
{
double currentTime = ImGui::GetTime();
if (file.path == m_lastClickedFilePath &&
(currentTime - m_lastClickTime) < DOUBLE_CLICK_THRESHOLD)
{
// Double-click handling
OnFileDoubleClicked(file);
}
else
{
// Single-click handling
OnFileSelected(file.path, file.name, file.extension, GetRelativePath(file.path));
}
m_lastClickedFilePath = file.path;
m_lastClickTime = currentTime;
}
}
}

Context Menu & Drag-Drop

Context menu supports creating scripts/folders/scenes, rename, delete, etc. For C# scripts, supports dragging to Inspector to auto-add components:

1
2
3
4
5
6
if (ImGui::BeginDragDropSource())
{
ImGui::SetDragDropPayload("CS_SCRIPT", file.path.c_str(), file.path.length());
ImGui::Text(file.name.c_str());
ImGui::EndDragDropTarget();
}

Script Creation

Context menu can create new C# scripts with template code:

1
2
3
4
5
6
7
8
9
10
11
12
void ProjectWindow::CreateNewScript(const std::string& name)
{
std::string filePath = assetsPath + "/Scripts/" + name + ".cs";
std::ofstream file(filePath);
file << "using DittoEngine;\n";
file << "public class " << name << " : MonoBehaviour\n";
file << "{\n";
file << " public float speed = 5.0f;\n";
file << " void Start() { Debug.Log(\"" << name << ": Start\"); }\n";
file << " void Update() { }\n";
file << "}\n";
}

Written Behind

The Project window now feels like a real modern editor’s file browser. But the code also really ballooned—from a hundred lines to nearly a thousand.

Inspector

Written Before

After clicking an object in Scene view, Inspector displays all component properties. This time added a useful feature: 3D model preview.

img

Rendering Target

Model preview needs an independent Framebuffer at 256x256 resolution:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void InspectorWindow::InitModelPreview()
{
glGenFramebuffers(1, &m_previewFBO);
glBindFramebuffer(GL_FRAMEBUFFER, m_previewFBO);

glGenTextures(1, &m_previewTexture);
glBindTexture(GL_TEXTURE_2D, m_previewTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, m_previewWidth, m_previewHeight, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, m_previewTexture, 0);

glGenRenderbuffers(1, &m_previewRBO);
glBindRenderbuffer(GL_RENDERBUFFER, m_previewRBO);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, m_previewWidth, m_previewHeight);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, m_previewRBO);

glBindFramebuffer(GL_FRAMEBUFFER, 0);
}

Preview Rendering

Render independent mini-scene in Inspector’s Model Preview area:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void InspectorWindow::RenderModelPreview()
{
glViewport(0, 0, m_previewWidth, m_previewHeight);
glBindFramebuffer(GL_FRAMEBUFFER, m_previewFBO);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

// Orbiting camera around model
float time = (float)glfwGetTime();
vec3 cameraPos = vec3(sin(time) * 3.0f, 2.0f, cos(time) * 3.0f);
mat4 view = lookAt(cameraPos, vec3(0.0f), vec3(0.0f, 1.0f, 0.0f));
mat4 projection = perspective(radians(45.0f), 1.0f, 0.1f, 100.0f);

// Render model
glUseProgram(m_previewProgram);
glUniformMatrix4fv(glGetUniformLocation(m_previewProgram, "view"), 1, GL_FALSE, &view[0][0]);
glUniformMatrix4fv(glGetUniformLocation(m_previewProgram, "projection"), 1, GL_FALSE, &projection[0][0]);

glBindVertexArray(m_currentPreviewModel.VAO);
glDrawElements(GL_TRIANGLES, m_currentPreviewModel.indexCount, GL_UNSIGNED_INT, 0);

glBindFramebuffer(GL_FRAMEBUFFER, 0);
}

Display in Inspector

Finally, render this texture at the bottom of Inspector:

1
2
3
4
5
6
7
8
9
10
11
12
13
void InspectorWindow::DrawModelPreview(const std::string& modelPath)
{
if (!m_previewInitialized)
InitModelPreview();

if (!m_modelInitialized || m_currentPreviewPath != modelPath)
LoadPreviewModel(modelPath);

RenderModelPreview();

ImGui::Text("Model Preview");
ImGui::Image((ImTextureID)(intptr_t)m_previewTexture, ImVec2(256, 256));
}

Written Behind

3D model preview massively improves the experience—at least you know what the model looks like without jumping to the Scene view.

Scripting System

Written Before

Tried C++ scripting before, but manually registering all variables and functions was too tedious. DLL approach like UE requires compiling before use, which isn’t great either. Finally went with C# like Unity.

img

C# Base Library

Engine provides a base C# library DittoEngine.dll, similar to Unity’s UnityEngine:

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
namespace DittoEngine
{
// Transform component
public class Transform
{
public Vector3 position { get; set; }
public Vector3 rotation;
public Vector3 scale;
}

// Vector3
public struct Vector3 { public float x, y, z; }

// MonoBehaviour base class
public class MonoBehaviour
{
public GameObject gameObject;
public Transform transform => gameObject?.transform;
public virtual void Start() { }
public virtual void Update() { }
public virtual void OnDestroy() { }
}

public class GameObject
{
public string name;
public Transform transform;
}
}

Field Parsing

Public variables in C# scripts need parsing on the C++ side for display and editing in Inspector:

1
2
3
4
5
6
7
void CSharpScriptComponent::ParseScriptFields()
{
std::regex floatRegex("public\\s+float\\s+(\\w+)\\s*=\\s*([0-9.-]+f?)\\s*;");
std::regex intRegex("public\\s+int\\s+(\\w+)\\s*=\\s*(-?[0-9]+)\\s*;");
std::regex vec3Regex("public\\s+Vector3\\s+(\\w+)\\s*=\\s*new\\s+Vector3\\s*\\(([0-9.-]+f?)\\s*,\\s*([0-9.-]+f?)\\s*,\\s*([0-9.-]+f?)\\)");
// Supports float, int, bool, string, Vector2, Vector3, Vector4
}

Dynamic Compilation

Dynamically compile C# to DLL at runtime:

1
2
3
4
5
6
7
8
9
10
11
bool CSharpScriptSystem::LoadScript(const std::string& csPath, CSharpScriptComponent* component)
{
// Compile C# to DLL
std::string cmd = "csc /target:library /reference:\"DittoEngine.dll\" "
"/out:\"" + dllPath + "\" \"" + csPath + "\"";
int result = system(cmd.c_str());

// Parse public variables
component->ParseScriptFields();
return true;
}

Inspector Display

Display script’s public fields in Inspector with real-time editing:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void CSharpScriptComponent::OnInspectorGUI()
{
ImGui::Checkbox("##Enabled", &enabled);
ImGui::SameLine();
ImGui::TextUnformatted(scriptName.c_str());

for (auto& field : fields)
{
switch (field.type)
{
case ScriptFieldType::Float:
ImGui::DragFloat(field.name.c_str(), &std::get<float>(field.value));
break;
case ScriptFieldType::Vector3:
ImGui::DragFloat3(field.name.c_str(), &std::get<glm::vec3>(field.value).x);
break;
}
}
}

Written Behind

The scripting system can now run. By integrating .NET runtime, C# script Start/Update lifecycle methods can truly integrate with C++, achieving runtime script invocation like Unity. More details in a future article.

Summary

The engine UI has been completely transformed. Project management, docking system, file browser, model preview, scripting system, scene interaction—these are standard features in commercial engines, but getting them working in a personal engine took serious effort. Time to focus on thesis and job hunting. If I keep polishing, I’ll never graduate, never find a job… might as well live under a bridge (