美文网首页
【GDC2017】Overwatch Gameplay Arch

【GDC2017】Overwatch Gameplay Arch

作者: 离原春草 | 来源:发表于2021-05-17 00:42 被阅读0次

今天要分享的是暴雪在GDC 2017年关于《守望先锋》关于ECS对象系统的实施方案,没有找到对应的PPT或PDF,这里没法给出原文链接了,感兴趣的可以从后面的参考文献[1]中拿到原始视频地址。

时间有限,这里不会百分百保留原文,只会对中间的一些关键内容进行介绍。

《守望先锋》(Overwatch,后面简称OW)的ECS经历了多年的开发与迭代,后面验证发现这种方案有助于降低代码复杂性,而这并不是当时使用此方案的预期(意外之喜啊)。

OW中存在大量的英雄,每个英雄都有自己独有的一套复杂的技能系统,最终暴雪选择了跟之前引擎中常见的经典Actor模型不同的ECS方案来对这些数据与逻辑进行组织。

这当然不是想当然的,因为团队在原始的Actor模型上面有着非常丰富的经验,在项目设计之初就尝试给出一套能够突破原有方案瓶颈获取更优表现的新方案,经过原型验证之后,最终才选择了ECS。

这里给出了ECS的整体架构,一个场景对应一个world,world由表示逻辑计算的system(不包含任何数据)以及表示对象的entity组成,每个entity则有多个只包含纯数据的component组成。

这里给出了OW ECS中的System以及Components的一个简单示意图,System按照Tick/Update顺序进行组织,高亮部分表示当前正在处理的System以及与这个System相关联的Components,其中每个Component可能会被多个System所引用,而每个System的计算逻辑也可能需要多个Component参与(最复杂的情况一个System可能对应数十个Components,简单的情况也有两到三个)。

每个System所需要处理的components会组合成一个OW所谓的Tuple(元组),元组表示的是一个System逻辑所需要的所有数据,比如我们需要将物理模拟的结果同步给Transform,那么我们就需要先获取Physics Component,之后将数据写入到Transform Component,元组跟Entity不是一个概念,一个Entity表示一个对象,一个对象会需要包含数目众多的Component,而元组则仅仅对应于这些Component中的与对应System相关的一小部分Components,如下图所示:

System不关心Entity,只关心Entity上跟System计算逻辑相关的部分Components,这些Components会需要提前从Entity中抽取(用指针的方式)出来,组成元组,并通过一定的方式注册(或订阅)到System上,当System按照一定的顺序触发Tick函数时,就会通过轮询的方式对元组进行计算处理(这个过程应该可以做成并行的)。

这里给出了一个更为细节的框架图。

图中的EntityAdmin就是我们此前说过的World,其中按照Tick顺序存储了一系列的System以及一个Entity Hashmap,每个Entity用一个32位无符号整数的Entity ID(全局唯一)来表征。

每个Entity中除了包含一个ID之外,还包含一个可选的ResourceHandle用于指向Entity Definition Asset(实际上就是Entity中的Component数组吧,OW将所有Asset资源(即各种Components)都抽离出来进行统一管理,因此这里Entity只存储一个句柄)。

Component是一个基类,包含了数百个子类Component,每个Component都有自己所独有的一些数据,这些数据后续会被System在执行逻辑计算时用到。前面说过,Component不包含任何逻辑,实际上这个地方需要做下修正,Component中包含了一个用于实现多态(虚函数)的Create以及对应的析构销毁函数,用来创建不同的Component,此外还有一些数据访问的辅助函数(比如Getter,Setter之类)。

