《Exploring in UE4》Unreal回放系统剖析(上)

《Exploring in UE4》Unreal回放系统剖析(上)

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

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

  • 精彩瞬间展示: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_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<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架构
UE的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(比如说游戏只有10秒,Checkpoint每30秒才录制一个,加载5秒数据的时候就取不到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不包含它,因此也不会被应用进来。


发送NetFieldExports信息

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

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

更多内容,请关注:
《Exploring in UE4》Unreal回放系统剖析(下)


这是侑虎科技第1367篇文章,感谢作者Jerish供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:465082844)

作者主页:https://www.zhihu.com/people/chang-xiao-qi-86

【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!