当前位置: 首页 > news >正文

帧同步、快照同步与状态同步

https://zhuanlan.zhihu.com/p/564017214

 

前段时间,在Epic举办的UnrealCircle会议上,我受邀分享了一场关于“UE4回放系统”的技术演讲。不过由于时长限制,很多细节都没有得到进一步的阐述。这篇文章会在演讲的基础上拓展更多内容,更好的帮助大家去理解虚幻引擎的回放系统,建议大家结合源码进行阅读和学习。(感兴趣的朋友可以到公众号去拿PPT)
[UnrealCircle]UE4回放系统剖析 | 网易游戏 Jerish_哔哩哔哩_bilibiliwww.bilibili.com/video/BV1Z34y1n72n/?spm_id_from=333.788

回放,是电子游戏中一项常见的功能,用于记录整个比赛过程或者展示游戏中的精彩瞬间。通过回放,我们可以观摩高手之间的对决,享受游戏中的精彩瞬间,甚至还可以拿到敌方玩家的比赛录像进行分析和学习。

从实现技术角度来讲,下面的这些功能本质上都属于回放的一部分

  • 精彩瞬间展示: FIFA / 实况足球 / NBA2K / 守望先锋 / 极限竞速:地平线 / 跑跑卡丁车
  • 死亡回放: 守望先锋 / 彩虹六号 / 使命召唤 / CODM
  • 全局比赛录制、下载、播放: 守望先锋 / CSGO / Dota / LOL / 魔兽争霸 / 星际争霸 / 红色警戒 / 坦克世界 / 绝地求生 / 王者荣耀
  • 观战(常用于非实时观战): CSGo / 堡垒之夜 / Dota
  • 时光倒流:Braid / 极限竞速:地平线

彩虹6号中的击杀回放

早在20世纪90年代,回放系统就已经诞生并广泛用于即时战略、第一人称射击以及体育竞技等类型的游戏当中,而那时存储器的容量非常有限,远远无法与当今动辄几十T的硬盘相提并论,面对一场数十分钟的比赛,比赛数据该如何存储和播放?回放该如何实现?这篇文章会通过剖析UE的回放系统,来由浅入深的帮助大家理解其中的原理和细节。

概述

其实实现回放系统有三种思路,分别是:

  • 逐帧录制游戏画面
    • 播放简单,方便分享
    • 性能开销大,占用空间,不灵活
  • 逐帧录制玩家的输入操作
    • 录制数据小,灵活
    • 跳跃、倒退困难,计算一致性处理复杂
  • 定时录制玩家以及游戏场景对象的状态
    • 录制数据较少,开销可控,灵活
    • 逻辑复杂

三种方案各有优劣,但由于第一种录制画面的方案存在着“占用大量存储空间”、”加载速度慢”、“不够灵活”等比较严重的问题,我们通常采用后两种方式来实现游戏中的回放。

可以参考“游戏中的回放系统是如何实现的?”来进一步了解这三种方案

一、帧同步、快照同步与状态同步

虽然不同游戏里回放系统具体的实现方式与应用场景不同,但本质上都是对数据的记录和重现,这个过程与网络游戏里面的同步技术非常相似。举个例子,假如AB两个客户端进行P2P的连接对战,A客户端上开始时并没有关于B的任何信息。当建立连接后,B开始把自己的相关信息(坐标,模型,大小)发给A,A在自己的客户端上利用这个信息重新构建了B,完成了数据的同步。

思考一下,假如B不把这个信息发给A,而发给自己进行处理,是不是就相当于录制了自己的机器上的比赛信息再进行回放呢?

没错,网络游戏中的同步信息正是回放系统中的录制信息,因此网络同步就是实现回放系统的技术基础!

在正式介绍回放系统前,不妨先概括地介绍一下游戏开发中的网络同步技术。我们常说网络同步可以简单分为帧同步、快照同步和状态同步,但实际上这几个中文概念是国内开发者不断摸索和自创的名词,并非严格指某种固定的算法,他们有很多变种,甚至可以结合到一起去使用。

  • 帧同步,对应的英文概念是LockStep/ Deterministic Lockstep。其基本思路是每固定间隔(如0.02秒)对玩家的行为进行一次采样得到一个“Input指令” 并发送给其他所有玩家,每个玩家都缓存来自其他所有玩家的“Input指令” ,当某个玩家收到所有其他玩家的“Input指令”后,他的本地游戏状态才会推进到下一帧。

  • 快照同步,可以翻译成Snapshot Synchronization。其思想是服务器把当前这帧整个游戏世界的状态进行一个备份,然后把这个备份发送给所有客户端,客户端按照这个备份对自己的世界状态进行修改和纠正进而完成同步。(快照,对应的英文概念是SnapShot,强调的是某一时刻的数据状态或者备份。从游戏世界的角度理解,快照就是整个世界所有的状态信息,包括对象的数量、对象的属性、位置线信息等。从每个对象的角度理解,快照就是指整个对象的各种属性,比如生命值、速度这些。所以,不同场景下快照所指的内容可能是不同的。)

  • 状态同步,可以翻译成State(State Based) Synchronization。其思想与快照同步相似,也是服务器将世界的状态同步给客户端。但不同的是状态同步的粒度变得非常小(以对象或者对象的属性为单位),服务器不需要把一帧里面所有的对象状态进行保存和同步,只需要把客户端需要的那些对象以及需要的属性进行保存和发送即可。

拓展:快照同步其实是状态同步的前身,那时候整个游戏需要记录的数据量还不是很大,人们也自然的使用快照来代表整个世界在某一时刻的状态,通过定时地同步整个世界的快照就可以做到完美的网络同步。但是这种直接把整个世界的状态进行同步的过程是很耗费流量和性能的,考虑到对象的数据是逐步产生变化的,我们可以只记录发生变化的那些数据,所以就有了基于delta的快照同步。更进一步的,我们可以把整个世界拆分一下,每一帧只针对需要的对象进行delta的同步,这样就完全将各个对象的同步拆分开来,再结合一些过滤可以进一步减少没必要的数据同步,最后形成了状态同步的方案。更多关于网络同步技术的发展和细节可以参考我的文章——《细谈网络同步在游戏历史中的发展变化》。

二、UE4网络同步基础

在虚幻引擎里面,默认实现的是一套相对完善的状态同步方案,场景里面的每个对象都称为一个Actor,每个Actor都可以单独设置是否进行同步(Actor身上还可以挂N个组件,也可以进行同步),Actor某一时刻的标记Replicated属性就是所谓的状态信息。服务器在每帧Tick的时候,会去判断哪些Actor应该同步给哪些客户端,哪些属性需要进行同步,然后统一序列化成二进制(可以理解为一个当前世界状态的增量快照)发给对应的客户端,客户端在受到后还可以调用回调函数进一步处理。这种通信方式我们称为属性同步。

此外,UE里面还有一个另一种通信方式叫RPC,可以像调用本地函数那样来调用远端的函数。RPC常用于做一些跨端的事件通知,虽然并不严格属于传统意义上状态同步的范畴,但也是UE网络同步里面不可缺少的一环。

 

为了实现上面两种同步方式,UE4通过抽象分层实现了一套NetDriver + NetConnection + Channel + Actor/Uobject的同步方式(如下图)。

  • NetDriver:网络驱动管理,封装了同步Actor的基本操作,还包括初始化客户端与服务器的连接,建立属性同步记录表,处理RPC函数,创建Socket,构建并管理Connection信息,接收数据包等等基本操作。
  • Connection:表示一个网络连接。服务器上,一个客户端到一个服务器的一个连接叫一个ClientConnection。在客户端上,一个服务器到一个客户端的连接叫一个ServerConnection。
  • Channel:数据通道,每一个通道只负责交换某一个特定类型特定实例的数据信息。比如一个ActorChannel只负责处理对应Actor本身相关信息的同步,包括自身的同步以及子组件,属性的同步,RPC调用等。
更多内容参考请我的知乎专栏

>>UE中的网络同步架构

 

三、回放系统框架与原理

3.1回放系统的核心与实现思路:

结合我们前面提到的网络同步技术,假如我们现在想在游戏里面录制一场比赛要怎么做呢?是不是像快照同步一样把每帧的状态数据记录下来,然后播放的时候再去读取这些数据呢?没错!利用网络同步的思想,把游戏本身当成一个服务器,游戏内容当成同步数据进行录制存储即可。

当然对于帧同步来说,我们并不会去记录不同时刻世界的状态信息,而是把关注点放在了玩家的行为指令上(Input队列)。帧同步会默认各个客户端的初始状态完全一致,只要保证同一时刻每个指令的相同,那么客户端上整个游戏世界的推进和表现也应该是完全一样的(需要解决浮点数精度、随机数一致性问题等)。由于只需要记录玩家的行为数据,所以一旦帧同步的框架完成,其回放系统的实现是非常方便和轻量化的。

无论哪种方式,回放系统都需要依靠网络同步框架来实现。虚幻系统本身是状态同步架构,所以我们后面会把重点都放在基于状态同步的回放系统中去。

如果你想深入UE4的网络同步,好好研究回放系统是一个不错的学习途径。官方文档链接:
https://docs.unrealengine.com/4.27/en-US/TestingAndOptimization/ReplaySystem/

根据上面的阐述,我们已经得到了实现回放系统的基本思路,

  1. 录制:就像服务器网络同步一样,每帧去记录所有对象(Actor)的状态信息,然后通过序列化的方式写到一个缓存里面。
  2. 播放:拿到那个缓存数据,反序列化后赋值给场景里面对应的Actor
