[Unity Mirror] Snapshot Compression(快照压缩)

81 篇文章 30 订阅

英语原文:

https://gafferongames.com/post/snapshot_compression/

简介

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

  在上一篇文章中,我们通过网络每秒 10 次发送整个模拟的快照,并在它们之间进行插值以重建另一侧的模拟视图。

  像10HZ这样的低快照率的问题是,快照之间的插值会在网络延迟的基础上增加插值延迟。在每秒钟10个快照的情况下,最小的插值延迟是100ms,考虑到网络抖动,更实际的最小延迟是150ms。如果需要保护一个或两个连续丢失的数据包,这将导致250ms或350ms的延迟。

  对于大多数游戏来说,这不是可接受的延迟量,但是当物理模拟像我们的一样不可预测时,减少它的唯一方法是提高数据包发送速率。不幸的是,增加发送速率也会增加带宽。所以我们在这篇文章中要做的是通过所有可能的带宽优化(至少我能想到的),直到我们控制带宽。

  我们的目标带宽是 256KB/s。

起点@60HZ

  生活很少是容易的,而网络程序员的生活更是如此。作为网络程序员,我们经常被赋予不可能完成的任务,所以本着这种精神,让我们把快照发送率从每秒钟10张增加到60张,看看我们离目标带宽到底有多远。

snapshot

  这是一个很大的带宽:每秒 17.37Mbps!

让我们分解一下,看看所有带宽的去向。

这是快照中发送的每个立方体的状态:

    struct CubeState
    {
        bool interacting;
        vec3f position;
        vec3f linear_velocity;
        quat4f orientation;
    };

  这是每个字段的大小:

  • quat orientation: 128 bits
  • vec3 linear_velocity: 96 bits
  • vec3 position: 96 bits
  • bool interacting: 1 bit

  这样,每个立方体共有321 bits(或每个立方体有40.125 bytes)。

  让我们做一个快速计算,看看带宽是否符合要求。这个场景有901个立方体,所以每张快照有90140.125=36152.625字节的立方体数据。每秒60个快照,所以36152.62560=2169157.5字节/秒。加入数据包头估计:2169157.5 + 32*60 = 2170957.5。将每秒钟的字节数转换成每秒钟的兆比特数:2170957.5 * 8 / ( 1000 * 1000 ) = 17.38mbps。

  一切都检查出来了。没有简单的办法,我们正在发送大量的带宽,我们必须将其减少到目前带宽的1-2%左右,以达到我们每秒256kb的目标。

  这有可能吗?当然可以! 让我们开始吧 😃

优化Orientation

  我们将从优化Orientation开始,因为它是最大的领域。(在优化带宽时,最好是尽可能按照从大到小的顺序进行。)

  许多人在压缩四元数时会想:“我知道。我将把它打包到 8.8.8.8 中,每个组件一个 8 位有符号整数!”。当然,这行得通,但是通过一些数学运算,您可以使用称为“最小三”的技巧以更少的位数获得更好的精度。

  最小的三个是如何工作的?由于我们知道四元数代表旋转,其长度必须是1,所以x2+y2+z2+w2=1。我们可以利用这个特性来丢弃一个分量,并在另一侧重建它。例如,如果你发送x,y,z,你可以重构w=sqrt( 1 - x^2 - y^2 - z^2 )。你可能认为你需要为w发送一个符号位,以防它是负的,但你不需要,因为如果w是负的,你可以通过否定整个四元数使w总是正的(在四元数空间(x,y,z,w)和(-x,-y,-z,-w)代表同样的旋转。)

  由于数字精度问题,不要总是放弃同一个分量。相反,找到绝对值最大的分量,用两个比特[0,3]对其索引进行编码(0=x,1=y,2=z,3=w),然后通过网络发送最大分量的索引和最小的三个分量(因此得名)。在另一边使用最大位的索引来知道你必须从其他三个分量中重构哪个分量。

  最后一项改进。如果v是最大的四元数分量的绝对值,那么当两个分量的绝对值相同而另外两个分量为0时,就会出现下一个最大的分量值。该四元数(v,v,0,0)的长度为1,因此v2+v2=1,2v^2=1,v=1/sqrt(2)。这意味着你可以用[-0.707107,+0.707107]而不是[-1,+1]来编码最小的三个分量,让你在相同的比特数下获得更多的精度。

  使用这种技术,我发现我的模拟的最低足够精度是每个最小组件 9 位。这给出了每个方向 2 + 9 + 9 + 9 = 29 位的结果(低于 128 位)。

