关于程序员:漫谈EntityComponentSystem

3次阅读

共计 5715 个字符,预计需要花费 15 分钟才能阅读完成。

原文链接

简介

对于很多人来说,ECS 只是一个能够晋升性能的架构,然而我感觉 ECS 更弱小的中央在于能够升高代码复杂度。

在游戏我的项目开发的过程中,个别会应用 OOP 的设计形式让 GameObject 解决本身的业务,而后框架去治理 GameObject 的汇合。然而应用 OOP 的思维进行框架设计的难点在于一开始就要构建出一个清晰类层次结构。而且在开发过程中须要改变类层次结构的可能性十分大,越到开发前期对类层次结构的改变就会越艰难。

通过一段时间的开发,总会在某个工夫点开始引入多重继承。实现一个又可工作、又易了解、又易保护的多重继承类层次结构的难度通常超过其得益。因而少数游戏工作室禁止或严格限度在类层次结构中应用多重继承。若非要应用多重继承,要求一个类只能多重继承一些 简略且无父类的类 (min-in class),例如 Shape 和 Animator。

也就是说在大型游戏我的项目中,OOP 并不适用于框架设计。然而也不必齐全摈弃 OOP,只是在很大水平上,代码中的类不再具体地对应事实世界中的具体物件,ECS 中类的语义变得更加形象了。

ECS 有一个很重要的思维:数据都放在一边,须要的时候就去用,不须要的时候不要动。ECS 的实质就是数据和操作拆散。传统 OOP 思维经常会面临一种状况,A 打了 B,那么到底是 A 被动打了 B 还是 B 被 A 打了,这个函数该放在哪里。然而 ECS 不必纠结这个问题,数据寄存到 Component 种,逻辑间接由 System 接管。借着这个思维,咱们能够大幅度缩小函数调用的档次,进而缩短数据流传递的深度。

基本概念

Entity 由多个 Component 组成,Component 由数据组成,System 由逻辑组成。

Component(组件)

Component 是数据的汇合,只有变量,没有函数,但能够有 getter 和 setter 函数。Component 之间不能够间接通信。

struct Component{// 子类将会有大量变量,以供 System 利用}

Entity(实体)

Entity 用来代表游戏世界中任意类型的游戏对象,宏观上 Entity 是一个 Component 实例的汇合,且领有一个全局惟一的 EntityID,用于标识 Entity 自身。

class Entity{
    Int32 ID;
    List<Component> components;
        // 通过观察者模式将本人注册到 System 能够晋升 System 遍历的速度,因为只须要遍历曾经注册的 entity
}

Entity 须要遵循立刻创立和提早销毁准则,销毁放在帧末执行。因为可能会呈现这样的状况:systemA 提出要在 entityA 所在位置创立一个特效,而后 systemB 认为须要销毁 entityA。如果 systemB 间接销毁了 entityA,那么稍后 FxSystem 就会拿不到 entityA 的地位导致特效播放失败(你可能会问为什么不间接把 entityA 的地位记录下来,这样就不会有问题了。这里只是简略举个例子,不要太深究 (●’◡’●))。现实的体现成果应该是,播放特效后隐没。

System(零碎)

System 用来制订游戏的运行规定,只有函数,没有变量。System 之间的执行程序须要严格制订。System 之间不能够间接通信。

一个 System 只关怀某一个固定的 Component 组合,这个组合汇合称为 tuple。

各个 System 的 Update 程序要依据具体情况设置好,System 在 Update 时都会遍历所有的 Entity,如果一个 Entity 领有该 System 的 tuple 中指定的所有 Component 实例,则对该 Entity 进行解决。

class System{public abstract void Update();
}

class ASystem:System{
    Tuple tuple;

    public override void Update(){for(Entity entity in World.entitys){if(entity.components 中有 tuple 指定的所有 Component 实例){//do something for Components}
        }
    }
}

一个 Component 会被不同 System 区别对待,因为每个 System 用到的数据可能只有其中一部分,且不肯定雷同。

World(世界)

World 代表整个游戏世界,游戏会视状况来创立一个或两个 World。通常状况下只有一个,然而守望先锋为了做死亡回放,有两个 World,别离是 liveGame 和 replyGame。World 上面会蕴含所有的 System 实例和 Entity 实例。

class World{
    List<System> systems;                   // 所有 System
    dictionary<Int32, Entity> entitys;      // 所有 Entity,Int32 是 Entity.ID

