《Space Engineers》基于物理的预测同步方案

1. 背景介绍

《Space Engineers》[1]是一款发行于2013年的沙盒类多人游戏。玩家可以建造功能各异且大小不一的太空船、太空站、行星哨所,也可以驾驶飞船在太空中畅游,在星球上探索并收集生存所需的资源。

用于开发《Space Engineers》的游戏引擎,是Keen Software自研的游戏引擎VRAGE。VARGE是一个基于体素(Voxel)的游戏引擎,并使用了Havok作为其物理引擎。

  1. 体素的一些基本概念[2]

    ​ 体素(Voxel)是体积元素(Volume Pixel)的简称,可以将其理解为三维空间下的像素。如果使用一个三维数组来表示栅格化的三维空间,那么三维数组里面的每个元素就是一个体素,每个体素只需要存储一个比特用来表示其所占的空间是空心还是实心的。与网格数据相比,体素数据更加简单和独立、更容易修改。

    ​ 当然,体素还能储存物体的材质、颜色,甚至是游戏属性等额外的信息。当把体素缩小之后,也可以获得高级别的真实感。如果赋予体素物理属性,那么每个体素都能在物理约束下独立地进行物理模拟,从而获得更加真实、更加丰富的物理效果。

  2. 不确定性物理引擎(Non-deterministic Physics Engine)[3]

    ​ 作为三大物理引擎之一的Havok物理引擎也是一个不确定性物理引擎

    ​ 不确定性物理引擎使用浮点数进行计算,不同机器上的CPU对浮点数实现的标准可能不一样,因此不同平台下计算的结果会有细微的差异。即便是同一机器,每次模拟的结果也都会因为浮点数精度的问题得到不一样的模拟结果。

    ​ 除此之外,不同平台上伪随机数、物理模拟帧率等其他因素所带来的差异,也是造成不确定性物理引擎在不同平台上的模拟结果不完全一致的原因。

该游戏的主要特点如下:

  1. 允许玩家可以任意改变地形;
  2. 玩家可以通过联机进行多人协作或者多人对抗,允许超过百人同时在线;
  3. 地图很大,有超过一万个实体需要管理;
  4. 所有物体的运动都是由物理模拟驱动的,也包括玩家控制的角色;

2. 基本同步思路

《Space Engineers》使用的网络同步模型是C/S模型。

玩家在进行交互操作时,客户端将采集到的输入数据发送给服务器进行模拟,然后服务器将模拟结果(如玩家的位置等数据)下发给客户端,最后由客户端负责更新数据并将游戏画面渲染到显示器上。

这无疑是一个简单直接的同步策略,但随之而来的,是巨大的、不可接受的延迟(Lag)

延迟指的是,从玩家操作输入设备,到屏幕上渲染出对应游戏画面所需要的时间。

如果下图所示,假设游戏帧率稳定为60HZ(16.6ms每帧)网络延迟(Ping)稳定为50ms,不考虑丢包等问题的影响,玩家的延迟为231ms

① 操作系统检测到玩家操作输入设备需要2ms;

② 客户端在游戏线程的Tick中搜集输入数据,需要16.6ms;

③ 服务器在25ms后收到从客户端发送过来的输入数据A

④ 服务器将输入数据A缓存到一个4帧大小的队列中,需要等待66.4ms;

⑤ 服务器使用数据输入A进行模拟,并在下一帧时将模拟结果下发给客户端,需要等待16.6ms;

⑥ 客户端在25ms后收到服务器下发的模拟结果;

⑦ 客户修改本地的数据,等待下一帧进行渲染,需要16.6ms;

⑧ 渲染器(Render)搜集数据,并提交到GPU进行渲染,需要16.6ms;

⑨ GPU渲染的画面显示到显示器上,需要的时间与显示器硬件有关,这里取平均值30ms;

如果网络情况较差,网络延迟提高到300ms,那么延迟则会飙升至481ms。在丢包、掉帧等问题的影响下,延迟会变得更高。

如此高的延迟会给玩家带来非常差的游戏体验。一个行之有效的、可以大幅度降低延迟的策略是,客户端在获得玩家的输入数据时,立刻在本地进行模拟并渲染对应的游戏画面,也就是所谓的预表现。这样延迟就只需要97ms,且不受网络波动影响。

