浅谈《守望先锋》中的 ECS 构架

本文转自云风的博客

今天读了一篇 《守望先锋》架构设计与网络同步 。这是根据 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 所关心了。

在演讲中,作者谈到了一个根据输入状态来决定是不是要把长期不产生输入的对象踢下线的例子,就是要对象同时具备连接组件、输入组件等,然后这个 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 框架对接。