前不久kevinan编译了该文章并首发于腾讯GAD,游戏葡萄已获转载授权。

哈喽,大家好,这次的分享是关于《守望先锋》游戏架构设计和网络部分。
老规矩,手机调成静音;离开时记得填写调查问卷;换下半藏,赶紧推车!
(众笑)

我是Tim Ford,是暴雪公司《守望先锋》开拓团队老大。
自从2013年夏季项目启动以来就在这个团队了。
在那之前,我在《Titan》项目组,不过这次分享跟Titan没有半毛钱关系。
(众笑)

这次分享的一些技能,是用来降落一直增长的代码库的繁芜度(译注,代码繁芜度的观点须要读者自行查阅)。
为了达到这个目的我们遵照了一套严谨的架构。
末了会通过谈论网络同步(netcode)这个实质很繁芜的问题,来解释详细如何管理繁芜性。

守望先锋首席工程师我们是若何做架构设计与收集同步的

《守望先锋》是一个近未来天下不雅观的在线团队英雄射击游戏,它的紧张特点是英雄的多样性, 每个英雄都有自己的独门绝技。

《守望先锋》利用了一个叫做“实体组件系统”的架构,接下来我会简称它为ECS。

ECS不同于一些现成引擎中很盛行的那种组件模型,而且与90年代后期到21世纪早期的经典Actor模式差异更大。
我们团队对这些架构都有多年的履历,以是我们选择用ECS有点是“这山望着那山高”的意味。
不过我们事先制作了一个原型,以是这个决定并不是一时冲动。

开拓了3年多往后,我们才创造,原来ECS架构可以管理快速增长的代码繁芜性。
虽然我很乐意分享ECS的优点,但是要知道,我本日所讲的统统实在都是事后诸葛亮 。

ECS架构概述

ECS架构看起来便是这样子的。
先有个World,它是系统(译注,这里的系统指的是ECS中的S,不是一样平常意义上的系统,为了方便阅读,下文统称System)和实体(Entity)的凑集。
而实体便是一个ID,这个ID对应了组件(Component)的凑集。
组件用来存储游戏状态并且没有任何的行为(Behavior)。
System有行为但是没有状态。

这听起来可能挺让人惊异的,由于组件没有函数而System没有任何字段。

ECS引擎用到的System和组件

图的左手边因此轮询顺序排列的System列表,右边是不同实体拥有的组件。
在左边选择不同的System往后,就像弹钢琴一样,所有对应的组件会在右边高亮显示,我们管这叫组件元组(译注,元组tuple,从后文来看,紧张浸染便是可以调用Sibling函数来获取同一个元组内的组件,有点虚拟分组的意思)。

System遍历检讨所有元组,并在其状态(State)上实行一些操作(也便是行为Behavior)。
记住组件不包含任何函数,它的状态都是裸存储的。

绝大多数的主要System都关注了不止一个组件,如你所见,这里的Transform组件就被很多System用到。

来自原型引擎里的一个System轮询(tick)的例子

这个是物理System的轮询函数,非常刀切斧砍,便是一个内部物理引擎的定时更新。
物理引擎可能是Box2d或者是Domino(暴雪自有物理引擎)。
实行完物理天下的仿照往后,就遍历元组凑集。
用DynamicPhysicsComponent组件里保存的proxy来取到底层的物理表示,并把它复制给Transform组件和Contact组件(译注:碰撞组件,后文会大量用到)。

System不知道实体到底是什么,它只关心组件凑集的小切片(slice,译注:可以理解为特定子凑集),然后在这个切片上实行一组行为。
有些实体有多达30个组件,而有些只有2、3个,System不关心数量,它只关心实行操作行为的组件的子集。

像这个原型引擎里的例子,(指着上图7中)这个是玩家角色实体,可以做出很多很酷的行为,右边这些是玩家能够发射的子弹实体。

每个System在运行时,不知道也不关心这些实体是什么,它们只是在实体干系组件的子集上实行操作而已。

《守望先锋》里的(ECS架构的)实现,便是这样子的。

EntityAdmin是个World,存储了一个所有System的凑集,和一个所有实体的哈希表。
表键是实体的ID。
ID是个32位无符号整形数,用来在实体管理器(Entity Array)上唯一标识这个实体。
另一方面,每个实体也都存了这个实体ID和资源句柄(resource handle),后者是个可选字段,指向了实体对应的Asset资源(译注:这须要依赖暴雪的另一套专门的Asset管理系统),资源定义了实体。

组件Component是个基类,有几百个子类。
每个子类组件都含有在System上实行Behavior时所需的成员变量。
在这里多态唯一的用途便是重载Create和析构(Destructor)之类的生命周期管理函数。
而其他能被继续组件类实例直策应用的,就只有一些用来方便地访问内部状态的helper函数了。
但这些helper函数不是行为(译注:这里强调是为了遵照前面提到的原则:组件没有行为),只是大略的访问器。

EntityAdmin的结尾部分会调用所有System的Update。
每个System都会做一些事情。
上图9便是我们的利用办法,我们没有在固定的元组组件凑集上实行操作,而是选择了一些根本组件来遍历,然后再由相应的行为去调用其他兄弟组件。
以是你可以看到这里的操作只针对那些含有Derp和Herp组件的实体的元组实行。

《守望先锋》客户真个System和组件列表

这里有大概46不同的System和103个组件。
这一页的炫酷动画是用来吸引你们看的(众笑)。

然后是做事器

你可以看到有些System实行须要很多组件,而有些System仅仅须要几个。
空想情形下,我们只管即便确保每个System都依赖很多组件去运行。
把他们当成纯函数(译注,pure function,无副浸染的函数),而不改变(mutating)它们的状态,就可以做到这一点。
我们的确有少量的System须要改变组件状态,这种情形下它们必须自己管理繁芜性。

下面是个真实的System代码

这个System是用来管理玩家连接的,它卖力我们所有游戏做事器上的逼迫下线(译注,AFK, Away From Keyboard,表示永劫光没操作而被认为离线)功能。

