网络物理的介绍

https://gafferongames.com/post/introduction_to_networked_physics/

最近项目在做物理同步游戏,网上收集了一些资料,准备学习一下。本篇是对一个介绍介绍物理同步的文章的翻译,希望有所收获。

这个文章系列我们准备我们准备用三个模块将物理网络化:确定性锁帧,快照同步和状态同步。

但是在我们谈论这些之前,然我们花一些时间探索一下我们将要在这个系列的文章中网络化的物理模拟:

这里我设置了一个方块的简单模拟,使用了个开源的物理引擎ODE。这个玩家通过在质心使力到处移动。这个物理模拟使用这种线性移动并且,当方块和地面碰撞时计算了摩擦力,包括滚动和颠簸的移动。

这就是为什么我选择了方块而不是椭圆。我希望这个复杂性,不可预测的运动因为通常运动中的有趣的刚体和他们的形状有关。

一个交互的世界

网络化的物理当玩家和其他物理模拟物体交互时变得有趣,特别是当这些物体往后退并影响玩家的运动。

所以让我们往这个模拟中加入一些更多的方块

当玩家和方块交互时它就变成红色。当这个方块休息下来时,他就变回灰色(非交互状态)。

尽管到处滚动并和其他的方块交互很酷,我真正想要的是一种把很多方块到处推的方式。我想到的是这种方式。

正如你看到的,交互并不是直接的。被玩家推着滚动滚动的红方块把碰到的方块也变红了。这种方式下,交互向四周蔓延并覆盖到所以受影响的物体。

一个复杂的例子

我也想要一个复杂的结合的行为在玩家和非玩家的方块之间,这样他们就成为一个系统:一组通过约束连接起来的刚体。

为了实现这个,我认为如果玩家能够到处滚动从而生成一个由方块组成的球,就像在我最喜欢的游戏之一Katamari Damacy。

距离玩家一个固定距离的方块会有一个作用于方块中心的力。这些方块在katamari球中保持了物理模拟,他们不只是简单的像原始的游戏中黏在玩家上。

这对于网络物理模拟来说是一个很难处理的情况。

确定性锁步

介绍

上一篇文章中我们探索了我们将要在这系列文章中将要网络化的物理模拟。这篇文章,我们要使用确定性锁步去实现网络化物理模拟。

确定性锁步是一个仅通过发送控制一个系统的输入而非系统的状态来实现从一台电脑到另一台电脑的网络化。在网络化物理的情况下,这个代表我们通过发送一些输入,避免发送像每个对象的位置,方向,线性速度和角速度等。

好处是带宽和输入的大小相匹配,而不是模拟中的物体的数量。是的,使用确定性锁步,实现100万个物体网络化模拟物理,和一个物体的带宽是一致的。

尽管理论很美好,实际上实现确定性锁步很难因为大多数物理模拟不是确定的。不同编译器的浮点数行为之间,操作系统甚至指令集的差异都让保证浮点运算的确定性变得不可能。

确定性

确定性意味给定相同的初始状态和相同的输入,你的模拟将给出同样的结果。我的意思是完全一致的结果。

不是接近,不是差不多,而是完全一致。bit层面的一致。你可以给你在每一帧的结束给你整个物理状态做了一个检查,结果必须是相同的。

上面是一个几乎确定性的模拟。左边的模拟是玩家自己控制的。右边的模拟是两秒后以相同初始条件开始的模拟。每个模拟都以相同的间隔向前模拟(一个必须的前置条件去保证模拟一致),并且两个模拟都应用了相同的输入。注意到在很小的不同之后,模拟开始变得越来越不同步。这个情况是不同步的。

原因是我用的这个物理引擎使用了一个随机数生成器去随机约束的执行顺序去提升稳定性。不幸的是这个打破了确定性因为这个左边的约束和右边的约束以不同的方式处理,导致了一个稍微不同的结果。

