这篇是对书本 网络多人游戏架构与编程 的学习第二篇(第一篇:多人网络游戏服务器开发基础学习笔记 I:基本知识 | 游戏设计模式 | 网游服务器层次结构 | 游戏对象序列化 | 游戏 RPC 框架 | 帧同步和状态同步_我说我谁呢 --CSDN博客),内容还是以基础为主。第一篇主要是讲解了网络多人游戏的一些最基础的知识。同时因为一些点书本内容太简略,所以参照学习了 GDC 2017 守望先锋对 ECS 架构涉及和网络同步的视频分享结合讲解加深理解。尝试提供所有必要基础知识理解游戏客户端预测(但是只是基础角度的分析,有需要深入学习的时候直接看视频)。对于守望先锋 ECS 架构部分这里不涉及,那部分内容属于 如何设计对象系统的部分,可以看云风大佬的分析。GDC 的视频连接下文会给出。


对于游戏中的各种帧,我之前专门总结了一下,从显卡,操作系统的硬件中断,定时器和 gameloop 来讲到,这里放个连接:游戏开发基础笔记:逻辑帧和物理帧辨析 | Gameloop | 游戏循环_我说我谁呢 --CSDN博客

帧同步(命令同步)和状态同步概念澄清(OW 是帧+状态同步)

  • 前面其实讲过一次,但是书本的概念不是很清晰,这里补充一下。
  • 帧同步的实现是定义逻辑帧(锁同步?),服务器的作用是用来同步指令,然后发送,客户端本地按顺序演算,支持战斗回放。就是前面说的确定锁同步网络模型。
  • 状态同步是服务器运算说有点状态,然后计算好下一个的状态再返回给客户端同步全局状态。
  • 但是状态同步和客户端先行不是同等级别的概念,为了防止客户端看到操作和结果之间时间点的割裂,必须再画面表现上优化,客户端先行、平滑插值等在表现上降低对延迟的感受。这里的机器猫完全没有这个实现。RPG游戏中,动画的特效一般做的比较长时间,看着好看同时延长网络响应时间, 攻击的时候给人感觉是击中了。放技能也有一个前摇,同时将攻击请求提交给服务器。等服务器结果返回时,动画也播放完毕了,之后就是更新状态和 HUD 而已。
  • 还有一个问题是反作弊,对于 RTS 和帧同步的方案,只需要定时服务器(或者某个客户的托管服务器,或者大家)校验一下就行了,但是这个只是防止了数据作弊,对于信息泄漏的话可能无解,因为运算和所有数据都在客户端,完全可以全部 dump 出来,包括视野信息,以及透视等。而状态同步不会泄漏运算流程。
  • 然而对于手游,客户端运算(帧同步)才能保证在各种无线网络下的延迟问题,这是和网络因素决定的(就和 TCP 的基础设施反而和网络不相适应导致要定制 UDP 一样)。这种反作弊的确就用校验可以解决,最常用的做法验算,确定客户端随机数种子,还有客户端操作。那么记录正常战斗的数据,放到其他客户端去验算,看看验算能否通过即可。不过这样针对泄漏游戏内部运行逻辑信息的外挂的确没办法了。
  • 帧同步的好处是,客户端可以像单机那样运行,只需要把 Input  sampling 和实际的 input(其他玩家的input来自网络,自己的input从 input sampling 里面拿)拆开就行了,这样就等于用网络输入 hook 在单机上面。状态同步的客户端开发是不能这样的。
  • Realword 部分,王者荣耀,皇室战争是帧同步,魔兽用的是帧同步,lol 可能是状态同步(没有相关信息,存疑),这部分资料有些难找,腾讯游戏 gameplay 大佬的文章的对现有各种著名游戏的同步方式以及 CS 还是 p2p 的总结 图片来自这里:网络游戏同步技术概述 - 知乎 (zhihu.com)(但是这个主要是理论说明没有涉及实现,所以我就只摘这两幅图了,由于原因,这里不放图片了,请点进连接阅读)。