这个System遍历所有的Connection组件(译注:这里不太得当直接翻译成“连接”),Connection组件用来管理做事器上的玩家网络连接,是挂在代表玩家的实体上的。
它可以是正在进行比赛的玩家、不雅观战者或者其他玩家掌握的角色。
System不知道也不关心这些细节,它的职责便是逼迫下线。

每一个Connection组件的元组包含了输入流(InputStream)和Stats组件(译注:看起来是用来统计战斗信息的)。
我们从输入流组件读入你的操作,来确保你必须做点什么事情,例如键盘按键;并从Stats组件读取你在某种程度上对游戏的贡献。

你只要做这些操作就会一直重置AFK定时器,否则的话,我们就会通过存储在Connection组件上的网络连接句柄发给你的客户端,踢你下线。

System上运行的实体必须拥有完全的元组才能使得这些行为能够正常事情。
像我们游戏里的机器人实体就没有Connection组件和输入流组件,只有一个Stats组件,以是它就不会受到逼迫下线功能的影响。
System的行为依赖于完全凑集的“切片”。
坦率来说,我们也确实没必要摧残浪费蹂躏资源去让逼迫机器人下线。

为什么不能直接用传统面向工具编程模型?

上面System的更新行为会带来了一个疑问:为什么不能利用传统的面向工具编程(OOP)的组件模型呢?例如在Connection组件里重载Update函数,一直地跟踪检测AFK?

答案是,由于Connection组件会同时被多个行为所利用,包括:AFK检讨;能吸收网络广播的已连接玩家列表;存储包括玩家名称在内的状态;存储玩家已解锁造诣之类的状态。
以是(如果用传统OOP办法的话)详细哪个行为该当放在组件的Update中调用?别的部分又该当放在哪里?

传统OOP中,一个类既是行为又是数据,但是Connection组件不是行为,它就只是状态。
Connection完备不符合OOP中的工具的观点,它在不同的System中、不同的机遇下,意味着完备不同的事情。

那么把行为和状态区分开,又有什么理论上的上风(conceptual advantages)呢?

想象一下你家前院盛开的樱桃树吧,从主不雅观上讲,这些树对付你、你们小区业委会主席、园丁、一只鸟、房产税官员和白蚁而言都是完备不同的。
从描述这些树的状态上,不同的不雅观察者会瞥见不同的行为。
树是一个被不同的不雅观察者差异对待的主体(subject)。

类最近说,玩家实体,或者更准确地说,Connection组件,便是一个被不同System差异对待的主体。
我们之前谈论过的管理玩家连接的System,把Connection组件视为AFK踢下线的主体;连接实用程序(ConnectUtility)则把Connection组件看作是广播玩家网络的主体;在客户端上,用户界面System则把Connection组件当做记分板上带有玩家名字的弹出式UI元素主体。

Behavior为什么要这么搞?结果看来,根据主体视角区分所有Behavior,这样来描述一棵树的全部行为会更随意马虎,这个道理同样也适用于游戏工具(game objects)。

然而随着这个工业级强度的ECS架构的实现,我们碰着了新的问题。

首先我们纠结于之前定下的规矩:组件不能有函数;System不能有状态。
显而易见地,System该当可以有一些状态的,对吧?一些从其他非ECS架构导入的遗留System都有成员变量,这有什么问题吗?举个例子,InputSystem, 你可以把玩家输入信息保存在InputSystem里,而其他System如果也须要感知按键是否被按下,只须要一个指向InputSystem的指针就能实现。

在单个组件里存储一个全局变量看起来很很屈曲,由于你开拓一个新的组件类型,不可能只实例化一次(译注:这里的意思是,如果实例化了多次,就会有多份全局变量的拷贝,明显不合理),这一点无需证明。
组件常日都是按照我们之前瞥见过的那种办法(译注:指的是通过ComponentItr<>函数模板那种办法)来迭代访问,如果某个组件在全体游戏里只有一个实例,那这样访问就会看起来比较怪异了。

无论如何,这种办法撑了一阵子。
我们在System里存储了一次性(one-off)的状态数据,然后供应了一个全局访问办法。
从图16可以看到全体访问过程(译注:重点是g_game->m_inputSystem这一行)。

如果一个System可以调用其余一个System的话,对付编译韶光来说就不太友好了,由于System须要相互包含(include)。
假定我现在正在重构InputSystem,想移动一些函数,修正头文件(译注:Client/System/Input/InputSystem.h),那么所有依赖这个头文件去获取输入状态的System都须要被重新编译,这很烦人,还会有大量的耦合,由于System之间相互暴露了内部行为的实现。

从图16最下面可以瞥见我们有个PostBuildPlayerCommand函数,这个函数是InputSystem在这里的紧张代价。
如果我想在这个函数里增加一些新功能,那么CommandSystem就须要根据玩家的输入,添补一些额外的构造体信息发给做事器。
那么我这个新功能该当加到CommandSystem里还是PostBuildPlayerCommand函数里呢?我正在System之间相互暴露内部实现吗?

随着系统的增长,选择在何处添加新的行为代码变得模棱两可。
上面CommandSystem的行为添补了一些构造体,为什么要混在一起?又为什么要放到这里而不是别处?

无论如何,我们就这样凑合了好一阵子,直到去世亡回放(Killcam)需求的涌现。

为了实现Killcam,我们会有两个不同的、并行的游戏环境,一个用来进行实时游戏过程渲染,一个用来专门做Killcam。
我接下来会展示它们是如何实现的。

首先,也很直接,我会添加第二个全新的ECS World,现在就有两个World了,一个是liveGame(正常游戏),一个是replayGame用来实现回放(Replay)。

回放(Replay)的事情办法是这样的,做事器会下发大概8到12秒旁边的网络游戏数据,接着客户端翻转World,开始渲染replayAdmin这个World的信息到玩家屏幕上。
然后转发网络游戏数据给replayAdmin,假装这些数据真的是来自网络的。
此时,所有的System,所有的组件,所有的行为都不知道它们并没有被预测(predict,译注:后面才讲到的同步技能),它们以为客户端便是实时运行在网络上的,像正常游戏过程一样。

