[Unity Mirror] Deterministic Lockstep(确定性锁步)

81 篇文章 30 订阅

英文原文:

https://gafferongames.com/post/deterministic_lockstep/

介绍

  大家好,我是 Glenn Fiedler,欢迎来到网络物理。

  在上一篇文章中,我们探讨了我们将在这一系列文章中联网的物理学模拟。在这篇文章中,我们将使用确定性锁步法对这个物理学模拟进行联网。

  确定性锁步是一种将一个系统从一台计算机联网到另一台计算机的方法,只发送控制该系统的输入,而不是该系统的状态。在物理模拟联网的背景下,这意味着我们发送少量的输入,同时避免发送每个物体的位置、方向、线速度和角速度等状态。

  好处是带宽与输入的大小成正比,而不是与模拟中的对象数量成正比。是的,通过确定性锁步,您可以将一百万个对象的物理模拟与一个具有相同带宽的对象联网。

  虽然这在理论上听起来很好,但在实践中却很难实现确定性的锁步,因为大多数物理模拟都不是确定性的。编译器、操作系统甚至指令集之间的浮点行为差异使得几乎不可能保证浮点计算的确定性。

确定性

  确定性意味着,在相同的初始条件和相同的输入集合下,你的模拟给出了完全相同的结果。我的意思是完全相同的结果。

  不是近似。不是接近。是完全一样。精确到比特级。如此精确,你可以在每一帧结束时对你的整个物理状态进行校验,它将是相同的。

deterministic

  上面你可以看到一个几乎是确定性的模拟。左边的模拟是由玩家控制的。右边的模拟有完全相同的输入,从相同的初始条件开始,延迟两秒钟。两个模拟都以相同的delta时间向前迈进(这是确保结果完全相同的必要前提),两个模拟都应用相同的输入。请注意,在最小的分歧之后,模拟变得越来越不同步。这个模拟是非确定性的。

  发生的事情是,我使用的物理引擎(Open Dynamics Engine)在其求解器内使用了一个随机数生成器来随机化约束处理的顺序,以提高稳定性。它是开源的。看一看,就知道了 不幸的是,这打破了确定性,因为左边的模拟与右边的模拟处理约束的顺序不同,导致结果略有不同。

  幸运的是,要使ODE在同一台机器上具有确定性,使用相同的编译二进制文件并在相同的操作系统上(这就够资格了吗?),只需要在运行模拟之前通过dSetRandomSeed将其内部随机种子设置为当前帧数。一旦这样做了,ODE就会给出完全相同的结果,而且左右两边的模拟会保持同步。

deterministic

  现在有一个警告。尽管上述模拟在同一台机器上是确定的,但这并不一定意味着它在不同的编译器、不同的操作系统或不同的机器架构(例如,PowerPC与Intel)上也是确定的。事实上,由于浮点优化,它在调试和发布版本之间可能都不是确定性的。

  浮点确定性是一个复杂的主题,没有灵丹妙药。

  有关更多信息,请参阅这篇文章

网络输入

  现在让我们开始实施。

  我们的物理模拟例子是由键盘输入驱动的:方向键施加力量使玩家立方体移动,按住空格将立方体抬起并将其他立方体吹到周围,按住’z’可启用 katamari 模式。

  我们怎样才能将这些输入联网?我们必须发送整个键盘的状态吗?不,没有必要发送整个键盘的状态,只发送影响模拟的按键的状态。那么按键的按下和释放事件呢?不,这也不是一个好的策略。我们需要确保完全相同的输入在同一时间应用于右侧,所以我们不能只是通过TCP发送 "按下的键 "和 "释放的键 "事件。

  我们所做的是用一个结构体表示输入,并在左侧每个模拟帧的开头,从键盘上对该结构体进行采样:

    struct Input
    {
        bool left;
        bool right;
        bool up;
        bool down;
        bool space;
        bool z;
    };

  接下来,我们把这个输入从左边的模拟发送到右边的模拟,让右边的模拟知道这个输入属于第n帧。

  这里是关键的部分:右边的模拟只有在有该帧的输入时才能模拟第n帧。如果它没有输入,它就必须等待。

  例如,如果你使用TCP发送,你可以简单地发送输入,而不需要其他东西,在另一边,你可以读取进来的数据包,每收到一个输入就对应着一帧模拟前进。如果在一个给定的渲染帧中没有输入到达,右边就不能向前推进,它必须等待下一个输入的到来。

  因此,让我们继续使用 TCP,您已禁用 Nagle 算法,并且您将每帧从左侧向右侧模拟发送一次输入(每秒 60 次)。

  这里就有点复杂了。由于我们不能向前模拟,除非我们有下一帧的输入,所以仅仅采取网络上的任何输入,然后在输入到达时运行模拟是不够的,因为结果将是非常抖动。以60HZ的频率在网络上发送的数据通常不会有很好的间隔,每个数据包之间有1/60秒的间隔。

  如果你想要这种行为,你必须自己实现它。

