// Copyright IHY. Vertical Partition Streaming plugin. #include "VerticalPartitionStatics.h" int32 UVerticalPartitionStatics::GetCellZFromWorldZ(float WorldZ, float VolumeMinZ, float CellHeight) { if (CellHeight <= KINDA_SMALL_NUMBER) { return 0; } return FMath::FloorToInt((WorldZ - VolumeMinZ) / CellHeight); } int32 UVerticalPartitionStatics::GetCellXFromWorldX(float WorldX, float VolumeMinX, float XYCellSize) { if (XYCellSize <= KINDA_SMALL_NUMBER) { return 0; } return FMath::FloorToInt((WorldX - VolumeMinX) / XYCellSize); } int32 UVerticalPartitionStatics::GetCellYFromWorldY(float WorldY, float VolumeMinY, float XYCellSize) { if (XYCellSize <= KINDA_SMALL_NUMBER) { return 0; } return FMath::FloorToInt((WorldY - VolumeMinY) / XYCellSize); } int32 UVerticalPartitionStatics::GetNumZCells(const FBox& VolumeBounds, const FVerticalPartitionSettings& S) { if (!VolumeBounds.IsValid || S.CellHeight <= KINDA_SMALL_NUMBER) { return 1; } const float SizeZ = VolumeBounds.Max.Z - VolumeBounds.Min.Z; return FMath::Max(1, FMath::CeilToInt(SizeZ / S.CellHeight)); } FVerticalCellId UVerticalPartitionStatics::GetCellIdFromPoint(const FVector& Point, const FBox& VolumeBounds, const FVerticalPartitionSettings& S) { FVerticalCellId Id; Id.Z = GetCellZFromWorldZ(Point.Z, VolumeBounds.Min.Z, S.CellHeight); if (S.bUseXYSubCells) { Id.X = GetCellXFromWorldX(Point.X, VolumeBounds.Min.X, S.XYCellSize); Id.Y = GetCellYFromWorldY(Point.Y, VolumeBounds.Min.Y, S.XYCellSize); } if (S.bClampToVolumeBounds && VolumeBounds.IsValid) { const int32 NumZ = GetNumZCells(VolumeBounds, S); Id.Z = FMath::Clamp(Id.Z, 0, NumZ - 1); if (S.bUseXYSubCells && S.XYCellSize > KINDA_SMALL_NUMBER) { const int32 NumX = FMath::Max(1, FMath::CeilToInt((VolumeBounds.Max.X - VolumeBounds.Min.X) / S.XYCellSize)); const int32 NumY = FMath::Max(1, FMath::CeilToInt((VolumeBounds.Max.Y - VolumeBounds.Min.Y) / S.XYCellSize)); Id.X = FMath::Clamp(Id.X, 0, NumX - 1); Id.Y = FMath::Clamp(Id.Y, 0, NumY - 1); } } return Id; } FVerticalCellId UVerticalPartitionStatics::GetCellIdFromBounds(const FBox& ActorBounds, const FBox& VolumeBounds, const FVerticalPartitionSettings& S, bool& bOutCrossesMultiple) { bOutCrossesMultiple = false; if (!ActorBounds.IsValid) { return FVerticalCellId(); } // Dominant cell = the cell containing the bounds centre (deterministic). const FVerticalCellId Id = GetCellIdFromPoint(ActorBounds.GetCenter(), VolumeBounds, S); // Does the box span more than one cell on any partitioned axis? const int32 MinZ = GetCellZFromWorldZ(ActorBounds.Min.Z, VolumeBounds.Min.Z, S.CellHeight); const int32 MaxZ = GetCellZFromWorldZ(ActorBounds.Max.Z, VolumeBounds.Min.Z, S.CellHeight); bOutCrossesMultiple = (MinZ != MaxZ); if (S.bUseXYSubCells && !bOutCrossesMultiple) { const int32 MinX = GetCellXFromWorldX(ActorBounds.Min.X, VolumeBounds.Min.X, S.XYCellSize); const int32 MaxX = GetCellXFromWorldX(ActorBounds.Max.X, VolumeBounds.Min.X, S.XYCellSize); const int32 MinY = GetCellYFromWorldY(ActorBounds.Min.Y, VolumeBounds.Min.Y, S.XYCellSize); const int32 MaxY = GetCellYFromWorldY(ActorBounds.Max.Y, VolumeBounds.Min.Y, S.XYCellSize); bOutCrossesMultiple = (MinX != MaxX) || (MinY != MaxY); } return Id; } FBox UVerticalPartitionStatics::GetCellBounds(const FVerticalCellId& Cell, const FBox& VolumeBounds, const FVerticalPartitionSettings& S) { FVector Min = VolumeBounds.Min; FVector Max = VolumeBounds.Max; Min.Z = VolumeBounds.Min.Z + Cell.Z * S.CellHeight; Max.Z = Min.Z + S.CellHeight; if (S.bUseXYSubCells && S.XYCellSize > KINDA_SMALL_NUMBER) { Min.X = VolumeBounds.Min.X + Cell.X * S.XYCellSize; Max.X = Min.X + S.XYCellSize; Min.Y = VolumeBounds.Min.Y + Cell.Y * S.XYCellSize; Max.Y = Min.Y + S.XYCellSize; } FBox Result(Min, Max); if (S.bClampToVolumeBounds && VolumeBounds.IsValid) { Result = Result.Overlap(VolumeBounds); if (!Result.IsValid) { Result = FBox(Min, Max); // degenerate clamp: keep nominal bounds } } return Result; } // Recompute the predicted player Z cell (shared by desired-state + preload). static int32 EffectivePlayerZ(const FVerticalStreamingContext& Ctx, const FVerticalPartitionSettings& S) { int32 Eff = Ctx.PlayerCellZ; if (S.bUsePlayerVelocityPrediction && S.CellHeight > KINDA_SMALL_NUMBER) { Eff += FMath::RoundToInt((Ctx.PlayerVelocityZ * S.VelocityPredictionSeconds) / S.CellHeight); } return Eff; } EVerticalCellDesiredState UVerticalPartitionStatics::GetDesiredStateForCell( const FVerticalCellId& Cell, const FVerticalStreamingContext& Ctx, const FVerticalPartitionSettings& S, EVerticalCellState CurrentState) { const int32 EffPlayerZ = EffectivePlayerZ(Ctx, S); const int32 Dz = Cell.Z - EffPlayerZ; // +above the player int32 FullAbove = S.FullLoadCellsAbove; int32 FullBelow = S.FullLoadCellsBelow; int32 HlodAbove = S.HLODLoadCellsAbove; int32 HlodBelow = S.HLODLoadCellsBelow; // Bias the reach upward: in a climbing map the route is above you. if (S.bPrioritizeAbovePlayer) { HlodAbove += 1; } // Look up -> extend upward; look down -> extend downward. if (S.bUseCameraDirectionPriority) { if (Ctx.CameraPitchDeg > 15.f) { FullAbove += 1; HlodAbove += 1; } else if (Ctx.CameraPitchDeg < -15.f) { HlodBelow += 1; } } // You can always fall back down: keep the cells below loaded out to the unload edge. if (S.bKeepBelowPlayerLoadedOnFallRisk) { HlodBelow = FMath::Max(HlodBelow, S.UnloadCellsBelow); } const int32 Hys = FMath::Max(0, S.HysteresisCells); const bool bCurFullish = (CurrentState == EVerticalCellState::Full || CurrentState == EVerticalCellState::LoadingFull || CurrentState == EVerticalCellState::PreloadingFull); const bool bCurHlodish = (CurrentState == EVerticalCellState::HLOD || CurrentState == EVerticalCellState::LoadingHLOD); EVerticalCellDesiredState Result; if (Dz >= -FullBelow && Dz <= FullAbove) { Result = EVerticalCellDesiredState::Full; } else if (bCurFullish && Dz >= -(FullBelow + Hys) && Dz <= (FullAbove + Hys)) { Result = EVerticalCellDesiredState::Full; // sticky: don't drop a loaded cell at the edge } else if (Dz >= -HlodBelow && Dz <= HlodAbove) { Result = EVerticalCellDesiredState::HLOD; } else if (bCurHlodish && Dz >= -(HlodBelow + Hys) && Dz <= (HlodAbove + Hys)) { Result = EVerticalCellDesiredState::HLOD; } else { Result = EVerticalCellDesiredState::Unloaded; } // XY refinement (optional): far columns never go full and drop out past 2 rings. if (Ctx.bUseXY) { const int32 XYDist = FMath::Max(FMath::Abs(Cell.X - Ctx.PlayerCellX), FMath::Abs(Cell.Y - Ctx.PlayerCellY)); if (XYDist > 2) { Result = EVerticalCellDesiredState::Unloaded; } else if (XYDist > 1 && Result == EVerticalCellDesiredState::Full) { Result = EVerticalCellDesiredState::HLOD; } } return Result; } bool UVerticalPartitionStatics::ShouldPreloadFull(const FVerticalCellId& Cell, const FVerticalStreamingContext& Ctx, const FVerticalPartitionSettings& S) { const int32 EffPlayerZ = EffectivePlayerZ(Ctx, S); const int32 Dz = Cell.Z - EffPlayerZ; const bool bAbovePreload = (Dz > S.FullLoadCellsAbove && Dz <= S.PreloadCellsAbove); const bool bBelowPreload = (Dz < -S.FullLoadCellsBelow && Dz >= -S.PreloadCellsBelow); return bAbovePreload || bBelowPreload; } FLinearColor UVerticalPartitionStatics::GetStateColor(EVerticalCellState State) { switch (State) { case EVerticalCellState::Full: return FLinearColor(0.10f, 0.85f, 0.15f); // green case EVerticalCellState::HLOD: return FLinearColor(0.15f, 0.45f, 0.95f); // blue case EVerticalCellState::LoadingHLOD: case EVerticalCellState::LoadingFull: case EVerticalCellState::PreloadingFull: case EVerticalCellState::Unloading: return FLinearColor(0.95f, 0.85f, 0.10f); // yellow case EVerticalCellState::Error: return FLinearColor(0.95f, 0.10f, 0.10f); // red case EVerticalCellState::Unloaded: default: return FLinearColor(0.35f, 0.35f, 0.35f); // gray } } FString UVerticalPartitionStatics::WarningTypeToString(EVerticalWarningType Type) { switch (Type) { case EVerticalWarningType::ActorCrossesMultipleCells: return TEXT("Actor crosses multiple cells"); case EVerticalWarningType::ActorTooLarge: return TEXT("Actor too large"); case EVerticalWarningType::ActorSimulatesPhysics: return TEXT("Actor simulates physics"); case EVerticalWarningType::ActorHasBlueprintTick: return TEXT("Actor has Blueprint tick"); case EVerticalWarningType::ActorDynamicMobility: return TEXT("Actor has dynamic mobility"); case EVerticalWarningType::ActorHasExternalReferences:return TEXT("Actor has external references"); case EVerticalWarningType::ActorAttachedAcrossCells: return TEXT("Attached children in another cell"); case EVerticalWarningType::ActorDependsOnOtherCell: return TEXT("Depends on actor in another cell"); case EVerticalWarningType::ActorNeedsManualReview: return TEXT("Gameplay tag requires manual review"); case EVerticalWarningType::ActorHasAudioLightNav: return TEXT("Contains audio/light/nav components"); case EVerticalWarningType::ActorNotStreamSafe: return TEXT("Actor not safe for streaming"); case EVerticalWarningType::ActorHasUnsavedChanges: return TEXT("Actor has unsaved changes"); case EVerticalWarningType::CellTooManyActors: return TEXT("Cell has too many actors"); case EVerticalWarningType::CellTooManyDrawCalls: return TEXT("Cell has too many draw calls"); case EVerticalWarningType::HLODGenerationFailed: return TEXT("HLOD generation failed"); case EVerticalWarningType::PackageSaveFailed: return TEXT("Package save failed"); case EVerticalWarningType::NoVolume: return TEXT("No partition volume"); case EVerticalWarningType::DescriptorOutOfDate: return TEXT("Descriptor out of date"); default: return TEXT("None"); } }