    // 由引擎帧循环驱动
    void Update(){for(System sys in systems)
            sys.Update();}
}

由 ECS 架构进去的游戏世界就像是一个数据库表,每个 Entity 对应一行,每个 Component 对应一列,打了✔代表 Entity 领有 Component。

Component1 Component2 ComponentN
EntityId1
EntityId2
EntityIdN

单例 Component

在定义一个 Component 时最好先搞清楚它的数据是 System 数据还是 Entity 数据。如果是 System 的数据,个别设计成单例 Component。例如寄存玩家键盘输入的 Component,全局只须要一个,很多 System 都须要去读这个惟一的 Component 中的数据。
单例 Component 顾名思义就是只有一个实例的 Component,它只能用来存储某些 System 状态。单例 Component 在整个架构中的占比通常会很高,据说在守望先锋中占比高达 40%。其实换一个角度来看,单例 Component 能够看成是只有一个 Component 的匿名 Entity 单例,但能够通过 GetSingletonIns 接口来间接拜访,而不必通过 EntityID。

例子

守望先锋种有一个依据输出状态来决定是不是要把长期不产生输出的对象踢下线的 AFKSystem,该 System 须要对象同时具备连贯 Component、输出 Component 等,而后 AFKSystem 遍历所有符合要求的对象,依据最近输出事件产生的工夫,把长期没有输出事件的对象告诉下线。

设计须要遵循的准则

  1. 设计并不是从 Entity 开始的,而是应该从 System 形象出 Component,最初组装到 Entity 中。
  2. 设计的过程中尽量确保每个 System 都依赖很多 Component 去运行,也就是说 System 和 Component 并不是一对一的关系,而是一对多的关系。所以 xxxCOM 不肯定有 xxxSys,xxxSys 不肯定有 xxxCOM。

    • System 和 Component 的划分很难在一开始就确定好,个别都是在实现的过程中看状况一步一步地去划分 System 和 Component。而且最终划分进去的 System 和 Component 个别都是比拟形象的,也就是说通常不会对应事实世界中的具体物件,能够参考下图守望先锋 System 和 Component 划分的例子。
  3. System 尽量不扭转 Component 的数据。

