组件模式
Component Pattern
组件模式 (Component Pattern) 让一个实体横跨多个领域(输入、物理、图形、AI……),同时不让这些领域彼此耦合:每个领域的代码放进自己的组件类,实体本身退化为组件的容器。它是现代引擎实体模型的基础,代价是对象变成需要正确装配的一组对象,组件之间如何通信也成了要做的设计决策。
意图
让单个实体跨越多个领域,而不让这些领域互相耦合。(Game Programming Patterns — component)
机制
单个实体横跨多个领域。为了让各领域彼此隔离,每个领域的代码放在自己的组件类里,实体被简化为一个组件容器。(Game Programming Patterns — component)书中的例子是平台游戏主角 Bjørn:原本一个巨大的 update() 里混着输入、物理和图形代码,拆成 InputComponent、PhysicsComponent、GraphicsComponent 之后,GameObject 只负责持有它们并依次调用。(Game Programming Patterns — component)
书中强调的几个要点(Game Programming Patterns — component):
- 问题不只是类太大,更是耦合:像
if (collidingWithFloor() && getRenderState() != INVISIBLE) playSound(HIT_FLOOR);这样的代码,改它的人要同时懂物理、图形和声音。在多核上按领域拆线程(AI 一个核、声音一个核、渲染一个核)时,这种耦合还会招来死锁等并发 bug。 - 继承无法干净地复用:装饰物(可见不可碰)、道具(可见可碰)、区域(不可见可碰)。若
Prop继承Zone复用碰撞,就无法再继承Decoration复用渲染,否则陷入多重继承的“致命菱形 (Deadly Diamond)”;把一切塞进GameObject又浪费内存。用组件后,三种东西只是同一个GameObject配上不同组件:类从四个变成三个,没有重复代码,也没有多重继承。作者的比喻是:单体类像只能点套餐,组件像单点菜单。 - 把组件藏到接口后面:
Bjorn持有InputComponent*而不是具体类后,传入DemoInputComponent(由 AI 控制)就能实现主机常要求的“演示模式”,物理和图形完全不知道区别;代价是update()变成虚调用。再把物理、图形也抽象成接口,Bjorn就变成通用的GameObject,用createBjorn()这样的工厂函数组装出各种对象。 - 为什么位置和速度留在容器里:它们是几乎所有组件都要用的“跨领域”状态,不清楚该归哪个组件;而且它们给了组件一种不互相耦合的通信渠道。
下图表示拆分后的结构,以及三种组件通信方式(下一节详述)。
flowchart TB GO["GameObject:位置、速度(共享状态)"] --> IC[InputComponent] GO --> PC[PhysicsComponent] GO --> GC[GraphicsComponent] IC -. "1 改写容器状态:设置速度" .-> GO GC -. "2 直接引用:查询是否在地面" .-> PC PC -. "3 经容器广播消息:发生碰撞" .-> GO
图中三条虚线代表三种可以并存的通信方式:经容器共享状态、组件之间直接引用、经容器转发消息。
何时使用
满足任一条件时:一个类触及多个你希望彼此解耦的领域;一个类变得庞大难用;你想定义许多共享不同能力组合的对象,但继承无法精确地挑出想复用的部分。(Game Programming Patterns — component)
取舍与坑(Game Programming Patterns — component)
- 复杂度上升:每个概念上的“对象”变成一组必须实例化、初始化并正确连接的对象,组件间通信更难,内存占用也更难控制。在大型代码库中,解耦和复用可能值得;但要先确认没有在为不存在的问题做过度设计。
- 多一层间接:先拿到组件,再做事情。在性能关键的内层循环里,这种指针跟随可能导致性能变差。反过来,组件也常常能改善性能和缓存一致性,因为它让 数据局部性 更容易按 CPU 需要的顺序组织数据。
设计决策(Game Programming Patterns — component)
对象如何获得组件
| 方式 | 优点 | 缺点 |
|---|---|---|
| 对象自己创建组件 | 保证总是有需要的组件 | 难以重新配置,浪费了“重新组合组件得到新对象”的能力 |
| 外部代码提供组件 | 对象更灵活,最终可以成为通用容器;对象只认识组件接口 | 需要外部正确装配 |
组件之间如何通信(不互斥,实际常常混用)
| 方式 | 优点 | 缺点 | 适合 |
|---|---|---|---|
| 修改容器对象的状态 | 组件彼此完全不知道对方存在 | 共享信息都得上提到容器,污染容器并浪费内存;通信是隐式的,依赖组件处理顺序(先更新图形就会画出上一帧的位置) | 位置、尺寸这类每个对象都有的基础数据 |
| 直接互相引用 | 简单、快 | 两个组件紧耦合 | 关系紧密的一对:动画与渲染、输入与 AI、物理与碰撞 |
| 发送消息(经容器广播,容器即 GoF 的中介者 (Mediator);消息也可以排队,见 事件队列) | 兄弟组件解耦,唯一的耦合是消息值本身;容器保持简单,只是盲目转发 | 最复杂;发送者自己也会收到,要小心消息回环 | 不太重要、“发出去就不管”的通信,如碰撞时播放音效 |
Nystrom 建议先从简单方式开始,需要时再增加通信路径。(Game Programming Patterns — component)
与其他模式的关系
- 组件和 游戏循环与更新方法 中的游戏循环、更新方法一起,构成很多引擎核心的“三件套”。(Game Programming Patterns — update-method)
- 它和策略 (Strategy) 模式相似:都把部分行为委托给从属对象。区别是策略对象通常无状态(定义“如何做”),组件则常持有描述对象身份的状态;无状态的组件可以在多个容器之间共享,这时就更像策略了。(Game Programming Patterns — component)
- 子类沙盒的基类变成“大杂烩”时,可以把部分提供的操作拆成组件(见 子类沙盒);现代引擎用组件和类型对象来表达实体种类,而不是为每种怪物写一个类(见 原型模式)。
代码草图(C++)
按书中示例整理:最终的通用 GameObject、可替换的输入组件、工厂函数,以及消息广播方式的容器。
class InputComponent {
public:
virtual ~InputComponent() {}
virtual void update(GameObject& obj) = 0; // 书中此时参数仍写作 Bjorn&
};
class PlayerInputComponent : public InputComponent {
public:
virtual void update(GameObject& obj) {
switch (Controller::getJoystickDirection()) {
case DIR_LEFT: obj.velocity -= WALK_ACCELERATION; break;
case DIR_RIGHT: obj.velocity += WALK_ACCELERATION; break;
case DIR_NONE: break;
}
}
private:
static const int WALK_ACCELERATION = 1;
};
class DemoInputComponent : public InputComponent {
public:
virtual void update(GameObject& obj) { /* AI 自动控制 */ }
};
class GameObject {
public:
int velocity;
int x, y; // 跨领域的共享状态
GameObject(InputComponent* input, PhysicsComponent* physics, GraphicsComponent* graphics)
: input_(input), physics_(physics), graphics_(graphics) {}
void update(World& world, Graphics& graphics) {
input_->update(*this); // 顺序很重要:输入 → 物理 → 图形
physics_->update(*this, world);
graphics_->update(*this, graphics);
}
private:
InputComponent* input_;
PhysicsComponent* physics_;
GraphicsComponent* graphics_;
};
GameObject* createBjorn() { // 工厂方法:组装出 Bjørn
return new GameObject(new PlayerInputComponent(),
new BjornPhysicsComponent(),
new BjornGraphicsComponent());
}
// 消息方式:容器把消息广播给所有组件(包括发送者自己)
class Component {
public:
virtual ~Component() {}
virtual void receive(int message) = 0;
};
class ContainerObject {
public:
void send(int message) {
for (int i = 0; i < MAX_COMPONENTS; i++)
if (components_[i] != NULL) components_[i]->receive(message);
}
private:
static const int MAX_COMPONENTS = 10;
Component* components_[MAX_COMPONENTS];
};
UE 中的对应
- Actor 与 Actor 组件 → Gameplay 框架:Actor 是“组件的容器”,Actor 组件控制移动、渲染、位置、视角等(场景组件、静态网格组件、相机组件、弹簧臂组件……);Character 就是一个自带角色移动组件、骨骼网格组件和胶囊组件的 Pawn。这是组件模式在 UE 里最直接的体现。
- 外部代码提供组件 → Game Features 与模块化玩法:Game Feature 插件最常用的 Action 是 Add Components,把功能插件中的组件注入到现有 Actor 上。这对应上表中“外部代码提供组件”的选项,而且官方给出的好处正是避免无关功能之间意外的交互或依赖。
- 实体只是一个 ID → MassEntity 数据导向框架:在 MassEntity 中,实体 (Entity) 只是一个 ID 加一组片段 (Fragment),无状态的处理器批量处理片段。本章旁注提到,有些组件系统更进一步:实体只是一个数字 ID,各类组件放在各自的集合里,并记录自己属于哪个实体,即实体组件系统 (Entity Component System),详见数据局部性一章。MassEntity 正是这种做法,组件模式在这里与缓存友好的内存布局结合在了一起。(Game Programming Patterns — component)
GEA 的视角:从单体层次到组件(卷 II 17.2.1)
GEA 从引擎架构的角度给出了与本页相同的结论,并补充了几点细节:
- 问题来源:单体类层次难以理解和修改、无法表达多维分类(两栖车放不进"陆地/水上"的载具树)、多重继承带来致命菱形;不许多重继承时功能会冒泡到根类。GEA 以 Unreal 的
Actor为冒泡效应的典型例子,并列记录在 运行时对象模型架构 中,与 UE5 文档"Actor 是组件的容器"的说法对照。 - 混入类:有的团队允许"只从主层次继承一个父类 + 任意多个无基类的混入类"(如
MHealth、MCarryable),但 GEA 认为通常组合或聚合这些类比继承更好。 - 组件的所有权:两种做法:根类为每种可能的组件各持一个指针、由派生类构造函数创建所需组件、基类析构统一删除;或根类持有通用组件链表,根类不必知道有哪些组件类型,同类组件也可以有多个。GEA 认为两者没有明显优劣。这与本页"设计决策"中"对象如何获得组件"(对象自己创建 vs 外部代码提供)的讨论对应。
- 纯组件模型:去掉
GameObject,每个组件存对象 id、按 id 分组。难点是创建正确的组件组合(工厂或数据驱动)、组件间通信(例如把同一对象的组件连成环形链表)和对象间消息(只能事先知道发给哪个组件或多播给全部组件)。GEA 的建议是:纯组件模型可行但不是明显更好,选团队最熟悉、最适合当前游戏的架构。 - 组件与属性:GEA 指出以对象为中心的组件往往对应某个底层子系统的连接(渲染、动画、物理),以属性为中心的属性对应对象的某种特性(生命、外观、背包),但这个区别细微到多数时候无关紧要。
出处
- Game Programming Patterns — component
- Game Programming Patterns — component(全文版)
- Robert Nystrom《Game Programming Patterns》(2014) component 章,gameprogrammingpatterns.com/component.html
- Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.2.1 节