但天下没有免费的午餐。前面提到,《Space Engineers》使用的Havok物理引擎是一个不确定物理引擎,因此客户端和服务器的模拟结果并不能保证完全一致。再加上网络的不稳定性,服务器收到的输入数据和客户端用来预表现的输入数据也不能保证完全一致。

因此,客户端预表现的结果服务器模拟的结果极大概率是不一致的。这也意味着,客户端需要在收到服务器下发的模拟结果时,及时对预表现的结果进行纠正(Correction),从而确保不出现不同步的问题。


3. 网络协议的选择

网络同步其实就是使用网络协议传输业务数据。

尽管TCP(Transmission Control Protocol)提供了可靠传输流量控制拥塞控制等特性,使开发者无需担心数据丢失和重传等细节问题。但在要求及时响应的网络游戏中,TCP为了提供这些特性而带来的延迟会极大地影响玩家的体验,毕竟TCP协议设计之初就不是为了及时响应的。

正因如此,网络游戏通常会使用延迟更低的UDP(User Datagram Protocol)作为其网络同步协议,《Space Engineers》也不例外。

但因为UDP只会尽最大能力交付,并不保证数据的可靠传输,会出现丢包、乱序等问题。所以在采用UDP来作为开发网络游戏的网络协议时,需要开发者自己实现可靠UDP。可靠UDP一般分为两种:

  1. 基于可靠传输的UDP(Reliable UDP):指在UDP上加一层封装,在传输层实现重传等类似TCP的特性,保证上层逻辑在处理数据包的时候,不需要考虑数据丢失和重传等细节,如Enet,KCP等;
  2. 业务按需实现的可靠UDP:指直接使用原始的UDP,然后在业务层针对特定的数据,实现一个带超时的重传确认机制,让业务层负责超时重发、排序等工作;

通常来讲,业务按需实现的可靠UDP要优于直接使用可靠UDP协议,因为不是所有的数据都需要可靠交付。让所有的数据都进行可靠交互,只会造成不必要的浪费。

如果某个状态是有实效性的,那么过期的状态信息就是可丢失的,每个新的状态信息可以直接取代旧的信息。例如玩家在场景中的位置数据,只有最新的位置数据是有意义的。

相反,有一些数据则需要避免丢失或者乱序的问题,如玩家的输入数据。如果玩家的输入数据在发送至服务器的过程中出现丢失或者乱序,那么会让服务器和客户端的模拟结果出现显著的差异,从而导致不同步的问题。


4. 输入数据的同步

4.1 Playout Delay Buffer

为了让UDP在传输输入数据时更加可靠,其采取的策略是:

  1. 客户端为每个输入数据打上对应的帧号;
  2. 在服务器添加一个4帧大小的播放延迟缓存区(Playout Delay Buffer)
  3. 服务器在收到输入数据时,先根据输入数据的帧号筛选出过期或者重复的输入数据并直接丢弃;
  4. 校验通过的输入数据会被放进缓存区里按照帧号进行排序;
  5. 最后服务器依次从缓存区里取出输入数据进行模拟;

