【GDC】守望先锋的游戏架构和网络代码
【GDC】守望先锋的游戏架构和网络代码
在完成了上一个项目的网络部分后,想着在网上找找相关的资料,结果意外发现了我最喜欢的守望先锋的GDC。这篇演讲可以说是真正的宝藏,虽然并未完全揭露守望先锋的优秀代码是如何写成的(事实上这里只展示了冰山一角),但是看到有这种优秀的程序员为其努力,那结果也是理所当然的。
这篇演讲主要包含了两大部分:守望先锋的ECS
架构,以及网络代码。而在网络部分,则还是主要围绕着两大问题:预测回滚,以及服务器回溯。事实上,在任何网络游戏中,网络部分的主要工作都围绕着这两点展开,而最困难的问题则是如何在这两个基本思想下进行性能和游玩体验的优化。虽然是2017年的演讲,但是我认为其内容直到今天都值得学习。
翻译视频链接:【【青幻译制】GDC讲座系列之三 守望先锋的游戏架构和网络代码】 https://www.bilibili.com/video/BV1p4411k7N8
1.ECS
架构
1.1 ECS
这篇演讲的主旨是分享一些在不断增长的代码库中降低复杂度的方法,而这个方法的底层逻辑是严格遵守ECS
架构。何谓ECS
架构?ECS
是三个单词的简称:Enity
(实体),Component
(组件),System
(系统),而整个架构就是围绕着这三部分展开。
这是一张很经典的图,展现了ECS
架构的大概面貌。游戏世界由一些系统和实体构成,而实体实际上就是一个对应着一组组件的ID
。组件用于存储游戏状态,而不具备行为;系统具有行为,却不保存游戏状态。简单来说,组件没有函数(除了一些辅助函数),而系统没有成员变量。
对于系统,它不需要知道每一个实体的存在,它只需要对某些组件进行操作。当系统进行更新时,它会遍历所有的相关组件并对其进行操作,而由于实体实际上就是组件的集合,系统的这些操作就会自然而然地反映在实体上。演讲中举了一个例子:在守望先锋中,对于玩家联网系统,它会遍历联网组件,这个组件是在服务器上对应每个每个玩家连接的组件,它存在于玩家控制的实体中,包括游戏的参与者与旁观者,但是系统不关心这个。系统会读取联网组件的操作流和状态,确保玩家做出了一些事情,而当玩家一段时间没有行动时,系统就会发出挂机警告。从宏观上来看,系统做的事情就是操作所有的联网组件,根据情况对其发出挂机警告或者将其踢出游戏。所有拥有联网组件的实体(玩家)都会受到这一约束,而对于AI控制的实体,它们没有联网组件,所以不会收到挂机警告,也不会被踢出游戏。对于一个实体,当它具有行为所需的组件元组时,即会成为该行为的对象。
这种行为和状态分离的做法有什么好处?
1.1.2 更好地使用ECS
架构
虽然ECS
架构的思想比较容易理解,但是在实际的项目开发中必然存在着一些问题。
守望先锋中的输入系统是从非ECS
架构的项目中迁移过来的,输入状态会被保存在输入系统中,任何对输入状态的访问可以通过一个指向输入系统的指针来实现。这听起来也不是不行,虽然它违反了“组件没有函数,系统没有状态”这条规则,但是由于输入状态只有一个实例,这样做似乎没什么破坏性,并且“只有在出现多个组件实例的时候才需要建立一个新的组件类型”是一个常见的思想。
但是这么做的后果就是,当一个系统需要查询输入状态的时候,它需要包含输入系统,这会增加编译时间;并且这种做法造成了一些耦合,将系统的行为暴露给了其它的系统。最致命的问题是,当守望先锋引入死亡回放时,回放的原理是创建一个新的ECS
世界,服务器会传送回来一个8-12秒的数据包,然后转而渲染新的世界,将数据包用于世界的运行。在这种情况下,不再只有一个全局的实体管理,而是变成了两个,系统A无法直接访问系统B,只能通过共享的实体管理来访问系统B,这种做法十分难受。
经过团队一段时间的反省和复盘后,最终的解决方案是,在每个实体管理中定义仅存在一个实例的组件类型,称之为单例组件。单例组件的引入解决了“系统状态”带来的麻烦,减少了耦合以及随之而来的复杂度。
另一个问题是,有一些行为会在多个系统中被调用。对于这些具有共享行为的辅助函数,有一些规则。如果想要从不同的地方调用这个辅助函数,这个函数应该访问尽可能少的组件,并且最好只有很小的副作用或者没有副作用。如果由一个辅助函数读取了多个组件以及有不少副作用,应该尽可能少地调用它。
演讲中还分享了一些减少耦合的技巧。
“延迟”是指将执行具有明显副作用的行为所需的状态保存起来,将调用延迟到帧间某个单一的、更加合适的时间点。事实上,延迟是在任何性能优化中都被广泛运用的思想,例如单例模式的懒汉模式,以及对象拷贝。在守望先锋中,当子弹发生命中时会在命中位置产生特效,而这些特效之间也有不同的层级关系,总之,调用产生特效的函数的副作用是巨大的。如果每次命中都调用产生特效的代码,那么代码的复杂度会飞升,一旦修改了相关代码,所有调用这些代码的地方都需要进行测试。守望先锋中的做法是,当发生命中时,不直接调用产生特效的函数,而是在一个触碰的单例中增加一个相应的记录,在场景更新和渲染准备前,系统会遍历所有即将发生的触碰,根据多层次细节的规则、覆盖的规则等输生成特效。这样一来,每一帧只会调用一次产生特效的函数,代码的复杂度大大降低了,同时提升了性能和美术表现。
“遵照这些约束,意味着你必须通过一种特定的方式来解决问题,然而这些技术所带来的是持续的可维护、解耦以及简介的代码。我们限制你,把你扔进一个坑里,但这是一个通往成功的坑。”
2.预测回滚
对于快速响应的网络动作游戏,必须预测玩家的行动,因为如果等着服务器来告诉玩家发生了什么事情是没法做到快速响应的。并且,不能在重要的模拟中信任客户端上除了输入以外的东西。
快速响应的需求是:当玩家按下按键的时候,要能立刻看到响应,即使是在高延迟的情况下。这是通过客户端的预测来实现的,而错误预测则是以服务器作为权威来验证以及网络延迟的副作用。在演讲提供的例子中,温斯顿在被小美冻住的前一瞬间跳了出去,然而这是在客户端预测的结果,服务器判断温斯顿最终被冻住了,因此在客户端上,温斯顿先是跳了出去,然后被拉回了原地并被冻住。
守望先锋的确定性模拟算法依赖于一个同步的时钟、固定间隔的更新以及离散化。服务器和客户端都根据同步的时钟以及离散化的值来进行操作。时间被离散化成称之为“命令帧”的单位,固定为16毫秒。在守望先锋的ECS
架构中,然后模拟都是基于玩家的输入来进行的,游戏会在每一个命令帧中调用UpdateFixed
函数来进行模拟。客户端在进行模拟的同时会将命令帧内的输入发送到服务器进行权威性模拟,时间间隔为半个RTT
+缓存大小,而客户端收到服务器的权威模拟结果的时间为一个完整的RTT
+缓存大小。
如果客户端计算的结果和服务器一致,那么客户端继续继续模拟;如果不一致,那么出现了预测错误,需要进行调解。最简单的方法就是用服务器端的结果覆盖客户端的结果,但是毕竟服务器的结果是过去的,所以守望先锋的做法是不仅保存了运动状态的环状缓存,同时也保存了输入的环状缓存,因为角色移动的代码是非常确定性的,如果有一个起始运动状态,然后用输入去模拟运行,那么每一次都会得到相同的结果(实际上技能系统也是如此,但是技能不会被模拟)。当发生错误预测时,客户端会根据历史输入重新模拟从出错的时间一直到现在的状态。
接下来的问题是丢包。服务器有一小段Buffer
用来缓存玩家的输入,而当Buffer
中的输入用完了以后仍然没有新的输入,说明发生了丢包。这时,服务器会进行猜测,重复上一个输入,而当真正的输入到来时,服务器会进行调解来确保没有错过任何一次按键。与此同时,服务器会告诉客户端:“我没有收到输入”,而客户端收到这个消息后会加快模拟的速度,同时服务器的Buffer
也会增大,来得到更多的输入,弥补数据的丢失,直到服务器意识到客户端恢复了正常,它会通知客户端,客户端会向另一个方向调整时间,使Buffer
缩小,直到恢复正常。
为了防止丢失因为服务器的猜测而被跳过的客户端的输入,客户端发送的不只是当前命令帧的输入,而是自从上一个已经得到服务器验证的运动状态之后的所有输入。
3.服务器回溯
对于射击游戏,通常会在本地进行命中预测,同时在服务器进行命中判定。然而,当角色瞄准的位置发送到服务器的时候,其瞄准的角色可能已经不在那了,因此需要将角色回溯到开火时的状态来进行验证。对于不同的游戏,进行回溯的方式不同。在我的上一个项目中,是直接在本地进行命中判定,然后将命中的角色发送到服务器,对特定的角色进行回溯,但是我并不认为这是一个好主意。在CS中,因为地图是固定的,并且只有10个玩家,角色的状态也比较简单,所以只需要将所有角色进行回溯即可。而守望先锋的做法在我看来充分体现了ECS
架构的优势。在守望先锋中,不止玩家,有各种各样移动的物体,比如艾什的Bob,秩序之光的摄像头,运载目标以及沃斯卡亚工业区和66号公路的移动的浮板等。因为它们都可以移动,都有阻挡子弹的可能性,因此都应该被回溯。这听起来很麻烦,然而在ECS
架构中,回溯系统只需要做一件事:对所有的运动组件进行回溯操作。这样一来,所有可能会移动并阻挡子弹的实体都会被回溯并实现正确的命中判定。
当然,如果每次射击都将所有移动组件进行回溯的话,肯定会有不少性能浪费。守望先锋中使用了一个巧妙的优化方式:使用包围框表示所有运动组件在倒带范围内可能的移动范围。只有射线和包围框发生碰撞时才会进行相应的回溯。在碰撞检测中,先判断有没有命中大的包围盒再进行具体判断是很常见的操作(如AABB盒),但是将其在服务器回溯盒命中判定中进行实现真的很酷。
和常见的做法一样,当客户端的延迟过高时,服务器只会回溯一段时间,然后通过预测来进行命中判断,防止躲到掩体后仍然被命中的情况太过严重。