乐趣区

关于unreal-engine4:Exploring-in-UE4Unreal回放系统剖析上

回放,是电子游戏中一项常见的性能,用于记录整个较量过程或者展现游戏中的精彩霎时。通过回放,咱们能够观摩高手之间的对决,享受游戏中的精彩霎时,甚至还能够拿到敌方玩家的较量录像进行剖析和学习。

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

  • 精彩霎时展现: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/TestingAndOptimizati…

依据下面的论述,咱们曾经失去了实现回放零碎的基本思路:

  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 专栏】如果你深怀绝技,爱“搞点钻研”,乐于分享也博采众长,咱们期待你的退出,让智慧的火花碰撞交错,让常识的传递生生不息!

退出移动版