下图演示播放延迟缓存区的具体工作过程:

  1. 服务器每帧都会取出缓存区的第一个元素作为当前帧的输入数据,如果当前取出的元素为空,则表示当前帧没有任何输入数据;

    1. 第5帧:收到输入数据2,将其存入缓存区的末尾;
  2. 第6帧:取出位于缓存区首位的空元素作为输入数据进行模拟,随后收到输入数据5,将其存入缓存区并进行排序;

  3. 第7帧:取出位于缓存区首位的空元素作为输入数据进行模拟,随后收到乱序的输入数据3,将其存入缓存区并进行排序;

  4. 第8帧:取出位于缓存区首位的空元素作为输入数据进行模拟,此时没有收到任何输入数据,将一个空元素插入缓存区的末尾;

  5. 第9帧:取出位于缓存区首位的输入数据2作为输入数据进行模拟,随后收到过时的输入数据1(此时输入数据2已经生效了,再使用输入数据1会出现乱序的问题),直接将其丢弃,此时视为没有收到任何输入数据,继续将一个空元素插入缓存区的末尾;

  6. 第10帧:取出位于缓存区首位的输入数据3作为输入数据进行模拟,此时没有收到任何输入数据,将一个空元素插入缓存区的末尾;

  7. 第11帧:本应取出输入数据5作为输入数据进行模拟,当检测到和上一帧使用的输入数据3并不连续,因此需要继续使用输入数据3补齐中间丢失的输入数据;

  8. 第12帧:使用输入数据5作为输入进行模拟,此时没有收到任何输入数据,则将一个空元素插入缓存区的末尾;

  9. 值得注意的是因为服务器从第10帧开始一直没有收到输入数据,所以在第12帧之后所有缓存的输入数据都被消耗完了。但此时服务器仍会在随后的几帧重复使用最后一个收到的输入数据5作为输入数据进行模拟。

    1. 视频中并没有提到这么做的目的,个人猜测这里应该是为了对抗网络波动,等待下一个潜在的输入数据;
  10. 不妨假设在第13帧时收到输入数据6
    1. 如果在第12帧使用了输入数据5之后,不将输入数据5留在缓存区里继续重复使用,那么在收到输入数据6时,输入数据6会被直接放到最末尾再等待4帧,这显然会极大地增加延迟;

    1. 如果将输入数据5留在缓存区里继续重复使用,那么在收到输入数据6时,会直接对其进行排序,放到输入数据5之后,那么第15帧就可以使用输入数据6了,延迟会显著降低;
  11. 当然,重复使用的帧数不能太多,否则玩家会发现停止操作之后,游戏里的角色仍在自己移动,这显然是有问题的。

从上面的流程不难理解,《Space Engineers》在处理输入数据时,选择以增加一定的延迟作为代价,将输入数据先放到缓存区里,在缓存区中对其进行筛选剔除和排序,从而尽可能降低了输入数据乱序对模拟结果的影响。这也是2. 框架总览一节中,输入数据在到达服务器之后还要等待66.4ms的原因。

当然,缓存区的长度是可调整的(Optional),但并不是越长越好,因为这会增加服务器模拟结果的延迟时间。而如果太短,则无法起到其应有的作用,其最终采用的4帧长度也是一个调整出来的经验值。

4.2 一个让输入数据更可靠的思路

虽然使用Playout Delay Buffer可以解决输入数据乱序的问题,但并不能解决丢包的问题。因为服务器在发现丢包(帧号不连续)时并没有请求客户端重发,客户端也不知道服务器在接收时丢失了哪些输入数据。

一个可行的优化策略是,通过冗余重传的方式[4]实现一个简单可靠的UDP。具体的修改如下:

  1. 客户端将本地的输入数据依次存放进发送缓存区里;
  2. 客户端每次将发送缓存区里的输入全量发送给服务器;
  3. 服务器接收到客户端发送过来的数据之后,将过期的输入数据直接丢弃,然后把尚未接收国的输入数据放进接收缓存区里;
  4. 服务器通知服务器当前已接收的最新输入数据;
  5. 客户端发送缓存区中服务器已确认的输入数据清理掉,不再发送;

不难看出,这个方案的缺点很明显,客户端每次都需要额外发送冗余的数据数据。当客户端网络较差,长时间收不到服务器的确认信息时,发送缓存区会迅速膨胀,不断加大后面发送输入数据的性能压力。因此,限制发送缓存区的最大上限、甚至在一定的时间间隔之后强制清空发送缓存区是很有必要的。

但不可否认的是,这种方式可以很好地解决丢包、乱序的问题,让UDP在传输数据时更可靠,且实际的延迟比Space Engineers的方案更低。


5. 位置数据的同步

虽然位置数据是具备时效性的,不需要额外处理乱序和丢包的问题,但还是需要给位置数据加一些额外的标记,从而让客户端区分该位置数据是否已经过时了。通常的做法,是给位置数据加上时间戳Space Engineers也不例外。

在客户端收到服务器下发的位置数据之后,根据位置数据的用途,可以区分出两种同步模式:AnimatedPredicted

5.1 Animated

Animated同步模式很简单,就是完全使用服务器下发的位置数据来修改物体在客户端上的位置。这也是模拟端(如多人游戏中其他玩家控制的角色)最常用的位置同步方式。

考虑到UDP在传输位置数据时是不可靠的,为了让物体移动更加平滑,尽可能在网络较差时出现频繁抖动的问题。客户端会将收到的位置数据存进一个缓存队列(Position History)里,然后使用内插值和外插值的方式更新物体的位置。

