我有兴趣探索实时多人客户端 - 服务器游戏开发和相关算法。许多着名的多人游戏,如 Quake 3 或 Half-Life 2 ,都使用 delta compression 技术来节省带宽。
服务器必须不断向所有客户端发送最新的游戏状态快照。总是发送完整快照会非常昂贵,因此服务器只会发送上一个快照和当前快照之间的差异。
......很简单,对吗?好吧,我觉得很难想到的部分是如何实际计算两种游戏状态之间的差异。
游戏状态可能非常复杂,并且在堆上分配的实体通过指针相互引用,可以具有数字值,其表示形式因架构而异,等等。
我发现很难相信每个游戏对象类型都有手写的序列化/反序列化/差异计算功能。
让我们回到基础。假设我有两个状态,用位表示,我想计算它们之间的区别:
state0: 00001000100 // state at time=0
state1: 10000000101 // state at time=1
-----------
added: 10000000001 // bits that were 0 in state0 and are 1 in state1
removed: 00001000000 // bits that were 1 in state0 and are 1 in state1
太好了,我现在拥有added
和removed
diff bitsets - 但是......
......差异的大小仍与州的大小完全相同。而且我实际上必须通过网络发送两个差异!
一个有效的策略实际上是从那些diff位集构建某种稀疏数据结构吗?例如:
// (bit index, added/removed)
// added = 0
// removed 1
(0,0)(4,1)(10,0)
// ^
// bit 0 was added, bit 4 was removed, bit 10 was added
这是一种可行的有效方法吗?
假设我设法为 JSON 的所有游戏对象类型编写序列化/反序列化函数。
我可以,不知何故,有两个JSON值,根据位计算自动之间的差异吗?
示例:
// state0
{
"hp": 10,
"atk": 5
}
// state1
{
"hp": 4,
"atk": 5
}
// diff
{
"hp": -6
}
// state0 as bits (example, random bits)
010001000110001
// state1 as bits (example, random bits)
000001011110000
// desired diff bits (example, random bits)
100101
如果这样的事情是可能的,那么避免依赖于体系结构的问题和手写差异计算功能将是相当容易的。
鉴于两个字符串 A 和 B 彼此相似,是否可以计算字符串 C ,其大小小于< strong> A 和 B ,表示 A 和 B 之间的差异,可以是应用到 A 以在结果中获得 B ?
答案 0 :(得分:4)
由于您已经使用Quake3作为示例,因此我将重点介绍如何在那里完成工作。你需要了解的第一件事是&#34;游戏状态&#34;与客户端 - 服务器游戏相关并不是指对象的整个内部状态,包括AI的当前状态,碰撞功能,定时器等。游戏服务器实际上给了客户一个很多的东西减。只是模型中的对象位置,方向,模型,框架的动画,速度和物理类型。后两者用于通过允许客户模拟弹道运动来使运动更平滑,但这是关于它的。
每个游戏框架,每秒大约发生10次,服务器为游戏中的所有对象运行物理,逻辑和计时器。然后,每个对象调用API函数来更新其新位置,帧等,以及更新是否在此帧中添加或删除它(例如,因为它撞墙而被删除)。实际上,Quake 3在这方面有一个有趣的错误 - 如果一个镜头在物理阶段移动并撞到一堵墙,它就会被删除,并且客户端收到的唯一更新将被删除,而不是之前的飞行,所以客户看到射击在半空中消失,在实际撞到墙壁之前的1/10秒内。
通过这个小的每个对象信息,很容易区分新信息与旧信息。此外,只有实际更改的对象才会调用更新API,因此保持相同的对象(例如Walls或非活动平台)甚至不需要执行这样的diff。另外,服务器可以进一步节省发送的信息,方法是在客户端进入视图之前不向客户端发送不可见的客户端对象。例如,在Quake2中,一个级别被划分为视图区域,并且一个区域(及其内部的所有对象)被视为&#34;在视图之外&#34;如果它们之间的所有门都关闭,则从另一个开始。
请记住,服务器不需要客户端拥有完整的游戏状态,只需要一个场景图,这需要更简单的序列化,并且绝对没有指针(在Quake中,它实际上是在一个静态大小的数组,它也限制了游戏中对象的最大数量。)
除此之外,还有玩家健康,弹药等内容的UI数据。同样,每个玩家只能获得自己的健康和弹药发送给他们,而不是服务器上的每个人。服务器没有理由共享该数据。
<强>更新强>
为了确保我获得最准确的信息,我仔细检查了代码。这是基于Quake3,而不是Quake Live,所以有些事情可能会有所不同。发送给客户端的信息基本上封装在一个名为snapshot_t
的结构中。这包含当前播放器的单个playerState_t
,以及可见游戏实体的256 entityState_t
数组,以及一些额外的整数,以及表示&#34;位掩码的字节数组区域&#34;
entityState_t
由22个整数,4个向量和2个轨迹组成。 A&#34;轨迹&#34;是一种数据结构,用于表示物体在没有任何反应时通过空间的移动,例如,弹道运动,或直线飞行。它是2个整数,2个向量和一个枚举,它们在概念上可以存储为一个小整数。
playerState_t
稍微大一些,包含大约34个整数,大约8个整数数组,大小从2到16,每个都有4个向量。这包含从武器的当前动画帧到玩家的库存,以及玩家正在制作的声音。
由于使用的结构具有预设的尺寸和结构,因此制作简单的手册&#34;差异&#34;功能,比较每个成员,每个成员都相当简单。但是,据我所知,entityState_t
和playerState_t
只是全部发送,而不是部分发送。唯一的事情是&#34; delta&#34; ed是哪些实体被发送,作为snapshot_t
中实体数组的一部分。
虽然快照最多可包含256个实体,但游戏本身最多可包含1024个实体。这意味着,从客户端的角度来看,只有25%的对象可以在一个帧中更新(任何更多会导致臭名昭着的数据包溢出&#34;错误)。服务器只是跟踪哪些对象有意义的移动,并发送它们。它比执行实际差异要快得多 - 只需发送任何名为&#34; update&#34;它本身就位于播放器的可见区域位掩码内。但从理论上讲,手写的每个结构差异并不会那么难。
对于团队健康,虽然Quake3似乎没有这样做,所以我只能推测它是如何在Quake Live中完成的。有两种选择:发送所有playerState_t
结构,因为它们最多有64个,或者在playerState_t
中添加另一个数组以保持团队HP,因为它只有64个整数。后者更有可能。
为了使对象数组在客户端和服务器之间保持同步,每个实体都有一个0到1023的实体索引,并且它作为消息的一部分从服务器发送到客户端。当客户端收到256个实体的数组时,它会通过数组,从每个实体读取索引字段,并在其本地存储的实体数组中读取读取索引处的实体。
答案 1 :(得分:3)
我建议退一步,环顾四周,以找到更好的解决方案。
正如你所提到的,游戏状态真的非常复杂和巨大。因此,智能压缩(差异,紧凑的序列化形式等)不太可能有所帮助。最终,需要转移真正的大差异,因此游戏体验将受到影响。
为了使故事简短,我建议回顾两个数字(将提供链接到来源)。
您可以将用户操作转换为函数调用,这将改变游戏状态:
或者您可以创建相应的命令/操作,让命令执行程序处理它以异步方式更改状态:
看起来差异可能很小,但第二种方法可以让你:
我刚刚描述了Command Pattern,这可能会非常有帮助。它带给我们computed state
的想法。如果命令行为是确定性的(应该是),为了获得一个新状态,你只需要一个前一个和一个命令。
因此,拥有初始状态(对于每个玩家都相同,或者在游戏开始时转移),只需要其他命令来进行状态增量。
我将使用预写和命令记录作为示例(让我们想象您的游戏状态在数据库中),因为几乎同样的问题正在解决,我已经尝试提供detailed explanation:
这种方法还允许简化状态恢复等,因为与generate new state, compare to the previous one, generate diff, compress diff, send diff, apply diff
序列相比,动作执行可能真的更快。
无论如何,如果您仍然认为发送差异更好,只需尝试让您的状态足够小(因为您将至少有两个快照),并且内存占用空间小且容易阅读。
在这种情况下,diff程序将生成sparsed数据,因此任何流压缩算法都将轻松产生良好的压缩因子。顺便说一句,确保您的压缩算法不需要更多内存。最好不要使用任何基于字典的解决方案。
Arithmetic coding,Radix tree可能会有所帮助。以下是您的想法和实施:
public static void encode(int[] buffer, int length, BinaryOut output) {
short size = (short)(length & 0x7FFF);
output.write(size);
output.write(buffer[0]);
for(int i=1; i< size; i++) {
int next = buffer[i] - buffer[i-1];
int bits = getBinarySize(next);
int len = bits;
if(bits > 24) {
output.write(3, 2);
len = bits - 24;
}else if(bits > 16) {
output.write(2, 2);
len = bits-16;
}else if(bits > 8) {
output.write(1, 2);
len = bits - 8;
}else{
output.write(0, 2);
}
if (len > 0) {
if ((len % 2) > 0) {
len = len / 2;
output.write(len, 2);
output.write(false);
} else {
len = len / 2 - 1;
output.write(len, 2);
}
output.write(next, bits);
}
}
}
public static short decode(BinaryIn input, int[] buffer, int offset) {
short length = input.readShort();
int value = input.readInt();
buffer[offset] = value;
for (int i = 1; i < length; i++) {
int flag = input.readInt(2);
int bits;
int next = 0;
switch (flag) {
case 0:
bits = 2 * input.readInt(2) + 2;
next = input.readInt(bits);
break;
case 1:
bits = 8 + 2 * input.readInt(2) +2;
next = input.readInt(bits);
break;
case 2:
bits = 16 + 2 * input.readInt(2) +2;
next = input.readInt(bits);
break;
case 3:
bits = 24 + 2 * input.readInt(2) +2;
next = input.readInt(bits);
break;
}
buffer[offset + i] = buffer[offset + i - 1] + next;
}
return length;
}
最后一句:不要公开状态,不要传输它,而是计算它。这种方法将更快,易于实现和调试。
答案 2 :(得分:1)
比较位时,是否有效存储每个更改位的位置取决于更改位数。在32位系统中,每个位置占用32位,因此仅当32位中的少于1位时才有效。但是,由于更改的位通常是相邻的,因此如果以较大的单位(例如字节或单词)进行比较,效率会更高。
请注意,比较位的方法仅在位的绝对位置不发生变化时才有效。但是,如果在中间插入或删除了某些位,则算法将在插入/移除位置后几乎每个位都看到更改。为了缓解这种情况,您需要计算最长的公共子序列,因此仅在A或B中的位是插入/移除的位。
但是比较JSON对象不必按位进行。 (如果必须,它与比较两个位串相同。)比较可以在更高级别进行。计算两个abritrary JSON对象差异的一种方法是编写一个带A和B的函数:
也可以使用最长的公共子序列计算两个字符串之间的差异。