需要预先学习:


1. GOC 架构

用继承树表达游戏对象,很快会撞上功能组合的墙——ECS 架构 开头详细描述了这个困境:继承表达 is-a(是一个)关系,而游戏实体需要 has-a(有一个)关系,一辆车有发动机、有轮子,而不是"是一类特殊的可驾驶物"。

对这条原则最直接的落地是 GOC 架构(GameObject-Component,游戏对象-组件):GameObject 是一个轻量的容器,本身只有身份与层级信息;所有功能——变换、网格渲染、刚体、光源、音源——做成一个个 Component,按需挂到 GameObject 上。想要一辆会飞的车?给同一个对象把 MeshRenderer、RigidBody、FlyingControl 都挂上即可,不需要任何新类型。

这套思路是商业引擎的主流选择:Unity 的 GameObject + MonoBehaviour、Unreal 的 Actor + Component、Godot 的 Node,内核都是 GOC。本篇以 ID 引擎的实现为主线,讲清它的每个零件,最后与 ECS 架构 对照。


2. 三个角色:GameObject、Component、Scene

角色 职责 持有什么
GameObject 身份 + 组件容器 + 父子层级 名字、ID、组件列表、父子关系
Component 一个功能单元(数据 + 行为) 自己那一方面的状态与逻辑
Scene 创建 / 销毁 / 遍历所有 GameObject ID → GameObject 的映射

与 ECS 架构 的角色对照一眼见分晓:

GOC ECS
实体 GameObject:持有组件的对象 Entity:纯 ID
组件 数据 + 行为(有 on_update 虚函数) 纯数据
逻辑 组件自己更新自己 独立的 System 批量处理数据

GOC 与 ECS 最本质的区别就一句话:GOC 的组件是有行为的对象。逻辑不是由某个系统集中遍历数据,而是分散在每个组件的 on_update 里,由 GameObject 每帧逐个分发。


3. Component 基类:生命周期与激活

ID 引擎的 Component 是一个抽象基类,定义所有组件共享的骨架:

// Component.hpp(精简)
class ID_API Component : public SerializableBase
{
public:
    using TypeID = uint32_t;

    Component() { }
    virtual ~Component() = default;

    // 生命周期:子类必须显式调用父类的 on_attach()
    virtual void on_attach(GameObject* owner) { m_owner = owner; }
    virtual void on_detach() { }
    virtual void on_update(Timestep ts) { }
    virtual void on_event(Event& event) { }

    GameObject* get_owner() const { return m_owner; }

    bool is_active() const { return m_is_active; }
    void make_inactive() { m_is_active = false; }

    // 基类无条件激活;依赖外部资源的组件应重载此函数做校验
    virtual void make_active() { m_is_active = true; }

    // 组件默认允许在同一 GameObject 上挂载多个实例
    static constexpr bool s_allow_multiple = true;

    virtual TypeID get_type_id() const = 0;
    virtual std::string get_component_type_name() const = 0;

protected:
    GameObject* m_owner = nullptr;   // 组件所属的 GameObject
    Component*  m_next = nullptr;    // 同类型组件链表的下一个
    bool        m_is_active = false; // 新组件默认不激活
};

几个设计点:

  • 生命周期挂钩:on_attach(挂上时)与 on_detach(析构前)负责资源绑定与解绑;on_update 与 on_event 是每帧入口;
  • 激活是两态的且默认关闭:新组件 m_is_active = false,必须显式 make_active()。基类的实现是无条件激活,但依赖外部资源的组件可以重载它——MeshRendererComponent 在网格或材质无效时拒绝激活并记日志,调用方用 is_active() 确认结果。坏资源不会把引擎带崩,只是这个组件沉默地不参与更新;
  • s_allow_multiple:默认允许同类型挂多个(一个对象挂两个光源很正常),TransformComponent 这类"每对象恰好一个"的组件把它设为 false——单例性由类型自己声明,而不是靠运行时检查。

4. TypeID:编译期的类型标识

get_component<T>() 要在组件列表里找到类型 T 的组件,需要"类型 → 标识"的映射。常见的做法是运行时静态计数器(比如 CRTP 基类里 static inline int s_id = ++counter),但它在模块化引擎里有个隐蔽的坑:每个 DLL 都有一份自己的静态计数器,同一个类型在引擎 DLL 和应用 EXE 里可能拿到不同的 ID,跨模块传组件指针时类型判断直接串台。

