网络延时处理

简述

  • 在多人在线游戏开发中,我们总希望服务器与客户端之间,不同客户端之间看到的游戏世界效果是一致的。然而,玩家的设备不尽相同,他们所在的网络也参差不齐,最终导致的网络延时严重影响着游戏的体验。虽然,提升带宽能一定程度上解决问题,但治标不治本。所以,如何通过优化客户端 / 服务器的架构来缓解因网络延时而带来的各种问题就是接下来要讨论的重点。本文主要是结合了 GDC2020 大会上 Infinity Ward 工作室发表的关于在 使命召唤-现代战争 中使用的网络延时处理方法的演讲,和其他一些游戏的优化思路,总结了一些常用的规避网络延时的做法。

网络同步

  • 目前主流的架构采用的是 客户端/服务器(C/S) 模式来进行同步的,其中一种比较简单的实现方案,就是客户端只负责采集输入和渲染画面,服务器则负责所有的计算。每一帧客户端将搜集到的操作信息发送到服务器,服务器收到后执行计算,并将计算后的信息,比如玩家的位置,方向等同步给客户端,客户端收到后将这些信息渲染出来,流程如下:
sequenceDiagram participant Client participant Server Note left of Client:pos = (0, 0) Note right of Server:pos = (0, 0) Client ->> Server:发送指令 [向右移动一格] Note right of Server:pos = (1, 0) Server ->> Client:同步状态(1, 0) Note left of Client:pos = (1, 0)
  • 但这里有一个问题,当网络延时比较大的时候,就会出现客户端迟滞。因为客户端的表现依赖于服务器的数据驱动,如果客户端收不到数据包,那么客户端的画面就会卡在当前的状态,表现出来的效果就像下面这个样子,当移动鼠标时,镜头并没有马上相应,而是等了一段时间,收到数据包后,才开始移动,这种表现很影响手感和观感。

客户端预测

  • 为了解决上面的问题,我们可以让客户端在按下按键时马上得到操作回馈。因为在大部分时间里,客户端的行为是可预测的,不会出现太多的跳变,除非开挂,或者被其他的玩家的行为影响。这也就意味着服务器收到的大多数输入都是有效的,并且服务器计算出来的结果应当跟客户端预测的结果是一致。比如像下面的这个图,当玩家按下按键时,会向服务器发送指令向右移动一格,然后在 100ms 后收到了服务器的状态更新,然后播放行走的动画,经过 100ms 到达目标点,可以看到,这整个过程耗费了 200ms。
sequenceDiagram participant Client participant Server Note left of Client:pos = (0, 0) note over Client: 100ms Note right of Server:pos = (0, 0) Client ->> Server:发送指令 [向右移动一格] Note right of Server:pos = (1, 0) Server->>Client:同步状态(1, 0) Note left of Client:pos = (0, 0) note over Client: 100ms Client ->> Client: 玩家移动 Note left of Client:pos = (1, 0)
  • 可以看到,前 100ms 的等待其实是没必要的。如果客户端预测的基本跟服务器计算的一致的话,可以在玩家输入时,就立马响应玩家的操作,比如像下图。理想情况下,当客户端完成移动时,同时也收到服务器的状态更新,而且预测的状态也跟服务器下发的一致。
sequenceDiagram participant Client participant Server Note left of Client:pos = (0, 0) note over Client: 100ms Note right of Server:pos = (0, 0) Client ->> Server:发送指令 [向右移动一格] Client ->> Client: 右移一格 Note right of Server:pos = (1, 0) Server->>Client:同步状态(1, 0) Note left of Client:pos = (1, 0)
  • 下面这个例子就是使用了客户端预测和服务器数据返回校正,在延时较小的时候,画面是比较跟手的。

* 然而,在真实的游戏环境里,网络延时很少像上面演示的一样,除非是在局域网里,更多的情况是客户端完成移动后等待一段时间才收到服务器的更新。比如像下面的这种情况,假设网络延时为 300ms,玩家连续像右移动两次。

