Horse3D 游戏引擎研发笔记(六):IBalikun——组件编辑器接口与宿主关联
- Bilibili 同步视频
- 一、为什么需要 IBalikun
- 二、类层次全景
- 三、Balikun 基类:序列化闭包与延迟加载
-
- 3.1 闭包注册模式
- 3.2 延迟加载:构造前就拿到 JSON 怎么办
- 3.3 className 与工厂匹配
- 四、IBalikun:编辑器接口与值类型系统
-
- 4.1 接口定义
- 4.2 值类型系统
- 五、属性宏:一行代码完成三件事
- 六、宿主关联:从组件反向访问 IObject
-
- 6.1 问题与方案
- 6.2 自动注入流程
- 6.3 组件访问代理方法
- 七、实战:TestBalikun 完整实现
- 八、序列化全流程
- 九、模块依赖:为什么 IBalikun 从 Balikun 移到了 Dragon
-
- 9.1 问题起源
- 9.2 移动决策
- 9.3 迁移细节
- 十、设计取舍
- 十一、当前成果
- 十二、下一步
- 项目仓库
目标:拆解 Horse3D 的组件编辑器接口层 IBalikun。从 Balikun 基类的序列化闭包机制出发,到 IBalikun 的值类型系统与属性宏,再到组件反向访问宿主 IObject 的设计,完整梳理组件从声明到编辑、序列化、运行时交互的全链路。
Bilibili 同步视频
Horse3D 游戏引擎研发笔记(六):IBalikun——组件编辑器接口与宿主关联
一、为什么需要 IBalikun
前五篇笔记逐步建立了渲染线程、材质系统、光照和编辑器框架。Ferghana 编辑器已经能通过 Inspector 展示组件的属性面板——但那个方案有一个明显的边界:组件层直接依赖 QWidget,Inspector 遍历 object->components() 调用每个组件的 editor() 来获取界面。
这能跑,但不够好。问题是:
IBalikun 就是为解决这三个问题而设计的中间层。它继承 Balikun 获得序列化能力,同时引入值类型系统和属性宏,让子类只需声明成员变量和一行宏调用就能自动获得编辑器 UI 与 JSON 序列化。此外,它存储宿主 IObject 指针,让组件可以反向访问同级组件和宿主属性。
二、类层次全景
#mermaid-svg-jNyHYdeOR8PUytGo{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-jNyHYdeOR8PUytGo .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-jNyHYdeOR8PUytGo .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-jNyHYdeOR8PUytGo .error-icon{fill:#552222;}#mermaid-svg-jNyHYdeOR8PUytGo .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-jNyHYdeOR8PUytGo .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-jNyHYdeOR8PUytGo .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-jNyHYdeOR8PUytGo .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-jNyHYdeOR8PUytGo .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-jNyHYdeOR8PUytGo .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-jNyHYdeOR8PUytGo .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-jNyHYdeOR8PUytGo .marker{fill:#333333;stroke:#333333;}#mermaid-svg-jNyHYdeOR8PUytGo .marker.cross{stroke:#333333;}#mermaid-svg-jNyHYdeOR8PUytGo svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-jNyHYdeOR8PUytGo p{margin:0;}#mermaid-svg-jNyHYdeOR8PUytGo g.classGroup text{fill:#9370DB;stroke:none;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:10px;}#mermaid-svg-jNyHYdeOR8PUytGo g.classGroup text .title{font-weight:bolder;}#mermaid-svg-jNyHYdeOR8PUytGo .cluster-label text{fill:#333;}#mermaid-svg-jNyHYdeOR8PUytGo .cluster-label span{color:#333;}#mermaid-svg-jNyHYdeOR8PUytGo .cluster-label span p{background-color:transparent;}#mermaid-svg-jNyHYdeOR8PUytGo .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-jNyHYdeOR8PUytGo .cluster text{fill:#333;}#mermaid-svg-jNyHYdeOR8PUytGo .cluster span{color:#333;}#mermaid-svg-jNyHYdeOR8PUytGo .nodeLabel,#mermaid-svg-jNyHYdeOR8PUytGo .edgeLabel{color:#131300;}#mermaid-svg-jNyHYdeOR8PUytGo .edgeLabel .label rect{fill:#ECECFF;}#mermaid-svg-jNyHYdeOR8PUytGo .label text{fill:#131300;}#mermaid-svg-jNyHYdeOR8PUytGo .labelBkg{background:#ECECFF;}#mermaid-svg-jNyHYdeOR8PUytGo .edgeLabel .label span{background:#ECECFF;}#mermaid-svg-jNyHYdeOR8PUytGo .classTitle{font-weight:bolder;}#mermaid-svg-jNyHYdeOR8PUytGo .node rect,#mermaid-svg-jNyHYdeOR8PUytGo .node circle,#mermaid-svg-jNyHYdeOR8PUytGo .node ellipse,#mermaid-svg-jNyHYdeOR8PUytGo .node polygon,#mermaid-svg-jNyHYdeOR8PUytGo .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-jNyHYdeOR8PUytGo .divider{stroke:#9370DB;stroke-width:1;}#mermaid-svg-jNyHYdeOR8PUytGo g.clickable{cursor:pointer;}#mermaid-svg-jNyHYdeOR8PUytGo g.classGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-jNyHYdeOR8PUytGo g.classGroup line{stroke:#9370DB;stroke-width:1;}#mermaid-svg-jNyHYdeOR8PUytGo .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-jNyHYdeOR8PUytGo .classLabel .label{fill:#9370DB;font-size:10px;}#mermaid-svg-jNyHYdeOR8PUytGo .relation{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-jNyHYdeOR8PUytGo .dashed-line{stroke-dasharray:3;}#mermaid-svg-jNyHYdeOR8PUytGo .dotted-line{stroke-dasharray:1 2;}#mermaid-svg-jNyHYdeOR8PUytGo #compositionStart,#mermaid-svg-jNyHYdeOR8PUytGo .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-jNyHYdeOR8PUytGo #compositionEnd,#mermaid-svg-jNyHYdeOR8PUytGo .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-jNyHYdeOR8PUytGo #dependencyStart,#mermaid-svg-jNyHYdeOR8PUytGo .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-jNyHYdeOR8PUytGo #dependencyStart,#mermaid-svg-jNyHYdeOR8PUytGo .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-jNyHYdeOR8PUytGo #extensionStart,#mermaid-svg-jNyHYdeOR8PUytGo .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-jNyHYdeOR8PUytGo #extensionEnd,#mermaid-svg-jNyHYdeOR8PUytGo .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-jNyHYdeOR8PUytGo #aggregationStart,#mermaid-svg-jNyHYdeOR8PUytGo .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-jNyHYdeOR8PUytGo #aggregationEnd,#mermaid-svg-jNyHYdeOR8PUytGo .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-jNyHYdeOR8PUytGo #lollipopStart,#mermaid-svg-jNyHYdeOR8PUytGo .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-jNyHYdeOR8PUytGo #lollipopEnd,#mermaid-svg-jNyHYdeOR8PUytGo .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-jNyHYdeOR8PUytGo .edgeTerminals{font-size:11px;line-height:initial;}#mermaid-svg-jNyHYdeOR8PUytGo .classTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-jNyHYdeOR8PUytGo .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-jNyHYdeOR8PUytGo .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-jNyHYdeOR8PUytGo :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
IObject
-m_components
+addComponent<T>() : T&
+component<T>() : T
+hasComponent<T>() : bool
+removeComponent<T>() : bool
+components() : vector<Balikun*>
+transform() : Transform&
+parent() : IObject
+children() : vector<IObject*>
Balikun
#m_setters
#m_getters
#m_onEdited
+editor() : QWidget
+update(elapsedMs) : void
+toJson() : QJsonObject
+fromJson(json) : void
+className() : QString
IBalikun
#m_object : IObject
#m_name : QString
+object() : IObject
+setObject(obj) : void
+component<T>() : T
+hasComponent<T>() : bool
+transform() : Transform
+objectName() : QString
Transform
+translation : Vector3Proxy
+rotation : QuaternionProxy
+scale : Vector3Proxy
+matrix() : QMatrix4x4
TestBalikun
+cnt : intValue
+speed : floatValue
+alias : StringValue
+position : Vector2Value
+color : Vector3Value
图 1:IBalikun 类层次。 Balikun 是所有组件的基类,IBalikun 和 Transform 是两条不同的分支——前者面向编辑器,后者是 IObject 的固有变换组件。
关键设计决策:Transform 继承 Balikun 但不继承 IBalikun。Transform 是 IObject 构造时自动创建的固有组件,不需要编辑器界面生成能力(它的编辑器是手写的,用 Vector3Proxy 实现可观察代理)。IBalikun 则专门面向需要声明式属性面板的组件。
三、Balikun 基类:序列化闭包与延迟加载
3.1 闭包注册模式
Balikun 不使用虚函数做序列化,而是让子类在构造函数中通过 addSetter / addGetter 注册 lambda 闭包。每个闭包捕获 this 并读写一个具体字段:
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 数据已经传进来了。
解决方案是一个全局缓存映射表:
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 基础上引入三层能力:编辑器界面生成、值类型系统、宿主对象关联。
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() 往布局里填充控件:
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 控件和回调机制:
| 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 为例:
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 为例:
#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)
一行宏完成三件事:
Vector 类型稍有不同——JSON 中以嵌套对象存储 {x, y, z} 字段,但宏的模式完全一致。这种声明式编程让组件开发者只需写成员声明和一行宏调用,不必关心 UI 构建、序列化注册、回调连接的任何细节。
六、宿主关联:从组件反向访问 IObject
6.1 问题与方案
在 ECS 架构中,组件通常只知道自己的数据,无法访问同级组件或宿主对象。但在实际开发中,组件经常需要读取同级组件的状态——比如自定义脚本组件需要读取 Transform 位置来做逻辑判断。
IBalikun 通过存储 IObject* 指针解决了这个问题。但怎么设置这个指针?组件是被 IObject::addComponent 创建的,组件不知道自己的宿主是谁。
方案是:在 addComponent 中,组件创建完毕后自动注入宿主指针。IBalikun 提供一个 protected 的 setObject 方法,IObject 通过辅助方法 setComponentHost 调用它:
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 自动注入流程
#mermaid-svg-87kCxevkm632wNiI{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-87kCxevkm632wNiI .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-87kCxevkm632wNiI .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-87kCxevkm632wNiI .error-icon{fill:#552222;}#mermaid-svg-87kCxevkm632wNiI .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-87kCxevkm632wNiI .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-87kCxevkm632wNiI .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-87kCxevkm632wNiI .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-87kCxevkm632wNiI .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-87kCxevkm632wNiI .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-87kCxevkm632wNiI .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-87kCxevkm632wNiI .marker{fill:#333333;stroke:#333333;}#mermaid-svg-87kCxevkm632wNiI .marker.cross{stroke:#333333;}#mermaid-svg-87kCxevkm632wNiI svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-87kCxevkm632wNiI p{margin:0;}#mermaid-svg-87kCxevkm632wNiI .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-87kCxevkm632wNiI .cluster-label text{fill:#333;}#mermaid-svg-87kCxevkm632wNiI .cluster-label span{color:#333;}#mermaid-svg-87kCxevkm632wNiI .cluster-label span p{background-color:transparent;}#mermaid-svg-87kCxevkm632wNiI .label text,#mermaid-svg-87kCxevkm632wNiI span{fill:#333;color:#333;}#mermaid-svg-87kCxevkm632wNiI .node rect,#mermaid-svg-87kCxevkm632wNiI .node circle,#mermaid-svg-87kCxevkm632wNiI .node ellipse,#mermaid-svg-87kCxevkm632wNiI .node polygon,#mermaid-svg-87kCxevkm632wNiI .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-87kCxevkm632wNiI .rough-node .label text,#mermaid-svg-87kCxevkm632wNiI .node .label text,#mermaid-svg-87kCxevkm632wNiI .image-shape .label,#mermaid-svg-87kCxevkm632wNiI .icon-shape .label{text-anchor:middle;}#mermaid-svg-87kCxevkm632wNiI .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-87kCxevkm632wNiI .rough-node .label,#mermaid-svg-87kCxevkm632wNiI .node .label,#mermaid-svg-87kCxevkm632wNiI .image-shape .label,#mermaid-svg-87kCxevkm632wNiI .icon-shape .label{text-align:center;}#mermaid-svg-87kCxevkm632wNiI .node.clickable{cursor:pointer;}#mermaid-svg-87kCxevkm632wNiI .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-87kCxevkm632wNiI .arrowheadPath{fill:#333333;}#mermaid-svg-87kCxevkm632wNiI .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-87kCxevkm632wNiI .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-87kCxevkm632wNiI .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-87kCxevkm632wNiI .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-87kCxevkm632wNiI .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-87kCxevkm632wNiI .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-87kCxevkm632wNiI .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-87kCxevkm632wNiI .cluster text{fill:#333;}#mermaid-svg-87kCxevkm632wNiI .cluster span{color:#333;}#mermaid-svg-87kCxevkm632wNiI 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-87kCxevkm632wNiI .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-87kCxevkm632wNiI rect.text{fill:none;stroke-width:0;}#mermaid-svg-87kCxevkm632wNiI .icon-shape,#mermaid-svg-87kCxevkm632wNiI .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-87kCxevkm632wNiI .icon-shape p,#mermaid-svg-87kCxevkm632wNiI .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-87kCxevkm632wNiI .icon-shape .label rect,#mermaid-svg-87kCxevkm632wNiI .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-87kCxevkm632wNiI .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-87kCxevkm632wNiI .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-87kCxevkm632wNiI :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
成功
失败
IObject::addComponent<T>()
创建 T 组件
emplace 到 m_components
setComponentHost(result, this)
dynamic_cast<IBalikun*>
ibalikun->setObject(this)
Transform 等非 IBalikun组件,跳过
组件可通过 component<T>()访问同级组件
组件可通过 transform()访问宿主变换
组件可通过 objectName()获取宿主名称
图 2:组件挂载与宿主指针注入流程。 addComponent 创建组件后,setComponentHost 通过 dynamic_cast 判断是否为 IBalikun 子类,是则注入宿主指针。
移除组件时也会清空指针,避免悬垂引用:
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 提供了一套代理方法,让组件像在自己身上调用一样访问宿主的组件和属性:
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 子类的完整写法:
// 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;
};
// 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() 的输出:
{
"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 }
}
整个流程:
延迟加载的实战场景:当 BalikunRegistry 工厂创建组件时,流程是 fromJson → 构造函数 → addGetter。此时 JSON 已被缓存到全局映射表,addGetter 注册时立即执行一次 getter 应用数据,确保不丢失。构造函数完成后,全局映射表中的条目在析构时自动清除。
九、模块依赖:为什么 IBalikun 从 Balikun 移到了 Dragon
9.1 问题起源
最初 IBalikun 位于 Balikun 模块,仅用前向声明引用 IObject:
// 在 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" 获取完整定义。移动后依赖关系保持单向:
#mermaid-svg-hrIv8FALSdREyKfJ{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-hrIv8FALSdREyKfJ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-hrIv8FALSdREyKfJ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-hrIv8FALSdREyKfJ .error-icon{fill:#552222;}#mermaid-svg-hrIv8FALSdREyKfJ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-hrIv8FALSdREyKfJ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-hrIv8FALSdREyKfJ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-hrIv8FALSdREyKfJ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-hrIv8FALSdREyKfJ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-hrIv8FALSdREyKfJ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-hrIv8FALSdREyKfJ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-hrIv8FALSdREyKfJ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-hrIv8FALSdREyKfJ .marker.cross{stroke:#333333;}#mermaid-svg-hrIv8FALSdREyKfJ svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-hrIv8FALSdREyKfJ p{margin:0;}#mermaid-svg-hrIv8FALSdREyKfJ .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-hrIv8FALSdREyKfJ .cluster-label text{fill:#333;}#mermaid-svg-hrIv8FALSdREyKfJ .cluster-label span{color:#333;}#mermaid-svg-hrIv8FALSdREyKfJ .cluster-label span p{background-color:transparent;}#mermaid-svg-hrIv8FALSdREyKfJ .label text,#mermaid-svg-hrIv8FALSdREyKfJ span{fill:#333;color:#333;}#mermaid-svg-hrIv8FALSdREyKfJ .node rect,#mermaid-svg-hrIv8FALSdREyKfJ .node circle,#mermaid-svg-hrIv8FALSdREyKfJ .node ellipse,#mermaid-svg-hrIv8FALSdREyKfJ .node polygon,#mermaid-svg-hrIv8FALSdREyKfJ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-hrIv8FALSdREyKfJ .rough-node .label text,#mermaid-svg-hrIv8FALSdREyKfJ .node .label text,#mermaid-svg-hrIv8FALSdREyKfJ .image-shape .label,#mermaid-svg-hrIv8FALSdREyKfJ .icon-shape .label{text-anchor:middle;}#mermaid-svg-hrIv8FALSdREyKfJ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-hrIv8FALSdREyKfJ .rough-node .label,#mermaid-svg-hrIv8FALSdREyKfJ .node .label,#mermaid-svg-hrIv8FALSdREyKfJ .image-shape .label,#mermaid-svg-hrIv8FALSdREyKfJ .icon-shape .label{text-align:center;}#mermaid-svg-hrIv8FALSdREyKfJ .node.clickable{cursor:pointer;}#mermaid-svg-hrIv8FALSdREyKfJ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-hrIv8FALSdREyKfJ .arrowheadPath{fill:#333333;}#mermaid-svg-hrIv8FALSdREyKfJ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-hrIv8FALSdREyKfJ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-hrIv8FALSdREyKfJ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hrIv8FALSdREyKfJ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-hrIv8FALSdREyKfJ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hrIv8FALSdREyKfJ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-hrIv8FALSdREyKfJ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-hrIv8FALSdREyKfJ .cluster text{fill:#333;}#mermaid-svg-hrIv8FALSdREyKfJ .cluster span{color:#333;}#mermaid-svg-hrIv8FALSdREyKfJ 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-hrIv8FALSdREyKfJ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-hrIv8FALSdREyKfJ rect.text{fill:none;stroke-width:0;}#mermaid-svg-hrIv8FALSdREyKfJ .icon-shape,#mermaid-svg-hrIv8FALSdREyKfJ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hrIv8FALSdREyKfJ .icon-shape p,#mermaid-svg-hrIv8FALSdREyKfJ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-hrIv8FALSdREyKfJ .icon-shape .label rect,#mermaid-svg-hrIv8FALSdREyKfJ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hrIv8FALSdREyKfJ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-hrIv8FALSdREyKfJ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-hrIv8FALSdREyKfJ :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
target_link_libraries(PUBLIC Balikun)
链接 Dragon + Balikun
链接 Balikun::Core
EditorSample
TestBalikun
Dragon 模块(引擎层)
IObject / Object3D
IBalikun
Light / Camera / Material
RenderThread / Scenario
Balikun 模块(基础层)
Balikun 基类
intValue / floatValue / StringValue
Vector2Value / Vector3Value
Transform
BalikunRegistry
图 3:模块依赖方向。 Dragon → Balikun 始终单向,IBalikun 移入 Dragon 后直接 include Object3D.h,消除跨模块前向声明的问题。
9.3 迁移细节
移动涉及的具体变更:
| 文件位置 | 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:https://gitee.com/shendeyidi/softwarer-horse

本系列记录 Horse3D 游戏引擎从零开始的研发过程,欢迎交流。
网硕互联帮助中心


评论前必须登录!
注册