序列化:把对象存储成二进制的形式
反序列化:根据二进制数据的内容,反过来还原当时的对象

3.2 UE4回放系统的简单使用:

为了能有一个直观的效果,我们先尝试动手录制并播放一段回放,步骤如下。

  1. 在EpicLancher里面下载引擎(我使用的是4.26版本),创建一个第三人称的模板工程命名为MyTestRec
  2. 点击Play进入游戏后,点击“~”按钮并在控制台命令执行demorec MyTestReplay开始录制回放
  3. 随便移动人物,30秒后再次打开控制台命令执行Demostop。
  4. 再次打开控制台,命令执行demoplay MyTestReplay,可以看到地图会被重新加载然后播放刚才录制的30秒回放‍

3.3 UE4中的回放系统架构:

虚幻在NetDriver + NetConnection + Channel的架构基础上(上一节有简单描述) ,拓展了一系列相关的类来实现回放系统(ReplaySystem)

  • UReplaySubsystem:一个全局的回放子系统,用于封装核心接口并暴露给上层调用。(注:Subsystem类似设计模式中的单例类)
  • DemoNetdriver:继承自NetDriver,专门用于宏观地控制回放系统的录制与播放。
  • Demonetconnection:继承自NetConnection,可以自定义实现回放数据的发送位置。
  • FReplayHelper:封装一些回放处理数据的接口,用于将回放逻辑与DemoNetDriver进行解耦。
  • XXXNetworkReplayStreamer:回放序列化数据的存储类,根据不同的存储方式有不同的具体实现。

3.3.1数据的存储和读取概述:

在前面的示例中,我们通过命令demorec将回放数据录制到本地文件,然后再通过命令demoplay找到对应名称的录制并播放,这些命令会被UWorld::HandleDemoPlayCommand解析,进而调用到回放系统的真正入口StartRecordingReplay/ StopRecordingReplay/ PlayReplay。

入口函数被封装在在UGameinstance上并且会最终执行到回放子系统UReplaySubsystem上(注:一个游戏客户端/服务器对应一个GameInstance)。

数据的存储:

当我们通过RecordReplay开始录制回放时,UReplaySubsystem会创建一个新的DemoNetDriver并初始化DemonetConnection、ReplayHelper、ReplayStreamer等相关的对象。接下来便会在每帧结尾时通过TickDemoRecord对所有同步对象进行序列化(序列化的逻辑完全复用网络同步框架)。

由于UDemoNetConnection重写了LowLevelSend接口,序列化之后这些数据并不会通过网络发出去,而是先临时存储在ReplayHelper的FQueuedDemoPacket数组里面

不过QueuedDemoPackets本身不包含时间戳等信息,还需要再通过FReplayHelper::WriteDemoFrame将当前Connection里面的QueuedDemoPacket与时间戳等信息一同封装并写到对应的NetworkReplayStreamer里面,然后再交给Streamer自行处理数据的保存方式,做到了与回放逻辑解耦的目的。

 

数据的读取:

与数据的存储流程相反,当我们通过PlayReplay开始播放回放时,需要先从对应的NetworkReplayStreamer里面取出回放数据,然后解析成FQueuedDemoPacket数组。随后每帧在TickDemoPlayback根据Packet里面的时间戳持续不断的进行反序列化来恢复场景里面的对象。

到这里,我们已经整理出了录制和回放的大致流程和入口位置。但为了能循序渐进的剖析回放系统,我还故意隐藏了很多细节,比如说NetworkReplayStreamer里面是如何存储回放数据的?回放系统如何做到从指定时间开始播放?想弄清这些问题就不得不进一步分析回放相关的数据结构与组织思想。

3.3.2回放数据结构的组织和存储:

无论通过哪种方式实现回放都一定会涉及到快进,暂停,跳转等类似的功能。然而,我们目前使用的方式并不能很好的支持跳转,主要问题在于虚幻引擎默认使用增量式的状态同步,任何一刻的状态数据都是前面所有状态同步数据的叠加,必须从最开始播放才能保证不丢失掉中间的任何一个数据包。比如下图的例子,如果我想从第20秒开始播放并且从第5个数据包开始加载,那么一定会丢失Actor1的创建与移动信息。

数据流在录制的时候中间是没有明确分割的,也就是所有的序列化数据都紧密的连接在一起的,无法进行拆分,只能从头开始一点点的读取并反序列化解析。中间哪怕丢了一个字节的数据都可能造成后面的数据解析乱掉。

为了解决这个问题,Unreal对数据流进行了分类

  • Checkpoint:存档点,即一个完整的世界快照(类似单机游戏中的存档),通过这个快照可以完全的回复当时的游戏状态。每隔一段时间(比如30s)存储一个checkpoint。
  • Stream:一段连续时间的数据流,存储着从上一个Checkpoint到当前的所有序列化录制数据
  • Event:记录一些特殊的自定义事件

通过这种方式,我们在任何时刻都可以找到一个临近的全局快照(Checkpoint)并进行加载,然后再根据最终目标的时间快速的读取后续的stream信息来实现目标位置的跳转。拿前面的案例来说,由于我现在在20s的时候可以通过Checkpoint的加载而得到前面Actor1在当前的状态,所以可以完美的实现跳转功能。在实际录制的时候,ReplayHelper的FQueuedDemoPacket其实有两个,分别用于存储Stream和Checkpoint。

//当前的时间DemoCurrentTime也会被序列化到FQueuedDemoPacket里面  
 TArray<FQueuedDemoPacket> QueuedDemoPackets;TArray<FQueuedDemoPacket> QueuedCheckpointPackets;

只有达到存储快照的条件时间时(可通过控制台命令设置CVarCheckpointUploadDelay InSeconds设置),我们才会调用SaveCheckpoint函数把表示Checkpoint的QueuedCheckpointPackets的写到NetworkReplayStreamer,其他情况下我们则会每帧把QueuedDemoPackets表示的stream数据进行写入处理。

void FReplayHelper::TickRecording(float DeltaSeconds, UNetConnection* Connection)
{//...省略部分代码
  FArchive* FileAr = ReplayStreamer->GetStreamingArchive();//...省略部分代码
    //录制这一帧,QueuedDemoPackets的数据写到ReplayStreamer里面
    RecordFrame(DeltaSeconds, Connection);// Save a checkpoint if it's time
    if (CVarEnableCheckpoints.GetValueOnAnyThread() == 1){check(CheckpointSaveContext.CheckpointSaveState == FReplayHelper::ECheckpointSaveState::Idle);    if (ShouldSaveCheckpoint()){SaveCheckpoint(Connection);}}
}

每次回放开始前我们都可以传入一个参数用来指定跳转的时间点,随后就会开启一个FPendingTaskHelper的任务,根据目标时间找到前面最靠近的快照,并通过UDemoNetDriver:: LoadCheckpoint函数来反序列化恢复场景对象数据(这一步完成Checkpoint的加载)。

如果目标时间比快照的时间要大,则需要在ConditionallyReadDemoFrameInto PlaybackPackets快速的把这段时间差的数据包全部读出来并进行处理,默认情况下在一帧内完成,所以玩家并无感知(数据流太大的话会造成卡顿,可以考虑分帧)。

// Buffer up demo frames until we have enough time built-up
 while (ConditionallyReadDemoFrameIntoPlaybackPackets(*GetReplayStreamer()->GetStreamingArchive())){}
// Process packets until we are caught up (this implicitly handles fast forward if DemoCurrentTime past many frames)
while (ConditionallyProcessPlaybackPackets())
{PRAGMA_DISABLE_DEPRECATION_WARNINGSDemoFrameNum++;PRAGMA_ENABLE_DEPRECATION_WARNINGSReplayHelper.DemoFrameNum++;
}

前面提到的QueuedDemoPackets只是临时缓存在ReplayHelper里,那最终序列化的Stream和Checkpoint具体存储在哪里呢?答案就是我们多次提到的NetworkReplayStreamer。在NetworkReplayStreamer里面会一直维护着StreamingAr和CheckpointAr两个数据流,DemonetDriver里面对回放数据的存储和读取本质上都是对这两个数据流的修改。

Archive可以翻译成档案,在虚幻里面是用来存储序列化数据的类。其中FArchive是数据存储的基类,封装了一些序列化/反序列化等操作的接口。我们可以通过继承FArchive来实现自定义的序列化操作。

那这两个Archive具体是如何存储和维护的呢?为了能有一个直观的展示,建议大家先去按照2.3小结的方式去操作一下,然后就可以在你工程下/Saved/Demo/路径下得到一个回放的文件。这个文件主要存储的就是多个Stream和一个checkpoint,打开后大概如下图(因为是序列化成了2进制,所以是不可读的)

接下来我们先打开LocalFileNetworkReplayStreaming.h文件,并找到StreamAr和CheckpointAr这两个成员,查看FLocalFileStreamFArchive的定义。

FLocalFileStreamFArchive继承自FArchive类,并重写了Serialize(序列化)函数,同时声明了一个TArray<uint8>的数组来保存所有序列化的数据,那些QueuedDemoPacket里面的二进制数据最终都会写到这个Buffer成员里面。不过StreamAr和CheckpointAr并不会一直保存着所有的录制数据,而是定时的把数据通过Flush写到本地磁盘里面,写完后Archive里面的数据就会清空,接着存储下一段时间的回放信息。

