《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx

上传人:b****2 文档编号:1464401 上传时间:2023-04-30 格式:DOCX 页数:57 大小:13.45MB
下载 相关 举报
《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx_第1页
第1页 / 共57页
《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx_第2页
第2页 / 共57页
《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx_第3页
第3页 / 共57页
《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx_第4页
第4页 / 共57页
《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx_第5页
第5页 / 共57页
《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx_第6页
第6页 / 共57页
《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx_第7页
第7页 / 共57页
《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx_第8页
第8页 / 共57页
《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx_第9页
第9页 / 共57页
《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx_第10页
第10页 / 共57页
《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx_第11页
第11页 / 共57页
《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx_第12页
第12页 / 共57页
《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx_第13页
第13页 / 共57页
《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx_第14页
第14页 / 共57页
《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx_第15页
第15页 / 共57页
《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx_第16页
第16页 / 共57页
《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx_第17页
第17页 / 共57页
《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx_第18页
第18页 / 共57页
《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx_第19页
第19页 / 共57页
《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx_第20页
第20页 / 共57页
亲,该文档总共57页,到这儿已超出免费预览范围,如果喜欢就下载吧!
下载资源
资源描述

《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx

《《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx》由会员分享,可在线阅读,更多相关《《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx(57页珍藏版)》请在冰点文库上搜索。

《守望先锋》架构设计与网络同步GDC精品分享实录Word文件下载.docx

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在运行时,不知道也不关心这些实体是什么,它们只是在实体相关组件的子集上执行操作而已。

Overwatch里的(ECS架构的)实现,就是这样子的。

EntityAdmin是个World,存储了一个所有System的集合,和一个所有实体的哈希表。

表键是实体的ID。

ID是个32位无符号整形数,用来在实体管理器(EntityArray)上唯一标识这个实体。

另一方面,每个实体也都存了这个实体ID和资源句柄(resourcehandle),后者是个可选字段,指向了实体对应的Asset资源(译注:

这需要依赖暴雪的另一套专门的Asset管理系统),资源定义了实体。

组件Component是个基类,有几百个子类。

每个子类组件都含有在System上执行Behavior时所需的成员变量。

在这里多态唯一的用处就是重载Create和析构(Destructor)之类的生命周期管理函数。

而其他能被继承组件类实例直接使用的,就只有一些用来方便地访问内部状态的helper函数了。

但这些helper函数不是行为(译注:

这里强调是为了遵循前面提到的原则:

组件没有行为),只是简单的访问器。

EntityAdmin的结尾部分会调用所有System的Update。

每个System都会做一些工作。

上图9就是我们的使用方式,我们没有在固定的元组组件集合上执行操作,而是选择了一些基础组件来遍历,然后再由相应的行为去调用其他兄弟组件。

所以你可以看到这里的操作只针对那些含有Derp和Herp组件的实体的元组执行。

Overwatch客户端的System和组件列表

这里有大概46不同的System和103个组件。

这一页的炫酷动画是用来吸引你们看的(众笑)。

然后是服务器

你可以看到有些System执行需要很多组件,而有些System仅仅需要几个。

理想情况下,我们尽量确保每个System都依赖很多组件去运行。

把他们当成纯函数(译注,purefunction,无副作用的函数),而不改变(mutating)它们的状态,就可以做到这一点。

我们的确有少量的System需要改变组件状态,这种情况下它们必须自己管理复杂性。

下面是个真实的System代码

这个System是用来管理玩家连接的,它负责我们所有游戏服务器上的强制下线(译注,AFK,AwayFromKeyboard,表示长时间没操作而被认为离线)功能。

这个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中、不同的时机下,意味着完全不同的事情。

那么把行为和状态区分开,又有什么理论上的优势(conceptualadvantages)呢?

想象一下你家前院盛开的樱桃树吧,从主观上讲,这些树对于你、你们小区业委会主席、园丁、一只鸟、房产税官员和白蚁而言都是完全不同的。

从描述这些树的状态上,不同的观察者会看见不同的行为。

树是一个被不同的观察者区别对待的主体(subject)。

类比来说,玩家实体,或者更准确地说,Connection组件,就是一个被不同System区别对待的主体。

我们之前讨论过的管理玩家连接的System,把Connection组件视为AFK踢下线的主体;

