Skip to content

Latest commit

 

History

History
407 lines (258 loc) · 58.3 KB

File metadata and controls

407 lines (258 loc) · 58.3 KB

#book_note #game #backend

Chapter 6: Network Topologies and Sample Games

网络拓扑 (6.1)

网络拓扑(network topology)决定了网络中的计算机之间是如何连接的。就游戏而言,拓扑决定了参与游戏的计算机是如何组织在一起的,目标是保证所有玩家都可以看到游戏状态的最新版本。正如决定网络协议一样,在不同的拓扑结构之间需要做出权衡。

一、客户端 --- 服务器 (6.1.1)

在客户端-服务器的拓扑结构中,一个游戏实例被指定为服务器,其他所有的游戏实例被指定为客户端。每个客户端只能和服务器通信,同时服务器负责与所有客户端通信。

假设客户端与服务器之间建立了双向的通讯,那么当给定 $n$ 个客户端,总共会有 $O(n^2)$ 个连接。这种结构是不对称的,服务器有 $O(n)$ 个连接(每个连接对应一个客户端),但每个客户端与服务器只有一个连接。

就带宽而言,当客户端的数量增加时,服务器的带宽要求是线性增加的。

  • 假设有 $n$ 个客户端,每个客户端每秒发送 $b$ 个字节的数据,那么服务器必须有足够的带宽处理每秒 $b × n$ 个字节的下载,相应的,如果服务器需要每秒发送 $c$ 个字节的数据给每个客户端,那么服务器必须支持每秒 $c×n$ 个字节的上传。

  • 然而,每个客户端仅仅需要支持每秒 $c$ 个字节的下载流和 $b$ 个字节的上传流。理论上,随着客户端数量的增加,客户端的带宽要求不变。但是,实际上支持更多的客户端会导致需要复制(replication)的世界对象数目增加,导致每个客户端的带宽要求略有增加。


Client-server topology.

尽管不是客户端-服务器的唯一方式,但是实现客户端-服务器模式的大部分游戏使用一台权威(authoritative)服务器。这意味着我们认为游戏服务器上的游戏模拟(simulate)是正确的。如果客户端发现自己的游戏状态与服务器的不同,那么它应该根据服务器的游戏状态更新自己的状态。

通常情况下,客户端并不能擅自决定模拟一些操作的行为,至少它们都需要经过服务器的权威校验。这就意味着,采用设置权威服务器方案,客户端的行为会有一些滞后或延迟,导致这种延迟的一个重要原因是往返时间(round trip time, RTT),即数据包从发送端到目的主机,再从目的主机到发送端总共经历的时间(一般用毫秒表示)。理想的情况下,RTT是$100$毫秒或更少,尽管有现代互联网连接,也有许多因素可能不允许如此低的RTT。

假设一个游戏有一台服务器和两个客户端A和B。因为服务器给每个客户端发送所有的游戏数据,意思是如果客户端A扔一个线球,那么包含扔线球请求的数据包首先被传递给服务器。接着,服务器在给客户端A和B反馈结果之前先处理这个扔线球请求。在这种情况下,客户端B经历的是最坏的网络延迟,等于客户端A的$\frac 1 2$RTT,加上服务器的处理时间,再加上客户端B的$\frac 1 2$RTT:

$$ \text{L}_B = \frac 1 2 \text{RTT}_A + L_S + \frac 1 2 \text{RTT}_B $$

Tip

服务器还可以被细分。一些服务器是专用的(dedicated),意思是它们只运行游戏状态并与所有客户端通信。专用服务器进程与运行游戏的所有客户端进程是完全分开的。这意味着专用服务器是无外设的,实际上不显示任何图像。

专用服务器的另外一种替代方案是监听服务器(listen server)。在这个设置中,服务器也是游戏本身的积极参与者。监听服务器方案的一个优点是降低部署成本,因为不需要在数据中心租用服务器,相反地,玩家可以使用自己的计算机既作为服务器也作为客户端。但是,监听服务器的不足是作为监听服务器的计算机性能必须足够高,而且需要足够快的网络连接以应付服务器的额外负载。监听服务器方案有时被错误地称为对等网络连接,但是一个更准确的说法是对等托管(peer hosted)。仍然有一台服务器,只是恰巧由游戏玩家托管。

需要注意的是,我们假设监听服务器是权威的,保存完整的游戏状态。这意味着运行监听服务器的玩家可能使用该信息来欺骗。进一步地,在客户端-服务器模型中,通常只有服务器知道所有活动客户端的网络地址。如果服务器断开——无论是由于网络问题,还是恶意玩家退出游戏,都将导致巨大的问题。一些使用监听服务器的游戏实现一种主机迁移(host migration)的概念,意思是如果监听服务器断开,客户端中的一个被晋升为新的服务器。但是,要想实现这一点,客户端之间需要有一定量的通信。这意味着主机迁移需要有一个结合客户端-服务器拓扑和对等网络拓扑的混合模型。

二、对等网络 (6.1.2)

在对等网络拓扑中,每个单独的参与者都与其他所有的参与者连接。意味着客户端之间有大量数据来回传输。连接的数量是一个二次函数。换句话说,给定 $n$ 个对等体,每个对等体必须有 $O(n-1)$ 个连接,所以网络中产生 $O(n^2)$ 个连接。这也意味着,每个对等体的带宽需求增加到与连接到游戏中的对等体个数一致。但是,与客户端-服务器模式不同,带宽需求是对称的,所以每个对等体需要上传和下载的可用带宽数量是一样的。


Peer-to-peer topology.

在对等网络游戏中,权威的概念更加模糊。一种可行的方法是某些对等体对游戏的某些部分有权限,但是在实际中,这样的系统难以实现。在对等游戏中更常见的做法是每个对等体共享所有动作,每个对等体都模拟这些动作的执行。这种模式有时也被称为输入共享模型(input sharing)。

对等网络拓扑让输入共享更可行的一个方面是更少的延迟。区别于客户端-服务器模型,对等网络的客户端之间没有中介,所有对等体彼此之间直接通信。这意味着最坏情况下,对等体之间的延迟是 $\frac 1 2 \text{RTT}$。但是仍存在一定的延迟,这可能会导致对等网络游戏中最大的技术挑战——确保所有对等体保持彼此同步。

