Files
obsidian-notes/InBox/zhihu_replay_system.md
Build Bot f7310caea0 同步
2026-05-18 01:20:38 +08:00

10 KiB
Raw Blame History

title, source, created, tags
title source created tags
回放系统让你的游戏时光倒流 - 知乎 https://zhuanlan.zhihu.com/p/2031265691286947108 2026-05-09 17:43
zhihu
article
game-dev
replay-system
sync-mechanism

回放系统让你的游戏时光倒流 - 知乎

来源: https://zhuanlan.zhihu.com/p/2031265691286947108


打一盘很爽快的游戏或者遇到了恼人的bug此时你一定想要一个“月光宝盒”能回放你的游戏过程。然而在很多游戏的初始开发阶段回放系统的优先级都是比较低的等到真正要去实现回放系统时发现现有的一些架构会带来很多困难不免感叹“如果当初...”。

我们不妨假设拥有了“月光宝盒”,回到游戏最初开发的阶段,看下怎么设计好一个回放系统。

回放深度绑定同步机制

回放的前提是录制游戏运行数据然后根据数据重新执行游戏流程。录制时的性能消耗、数据的存储大小以及重新运行数据的复杂程度都是必须考虑的因素。再考虑到为了复现bug的目的回放时要尽可能走正常游戏时一样的数据处理逻辑。稍加思考就能发现不同的游戏同步机制下这些问题的解决方案会大相径庭。所以我们也必须区分讨论。

状态同步下的回放方案

采用状态同步机制的游戏,客户端表现主要是通过与服务器的交互状态数据包驱动。那么,直接将客户端收发的数据按照时间顺序保存下来,然后重新创建一场游戏去加载这些数据包,能实现回放吗?很遗憾,这种方案是行不通的。

1P困境

因为1P玩家的往来数据包并不能完全还原当时的操作比如最基础的位置信息客户端通常是先依据操作计算出位置然后将位置发送给服务器服务器进行一定的校验后如果通过则不会返回任何数据给客户端可以看出1P玩家的位置完全是由本地事件驱动的先不考虑弱网情况

为了极致的手感很多操作都是1P本地先行触发然后上报服务器进行校验和广播。既然只保存1P的交互协议数据是不完整的那能不能同时记录本地操作的事件呢只能说原理上可行但实现上很难。

游戏中有很多的操作输入口你需要重构这些输入层统一汇总到一个管线然后在管线中执行录制操作录制的信息格式要能反映出多种类型的输入后续的响应逻辑也要按照约定的参数进行设计。想一想就知道涉及面很广涉及的人很多如果版本迭代多年后才要开发回放系统这种情况下很难推动方案的落地。况且你也不想Bug缠身天天加班不是最后如果游戏逻辑中事件响应还有随机逻辑你必将面临随机数的确定性问题估计又要打一个很大的补丁。

换个观察者视角

揉一揉疲惫的脑袋想一下游戏比赛中转播的画面是怎么做的主持人选择一个目标角色画面就能切换到这个玩家的视角。实际上服务器会为主持人创建一个observer设定其观战某个target玩家所有要转发给target的数据包都会转发给observer。

假如给开启回放的玩家创建一个隐藏的observer然后把target设置为自己可行么这绝对是可行的。客户端只要保存observer相关的数据包就能用来回放。

数据包回放

保存数据包时同时记录其时间再回放时可以设定一定的帧率比如30帧驱动客户端Update。网络层并不发起真正的socket连接而是直接设定为已连接状态网络模块Update改为从回放数据包中取数据。依照时间取出所有33ms内的数据包数据包处理走正常3P一样的处理逻辑。

在移动设备上如果每条数据包都立刻落盘到文件累积会有很大的IO消耗。可以采用批量写入的方案积累到一定条数的数据包以后统一写入文件。写入文件的操作可以放到单独的线程中执行。

性能“打脸”

当你将功能自测完毕,交由性能测试时,无情的报告单显示"数据流量增长、发热出现增长"。是啊多出一个observer的数据发送必然会导致这样的结果。

你揪着越来越少的头发漫无目的地对比1P普通情况下和开启replay情况下的数据包日志文件。突然发现observer的数据包和1P数据包绝大部分都是重合的只是多了些1P没有必要的数据包。你突然有了精神能不能使用1P的数据包伪造出observer的数据包呢

基于差分的缝合怪

经过一番代码排查最终确认只要为1P补发一些observer初始化数据以及几大类1P操作对应的广播数据包就行。那些广播数据包通常都有集中的逻辑流管线处理起来并不麻烦。

此方案完美地解决了流量增长和发热增加的性能问题。