C/S 服务器架构实例

这篇内容是第六章第一部分,讲解的是一个 C/S 架构的状态同步的例子。(由于主要还是以书本学习为主,需要这部分了解了才能讲到下面的内容,这部分也是前面说的提供必要的基础属于了)。

权威服务器,专用服务器和监听服务器(托管,比如通过某种云服务提供的),对于托管服务器意思是客户端(其中一个玩家)本身充当服务器。然后托管服务器可以实现 host migration 专用服务器需要配置备用服务器。对等网络的时候除了伪随机数,还要考虑状态一致性。

书本的 demo 机器猫行动MultiplayerBook/MultiplayerBook (github.com) ch6 代码,有 VS 的读者可以下载来感受一下,只需要运行 win32 的生成,使用 SDL2 多媒体 2d 库开发的。下面说一下这个怎么运行:

编译好之后在 Debug 目录下面应该会有 RoboCatServer 和 RoboCatClient 的可运行 exe,这个时候进入 cmd 里面输入这个运行一个 S 两个 C:

RoboCatServer 45000
RoboCatClient 127.0.0.1:45000 Aohn
RoboCatClient 127.0.0.1:45000 Bohn

sdl 是接口而已,实现可以用 opngl 的接口进一步封装的,带有窗口和输出输出管理。注意事项是这个游戏必须要用 win32 编译。提供一些必要的上下文信息供读者(我)阅读时用,这个例子讲的是一个 CS 架构的游戏,玩家是两个猫,然后猫按键盘移动和发射射线攻击其他猫。所有的运算都在 server 上进行,读者(我)应该可以无障碍阅读下面内容了。

这个是一个 UDP 来的,而且第六章基本是 minimum code,所以没有流控和重传。

代码分离

  • 首先是 CS 的代码分离,对于整盘游戏实际是在服务器演算的,这样就涉及两套东西,一套是给 UI 用的,一套是给服务器运算的。对于 common attribute 或者 member function 可能就要做一个 base class 。
  • 游戏逻辑的共享,以及 socket helper 的共享,从而做出层次结构来。比如网络库会有公共都要用到的发包接包的逻辑,抽象一个 manager 出来,然后 client 主要是封装 C 请求解析 S 响应,然后 override 就行了。
  • 服务器跨平台的问题,对于这个我的想法是只要他发包和收包序列化的逻辑相同就行了,所以我完全可以搞到 linux 上,不过前台进程转 daemon 的代码要另外做而已。最好还是用第三方的已经搞过适配层的,实在不行可以自己造轮子。

UDP 握手

  • 对于 socket helper 的类这里不再看了,但是感觉还是得自己写一套熟悉 socket api 和各种选项。不过 windows 下又没有 epoll 这种,高性能保证需要用 windows 提供的 api,这个实际不靠谱,所以服务器和客户端肯定得分开来开发的。
  • 机器猫全部用的静态工厂模式,禁用了默认构造,这个可能是一种完全委托给 shared ptr 管理的
  • 就是用之前说到 4 char int 来标识一个包的头部
  • hello 包和 welcome 包。
  • 服务器分发 client id 过程,使用 autoincrement variable 就行了。
  • 原来 NONBLOCK 这么消耗 CPU 的,如果开了 NONBLOCK 然后线程又不 sleep,结果就是一直 context switch 来去,没有用户的情况下都会占满 CPU(属于是死循环了)。这还只是 UDP recvfrom 而已。额,所以为什么标准的服务器进行 event loop 不会过大的 CPU 占用率呢?nginx 这种没什么连接的时候也就几,有连接也稳定十几,这是因为 epoll 这种阻塞吧。这里这个例子直接死循环 recvfrom 的确不太靠谱。
  • robust 1,对于 UDP 而言,必须处理hello 失败的情况,就是没有收到 welcome,然后他会重新发hello,但是又不能连续发 hello 因为 hello 发多了等于引发 congestion,并且之后对于迟来的 welcome 会增加(那还不如用 tcp 呢),所以需要等待一定的间隔。当然最好的方法是首先通过某种方法测量 rtt(然而 hello 是第一个包),然后对于迟来的 welcome,应该丢弃他,所以要判断当前的状态是不是已经被 welcomed 了。
  • 吐槽一下 C++ 头文件分离,太坑了,什么 intellisence 的跳转都不好用,太难受了。然后 editor 一般有跳转功能,主要是这个功能各家快捷键都不一样,vscode 的一个 f1 打开命令栏可太好用了。这里mark 一下 clion 是 ctrl shift +n, vs 是 ctrl+,

