如何设计重放系统


75

那么我将如何设计一个重放系统?

您可能从某些游戏(例如《魔兽争霸3》或《星际争霸》)中知道了这一点,您可以在游戏玩完后再次观看。

您最终得到一个相对较小的重播文件。所以我的问题是:

  • 如何保存数据?(自定义格式?)(小文件大小)
  • 应该保存什么?
  • 如何使其具有通用性,使其可以在其他游戏中用于记录时间段(例如,不是完整的比赛记录)?
  • 使前进和后退成为可能(据我记得,WC3无法后退)

3
尽管下面的答案提供了很多有价值的见解,但我只是想强调将游戏/引擎开发为具有高度确定性的重要性(en.wikipedia.org/wiki/Deterministic_algorithm),因为这对于实现您的目标至关重要。
阿里·帕特里克

2
还要注意,物理引擎不是确定性的(Havok声称是...),因此,如果您的游戏使用物理,则仅存储输入和时间戳的解决方案每次都会产生不同的结果。
Samaursa 2010年

5
只要您使用固定的时间步,大多数物理引擎都是确定性的,无论如何您都应该这样做。如果Havok没有,我会感到非常惊讶。非确定性在计算机上很难实现...

4
确定性意味着相同的输入=相同的输出。如果您在一个平台上有浮点数,而在另一个平台上则成倍增加(例如),或者故意禁用了IEEE浮点标准实现,则意味着您没有使用相同的输入,不是说它不是确定性的。

3
是我,还是这个问题每隔两周得到悬赏?
共产党鸭子

Answers:


39

这篇出色的文章涵盖了很多问题:http : //www.gamasutra.com/view/feature/2029/developing_your_own_replay_system.php

本文提到并做得很好的一些事情:

  • 您的游戏必须具有确定性。
  • 它在第一帧记录游戏系统的初始状态,并且仅记录游戏过程中的玩家输入。
  • 量化输入以降低位数。就是 表示在各种范围内的浮点数(例如,[0,1]或[-1,1]范围内的位数更少)。在实际游戏过程中也必须获得量化的输入。
  • 使用一位来确定输入流是否具有新数据。由于某些流不会频繁更改,因此会利用输入中的时间一致性。

在大多数情况下,进一步提高压缩率的一种方法是将所有输入流解耦,并对它们进行完整的行程编码。如果您以8位编码运行并且运行本身超过8帧(除非您的游戏是真正的按钮混搭程序,否则很有可能),这将是delta编码技术的胜利。我在赛车游戏中使用了此技术,压缩了2位玩家的8分钟输入,同时在赛道上缩小了几百个字节。

在使此类系统可重用方面,我使重放系统处理了通用输入流,但还提供了挂钩,以允许特定于游戏的逻辑将键盘/游戏手柄/鼠标输入编组到这些流中。

如果您想快退或随机寻道,可以每N帧保存一个检查点(您的完整游戏状态)。应该选择N以减小重播文件的大小,并确保在将状态重播到所选点时播放器必须等待的时间是合理的。解决此问题的一种方法是确保只能对这些确切的检查点位置进行随机搜索。快退是将游戏状态设置为紧挨相关帧之前的检查点的问题,然后重播输入内容直至到达当前帧。但是,如果N太大,则可能每隔几帧就会出现故障。缓解这些故障的一种方法是在从当前检查点区域回放缓存的帧时,异步地预先缓存前两个检查点之间的帧。


如果涉及RNG,则将所述RNG的结果包括在流中
棘手怪胎2014年

1
@ratchet freak:通过确定性地使用PRNG,您可以在检查点期间仅存储其种子。
NonNumeric 2014年

22

除了“确保击键可重播”解决方案(这可能非常困难)之外,您还可以只记录每一帧的整个游戏状态。稍加压缩,即可将其大幅压缩。这就是Braid处理其倒带代码的方式,并且效果很好。

由于无论如何都需要倒退检查点,因此您可能只想在复杂的事情之前尝试以简单的方式实现它。