而在读取播放时,数据的处理流程会有一些差异。系统会尝试一次性从磁盘加载所有信息到一个用于组织回放的数据结构中——FLocalFileReplayInfo,然后再逐步的读取与反序列化,因此下图的FLocalFileReplayInfo在回放开始后其实已经完整地保存着一场录制里面的所有的序列化信息了(Chunks数组里面就存储着不同时间段的StreamAr)

FLocalFileNetworkReplayStreamer是为了专门将序列化数据写到本地而封装的类,类似的还有的用于Http发送的FHttpNetworkReplayStreamer。这些类都继承自接口INetworkReplayStreamer,在第一次执行录制的时候会通过对应的工厂类进行创建。

  • Http:把回放的数据定时的通过Http发送到一个指定url的服务器上
  • InMemory:不断的将回放数据写到内存里面,可以随时快速地取出
  • LocalFile:写到本地指定目录的文件里面,维护了一个FQueuedLocalFileRequest队列不停地按顺序处理数据的写入和加载 NetWork:各种基类接口、基类工厂
  • Null:早期默认的存储方式,通过Json写到本地文件里面,但是效率比较低(已废弃)
  • SavGame:LocalFile的前身,现在已经完全继承并使用LocalFile的实现

我们可以通过在StartRecordingReplay/ PlayReplay的第三个参数(AdditionalOptions)里面添加“ReplayStreamerOverride=XXX”来设置不同类型的ReplayStreamer,同时在工程的Build.cs里面配置对应的代码来确保模块能正确的加载。

TArray<FString> Options;
Options.Add(TEXT("ReplayStreamerOverride=LocalFileNetworkReplayStreaming"));
UGameInstance* GameInstance = GetWorld()->GetGameInstance();
GameInstance->StartRecordingReplay("MyTestReplay", "MyTestReplay", Options);
//GameInstance->PlayReplay("MyTestReplay", GetWorld(), Options);

//MyTestReplay.build.cs
DynamicallyLoadedModuleNames.AddRange(new string[] {"NetworkReplayStreaming","LocalFileNetworkReplayStreaming",//"InMemoryNetworkReplayStreaming",可选,按需配置加载
        //"HttpNetworkReplayStreaming"
    }
);
PrivateIncludePathModuleNames.AddRange(new string[] {"NetworkReplayStreaming"}
);

当然,在NetworkReplayStreamer还有许多重要的函数,比如我们每次录制或者播放回放的入口Startstream会事先设置好我们要存储的位置、进行Archive的初始化等,不同的Streamer在这些函数的实现上差异很大。

virtual void StartStreaming(const FStartStreamingParameters& Params, const FStartStreamingCallback& Delegate) = 0;
virtual void StopStreaming() = 0;
virtual FArchive* GetHeaderArchive() = 0;
virtual FArchive* GetStreamingArchive() = 0;
virtual FArchive* GetCheckpointArchive() = 0;
virtual void FlushCheckpoint(const uint32 TimeInMS) = 0;
virtual void GotoCheckpointIndex(const int32 CheckpointIndex, const FGotoCallback& Delegate, EReplayCheckpointType CheckpointType) = 0;
virtual void GotoTimeInMS(const uint32 TimeInMS, const FGotoCallback& Delegate, EReplayCheckpointType CheckpointType) = 0;
0;

3.3.3 回放架构梳理小结

到此,我们已经对整个系统有了更深入的理解,再回头看整个回放的流程就会清晰很多。

  1. 游戏运行的任何时候我们都可以通过StartRecordingReplay执行录制逻辑,然后通过初始化函数创建DemonetDriver、DemonetConnection以及对应的ReplayStreamer。
  2. DemonetDriver在Tick的时候会根据一定规则对当前场景里面的同步对象进行录制,录制的数据先存储到FQueuedDemoPacket数组里面,然后再写到自定义ReplayStreamer的FArcive里面缓存。
  3. FArcive分为StreamAr和CheckpointAr,分别用持续的录制和特定时刻的全局快照保存,里面的数据到达一定量时我们就可以把他们写到本地或者发送出去,然后清空后继续录制。
  4. 当执行PlayReplay开始回放的时候,我们先根据时间戳找到就近的CheckpointAr进行反序列化,利用快照恢复整个场景后在使用Tick去读取StreamAr里面的数据并播放。
回放系统的Connection是100%Reliable的,Connection->IsInternalAck()为true

3.4 回放实现的录制与加载细节

上个小结我们已经从架构的角度上梳理了回放录制的原理和过程,但是等很多细节问题还没有深究,比如

  • 回放时观看的视角如何设置?
  • 哪些对象应该被录制?
  • 录制频率如何设置?
  • RPC和属性都能正常录制么?
  • 加载Checkpoint的时候要不要删除之前的actor?
  • 快进和暂停如何实现?

这些问题看似简单,但实现起来却并不容易。比如我们在播放时需要动态的切换特定的摄像机视角,那就需要知道UE里面的摄像机系统,包括Camera的管理、如何设置ViewTarget,如何通过网络GUID找到对应的目标等,这些内容都与游戏玩法高度耦合,因此在分析录制加载细节前建议先回顾一下UE的Gameplay框架。

3.4.1 回放世界的Gameplay架构

虚幻的Gameplay基本是按照面向对象的方式来设计的,涉及到常见概念(类)如下

  • World:对应一个游戏世界
  • Level:对应一个子关卡,一个World可以有很多Level
  • Controller/PlayerController:玩家控制器,可以接受玩家输入,设置观察对象等。
  • Pawn/Character:一个可控的游戏单位,Character相比Pawn多了很多人型角色的功能,比如移动、下蹲、跳跃等。
  • CameraManager:所有摄像机相关的功能都通过CameraManager管理,比如摄像机的位置、摄像机震动效果等。
  • GameMode:用于控制一场比赛的规则。
  • PlayerState:用于记录每个玩家的数据信息,比如玩家的得分情况。
  • GameState:用于记录整场比赛的信息,比如比赛所处的阶段,各个队伍的人员信息等。

概括的讲,一个游戏场景是一个World,每个场景可以拆分成很多子关卡(即Level),我们可以通过配置Gamemode参数来设置游戏规则(只存在与于服务器),在Gamestate上记录当前游戏的比赛状态和进度。对于每个玩家,我们一般至少会给他一个可以控制的的角色(即Pawn/character),同时把这个角色相关的数据存储在Playerstate上。最后,针对每个玩家使用唯一的一个控制器Playercontroller来响应玩家的输入或者执行一些本地玩家相关的逻辑(比如设置我们的观察对象VIewTarget,会调用到Camermanager相关接口)。此外,PC是网络同步的关键,我们需要通过PC找到网络同步的中心点进而剔除不需要同步的对象,服务器也需要依靠PC才能判断不同的RPC应该发给哪个客户端。

回放系统Gameplay逻辑依然遵循UE的基础框架,但由于只涉及到数据的播放还是有不少需要注意的地方。

  • 在一个Level里,有一些对象是默认存在的,称为StartupActor。这些对象的录制与回放可能需要特殊处理,比如回放一开始就默认创建,尽量避免动态的构造开销
  • UE的网络同步本身需要借助Controller定位到ViewTarget(同步中心,便于做范围剔除),所以回放录制时会创建一个新的DemoPlayerController(注意:所以在本地可能同时存在多个PC,获取PC时不要拿错了)。这个Controller的主要用途就是辅助同步逻辑,而且会被录制到回放数据里面

  • 回放系统并不限制你的观察视角,但是会默认提供一个自由移动的观战对象(SpectatorPawn)。当我们播放时会收到同步数据并创建DemoPC,DemoPC会从GameState上查找SpectatorClass配置并生成一个用于观战的Pawn。我们通常会Possess这个对象并移动来控制摄像机的视角,当然也可以把观战视角锁定在游戏中的其他对象上。
  • 回放不建议录制PlayerController(简称PC),游戏中的与玩家角色相关的数据也不应该放在PC上,最好放在PlayerState或者Character上面。为什么回放不处理PC?主要原因是每个客户端只有一个PC。如果我在客户端上面录制回放并且把很多重要数据放在PC上,那么当你回放的时候其他玩家PC上的数据你就无法拿到。
  • 回放不会录制Gamemode,因为Gamemode只在服务器才有,并不做同步。

3.4.2 录制细节分析

  • 录制Stream

TickDemoRecordFrame每一帧都会去尝试执行,是录制回放数据的关键。其核心思想就是拿到场景里面所有需要同步的Actor,进行一系列的过滤后把需要同步的数据序列化。步骤如下:

  1. 通过GetNetworkObjectList获取所有Replicated的Actor
  2. 找到当前Connection的DemoPC,决定录制中心坐标(用于剔除距离过远对象)
  3. 遍历所有同步对象,通过NextUpdateTime判断是否满足录制时间要求
  4. 通过IsDormInitialStartupActor排除休眠对象
  5. 判断相关性,包括距离判定、是不是bAlwaysRelevant等
  6. 加入PrioritizedActors进行同步前的排序
  7. ReplicatePrioritizedActors对每个actor进行序列化
  8. 根据录制频率CVarDemoRecordHz/ CVarDemoMinRecordHz,更新下次同步时间NextUpdateTime
  9. DemoReplicate Actor处理序列化,包括创建通道channel、属性同步等
  10. LowLevelSend写入QueuedPacket
  11. WriteDemoFrameFrom QueuedDemoPackets将QueuedPackets数据写入到StreamArchive