听起来很酷吧?如果有人想要理解更多关于回放的技能,我建议你们来日诰日去听一下Phil Orwig的分享,也是在这个房间,上午11点整。

无论如何,到现在我们已经知道的是:首先,所有须要全局访问System的调用点(call sites)会溘然出错(译注:Tim思维太跳跃了,溘然话锋一转,完备跟不上);其余,不再只有唯一一个全局EntityAdmin了,现在有两个;System A无法直接访问全局System B,不知怎地,只能通过共享的EntityAdmin来访问了,这样很绕。

在Killcam之后,我们花了很永劫光来回顾我们的编程模式的毛病,包括:怪异的访问模式;编译周期太长;最危险的是内部系统的耦合。
看起来我们有大麻烦了。

针对这些问题的终极办理方案,依赖于这样一个事实:开拓一个只有唯一实例的组件实在没什么不对!
根据这个原则,我们实现了一个单例(Singleton)组件。

这些组件属于单一的匿名实体,可以通过EntityAdmin直接访问。
我们把System中的大部分状态都移到了单例中。

这里我要提一句,只须要被一个System访问的状态实在是很罕见的。
后来在开拓一个新System的过程中我们保持了这个习气,如果创造这个别系须要依赖一些状态。
就做一个单例来存储,险些每一次都会创造其他一些System也同样须要这些状态,以是这里实在已经提前办理了前面架构里的耦合问题。

下面是一个单例输入的例子。

全部按键信息都存在一个单例里面,只是我们把它从InputSystem中移出来了。
任何System如果想知道按键是否按下,只须要随便拿一个组件来讯问(那个单例)就行了。
这样做往后,一些很麻烦的耦合问题消逝了,我们也更加遵照ECS的架构哲学了:System没有状态;组件不带行为。

按键并不是行为,掌管本地玩家移动的Movement System里有一个行为,用这个单例来预测本地玩家的移动。
而MovementStateSystem里有个行为是把这些按键信息打包发到做事器(译注:按键对付不同的System就不是不同的主体)。

结果创造,单例模式的利用非常普遍,我们全体游戏里的40%组件都是单例的。

一旦我们把某些System状态移到单例中,会把共享的System函数分解成Utility(实用)函数,这些函数须要在那些单例上运行,这又有点耦合了,我们接下来会详细谈论。

改造后如图22,InputSystem依然存在(译注:然而并没有看到InputSystem在哪里),它卖力从操作系统读取输入操作,添补SingletonInput的值,然后下贱的其他System就可以得到同样的Input去做它们想做的。

像按键映射之类的事情就可以在单例里实现,就与CommandSystem解耦了。

我们把PostBuildPlayerCommand函数也挪到了CommandSysem里,本应如此,现在可以担保所有对玩家输入的命令(PlayerCommand)的修正都能且仅能在此处进行了。
这些玩家命令是很主要的数据构造,将来会在网络上同步并用来仿照游戏过程。

在引入单例组件时,我们还不知道,我们实在正在打造的是一个解耦合、降落繁芜度的开拓模式。
在这个例子中,CommandSystem是唯一一处能够产生与玩家输入命令干系副浸染的地方(译注:sideeffect,指当调用函数时,除了返回函数值之外,还对主调用函数产生附加影响,例如修正全局变量了)。

每个程序员都能轻易地理解玩家命令的变革,由于在一次System更新的同一时候,只有这一处代码有可能产生变革。
如果想添加针对玩家命令的修正代码,那也很明朗,只能在这个源文件中改,所有的模棱两可都消逝了。

现在谈论其余一个问题,与共享行为(sharedbehavior)有关。

共享行为一样平常涌如今同一行为被多个System用到的时候。

有时,同一个主体的两个不雅观察者,会对同一个行为感兴趣。
回到前面樱花树的例子,你的小区业委会主席和园丁,可能都想知道这棵树会在春天到来的时候,掉落多少叶子。

根据这个输出可以做不同的处理,至少主席可能会冲你大喊大叫,园丁会老诚笃实回去干活,但是这里的行为是相同的。

举个例子,大量代码都会关心“敌对关系”,例如,实体A与实体B相互敌对吗?敌对关系是由3个可选组件共同决定的:filter bits,pet master和pet。
filter bits存储军队编号(team index);pet master存储了它所拥有全部pet的唯一键;pet一样平常用于像托比昂的炮台之类。

如果2个实体都没有filter bits,那么它们就不是敌对的。
以是对付两扇门来说,它们就不是敌对的,由于它们的filter bits组件没有军队编号。

如果它们(译注:2个实体)都在同一个军队,那自然就不是敌对的,这很随意马虎理解。

如果它们分别属于永久敌对的2个军队,它们会同时检讨自己身上和对方身上的pet master组件,确保每个pet都和对方是敌对关系。
这也办理了一个问题:如果你跟每个人都是敌对的,那么当你建造一个炮台时,炮台会立马攻击你(译注:完备没理解为什么会这样)。
确实会的,这是个bug,我们修复了。
(众笑)

如果你想检讨一枚翱翔中的炮弹的敌对关系,只须要回溯检讨射出这枚炮弹的开火者就行了,很大略。

这个例子的实现,实在便是个函数调用,函数名是CombatUtilityIsHostile,它接管2个实体作为参数,并返回true或者false来代表它们是否敌对。
无数System都调用了这个函数。

图25中便是调用了这个函数的System,但是如你所见,只用到了3个组件,少得可怜,而且这3个组件对它们都是只读的。
更主要的是,它们是纯数据,而且这些System绝不会修正里面的数据,仅仅是读。

再举一个用到这个函数的例子。

作为一个例子,当用到共享行为的Utility函数时我们采取了不同的规则。

如果你想在多处调用一个Utility函数,那么这个函数就该当依赖很少的组件,而且不应该带副浸染或者很少的副浸染。
如果你的Utility函数依赖很多组件,那就试着限定调用点的数量。

我们这里的例子叫做CharacterMoveUtil,这个函数用来在游戏仿照过程中的每个tick里移动玩家位置。
有两处调用点,一处是在做事器上仿照实行玩家的输入命令,另一处是在客户端上预测玩家的输入。

