目标:拆解 Horse3D 的组件编辑器接口层 IBalikun。从 Balikun 基类的序列化闭包机制出发,到 IBalikun 的值类型系统与属性宏,再到组件反向访问宿主 IObject 的设计,完整梳理组件从声明到编辑、序列化、运行时交互的全链路。
Bilibili 同步视频
一、为什么需要 IBalikun
前五篇笔记逐步建立了渲染线程、材质系统、光照和编辑器框架。Ferghana 编辑器已经能通过 Inspector 展示组件的属性面板------但那个方案有一个明显的边界:组件层直接依赖 QWidget,Inspector 遍历 object->components() 调用每个组件的 editor() 来获取界面。
这能跑,但不够好。问题是:
- 每个组件子类都要手写 UI 布局代码------创建标签、创建控件、设置布局、注册回调,重复且容易出错。
- 序列化没有统一机制------组件的哪些字段需要存到 JSON、字段名叫什么、怎么读回来,全靠每个子类自己实现。
- 组件无法访问宿主对象------一个自定义脚本组件想读取同级 Transform 的位置,没有任何途径拿到 IObject 指针。
IBalikun 就是为解决这三个问题而设计的中间层。它继承 Balikun 获得序列化能力,同时引入值类型系统和属性宏,让子类只需声明成员变量和一行宏调用就能自动获得编辑器 UI 与 JSON 序列化。此外,它存储宿主 IObject 指针,让组件可以反向访问同级组件和宿主属性。
二、类层次全景
图 1:IBalikun 类层次。 Balikun 是所有组件的基类,IBalikun 和 Transform 是两条不同的分支------前者面向编辑器,后者是 IObject 的固有变换组件。
关键设计决策:Transform 继承 Balikun 但不继承 IBalikun。Transform 是 IObject 构造时自动创建的固有组件,不需要编辑器界面生成能力(它的编辑器是手写的,用 Vector3Proxy 实现可观察代理)。IBalikun 则专门面向需要声明式属性面板的组件。
三、Balikun 基类:序列化闭包与延迟加载
3.1 闭包注册模式
Balikun 不使用虚函数做序列化,而是让子类在构造函数中通过 addSetter / addGetter 注册 lambda 闭包。每个闭包捕获 this 并读写一个具体字段:
cpp
class Balikun
{
protected:
std::vector<std::function<void(QJsonObject&)>> m_setters;
std::vector<std::function<void(const QJsonObject&)>> m_getters;
std::function<void()> m_onEdited;
public:
void addSetter(std::function<void(QJsonObject &)> setter)
{ m_setters.push_back(std::move(setter)); }
void addGetter(std::function<void(const QJsonObject &)> getter);
QJsonObject toJson() const;
void fromJson(const QJsonObject &json);
};
toJson() 遍历所有 setter 闭包,每个向 JSON 写入一个字段;fromJson() 反向调用 getter 闭包。这种设计的优势是零反射开销------编译期确定类型,运行期无需字符串匹配。代价是无法跨类型批量管理字段,但配合后文的属性宏,这个代价被完全消除。
3.2 延迟加载:构造前就拿到 JSON 怎么办
Balikun 的 addGetter 实现了一个精巧的延迟加载机制。考虑工厂模式:BalikunRegistry 从 JSON 创建组件时,流程是 fromJson → 构造函数 → addGetter。此时构造函数还没跑完,getter 还没注册,但 JSON 数据已经传进来了。
解决方案是一个全局缓存映射表:
cpp
static std::unordered_map<const Balikun *, QJsonObject> s_loadedJson;
void Balikun::addGetter(std::function<void(const QJsonObject &)> getter)
{
QJsonObject loadedJson;
bool hasLoadedJson = false;
{
const std::lock_guard<std::mutex> lock(s_loadedJsonMutex);
const auto found = s_loadedJson.find(this);
if (found != s_loadedJson.end()) {
loadedJson = found->second;
hasLoadedJson = true;
}
}
if (hasLoadedJson)
getter(loadedJson); // 立即应用缓存的 JSON
m_getters.push_back(std::move(getter));
}
当 getter 被注册时,如果全局表里已有缓存数据,就立即执行一次。这意味着子类构造函数中的 addGetter 调用顺序决定了字段加载顺序------即使 fromJson 在构造前调用,数据也不会丢失。析构时自动清除缓存条目。
3.3 className 与工厂匹配
className() 默认实现通过 typeid 推导类型名,与 BalikunRegistry 的命名规则一致。工厂创建组件时,用类名匹配 JSON 中的组件类型字段,实现反序列化时的类型路由。
四、IBalikun:编辑器接口与值类型系统
4.1 接口定义
IBalikun 在 Balikun 基础上引入三层能力:编辑器界面生成、值类型系统、宿主对象关联。
cpp
class DRAGON_EXPORT IBalikun : public Balikun
{
public:
explicit IBalikun(const QString &name);
QWidget *editor() override;
virtual void onEditor(QWidget *editor, QVBoxLayout *layout) = 0;
~IBalikun() override;
IObject *object() const;
void setObject(IObject *object);
template<typename T> T *component();
template<typename T> bool hasComponent() const;
std::vector<Balikun *> components() const;
Transform *transform() const;
protected:
QString m_name;
IObject *m_object = nullptr;
};
editor() 是工厂方法------创建一个 QGroupBox,内含 QVBoxLayout,然后调用子类实现的 onEditor() 往布局里填充控件:
cpp
QWidget *IBalikun::editor()
{
auto *widget = new QGroupBox(m_name);
auto *layout = new QVBoxLayout(widget);
layout->setContentsMargins(8, 8, 8, 8);
onEditor(widget, layout);
return widget;
}
子类只需要实现 onEditor(),在方法体里调用属性宏即可。不需要手动创建 QGroupBox、不需要管理 layout 生命周期。
4.2 值类型系统
IBalikun 的编辑器界面不是手写 QWidget,而是通过一套值类型代理来声明式构建。引擎提供五种值类型,每种封装了对应的 Qt 控件和回调机制:
| 值类型 | C++ 原生类型 | Qt 控件 | 属性宏 |
|---|---|---|---|
intValue |
int |
QSpinBox |
BALIKUN_INT_PROPERTY |
floatValue |
float |
QDoubleSpinBox |
BALIKUN_FLOAT_PROPERTY |
StringValue |
QString |
QLineEdit |
BALIKUN_STRING_PROPERTY |
Vector2Value |
QVector2D |
2× QDoubleSpinBox |
BALIKUN_VECTOR2_PROPERTY |
Vector3Value |
QVector3D |
3× QDoubleSpinBox |
BALIKUN_VECTOR3_PROPERTY |
每个值类型都继承 QObject,具备多视图同步能力------同一个值可以关联多个控件实例(通过 QVector<QPointer<...>> 管理),修改任意一个控件会同步所有其他控件。同时支持 addCallback 注册值变更回调。
以 intValue 为例:
cpp
class BALIKUN_EXPORT intValue : public QObject
{
public:
explicit intValue(int value = 0) noexcept;
int value() const noexcept;
void setValue(int value) noexcept;
QSpinBox *create(QWidget *parent = nullptr);
intValue &operator=(int value) noexcept;
operator int() const noexcept;
void addCallback(std::function<void(int)> callback);
private:
int m_value = 0;
QVector<QPointer<QSpinBox>> m_spinBoxes;
std::vector<std::function<void(int)>> m_callbacks;
};
隐式转换运算符 operator int() 让值类型在表达式中像原生类型一样使用------info() << "cnt" << cnt 里的 cnt 是 intValue,但输出时自动转为 int。create() 方法每次创建一个新的关联控件,自动同步当前值并注册回调。
五、属性宏:一行代码完成三件事
值类型本身只管理控件和回调,但要完整接入编辑器还需要:创建 UI 行、注册序列化 setter/getter、注册变更回调触发 m_onEdited。这三步在每个属性上都重复,因此被封装为宏。
以 BALIKUN_INT_PROPERTY 为例:
cpp
#define BALIKUN_INT_PROPERTY(displayName, member) \
do { \
layout->addLayout(create(QStringLiteral(displayName), member, editor)); \
addSetter([this](QJsonObject &json) { \
json.insert(QStringLiteral(#member), int(member)); \
}); \
addGetter([this](const QJsonObject &json) { \
if (json.contains(QStringLiteral(#member))) \
member = json.value(QStringLiteral(#member)).toInt(); \
}); \
member.addCallback([this](int) { \
if (m_onEdited) m_onEdited(); \
}); \
} while (0)
一行宏完成三件事:
- UI 创建 ---
create()生成标签 + 控件的水平布局,加入编辑器 layout。 - 序列化注册 --- 向 Balikun 基类注册 setter(写 JSON)和 getter(读 JSON),字段名取自
#member字符化。 - 编辑回调 --- 值变更时触发
m_onEdited,通知编辑器刷新。
Vector 类型稍有不同------JSON 中以嵌套对象存储 {x, y, z} 字段,但宏的模式完全一致。这种声明式编程让组件开发者只需写成员声明和一行宏调用,不必关心 UI 构建、序列化注册、回调连接的任何细节。
六、宿主关联:从组件反向访问 IObject
6.1 问题与方案
在 ECS 架构中,组件通常只知道自己的数据,无法访问同级组件或宿主对象。但在实际开发中,组件经常需要读取同级组件的状态------比如自定义脚本组件需要读取 Transform 位置来做逻辑判断。
IBalikun 通过存储 IObject* 指针解决了这个问题。但怎么设置这个指针?组件是被 IObject::addComponent 创建的,组件不知道自己的宿主是谁。
方案是:在 addComponent 中,组件创建完毕后自动注入宿主指针。IBalikun 提供一个 protected 的 setObject 方法,IObject 通过辅助方法 setComponentHost 调用它:
cpp
void IObject::setComponentHost(Balikun *component, IObject *host)
{
if (auto *ibalikun = dynamic_cast<IBalikun *>(component))
ibalikun->setObject(host);
}
dynamic_cast 确保只有 IBalikun 子类才被设置指针------Transform 等非 IBalikun 组件不受影响,dynamic_cast 返回 nullptr 直接跳过。
6.2 自动注入流程
图 2:组件挂载与宿主指针注入流程。 addComponent 创建组件后,setComponentHost 通过 dynamic_cast 判断是否为 IBalikun 子类,是则注入宿主指针。
移除组件时也会清空指针,避免悬垂引用:
cpp
template<typename T>
bool removeComponent()
{
const auto it = m_components.find(std::type_index(typeid(T)));
if (it == m_components.end())
return false;
setComponentHost(it->second.get(), nullptr); // 清空宿主指针
m_components.erase(it);
return true;
}
6.3 组件访问代理方法
有了宿主指针后,IBalikun 提供了一套代理方法,让组件像在自己身上调用一样访问宿主的组件和属性:
cpp
template<typename T>
T *component()
{
return m_object ? m_object->component<T>() : nullptr;
}
Transform *transform() const
{
return m_object ? &m_object->transform() : nullptr;
}
QString objectName() const
{
return m_object ? m_object->name() : QString{};
}
IObject *parentObject() const
{
return m_object ? m_object->parent() : nullptr;
}
std::vector<IObject *> childObjects() const
{
return m_object ? m_object->children() : std::vector<IObject *>{};
}
所有方法都处理了 m_object == nullptr 的情况(组件未挂载到对象时),返回安全的空值。这在组件单元测试或独立创建场景中很重要------组件可以先构造再挂载。
七、实战:TestBalikun 完整实现
用一个示例组件串联所有概念。TestBalikun 展示了一个典型 IBalikun 子类的完整写法:
cpp
// TestBalikun.h
#pragma once
#include "IBalikun.h"
using namespace Horse;
class TestBalikun : public IBalikun
{
public:
TestBalikun();
intValue cnt;
floatValue speed;
StringValue alias;
Vector2Value position;
Vector3Value color;
public:
void onEditor(QWidget *editor, QVBoxLayout *layout) override;
void update(qint64 elapsedMilliseconds) override;
};
cpp
// TestBalikun.cpp
#include "TestBalikun.h"
#include "Clydesdale.h"
TestBalikun::TestBalikun()
: IBalikun(QStringLiteral("测试组件"))
{
}
void TestBalikun::onEditor(QWidget *editor, QVBoxLayout *layout)
{
BALIKUN_INT_PROPERTY("个数", cnt);
BALIKUN_FLOAT_PROPERTY("速度", speed);
BALIKUN_STRING_PROPERTY("名称", alias);
BALIKUN_VECTOR2_PROPERTY("位置", position);
BALIKUN_VECTOR3_PROPERTY("颜色", color);
}
void TestBalikun::update(qint64 elapsedMilliseconds)
{
info() << "cnt" << cnt << "TestBalikun::update" << elapsedMilliseconds << "ms";
}
如果没有 IBalikun 和属性宏,实现同样的功能需要:手动创建 5 组标签 + 控件、手动注册 5 套 JSON 序列化、手动连接 5 个值变更信号------至少 80+ 行重复代码。而现在 onEditor 只需 5 行宏调用。
update() 方法展示了值类型的隐式转换------cnt(intValue)可以直接用 << 输出到日志流,因为 operator int() 会自动触发。日志使用了项目约定的 info() 宏,命名空间为 Horse,定义在 Clydesdale.h 中。
八、序列化全流程
把序列化机制串起来看一个完整的数据流。以 TestBalikun 为例,toJson() 的输出:
json
{
"name": "测试组件",
"cnt": 42,
"speed": 1.5,
"alias": "Hero",
"position": { "x": 0.0, "y": 0.0 },
"color": { "x": 1.0, "y": 0.0, "z": 0.0 }
}
整个流程:
- IBalikun 构造函数注册
name字段的 setter/getter。 - TestBalikun 构造完成(5 个值类型成员已默认构造)。
onEditor()被调用时,5 个属性宏各注册一对 setter/getter。- 此时 Balikun 共持有 6 组 setter + 6 组 getter(name + 5 个属性)。
toJson()遍历 6 个 setter,生成上述 JSON。fromJson()遍历 6 个 getter,从 JSON 恢复所有字段。
延迟加载的实战场景:当 BalikunRegistry 工厂创建组件时,流程是 fromJson → 构造函数 → addGetter。此时 JSON 已被缓存到全局映射表,addGetter 注册时立即执行一次 getter 应用数据,确保不丢失。构造函数完成后,全局映射表中的条目在析构时自动清除。
九、模块依赖:为什么 IBalikun 从 Balikun 移到了 Dragon
9.1 问题起源
最初 IBalikun 位于 Balikun 模块,仅用前向声明引用 IObject:
cpp
// 在 Balikun 模块中
class IObject; // 前向声明
class BALIKUN_EXPORT IBalikun : public Balikun
{
IObject *m_object = nullptr; // 指针,前向声明足够
};
这在前向声明阶段没问题------存一个指针确实不需要完整定义。但当 IBalikun 需要添加操作 IObject 组件的方法(component<T>()、transform() 等)时,模板方法必须在头文件内联展开,编译器需要看到 IObject::component<T>() 的完整定义。前向声明不够用了。
9.2 移动决策
将 IBalikun 从 Balikun 模块移入 Dragon 模块(IObject 所在的模块),可以直接 #include "Object3D.h" 获取完整定义。移动后依赖关系保持单向:
图 3:模块依赖方向。 Dragon → Balikun 始终单向,IBalikun 移入 Dragon 后直接 include Object3D.h,消除跨模块前向声明的问题。
9.3 迁移细节
移动涉及的具体变更:
| 变更项 | 旧值(Balikun 模块) | 新值(Dragon 模块) |
|---|---|---|
| 文件位置 | Sinohorse/Balikun/IBalikun.h/.cpp |
Sinohorse/Dragon/IBalikun.h/.cpp |
| 导出宏 | BALIKUN_EXPORT |
DRAGON_EXPORT |
| 全局头 | balikun_global.h |
dragon_global.h |
| IObject 引用 | class IObject; 前向声明 |
#include "Object3D.h" |
| CMake 注册 | Balikun/CMakeLists.txt |
Dragon/CMakeLists.txt |
Balikun 模块仍保留值类型和 Transform。IBalikun 在 Dragon 中通过 #include "Balikun.h" 等引入这些类型------Dragon 的 CMake 已经 target_link_libraries(PUBLIC Balikun),include 路径随 PUBLIC 传播,无需额外配置。
EditorSample 因为 TestBalikun 继承 IBalikun,需要在 CMakeLists 中显式链接 Dragon(之前只链接 Balikun::Core)。
十、设计取舍
| 设计点 | 当前方案 | 优点 | 后续演进 |
|---|---|---|---|
| 序列化机制 | 闭包注册(addSetter/addGetter) | 零反射开销、支持运行时动态字段 | 可考虑自动注册元数据 |
| 属性 UI | 值类型 + 属性宏 | 一行宏完成 UI + 序列化 + 回调 | 可扩展更多类型(bool、enum、color) |
| 宿主指针 | dynamic_cast + setComponentHost | 对非 IBalikun 组件透明 | Transform 等也可考虑持有宿主指针 |
| 值类型多视图 | QPointer 管理多个控件实例 | 同一值多处编辑自动同步 | 可考虑数据绑定框架 |
| 延迟加载 | 全局映射表缓存 JSON | 解决工厂模式构造前反序列化问题 | 可考虑传入构造参数 |
| 模块归属 | IBalikun 在 Dragon | 模板方法可直接访问 IObject 完整定义 | 可考虑拆分接口与实现 |
十一、当前成果
- Balikun 基类提供序列化闭包机制和延迟加载,支持工厂模式下的构造前反序列化。
- IBalikun 在 Balikun 基础上引入编辑器接口、值类型系统和属性宏,让组件开发者一行宏即可完成 UI 创建 + 序列化注册 + 回调连接。
- 五种值类型(int、float、string、Vector2、Vector3)覆盖常见属性类型,均支持多视图同步和隐式转换。
- IObject::addComponent 自动注入宿主指针,IBalikun 提供
component<T>()、transform()等代理方法让组件反向访问宿主。 - IBalikun 从 Balikun 模块移入 Dragon 模块,消除跨模块前向声明问题,依赖关系保持单向。
- TestBalikun 示例展示了完整开发流程:声明值类型成员、调用属性宏、实现 onEditor 和 update。
十二、下一步
| 优先级 | 方向 | 目标 |
|---|---|---|
| P0 | 更多值类型 | 支持 bool、enum、color 等常用属性类型 |
| P1 | 属性元数据 | 用元数据统一驱动 Inspector,减少组件对 QWidget 的直接依赖 |
| P1 | Transform 宿主指针 | 让 Transform 也持有 IObject 指针,消除 friend class 的需要 |
| P2 | 组件间通信 | 基于 Mustang 消息总线实现组件间事件订阅 |
| P2 | 热重载 | 监听场景 JSON 变化,运行时重建组件 |
| P2 | 撤销重做 | 配合属性宏的闭包机制实现属性变更的 Undo/Redo |
项目仓库
- Gitee :gitee.com/shendeyidi/...

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