2
+1通过一些巧妙的压缩,您可以真正减少需要存储的数据量(例如,与当前对象的上次存储状态相比,如果状态没有变化,请不要存储状态) 。我已经在物理上尝试过了,并且效果很好。如果您没有物理知识并且不想倒带完整的游戏,那么我会选择Joe的解决方案,因为它会产生尽可能小的文件,在这种情况下,如果您也想倒带,则可以只存储最后的n几秒钟。游戏。
Samaursa 2010年

@Samaursa-如果您使用标准的压缩库(例如gzip),则将获得相同(可能更好)的压缩,而无需手动执行诸如查看状态是否已更改的操作。
贾斯丁

2
@克拉根:不是真的。标准压缩库当然不错,但是通常无法利用特定领域的知识。如果您可以帮助他们一点点,那就是将相似的数据放在相邻的位置,然后剔除那些实际上没有改变的东西,那么您就可以大幅度地减少麻烦。
ZorbaTHut 2010年

1
@ZorbaTHut从理论上讲是的,但是实际上值得这样做吗?
贾斯汀2010年

4
是否值得付出努力完全取决于您拥有多少数据。如果您拥有包含成百上千个单位的RTS,则可能很重要。如果需要像Braid这样将重播存储在内存中,则可能很重要。

21

您可以将系统视为由一系列状态和函数组成,其中f[j]带有输入的函数x[j]会将系统状态更改s[j]为state s[j+1],如下所示:

s[j+1] = f[j](s[j], x[j])

状态是对整个世界的解释。玩家的位置,敌人的位置,分数,剩余的弹药等。画出游戏框架所需的一切。

功能是可能影响世界的任何事物。帧更改,按键,网络数据包。

输入是函数获取的数据。更改帧可能要花费自上一帧通过以来的时间,按键可能包括实际按下的按键以及是否按下了Shift键。

为了便于说明,我将作以下假设:

假设1:

给定游戏运行的状态数量远大于功能的数量。您可能有成千上万个状态,但只有几十个功能(帧更改,按键,网络数据包等)。当然,输入量必须等于状态量减一。

假设2:

存储单个状态的空间成本(内存,磁盘)比存储函数及其输入的空间成本大得多。

假设3:

呈现状态的时间成本(时间)相近,或者比在状态上计算函数的时间成本(时间)长一个或两个数量级。

根据您的重放系统的要求,有几种方法可以实现重放系统,因此我们可以从最简单的方法开始。我还将用棋盘游戏作一个小例子,记录在纸上。

方法1:

商店s[0]...s[n]。这非常简单,非常简单。由于假设2,其空间成本相当高。

对于国际象棋,这将通过为每一步绘制整个棋盘来完成。

方法2:

如果只需要向前重放,则可以简单地存储s[0],然后存储f[0]...f[n-1](请记住,这只是函数ID的名称)和x[0]...x[n-1](这些函数的输入是什么)。要重播,您只需从开始s[0],然后计算

s[1] = f[0](s[0], x[0])
s[2] = f[1](s[1], x[1])

等等...

我想在这里做一个小的注释。其他几位评论员说,这款游戏“必须具有确定性”。凡是这样说的人都需要重新获得计算机科学101的资格,因为除非您的游戏要在量子计算机上运行,​​否则所有计算机程序都是确定的¹。这就是使计算机如此出色的原因。

但是,由于您的程序很可能依赖于外部程序,从库到CPU的实际实现,因此确保平台之间的功能相同可能非常困难。

如果使用伪随机数,则可以将生成的数字存储为输入的一部分,也可以x将prng函数的状态存储为状态的一部分s,并将其实现存储为function的一部分f

对于国际象棋,这可以通过绘制初始棋盘(已知)来完成,然后描述每个动作并说明哪一块去了哪里。顺便说一下,这就是他们实际的做法。

方法3:

