开篇
之前文章中讲述了引擎的初始架构和基础渲染、物理模拟与并行化,本想告一段落专心论文春招的,但某天刷到了隔壁的Krystallos Engine。就是一种很奇妙的心态,觉也睡不下去了,肝了一周有了这次大改。
上一篇已经把引擎的基本框架搭起来了,也有了一个能跑的Editor界面。但那个界面还是太简陋了——固定大小的窗口、缺少拖拽功能、没有项目管理。作为一个现代化引擎编辑器,这些基础功能还是得有的。这一篇就来完善下Editor的整体界面,让它看起来更像那么回事。

窗口停泊
前言
之前的界面都是写死位置和大小的,用起来很不灵活。Unity、UE这些商业引擎都支持自由拖拽窗口、停靠布局,这才是现代编辑器该有的样子。好在ImGui的Docking分支已经提供了这个功能,我们直接用就行。
实现Docking
首先在ImGui初始化时要开启Docking支持:
1 2
| ImGuiIO& io = ImGui::GetIO(); io.ConfigFlags |= ImGuiConfigFlags_DockingEnable;
|
然后创建一个全屏的DockSpace作为停靠区域:
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); }
|
默认布局
第一次启动时需要设置一个默认布局,不然窗口都堆在一起。我的布局是左边30%放Hierarchy和Scene,中间40%放Game和Project,右边30%放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
| if (!dockingInitialized) { dockingInitialized = true; ImGui::DockBuilderRemoveNode(dockspace_id); ImGui::DockBuilderAddNode(dockspace_id, ImGuiDockNodeFlags_None); ImGuiID dock_id_left, dock_id_right, dock_id_center; ImGui::DockBuilderSplitNode(dockspace_id, ImGuiDir_Left, 0.3f, &dock_id_left, &dock_id_center); ImGui::DockBuilderSplitNode(dock_id_center, ImGuiDir_Right, 0.42f, &dock_id_right, &dock_id_center); 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); 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); }
|
布局保存与加载
用户自定义了布局后当然希望下次打开还是这个样子。ImGui本身就有INI文件保存机制,我们只需要封装一下:
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; } }
|
布局管理器本质上就是管理ImGui的INI文件,保存到Settings目录下,这样每个布局都是一个.ini文件。
项目管理

前言
一个引擎肯定要支持多项目切换的,不能每次都从固定路径加载场景。所以需要一个项目管理系统,能创建、打开、删除、重命名项目。项目结构采用Unity那套:每个项目是一个文件夹,里面有Assets、Scenes、Scripts等子文件夹,还有一个project.json配置文件。
项目选择器
启动编辑器时首先显示项目选择器,列出所有项目:
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();
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; ImGui::End(); }
|
项目结构
每个项目的目录结构如下:
1 2 3 4 5 6 7 8 9 10 11 12
| MyProject/ ├── Assets/ │ ├── Materials/ │ ├── Models/ │ ├── Prefabs/ │ ├── Scenes/ │ ├── Scripts/ │ ├── Shaders/ │ └── Textures/ ├── Temp/ # 临时文件(编译的DLL等) ├── Build/ # 构建输出 └── project.json # 项目配置
|
project.json记录项目名称、引擎版本、上次打开的场景等信息:
1 2 3 4 5 6
| { "name": "MyProject", "version": "1.0", "engineVersion": "1.0", "lastScene": "Assets/Scenes/Default.bin" }
|
文件图标
前言
项目窗口里全是文件名,看起来很干涩。加上文件图标后识别度会高很多。不同类型的文件(.cs、.scene、.mat、.shader等)显示不同的图标,文件夹也有专门的图标。
图标加载
在Assets/Icon目录下放了各种文件类型的图标PNG,启动时加载到GPU:
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(); 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"); m_folderIcon = LoadIcon(m_assetsPath + "/Icon/Folder.png"); m_folderEmptyIcon = LoadIcon(m_assetsPath + "/Icon/FolderEmpty.png"); m_folderOpenedIcon = LoadIcon(m_assetsPath + "/Icon/FolderOpened.png"); m_fileIconsInitialized = true; }
|
LoadIcon函数通过引擎的Renderer加载纹理,返回一个TextureHandle,最后转换成ImGui的纹理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); }
|
图标使用
在Project窗口绘制文件时,根据扩展名显示对应图标:
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());
|
撤销重做
前言
编辑器没有Ctrl+Z/Ctrl+Y简直不能忍。实现撤销重做有很多方案,我用的是Memento模式——每次操作前保存整个场景的快照,撤销时直接恢复快照。虽然内存占用大一些,但实现简单,而且场景序列化本来就有了。
快照结构
每个快照保存场景数据、选中对象路径、展开的对象路径、选中的组件序号:
1 2 3 4 5 6 7 8
| struct EditorSnapshot { std::string sceneData; bool hasSelectedObject = false; std::vector<int> selectedObjectPath; int selectedComponentOrdinal = -1; std::vector<std::vector<int>> expandedObjectPaths; };
|
捕获快照
捕获快照时,把场景序列化成字符串,同时记录编辑器状态:
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; 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); }
for (GameObject* expanded : m_expandedGameObjects) snapshot.expandedObjectPaths.push_back(buildPath(expanded));
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; }
|
推入栈
离散操作(创建对象、删除对象等)直接调用PushUndoSnapshot:
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; EditorSnapshot snap = CaptureEditorSnapshot(); if (snap.sceneData.empty()) return; 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(); }
|
连续操作(拖拽Transform等)用BeginInspectorEdit/EndInspectorEdit包起来,只在最后提交一次:
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; 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栈,恢复场景,把当前状态推入redo栈:
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)); } }
|
构建系统