在同步每个对象时,我们可以通过CVarDemoRecordHz 和CVarDemoMinRecordHz两个参数来控制回放的录制频率,此外我们也可以通过Actor自身的NetUpdateFrequency来设置不同Actor的录制间隔。

上述的逻辑主要针对Actor的创建销毁以及属性同步,那么我们常见的RPC通信在何时录制呢?答案是在Actor执行RPC时。每次Actor调用RPC时,都会通过CallRemoteFunction来遍历所有的NetDriver触发调用,如果发现了用于回放的DemoNetdriver就会将相关的数据写到Demonet connection的QueuedPackets

bool AActor::CallRemoteFunction( UFunction* Function, void* Parameters, FOutParmRec* OutParms, FFrame* Stack )
{bool bProcessed = false;FWorldContext* const Context = GEngine->GetWorldContextFromWorld(GetWorld());if (Context != nullptr){for (FNamedNetDriver& Driver : Context->ActiveNetDrivers){if (Driver.NetDriver != nullptr && Driver.NetDriver->ShouldReplicateFunction(this, Function))
{Driver.NetDriver->ProcessRemoteFunction(this, Function, Parameters, OutParms, Stack, nullptr);bProcessed = true;}}}return bProcessed;
}

然而在实际情况下,UDemoNetDriver重写了ShouldReplicateFunction/ ProcessRemoteFunction,默认情况下只支持录制多播类型的RPC。

为什么要这么做呢?

  • RPC的目的是跨端远程调用,对于非多播的RPC,他只会在某一个客户端或者服务器上面执行。也就是说,我在服务器上录制就拿不到客户端的RPC,我在客户端上录制就拿不到服务器上的RPC,总会丢失掉一些RPC。
  • RPC是冗余的,可能我们在回放的时候不想调用。比如服务器触发了一个ClientRPC(让客户端播放摄像机震动)并录制,那么回放的时候我作为一个观战的视角不应该调用这个RPC(当然也可以自定义的过滤掉)。
  • RPC是一个无状态的通知,一旦错过了就再也无法获取。回放中经常会有时间的跳转,跳转之后我们再就无法拿到前面的RPC了。如果我们过度依赖RPC做逻辑处理,就很容易出现回放表现不对的情况。

综上所述,我并不建议在支持回放系统的游戏里面频繁的使用RPC,最好使用属性同步来代替,这样也能很好的支持断线重连。

 

  • 录制Checkpoint

在每帧执行TickDemoRecord时,会根据ShouldSaveCheckpoint来决定是否触发Checkpoint快照的录制,可以通过CVarCheckpointUpload DelayInSeconds命令行参数来设置其录制间隔,默认30秒。

存储Checkpoint的步骤如下:

  1. 通过GetNetworkObjectList获取所有Replicated的Actor
  2. 过滤掉PendingKill,非DemoPC等对象并排序
  3. 构建快照上下文CheckpointSaveContext,把Actor以及对应的LevelIndex放到PendingCheckpointActors数组里面
  4. 调用FReplayHelper:: TickCheckpoint,开始分帧处理快照的录制(避免快照录制造成卡顿)。实现方式是构建一个状态机,会根据当前所处的状态决定进入哪种逻辑,如果超时就会保存当前状态在下一帧执行的时候继续
    1. 第一步是ProcessCheckpoint Actors,遍历并序列化所有Actor的相关数据
    2. 进入SerializeDeleted StartupActors状态,处理那些被删掉的对象
    3. 缓存并序列化所有同步Actor的GUID
    4. 导出所有同步属性基本信息FieldExport GroupMap,用于播放时准确且能兼容地接收这些属性
    5. 通过WriteDemoFrame把所有QueuedPackets写到Checkpoint Archive里面
    6. 调用FlushCheckpoint把当前的StreamArchive和Checkpoint Archive写到目标位置(内存、本地磁盘、Http请求等)
enum class ECheckpointSaveState
{Idle,ProcessCheckpointActors,SerializeDeletedStartupActors,CacheNetGuids,SerializeGuidCache,SerializeNetFieldExportGroupMap,SerializeDemoFrameFromQueuedDemoPackets,Finalize,
};

3.4.3 播放细节分析

  • 播放Stream:

当我们触发了PlayReplay开始回放后,每一帧都会在开始的时候尝试执行TickDemoPlayback来尝试读取并解析回放数据。与录制的逻辑相反,我们需要找到Stream数据流的起始点,然后进行反序列化的操作。步骤如下:

  1. 确保当前World没有进行关卡的切换,确保当前的比赛正在播放
  2. 尝试设置比赛的总时间SetDemoTotalTime
  3. 调用ProcessReplayTasks处理当前正在执行的任务,如果任务没有完成就返回(任务有很多种,比如FGotoTime InSecondsTask就是用来执行时间跳转的任务)
  4. 拿到StreamArchive,设置当前回放的时间(回放时间决定了当前回放数据加载的进度)
  5. 去PlaybackPackets查找是否还有待处理的数据,如果没有数据就暂停回放
  6. ConditionallyReadDemo FrameIntoPlaybackPackets根据当前的时间,读取StreamArchive里面的数据,缓存到PlaybackPackets数组里面
  7. ConditionallyProcess PlaybackPackets逐个去处理PlaybackPackets里面的数据,进行反序列化的操作(这一步是还原数据的关键,回放Actor的创建通常是这里触发的)
  8. FinalizeFastForward处理快进等操作,由于我们可能在一帧的时候处理了回放N秒的数据(也就是快进),所以这里需要把被快进掉的回调函数(OnRep)都执行到,同时记录到底快进了多少时间

 

  • 加载checkpoint:

在2.3.2小节,我们提到了UE的网络同步方式为增量式的状态同步,任何一刻的状态数据都是前面所有状态同步数据的叠加,所以必须从最开始播放才能保证不丢失掉中间的任何一个数据包。想要实现快进和时间跳跃必须通过加载最近的Checkpoint才能完成。

在每次开始回放前,我们可以给回放指定一个目标时间,然后回放系统就会创建一个FGotoTimeIn SecondsTask来执行时间跳跃的逻辑。基本思路是先找到附近的一个Checkpoint(快照点)加载,然后快速的读取从Checkpoint时间到目标时间的数据包进行解析。这个过程中有很多细节需要理解,比如我们从20秒跳跃到10秒的时候,20秒时刻的Actor是不是都要删除?删除之后要如何再去创建一个新的和10秒时刻一模一样的Actor?不妨带着这些问题去理解下面的流程。

  1. FGotoTime InSecondsTask调用StartTask开始设置当前的目标时间,然后调用ReplayStreamer的GotoTimeInMS去查找要回放的数据流位置,这个时候暂停回放的逻辑
  2. 查找到回放数据流后,调用UDemoNetDriver:: LoadCheckpoint开始加载快照存储点
    1. 反序列化Level的Index,如果当前的Level与Index标记的Level不同,需要把Actor删掉然后无缝加载目标的Level
    2. 把一些重要的Actor设置成同步立刻处理AddNonQueued ActorForScrubbing,其他不重要的Actor同步数据可以排队慢慢的处理(备注:由于在回放的时候可能会立刻收到大量的数据,如果全部在一帧进行反序列并生成Actor就会导致严重的卡顿。所以我们可以通过AddNonQueued ActorForScrubbing/ AddNonQueued GUIDForScrubbing设置是否延迟处理这些Actor对应的二进制数据)
    3. 删除掉所有非StartUp(StartUp:一开始摆在场景里的)的Actor,StartUp根据情况选择性删除(在跳转进度的时候,整个场景的Actor可能已经完全不一样了,所以最好全部删除,对于摆在场景里面的可破坏墙,如果没有发生过变化可以无需处理,如果被打坏了则需要删除重新创建)
    4. 删除粒子
    5. 重新创建连接ServerConnection,清除旧的Connection关联信息(虽然我们在刚开始播放的时候创建了,但是为了在跳跃的时候清理掉Connection关联的信息,最好把彻底的把原来connection以及引用的对象GC掉)
    6. 如果没有找到CheckpointArchive(比如说游戏只有10s,Checkpoint每30秒才录制一个,加载5s数据的时候就取不到CheckpointArchive)
    7. 反序列化Checkpoint的时间、关卡信息等内容,将CheckpointArchive里面的回放数据读取到FPlaybackPacket数组
    8. 重新创建那些被删掉的StartUp对象
    9. 获取最后一个数据包的时间用作当前的回放时间,然后根据跳跃的时长设置最终的目标时间(备注:比如目标时间是35秒,Checkpoint数据包里面最一个包的时间是30.01秒。那么还需要快进跳跃5秒,最终时间是35.01秒,这个时间必须非常精确)
    10. 解析FPlaybackPacket,反序列所有的Actor数据
  3. 加载完Checkpoint之后,接下来的一帧TickDemoPlayback会快速的读取数据直到追上目标时间。同时处理一下加载Checkpoint Actor的回调函数
  4. 回放流程继续,TickDemoPlayback开始每帧读取StreamArchive里面的数据并进行反序列化
Checkpoint的加载逻辑里面,既包含了时间跳转,也涵盖了快进的功能,只不过这个快进速度比较快,是在一帧内完成的。

