大家好,我是格伦·菲德勒。欢迎大家阅读系列教程《网络物理仿真》,这个系列教程的目的是将物理仿真的状态通过网络进行广播。
在之前的文章中,我们讨论了物理仿真需要在网络上进行广播的各种属性。在这篇文章中,我们将使用具有确定性的帧同步技术来将物理仿真通过网络进行传递和广播。
具有确定性的帧同步是一种用来在一台电脑和其他电脑之间进行同步的方法,这种方法发送的是控制仿真状态变化的输入,而不是像其他方法那样发送的是仿真过程中物体的状态变化。这种方法的背后思想是给定一个初始状态,不妨设为S(n),我们通过使用输入信息I(n)来运行仿真就能得到S(n+1)这个状态。然后我们可以通过S(n+1)这个状态和输入信息I(n+1)来运行仿真就能得到S(n+2)这个状态,我们可以一直重复这个过程得到S(n+3)、S(n+4)以及其后的各个状态。这看上去有点像是数学归纳法,我们可以只通过输入信息和之前的仿真状态就能得到后面的仿真状态-而且得到的仿真状态是高度一致,并且也不需要发送任何状态方面的同步。
这个网络模型的主要优点是所需的带宽仅仅用来传递输入信息,而输入信息所占的带宽其实是与仿真中物体的数目是完全无关的。你可以通过网络来对一百万个物体进行物理仿真,它所需的带宽会跟只对一个物体进行物理仿真所需的带宽完全相同。可以很容易的看到物理物体的状态通常是包含位置、方向、线性速度和角速度(如果是未压缩的话,这些状态一共需要52字节,在这里面假设方向使用的是四元数而其他所有的变量都是用vec3来表示),所以当你有大量的物体需要进行物理仿真的时候,这是一个非常具有吸引力的方案。
如果要采用具有确定性的帧同步这个方案来将物理仿真网络化,首先要做的第一件事就是要确保你的仿真具有确定性。在这个上下文中,确定性其实和自由意志之类的没有关系。它只是意味着给定相同的初始条件和相同的一组输入,仿真能够给出完全相同的结果。而且我在这里要着重强调下是完全相同的结果。而不是说的什么在在浮点数容忍度内足够接近。这种精确是精确到比特位的。所以这种精确性使得你可以在每帧的末尾对整个物理状态做一个校验和,不同机器上面同一帧得到的校验和是完全一致的。
从上面的图中可以看到,这里面的仿真几乎是具有确定性的,但是不完全具有确定性。左边的仿真由玩家进行控制,而右边的仿真有完全一致的初始状态,输入信息也和左边完全相同,但是要有2秒钟的延迟。这两个仿真使用相同的间隔时间进行更新(使用相同的间隔时间进行更新也是确保得到完全一致结果的一个必要前提条件),并且在每一帧前对相同的输入信息进行相应。你可以注意到随着仿真的进行,那些一开始很微小的差异是如何一点点被扩大,最后导致两个仿真完全不同步。所以说这个仿真其实不具有确定性。
上面到底发生了什么?最后会导致两个仿真的结果差的这么大?这是因为我使用的物理引擎(ODE)在它的内部使用了一个随机数生成器来对约束处理的顺序进行随机化来提高稳定性。这个物理引擎是完全开源的,所以可以看看它的内部实现!不幸的是,由于左边的仿真处理约束的顺序和右边的仿真处理约束的顺序不同,这导致有一些轻微不同的结果。
幸运的是我们还是能找到让ODE这个物理引擎具有确定性的条件:要在同一台机器上、使用同一个编译好的二进制文件、并且在完全相同的操作系统上运行(这是必要的限制条件么?),还有就是在运行仿真之前通过dSetRandomSeed把随机数的种子设为当前帧的帧数。一旦满足这些条件的话,ODE这个物理引擎能够给出完全相同的结果,并且左边和右边的仿真能够保持高度一致的同步。
现在让我们针对上面这个情况给出一个警告。即使ODE这个物理引擎能够在相同的机器上得到确定性的结果,但是这并不一定意味着在不同编译器、不同的操作系统甚至不同的机器架构上(比如说在PowerPC架构上和在Intel架构上)它能够得到确定性的结果。事实上,由于浮点数的优化,在程序的debug版本和release版本之间可能都没有办法得到确定性的结果。浮点数的确定性是一个非常复杂的问题,而且这个问题没有银弹(意味着这个问题没有什么简单可行的解决办法)。要了解更多这方面的信息,请参考这篇文章。
让我们讨论下具有确定性的帧同步的具体实现方法。
你可能想知道在我们这个示例仿真中输入信息到底是啥,以及我们该如何吧这些输入信息进行网络化。我们这个示例仿真是由键盘输入进行驱动的:方向键会给代表玩家的立方体施加一个力让他进行移动、按下空格键会把代表玩家的立方体提起来并把碰到的立方体四处滚落、按下‘z’键会启动katamari模式。
但是我们该如何对这些输入信息进行网络化呢?我们需要把整个键盘的状态在网络上进行传输么?在这些键被按下和释放的时候我们要发送这些事件么?不,整个键盘的状态不需要在网络上进行传输,我们只需要传输那些会影响仿真的按键。那么被按下和释放的键的事件需要在网络上进行传输么?不,这也不是一个好的策略。我们需要确保的是在仿真第n帧的时候右边的仿真能够应用完全相同的输入信息,所以我们不能仅仅是通过TCP来发送“按键按下”和“按键释放”的事件,因为这些事件到达网络的另外一侧的时间如果早于或者晚于第n帧的时候都会给仿真造成偏差。
相反我们做的事情是用一个结构来表示整个输入信息,并且在左边一侧仿真开始的时候,通过键盘的访问来填充这个结构并把填充好的结构放到一个滑动窗口中,我们在后面可以根据帧号来对这个输入进行访问。
1
2
3
4
5
6
7
8
9
|
struct
Input
{
bool
left;
bool
right;
bool
up;
bool
down;
bool
space;
bool
z;
};
|
现在我们就可以通过上面的方法来把左边仿真的输入信息发送到右边仿真中去,这样右边的仿真就知道属于第n帧的输入信息到底是怎么样的。举个简单的例子来说,如果你在通过TCP进行发送的话,你可以简单的只发送输入信息而不发送其他的内容,而发送的输入信息的顺序隐含着帧号N。而在网络的另外一侧,你可以读取传送过来的数据包,并且对输入信息进行处理并把输入信息应用到仿真中去。我不推荐这种方法,但我们可以从这里开始,然后再向你展示如何把这种方法变得更好。
在进一步对这个方法进行优化之前,让我们先统一下使用的网络环境,让我们假设下我们是通过TCP进行数据传输,已经禁止了Nagle算法并且每帧都会从左边的仿真向右边的仿真发送一次输入信息(频率是每秒60次)。
这里面有一个问题会变得比较复杂。把左边仿真发生的输入信息通过网络进行传输,然后右边仿真并没有足够的时间来从网络上收到输入信息并利用这些到达的输入信息来模拟仿真,因为这个过程需要一定的时间。你不能按照某个频率在网络上发送信息并且期望它们能够按照完全相同一致的频率到达网络的另外一侧(比如说,每六十分之一秒到达一个数据包)。互联网并不是按照这个方式工作的。根本就没有这样的保证。
如果你想要做到这一点的话,你必须实现一个叫做播放延迟缓冲区的东西。不幸的是,播放延迟缓冲区收到了专利保护,也就是一个专利雷区。我不建议读者在实际使用确定性的帧同步模型的时候搜索“播放延迟缓冲区”或者是“自适应性延迟缓冲区”。但简而言之,你所需要做的事情是缓存收到的数据包一小段时间以便让这些数据包表现的像是以一个稳定的速度到达那样,即使实际上它们的到达时间是充满抖动的。
你现在所做的事情就跟你在看一个视频流的时候,Netflix所做的事情是很类似的。你在最初开始的时候停顿了一下以便你可以拥有一个缓冲区,这样即使一些数据包的到达时间有点晚,但是这种延迟不会对视频帧按正确时间间距的表现有什么影响,视频帧仍然会按照正确的时间间隔一帧帧的播放。当然如果你的缓冲区没有足够大的话,那么这些视频帧的播放可能还是会充满一些抖动。有了确定性的帧同步机制,你的模拟仿真将会以完全相同的方式执行。我建议在播放的时候最好在一开始有100毫秒-250毫秒的延迟。在下面的例子中,我使用的是100毫秒的延迟,这是因为我让延迟最小化来增强响应性。
我的播放延迟缓冲区的实现非常的简单。是将输入信息按照帧序号进行添加,当收到第一个输入信息的时候,它保存了接收方机器上的当前本地时间,并且从那一个时刻起假设所有到达的数据包都会带上100毫秒的延迟。你可能需要一些更加复杂的机制来适应真实世界的情况,比如说可能需要处理时钟漂移、检测在什么时候应该适当的加速或者减慢模拟的速度来让缓冲区的大小在能够保证整体延迟最小的情况下保持在一个适度的情形(这就是所谓的“自适应”),但是这些内容可能会相当的复杂并且可能需要一整篇文章来专门对这些情况进行专门论述。而且如前所述,这些内容还涉及到了专利保护方面的内容,所以这些内容我就不详细展开了,把如何处理这些东西全部托付给你自己实现。
在平均情况下,播放延迟缓冲区给帧n、n+1、n+2以及后续的帧提供了一个稳定的输入信息流,非常完美的以六十分之一秒的间隔依次到达。在最坏的情况下,就是已经该执行第N帧的模拟仿真了,但是这一帧的输入信息还没有到达,那么它就会返回一个空指针,这样整个模拟仿真就必须在那里进行等待了。如果数据包被集中起来发送并且到达接收方的时候已经比预期时间延迟了,这可能会导致多个帧的输入信息同时准备好等待出列进行计算。如果是这种情况的话,我会限制在一个渲染帧的时间最多只能进行4次模拟仿真,这样给模拟仿真一个追上来的机会。如果你把这个值设置的更高的话,那么可能会引起更多其他的问题,比如卡顿,因为你可能需要超过六十分之一秒的时间来运行这些帧(这可能会造成一个非常不好的反馈体验)。总而言之,重要的是确保你的模拟仿真在使用确定性的帧同步这个方案的时候性能不是在中央处理器这一端受限的,否则的话,你在运行更多的模拟帧来追上正常的模拟速度的时候会遇到很多麻烦。
通过使用这种延迟缓冲区的策略以及通过TCP协议来发送输入信息,我们可以很轻松的确保所有的输入信息会有序的到达并且传输是可信赖的。这就是一开始TCP协议在设计的时候希望达到的目标。实际上,下面这些东西就是互联网的专家常说的一些东西:
· 如果你需要一个可以信赖的有序的发送信息的方法,你不可能找到一种比通过TCP协议进行传输更好的方法!
· 你的游戏根本就不会需要UDP协议。
我在这里将告诉你上面这些想法都是大错特错的。
在上面的讨论中,你可以看到如果网络同步模型使用基于TCP协议的确定性的帧同步模型的话,模拟仿真的网络延迟大概是100毫秒,并且有百分之一的丢包率。如果你仔细看下右边所提供的数据的话,你可以每隔几秒就会出现一些抖动。如果你在两边都出现这种的情况,那么很抱歉这意味着你的电脑的性能对于播放这些视频而言可能有些艰难。如果是这种情况的话,我建议下载这个视频然后离线观看。无论如何,这里所发生的事情是当一个数据包丢失的时候,TCP协议需要等待至少2个往返时延才会重新发送这个数据包(实际上这里面的等待时间可能会更糟,但是我很慷慨的设定了一个非常理想的情况。。。)。所以上面发生的抖动原因是确定性的帧同步模型要求右边的模拟仿真在没有第n帧的输入信息的时候不能执行第N帧的模拟仿真计算,所以整个模拟仿真就停下来等待对应帧的输入信息的到达!
这还不是全部!随着延迟时间的增大和丢包率的增加,整个情况会变得更加的糟糕。这是在250毫秒延迟和百分之五丢包率的情况下,使用基于TCP协议的确定性的帧同步模型进行相同的仿真模拟运算导致的结果:
现在我要承认一个事情,如果延迟时间设置的非常低的话同时不存在丢包的情况下,那么使用TCP协议进行输入信息的传输会是一个非常可以接受的结果。但是请注意,如果你使用TCP协议来发送时间敏感的数据的话,随着延迟时间的增大和丢包率的增加,整个结果会急剧恶化。
我们可以做得更好吗?我们能在自己的游戏里面找到一种比使用TCP协议更好的办法。同时还能实现可信赖的有序传递?
答案是肯定的。但前提是我们需要改变游戏的规则。
下面将具体描述下我们将使用的技巧。我们需要确保所有的输入信息能够可靠地按顺序到达。但是如果我们只发送UDP数据包输入。但是如果我们只使用UDP数据包来发送输入信息的话,这里面的一些数据包会丢失。那么如果我们不采用事后检测的方法来判断哪些数据包丢失并发送这些丢失的数据包的话,我们采用另外一种方法,只是把我们有的所有输入信息都冗余的发送直到我们知道这些输入信息成功的到达另外一侧怎么样?
输入信息都非常非常的小(只有6比特这么大)。让我们假设下我们在每秒需要发送60个输入信息(因为模拟仿真的频率是60fps ),而且我们知道一个往返的时间大概是在30毫秒到250毫秒之间。纯粹为了好玩,让我们假设下载最糟糕的情况下,一个往返的时间可以高达2秒,如果出现这种情况的话,那么整个连接就会超时。这意味着在平均情况我们只需要包括大概2到15帧的输入信息,而在最坏情况下,我们大概需要120帧的输入信息。那么最坏情况下,输入信息的大小是120*6 = 720比特。这只是90字节的输入信息!这是安全合理的。
我们还能做的更好。在每一帧中都出现输入信息的变化是非常不常见的。如果我们不是从最近有输入发生的那一帧开始计数来发送我们的数据包,而是从第一个输入信息的6比特开始,并且加上所有未打包的输入信息的数目。这样,当我们对这些输入信息进行遍历将它们写入数据包的时候,如果发现这一帧的输入信息如果和之前帧的输入信息不同的话,我们可以写入一个单独的比特位(1),如果发现这一帧的输入信息如果和之前帧的输入信息相同的话,我们可以写入一个单独的比特位(0)。所以这一帧的输入信息如果和之前帧的输入信息不同的话,我们需要写入7个比特位(这种情况很少见),这一帧的输入信息如果和之前帧的输入信息相同的话,我们只需要写入1个比特位(这种情况其实非常常见)。在输入信息很少发生变化的情况,这是一个重大的胜利,而在最坏的情况下出现的情况也不会非常糟糕。只需要发送额外120个比特的数据,也就是说在最坏情况下,也只有15字节的额外开销
当然在这种情况下,需要从右边的模拟仿真中发送一个数据包到左边的模拟仿真中去,这样左边的模拟仿真才知道哪些输入信息被成功收到了。在每一帧,右边的模拟仿真都会从网络中读取输入的数据包,然后才会把这些数据包添加到延迟播放缓冲区,并且通过帧号记录它已经收到的最近那一帧的输入信息,或者如果你想容易一点处理这个问题的话,那么使用一个16比特的序列号就能很好的包装这个信息。在所有的输入数据包都被处理以后,如果右边的模拟仿真收到任何帧的输入信息以后都会回复一个数据包给左边的模拟仿真,告诉它最近收到的最新序列号是多少,这基本就是一个“ack”包,也就是确认包。
当左边的模拟仿真收到这个“ack”包,也就是确认包以后,它会滑动输入信息窗口并且丢弃比已经确认的序列号还老的输入信息包。已经没有必要再发送这些输入信息包给右边的模拟仿真了,因为已经知道右边的模拟仿真成功的接受到了这些输入信息包。通过这种方式,我们通常只有少量的输入信息正在传输过程中,而且这个数量还是与数据包的往返时间成正比的。
我们通过改变游戏的规则成功了找到了一种比TCP协议更好的办法。我们并不是通过在UDP协议纸上构建了实现TCP协议百分之九五功能的新协议,而是实现了一种非常不同的方法,而且更加适合我们的要求:数据对时间非常敏感。我们开发了一个自定义的协议,可以冗余的发送所有未被确认的输入信息,这样就可以在不降低同步质量的情况下处理延迟时间和丢包的问题。
所以这种方法到底比通过TCP协议来发送数据好多少呢?
让我们通过一个例子来看一下。
上面的视频是基于UDP协议来使用具有确定性的帧同步模型,延迟时间是2秒,并且有百分之二十五的丢包率。事实上,如果我仅仅把播放延迟缓冲区的缓冲时间从100毫秒增大到250毫秒,我就能让整个模拟仿真在百分之五十丢包率的情况平滑的运行。想象下如果我们是使用基于TCP协议的具有确定性的帧同步模型,我们该看到多么可怕的场景!
所以最后我们能得到这么一个结论:即使这是一个TCP协议最具有优势的情况下,这是整个系列里面唯一一个依赖可靠性、有序性数据传输的网络模型,我们还是可以很容易的通过一个自定义的协议基于UDP来发送我们的数据包,并且得到的效果更好。