// Read-only, deterministic strate measurement and connectivity checks. #include "VoxelStrateMeasure.h" #if WITH_EDITOR #include "VoxelGenerator.h" #include "VoxelStrateManager.h" #include "VoxelTypes.h" #include "Async/ParallelFor.h" namespace VoxelStrateMeasurePrivate { struct FSampleGrid { int32 NumX = 0; int32 NumY = 0; int32 NumZ = 0; int32 SampleStep = 1; int32 CellCount = 0; float MinX = 0.0f; float MinY = 0.0f; float MinZ = 0.0f; float MaxX = 0.0f; float MaxY = 0.0f; float MaxZ = 0.0f; float ExtentX = 0.0f; float ExtentY = 0.0f; float ExtentZ = 0.0f; int32 ResolvedMarginVoxels = 0; int32 SampledMinZ = 0; int32 SampledMaxZ = 0; // 1 = air, 0 = solid. The polarity is deliberately explicit at the sampling site below. TArray Air; // Exact scalar samples retained when the player-fit stencil needs a bounded local // surface/clearance interpolation, or when an explicit preview capture requests them. TArray Density; FORCEINLINE int32 Index(int32 X, int32 Y, int32 Z) const { return static_cast( static_cast(X) + static_cast(NumX) * (static_cast(Y) + static_cast(NumY) * static_cast(Z))); } FVector CellCenter(int32 X, int32 Y, int32 Z) const { const float Step = static_cast(SampleStep); const float XOffset = FMath::Min( static_cast(X) * Step + 0.5f * Step, ExtentX - 0.5f); const float YOffset = FMath::Min( static_cast(Y) * Step + 0.5f * Step, ExtentY - 0.5f); const float ZOffset = FMath::Min( static_cast(Z) * Step + 0.5f * Step, ExtentZ - 0.5f); return FVector(MinX + XOffset, MinY + YOffset, MinZ + ZOffset); } bool ContainsPoint(const FVector& Point) const { // The upper bounds are exclusive: a point on the box edge belongs to the next box, // not to the last coarse cell in this measurement. return Point.X >= MinX && Point.X < MaxX && Point.Y >= MinY && Point.Y < MaxY && Point.Z >= MinZ && Point.Z < MaxZ; } }; int64 CeilDivPositive(int64 Numerator, int64 Denominator) { return (Numerator + Denominator - 1) / Denominator; } bool Refuse(FString& OutReason, const TCHAR* Reason) { OutReason = Reason; return false; } bool BuildSampleGrid( const UVoxelGenerator* Generator, const UVoxelStrateManager* Manager, const IVoxelStrateDensitySampler* Sampler, int32 StrateIndex, int32 ExplicitBottomWorldZ, int32 ExplicitTopWorldZ, float ExplicitBoundarySealThickness, bool bUseExplicitBounds, const FVoxelStrateMeasureSettings& Settings, bool bCaptureDensity, FSampleGrid& OutGrid, FString& OutReason) { if (bUseExplicitBounds) { if (Sampler == nullptr) { return Refuse(OutReason, TEXT("An explicit measurement window requires a density sampler.")); } } else if (Generator == nullptr || Manager == nullptr) { return Refuse(OutReason, TEXT("A generator and manager are required for a layout measurement.")); } if (Settings.SampleStep <= 0) { return Refuse(OutReason, TEXT("SampleStep must be greater than zero.")); } const bool bHasCoverPointA = Settings.CoverPointA.IsSet(); const bool bHasCoverPointB = Settings.CoverPointB.IsSet(); if (bHasCoverPointA != bHasCoverPointB) { return Refuse(OutReason, TEXT("CoverPointA and CoverPointB must be set together.")); } const bool bUseFittedWindow = bHasCoverPointA && bHasCoverPointB; if (!bUseFittedWindow && Settings.RadiusInVoxels <= 0) { return Refuse(OutReason, TEXT("RadiusInVoxels must be greater than zero.")); } if (Settings.MaxCells <= 0) { return Refuse(OutReason, TEXT("MaxCells must be greater than zero.")); } if (Settings.HeadroomCells < 0) { return Refuse(OutReason, TEXT("HeadroomCells cannot be negative.")); } if (!bUseFittedWindow && (!FMath::IsFinite(Settings.CenterXY.X) || !FMath::IsFinite(Settings.CenterXY.Y))) { return Refuse(OutReason, TEXT("CenterXY must contain finite voxel coordinates.")); } if (bUseFittedWindow) { const FVector2D& PointA = Settings.CoverPointA.GetValue(); const FVector2D& PointB = Settings.CoverPointB.GetValue(); if (!FMath::IsFinite(PointA.X) || !FMath::IsFinite(PointA.Y) || !FMath::IsFinite(PointB.X) || !FMath::IsFinite(PointB.Y)) { return Refuse(OutReason, TEXT("Cover points must contain finite voxel coordinates.")); } if (!FMath::IsFinite(Settings.CoverMarginVoxels) || Settings.CoverMarginVoxels < 0.0f) { return Refuse(OutReason, TEXT("CoverMarginVoxels must be finite and non-negative.")); } } int64 StrateBottomZ = 0; int64 StrateTopZ = 0; if (bUseExplicitBounds) { StrateBottomZ = static_cast(ExplicitBottomWorldZ); StrateTopZ = static_cast(ExplicitTopWorldZ); if (StrateTopZ <= StrateBottomZ) { return Refuse(OutReason, TEXT("The explicit strate has no positive vertical extent.")); } } else { const TArray& Layout = Manager->GetLayout(); if (!Layout.IsValidIndex(StrateIndex)) { return Refuse(OutReason, TEXT("StrateIndex is outside the manager layout.")); } const FStrateSlot& Slot = Layout[StrateIndex]; if (Slot.Definition == nullptr) { return Refuse(OutReason, TEXT("The requested strate has no definition.")); } if (Slot.TopChunkZ <= Slot.BottomChunkZ) { return Refuse(OutReason, TEXT("The requested strate has no positive vertical extent.")); } StrateBottomZ = static_cast(Slot.BottomChunkZ) * CHUNK_SIZE; StrateTopZ = (static_cast(Slot.TopChunkZ) + 1) * CHUNK_SIZE; } const int64 StrateHeight = StrateTopZ - StrateBottomZ; if (StrateHeight <= 0) { return Refuse(OutReason, TEXT("The requested strate has no positive voxel height.")); } // Resolve the margin from the same blended parameter query used by generation. The // representative chunk is deliberately in the middle of this slot and uses the sample // window centre's XY chunk, so a future non-Hard transition resolves through the manager // exactly as generation does at that representative location. FVector2D WindowCenter = Settings.CenterXY; double MinX64 = 0.0; double MaxX64 = 0.0; double MinY64 = 0.0; double MaxY64 = 0.0; if (bUseFittedWindow) { const FVector2D& PointA = Settings.CoverPointA.GetValue(); const FVector2D& PointB = Settings.CoverPointB.GetValue(); const double Margin = static_cast(Settings.CoverMarginVoxels); MinX64 = FMath::Min(static_cast(PointA.X), static_cast(PointB.X)) - Margin; MaxX64 = FMath::Max(static_cast(PointA.X), static_cast(PointB.X)) + Margin; MinY64 = FMath::Min(static_cast(PointA.Y), static_cast(PointB.Y)) - Margin; MaxY64 = FMath::Max(static_cast(PointA.Y), static_cast(PointB.Y)) + Margin; if (Settings.bIncludeOriginInCoverWindow) { // The fitted box is a route-coverage contract, not merely an endpoint box. An // origin-rooted graph may have its only mouth-to-mouth path through this spine. // Expand the requested bounds before the MaxCells check below; never crop the // route after discovering that the full box is too large. MinX64 = FMath::Min(MinX64, 0.0); MaxX64 = FMath::Max(MaxX64, 0.0); MinY64 = FMath::Min(MinY64, 0.0); MaxY64 = FMath::Max(MaxY64, 0.0); } if (Settings.bForceOriginColumnInCoverWindow) { // Upper bounds are exclusive. The ordinary origin policy can therefore stop at // exactly X=0/Y=0 when both mouths are on one side; a diagnostic column probe // needs one real sampled column straddling the origin instead. MinX64 = FMath::Min(MinX64, -1.0); MaxX64 = FMath::Max(MaxX64, 1.0); MinY64 = FMath::Min(MinY64, -1.0); MaxY64 = FMath::Max(MaxY64, 1.0); } WindowCenter = FVector2D( static_cast(0.5 * (MinX64 + MaxX64)), static_cast(0.5 * (MinY64 + MaxY64))); } else { MinX64 = static_cast(Settings.CenterXY.X) - static_cast(Settings.RadiusInVoxels); MaxX64 = static_cast(Settings.CenterXY.X) + static_cast(Settings.RadiusInVoxels); MinY64 = static_cast(Settings.CenterXY.Y) - static_cast(Settings.RadiusInVoxels); MaxY64 = static_cast(Settings.CenterXY.Y) + static_cast(Settings.RadiusInVoxels); } const double XYExtentX = MaxX64 - MinX64; const double XYExtentY = MaxY64 - MinY64; if (!FMath::IsFinite(MinX64) || !FMath::IsFinite(MaxX64) || !FMath::IsFinite(MinY64) || !FMath::IsFinite(MaxY64) || !FMath::IsFinite(XYExtentX) || !FMath::IsFinite(XYExtentY) || XYExtentX < 1.0 || XYExtentY < 1.0) { return Refuse(OutReason, TEXT("The requested XY window is not a finite positive voxel box.")); } FStrateGenerationParams RepresentativeParams; if (bUseExplicitBounds) { RepresentativeParams.BoundarySealThickness = ExplicitBoundarySealThickness; } else { const TArray& Layout = Manager->GetLayout(); const FStrateSlot& Slot = Layout[StrateIndex]; const int32 MidChunkZ = Slot.BottomChunkZ + (Slot.TopChunkZ - Slot.BottomChunkZ) / 2; const FIntVector RepresentativeChunk( FMath::FloorToInt(WindowCenter.X / static_cast(CHUNK_SIZE)), FMath::FloorToInt(WindowCenter.Y / static_cast(CHUNK_SIZE)), MidChunkZ); RepresentativeParams = Manager->GetGenerationParams(RepresentativeChunk); } const int64 MaxMargin64 = FMath::Max(1, StrateHeight / 4); const int32 MaxMargin = static_cast( FMath::Min(MaxMargin64, static_cast(INT32_MAX))); int32 RequestedMargin = Settings.InteriorMarginVoxels; if (RequestedMargin < 0) { const float DoubleSealThickness = 2.0f * RepresentativeParams.BoundarySealThickness; if (FMath::IsNaN(RepresentativeParams.BoundarySealThickness)) { return Refuse(OutReason, TEXT("BoundarySealThickness is NaN; the interior margin cannot be derived.")); } // Avoid converting an infinite value to int. A positive overflow is still an // intentionally absurd seal request and therefore resolves to the safe maximum; // negative infinity resolves to the minimum. if (!FMath::IsFinite(DoubleSealThickness)) { RequestedMargin = DoubleSealThickness > 0.0f ? MaxMargin : 1; } else if (DoubleSealThickness >= static_cast(INT32_MAX)) { RequestedMargin = MaxMargin; } else if (DoubleSealThickness <= static_cast(INT32_MIN)) { RequestedMargin = 0; } else { RequestedMargin = FMath::CeilToInt(DoubleSealThickness); } } const int32 ResolvedMargin = FMath::Clamp(RequestedMargin, 0, MaxMargin); const int64 InteriorMinZ = StrateBottomZ + static_cast(ResolvedMargin); const int64 InteriorMaxZ = StrateTopZ - static_cast(ResolvedMargin); const int64 InteriorHeight = InteriorMaxZ - InteriorMinZ; if (InteriorHeight <= 0) { return Refuse(OutReason, TEXT("The strate has no measurable height after applying its interior margin.")); } if (InteriorMinZ < static_cast(INT32_MIN) || InteriorMinZ > static_cast(INT32_MAX) || InteriorMaxZ < static_cast(INT32_MIN) || InteriorMaxZ > static_cast(INT32_MAX)) { return Refuse(OutReason, TEXT("The sampled Z window is outside the metrics struct's int32 range.")); } const int64 Step = static_cast(Settings.SampleStep); const double NumXReal = XYExtentX / static_cast(Step); const double NumYReal = XYExtentY / static_cast(Step); if (!FMath::IsFinite(NumXReal) || !FMath::IsFinite(NumYReal) || NumXReal <= 0.0 || NumYReal <= 0.0 || NumXReal > static_cast(INT32_MAX) || NumYReal > static_cast(INT32_MAX)) { return Refuse(OutReason, TEXT("A sample-grid dimension exceeds the supported array size.")); } const int64 NumX64 = FMath::CeilToInt64(NumXReal); const int64 NumY64 = FMath::CeilToInt64(NumYReal); const int64 NumZ64 = CeilDivPositive(InteriorHeight, Step); const int64 MaxCells = static_cast(Settings.MaxCells); if (NumX64 > INT32_MAX || NumY64 > INT32_MAX || NumZ64 > INT32_MAX) { return Refuse(OutReason, TEXT("A sample-grid dimension exceeds the supported array size.")); } if (NumX64 > MaxCells || NumY64 > MaxCells || NumZ64 > MaxCells || NumX64 > MaxCells / NumY64) { return Refuse(OutReason, TEXT("The requested sample grid exceeds MaxCells; measurement refused.")); } const int64 XYCells = NumX64 * NumY64; if (XYCells > MaxCells / NumZ64) { return Refuse(OutReason, TEXT("The requested sample grid exceeds MaxCells; measurement refused.")); } const int64 CellCount64 = XYCells * NumZ64; if (CellCount64 <= 0 || CellCount64 > MaxCells || CellCount64 > INT32_MAX) { return Refuse(OutReason, TEXT("The requested sample grid exceeds the bounded cell count.")); } const float MinX = static_cast(MinX64); const float MaxX = static_cast(MaxX64); const float MinY = static_cast(MinY64); const float MaxY = static_cast(MaxY64); const float MinZ = static_cast(InteriorMinZ); const float MaxZ = static_cast(InteriorMaxZ); if (!FMath::IsFinite(MinX) || !FMath::IsFinite(MaxX) || !FMath::IsFinite(MinY) || !FMath::IsFinite(MaxY) || !FMath::IsFinite(MinZ) || !FMath::IsFinite(MaxZ) || MaxX <= MinX || MaxY <= MinY || MaxZ <= MinZ) { return Refuse(OutReason, TEXT("The requested measurement bounds are not representable as finite voxel coordinates.")); } OutGrid.NumX = static_cast(NumX64); OutGrid.NumY = static_cast(NumY64); OutGrid.NumZ = static_cast(NumZ64); OutGrid.SampleStep = Settings.SampleStep; OutGrid.CellCount = static_cast(CellCount64); OutGrid.MinX = MinX; OutGrid.MinY = MinY; OutGrid.MinZ = MinZ; OutGrid.MaxX = MaxX; OutGrid.MaxY = MaxY; OutGrid.MaxZ = MaxZ; OutGrid.ExtentX = MaxX - MinX; OutGrid.ExtentY = MaxY - MinY; OutGrid.ExtentZ = MaxZ - MinZ; OutGrid.ResolvedMarginVoxels = ResolvedMargin; OutGrid.SampledMinZ = static_cast(InteriorMinZ); OutGrid.SampledMaxZ = static_cast(InteriorMaxZ); OutGrid.Air.SetNumUninitialized(OutGrid.CellCount); if (bCaptureDensity) { OutGrid.Density.SetNumUninitialized(OutGrid.CellCount); } // Each Z slab writes a disjoint contiguous region. GetDensityAt is a const, read-only // query used concurrently by the mesher, and custom samplers are required to provide // the same const/thread-safe contract. The non-finite flag is also per-cell so the // diagnostic reduction below never depends on task completion order. TArray NonFiniteFlags; NonFiniteFlags.Init(0u, OutGrid.CellCount); ParallelFor(OutGrid.NumZ, [&](int32 Z) { for (int32 Y = 0; Y < OutGrid.NumY; ++Y) { for (int32 X = 0; X < OutGrid.NumX; ++X) { const int32 CellIndex = OutGrid.Index(X, Y, Z); const FVector Sample = OutGrid.CellCenter(X, Y, Z); const float Density = Sampler != nullptr ? Sampler->SampleDensity(Sample.X, Sample.Y, Sample.Z) : Generator->GetDensityAt(Sample.X, Sample.Y, Sample.Z); if (!FMath::IsFinite(Density)) { NonFiniteFlags[CellIndex] = 1u; } // MC convention in this codebase: negative is solid, positive is air. if (bCaptureDensity) { OutGrid.Density[CellIndex] = Density; } OutGrid.Air[CellIndex] = Density > 0.0f ? 1u : 0u; } } }); bool bSawNonFiniteDensity = false; for (int32 CellIndex = 0; CellIndex < OutGrid.CellCount; ++CellIndex) { bSawNonFiniteDensity |= NonFiniteFlags[CellIndex] != 0u; } if (bSawNonFiniteDensity) { return Refuse(OutReason, TEXT("At least one density sample was non-finite.")); } return true; } void ExportSampleGrid(FSampleGrid& Source, FVoxelStrateSampleGrid& Destination) { Destination = FVoxelStrateSampleGrid(); Destination.bValid = true; Destination.NumX = Source.NumX; Destination.NumY = Source.NumY; Destination.NumZ = Source.NumZ; Destination.SampleStep = Source.SampleStep; Destination.CellCount = Source.CellCount; Destination.MinX = Source.MinX; Destination.MinY = Source.MinY; Destination.MinZ = Source.MinZ; Destination.MaxX = Source.MaxX; Destination.MaxY = Source.MaxY; Destination.MaxZ = Source.MaxZ; Destination.ResolvedMarginVoxels = Source.ResolvedMarginVoxels; Destination.SampledMinZ = Source.SampledMinZ; Destination.SampledMaxZ = Source.SampledMaxZ; // Move, rather than copy, the already-built polarity grid. The caller owns this buffer // only when it explicitly requested a capture. Destination.Air = MoveTemp(Source.Air); Destination.Density = MoveTemp(Source.Density); Destination.PlayerFitMask.Reset(); Destination.PlayerFitCellCount = 0; } void DecodeIndex(const FSampleGrid& Grid, int32 Index, int32& OutX, int32& OutY, int32& OutZ) { const int32 Plane = Grid.NumX * Grid.NumY; OutZ = Index / Plane; const int32 InPlane = Index - OutZ * Plane; OutY = InPlane / Grid.NumX; OutX = InPlane - OutY * Grid.NumX; } struct FPlayerTraversalNeighbour { int32 Cell = INDEX_NONE; bool bClimb = false; }; // Walk is the hard capability contract. The climb policy is used only for the diagnostic // tier; keeping it explicit at every graph operation prevents a future caller from silently // turning a climbable ledge into a walk-only connectivity pass. enum class EPlayerTraversalPolicy : uint8 { WalkOnly, WalkAndClimb, }; bool IsEligiblePlayerFitCell( const FSampleGrid& Grid, const TArray& PlayerFitMask, int32 Cell) { return Cell >= 0 && Cell < Grid.CellCount && Grid.Air[Cell] != 0u && PlayerFitMask.IsValidIndex(Cell) && PlayerFitMask[Cell] != 0u; } bool IsClimbableSurfaceEdge( const FSampleGrid& Grid, int32 First, int32 Second, float FirstSupportHeight, float SecondSupportHeight) { int32 FirstX = 0; int32 FirstY = 0; int32 FirstZ = 0; DecodeIndex(Grid, First, FirstX, FirstY, FirstZ); int32 SecondX = 0; int32 SecondY = 0; int32 SecondZ = 0; DecodeIndex(Grid, Second, SecondX, SecondY, SecondZ); if (FMath::Abs(FirstX - SecondX) + FMath::Abs(FirstY - SecondY) != 1) { return false; } const bool bFirstIsHigher = FirstSupportHeight > SecondSupportHeight; const int32 HigherColumnX = bFirstIsHigher ? FirstX : SecondX; const int32 HigherColumnY = bFirstIsHigher ? FirstY : SecondY; const float LowerHeight = FMath::Min(FirstSupportHeight, SecondSupportHeight); const float HigherHeight = FMath::Max(FirstSupportHeight, SecondSupportHeight); const float Step = static_cast(Grid.SampleStep); if (!FMath::IsFinite(Step) || Step <= 0.0f || !FMath::IsFinite(LowerHeight) || !FMath::IsFinite(HigherHeight)) { return false; } // A climb edge must meet a solid riser, not connect two independently supported // floating poses across an empty gap. Test the actual vertical interval between the two // support surfaces in the higher column. The old floor/ceil/-2 indexing skipped the only // solid sample for short quantised ledges and was especially brittle when the support // heights came from scalar interpolation rather than an integer cell boundary. for (int32 Z = 0; Z < Grid.NumZ; ++Z) { const float SampleZ = Grid.MinZ + (static_cast(Z) + 0.5f) * Step; if (SampleZ + KINDA_SMALL_NUMBER < LowerHeight || SampleZ >= HigherHeight - KINDA_SMALL_NUMBER) { continue; } if (Grid.Air[Grid.Index(HigherColumnX, HigherColumnY, Z)] == 0u) { return true; } } return false; } bool IsPlayerTraversalEdge( const FSampleGrid& Grid, const TArray& PlayerFitMask, const TArray* PlayerFitSupportHeights, const FVoxelStrateMeasureSettings* Settings, int32 First, int32 Second, bool& bOutClimb, EPlayerTraversalPolicy Policy = EPlayerTraversalPolicy::WalkAndClimb) { bOutClimb = false; if (!IsEligiblePlayerFitCell(Grid, PlayerFitMask, First) || !IsEligiblePlayerFitCell(Grid, PlayerFitMask, Second)) { return false; } if (PlayerFitSupportHeights == nullptr || Settings == nullptr) { // Callers that do not carry support data retain the historical six-neighbour graph. return true; } if (!PlayerFitSupportHeights->IsValidIndex(First) || !PlayerFitSupportHeights->IsValidIndex(Second)) { return false; } int32 FirstX = 0; int32 FirstY = 0; int32 FirstZ = 0; DecodeIndex(Grid, First, FirstX, FirstY, FirstZ); int32 SecondX = 0; int32 SecondY = 0; int32 SecondZ = 0; DecodeIndex(Grid, Second, SecondX, SecondY, SecondZ); const int32 DeltaX = FMath::Abs(FirstX - SecondX); const int32 DeltaY = FMath::Abs(FirstY - SecondY); const int32 DeltaZ = FMath::Abs(FirstZ - SecondZ); if (DeltaX + DeltaY == 0 && DeltaZ == 1) { return true; } if (DeltaX + DeltaY != 1) { return false; } const float FirstHeight = (*PlayerFitSupportHeights)[First]; const float SecondHeight = (*PlayerFitSupportHeights)[Second]; if (!FMath::IsFinite(FirstHeight) || !FMath::IsFinite(SecondHeight)) { return false; } const float VoxelSize = FVoxelPlayerCapsuleConstants::VoxelSizeMeters; const float StepHeightVoxels = Settings->PlayerMaxStepHeightMeters / VoxelSize; const float ClimbHeightVoxels = Settings->PlayerMaxClimbHeightMeters / VoxelSize; if (!FMath::IsFinite(StepHeightVoxels) || StepHeightVoxels < 0.0f || !FMath::IsFinite(ClimbHeightVoxels) || ClimbHeightVoxels < 0.0f) { return false; } const float HeightDelta = FMath::Abs(FirstHeight - SecondHeight); if (HeightDelta <= StepHeightVoxels + KINDA_SMALL_NUMBER) { return true; } if (Policy == EPlayerTraversalPolicy::WalkOnly || !Settings->bPlayerClimbingEnabled || HeightDelta > ClimbHeightVoxels + KINDA_SMALL_NUMBER) { return false; } bOutClimb = IsClimbableSurfaceEdge( Grid, First, Second, FirstHeight, SecondHeight); return bOutClimb; } void AppendPlayerTraversalNeighbours( const FSampleGrid& Grid, const TArray& PlayerFitMask, const TArray* PlayerFitSupportHeights, const FVoxelStrateMeasureSettings* Settings, int32 Cell, TArray>& OutNeighbours, EPlayerTraversalPolicy Policy = EPlayerTraversalPolicy::WalkAndClimb) { OutNeighbours.Reset(); if (Cell < 0 || Cell >= Grid.CellCount) { return; } int32 X = 0; int32 Y = 0; int32 Z = 0; DecodeIndex(Grid, Cell, X, Y, Z); auto TryAdd = [&](int32 Neighbor) { if (Neighbor == INDEX_NONE || Neighbor == Cell) { return; } bool bClimb = false; if (IsPlayerTraversalEdge( Grid, PlayerFitMask, PlayerFitSupportHeights, Settings, Cell, Neighbor, bClimb, Policy)) { OutNeighbours.Add({ Neighbor, bClimb }); } }; // Preserve the old direction order for ordinary movement. Extra climb edges are appended // in a fixed direction/height order, so the route and all derived counts are stable. if (X + 1 < Grid.NumX) TryAdd(Grid.Index(X + 1, Y, Z)); if (X > 0) TryAdd(Grid.Index(X - 1, Y, Z)); if (Y + 1 < Grid.NumY) TryAdd(Grid.Index(X, Y + 1, Z)); if (Y > 0) TryAdd(Grid.Index(X, Y - 1, Z)); if (Z + 1 < Grid.NumZ) TryAdd(Grid.Index(X, Y, Z + 1)); if (Z > 0) TryAdd(Grid.Index(X, Y, Z - 1)); if (Policy == EPlayerTraversalPolicy::WalkOnly || PlayerFitSupportHeights == nullptr || Settings == nullptr || !Settings->bPlayerClimbingEnabled) { return; } const float VoxelSize = FVoxelPlayerCapsuleConstants::VoxelSizeMeters; const float ClimbHeightVoxels = Settings->PlayerMaxClimbHeightMeters / VoxelSize; if (!FMath::IsFinite(ClimbHeightVoxels) || ClimbHeightVoxels <= 0.0f) { return; } const int32 SearchCells = FMath::Min( 4096, FMath::Max(1, FMath::CeilToInt(ClimbHeightVoxels))); const int32 DirectionX[4] = { 1, -1, 0, 0 }; const int32 DirectionY[4] = { 0, 0, 1, -1 }; for (int32 Direction = 0; Direction < 4; ++Direction) { const int32 TargetX = X + DirectionX[Direction]; const int32 TargetY = Y + DirectionY[Direction]; if (TargetX < 0 || TargetX >= Grid.NumX || TargetY < 0 || TargetY >= Grid.NumY) { continue; } for (int32 OffsetZ = -SearchCells; OffsetZ <= SearchCells; ++OffsetZ) { if (OffsetZ == 0) { continue; } const int32 TargetZ = Z + OffsetZ; if (TargetZ >= 0 && TargetZ < Grid.NumZ) { const int32 Neighbor = Grid.Index(TargetX, TargetY, TargetZ); bool bClimb = false; if (IsPlayerTraversalEdge( Grid, PlayerFitMask, PlayerFitSupportHeights, Settings, Cell, Neighbor, bClimb, Policy) && bClimb) { OutNeighbours.Add({ Neighbor, true }); } } } } } void FloodFillAir( const FSampleGrid& Grid, TArray& OutComponents, int32& OutNumComponents, int64 NumAir, int64& OutLargestComponentCells, int32& OutLargestComponentLowestCell, int32& OutNumComponentsAtLeast1Pct, TArray* OutComponentCells, const TArray* EligibilityMask = nullptr, const TArray* PlayerFitSupportHeights = nullptr, const FVoxelStrateMeasureSettings* TraversalSettings = nullptr, EPlayerTraversalPolicy TraversalPolicy = EPlayerTraversalPolicy::WalkAndClimb) { OutComponents.SetNumUninitialized(Grid.CellCount); for (int32 Index = 0; Index < Grid.CellCount; ++Index) { OutComponents[Index] = -1; } TArray Queue; Queue.SetNumUninitialized(Grid.CellCount); OutNumComponents = 0; OutLargestComponentCells = 0; OutLargestComponentLowestCell = INDEX_NONE; OutNumComponentsAtLeast1Pct = 0; if (OutComponentCells != nullptr) { OutComponentCells->Reset(); } for (int32 Start = 0; Start < Grid.CellCount; ++Start) { if (Grid.Air[Start] == 0u || (EligibilityMask != nullptr && (*EligibilityMask)[Start] == 0u) || OutComponents[Start] >= 0) { continue; } const int32 ComponentId = OutNumComponents++; int32 Head = 0; int32 Tail = 0; int32 ComponentCells = 0; OutComponents[Start] = ComponentId; Queue[Tail++] = Start; while (Head < Tail) { const int32 Current = Queue[Head++]; ++ComponentCells; int32 X = 0; int32 Y = 0; int32 Z = 0; DecodeIndex(Grid, Current, X, Y, Z); auto Visit = [&](int32 Neighbor) { if (Grid.Air[Neighbor] != 0u && (EligibilityMask == nullptr || (*EligibilityMask)[Neighbor] != 0u) && OutComponents[Neighbor] < 0) { OutComponents[Neighbor] = ComponentId; Queue[Tail++] = Neighbor; } }; if (EligibilityMask != nullptr && PlayerFitSupportHeights != nullptr && TraversalSettings != nullptr) { TArray> Neighbours; AppendPlayerTraversalNeighbours( Grid, *EligibilityMask, PlayerFitSupportHeights, TraversalSettings, Current, Neighbours, TraversalPolicy); for (const FPlayerTraversalNeighbour& Neighbour : Neighbours) { Visit(Neighbour.Cell); } } else { // Fixed order keeps every derived value independent of container iteration order. if (X + 1 < Grid.NumX) Visit(Grid.Index(X + 1, Y, Z)); if (X > 0) Visit(Grid.Index(X - 1, Y, Z)); if (Y + 1 < Grid.NumY) Visit(Grid.Index(X, Y + 1, Z)); if (Y > 0) Visit(Grid.Index(X, Y - 1, Z)); if (Z + 1 < Grid.NumZ) Visit(Grid.Index(X, Y, Z + 1)); if (Z > 0) Visit(Grid.Index(X, Y, Z - 1)); } } const int64 ComponentCells64 = static_cast(ComponentCells); if (OutComponentCells != nullptr) { OutComponentCells->Add(ComponentCells64); } if (NumAir > 0 && ComponentCells64 * 100 >= NumAir) { ++OutNumComponentsAtLeast1Pct; } if (ComponentCells64 > OutLargestComponentCells || (ComponentCells64 == OutLargestComponentCells && (OutLargestComponentLowestCell == INDEX_NONE || Start < OutLargestComponentLowestCell))) { OutLargestComponentCells = ComponentCells64; OutLargestComponentLowestCell = Start; } } } void BuildManhattanDistanceToSolid(const FSampleGrid& Grid, TArray& OutDistances) { // Two wavefront passes with 6-neighbour Manhattan relaxation. A cell depends only on the // immediately preceding diagonal in the forward pass (or following diagonal in the // reverse pass), so each diagonal is safe to process in parallel while the diagonal // barriers preserve the exact raster result. Each coarse grid unit is SampleStep VOXELS. constexpr int32 Infinity = INT32_MAX; OutDistances.SetNumUninitialized(Grid.CellCount); ParallelFor(Grid.CellCount, [&](int32 Index) { OutDistances[Index] = Grid.Air[Index] == 0u ? 0 : Infinity; }); const int32 LastDiagonal = Grid.NumX + Grid.NumY + Grid.NumZ - 3; const auto ProcessDiagonal = [&](int32 Diagonal, bool bForward) { const int32 MinZ = FMath::Max( 0, Diagonal - (Grid.NumX - 1) - (Grid.NumY - 1)); const int32 MaxZ = FMath::Min(Grid.NumZ - 1, Diagonal); if (MinZ > MaxZ) { return; } int32 DiagonalCellCount = 0; for (int32 Z = MinZ; Z <= MaxZ; ++Z) { const int32 MinY = FMath::Max( 0, Diagonal - Z - (Grid.NumX - 1)); const int32 MaxY = FMath::Min(Grid.NumY - 1, Diagonal - Z); DiagonalCellCount += FMath::Max(0, MaxY - MinY + 1); } const auto ProcessZ = [&](int32 Z) { const int32 MinY = FMath::Max( 0, Diagonal - Z - (Grid.NumX - 1)); const int32 MaxY = FMath::Min(Grid.NumY - 1, Diagonal - Z); for (int32 Y = MinY; Y <= MaxY; ++Y) { const int32 X = Diagonal - Z - Y; const int32 Current = Grid.Index(X, Y, Z); if (Grid.Air[Current] == 0u) { continue; } const auto Relax = [&](int32 Neighbor) { if (OutDistances[Neighbor] == Infinity) { return; } const int32 Candidate = OutDistances[Neighbor] == INT32_MAX - 1 ? Infinity : OutDistances[Neighbor] + 1; OutDistances[Current] = FMath::Min( OutDistances[Current], Candidate); }; if (bForward) { if (X > 0) Relax(Grid.Index(X - 1, Y, Z)); if (Y > 0) Relax(Grid.Index(X, Y - 1, Z)); if (Z > 0) Relax(Grid.Index(X, Y, Z - 1)); } else { if (X + 1 < Grid.NumX) Relax(Grid.Index(X + 1, Y, Z)); if (Y + 1 < Grid.NumY) Relax(Grid.Index(X, Y + 1, Z)); if (Z + 1 < Grid.NumZ) Relax(Grid.Index(X, Y, Z + 1)); } } }; // Tiny edge diagonals are cheaper to execute directly; substantial diagonals get // one task per Z row, with every row writing a disjoint set of cells. const int32 ZCount = MaxZ - MinZ + 1; if (ZCount > 1 && DiagonalCellCount >= 256) { ParallelFor(ZCount, [&](int32 ZOffset) { ProcessZ(MinZ + ZOffset); }); } else { for (int32 Z = MinZ; Z <= MaxZ; ++Z) { ProcessZ(Z); } } }; for (int32 Diagonal = 0; Diagonal <= LastDiagonal; ++Diagonal) { ProcessDiagonal(Diagonal, true); } for (int32 Diagonal = LastDiagonal; Diagonal >= 0; --Diagonal) { ProcessDiagonal(Diagonal, false); } } struct FPlayerCapsuleStencilRow { int32 RelativeZ = 0; TArray HorizontalOffsets; }; bool HasScalarDensity(const FSampleGrid& Grid) { return Grid.Density.Num() == Grid.CellCount; } float CellDensity(const FSampleGrid& Grid, int32 Cell) { return HasScalarDensity(Grid) ? Grid.Density[Cell] : (Grid.Air[Cell] != 0u ? 1.0f : -1.0f); } bool SampleGridDensity( const FSampleGrid& Grid, const FVector& Point, float& OutDensity) { if (!Grid.ContainsPoint(Point)) { return false; } const float Step = static_cast(Grid.SampleStep); const float LocalX = (Point.X - Grid.MinX) / Step - 0.5f; const float LocalY = (Point.Y - Grid.MinY) / Step - 0.5f; const float LocalZ = (Point.Z - Grid.MinZ) / Step - 0.5f; if (!HasScalarDensity(Grid)) { const int32 X = FMath::Clamp( FMath::FloorToInt((Point.X - Grid.MinX) / Step), 0, Grid.NumX - 1); const int32 Y = FMath::Clamp( FMath::FloorToInt((Point.Y - Grid.MinY) / Step), 0, Grid.NumY - 1); const int32 Z = FMath::Clamp( FMath::FloorToInt((Point.Z - Grid.MinZ) / Step), 0, Grid.NumZ - 1); OutDensity = CellDensity(Grid, Grid.Index(X, Y, Z)); return FMath::IsFinite(OutDensity); } const int32 X0 = FMath::Clamp(FMath::FloorToInt(LocalX), 0, Grid.NumX - 1); const int32 Y0 = FMath::Clamp(FMath::FloorToInt(LocalY), 0, Grid.NumY - 1); const int32 Z0 = FMath::Clamp(FMath::FloorToInt(LocalZ), 0, Grid.NumZ - 1); const int32 X1 = FMath::Min(X0 + 1, Grid.NumX - 1); const int32 Y1 = FMath::Min(Y0 + 1, Grid.NumY - 1); const int32 Z1 = FMath::Min(Z0 + 1, Grid.NumZ - 1); const float AlphaX = FMath::Clamp(LocalX - static_cast(X0), 0.0f, 1.0f); const float AlphaY = FMath::Clamp(LocalY - static_cast(Y0), 0.0f, 1.0f); const float AlphaZ = FMath::Clamp(LocalZ - static_cast(Z0), 0.0f, 1.0f); const float D000 = CellDensity(Grid, Grid.Index(X0, Y0, Z0)); const float D100 = CellDensity(Grid, Grid.Index(X1, Y0, Z0)); const float D010 = CellDensity(Grid, Grid.Index(X0, Y1, Z0)); const float D110 = CellDensity(Grid, Grid.Index(X1, Y1, Z0)); const float D001 = CellDensity(Grid, Grid.Index(X0, Y0, Z1)); const float D101 = CellDensity(Grid, Grid.Index(X1, Y0, Z1)); const float D011 = CellDensity(Grid, Grid.Index(X0, Y1, Z1)); const float D111 = CellDensity(Grid, Grid.Index(X1, Y1, Z1)); const float D00 = FMath::Lerp(D000, D100, AlphaX); const float D10 = FMath::Lerp(D010, D110, AlphaX); const float D01 = FMath::Lerp(D001, D101, AlphaX); const float D11 = FMath::Lerp(D011, D111, AlphaX); const float D0 = FMath::Lerp(D00, D10, AlphaY); const float D1 = FMath::Lerp(D01, D11, AlphaY); OutDensity = FMath::Lerp(D0, D1, AlphaZ); return FMath::IsFinite(OutDensity); } bool FindSupportSurfaceBelow( const FSampleGrid& Grid, int32 X, int32 Y, int32 CandidateAnchorZ, int32 MaxDownwardSearchCells, float MaxStepHeightVoxels, float& OutSurfaceHeight, int32& OutSurfaceAnchorZ) { const float Step = static_cast(Grid.SampleStep); const float CandidateBottom = Grid.MinZ + static_cast(CandidateAnchorZ) * Step; const float LowestAllowedSurface = CandidateBottom - MaxStepHeightVoxels; const int32 LowestAnchorZ = FMath::Max( 1, CandidateAnchorZ - MaxDownwardSearchCells); // Search from the free pose downwards. The first transition in a column is the nearest // supporting surface; lower transitions are relevant only after the candidate has been // lifted by the bounded step-height tolerance. for (int32 SurfaceAnchorZ = CandidateAnchorZ; SurfaceAnchorZ >= LowestAnchorZ; --SurfaceAnchorZ) { const int32 SolidZ = SurfaceAnchorZ - 1; if (SolidZ < 0 || SurfaceAnchorZ >= Grid.NumZ) { continue; } const int32 SolidCell = Grid.Index(X, Y, SolidZ); const int32 AirCell = Grid.Index(X, Y, SurfaceAnchorZ); if (Grid.Air[SolidCell] != 0u || Grid.Air[AirCell] == 0u) { continue; } float SurfaceHeight = Grid.MinZ + static_cast(SurfaceAnchorZ) * Step; if (HasScalarDensity(Grid)) { const float LowerDensity = CellDensity(Grid, SolidCell); const float UpperDensity = CellDensity(Grid, AirCell); const float Denominator = UpperDensity - LowerDensity; if (!FMath::IsFinite(LowerDensity) || !FMath::IsFinite(UpperDensity) || !FMath::IsFinite(Denominator) || Denominator <= 0.0f) { continue; } const float Fraction = FMath::Clamp( -LowerDensity / Denominator, 0.0f, 1.0f); SurfaceHeight = Grid.CellCenter(X, Y, SolidZ).Z + Fraction * Step; } if (FMath::IsFinite(SurfaceHeight) && SurfaceHeight <= CandidateBottom + KINDA_SMALL_NUMBER && SurfaceHeight >= LowestAllowedSurface - KINDA_SMALL_NUMBER) { OutSurfaceHeight = SurfaceHeight; OutSurfaceAnchorZ = SurfaceAnchorZ; return true; } } return false; } bool BuildPlayerSupportPatchOffsets( const FVoxelStrateMeasureSettings& Settings, TArray& OutOffsets, int32& OutCentreOffsetIndex, FString& OutReason) { OutOffsets.Reset(); OutCentreOffsetIndex = INDEX_NONE; const double Radius = static_cast(Settings.PlayerCapsuleRadiusVoxels); if (!FMath::IsFinite(Radius) || Radius <= 0.0) { return Refuse(OutReason, TEXT("Player capsule radius must be finite and greater than zero.")); } const double MaxOffsetReal = FMath::CeilToDouble(Radius); constexpr double MaxSupportPatchOffsets = static_cast(1ll << 20); if (!FMath::IsFinite(MaxOffsetReal) || MaxOffsetReal > 4096.0) { return Refuse(OutReason, TEXT("The player support patch radius exceeds the bounded fit stencil.")); } const int32 MaxOffset = static_cast(MaxOffsetReal); int64 NumOffsets = 0; for (int32 OffsetY = -MaxOffset; OffsetY <= MaxOffset; ++OffsetY) { for (int32 OffsetX = -MaxOffset; OffsetX <= MaxOffset; ++OffsetX) { if (static_cast(OffsetX * OffsetX + OffsetY * OffsetY) > Radius * Radius + KINDA_SMALL_NUMBER) { continue; } if (NumOffsets >= static_cast(MaxSupportPatchOffsets)) { return Refuse( OutReason, TEXT("The player support patch exceeds its bounded work limit.")); } if (OffsetX == 0 && OffsetY == 0) { OutCentreOffsetIndex = OutOffsets.Num(); } OutOffsets.Add(FIntPoint(OffsetX, OffsetY)); ++NumOffsets; } } if (OutCentreOffsetIndex == INDEX_NONE || OutOffsets.IsEmpty()) { return Refuse(OutReason, TEXT("The player support patch has no footprint samples.")); } return true; } bool IsWalkableSupportPatch( const TArray& SupportOffsets, const TArray& SupportHeights, const TArray& bSupported, int32 CentreOffsetIndex, int32 RequiredSupportCount, float MinimumWalkableNormalZ, int32& OutSupportCount, float& OutSupportHeight, int32& OutSupportAnchorZ, const TArray& SupportAnchorZs) { OutSupportCount = 0; OutSupportHeight = -FLT_MAX; OutSupportAnchorZ = INDEX_NONE; if (!bSupported.IsValidIndex(CentreOffsetIndex) || bSupported[CentreOffsetIndex] == 0u) { return false; } for (int32 Index = 0; Index < SupportOffsets.Num(); ++Index) { if (bSupported[Index] == 0u) { continue; } ++OutSupportCount; if (SupportHeights[Index] > OutSupportHeight || (SupportHeights[Index] == OutSupportHeight && (OutSupportAnchorZ == INDEX_NONE || SupportAnchorZs[Index] > OutSupportAnchorZ))) { OutSupportHeight = SupportHeights[Index]; OutSupportAnchorZ = SupportAnchorZs[Index]; } } if (OutSupportCount < RequiredSupportCount || OutSupportAnchorZ == INDEX_NONE) { return false; } // The worst pairwise secant is intentionally conservative: an isolated height jump in // the footprint cannot hide behind an average plane normal. For the default 1.36-voxel // capsule this checks the centre and the four cardinal support samples. float MaximumGradient = 0.0f; for (int32 First = 0; First < SupportOffsets.Num(); ++First) { if (bSupported[First] == 0u) { continue; } for (int32 Second = First + 1; Second < SupportOffsets.Num(); ++Second) { if (bSupported[Second] == 0u) { continue; } const float DX = static_cast( SupportOffsets[Second].X - SupportOffsets[First].X); const float DY = static_cast( SupportOffsets[Second].Y - SupportOffsets[First].Y); const float HorizontalDistance = FMath::Sqrt(DX * DX + DY * DY); if (HorizontalDistance <= KINDA_SMALL_NUMBER) { continue; } MaximumGradient = FMath::Max( MaximumGradient, FMath::Abs(SupportHeights[Second] - SupportHeights[First]) / HorizontalDistance); } } const float SupportNormalZ = 1.0f / FMath::Sqrt( 1.0f + MaximumGradient * MaximumGradient); return FMath::IsFinite(SupportNormalZ) && SupportNormalZ + KINDA_SMALL_NUMBER >= MinimumWalkableNormalZ; } bool CapsuleClearAtSupportHeight( const FSampleGrid& Grid, const TArray& StencilRows, int32 X, int32 Y, float SupportHeight) { const float Step = static_cast(Grid.SampleStep); const float BaseX = Grid.MinX + (static_cast(X) + 0.5f) * Step; const float BaseY = Grid.MinY + (static_cast(Y) + 0.5f) * Step; for (const FPlayerCapsuleStencilRow& Row : StencilRows) { const float SampleZ = SupportHeight + static_cast(Row.RelativeZ) + 0.5f * Step; for (const FIntPoint& Offset : Row.HorizontalOffsets) { float Density = 0.0f; if (!SampleGridDensity( Grid, FVector( BaseX + static_cast(Offset.X) * Step, BaseY + static_cast(Offset.Y) * Step, SampleZ), Density) || !(Density > 0.0f)) { return false; } } } return true; } bool IsPointInsideCapsule( float RelativeZ, int32 OffsetX, int32 OffsetY, float Radius, float HalfHeight) { // The capsule is upright and this row is evaluated relative to the resolved support // surface. This is a direct capsule test in the fine voxel lattice; it deliberately does // not infer occupancy from Manhattan distance. const float AxisHalfLength = FMath::Max(0.0f, HalfHeight - Radius); const float DistanceToAxis = FMath::Max( FMath::Abs(RelativeZ) - AxisHalfLength, 0.0f); const float DistanceSquared = static_cast(OffsetX * OffsetX + OffsetY * OffsetY) + DistanceToAxis * DistanceToAxis; return DistanceSquared <= Radius * Radius + KINDA_SMALL_NUMBER; } bool BuildPlayerCapsuleStencil( const FVoxelStrateMeasureSettings& Settings, TArray& OutRows, FString& OutReason) { OutRows.Reset(); if (!FMath::IsFinite(Settings.PlayerCapsuleRadiusVoxels) || Settings.PlayerCapsuleRadiusVoxels <= 0.0f || !FMath::IsFinite(Settings.PlayerCapsuleHalfHeightVoxels) || Settings.PlayerCapsuleHalfHeightVoxels <= 0.0f) { return Refuse(OutReason, TEXT("Player capsule radius and half-height must be finite and greater than zero.")); } const double HeightCellsReal = FMath::CeilToDouble( 2.0 * static_cast(Settings.PlayerCapsuleHalfHeightVoxels)); // This is a stencil-size guard, not a gameplay threshold. It prevents a malformed // caller-supplied capsule from allocating an unbounded per-anchor loop. constexpr double MaxStencilHeightCells = 4096.0; if (!FMath::IsFinite(HeightCellsReal) || HeightCellsReal <= 0.0 || HeightCellsReal > MaxStencilHeightCells) { return Refuse(OutReason, TEXT("The player capsule height exceeds the bounded fit stencil.")); } const double MaxHorizontalOffsetReal = FMath::CeilToDouble( static_cast(Settings.PlayerCapsuleRadiusVoxels)); constexpr double MaxStencilHorizontalRadiusCells = 4096.0; if (!FMath::IsFinite(MaxHorizontalOffsetReal) || MaxHorizontalOffsetReal < 0.0 || MaxHorizontalOffsetReal > MaxStencilHorizontalRadiusCells) { return Refuse(OutReason, TEXT("The player capsule radius exceeds the bounded fit stencil.")); } const int32 HeightCells = static_cast(HeightCellsReal); const int32 MaxHorizontalOffset = static_cast(MaxHorizontalOffsetReal); // This bounds both the retained stencil and the per-anchor occupancy work. It is // deliberately far above the confirmed capsule's handful of offsets, but prevents a // malformed caller from turning a valid-looking finite radius into an enormous array. constexpr int64 MaxStencilOffsets = 1ll << 20; int64 NumStencilOffsets = 0; OutRows.Reserve(HeightCells); for (int32 RelativeZ = 0; RelativeZ < HeightCells; ++RelativeZ) { FPlayerCapsuleStencilRow& Row = OutRows.AddDefaulted_GetRef(); Row.RelativeZ = RelativeZ; const float CapsuleRelativeZ = static_cast(RelativeZ) + 0.5f - Settings.PlayerCapsuleHalfHeightVoxels; for (int32 OffsetY = -MaxHorizontalOffset; OffsetY <= MaxHorizontalOffset; ++OffsetY) { for (int32 OffsetX = -MaxHorizontalOffset; OffsetX <= MaxHorizontalOffset; ++OffsetX) { if (IsPointInsideCapsule( CapsuleRelativeZ, OffsetX, OffsetY, Settings.PlayerCapsuleRadiusVoxels, Settings.PlayerCapsuleHalfHeightVoxels)) { if (NumStencilOffsets >= MaxStencilOffsets) { return Refuse( OutReason, TEXT("The player capsule occupancy stencil exceeds its bounded work limit.")); } Row.HorizontalOffsets.Add(FIntPoint(OffsetX, OffsetY)); ++NumStencilOffsets; } } } // Keep a centre sample for every row. This matters for the top partial row of a // capsule whose height is not an integer number of voxels: the full-height clearance // requirement must not silently become a seven-centre-point approximation. if (Row.HorizontalOffsets.IsEmpty()) { Row.HorizontalOffsets.Add(FIntPoint::ZeroValue); } } return true; } bool BuildPlayerFitMask( const FSampleGrid& Grid, const FVoxelStrateMeasureSettings& Settings, TArray& OutPlayerFit, int64& OutNumPlayerFitCells, FString& OutReason, TArray* OutPlayerFitSupportHeights = nullptr) { OutPlayerFit.Reset(); OutNumPlayerFitCells = 0; if (OutPlayerFitSupportHeights != nullptr) { OutPlayerFitSupportHeights->Reset(); } if (Grid.SampleStep != 1) { return Refuse( OutReason, *FString::Printf( TEXT("Player-fit metrics refused: SampleStep=%d; exact capsule fit requires SampleStep=1."), Grid.SampleStep)); } if (!FMath::IsFinite(Settings.PlayerMaxStepHeightMeters) || Settings.PlayerMaxStepHeightMeters < 0.0f) { return Refuse( OutReason, TEXT("PlayerMaxStepHeightMeters must be finite and non-negative.")); } if (!FMath::IsFinite(Settings.PlayerWalkableFloorAngleDegrees) || Settings.PlayerWalkableFloorAngleDegrees < 0.0f || Settings.PlayerWalkableFloorAngleDegrees > 90.0f) { return Refuse( OutReason, TEXT("PlayerWalkableFloorAngleDegrees must be finite and within [0,90].")); } if (!FMath::IsFinite(Settings.PlayerSupportPatchMinCoverageFraction) || Settings.PlayerSupportPatchMinCoverageFraction <= 0.0f || Settings.PlayerSupportPatchMinCoverageFraction > 1.0f) { return Refuse( OutReason, TEXT("PlayerSupportPatchMinCoverageFraction must be within (0,1].")); } const double MaxStepHeightVoxelsReal = static_cast(Settings.PlayerMaxStepHeightMeters) / static_cast(FVoxelPlayerCapsuleConstants::VoxelSizeMeters); constexpr double MaxSupportSearchHeightVoxels = 4096.0; if (!FMath::IsFinite(MaxStepHeightVoxelsReal) || MaxStepHeightVoxelsReal < 0.0 || MaxStepHeightVoxelsReal > MaxSupportSearchHeightVoxels) { return Refuse( OutReason, TEXT("PlayerMaxStepHeightMeters exceeds the bounded support search.")); } const int32 MaxDownwardSearchCells = static_cast( FMath::CeilToDouble(MaxStepHeightVoxelsReal)); const float MaxStepHeightVoxels = static_cast(MaxStepHeightVoxelsReal); const float MinimumWalkableNormalZ = FMath::Cos(FMath::DegreesToRadians( Settings.PlayerWalkableFloorAngleDegrees)); TArray SupportOffsets; int32 CentreOffsetIndex = INDEX_NONE; if (!BuildPlayerSupportPatchOffsets( Settings, SupportOffsets, CentreOffsetIndex, OutReason)) { return false; } const int32 RequiredSupportCount = FMath::Clamp( FMath::CeilToInt( Settings.PlayerSupportPatchMinCoverageFraction * static_cast(SupportOffsets.Num())), 1, SupportOffsets.Num()); TArray StencilRows; if (!BuildPlayerCapsuleStencil(Settings, StencilRows, OutReason)) { return false; } OutPlayerFit.Init(0u, Grid.CellCount); if (OutPlayerFitSupportHeights != nullptr) { OutPlayerFitSupportHeights->Init(-FLT_MAX, Grid.CellCount); } TArray PlayerFitCounts; PlayerFitCounts.Init(0, Grid.NumZ); // A candidate pose only reads the immutable sampled grid and writes its own mask cell. // Give each Z slab private stencil scratch, then reduce the per-slab counts in Z order // below. This removes the old shared scratch/count race without changing any predicate. ParallelFor(FMath::Max(Grid.NumZ - 1, 0), [&](int32 SliceIndex) { const int32 Z = SliceIndex + 1; TArray SupportHeights; SupportHeights.SetNumUninitialized(SupportOffsets.Num()); TArray SupportAnchorZs; SupportAnchorZs.SetNumUninitialized(SupportOffsets.Num()); TArray bSupported; bSupported.Init(0u, SupportOffsets.Num()); int64 NumPlayerFitInSlice = 0; for (int32 Y = 0; Y < Grid.NumY; ++Y) { for (int32 X = 0; X < Grid.NumX; ++X) { const int32 Current = Grid.Index(X, Y, Z); // This is the free-pose candidate. Its support is resolved independently // below, so an air cell one or two voxels above a surface can be lowered to // that surface when the character's named step height permits it. if (Grid.Air[Current] == 0u) { continue; } int32 NumSupported = 0; for (int32 SupportIndex = 0; SupportIndex < SupportOffsets.Num(); ++SupportIndex) { const FIntPoint& Offset = SupportOffsets[SupportIndex]; const int32 SupportX = X + Offset.X; const int32 SupportY = Y + Offset.Y; bSupported[SupportIndex] = 0u; if (SupportX < 0 || SupportX >= Grid.NumX || SupportY < 0 || SupportY >= Grid.NumY) { continue; } float SurfaceHeight = 0.0f; int32 SurfaceAnchorZ = INDEX_NONE; if (FindSupportSurfaceBelow( Grid, SupportX, SupportY, Z, MaxDownwardSearchCells, MaxStepHeightVoxels, SurfaceHeight, SurfaceAnchorZ)) { bSupported[SupportIndex] = 1u; SupportHeights[SupportIndex] = SurfaceHeight; SupportAnchorZs[SupportIndex] = SurfaceAnchorZ; ++NumSupported; } } if (NumSupported < RequiredSupportCount || bSupported[CentreOffsetIndex] == 0u) { continue; } int32 SupportCount = 0; float SupportHeight = 0.0f; int32 SupportAnchorZ = INDEX_NONE; if (!IsWalkableSupportPatch( SupportOffsets, SupportHeights, bSupported, CentreOffsetIndex, RequiredSupportCount, MinimumWalkableNormalZ, SupportCount, SupportHeight, SupportAnchorZ, SupportAnchorZs)) { continue; } // Resolve the pose to the highest coherent support in the patch and run the // full capsule stencil again there. The support search alone is not allowed // to turn a ledge or an overhang into a fit result. Keep the original free // pose cell in the mask: every accepted pose is a valid point in the // existing 6-neighbour traversal graph, and retaining the bounded vertical // band is what lets adjacent gently sloped surfaces overlap instead of // becoming diagonal, disconnected floor anchors. if (!CapsuleClearAtSupportHeight( Grid, StencilRows, X, Y, SupportHeight)) { continue; } if (SupportAnchorZ < 1 || SupportAnchorZ >= Grid.NumZ) { continue; } OutPlayerFit[Current] = 1u; if (OutPlayerFitSupportHeights != nullptr) { (*OutPlayerFitSupportHeights)[Current] = SupportHeight; } ++NumPlayerFitInSlice; } } PlayerFitCounts[Z] = NumPlayerFitInSlice; }); // Fixed-index reduction is intentional: even though the terms are integral, keeping the // reduction serial documents the determinism rule shared by the floating metrics below. for (int32 Z = 1; Z < Grid.NumZ; ++Z) { OutNumPlayerFitCells += PlayerFitCounts[Z]; } return true; } float MinimumPlayerClearanceChebyshev( const FSampleGrid& Grid, const TArray& PlayerFit) { if (PlayerFit.Num() != Grid.CellCount) { return 0.0f; } const int32 PlaneCells = Grid.NumX * Grid.NumY; constexpr int32 Infinity = INT32_MAX / 4; TArray PerSliceMinimum; PerSliceMinimum.Init(Infinity, Grid.NumZ); // Each horizontal distance transform is independent. Keep its mutable raster in the // worker and publish one integer result per Z slice; the final minimum is reduced in // ascending Z order for an explicit deterministic reduction. ParallelFor(Grid.NumZ, [&](int32 Z) { TArray Distances; Distances.SetNumUninitialized(PlaneCells); for (int32 Y = 0; Y < Grid.NumY; ++Y) { for (int32 X = 0; X < Grid.NumX; ++X) { const int32 PlaneIndex = X + Grid.NumX * Y; Distances[PlaneIndex] = Grid.Air[Grid.Index(X, Y, Z)] == 0u ? 0 : Infinity; } } auto Relax = [&](int32 Current, int32 Neighbor) { if (Distances[Neighbor] < Infinity) { Distances[Current] = FMath::Min( Distances[Current], Distances[Neighbor] + 1); } }; // Two raster passes with the complete 8-neighbour stencil give a true 2D // Chebyshev distance to solid in this horizontal slice. It is used only for the // reported minimum; capsule occupancy above is the direct stencil check. for (int32 Y = 0; Y < Grid.NumY; ++Y) { for (int32 X = 0; X < Grid.NumX; ++X) { const int32 Current = X + Grid.NumX * Y; if (Grid.Air[Grid.Index(X, Y, Z)] == 0u) continue; if (X > 0) Relax(Current, Current - 1); if (Y > 0) { Relax(Current, Current - Grid.NumX); if (X > 0) Relax(Current, Current - Grid.NumX - 1); if (X + 1 < Grid.NumX) Relax(Current, Current - Grid.NumX + 1); } } } for (int32 Y = Grid.NumY - 1; Y >= 0; --Y) { for (int32 X = Grid.NumX - 1; X >= 0; --X) { const int32 Current = X + Grid.NumX * Y; if (Grid.Air[Grid.Index(X, Y, Z)] == 0u) continue; if (X + 1 < Grid.NumX) Relax(Current, Current + 1); if (Y + 1 < Grid.NumY) { Relax(Current, Current + Grid.NumX); if (X > 0) Relax(Current, Current + Grid.NumX - 1); if (X + 1 < Grid.NumX) Relax(Current, Current + Grid.NumX + 1); } } } int32 SliceMinimum = Infinity; for (int32 Y = 0; Y < Grid.NumY; ++Y) { for (int32 X = 0; X < Grid.NumX; ++X) { const int32 Cell = Grid.Index(X, Y, Z); if (PlayerFit[Cell] != 0u) { SliceMinimum = FMath::Min( SliceMinimum, Distances[X + Grid.NumX * Y]); } } } PerSliceMinimum[Z] = SliceMinimum; }); int32 MinimumDistance = Infinity; for (int32 Z = 0; Z < Grid.NumZ; ++Z) { MinimumDistance = FMath::Min(MinimumDistance, PerSliceMinimum[Z]); } return MinimumDistance == Infinity ? 0.0f : static_cast(MinimumDistance); } int64 CountAirCells( const FSampleGrid& Grid, const TArray* EligibilityMask = nullptr) { TArray AirCountsByZ; AirCountsByZ.Init(0, Grid.NumZ); ParallelFor(Grid.NumZ, [&](int32 Z) { int64 Count = 0; for (int32 Y = 0; Y < Grid.NumY; ++Y) { for (int32 X = 0; X < Grid.NumX; ++X) { const int32 Cell = Grid.Index(X, Y, Z); Count += Grid.Air[Cell] != 0u && (EligibilityMask == nullptr || (*EligibilityMask)[Cell] != 0u) ? 1 : 0; } } AirCountsByZ[Z] = Count; }); int64 Total = 0; for (int32 Z = 0; Z < Grid.NumZ; ++Z) { Total += AirCountsByZ[Z]; } return Total; } void BuildPlayerFitMetrics( const FSampleGrid& Grid, const FVoxelStrateMeasureSettings& Settings, FVoxelStrateMetrics& InOutMetrics, TArray* OutPlayerFitMask = nullptr, TArray* OutPlayerFitSupportHeights = nullptr) { InOutMetrics.bPlayerFitResolved = false; InOutMetrics.PlayerFitRefusalReason.Reset(); InOutMetrics.NumPlayerFitCells = 0; InOutMetrics.PlayerFitFraction = 0.0f; InOutMetrics.NumTraversableComponents = 0; InOutMetrics.LargestTraversableComponentCells = 0; InOutMetrics.TraversableComponentShare = 0.0f; InOutMetrics.MinimumPlayerClearanceVoxels = 0.0f; TArray PlayerFit; TArray PlayerFitSupportHeights; if (!BuildPlayerFitMask( Grid, Settings, PlayerFit, InOutMetrics.NumPlayerFitCells, InOutMetrics.PlayerFitRefusalReason, &PlayerFitSupportHeights)) { if (OutPlayerFitMask != nullptr) { OutPlayerFitMask->Reset(); } return; } InOutMetrics.bPlayerFitResolved = true; InOutMetrics.PlayerFitFraction = InOutMetrics.NumAir > 0 ? static_cast(static_cast(InOutMetrics.NumPlayerFitCells) / static_cast(InOutMetrics.NumAir)) : 0.0f; TArray Components; int32 NumComponents = 0; int64 LargestComponentCells = 0; int32 LargestComponentLowestCell = INDEX_NONE; int32 NumComponentsAtLeast1Pct = 0; FloodFillAir( Grid, Components, NumComponents, InOutMetrics.NumPlayerFitCells, LargestComponentCells, LargestComponentLowestCell, NumComponentsAtLeast1Pct, nullptr, &PlayerFit, &PlayerFitSupportHeights, &Settings, EPlayerTraversalPolicy::WalkOnly); InOutMetrics.NumTraversableComponents = NumComponents; InOutMetrics.LargestTraversableComponentCells = LargestComponentCells; InOutMetrics.TraversableComponentShare = InOutMetrics.NumPlayerFitCells > 0 ? static_cast(static_cast(LargestComponentCells) / static_cast(InOutMetrics.NumPlayerFitCells)) : 0.0f; InOutMetrics.MinimumPlayerClearanceVoxels = MinimumPlayerClearanceChebyshev( Grid, PlayerFit); if (OutPlayerFitMask != nullptr) { *OutPlayerFitMask = MoveTemp(PlayerFit); } if (OutPlayerFitSupportHeights != nullptr) { *OutPlayerFitSupportHeights = MoveTemp(PlayerFitSupportHeights); } } void BuildPlayerFitConnectivityMetrics( const FSampleGrid& Grid, const FVoxelStrateMeasureSettings& Settings, FVoxelStrateMetrics& OutMetrics, TArray& OutPlayerFitMask, TArray& OutPlayerFitSupportHeights) { OutMetrics = FVoxelStrateMetrics(); OutMetrics.ResolvedMarginVoxels = Grid.ResolvedMarginVoxels; OutMetrics.SampledMinZ = Grid.SampledMinZ; OutMetrics.SampledMaxZ = Grid.SampledMaxZ; OutMetrics.SampledNumX = Grid.NumX; OutMetrics.SampledNumY = Grid.NumY; OutMetrics.SampledNumZ = Grid.NumZ; OutMetrics.SampledMinX = Grid.MinX; OutMetrics.SampledMaxX = Grid.MaxX; OutMetrics.SampledMinY = Grid.MinY; OutMetrics.SampledMaxY = Grid.MaxY; OutMetrics.NumSampled = Grid.CellCount; OutMetrics.NumAir = CountAirCells(Grid); OutMetrics.NumSolid = static_cast(Grid.CellCount) - OutMetrics.NumAir; OutMetrics.AirFraction = OutMetrics.NumSampled > 0 ? static_cast(static_cast(OutMetrics.NumAir) / static_cast(OutMetrics.NumSampled)) : 0.0f; BuildPlayerFitMetrics( Grid, Settings, OutMetrics, &OutPlayerFitMask, &OutPlayerFitSupportHeights); OutMetrics.bValid = OutMetrics.NumSampled > 0; } float MedianFloat(TArray& Values) { if (Values.Num() == 0) return 0.0f; Values.Sort(); const int32 Middle = Values.Num() / 2; if ((Values.Num() & 1) != 0) { return Values[Middle]; } return (Values[Middle - 1] + Values[Middle]) * 0.5f; } void BuildMetricsFromGrid( const FSampleGrid& Grid, const FVoxelStrateMeasureSettings& Settings, FVoxelStrateMetrics& InOutMetrics, TArray* OutPlayerFitMask = nullptr) { TArray Components; int32 NumComponents = 0; const int64 NumAir = CountAirCells(Grid); int64 LargestComponentCells = 0; int32 LargestComponentLowestCell = INDEX_NONE; int32 NumComponentsAtLeast1Pct = 0; TArray ComponentCells; FloodFillAir( Grid, Components, NumComponents, NumAir, LargestComponentCells, LargestComponentLowestCell, NumComponentsAtLeast1Pct, &ComponentCells); InOutMetrics.NumSampled = Grid.CellCount; InOutMetrics.NumAir = NumAir; InOutMetrics.NumSolid = static_cast(Grid.CellCount) - NumAir; InOutMetrics.AirFraction = static_cast( static_cast(NumAir) / static_cast(Grid.CellCount)); InOutMetrics.NumAirComponents = NumComponents; InOutMetrics.AirComponentCells = MoveTemp(ComponentCells); InOutMetrics.LargestComponentCells = LargestComponentCells; InOutMetrics.NumComponentsAtLeast1Pct = NumComponentsAtLeast1Pct; InOutMetrics.LargestComponentShare = NumAir > 0 ? static_cast(static_cast(LargestComponentCells) / static_cast(NumAir)) : 0.0f; if (NumAir > 0 && LargestComponentLowestCell != INDEX_NONE) { int32 LargestX = 0; int32 LargestY = 0; int32 LargestZ = 0; DecodeIndex(Grid, LargestComponentLowestCell, LargestX, LargestY, LargestZ); InOutMetrics.LargestComponentPoint = Grid.CellCenter(LargestX, LargestY, LargestZ); } TArray ClearanceValues; ClearanceValues.Reserve(static_cast(NumAir)); int64 NumWalkable = 0; const int64 XYColumnCount64 = static_cast(Grid.NumX) * static_cast(Grid.NumY); TArray WalkableColumns; WalkableColumns.Init(0u, static_cast(XYColumnCount64)); // Candidate poses read only the immutable sampled field. Keep every worker's clearance // values and compact column flags private, then merge by fixed Z order so the median and // projected surface graph remain deterministic. TArray WalkableCountsByZ; WalkableCountsByZ.Init(0, Grid.NumZ); TArray> ClearanceValuesByZ; ClearanceValuesByZ.SetNum(Grid.NumZ); TArray> WalkableColumnsByZ; WalkableColumnsByZ.SetNum(Grid.NumZ); ParallelFor(Grid.NumZ, [&](int32 Z) { TArray& SliceClearanceValues = ClearanceValuesByZ[Z]; TArray& SliceWalkableColumns = WalkableColumnsByZ[Z]; SliceWalkableColumns.Init(0u, static_cast(XYColumnCount64)); int64 SliceWalkableCount = 0; for (int32 Y = 0; Y < Grid.NumY; ++Y) { for (int32 X = 0; X < Grid.NumX; ++X) { const int32 Current = Grid.Index(X, Y, Z); if (Grid.Air[Current] == 0u || Z == 0 || Grid.Air[Grid.Index(X, Y, Z - 1)] != 0u) { continue; } bool bHasHeadroom = true; for (int32 H = 0; H < Settings.HeadroomCells; ++H) { const int64 HeadroomZ = static_cast(Z) + H; if (HeadroomZ >= Grid.NumZ || Grid.Air[Grid.Index(X, Y, static_cast(HeadroomZ))] == 0u) { bHasHeadroom = false; break; } } if (!bHasHeadroom) continue; ++SliceWalkableCount; SliceWalkableColumns[X + Grid.NumX * Y] = 1u; int32 ClearanceCells = 0; while (static_cast(Z) + ClearanceCells < Grid.NumZ && Grid.Air[Grid.Index(X, Y, Z + ClearanceCells)] != 0u) { ++ClearanceCells; } const int64 ClearanceVoxels = static_cast(ClearanceCells) * Settings.SampleStep; SliceClearanceValues.Add(ClearanceVoxels > INT32_MAX ? INT32_MAX : static_cast(ClearanceVoxels)); } } WalkableCountsByZ[Z] = SliceWalkableCount; }); for (int32 Z = 0; Z < Grid.NumZ; ++Z) { NumWalkable += WalkableCountsByZ[Z]; const TArray& SliceWalkableColumns = WalkableColumnsByZ[Z]; for (int32 Column = 0; Column < SliceWalkableColumns.Num(); ++Column) { if (SliceWalkableColumns[Column] != 0u) { WalkableColumns[Column] = 1u; } } ClearanceValues.Append(ClearanceValuesByZ[Z]); } InOutMetrics.WalkableFraction = NumAir > 0 ? static_cast(static_cast(NumWalkable) / static_cast(NumAir)) : 0.0f; for (const uint8 bWalkable : WalkableColumns) { InOutMetrics.WalkableFloorColumns += bWalkable != 0u ? 1 : 0; } InOutMetrics.WalkableFloorAreaFraction = XYColumnCount64 > 0 ? static_cast(static_cast(InOutMetrics.WalkableFloorColumns) / static_cast(XYColumnCount64)) : 0.0f; // The area graph is intentionally projected to XY: each column contributes one floor // footprint even when several sampled Z cells in it meet the walkability predicate. // Keep the flood fill deterministic and bounded by the already bounded XY grid. TArray VisitedWalkableColumns; VisitedWalkableColumns.Init(0u, static_cast(XYColumnCount64)); TArray SurfaceQueue; SurfaceQueue.Reserve(static_cast(XYColumnCount64)); for (int32 Column = 0; Column < WalkableColumns.Num(); ++Column) { if (WalkableColumns[Column] == 0u || VisitedWalkableColumns[Column] != 0u) { continue; } ++InOutMetrics.NumWalkableSurfaceComponents; VisitedWalkableColumns[Column] = 1u; SurfaceQueue.Reset(); SurfaceQueue.Add(Column); int64 ComponentColumns = 0; for (int32 QueueIndex = 0; QueueIndex < SurfaceQueue.Num(); ++QueueIndex) { const int32 CurrentColumn = SurfaceQueue[QueueIndex]; ++ComponentColumns; const int32 CurrentX = CurrentColumn % Grid.NumX; const int32 CurrentY = CurrentColumn / Grid.NumX; const int32 NeighbourColumns[4] = { CurrentX > 0 ? CurrentColumn - 1 : INDEX_NONE, CurrentX + 1 < Grid.NumX ? CurrentColumn + 1 : INDEX_NONE, CurrentY > 0 ? CurrentColumn - Grid.NumX : INDEX_NONE, CurrentY + 1 < Grid.NumY ? CurrentColumn + Grid.NumX : INDEX_NONE }; for (const int32 Neighbour : NeighbourColumns) { if (Neighbour != INDEX_NONE && WalkableColumns[Neighbour] != 0u && VisitedWalkableColumns[Neighbour] == 0u) { VisitedWalkableColumns[Neighbour] = 1u; SurfaceQueue.Add(Neighbour); } } } InOutMetrics.LargestWalkableSurfaceColumns = FMath::Max( InOutMetrics.LargestWalkableSurfaceColumns, ComponentColumns); } InOutMetrics.LargestWalkableSurfaceShare = InOutMetrics.WalkableFloorColumns > 0 ? static_cast(static_cast(InOutMetrics.LargestWalkableSurfaceColumns) / static_cast(InOutMetrics.WalkableFloorColumns)) : 0.0f; if (ClearanceValues.Num() > 0) { ClearanceValues.Sort(); // The public metric is integral, so use the lower median for an even sample count. InOutMetrics.MedianVerticalClearance = ClearanceValues[(ClearanceValues.Num() - 1) / 2]; } if (InOutMetrics.NumSolid > 0 && NumAir > 0) { TArray Distances; BuildManhattanDistanceToSolid(Grid, Distances); TArray> FeatureScalesByZ; FeatureScalesByZ.SetNum(Grid.NumZ); ParallelFor(Grid.NumZ, [&](int32 Z) { TArray& SliceFeatureScales = FeatureScalesByZ[Z]; for (int32 Y = 0; Y < Grid.NumY; ++Y) { for (int32 X = 0; X < Grid.NumX; ++X) { const int32 Index = Grid.Index(X, Y, Z); if (Grid.Air[Index] != 0u) { SliceFeatureScales.Add(static_cast(Distances[Index]) * static_cast(Settings.SampleStep)); } } } }); TArray FeatureScales; FeatureScales.Reserve(static_cast(NumAir)); for (int32 Z = 0; Z < Grid.NumZ; ++Z) { FeatureScales.Append(FeatureScalesByZ[Z]); } InOutMetrics.MedianFeatureScale = MedianFloat(FeatureScales); } // This is intentionally a separate fine-resolution pass. The legacy metrics above may // be useful at SampleStep=4/8, but they cannot answer whether the confirmed player capsule // fits. A coarse grid therefore receives an explicit refusal instead of an estimate. BuildPlayerFitMetrics(Grid, Settings, InOutMetrics, OutPlayerFitMask); } bool FindCellForPoint(const FSampleGrid& Grid, const FVector& Point, int32& OutCell) { if (!Grid.ContainsPoint(Point)) return false; const float Step = static_cast(Grid.SampleStep); const int32 X = FMath::Clamp(FMath::FloorToInt((Point.X - Grid.MinX) / Step), 0, Grid.NumX - 1); const int32 Y = FMath::Clamp(FMath::FloorToInt((Point.Y - Grid.MinY) / Step), 0, Grid.NumY - 1); const int32 Z = FMath::Clamp(FMath::FloorToInt((Point.Z - Grid.MinZ) / Step), 0, Grid.NumZ - 1); OutCell = Grid.Index(X, Y, Z); return true; } enum class EEndpointCellResult : uint8 { OutOfWindow, Air, Solid, NotPlayerFit }; EEndpointCellResult ResolveEndpointCell( const FSampleGrid& Grid, const FVector& Point, int32& OutCell, bool& bOutSnapped, const TArray* EligibilityMask = nullptr) { bOutSnapped = false; int32 OriginalCell = -1; if (!FindCellForPoint(Grid, Point, OriginalCell)) { return EEndpointCellResult::OutOfWindow; } const auto IsEligible = [&, EligibilityMask](int32 Cell) { return Grid.Air[Cell] != 0u && (EligibilityMask == nullptr || (*EligibilityMask)[Cell] != 0u); }; if (IsEligible(OriginalCell)) { OutCell = OriginalCell; return EEndpointCellResult::Air; } int32 OriginalX = 0; int32 OriginalY = 0; int32 OriginalZ = 0; DecodeIndex(Grid, OriginalCell, OriginalX, OriginalY, OriginalZ); int32 BestCell = INDEX_NONE; float BestDistanceSquared = FLT_MAX; const int32 MaxX = Grid.NumX - 1; const int32 MaxY = Grid.NumY - 1; const int32 MaxZ = Grid.NumZ - 1; const int32 MinX = OriginalX > 0 ? OriginalX - 1 : 0; const int32 MinY = OriginalY > 0 ? OriginalY - 1 : 0; const int32 MinZ = OriginalZ > 0 ? OriginalZ - 1 : 0; const int32 CandidateMaxX = OriginalX < MaxX ? OriginalX + 1 : MaxX; const int32 CandidateMaxY = OriginalY < MaxY ? OriginalY + 1 : MaxY; const int32 CandidateMaxZ = OriginalZ < MaxZ ? OriginalZ + 1 : MaxZ; for (int32 Z = MinZ; Z <= CandidateMaxZ; ++Z) { for (int32 Y = MinY; Y <= CandidateMaxY; ++Y) { for (int32 X = MinX; X <= CandidateMaxX; ++X) { if (X == OriginalX && Y == OriginalY && Z == OriginalZ) { continue; } const int32 Candidate = Grid.Index(X, Y, Z); if (!IsEligible(Candidate)) { continue; } const float DistanceSquared = FVector::DistSquared( Point, Grid.CellCenter(X, Y, Z)); if (DistanceSquared < BestDistanceSquared || (DistanceSquared == BestDistanceSquared && (BestCell == INDEX_NONE || Candidate < BestCell))) { BestDistanceSquared = DistanceSquared; BestCell = Candidate; } } } } if (BestCell == INDEX_NONE) { if (EligibilityMask != nullptr) { // Passage mouth points are authored on the centreline of a carved connector, not // necessarily on the floor-anchor lattice used by PlayerFit. Resolve them to the // nearest player-fitting floor cell over the bounded fine grid, rather than // declaring an in-air mouth unreachable just because its floor is several cells // below. The endpoint is reported as snapped, and the route re-check below still // validates the endpoint-to-anchor segment against density. for (int32 Candidate = 0; Candidate < Grid.CellCount; ++Candidate) { if ((*EligibilityMask)[Candidate] == 0u) { continue; } int32 CandidateX = 0; int32 CandidateY = 0; int32 CandidateZ = 0; DecodeIndex(Grid, Candidate, CandidateX, CandidateY, CandidateZ); const float DistanceSquared = FVector::DistSquared( Point, Grid.CellCenter(CandidateX, CandidateY, CandidateZ)); if (DistanceSquared < BestDistanceSquared || (DistanceSquared == BestDistanceSquared && (BestCell == INDEX_NONE || Candidate < BestCell))) { BestDistanceSquared = DistanceSquared; BestCell = Candidate; } } } } if (BestCell == INDEX_NONE) { return EligibilityMask != nullptr ? EEndpointCellResult::NotPlayerFit : EEndpointCellResult::Solid; } OutCell = BestCell; bOutSnapped = true; return EEndpointCellResult::Air; } struct FCoarseEdge { int32 First = INDEX_NONE; int32 Second = INDEX_NONE; bool IsValid() const { return First >= 0 && Second >= 0 && First != Second; } }; FCoarseEdge MakeCoarseEdge(int32 A, int32 B) { FCoarseEdge Edge; Edge.First = FMath::Min(A, B); Edge.Second = FMath::Max(A, B); return Edge; } bool IsCoarseEdgeBlocked( const TArray& BlockedEdges, int32 A, int32 B) { const FCoarseEdge Candidate = MakeCoarseEdge(A, B); for (const FCoarseEdge& Blocked : BlockedEdges) { if (Blocked.First == Candidate.First && Blocked.Second == Candidate.Second) { return true; } } return false; } bool FullResolutionAirOnSegment( const UVoxelGenerator* Generator, const IVoxelStrateDensitySampler* Sampler, const FVector& Start, const FVector& End) { const FVector Delta = End - Start; const float MaxDelta = FMath::Max3( FMath::Abs(Delta.X), FMath::Abs(Delta.Y), FMath::Abs(Delta.Z)); const int32 NumSteps = FMath::Max(1, FMath::CeilToInt(MaxDelta)); for (int32 Step = 0; Step <= NumSteps; ++Step) { const float Alpha = static_cast(Step) / static_cast(NumSteps); const FVector Sample = Start + Delta * Alpha; const float Density = Sampler != nullptr ? Sampler->SampleDensity(Sample.X, Sample.Y, Sample.Z) : Generator->GetDensityAt(Sample.X, Sample.Y, Sample.Z); if (!FMath::IsFinite(Density) || !(Density > 0.0f)) { return false; } } return true; } bool FullResolutionPathIsAir( const UVoxelGenerator* Generator, const IVoxelStrateDensitySampler* Sampler, const FSampleGrid& Grid, const FVector& A, const FVector& B, const TArray& Path, FCoarseEdge& OutFailedEdge, const TArray* EligibilityMask = nullptr, const TArray* PlayerFitSupportHeights = nullptr, const FVoxelStrateMeasureSettings* TraversalSettings = nullptr, EPlayerTraversalPolicy TraversalPolicy = EPlayerTraversalPolicy::WalkAndClimb) { OutFailedEdge = FCoarseEdge(); if (EligibilityMask != nullptr) { // A player-fit route is deliberately a graph over supported free poses, not a second // air route with a coarse-cell illusion. The mask was built at SampleStep=1 and every // edge below is between adjacent player-fitting cells. for (int32 PathIndex = 0; PathIndex < Path.Num(); ++PathIndex) { if (!EligibilityMask->IsValidIndex(Path[PathIndex]) || (*EligibilityMask)[Path[PathIndex]] == 0u) { if (PathIndex > 0) { OutFailedEdge = MakeCoarseEdge( Path[PathIndex - 1], Path[PathIndex]); } return false; } if (PathIndex > 0 && PlayerFitSupportHeights != nullptr && TraversalSettings != nullptr) { bool bClimb = false; if (!IsPlayerTraversalEdge( Grid, *EligibilityMask, PlayerFitSupportHeights, TraversalSettings, Path[PathIndex - 1], Path[PathIndex], bClimb, TraversalPolicy)) { OutFailedEdge = MakeCoarseEdge( Path[PathIndex - 1], Path[PathIndex]); return false; } } } int32 StartX = 0; int32 StartY = 0; int32 StartZ = 0; DecodeIndex(Grid, Path[0], StartX, StartY, StartZ); int32 EndX = 0; int32 EndY = 0; int32 EndZ = 0; DecodeIndex(Grid, Path.Last(), EndX, EndY, EndZ); if (!FullResolutionAirOnSegment( Generator, Sampler, A, Grid.CellCenter(StartX, StartY, StartZ)) || !FullResolutionAirOnSegment( Generator, Sampler, Grid.CellCenter(EndX, EndY, EndZ), B)) { // Endpoint approaches have no graph edge to exclude. The player-fit route is // therefore unknown if either mouth-to-anchor segment contains solid density. return false; } return true; } int32 StartX = 0; int32 StartY = 0; int32 StartZ = 0; DecodeIndex(Grid, Path[0], StartX, StartY, StartZ); int32 EndX = 0; int32 EndY = 0; int32 EndZ = 0; DecodeIndex(Grid, Path.Last(), EndX, EndY, EndZ); if (!FullResolutionAirOnSegment(Generator, Sampler, A, Grid.CellCenter(StartX, StartY, StartZ))) { // The endpoint-to-cell-center segment has no coarse cell pair to exclude. It is // common to every route from this endpoint, so report unknown rather than claiming // that the pair is disconnected at this resolution. return false; } for (int32 PathIndex = 1; PathIndex < Path.Num(); ++PathIndex) { int32 PreviousX = 0; int32 PreviousY = 0; int32 PreviousZ = 0; DecodeIndex(Grid, Path[PathIndex - 1], PreviousX, PreviousY, PreviousZ); int32 CurrentX = 0; int32 CurrentY = 0; int32 CurrentZ = 0; DecodeIndex(Grid, Path[PathIndex], CurrentX, CurrentY, CurrentZ); if (!FullResolutionAirOnSegment( Generator, Sampler, Grid.CellCenter(PreviousX, PreviousY, PreviousZ), Grid.CellCenter(CurrentX, CurrentY, CurrentZ))) { OutFailedEdge = MakeCoarseEdge(Path[PathIndex - 1], Path[PathIndex]); return false; } } if (!FullResolutionAirOnSegment(Generator, Sampler, Grid.CellCenter(EndX, EndY, EndZ), B)) { // As above, the final endpoint segment is shared by every route that reaches the // goal cell and therefore has no precise coarse edge to exclude. return false; } return true; } bool FindCoarsePath( const FSampleGrid& Grid, int32 Start, int32 Goal, const TArray& BlockedEdges, TArray& OutPath, const TArray* EligibilityMask = nullptr, const TArray* PlayerFitSupportHeights = nullptr, const FVoxelStrateMeasureSettings* TraversalSettings = nullptr, EPlayerTraversalPolicy TraversalPolicy = EPlayerTraversalPolicy::WalkAndClimb) { const auto IsEligible = [&, EligibilityMask](int32 Cell) { return Grid.Air[Cell] != 0u && (EligibilityMask == nullptr || (*EligibilityMask)[Cell] != 0u); }; if (!IsEligible(Start) || !IsEligible(Goal)) { return false; } TArray Parent; Parent.SetNumUninitialized(Grid.CellCount); for (int32 Index = 0; Index < Grid.CellCount; ++Index) { Parent[Index] = -2; // unvisited; -1 is the root's parent } TArray Queue; Queue.SetNumUninitialized(Grid.CellCount); int32 Head = 0; int32 Tail = 0; Parent[Start] = -1; Queue[Tail++] = Start; while (Head < Tail && Parent[Goal] == -2) { const int32 Current = Queue[Head++]; int32 X = 0; int32 Y = 0; int32 Z = 0; DecodeIndex(Grid, Current, X, Y, Z); auto Visit = [&](int32 Neighbor) { if (IsEligible(Neighbor) && Parent[Neighbor] == -2 && !IsCoarseEdgeBlocked(BlockedEdges, Current, Neighbor)) { Parent[Neighbor] = Current; Queue[Tail++] = Neighbor; } }; if (EligibilityMask != nullptr && PlayerFitSupportHeights != nullptr && TraversalSettings != nullptr) { TArray> Neighbours; AppendPlayerTraversalNeighbours( Grid, *EligibilityMask, PlayerFitSupportHeights, TraversalSettings, Current, Neighbours, TraversalPolicy); for (const FPlayerTraversalNeighbour& Neighbour : Neighbours) { Visit(Neighbour.Cell); } } else { // The order is fixed so the recovered route is deterministic. This is a real BFS // parent chain, not just a component-membership answer. if (X + 1 < Grid.NumX) Visit(Grid.Index(X + 1, Y, Z)); if (X > 0) Visit(Grid.Index(X - 1, Y, Z)); if (Y + 1 < Grid.NumY) Visit(Grid.Index(X, Y + 1, Z)); if (Y > 0) Visit(Grid.Index(X, Y - 1, Z)); if (Z + 1 < Grid.NumZ) Visit(Grid.Index(X, Y, Z + 1)); if (Z > 0) Visit(Grid.Index(X, Y, Z - 1)); } } if (Parent[Goal] == -2) { return false; } OutPath.Reset(); OutPath.Reserve(Tail); for (int32 Current = Goal; Current >= 0; Current = Parent[Current]) { OutPath.Add(Current); if (Current == Start) break; } if (OutPath.Last() != Start) { OutPath.Reset(); return false; } for (int32 Left = 0, Right = OutPath.Num() - 1; Left < Right; ++Left, --Right) { Swap(OutPath[Left], OutPath[Right]); } return true; } EVoxelConnectivityResult EvaluateConnectivityOnGrid( const UVoxelGenerator* Generator, const IVoxelStrateDensitySampler* Sampler, const FSampleGrid& Grid, const FVector& AVoxel, const FVector& BVoxel, int32& OutStart, int32& OutGoal, bool& bOutStartSnapped, bool& bOutGoalSnapped, int32& OutNumRouteRetries, int32 MaxRouteRetries, const TArray* EligibilityMask = nullptr, const TArray* PlayerFitSupportHeights = nullptr, const FVoxelStrateMeasureSettings* TraversalSettings = nullptr, EPlayerTraversalPolicy TraversalPolicy = EPlayerTraversalPolicy::WalkAndClimb) { OutStart = -1; OutGoal = -1; bOutStartSnapped = false; bOutGoalSnapped = false; OutNumRouteRetries = 0; const EEndpointCellResult StartCell = ResolveEndpointCell( Grid, AVoxel, OutStart, bOutStartSnapped, EligibilityMask); const EEndpointCellResult GoalCell = ResolveEndpointCell( Grid, BVoxel, OutGoal, bOutGoalSnapped, EligibilityMask); if (StartCell == EEndpointCellResult::OutOfWindow || GoalCell == EEndpointCellResult::OutOfWindow) { return EVoxelConnectivityResult::OutOfWindow; } if (StartCell == EEndpointCellResult::Solid) { return EVoxelConnectivityResult::StartCellSolid; } if (StartCell == EEndpointCellResult::NotPlayerFit) { return EVoxelConnectivityResult::StartCellNotPlayerFit; } if (GoalCell == EEndpointCellResult::Solid) { return EVoxelConnectivityResult::GoalCellSolid; } if (GoalCell == EEndpointCellResult::NotPlayerFit) { return EVoxelConnectivityResult::GoalCellNotPlayerFit; } TArray BlockedEdges; TArray Path; for (;;) { if (!FindCoarsePath( Grid, OutStart, OutGoal, BlockedEdges, Path, EligibilityMask, PlayerFitSupportHeights, TraversalSettings, TraversalPolicy)) { // This is a negative verdict only after all edges excluded by earlier // full-resolution failures leave no coarse route at this resolution. return EVoxelConnectivityResult::NotConnectedAtThisResolution; } // The coarse route is evidence, not a pass. Re-check every candidate at one-voxel // spacing in the original voxel field. A single solid sample refutes only this // route, so exclude its precise coarse edge and search again. FCoarseEdge FailedEdge; if (FullResolutionPathIsAir( Generator, Sampler, Grid, AVoxel, BVoxel, Path, FailedEdge, EligibilityMask, PlayerFitSupportHeights, TraversalSettings, TraversalPolicy)) { return EVoxelConnectivityResult::Connected; } if (OutNumRouteRetries >= MaxRouteRetries || !FailedEdge.IsValid()) { // A route can be refuted without a retryable pair when the failure is in an // endpoint-to-cell segment (or when the explicit retry budget is spent). Both // cases are unknown, never evidence of disconnection. return EVoxelConnectivityResult::CoarseLiedBudgetExhausted; } check(FailedEdge.First != FailedEdge.Second); check(!IsCoarseEdgeBlocked( BlockedEdges, FailedEdge.First, FailedEdge.Second)); BlockedEdges.Add(FailedEdge); ++OutNumRouteRetries; } } float DistanceToComponent( const FSampleGrid& Grid, int32 StartCell, int32 TargetComponent, const TArray& Components) { if (Components[StartCell] == TargetComponent) { return 0.0f; } int32 StartX = 0; int32 StartY = 0; int32 StartZ = 0; DecodeIndex(Grid, StartCell, StartX, StartY, StartZ); int64 BestDistanceSquared = MAX_int64; for (int32 Index = 0; Index < Grid.CellCount; ++Index) { if (Components[Index] != TargetComponent) { continue; } int32 X = 0; int32 Y = 0; int32 Z = 0; DecodeIndex(Grid, Index, X, Y, Z); const int64 DX = static_cast(X) - static_cast(StartX); const int64 DY = static_cast(Y) - static_cast(StartY); const int64 DZ = static_cast(Z) - static_cast(StartZ); const int64 DistanceSquared = DX * DX + DY * DY + DZ * DZ; BestDistanceSquared = FMath::Min(BestDistanceSquared, DistanceSquared); } return BestDistanceSquared == MAX_int64 ? -1.0f : FMath::Sqrt(static_cast(BestDistanceSquared)); } void PopulateConnectivityDiagnostics( const FSampleGrid& Grid, int32 Start, int32 Goal, EVoxelConnectivityResult ConnectivityResult, bool bStartSnapped, bool bGoalSnapped, int32 NumRouteRetries, FVoxelConnectivityDiagnostics& OutDiagnostics, const TArray* EligibilityMask = nullptr, TArray* OutComponents = nullptr, int32* OutNumComponents = nullptr, const TArray* PlayerFitSupportHeights = nullptr, const FVoxelStrateMeasureSettings* TraversalSettings = nullptr, EPlayerTraversalPolicy TraversalPolicy = EPlayerTraversalPolicy::WalkAndClimb) { OutDiagnostics = FVoxelConnectivityDiagnostics(); if (OutComponents != nullptr) { OutComponents->Reset(); } if (OutNumComponents != nullptr) { *OutNumComponents = 0; } OutDiagnostics.Result = ConnectivityResult; OutDiagnostics.bPlayerFitRestricted = EligibilityMask != nullptr; OutDiagnostics.bPlayerFitResolved = EligibilityMask != nullptr; OutDiagnostics.bStartSnapped = bStartSnapped; OutDiagnostics.bGoalSnapped = bGoalSnapped; OutDiagnostics.NumRouteRetries = NumRouteRetries; OutDiagnostics.bValid = ConnectivityResult != EVoxelConnectivityResult::OutOfWindow || (Start >= 0 && Goal >= 0); if (Start < 0 || Goal < 0) { return; } TArray Components; int32 NumComponents = 0; int64 LargestComponentCells = 0; int32 LargestComponentLowestCell = INDEX_NONE; int32 NumComponentsAtLeast1Pct = 0; const int64 NumAir = CountAirCells(Grid, EligibilityMask); FloodFillAir( Grid, Components, NumComponents, NumAir, LargestComponentCells, LargestComponentLowestCell, NumComponentsAtLeast1Pct, nullptr, EligibilityMask, PlayerFitSupportHeights, TraversalSettings, TraversalPolicy); if (OutComponents != nullptr) { *OutComponents = Components; } if (OutNumComponents != nullptr) { *OutNumComponents = NumComponents; } const int32 StartComponent = Components[Start]; const int32 GoalComponent = Components[Goal]; if (StartComponent < 0 || GoalComponent < 0) { return; } TArray ComponentCells; ComponentCells.Init(0, NumComponents); for (const int32 Component : Components) { if (Component >= 0) { ++ComponentCells[Component]; } } OutDiagnostics.NumAirCells = NumAir; OutDiagnostics.StartComponentCells = ComponentCells[StartComponent]; OutDiagnostics.GoalComponentCells = ComponentCells[GoalComponent]; OutDiagnostics.LargestComponentCells = LargestComponentCells; if (NumAir > 0) { OutDiagnostics.StartComponentShare = static_cast( static_cast(OutDiagnostics.StartComponentCells) / static_cast(NumAir)); OutDiagnostics.GoalComponentShare = static_cast( static_cast(OutDiagnostics.GoalComponentCells) / static_cast(NumAir)); OutDiagnostics.LargestComponentShare = static_cast( static_cast(LargestComponentCells) / static_cast(NumAir)); } OutDiagnostics.bStartComponentIsLargest = OutDiagnostics.StartComponentCells == LargestComponentCells; OutDiagnostics.bGoalComponentIsLargest = OutDiagnostics.GoalComponentCells == LargestComponentCells; TArray ComponentDistances; ComponentDistances.Init(0.0f, 2); ParallelFor(2, [&](int32 DistanceIndex) { ComponentDistances[DistanceIndex] = DistanceIndex == 0 ? DistanceToComponent(Grid, Start, GoalComponent, Components) : DistanceToComponent(Grid, Goal, StartComponent, Components); }); OutDiagnostics.StartToGoalComponentDistanceCells = ComponentDistances[0]; OutDiagnostics.GoalToStartComponentDistanceCells = ComponentDistances[1]; } // The hard capability graph is deliberately flooded with walking edges only. This second // pass contracts each walk-only component to one node and adds only the explicitly supported // climb edges between nodes. It gives the reporting tiers without allowing a climb to turn a // failed primordial walk-only verdict into a pass. void BuildClimbTierFromWalkComponents( const FSampleGrid& Grid, const TArray& PlayerFitMask, const TArray& PlayerFitSupportHeights, const FVoxelStrateMeasureSettings& Settings, const TArray& Components, int32 NumComponents, int32 StartCell, int32 GoalCell, int64 PlayerFitCells, int64& OutWalkOnlyReachableCells, int64& OutClimbReachableCells, int64& OutMineRequiredCells, bool& bOutClimbCanReach, int64& OutClimbEdges) { OutWalkOnlyReachableCells = 0; OutClimbReachableCells = 0; OutMineRequiredCells = 0; bOutClimbCanReach = false; OutClimbEdges = 0; if (NumComponents <= 0 || Components.Num() != Grid.CellCount || !Components.IsValidIndex(StartCell) || !Components.IsValidIndex(GoalCell)) { return; } const int32 StartComponent = Components[StartCell]; const int32 GoalComponent = Components[GoalCell]; if (StartComponent < 0 || StartComponent >= NumComponents || GoalComponent < 0 || GoalComponent >= NumComponents) { return; } TArray> CellsByComponent; CellsByComponent.SetNum(NumComponents); for (int32 Cell = 0; Cell < Grid.CellCount; ++Cell) { const int32 Component = Components[Cell]; if (Component >= 0 && Component < NumComponents && IsEligiblePlayerFitCell(Grid, PlayerFitMask, Cell)) { CellsByComponent[Component].Add(Cell); } } OutWalkOnlyReachableCells = CellsByComponent[StartComponent].Num(); TArray ReachedComponents; ReachedComponents.Init(0u, NumComponents); TArray Queue; Queue.Reserve(NumComponents); ReachedComponents[StartComponent] = 1u; Queue.Add(StartComponent); for (int32 QueueIndex = 0; QueueIndex < Queue.Num(); ++QueueIndex) { const int32 Component = Queue[QueueIndex]; for (const int32 Cell : CellsByComponent[Component]) { TArray> Neighbours; AppendPlayerTraversalNeighbours( Grid, PlayerFitMask, &PlayerFitSupportHeights, &Settings, Cell, Neighbours, EPlayerTraversalPolicy::WalkAndClimb); for (const FPlayerTraversalNeighbour& Neighbour : Neighbours) { if (!Neighbour.bClimb || !Components.IsValidIndex(Neighbour.Cell)) { continue; } ++OutClimbEdges; const int32 NeighbourComponent = Components[Neighbour.Cell]; if (NeighbourComponent >= 0 && NeighbourComponent < NumComponents && NeighbourComponent != Component && ReachedComponents[NeighbourComponent] == 0u) { ReachedComponents[NeighbourComponent] = 1u; Queue.Add(NeighbourComponent); } } } } for (int32 Component = 0; Component < NumComponents; ++Component) { if (ReachedComponents[Component] != 0u) { OutClimbReachableCells += CellsByComponent[Component].Num(); } } bOutClimbCanReach = ReachedComponents[GoalComponent] != 0u; OutMineRequiredCells = FMath::Max( 0, PlayerFitCells - OutClimbReachableCells); } EVoxelConnectivityResult QueryConnectivity( const UVoxelGenerator* Generator, const UVoxelStrateManager* Manager, const IVoxelStrateDensitySampler* Sampler, int32 StrateIndex, int32 ExplicitBottomWorldZ, int32 ExplicitTopWorldZ, float ExplicitBoundarySealThickness, bool bUseExplicitBounds, const FVector& AVoxel, const FVector& BVoxel, const FVoxelStrateMeasureSettings& Settings, bool& bOutStartSnapped, bool& bOutGoalSnapped, int32& OutNumRouteRetries, FVoxelConnectivityDiagnostics* OutDiagnostics, bool bPlayerFitRestricted = false, FVoxelStrateMetrics* OutPlayerMetrics = nullptr) { bOutStartSnapped = false; bOutGoalSnapped = false; OutNumRouteRetries = 0; if (OutPlayerMetrics != nullptr) { *OutPlayerMetrics = FVoxelStrateMetrics(); } auto RefusePlayerFit = [&](const FString& Reason) { if (OutDiagnostics != nullptr) { *OutDiagnostics = FVoxelConnectivityDiagnostics(); OutDiagnostics->bPlayerFitRestricted = bPlayerFitRestricted; OutDiagnostics->RefusalReason = Reason; } if (OutPlayerMetrics != nullptr) { OutPlayerMetrics->RefusalReason = Reason; OutPlayerMetrics->PlayerFitRefusalReason = Reason; } }; if (!FMath::IsFinite(AVoxel.X) || !FMath::IsFinite(AVoxel.Y) || !FMath::IsFinite(AVoxel.Z) || !FMath::IsFinite(BVoxel.X) || !FMath::IsFinite(BVoxel.Y) || !FMath::IsFinite(BVoxel.Z)) { if (OutDiagnostics != nullptr) { *OutDiagnostics = FVoxelConnectivityDiagnostics(); OutDiagnostics->bPlayerFitRestricted = bPlayerFitRestricted; } return EVoxelConnectivityResult::OutOfWindow; } if (Settings.MaxRouteRetries < 0) { if (OutDiagnostics != nullptr) { *OutDiagnostics = FVoxelConnectivityDiagnostics(); OutDiagnostics->bPlayerFitRestricted = bPlayerFitRestricted; } return EVoxelConnectivityResult::OutOfWindow; } if (bPlayerFitRestricted && Settings.SampleStep != 1) { RefusePlayerFit(FString::Printf( TEXT("Player-fit connectivity refused: SampleStep=%d; exact capsule fit requires SampleStep=1."), Settings.SampleStep)); return EVoxelConnectivityResult::OutOfWindow; } FSampleGrid Grid; FString RefusalReason; if (!BuildSampleGrid(Generator, Manager, Sampler, StrateIndex, ExplicitBottomWorldZ, ExplicitTopWorldZ, ExplicitBoundarySealThickness, bUseExplicitBounds, Settings, bPlayerFitRestricted, Grid, RefusalReason)) { if (OutDiagnostics != nullptr) { *OutDiagnostics = FVoxelConnectivityDiagnostics(); OutDiagnostics->bPlayerFitRestricted = bPlayerFitRestricted; OutDiagnostics->RefusalReason = RefusalReason; } if (OutPlayerMetrics != nullptr) { OutPlayerMetrics->RefusalReason = RefusalReason; OutPlayerMetrics->PlayerFitRefusalReason = RefusalReason; } return EVoxelConnectivityResult::OutOfWindow; } TArray PlayerFitMask; TArray PlayerFitSupportHeights; FVoxelStrateMetrics PlayerMetrics; if (bPlayerFitRestricted) { // A player-fit query needs only the fine-grid air count plus the direct capsule // stencil. Do not pay for the legacy all-air flood fill, walkable-column scan, or // Manhattan feature transform here: those values belong to the separate legacy // measurement pass, and recomputing them for every fine candidate made the gate // needlessly expensive without changing its answer. BuildPlayerFitConnectivityMetrics( Grid, Settings, PlayerMetrics, PlayerFitMask, PlayerFitSupportHeights); if (OutPlayerMetrics != nullptr) { *OutPlayerMetrics = PlayerMetrics; } if (!PlayerMetrics.bPlayerFitResolved) { RefusePlayerFit(PlayerMetrics.PlayerFitRefusalReason); return EVoxelConnectivityResult::OutOfWindow; } } int32 Start = -1; int32 Goal = -1; const EVoxelConnectivityResult Result = EvaluateConnectivityOnGrid( Generator, Sampler, Grid, AVoxel, BVoxel, Start, Goal, bOutStartSnapped, bOutGoalSnapped, OutNumRouteRetries, Settings.MaxRouteRetries, bPlayerFitRestricted ? &PlayerFitMask : nullptr, bPlayerFitRestricted ? &PlayerFitSupportHeights : nullptr, bPlayerFitRestricted ? &Settings : nullptr, bPlayerFitRestricted ? EPlayerTraversalPolicy::WalkOnly : EPlayerTraversalPolicy::WalkAndClimb); if (OutDiagnostics != nullptr) { PopulateConnectivityDiagnostics( Grid, Start, Goal, Result, bOutStartSnapped, bOutGoalSnapped, OutNumRouteRetries, *OutDiagnostics, bPlayerFitRestricted ? &PlayerFitMask : nullptr, nullptr, nullptr, bPlayerFitRestricted ? &PlayerFitSupportHeights : nullptr, bPlayerFitRestricted ? &Settings : nullptr, bPlayerFitRestricted ? EPlayerTraversalPolicy::WalkOnly : EPlayerTraversalPolicy::WalkAndClimb); if (bPlayerFitRestricted && (Result == EVoxelConnectivityResult::StartCellNotPlayerFit || Result == EVoxelConnectivityResult::GoalCellNotPlayerFit)) { OutDiagnostics->RefusalReason = Result == EVoxelConnectivityResult::StartCellNotPlayerFit ? TEXT("arrival endpoint is not a player-fitting floor cell") : TEXT("departure endpoint is not a player-fitting floor cell"); } } return Result; } int32 GetSixNeighbour(const FSampleGrid& Grid, int32 Cell, int32 Direction) { int32 X = 0; int32 Y = 0; int32 Z = 0; DecodeIndex(Grid, Cell, X, Y, Z); switch (Direction) { case 0: return X + 1 < Grid.NumX ? Grid.Index(X + 1, Y, Z) : INDEX_NONE; case 1: return X > 0 ? Grid.Index(X - 1, Y, Z) : INDEX_NONE; case 2: return Y + 1 < Grid.NumY ? Grid.Index(X, Y + 1, Z) : INDEX_NONE; case 3: return Y > 0 ? Grid.Index(X, Y - 1, Z) : INDEX_NONE; case 4: return Z + 1 < Grid.NumZ ? Grid.Index(X, Y, Z + 1) : INDEX_NONE; case 5: return Z > 0 ? Grid.Index(X, Y, Z - 1) : INDEX_NONE; default: return INDEX_NONE; } } int32 CountPlayerFitNeighbours( const FSampleGrid& Grid, const TArray& PlayerFitMask, const TArray* PlayerFitSupportHeights, const FVoxelStrateMeasureSettings* Settings, int32 Cell, EPlayerTraversalPolicy Policy = EPlayerTraversalPolicy::WalkAndClimb) { TArray> Neighbours; AppendPlayerTraversalNeighbours( Grid, PlayerFitMask, PlayerFitSupportHeights, Settings, Cell, Neighbours, Policy); int32 Count = 0; for (const FPlayerTraversalNeighbour& Neighbour : Neighbours) { ++Count; } return Count; } int32 LocalAirSpanVoxels( const FSampleGrid& Grid, int32 Cell, int32 Axis, int32 MaxRelevantSpanVoxels) { if (Cell < 0 || Cell >= Grid.CellCount || Grid.Air[Cell] == 0u) { return 0; } int32 CentreX = 0; int32 CentreY = 0; int32 CentreZ = 0; DecodeIndex(Grid, Cell, CentreX, CentreY, CentreZ); int32 Span = 1; bool bTouchesWindowBoundary = false; for (const int32 Sign : { -1, 1 }) { int32 Offset = 1; for (;; ++Offset) { if (Span >= MaxRelevantSpanVoxels) { return Span; } const int32 X = Axis == 0 ? CentreX + Sign * Offset : CentreX; const int32 Y = Axis == 1 ? CentreY + Sign * Offset : CentreY; const int32 Z = CentreZ; if (X < 0 || X >= Grid.NumX || Y < 0 || Y >= Grid.NumY) { bTouchesWindowBoundary = true; break; } if (Grid.Air[Grid.Index(X, Y, Z)] == 0u) { break; } ++Span; } } // A window edge is a lower bound, not a solid wall. Do not label a corridor narrow merely // because the fitted diagnostic box clipped the air span before reaching rock. return bTouchesWindowBoundary ? MAX_int32 : Span * Grid.SampleStep; } float LocalWidestHorizontalAirSpanVoxels( const FSampleGrid& Grid, int32 Cell, float NarrowGapThresholdVoxels) { const int32 MaxRelevantSpanVoxels = FMath::Max( 1, FMath::CeilToInt(NarrowGapThresholdVoxels)); return static_cast(FMath::Max( LocalAirSpanVoxels(Grid, Cell, 0, MaxRelevantSpanVoxels), LocalAirSpanVoxels(Grid, Cell, 1, MaxRelevantSpanVoxels))); } void WalkPlayerFitGraph( const FSampleGrid& Grid, const TArray& PlayerFitMask, const TArray* PlayerFitSupportHeights, const FVoxelStrateMeasureSettings* TraversalSettings, EPlayerTraversalPolicy TraversalPolicy, int32 Start, int32 Goal, float NarrowGapThresholdVoxels, FVoxelPlayerFitWalkReport& InOutReport, int32& OutFinalCell, int32& OutLastReachedCell) { OutFinalCell = INDEX_NONE; OutLastReachedCell = INDEX_NONE; if (!IsEligiblePlayerFitCell(Grid, PlayerFitMask, Start) || !IsEligiblePlayerFitCell(Grid, PlayerFitMask, Goal)) { return; } OutFinalCell = Start; OutLastReachedCell = Start; TArray Visited; Visited.Init(0u, Grid.CellCount); TArray NarrowCell; NarrowCell.Init(255u, Grid.CellCount); TArray Stack; TArray NextDirection; // Reserve the full bounded capacity up front. TArray growth slack is otherwise allowed // to exceed the walker's stated memory budget while the DFS discovers a large component. Stack.Reserve(Grid.CellCount); NextDirection.Reserve(Grid.CellCount); Stack.Add(Start); NextDirection.Add(0); Visited[Start] = 1u; auto RecordTraversal = [&](int32 From, int32 To) { ++InOutReport.AgentGraphTraversals; bool bClimb = false; if (PlayerFitSupportHeights != nullptr && TraversalSettings != nullptr && IsPlayerTraversalEdge( Grid, PlayerFitMask, PlayerFitSupportHeights, TraversalSettings, From, To, bClimb, TraversalPolicy) && bClimb) { ++InOutReport.ClimbTraversals; } auto IsNarrowCell = [&](int32 Cell) { if (NarrowCell[Cell] == 255u) { NarrowCell[Cell] = LocalWidestHorizontalAirSpanVoxels( Grid, Cell, NarrowGapThresholdVoxels) < NarrowGapThresholdVoxels ? 1u : 0u; } return NarrowCell[Cell] != 0u; }; const bool bNarrow = IsNarrowCell(From) || IsNarrowCell(To); if (bNarrow) { ++InOutReport.NarrowGapTraversals; } }; while (Stack.Num() > 0) { const int32 Current = Stack.Last(); OutFinalCell = Current; if (Current == Goal) { InOutReport.bCanReachDeparture = true; break; } TArray> Neighbours; AppendPlayerTraversalNeighbours( Grid, PlayerFitMask, PlayerFitSupportHeights, TraversalSettings, Current, Neighbours, TraversalPolicy); bool bAdvanced = false; while (NextDirection.Last() < Neighbours.Num()) { const int32 Direction = NextDirection.Last()++; const int32 Neighbour = Neighbours[Direction].Cell; if (!IsEligiblePlayerFitCell(Grid, PlayerFitMask, Neighbour) || Visited[Neighbour] != 0u) { continue; } Visited[Neighbour] = 1u; RecordTraversal(Current, Neighbour); Stack.Add(Neighbour); NextDirection.Add(0); OutLastReachedCell = Neighbour; bAdvanced = true; break; } if (bAdvanced) { continue; } // A degree-one pose is an actual topological dead end. A higher-degree pose with all // branches already visited is merely a DFS backtrack through a loop/junction. if (Current != Start && Current != Goal && CountPlayerFitNeighbours( Grid, PlayerFitMask, PlayerFitSupportHeights, TraversalSettings, Current, TraversalPolicy) <= 1) { ++InOutReport.DeadEndsEncountered; } Stack.Pop(); NextDirection.Pop(); if (Stack.Num() > 0) { RecordTraversal(Current, Stack.Last()); } } const float DistanceMeters = static_cast(InOutReport.AgentGraphTraversals) * FVoxelPlayerCapsuleConstants::VoxelSizeMeters; InOutReport.DistanceTravelledMeters = DistanceMeters; InOutReport.NarrowGapFraction = InOutReport.AgentGraphTraversals > 0 ? static_cast(static_cast(InOutReport.NarrowGapTraversals) / static_cast(InOutReport.AgentGraphTraversals)) : 0.0f; InOutReport.NarrowGapEventsPer100m = DistanceMeters > KINDA_SMALL_NUMBER ? static_cast(static_cast(InOutReport.NarrowGapTraversals) * 100.0 / static_cast(DistanceMeters)) : 0.0f; InOutReport.DeadEndsPer100m = DistanceMeters > KINDA_SMALL_NUMBER ? static_cast(static_cast(InOutReport.DeadEndsEncountered) * 100.0 / static_cast(DistanceMeters)) : 0.0f; InOutReport.DeadEndsEncountered = FMath::Max( InOutReport.DeadEndsEncountered, 0); } } FVoxelStrateMetrics VF_MeasureStrate( const UVoxelGenerator& Generator, const UVoxelStrateManager& Manager, int32 StrateIndex, const FVoxelStrateMeasureSettings& Settings, FVoxelStrateSampleGrid* OutSampleGrid) { FVoxelStrateMetrics Result; if (OutSampleGrid != nullptr) { *OutSampleGrid = FVoxelStrateSampleGrid(); } VoxelStrateMeasurePrivate::FSampleGrid Grid; if (!VoxelStrateMeasurePrivate::BuildSampleGrid( &Generator, &Manager, nullptr, StrateIndex, 0, 0, 0.0f, false, Settings, OutSampleGrid != nullptr || Settings.SampleStep == 1, Grid, Result.RefusalReason)) { return Result; } Result.ResolvedMarginVoxels = Grid.ResolvedMarginVoxels; Result.SampledMinZ = Grid.SampledMinZ; Result.SampledMaxZ = Grid.SampledMaxZ; Result.SampledNumX = Grid.NumX; Result.SampledNumY = Grid.NumY; Result.SampledNumZ = Grid.NumZ; Result.SampledMinX = Grid.MinX; Result.SampledMaxX = Grid.MaxX; Result.SampledMinY = Grid.MinY; Result.SampledMaxY = Grid.MaxY; TArray PlayerFitMask; VoxelStrateMeasurePrivate::BuildMetricsFromGrid( Grid, Settings, Result, OutSampleGrid != nullptr ? &PlayerFitMask : nullptr); Result.bValid = Result.NumSampled > 0; if (!Result.bValid) { Result.RefusalReason = TEXT("The measurement grid was empty."); } if (OutSampleGrid != nullptr && Result.bValid) { VoxelStrateMeasurePrivate::ExportSampleGrid(Grid, *OutSampleGrid); OutSampleGrid->PlayerFitMask = MoveTemp(PlayerFitMask); OutSampleGrid->PlayerFitCellCount = Result.NumPlayerFitCells; } return Result; } FVoxelStrateMetrics VF_MeasureStrateWithSampler( const IVoxelStrateDensitySampler& Sampler, int32 StrateBottomWorldZ, int32 StrateTopWorldZ, float BoundarySealThickness, const FVoxelStrateMeasureSettings& Settings, FVoxelStrateSampleGrid* OutSampleGrid) { FVoxelStrateMetrics Result; if (OutSampleGrid != nullptr) { *OutSampleGrid = FVoxelStrateSampleGrid(); } VoxelStrateMeasurePrivate::FSampleGrid Grid; if (!VoxelStrateMeasurePrivate::BuildSampleGrid( nullptr, nullptr, &Sampler, INDEX_NONE, StrateBottomWorldZ, StrateTopWorldZ, BoundarySealThickness, true, Settings, OutSampleGrid != nullptr || Settings.SampleStep == 1, Grid, Result.RefusalReason)) { return Result; } Result.ResolvedMarginVoxels = Grid.ResolvedMarginVoxels; Result.SampledMinZ = Grid.SampledMinZ; Result.SampledMaxZ = Grid.SampledMaxZ; Result.SampledNumX = Grid.NumX; Result.SampledNumY = Grid.NumY; Result.SampledNumZ = Grid.NumZ; Result.SampledMinX = Grid.MinX; Result.SampledMaxX = Grid.MaxX; Result.SampledMinY = Grid.MinY; Result.SampledMaxY = Grid.MaxY; TArray PlayerFitMask; VoxelStrateMeasurePrivate::BuildMetricsFromGrid( Grid, Settings, Result, OutSampleGrid != nullptr ? &PlayerFitMask : nullptr); Result.bValid = Result.NumSampled > 0; if (!Result.bValid) { Result.RefusalReason = TEXT("The measurement grid was empty."); } if (OutSampleGrid != nullptr && Result.bValid) { VoxelStrateMeasurePrivate::ExportSampleGrid(Grid, *OutSampleGrid); OutSampleGrid->PlayerFitMask = MoveTemp(PlayerFitMask); OutSampleGrid->PlayerFitCellCount = Result.NumPlayerFitCells; } return Result; } EVoxelConnectivityResult VF_AreConnected( const UVoxelGenerator& Generator, const UVoxelStrateManager& Manager, int32 StrateIndex, const FVector& AVoxel, const FVector& BVoxel, const FVoxelStrateMeasureSettings& Settings, bool& bOutStartSnapped, bool& bOutGoalSnapped) { int32 NumRouteRetries = 0; return VoxelStrateMeasurePrivate::QueryConnectivity( &Generator, &Manager, nullptr, StrateIndex, 0, 0, 0.0f, false, AVoxel, BVoxel, Settings, bOutStartSnapped, bOutGoalSnapped, NumRouteRetries, nullptr); } EVoxelConnectivityResult VF_AreConnected( const UVoxelGenerator& Generator, const UVoxelStrateManager& Manager, int32 StrateIndex, const FVector& AVoxel, const FVector& BVoxel, const FVoxelStrateMeasureSettings& Settings, bool& bOutStartSnapped, bool& bOutGoalSnapped, int32& OutNumRouteRetries) { return VoxelStrateMeasurePrivate::QueryConnectivity( &Generator, &Manager, nullptr, StrateIndex, 0, 0, 0.0f, false, AVoxel, BVoxel, Settings, bOutStartSnapped, bOutGoalSnapped, OutNumRouteRetries, nullptr); } EVoxelConnectivityResult VF_AreConnected( const UVoxelGenerator& Generator, const UVoxelStrateManager& Manager, int32 StrateIndex, const FVector& AVoxel, const FVector& BVoxel, const FVoxelStrateMeasureSettings& Settings, bool& bOutStartSnapped, bool& bOutGoalSnapped, FVoxelConnectivityDiagnostics& OutDiagnostics) { int32 NumRouteRetries = 0; return VoxelStrateMeasurePrivate::QueryConnectivity( &Generator, &Manager, nullptr, StrateIndex, 0, 0, 0.0f, false, AVoxel, BVoxel, Settings, bOutStartSnapped, bOutGoalSnapped, NumRouteRetries, &OutDiagnostics); } FVoxelConnectivityDiagnostics VF_DiagnoseConnectivity( const UVoxelGenerator& Generator, const UVoxelStrateManager& Manager, int32 StrateIndex, const FVector& AVoxel, const FVector& BVoxel, const FVoxelStrateMeasureSettings& Settings) { FVoxelConnectivityDiagnostics Result; int32 NumRouteRetries = 0; VoxelStrateMeasurePrivate::QueryConnectivity( &Generator, &Manager, nullptr, StrateIndex, 0, 0, 0.0f, false, AVoxel, BVoxel, Settings, Result.bStartSnapped, Result.bGoalSnapped, NumRouteRetries, &Result); return Result; } EVoxelConnectivityResult VF_AreConnectedWithSampler( const IVoxelStrateDensitySampler& Sampler, int32 StrateBottomWorldZ, int32 StrateTopWorldZ, float BoundarySealThickness, const FVector& AVoxel, const FVector& BVoxel, const FVoxelStrateMeasureSettings& Settings, bool& bOutStartSnapped, bool& bOutGoalSnapped, FVoxelConnectivityDiagnostics* OutDiagnostics) { int32 NumRouteRetries = 0; return VoxelStrateMeasurePrivate::QueryConnectivity( nullptr, nullptr, &Sampler, INDEX_NONE, StrateBottomWorldZ, StrateTopWorldZ, BoundarySealThickness, true, AVoxel, BVoxel, Settings, bOutStartSnapped, bOutGoalSnapped, NumRouteRetries, OutDiagnostics); } FVoxelConnectivityDiagnostics VF_DiagnoseConnectivityWithSampler( const IVoxelStrateDensitySampler& Sampler, int32 StrateBottomWorldZ, int32 StrateTopWorldZ, float BoundarySealThickness, const FVector& AVoxel, const FVector& BVoxel, const FVoxelStrateMeasureSettings& Settings) { FVoxelConnectivityDiagnostics Result; VF_AreConnectedWithSampler(Sampler, StrateBottomWorldZ, StrateTopWorldZ, BoundarySealThickness, AVoxel, BVoxel, Settings, Result.bStartSnapped, Result.bGoalSnapped, &Result); return Result; } EVoxelConnectivityResult VF_ArePlayerFitConnectedWithSampler( const IVoxelStrateDensitySampler& Sampler, int32 StrateBottomWorldZ, int32 StrateTopWorldZ, float BoundarySealThickness, const FVector& AVoxel, const FVector& BVoxel, const FVoxelStrateMeasureSettings& Settings, bool& bOutStartSnapped, bool& bOutGoalSnapped, FVoxelConnectivityDiagnostics* OutDiagnostics) { int32 NumRouteRetries = 0; return VoxelStrateMeasurePrivate::QueryConnectivity( nullptr, nullptr, &Sampler, INDEX_NONE, StrateBottomWorldZ, StrateTopWorldZ, BoundarySealThickness, true, AVoxel, BVoxel, Settings, bOutStartSnapped, bOutGoalSnapped, NumRouteRetries, OutDiagnostics, true, nullptr); } FVoxelConnectivityDiagnostics VF_DiagnosePlayerFitConnectivityWithSampler( const IVoxelStrateDensitySampler& Sampler, int32 StrateBottomWorldZ, int32 StrateTopWorldZ, float BoundarySealThickness, const FVector& AVoxel, const FVector& BVoxel, const FVoxelStrateMeasureSettings& Settings, FVoxelStrateMetrics* OutPlayerMetrics) { FVoxelConnectivityDiagnostics Result; int32 NumRouteRetries = 0; VoxelStrateMeasurePrivate::QueryConnectivity( nullptr, nullptr, &Sampler, INDEX_NONE, StrateBottomWorldZ, StrateTopWorldZ, BoundarySealThickness, true, AVoxel, BVoxel, Settings, Result.bStartSnapped, Result.bGoalSnapped, NumRouteRetries, &Result, true, OutPlayerMetrics); return Result; } bool VF_MeasurePlayerFitWalkWithSampler( const IVoxelStrateDensitySampler& Sampler, int32 StrateBottomWorldZ, int32 StrateTopWorldZ, float BoundarySealThickness, const FVector& AVoxel, const FVector& BVoxel, const FVoxelStrateMeasureSettings& Settings, FVoxelPlayerFitWalkReport& OutReport, const TArray* ComponentProbePoints, FVoxelStrateSampleGrid* OutSampleGrid) { using namespace VoxelStrateMeasurePrivate; OutReport = FVoxelPlayerFitWalkReport(); if (OutSampleGrid != nullptr) { *OutSampleGrid = FVoxelStrateSampleGrid(); } OutReport.CapsuleRadiusMeters = Settings.PlayerCapsuleRadiusVoxels * FVoxelPlayerCapsuleConstants::VoxelSizeMeters; OutReport.CapsuleWidthMeters = 2.0f * OutReport.CapsuleRadiusMeters; OutReport.CapsuleHeightMeters = 2.0f * Settings.PlayerCapsuleHalfHeightVoxels * FVoxelPlayerCapsuleConstants::VoxelSizeMeters; OutReport.bClimbingEnabled = Settings.bPlayerClimbingEnabled; OutReport.MaxClimbHeightMeters = Settings.PlayerMaxClimbHeightMeters; OutReport.NarrowGapThresholdMeters = 1.5f * OutReport.CapsuleWidthMeters; OutReport.NarrowGapDefinition = TEXT( "widest axis-aligned horizontal air span at each traversed fit-graph cell"); const bool bFiniteEndpoints = FMath::IsFinite(AVoxel.X) && FMath::IsFinite(AVoxel.Y) && FMath::IsFinite(AVoxel.Z) && FMath::IsFinite(BVoxel.X) && FMath::IsFinite(BVoxel.Y) && FMath::IsFinite(BVoxel.Z); if (!bFiniteEndpoints) { OutReport.RefusalReason = TEXT("Arrival and departure points must be finite."); return false; } if (Settings.MaxRouteRetries < 0) { OutReport.RefusalReason = TEXT("MaxRouteRetries cannot be negative."); return false; } if (Settings.SampleStep != 1) { OutReport.RefusalReason = TEXT( "Player-fit walk refused: exact capsule fit requires SampleStep=1."); return false; } FSampleGrid Grid; FString RefusalReason; if (!BuildSampleGrid( nullptr, nullptr, &Sampler, INDEX_NONE, StrateBottomWorldZ, StrateTopWorldZ, BoundarySealThickness, true, Settings, true, Grid, RefusalReason)) { OutReport.RefusalReason = RefusalReason; return false; } TArray PlayerFitMask; TArray PlayerFitSupportHeights; int64 NumPlayerFitCells = 0; if (!BuildPlayerFitMask( Grid, Settings, PlayerFitMask, NumPlayerFitCells, RefusalReason, &PlayerFitSupportHeights)) { OutReport.RefusalReason = RefusalReason; return false; } int32 Start = INDEX_NONE; int32 Goal = INDEX_NONE; int32 NumRouteRetries = 0; const EVoxelConnectivityResult Result = EvaluateConnectivityOnGrid( nullptr, &Sampler, Grid, AVoxel, BVoxel, Start, Goal, OutReport.bStartSnapped, OutReport.bGoalSnapped, NumRouteRetries, Settings.MaxRouteRetries, &PlayerFitMask, &PlayerFitSupportHeights, &Settings, EPlayerTraversalPolicy::WalkOnly); FVoxelConnectivityDiagnostics Diagnostics; TArray Components; int32 NumComponents = 0; PopulateConnectivityDiagnostics( Grid, Start, Goal, Result, OutReport.bStartSnapped, OutReport.bGoalSnapped, NumRouteRetries, Diagnostics, &PlayerFitMask, &Components, &NumComponents, &PlayerFitSupportHeights, &Settings, EPlayerTraversalPolicy::WalkOnly); OutReport.bValid = true; OutReport.Result = Result; OutReport.NumRouteRetries = NumRouteRetries; OutReport.SampledNumX = Grid.NumX; OutReport.SampledNumY = Grid.NumY; OutReport.SampledNumZ = Grid.NumZ; OutReport.SampledMinZ = Grid.SampledMinZ; OutReport.SampledMaxZ = Grid.SampledMaxZ; OutReport.SampledMinX = Grid.MinX; OutReport.SampledMaxX = Grid.MaxX; OutReport.SampledMinY = Grid.MinY; OutReport.SampledMaxY = Grid.MaxY; OutReport.PlayerFitVolumeCells = NumPlayerFitCells; OutReport.WalkOnlyReachablePlayerFitCells = FMath::Max( Diagnostics.StartComponentCells, 0); OutReport.ReachablePlayerFitCells = OutReport.WalkOnlyReachablePlayerFitCells; OutReport.bWalkOnlyCanReachDeparture = Result == EVoxelConnectivityResult::Connected; OutReport.bCanReachDeparture = OutReport.bWalkOnlyCanReachDeparture; BuildClimbTierFromWalkComponents( Grid, PlayerFitMask, PlayerFitSupportHeights, Settings, Components, NumComponents, Start, Goal, NumPlayerFitCells, OutReport.WalkOnlyReachablePlayerFitCells, OutReport.ClimbReachablePlayerFitCells, OutReport.MineRequiredPlayerFitCells, OutReport.bClimbCanReachDeparture, OutReport.ClimbEdges); OutReport.ReachablePlayerFitFraction = OutReport.PlayerFitVolumeCells > 0 ? static_cast(static_cast(OutReport.ReachablePlayerFitCells) / static_cast(OutReport.PlayerFitVolumeCells)) : 0.0f; OutReport.ClimbReachablePlayerFitFraction = OutReport.PlayerFitVolumeCells > 0 ? static_cast(static_cast(OutReport.ClimbReachablePlayerFitCells) / static_cast(OutReport.PlayerFitVolumeCells)) : 0.0f; OutReport.PlayerFitComponents = NumComponents; OutReport.LargestPlayerFitComponentCells = Diagnostics.LargestComponentCells; OutReport.ArrivalComponentCells = Diagnostics.StartComponentCells; OutReport.DepartureComponentCells = Diagnostics.GoalComponentCells; OutReport.MouthComponentGapVoxels = Diagnostics.StartToGoalComponentDistanceCells; OutReport.StraightLineMeters = FVector::Dist(AVoxel, BVoxel) * FVoxelPlayerCapsuleConstants::VoxelSizeMeters; int32 AgentFinalCell = INDEX_NONE; int32 LastReachedCell = INDEX_NONE; if (Start >= 0 && Goal >= 0) { const float NarrowGapThresholdVoxels = OutReport.NarrowGapThresholdMeters / FVoxelPlayerCapsuleConstants::VoxelSizeMeters; WalkPlayerFitGraph( Grid, PlayerFitMask, &PlayerFitSupportHeights, &Settings, EPlayerTraversalPolicy::WalkOnly, Start, Goal, NarrowGapThresholdVoxels, OutReport, AgentFinalCell, LastReachedCell); } auto RecordCellPosition = [&Grid]( int32 Cell, bool& bOutHasPosition, FVector& OutPosition) { if (Cell >= 0 && Cell < Grid.CellCount) { int32 X = 0; int32 Y = 0; int32 Z = 0; DecodeIndex(Grid, Cell, X, Y, Z); bOutHasPosition = true; OutPosition = Grid.CellCenter(X, Y, Z); } }; RecordCellPosition( AgentFinalCell, OutReport.bHasAgentFinalPosition, OutReport.AgentFinalVoxels); RecordCellPosition( LastReachedCell, OutReport.bHasLastReachedPosition, OutReport.LastReachedVoxels); const int32 StartComponent = Start >= 0 && Components.IsValidIndex(Start) ? Components[Start] : INDEX_NONE; const int32 GoalComponent = Goal >= 0 && Components.IsValidIndex(Goal) ? Components[Goal] : INDEX_NONE; if (ComponentProbePoints != nullptr) { OutReport.ComponentProbePlayerFit.Init(0u, ComponentProbePoints->Num()); OutReport.ComponentProbeInStartComponent.Init(0u, ComponentProbePoints->Num()); for (int32 ProbeIndex = 0; ProbeIndex < ComponentProbePoints->Num(); ++ProbeIndex) { int32 ProbeCell = INDEX_NONE; const FVector& ProbePoint = (*ComponentProbePoints)[ProbeIndex]; if (!FindCellForPoint(Grid, ProbePoint, ProbeCell) || !IsEligiblePlayerFitCell(Grid, PlayerFitMask, ProbeCell)) { continue; } OutReport.ComponentProbePlayerFit[ProbeIndex] = 1u; if (StartComponent >= 0 && Components.IsValidIndex(ProbeCell) && Components[ProbeCell] == StartComponent) { OutReport.ComponentProbeInStartComponent[ProbeIndex] = 1u; } } } if (StartComponent >= 0) { float BestOriginDistanceSquared = FLT_MAX; for (int32 Cell = 0; Cell < Grid.CellCount; ++Cell) { if (!Components.IsValidIndex(Cell) || Components[Cell] != StartComponent) { continue; } int32 X = 0; int32 Y = 0; int32 Z = 0; DecodeIndex(Grid, Cell, X, Y, Z); const FVector Position = Grid.CellCenter(X, Y, Z); BestOriginDistanceSquared = FMath::Min( BestOriginDistanceSquared, Position.X * Position.X + Position.Y * Position.Y); } if (BestOriginDistanceSquared < FLT_MAX) { OutReport.ReachableSetToOriginColumnVoxels = FMath::Sqrt( BestOriginDistanceSquared); } } const bool bOriginPointInWindow = 0.0f >= Grid.MinX && 0.0f < Grid.MaxX && 0.0f >= Grid.MinY && 0.0f < Grid.MaxY; if (bOriginPointInWindow) { const int32 OriginX = FMath::Clamp( FMath::FloorToInt((0.0f - Grid.MinX) / static_cast(Grid.SampleStep)), 0, Grid.NumX - 1); const int32 OriginY = FMath::Clamp( FMath::FloorToInt((0.0f - Grid.MinY) / static_cast(Grid.SampleStep)), 0, Grid.NumY - 1); OutReport.bOriginColumnInSampledWindow = true; for (int32 Z = 0; Z < Grid.NumZ; ++Z) { const int32 Cell = Grid.Index(OriginX, OriginY, Z); if (PlayerFitMask[Cell] == 0u) { continue; } ++OutReport.OriginColumnPlayerFitCells; OutReport.bOriginColumnHasPlayerFit = true; if (Components.IsValidIndex(Cell) && Components[Cell] == StartComponent) { OutReport.bOriginColumnReachable = true; } } } if (AgentFinalCell >= 0 && AgentFinalCell < Grid.CellCount && GoalComponent >= 0) { const FVector AgentPosition = OutReport.AgentFinalVoxels; float BestTargetDistanceSquared = FLT_MAX; int32 BestTargetCell = INDEX_NONE; for (int32 Cell = 0; Cell < Grid.CellCount; ++Cell) { if (!Components.IsValidIndex(Cell) || Components[Cell] != GoalComponent) { continue; } int32 X = 0; int32 Y = 0; int32 Z = 0; DecodeIndex(Grid, Cell, X, Y, Z); const float DistanceSquared = FVector::DistSquared( AgentPosition, Grid.CellCenter(X, Y, Z)); if (DistanceSquared < BestTargetDistanceSquared || (DistanceSquared == BestTargetDistanceSquared && (BestTargetCell == INDEX_NONE || Cell < BestTargetCell))) { BestTargetDistanceSquared = DistanceSquared; BestTargetCell = Cell; } } if (BestTargetCell >= 0) { RecordCellPosition( BestTargetCell, OutReport.bHasTargetComponentNearestCell, OutReport.TargetComponentNearestVoxels); OutReport.AgentToTargetComponentGapVoxels = FMath::Sqrt( BestTargetDistanceSquared); } } OutReport.bCanReachDeparture = Result == EVoxelConnectivityResult::Connected; OutReport.Tortuosity = OutReport.StraightLineMeters > KINDA_SMALL_NUMBER ? OutReport.DistanceTravelledMeters / OutReport.StraightLineMeters : 0.0f; OutReport.DeadEndsEncountered = FMath::Max( OutReport.DeadEndsEncountered, 0); OutReport.NarrowGapEventsPer100m = OutReport.DistanceTravelledMeters > KINDA_SMALL_NUMBER ? static_cast(static_cast(OutReport.NarrowGapTraversals) * 100.0 / static_cast(OutReport.DistanceTravelledMeters)) : 0.0f; OutReport.DeadEndsPer100m = OutReport.DistanceTravelledMeters > KINDA_SMALL_NUMBER ? static_cast(static_cast(OutReport.DeadEndsEncountered) * 100.0 / static_cast(OutReport.DistanceTravelledMeters)) : 0.0f; if (Result == EVoxelConnectivityResult::StartCellNotPlayerFit) { OutReport.RefusalReason = TEXT("arrival endpoint is not a player-fitting floor cell"); } else if (Result == EVoxelConnectivityResult::GoalCellNotPlayerFit) { OutReport.RefusalReason = TEXT("departure endpoint is not a player-fitting floor cell"); } else if (Result == EVoxelConnectivityResult::OutOfWindow) { OutReport.RefusalReason = TEXT("arrival or departure point is outside the fitted walk window"); } else { OutReport.RefusalReason = Diagnostics.RefusalReason; } if (OutSampleGrid != nullptr) { ExportSampleGrid(Grid, *OutSampleGrid); OutSampleGrid->PlayerFitMask = MoveTemp(PlayerFitMask); OutSampleGrid->PlayerFitCellCount = NumPlayerFitCells; } return true; } #endif // WITH_EDITOR — measurement never ships