客户端 - 服务器实现 (6.2)

基于客户端 - 服务器的网络拓扑模型,我们创建一个网络游戏的最初版本 --《机器猫行动》(Robo Cat Action),这是一个自上而下的游戏,猫竞相收集尽可能多的老鼠,同时还能互相投掷线球。

该版本的网络代码(network code​)采用的是一种简单的状态同步实现。通常来说,玩家需要将其在客户端中的输入告诉给一个权威角色,角色依据玩家的输入在本地运行游戏的模拟,并将模拟的结果(即游戏对象的状态)同步给客户端,客户端再依据这个状态做相应的渲染处理。


Client-server topology implement.

对等网络实现 (6.3)

基于对等网络的网络拓扑模型,我们创建一个新的游戏副本——《机器猫RTS(Robo Cat RTS)》。这是一款即时战略游戏,最多同时支持$4$名玩家,每个玩家给3只猫。单击鼠标左键来选择一只猫,接着右键选择一个目标。如果目标是一个位置,那么这只猫移动到那个位置。如果目标是敌人的猫,那么在攻击之前先移动到敌人猫的势力范围。作为动作游戏,猫之间通过投掷线球来互相攻击。

在具体的实现上,《机器猫RTS》使用了主对等体(master peer)的思想。主对等体的主要目的是提供游戏中已知对等体的IP地址。当使用了游戏匹配服务来保存已知的可用游戏列表时,这是非常重要的。此外,只有主对等体可以给新玩家分配玩家 ID。这主要是为了避免在两个不同玩家同时连接多个对等体时所产生的竞争。除了这一个特例,主对等体与其他所有对等体行为一致。因为每个对等体独立存储整个游戏的状态,所以如果主对等体断开了,游戏仍然可以继续。


Peer-to-peer topology implement.

一、命令共享和锁步(Lookstep)回合制 (6.3.2)

此版本的游戏类型是 RTS,由于游戏内的单位对象数量较多,仅只是简单的同步游戏对象的状态的实现会带来更多的带宽与计算的负担。一种优化的手段就是改为仅同步玩家所发出的操作指令,这些操作指令序列的数据包会精简到很小,客户端接受并使用这些操作指令来单独执行游戏世界的模拟。

为了简化,《机器猫RTS》以锁定的 30 帧每秒的速率运行,即锁定的增量时间是~33ms。意思是某个对等体以多于 33ms 的时间渲染一帧,但是模拟仍然认为它是以 33ms 运行。

游戏将时间的长度切割为独立的回合(turn),其中每个回合内又包含了3个子回合(sub-turn)。我们将 33ms 长度的 tick 标记为一个子回合,也就是说,每一个完整的回合长度是 100ms,换句话说,每秒有 10 个回合。理想情况下,回合的持续时间可根据网络和设备的性能条件而变化。事实上,这也是关于 《帝国时代》论文 的讨论主题之一。

对于复制而言,每个对等体运行游戏世界的完整模拟。这意味着,对象不以任何方式进行复制,而是在游戏中只传输回合的数据包。这些数据包包含在某一轮中每个对等体发出的一系列操作指令和其他一些关键数据。应当指出的是,操作指令和实际输入之间会存在清晰的界限,并不意味着一切客户端输入都可以打包为一个操作指令,它们只发生在游戏认定的操作界限范围内,另外一点的是,不操作本身也是属于一种操作指令。

为保证同步,操作指令不是在采集了的一瞬间就被执行了。而是每个对等体将某一回合中发生的所有操作指令存储到队列(command_list)中,然后在此回合结束时,每个对等体再统一将指令序列中的内容广播给其它的玩家。自身的、包括其它玩家的操作指令序列会通过广播回调的手段存储回玩家自身的回合数据(turn_datas)当中。在未来的某一个回合中,客户端将检查当前回合的数据是否已经到齐(即所有玩家的操作指令序列),如果允许,将取出暂存的操作指令序列去执行以完全模拟游戏世界的状态变化;否则,此过程将流入到下一个回合并执行重复的检查判,换一句话说就是,如果不符合条件的话,则游戏将被锁定(lock)。

在实际的实现中,我们会将回合的初始状态设置为 $-2$,并且会将第 $x$ 回合发生的操作指令留有第 $x+2$ 回合被检查,也就是说我们会前置留有初始两个回合的等待时间,以方便后续逻辑执行的连续性。将操作指令延迟执行可以允许每个玩家在大约 100ms 的时间里接收和处理数据包。虽然可以给对抗网络延迟问题提供了更多的可能性,但是也就意味着在正常的情况下从操作指令发出到执行有高 200ms 的延迟。但是,因为延迟是一致的,所以并不会影响游戏体验,至少在 RTS 这个例子中没有受到影响。

二、保持同步 (6.3.3)

由于我们需要每个客户端内以自身和其它玩家的操作序列去独立执行游戏世界的状态模拟,那么保持游戏中所有实例的同步就成了该架构下所遇到最大的挑战之一。即使轻微的出入,例如不一致的位置,都会在未来演变成更加严重的问题。如果允许这些出入存在,随着时间的推移,客户端之间的模拟将会出现分歧。在某一时刻,这些模拟的差异如此大,以至于感觉在玩不同的游戏。

同步伪随机数生成器:

为每个对等体的随机数生成器设置相同的种子和相同的随机数生成算法。在《机器猫RTS》例子中,当主对等体发送开始数据包时,选择一个种子。开始数据包中包含这个种子,所以每个对等体都知道开始游戏时的种子取值。

保证所有对等体使用相同的随机数生成算法。C 标准中没有指定函数 rand(3) 必须使用哪种伪随机数生成器算法。也就是说不同平台(甚至只是不同编译器)的 C 语言库的不同实现不能保证使用的是同一个伪随机数生成器算法。为此,机器猫 RTS 的代码实现了伪随机数生成器的梅森旋转算法(Mersenne Twister PRNG algorithm)。被称为 MT19937 的 32 位梅森旋转算法的取值区间为 $2^{19937}$,意味着事实上在给定游戏的过程中随机数序列是不会重复的。

必须保证,每个对等体在每一回合总是调用相同次数的伪随机数生成器,同样的顺序,在代码中同样的位置。这意味着几乎不能创建游戏的不同版本,这些版本使用伪随机生成器的次数有所差异,例如为跨平台运行中不同硬件编写的版本。