连接实用程序(ConnectUtility)则把Connection组件看作是广播玩家网络消息的主体;

在客户端上,用户界面System则把Connection组件当做记分板上带有玩家名字的弹出式UI元素主体。

Behavior为什么要这么搞?

结果看来,根据主体视角区分所有Behavior,这样来描述一棵树的全部行为会更容易,这个道理同样也适用于游戏对象(gameobjects)。

然而随着这个工业级强度的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之间互相暴露了内部行为的实现。

(译注:

转载不注明出处,真的大丈夫吗?

还把译者的名字都删除!

声明:

这篇文章是本人kevinan应GAD要求而翻译!

从图16最下面可以看见我们有个PostBuildPlayerCommand函数,这个函数是InputSystem在这里的主要价值。

如果我想在这个函数里增加一些新功能,那么CommandSystem就需要根据玩家的输入,填充一些额外的结构体信息发给服务器。

那么我这个新功能应该加到CommandSystem里还是PostBuildPlayerCommand函数里呢?

我正在System之间互相暴露内部实现吗?

随着系统的增长,选择在何处添加新的行为代码变得模棱两可。

上面CommandSystem的行为填充了一些结构体,为什么要混在一起?

又为什么要放到这里而不是别处?

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

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

我接下来会展示它们是如何实现的。

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

回放(Replay)的工作方式是这样的,服务器会下发大概8到12秒左右的网络游戏数据,接着客户端翻转World,开始渲染replayAdmin这个World的信息到玩家屏幕上。

然后转发网络游戏数据给replayAdmin,假装这些数据真的是来自网络的。

此时,所有的System,所有的组件,所有的行为都不知道它们并没有被预测(predict,译注:

后面才讲到的同步技术),它们以为客户端就是实时运行在网络上的,像正常游戏过程一样。

听起来很酷吧?

如果有人想要了解更多关于回放的技术,我建议你们明天去听一下PhilOrwig的分享,也是在这个房间,上午11点整。

无论如何,到现在我们已经知道的是:

首先,所有需要全局访问System的调用点(callsites)会突然出错(译注:

Tim思维太跳跃了,突然话锋一转,完全跟不上);

另外,不再只有唯一一个全局EntityAdmin了,现在有两个;

SystemA无法直接访问全局SystemB,不知怎地,只能通过共享的EntityAdmin来访问了,这样很绕。

在Killcam之后,我们花了很长时间来回顾我们的编程模式的缺陷,包括:

怪异的访问模式;

编译周期太长;

最危险的是内部系统的耦合。

看起来我们有大麻烦了。

针对这些问题的最终解决方案,依赖于这样一个事实:

开发一个只有唯一实例的组件其实没什么不对!

根据这个原则,我们实现了一个单例(Singleton)组件。

这些组件属于单一的匿名实体,可以通过EntityAdmin直接访问。

我们把System中的大部分状态都移到了单例中。

这里我要提一句,只需要被一个System访问的状态其实是很罕见的。

后来在开发一个新System的过程中我们保持了这个习惯,如果发现这个系统需要依赖一些状态。

就做一个单例来存储,几乎每一次都会发现其他一些System也同样需要这些状态,所以这里其实已经提前解决了前面架构里的耦合问题。

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

全部按键信息都存在一个单例里面,只是我们把它从InputSystem中移出来了。

任何System如果想知道按键是否按下,只需要随便拿一个组件来询问(那个单例)就行了。

这样做以后,一些很麻烦的耦合问题消失了,我们也更加遵循ECS的架构哲学了:

System没有状态;

组件不带行为。

按键并不是行为,掌管本地玩家移动的MovementSystem里有一个行为,用这个单例来预测本地玩家的移动。

而MovementStateSystem里有个行为是把这些按键信息打包发到服务器(译注:

按键对于不同的System就不是不同的主体)。

结果发现,单例模式的使用非常普遍,我们整个游戏里的40%组件都是单例的。

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

改造后如图22,InputSystem依然存在(译注:

然而并没有看到InputSystem在哪里),它负责从操作系统读取输入操作,填充SingletonInput的值,然后下游的其他System就可以得到同样的Input去做它们想做的。

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

我们把PostBuildPlayerCommand函数也挪到了CommandSysem里,本应如此,现在可以保证所有对玩家输入的命令(PlayerCommand)的修改都能且仅能在此处进行了。

这些玩家命令是很重要的数据结构,将来会在网络上同步并用来模拟游戏过程。

在引入单例组件时,我们还不知道,我们其实正在打造的是一个解耦合、降低复杂度的开发模式。

在这个例子中,CommandSystem是唯一一处能够产生与玩家输入命令相关副作用的地方(译注:

sideeffect,指当调用函数时,除了返回函数值之外,还对主调用函数产生附加影响,例如修改全局变量了)。

每个程序员都能轻易地了解玩家命令的变化,因为在一次System更新的同一时刻,只有这一处代码有可能产生变化。

如果想添加针对玩家命令的修改代码,那也很明朗,只能在这个源文件中改,所有的模棱两可都消失了。

现在讨论另外一个问题,与共享行为(sharedbehavior)有关。

共享行为一般出现在同一行为被多个System用到的时候。

有时,同一个主体的两个观察者,会对同一个行为感兴趣。

回到前面樱花树的例子,你的小区业委会主席和园丁,可能都想知道这棵树会在春天到来的时候,掉落多少叶子。

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

举个例子,大量代码都会关心“敌对关系”,例如,实体A与实体B互相敌对吗?

敌对关系是由3个可选组件共同决定的:

filterbits,petmaster和pet。

filterbits存储队伍编号(teamindex);

petmaster存储了它所拥有全部pet的唯一键;

pet一般用于像托比昂的炮台之类。

如果2个实体都没有filterbits,那么它们就不是敌对的。

所以对于两扇门来说,它们就不是敌对的,因为它们的filterbits组件没有队伍编号。

如果它们(译注:

2个实体)都在同一个队伍,那自然就不是敌对的,这很容易理解。

如果它们分别属于永远敌对的2个队伍,它们会同时检查自己身上和对方身上的petmaster组件,确保每个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间耦合的问题。

“推迟”存储了行为所需状态,然后把副作用延后到当前帧里更好的时机再执行。

例如,代码里有好多调用点都要生成一个碰撞特效(impacteffects)。

包括hitscan(译注:

直射,没有飞行时间)子弹;

带飞行时间的可爆炸抛射物;

查里娅的粒子光束,光束长得就像墙壁裂缝,而且在开火时需要保持接触目标;

另外还有喷涂。

创建碰撞特效的副作用很大,因为你需要在屏幕上创建一个新的实体,这个实体可能间接地影响到生命周期、线程、场景管理和资源管理。

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

下图30是用来创建碰撞特效的一小部分代码。

基于Transform(译注:

变形,包括位移旋转和缩放)、碰撞类型、材质结构数据来做碰撞计算,而且还调用了LOD、场景管理、优先级管理等,最终生成了所需的特效。

这些代码确保了像弹孔、焦痕持久特效不会很奇怪的叠在一起。

例如,你用猎空的枪去射击一面墙,留下了一堆麻点,然后法老之鹰发出一枚火箭弹,在麻点上面造成了一个大面积焦痕。

你肯定想删了那些麻点,要不然看起来会很丑,像是那种深度冲突(Z-Fighting)引起的闪烁。

我可不想在到处去执行那个删除操作,最好能在一处搞定。

我得修改代码了,但是看上去好多啊,调用点一大堆,改完了以后每一处都需要测试。

而且以后英雄越来越多,每个人都需要新的特效。

然后我就到处复制粘贴这个函数的调用,没什么大不了的,不就是个函数调用嘛,又不是什么噩梦。

其实这样做以后,会在每个调用点都产生副作用的。

程序员就得花费更多脑力来记住这段代码是如何运作的,这就是代码复杂度所在,肯定是应该避免的。

于是我们有了Contact单例。

它包含了一个未决的碰撞记录的数组,每个记录都有足够的信息,来在本帧的晚些时候创建那个特效。

如果你想要生成一个特效的时候,只需要添加一条新记录并填充数据就可以了

展开阅读全文
相关资源
猜你喜欢
相关搜索
资源标签

当前位置:首页 > 总结汇报 > 学习总结

copyright@ 2008-2023 冰点文库 网站版权所有

经营许可证编号:鄂ICP备19020893号-2