幸运的只要设置它内部的种子为当前的帧的值在通过dsetrandomseed运行之前,就可以保证ODE在相同机器,相同编译二进制和相同的操作系统上的一致性。这样ODE就给出了完全相同的结果。

值得注意的是。尽管尽管相同设备,操作系统下一致,不同设备,操作系统就不一样一致。实际上,甚至debug和release在浮点数的优化上的不同也会导致结果不一致。

浮点数的确定性是一个复杂的问题,没有百试百灵的方法。

箭头施加力让方块运动,按住空格剑是方块挑起并炸开其他方块,按住z激活katamari模式。

我们怎么网络化这个输入呢?我们必须发送键盘的所有状态吗?不,不需要发送全部的键盘状态,只有影响运行状态的键盘输入。那按键的按下释放事件呢?不,这不是一个好的策略。我们需要保证相同时间下应用到右侧的是完全一致的输入,我们不能只是以tcp发送按键按下释放。

我们需要做的是以这个结构体代表输入,并在左侧每帧开始时,从键盘采样输入。

struct Input

{

boolleft,bool  right,bool up,bool down,bool space,bool z

}

接下来我们发送左侧的输入到右侧,右侧需要知道输入属于第几帧

关键在于:右侧的模拟只有知道第n帧的输入时才能模拟第n帧。如果没有输入,只能等待。

例如,如果你使用Tcp发送,你可以只发送tcp,另一边,你可以读取输入进来的包,每个收到的输入对应模拟前进的一帧。如果某一个渲染帧没有输入到达,右侧就不能向前进,它必须等待下一帧到来。

所以让我们带着Tcp继续前进,你关闭了Nagle算法,你每帧从左侧发送输入到右侧。

这里有点复杂。因为我们收不到输入就无法继续向下一帧前进,我们不能直接使用收到的网络包,并在输入包到来后直接模拟输入,这样结果会令人不安。以60hz发送的数据并不会以恰好的间隔到达。

如果你想要这样的行为,你得自己实现

播放延时缓存

这样的设备叫做播放延迟缓存。

不幸的是,播放延迟缓存的主题是一个专利雷场。我不建议在工作时寻找播放延迟缓存或适应播放延迟。但是,你想要做的是缓存一个短量时间的包,所以他们看起来以一个稳定的速率到达,尽管实际上他们到达的时间不确定。

你这里要做的和当你播放一个视频时netflix做的一样。你开始时暂停了一小会所以你有一个缓存以防有些包来的晚了,那么一旦延迟过去,视频帧就以正确的时间呈现。如果你的buffer不够大那么视频的播放就会卡顿。使用确定性锁步,你的模拟表现如下:表现出卡顿如果你的buffer不够大去平缓抖动。当然,提高buffer的大小的代价就是额外的延迟,所以你不能靠缓存解决所有的问题。玩家也接受不了1s的额外延迟。

我的播放缓存实现十分简单。你将加好记号的输入加到缓存中,当第一个输入到达时,它缓存下当前的本地时间在接收者的机器上,从那时起假设他们应该延迟100ms播放。对于现实世界的模拟,你需要一个更加复杂的东西,也许时解决时钟飘逸,或为了最小化全局的延迟检测什么时候该模拟需要轻微的加速或者减速去保持一个缓存安全容量,但这个相当复杂,值得单独写一篇文章。

目标就是在平均的条件下,这个播放延迟缓存为n,n+1,n+2等待输入提供了一个稳定的输入流,平缓的以1/60s间隔,没有延迟。最坏的情况时这个时间到了第n帧但是输入没有到达,返回了null,模拟就必须强制等待。如果包被串起来并延迟发送了,有可能一帧内有多个输入等待出栈。这种情况我限制4个模拟帧每个渲染帧这样模拟就有机会追上来,但是不能模拟时间过长以至于远远落后了,又叫死亡漩涡。

TCP足够好吗

使用播放缓存策略并通过TCP发送输入,我们确保了所有的输入都是可靠并且按序到达。这时很方便的,毕竟,TCP时被设计用来处理可靠按序到达的数据。

