赞
踩
在前面的文章中介绍了Level与World的概念以及它们的关系,也介绍了UE实现开放大世界的关卡流送机制,现在对Level与World以及开放大世界有了一定的了解,那么,接下来让我们思考这样一个问题,那种关卡类的游戏(即带有传送门这种)应该如何切换关卡?这里说的切换关卡是指切换PersistentLevel,根据前面的介绍,流式关卡只是在PersistentLevel(主关卡)上添加进SubLevel,并没有切换PersistentLevel,接下来让我们来看看UE中是如何实现关卡切换的。
为了避免大篇幅代码,文中涉及的代码仅包含关键部分
在分析关卡切换的底层实现前,我们先来看看如果要实现关卡切换,应该怎么做。
按照一贯的套路,先会用再探究原理
创建两个Level,在其中一个Level放一个Trigger(或其他带有碰撞盒的Actor)
编辑Character的蓝图
运行游戏,当角色跑到Trigger所在的地方就会切换关卡,用法很非常简单。
前面已经会使用关卡切换的API了,那么接下来我们看看引擎中是如何实现关卡切换的。
在分析关卡流程前,我们先来看看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,它在关卡切换中扮演着非常重要的角色,它保存了关卡切换的上下文,下面将详细介绍。
从前面的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是一一对应的。
其一,在切换之前,会干掉当前世界以及其中的任何对象,因此上一个World的对象是不会保存到下一个World,只能在下一个World中重新创建!
其二,LoadPackage是在主线程中执行的(非异步线程),也就是主线程阻塞的,在切换过程中是不能做其他逻辑的,那么,我们要实现LoadingScreen这样的功能应该怎么做?
有两种解决思路:
一种是是先异步加载关卡资源,然后切换关卡的时候发现已经在内存中了(上面代码中的FindPackage),就直接切换了,但是这个有一点需要考虑,在当前关卡加载另外一个关卡,如果关卡资源很大的话,建议用一个过渡关卡(UE中叫TransistionMap)来做;
一种是UE推荐的方式,就是在切换关卡前,开启一个异步线程来做LoadingScreen相关的逻辑,大致就是用Slate框架来写(Slate是独立框架,不依赖Engine的Tick),UE提供的MoviePlayer就是用来做LoadingScreen的,具体实现不在此详述,感兴趣的可以参考官方demo ARPG工程里LoadingScreen的实现,还有一个很好用的插件也可以研究研究(AsyncLoadingScreen)。
本文从使用OpenLevel接口开始,介绍了关卡的切换流程,用这种方式切换关卡,真正的切换时机是下一次Tick,切换关卡会导致World生成,也提到了几个注意的点和解决LoadingScreen的方案。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。