Horse3D 游戏引擎研发笔记(五):Ferghana 编辑器——从场景 JSON 到可交互视口

目标:拆解 Horse3D 的 Ferghana 编辑器原型,梳理项目创建、场景加载、对象检查、场景保存与渲染线程之间的完整链路,并分析这套架构已经建立的边界以及目前仍需统一的场景格式契约。

Bilibili 同步视频

Horse3D 游戏引擎研发笔记(五):Ferghana 编辑器------从场景 JSON 到可交互视口

一、从渲染器走向编辑器

前四篇笔记主要讨论渲染线程、材质、多 Pass 和光照。这些能力解决了"如何把一帧画出来",但游戏引擎还需要回答另一组问题:

  1. 项目如何创建、识别和再次打开?
  2. 场景如何持久化为可读、可版本化的数据?
  3. 数据如何实例化为运行时对象?
  4. 用户在 Inspector 中修改属性后,如何同步回场景文件和渲染画面?
  5. 编辑器如何复用渲染核心,而不把 GUI 逻辑塞进渲染库?

Ferghana 正是 Horse3D 对这些问题的第一轮回答。它不是一个单独的窗口类,而是由两个部分组成:

  • AkhalTeke:项目管理器,负责创建工程、维护最近项目、生成 CMake 工程和初始场景;
  • Ferghana Editor:可复用的静态库,负责编辑器主窗口、Hierarchy、Inspector 和实时渲染视口。

底层仍由 Dragon、Balikun 等模块提供渲染和组件能力。Ferghana 做的事情,是把这些底层能力组织为一条面向创作者的工作流。

二、整体架构

flowchart LR subgraph ProjectManager["AkhalTeke 项目管理器"] UI["SetupWidget"] TEMPLATE["CMake / main.cpp 模板"] MANIFEST["Ferghana.json"] SCENE["Scenes/Main.scene"] end subgraph Project["用户工程"] EXE["工程可执行文件"] end subgraph Editor["Ferghana Editor"] APP["runEditor()"] MAIN["FerghanaEditor"] HIERARCHY["HierarchyDock"] INSPECTOR["InspectorDock"] VIEWPORT["SkyViewport"] end subgraph Engine["Horse3D 核心"] OBJECT["Object3D / Transform"] SCENARIO["Scenario"] SCREEN["IScreen"] THREAD["RenderThread"] end UI --> TEMPLATE --> EXE UI --> MANIFEST UI --> SCENE EXE --> APP --> MAIN SCENE --> MAIN MAIN --> HIERARCHY MAIN --> INSPECTOR MAIN --> VIEWPORT VIEWPORT --> OBJECT VIEWPORT --> SCENARIO --> SCREEN --> THREAD

图 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 工程会做三件事:

  1. 关闭 Horse3D 自带应用、可选模块和示例,避免递归构建无关目标;
  2. 通过 add_subdirectory 引入 Horse3D 源码;
  3. 链接 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()。启动顺序如下:

sequenceDiagram participant Main as 用户工程 main participant App as runEditor participant Qt as QApplication participant Editor as FerghanaEditor participant View as SkyViewport Main->>App: argc, argv, defaultProjectRoot App->>Qt: 启用共享 OpenGL Context App->>Qt: 设置 OpenGL 4.5 Core Profile App->>Qt: 创建 QApplication / 应用主题 App->>App: 解析 --project 参数 App->>Editor: 创建编辑器(projectRoot) Editor->>View: 创建视口与 Dock Editor->>Editor: loadStartupScene() Editor->>View: setSceneEntities(entities) App->>Qt: exec()

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。创建请求到达主窗口后,会:

  1. 生成 UUID、默认 Transform 和 MeshRenderer JSON;
  2. 追加到 m_scene 与 m_entities;
  3. 用 QSaveFile 原子写回场景;
  4. 重新构建 Scenario、Hierarchy 与对象映射;
  5. 自动选中新实体。

当前策略是每次创建实体都重建整个运行时场景。它避免了局部更新时的所有权和线程同步问题,非常适合先验证数据闭环;场景规模增大后,再演进为命令队列或增量 World 更新会更稳妥。