实际上,网络上的专家通常都说:如果你需要可靠按序到达的数据,没有比TCP更好的了;你的游戏还不需要UDP

但是我这里要告诉你这种想法时致命的错误

上面你可以看到在TCP上100ms延迟的使用确定性锁步的模拟网络,并且有1%的包丢失。如果你仔细看右边你会发现没过几秒有轻微的抖动。因为每次有包丢失,TCP就必须要等RTT*2的重发送的时间(实际上可能会更差)。这个抖动的出现是因为确定性锁步,右边的模拟没有n输入的话就不能模拟第n帧,所以它必须暂停去等待输入n被重新发送。

这还不是全部。这个变得更加糟糕当延迟和包丢失变得严重时。这个时使用确定性锁步的TCP在250ms和5%的包丢失下相同的网络模拟的情况。

我承认如果你没有包丢失或者很小的延迟使用TCP你会获得可接受的结果。但是请意识到使用TCP将表现的十分糟糕如果网络条件变差。

我们能做的比TCP好吗?

我们能在稳定和按序传输上打败TCP吗?

答案是肯定的。但是只有当我们改变游戏的规则时。

请看。我们需要确保所有的输入都可靠和按序到达。但是如果以UDP我们发送包,这些包的一部分将被丢失。但是,与其检测包丢失后的事实后,重新发送丢失的包,我们每次都在UDP中冗余的发送所有的输入,知道我们知道另一边明确的收到了他们。

输入时很小的(6bits)。假设我们发送了60个输入每秒,路上传输的时间大概在30-250ms之间。又假设最坏的情况下,2s就是我们觉得这个连接超时了。这表示平均我们需要包含2-15帧的输入,最坏的情况下,我们需要120个输入。最坏的情况时120*6 = 720bits。这是需要90个bytes的输入。这完全合理。

我们做的更好。输入不是每帧都会变化。如果我们发送数据包时,从最早输入的序列号、第一帧的6bits,和未确认的输入开始。然后我们遍历这些这些输入写道包中。当下一个输入和上一个输入不同时我们写1,当一样时我们写0.所以如果输入和上一帧不同我们需要写7个bits(很少),当输入和之前一样时,我们只需要写1个bit(通常是这种情况)。当输入改变不是很频繁时,这是一个很大的优势,最坏的情况下也不是很差。120个bits的额外数据。比最坏的情况多了15个bytes。

当然,为了让左侧知道那些输入以及被收到,另一个包需要从右侧发往左侧。每帧右侧模拟从网络读取输入包把他们加到播放延迟缓存中,并记录收到的最新帧,发送给左侧的客户端一个作为输入的ack。

当左侧收到了这个ack,它会丢弃所有比这个帧老的所有输入。通过这种方式我们只会有小部分数量的输入和在两个输入之间传输的时间内的输入差不多。

完美的胜利

我们通过改变游戏的规则打败了TCP

与其“在UDP上实现95%的TCP”,我们实现了完全不一样的更好的适合我们需要的东西。一个冗余发送输入的协议,因为我们知道他们很小,所以我们不需要等待重新传输。

到底这个方法比TCP好多少呢?

上面这个视频展示了UDP的确定性锁步同步使用了2s的延迟和25%的包丢失。想象TCP在这些情况下会表现的多么糟糕。

结论是,即使TCP很有优势,在依赖于可靠和按序到达的数据上的网络模型,我们仍然可以简单的建立在UDP上的协议去抽它的ass。

快照插值

背景

尽管确定性锁步在带宽方面很有优势,但模拟的结果也不是总是确定性的。跨平台的浮点数运算的确定性是很难的。

并且,当玩家的数量增加后,确定性锁步变得问题重重:你在收到所有玩家的操作之前不能模拟下一帧,所以玩家最后将等待网络最差的玩家。因为这个,我推荐确定性同步最多支持2-4个玩家。