我们连续用Utility函数更换 System间的函数调用,并把状态从System移到单例组件中。

如果你打算用一个共享的Utility函数更换System间的函数调用,是不可能自动地(magically)避免繁芜性的,险些都得做语句级的调度。

正如你可以把副浸染都隐蔽在那些公开访问的System函数后面一样,你也可以在Utility函数后面做同样的事。

如果你须要从好几处调用那些Utility函数,就会在全体游戏循环中引入很多严重的副浸染。
虽然是在函数调用后面发生的,看起来没那么明显,但这也是相称恐怖的耦合。

如果本次分享只让你学到一点的话,那最好是:如果只有一个调用点,那么行为的繁芜性就会很低,由于所有的副浸染都限定到函数调用发生的地方了。

下面浏览一下我们用来减少这类耦合的技能。

当你创造有些行为可能产生严重的副浸染,又必须实行时,先问问你自己:这些代码,是必须现在就实行吗?

好的单例组件可以通过“推迟”(Deferment)来办理System间耦合的问题。
“推迟”存储了行为所需状态,然后把副浸染延后到当前帧里更好的机遇再实行。

例如,代码里有好多调用点都要天生一个碰撞殊效(impact effects)。

包括hitscan(译注:直射,没有翱翔韶光)子弹;带翱翔韶光的可爆炸抛射物;查里娅的粒子光束,光束长得就像墙壁裂痕,而且在开火时须要保持打仗目标;其余还有喷涂。

创建碰撞殊效的副浸染很大,由于你须要在屏幕上创建一个新的实体,这个实体可能间接地影响到生命周期、线程、场景管理和资源管理。

碰撞殊效的生命周期,须要在屏幕渲染之前就开始,这意味着它们不须要在游戏仿照的中途显现,在不同的调用点都是如此。

下图30是用来创建碰撞殊效的一小部分代码。
基于Transform(译注:变形,包括位移旋转和缩放)、碰撞类型、材质构造数据来做碰撞打算,而且还调用了LOD、场景管理、优先级管理等,最终生成了所需的殊效。

这些代码确保了像弹孔、焦痕持久殊效不会很奇怪的叠在一起。
例如,你用猎空的枪去射击一壁墙,留下了一堆麻点,然后法老之鹰发出一枚火箭弹,在麻点上面造成了一个大面积焦痕。
你肯定想删了那些麻点,要不然看起来会很丑,像是那种深度冲突(Z-Fighting)引起的闪烁。
我可不想在到处去实行那个删除操作,最好能在一处搞定。

我得修正代码了,但是看上去好多啊,调用点一大堆,改完了往后每一处都须要测试。
而且往后英雄越来越多,每个人都须要新的殊效。
然后我就到处复制粘贴这个函数的调用,没什么大不了的,不便是个函数调用嘛,又不是什么噩梦。
(众笑)

实在这样做往后,会在每个调用点都产生副浸染的。
程序员就得花费更多脑力来记住这段代码是如何运作的,这便是代码繁芜度所在,肯定是该当避免的。

于是我们有了Contact单例。

它包含了一个未决的碰撞记录的数组,每个记录都有足够的信息,来在本帧的晚些时候创建那个殊效。
如果你想要天生一个殊效的时候,只须要添加一条新记录并填充数据就可以了。
等运行到帧的后期,进行场景更新和准备渲染的时候,ResolveContactSystem会遍历数组,根据LOD规则天生殊效并相互叠加。
这样的话,纵然有严重的副浸染,在每一帧也只是发生在一个调用点而已。

除了降落繁芜度以外,“推迟”方案还有很多其他优点。
数据和指令都缓存在本地,可以带来性能提升;你可以针对殊效做性能预算了,例如你有12个D.VA同时在射墙,她们会带来数百个殊效,你不用立即创建全部这些殊效,你可以仅仅创建自己操纵的D.VA的殊效就可以了,其他殊效可以在后面的运算过程等分放开来,平滑性能毛刺。
这样做有很多好处,真的,你现在可以实现一些繁芜的逻辑了。
纵然ResolveContactSystem须要实行多线程协作,来确定单个粒子效果的朝向, 现在也很随意马虎做。
“推迟”技能真的很酷。

Utility函数,单例,推迟,这些都只是我们过去3年韶光建立ECS架构的一小部分模式。
除了限定System中不能有状态,组件里不能有行为以外,这些技能也规定了我们在《守望先锋》中如何办理问题。

遵守这些限定意味着你要用很多奇技淫巧来办理问题。
不过,这些技能终极造就了一个可持续掩护的、解耦合的、简洁的代码系统。
它限定了你,它把你带到坑里,但这是个“成功之坑”。

学习了这些之后呢,咱们来聊聊真正的难题之一,以及ECS是如何简化它的。

作为gameplay(游辱弄法,机制)工程师,我们办理过的最主要的问题便是网络同步(netcode)。

这里先说下目标,是要开拓一款快速相应(responsive)的网络对战动作游戏。
为了实现快速相应,就必须针对玩家的操作做预测(predict,也可以说是预表现)。
如果每个操作都要等做事器回包的话,就不可能有高相应性了。
只管由于一些忘八玩家作弊以是不能信赖客户端,但是已经20年了,这条FPS游戏真理没变过。

游戏中有快速相应需求的操作包括:移动,技能,就我们而言还有带技能的武器,以及命中剖断(hit registration)。

这里所有的操作都有统一的原则:玩家按下按键后必须立即能够看到相应。
纵然网络延迟很高时也必须是如此。

像我这页PPT中演示的那样,ping值已经250ms了,我所有的操作也都是立即得到反馈的,“看上去”很完美,一点延迟都没有。

然而呢,带预测的客户端,做事器的验证和网络延迟就会带来副浸染:预测缺点(misprediction,或者说预测失落败)了。
预测缺点的紧张症状就一点,会使得你没能成功实行“你认为你已经做出的”操作。