检查游戏同步:

不同步的其他原因可能就没有伪随机数生成器那么明显了。例如,浮点数的实现是确定的,但是根据硬件实现的不同会有出入。例如,更快的 SIMD 指令可能和普通的浮点指令产生不同的结果。通常也可以在处理器上设置不同的标志来改变浮点数的行为,例如是否严格遵循 IEEE754 的实现。

同步的其他问题可能只是由程序员引起的一个意外错误。也许程序员不知道同步如何工作,或者只是犯了一个错误。无论哪种方式,重要的是,游戏中有用于定期检查同步的代码。这样,在引入不同步之后有希望尽快找到不同步的错误原因。

一种常见的方法是使用检验和(checksum),与网络中用于保证数据包数据完整性的检验和类似。本质上,在每一回合结束时,就计算好了游戏状态的检验和。检验和被放入轮数据包中并发送,这样每个对等体可以验证所有游戏实例在每轮结束时是否计算得到相同的检验和。在检验和算法选择方面,有许多不同的选项。《机器猫RTS》使用循环冗余检验(cyclic redundancy check, CRC),生成一个$32$位的检验和值。

Chapter 7: LATENCY, JITTER, AND RELIABILITY

延迟 (latency) (7.1)

你的游戏一旦发布到外界,就必须对付许多不利的因素,这是在被严格控制的本地网络中所不存在的。这些因素中的第一个就是延迟(latency)。延迟这个词在不同的情况中有不同的含义。在电脑游戏的上下文中,指的是从可观察的原因到看到结果之间的时间。根据游戏的类型,可以是从实时战略(realtime strategy, RTS)游戏中鼠标单击和单元响应命令之间的时间间隔到用户移动头部和虚拟现实(virtual reality, VR)显示更新之间的时间间隔。

一定量的延迟是不可避免的,并且不同的游戏类型对于延迟的容忍程度也是不同的。虚拟现实游戏是对延迟最敏感的,因为我们人类只要头旋转了,眼睛就期望看到不同的事物。在这些情况下,保证用户感觉在虚拟现实世界中就要求延迟少于 20ms。格斗游戏、第一人称射击游戏和其他动作频繁的游戏是对延迟第二敏感的。这些游戏的延迟范围可以从 16ms ~ 150ms,不考虑帧速率,在这么少的延迟下用户是感觉不到的。RTS游戏是对延迟容忍度最高的,这个容忍度通常很有用,游戏的延迟可以高达 500ms,而不影响用户体验。

一、非网络延迟 (7.1.1)

有一个很普遍的误解是,网络延迟是游戏延迟的主要来源。尽管网络上数据包交换是延迟的一个显著来源,但是绝对不是唯一的来源。

  • 输入采样延迟(input sampling lateney):从用户按下一个按钮到游戏检测到这个按钮被按下之间的时间可以很长。考虑这种情况,一个游戏以每秒 60 帧运行,在每一帧开始时检测输入,然后在最后渲染游戏世界之前相应地更新所有对象。如下图(a)所示,如果用户在游戏检测完输入之后 2ms 按下跳跃按钮,几乎过了一帧游戏才能更新基于这个按钮被按下的游戏状态。对于驱动视角旋转的输入,可以在帧结束时再次对输入进行采样,然后根据改变的角度渲染输出,但是这通常只限于对延迟非常敏感的应用。

  • 渲染流水线延迟(render pipeline latency):GPU 不是在 CPU 批量发布绘制命令之后马上执行这些命令。事实上,驱动程序将这些命令插入到命令缓冲区,GPU 在将来的某个时刻执行这些命令。如果有许多渲染任务要做,GPU 给用户显示渲染图像可能会滞后 CPU 一帧的时间。下图(b)展示了这样一个在单线程游戏中常见的时间轴。

  • 多线程渲染流水线延迟(multithreaded render pipeline latency):多线程游戏将更多的延迟引入到了渲染流水线中。通常情况下,一个或多个线程运行游戏模拟,更新游戏世界时要发送给一个或多个渲染线程。然后在模拟线程准备模拟下一帧时,这些渲染线程批量处理 GPU 请求。下图(c)展示了多线程渲染如何给用户体验添加了额外一帧的延迟。

  • 垂直同步(VSync):为了避免画面撕裂,通常的做法是仅仅在显示器的垂直消隐间隙改变由视频卡显示的图像。这样显示器就不会同时显示这一帧的部分图像和下一帧的部分图像。这意味着 GPU 的更新图像调用必须等到用户显示器的垂直消隐间隙,通常每 $\frac {1} {60}$ 秒一次。如果游戏的帧只需要 16ms,那么这是没有问题的。但是,即使一帧的渲染时间延长 1ms,那么在视频卡准备更新显示的时候不能完成渲染。在这种情况下,将后台缓冲区内容展示到显示器的命令将延迟,等待额外的 15ms 直到下一个垂直消隐间隙。一旦发生这种情况,用户将感受到另外一帧延迟。

  • 显示延迟(display lag):大部分的 HDTV 和 LCD 显示器在真正显示图像之前,都会在一定程度上处理输入。这个过程包括去隔行、HDCP 以及 DRM 处理,还包括一些图像效果,例如视频缩放、降噪、自适应亮度、图像过滤等。这个处理是有代价的,很容易给用户体验增加几十毫秒的延迟。一些电视有游戏模式,减少视频处理以便最小化延迟,但是你不能默认它是被启用的。

  • 像素响应时间(pixel response time):LCD 显示还有一个问题是像素亮度的改变需要时间。通常情况下,这个时间在几毫秒级别,但是老的显示器,可以很容易地添加额外的半帧延迟。幸运的是,这种延迟更像是图像重影,而非绝对意义上的延迟——改变立刻开始,但是需要几毫秒时间。


Latency timing diagrams.

二、网络延迟 (7.1.2)

