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 材质和橙红色基础色,然后加入 ScenarioDefault 渲染队列。没有可识别网格的对象不会丢失,而是保存在 m_nonRenderableObjects 中,使 Camera 等实体仍然可以出现在 Hierarchy 和 Inspector 中。

这个细节很重要:编辑器世界中的实体不等于渲染对象集合。相机、光源、空节点和逻辑实体都应当存在于场景中,即使它们不提交任何 Draw Call。

6.1 基础网格创建

Hierarchy 的右键菜单提供 Cube、Sphere、Plane 和 Capsule。创建请求到达主窗口后,会:

  1. 生成 UUID、默认 Transform 和 MeshRenderer JSON;
  2. 追加到 m_scenem_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.transformhorse.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 游戏引擎从零开始的研发过程,欢迎交流。

相关推荐
vivo互联网技术3 天前
扩散模型自引导新范式 SSG:直接交换Token就能变强! | CVPR 2026 Oral
图像识别·计算机图形学
小孔龙3 天前
Android 图形系统全景
android·计算机图形学
一枚懒人6 天前
OpenGL 视频渲染导论:从图形管线到视频处理全览
程序员·opengl
shawn032611 天前
《疯狂动物城 2》背后的 Hybrid BVH:迪士尼如何让复杂场景跑进交互式 GPU 光追
前端·计算机图形学
郝学胜-神的一滴12 天前
中级OpenGL教程 026:Assimp库从编译到实战全攻略
c++·unity·游戏引擎·图形渲染·unreal engine·opengl
北域码匠15 天前
【超详细】贝塞尔曲线算法完全解析(纯C#原生实现、无第三方库、原理+代码+性能全剖析)
计算机图形学·贝塞尔曲线·c# 图形算法·德卡斯特里奥算法·纯 c# 无第三方库·动画轨迹算法·矢量绘图底层原理
郝学胜_神的一滴17 天前
中级OpenGL教程 024:Assimp模型加载之Mesh解码玄功
计算机图形学·opengl
郝学胜-神的一滴18 天前
中级OpenGL教程 023:Assimp模型加载全解——从源码到架构的骈文探秘
c++·unity·游戏引擎·cmake·unreal engine·opengl
郝学胜-神的一滴19 天前
中级OpenGL教程 022:探秘三维世界的血脉传承——物体父子关系与矩阵递归奥义
c++·线性代数·unity·矩阵·游戏引擎·unreal engine·opengl