七、Inspector:让组件自己提供编辑界面

Horse3D 的组件基类 Balikun 定义了一个很小的接口:

cpp 复制代码
class Balikun
{
public:
    virtual QWidget *editor() = 0;
    virtual ~Balikun();
};

InspectorDock 不需要知道 Transform、Geometry 或 Material 的具体布局。它遍历 object->components(),调用每个组件的 editor(),再把返回的 QWidget 依次加入滚动区域。

flowchart LR SELECT[&#34;Hierarchy 选择实体&#34;] --> OBJECT[&#34;IObject&#34;] OBJECT --> COMPONENTS[&#34;components()&#34;] COMPONENTS --> TRANSFORM[&#34;Transform::editor()&#34;] COMPONENTS --> GEOMETRY[&#34;Geometry::editor()&#34;] COMPONENTS --> MATERIAL[&#34;Material::editor()&#34;] TRANSFORM --> INSPECTOR[&#34;InspectorDock&#34;] GEOMETRY --> INSPECTOR MATERIAL --> INSPECTOR

这种方案的优点是扩展成本低:新增组件时,只要组件提供编辑器,Inspector 就能展示它。它也有明显边界:组件层直接依赖 QWidget,使运行时数据与桌面编辑器 UI 耦合。未来如果要支持无界面服务器、脚本反射、撤销重做或多种前端,更通用的路线是提供属性元数据,由 Inspector 根据类型生成控件。

7.1 修改如何保存

当前 Inspector 会查找组件编辑器中的 QDoubleSpinBox,将 valueChanged 统一转发为 componentEdited。主窗口收到信号后:

  1. 从当前运行时对象读取 Transform;
  2. 将 position、Euler rotation、scale 转为 JSON;
  3. 更新实体和场景对象;
  4. 使用 QSaveFile 提交到 Main.scene。

QSaveFile 先写临时文件,成功后再替换目标文件。即使进程在写入途中异常退出,也比直接 QFile::Truncate 更不容易留下半份 JSON。

不过,目前只有 Transform 的修改被显式序列化,且 Inspector 只监听 QDoubleSpinBox。材质颜色、枚举、布尔值以及其他组件参数仍需要更完整的属性变更协议。

八、视口如何接入独立渲染线程

SkyViewport 继承 Dragon 的 IScreen。场景设置完成后,IScreen 创建渲染线程,并把 OpenGL Context 移交给该线程。运行时的数据流如下:

sequenceDiagram participant GUI as GUI 线程 / IScreen participant RT as RenderThread participant FBO as 双 FBO participant TEX as 共享纹理 GUI->>RT: renderNext() RT->>RT: Scenario::render() RT->>FBO: SwapFrameRenderPass RT->>TEX: 发布最新 textureId RT-->>GUI: frameReady() GUI->>TEX: 获取 currentFrame() GUI->>GUI: 全屏四边形采样纹理

视口默认以约 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

项目仓库


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

相关推荐
扶尔魔ocy2 天前
【qt openGL入门笔记】FPS相机
qt·opengl
扶尔魔ocy2 天前
qt openGL入门笔记】FPS相机和轨道相机
qt·opengl
扶尔魔ocy4 天前
【qt openGL入门笔记】基础光照
qt·opengl
扶尔魔ocy4 天前
【qt openGL入门笔记】坐标系统和摄像机
qt·opengl
茉莉玫瑰花茶7 天前
OpenGL [ 纹理 ]
开发语言·c++·opengl
EachYoungX11 天前
利用可控的风格迁移注入路径提供AI画面结构保持的思路
人工智能·计算机图形学
mmsx18 天前
Android 地图十万要素不卡顿:空间网格 + 渐进式加载的移动端实践
android·大数据·opengl
mmsx19 天前
Android 地图几万要素卡成幻灯片?顶点缓冲批上传与显存复用
android·opengl·地图·栅格
cAuth20 天前
实现一个图形编辑器
前端·架构·计算机图形学
h_a_o777oah25 天前
【Games101】C++ 软光追:光线追踪的求交逻辑和 BVH 提效实现及代码实现细节
c++·计算机图形学·光线追踪·games101·bvh·求交算法·软件渲染