尽管有许多延迟的原因,数据包从源主机传输到目的主机的延迟往往是多人游戏中延迟的最显著原因。

  • 处理延迟(processing delay):网络路由器的工作是读取来自网络接口的数据包、检查目的IP地址、找出应该接收数据包的下一台机器,然后从合适的接口将数据包转发出去。检查源地址和确定合适路由的时间称为处理延迟。处理延迟也包括路由器提供的其他功能,例如 NAT 或者加密。

  • 传输延迟(transmission delay):路由器转发数据包时,必须有一个链路层接口允许它通过一些物理介质传输数据包。链路层协议控制写入物理介质的平均速率。例如,1MB 的以太网连接允许大约每秒向以太网电缆写入 $10^6\text{bit}$。这样,向 1MB 的以太网电缆写一个比特需要花费 $\frac {1} {1000000} \text{s}$,即 1 微秒(1μs),因此写一个 1500bytes 的数据包需要 12.5ms。向物理介质写比特流所花费的时间被称为传输延迟。

  • 排队延迟(queuing delay):路由器在一个时间点只能处理有限个数的数据包。如果数据包到达的速度比路由器处理的速度快,那么数据包将进入接收队列,等待被处理。同样地,网络接口一次只能输出一个数据包,所以数据包被处理之后,如果合适的网络接口繁忙,那么它将进入传输队列。在队列中消耗的时间被称为排队延迟。

  • 传播延迟(propagation delay):在大多数的情况下,无论什么物理介质,信息的传输也不可能比光速还快。这样,发送数据包的延迟至少是 0.3ns/m(纳秒/米) 乘以数据包必须传输的距离。这意味着,即使在理想的情况下,一个数据包要穿越美国至少需要 12ms。在传播过程中花费的时间被称为传播延迟。

这些延迟中的一些可以被优化,一些不能被优化:

  • 处理延迟是很小的因素,因为现在大部分路由器是非常快的。

  • 传输延迟通常依赖于终端用户链路层连接的类型。因为当数据包接近互联网的骨干时,带宽能力通常会增加,传输延迟在互联网边缘时是最大的。保证你的服务器使用高带宽的连接是最重要的。之后,通过鼓励终端用户升级到高速互联网连接可以很好地降低网络延迟。发送尽可能大的数据包也会有帮助,因为可以减少数据包头部的数据量。如果这些头部占你的数据包大小的很大一部分,那么其也将带来很大一部分传输延迟。

  • 排队延迟是数据包等待被传输和处理的结果。最小化处理延迟和传输延迟有助于最小化排队延迟。值得注意的是,因为通常的路由器仅仅需要检查数据包的头部,所以通过发送少量大的数据包来代替许多小的数据包可以降低总的排队延迟。例如,包含 1400bytes 负载的数据包与包含 200bytes 负载的数据包通常经历相同时间的处理延迟。如果你发送 7 个包含 200bytes 负载的数据包,最后那个数据包将不得不在队列中等待前面 6 个数据包的处理,这样将经历比一个大数据包更多的累积网络延迟。

  • 传播延迟通常是优化的良好对象。因为它依赖于主机之间交换数据的电缆长度,最好的方法是移动主机使得彼此之间距离非常近。在对等网络游戏中,这意味着在匹配玩家时优先优化几何位置。在客户端-服务器游戏中,这意味着要保证游戏服务器离客户端近。请注意,有时物理位置不足以保证低的传播延迟:两个位置之间的直接连接可能不存在,这就要求路由器在迂回线路中路由,而不是通过直线连接。重要的是在规划你的游戏服务器时,要考虑到现有和未来的路由路线。

在网络的上下文中,工程师有时使用延迟这个词来描述以上四种延迟的组合。因为延迟是这样一个重载的术语,所以游戏开发者更经常讨论往返时间(Round Trip Time, RTT)。RTT指的是数据包从一台主机传输到另一台主机的时间,加上响应数据包返回的时间。这不仅反映了两个方向的处理延迟、排队延迟、传输延迟和传播延迟,还反映了远程主机的帧率,因为这影响了它发送响应包的速度。请注意,在每个方向上传输的速度不一定相同。RTT 几乎不可能是数据包从一台主机到另外一台主机时间的两倍。尽管这样,游戏往往用一半的 RTT 来近似单向的传输时间。

抖动 (Jitter) (7.2)

对于任意的两个客户端,它们之间的 RTT 一般围绕着一个基于平均延迟的特定值变化。但是,这些延迟随着时间的推移会变化,导致 RTT 与期望值有偏差。这个偏差被称为抖动(jitter)

  • 处理延迟 (processing delay):因为处理延迟是网络延迟中最小的组成部分,所以它对抖动的贡献也是最小的。因为路由器动态调整数据包的路线,所以处理延迟可能会变化,但这是一个次要问题。

  • 传输延迟和传播延迟 (transmission delay and propagation delay):这两种延迟都是数据包所采用的路由导致的,链路层协议决定了传输延迟,路由长度决定了传播延迟。这样,当路由器动态进行负载均衡和改变路由以避免严重拥堵区域时,这些延迟会改变。这在网络堵塞时可以迅速波动,路由改变可以显著地改变往返时间。

  • 排队延迟 (queuing delay):排队延迟是路由器必须处理多个数据包导致的。这样,到达路由器的数据包的数量变化了,排队延迟也改变了。突发的网络流量将导致排队延迟,并改变往返时间。

减少抖动的技术与降低总体延迟十分类似。发送尽可能少的数据包来保持低流量,将服务器布置在玩家附近来降低遇到严重拥堵的可能性。此外,帧率也会影响 RTT,因为帧率的变化导致处理器处理能力变弱,导致网络包处理受到影响,所以帧率的巨大变化会给客户端带来负面影响。保证复杂的操作合理分散在多个帧中,防止由帧率导致的抖动。

数据包丢失 (7.3, 7.4)

比延迟和抖动更严重的,网络游戏开发者面临的最大问题是数据包丢失(packet loss)。数据包需要花费很长时间才能到达目的地,和数据包永远不能到达目的地是两码事。造成数据包丢失有许多原因:

  • 不可靠的物理介质 (unreliable physical medium):从根本上说,数据传输是电磁能量的传输。任何外部的电磁干扰都可能导致数据破坏。在数据损坏的情况下,链路层通过验证检验和来检测损坏,并丢弃包含损坏数据的帧。宏观的物理问题,如松动的连接或者附近有一个微波炉在工作,也都可能导致信号损坏和数据丢失。

  • 不可靠的链路层 (unreliable link layer):链路层规定了他们什么时候可以发送数据,什么时候不可以发送数据。有时链路层信道完全满了,必须丢失正在发送的帧。因为链路层不保证可靠性,所以这是一个完全可以接受的响应。

  • 不可靠的网络层 (unreliable network layer):当数据包到达路由器的速度比处理数据包的速度快,就会将数据包插入接收队列中。这个队列只能存储固定数量的数据包。当队列满了,路由器开始抛弃队列中的数据包或者刚传入的数据包。