现在,您很可能希望能够进行重放。也就是说,计算s[n]任意n。通过使用方法2,您需要先进行计算,s[0]...s[n-1]然后才能s[n]根据假设2 进行计算,这可能会非常慢。

为了实现这一点,方法3是方法1和2的概括:存储f[0]...f[n-1]x[0]...x[n-1]就像方法2一样s[j],对于j % Q == 0给定的常量,都存储Q。简单来说,这意味着您将书签存储在每个Q州中的一个州。例如,对于Q == 100,您存储s[0], s[100], s[200]...

为了计算s[n]任意值n,您首先加载先前存储的s[floor(n/Q)],然后从floor(n/Q)到计算所有函数n。最多,您将在计算Q函数。较小的值Q可以更快地计算但会占用更多的空间,而较大的值会Q消耗较少的空间,但需要较长的时间来计算。

方法3与Q==1方法1相同,而方法3与Q==inf方法2相同。

对于国际象棋,这可以通过绘制每一个动作以及每10个棋盘中的一个来完成Q==10

方法4:

如果要反转重播,可以使方法3的微小变化假设Q==100,你要计算s[150]通过s[90]反向。使用未经修改的方法3,您将需要进行50次计算s[150],然后再进行49次计算s[149],依此类推。但是,由于您已经计算过s[149]要获取get s[150],因此可以在第一次s[100]...s[150]计算时使用来创建一个缓存s[150],然后s[149]在需要显示它时就已经在缓存中。

对于任何给定的值s[j],只需要在每次计算时重新生成缓存即可。这次,增加将导致较小的大小(仅用于高速缓存),但时间较长(仅用于重新创建高速缓存)。如果您知道计算状态和函数所需的大小和时间,则可以计算出的最佳值。j==(k*Q)-1kQQ

对于国际象棋,这可以通过绘制每一个动作以及每10个棋盘中的一个完成(对于Q==10)来完成,但是,还需要绘制一张单独的纸,即您计算出的最后10个棋盘。

方法5:

如果状态仅占用太多空间,或者函数占用太多时间,则可以创建一个实际上实现(而非伪造)反向重放的解决方案。为此,必须为每个功能创建反向功能。但是,这要求您的每个功能都是注入。如果这是可行的,那么为了f'表示函数的逆f,计算s[j-1]就像

s[j-1] = f'[j-1](s[j], x[j-1])

请注意,此处的函数和输入均为j-1,不是j。如果您要进行计算,则将使用相同的函数和输入

s[j] = f[j-1](s[j-1], x[j-1])

创建这些函数的逆函数是棘手的部分。但是,您通常不能这样做,因为游戏中的每个功能通常都会丢失一些状态数据。

此方法可以按原样反向计算s[j-1],但前提是您有s[j]。这意味着从您决定向后重放的点开始,您只能向后观看重放。如果要从任意点向后回放,则必须将此方法与方法4混合使用。

对于国际象棋,这是无法实现的,因为对于给定的棋盘和先前的移动,您可以知道移动的是哪一块,但不知道从哪移动了。

方法6:

最后,如果不能保证所有功能都是注入,则可以通过一些小技巧来实现。除了让每个函数只返回一个新的状态,您还可以让它返回它丢弃的数据,如下所示:

s[j+1], r[j] = f[j](s[j], x[j])

r[j]丢弃的数据在哪里。然后创建您的反函数,以使它们采用丢弃的数据,如下所示:

s[j] = f'[j](s[j+1], x[j], r[j])

f[j]和之外x[j],还必须r[j]为每个函数存储。再一次,如果您希望能够搜索,则必须存储书签,例如方法4。

对于国际象棋,这与方法2相同,但是与方法2不同,方法2仅说明哪一块棋子到达何处,还需要存储每块棋子来自何处。

实现方式:

由于这适用于特定游戏的各种状态,各种功能,因此您可以做几个假设,这将使其易于实现。实际上,如果您在整个游戏状态下实现方法6,则不仅可以重播数据,还可以及时返回并从任何给定时刻恢复播放。那真是太棒了。

