开篇

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

项目管理

前言

引擎现在支持多项目,所以打开引擎的第一步就是选择项目。项目管理界面在启动时显示,可以新建或打开已有项目。

img

启动界面

启动时先判断是否存在项目,不存在就显示项目选择器:

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

项目选择器

项目选择器展示所有可用项目,支持新建、打开、重命名操作:

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);

// 左侧:项目列表
ImGui::BeginChild("ProjectList", ImVec2(300, 0));
ImGui::Text("My Projects");
ImGui::Separator();

// 遍历 Projects 目录下的所有项目
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();

// 右侧:项目预览
ImGui::SameLine();
ImGui::BeginChild("ProjectPreview");
ImGui::Text("Select a project to open");
ImGui::EndChild();

ImGui::End();
}

后记

项目管理涉及项目路径解析、Json读写、场景自动加载等杂事,好在最后总算是能跑起来了。

停泊系统

前言

选完项目后进入主界面。之前的界面用的是最原始的”手动分区域”方案:Hierarchy 占左侧 1/8,Scene 占中间 1/2,Inspector 占右侧 1/4。虽然能用,但扩展性极差——没法随意拖拽、调整大小,更没法像 Unity 那样搞多视图布局。恰好 ImGui 本身就支持 Docking(停泊系统),于是便有了这套新方案。

img

启用 Docking

在初始化 ImGui 时需要开启 Docking 功能,同时开启 Viewport 以支持多窗口:

1
2
3
4
5
6
// 启用 Docking 功能
io.ConfigFlags |= ImGuiConfigFlags_DockingEnable;

// 启用全屏透明背景 - 配合 Docking 系统
io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable;
ImGui::GetStyle().Colors[ImGuiCol_DockingEmptyBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.0f);

搭建 DockSpace

停泊系统的核心是创建一个全屏的 DockSpace,所有子窗口都可以停泊在其中:

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);

// 获取唯一的 DockSpace ID
ImGuiID dockspace_id = ImGui::GetID("MainDockSpace");
dockSpaceID = dockspace_id;

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

ImGui::End();
}

初始化布局

第一次运行时需要手动划分窗口区域。代码中采用了经典的五分布局:左侧上下(Hierarchy + Project)、中间上下(Scene + Game)、右侧(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)
{
// 检查 INI 文件是否需要对应(之后再构建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);

// 重建 DockSpace - 使用户可以自由调整大小
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);

// 左侧再分为上下
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);

// 中间再分为上下(Scene 和 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);

// 分配窗口到各个 Dock 节点
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 管理

引擎支持保存和加载界面布局,使用 ImGui 内置的 INI 保存机制:

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();
}
}

后记

停泊系统让引擎的界面从”原始社会”进阶到了现代编辑器的及格线。现在你可以随意拖拽窗口、调整大小、保存布局。不过代价是 ImGui 的 INI 文件开始变得重要起来——一旦写崩,界面就会变得很酸爽。

文件浏览器

前言

选完项目后,你会看到 Project 窗口。之前的 Project 窗口基本上只是个贴图显示器,连基本的文件夹导航都没有。这次彻底重写,做成了一个完整的文件浏览器。

img

基础结构

文件浏览器需要维护当前路径、展开的文件夹列表、文件图标映射等状态:

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;

// 文件夹展开状态
std::set<std::string> m_expandedFolders;

// 单击/双击判断
std::string m_lastClickedFilePath;
double m_lastClickTime = 0.0;
static constexpr double DOUBLE_CLICK_THRESHOLD = 0.5;
};

绘制流程

整体采用左右分栏布局:左侧显示文件夹树状结构,右侧显示文件内容列表:

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(); // 绘制各种弹窗
}

文件夹树状显示

左侧显示树状文件夹结构,支持展开/收起操作:

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);
}

文件列表显示

右侧显示当前文件夹下的所有文件,按照扩展名显示对应的图标,支持单击双击:

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)
{
// 双击处理
OnFileDoubleClicked(file);
}
else
{
// 单击处理
OnFileSelected(file.path, file.name, file.extension, GetRelativePath(file.path));
}
m_lastClickedFilePath = file.path;
m_lastClickTime = currentTime;
}
}
}

右键菜单与拖拽

右键菜单支持创建脚本/文件夹/场景、重命名、删除等操作。对于 C# 脚本,还支持拖拽到 Inspector 窗口自动添加组件:

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();
}

脚本创建

右键菜单可以创建新的 C# 脚本,自动生成模板代码:

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";
}

后记

现在的 Project 窗口才算真正像个现代编辑器的文件浏览器了。不过代码量也是真的涨了不少,从一百多行直接干到了近千行。

属性界面

前言

点击 Scene 视图中的物体后,Inspector 会显示该物体的所有组件属性。这次加入了一个实用的功能:3D 模型预览。

渲染目标

模型预览需要创建一个独立的 Framebuffer,分辨率设为 256x256:

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);
}

预览渲染

在 Inspector 的 Model Preview 区域渲染独立的小场景:

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);

// 环绕模型旋转的相机
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);

// 渲染模型
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);
}

在属性界面中显示

最后在 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));
}

后记

3D 模型预览极大提升了使用体验——至少你知道这个模型到底长什么样,不用再跑到 Scene 视图里看了。

脚本系统

前言

之前尝试过 C++ 脚本,但发现需要手动注册所有变量和函数,太麻烦。如果走 DLL 方案又和 UE 一样需要编译后才能用,体验不太好。最终决定像 Unity 一样走 C# 路线。

C# 基础库

引擎提供了一个基础 C# 库 DittoEngine.dll,类似于 Unity 的 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 组件
public class Transform
{
public Vector3 position { get; set; }
public Vector3 rotation;
public Vector3 scale;
}

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

// MonoBehaviour 基类
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;
}
}

字段解析

C# 脚本中的 public 变量需要在 C++ 端解析,以便在 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?)\\)");
// 支持 float、int、bool、string、Vector2、Vector3、Vector4
}

动态编译

点击运行时动态编译 C# 为 DLL:

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

// 解析 public 变量
component->ParseScriptFields();
return true;
}

Inspector 显示

在 Inspector 中显示脚本的 public 字段,并支持实时编辑:

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;
}
}
}

后记

脚本系统目前只支持解析 public 字段,C# 端的 Start/Update 还没有真正和 C++ 打通。要真正实现像 Unity 那样运行时的脚本调用,还需要集成 Mono 虚拟机或者 Dotnet 运行时,暂列 Todo 里吧。

小结

至此,引擎界面算是彻底改头换面了。项目管理、停泊系统、文件浏览器、模型预览、脚本系统——这些在商业引擎里稀松平常的功能,放到个人引擎里也算是下了功夫。接下来熬过论文春招就继续打磨吧,再干下去毕业也别想毕了,工作也别想找了……直接桥洞底下盖小被算了(