所以如果你的模拟不是确定性的或者你想要更高的玩家数量,那么你需要一个不同的工具。快照插值更好的匹配这个。在很多方面,这个方法和确定性锁步完全相反:不是跑两个同步,一个在左,一个在右,并使用完美的确定性和同步输入来保证他们的同步,快照同步在右侧不跑任何模拟。

快照

相反,我们从左侧的模拟抓取一个所有相关状态的快照,并把它传输给右侧,然后我们在右侧用这些快照重建一个虚拟的模拟的大概情况,完全不用自己跑模拟。

第一部分,我们发送渲染每个方块需要的状态:

struct CubeState

{

        bool interacting

        vec3f position

        quat4f orientation

}

我知道你现在想明白了,这个工具的代价是提升带宽的使用。巨大的带宽使用的提升。不要过早惊讶,因为快照需要包含整个模拟的虚拟状态。使用一点数学我们可以看到每个方块序列化到225bits。因为有900个方块,那就意味着快照大概是25kb,太大了!

关于这一点,我想让你们先放松,深呼吸,想象我们生活在一个我们实际上可以在网上每秒60次发送这么大的包的世界,没有任何东西会爆炸。想象我有一个光纤服务,并且我和另一台电脑都在主干网络上。不要急,下面的部分我会介绍如何优化快照的带宽。

当我们发送快照数据的时候,我们了16个bit的序列数字。这个序列数字从0开始,并且每发送一个包增加一次。我们使用这个序列数字去判断这个包是早了还是晚了。如果是老的那么就扔掉。

每一帧我们都渲染收到的最新的快照。

尽管我们以及尽可能频繁的发送包,但是右侧还是有卡顿。这时因为网络不保证以60次每秒发送的网络包也是平等间隔的到达。包是有抖动的。有些帧你收到两个帧,有些帧你啥也收不到。

抖动和卡顿

当你最开始网络化的时候,这是很常见的事情。LAN局域网下网络包到达很顺利平缓,但是无线情况下或者网络上就会有卡顿。不要急,总是有办法解决的。

首先,让我们看看使用最原始的方法,我们需要使用多少带宽。每个包是25312.5bytes加上28bytes的IP+UDP头和2bytes的序列号。就是25342.5bytes每个包,60个包就是1520550个bytes或者11.6Mb每秒。尽管真有可以传输这么多数据的网络,在有抖动的情况下,也不会有很大的益处,让我们看看每秒10次的情况。

尽管右侧的情况不是很好,但是我们把带宽降低了6倍,我们绝对是在正确的方向上。

线性插值

我们要做的不是立即渲染收到的快照数据,而是缓存一定数量的快照到一个插值缓存中。这个插值缓存区保留快照一段时间所以你不仅可以获得当前的快照值,你还很可能知道下一个快照值。当右侧在两个快照之间移动时,就提供了一个平滑移动的假象。实际上,我们是以一小段时间的延迟为代价的。

你可能惊讶于10apps的表现有多好。

仔细看的话,你看到右侧会有一些人为改变的东西。首先是一些不易察觉的位置抖动,当玩家悬浮在空中时。这是你的大脑检测到了一阶不连续性在采样点插值上。另一个人为行为出现在很多方块吸附在katamari球上时,当滚动的速度提升和下降时你可以看见很多的脉冲。这时因为黏附的方块在两个围绕玩家方块选装的采样点之间线性插值,有效的穿过玩家方块因为他们采用了球上的两点之间最短的距离来插值。

Hermite 插值

我发现这些认为的迹象很难被接受,但是我不想提升发送的包的频率去修复它。让我们看看能做上面去改善让它看起来好一些,在一个相同的速率下。一种方法时我们可以尝试升级到一个更加准确的位置的插值模式,另一种方式是在考虑采样点线性速度的情况下,在两个位置采样点之间插值。

这个可以通过Hermite spline来完成

不像其他的带控制点的可以直接影响曲线的样条曲线,hermite曲线保证通过起点和终点并匹配七点和重点的速度。这个表示在采样点之间的速度是平缓过渡的。katamari球的方块倾向于环绕方块旋转而不是以一定速度穿过他。