除了存储所有游戏状态外,您还可以简单地存储绘制给定状态所需的最低要求,并每隔固定的时间序列化此数据。您的状态将是这些序列化,而您的输入现在将是两个序列化之间的差异。它们起作用的关键是,如果世界状态也几乎没有变化,则序列化应该几乎不变。这种差异是完全不可逆的,因此使用书签实现方法5是非常可能的。

我已经在一些主要游戏中实现了此功能,主要是当事件(fps片段减少或体育游戏中的得分)发生时即时回放最近的数据。

我希望这个解释不要太无聊。

¹这并不意味着某些程序的行为就像不确定的(例如MS Windows ^^)。现在说真的,如果您可以在确定性计算机上进行非确定性程序,则可以肯定地说,您将同时赢得菲尔兹奖章,图灵奖,甚至还有可能获得奥斯卡奖和格莱美奖。


在“所有计算机程序都是确定的”上,您忽略了考虑依赖线程的程序。尽管线程主要用于加载资源或分隔渲染循环,但还有一些例外,此时,除非您对执行确定性有严格的严格要求,否则您可能不再能够主张真正的确定性。仅锁定机制是不够的。如果没有其他额外的工作,您将无法共享任何可变数据。在许多情况下,游戏本身并不需要那种严格的要求,但可能需要重玩。
krdluzni 2011年

1
@krdluzni来自真正随机源的线程,并行性和随机数不会使程序具有不确定性。线程时间,死锁,未初始化的内存甚至竞争条件只是程序需要接受的其他输入。您选择放弃这些输入,甚至根本不考虑它们(无论出于何种原因),这不会影响您的程序在给定完全相同的输入的情况下将执行完全相同的事实。“不确定性”是一个非常精确的计算机科学术语,因此,如果您不知道它的含义,请避免使用它。

@oscar(可能有点简洁,忙碌,可能稍后再编辑):尽管从某种严格的理论意义上讲,您可以要求线程定时等作为输入,但这在任何实际意义上都没有用,因为它们通常不能被观察到。自己编程或完全由开发人员控制。此外,未确定性的程序与未确定性的程序(在状态机意义上)明显不同。我确实了解该词的含义。我希望他们选择了其他东西,而不是使先前的术语变得多余。
krdluzni 2011年

@krdluzni设计具有不可预测元素(例如线程时间)(如果它们影响您准确计算重放的能力)的重放系统时,我的观点是像对待任何其他输入源一样对待它们,就像处理用户输入一样。我看不到有人抱怨程序是“不确定的”,因为它需要完全无法预测的用户输入。至于术语,这是不准确和混乱的。我宁愿让他们使用诸如“实际上不可预测”之类的东西。不,这不是不可能,请检查VMWare的重放调试。

9

其他答案尚未涵盖的一件事是漂浮的危险。您不能使用浮点数创建完全确定的应用程序。

使用浮点数,您可以拥有一个完全确定性的系统,但前提是:

  • 使用完全相同的二进制文件
  • 使用完全相同的CPU

这是因为浮点数的内部表示从一个CPU到另一个CPU有所不同-在AMD和Intel CPU之间最为明显。只要值在FPU寄存器中,它们的精度就比C端的精度高,因此任何中间计算都将以更高的精度进行。

很明显,这将如何影响AMD与Intel位-例如,假设一个使用80位浮点数,而另一个使用64位浮点数-但是为什么要使用相同的二进制要求呢?

如我所说,只要值在FPU寄存器中,就可以使用更高的精度。这意味着无论何时重新编译,编译器优化都可能在FPU寄存器内外交换值,从而导致结果略有不同。

您可能可以通过设置_control87()/ _ controlfp()标志来使用可能的最低精度来提供帮助。但是,某些库也可能会碰到这一点(至少某些d3d版本这样做了)。


3
使用GCC,您可以使用-ffloat-store强制将值从寄存器中移出并截断为32/64位精度,而不必担心其他库会使您的控制标志混乱。显然,这会对您的速度产生负面影响(但其他量化也会影响速度)。

