![](https://upload-images.jianshu.io/upload_images/1431816-9548d61116b8c93f.gif)

Entity–component–system (ECS) 是在游戏架构中广泛采用的一种架构。在游戏中,每个物体是一个 Entity(实体),比如,敌人、子弹、车辆等。每个实体具有一个或多个组件,赋予这个实体不同的行为或功能。所以,一个实体的行为可以在游戏运行时通过加减组件进行改变。ECS 经常和数据驱动模式一起使用。

游戏行业中设计模式的演变

设计模式产生的动机,往往源于尝试解决一些存在的问题。游戏开发领域技术架构的宏观目标,大体包括以下目标:

  • 适合快速迭代。无论是上线前的敏捷开发流程还是上线后根据市场反馈的调整,迭代都是基本需求。
  • 易于保证产品质量。良好的架构,可以降低 Bug 和 Crash 出现概率。
  • 开发效率。重要性不必多说。即使是更重视游戏质量的公司,越高的开发效率也有助于更短的时间打造出质量更高的游戏。
  • 运行效率。大部分游戏对实时响应和运行流畅度都有很高的要求,同时游戏中又存在大量吃性能的模块(比如渲染、物理、AI 等)。
  • 协作扩展性。能够在开发团队扩张时尽可能无痛,同时方便支持美术、策划、音效等非程序同事的开发需求。

不适合这个版本的上个世纪开发模式:

注重于实现相关算法、功能和逻辑,代码只要能实现功能就行,怎么直观怎么来。比如

1
2
3
4
5
6
class Player {
int hp;
Model *model;
void move();
void attack();
};

类似这样完全没有或很少架构设计的代码,在项目规模增大后,很快变得臃肿、难以扩展和维护。
为了实现代码复用这一终极理想。有人提出可以借鉴他山之石——GoF 提出的设计模式,来提高代码的可维护性和可扩展性。例如曾经在国内风靡一时的游戏引擎 OGRE

然而,历史无情的车轮已经将其淘汰。同时也告诉了我们:

  • 设计模式的 六大原则 大部分仍值得遵循。
  • 基于 Java 的设计模式,未必适合其它语言和领域。
  • C++ 是游戏开发领域最主要的语言,可以 OOP 但并不那么 OO,比如缺少语言层面纯粹的 interface,也缺少 GC、反射等特性。照抄 Java 的设计模式未免有些东施尿频,而且难以实现 C++ 所推崇的零代价抽象。(template 笑而不语)
  • 局部使用 OOP 设计模式来实现模块,并暴露简单接口,是可以起到提升代码质量和逼格的效果。然而在架构层面滥用,往往只是把逻辑中的复杂度转移到架构复杂度上。
  • 滥用设计模式导致的复杂架构,并不对可读性和可维护性有帮助。比如原本 C style 只要一个文件顺序读下来就能了解清楚的模块,滥用设计模式的 OOP 实现,阅读代码时有可能需要在十几个文件中来回跳转,还需要人脑去正确保证阅读代码的上下文…
  • 过多的抽象导致过多的中间层次,却只是把耦合一层一层传递。直到最后结合反射 + IoC 框架 + 数据驱动,才算有了靠谱的解决方案。然而一提到反射,C++ 表示我的蛋蛋有点疼。

那么,有没有办法简化和沉淀出游戏开发领域较通用的模式?

现代 Entity Component System

  • Entity:代表游戏中的实体,是 Component 的容器。本身并无数据和逻辑。
  • Component:代表实体 “有什么”,一个或多个 Component 组成了游戏中的逻辑实体。只有数据,不涉及逻辑。
  • System:对 Component 集中进行逻辑操作的部分。一个 System 可以操作一类或多类 Component。同一个 Component 在不同的 System 中,可以关联不同的逻辑。

近年来以 ECS 为基础架构逐渐成为国际游戏开发领域的主流趋势,其中以「守望先锋」最为著名。

采用 ECS 的范式进行开发,思路上跟传统的开发模式有较大的差别:

  • Entity 是个抽象的概念,并不直接映射为具体的事物:比如可以不存在 Player 类,而是由多个相关 Component 所组成的 Entity 代表了 Player。如 Entity {MovementComponent, RenderComponent, StateMachineComponent, …} 等等。
  • 行为通过对 Component 实施操作来表达。比如简单重力系统的实现,可以遍历所有 Movement Component 实施位移,而不是遍历所有 玩家、怪物、场景物件,或者它们统一的基类。
  • 剥离数据和行为,数据存储于 Component 中,而 Component 的相关行为,和涉及多个 Component 的交互和耦合,由 System 进行实施。

简单的例子:

重复一下区别:

  1. OOP 的核心思想是:我是什么——我是一个角色对象,我是一个子弹对象。
  2. ECS 的核心思想是:我有什么——我有 render,我有 move,我有 motion。

就拿子弹和角色来说,如果我们是一个很简单的射击游戏,例如 FC 上的坦克大战 Battle City

![](https://upload.wikimedia.org/wikipedia/zh/a/ab/Battle_City_video_game.jpg)

如果用 OOP 的方式做,它里面有角色(坦克)对象和子弹对象,伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class tank {
// 这里仅说一些有代表性的,就不细节设计了
public tank ()
public Vector2 move (direction) // 有一个 move 方法来移动坦克,在这个方法里
// 面,我们根据面向、速度等因素来让坦克的坐标发生位移,同时我们让坦克的动作发生了
// 变化。注意:我们把坦克的渲染其实也放在了这里,所谓的渲染包括了移动时候播放的动画等。
public bullet fire () // 我们通过这个来让坦克开火,产生子弹
public void kill () // 我们通过这个来让坦克在 HP 小于等于 0 的时候死亡。
}

class bullet {
public bullet () {}
public Vector2 move (direction) // 子弹也会根据方向移动,这毫无疑问。注意,
// 我们把子弹的渲染也放在了这里,所谓渲染包括了子弹移动的时候贴出他的位置。
public void hit () // 子弹命中了什么东西之后要调用这个,用来消除子弹。
}

我相信不用写 game,大多人也知道这个 game 要怎么写,毕竟这么简单的游戏。

那么我们来看看 ECS 是怎么做这个的?伪代码

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
moveComponent {
enum direction // 移动方向,不做进一步解释了
float moveSpeed // 移动速度,我想你也明白
}

positionComponent {
float x; // 位于地图的坐标,不解释了
float y;
}

function moveSystem (entities) { // 这个 system 只关心带有 positionComponent 和
//moveComponent 的 entity,所以 entities 里面的所有 entity 都必然有个 moveComponent
// 和 positionComponent
for (entity in entities) {
// 这里就不详细写了, 执行的事情就是根据 moveComponent 中的 direction 和
// moveSpeed, 为 positionComponent 中的 x, y 重新赋值。
}
}

renderComponent {
string resource; // 贴图的信息,可以是资源名,等等,老做游戏了你应该理解这个
}

function renderSystem (entities) {
// 这个系统的工作,就是根据自己关心的带有 positionComponent 和 renderComponent
// 的对象,来进行渲染工作。
}

collisionComponent {
rect body; //FC 的坦克大战中,所有的子弹和坦克,其实都是矩形的。
int side; // 可以理解为阵营,同一阵营的互相忽略碰撞,当然写在这里面并不是最好的
// 方案,具体要看需求,目前的需求下这还算凑合。
hitEntities : array<entity>; // 用于记录碰撞到的 entity
}

tankComponent {
// 证明这是一个 tank,并且记录坦克需要的数据
int hp
}

bulletComponent {
// 证明这是一个子弹,并记录子弹需要的信息
int power
bool hitSomeThing
}

function collisionSystem (entities) {
// 这个 system 关心的是 collisionComponent 和 positionComponent,他的工作就是
// 根据 position 和 collisionComponent.rect 来判断 2 个 entity 是否碰撞,如果碰
// 撞,则将彼此加入到对方的 collisionComponent.hitEntities 数组中去。
}

function damageSystem (entities) {
// 这是一个很有意思的 system,他只关心带有 collisionComponent,然后会根据
//collisionComponent.hitEntities 中的 entity 提供的信息配合被捕捉的 entity 的
// 其他 component(比如 tankComponent 和 bulletComponent)进行特殊处理。
// 这个 system 的工作之一:如果这个 entity 并没有 tankComponent,同时还没有
// bulletComponent,for 循环执行 continue,检查下一个 entity。
// 如果这个 entity 拥有 tankComponent,证明这是坦克,则遍历
// collisionComponent.hitEntities,根据其中带有 bulletComponent 的 entity
// 进行处理,你可以同时销毁他们全部,并且都对这个坦克进行伤害,也可以根据被认为是
// 子弹(带有 bulletComponent 的 entity)的个数特殊处理,比如被 3 个子弹命中则 hp 提
// 高 10000 等,根据设计需求来实现。
// 值得注意的是:即使 entity.tankComponent.hp<=0,也不由我来进行下一步操作,
// 我就是一个 damageSystem,我负责的就是 damage(造成伤害),造成伤害的后果,管我鸟事儿。
}

function tankDestroySystem (entities) {
// 我只关心带有 tankComponent 的 entity,我的任务是如果
// entity.tankComponent.hp<=0,则将他们杀死。
}

function bulletDestroySystem (entities) {
// 和 tankDestroySystem 差不多,但我只负责对
// bulletComponent.hitSomeThing==true 的对象进行 dispose()
}

这就是一个典型的 ECS 的游戏写法,他没有坦克对象,子弹对象,但是他有坦克和子弹

1
2
3
4
5
6
7
8
9
tankEntity = entities.new ()
.add (new positionComponent (x, y))
.add (new renderComponent (xxxx))
.add (new collisionComponent (rect (x, x, x, x), y)
.add (new tankComponent (x)) bulletEntity = entities.new ()
.add (new positionComponent (x, y))
.add (new renderComponent (xxxx))
.add (new collisionComponent (rect (x, x, x, x), y)
.add (new bulletComponent (x))

你会发现,子弹和坦克唯一的区别就是一个是子弹,一个是坦克。你可能要问,为什么没有 moveComponent?因为 moveComponent 只有在移动的时候才需要添加上去,当移动完毕,就可以把它移除掉,这对于效率来说,还算是一种优化。

到这里,我们是不是可以思考另外一个问题——在一个 3 岁小孩子眼里,画出来的羊和活着的羊有什么区别?区别就是:活着的羊会动,他有 motionComponent 和 moveComponent。当然话题收回来,现在,我们发现游戏中还没有设计地形,对,没有地形玩什么?

OOP 方式:

1
2
3
4
5
6
7
8
9
class ObstacleGrid {
// tilebased 游戏,地图格当然不能没有,当然这种游戏需要的只是会阻挡的地形格子信息而已。
string resource; // 贴图资源
bool breakable; // 能否被子弹击穿
Vector2 position; // 位置
//.....
public void
break () // 被子弹销毁了
}

看起来加一个对象不复杂,但是当地图上有了这些 obstacleGrids 之后,你要在 game 中 coding 什么呢?很多,比如坦克移动的时候,你不能穿越他,再比如子弹命中他的时候…… 等等等等。

ECS 方式,我们要做的事情很清晰:

1
2
3
4
5
6
7
8
9
10
11
wallComponent {
// 证明我是阻挡地形,别在意名字的细节……
bool breakable;
}

function obstacleSystem (entities) {
// 这个 system 关心的,只有带有 wallComponent 和 collisionComponent 的 entity,
// 根据 collisionComponent.hitEntities 进行对应的处理,相信你动动脑子能想明白。
// 这里有一个独特与 oop 的地方,如果我(墙壁 entity)阻挡了坦克或者子弹,我要做的是
// 对对方 entity.remove(moveComponent),导致对方不能发生移动。
}

阻挡地形:

1
2
3
4
5
obstacleGrid = entities.new ()
.add (new positionComponent (x, y))
.add (new renderComponent (xxxx))
.add (new collisionComponent (rect (x, x, x, x), y)
.add (new wallComponent (x))

看到这里,发现为啥他和子弹和坦克如此接近?是不是能突然冒出一个有意思的想法?如果产品一拍脑门说,我的阻挡地形也会移动!是不是 OOP 很懵逼?但 ECS 就很好处理了?

##ECS 对游戏开发领域的意义:

  • 模式简单。如果还是觉得复杂,推荐看看 GoF 的《设计模式》。
  • 概念统一。不再需要庞大臃肿的 OOP 继承体系和大量中间抽象,有助于迅速把握系统全貌。同时,统一的概念也有利于实现 ** 数据驱动 **。
  • 结构清晰。Component 即数据,System 即行为。Component 扁平的表达有助于实现 Component 间的正交。而封装数据和行为的做法,不仔细设计就会导致 Component 越来越臃肿。
  • 容易组合,高度复用。Component 具有高度可插拔、可复用的特性。而 System 主要关心的是 Component 而不是 Entity,通过 Component 来组装新的 Entity,对 System 来说是无痛的。
  • 扩展性强。增加 Component 和 System,不需要对原有代码框架进行改动。
  • 利于实现面向数据编程(DOP。对于游戏开发领域来说,面向数据编程是个很重要的思路。天然亲和数据驱动的开发模式,有助于实现以编辑器为核心的工作流程。
  • 性能更好优化。接上条,相比 OOP 来说,DOP 有更大的性能优化空间。

Unity 中的 Entity–component 模式

经过上次用 Unity 制作打箱子的案例,大家应该知道了 Unity 的 Entity–component 模式是怎么回事。(在 Unity3D 中,Entity 叫 GameObject)。

其优点:

  • 组件复用。体现了 ECS 的基本思想之一,Entity 由 Component 组成,而不是具体逻辑对象。设计得好的 Component 是可以高度复用的。
  • 数据驱动。场景创建、游戏实体创建,主要源于数据而不是硬编码。以此为基础,引擎实现了以编辑器为中心的开发模式。
  • 编辑器为中心。用户可在编辑器中可视化地编辑和配置 Entity 和 Component 的关系,修改属性和配置数据。在有成熟 Component 集合的情况下,新的关卡和玩法的开发,都可以完全不需要改动代码,由策划通过编辑器实现。

看起来,Unity 已经在很大程度上解决了游戏设计领域通用模式的问题。然而,其 Entity–component 模式仍然存在一些问题:Component 仍然延续了一些 OOP 的思路。比如:

  • Component 是数据和行为的封装。虽然此概念容易导致的问题可以通过其它方式避免,但以不加思考照着最自然的方式去做,往往会造成 Component 后期的膨胀。比如 Component 需要支持不同的行为就定义了不同的函数和相关变量;Component 之间有互相依赖的话逻辑该写在哪个 Component 中;多个 Component 逻辑上互相依赖之后,就难以实现单个 Component 级别的复用,最后的引用链有可能都涉及了代码库中大部分 Component 等等。
  • Component 是支持多态的引用语义。这意味着单个 Component 需要单独在堆上分配,难以实现对同类型多个 Component 进行数据局部性友好的存储方式。这样的存储方式好处在于,批量处理可以减少 cache miss 和内存换页的情况。

因此可以说 Unity 中 Entity–component 模式并为脱离 OO 思想。


—— 本文结束 ——

本作品采用采用[知识共享署名 4.0 国际许可协议](https://creativecommons.org/licenses/by/4.0/deed.zh)进行许可。