更少的数据包丢失肯定会带来更好的游戏体验,所以在上层架构设计时,就应该尝试降低数据包丢失的可能性。使用与玩家尽可能近的服务器数据中心,因为较少的路由器和电缆意味着较低的数据丢失可能性。另外,发送尽可能少的数据包,因为大部分路由器的处理能力是以数据包的个数为基础,而不是总数据量。在这种情况下,如果发送许多包含少量数据的小数据包,而不是发送少量的大数据包,那么你的游戏发生路由器过载的可能性更高。

[!info] 当队列满了路由器不一定丢弃每一个传入的数据包。相反,它可能丢弃先前进入队列的数据包。当路由器确定传入的数据包比队列里的数据包有更高的优先级或者更重要时,将会这么做。路由器基于网络层头部的 QoS 数据来确定数据包的优先级,有时也通过检查数据包的负载收集更深的信息。有些路由器甚至配置成采用贪婪算法,为了减少它们必须处理的总流量:它们有时在丢弃 TCP 报文之前先丢弃 UDP 报文,因为它们知道丢弃的 TCP 报文将会自动重传。了解数据中心和目标市场 ISP 附近的路由器配置有助于调整数据包类型和传输模式,来减少数据包丢失。

数据包丢失是无法改变的事实,初期设计网络架构时我们则需要更多的考虑与准备。另外一个重要的决定是在 TCP 与 UDP 之间做出一个选择,让你的游戏依赖于 TCP 已有的可靠系统,还是在 UDP 基础上开发自己的自定义的可靠系统

TCP 的主要优点是,它提供了一个经得起时间考验、坚固的、稳定的可靠性实现。使用它我们不需要额外的工程工作就能保证所有的数据不仅能送达,而且能按序送达。此外,它提供了复杂的拥塞控制功能,通过以不会阻塞中间路由器的速率发送数据来限制数据包丢失。TCP 的主要缺点是,它发送的所有东西必须被可靠发送并按序处理。在游戏状态瞬息万变的多人游戏中,在三种不同的情景下,这种强制的、统一的可靠传输可能会造成其它的问题,比如:

  • 优先级数据的丢失干扰高优先级数据的接收

  • 两个单独的可靠有序数据流相互干扰

  • 过时游戏状态重传

除了执行强制的可靠性,使用 TCP 还有一些其他缺点。尽管拥塞控制有利于防止丢失数据包,但是并非所有的平台都是统一可配置的,有时可能导致你的游戏发送数据包的速度比你期望的要慢。Nagle 算法在这里起了非常不好的作用,因为它在将数据包发送出去之前可以延迟长达半秒。事实上,使用 TCP 作为传输层协议的游戏通常禁用 Nagle 算法以避免这个问题,虽然同时放弃了它提供的减少数据包数量的优势。最后,TCP 为管理连接和跟踪所有可能被重传的数据分配了很多资源。这些分配通常是由操作系统管理的,游戏需要时很难通过自定义内存管理器的方式跟踪和路由。

另一方面,UDP 没有提供 TCP 所提供的内置可靠性和流量控制。但是,它提供了一张空白画布,你可以根据游戏的需要绘制任何类型的自定义可靠系统。你可以允许发送可靠的和不可靠的数据,或者分离的可靠有序数据流的交错。也可以创建一个系统,在丢包时只发送最新消息,而不是重传丢失的数据。可以自己管理内存,对数据如何分组成网络层数据包进行细粒度的控制。


Comparison of TCP to UDP.

Chapter 8: IMPROVED LATENCY HANDLING

沉默的客户端 (8.1)

服务器是唯一拥有真实和正确游戏状态的主机。这是所有反欺骗客户端-服务器设置的一个传统需求:服务器是唯一运行最重要模拟的主机。这意味着一个玩家产生动作到这个玩家观察到该动作导致的真实游戏状态,总有一些延迟。

下图描绘了延迟发生的过程:$\text{client}_A$ 和服务器之间的往返时间是 100ms。在 0 时刻,$\text{client}_A$ 上的虚拟人在休息,Z 轴的位置是 0 。接着 $\text{player}_A$ 按下了跳跃按钮。假设延迟大致是对称的,那么需要往返时间的一半,即 50,携带 $\text{player}_A$ 输入的数据包到达服务器。当服务器收到输入,开始执行玩家的跳跃动作,并设置 $\text{player}_A$ 虚拟人的 Z 轴位置是 1。服务器发送新的状态,该状态再需要另一半往返时间,即 50ms,到达 $\text{client}_A$。$\text{client}_A$ 根据服务器发来的状态更新虚拟人的 $Z$ 轴位置,并显示在屏幕上。所以最后,在按下跳跃按钮之后的 100ms,$\text{player}_A$ 才能看到跳跃动作的结果。


Packet round trip.

从这个例子中可以总结出一个有用的结论:运行在服务器上的真实模拟通常比远程玩家感觉到的真实模拟早半个往返时间。换句话说,如果玩家观察的仅仅是服务器复制给客户端的真实模拟状态,那么玩家对游戏世界状态的感知至少比服务器的真实世界状态晚半个往返时间。根据网络流量、物理距离和中间硬件不同,这个时间可以高达 100ms 或者更多。

客户端给服务器发送输入,然后服务器运行模拟并返回给客户端显示。这些游戏中的客户端被称为沉默的终端(dumb terminal)

  • 因为它们并不需要对模拟有任何了解,唯一的目的就是发送输入,接收结果状态,并把它显示给用户

  • 因为它们仅仅显示服务器发出的状态,所以绝对不会显示给用户错误的状态。尽管有些延迟,但是沉默的终端显示给用户的所有状态都是在那个时间点附近绝对正确的状态。

  • 因为整个系统的状态总是一致和正确的,所以这种网络方法被称为保守算法(conservative algorithm)。以用户能感受到延迟为代价,保守算法至少是绝对正确的。