8

保存您的随机数生成器的初始状态。然后保存,加盖时间戳,每个输入(鼠标,键盘,网络等)。如果您有网络游戏,则可能已经具备了所有功能。

重新设置RNG并播放输入。而已。

这不能解决重绕问题,因为没有从头开始尽可能快地回放的方法,没有通用解决方案。您可以通过每X秒检查一次整个游戏状态来提高性能,然后只需要重播那么多游戏即可,但是整个游戏状态的获取成本也可能非常高。

文件格式的详细信息无关紧要,但是大多数引擎已经有一种方法可以序列化命令和状态-用于联网,保存等。只是使用它。


4

我会投票反对确定性重放。每1 / N秒保存每个实体的状态更容易实现FAR且不易出错。

只保存要在回放中显示的内容-如果只是位置和方向,还可以,如果您还想显示统计信息,也可以保存,但通常尽可能少保存。

调整编码。尽可能少地使用所有内容。只要看起来足够好,重播就不必是完美的。即使您使用浮点数作为标题,也可以将其保存为一个字节,并获得256个可能的值(1.4º精度)。对于您的特定问题,这可能足够,甚至太多。

使用增量编码。除非您的实体进行传送(如果确实如此,则将它们分开对待),将位置编码为新位置和旧位置之间的差异-对于短距离的移动,您获得的位数远少于完整位置所需的位数。

如果要轻松倒带,请每N帧添加关键帧(完整数据,无增量)。这样,您就可以以较低的精度获得差值和其他值,如果定期重置为“ true”值,则舍入错误不会造成太大问题。

最后,用gzip压缩整个内容:)


1
不过,这取决于游戏类型。
Jari Komppa

我对此声明会非常小心。特别是对于具有第三方依赖的大型项目,保存状态可能是不可能的。在重置和重放输入时,总是可能的。
TomSmartBishop

2

