UE5中提供了WorldPartitionRuntimeSpatialHash 与 新版WorldPartitionRuntimeHashSet两种流送Hash,官方推荐使用 WorldPartitionRuntimeHashSet(文档链接)
不过秉着学习的想法,还是先看下SpatialHash的实现,再与最新的HashSet对比其优缺点。
参考文章
若不懂 WorldPartition Streaming 的Cell、Grid概念,可先学习下面两篇文章
流送信息生成
这是流送最重要且复杂的一步,入口函数为 UWorldPartitionRuntimeSpatialHash::GenerateStreaming()
-
首先会收集在WorldSetting中的RuntimeGrid配置,并追加 None 的 RuntimeGrid 名,建立 RuntimeGridName -> GridIndex 的映射 GridsMapping
-
遍历场景Actor,按 RuntimeGrid 划分,得到 GridActorSetInstances
TArray<TArray<const IStreamingGenerationContext::FActorSetInstance*>> GridActorSetInstances;
GridActorSetInstances.InsertDefaulted(0, AllGrids.Num());
StreamingGenerationContext->ForEachActorSetInstance([&GridsMapping, &GridActorSetInstances](const IStreamingGenerationContext::FActorSetInstance& ActorSetInstance)
{
int32* FoundIndex = GridsMapping.Find(ActorSetInstance.RuntimeGrid);
if (!FoundIndex)
{
//@todo_ow should this be done upstream?
UE_LOG(LogWorldPartition, Log, TEXT("Invalid partition grid '%s' referenced by actor cluster"), *ActorSetInstance.RuntimeGrid.ToString());
}
int32 GridIndex = FoundIndex ? *FoundIndex : 0;
GridActorSetInstances[GridIndex].Add(&ActorSetInstance);
});
- 通过 GetPartitionedActors() 在对应的 Grid 中,进一步划分 Actor。
const FBox WorldBounds = StreamingGenerationContext->GetWorldBounds();
for (int32 GridIndex = 0; GridIndex < AllGrids.Num(); GridIndex++)
{
const FSpatialHashRuntimeGrid& Grid = AllGrids[GridIndex];
// 进一步划分 Actor
const FSquare2DGridHelper PartitionedActors = GetPartitionedActors(WorldBounds, Grid, GridActorSetInstances[GridIndex], Settings);
// 创建 Runtime 使用的 StreamingGrid
if (!CreateStreamingGrid(Grid, PartitionedActors, StreamingPolicy, OutPackagesToGenerate))
{
return false;
}
}
Actor划分到对应的GridCell
该步骤函数为 GetPartitionedActors()
- 首先创建 FSquare2DGridHelper,这一步里面根据CellSize、WorldBounds,计算了LevelCount、GridSize(这里需搞清 GridSize、CellSize的概念)
FSquare2DGridHelper PartitionedActors = GetGridHelper(WorldBounds, FVector(Grid.Origin, 0), Grid.CellSize, Settings.bUseAlignedGridLevels);
Level越低(Level0是最高级),GridSize越小,CellSize越大,最低级GridSize == 1,WorldBounds范围(不会被卸载,AlwaysLoad)。
Levels.Reserve(GridLevelCount);
int64 CurrentCellSize = CellSize;
int64 CurrentGridSize = GridSize;
for (int32 Level = 0; Level < GridLevelCount; ++Level)
{
int64 LevelGridSize = CurrentGridSize;
if (!bUseAlignedGridLevels)
{
LevelGridSize = (Level == GridLevelCount - 1) ? CurrentGridSize : CurrentGridSize + 1;
}
Levels.Emplace(FVector2D(InOrigin), CurrentCellSize, LevelGridSize, Level);
CurrentCellSize <<= 1;
CurrentGridSize >>= 1;
}
// Make sure the always loaded cell exists
GetAlwaysLoadedCell();
bUseAlignedGridLevels 就是 RuntimeGrid 配置中为了解决边界对齐时,导致边界上Actor被分配到最低一级Level的选项。
最后的 GetAlwaysLoadedCell() ,是为了先将最低级Level的Cell创建出来,后续马上用来Check,
通过 LastGridLevel.ForEachIntersectingCells() 对创建的 Level 进行Check,不做也不影响后续逻辑。
- FSquare2DGridHelper::ForEachIntersectingCells() 这个函数相当重要,是快速查找相交Cells的重要组成部分(RecastNavigation中也有这种操作,看来是个相当常见好用的空间哈希方法)。
将传入的包围盒坐标映射为CellCoord,快速得到与传入包围盒相交的Cell,执行操作。
LastGridLevel.ForEachIntersectingCells(WorldBounds, [&IntersectingCellCount](const FGridCellCoord2& Coords) { ++IntersectingCellCount; });
int32 ForEachIntersectingCells(const FBox& InBox, TFunctionRef<void(const FGridCellCoord2&)> InOperation) const
{
return ForEachIntersectingCellsBreakable(InBox, [InOperation](const FGridCellCoord2& Vector) { InOperation(Vector); return true; });
}
int32 ForEachIntersectingCellsBreakable(const FBox& InBox, TFunctionRef<bool(const FGridCellCoord2&)> InOperation) const
{
int32 NumCells = 0;
FGridCellCoord2 MinCellCoords;
FGridCellCoord2 MaxCellCoords;
const FBox2D Bounds2D(FVector2D(InBox.Min), FVector2D(InBox.Max));
if (GetCellCoords(Bounds2D, MinCellCoords, MaxCellCoords))
{
for (int64 y = MinCellCoords.Y; y <= MaxCellCoords.Y; y++)
{
for (int64 x = MinCellCoords.X; x <= MaxCellCoords.X; x++)
{
const FGridCellCoord2 Coord(x, y);
// Validate that generated coordinate is valid (in case we reached the 64-bit limit of cell index)
if (IsValidCoords(Coord))
{
if (!InOperation(Coord))
{
return NumCells;
}
++NumCells;
}
}
}
}
return NumCells;
}
- 接下来,就是将 RuntimeGrid 的 Actor 划分到尽可能小的、能容纳该Actor的GridCell。
FirstPotentialGridLevel 是 CellSize 刚好比 ActorBound 大的Level,因为Actor不是居中放置的,可能跨多个Cell,因此从 FirstPotentialGridLevel 开始逐级判断,找到Actor仅与一个GridCell相交的那级GridLevel(找不到,则放入最低级Level,AlwaysLoad)。
for (const IStreamingGenerationContext::FActorSetInstance* ActorSetInstance : ActorSetInstances)
{
check(ActorSetInstance->ActorSet->Actors.Num() > 0);
FSquare2DGridHelper::FGridLevel::FGridCell* GridCell = nullptr;
if (ActorSetInstance->bIsSpatiallyLoaded)
{
const FBox2D ActorSetInstanceBounds(FVector2D(ActorSetInstance->Bounds.Min), FVector2D(ActorSetInstance->Bounds.Max));
int32 LocationPlacementGridLevel = 0;
// UseLocationPlacement 是用Actor中心点而不是包围盒来做划分
if (ShouldActorUseLocationPlacement(ActorSetInstance, ActorSetInstanceBounds, LocationPlacementGridLevel))
{
……
}
else
{
// Find grid level cell that encompasses the actor cluster bounding box and put actors in it.
const FVector2D ClusterSize = ActorSetInstanceBounds.GetSize();
const double MinRequiredCellExtent = FMath::Max(ClusterSize.X, ClusterSize.Y);
// 计算得出 CellSize 刚好比 ActorBound 大的Level
const int32 FirstPotentialGridLevel = FMath::Max(FMath::CeilToDouble(FMath::Log2(MinRequiredCellExtent / (double)PartitionedActors.CellSize)), 0);
// 从低Level到高Level判断
for (int32 GridLevelIndex = FirstPotentialGridLevel; GridLevelIndex < PartitionedActors.Levels.Num(); GridLevelIndex++)
{
FSquare2DGridHelper::FGridLevel& GridLevel = PartitionedActors.Levels[GridLevelIndex];
// 选择仅与一个GridCell相交的那级GridLevel
if (GridLevel.GetNumIntersectingCells(ActorSetInstance->Bounds) == 1)
{
GridLevel.ForEachIntersectingCells(ActorSetInstance->Bounds, [&GridLevel, &GridCell](const FGridCellCoord2& Coords)
{
check(!GridCell);
GridCell = &GridLevel.GetCell(Coords);
});
break;
}
}
}
}
// 找不到,则放到最低级Level(即是AlwaysLoad)
if (!GridCell)
{
GridCell = &PartitionedActors.GetAlwaysLoadedCell();
}
GridCell->AddActorSetInstance(ActorSetInstance);
}
- 最后一步,GridCell->AddActorSetInstance(ActorSetInstance),根据Actor的DataLayer分组。
void AddActorSetInstance(const IStreamingGenerationContext::FActorSetInstance* ActorSetInstance)
{
const FDataLayersID DataLayersID = FDataLayersID(ActorSetInstance->DataLayers);
FGridCellDataChunk& ActorDataChunk = DataChunks.FindOrAddByHash(DataLayersID.GetHash(), FGridCellDataChunk(ActorSetInstance->DataLayers, ActorSetInstance->ContentBundleID));
ActorDataChunk.AddActorSetInstance(ActorSetInstance);
}
创建SreamingGrid
该步骤对应函数为 UWorldPartitionRuntimeSpatialHash::CreateStreamingGrid()。
简单来说就是使用上面划分好的GridCell,为每个GridCell的每种DataLayer,创建Runtime使用的 StreamingCell,同时生成对应序列化的Package,记录其关联的Actor信息。
- PopulateCellActorInstances():没看明白,看注释是与PIE的 AlwaysLoad 相关,需要过滤掉一些Actor,总之是取出该Cell对应的Actors。
- GetCellNameString():根据WorldName、GridName、CellCoord、DataLayer等,得到唯一的CellName
形如:MapName_Grid_Test_L0_X16_Y-21 - CreateRuntimeCell():创建RuntimeCell,同时创建 RuntimeCellData(UWorldPartitionRuntimeCellDataSpatialHash,含有空间划分的一些信息,主要就一个ContentBounds)
- PopulateRuntimeCell():将Actor 放入对应的 RuntimeStreamingCell,其中 RuntimeCell->Fixup() 会去除重复的Actor。
- 随后,将生成的 RuntimeStreamingCell 放入对应的格子GridCells中。
- CellIndex代表2D Grid坐标
- GridLevel.LayerCells[LayerCellIndex].GridCells 中存放的是同个格子中、不同 DataLayer 的 StreamingCell
PopulateRuntimeCell(StreamingCell, FilteredActors, OutPackagesToGenerate);
int32 LayerCellIndex;
int32* LayerCellIndexPtr = GridLevel.LayerCellsMapping.Find(CellIndex);
if (LayerCellIndexPtr)
{
LayerCellIndex = *LayerCellIndexPtr;
}
else
{
LayerCellIndex = GridLevel.LayerCells.AddDefaulted();
GridLevel.LayerCellsMapping.Add(CellIndex, LayerCellIndex);
}
// GridCells 中存放的是不同 DataLayer 的 StreamingCell
GridLevel.LayerCells[LayerCellIndex].GridCells.Add(StreamingCell);
至此 Runtime 使用的流送数据生成完毕,后续对这些Cells进行距离判断,以Cell为单位加/卸载。
流送Update
- UWorldPartitionSubsystem::UpdateStreamingState():入口函数
- 遍历生成的全部StreamingGrid,通过 FSpatialHashStreamingGrid::GetCells() 获得流送范围内的 GridCells
这里面好几个for循环,详见下面代码注释
// 遍历流送源
for (const FWorldPartitionStreamingSource& Source : Sources)
{
// 遍历流送源中的多个Shape
Source.ForEachShape(GridLoadingRange, GridName, /*bProjectIn2D*/ true, [&](const FSphericalSector& Shape)
{
FStreamingSourceInfo Info(Source, Shape);
// 遍历Grid的Level、Level再计算出Shape范围内的Cell
Helper.ForEachIntersectingCells(Shape, [&](const FGridCellCoord& Coords)
{
bool bAddedActivatedCell = false;
#if !UE_BUILD_SHIPPING
if ((GFilterRuntimeSpatialHashGridLevel == INDEX_NONE) || (GFilterRuntimeSpatialHashGridLevel == Coords.Z))
#endif
{
// 遍历格子中的全部Cell(可能存在不同DataLayer)
ForEachRuntimeCell(Coords, [&](const UWorldPartitionRuntimeCell* Cell)
{
bool bIncludeCell = true;
// Z轴方向的判定,但注意划分的时候没考虑Z值,这块实现不完整
if (bEnableZCulling)
{
const FVector2D CellMinMaxZ(Cell->GetContentBounds().Min.Z, Cell->GetContentBounds().Max.Z);
bIncludeCell = TRange<double>::Inclusive(CellMinMaxZ.X, CellMinMaxZ.Y).Overlaps(TRange<double>::Inclusive(Shape.GetCenter().Z - Shape.GetRadius(), Shape.GetCenter().Z + Shape.GetRadius()));
}
if (bIncludeCell)
{
……
}
});
}
if (bAddedActivatedCell)
{
AllActivatedCells.FindOrAdd(Coords).Add(Info);
}
});
});
}
bSnapNonAlignedGridLevelsToLowerLevels
该选项开启时,拿着 AllActivatedCells 去获取低一级的Cell,并将这些Cell添加到 OutActivateCells,视作需激活的Cell,这一步断点看了还是不明白,暂时先略过。
FSquare2DGridHelper::ForEachIntersectingCells 的实现
FSquare2DGridHelper::ForEachIntersectingCells()先利用包围盒得到可能相交的Cells,再进行更细致的距离计算得到真正相交的Cells。
- 利用AABB相交判定进行预筛选是个经典的技巧。
- FVector2D::Max(CellBounds.Min, FVector2D::Min(SphereCenter, CellBounds.Max)) 这一句得到包围盒到圆心最近的点,好巧妙的方法。
int32 ForEachIntersectingCells(const FSphere& InSphere, TFunctionRef<void(const FGridCellCoord2&)> InOperation) const
{
int32 NumCells = 0;
// @todo_ow: rasterize circle instead?
const FBox Box(InSphere.Center - FVector(InSphere.W), InSphere.Center + FVector(InSphere.W));
const double SquareDistance = InSphere.W * InSphere.W;
const FVector2D SphereCenter(InSphere.Center);
// 这里传入的是圆的包围盒,先利用包围盒快速得到可能相交的Cell,最终调用到ForEachIntersectingCellsBreakable
ForEachIntersectingCells(Box, [this, SquareDistance, &SphereCenter, &InOperation, &NumCells](const FGridCellCoord2& Coords)
{
// No need to check validity of coords as it's already done
const bool bCheckIsValidCoords = false;
FBox2D CellBounds;
GetCellBounds(Coords, CellBounds, bCheckIsValidCoords);
// 得到CellBounds包围盒内离圆心最近的点的距离
FVector2D Delta = SphereCenter - FVector2D::Max(CellBounds.Min, FVector2D::Min(SphereCenter, CellBounds.Max));
// 相交计算
if ((Delta.X * Delta.X + Delta.Y * Delta.Y) < SquareDistance)
{
InOperation(Coords);
NumCells++;
}
});
return NumCells;
}
EnableZCulling
这部分实现不完全,仅在最终判断CellBounds的时候考虑上Z值,但划分Actors、创建Cell的时候没做处理,没在垂直方向分割出Cell,同一个格子GridCell中的内容,不论Z值相差多少,都会同时被加载。(估计是因为新版的 UWorldPartitionRuntimeHashSet 已经是真正的3D流送了,所以这边实现一半就草草收尾了)
- Cell->GetContentBounds() 是Cell中全部Actor的包围盒
ForEachRuntimeCell(Coords, [&](const UWorldPartitionRuntimeCell* Cell)
{
bool bIncludeCell = true;
// Z轴方向的判定,但注意划分的时候没考虑Z值,这块实现不完整
if (bEnableZCulling)
{
const FVector2D CellMinMaxZ(Cell->GetContentBounds().Min.Z, Cell->GetContentBounds().Max.Z);
bIncludeCell = TRange<double>::Inclusive(CellMinMaxZ.X, CellMinMaxZ.Y).Overlaps(TRange<double>::Inclusive(Shape.GetCenter().Z - Shape.GetRadius(), Shape.GetCenter().Z + Shape.GetRadius()));
}
……
});