ID 引擎的方案是把类型登记表写成编译期常量:

// Component.hpp(节选)
namespace detail
{
    // 编译期计算类型 T 在类型列表中的位置(从 1 开始)
    template<typename T, typename... Ts>
    consteval std::size_t type_index()
    {
        std::size_t idx = 0;
        std::size_t i   = 0;
        ((++i, (std::is_same_v<T, Ts> ? (idx = i) : 0)), ...);
        return idx;   // 0 表示 T 不在列表中(未登记)
    }
}

class ID_API Component
{
public:
    template<typename ComponentType>
    static constexpr TypeID get_static_type_id()
    {
        static_assert(std::is_base_of<Component, ComponentType>::value,
                      "传入的类型必须是 Component 的子类");
        constexpr std::size_t idx = detail::type_index<ComponentType,
            TransformComponent, MeshRendererComponent, LightComponent,
            RigidBodyComponent, AudioSourceComponent, AudioListenerComponent>();
        static_assert(idx != 0,
            "组件类型未登记:请在前向声明区添加该类声明,并追加到类型列表中");
        return static_cast<TypeID>(idx);
    }
};

类型列表是显式写死的一个序列,consteval(编译期求值,见 模版基础)算出类型在序列中的位置作为 ID。收益有三层:

  1. 跨模块稳定:没有运行时状态,同一类型在任何编译单元、任何 DLL 里都是同一个 ID;
  2. 未登记直接编译失败:忘了登记的新组件类型,static_assert 在编译期就把问题钉出来,而不是运行时悄悄查不到;
  3. ID 是连续小整数:可以直接当数组下标用,后面 unordered_map 的键也在编译期确定。

5. GameObject:组件的容器

GameObject 存两套东西——一套面向生命周期,一套面向查询:

// GameObject.hpp(节选)
class ID_API GameObject
{
public:
    using ID = uint32_t;
    static constexpr ID INVALID_ID = static_cast<ID>(-1);

    template<typename ComponentType, typename... Args>
    ComponentType& add_component(Args&&... args);

    template<typename ComponentType>
    ComponentType* get_component();

    template<typename ComponentType>
    void remove_component();

    void on_update(Timestep ts);
    void on_event(Event& event);

private:
    Scene*      m_scene = nullptr;
    ID          m_id = INVALID_ID;
    std::string m_name;
    bool        m_is_active = false;

    using ComponentPtr = std::unique_ptr<Component>;
    std::vector<ComponentPtr> m_components;                        // 拥有 + 遍历
    std::unordered_map<Component::TypeID, Component*> m_component_index;  // O(1) 查找

    ID              m_parent_id = INVALID_ID;
    std::vector<ID> m_children;
};

m_components 是 unique_ptr 数组(见 智能指针与RAII),负责所有权与顺序遍历;m_component_index 是类型到组件的索引,负责 O(1) 查找。同一个组件在两处各有一个入口,但对象只有一份、只销毁一次——这就是同类型多实例的关键。

5.1 add_component:登记与前置组件

// GameObject.hpp(节选)
template<typename ComponentType, typename... Args>
ComponentType& GameObject::add_component(Args&&... args)
{
    static_assert(std::is_base_of<Component, ComponentType>::value,
                  "传入的类型必须是 Component 的子类");
    auto component = std::make_unique<ComponentType>(std::forward<Args>(args)...);

    // 单例组件:已存在同类型组件则直接返回,不再挂载
    Component::TypeID type_id = Component::get_static_type_id<ComponentType>();
    if constexpr (!ComponentType::s_allow_multiple)
    {
        auto it = m_component_index.find(type_id);
        if (it != m_component_index.end())
            return *static_cast<ComponentType*>(it->second);
    }

    // 前置组件:挂 RigidBody 等组件前自动补一个 Transform
    if constexpr (std::same_as<ComponentType, RigidBodyComponent> ||
                  std::same_as<ComponentType, AudioSourceComponent> ||
                  std::same_as<ComponentType, MeshRendererComponent>)
    {
        if (!has_component<TransformComponent>())
            add_component<TransformComponent>().make_active();
    }

    component->on_attach(this);
    ComponentType& ref = *component;

    // 链表头插:新组件 next 指向旧头,自己成为新头
    component->set_next(m_component_index[type_id]);
    m_component_index[type_id] = component.get();

    m_components.push_back(std::move(component));
    return ref;
}