这个很难(硬。首先,最重要的是阅读Jari Komppa的答案。

由于浮动结果略有不同,因此在我的计算机上进行的重播可能无法在您的计算机上进行。这很重要。

但是之后,如果您有随机数,则将种子值存储在重播中。然后加载所有默认状态并将随机数设置为该种子。从那里您可以简单地记录当前的键/鼠标状态及其持续的时间。然后使用该事件作为输入运行所有事件。

要跳动文件(这要困难得多),您需要转储MEMORY。就像每个单位都在哪里,金钱,时间长短以及所有游戏状态一样。然后快速转发,但重播所有内容,但跳过渲染,声音等,直到到达所需的时间目标为止。这可能每分钟或5分钟发生一次,具体取决于转发的速度。

要点是-处理随机数-复制输入(播放器和远程播放器)-转储文件的状态,以便跳出文件和...-使浮动不中断(是的,我不得不大喊大叫)


2

对于没有人提及此选项,我感到有些惊讶,但是如果您的游戏包含多人游戏组件,则可能已经完成了该功能所需的许多艰苦工作。毕竟,什么是多人游戏,但是尝试在(略微)不同的时间在您自己的计算机上重播其他人的动作?

再次假设您一直在研究带宽友好的网络代码,这也为您带来了较小文件大小的好处。

它在许多方面都结合了“非常确定性”和“保留所有记录”选项。您仍然需要确定性-如果您的重播本质上是机器人以与您最初玩游戏时完全相同的方式再次玩游戏,那么他们采取的任何可能产生随机结果的动作都必须具有相同的结果。

数据格式可能就像网络流量的转储一样简单,尽管我想稍微清理一下不会有什么坏处(毕竟,您不必担心重放延迟)。通过使用其他人提到的检查点机制,您可以只重玩游戏的一部分-通常,多人游戏无论如何都会经常发送完整的游戏更新状态,因此您可能已经完成了这项工作。


0

为了获得尽可能小的重播文件,您需要确保您的游戏具有确定性。通常,这涉及查看您的随机数生成器,并查看它在游戏逻辑中的使用位置。

您很可能需要具有游戏逻辑RNG,以及用于GUI,粒子效果,声音之类的其他所有RNG。完成此操作后,需要记录游戏逻辑RNG的初始状态,然后记录每帧所有玩家的游戏命令。

对于许多游戏而言,输入与游戏逻辑之间存在一定程度的抽象,其中输入被转换为命令。例如,按下控制器上的A按钮会导致将“跳转”数字命令设置为true,并且游戏逻辑会对命令做出反应,而无需直接检查控制器。这样,您只需记录会影响游戏逻辑的命令(无需记录“暂停”命令),并且该数据很可能会比记录控制器数据小。您也不必担心记录控制方案的状态,以防玩家决定重新映射按钮。

使用确定性方法而不是使用游戏状态的快照并快进到要查看的时间点,倒退是一个难题,除了在每一帧记录整个游戏状态外,您无能为力。

另一方面,快进无疑是可行的。只要您的游戏逻辑不依赖于渲染,就可以在渲染游戏的新框架之前根据需要多次运行游戏逻辑。快进的速度将取决于您的计算机。如果要大幅度跳过,则需要使用与倒带相同的快照方法。

编写依赖于确定性的重放系统,最重要的部分可能就是记录调试数据流。该调试流包含每个帧(RNG种子,实体转换,动画等)中尽可能多的信息的快照,并且能够在重播期间针对游戏状态测试记录的调试流。这样一来,您便可以在任何给定帧的末尾快速让您知道不匹配的情况。这将节省无数小时想要从未知的不确定性错误中拔出头发的时间。诸如未初始化的变量之类的简单操作会在第11小时将所有内容弄乱。

注意:如果您的游戏涉及动态内容流,或者您在多个线程或不同内核上具有游戏逻辑,请祝您好运。


0

要同时启用记录和倒带,请记录所有事件(用户生成,计时器生成,通信生成...)。

对于每个事件,记录事件的时间,更改的内容,以前的值,新值。

除非计算是随机的,否则不需要
记录计算值(在这些情况下,您也可以记录计算值,或在每次随机计算后记录对种子的更改)。

保存的数据是更改列表。
更改可以以各种格式(二进制,xml等)保存。
更改包括实体ID,属性名称,旧值,新值。

确保您的系统可以播放这些更改(访问所需实体,将所需属性更改为前进到新状态或后退到旧状态)。

例:

  • 从开始的时间= t1,实体=玩家1,属性=位置,从a更改为b
  • 从开始= t1的时间,实体=系统,属性=游戏模式,从c更改为d
  • 从开始= t2的时间,实体=玩家2,属性=状态,从e更改为f
  • 为了能够更快地快退/快进或仅记录特定的时间范围,
    关键帧是必要的-如果始终记录,请立即保存整个游戏状态。
    如果仅记录特定时间范围,则在开始时保存初始状态。


    -1

    如果您需要有关如何实现重放系统的想法,请在google中搜索如何在应用程序中实现撤消/重做。对于某些人(可能不是全部),显而易见的是,撤消/重做与游戏重播在概念上是相同的。这只是一种特殊情况,您可以倒退,然后根据应用程序寻找特定的时间点。

    您会看到没有人执行撤消/重做,抱怨确定性/不确定性,浮点变量或特定的CPU。


    撤消/重做发生在本质上是确定性的,事件驱动的和状态光的应用程序中(例如,文字处理器文档的状态仅是文本和所选内容,而不是整个布局,可以重新计算)。

    那么很明显,您从未使用过CAD / CAM应用程序,电路设计软件,运动跟踪软件或任何具有比字处理器更复杂的撤消/重做功能的应用程序。我并不是说可以复制撤消/重做的代码以在游戏上重播,只是在概念上是相同的(保存状态并稍后重播)。但是,主要数据结构不是队列而是堆栈。
    By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
    Licensed under cc by-sa 3.0 with attribution required.