除了能感觉到延迟,单纯的沉默终端还存在另外一个问题。鉴于高性能的 GPU,$\text{client}_A$ 能以每秒 $60$ 帧运行。服务器也能以每秒 60 帧运行。但是由于服务器和 $\text{client}_A$ 之间的连接带宽限制,服务器只能以每秒更新 15 次的频率发送状态。假设玩家在开始跳跃时每秒向上移动 60 个单位,服务器以每帧 1 个单位的频率平滑地增加 Z 轴的位置。但是,服务器每 4 帧给客户端发送一个状态。当 $\text{client}_A$ 接收到这个状态时,更新 $\text{player}_A$ 虚拟人的 Z 轴位置,但是同一个 Z 轴位置必须被渲染 4 帧,直到有服务器传来的新状态。这意味着 $\text{player}_A$$4$ 帧内看到的是同一幅画面。即使玩家在 GPU 上花费了很多金钱使得渲染速度达到每秒 60 帧,但是由于网络的限制导致只能得到每秒 15 帧的体验。除了跳跃的案例,这种类型的延迟(反应迟钝的感觉)在第一人称射击类游戏中会导致很难瞄准目标。如果没有玩家位置的最新信息,那么指出瞄准的位置同样也是一个令人很不愉快的体验。


Jumping with 15 packets per second.

客户端插值 (Client Side Interpolation) (8.2)

来自服务器不频繁的状态更新带来的跳跃结果让玩家感觉他们的游戏运行速度比实际慢。缓解这个问题的一种方法是通过客户端插值(client side interpolation)。当使用客户端插值时,客户端游戏不是自动将对象移动到服务器发送来的新位置。而是每当客户端收到一个对象的新状态时,它使用被称为本地感知过滤器(local perception filter)的方法根据时间平滑地插值到这个状态。


Timing of client side interpolation.

$\text{IP}$ 表示以毫秒为单位的插值周期(interpolation period),即客户端从旧状态插值过渡到新状态需要的时间。让 $\text{PP}$ 表示以毫秒为单位的数据包周期,即服务器在发送两个数据包之间需要等待的时间。在数据包到达之后 $\text{IP}$ 毫秒时,客户端完成到这个数据包状态的插值。如果 $\text{IP} < \text{PP}$,那么客户端在新数据包到达之前将停止插值,玩家仍然会感觉到卡顿。为了保证客户端的状态每帧都平滑地变化,插值不应该停止,则要保证 $\text{IP} > \text{PP}$。通过这种方式,每当客户端完成插值到一个给定状态,它都已经接收到了下一个状态,并再一次启动这个过程。

但是,单纯的仅采用插值技术并无法完全解决延迟问题,因为这可能导致更多的延迟,特别是当服务器帧的状态到达后客户端还要平滑的花费一定时间进行模拟时,给用户的感官就是这个动作执行被延迟了。具体来说,使用客户端插值的游戏给玩家展示的状态比服务器上的真实状态滞后大约 $\frac{1}{2} \text{RTT} + \text{IP}$ 毫秒。为了最小化延迟,$\text{IP}$ 应该尽可能小。考虑到为避免玩家感到卡顿,同时必须使 $\text{IP} >= \text{PP}$,这意味着 $\text{IP}$ 应该正好等于 $\text{PP}$

服务器可以通知客户端它打算发送数据包的频率,或者客户端凭经验根据数据包到达的频率计算 $\text{PP}$。注意,服务器应该根据带宽,而不是延迟,来设置数据包周期。服务器可以根据它认为的客户端和服务器之间的网络情况来以尽可能高的频率发送数据包,更高的频率也就意味着更低的 $\text{PP}$,同时也能够使 $\text{IP}$ 尽可能的小。这意味着使用客户端插值方式的游戏玩家感知到的延迟是网络延迟和网络带宽综合的一个因素。

并不意味着将一切动作都应用插值是一个最好的选择,更合适的方法,我们应该从动作中进行一个筛选,筛选出一些并不会影响游戏世界模拟的动作让客户端直接处理它,而非等待一些网络延迟的时间。在这里,调整摄像机是一个很不错的例子,我们将调整的动作都仅放在客户端完全执行模拟,这样可以给玩家带来更好的游玩体验。

客户端插值仍然被认为是一个保守算法:尽管它有时表示的状态不完全是服务器复制过来的,仅仅是服务器真正模拟的两个状态之间的状态。

客户端预测 (Client Side Prediction) (8.3)

客户端插值使得玩家的体验更加平滑,但是仍然不能让客户端状态更接近服务器实际发生的状态。即使是微小的插值周期,玩家看到的状态仍然滞后至少半个 RTT。为了展示更近的游戏状态,你的游戏需要从插值转为推测。通过推测法,客户端可以接收略旧的状态,并在显示给玩家之前推测近似的最新状态。这种推测(也称为外推)技术通常被称为客户端预测(client side prediction)。

为了推测当前的状态,客户端必须能运行与服务器相同的模拟代码。当客户端收到一个状态更新,它知道该更新是 $\frac{1}{2}$RTT 之前的,为了使得客户端接下来的状态与服务器中的状态更加接近,客户端只需以当前对象所拥有的状态为基准去运行额外 $\frac{1}{2}$RTT 的预测模拟(即服务器在下发该状态后游戏对象所运行的时间)。接着,当客户端给 player 显示结果时,就会更接近服务器当前模拟的真正游戏状态。为了保持这种近似,客户端继续每帧运行模拟,并将结果显示给玩家。最终,客户端收到来自服务器的下一个状态数据包,内部运行额外 $\frac{1}{2}$RTT 的模拟得到新状态,此刻理想情况是该新状态与客户端根据上一次接收状态计算得到的当前状态完全一致(意味着我们所做的预测模拟后的状态在理想情况下要和服务器中的状态保持一致)。

为了执行 $\frac{1}{2}$RTT 推测,客户端必须首先能够粗略估计 RTT。因为服务器和客户端的时钟不一定同步,最简单的方法,即客户端给服务器发送一个包含基于客户端本地时钟的时间戳的数据包。接收到这个数据包时,服务器复制该时间戳到新的数据包,并发送回客户端。当客户端收到这个新数据包时,它根据自己的时钟,从当前的时间中减去旧的时间戳。这样就得到了客户端首次发送数据包到收到响应之间的确切时间—— RTT 的定义。需要注意的是,$\frac{1}{2}$RTT 只是数据年龄的近似值。两个方向上的传输速度不一定一样,所以从服务器到客户端的真实传输时间可能比 $\frac{1}{2}$RTT 大,也可能比 $\frac{1}{2}$RTT 小。不管怎样,对于大部分实时游戏来说,$\frac{1}{2}$RTT 已经是一个足够好的近似。


