开篇 之前文章中讲述了引擎的初始架构和基础渲染、物理模拟与并行化,本想告一段落专心论文春招的,但某天刷到了隔壁的Krystallos Engine。就是一种很奇妙的心态,觉也睡不下去了,肝了一周有了这次大改。
项目管理 前言 引擎现在支持多项目,所以打开引擎的第一步就是选择项目。项目管理界面在启动时显示,可以新建或打开已有项目。
启动界面 启动时先判断是否存在项目,不存在就显示项目选择器:
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(停泊系统),于是便有了这套新方案。
启用 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 窗口基本上只是个贴图显示器,连基本的文件夹导航都没有。这次彻底重写,做成了一个完整的文件浏览器。
基础结构 文件浏览器需要维护当前路径、展开的文件夹列表、文件图标映射等状态:
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 里吧。
小结 至此,引擎界面算是彻底改头换面了。项目管理、停泊系统、文件浏览器、模型预览、脚本系统——这些在商业引擎里稀松平常的功能,放到个人引擎里也算是下了功夫。接下来熬过论文春招就继续打磨吧,再干下去毕业也别想毕了,工作也别想找了……直接桥洞底下盖小被算了(