前面给的代码示意图,只是为了阐述元组的概念,实际情况中,System对元组的访问是通过链表完成的,也就是,或许不存在显示定义的元组,而是将元组中的Component按照链表的方式进行组织,每个链表的起始Component我们称之为Primary Component,System中就存储了Primary Component组成的数组,之后访问的时候就逐个取出Primary Component进行处理,当需要访问到对应的其他元组数据的时候,则会以Sibling(兄弟)函数进行访问(这种做法有什么好处呢?为什么不显式定义一个元组,这样逻辑不是更清晰吗?首先,即使创建Tuple,也只能在Tuple中存储Component的指针,不能直接存储拷贝,否则数据一致性不好维护;其次,使用链表结构,可以实现代码逻辑的统一,不再需要跳转到对应的Tuple上查找其Name,统统通过查询的方式获得,而且数据只需要维护一份即可(即Entity上的Resource Handle中的一份),不需要每个System维护一份)。(不知道是否还有其他考虑)

Client跟Server对应于不同的System,这种做法可以很好的将Client跟System等同起来,部分共有逻辑也可以直接使用同一个System进行处理。

这里设计System跟Component的时候,希望将粒度拆得尽可能的小,从而保证绝大部分System只是对Component数据进行读取,而只有一小部分System负责对数据进行修正(很疑惑,如果System不做修正,那么程序运行还有什么意义?),对数据进行修正会导致一些副作用(Side Effect,因为逻辑是依赖时序的,数据修正前后,执行同一段逻辑会导致结果的差异,为了保证结果的一致性,就需要仔细设计函数调用的顺序,而一些情况下这种做法会存在困难,因此不要轻易对数据进行修正,一旦修正就可能会导致这种副作用,Unity中的ECS就是将数据的修正放在帧末,从而保证数据读取先于数据更正完成,以此来保证状态的一致性),而这就需要System去处理其中带来的一系列复杂的问题(具体做法这里没提)。

比如这里,当需要修正状态来对某个ConnectionComponent所对应的Entity进行下线处理的时候,就需要通过SendMessage函数来完成相关状态修正的通知。

这里的一个问题是,为什么update函数不做成传统的面向对象的component update呢?比如在connectionComponent中对一个Update虚函数进行重写,在这个函数中完成AFK(Away From Keyboard,下线处理)逻辑,这是因为AFK涉及到很多的功能点,每个功能点可能都涉及到不止一个Component的参与,这就带来一个问题,那就是哪些功能点应该在ConnectionComponent中的Update函数中完成,哪些应该在其他Component的Update函数完成,难以给出一套直观的规则,会导致后续代码维护的困难,而ECS将数据跟逻辑分离就不会面临这个问题。

另外,对于同一个Component而言,在不同的System中扮演着不同的角色,会被用作不同的目的,而如果将Update放在Component中的话,会使得逻辑计算很复杂,且可能存在计算消耗的浪费(部分System不需要的一些计算逻辑也需要做分支判断处理)。

ECS方案在开发过程中也遭遇了不少的问题,其中的一个问题就是此前对ECS的约束,即Component不包含函数,而System不包含数据。

这里的一个问题就是,一些从此前的项目中转换到ECS框架中的System会带有一些成员变量,比如上图中的InputSystem,我们需要将按键的数据存储在里面,用于后续的输入判断,而如果严格按照此前的约束来做的话,我们就需要一个Input Component单例来存储相关数据,而这种做法看起来感觉就太过刻意,显得很呆板(因为按照设想,每种Component应该至少需要有很多的Component实例,这样才能称之为Component,而且如果使用单例的话,按照之前对Component访问的方式会显得很奇怪)。

因此最开始,OW的做法是将这些数据还是存在System中,不过这些System通常都是Singleton的,这样就可以通过一个全局变量g_game来进行访问,从而将这些奇怪的东西单独剥离出来进行处理。不过这种做法在编译时会看起来很劣质,因为在System中包含了其他System的头文件,从而导致System之间的耦合发生(注意其中调用了InputSystem的PostBuildPlayerCommand接口,当需要对CommandSytem的这个接口进行扩展时,就会存在一个困惑,这个扩展功能应该放在CommandSystem里面还是放在InputSystem里面),此外当某个System的函数需要变化时,就会触发其他相关联的System也需要同步触发编译,从而导致编译效率的下降。