前言
引擎做完了总得能打包发布吧。构建系统负责把项目打包成可执行文件,包括编译脚本、复制资源、拷贝引擎DLL等。目前只支持Windows平台,后续可以扩展到其他平台。
构建设置
Build Settings窗口让用户配置构建参数:
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; };
|
用户可以选择要打包哪些场景、设置启动场景、选择Debug/Release配置等。
构建流程
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("正在准备构建...", 0.0f); fs::create_directories(settings.outputPath); progress("编译脚本...", 0.2f); if (!CompileAllScripts(projectPath, settings.outputPath)) return false; progress("复制资源...", 0.5f); CopyDirectory(projectPath + "/Assets", settings.outputPath); progress("复制引擎文件...", 0.8f); CopyEngineRuntime(settings.outputPath); progress("生成配置...", 0.9f); GenerateLaunchConfig(settings); progress("构建完成!", 1.0f); return true; }
|
最终输出目录结构:
1 2 3 4 5 6
| Build/Windows/ ├── Ditto.exe # 引擎可执行文件 ├── GameScripts.dll # 编译的游戏脚本 ├── Assets/ # 项目资源(场景、材质、模型等) ├── 3rdParty/ # 第三方DLL(Mono运行时等) └── launch.json # 启动配置(产品名、启动场景等)
|
其他改进
Play/Pause/Stop
工具栏的Play/Pause/Stop按钮加了状态显示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| 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) { m_playModeEntrySnapshot = CaptureEditorSnapshot(); engine->SetEngineState(Engine::Play); } else { engine->SetEngineState(Engine::Stop); StopAndRestoreScene(); } }
|
退出Play时恢复进入前的场景快照,这样在Play模式的修改不会影响Edit模式的场景。
场景脏标记
修改场景后在场景名后显示星号提示未保存:
1 2 3
| std::string displayName = obj->name; if (isRoot && sceneDirty) displayName += " *";
|
Ctrl+S保存场景后清除脏标记。
Console窗口
把脚本的Debug.Log输出显示在Console窗口,方便调试:
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); }
|
Console窗口和Project窗口共用一个Dock节点,显示为两个标签页。
后记
到这里,Editor的界面已经比较完善了。窗口可以自由停靠、项目管理、撤销重做、构建打包,该有的都有了。虽然和商业引擎比还差得远,但对个人项目来说够用了。下一步就该完善脚本系统,让引擎真正可编程起来。