--- title: 回放系统让你的游戏时光倒流 - 知乎 source: https://zhuanlan.zhihu.com/p/2031265691286947108 created: 2026-05-09 17:43 tags: [zhihu, article, game-dev, replay-system, sync-mechanism] --- # 回放系统让你的游戏时光倒流 - 知乎 **来源:** [https://zhuanlan.zhihu.com/p/2031265691286947108](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也经常发生,此为头痛之事,尚没有好的解决办法。 性能优化的好帮手 性能优化的一大难点是如何验证优化是有效的,可重现的基准场景能提供极大的帮助,而回放系统就能提供可重现的基准场景。以同样的录像数据包,在优化前和优化后的客户端中播放录像,对比性能数据能有力的验证优化的效果,因为能排除玩家数量、操作行为、时序等变量的干扰。所以,早日实现回放系统能给你带来很多意想不到的好处。