目标:拆解 Horse3D 的 Ferghana 编辑器原型,梳理项目创建、场景加载、对象检查、场景保存与渲染线程之间的完整链路,并分析这套架构已经建立的边界以及目前仍需统一的场景格式契约。
Bilibili 同步视频
一、从渲染器走向编辑器
前四篇笔记主要讨论渲染线程、材质、多 Pass 和光照。这些能力解决了"如何把一帧画出来",但游戏引擎还需要回答另一组问题:
- 项目如何创建、识别和再次打开?
- 场景如何持久化为可读、可版本化的数据?
- 数据如何实例化为运行时对象?
- 用户在 Inspector 中修改属性后,如何同步回场景文件和渲染画面?
- 编辑器如何复用渲染核心,而不把 GUI 逻辑塞进渲染库?
Ferghana 正是 Horse3D 对这些问题的第一轮回答。它不是一个单独的窗口类,而是由两个部分组成:
- AkhalTeke:项目管理器,负责创建工程、维护最近项目、生成 CMake 工程和初始场景;
- Ferghana Editor:可复用的静态库,负责编辑器主窗口、Hierarchy、Inspector 和实时渲染视口。
底层仍由 Dragon、Balikun 等模块提供渲染和组件能力。Ferghana 做的事情,是把这些底层能力组织为一条面向创作者的工作流。
二、整体架构
图 1:Ferghana 的模块关系。 AkhalTeke 生成一个链接
Ferghana::Editor的独立工程;工程启动后,编辑器读取场景 JSON,将实体转换为 Dragon 的运行时对象,再交给独立渲染线程绘制。
这里最重要的设计并不是某个具体控件,而是 编辑器以库的形式被用户工程链接。项目模板不会复制整套编辑器代码,只生成一个很薄的入口:
cpp
#include <FerghanaApplication.h>
int main(int argc, char *argv[])
{
return Ferghana::runEditor(argc, argv, "项目根目录");
}
这让每个项目拥有自己的可执行目标和调试入口,同时共享 Horse3D 源码中的编辑器实现。
三、AkhalTeke:把"新建项目"变成可重复过程
3.1 项目目录
SetupWidget::createProjectFiles() 创建如下结构:
text
MyGame/
├── Assets/
├── Plugins/
├── Scenes/
│ └── Main.scene
├── Source/
│ └── main.cpp
├── CMakeLists.txt
└── Ferghana.json
其中 Ferghana.json 是项目清单,记录格式版本、项目名、目标名和启动场景:
json
{
"format": "horse.project",
"version": 3,
"name": "MyGame",
"type": "executable",
"executable": {
"target": "MyGame"
},
"startupScene": "Scenes/Main.scene"
}
项目管理器通过这个文件判断一个目录是否为 Ferghana 工程。最近项目列表和上次使用目录则存入 QSettings,因此它们属于本机编辑器状态,不会污染项目仓库。
3.2 模板渲染
AkhalTeke 没有在 C++ 中硬编码整份工程文件,而是读取 Templates/Project 下的 .in 模板,再替换项目名、源码根目录和项目路径等占位符。
生成后的 CMake 工程会做三件事:
- 关闭 Horse3D 自带应用、可选模块和示例,避免递归构建无关目标;
- 通过
add_subdirectory引入 Horse3D 源码; - 链接
Ferghana::Editor,并调用ferghana_configure_executable()复制材质与公共资源。
cmake
add_subdirectory(
"${HORSE3D_SOURCE_ROOT}"
"${CMAKE_BINARY_DIR}/Horse3D"
EXCLUDE_FROM_ALL
)
add_executable(${PROJECT_NAME} Source/main.cpp)
target_link_libraries(${PROJECT_NAME} PRIVATE Ferghana::Editor)
ferghana_configure_executable(${PROJECT_NAME})
这种方式很适合引擎早期开发:用户工程总是使用当前工作区里的最新引擎源码,断点也可以直接进入编辑器和渲染核心。代价是项目依赖源码树的绝对路径,暂时还不是可独立分发的 SDK 模式。
3.3 打开工程
目前 AkhalTeke 的"打开"更接近开发工作流入口:Windows 下通过 vswhere.exe 定位最新 Visual Studio,然后将项目目录交给 devenv.exe;找不到 Visual Studio 时则退化为用资源管理器打开目录。
也就是说,AkhalTeke 当前负责的是 项目生成与 IDE 交接,并不直接启动项目编辑器。编译并运行用户工程后,才会进入 Ferghana。
四、编辑器启动:先约束 OpenGL,再创建窗口
用户工程最终调用 Ferghana::runEditor()。启动顺序如下:
Qt::AA_ShareOpenGLContexts 必须在 QApplication 创建前设置。原因是 Horse3D 使用两个 OpenGL 上下文:GUI 线程中的 QOpenGLWidget 负责显示,渲染线程中的共享 Context 负责真正的场景绘制。若上下文不共享,GUI 线程就无法采样渲染线程生成的纹理。
启动函数还支持 --project / -p 参数覆盖模板写入的默认项目路径,为以后从项目管理器或命令行直接启动编辑器保留了入口。
五、主窗口:三个视图,一份场景状态
FerghanaEditor 继承 QMainWindow,将界面拆成三个区域:
| 区域 | 类 | 职责 |
|---|---|---|
| 中央视口 | SkyViewport |
将场景 JSON 实例化,并显示实时渲染结果 |
| 左侧层级 | HierarchyDock |
展示实体列表、选择实体、创建基础网格 |
| 右侧检查器 | InspectorDock |
展示对象身份和组件生成的属性编辑器 |
主窗口内部同时保存两份相关状态:
m_scene/m_entities:可序列化的 JSON 数据;SkyViewport中的Object3D:供编辑和渲染使用的运行时对象。
它们之间通过实体数组下标建立对应关系。Hierarchy 的每个列表项还保存一个非持有的 IObject*,选中列表项时就能将对应对象交给 Inspector。
这套映射简单直接,适合原型;但当实体支持删除、拖拽排序和异步加载后,数组下标就不够稳定。场景已经为每个实体保存 UUID,后续更合理的做法是以 UUID 作为编辑器、序列化层和运行时世界之间的统一主键。
六、场景加载:从 JSON 到 Object3D
FerghanaEditor::loadStartupScene() 当前固定读取项目下的 Scenes/Main.scene,取得 entities 数组后交给 SkyViewport::setSceneEntities()。
视口通过 createScenario() 完成实例化:
text
JSON Entity
├── Transform ───────> QMatrix4x4 / Object3D::Transform
├── Camera ──────────> Scenario 初始相机与 cameraEntity
├── DirectionalLight -> 非渲染实体(当前原型)
└── MeshRenderer
├── Cube ─────> BoxGeometry
├── Sphere ───> SphereGeometry
├── Plane ────> PlaneGeometry
└── Capsule ──> CapsuleGeometry
具备 MeshRenderer 的实体会获得一份 Phong 材质和橙红色基础色,然后加入 Scenario 的 Default 渲染队列。没有可识别网格的对象不会丢失,而是保存在 m_nonRenderableObjects 中,使 Camera 等实体仍然可以出现在 Hierarchy 和 Inspector 中。
这个细节很重要:编辑器世界中的实体不等于渲染对象集合。相机、光源、空节点和逻辑实体都应当存在于场景中,即使它们不提交任何 Draw Call。
6.1 基础网格创建
Hierarchy 的右键菜单提供 Cube、Sphere、Plane 和 Capsule。创建请求到达主窗口后,会:
- 生成 UUID、默认 Transform 和 MeshRenderer JSON;
- 追加到
m_scene与m_entities; - 用
QSaveFile原子写回场景; - 重新构建
Scenario、Hierarchy 与对象映射; - 自动选中新实体。
当前策略是每次创建实体都重建整个运行时场景。它避免了局部更新时的所有权和线程同步问题,非常适合先验证数据闭环;场景规模增大后,再演进为命令队列或增量 World 更新会更稳妥。
七、Inspector:让组件自己提供编辑界面
Horse3D 的组件基类 Balikun 定义了一个很小的接口:
cpp
class Balikun
{
public:
virtual QWidget *editor() = 0;
virtual ~Balikun();
};
InspectorDock 不需要知道 Transform、Geometry 或 Material 的具体布局。它遍历 object->components(),调用每个组件的 editor(),再把返回的 QWidget 依次加入滚动区域。
这种方案的优点是扩展成本低:新增组件时,只要组件提供编辑器,Inspector 就能展示它。它也有明显边界:组件层直接依赖 QWidget,使运行时数据与桌面编辑器 UI 耦合。未来如果要支持无界面服务器、脚本反射、撤销重做或多种前端,更通用的路线是提供属性元数据,由 Inspector 根据类型生成控件。
7.1 修改如何保存
当前 Inspector 会查找组件编辑器中的 QDoubleSpinBox,将 valueChanged 统一转发为 componentEdited。主窗口收到信号后:
- 从当前运行时对象读取 Transform;
- 将 position、Euler rotation、scale 转为 JSON;
- 更新实体和场景对象;
- 使用
QSaveFile提交到Main.scene。
QSaveFile 先写临时文件,成功后再替换目标文件。即使进程在写入途中异常退出,也比直接 QFile::Truncate 更不容易留下半份 JSON。
不过,目前只有 Transform 的修改被显式序列化,且 Inspector 只监听 QDoubleSpinBox。材质颜色、枚举、布尔值以及其他组件参数仍需要更完整的属性变更协议。
八、视口如何接入独立渲染线程
SkyViewport 继承 Dragon 的 IScreen。场景设置完成后,IScreen 创建渲染线程,并把 OpenGL Context 移交给该线程。运行时的数据流如下:
视口默认以约 16 ms 的定时器持续请求渲染。渲染线程在离屏表面上完成场景绘制,通过两个 FBO 乒乓交换,GUI 线程只读取已经完成的纹理并绘制全屏四边形。
这让编辑器 UI 不必承担复杂的场景渲染工作,也延续了笔记(一)建立的线程边界:
- GUI 线程负责窗口事件、属性编辑和最终合成;
- RenderThread 负责 GPU 资源初始化、场景绘制和 FBO 交换;
- 两者通过共享纹理和排队信号协作。
当 Inspector 修改相机实体的 Transform 时,渲染线程会在每帧从相机对象的模型矩阵同步 eye、forward 和 up,因此视图能够随编辑结果更新。
九、当前最关键的问题:场景 Schema 尚未统一
阅读当前代码时可以发现一个明确的格式断层。
AkhalTeke 生成的初始场景使用带命名空间的组件键:
json
{
"horse.transform": { "position": [0, 1.2, 3] },
"horse.camera": { "fieldOfView": 60, "primary": true }
}
而 SkyViewport 与编辑器保存逻辑读取的是另一套键:
json
{
"Transform": { "position": [0, 1.2, 3] },
"Camera": {},
"DirectionalLight": {}
}
另外,生成器的 rotation 默认是四元数 [x, y, z, w],视口却将它按三个元素的 Euler 角读取。这会导致新建工程的初始相机和光源无法被当前实例化器正确识别。
这不是简单改一个字符串就能彻底解决的问题,而是在提醒我们:场景文件已经成为多个模块之间的公共 API,应当拥有独立、稳定的 Schema。
建议下一步统一为带命名空间的组件 ID,并将迁移逻辑集中到场景加载层:
| 项目 | 建议 |
|---|---|
| 组件 ID | 使用 horse.transform、horse.camera 等稳定 ID,不以 C++ 类名作为磁盘格式 |
| 旋转格式 | 明确使用 quaternion 或 Euler,并在字段名或 Schema 中固定 |
| 版本迁移 | 根据 scene version 执行 v1 -> v2 升级,不让 Viewport 猜格式 |
| 校验 | 加载时报告缺失字段、未知组件和类型错误 |
| 启动场景 | 从 Ferghana.json.startupScene 读取,不再硬编码 Scenes/Main.scene |
| 往返测试 | 验证 load → save → load 后语义不变 |
十、设计取舍
| 设计点 | 当前方案 | 优点 | 后续演进 |
|---|---|---|---|
| 编辑器集成 | 用户工程链接 Ferghana::Editor |
调试链路短,可直接跟进引擎源码 | 安装式 SDK 或预编译包 |
| 场景存储 | 可读 JSON | 易调试、易版本控制 | Schema 校验、资源引用和版本迁移 |
| 实体映射 | JSON 数组下标对应对象指针 | 实现简单 | 以 UUID 建立 World 索引 |
| 新建实体 | 重建整个 Scenario | 避免增量同步复杂度 | 编辑命令队列、增量创建与销毁 |
| 属性 UI | 组件返回 QWidget | 组件接入快 | 反射元数据驱动 Inspector |
| 自动保存 | 属性变化立即 QSaveFile |
数据闭环直观、安全 | 脏标记、节流、撤销/重做、显式保存 |
| 项目打开 | 移交 Visual Studio | 适合源码阶段开发 | 项目管理器直接启动 Editor |
十一、当前成果
- AkhalTeke 能创建、识别和记录 Ferghana 项目,并由模板生成可编译的 CMake 工程。
- Ferghana Editor 已形成中央视口、Hierarchy 和 Inspector 的基础布局。
- 场景 JSON 能实例化基础网格、相机和非渲染实体。
- Hierarchy 能创建 Cube、Sphere、Plane 和 Capsule,并原子保存到场景文件。
- Inspector 能复用组件自带的编辑器,并把 Transform 修改写回 JSON。
- SkyViewport 已接入 Horse3D 的独立渲染线程和共享纹理显示链路。
- 编辑器以
Ferghana::Editor库输出,用户工程只需保留一个轻量启动入口。
十二、下一步
| 优先级 | 方向 | 目标 |
|---|---|---|
| P0 | 统一场景 Schema | 修复生成器与编辑器的组件键、rotation 格式不一致 |
| P0 | 场景 round-trip 测试 | 保证加载、编辑、保存后数据语义不丢失 |
| P1 | 属性系统 | 用元数据统一展示、修改、序列化和撤销重做 |
| P1 | UUID World 索引 | 支持稳定选择、删除、重排和父子层级 |
| P1 | 增量场景更新 | 避免每次创建实体都重启渲染场景 |
| P2 | 资源浏览器 | 管理模型、纹理、材质和场景资源引用 |
| P2 | 编辑器命令系统 | 支持 Undo / Redo、复制粘贴和批量操作 |
| P2 | 发布模型 | 将源码路径依赖演进为可安装、可版本化的 Horse3D SDK |
项目仓库
- Gitee :gitee.com/shendeyidi/...

本系列记录 Horse3D 游戏引擎从零开始的研发过程,欢迎交流。