回放,是电子游戏中一项常见的功能,用于记录整个比赛过程或者展示游戏中的精彩瞬间。通过回放,我们可以观摩高手之间的对决,享受游戏中的精彩瞬间,甚至还可以拿到敌方玩家的比赛录像进行分析和学习。
从实现技术角度来讲,下面的这些功能本质上都属于回放的一部分
早在20世纪90年代,回放系统就已经诞生并广泛用于即时战略、第一人称射击以及体育竞技等类型的游戏当中,而那时存储器的容量非常有限,远远无法与当今动辄几十T的硬盘相提并论,面对一场数十分钟的比赛,比赛数据该如何存储和播放?回放该如何实现?这篇文章会通过剖析UE的回放系统,来由浅入深地帮助大家理解其中的原理和细节。
概述
其实实现回放系统有三种思路,分别是:
三种方案各有优劣,但由于第一种录制画面的方案存在着“占用大量存储空间”、”加载速度慢”、“不够灵活”等比较严重的问题,我们通常采用后两种方式来实现游戏中的回放。
可以参考“游戏中的回放系统是如何实现的?”来进一步了解这三种方案
一、帧同步、快照同步与状态同步
虽然不同游戏里回放系统具体的实现方式与应用场景不同,但本质上都是对数据的记录和重现,这个过程与网络游戏里面的同步技术非常相似。举个例子,假如AB两个客户端进行P2P的连接对战,A客户端上开始时并没有关于B的任何信息。当建立连接后,B开始把自己的相关信息(坐标,模型,大小)发给A,A在自己的客户端上利用这个信息重新构建了B,完成了数据的同步。
思考一下,假如B不把这个信息发给A,而发给自己进行处理,是不是就相当于录制了自己的机器上的比赛信息再进行回放呢?
没错,网络游戏中的同步信息正是回放系统中的录制信息,因此网络同步就是实现回放系统的技术基础!
在正式介绍回放系统前,不妨先概括地介绍一下游戏开发中的网络同步技术。我们常说网络同步可以简单分为帧同步、快照同步和状态同步,但实际上这几个中文概念是国内开发者不断摸索和自创的名词,并非严格指某种固定的算法,他们有很多变种,甚至可以结合到一起去使用。
拓展:快照同步其实是状态同步的前身,那时候整个游戏需要记录的数据量还不是很大,人们也自然的使用快照来代表整个世界在某一时刻的状态,通过定时地同步整个世界的快照就可以做到完美的网络同步。但是这种直接把整个世界的状态进行同步的过程是很耗费流量和性能的,考虑到对象的数据是逐步产生变化的,我们可以只记录发生变化的那些数据,所以就有了基于delta的快照同步。更进一步的,我们可以把整个世界拆分一下,每一帧只针对需要的对象进行delta的同步,这样就完全将各个对象的同步拆分开来,再结合一些过滤可以进一步减少没必要的数据同步,最后形成了状态同步的方案。更多关于网络同步技术的发展和细节可以参考我的文章——《细谈网络同步在游戏历史中的发展变化》。
二、UE4网络同步基础
在虚幻引擎里面,默认实现的是一套相对完善的状态同步方案,场景里面的每个对象都称为一个Actor,每个Actor都可以单独设置是否进行同步(Actor身上还可以挂N个组件,也可以进行同步),Actor某一时刻的标记Replicated属性就是所谓的状态信息。服务器在每帧Tick的时候,会去判断哪些Actor应该同步给哪些客户端,哪些属性需要进行同步,然后统一序列化成二进制(可以理解为一个当前世界状态的增量快照)发给对应的客户端,客户端在收到后还可以调用回调函数进一步处理。这种通信方式我们称为属性同步。
此外,UE里面还有另一种通信方式叫RPC,可以像调用本地函数那样来调用远端的函数。RPC常用于做一些跨端的事件通知,虽然并不严格属于传统意义上状态同步的范畴,但也是UE网络同步里面不可缺少的一环。
为了实现上面两种同步方式,UE4通过抽象分层实现了一套NetDriver + NetConnection + Channel + Actor/Uobject的同步方式(如下图)。
三、回放系统框架与原理
3.1 回放系统的核心与实现思路
结合我们前面提到的网络同步技术,假如我们现在想在游戏里面录制一场比赛要怎么做呢?是不是像快照同步一样把每帧的状态数据记录下来,然后播放的时候再去读取这些数据呢?没错!利用网络同步的思想,把游戏本身当成一个服务器,游戏内容当成同步数据进行录制存储即可。
当然对于帧同步来说,我们并不会去记录不同时刻世界的状态信息,而是把关注点放在了玩家的行为指令上(Input队列)。帧同步会默认各个客户端的初始状态完全一致,只要保证同一时刻每个指令的相同,那么客户端上整个游戏世界的推进和表现也应该是完全一样的(需要解决浮点数精度、随机数一致性问题等)。由于只需要记录玩家的行为数据,所以一旦帧同步的框架完成,其回放系统的实现是非常方便和轻量化的。
无论哪种方式,回放系统都需要依靠网络同步框架来实现。虚幻系统本身是状态同步架构,所以我们后面会把重点都放在基于状态同步的回放系统中去。
如果你想深入UE4的网络同步,好好研究回放系统是一个不错的学习途径。官方文档链接:
https://docs.unrealengine.com/4.27/en-US/TestingAndOptimization/ReplaySystem/
根据上面的阐述,我们已经得到了实现回放系统的基本思路:
序列化:把对象存储成二进制的形式。
反序列化:根据二进制数据的内容,反过来还原当时的对象。
3.2 UE4回放系统的简单使用
为了能有一个直观的效果,我们先尝试动手录制并播放一段回放,步骤如下:
3.3 UE4中的回放系统架构
虚幻引擎在NetDriver + NetConnection + Channel的架构基础上(上一节有简单描述) ,拓展了一系列相关的类来实现回放系统(ReplaySystem):
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)并进行加载,然后再根据最终目标的时间快速地读取后续的Stream信息来实现目标位置的跳转。拿前面的案例来说,由于我现在在20s的时候可以通过Checkpoint的加载而得到前面Actor1在当前的状态,所以可以完美地实现跳转功能。在实际录制的时候,ReplayHelper的FQueuedDemoPacket其实有两个,分别用于存储Stream和Checkpoint。
//当前的时间DemoCurrentTime也会被序列化到FQueuedDemoPacket里面
TArray QueuedDemoPackets;
TArray 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_WARNINGS
DemoFrameNum++;
PRAGMA_ENABLE_DEPRECATION_WARNINGS
ReplayHelper.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
而在读取播放时,数据的处理流程会有一些差异。系统会尝试一次性从磁盘加载所有信息到一个用于组织回放的数据结构中——FLocalFileReplayInfo,然后再逐步读取与反序列化,因此下图的FLocalFileReplayInfo在回放开始后其实已经完整地保存着一场录制里面的所有的序列化信息了(Chunks数组里面就存储着不同时间段的StreamAr)。
FLocalFileNetworkReplayStreamer是为了专门将序列化数据写到本地而封装的类,类似的还有用于Http发送的FHttpNetworkReplayStreamer。这些类都继承自接口INetworkReplayStreamer,在第一次执行录制的时候会通过对应的工厂类进行创建。
我们可以通过在StartRecordingReplay/PlayReplay的第三个参数(AdditionalOptions)里面添加“ReplayStreamerOverride=XXX”来设置不同类型的ReplayStreamer,同时在工程的Build.cs里面配置对应的代码来确保模块能正确的加载。
TArray 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 回放架构梳理小结
到此,我们已经对整个系统有了更深入的理解,再回头看整个回放的流程就会清晰很多。
游戏运行的任何时候我们都可以通过StartRecordingReplay执行录制逻辑,然后通过初始化函数创建DemonetDriver、DemonetConnection以及对应的ReplayStreamer。
DemonetDriver在Tick的时候会根据一定规则对当前场景里面的同步对象进行录制,录制的数据先存储到FQueuedDemoPacket数组里面,然后再写到自定义ReplayStreamer的FArcive里面缓存。
FArcive分为StreamAr和CheckpointAr,分别用持续的录制和特定时刻的全局快照保存,里面的数据到达一定量时我们就可以把他们写到本地或者发送出去,然后清空后继续录制。
当执行PlayReplay开始回放的时候,我们先根据时间戳找到就近的CheckpointAr进行反序列化,利用快照恢复整个场景后再使用Tick去读取StreamAr里面的数据并播放。
回放系统的Connection是100%Reliable的,Connection->IsInternalAck()为true。
3.4 回放实现的录制与加载细节
上个小结我们已经从架构的角度上梳理了回放录制的原理和过程,但是还有很多细节问题还没有深究,比如:
这些问题看似简单,但实现起来却并不容易。比如我们在播放时需要动态切换特定的摄像机视角,那就需要知道UE里面的摄像机系统,包括Camera的管理、如何设置ViewTarget、如何通过网络GUID找到对应的目标等,这些内容都与游戏玩法高度耦合,因此在分析录制加载细节前建议先回顾一下UE的Gameplay框架。
3.4.1 回放世界的Gameplay架构
UE的Gameplay基本是按照面向对象的方式来设计的,涉及到常见概念(类)如下:
概括来讲,一个游戏场景是一个World,每个场景可以拆分成很多子关卡(即Level),我们可以通过配置Gamemode参数来设置游戏规则(只存在与于服务器),在Gamestate上记录当前游戏的比赛状态和进度。对于每个玩家,我们一般至少会给他一个可以控制的角色(即Pawn/Character),同时把这个角色相关的数据存储在Playerstate上。最后,针对每个玩家使用唯一的一个控制器Playercontroller来响应玩家的输入或者执行一些本地玩家相关的逻辑(比如设置我们的观察对象VIewTarget,会调用到Camermanager相关接口)。此外,PC是网络同步的关键,我们需要通过PC找到网络同步的中心点进而剔除不需要同步的对象,服务器也需要依靠PC才能判断不同的RPC应该发给哪个客户端。
回放系统Gameplay逻辑依然遵循UE的基础框架,但由于只涉及到数据的播放还是有不少需要注意的地方。
3.4.2 录制细节分析
通过GetNetworkObjectList获取所有Replicated的Actor。
找到当前Connection的DemoPC,决定录制中心坐标(用于剔除距离过远对象)。
遍历所有同步对象,通过NextUpdateTime判断是否满足录制时间要求。
通过IsDormInitialStartupActor排除休眠对象。
判断相关性,包括距离判定、是不是bAlwaysRelevant等。
加入PrioritizedActors进行同步前的排序。
ReplicatePrioritizedActors对每个Actor进行序列化。
根据录制频率CVarDemoRecordHz/CVarDemoMinRecordHz,更新下次同步时间NextUpdateTime。
DemoReplicate Actor处理序列化,包括创建通道Channel、属性同步等。
LowLevelSend写入QueuedPacket。
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,最好使用属性同步来代替,这样也能很好的支持断线重连。
存储Checkpoint的步骤如下:
enum class ECheckpointSaveState
{
Idle,
ProcessCheckpointActors,
SerializeDeletedStartupActors,
CacheNetGuids,
SerializeGuidCache,
SerializeNetFieldExportGroupMap,
SerializeDemoFrameFromQueuedDemoPackets,
Finalize,
};
3.4.3 播放细节分析
在每次开始回放前,我们可以给回放指定一个目标时间,然后回放系统就会创建一个FGotoTimeIn SecondsTask来执行时间跳跃的逻辑。基本思路是先找到附近的一个Checkpoint(快照点)加载,然后快速读取从Checkpoint时间到目标时间的数据包进行解析。这个过程中有很多细节需要理解,比如我们从20秒跳跃到10秒的时候,20秒时刻的Actor是不是都要删除?删除之后要如何再去创建一个新的和10秒时刻一模一样的Actor?不妨带着这些问题去理解下面的流程。
FGotoTime InSecondsTask调用StartTask开始设置当前的目标时间,然后调用ReplayStreamer的GotoTimeInMS去查找要回放的数据流位置,这个时候暂停回放的逻辑。
查找到回放数据流后,调用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(比如说游戏只有10秒,Checkpoint每30秒才录制一个,加载5秒数据的时候就取不到CheckpointArchive)。
7)反序列化Checkpoint的时间、关卡信息等内容,将CheckpointArchive里面的回放数据读取到FPlaybackPacket数组。
8)重新创建那些被删掉的StartUp对象。
9)获取最后一个数据包的时间用作当前的回放时间,然后根据跳跃的时长设置最终的目标时间(备注:比如目标时间是35秒,Checkpoint数据包里面最近的一个包的时间是30.01秒。那么还需要快进跳跃5秒,最终时间是35.01秒,这个时间必须非常精确)。
10)解析FPlaybackPacket,反序列所有的Actor数据。
加载完Checkpoint之后,接下来的一帧TickDemoPlayback会快速读取数据直到追上目标时间。同时处理一下加载Checkpoint Actor的回调函数。
回放流程继续,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不包含它,因此也不会被应用进来。
从调用流程来说,兼容性的属性序列化走的接口是SendProperties_ BackwardsCompatible_r/ReceiveProperties_ BackwardsCompatible_r,会把属性在NetFieldExports里面标识符一并发送。而常规的属性同步序列化走的接口是SendProperties_r/ReceiveProperties_r,直接序列化属性的Index以及内容,不使用NetFieldExports相关结构。
到这里,我们基本上可以理解虚幻引擎对回放系统的向后兼容性方案。然而即使有了上面的方案,我们其实也只是兼容了类成员发生改变的情况,保证了不会由于属性丢失而出现逻辑的错误执行。但是对于新增的属性,由于原来存储的回放文件里面根本不存在这个数据,回放的时候是完全不会有任何相关的逻辑的。因此,所谓回放系统的兼容也只是有一定限制的兼容,想很好地支持版本差异过大的回放文件还是相对困难许多的。
更多内容,请关注:
《Exploring in UE4》Unreal回放系统剖析(下)
这是侑虎科技第1367篇文章,感谢作者Jerish供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。
作者主页:Jerish - 知乎
【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!