这种做法在只有一个world的时候没有什么问题,但是OW中后面增加了一个角色死亡后玩家还可以继续看其他玩家的操作(Replay)的功能,这个功能导致需要新增一个world(dead world,replay逻辑都在这个world中完成),这就使得g_game的访问存在问题了(不再是单例了)。

最终还是像现实屈服,OW选择接受了单例Component的设定,这些单例Component跟普通Component不一样,不会挂接到真实的Entity上面,而是会挂接到一些匿名Entity上面,这些Entity可以通过Entity Admin进行直接访问(相当于还是跟之前的Component访问方式做了区分,避免逻辑编写时的怪异感)。

比如这里,将InputSystem中的成员变量抽取出来做成Component单例之后,其数据组织如上图所示,这种做法可以更好的践行此前ECS中的数据与逻辑分离的哲学,从而避免很多很难看的写法。

经过统计,单例Component占据了所有Component类型中的40%,看起来还是具有很大的应用市场。

经过改造后,原来的代码就变成上图所示的形式,从而实现InputSystem跟CommandSystem之间的解耦(InputSystem还在,只是成员变量被移除出去了,毕竟对InputComponent数据进行修正的相关的逻辑还需要有人完成呀)。

而通过这个改造,唯一会对PlayerCommand结构进行修正的就只有CommandSystem,这就使得对这个数据的维护变得简单,因为所有的改动都是在这一个文件中的Update函数中发生的,不需要天南地北的去搜索会对这个结构进行修正的代码了,而后续对这个结构的改动也就应该只发生在这个Update函数中(不再存在此前不知道该将修正代码放在哪个System中的困惑,极大的简化了代码编写的难度)。

ECS践行过程中遇到的另一个问题是共有行为(Shared Behavior),即某个行为可能是在多个System共同Update之后的结果。

比如很多逻辑都想知道某个Entity A对Entity B的相对敌意(Relative Hostility),而敌意(Hostility)则是由三个可选Components共同决定的:FilterBits,PetMaster以及PetFilter。

FilterBits存储Entity的Team Index(没有这个数据的就不会构成对手,而这个数值相同的,也不会构成对手),PetMaster则存储一个用于表征其拥有的所有Pet的Unique Key。

这里给出了对共有行为一系列不同的处理规则,比如如果某个Utility Function可能会被多个System调用,那么这个函数应该尽可能的斩断与其他Component的依赖,同时将Side Effect降到最低,从而避免逻辑的混乱与代码编写的复杂度。

而如果某个Utility函数需要调用较多的Component数据,那么就尽可能的减少对这个函数的调用入口。

这里给出了更为具体的一些做法,比如有一个Move函数用于对角色位置进行修正,会有两个调用入口,一个是服务器用于同步,另一个是客户端用于预测,这种时候我们可以将这个Move函数从System中移出来放到一个单例中(即Shared Utility Function,有什么意义呢?这里需要注意,之前是多个System对这个数值的改动,现在则统一放到一个接口中完成了,可以降低逻辑的复杂性与出错的概率),之后将所有的Side Effect(即所有的改动操作?)都放到这个单例函数中完成,从而降低逻辑处理的复杂性,同时也能更容易避免因此带来的问题。

另外,当某个函数调用可能会导致后续System的Update逻辑存在问题(严重的Side Effect)时,可以考虑下这个函数调用是否必须要现在完成,是否可以延迟处理,从而避免Side Effect?

如果考虑后的结果是可以延迟访问,那么就可以考虑将导致严重Side Effect的数据缓存起来,之后等时机成熟之后再触发相关的逻辑(不过这种做法也有弊端,会导致调试的困难。)。

这里给了一个例子描述延迟访问的做法,假如创建部分Contact Effect会导致比较严重的Side Effect,那么我们可以考虑使用单例来进行这种工作,比如我们创建一个包含PendingContact Effect的数组,所有需要创建的Contact Effect都塞入到这个数组中,对于那些不会导致严重Side Effect的Contact Effect可以立即完成相关的处理工作,而对于那些会导致严重Side Effect的Contact Effect,则会在场景Update之前对这个数组进行处理,这个处理是通过一个唯一的入口(比如ResolveContactSystem)完成的。

