这篇是对书本 网络多人游戏架构与编程 的学习第二篇(第一篇:多人网络游戏服务器开发基础学习笔记 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 架构的状态同步的例子。(由于主要还是以书本学习为主,需要这部分了解了才能讲到下面的内容,这部分也是前面说的提供必要的基础属于了)。
![多人网络游戏服务器开发基础学习笔记 II: 帧同步 | 游戏客户端预测原理分析 | FPS 游戏状态同步_第1张图片](http://img.e-com-net.com/image/info8/7d4e9547152646c4ba9775f6f64f4e6c.jpg)
权威服务器,专用服务器和监听服务器(托管,比如通过某种云服务提供的),对于托管服务器意思是客户端(其中一个玩家)本身充当服务器。然后托管服务器可以实现 host migration 专用服务器需要配置备用服务器。对等网络的时候除了伪随机数,还要考虑状态一致性。
![多人网络游戏服务器开发基础学习笔记 II: 帧同步 | 游戏客户端预测原理分析 | FPS 游戏状态同步_第2张图片](http://img.e-com-net.com/image/info8/899bafddaf1d4fd1bfc97f78945de313.jpg)
书本的 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 什么都得做,特别是查询数据库)。
服务端结算(即状态同步)
- ClientProxy 类的作用,server 必须维护所有玩家的信息,这样才能路由包到 handler?(我感觉因为包里面有 uid 了,所以用 ip port 来做这个没必要)。不过根据异步处理的思想,这个用 Proxy 类来管理动作方便一点,对于他的 move,只需要 serever 进行游戏演算的时候调用 proxy 的时间然后引发就行了,而不用解析到包马上就执行游戏演算。
- 帧率同步的问题,对于积累的多个动作演算,服务器运行的时间也要同步,这个还没搞懂。对于运算完全在 server 进行,这样小猫移动的时候不久不能即时看到反馈了?但是实际网络运行很快的,这个应该不成问题。
- 结算之后要发回去,server 的主要工作是这样的(doframe):
- 首先是读包,检查掉线问题。
- 如果需要重建猫猫(比如倒计时复活)
- 更新全世界(调用所有对象的 update),调用 update 函数,这个在 server 做的是走路和伤害结算,在伤害结算里面创建 yarn(毛球),每次 update 调用的时候 move 一点点,然后进入下一步。(这代码跳转把我弄晕了,梦回操作系统)
- 然后发送包(主要是所有对象的状态),同样还是锁同步,没到时间不发包。
P2P 实例
以第六章 RoboCatRTS 为例子分析的。但是因为其实内容很简单,下面的内容不涉及demo的源码。所以应该不用具备 demo 的上下文也能看懂。
![多人网络游戏服务器开发基础学习笔记 II: 帧同步 | 游戏客户端预测原理分析 | FPS 游戏状态同步_第3张图片](http://img.e-com-net.com/image/info8/a5db775f49ff4dbbb6104dbb29a306b0.jpg)
![多人网络游戏服务器开发基础学习笔记 II: 帧同步 | 游戏客户端预测原理分析 | FPS 游戏状态同步_第4张图片](http://img.e-com-net.com/image/info8/f8c4eea2baa54803bbb03bb7c55bb20d.jpg)
书本的 demo 机器猫行动MultiplayerBook/MultiplayerBook (github.com) ch6 代码,有 VS 的读者可以下载来感受一下,只需要运行 win32 的生成,使用 SDL2 多媒体 2d 库开发的。下面说一下这个怎么运行:
编译好之后在 Debug 目录下面应该会有 RoboCatRTS ,这个时候这样运行:
RoboCatRTS 45000 John # 这个是 master peer
RoboCatRTS 127.0.0.1:45000 Aane # normal peer
RoboCatRTS 127.0.0.1:45000 Bane
RoboCatRTS 127.0.0.1:45000 Cane
Master Peer
- P2P 模型还是需要做一个 master client(不过都没有 CS 概念了,统一叫 peer) 来实现 autoincr 的 id 分配和协调已连接的各个中断地址并且将他们发放出去,否则就要做很简单的同步和协调,所以还是用 master peer 吧。
- 转发的问题,对于新加入者请求连接到一个非 master 的情况有两种方法,一个是进行迭代查询一个是进行递归查询,这里的例子用的是迭代查询,类似 301 和 302 重定向吧,不过这里应该是像 301 多一点。
- 这里讲解了一种情况是 p2p 没有办法连接一个 nat (nat 在我前面一篇计网的内容里面讲解了计算机网络全部知识速通指南_我说我谁呢 --CSDN博客)里面或者局域网里面的非绑定公网 ip 的地址。对于局域网这个只能要求端口转发什么的,然后 nat 打洞,而 tcp 可能要同时连接。不过实际可以配置公网主机支持 master 转发或者什么的,,(这样就做成帧同步集中服务器 rendezvous server 模型了)。否则对于一个终端没办法直接连接到所有的 peer 的时候,他就没办法进行广播游戏,只能t掉他了。我的感觉是不要做涉及无服务器打 nat 的任何应用,没有普遍可用性,尽量用中转服务器,xbox live 的p2p 也是用的中间服务器的 p2p 方案。
命令共享和锁步回合制
- 基本的 P2P 实现就是维护一个 peers 的列表,每次动作的时候都要给所有人 foreach send 信息。这里涉及时序的问题,所以 packet 里面实际是包含了 time 的,不过这个多人同步的时候会有 bug 和本地时钟有关吧我感觉。所以实际实现用的不是时钟,而是游戏的逻辑时钟(这个之前看那个分布式的逻辑时钟和相对论把我看懵了,原来多人游戏就算分布式系统了属于),这里表述的是轮数 turn number。具体的执行因为是先累积轮命令,然后送到发送队列(新一轮开始),然后再到新一轮才能看到队列非空然后发出去,所以实际是 x 轮(一轮 100ms 内 3个 subturn 采样+渲染 33.33ms 一帧) 要等到 x+2 才实现,不过大家抬头看到的都是同一个月亮就行了,不管他是多少年前的。下面是用 windows 画图画的一幅图(abc 三个客户端 P2P,然后 q 是 command buffer queue,红线没有严格三等分区间是个画图错误),这个也可以看到一开始的 200 ms 是完全没有世界运行的效果的,不过之后就是连续运行了:
![多人网络游戏服务器开发基础学习笔记 II: 帧同步 | 游戏客户端预测原理分析 | FPS 游戏状态同步_第5张图片](http://img.e-com-net.com/image/info8/e39e507baf8e4527990cf29d9d584bb7.png)
- command 类的实现是 OO,让 base class 纯虚 ProcessCommand 然后虚函数表访问(不如用静态多态)。静态 Create 是为了全部让 shared ptr 来管理对象,而不用裸指针(变成 Java 了)。
- 对时序的理解首先是理解轮和子轮先,轮是 lockstep 的 command 同步的,而子轮是进行 IO 采样和渲染状态更新的!之前已经在逻辑帧物理帧和帧同步里理清了很多东西和思想实验了,这里我我感觉不难理解,每个子轮做的事情是采样命令和更新物理的渲染状态,因此是保证了这样的东西:画面是30帧的(100ms 内3个子轮会更新 game object(这里是猫猫)的位置以及其他东西),而指令的执行是每 100 ms 同步一次的。这样能流畅的原理是因为指令的执行本身会有延迟,所以大家都**就等于不**。(但是键盘鼠标采样仍然是 30 帧的这一点)。
- 然后讲一下采样率,最近的手机宣称的触控采样率都比屏幕高刷帧率还要高,这个采样率就是上一段锁的那个 30 帧的采样率,但是问题在于如果采样率等于渲染帧率的话,由于这个是采样,所以是有间隔的,最主要的区别可能是响应速度,如果按下按键的时候不靠近采样点,就会增加延迟。下面是用 windows 画图画的一副示意图:
![多人网络游戏服务器开发基础学习笔记 II: 帧同步 | 游戏客户端预测原理分析 | FPS 游戏状态同步_第6张图片](http://img.e-com-net.com/image/info8/146cbfb80c8b4cf38a30cf70d8212266.jpg)
所以这个东西如果 gameloop 里面子轮太大的话实际采样延迟也提高了?我感觉如果采样率接近帧率就差不多了了吧,这点延迟有渲染画面的大?不过这里可能是很多次层层分包的采样? 硬件采样一次到驱动,OS响应硬件中断采样一次,软件再采样一次,这样就有影响了,就像从 ISP 到区域路由到家里路由器。。。层层分包过来延迟越来越高 ,这里就让他不深究下去。
延迟问题
- 再回来研究一下延迟的问题,对于每一轮更新所有动作执行状态的操作,前提是收到所有 peers 的数据,不然就不能进行模拟了。
- 所以这里有一个问题,如果本轮没有收集完所有数据包,说明一个延迟发生了,这个时候可以尝试等待一会儿,但是此时也说明了这一轮没有任何事情能进行,这样这一轮之后的输入(即积压给下一轮的输入)不能生效(因为这一轮的命令还没有执行),所以这个时候需要锁在这一步里即轮数,step 阻塞而不增加(但是渲染还是要继续进行的!)。
- 之后下一个渲染帧到达了,再尝试检查是否全部包都收到了,如果收到了就撤销同步锁,从而游戏回合继续进行。
- 当然,这样也意味着逻辑时间停顿了!但是由于这是对于所有用户都同步的,所以无所谓。
- 如果说一个用户延迟之后要 T 他出去,就更加麻烦了,因为 T 他出去也要同步。如果有 ACK 的情况很容易 T 一个用户出去,但是要同步广播这个 T人的信息,还有协调的工作。
- 对于使用 CS 的帧同步,有一个办法 帧锁定同步算法 - Skywind Inside,就是乐观帧锁定,这个方案主要是用在 CS 架构的帧同步,由于服务器只是负责转发所有的包(相当于一个同步器同步各方 commands 然后 broadcast 出去),所以如果服务器没有收到一个 peer 的包,照样可以强制推送,这样就只有一个客户端会被影响,对于其他客户端,正常运行。守望先锋采用的就是这种方案GDC Vault - 'Overwatch' Gameplay Architecture and Netcode,虽然主要是讲 ECS 架构的,但是也提到了这个 fixed update(netcode 部分)。对于 P2P 我还没找到好的办法,大概只能投票 T 人了。
游戏同步
- 由于 p2p 帧同步同步的是命令,游戏演算必须在 peer 上分别模拟,所以必须保证所有 peers 的模拟结果都是一致的。
- 对于游戏里要用到的随机性,只需要保证伪随机数 generator 的种子一样并且各自调用次数都一样就行了。这个工作就让 master peer 决定好 seed broadcast 出去。然后游戏版本必须一样,如果 peers 的游戏版本不一样,代码不一样可能调用随机数的次数就不一样了!(但是对于随机次数生成随机数是正确的因为种子是一致的总是保证出来的结果一样)。对于游戏的随机性而言,种子当然也要是随机的,只需要 mater 保证先生成了广播了就行。
- 还有一个问题是 C/C++ 是一个行为指定的平台差异的编程语言。所以其实 seed 一样,产生的结果可能也不一样。。。(不过没关系,反正单平台游戏编译出来的执行文件话肯定都是一样的)。(比如java不同 jdk 产生的会不会是一样呢?先存疑先)。
- C++ 11 的伪随机数生成器应该就是多了实现标准,所以能保证是一样的。比如你指定采用一个 generator 就行了,例如 mt19937 是32位梅森旋转算法,把这个 generator pass 给一个 distribution 的函数调用运算符重载,就能得到数字(uint32_t)。
- 浮点数同步问题还有因为使用了不同的指令导致运算结果不一样(书本说是 SIMD)
- 为什么要进行应用层强数据检验看这个系统设计的论文。 http://web.mit.edu/Saltzer/www/publications/endtoend/endtoend.pdf
- 潜在的网络错误,这个应用层协议 做 CRC 或者 sha 或者md5 就行了。这里 CRC 是支持增量算法(模2除法的性质?)的,从而可以减少运算(但是实现有点复杂,IP 头部是 CRC,这里好像有增量算法RFC 1071: Computing the Internet checksum (rfc-editor.org))。用增量算法是因为每次都复制一遍数据然后再计算 CRC 太浪费了。Incremental Checksums - Stack Overflow,这里给出了一种 incremental 计算 CRC 的代码和数学证明。数学证明时间关系下次再看了(后悔学计网没有讲到这部分,当时重心不在这些旁支末节,而计组里面因为要实现的不是增量节约数据复制而是要并行快速计算(那个提前建表并行算的方法))。不过这个还不是 block 的,实际这样算的话需要每个 bit 进行一次,一种方法是对值的 bytes 长度进行限制,这样的话设计一个 crc_calc_stream 就可以一次算一个数据量了。
- 然后多线程或者多终端的编程,需要一个靠谱的异步日志系统,因为没办法调试的,只能分析问题。。这个让我想起做DBMS多线程索引的时候深有体会了属于。
- 还有这篇 网络游戏同步法则 - Skywind Inside。
网络中的各种不确定因素
其实还讲了非网络延迟(我认为这个不是这个主题要处理的事情,所以把本地笔记这部分内容折叠了,这个涉及显卡和硬件)。
网络延迟
- 路由器,NAT,加密
- 物理介质延迟,线路延迟
- 排队延迟,链路节点性能
- 传播延迟,光速有限而物理链路距离不一样延迟
- 使用 RTT 进行反馈。 这个是游戏延迟的衡量是因为 RTT 实际是讲发送之后得到 ACK 的时间,而网络应用判断是否不需要重传(即没有延迟)就是在于 ACK ,所以必须算上 ACK。然后问题是如果 ACK 很久都不发送,就会引发超时重传等东西。
- TCP 的所有流控都是为了利用带宽,TCP 超时后会引发指数 RTO 避让才重发,提高了大量的延迟。TCP 重传难讲,congestion control 复杂。
- 其实 TCP 对长肥管道的带宽利用不是很好,因为一个窗口可能限制 65536,不能充满线路。然后序号很容易耗尽模回0。然后窗口增长是慢慢探测的(慢启动的时候连通告约定的rwnd窗口都达不到,慢慢 cwnd 才指数增上去).
- TCP 的选择性重传并不是实现必须(因为 SACK 这个东西是后面才提出来),而且需要双方支持,普通情况还是直接一系列重传直到 ACK 打断这个重传。
- 延迟 ACK 的存在,TCP 为了利用带宽,会用延迟 ACK,而 NODELAY 选项只能保证 Nagle 不会做事(which 是说不会延缓小包的发送而已),延迟 ACK 无法避免,而且是由协议栈内部实现决定的,比如捎带技术,这个和 nagle 没有关系。这个直接影响了 RTT,直接引发延迟(本 peer 无法确定是否成功,所以无法推动游戏进展)。
- KCP 浪费带宽换低延时讲的是因为他没有延迟 ACK,所以小包(比如 ACK) header 部分是回浪费掉这部分带宽的(当我们说这个带宽,可能说的是 payload 的带宽)。
抖动 jitter
- 网络拓扑变化和性能突然变化引发 jitter。这个只是一会儿的,并不是带宽受限,并不会丢包,可能只是因为某个线路突然出事了(但是由于一般 backbone 和各种子网都由冗余,不过是换个路走而已),然后又马上恢复了。
- 抖动会导致乱序,迟到(不是丢包,这里只是抖动),这个也是 TCP 拥塞控制把 3dup ack 引发 fast retransmission 的原因(判断为继续 reordering 而不是 congestion)。
- UDP 很容易 overwhelming (没有 congestion control,一种方法是使用 bbr),降低流量,限制重复发包。
- 然后是 google 的 BBR 算法,这个则是用在 congestion control 上的。
- 之前我们说过 tcp 的判断拥塞可能是不实的(比如一开始把 3dup 算在 congestion 上,其实 3dup 只是更可能 jitter 而已,不过改进 reno 用快重传替代了这个判据)。
- 然后 tcp 没有办法完全利用上信息来调控,他的闭环控制还是不太给力。
- 我们 Reno 算法在 slow start 的时候是一个 ack 来就搞一个递增, 实际上看就是一个 RTT(round trip time) 倍增了(因为一个 RTT 发的包也增加了). 但是由于 ACK 不一定就在相同的时间发来, 也就是一个 RTT 并不能严格对应多个的 ACK, 也就是实际 Reno 的倍增速度是没有理论上的 2 to the power of n 的 exponential growth 的.(比如对端用了延迟 ACK,RTT 会出现波动(变大)!)
- BBR 就不是根据 ACK 来增加 cwnd 了, 而是检测 RTT 来动态调整状态机状态. 另外还有动态计算 Throughput, 我们研究 Reno 的时候只是用 Throughput 计算作为一个评估算法的工具, 并没有参与内部算法决策.
- 为什么要基于 UDP 定制而不是 TCP 这个之前一篇博客讲过了,补充一个对于游戏应用来说,有时我们的确需要的是基于数据包的而不是流的,这个也是一个原因。(不过对于最近一直看的锁步帧同步的做法本身也要把包当成流用)。
- 游戏长连接,这里的连接的概念其实并不是很具体的,实际只要存在某个已经握手好的关系就能叫长连接(应用层应该保留了各端的信息从而能判断,就像kernel 保留了 TCP 的整个数据结构一样)。
- 我再补充一下,实际这部分内容在数据通信与计算机网络里面说是计算机科学也行,但是他很多是自动化专业的内容,闭环控制理论,还涉及信号与系统。对于无线网络而言,这个还会涉及通信的方面,因为在无线网络中连丢包超时都并不一定是拥塞(或者说常常不是拥塞(环境和介质的问题),这种情况要尽快重传而不是避免拥塞),因此无线网络的拥塞判断更加复杂了。
- 至于真正的丢包涉及的层面就很多了,不限于网络问题,链路层接口问题,路由器 QoS,路由器缓冲区溢出等,无线信号不佳(信道干扰等),ISP udp 限流等。
简单的定制 UDP 方法
- 这个内容其实跟过 TCP 走了一遍,思路已经很多了,而且细节特别多,自己搞其实很难周全的。
- 静态自增 seq
- 准时的 ACK
- 处理时丢弃过时的包,这个涉及 seq 的回绕 warp around 问题。这个问题 TCP 也存在,而且概率不小(比如长肥管道里并且 TCP 的开始序列号总是随机的,很容易回到 0),我们知道 seq 的范围是 0 到 2**32-1,当然不能说上一个是 4gb,下一个是 4gb-1 也能算是新的序列号排到新窗口里的(怎么可能乱序了近 8gb 个包呢,不过也是有可能)!所以要限制一个回绕范围,实践中这个范围和最大窗口大小有关,因为不可能 4gb 的窗口给发到 8gb 的包。但是思想实验上还有一个拼图,就是真的是 out-of-date 的 packet 怎么办呢?之前我讲过了遇事不决查 rfc,因为标准肯定是考虑周全的了(一般情况下)根据 RFC 1323 这里的说法(4.3节),the TCP mechanisms to prevent such errors depend upon the enforcement of a maximum segment lifetime (MSL) by the Internet (IP) layer . Unlike the case of sequence space wrap-around, the MSL required to prevent old duplicate errors from earlier incarnations does not depend upon the transfer rate. If the IP layer enforces the recommended 2 minute MSL of TCP, and if the TCP rules are followed, TCP connections will be safe from earlier incarnations, no matter how high the network speed. 这下也完全搞明白了。
- 累积确认的实现方法,尽管说累计确认对游戏来说并不好,但是实际这个东西要做起来也不简单的,比如捎带,实现捎带也很麻烦;然后还有 UNACK(此前都收到)。理所当然的思路是用一个东西记一个 range 实现 unack,然后捎带需要用数据结构。而且还有标记一下 timestamp,这样每次发大包的时候同时查询下 pigyback (如果涉及多 peers 还要涉及对照他们的目标(又要用 map))。然后每次添加确认的时候查询是否有即将过期 ack 没有发送,如果有就不在 backlog 而是直接发一个包出去。这个是一个很简单的想法,至于实际的 tcp 实现,tcp/ipv2 有 bsd 的 tcp 实现,linux 的实现需要看 linux 的源码。
C/S 架构中的客户端预测技术与守望先锋网络同步方案
这部分不需要太多理解,主要是一些概念和图解比较重要。然后用守望先锋的方案讲解加深实例的理解。
沉默终端
- 服务器模拟程序,只是给客户端下发 states 而已,这样在延迟之间显卡肯定什么也做不了。
![多人网络游戏服务器开发基础学习笔记 II: 帧同步 | 游戏客户端预测原理分析 | FPS 游戏状态同步_第7张图片](http://img.e-com-net.com/image/info8/61f183e2dba04d6d9d7e33c6407d7824.jpg)
- 这样是保守的,但是是正确的,想要良好的游戏体验,就要激进一点,甚至不再保守以至于容忍错误。
Client side interpolation
- 首先是不容忍错误的前提下,能提高体验的一种办法是继续增加延迟(插值周期),让client CPU 和显卡能进行更多的工作,这就包括插值。插值这个东西在学图形学的时候天天见了,老朋友属于是。
![多人网络游戏服务器开发基础学习笔记 II: 帧同步 | 游戏客户端预测原理分析 | FPS 游戏状态同步_第8张图片](http://img.e-com-net.com/image/info8/17750f2b8bc24b659d64dedde4b685b1.jpg)
- 对于 Interpolation perioid 的设置很关键,因为这个是我们添加的延迟,图像和用户输入实际的延迟将会是 RTT + IP. 图像和服务器是延迟会是 1/2RTT+IP. 这个半个 RTT 和一个 RTT 的说明再用守望先锋 GDC 分享 PPT 再说明一次(Client is always ahead of server):
![多人网络游戏服务器开发基础学习笔记 II: 帧同步 | 游戏客户端预测原理分析 | FPS 游戏状态同步_第9张图片](http://img.e-com-net.com/image/info8/61f78f740df248c0b535c778de0550e3.jpg)
- 然后是实际的 predition 和真实 server 返回的差距,这里可以看到从 command 到 server 返回到 client 显示的差距是 一个 RTT 外加 buffer:
![多人网络游戏服务器开发基础学习笔记 II: 帧同步 | 游戏客户端预测原理分析 | FPS 游戏状态同步_第10张图片](http://img.e-com-net.com/image/info8/a383d020ad244607b5dab599603a097f.jpg)
- 为了让插值连续而不卡顿,还有一个要点,对于 IP 来说,他必须大于等于两个 Network Travel 之间的间隔(这里方便,叫做 PP,packet-packet 的意思)。然后为了减少延时,IP 应该越小越好(否则后续就会持续拖下去,当然可以实现为如果新包到了就不插了,但是这样可能刚刚插到一半,然后还是有一个闪现效果)。
- 综合上述,所以说 IP 应该等于 PP。然而 PP 并不是固定的,这点很离谱。PP 实际和服务器运算有关,和 C - S 之间的网络拓扑及链路实时状态有关。
- 对于摄像机来说,移动摄像机是不需要任何 command 的,而能够利用 GPU 渲染给用户提供流畅体验,因此结合摄像机和客户端插值,能够提高了一下体验。
Client Side prediction(expercolation)
- 如果想要去掉 server 到 client 的那半个 RTT,就要更加激进了。客户端本身需要进行一些逻辑的演算,只需要演算 1/2 RTT 部分(当然 RTT 的测量本身特别 tricky,这个点在讲 congestion control (网络波动 RTT 测不准)和 karn 退避利用 RTT 决议超时 RTO 算法(重传二义性 RTT 测不准)的时候都讲过了)等到服务器的状态到达即可。这就是很类似混合的帧同步+状态同步?这个是守望先锋的 GDC 分享 GDC Vault - 'Overwatch' Gameplay Architecture and Netcode。
![多人网络游戏服务器开发基础学习笔记 II: 帧同步 | 游戏客户端预测原理分析 | FPS 游戏状态同步_第11张图片](http://img.e-com-net.com/image/info8/5ef4d18529aa4089adda3a0dadb87a24.jpg)
- 守望先锋这个就讲了这个 prediction,然后再一个例子是 RTT 大概是 250ms,然后小美已经冻住了路霸的时候,路霸客户端还在向前走,然后过来一会儿,路霸会马上闪现回来,undo 自启动 prediction。下面详细分析这个守望先锋补充上书本这部分的简略说明。
- 要做 prediction,就要维护command队列,一个是为了 predict 嘛,所以你必须有前面的状态用于 predict以及前面和现在发给 server 但是没有回来的 command 。
![多人网络游戏服务器开发基础学习笔记 II: 帧同步 | 游戏客户端预测原理分析 | FPS 游戏状态同步_第12张图片](http://img.e-com-net.com/image/info8/848771b3a5c243f8b594f07215252342.jpg)
- 如上图,如果 predition 出错了,此时 client 还是 半个 RTT ahead of server,这时候就要做 undo 了,当不匹配的数据到达的时候,此时 client 显示的已经在一整个 RTT 之后了(前面说过了为什么是一个 RTT)。
![多人网络游戏服务器开发基础学习笔记 II: 帧同步 | 游戏客户端预测原理分析 | FPS 游戏状态同步_第13张图片](http://img.e-com-net.com/image/info8/a89731d7ae4245898d5f7bcc542b87f2.jpg)
- 此时需要更新所有的 buffer 尽管他们已经显示出去了(为了接下来的 prediction 是正确的),然后做一些 undo 的插值,比如路霸上面的一个顺畅的回退。
- 由于这里讲了守望先锋,所以干脆把另一个涉及这个 GDC 讲演的问题一个他拖到这一再一起讲一遍(书本偏基础,具体的讲解很少,所以我觉得这个例子放在这里很有意义),前面在 P2P 实例里面我讲过一个帧同步对于丢包的 CS 做法:对于使用 CS 的帧同步,有一个办法 帧锁定同步算法 - Skywind Inside,就是乐观帧锁定,这个方案主要是用在 CS 架构的帧同步,由于服务器只是负责转发所有的包(相当于一个同步器同步各方 commands 然后 broadcast 出去),所以如果服务器没有收到一个 peer 的包,照样可以强制推送,这样就只有一个客户端会被影响,对于其他客户端,正常运行。守望先锋采用的就是这种方案GDC Vault - 'Overwatch' Gameplay Architecture and Netcode,虽然主要是讲 ECS 架构的,但是也提到了这个 fixed update(netcode 部分)。对于 P2P 我还没找到好的办法,大概只能投票 T 人了。
- 这里涉及的是丢包的时候,照样要强推 Update,不过当然应该告知客户端我没有收到你的包。
![多人网络游戏服务器开发基础学习笔记 II: 帧同步 | 游戏客户端预测原理分析 | FPS 游戏状态同步_第14张图片](http://img.e-com-net.com/image/info8/1baa25d79dda49c592a72137ce7aff3d.jpg)
- 之前说过了,对于 gameloop 必须要保证游戏的进度,所以这种情况由于这一轮的指令没有在服务器运算,所以客户端必须要持续推进进度再进行渲染(即使是状态同步也是这样的,因为这里实际客户端进行了 prediction,而这个 prediction 又是基于一系列的 command + 前面的已经被 server 确定的 state 来 predict 的,所以等于自己也在演算了一遍类似之前讲的帧同步的机制了),这个是因为要尽量不 undo 我的 prediction,尝试能不能提交客户端的动作,如果可以,那么客户端最多只是比玩家刚才看到的动作对同局的其他玩家来说慢一点而已,实际还是进行了的。这种情况 server 需要模拟所有缺失的这些动作,所以需要加大缓冲区,同时 client 由于慢了,所以他必须 predict 更多的帧从而 catch up 整个游戏进度。(注意这里的坐标是帧,而不是游戏时间,具体的实现就是我们之前所的,gameloop 里面推动逻辑帧,渲染新的状态要等我们 catch up 了再做)。
![多人网络游戏服务器开发基础学习笔记 II: 帧同步 | 游戏客户端预测原理分析 | FPS 游戏状态同步_第15张图片](http://img.e-com-net.com/image/info8/7d4f084ccfb54eb19c9f09c51898279e.jpg)
- 然而这一套东西做起来是不简单的,很麻烦属于,还要用上了 sliding window。同时正如我们之前说的很多 overwhelming 的情况玩家卡顿了会一直按键盘引发很多重复包,这种情况也要处理。
- 这种 prediction 的方案可以认为是服务端校验,服务端会有一个 snapshot,如果校验失败,prediction 就要 undo,然后用服务端的 snapshot 然后循环往复这个过程。
实时应用
- 对于射击游戏来说,实时性是至关重要的,不可能说能看到打死了一个 avatar,过一会儿服务端告诉你你预测错了,现在你没有打死它,这是不可接受的。
- Valve 提出了一个解决方案,他的 CS 也会用这个。书本没有给链接,不过我找到了,在这里有描述,摘录一部分(就是书本描述的内容):Valve Source 引擎 Wiki
Let's say a player shoots at a target at client time 10.5. The firing information is packed into a user command and sent to the server. While the packet is on its way through the network, the server continues to simulate the world, and the target might have moved to a different position. The user command arrives at server time 10.6 and the server wouldn't detect the hit, even though the player has aimed exactly at the target. This error is corrected by the server-side lag compensation.
The lag compensation system keeps a history of all recent player positions for one second. If a user command is executed, the server estimates at what time the command was created as follows:
Command Execution Time = Current Server Time - Packet Latency - Client
Then the server moves all other players - only players - back to where they were at the command execution time. The user command is executed and the hit is detected correctly. After the user command has been processed, the players revert to their original positions.
- 首先是非我自己的用户是不能使用预测,所以回退到了从输入到显示一整个 RTT的延迟时间的那个客户端插值的方案。
- 对于本地玩家,还是可以用 input预测+server state replay +冲突时undo 的方案。
- 客户端必须提供插值的两个帧的 ID 和新的输入(开枪的时候)在插值的百分比,从而让服务器知道客户端当时看到的是什么样的景象。
- 服务器对所有对象都要保留最近的逻辑帧,由于服务端时间总是领先于客户端的原因,这些逻辑帧会包含客户端插值的那个帧。
- 如果爆头了,直接回退被爆头玩家的。理论上只是回退一两个个逻辑帧。(300ms 的话基本好几帧了),这样网络质量好的玩家反而被网络质量差的玩家爆头。
- 还有一个是同时残血击毙的现象,理论上是不会同时击的,因为根本做不到同时开火的,当时因为回退使这个成为了可能。参考这个:fps 同步机制 zhihu
- 手雷的实现方法,书本后面延申阅读材料有一个 在 2011 的 gdc 演讲 halo reach 的 shot you first。一开始连动画都要 lag。
![多人网络游戏服务器开发基础学习笔记 II: 帧同步 | 游戏客户端预测原理分析 | FPS 游戏状态同步_第16张图片](http://img.e-com-net.com/image/info8/ed81e6c5aa204f83b6c5d1ddd0ba2cfc.jpg)
- 但是如果马上丢手雷的,不行,这样会涉及回退!因为没有许可就丢了手雷。解决方案是让 client 丢手雷的时候,画面会显示一个手臂。
![多人网络游戏服务器开发基础学习笔记 II: 帧同步 | 游戏客户端预测原理分析 | FPS 游戏状态同步_第17张图片](http://img.e-com-net.com/image/info8/389d4b46ed2044688fb27458fb134329.jpg)
- 无敌的实现方法,注意这里普通的情况由于所有游戏都发生在 server,client 的动作实际是和 server 完全一致的,也就是说如果有一个前摇 animation,理论上也是同步有这个延迟的。这里就是让CS双方的延迟不对等。无敌的实现方法则是让服务器的这个触发的 animation 延迟减少,从而更快允许玩家进入无敌状态。
![多人网络游戏服务器开发基础学习笔记 II: 帧同步 | 游戏客户端预测原理分析 | FPS 游戏状态同步_第18张图片](http://img.e-com-net.com/image/info8/f96f30b22d174fafbf0d545e5c9fee92.jpg)
- 腾讯天美有这个,摘录一下。fps 同步机制 知乎2
- 在任一时刻,客户端看到的自己的位置始终是领先于服务器的,但看到的其他人的位置又都是落后于服务器的。这导致战斗中,我们始终是在用将来位置的自己去打过去位置的敌人。
![多人网络游戏服务器开发基础学习笔记 II: 帧同步 | 游戏客户端预测原理分析 | FPS 游戏状态同步_第19张图片](http://img.e-com-net.com/image/info8/a655d6b8a7f74eb39dc93e13e9fdf7b5.jpg)
![多人网络游戏服务器开发基础学习笔记 II: 帧同步 | 游戏客户端预测原理分析 | FPS 游戏状态同步_第20张图片](http://img.e-com-net.com/image/info8/040d7006ee24455f8306b1383abea441.jpg)
- Peeker’s advantage 问题Peeking into VALORANT's Netcode | Riot Games Technology 这个链接里面那个 gif 特别好看懂。