功能交付QA测试后又报过来一个新的问题总是感觉角色移动和一些关键操作慢了一拍这是为什么呢问题在于observer下发数据包自带的延迟网络延迟+处理延迟)!

如果简单地为这些关键数据包统一做延迟修正,能有所改善,但是延时设置为多少是个问题。设置过大可能会早于实际的操作发生时间,就会与其他正常的数据包时间错位,产生“穿帮”,设想下被击中者还没有走到位置,你的击中操作已经显示了,这是无法接受的。

此时就要采用非常规的手段了能不能将这些关键的数据包对应的IP发送包C2S包处理成S2C包呢这样时间肯定是正确的避免强行修正延迟导致的数据包之间的时间错乱问题。数据包格式的转换也并不复杂大部分字段都是复用的仅需要做简单的头部调整即可。

快进和回退的难处

在回放时,快进和回退是必不可少的功能。要实现快进,只要在极短的时间里快速处理跳过时间段里的所有数据包即可。很显然,如果是小时间段的快进,处理会很顺畅,但是如果跳过的时间段非常大,那么快进消耗的时间就很明显了,必须考虑做遮罩处理避免看起来画面卡住。

虽然通过一定时间间隔保存全量快照的方式可以缓解长时间段快进的耗时(直接调到最近的快照点),但这会给录制带来计算和存储压力,不一定划算。

回退的问题更大,因为你无法从当前状态通过向后回退数据包而把场景也回退了。唯一的方法只能是从头重新播放,然后以快进的方式调到回退点,让玩家看起来在回退。

帧同步就比较简单了

因为帧同步天然的“只同步输入不同步状态”只要输入一致客户端演算的结果就一致所以录像就比较简单了只要全部录制发送和接收的操作指令即可。而且因为输入指令的数据量很小所以整场比赛的录像数据也很小存储和IO压力可以忽略不计。

至于快进和回放面临的问题与状态同步下的问题本质一样,这里不再赘述。不过,采用帧同步的游戏,场景实体数量通常较少,所以可以更多地考虑关键帧快照的优化方式。

更酷的死亡回放

战斗中被人击杀了你一定很想立刻知道“TM谁杀了我”。死亡回放可以让你查看死亡前几秒内发生的事情一定对你的“复仇大业”很有帮助。

死亡回放的关键是要服务器时刻为每个玩家保存一段可回放数据包因为你不知道自己会被谁杀死只有在被击杀的时候你才能确定要回放的“凶手”视角。毫无疑问直接为所有玩家保存回放数据包服务器的压力会很大。好在通常也只需要保存不到10s的时间段所以还能接受。显然这种不完整的录像数据段必须配合全量快照才能工作。

以10s为例要设计一套双缓冲存储机制。假设命名为P缓冲和N缓冲。当P写满后开始写入N如果N写满后再次写入P如此往复。每次写入新的缓冲时都生成服务器全量快照。当死亡点发生再N中时 N_d 则从P的全量快照开始恢复游戏场景然后播放录像到N_d。

关键是断线重连

对于有复活机制的玩法,播放死亡回放时玩家还在游戏对局中,那么服务器怎么看待这个玩家的状态呢?如果当作在线的玩家,一些广播的数据包就还要通知客户端,这必然导致和正在播放的死亡回放场景冲突。另外,玩家死亡回放播放结束后,还要回到正在进行的游戏场景中。

这段时间在服务器看来如同客户端发生了断线重连一样。是的,关键就是断线重连!依靠断线重连,如同为死亡回放在客户端构建了一个平行宇宙,完全不会对服务器架构和逻辑产生过多的影响。而且,重连机制通常是游戏的基础设施之一,直接拿来用“何乐而不为”。

当然,死亡回放是模拟断线重连的表现,但是并不能真的完全断线,一些重要的即时数据还是要接收的,比如被复活的消息。可以制定协议白名单,处理这些特殊的逻辑,既保证不影响死亡回放的场景,又能让玩家不漏掉重要信息。

清理场景的脏活儿

为了死亡回放而清理场景中的实体以及HUD资源并非想象中容易对于迭代了几年的游戏来说并不一定有统一的卸载逻辑收口难免需要人工排查清理还有一些全局的数据状态都要特别处理后续添加的新逻辑也都要考虑死亡回放的情况。因进出死亡回放引发的bug也经常发生此为头痛之事尚没有好的解决办法。

性能优化的好帮手

性能优化的一大难点是如何验证优化是有效的,可重现的基准场景能提供极大的帮助,而回放系统就能提供可重现的基准场景。以同样的录像数据包,在优化前和优化后的客户端中播放录像,对比性能数据能有力的验证优化的效果,因为能排除玩家数量、操作行为、时序等变量的干扰。所以,早日实现回放系统能给你带来很多意想不到的好处。