内插值和外插值的具体实现并不是这里的重点,在网上有很多讲解的文章[5]

当然,在将位置数据放入缓存队列之前,客户端会检查位置数据的时间戳,来判断该位置数据是否过期。原视频并没有很详细地介绍缓存队列的具体工作流程,只是简单地介绍了客户端具体是如何通过时间戳来判断位置数据是否过期的。

但从上面的描述,个人猜测具体工作流程应该是这样的。客户端在收到位置数据时,先算出该位置数据的有效时间:

位置数据的有效时间:服务器时间戳 + ping / 2 + T(T推测应该就是60ms)

如果在当前用来内插值的位置数据的有效时间内,收到了更新的位置数据,那么就会将新收到的位置数据存起来,并按照服务器的时间戳排序。否则就会认为新收到的位置数据已经过期了,直接丢弃,如下图所示:

从上图中不难看到,乱序的位置数据B位置数据C被客户端用于内插值之前到达客户端,所以位置数据B会被放到缓存区里进行排序,等待后面用于内插值。如果位置数据B在位置数据C被用于内插值之后到达客户端,那么会直接丢弃。相当于位置数据B在传输的过程中发生了丢包的问题。

这并不影响最后的结果,只是会丢失一部分运动轨迹,从原本的A→B→C直接变成A→C

最后,如果客户端一直没收到新的位置数据,那么会从最后收到的位置数据C继续外插值一段时间,这是为了避免在网络出现波动时,后面的位置数据因为传输时延迟较大而出现物体时走时停的问题。

当然,外插值毕竟只是猜测服务器后续的运动轨迹,外插值的时间越长,其与服务器实际运动轨迹的误差就越大。通常来讲,外插值的时间最长不能超过ping / 2 ,当超过ping / 2都没收到新的位置数据时,再进行外插值就失去了意义。此时应该认为服务器上的物体已停止运动,客户端上的物体也应该立即停止运动。

5.2 Relative Position Updates

Animated同步模式的主要目的在于平滑地更新物体在客户端上的位置,所付出的代价是增加物体位置更新的延迟,并且在网络不好时会丢失物体部分的运动轨迹。

如果是单个物体,玩家可能察觉不到。但如果将多个物体链接在一起,并且施加一定的物理约束(如物体只能沿着链接点的某个轴转动),那么物体在各自更新位置的过程中,可能会出现物体之间穿模或者断开等违背物理约束的问题。

《Space Engineers》采取的解决办法是,为这些链接在一起的物体建立一个树状的层级结构(Hierarchy)。其中,Root节点还是接着使用原本的Animated同步逻辑,而Children节点则略有不同。

首先,服务器不再同步Child节点的世界位置数据,而是同步Child在Parent节点下的相对位置数据。然后,Child节点在Parent节点的局部坐标系下执行Animated同步逻辑,算出当前的相对位置数据,最后再其转换成世界位置数据,并修改物体的位置。这样,虽然Child节点的世界位置数据不一定准确,但至少彼此之间的相对位置是正确的。

最后,因为链接在一起的物体是平级的,如何在它们之间选取一个物体作为Root节点并建立层级结构是一个需要解决的问题。

考虑到Child节点的世界位置数据会受Parent节点的世界位置数据影响(因为需要利用Parent节点的世界位置数据计算),更新的延迟会增加。因此《Space Engineers》选择的策略是:将体积更大或者玩家当前正在操作、交互的物体选择为Root节点,因为这些物体对位置数据的准确性要求更高。

5.3 Predicted

Predicted同步模式只用于玩家当前所控制的物体。

2. 基本同步思路中提到,为了降低玩家的延迟,客户端会先使用玩家的输入数据直接进行模拟(也就是预表现),随后在收到服务器同步来下的模拟结果时,对客户端本地的预表现结果进行纠正。

为此,《Space Engineers》采用了和《Rocket League》[6]类似的同步策略:

  1. 客户端将带上帧号的输入数据发送给服务器,随后立即使用输入数据进行模拟,并记录当前的模拟结果和帧号存进History List里;
  2. 当服务器在使用该输入数据进行模拟并得到模拟结果之后,服务器会将模拟结果打上对应输入数据的帧号,并下发给客户端;
  3. 客户端收到服务器下发的模拟结果之后,通过帧号从History List中找到对应的数据进行验证,判断是否需要进行纠正;
  4. 从客户端的History List删除已经验证过的记录(帧号比当前已被验证过的数据小的);

