Unreal回放系统剖析(下)

Posted Jerish_C

tags:

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

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

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

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

识别二维码观看视频

目录(下篇)

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

    • 3.4 回放实现的录制与加载细节

      • 3.4.1 回放世界的Gameplay架构

      • 3.4.2 录制细节分析

      • 3.4.3 播放细节分析

    • 3.5 回放系统的跨版本兼容

      • 3.5.1 回放兼容性的意义

      • 3.5.2 虚幻引擎的回放兼容方案

  • 四、死亡回放/精彩镜头

    • 4.1 回放场景与真实场景分离

    • 4.2 回放录制与播放分离

    • 4.3 基于内存的回放数据流

  • 五、Livematch观战系统

  • 六、性能优化/使用建议

回放系统框架与原理

3.4 回放实现的录制与加载细节

上个小结我们已经从架构的角度上梳理了回放录制的原理和过程,但是等很多细节问题还没有深究,比如

  • 回放时观看的视角如何设置?

  • 哪些对象应该被录制?

  • 录制频率如何设置?

  • RPC和属性都能正常录制么?

  • 加载Checkpoint的时候要不要删除之前的actor?

  • 快进和暂停如何实现?

这些问题看似简单,但实现起来却并不容易。比如我们在播放时需要动态的切换特定的摄像机视角,那就需要知道UE里面的摄像机系统,包括Camera的管理、如何设置ViewTarget,如何通过网络GUID找到对应的目标等,这些内容都与游戏玩法高度耦合,因此在分析录制加载细节前建议先回顾一下UE的Gameplay框架。

3.4.1 回放世界的Gameplay架构