snapshot

  这种优化将带宽减少了超过 5 兆比特/秒,我认为如果您查看右侧,您将很难从压缩中发现任何痕迹。

优化线性速度

  我们接下来应该优化什么?在线性速度和位置之间打一个平手。两者都是96比特。根据我的经验,位置是更难压缩的数量,所以让我们从这里开始。

  为了压缩线性速度,我们需要把它的x,y,z分量约束在某个范围内,这样我们就不需要发送完整的浮点值。我发现最大速度为32米/秒是一个很好的2次方,而且不会对玩家在立方体模拟中的体验产生负面影响。因为我们实际上只是把线性速度作为一种提示,以改善位置样本点之间的插值,所以我们可以很粗暴地进行压缩。每米每秒32个不同的值提供了可接受的精度。

  线性速度已经被约束和量化,现在是[-1024,1023]范围内的三个整数。这可以分解为以下内容。[-32,+31](6位)为整数部分,乘以5位分数精度。我讨厌乱用符号位,所以我只是加1024来得到范围为[0,2047]的值,然后发送。在接收时解码,只需减去1024,在转换为浮点数之前回到有符号的整数范围。

  每个分量11比特,每线性速度共33比特。刚好是原来未压缩大小的1/3!

  我们可以做得比这更好,因为大多数立方体是静止的。为了利用这一点,我们只需写一个单一的位 “静止”。如果这个位是1,那么速度就隐含为零,不被发送。否则,压缩后的速度会跟在该位之后(33位)。现在静止的立方体只需要127比特,而正在移动的立方体比以前多花了一个比特。159+1=160比特。

snapshot

  但是我们为什么要发送线性速度呢?在上一篇文章中,我们决定发送它,因为它提高了每秒10个快照的插值质量,但现在我们要发送每秒60个快照,这还有必要吗?正如你在下面看到的,答案是否定的。

snapshot

  线性插值在60HZ时已经很好了。这意味着我们可以完全避免发送线性速度。有时,最好的带宽优化并不是优化你发送的内容,而是优化你不发送的内容。

优化位置

  现在我们只有位置需要压缩了。我们将使用我们用于线性速度的同样的技巧:边界和量化。我在水平面(xy)选择了一个[-256,255]米的位置边界,由于在立方体模拟中,地板在z=0,我选择了一个[0,32]米的z范围。

  现在我们需要计算出需要多少精度。通过实验,我发现每米512个值(大约2毫米的精度)就能提供足够的精度。这使得位置的x和y分量在[-131072,+131071],z分量在[0,16383]范围。这就是18位的x,18位的y和14位的z,每个位置总共有50位(原来是96位)。

  这将我们的立方体状态减少到80位,或者说每个立方体只有10个字节。

  这大约是原始成本的 1/4。绝对进步!

snapshot

  现在,我们已经压缩了位置和方向,我们已经用完了简单的优化方法。任何进一步降低精度的做法都会导致不可接受的假象。

Delta压缩

  我们可以进一步优化吗?答案是肯定的,但前提是我们采用了一种全新的技术: Delta 压缩。

  Delta压缩听起来很神秘。神奇的。很难。实际上,这一点也不难。它是这样工作的:左边的人向右边的人发送数据包,像这样。“这是快照110相对于快照100的编码”。被相对编码的快照被称为基线。如何进行编码由你决定,有很多花哨的技巧,但基本的、大数量级的胜利是在你说的时候。“快照110中的立方体n与基线是一样的。一个比特:没有改变!”

  为了实现delta编码,发送方当然必须只对相对于对方已经收到的基线的快照进行编码,否则他们就无法解码快照。因此,为了处理数据包的丢失,接收方必须不断向发送方发送 "ack "数据包,说。“我收到的最近的快照是快照n”。发送方接收这个最新的 ack,如果它比前一个 ack 更新,则将基线快照更新为这个值。下一次发送数据包时,快照会相对于这个较新的基线进行编码。这个过程不断发生,稳定状态成为发送方相对于过去的RTT(往返时间)的基线编码快照。

  有一个小问题:在初始连接后的一个往返时间里,发送方没有任何基线来进行编码,因为它还没有收到接收方的确认。我通过在数据包中添加一个标志来处理这个问题:“这个快照是相对于模拟的初始状态进行编码的”,这在双方都是已知的。如果接收方不知道初始状态,另一个选择是使用非delta编码的路径向下发送初始状态,例如,作为一个大的数据块,一旦收到该数据块,delta编码的快照首先相对于数据块中的初始基线发送,然后最终收敛到RTT的基线的稳定状态。