上面的流程看上去很简单,但细究起来,会发现有很多细节需要处理:

  1. 如何确保客户端预表现结果服务器模拟结果的比较是有意义的?

    客户端预表现结果服务器模拟结果有意义的前提是,客户端和服务器在相同的输入数据下,可以得到大致相同的模拟结果(之所以是大致相同,是因为Space Engineers使用的Havok物理引擎是不确定性物理引擎)。

    (1)确保输入数据相同:除了前面提到的使用帧号进行标记之外,还要确保在网络传输的过程中,客户端在序列化输入数据和服务器在反序列化输入数据时要保持一定的精度,不能出现很大的差异;

    (2)确保模拟结果相同:一个很重要的前提则是客户端和服务器每次模拟的时间相同(Synchronizing simulation steps)。这不是将客户端和服务器的帧率设置成一样就可以的,因为每一帧的实际耗时是由该帧的计算量决定的。当计算量小时,当前帧会提前结束进入下一帧,而当计算量大时,当前帧则会占用更多的时间,出现丢帧(Frame Drop)的情况。

     1.  计算量小提前进入下一帧的问题,可以很方便地通过锁帧,也就是在帧的末尾等到该帧分配的时间耗尽才进入下一帧的方式解决;
     2.  计算量大导致丢帧的问题,则可以通过性能优化的方式去缓解。但性能优化并不能保证100%不出现丢帧的问题,因为玩家本地可以使用各种Mod,而Mod的性能则是无法控制的;
    

    因此,通过一些额外的处理来确保客户端和服务器的帧率尽可能保持一致,是让客户端预表现结果服务器模拟结果的比较有意义的重点和难点。

  2. 如何让客户端和服务器的帧率尽可能保持一致?

    让客户端和服务器的帧率尽可能保持一致的前提条件是,能知道客户端和服务器当前帧率的实际差距,从而决定如何对客户端的帧率进行调整。

    如何计算客户端和服务器当前帧率的实际差距呢?首先,客户端需要记录每一帧对应的实际时间。其次,当服务器在使用输入数据得到模拟结果之后,要把输入数据的帧号服务器当前的帧号一起带上下发客户端。

    如上图所示,当客户端在第9帧收到服务器下发的数据时,用第9帧的时间戳减去第1帧的时间戳,就得到了RTT(Rount-Trip Time)的具体时长。

    用第9帧的时间戳减去第1帧的时间戳,其实应该包括RTT、输入数据在Playout Delay Buffer里等待的时间、服务器得到模拟结果的实际耗时,原视频将其近似为RTT。

    至于服务器帧号的具体用途,原视频并没有很详细地介绍。根据上下文信息,个人猜测服务器帧号的具体用途应该是用来得到客户端对应帧的时间戳。之所以会这样猜测,是因为在理想情况下,客户端会在ping / 2之后收到服务器的模拟结果,因此可以反推出服务器在得到该模拟结果时所对应的客户端时间戳。

    但此时我们希望得到的是客户端和服务器之间帧率的差异,因此我们还需要知道在服务器得到该模拟结果时客户端实际的时间戳。因此,使用服务器帧号来拿客户端实际的时间戳,然后来比较服务器帧率和客户端帧率的快慢,这就比较合理了。

    如下图所示,不妨假设客户端的帧率低于服务器,也就是客户端每帧的耗时大于服务器。当客户端在第7帧收到服务器下发的数据时,先根据推算出的ping值,算出服务器得到模拟结果的估算时间,然后再根据服务器下发的帧号,找到客户端对应帧的实际时间。根据这两个时间,可以很快推算出客户端和服务器每帧耗时的差距,也就是左边的红色部分。

    但《Space Engineers》采用的算法略有不同,是先根据客户端对应帧的实际时间推算出在客户端的实际时间下服务器数据预期的抵达时间,然后再根据服务器数据的实际抵达时间算出客户端和服务器每帧耗时的差距,也就是右边的红色部分,从而判断客户端当前是比服务器慢还是比服务器更快。

    虽然算法不一样,但很明显能知道两种方法算出来的差值,也就是左右两边的红色部分是一样的。

    服务器数据的预期抵达时间快于实际抵达时间时,客户端的进度落后于服务器(Client falling beind),需要对客户端进行加速,也就是缩短客户端每帧执行的时间。而当服务器数据的预期抵达时间慢于实际抵达时间时,客户端的进度比服务器更快(Client ahead),客户端需要等待服务器,也就是让客户端每帧多等待一段时间。

    当客户端落后服务器太多,大于1000ms时,需要执行reset to server操作。原视频对这里的介绍比较含糊,只是提到了”skips all frames up to the current server time”。

    个人猜测,此时累积的误差已足够大,后面的调整已经失去了意义。这里最好的方式,应该是指将客户端上的History List中的所有帧都清空,并强制客户端用服务器下发的位置数据进行重置。然后以当前收到的数据包为起点,重新进行对时,开始新一轮的预测。

  3. 如何判断客户端是否需要纠正?

    由于浮点数精度的问题,比较两个浮点数是否相等,本质上是判断这两个浮点数的差值是否在一个可接受的范围内。

    同理,对于使用浮点数计算并表示的模拟结果来说,判断客户端和服务器的模拟结果是否一致,本质上也是判断两者的位置、朝向、速度等数据的差值是否在预期的阈值内。

    如果误差小于阈值,那么可以近似地将它们视为一致的,此时不需要对客户端进行纠正。而如果误差超出了阈值,那么就应该对客户端进行纠正。

  4. 如何对客户端进行纠正?

    当对客户端进行纠正的时候,其实是在对客户端的预表现,也就是客户端所记录的History List进行纠正。

    《Rocket League》采用的纠正办法是,先回溯到不同步帧,然后将物体强行重置到服务器下发的位置,最后再按照History List所记录的输入数据重新逐帧进行模拟,得到一个新的History List,也就是纠正后的预表现结果。

    因为场景很大物体很多,重新模拟的计算量是巨大的,所以《Space Engineers》并没有采用上述方法,而是直接使用不同步帧中客户端与服务器模拟结果的差异值去纠正History List里 的每一个数据,从而得到纠正后的预表现结果。

    当然,这种修正并不是准确的,甚至某种程度上还有可能加大客户端与服务器后续模拟结果的差异。因此,在应用修正值时,应该随着时间的推进不断减小修正的幅度。

    原文是:The correction should be applied over time with small doses - exponential to its extent.