延迟处理除了能够解决严重Side Effect的问题之外还有很多其他的优点,比如在性能上有所提升,因为数据都是提前塞入到一个数组中,而指令都是相同的,具有很高的缓存命中率。

虽然这些限制会导致后续遇到问题的解决方案非常受限,但是却可以给出一套可维护的,解耦的且简单的代码。

ECS的大致框架就介绍完了,下面介绍下ECS是如何用于简化网络代码(NetCode)的,OW是一个快速响应的游戏,而为了提高操作响应的速度,就需要提前对客户端进行预测计算,因为如果等待服务器的消息下发才进行表现的话,响应是不可能快得起来的。

但是预表现的一个问题则在于客户端会有作弊的行为,是不可信任的,因此需要服务器进行校验,而这种做法就会导致当客户端与表现跟服务器最终的结果契合度比较低时,就会存在miss prediction导致的卡顿、拉扯等异常表现,因此要想得到一个较好的表现,就要降低miss prediction的几率。

这是下面要介绍的内容大纲,会给出一套新的用于实现较优预表现的方案,并介绍如何使用ECS来降低整个方案的复杂度,当然这里并不会覆盖所有内容,如上图中列举的部分内容这里是没有涉及的,因为这些方案在前人的著述中已经有了非常详细的描述,而OW也没有做出特别突出的创新与优化,所以这里就不做赘述了。

OW是通过一种叫做determinism模拟的方案来降低miss prediction(误判)的,这种方案需要依赖上图所描述的几个要素。

客户端跟服务器都是在同步的时钟以及量子化的数值(quantized values,这个是啥意思,固定精度的浮点数?)上进行操作的,时间被划分成以16ms为间隔的command frames(命令帧),而锦标赛(tournament)的命令帧间隔则是7ms。

由于命令帧间隔是固定的,因此这里需要将计算机的循环时钟转换成定点的命令帧数,转换的方法就是累加取整保留小数继续下一轮累加。

在OW的ECS方案中,任意需要根据玩家的输入在客户端上对角色进行预表现计算的System会使用一个跟前面提到的Update不同的API,即UpdateFixed,这个接口每个命令帧都会执行一次。

假设客户端时钟这边输出的稳定的output stream永远(最大?)超前服务器半个RTT的时长加上一个buffered command的时长(客户端按照当前的输入进行预表现,同时发送相应的输入数据到服务器,中间的传输时间就是半个RTT,而最坏的情况就是刚刚过去了一个命令帧,需要等待下一个命令帧才能进行输入数据的发送),也就是说,这里就只有ping time加上command处理时长的超前时间,那么假设RTT为160ms,那么客户端相对于服务器的超前时长就是80+16(命令帧间隔)=96ms,接近1/10 s。

上图中的垂直竖线表示的一个个的命令帧,那么客户端预表现的结果就会超前于服务器模拟的结果。而如果客户端需要等待服务器的正确结果下发,就还需要另外半个RTT的时长,那么客户端的预表现结果与收到正确结果之间的间隔将等于命令帧间隔+RTT。

为了能实现快速矫正,这里客户端需要将命令帧间隔+RTT时长内的客户端模拟结果存入到一个buffer中(环形buffer,此外还会有另外一个环形buffer用来存储客户端收到的输入),在收到服务器的确认消息后对buffer中的结果与服务器下发结果进行比对,如果两者一致,就将这个缓存的快照取出丢掉,否则就会以服务器的数据为准,之后在这个基础上叠加此前客户端收到的输入数据进行重新计算,并根据计算的结果对当前角色的状态进行修正(可以直接硬切,也可以做一个平滑过渡)。

