搬砖笔记 (1) —— ECS 架构

打工人搬砖入门第一课 —— ECS 架构。

ECS (Entity-Component-System) 即实体-组件-系统,是一种软件架构模式,遵循组合优于继承 (Composition Over Inheritance) 原则,主要用于游戏开发。在 ECS 架构中,场景内的每个对象(如敌人、子弹、车辆等)都是一个实体 (Entity),每个实体都由一个或多个包含数据(状态)的组件 (Component) 组成,架构允许灵活地定义、添加和删除实体,避免了难以理解、维护和扩展的继承层次造成的模糊性问题。

组合优于继承

要了解 ECS 架构的概念以及为什么要使用这种模式,首先要从组合优于继承原则讲起。

面向对象程序设计 (OOP) 中的组合优于继承是指类应该倾向于通过组合,即包含实现所需功能的其他类的实例,而非从基类继承来实现多态行为和代码复用。

定义表示系统所具有行为的接口 (Interface) 是实现组合优于继承的基础,接口实现多态行为,而实现指定接口的类根据需要被添加到业务领域类 (Business Domain Classes) 中。通常情况下,业务领域类都是基类,无需任何继承,系统行为的替代实现通过另一个实现所需行为的接口来完成。包含对接口引用的类可以将对该接口实现的选择推迟到运行时。

以 C++ 为例,分别采用继承和组合的方式实现一个简单的系统,可以很容易地看出使用组合而非继承带来的好处。

继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Object {
public:
virtual void update() {}
virtual void draw() {}
virtual void collide(Object objects[]) {}
};

class Visible : public Object {
public:
virtual void draw() override {
// draw
}
};

class Movable : public Object {
public:
virtual void update() override {
// update
}
};

以上定义了系统的两个接口,此时,可定义实现对应接口的业务领域类,如:

  • Player:是 VisibleMovable,即可见且可移动
  • Building:是 Visible 但不是 Movable,即可见但不可移动

C++ 虽然支持多重继承,但十分复杂,且使用多重继承是一个具有很大风险的行为,可能导致钻石问题 (Diamond Problem)。

若要避免使用多重继承,则需要额外定义如 VisibleAndMovableVisibleButNotMovable 等这样的组合接口。但是,当接口很多时,此类组合将会导致大量重复的代码,难以维护和扩展。

组合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class VisibilityDelegate {
public:
virtual void draw() = 0;
};

class Visible : public VisibilityDelegate {
public:
virtual void draw() override {
// draw
}
};

class NotVisible : public VisibilityDelegate {
public:
virtual void draw() override {
// do nothing
}
};

class UpdateDelegate {
public:
virtual void update() = 0;
};

class Movable : public UpdateDelegate {
public:
virtual void update() override {
// update
}
};

class NotMovable : public UpdateDelegate {
public:
virtual void update() override {
// do nothing
}
};

利用以上定义的几个接口的组合,同样可以实现之前的系统:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Object {
public:
Object(VisibilityDelegate* v, UpdateDelegate* u) : v_(v), u_(u) {}
void draw() { v_->draw(); }
void update() { u_->update(); }

private:
VisibilityDelegate* v_;
UpdateDelegate* u_;
};

class Player {
public:
Player() : Object(new Visible(), new Movable()) {}
};

class Building {
public:
Building() : Object(new Visible(), new NotMovable()) {}
}

优点

  • 赋予设计更高的灵活性

  • 通过组件组合类比寻找类的共性并建立继承关系更自然、简单

  • 组合一个对象的能力比扩展一个对象本身更好

    It is better to compose what an object can do (HAS-A) than extend what it is (IS-A)

  • 可以适应未来需求的变化,避免继承模型在需求变化时需要对业务领域类进行彻底重组

  • 组合关系更加灵活,可在运行时改变;继承关系是静态的,许多语言需要重新编译

缺点

  • 继承模型中,派生类只需要实现(覆盖)于基类方法具有不同行为的方法;而 ECS 架构中,由单个组件提供的方法可能必须在派生类型中实现,即使只是转发方法

ECS 的基本概念

2007 年,制作 Operation Flashpoint: Dragon Rising 的团队尝试了 ECS 架构。此后,Adam Martin 写了一篇关于 ECS 设计的详细说明,包括核心术语和概念定义,特别提出了系统作为一等元素、实体作为 ID、组件作为原始数据、代码存储在系统而非组件或实体中等观点。

2015 年,苹果公司推出了用于 iOS、macOS 和 tvOS 的游戏开发框架 GameplayKit,其中包含了 ECS 架构的实现。

ECS 架构

ECS 架构如上图所示,下面分别介绍实体、组件和系统的概念。

实体 (Entity)

实体是一个抽象的概念,用来表示通用的对象,是一系列表示该实体所具有能力的组件的集合。在代码实现中,实体通常使用一个整型 ID 来表示,该实体所具有的所有组件都会被这个 ID 标记,且在运行过程中,可以动态地为实体添加或删除组件。

样例:

  • Player(Sprite, Position, Velocity, Health)
  • Tree(Sprite, Position)

组件 (Component)

组件是对象某一个特性的原始数据(状态),拥有某个组件即表示实体具有这个特性或能力。在代码中通常使用结构体、类或关联数组实现。

样例:

  • Position(x, y)
  • Velocity(x, y)
  • Health(value)

系统 (System)

系统是一个连续运行的、对拥有与该系统相同组件的每个实体执行全局操作的工具,通常只有行为(方法),不包含状态(数据)。

样例:

  • MoveSystem(Position, Velocity)
  • RenderSystem(Sprite, Position)

实战 —— EntityX

创建组件

1
2
3
4
5
6
struct Position {
Position(double x = 0, double y = 0) : x(x), y(y) {}

double x;
double y;
};

给实体添加组件

1
entity.assign<Position>(1.0, 2.0);

获取拥有特定组件的实体

函数式:

1
2
3
4
entities.each<Position, Direction>([](Entity entity, Position &position,
Direction &direction) {
// Do things with entity, position and direction.
});

迭代器方式:

1
2
3
4
5
ComponentHandle<Position> position;
ComponentHandle<Direction> direction;
for (Entity entity : entities.entities_with_components(position, direction)) {
// Do things with entity, position and direction.
}

获取实体的某个组件

1
2
3
4
ComponentHandle<Position> position = entity.component<Position>();
if (position) {
// Do stuff with position
}