5.4 Relative Prediction

Animated同步模式下,物体的位置数据会晚于服务器,而在Predicted同步模式下,物体的位置数据则是早于服务器。

如果说服务器所处的时间线是现在,那么玩家在客户端上控制的角色则处于未来,其他玩家所控制的物体则处于过去。此时,当玩家想控制角色与其他玩家控制的物体进行交互时,无疑会产生和服务器上不一样的结果,这就是时间悖论(Time Paradox)

如下所示,其他玩家开的飞船(蓝色方块)以50m/s的速度向左飞行,玩家控制的角色(灰色圆圈)也以50m/s的速度向左飞行,并在追上之后准备从门(黄色方块)进入飞船。然而,在服务器,角色所在的位置却是飞船偏后的位置。不难知道,在一段时间之后,服务器上的角色会撞在船外面无法进入,然后将客户端上的角色纠正回船外面。此时控制角色的玩家看到自己进入飞船之后又迅速被拉扯回飞船外面,这无疑是非常差的体验。

《Space Engineers》解决这一问题的方法被称为Relative Prediction,和前面提到的Relative Position Updates很像,那就是将飞船当前玩家所控制角色的Parent节点,然后服务器给客户端下发角色的相对位置数据,此时客户端本地的History List所记录的位置数据也全部转换成相对位置数据,并在Parent节点的局部坐标系下完成纠正,最后再转换成世界位置数据。

服务器下发相对位置之后,先纠正客户端的相对位置,然后再转换成世界位置。此时,角色的相对位置是正确的,但世界位置则和服务器相差比较大,这是因为转换时所使用的Parent在客户端上的世界位置是晚于服务器的。虽然此时玩家也被拉扯了,但玩家会认为是自己网络突然卡了导致没追上,而不会觉得是bug。

那么什么时候该选择合适的Parent并切换成同步相对位置呢?

一个理想的情况,是角色接触到其他物体之后,将角色所接触的物体设置为Parent然后开始同步相对位置,如角色站在其他玩家操作的飞船上。

