0 前言
对于地图切换(也即关卡切换),UE还提供了无缝切换(Seamless Travel)和非无缝切换(Non-Seamless Travel),无缝切换使用异步加载关卡资源,是非阻塞式切换,而非无缝切换即为前面介绍的同步加载关卡资源,是阻塞式切换(传送门),在网络联机游戏中,无缝切换不会导致网络断开,而非无缝会导致网络断开后重连,UE推荐在网络联机游戏中使用无缝切换,感兴趣可以看看官方文档(有点晦涩难懂T_T,因此需要深入研究一番)。
注意,无缝切换和关卡流送都是异步加载关卡资源的,但它们是不同的,关卡流送只是在当前World添加SubLevel,并不会切换World,而无缝切换会销毁之前的World而创建新的World。
为了避免大篇幅代码,文中涉及的代码仅包含关键部分
1 使用指南
按照一贯的套路,先会用再探究原理
先手动创建一个空的或者有少量actor的过渡地图,然后通过ProjectSettings来设置(也可以不自己创建,UE会自动创建一个“dummy World”来作为过渡地图)
至于为什么需要设置过渡地图?试想如果没有过渡地图,那么在当前地图中加载(异步加载的,后面会详细介绍)的新地图之后,此时World同时存在新旧两个地图,如果都比较大的话,内存告急!
开启SeamlessTravel,通过代码或蓝图都可以
通过蓝图开启
修改自己的项目的Character类(我的是ThirdPersonMPCharacter),添加SeamlessTravelTo,这里是为了能在蓝图中用而对SeamlessTravel进行了简单的封装
//在ThirdPersonMPCharacter.h文件中添加
UFUNCTION(BlueprintCallable, Category = "SeamlessTravel")
void SeamlessTravelTo(FString URL);
//在ThirdPersonMPCharacter.cpp里实现
void AThirdPersonMPCharacter::SeamlessTravelTo(FString URL)
{
//服务器执行
//if (GetLocalRole() == ROLE_Authority)
//{
// UWorld* World = GetWorld();
// World->ServerTravel(URL, false);
//}
//客户端执行
if (IsLocallyControlled())
{
UWorld* World = GetWorld();
//本例以客户端无缝切换作为例子。
World->GetFirstPlayerController()->ClientTravel(URL, ETravelType::TRAVEL_Relative, true);
}
}
在关卡里放一个触发器,然后编辑ThirdPersonCharacter蓝图
ok了,例子很简单,当角色跑到TriggerSphere附近就会触发无缝切换。
2 底层实现
UE提供了ClientTravel和ServerTravel两个无缝切换相关的接口,分别用于客户端和服务端的无缝切换。
2.1 ClientTravel
void APlayerController::ClientTravel(const FString& URL, ETravelType TravelType, bool bSeamless, FGuid MapPackageGuid)
{
// 最终执行ClientTravelInternal_Implementation
ClientTravelInternal(URL, TravelType, bSeamless, MapPackageGuid);
}
void APlayerController::ClientTravelInternal_Implementation(const FString& URL, ETravelType TravelType, bool bSeamless, FGuid MapPackageGuid)
{
UWorld* World = GetWorld();
if (bSeamless && TravelType == TRAVEL_Relative)
{
//无缝切换
World->SeamlessTravel(URL);
}
else
{
//非无缝切换,会在UEngine::TickWorldTravel中调用UEngine::Browse进行非无缝切换
GEngine->SetClientTravel(World, *URL, (ETravelType)TravelType);
}
}
如上代码所示,使用ClientTravel时要注意,TravelType要为TRAVEL_Relative、bSeamless为true才能进行无缝切换,否则就是非无缝切换(非无缝切换),代码如下:
void UEngine::TickWorldTravel(FWorldContext& Context, float DeltaSeconds)
{
// 处理客户端travel.
if( !Context.TravelURL.IsEmpty() )
{
if (Browse( Context, FURL(&Context.LastURL,*TravelURLCopy,(ETravelType)Context.TravelType), Error ) == EBrowseReturnVal::Failure)
{
if (Context.World() == NULL)
{
BrowseToDefaultMap(Context);
}
}
return;
}
}
2.2 ServerTravel
bool UWorld::ServerTravel(const FString & FURL, bool bAbsolute, bool bShouldSkipGameNotify)
{
AGameModeBase* GameMode = GetAuthGameMode();
// Set the next travel type to use
NextTravelType = bAbsolute ? TRAVEL_Absolute : TRAVEL_Relative;
if (NextURL.IsEmpty() && (!IsInSeamlessTravel() || bShouldSkipGameNotify))
{
NextURL = FURL;
if (!bShouldSkipGameNotify)
{
//这里面会将NextURL置空!
GameMode->ProcessServerTravel(FURL, bAbsolute);
}
else
{
NextSwitchCountdown = 0;
}
}
return true;
}
注意,ServerTravel和ClientTravel类似,如果没有执行到ProcessServerTravel,会在TickWorldTravel里根据NextTravelType和NextURL调用UEngine::Browse方法,这是非无缝切换。代码如下:
void UEngine::TickWorldTravel(FWorldContext& Context, float DeltaSeconds)
{
// 处理服务端的travel
if( !Context.World()->NextURL.IsEmpty() )
{
if( Context.World()->NextSwitchCountdown <= 0.f )
{
FString NextURL = Context.World()->NextURL;
EBrowseReturnVal::Type Ret = Browse( Context, FURL(&Context.LastURL,*NextURL,(ETravelType)Context.World()->NextTravelType), Error );
//...
}
}
}
再来看看ProcessServerTravel
void AGameModeBase::ProcessServerTravel(const FString& URL, bool bAbsolute)
{
StartToLeaveMap(); //这里可以写一些切换前的逻辑
UWorld* World = GetWorld();
bool bSeamless = (bUseSeamlessTravel && GetWorld()->TimeSeconds < 172800.0f); // 172800 seconds == 48 hours
//这里会通过RPC调用客户端的ClientTravel
APlayerController* LocalPlayer = ProcessClientTravel(URLMod, NextMapGuid, bSeamless, bAbsolute);
if (bSeamless)
{
//无缝切换
World->SeamlessTravel(World->NextURL, bAbsolute);
World->NextURL = TEXT(""); //如果执行到这里,就是上面提到的,TickWorldTravel里就不会再Browse了!
}
}
它通过调用ProcessClientTravel,先通知客户端进行切换(即针对Remote player调用ClientTravel,逻辑较为简单,不再展开),然后开始SeamlessTravel。
可以看到,不管是ClientTravel还是ServerTravel,都有无缝和非无缝两种方式,非无缝通过调用UEngine::Browse实现,而无缝通过调用SeamlessTravel实现。
2.3 SeamlessTravel
通过上面的代码也可以知道,不管是ClientTravel还是ServerTravel的无缝切换都调用了SeamlessTravel。因为逻辑较为复杂,UE专门封装了一个FSeamlessTravelHandler类来处理。其大致流程为:
具体的代码流程:
void UWorld::SeamlessTravel(const FString & SeamlessTravelURL, bool bAbsolute, FGuid MapPackageGuid)
{
FURL NewURL(&GEngine->LastURLFromWorld(this), *SeamlessTravelURL, bAbsolute ? TRAVEL_Absolute : TRAVEL_Relative);
FSeamlessTravelHandler& SeamlessTravelHandler = GEngine->SeamlessTravelHandlerForWorld(this);
//开始切换
if (!SeamlessTravelHandler.StartTravel(this, NewURL, MapPackageGuid) && !SeamlessTravelHandler.IsInTransition())
{
//...
}
}
在StartTravel中先检查是否有TransitionMap,如果没有就默认创建一个World作为过渡,最后开始异步加载TransitionMap资源。
bool FSeamlessTravelHandler::StartTravel(UWorld * InCurrentWorld, const FURL & InURL, const FGuid & InGuid)
{
FWorldContext& Context = GEngine->GetWorldContextFromWorldChecked(InCurrentWorld);
//缓存DestinationMap的URL,在Tick的时候会用到
PendingTravelURL = InURL;
bSwitchedToDefaultMap = false;
FName DestinationMapName = FName(*PendingTravelURL.Map);
if (DefaultMapFinalName == CurrentMapName || DefaultMapFinalName == DestinationMapName)
{
//如果此时已经在TransitionMap中了,直接开始加载DestinationMap即可
StartLoadingDestination();
return;
}
//获取ProjectSettings里配置的TransitionMap
FString TransitionMap = GetDefault<UGameMapsSettings>()->TransitionMap.GetLongPackageName();
if (TransitionMap.IsEmpty())
{
//如果没有配置TransitionMap,就默认创建一个的World给SeamlessTravelHandler
SetHandlerLoadedData(NULL, UWorld::CreateWorld(EWorldType::PIE, false));
}
else
{
//异步加载TransitionMap
LoadPackageAsync(TransitionMap,FLoadPackageAsyncDelegate::CreateRaw(this, &FSeamlessTravelHandler::SeamlessTravelLoadCallback),0,(CurrentWorld->WorldType == EWorldType::PIE ? PKG_PlayInEditor : PKG_None),Context.PIEInstance);
}
return true;
}
异步加载TransitionMap资源完毕,将World设置到SeamlessTravelHandler。
void FSeamlessTravelHandler::SeamlessTravelLoadCallback(const FName & PackageName, UPackage * LevelPackage, EAsyncLoadingResult::Type Result)
{
if (IsInTransition())
{
UWorld* World = UWorld::FindWorldInPackage(LevelPackage);
//将LoadWorld设置给SeamlessTravelHandler
SetHandlerLoadedData(LevelPackage, World);
}
}
在Tick中完成CurrentMap切换到TransitionMap,完成之后开始异步加载DestinationMap
void UEngine::TickWorldTravel(FWorldContext& Context, float DeltaSeconds)
{
// 处理无缝travel.
if (Context.SeamlessTravelHandler.IsInTransition())
{
Context.SeamlessTravelHandler.Tick();
}
}
//这里执行CurrentMap -> TransitionMap,TransitionMap -> DestinationMap的处理
UWorld* FSeamlessTravelHandler::Tick()
{
if ((LoadedPackage != nullptr || LoadedWorld != nullptr) && CurrentWorld->NextURL == TEXT(""))
{
// 一些常规判断,比如是否正在加载中?是否加载完的World和当前World是同一个?
//保存通过AGameModeBase::GetSeamlessTravelActorList和APlayerController::GetSeamlessTravelActorList处理的Actor,将它们带到下一个地图去
TArray<AActor*> KeepActors;
if (AGameModeBase* AuthGameMode = CurrentWorld->GetAuthGameMode())
{
AuthGameMode->GetSeamlessTravelActorList(!bSwitchedToDefaultMap, KeepActors);
}
for (FLocalPlayerIterator It(GEngine, CurrentWorld); It; ++It)
{
if (It->PlayerController != nullptr)
{
It->PlayerController->GetSeamlessTravelActorList(!bSwitchedToDefaultMap, KeepActors);
}
}
//用于存放实际需要保存到下一个地图的actor
TArray<AActor*> ActuallyKeptActors;
//匿名函数,将需要保存到下一个Map的actor放到ActuallyKeptActors,其他的删掉
auto ProcessActor = [this, &KeepAnnotation, &ActuallyKeptActors, NetDriver](AActor* TheActor) -> bool
{
//...
};
// 开始清理当前World
CurrentWorld->CleanupWorld(bSwitchedToDefaultMap);
// 设置当前World
FWorldContext& CurrentContext = GEngine->GetWorldContextFromWorldChecked(CurrentWorld);
CurrentContext.SetCurrentWorld(LoadedWorld);
CurrentWorld = nullptr;
//给当前加载的World设置GameMode
if (bCreateNewGameMode)
{
LoadedWorld->SetGameMode(PendingTravelURL);
}
if (bSwitchedToDefaultMap)
{
// remember the last used URL
CurrentContext.LastURL = PendingTravelURL;
}
else
{
bSwitchedToDefaultMap = true;
CurrentWorld = LoadedWorld;
//开始加载DestinationMap
StartLoadingDestination();
}
}
return OutWorld;
}
异步加载DestinationMap,加载完后同样调用SeamlessTravelLoadCallback,然后在Tick中完成从TransitionMap切换到DestinationMap(代码同上)。
void FSeamlessTravelHandler::StartLoadingDestination()
{
if (bTransitionInProgress && bSwitchedToDefaultMap)
{
//加载DestinationMap
LoadPackageAsync(URLMapPackageName,PendingTravelGuid.IsValid() ? &PendingTravelGuid : NULL,*URLMapPackageToLoadFrom,FLoadPackageAsyncDelegate::CreateRaw(this, &FSeamlessTravelHandler::SeamlessTravelLoadCallback),
PackageFlags,PIEInstanceID);
}
}
地图无缝切换的流程图
3 使用注意事项
1.使用ClientTravel时,TravelType一定要为TRAVEL_Relative,并且bSeamless一定要为true
2. ServerTravel在编辑器运行模式下运行是不生效的,具体代码如下:
bool AGameModeBase::CanServerTravel(const FString& FURL, bool bAbsolute)
{
if (World->WorldType == EWorldType::PIE && bUseSeamlessTravel && !FParse::Param(FCommandLine::Get(), TEXT("MultiprocessOSS")))
{
//在PIE模式下,返回false
return false;
}
return true;
}
3.在travel到新地图后,由于会清理当前world后生成新的World,因此如果有想要保留到下一个World的actor,需要手动添加,具体做法是重载下面两个接口:
//在服务端上执行
virtual void GetSeamlessTravelActorList(bool bToTransition, TArray<AActor*>& ActorList);
//在客户端上执行
virtual void GetSeamlessTravelActorList(bool bToEntry, TArray<class AActor*>& ActorList);
注意,这里有两个过程,如果bToTransition/bToEntry为true是指保存到Transition的world,而为false是保存到Destination的World。
4.Browse是非无缝切换,它不但会同步加载地图资源(参考关卡切换流程),而且还会断开网络连接,具体代码:
EBrowseReturnVal::Type UEngine::Browse( FWorldContext& WorldContext, FURL URL, FString& Error )
{
//在LoadMap会干掉NetDriver!
LoadMap(URL);
}
bool UEngine::Browse( FWorldContext& WorldContext, FURL URL, FString& Error )
{
// Clean up networking
ShutdownWorldNetDriver(WorldContext.World());
}
4 总结
本文简单的介绍了地图无缝切换的使用,然后从无缝的两个入口ClientTravel、ServerTravel入手,详细介绍了SeamlessTravel的代码实现。其基本原理为用异步加载的方式分别加载TransitionMap和DestinationMap,然后在FSeamlessTravelHandler::Tick中完成CurrentMap到TransitionMap和TransistionMaap到DestinationMap的切换。