开篇

之前文章中讲述了引擎的初始架构和基础渲染、物理模拟与并行化,本想告一段落专心论文春招的,但某天刷到了隔壁的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 文件开始变得重要起来——一旦写崩,界面就会变得很酸爽。

场景交互

前言

之前的 Scene 视图只能看,这次终于加上了交互功能:坐标轴指示器、物体变换工具(Gizmo),以及相机控制。

img

坐标轴指示器

界面右上角添加了一个小的坐标轴指示器,实时显示相机朝向:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void SceneWindow::DrawAxisGizmo()
{
// 绘制在窗口右上角
ImVec2 center(windowPos.x + windowSize.x - gizmoSize * 0.5f - padding, ...);

// 获取相机基向量
glm::vec3 camRight = camera->right;
glm::vec3 camUp = camera->up;
glm::vec3 camForward = camera->forward;

// 将世界坐标轴投影到视图空间
// 按深度排序后绘制(远的先画)
drawList->AddLine(center, endPoint, axis.color, lineThickness);
}

效果类似 Unity 右上角的那个小坐标轴,红色=X,绿色=Y,蓝色=Z。

变换工具

按下 W/E/R 切换三种变换模式:

快捷键 模式 颜色
W Translate(平移) 红X、绿Y、蓝Z
E Rotate(旋转) 红X、绿Y、蓝Z
R Scale(缩放) 红X、绿Y、蓝Z

Gizmo 渲染

每种工具对应不同的 Gizmo 绘制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void SceneWindow::DrawTranslateGizmo()
{
// 绘制三根轴 + 箭头
drawList->AddLine(center, xEnd, xCol, 3.0f);
drawList->AddTriangleFilled(...); // 箭头
}

void SceneWindow::DrawRotateGizmo()
{
// 绘制三个圆环(根据相机朝向只绘制后半部分)
for (int i = 0; i <= segments; i++) {
// YZ平面(X轴)、XZ平面(Y轴)、XY平面(Z轴)
}
}

void SceneWindow::DrawScaleGizmo()
{
// 绘制三根轴 + 末端方块
drawList->AddRectFilled(...);
}

鼠标交互

  • 左键拖拽 Gizmo:变换选中的物体
  • 右键拖拽:旋转相机视角
  • 方向键:前后左右移动相机
  • Page 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()
{
// 射线检测选中的轴
m_highlightedAxis = RaycastGizmos(mousePos);

// 拖拽时计算鼠标位移在屏幕空间的投影
float projectedDelta = mouseDelta.x * screenAxisDir.x + mouseDelta.y * screenAxisDir.y;

// 根据当前工具模式应用变换
switch (m_toolMode) {
case ToolMode::Translate:
transform->position = newPos;
break;
case ToolMode::Rotate:
transform->rotation = newRot;
break;
case ToolMode::Scale:
transform->scale = newScale;
break;
}
}

后记

终于能在 Scene 视图里愉快地拖拽物体了。虽然 Gizmo 的射线检测和坐标投影还有优化空间,但至少能用了。下一步打算加上网格平面辅助,以及多选物体的批量变换——不过那是后话了。

文件浏览

前言

选完项目后,你会看到 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 模型预览。

img

渲染目标

模型预览需要创建一个独立的 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# 路线。

img

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

后记

脚本系统目前已经能跑,通过集成 .NET 运行时,C# 脚本的 Start/Update 生命周期方法可以真正和 C++ 打通,实现像 Unity 一样的运行时脚本调用。具体实现后续再单独写一篇展开讲讲

小结

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