除此之外,我们还提到了回放的暂停。其实暂停分为两种,一种是暂停回放数据的录制/读取,通过UDemoNetDriver:: PauseRecording可以实现暂停回放的录制,通过PauseChannels可以暂停回放所有Actor的表现逻辑(一般是在加载Checkpoint、快进、没有数据读取时自动调用),但是不会停止Tick等逻辑执行。另一种暂停是暂停Tick更新(也可以用于非回放世界),通过AWorldSetting:: SetPauserPlayerState实现,这种暂停不仅会停止回放数据包的读取,还会停止WorldTick的更新,包括动画、移动、粒子等,是严格意义上的暂停。

//这里会检查GetPauserPlayerState是否为空
bool UWorld::IsPaused() const
{// pause if specifically set or if we're waiting for the end of the tick to perform streaming level loads (so actors don't fall through the world in the meantime, etc)
  const AWorldSettings* Info = GetWorldSettings(/*bCheckStreamingPersistent=*/false, /*bChecked=*/false);return ( (Info && Info->GetPauserPlayerState() != nullptr && TimeSeconds >= PauseDelay) ||(bRequestedBlockOnAsyncLoading && GetNetMode() == NM_Client) ||(GEngine->ShouldCommitPendingMapChange(this)) ||(IsPlayInEditor() && bDebugPauseExecution) );
}//void UWorld::Tick( ELevelTick TickType, float DeltaSeconds ) 
bool bDoingActorTicks = (TickType!=LEVELTICK_TimeOnly)&&  !bIsPaused&&  (!NetDriver || !NetDriver->ServerConnection || NetDriver->ServerConnection->State==USOCK_Open);

3.5 回放系统的跨版本兼容

3.5.1 回放兼容性的意义

回放的录制和播放往往不是一个时机,玩家可能下载了回放后过了几天才想起来观看,甚至还会用已经升级到5.0的游戏版本去播放1.0时下载的回放数据。因此,我们需要有一个机制来尽可能的兼容过去一段时间游戏版本的回放数据。

先抛出问题,为什么不同版本的游戏回放不好做兼容?

答:因为代码在迭代的时候,函数流程、数据格式、类的成员等都会发生变化(增加、删除、修改),游戏逻辑是必须要依赖这些内容才能正确执行。举个例子,假如1.0版本的代码中类ACharacter上有一个成员变量FString CurrentSkillName记录了游戏角色当前的技能名字,在2.0版本的代码时我们把这个成员删掉了。由于在1.0版本录制的数据里面存储了CurrentSkillName,我们在使用2.0版本代码执行的时候必须得想办法绕过这个成员,因为这个值在当前版本里面没有任何意义,强行使用的话可能造成回放正常的数据被覆盖掉。

其实不只是回放,我们日常在使用编辑器等工具时,只要同时涉及到对象的序列化(通用点来讲是固定格式的数据存储)以及版本迭代就一定会遇到类似的问题,轻则导致引擎资源无效重则发生崩溃。

 

3.5.2 虚幻引擎的回放兼容方案

在UE的回放系统里面,兼容性的问题还要更复杂一些,因为涉及到了虚幻网络同步的实现原理。

第一节我们谈到了虚幻有属性同步和RPC两种同步方式,且二者都是基于Actor来实现的。在每个Actor同步的时候,我们会给每个类创建一个FClassNetCache用于唯一标识并缓存他的同步属性,每个同步属性/RPC函数也会被唯一标识并缓存其相关数据在FFieldNetCache结构里面。由于同一份版本的客户端代码和服务器代码相同,我们就可以保证客户端与服务器每个类的FClassNetCache以及每个属性的FFieldNetCache都是相同的。这样在同步的时候我们只需要在服务器上序列化属性的Index就可以在客户端反序列化的时候通过Index找到对应的属性。

这种方案的实现前提是客户端与服务器的代码必须是一个版本的。假如客户端的类成员与服务器对应的类成员不同,那么这个Index在客户端上所代表的成员就与服务器上的不一致,最终的执行结果就是错误的。所以对于正常的游戏来说,我们必须要保持客户端与服务器版本相同。但是对于回放这种可能跨版本执行的情况就需要有一个新的兼容方案。

思路其实也很简单,就是在录制回放数据的时候,把这个Index换成一个属性的唯一标识符(标识ID),同时把回放中所有可能用到的属性标识ID的相关信息(FNetFieldExport)全部发送过去。

通过下图的代码可以看到,同样是序列化属性的标识信息,当这个Connection是InteralACk时(即一个完全可靠不会丢包的连接,目前只有回放里面的DemonetConnection符合条件),就会序列化这个属性的唯一标识符NetFieldExportHandle。

虽然这种方式增加了同步的开销和成本,但对于回放系统来说是可以接受的,而且回放的整个录制过程中是完全可靠的,不会由于丢包而发生播放时导出数据没收到的情况。这样即使我新版本的对象属性数量发生变化(比如顺序发生变化),由于我在回放数据里面已经存储了这个对象所有会被序列化的属性信息,我一定能找到对应的同步属性,而对于已经被删掉的属性,我回放时本地代码创建的FClassNetCache不包含它,因此也不会被应用进来。

&gt;&gt;发送NetFieldExports信息

从调用流程来说,兼容性的属性序列化走的接口是SendProperties_ BackwardsCompatible_r /ReceiveProperties_ BackwardsCompatible_r,会把属性在NetFieldExports里面标识符一并发送。而常规的属性同步序列化走的接口是SendProperties_r/ReceiveProperties_r,直接序列化属性的Index以及内容,不使用NetFieldExports相关结构。

到这里,我们基本上可以理解虚幻引擎对回放系统的向后兼容性案。然而即使有了上面的方案,我们其实也只是兼容了类成员发生改变的情况,保证了不会由于属性丢失而出现逻辑的错误执行。但是对于新增的属性,由于原来存储的回放文件里面根本不存在这个数据,回放的时候是完全不会有任何相关的逻辑的。因此,所谓回放系统的兼容也只是有一定限制的兼容,想很好地支持版本差异过大的回放文件还是相对困难许多的。

四、死亡回放/精彩镜头功能的实现

在FPS游戏里,一个角色被击杀之后,往往会以敌方的视角回放本角色被定位、瞄准、射击的过程,这就是我们常提到的死亡回放(DeathCameraReplay)。类似的,我们在各种体育游戏里面经常需要在一次得分后展示精彩瞬间,这种功能一般称为精彩镜头。

上一节案例使用的是基于本地文件存储的回放系统,每次播放时都需要重新加载地图。那有没有办法实现类似实况足球的实时精彩回放呢?有的,那就是基于DuplicatedLevelCollection和内存数据流的回放方案。

思考一下,通常射击游戏里的击杀镜头、体育竞技里的精彩时刻对回放的基本需求是什么?这类回放功能往往是在某个时间点可以无感知的立刻切换到回放镜头,并在回放结束后迅速的再切换到正常的游戏环境。同时,考虑到联机的情况,我们在回放时要保持游戏世界的正常运转,从而确保不错过任何服务器的同步信息,不影响其他玩家。

简单总结就是,

  1. 可以迅速的在真实游戏与回放镜头间切换
  2. 回放的时候不会影响真实游戏里面的逻辑变化

4.1 回放场景与真实场景分离

为了实现上述的要求,我们需要将回放的场景和真实的场景进行分离,在不重新加载地图的情况下快速地进行切换。虚幻引擎给出的方案是对游戏世界World进行进一步的拆分,把所有的Level组织到了三个LevelCollection里面,分别是

  • DynamicSourceLevels,存储真实世界的所有标记为Dynamic的Level(包含里面的所有Actor)
  • StaticLevels,存储了静态的actor,也就是回放过程中不会发生变化的对象,通常指那些不可破坏建筑(通过关卡编辑器里面的Static选项,可以设置任何一个SubLevel是属于DynamicSourceLevels还是StaticLevels的,PersistLevel永远是Dynamic的)
  • DynamicDuplicatedLevels,回放世界的Level(包含里面的所有Actor),会把DynamicSourceLevels里面的所有Level都复制一遍

 

在游戏地图Loading的时候,我们就会把这三种LevelCollection全部构建并加载进来(可以通过Experimental_ShouldPreDuplicateMap来决定某张地图是否可以复制Level到DynamicDuplicatedLevels),这样在进行回放的时候我们只要控制LevelCollection的显示和隐藏就可以瞬间对真实世界和回放世界进行切换了。

判断一个对象是否处于回放世界(DynamicDuplicatedLevels)也很简单

UWorld* World = WorldContextObject->GetWorld();
ULevel* Level = Cast<ULevel>(WorldContextObject->GetTypedOuter<ULevel>());
if (World && Level)
{FLevelCollection* const DuplicateCollection = World->FindCollectionByType(ELevelCollectionType::DynamicDuplicatedLevels);if (DuplicateCollection){for (auto& TempLevel : DuplicateCollection->GetLevels()){if (TempLevel == Level){return true;}}}
}