还有一种复杂的情况,是玩家在飞行或者行走的过程中,试图靠近其他物体(例如前面准备进入飞船)。此时,如果等待接触时再切换到同步相对位置就太晚了。这个问题可以通过给物体加上一个包围盒解决,但角色进入包围盒,且速度和当前物体接近时,就认为物体是角色潜在的操作对象,此时将该物体设置为角色Parent并开始同步相对位置是比较合理的。

(1)之所以要加上速度的限制,是为了避免玩家只是单纯路过物体时,出现奇怪的拉扯问题;

(2)如果角色同时进入了多个物体的包围盒,且这些物体都满足成为Parent的要求,那么会优先选择最大的物体作为Parent;


6. 性能优化带来的问题

作为一个太空沙盒类游戏,《Space Engineers》的场景很大,需要同步的物体也很多。直接将整个游戏世界的状态全部同步给客户端会造成巨大的性能浪费,因为距离玩家过远的物体,玩家根本看不到也并不关心,同步这些物体的状态没有任何作用。

因此,《Space Engineers》使用了常规的AOI(Area of Interest)技术,根据玩家所控制角色所在的位置,将位于其附近的物体同步给客户端。且物体与角色的距离越远,物体同步的频率就越低。

此外,为了进一步优化客户端的性能,《Space Engineers》将客户端上所有非玩家控制的物体都设置为不会进行物理模拟的Static RigidBody,只有玩家控制的物体才会被设置为Dynamic RigidBody进行物理模拟。当玩家切换当前控制的物体时,新控制的物体会被切换为Dynamic RigidBody,而被停止控制的物体则会被切换为Static RigidBody

(1)对于被设置为Static RigidBody的物体,其只会使用服务器下发的数据来修改位置,也就是前面提到的Animated同步模式;

(2)而玩家控制的物体则会直接使用输入数据进行物理模拟,然后再用服务器下发的数据进行纠正,也就是前面的Predicted同步模式;

(3)需要同步的物体在服务器上都是Dynamic RigidBody,这样才能得到模拟数据下发给客户端;

这个优化策略无疑大大减少了客户端物理模拟的开销,但付出的代价则是同步上的各种的问题╮(╯_╰)╭

6.1 物理模拟差异导致的不同步

当角色准备推动比较小的物体时,由于客户端上的物体是Static RigidBody,所以角色没办法推动物体,而是直接爬到物体之上。但服务器上的物体是Dynamic RigidBody,角色可以正常推动物体,并给客户端下发物体移动后的位置数据。

由于客户端和服务器的物理设置不同,导致两边的模拟结果出现了不同步的问题。再加上角色接触到了物体之后,会按照前面的Relative Prediction规则将物体设置为角色的Parent。那么可想而知,后面物体会正常通过Animated同步模式使用服务器下发的数据往前移动,而角色则会因为纠正而出现拉扯问题。

6.2 物理属性缺失导致的不同步

被设置为Static RigidBody的物体是不具备速度角速度等物理属性的。当角色站在运动的物体上时,如果不做特殊处理,角色的速度会和服务器保持一致,而物体则会因为被设置成Static RigidBody失去速度。相当于客户端上角色和物体之间的相对运动出现了极大的不同步。

为了解决这一问题,当其他物体成为玩家控制角色的Parent时,服务器会将客户端的速度修改成相对速度,从而确保角色和物体的相对运动是同步的。

同时,为了在客户端还原角色和物体之间的相对运动关系(也就是被抹除的那一部分速度所带来的位移),当服务器下发物体的位置数据让客户端修改物体的位置时,客户端会将物体位置的变化传播(Propagate)给角色,先让角色移动相同的距离,然后角色再以当前的实际速度向前运动。

这种策略,本质上是通过牺牲角色世界位置的准确性为代价,来获得相对位置的正确性。不难想象,当物体的速度处于一直变化的状态时,如果角色通过跳跃等方式与物体不再进行接触,不再将物体视为Parent,切换为世界位置的同步,前面累积的巨大差异将会使角色立刻触发纠正被拉扯。