两处值得注意:

  • 单例组件的幂等性:对 s_allow_multiple == false 的类型重复 add_component,返回已有的那份,调用方无需先检查再添加;
  • 前置组件自动补齐:RigidBody、AudioSource、MeshRenderer 都离不开 Transform(物理要位置、声音要方位、渲染要模型矩阵),与其让每个调用方记得先挂 Transform,不如挂载时自动补上。依赖在挂载点声明,比散落在各系统的运行时判空更早暴露问题。

5.2 同类型多实例:链表

m_component_index[type_id] 存的不是"组件指针",而是该类型链表的表头——同类型的多个组件用 m_next 串成链,新组件头插。get_component<T>() 返回链头(第一个该类型组件),remove_component<T>() 让链头出链。vector 保所有权与稳定遍历,链表保类型内聚,索引表保查找速度,三者各司其职。

5.3 on_update 与 on_event 的分发

// GameObject.cpp(节选)
void GameObject::on_update(Timestep ts)
{
    if (!m_is_active) return;

    for (auto& component : m_components)
    {
        // 未激活的组件不参与更新
        if (component && component->is_active())
            component->on_update(ts);
    }
}

void GameObject::on_event(Event& event)
{
    if (!m_is_active) return;

    for (auto& component : m_components)
    {
        if (event.is_handled()) break;   // 事件已被处理,不再继续传播
        if (component && component->is_active())
            component->on_event(event);
    }
}

两层过滤:对象级 m_is_active(整个对象隐藏)与组件级 is_active(单个组件沉默)。事件分发比更新多一条短路——某个组件处理完事件后 is_handled() 为真,后面的组件不再收到,传播语义与 UI 事件冒泡一致。


6. 父子层级与变换

GameObject 之间可以搭父子层级:set_parent 同时维护自己身上的 m_parent_id 与父对象的 m_children。注意所有引用存的都是 ID 而不是指针——get_parent() 每次经 Scene 解析 ID 取对象。对象销毁后 ID 失效,解析失败可以报告错误,而不是拿着悬空指针继续跑。这与 ECS 架构 里"Entity 用世代号防 ID 悬空"是同一个思想:关系走 ID,不走内存地址。

层级的主要服务对象是 TransformComponent——子物体的世界矩阵 = 父物体的世界矩阵 × 自己的本地矩阵(见 变换)。深层级下每帧全量重算世界矩阵是浪费,ID 引擎用脏标记 + 懒计算规避:

// TransformComponent.hpp(节选)
class ID_API TransformComponent : public Component
{
public:
    // 控制一个 GameObject 只有一个 TransformComponent
    static constexpr bool s_allow_multiple = false;

    void set_position(const Pos3& position)
    {
        m_pose.position = position;
        make_dirty();                    // 只标脏,不立即计算
    }

    Mat4 get_model_matrix();             // 取本地矩阵 T * R * S,顺便清脏标记
    const Mat4& get_world_matrix() const; // 世界矩阵:带缓存,合并父级矩阵

private:
    ComponentPose m_pose;                          // 位置 + 朝向
    Vec3 m_scale = Vec3(1.0f, 1.0f, 1.0f);

    mutable bool m_dirty = true;                   // 本地矩阵脏标记
    mutable Mat4  m_model = Math::get_identity_mat4();

    mutable bool m_world_dirty = true;             // 世界矩阵脏标记
    mutable Mat4  m_world_cache = Math::get_identity_mat4();

    void make_dirty() const;                       // 标脏,并向子层级传播世界矩阵脏标记
};

读写路径完全不对称:写只标脏(set_position、translate 等全部如此,代价近乎为零),读时才算——get_model_matrix() 发现脏标记才重算本地矩阵;世界矩阵的脏标记由父向子传播,父动了、子没动,子的缓存失效但不必马上重算,等谁要读谁付钱。静止不动的物体整帧零矩阵乘法。


7. 序列化与 ComponentFactory

