开篇

之前文章中讲述了引擎的初始架构和基础渲染、物理模拟与并行化,本想告一段落专心论文春招的,但某天刷到了隔壁的Krystallos Engine。就是一种很奇妙的心态,觉也睡不下去了,肝了一周有了这次大改。

上一篇已经把引擎的基本框架搭起来了,也有了一个能跑的Editor界面。但那个界面还是太简陋了——固定大小的窗口、缺少拖拽功能、没有项目管理。作为一个现代化引擎编辑器,这些基础功能还是得有的。这一篇就来完善下Editor的整体界面,让它看起来更像那么回事。

img

窗口停泊

前言

之前的界面都是写死位置和大小的,用起来很不灵活。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;

// 左边30%
ImGui::DockBuilderSplitNode(dockspace_id, ImGuiDir_Left, 0.3f, &dock_id_left, &dock_id_center);
// 右边30%
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文件。

项目管理

img

前言

一个引擎肯定要支持多项目切换的,不能每次都从固定路径加载场景。所以需要一个项目管理系统,能创建、打开、删除、重命名项目。项目结构采用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();

// Create, Delete, Rename, Open 按钮
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(); // 找到Assets目录

// 加载文件类型图标
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;

// 构建选中对象的路径(从root到当前对象的子索引序列)
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; // Play模式不记录

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(); // 新操作会清空redo栈
}

连续操作(拖拽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)); // 恢复失败就放回去
}
}

构建系统

img

前言

引擎做完了总得能打包发布吧。构建系统负责把项目打包成可执行文件,包括编译脚本、复制资源、拷贝引擎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);

// 1. 创建输出目录
fs::create_directories(settings.outputPath);

// 2. 编译所有C#脚本为GameScripts.dll
progress("编译脚本...", 0.2f);
if (!CompileAllScripts(projectPath, settings.outputPath))
return false;

// 3. 复制资源文件
progress("复制资源...", 0.5f);
CopyDirectory(projectPath + "/Assets", settings.outputPath);

// 4. 复制引擎运行时DLL
progress("复制引擎文件...", 0.8f);
CopyEngineRuntime(settings.outputPath);

// 5. 生成启动配置
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
// Play按钮:Play或Pause时显示为蓝色
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)
{
// 进入Play前保存快照
m_playModeEntrySnapshot = CaptureEditorSnapshot();
engine->SetEngineState(Engine::Play);
}
else
{
// 点击Play时退出Play(相当于Stop)
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的界面已经比较完善了。窗口可以自由停靠、项目管理、撤销重做、构建打包,该有的都有了。虽然和商业引擎比还差得远,但对个人项目来说够用了。下一步就该完善脚本系统,让引擎真正可编程起来。