如上图所示,当角色站在一个以固定角速度旋转的物体上时,虽然物体的速度大小不变、但速度的方向一直在变化。虽然客户端上物体的角度与服务器相差不大,但角色所在的位置相差却很大。当角色跳跃脱离物体时,会立刻触发纠正被拉扯(离开时被拉扯回正确的世界位置,但相对位置不对,于是落下时又被拉扯回正确的相对位置)。

客户端无法正确预测缺失的数据,这是一个无法解决的问题。因此,《Space Engineers》只能打上一个补丁:当检测到玩家所控制的角色频繁触发纠正被拉扯时,会关闭角色的预测,将其切换到Animated同步模式。这样角色就不再会出现频繁的拉扯问题,但付出的代价则是玩家的延迟大大增加

6.3 复杂物理约束导致的不同步

玩家可能会通过游戏提供的链接组件将许多物体链接在一起,制造出很复杂的、可控制的物体。例如下图的Strandbeest-like Walker,它的每条腿都拥有很多个关节。

当玩家控制这个步行器时,其每个关节都需要根据玩家的输入数据在物理约束下进行物理模拟,并驱动这个步行器进行移动。因此,每个关节都需要通过Prediced同步模式进行预表现和纠正。

但是,在不确定性物理引擎中,物理的模拟结果是存在差异的。尤其是在物理约束的作用下,前面关节的模拟结果会影响到后面关节的模拟结果,也就是物理模拟的误差会逐渐累积。当物体的关节越多、物理约束链越长时,其末尾的物体在客户端和服务器上的模拟差异就会越大,就会频繁地触发纠正,导致抽搐等不自然的表现。

为了规避这一问题,当物体的物理约束链过于复杂时,不再对约束链上的物体进行预测,而是切换到Animated同步模式,并通过前面提到的Relative Position Updates,确保约束链上物体相对位置的正确性,从而获得更加流畅自然的效果。

虽然这会极大地提高玩家在操作步行器的延迟,但考虑到约束链的模拟本身就存在延迟(需要等上一级模拟完之后才接着模拟,可以简单地理解为力的传递需要时间),不进行预测所带来的额外延迟玩家很难察觉到,是可以接受的。

6.4 脏碰撞导致的不同步

按照物体物理设置的切换规则,当玩家尝试操纵一个载具时,载具会从Static RigidBody切换成Dynamic RigidBody。此时,如果载具内部挂着另外一个物体,尽管该物体在服务器上被设置为Dynamic RigidBody,但因为该物体在客户端上没有被玩家控制,所以会被设置为Static RigidBody并与载具不断发生自碰撞。

其实这里没有特别理解为什么会出现这样的问题。

如果物体在服务器上与载具没有发生自碰撞,说明碰撞设置应该是彼此之间都Ignore Collision。此时就算物体被设置为Static RigidBody,载具和物体也不会产生任何碰撞。

而且在分享中也没有明确提到,但物体从Dynamic RigidBody被设置为Static RigidBody时,会修改其碰撞设置(事实上也没这个必要)。

因此,唯一的可能是,载具和物体是通过某种特殊的物理约束链接在一起的,这中物理约束会忽略物体和载具之间的碰撞且只对Dynamic RigidBody起作用。当物体被设置为Static RigidBody时,这个约束会失效,自然就会出现自碰撞的问题。之所以会有这个猜测,是因为既然允许玩家自由将不同的物体链接在一起去自由建造新的东西,那肯定需要相应的措施去解决物体碰撞体重叠时的碰撞问题。

针对这个问题,一个解决的方法是,如果发现玩家控制的物体出现频繁的脏碰撞,那么可以考虑将其也设置为Static RigidBody并切换到Animated同步模式,等过段时间没有检测到任何脏碰撞时,再将其恢复成Dynamic RigidBody并切换回Predicted同步模式。


7. 总结

总的来说,这是一次很棒的分享,里面很详细地介绍了方案的实现细节,并很客观地介绍了一些不得不做的优化,以及这些优化所带来的各种问题,并逐一分享了这些问题对同步方案的挑战和解决的思路,很有启发。

唯一的缺点是分享的内容比较分散,需要多看几遍才能理解前后不同章节的内容之间的联系。但瑕不掩瑜,这仍是一个非常值得认真观看并学习的GDC分享。


参考文章


《Space Engineers》基于物理的预测同步方案
https://asancai.github.io/posts/53757809/
作者
RainbowCyan
发布于
2023年11月11日
许可协议