from: http://elvisco.de/2013/09/entity-component-system/
(本文已发表在《程序员》杂志2013年10月刊,转载需经《程序员》杂志许可)
在2002年的GDC大会上,Scott Bilas做了一个题目叫做《A Data-Driven Game Object System》的演讲,他在自己的博客上说怀疑自己是第一个提出这个概念的人,但肯定是第一个对公众解说这个概念的人。
这个概念引起了游戏圈内广泛的讨论,至今已形成比较完整的理论,维基百科上称作Entity-component-system(ECS),或者更简便的称作Entity System。到今天,著名的游戏引擎Unity甚至完全基于ECS来构建。
然而国内社区似乎很少讨论ECS,我们常使用的Cocos2d游戏引擎默认也不支持(最近的2.1.4版本加入了简单支持,后面分析)ECS。本文 将详细探讨ECS的工作原理,优点及不足,以及它和游戏引擎的关系,还会简单分析Unity中ECS实现的方法,最后提供一个基于Cocos2d-x实现 ECS的完整的源代码实例。
- 面向对象在游戏设计中遇到的问题
ECS的出现起源于面向对象的架构风格在游戏设计中遇到了一些问题。在现代软件设计中,通常使用面向对象的思想来抽象各种实体,数据模型,业务逻 辑,它工作的很好,我们用易于理解的名词例如Car,People等类来描述软件中的各种概念,通过继承,多态来表述同类事物的不同特征和不同行为,如下 图:
图1. 《Ghost in the Sea》中部分类结构
然而这样的设计方式在游戏开发中却遇到了困难,例如在上面结构图中,船是不同于英雄的类,但是其实船也是可以产生资源的,它是否应该继承于“资源” 类呢?如果他们是相互独立的,意味着有部分重复代码;其次,如果设计需求中新加入一种敌人,它可以“远程”攻击轮船,用什么攻击呢,我们给它配一把 “枪”,我们有一些解决方法:
- 使用C++中的多继承,让新加的敌人同时继承于“枪”和“远程”。
- 从“远程”继承,复制“枪”的代码。
- 为了清晰表达一个新的类型,干脆新建一个类,复制两个类中的代码。
- 把代码移至父类中,不同的子类执行不同的方法。
我们看到,不管使用哪一种方法,都需要对程序进行不同程度的修改,修改的原因是为了代码重用,修改的结果却使得类的职责不再清晰。这就是面向对象在 处理交叉关系时遇到的问题,这不仅涉及到每次变更都要重构类的结构,更加重了理解和处理这些关系的负担。并且这会占据很多时间:在项目开始的时候,绞尽脑 汁把简单的数据库转化为面向对象世界复杂的关系,在每次需求变更的时候重构这种关系。
而在整个游戏开发过程中这样的变更非常频繁。每一次变更可能会影响到很多地方,对游戏的稳定性也是非常不利的。而应对这种频繁复杂的需求变更正是基于组件的架构最擅长的。
- 基于组件的架构风格
基于组件的架构设计风格核心思想是组合优于继承(prefer composition over inheritance),即是通过将各个相对独立的数据和逻辑组织成一个组件(而不是通过继承)来实现代码重用。这样我们可以通过不同的组件组合形成不 同特征的对象,这样形成一个扁平而不是树形的结构,如下图:
图2 基于组件的扁平的架构设计风格
从上图可以看到,一个GameObject本身包含很少的信息,它仅仅是由一些组件组成,这些组件的组合决定一个特定GameObject的特征和 行为。这样就可以应对不同的需求同时保持比较稳固的架构。如果新的需求加入一种新的数据和行为,我们就定义一个新的组件,如果新的需求具有不同的行为组 合,我们就为这类对象添加不同的组件组合即可,这对已有程序不会有什么影响。
基于组件的设计还使我们将精力集中在逻辑及数据本身,而不是绞尽脑汁去抽象各种类层次结构,继承关系。软件设计关心的是数据和行为,面向对象是为了 重用代码,简化设计的一种方法,它不是程序设计的规则和原则,它也有它的局限性。因此我们可以说基于组件的设计风格和面向对象的设计风格是两种比较独立的 方法,读者有必要区分这两个概念。
- Entity Component System中的概念
ECS是一种架构风格,所以它可以有不同的实现方式,限于篇幅,这里只介绍社区讨论和使用比较多的方法。传统的ECS架构分3部分,分别是 Entity,Component和System,以及一个用来管理Entity和Components对应关系的EntityManager,以下分别 介绍:
- Entity:Entity泛指一个游戏对象,它仅用来标识一个对象,除此之外不包含任何信息。它可以包含或者不包含UI,这些都由它所拥有的Components来决定,不同的组件组合构成了一个特定的游戏对象:
System:System表示Entity的一个行为,它用Component提供的数据,根据一定的逻辑更新Entity的状态,例如HealthSystem计算英雄的血量并绘制血条。在一个冒险游戏中,MoveSystem则会移动人物不断向指定方向前进:
EntityManager:EntityManager是Entity的数据库,它存储所有的Entity及每个Entity拥有的Component集合,System将从这里检索所有Entity及获取需要的Component
- 怎样使用Entity Component System
有了ECS的一些基本概念,那么首先来看一下在cocos2d-x中怎样使用它,然后再详述ECS的工作机制。现在假设需求是要为一个游戏对象添加 一个Sprite,并绘制血条。现在我们还不太清楚ECS的工作机制,那么我们试着根据Entity-Component-System的定义来思考我们 该怎样设计。
首先,我们要用一个特定的Entity来表示一个特定的游戏对象,这里假设我们现在要处理的是轮船,我们这样写:
Entity* ship = _entityManager->createEntity();
我想我们已经完成轮船的定义了,不是吗?还记得前面说过Entity仅用来代表一个游戏对象吗?除了一个用于区别自己的Id,再没有什么其他信息。
接下来,应该定义Component。因为Component代表数据,分析需求,我们需要什么数据呢,我觉得应该需要一个UI元素,以及表示血条 相关的信息。那么我们将定义两个Component:RenderComponent和HealthComponent,这里仅分析 HealthComponent,读者自行查看源代码关于RenderComponent:
我们定义了HP,Alive等Health相关的属性,然后重写父类的虚函数getComponentType(),它用来表示一个 Component的类型。在有些设计中使用RTTI中的typeId来表示Component的类型,这里用std::string来表示类型,因为会 频繁检索。
最后就是System了,这是处理Component数据并更新游戏对象状态的地方,也就是游戏中的逻辑,这里不再贴healthSystem.cpp的代码,读者自己参照源代码:
System是游戏的循环,它应该被mainLoop调用。从HealthSystem的源代码中可以看出,在每次update的时候,它首先从 Entity中查询所有具有HealthComponent数据的Entity对象,然后遍历每个Entity,如果当前血量小于0,则将alive设置 为false,最后它还检查该Entity是否包含RenderComponent数据,如果包含则将UI元素从屏幕移除,因为该对象已经死亡。
现在我们已经准备好所有需要的数据(Component),以及处理逻辑的算法(System)了,接下来我们让ECS系统工作起来,打开HelloWorldScene.cpp文件,找到以下部分:
分析上面的代码,我们创建了一个Entity对象,用来表示船。ship对象拥有UI表现,血量等相关信息,所以我们添加RenderComponent和HealthComponent到ship对象,这样ship对象就有了UI和血量相关的数据。
然后,我们在update中调用每个System的update方法,每个System在update中将根据自身行为对数据的需求,从 EntityManager中遍历所有Entity,满足条件的Entity将对其进行处理,处理什么呢,其实就是修改Component中的数据,这样 就实现了游戏循环。
也许你现在还是很迷惑,System到底应该怎样处理Entity呢,我们再用一个比喻来看一下ECS的工作原理。
- ECS是怎样做到数据驱动的
在StackExchange上有个叫Byte56的开发者将ECS比作锁系统,他把Entity比作一把钥匙,而每个Component是一把钥匙上的齿槽,拥有不同Component组合的Entity就形成一把不一样的钥匙:
图3 将Entity比作一把钥匙
而将System比作一把锁,如下图的MoveSystem对钥匙的定义,只要是同时拥有Position和Velocity齿槽的Entity就将会被MoveSystem处理。
图4 将System比作锁
与真实锁系统不同的是,这里System定义的锁和Entity定义的钥匙不是绝对匹配的,Entity只要包含System需要的齿槽即可,如图3表示的Entity也会被图4表示的System处理。
通过这样的机制,我们可以将游戏中的每一个行为抽象成一个独立的System,一个System封装了某一个方面的游戏逻辑单元,这个逻辑单元基本 上不可再拆分成更小的逻辑单元。而每个System需要特定的一些数据,这些数据由Component提供,满足某个System所需要的数据集合,即被 认为需要执行该System中的逻辑处理。
反过来,通过给Entity组合不同的Component,构成不同的“条件”,就自动给Entity附加上了一个不同的行为,这样就实现了数据驱动。
- 关于数据和逻辑的分离
ECS是一个数据和逻辑高度分离的很好的例子,Component仅定义纯数据,而System中不存储任何Entity的数据,最多包含一个循环 内的临时数据,由于系统中每个System只存在一个实例,它也约束着我们不能在System中存储游戏对象的数据,因此完全成为一个逻辑的封装。
数据和逻辑的分离从一定程度上降低了耦合,因为耦合的原因一般都是一个对象希望访问另一个对象中的数据,如果仅是访问一个纯算法,这是完全可以排除 这部分耦合的,就是因为包含了特定的数据,才使得两个类之间有直接的关联,因此如果将数据存储至一个公共的地方,不同的算法都从这里读取数据,这样就能排 除耦合,在ECS中每个System都是纯算法,相互之间没有什么耦合的部分。
图5 ECS中数据和逻辑完全分离
- ECS和游戏引擎的关系
通过对ECS的学习,我们明白它是一种架构风格,更具体一点,它指导我们应该怎样设计算法和使用数据,因此它是和绘制无关的,它需要和游戏引擎的绘制系统一起工作。
那么它到底需不需要引擎支持?确实有引擎如Unity(我们后面会分析)基于ECS系统来设计引擎,然而从ECS的概念来看,它基本上可以和游戏引 擎一起工作,而不需要引擎提供专门的支持,因为它仅包含行为和数据,与绘制无关。就像Box2D物理引擎,它包含的也仅仅是算法,它可以和大多数游戏引擎 例如Cocos2d一起工作。
值得注意的是,在实践ECS的时候,问的比较多的问题是关于输入(触摸,鼠标,重力感应等)应该怎样处理,是应该在System还是在 Component中获取系统事件。其实输入属于游戏引擎的基础设施,就像UI元素的树形结构,这些和游戏行为无关的事情不应该放入到ECS中去,这些都 应该转化成数据存储在Component中。
所以,不是所有代码都应该ECS化,ECS应该是和游戏引擎结合起来使用,例如元素的层级结构,深度,触摸响应,动画,地图等等都应该在引擎层面来 处理,然后将之转化为Component数据供System使用。记住,ECS只包含游戏行为和游戏数据,ECS帮助我们将这部分逻辑从引擎中抽取出来。
当然,ECS也可以被集成进游戏引擎中,它跟引擎之间就能形成了一个比较好的协作,它甚至能帮助引擎实现某些功能,我们来分析一下Unity引擎中的组件系统。
- Unity中的ECS分析
我们一直强调ECS是一种架构风格,它可以有不同的实现方式,Unity在组件系统的实现上较标准的ECS系统有几点不一样:
- 将System转化为实例,附加到每个GameObject中,而不是集中一个System处理所有GameObject。这样做的好处 是:System中的逻辑处理可以和UI结构相对应,标准的ECS系统是忽略UI结构的;其次是支持可视化设计,通过引擎本身提供一些标准的绘制相关的 Component就可以很好的支持设计器,开发者自定义Perfab的时候只是组合元素之间的父子关系,并修改这些标准的UI组件的属性,设计器可以应 付任何可视化设计。
- 因为System成为了GameObject的集合,而Component也是GameObject的集合,它们之间的区别就仅仅是一个处理逻 辑,一个存储数据,所以Unity就将这两者简化成一个东西:Component。这给引擎设计带来一致,简便,然而却给开发者留下一些模糊的概念,如果 学习Unity之初不熟悉ECS系统,则很难实现数据和逻辑的分离,数据和逻辑混在一个Component就带来组件之间频繁通信,因为一个组件需要访问 另一个组件中的数据。这个时候的表现往往是大量使用Message之类的,Unity支持向GameObject发送消息,这些消息会被 Component中对应的方法处理。
- 由于将System集合化,这也使得寻找Component的任务落到GameObject上,System的update需要由 GameObject自身来驱动,所以整个ECS也必须由引擎支持,相应地GameObject就需要在全局范围内根据name任意查找,否则整个系统就 工作不了,因为你无法找到Entity,无法和其他Entity之间交互。
图6 Unity中的组件系统
- Cocos2d-x引擎提供的组件支持
说Cocos2d-x引擎不支持组件系统其实是不准确的,在2.1.4版本中给CCNode加入了组件支持:
每个CCNode新包含一个m_pComponentContainer的容器,它存储每个CCNode所拥有的所有Component集合,然后 在CCNode的update循环中调用每个Component的update方法。这和Unity的设计思路是类似的,然而因为cocos2d-x缺乏 全局范围内检索游戏对象,使得引擎提供的组件系统使用起来有点困难。
通过前面的分析,我们知道标准的ECS不需要了解游戏元素的UI结构,Entity之间的交互全是通过Component数据来确定的,例如判断两 个Entity是否发生碰撞,首先ColliderSystem会从EntityManager检查所有包含ColliderComponent的 Entity,然后判断和修改transform相关的属性,它不需要和UI树打交道。
单纯一个Component融合了数据和行为,这不是数据驱动的方式。因此也就不存在EntityManager,因此Unity提供全局查找 GameObject的方法,而Cocos2d-x目前是不能全局查找CCNode,所以我们就只能通过parent去搜索UI树,这会在 Component中引入大量getParent,getChild之类的跟UI树关系紧密的代码,而一旦UI结构发生变化,则会影响这部分代码。同时数 据和行为混在一起对组件间消息传递的需求比较大,目前cocos2d-x中的组件也没有提供这样的机制。
所以cocos2d-x目前的组件系统适合做一些简单的算法方面的使用,不适宜用来构建整个系统,或者你也可以按ECS的方式,将数据抽取出来,建 立自己的EntityManager,同样可以做到数据驱动,读者可以自己去尝试。然而cocos2d正在朝着组件做一些努力,我们期待cocos2d- x 3.0能有高效简便的组件系统支持。
- Entity Component System总结
至此,我们应该对ECS有一定的认识了,它是一种软件架构风格,与面向对象中继承的方式相反,ECS通过组合的方式重用代码,它能实现行为和数据的 完全分离,从而降低耦合,并通过定义一个System需要满足的条件(Component构成的数据组合)来达到数据驱动。并且它几乎是可以和任何绘制引 擎一起工作的。
ECS有很多优点,比如数据驱动,游戏设计师在不太多依赖程序员的情况下就可以通过数据变更游戏行为(当然需要将Component的组合转化为读取外部配置文件),最重要的是,它可以非常灵活地满足频繁变更的需求。
当然ECS也有一些缺点,社区讨论最多的是每一帧频繁查询大量Entity,如果这里用到了一些RTTI的方法可能会比较明显的影响性能,而且 System的每次处理都需要判断太多的变量,因为System本身并不保存任何数据,它的算法的数据完全需要Component来提供,并且为了减少 System之间的耦合,会大量增加一些变量在Component中。
社区也讨论了很多优化方案,感兴趣的读者可以继续阅读相关信息。总之,Entity Component System作为一种优秀的架构思想,它能为你带来愉悦的开发体验,减少你大量的痛苦。