要注意的是,由于LevelCollection的引入,原来很多逻辑都变得复杂了。

  1. 不同LevelCollection的Tick是有先后顺序的,默认情况下是按照他们在数组的排列顺序DynamicSourceLevels-> StaticLevels-> DynamicDuplicatedLevels,这个顺序可能影响我们的代码逻辑或者摄像机更新时机
  2. 回放世界DynamicDuplicatedLevels里面也会有很多Actor,如果不加处理的话很有可能也被录制到回放系统中,造成嵌套录制
  3. 当一个DynamicDuplicatedLevels执行Tick的时候,会通过FScopedLevelCollectionContextSwitch来切换当前的ActiveCollection,进而修改当前World的GameState等指针,所以在回放时需要注意获取对象的正确性。(比如下图获取PC的迭代器接口,在DuplicatedLevels Tick时只能获取到回放世界的PC)
  4. 用于回放的UDemoNetDriver会绑定一个LevelCollection(通过传入PlayReplay的参数LevelPrefixOverride来决定)。当触发回放逻辑后,即UDemoNetDriver::TickDispatch每帧解析回放数据时,我们也会通过FScopedLevelCollectionContextSwitch主动切换到当前DemoNetDriver绑定的LevelCollection,保证解析回放数据时可以通过Outer找到回放场景(DynamicDuplicatedLevels)

////3/////
FScopedLevelCollectionContextSwitch::FScopedLevelCollectionContextSwitch(const FLevelCollection* const InLevelCollection, UWorld* const InWorld)
{if (World){const int32 FoundIndex = World->GetLevelCollections().IndexOfByPredicate([InLevelCollection](const FLevelCollection& Collection){return &Collection == InLevelCollection;});World->SetActiveLevelCollection(FoundIndex);}
}
void UWorld::SetActiveLevelCollection(int32 LevelCollectionIndex)
{ActiveLevelCollectionIndex = LevelCollectionIndex;const FLevelCollection* const ActiveLevelCollection = GetActiveLevelCollection();if (ActiveLevelCollection == nullptr){return;}PersistentLevel = ActiveLevelCollection->GetPersistentLevel();GameState = ActiveLevelCollection->GetGameState();NetDriver = ActiveLevelCollection->GetNetDriver();DemoNetDriver = ActiveLevelCollection->GetDemoNetDriver();}////4////
bool UDemoNetDriver::InitConnect(FNetworkNotify* InNotify, const FURL& ConnectURL, FString& Error)
{const TCHAR* const LevelPrefixOverrideOption = ConnectURL.GetOption(TEXT("LevelPrefixOverride="), nullptr);if (LevelPrefixOverrideOption){SetDuplicateLevelID(FCString::Atoi(LevelPrefixOverrideOption));}if (GetDuplicateLevelID() == -1){// Set this driver as the demo net driver for the source level collection.
    FLevelCollection* const SourceCollection = World->FindCollectionByType(ELevelCollectionType::DynamicSourceLevels);if (SourceCollection){SourceCollection->SetDemoNetDriver(this);}}else{// Set this driver as the demo net driver for the duplicate level collection.
    FLevelCollection* const DuplicateCollection = World->FindCollectionByType(ELevelCollectionType::DynamicDuplicatedLevels);if (DuplicateCollection){DuplicateCollection->SetDemoNetDriver(this);}}}

4.2 回放录制与播放分离

考虑到在死亡回放的时候不会影响正常比赛的进行和录制,所以我们通常也需要讲录制逻辑与播放逻辑完全分离。

简单来说,就是创建两个不同的Demonetdriver,一个用于回放的录制,另一个用于回放的播放。在游戏一开始的时候,就创建一个DemonetdriverA来开始录制游戏,当角色死亡触发回放的时候,这时候创建一个新的DemonetdriverB来进行回放数据的读取并播放,整个过程中DemonetdriverA一直在处于录制状态,不会受到任何影响。(需要我们手动重写GameInstance::PlayReplay函数,因为默认的逻辑每次创建一个新的Demonetdriver就会删掉原来的那个)

4.3 基于内存的回放数据流

当然,想要实现真正的快速切换,只将回放场景与真实世界的分离还不够,我们还需要保证回放数据的加载也能达到毫秒级别?所以这个时候就不能再使用前面提到的LocalFileNetworkReplayStreamer把数据放到磁盘上,正确的方案是采用基于内存数据流的ReplayStreamer来加快回放数据的读取。下面是InMemoryNetworkReplayStreamer对回放数据的组织方式,每帧的数据流会根据时间分段存储在StreamChunks里面,而不同时间点的快照则会存储在Checkpoints数组里面。对于射击游戏,我们通常会在比赛一开始就执行录制,录制的数据会不断写到下面的结构里面并在整场比赛中一直保存着,当玩家被击杀后就可以立刻从这里取出数据来进行回放。

//基于内存回放
TArray<FString> AdditionalOptions;
AdditionalOptions.Add(TEXT("ReplayStreamerOverride=InMemoryNetworkReplayStreaming"));
GameInstance->StartRecordingReplay("MyTestReplay", "MyTestReplay", Options);
//GameInstance->PlayReplay("MyTestReplay", GetWorld(), AdditionalOptions);

//MyProject.build.cs
DynamicallyLoadedModuleNames.AddRange(new string[] {"NetworkReplayStreaming",//"LocalFileNetworkReplayStreaming",可选,按需配置加载
        "InMemoryNetworkReplayStreaming",//"HttpNetworkReplayStreaming"
    }
);

关于死亡回放/精彩镜头其实还有很多细节问题,这里列举一些(最后一节会给出一些建议):

  • 引擎编辑器里面默认不支持DynamicDuplicatedLevels的创建,所以在不改源码的情况下无法在编辑器里面实现死亡回放功能。
  • 回放世界与真实世界都是存在的,可以通过SetVisible来处理渲染,但是回放世界的物理怎么控制?
  • 回放世界默认情况下不会复制Controller(容易和本地的Controller发生冲突),所以很多相关的接口都不能使用
  • 由于不同Collection的Tick更新时机不同,但是Controller只有一个,所以回放的时候要注意Controller的更新时机
  • 默认的录制逻辑都是在本地客户端实现的,可能对客户端有一定的性能影响
更多细节建议到GitHub参考虚幻竞技场的源码

五、Livematch观战系统

在CSGO、Dota、堡垒之夜等游戏里,都支持玩家观战的功能,即玩家可以通过客户端直接进入到某个正在进行的比赛的场景里进行实时观战。不过一般情况下并不是严格意义上的完全实时,通常根据情况会有一定程度的延迟。

实现该功能的一个简易方案就是让观战的玩家作为一个客户端连接进去,然后实时的接受服务器同步数据来进行观战。这种方式既简单,效果也好,但是问题也非常致命——观战的玩家可能会影响正常服务器性能,无法很好的支持大量的玩家进入。

所以大部分的游戏实现的都是另一种方案,即基于Webserver和回放的观战系统。这种方案的思路如下图,首先我们需要专门搭建一个用于处理回放数据的WebServer,源源不断的接收来自GameServer的回放录制数据。然后客户端在请求观战时不会去连接GameServer,而是直接通过Http请求当前需要播放的回放数据,从WebServer拿到数据后再进行本地的解析与播放。虽然会有一定的延迟,但是理想情况下效果和直接连入战斗服观战是一样的。

前面我们提到过基于Httpstream的数据流,正是为这种方案而实现的。去仔细的看一下FHttpNetworkReplayStreamer的接口实现,都是通过Http协议对回放数据进行封装而后通过固定的格式来发给WebServer的(格式可以按照需求修改,和WebServer的代码要事先规定统一)。

// FHttpNetworkReplayStreamer::StartStreaming 
// 开始下载时会发送一个特定的Http请求
const FString URL = FString::Printf(TEXT("%sreplay/%s/startDownloading?user=%s"), *ServerURL, *SessionName, *UserName);
HttpRequest->SetURL(URL);
HttpRequest->SetVerb(TEXT("POST"));
HttpRequest->OnProcessRequestComplete().BindRaw(this, &FHttpNetworkReplayStreamer::HttpStartDownloadingFinished);
// Add the request to start downloading
AddRequestToQueue(EQueuedHttpRequestType::StartDownloading, HttpRequest);

六、性能优化/使用建议

前面我们花了大量的篇幅,由浅入深的讲解了回放系统的概念以及原理,而后又对两个具体的实践案例(死亡回放、观战系统)做了进一步的分析,希望这样可以帮助大家更好的理解UE乃至其他游戏里面回放系统的思想思路。

文章的最后,我会根据个人经验给大家分享一些使用建议:

  • 如果想创建自定义的DemonetDriver,需要在配置文件里面