虽然做事器须要纠正你的操作,但代价并不会是操作延迟。
我们会用”确定性”(Determinism)来减少预测缺点发生的概率,下面是详细的做法。

条件条件不变,PING值还是250毫秒。
我认为我跳起来了,但是做事器不这么看,我被猛拉回原地,而且被冻住了(冰冻是英雄Mei的技能之一)。
这里(PPT中视频演示)你乃至可以看到全体预测的事情过程。
预测过程开始时,试图把我们移到空中,乃至大猩猩跳跃技能的CD都已经进入冷却了,这是对的,我们不肯望预测准确率仅仅是十之八九。
以是我们希望尽可能的快速相应。

如果你恰巧在斯里兰卡玩这个游戏,而且又被Mei冻住了,那么就有可能会预测缺点。

下面我会首先给出一些准则,然后谈论一下这个崭新的技能是如何利用ECS来减少繁芜度的。

这里不会涉及到通用的数据复制技能、远端实体插值(remote entity interpolation)或者是向后缓和(backwardsreconciliation)技能细节。

我们完备是站在巨人的肩膀上,利用了一些其他文献中提过的技能而已。
后面的幻灯片会假定大家对那些技能都已经很熟习了。

确定性(Determinism)

确定性仿照技能依赖于时钟的同步,固定的更新周期和量化。
做事器和客户端都运行在这个保持同步的时钟和量化值之上。
韶光被量化成command frame,我们称之为“命令帧”。
每个命令帧都是固定的16毫秒,不过在电竞比赛时是7毫秒。

仿照过程的频率是固定的,以是须要把打算机时钟循环转换为固定的命令帧序号。
我们利用了一个循环累加器来处理帧号的增长。

在我们的ECS框架内,任何必要进行预表现、或者基于玩家的输入仿照结果的System,都不会利用Update,而是用UpdateFixed。
UpdateFixed会在每个固定的命令帧调用。

假定输出流是稳定的,那么客户真个始终总是会超前于做事器的,超前了大概半个RTT加上一个缓存帧的时长。
这里的RTT是PING值加上逻辑处理的韶光。
上图39的例子中,我们的RTT是160毫秒,一半便是80毫秒,再加上1帧,我们每帧是16毫秒,全加起来便是客户审察对付做事器的提前量。

图中的垂直线代表每一个处理中的帧。
客户端开始仿照并把第19帧的输入上报给做事器,过一段韶光(基本上是半个RTT加上缓冲韶光)往后,做事器才开始仿照这一帧。
这便是我为什么要说客户端永久是领先于做事器的。

正由于客户端是一股脑的尽快接管玩家输入,尽可能地贴近现在时候,如果还须要等待做事器回包才能相应的话,那看起来就太慢了,会让游戏变得卡顿。
图39中的缓冲区,你肯定希望尽可能的小(译注:缓冲越小,仿照时就越靠近当前时候),顺便说一句,游戏运行的频率是60赫兹,我这里播放动画的速率是正常速率的百分之一(译注:这也是为了让不雅观众看得更清晰、明白)。

客户真个预测System读取当前输入,然后仿照猎空的移动过程。
我这里是用游戏摇杆来表示猎空的输入操作并上报的。
这里的(第14帧)猎空是我当前时候仿照出来的运动状态,经由完全的RTT加上缓冲事宜,终极猎空会从做事器上回到客户端(译注:这里最好结合演讲视频,静态的文章无法表达到位)。
这里回来的是经由做事器验证的运动状态快照。
做事器仿照威信带来的副浸染便是验证须要额外的半个RTT韶光才能回到客户端。

那么这里客户端为什么要用一个环形缓冲(ring buffer)来记录历史运动轨迹呢?这是为了方便与做事器返回的结果进行比拟。
经由比较,如果与做事器仿照结果相同,那么客户端会开愉快心地连续处理下一个输入。
如果结果不一致,那便是一个“预测缺点”,这时就须要“和解”(reconcile)了。

如果想大略,那就直接用做事器下发的结果覆盖客户端就行了,但是这个结果已经是“旧”(相对付当前时候的输入来讲)的了,由于做事器的回包一样平常都是几百毫秒之前的了。

除了上面那个环形缓冲以外,我们还有另一个环形缓冲用来存储玩家的输入操作。
由于处理移动的代码是确定性的,一旦玩家开始进入他想要进入到移动状态,想要重现这个过程也是很随意马虎的。
以是这里我们的处理办法便是,一旦从做事器回包创造预测失落败,我们把你的全部输入都重播一遍直至追上当前时候。
如下图41中的第17帧所示,客户端认为猎空正在跑路,而做事器指出,你已经被晕住了,有可能是受到了麦克雷的闪光弹的攻击。

接下来的流程是,当客户端收到描述角色状态的数据包时,我们基本上就得把移动状态及时规复到最近一次经由做事器验证过状态上去,而且必须重新打算之后所有的输入操作,直至追上当前时候(第25帧)。

现在客户端进行到第27帧(上图)了,这时我们收到了做事器上第17帧的回包。
一旦重新同步(译注:把稳下图41中客户端猎空的状态全都更正为“晕”了)往后,就相称于回退到了“帧同步”(lockstep)算法了。

我们肯定知道我们到底被晕了多久。

到了下图第33帧往后,客户端就知道已经不再是晕住的状态了,而做事器上也正在仿照相同的情形。
不再有奇怪的同步追赶问题了。
一旦进入这个移动状态,就可以重发玩家当前时候的操作输入了。

然而,客户端网络并不担保如此稳定,时有丢包发生。
我们游戏里的输入都是通过定制化的可靠UDP实现。
以是客户真个输入包常常无法到达做事器,也便是丢包。
做事器又试图保持了一个小小的、保存未仿照输入的缓冲区,但是让它只管即便的小,以担保游戏操作的流畅。

一旦这个缓冲区是空的,做事器只能根据你末了一次输入去“预测”。
等到真正的输入到达时,它会试着“缓和”,确保不会弄丢你的任何操作,但是也会有预测缺点。

下面是见证奇迹的时候。