这里还有一个问题,客户端向服务器发送的stream是不可靠的(OW的消息发送是通过UDP加上一层可选的可靠层完成的,当可靠层没有用的时候,就会导致传输消息丢失,inconsistent & lossy),服务器会维护一个较小的buffer用于接收客户端发送过来的输入数据,当服务器没有收到对应的数据的时候,就会触发一个guess操作,对客户端的输入进行猜测(相当于服务器版本的预表现),最常见的猜测方法就是直接复用上一个输入,而当后续客户端发送的输入数据抵达的时候,就会进行一轮比对,当发现猜测输入与真实输入不一致的时候,就会触发服务器这边的调整处理。

在服务器没有收到客户端的输入时,还会发送消息给客户端,通知说丢包了。客户端收到丢包消息后,不会对消息进行重传,而是会降低命令帧的间隔,加快消息发送频率,这种做法的意义何在呢?

因为传输延迟与消息丢失的原因,数据重传是没有意义的,只会使得表现更糟糕,此时直接以服务器的推测为准,对客户端而言,如果服务器按照此前的输入进行推测是正确的话,那么表现上不会有任何问题,而如果不正确,那么对客户端而言,表现也就相当于某个操作failed,直接按照此前服务器确认消息对客户端的预表现进行修正的处理逻辑工作就好了,表现上也不会有什么问题。而此时客户端收到丢包消息开始加速模拟的作用在于,可以降低同步延迟(因为前面说过,延迟时长等于RTT+命令帧间隔,现在缩小命令帧间隔,就能缩小两者之间的延迟,此外降低同步延迟还有助于消除因为丢包带来的影响,因为服务器虽然收到的包丢了一个,但是由于间隔变小,丢包的影响也就相应下降了,相当于提升因为网络质量下降导致的表现问题,最后,由于客户端发送到服务器的包变多了,那么服务器的buffer中存储的数据就变多了,偶尔丢了一个,也可以从前后两个包中进行插值来得到更为精准的一个输入猜测)从而提升表现。不过降低同步延迟并不是没有代价的,网络带宽计算频率等消耗对客户端与服务器都是一种负担,因此如果等到后面丢包的影响已经消弭了,这里还需要再将命令帧时间再调整回来。

在丢包处理上,OW做得更多,服务器不但会使用上一次的输入进行模拟,同时客户端这边还会在下一次输入时附带上最近一段时间(从被服务器确认的输入到当前最新的输入这一段时间)内的所有输入数据,这个叫做滑动窗口技术(在雷神之锤中就使用了这种方案)。因为玩家操作的速度不会超过帧率(即1/60s才会有一次操作,而这种极限情况一般很少会达到),因此输入数据不会很多,再经过压缩处理,最终整合起来的包体也不会很大。通过这种方式,可以进一步纠正因为丢包而导致的预测错误。

实践证明这种方案可以得到非常不错的表现,非常适合那些网络抖动比较厉害的应用环境。

这里来介绍一下ECS在命中处理中的工作原理,OW将命中处理单独抽离成一个System负责所有Entity的命中处理逻辑,ECS的做法是不关心Entity的具体信息,只需要判断当前Tuple中是否包含Hostile组件与ModifyHealthQueue组件,前者用于判断是否敌对,后者则用于进行伤害处理。因为伤害处理的调用入口较多,所以会存在比较大的Side Effect,此外,为了避免子弹等抛射物在抛射途中就生成一大堆特效,这里将伤害处理逻辑做延后处理。

虽然伤害处理不会放在客户端上进行,但是命中判断并进行相应表现(比如受击特效等)则是放在客户端上完成的,而客户端会根据服务器下发的两次位置消息通过内插来给出从属角色(其他客户端主控角色)的位置数据,这些数据是通过MovementState组件完成的。

MovementState组件用于控制角色位置,进行命中判定等逻辑,而伤害处理则放在另外的组件中由其他System完成,两者分开处理。

命中判定跟移动一样,也会出现错误预判的情况。如上图中绿色球体组合的对象为客户端视角下的对手位置,而黄色球体则是服务器视角下的真实位置,当Ping值特别高的时候,就容易出现命中判定预测错误的情况。