snapshot

  正如你在上面看到的,这是一场大胜。我们可以完善这种方法,并锁定更多的收益,但我们不会在这一点上获得另一个数量级的改进。从现在开始,我们将不得不非常努力地工作,以获得一些小的、累积的收益,以达到我们每秒256kb的目标。

渐进式的改善

  第一个小改进。每个未发送的多维数据集花费 1 位(未更改)。有 901 个立方体,所以我们在每个数据包中发送 901 位,即使没有立方体发生变化。在每秒 60 个数据包的情况下,这增加了 54kbps 的带宽。鉴于在常见情况下,每个快照通常明显少于 901 个更改的立方体,我们可以通过仅发送具有立方体索引 [0,900] 的已更改立方体来识别它是哪个立方体来减少带宽。为此,我们需要为每个立方体添加一个 10 位索引来识别它。

  有一个交叉点,发送索引实际上比不改变的比特更昂贵。对于10比特的索引,索引的成本是10*n比特。因此,如果我们发送90个立方体或更少(900比特),使用索引会更有效率。我们可以根据每个快照进行评估,并在头中发送一个单一的位,表明我们正在使用哪种编码。0=索引,1=改变的比特。这样,我们就可以针对快照中的变化方块的数量使用最有效的编码。

  这使所有对象静止时的稳态带宽减少到每秒15千比特左右。这个带宽完全由我们自己的数据包头(uint16 sequence, uint16 base, bool initial)加上IP和UDP头(28字节)组成。

  下一个小收获。如果我们相对于前一个立方体索引对立方体索引进行编码会怎样?由于我们正在遍历并按顺序发送更改的立方体索引:立方体 0、立方体 10、立方体 11、50、52、55 等等,我们可以轻松地对相对于先前更改的索引的第二个和剩余的立方体索引进行编码,例如: +10、+1、+39、+2、+3。如果我们对如何编码这个索引偏移量很聪明,我们应该能够平均表示一个少于 10 位的立方体索引。

  最好的编码取决于你与之互动的对象集。如果你花大量时间水平移动,同时从初始立方体网格中吹出立方体,那么你就会打出大量的+1s。如果你从初始状态垂直移动,你会碰到很多+30s(sqrt(900))。那么我们需要的是一个通用的编码,能够用更少的比特来表示统计学上常见的索引偏移。

经过少量实验后,我想出了这个简单的编码:

  • [1,8] => 1 + 3 (4 bits)
  • [9,40] => 1 + 1 + 5 (7 bits)
  • [41,900] => 1 + 1 + 10 (12 bits)

  请注意,大的相对偏移量实际上比10比特更昂贵。这是一个统计游戏。赌注是我们会得到更多的小偏移,这样一来,那里的胜利就抵消了大偏移的成本增加。这很有效。用这种编码,我能够得到平均每相对索引5.5比特的结果。

  现在我们有一个小问题。我们不再能轻易地确定改变的比特或相对指数是最好的编码。我使用的解决方案是,在数据包写入时对所有改变的立方体进行模拟编码,并计算编码相对指数所需的比特数。如果所需的比特数大于901,就退回到改变的比特。

这是我们到目前为止的情况,这是一个显着的改进:

