0 前言
在前面的文章中介绍了Level与World的概念以及它们的关系,也介绍了UE实现开放大世界的关卡流送机制,现在对Level与World以及开放大世界有了一定的了解,那么,接下来让我们思考这样一个问题,那种关卡类的游戏(即带有传送门这种)应该如何切换关卡?这里说的切换关卡是指切换PersistentLevel,根据前面的介绍,流式关卡只是在PersistentLevel(主关卡)上添加进SubLevel,并没有切换PersistentLevel,接下来让我们来看看UE中是如何实现关卡切换的。
为了避免大篇幅代码,文中涉及的代码仅包含关键部分
1 关卡切换的接口
在分析关卡切换的底层实现前,我们先来看看如果要实现关卡切换,应该怎么做。
按照一贯的套路,先会用再探究原理
创建两个Level,在其中一个Level放一个Trigger(或其他带有碰撞盒的Actor)
编辑Character的蓝图
运行游戏,当角色跑到Trigger所在的地方就会切换关卡,用法很非常简单。
2 关卡切换流程分析
前面已经会使用关卡切换的API了,那么接下来我们看看引擎中是如何实现关卡切换的。
2.1 OpenLevel接口
在分析关卡流程前,我们先来看看OpenLevel接口干了什么
void UGameplayStatics::OpenLevel(const UObject* WorldContextObject, FName LevelName, bool bAbsolute, FString Options)
{
const ETravelType TravelType = (bAbsolute ? TRAVEL_Absolute : TRAVEL_Relative);
FWorldContext &WorldContext = GEngine->GetWorldContextFromWorldChecked(World);
FURL TestURL(&WorldContext.LastURL, *Cmd, TravelType);
//设置将要切换的关卡信息,包括Url,TravelType
GEngine->SetClientTravel( World, *Cmd, TravelType );
}
//设置将要切换的关卡信息
void UEngine::SetClientTravel( UWorld *InWorld, const TCHAR* NextURL, ETravelType InTravelType )
{
FWorldContext &Context = GetWorldContextFromWorldChecked(InWorld);
// 设置URL和Type,下一帧UGameEngine::Tick()时候会处理这些信息
Context.TravelURL = NextURL;
Context.TravelType = InTravelType;
}
其中FURL即关卡的文件路径,有一定的格式,可以用来表示本地或者远端的关卡路径,如果是本地的关卡,那么只需要填Map字段
struct ENGINE_API FURL
{
FString Protocol; //可以是"unreal" or "http"
FString Host;//可以是"204.157.115.40" or "unreal.epicgames.com"这种
int32 Port; //端口
FString Map; //地图名词,比如MainCity
//...
};
TravelType表示绝对路径还是相对路径
enum ETravelType
{
/** Absolute URL. */
TRAVEL_Absolute,
/** Partial (carry name, reset server). */
TRAVEL_Partial,
/** Relative URL. */
TRAVEL_Relative,
//...
};
还有一个比较重要的类FWorldContext,它在关卡切换中扮演着非常重要的角色,它保存了关卡切换的上下文,下面将详细介绍。
2.2 关卡切换流程
从前面的OpenLevel入口可知,它仅仅是设置了TravelURL和TravelType,那么关卡到底是如何切换的?因此我们第一时间应该会想到可能是在下一次Tick的时候才真正切换的。
话不多说,直接上调用堆栈
如图所见,EngineLoop:Tick经过层层调用,最终调到了UEngine:TickWorldTravel这个函数,这就是我们要关注的重点——关卡切换的起点!至于为啥是从UUnrealEdEngine:Tick中调用是因为在PIE模式下调试的,但这不是本文的重点,感兴趣可以看看下面的代码
int32 FEngineLoop::Init()
{
//如果不是在编辑器中的话,GEngine为UGameEngine的实例
if( !GIsEditor )
{
EngineClass = StaticLoadClass( UGameEngine::StaticClass(), nullptr, *GameEngineClassName);
GEngine = NewObject<UEngine>(GetTransientPackage(), EngineClass);
}
else
{ //UUnrealEdEngine继承自UEditorEngine
EngineClass = StaticLoadClass(UUnrealEdEngine::StaticClass(), nullptr, *UnrealEdEngineClassName);
GEngine = GEditor = GUnrealEd = NewObject<UUnrealEdEngine>(GetTransientPackage(), EngineClass);
}
return 0;
}
关卡切换流程图
TickWorldTravel作为关卡切换的入口,这里会涉及到无缝切换(SeamlessTravelHandler)、服务端切换和客户端切换,其中无缝切换比较复杂,不在此处展开,而服务端和客户端的逻辑基本一致,只是传的参数不一样,所以这里就只看客户端流程。
void UEngine::TickWorldTravel(FWorldContext& Context, float DeltaSeconds)
{
//处理无缝切换相关
if (Context.SeamlessTravelHandler.IsInTransition())
{
Context.SeamlessTravelHandler.Tick();
}
//处理服务端切换关卡
if( !Context.World()->NextURL.IsEmpty() )
{
Context.World()->NextSwitchCountdown -= DeltaSeconds;
if( Context.World()->NextSwitchCountdown <= 0.f )
{
EBrowseReturnVal::Type Ret = Browse( Context, FURL(&Context.LastURL,*NextURL,(ETravelType)Context.World()->NextTravelType), Error );
if (Ret != EBrowseReturnVal::Success )
{
BrowseToDefaultMap(Context); //切换到默认关卡
}
return;
}
}
//处理客户端切换关卡
if( !Context.TravelURL.IsEmpty() )
{
if (Browse( Context, FURL(&Context.LastURL,*TravelURLCopy,(ETravelType)Context.TravelType), Error ) == EBrowseReturnVal::Failure)
{
if (Context.World() == NULL)
{
BrowseToDefaultMap(Context);//切换到默认关卡
}
}
return;
}
return;
}
URL相关的处理
EBrowseReturnVal::Type UEngine::Browse( FWorldContext& WorldContext, FURL URL, FString& Error )
{
WorldContext.TravelURL = TEXT("");
if( URL.IsLocalInternal() )
{
//加载map
return LoadMap( WorldContext, URL, NULL, Error ) ? EBrowseReturnVal::Success : EBrowseReturnVal::Failure;
}
}
开始加载map
bool UEngine::LoadMap( FWorldContext& WorldContext, FURL URL, class UPendingNetGame* Pending, FString& Error )
{
//干掉当前World
if( WorldContext.World())
{
//清空Player相关
for(auto It = WorldContext.OwningGameInstance->GetLocalPlayerIterator(); It; ++It)
{
ULocalPlayer *Player = *It;
WorldContext.World()->DestroyActor(Player->PlayerController->GetPawn(), true);
WorldContext.World()->DestroyActor(Player->PlayerController, true);
}
//清空Level,AISystem、PhysicScene等
WorldContext.World()->CleanupWorld();
WorldContext.SetCurrentWorld(nullptr);
}
//开始切换到新World
if (NewWorld == NULL)
{
//先看看内存中是否已经有这Map了
WorldPackage = FindPackage(nullptr, *URL.Map);
//如果没有的话,LoadPackage
if (WorldPackage == nullptr)
{
WorldPackage = LoadPackage(nullptr, *URL.Map, (WorldContext.WorldType == EWorldType::PIE ? LOAD_PackageForPIE : LOAD_None));
}
//从map的packge中取World
NewWorld = UWorld::FindWorldInPackage(WorldPackage);
}
//创建新World之后
GWorld = NewWorld;
WorldContext.SetCurrentWorld(NewWorld);
}
这里可以看出,通过OpenLevel的方式切换关卡(PersistentLevel),实际上会将整个World切换,这里World与PersistentLevel是一一对应的。
2.3 需要注意的点
其一,在切换之前,会干掉当前世界以及其中的任何对象,因此上一个World的对象是不会保存到下一个World,只能在下一个World中重新创建!
其二,LoadPackage是在主线程中执行的(非异步线程),也就是主线程阻塞的,在切换过程中是不能做其他逻辑的,那么,我们要实现LoadingScreen这样的功能应该怎么做?
有两种解决思路:
一种是是先异步加载关卡资源,然后切换关卡的时候发现已经在内存中了(上面代码中的FindPackage),就直接切换了,但是这个有一点需要考虑,在当前关卡加载另外一个关卡,如果关卡资源很大的话,建议用一个过渡关卡(UE中叫TransistionMap)来做;
一种是UE推荐的方式,就是在切换关卡前,开启一个异步线程来做LoadingScreen相关的逻辑,大致就是用Slate框架来写(Slate是独立框架,不依赖Engine的Tick),UE提供的MoviePlayer就是用来做LoadingScreen的,具体实现不在此详述,感兴趣的可以参考官方demo ARPG工程里LoadingScreen的实现,还有一个很好用的插件也可以研究研究(AsyncLoadingScreen)。
3 总结
本文从使用OpenLevel接口开始,介绍了关卡的切换流程,用这种方式切换关卡,真正的切换时机是下一次Tick,切换关卡会导致World生成,也提到了几个注意的点和解决LoadingScreen的方案。