From 412a7ad219a8d493b182ea56cb417a6afd999c00 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Thu, 10 Sep 2026 11:05:09 +0200 Subject: [PATCH] perf(game): the path he actually walks -- meshed p95 4.91s -> 0.41s, RSS 19.5GB -> 1.7GB The owner: "I want real performance gain for the ingame path... not in deletion, but in optimization and tricks." Three rounds of ours had improved the commandlet export he never runs. This one is the streaming path. MEMORY -- it was retention, not the new lattice 19.5 GB was worker FChunkSDFCache arrays keeping capacity when keys were replaced (946 MB across 15 workers in diagnostics), shared graph caches with no byte or lifetime budget, and batch commandlets holding transient UObject worlds until exit. BlockSkip is bounded to 8^3 bytes per tile; interval scratch is worker-local and capped. Fixes: release storage on key replacement, LRU with a 256 MiB resident budget, limit in-flight graph builds, drop oversized entries, CollectGarbage between batch cases. peak RSS 19.5 GB -> 1.643 GB (their runs) / 1.71 GB (mine, 3x128^3) -91% a 3-case stress batch showed no accumulation WHY SOME EMPTY TILES COST 5.6s AND OTHERS 3us -- a 1.9 MILLION x spread Fast ones returned straight from the layout/edge proof (verdict AllAir/AllSolid, refine=0, mesh=0). Slow ones returned Mixed, so the caller ran the FULL mesher grid and only discovered emptiness by brute force -- logs show empty=1 after ~5 s of mesh work. The interval machinery now reaches a verdict before a mesh job is enqueued. IN GAME, 841 requests at streaming radius: count p50 p95 max empty 394 6.087 ms 12.113 ms 25.049 ms meshed 447 114.748 ms 410.314 ms 844.043 ms LOD0 empty 221 7.126 ms 11.901 ms 25.049 ms LOD0 meshed 122 355.794 ms 600.945 ms 844.043 ms against d38d4ca: meshed p50/p95/max 0.6085 / 4.9075 / 5.0199 s LOD4 empty max 5.618 s => meshed p50 5.3x, p95 12.0x, and the multi-second empty outliers are gone. REQUEST-TO-READY -- the number that answers "I can outrun the generation and fall" all tiles p50 57.576 ms p95 429.052 ms max 905.633 ms empty p50 30.943 ms p95 98.302 ms max 305.505 ms meshed p50 128.521 ms p95 462.235 ms max 905.633 ms At the harness walking speed of 6 m/s: p95 = 2.574 m, worst case = 5.434 m. A walking player now outruns tile readiness by a couple of metres, not by a strate. CAPABILITY PRESERVED, which is the point The owner asked that speed never come from quietly simplifying the world. It did not: geometry SHA-256 98eaad61..d3c4f is IDENTICAL to d38d4ca, 153,740 triangles, 82,516 vertices, across three runs. No resolution, sample-count, octave or seed reduction. Unknown intervals still cost the skip. Prove and skip, never simplify and hope. Export path, my own build, 3 runs: 1.088 / 1.102 / 1.100 s (was 1.171-1.258 s). ~9%, below the 17% noise floor, so not claimed as a speed win -- the export was not the target. Honest remainder: LOD0 empties are ~7 ms p50 with a 25 ms tail, not the 2-5 ms of a pure layout proof. Those are bounded, sound interval refinements that never invoke meshing; going lower needs stronger proof bounds, not more skipping. Verified by me: build 0 errors, binary timestamp checked against sources, peak RSS measured, hashes compared. Nothing outside Plugins/VoxelForge; RMC untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb --- .../Private/VoxelCaveMorphology.cpp | 823 +++++++- .../Private/VoxelDensityOpStack.cpp | 1747 +++++++++++++---- Source/VoxelForge/Private/VoxelGenerator.cpp | 614 ++++-- .../Private/VoxelMarchingCubesMesher.cpp | 171 +- .../VoxelForge/Private/VoxelStrateManager.cpp | 1594 ++++++++++++++- Source/VoxelForge/Private/VoxelWorld.cpp | 35 +- .../VoxelForge/Public/VoxelCaveMorphology.h | 22 + .../VoxelForge/Public/VoxelDensityOpStack.h | 52 +- Source/VoxelForge/Public/VoxelGenerator.h | 44 +- Source/VoxelForge/Public/VoxelStrateManager.h | 12 + .../Private/VoxelForgeExploreCommandlet.cpp | 12 + 11 files changed, 4374 insertions(+), 752 deletions(-) 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();