回放,是电子游戏中一项常见的性能,用于记录整个较量过程或者展现游戏中的精彩霎时。通过回放,咱们能够观摩高手之间的对决,享受游戏中的精彩霎时,甚至还能够拿到敌方玩家的较量录像进行剖析和学习。
从实现技术角度来讲,上面的这些性能实质上都属于回放的一部分
- 精彩霎时展现: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...
依据下面的论述,咱们曾经失去了实现回放零碎的基本思路:
- 录制:就像服务器网络同步一样,每帧去记录所有对象(Actor)的状态信息,而后通过序列化的形式写到一个缓存外面。
- 播放:拿到那个缓存数据,反序列化后赋值给场景外面对应的Actor。
序列化:把对象存储成二进制的模式。
反序列化:依据二进制数据的内容,反过来还原过后的对象。
3.2 UE4回放零碎的简略应用
为了能有一个直观的成果,咱们先尝试入手录制并播放一段回放,步骤如下:
- 在EpicLancher外面下载引擎(我应用的是4.26版本),创立一个第三人称的模板工程命名为MyTestRec;
- 点击Play进入游戏后,点击“~”按钮并在控制台命令执行demorec MyTestReplay开始录制回放;
- 轻易挪动人物,30秒后再次关上控制台命令执行Demostop;
- 再次关上控制台,命令执行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.csDynamicallyLoadedModuleNames.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 回放实现的录制与加载细节
上个小结咱们曾经从架构的角度上梳理了回放录制的原理和过程,然而还有很多细节问题还没有深究,比方:
- 回放时观看的视角如何设置?
- 哪些对象应该被录制?
- 录制频率如何设置?
- 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,进行一系列过滤后把须要同步的数据序列化。步骤如下:
- 通过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的目标是跨端近程调用,对于非多播的RPC,他只会在某一个客户端或者服务器下面执行。也就是说,我在服务器上录制就拿不到客户端的RPC,我在客户端上录制就拿不到服务器上的RPC,总会失落掉一些RPC。
- RPC是冗余的,可能咱们在回放的时候不想调用。比方服务器触发了一个ClientRPC(让客户端播放摄像机触动)并录制,那么回放的时候我作为一个观战的视角不应该调用这个RPC(当然也能够自定义的过滤掉)。
- RPC是一个无状态的告诉,一旦错过了就再也无奈获取。回放中常常会有工夫的跳转,跳转之后咱们再就无奈拿到后面的RPC了。如果咱们适度依赖RPC做逻辑解决,就很容易呈现回放体现不对的状况。
综上所述,我并不倡议在反对回放零碎的游戏外面频繁应用RPC,最好应用属性同步来代替,这样也能很好的反对断线重连。
- 录制Checkpoint
在每帧执行TickDemoRecord时,会依据ShouldSaveCheckpoint来决定是否触发Checkpoint快照的录制,能够通过CVarCheckpointUpload DelayInSeconds命令行参数来设置其录制距离,默认30秒。
存储Checkpoint的步骤如下:
- 通过GetNetworkObjectList获取所有Replicated的Actor
- 过滤掉PendingKill,非DemoPC等对象并排序
- 构建快照上下文CheckpointSaveContext,把Actor以及对应的LevelIndex放到PendingCheckpointActors数组外面
调用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数据流的起始点,而后进行反序列化的操作。步骤如下:
- 确保以后World没有进行关卡的切换,确保以后的较量正在播放
- 尝试设置较量的总工夫SetDemoTotalTime
- 调用ProcessReplayTasks解决以后正在执行的工作,如果工作没有实现就返回(工作有很多种,比方FGotoTime InSecondsTask就是用来执行工夫跳转的工作)
- 拿到StreamArchive,设置以后回放的工夫(回放工夫决定了以后回放数据加载的进度)
- 去PlaybackPackets查找是否还有待处理的数据,如果没有数据就暂停回放
- ConditionallyReadDemo FrameIntoPlaybackPackets依据以后的工夫,读取StreamArchive外面的数据,缓存到PlaybackPackets数组外面
- ConditionallyProcess PlaybackPackets一一去解决PlaybackPackets外面的数据,进行反序列化的操作(这一步是还原数据的要害,回放Actor的创立通常是这里触发的)
- FinalizeFastForward解决快进等操作,因为咱们可能在一帧的时候解决了回放N秒的数据(也就是快进),所以这里须要把被快进掉的回调函数(OnRep)都执行到,同时记录到底快进了多少工夫
- 加载Checkpoint
在2.3.2大节,咱们提到了UE的网络同步形式为增量式的状态同步,任何一刻的状态数据都是后面所有状态同步数据的叠加,所以必须从最开始播放能力保障不失落掉两头的任何一个数据包。想要实现快进和工夫跳跃必须通过加载最近的Checkpoint能力实现。
在每次开始回放前,咱们能够给回放指定一个指标工夫,而后回放零碎就会创立一个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不蕴含它,因而也不会被利用进来。
发送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专栏】如果你深怀绝技,爱“搞点钻研”,乐于分享也博采众长,咱们期待你的退出,让智慧的火花碰撞交错,让常识的传递生生不息!