//DefaultEngine.ini MyTestRec为项目名称
[/Script/Engine.Engine]
!NetDriverDefinitions=ClearArray
NetDriverDefinitions=(DefName="GameNetDriver",DriverClassName="/Script/OnlineSubsystemUtils.IpNetDriver",DriverClassNameFallback="/Script/OnlineSubsystemUtils.IpNetDriver")
+NetDriverDefinitions=(DefName="DemoNetDriver",DriverClassName="/Script/MyTestRec.MyTestRecDemoNetDriver",DriverClassNameFallback="/Script/MyTestRec.MyTestRecDemoNetDriver")[/Script/Engine.DemoNetDriver]
NetConnectionClassName="/Script/Engine.DemoNetConnection"
DemoSpectatorClass="/Script/MyTestRec.MyTestRecSpectatorPC"
  • 回放的录制既可以在客户端也可以在服务器
  • 在回放中同步Controller要慎重,如果是在客户端录制回放数据最好不要同步Controller,因此玩家相关同步数据也最好不要放在Controller里面(PS代替)
  • RPC由于没有状态,所以很容易在回放里面丢失掉,对于有持续状态的同步效果(比如播放一个比较长的动画、道具的显示隐藏等),不要用RPC做同步(改为属性同步)。总的来说,整个项目代码里面都要克制地使用RPC。
  • 死亡回放涉及到Level的拷贝,这回明显的增大游戏的内存使用,对于那些在回放中不会发生变化的物体(比如staticmesh的墙体),一定要放置到StaticLevels里面。
  • 播放回放时会预先多加载5秒左右的数据(MAX_PLAYBACK_ BUFFER_SECONDS),在观战系统里面要注意这个间隔,如果Http发送不及时就很容易造成卡顿
  • 回放里面很多NetStartActor的逻辑都是通过资源路径来定位的,使用不当很容易造成一些资源引用、垃圾回收以及资源查找的问题。举个例子,比如我们删除了一个NetStartActor对象(已经标记为Pendingkill了),但是通过StaticFindObject我们仍然能查到这个对象,这时候如果再拿这个路径去生成Actor就会报错并提示场景里面已经有一个一模一样的Actor了。
  • Checkpoint的加载可能会造成性能问题,可以考虑分帧去处理
  • 回放有很多加载和生成对象的逻辑,很容易造成卡顿,建议项目内自己维护一个对象池来优化
  • 死亡回放结束的时候一定要及时的清理回放数据,否则可能造成内存的持续增加,也可能造成一些残留的Actor对功能造成影响
  • 回放世界和真实世界是同一个物理场景,需要避免碰撞
    • 尽量避免在回放世界打开物理
    • 通过设置PxFilterFlags并修改引擎的碰撞规则处理
  • 序列化的操作要注意很多细节,比如结尾处是不是一个完整的字节。很多奇怪的Check在网络部分的崩溃八成都是序列化反序列化没有匹配造成的
  • 临时拷贝尽量使用全局static,对于较大的数据,一定要压缩,效果明显

相关文章:

帧同步、快照同步与状态同步

https://zhuanlan.zhihu.com/p/564017214前段时间,在Epic举办的UnrealCircle会议上,我受邀分享了一场关于“UE4回放系统”的技术演讲。不过由于时长限制,很多细节都没有得到进一步的阐述。这篇文章会在演讲的基础上拓展更多内容,更好的帮助大家去理解虚幻引擎的回放系统,建…...

内存一致性模型

顺序一致性(Sequential Consistency)是计算机系统中保证多线程程序正确执行的一种内存一致性模型。 它要求所有操作的执行顺序与程序员的源码顺序一致,即每个线程的操作在其本地源码顺序中保持不变, 同时不同线程的操作全局排序也符合源码顺序。 ‌核心要求顺序一致性模型确…...

MahMetro 框架学习

学习建议: 1.从Demo开始:运行官方Demo,玩遍每一个功能,看看它是如何实现的。 2.动手实践:在自己的一个小项目中应用它,从改造MetroWindow和设置主题开始 3.逐个攻克:依次自学习一个控件(比如先学会用Flyout,再学HamburgerMenu),不要试图一下子掌握所有内容 4.善用搜索引…...

基于MATLAB的标准化降水蒸散指数(SPEI)实现

一、架构 %% 主程序框架 [prec, pet, time] = load_data(input.nc); % 加载降水与PET数据 prec_acc = accumulate(prec, 3); % 3个月时间尺度累积 pet_acc = accumulate(pet, 3); d = prec_acc - pet_acc; % 水分盈亏量 spei = calculate_spei(d, loglogistic); % 计算SPEI plo…...

Prometheus Probe 监控配置文档

概述 本文档描述了使用 Prometheus Operator 的 Probe 资源监控外部服务的配置方法。该配置通过静态目标地址直接监控多个服务端点,无需创建额外的 Service 和 Endpoints 资源。 前提条件Kubernetes 集群 Prometheus Operator v0.42 或更高版本 monitoring 命名空间已存在配置…...

客户案例|邦普循环x甄知科技,筑牢高效智能的IT运维底座

燕千云ITSM为邦普循环后续业务扩张与系统升级提供可扩展的IT服务框架,筑牢稳定、高效、智能的数字化底座,助力其在动力电池循环利用领域持续保持竞争优势。客户介绍 广东邦普循环科技有限公司(以下简称邦普循环),成立于2005年,总部位于广东省佛山市,地处粤港澳大湾区腹地,…...

VMware Exporter 指标转换方案

概述 本文档提供将 VMware Exporter 指标转换为标准 Node Exporter 格式的方案,实现监控基础设施的统一化和标准化。 背景 VMware Exporter 提供的原始指标格式与标准的 Node Exporter 不兼容,导致无法直接使用为 Node Exporter 设计的现有仪表板和告警规则。通过 Prometheus…...

可5V使用引脚兼容STM32F103C8T6的国产32位MCU

经常的型号如下,仅做记录 1.MM32F103C8T6 灵动微 2.CH32F103C8T6 沁恒微 3.FCM32F103C8T6 闪芯微 此料内核为M4, 4.CH32V103C8T6 沁恒微 此料内核为RISC-V 5.CW32F030C8T6 武汉芯源 此料内核为M0+ 6.待补充...

git clone操作报错diffie-hellman-group1-sha1的解决方案

在使用Git进行克隆操作的过程中,可能会遇到一个与加密算法相关的报错,尤其是当服务器使用了过时的安全算法 diffie-hellman-group1-sha1时。这通常发生在尝试克隆旧的或配置较老的Git服务器时,当代的SSH客户端默认不再支持这种较弱的加密方式,导致无法成功建立连接。 要解决…...

Celery inspect 常用命令手册