上图可以看到,已经丢了一些来自客户真个包,做事器意识到往后,就会复制先前的输入操作来就行预测,一边祈祷希望预测精确,一边发包见告客户端:“嘿哥们,丢包了,不太对劲哦”。
接下来发生的就更奇怪的了,客户端会进行韶光膨胀,比约定的帧率更快地进行仿照。

这个例子里,约定好的帧速是16毫秒,客户端就会假装现在帧速是15.2毫秒,它想要更加提前。
结果便是,这些输入来的越来越快。
做事器上缓冲区也会随着变大,这便是为了在只管即便不摧残浪费蹂躏的情形下,度过(丢包的)难关。

这种技能运转良好,尤其是在常常抖动的互联网环境下,丢包和PING都不稳定。
纵然你是在国际空间站里玩这个游戏,也是可以的。
以是我想这个方案真的很NB。

现在,各位都记个条记吧,这里收到,现在开始放大韶光刻度,把稳我们是真的加速轮询了,你可以瞥见图中右边的坡越来越平坦了。
它比以前更加快速地上报输入。
同时做事器上的缓冲也越来越大了,可以容忍更多地丢包,如果真的发生丢包也有可能在缓冲期间补上。

一旦做事器创造,你现在的网络规复康健了,它就会发给你说:“嘿哥们,现在没事了”。
而客户端会做相反的事情:它会缩小韶光刻度,以更慢的速率发包。
同时做事器会减小缓冲区的尺寸。

如果这个过程持续发生,那目标就会是是不要超过承受极限,并通过输入冗余来使得预测缺点最小化。

早些时候我有提到过,做事器一旦饥饿,就会复制末了一次输入操作,对吧?一旦客户端遇上来了,就不会再复制输入了,这样会有由于丢包而被忽略的风险。
办理方法是,客户端坚持一个输入操作的滑动窗口。
这项技能从《雷神天下》开始就有了。

我们不是仅仅发送当前第19帧的输入,而是把从末了一次被做事器确认的运动状态到现在的全部输入都发送过去。
上面的例子可以看出,末了一次从做事器来的确认是第4帧。
而我们刚刚仿照到了第19帧。
我们会把每一帧的每一个输入都打包成为一个数据包。
玩家一样平常顶多每1/60秒才会有一次操作,以是压缩后数据量实在不大。
一样平常你按住“向前”按钮之前,很可能是已经在“提高”了。

结果便是,纵然发生丢包,下一个数据包到达时依然会有全部的输入操作,这会在你真正仿照以前,就添补上所有由于丢包而涌现的空洞。
以是这个反馈循环的过程和可增长的缓冲区大小,以及滑动窗口,使得你不会由于丢包而丢失什么。
以是纵然丢包也不会涌现预测缺点。

接下来会再次给你展示动画过程,这一次是双倍速,是正常速率的1/50了。

这里有全部不稳定成分:网络PING值抖动,有丢包,客户端韶光刻度放大,输入窗口添补了全部漏洞,有预测失落败,有做事器纠正。
我们它们都合在一起播放给你看。

接下来的议题,我不想讲太多细节,由于这是Dan Reid的分享的主题,由于这是开幕式的一部分,以是强烈推举各位听一下,真的很棒。
还是在这个房间,我讲完了就开始。

所有的技能都是用暴雪自有指令式脚本措辞Statescript开拓的。
脚本系统的一大优点便是它可以在前后穿越时空。
在客户端预测,然后做事器验证,就像之前的例子里面的移动操作,我们可以把你回滚然后重播所有输入。
技能也利用了与移动相同的前后滚原则,先回退到末了一次经由验证的快照的状态,然后重播输入直到当前时候。

大家肯定还记得这个例子,便是猎空被晕导致的做事器纠正过程,技能的处理过程是相同的。
客户端和做事器都会仿照技能实行的确定性过程,客户端领先于做事器,以是一样平常是客户端先仿照,做事器稍后跟进。
客户端处理预测缺点的办法是,先根据做事器快照回滚,然后再前滚(roll forth),就像这样幻灯演示的动画过程那样。
这里演示的是去世神的幽灵形态。
图45中的这些方块(译注:Statescript中的State)代表了幽灵形态,有了这些方块我就可以很自傲的播放很酷的殊效和动画了。

幽灵形态结束后就会关闭这些方块。
在同一帧中这些小动画会展示出State的关闭过程。
紧接着便是幽灵形态的涌现,不久往后我们就会得到来自做事器的:“嗨,我预测的幽灵形态的过程已经见告你了,以是你赶紧倒退回去,把这些State都打开,然后咱们再重新仿照全部输入,把这些State都关了”。
这基本上便是每次做事器下发更新时回滚和前滚的过程了。

能预测移动很酷,这意味着可以预测每个技能,我们也确实这样做了,同样,对付武器或者其他的模块,我们也可以这么做。

现在谈论一下命中剖断的预测和确认。

ECS处理这个实在很方便,还记得吗,实体如果拥有行为所需的组件元组,它就会是这个行为的主体。
如果你的实体是敌对的(还记得我们之前讲的敌对性检讨吧)而且你有一个ModifyHealthQueue组件,你就可以被别的玩家击中,这都受制于“命中剖断”。

这两个组件,一个是用来检讨敌对性的,一个是ModifyHealthQueue。
ModifyHealthQueue是做事器记录的你身上的全部侵害和治疗。
与单例Contact类似,也是延迟打算的,而且有多个调用点,这便是最大的副浸染。
延迟打算是由于不想在抛射物仿照途中,立即天生一大堆殊效,我们选择延后。

顺便说一句,侵害,也完备不会在客户端预测,由于它们全都是骗子。

然而命中剖断却是在客户端处理的。
以是,如果你有一个MovementState组件,而且是一个不会被本地玩家操纵的remote工具,那你会被运动 System经由插值(interpolate)运算来重新定位。
标准插值是发生在末了一次收到的两个MovementState之间的,这项技能自从《Quake》时期就有了。

System根本不在乎你是一个移动平台、炮台、门还是法老之鹰,你只须要拥有一个MovementState组件就够了,MovementState组件还要卖力存储环形缓冲区,还记得环形缓冲嘛?之前用来保存那些猎空小人的位置的。

