本篇分为高低两篇,上篇内容请关注:
《Exploring in UE4》Unreal回放零碎分析(上)
四、死亡回放/精彩镜头性能的实现
在FPS游戏里,一个角色被击杀之后,往往会以敌方的视角回放本角色被定位、瞄准、射击的过程,这就是咱们常提到的死亡回放(DeathCameraReplay)。相似的,咱们在各种体育游戏外面常常须要在一次得分后展现精彩霎时,这种性能个别称为精彩镜头。
上一节案例应用的是基于本地文件存储的回放零碎,每次播放时都须要从新加载地图。那有没有方法实现相似实况足球的实时精彩回放呢?有的,那就是基于DuplicatedLevelCollection和内存数据流的回放计划。
思考一下,通常射击游戏里的击杀镜头、体育竞技里的精彩时刻对回放的根本需要是什么?这类回放性能往往是在某个工夫点能够无感知地立即切换到回放镜头,并在回放完结后迅速再切换到失常的游戏环境。同时,思考到联机的状况,咱们在回放时要放弃游戏世界的失常运行,从而确保不错过任何服务器的同步信息,不影响其余玩家。
简略总结就是:
- 能够迅速地在实在游戏与回放镜头间切换
- 回放的时候不会影响实在游戏外面的逻辑变动
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的引入,原来很多逻辑都变得复杂了。
- 不同LevelCollection的Tick是有先后顺序的,默认状况下是依照他们在数组的排列程序DynamicSourceLevels-> StaticLevels-> DynamicDuplicatedLevels,这个程序可能影响咱们的代码逻辑或者摄像机更新机会。
- 回放世界DynamicDuplicatedLevels外面也会有很多Actor,如果不加解决的话很有可能也被录制到回放零碎中,造成嵌套录制。
- 当一个DynamicDuplicatedLevels执行Tick的时候,会通过FScopedLevelCollectionContextSwitch来切换以后的ActiveCollection,进而批改以后World的GameState等指针,所以在回放时须要留神获取对象的正确性。(比方下图获取PC的迭代器接口,在DuplicatedLevels Tick时只能获取到回放世界的PC)。
- 用于回放的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.csDynamicallyLoadedModuleNames.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 downloadingAddRequestToQueue(EQueuedHttpRequestType::StartDownloading, HttpRequest);
六、性能优化/应用倡议
后面咱们花了大量的篇幅,由浅入深地解说了回放零碎的概念以及原理,而后又对两个具体的实际案例(死亡回放、观战零碎)做了进一步的剖析,心愿这样能够帮忙大家更好地了解UE乃至其余游戏外面回放零碎的思维思路。
文章的最初,我会依据集体教训给大家分享一些应用倡议:
如果想创立自定义的DemonetDriver,须要在配置文件外面:
//DefaultEngine.ini MyTestRec为项目名称[/Script/Engine.Engine]!NetDriverDefinitions=ClearArrayNetDriverDefinitions=(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专栏】如果你深怀绝技,爱“搞点钻研”,乐于分享也博采众长,咱们期待你的退出,让智慧的火花碰撞交错,让常识的传递生生不息!