Horse3D 游戏引擎研发笔记(五):Ferghana 编辑器——从场景 JSON 到可交互视口
- Bilibili 同步视频
- 一、从渲染器走向编辑器
- 二、整体架构
- 三、AkhalTeke:把“新建项目”变成可重复过程
-
- 3.1 项目目录
- 3.2 模板渲染
- 3.3 打开工程
- 四、编辑器启动:先约束 OpenGL,再创建窗口
- 五、主窗口:三个视图,一份场景状态
- 六、场景加载:从 JSON 到 Object3D
-
- 6.1 基础网格创建
- 七、Inspector:让组件自己提供编辑界面
-
- 7.1 修改如何保存
- 八、视口如何接入独立渲染线程
- 九、当前最关键的问题:场景 Schema 尚未统一
- 十、设计取舍
- 十一、当前成果
- 十二、下一步
- 项目仓库
目标:拆解 Horse3D 的 Ferghana 编辑器原型,梳理项目创建、场景加载、对象检查、场景保存与渲染线程之间的完整链路,并分析这套架构已经建立的边界以及目前仍需统一的场景格式契约。
Bilibili 同步视频
Horse3D 游戏引擎研发笔记(五):Ferghana 编辑器——从场景 JSON 到可交互视口
一、从渲染器走向编辑器
前四篇笔记主要讨论渲染线程、材质、多 Pass 和光照。这些能力解决了“如何把一帧画出来”,但游戏引擎还需要回答另一组问题:
Ferghana 正是 Horse3D 对这些问题的第一轮回答。它不是一个单独的窗口类,而是由两个部分组成:
- AkhalTeke:项目管理器,负责创建工程、维护最近项目、生成 CMake 工程和初始场景;
- Ferghana Editor:可复用的静态库,负责编辑器主窗口、Hierarchy、Inspector 和实时渲染视口。
底层仍由 Dragon、Balikun 等模块提供渲染和组件能力。Ferghana 做的事情,是把这些底层能力组织为一条面向创作者的工作流。
二、整体架构
#mermaid-svg-M6ljgmj3ULoBeHwH{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-M6ljgmj3ULoBeHwH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-M6ljgmj3ULoBeHwH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-M6ljgmj3ULoBeHwH .error-icon{fill:#552222;}#mermaid-svg-M6ljgmj3ULoBeHwH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-M6ljgmj3ULoBeHwH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-M6ljgmj3ULoBeHwH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-M6ljgmj3ULoBeHwH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-M6ljgmj3ULoBeHwH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-M6ljgmj3ULoBeHwH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-M6ljgmj3ULoBeHwH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-M6ljgmj3ULoBeHwH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-M6ljgmj3ULoBeHwH .marker.cross{stroke:#333333;}#mermaid-svg-M6ljgmj3ULoBeHwH svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-M6ljgmj3ULoBeHwH p{margin:0;}#mermaid-svg-M6ljgmj3ULoBeHwH .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-M6ljgmj3ULoBeHwH .cluster-label text{fill:#333;}#mermaid-svg-M6ljgmj3ULoBeHwH .cluster-label span{color:#333;}#mermaid-svg-M6ljgmj3ULoBeHwH .cluster-label span p{background-color:transparent;}#mermaid-svg-M6ljgmj3ULoBeHwH .label text,#mermaid-svg-M6ljgmj3ULoBeHwH span{fill:#333;color:#333;}#mermaid-svg-M6ljgmj3ULoBeHwH .node rect,#mermaid-svg-M6ljgmj3ULoBeHwH .node circle,#mermaid-svg-M6ljgmj3ULoBeHwH .node ellipse,#mermaid-svg-M6ljgmj3ULoBeHwH .node polygon,#mermaid-svg-M6ljgmj3ULoBeHwH .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-M6ljgmj3ULoBeHwH .rough-node .label text,#mermaid-svg-M6ljgmj3ULoBeHwH .node .label text,#mermaid-svg-M6ljgmj3ULoBeHwH .image-shape .label,#mermaid-svg-M6ljgmj3ULoBeHwH .icon-shape .label{text-anchor:middle;}#mermaid-svg-M6ljgmj3ULoBeHwH .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-M6ljgmj3ULoBeHwH .rough-node .label,#mermaid-svg-M6ljgmj3ULoBeHwH .node .label,#mermaid-svg-M6ljgmj3ULoBeHwH .image-shape .label,#mermaid-svg-M6ljgmj3ULoBeHwH .icon-shape .label{text-align:center;}#mermaid-svg-M6ljgmj3ULoBeHwH .node.clickable{cursor:pointer;}#mermaid-svg-M6ljgmj3ULoBeHwH .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-M6ljgmj3ULoBeHwH .arrowheadPath{fill:#333333;}#mermaid-svg-M6ljgmj3ULoBeHwH .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-M6ljgmj3ULoBeHwH .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-M6ljgmj3ULoBeHwH .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-M6ljgmj3ULoBeHwH .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-M6ljgmj3ULoBeHwH .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-M6ljgmj3ULoBeHwH .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-M6ljgmj3ULoBeHwH .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-M6ljgmj3ULoBeHwH .cluster text{fill:#333;}#mermaid-svg-M6ljgmj3ULoBeHwH .cluster span{color:#333;}#mermaid-svg-M6ljgmj3ULoBeHwH div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-M6ljgmj3ULoBeHwH .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-M6ljgmj3ULoBeHwH rect.text{fill:none;stroke-width:0;}#mermaid-svg-M6ljgmj3ULoBeHwH .icon-shape,#mermaid-svg-M6ljgmj3ULoBeHwH .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-M6ljgmj3ULoBeHwH .icon-shape p,#mermaid-svg-M6ljgmj3ULoBeHwH .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-M6ljgmj3ULoBeHwH .icon-shape .label rect,#mermaid-svg-M6ljgmj3ULoBeHwH .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-M6ljgmj3ULoBeHwH .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-M6ljgmj3ULoBeHwH .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-M6ljgmj3ULoBeHwH :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
Horse3D 核心
Ferghana Editor
用户工程
AkhalTeke 项目管理器
SetupWidget
CMake / main.cpp 模板
Ferghana.json
Scenes/Main.scene
工程可执行文件
runEditor()
FerghanaEditor
HierarchyDock
InspectorDock
SkyViewport
Object3D / Transform
Scenario
IScreen
RenderThread
图 1:Ferghana 的模块关系。 AkhalTeke 生成一个链接 Ferghana::Editor 的独立工程;工程启动后,编辑器读取场景 JSON,将实体转换为 Dragon 的运行时对象,再交给独立渲染线程绘制。
这里最重要的设计并不是某个具体控件,而是 编辑器以库的形式被用户工程链接。项目模板不会复制整套编辑器代码,只生成一个很薄的入口:
#include <FerghanaApplication.h>
int main(int argc, char *argv[])
{
return Ferghana::runEditor(argc, argv, "项目根目录");
}
这让每个项目拥有自己的可执行目标和调试入口,同时共享 Horse3D 源码中的编辑器实现。
三、AkhalTeke:把“新建项目”变成可重复过程
3.1 项目目录
SetupWidget::createProjectFiles() 创建如下结构:
MyGame/
├── Assets/
├── Plugins/
├── Scenes/
│ └── Main.scene
├── Source/
│ └── main.cpp
├── CMakeLists.txt
└── Ferghana.json
其中 Ferghana.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 工程会做三件事:
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()。启动顺序如下:
SkyViewport
FerghanaEditor
QApplication
runEditor
用户工程 main
SkyViewport
FerghanaEditor
QApplication
runEditor
用户工程 main
#mermaid-svg-kQoVaFSQpqdiu6fF{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kQoVaFSQpqdiu6fF .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kQoVaFSQpqdiu6fF .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kQoVaFSQpqdiu6fF .error-icon{fill:#552222;}#mermaid-svg-kQoVaFSQpqdiu6fF .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kQoVaFSQpqdiu6fF .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kQoVaFSQpqdiu6fF .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kQoVaFSQpqdiu6fF .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kQoVaFSQpqdiu6fF .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kQoVaFSQpqdiu6fF .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kQoVaFSQpqdiu6fF .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kQoVaFSQpqdiu6fF .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kQoVaFSQpqdiu6fF .marker.cross{stroke:#333333;}#mermaid-svg-kQoVaFSQpqdiu6fF svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kQoVaFSQpqdiu6fF p{margin:0;}#mermaid-svg-kQoVaFSQpqdiu6fF .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-kQoVaFSQpqdiu6fF text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-kQoVaFSQpqdiu6fF .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-kQoVaFSQpqdiu6fF .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-kQoVaFSQpqdiu6fF .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-kQoVaFSQpqdiu6fF .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-kQoVaFSQpqdiu6fF #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-kQoVaFSQpqdiu6fF .sequenceNumber{fill:white;}#mermaid-svg-kQoVaFSQpqdiu6fF #sequencenumber{fill:#333;}#mermaid-svg-kQoVaFSQpqdiu6fF #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-kQoVaFSQpqdiu6fF .messageText{fill:#333;stroke:none;}#mermaid-svg-kQoVaFSQpqdiu6fF .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-kQoVaFSQpqdiu6fF .labelText,#mermaid-svg-kQoVaFSQpqdiu6fF .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-kQoVaFSQpqdiu6fF .loopText,#mermaid-svg-kQoVaFSQpqdiu6fF .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-kQoVaFSQpqdiu6fF .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-kQoVaFSQpqdiu6fF .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-kQoVaFSQpqdiu6fF .noteText,#mermaid-svg-kQoVaFSQpqdiu6fF .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-kQoVaFSQpqdiu6fF .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-kQoVaFSQpqdiu6fF .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-kQoVaFSQpqdiu6fF .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-kQoVaFSQpqdiu6fF .actorPopupMenu{position:absolute;}#mermaid-svg-kQoVaFSQpqdiu6fF .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-kQoVaFSQpqdiu6fF .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-kQoVaFSQpqdiu6fF .actor-man circle,#mermaid-svg-kQoVaFSQpqdiu6fF line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-kQoVaFSQpqdiu6fF :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
argc, argv, defaultProjectRoot
启用共享 OpenGL Context
设置 OpenGL 4.5 Core Profile
创建 QApplication / 应用主题
解析 –project 参数
创建编辑器(projectRoot)
创建视口与 Dock
loadStartupScene()
setSceneEntities(entities)
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() 完成实例化:
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。创建请求到达主窗口后,会:
当前策略是每次创建实体都重建整个运行时场景。它避免了局部更新时的所有权和线程同步问题,非常适合先验证数据闭环;场景规模增大后,再演进为命令队列或增量 World 更新会更稳妥。
七、Inspector:让组件自己提供编辑界面
Horse3D 的组件基类 Balikun 定义了一个很小的接口:
class Balikun
{
public:
virtual QWidget *editor() = 0;
virtual ~Balikun();
};
InspectorDock 不需要知道 Transform、Geometry 或 Material 的具体布局。它遍历 object->components(),调用每个组件的 editor(),再把返回的 QWidget 依次加入滚动区域。
#mermaid-svg-WVIJqK1AiXvPEL9z{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WVIJqK1AiXvPEL9z .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WVIJqK1AiXvPEL9z .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WVIJqK1AiXvPEL9z .error-icon{fill:#552222;}#mermaid-svg-WVIJqK1AiXvPEL9z .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WVIJqK1AiXvPEL9z .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WVIJqK1AiXvPEL9z .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WVIJqK1AiXvPEL9z .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WVIJqK1AiXvPEL9z .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WVIJqK1AiXvPEL9z .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WVIJqK1AiXvPEL9z .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WVIJqK1AiXvPEL9z .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WVIJqK1AiXvPEL9z .marker.cross{stroke:#333333;}#mermaid-svg-WVIJqK1AiXvPEL9z svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WVIJqK1AiXvPEL9z p{margin:0;}#mermaid-svg-WVIJqK1AiXvPEL9z .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-WVIJqK1AiXvPEL9z .cluster-label text{fill:#333;}#mermaid-svg-WVIJqK1AiXvPEL9z .cluster-label span{color:#333;}#mermaid-svg-WVIJqK1AiXvPEL9z .cluster-label span p{background-color:transparent;}#mermaid-svg-WVIJqK1AiXvPEL9z .label text,#mermaid-svg-WVIJqK1AiXvPEL9z span{fill:#333;color:#333;}#mermaid-svg-WVIJqK1AiXvPEL9z .node rect,#mermaid-svg-WVIJqK1AiXvPEL9z .node circle,#mermaid-svg-WVIJqK1AiXvPEL9z .node ellipse,#mermaid-svg-WVIJqK1AiXvPEL9z .node polygon,#mermaid-svg-WVIJqK1AiXvPEL9z .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-WVIJqK1AiXvPEL9z .rough-node .label text,#mermaid-svg-WVIJqK1AiXvPEL9z .node .label text,#mermaid-svg-WVIJqK1AiXvPEL9z .image-shape .label,#mermaid-svg-WVIJqK1AiXvPEL9z .icon-shape .label{text-anchor:middle;}#mermaid-svg-WVIJqK1AiXvPEL9z .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-WVIJqK1AiXvPEL9z .rough-node .label,#mermaid-svg-WVIJqK1AiXvPEL9z .node .label,#mermaid-svg-WVIJqK1AiXvPEL9z .image-shape .label,#mermaid-svg-WVIJqK1AiXvPEL9z .icon-shape .label{text-align:center;}#mermaid-svg-WVIJqK1AiXvPEL9z .node.clickable{cursor:pointer;}#mermaid-svg-WVIJqK1AiXvPEL9z .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-WVIJqK1AiXvPEL9z .arrowheadPath{fill:#333333;}#mermaid-svg-WVIJqK1AiXvPEL9z .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-WVIJqK1AiXvPEL9z .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-WVIJqK1AiXvPEL9z .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WVIJqK1AiXvPEL9z .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-WVIJqK1AiXvPEL9z .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WVIJqK1AiXvPEL9z .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-WVIJqK1AiXvPEL9z .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-WVIJqK1AiXvPEL9z .cluster text{fill:#333;}#mermaid-svg-WVIJqK1AiXvPEL9z .cluster span{color:#333;}#mermaid-svg-WVIJqK1AiXvPEL9z div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-WVIJqK1AiXvPEL9z .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-WVIJqK1AiXvPEL9z rect.text{fill:none;stroke-width:0;}#mermaid-svg-WVIJqK1AiXvPEL9z .icon-shape,#mermaid-svg-WVIJqK1AiXvPEL9z .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WVIJqK1AiXvPEL9z .icon-shape p,#mermaid-svg-WVIJqK1AiXvPEL9z .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-WVIJqK1AiXvPEL9z .icon-shape .label rect,#mermaid-svg-WVIJqK1AiXvPEL9z .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WVIJqK1AiXvPEL9z .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-WVIJqK1AiXvPEL9z .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-WVIJqK1AiXvPEL9z :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
Hierarchy 选择实体
IObject
components()
Transform::editor()
Geometry::editor()
Material::editor()
InspectorDock
这种方案的优点是扩展成本低:新增组件时,只要组件提供编辑器,Inspector 就能展示它。它也有明显边界:组件层直接依赖 QWidget,使运行时数据与桌面编辑器 UI 耦合。未来如果要支持无界面服务器、脚本反射、撤销重做或多种前端,更通用的路线是提供属性元数据,由 Inspector 根据类型生成控件。
7.1 修改如何保存
当前 Inspector 会查找组件编辑器中的 QDoubleSpinBox,将 valueChanged 统一转发为 componentEdited。主窗口收到信号后:
QSaveFile 先写临时文件,成功后再替换目标文件。即使进程在写入途中异常退出,也比直接 QFile::Truncate 更不容易留下半份 JSON。
不过,目前只有 Transform 的修改被显式序列化,且 Inspector 只监听 QDoubleSpinBox。材质颜色、枚举、布尔值以及其他组件参数仍需要更完整的属性变更协议。
八、视口如何接入独立渲染线程
SkyViewport 继承 Dragon 的 IScreen。场景设置完成后,IScreen 创建渲染线程,并把 OpenGL Context 移交给该线程。运行时的数据流如下:
共享纹理
双 FBO
RenderThread
GUI 线程 / IScreen
共享纹理
双 FBO
RenderThread
GUI 线程 / IScreen
#mermaid-svg-U9fXrqWc3HLLANSU{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-U9fXrqWc3HLLANSU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-U9fXrqWc3HLLANSU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-U9fXrqWc3HLLANSU .error-icon{fill:#552222;}#mermaid-svg-U9fXrqWc3HLLANSU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-U9fXrqWc3HLLANSU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-U9fXrqWc3HLLANSU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-U9fXrqWc3HLLANSU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-U9fXrqWc3HLLANSU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-U9fXrqWc3HLLANSU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-U9fXrqWc3HLLANSU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-U9fXrqWc3HLLANSU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-U9fXrqWc3HLLANSU .marker.cross{stroke:#333333;}#mermaid-svg-U9fXrqWc3HLLANSU svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-U9fXrqWc3HLLANSU p{margin:0;}#mermaid-svg-U9fXrqWc3HLLANSU .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-U9fXrqWc3HLLANSU text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-U9fXrqWc3HLLANSU .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-U9fXrqWc3HLLANSU .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-U9fXrqWc3HLLANSU .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-U9fXrqWc3HLLANSU .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-U9fXrqWc3HLLANSU #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-U9fXrqWc3HLLANSU .sequenceNumber{fill:white;}#mermaid-svg-U9fXrqWc3HLLANSU #sequencenumber{fill:#333;}#mermaid-svg-U9fXrqWc3HLLANSU #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-U9fXrqWc3HLLANSU .messageText{fill:#333;stroke:none;}#mermaid-svg-U9fXrqWc3HLLANSU .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-U9fXrqWc3HLLANSU .labelText,#mermaid-svg-U9fXrqWc3HLLANSU .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-U9fXrqWc3HLLANSU .loopText,#mermaid-svg-U9fXrqWc3HLLANSU .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-U9fXrqWc3HLLANSU .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-U9fXrqWc3HLLANSU .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-U9fXrqWc3HLLANSU .noteText,#mermaid-svg-U9fXrqWc3HLLANSU .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-U9fXrqWc3HLLANSU .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-U9fXrqWc3HLLANSU .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-U9fXrqWc3HLLANSU .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-U9fXrqWc3HLLANSU .actorPopupMenu{position:absolute;}#mermaid-svg-U9fXrqWc3HLLANSU .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-U9fXrqWc3HLLANSU .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-U9fXrqWc3HLLANSU .actor-man circle,#mermaid-svg-U9fXrqWc3HLLANSU line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-U9fXrqWc3HLLANSU :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
renderNext()
Scenario::render()
SwapFrameRenderPass
发布最新 textureId
frameReady()
获取 currentFrame()
全屏四边形采样纹理
视口默认以约 16 ms 的定时器持续请求渲染。渲染线程在离屏表面上完成场景绘制,通过两个 FBO 乒乓交换,GUI 线程只读取已经完成的纹理并绘制全屏四边形。
这让编辑器 UI 不必承担复杂的场景渲染工作,也延续了笔记(一)建立的线程边界:
- GUI 线程负责窗口事件、属性编辑和最终合成;
- RenderThread 负责 GPU 资源初始化、场景绘制和 FBO 交换;
- 两者通过共享纹理和排队信号协作。
当 Inspector 修改相机实体的 Transform 时,渲染线程会在每帧从相机对象的模型矩阵同步 eye、forward 和 up,因此视图能够随编辑结果更新。
九、当前最关键的问题:场景 Schema 尚未统一
阅读当前代码时可以发现一个明确的格式断层。
AkhalTeke 生成的初始场景使用带命名空间的组件键:
{
"horse.transform": { "position": [0, 1.2, 3] },
"horse.camera": { "fieldOfView": 60, "primary": true }
}
而 SkyViewport 与编辑器保存逻辑读取的是另一套键:
{
"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:https://gitee.com/shendeyidi/softwarer-horse

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



