0 前言
UE引擎的一大特色就是通过关卡流送机制提供了大世界的解决方案。什么是关卡流送?我的理解是将超大关卡划分为若干小关卡,然后通过一定的策略动态加载/卸载小关卡,而置身其中的玩家感觉不到加载/卸载过程,仿佛它们本来就长这样。那么它是如何实现的呢?下面就让我们来一步步揭开它的神秘面纱吧。
为了避免大篇幅代码,文中涉及的代码仅包含关键部分
1 流式关卡的操作指南
创建流关卡有手动添加和WorldComposition自动生成两种方式,手动方式比较适用于创建少量且易于编辑的关卡,如果关卡太多太大,手动编辑无疑是非常麻烦的,此时就需要用到WorldComposition方式了,它会把N个关卡用类似于拼图的形式拼接成大世界。
1.1 手动方式
首先创建几个SubLevel,然后按照下面的操作即可完成。
其中SubLevel两种加载方式,一种是会随着PersistentLevel(主关卡)一起加载(Always Loaded),一种是通过代码动态加载(Blueprint)。
1.2 WorldComposition方式
在PersistentLevel(主关卡)的WorldSettings中开启WorldComposition
注意,在开启WorldComposition前不要手动给PersistentLevel添加SubLevel,否则无法开启WorldComposition,弹出如下MessageBox。
开启之后,会自动将PersistentLevel所在文件夹下的所有Level添加进PersistentLevel,此时添加进去的SubLevel是没有加载的,可以右击SubLevel选择Load来加载。
点击如上红框内按钮打开WorldComposition编辑界面,你可以在这里任意编排SubLevel(前提是先Load要编排的SubLevel)!
可以通过滑动鼠标滚轮来放大该界面,其中箭头表示玩家位置,黄色框表示选中的SubLevel,StreamingDistance表示该关卡距离玩家50000cm内会加载。
2 流式关卡的代码实现
前面介绍了流关卡的创建方式,接下来看看他的代码实现
World是所有关卡的管理者,不管是通过手动方式还是WorldComposition方式,流式关卡最终都会被添加到World的StreamingLevels和StreamingLevelsToConsider容器中,这将用于之后的流式关卡加载。
下面是两种创建流式关卡方式的代码实现。
2.1 手动方式
手动创建方式会在添加SubLevel时创建一个ULevelStreamingDynamic类型的流式关卡,创建完毕之后调用World类的AddStreamingLevel方法添加进StreamingLevelsToConsider。
void FStreamingLevelCollectionModel::HandleAddExistingLevelSelected(const TArray<FAssetData>& SelectedAssets, bool bRemoveInvalidSelectedLevelsAfter)
{
//...
//AddedLevelStreamingClass为ULevelStreamingDynamic::StaticClass()
EditorLevelUtils::AddLevelsToWorld(CurrentWorld.Get(), MoveTemp(PackageNames), AddedLevelStreamingClass);
}
//创建StreamingLevel并调用AddStreamingLevel方法
ULevelStreaming* UEditorLevelUtils::AddLevelToWorld_Internal(UWorld* InWorld, const TCHAR* LevelPackageName, TSubclassOf<ULevelStreaming> LevelStreamingClass, const FTransform& LevelTransform)
{
//...
StreamingLevel = NewObject<ULevelStreaming>(InWorld, LevelStreamingClass, NAME_None, RF_NoFlags, NULL);
// Add the new level to world.
InWorld->AddStreamingLevel(StreamingLevel);
return StreamingLevel;
}
//添加到StreamingLevels和StreamingLevelsToConsider
void UWorld::AddStreamingLevel(ULevelStreaming* StreamingLevelToAdd)
{
//...
StreamingLevels.Add(StreamingLevelToAdd);
StreamingLevelsToConsider.Add(StreamingLevelToAdd);
}
2.2 WorldComposition方式
当开启WorldComposition时,引擎会遍历PersistentLevel(即WorldRoot)所在文件下的所有关卡,生成WorldCompositionTile,给每个Tile生成对应的StreamingLevel,最终添加进StreamingLevelsToConsider
bool FUnrealEdMisc::EnableWorldComposition(UWorld* InWorld, bool bEnable)
{
//创建WorldCompostion会调用Rescan()
auto WorldCompostion = NewObject<UWorldComposition>(InWorld);
InWorld->WorldComposition = WorldCompostion;
return true;
}
void UWorldComposition::Rescan()
{
//收集Tile packageNames
Gatherer.BuildTileCollection(WorldRootFilename);
for (const FString& TilePackageName : Gatherer.TilesCollection)
{
//读取WorldTileInfo信息
FWorldTileInfo Info;
if (!FWorldTileInfo::Read(TileFilename, Info))
//创建WorldCompositionTile
FWorldCompositionTile Tile;
Tiles.Add(Tile);
}
// 为每个tile创建StreamingLevel
PopulateStreamingLevels();
}
// 为每个tile创建StreamingLevel
void UWorldComposition::PopulateStreamingLevels()
{
for (const FWorldCompositionTile& Tile : Tiles)
{ //创建StreamingLevel
TilesStreaming.Add(CreateStreamingLevel(Tile));
}
}
//创建StreamingLevel
ULevelStreaming* UWorldComposition::CreateStreamingLevel(const FWorldCompositionTile& InTile) const
{
ULevelStreamingDynamic* StreamingLevel = NewObject<ULevelStreamingDynamic>(OwningWorld, NAME_None, RF_Transient);
return StreamingLevel;
}
//打开WorldComposition编辑界面或者运行游戏时添加到World的StreamingLevelsToConsider
void UWorldComposition::PostLoad()
{
World->SetStreamingLevels(TilesStreaming);
}
3 流式关卡的加载/卸载
3.1 流式关卡加载/卸载状态机
流式关卡的加载/卸载通过一个状态机实现,状态包括当前状态和目标状态,这些状态会在UpdateStreamingState中更新。
//当前状态
enum class ECurrentState : uint8
{
Removed,
Unloaded,
FailedToLoad,
Loading,
LoadedNotVisible,
MakingVisible,
LoadedVisible,
MakingInvisible
};
//目标状态
enum class ETargetState : uint8
{
Unloaded,
UnloadedAndRemoved,
LoadedNotVisible,
LoadedVisible,
};
EngineLoop的Tick调用World的UpdateLevelStreaming,前面多次提到的StreamingLevelsToConsider在此处排上用场了。
void UWorld::UpdateLevelStreaming()
{
for (int32 Index = StreamingLevelsToConsider.GetStreamingLevels().Num() - 1; Index >= 0; --Index)
{
if (ULevelStreaming* StreamingLevel = StreamingLevelsToConsider.GetStreamingLevels()[Index])
{
//这里会调用ULevelStreaming的UpdateStreamingState
FStreamingLevelPrivateAccessor::UpdateStreamingState(StreamingLevel, bUpdateAgain, bRedetermineTarget);
}
}
}
更新关卡加载/卸载的状态机
void ULevelStreaming::UpdateStreamingState(bool& bOutUpdateAgain, bool& bOutRedetermineTarget)
{
//匿名函数,开启异步加载关卡的线程
auto UpdateStreamingState_RequestLevel = [&]()
{
//发起加载关卡的请求
RequestLevel(World, bAllowLevelLoadRequests, (bBlockOnLoad ? ULevelStreaming::AlwaysBlock : ULevelStreaming::BlockAlwaysLoadedLevelsOnly));
};
//状态机,由于代码比较多,不展开
switch(CurrentState)
{
case ECurrentState::MakingVisible:
break;
case ECurrentState::MakingInvisible:
break;
case ECurrentState::Loading:
// Just waiting
break;
case ECurrentState::Unloaded:
switch (TargetState)
{
case ETargetState::LoadedNotVisible:
{
//当前状态为未加载,目标状态为加载后不显示
UpdateStreamingState_RequestLevel();
}
break;
case ETargetState::UnloadedAndRemoved:
break;
default:
ensure(false);
}
break;
case ECurrentState::LoadedNotVisible:
switch (TargetState)
{
case ETargetState::LoadedVisible:
break;
case ETargetState::Unloaded:
//卸载关卡
DiscardPendingUnloadLevel(World);
break;
case ETargetState::LoadedNotVisible:
//加载关卡
UpdateStreamingState_RequestLevel();
break;
default:
ensure(false);
}
break;
case ECurrentState::LoadedVisible:
switch (TargetState)
{
case ETargetState::LoadedNotVisible:
break;
case ETargetState::LoadedVisible:
UpdateStreamingState_RequestLevel();
break;
default:
ensure(false);
}
break;
case ECurrentState::FailedToLoad:
break;
default:
}
}
状态机图
3.2 关卡加载流程
发起异步加载资源的请求
bool ULevelStreaming::RequestLevel(UWorld* PersistentWorld, bool bAllowLevelLoadRequests, EReqLevelBlock BlockPolicy)
{
//开启异步加载线程
LoadPackageAsync(DesiredPackageName.ToString(), nullptr, *PackageNameToLoadFrom, FLoadPackageAsyncDelegate::CreateUObject(this, &ULevelStreaming::AsyncLevelLoadComplete), PackageFlags, PIEInstanceID, GetPriority());
return true;
}
资源加载完毕的回调
void ULevelStreaming::AsyncLevelLoadComplete(const FName& InPackageName, UPackage* InLoadedPackage, EAsyncLoadingResult::Type Result)
{
ULevel* Level = World->PersistentLevel;
if (Level)
{
SetLoadedLevel(Level);
}
}
3.3 关卡卸载流程
标记需要卸载的关卡
void ULevelStreaming::DiscardPendingUnloadLevel(UWorld* PersistentWorld)
{
if (PendingUnloadLevel->bIsVisible)
{
PersistentWorld->RemoveFromWorld(PendingUnloadLevel);
}
if (!PendingUnloadLevel->bIsVisible)
{
//标记要卸载的关卡
FLevelStreamingGCHelper::RequestUnload(PendingUnloadLevel);
}
}
卸载关卡
void UWorld::Tick( ELevelTick TickType, float DeltaSeconds )
{
//调用PrepareStreamedOutLevelsForGC卸载关卡
GEngine->ConditionalCollectGarbage();
}
//卸载关卡
void FLevelStreamingGCHelper::PrepareStreamedOutLevelsForGC()
{
// Iterate over all level objects that want to be unloaded.
for (int32 LevelIndex = 0; LevelIndex < LevelsPendingUnload.Num(); LevelIndex++)
{
ULevel* Level = LevelsPendingUnload[LevelIndex].Get();
Level->CleanupLevel();
}
}
4 关卡流送机制
UE提供了三种关卡流送机制:LevelStreamingVolume、WorldComposition和代码(LoadStreamLevel/UnloadStreamLevel)
4.1 LevelStreamingVolume
这种方式比较简单,类似在关卡放一个Trigger,当玩家进入/离开该区域,会自动加载/卸载指定的关卡。
LevelStreamingVolume继承自Actor,我们可以像其他Actor一样放入指定的关卡(通常指PersistentLevel),还可以编辑它的大小来调整有效区域。
将LevelStreamingVolume拖动关卡指定位置,并调整大小
编辑SubLevel,将刚拖到关卡里面的LevelStreamingVolume赋给SubLevel即可。
在运行时,引擎通过判断玩家位置是否在LevelStreamingVolume内来动态加载/卸载指定SubLevel
void UWorld::Tick( ELevelTick TickType, float DeltaSeconds )
{
ProcessLevelStreamingVolumes();
}
//每次Tick获取玩家ViewLocation信息
void UWorld::ProcessLevelStreamingVolumes(FVector* OverrideViewLocation)
{
FVector ViewLocation(0,0,0);
FRotator ViewRotation(0,0,0);
PlayerActor->GetPlayerViewPoint( ViewLocation, ViewRotation );
//判断是不是在LevelStreamingLevel的区域内
bViewpointInVolume = StreamingVolume->EncompassesPoint( ViewLocation );
if ( bViewpointInVolume )
{
StreamingSettings |= FVisibleLevelStreamingSettings( (EStreamingVolumeUsage) StreamingVolume->StreamingUsage );
VisibleLevelStreamingObjects.Add( LevelStreamingObject, StreamingSettings );
}
}
判断是不是在LevelStreamingLevel的Bounds内
bool AVolume::EncompassesPoint(FVector Point, float SphereRadius/*=0.f*/, float* OutDistanceToPoint) const
{
FBoxSphereBounds Bounds = GetBrushComponent()->CalcBounds(GetBrushComponent()->GetComponentTransform());
const float DistanceSqr = Bounds.GetBox().ComputeSquaredDistanceToPoint(Point);
return DistanceSqr >= 0.f && DistanceSqr <= FMath::Square(SphereRadius);
}
4.2 WorldComposition
前面已经介绍了如何利用WorldComposition制作流式关卡,在运行时,引擎通过判断关卡与玩家的距离(默认50000cm)来动态加载/卸载关卡,做开放世界大地图推荐用这种方式。
void UWorld::Tick( ELevelTick TickType, float DeltaSeconds )
{
if (WorldComposition)
{
WorldComposition->UpdateStreamingState();
}
}
计算玩家的ViewLocation
void UWorldComposition::UpdateStreamingState()
{
TArray<FVector, TInlineAllocator<16>> Locations;
for (int32 PlayerIndex = 0; PlayerIndex < NumPlayers; ++PlayerIndex)
{
ULocalPlayer* Player = GEngine->GetGamePlayer(PlayWorld, PlayerIndex);
if (Player && Player->PlayerController)
{
FVector ViewLocation;
FRotator ViewRotation;
Player->PlayerController->GetPlayerViewPoint(ViewLocation, ViewRotation);
Locations.Add(ViewLocation);
}
}
if (Locations.Num())
{
UpdateStreamingState(Locations.GetData(), Locations.Num());
}
}
根据玩家位置来加载/卸载关卡
void UWorldComposition::UpdateStreamingState(const FVector* InLocations, int32 Num)
{
TArray<FDistanceVisibleLevel> DistanceVisibleLevels;
TArray<FDistanceVisibleLevel> DistanceHiddenLevels;
//根据玩家位置来将关卡放到可见和不可见列表
GetDistanceVisibleLevels(InLocations, Num, DistanceVisibleLevels, DistanceHiddenLevels);
// 不可见的关卡卸载
for (const FDistanceVisibleLevel& Level : DistanceHiddenLevels)
{
CommitTileStreamingState(OwningWorld, Level.TileIdx, false, false, bShouldBlock, Level.LODIndex);
}
// 可见的关卡加载
for (const FDistanceVisibleLevel& Level : DistanceVisibleLevels)
{
CommitTileStreamingState(OwningWorld, Level.TileIdx, true, true, bShouldBlock, Level.LODIndex);
}
}
距离计算玩家与Level的距离,AABB包围盒算法
void UWorldComposition::GetDistanceVisibleLevels(const FVector* InLocations,int32 NumLocations,TArray<FDistanceVisibleLevel>& OutVisibleLevels,TArray<FDistanceVisibleLevel>& OutHiddenLevels) const
{
for (int32 TileIdx = 0; TileIdx < Tiles.Num(); TileIdx++)
{
const FWorldCompositionTile& Tile = Tiles[TileIdx];
bool bIsVisible = false;
// 判断Sphere是否与LevelBounds组成Box相交
FIntPoint LevelPositionXY = FIntPoint(Tile.Info.AbsolutePosition.X, Tile.Info.AbsolutePosition.Y);
FIntPoint LevelOffsetXY = LevelPositionXY - WorldOriginLocationXY;
FBox LevelBounds = Tile.Info.Bounds.ShiftBy(FVector(LevelOffsetXY));
int32 TileStreamingDistance = Tile.Info.GetStreamingDistance(LODIdx);
for (int32 LocationIdx = 0; LocationIdx < NumLocations; ++LocationIdx)
{
FSphere QuerySphere(InLocations[LocationIdx], TileStreamingDistance);
if (FMath::SphereAABBIntersection(QuerySphere, LevelBounds))
{
bIsVisible = true;
break;
}
}
if (bIsVisible)
{
OutVisibleLevels.Add(VisibleLevel);
}
else
{
OutHiddenLevels.Add(VisibleLevel);
}
}
}
4.3 LoadStreamLevel/UnloadStreamLevel
该方式可由程序员在特定逻辑下通过代码来加载/卸载流式关卡,比如在触发某个Actor的Overlap事件时加载/卸载关卡。
首先在PersistentLevel添加需要Load的子关卡(也可以通过代码动态添加进PersistentLevel),因为在调用加载/卸载前会去查找存放与World里的StreamingLevels,找不到返回NULL
ULevelStreaming* FStreamLevelAction::FindAndCacheLevelStreamingObject( const FName LevelName, UWorld* InWorld )
{
for (ULevelStreaming* LevelStreaming : InWorld->GetStreamingLevels())
{
if (LevelStreaming && LevelStreaming->GetWorldAssetPackageName().EndsWith(SearchPackageName, ESearchCase::IgnoreCase))
{
return LevelStreaming;
}
}
return NULL;
}
然后在蓝图或者c++代码里调用非常简单,不再详述。
5 总结
本文从创建流式关卡的操作指南开始,一步一步详细的介绍了流式关卡的代码实现,资源加载与卸载,以及引擎提供的三种流送机制。至此,对UE中的流式关卡有了一定的认识。