📘 Celery Inspect 常用命令清单 & 字段解释 运行格式: celery -A <app_name> inspect <command> 1. active 含义 显示 正在执行的任务(worker 正在跑的任务)。 示例 { "worker1@host": [ { "id": "f5e9b8c7-1234-5678-90ab-a…...

都可以!燕千云ITSM一站式接入全球主流AI大模型

燕千云ITSM基于对行业痛点的深度洞察,已实现对国内外多家主流大型语言模型的无缝接入与深度适配,构建覆盖多场景、高可用的企业级大模型矩阵,全面支持企业在AI技术选型与落地过程中的灵活性与可控性,实现真正的“全球优秀模型一站式接入”。在企业加速推进数智化转型升级的…...

删边最短路

今天写题的时候做到一个非常牛的东西。 给你一个图,\(q\) 次问你如果删掉一条边,\(1\) 到 \(n\) 的最短路会变成多少。 首先搞出来 \(1\) 出发的最短路树,然后如果这条边根本不在这棵树上,显然没有任何影响。 如果在的话,我们必然要绕路了。 给出一个性质:我们选择绕的路…...

问题解决模板

背景: 晚11点客户反馈,发送短信收不到了问题现象: 查看短信服务日志,发现日志提示: RocketMqMsgSender.java:41 - 发送短信消息到消息队列失败,CODE: 14 DESC: service not available now. It may be caused by one of the following reasons: the brokers disk is full …...

一站式接入全球股票数据:日本、美国、印度、马来西亚等多国API对接实战

一站式接入全球股票数据:日本、美国、印度、马来西亚等多国API对接实战 引言 在全球化资产配置的大背景下,开发者经常需要集成多国股票市场数据。本文将为您详解如何通过StockTV API快速接入日本、美国、印度、马来西亚等国家的实时股票行情、历史K线、指数数据等核心信息。 …...

基于MATLAB的图像处理程序

基于MATLAB的图像处理程序,结合傅里叶变换和滤波技术去除横条纹、渐变条纹及噪声干扰实现一、流程频域分析:通过傅里叶变换定位条纹频率成分 频域滤波:设计带阻/陷波滤波器抑制条纹 空域去噪:结合中值滤波/维纳滤波消除残留噪声 后处理优化:对比度增强与边缘锐化二、代码 …...

跨网文件安全交换系统推荐厂商详解

内容概要 在当今数字化时代,跨网文件交换已成为企业日常运营不可或缺的一部分,其安全性与效率直接关系到企业的数据安全与业务流畅度。因此,选择一家可靠的跨网文件安全交换系统推荐厂商尤为关键。本文将聚焦于飞驰云联及其出色的Ftrans Ferry跨网文件安全交换系统,该系统以…...

走迷宫

2025.9.15 题目内容 有一个 \(m\times n\) 格的迷宫(表示有 \(m\) 行、\(n\) 列),其中有可走的也有不可走的,如果用 \(1\) 表示可以走,\(0\) 表示不可以走,文件读入这 \(m\times n\) 个数据和起始点、结束点(起始点和结束点都是用两个数据来描述的,分别表示这个点的行号和…...

MVC 架构解析

认真对待每时、每刻每一件事,把握当下、立即去做。MVC 模式的目的是实现一种动态的程序设计,使后续对程序的修改和扩展简化,并且使程序某一部分的重复利用成为可能。除此之外,此模式通过对复杂度的简化,使程序结构更加直观。下面主要对 MVC 架构下的优化方案以及其项目结构…...

鸿蒙应用开发从入门到实战(五):ArkUI概述

HarmonyOS提供了一套UI开发框架,即方舟开发框架(ArkUI框架)。方舟开发框架可为开发者提供应用UI开发所必需的能力,比如多种组件、布局计算、动画能力、UI交互、绘制等。​ 大家好,我是潘Sir,持续分享IT技术,帮你少走弯路。《鸿蒙应用开发从入门到项目实战》系列文章持…...

好用的跨网文件安全交换系统:守护企业数据流转的核心屏障!

在数字化时代,企业数据跨网流转需求日益频繁,然而内网与外网的隔离、多安全域的划分,让文件传输面临效率与安全的双重挑战。传统方式如U盘拷贝易导致病毒传播和数据泄露,FTP等工具缺乏合规审计能力,难以满足企业对数据安全的高要求。因此一款好用的跨网文件安全交换系统,…...

SIM笔记

SIM组成 金属触点 + 塑料基板 + 芯片模块(封装在透明的环养树脂)+ 极细小的内部电路金属触点 C1: 输送电力(5V/3V/1.8/); C5:接地 形成回路; C3: 提供时钟信号; C2:复位信号触点; C7:数据传输; C4、C8: 预留 芯片模块 如何上网qq:505645074...

2025第五届“长城杯”网络安全大赛暨京津冀蒙网络安全技能竞赛 WP Web全

文曲签学 首先随便输入一个指令尝试,发现提示输入help查看帮助输入help,发现有list命令查看笔记和read命令读取笔记list查看笔记列表,发现HINT,read查看关注公众号后提示写的很明确了,目录穿越加双写绕过拿到flag EZ_upload 随便上传一个文件,跳转至upload.php查看源码 &…...

FTP替代工具哪个产品好,高效安全之选

内容概要 在探讨FTP替代工具哪个产品好时,我们不得不先概览一下当前的市场情况。随着企业对数据传输效率和安全性要求的日益提升,FTP这一传统工具已难以满足需求。市场上涌现出众多FTP替代产品,其中Ftrans SFT文件安全传输系统‌备受瞩目。本文将从高效传输速度和数据安全性…...

c++之内存对齐模板类aligned_storage

始于c++11,c++23弃用 aligned_storage 是 C++ 标准库中用于管理对齐内存的模板类,定义在 <type_traits> 头文件中。它通过模板参数指定内存大小和对齐方式,提供未初始化的内存区域,适用于需要精确控制内存布局的场景。核心功能 ‌内存对齐管理‌:通过模板参数设置…...

ABC 423先慢慢改吧题解

被模拟题狙击了,数组越界为啥不爆 RE 啊啊啊啊 整场白打,这是真导管了 C - Lock All Doors 想了半天是不是被边界情况卡了,鼓捣半天写了一堆等价的东西,屋檐了 记得检查数组大小 D - Long Waiting 可以维护一个小根堆来判断已经进入餐厅的客人离开的顺序,再记一个人数 \(s…...

汇聚层交换机的替换要考虑到的因素

背景: 当汇聚层交换机的流量端口承受不住现有流量,需要替换交换机实现业务正常工作。 考虑因素:现有网络架构:先梳理清现有网络架构,为后续工作打下坚实的基础。需要考虑业务需求、结构层次(汇聚层、接入层、核心层)、未来需求等; 交换机选型:硬件方面需要考虑适配性,…...

git 常见使用

取消git commit git reset --soft HEAD~1 文件名 取消git add git reset HEAD 文件名 强制分支B覆盖分支A git checkout A git reset --hard origin/B git push -f origin A...

python UV 包管理工具安装

安裝 uv uv 本身並不需要 Python,所以不建議用 pip 或是 pipx 安裝,這樣都會跟特定的 Python 環境綁在一起,Windows 上就直接透過 PowerSehll 安裝即可: powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | scoop 安裝:scoop install u…...

什么是网络分区

目录背景和价值一、先搞懂:什么是Redis场景下的“网络分区”?二、同一交换机下,Redis主从发生网络分区的5个常见原因1. 节点自身的“网络硬件故障”2. 节点到交换机的“链路故障”3. 交换机自身的“功能故障”4. “网络风暴/拥堵”导致的“暂时性分区”5. 防火墙/安全软件的…...

完整教程:《驾驭云原生复杂性:隐性Bug的全链路防御体系构建》

完整教程:《驾驭云原生复杂性:隐性Bug的全链路防御体系构建》pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New&quo…...

从机器的角度来说ECS为何性能好

ECS的写法,让数据的命中变高了,从而提高了CPU的使用数据的性能,因为ECS的数据是连续,批量的 而OOP的写法,数据是没有组织的,数据比较离散,要去多个cache里面去找,命中率低...

人生最幸福的时刻也就几个瞬间

1...

网络流笔记

流网络:有向图,有两个特殊点:源点,汇点。每条边有个流量。(不考虑反向边)我们可以假设流网络中不存在自环,即对于任意的节点 \(v\),\((v,v) /∈E\)。 我们同样可以假设流网络中不存在重边,即对于任意的节点 \(u\), \(v\),如果 \((u,v)∈E\), 那么 \((v,u) /∈ E\)。…...

实用指南:经典动态规划题解

实用指南:经典动态规划题解pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", monospace !important; font-si…...

2025杭电多校(2)

F https://acm.hdu.edu.cn/showproblem.php?pid=7996 题意 有两场比赛,统计对于每个 \(i\) ,有多少个人排在 \(i\) 的前面,需要去重。 思路 第一思路是统计每个位置 \(i\) 前面有多少人数,发现有个小容斥在这里,两场比赛排名前的总人数减去两场都在排名前的人数。 用树状…...

latex 打印生僻字

默认的字体格式很难打出生僻字. 我们可以使用ctex的其他字体. 首先要知道有哪些字体, 参考: https://www.cnblogs.com/wodedow/p/13845213.html. 比如我们要使用字体名称为AR PL KaitiM GB, 我们需要在usepackage区域加入下面的代码 \setCJKfamilyfont{font01}{AR PL KaitiM GB…...

CSP-S 2025 游记(The Last CSP ver.)

【洛谷专栏】。 前言 前作:CSP-S 2024 游记。 上一篇文章:2025 年南京大学计算机学科体验专题营 游记。 停课最早的一次,但是没有去年写的早,不过小问题。 与文化课告别的不突然,但仍有些不舍吧。也许未来不会再担任化学课代表了,化学老师真的对我很好(可怜。如果不是现…...

电机ADC采集

正点原直流有刷驱动板的硬件解说_直流有刷电机电流检测电路-CSDN博客电平移位电路设计(常用于将双极性的宽动态范围信号变成单极性窄动态范围的信号供ADC采集)-CSDN博客运放实现交流信号的放大与平移-CSDN博客...

道德经

1.道可道,非常道。名可名,非常名。无名天地之始;有名万物之母。2.天下皆知美之为美,斯恶已。皆知善之为善,斯不善已。3.有无相生,难易相成,长短相形,高下相盈,音声相和,前后相随。恒也。4.不尚贤,使民不争;不贵难得之货,使民不为盗;不见可欲,使民心不乱。是以圣…...

TokenFlow: Unified Image Tokenizer for Multimodal Understanding and Generation - jack

https://github.com/ByteVisionLab/TokenFlow https://arxiv.org/abs/2412.03069...

digitalworld.local: TORMENT - 实践

digitalworld.local: TORMENT - 实践pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", monospace !important;…...

8.25-9.2周报六

1111...

Go by Example(3.Variables)

package mainimport "fmt"func main() {var a = "initial"fmt.Println(a)var b, c int = 1, 2fmt.Println(b, c)var d = truefmt.Println(d)var e intfmt.Println(e)f := "apple"fmt.Println(f) }运行结果: $ go run variables.go initial 1 2 …...

小程序分包方法

1、 图片上云 2、 删除不用的代码、函数和文件 3、 只有子包需要的接口移到子包中 4、 代码复用。效果不明显,实现两个页面复用一个大组件,可减少10kB大小 5、 还未实践见到效果的备选方案:把node_modules、uni_modules(在微信开发者工具的依赖分析看项目依赖这两个目录中的…...

9.3-9.10周报七

111111...

pyinstaller打包整个文件文件夹和相关exe,三方库

#打包目的:完全脱离环境,只copy hello.exe去其他机器就可以完美运行#打包命令: pyinstaller --onefile .\xxx\hello.py --hidden-import "tkinter" --hidden-import=glob --hidden-import=lxml --add-data ".\xxx\*;." --distpath output_dir --add-…...

学习心得

初次接触Hadoop时,我被其庞大的生态系统所震撼 学习过程中,我最大的感悟是理论与实践的结合至关重要。单纯阅读MapReduce的原理或HDFS的架构设计,总感觉隔着一层迷雾。直到亲手搭建环境、编写第一个WordCount程序,看到分布式计算如何将大任务拆解到多个节点并行处理,那种豁…...

Web前端入门第 87 问:JavaScript 中 setInterval 和 setTimeout 细节

setInterval 和 setTimeout 两者都是用于控制 JS 函数延迟执行,但是在执行机制和用途上还是有点儿差异。 虽然说两者功能上有区别,但在使用上却可以相互模拟各自的功能,大胆的猜测下:也许浏览器内核底层都是同一个方法,只是上层封装出的两个语法糖而已。 语法 两者在语法上…...

基于Python+Vue开发的农产品商城管理系统源码+运行

项目简介该项目是基于Python+Vue开发的农产品商城管理系统(前后端分离),这是一项为大学生课程设计作业而开发的项目。该系统旨在帮助大学生学习并掌握Python编程技能,同时锻炼他们的项目设计与开发能力。通过学习基于Python的农产品商城管理系统项目,大学生可以在实践中学…...

多人多次并发

在测试行业一年多,接触到多人多次并发首先创建线程组在JMeter中添加线程组,设置线程数、Ramp-Up时间和循环次数,然后添加HTTP请求:配置目标服务器的IP、端口和路径,模拟用户的具体操作。通过CSV Data Set Config加载用户数据,实现动态参数化。 查看结果树收集测试数据。合…...