本文转自公众号“游戏开发故事”作者:Jerish
这是所有虚幻网络游戏开发者都应该阅读的一篇技术文章。
我从2016年开始使用Unreal,到现在已经4年了。最近看了很多关于网络同步的论文和书籍,终于了解了Doom、Quake等老牌游戏的发展历史,对其网络架构也有了更深入的了解。这次想结合自己工作学习的经历,从全局的角度来回顾一下Unreal Network模块,并总结一些我们经常遇到的问题。相信对UE同步细节不清楚的朋友可以看一下,看完一定有所收获。
在开始之前,我先给初学者一个建议,如果你打算看UE4同步源码,最好先看这本书——《网络多人游戏架构与编程》,里面基本涵盖了UE4同步框架的部分内容,可以帮你少走很多弯路。
让我们进入正题:
网络同步就是为了让各个客户端上的角色行为一致,它是游戏引擎的高级功能,所以我们通常把它放在 Gameplay 模块中。但具体的实现方案其实会对底层网络架构(甚至整个游戏架构)产生深远的影响,我们要决定使用哪种网络协议,以及游戏各个模块循环执行的顺序。其实它不只是“Gameplay”层面的事情。
Unreal Engine 是标准的 CS 架构(经过无数次改版),内置了状态同步功能,它的同步频率与游戏的帧率相同,是可变长度的步长更新,由于帧率完全受 CPU 和 GPU 性能影响,因此网络同步的频率与整个项目的性能息息相关。不过,我们必须意识到的一点是,Unreal 已经在以尽可能最快的速度发送和接收数据了,只要我们在各方面优化性能,就可以了。
1. RPC 和属性同步
在Unreal中,同步的手段有两种,分别是RPC和属性同步(很多服务器引擎都是这样)。与其说RPC是一种同步手段,倒不如说它是一种传输数据的方式,好处就是可以直接通过类函数来写,格式简单,容易理解。同时不需要直接写Socket,也不需要处理封包和解包。在计算机网络的概念中,RPC全称为“Remote Procedure Call”,本质是一种传输数据的手段。实现方式既可以是应用层的HTTP,也可以是传输层的TCP/UDP。在Unreal中,由于很多游戏(比如FPS)的同步对网络延迟有严格的要求,所以我们放弃了需要三次握手的TCP,而是采用了UDP(更别说HTTP了)。RPC可以标记为可靠或不可靠,可靠的RPC最终会到达目的地,而不可靠的RPC在拥塞的网络环境下就会丢失。 ,或者引擎在限流的时候可能会被提前阻塞。RPC本身并不是一个可以持久化的对象,我们通过RPC参数只能将数据从一端“一次”发送到另一端,所以每个RPC调用都只能“执行一次”(可以理解为只有一瞬间的生命周期)。如果RPC消息从网络上丢失,那么它将永久丢失(不可靠的RPC),所以它并不适合用于游戏世界中各种物体的状态恢复,必须结合能保持物体状态的属性。另外,UE4的RPC不支持回调,所有RPC函数的返回类型都是void。
属性同步本质上是一个基于每个对象进行处理的高级功能(它不支持更细粒度的同步,但理论上可以通过条件属性进行部分调整,详情见AACtor::PreReplicate)。Unreal 的服务端会按照一定的频率发送和接收同步对象属性的数据,同时处理回调函数。属性同步是为了保持对象的状态而产生的,这是一个概念上非常接近“同步”这个词的功能。一旦服务端的同步属性发生变化,就会发送到客户端(注意:属性同步只是服务端到客户端的同步,不存在客户端到服务端的流量),中间可能会有丢包和延迟(第一次同步 Actor 时是可靠的),但它内置的机制会确保属性的值最终传递到客户端。借用一句经典的话,同步的数据可能会迟到,但永远不会缺席。
不管是RPC还是属性同步,你会发现它们都是基于UObject,或者更准确的说是基于Actor(及其附属组件)的,由于这两个功能都是基于类中的功能,并且都是使用类对象的属性,它们都需要通过特定的对象来中介,在UE架构中,设计是面向对象的,每个Actor都可以理解为现实世界中的一个对象,那么整个同步就和GamePlay框架紧密联系在一起了,由于我们在发送同步数据的时候需要知道数据应该发送给哪个客户端,而客户端与服务器的连接信息(IP等)都在Playercontroller中,所以同步逻辑和playercontroller紧密相关。很多刚接触Unreal的朋友经常会遇到RPC数据无法发送或者接收的问题,这是因为他们没有意识到playercontroller其实包含了客户端与服务器的连接信息,最典型的情况就是你有10个玩家客户端连接到了服务器,服务器上有一辆车。如果你让它执行Client RPC,它怎么知道要发给哪个client呢?当然是先用car找到控制它的playercontroller,然后找到对应client的IP,如果car不被任何一个client控制网络游戏开发,那它就不知道发给谁了。
当然RPC和属性同步不同的实现原理也决定了它们有很多不同。由于属性同步是跟随着每个实例对象进行的,不存在“按需”的,也就是说属性同步需要在每一帧进行,具体的时机通过统一的引擎接口写入发送缓冲区,这样做的问题是,只有你在同一帧中修改的属性的最后一个值才会被传到客户端,这就会导致你的回调函数只会被执行一次。而RPC则不同,它每次执行的时候,数据都会立即插入到发送缓冲区中,这样就保证了不会丢失任何RPC调用(假设RPC是可靠的)。
另外这里还有一个坑,就是Actor和Component的同步顺序。一个对象的同步首先要建立客户端上的对象和服务器上对象的关联,这样当服务器上A发生变化的时候客户端才会得到通知,客户端上的A也会发生变化。但是A是一个对象,对象也是需要同步的,一个场景中的对象那么多,同步必须按照顺序进行,这样一来,往往会出现A的对象中会有很多指针指向对象B的同步指针属性,但是当对象A出现的时候,B还没有同步,所以在A的Beginplay中是无法访问B的。那么如何解决这个问题呢?答案就是使用属性回调,属性回调一旦执行,就能保证A中B的指针是存在的。但是属性回调并不能解决所有的问题,如果B对象中还有指向C对象的指针,而调用回调的时候C还没有同步,想用B去访问C,发现又是一个空指针。目前的虚幻引擎中还没有针对该问题的完美解决方案,所以我们应该尽可能的避免这种情况(我正在尝试实现一些可行的方法)。类似导致的更详细的问题还有很多,我后面会列举一些。
2. 移动同步
既然介绍了两种同步方式,接下来我们重点说一下网络同步的解决方案。游戏中的同步本质上是客户端之间表现的同步,而RPC和属性同步只是数据的同步。它与屏幕表现是结合在一起的。简单来说,屏幕表现就是物体的显示与隐藏、动画、位置等。其中,位置同步是最复杂的,因为游戏中的角色可能每一帧都在移动,而移动组件(Movementcomponent)就是为了解决这个问题而诞生的。
移动组件非常复杂,需要考虑各种情况的延迟、抖动,解决不同客户端、不同角色的流畅度问题,实现各种插值方法。在网络同步中,总是有三种类型的角色,分别是本地玩家控制、服务器控制、其他玩家控制,分别对应Unreal中的Autonomous、Authority、Simulate。这三种类型的存在,本质上代表了谁来控制角色(哪一端可以直接通过命令来控制角色)。从另一个角度来说,这种分类其实代表了玩家的操作是否有网络延迟,以及延迟的大小。对于本地控制的Autonomous角色,他可以直接在本地响应你的操作。在向服务器发送消息的时候,有一个客户端到服务器的延迟,而如果服务器要将这个操作同步给其他客户端,又有一个服务器到客户端的延迟。
同步最难的地方就在于如何有效对抗这种延迟,因此诞生了延迟补偿等同步策略,即当服务器收到其他客户端的开枪消息时,将所有本地字符回滚到[当前时间-网络延迟时间]的位置对消息进行处理和计算。(UE4默认引擎中没有这个操作,虚幻竞技场中有,如下图所示)。
红色为当前结束的具体位置,黄色为回滚的预测位置
移动组件本地客户端对服务器使用不可靠的RPC,而服务器对其他客户端使用属性同步。为什么要用RPC呢?因为客户端只能通过RPC向服务器发送消息,而属性同步只用于服务器同步。对于客户端来说。Unreal在同步位置时会记录每个客户端和服务端的时间戳,通过位置缓冲区缓存、每帧不断发送位置、判断时间戳来调整位置和回滚等方式达到相对理想的效果,本质上和守望先锋的帧同步(更准确的说是借用帧同步的命令帧)+状态同步是一样的(参见:守望先锋的架构与网络同步)[1],但是Unreal并没有使用ECS,而且架构无法支持所有逻辑的回滚。网络同步从发展到现在基本成型,从早期的Lockstep到指令流水线再到预测回滚TimeWarp,大致的同步优化手段就是这些。趋势是状态同步和帧同步中各种机制互相学习,互相促进。除了移动同步,对于动作同步、隐藏显示等其他同步我们一般没有那么严格的要求,因为它们不需要每帧都处理,一般用RPC进行一次性通知修改。一个不是很重要的细节就是同步频率,前面说了UE4会尽可能快的发送同步数据,如果客户端的性能很好,帧率很高,那么一帧就会产生很多移动RPC,理论上如果不丢包的话,即使服务器帧率很低,服务器也会把客户端发送的数据一一模拟出来,两端的结果是一样的,依然很流畅。(引擎内部会限流发送),这可能会导致服务器的计算结果和客户端不一样,然后不断拉回到客户端,造成卡顿。
总体来说,RPC和属性同步在某些场景下是可以互相替代的,对于简单、实时性要求不高的状态可以使用RPC,而对于需要服务端保持实时控制、持续同步的状态,我们可以使用属性同步。属性同步本身已经做了优化,消耗并没有那么大,你可以根据各类情况来设置它的同步规则。但请注意,量变导致质变,如果毫无节制地使用属性同步,那么actor(以及其属性)的遍历代价是相当大的,所以合理使用它非常重要。理论上可以优化的地方还有很多,比如Actor可以设置同步范围(类似AOI),距离玩家较远的物体就不需要同步;Actor可以根据一些规则对某些客户端禁用属性复制(休眠),同时关闭ActorChannel并从NetConnection中移除;使用replicationgraph来划分空间,移除相关性弱的物体,以减少带宽占用(但此方案只适合大世界游戏)。理论上我们可以加入更多的优化手段和更细的粒度进行调整,但具体方案要根据游戏类型灵活处理。
复制图显示每个宝箱被放置在其影响的所有格子中。玩家只有进入这些格子时才会收到有关宝箱的同步信息。
3.播放系统
回放看似是个很高级的功能,其实早在 90 年代就伴随 Lockstep 算法诞生了。UE4 内置了 Demonetdriver 系统来处理回放和录制,但是由于它采用的是状态同步而非帧同步,因此也可以用来同步场景的状态。基本思路是在本地创建一个虚拟服务器,录制时作为服务器,回放时作为客户端。将数据序列化成数据流(可以是内存、磁盘或网络数据包),播放时再从对应的数据流中读出。虽然框架存在,但还处于未完成阶段,使用难度较大,也存在不少坑(比如过期的多播事件不会在回放中执行)。对于死亡回放和精彩片段实时切换的需求,涉及的逻辑比较复杂(比如现实世界和回放世界的切换与隐藏),有空我会再写一篇文章详细讲解这个。
官方射击游戏演示 - ShooterGame 包含简单的播放演示功能
4.网络框架
说完了上层的网络同步,我们再简单说一下下层。虚幻引擎诞生于90年代,肯定参考了很多其他游戏的设计,比如《Quake》、《星际围攻:部落》等。当时,Quake是最早采用“CS架构状态同步”的游戏之一,而Tribe则将模块拆分封装,是第一个搭建了比较完整的网络同步架构的游戏。UE4的架构和Tribe很像,目前的同步方式是通过NetDriver+NetConnection+Channel+Actor/Uobject抽象分层来实现的。很多人总是抱怨虚幻引擎把底层搞得太复杂,但其实这里面有很多历史原因和技术上的取舍,官方团队在过去20年里肯定无数次思考过这个问题,这里就不细说了。总之从网络角度来说,UE4 那种高耦合的网络框架并不适合做帧同步(这里指的是锁步),也很难转型为 ECS 架构。不过我个人也认为很多游戏没必要追求帧同步,两种同步开发各有各的坑,其实做游戏也没那么简单(或许踩到 UE 官方的坑可能会更不开心,毕竟不是我自己写的)。
关于网络协议,游戏业界早就通过大量测试认识到,对于高频同步游戏,UDP同步优于TCP,因此Unreal采用了UDP协议,但是为了保证数据的可靠性,需要在上层封装一个可靠的UDP,也就是NetDriver+NetConnection+Channel,逻辑非常复杂,涉及模块也很多,确实有一定的冗余。另外虽然可靠,但在属性同步和RPC上可靠性不是很高,处理方式不一样,属性同步只能保证最终的数据可靠,中间结果可能会丢失,而RPC可以保证消息按序投递。我们做了很多优化和调整,之前任何丢包、乱序都会立刻触发重传,4.24版本增加了循环队列来接收数据包,并修正了接收数据包的顺序,一定程度上减少了不必要的重传。默认情况下,消息的接收和发送还是在主线程中处理(我们可以决定是否开启多线程)。由于UDP不需要监听多个socket,使用多线程收包、iocp或者其他异步IO方式意义不大。虚幻引擎中,网络包的更新顺序是“接收数据-更新逻辑-发送数据”,但并不是所有的同步更新逻辑都是在收包时做的,UObject类型同步属性的更新可能在发包时就更新过了(这部分是个坑,请注意)。具体可以参考我的文章《【UE4探索记:深入研究网络同步原理(下)】[2]第五部分第八节》。
最后我们来总结一下同步中经常遇到的一些问题。
客户端RPC并不保证一定在客户端执行,在服务端,如果存在没有连接信息的actor(比如不是同步的,完全由AI控制。或者其远程角色等于none),那么其客户端RPC就只会在自己的客户端上执行。最终的后果可能是函数调用栈死循环,然后crash。
beginplay 在客户端和服务端都会执行网络游戏开发,如果在 beginplay 过程中生成了另外一个 Actor,那么可能会触发客户端和服务端都生成自己的 Actor,结果客户端就有两个 Actor(一个自己生成的,一个服务端生成的)。在后续调用 RPC 的时候,RPC 执行很可能会失败,因为本地生成的 Actor 没有任何连接信息。
客户端上某个物体的Beginplay可能会被执行多次,在Unreal中,如果某个actor是服务器创建的,并同步到客户端,那么服务器可以随时关闭这个物体的同步,一旦这个物体距离玩家角色非常远或者服务器主动关闭,玩家再次靠近这个物体,就会重新同步到客户端,再次执行Beginplay,这可能不是我们想要的。
我们经常会遇到“游戏状态恢复”的场景,比如网络游戏中的断线重连。那么重连之后可能会遇到一些对象处于错误状态的情况,因为很多对象改变都是通过 RPC 进行的,RPC 是一次性操作,重连的时候不会再执行 RPC,所以客户端重连状态其实和服务端是不一样的。这时候就需要使用属性同步来解决问题,但是属性回调在断线重连的时候可能不一定想执行,所以需要重新审视回调函数的内容。
不要将随时可能被销毁的对象传递到 RPC 参数中。RPC 参数不会检查对象是否合法。如果对象在传输过程中被销毁,可能会触发与序列化找不到 NETGUID 相关的崩溃。
一般情况下,同一个角色内的 onrep 回调的执行顺序严格按照属性声明的顺序,不同角色之间的执行顺序无法保证。
一般对于回调的函数,需要注意是否有返回null的情况,这时候其他actor的指针就有可能为null。
UObject 指针类型的数组属性可能会触发多个回调,最后一个回调可以确保所有指针都有值。
属性回调执行的前提是客户端和服务端的值不同,如果本地先修改了一个值,然后服务端修改为和客户端一样的值,则不会触发回调。也可以使用 DOREPLIFETIME_CONDITION_NOTIFY(AActor, XXX, COND_Custom, REPNOTIFY_Always ); 宏强制客户端执行
一般来说,当Actor与PC解除绑定时,Actor是无法保证RPC的执行的,这种情况往往发生在角色死亡执行unpossess的时候,所以此时要注意RPC的执行情况。
若属性没有同步到客户端或者没有执行回调,请注意是否使用了自定义条件属性。
所有设置定时器来判断同步属性是否收到的逻辑并不规范,一旦服务器或者客户端出现卡顿(一开始没什么性能,但随着游戏内容的增加可能会出现各种奇怪的bug),可能会造成信息丢失
笔记:
[1] 守望先锋架构与网络同步
[2]《探索UE4》深入研究网络同步原理(下)