本篇分为高低两篇,上篇内容请关注:
《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.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 专栏】如果你深怀绝技,爱“搞点钻研”,乐于分享也博采众长,咱们期待你的退出,让智慧的火花碰撞交错,让常识的传递生生不息!