RTT calculation.

航位推测法 (Dead Reckoning) (8.3.1)

游戏模拟的大多数方面都是确定性的,所以客户端只需通过执行服务器模拟代码的副本即可完成模拟。游戏中的大多数对象(比如子弹、飞机等)我们都能够在遵循相同的物理定律和代码逻辑的前提条件下来完成一致性的模拟预测,但对于远程玩家的行为我们是无法很好的执行预测模拟的,我们无法猜到远程玩家此刻在做什么。在这种情况下,客户端最好的解决方案是先做一个有根据的猜测,然后当来自服务器的更新到达时,如有必要就更正该猜测。

航位推测法(dead reckoning) 是基于实体继续做当前正在做的事情这一假设,进行实体行为预测的过程。当被模拟的对象被玩家控制时,航位推测需要运行与服务器相同的模拟,为了保持模拟过程中玩家输入是不变的这一定论,意味着除了被玩家控制的对象需要复制外,为了计算对象在未来的位置,服务器必须复制用于模拟的更多细节,这包括速度、加速度、跳跃状态等等。

只要远程玩家不断地做当前正在做的事情,航位推测就能保证客户端游戏能够准确预测当前服务器上的真实世界状态。但是,当远程玩家采取了意外行动,客户端模拟就会与真实状态产生分歧,必须被纠正。因为航位推测并没有真正获取到所有的状态数据,而仅仅是对服务器上的对象行为做了一个假设,所以航位模拟被认为是不保守的算法,称为乐观算法 (optimistic algorithm)。它希望做到最好且能猜对大部分情况,但是有时又是错的离谱,必须被纠正。

我们以一个例子来说明预测的过程:假设 RTT 为 100ms,帧率是 60hz。在 50ms 的时候,$\text{client}_A$ 收到 $\text{player}_B$ 在位置 $(0,0)$,正沿着 X 轴的正方向以每毫秒 1 个单位的速度奔跑。因为该状态滞后 $\frac{1}{2}$RTT,所以它模拟 $\text{player}_B$ 继续以该速度奔跑 50ms,然后显示 $\text{player}_B$ 的位置为 $(50,0)$。然后,在等待下一个状态数据包的 4 帧中,$\text{client}_A$ 继续每帧模拟 $\text{player}_B$ 的奔跑。在第 4 帧,即 117ms 时,它已经预测 $\text{player}_B$ 应该位于 $(117,0)$。接着,$\text{client}_A$ 收到来自服务器的数据包,得到 $\text{player}_B$ 的速度是 $(1,0)$,位置是 $(67,0)$。客户端继续向前模拟 $\frac{1}{2}$RTT,并发现该位置与预期(历史)的位置一致;至此一切都很好,$\text{client}_A$ 继续模拟下一个 $4$ 帧,预测 $\text{player}_B$ 的位置为 $(184,0)$。但是在该时刻,它收到来自服务器的新状态,指示 $\text{player}_B$ 的位置是 $(134,0)$,速度变为 $(0,1)$。$\text{player}_B$ 很有可能停止向前跑,并开始扫射。向前模拟 $\frac{1}{2}$RTT 得到位置 $(134,50)$,根本不是客户端之前航位推测所预测的结果。$\text{player}_B$ 发生了意想不到的、不可预知的行为(也就是意外事件)。正因为如此,$\text{client}_A$ 的本地预测与服务器中真实的世界状态发生了分歧。


Dead reckoning misprediction.

当客户端检测到它的本地模拟发生错误时,客户端必须执行一些纠正弥补的操作:

  • 即时状态更新(instant state update):只需立即更新到最新状态。玩家可能发现对象跳来跳去,但这样也许也好过错误的数据。记住即使是即时更新,来自服务器的状态仍然滞$\frac{1}{2}$RTT,所以客户端应该使用航位推测和最近的状态来继续模拟额外的 $\frac{1}{2}$RTT。

  • 插值(interpolation):从客户端插值的方法可以看到,你的游戏可以在一定数量的帧内平滑地插值到新状态。这意味着对于每个错误状态(位置、旋转等)都要计算和存储一个偏移量,用于每一帧。或者只将对象移动一部分路程,使其更接近正确位置,等待将来的服务器状态继续进行纠正。一种流行的方法是使用 三次样条插值 创建路径,以实现位置和速度同时平滑地从预测状态过渡到正确状态。

  • 二阶状态调整(second-order state adjustment):如果一个几乎静止的对象突然加速,即使插值也可能发生抖动。为了更精细地处理,你的游戏可以调整二阶参数,例如加速度,非常平缓地对模拟进行同步修正。这在数学上有些复杂,但是可以使得纠正最不明显。

通常情况下,游戏基于差异的幅度和游戏特性,将使用这些方法的组合。快节奏的射击游戏通常为小错误使用插值,为大错误使用瞬间移动。慢节奏的游戏,如飞机模拟或巨型机器人争霸,可能使用二阶状态调整处理除了最大错误之外的所有错误。

航位推测对于远程玩家非常有效,因为本地玩家实际上并不知道远程玩家在做什么。当 $\text{player}_A$ 看到 $\text{player}_B$ 的虚拟人跑过屏幕,每次 $\text{player}_B$ 改变方向,模拟都会发生分歧,但是 $\text{player}_A$ 很难觉察到这一点。除非他们都处于同一个物理空间当中。$\text{player}_A$ 实际上并不知道 $\text{player}_B$ 什么时候改变的输入。在大多数情况下,他看到的模拟是一致的,即使客户端应用程序总是在服务器告知状态的基础上向前预测至少 $\frac{1}{2}$RTT。

客户端移动预测和重放 (Client Move Prediction and Replay) (8.3.2)

