游戏开发论坛

 找回密码
 立即注册
搜索
查看: 23901|回复: 2

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

[复制链接]

1万

主题

1万

帖子

3万

积分

论坛元老

Rank: 8Rank: 8

积分
36572
发表于 2017-11-27 10:13:32 | 显示全部楼层 |阅读模式
5879ef17463be.jpg

文/云风

最近读了一篇《守望先锋》架构设计与网络同步。这是根据GDC 2017上的演讲Overwatch Gameplay Architecture and Netcode视频翻译而来的,所以并没有原文。由于是个一小时的演讲,不可能讲得面面俱到,所以理解起来有些困难,我反复读了三遍,然后把英文视频找来(订阅GDC Vault可以看,有版权)看了一遍,大致理解了ECS这个框架。写这篇Blog记录一下我对ECS的理解,结合我自己这些年做游戏开发的经验,可能并非等价于原演讲中的思想。

onent 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框架对接。

2

主题

99

帖子

545

积分

高级会员

Rank: 4

积分
545
发表于 2017-11-30 22:39:28 | 显示全部楼层
可是fps是游戏画面的渲染帧率,并不等同于游戏逻辑的帧率呀

5

主题

266

帖子

809

积分

高级会员

Rank: 4

积分
809
发表于 2018-8-16 20:29:51 | 显示全部楼层
空谈误国,理解一下
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

作品发布|文章投稿|广告合作|关于本站|游戏开发论坛 ( 闽ICP备17032699号-3 )

GMT+8, 2024-3-29 08:48

Powered by Discuz! X3.4

Copyright © 2001-2021, Tencent Cloud.

快速回复 返回顶部 返回列表