状态机

  • 通过状态机的方法控制发包的类型,全程用一个成员变量 mstate 来做这个控制,这样不用传参(就是对于一个类来说的)。
  • 状态机模式能保证只有在当前的状态可以被数据包转移才进行转移,于是顺利完成了迟来的重复 welcome 的的丢弃和正确的转移。而且不用各种参数传递,只需要维护当前状态就行了。

circular buffer

  • 解耦 Socket 层和 application 层,中间用一个 NetworkManager 来,直接操作 Packet 类而不是操作流(UDP packet 可能包含多个 application packet,所以本质还是当成流看待的)。

客户端 IO

  • 这里客户端会涉及一个问题是多个事件等待,然而windows 又没有 epoll,看看他是怎么解决的。
  • 因为用到了 SDL2 来做多媒体 IO 处理,这里键盘事件是由 SDL 负责的,SDL 提供 wait event 和 poll event 两种调用来处理各种游戏设备输入,由于我们需要同时处理键盘事件和网络事件,所以这里只能用 non block 的 sdl poll,正如其名就是 polling 所有设备看看有没有事件(内部可以用 queue 实现,可能会是高效率的)然后返回。
  • 非阻塞能让我们自己实现多路 IO 复用了属于。。。不过有点空转了。不过游戏本来就不可能不占用 CPU。
  • 这里的思路是,先 poll 一下键盘,如果有键盘就响应键盘(更新 inputstate)。然后没有键盘才 do frame。我很好奇这里的时序问题,因为如果处理了事件,会不会引发一个消息发给 server 呢?后面能看到不会,这里是用锁同步的方法的。
  • doframe 做的事情有很多。
  • 首先是更新当前捕获的状态,但是会先判断一下是否到达采样点。再更新到 move 里,所以 move 是 input 的采样,而 move 在之后会发给 server。
  • 然后接收一个包(真实实现是从网络读一个包进 queue,然后再从 queue 里面接收一个包的),这个包理论上是 FIFO 的,但是对于 UDP 可能过程寻路的问题,导致顺序不一样。这样搞你要决议了,必须约定处理顺序,这样很麻烦。另外一种方法是 1:1 ack ,这种太坑了,不过是必要的。
  • 上面这个包会更新服务器的最新运算成果,然后把他 render 出来。之后再把这次的 move 发出去(当然的,如果没达到 buffer 的操作的限制是不会发送的,等于什么都不做)。
  • 这里的 move 只是一个 采样,但是实际编写应该要累积所有操作(额,不过对于可覆盖的操作的确没必要,比如 sprite 的 replication 的确是采样就行了)。第七章继续研究。
  • 这里还讲解了一个冗余数据的东西一个操作发三个 UDP(我感觉还是 ACK 靠谱,或者这个也有道理的,如果是你的网络不好你丢包了,那你放的技能没有发出来也是正常的,但是我觉得可能还是会引发拥塞,这个不能想当然,得实测才知道。而且必须考虑到整个路径上会有很多重传的请求的,比如玩家不断地在那里点他,理论也会触发多次发送(除非客户端进行某种缓冲),这个和 TCP 的网页那个差不多了属于,因为某个地方网络差了,一直刷新,这种问题要让客户端做一套防护,同时服务端,cdn 什么都得做,特别是查询数据库)。

服务端结算(即状态同步)