有了MovementState组件,做事器在打算命中以前,就会把你回滚到攻击者上报时你所在的那一帧,这便是向后缓和(backwards reconcilation)。
这统统都与ModifyHealthQueue组件正交, ModifyHealthQueue组件决定了是否接管侵害。
我们还须要倒回门、平台、车的状态,如果子弹被挡住了的话,就无所谓了。
一样平常来说如果你是敌对的,而且有MovementState组件,你就会被倒回,而且可能会受伤。

被倒回(rewind)是被一组Utility函数操纵的行为;而受伤是MovementState组件被延迟处理时发生的其余一个行为。
这两种行为独立开来,各自发生在各自的组件切片上。

射击过程有点抽象,我这里会分解一下。

图47中的框是每一个实体的逻辑边界(bounding volumes)。
逻辑边界基本上便是代表了这个源氏的实时快照的并集。
以是源氏周围的逻辑边界就代表了过去半秒钟这个角色的全部运动(的最大范围)。
如果我现在沿着准星方向射击,在倒回这个角色以前,会首先与这个边界相交,由于基于我的PING值,它有可能在边界内的任意一处位置。

这个例子里,如果我沿着这个方向射击,那只须要单独倒回安娜即可,由于子弹只和她的边界相交了。
不须要同时倒回大锤和他的能量盾或者车,以及后面的门。

射击犹如移动一样,也可能会有预测失落败。

这里的绿色人偶是去世神的客户端视角,黄色是做事器视角。
这些绿色的小点点是客户端认为它的子弹击中的位置。
可以瞥见绿色的细线是子弹经由的路径,但做事器在校验的时候,这个蓝紫色的半球才代表示实命中的位置。

这完备是个人为制造的例子,确定型仿照过程是很可靠的,为了重现射击过程中的预测失落败,我把我的丢包率设置为60%,然后足足射了这个忘八20分钟才成功重现(众笑)。

这里我还得提一句,仿照过程如此精确,要归功于我们的QA团队的同事。
他们从不接管“NO”作为答案,而且由于市情上其他游戏都不会把命中剖断的预测精确度做到这个水平,以是我们的QA小伙伴们根本不相信我,也不在乎我。
只是一直地提bug单,而且是越来越多的bug单,而每一次当我们去检讨是否真的有bug时,结果是每次都真的有。
这里要对他们表示深深的感谢,有了他们的事情才使得我们能做出如此伟大的产品。

如果你的PING值特殊高,命中剖断就会失落效。

一旦PING值超过220毫秒,我们就会延后一些命中效果,也不会再去预测了,直接等做事器回包确认。
之以是这么做的缘故原由是,客户端上本来就做了外插值(extrapolate),不想把目标倒回那么远。
不想让受害者以为他们冒死跑到墙后面找掩护,结果还是被回拉、受伤。
以是加了一层保护。

PING为0的时候,对弹道碰撞做了预测,而击中点和血条没有预测,要等做事器回包才渲染。

当PING达到300毫秒的时候,碰撞都不预测了,由于射击目标正在做快读的外插,他实际上根本没在这里,这里我们用了DR(Dead Reckoning)导航推测算法,虽然很靠近,但是他真没在那里。
去世神旁边来回晃动时就会涌现这种情形,外插时完备无法精确预测。
这里我们不会照顾你的感想熏染,你的网络太差了。

PING达到1秒的时候,尤为明显。
去世神的移动办法不变,还会有外插。
顺便提一句,乃至PING已经是1秒钟那么慢了,客户真个所有操作都还是能够立即预测、相应的,只不过大部分都是错的而已。
实在我该当放大招的(午时已到),肯定能弄去世他。

下面讲下其他预测失落败的例子,PING值还是不怎么好,150毫秒。
这种条件下,无论何时碰着运动预测失落败,都会缺点的预测命中。
下面用慢动作展现一下。

看,都已经飙血了,但是却没瞥见血条,也没瞥见弹坑,以是对付弹道碰撞的预测来讲便是缺点的。
做事器谢绝了,这不是一次合法的命中。
碰撞效果预测失落败的缘故原由便是“冰墙”立起来了。
你“以为”自己开火时还站在地上,但是做事器仿照时,你已经被冰墙升到了空中,便是这个行为导致预测失落败的。

当我们修复这些眇小的命中预测缺点时,创造大部分情形都能通过与做事器当场位问题达成同等来肃清,以是我们花了很多韶光来对齐位置。

下面是与运动干系的预测失落败的例子,同时也与游辱弄法有关。

PING值还是150毫秒,你想命中这个去世神,但是他处于幽灵形态,箭头碰到他时,客户端会预测说该当有血飚出来,没有弹坑(hit pit),也没有血条。
我们根本没击中他,由于它已经前辈入幽灵状态了。

这种例子里,虽然大部分韶光都会优先知足进攻者,但除非受害者做了什么事情缓和(mitigate)了这次进攻。
在这个例子里,去世神的幽灵形态会给他3秒钟的无敌韶光。
无论如何,我们没有真的打到去世神。

让我从哲学角度想象一下,你便是那个去世神,你进入了幽灵状态,但事实上做事器很可能会让你播放所有殊效,让后让你去世掉,由于你不可能如此快速进入那个状态。

ECS简化了网络同步问题。
网络同步代码中用到的System,知道自己何时被用于玩家身上,很大略直接,基本上如果一个实体被一个带有Connection组件的东西掌握了,它便是一个玩家。

System也知道哪些目标须要被倒回到进攻者时候的那一帧上,任何包含MovementState组件的实体都会被倒回。

实体与组件之间的内在关联紧张行为是MovementState可以在韶光线上被取消。

上图52是System和组件的全景图,个中只有少数几个与网络同步辇儿动有关。
而这便是我们已知最繁芜的问题了。
System中有两个是NetworkEvent和NetworkMessage,是网络同步模块的核心组成部分,参与了吸收输入和发送输出这样的范例网络行为。