OW中当Ping值超过220ms,客户端会放弃预测,直接等待服务器下发对手的数据,并据此进行外插。这是因为网络表现很差的时候,预测会存在很多问题,比如玩家在被射击的时候费尽力气躲到墙角,结果还是被服务器下发数据拉回到战场之后被干掉,这种心情极为不爽。

当Ping值超过300ms之后,OW会进一步放弃碰撞等数据的预测。因为对手的数据距离真实数据已经相差太远了,OW这边使用了Dead Reckoning导航推测算法,虽然结果相差不远,但是就是不正确,外插结果已经严重滞后甚至失真了,对于这种情况,OW已经放弃处理了,因为玩家网络太差了,失去了照顾的价值。

原视频中还介绍了Ping值超过1s时表现,不过基本上没啥看头了,这里就不介绍了。

ECS简化了网络处理的逻辑,跟网络相关的系统中,一只手都可以数得出来:
NetworkEvent负责接收消息进行同步处理;
NetworkMessage负责消息发送;
InterpolateMovement负责位置内插逻辑;
下面三个负责主要的与网络相关的游戏逻辑:
Weapons
StateScript
MovementState负责位置同步相关逻辑?

而右边对应的则是这些系统所关联的components。

设计层面上而言,最佳的ECS实践方法是每个System向上图一样实现对Tuple进行访问,组成Tuple的component则是显式可见的,此前components通过sibling方法搜索的方法会使得Component的访问显得含混不清,不过对于一些复杂系统而言,可能会导致显式的tuple长度达到40+个components,这会使得系统过于复杂。

使用Tuple设计的一个好处是,给定System,我们就提前知道了所需要访问的数据,而给定Component就能够知道能够访问这些component的system,同时也知道哪些system对于component的访问是只读的,这样可以更加方便进行多线程编程。

Entity的生命周期管理这里需要介绍一下,最开始的时候,是将Entity的创建放在每帧的最后,但是引发的一个问题是,假设B System需要访问A System中的Entity,就会出现访问不到的情况,因此最终这边的做法是将Entity的创建放在每帧的中间,之后访问放在更后边。这个是一个非常常见且困扰的问题,目前这种方案也只是一种尝试,后面或许会遇到一些问题。

这里给出ECS使用中的一些规则:

  1. Component没有逻辑
  2. System没有数据
  3. 共享代码放到公共函数中
  4. 复杂的Side Effects考虑延时调用
  5. System尽量解耦

不过OW在开发过程中遇到了一些问题,比如从其他老的游戏项目中挪移过来的代码,要按照这个规则进行改造会遇到很多问题,而实际上这些挪移过来的代码自成体系,不会与其他模块发生交互,实际上这种情况最好的处理办法就是ECS在上层做个封装,底层实现可以使用任何的模式(比如OOP),因为这种做法并不会有损于最终的ECS模块组织方式的效益,实际上后面会说,ECS就是一个胶水系统,负责将各个复杂的模块胶合到一起,它只负责处理冰山之上的部分,冰山之下的由各个系统自己完成处理。

这个是系统的冰山模型,各个System底下可能存在非常复杂的逻辑处理,而ECS只负责最上层的组织架构,一些没有必要被ECS所知道的数据与逻辑就可以隐藏在水面之下,通过这种方式完成众多复杂模块的整合。

最终做个总结:

  1. ECS是OW中的胶水框架,负责将各个复杂模块整合到一个清晰而解耦的框架中
  2. ECS可以将各个系统之间的耦合降到最低
  3. ECS可以为胶水代码提供一个统一的约束框架,这样可以实现更为清晰且直观的编程
  4. Netcode非常复杂,因此最好将之从其他系统中解耦出来

参考

[1] Overwatch Gameplay Architecture and Netcode

相关文章

网友评论

      本文标题:【GDC2017】Overwatch Gameplay Arch

      本文链接:https://www.haomeiwen.com/subject/tihcjltx.html