    • 能够读数据实现的性能就不要写数据来实现。因为写数据会影响到应用了这些数据的模块,如果对于其它模块不相熟的话,就会产生 Bug。如果只是读数据来减少性能的话,即便出 Bug 也只局限于新性能中,而不会影响其它模块。这样容易治理复杂度,而且给并行处理留下了优化空间。

应用心得

我在一个游戏 demo 里尝试应用 ECS 去进行设计,最大的感触是所有游戏逻辑都变得那么的正当,应答改变、扩大也变得那么的轻松。加班变少了,也不再焦虑。在开始应用 ECS 来架构业务层之前,我对 ECS 还是存有一丝疑虑的。放心会不会因为规矩太多了,导致有些性能写不进去。中途也的确因为 ECS 的种种规矩,导致有些性能不好写进去,须要用到一些奇技淫巧,剑走偏锋。但这些技术最终造就了一个可继续保护的、解耦合的、简洁易读的代码零碎。据说守望团队在将整个游戏转成 ECS 之前也不确定 ECS 是不是真的好使。当初他们说 ECS 能够治理快速增长的代码复杂性,也是事后诸葛亮。

引擎层的 System 比拟好定义,因为引擎相干层级划分比拟明确。然而游戏业务逻辑层可能会呈现各种奇奇怪怪的 System,因为业务层的需要变幻无穷,有时没有方法划分出一个对应具体业务的 System。例如我已经在业务层定义过 DamageHitSystem、PointForceSys。

推延技术:不是十分必要马上执行的内容能够推延到适合的时再执行,这样能够将副作用集中到一处,易于做优化。例如游戏可能会在某个霎时产生大量的贴花,利用提早技术能够将这些须要产生的贴花数据保留下来,稍后能够将局部重叠的贴花删除,再根据性能状况分到多个帧中去创立,能够无效平滑性能毛刺。

如果不晓得该如何去划分 System,而导致 System 之间肯定要互相通信能力实现性能,能够通过将数据放在中的一个队列里提早解决。比方 SystemA 在执行 Update 的时候,须要执行 SystemB 中的逻辑。然而这个时候还没轮到 SystemB 执行 Update,只能先将须要执行的内容保留到一个中央。然而 System 自身又没有数据,所以 SystemA 只好将须要执行的内容保留到单例 Component 中的一个队列里,等轮到 SystemB 执行 Update 的时候再从队列里拿出数据来执行逻辑。

然而 System 之间通过单例 Component 有个毛病。如果向单例 Component 中增加太多须要提早解决的数据,一旦呈现 bug 就不好查了。因为这类数据是一段时间之前增加进来的,到前面才出问题的话,不好定位是何处、何时、基于什么状况增加进来的。解决方案是给每一条须要提早解决的数据加上调用堆栈信息、工夫戳、一个用于形容为什么增加进来的字符串。

各个 System 都用到的公共函数能够定义在全局,也能够作为对应 System 的动态函数,这类函数叫做 Utility 函数。Utility 函数波及的 Component 最好尽可能少,不然须要作为参数传进函数 Component 会很多,导致函数调用不太雅观。Utility 函数最好是无副作用的,即不对 Component 的数据做任何写操作,只读取数据,最初返回计算结果。要改 Component 的数据的话,也要交给 System 来改。

函数调用堆栈的档次变浅了,因为逻辑被摊开到各个 System,而 System 之间又禁止间接拜访。代码变得扁平化,扁平化象征的函数封装少了,所以浏览、批改、扩大也很轻松。

如果能够把整个游戏世界都形象成数据,存档 / 读档性能的实现也变得容易了。存档时只须要将所有 Component 数据保留下来,读档时只须要将所有 Component 数据加载进来,而后 System 照常运行。想想就感觉弱小,这就是 DOP 的魅力。

长处

模式简略

构造清晰

通过组合高度复用。用组合代替继承,能够像拼积木一样将任意 Component 组装到任意 Entity 中。

扩展性强。Component 和 System 能够随便增删。因为 Component 之间不能够间接拜访,System 之间也不能够间接拜访,也就是说 Component 之间不存在耦合,System 之间也不存在耦合。System 和 Component 在设计原则上也不存在耦合。对于 System 来说,Component 只是放在一边的数据,Component 提供的数据足够就 update,数据不够就不 update。所以随时增删任意 Component 和 System 都不会导致游戏解体报错。

人造与 DOP(data-oriented processing) 亲和。数据都被对立寄存到各种各样的 Component 中,System 间接对这些数据进行解决。函数调用堆栈深度大幅度降低,流程被弱化。

易优化性能。因为数据都被对立寄存到 Component 中,所以如果可能在内存中以正当的形式将所有 Component 聚合到间断的内存中,这样能够大幅度晋升 cpu cache 命中率。cpu cache 命中良好的状况下,Entity 的遍历速度能够晋升 50 倍,游戏对象越多,性能晋升越显著。ECS 的这项个性给大部分人留下了深刻印象,然而大部分人也认为这就是 ECS 的全副。我感觉可能是被 Unity 的官网演示带歪的。

易实现多线程。因为 System 之间不能够间接拜访,曾经齐全解耦,所以实践上能够为每个 System 调配一个线程来运行。须要留神的是,局部 System 的执行程序须要严格制订,为这部分 System 调配线程时须要留神一下执行先后顺序。

毛病

在充斥限度的状况下写代码,有时速度会慢一些。然而习惯之后,前期开发速度会越来越快。

优化

一个 entity 就是一个 ID,所有组成这个 entity 的 component 将会被这个 ID 给标记。因为不必创立 entity 类,能够升高内存的耗费。如果通过以下形式来组织架构,还能够晋升 cpu cache 命中率。

// 数组下标代表 entity 的 ID
ComponentA[] componentAs;
ComponentB[] componentBs;
ComponentC[] componentCs;
ComponentD[] componentDs;
...

参考资料

  • 《守望先锋》架构设计与网络同步 — GDC2017 精品分享实录
  • http://gamadu.com/artemis/
  • http://gameprogrammingpattern…
  • http://t-machine.org/index.ph…
  • http://blog.lmorchard.com/201…
  • 浅谈《守望先锋》中的 ECS 构架

小弟才浅,如果本篇文章有任何谬误和倡议,欢送大家留言

感激大家的点赞、珍藏

微信搜「三年游戏人」第一工夫浏览最新内容,获取一份收集多年的书籍包 以及 优质工作内推

正文完
 0