https://blog.codingnow.com/2017/06/overwatch_ecs.htmlhtml
今天读了一篇 《守望先锋》架构设计与网络同步 。这是根据 GDC 2017 上的演讲 Overwatch Gameplay Architecture and Netcode 视频翻译而来的,因此并无原文。因为是个一小时的演讲,不可能讲得面面俱到,因此理解起来有些困难,我反复读了三遍,而后把英文视频找来(订阅 GDC Vault 能够看,有版权)看了一遍,大体理解了 ECS 这个框架。写这篇 Blog 记录一下我对 ECS 的理解,结合我本身这些年作游戏开发的经验,可能并不是等价于原演讲中的思想。数组
Entity Component System (ECS) 是一个 gameplay 层面的框架,它是创建在渲染引擎、物理引擎之上的,主要解决的问题是如何创建一个模型来处理游戏对象 (Game Object) 的更新操做。服务器
传统的不少游戏引擎是基于面向对象来设计的,游戏中的东西都是对象,每一个对象有一个叫作 Update 的方法,框架遍历全部的对象,依次调用其 Update 方法。有些引擎甚至定义了多种 Update 方法,在同一帧的不一样时机去调用。网络
这么作实际上是有极大的缺陷的,我相信不少作过游戏开发的程序都会有这种体会。由于游戏对象实际上是由不少部分聚合而成,引擎的功能模块不少,不一样的模块关注的部分每每互不相关。好比渲染模块并不关心网络链接、游戏业务处理不关心玩家的名字、用的什么模型。从天然意义上说,把游戏对象的属性聚合在一块儿成为一个对象是很天然的事情,对于这个对象的生命期管理也是最合理的方式。但对于不一样的业务模块来讲,针对聚合在一块儿的对象作处理,把处理方法绑定在对象身上就不那么天然了。这会致使模块的内聚性不好、模块间也会出现没必要要的耦合。数据结构
我以为守望先锋之因此要设计一个新的框架来解决这个问题,是由于他们面对的问题复杂度可能到了一个更高的程度:好比如何用预测技术作更准确的网络同步。网络同步只关心不多的对象属性,不必在设计同步模块时牵扯过多没必要要的东西。为了准确,须要让客户端和服务器跑同一套代码,而服务器并不须要作显示,因此要比较容易的去掉显示系统;客户端和服务器也不彻底是一样的逻辑,须要共享一部分系统,而在另外一部分上根据分别实现……多线程
总的来讲、须要想一个办法拆分复杂问题,把问题聚焦到一个较小的集合,提升每一个子任务的内聚性。架构
ECS 的 E ,也就是 Entity ,能够说就是传统引擎中的 Game Object 。但在这个系统下,它仅仅是 C/Component 的组合。它的意义在于生命期管理,这里是用 32bit ID 而不是指针来表示的,另外附着了渲染用到的资源 ID 。由于仅负责生命期管理,而不设计调用其上的方法,用整数 ID 更健壮。整数 ID 更容易指代一个无效的对象,而指针就很难作到。框架
C 和 S 是这个框架的核心。System 系统,也就是我上面提到的模块。对于游戏来讲,每一个模块应该专一于干好一件事,而每件事要么是做用于游戏世界里同类的一组对象的每单个个体的,要么是关心这类对象的某种特定的交互行为。好比碰撞系统,就只关心对象的体积和位置,不关心对象的名字,链接状态,音效、敌对关系等。它也不必定关心游戏世界中的全部对象,好比关心那些不参与碰撞的装饰物。因此对每一个子系统来讲,筛选出系统关心的对象子集以及只给它展现它所关心的数据就是框架的责任了。函数
在 ECS 框架中,把每一个可能单独使用的对象属性概括为一个个 Component ,好比对象的名字就是一个 Component ,对象的位置状态是另外一个 Component 。每一个 Entity 是由多个 Component 组合而成,共享一个生命期;而 Component 之间能够组合在一块儿做为 System 筛选的标准。咱们在开发的时候,能够定义一个 System 关心某一个固定 Component 的组合;那么框架就会把游戏世界中知足有这个组合的 Entity 都筛选出来供这个 System 遍历,若是一个 Entity 只具有这组 Component 中的一部分,就不会进入这个筛选集合,也就不被这个 System 所关心了。post
在演讲中,做者谈到了一个根据输入状态来决定是否是要把长期不产生输入的对象踢下线的例子,就是要对象同时具有链接组件、输入组件等,而后这个 AFK 处理系统遍历全部符合要求的对象,根据最近输入事件产生的时间,把长期没有输入事件的对象通知下线;他特别说到,AI 控制的机器人,因为没有链接组件,虽然具有状态组件,但不知足 AFK 系统要求的完整组件组的要求,就根本不会遍历到,也就不用在其上面浪费计算资源了。我认为这是 ECS 相对传统对象 Update 模型的一点优点;用传统方法的话,极可能须要写一个空的 Update 函数。
游戏的业务循环就是在调用不少不一样的系统,每一个系统本身遍历本身感兴趣的对象,只有预约义的组件部分能够被子系统感知到,这样每一个系统就能具有很强的内聚性。注意、这和传统的面向对象或是 Actor 模型是大相径庭的。OO 或 Actor 强调的是对象自身处理自身的业务,而后框架去管理对象的集合,负责用消息驱动它们。而在 ECS 中,每一个系统关注的是不一样的对象集合,它处理的对象中有共性的切片。这是很符合守望先锋这种 MOBA 类游戏的。这类游戏关注的是对象间的关系,好比 A 攻击了 B 对 B 形成了伤害,这件事情是在 A 和 B 之间发生的,在传统模型中,你会纠结于伤害计算到底在 A 对象的方法中完成仍是在 B 的方法中完成。而在 ECS 中不须要纠结,由于它能够在伤害计算这个 System 中完成,这个 System 关注的是全部对象中,和伤害的产生有关的那一小部分数据的集合。
ECS 的设计就是为了管理复杂度,它提供的指导方案就是 Component 是纯数据组合,没有任何操做这个数据的方法;而 System 是纯方法组合,它本身没有内部状态。它要么作成无反作用的纯函数,根据它所能见到的对象 Component 组合计算出某种结果;要么用来更新特定 Component 的状态。System 之间也不须要相互调用(减小耦合),是由游戏世界(外部框架)来驱动若干 System 的。若是知足了这些前提条件,每一个 System 均可以独立开发,它只须要遍历给框架提供给它的组件集合,作出正确的处理,更新组件状态就够了。编写 Gameplay 的人更像是在用胶水粘合这些 System ,他只要清楚每一个 System 到底作了什么,操做自己对哪些 Component 形成了影响,正确的书写 System 的更新次序就能够了。一个 System 对大多数 Component 是只读的,只对少许 Component 是会改写的,这个能够预先定义清楚,有了这个知识,一是容易管理复杂度,二是给并行处理留下了优化空间。
在演讲中谈到了开发团队对 ECS 的设计认知也是逐步演进的。
好比在一开始,他们认为 Component 就是大量有某种同类 Entity 属性的集合的筛选器。ECS 框架辅助这个筛选过程,每一个 System 模块都用 for each 的方式迭代相关的 Entity 中对象的组件。以后他们发现,其实对于每一个游戏对象集合体来讲,一类 Component 能够也应该只有一个。好比存放玩家键盘输入的 Component ,就没有多个。不少 System 都须要去读这个惟一的 Component 内的状态(哪些按钮被按下了),能够安排一个 System 来更新这个 Component 。原文把这种 Component 成为 Singleton Component ,我认为这个东西和一开始 ECS 想解决的问题仍是有一些差异的:不一样种类的 Entity 分别拥有同类的属性组,框架负责管理同类集合。咱们的确仍是能够建立一个叫作玩家键盘的 Entity 加到游戏世界中,这个 Entity 是由键盘组件构成。可是咱们彻底没必要迭代玩家键盘这个 Entity 集合,由于它确定只有一个,直接把这个对象放在游戏世界中便可。但把它放在 System 中就不是一个好设计了。由于它破坏了 System 无状态的设计原则,并且也不支持多个游戏世界:在原文中举了个例子,实际游戏和游戏回放就是两个不一样的游戏世界,不一样的游戏世界意味着不一样的业务流程的组合,须要用不一样的方式粘合已经开发好的 System 。把游戏键盘状态这种状态内置在特定的 System 中就是不合适的了。从这个角度来讲 ECS 的本质仍是数据 C 和操做 S 分离。而操做 S 并不局限于对同类组件集合的管理,也但是是针对单个组件。做者本身也说,最终有 40% 的组件就是单件。
单件自己其实就和传统面向对象模型差很少了。可是数据和方法分离仍是颇有意义。咱们在用面向对象模式作开发的时候也会碰到一个对象有几个不一样的方法,某些方法关注这部分状态、另外一些方法关注另外一部分状态,还有一些方法关注前面几组状态的集合。这里的方法就是 ECS 中的系统、状态就是组件。将数据和方法分离能够将不一样的方法解耦。若是用传统的 C++ 的面向对象模式,极可能须要用多继承、组合转发等等复杂的语法手段。
演讲后面还提到了一些 ECS 模式下处理一些复杂问题的常见手法。
Component 没有方法,而 System 则没有状态,只是对定义好的 Component 状态的加工过程。而许多 System 中极可能会处理同一类问题,涉及的 Component 类型是相同的。若是这个有共性的问题只涉及一个 Entity ,那么直观的方法是设计一个 System ,迭代,逐个把结果计算出来,存为 Component 的状态,别的 System 能够在后续把这个结果做为一个状态读出来就能够了。
但若是这个行为涉及多个 Entity ,好比在不一样的 System 中,都须要查询两个 Entity 的敌对关系。咱们不可能用一个 System 计算出全部 Entity 间的敌对关系,这样必然产生了大量没必要要的计算;又或者这个行为并不想额外修改 Component 的状态,但愿对它保持无反作用,好比我想持续模拟一个对象随时间流逝的位置变化,就不能用一个 System 计算好,再从另外一个 System 读出来。
这样,就引入了 Utility 函数的概念,来作上面这种类型的操做,再把 Utility 函数共享给不一样的 System 调用。为了下降系统复杂度,就要求要么这种函数是无反作用的,随便怎么调用都没问题,好比上面查询敌对关系的例子;要么就限制调用这种函数的地方,仅在不多的地方调用,由调用者当心的保证反作用的影响,好比上面那个持续位置变化的过程。
若是产生状态改变这种反作用的行为必须存在时,又在不少 System 中都会触发,那么为了减小调用的地方,就须要把真正产生反作用的点集中在一处了。这个技巧就是推迟行为的发生时机。就是把行为发生时须要的状态保存起来,放在队列里,由一个单独的 System 在独立的环节集中处理它们。
例如不一样的射击行为均可能建立出新的对象、破坏场景、影响已有对象的状态。在同一面墙上留下不一样的弹孔,不须要堆叠在一块儿,而只须要保留最后一个,删除前面的。咱们能够把让不一样的 System 触发这些对象建立、删除的行为,但并不真正去作。集中在一块儿推迟到当前帧的末尾或下一帧的开头来作。这样就尽可能保证了多数 System 工做的时候,对大多数组件来讲是无反作用的,而把严重反作用的行为集中在单点当心处理。
ECS 要解决的最复杂,最核心的问题,或许仍是网络同步。我认为这也是设计一个状态和行为严格分离的框架的主要动机。由于一个好的网络同步系统必须实现预测、有预测就有预测失败的状况,发生后要解决冲突,回滚状态是必须支持的。而状态回滚还包括了只回滚部分状态,而不能简单回滚整个世界。
我在去年其实在本 blog 中谈过这个问题 。个人观点是,状态的单独保存是很是重要的。在 ECS 模型中,C 是纯数据,因此很是方便作快照和回滚。Entity 的组件分离,也适合作关键状态的记录。去年和一个同事一块儿作了一个射击类的 MOBA demo ,最终的实现方案就是把游戏对象的位置(移动)状态,和射击状态专门抽出来实现预测同步,效果很是不错。
这个演讲其实并无谈及预测和同步的具体技术,而是谈 ECS 怎么帮助下降利用这些技术的实现复杂度。同时也说起了一些有趣的细节。
好比说,ECS 规定每一个须要根据输入表现的 System 都提供了一个 UpdateFixed 函数。守望先锋的同步逻辑是基于 60fps 的,因此这个 UpdateFixed 函数会每 16ms 调用一次,专门用于计算这个逻辑帧的状态。服务器会根据玩家延迟,稍微推迟一点时间,比客户端晚一些调用 UpdateFixed 。在我去年谈同步的 blog 中也说过,玩家其实不关心各个客户端和服务器是否是时刻上绝对一致(绝对一致是不可能作到的),而关心的是,不一样客户端和服务器是否是展示了相同的过程。就像直播电影,不一样的位置早点播放和晚点播放,你们看到的内容是一致的就够了,是否是同时在观看并不重要。
可是,游戏和电影不同的地方是,玩家本身的操做影响了电影的情节。咱们须要在服务器仲裁玩家的输入对世界的影响。玩家须要告知服务器的是,我这个操做是在电影开场的几分几秒下达的,服务器按这个时刻,把操做插入到世界的进程中。若是客户端等待服务器回传操做结果那就实在是太卡了,因此客户端要在操做下达后本身模拟后果。若是操做不被打断,其实客户端模拟的结果和服务器仲裁后的结果是同样的,这样服务器在回传后告之客户端过去某个时间点的对象的状态,其实和当初客户端模拟的其实就是一致的,这种状况下,客户端就开开心心继续往前跑就行了。
只有在预测操做时,好比玩家一直在向前跑,可是服务器那里感知到另外一个玩家对他释放了一个冰冻,将他顶在原地。这样,服务器回传给玩家的位置数据:他在某时刻停留在某地就和当初他本身预测的那个时刻的位置不一样。产生这种预测失败后,客户端就须要本身调节。有 ECS 的帮助,状态回滚到发生分歧的版本,考虑到服务器回传的结果和新了解到的世界变化,从新将以后一段时间的操做从新做用到那一刻的状态上,作起来就相对简单了。
对于服务器来讲,它默认客户端会持续不断的以固定周期向它推送新的操做。正如前面所说,服务器的时刻是有意比客户端延后的,这样,它并不是马上处理客户端来的输入,而是把输入先放在一个缓冲区里,而后按和客户端固定的周期 ( 60fps ) 从缓冲区里取。因为有这个小的缓冲区的存在,轻微的网络波动(每一个网络包送达的路程时间不彻底一致)是彻底没有影响的。但若是网络不稳定,就会出现到时间了客户端的操做尚未送到。这个时候,服务器也会尝试预测一下客户端发生了什么。等真的操做包到达后,比对一下和本身的预测值有什么不一样,基于过去那个产生分歧的预测产生的状态和实际上传的操做计算出下一个状态。
同时,这个时候服务器会意识到网络状态很差,它主动通知客户端说,网络不太对劲,这个时候的你们遵循的协议就比较有趣了。那就是客户端获得这个消息就开始作时间压缩,用更高的频率来跑游戏,从 60fps 提升到 65fps ,玩家会在感觉到轻微的加速,结果就是客户端用更高的频率产生新的输入:从 16 ms 一次变成了 15.2 ms 一次。也就是说,短期内,客户端的时刻更加领先服务器了,且越领先越多。这样,服务器的预读队列就能更多的接收到将来将发生的操做,遇到到点殊不知道客户端输入的可能性就变少了。可是总流量并无增长,由于假设一局游戏由一万个 tick 组成,不管客户端怎么压缩时间,提早时刻,总的数据仍是一万个 tick 产生的操做,并无变化。
一旦度过了网络不稳按期,服务器会通知客户端已经正常了,这个时候客户端知道本身压缩时间致使的领先时长,对应的膨胀放慢时间(下降向服务器发送操做的频率)让状态回到原点便可。
btw, 守望先锋 是基于 UDP 通信的,从演讲介绍看,对于 UDP 可能丢包的这个问题,他们处理的简单粗暴:客户端每次都将没有通过服务器确认的包打包在一块儿发送。因为每一个逻辑帧的操做不多,打包在一块儿也不会超过 MTU 限制。
ECS 在这个过程当中真正发生威力的地方是在预测错误后纠正错误的阶段。一旦须要纠正过去发生的错误,就须要回滚、从新执行指令。移动、射击这些都属于常规的设定,比较容易作回滚从新执行;技能自己是基于暴雪开发的 Statescript 的,经过它来达到一样的效果。ECS 的威力在于,把这些元素用 Component 分离了,能够单独处理。
好比说射击命中断定,就是一个单独的系统,它基于被断定对象都有一个叫作 ModifyHealthQueue 的组件。这个组件里记录的是 Entity 身上收到的全部伤害和治疗效果。这个组件能够用于 Entity 的筛选器,没有这个组件的对象不会受到伤害,也就不须要参与命中断定。真正影响命中断定的是 MovementState 组件,它也参与了命中断定这个系统的筛选,并真正参与了运算。命中断定在查询了敌对关系后从 MovementState 中获取应该比对的对象的位置,来预测它是否被命中(可能须要播放对应的动画)。可是伤害计算,也就是 ModifyHealthQueue 里的数据是只能在服务器填写并推送给客户端的。
MovementState 会由于须要纠正错误预测而被回退,同时还有一些非 MovementState 的状态也会回退,好比门的状态、平台的状态等等。这个回退是 Utility 函数的行为,它可能会影响受击的表现,而受伤则是另外一种固定行为(服务器肯定的推送)的后果。他们发生在 Entity 的不一样组件切片上,就能够正交分离。
射击预测和纠正能够利用对象的活动区域来减小断定计算量。若是能老是计算保持当前对象在过去一段时间的最大移动范围(即过去一段时间的包围盒的并集),那么当须要作一个以前发生的射击命中断定时,就只须要把射击弹道和当前全部对象的检测区域比较,只有相交才作进一步检测:回退相关对象到射击发生的时刻,作严格的命中校验。若是当初预测的命中结果和如今核验的一致就无所谓了,不须要修正结果(若是命中了,具体打中在哪不重要;若是未命中,也无论子弹射到哪里去了)。
若是 ping 值很高,客户端作命中预测每每是没有什么意义的,徒增计算量。因此在 Ping 超过 220ms 后,客户端就再也不提早预测命中事件,直接等服务器回传。
ECS 框架在这件事上能够作到只去回滚和重算相关的 Component ,一个 System 知道哪些 Entity 才是它真正关心的,该怎么回退它所关心的东西。这样开发的复杂度就减小了。游戏自己是复杂的,可是和网络同步相关的影响到游戏业务的 System 却不多,并且参与的 Component 几乎都是只读的。这样咱们就尽量的把这个复杂的问题和引擎其它部分解耦。
ECS 是个不错的框架,可是须要遵循必定的规范才能起到他应有的效果:减小大量系统间的耦合度。但并不是全部的问题都适合遵循 ECS 的规范来开发,尤为是一些旧有的模块,很难作到把数据结构按 Component 得规范暴露出来,并把状态改变的方法集成到独立的 System 中去。这个时候就应该作一些封装的工做。好比说有些系统本来就利用了多线程模型做并行优化,因此咱们须要把这些已经作好的工做隔离在 ECS 框架以外,仅仅暴露一些接口和 ECS 框架对接。