diff --git a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp index 96c83a8..45f2383 100644 --- a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp +++ b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -33,43 +33,8 @@ #include "VoxelStrateMeasure.h" #include "VoxelTypes.h" // Pour VOXEL_NOISE_SCALE, SmoothStep01 #include "VoxelStrateTypes.h" -#include "HAL/PlatformTime.h" - namespace { - struct FTask4BuildProbeScope - { - FChunkSDFCache& Cache; - const float MinX; - const float MinY; - const float MaxX; - const float MaxY; - const uint64 StartCycles; - const bool bEnabled; - - FTask4BuildProbeScope(FChunkSDFCache& InCache, float InMinX, float InMinY, - float InMaxX, float InMaxY) - : Cache(InCache), MinX(InMinX), MinY(InMinY), MaxX(InMaxX), MaxY(InMaxY) - , StartCycles(FPlatformTime::Cycles64()) - , bEnabled((InMaxX - InMinX) > 120.0f && (InMaxX - InMinX) < 220.0f - && (InMaxY - InMinY) > 120.0f && (InMaxY - InMinY) < 220.0f - && InMinX > -512.0f && InMaxX < 512.0f - && InMinY > -512.0f && InMaxY < 512.0f) - {} - - ~FTask4BuildProbeScope() - { - if (bEnabled) - { - UE_LOG(LogTemp, Warning, - TEXT("[Task4BuildProbe] search=(%.0f,%.0f)-(%.0f,%.0f) rooms=%d joins=%d tunnels=%d pits=%d chimneys=%d ms=%.3f"), - MinX, MinY, MaxX, MaxY, Cache.Rooms.Num(), Cache.RoomFloorJoins.Num(), - Cache.Tunnels.Num(), Cache.Pits.Num(), Cache.Chimneys.Num(), - FPlatformTime::ToMilliseconds64( - FPlatformTime::Cycles64() - StartCycles)); - } - } - }; } #include "VoxelNoise.h" // Pure FBM used by the slab landing query #include "VoxelPassageGeometry.h" @@ -109,6 +74,40 @@ namespace } +void FChunkSDFCache::Reset() +{ + // Keep the allocations: classifier windows can alternate between empty and + // populated regions on adjacent requests. + Rooms.Reset(); + RoomFloorJoins.Reset(); + Tunnels.Reset(); + Pits.Reset(); + Chimneys.Reset(); + Columns.Reset(); + SupportColumnMinX = 0; + SupportColumnMinY = 0; + SupportColumnCellsX = 0; + SupportColumnCellsY = 0; + SupportColumnEntries.Reset(); + SupportColumns.Reset(); +} + +void FChunkSDFCache::Release() +{ + Rooms.Empty(); + RoomFloorJoins.Empty(); + Tunnels.Empty(); + Pits.Empty(); + Chimneys.Empty(); + Columns.Empty(); + SupportColumnEntries.Empty(); + SupportColumns.Empty(); + SupportColumnMinX = 0; + SupportColumnMinY = 0; + SupportColumnCellsX = 0; + SupportColumnCellsY = 0; +} + SIZE_T FChunkSDFCache::GetAllocatedSize() const { return static_cast(GetAllocatedSizeBreakdown().TotalBytes()); @@ -166,6 +165,473 @@ struct FBuildRoom bool bPlayerFitAttempted = false; }; +struct FVFRoomGraphReach +{ + float DirectRoomReach = 0.0f; + float FloorReliefEnvelope = 0.0f; + float BlendEnvelope = 0.0f; + float MaxTunnelLength = 0.0f; + float PairAabbReach = 0.0f; + float PairZReach = 0.0f; + float CollectMargin = 0.0f; +}; + +static float VF_RoomShapeReachUpperBound(float RadiusXY, float RadiusZ) +{ + const float RXY = FMath::Abs(RadiusXY); + const float RZ = FMath::Abs(RadiusZ); + const float Ellipsoid = FMath::Max(RXY, RZ); + const FVector BoxExtent( + RXY * 0.8f + RXY * 0.25f, + RXY * 0.8f + RXY * 0.25f, + RZ * 0.8f + RXY * 0.25f); + const float RoundedBox = BoxExtent.Size(); + const float Capsule = RXY * 0.7f + FMath::Min(RXY * 0.6f, RZ); + return FMath::Max3(Ellipsoid, RoundedBox, Capsule); +} + +static bool VF_ComputeRoomGraphReach( + const FStrateGenerationParams& Params, + FVFRoomGraphReach& OutReach) +{ + const float RelevantParams[] = { + Params.RoomDensity, + Params.RoomSpacing, + Params.MinRoomRadius, + Params.MaxRoomRadius, + Params.RoomHeightRatio, + Params.RoomFloorCutMin, + Params.RoomFloorCutMax, + Params.FloorReliefStrength, + Params.FloorReliefFrequency, + Params.OriginRoomRadius, + Params.TunnelMinRadius, + Params.TunnelMaxRadius, + Params.TunnelDensity, + Params.MaxTunnelLength, + Params.TunnelWarpStrength, + Params.TunnelHorizontalBias, + Params.TunnelEndpointZOffset, + Params.SDFBlendRadius, + Params.CaveWarpStrength, + Params.CaveWarpFrequency, + Params.VerticalScale, + Params.BoundarySealThickness, + Params.StrateTopWorldZ, + Params.StrateBottomWorldZ}; + for (const float Value : RelevantParams) + { + if (!FMath::IsFinite(Value)) + { + return false; + } + } + + if (Params.RoomSpacing <= 0.0f + || Params.RoomDensity < 0.0f + || Params.MinRoomRadius < 0.0f + || Params.MaxRoomRadius < 0.0f + || Params.RoomHeightRatio < 0.0f + || Params.OriginRoomRadius < 0.0f + || Params.TunnelMinRadius < 0.0f + || Params.TunnelMaxRadius < 0.0f + || Params.SDFBlendRadius < 0.0f + || FMath::Abs(Params.FloorReliefStrength) > 1000000.0f + || FMath::Abs(Params.CaveWarpStrength) > 1000000.0f + || FMath::Abs(Params.RoomFloorCutMin) > 1000000.0f + || FMath::Abs(Params.RoomFloorCutMax) > 1000000.0f + || FMath::Max(Params.MaxTunnelLength, 0.0f) > 1000000.0f) + { + return false; + } + + // The cache's room centres and the world-space tunnel chain have separate + // Z conventions when VerticalScale != 1. A proof that ignores that + // conversion could under-estimate the chain length used for its lateral + // wander. Let the normal cache path handle those authored variants. + if (Params.VerticalScale > 0.0f + && FMath::Abs(Params.VerticalScale - 1.0f) > 1.0e-6f) + { + return false; + } + + const float RoomRadiusEnvelope = FMath::Max( + FMath::Abs(Params.MinRoomRadius), + FMath::Abs(Params.MaxRoomRadius)); + const float AllRoomRadiusEnvelope = FMath::Max( + RoomRadiusEnvelope, FMath::Abs(Params.OriginRoomRadius)); + const float AllRoomHeightEnvelope = AllRoomRadiusEnvelope + * FMath::Abs(Params.RoomHeightRatio); + const float FloorReliefEnvelope = FMath::Abs(Params.FloorReliefStrength) + * VOXEL_NOISE_SCALE * 1.5f; + const float BlendEnvelope = FMath::Max(Params.SDFBlendRadius, 0.0f); + const float TunnelRadiusEnvelope = FMath::Max( + 0.5f, + FMath::Max( + FMath::Abs(Params.TunnelMinRadius), + FMath::Abs(Params.TunnelMaxRadius)) * 1.18f); + const float MaxTunnelLength = FMath::Max( + Params.MaxTunnelLength, 0.0f); + const float MaxInfluence = FMath::Max( + RoomRadiusEnvelope + FloorReliefEnvelope, + FMath::Abs(Params.TunnelWarpStrength) + TunnelRadiusEnvelope) + + BlendEnvelope * 3.0f; + const float DirectRoomReach = VF_RoomShapeReachUpperBound( + AllRoomRadiusEnvelope, AllRoomHeightEnvelope) + + FloorReliefEnvelope + BlendEnvelope * 3.0f; + + // A fitted tunnel mouth may move by one bounded cave-warp displacement in + // each XY axis. The chain then wanders by at most 25.5% of its authored + // length. Bound the endpoint's possible Z correction too, because that + // length controls the lateral wander even though the preflight is XY-only. + const float CaveWarpRaw = FMath::Abs(Params.CaveWarpStrength) + * VOXEL_NOISE_SCALE; + const float CaveWarpEnvelope = CaveWarpRaw * 1.5f; + const float EndpointXY = FMath::Sqrt(2.0f) + * CaveWarpEnvelope; + const float FloorCutEnvelope = FMath::Max( + 1.0f, + FMath::Max( + FMath::Abs(Params.RoomFloorCutMin), + FMath::Abs(Params.RoomFloorCutMax))); + const float ReliefSearch = static_cast(FMath::Clamp( + FMath::CeilToInt(FMath::Abs(Params.FloorReliefStrength) + * VOXEL_NOISE_SCALE + CaveWarpRaw) + 8, + 8, + 128)); + const float MaxStepHeight = FVoxelPlayerCapsuleConstants::MaxStepHeightMeters + / FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + const float EndpointVertical = AllRoomHeightEnvelope * FloorCutEnvelope + + FloorReliefEnvelope + CaveWarpEnvelope + ReliefSearch + + MaxStepHeight + TunnelRadiusEnvelope + 2.0f; + const float EndpointOffset = FMath::Sqrt( + EndpointXY * EndpointXY + + EndpointVertical * EndpointVertical); + const float TunnelLengthBound = MaxTunnelLength + + 2.0f * EndpointOffset; + const float WorldFloorPad = FMath::Max( + VoxelPassageGeometry::LandingFloorThicknessVoxels, + BlendEnvelope); + const float TunnelTubeReach = TunnelRadiusEnvelope + + WorldFloorPad + BlendEnvelope * 3.0f + + CaveWarpEnvelope + 2.0f; + const float PairAabbReach = EndpointXY + + TunnelLengthBound * 0.255f + + FMath::Max(MaxInfluence, TunnelTubeReach); + const float PairZReach = EndpointVertical + TunnelTubeReach; + const float CollectMargin = 2.0f * MaxTunnelLength + MaxInfluence; + + if (!FMath::IsFinite(DirectRoomReach) + || !FMath::IsFinite(PairAabbReach) + || !FMath::IsFinite(PairZReach) + || !FMath::IsFinite(CollectMargin) + || DirectRoomReach < 0.0f + || PairAabbReach < 0.0f + || PairZReach < 0.0f + || CollectMargin < 0.0f) + { + return false; + } + + OutReach.DirectRoomReach = DirectRoomReach; + OutReach.FloorReliefEnvelope = FloorReliefEnvelope; + OutReach.BlendEnvelope = BlendEnvelope; + OutReach.MaxTunnelLength = MaxTunnelLength; + OutReach.PairAabbReach = PairAabbReach; + OutReach.PairZReach = PairZReach; + OutReach.CollectMargin = CollectMargin; + return true; +} + +template +static bool VF_MayHaveRoomGraphFeature( + const TArray& Rooms, + float SearchMinX, float SearchMinY, + float SearchMaxX, float SearchMaxY, + const FStrateGenerationParams& Params, + uint32 StrateSeed, + const FVFRoomGraphReach& Reach, + bool bUseZ = false, + float SearchMinZ = 0.0f, + float SearchMaxZ = 0.0f) +{ + auto AabbDistanceSquared = [&]( + float X, float Y, float MinX, float MinY, + float MaxX, float MaxY) -> float + { + const float DX = X < MinX ? MinX - X + : (X > MaxX ? X - MaxX : 0.0f); + const float DY = Y < MinY ? MinY - Y + : (Y > MaxY ? Y - MaxY : 0.0f); + return DX * DX + DY * DY; + }; + + for (const FBuildRoom& Room : Rooms) + { + if (!FMath::IsFinite(Room.Center.X) + || !FMath::IsFinite(Room.Center.Y) + || !FMath::IsFinite(Room.Center.Z) + || !FMath::IsFinite(Room.RadiusXY) + || !FMath::IsFinite(Room.RadiusZ)) + { + return true; + } + const float RoomReach = VF_RoomShapeReachUpperBound( + Room.RadiusXY, Room.RadiusZ) + + Reach.FloorReliefEnvelope + Reach.BlendEnvelope * 3.0f; + if (!FMath::IsFinite(RoomReach) || RoomReach < 0.0f) + { + return true; + } + if (bUseZ + && (static_cast(Room.Center.Z) + RoomReach < SearchMinZ + || static_cast(Room.Center.Z) - RoomReach > SearchMaxZ)) + { + continue; + } + const float DistanceSquared = AabbDistanceSquared( + static_cast(Room.Center.X), + static_cast(Room.Center.Y), + SearchMinX - RoomReach, + SearchMinY - RoomReach, + SearchMaxX + RoomReach, + SearchMaxY + RoomReach); + if (!FMath::IsFinite(DistanceSquared)) + { + return true; + } + if (DistanceSquared <= KINDA_SMALL_NUMBER) + { + return true; + } + } + + if (Reach.MaxTunnelLength <= 0.0f || Rooms.Num() < 2) + { + return false; + } + + // A pair whose broad centerline envelope can reach the query is enough to + // keep the full graph path. This deliberately treats every geometrically + // eligible pair as potential; the exact backbone/random decision remains + // owned by BuildChunkCache. It keeps this preflight linear in the common + // feature-present case and avoids player-fit work in the feature-free case. + for (int32 First = 0; First < Rooms.Num(); ++First) + { + for (int32 Second = First + 1; Second < Rooms.Num(); ++Second) + { + const FVector& FirstCenter = Rooms[First].Center; + const FVector& SecondCenter = Rooms[Second].Center; + if (bUseZ + && (FMath::Max( + static_cast(FirstCenter.Z), + static_cast(SecondCenter.Z)) + + Reach.PairZReach < SearchMinZ + || FMath::Min( + static_cast(FirstCenter.Z), + static_cast(SecondCenter.Z)) + - Reach.PairZReach > SearchMaxZ)) + { + continue; + } + const float EuclideanDistance = static_cast( + FVector::Dist(FirstCenter, SecondCenter)); + if (!FMath::IsFinite(EuclideanDistance)) + { + return true; + } + if (EuclideanDistance > Reach.MaxTunnelLength) + { + continue; + } + const float PairMinX = FMath::Min( + static_cast(FirstCenter.X), + static_cast(SecondCenter.X)) + - Reach.PairAabbReach; + const float PairMinY = FMath::Min( + static_cast(FirstCenter.Y), + static_cast(SecondCenter.Y)) + - Reach.PairAabbReach; + const float PairMaxX = FMath::Max( + static_cast(FirstCenter.X), + static_cast(SecondCenter.X)) + + Reach.PairAabbReach; + const float PairMaxY = FMath::Max( + static_cast(FirstCenter.Y), + static_cast(SecondCenter.Y)) + + Reach.PairAabbReach; + if (!FMath::IsFinite(PairMinX) || !FMath::IsFinite(PairMinY) + || !FMath::IsFinite(PairMaxX) || !FMath::IsFinite(PairMaxY)) + { + return true; + } + if (PairMaxX >= SearchMinX && PairMaxY >= SearchMinY + && PairMinX <= SearchMaxX && PairMinY <= SearchMaxY) + { + return true; + } + } + } + return false; + +#if 0 + const float MaxTunnelLengthSquared = + Reach.MaxTunnelLength * Reach.MaxTunnelLength; + if (!FMath::IsFinite(MaxTunnelLengthSquared)) + { + return true; + } + + TArray> NearestNeighbor; + NearestNeighbor.SetNumUninitialized(Rooms.Num()); + const bool bFlowToOrigin = Params.bTunnelsFlowTowardOrigin; + auto OriginKeySquared = [&](int32 Index) -> float + { + const FVector& Center = Rooms[Index].Center; + return static_cast( + Center.X * Center.X + Center.Y * Center.Y); + }; + auto LinkMetric = [&](int32 First, int32 Second) -> float + { + const float Distance = static_cast( + FVector::Dist(Rooms[First].Center, Rooms[Second].Center)); + const float VerticalSeparation = FMath::Abs( + static_cast( + Rooms[First].Center.Z - Rooms[Second].Center.Z)); + return Distance + + VerticalSeparation * Params.TunnelHorizontalBias * 5.0f; + }; + auto PickNeighbor = [&](int32 Index) -> int32 + { + const float MyKeySquared = OriginKeySquared(Index); + float BestInward = FLT_MAX; + int32 BestInwardIndex = -1; + float BestAny = FLT_MAX; + int32 BestAnyIndex = -1; + for (int32 Other = 0; Other < Rooms.Num(); ++Other) + { + if (Other == Index) + { + continue; + } + const float DistanceSquared = static_cast( + FVector::DistSquared( + Rooms[Index].Center, Rooms[Other].Center)); + if (!FMath::IsFinite(DistanceSquared)) + { + return -2; + } + if (DistanceSquared > MaxTunnelLengthSquared) + { + continue; + } + const float Metric = LinkMetric(Index, Other); + if (!FMath::IsFinite(Metric)) + { + return -2; + } + if (Metric < BestAny) + { + BestAny = Metric; + BestAnyIndex = Other; + } + if (bFlowToOrigin + && OriginKeySquared(Other) < MyKeySquared + && Metric < BestInward) + { + BestInward = Metric; + BestInwardIndex = Other; + } + } + return (bFlowToOrigin && BestInwardIndex != -1) + ? BestInwardIndex : BestAnyIndex; + }; + for (int32 Index = 0; Index < Rooms.Num(); ++Index) + { + NearestNeighbor[Index] = PickNeighbor(Index); + if (NearestNeighbor[Index] == -2) + { + return true; + } + } + + for (int32 First = 0; First < Rooms.Num(); ++First) + { + for (int32 Second = First + 1; Second < Rooms.Num(); ++Second) + { + const float EuclideanDistance = static_cast( + FVector::Dist(Rooms[First].Center, Rooms[Second].Center)); + if (!FMath::IsFinite(EuclideanDistance)) + { + return true; + } + bool bBackbone = NearestNeighbor[First] == Second + || NearestNeighbor[Second] == First; + float CheckDistance = EuclideanDistance; + if (!bBackbone && Params.TunnelHorizontalBias > 0.0f) + { + const float VerticalSeparation = FMath::Abs( + static_cast( + Rooms[First].Center.Z - Rooms[Second].Center.Z)); + CheckDistance += VerticalSeparation + * Params.TunnelHorizontalBias * 5.0f; + } + if (!FMath::IsFinite(CheckDistance) + || CheckDistance > Reach.MaxTunnelLength) + { + continue; + } + if (!bBackbone) + { + if (Params.TunnelDensity <= 0.0f) + { + continue; + } + const uint32 PairHash = VoxelHash::Pair( + Rooms[First].CellX, Rooms[First].CellY, + Rooms[Second].CellX, Rooms[Second].CellY, + StrateSeed); + if (VoxelHash::ToFloat01(PairHash) >= Params.TunnelDensity) + { + continue; + } + } + + const float PairMinX = FMath::Min( + static_cast(Rooms[First].Center.X), + static_cast(Rooms[Second].Center.X)) + - Reach.PairAabbReach; + const float PairMinY = FMath::Min( + static_cast(Rooms[First].Center.Y), + static_cast(Rooms[Second].Center.Y)) + - Reach.PairAabbReach; + const float PairMaxX = FMath::Max( + static_cast(Rooms[First].Center.X), + static_cast(Rooms[Second].Center.X)) + + Reach.PairAabbReach; + const float PairMaxY = FMath::Max( + static_cast(Rooms[First].Center.Y), + static_cast(Rooms[Second].Center.Y)) + + Reach.PairAabbReach; + if (!FMath::IsFinite(PairMinX) || !FMath::IsFinite(PairMinY) + || !FMath::IsFinite(PairMaxX) || !FMath::IsFinite(PairMaxY)) + { + return true; + } + if (PairMaxX >= SearchMinX && PairMaxY >= SearchMinY + && PairMinX <= SearchMaxX && PairMinY <= SearchMaxY) + { + // A backbone link is considered potential even if the origin + // connection cap later downgrades it. That only sacrifices a + // skip; it can never turn a real tunnel into an empty proof. + return true; + } + } + } + return false; +#endif +} + //============================================================================= // INTERNAL: Shared hash-placement skeleton for the per-room baked features // (pits / chimneys / columns). Squelette commun de placement par hash — les @@ -2848,6 +3314,175 @@ bool VF_SuggestLandingPoint( } } +bool VoxelCaveMorphology::MayHaveFeatureInSearchBox( + float SearchMinX, float SearchMinY, + float SearchMaxX, float SearchMaxY, + const FStrateGenerationParams& Params, + uint32 Seed, int32 StrateIndex, + bool bUseZ, float SearchMinZ, float SearchMaxZ) +{ + if (!FMath::IsFinite(SearchMinX) || !FMath::IsFinite(SearchMinY) + || !FMath::IsFinite(SearchMaxX) || !FMath::IsFinite(SearchMaxY) + || SearchMinX > SearchMaxX || SearchMinY > SearchMaxY) + { + return true; + } + if (bUseZ + && (!FMath::IsFinite(SearchMinZ) || !FMath::IsFinite(SearchMaxZ) + || SearchMinZ > SearchMaxZ)) + { + return true; + } + if (Params.RoomSpacing <= 0.0f) + { + // BuildChunkCache returns an empty cache before creating the origin + // room when spacing is non-positive. + return false; + } + + FVFRoomGraphReach Reach; + if (!VF_ComputeRoomGraphReach(Params, Reach)) + { + return true; + } + + const float CollectMinX = SearchMinX - Reach.CollectMargin; + const float CollectMinY = SearchMinY - Reach.CollectMargin; + const float CollectMaxX = SearchMaxX + Reach.CollectMargin; + const float CollectMaxY = SearchMaxY + Reach.CollectMargin; + if (!FMath::IsFinite(CollectMinX) || !FMath::IsFinite(CollectMinY) + || !FMath::IsFinite(CollectMaxX) || !FMath::IsFinite(CollectMaxY)) + { + return true; + } + + auto FloorCell = [](double Value, double CellSize, int32& OutCell) -> bool + { + const double Cell = FMath::FloorToDouble(Value / CellSize); + if (!FMath::IsFinite(Cell) + || Cell < static_cast(MIN_int32) + 2.0 + || Cell > static_cast(MAX_int32) - 2.0) + { + return false; + } + OutCell = static_cast(Cell); + return true; + }; + int32 CellMinX = 0; + int32 CellMaxX = 0; + int32 CellMinY = 0; + int32 CellMaxY = 0; + if (!FloorCell( + static_cast(CollectMinX), + static_cast(Params.RoomSpacing), CellMinX) + || !FloorCell( + static_cast(CollectMaxX), + static_cast(Params.RoomSpacing), CellMaxX) + || !FloorCell( + static_cast(CollectMinY), + static_cast(Params.RoomSpacing), CellMinY) + || !FloorCell( + static_cast(CollectMaxY), + static_cast(Params.RoomSpacing), CellMaxY)) + { + return true; + } + + const int64 SpanX = static_cast(CellMaxX) + - static_cast(CellMinX) + 1; + const int64 SpanY = static_cast(CellMaxY) + - static_cast(CellMinY) + 1; + // This is deliberately a bounded proof pass. If an authored asset asks + // BuildChunkCache to hash a pathological number of cells, keep the old + // conservative path instead of making classification unbounded. + if (SpanX <= 0 || SpanY <= 0 || SpanX > 4096 || SpanY > 4096 + || SpanX > 262144 / FMath::Max(SpanY, 1)) + { + return true; + } + + const float RoomRadiusEnvelope = FMath::Max( + FMath::Abs(Params.MinRoomRadius), + FMath::Abs(Params.MaxRoomRadius)); + const float RoomZBuffer = RoomRadiusEnvelope * Params.RoomHeightRatio; + const float StrateMinZ = Params.StrateBottomWorldZ + + Params.BoundarySealThickness + RoomZBuffer; + const float StrateMaxZ = Params.StrateTopWorldZ + - Params.BoundarySealThickness - RoomZBuffer; + const float StrateRangeZ = StrateMaxZ - StrateMinZ; + const float StrateCenterZ = (StrateMinZ + StrateMaxZ) * 0.5f; + if (!FMath::IsFinite(StrateMinZ) || !FMath::IsFinite(StrateMaxZ) + || !FMath::IsFinite(StrateRangeZ) + || !FMath::IsFinite(StrateCenterZ)) + { + return true; + } + + TArray> Rooms; + if (Params.OriginRoomRadius > 0.0f) + { + FBuildRoom OriginRoom{}; + OriginRoom.Center = FVector(0.0f, 0.0f, StrateCenterZ); + OriginRoom.RadiusXY = Params.OriginRoomRadius; + OriginRoom.RadiusZ = Params.OriginRoomRadius + * Params.RoomHeightRatio; + OriginRoom.CellX = INT32_MAX; + OriginRoom.CellY = INT32_MAX; + OriginRoom.Hash = VoxelHash::Cell( + 0, 0, VoxelCaveMorphology::MakeStrateSeed(Seed, StrateIndex) + ^ 0x0A161Cu); + OriginRoom.bIsOrigin = true; + OriginRoom.bStore = true; + Rooms.Add(OriginRoom); + } + + const uint32 StrateSeed = VoxelCaveMorphology::MakeStrateSeed( + Seed, StrateIndex); + if (Params.RoomDensity > 0.0f) + { + for (int32 CY = CellMinY; CY <= CellMaxY; ++CY) + { + for (int32 CX = CellMinX; CX <= CellMaxX; ++CX) + { + const uint32 CellHash = VoxelHash::Cell( + CX, CY, StrateSeed); + if (VoxelHash::ToFloat01(CellHash) >= Params.RoomDensity) + { + continue; + } + + const float JitterX = VoxelHash::ToFloat01( + VoxelHash::Mix(CellHash ^ 0x12345678u)); + const float JitterY = VoxelHash::ToFloat01( + VoxelHash::Mix(CellHash ^ 0x9ABCDEF0u)); + const float JitterZ = VoxelHash::ToFloat01( + VoxelHash::Mix(CellHash ^ 0x55AA55AAu)); + FBuildRoom Room{}; + Room.CellX = CX; + Room.CellY = CY; + Room.Hash = CellHash; + Room.Center.X = (static_cast(CX) + 0.15f + + JitterX * 0.7f) * Params.RoomSpacing; + Room.Center.Y = (static_cast(CY) + 0.15f + + JitterY * 0.7f) * Params.RoomSpacing; + Room.Center.Z = StrateMinZ + JitterZ + * FMath::Max(StrateRangeZ, 1.0f); + const float SizeFactor = VoxelHash::ToFloat01( + VoxelHash::Mix(CellHash ^ 0xFEDCBA98u)); + Room.RadiusXY = FMath::Lerp( + Params.MinRoomRadius, Params.MaxRoomRadius, SizeFactor); + Room.RadiusZ = Room.RadiusXY * Params.RoomHeightRatio; + Room.bStore = true; + Rooms.Add(Room); + } + } + } + + return VF_MayHaveRoomGraphFeature( + Rooms, SearchMinX, SearchMinY, SearchMaxX, SearchMaxY, + Params, StrateSeed, Reach, bUseZ, SearchMinZ, SearchMaxZ); +} + //============================================================================= // PHASE 1: BUILD CHUNK CACHE //============================================================================= @@ -2866,21 +3501,8 @@ void VoxelCaveMorphology::BuildChunkCache( uint32 Seed, int32 StrateIndex, const TArray* TerrainOps) { - FTask4BuildProbeScope Task4BuildProbe(OutCache, SearchMinX, SearchMinY, SearchMaxX, SearchMaxY); - // Clear previous data (arrays keep their allocation for reuse) - OutCache.Rooms.Reset(); - OutCache.RoomFloorJoins.Reset(); - OutCache.Tunnels.Reset(); - OutCache.Pits.Reset(); - OutCache.Chimneys.Reset(); - OutCache.Columns.Reset(); - OutCache.SupportColumnMinX = 0; - OutCache.SupportColumnMinY = 0; - OutCache.SupportColumnCellsX = 0; - OutCache.SupportColumnCellsY = 0; - OutCache.SupportColumnEntries.Reset(); - OutCache.SupportColumns.Reset(); + OutCache.Reset(); // Combine world seed with strate index so each strate gets unique caves const uint32 StrateSeed = VoxelCaveMorphology::MakeStrateSeed(Seed, StrateIndex); @@ -2970,23 +3592,9 @@ void VoxelCaveMorphology::BuildChunkCache( // That's FALSE for the origin room (OriginRoomRadius >> MaxRoomRadius) — chunks inside // the big room but > MaxInfluence from (0,0) didn't store it, so its carve clipped at an // arbitrary chunk-aligned radius — and slightly false even for hash rooms (1.5x stretch). - const auto RoomShapeReachUpperBound = [](float RadiusXY, float RadiusZ) -> float - { - const float RXY = FMath::Abs(RadiusXY); - const float RZ = FMath::Abs(RadiusZ); - const float Ellipsoid = FMath::Max(RXY, RZ); - const FVector BoxExtent( - RXY * 0.8f + RXY * 0.25f, - RXY * 0.8f + RXY * 0.25f, - RZ * 0.8f + RXY * 0.25f); - const float RoundedBox = BoxExtent.Size(); - const float Capsule = RXY * 0.7f + FMath::Min(RXY * 0.6f, RZ); - return FMath::Max3(Ellipsoid, RoundedBox, Capsule); - }; - auto RoomReachesSearchBox = [&](const FVector& C, float RadiusXY, float RadiusZ) -> bool { - const float ShapeReach = RoomShapeReachUpperBound(RadiusXY, RadiusZ); + const float ShapeReach = VF_RoomShapeReachUpperBound(RadiusXY, RadiusZ); const float Reach = ShapeReach + FloorReliefEnvelope + BlendEnvelope * 3.0f; const float dx = FMath::Max3((float)(SearchMinX - C.X), 0.0f, (float)(C.X - SearchMaxX)); const float dy = FMath::Max3((float)(SearchMinY - C.Y), 0.0f, (float)(C.Y - SearchMaxY)); @@ -3002,6 +3610,60 @@ void VoxelCaveMorphology::BuildChunkCache( return (dx * dx + dy * dy) <= RSq; }; + // A connected edge can be discarded before the expensive player-fit bake when even a + // conservative AABB around its entire possible chain misses the search window. The endpoint + // fit stays inside 75% of its room radius; each cave-warped endpoint/control point can move by + // at most two proven warp envelopes (fit inversion plus the final map); the interior wander is + // bounded by TunnelWarpStrength. This is only a reject gate: non-finite inputs retain the old + // full build, and a touching envelope still takes the exact path below. + const float CaveWarpBound = FMath::Abs(Params.CaveWarpStrength) + * VOXEL_NOISE_SCALE * 1.5f; + auto PossibleTunnelTouchesSearchXY = [&](const FBuildRoom& RoomA, + const FBuildRoom& RoomB) -> bool + { + const float RadiusA = FMath::Abs(RoomA.RadiusXY); + const float RadiusB = FMath::Abs(RoomB.RadiusXY); + const float RoomEndpointShift = FMath::Max( + 2.0f, 0.75f * FMath::Max(RadiusA, RadiusB)) + CaveWarpBound; + const float TunnelRadius = FMath::Max( + 0.5f, + FMath::Max(FMath::Abs(Params.TunnelMinRadius), + FMath::Abs(Params.TunnelMaxRadius)) * 1.18f); + const float ChainReach = RoomEndpointShift + + FMath::Abs(Params.TunnelWarpStrength) + + TunnelRadius + + FMath::Max( + FMath::Max(Params.SDFBlendRadius, 0.0f) * 3.0f, + VoxelPassageGeometry::LandingFloorThicknessVoxels); + if (!FMath::IsFinite(CaveWarpBound) + || !FMath::IsFinite(ChainReach) + || ChainReach < 0.0f + || !FMath::IsFinite((float)RoomA.Center.X) + || !FMath::IsFinite((float)RoomA.Center.Y) + || !FMath::IsFinite((float)RoomB.Center.X) + || !FMath::IsFinite((float)RoomB.Center.Y)) + { + return true; + } + + const float MinX = FMath::Min( + static_cast(RoomA.Center.X), static_cast(RoomB.Center.X)) + - ChainReach; + const float MinY = FMath::Min( + static_cast(RoomA.Center.Y), static_cast(RoomB.Center.Y)) + - ChainReach; + const float MaxX = FMath::Max( + static_cast(RoomA.Center.X), static_cast(RoomB.Center.X)) + + ChainReach; + const float MaxY = FMath::Max( + static_cast(RoomA.Center.Y), static_cast(RoomB.Center.Y)) + + ChainReach; + return FMath::IsFinite(MinX) && FMath::IsFinite(MinY) + && FMath::IsFinite(MaxX) && FMath::IsFinite(MaxY) + && MaxX >= SearchMinX && MaxY >= SearchMinY + && MinX <= SearchMaxX && MinY <= SearchMaxY; + }; + // Temporary array with cell coordinates for tunnel connection decisions TArray> BuildRooms; @@ -3071,6 +3733,21 @@ void VoxelCaveMorphology::BuildChunkCache( } } + // The collect window is intentionally much wider than the stored window: + // it is needed to make the nearest-neighbor graph seam-invariant. Prove + // the genuinely feature-free case before the O(N^2) graph and player-fit + // bake. The helper repeats only the deterministic graph decisions that can + // affect whether a room/tunnel bound reaches this search window. + FVFRoomGraphReach FeatureReach; + if (VF_ComputeRoomGraphReach(Params, FeatureReach) + && !VF_MayHaveRoomGraphFeature( + BuildRooms, SearchMinX, SearchMinY, SearchMaxX, SearchMaxY, + Params, StrateSeed, FeatureReach)) + { + VF_FinalizeCacheBroadPhaseBounds(OutCache, BlendK); + return; + } + const int32 NumRooms = BuildRooms.Num(); if (NumRooms == 0) { @@ -3298,6 +3975,14 @@ void VoxelCaveMorphology::BuildChunkCache( RoomConnected[I] = true; RoomConnected[J] = true; + if (!PossibleTunnelTouchesSearchXY(RoomA, RoomB)) + { + // The graph decision is retained above; only the search-window geometry is + // omitted. This preserves connectivity, joins, and all neighbouring-window + // decisions while avoiding player-fit work for edges that cannot be stored. + continue; + } + // --- TUNNEL HASH (for deriving all tunnel properties) --- const uint32 TunnelHash = VoxelHash::Pair( RoomA.CellX, RoomA.CellY, @@ -3343,8 +4028,9 @@ void VoxelCaveMorphology::BuildChunkCache( FVector WorldEndB = VF_UnwarpCavePoint(EndB, Params, Seed); if (!RoomA.bIsOrigin) { - if (ResolvePlayerFitPoint( - BuildRooms[I], Params, Seed)) + const bool bHasPlayerFit = ResolvePlayerFitPoint( + BuildRooms[I], Params, Seed); + if (bHasPlayerFit) { WorldEndA = FVector( RoomA.PlayerFitPoint.X, @@ -3355,8 +4041,9 @@ void VoxelCaveMorphology::BuildChunkCache( } if (!RoomB.bIsOrigin) { - if (ResolvePlayerFitPoint( - BuildRooms[J], Params, Seed)) + const bool bHasPlayerFit = ResolvePlayerFitPoint( + BuildRooms[J], Params, Seed); + if (bHasPlayerFit) { WorldEndB = FVector( RoomB.PlayerFitPoint.X, diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 14e51e2..99f03a5 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -35,6 +35,7 @@ #include // l'id d'instance non recyclé du mémo de colonne #include "HAL/CriticalSection.h" #include "HAL/Event.h" +#include "HAL/PlatformTime.h" #include "HAL/PlatformProcess.h" #include "Misc/ScopeLock.h" @@ -180,7 +181,14 @@ namespace return VF_PerlinAbsBound; } - constexpr int32 Subdivisions = 8; + // The interval remains a proof for every subdivision size: each cell piece is evaluated + // with interval arithmetic, so a coarser partition can only widen the envelope. Eight + // pieces made a 32-voxel LOD0 root pay hundreds of thousands of tiny interval operations + // before the graph proof even started. Four pieces are still bounded, but retain enough + // finite-domain tightness at the 4-voxel refinement tail to discharge a proof without an + // exact lattice walk. If the bound cannot discharge the proof, the caller returns Mixed + // and the normal mesher remains the conservative fallback. + constexpr int32 Subdivisions = 4; float Bound = 0.0f; for (int32 CellZ = Z0; CellZ <= Z1; ++CellZ) for (int32 CellY = Y0; CellY <= Y1; ++CellY) @@ -3174,6 +3182,26 @@ namespace uint64 ManagerLifetimeId, const TArray* TerrainOps, int32 RegionMinX, int32 RegionMinY, int32 RegionSize) { + auto SelectForReuse = [](FRoomGraphCacheEntry* Selected, uint32 Use) + -> FRoomGraphCacheEntry* + { + if (Selected != nullptr && Selected->bValid) + { + // Reset() deliberately preserves hot-path capacity. That is useful while the + // same window is reused, but not when this slot is about to become a different + // key: every worker could otherwise retain one full graph window per historical + // streaming key. Release before invalidating the key so a long-lived game has a + // bounded working set instead of a capacity leak. + Selected->Cache.Release(); + Selected->bValid = false; + } + if (Selected != nullptr) + { + Selected->LastUse = Use; + } + return Selected; + }; + const int32 Bank = RoomGraphCacheBank(RegionSize); const uint32 Use = ++GRoomGraphCacheClock; const int32 StartSlot = RoomGraphCacheSlot( @@ -3188,8 +3216,7 @@ namespace if (!Candidate.bValid) { FRoomGraphCacheEntry* Selected = ReuseEntry != nullptr ? ReuseEntry : &Candidate; - Selected->LastUse = Use; - return Selected; + return SelectForReuse(Selected, Use); } if (Candidate.Seed == Seed && Candidate.StrateIndex == StrateIndex @@ -3215,8 +3242,8 @@ namespace } FRoomGraphCacheEntry* Selected = ReuseEntry != nullptr ? ReuseEntry : LeastRecentlyUsed; - Selected->LastUse = Use; - return Selected != nullptr ? Selected : &GRoomGraphCache[Bank][StartSlot]; + return SelectForReuse( + Selected != nullptr ? Selected : &GRoomGraphCache[Bank][StartSlot], Use); } // Classification is dispatched across many workers before most of those workers have @@ -3241,6 +3268,7 @@ namespace bool bBuilding = false; FEvent* ReadyEvent = nullptr; TSharedPtr Cache; + uint64 AllocatedBytes = 0; ~FSharedRoomGraphCacheEntry() { @@ -3255,7 +3283,114 @@ namespace static TArray> GSharedRoomGraphCache; static uint32 GSharedRoomGraphCacheClock = 0; - constexpr int32 SharedRoomGraphCacheMaxEntries = 128; + static uint64 GSharedRoomGraphCacheResidentBytes = 0; + static FCriticalSection GSharedRoomGraphCacheBuildGateMutex; + static int32 GSharedRoomGraphCacheActiveBuilds[2] = {0, 0}; + // The byte budget is the actual memory guard. A 128-entry count is too small for the + // player's streaming radius: it turns otherwise reusable 128-voxel windows into transient + // rebuilds while only a few MiB are resident. Keep a generous bounded index so the budget, + // rather than an arbitrary key count, controls retention. + constexpr int32 SharedRoomGraphCacheMaxEntries = 4096; + // Limit the number of graph allocations that can exist before their size is + // known and published into the byte budget. This is the guard against a + // burst of distinct coarse tiles retaining one large build per worker. + // Fine LOD0 proof windows are small and latency-sensitive. Keep their build slots separate + // from coarse streaming windows: a pair of 250 ms LOD4 graph builds must not make a 32/64 + // voxel empty tile wait behind them. The fine slots are still bounded, and their graph + // allocations are tiny compared with the coarse builds. + constexpr int32 SharedRoomGraphCacheBuildBankCount = 2; + constexpr int32 SharedRoomGraphCacheBuildConcurrency[SharedRoomGraphCacheBuildBankCount] = {4, 2}; + // This is a proof-cache budget, not a geometry budget. A miss rebuilds an immutable cache; + // unknown/evicted proof data therefore falls back to sampling and cannot change the mesh. + constexpr uint64 SharedRoomGraphCacheBudgetBytes = 256ull * 1024ull * 1024ull; + + int32 SharedRoomGraphCacheBuildBank(int32 RegionSize) + { + return RegionSize <= 2 * CHUNK_SIZE ? 0 : 1; + } + + void AcquireSharedRoomGraphCacheBuildSlot(int32 RegionSize) + { + const int32 Bank = SharedRoomGraphCacheBuildBank(RegionSize); + for (;;) + { + { + FScopeLock Lock(&GSharedRoomGraphCacheBuildGateMutex); + if (GSharedRoomGraphCacheActiveBuilds[Bank] + < SharedRoomGraphCacheBuildConcurrency[Bank]) + { + ++GSharedRoomGraphCacheActiveBuilds[Bank]; + return; + } + } + FPlatformProcess::Yield(); + } + } + + void ReleaseSharedRoomGraphCacheBuildSlot(int32 RegionSize) + { + const int32 Bank = SharedRoomGraphCacheBuildBank(RegionSize); + FScopeLock Lock(&GSharedRoomGraphCacheBuildGateMutex); + check(GSharedRoomGraphCacheActiveBuilds[Bank] > 0); + --GSharedRoomGraphCacheActiveBuilds[Bank]; + } + + void EvictSharedRoomGraphCacheLocked(const FSharedRoomGraphCacheEntry* ProtectedEntry = nullptr) + { + while (GSharedRoomGraphCache.Num() > SharedRoomGraphCacheMaxEntries + || GSharedRoomGraphCacheResidentBytes > SharedRoomGraphCacheBudgetBytes) + { + int32 OldestIndex = INDEX_NONE; + for (int32 Index = 0; Index < GSharedRoomGraphCache.Num(); ++Index) + { + const TSharedPtr& Candidate = + GSharedRoomGraphCache[Index]; + if (Candidate->bBuilding || Candidate.Get() == ProtectedEntry) + { + continue; + } + if (OldestIndex == INDEX_NONE + || Candidate->LastUse < GSharedRoomGraphCache[OldestIndex]->LastUse) + { + OldestIndex = Index; + } + } + if (OldestIndex == INDEX_NONE) + { + // All remaining entries are in-flight or the protected oversized entry. Keep + // them alive until their consumers finish; the next completed publish can evict. + break; + } + + const uint64 Bytes = GSharedRoomGraphCache[OldestIndex]->AllocatedBytes; + GSharedRoomGraphCacheResidentBytes = Bytes >= GSharedRoomGraphCacheResidentBytes + ? 0 + : GSharedRoomGraphCacheResidentBytes - Bytes; + GSharedRoomGraphCache.RemoveAtSwap(OldestIndex, 1, EAllowShrinking::No); + } + } + + void RemoveStaleSharedRoomGraphCachesLocked(uint64 KeepManagerLifetimeId) + { + for (int32 Index = GSharedRoomGraphCache.Num() - 1; Index >= 0; --Index) + { + const TSharedPtr& Candidate = + GSharedRoomGraphCache[Index]; + if (Candidate->bBuilding + || (KeepManagerLifetimeId != 0 + && Candidate->ManagerLifetimeId == KeepManagerLifetimeId)) + { + continue; + } + + const uint64 Bytes = Candidate->AllocatedBytes; + GSharedRoomGraphCacheResidentBytes = Bytes >= GSharedRoomGraphCacheResidentBytes + ? 0 + : GSharedRoomGraphCacheResidentBytes - Bytes; + GSharedRoomGraphCache.RemoveAtSwap(Index, 1, EAllowShrinking::No); + } + EvictSharedRoomGraphCacheLocked(); + } TSharedPtr FindOrBuildSharedRoomGraphCache( uint32 Seed, int32 StrateIndex, uint32 ParamsFingerprint, uint32 LayoutVersion, @@ -3268,6 +3403,7 @@ namespace { TSharedPtr Entry; bool bBuild = false; + bool bBuildTransient = false; { FScopeLock Lock(&GSharedRoomGraphCacheMutex); const uint32 Use = ++GSharedRoomGraphCacheClock; @@ -3291,6 +3427,60 @@ namespace } if (!Entry.IsValid()) + { + // Evict completed entries before admitting a new key. If + // the table is full of in-flight keys, do not append an + // unbounded waiting entry: build this request transiently + // under the build gate instead. The caller still gets the + // same immutable cache, but it is released with that + // classifier state rather than retained globally. + EvictSharedRoomGraphCacheLocked(); + if (GSharedRoomGraphCache.Num() >= SharedRoomGraphCacheMaxEntries) + { + bBuildTransient = true; + } + else + { + Entry = MakeShared(); + Entry->Seed = Seed; + Entry->StrateIndex = StrateIndex; + Entry->ParamsFingerprint = ParamsFingerprint; + Entry->LayoutVersion = LayoutVersion; + Entry->ManagerLifetimeId = ManagerLifetimeId; + Entry->TerrainOps = TerrainOps; + Entry->RegionMinX = RegionMinX; + Entry->RegionMinY = RegionMinY; + Entry->RegionSize = RegionSize; + Entry->LastUse = Use; + Entry->bBuilding = true; + Entry->ReadyEvent = FPlatformProcess::GetSynchEventFromPool(true); + GSharedRoomGraphCache.Add(Entry); + bBuild = true; + } + } + } + + if (bBuild || bBuildTransient) + { + const double CacheBuildCallStart = FPlatformTime::Seconds(); + AcquireSharedRoomGraphCacheBuildSlot(RegionSize); + const double CacheBuildSlotWait = FPlatformTime::Seconds() - CacheBuildCallStart; + TSharedPtr BuiltCache = + MakeShared(); + VoxelCaveMorphology::BuildChunkCache( + *BuiltCache, SearchMinX, SearchMinY, SearchMaxX, SearchMaxY, + Params, Seed, StrateIndex, TerrainOps); + const double CacheBuildSeconds = FPlatformTime::Seconds() - CacheBuildCallStart; + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::SdfCacheBuild); + } + + const uint64 AllocatedBytes = static_cast(sizeof(FSharedRoomGraphCacheEntry)) + + static_cast(sizeof(FChunkSDFCache)) + + static_cast(BuiltCache->GetAllocatedSize()); + if (bBuildTransient) { Entry = MakeShared(); Entry->Seed = Seed; @@ -3302,94 +3492,78 @@ namespace Entry->RegionMinX = RegionMinX; Entry->RegionMinY = RegionMinY; Entry->RegionSize = RegionSize; - Entry->LastUse = Use; - Entry->bBuilding = true; - Entry->ReadyEvent = FPlatformProcess::GetSynchEventFromPool(true); - GSharedRoomGraphCache.Add(Entry); - bBuild = true; - if (RegionMinX == 0 && RegionMinY == 0 && RegionSize == 128) - { - UE_LOG(LogTemp, Warning, - TEXT("[Task4SharedCache] phase=create key=(%u,%d,%u,%u,%llu,%p,%d,%d,%d)"), - Seed, StrateIndex, ParamsFingerprint, LayoutVersion, - (unsigned long long)ManagerLifetimeId, TerrainOps, - RegionMinX, RegionMinY, RegionSize); - } + Entry->LastUse = 0; + Entry->AllocatedBytes = AllocatedBytes; + Entry->Cache = BuiltCache; } - else if (RegionMinX == 0 && RegionMinY == 0 && RegionSize == 128) - { - UE_LOG(LogTemp, Warning, - TEXT("[Task4SharedCache] phase=found building=%d cache=%d"), - Entry->bBuilding ? 1 : 0, Entry->Cache.IsValid() ? 1 : 0); - } - } - - if (bBuild) - { - if (RegionMinX == 0 && RegionMinY == 0 && RegionSize == 128) - { - UE_LOG(LogTemp, Warning, TEXT("[Task4SharedCache] phase=build_begin")); - } - TSharedPtr BuiltCache = - MakeShared(); - VoxelCaveMorphology::BuildChunkCache( - *BuiltCache, SearchMinX, SearchMinY, SearchMaxX, SearchMaxY, - Params, Seed, StrateIndex, TerrainOps); - if (VoxelDensityProfile::AreCountersEnabled()) - { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::SdfCacheBuild); - } - - if (RegionMinX == 0 && RegionMinY == 0 && RegionSize == 128) - { - UE_LOG(LogTemp, Warning, TEXT("[Task4SharedCache] phase=build_done")); - } - + else { FScopeLock Lock(&GSharedRoomGraphCacheMutex); Entry->Cache = BuiltCache; + Entry->AllocatedBytes = AllocatedBytes; Entry->bBuilding = false; Entry->ReadyEvent->Trigger(); - if (RegionMinX == 0 && RegionMinY == 0 && RegionSize == 128) + if (AllocatedBytes <= SharedRoomGraphCacheBudgetBytes) { - UE_LOG(LogTemp, Warning, - TEXT("[Task4SharedCache] phase=published cache=%d"), - Entry->Cache.IsValid() ? 1 : 0); + GSharedRoomGraphCacheResidentBytes += Entry->AllocatedBytes; + EvictSharedRoomGraphCacheLocked(Entry.Get()); } - if (GSharedRoomGraphCache.Num() > SharedRoomGraphCacheMaxEntries) + else { - int32 OldestIndex = INDEX_NONE; + // Publish to any waiters through the shared entry, but do not retain an + // oversized graph in the process-wide table. The caller's shared pointer + // keeps this one immutable build alive only for the current work item. for (int32 Index = 0; Index < GSharedRoomGraphCache.Num(); ++Index) { - const TSharedPtr& Candidate = - GSharedRoomGraphCache[Index]; - if (!Candidate->bBuilding - && (OldestIndex == INDEX_NONE - || Candidate->LastUse - < GSharedRoomGraphCache[OldestIndex]->LastUse)) + if (GSharedRoomGraphCache[Index].Get() == Entry.Get()) { - OldestIndex = Index; + GSharedRoomGraphCache.RemoveAtSwap( + Index, 1, EAllowShrinking::No); + break; } } - if (OldestIndex != INDEX_NONE) - { - GSharedRoomGraphCache.RemoveAtSwap( - OldestIndex, 1, EAllowShrinking::No); - } + EvictSharedRoomGraphCacheLocked(); + UE_LOG(LogTemp, Warning, + TEXT("[VoxelForgeSharedCacheMemory] oversized_entry_bytes=%llu " + "budget_bytes=%llu retained=0"), + static_cast(AllocatedBytes), + static_cast(SharedRoomGraphCacheBudgetBytes)); } } - VoxelDensityOps::ReportWorkerRoomGraphCacheFootprint(); + ReleaseSharedRoomGraphCacheBuildSlot(RegionSize); + if (!bBuildTransient) + { + VoxelDensityOps::ReportWorkerRoomGraphCacheFootprint(); + } + if (RegionSize <= 2 * CHUNK_SIZE + || CacheBuildSeconds >= 0.25 || CacheBuildSlotWait >= 0.25) + { + UE_LOG(LogTemp, Warning, + TEXT("[VoxelForgeSharedCacheTiming] action=build transient=%d " + "region=(%d,%d,%d) slot_wait=%.6f build=%.6f bytes=%llu"), + bBuildTransient ? 1 : 0, RegionMinX, RegionMinY, RegionSize, + CacheBuildSlotWait, CacheBuildSeconds, + static_cast(AllocatedBytes)); + } + return Entry; + } + + if (Entry->Cache.IsValid()) + { return Entry; } // A different worker is building this exact immutable key. Waiting on its event // avoids duplicate graph work without serializing unrelated region keys. - if (RegionMinX == 0 && RegionMinY == 0 && RegionSize == 128) - { - UE_LOG(LogTemp, Warning, TEXT("[Task4SharedCache] phase=wait")); - } + const double CacheWaitStart = FPlatformTime::Seconds(); Entry->ReadyEvent->Wait(); + const double CacheWaitSeconds = FPlatformTime::Seconds() - CacheWaitStart; + if (Entry->RegionSize <= 2 * CHUNK_SIZE || CacheWaitSeconds >= 0.25) + { + UE_LOG(LogTemp, Warning, + TEXT("[VoxelForgeSharedCacheTiming] action=wait region=(%d,%d,%d) wait=%.6f"), + Entry->RegionMinX, Entry->RegionMinY, Entry->RegionSize, CacheWaitSeconds); + } } } @@ -3457,6 +3631,10 @@ namespace // graph merely because the stack object changed. FChunkSDFCache Cache; const FChunkSDFCache* ActiveCache = nullptr; + // Coarse density samples use the process-local immutable graph cache. Keep the entry + // alive for the whole sequence of detail operators that consume ActiveCache. + TSharedPtr + SharedCacheEntry; /** La salle de SDF minimal pour le dernier voxel évalué. -1 = aucune. */ int32 NearestRoom = -1; @@ -3791,6 +3969,7 @@ namespace if (ManagerLifetimeId != 0 && LiveManager == nullptr) { S.Cache = FChunkSDFCache(); + S.SharedCacheEntry.Reset(); S.ActiveCache = &S.Cache; S.SupportColumns.Invalidate(); InOut.Sdf = FLT_MAX; @@ -3799,6 +3978,7 @@ namespace if (!(P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f)) { + S.SharedCacheEntry.Reset(); S.ActiveCache = &S.Cache; S.SupportColumns.Invalidate(); return; // Sdf reste FLT_MAX @@ -3882,7 +4062,7 @@ namespace const int32 SampleStep = FMath::Max(VoxelGenLOD::SampleStep, 1); const int32 RegionChunks = SampleStep <= 1 ? 1 - : (SampleStep >= 32 ? 4 : FMath::Clamp((SampleStep + 3) / 4, 1, 4)); + : (SampleStep >= 32 ? 4 : FMath::Max(4, (SampleStep + 3) / 4)); const int32 RegionSize = CHUNK_SIZE * RegionChunks; const int32 RegionCellX = FMath::FloorToInt(WorldX / (float)RegionSize); const int32 RegionCellY = FMath::FloorToInt(WorldY / (float)RegionSize); @@ -3900,50 +4080,91 @@ namespace if (Def) { TerrainOps = &Def->TerrainOperations; } } - FRoomGraphCacheEntry* SharedCache = FindRoomGraphCacheEntry( - SeedU, StrateIdx, ParamsFingerprint, LayoutVersion, ManagerLifetimeId, - TerrainOps, RegionMinX, RegionMinY, RegionSize); - const bool bCacheMatches = SharedCache->bValid - && SharedCache->Seed == SeedU - && SharedCache->StrateIndex == StrateIdx - && SharedCache->ParamsFingerprint == ParamsFingerprint - && SharedCache->LayoutVersion == LayoutVersion - && SharedCache->ManagerLifetimeId == ManagerLifetimeId - && SharedCache->TerrainOps == TerrainOps - && SharedCache->RegionMinX == RegionMinX - && SharedCache->RegionMinY == RegionMinY - && SharedCache->RegionSize == RegionSize; - if (!bCacheMatches) + const FChunkSDFCache* PreviousActiveCache = S.ActiveCache; + bool bRebuiltLocalCache = false; + if (SampleStep > 1) { - if (VoxelDensityProfile::AreCountersEnabled()) + const bool bSharedCacheMatches = + S.SharedCacheEntry.IsValid() + && S.SharedCacheEntry->Cache.IsValid() + && S.SharedCacheEntry->Seed == SeedU + && S.SharedCacheEntry->StrateIndex == StrateIdx + && S.SharedCacheEntry->ParamsFingerprint == ParamsFingerprint + && S.SharedCacheEntry->LayoutVersion == LayoutVersion + && S.SharedCacheEntry->ManagerLifetimeId == ManagerLifetimeId + && S.SharedCacheEntry->TerrainOps == TerrainOps + && S.SharedCacheEntry->RegionMinX == RegionMinX + && S.SharedCacheEntry->RegionMinY == RegionMinY + && S.SharedCacheEntry->RegionSize == RegionSize; + if (!bSharedCacheMatches) { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::SdfCacheBuild); + const float Expansion = FMath::Abs(P.CaveWarpStrength) + * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + 2.0f; + S.SharedCacheEntry = FindOrBuildSharedRoomGraphCache( + SeedU, StrateIdx, ParamsFingerprint, LayoutVersion, + ManagerLifetimeId, TerrainOps, + RegionMinX, RegionMinY, RegionSize, + (float)RegionMinX - Expansion, + (float)RegionMinY - Expansion, + (float)(RegionMinX + RegionSize) + Expansion, + (float)(RegionMinY + RegionSize) + Expansion, + P); } - SharedCache->bValid = false; - SharedCache->Seed = SeedU; - SharedCache->StrateIndex = StrateIdx; - SharedCache->ParamsFingerprint = ParamsFingerprint; - SharedCache->LayoutVersion = LayoutVersion; - SharedCache->ManagerLifetimeId = ManagerLifetimeId; - SharedCache->TerrainOps = TerrainOps; - SharedCache->RegionMinX = RegionMinX; - SharedCache->RegionMinY = RegionMinY; - SharedCache->RegionSize = RegionSize; - const float Expansion = FMath::Abs(P.CaveWarpStrength) - * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + 2.0f; - VoxelCaveMorphology::BuildChunkCache( - SharedCache->Cache, - (float)RegionMinX - Expansion, - (float)RegionMinY - Expansion, - (float)(RegionMinX + RegionSize) + Expansion, - (float)(RegionMinY + RegionSize) + Expansion, - P, SeedU, StrateIdx, TerrainOps); - SharedCache->bValid = true; - VoxelDensityOps::ReportWorkerRoomGraphCacheFootprint(); + S.ActiveCache = S.SharedCacheEntry->Cache.Get(); + } + else + { + S.SharedCacheEntry.Reset(); + FRoomGraphCacheEntry* LocalCache = FindRoomGraphCacheEntry( + SeedU, StrateIdx, ParamsFingerprint, LayoutVersion, + ManagerLifetimeId, TerrainOps, + RegionMinX, RegionMinY, RegionSize); + const bool bCacheMatches = LocalCache->bValid + && LocalCache->Seed == SeedU + && LocalCache->StrateIndex == StrateIdx + && LocalCache->ParamsFingerprint == ParamsFingerprint + && LocalCache->LayoutVersion == LayoutVersion + && LocalCache->ManagerLifetimeId == ManagerLifetimeId + && LocalCache->TerrainOps == TerrainOps + && LocalCache->RegionMinX == RegionMinX + && LocalCache->RegionMinY == RegionMinY + && LocalCache->RegionSize == RegionSize; + if (!bCacheMatches) + { + bRebuiltLocalCache = true; + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::SdfCacheBuild); + } + LocalCache->bValid = false; + LocalCache->Seed = SeedU; + LocalCache->StrateIndex = StrateIdx; + LocalCache->ParamsFingerprint = ParamsFingerprint; + LocalCache->LayoutVersion = LayoutVersion; + LocalCache->ManagerLifetimeId = ManagerLifetimeId; + LocalCache->TerrainOps = TerrainOps; + LocalCache->RegionMinX = RegionMinX; + LocalCache->RegionMinY = RegionMinY; + LocalCache->RegionSize = RegionSize; + const float Expansion = FMath::Abs(P.CaveWarpStrength) + * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + 2.0f; + VoxelCaveMorphology::BuildChunkCache( + LocalCache->Cache, + (float)RegionMinX - Expansion, + (float)RegionMinY - Expansion, + (float)(RegionMinX + RegionSize) + Expansion, + (float)(RegionMinY + RegionSize) + Expansion, + P, SeedU, StrateIdx, TerrainOps); + LocalCache->bValid = true; + VoxelDensityOps::ReportWorkerRoomGraphCacheFootprint(); + } + S.ActiveCache = &LocalCache->Cache; + } + if (bRebuiltLocalCache || S.ActiveCache != PreviousActiveCache) + { S.SupportColumns.Invalidate(); } - S.ActiveCache = &SharedCache->Cache; float CaveSDF = VoxelCaveMorphology::EvaluateSDFCached( WarpedX, WarpedY, WarpedZ, GetCache(), P.SDFBlendRadius, &S.NearestRoom); @@ -4069,6 +4290,43 @@ namespace bool bCacheWindowUsesShared = false; bool bCacheWindowValid = false; + // Tight warp bounds are proofs over a box. Refinement asks about nested child boxes, + // so one bound for the current root is a sound superset for all descendants. + FBox TightWarpEnvelopeBox = FBox(ForceInit); + FVector TightWarpEnvelope = FVector::ZeroVector; + bool bTightWarpEnvelopeValid = false; + + // Exact lattice warp queries are shared by all refined children of one classifier + // root. The old path rebuilt the same Perlin warp for every small child (64 times on + // a uniform LOD0 tile). This is a bounded worker-local scratch array, not a process + // cache: it is keyed by the current box-proof state and never retains data for a + // different tile or parameter set. + TArray ExactWarpedLatticeQueries; + // Four-state results for the exact world-space tunnel-post check. This is a bounded + // per-window point cache: refinement boxes share boundary vertices, but no interval or + // tile verdict is retained here. + TArray ExactTailStates; + // Per-box exact proofs reuse these bounded working arrays. They are scratch only: the + // arrays are reset before every propagation and never retain an interval or verdict. + // Keeping their capacity on the worker removes a heap allocation for every refined + // LOD0 child while preserving the existing 100000-sample safety cap below. + TArray ExactWarpedLatticeScratch; + TArray ExactWorldLatticeScratch; + FBox ExactWarpedLatticeCacheBox = FBox(ForceInit); + int32 ExactWarpedLatticeIX0 = 0; + int32 ExactWarpedLatticeIY0 = 0; + int32 ExactWarpedLatticeIZ0 = 0; + int32 ExactWarpedLatticeNX = 0; + int32 ExactWarpedLatticeNY = 0; + int32 ExactWarpedLatticeNZ = 0; + FIntVector ExactWarpedLatticeOrigin = FIntVector::ZeroValue; + int32 ExactWarpedLatticeStep = 0; + uint32 ExactWarpedLatticeSeed = 0xFFFFFFFFu; + uint32 ExactWarpedLatticeFingerprint = 0xFFFFFFFFu; + uint32 ExactWarpedLatticeLayout = 0xFFFFFFFFu; + uint64 ExactWarpedLatticeManagerLifetimeId = 0; + bool bExactWarpedLatticeCacheValid = false; + EVoxelOpEffect Verdict = EVoxelOpEffect::Both; FVoxelBoxSdfInterval SdfInterval; @@ -4082,6 +4340,17 @@ namespace * « les tunnels bloquent » et « ma boîte est 125x trop grosse » sont indiscernables. */ int32 HitRoomsNoWarp = 0, HitTunnelsNoWarp = 0; float WarpDilation = 0.0f; + bool bMayHaveTunnelCoreAir = false; + bool bMayHaveTunnelSupportFloor = false; + bool bMayHaveTunnelCoreTail = false; + uint32 ExactTailQueries = 0; + uint32 ExactTailEvaluated = 0; + uint32 ExactTailCacheHits = 0; + uint64 ExactTailCycles = 0; + uint64 PropagateCycles = 0; + uint64 ExactPrimitiveCycles = 0; + uint64 CacheWindowCycles = 0; + int32 NumRoomFloorJoins = 0; }; static FBoxState& BoxState() @@ -4244,23 +4513,38 @@ namespace const FVoxelOpContext& Ctx) const override { FBoxState& B = BoxState(); + const uint64 PropagateStartCycles = FPlatformTime::Cycles64(); + B.ExactTailQueries = 0; + B.ExactTailEvaluated = 0; + B.ExactTailCycles = 0; + B.PropagateCycles = 0; + B.ExactPrimitiveCycles = 0; + B.CacheWindowCycles = 0; + B.NumRoomFloorJoins = 0; const UVoxelStrateManager* LiveManager = Manager.Get(); - const bool bTask4SourceProbe = Ctx.bUseLatticeProof - && Ctx.LatticeOriginVoxels == FIntVector(32, 0, -32) - && Ctx.Step == 1; - const uint64 Task4SourceStartCycles = bTask4SourceProbe - ? FPlatformTime::Cycles64() : 0; - if (bTask4SourceProbe) + auto InvalidateExactLatticeScratch = [&]() { - UE_LOG(LogTemp, Warning, - TEXT("[Task4SourceProbe] phase=enter box=(%.0f,%.0f,%.0f)-(%.0f,%.0f,%.0f) tight=%d"), - (float)VoxelBox.Min.X, (float)VoxelBox.Min.Y, (float)VoxelBox.Min.Z, - (float)VoxelBox.Max.X, (float)VoxelBox.Max.Y, (float)VoxelBox.Max.Z, - Ctx.bTightenWarpProof ? 1 : 0); - } + B.bExactWarpedLatticeCacheValid = false; + B.ExactWarpedLatticeQueries.Reset(); + B.ExactTailStates.Reset(); + B.ExactWarpedLatticeScratch.Reset(); + B.ExactWorldLatticeScratch.Reset(); + B.ExactWarpedLatticeCacheBox = FBox(ForceInit); + B.ExactWarpedLatticeIX0 = B.ExactWarpedLatticeIY0 = 0; + B.ExactWarpedLatticeIZ0 = 0; + B.ExactWarpedLatticeNX = B.ExactWarpedLatticeNY = 0; + B.ExactWarpedLatticeNZ = 0; + B.ExactWarpedLatticeOrigin = FIntVector::ZeroValue; + B.ExactWarpedLatticeStep = 0; + B.ExactWarpedLatticeSeed = 0xFFFFFFFFu; + B.ExactWarpedLatticeFingerprint = 0xFFFFFFFFu; + B.ExactWarpedLatticeLayout = 0xFFFFFFFFu; + B.ExactWarpedLatticeManagerLifetimeId = 0; + }; auto Unknown = [&]() { + InvalidateExactLatticeScratch(); B.bValid = false; B.ActiveCache = nullptr; B.CacheWindowSharedEntry.Reset(); @@ -4269,6 +4553,10 @@ namespace B.CacheWindowSharedRegionSize = 0; B.bCacheWindowUsesShared = false; B.bCacheWindowValid = false; + B.bTightWarpEnvelopeValid = false; + B.bMayHaveTunnelCoreAir = false; + B.bMayHaveTunnelSupportFloor = false; + B.bMayHaveTunnelCoreTail = false; B.SdfInterval.SetUnknown(); InOut.SetUnknown(); }; @@ -4316,6 +4604,7 @@ namespace if (!(P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f)) { + InvalidateExactLatticeScratch(); B.bValid = false; B.ActiveCache = nullptr; B.CacheWindowSharedEntry.Reset(); @@ -4325,6 +4614,9 @@ namespace B.HitRooms = B.HitTunnels = B.HitPits = B.HitChimneys = 0; B.HitRoomsNoWarp = B.HitTunnelsNoWarp = 0; B.WarpDilation = 0.0f; + B.bMayHaveTunnelCoreAir = false; + B.bMayHaveTunnelSupportFloor = false; + B.bMayHaveTunnelCoreTail = false; B.SdfInterval.Set(FLT_MAX, FLT_MAX); InOut = B.SdfInterval; return; @@ -4354,6 +4646,19 @@ namespace return; } + // A coarse tile is allowed to warm the proof, but never by allocating a morphology + // cache for the whole tile. At LOD3/4 the root can cover hundreds or thousands of + // chunk keys; that is a proof-domain size, not a useful working set. Descendants + // are still queried normally and can use the bounded shared windows below. Unknown + // is deliberately the only answer here: it cannot authorize an empty-tile skip. + constexpr int64 MaxLatticeProofChunkVolume = 512; + if (Ctx.bUseLatticeProof + && SpanX * SpanY * SpanZ > MaxLatticeProofChunkVolume) + { + Unknown(); + return; + } + // The ordinary (non-lattice) proof keeps its original bounded query contract. A // tile proof is different: its first query is the root box and its descendants are // all inside that box. Build the root cache once even when the root spans more than @@ -4385,13 +4690,19 @@ namespace // The room-op pool is part of the SDF: BuildChunkCache bakes pits, chimneys, and // columns from it. Every chunk touched by this query must therefore resolve to the // same definition before one cache can represent the box. - UVoxelStrateDefinition* Def0 = - LiveManager->GetStrateForChunk(FIntVector(CX0, CY0, CZ0)); + // GetStrateForChunk is a Z-layout lookup; the manager ignores + // X/Y. Check one representative per touched Z instead of + // repeating the same layout search for every coarse XY key. + UVoxelStrateDefinition* Def0 = nullptr; for (int32 CZ = CZ0; CZ <= CZ1; ++CZ) - for (int32 CY = CY0; CY <= CY1; ++CY) - for (int32 CX = CX0; CX <= CX1; ++CX) { - if (LiveManager->GetStrateForChunk(FIntVector(CX, CY, CZ)) != Def0) + UVoxelStrateDefinition* Def = + LiveManager->GetStrateForChunk(FIntVector(0, 0, CZ)); + if (CZ == CZ0) + { + Def0 = Def; + } + else if (Def != Def0) { Unknown(); return; @@ -4400,14 +4711,6 @@ namespace if (Def0) { TerrainOps = &Def0->TerrainOperations; } } - if (bTask4SourceProbe) - { - UE_LOG(LogTemp, Warning, - TEXT("[Task4SourceProbe] phase=layout box=(%.0f,%.0f,%.0f)-(%.0f,%.0f,%.0f) chunks=(%d,%d,%d)-(%d,%d,%d) strate=%d"), - BoxMinX, BoxMinY, BoxMinZ, BoxMaxX, BoxMaxY, BoxMaxZ, - CX0, CY0, CZ0, CX1, CY1, CZ1, StrateIdx); - } - const uint32 LV = Ctx.LayoutVersion; auto IsSharedCacheCurrent = [&]() -> bool { @@ -4433,14 +4736,6 @@ namespace || (B.KeyLatticeOrigin == Ctx.LatticeOriginVoxels && B.KeyLatticeStep == Ctx.Step))) { - if (bTask4SourceProbe) - { - UE_LOG(LogTemp, Warning, - TEXT("[Task4SourceProbe] phase=box_cache_hit box=(%.0f,%.0f,%.0f)-(%.0f,%.0f,%.0f) ms=%.3f"), - BoxMinX, BoxMinY, BoxMinZ, BoxMaxX, BoxMaxY, BoxMaxZ, - FPlatformTime::ToMilliseconds64( - FPlatformTime::Cycles64() - Task4SourceStartCycles)); - } InOut = B.SdfInterval; return; } @@ -4459,19 +4754,44 @@ namespace } FVector WarpEnvelope(GlobalWarp, GlobalWarp, GlobalWarp); - if (Ctx.bTightenWarpProof && P.CaveWarpStrength > 0.0f + // The finite Perlin interval is useful at the root too when the root is one ordinary + // LOD0 tile wide. Compute it once and retain it as a superset for every descendant; + // otherwise each refined child repeats the same finite-domain envelope work. The + // helper falls back to the global theorem for larger/non-finite noise domains, so + // widening this gate cannot weaken soundness. + const bool bUseFiniteWarpEnvelope = Ctx.bTightenWarpProof + && FMath::Max3(BoxMaxX - BoxMinX, BoxMaxY - BoxMinY, BoxMaxZ - BoxMinZ) + <= 32.0f * static_cast(FMath::Max(Ctx.Step, 1)); + if (bUseFiniteWarpEnvelope && P.CaveWarpStrength > 0.0f && P.CaveWarpFrequency > 0.0f) { - const float WarpAmplitude = P.CaveWarpStrength * VOXEL_NOISE_SCALE; - WarpEnvelope.X = WarpAmplitude * VF_PerlinAbsBoundOverWorldBox( - BoxMinX, BoxMaxX, BoxMinY, BoxMaxY, EffectiveMinZ, EffectiveMaxZ, - P.CaveWarpFrequency, VoxelHash::SeedOffset(SeedU, 0.37f), 1.3f, 5.7f); - WarpEnvelope.Y = WarpAmplitude * VF_PerlinAbsBoundOverWorldBox( - BoxMinX, BoxMaxX, BoxMinY, BoxMaxY, EffectiveMinZ, EffectiveMaxZ, - P.CaveWarpFrequency, 7.1f, VoxelHash::SeedOffset(SeedU, 0.59f), 2.3f); - WarpEnvelope.Z = WarpAmplitude * VF_PerlinAbsBoundOverWorldBox( - BoxMinX, BoxMaxX, BoxMinY, BoxMaxY, EffectiveMinZ, EffectiveMaxZ, - P.CaveWarpFrequency, 11.3f, 9.7f, VoxelHash::SeedOffset(SeedU, 0.41f)); + const bool bCanReuseTightEnvelope = B.bTightWarpEnvelopeValid + && B.TightWarpEnvelopeBox.Min.X <= BoxMinX + && B.TightWarpEnvelopeBox.Min.Y <= BoxMinY + && B.TightWarpEnvelopeBox.Min.Z <= BoxMinZ + && B.TightWarpEnvelopeBox.Max.X >= BoxMaxX + && B.TightWarpEnvelopeBox.Max.Y >= BoxMaxY + && B.TightWarpEnvelopeBox.Max.Z >= BoxMaxZ; + if (bCanReuseTightEnvelope) + { + WarpEnvelope = B.TightWarpEnvelope; + } + else + { + const float WarpAmplitude = P.CaveWarpStrength * VOXEL_NOISE_SCALE; + WarpEnvelope.X = WarpAmplitude * VF_PerlinAbsBoundOverWorldBox( + BoxMinX, BoxMaxX, BoxMinY, BoxMaxY, EffectiveMinZ, EffectiveMaxZ, + P.CaveWarpFrequency, VoxelHash::SeedOffset(SeedU, 0.37f), 1.3f, 5.7f); + WarpEnvelope.Y = WarpAmplitude * VF_PerlinAbsBoundOverWorldBox( + BoxMinX, BoxMaxX, BoxMinY, BoxMaxY, EffectiveMinZ, EffectiveMaxZ, + P.CaveWarpFrequency, 7.1f, VoxelHash::SeedOffset(SeedU, 0.59f), 2.3f); + WarpEnvelope.Z = WarpAmplitude * VF_PerlinAbsBoundOverWorldBox( + BoxMinX, BoxMaxX, BoxMinY, BoxMaxY, EffectiveMinZ, EffectiveMaxZ, + P.CaveWarpFrequency, 11.3f, 9.7f, VoxelHash::SeedOffset(SeedU, 0.41f)); + B.TightWarpEnvelopeBox = VoxelBox; + B.TightWarpEnvelope = WarpEnvelope; + B.bTightWarpEnvelopeValid = true; + } if (!Finite((float)WarpEnvelope.X) || !Finite((float)WarpEnvelope.Y) || !Finite((float)WarpEnvelope.Z)) { @@ -4479,7 +4799,6 @@ namespace return; } } - // The search window is a superset of every warped point in the box, with the same // two-voxel gradient margin used by Eval's cache path. In lattice mode, retain the // first/root window and reuse it for every refined child. A larger morphology @@ -4515,12 +4834,15 @@ namespace if (!bWindowKeyMatches) { + const uint64 CacheWindowStartCycles = FPlatformTime::Cycles64(); + InvalidateExactLatticeScratch(); B.ActiveCache = nullptr; B.CacheWindowSharedEntry.Reset(); B.CacheWindowSharedRegionMinX = 0; B.CacheWindowSharedRegionMinY = 0; B.CacheWindowSharedRegionSize = 0; B.bCacheWindowUsesShared = false; + B.bTightWarpEnvelopeValid = false; // The morphology builder is window-invariant. For the bounded lattice boxes, // reuse the worker-local graph cache used by Eval, but at a 128/256-voxel region @@ -4529,14 +4851,35 @@ namespace // populate its cache. The shared window is always built from the global warp // envelope, so it is a superset of this tight query and can only add conservative // primitives to the interval. + const bool bFeatureFree = + !VoxelCaveMorphology::MayHaveFeatureInSearchBox( + BoxMinX - (float)WarpEnvelope.X - 2.0f, + BoxMinY - (float)WarpEnvelope.Y - 2.0f, + BoxMaxX + (float)WarpEnvelope.X + 2.0f, + BoxMaxY + (float)WarpEnvelope.Y + 2.0f, + P, SeedU, StrateIdx, true, + BoxMinZ - (float)WarpEnvelope.Z - 2.0f, + BoxMaxZ + (float)WarpEnvelope.Z + 2.0f); bool bUsedSharedCache = false; - if (Ctx.bUseLatticeProof && SpanX <= 8 && SpanY <= 8) + const int64 MaxSharedChunkSpan = Ctx.Step >= 32 ? 32 : 16; + if (!bFeatureFree + && Ctx.bUseLatticeProof + // Fine LOD0/LOD1 requests are latency-sensitive and each worker already has + // a bounded graph cache. Sharing their tiny 64-voxel windows makes otherwise + // sub-millisecond empty proofs wait behind a different worker's build (the + // build itself is cheap). Coarse samples keep the shared window, where its + // larger footprint prevents thousands of per-chunk rebuilds. + && Ctx.Step > 1 + && SpanX <= MaxSharedChunkSpan + && SpanY <= MaxSharedChunkSpan) { + // Coarser LODs retain the four-chunk working set needed to amortise their + // wider sample footprint. Fine LODs take the worker-local path above. int32 SharedRegionSize = 4 * CHUNK_SIZE; int32 SharedRegionMinX = 0; int32 SharedRegionMinY = 0; bool bCoversBox = false; - for (int32 Attempt = 0; Attempt < 3; ++Attempt) + for (int32 Attempt = 0; Attempt < 6; ++Attempt) { SharedRegionMinX = FMath::FloorToInt( BoxMinX / (float)SharedRegionSize) * SharedRegionSize; @@ -4548,18 +4891,12 @@ namespace SharedRegionSize *= 2; } - if (bCoversBox && SharedRegionSize <= 8 * CHUNK_SIZE) + if (bCoversBox + && SharedRegionSize + <= static_cast(MaxSharedChunkSpan) * CHUNK_SIZE) { const float Expansion = FMath::Abs(P.CaveWarpStrength) * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + 2.0f; - if (bTask4SourceProbe) - { - UE_LOG(LogTemp, Warning, - TEXT("[Task4SourceProbe] phase=shared_begin region=(%d,%d,%d) ms=%.3f"), - SharedRegionMinX, SharedRegionMinY, SharedRegionSize, - FPlatformTime::ToMilliseconds64( - FPlatformTime::Cycles64() - Task4SourceStartCycles)); - } TSharedPtr SharedCache = FindOrBuildSharedRoomGraphCache( SeedU, StrateIdx, ParamsFingerprint, LayoutVersion, @@ -4577,27 +4914,28 @@ namespace B.CacheWindowSharedRegionSize = SharedRegionSize; B.bCacheWindowUsesShared = true; bUsedSharedCache = true; - if (bTask4SourceProbe) - { - UE_LOG(LogTemp, Warning, - TEXT("[Task4SourceProbe] phase=shared_ready box=(%.0f,%.0f,%.0f)-(%.0f,%.0f,%.0f) ms=%.3f rooms=%d tunnels=%d"), - BoxMinX, BoxMinY, BoxMinZ, BoxMaxX, BoxMaxY, BoxMaxZ, - FPlatformTime::ToMilliseconds64( - FPlatformTime::Cycles64() - Task4SourceStartCycles), - SharedCache->Cache->Rooms.Num(), SharedCache->Cache->Tunnels.Num()); - } } } if (!bUsedSharedCache) { - VoxelCaveMorphology::BuildChunkCache( - B.Cache, - BoxMinX - (float)WarpEnvelope.X - 2.0f, - BoxMinY - (float)WarpEnvelope.Y - 2.0f, - BoxMaxX + (float)WarpEnvelope.X + 2.0f, - BoxMaxY + (float)WarpEnvelope.Y + 2.0f, - P, SeedU, StrateIdx, TerrainOps); + if (bFeatureFree) + { + // Keep an empty worker-local cache object as the active + // window. Its arrays retain capacity, but no graph + // build or player-fit bake is paid for this box. + B.Cache.Release(); + } + else + { + VoxelCaveMorphology::BuildChunkCache( + B.Cache, + BoxMinX - (float)WarpEnvelope.X - 2.0f, + BoxMinY - (float)WarpEnvelope.Y - 2.0f, + BoxMaxX + (float)WarpEnvelope.X + 2.0f, + BoxMaxY + (float)WarpEnvelope.Y + 2.0f, + P, SeedU, StrateIdx, TerrainOps); + } B.ActiveCache = &B.Cache; } @@ -4614,12 +4952,12 @@ namespace B.CacheWindowLatticeOrigin = Ctx.LatticeOriginVoxels; B.CacheWindowLatticeStep = Ctx.Step; B.bCacheWindowValid = true; + B.CacheWindowCycles = FPlatformTime::Cycles64() - CacheWindowStartCycles; } else if (B.bCacheWindowUsesShared) { B.ActiveCache = B.CacheWindowSharedEntry->Cache.Get(); } - // Large root boxes are only a cache warm-up. Their interval is intentionally still // unknown; proof resumes at bounded descendants where all bounds below are finite. if (SpanX * SpanY * SpanZ > 64) @@ -4628,21 +4966,14 @@ namespace // different from Unknown(): the root is not invalid, it is merely too large to // publish a finite interval in one pass. B.bValid = false; + B.bMayHaveTunnelCoreAir = false; + B.bMayHaveTunnelSupportFloor = false; + B.bMayHaveTunnelCoreTail = false; B.SdfInterval.SetUnknown(); InOut.SetUnknown(); return; } - if (bTask4SourceProbe) - { - UE_LOG(LogTemp, Warning, - TEXT("[Task4SourceProbe] phase=bounded box=(%.0f,%.0f,%.0f)-(%.0f,%.0f,%.0f) ms=%.3f rooms=%d tunnels=%d"), - BoxMinX, BoxMinY, BoxMinZ, BoxMaxX, BoxMaxY, BoxMaxZ, - FPlatformTime::ToMilliseconds64( - FPlatformTime::Cycles64() - Task4SourceStartCycles), - GetBoxCache().Rooms.Num(), GetBoxCache().Tunnels.Num()); - } - const FVector QMin(BoxMinX - (float)WarpEnvelope.X, BoxMinY - (float)WarpEnvelope.Y, EffectiveMinZ - (float)WarpEnvelope.Z); @@ -4685,6 +5016,7 @@ namespace || !HasAxisSample(BoxMinY, BoxMaxY, LatticeOriginY, LatticeStepXY) || !HasAxisSample(BoxMinZ, BoxMaxZ, LatticeOriginZ, LatticeStepZ)) { + InvalidateExactLatticeScratch(); B.bValid = false; B.ActiveCache = nullptr; B.CacheWindowSharedEntry.Reset(); @@ -4702,8 +5034,10 @@ namespace // retained only an AABB at LOD0; that still admitted a tunnel when the warped samples // themselves missed it. The exact primitive minima below remove that false overlap // without changing the lattice or the density evaluator used by the mesher. - TArray ExactLatticeWarpedQueries; - TArray ExactLatticeWorldQueries; + TArray& ExactLatticeWarpedQueries = B.ExactWarpedLatticeScratch; + TArray& ExactLatticeWorldQueries = B.ExactWorldLatticeScratch; + ExactLatticeWarpedQueries.Reset(); + ExactLatticeWorldQueries.Reset(); FVector ExactLatticeQueryMin(FLT_MAX, FLT_MAX, FLT_MAX); FVector ExactLatticeQueryMax(-FLT_MAX, -FLT_MAX, -FLT_MAX); int32 ExactIX0 = 0, ExactIY0 = 0, ExactIZ0 = 0; @@ -4728,11 +5062,11 @@ namespace // a 33^3 query list before the eight 17^3 child proofs that actually decide the // residual solid tile. A non-exact root remains conservative and cannot create a // skip on its own. - // Strictly smaller excludes the tile root when it is exactly one coarse child - // wide (the common 16-cell LOD0 case). The root is only a cache warm-up; doing - // a full 17^3 warped-query scan there paid the expensive exact primitive loop - // once per empty tile before refinement had had a chance to narrow the box. - && MaxBoxExtent < ExactChildExtent; + // Include a box exactly one coarse child wide. This lets a depth-one LOD0 node + // use the exact warped lattice certificate before it fans out into eight smaller + // proofs; the certificate remains conservative because it samples only the same + // lattice points the mesher reads. + && MaxBoxExtent <= ExactChildExtent; if (bTryExactLatticeWarp) { const int32 IX0 = FMath::CeilToInt( @@ -4763,19 +5097,17 @@ namespace ExactNZ = IZ1 - IZ0 + 1; ExactLatticeWarpedQueries.Reserve(static_cast(Count)); ExactLatticeWorldQueries.Reserve(static_cast(Count)); - const float WorldOriginZ = static_cast(Ctx.LatticeOriginVoxels.Z); const float WarpAmplitude = P.CaveWarpStrength * VOXEL_NOISE_SCALE; - for (int32 IZ = IZ0; IZ <= IZ1; ++IZ) - for (int32 IY = IY0; IY <= IY1; ++IY) - for (int32 IX = IX0; IX <= IX1; ++IX) + auto MakeWarpedQuery = [&](int32 QueryIX, int32 QueryIY, + int32 QueryIZ) -> FVector { - const float WorldX = LatticeOriginX + (float)IX * LatticeStepXY; - const float WorldY = LatticeOriginY + (float)IY * LatticeStepXY; - const float WorldZ = WorldOriginZ + (float)IZ * (float)Ctx.Step; + const float WorldX = LatticeOriginX + (float)QueryIX * LatticeStepXY; + const float WorldY = LatticeOriginY + (float)QueryIY * LatticeStepXY; + const float WorldZ = static_cast(Ctx.LatticeOriginVoxels.Z) + + (float)QueryIZ * (float)Ctx.Step; const float EffectiveZ = (P.VerticalScale > 0.0f && P.VerticalScale != 1.0f) - ? WorldZ / P.VerticalScale : WorldZ; - ExactLatticeWorldQueries.Add(FVector(WorldX, WorldY, WorldZ)); + ? WorldZ / P.VerticalScale : WorldZ; FVector Query(WorldX, WorldY, EffectiveZ); if (P.CaveWarpStrength > 0.0f) { @@ -4794,7 +5126,139 @@ namespace EffectiveZ * Frequency + VoxelHash::SeedOffset(SeedU, 0.41f)) * WarpAmplitude; } - ExactLatticeWarpedQueries.Add(Query); + return Query; + }; + + // All exact child boxes are contained in the classifier root held by the + // window cache. Cache only the deterministic warp samples, not any interval or + // verdict, so a cache miss can only cost work. The 100000-sample cap bounds + // this worker-local scratch allocation even for an unusually large root. + bool bCanUseWarpedQueryCache = false; + int32 CacheIX0 = 0; + int32 CacheIY0 = 0; + int32 CacheIZ0 = 0; + int32 CacheNX = 0; + int32 CacheNY = 0; + int32 CacheNZ = 0; + if (B.bCacheWindowValid + && Finite((float)B.CacheWindowBox.Min.X) + && Finite((float)B.CacheWindowBox.Min.Y) + && Finite((float)B.CacheWindowBox.Min.Z) + && Finite((float)B.CacheWindowBox.Max.X) + && Finite((float)B.CacheWindowBox.Max.Y) + && Finite((float)B.CacheWindowBox.Max.Z) + && B.CacheWindowBox.Min.X <= B.CacheWindowBox.Max.X + && B.CacheWindowBox.Min.Y <= B.CacheWindowBox.Max.Y + && B.CacheWindowBox.Min.Z <= B.CacheWindowBox.Max.Z) + { + CacheIX0 = FMath::CeilToInt( + ((float)B.CacheWindowBox.Min.X - LatticeOriginX) + / LatticeStepXY - 1.0e-4f); + CacheIY0 = FMath::CeilToInt( + ((float)B.CacheWindowBox.Min.Y - LatticeOriginY) + / LatticeStepXY - 1.0e-4f); + CacheIZ0 = FMath::CeilToInt( + ((float)B.CacheWindowBox.Min.Z + - static_cast(Ctx.LatticeOriginVoxels.Z)) + / static_cast(Ctx.Step) - 1.0e-4f); + const int32 CacheIX1 = FMath::FloorToInt( + ((float)B.CacheWindowBox.Max.X - LatticeOriginX) + / LatticeStepXY + 1.0e-4f); + const int32 CacheIY1 = FMath::FloorToInt( + ((float)B.CacheWindowBox.Max.Y - LatticeOriginY) + / LatticeStepXY + 1.0e-4f); + const int32 CacheIZ1 = FMath::FloorToInt( + ((float)B.CacheWindowBox.Max.Z + - static_cast(Ctx.LatticeOriginVoxels.Z)) + / static_cast(Ctx.Step) + 1.0e-4f); + const int64 CacheCount = ((int64)CacheIX1 - CacheIX0 + 1) + * ((int64)CacheIY1 - CacheIY0 + 1) + * ((int64)CacheIZ1 - CacheIZ0 + 1); + if (CacheIX1 >= CacheIX0 && CacheIY1 >= CacheIY0 && CacheIZ1 >= CacheIZ0 + && CacheCount > 0 && CacheCount <= 100000 + && IX0 >= CacheIX0 && IY0 >= CacheIY0 && IZ0 >= CacheIZ0 + && IX1 <= CacheIX1 && IY1 <= CacheIY1 && IZ1 <= CacheIZ1) + { + CacheNX = CacheIX1 - CacheIX0 + 1; + CacheNY = CacheIY1 - CacheIY0 + 1; + CacheNZ = CacheIZ1 - CacheIZ0 + 1; + const bool bCacheKeyMatches = B.bExactWarpedLatticeCacheValid + && B.ExactWarpedLatticeCacheBox == B.CacheWindowBox + && B.ExactWarpedLatticeOrigin == Ctx.LatticeOriginVoxels + && B.ExactWarpedLatticeStep == Ctx.Step + && B.ExactWarpedLatticeSeed == SeedU + && B.ExactWarpedLatticeFingerprint == ParamsFingerprint + && B.ExactWarpedLatticeLayout == LV + && B.ExactWarpedLatticeManagerLifetimeId == ManagerLifetimeId + && B.ExactWarpedLatticeIX0 == CacheIX0 + && B.ExactWarpedLatticeIY0 == CacheIY0 + && B.ExactWarpedLatticeIZ0 == CacheIZ0 + && B.ExactWarpedLatticeNX == CacheNX + && B.ExactWarpedLatticeNY == CacheNY + && B.ExactWarpedLatticeNZ == CacheNZ + && B.ExactWarpedLatticeQueries.Num() == CacheCount; + if (!bCacheKeyMatches) + { + B.ExactWarpedLatticeQueries.Reset(); + B.ExactWarpedLatticeQueries.SetNumUninitialized( + static_cast(CacheCount)); + B.ExactTailStates.Init(0, static_cast(CacheCount)); + for (int32 CacheIZ = CacheIZ0; CacheIZ <= CacheIZ1; ++CacheIZ) + for (int32 CacheIY = CacheIY0; CacheIY <= CacheIY1; ++CacheIY) + for (int32 CacheIX = CacheIX0; CacheIX <= CacheIX1; ++CacheIX) + { + const int32 FlatIndex = + ((CacheIZ - CacheIZ0) * CacheNY + + (CacheIY - CacheIY0)) * CacheNX + + (CacheIX - CacheIX0); + B.ExactWarpedLatticeQueries[FlatIndex] = + MakeWarpedQuery(CacheIX, CacheIY, CacheIZ); + } + B.ExactWarpedLatticeCacheBox = B.CacheWindowBox; + B.ExactWarpedLatticeIX0 = CacheIX0; + B.ExactWarpedLatticeIY0 = CacheIY0; + B.ExactWarpedLatticeIZ0 = CacheIZ0; + B.ExactWarpedLatticeNX = CacheNX; + B.ExactWarpedLatticeNY = CacheNY; + B.ExactWarpedLatticeNZ = CacheNZ; + B.ExactWarpedLatticeOrigin = Ctx.LatticeOriginVoxels; + B.ExactWarpedLatticeStep = Ctx.Step; + B.ExactWarpedLatticeSeed = SeedU; + B.ExactWarpedLatticeFingerprint = ParamsFingerprint; + B.ExactWarpedLatticeLayout = LV; + B.ExactWarpedLatticeManagerLifetimeId = ManagerLifetimeId; + B.bExactWarpedLatticeCacheValid = true; + } + else if (B.ExactTailStates.Num() != static_cast(CacheCount)) + { + B.ExactTailStates.Init(0, static_cast(CacheCount)); + } + bCanUseWarpedQueryCache = B.bExactWarpedLatticeCacheValid; + } + } + + for (int32 IZ = IZ0; IZ <= IZ1; ++IZ) + for (int32 IY = IY0; IY <= IY1; ++IY) + for (int32 IX = IX0; IX <= IX1; ++IX) + { + const float WorldX = LatticeOriginX + (float)IX * LatticeStepXY; + const float WorldY = LatticeOriginY + (float)IY * LatticeStepXY; + const float WorldZ = static_cast(Ctx.LatticeOriginVoxels.Z) + + (float)IZ * (float)Ctx.Step; + ExactLatticeWorldQueries.Add(FVector(WorldX, WorldY, WorldZ)); + if (bCanUseWarpedQueryCache) + { + const int32 FlatIndex = + ((IZ - CacheIZ0) * CacheNY + (IY - CacheIY0)) * CacheNX + + (IX - CacheIX0); + ExactLatticeWarpedQueries.Add( + B.ExactWarpedLatticeQueries[FlatIndex]); + } + else + { + ExactLatticeWarpedQueries.Add(MakeWarpedQuery(IX, IY, IZ)); + } + const FVector& Query = ExactLatticeWarpedQueries.Last(); ExactLatticeQueryMin.X = FMath::Min(ExactLatticeQueryMin.X, (float)Query.X); ExactLatticeQueryMin.Y = FMath::Min(ExactLatticeQueryMin.Y, (float)Query.Y); ExactLatticeQueryMin.Z = FMath::Min(ExactLatticeQueryMin.Z, (float)Query.Z); @@ -4803,45 +5267,32 @@ namespace ExactLatticeQueryMax.Z = FMath::Max(ExactLatticeQueryMax.Z, (float)Query.Z); } - constexpr int32 BlockAxisSize = 2; - ExactLatticeBlocks.Reserve( - ((ExactNX + BlockAxisSize - 1) / BlockAxisSize) - * ((ExactNY + BlockAxisSize - 1) / BlockAxisSize) - * ((ExactNZ + BlockAxisSize - 1) / BlockAxisSize)); - for (int32 IZBlock = ExactIZ0; IZBlock <= IZ1; IZBlock += BlockAxisSize) - for (int32 IYBlock = ExactIY0; IYBlock <= IY1; IYBlock += BlockAxisSize) - for (int32 IXBlock = ExactIX0; IXBlock <= IX1; IXBlock += BlockAxisSize) - { - FExactLatticeBlock& Block = ExactLatticeBlocks.AddDefaulted_GetRef(); - Block.IX0 = IXBlock; - Block.IX1 = FMath::Min(IXBlock + BlockAxisSize, IX1); - Block.IY0 = IYBlock; - Block.IY1 = FMath::Min(IYBlock + BlockAxisSize, IY1); - Block.IZ0 = IZBlock; - Block.IZ1 = FMath::Min(IZBlock + BlockAxisSize, IZ1); - for (int32 IZ = Block.IZ0; IZ <= Block.IZ1; ++IZ) - for (int32 IY = Block.IY0; IY <= Block.IY1; ++IY) - for (int32 IX = Block.IX0; IX <= Block.IX1; ++IX) - { - const int32 FlatIndex = - ((IZ - ExactIZ0) * ExactNY + (IY - ExactIY0)) * ExactNX - + (IX - ExactIX0); - const FVector& Query = ExactLatticeWarpedQueries[FlatIndex]; - const FVector& World = ExactLatticeWorldQueries[FlatIndex]; - Block.WarpedMin.X = FMath::Min(Block.WarpedMin.X, (float)Query.X); - Block.WarpedMin.Y = FMath::Min(Block.WarpedMin.Y, (float)Query.Y); - Block.WarpedMin.Z = FMath::Min(Block.WarpedMin.Z, (float)Query.Z); - Block.WarpedMax.X = FMath::Max(Block.WarpedMax.X, (float)Query.X); - Block.WarpedMax.Y = FMath::Max(Block.WarpedMax.Y, (float)Query.Y); - Block.WarpedMax.Z = FMath::Max(Block.WarpedMax.Z, (float)Query.Z); - Block.WorldMin.X = FMath::Min(Block.WorldMin.X, (float)World.X); - Block.WorldMin.Y = FMath::Min(Block.WorldMin.Y, (float)World.Y); - Block.WorldMin.Z = FMath::Min(Block.WorldMin.Z, (float)World.Z); - Block.WorldMax.X = FMath::Max(Block.WorldMax.X, (float)World.X); - Block.WorldMax.Y = FMath::Max(Block.WorldMax.Y, (float)World.Y); - Block.WorldMax.Z = FMath::Max(Block.WorldMax.Z, (float)World.Z); - } - } + // One envelope contains every warped lattice point in this refined child. + // The lower-bound helpers below are valid over any enclosing AABB, so using + // one envelope removes the repeated overlapping block traversal that made a + // single LOD0 classifier visit the same primitive geometry dozens of times. + // It is deliberately looser than the old sub-block decomposition: a looser + // lower bound can only retain Mixed and send work to the mesher; it cannot + // authorize a false empty-tile skip. The exact lattice itself is unchanged. + FExactLatticeBlock& Block = ExactLatticeBlocks.AddDefaulted_GetRef(); + Block.IX0 = ExactIX0; + Block.IX1 = IX1; + Block.IY0 = ExactIY0; + Block.IY1 = IY1; + Block.IZ0 = ExactIZ0; + Block.IZ1 = IZ1; + Block.WarpedMin = ExactLatticeQueryMin; + Block.WarpedMax = ExactLatticeQueryMax; + Block.WorldMin = FVector( + LatticeOriginX + (float)ExactIX0 * LatticeStepXY, + LatticeOriginY + (float)ExactIY0 * LatticeStepXY, + (float)Ctx.LatticeOriginVoxels.Z + + (float)ExactIZ0 * (float)Ctx.Step); + Block.WorldMax = FVector( + LatticeOriginX + (float)IX1 * LatticeStepXY, + LatticeOriginY + (float)IY1 * LatticeStepXY, + (float)Ctx.LatticeOriginVoxels.Z + + (float)IZ1 * (float)Ctx.Step); } } @@ -4862,9 +5313,17 @@ namespace auto ExactLatticePointDistance = [&](const FVector& Target) -> float { float Distance = FLT_MAX; - for (const FVector& Query : ExactLatticeWarpedQueries) + // The block AABB contains every warped lattice point in the block. Its distance + // to the target is therefore a conservative lower bound for the exact point + // minimum. Do not rescan all 17^3 points for every room just to obtain this + // broad-phase value; the same 2x2x2 block lattice is already built below for the + // exact primitive walk. + for (const FExactLatticeBlock& Block : ExactLatticeBlocks) { - Distance = FMath::Min(Distance, (float)FVector::Dist(Query, Target)); + Distance = FMath::Min( + Distance, + VF_DistanceBetweenBoxes( + Block.WarpedMin, Block.WarpedMax, Target, Target)); } return FMath::Max(0.0f, Distance - 0.001f); }; @@ -4873,10 +5332,15 @@ namespace const FVector& TargetMax) -> float { float Distance = FLT_MAX; - for (const FVector& Query : ExactLatticeWarpedQueries) + // As above, the block AABB distance is no larger than the distance from the + // contained exact points to the target interval. It can lose a skip, but can + // never turn an unproven box into a proof. + for (const FExactLatticeBlock& Block : ExactLatticeBlocks) { Distance = FMath::Min( - Distance, VF_DistanceBetweenBoxes(Query, Query, TargetMin, TargetMax)); + Distance, + VF_DistanceBetweenBoxes( + Block.WarpedMin, Block.WarpedMax, TargetMin, TargetMax)); } return FMath::Max(0.0f, Distance - 0.001f); }; @@ -5074,78 +5538,155 @@ namespace : FLT_MAX; }; - // Exact lower bounds for the primitive values at the sampled points. These mirror - // EvaluateSDFCached's broad-phase culls before evaluating the same primitive, so a - // skipped point contributes the identity (FLT_MAX), not a guessed distance. The - // smooth-min fold below is monotone in both operands; folding each primitive's exact - // sampled minimum in the same order is therefore a conservative lower bound for every - // lattice sample, while being much tighter than the AABB distance. + // Conservative lower bounds for primitive values on each warped lattice block. + // Earlier versions walked every exact point in every refined child here. That was + // sound, but it made an empty LOD0 tile pay thousands of room/tunnel evaluations. + // These bounds are over the whole block AABB, which contains every warped lattice + // point in the block. They are therefore allowed to be looser than the finite-point + // minimum: looser costs a proof, never geometry. The smooth-min fold below remains + // monotone in every primitive lower bound. + auto ExactRoomShapeLower = [&](const FCachedRoom& Room, + const FExactLatticeBlock& Block) -> float + { + auto Invalid = [&]() -> float + { + bInvalidBound = true; + return 0.0f; + }; + const FVector BlockMin = Block.WarpedMin; + const FVector BlockMax = Block.WarpedMax; + if (!Finite((float)BlockMin.X) || !Finite((float)BlockMin.Y) + || !Finite((float)BlockMin.Z) || !Finite((float)BlockMax.X) + || !Finite((float)BlockMax.Y) || !Finite((float)BlockMax.Z)) + { + return Invalid(); + } + + switch (Room.ShapeType) + { + case 0: + { + // VoxelSDF::Ellipsoid uses the scaled Euclidean distance multiplied by + // the average radius. The closest scaled point to an axis-aligned box is + // obtained independently on each axis, so this is a direct lower bound for + // the exact approximation used by Eval. + const FVector& Radii = Room.ShapeA; + if (!Finite((float)Radii.X) || !Finite((float)Radii.Y) + || !Finite((float)Radii.Z) || Radii.X <= 0.0 + || Radii.Y <= 0.0 || Radii.Z <= 0.0) + { + return Invalid(); + } + const float Cx = (float)Room.Center.X; + const float Cy = (float)Room.Center.Y; + const float Cz = (float)Room.Center.Z; + const float Rx = (float)Radii.X; + const float Ry = (float)Radii.Y; + const float Rz = (float)Radii.Z; + const float DX = BlockMin.X > Cx ? BlockMin.X - Cx + : BlockMax.X < Cx ? Cx - BlockMax.X : 0.0f; + const float DY = BlockMin.Y > Cy ? BlockMin.Y - Cy + : BlockMax.Y < Cy ? Cy - BlockMax.Y : 0.0f; + const float DZ = BlockMin.Z > Cz ? BlockMin.Z - Cz + : BlockMax.Z < Cz ? Cz - BlockMax.Z : 0.0f; + const float AverageRadius = (Rx + Ry + Rz) / 3.0f; + const float ScaledDistance = FMath::Sqrt( + (DX / Rx) * (DX / Rx) + + (DY / Ry) * (DY / Ry) + + (DZ / Rz) * (DZ / Rz)); + const float Lower = (ScaledDistance - 1.0f) * AverageRadius; + return Finite(Lower) ? Lower : Invalid(); + } + case 1: + { + // The rounded-box SDF is the exact SDF of a box dilated by a sphere. The + // enclosing sphere gives a cheap, unconditional lower bound for its signed + // distance, including points inside the box. + const FVector& HalfExtent = Room.ShapeA; + const float Rounding = (float)Room.ShapeR; + if (!Finite((float)HalfExtent.X) || !Finite((float)HalfExtent.Y) + || !Finite((float)HalfExtent.Z) || !Finite(Rounding) + || HalfExtent.X < 0.0 || HalfExtent.Y < 0.0 + || HalfExtent.Z < 0.0 || Rounding < 0.0f) + { + return Invalid(); + } + const FVector OuterExtent( + (float)HalfExtent.X + Rounding, + (float)HalfExtent.Y + Rounding, + (float)HalfExtent.Z + Rounding); + const float Lower = VF_DistanceBetweenBoxes( + BlockMin, BlockMax, Room.Center, Room.Center) - OuterExtent.Size(); + return Finite(Lower) ? Lower : Invalid(); + } + case 2: + { + if (!Finite((float)Room.ShapeA.X) || !Finite((float)Room.ShapeA.Y) + || !Finite((float)Room.ShapeA.Z) || !Finite((float)Room.ShapeB.X) + || !Finite((float)Room.ShapeB.Y) || !Finite((float)Room.ShapeB.Z) + || !Finite((float)Room.ShapeR) || Room.ShapeR < 0.0f) + { + return Invalid(); + } + const float Lower = DistanceBlockToInfiniteLineLower( + Block, Room.ShapeA, Room.ShapeB) - FMath::Abs((float)Room.ShapeR); + return Finite(Lower) ? Lower : Invalid(); + } + default: + return Invalid(); + } + }; + auto ExactRoomLower = [&](const FCachedRoom& Room) -> float { float LowerBound = FLT_MAX; - const float CullRadius = FMath::Sqrt(FMath::Max(Room.CullRadiusSq, 0.0f)); + const float CullRadiusSq = Room.CullRadiusSq; + if (!Finite(CullRadiusSq) || CullRadiusSq < 0.0f) + { + bInvalidBound = true; + return LowerBound; + } + const float CullRadius = FMath::Sqrt(CullRadiusSq); for (const FExactLatticeBlock& Block : ExactLatticeBlocks) { - if (VF_DistanceBetweenBoxes( - Block.WarpedMin, Block.WarpedMax, - Room.Center, Room.Center) > CullRadius) + const float BlockDistance = VF_DistanceBetweenBoxes( + Block.WarpedMin, Block.WarpedMax, + Room.Center, Room.Center); + if (!Finite(BlockDistance)) { + bInvalidBound = true; continue; } - for (int32 IZ = Block.IZ0; IZ <= Block.IZ1; ++IZ) - for (int32 IY = Block.IY0; IY <= Block.IY1; ++IY) - for (int32 IX = Block.IX0; IX <= Block.IX1; ++IX) - { - const int32 FlatIndex = - ((IZ - ExactIZ0) * ExactNY + (IY - ExactIY0)) * ExactNX - + (IX - ExactIX0); - const FVector& Query = ExactLatticeWarpedQueries[FlatIndex]; - const float DX = (float)Query.X - (float)Room.Center.X; - const float DY = (float)Query.Y - (float)Room.Center.Y; - const float DZ = (float)Query.Z - (float)Room.Center.Z; - if (DX * DX + DY * DY + DZ * DZ > Room.CullRadiusSq) + // Every point in this block fails Eval's sphere cull when the block itself + // is farther than the cull radius. This reject is exact for that cull. + if (BlockDistance > CullRadius) { continue; } - float RoomSDF = 0.0f; - switch (Room.ShapeType) - { - case 1: - RoomSDF = VoxelSDF::RoundedBox( - Query, Room.Center, Room.ShapeA, Room.ShapeR); - break; - case 2: - RoomSDF = VoxelSDF::Capsule( - Query, Room.ShapeA, Room.ShapeB, Room.ShapeR); - break; - default: - RoomSDF = VoxelSDF::Ellipsoid( - Query, Room.Center, Room.ShapeA); - break; - } - + float RoomLower = ExactRoomShapeLower(Room, Block); + if (bInvalidBound) { continue; } if (Room.FloorCutZ > -FLT_MAX) { - float FloorZ = Room.FloorCutZ; - if (Room.FloorReliefStrength > 0.0f) + if (!Finite((float)Room.FloorCutZ) + || !Finite((float)Room.FloorReliefStrength) + || (Room.FloorReliefStrength > 0.0f + && !Finite((float)Room.FloorReliefFrequency))) { - const float RF = Room.FloorReliefFrequency; - const float SF = (float)Room.FloorSeed * 0.00001f; - float N = FMath::PerlinNoise2D( - FVector2D((float)Query.X * RF + SF, - (float)Query.Y * RF + SF * 1.7f)) * 0.65f - + FMath::PerlinNoise2D( - FVector2D((float)Query.X * RF * 2.3f + SF * 3.1f, - (float)Query.Y * RF * 2.3f + SF * 5.3f)) * 0.35f; - N *= VOXEL_NOISE_SCALE; - FloorZ += N * Room.FloorReliefStrength; + bInvalidBound = true; + continue; } - RoomSDF = VoxelSDF::SmoothMax( - RoomSDF, FloorZ - (float)Query.Z, P.SDFBlendRadius * 0.35f); - } - LowerBound = FMath::Min(LowerBound, RoomSDF); + const float ReliefBound = Room.FloorReliefStrength > 0.0f + ? FMath::Abs((float)Room.FloorReliefStrength) + * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + : 0.0f; + // SmoothMax(a,b) is never below max(a,b), so independently bounded + // shape and floor terms can be combined with an ordinary Max. + const float FloorLower = (float)Room.FloorCutZ - ReliefBound + - (float)Block.WarpedMax.Z; + RoomLower = FMath::Max(RoomLower, FloorLower); } + LowerBound = FMath::Min(LowerBound, RoomLower); } return LowerBound; }; @@ -5153,38 +5694,41 @@ namespace auto ExactJoinLower = [&](const FCachedRoomFloorJoin& Join) -> float { float LowerBound = FLT_MAX; - const float BoundRadius = FMath::Sqrt(FMath::Max(Join.BoundRadiusSq, 0.0f)); + if (!Finite((float)Join.BoundRadiusSq) || Join.BoundRadiusSq < 0.0f + || !Finite((float)Join.BoundCenter.X) + || !Finite((float)Join.BoundCenter.Y) + || !Finite((float)Join.BoundCenter.Z) + || !Finite((float)Join.Start.X) || !Finite((float)Join.Start.Y) + || !Finite((float)Join.Start.Z) || !Finite((float)Join.End.X) + || !Finite((float)Join.End.Y) || !Finite((float)Join.End.Z) + || !Finite((float)Join.Radius) || !Finite((float)Join.FloorZ) + || !Finite((float)Join.CeilingZ) || Join.Radius < 0.0f) + { + bInvalidBound = true; + return LowerBound; + } + const float BoundRadius = FMath::Sqrt(Join.BoundRadiusSq); for (const FExactLatticeBlock& Block : ExactLatticeBlocks) { - if (VF_DistanceBetweenBoxes( - Block.WarpedMin, Block.WarpedMax, - Join.BoundCenter, Join.BoundCenter) > BoundRadius) + const float BlockDistance = VF_DistanceBetweenBoxes( + Block.WarpedMin, Block.WarpedMax, + Join.BoundCenter, Join.BoundCenter); + if (BlockDistance > BoundRadius) { continue; } - for (int32 IZ = Block.IZ0; IZ <= Block.IZ1; ++IZ) - for (int32 IY = Block.IY0; IY <= Block.IY1; ++IY) - for (int32 IX = Block.IX0; IX <= Block.IX1; ++IX) - { - const int32 FlatIndex = - ((IZ - ExactIZ0) * ExactNY + (IY - ExactIY0)) * ExactNX - + (IX - ExactIX0); - const FVector& Query = ExactLatticeWarpedQueries[FlatIndex]; - const float DX = (float)Query.X - (float)Join.BoundCenter.X; - const float DY = (float)Query.Y - (float)Join.BoundCenter.Y; - const float DZ = (float)Query.Z - (float)Join.BoundCenter.Z; - if (DX * DX + DY * DY + DZ * DZ > Join.BoundRadiusSq) + const float HorizontalLower = DistanceBlockToInfiniteLineLower( + Block, Join.Start, Join.End) - FMath::Abs(Join.Radius); + const float VerticalLower = FMath::Max( + Join.FloorZ - (float)Block.WarpedMax.Z, + (float)Block.WarpedMin.Z - Join.CeilingZ); + const float Lower = FMath::Max(HorizontalLower, VerticalLower); + if (!Finite(Lower)) { + bInvalidBound = true; continue; } - const float HorizontalSDF = VoxelSDF::Capsule( - Query, Join.Start, Join.End, Join.Radius); - const float VerticalSDF = FMath::Max( - Join.FloorZ - (float)Query.Z, - (float)Query.Z - Join.CeilingZ); - LowerBound = FMath::Min( - LowerBound, FMath::Max(HorizontalSDF, VerticalSDF)); - } + LowerBound = FMath::Min(LowerBound, Lower); } return LowerBound; }; @@ -5192,16 +5736,45 @@ namespace auto ExactTunnelLower = [&](const FCachedTunnel& Tunnel) -> float { float LowerBound = FLT_MAX; - const float BoundRadius = FMath::Sqrt(FMath::Max(Tunnel.BoundRadiusSq, 0.0f)); + if (!Finite((float)Tunnel.BoundRadiusSq) + || Tunnel.BoundRadiusSq < 0.0f + || !Finite((float)Tunnel.BoundCenter.X) + || !Finite((float)Tunnel.BoundCenter.Y) + || !Finite((float)Tunnel.BoundCenter.Z) + || !Finite((float)Tunnel.SDFInfluenceRadius) + || !Finite((float)Tunnel.SDFCenterlineMin.X) + || !Finite((float)Tunnel.SDFCenterlineMin.Y) + || !Finite((float)Tunnel.SDFCenterlineMin.Z) + || !Finite((float)Tunnel.SDFCenterlineMax.X) + || !Finite((float)Tunnel.SDFCenterlineMax.Y) + || !Finite((float)Tunnel.SDFCenterlineMax.Z) + || Tunnel.SDFCenterlineMin.X > Tunnel.SDFCenterlineMax.X + || Tunnel.SDFCenterlineMin.Y > Tunnel.SDFCenterlineMax.Y + || Tunnel.SDFCenterlineMin.Z > Tunnel.SDFCenterlineMax.Z) + { + bInvalidBound = true; + return LowerBound; + } + const float BoundRadius = FMath::Sqrt(Tunnel.BoundRadiusSq); auto EvaluateSegment = [&](const FVector& A, const FVector& BPoint, float RadiusA, float RadiusB) { - // Every point in the block is at least this far from the segment's - // infinite centerline. Subtracting the largest endpoint radius is a - // conservative lower bound for the tapered capsule and lets a good result - // from an earlier segment reject the rest of this segment without changing - // the exact point walk. - const float SegmentRadius = FMath::Max(FMath::Abs(RadiusA), FMath::Abs(RadiusB)); + if (!Finite((float)A.X) || !Finite((float)A.Y) + || !Finite((float)A.Z) || !Finite((float)BPoint.X) + || !Finite((float)BPoint.Y) || !Finite((float)BPoint.Z) + || !Finite(RadiusA) || !Finite(RadiusB)) + { + bInvalidBound = true; + return; + } + const float SegmentRadius = FMath::Max( + FMath::Abs(RadiusA), FMath::Abs(RadiusB)); + const FVector SegmentMin( + FMath::Min(A.X, BPoint.X), FMath::Min(A.Y, BPoint.Y), + FMath::Min(A.Z, BPoint.Z)); + const FVector SegmentMax( + FMath::Max(A.X, BPoint.X), FMath::Max(A.Y, BPoint.Y), + FMath::Max(A.Z, BPoint.Z)); for (const FExactLatticeBlock& Block : ExactLatticeBlocks) { if (Tunnel.SDFInfluenceRadius > 0.0f @@ -5215,36 +5788,27 @@ namespace if (VF_DistanceBetweenBoxes( Block.WarpedMin, Block.WarpedMax, Tunnel.BoundCenter, Tunnel.BoundCenter) - > BoundRadius) + > BoundRadius) { continue; } - if (FMath::IsFinite(LowerBound) - && DistanceBlockToInfiniteLineLower(Block, A, BPoint) - - SegmentRadius >= LowerBound) + + // Both terms are lower bounds for distance to the finite segment: + // the first uses the infinite supporting line, while the second uses + // the segment's AABB. Their maximum is still a lower bound and is much + // tighter near a segment endpoint than either term alone. + const float LineLower = DistanceBlockToInfiniteLineLower( + Block, A, BPoint); + const float SegmentBoxLower = VF_DistanceBetweenBoxes( + Block.WarpedMin, Block.WarpedMax, SegmentMin, SegmentMax); + const float Lower = FMath::Max(LineLower, SegmentBoxLower) + - SegmentRadius; + if (!Finite(Lower)) { + bInvalidBound = true; continue; } - for (int32 IZ = Block.IZ0; IZ <= Block.IZ1; ++IZ) - for (int32 IY = Block.IY0; IY <= Block.IY1; ++IY) - for (int32 IX = Block.IX0; IX <= Block.IX1; ++IX) - { - const int32 FlatIndex = - ((IZ - ExactIZ0) * ExactNY + (IY - ExactIY0)) * ExactNX - + (IX - ExactIX0); - const FVector& Query = ExactLatticeWarpedQueries[FlatIndex]; - const float DX = (float)Query.X - (float)Tunnel.BoundCenter.X; - const float DY = (float)Query.Y - (float)Tunnel.BoundCenter.Y; - const float DZ = (float)Query.Z - (float)Tunnel.BoundCenter.Z; - if (DX * DX + DY * DY + DZ * DZ > Tunnel.BoundRadiusSq) - { - continue; - } - LowerBound = FMath::Min( - LowerBound, - VoxelSDF::TaperedCapsule( - Query, A, BPoint, RadiusA, RadiusB)); - } + LowerBound = FMath::Min(LowerBound, Lower); } }; @@ -5413,16 +5977,197 @@ namespace B.NumRooms = GetBoxCache().Rooms.Num(); B.NumTunnels = GetBoxCache().Tunnels.Num(); + B.NumRoomFloorJoins = GetBoxCache().RoomFloorJoins.Num(); B.NumPits = GetBoxCache().Pits.Num(); B.NumChimneys = GetBoxCache().Chimneys.Num(); B.HitRooms = B.HitTunnels = B.HitPits = B.HitChimneys = 0; B.HitRoomsNoWarp = B.HitTunnelsNoWarp = 0; B.WarpDilation = FMath::Max3( (float)WarpEnvelope.X, (float)WarpEnvelope.Y, (float)WarpEnvelope.Z); + B.bMayHaveTunnelCoreAir = false; + B.bMayHaveTunnelSupportFloor = false; + B.bMayHaveTunnelCoreTail = false; + + auto MayHaveTunnelCoreTail = [&](const FCachedTunnel& Tunnel) -> uint8 + { + const bool bHasWorldChain = Tunnel.WorldControlPoints.Num() >= 2 + && Tunnel.WorldControlRadii.Num() == Tunnel.WorldControlPoints.Num(); + const FVector& CenterlineMin = bHasWorldChain + ? Tunnel.WorldCenterlineMin : Tunnel.SDFCenterlineMin; + const FVector& CenterlineMax = bHasWorldChain + ? Tunnel.WorldCenterlineMax : Tunnel.SDFCenterlineMax; + const float InfluenceRadius = bHasWorldChain + ? Tunnel.WorldInfluenceRadius : Tunnel.SDFInfluenceRadius; + const FVector& BoundCenter = bHasWorldChain + ? Tunnel.WorldBoundCenter : Tunnel.BoundCenter; + const float BoundRadiusSq = bHasWorldChain + ? Tunnel.WorldBoundRadiusSq : Tunnel.BoundRadiusSq; + if (!Finite((float)CenterlineMin.X) || !Finite((float)CenterlineMin.Y) + || !Finite((float)CenterlineMin.Z) || !Finite((float)CenterlineMax.X) + || !Finite((float)CenterlineMax.Y) || !Finite((float)CenterlineMax.Z) + || !Finite(InfluenceRadius) || InfluenceRadius < 0.0f + || !Finite((float)BoundCenter.X) || !Finite((float)BoundCenter.Y) + || !Finite((float)BoundCenter.Z) || !Finite(BoundRadiusSq) + || BoundRadiusSq < 0.0f) + { + return 3u; + } + + // A post-air sample must lie within a tapered capsule's maximum radius of one + // world-chain segment. The segment AABB expanded by that radius is a cheap + // superset of the exact capsule; unlike the old enclosing sphere it rejects a + // long, thin tunnel from most unrelated LOD0 boxes. Support uses its own + // projected-floor AABB, because AllAir is killed by the finite floor band. + if (Ctx.bUseLatticeProof) + { + const float WorldLatticeOriginX = + static_cast(Ctx.LatticeOriginVoxels.X); + const float WorldLatticeOriginY = + static_cast(Ctx.LatticeOriginVoxels.Y); + const float WorldLatticeOriginZ = + static_cast(Ctx.LatticeOriginVoxels.Z); + const float WorldLatticeStep = static_cast(Ctx.Step); + if (!(WorldLatticeStep > 0.0f)) { return 3u; } + + auto TouchesLatticeAABB = [&](float MinX, float MaxX, + float MinY, float MaxY, + float MinZ, float MaxZ) -> bool + { + return VF_LatticeAxisDistanceToInterval( + BoxMinX, BoxMaxX, WorldLatticeOriginX, WorldLatticeStep, + MinX, MaxX, 0.0f) <= 0.0f + && VF_LatticeAxisDistanceToInterval( + BoxMinY, BoxMaxY, WorldLatticeOriginY, WorldLatticeStep, + MinY, MaxY, 0.0f) <= 0.0f + && VF_LatticeAxisDistanceToInterval( + BoxMinZ, BoxMaxZ, WorldLatticeOriginZ, WorldLatticeStep, + MinZ, MaxZ, 0.0f) <= 0.0f; + }; + + uint8 Result = 0u; + auto AddSegment = [&](const FVector& A, const FVector& BPoint, + float RadiusA, float RadiusB, + bool bWorldChain) + { + if (!Finite((float)A.X) || !Finite((float)A.Y) + || !Finite((float)A.Z) || !Finite((float)BPoint.X) + || !Finite((float)BPoint.Y) || !Finite((float)BPoint.Z) + || !Finite(RadiusA) || !Finite(RadiusB)) + { + Result = 3u; + return; + } + const float SafeRadiusA = FMath::Abs(RadiusA); + const float SafeRadiusB = FMath::Abs(RadiusB); + const float MaxRadius = FMath::Max(SafeRadiusA, SafeRadiusB); + if (!Finite(MaxRadius)) { Result = 3u; return; } + + if (TouchesLatticeAABB( + FMath::Min(A.X, BPoint.X) - MaxRadius, + FMath::Max(A.X, BPoint.X) + MaxRadius, + FMath::Min(A.Y, BPoint.Y) - MaxRadius, + FMath::Max(A.Y, BPoint.Y) + MaxRadius, + FMath::Min(A.Z, BPoint.Z) - MaxRadius, + FMath::Max(A.Z, BPoint.Z) + MaxRadius)) + { + Result |= 1u; + } + + if (bWorldChain) + { + const float FloorA = VoxelPassageGeometry::TunnelFloorZ( + A, SafeRadiusA); + const float FloorB = VoxelPassageGeometry::TunnelFloorZ( + BPoint, SafeRadiusB); + const float SupportRadius = FMath::Max( + FMath::Min(SafeRadiusA, SafeRadiusB) - 0.5f, + VoxelPassageGeometry::PlayerRadiusVoxels); + if (!Finite(FloorA) || !Finite(FloorB) + || !Finite(SupportRadius)) + { + Result = 3u; + return; + } + if (TouchesLatticeAABB( + FMath::Min(A.X, BPoint.X) - SupportRadius, + FMath::Max(A.X, BPoint.X) + SupportRadius, + FMath::Min(A.Y, BPoint.Y) - SupportRadius, + FMath::Max(A.Y, BPoint.Y) + SupportRadius, + FMath::Min(FloorA, FloorB) + - VoxelPassageGeometry::LandingFloorThicknessVoxels, + FMath::Max(FloorA, FloorB) + + VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels)) + { + Result |= 2u; + } + } + }; + + if (bHasWorldChain) + { + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Tunnel.WorldControlPoints.Num(); + ++SegmentIndex) + { + AddSegment( + Tunnel.WorldControlPoints[SegmentIndex], + Tunnel.WorldControlPoints[SegmentIndex + 1], + Tunnel.WorldControlRadii[SegmentIndex], + Tunnel.WorldControlRadii[SegmentIndex + 1], true); + if (Result == 3u) { return Result; } + } + } + else if (Tunnel.ControlPoints.Num() >= 2 + && Tunnel.ControlRadii.Num() == Tunnel.ControlPoints.Num()) + { + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Tunnel.ControlPoints.Num(); + ++SegmentIndex) + { + AddSegment( + Tunnel.ControlPoints[SegmentIndex], + Tunnel.ControlPoints[SegmentIndex + 1], + Tunnel.ControlRadii[SegmentIndex], + Tunnel.ControlRadii[SegmentIndex + 1], false); + if (Result == 3u) { return Result; } + } + } + else if (Tunnel.bHasMidpoint) + { + AddSegment(Tunnel.EndpointA, Tunnel.Midpoint, + Tunnel.RadiusA, Tunnel.RadiusMid, false); + AddSegment(Tunnel.Midpoint, Tunnel.EndpointB, + Tunnel.RadiusMid, Tunnel.RadiusB, false); + } + else + { + AddSegment(Tunnel.EndpointA, Tunnel.EndpointB, + Tunnel.RadiusA, Tunnel.RadiusB, false); + } + return Result; + } + + if (InfluenceRadius > 0.0f + && VF_DistanceBetweenBoxes( + VoxelBox.Min, VoxelBox.Max, CenterlineMin, CenterlineMax) + > InfluenceRadius) + { + return 0u; + } + if (BoundRadiusSq > 0.0f + && VF_DistanceBetweenBoxes( + VoxelBox.Min, VoxelBox.Max, BoundCenter, BoundCenter) + > FMath::Sqrt(BoundRadiusSq)) + { + return 0u; + } + return 3u; + }; const FVector NoWarpMin(BoxMinX, BoxMinY, EffectiveMinZ); const FVector NoWarpMax(BoxMaxX, BoxMaxY, EffectiveMaxZ); + const uint64 ExactPrimitiveStartCycles = FPlatformTime::Cycles64(); for (const FCachedRoom& Room : GetBoxCache().Rooms) { const float CullRadiusSq = Room.CullRadiusSq; @@ -5472,6 +6217,13 @@ namespace for (const FCachedTunnel& Tunnel : GetBoxCache().Tunnels) { + const uint8 TailMask = MayHaveTunnelCoreTail(Tunnel); + B.bMayHaveTunnelCoreAir = B.bMayHaveTunnelCoreAir + || (TailMask & 1u) != 0; + B.bMayHaveTunnelSupportFloor = B.bMayHaveTunnelSupportFloor + || (TailMask & 2u) != 0; + B.bMayHaveTunnelCoreTail = B.bMayHaveTunnelCoreTail + || TailMask != 0u; float MaxRadius = FMath::Max( FMath::Abs(Tunnel.RadiusA), FMath::Abs(Tunnel.RadiusB)); float TunnelLower = FLT_MAX; @@ -5611,6 +6363,165 @@ namespace CountThreshold(TunnelLowerNoWarp, B.HitTunnelsNoWarp); } + if ((B.bMayHaveTunnelCoreAir || B.bMayHaveTunnelSupportFloor) + && bUseLatticeProof + && ExactLatticeWorldQueries.Num() > 0) + { + // The bound test above is only a broad candidate. The actual post is a + // discrete writer: it opens a point when the world-core SDF is below the inset + // without a support floor, or makes it solid in the support-floor band. Keep the + // two polarities separate: an AllSolid box only needs air, while an AllAir box + // only needs support. This is still a proof of the final lattice, never a density + // shortcut; if an exact query is invalid, keep both conservative candidates. + bool bExactAir = false; + bool bExactSupport = false; + bool bExactTailUnknown = false; + TArray> ExactTailDomains; + bool bExactTailDomainCullSafe = true; + const uint64 ExactTailStartCycles = FPlatformTime::Cycles64(); + for (const FCachedTunnel& Tunnel : GetBoxCache().Tunnels) + { + const bool bHasWorldChain = Tunnel.WorldControlPoints.Num() >= 2 + && Tunnel.WorldControlRadii.Num() == Tunnel.WorldControlPoints.Num(); + const FVector& CenterlineMin = bHasWorldChain + ? Tunnel.WorldCenterlineMin : Tunnel.SDFCenterlineMin; + const FVector& CenterlineMax = bHasWorldChain + ? Tunnel.WorldCenterlineMax : Tunnel.SDFCenterlineMax; + const float InfluenceRadius = bHasWorldChain + ? Tunnel.WorldInfluenceRadius : Tunnel.SDFInfluenceRadius; + // EvaluateTunnel's first cull is exactly the distance to this AABB. Turning + // it into a point-in-expanded-box test lets the exact tail reject points + // before it builds a support column or scans every tunnel. A disabled, + // inverted, or non-finite bound cannot authorize a reject: fall back to the + // existing fully conservative evaluator for the whole query set. + if (!Finite((float)CenterlineMin.X) || !Finite((float)CenterlineMin.Y) + || !Finite((float)CenterlineMin.Z) || !Finite((float)CenterlineMax.X) + || !Finite((float)CenterlineMax.Y) || !Finite((float)CenterlineMax.Z) + || CenterlineMin.X > CenterlineMax.X + || CenterlineMin.Y > CenterlineMax.Y + || CenterlineMin.Z > CenterlineMax.Z + || !Finite(InfluenceRadius) || InfluenceRadius <= 0.0f) + { + bExactTailDomainCullSafe = false; + break; + } + const FVector Radius(InfluenceRadius, InfluenceRadius, InfluenceRadius); + ExactTailDomains.Emplace(CenterlineMin - Radius, CenterlineMax + Radius); + } + ForEachExactQueryInWorldBox( + VoxelBox.Min, VoxelBox.Max, + [&](const FVector&, const FVector& World) + { + if ((bExactAir && bExactSupport) || bExactTailUnknown) return; + ++B.ExactTailQueries; + if (bExactTailDomainCullSafe && ExactTailDomains.Num() > 0) + { + bool bInsideTailDomain = false; + for (const FBox& Domain : ExactTailDomains) + { + if (World.X >= Domain.Min.X && World.X <= Domain.Max.X + && World.Y >= Domain.Min.Y && World.Y <= Domain.Max.Y + && World.Z >= Domain.Min.Z && World.Z <= Domain.Max.Z) + { + bInsideTailDomain = true; + break; + } + } + if (!bInsideTailDomain) return; + } + + uint8* CachedTailState = nullptr; + if (B.bExactWarpedLatticeCacheValid + && B.ExactTailStates.Num() == B.ExactWarpedLatticeQueries.Num()) + { + const int32 TailIX = FMath::RoundToInt( + (World.X - LatticeOriginX) / LatticeStepXY); + const int32 TailIY = FMath::RoundToInt( + (World.Y - LatticeOriginY) / LatticeStepXY); + const int32 TailIZ = FMath::RoundToInt( + (World.Z - static_cast(Ctx.LatticeOriginVoxels.Z)) + / static_cast(Ctx.Step)); + if (TailIX >= B.ExactWarpedLatticeIX0 + && TailIX < B.ExactWarpedLatticeIX0 + B.ExactWarpedLatticeNX + && TailIY >= B.ExactWarpedLatticeIY0 + && TailIY < B.ExactWarpedLatticeIY0 + B.ExactWarpedLatticeNY + && TailIZ >= B.ExactWarpedLatticeIZ0 + && TailIZ < B.ExactWarpedLatticeIZ0 + B.ExactWarpedLatticeNZ) + { + const int32 TailIndex = + ((TailIZ - B.ExactWarpedLatticeIZ0) + * B.ExactWarpedLatticeNY + + (TailIY - B.ExactWarpedLatticeIY0)) + * B.ExactWarpedLatticeNX + + (TailIX - B.ExactWarpedLatticeIX0); + CachedTailState = &B.ExactTailStates[TailIndex]; + } + } + if (CachedTailState != nullptr && (*CachedTailState & 0x80u) != 0) + { + const uint8 State = *CachedTailState; + if ((State & 0x04u) != 0) + { + bExactTailUnknown = true; + } + else + { + bExactSupport |= (State & 0x02u) != 0; + bExactAir |= (State & 0x01u) != 0; + } + return; + } + + FTunnelSupportFloorColumn EmptySupportColumn; + const FTunnelSupportFloorColumn* SupportColumn = nullptr; + const bool bIntegerXY = + World.X == FMath::FloorToFloat(static_cast(World.X)) + && World.Y == FMath::FloorToFloat(static_cast(World.Y)); + if (bIntegerXY) + { + SupportColumn = GetSupportColumn(World); + } + else + { + VoxelCaveMorphology::BuildTunnelSupportFloorColumn( + static_cast(World.X), static_cast(World.Y), + GetBoxCache(), EmptySupportColumn); + SupportColumn = &EmptySupportColumn; + } + const FTunnelCoreWorldEvaluation Evaluation = + VoxelCaveMorphology::EvaluateTunnelCoreWorld( + static_cast(World.X), static_cast(World.Y), + static_cast(World.Z), GetBoxCache(), SupportColumn); + ++B.ExactTailEvaluated; + if (!FMath::IsFinite(Evaluation.SDF)) + { + bExactTailUnknown = true; + if (CachedTailState != nullptr) { *CachedTailState = 0x84u; } + } + else + { + const bool bAir = !Evaluation.bSupportFloor + && Evaluation.SDF + < -VoxelPassageGeometry::CaveTunnelAirCoreInsetVoxels; + bExactSupport |= Evaluation.bSupportFloor; + bExactAir |= bAir; + if (CachedTailState != nullptr) + { + *CachedTailState = static_cast( + 0x80u | (Evaluation.bSupportFloor ? 0x02u : 0u) + | (bAir ? 0x01u : 0u)); + } + } + }); + B.ExactTailCycles = FPlatformTime::Cycles64() - ExactTailStartCycles; + if (!bExactTailUnknown) + { + B.bMayHaveTunnelCoreAir = bExactAir; + B.bMayHaveTunnelSupportFloor = bExactSupport; + B.bMayHaveTunnelCoreTail = bExactAir || bExactSupport; + } + } + for (const FCachedPit& Pit : GetBoxCache().Pits) { if (!(BoxMinZ < Pit.TopZ + Pit.BlendK) @@ -5654,17 +6565,7 @@ namespace if (bUseExactLatticeWarp) { ConsiderExact(ChimneyLower, Chimney.BlendK); } CountThreshold(ChimneyLower, B.HitChimneys); } - - if (bTask4SourceProbe) - { - UE_LOG(LogTemp, Warning, - TEXT("[Task4SourceProbe] box=(%.0f,%.0f,%.0f)-(%.0f,%.0f,%.0f) exact=%d queries=%d rooms=%d joins=%d tunnels=%d ms=%.3f"), - BoxMinX, BoxMinY, BoxMinZ, BoxMaxX, BoxMaxY, BoxMaxZ, - bUseExactLatticeWarp ? 1 : 0, ExactLatticeWarpedQueries.Num(), - GetBoxCache().Rooms.Num(), GetBoxCache().RoomFloorJoins.Num(), GetBoxCache().Tunnels.Num(), - FPlatformTime::ToMilliseconds64( - FPlatformTime::Cycles64() - Task4SourceStartCycles)); - } + B.ExactPrimitiveCycles = FPlatformTime::Cycles64() - ExactPrimitiveStartCycles; if (bInvalidBound) { @@ -5700,24 +6601,8 @@ namespace B.KeyLatticeOrigin = Ctx.LatticeOriginVoxels; B.KeyLatticeStep = Ctx.Step; B.bValid = true; - if (Ctx.bUseLatticeProof - && Ctx.LatticeOriginVoxels == FIntVector(32, 0, -32) - && Ctx.Step == 1) - { - UE_LOG(LogTemp, Warning, - TEXT("[Task4RoomBoxProbe] box=(%.0f,%.0f,%.0f)-(%.0f,%.0f,%.0f) exact=%d queries=%d rooms=%d joins=%d tunnels=%d pits=%d chimneys=%d lower=%.6g"), - BoxMinX, BoxMinY, BoxMinZ, BoxMaxX, BoxMaxY, BoxMaxZ, - bUseExactLatticeWarp ? 1 : 0, ExactLatticeWarpedQueries.Num(), - GetBoxCache().Rooms.Num(), GetBoxCache().RoomFloorJoins.Num(), GetBoxCache().Tunnels.Num(), - GetBoxCache().Pits.Num(), GetBoxCache().Chimneys.Num(), - bUseExactLatticeWarp ? ExactSdfLower : Lower); - UE_LOG(LogTemp, Warning, - TEXT("[Task4RoomCheapProbe] cheap=%.6g exact=%.6g threshold=%.6g refine=%d,%d,%d,%d,%d"), - CheapSdfLower, ExactSdfLower, ThresholdWithBlend, - ExactRoomRefinements, ExactJoinRefinements, ExactTunnelRefinements, - ExactPitRefinements, ExactChimneyRefinements); - } InOut = Own; + B.PropagateCycles = FPlatformTime::Cycles64() - PropagateStartCycles; } const TCHAR* DebugName() const override { return TEXT("RoomGraphSource"); } @@ -7788,6 +8673,20 @@ VoxelDensityOps::FRoomBoxDiagnostic VoxelDensityOps::GetLastRoomBoxDiagnostic() D.HitRoomsNoWarp = B.HitRoomsNoWarp; D.HitTunnelsNoWarp = B.HitTunnelsNoWarp; D.WarpDilation = B.WarpDilation; + D.bMayHaveTunnelCoreAir = B.bMayHaveTunnelCoreAir; + D.bMayHaveTunnelSupportFloor = B.bMayHaveTunnelSupportFloor; + D.bMayHaveTunnelCoreTail = B.bMayHaveTunnelCoreTail; + D.ExactTailQueries = B.ExactTailQueries; + D.ExactTailEvaluated = B.ExactTailEvaluated; + D.ExactTailCycles = B.ExactTailCycles; + D.PropagateCycles = B.PropagateCycles; + D.ExactPrimitiveCycles = B.ExactPrimitiveCycles; + D.CacheWindowCycles = B.CacheWindowCycles; + D.NumRooms = B.NumRooms; + D.NumTunnels = B.NumTunnels; + D.NumRoomFloorJoins = B.NumRoomFloorJoins; + D.NumPits = B.NumPits; + D.NumChimneys = B.NumChimneys; return D; } @@ -7836,6 +8735,68 @@ void VoxelDensityOps::ReportWorkerRoomGraphCacheFootprint() Breakdown); } +void VoxelDensityOps::ReportSharedRoomGraphCacheFootprint() +{ + uint64 ReadyEntries = 0; + uint64 BuildingEntries = 0; + uint64 LargestEntryBytes = 0; + uint64 CacheBytes = 0; + { + FScopeLock Lock(&GSharedRoomGraphCacheMutex); + for (const TSharedPtr& Entry + : GSharedRoomGraphCache) + { + if (Entry->bBuilding) + { + ++BuildingEntries; + } + else + { + ++ReadyEntries; + LargestEntryBytes = FMath::Max(LargestEntryBytes, Entry->AllocatedBytes); + CacheBytes += Entry->AllocatedBytes; + } + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeSharedCacheMemory] entries=%d ready=%llu building=%llu " + "resident_bytes=%llu resident_mib=%.2f budget_bytes=%llu " + "largest_entry_bytes=%llu"), + GSharedRoomGraphCache.Num(), + static_cast(ReadyEntries), + static_cast(BuildingEntries), + static_cast(CacheBytes), + static_cast(CacheBytes) / (1024.0 * 1024.0), + static_cast(SharedRoomGraphCacheBudgetBytes), + static_cast(LargestEntryBytes)); + } +} + +void VoxelDensityOps::TrimSharedRoomGraphCache(uint64 KeepManagerLifetimeId) +{ + uint64 BeforeBytes = 0; + uint64 AfterBytes = 0; + int32 BeforeEntries = 0; + int32 AfterEntries = 0; + { + FScopeLock Lock(&GSharedRoomGraphCacheMutex); + BeforeBytes = GSharedRoomGraphCacheResidentBytes; + BeforeEntries = GSharedRoomGraphCache.Num(); + RemoveStaleSharedRoomGraphCachesLocked(KeepManagerLifetimeId); + AfterBytes = GSharedRoomGraphCacheResidentBytes; + AfterEntries = GSharedRoomGraphCache.Num(); + } + if (BeforeBytes != AfterBytes || BeforeEntries != AfterEntries) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeSharedCacheMemory] trim keep_lifetime=%llu entries=%d->%d " + "resident_bytes=%llu->%llu"), + static_cast(KeepManagerLifetimeId), + BeforeEntries, AfterEntries, + static_cast(BeforeBytes), + static_cast(AfterBytes)); + } +} + //============================================================================= // FVoxelOpStack //============================================================================= diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index cbcd840..2d22d4d 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -19,6 +19,7 @@ #include "VoxelStats.h" #include "VoxelDensityProfile.h" #include "ProfilingDebugging/CpuProfilerTrace.h" +#include "HAL/PlatformTime.h" #if WITH_EDITOR #include "VoxelStrateComposer.h" @@ -3506,76 +3507,218 @@ static EVoxelTileClass VF_ClassifyBoxRefined(const FVoxelOpStack& Stack, const FBox& VoxelBox, const FVoxelOpContext& Context, const UVoxelGenerator* Generator, - const UVoxelStrateManager* StructuralManager) + const UVoxelStrateManager* StructuralManager, + float StructuralBaseDensity = 8.0f, + FVoxelTileClassificationStats* Stats = nullptr) { // The ordinary proof stays shallow. The finite-domain warp retry is paid only by a // tile that already failed the cheap proof, and can afford one more split level because // its purpose is to discharge the LOD0 false-Mixed cases without sampling the field. const int32 MaxRefinementDepth = Context.bTightenWarpProof ? 5 : 2; - const bool bTask4TimingProbe = Context.bUseLatticeProof - && Context.LatticeOriginVoxels == FIntVector(32, 0, -32) - && Context.Step == 1; - int32 RefineNodeCount = 0; - int32 ExactLeafCount = 0; - int32 ExactSampleCount = 0; - uint64 StackClassifyCycles = 0; - uint64 ExactSampleCycles = 0; - const bool bTask4ExactProbe = Context.bUseLatticeProof - && (Context.LatticeOriginVoxels == FIntVector(-64, -64, -64) - || Context.LatticeOriginVoxels == FIntVector(-64, -96, -64)) - && Context.Step == 1; - auto ClassifyExactLatticeLeaf = [&](const FBox& Box, bool bUseFinalField) -> EVoxelTileClass + + // Exact certificates are still the mesher's lattice, never a reduced-resolution shortcut. + // Keep the samples local to this one classifier call. The old recursive fallback evaluated + // the same shared lattice vertices once per depth-3 leaf (up to 64,000 calls for one LOD0 + // tile); besides being wasteful, that made an empty tile look like a brute-force generation. + // A byte validity plane is intentional: it is transient, bounded by one tile, and must not + // become another process-wide cache. + int32 ExactCacheX0 = 0, ExactCacheY0 = 0, ExactCacheZ0 = 0; + int32 ExactCacheNX = 0, ExactCacheNY = 0, ExactCacheNZ = 0; + TArray ExactCoreValues; + TArray ExactFinalValues; + TArray ExactCoreValid; + TArray ExactFinalValid; + // A negative structural result is monotone over nested boxes. Compute it once for the root + // lattice and carry that absence through the refinement tree; otherwise every AllSolid child + // repeats the same 33³ post-air scan even though a false root result already excludes all of + // its descendants. A positive result is not cached as absent and keeps the old conservative + // per-child checks. + bool bRootPassageAirKnownAbsent = false; + bool bRootOriginAirKnownAbsent = false; + const bool bUseExactCache = Context.bUseLatticeProof && Context.Step > 0 + && [&]() + { + const float Step = static_cast(Context.Step); + const FVector Origin(static_cast(Context.LatticeOriginVoxels.X), + static_cast(Context.LatticeOriginVoxels.Y), + static_cast(Context.LatticeOriginVoxels.Z)); + ExactCacheX0 = FMath::CeilToInt((static_cast(VoxelBox.Min.X) - Origin.X) + / Step - 1.0e-4f); + ExactCacheY0 = FMath::CeilToInt((static_cast(VoxelBox.Min.Y) - Origin.Y) + / Step - 1.0e-4f); + ExactCacheZ0 = FMath::CeilToInt((static_cast(VoxelBox.Min.Z) - Origin.Z) + / Step - 1.0e-4f); + const int32 X1 = FMath::FloorToInt((static_cast(VoxelBox.Max.X) - Origin.X) + / Step + 1.0e-4f); + const int32 Y1 = FMath::FloorToInt((static_cast(VoxelBox.Max.Y) - Origin.Y) + / Step + 1.0e-4f); + const int32 Z1 = FMath::FloorToInt((static_cast(VoxelBox.Max.Z) - Origin.Z) + / Step + 1.0e-4f); + ExactCacheNX = X1 - ExactCacheX0 + 1; + ExactCacheNY = Y1 - ExactCacheY0 + 1; + ExactCacheNZ = Z1 - ExactCacheZ0 + 1; + const int64 Count = static_cast(ExactCacheNX) + * static_cast(ExactCacheNY) + * static_cast(ExactCacheNZ); + if (ExactCacheNX <= 0 || ExactCacheNY <= 0 || ExactCacheNZ <= 0 + || Count <= 0 || Count > 100000) + { + ExactCacheNX = ExactCacheNY = ExactCacheNZ = 0; + return false; + } + + const int32 NumVertices = static_cast(Count); + ExactCoreValues.SetNumUninitialized(NumVertices); + ExactFinalValues.SetNumUninitialized(NumVertices); + ExactCoreValid.Init(0, NumVertices); + ExactFinalValid.Init(0, NumVertices); + return true; + }(); + + auto GetLatticeBounds = [&](const FBox& Box, + int32& OutX0, int32& OutY0, int32& OutZ0, + int32& OutX1, int32& OutY1, int32& OutZ1, + int64& OutCount) -> bool { if (!Context.bUseLatticeProof || Context.Step <= 0) { - return EVoxelTileClass::Mixed; + return false; } - const float Step = static_cast(Context.Step); const FVector Origin(static_cast(Context.LatticeOriginVoxels.X), static_cast(Context.LatticeOriginVoxels.Y), static_cast(Context.LatticeOriginVoxels.Z)); - const int32 IX0 = FMath::CeilToInt((static_cast(Box.Min.X) - Origin.X) / Step - 1.0e-4f); - const int32 IY0 = FMath::CeilToInt((static_cast(Box.Min.Y) - Origin.Y) / Step - 1.0e-4f); - const int32 IZ0 = FMath::CeilToInt((static_cast(Box.Min.Z) - Origin.Z) / Step - 1.0e-4f); - const int32 IX1 = FMath::FloorToInt((static_cast(Box.Max.X) - Origin.X) / Step + 1.0e-4f); - const int32 IY1 = FMath::FloorToInt((static_cast(Box.Max.Y) - Origin.Y) / Step + 1.0e-4f); - const int32 IZ1 = FMath::FloorToInt((static_cast(Box.Max.Z) - Origin.Z) / Step + 1.0e-4f); - const int64 Count = ((int64)IX1 - IX0 + 1) - * ((int64)IY1 - IY0 + 1) - * ((int64)IZ1 - IZ0 + 1); - if (IX1 < IX0 || IY1 < IY0 || IZ1 < IZ0 || Count <= 0 || Count > 256) + OutX0 = FMath::CeilToInt((static_cast(Box.Min.X) - Origin.X) + / Step - 1.0e-4f); + OutY0 = FMath::CeilToInt((static_cast(Box.Min.Y) - Origin.Y) + / Step - 1.0e-4f); + OutZ0 = FMath::CeilToInt((static_cast(Box.Min.Z) - Origin.Z) + / Step - 1.0e-4f); + OutX1 = FMath::FloorToInt((static_cast(Box.Max.X) - Origin.X) + / Step + 1.0e-4f); + OutY1 = FMath::FloorToInt((static_cast(Box.Max.Y) - Origin.Y) + / Step + 1.0e-4f); + OutZ1 = FMath::FloorToInt((static_cast(Box.Max.Z) - Origin.Z) + / Step + 1.0e-4f); + OutCount = static_cast(OutX1 - OutX0 + 1) + * static_cast(OutY1 - OutY0 + 1) + * static_cast(OutZ1 - OutZ0 + 1); + return OutX1 >= OutX0 && OutY1 >= OutY0 && OutZ1 >= OutZ0 && OutCount > 0; + }; + + auto EvaluateExactLatticeValue = [&](int32 IX, int32 IY, int32 IZ, + bool bUseFinalField) -> float + { + const float Step = static_cast(Context.Step); + const FVector Origin(static_cast(Context.LatticeOriginVoxels.X), + static_cast(Context.LatticeOriginVoxels.Y), + static_cast(Context.LatticeOriginVoxels.Z)); + TArray& Values = bUseFinalField ? ExactFinalValues : ExactCoreValues; + TArray& Valid = bUseFinalField ? ExactFinalValid : ExactCoreValid; + int32 CacheIndex = INDEX_NONE; + if (bUseExactCache + && IX >= ExactCacheX0 && IX < ExactCacheX0 + ExactCacheNX + && IY >= ExactCacheY0 && IY < ExactCacheY0 + ExactCacheNY + && IZ >= ExactCacheZ0 && IZ < ExactCacheZ0 + ExactCacheNZ) + { + CacheIndex = (IZ - ExactCacheZ0) * ExactCacheNY * ExactCacheNX + + (IY - ExactCacheY0) * ExactCacheNX + + (IX - ExactCacheX0); + if (Valid[CacheIndex] != 0) + { + if (Stats) + { + if (bUseFinalField) { ++Stats->ExactFinalCacheHits; } + else { ++Stats->ExactCoreCacheHits; } + } + return Values[CacheIndex]; + } + } + + const float WorldX = Origin.X + static_cast(IX) * Step; + const float WorldY = Origin.Y + static_cast(IY) * Step; + const float WorldZ = Origin.Z + static_cast(IZ) * Step; + // The operator stack is the cheap proof field. A passage/landing post is applied after + // that stack by GetDensityAt; for a child intersecting one of those modifiers, certify + // the final MC-facing field rather than trusting the conservative core proof. + const float Density = bUseFinalField + ? -Generator->GetDensityAt(WorldX, WorldY, WorldZ) + : Stack.EvalInternal(WorldX, WorldY, WorldZ); + if (Stats) + { + if (bUseFinalField) { ++Stats->ExactFinalSamples; } + else { ++Stats->ExactCoreSamples; } + } + if (CacheIndex != INDEX_NONE) + { + Values[CacheIndex] = Density; + Valid[CacheIndex] = 1; + } + return Density; + }; + + auto CornersHaveOneSign = [&](const FBox& Box, bool bUseFinalField) -> bool + { + int32 IX0 = 0, IY0 = 0, IZ0 = 0, IX1 = 0, IY1 = 0, IZ1 = 0; + int64 Count = 0; + if (!GetLatticeBounds(Box, IX0, IY0, IZ0, IX1, IY1, IZ1, Count)) + { + return false; + } + bool bHaveSign = false; + bool bPositive = false; + const int32 Xs[2] = { IX0, IX1 }; + const int32 Ys[2] = { IY0, IY1 }; + const int32 Zs[2] = { IZ0, IZ1 }; + for (const int32 IZ : Zs) + for (const int32 IY : Ys) + for (const int32 IX : Xs) + { + const float Density = EvaluateExactLatticeValue(IX, IY, IZ, bUseFinalField); + if (!FMath::IsFinite(Density) || Density == 0.0f) + { + return false; + } + const bool bThisPositive = Density > 0.0f; + if (!bHaveSign) + { + bHaveSign = true; + bPositive = bThisPositive; + } + else if (bPositive != bThisPositive) + { + return false; + } + } + return bHaveSign; + }; + + auto ClassifyExactLatticeLeaf = [&](const FBox& Box, bool bUseFinalField) -> EVoxelTileClass + { + const uint64 ExactStartCycles = Stats ? FPlatformTime::Cycles64() : 0; + int32 IX0 = 0, IY0 = 0, IZ0 = 0, IX1 = 0, IY1 = 0, IZ1 = 0; + int64 Count = 0; + if (!GetLatticeBounds(Box, IX0, IY0, IZ0, IX1, IY1, IZ1, Count) + || Count > 8192) { return EVoxelTileClass::Mixed; } + if (Stats) + { + if (bUseFinalField) { ++Stats->ExactFinalLeaves; } + else { ++Stats->ExactCoreLeaves; } + } bool bAllSolid = true; bool bAllAir = true; int32 PositiveCount = 0; int32 NegativeCount = 0; int32 ZeroOrInvalidCount = 0; - float MinDensity = FLT_MAX; - float MaxDensity = -FLT_MAX; for (int32 IZ = IZ0; IZ <= IZ1; ++IZ) for (int32 IY = IY0; IY <= IY1; ++IY) for (int32 IX = IX0; IX <= IX1; ++IX) { - const float WorldX = Origin.X + static_cast(IX) * Step; - const float WorldY = Origin.Y + static_cast(IY) * Step; - const float WorldZ = Origin.Z + static_cast(IZ) * Step; - // The operator stack is the cheap proof field. A passage/landing post is applied - // after that stack by GetDensityAt; for a small child that intersects one of those - // exact lattice modifiers, certify the final MC-facing field instead of trusting a - // core-stack verdict that is deliberately conservative at the post boundary. - const uint64 SampleStartCycles = bTask4TimingProbe ? FPlatformTime::Cycles64() : 0; - const float Density = bUseFinalField - ? -Generator->GetDensityAt(WorldX, WorldY, WorldZ) - : Stack.EvalInternal(WorldX, WorldY, WorldZ); - if (bTask4TimingProbe) - { - ExactSampleCycles += FPlatformTime::Cycles64() - SampleStartCycles; - ++ExactSampleCount; - } + const float Density = EvaluateExactLatticeValue(IX, IY, IZ, bUseFinalField); if (!FMath::IsFinite(Density) || Density == 0.0f) { ++ZeroOrInvalidCount; @@ -3583,8 +3726,6 @@ static EVoxelTileClass VF_ClassifyBoxRefined(const FVoxelOpStack& Stack, IY = IY1; break; } - MinDensity = FMath::Min(MinDensity, Density); - MaxDensity = FMath::Max(MaxDensity, Density); if (Density > 0.0f) { ++PositiveCount; bAllAir = false; } else { ++NegativeCount; bAllSolid = false; } if (!bAllSolid && !bAllAir) @@ -3597,56 +3738,156 @@ static EVoxelTileClass VF_ClassifyBoxRefined(const FVoxelOpStack& Stack, const EVoxelTileClass Verdict = ZeroOrInvalidCount > 0 || (PositiveCount > 0 && NegativeCount > 0) ? EVoxelTileClass::Mixed : (bAllSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir); - if (bTask4ExactProbe) + if (Stats) { - UE_LOG(LogTemp, Warning, - TEXT("[Task4ExactProbe] depth_box=(%.0f,%.0f,%.0f)-(%.0f,%.0f,%.0f) count=%lld pos=%d neg=%d zero=%d min=%.6g max=%.6g verdict=%d"), - (float)Box.Min.X, (float)Box.Min.Y, (float)Box.Min.Z, - (float)Box.Max.X, (float)Box.Max.Y, (float)Box.Max.Z, - (long long)Count, PositiveCount, NegativeCount, ZeroOrInvalidCount, - MinDensity, MaxDensity, static_cast(Verdict)); + const uint64 Elapsed = FPlatformTime::Cycles64() - ExactStartCycles; + if (bUseFinalField) { Stats->ExactFinalCycles += Elapsed; } + else { Stats->ExactCoreCycles += Elapsed; } } return Verdict; }; auto Refine = [&](auto&& Self, const FBox& Box, int32 Depth) -> EVoxelTileClass { - ++RefineNodeCount; - const uint64 StackStartCycles = bTask4TimingProbe ? FPlatformTime::Cycles64() : 0; + if (Stats) { ++Stats->RefineNodes; } + const uint64 StackStartCycles = Stats ? FPlatformTime::Cycles64() : 0; const EVoxelTileClass Whole = Stack.ClassifyBox(Box, Context); - if (bTask4TimingProbe) + if (Stats) { - StackClassifyCycles += FPlatformTime::Cycles64() - StackStartCycles; + ++Stats->StackBoxCalls; + Stats->StackBoxCycles += FPlatformTime::Cycles64() - StackStartCycles; + switch (Whole) + { + case EVoxelTileClass::Mixed: ++Stats->WholeMixedNodes; break; + case EVoxelTileClass::AllSolid: ++Stats->WholeSolidNodes; break; + case EVoxelTileClass::AllAir: ++Stats->WholeAirNodes; break; + default: break; + } + Stats->MaxRefinementDepth = FMath::Max( + Stats->MaxRefinementDepth, static_cast(Depth)); } - const bool bHasStructuralCandidate = StructuralManager != nullptr - && (StructuralManager->AnyPassageNearLattice( - Box, Context.LatticeOriginVoxels, Context.Step) - || StructuralManager->AnyPassageLandingFloorNearLattice( - Box, Context.LatticeOriginVoxels, Context.Step) - || StructuralManager->AnyOriginLandingNearLattice( - Box, Context.LatticeOriginVoxels, Context.Step) - || StructuralManager->AnyOriginLandingFloorNearLattice( - Box, Context.LatticeOriginVoxels, Context.Step)); - // A resolved core box already includes the conservative passage carve bound. Only a - // Mixed core box needs the post-disturbance/floor evaluator; applying it to every resolved - // child would turn one narrow landing into hundreds of tiny GetDensityAt walks. + // The graph stack is not the complete MC field: GetDensityAt reasserts the walkable + // tunnel core after the stack and after the disturbance/landing posts. The source's + // box audit tells us whether that post can have a tunnel candidate in this exact box. + // Keep this test local to room-graph stacks; the diagnostic is thread-local and would be + // stale for a slab/maze stack. A candidate only widens the work, never grants a skip. + if (Depth == 0 && Context.bUseLatticeProof && StructuralManager != nullptr) + { + bRootPassageAirKnownAbsent = !StructuralManager->AnyPassageAirPostNearLattice( + VoxelBox, Context.LatticeOriginVoxels, Context.Step, + StructuralBaseDensity, Context.EdgeSealThickness); + bRootOriginAirKnownAbsent = !StructuralManager->AnyOriginLandingAirNearLattice( + VoxelBox, Context.LatticeOriginVoxels, Context.Step, + StructuralBaseDensity, Context.EdgeSealThickness); + } + const bool bHasRoomGeometry = Stack.ProvidesResource(VoxelOpResources::RoomGeometry); + const VoxelDensityOps::FRoomBoxDiagnostic RoomBox = bHasRoomGeometry + ? VoxelDensityOps::GetLastRoomBoxDiagnostic() + : VoxelDensityOps::FRoomBoxDiagnostic(); + if (Stats && bHasRoomGeometry) + { + Stats->RoomTailQueries += RoomBox.ExactTailQueries; + Stats->RoomTailEvaluated += RoomBox.ExactTailEvaluated; + Stats->RoomTailCycles += RoomBox.ExactTailCycles; + Stats->RoomPropagateCycles += RoomBox.PropagateCycles; + Stats->RoomExactPrimitiveCycles += RoomBox.ExactPrimitiveCycles; + Stats->RoomCacheWindowCycles += RoomBox.CacheWindowCycles; + Stats->RoomNumRooms = RoomBox.NumRooms; + Stats->RoomNumTunnels = RoomBox.NumTunnels; + Stats->RoomNumRoomFloorJoins = RoomBox.NumRoomFloorJoins; + Stats->RoomNumPits = RoomBox.NumPits; + Stats->RoomNumChimneys = RoomBox.NumChimneys; + } + const bool bHasTunnelAirCandidate = bHasRoomGeometry + && RoomBox.bMayHaveTunnelCoreAir; + const bool bHasTunnelSupportCandidate = bHasRoomGeometry + && RoomBox.bMayHaveTunnelSupportFloor; + // The tail has two polarities. A solid-only tail cannot turn a proven solid child into + // geometry, and an air-only tail cannot turn a proven air child into geometry. Keep the + // uncertain Mixed parent conservative by descending; at the resolved children only the + // polarity that can change the answer is allowed to request a final-field certificate. + const bool bHasTunnelCoreCandidate = Whole == EVoxelTileClass::AllSolid + ? bHasTunnelAirCandidate + : Whole == EVoxelTileClass::AllAir + ? bHasTunnelSupportCandidate + : false; + // FPassageCarveOp and FOriginSpine are already in the stack. Reusing their broad spatial + // candidates here was the LOD0 bug: a passage that the stack had already proved to miss + // the sampled sign still forced a final-field refinement of every mixed parent. Only the + // writers that run after the stack remain candidates in this section: post-disturbance + // air, and support floors that can add solid. Unknown/non-finite input remains a + // candidate in each exact helper, so this separation cannot authorize a false skip. + // A Mixed core is not a verdict yet. Descend until the interval proof resolves its + // polarity; an air post matters to an AllSolid child, while a solid floor/support post + // matters to an AllAir child. Checking both writers on every Mixed ancestor was the + // remaining LOD0 classifier tax and could not improve the answer before the child split. + const bool bNeedsPostAirCandidate = Whole == EVoxelTileClass::AllSolid; + const bool bPassageAirCandidate = bNeedsPostAirCandidate + && !bRootPassageAirKnownAbsent + && StructuralManager != nullptr + && StructuralManager->AnyPassageAirPostNearLattice( + Box, Context.LatticeOriginVoxels, Context.Step, + StructuralBaseDensity, Context.EdgeSealThickness); + const bool bOriginAirCandidate = bNeedsPostAirCandidate + && !bRootOriginAirKnownAbsent + && StructuralManager != nullptr + && StructuralManager->AnyOriginLandingAirNearLattice( + Box, Context.LatticeOriginVoxels, Context.Step, + StructuralBaseDensity, Context.EdgeSealThickness); + // Floors only add solid. They matter when this core box is already AllAir; on a Mixed + // parent, descending to its children preserves the proof while avoiding a padded floor + // AABB forcing a final-field walk over every mixed ancestor. Ask the exact lattice floor + // predicate only at an AllAir child. It is conservative on malformed data and never + // grants a skip. + const bool bHasCarveCandidate = bPassageAirCandidate || bOriginAirCandidate; + const bool bHasStructuralCandidate = Whole == EVoxelTileClass::AllSolid + ? bHasCarveCandidate + : Whole == EVoxelTileClass::AllAir + && (StructuralManager != nullptr + && StructuralManager->AnyLandingFloorAtLattice( + Box, Context.LatticeOriginVoxels, Context.Step)); + // A resolved core box still needs a finite-lattice certificate when a common MC tail can + // rewrite it (passage/landing posts or the graph tunnel-core backstop). Without this, + // the interval proves the stack's "all solid" field and silently skips a core tunnel + // that GetDensityAt opens afterwards. Boxes with no tail candidate retain the cheap + // interval path. const bool bNeedsFinalField = Generator != nullptr - && bHasStructuralCandidate - && Whole == EVoxelTileClass::Mixed; - const bool bTask4RefineProbe = Context.bUseLatticeProof - && (Context.LatticeOriginVoxels == FIntVector(-96, 96, -96) - || Context.LatticeOriginVoxels == FIntVector(-32, 64, -64) - || Context.LatticeOriginVoxels == FIntVector(-64, -64, -64) - || Context.LatticeOriginVoxels == FIntVector(-64, -96, -64)) - && Context.Step == 1; - if (bTask4RefineProbe) + && (bHasStructuralCandidate || bHasTunnelCoreCandidate); + if (Stats && bNeedsFinalField) { - UE_LOG(LogTemp, Warning, - TEXT("[Task4RefineProbe] depth=%d box=(%.0f,%.0f,%.0f)-(%.0f,%.0f,%.0f) verdict=%d"), - Depth, (float)Box.Min.X, (float)Box.Min.Y, (float)Box.Min.Z, - (float)Box.Max.X, (float)Box.Max.Y, (float)Box.Max.Z, - static_cast(Whole)); + ++Stats->NeedsFinalFieldNodes; } - if (Whole != EVoxelTileClass::Mixed) + // A final-field certificate is exact on the same MC lattice as GenerateMesh. Use it for + // the small block-sized tail-risk boxes, including an already-resolved AllSolid/AllAir + // box. The common streamed tile is deliberately not a final-field sampling job: a + // coarse tile can contain thousands of MC vertices, and its proof must stay interval-only. + // Larger tail-risk boxes remain conservative Mixed until the mesher's normal sampling. + bool bAttemptedExact = false; + const float FinalFieldBoxExtent = FMath::Max3( + static_cast(Box.Max.X - Box.Min.X), + static_cast(Box.Max.Y - Box.Min.Y), + static_cast(Box.Max.Z - Box.Min.Z)); + const bool bSmallFinalFieldBox = bNeedsFinalField + && FinalFieldBoxExtent <= 8.0f; + if (bSmallFinalFieldBox) + { + int32 ExactIX0 = 0, ExactIY0 = 0, ExactIZ0 = 0; + int32 ExactIX1 = 0, ExactIY1 = 0, ExactIZ1 = 0; + int64 ExactCount = 0; + if (GetLatticeBounds(Box, ExactIX0, ExactIY0, ExactIZ0, + ExactIX1, ExactIY1, ExactIZ1, ExactCount) + && ExactCount <= 8192) + { + bAttemptedExact = true; + const EVoxelTileClass Exact = ClassifyExactLatticeLeaf(Box, true); + if (Exact != EVoxelTileClass::Mixed) + { + return Exact; + } + return EVoxelTileClass::Mixed; + } + } + + if (Whole != EVoxelTileClass::Mixed && !bNeedsFinalField) { return Whole; } @@ -3655,10 +3896,41 @@ static EVoxelTileClass VF_ClassifyBoxRefined(const FVoxelOpStack& Stack, // it is not a lower-resolution fallback and never changes GenerateMesh's sample count. // This discharges the last conservative SDF interval misses without walking another // refinement level or rebuilding the morphology bounds for every half-box. - if (Depth >= 3) + // The corner check is only a work gate. It is not a proof: matching corners still pay the + // complete exact lattice certificate, so a thin cave or a zero/invalid sample cannot be + // skipped accidentally. For a genuinely uniform LOD0 empty box this collapses the old + // 585-node/64k-sample fallback to at most the 33^3 unique lattice vertices. + // A depth-1 box still contains 17^3 MC vertices; probing its corners and then certifying + // the whole child is more expensive than the interval refinement it was meant to avoid. + // Wait until the depth-3 tail (at most 9^3 vertices) before paying an exact core walk. + const bool bCornersHaveOneSign = !bNeedsFinalField && Depth >= 3 + && CornersHaveOneSign(Box, false); + if (bCornersHaveOneSign) { - ++ExactLeafCount; - const EVoxelTileClass Exact = ClassifyExactLatticeLeaf(Box, bNeedsFinalField); + bAttemptedExact = true; + const EVoxelTileClass Exact = ClassifyExactLatticeLeaf(Box, false); + if (Exact != EVoxelTileClass::Mixed) + { + return Exact; + } + } + + // A two-voxel unresolved tail is still cheap to certify on the final MC lattice (3^3 + // points). Doing this before the last split avoids eight more interval folds for the + // localized passage/post boundary that caused the original LOD0 false-Mixed bail. + if (!bAttemptedExact && Whole == EVoxelTileClass::Mixed && !bNeedsFinalField + && Context.bUseLatticeProof && Depth >= 4 && FinalFieldBoxExtent <= 2.0f) + { + const EVoxelTileClass ExactFinal = ClassifyExactLatticeLeaf(Box, true); + if (ExactFinal != EVoxelTileClass::Mixed) + { + return ExactFinal; + } + bAttemptedExact = true; + } + if (!bAttemptedExact && Depth >= 3 && (!bNeedsFinalField || bSmallFinalFieldBox)) + { + const EVoxelTileClass Exact = ClassifyExactLatticeLeaf(Box, bSmallFinalFieldBox); if (Exact != EVoxelTileClass::Mixed) { return Exact; @@ -3674,10 +3946,24 @@ static EVoxelTileClass VF_ClassifyBoxRefined(const FVoxelOpStack& Stack, } if (Depth >= MaxRefinementDepth) { + // The operator interval can remain Mixed at a one-voxel tail even though the final + // MC-facing field is uniform after a structural post. Certify that tiny tail on the + // exact lattice before conceding the whole tile to the mesher. This is a proof over + // all corners the mesher would consume; zero/non-finite values remain Mixed. + if (Whole == EVoxelTileClass::Mixed && !bNeedsFinalField + && Context.bUseLatticeProof && FinalFieldBoxExtent <= 1.0f) + { + const EVoxelTileClass ExactFinal = ClassifyExactLatticeLeaf(Box, true); + if (ExactFinal != EVoxelTileClass::Mixed) + { + return ExactFinal; + } + } return bNeedsFinalField ? EVoxelTileClass::Mixed : Whole; } const FVector Mid = (Box.Min + Box.Max) * 0.5f; + if (Stats) { ++Stats->SplitNodes; } bool bSawSolid = false; bool bSawAir = false; for (int32 Z = 0; Z < 2; ++Z) @@ -3707,17 +3993,6 @@ static EVoxelTileClass VF_ClassifyBoxRefined(const FVoxelOpStack& Stack, return bSawSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir; }; const EVoxelTileClass Result = Refine(Refine, VoxelBox, 0); - if (bTask4TimingProbe) - { - UE_LOG(LogTemp, Warning, - TEXT("[Task4TimingProbe] origin=(%d,%d,%d) step=%d nodes=%d exact_leaves=%d samples=%d stack_ms=%.3f sample_ms=%.3f result=%d"), - Context.LatticeOriginVoxels.X, Context.LatticeOriginVoxels.Y, - Context.LatticeOriginVoxels.Z, Context.Step, - RefineNodeCount, ExactLeafCount, ExactSampleCount, - FPlatformTime::ToMilliseconds64(StackClassifyCycles), - FPlatformTime::ToMilliseconds64(ExactSampleCycles), - static_cast(Result)); - } return Result; } @@ -3728,10 +4003,12 @@ static EVoxelTileClass VF_ClassifyBoxWithWarpRetry(const FVoxelOpStack& Stack, const FBox& VoxelBox, const FVoxelOpContext& Context, const UVoxelGenerator* Generator = nullptr, - const UVoxelStrateManager* StructuralManager = nullptr) + const UVoxelStrateManager* StructuralManager = nullptr, + float StructuralBaseDensity = 8.0f, + FVoxelTileClassificationStats* Stats = nullptr) { const EVoxelTileClass Initial = VF_ClassifyBoxRefined( - Stack, VoxelBox, Context, Generator, StructuralManager); + Stack, VoxelBox, Context, Generator, StructuralManager, StructuralBaseDensity, Stats); // A Mixed fine tile is not evidence of geometry: it can be the conservative result of a // coarse operator interval. Keep the bounded retry for every LOD; skipping it recreates // the false-Mixed empty tiles that send the mesher through the expensive path. @@ -3744,11 +4021,20 @@ static EVoxelTileClass VF_ClassifyBoxWithWarpRetry(const FVoxelOpStack& Stack, FVoxelOpContext TightContext = Context; TightContext.bTightenWarpProof = true; return VF_ClassifyBoxRefined( - Stack, VoxelBox, TightContext, Generator, StructuralManager); + Stack, VoxelBox, TightContext, Generator, StructuralManager, StructuralBaseDensity, Stats); } -EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis) const +EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, + int32 Step, int32 CellsPerAxis) const { + return ClassifyTile(OriginVoxels, Step, CellsPerAxis, nullptr); +} + +EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, int32 Step, + int32 CellsPerAxis, + FVoxelTileClassificationStats* OutStats) const +{ + if (OutStats) { *OutStats = FVoxelTileClassificationStats(); } // Mêmes clamps que GenerateMesh — le verdict doit couvrir le treillis réellement échantillonné. Step = FMath::Max(1, Step); // ClassifyTile runs before GenerateMesh installs its LOD TLS. The room-graph source uses this @@ -3767,15 +4053,6 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in const int32 MinX = OriginVoxels.X, MaxX = OriginVoxels.X + CPA * Step; const int32 MinY = OriginVoxels.Y, MaxY = OriginVoxels.Y + CPA * Step; const int32 MinZ = OriginVoxels.Z, MaxZ = OriginVoxels.Z + CPA * Step; - const bool bTask4Probe = - (OriginVoxels == FIntVector(-96, 96, -96) && Step == 1) - || (OriginVoxels == FIntVector(-32, 64, -64) && Step == 1) - || (OriginVoxels == FIntVector(-64, -64, -64) && Step == 1) - || (OriginVoxels == FIntVector(-64, -96, -64) && Step == 1) - || (OriginVoxels == FIntVector(0, 32, -96) && Step == 2) - || (OriginVoxels == FIntVector(-32, -32, -32) && Step == 8) - || (OriginVoxels == FIntVector(-96, -32, -32) && Step == 16); - // Division entière PLANCHER (les coords négatives tronquent vers 0 en C++ — pas floor). auto FloorDivC = [](int32 A, int32 B) -> int32 { @@ -3893,7 +4170,11 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in RecipeContext.LatticeOriginVoxels = OriginVoxels; RecipeStack.PrepareChunk(RecipeContext); const EVoxelTileClass RecipeVerdict = - VF_ClassifyBoxWithWarpRetry(RecipeStack, TileVoxelBox, RecipeContext); + VF_ClassifyBoxWithWarpRetry(RecipeStack, TileVoxelBox, RecipeContext, + nullptr, nullptr, 8.0f, OutStats); + if (RecipeVerdict == EVoxelTileClass::Mixed) + { + } return RecipeVerdict; } @@ -3953,7 +4234,7 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in || TouchedRegions.RegionCount != ComposerRegions.RegionCount || TouchedRegions.PartitionSeed != ComposerRegions.PartitionSeed || TouchedRegions.LatticeCellSize != ComposerRegions.LatticeCellSize - || TouchedRegions.BlendWidth != ComposerRegions.BlendWidth) + || TouchedRegions.BlendWidth != ComposerRegions.BlendWidth) { return EVoxelTileClass::Mixed; } @@ -3986,7 +4267,11 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in RegionContext.LatticeOriginVoxels = OriginVoxels; RegionStack.PrepareChunk(RegionContext); const EVoxelTileClass RegionVerdict = - VF_ClassifyBoxWithWarpRetry(RegionStack, TileVoxelBox, RegionContext); + VF_ClassifyBoxWithWarpRetry(RegionStack, TileVoxelBox, RegionContext, + nullptr, nullptr, 8.0f, OutStats); + if (RegionVerdict == EVoxelTileClass::Mixed) + { + } return RegionVerdict; } @@ -4100,7 +4385,10 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in { // Retrouve (ou résout) le slot de strate — l'identité vient des bornes chunk-Z du layout. int32 TopCZ = 0, BotCZ = 0; - if (!StrateManager->GetStrateChunkZBounds(ChunkZ, TopCZ, BotCZ)) return EVoxelTileClass::Mixed; + if (!StrateManager->GetStrateChunkZBounds(ChunkZ, TopCZ, BotCZ)) + { + return EVoxelTileClass::Mixed; + } MemoSlotIdx = -1; for (int32 s = 0; s < NumSlots; ++s) { @@ -4108,7 +4396,10 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in } if (MemoSlotIdx < 0) { - if (NumSlots >= 2) return EVoxelTileClass::Mixed; // >2 strates surface dans une tuile : improbable + if (NumSlots >= 2) + { + return EVoxelTileClass::Mixed; // >2 strates surface dans une tuile : improbable + } MemoSlotIdx = NumSlots++; FSurfSlot& S = Slots[MemoSlotIdx]; S.BotChunkZ = BotCZ; @@ -4232,17 +4523,10 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in if (bInBand) { bCanAir = false; } else { S.InteriorZ.Add(Z); } } - if (!bCanSolid && !bCanAir) { return EVoxelTileClass::Mixed; } - } - - if (bTask4Probe) - { - UE_LOG(LogTemp, Warning, - TEXT("[Task4Probe] phase=z origin=(%d,%d,%d) step=%d solid=%d air=%d cave=%d gap=%d surface=%d out=%d cave_z=(%d,%d)"), - OriginVoxels.X, OriginVoxels.Y, OriginVoxels.Z, Step, - bCanSolid ? 1 : 0, bCanAir ? 1 : 0, bAnyCave ? 1 : 0, - bAnyGap ? 1 : 0, bAnySurface ? 1 : 0, bAnyOutOfLayout ? 1 : 0, - CaveMinZ, CaveMaxZ); + if (!bCanSolid && !bCanAir) + { + return EVoxelTileClass::Mixed; + } } // Out-of-layout is a constant-air field. Passage tubes and origin rooms can only carve that @@ -4284,13 +4568,6 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in { bCanAir = false; } - if (bTask4Probe) - { - UE_LOG(LogTemp, Warning, - TEXT("[Task4Probe] phase=guards origin=(%d,%d,%d) step=%d solid=%d air=%d"), - OriginVoxels.X, OriginVoxels.Y, OriginVoxels.Z, Step, - bCanSolid ? 1 : 0, bCanAir ? 1 : 0); - } // ── LE PLIAGE DE LA PILE D'OPÉRATEURS, POUR LES ARCHÉTYPES DE CAVE ── //========================================================================= // ⚠️ ERREUR ICI = TROU, PAS RÉGRESSION. Un verdict non-Mixed fait SAUTER `GenerateMesh` : pas @@ -4484,65 +4761,12 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in return EVoxelTileClass::Mixed; } TileStack.PrepareChunk(OpCtx); - const FBox CaveBox(FVector((float)MinX, (float)MinY, (float)CaveMinZ), FVector((float)MaxX, (float)MaxY, (float)CaveMaxZ)); const EVoxelTileClass StackVerdict = VF_ClassifyBoxWithWarpRetry( - TileStack, CaveBox, OpCtx, this, StrateManager); - if (bTask4Probe) - { - UE_LOG(LogTemp, Warning, - TEXT("[Task4Probe] phase=stack origin=(%d,%d,%d) step=%d verdict=%d solid=%d air=%d"), - OriginVoxels.X, OriginVoxels.Y, OriginVoxels.Z, Step, - static_cast(StackVerdict), bCanSolid ? 1 : 0, bCanAir ? 1 : 0); - } + TileStack, CaveBox, OpCtx, this, StrateManager, TileTunnel.BaseDensity, OutStats); if (StackVerdict == EVoxelTileClass::Mixed) { - if (bTask4Probe) - { - int32 SolidKiller = INDEX_NONE; - int32 AirKiller = INDEX_NONE; - const EVoxelTileClass Attributed = TileStack.ClassifyBoxAttributed( - CaveBox, OpCtx, SolidKiller, AirKiller); - UE_LOG(LogTemp, Warning, - TEXT("[Task4Probe] stack_attributed origin=(%d,%d,%d) step=%d verdict=%d solid_killer=%d:%s air_killer=%d:%s"), - OriginVoxels.X, OriginVoxels.Y, OriginVoxels.Z, Step, - static_cast(Attributed), SolidKiller, - TileStack.GetOpDebugName(SolidKiller), AirKiller, - TileStack.GetOpDebugName(AirKiller)); - - int32 FinalPositive = 0; - int32 FinalNegative = 0; - int32 FinalZeroOrInvalid = 0; - float FinalMin = FLT_MAX; - float FinalMax = -FLT_MAX; - for (int32 Z = MinZ; Z <= MaxZ; Z += Step) - for (int32 Y = MinY; Y <= MaxY; Y += Step) - for (int32 X = MinX; X <= MaxX; X += Step) - { - const float FinalDensity = GetDensityAt((float)X, (float)Y, (float)Z); - if (!FMath::IsFinite(FinalDensity) || FinalDensity == 0.0f) - { - ++FinalZeroOrInvalid; - } - else if (FinalDensity > 0.0f) - { - ++FinalPositive; - FinalMin = FMath::Min(FinalMin, FinalDensity); - FinalMax = FMath::Max(FinalMax, FinalDensity); - } - else - { - ++FinalNegative; - FinalMin = FMath::Min(FinalMin, FinalDensity); - FinalMax = FMath::Max(FinalMax, FinalDensity); - } - } - UE_LOG(LogTemp, Warning, - TEXT("[Task4Probe] final_samples origin=(%d,%d,%d) step=%d pos=%d neg=%d zero=%d min=%.6g max=%.6g"), - OriginVoxels.X, OriginVoxels.Y, OriginVoxels.Z, Step, - FinalPositive, FinalNegative, FinalZeroOrInvalid, FinalMin, FinalMax); - } INC_DWORD_STAT(STAT_VoxelForgeCaveBailStackVerdict); return EVoxelTileClass::Mixed; } @@ -4657,13 +4881,6 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in // sans point intérieur ont tué AllAir via gap/seal). Égalité = prudence → Mixed. if (bCanSolid == bCanAir) { - if (bTask4Probe) - { - UE_LOG(LogTemp, Warning, - TEXT("[Task4Probe] phase=final origin=(%d,%d,%d) step=%d verdict=0 solid=%d air=%d"), - OriginVoxels.X, OriginVoxels.Y, OriginVoxels.Z, Step, - bCanSolid ? 1 : 0, bCanAir ? 1 : 0); - } return EVoxelTileClass::Mixed; } if (bAnyCave) @@ -4675,13 +4892,6 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in if (bCanSolid) { INC_DWORD_STAT(STAT_VoxelForgeTilesOpStackSolid); } else { INC_DWORD_STAT(STAT_VoxelForgeTilesOpStackAir); } } - if (bTask4Probe) - { - UE_LOG(LogTemp, Warning, - TEXT("[Task4Probe] phase=final origin=(%d,%d,%d) step=%d verdict=%d solid=%d air=%d"), - OriginVoxels.X, OriginVoxels.Y, OriginVoxels.Z, Step, - bCanSolid ? 1 : 2, bCanSolid ? 1 : 0, bCanAir ? 1 : 0); - } return bCanSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir; } diff --git a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp index a9422fc..24bfac1 100644 --- a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp +++ b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp @@ -237,7 +237,13 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, // resolution are removed from a tile that reaches the mesher. TArray BlockSkip; int32 BlocksPerAxis = 0; - if (bUseBlockEarlyOut && !OutCaptureGrid && Step == 1) + // LOD2's 16-cell tile is the remaining false-Mixed empty case in the streamed game path. + // Its step-8 lattice can use the same proof-only 8-cell block partition as LOD0. Keep the + // gate off for steps 2/16/32: their meshing work is already smaller than repeating a full + // tile classifier per block, and this preserves the established coarse-LOD latency profile. + const bool bUseProofBlocks = bUseBlockEarlyOut && !OutCaptureGrid + && (Step == 1 || Step == 8); + if (bUseProofBlocks) { constexpr int32 BlockCells = 8; BlocksPerAxis = (CellsPerAxis + BlockCells - 1) / BlockCells; @@ -246,6 +252,7 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, EVoxelTileClass FirstUniformVerdict = EVoxelTileClass::Mixed; bool bAllRelevantBlocksUniform = true; bool bAllRelevantBlocksSame = true; + TArray MixedBlockIndices; for (int32 BlockZ = 0; BlockZ < BlocksPerAxis; ++BlockZ) { @@ -271,17 +278,22 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, } const EVoxelTileClass Verdict = Generator->ClassifyTile( BlockOrigin, Step, BlockCells); + if (Verdict == EVoxelTileClass::Mixed) + { + MixedBlockIndices.Add( + ((BlockZ * BlocksPerAxis) + BlockY) * BlocksPerAxis + BlockX); + } if (Verdict == EVoxelTileClass::AllSolid || Verdict == EVoxelTileClass::AllAir) { - BlockSkip[((BlockZ * BlocksPerAxis) + BlockY) * BlocksPerAxis + BlockX] = 1; - if (VoxelDensityProfile::AreCountersEnabled()) - { - VoxelDensityProfile::AddCounter( - Verdict == EVoxelTileClass::AllSolid - ? VoxelDensityProfile::ECounter::MesherBlockAllSolid - : VoxelDensityProfile::ECounter::MesherBlockAllAir); - } + // Keep the classifier result as a candidate until the exact density grid + // used by this mesher has been filled. ClassifyTile is conservative by + // contract, but its proof and the mesher share worker-local caches; a + // cache replacement must never turn that contract into a cell skip by + // itself. The post-grid sign check below is the final proof consumed by + // the cell loop and clears any candidate whose actual MC vertices disagree. + BlockSkip[((BlockZ * BlocksPerAxis) + BlockY) * BlocksPerAxis + BlockX] = + Verdict == EVoxelTileClass::AllSolid ? 1 : 2; if (!bHaveUniformBlock) { FirstUniformVerdict = Verdict; @@ -300,10 +312,84 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, } } - if (bHaveUniformBlock && bAllRelevantBlocksUniform && bAllRelevantBlocksSame) + // A conservative whole-tile proof can leave a small block unresolved even when every + // other block has the same verdict. Resolve that tail on the exact MC lattice. This uses + // the final GetDensityAt field, checks every cell corner, treats zero/non-finite values + // as unknown, and never changes the grid resolution or skips an uncertified block. + if (bHaveUniformBlock && bAllRelevantBlocksSame && !bAllRelevantBlocksUniform + && MixedBlockIndices.Num() <= 4) + { + int32 RemainingMixedBlocks = MixedBlockIndices.Num(); + for (const int32 MixedBlockIndex : MixedBlockIndices) + { + const int32 BlockX = MixedBlockIndex % BlocksPerAxis; + const int32 BlockY = (MixedBlockIndex / BlocksPerAxis) % BlocksPerAxis; + const int32 BlockZ = MixedBlockIndex / (BlocksPerAxis * BlocksPerAxis); + const FIntVector BlockOrigin = OriginVoxels + FIntVector( + BlockX * BlockCells * Step, + BlockY * BlockCells * Step, + BlockZ * BlockCells * Step); + + bool bAllSolid = true; + bool bAllAir = true; + for (int32 Z = 0; Z <= BlockCells && (bAllSolid || bAllAir); ++Z) + { + for (int32 Y = 0; Y <= BlockCells && (bAllSolid || bAllAir); ++Y) + { + for (int32 X = 0; X <= BlockCells; ++X) + { + const float Density = Generator->GetDensityAt( + BlockOrigin.X + X * Step, + BlockOrigin.Y + Y * Step, + BlockOrigin.Z + Z * Step); + if (!FMath::IsFinite(Density) || Density == 0.0f) + { + bAllSolid = false; + bAllAir = false; + break; + } + bAllSolid &= Density < 0.0f; + bAllAir &= Density > 0.0f; + if (!bAllSolid && !bAllAir) + { + break; + } + } + } + } + + const EVoxelTileClass ExactVerdict = bAllSolid + ? EVoxelTileClass::AllSolid + : (bAllAir ? EVoxelTileClass::AllAir : EVoxelTileClass::Mixed); + if (ExactVerdict == EVoxelTileClass::AllSolid + || ExactVerdict == EVoxelTileClass::AllAir) + { + BlockSkip[MixedBlockIndex] = + ExactVerdict == EVoxelTileClass::AllSolid ? 1 : 2; + --RemainingMixedBlocks; + if (ExactVerdict != FirstUniformVerdict) + { + bAllRelevantBlocksSame = false; + } + } + } + bAllRelevantBlocksUniform = RemainingMixedBlocks == 0; + } + + // Every relevant block has independently proved the same sign. This is a complete + // certificate for the tile's MC lattice: the block boxes share their boundary vertices, + // so equal signs on both sides cannot hide a surface between blocks. The whole-tile + // ClassifyTile path already relies on this same non-Mixed proof contract. Return before + // allocating the density grid; no samples are skipped for a tile that can contain + // geometry, and the final geometry remains byte-identical because this path emits none. + if (bHaveUniformBlock && bAllRelevantBlocksSame && bAllRelevantBlocksUniform) { return MeshData; } + + // A mixed or differently signed block set still needs the exact mesher grid. Classifier + // results remain candidates only in that case; the grid validation below keeps the cell + // skip independent of worker-local cache state and preserves geometry byte-for-byte. } // Réutilise le tampon entre tuiles (thread_local) : SetNumUninitialized garde la @@ -364,6 +450,69 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, } MesherDensityGridTimer.End(); + if (bUseProofBlocks) + { + // Finalize every block candidate against the exact density samples that the marching- + // cubes loop will consume. This is intentionally redundant with ClassifyTile: it is a + // cheap soundness fence around the proof/cache hand-off. Unknown, zero, or opposite-sign + // vertices clear the candidate, so an unproven block pays the ordinary cell path. + constexpr int32 BlockCells = 8; + for (int32 BlockZ = 0; BlockZ < BlocksPerAxis; ++BlockZ) + { + const int32 BlockMinZ = BlockZ * BlockCells; + const int32 BlockMaxZ = FMath::Min(CellsPerAxis - 1, + BlockMinZ + BlockCells - 1); + if (BlockMaxZ < CzLo || BlockMinZ > CzHi) + { + continue; + } + for (int32 BlockY = 0; BlockY < BlocksPerAxis; ++BlockY) + for (int32 BlockX = 0; BlockX < BlocksPerAxis; ++BlockX) + { + const int32 BlockIndex = + ((BlockZ * BlocksPerAxis) + BlockY) * BlocksPerAxis + BlockX; + const uint8 Candidate = BlockSkip[BlockIndex]; + if (Candidate == 0) + { + continue; + } + + const int32 BlockMaxX = FMath::Min(CellsPerAxis - 1, + BlockX * BlockCells + BlockCells - 1); + const int32 BlockMaxY = FMath::Min(CellsPerAxis - 1, + BlockY * BlockCells + BlockCells - 1); + bool bUniform = true; + for (int32 Z = BlockMinZ; Z <= BlockMaxZ + 1 && bUniform; ++Z) + for (int32 Y = BlockY * BlockCells; Y <= BlockMaxY + 1 && bUniform; ++Y) + for (int32 X = BlockX * BlockCells; X <= BlockMaxX + 1; ++X) + { + const float Density = DensityGrid[ + ((Z + 1) * MDim + (Y + 1)) * MDim + (X + 1)]; + if (!FMath::IsFinite(Density) + || Density == 0.0f + || (Candidate == 1 ? Density >= 0.0f : Density <= 0.0f)) + { + bUniform = false; + break; + } + } + if (!bUniform) + { + BlockSkip[BlockIndex] = 0; + continue; + } + + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + Candidate == 1 + ? VoxelDensityProfile::ECounter::MesherBlockAllSolid + : VoxelDensityProfile::ECounter::MesherBlockAllAir); + } + } + } + } + // ── CAPTURE-DURING-MESHING ── // Si demandé et que la tuile est pleine résolution (CellsPerAxis==CHUNK_SIZE ⇒ Step==1<& Passages, + TArray>& Domains) + { + constexpr int32 MaxControlPointsForBound = 4096; + Domains.Reset(); + + auto AddDomain = [&](const FVector& Min, const FVector& Max) -> bool { - return false; + if (!FMath::IsFinite(Min.X) || !FMath::IsFinite(Min.Y) + || !FMath::IsFinite(Min.Z) || !FMath::IsFinite(Max.X) + || !FMath::IsFinite(Max.Y) || !FMath::IsFinite(Max.Z) + || Min.X > Max.X || Min.Y > Max.Y || Min.Z > Max.Z) + { + return false; + } + Domains.Add({Min, Max}); + return true; + }; + + for (const FVoxelPassage& Passage : Passages) + { + if (!FMath::IsFinite(Passage.BoundCenter.X) + || !FMath::IsFinite(Passage.BoundCenter.Y) + || !FMath::IsFinite(Passage.BoundCenter.Z) + || !FMath::IsFinite(Passage.BoundRadius) + || !FMath::IsFinite(Passage.BoundRadiusSq) + || Passage.BoundRadius < 0.0f || Passage.BoundRadiusSq < 0.0f) + { + // Invalid generated geometry is an unknown, not an empty passage set. + return false; + } + + if (Passage.ControlPoints.Num() >= 2) + { + if (Passage.ControlPoints.Num() > MaxControlPointsForBound) + { + return false; + } + const bool bTaper = Passage.ControlRadii.Num() == Passage.ControlPoints.Num(); + if (!bTaper && !FMath::IsFinite(Passage.Radius)) + { + return false; + } + for (int32 PointIndex = 0; + PointIndex < Passage.ControlPoints.Num(); ++PointIndex) + { + const FVector& Point = Passage.ControlPoints[PointIndex]; + if (!FMath::IsFinite(Point.X) || !FMath::IsFinite(Point.Y) + || !FMath::IsFinite(Point.Z)) + { + return false; + } + if (bTaper && !FMath::IsFinite(Passage.ControlRadii[PointIndex])) + { + return false; + } + } + + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Passage.ControlPoints.Num(); ++SegmentIndex) + { + const FVector& A = Passage.ControlPoints[SegmentIndex]; + const FVector& B = Passage.ControlPoints[SegmentIndex + 1]; + const float RadiusA = bTaper + ? Passage.ControlRadii[SegmentIndex] : Passage.Radius; + const float RadiusB = bTaper + ? Passage.ControlRadii[SegmentIndex + 1] : Passage.Radius; + const float Radius = FMath::Max(FMath::Abs(RadiusA), FMath::Abs(RadiusB)); + if (!FMath::IsFinite(Radius)) + { + return false; + } + if (!AddDomain( + FVector(FMath::Min(A.X, B.X) - Radius, + FMath::Min(A.Y, B.Y) - Radius, + FMath::Min(A.Z, B.Z) - Radius), + FVector(FMath::Max(A.X, B.X) + Radius, + FMath::Max(A.Y, B.Y) + Radius, + FMath::Max(A.Z, B.Z) + Radius))) + { + return false; + } + } + } + else + { + if (!FMath::IsFinite(Passage.UpperPoint.X) + || !FMath::IsFinite(Passage.UpperPoint.Y) + || !FMath::IsFinite(Passage.UpperPoint.Z) + || !FMath::IsFinite(Passage.LowerPoint.X) + || !FMath::IsFinite(Passage.LowerPoint.Y) + || !FMath::IsFinite(Passage.LowerPoint.Z) + || !FMath::IsFinite(Passage.Radius)) + { + return false; + } + const float Radius = FMath::Abs(Passage.Radius); + if (!AddDomain( + FVector(FMath::Min(Passage.UpperPoint.X, Passage.LowerPoint.X) - Radius, + FMath::Min(Passage.UpperPoint.Y, Passage.LowerPoint.Y) - Radius, + FMath::Min(Passage.UpperPoint.Z, Passage.LowerPoint.Z) - Radius), + FVector(FMath::Max(Passage.UpperPoint.X, Passage.LowerPoint.X) + Radius, + FMath::Max(Passage.UpperPoint.Y, Passage.LowerPoint.Y) + Radius, + FMath::Max(Passage.UpperPoint.Z, Passage.LowerPoint.Z) + Radius))) + { + return false; + } + } + + const FVoxelPassageLanding* Landings[] = { + &Passage.UpperLanding, &Passage.LowerLanding }; + for (const FVoxelPassageLanding* Landing : Landings) + { + if (!FMath::IsFinite(Landing->StandingPoint.X) + || !FMath::IsFinite(Landing->StandingPoint.Y) + || !FMath::IsFinite(Landing->FloorZ) + || !FMath::IsFinite(Landing->CeilingZ) + || !FMath::IsFinite(Landing->HalfWidth)) + { + // The canonical evaluator returns FLT_MAX for this landing. Treating it as + // unknown is safer for future evaluator changes and only gives up a skip. + return false; + } + if (Landing->HalfWidth <= 0.0f + || Landing->CeilingZ <= Landing->FloorZ) + { + continue; + } + constexpr float RoomRounding = 1.5f; + const float Height = Landing->CeilingZ - Landing->FloorZ; + const float HalfX = FMath::Max(Landing->HalfWidth - RoomRounding, 0.25f); + const float HalfZ = FMath::Max(Height * 0.5f - RoomRounding, 0.25f); + const float CenterZ = (Landing->FloorZ + Landing->CeilingZ) * 0.5f; + if (!AddDomain( + FVector(Landing->StandingPoint.X - HalfX - RoomRounding, + Landing->StandingPoint.Y - HalfX - RoomRounding, + FMath::Min(Landing->FloorZ, + CenterZ - HalfZ - RoomRounding)), + FVector(Landing->StandingPoint.X + HalfX + RoomRounding, + Landing->StandingPoint.Y + HalfX + RoomRounding, + FMath::Max(Landing->CeilingZ, + CenterZ + HalfZ + RoomRounding)))) + { + return false; + } + } } - const int32 Nearest = FMath::Clamp( - FMath::RoundToInt((Target - Origin) / static_cast(Step)), First, Last); - OutDistance = FMath::Abs( - Origin + static_cast(Nearest * Step) - Target); return true; } - static bool VF_LatticeBoxTouchesSphere( - const FBox& Box, const FIntVector& Origin, int32 Step, - const FVector& Center, float Radius) - { - float DX = 0.0f, DY = 0.0f, DZ = 0.0f; - if (!VF_LatticeAxisNearestDistance( - (float)Box.Min.X, (float)Box.Max.X, (float)Origin.X, - Step, (float)Center.X, DX) - || !VF_LatticeAxisNearestDistance( - (float)Box.Min.Y, (float)Box.Max.Y, (float)Origin.Y, - Step, (float)Center.Y, DY) - || !VF_LatticeAxisNearestDistance( - (float)Box.Min.Z, (float)Box.Max.Z, (float)Origin.Z, - Step, (float)Center.Z, DZ)) - { - return false; - } - return DX * DX + DY * DY + DZ * DZ <= FMath::Square(Radius); - } - - static bool VF_AnyPassageBoundTouchesLattice( + static bool VF_AnyPassageModifierDomainTouchesLattice( const TArray& Passages, const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step) { @@ -2219,16 +2345,38 @@ namespace return true; } - for (const FVoxelPassage& Passage : Passages) + // EvaluateModifierSDF smooth-mins every finite primitive in passage order. The polynomial + // smooth-min can dip below the smallest raw term by at most K/6 per combine (K=3 here). + // Build a conservative AABB for each actual tube/landing primitive, then pad the union by + // the worst finite dip. This is materially tighter than Passage.BoundRadius for a long, + // thin inter-strate route, while still being a pure reject gate: a miss proves only that + // the modifier cannot be < 4 at any MC lattice point. Malformed data remains Unknown. + constexpr float CarveThreshold = 4.0f; + constexpr float SmoothMinK = 3.0f; + TArray> Domains; + if (!VF_BuildPassageModifierDomains(Passages, Domains)) { - const float Reach = Passage.BoundRadius + 4.0f; - if (!FMath::IsFinite(Reach) || Reach < 0.0f) - { - // Invalid generated geometry is an unknown, not an empty passage set. - return true; - } - if (VF_LatticeBoxTouchesSphere( - VoxelBox, LatticeOrigin, Step, Passage.BoundCenter, Reach)) + return true; + } + if (Domains.Num() == 0) + { + return false; + } + const int64 DomainCount = Domains.Num(); + const float SmoothDip = static_cast(DomainCount - 1) + * (SmoothMinK / 6.0f); + const float Padding = CarveThreshold + SmoothDip; + if (!FMath::IsFinite(Padding) || Padding < 0.0f) + { + return true; + } + for (const FVoxelPassageModifierDomain& Domain : Domains) + { + if (VF_LatticeBoxTouchesAABB( + VoxelBox, LatticeOrigin, Step, + Domain.Min.X - Padding, Domain.Max.X + Padding, + Domain.Min.Y - Padding, Domain.Max.Y + Padding, + Domain.Min.Z - Padding, Domain.Max.Z + Padding)) { return true; } @@ -2236,6 +2384,426 @@ namespace return false; } + static FORCEINLINE float VF_DistanceToSegmentLowerBound( + const FVector& Position, const FVector& A, const FVector& B, float Radius) + { + const FVector AB = B - A; + const FVector AP = Position - A; + const float LenSq = FVector::DotProduct(AB, AB); + const float T = LenSq > KINDA_SMALL_NUMBER + ? FMath::Clamp(FVector::DotProduct(AP, AB) / LenSq, 0.0f, 1.0f) + : 0.0f; + const FVector Closest = A + AB * T; + return FVector::Dist(Position, Closest) - Radius; + } + + static bool VF_PointMayHavePassageCarve( + const TArray& Passages, + const FVector& Position) + { + if (!FMath::IsFinite(Position.X) || !FMath::IsFinite(Position.Y) + || !FMath::IsFinite(Position.Z)) + { + return true; + } + + constexpr float BlendK = 3.0f; + constexpr float CarveThreshold = 4.0f; + constexpr float NumericalSafetyMargin = 1.0e-3f; + float ModifierLowerBound = FLT_MAX; + for (const FVoxelPassage& Passage : Passages) + { + if (!FMath::IsFinite(Passage.BoundCenter.X) + || !FMath::IsFinite(Passage.BoundCenter.Y) + || !FMath::IsFinite(Passage.BoundCenter.Z) + || !FMath::IsFinite(Passage.BoundRadius) + || !FMath::IsFinite(Passage.BoundRadiusSq) + || Passage.BoundRadius < 0.0f || Passage.BoundRadiusSq < 0.0f) + { + return true; + } + if (FVector::DistSquared(Position, Passage.BoundCenter) > Passage.BoundRadiusSq) + { + continue; + } + + float PassageLowerBound = FLT_MAX; + if (Passage.ControlPoints.Num() >= 2) + { + if (Passage.ControlPoints.Num() > 4096) + { + return true; + } + const bool bTaper = Passage.ControlRadii.Num() == Passage.ControlPoints.Num(); + if (!bTaper && !FMath::IsFinite(Passage.Radius)) + { + return true; + } + for (int32 PointIndex = 0; + PointIndex < Passage.ControlPoints.Num(); ++PointIndex) + { + const FVector& Point = Passage.ControlPoints[PointIndex]; + if (!FMath::IsFinite(Point.X) || !FMath::IsFinite(Point.Y) + || !FMath::IsFinite(Point.Z)) + { + return true; + } + if (bTaper && !FMath::IsFinite(Passage.ControlRadii[PointIndex])) + { + return true; + } + } + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Passage.ControlPoints.Num(); ++SegmentIndex) + { + const FVector& A = Passage.ControlPoints[SegmentIndex]; + const FVector& B = Passage.ControlPoints[SegmentIndex + 1]; + const float RadiusA = bTaper + ? Passage.ControlRadii[SegmentIndex] : Passage.Radius; + const float RadiusB = bTaper + ? Passage.ControlRadii[SegmentIndex + 1] : Passage.Radius; + const float Radius = FMath::Max(FMath::Abs(RadiusA), FMath::Abs(RadiusB)); + const float SegmentLowerBound = VF_DistanceToSegmentLowerBound( + Position, A, B, Radius); + if (!FMath::IsFinite(SegmentLowerBound)) + { + return true; + } + PassageLowerBound = VoxelSDF::SmoothMin( + PassageLowerBound, SegmentLowerBound, BlendK); + } + } + else + { + if (!FMath::IsFinite(Passage.UpperPoint.X) + || !FMath::IsFinite(Passage.UpperPoint.Y) + || !FMath::IsFinite(Passage.UpperPoint.Z) + || !FMath::IsFinite(Passage.LowerPoint.X) + || !FMath::IsFinite(Passage.LowerPoint.Y) + || !FMath::IsFinite(Passage.LowerPoint.Z) + || !FMath::IsFinite(Passage.Radius)) + { + return true; + } + PassageLowerBound = VF_DistanceToSegmentLowerBound( + Position, Passage.UpperPoint, Passage.LowerPoint, + FMath::Abs(Passage.Radius)); + if (!FMath::IsFinite(PassageLowerBound)) + { + return true; + } + } + + const FVoxelPassageLanding* Landings[] = { + &Passage.UpperLanding, &Passage.LowerLanding }; + for (const FVoxelPassageLanding* Landing : Landings) + { + if (!FMath::IsFinite(Landing->StandingPoint.X) + || !FMath::IsFinite(Landing->StandingPoint.Y) + || !FMath::IsFinite(Landing->FloorZ) + || !FMath::IsFinite(Landing->CeilingZ) + || !FMath::IsFinite(Landing->HalfWidth)) + { + return true; + } + if (Landing->HalfWidth <= 0.0f + || Landing->CeilingZ <= Landing->FloorZ) + { + continue; + } + const float LandingSDF = VF_EvaluatePassageLandingSDF(Position, *Landing); + if (!FMath::IsFinite(LandingSDF)) + { + return true; + } + PassageLowerBound = VoxelSDF::SmoothMin( + PassageLowerBound, LandingSDF, BlendK); + } + + ModifierLowerBound = VoxelSDF::SmoothMin( + ModifierLowerBound, PassageLowerBound, BlendK); + } + return !FMath::IsFinite(ModifierLowerBound) + || ModifierLowerBound < CarveThreshold + NumericalSafetyMargin; + } + + static float VF_DistanceBetweenAABBs( + const FVector& MinA, const FVector& MaxA, + const FVector& MinB, const FVector& MaxB) + { + const float DX = MaxA.X < MinB.X ? MinB.X - MaxA.X + : MaxB.X < MinA.X ? MinA.X - MaxB.X : 0.0f; + const float DY = MaxA.Y < MinB.Y ? MinB.Y - MaxA.Y + : MaxB.Y < MinA.Y ? MinA.Y - MaxB.Y : 0.0f; + const float DZ = MaxA.Z < MinB.Z ? MinB.Z - MaxA.Z + : MaxB.Z < MinA.Z ? MinA.Z - MaxB.Z : 0.0f; + return FMath::Sqrt(DX * DX + DY * DY + DZ * DZ); + } + + // Exact minimum distance between a finite segment and an axis-aligned box. The squared + // distance to a box is piecewise quadratic along the segment; the only breakpoints are where + // one segment coordinate crosses one of the six box planes. This is a small, allocation-free + // lower-bound primitive for the passage proof, not a change to the runtime SDF evaluator. + static bool VF_SegmentDistanceToBox( + const FVector& A, const FVector& B, const FBox& Box, float& OutDistance) + { + if (!FMath::IsFinite(A.X) || !FMath::IsFinite(A.Y) || !FMath::IsFinite(A.Z) + || !FMath::IsFinite(B.X) || !FMath::IsFinite(B.Y) || !FMath::IsFinite(B.Z) + || !Box.IsValid) + { + return false; + } + + const FVector D = B - A; + float Breaks[8] = { 0.0f, 1.0f }; + int32 BreakCount = 2; + const float AValues[3] = { A.X, A.Y, A.Z }; + const float DValues[3] = { D.X, D.Y, D.Z }; + const float BoxMinValues[3] = { + (float)Box.Min.X, (float)Box.Min.Y, (float)Box.Min.Z }; + const float BoxMaxValues[3] = { + (float)Box.Max.X, (float)Box.Max.Y, (float)Box.Max.Z }; + for (int32 Axis = 0; Axis < 3; ++Axis) + { + // This is a lower-bound proof. A nonzero direction, however small, can cross a + // box plane and reduce the segment distance; treating it as stationary would make + // the bound too high and could incorrectly skip geometry. + if (DValues[Axis] == 0.0f) + { + continue; + } + const float TMin = (BoxMinValues[Axis] - AValues[Axis]) / DValues[Axis]; + const float TMax = (BoxMaxValues[Axis] - AValues[Axis]) / DValues[Axis]; + if (TMin > 0.0f && TMin < 1.0f && BreakCount < UE_ARRAY_COUNT(Breaks)) + { + Breaks[BreakCount++] = TMin; + } + if (TMax > 0.0f && TMax < 1.0f && BreakCount < UE_ARRAY_COUNT(Breaks)) + { + Breaks[BreakCount++] = TMax; + } + } + for (int32 I = 1; I < BreakCount; ++I) + { + const float Value = Breaks[I]; + int32 J = I - 1; + while (J >= 0 && Breaks[J] > Value) + { + Breaks[J + 1] = Breaks[J]; + --J; + } + Breaks[J + 1] = Value; + } + + float MinimumDistanceSquared = FLT_MAX; + for (int32 Interval = 0; Interval + 1 < BreakCount; ++Interval) + { + const float T0 = Breaks[Interval]; + const float T1 = Breaks[Interval + 1]; + const float TM = 0.5f * (T0 + T1); + float QuadraticA = 0.0f; + float QuadraticB = 0.0f; + float QuadraticC = 0.0f; + for (int32 Axis = 0; Axis < 3; ++Axis) + { + const float Mid = AValues[Axis] + DValues[Axis] * TM; + float Offset = 0.0f; + if (Mid < BoxMinValues[Axis]) + { + Offset = AValues[Axis] - BoxMinValues[Axis]; + } + else if (Mid > BoxMaxValues[Axis]) + { + Offset = AValues[Axis] - BoxMaxValues[Axis]; + } + else + { + continue; + } + QuadraticA += DValues[Axis] * DValues[Axis]; + QuadraticB += 2.0f * Offset * DValues[Axis]; + QuadraticC += Offset * Offset; + } + float T = T0; + if (QuadraticA > 0.0f) + { + T = FMath::Clamp( + -QuadraticB / (2.0f * QuadraticA), T0, T1); + } + const float DistanceSquared = FMath::Max( + 0.0f, + (QuadraticA * T + QuadraticB) * T + QuadraticC); + MinimumDistanceSquared = FMath::Min( + MinimumDistanceSquared, DistanceSquared); + } + if (!FMath::IsFinite(MinimumDistanceSquared)) + { + return false; + } + OutDistance = FMath::Sqrt(MinimumDistanceSquared); + return FMath::IsFinite(OutDistance); + } + + static bool VF_LandingLowerBoundOverBox( + const FVoxelPassageLanding& Landing, const FBox& Box, float& OutLowerBound) + { + if (!FMath::IsFinite(Landing.StandingPoint.X) + || !FMath::IsFinite(Landing.StandingPoint.Y) + || !FMath::IsFinite(Landing.FloorZ) + || !FMath::IsFinite(Landing.CeilingZ) + || !FMath::IsFinite(Landing.HalfWidth) + || !Box.IsValid) + { + return false; + } + if (Landing.HalfWidth <= 0.0f || Landing.CeilingZ <= Landing.FloorZ) + { + OutLowerBound = FLT_MAX; + return true; + } + + constexpr float RoomRounding = 1.5f; + const float Height = Landing.CeilingZ - Landing.FloorZ; + const FVector Center( + Landing.StandingPoint.X, Landing.StandingPoint.Y, + (Landing.FloorZ + Landing.CeilingZ) * 0.5f); + const FVector HalfExtent( + FMath::Max(Landing.HalfWidth - RoomRounding, 0.25f), + FMath::Max(Landing.HalfWidth - RoomRounding, 0.25f), + FMath::Max(Height * 0.5f - RoomRounding, 0.25f)); + const FVector OuterMin = Center - HalfExtent - FVector(RoomRounding); + const FVector OuterMax = Center + HalfExtent + FVector(RoomRounding); + const FVector BoxMin((float)Box.Min.X, (float)Box.Min.Y, (float)Box.Min.Z); + const FVector BoxMax((float)Box.Max.X, (float)Box.Max.Y, (float)Box.Max.Z); + const float OuterDistance = VF_DistanceBetweenAABBs( + BoxMin, BoxMax, OuterMin, OuterMax); + const float RoundedBoxLower = OuterDistance > 0.0f + ? OuterDistance + : -FMath::Max(HalfExtent.X, + FMath::Max(HalfExtent.Y, HalfExtent.Z)) - RoomRounding; + OutLowerBound = FMath::Max( + RoundedBoxLower, Landing.FloorZ - (float)Box.Max.Z); + return FMath::IsFinite(OutLowerBound); + } + + static bool VF_PassageModifierLowerBoundOverBox( + const TArray& Passages, + const FBox& Box, float& OutLowerBound) + { + if (!Box.IsValid) + { + return false; + } + + constexpr float BlendK = 3.0f; + float ModifierLowerBound = FLT_MAX; + const FVector BoxMin((float)Box.Min.X, (float)Box.Min.Y, (float)Box.Min.Z); + const FVector BoxMax((float)Box.Max.X, (float)Box.Max.Y, (float)Box.Max.Z); + for (const FVoxelPassage& Passage : Passages) + { + if (!FMath::IsFinite(Passage.BoundCenter.X) + || !FMath::IsFinite(Passage.BoundCenter.Y) + || !FMath::IsFinite(Passage.BoundCenter.Z) + || !FMath::IsFinite(Passage.BoundRadius) + || !FMath::IsFinite(Passage.BoundRadiusSq) + || Passage.BoundRadius < 0.0f || Passage.BoundRadiusSq < 0.0f) + { + return false; + } + const FVector ClosestPoint( + FMath::Clamp(Passage.BoundCenter.X, BoxMin.X, BoxMax.X), + FMath::Clamp(Passage.BoundCenter.Y, BoxMin.Y, BoxMax.Y), + FMath::Clamp(Passage.BoundCenter.Z, BoxMin.Z, BoxMax.Z)); + if (FVector::DistSquared(ClosestPoint, Passage.BoundCenter) + > Passage.BoundRadiusSq) + { + continue; + } + + float PassageLowerBound = FLT_MAX; + if (Passage.ControlPoints.Num() >= 2) + { + if (Passage.ControlPoints.Num() > 4096) + { + return false; + } + const bool bTaper = Passage.ControlRadii.Num() == Passage.ControlPoints.Num(); + if (!bTaper && !FMath::IsFinite(Passage.Radius)) + { + return false; + } + for (int32 PointIndex = 0; + PointIndex < Passage.ControlPoints.Num(); ++PointIndex) + { + const FVector& Point = Passage.ControlPoints[PointIndex]; + if (!FMath::IsFinite(Point.X) || !FMath::IsFinite(Point.Y) + || !FMath::IsFinite(Point.Z) + || (bTaper && !FMath::IsFinite(Passage.ControlRadii[PointIndex]))) + { + return false; + } + } + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Passage.ControlPoints.Num(); ++SegmentIndex) + { + const float RadiusA = bTaper + ? Passage.ControlRadii[SegmentIndex] : Passage.Radius; + const float RadiusB = bTaper + ? Passage.ControlRadii[SegmentIndex + 1] : Passage.Radius; + const float Radius = FMath::Max(FMath::Abs(RadiusA), FMath::Abs(RadiusB)); + float SegmentDistance = 0.0f; + if (!FMath::IsFinite(Radius) + || !VF_SegmentDistanceToBox( + Passage.ControlPoints[SegmentIndex], + Passage.ControlPoints[SegmentIndex + 1], Box, SegmentDistance)) + { + return false; + } + PassageLowerBound = VoxelSDF::SmoothMin( + PassageLowerBound, SegmentDistance - Radius, BlendK); + } + } + else + { + if (!FMath::IsFinite(Passage.UpperPoint.X) + || !FMath::IsFinite(Passage.UpperPoint.Y) + || !FMath::IsFinite(Passage.UpperPoint.Z) + || !FMath::IsFinite(Passage.LowerPoint.X) + || !FMath::IsFinite(Passage.LowerPoint.Y) + || !FMath::IsFinite(Passage.LowerPoint.Z) + || !FMath::IsFinite(Passage.Radius)) + { + return false; + } + float SegmentDistance = 0.0f; + if (!VF_SegmentDistanceToBox( + Passage.UpperPoint, Passage.LowerPoint, Box, SegmentDistance)) + { + return false; + } + PassageLowerBound = SegmentDistance - FMath::Abs(Passage.Radius); + } + + const FVoxelPassageLanding* Landings[] = { + &Passage.UpperLanding, &Passage.LowerLanding }; + for (const FVoxelPassageLanding* Landing : Landings) + { + float LandingLowerBound = FLT_MAX; + if (!VF_LandingLowerBoundOverBox( + *Landing, Box, LandingLowerBound)) + { + return false; + } + PassageLowerBound = VoxelSDF::SmoothMin( + PassageLowerBound, LandingLowerBound, BlendK); + } + ModifierLowerBound = VoxelSDF::SmoothMin( + ModifierLowerBound, PassageLowerBound, BlendK); + } + OutLowerBound = ModifierLowerBound; + return FMath::IsFinite(OutLowerBound); + } + // MaxPassageCarveFactorNearLattice is queried once for the root and once for every refined // child. The child lattice is a strict subset of the root lattice, so retaining the exact // carve factor at each root point is a sound memo: a child reads the same values the old @@ -2369,10 +2937,336 @@ bool UVoxelStrateManager::AnyPassageNearLattice( // MaxPassageCarveFactorNearLattice during the same fold. A bound hit is sufficient to keep // the carve hypothesis alive; MaxPassageCarveFactorNearLattice supplies the exact lattice // amplitude before the fold can preserve AllSolid. - return VF_AnyPassageBoundTouchesLattice( + return VF_AnyPassageModifierDomainTouchesLattice( Passages, VoxelBox, LatticeOrigin, Step); } +bool UVoxelStrateManager::AnyPassageAirPostNearLattice( + const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step, + float BaseDensity, float SealThickness) const +{ + // FPassageCarveOp already evaluates the complete passage modifier in the stack. The only + // passage work still outside that proof is the post-disturbance landing/tunnel-air backstop. + // Do not use the enclosing passage sphere as its candidate: that sphere is deliberately much + // wider than a walkable core and was the false-positive that made LOD0 all-solid tiles recurse. + if (Step <= 0 || !VoxelBox.IsValid + || VoxelPassageGeometry::VerticalShaftConnectorAirMarker()) + { + return true; + } + if (!FMath::IsFinite(BaseDensity) || !(BaseDensity > 0.0f) + || !FMath::IsFinite(SealThickness) || SealThickness < 0.0f) + { + return true; + } + + int32 FirstX = 0, LastX = -1; + int32 FirstY = 0, LastY = -1; + int32 FirstZ = 0, LastZ = -1; + if (!VF_LatticeAxisRange( + VoxelBox.Min.X, VoxelBox.Max.X, (float)LatticeOrigin.X, Step, FirstX, LastX) + || !VF_LatticeAxisRange( + VoxelBox.Min.Y, VoxelBox.Max.Y, (float)LatticeOrigin.Y, Step, FirstY, LastY) + || !VF_LatticeAxisRange( + VoxelBox.Min.Z, VoxelBox.Max.Z, (float)LatticeOrigin.Z, Step, FirstZ, LastZ)) + { + return true; + } + + const int64 DimX = (int64)LastX - FirstX + 1; + const int64 DimY = (int64)LastY - FirstY + 1; + const int64 DimZ = (int64)LastZ - FirstZ + 1; + const int64 SampleCount = DimX * DimY * DimZ; + // This query is a proof helper, not a second unbounded density grid. An unusual external + // caller gets Unknown/candidate and therefore keeps the normal mesher path. + if (SampleCount <= 0 || SampleCount > 65536) + { + return true; + } + + // The tunnel-air post is a vertical prism over the nearest horizontal control segment. Its + // existence on a lattice does not require a 3-D walk: project each XY lattice point once, + // then ask whether the resulting floor interval contains a Z lattice sample. The interval is + // deliberately closed at both ends here, so strict predicate boundaries can only create a + // false candidate; they can never hide a real air sample. + constexpr int32 MaxTunnelControlPointsForQuery = 4096; + for (const FVoxelPassage& Passage : Passages) + { + if (!Passage.bWalkableTunnelContract) + { + continue; + } + if (Passage.ControlPoints.Num() < 2 + || Passage.ControlRadii.Num() != Passage.ControlPoints.Num() + || Passage.ControlPoints.Num() > MaxTunnelControlPointsForQuery) + { + return true; + } + for (int32 PointIndex = 0; + PointIndex < Passage.ControlPoints.Num(); ++PointIndex) + { + const FVector& Point = Passage.ControlPoints[PointIndex]; + if (!FMath::IsFinite(Point.X) || !FMath::IsFinite(Point.Y) + || !FMath::IsFinite(Point.Z) + || !FMath::IsFinite(Passage.ControlRadii[PointIndex])) + { + return true; + } + } + + for (int32 IY = FirstY; IY <= LastY; ++IY) + { + const float Y = (float)LatticeOrigin.Y + (float)(IY * Step); + for (int32 IX = FirstX; IX <= LastX; ++IX) + { + const FVector Position( + (float)LatticeOrigin.X + (float)(IX * Step), Y, 0.0f); + float FloorZ = 0.0f; + float SupportRadius = 0.0f; + if (!VoxelPassageGeometry::ProjectWalkableTunnelFloor( + Passage.ControlPoints, Passage.ControlRadii, Position, + FloorZ, SupportRadius)) + { + continue; + } + const float AirMinZ = FMath::Max( + (float)VoxelBox.Min.Z, + FloorZ + VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels); + const float AirMaxZ = FMath::Min( + (float)VoxelBox.Max.Z, + FloorZ + 2.0f * SupportRadius); + if (VoxelPassageGeometry::LatticeAxisHasSampleInInterval( + AirMinZ, AirMaxZ, + (float)LatticeOrigin.Z, Step)) + { + return true; + } + } + } + } + + constexpr float LandingBlendRadius = 4.0f; + TArray> Candidates; + struct FPostPotentialDomain + { + int32 PassageIndex = INDEX_NONE; + FVector Min = FVector(FLT_MAX, FLT_MAX, FLT_MAX); + FVector Max = FVector(-FLT_MAX, -FLT_MAX, -FLT_MAX); + }; + TArray> PotentialDomains; + float PotentialMinX = FLT_MAX; + float PotentialMinY = FLT_MAX; + float PotentialMinZ = FLT_MAX; + float PotentialMaxX = -FLT_MAX; + float PotentialMaxY = -FLT_MAX; + float PotentialMaxZ = -FLT_MAX; + + // BoundCenter/BoundRadius encloses the whole passage, which is intentionally much larger + // than the post writers. Using that sphere here made every LOD0 child along a long route + // walk its complete 17^3 lattice even when the route's landing/tunnel-air AABBs were many + // chunks away. Build a conservative union of the actual post domains instead. A point + // outside these AABBs cannot satisfy either VF_EvaluatePassageLandingSDF or + // VF_IsWalkableTunnelAir; the exact predicates below still decide every point inside. + auto AddPotentialAABB = [&](int32 PassageIndex, + float MinX, float MaxX, float MinY, float MaxY, + float MinZ, float MaxZ, bool& bTouches) -> bool + { + if (!FMath::IsFinite(MinX) || !FMath::IsFinite(MaxX) + || !FMath::IsFinite(MinY) || !FMath::IsFinite(MaxY) + || !FMath::IsFinite(MinZ) || !FMath::IsFinite(MaxZ) + || MinX > MaxX || MinY > MaxY || MinZ > MaxZ) + { + return false; + } + if (VF_LatticeBoxTouchesAABB( + VoxelBox, LatticeOrigin, Step, + MinX, MaxX, MinY, MaxY, MinZ, MaxZ)) + { + bTouches = true; + PotentialMinX = FMath::Min(PotentialMinX, MinX); + PotentialMinY = FMath::Min(PotentialMinY, MinY); + PotentialMinZ = FMath::Min(PotentialMinZ, MinZ); + PotentialMaxX = FMath::Max(PotentialMaxX, MaxX); + PotentialMaxY = FMath::Max(PotentialMaxY, MaxY); + PotentialMaxZ = FMath::Max(PotentialMaxZ, MaxZ); + PotentialDomains.Add({ + PassageIndex, + FVector(MinX, MinY, MinZ), FVector(MaxX, MaxY, MaxZ) }); + } + return true; + }; + + for (int32 PassageIndex = 0; PassageIndex < Passages.Num(); ++PassageIndex) + { + const FVoxelPassage& Passage = Passages[PassageIndex]; + if (!FMath::IsFinite(Passage.BoundCenter.X) + || !FMath::IsFinite(Passage.BoundCenter.Y) + || !FMath::IsFinite(Passage.BoundCenter.Z) + || !FMath::IsFinite(Passage.BoundRadius) + || !FMath::IsFinite(Passage.BoundRadiusSq) + || Passage.BoundRadius < 0.0f || Passage.BoundRadiusSq < 0.0f) + { + return true; + } + + bool bPassageTouches = false; + const FVoxelPassageLanding* Landings[] = { + &Passage.UpperLanding, &Passage.LowerLanding }; + for (const FVoxelPassageLanding* Landing : Landings) + { + // A passage such as the optional surface entry has no landing post and therefore + // leaves this descriptor at its zero-valued default. VF_EvaluatePassageLandingSDF + // treats that descriptor as FLT_MAX (the writer is a known no-op), so it must not + // turn the whole tile into an Unknown/candidate. FloorThickness is intentionally + // not part of this validity check: the landing-air writer reads the SDF fields even + // when a separate floor descriptor is degenerate. + const bool bLandingSdfValid = + FMath::IsFinite(Landing->StandingPoint.X) + && FMath::IsFinite(Landing->StandingPoint.Y) + && FMath::IsFinite(Landing->FloorZ) + && FMath::IsFinite(Landing->CeilingZ) + && FMath::IsFinite(Landing->HalfWidth) + && Landing->HalfWidth > 0.0f + && Landing->CeilingZ > Landing->FloorZ; + if (bLandingSdfValid) + { + const float HalfExtent = Landing->HalfWidth + LandingBlendRadius; + if (!AddPotentialAABB( + PassageIndex, + Landing->StandingPoint.X - HalfExtent, + Landing->StandingPoint.X + HalfExtent, + Landing->StandingPoint.Y - HalfExtent, + Landing->StandingPoint.Y + HalfExtent, + Landing->FloorZ - LandingBlendRadius, + Landing->CeilingZ + LandingBlendRadius, + bPassageTouches)) + { + return true; + } + } + } + + if (bPassageTouches) + { + Candidates.Add(PassageIndex); + } + } + if (Candidates.Num() == 0) + { + return false; + } + + // The union above is conservative but can be much smaller than the queried box. Restrict the + // exact walk to its lattice intersection; a false positive still pays the exact predicates, + // while a far-away long-passage sphere no longer does. + if (!VF_LatticeAxisRange( + FMath::Max((float)VoxelBox.Min.X, PotentialMinX), + FMath::Min((float)VoxelBox.Max.X, PotentialMaxX), + (float)LatticeOrigin.X, Step, FirstX, LastX) + || !VF_LatticeAxisRange( + FMath::Max((float)VoxelBox.Min.Y, PotentialMinY), + FMath::Min((float)VoxelBox.Max.Y, PotentialMaxY), + (float)LatticeOrigin.Y, Step, FirstY, LastY) + || !VF_LatticeAxisRange( + FMath::Max((float)VoxelBox.Min.Z, PotentialMinZ), + FMath::Min((float)VoxelBox.Max.Z, PotentialMaxZ), + (float)LatticeOrigin.Z, Step, FirstZ, LastZ)) + { + return false; + } + + const int64 PotentialSampleCount = ((int64)LastX - FirstX + 1) + * ((int64)LastY - FirstY + 1) + * ((int64)LastZ - FirstZ + 1); + if (PotentialSampleCount <= 0 || PotentialSampleCount > 65536) + { + return true; + } + + const float AirTarget = -(BaseDensity * 2.0f + SealThickness + 4.0f); + if (!FMath::IsFinite(AirTarget)) + { + return true; + } + for (int32 IZ = FirstZ; IZ <= LastZ; ++IZ) + { + const float Z = (float)LatticeOrigin.Z + (float)(IZ * Step); + for (int32 IY = FirstY; IY <= LastY; ++IY) + { + const float Y = (float)LatticeOrigin.Y + (float)(IY * Step); + for (int32 IX = FirstX; IX <= LastX; ++IX) + { + const FVector Position( + (float)LatticeOrigin.X + (float)(IX * Step), Y, Z); + float MinLandingSDF = FLT_MAX; + + for (const int32 PassageIndex : Candidates) + { + const FVoxelPassage& Passage = Passages[PassageIndex]; + bool bInPotentialDomain = false; + for (const FPostPotentialDomain& Domain : PotentialDomains) + { + if (Domain.PassageIndex == PassageIndex + && Position.X >= Domain.Min.X && Position.X <= Domain.Max.X + && Position.Y >= Domain.Min.Y && Position.Y <= Domain.Max.Y + && Position.Z >= Domain.Min.Z && Position.Z <= Domain.Max.Z) + { + bInPotentialDomain = true; + break; + } + } + if (!bInPotentialDomain) + { + continue; + } + const float DistanceSquared = + FVector::DistSquared(Position, Passage.BoundCenter); + if (DistanceSquared > Passage.BoundRadiusSq) + { + continue; + } + + const float UpperSDF = VF_EvaluatePassageLandingSDF( + Position, Passage.UpperLanding); + const float LowerSDF = VF_EvaluatePassageLandingSDF( + Position, Passage.LowerLanding); + if (!FMath::IsFinite(UpperSDF) || !FMath::IsFinite(LowerSDF)) + { + // A malformed landing is an unknown post, never an identity proof. + return true; + } + MinLandingSDF = FMath::Min( + MinLandingSDF, FMath::Min(UpperSDF, LowerSDF)); + } + + // The floor writer can only suppress a landing-air post, so omitting that + // exclusion here is deliberately conservative: it can retain a final-field + // candidate, but can never prove an empty tile that owns a support floor. + // The exact same SDF threshold as the post is sufficient for the current-density + // interpolation: SDF < 4 is exactly its activation domain. + if (MinLandingSDF < LandingBlendRadius) + { + float CarveFactor = FMath::Clamp( + (LandingBlendRadius - MinLandingSDF) + / (LandingBlendRadius * 2.0f), + 0.0f, 1.0f); + CarveFactor = SmoothStep01(CarveFactor); + const float LandingThreshold = FMath::Lerp( + BaseDensity, AirTarget, CarveFactor); + if (!FMath::IsFinite(LandingThreshold) + || LandingThreshold <= 0.0f) + { + // Zero is not solid under the mesher's sign convention, so it is part of + // the final-field risk just like a strictly air threshold. + return true; + } + } + } + } + } + return false; +} + float UVoxelStrateManager::MaxPassageCarveFactorNearLattice( const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step) const { @@ -2414,8 +3308,60 @@ float UVoxelStrateManager::MaxPassageCarveFactorNearLattice( return Cache.MaxIn(FirstX, LastX, FirstY, LastY, FirstZ, LastZ); } - if (!VF_AnyPassageBoundTouchesLattice( - Passages, VoxelBox, LatticeOrigin, Step)) + constexpr float PassageCarveThreshold = 4.0f; + constexpr float SmoothMinK = 3.0f; + float ModifierBoxLowerBound = 0.0f; + if (VF_PassageModifierLowerBoundOverBox( + Passages, VoxelBox, ModifierBoxLowerBound) + && ModifierBoxLowerBound >= PassageCarveThreshold + 1.0e-3f) + { + Cache.Begin( + this, LifetimeId, Version, LatticeOrigin, Step, + FirstX, LastX, FirstY, LastY, FirstZ, LastZ, + /*bInNoCandidate*/ true); + return 0.0f; + } + TArray> ModifierDomains; + bool bKnownModifierDomains = VF_BuildPassageModifierDomains( + Passages, ModifierDomains); + float ModifierDomainPadding = FLT_MAX; + bool bModifierDomainTouchesLattice = true; + if (bKnownModifierDomains) + { + if (ModifierDomains.Num() == 0) + { + bModifierDomainTouchesLattice = false; + } + else + { + const float SmoothDip = static_cast(ModifierDomains.Num() - 1) + * (SmoothMinK / 6.0f); + const float Padding = PassageCarveThreshold + SmoothDip; + ModifierDomainPadding = Padding; + if (!FMath::IsFinite(Padding) || Padding < 0.0f) + { + bKnownModifierDomains = false; + } + else + { + bModifierDomainTouchesLattice = false; + for (const FVoxelPassageModifierDomain& Domain : ModifierDomains) + { + if (VF_LatticeBoxTouchesAABB( + VoxelBox, LatticeOrigin, Step, + Domain.Min.X - Padding, Domain.Max.X + Padding, + Domain.Min.Y - Padding, Domain.Max.Y + Padding, + Domain.Min.Z - Padding, Domain.Max.Z + Padding)) + { + bModifierDomainTouchesLattice = true; + break; + } + } + } + } + } + + if (bKnownModifierDomains && !bModifierDomainTouchesLattice) { Cache.Begin( this, LifetimeId, Version, LatticeOrigin, Step, @@ -2424,7 +3370,6 @@ float UVoxelStrateManager::MaxPassageCarveFactorNearLattice( return 0.0f; } - constexpr float PassageCarveThreshold = 4.0f; auto CarveFactorFromSDF = [](float ModifierSDF) { if (!(ModifierSDF < PassageCarveThreshold)) @@ -2438,6 +3383,193 @@ float UVoxelStrateManager::MaxPassageCarveFactorNearLattice( return SmoothStep01(CarveFactor); }; + // A domain hit is still much wider than the tapered capsule itself. Before paying for the + // full modifier evaluator, use a point-level lower-bound fold over the actual primitive + // domains. It preserves the saturating SmoothMin behavior, so a far segment contributes no + // artificial global K/6 penalty. This keeps the proof exact while avoiding a 33^3 walk + // through every segment for a tile that merely grazes a passage's old sphere. + const auto MayEvaluatePoint = [&](const FVector& Position) -> bool + { + if (!bKnownModifierDomains) + { + return true; + } + bool bInsideModifierDomain = false; + for (const FVoxelPassageModifierDomain& Domain : ModifierDomains) + { + if (Position.X >= Domain.Min.X - ModifierDomainPadding + && Position.X <= Domain.Max.X + ModifierDomainPadding + && Position.Y >= Domain.Min.Y - ModifierDomainPadding + && Position.Y <= Domain.Max.Y + ModifierDomainPadding + && Position.Z >= Domain.Min.Z - ModifierDomainPadding + && Position.Z <= Domain.Max.Z + ModifierDomainPadding) + { + bInsideModifierDomain = true; + break; + } + } + return bInsideModifierDomain + && VF_PointMayHavePassageCarve(Passages, Position); + }; + + // Do not sweep the complete 33^3 lattice merely because a continuous passage domain enters + // the tile. Recursively split the sample index box and use the exact segment/box lower bound + // above to discard whole groups. A leaf still evaluates only the original MC vertices; this is + // a proof acceleration, not a lower-resolution sample. + const int64 FullSampleCount = + (static_cast(LastX) - FirstX + 1) + * (static_cast(LastY) - FirstY + 1) + * (static_cast(LastZ) - FirstZ + 1); + constexpr int64 MaxRecursiveLatticeSamples = 65536; + if (bKnownModifierDomains && FullSampleCount > 0 + && FullSampleCount <= MaxRecursiveLatticeSamples) + { + Cache.Begin( + this, LifetimeId, Version, LatticeOrigin, Step, + FirstX, LastX, FirstY, LastY, FirstZ, LastZ, + /*bInNoCandidate*/ false); + Cache.Factors.SetNumZeroed(static_cast(FullSampleCount)); + + float MaxCarveFactor = 0.0f; + auto EvaluatePoint = [&](int32 IX, int32 IY, int32 IZ) + { + const float X = (float)LatticeOrigin.X + (float)(IX * Step); + const float Y = (float)LatticeOrigin.Y + (float)(IY * Step); + const float Z = (float)LatticeOrigin.Z + (float)(IZ * Step); + const FVector Position(X, Y, Z); + if (!MayEvaluatePoint(Position)) + { + return; + } + const float ModifierSDF = EvaluateModifierSDF(X, Y, Z); + const float CarveFactor = FMath::IsFinite(ModifierSDF) + ? CarveFactorFromSDF(ModifierSDF) : 1.0f; + Cache.Factors[Cache.Index(IX, IY, IZ)] = CarveFactor; + MaxCarveFactor = FMath::Max(MaxCarveFactor, CarveFactor); + }; + auto Visit = [&](auto&& Self, + int32 IX0, int32 IX1, + int32 IY0, int32 IY1, + int32 IZ0, int32 IZ1) -> void + { + const FVector NodeMin( + (float)LatticeOrigin.X + (float)(IX0 * Step), + (float)LatticeOrigin.Y + (float)(IY0 * Step), + (float)LatticeOrigin.Z + (float)(IZ0 * Step)); + const FVector NodeMax( + (float)LatticeOrigin.X + (float)(IX1 * Step), + (float)LatticeOrigin.Y + (float)(IY1 * Step), + (float)LatticeOrigin.Z + (float)(IZ1 * Step)); + const FBox NodeBox(NodeMin, NodeMax); + float NodeLowerBound = 0.0f; + if (VF_PassageModifierLowerBoundOverBox( + Passages, NodeBox, NodeLowerBound) + && NodeLowerBound >= PassageCarveThreshold + 1.0e-3f) + { + return; + } + + const int64 NodeSampleCount = + (static_cast(IX1) - IX0 + 1) + * (static_cast(IY1) - IY0 + 1) + * (static_cast(IZ1) - IZ0 + 1); + if (NodeSampleCount <= 64) + { + for (int32 IZ = IZ0; IZ <= IZ1; ++IZ) + for (int32 IY = IY0; IY <= IY1; ++IY) + for (int32 IX = IX0; IX <= IX1; ++IX) + { + EvaluatePoint(IX, IY, IZ); + } + return; + } + + const int32 SpanX = IX1 - IX0; + const int32 SpanY = IY1 - IY0; + const int32 SpanZ = IZ1 - IZ0; + if (SpanX >= SpanY && SpanX >= SpanZ && SpanX > 0) + { + const int32 Mid = IX0 + SpanX / 2; + Self(Self, IX0, Mid, IY0, IY1, IZ0, IZ1); + Self(Self, Mid + 1, IX1, IY0, IY1, IZ0, IZ1); + } + else if (SpanY >= SpanZ && SpanY > 0) + { + const int32 Mid = IY0 + SpanY / 2; + Self(Self, IX0, IX1, IY0, Mid, IZ0, IZ1); + Self(Self, IX0, IX1, Mid + 1, IY1, IZ0, IZ1); + } + else if (SpanZ > 0) + { + const int32 Mid = IZ0 + SpanZ / 2; + Self(Self, IX0, IX1, IY0, IY1, IZ0, Mid); + Self(Self, IX0, IX1, IY0, IY1, Mid + 1, IZ1); + } + else + { + EvaluatePoint(IX0, IY0, IZ0); + } + }; + Visit(Visit, FirstX, LastX, FirstY, LastY, FirstZ, LastZ); + return MaxCarveFactor; + } + + int32 EvaluationFirstX = FirstX, EvaluationLastX = LastX; + int32 EvaluationFirstY = FirstY, EvaluationLastY = LastY; + int32 EvaluationFirstZ = FirstZ, EvaluationLastZ = LastZ; + if (bKnownModifierDomains) + { + float CandidateMinX = FLT_MAX; + float CandidateMinY = FLT_MAX; + float CandidateMinZ = FLT_MAX; + float CandidateMaxX = -FLT_MAX; + float CandidateMaxY = -FLT_MAX; + float CandidateMaxZ = -FLT_MAX; + for (const FVoxelPassageModifierDomain& Domain : ModifierDomains) + { + CandidateMinX = FMath::Min(CandidateMinX, + Domain.Min.X - ModifierDomainPadding); + CandidateMinY = FMath::Min(CandidateMinY, + Domain.Min.Y - ModifierDomainPadding); + CandidateMinZ = FMath::Min(CandidateMinZ, + Domain.Min.Z - ModifierDomainPadding); + CandidateMaxX = FMath::Max(CandidateMaxX, + Domain.Max.X + ModifierDomainPadding); + CandidateMaxY = FMath::Max(CandidateMaxY, + Domain.Max.Y + ModifierDomainPadding); + CandidateMaxZ = FMath::Max(CandidateMaxZ, + Domain.Max.Z + ModifierDomainPadding); + } + if (!FMath::IsFinite(CandidateMinX) || !FMath::IsFinite(CandidateMinY) + || !FMath::IsFinite(CandidateMinZ) || !FMath::IsFinite(CandidateMaxX) + || !FMath::IsFinite(CandidateMaxY) || !FMath::IsFinite(CandidateMaxZ)) + { + bKnownModifierDomains = false; + } + else if (!VF_LatticeAxisRange( + FMath::Max((float)VoxelBox.Min.X, CandidateMinX), + FMath::Min((float)VoxelBox.Max.X, CandidateMaxX), + (float)LatticeOrigin.X, Step, + EvaluationFirstX, EvaluationLastX) + || !VF_LatticeAxisRange( + FMath::Max((float)VoxelBox.Min.Y, CandidateMinY), + FMath::Min((float)VoxelBox.Max.Y, CandidateMaxY), + (float)LatticeOrigin.Y, Step, + EvaluationFirstY, EvaluationLastY) + || !VF_LatticeAxisRange( + FMath::Max((float)VoxelBox.Min.Z, CandidateMinZ), + FMath::Min((float)VoxelBox.Max.Z, CandidateMaxZ), + (float)LatticeOrigin.Z, Step, + EvaluationFirstZ, EvaluationLastZ)) + { + Cache.Begin( + this, LifetimeId, Version, LatticeOrigin, Step, + FirstX, LastX, FirstY, LastY, FirstZ, LastZ, + /*bInNoCandidate*/ true); + return 0.0f; + } + } + const int64 DimX = static_cast(LastX) - FirstX + 1; const int64 DimY = static_cast(LastY) - FirstY + 1; const int64 DimZ = static_cast(LastZ) - FirstZ + 1; @@ -2449,21 +3581,33 @@ float UVoxelStrateManager::MaxPassageCarveFactorNearLattice( this, LifetimeId, Version, LatticeOrigin, Step, FirstX, LastX, FirstY, LastY, FirstZ, LastZ, /*bInNoCandidate*/ false); - Cache.Factors.SetNumUninitialized(static_cast(SampleCount)); + if (bKnownModifierDomains) + { + Cache.Factors.SetNumZeroed(static_cast(SampleCount)); + } + else + { + Cache.Factors.SetNumUninitialized(static_cast(SampleCount)); + } float MaxCarveFactor = 0.0f; - for (int32 IZ = FirstZ; IZ <= LastZ; ++IZ) + for (int32 IZ = EvaluationFirstZ; IZ <= EvaluationLastZ; ++IZ) { const float Z = (float)LatticeOrigin.Z + (float)(IZ * Step); - for (int32 IY = FirstY; IY <= LastY; ++IY) + for (int32 IY = EvaluationFirstY; IY <= EvaluationLastY; ++IY) { const float Y = (float)LatticeOrigin.Y + (float)(IY * Step); - for (int32 IX = FirstX; IX <= LastX; ++IX) + for (int32 IX = EvaluationFirstX; IX <= EvaluationLastX; ++IX) { const float X = (float)LatticeOrigin.X + (float)(IX * Step); - const float ModifierSDF = EvaluateModifierSDF(X, Y, Z); - const float CarveFactor = FMath::IsFinite(ModifierSDF) - ? CarveFactorFromSDF(ModifierSDF) : 1.0f; + const FVector Position(X, Y, Z); + float CarveFactor = 0.0f; + if (MayEvaluatePoint(Position)) + { + const float ModifierSDF = EvaluateModifierSDF(X, Y, Z); + CarveFactor = FMath::IsFinite(ModifierSDF) + ? CarveFactorFromSDF(ModifierSDF) : 1.0f; + } Cache.Factors[Cache.Index(IX, IY, IZ)] = CarveFactor; MaxCarveFactor = FMath::Max(MaxCarveFactor, CarveFactor); if (MaxCarveFactor >= 1.0f) @@ -2485,11 +3629,18 @@ float UVoxelStrateManager::MaxPassageCarveFactorNearLattice( { const float RemainingWorldX = (float)LatticeOrigin.X + (float)(RemainingX * Step); - const float RemainingSDF = EvaluateModifierSDF( + const FVector RemainingPosition( RemainingWorldX, RemainingWorldY, RemainingWorldZ); - Cache.Factors[Cache.Index(RemainingX, RemainingY, RemainingZ)] = - FMath::IsFinite(RemainingSDF) + float RemainingFactor = 0.0f; + if (MayEvaluatePoint(RemainingPosition)) + { + const float RemainingSDF = EvaluateModifierSDF( + RemainingWorldX, RemainingWorldY, RemainingWorldZ); + RemainingFactor = FMath::IsFinite(RemainingSDF) ? CarveFactorFromSDF(RemainingSDF) : 1.0f; + } + Cache.Factors[Cache.Index(RemainingX, RemainingY, RemainingZ)] = + RemainingFactor; } } } @@ -2505,15 +3656,20 @@ float UVoxelStrateManager::MaxPassageCarveFactorNearLattice( // original exact walk for them; refusing the cache must never change the proof. Cache.Reset(); float MaxCarveFactor = 0.0f; - for (int32 IZ = FirstZ; IZ <= LastZ; ++IZ) + for (int32 IZ = EvaluationFirstZ; IZ <= EvaluationLastZ; ++IZ) { const float Z = (float)LatticeOrigin.Z + (float)(IZ * Step); - for (int32 IY = FirstY; IY <= LastY; ++IY) + for (int32 IY = EvaluationFirstY; IY <= EvaluationLastY; ++IY) { const float Y = (float)LatticeOrigin.Y + (float)(IY * Step); - for (int32 IX = FirstX; IX <= LastX; ++IX) + for (int32 IX = EvaluationFirstX; IX <= EvaluationLastX; ++IX) { const float X = (float)LatticeOrigin.X + (float)(IX * Step); + const FVector Position(X, Y, Z); + if (!MayEvaluatePoint(Position)) + { + continue; + } const float ModifierSDF = EvaluateModifierSDF(X, Y, Z); if (!FMath::IsFinite(ModifierSDF)) { @@ -2534,12 +3690,24 @@ float UVoxelStrateManager::MaxPassageCarveFactorNearLattice( bool UVoxelStrateManager::AnyPassageLandingFloorNearLattice( const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step) const { + if (Step <= 0 || !VoxelBox.IsValid) + { + return true; + } for (const FVoxelPassage& Passage : Passages) { const FVoxelPassageLanding* Landings[] = { &Passage.UpperLanding, &Passage.LowerLanding }; for (const FVoxelPassageLanding* Landing : Landings) { + if (!FMath::IsFinite(Landing->StandingPoint.X) + || !FMath::IsFinite(Landing->StandingPoint.Y) + || !FMath::IsFinite(Landing->FloorZ) + || !FMath::IsFinite(Landing->HalfWidth) + || !FMath::IsFinite(Landing->FloorThickness)) + { + return true; + } if (Landing->HalfWidth <= 0.0f || Landing->FloorThickness <= 0.0f) { continue; @@ -2558,16 +3726,27 @@ bool UVoxelStrateManager::AnyPassageLandingFloorNearLattice( } } - if (Passage.bWalkableTunnelContract - && Passage.ControlPoints.Num() >= 2 - && Passage.ControlRadii.Num() == Passage.ControlPoints.Num()) + if (Passage.bWalkableTunnelContract) { + if (Passage.ControlPoints.Num() < 2 + || Passage.ControlRadii.Num() != Passage.ControlPoints.Num()) + { + return true; + } constexpr float Pad = 1.0f; for (int32 SegmentIndex = 0; SegmentIndex + 1 < Passage.ControlPoints.Num(); ++SegmentIndex) { const FVector& A = Passage.ControlPoints[SegmentIndex]; const FVector& B = Passage.ControlPoints[SegmentIndex + 1]; + if (!FMath::IsFinite(A.X) || !FMath::IsFinite(A.Y) + || !FMath::IsFinite(A.Z) || !FMath::IsFinite(B.X) + || !FMath::IsFinite(B.Y) || !FMath::IsFinite(B.Z) + || !FMath::IsFinite(Passage.ControlRadii[SegmentIndex]) + || !FMath::IsFinite(Passage.ControlRadii[SegmentIndex + 1])) + { + return true; + } const float SupportRadius = FMath::Max( FMath::Min( FMath::Abs(Passage.ControlRadii[SegmentIndex]), @@ -2595,6 +3774,208 @@ bool UVoxelStrateManager::AnyPassageLandingFloorNearLattice( return false; } +bool UVoxelStrateManager::AnyLandingFloorAtLattice( + const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step) const +{ + // This query is used only after the stack has proved AllAir. The broad floor guards above + // are intentionally padded for sound interval folding, but that padding turns a long tunnel + // or landing AABB into a false final-field candidate. Check the actual writer predicates on + // the same MC lattice before asking GetDensityAt for a certificate. Unknown inputs remain + // candidates; this function is never allowed to manufacture an empty proof. + if (Step <= 0 || !VoxelBox.IsValid + || VoxelPassageGeometry::VerticalShaftConnectorAirMarker()) + { + return true; + } + + const bool bBroadPassageFloor = AnyPassageLandingFloorNearLattice( + VoxelBox, LatticeOrigin, Step); + const bool bBroadOriginFloor = AnyOriginLandingFloorNearLattice( + VoxelBox, LatticeOrigin, Step); + if (!bBroadPassageFloor && !bBroadOriginFloor) + { + return false; + } + + auto GetRange = [&](float Min, float Max, int32& OutFirst, int32& OutLast) -> bool + { + return VF_LatticeAxisRange( + Min, Max, static_cast(LatticeOrigin.X), Step, OutFirst, OutLast); + }; + auto GetYRange = [&](float Min, float Max, int32& OutFirst, int32& OutLast) -> bool + { + return VF_LatticeAxisRange( + Min, Max, static_cast(LatticeOrigin.Y), Step, OutFirst, OutLast); + }; + auto GetZRange = [&](float Min, float Max, int32& OutFirst, int32& OutLast) -> bool + { + return VF_LatticeAxisRange( + Min, Max, static_cast(LatticeOrigin.Z), Step, OutFirst, OutLast); + }; + + auto HasAnyLandingSample = [&](const FVoxelPassageLanding& Landing) -> bool + { + if (!FMath::IsFinite(Landing.StandingPoint.X) + || !FMath::IsFinite(Landing.StandingPoint.Y) + || !FMath::IsFinite(Landing.FloorZ) + || !FMath::IsFinite(Landing.HalfWidth) + || !FMath::IsFinite(Landing.FloorThickness) + || Landing.HalfWidth <= 0.0f || Landing.FloorThickness <= 0.0f) + { + // This function gates an AllAir proof. Invalid authored floor data is an unknown + // structural writer, not evidence that the writer is absent. + return true; + } + const float HalfWidth = FMath::Max(Landing.HalfWidth - 1.0f, 0.0f); + int32 IX0 = 0, IX1 = -1, IY0 = 0, IY1 = -1, IZ0 = 0, IZ1 = -1; + return VF_LatticeAxisRange( + FMath::Max((float)VoxelBox.Min.X, + Landing.StandingPoint.X - HalfWidth), + FMath::Min((float)VoxelBox.Max.X, + Landing.StandingPoint.X + HalfWidth), + (float)LatticeOrigin.X, Step, IX0, IX1) + && VF_LatticeAxisRange( + FMath::Max((float)VoxelBox.Min.Y, + Landing.StandingPoint.Y - HalfWidth), + FMath::Min((float)VoxelBox.Max.Y, + Landing.StandingPoint.Y + HalfWidth), + (float)LatticeOrigin.Y, Step, IY0, IY1) + && VF_LatticeAxisRange( + FMath::Max((float)VoxelBox.Min.Z, + Landing.FloorZ - Landing.FloorThickness), + FMath::Min((float)VoxelBox.Max.Z, + Landing.FloorZ + KINDA_SMALL_NUMBER), + (float)LatticeOrigin.Z, Step, IZ0, IZ1); + }; + + if (bBroadPassageFloor) + { + for (const FVoxelPassage& Passage : Passages) + { + if (HasAnyLandingSample(Passage.UpperLanding) + || HasAnyLandingSample(Passage.LowerLanding)) + { + return true; + } + + if (!Passage.bWalkableTunnelContract) + { + continue; + } + if (Passage.ControlPoints.Num() < 2 + || Passage.ControlRadii.Num() != Passage.ControlPoints.Num()) + { + return true; + } + + constexpr float Pad = 1.0f; + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Passage.ControlPoints.Num(); ++SegmentIndex) + { + const FVector& A = Passage.ControlPoints[SegmentIndex]; + const FVector& B = Passage.ControlPoints[SegmentIndex + 1]; + if (!FMath::IsFinite(A.X) || !FMath::IsFinite(A.Y) + || !FMath::IsFinite(A.Z) || !FMath::IsFinite(B.X) + || !FMath::IsFinite(B.Y) || !FMath::IsFinite(B.Z) + || !FMath::IsFinite(Passage.ControlRadii[SegmentIndex]) + || !FMath::IsFinite(Passage.ControlRadii[SegmentIndex + 1])) + { + return true; + } + const float SupportRadius = FMath::Max( + FMath::Min( + FMath::Abs(Passage.ControlRadii[SegmentIndex]), + FMath::Abs(Passage.ControlRadii[SegmentIndex + 1])) - 0.5f, + VoxelPassageGeometry::PlayerRadiusVoxels); + const float FloorA = VoxelPassageGeometry::TunnelFloorZ( + A, Passage.ControlRadii[SegmentIndex]); + const float FloorB = VoxelPassageGeometry::TunnelFloorZ( + B, Passage.ControlRadii[SegmentIndex + 1]); + const float MinX = FMath::Min(A.X, B.X) - SupportRadius - Pad; + const float MaxX = FMath::Max(A.X, B.X) + SupportRadius + Pad; + const float MinY = FMath::Min(A.Y, B.Y) - SupportRadius - Pad; + const float MaxY = FMath::Max(A.Y, B.Y) + SupportRadius + Pad; + const float MinZ = FMath::Min(FloorA, FloorB) + - VoxelPassageGeometry::LandingFloorThicknessVoxels - Pad; + const float MaxZ = FMath::Max(FloorA, FloorB) + Pad; + if (!FMath::IsFinite(MinX) || !FMath::IsFinite(MaxX) + || !FMath::IsFinite(MinY) || !FMath::IsFinite(MaxY) + || !FMath::IsFinite(MinZ) || !FMath::IsFinite(MaxZ)) + { + return true; + } + + int32 IX0 = 0, IX1 = -1, IY0 = 0, IY1 = -1, IZ0 = 0, IZ1 = -1; + if (!GetRange(FMath::Max((float)VoxelBox.Min.X, MinX), + FMath::Min((float)VoxelBox.Max.X, MaxX), IX0, IX1) + || !GetYRange(FMath::Max((float)VoxelBox.Min.Y, MinY), + FMath::Min((float)VoxelBox.Max.Y, MaxY), IY0, IY1) + || !GetZRange(FMath::Max((float)VoxelBox.Min.Z, MinZ), + FMath::Min((float)VoxelBox.Max.Z, MaxZ), IZ0, IZ1)) + { + continue; + } + + for (int32 IZ = IZ0; IZ <= IZ1; ++IZ) + { + const float Z = (float)LatticeOrigin.Z + (float)(IZ * Step); + for (int32 IY = IY0; IY <= IY1; ++IY) + { + const float Y = (float)LatticeOrigin.Y + (float)(IY * Step); + for (int32 IX = IX0; IX <= IX1; ++IX) + { + const FVector Position( + (float)LatticeOrigin.X + (float)(IX * Step), Y, Z); + if (VF_IsWalkableTunnelFloor(Passage, Position)) + { + return true; + } + } + } + } + } + } + } + + if (bBroadOriginFloor) + { + for (const FStrateSlot& Slot : StrateLayout) + { + if (Slot.Definition == nullptr) continue; + const float TopZ = (static_cast(Slot.TopChunkZ) + 1.0f) * CHUNK_SIZE; + const float BottomZ = static_cast(Slot.BottomChunkZ) * CHUNK_SIZE; + const float Seal = VF_BoundarySealThicknessForDefinition(*Slot.Definition); + const VoxelPassageGeometry::FOriginLandingGeometry Geometry = + VoxelPassageGeometry::BuildOriginLandingGeometry( + TopZ, BottomZ, Seal, OriginSpineRadius); + if (!Geometry.bValid) + { + continue; + } + const float HalfWidth = FMath::Max(Geometry.HalfWidth - 1.0f, 0.0f); + int32 IX0 = 0, IX1 = -1, IY0 = 0, IY1 = -1, IZ0 = 0, IZ1 = -1; + if (VF_LatticeAxisRange( + FMath::Max((float)VoxelBox.Min.X, -HalfWidth - 0.0f), + FMath::Min((float)VoxelBox.Max.X, HalfWidth + 0.0f), + (float)LatticeOrigin.X, Step, IX0, IX1) + && VF_LatticeAxisRange( + FMath::Max((float)VoxelBox.Min.Y, -HalfWidth - 0.0f), + FMath::Min((float)VoxelBox.Max.Y, HalfWidth + 0.0f), + (float)LatticeOrigin.Y, Step, IY0, IY1) + && VF_LatticeAxisRange( + FMath::Max((float)VoxelBox.Min.Z, + Geometry.FloorZ - Geometry.FloorThickness), + FMath::Min((float)VoxelBox.Max.Z, + Geometry.FloorZ + KINDA_SMALL_NUMBER), + (float)LatticeOrigin.Z, Step, IZ0, IZ1)) + { + return true; + } + } + } + return false; +} + bool UVoxelStrateManager::AnyOriginLandingNearBox( const FVector& MinVoxel, const FVector& MaxVoxel) const { @@ -2656,6 +4037,101 @@ bool UVoxelStrateManager::AnyOriginLandingNearLattice( return false; } +bool UVoxelStrateManager::AnyOriginLandingAirNearLattice( + const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step, + float BaseDensity, float SealThickness) const +{ + if (Step <= 0 || !VoxelBox.IsValid || OriginSpineRadius <= 0.0f) + { + return true; + } + if (!FMath::IsFinite(BaseDensity) || !(BaseDensity > 0.0f) + || !FMath::IsFinite(SealThickness) || SealThickness < 0.0f) + { + return true; + } + const float AirTarget = -(BaseDensity * 2.0f + SealThickness + 4.0f); + if (!FMath::IsFinite(AirTarget)) + { + return true; + } + + for (const FStrateSlot& Slot : StrateLayout) + { + if (Slot.Definition == nullptr) + { + continue; + } + const float TopZ = (static_cast(Slot.TopChunkZ) + 1.0f) * CHUNK_SIZE; + const float BottomZ = static_cast(Slot.BottomChunkZ) * CHUNK_SIZE; + const float Seal = VF_BoundarySealThicknessForDefinition(*Slot.Definition); + const VoxelPassageGeometry::FOriginLandingGeometry Geometry = + VoxelPassageGeometry::BuildOriginLandingGeometry( + TopZ, BottomZ, Seal, OriginSpineRadius); + if (!Geometry.bValid) + { + continue; + } + if (!VoxelPassageGeometry::OriginLandingRoomTouchesLattice( + VoxelBox, LatticeOrigin, Step, TopZ, BottomZ, Seal, OriginSpineRadius)) + { + continue; + } + + int32 FirstX = 0, LastX = -1; + int32 FirstY = 0, LastY = -1; + int32 FirstZ = 0, LastZ = -1; + if (!VF_LatticeAxisRange( + VoxelBox.Min.X, VoxelBox.Max.X, (float)LatticeOrigin.X, Step, FirstX, LastX) + || !VF_LatticeAxisRange( + VoxelBox.Min.Y, VoxelBox.Max.Y, (float)LatticeOrigin.Y, Step, FirstY, LastY) + || !VF_LatticeAxisRange( + VoxelBox.Min.Z, VoxelBox.Max.Z, (float)LatticeOrigin.Z, Step, FirstZ, LastZ)) + { + return true; + } + const int64 SampleCount = ((int64)LastX - FirstX + 1) + * ((int64)LastY - FirstY + 1) + * ((int64)LastZ - FirstZ + 1); + if (SampleCount <= 0 || SampleCount > 65536) + { + return true; + } + + for (int32 IZ = FirstZ; IZ <= LastZ; ++IZ) + for (int32 IY = FirstY; IY <= LastY; ++IY) + for (int32 IX = FirstX; IX <= LastX; ++IX) + { + const FVector Position( + (float)LatticeOrigin.X + (float)(IX * Step), + (float)LatticeOrigin.Y + (float)(IY * Step), + (float)LatticeOrigin.Z + (float)(IZ * Step)); + const float RoomSDF = VoxelPassageGeometry::OriginLandingRoomSDF( + Position, Geometry); + if (!FMath::IsFinite(RoomSDF)) + { + return true; + } + if (RoomSDF < VoxelPassageGeometry::LandingCarveBlendVoxels) + { + float CarveFactor = FMath::Clamp( + (VoxelPassageGeometry::LandingCarveBlendVoxels - RoomSDF) + / (VoxelPassageGeometry::LandingCarveBlendVoxels * 2.0f), + 0.0f, 1.0f); + CarveFactor = SmoothStep01(CarveFactor); + const float LandingThreshold = FMath::Lerp( + BaseDensity, AirTarget, CarveFactor); + if (!FMath::IsFinite(LandingThreshold) + || LandingThreshold <= 0.0f) + { + return true; + } + } + } + } + return false; +} + bool UVoxelStrateManager::AnyOriginLandingFloorNearLattice( const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step) const { diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index f91f217..079be10 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -11,6 +11,7 @@ #include "VoxelTerrainOpDefinition.h" #include "VoxelContentManager.h" #include "VoxelDensityVolume.h" +#include "VoxelDensityOpStack.h" #include "VoxelStats.h" // IWYU (FPSemantics = Precise ⇒ plus de PCH partagé) : GetPlayerPosition déréférence le pawn, donc // APawn doit être COMPLET — `Casts.h` n'en donne qu'une déclaration avant. APlayerController était @@ -984,6 +985,7 @@ void AVoxelWorld::BeginPlay() Mesher->bGenerateSkirts = Settings->bGenerateSkirts; Mesher->SkirtCells = Settings->SkirtCells; Mesher->LODOctaveDrop = Settings->LODOctaveDrop; // T2.b — 0 = off + Mesher->bUseBlockEarlyOut = true; // Système de strates — a cooked season takes precedence; otherwise the authored pool path is // exactly the legacy one. @@ -2123,17 +2125,43 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector double MeshSeconds = 0.0; double StreamSeconds = 0.0; int32 ClassifyVerdict = -1; // Mixed = 0, AllSolid = 1, AllAir = 2 + FVoxelTileClassificationStats ClassifierStats; auto EmitTileProfile = [&]() { if (bProfileTile) { UE_LOG(LogTemp, Display, TEXT("[VoxelForgeTileProfile] tile=(%d,%d,%d) level=%d step=%d cells=%d sheet=%d aborted=%d empty=%d " - "verdict=%d classify=%.6f mesh=%.6f streams=%.6f seconds=%.6f"), + "verdict=%d classify=%.6f mesh=%.6f streams=%.6f seconds=%.6f " + "refine=%u stack=%u core_samples=%u final_samples=%u core_hits=%u final_hits=%u " + "core_leaves=%u final_leaves=%u tail_queries=%u tail_eval=%u " + "stack_work=%.6f core_work=%.6f final_work=%.6f tail_work=%.6f " + "room_work=%.6f room_exact=%.6f cache_work=%.6f " + "rooms=%d tunnels=%d joins=%d pits=%d chimneys=%d " + "whole_mixed=%u whole_solid=%u whole_air=%u final_nodes=%u " + "split_nodes=%u max_depth=%u"), Tile.Coord.X, Tile.Coord.Y, Tile.Coord.Z, Tile.Level, Step, Cells, bSheetTile ? 1 : 0, Result.bAborted ? 1 : 0, Result.bEmpty ? 1 : 0, ClassifyVerdict, ClassifySeconds, MeshSeconds, StreamSeconds, - FPlatformTime::Seconds() - TileStartSeconds); + FPlatformTime::Seconds() - TileStartSeconds, + ClassifierStats.RefineNodes, ClassifierStats.StackBoxCalls, + ClassifierStats.ExactCoreSamples, ClassifierStats.ExactFinalSamples, + ClassifierStats.ExactCoreCacheHits, ClassifierStats.ExactFinalCacheHits, + ClassifierStats.ExactCoreLeaves, ClassifierStats.ExactFinalLeaves, + ClassifierStats.RoomTailQueries, ClassifierStats.RoomTailEvaluated, + FPlatformTime::ToSeconds64(ClassifierStats.StackBoxCycles), + FPlatformTime::ToSeconds64(ClassifierStats.ExactCoreCycles), + FPlatformTime::ToSeconds64(ClassifierStats.ExactFinalCycles), + FPlatformTime::ToSeconds64(ClassifierStats.RoomTailCycles), + FPlatformTime::ToSeconds64(ClassifierStats.RoomPropagateCycles), + FPlatformTime::ToSeconds64(ClassifierStats.RoomExactPrimitiveCycles), + FPlatformTime::ToSeconds64(ClassifierStats.RoomCacheWindowCycles), + ClassifierStats.RoomNumRooms, ClassifierStats.RoomNumTunnels, + ClassifierStats.RoomNumRoomFloorJoins, ClassifierStats.RoomNumPits, + ClassifierStats.RoomNumChimneys, + ClassifierStats.WholeMixedNodes, ClassifierStats.WholeSolidNodes, + ClassifierStats.WholeAirNodes, ClassifierStats.NeedsFinalFieldNodes, + ClassifierStats.SplitNodes, ClassifierStats.MaxRefinementDepth); } }; @@ -2175,7 +2203,8 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ClassifyTile); INC_DWORD_STAT(STAT_VoxelForgeTilesClassified); const double ClassifyStartSeconds = bProfileTile ? FPlatformTime::Seconds() : 0.0; - const EVoxelTileClass Verdict = Generator->ClassifyTile(OriginVoxels, Step, Cells); + const EVoxelTileClass Verdict = Generator->ClassifyTile( + OriginVoxels, Step, Cells, bProfileTile ? &ClassifierStats : nullptr); ClassifySeconds = bProfileTile ? FPlatformTime::Seconds() - ClassifyStartSeconds : 0.0; ClassifyVerdict = static_cast(Verdict); if (ShouldAbortWork()) diff --git a/Source/VoxelForge/Public/VoxelCaveMorphology.h b/Source/VoxelForge/Public/VoxelCaveMorphology.h index c3a23ba..f67476b 100644 --- a/Source/VoxelForge/Public/VoxelCaveMorphology.h +++ b/Source/VoxelForge/Public/VoxelCaveMorphology.h @@ -768,6 +768,14 @@ struct FChunkSDFCache // Allocator-backed bytes owned by this cache, including the nested tunnel chains. The returned // value excludes sizeof(FChunkSDFCache) itself so callers can add the enclosing entry's inline // storage exactly once. + // Reset keeps the allocated backing storage and clears the immutable contents. + // It is used when a classifier proves that the current search window has no + // possible room/tunnel feature. + void Reset(); + // Release all backing storage. Used when a worker has moved from a populated + // classifier window to a feature-free one and the retained capacity is no + // longer useful; this is intentionally separate from hot-path Reset(). + void Release(); SIZE_T GetAllocatedSize() const; VoxelDensityProfile::FCacheMemoryBreakdown GetAllocatedSizeBreakdown() const; }; @@ -830,6 +838,20 @@ namespace VoxelCaveMorphology const TArray* TerrainOps = nullptr ); + // Conservative, geometry-free preflight for BuildChunkCache. It recreates + // the deterministic room candidates and edge decisions, but does not build + // player-fit points or tunnel control chains. False means that the same + // cache window is guaranteed to contain no room, join, decoration, or + // connected tunnel. Any malformed or unbounded input returns true. + VOXELFORGE_API bool MayHaveFeatureInSearchBox( + float SearchMinX, float SearchMinY, + float SearchMaxX, float SearchMaxY, + const FStrateGenerationParams& Params, + uint32 Seed, int32 StrateIndex, + bool bUseZ = false, + float SearchMinZ = 0.0f, float SearchMaxZ = 0.0f + ); + // PHASE 2: Evaluate the SDF at a single world position using cached data. // Loops through cached rooms and tunnels, computes SDF primitives, applies // SmoothMin. Includes per-room/tunnel distance culling to skip primitives diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index 0d07bb4..f8a2c1e 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -130,6 +130,23 @@ public: int32 Num() const { return Ops.Num(); } + /** True when this stack publishes the requested immutable geometry resource. */ + bool ProvidesResource(EVoxelOpResourceMask Resource) const + { + if (Resource == VoxelOpResources::None) + { + return false; + } + for (const FOpEntry& Entry : Ops) + { + if ((Entry.ProvidedResources & Resource) == Resource) + { + return true; + } + } + return false; + } + // Allocator-backed bytes owned by the stack, including each concrete operator allocation. // The enclosing cache entry adds sizeof(FVoxelOpStack) separately. SIZE_T GetAllocatedSize() const; @@ -186,25 +203,10 @@ public: EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const { FVoxelBoxHypotheses H; - const bool bTask4OpProbe = Ctx.bUseLatticeProof - && Ctx.LatticeOriginVoxels == FIntVector(32, 0, -32) - && Ctx.Step == 1; for (const FOpEntry& Entry : Ops) { - const uint64 Task4OpStartCycles = bTask4OpProbe - ? FPlatformTime::Cycles64() : 0; VF_FoldOp(H, *Entry.Op, VoxelBox, Ctx, (Entry.Writes & VoxelOpChannels::Sdf) != 0); - if (bTask4OpProbe) - { - UE_LOG(LogTemp, Warning, - TEXT("[Task4OpProbe] box=(%.0f,%.0f,%.0f)-(%.0f,%.0f,%.0f) op=%s ms=%.3f"), - (float)VoxelBox.Min.X, (float)VoxelBox.Min.Y, (float)VoxelBox.Min.Z, - (float)VoxelBox.Max.X, (float)VoxelBox.Max.Y, (float)VoxelBox.Max.Z, - Entry.Op->DebugName(), - FPlatformTime::ToMilliseconds64( - FPlatformTime::Cycles64() - Task4OpStartCycles)); - } // Do not early-out on a dead hypothesis: a later forcing structural post may // deliberately overwrite it (the XY edge seal is appended after passage tube/landing // carving and support). @@ -502,6 +504,20 @@ namespace VoxelDensityOps * resserrement autour du mauvais terme. */ int32 HitRoomsNoWarp = 0, HitTunnelsNoWarp = 0; float WarpDilation = 0.0f; + + /** Conservative candidate for the world-space tunnel-core post that runs after EvalMC. + * This is derived from the final post's own world-chain bounds, not from the warped SDF + * source's broader threshold hit count. */ + bool bMayHaveTunnelCoreAir = false; + bool bMayHaveTunnelSupportFloor = false; + bool bMayHaveTunnelCoreTail = false; + uint32 ExactTailQueries = 0; + uint32 ExactTailEvaluated = 0; + uint64 ExactTailCycles = 0; + uint64 PropagateCycles = 0; + uint64 ExactPrimitiveCycles = 0; + uint64 CacheWindowCycles = 0; + int32 NumRoomFloorJoins = 0; }; VOXELFORGE_API FRoomBoxDiagnostic GetLastRoomBoxDiagnostic(); @@ -509,6 +525,12 @@ namespace VoxelDensityOps /** Current worker-local room-graph cache footprint; profiling/diagnostic use only. */ VOXELFORGE_API void ReportWorkerRoomGraphCacheFootprint(); + /** Current process-wide immutable room-graph proof-cache footprint; diagnostic use only. */ + VOXELFORGE_API void ReportSharedRoomGraphCacheFootprint(); + + /** Drop completed proof caches from older manager lifetimes, or all of them when zero. */ + VOXELFORGE_API void TrimSharedRoomGraphCache(uint64 KeepManagerLifetimeId = 0); + /** * FloatingIslands — 8 ops, et **la pile tourne à l'ENVERS** : * ConstantVoid → IslandBlob → SdfRoughness → SdfFill → [structural post ×4] diff --git a/Source/VoxelForge/Public/VoxelGenerator.h b/Source/VoxelForge/Public/VoxelGenerator.h index b88a6fa..e27be55 100644 --- a/Source/VoxelForge/Public/VoxelGenerator.h +++ b/Source/VoxelForge/Public/VoxelGenerator.h @@ -340,7 +340,11 @@ public: * des gardes conservatrices. Worker-safe (lecture seule + caches thread_local partagés avec * GetDensityAt — un verdict Mixed laisse les colonnes chaudes pour la génération qui suit). */ - EVoxelTileClass ClassifyTile(const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis) const; + EVoxelTileClass ClassifyTile(const FIntVector& OriginVoxels, int32 Step, + int32 CellsPerAxis) const; + EVoxelTileClass ClassifyTile(const FIntVector& OriginVoxels, int32 Step, + int32 CellsPerAxis, + struct FVoxelTileClassificationStats* OutStats) const; private: /** Identité process-unique du propriétaire des caches `CP_*` thread_local. @@ -380,3 +384,41 @@ private: void RebuildBiomeGrid(int32 ChunkX, int32 ChunkY, int32 ChunkZ, const FBiomeContext& Ctx, FChunkBiomeCache& Cache) const; }; + +/** + * Optional diagnostics for one ClassifyTile call. This is populated only when the caller asks + * for it (the streaming profiler does); it is never consulted by the proof and cannot affect a + * verdict. Keeping the counters beside the classifier lets the game profile distinguish cache, + * interval refinement, and exact-lattice work without sampling a different code path. + */ +struct FVoxelTileClassificationStats +{ + uint32 RefineNodes = 0; + uint32 StackBoxCalls = 0; + uint32 WholeMixedNodes = 0; + uint32 WholeSolidNodes = 0; + uint32 WholeAirNodes = 0; + uint32 NeedsFinalFieldNodes = 0; + uint32 SplitNodes = 0; + uint32 MaxRefinementDepth = 0; + uint32 ExactCoreSamples = 0; + uint32 ExactFinalSamples = 0; + uint32 ExactCoreCacheHits = 0; + uint32 ExactFinalCacheHits = 0; + uint32 ExactCoreLeaves = 0; + uint32 ExactFinalLeaves = 0; + uint64 StackBoxCycles = 0; + uint64 ExactCoreCycles = 0; + uint64 ExactFinalCycles = 0; + uint32 RoomTailQueries = 0; + uint32 RoomTailEvaluated = 0; + uint64 RoomTailCycles = 0; + uint64 RoomPropagateCycles = 0; + uint64 RoomExactPrimitiveCycles = 0; + uint64 RoomCacheWindowCycles = 0; + int32 RoomNumRooms = 0; + int32 RoomNumTunnels = 0; + int32 RoomNumRoomFloorJoins = 0; + int32 RoomNumPits = 0; + int32 RoomNumChimneys = 0; +}; diff --git a/Source/VoxelForge/Public/VoxelStrateManager.h b/Source/VoxelForge/Public/VoxelStrateManager.h index ddaf8a6..df2faef 100644 --- a/Source/VoxelForge/Public/VoxelStrateManager.h +++ b/Source/VoxelForge/Public/VoxelStrateManager.h @@ -427,8 +427,16 @@ public: * carve reaches a sampled point. Invalid geometry returns 1 conservatively. */ float MaxPassageCarveFactorNearLattice(const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step) const; + /** Exact conservative candidate for the post-stack passage air writers on the MC lattice. + * This deliberately excludes the passage modifier already folded by FPassageCarveOp. */ + bool AnyPassageAirPostNearLattice(const FBox& VoxelBox, + const FIntVector& LatticeOrigin, int32 Step, + float BaseDensity, float SealThickness) const; bool AnyPassageLandingFloorNearLattice(const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step) const; + /** Exact presence test for a structural floor write on the MC lattice. */ + bool AnyLandingFloorAtLattice(const FBox& VoxelBox, + const FIntVector& LatticeOrigin, int32 Step) const; /** Conservative box guards for the per-strate (0,0) landing rooms and their floor slabs. */ bool AnyOriginLandingNearBox(const FVector& MinVoxel, const FVector& MaxVoxel) const; @@ -436,6 +444,10 @@ public: bool AnyOriginLandingNearLattice(const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step) const; + /** Exact conservative candidate for the post-disturbance origin-room air reassertion. */ + bool AnyOriginLandingAirNearLattice(const FBox& VoxelBox, + const FIntVector& LatticeOrigin, int32 Step, + float BaseDensity, float SealThickness) const; bool AnyOriginLandingFloorNearLattice(const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step) const; diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp index 3c690c4..c891971 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -4940,6 +4940,10 @@ int32 RunExploreCase(const FString& Params, FString* OutJson) return 1; } + // A commandlet can run several fresh manager lifetimes in one process. Release completed + // immutable proof caches from the preceding case before constructing this one. + VoxelDensityOps::TrimSharedRoomGraphCache(); + // Every case starts from a disabled, empty diagnostic state. The world/cache objects are // local to this invocation and their owner/lifetime keys prevent stale worker caches from // serving the next case's generation. @@ -5529,6 +5533,8 @@ int32 RunExploreCase(const FString& Params, FString* OutJson) static_cast(MesherAttributionCalls)); VoxelDensityProfile::SetMode(VoxelDensityProfile::EMode::Disabled); } + VoxelDensityOps::ReportSharedRoomGraphCacheFootprint(); + VoxelDensityOps::TrimSharedRoomGraphCache(); // Per-case isolation boundary. The UObject world dies with this function; worker-local // caches are keyed by the fresh generator/manager lifetime and the diagnostics are reset before // the next case. No case may inherit profiler counters or a live profiler mode. @@ -5861,6 +5867,12 @@ int32 RunBatch(const FString& Params) { ++FailedCases; } + + // RunExploreCase owns a complete transient UObject world. Release that case's strong + // roots before starting the next one and collect now; otherwise a batch retains every + // completed mesh/settings/manager graph until the commandlet exits, making peak RSS grow + // with the number of cases even though no report needs those objects afterward. + CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS); } TSharedPtr BatchReport = MakeShared();