sequenceDiagram participant Client participant Server Note left of Client:pos = (0, 0), t = 0ms note over Client: 100ms Note right of Server:pos = (0, 0) Client ->> Server:发送指令 [向右移动一格] Client ->> Client: 右移一格 Note left of Client:pos = (1, 0), t = 100ms note over Client: 100ms Client ->> Server:发送指令 [向右移动一格] Client ->> Client: 右移一格 Note left of Client:pos = (2, 0), t = 200ms Note right of Server:pos = (1, 0) Server ->> Client: 同步状态(1, 0) Note left of Client:pos = (1, 0), t = 300ms Client ->> Client: 回退一格 Note right of Server:pos = (2, 0) Server ->> Client: 同步状态(2, 0) Note left of Client:pos = (2, 0), t = 400ms Client ->> Client: 右移一格
  • 可以看到,当 t = 300ms 时,客户端收到了服务器下发的状态更新,但是,服务器通知的位置是 (1, 0),而客户端此时的位置是 (2, 0),所以客户端会将位置拉回到 (1, 0),然后当 t = 400ms 时,再次收到了服务器的数据,位置为 (2, 0),又将位置拉回到 (2, 0),在玩家的视角里看到的就是,向右移动了两格,然后回退一格,接着又向右移动一格,这样的体验是很不好的。

  • 下面这个例子就是在延时比较大的时候,因为回退问题,造成画面抖动。

  • 实际上,服务器返回的状态总是过去发生的状态,因为在服务器返回状态时,它并没有处理完所有来自客户端发过来的指令,所以,为了解决这个问题,可以采用下面的这种做法。给每一个指令编号,比如像下图总共有两条指令,分别是 [id: 1] 和 [id: 2]。客户端每发送一条指令,就将指令塞入一个缓存队列里,同时将当前的状态也存入缓存队列里,用来做回滚用的。然后服务器处理完指令并返回数据时需要带上对应指令的编号。当 t = 300ms 时,因为收到服务器的 (1, 0) 数据,所以这个时候需要重新计算玩家的位置。计算的方式就是拿服务器下发的 (1, 0) 数据 加上 [id: 2] 指令所产生的位移,计算出的结果是 (2, 0),跟之前客户端预测的一致,所以不会出现玩家位置来回设置的问题,从而解决了上述抖动的问题。同时删除 [id: 1] 的指令,因为这条指令已经得到服务器的返回。当 t = 400ms 时,收到了服务器返回的数据 (2, 0),因为现在已经没有需要预测的指令了,所以直接用下发的这个位置就好,而且这个位置也跟玩家上次的位置是一致的。
sequenceDiagram participant Client participant Server Note left of Client:pos = (0, 0), t = 0ms note over Client: 100ms Note right of Server:pos = (0, 0) Client ->> Server:[id: 1] 发送指令 [向右移动一格] Client ->> Client: 右移一格 Client ->> Client: 保存 [id: 1] 指令 Note left of Client:pos = (1, 0), t = 100ms note over Client: 100ms Client ->> Server:[id: 2] 发送指令 [向右移动一格] Client ->> Client: 右移一格 Client ->> Client: 保存 [id: 2] 指令 Note left of Client:pos = (2, 0), t = 200ms Note right of Server:pos = (1, 0) Server ->> Client: 同步状态(1, 0) [id: 1] Note left of Client:pos = (2, 0), t = 300ms Client ->> Client: 删除 [id: 1] 指令 Client ->> Client: 执行 [id: 2] 预测(实际就是拿 (1, 0) + [id: 2]指令位移) Note left of Client:pos = (2, 0), t = 400ms Note right of Server:pos = (2, 0) Server ->> Client: 同步状态(2, 0) [id: 2] Note left of Client:pos = (2, 0), t = 400ms Client ->> Client: 删除 [id: 2] 指令
  • 实现的大致代码如下
c# 复制代码
public override void OnStateChange(PlayerState newState)
{
	if (newState.Timestamp <= lastServerState.Timestamp) 
	{
		return;
	}

	while (pendingInputs.Count > 0 && pendingInputs.First().InputNum <= newState.MoveNum)
	{
		pendingInputs.RemoveFirst();
	}

	predictedState = newState;
	lastServerState = newState;
	UpdatePredictedState();
}

private void UpdatePredictedState()
{
	foreach (PlayerInputs input in pendingInputs)
	{
		ApplyInput(input);
	}

	character.SyncState(predictedState);
}

private void ApplyInput(PlayerInputs input)
{
	predictedState = character.Move(predictedState, input, 0);
}
  • 下面这个例子就是使用了上述回滚方法后的效果,可以看到,因为不会出现拉回,所以画面基本上是没有抖动的。
  • 有一个需要注意的地方就是,当客户端在执行回滚时,需要保证当前的玩家状态跟之前执行预测时的状态一致。如果不一致就会导致模拟的结果跟之前预测的不一致,计算出来的位置点也跟之前预测的有偏差,从而导致画面抖动。比如说跳起这个操作,一般只有当跳起按键按下,同时玩家在地面时,才会给玩家一个向上的速度让玩家跳起,然后通过计算重力,使得玩家跳起后掉下。如果玩家没有在输入的指令中缓存当前是否在地面这个信息,而是使用玩家当前的状态来判断的话,就有可能会出问题了。因为在回滚时,当前玩家已经腾空,所以无法给玩家一个向上的速度,最终导致回滚计算出来的位置点和现在的位置点出现偏差,进而产生抖动,具体的实例看下面这两个例子。