带宽稍微提高了一点,因为我们需要包含每个方块的线性速度,但是我们能够显著的提升质量,以一个相同的发送速率。我看不到任何认为的痕迹了。和之前的粗糙版相比。这个很惊艳我们可以以一个这么低的发送速率完成这个等级的质量。

作为旁白,我发现为方向提供高阶的插值去获得平缓的插值是没有必要的。这个很好,因为我做了大量在方向四元数之间以一个精确的角速度插值的研究,这个看起来很困难。

处理真实世界条件

现在我们需要处理包丢失的情况。在前一篇文章中我们讨论了UDP和TCP,我可以肯定你知道为什么我们不采用TCP来发送快照。

快照是时间严格的,但是不像确定性同步不需要是可靠的(可以被丢失)。如果一个快照丢失了我们可以直接跳过它,并且和一个插值缓存中更早的快照进行插值。我们从来不想停下等待一个快照包被重新发送。这就是为什么我们总是用UDP来发送快照数据。

我告诉你一个秘密。上面的线性和hermite插值视频不仅是以10个包每秒的速度发送的,他们还有5%的丢包率和+/-2个帧抖动。我处理丢包和抖动的办法就是简单的保证快照在插值前在插值缓存区中保存了一个合适的时间。

我的经验法则是,插值缓存去应该有足够的延迟,这样我可以丢失两个包并且仍然有一些帧去插值。经验来说,我发现最合适的延迟在2-5%包丢失的情况下是3被的包发送速率。在10包每秒的情况下是300ms。我还需要一些额外的延迟去处理抖动,在我的经验里只有1或2个每60fps,所以上面的视频是以350ms的延迟记录的。

添加350ms的延迟看起来很多了。但是如果你尝试节省,那么每个包丢失的时候每秒会有1/10的抖动。人们处理插值缓存在其他区域增加的延迟的一个方式就是使用外推(FPS,飞机模拟器,竞速游戏等等)。但是在我的经验中,外推在刚体中不是工作的很好因为他们的运动是非线性的和不可预测的。这里你可以看到一个200ms的外推,见到350ms的延迟到150ms。

问题是这个不是很好。原因是外推一点也不知道物理模拟。外推不知道碰撞到地板所以外推穿过了地板,然后回旋到正确的位置上。预测不知道回旋力把玩家置于空中所以方块移动的速度比初始的慢,必须赶快跟上。他也不知道碰撞和碰撞反应是如何工作的,所以方块滚动穿过了地板,其他方块也预测错误了。最终,如果你看katamari球,你会看到外推预测的黏附的方块沿着切线的速度移动,而不是应该围绕这玩家方块旋转。

结论

你可想而知花费很大一部分时间去提升外推的质量,让他知道不同方块的运动模式。你可以让每个方块不穿过地板。你可以使用方块之间的边界球体加一些近似的碰撞检测或者响应。你甚至可以控制katamari球中的方块,预测他们绕着玩家的球一起运动。

但是尽管你做了这些,你还是会有错误预测,因为你不能通过近似准确的匹配一个物理模拟。如果你的模拟很多都是线性运动,例如快速运动的飞机,船,太空飞船--你也许发现简单的外推在短时间内也能工作的很好,但是我的经验来看一旦物体开始和不动的物体开始碰撞,外推就开始不行了。

那我们该如何减少插值的延时呢?350ms看起来仍然不可接受,我们也不能在没有任何不准确的条件下使用外推去减少延迟。解决办法很简单:提高发送的频率!如果我们发送30个快照每秒我们可以以150ms的延迟获得相同数量的丢包预测。60个包每秒只需要85ms。

为了提高发送频率我们需要很好的带宽优化。但是不要担心,我们可以做很多事情去优化带宽。太多了以至于我要单独写一篇文章去说。

快照压缩

低快照频率

我们的目标是256kb/s

从60hz开始

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值