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

目标:拆解 Horse3D 的组件编辑器接口层 IBalikun。从 Balikun 基类的序列化闭包机制出发,到 IBalikun 的值类型系统与属性宏,再到组件反向访问宿主 IObject 的设计,完整梳理组件从声明到编辑、序列化、运行时交互的全链路。

Bilibili 同步视频

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

一、为什么需要 IBalikun

前五篇笔记逐步建立了渲染线程、材质系统、光照和编辑器框架。Ferghana 编辑器已经能通过 Inspector 展示组件的属性面板------但那个方案有一个明显的边界:组件层直接依赖 QWidget,Inspector 遍历 object->components() 调用每个组件的 editor() 来获取界面。

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

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

IBalikun 就是为解决这三个问题而设计的中间层。它继承 Balikun 获得序列化能力,同时引入值类型系统和属性宏,让子类只需声明成员变量和一行宏调用就能自动获得编辑器 UI 与 JSON 序列化。此外,它存储宿主 IObject 指针,让组件可以反向访问同级组件和宿主属性。

二、类层次全景

classDiagram class IObject { +addComponent~T~() T& +component~T~() T* +hasComponent~T~() bool +removeComponent~T~() bool +components() vector~Balikun*~ +transform() Transform& +parent() IObject* +children() vector~IObject*~ -m_components } class Balikun { +editor() QWidget* +update(elapsedMs) void +toJson() QJsonObject +fromJson(json) void +className() QString #m_setters #m_getters #m_onEdited } class IBalikun { +object() IObject* +setObject(obj) void +component~T~() T* +hasComponent~T~() bool +transform() Transform* +objectName() QString #m_object : IObject* #m_name : QString } class Transform { +translation : Vector3Proxy +rotation : QuaternionProxy +scale : Vector3Proxy +matrix() QMatrix4x4 } class TestBalikun { +cnt : intValue +speed : floatValue +alias : StringValue +position : Vector2Value +color : Vector3Value } IObject *-- Balikun Balikun <|-- IBalikun Balikun <|-- Transform IBalikun <|-- TestBalikun IBalikun ..> 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 QDoubleSpinBox BALIKUN_VECTOR2_PROPERTY
Vector3Value QVector3D 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 里的 cntintValue,但输出时自动转为 intcreate() 方法每次创建一个新的关联控件,自动同步当前值并注册回调。

五、属性宏:一行代码完成三件事

值类型本身只管理控件和回调,但要完整接入编辑器还需要:创建 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)

一行宏完成三件事:

  1. UI 创建 --- create() 生成标签 + 控件的水平布局,加入编辑器 layout。
  2. 序列化注册 --- 向 Balikun 基类注册 setter(写 JSON)和 getter(读 JSON),字段名取自 #member 字符化。
  3. 编辑回调 --- 值变更时触发 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 自动注入流程

flowchart LR A[&#34;IObject::addComponent()&#34;] --> B[&#34;创建 T 组件&#34;] B --> C[&#34;emplace 到 m_components&#34;] C --> D[&#34;setComponentHost(result, this)&#34;] D --> E{&#34;dynamic_cast&#34;} E -- 成功 --> F[&#34;ibalikun->setObject(this)&#34;] E -- 失败 --> G[&#34;Transform 等非 IBalikun<br/>组件,跳过&#34;] F --> H[&#34;组件可通过 component()<br/>访问同级组件&#34;] F --> I[&#34;组件可通过 transform()<br/>访问宿主变换&#34;] F --> J[&#34;组件可通过 objectName()<br/>获取宿主名称&#34;]

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

整个流程:

  1. IBalikun 构造函数注册 name 字段的 setter/getter。
  2. TestBalikun 构造完成(5 个值类型成员已默认构造)。
  3. onEditor() 被调用时,5 个属性宏各注册一对 setter/getter。
  4. 此时 Balikun 共持有 6 组 setter + 6 组 getter(name + 5 个属性)。
  5. toJson() 遍历 6 个 setter,生成上述 JSON。
  6. 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" 获取完整定义。移动后依赖关系保持单向:

flowchart TD subgraph Balikun[&#34;Balikun 模块(基础层)&#34;] B1[&#34;Balikun 基类&#34;] B2[&#34;intValue / floatValue / StringValue&#34;] B3[&#34;Vector2Value / Vector3Value&#34;] B4[&#34;Transform&#34;] B5[&#34;BalikunRegistry&#34;] end subgraph Dragon[&#34;Dragon 模块(引擎层)&#34;] D1[&#34;IObject / Object3D&#34;] D2[&#34;IBalikun&#34;] D3[&#34;Light / Camera / Material&#34;] D4[&#34;RenderThread / Scenario&#34;] end subgraph Samples[&#34;EditorSample&#34;] S1[&#34;TestBalikun&#34;] end Dragon -- &#34;target_link_libraries(PUBLIC Balikun)&#34; --> Balikun Samples -- &#34;链接 Dragon + Balikun&#34; --> Dragon Samples -- &#34;链接 Balikun::Core&#34; --> Balikun

图 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

项目仓库


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

相关推荐
空堂与归1 小时前
AI 电商智能客服助手:Coze 全流程实战
人工智能·开源
a1117761 小时前
原生 Markdown 阅读与编辑器 开源项目
前端·开源·软件
fthux11 小时前
装闭 RenoPit 源码解析(09):AnalysisEngine装修闭坑分析主流程
人工智能·ai·开源·github·open source·renopit
ovO11 小时前
我按下“发送”之后:DeepSeek Harness 如何把一次请求变成 1,177 个增量片段
人工智能·开源·deepseek
Gem_S_60811 小时前
从 Chonkie 到 RAGFlow:主流开源知识库 Chunking 工具横向测评
开源
zlinear数据采集卡17 小时前
D223上位机C#开发实战:从帧解析到波形显示的完整实现
开发语言·arm开发·嵌入式硬件·fpga开发·开源·c#
番茄不是西红柿kk20 小时前
DeepSeek Harness 开源解读
人工智能·开源·agent·harness·deekseep
zlinear数据采集卡20 小时前
D223 ADC数据处理流水线:从原始采样值到工程单位的完整转换链
开发语言·arm开发·嵌入式硬件·fpga开发·开源·c#
qq_2529599721 小时前
DeepSeek Harness开源:把Agent Loop、工具与工作流全部变成插件
开源