实体插值

  • 上面的预测方法主要应用在玩家自己控制的角色,至于其他玩家控制的角色,则需要依赖服务器下发的数据才能显示。这个时候,最简单的方法就是当服务器每下发一个数据过来,本地客户端就渲染一次,大致的效果像这样:

  • 可以看到,当下发的帧率较高时,表现的效果可能还可以接受,不至于太卡。但是一旦下发的帧率比较低时,比如像上图中的16帧,即使客户端的渲染帧率能轻松破百,但最终表现出来的效果还是16帧的效果,看起来就像是PPT一样。同时,因为网络延时波动,收到的数据也是有快有慢,这就更加加剧这种卡顿的效果。为此,可以使用插值的方法来减缓这种影响。具体的做法,就是在收到数据的时候将数据存入缓存队列里,然后每次从队列取第一个点跟第二个点,然后每帧在这两个点插值,直到当前位置点已经到达第二个点,就删掉第一个点,然后重复这个操作。

  • 下面的例子就是使用了插值后的效果,可以看到,其他玩家控制的角色移动的轨迹是比较平滑的

  • 然后有一个需要注意的点就是,在插值时,需要注意缓存队列的大小,如果缓存队列越积越多,但是不改变插值速率的话,就会导致延时。所以可以根据队列的大小,适当地调节插值的速率,减少延时影响。
  • 然后另外一个需要注意的点就是,不是所有时候都需要插值的。比如像玩家死亡后复活,正常应该是在死亡点消失,然后直接在出生点出现。如果此时还应用插值的话,就会出现玩家从死亡点平滑移动到出生点。所以需要根据实际的游戏需求,调整是否启用插值。

延时补偿

  • 由于网络延时和实体插值的关系,玩家看到的其他玩家的位置点总是滞后于服务器的。当玩家瞄准其他玩家并射击时,这个指令需要经过一段时间(一般是 1/2 RTT)才到达服务器的。在这段时间里,被射击的玩家有可能是在移动的,如果服务器在收到指令时,取被射击玩家当前的服务器位置点来做射线检测的话,那么就有可能出现打不中的情况了,这对于射击者来说就很诡异了,明明看着打中了,但是就是不造成伤害。

  • 所以为了解决这个问题,可以使用延时补偿。具体的做法是,服务器给每一个逻辑帧编号,并缓存所有玩家过去一段时间每一帧的位置状态,用帧ID做索引。当客户端生成射击指令时,传入客户端当前收到的帧编号。当服务器收到这个射击指令时,读出帧编号,然后将所有玩家的位置先调回到这一帧的位置,然后执行射线检测,看有没有打中,射击逻辑执行完后,再还原所有玩家的位置,具体的效果像下面这个样子。

  • 可以看到,这样做的话,被射击的玩家会延时死亡。如果此时这个玩家刚好走到了掩体后,就会出现明明躲在墙后,但还是被打死的情况。所以延时补偿力度的大小,甚至是否使用延时补偿就需要根据游戏的需求来制定。如果为了比较更加精准地判断命中,可以直接使用客户端做射线检测,然后通知服务器命中结果,但这样比较容易出现作弊的情况。

其他方法

  • 使用动画隐藏延时
  • 使用buff来应对网络波动
  • 收不到数据时可以使用外推或者预测的方法来计算
  • 通过控制玩家发送数据的频率来抗衡网络丢包

工具

参考

相关推荐
技术小甜甜2 天前
【Blender Texture】【游戏开发】高质感 Blender 4K 材质资源推荐合集 —— 提升场景真实感与美术表现力
blender·游戏开发·材质·texture
Thomas游戏开发2 天前
Unity3D TextMeshPro终极使用指南
前端·unity3d·游戏开发
Thomas游戏开发3 天前
Unity3D 逻辑代码性能优化策略
前端框架·unity3d·游戏开发
Thomas游戏开发4 天前
Unity3D HUD高性能优化方案
前端框架·unity3d·游戏开发
陈哥聊测试5 天前
游戏公司如何同时管好上百个游戏项目?
游戏·程序员·游戏开发
一名用户6 天前
unity随机生成未知符号教程
c#·unity3d·游戏开发
Be_Somebody11 天前
计算机图形学——Games101深度解析_第二章
游戏开发·计算机图形学·games101
GameTomato11 天前
【IOS】【OC】【应用内打印功能的实现】如何在APP内实现打印功能,连接本地打印机,把想要打印的界面打印成图片
macos·ios·objective-c·xcode·游戏开发·cocos2d
Be_Somebody12 天前
计算机图形学——Games101深度解析_第一章
游戏开发·计算机图形学·games101
飞起的猪23 天前
【虚幻引擎】UE5独立游戏开发全流程(商业级架构)
ue5·游戏引擎·游戏开发·虚幻·独立开发·游戏设计·引擎架构