Unreal回放系统剖析(上)

Posted Jerish_C

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Unreal回放系统剖析(上)相关的知识,希望对你有一定的参考价值。

这是【游戏开发那些事】第56篇原创

两周前,在Epic举办的UnrealCircle会议上,我受邀分享了一场关于“UE4回放系统”的技术演讲。不过由于时长限制,很多细节都没有得到进一步的阐述。

这篇文章会在演讲的基础上拓展更多内容,更好的帮助大家去理解虚幻引擎的回放系统,建议大家结合源码进行阅读和学习。

识别二维码观看

目录(上篇)

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

  • 二、UE4网络同步基础

  • 三、回放系统框架与原理

    • 3.1 回放系统核心实现思路

    • 3.2 回放系统简单使用

    • 3.3 UE4回放系统架构

      • 3.3.1 数据的存储和读取

      • 3.3.2 数据的组织和存储

      • 3.3.3 小结

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

>>彩虹6号中的击杀回放

早在20世纪90年代,回放系统就已经诞生并广泛用于即时战略、第一人称射击以及体育竞技等类型的游戏当中。

在上一篇文章“游戏中的回放系统是如何实现的?”里,我们简单讲解了实现回放系统的三种思路:

  • 逐帧录制游戏画

  • 逐帧录制玩家的输入操作

  • 定时录制玩家以及游戏场景对象的状态

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

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

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

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

>>AB两个客户端进行联机对战

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

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

  • 帧同步,对应的英文概念是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调用等。

更多内容参考请我的知乎专栏

https://www.zhihu.com/column/c_164452593

>>UE中的网络同步架构

回放系统框架与原理

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

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

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

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

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

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

  1. 录制:就像服务器网络同步一样,每帧去记录所有对象(Actor)的状态信息,然后通过序列化的方式写到一个缓存里面。

  2. 播放:拿到那个缓存数据,反序列化后赋值给场景里面对应的Actor

序列化:把对象存储成二进制的形式

反序列化:根据二进制数据的内容,反过来还原当时的对象

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

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

  1. 在EpicLancher里面下载引擎(我使用的是4.26版本),创建一个第三人称的模板工程命名为MyTestRec

  2. 点击Play进入游戏后,点击“~”按钮并在控制台命令执行demorec MyTestReplay开始录制回放

  3. 随便移动人物,30秒后再次打开控制台命令执行Demostop。

  4. 再次打开控制台,命令执行demoplay MyTestReplay,可以看到地图会被重新加载然后播放刚才录制的30秒回放‍

2.3 UE4中的回放系统架构:

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

  •   UReplay:一个全局的回放子系统,用于封装核心接口并暴露给上层调用。(注:Subsystem类似C++中的单例类)

  • DemoNetdriver:继承自NetDriver,专门用于宏观地控制回放系统的录制与播放。

  • Demonetconnection:继承自NetConnection,可以自定义实现回放数据的发送位置。

  • FReplayHelper:封装一些回放处理数据的接口,用于将回放逻辑与DemoNetDriver进行解耦。

  • XXXNetworkReplayStreamer:回放序列化数据的存储类,根据不同的存储方式有不同的具体实现。

2.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里面是如何存储回放数据的?回放系统如何做到从指定时间开始播放?想弄清这些问题就不得不进一步分析回放相关的数据结构与组织思想。

2.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;

2.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

- 未完待续 -

 往期文章推荐 

游戏开发技术系列【想做游戏开发,我应该会点啥?】

虚幻引擎技术系列【使用虚幻引擎4年,我想再谈谈他的网络架构】

游戏科普系列【盘点游戏中那些“欺骗玩家眼睛的开发技巧”】

C++面试系列【史上最全的C++/游戏开发面试经验总结】

我是Jerish,网易游戏工程师,6年从业经验。该公众号会定期输出技术干货和游戏科普的文章,关注我回复关键字可以获取游戏开发、操作系统、面试、C++、游戏设计等相关书籍和参考资料。

以上是关于Unreal回放系统剖析(上)的主要内容,如果未能解决你的问题,请参考以下文章

Unreal回放系统剖析(上)

Unreal回放系统剖析(上)

Unreal回放系统剖析(下)

Unreal回放系统剖析(下)

Unreal回放系统剖析(下)

Unreal回放系统剖析(下)