播出延迟缓冲器(Playout Delay Buffer)

  这种设备称为播出延迟缓冲器。

  不幸的是,播放延迟缓冲器的主题是一个专利雷区。我不建议在工作中搜索 "播放延迟缓冲器 "或 “自适应播放延迟”。但简而言之,你要做的是在短时间内缓冲数据包,使它们看起来以稳定的速度到达,尽管实际上它们到达时有些抖动。

  你在这里所做的类似于Netflix在流媒体视频时的做法。你一开始会暂停一下,这样你就有一个缓冲区,以防一些数据包晚点到达,然后一旦延迟过后,视频帧就会以正确的时间间隔呈现。如果你的缓冲区不够大,那么视频的播放就会出现问题。使用确定性锁步,你的模拟表现完全相同:当缓冲区不够大,无法平滑抖动时,就会出现障碍。当然,增加缓冲区大小的代价是额外的延迟,所以你不可能通过缓冲区来解决所有的问题。在某些时候,用户会说够了! 这是太多的延迟了。不,先生,我不会用1秒钟的额外延迟来玩你的游戏。)

  我的播放延迟缓冲器的实现非常简单。你按照帧的索引向它添加输入,当收到第一个输入时,它就会在接收机上存储当前的本地时间,并从这一点开始发送数据包,假设它们应该在该时间+100ms时播放。对于现实世界的情况,你可能需要更复杂的东西,也许是处理时钟漂移的东西,以及检测模拟何时应该稍微加快或减慢,以保持相当数量的缓冲安全(“自适应”),同时尽量减少整体延迟,但这是相当复杂的,可能本身就值得写一篇文章。

  我们的目标是,在平均条件下,播放延迟缓冲器为第n帧、第n+1帧、第n+2帧等提供稳定的输入流,间隔时间为1/60秒,没有任何戏剧性。在最坏的情况下,如果第n帧的时间到了,而输入还没有到达,它就会返回空,模拟就会被迫等待。如果数据包被捆绑在一起,迟迟不能送达,那么就有可能在每一帧中准备好多个输入,以便取消排队。在这种情况下,我把每个渲染帧限制在4个模拟帧,这样模拟就有机会赶上,但不会模拟得太长,以至于进一步落后,也就是 “死亡的螺旋”。

TCP是否足够好?

  使用这种播放缓冲策略并通过TCP发送输入,我们可以确保所有的输入都能可靠地、按顺序地到达。这很方便,毕竟,TCP正是为这种情况而设计的:可靠有序的数据。

  事实上,在互联网上,专家们常说这样的话:

但我在这里告诉你,这种想法是完全错误的。

deterministic

  上面你可以看到在100ms延迟和1%丢包的情况下,通过TCP使用确定性锁步进行的模拟联网。如果你仔细看右边,你可以看到每隔几秒钟就会出现一次中断。这里发生的情况是,每次数据包丢失,TCP都要等待RTT*2,然后再重新发送(实际上情况可能更糟,但我很慷慨…)。出现这种情况是因为在确定性锁步的情况下,正确的模拟不能在没有输入n的情况下模拟第n帧,所以它必须暂停,以等待输入n被重新送出。

  这还不是全部。随着延迟和丢包的增加,情况会明显恶化。下面是在250ms延迟和5%丢包的情况下,通过TCP使用确定性锁步进行的相同模拟。

deterministic

  现在我承认,如果你没有丢包和/或有非常小的延迟,那么你很可能用TCP获得可接受的结果。但请注意,如果你使用TCP,它在恶劣的网络条件下表现得很糟糕。

我们能比 TCP 做得更好吗?

  我们能否在自己的游戏中击败TCP。可靠的订单式交付?

  答案是肯定的。但前提是我们改变了游戏规则。

  诀窍是这样的。我们需要确保所有的输入都能可靠地、有序地到达。但如果我们用UDP数据包发送输入,其中一些数据包会丢失。如果我们不在事后检测数据包丢失并重新发送丢失的数据包,而是在每个UDP数据包中冗余地包含所有的输入,直到我们确定对方已经收到这些数据包,那么会怎样呢?

  输入是非常小的(6比特)。假设我们每秒钟发送60个输入(60fps模拟),我们知道往返时间将在30-250ms之间。为了好玩,我们假设最坏的情况可能是2秒,这时我们就会中断连接(去他的)。这意味着,我们平均只需要包括2-15帧的输入,最坏的情况下我们需要120个输入。最坏的情况是120*6=720比特。这只是90字节的输入! 这是完全合理的。

  我们可以做得更好。每一帧输入的变化并不常见。如果当我们发送数据包时,我们从最近的输入的序列号和第一个(最老的)输入的6位开始,再加上未打码的输入的数量。然后,当我们迭代这些输入并将其写入数据包时,如果下一个输入与前一个输入不同,我们可以写一个单比特(1),如果输入相同,则写(0)。因此,如果输入与前一帧不同,我们就写7位(罕见)。如果输入是相同的,我们只写一个(常见)。在输入不经常变化的地方,这是一个很大的胜利,在最坏的情况下,这真的不是那么糟糕。发送120比特的额外数据。最坏的情况下只有15个字节的开销。

  当然,另一个数据包需要从右边的模拟到左边,以便左边知道哪些输入已经收到。每一帧,右边的模拟从网络上读取输入数据包,然后将其添加到播放延迟缓冲区,并跟踪它所收到的最新输入,并将其作为输入的 "ack "或确认返回到左边。

  当左边收到这个应答时,它将丢弃任何比最近收到的输入更早的输入。这样,我们只有少量的输入在飞行,与两个模拟之间的往返时间成正比。

完美的胜利

  我们通过改变游戏规则击败了 TCP。

  我们没有实现“在 UDP 之上实现 95% 的 TCP”,而是实现了一些完全不同的东西,并且更适合我们的要求。一种冗余发送输入的协议,因为我们知道它们很小,所以我们永远不必等待重传。

  那么这种方法到底比通过 TCP 发送输入要好多少呢?

  让我们来看看…

deterministic

  上面的视频显示了使用这种技术在 UDP 上同步的确定性锁步,具有 2 秒的延迟和 25% 的数据包丢失。想象一下,在这些条件下 TCP 看起来会多么糟糕。

  因此,总之,即使在TCP应该具有最大优势的地方,在唯一依赖可靠的有序数据的网络模型中,我们仍然可以用一个建立在UDP之上的简单协议轻松地鞭打它的屁股。

下一篇:快照插值

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值