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

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

本篇分为上下两篇,上篇内容请关注:
《Exploring in UE4》Unreal回放系统剖析(上)


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

在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)。

  4. 用于回放的UDemoNetDriver会绑定一个LevelCollection(通过传入PlayReplay的参数LevelPrefixOverride来决定)。当触发回放逻辑后,即UDemoNetDriver::TickDispatch每帧解析回放数据时,我们也会通过FScopedLevelCollectionContextSwitch主动切换到当前DemoNetDriver绑定的LevelCollection,保证解析回放数据时可以通过Outer找到回放场景(DynamicDuplicatedLevels)

////3/////
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////
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,对于较大的数据,一定要压缩,效果明显。

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

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

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