Bounded vertical route partition / streaming for handcrafted spiral climb maps (Only Up / Only Climb). Not World Partition, not Level Instances as core arch. - Z-cell segmentation + optional XY subcells, editor builder + commandlet - NonDestructive visibility backend + streaming-level backend - Offline HLOD proxies: MergedMesh / SimplifiedProxy (ProxyLOD) - Universal Level Instance + Packed Level Actor + HISM support (flatten+merge) - Vertical-aware runtime streamer (full/preload/HLOD/unload, hysteresis, velocity prediction, fall-risk), WP-style debug viz + vp.* console - Safe-by-default: only static decor streams; sky/lights/gameplay stay persistent Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
258 lines
9.8 KiB
C++
258 lines
9.8 KiB
C++
// 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");
|
|
}
|
|
}
|