虚幻的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(比如说游戏只有10s,Checkpoint每30秒才录制一个,加载5s数据的时候就取不到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相关结构。

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

死亡回放/精彩镜头功能的实现

在FPS游戏里,一个角色被击杀之后,往往会以敌方的视角回放本角色被定位、瞄准、射击的过程,这就是我们常提到的死亡回放(DeathCameraReplay)。类似的,我们在各种体育游戏里面经常需要在一次得分后展示精彩瞬间,这种功能一般称为精彩镜头。

上一节案例使用的是基于本地文件存储的回放系统,每次播放时都需要重新加载地图。那有没有办法实现类似实况足球的实时精彩回放呢?有的,那就是基于DuplicatedLevelCollection和内存数据流的回放方案。

思考一下,通常射击游戏里的击杀镜头、体育竞技里的精彩时刻对回放的基本需求是什么?这类回放功能往往是在某个时间点可以无感知的立刻切换到回放镜头,并在回放结束后迅速的再切换到正常的游戏环境。同时,考虑到联机的情况,我们在回放时要保持游戏世界的正常运转,从而确保不错过任何服务器的同步信息,不影响其他玩家。

简单总结就是,

  1. 可以迅速的在真实游戏与回放镜头间切换

  2. 回放的时候不会影响真实游戏里面的逻辑变化

4.1 回放场景与真实场景分离

为了实现上述的要求,我们需要将回放的场景和真实的场景进行分离,在不重新加载地图的情况下快速地进行切换。虚幻引擎给出的方案是对游戏世界World进行进一步的拆分,把所有的Level组织到了三个LevelCollection里面,分别是

  • DynamicSourceLevels,存储真实世界的所有标记为Dynamic的Level(包含里面的所有Actor)

  • StaticLevels,存储了静态的actor,也就是回放过程中不会发生变化的对象,通常指那些不可破坏建筑(通过关卡编辑器里面的Static选项,可以设置任何一个SubLevel是属于DynamicSourceLevels还是StaticLevels的,PersistLevel永远是Dynamic的)

  • DynamicDuplicatedLevels,回放世界的Level(包含里面的所有Actor),会把DynamicSourceLevels里面的所有Level都复制一遍

在游戏地图Loading的时候,我们就会把这三种LevelCollection全部构建并加载进来(可以通过Experimental_ShouldPreDuplicateMap来决定某张地图是否可以复制Level到DynamicDuplicatedLevels),这样在进行回放的时候我们只要控制LevelCollection的显示和隐藏就可以瞬间对真实世界和回放世界进行切换了。

判断一个对象是否处于回放世界(DynamicDuplicatedLevels)也很简单

UWorld* World = WorldContextObject->GetWorld();
ULevel* Level = Cast<ULevel>(WorldContextObject->GetTypedOuter<ULevel>());
if (World && Level)

  FLevelCollection* const DuplicateCollection = World->FindCollectionByType(ELevelCollectionType::DynamicDuplicatedLevels);
  if (DuplicateCollection)
  
    for (auto& TempLevel : DuplicateCollection->GetLevels())
    
      if (TempLevel == Level)
      
        return true;
      
    
  

要注意的是,由于LevelCollection的引入,原来很多逻辑都变得复杂了。

  1. 不同LevelCollection的Tick是有先后顺序的,默认情况下是按照他们在数组的排列顺序DynamicSourceLevels-> StaticLevels-> DynamicDuplicatedLevels,这个顺序可能影响我们的代码逻辑或者摄像机更新时机

  2. 回放世界DynamicDuplicatedLevels里面也会有很多Actor,如果不加处理的话很有可能也被录制到回放系统中,造成嵌套录制

  3. 当一个DynamicDuplicatedLevels执行Tick的时候,会通过FScopedLevelCollectionContextSwitch来切换当前的ActiveCollection,进而修改当前World的GameState等指针,所以在回放时需要注意获取对象的正确性。(比如下图获取PC的迭代器接口,在DuplicatedLevels Tick时只能获取到回放世界的PC)

    FScopedLevelCollectionContextSwitch::FScopedLevelCollectionContextSwitch(const FLevelCollection* const InLevelCollection, UWorld* const InWorld)
    
      if (World)
      
        const int32 FoundIndex = World->GetLevelCollections().IndexOfByPredicate([InLevelCollection](const FLevelCollection& Collection)
        
          return &Collection == InLevelCollection;
        );
        World->SetActiveLevelCollection(FoundIndex);
      
    
    void UWorld::SetActiveLevelCollection(int32 LevelCollectionIndex)
    
      ActiveLevelCollectionIndex = LevelCollectionIndex;
      const FLevelCollection* const ActiveLevelCollection = GetActiveLevelCollection();
      if (ActiveLevelCollection == nullptr)
      
        return;
      
      PersistentLevel = ActiveLevelCollection->GetPersistentLevel();
      GameState = ActiveLevelCollection->GetGameState();
      NetDriver = ActiveLevelCollection->GetNetDriver();
      DemoNetDriver = ActiveLevelCollection->GetDemoNetDriver();
     
  4. 用于回放的UDemoNetDriver会绑定一个LevelCollection(通过传入PlayReplay的参数LevelPrefixOverride来决定)。当触发回放逻辑后,即UDemoNetDriver::TickDispatch每帧解析回放数据时,我们也会通过FScopedLevelCollectionContextSwitch主动切换到当前DemoNetDriver绑定的LevelCollection,保证解析回放数据时可以通过Outer找到回放场景(DynamicDuplicatedLevels)

bool UDemoNetDriver::InitConnect(FNetworkNotify* InNotify, const FURL& ConnectURL, FString& Error)

  const TCHAR* const LevelPrefixOverrideOption = ConnectURL.GetOption(TEXT("LevelPrefixOverride="), nullptr);
  if (LevelPrefixOverrideOption)
  
    SetDuplicateLevelID(FCString::Atoi(LevelPrefixOverrideOption));
  


  if (GetDuplicateLevelID() == -1)
  
    // Set this driver as the demo net driver for the source level collection.
    FLevelCollection* const SourceCollection = World->FindCollectionByType(ELevelCollectionType::DynamicSourceLevels);
    if (SourceCollection)
    
      SourceCollection->SetDemoNetDriver(this);
    
  
  else
  
    // Set this driver as the demo net driver for the duplicate level collection.
    FLevelCollection* const DuplicateCollection = World->FindCollectionByType(ELevelCollectionType::DynamicDuplicatedLevels);
    if (DuplicateCollection)
    
      DuplicateCollection->SetDemoNetDriver(this);
    
    
  

4.2 回放录制与播放分离

考虑到在死亡回放的时候不会影响正常比赛的进行和录制,所以我们通常也需要讲录制逻辑与播放逻辑完全分离。

简单来说,就是创建两个不同的Demonetdriver,一个用于回放的录制,另一个用于回放的播放。在游戏一开始的时候,就创建一个DemonetdriverA来开始录制游戏,当角色死亡触发回放的时候,这时候创建一个新的DemonetdriverB来进行回放数据的读取并播放,整个过程中DemonetdriverA一直在处于录制状态,不会受到任何影响。(需要我们手动重写GameInstance::PlayReplay函数,因为默认的逻辑每次创建一个新的Demonetdriver就会删掉原来的那个)

4.3 基于内存的回放数据流

当然,想要实现真正的快速切换,只将回放场景与真实世界的分离还不够,我们还需要保证回放数据的加载也能达到毫秒级别?所以这个时候就不能再使用前面提到的LocalFileNetworkReplayStreamer把数据放到磁盘上,正确的方案是采用基于内存数据流的ReplayStreamer来加快回放数据的读取。下面是InMemoryNetworkReplayStreamer对回放数据的组织方式,每帧的数据流会根据时间分段存储在StreamChunks里面,而不同时间点的快照则会存储在Checkpoints数组里面。对于射击游戏,我们通常会在比赛一开始就执行录制,录制的数据会不断写到下面的结构里面并在整场比赛中一直保存着,当玩家被击杀后就可以立刻从这里取出数据来进行回放。

//基于内存回放
TArray<FString> AdditionalOptions;
AdditionalOptions.Add(TEXT("ReplayStreamerOverride=InMemoryNetworkReplayStreaming"));
GameInstance->StartRecordingReplay("MyTestReplay", "MyTestReplay", Options);
//GameInstance->PlayReplay("MyTestReplay", GetWorld(), AdditionalOptions);


//MyProject.build.cs
DynamicallyLoadedModuleNames.AddRange(
    new string[] 
        "NetworkReplayStreaming",
        //"LocalFileNetworkReplayStreaming",可选,按需配置加载
        "InMemoryNetworkReplayStreaming",
        //"HttpNetworkReplayStreaming"
    
);

关于死亡回放/精彩镜头其实还有很多细节问题,这里列举一些(最后一节会给出一些建议):

  • 引擎编辑器里面默认不支持DynamicDuplicatedLevels的创建,所以在不改源码的情况下无法在编辑器里面实现死亡回放功能。

  • 回放世界与真实世界都是存在的,可以通过SetVisible来处理渲染,但是回放世界的物理怎么控制?

  • 回放世界默认情况下不会复制Controller(容易和本地的Controller发生冲突),所以很多相关的接口都不能使用

  • 由于不同Collection的Tick更新时机不同,但是Controller只有一个,所以回放的时候要注意Controller的更新时机

  • 默认的录制逻辑都是在本地客户端实现的,可能对客户端有一定的性能影响

更多细节建议到GitHub参考虚幻竞技场的源码
https://github.com/EpicGames/UnrealTournament

Livematch观战系统

在CSGO、Dota、堡垒之夜等游戏里,都支持玩家观战的功能,即玩家可以通过客户端直接进入到某个正在进行的比赛的场景里进行实时观战。不过一般情况下并不是严格意义上的完全实时,通常根据情况会有一定程度的延迟。

实现该功能的一个简易方案就是让观战的玩家作为一个客户端连接进去,然后实时的接受服务器同步数据来进行观战。这种方式既简单,效果也好,但是问题也非常致命——观战的玩家可能会影响正常服务器性能,无法很好的支持大量的玩家进入。

所以大部分的游戏实现的都是另一种方案,即基于Webserver和回放的观战系统。这种方案的思路如下图,首先我们需要专门搭建一个用于处理回放数据的WebServer,源源不断的接收来自GameServer的回放录制数据。然后客户端在请求观战时不会去连接GameServer,而是直接通过Http请求当前需要播放的回放数据,从WebServer拿到数据后再进行本地的解析与播放。虽然会有一定的延迟,但是理想情况下效果和直接连入战斗服观战是一样的。

前面我们提到过基于Httpstream的数据流,正是为这种方案而实现的。去仔细的看一下FHttpNetworkReplayStreamer的接口实现,都是通过Http协议对回放数据进行封装而后通过固定的格式来发给WebServer的(格式可以按照需求修改,和WebServer的代码要事先规定统一)。

// FHttpNetworkReplayStreamer::StartStreaming 
// 开始下载时会发送一个特定的Http请求
const FString URL = FString::Printf(TEXT("%sreplay/%s/startDownloading?user=%s"), *ServerURL, *SessionName, *UserName);
HttpRequest->SetURL(URL);
HttpRequest->SetVerb(TEXT("POST"));
HttpRequest->OnProcessRequestComplete().BindRaw(this, &FHttpNetworkReplayStreamer::HttpStartDownloadingFinished);
// Add the request to start downloading
AddRequestToQueue(EQueuedHttpRequestType::StartDownloading, HttpRequest);

性能优化/使用建议

前面我们花了大量的篇幅,由浅入深的讲解了回放系统的概念以及原理,而后又对两个具体的实践案例(死亡回放、观战系统)做了进一步的分析,希望这样可以帮助大家更好的理解UE乃至其他游戏里面回放系统的思想思路。

文章的最后,我会根据个人经验给大家分享一些使用建议:

  • 如果想创建自定义的DemonetDriver,需要在配置文件里面

//DefaultEngine.ini MyTestRec为项目名称
[/Script/Engine.Engine]
!NetDriverDefinitions=ClearArray
NetDriverDefinitions=(DefName="GameNetDriver",DriverClassName="/Script/OnlineSubsystemUtils.IpNetDriver",DriverClassNameFallback="/Script/OnlineSubsystemUtils.IpNetDriver")
+NetDriverDefinitions=(DefName="DemoNetDriver",DriverClassName="/Script/MyTestRec.MyTestRecDemoNetDriver",DriverClassNameFallback="/Script/MyTestRec.MyTestRecDemoNetDriver")


[/Script/Engine.DemoNetDriver]
NetConnectionClassName="/Script/Engine.DemoNetConnection"
DemoSpectatorClass="/Script/MyTestRec.MyTestRecSpectatorPC"
  • 回放的录制既可以在客户端也可以在服务器

  • 在回放中同步Controller要慎重,如果是在客户端录制回放数据最好不要同步Controller,因此玩家相关同步数据也最好不要放在Controller里面(PS代替)

  • RPC由于没有状态,所以很容易在回放里面丢失掉,对于有持续状态的同步效果(比如播放一个比较长的动画、道具的显示隐藏等),不要用RPC做同步(改为属性同步)。总的来说,整个项目代码里面都要克制地使用RPC。

  • 死亡回放涉及到Level的拷贝,这回明显的增大游戏的内存使用,对于那些在回放中不会发生变化的物体(比如staticmesh的墙体),一定要放置到StaticLevels里面。

  • 播放回放时会预先多加载5秒左右的数据(MAX_PLAYBACK_ BUFFER_SECONDS),在观战系统里面要注意这个间隔,如果Http发送不及时就很容易造成卡顿

  • 回放里面很多NetStartActor的逻辑都是通过资源路径来定位的,使用不当很容易造成一些资源引用、垃圾回收以及资源查找的问题。举个例子,比如我们删除了一个NetStartActor对象(已经标记为Pendingkill了),但是通过StaticFindObject我们仍然能查到这个对象,这时候如果再拿这个路径去生成Actor就会报错并提示场景里面已经有一个一模一样的Actor了。

  • Checkpoint的加载可能会造成性能问题,可以考虑分帧去处理

  • 回放有很多加载和生成对象的逻辑,很容易造成卡顿,建议项目内自己维护一个对象池来优化

  • 死亡回放结束的时候一定要及时的清理回放数据,否则可能造成内存的持续增加,也可能造成一些残留的Actor对功能造成影响

  • 回放世界和真实世界是同一个物理场景,需要避免碰撞

    • 尽量避免在回放世界打开物理

    • 通过设置PxFilterFlags并修改引擎的碰撞规则处理

  • 序列化的操作要注意很多细节,比如结尾处是不是一个完整的字节。很多奇怪的Check在网络部分的崩溃八成都是序列化反序列化没有匹配造成的

  • 临时拷贝尽量使用全局static,对于较大的数据,一定要压缩,效果明显

 往期文章推荐 

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

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

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

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

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

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

Unreal回放系统剖析(下)

Unreal回放系统剖析(下)

Unreal回放系统剖析(上)

Unreal回放系统剖析(上)

Unreal回放系统剖析(上)

Unreal回放系统剖析(上)