还有其余几个System,一只手就数得过来:InterpolateMovement,Weapons,Statescript,MovementState,我特殊想删了MovementState,由于我不喜好它。
以是呢,实际上网络同步模块中,只有3个System是与gameplay有关的,个顶用到的组件便是右边高亮列出的,也只有组件对付网络同步模块是只读的。
真正修正了数据的便是像ModifyHealthQueue,由于对仇敌造成的侵害是真实的。

现在转头看一下,用了ECS这么多年后,都学到了哪些知识与心得。

我有点希望System和Utility都能回到最早那个ECS操作元祖的威信例程的用法,做法有点分外,我们只遍历一个组件就够了,再通过它访问所有兄弟组件。
对付真正繁芜的组件访问元组模型,你必须知道确切的访问工具才行。
如果有个行为须要一个含有40个组件的元组,那可能是由于你的系统设计过于繁芜了,元组之间有冲突。

元组另一个很酷的副浸染是,你节制了关于什么System能访问什么状态的先验知识,那么回到我们用到元组的那个原型引擎当中,就可以知道2或3个System可以操作不同的组件凑集。
由于根据元组的定义就可以知道他们的用场。
这里设计的非常随意马虎扩展。
就像之前那个弹钢琴的动画一样,不过可以看到多个System同时点亮,只由于它们操纵的组件凑集是不同的。

由于已经知道组件读写的优先级,System的轮询可以做到多线程处理gameplay代码。
这里要提一句,Transform组件依然很受欢迎,但只有为数不多的几个System会真正修正它,大部分System都是对它只读。
以是当你定义元组时,可以把组件标记上“只读”属性,这就意味着,纵然有多个System都操为难刁难该组件,但都是只读,可以并行处理。

实体生命周期管理须要一些技巧,尤其是在一帧的中间创建出来的那些。
在早期,我们推迟了创建和销毁行为,当你说“嘿我想要创建一个实体时”,实际上是在那一帧结束时才完成的。
事实证明,推迟销毁一点问题都没有,而推迟创建却有一大堆副浸染。
尤其是当你在System A 中申请创建一个新的实体,然后在System B中利用,这时如果你推迟了创建过程,你就要隔一帧才能利用。

这有点不爽。
这也增加了很多内部繁芜性(译注:看到这里,繁芜性都是一些潜规则,须要花脑力去记住的hardcode),我们想修正掉这部分代码,使它可以在一帧的中途创建好,这样就可以立时利用了。

我们在游戏发布之后才做了这些改动,实在很胆怯。
这个补丁打在了1.2或者1.3版本,上线那天晚上我都是通宵的。

我们大概花了1年半的韶光来制订ECS的利用准则,就像之前那个威信的例子,但是我们须要改造一些现有的代码使之能够适应新的架构。
这些准则包括:组件没有函数;System没有状态;共享代码要放到Utils里;组件里繁芜的副浸染要通过行列步队的办法推迟处理,尤其是单例组件;System不能调用其他System的函数,纵然是我们自己的取名System也弗成,这个System几年之前暴雪分享过的。

仍旧有大量代码不符合这个规范,以是它们是繁芜度和掩护事情的紧张来源,就一点也不奇怪了。
通过检视代码变更数量或者说bug数量,你就能创造这一点。

以是,如果你有什么遗留代码而且无法融入ECS规范的话,就绝对不应该利用。
保持子系统整洁,不用创建任何代理组件去对它们进行封装。

不同的系统设计是用来办理问题的不同方法。

ECS是一个集成大量System的工具,不得当的系统设计原则就不应该被采取。

ECS的设计目的是用来把大量的模块进行集成并解耦,很多 System及其依赖的组件都是冰山形状的。

冰山型组件对其他ECS的System暴露的表面很小,但它们内部实在有大量的状态、代理或者数据构造是ECS层无法访问的。

在线程模型中这些冰山的体型相称明显,大部分ECS的事情,例如更新System,都是发生在主线程(图58顶部)上的。
我们也用到了大量的多线程技能,像fork和join。
这个例子里,有角色发射了大量的抛射物,然后脚本System说我们须要天生一些抛射物,就创建了几个事情线程来干活。
还有这里是ResolvedContactSystem想要创建一些碰撞殊效,这里花费了几个事情线程去做这项事情。

抛射物仿照的幕后事情已经被隔离,而且对上层ECS是不可见的,这样很好。

其余一个很酷的例子便是AIPetDataSystem,很好的运用了fork和join模式,在ECS层面,只有一点点耦合,可能是说“嗨,这是一扇可毁坏的门,你可能须要在这些区域重修路径”,但是幕后事情实在很多,像获取所有三角形,渲染并裁减,这些都与ECS无关,我们也不应该把ECS置于那些问题领域,该当自己想办法。

这里演示的是PathValidationSystem,路径(Path)便是全部这些蓝色色块,AI可以行走于其表面上。
实在路径并不但用于AI,也用在很多英雄的技能上。
以是就须要在做事器和客户端之间对这些路径进行数据同步。

禅亚塔将会毁坏这里的这些物品,你会瞥见毁坏后的物体掉落到表面下方。
然后那里的门会打开我们会把那些表面粘在一起。
PathValidationSystem只须要说:“嗨,三角形有变革”。
然后冰山背后就会用全部数据重修路径。

现在准备结束本日的分享了。

ECS是《守望先锋》的粘合剂,它很酷,由于它可以帮你用最小的耦合来集成大量分散的系统。
如果你打算用ECS定义你的规范,实际上无论你想用什么架构来快速定义你的规范,该当都是只有少数程序员须要打仗物理系统代码、脚本引擎或者音频库。
但是每个人都该当能够用到胶水代码,一起集成系统。

履行这些限定,就能够马到成功。

事实证明,网络同步真的很繁芜,以是必须尽可能的与引擎别的部分解耦,ECS是办理这个问题的好办法。

末了在接管提问以前,我想感谢我们团队成员,尤其是gameplay工程师,大家花了3年韶光创造了如此美妙的艺术品。
我们共同努力,创建原则,架构不断进化,结果也是有目共睹的。