GOC 架构
需要预先学习:
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。收益有三层:
- 跨模块稳定:没有运行时状态,同一类型在任何编译单元、任何 DLL 里都是同一个 ID;
- 未登记直接编译失败:忘了登记的新组件类型,
static_assert在编译期就把问题钉出来,而不是运行时悄悄查不到; - 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 架构 思想做局部特化的时机。