snapshot

  下一个小改进。编码位置相对于(偏离)基线位置。这里有很多不同的选择。你可以只做显而易见的事情,例如,1比特的相对位置,然后说每个组件8-10比特,如果所有组件都在这些比特提供的范围内有延迟,否则发送绝对位置(50比特)。

  这给出了一个体面的编码,但我们可以做得更好。如果你想一想,会有这样的情况:一个位置分量很大,但其他分量很小。如果我们能利用这一点,用更少的比特发送这些小的组件,那就更好了。

  这是一个统计游戏,每个部件的小范围和大范围的最佳选择取决于数据集。我无法真正判断看一个嘈杂的带宽表是否有任何收获,所以我捕捉了位置与位置基础数据集,并将其写入文本文件进行分析。

  我写了一个简短的Ruby脚本,用贪婪搜索的方式找到最佳编码。我为这组数据找到的最佳位包编码是这样的。每个delta分量有1比特小值,如果小值[-16,+15]范围,则有5比特,否则delta分量在[-256,+255]范围,用9比特发送。如果任何组件的delta值在大范围之外,则回退到绝对位置。使用这种编码,我能够获得平均26.1比特的改变位置值。

Delta 编码最小三

  接下来,我认为相对方向将是一个类似的轻松大胜利。问题在于,与位置偏移的范围相对于总位置空间非常小的位置不同,100 毫秒内的方向变化占总四元数空间的百分比要大得多。

  我尝试了一堆东西,但没有好的结果。我试着直接对delta方向的4D向量进行编码,并使用与最小3相同的技巧对delta后的最大分量进行重组。我试着计算方向和基础方向之间的相对四元数,由于我知道w对此会很大(相对于身份的旋转),我可以避免发送2位来识别最大分量,但反过来需要发送1位来识别w的符号,因为我不想否定四元数。使用这种方案,我能找到的最好的压缩方法只是最小的三个的90%。这不是很好。

  我正准备放弃,但我对最小的三个表示法进行了一些分析。我发现,在最小三格式中,90%的方向与100毫秒前的基本方向具有相同的最大成分指数。这意味着,直接对最小三格式进行delta编码是有利可图的。更重要的是,我发现用这种方法从基点重建方向时,不会有额外的精度损失。我将典型运行中的四元数值导出为最小三格式的数据集,并开始尝试与我用于位置的多级小/大范围每分量贪婪搜索。

  找到的最佳编码是:5-8,表示 [-16,+15] 小和 [-128,+127] 大。最后一件事:与位置一样,通过知道如果组件值不小,则值不能在 [-16,+15] 范围内,可以进一步扩展大范围。我将计算如何做到这一点作为练习留给读者。注意不要将两个值折叠为零。

  最终结果是每个相对四元数平均 23.3 位。这是绝对最小的三个的 80.3%。

  仅此而已,但还剩下一个小胜利。对位置和方向数据集进行最终分析时,我注意到在量化到 0.5 毫米分辨率后,5% 的位置与基本位置相比没有变化,最小三种格式的 5% 的方向也与基本位置没有变化。

  这两个概率是相互排斥的,因为如果这两个概率相同,那么立方体将是不变的,因此不会被发送,这意味着如果我们为位置变化发送一个比特,为方向变化发送一个比特,10%的立方体状态存在一个小的统计胜利。是的,90%的立方体有2比特的开销,但是10%的立方体通过发送2比特而不是23.3比特的方向或26.1比特的位置而节省了20多个比特,这就弥补了每个立方体大约2比特的小的整体胜利。

snapshot

  如您所见,最终结果非常好。

总结

  而这已经是我使用传统的手卷比特打包技术所能达到的极限了。你可以在这里找到我对本文提到的所有压缩技术的实现的源代码

  使用不同的方法可以获得更好的压缩效果。位打包效率低下,因为并非所有位值都具有相同的 0 与 1 概率。无论您如何调整位打包器,上下文感知算术编码都可以通过更准确地建模数据中出现的值的概率来击败您的结果放。 Fabian Giesen 的这个实现比我最好的位压缩结果高出 25%。

  对于delta编码的方向来说,使用以前的基线方向值来估计角速度和预测未来的方向,而不是直接对最小的三个表示进行delta编码,也有可能得到更好的结果。

下一篇:状态同步

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值