diff --git a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp index 7a4b6de..67de18c 100644 --- a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp +++ b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -69,279 +69,6 @@ namespace return DX * DX + DY * DY + DZ * DZ; } - FORCEINLINE bool VF_IndexCell(float Coordinate, int32& OutCell) - { - const float Scaled = Coordinate / static_cast(FCaveSpatialIndex::BinSizeVoxels); - if (!FMath::IsFinite(Scaled) - || Scaled < static_cast(MIN_int32) + 1.0f - || Scaled > static_cast(MAX_int32) - 1.0f) - { - return false; - } - OutCell = FMath::FloorToInt(Scaled); - return true; - } - - FORCEINLINE bool VF_IndexEntryRange( - const FCaveSpatialIndexEntry& Entry, - int32& OutMinX, int32& OutMinY, int32& OutMinZ, - int32& OutMaxX, int32& OutMaxY, int32& OutMaxZ) - { - const float MinX = static_cast(FMath::Min(Entry.Min.X, Entry.Max.X)); - const float MinY = static_cast(FMath::Min(Entry.Min.Y, Entry.Max.Y)); - const float MinZ = static_cast(FMath::Min(Entry.Min.Z, Entry.Max.Z)); - const float MaxX = static_cast(FMath::Max(Entry.Min.X, Entry.Max.X)); - const float MaxY = static_cast(FMath::Max(Entry.Min.Y, Entry.Max.Y)); - const float MaxZ = static_cast(FMath::Max(Entry.Min.Z, Entry.Max.Z)); - return VF_IndexCell(MinX, OutMinX) - && VF_IndexCell(MinY, OutMinY) - && VF_IndexCell(MinZ, OutMinZ) - && VF_IndexCell(MaxX, OutMaxX) - && VF_IndexCell(MaxY, OutMaxY) - && VF_IndexCell(MaxZ, OutMaxZ) - && OutMinX <= OutMaxX - && OutMinY <= OutMaxY - && OutMinZ <= OutMaxZ; - } -} - -void FCaveSpatialIndex::Reset() -{ - bValid = false; - MinBinX = 0; - MinBinY = 0; - MinBinZ = 0; - CellsX = 0; - CellsY = 0; - CellsZ = 0; - Bins.Reset(); - CandidateIndices.Reset(); -} - -void FCaveSpatialIndex::Build(const TArray& Entries) -{ - Reset(); - - // An empty cache is a valid empty index. This keeps the query path branch-light while still - // making malformed/overflowing non-empty indexes fall back to the exact full scan. - if (Entries.Num() == 0) - { - bValid = true; - return; - } - - constexpr int64 MaxBins = 1ll << 20; - constexpr int64 MaxReferences = 1ll << 23; - int32 GlobalMinX = MAX_int32; - int32 GlobalMinY = MAX_int32; - int32 GlobalMinZ = MAX_int32; - int32 GlobalMaxX = MIN_int32; - int32 GlobalMaxY = MIN_int32; - int32 GlobalMaxZ = MIN_int32; - - TArray> MinCells; - TArray> MaxCells; - MinCells.SetNumUninitialized(Entries.Num()); - MaxCells.SetNumUninitialized(Entries.Num()); - - int64 TotalReferences = 0; - for (int32 EntryIndex = 0; EntryIndex < Entries.Num(); ++EntryIndex) - { - const FCaveSpatialIndexEntry& Entry = Entries[EntryIndex]; - if (Entry.CandidateIndex < 0) - { - return; - } - - int32 MinX, MinY, MinZ, MaxX, MaxY, MaxZ; - if (!VF_IndexEntryRange(Entry, MinX, MinY, MinZ, MaxX, MaxY, MaxZ)) - { - return; - } - - const int64 SpanX = static_cast(MaxX) - MinX + 1; - const int64 SpanY = static_cast(MaxY) - MinY + 1; - const int64 SpanZ = static_cast(MaxZ) - MinZ + 1; - const int64 References = SpanX * SpanY * SpanZ; - if (References <= 0 || References > MaxReferences - || TotalReferences > MaxReferences - References) - { - return; - } - - MinCells[EntryIndex] = FIntVector(MinX, MinY, MinZ); - MaxCells[EntryIndex] = FIntVector(MaxX, MaxY, MaxZ); - GlobalMinX = FMath::Min(GlobalMinX, MinX); - GlobalMinY = FMath::Min(GlobalMinY, MinY); - GlobalMinZ = FMath::Min(GlobalMinZ, MinZ); - GlobalMaxX = FMath::Max(GlobalMaxX, MaxX); - GlobalMaxY = FMath::Max(GlobalMaxY, MaxY); - GlobalMaxZ = FMath::Max(GlobalMaxZ, MaxZ); - TotalReferences += References; - } - - const int64 NumBins64 = - (static_cast(GlobalMaxX) - GlobalMinX + 1) - * (static_cast(GlobalMaxY) - GlobalMinY + 1) - * (static_cast(GlobalMaxZ) - GlobalMinZ + 1); - if (NumBins64 <= 0 || NumBins64 > MaxBins || TotalReferences > MAX_int32) - { - return; - } - - MinBinX = GlobalMinX; - MinBinY = GlobalMinY; - MinBinZ = GlobalMinZ; - CellsX = GlobalMaxX - GlobalMinX + 1; - CellsY = GlobalMaxY - GlobalMinY + 1; - CellsZ = GlobalMaxZ - GlobalMinZ + 1; - - const int32 NumBins = static_cast(NumBins64); - Bins.SetNumZeroed(NumBins); - TArray Counts; - Counts.Init(0, NumBins); - - auto BinNumber = [this](int32 CellX, int32 CellY, int32 CellZ) -> int32 - { - const int64 X = static_cast(CellX) - MinBinX; - const int64 Y = static_cast(CellY) - MinBinY; - const int64 Z = static_cast(CellZ) - MinBinZ; - return static_cast((Z * CellsY + Y) * CellsX + X); - }; - - for (int32 EntryIndex = 0; EntryIndex < Entries.Num(); ++EntryIndex) - { - const FIntVector MinCell = MinCells[EntryIndex]; - const FIntVector MaxCell = MaxCells[EntryIndex]; - for (int32 Z = MinCell.Z; Z <= MaxCell.Z; ++Z) - for (int32 Y = MinCell.Y; Y <= MaxCell.Y; ++Y) - for (int32 X = MinCell.X; X <= MaxCell.X; ++X) - { - ++Counts[BinNumber(X, Y, Z)]; - } - } - - int32 Running = 0; - for (int32 BinIndex = 0; BinIndex < NumBins; ++BinIndex) - { - Bins[BinIndex].First = Running; - Bins[BinIndex].Count = Counts[BinIndex]; - Running += Counts[BinIndex]; - } - - CandidateIndices.SetNumUninitialized(Running); - TArray WriteOffsets; - WriteOffsets.SetNumUninitialized(NumBins); - for (int32 BinIndex = 0; BinIndex < NumBins; ++BinIndex) - { - WriteOffsets[BinIndex] = Bins[BinIndex].First; - } - - // Entries are generated outermost by candidate ID and innermost by segment index. Therefore - // every bin receives nondecreasing IDs here. Duplicates are removed at query time with one - // LastCandidate comparison; preserving this order also preserves the old deterministic SDF - // reduction order. - for (int32 EntryIndex = 0; EntryIndex < Entries.Num(); ++EntryIndex) - { - const FCaveSpatialIndexEntry& Entry = Entries[EntryIndex]; - const FIntVector MinCell = MinCells[EntryIndex]; - const FIntVector MaxCell = MaxCells[EntryIndex]; - for (int32 Z = MinCell.Z; Z <= MaxCell.Z; ++Z) - for (int32 Y = MinCell.Y; Y <= MaxCell.Y; ++Y) - for (int32 X = MinCell.X; X <= MaxCell.X; ++X) - { - const int32 BinIndex = BinNumber(X, Y, Z); - CandidateIndices[WriteOffsets[BinIndex]++] = Entry.CandidateIndex; - } - } - - bValid = true; -} - -bool FCaveSpatialIndex::GetBinAt( - int32 CellX, int32 CellY, int32 CellZ, - int32& OutFirst, int32& OutCount) const -{ - OutFirst = 0; - OutCount = 0; - if (!bValid) - { - return false; - } - if (CellsX <= 0 || CellX < MinBinX || CellX >= MinBinX + CellsX - || CellY < MinBinY || CellY >= MinBinY + CellsY - || CellZ < MinBinZ || CellZ >= MinBinZ + CellsZ) - { - return true; - } - - const int64 LocalX = static_cast(CellX) - MinBinX; - const int64 LocalY = static_cast(CellY) - MinBinY; - const int64 LocalZ = static_cast(CellZ) - MinBinZ; - const int64 BinIndex = (LocalZ * CellsY + LocalY) * CellsX + LocalX; - const FCaveSpatialIndexBin& Bin = Bins[static_cast(BinIndex)]; - OutFirst = Bin.First; - OutCount = Bin.Count; - return true; -} - -bool FCaveSpatialIndex::GetBin( - float X, float Y, float Z, - int32& OutFirst, int32& OutCount) const -{ - OutFirst = 0; - OutCount = 0; - if (!bValid) - { - return false; - } - - int32 CellX, CellY, CellZ; - if (!VF_IndexCell(X, CellX) || !VF_IndexCell(Y, CellY) || !VF_IndexCell(Z, CellZ)) - { - return false; - } - return GetBinAt(CellX, CellY, CellZ, OutFirst, OutCount); -} - -template -bool FCaveSpatialIndex::GetColumnCandidates( - float X, float Y, TArray& OutCandidates) const -{ - OutCandidates.Reset(); - if (!bValid) - { - return false; - } - - int32 CellX, CellY; - if (!VF_IndexCell(X, CellX) || !VF_IndexCell(Y, CellY)) - { - return false; - } - if (CellsX <= 0 || CellX < MinBinX || CellX >= MinBinX + CellsX - || CellY < MinBinY || CellY >= MinBinY + CellsY) - { - return true; - } - - // The support decision is a column query, so intentionally inspect every height bin for this - // XY cell. This work occurs once per column, not once per sample; the regular SDF/core query - // still uses the full 3D bin lookup above. - for (int32 CellZ = MinBinZ; CellZ < MinBinZ + CellsZ; ++CellZ) - { - int32 First, Count; - GetBinAt(CellX, CellY, CellZ, First, Count); - for (int32 Offset = 0; Offset < Count; ++Offset) - { - const int32 Candidate = CandidateIndices[First + Offset]; - if (!OutCandidates.Contains(Candidate)) - { - OutCandidates.Add(Candidate); - } - } - } - return true; } SIZE_T FChunkSDFCache::GetAllocatedSize() const @@ -378,14 +105,6 @@ VoxelDensityProfile::FCacheMemoryBreakdown FChunkSDFCache::GetAllocatedSizeBreak Breakdown.TunnelsBytes += ArrayBytes(Tunnel.WorldControlRadii); } - Breakdown.RoomIndexBinsBytes = ArrayBytes(RoomIndex.Bins); - Breakdown.RoomIndexCandidatesBytes = ArrayBytes(RoomIndex.CandidateIndices); - Breakdown.RoomFloorJoinIndexBinsBytes = ArrayBytes(RoomFloorJoinIndex.Bins); - Breakdown.RoomFloorJoinIndexCandidatesBytes = ArrayBytes(RoomFloorJoinIndex.CandidateIndices); - Breakdown.TunnelIndexBinsBytes = ArrayBytes(TunnelIndex.Bins); - Breakdown.TunnelIndexCandidatesBytes = ArrayBytes(TunnelIndex.CandidateIndices); - Breakdown.TunnelWorldIndexBinsBytes = ArrayBytes(TunnelWorldIndex.Bins); - Breakdown.TunnelWorldIndexCandidatesBytes = ArrayBytes(TunnelWorldIndex.CandidateIndices); return Breakdown; } @@ -447,115 +166,6 @@ static void BakeRoomFeature( } } -namespace -{ - FORCEINLINE void VF_AddSpatialEntry( - TArray& Entries, - int32 CandidateIndex, - const FVector& InMin, - const FVector& InMax) - { - constexpr float Epsilon = 0.001f; - FCaveSpatialIndexEntry& Entry = Entries.Emplace_GetRef(); - Entry.CandidateIndex = CandidateIndex; - Entry.Min = FVector( - FMath::Min(InMin.X, InMax.X) - Epsilon, - FMath::Min(InMin.Y, InMax.Y) - Epsilon, - FMath::Min(InMin.Z, InMax.Z) - Epsilon); - Entry.Max = FVector( - FMath::Max(InMin.X, InMax.X) + Epsilon, - FMath::Max(InMin.Y, InMax.Y) + Epsilon, - FMath::Max(InMin.Z, InMax.Z) + Epsilon); - } - - FORCEINLINE void VF_AddSegmentSpatialEntry( - TArray& Entries, - int32 CandidateIndex, - const FVector& A, - const FVector& B, - float PadXY, - float PadZ) - { - const FVector Pad(PadXY, PadXY, PadZ); - VF_AddSpatialEntry(Entries, CandidateIndex, - FVector(FMath::Min(A.X, B.X), FMath::Min(A.Y, B.Y), FMath::Min(A.Z, B.Z)) - Pad, - FVector(FMath::Max(A.X, B.X), FMath::Max(A.Y, B.Y), FMath::Max(A.Z, B.Z)) + Pad); - } - - static void VF_AppendTunnelSpatialEntries( - TArray& Entries, - int32 TunnelIndex, - const TArray& Points, - const TArray& Radii, - float BlendPad, - float VerticalPad) - { - if (Points.Num() >= 2 && Radii.Num() == Points.Num()) - { - for (int32 SegmentIndex = 0; SegmentIndex + 1 < Points.Num(); ++SegmentIndex) - { - const float Radius = FMath::Max( - FMath::Abs(Radii[SegmentIndex]), - FMath::Abs(Radii[SegmentIndex + 1])); - VF_AddSegmentSpatialEntry( - Entries, TunnelIndex, Points[SegmentIndex], Points[SegmentIndex + 1], - Radius + BlendPad, Radius + VerticalPad); - } - return; - } - - // Keep the descriptor compatibility path conservative as well. Current caches always use - // the chain, but cooked/diagnostic caches can still contain the old midpoint descriptor. - if (Points.Num() >= 2) - { - VF_AddSegmentSpatialEntry( - Entries, TunnelIndex, Points[0], Points[1], - BlendPad, VerticalPad); - } - } - - static void VF_AppendTunnelDescriptorSpatialEntries( - TArray& Entries, - int32 TunnelIndex, - const FCachedTunnel& Tunnel, - float BlendPad, - float VerticalPad) - { - if (Tunnel.ControlPoints.Num() >= 2 - && Tunnel.ControlRadii.Num() == Tunnel.ControlPoints.Num()) - { - VF_AppendTunnelSpatialEntries( - Entries, TunnelIndex, Tunnel.ControlPoints, Tunnel.ControlRadii, - BlendPad, VerticalPad); - return; - } - - // Keep old cooked/diagnostic descriptors conservative too. The current - // cache always has a control chain, but the evaluator still supports the - // endpoint/midpoint representation and its index must cover that path. - if (Tunnel.bHasMidpoint) - { - const float RadiusA = FMath::Max( - FMath::Abs(Tunnel.RadiusA), FMath::Abs(Tunnel.RadiusMid)); - const float RadiusB = FMath::Max( - FMath::Abs(Tunnel.RadiusMid), FMath::Abs(Tunnel.RadiusB)); - VF_AddSegmentSpatialEntry( - Entries, TunnelIndex, Tunnel.EndpointA, Tunnel.Midpoint, - RadiusA + BlendPad, RadiusA + VerticalPad); - VF_AddSegmentSpatialEntry( - Entries, TunnelIndex, Tunnel.Midpoint, Tunnel.EndpointB, - RadiusB + BlendPad, RadiusB + VerticalPad); - } - else - { - const float Radius = FMath::Max( - FMath::Abs(Tunnel.RadiusA), FMath::Abs(Tunnel.RadiusB)); - VF_AddSegmentSpatialEntry( - Entries, TunnelIndex, Tunnel.EndpointA, Tunnel.EndpointB, - Radius + BlendPad, Radius + VerticalPad); - } - } - static void VF_FinalizeTunnelBroadPhaseBounds(FCachedTunnel& Tunnel, float BlendK) { const float BlendPad = FMath::Max(BlendK, 0.0f); @@ -637,68 +247,16 @@ namespace } } - static void VF_BuildCacheSpatialIndices( + static void VF_FinalizeCacheBroadPhaseBounds( FChunkSDFCache& Cache, float BlendK) { - TArray RoomEntries; - TArray JoinEntries; - TArray TunnelEntries; - TArray WorldTunnelEntries; - RoomEntries.Reserve(Cache.Rooms.Num()); - JoinEntries.Reserve(Cache.RoomFloorJoins.Num()); - - for (int32 Index = 0; Index < Cache.Rooms.Num(); ++Index) - { - const FCachedRoom& Room = Cache.Rooms[Index]; - const float Radius = FMath::Sqrt(FMath::Max(Room.CullRadiusSq, 0.0f)); - const FVector Extent(Radius, Radius, Radius); - VF_AddSpatialEntry(RoomEntries, Index, Room.Center - Extent, Room.Center + Extent); - } - for (int32 Index = 0; Index < Cache.RoomFloorJoins.Num(); ++Index) - { - const FCachedRoomFloorJoin& Join = Cache.RoomFloorJoins[Index]; - const float Radius = FMath::Sqrt(FMath::Max(Join.BoundRadiusSq, 0.0f)); - const FVector Extent(Radius, Radius, Radius); - VF_AddSpatialEntry(JoinEntries, Index, - Join.BoundCenter - Extent, Join.BoundCenter + Extent); - } - for (int32 Index = 0; Index < Cache.Tunnels.Num(); ++Index) { FCachedTunnel& Tunnel = Cache.Tunnels[Index]; VF_FinalizeTunnelBroadPhaseBounds(Tunnel, BlendK); - - const float SDFPad = FMath::Max(BlendK, 0.0f) * 3.0f; - VF_AppendTunnelDescriptorSpatialEntries( - TunnelEntries, Index, Tunnel, SDFPad, SDFPad); - - const bool bHasWorldChain = Tunnel.WorldControlPoints.Num() >= 2 - && Tunnel.WorldControlRadii.Num() == Tunnel.WorldControlPoints.Num(); - if (bHasWorldChain) - { - VF_AppendTunnelSpatialEntries( - WorldTunnelEntries, Index, - Tunnel.WorldControlPoints, Tunnel.WorldControlRadii, - VoxelPassageGeometry::LandingFloorThicknessVoxels, - VoxelPassageGeometry::LandingFloorThicknessVoxels); - } - else - { - VF_AppendTunnelDescriptorSpatialEntries( - WorldTunnelEntries, Index, Tunnel, - VoxelPassageGeometry::LandingFloorThicknessVoxels, - VoxelPassageGeometry::LandingFloorThicknessVoxels); - } } - - Cache.RoomIndex.Build(RoomEntries); - Cache.RoomFloorJoinIndex.Build(JoinEntries); - Cache.TunnelIndex.Build(TunnelEntries); - Cache.TunnelWorldIndex.Build(WorldTunnelEntries); } -} - namespace { // Runtime landing queries use the same pinned capsule dimensions and support rules as the @@ -3277,10 +2835,6 @@ void VoxelCaveMorphology::BuildChunkCache( OutCache.Pits.Reset(); OutCache.Chimneys.Reset(); OutCache.Columns.Reset(); - OutCache.RoomIndex.Reset(); - OutCache.RoomFloorJoinIndex.Reset(); - OutCache.TunnelIndex.Reset(); - OutCache.TunnelWorldIndex.Reset(); OutCache.SupportColumnMinX = 0; OutCache.SupportColumnMinY = 0; OutCache.SupportColumnCellsX = 0; @@ -3480,7 +3034,7 @@ void VoxelCaveMorphology::BuildChunkCache( const int32 NumRooms = BuildRooms.Num(); if (NumRooms == 0) { - VF_BuildCacheSpatialIndices(OutCache, BlendK); + VF_FinalizeCacheBroadPhaseBounds(OutCache, BlendK); return; } @@ -4366,7 +3920,7 @@ void VoxelCaveMorphology::BuildChunkCache( // All candidate arrays are complete now. Build the immutable broad phase only after every // deterministic room/tunnel decision and feature bake has finished. - VF_BuildCacheSpatialIndices(OutCache, BlendK); + VF_FinalizeCacheBroadPhaseBounds(OutCache, BlendK); } //============================================================================= @@ -4394,15 +3948,11 @@ float VoxelCaveMorphology::EvaluateSDFCached( float NearestRoomRawSDF = FLT_MAX; int32 NearestIdx = -1; - int32 RoomFirst = 0; - int32 RoomCount = 0; - const bool bIndexedRooms = Cache.RoomIndex.GetBin( - WorldX, WorldY, WorldZ, RoomFirst, RoomCount); if (VoxelDensityProfile::AreCountersEnabled()) { VoxelDensityProfile::AddCounter( VoxelDensityProfile::ECounter::CaveRoomCandidates, - static_cast(bIndexedRooms ? RoomCount : Cache.Rooms.Num())); + static_cast(Cache.Rooms.Num())); } //========================================================================= @@ -4459,19 +4009,9 @@ float VoxelCaveMorphology::EvaluateSDFCached( MinSDF = VoxelSDF::SmoothMin(MinSDF, RoomSDF, BlendK); }; - if (bIndexedRooms) + for (int32 RoomIdx = 0; RoomIdx < Cache.Rooms.Num(); ++RoomIdx) { - for (int32 Offset = 0; Offset < RoomCount; ++Offset) - { - EvaluateRoom(Cache.RoomIndex.CandidateIndices[RoomFirst + Offset]); - } - } - else - { - for (int32 RoomIdx = 0; RoomIdx < Cache.Rooms.Num(); ++RoomIdx) - { - EvaluateRoom(RoomIdx); - } + EvaluateRoom(RoomIdx); } //========================================================================== @@ -4481,10 +4021,6 @@ float VoxelCaveMorphology::EvaluateSDFCached( // single horizontal plane, so a player crossing the shared volume cannot meet the tall lip // produced by two independent floor cuts. They are deliberately not reported as rooms and do // not participate in nearest-room terrain-op ownership. - int32 JoinFirst = 0; - int32 JoinCount = 0; - const bool bIndexedJoins = Cache.RoomFloorJoinIndex.GetBin( - WorldX, WorldY, WorldZ, JoinFirst, JoinCount); auto EvaluateJoin = [&](int32 JoinIdx) { const FCachedRoomFloorJoin& Join = Cache.RoomFloorJoins[JoinIdx]; @@ -4499,33 +4035,19 @@ float VoxelCaveMorphology::EvaluateSDFCached( const float JoinSDF = FMath::Max(HorizontalSDF, VerticalSDF); MinSDF = VoxelSDF::SmoothMin(MinSDF, JoinSDF, BlendK); }; - if (bIndexedJoins) + for (int32 JoinIdx = 0; JoinIdx < Cache.RoomFloorJoins.Num(); ++JoinIdx) { - for (int32 Offset = 0; Offset < JoinCount; ++Offset) - { - EvaluateJoin(Cache.RoomFloorJoinIndex.CandidateIndices[JoinFirst + Offset]); - } - } - else - { - for (int32 JoinIdx = 0; JoinIdx < Cache.RoomFloorJoins.Num(); ++JoinIdx) - { - EvaluateJoin(JoinIdx); - } + EvaluateJoin(JoinIdx); } //========================================================================= // Tunnel SDFs //========================================================================= - int32 TunnelFirst = 0; - int32 TunnelCount = 0; - const bool bIndexedTunnels = Cache.TunnelIndex.GetBin( - WorldX, WorldY, WorldZ, TunnelFirst, TunnelCount); if (VoxelDensityProfile::AreCountersEnabled()) { VoxelDensityProfile::AddCounter( VoxelDensityProfile::ECounter::CaveTunnelCandidates, - static_cast(bIndexedTunnels ? TunnelCount : Cache.Tunnels.Num())); + static_cast(Cache.Tunnels.Num())); } auto EvaluateTunnel = [&](int32 TunnelIdx) @@ -4590,23 +4112,9 @@ float VoxelCaveMorphology::EvaluateSDFCached( MinSDF = VoxelSDF::SmoothMin(MinSDF, TunnelSDF, BlendK); }; - if (bIndexedTunnels) + for (int32 TunnelIdx = 0; TunnelIdx < Cache.Tunnels.Num(); ++TunnelIdx) { - int32 LastTunnelIdx = INDEX_NONE; - for (int32 Offset = 0; Offset < TunnelCount; ++Offset) - { - const int32 TunnelIdx = Cache.TunnelIndex.CandidateIndices[TunnelFirst + Offset]; - if (TunnelIdx == LastTunnelIdx) continue; - LastTunnelIdx = TunnelIdx; - EvaluateTunnel(TunnelIdx); - } - } - else - { - for (int32 TunnelIdx = 0; TunnelIdx < Cache.Tunnels.Num(); ++TunnelIdx) - { - EvaluateTunnel(TunnelIdx); - } + EvaluateTunnel(TunnelIdx); } // Write nearest room index for the caller (terrain ops system) @@ -4625,10 +4133,6 @@ float VoxelCaveMorphology::EvaluateTunnelCoreSDF( const FVector Pos(WorldX, WorldY, WorldZ); float MinSDF = FLT_MAX; - int32 TunnelFirst = 0; - int32 TunnelCount = 0; - const bool bIndexedTunnels = Cache.TunnelIndex.GetBin( - WorldX, WorldY, WorldZ, TunnelFirst, TunnelCount); auto EvaluateTunnel = [&](int32 TunnelIdx) { const FCachedTunnel& Tunnel = Cache.Tunnels[TunnelIdx]; @@ -4686,23 +4190,9 @@ float VoxelCaveMorphology::EvaluateTunnelCoreSDF( } }; - if (bIndexedTunnels) + for (int32 TunnelIdx = 0; TunnelIdx < Cache.Tunnels.Num(); ++TunnelIdx) { - int32 LastTunnelIdx = INDEX_NONE; - for (int32 Offset = 0; Offset < TunnelCount; ++Offset) - { - const int32 TunnelIdx = Cache.TunnelIndex.CandidateIndices[TunnelFirst + Offset]; - if (TunnelIdx == LastTunnelIdx) continue; - LastTunnelIdx = TunnelIdx; - EvaluateTunnel(TunnelIdx); - } - } - else - { - for (int32 TunnelIdx = 0; TunnelIdx < Cache.Tunnels.Num(); ++TunnelIdx) - { - EvaluateTunnel(TunnelIdx); - } + EvaluateTunnel(TunnelIdx); } return MinSDF; @@ -4716,10 +4206,6 @@ FTunnelCoreWorldEvaluation VoxelCaveMorphology::EvaluateTunnelCoreWorld( const FVector Pos(WorldX, WorldY, WorldZ); FTunnelCoreWorldEvaluation Result; - int32 TunnelFirst = 0; - int32 TunnelCount = 0; - const bool bIndexedTunnels = Cache.TunnelWorldIndex.GetBin( - WorldX, WorldY, WorldZ, TunnelFirst, TunnelCount); if (SupportColumn != nullptr && VoxelDensityProfile::AreCountersEnabled()) { VoxelDensityProfile::AddCounter( @@ -4729,7 +4215,7 @@ FTunnelCoreWorldEvaluation VoxelCaveMorphology::EvaluateTunnelCoreWorld( { VoxelDensityProfile::AddCounter( VoxelDensityProfile::ECounter::TunnelCoreCandidates, - static_cast(bIndexedTunnels ? TunnelCount : Cache.Tunnels.Num())); + static_cast(Cache.Tunnels.Num())); } auto EvaluateTunnel = [&](int32 TunnelIdx) @@ -4848,23 +4334,9 @@ FTunnelCoreWorldEvaluation VoxelCaveMorphology::EvaluateTunnelCoreWorld( } }; - if (bIndexedTunnels) + for (int32 TunnelIdx = 0; TunnelIdx < Cache.Tunnels.Num(); ++TunnelIdx) { - int32 LastTunnelIdx = INDEX_NONE; - for (int32 Offset = 0; Offset < TunnelCount; ++Offset) - { - const int32 TunnelIdx = Cache.TunnelWorldIndex.CandidateIndices[TunnelFirst + Offset]; - if (TunnelIdx == LastTunnelIdx) continue; - LastTunnelIdx = TunnelIdx; - EvaluateTunnel(TunnelIdx); - } - } - else - { - for (int32 TunnelIdx = 0; TunnelIdx < Cache.Tunnels.Num(); ++TunnelIdx) - { - EvaluateTunnel(TunnelIdx); - } + EvaluateTunnel(TunnelIdx); } return Result; @@ -4883,15 +4355,11 @@ bool VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( { const FVector Pos(WorldX, WorldY, WorldZ); - int32 TunnelFirst = 0; - int32 TunnelCount = 0; - const bool bIndexedTunnels = Cache.TunnelWorldIndex.GetBin( - WorldX, WorldY, WorldZ, TunnelFirst, TunnelCount); if (VoxelDensityProfile::AreCountersEnabled()) { VoxelDensityProfile::AddCounter( VoxelDensityProfile::ECounter::TunnelCoreCandidates, - static_cast(bIndexedTunnels ? TunnelCount : Cache.Tunnels.Num())); + static_cast(Cache.Tunnels.Num())); } auto TestTunnel = [&](int32 TunnelIdx) -> bool @@ -4936,40 +4404,17 @@ bool VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( }; bool bSupport = false; - if (bIndexedTunnels) + for (int32 TunnelIdx = 0; TunnelIdx < Cache.Tunnels.Num(); ++TunnelIdx) { - int32 LastTunnelIdx = INDEX_NONE; - for (int32 Offset = 0; Offset < TunnelCount; ++Offset) + if (TestTunnel(TunnelIdx)) { - const int32 TunnelIdx = Cache.TunnelWorldIndex.CandidateIndices[TunnelFirst + Offset]; - if (TunnelIdx == LastTunnelIdx) continue; - LastTunnelIdx = TunnelIdx; - if (TestTunnel(TunnelIdx)) + bSupport = true; + if (VoxelDensityProfile::AreCountersEnabled()) { - bSupport = true; - if (VoxelDensityProfile::AreCountersEnabled()) - { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::TunnelCoreEvaluated); - } - break; - } - } - } - else - { - for (int32 TunnelIdx = 0; TunnelIdx < Cache.Tunnels.Num(); ++TunnelIdx) - { - if (TestTunnel(TunnelIdx)) - { - bSupport = true; - if (VoxelDensityProfile::AreCountersEnabled()) - { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::TunnelCoreEvaluated); - } - break; + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelCoreEvaluated); } + break; } } return bSupport; @@ -4988,25 +4433,14 @@ void VoxelCaveMorphology::BuildTunnelSupportFloorColumn( VoxelDensityProfile::ECounter::TunnelSupportColumnBuilds); } - TArray CandidateIds; - const bool bIndexed = Cache.TunnelWorldIndex.GetColumnCandidates(WorldX, WorldY, CandidateIds); - if (!bIndexed) - { - CandidateIds.Reserve(Cache.Tunnels.Num()); - for (int32 TunnelIdx = 0; TunnelIdx < Cache.Tunnels.Num(); ++TunnelIdx) - { - CandidateIds.Add(TunnelIdx); - } - } - if (VoxelDensityProfile::AreCountersEnabled()) { VoxelDensityProfile::AddCounter( VoxelDensityProfile::ECounter::TunnelSupportColumnCandidates, - static_cast(CandidateIds.Num())); + static_cast(Cache.Tunnels.Num())); } - for (const int32 TunnelIdx : CandidateIds) + for (int32 TunnelIdx = 0; TunnelIdx < Cache.Tunnels.Num(); ++TunnelIdx) { if (!Cache.Tunnels.IsValidIndex(TunnelIdx)) { diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 9a2e4c4..e56584b 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -89,6 +89,12 @@ namespace */ static constexpr float VF_PerlinAbsBound = 1.5f; + // Cellular3D returns 2*(F2-F1)-1 without a clamp. Every searched feature point is at most + // sqrt(12) from the query's unit cell, so F2-F1 <= sqrt(12) and the positive supremum is + // 2*sqrt(12)-1 = 5.9282... . Round that up for float evaluation instead of trusting the + // "~[-1,1]" comment in the noise implementation (which is an observation, not a proof). + static constexpr float VF_CellularAbsBound = 6.0f; + /** La même enveloppe que `FractalNoise3D` de VoxelGenerator.cpp (qui y est `static`, donc * invisible ici). Les coordonnées de bruit restent en float : FVector est double dans UE5 * et son aller-retour ne servait qu'à conserver l'ancien arrondi, exigence abandonnée. */ @@ -1637,6 +1643,22 @@ namespace ? EVoxelOpEffect::Both : EVoxelOpEffect::CarveOnly; } + // The landing carve interpolates from the incoming density toward an air target, so its + // delta is unbounded without an input-density interval. It is nevertheless exactly + // inactive outside the geometric influence box; return zero there so an unrelated block + // does not inherit the interface's conservative FLT_MAX sentinel. + float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + { + const EVoxelOpEffect Effect = EffectOverBox(VoxelBox, Ctx); + return (Effect == EVoxelOpEffect::CarveOnly || Effect == EVoxelOpEffect::Both) + ? FLT_MAX : 0.0f; + } + + float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + { + return EffectOverBox(VoxelBox, Ctx) == EVoxelOpEffect::Both ? FLT_MAX : 0.0f; + } + const TCHAR* DebugName() const override { return TEXT("OriginSpineOp"); } private: @@ -1709,6 +1731,15 @@ namespace return EVoxelOpEffect::FillOnly; } + // A partial seal can raise an arbitrarily negative incoming density, so its fill delta is + // genuinely unbounded through this API. Outside both bands it is an exact identity. + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } + + float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + { + return EffectOverBox(VoxelBox, Ctx) == EVoxelOpEffect::Identity ? 0.0f : FLT_MAX; + } + const TCHAR* DebugName() const override { return TEXT("BoundarySealOp"); } private: @@ -1765,7 +1796,14 @@ namespace { return VF_XYEdgeSealBoxTouchesBand( VoxelBox, Ctx.WorldRadiusVoxels, Ctx.EdgeSealThickness) - ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity; + ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity; + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } + + float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + { + return EffectOverBox(VoxelBox, Ctx) == EVoxelOpEffect::Identity ? 0.0f : FLT_MAX; } bool IsXYPure() const override { return true; } @@ -1827,6 +1865,21 @@ namespace ? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity; } + // The current-density Lerp/Min passage carve has no finite delta bound for an arbitrary + // incoming density. Keep that honest where it can affect a box, but expose exact identity + // for the overwhelmingly common no-passage case. + float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + { + const EVoxelOpEffect Effect = EffectOverBox(VoxelBox, Ctx); + return (Effect == EVoxelOpEffect::CarveOnly || Effect == EVoxelOpEffect::Both) + ? FLT_MAX : 0.0f; + } + + float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + { + return EffectOverBox(VoxelBox, Ctx) == EVoxelOpEffect::Both ? FLT_MAX : 0.0f; + } + const TCHAR* DebugName() const override { return TEXT("PassageCarveOp"); } private: @@ -3486,6 +3539,93 @@ namespace return EVoxelOpEffect::Identity; } + // This operator publishes SDF and does not change Density. Reporting zero here is + // important: FLT_MAX means "an unknown density delta", whereas this source has no density + // delta at all. The SDF interval remains the source's separate box contract below. + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } + + /** + * State-aware detail gate support. A room terrain op can replace the strate-level + * activation/strength fields for the room selected by Eval. Until the cache tells us + * that the queried box contains no such override, a detail bound based on P alone would be + * unsound. Returning true on an invalid box state deliberately loses a skip rather than + * manufacturing one. + */ + bool HasRoomTerrainOverrideForLastBox() const + { + const FBoxState& B = BoxState(); + if (!B.bValid) { return true; } + for (const FCachedRoom& Room : B.Cache.Rooms) + { + if (Room.RoomOp != nullptr) { return true; } + } + return false; + } + + bool HasRoomColumnsForLastBox() const + { + const FBoxState& B = BoxState(); + return !B.bValid || B.Cache.Columns.Num() > 0; + } + + float RoomColumnFillSupremumForLastBox() const + { + const FBoxState& B = BoxState(); + if (!B.bValid) { return FLT_MAX; } + + float Supremum = 0.0f; + for (const FCachedColumn& Column : B.Cache.Columns) + { + if (!FMath::IsFinite(Column.BaseDensity) || Column.BaseDensity < 0.0f) + { + // Eval multiplies by BaseDensity. A negative authored value reverses the + // declared FillOnly direction, so the state-aware caller must fall back to + // Both/unknown rather than pretend this is a fill envelope. + return FLT_MAX; + } + const float Contribution = Column.BaseDensity * 1.5f; + if (!FMath::IsFinite(Contribution) + || Supremum > FLT_MAX - Contribution) + { + return FLT_MAX; + } + Supremum += Contribution; + } + return Supremum; + } + + float FloorBiasFillSupremumForLastBox() const + { + const FBoxState& B = BoxState(); + if (!B.bValid) { return FLT_MAX; } + + // Eval uses NormZ² only below the room centre. The largest value over the queried + // box is therefore the squared distance from the room centre to Box.Min.Z, divided by + // max(RadiusZ, 1)². Taking the maximum over cached rooms is conservative for whichever + // room wins the nearest-SDF query, and is finite without assuming a global room height. + float Supremum = 0.0f; + const float BoxMinZ = static_cast(B.KeyBox.Min.Z); + if (!FMath::IsFinite(BoxMinZ)) { return FLT_MAX; } + for (const FCachedRoom& Room : B.Cache.Rooms) + { + const float CenterZ = static_cast(Room.Center.Z); + const float RadiusZ = Room.RadiusZ; + if (!FMath::IsFinite(CenterZ) || !FMath::IsFinite(RadiusZ) + || !FMath::IsFinite(P.FloorBias) || P.FloorBias < 0.0f) + { + return FLT_MAX; + } + const float Denominator = FMath::Max(RadiusZ, 1.0f); + const float Below = FMath::Max(CenterZ - BoxMinZ, 0.0f); + const float Normalized = Below / Denominator; + const float Contribution = Normalized * Normalized * P.FloorBias; + if (!FMath::IsFinite(Contribution)) { return FLT_MAX; } + Supremum = FMath::Max(Supremum, Contribution); + } + return Supremum; + } + /** * Publish a conservative interval for this source alone. * @@ -4007,17 +4147,29 @@ namespace { // A detail op with no room source has the same no-op guard as Eval. An unknown interval is // not a reason to claim Identity: uncertainty costs a skip, while a false skip is a hole. - return (Rooms == nullptr || VF_CaveBoxIsFar(H, SDFBlendRadius)) - ? EVoxelOpEffect::Identity : Intrinsic; + if (Rooms == nullptr || VF_CaveBoxIsFar(H, SDFBlendRadius)) + { + return EVoxelOpEffect::Identity; + } + // A terrain op is allowed to replace the strate-level activation fields for the room that + // Eval selects. Its exact direction is not part of the generic cache proof, so preserve + // safety with Both whenever one is present. The common no-override TunnelNetwork path + // gets the real intrinsic direction and finite amplitude below. + return Rooms->HasRoomTerrainOverrideForLastBox() + ? EVoxelOpEffect::Both : Intrinsic; } FORCEINLINE float VF_CaveDetailMax(const FRoomGraphSource* Rooms, const FVoxelBoxHypotheses& H, - float SDFBlendRadius) + float SDFBlendRadius, + float IntrinsicMax) { // Per-room overrides can activate or enlarge a modifier even when the strate-level - // parameter is zero. Near/unknown therefore returns the safe default amplitude. - return (Rooms == nullptr || VF_CaveBoxIsFar(H, SDFBlendRadius)) ? 0.0f : FLT_MAX; + // parameter is zero. A valid cache with no override makes the strate-level supremum + // exact for these modifiers; an unknown/overridden cache keeps the old safe default. + if (Rooms == nullptr || VF_CaveBoxIsFar(H, SDFBlendRadius)) { return 0.0f; } + if (Rooms->HasRoomTerrainOverrideForLastBox()) { return FLT_MAX; } + return IntrinsicMax; } //========================================================================= @@ -4197,11 +4349,11 @@ namespace /** * ✅ Borne consommée par le pliage numérique. `RoughNoise` et `FineNoise` respectent tous - * deux sont couverts par la borne prouvée `VF_PerlinAbsBound = 1.5`, mis à l'échelle par - * `VOXEL_NOISE_SCALE`, et `TotalRough = Rough·S + Fine·S·0.4` ⇒ - * `|TotalRough| ≤ 2.1 · S · SCALE`. Le fade est - * dans `[0,1]`. Le clamp anti-remplissage ne fait que RÉDUIRE côté fill ; on ne s'appuie pas - * dessus (il ne s'applique que dans l'air certain), donc la borne fill reste la même. + * `FBM` est couvert par la borne prouvée `VF_PerlinAbsBound = 1.5`, mis à l'échelle par + * `VOXEL_NOISE_SCALE`. `Ridged` is in [-1,1] after its square/fold, while the unclamped + * cellular mapping needs its own finite envelope below. The two octaves contribute + * `|Rough| + 0.4·|Fine|`; the fade is in `[0,1]`. The anti-fill clamp only reduces the + * positive side, so the same envelope is valid in both directions. */ float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return MaxAmplitude(); } float MaxFillOverBox (const FBox&, const FVoxelOpContext&) const override { return MaxAmplitude(); } @@ -4221,8 +4373,29 @@ namespace /** La borne d'amplitude, en unités de densité. */ float MaxAmplitude() const { - return (P.SurfaceRoughness > 0.0f) - ? (2.1f * P.SurfaceRoughness * VOXEL_NOISE_SCALE) : 0.0f; + if (!(P.SurfaceRoughness > 0.0f)) { return 0.0f; } + if (!FMath::IsFinite(P.SurfaceRoughness)) { return FLT_MAX; } + + float NoiseBound = VF_PerlinAbsBound; + switch (P.RoughnessNoiseType) + { + case EVoxelNoiseType::Ridged: + NoiseBound = 1.0f; + break; + case EVoxelNoiseType::Mixed: + NoiseBound = 0.5f * (VF_PerlinAbsBound + 1.0f); + break; + case EVoxelNoiseType::Cellular: + NoiseBound = VF_CellularAbsBound; + break; + case EVoxelNoiseType::FBM: + default: + NoiseBound = VF_PerlinAbsBound; + break; + } + + const float Result = 1.4f * NoiseBound * P.SurfaceRoughness * VOXEL_NOISE_SCALE; + return FMath::IsFinite(Result) ? Result : FLT_MAX; } private: @@ -4339,7 +4512,45 @@ namespace const FVoxelBoxHypotheses& H) const override { return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, - EVoxelOpEffect::Both); + EffectOverBox(VoxelBox, Ctx)); + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override + { + return MaxAmplitude(); + } + + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override + { + return MaxAmplitude(); + } + + float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, + MaxCarveOverBox(VoxelBox, Ctx)); + } + + float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, + MaxFillOverBox(VoxelBox, Ctx)); + } + + /** + * `TerracedZ` is one staircase step below or above `NoisedZ`; the modulo phase is in + * [0,1], so the safe supremum of `abs(TerracedZ - NoisedZ)` is one full step, not StepH/2. + * The noise displacement changes which phase is selected but cannot enlarge that interval. + */ + float MaxAmplitude() const + { + return (P.TerraceStepHeight > 0.0f + && FMath::IsFinite(P.TerraceStepHeight) + && FMath::IsFinite(P.TerraceHardness) + && P.TerraceHardness >= 0.0f && P.TerraceHardness <= 1.0f) + ? P.TerraceStepHeight : (P.TerraceStepHeight > 0.0f ? FLT_MAX : 0.0f); } private: @@ -4407,7 +4618,7 @@ namespace const FVoxelBoxHypotheses& H) const override { return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, - EVoxelOpEffect::CarveOnly); + EffectOverBox(VoxelBox, Ctx)); } /** `LineValue = max(sin,0)³ ∈ [0,1]`, `Fade ∈ [0,1]` ⇒ retrait ≤ `LayerLineDepth`. @@ -4417,18 +4628,25 @@ namespace * réglée par le même correctif — AVANT que `ClassifyTile` ne consomme `ClassifyBox`. */ float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { - return (P.LayerLineSpacing > 0.0f) ? FMath::Max(P.LayerLineDepth, 0.0f) : 0.0f; + return MaxAmplitude(); } float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } float MaxCarveOverBox(const FBox&, const FVoxelOpContext&, const FVoxelBoxHypotheses& H) const override { - return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius); + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, MaxAmplitude()); } float MaxFillOverBox(const FBox&, const FVoxelOpContext&, const FVoxelBoxHypotheses&) const override { return 0.0f; } + float MaxAmplitude() const + { + if (!(P.LayerLineSpacing > 0.0f)) { return 0.0f; } + return FMath::IsFinite(P.LayerLineDepth) + ? FMath::Max(P.LayerLineDepth, 0.0f) : FLT_MAX; + } + private: FStrateGenerationParams P; const FRoomGraphSource* Rooms; // NON possédant @@ -4496,7 +4714,7 @@ namespace const FVoxelBoxHypotheses& H) const override { return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, - EVoxelOpEffect::FillOnly); + EffectOverBox(VoxelBox, Ctx)); } /** `RibValue = max(sin,0)² ∈ [0,1]`, `Fade ∈ [0,1]` ⇒ ajout ≤ `RibbingDepth`. @@ -4504,7 +4722,7 @@ namespace float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override { - return (P.RibbingSpacing > 0.0f) ? FMath::Max(P.RibbingDepth, 0.0f) : 0.0f; + return MaxAmplitude(); } float MaxCarveOverBox(const FBox&, const FVoxelOpContext&, @@ -4512,7 +4730,14 @@ namespace float MaxFillOverBox(const FBox&, const FVoxelOpContext&, const FVoxelBoxHypotheses& H) const override { - return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius); + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, MaxAmplitude()); + } + + float MaxAmplitude() const + { + if (!(P.RibbingSpacing > 0.0f)) { return 0.0f; } + return FMath::IsFinite(P.RibbingDepth) + ? FMath::Max(P.RibbingDepth, 0.0f) : FLT_MAX; } private: @@ -4588,7 +4813,7 @@ namespace const FVoxelBoxHypotheses& H) const override { return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, - EVoxelOpEffect::FillOnly); + EffectOverBox(VoxelBox, Ctx)); } /** fBM ∈ [-1,1] × `VOXEL_NOISE_SCALE`, lobe positif seulement, `Fade ∈ [0,1]` ⇒ ajout @@ -4596,9 +4821,7 @@ namespace float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override { - return VOXEL_NOISE_SCALE * VF_PerlinAbsBound - * FMath::Max(P.OverhangDepth, 0.0f) - * FMath::Max(P.OverhangStrength, 0.0f); + return MaxAmplitude(); } float MaxCarveOverBox(const FBox&, const FVoxelOpContext&, @@ -4606,7 +4829,20 @@ namespace float MaxFillOverBox(const FBox&, const FVoxelOpContext&, const FVoxelBoxHypotheses& H) const override { - return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius); + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, MaxAmplitude()); + } + + float MaxAmplitude() const + { + if (!(P.OverhangStrength > 0.0f && P.OverhangDepth > 0.0f)) { return 0.0f; } + if (!FMath::IsFinite(P.OverhangDepth) + || !FMath::IsFinite(P.OverhangStrength)) + { + return FLT_MAX; + } + const float Result = VOXEL_NOISE_SCALE * VF_PerlinAbsBound + * P.OverhangDepth * P.OverhangStrength; + return FMath::IsFinite(Result) ? Result : FLT_MAX; } private: @@ -4689,7 +4925,41 @@ namespace const FVoxelBoxHypotheses& H) const override { return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, - EVoxelOpEffect::Both); + EffectOverBox(VoxelBox, Ctx)); + } + + /** `|VertGrad| ≤ SCALE·1.5`, `|CaveSDF| < 8`, `Fade ≤ 1`, final multiplier `3`. */ + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override + { + return MaxAmplitude(); + } + + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override + { + return MaxAmplitude(); + } + + float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, + MaxCarveOverBox(VoxelBox, Ctx)); + } + + float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, + MaxFillOverBox(VoxelBox, Ctx)); + } + + float MaxAmplitude() const + { + if (!(P.CliffStrength > 0.0f)) { return 0.0f; } + if (!FMath::IsFinite(P.CliffStrength)) { return FLT_MAX; } + const float Result = 8.0f * 3.0f * VOXEL_NOISE_SCALE + * VF_PerlinAbsBound * P.CliffStrength; + return FMath::IsFinite(Result) ? Result : FLT_MAX; } private: @@ -4765,25 +5035,34 @@ namespace const FVoxelBoxHypotheses& H) const override { return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, - EVoxelOpEffect::CarveOnly); + EffectOverBox(VoxelBox, Ctx)); } - /** `Cellular3D ∈ [-1,1]`, lobe positif seulement, `Fade ∈ [0,1]` ⇒ retrait ≤ - * `ScallopStrength`. Même réserve « params de strate ». */ + /** The implementation maps an unclamped F2-F1 distance to `2*d-1`; the proven finite + * envelope used here is `VF_CellularAbsBound`, not the approximate [-1,1] comment beside + * the noise function. The positive lobe and `Fade ∈ [0,1]` then bound the carve. */ float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { - return VF_PerlinAbsBound * FMath::Max(P.ScallopStrength, 0.0f); + return MaxAmplitude(); } float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } float MaxCarveOverBox(const FBox&, const FVoxelOpContext&, const FVoxelBoxHypotheses& H) const override { - return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius); + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, MaxAmplitude()); } float MaxFillOverBox(const FBox&, const FVoxelOpContext&, const FVoxelBoxHypotheses&) const override { return 0.0f; } + float MaxAmplitude() const + { + if (!(P.ScallopStrength > 0.0f)) { return 0.0f; } + if (!FMath::IsFinite(P.ScallopStrength)) { return FLT_MAX; } + const float Result = VF_CellularAbsBound * P.ScallopStrength; + return FMath::IsFinite(Result) ? Result : FLT_MAX; + } + private: FStrateGenerationParams P; uint32 SeedU; @@ -4863,14 +5142,49 @@ namespace * consomme l'intervalle SDF publié par la source, comme les autres détails de salle. */ EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override { - return (P.ArchDensity > 0.0f) ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity; + if (!(P.ArchDensity > 0.0f)) { return EVoxelOpEffect::Identity; } + if (P.BaseDensity > 0.0f) { return EVoxelOpEffect::FillOnly; } + if (P.BaseDensity < 0.0f) { return EVoxelOpEffect::CarveOnly; } + return EVoxelOpEffect::Identity; } EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, const FVoxelBoxHypotheses& H) const override { return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, - EVoxelOpEffect::FillOnly); + EffectOverBox(VoxelBox, Ctx)); + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override + { + return P.BaseDensity < 0.0f ? MaxAmplitude() : 0.0f; + } + + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override + { + return P.BaseDensity > 0.0f ? MaxAmplitude() : 0.0f; + } + + float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, + MaxCarveOverBox(VoxelBox, Ctx)); + } + + float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, + MaxFillOverBox(VoxelBox, Ctx)); + } + + float MaxAmplitude() const + { + if (!(P.ArchDensity > 0.0f)) { return 0.0f; } + if (!FMath::IsFinite(P.BaseDensity)) { return FLT_MAX; } + const float Result = FMath::Abs(P.BaseDensity) * 3.0f * 1.5f; + return FMath::IsFinite(Result) ? Result : FLT_MAX; } private: @@ -4935,8 +5249,8 @@ namespace } } - /** N'AJOUTE que du solide ⇒ `FillOnly`. On ne peut pas rendre `Identity` sans consulter le - * cache pour la boîte interrogée — même dette que le graphe de salles. */ + /** The cached column list is the complete source of this op's work. A valid empty list is + * therefore an exact Identity result; an invalid box cache stays conservative. */ EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override { return EVoxelOpEffect::FillOnly; @@ -4945,8 +5259,35 @@ namespace EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, const FVoxelBoxHypotheses& H) const override { - return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, - EVoxelOpEffect::FillOnly); + if (Rooms == nullptr || VF_CaveBoxIsFar(H, P.SDFBlendRadius)) + { + return EVoxelOpEffect::Identity; + } + const float Bound = Rooms->RoomColumnFillSupremumForLastBox(); + if (!(Bound > 0.0f)) { return EVoxelOpEffect::Identity; } + return FMath::IsFinite(Bound) ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Both; + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } + + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override + { + return FLT_MAX; + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses& H) const override + { + if (Rooms == nullptr || VF_CaveBoxIsFar(H, P.SDFBlendRadius)) { return 0.0f; } + const float Bound = Rooms->RoomColumnFillSupremumForLastBox(); + return FMath::IsFinite(Bound) ? 0.0f : Bound; + } + + float MaxFillOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses& H) const override + { + if (Rooms == nullptr || VF_CaveBoxIsFar(H, P.SDFBlendRadius)) { return 0.0f; } + return Rooms->RoomColumnFillSupremumForLastBox(); } private: @@ -5053,7 +5394,38 @@ namespace const FVoxelBoxHypotheses& H) const override { return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, - EVoxelOpEffect::CarveOnly); + EffectOverBox(VoxelBox, Ctx)); + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override + { + return MaxAmplitude(); + } + + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override + { + return 0.0f; + } + + float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, + MaxCarveOverBox(VoxelBox, Ctx)); + } + + float MaxFillOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses&) const override + { + return 0.0f; + } + + float MaxAmplitude() const + { + if (!(P.DomeDensity > 0.0f)) { return 0.0f; } + if (!FMath::IsFinite(P.BaseDensity)) { return FLT_MAX; } + const float Result = FMath::Abs(P.BaseDensity) * 2.0f * 1.5f; + return FMath::IsFinite(Result) ? Result : FLT_MAX; } private: @@ -5142,7 +5514,35 @@ namespace const FVoxelBoxHypotheses& H) const override { return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, - EVoxelOpEffect::FillOnly); + EffectOverBox(VoxelBox, Ctx)); + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } + + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override + { + return MaxAmplitude(); + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, 0.0f); + } + + float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveDetailMax(Rooms, H, P.SDFBlendRadius, + MaxFillOverBox(VoxelBox, Ctx)); + } + + float MaxAmplitude() const + { + if (!(P.PinchDensity > 0.0f)) { return 0.0f; } + if (!FMath::IsFinite(P.BaseDensity)) { return FLT_MAX; } + const float Result = FMath::Abs(P.BaseDensity) * 3.0f * 1.5f; + return FMath::IsFinite(Result) ? Result : FLT_MAX; } private: @@ -5217,7 +5617,28 @@ namespace const FVoxelBoxHypotheses& H) const override { return VF_CaveDetailEffect(Rooms, H, P.SDFBlendRadius, - EVoxelOpEffect::FillOnly); + EffectOverBox(VoxelBox, Ctx)); + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&) const override { return 0.0f; } + + float MaxFillOverBox(const FBox&, const FVoxelOpContext&) const override + { + return FMath::IsFinite(P.FloorBias) && P.FloorBias >= 0.0f + ? P.FloorBias : (P.FloorBias < 0.0f ? 0.0f : FLT_MAX); + } + + float MaxCarveOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses& H) const override + { + return VF_CaveBoxIsFar(H, P.SDFBlendRadius) ? 0.0f : 0.0f; + } + + float MaxFillOverBox(const FBox&, const FVoxelOpContext&, + const FVoxelBoxHypotheses& H) const override + { + if (Rooms == nullptr || VF_CaveBoxIsFar(H, P.SDFBlendRadius)) { return 0.0f; } + return Rooms->FloorBiasFillSupremumForLastBox(); } private: @@ -5359,7 +5780,8 @@ namespace * réseau, c'est `MaxCarveOverBox` qui l'applique. */ float MaxCarveAmplitude() const { - return (P.WormStrength > 0.0f && P.WormThreshold > 0.0f) ? P.WormStrength : 0.0f; + if (!(P.WormStrength > 0.0f && P.WormThreshold > 0.0f)) { return 0.0f; } + return FMath::IsFinite(P.WormStrength) ? P.WormStrength : FLT_MAX; } const TCHAR* DebugName() const override { return TEXT("WormFieldSource"); } @@ -5582,6 +6004,167 @@ SIZE_T FVoxelOpStack::GetAllocatedSize() const return Bytes; } +void FVoxelOpStack::DiagnoseBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + int32 SampleStep, TArray& OutDiagnostics, + EVoxelTileClass* OutVerdict) const +{ + OutDiagnostics.Reset(); + OutDiagnostics.SetNum(Ops.Num()); + if (OutVerdict != nullptr) + { + *OutVerdict = EVoxelTileClass::Mixed; + } + + if (!VoxelBox.IsValid || SampleStep <= 0) + { + return; + } + + FVoxelBoxHypotheses H; + for (int32 Index = 0; Index < Ops.Num(); ++Index) + { + const FOpEntry& Entry = Ops[Index]; + FOpBoxDiagnostic& Diagnostic = OutDiagnostics[Index]; + Diagnostic.Index = Index; + Diagnostic.Name = Entry.Op.IsValid() ? FString(Entry.Op->DebugName()) : TEXT("(null op)"); + Diagnostic.bWritesSdf = (Entry.Writes & VoxelOpChannels::Sdf) != 0; + + if (!Entry.Op.IsValid()) + { + continue; + } + + // This is intentionally the same order and branch structure as VF_FoldOp. The diagnostic + // must explain the production classifier, not an approximation of it. + const EVoxelTileClass Forced = Entry.Op->ClassifyBox(VoxelBox, Ctx); + Diagnostic.ForcedVerdict = Forced; + Diagnostic.bForced = Forced != EVoxelTileClass::Mixed; + if (Diagnostic.bForced) + { + Diagnostic.Effect = EVoxelOpEffect::Identity; + Diagnostic.ForcedMargin = Entry.Op->ForcedMarginOverBox(VoxelBox, Ctx); + VF_ForceHypotheses(H, Forced, Diagnostic.ForcedMargin); + if (Diagnostic.bWritesSdf) + { + Entry.Op->PropagateSdfOverBox(H.Sdf, VoxelBox, Ctx); + Diagnostic.bHasSdfBound = H.Sdf.IsKnown(); + if (Diagnostic.bHasSdfBound) + { + Diagnostic.SdfBoundMin = H.Sdf.Min; + Diagnostic.SdfBoundMax = H.Sdf.Max; + } + } + } + else + { + Diagnostic.Effect = Entry.Op->EffectOverBox(VoxelBox, Ctx, H); + Diagnostic.MaxCarve = Entry.Op->MaxCarveOverBox(VoxelBox, Ctx, H); + Diagnostic.MaxFill = Entry.Op->MaxFillOverBox(VoxelBox, Ctx, H); + VF_FoldEffect(H, Diagnostic.Effect, Diagnostic.MaxCarve, Diagnostic.MaxFill); + if (Diagnostic.bWritesSdf) + { + Entry.Op->PropagateSdfOverBox(H.Sdf, VoxelBox, Ctx); + Diagnostic.bHasSdfBound = H.Sdf.IsKnown(); + if (Diagnostic.bHasSdfBound) + { + Diagnostic.SdfBoundMin = H.Sdf.Min; + Diagnostic.SdfBoundMax = H.Sdf.Max; + } + } + } + } + + if (OutVerdict != nullptr) + { + *OutVerdict = H.Resolve(); + } + + const int32 MinX = FMath::CeilToInt(static_cast(VoxelBox.Min.X)); + const int32 MinY = FMath::CeilToInt(static_cast(VoxelBox.Min.Y)); + const int32 MinZ = FMath::CeilToInt(static_cast(VoxelBox.Min.Z)); + const int32 MaxX = FMath::FloorToInt(static_cast(VoxelBox.Max.X)); + const int32 MaxY = FMath::FloorToInt(static_cast(VoxelBox.Max.Y)); + const int32 MaxZ = FMath::FloorToInt(static_cast(VoxelBox.Max.Z)); + if (MinX > MaxX || MinY > MaxY || MinZ > MaxZ) + { + return; + } + + constexpr float BoundEpsilon = 1.0e-4f; + for (int32 Z = MinZ; Z <= MaxZ; ) + { + for (int32 Y = MinY; Y <= MaxY; ) + { + for (int32 X = MinX; X <= MaxX; ) + { + FVoxelOpSample Sample; + for (int32 Index = 0; Index < Ops.Num(); ++Index) + { + const FOpEntry& Entry = Ops[Index]; + FOpBoxDiagnostic& Diagnostic = OutDiagnostics[Index]; + if (!Entry.Op.IsValid()) + { + continue; + } + + const float DensityBefore = Sample.Density; + Entry.Op->Eval(static_cast(X), static_cast(Y), + static_cast(Z), Sample); + const float DensityDelta = Sample.Density - DensityBefore; + if (FMath::IsFinite(DensityDelta)) + { + Diagnostic.bHasDensityDelta = true; + Diagnostic.ActualDensityDeltaMin = + FMath::Min(Diagnostic.ActualDensityDeltaMin, DensityDelta); + Diagnostic.ActualDensityDeltaMax = + FMath::Max(Diagnostic.ActualDensityDeltaMax, DensityDelta); + + // A forced classifier is a stronger statement than a directional delta + // bound. Its density can legitimately replace an arbitrary incoming + // value (the constant source is the canonical example), so do not call + // that replacement a MaxFill/MaxCarve violation. ForcedMargin records the + // proof margin separately for the report. + if (!Diagnostic.bForced) + { + const float CarveEpsilon = BoundEpsilon + * FMath::Max(1.0f, FMath::Abs(Diagnostic.MaxCarve)); + const float FillEpsilon = BoundEpsilon + * FMath::Max(1.0f, FMath::Abs(Diagnostic.MaxFill)); + if (FMath::IsFinite(Diagnostic.MaxCarve) + && DensityDelta < -Diagnostic.MaxCarve - CarveEpsilon) + { + Diagnostic.bCarveBoundViolated = true; + } + if (FMath::IsFinite(Diagnostic.MaxFill) + && DensityDelta > Diagnostic.MaxFill + FillEpsilon) + { + Diagnostic.bFillBoundViolated = true; + } + } + } + + if (Diagnostic.bWritesSdf && FMath::IsFinite(Sample.Sdf)) + { + Diagnostic.bHasSdfValue = true; + Diagnostic.ActualSdfMin = FMath::Min( + Diagnostic.ActualSdfMin, Sample.Sdf); + Diagnostic.ActualSdfMax = FMath::Max( + Diagnostic.ActualSdfMax, Sample.Sdf); + } + ++Diagnostic.SampleCount; + } + + if (X > MaxX - SampleStep) { break; } + X += SampleStep; + } + if (Y > MaxY - SampleStep) { break; } + Y += SampleStep; + } + if (Z > MaxZ - SampleStep) { break; } + Z += SampleStep; + } +} + bool FVoxelOpStack::ValidateChannelOrder(FString* OutError) const { if (OutError != nullptr) { OutError->Reset(); } diff --git a/Source/VoxelForge/Private/VoxelDensityProfile.cpp b/Source/VoxelForge/Private/VoxelDensityProfile.cpp index f7e03e7..388d7b8 100644 --- a/Source/VoxelForge/Private/VoxelDensityProfile.cpp +++ b/Source/VoxelForge/Private/VoxelDensityProfile.cpp @@ -416,6 +416,9 @@ namespace VoxelDensityProfile case ECounter::TunnelSupportFloorChecks:return TEXT("TunnelSupportFloorChecks"); case ECounter::PassageCandidates: return TEXT("PassageCandidates"); case ECounter::PassageEvaluated: return TEXT("PassageEvaluated"); + case ECounter::MesherBlockTests: return TEXT("MesherBlockTests"); + case ECounter::MesherBlockAllSolid:return TEXT("MesherBlockAllSolid"); + case ECounter::MesherBlockAllAir: return TEXT("MesherBlockAllAir"); case ECounter::Count: break; } return TEXT("Unknown"); diff --git a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp index 9692f15..8324be4 100644 --- a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp +++ b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp @@ -285,6 +285,58 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, } MesherDensityGridTimer.End(); + // Optional 8x8x8-cell block early-out. ClassifyTile uses the same one-point halo and the + // same cave/disturbance/diff-layer guards as production tile loading. It is deliberately + // performed after the density grid is complete: skipping only the Marching Cubes work keeps + // the grid, capture buffer, geometry, normals, and all density-side effects identical. + TArray BlockSkip; + int32 BlocksPerAxis = 0; + if (bUseBlockEarlyOut) + { + constexpr int32 BlockCells = 8; + BlocksPerAxis = (CellsPerAxis + BlockCells - 1) / BlockCells; + BlockSkip.Init(0, BlocksPerAxis * BlocksPerAxis * BlocksPerAxis); + 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 FIntVector BlockOrigin = OriginVoxels + FIntVector( + BlockX * BlockCells * Step, + BlockY * BlockCells * Step, + BlockMinZ * Step); + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::MesherBlockTests); + } + const EVoxelTileClass Verdict = Generator->ClassifyTile( + BlockOrigin, Step, BlockCells); + 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); + } + } + } + } + } + } + // ── CAPTURE-DURING-MESHING ── // Si demandé et que la tuile est pleine résolution (CellsPerAxis==CHUNK_SIZE ⇒ Step==1< Bins; - TArray CandidateIndices; - - void Reset(); - void Build(const TArray& Entries); - - // Returns false only when the index is invalid. A valid query outside its extent returns - // true with an empty range, which is the normal cheap reject for that sample. - bool GetBin(float X, float Y, float Z, int32& OutFirst, int32& OutCount) const; - - // Build-time/column-query helper. Coordinates are already integer cell coordinates. - bool GetBinAt(int32 CellX, int32 CellY, int32 CellZ, - int32& OutFirst, int32& OutCount) const; - - // A support-floor column is Z-dependent but not Z-search-dependent. Gather the unique - // tunnel IDs from every height bin crossing one XY cell once, then reuse the resulting floor - // intervals for all samples in that column. - template - bool GetColumnCandidates(float X, float Y, TArray& OutCandidates) const; -}; - struct FTunnelSupportFloorInterval { int32 TunnelIndex = INDEX_NONE; @@ -806,13 +756,6 @@ struct FChunkSDFCache TArray Chimneys; TArray Columns; - // Immutable broad-phase indices. The world index is also the source for per-column support - // intervals; its Z bins are traversed only when a column is first built. - FCaveSpatialIndex RoomIndex; - FCaveSpatialIndex RoomFloorJoinIndex; - FCaveSpatialIndex TunnelIndex; - FCaveSpatialIndex TunnelWorldIndex; - // Sparse immutable support-column table. SupportColumnEntries is a dense // integer-XY slot map whose values index only non-empty SupportColumns. int32 SupportColumnMinX = 0; @@ -822,9 +765,9 @@ struct FChunkSDFCache TArray SupportColumnEntries; TArray SupportColumns; - // Allocator-backed bytes owned by this cache, including the nested tunnel chains and index - // arrays. The returned value excludes sizeof(FChunkSDFCache) itself so callers can add the - // enclosing entry's inline storage exactly once. + // 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. SIZE_T GetAllocatedSize() const; VoxelDensityProfile::FCacheMemoryBreakdown GetAllocatedSizeBreakdown() const; }; diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index 97af679..4ff41a4 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -231,6 +231,40 @@ public: return H.Resolve(); } + /** + * Per-operator box-bound audit. This deliberately replays the same fold as ClassifyBox while + * also brute-forcing Eval on the queried lattice. It is report/test infrastructure only: it + * must never be used to make a generation decision. + */ + struct FOpBoxDiagnostic + { + int32 Index = INDEX_NONE; + FString Name; + EVoxelOpEffect Effect = EVoxelOpEffect::Identity; + EVoxelTileClass ForcedVerdict = EVoxelTileClass::Mixed; + bool bForced = false; + bool bWritesSdf = false; + float ForcedMargin = 0.0f; + float MaxCarve = 0.0f; + float MaxFill = 0.0f; + bool bHasDensityDelta = false; + float ActualDensityDeltaMin = FLT_MAX; + float ActualDensityDeltaMax = -FLT_MAX; + bool bHasSdfValue = false; + float ActualSdfMin = FLT_MAX; + float ActualSdfMax = -FLT_MAX; + bool bHasSdfBound = false; + float SdfBoundMin = FLT_MAX; + float SdfBoundMax = FLT_MAX; + bool bCarveBoundViolated = false; + bool bFillBoundViolated = false; + uint64 SampleCount = 0; + }; + + VOXELFORGE_API void DiagnoseBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + int32 SampleStep, TArray& OutDiagnostics, + EVoxelTileClass* OutVerdict = nullptr) const; + /** Nom lisible d'un opérateur, pour les rapports de test. Voir `IVoxelDensityOp::DebugName`. */ const TCHAR* GetOpDebugName(int32 Index) const { diff --git a/Source/VoxelForge/Public/VoxelDensityProfile.h b/Source/VoxelForge/Public/VoxelDensityProfile.h index 74ab9ff..f4a8510 100644 --- a/Source/VoxelForge/Public/VoxelDensityProfile.h +++ b/Source/VoxelForge/Public/VoxelDensityProfile.h @@ -100,6 +100,9 @@ namespace VoxelDensityProfile TunnelSupportFloorChecks, PassageCandidates, PassageEvaluated, + MesherBlockTests, + MesherBlockAllSolid, + MesherBlockAllAir, Count }; @@ -122,25 +125,12 @@ namespace VoxelDensityProfile uint64 SupportColumnEntriesBytes = 0; uint64 SupportColumnsBytes = 0; uint64 SupportColumnIntervalsBytes = 0; - uint64 RoomIndexBinsBytes = 0; - uint64 RoomIndexCandidatesBytes = 0; - uint64 RoomFloorJoinIndexBinsBytes = 0; - uint64 RoomFloorJoinIndexCandidatesBytes = 0; - uint64 TunnelIndexBinsBytes = 0; - uint64 TunnelIndexCandidatesBytes = 0; - uint64 TunnelWorldIndexBinsBytes = 0; - uint64 TunnelWorldIndexCandidatesBytes = 0; - uint64 TotalBytes() const { return SlotStorageBytes + OpStackBytes + RoomsBytes + RoomFloorJoinsBytes + TunnelsBytes + PitsBytes + ChimneysBytes + ColumnsBytes + SupportColumnEntriesBytes - + SupportColumnsBytes + SupportColumnIntervalsBytes - + RoomIndexBinsBytes + RoomIndexCandidatesBytes - + RoomFloorJoinIndexBinsBytes + RoomFloorJoinIndexCandidatesBytes - + TunnelIndexBinsBytes + TunnelIndexCandidatesBytes - + TunnelWorldIndexBinsBytes + TunnelWorldIndexCandidatesBytes; + + SupportColumnsBytes + SupportColumnIntervalsBytes; } uint64 DynamicBytes() const @@ -161,14 +151,6 @@ namespace VoxelDensityProfile SupportColumnEntriesBytes += Other.SupportColumnEntriesBytes; SupportColumnsBytes += Other.SupportColumnsBytes; SupportColumnIntervalsBytes += Other.SupportColumnIntervalsBytes; - RoomIndexBinsBytes += Other.RoomIndexBinsBytes; - RoomIndexCandidatesBytes += Other.RoomIndexCandidatesBytes; - RoomFloorJoinIndexBinsBytes += Other.RoomFloorJoinIndexBinsBytes; - RoomFloorJoinIndexCandidatesBytes += Other.RoomFloorJoinIndexCandidatesBytes; - TunnelIndexBinsBytes += Other.TunnelIndexBinsBytes; - TunnelIndexCandidatesBytes += Other.TunnelIndexCandidatesBytes; - TunnelWorldIndexBinsBytes += Other.TunnelWorldIndexBinsBytes; - TunnelWorldIndexCandidatesBytes += Other.TunnelWorldIndexCandidatesBytes; return *this; } }; diff --git a/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h b/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h index 3899f73..d79dd99 100644 --- a/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h +++ b/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h @@ -147,6 +147,11 @@ public: // l'écart vers un voisin un niveau plus grossier (cellule 2×). Monter si des fissures persistent. float SkirtCells = 2.0f; + // Optional conservative block classifier. When enabled, 8x8x8 cell regions proven uniformly + // solid or air by UVoxelGenerator::ClassifyTile skip the marching-cubes cell loop. The density + // grid is still produced in full, so this switch cannot change samples or geometry. + bool bUseBlockEarlyOut = false; + // T2.b — LOD-aware octave reduction (opt-in, copied from UVoxelSettings::LODOctaveDrop). // Octaves dropped from per-voxel volumetric noise PER Step doubling: a tile at Step=S // drops LODOctaveDrop * log2(S) octaves (see VoxelGenLOD in VoxelGenerator.h). diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp index b10a36b..3c690c4 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -25,7 +25,9 @@ #include "UObject/Package.h" #include "UObject/StrongObjectPtr.h" #include "UObject/UObjectGlobals.h" +#include "VoxelCaveMorphology.h" #include "VoxelDiffLayer.h" +#include "VoxelDensityOpStack.h" #include "VoxelDensityProfile.h" #include "VoxelGenerator.h" #include "VoxelMarchingCubesMesher.h" @@ -202,6 +204,7 @@ struct FExploreArguments bool bProfileDensity = false; bool bProfileDensityFull = false; bool bProfileLod = false; + bool bOpBounds = false; bool bFailureFocusRender = false; FString OutDirectory; @@ -214,6 +217,7 @@ struct FExploreArguments // The canonical export owns one immutable lattice for the whole run. Batch cases can pass // density_grid_reuse=false to produce a matched wall-clock control. bool bReuseDensityGrid = true; + bool bBlockEarlyOut = false; int32 MeshMinBatchSize = 1; int32 MaxWalkCells = DefaultMaxWalkCells; float BudgetMinutes = DefaultBudgetMinutes; @@ -226,6 +230,7 @@ struct FExploreArguments if (bRender) Result += TEXT("render,"); if (bWalk) Result += TEXT("walk,"); if (bExport) Result += TEXT("export,"); + if (bOpBounds) Result += TEXT("opbounds,"); if (Result.EndsWith(TEXT(","))) { Result.LeftChopInline(1); @@ -265,6 +270,7 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr OutArguments.bProfileDensityFull = FParse::Param(*Params, TEXT("profiledensityfull")); OutArguments.bProfileDensity |= OutArguments.bProfileDensityFull; OutArguments.bProfileLod = FParse::Param(*Params, TEXT("profilelod")); + OutArguments.bOpBounds = FParse::Param(*Params, TEXT("opbounds")); FParse::Value(*Params, TEXT("out="), OutText); FParse::Value(*Params, TEXT("renderwidth="), OutArguments.RenderWidth); FParse::Value(*Params, TEXT("renderheight="), OutArguments.RenderHeight); @@ -275,6 +281,9 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr int32 DensityGridReuse = OutArguments.bReuseDensityGrid ? 1 : 0; FParse::Value(*Params, TEXT("densitygridreuse="), DensityGridReuse); OutArguments.bReuseDensityGrid = DensityGridReuse != 0; + int32 BlockEarlyOut = OutArguments.bBlockEarlyOut ? 1 : 0; + FParse::Value(*Params, TEXT("blockearlyout="), BlockEarlyOut); + OutArguments.bBlockEarlyOut = BlockEarlyOut != 0; FParse::Value(*Params, TEXT("meshminbatch="), OutArguments.MeshMinBatchSize); OutArguments.MeshMinBatchSize = FMath::Clamp(OutArguments.MeshMinBatchSize, 1, 64); int32 Lod = 0; @@ -322,6 +331,10 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr OutArguments.bWalk = true; OutArguments.bExport = true; } + else if (Mode == TEXT("opbounds")) + { + OutArguments.bOpBounds = true; + } else if (Mode == TEXT("render")) { OutArguments.bRender = true; @@ -337,11 +350,12 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr else { OutError = FString::Printf( - TEXT("Unknown mode '%s'. Expected render, walk, export, or all."), *Mode); + TEXT("Unknown mode '%s'. Expected render, walk, export, opbounds, or all."), *Mode); return false; } } - if (!OutArguments.bRender && !OutArguments.bWalk && !OutArguments.bExport) + if (!OutArguments.bRender && !OutArguments.bWalk && !OutArguments.bExport + && !OutArguments.bOpBounds) { OutError = TEXT("At least one mode must be selected."); return false; @@ -717,6 +731,7 @@ struct FExploreWorld Mesher = TStrongObjectPtr( NewObject(GetTransientPackage(), NAME_None, RF_Transient)); Mesher->SetGenerator(Generator.Get()); + Mesher->bUseBlockEarlyOut = Arguments.bBlockEarlyOut; if (Settings->WorldRadiusVoxels != 0.0f || Generator->WorldRadiusVoxels != 0.0f @@ -3861,26 +3876,31 @@ FString BuildExploreJson( if (Arguments.bRender) Writer->WriteValue(TEXT("render")); if (Arguments.bWalk) Writer->WriteValue(TEXT("walk")); if (Arguments.bExport) Writer->WriteValue(TEXT("export")); + if (Arguments.bOpBounds) Writer->WriteValue(TEXT("opbounds")); Writer->WriteArrayEnd(); Writer->WriteValue(TEXT("failure_focus_render"), Arguments.bFailureFocusRender); Writer->WriteValue(TEXT("render_step_voxels"), static_cast(Arguments.RenderStepVoxels)); Writer->WriteValue(TEXT("export_size"), Arguments.ExportSize); Writer->WriteValue(TEXT("export_step"), Arguments.ExportStep); Writer->WriteValue(TEXT("density_grid_reuse"), Arguments.bReuseDensityGrid); + Writer->WriteValue(TEXT("block_early_out"), Arguments.bBlockEarlyOut); Writer->WriteValue(TEXT("mesh_min_batch_size"), Arguments.MeshMinBatchSize); Writer->WriteValue(TEXT("profile_density"), Arguments.bProfileDensity); Writer->WriteValue(TEXT("profile_density_full"), Arguments.bProfileDensityFull); Writer->WriteValue(TEXT("profile_lod"), Arguments.bProfileLod); + Writer->WriteValue(TEXT("op_bounds"), Arguments.bOpBounds); Writer->WriteValue(TEXT("budget_minutes"), static_cast(Arguments.BudgetMinutes)); Writer->WriteValue(TEXT("out_directory"), Arguments.OutDirectory); Writer->WriteValue(TEXT("canonical_invocation"), FString::Printf( TEXT("UnrealEditor-Cmd VoxelM.uproject -run=VoxelForgeExplore -seed=%d -archetype=%s " - "-slot=%d -opstack=%d -modes=%s -out="), + "-slot=%d -opstack=%d -modes=%s -blockearlyout=%d " + "-out="), Arguments.Seed, ArchetypeName(Arguments.Archetype), Arguments.Slot, Arguments.bUseOperatorStack ? 1 : 0, - *Arguments.CanonicalModes())); + *Arguments.CanonicalModes(), + Arguments.bBlockEarlyOut ? 1 : 0)); Writer->WriteObjectEnd(); Writer->WriteObjectStart(TEXT("summary")); @@ -4319,14 +4339,6 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("support_column_entries_bytes"), static_cast(Breakdown.SupportColumnEntriesBytes)); Writer->WriteValue(TEXT("support_columns_bytes"), static_cast(Breakdown.SupportColumnsBytes)); Writer->WriteValue(TEXT("support_column_intervals_bytes"), static_cast(Breakdown.SupportColumnIntervalsBytes)); - Writer->WriteValue(TEXT("room_index_bins_bytes"), static_cast(Breakdown.RoomIndexBinsBytes)); - Writer->WriteValue(TEXT("room_index_candidates_bytes"), static_cast(Breakdown.RoomIndexCandidatesBytes)); - Writer->WriteValue(TEXT("room_floor_join_index_bins_bytes"), static_cast(Breakdown.RoomFloorJoinIndexBinsBytes)); - Writer->WriteValue(TEXT("room_floor_join_index_candidates_bytes"), static_cast(Breakdown.RoomFloorJoinIndexCandidatesBytes)); - Writer->WriteValue(TEXT("tunnel_index_bins_bytes"), static_cast(Breakdown.TunnelIndexBinsBytes)); - Writer->WriteValue(TEXT("tunnel_index_candidates_bytes"), static_cast(Breakdown.TunnelIndexCandidatesBytes)); - Writer->WriteValue(TEXT("tunnel_world_index_bins_bytes"), static_cast(Breakdown.TunnelWorldIndexBinsBytes)); - Writer->WriteValue(TEXT("tunnel_world_index_candidates_bytes"), static_cast(Breakdown.TunnelWorldIndexCandidatesBytes)); Writer->WriteValue(TEXT("dynamic_bytes"), static_cast(Breakdown.DynamicBytes())); Writer->WriteValue(TEXT("total_bytes"), static_cast(Breakdown.TotalBytes())); Writer->WriteObjectEnd(); @@ -4398,6 +4410,513 @@ FString BuildExploreJson( return Writer->Close() ? Json : FString(); } +const TCHAR* ExploreEffectName(EVoxelOpEffect Effect) +{ + switch (Effect) + { + case EVoxelOpEffect::Identity: return TEXT("Identity"); + case EVoxelOpEffect::CarveOnly: return TEXT("CarveOnly"); + case EVoxelOpEffect::FillOnly: return TEXT("FillOnly"); + case EVoxelOpEffect::Both: return TEXT("Both"); + default: return TEXT("Unknown"); + } +} + +const TCHAR* ExploreTileClassName(EVoxelTileClass Class) +{ + switch (Class) + { + case EVoxelTileClass::AllSolid: return TEXT("AllSolid"); + case EVoxelTileClass::AllAir: return TEXT("AllAir"); + case EVoxelTileClass::Mixed: return TEXT("Mixed"); + default: return TEXT("Unknown"); + } +} + +// Density-bound APIs use FLT_MAX as the documented "unknown/unbounded" sentinel. It is finite +// according to IEEE-754, so FMath::IsFinite alone would misreport the very failure this audit is +// intended to expose as a real numeric envelope. +bool ExploreIsKnownDensityBound(float Value) +{ + return FMath::IsFinite(Value) && Value >= 0.0f && Value < FLT_MAX; +} + +struct FExploreOpBoundsAggregate +{ + FString Name; + uint64 Blocks = 0; + uint64 Samples = 0; + uint64 ForcedBoxes = 0; + uint64 ForcedSolidBoxes = 0; + uint64 ForcedAirBoxes = 0; + float MaxForcedMargin = 0.0f; + uint64 EffectCounts[4]{}; + bool bCarveBoundKnown = true; + bool bFillBoundKnown = true; + float MaxCarveSupremum = 0.0f; + float MaxFillSupremum = 0.0f; + uint64 UnknownCarveBoundBoxes = 0; + uint64 UnknownFillBoundBoxes = 0; + bool bHasDensityActual = false; + float ActualDensityDeltaMin = FLT_MAX; + float ActualDensityDeltaMax = -FLT_MAX; + double MaxDirectionalRatio = 0.0; + double MaxConservativeRatio = 0.0; + bool bRatioFinite = true; + uint64 CarveBoundViolations = 0; + uint64 FillBoundViolations = 0; + bool bSdfBoundKnown = true; + bool bHasSdfBound = false; + float SdfBoundMin = FLT_MAX; + float SdfBoundMax = -FLT_MAX; + bool bHasSdfActual = false; + float ActualSdfMin = FLT_MAX; + float ActualSdfMax = -FLT_MAX; + uint64 SdfBoundViolations = 0; +}; + +int32 ExploreEffectIndex(EVoxelOpEffect Effect) +{ + switch (Effect) + { + case EVoxelOpEffect::Identity: return 0; + case EVoxelOpEffect::CarveOnly: return 1; + case EVoxelOpEffect::FillOnly: return 2; + case EVoxelOpEffect::Both: return 3; + default: return 0; + } +} + +void AccumulateExploreOpDiagnostic( + const FVoxelOpStack::FOpBoxDiagnostic& Diagnostic, + FExploreOpBoundsAggregate& Aggregate) +{ + Aggregate.Name = Diagnostic.Name; + ++Aggregate.Blocks; + Aggregate.Samples += Diagnostic.SampleCount; + ++Aggregate.EffectCounts[ExploreEffectIndex(Diagnostic.Effect)]; + if (Diagnostic.bForced) + { + ++Aggregate.ForcedBoxes; + if (FMath::IsFinite(Diagnostic.ForcedMargin)) + { + Aggregate.MaxForcedMargin = FMath::Max( + Aggregate.MaxForcedMargin, Diagnostic.ForcedMargin); + } + if (Diagnostic.ForcedVerdict == EVoxelTileClass::AllSolid) + { + ++Aggregate.ForcedSolidBoxes; + } + else if (Diagnostic.ForcedVerdict == EVoxelTileClass::AllAir) + { + ++Aggregate.ForcedAirBoxes; + } + } + else + { + if (!ExploreIsKnownDensityBound(Diagnostic.MaxCarve)) + { + Aggregate.bCarveBoundKnown = false; + ++Aggregate.UnknownCarveBoundBoxes; + if (Diagnostic.Effect == EVoxelOpEffect::CarveOnly + || Diagnostic.Effect == EVoxelOpEffect::Both) + { + Aggregate.bRatioFinite = false; + } + } + else + { + Aggregate.MaxCarveSupremum = FMath::Max( + Aggregate.MaxCarveSupremum, Diagnostic.MaxCarve); + } + if (!ExploreIsKnownDensityBound(Diagnostic.MaxFill)) + { + Aggregate.bFillBoundKnown = false; + ++Aggregate.UnknownFillBoundBoxes; + if (Diagnostic.Effect == EVoxelOpEffect::FillOnly + || Diagnostic.Effect == EVoxelOpEffect::Both) + { + Aggregate.bRatioFinite = false; + } + } + else + { + Aggregate.MaxFillSupremum = FMath::Max( + Aggregate.MaxFillSupremum, Diagnostic.MaxFill); + } + } + + if (Diagnostic.bCarveBoundViolated) { ++Aggregate.CarveBoundViolations; } + if (Diagnostic.bFillBoundViolated) { ++Aggregate.FillBoundViolations; } + + if (Diagnostic.bHasDensityDelta) + { + Aggregate.bHasDensityActual = true; + Aggregate.ActualDensityDeltaMin = FMath::Min( + Aggregate.ActualDensityDeltaMin, Diagnostic.ActualDensityDeltaMin); + Aggregate.ActualDensityDeltaMax = FMath::Max( + Aggregate.ActualDensityDeltaMax, Diagnostic.ActualDensityDeltaMax); + + if (!Diagnostic.bForced) + { + const double ActualCarve = FMath::Max( + 0.0, -static_cast(Diagnostic.ActualDensityDeltaMin)); + const double ActualFill = FMath::Max( + 0.0, static_cast(Diagnostic.ActualDensityDeltaMax)); + double Ratio = 0.0; + if (ExploreIsKnownDensityBound(Diagnostic.MaxCarve) + && Diagnostic.MaxCarve > 0.0f) + { + Ratio = FMath::Max(Ratio, ActualCarve / Diagnostic.MaxCarve); + if (ActualCarve > 1.0e-4) + { + Aggregate.MaxConservativeRatio = FMath::Max( + Aggregate.MaxConservativeRatio, + static_cast(Diagnostic.MaxCarve) / ActualCarve); + } + } + else if (ActualCarve > 1.0e-4) + { + Aggregate.bRatioFinite = false; + } + if (ExploreIsKnownDensityBound(Diagnostic.MaxFill) + && Diagnostic.MaxFill > 0.0f) + { + Ratio = FMath::Max(Ratio, ActualFill / Diagnostic.MaxFill); + if (ActualFill > 1.0e-4) + { + Aggregate.MaxConservativeRatio = FMath::Max( + Aggregate.MaxConservativeRatio, + static_cast(Diagnostic.MaxFill) / ActualFill); + } + } + else if (ActualFill > 1.0e-4) + { + Aggregate.bRatioFinite = false; + } + if (FMath::IsFinite(Ratio)) + { + Aggregate.MaxDirectionalRatio = FMath::Max( + Aggregate.MaxDirectionalRatio, Ratio); + } + else + { + Aggregate.bRatioFinite = false; + } + } + } + + if (Diagnostic.bHasSdfBound) + { + Aggregate.bHasSdfBound = true; + Aggregate.SdfBoundMin = FMath::Min( + Aggregate.SdfBoundMin, Diagnostic.SdfBoundMin); + Aggregate.SdfBoundMax = FMath::Max( + Aggregate.SdfBoundMax, Diagnostic.SdfBoundMax); + if (Diagnostic.bHasSdfValue) + { + const float Epsilon = 1.0e-4f * FMath::Max( + 1.0f, FMath::Max(FMath::Abs(Diagnostic.SdfBoundMin), + FMath::Abs(Diagnostic.SdfBoundMax))); + if (Diagnostic.ActualSdfMin < Diagnostic.SdfBoundMin - Epsilon + || Diagnostic.ActualSdfMax > Diagnostic.SdfBoundMax + Epsilon) + { + ++Aggregate.SdfBoundViolations; + } + } + } + else if (Diagnostic.bWritesSdf) + { + Aggregate.bSdfBoundKnown = false; + } + + if (Diagnostic.bHasSdfValue) + { + Aggregate.bHasSdfActual = true; + Aggregate.ActualSdfMin = FMath::Min( + Aggregate.ActualSdfMin, Diagnostic.ActualSdfMin); + Aggregate.ActualSdfMax = FMath::Max( + Aggregate.ActualSdfMax, Diagnostic.ActualSdfMax); + } +} + +bool WriteExploreOpBoundsReport( + const FExploreArguments& Arguments, + const FExploreWorld& World, + int32 BlocksTested, + int32 RandomBlocksTested, + int32 AllSolidBlocks, + int32 AllAirBlocks, + int32 MixedBlocks, + const TArray& StackSolidKillerCounts, + const TArray& StackAirKillerCounts, + int32 StackVerdictMismatches, + const TArray& Aggregates, + FString& OutError) +{ + FString Json; + TSharedRef Writer = + TJsonWriterFactory>::Create(&Json); + Writer->WriteObjectStart(); + Writer->WriteValue(TEXT("schema_version"), 1); + Writer->WriteValue(TEXT("tool"), TEXT("VoxelForgeExplore")); + Writer->WriteValue(TEXT("purpose"), TEXT( + "Conservative EffectOverBox/ClassifyBox bounds compared with brute-force Eval over the same " + "one-voxel-halo domain of an 8-cell block.")); + Writer->WriteValue(TEXT("seed"), Arguments.Seed); + Writer->WriteValue(TEXT("archetype"), ArchetypeName(Arguments.Archetype)); + Writer->WriteValue(TEXT("slot"), Arguments.Slot); + Writer->WriteValue(TEXT("operator_stack"), Arguments.bUseOperatorStack); + Writer->WriteValue(TEXT("sample_step"), 1); + Writer->WriteValue(TEXT("block_cells"), 8); + Writer->WriteValue(TEXT("classifier_blocks_tested"), BlocksTested); + Writer->WriteValue(TEXT("classifier_all_solid_blocks"), AllSolidBlocks); + Writer->WriteValue(TEXT("classifier_all_air_blocks"), AllAirBlocks); + Writer->WriteValue(TEXT("classifier_mixed_blocks"), MixedBlocks); + Writer->WriteValue(TEXT("classifier_skip_rate"), BlocksTested > 0 + ? static_cast(AllSolidBlocks + AllAirBlocks) / BlocksTested : 0.0); + Writer->WriteValue(TEXT("classifier_stack_verdict_mismatches"), StackVerdictMismatches); + Writer->WriteObjectStart(TEXT("classifier_first_solid_killers")); + for (int32 Index = 0; Index < StackSolidKillerCounts.Num(); ++Index) + { + if (StackSolidKillerCounts[Index] <= 0) { continue; } + Writer->WriteValue( + FString::Printf(TEXT("op_%d"), Index), StackSolidKillerCounts[Index]); + } + Writer->WriteObjectEnd(); + Writer->WriteObjectStart(TEXT("classifier_first_air_killers")); + for (int32 Index = 0; Index < StackAirKillerCounts.Num(); ++Index) + { + if (StackAirKillerCounts[Index] <= 0) { continue; } + Writer->WriteValue( + FString::Printf(TEXT("op_%d"), Index), StackAirKillerCounts[Index]); + } + Writer->WriteObjectEnd(); + Writer->WriteValue(TEXT("random_blocks_tested"), RandomBlocksTested); + Writer->WriteValue(TEXT("bruteforce_block_index_min"), 1); + Writer->WriteValue(TEXT("bruteforce_block_index_max_inclusive"), 14); + Writer->WriteValue(TEXT("bounds_validated"), true); + Writer->WriteValue(TEXT("target_bottom_world_z"), World.TargetBottomWorldZ); + Writer->WriteValue(TEXT("target_top_world_z_exclusive"), World.TargetTopWorldZ); + Writer->WriteArrayStart(TEXT("operators")); + for (const FExploreOpBoundsAggregate& Aggregate : Aggregates) + { + Writer->WriteObjectStart(); + Writer->WriteValue(TEXT("name"), Aggregate.Name); + Writer->WriteValue(TEXT("blocks"), static_cast(Aggregate.Blocks)); + Writer->WriteValue(TEXT("sample_points"), static_cast(Aggregate.Samples)); + Writer->WriteValue(TEXT("forced_boxes"), static_cast(Aggregate.ForcedBoxes)); + Writer->WriteValue(TEXT("forced_all_solid_boxes"), static_cast(Aggregate.ForcedSolidBoxes)); + Writer->WriteValue(TEXT("forced_all_air_boxes"), static_cast(Aggregate.ForcedAirBoxes)); + Writer->WriteValue(TEXT("forced_margin_supremum"), static_cast(Aggregate.MaxForcedMargin)); + Writer->WriteObjectStart(TEXT("effect_counts")); + Writer->WriteValue(TEXT("Identity"), static_cast(Aggregate.EffectCounts[0])); + Writer->WriteValue(TEXT("CarveOnly"), static_cast(Aggregate.EffectCounts[1])); + Writer->WriteValue(TEXT("FillOnly"), static_cast(Aggregate.EffectCounts[2])); + Writer->WriteValue(TEXT("Both"), static_cast(Aggregate.EffectCounts[3])); + Writer->WriteObjectEnd(); + Writer->WriteValue(TEXT("carve_bound_known"), Aggregate.bCarveBoundKnown); + Writer->WriteValue(TEXT("unknown_carve_bound_boxes"), + static_cast(Aggregate.UnknownCarveBoundBoxes)); + Writer->WriteValue(TEXT("carve_bound_supremum"), static_cast( + Aggregate.bCarveBoundKnown ? Aggregate.MaxCarveSupremum : 0.0f)); + Writer->WriteValue(TEXT("fill_bound_known"), Aggregate.bFillBoundKnown); + Writer->WriteValue(TEXT("unknown_fill_bound_boxes"), + static_cast(Aggregate.UnknownFillBoundBoxes)); + Writer->WriteValue(TEXT("fill_bound_supremum"), static_cast( + Aggregate.bFillBoundKnown ? Aggregate.MaxFillSupremum : 0.0f)); + Writer->WriteValue(TEXT("actual_density_delta_min"), static_cast( + Aggregate.bHasDensityActual ? Aggregate.ActualDensityDeltaMin : 0.0f)); + Writer->WriteValue(TEXT("actual_density_delta_max"), static_cast( + Aggregate.bHasDensityActual ? Aggregate.ActualDensityDeltaMax : 0.0f)); + Writer->WriteValue(TEXT("actual_carve_supremum"), Aggregate.bHasDensityActual + ? static_cast(FMath::Max(0.0f, -Aggregate.ActualDensityDeltaMin)) : 0.0); + Writer->WriteValue(TEXT("actual_fill_supremum"), Aggregate.bHasDensityActual + ? static_cast(FMath::Max(0.0f, Aggregate.ActualDensityDeltaMax)) : 0.0); + Writer->WriteValue(TEXT("directional_ratio_known"), Aggregate.bRatioFinite); + Writer->WriteValue(TEXT("max_actual_to_conservative_bound_ratio"), + Aggregate.bRatioFinite ? Aggregate.MaxDirectionalRatio : 0.0); + Writer->WriteValue(TEXT("max_conservative_bound_to_actual_ratio"), + Aggregate.bRatioFinite ? Aggregate.MaxConservativeRatio : 0.0); + Writer->WriteValue(TEXT("carve_bound_violations"), static_cast(Aggregate.CarveBoundViolations)); + Writer->WriteValue(TEXT("fill_bound_violations"), static_cast(Aggregate.FillBoundViolations)); + Writer->WriteValue(TEXT("sdf_bound_known"), Aggregate.bSdfBoundKnown); + Writer->WriteValue(TEXT("sdf_bound_min"), static_cast( + Aggregate.bHasSdfBound ? Aggregate.SdfBoundMin : 0.0f)); + Writer->WriteValue(TEXT("sdf_bound_max"), static_cast( + Aggregate.bHasSdfBound ? Aggregate.SdfBoundMax : 0.0f)); + Writer->WriteValue(TEXT("actual_sdf_min"), static_cast( + Aggregate.bHasSdfActual ? Aggregate.ActualSdfMin : 0.0f)); + Writer->WriteValue(TEXT("actual_sdf_max"), static_cast( + Aggregate.bHasSdfActual ? Aggregate.ActualSdfMax : 0.0f)); + Writer->WriteValue(TEXT("sdf_bound_violations"), static_cast(Aggregate.SdfBoundViolations)); + Writer->WriteObjectEnd(); + } + Writer->WriteArrayEnd(); + Writer->WriteObjectEnd(); + + if (!Writer->Close() || Json.IsEmpty() + || !FFileHelper::SaveStringToFile( + Json, + *FPaths::Combine(Arguments.OutDirectory, TEXT("op_bounds.json")), + FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)) + { + OutError = TEXT("Could not write op_bounds.json."); + return false; + } + OutError.Reset(); + return true; +} + +bool RunOpBounds( + const FExploreArguments& Arguments, + FExploreWorld& World, + FExploreBudget& Budget, + FString& OutError) +{ + if (Arguments.Archetype != ECaveGeneratorType::TunnelNetwork + || !Arguments.bUseOperatorStack) + { + OutError = TEXT("opbounds requires archetype=TunnelNetwork and opstack=1."); + return false; + } + + const FIntVector TargetChunk(0, 0, World.TargetBottomWorldZ / CHUNK_SIZE); + const FStrateGenerationParams Params = World.Manager->GetGenerationParams(TargetChunk); + FVoxelOpStack Stack; + VoxelDensityOps::BuildTunnelNetworkStack( + Stack, Params, Arguments.Seed, World.Settings->OriginSpineRadius, + World.Manager.Get()); + FVoxelOpContext Context; + Context.ChunkCoord = TargetChunk; + Context.Step = 1; + Context.Seed = static_cast(Arguments.Seed); + Context.LayoutVersion = World.Manager->GetLayoutVersion(); + Context.WorldRadiusVoxels = World.Settings->WorldRadiusVoxels; + Context.EdgeSealThickness = World.Settings->EdgeSealThickness; + Context.StrateTopWorldZ = World.TargetTopWorldZ; + Context.StrateBottomWorldZ = World.TargetBottomWorldZ; + Stack.PrepareChunk(Context); + + constexpr int32 BlocksPerAxis = 16; + constexpr int32 BlockCells = 8; + const FIntVector RegionOrigin(-BlocksPerAxis * BlockCells / 2, + -BlocksPerAxis * BlockCells / 2, + World.TargetBottomWorldZ); + int32 AllSolidBlocks = 0; + int32 AllAirBlocks = 0; + int32 MixedBlocks = 0; + int32 BlocksTested = 0; + TArray StackSolidKillerCounts; + TArray StackAirKillerCounts; + int32 StackVerdictMismatches = 0; + for (int32 BZ = 0; BZ < BlocksPerAxis; ++BZ) + for (int32 BY = 0; BY < BlocksPerAxis; ++BY) + for (int32 BX = 0; BX < BlocksPerAxis; ++BX) + { + if ((BlocksTested & 63) == 0 && Budget.ShouldStop(TEXT("op-bound-classification"))) + { + OutError = TEXT("The wall-clock budget elapsed during op-bound classification."); + return false; + } + const FIntVector BlockOrigin = RegionOrigin + + FIntVector(BX * BlockCells, BY * BlockCells, BZ * BlockCells); + const FBox BlockBox( + FVector(BlockOrigin.X - 1, BlockOrigin.Y - 1, BlockOrigin.Z - 1), + FVector(BlockOrigin.X + BlockCells + 1, + BlockOrigin.Y + BlockCells + 1, + BlockOrigin.Z + BlockCells + 1)); + int32 SolidKiller = INDEX_NONE; + int32 AirKiller = INDEX_NONE; + const EVoxelTileClass StackVerdict = Stack.ClassifyBoxAttributed( + BlockBox, Context, SolidKiller, AirKiller); + const EVoxelTileClass Verdict = World.Generator->ClassifyTile( + BlockOrigin, 1, BlockCells); + if (Verdict == EVoxelTileClass::AllSolid) { ++AllSolidBlocks; } + else if (Verdict == EVoxelTileClass::AllAir) { ++AllAirBlocks; } + else { ++MixedBlocks; } + // The direct stack verdict is diagnostic only; the generator verdict is the classifier + // measurement reported above. + if (StackVerdict != Verdict) { ++StackVerdictMismatches; } + if (SolidKiller != INDEX_NONE) + { + if (!StackSolidKillerCounts.IsValidIndex(SolidKiller)) + { + StackSolidKillerCounts.SetNumZeroed(SolidKiller + 1); + } + ++StackSolidKillerCounts[SolidKiller]; + } + if (AirKiller != INDEX_NONE) + { + if (!StackAirKillerCounts.IsValidIndex(AirKiller)) + { + StackAirKillerCounts.SetNumZeroed(AirKiller + 1); + } + ++StackAirKillerCounts[AirKiller]; + } + ++BlocksTested; + } + + constexpr int32 RandomBlocks = 256; + TArray Aggregates; + TArray Diagnostics; + int32 RandomBlocksTested = 0; + for (int32 SampleIndex = 0; SampleIndex < RandomBlocks; ++SampleIndex) + { + if ((SampleIndex & 15) == 0 && Budget.ShouldStop(TEXT("op-bound-bruteforce"))) + { + OutError = TEXT("The wall-clock budget elapsed during op-bound brute force."); + return false; + } + const uint32 BaseHash = VoxelHash::Mix( + static_cast(Arguments.Seed) + ^ (static_cast(SampleIndex) * 0x9E3779B9u)); + // Keep the audit boxes inside the target's four-chunk representative volume. Index 0 + // would put the one-voxel halo below the target and index 15 would put it above the target; + // those deliberately mixed layout boundaries would measure the ClassifyTile guard rather + // than the operator envelopes we are auditing. + const int32 BX = 1 + static_cast(BaseHash % 14u); + const int32 BY = 1 + static_cast(VoxelHash::Mix(BaseHash ^ 0xA341316Cu) % 14u); + const int32 BZ = 1 + static_cast(VoxelHash::Mix(BaseHash ^ 0xC8013EA4u) % 14u); + const FIntVector BlockOrigin = RegionOrigin + + FIntVector(BX * BlockCells, BY * BlockCells, BZ * BlockCells); + const FBox BlockBox( + FVector(BlockOrigin.X - 1, BlockOrigin.Y - 1, BlockOrigin.Z - 1), + FVector(BlockOrigin.X + BlockCells + 1, + BlockOrigin.Y + BlockCells + 1, + BlockOrigin.Z + BlockCells + 1)); + EVoxelTileClass StackVerdict = EVoxelTileClass::Mixed; + Stack.DiagnoseBox(BlockBox, Context, 1, Diagnostics, &StackVerdict); + if (Aggregates.Num() == 0) + { + Aggregates.SetNum(Diagnostics.Num()); + } + for (int32 OpIndex = 0; OpIndex < Diagnostics.Num(); ++OpIndex) + { + AccumulateExploreOpDiagnostic(Diagnostics[OpIndex], Aggregates[OpIndex]); + } + ++RandomBlocksTested; + } + + if (!WriteExploreOpBoundsReport( + Arguments, World, BlocksTested, RandomBlocksTested, + AllSolidBlocks, AllAirBlocks, MixedBlocks, + StackSolidKillerCounts, StackAirKillerCounts, + StackVerdictMismatches, Aggregates, OutError)) + { + return false; + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeOpBounds] blocks=%d all_solid=%d all_air=%d mixed=%d skip_rate=%.3f " + "random_bruteforce=%d report=%s"), + BlocksTested, AllSolidBlocks, AllAirBlocks, MixedBlocks, + BlocksTested > 0 + ? static_cast(AllSolidBlocks + AllAirBlocks) / BlocksTested : 0.0, + RandomBlocksTested, + *FPaths::Combine(Arguments.OutDirectory, TEXT("op_bounds.json"))); + return true; +} + } // namespace UVoxelForgeExploreCommandlet::UVoxelForgeExploreCommandlet() @@ -4450,6 +4969,26 @@ int32 RunExploreCase(const FString& Params, FString* OutJson) } const double SetupSeconds = FPlatformTime::Seconds() - SetupStartSeconds; + bool bRequestedModeFailed = false; + if (Arguments.bOpBounds) + { + const double Start = FPlatformTime::Seconds(); + const bool bOpBoundsOk = !Budget.bTruncated + && RunOpBounds(Arguments, World, Budget, Error); + if (!bOpBoundsOk && !Budget.bTruncated) + { + bRequestedModeFailed = true; + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] opbounds failed: %s"), *Error); + } + if (bOpBoundsOk && !Budget.bTruncated) + { + Budget.CompleteMode(TEXT("opbounds")); + } + UE_LOG(LogTemp, Display, TEXT("[VoxelForgeExplore] opbounds %.3fs (%s)"), + FPlatformTime::Seconds() - Start, + bOpBoundsOk ? TEXT("ok") : (Budget.bTruncated ? TEXT("truncated") : TEXT("error"))); + } + if (Arguments.bProfileLod) { const FIntVector ProbeOrigin(0, 0, World.TargetBottomWorldZ); @@ -4523,7 +5062,6 @@ int32 RunExploreCase(const FString& Params, FString* OutJson) UE_LOG(LogTemp, Warning, TEXT("[VoxelForgeExplore] setup consumed the wall-clock budget; no mode was started.")); } - bool bRequestedModeFailed = false; FExploreWalkOutput RenderSeedWalk; const FExploreWalkOutput* CameraSeedWalk = nullptr; if (Arguments.bWalk) @@ -4570,7 +5108,7 @@ int32 RunExploreCase(const FString& Params, FString* OutJson) // it cannot affect the profiled world's generation or its deterministic output. if (Arguments.bProfileDensity && (Arguments.bRender || Arguments.bExport) - && CameraSeedWalk != nullptr + && (CameraSeedWalk != nullptr || Arguments.bExport) && !Budget.bTruncated) { Output.ProfilerComparison.Scope = TEXT("canonical_mesh_export"); @@ -5101,6 +5639,8 @@ bool BuildBatchCaseParams( bool bReuseDensityGrid = true; JsonBool(Case, TEXT("density_grid_reuse"), bReuseDensityGrid); + bool bBlockEarlyOut = false; + JsonBool(Case, TEXT("block_early_out"), bBlockEarlyOut); int32 MeshMinBatchSize = 1; if (JsonNumber(Case, TEXT("mesh_min_batch_size"), Number)) { @@ -5118,15 +5658,19 @@ bool BuildBatchCaseParams( bProfileDensity |= bProfileDensityFull; bool bProfileLod = false; JsonBool(Case, TEXT("profile_lod"), bProfileLod); + bool bOpBounds = false; + JsonBool(Case, TEXT("op_bounds"), bOpBounds); bool bFailureFocus = false; JsonBool(Case, TEXT("failure_focus_render"), bFailureFocus); OutParams = FString::Printf( TEXT("-seed=%d -archetype=%s -slot=%d -modes=%s -opstack=%d " - "-exportsize=%d -exportstep=%d -densitygridreuse=%d -meshminbatch=%d " + "-exportsize=%d -exportstep=%d -densitygridreuse=%d " + "-blockearlyout=%d -meshminbatch=%d " "-failurefocus=%d -out=\"%s\""), Seed, *Archetype, Slot, *Modes, bOperatorStack ? 1 : 0, - ExportSize, ExportStep, bReuseDensityGrid ? 1 : 0, MeshMinBatchSize, + ExportSize, ExportStep, bReuseDensityGrid ? 1 : 0, + bBlockEarlyOut ? 1 : 0, MeshMinBatchSize, bFailureFocus ? 1 : 0, *CaseOutDirectory); if (bProfileDensity) @@ -5139,6 +5683,10 @@ bool BuildBatchCaseParams( { OutParams += TEXT(" -profilelod"); } + if (bOpBounds) + { + OutParams += TEXT(" -opbounds"); + } const TPair IntegerFields[] = { { TEXT("render_width"), TEXT("renderwidth") },