网游开发技术分享!虚幻引擎的网络架构
我从16年开始接触Unreal,到如今已经4年了。最近看了不少关于网络同步的论文和书籍,总算是理解了Doom和Quake这种古董级游戏的发展历史,对其网络架构也有了更深一层的认识。这次想根据自己的工作和学习经验,以一个全局的视角来重新回顾一下虚幻的网络模块,并总结一些我们常见的问题,相信对UE同步细节模糊不清的你看完后一定会醍醐灌顶。一起来看这篇网游开发技术分享!虚幻引擎的网络架构。
开始前,我先给初学者一个建议。如果你打算看UE4的同步源码,最好先大致阅读一遍这本书——《网络多人游戏架构与编程》,里面基本涵盖了UE4同步框架的大部分内容,可以让你少走不少弯路。
下面进入正题:
网络同步,就是使各个客户端上的角色表现保持一致,属于游戏引擎的高级功能,所以一般我们都将其归类于Gameplay模块当中。不过具体的实现方案其实会深刻影响到底层的网络架构(甚至是整个游戏架构)。我们既要决定通过哪种网络协议来完成,又要决定游戏各个模块的循环执行顺序,这已经不单单是“Gameplay”层面的东西了。
虚幻引擎属于标准的CS架构(经过无数次改版的),内置状态同步功能,其同步频率与游戏的帧率相同,属于变长步更新。由于帧率完全受CPU、GPU性能的影响,所以网络同步的频率与整个项目的性能息息相关。不过,有一点我们要认识到,unreal已经是尽可能的按照自己最快的速度进行数据的发送与接收了,只要我们做好各方面的性能优化即可。
一. RPC与属性同步
在Unreal里面,同步有两种手段,即RPC与属性同步(很多服务器引擎都是如此)。与其说RPC是同步手段,不如说他是一种传输数据的方式,好处就是可以直接通过类的函数形式书写,方便理解。同时不需要你直接写Socket,也不需要你处理封包和拆包。在计算机网络的概念里面,RPC叫做“远程过程调用”,本质上就是一种传递数据的手段,而其实现方式既可以是应用层的Http,也可以是传输层的TCP/UDP。在虚幻里面,由于很多游戏的同步(比如FPS)对网络延迟要求比较苛刻,所以我们放弃了需要三次握手的TCP而改用UDP(更不可能考虑HTTP了)。RPC既可以标记为可靠,也可以标记为不可靠。可靠的RPC最终一定会到达目标终端,但不可靠的RPC除了在网络拥挤的环境下丢失,也可能在引擎限流的情况下被提前拦住。RPC本身并不是一个可以持续存在的对象,我们只能通过RPC参数“一次性”的将数据从一端发送到另一端,所以每个RPC调用只能“只执行一次”(换句话说,他的生命周期只有一瞬间)。如果RPC消息从网络中丢失,那么他就会永久的丢失(这里指不可靠的RPC),所以并不适合游戏世界各种对象的状态恢复,必须要结合可以保持对象状态的属性才行。此外,UE4里面RPC并不支持回调,所有RPC函数的返回类型都是void。
属性同步,本质上属于一个比较上层的功能特性,是以每个对象为单位处理的(不支持更细粒度的同步,但理论上可以通过条件属性做部分调整,详见AACtor::PreReplicate)。unreal的服务器会按照一定频率的去执行同步对象属性的数据发送和接收,同时处理回调函数。属性同步的产生是为了维持对象的状态,是一个从概念上非常贴近“同步”二字的功能,一旦服务器上的同步属性发生了变化,就一定会发送给客户端(注意:属性同步只是服务器向客户端的同步,不存在客户端向服务器流通),也许中间会丢包会延迟(actor首次同步时是reliable的),但是其内置的机制会保证属性的值最终送达到客户端。借用一句经典的话来说就是,同步数据也许会迟到,但是永远不会缺席。
无论是RPC,还是属性同步,你会发现他都是基于UObject的,或者更确切的讲都是基于Actor的(以及其附属组件)。因为这两种功能一个是利用类中的函数,另一个是利用类对象的属性,他们都需要与某一个具体的对象作为媒介,而在UE的架构中,设计都是面向对象的,每个Actor都可以理解为游戏世界的对象。
既然是基于Actor的,那么整个同步就与GamePlay框架紧密相连。由于我们在发送同步数据的时候需要知道这个数据应该发向哪个客户端,而客户端与服务器的链接信息(IP等)又在Playercontroller里面,所以同步的逻辑与playercontroller密切相关。很多刚接触unreal的朋友经常会遇到RPC数据发不出去或者收不到的问题,就是没有认识到playercontroller其实是包含客户端与服务器的连接信息的。最典型的,假如你有服务器上连着10个玩家客户端,服务器上有一辆车,让他执行Client RPC,他怎么知道发给哪个客户端?当然是通过这个车找到控制他的playercontroller,然后找到对应客户端的IP,如果这个车不被任何客户端控制,那他就不知道要发给谁。
当然,RPC与属性同步的实现原理不同也决定了他们有很多差异。由于属性同步是跟着每一个实例对象走的,所以不存在“随用随发”。也就是说,属性同步需要在每帧特定的时机通过统一的引擎接口写到发送缓存(sendbuffer)里面。这样带来的问题就是,你在同一帧里面修改的属性只有最后的那个值会传到客户端那里,进而导致你的回调函数也只会执行一次。而RPC不同,每次执行时都会立刻将数据塞到发送缓存里面,从而保证不会丢失任何一次RPC的调用(假如RPC是可靠的)。
另外,这里面还有一个深坑,就是关于Actor以及Component的同步顺序问题。一个对象的同步首先要给客户端上的对象与服务器上的对象建立关联,这样服务器的A变化了才会告诉客户端上的A也去变化。但是A是一个对象,对象也是需要同步的,一个场景里面有那么多的对象,同步肯定是按顺序的来的。这样就会经常出现A的对象里面有很多指向B对象的同步指针属性,但是A对象出现的时候B还没同步过来,所以在A的Beginplay里面访问B是不行的。那么如何解决这个问题?答案是用属性回调,一旦执行了属性回调,就可以确保A的B指针是存在的。不过,属性回调并不能解决所有问题。假如B对象还有C对象的指针,回调的时候C还没同步过来,你想用B去访问C发现又是空指针。这问题目前在现在的虚幻引擎里面还没有完美的解决方案,所以我们要尽可能的避免这种情况(我本人正在尝试实现一些可行的方法)。类似引发的更细节的问题还有很多,后面我会列举一些。
二. 移动同步理解
两种同步手段已经介绍完毕,我们现在把视角锁定在网络同步的解决方案上。游戏中同步本质上是同步客户端之间的表现,而RPC与属性同步都只是数据上的同步,我们需要将其与画面表现结合起来。画面表现说白了就是物体的显示与隐藏、动画、位置等,其中位置同步就是最复杂的一项,因为游戏中的角色可能是每帧都在移动的,移动组件(movementcomponent)就是为了解决这个问题而诞生的。
移动组件很复杂,他需要考虑到各种情况的延迟、抖动,需要解决不同客户端不同角色的流畅性问题,需要实现各种插值手段。在网络同步中,始终存在三种形式的角色,分别是本地玩家控制的、服务器控制的以及其他玩家控制的,在unreal中分别对应着Autonomous、Authority与Simulate。这三种类型的存在本质上代表着角色的控制者是谁(哪个端可以直接通过命令操作他),而从另一个角度讲这种分类其实是代表着玩家的操作是否有网络延迟以及延迟的大小。对于本地控制的Autonomous角色,他可以在本地直接响应你的操作,如果想把操作发给服务器,则需要经历一个client——server的延迟,而服务器想把这个操作同步给其他客户端又需要一个server——client的延迟。
同步中最难的其实就是如何有效的对抗这种延迟。所以,会诞生诸如延迟补偿这种同步策略,即本地客户端收到其他客户端消息的时候将本地的所有角色回滚到【当前时间 - 网络延迟时间】时的位置再进行消息的处理和计算。
(UE4默认引擎里面没有这种操作,虚幻竞技场里面有。如下图,红色是当前端的具体位置,黄色是回滚预测的位置)。
移动组件本地客户端到服务器采用的是不可靠的RPC,而服务器到其他客户端采用的是属性同步。为什么使用RPC?因为客户端向服务器发送消息只能通过RPC,属性同步只是用来服务器同步给客户端用的。unreal在同步位置时记录了各个客户端以及服务器的时间戳,通过位置buffer缓存、每帧不停的发送位置、判断时间戳调整位置与回滚等操作实现比较理想的效果,本质上守望先锋的帧同步+状态同步是相同的(详见:守望先锋架构与网络同步)。不过虚幻并没有采用ECS,并不能在架构上很好的支持所有逻辑的回滚。
网络同步发展至今,其实基本已经成型。从早期的Lockstep到指令流水线化再到预测回滚TimeWarp,大体的同步优化手段都是这些,现在的趋势就是状态同步与帧同步里的各种机制互相借鉴互相促进。除了移动同步,其他的诸如动作同步和隐藏显示我们一般要求不那么苛刻,因为他们不需要每帧都做处理,一般采用RPC做一次性的通知修改就可以了。
关于同步,还有一个大家平时不是很在意的细节,那就是同步频率。前面提到了UE4会按照尽可能快的速度去发送同步数据,如果客户端的性能非常好帧数非常高,那么一帧就会产生非常多的移动RPC。理论上来说,如果没有丢包的话,即使服务器帧率很低,服务器也会按照客户端发来数据逐个模拟,最后两端结果相同,仍然是流畅的。但是,如果中间丢失了部分移动的RPC(引擎内部就会对发送进行限流),就可能造成服务器计算结果与客户端不同进而不断拉回客户端,造成卡顿。
总的来说,RPC与属性同步有些场景是可以相互替代的。对于简单且实时性要求不高的使用RPC就可以,而对于需要服务器实时保有主控权且持续性同步的状态我们就可以使用属性同步。属性同步本身已经做了优化消耗没有那么大,你可以通过各种条件来设置他的同步规则。但是注意,量变产生质变,如果不加节制的全部使用属性同步,那么actor(以及属性)遍历的开销与会相当可观,所以还是合理的使用还是非常重要的。这块理论上有很多可以优化的地方,比如Actor可以设置同步的范围(类似AOI),距离玩家很远的对象不需要同步;Actor可以根据一些规则关闭对某些客户端的属性复制功能(Dormancy),同时关闭ActorChannel并从NetConnection里移除;采用replicationgraph对空间进行划分,剔除相关性不强的对象从而减少带宽的占用(但是这个方案只适合大世界类型的游戏)。理论上,我们还可以添加更多的优化方式以及更细的粒度来进行调整,不过具体方案就要根据游戏类型来灵活处理了。
(Replicationgraph示意,每个宝箱被放置到他所影响的所有格子里面。玩家只有进入这些格子里面才会收到宝箱的同步信息)
三. 回放系统
回放看起来是个很高大上的功能,但其实早在上世纪90年代就随着Lockstep算法一起诞生了。UE4内置了一套Demonetdriver系统来处理回放和录制,但由于采用的是状态同步而不是帧同步,所以实现起来比较复杂。基本思路就是在本地创建一个虚拟的服务器,录制的时候本地当成一个服务器,回放的时候本地又当做一个客户端。在游戏进行的时候,本地开始录制并把回放相关的数据序列化到数据流里面(可以是内存、磁盘或者是网络包),播放的时候再去对应的数据流里面读出来。虽然框架是有的,但还处于一个未完成的阶段,用起来坑也是相当的多(比如过期的多播事件在回放中不会被执行到)。对于死亡回放以及精彩镜头这种实时切换的需求,涉及到的逻辑要更复杂一些(比如真实世界和回放世界的切换与隐藏)。
(官方射击游戏Demo——ShooterGame中就含有一个简单的回放演示功能)
四. 底层框架
说完了上层的网络同步,再简单谈谈底层。虚幻引擎诞生于90年代,也肯定参考了很多其他游戏的设计,比如“雷神之锤(Quake)”,“星际围攻:部落(Tribe)”等。当时Quake是最早一批采用基于“CS架构状态同步”的游戏,而Tribe将模块进行拆分和封装,是第一个构建了比较完善的网络同步架构的游戏。UE4的架构与Tribe很像,通过NetDriver + NetConnection + Channel + Actor/Uobject抽象分层实现了目前的同步方式。很多人总是抱怨虚幻引擎把底层搞得太复杂,但这其实有很多历史原因以及技术上的权衡,官方团队在过去的20年里肯定也无数次地思考过这种问题,这里也不过多赘述。总之,从网络层面上说,UE4高度耦合的网络框架不适合帧同步(这里指lockstep),同时也很难改造成ECS架构。不过,我个人也同样觉得很多游戏开发没必要非要追求帧同步,两种同步开发各有各的坑,真做起来游戏其实都没那么简单(也许踩UE官方的坑可能会让你更不爽一点,毕竟不是自己写的)。
关于网络协议,游戏界经过大量的测试很早就公认——对于高频同步的游戏,使用UDP同步的效果要好于TCP。因此,Unreal使用的就是UDP协议,但是为了保证数据的可靠性,需要在上层封装一个可靠的UDP,也就是NetDriver + NetConnection + Channel那一套。里面的逻辑很复杂而且涉及到很多模块,确实有一些冗余。此外,虽说是可靠的,但是在属性同步和RPC的处理方式上并不相同,属性同步只保证最后的数据是可靠的,中间的结果可能会丢失,而RPC则可以保证消息一定按序送达。针对其内置的RUDP的重发机制,UE其实已经做过很多次的优化和调整了,之前任何的丢包和乱序都会立刻触发重发,4.24里面已经添加了循环队列来收包矫正收包的次序,一定程度上减少了不必要的重传。消息的接收和发送默认还是在主线程处理的(我们可以决定是否启用多线程),由于UDP不需要监听多个Socket而且针对收包采用多线程意义也不大,所以也没有采用iocp或者其他异步IO的方式。在虚幻引擎中,网络包的更新顺序是“收数据——逻辑更新——发数据”,但并不是所有的同步更新逻辑都在收包的时候做,UObject类型同步属性的更新可能就是在发包前更新的(这块是一个坑,要注意),具体可以参考我的知乎文章“《Exploring in UE4》网络同步原理深入(下)” 中的第五部分第8小节。
到此,我已经比较全面的把虚幻引擎的网络模块重新梳理一遍。
最后,我们再总结一些在同步中经常会遇到的问题,这些都是我踩了无数坑才总结出来的,拿大家的 “在看” 或 “转发” 换一下不过分吧。
1.Client的RPC并不能保证一定在客户端执行。在服务器上,如果有一个没有connection信息的actor(比如不是同步的,完全由AI控制的。或者说他的remote role等于none),那么他的clientRPC只会在自己的客户端上面执行。最后可能造成的后果就是函数调用栈的无限循环进而崩溃。
2.beginplay在客户端服务器都会执行,如果在beginplay执行另外一个actor的生成。可能会触发客户端和服务器都生成一遍自己的actor,结果客户端存在了两个Actor(一个自己生成的,一个服务器生产的)。之后在调用RPC的时候很可能会出现RPC执行失败,因为本地生成的Actor没有任何connection信息。
3.客户端上对象的Beginplay是可能执行多次。在unreal中,如果一个actor是服务器创建并同步给客户端,那么服务器可以随时关闭这个对象的同步。一旦这个对象距离玩家角色非常远或者服务器主动关闭同步,客户端上的对象就会被删除掉。后期如果玩家又靠近了这个对象,那么就会重新同步到客户端,再执行一次Beginplay。这样某些数据进行两次初始化,可能不是我们想要的。
4.我们经常会遇到“游戏状态恢复”的场景,比如网络游戏中的断线重连。然后你就可能会遇到一些对象在重连后状态不对,因为很多对象的变化是通过RPC去做的,RPC是一次性的。当你重连后,RPC不会再执行一次,所以客户端重连的状态与服务器其实是不同的。这时候需要使用属性同步来解决问题,但是属性回调在断线重连的时候你也并不一定想执行,所以要重新审视一下回调函数里面的内容。
5.不要把随时可能被destroyed的对象传进RPC的参数里面,RPC参数里面没有判断对象是否是合法的。如果传递的过程中对象被destroy掉,后续可能触发序列化找不到NETGUID的相关崩溃。
6.一般情况下,同步顺序在一个character内是严格按照属性的声明顺序的,不同actor无法保证
7.一般回调会调到的函数,要注意里面有没有判空return的情况,这个时候其他actor的指针是有可能为空的。
8.一个UObject指针类型的数组属性,可能会触发多次回调,最后一次可以确保所有指针都有值。
9.属性回调执行的前提是客户端与服务器的值不同,如果你本地先修改一个值,然后服务器修改的与客户端相同,那么是不会触发回调的
10.一般来说当Actor与PC解绑后,Actor就无法保证RPC的执行了。这种情况往往发生在角色死亡后执行unpossess时,所以在这时应该注意RPC的执行情况。
11.如果属性没有同步到客户端或者不执行回调,注意一下是否使用了自定义的条件属性
12.所有设置定时器来判断同步属性是否收到的逻辑都是不规范的,一旦服务器或者客户端变卡(一开始没有表现,但是随着游戏内容的增加可能出现各种诡异的bug)就可能导致信息丢失
结语:写这篇文章花了半个多月,而其内容却是我花了几年才积累出来的。我相信每个Unreal网游开发团队都会从这篇文章中得到一些启示或帮助,所以请转发给分享给更多的Unreal开发者吧。