英文原文:
https://gafferongames.com/post/state_synchronization/
简介
大家好,我是 Glenn Fiedler,欢迎来到网络物理。
在上一篇文章中,我们讨论了压缩快照的技术。
在这篇文章中,我们用状态同步来完善我们对网络物理学策略的讨论,这是本文系列中的第三个也是最后一个策略。
状态同步
什么是状态同步?其基本思想是,有点像确定性锁步,我们在两边运行模拟,但与确定性锁步不同,我们不只是发送输入,我们同时发送输入和状态。
这使状态同步具有有趣的特性。因为我们发送状态,我们不需要完美的确定性来保持同步,而且因为模拟在两边运行,对象在更新之间继续向前移动。
这让我们以不同的方式处理状态同步和快照插值。与其为每个数据包中的每个对象发送状态更新,我们现在可以只为少数对象发送更新,如果我们知道如何为每个数据包选择对象,我们可以通过集中更新最重要的对象来节省带宽。
那么有什么问题呢?状态同步是一种近似的有损同步策略。在实践中,这意味着您将花费大量时间来追踪外推分歧和弹出的来源。但除此之外,这是一种快速简便的入门策略。
实施
这是每个对象通过网络发送的状态:
struct StateUpdate
{
int index;
vec3f position;
quat4f orientation;
vec3f linear_velocity;
vec3f angular_velocity;
};
与快照插值不同,我们不仅发送位置和方向等视觉量,还发送非视觉状态,例如线速度和角速度。为什么是这样?
原因是,状态同步在两边运行模拟,所以它总是从应用于每个物体的最后一次状态更新中推断出来。如果线性速度和角速度不同步,这种推断就会以不正确的速度进行,导致物体更新时出现弹出。
虽然我们必须发送速度,但当一个物体处于静止状态时,反复发送(0,0,0)是没有必要浪费带宽的。我们可以通过一个微不足道的优化来解决这个问题,就像这样。
void serialize_state_update( Stream & stream,
int & index,
StateUpdate & state_update )
{
serialize_int( stream, index, 0, NumCubes - 1 );
serialize_vector( stream, state_update.position );
serialize_quaternion( stream, state_update.orientation );
bool at_rest = stream.IsWriting() ? state_update.AtRest() : false;
serialize_bool( stream, at_rest );
if ( !at_rest )
{
serialize_vector( stream, state_update.linear_velocity );
serialize_vector( stream, state_update.angular_velocity );
}
else if ( stream.IsReading() )
{
state_update.linear_velocity = vec3f(0,0,0);
state_update.angular_velocity = vec3f(0,0,0);
}
}
您在上面看到的是一个序列化函数。这是我喜欢用来统一数据包读写的技巧。我喜欢它,因为它具有表现力,同时很难使读写不同步。你可以在这里阅读更多关于它们的信息。
数据包结构
现在让我们看看正在发送的数据包的整体结构:
const int MaxInputsPerPacket = 32;
const int MaxStateUpdatesPerPacket = 64;
struct Packet
{
uint32_t sequence;
Input inputs[MaxInputsPerPacket];
int num_object_updates;
StateUpdate state_updates[MaxStateUpdatesPerPacket];
};
首先,我们在每个数据包中包含一个序列号,以便我们可以确定乱序、丢失或重复的数据包。我建议您在两侧以相同的帧速率(例如 60HZ)运行模拟,在这种情况下,序列号可以作为帧号工作。
input 被包含在每个数据包中,因为它需要进行推断。像确定性锁步一样,我们发送多个冗余的inputs,所以在丢包的情况下,input被丢掉的可能性很小。与确定性锁步不同的是,如果没有下一个input,我们不会停止模拟并等待它,而是用最后收到的input继续向前推断。
接下来您可以看到我们每个数据包最多只发送 64 个状态更新。由于我们在模拟中总共有 901 个立方体,所以我们需要一些方法来选择 n 个最重要的状态更新以包含在每个数据包中。我们需要某种优先级方案。
要开始每一帧走过你的模拟中的所有对象,并计算它们当前的优先级。例如,在立方体模拟中,我计算玩家立方体的优先级为1000000,因为我总是希望它被包括在每个数据包中,对于互动的(红色立方体),我给他们一个更高的优先级100,而在休息的物体的优先级为1。
不幸的是,如果你只是根据每一帧物体的当前优先级来挑选物体,你就只能在katamari球中发送红色物体,而地面上的白色物体将永远不会被更新。我们需要采取一个稍微不同的方法,一个优先发送重要物体的方法,同时将更新分布在模拟中的所有物体上。
优先累加器
您可以使用优先级累加器来执行此操作。这是一个浮点值数组,每个对象一个值,逐帧记住。我们不是采用对象的直接优先级值并对其进行排序,而是在每一帧将每个对象的当前优先级添加到其优先级累加器值,然后按照从最大到最小优先级累加器值的顺序对对象进行排序。此排序列表中的前 n 个对象是您应该发送该帧的对象。
你可以直接为所有n个对象发送状态更新,但通常你有一些你想支持的最大带宽,如256kbit/sec。遵守这个带宽限制很容易。只需计算你的数据包头有多大,数据包中有多少字节的前言(序列、数据包中的对象数量等等),然后保守地计算出数据包中剩余的字节数,同时保持在你的带宽目标之下。
然后根据优先级累加器的值,选取n个最重要的对象,当你构建数据包时,依次走过这些对象,并测量它们的状态更新是否适合放入数据包。如果你遇到一个不合适的状态更新,就跳过它,尝试下一个。在你将数据包序列化之后,对于适合的对象,将优先级累加器重置为零,但对于不适合的对象,将优先级累加器的值保持不变。这样一来,不适合的对象就会在下一个数据包中排在第一位。
甚至可以即时调整所需的带宽。这使得状态同步适应不断变化的网络条件变得非常容易,例如,如果您检测到连接有困难,您可以减少发送的带宽量(避免拥塞)并且状态同步的质量会自动缩减。如果网络连接看起来以后应该能够处理更多带宽,那么您可以提高带宽限制。
抖动缓冲器
优先级累加器涵盖了发送端,但在接收端,在应用这些状态更新时需要做很多事情,以确保在对象更新之间的推断中看不到分歧和突发情况。
你需要考虑的第一件事是网络抖动的存在。你不能保证你发送的数据包以每秒60次的良好间隔到达另一方。在现实世界中发生的情况是,你通常会在一帧中收到两个数据包,下一帧是0个数据包,1个,2个,0个等等,因为数据包往往会在不同的帧中聚集在一起。为了处理这种情况,你需要为你的状态更新数据包实现一个抖动缓冲器。如果你没有做到这一点,你的推断质量就会很差,并且会弹出一堆对象,因为不同状态更新包中的对象在时间上彼此之间略有不同。
您在抖动缓冲区中所做的所有事情都是在将数据包交付给应用程序之前按照数据包中的序列号(帧号)指示的正确时间保留数据包。与快照插值的插值延迟相比,在此缓冲区中保存数据包所需的延迟时间要少得多,但基本思想相同。你只需要对数据包进行足够的延迟(比如4-5帧@60HZ),使它们从缓冲区出来时有适当的间隔。
应用状态更新
一旦数据包摆脱了抖动,你如何应用状态更新?我的建议是你应该努力捕捉物理状态。这意味着您将状态更新中的值直接应用于模拟。
我建议不要尝试在模拟级别的状态更新和当前状态之间应用一些平滑。这听起来可能违反直觉,但其原因是模拟从状态更新中推断出来,因此您要确保它从该对象的有效物理状态推断出来,而不是一些平滑的、完全胡说八道的虚构状态。当您将大量对象联网时,这一点尤其重要。
令人惊讶的是,没有任何平滑的结果已经相当不错了:
state_synchronization_uncompressed
如您所见,它看起来已经相当不错,几乎没有进行任何带宽优化。将此与第一个 18mbit/sec 的快照插值视频进行对比,您可以看到使用模拟在状态更新之间进行推断是使用更少带宽的好方法。
当然,我们可以做得比这更好,我们所做的每次优化都可以让我们在相同的带宽内压缩更多的状态更新。我们可以做的下一个显而易见的事情是应用所有标准量化压缩技术,例如边界和量化位置、线性和角速度值,并使用快照压缩中描述的 smallest three 压缩。
但这里就变得有点复杂了。我们是从这些状态更新中推断出来的,所以如果我们在网络上量化这些值,那么到达右边的状态与左边的状态略有不同,导致推断结果略有不同,当该对象的下一个状态更新到达时,就会出现弹出。
https://gafferongames.com/videos/state_synchronization_compressed.mp4
量化两侧
解决办法是对两边的状态进行量化。这意味着,在每一个模拟步骤之前,你都要对整个模拟状态进行量化,就像它在网络上被传输一样。一旦这样做了,左边和右边都是从量化的状态推算出来的,它们的推算结果非常相似。
因为这些量化的数值被反馈到模拟中,你会发现比快照插值需要更多的精度,因为在快照插值中它们只是用于插值的视觉量。在立方体模拟中,我发现每米需要4096个位置值,比快照插值的512个要高,而且最小的三个四元数分量需要高达15位(比9位要高)。如果没有这些额外的精度,就会出现明显的爆裂,因为量化迫使物理对象相互渗透,与试图保持对象不渗透的模拟相对抗。我还发现,软化约束和减少模拟用来推开穿透物体的最大速度也有助于减少爆裂的数量。
https://gafferongames.com/videos/state_synchronization_quantize_both_sides.mp4
在对两边进行量化处理后,你可以看到结果又是完美的。它在视觉上看起来和未压缩的版本差不多,但事实上我们能够在256kbit/sec的带宽限制下,每包容纳更多的状态更新。这意味着我们能够更好地处理数据包丢失问题,因为每个对象的状态更新被更快地发送。如果一个数据包丢失了,问题就不大了,因为这些对象的状态更新会被持续地包含在未来的数据包中。
请注意,当发生突发性丢包时,比如1/4秒内没有数据包通过,这是不可避免的,最终会发生这样的事情,你可能会在左边和右边得到不同的结果。我们必须对此进行规划。尽管我们做了种种努力来确保推断结果尽可能接近(对两边进行量化等等),但如果网络停止传送数据包,就会发生流行病。
视觉平滑
我们可以用平滑法来掩盖这些突起。
还记得我之前说过你不应该在模拟级别应用平滑,因为它会破坏推断吗?相反,我们将为平滑做的是计算和维护我们随着时间减少的位置和方向误差偏移。然后,当我们在右侧渲染立方体时,我们不会在模拟位置和方向上渲染它们,而是在模拟位置 + 误差偏移量和方向 * 方向误差处渲染它们。
随着时间的推移,我们努力将这些误差偏移量减少到零(位置误差),将方位误差减少到相同。为了减少误差,我使用了一个趋于零的指数平滑移动平均值。因此,实际上,我在每一帧中把位置误差偏移量乘以某个系数(例如0.9),直到它足够接近于零而被清除(从而避免畸形)。对于方向,我在每一帧中都向身份倾斜一定量(0.1),这对方向误差有同样的效果。
使这一切正常工作的诀窍是,当状态更新进入时,您获取当前模拟位置并将位置误差添加到该位置,然后从新位置中减去该位置误差,从而给出新的位置误差偏移量,从而得到与当前(平滑的)视觉位置。
然后将相同的过程应用于误差四元数(使用共轭乘法而不是减法),这样您就可以有效地计算每个状态更新相对于新状态的新位置误差和方向误差,使得对象似乎没有完全动了。因此,状态更新是平滑的并且没有立即的视觉效果,并且随着时间的推移,错误减少会消除推断中的任何错误,而玩家通常不会注意到。
我发现使用单个平滑因子会产生不可接受的结果。 0.95 系数非常适合小抖动,因为它可以很好地消除高频抖动,但同时对于大位置错误(例如在数秒丢包后发生的错误)来说太慢了:
state_synchronization_basic_smoothing
我使用的解决方案是在不同的误差距离下使用两个不同的比例因子,为了确保过渡平滑,我根据需要减少的位置误差量线性地混合这两个因子。在此模拟中,对于较小的位置误差(25cm 或更小),具有 0.95 的混合因子,而对于较大的距离(1m 误差或更大)具有更紧密的混合因子 0.85,会产生良好的结果。同样的策略适用于使用方向误差和单位矩阵之间的点积的方向。我发现在这种情况下,点 0.1 和 0.5 之间的相同因素的混合效果很好。
最终结果是小位置和方向误差的平滑误差减少以及大弹出的严格误差减少。正如你在上面看到的,你不想拖出对这些大爆裂声的修正,它们需要很快,所以它们很快就会结束,否则它们真的会让玩家迷失方向,但同时你想要真正当误差较小时平滑误差减少,因此自适应误差减少方法非常有效。
state_synchronization_adaptive_smoothing
增量压缩
尽管我认为上面的结果可能已经足够好了,但从这一点上看,还是有可能大大改善同步性的。例如,支持一个有更大物体或更多物体被交互的世界。因此,让我们通过这些技术,把这个技术推到它所能达到的极限。
有一个简单的压缩方法可以执行。不要对绝对位置进行编码,如果它在玩家立方体中心的范围内,就把位置编码为玩家中心位置的相对偏移。在带宽很高和状态更新需要更频繁的常见情况下(卡塔玛里球),这提供了一个很大的胜利。
接下来,如果我们确实想为状态同步执行某种增量编码怎么办?我们可以,但在这种情况下,它与快照有很大的不同,因为我们不包括每个数据包中的每个立方体,所以我们不能只是跟踪最近收到的数据包,并说,好吧,这个数据包中的所有这些状态更新都是相对于数据包X的。
你实际上要做的是每个对象的更新都要跟踪包括该更新的基础的数据包。你还需要准确地跟踪所收到的数据包的集合,以便发送者知道哪些数据包是有效的基数,可以相对编码。这相当复杂,需要一个通过UDP的双向ack系统。这样的系统正是为这种情况而设计的,你需要确切地知道哪些数据包肯定通过了。你可以在这篇文章中找到一个关于如何实现这个的教程。
因此,假设你有一个ack系统,你知道数据包的序列号可以通过。那么你要做的是,如果更新是相对的或绝对的,每状态更新写一位,如果是绝对的,那么就像以前一样用无基数编码,否则如果是相对的,就发送16位序列号的每状态更新的基数,然后对该包中发送的状态更新数据进行相对编码。这为每个更新增加了1比特的开销,同时也增加了16比特来识别每个对象更新的基础序列号。我们能做得更好吗?
是的。事实证明,为了实现这种相对编码,你当然要在发送和接收方面进行缓冲,而且你不可能永远缓冲。事实上,如果你考虑一下,在变得不切实际之前,你只能缓冲几秒钟,而且在移动物体的常见情况下,你将频繁地发送同一物体的更新(卡塔玛里球),所以实际上,基本序列将只是短时间内的。
因此,与其按对象发送16位序列基数,不如在数据包的头部发送最近的应答数据包(来自可靠性应答系统),并按对象用5位编码相对于该值的基数序列的偏移。这样,在每秒钟60个数据包的情况下,你可以识别出半秒前的基数的状态更新。任何比这更早的基数都不可能提供一个好的delta编码,因为它是旧的,所以在这种情况下,只需对该更新退回到绝对编码。
现在让我们来看看那些将拥有这些绝对编码而不是相对编码的对象类型。它们是静止的物体。我们能做些什么来使它们尽可能地有效呢?在立方体模拟的情况下,可能出现的一个坏结果是,一个立方体静止(变成灰色),然后它的优先级被大大降低。如果由于数据包丢失而错过了对该物体位置的最后一次更新,那么该物体可能需要很长时间才能更新其静止的位置。
我们可以通过跟踪最近处于静止状态的对象并提高它们的优先级来解决这个问题,直到它们被发送的数据包有一个ack回来。因此,与正常的灰色立方体(处于静止状态,没有移动)相比,它们被以较高的优先级发送,并一直以较高的速率重新发送,直到我们知道已经收到更新,从而 "承诺 "该灰色立方体处于正确的静止位置。
总结
这就是这项技术的真正内容。没有任何花哨的东西,它已经很不错了,而且在此基础上,通过压缩压缩,又有了一个数量级的改进,但代价是极大的复杂性。