场景要能保存成文件、再从文件恢复。GameObject::serialize 把自己写成 JSON 树:名字、激活状态、组件数组、子对象数组(递归)。反方向难在一点:从 JSON 里读出一个字符串 "MeshRendererComponent",怎么变出一个活的对象?——反序列化时 C++ 的类型信息早已被擦除。

答案是把"名字 → 构造函数"做成一张注册表,这就是 ComponentFactory:

// ComponentFactory.hpp(精简)
class ID_API ComponentFactory
{
public:
    using ComponentCreator = std::function<std::unique_ptr<Component>()>;

    static bool register_component(const std::string& name, ComponentCreator creator);
    static std::unique_ptr<Component> create(const std::string& name);

private:
    // Meyers Singleton:函数内静态变量,首次调用时才构造
    static std::unordered_map<std::string, ComponentCreator>& get_registry();
};

// 每个组件的 .cpp 里用宏完成注册
#define ID_REGISTER_COMPONENT(ComponentType, TypeName)              \
    static bool _registered_##ComponentType =                       \
        ID::ComponentFactory::register_component(TypeName,          \
            []()-> std::unique_ptr<ID::Component>                   \
            {                                                       \
                return std::make_unique<ComponentType>();           \
            })
  • 每个组件在自己源文件里自注册:ID_REGISTER_COMPONENT(RigidBodyComponent, "RigidBodyComponent") 一行,新增组件不需要改动任何中心化的清单;
  • Meyers Singleton:注册表是函数内的静态变量,首次调用时才构造,绕开跨翻译单元静态初始化顺序不确定的问题——注册发生在全局构造期,普通的全局 map 可能还没生出来。

反序列化的流程串起来就是:读 "type" 字符串 → 工厂 create 出空组件 → 组件自己 deserialize 填数据 → on_attach 挂上 → 写入索引表。组件的 get_component_type_name() 必须与注册名一致,序列化与反序列化因此闭环。


8. GOC 的代价

GOC 换来了开发效率与灵活性,代价在 ECS 架构 里已经系统分析过,这里对着 GOC 的实现点一遍名:

  • 内存布局散:每个组件一次堆分配,对象之间靠 unique_ptr 与链表指针连接。遍历一个对象的组件就是跳一串指针,缓存行利用率低——这正是 ECS 架构 里 AoS 与 SoA 对比的问题,GOC 无法通过存储层修复,因为组件本身是对象,数据和行为绑在一起;
  • 逻辑分散,并行难:更新逻辑藏在每个组件的 on_update 里,引擎拿到的是"一堆会自己动的对象",没有集中的数据依赖声明可供调度器分析(ECS 的 System 靠声明读写集合获得自动并行);
  • 组件间耦合悄悄滋生:组件 A 在 on_update 里 get_component<B>() 拿到 B 直接操作——依赖不出现在类型系统里,只出现在函数体里。小项目无感,组件多了以后"谁在改谁"的图景会失控;
  • 每对象每帧一层虚调用:单个开销微小,万级同质对象(弹幕、粒子)叠加起来就不再是噪音。

但换一个角度:绝大多数游戏的场景物体是百到千级、行为各异、编辑器里逐个调的——这个规模下 GOC 的上述代价几乎测不出来,而"Inspector 面板 = 组件列表"的直观性、逻辑就地可见的调试体验,每天都在还本。Unity 至今没有把主架构换成 ECS(DOTS 是并行的可选栈),说明这笔账在工程上是划算的。


9. 总结:继承 → GOC → ECS

继承树 GOC ECS
组合能力 差(挖树、组合爆炸) 好(挂组件即组合) 好(配组件即组合)
数据与逻辑 绑定在类里 绑定在组件里 分离(组件纯数据、系统纯逻辑)
内存布局 对象数组 指针网 连续数组(SoA)
批量性能 差 中 好
开发直观性 直观 直观 间接(要理解查询与调度)
典型规模 玩具项目 百~千级对象 万~百万级同质实体

三者的演进不是替代关系,而是同一根轴上的三档:继承把共性放上、特性放下;GOC 把特性拆成可插拔的零件;ECS 再把零件拆成数据与逻辑两半。ID 引擎当前采用 GOC——在这个学习阶段的场景规模下,它以最小的实现复杂度提供了完整的组合能力;如果未来要做大规模同质实体(植被、人群、弹幕),那正是引入 ECS 架构 思想做局部特化的时机。