云计算百科
云计算领域专业知识百科平台

Horse3D 游戏引擎研发笔记(六):IBalikun——组件编辑器接口与宿主关联

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() 来获取界面。

这能跑,但不够好。问题是:

  • 每个组件子类都要手写 UI 布局代码——创建标签、创建控件、设置布局、注册回调,重复且容易出错。
  • 序列化没有统一机制——组件的哪些字段需要存到 JSON、字段名叫什么、怎么读回来,全靠每个子类自己实现。
  • 组件无法访问宿主对象——一个自定义脚本组件想读取同级 Transform 的位置,没有任何途径拿到 IObject 指针。
  • 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 控件和回调机制:

    值类型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 为例:

    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)

    一行宏完成三件事:

  • 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 调用它:

    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 }
    }

    整个流程:

  • 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:

    // 在 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 迁移细节

    移动涉及的具体变更:

    变更项旧值(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:https://gitee.com/shendeyidi/softwarer-horse

    Horse3D 游戏引擎研发笔记(六):IBalikun——组件编辑器接口与宿主关联

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

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Horse3D 游戏引擎研发笔记(六):IBalikun——组件编辑器接口与宿主关联
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!