航位推测无法在本地客户端中达到很好的效果,它不能为本地玩家隐藏延迟。一方面,因为该算法的执行往往需要依赖服务器状态的传入才能开始,那么当本地玩家发起一个操作并等到服务器将状态传回,本地客户端都需要经历一个 $RTT$ 的延迟等待时间才能开始向前预测模拟。另一方面,我们向前预测的依据是假设从状态传入起,玩家的输入不会产生变化,然而本地玩家是知道它们在哪当前是在执行什么操作的,所以一旦预测的结果与实际的输入产生了偏移,就会产生较为频繁的修正操作。理想情况下,某些操作(例如行走)对于本地玩家的感觉应该是她在玩单机游戏,而不是网络游戏。

一个更好的解决方案是,$\text{player}_A$ 将他发起的某些输入(比如移动)直接投递给 $\text{client}_A$,也就是说,$\text{client}_A$ 可以本地执行这些输入的模拟而非等待服务器状态的下发,即,乐观的认为(预测)动作模拟执行时并没有产生改变结果的意外事件。当 $\text{client}_A$ 收到来自服务器的 $\text{player}_A$ 的状态后,为保证状态一致,$\text{client}_A$ 需要执行一些对前置预测结果的一些校验。$\text{client}_A$ 可以使用 $\text{player}_A$ 的历史输入重新模拟(重放)从服务器计算该传入状态起 $\text{player}_A$ 发起的所有状态改变。记住,客户端不是使用航位推测模拟 $\frac{1}{2}$RTT,而是使用 $\text{player}_A$ 的精确输入来模拟 $\frac{1}{2}$RTT。最后将结果与本地状态进行对比,产生差异也就意味着在这段时间内客户端可能受到了一些远程玩家发起的意外事件,为此,我们需要在尽量无法让玩家察觉的范畴内去执行一些结果的修正以保持状态一致性。

举一个例子,假设 client 中 player 操作的虚拟人目前正处在一片空旷地带,并且在它的面前有一个可用于躲避的障碍物。player 按下来前进和奔跑键,client 收到这些输入指令后直接投递给服务器,并且开始模拟执行操作以让虚拟人开始向前奔跑并躲入前方的障碍物内。在结果完整呈现给 player 后,client 收到来自服务器下发的状态数据包,client 拿到历史输入针对该状态数据包继续向前模拟,发现所得到的结果和当前状态不一致,比如产生了一些扣血,并且位置和当前位置存在一些偏差,那么就意味着 client 在执行预测模拟的期间产生了一些会改变结果的意外事件,可能是客户端在冲入掩体的时候被远程玩家的枪械击中导致了减速和扣血。此时客户端应当在尽量不让玩家察觉的前提条件下去修正我们的最终结果。

通过技巧和优化隐藏延迟 (8.3.3)

几乎所有的视频游戏动作都有通知(tell),或者视觉线索来指示事情将要发生。在血浆喷射之前枪口闪烁,在喷射火焰之前法师挥动双手并嘴里嘟囔。这些通知持续至少服务器与客户端之间一个通信来回的时间。乐观地讲,这意味着客户端可以通过在本地执行适当的模拟和特效给玩家的任何输入提供即时反馈,同时等待服务器更新真实模拟。这并不意味着客户端产生抛射物,但是它可以开始播放施法动画和声音。如果一切顺利,在施法过程中,服务器接收输入数据包,产生火球,并将它复制给客户端,正赶上显示施法的结果。航位推测代码向前模拟 $\frac{1}{2}$RTT 的抛射物动作,玩家看起来好像她在扔火球,没有延迟。如果发生问题,例如,服务器知道该玩家最近被沉默(不能施魔法),但尚未将这个信息通知玩家,那么该优化就失去了意义,施法动画开始了但是没有出现抛射物。这是一种非常罕见的情况,但和这种方法所能提供的好处相比,是可以容忍的。

服务器回退 (Server Side Rewind) (8.4)

仍然有一种常见的游戏动作是客户端预测不能很好处理的:长距离的即时射击。当玩家配备狙击步枪,准确瞄准另一位玩家,扣动扳机,她希望有一次完美的命中。但是,由于航位推测的不准确性,客户端上完美的瞄准射击可能在服务器上就不太准了。这对于依赖实时、即时射击武器的游戏来说,是一个问题。

这个问题有一个解决方案,被维尔福软件公司(Valve)的起源引擎(Source-Engine)推广流行,这个方案也被诸如《反恐精英》之类的游戏采用,来给玩家带来准确无误的射击体验。它的核心是,当瞄准和开火时,让服务器状态回退到玩家感受到的那个状态。这样,如果玩家感觉她瞄得很准,那么她就能百分百击中。

  • 远程玩家使用客户端插值,而不是航位推测:服务器需要准确地知道客户端玩家每个时刻看到了什么。因为航位推测依赖客户端基于假设的向前模拟,将给服务器带来额外的复杂度,因此不应该开启该功能。为了避免数据包之间的抖动或卡顿,客户端转而使用本章前面介绍的客户端插值方法。插值周期应该精确等于数据包周期,这一周期被服务器牢牢控制。客户端插值引入了额外的延迟,但是鉴于移动重放和服务器端回退算法,它不会被玩家明显感觉到。

  • 使用本地客户端移动预测和移动重放:尽管客户端预测对远程玩家是关闭的,但是对本地玩家仍然是保留的。没有本地移动预测和移动重放,本地玩家会立即注意到来自网络和客户端插值所增加的延迟。但是,通过即时模拟玩家移动,不管存在多少延迟,玩家都不会感觉到。

  • 发送给服务器的每个移动数据包中保存客户端视角:客户端应该在每个发送的数据包中记录客户端当前插值的两个帧的 ID,以及插值进度百分比。这给服务器提供了客户端当时所感知世界的精确指示。

  • 在服务器端,存储每个相关对象最近几帧的位置:当传入的客户端输入数据包中包含射击时,查找在射击时刻用于插值的两帧。使用数据包中的插值进度百分比将所有相关对象回退到客户端扣动扳机的那一刻。然后从客户端的位置采用光线投射法来确定是否击中。

服务器端回退保证如果客户端准确地瞄准了,那么在服务器端一定会被击中。这给射击玩家带来了满意的体验。但是,仍然存在一些不足。因为服务器回退的时间是根据服务器和客户端之间的延迟决定的,对于被击中的玩家会造成一些意想不到和令人沮丧的体验。对于游戏开发来说,这是一个权衡问题。需要根据你的游戏特性决定是否使用这些技术。