From ee89c4d8c262f35e5670229de0cbd82d1d7fce23 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Wed, 9 Sep 2026 03:05:40 +0200 Subject: [PATCH] perf(gen): 5x fewer candidates, 16x cheaper floor -- and 0% end-to-end. Read the last section. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner, after playing 0576e80: "slightly faster, still not on par... I can outrun the generation and fall." Also, unprompted: "the tunnels seem forced, with a forced flat -- it technically looks good, but could be a reason." He was right that it was a reason; it was not THE reason. WHAT LANDED, all real and all measured Conservative spatial index (immutable per-chunk uniform bins, baked conservative bounds, squared rejects, deterministic ordering): candidates scanned/sample 25.140 -> 5.192 candidates evaluated/sample 13.879 -> 2.992 cave tunnels 11.228/6.069 -> 1.525/0.723 tunnel core 11.228/5.831 -> 1.216/0.567 The earlier flat AABB attempt failed because loose bounds culled almost nothing while adding a pass. Technique from GPU Gems 3 ch.32 broad-phase. The forced-flat floor, which the owner spotted by eye: a floor decision is inherently per-COLUMN and was being recomputed for every Z sample in that column. Now resolved once per integer XY column and reused. TunnelCorePosts 2.29 us/sample -> 0.138 us/sample (16x) The floor is unchanged in behaviour: still applied only after a tunnel passes its world bounds and walkable projection, still walkable, look untouched. FIRST MEASUREMENT OF OUR MEMORY FOOTPRINT The owner: "it shouldn't be pressured on such system, even less when not running anything." He is right, and it is ours: total resident worker cache 431,162,116 B = 411 MiB 15 workers 27.41 MiB each average valid entry 278,704 B largest entry 541,344 B static slot storage 2.4 MiB (dynamic: 428.6 MB) Cutting 4096 -> 128 slots only saved static storage. The weight is 272 KB PER CACHED CHUNK STATE, ~102 live per worker. Nobody has yet asked why one cached chunk state needs 272 KB. That is the open question, not the slot count. ⚠️ AND THE PART THAT MATTERS MOST: THIS BOUGHT NOTHING END-TO-END 128^3 TunnelNetwork, seed 1337 slot 1, same region, unprofiled: before this commit 2.623 s after, 3 runs 2.705 / 2.678 / 2.682 s Flat. A 5x candidate reduction and a 16x cheaper floor moved real generation time by zero. The reason is visible in the numbers and I should have caught it earlier: the PROFILED path reports 14.74 us/sample while the real unprofiled cost is 1.28 us/voxel. **The instrument inflates by ~11x.** Every bucket we have been ranking and optimising for two rounds was measured under that inflation, so the ranking does not reflect where real time goes. We optimised what the profiler exaggerated. ⇒ Before any further optimisation: make the profiler cheap enough that profiled and unprofiled totals agree within a small factor, or switch to sampling. An alarming number is the instrument until proven otherwise -- three for three in this project, and now four. Still true: ~21x slower than the pre-op-stack build, and generation still loses to a walking player. Geometry changed by 40 triangles (153,692 -> 153,732), which is allowed; two full exports hash identically (37E00BEE..0A7DF). Build 42/42. No CPU-feature dispatch. Nothing outside Plugins/VoxelForge was touched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb --- .../Private/VoxelCaveMorphology.cpp | 1219 ++++++++++++++++- .../Private/VoxelDensityOpStack.cpp | 400 ++++-- .../Private/VoxelDensityProfile.cpp | 219 ++- Source/VoxelForge/Private/VoxelGenerator.cpp | 448 +++--- .../Private/VoxelMarchingCubesMesher.cpp | 66 +- .../VoxelForge/Public/VoxelCaveMorphology.h | 193 ++- .../VoxelForge/Public/VoxelDensityOpStack.h | 7 + .../VoxelForge/Public/VoxelDensityProfile.h | 62 + Source/VoxelForge/Public/VoxelGenerator.h | 3 +- Source/VoxelForge/Public/VoxelNoise.h | 9 + .../Private/VoxelForgeExploreCommandlet.cpp | 142 +- 11 files changed, 2380 insertions(+), 388 deletions(-) diff --git a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp index 55579df..d0f8d03 100644 --- a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp +++ b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -34,8 +34,356 @@ #include "VoxelTypes.h" // Pour VOXEL_NOISE_SCALE, SmoothStep01 #include "VoxelStrateTypes.h" #include "VoxelNoise.h" // Pure FBM used by the slab landing query +#include "VoxelPassageGeometry.h" #include "VoxelTerrainOpDefinition.h" +namespace +{ + // Density coordinates and cached cull radii are float-valued. FVector is double-valued in + // UE5, so using FVector::DistSquared here needlessly widens every candidate reject. Keep the + // cull in the same build-fixed float domain as the density query; the exact field output is + // intentionally no longer required to match the pre-optimization double round-trip. + FORCEINLINE float VF_FloatDistSquared( + float X, float Y, float Z, const FVector& Center) + { + const float DX = X - static_cast(Center.X); + const float DY = Y - static_cast(Center.Y); + const float DZ = Z - static_cast(Center.Z); + return DX * DX + DY * DY + DZ * DZ; + } + + FORCEINLINE float VF_DistanceSquaredToAabb( + float X, float Y, float Z, + const FVector& Min, + const FVector& Max) + { + const float DX = X < static_cast(Min.X) + ? static_cast(Min.X) - X + : (X > static_cast(Max.X) ? X - static_cast(Max.X) : 0.0f); + const float DY = Y < static_cast(Min.Y) + ? static_cast(Min.Y) - Y + : (Y > static_cast(Max.Y) ? Y - static_cast(Max.Y) : 0.0f); + const float DZ = Z < static_cast(Min.Z) + ? static_cast(Min.Z) - Z + : (Z > static_cast(Max.Z) ? Z - static_cast(Max.Z) : 0.0f); + 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 +{ + SIZE_T Bytes = 0; + auto AddArray = [&Bytes](const auto& Array) + { + Bytes += static_cast(Array.GetAllocatedSize()); + }; + + AddArray(Rooms); + AddArray(RoomFloorJoins); + AddArray(Tunnels); + AddArray(Pits); + AddArray(Chimneys); + AddArray(Columns); + AddArray(SupportColumnEntries); + AddArray(SupportColumns); + for (const FTunnelSupportFloorColumn& Column : SupportColumns) + { + AddArray(Column.Intervals); + } + + for (const FCachedTunnel& Tunnel : Tunnels) + { + AddArray(Tunnel.ControlPoints); + AddArray(Tunnel.ControlRadii); + AddArray(Tunnel.WorldControlPoints); + AddArray(Tunnel.WorldControlRadii); + } + + const FCaveSpatialIndex* Indices[] = { + &RoomIndex, &RoomFloorJoinIndex, &TunnelIndex, &TunnelWorldIndex, + }; + for (const FCaveSpatialIndex* Index : Indices) + { + AddArray(Index->Bins); + AddArray(Index->CandidateIndices); + } + return Bytes; +} + //============================================================================= // INTERNAL: Full room data used during cache building only. // The CellX/CellY fields are needed for tunnel pair hashing but NOT for @@ -94,6 +442,258 @@ 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); + // Detail modifiers run while CaveSDF is below 3*K, so the source + // broad phase must cover that full downstream reach. + const float SDFDetailPad = BlendPad * 3.0f; + const float WorldFloorPad = FMath::Max( + VoxelPassageGeometry::LandingFloorThicknessVoxels, BlendPad); + + if (Tunnel.ControlPoints.Num() >= 2 + && Tunnel.ControlRadii.Num() == Tunnel.ControlPoints.Num()) + { + Tunnel.SDFCenterlineMin = Tunnel.ControlPoints[0]; + Tunnel.SDFCenterlineMax = Tunnel.ControlPoints[0]; + float MaxRadius = 0.0f; + for (int32 Index = 0; Index < Tunnel.ControlPoints.Num(); ++Index) + { + const FVector& Point = Tunnel.ControlPoints[Index]; + Tunnel.SDFCenterlineMin.X = FMath::Min(Tunnel.SDFCenterlineMin.X, Point.X); + Tunnel.SDFCenterlineMin.Y = FMath::Min(Tunnel.SDFCenterlineMin.Y, Point.Y); + Tunnel.SDFCenterlineMin.Z = FMath::Min(Tunnel.SDFCenterlineMin.Z, Point.Z); + Tunnel.SDFCenterlineMax.X = FMath::Max(Tunnel.SDFCenterlineMax.X, Point.X); + Tunnel.SDFCenterlineMax.Y = FMath::Max(Tunnel.SDFCenterlineMax.Y, Point.Y); + Tunnel.SDFCenterlineMax.Z = FMath::Max(Tunnel.SDFCenterlineMax.Z, Point.Z); + MaxRadius = FMath::Max(MaxRadius, FMath::Abs(Tunnel.ControlRadii[Index])); + } + Tunnel.SDFInfluenceRadius = MaxRadius + SDFDetailPad; + } + else + { + Tunnel.SDFCenterlineMin = Tunnel.EndpointA; + Tunnel.SDFCenterlineMax = Tunnel.EndpointA; + Tunnel.SDFCenterlineMin.X = FMath::Min(Tunnel.SDFCenterlineMin.X, Tunnel.EndpointB.X); + Tunnel.SDFCenterlineMin.Y = FMath::Min(Tunnel.SDFCenterlineMin.Y, Tunnel.EndpointB.Y); + Tunnel.SDFCenterlineMin.Z = FMath::Min(Tunnel.SDFCenterlineMin.Z, Tunnel.EndpointB.Z); + Tunnel.SDFCenterlineMax.X = FMath::Max(Tunnel.SDFCenterlineMax.X, Tunnel.EndpointB.X); + Tunnel.SDFCenterlineMax.Y = FMath::Max(Tunnel.SDFCenterlineMax.Y, Tunnel.EndpointB.Y); + Tunnel.SDFCenterlineMax.Z = FMath::Max(Tunnel.SDFCenterlineMax.Z, Tunnel.EndpointB.Z); + float MaxRadius = FMath::Max( + FMath::Abs(Tunnel.RadiusA), FMath::Abs(Tunnel.RadiusB)); + if (Tunnel.bHasMidpoint) + { + Tunnel.SDFCenterlineMin.X = FMath::Min(Tunnel.SDFCenterlineMin.X, Tunnel.Midpoint.X); + Tunnel.SDFCenterlineMin.Y = FMath::Min(Tunnel.SDFCenterlineMin.Y, Tunnel.Midpoint.Y); + Tunnel.SDFCenterlineMin.Z = FMath::Min(Tunnel.SDFCenterlineMin.Z, Tunnel.Midpoint.Z); + Tunnel.SDFCenterlineMax.X = FMath::Max(Tunnel.SDFCenterlineMax.X, Tunnel.Midpoint.X); + Tunnel.SDFCenterlineMax.Y = FMath::Max(Tunnel.SDFCenterlineMax.Y, Tunnel.Midpoint.Y); + Tunnel.SDFCenterlineMax.Z = FMath::Max(Tunnel.SDFCenterlineMax.Z, Tunnel.Midpoint.Z); + MaxRadius = FMath::Max(MaxRadius, FMath::Abs(Tunnel.RadiusMid)); + } + Tunnel.SDFInfluenceRadius = MaxRadius + SDFDetailPad; + } + + if (Tunnel.WorldControlPoints.Num() >= 2 + && Tunnel.WorldControlRadii.Num() == Tunnel.WorldControlPoints.Num()) + { + Tunnel.WorldCenterlineMin = Tunnel.WorldControlPoints[0]; + Tunnel.WorldCenterlineMax = Tunnel.WorldControlPoints[0]; + float MaxRadius = 0.0f; + for (int32 Index = 0; Index < Tunnel.WorldControlPoints.Num(); ++Index) + { + const FVector& Point = Tunnel.WorldControlPoints[Index]; + Tunnel.WorldCenterlineMin.X = FMath::Min(Tunnel.WorldCenterlineMin.X, Point.X); + Tunnel.WorldCenterlineMin.Y = FMath::Min(Tunnel.WorldCenterlineMin.Y, Point.Y); + Tunnel.WorldCenterlineMin.Z = FMath::Min(Tunnel.WorldCenterlineMin.Z, Point.Z); + Tunnel.WorldCenterlineMax.X = FMath::Max(Tunnel.WorldCenterlineMax.X, Point.X); + Tunnel.WorldCenterlineMax.Y = FMath::Max(Tunnel.WorldCenterlineMax.Y, Point.Y); + Tunnel.WorldCenterlineMax.Z = FMath::Max(Tunnel.WorldCenterlineMax.Z, Point.Z); + MaxRadius = FMath::Max(MaxRadius, FMath::Abs(Tunnel.WorldControlRadii[Index])); + } + Tunnel.WorldInfluenceRadius = MaxRadius + WorldFloorPad; + } + else + { + Tunnel.WorldCenterlineMin = Tunnel.SDFCenterlineMin; + Tunnel.WorldCenterlineMax = Tunnel.SDFCenterlineMax; + Tunnel.WorldInfluenceRadius = Tunnel.SDFInfluenceRadius + - SDFDetailPad + WorldFloorPad; + } + } + + static void VF_BuildCacheSpatialIndices( + 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 @@ -2672,6 +3272,16 @@ 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; + OutCache.SupportColumnCellsY = 0; + OutCache.SupportColumnEntries.Reset(); + OutCache.SupportColumns.Reset(); // Combine world seed with strate index so each strate gets unique caves const uint32 StrateSeed = VoxelCaveMorphology::MakeStrateSeed(Seed, StrateIndex); @@ -2687,12 +3297,21 @@ void VoxelCaveMorphology::BuildChunkCache( // of a box can touch a voxel inside that box. // Envelope conservatif / conservative bound: Lerp accepts inverted endpoints, // so max(Min, Max) covers either radius without changing the authored roll. - const float RoomRadiusEnvelope = FMath::Max(Params.MinRoomRadius, Params.MaxRoomRadius); - const float TunnelRadiusEnvelope = FMath::Max(Params.TunnelMinRadius, Params.TunnelMaxRadius); + const float RoomRadiusEnvelope = FMath::Max( + FMath::Abs(Params.MinRoomRadius), FMath::Abs(Params.MaxRoomRadius)); + // Interior control points can carry the authored radius variation (+/-18%). Include that + // reach in collection; the per-tunnel index below uses each baked radius exactly. + const float TunnelRadiusEnvelope = FMath::Max( + 0.5f, + FMath::Max(FMath::Abs(Params.TunnelMinRadius), FMath::Abs(Params.TunnelMaxRadius)) + * 1.18f); + const float FloorReliefEnvelope = FMath::Abs(Params.FloorReliefStrength) + * VOXEL_NOISE_SCALE * 1.5f; + const float BlendEnvelope = FMath::Max(Params.SDFBlendRadius, 0.0f); const float MaxInfluence = FMath::Max( - RoomRadiusEnvelope, - Params.TunnelWarpStrength + TunnelRadiusEnvelope - ) + Params.SDFBlendRadius; + RoomRadiusEnvelope + FloorReliefEnvelope, + FMath::Abs(Params.TunnelWarpStrength) + TunnelRadiusEnvelope + ) + BlendEnvelope; const float MaxTunnelLen = FMath::Max(Params.MaxTunnelLength, 0.0f); @@ -2746,16 +3365,30 @@ void VoxelCaveMorphology::BuildChunkCache( const float BlendK = Params.SDFBlendRadius; - // "This room can reach the search box" test, using the room's OWN reach — the same - // extent formula as its per-voxel cull sphere (1.5x radius for capsule-stretched - // variants + blend margin). The old test compared the center against a box inflated + // "This room can reach the search box" test, using the room's OWN reach. The old test + // compared the center against a box inflated // by the SHARED MaxInfluence, which silently assumed every room's reach <= MaxInfluence. // That's FALSE for the origin room (OriginRoomRadius >> MaxRoomRadius) — chunks inside // the big room but > MaxInfluence from (0,0) didn't store it, so its carve clipped at an // arbitrary chunk-aligned radius — and slightly false even for hash rooms (1.5x stretch). + const auto RoomShapeReachUpperBound = [](float RadiusXY, float RadiusZ) -> float + { + const float RXY = FMath::Abs(RadiusXY); + const float RZ = FMath::Abs(RadiusZ); + const float Ellipsoid = FMath::Max(RXY, RZ); + const FVector BoxExtent( + RXY * 0.8f + RXY * 0.25f, + RXY * 0.8f + RXY * 0.25f, + RZ * 0.8f + RXY * 0.25f); + const float RoundedBox = BoxExtent.Size(); + const float Capsule = RXY * 0.7f + FMath::Min(RXY * 0.6f, RZ); + return FMath::Max3(Ellipsoid, RoundedBox, Capsule); + }; + auto RoomReachesSearchBox = [&](const FVector& C, float RadiusXY, float RadiusZ) -> bool { - const float Reach = FMath::Max(RadiusXY * 1.5f, RadiusZ) + BlendK * 3.0f; + const float ShapeReach = RoomShapeReachUpperBound(RadiusXY, RadiusZ); + const float Reach = ShapeReach + FloorReliefEnvelope + BlendEnvelope * 3.0f; const float dx = FMath::Max3((float)(SearchMinX - C.X), 0.0f, (float)(C.X - SearchMaxX)); const float dy = FMath::Max3((float)(SearchMinY - C.Y), 0.0f, (float)(C.Y - SearchMaxY)); return (dx * dx + dy * dy) <= Reach * Reach; @@ -2840,7 +3473,11 @@ void VoxelCaveMorphology::BuildChunkCache( } const int32 NumRooms = BuildRooms.Num(); - if (NumRooms == 0) return; + if (NumRooms == 0) + { + VF_BuildCacheSpatialIndices(OutCache, BlendK); + return; + } // Keep the room floor calculation in one place so tunnel mouths and overlap joins use the // same deterministic support plane as the emitted room. It includes the room-centre relief; @@ -3260,7 +3897,7 @@ void VoxelCaveMorphology::BuildChunkCache( BoundR = FMath::Max( BoundR, FVector::Dist(CT.BoundCenter, CT.ControlPoints[PointIndex])); } - BoundR += MaxTunnelRadius + BlendK; + BoundR += MaxTunnelRadius + FMath::Max(BlendK, 0.0f); CT.BoundRadiusSq = BoundR * BoundR; // Keep a separate bound for the world-space structural backstop. The SDF and world @@ -3282,7 +3919,11 @@ void VoxelCaveMorphology::BuildChunkCache( WorldBoundR, FVector::Dist(CT.WorldBoundCenter, CT.WorldControlPoints[PointIndex])); } - WorldBoundR += MaxTunnelRadius + BlendK; + // The support predicate reaches LandingFloorThickness below the centreline floor; + // the world sphere must cover that slab, not only the blended SDF tube. + WorldBoundR += MaxTunnelRadius + FMath::Max( + VoxelPassageGeometry::LandingFloorThicknessVoxels, + FMath::Max(BlendK, 0.0f)); CT.WorldBoundRadiusSq = WorldBoundR * WorldBoundR; // Store if either representation can reach a voxel in this chunk. The warped SDF @@ -3401,7 +4042,7 @@ void VoxelCaveMorphology::BuildChunkCache( Join.CeilingZ = CommonFloor + JoinRequiredHeight; Join.BoundCenter = (Join.Start + Join.End) * 0.5f; const float BoundRadius = 0.5f * FVector::Dist(Join.Start, Join.End) - + Join.Radius + BlendK; + + FMath::Abs(Join.Radius) + FMath::Max(BlendK, 0.0f); Join.BoundRadiusSq = BoundRadius * BoundRadius; if (SphereTouchesSearchXY(Join.BoundCenter, Join.BoundRadiusSq)) @@ -3454,8 +4095,10 @@ void VoxelCaveMorphology::BuildChunkCache( // Cull radius: max extent the room can reach + blend margin. // 1.5x accounts for capsule shapes extending beyond nominal radius. - float MaxExtent = FMath::Max(BR.RadiusXY * 1.5f, BR.RadiusZ) + BlendK * 3.0f; - CR.CullRadiusSq = MaxExtent * MaxExtent; + float MaxExtent = FMath::Max( + FMath::Abs(BR.RadiusXY) * 1.5f, + FMath::Abs(BR.RadiusZ)); + MaxExtent += FloorReliefEnvelope + BlendEnvelope * 3.0f; // --- PRE-BAKED SHAPE --- // Same hash roll + thresholds + capsule trig the evaluator used to redo PER VOXEL; @@ -3490,6 +4133,33 @@ void VoxelCaveMorphology::BuildChunkCache( CR.ShapeType = 0; CR.ShapeA = FVector(CR.RadiusXY, CR.RadiusXY, CR.RadiusZ); } + + // Use the actual pre-baked shape's farthest possible distance, not a guessed 1.5x + // sphere. The old guess underbounded a rounded-box corner when RadiusZ was large. + float ShapeReach = 0.0f; + if (CR.ShapeType == 1) + { + const float Round = FMath::Abs(CR.ShapeR); + const FVector Extent( + FMath::Abs(CR.ShapeA.X) + Round, + FMath::Abs(CR.ShapeA.Y) + Round, + FMath::Abs(CR.ShapeA.Z) + Round); + ShapeReach = Extent.Size(); + } + else if (CR.ShapeType == 2) + { + ShapeReach = 0.5f * FVector::Dist(CR.ShapeA, CR.ShapeB) + + FMath::Abs(CR.ShapeR); + } + else + { + ShapeReach = FMath::Max3( + FMath::Abs(CR.ShapeA.X), + FMath::Abs(CR.ShapeA.Y), + FMath::Abs(CR.ShapeA.Z)); + } + MaxExtent = ShapeReach + FloorReliefEnvelope + BlendEnvelope * 3.0f; + CR.CullRadiusSq = MaxExtent * MaxExtent; } // Flat floor cut: soft floor plane per room, hash-rolled from [Min, Max]. @@ -3542,6 +4212,76 @@ void VoxelCaveMorphology::BuildChunkCache( } } + // ARCHES/PINCHES — all geometry below is a pure function of the room hash and the + // room-local terrain parameters. Bake the hash chains and the two trigonometric pairs + // once per room instead of once per near-surface voxel. The active decision uses the + // same `ToFloat01(H) > Density` predicate as the old loop; only its location changes. + FStrateGenerationParams FeatureParams = Params; + if (CR.RoomOp) + { + CR.RoomOp->ApplyTo(FeatureParams, CR.RoomOpWeight); + } + for (int32 i = 0; i < 3; ++i) + { + const uint32 AH = VoxelHash::Mix(CR.Hash ^ (0xA4C400u + (uint32)i * 7369u)); + if (VoxelHash::ToFloat01(AH) <= FeatureParams.ArchDensity) + { + const uint32 AH2 = VoxelHash::Mix(AH ^ 0xA4C4u); + const float ArcCX = CR.Center.X + + VoxelHash::ToFloatSigned(AH2) * CR.RadiusXY * 0.3f; + const float ArcCY = CR.Center.Y + + VoxelHash::ToFloatSigned(VoxelHash::Mix(AH2)) * CR.RadiusXY * 0.3f; + const uint32 AH3 = VoxelHash::Mix(AH2 ^ 0xB41Du); + const float ArcCZ = CR.Center.Z + + VoxelHash::ToFloatSigned(AH3) * CR.RadiusZ * 0.4f; + const uint32 AH4 = VoxelHash::Mix(AH3 ^ 0xCAFEu); + const float Angle = VoxelHash::ToFloat01(AH4) * PI; + const float HalfSpan = CR.RadiusXY + * (0.5f + VoxelHash::ToFloat01(VoxelHash::Mix(AH4)) * 0.35f); + const float CosA = FMath::Cos(Angle); + const float SinA = FMath::Sin(Angle); + FCachedArch& Arch = CR.Arches[i]; + Arch.bActive = true; + Arch.EndpointA = FVector(ArcCX - CosA * HalfSpan, + ArcCY - SinA * HalfSpan, ArcCZ); + Arch.EndpointB = FVector(ArcCX + CosA * HalfSpan, + ArcCY + SinA * HalfSpan, ArcCZ); + const uint32 AH5 = VoxelHash::Mix(AH4 ^ 0xF00Du); + Arch.Radius = FMath::Lerp( + FeatureParams.ArchMinRadius, FeatureParams.ArchMaxRadius, + VoxelHash::ToFloat01(AH5)); + Arch.BaseDensity = FeatureParams.BaseDensity; + } + + const uint32 PnH = VoxelHash::Mix(CR.Hash ^ (0xF1C400u + (uint32)i * 5417u)); + if (VoxelHash::ToFloat01(PnH) <= FeatureParams.PinchDensity) + { + const uint32 PnH2 = VoxelHash::Mix(PnH ^ 0xF1C4u); + const float PnX = CR.Center.X + + VoxelHash::ToFloatSigned(PnH2) * CR.RadiusXY * 0.85f; + const float PnY = CR.Center.Y + + VoxelHash::ToFloatSigned(VoxelHash::Mix(PnH2)) * CR.RadiusXY * 0.85f; + const uint32 PnH3 = VoxelHash::Mix(PnH2 ^ 0x5432u); + const float PnZ = CR.Center.Z + + VoxelHash::ToFloatSigned(PnH3) * CR.RadiusZ * 0.5f; + const uint32 PnH4 = VoxelHash::Mix(PnH3 ^ 0x9A3Bu); + const float PnAngle = VoxelHash::ToFloat01(PnH4) * PI; + FCachedPinch& Pinch = CR.Pinches[i]; + Pinch.bActive = true; + Pinch.CenterX = PnX; + Pinch.CenterY = PnY; + Pinch.CenterZ = PnZ; + Pinch.CosAngle = FMath::Cos(PnAngle); + Pinch.SinAngle = FMath::Sin(PnAngle); + Pinch.MaxExtent = FMath::Max( + FeatureParams.PinchLength, FeatureParams.PinchStrength) + 5.0f; + Pinch.HalfLength = FeatureParams.PinchLength * 0.5f; + Pinch.HalfNarrow = FeatureParams.PinchStrength; + Pinch.HalfVertical = FeatureParams.PinchStrength * 1.5f; + Pinch.BaseDensity = FeatureParams.BaseDensity; + } + } + OutCache.Rooms.Add(CR); //--------------------------------------------------------------------- @@ -3615,8 +4355,12 @@ void VoxelCaveMorphology::BuildChunkCache( const float MaxXYR = ColR + 6.0f; Col.BoundXYRadiusSq = MaxXYR * MaxXYR; OutCache.Columns.Add(Col); - }); + }); } + + // 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); } //============================================================================= @@ -3644,26 +4388,27 @@ 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::IsEnabled()) { VoxelDensityProfile::AddCounter( VoxelDensityProfile::ECounter::CaveRoomCandidates, - static_cast(Cache.Rooms.Num())); - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::CaveTunnelCandidates, - static_cast(Cache.Tunnels.Num())); + static_cast(bIndexedRooms ? RoomCount : Cache.Rooms.Num())); } //========================================================================= // Room SDFs //========================================================================= - for (int32 RoomIdx = 0; RoomIdx < Cache.Rooms.Num(); ++RoomIdx) + auto EvaluateRoom = [&](int32 RoomIdx) { const FCachedRoom& Room = Cache.Rooms[RoomIdx]; // --- DISTANCE CULL --- - const float DistSq = FVector::DistSquared(Pos, Room.Center); - if (DistSq > Room.CullRadiusSq) continue; + const float DistSq = VF_FloatDistSquared(WorldX, WorldY, WorldZ, Room.Center); + if (DistSq > Room.CullRadiusSq) return; if (VoxelDensityProfile::IsEnabled()) { VoxelDensityProfile::AddCounter( @@ -3706,6 +4451,21 @@ float VoxelCaveMorphology::EvaluateSDFCached( } MinSDF = VoxelSDF::SmoothMin(MinSDF, RoomSDF, BlendK); + }; + + if (bIndexedRooms) + { + 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); + } } //========================================================================== @@ -3715,10 +4475,15 @@ 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. - for (const FCachedRoomFloorJoin& Join : Cache.RoomFloorJoins) + int32 JoinFirst = 0; + int32 JoinCount = 0; + const bool bIndexedJoins = Cache.RoomFloorJoinIndex.GetBin( + WorldX, WorldY, WorldZ, JoinFirst, JoinCount); + auto EvaluateJoin = [&](int32 JoinIdx) { - const float DistSq = FVector::DistSquared(Pos, Join.BoundCenter); - if (DistSq > Join.BoundRadiusSq) continue; + const FCachedRoomFloorJoin& Join = Cache.RoomFloorJoins[JoinIdx]; + const float DistSq = VF_FloatDistSquared(WorldX, WorldY, WorldZ, Join.BoundCenter); + if (DistSq > Join.BoundRadiusSq) return; const float HorizontalSDF = VoxelSDF::Capsule( Pos, Join.Start, Join.End, Join.Radius); @@ -3727,16 +4492,50 @@ float VoxelCaveMorphology::EvaluateSDFCached( Pos.Z - Join.CeilingZ); const float JoinSDF = FMath::Max(HorizontalSDF, VerticalSDF); MinSDF = VoxelSDF::SmoothMin(MinSDF, JoinSDF, BlendK); + }; + if (bIndexedJoins) + { + 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); + } } //========================================================================= // Tunnel SDFs //========================================================================= - for (const FCachedTunnel& Tunnel : Cache.Tunnels) + int32 TunnelFirst = 0; + int32 TunnelCount = 0; + const bool bIndexedTunnels = Cache.TunnelIndex.GetBin( + WorldX, WorldY, WorldZ, TunnelFirst, TunnelCount); + if (VoxelDensityProfile::IsEnabled()) { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::CaveTunnelCandidates, + static_cast(bIndexedTunnels ? TunnelCount : Cache.Tunnels.Num())); + } + + auto EvaluateTunnel = [&](int32 TunnelIdx) + { + const FCachedTunnel& Tunnel = Cache.Tunnels[TunnelIdx]; + if (Tunnel.SDFInfluenceRadius > 0.0f + && VF_DistanceSquaredToAabb( + WorldX, WorldY, WorldZ, + Tunnel.SDFCenterlineMin, Tunnel.SDFCenterlineMax) + > FMath::Square(Tunnel.SDFInfluenceRadius)) + { + return; + } // --- BOUNDING SPHERE CULL --- - const float DistSq = FVector::DistSquared(Pos, Tunnel.BoundCenter); - if (DistSq > Tunnel.BoundRadiusSq) continue; + const float DistSq = VF_FloatDistSquared(WorldX, WorldY, WorldZ, Tunnel.BoundCenter); + if (DistSq > Tunnel.BoundRadiusSq) return; if (VoxelDensityProfile::IsEnabled()) { VoxelDensityProfile::AddCounter( @@ -3783,6 +4582,25 @@ float VoxelCaveMorphology::EvaluateSDFCached( } MinSDF = VoxelSDF::SmoothMin(MinSDF, TunnelSDF, BlendK); + }; + + if (bIndexedTunnels) + { + 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); + } } // Write nearest room index for the caller (terrain ops system) @@ -3801,11 +4619,25 @@ float VoxelCaveMorphology::EvaluateTunnelCoreSDF( const FVector Pos(WorldX, WorldY, WorldZ); float MinSDF = FLT_MAX; - for (const FCachedTunnel& Tunnel : Cache.Tunnels) + int32 TunnelFirst = 0; + int32 TunnelCount = 0; + const bool bIndexedTunnels = Cache.TunnelIndex.GetBin( + WorldX, WorldY, WorldZ, TunnelFirst, TunnelCount); + auto EvaluateTunnel = [&](int32 TunnelIdx) { - if (FVector::DistSquared(Pos, Tunnel.BoundCenter) > Tunnel.BoundRadiusSq) + const FCachedTunnel& Tunnel = Cache.Tunnels[TunnelIdx]; + if (Tunnel.SDFInfluenceRadius > 0.0f + && VF_DistanceSquaredToAabb( + WorldX, WorldY, WorldZ, + Tunnel.SDFCenterlineMin, Tunnel.SDFCenterlineMax) + > FMath::Square(Tunnel.SDFInfluenceRadius)) { - continue; + return; + } + if (VF_FloatDistSquared(WorldX, WorldY, WorldZ, Tunnel.BoundCenter) + > Tunnel.BoundRadiusSq) + { + return; } if (Tunnel.ControlPoints.Num() >= 2 @@ -3846,6 +4678,25 @@ float VoxelCaveMorphology::EvaluateTunnelCoreSDF( Pos, Tunnel.EndpointA, Tunnel.EndpointB, Tunnel.RadiusA, Tunnel.RadiusB)); } + }; + + if (bIndexedTunnels) + { + 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); + } } return MinSDF; @@ -3853,30 +4704,54 @@ float VoxelCaveMorphology::EvaluateTunnelCoreSDF( FTunnelCoreWorldEvaluation VoxelCaveMorphology::EvaluateTunnelCoreWorld( float WorldX, float WorldY, float WorldZ, - const FChunkSDFCache& Cache) + const FChunkSDFCache& Cache, + const FTunnelSupportFloorColumn* SupportColumn) { 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::IsEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelSupportFloorQueries); + } if (VoxelDensityProfile::IsEnabled()) { VoxelDensityProfile::AddCounter( VoxelDensityProfile::ECounter::TunnelCoreCandidates, - static_cast(Cache.Tunnels.Num())); + static_cast(bIndexedTunnels ? TunnelCount : Cache.Tunnels.Num())); } - for (const FCachedTunnel& Tunnel : Cache.Tunnels) + auto EvaluateTunnel = [&](int32 TunnelIdx) { + const FCachedTunnel& Tunnel = Cache.Tunnels[TunnelIdx]; const bool bHasWorldChain = Tunnel.WorldControlPoints.Num() >= 2 && Tunnel.WorldControlRadii.Num() == Tunnel.WorldControlPoints.Num(); const FVector& BoundCenter = bHasWorldChain ? Tunnel.WorldBoundCenter : Tunnel.BoundCenter; const float BoundRadiusSq = bHasWorldChain ? Tunnel.WorldBoundRadiusSq : Tunnel.BoundRadiusSq; - if (BoundRadiusSq > 0.0f - && FVector::DistSquared(Pos, BoundCenter) > BoundRadiusSq) + const float InfluenceRadius = bHasWorldChain + ? Tunnel.WorldInfluenceRadius : Tunnel.SDFInfluenceRadius; + const FVector& CenterlineMin = bHasWorldChain + ? Tunnel.WorldCenterlineMin : Tunnel.SDFCenterlineMin; + const FVector& CenterlineMax = bHasWorldChain + ? Tunnel.WorldCenterlineMax : Tunnel.SDFCenterlineMax; + if (InfluenceRadius > 0.0f + && VF_DistanceSquaredToAabb( + WorldX, WorldY, WorldZ, CenterlineMin, CenterlineMax) + > FMath::Square(InfluenceRadius)) { - continue; + return; + } + if (BoundRadiusSq > 0.0f + && VF_FloatDistSquared(WorldX, WorldY, WorldZ, BoundCenter) > BoundRadiusSq) + { + return; } if (VoxelDensityProfile::IsEnabled()) { @@ -3890,18 +4765,31 @@ FTunnelCoreWorldEvaluation VoxelCaveMorphology::EvaluateTunnelCoreWorld( ? Tunnel.WorldControlRadii : Tunnel.ControlRadii; if (bHasWorldChain) { + if (SupportColumn != nullptr && VoxelDensityProfile::IsEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelSupportFloorChecks); + } float FloorZ = 0.0f; float SupportRadius = 0.0f; - const bool bProjectedFloor = VoxelPassageGeometry::ProjectWalkableTunnelFloor( - ControlPoints, ControlRadii, Pos, FloorZ, SupportRadius); - const bool bWithinWorldBounds = Tunnel.WorldBoundRadiusSq <= 0.0f - || FVector::DistSquared(Pos, Tunnel.WorldBoundCenter) - <= Tunnel.WorldBoundRadiusSq; - if (bProjectedFloor && bWithinWorldBounds + float SupportMinZ = 0.0f; + float SupportMaxZ = 0.0f; + const bool bProjectedFloor = SupportColumn != nullptr + ? VoxelCaveMorphology::GetTunnelSupportFloorColumnBand( + TunnelIdx, *SupportColumn, FloorZ, SupportMinZ, SupportMaxZ) + : VoxelPassageGeometry::ProjectWalkableTunnelFloor( + ControlPoints, ControlRadii, Pos, FloorZ, SupportRadius); + // The first cull above already used WorldBoundCenter/WorldBoundRadiusSq for a + // world-chain tunnel. Repeating the same squared-distance test here only doubled + // arithmetic on every accepted tunnel; keep the projection and Z checks, which are + // the distinct support-floor predicates. + if (bProjectedFloor && WorldZ <= FloorZ + VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels - && WorldZ >= FloorZ - - VoxelPassageGeometry::LandingFloorThicknessVoxels) + && (SupportColumn == nullptr + ? WorldZ >= FloorZ + - VoxelPassageGeometry::LandingFloorThicknessVoxels + : WorldZ >= SupportMinZ && WorldZ <= SupportMaxZ)) { Result.bSupportFloor = true; } @@ -3911,7 +4799,7 @@ FTunnelCoreWorldEvaluation VoxelCaveMorphology::EvaluateTunnelCoreWorld( // The finite floor slab owns the bottom of this tunnel. Another intersecting // tunnel may still win below its own floor; the loop continues so that air // ownership remains the union of all valid tunnel cores. - continue; + return; } } if (ControlPoints.Num() >= 2 @@ -3952,6 +4840,25 @@ FTunnelCoreWorldEvaluation VoxelCaveMorphology::EvaluateTunnelCoreWorld( Pos, Tunnel.EndpointA, Tunnel.EndpointB, Tunnel.RadiusA, Tunnel.RadiusB)); } + }; + + if (bIndexedTunnels) + { + 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); + } } return Result; @@ -3970,18 +4877,33 @@ 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::IsEnabled()) { VoxelDensityProfile::AddCounter( VoxelDensityProfile::ECounter::TunnelCoreCandidates, - static_cast(Cache.Tunnels.Num())); + static_cast(bIndexedTunnels ? TunnelCount : Cache.Tunnels.Num())); } - for (const FCachedTunnel& Tunnel : Cache.Tunnels) + + auto TestTunnel = [&](int32 TunnelIdx) -> bool { + const FCachedTunnel& Tunnel = Cache.Tunnels[TunnelIdx]; if (Tunnel.WorldControlPoints.Num() < 2 || Tunnel.WorldControlRadii.Num() != Tunnel.WorldControlPoints.Num()) { - continue; + return false; + } + + if (Tunnel.WorldInfluenceRadius > 0.0f + && VF_DistanceSquaredToAabb( + WorldX, WorldY, WorldZ, + Tunnel.WorldCenterlineMin, Tunnel.WorldCenterlineMax) + > FMath::Square(Tunnel.WorldInfluenceRadius)) + { + return false; } float FloorZ = 0.0f; @@ -3995,18 +4917,188 @@ bool VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( || WorldZ > FloorZ + VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels || WorldZ < FloorZ - VoxelPassageGeometry::LandingFloorThicknessVoxels) { - continue; + return false; } if (Tunnel.WorldBoundRadiusSq <= 0.0f - || FVector::DistSquared(Pos, Tunnel.WorldBoundCenter) + || VF_FloatDistSquared(WorldX, WorldY, WorldZ, Tunnel.WorldBoundCenter) <= Tunnel.WorldBoundRadiusSq) { - if (VoxelDensityProfile::IsEnabled()) + return true; + } + return false; + }; + + bool bSupport = false; + if (bIndexedTunnels) + { + 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; + if (TestTunnel(TunnelIdx)) { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::TunnelCoreEvaluated); + bSupport = true; + if (VoxelDensityProfile::IsEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelCoreEvaluated); + } + break; } + } + } + else + { + for (int32 TunnelIdx = 0; TunnelIdx < Cache.Tunnels.Num(); ++TunnelIdx) + { + if (TestTunnel(TunnelIdx)) + { + bSupport = true; + if (VoxelDensityProfile::IsEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelCoreEvaluated); + } + break; + } + } + } + return bSupport; +} + +void VoxelCaveMorphology::BuildTunnelSupportFloorColumn( + float WorldX, float WorldY, + const FChunkSDFCache& Cache, + FTunnelSupportFloorColumn& OutColumn) +{ + OutColumn.Reset(); + + if (VoxelDensityProfile::IsEnabled()) + { + VoxelDensityProfile::AddCounter( + 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::IsEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelSupportColumnCandidates, + static_cast(CandidateIds.Num())); + } + + for (const int32 TunnelIdx : CandidateIds) + { + if (!Cache.Tunnels.IsValidIndex(TunnelIdx)) + { + continue; + } + const FCachedTunnel& Tunnel = Cache.Tunnels[TunnelIdx]; + if (Tunnel.WorldControlPoints.Num() < 2 + || Tunnel.WorldControlRadii.Num() != Tunnel.WorldControlPoints.Num()) + { + continue; + } + + const FVector ColumnPosition(WorldX, WorldY, 0.0f); + float FloorZ = 0.0f; + float SupportRadius = 0.0f; + if (!VoxelPassageGeometry::ProjectWalkableTunnelFloor( + Tunnel.WorldControlPoints, + Tunnel.WorldControlRadii, + ColumnPosition, + FloorZ, + SupportRadius)) + { + continue; + } + + float MinZ = FloorZ - VoxelPassageGeometry::LandingFloorThicknessVoxels; + float MaxZ = FloorZ + VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels; + + // Preserve the public point predicate's world bounding sphere exactly, while resolving + // its Z interval once. The XY projection already proves the support-radius condition. + if (Tunnel.WorldBoundRadiusSq > 0.0f) + { + const float DX = WorldX - static_cast(Tunnel.WorldBoundCenter.X); + const float DY = WorldY - static_cast(Tunnel.WorldBoundCenter.Y); + const float Remaining = Tunnel.WorldBoundRadiusSq - (DX * DX + DY * DY); + if (Remaining < 0.0f) + { + continue; + } + const float HalfZ = FMath::Sqrt(Remaining); + MinZ = FMath::Max(MinZ, static_cast(Tunnel.WorldBoundCenter.Z) - HalfZ); + MaxZ = FMath::Min(MaxZ, static_cast(Tunnel.WorldBoundCenter.Z) + HalfZ); + } + + if (MinZ <= MaxZ) + { + FTunnelSupportFloorInterval& Interval = + OutColumn.Intervals.Emplace_GetRef(); + Interval.TunnelIndex = TunnelIdx; + Interval.FloorZ = FloorZ; + Interval.MinZ = MinZ; + Interval.MaxZ = MaxZ; + } + } + + // Candidate IDs are deterministic and allow the world evaluator to find a + // tunnel's own band without re-running the XY projection. The public union + // query below simply tests every band; graph columns normally contain only a + // handful of candidates. + OutColumn.Intervals.Sort( + [](const FTunnelSupportFloorInterval& A, + const FTunnelSupportFloorInterval& B) + { + return A.TunnelIndex < B.TunnelIndex; + }); +} + +bool VoxelCaveMorphology::IsTunnelSupportFloorColumnZ( + float WorldZ, + const FTunnelSupportFloorColumn& Column) +{ + for (const FTunnelSupportFloorInterval& Interval : Column.Intervals) + { + if (WorldZ <= Interval.MaxZ) + { + if (WorldZ >= Interval.MinZ) + { + return true; + } + } + } + return false; +} + +bool VoxelCaveMorphology::GetTunnelSupportFloorColumnBand( + int32 TunnelIndex, + const FTunnelSupportFloorColumn& Column, + float& OutFloorZ, + float& OutMinZ, + float& OutMaxZ) +{ + for (const FTunnelSupportFloorInterval& Interval : Column.Intervals) + { + if (Interval.TunnelIndex == TunnelIndex) + { + OutFloorZ = Interval.FloorZ; + OutMinZ = Interval.MinZ; + OutMaxZ = Interval.MaxZ; return true; } } @@ -4027,12 +5119,19 @@ float VoxelCaveMorphology::EvaluateSDF( const FStrateGenerationParams& Params, uint32 Seed, int32 StrateIndex) { - const float RoomRadiusEnvelope = FMath::Max(Params.MinRoomRadius, Params.MaxRoomRadius); - const float TunnelRadiusEnvelope = FMath::Max(Params.TunnelMinRadius, Params.TunnelMaxRadius); + const float RoomRadiusEnvelope = FMath::Max( + FMath::Abs(Params.MinRoomRadius), FMath::Abs(Params.MaxRoomRadius)); + const float TunnelRadiusEnvelope = FMath::Max( + 0.5f, + FMath::Max(FMath::Abs(Params.TunnelMinRadius), FMath::Abs(Params.TunnelMaxRadius)) + * 1.18f); + const float FloorReliefEnvelope = FMath::Abs(Params.FloorReliefStrength) + * VOXEL_NOISE_SCALE * 1.5f; + const float BlendEnvelope = FMath::Max(Params.SDFBlendRadius, 0.0f); const float Margin = FMath::Max( - RoomRadiusEnvelope, - Params.TunnelWarpStrength + TunnelRadiusEnvelope - ) + Params.SDFBlendRadius; + RoomRadiusEnvelope + FloorReliefEnvelope, + FMath::Abs(Params.TunnelWarpStrength) + TunnelRadiusEnvelope + ) + BlendEnvelope; FChunkSDFCache TempCache; BuildChunkCache( diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 7540ade..516763f 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -90,21 +90,20 @@ namespace static constexpr float VF_PerlinAbsBound = 1.5f; /** La même enveloppe que `FractalNoise3D` de VoxelGenerator.cpp (qui y est `static`, donc - * invisible ici). Le détour par `FVector` est délibéré — voir l'en-tête de ce fichier. */ - FORCEINLINE float HFractal3D(const FVector& Position, int32 Octaves = 4, + * 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. */ + FORCEINLINE float HFractal3D(const FVector3f& Position, int32 Octaves = 4, float Lacunarity = 2.0f, float Persistence = 0.5f) { - return VoxelNoise::FBM((float)Position.X, (float)Position.Y, (float)Position.Z, + return VoxelNoise::FBM(Position.X, Position.Y, Position.Z, Octaves, Lacunarity, Persistence); } - /** Idem pour `RidgedNoise3D` (également `static` dans VoxelGenerator.cpp). Le bruit cellulaire, - * lui, n'a pas besoin d'enveloppe : son corps a migré dans VoxelCaveMorphology.h et s'appelle - * `VoxelNoise::Cellular3D`, avec la MÊME signature `const FVector&` que l'original. */ - FORCEINLINE float HRidged3D(const FVector& Position, int32 Octaves = 4, + /** Idem pour `RidgedNoise3D` (également `static` dans VoxelGenerator.cpp). */ + FORCEINLINE float HRidged3D(const FVector3f& Position, int32 Octaves = 4, float Lacunarity = 2.0f, float Persistence = 0.5f) { - return VoxelNoise::Ridged((float)Position.X, (float)Position.Y, (float)Position.Z, + return VoxelNoise::Ridged(Position.X, Position.Y, Position.Z, Octaves, Lacunarity, Persistence); } @@ -285,12 +284,12 @@ namespace void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override { const float Frequency = 1.0f / CellSize; - const FVector NoisePos( + const FVector3f NoisePos( WorldX * Frequency + VoxelHash::SeedOffset(SeedU, 3.17f), WorldY * Frequency + VoxelHash::SeedOffset(SeedU, 7.31f), WorldZ * Frequency + VoxelHash::SeedOffset(SeedU, 11.47f)); const float Ribbon = FMath::Clamp( - FMath::Abs(VoxelNoise::FBM((float)NoisePos.X, (float)NoisePos.Y, (float)NoisePos.Z, + FMath::Abs(VoxelNoise::FBM(NoisePos.X, NoisePos.Y, NoisePos.Z, VoxelGenLOD::Eff(3), 2.0f, 0.5f)) * VOXEL_NOISE_SCALE, 0.0f, 2.0f); @@ -343,12 +342,12 @@ namespace void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override { if (!(Strength > 0.0f)) { return; } - const FVector NoisePos( + const FVector3f NoisePos( WorldX * Frequency + VoxelHash::SeedOffset(SeedU, 13.2f), WorldY * Frequency + VoxelHash::SeedOffset(SeedU, 17.8f), WorldZ * Frequency + VoxelHash::SeedOffset(SeedU, 23.4f)); const float Noise01 = FMath::Clamp( - VoxelNoise::FBM((float)NoisePos.X, (float)NoisePos.Y, (float)NoisePos.Z, + VoxelNoise::FBM(NoisePos.X, NoisePos.Y, NoisePos.Z, VoxelGenLOD::Eff(Octaves), 2.0f, 0.5f) * 0.5f + 0.5f, 0.0f, 1.0f); const float Delta = Strength * Noise01; @@ -693,17 +692,16 @@ namespace } private: - // Les deux surfaces, transcrites au caractère près depuis GetSlabDensity — y compris le - // détour par FVector, qui est le même piège d'arrondi que dans FSdfRoughnessMod - // (float → double → float sous /fp:fast). Ne pas « simplifier ». + // The surface coordinates stay in float; the old FVector round-trip only preserved + // legacy rounding and is no longer part of the output contract. float SurfaceFloor(float WorldX, float WorldY) const { if (FloorRoughness <= 0.0f) { return FloorZ; } const float FF = FloorFrequency; - const FVector NoisePos(WorldX * FF + VoxelHash::SeedOffset(SeedU, 7.3f), + const FVector3f NoisePos(WorldX * FF + VoxelHash::SeedOffset(SeedU, 7.3f), WorldY * FF + VoxelHash::SeedOffset(SeedU, 11.1f), 0.0f); - const float N = VoxelNoise::FBM((float)NoisePos.X, (float)NoisePos.Y, (float)NoisePos.Z, + const float N = VoxelNoise::FBM(NoisePos.X, NoisePos.Y, NoisePos.Z, VoxelGenLOD::Eff(3), 2.0f, 0.5f) * VOXEL_NOISE_SCALE * FloorRoughness; return FloorZ + N; @@ -715,10 +713,10 @@ namespace if (CeilRoughness > 0.0f) { const float CF = CeilFrequency; - const FVector NoisePos(WorldX * CF + VoxelHash::SeedOffset(SeedU, 17.3f) + 1000.0f, + const FVector3f NoisePos(WorldX * CF + VoxelHash::SeedOffset(SeedU, 17.3f) + 1000.0f, WorldY * CF + VoxelHash::SeedOffset(SeedU, 19.7f) + 2000.0f, 3000.0f); - const float Raw = VoxelNoise::FBM((float)NoisePos.X, (float)NoisePos.Y, (float)NoisePos.Z, + const float Raw = VoxelNoise::FBM(NoisePos.X, NoisePos.Y, NoisePos.Z, VoxelGenLOD::Eff(3), 2.0f, 0.5f) * VOXEL_NOISE_SCALE; // abs() ⇒ les formations ne pendent QUE vers le bas. @@ -1199,7 +1197,7 @@ namespace const float f = P.OverhangFrequency; // Bruit de forme d'étagère [0,1] ; le terme en Z fait onduler la portée avec la hauteur // (déchiqueté, pas une lèvre lisse). - const float Ns = HFractal3D(FVector( + const float Ns = HFractal3D(FVector3f( WorldX * f + VoxelHash::SeedOffset(SeedU, 17.3f), WorldY * f + VoxelHash::SeedOffset(SeedU, 23.9f), WorldZ * f * P.OverhangZScale + VoxelHash::SeedOffset(SeedU, 5.1f)), 3) * 0.5f + 0.5f; // [0,1] @@ -1259,24 +1257,10 @@ namespace { if (Strength <= 0.0f || InOut.Sdf >= ApplyWithin) { return; } - // ⚠️ LE DÉTOUR PAR FVector EST DÉLIBÉRÉ — ne pas « simplifier ». - // L'original écrit `FractalNoise3D(FVector(WorldX * 0.12f, ...), Eff(3))`, et - // FractalNoise3D fait `VoxelNoise::FBM((float)Position.X, ...)`. FVector étant en - // DOUBLE (UE5), le produit flottant y transite par un double avant d'être re-arrondi - // en float. Passer directement des floats saute cet aller-retour, et sous /fp:fast - // les deux chemins ne s'arrondissent pas au même endroit : ~1 ULP d'écart sur le SDF, - // qui ressort en 1 ULP sur la densité finale. Reproduire le détour, c'est reproduire - // l'arrondi. VERIFIE par MazeEquivalence : le détour conserve l'identité binaire avec - // GetMazeDensity (0 écart sur 20,000 échantillons). Si cela change, le candidat suivant - // est une contraction FMA entre unités de compilation. - // - // THE FVector ROUND-TRIP IS DELIBERATE — do not "simplify" it. MazeEquivalence - // verifies the resulting path against GetMazeDensity (0 differences / 20,000 samples). - // The original goes float -> double (FVector is double in UE5) -> float; going straight - // through floats skips a rounding step, and under /fp:fast the two paths round in - // different places. Reproducing the detour reproduces the rounding. - const FVector NoisePos(WorldX * Frequency, WorldY * Frequency, WorldZ * Frequency); - InOut.Sdf += VoxelNoise::FBM((float)NoisePos.X, (float)NoisePos.Y, (float)NoisePos.Z, + // Keep hot noise coordinates in float. The old FVector round-trip only preserved + // legacy rounding; output compatibility with that field is intentionally abandoned. + const FVector3f NoisePos(WorldX * Frequency, WorldY * Frequency, WorldZ * Frequency); + InOut.Sdf += VoxelNoise::FBM(NoisePos.X, NoisePos.Y, NoisePos.Z, VoxelGenLOD::Eff(BaseOctaves), 2.0f, 0.5f) * VOXEL_NOISE_SCALE * Strength; } @@ -1300,9 +1284,8 @@ namespace } if (Strength <= 0.0f) { return; } - // The noise input is float after the deliberate FVector round-trip in Eval. If an - // authored frequency/box product overflows, Eval can publish a non-finite SDF and no - // finite interval is a proof. Unknown costs the skip and protects the geometry. + // If an authored frequency/box product overflows, Eval can publish a non-finite SDF + // and no finite interval is a proof. Unknown costs the skip and protects the geometry. const float MaxAbsCoord = FMath::Max3( FMath::Max(FMath::Abs((float)VoxelBox.Min.X), FMath::Abs((float)VoxelBox.Max.X)), FMath::Max(FMath::Abs((float)VoxelBox.Min.Y), FMath::Abs((float)VoxelBox.Max.Y)), @@ -2546,10 +2529,10 @@ namespace // partagé par toutes les îles proches — chacune échantillonne une autre partie du champ, // d'où des silhouettes distinctes. const float WarpAmp = (P.IslandMinRadius + P.IslandMaxRadius) * 0.5f * 0.35f; - const float WX = WorldX + HFractal3D(FVector(WorldX * 0.04f + VoxelHash::SeedOffset(Salt, 0.0007f), + const float WX = WorldX + HFractal3D(FVector3f(WorldX * 0.04f + VoxelHash::SeedOffset(Salt, 0.0007f), WorldY * 0.04f, WorldZ * 0.012f), VoxelGenLOD::Eff(3)) * VOXEL_NOISE_SCALE * WarpAmp; - const float WY = WorldY + HFractal3D(FVector(WorldX * 0.04f + 31.0f, WorldY * 0.04f + 7.0f, + const float WY = WorldY + HFractal3D(FVector3f(WorldX * 0.04f + 31.0f, WorldY * 0.04f + 7.0f, WorldZ * 0.012f), VoxelGenLOD::Eff(3)) * VOXEL_NOISE_SCALE * WarpAmp; @@ -2931,8 +2914,112 @@ namespace /** The warped SDF-space position belonging to the current voxel. */ FVector LastWarpedPosition = FVector::ZeroVector; - /** The unwarped position belonging to the current voxel. */ - FVector LastWorldPosition = FVector::ZeroVector; + /** The unwarped position belonging to the current voxel. */ + FVector LastWorldPosition = FVector::ZeroVector; + + // Support-floor projection depends only on XY for one immutable SDF cache. The + // mesher traverses Z outside X/Y, so keep a small worker-local LRU of XY boxes rather + // than only the immediately previous column; each integer column is then projected + // once and both structural posts reuse its interval for every Z sample. + struct FSupportColumnBox + { + static constexpr int32 Halo = CHUNK_SIZE + 8; + static constexpr int32 Dim = 2 * Halo + 1; + + const FChunkSDFCache* Cache = nullptr; + int32 BaseX = 0; + int32 BaseY = 0; + uint32 LastUse = 0; + bool bValid = false; + TArray SlotToEntry; + TArray Columns; + + void Clear() + { + Cache = nullptr; + bValid = false; + LastUse = 0; + SlotToEntry.Reset(); + Columns.Reset(); + } + + FTunnelSupportFloorColumn* AcquireColumn( + int32 X, int32 Y, const FChunkSDFCache* InCache, + uint32 InUse, bool& bOutNew) + { + bOutNew = false; + if (!bValid || Cache != InCache + || X < BaseX || X >= BaseX + Dim + || Y < BaseY || Y >= BaseY + Dim) + { + Cache = InCache; + BaseX = X - Halo; + BaseY = Y - Halo; + LastUse = InUse; + bValid = true; + SlotToEntry.Init(INDEX_NONE, Dim * Dim); + Columns.Reset(); + } + else + { + LastUse = InUse; + } + + const int32 Slot = (Y - BaseY) * Dim + (X - BaseX); + int32& EntryIndex = SlotToEntry[Slot]; + if (EntryIndex == INDEX_NONE) + { + EntryIndex = Columns.AddDefaulted(); + bOutNew = true; + } + return &Columns[EntryIndex]; + } + }; + + struct FSupportColumnCache + { + static constexpr int32 NumBoxes = 4; + FSupportColumnBox Boxes[NumBoxes]; + uint32 Clock = 0; + + void Invalidate() + { + for (FSupportColumnBox& Box : Boxes) + { + Box.Clear(); + } + Clock = 0; + } + + FSupportColumnBox& AcquireBox( + int32 X, int32 Y, const FChunkSDFCache* Cache) + { + ++Clock; + for (FSupportColumnBox& Box : Boxes) + { + if (Box.bValid && Box.Cache == Cache + && X >= Box.BaseX && X < Box.BaseX + FSupportColumnBox::Dim + && Y >= Box.BaseY && Y < Box.BaseY + FSupportColumnBox::Dim) + { + Box.LastUse = Clock; + return Box; + } + } + + FSupportColumnBox* Victim = &Boxes[0]; + for (FSupportColumnBox& Box : Boxes) + { + if (!Box.bValid || Box.LastUse < Victim->LastUse) + { + Victim = &Box; + } + } + Victim->Clear(); + return *Victim; + } + } SupportColumns; + + FTunnelSupportFloorColumn FractionalSupportColumn; /** ÉTAPE C1 — les params de la strate avec l'op de CETTE salle appliqué par-dessus. * Mémo par voxel : invalidé au début de chaque `Eval`, calculé au PREMIER modificateur @@ -3081,6 +3168,7 @@ namespace { S.Cache = FChunkSDFCache(); S.ActiveCache = &S.Cache; + S.SupportColumns.Invalidate(); InOut.Sdf = FLT_MAX; return; } @@ -3088,6 +3176,7 @@ namespace if (!(P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f)) { S.ActiveCache = &S.Cache; + S.SupportColumns.Invalidate(); return; // Sdf reste FLT_MAX } @@ -3106,15 +3195,15 @@ namespace { const float WF = P.CaveWarpFrequency; const float WS = P.CaveWarpStrength; - WarpedX += VoxelNoise::Perlin3D(FVector( + WarpedX += VoxelNoise::Perlin3D(FVector3f( WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.37f), WorldY * WF + 1.3f, EffectiveZ * WF + 5.7f)) * VOXEL_NOISE_SCALE * WS; - WarpedY += VoxelNoise::Perlin3D(FVector( + WarpedY += VoxelNoise::Perlin3D(FVector3f( WorldX * WF + 7.1f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.59f), EffectiveZ * WF + 2.3f)) * VOXEL_NOISE_SCALE * WS; - WarpedZ += VoxelNoise::Perlin3D(FVector( + WarpedZ += VoxelNoise::Perlin3D(FVector3f( WorldX * WF + 11.3f, WorldY * WF + 9.7f, EffectiveZ * WF + VoxelHash::SeedOffset(SeedU, 0.41f))) * VOXEL_NOISE_SCALE * WS; @@ -3219,7 +3308,8 @@ namespace SharedCache->RegionMinX = RegionMinX; SharedCache->RegionMinY = RegionMinY; SharedCache->RegionSize = RegionSize; - const float Expansion = P.CaveWarpStrength + 2.0f; + const float Expansion = FMath::Abs(P.CaveWarpStrength) + * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + 2.0f; VoxelCaveMorphology::BuildChunkCache( SharedCache->Cache, (float)RegionMinX - Expansion, @@ -3228,6 +3318,8 @@ namespace (float)(RegionMinY + RegionSize) + Expansion, P, SeedU, StrateIdx, TerrainOps); SharedCache->bValid = true; + VoxelDensityOps::ReportWorkerRoomGraphCacheFootprint(); + S.SupportColumns.Invalidate(); } S.ActiveCache = &SharedCache->Cache; @@ -3749,9 +3841,48 @@ namespace bool IsTunnelSupportFloor() const { - const FVector& Position = State().LastWorldPosition; - return VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( - Position.X, Position.Y, Position.Z, GetCache()); + FState& S = State(); + const FVector& Position = S.LastWorldPosition; + const FChunkSDFCache& Cache = GetCache(); + if (VoxelDensityProfile::IsEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelSupportFloorQueries); + } + + // Density-grid samples are integer XY. Keep fractional callers exact by using a + // scratch column; they are uncommon gradient/debug queries and must not alias a + // neighbouring integer column. + const bool bIntegerXY = + Position.X == FMath::FloorToFloat(static_cast(Position.X)) + && Position.Y == FMath::FloorToFloat(static_cast(Position.Y)); + if (bIntegerXY) + { + const int32 IX = FMath::FloorToInt(static_cast(Position.X)); + const int32 IY = FMath::FloorToInt(static_cast(Position.Y)); + FState::FSupportColumnBox& Box = S.SupportColumns.AcquireBox(IX, IY, &Cache); + bool bNewColumn = false; + FTunnelSupportFloorColumn* Column = Box.AcquireColumn( + IX, IY, &Cache, S.SupportColumns.Clock, bNewColumn); + if (bNewColumn) + { + VoxelCaveMorphology::BuildTunnelSupportFloorColumn( + static_cast(Position.X), + static_cast(Position.Y), + Cache, + *Column); + } + return VoxelCaveMorphology::IsTunnelSupportFloorColumnZ( + static_cast(Position.Z), *Column); + } + + VoxelCaveMorphology::BuildTunnelSupportFloorColumn( + static_cast(Position.X), + static_cast(Position.Y), + Cache, + S.FractionalSupportColumn); + return VoxelCaveMorphology::IsTunnelSupportFloorColumnZ( + static_cast(Position.Z), S.FractionalSupportColumn); } private: @@ -3947,15 +4078,14 @@ namespace const float RF = P.RoughnessFrequency; - // ⚠️ LE DÉTOUR PAR FVector EST DÉLIBÉRÉ (même raison que dans FSdfRoughnessMod) : - // FVector est en DOUBLE, donc chaque produit transite par un double avant d'être - // re-arrondi en float à l'appel du bruit. Sauter l'aller-retour change l'arrondi. - FVector MainPos( + // Keep both hot noise-coordinate chains in float. The old FVector round-trip only + // preserved legacy rounding; output compatibility with that field is abandoned. + FVector3f MainPos( WorldX * RF + VoxelHash::SeedOffset(SeedU, 11.3f), WorldY * RF + VoxelHash::SeedOffset(SeedU, 13.7f), EffectiveZ * RF + VoxelHash::SeedOffset(SeedU, 17.1f) ); - FVector FinePos( + FVector3f FinePos( WorldX * RF * 3.0f + VoxelHash::SeedOffset(SeedU, 19.1f) + 2000.0f, WorldY * RF * 3.0f + VoxelHash::SeedOffset(SeedU, 23.7f) + 2500.0f, EffectiveZ * RF * 3.0f + VoxelHash::SeedOffset(SeedU, 29.3f) + 3000.0f @@ -3969,25 +4099,25 @@ namespace const float WF = P.DomainWarpFrequency; const float WS = P.DomainWarpStrength; - const float WarpX = VoxelNoise::Perlin3D(FVector( + const float WarpX = VoxelNoise::Perlin3D(FVector3f( WorldX * WF + VoxelHash::SeedOffset(SeedU, 5.2f), WorldY * WF + VoxelHash::SeedOffset(SeedU, 1.3f), EffectiveZ * WF + VoxelHash::SeedOffset(SeedU, 9.7f) )) * VOXEL_NOISE_SCALE * WS; - const float WarpY = VoxelNoise::Perlin3D(FVector( + const float WarpY = VoxelNoise::Perlin3D(FVector3f( WorldX * WF + 100.0f + VoxelHash::SeedOffset(SeedU, 7.7f), WorldY * WF + 200.0f + VoxelHash::SeedOffset(SeedU, 3.1f), EffectiveZ * WF + 300.0f )) * VOXEL_NOISE_SCALE * WS; - const float WarpZ = VoxelNoise::Perlin3D(FVector( + const float WarpZ = VoxelNoise::Perlin3D(FVector3f( WorldX * WF + 400.0f, WorldY * WF + 500.0f + VoxelHash::SeedOffset(SeedU, 11.9f), EffectiveZ * WF + 600.0f + VoxelHash::SeedOffset(SeedU, 13.3f) )) * VOXEL_NOISE_SCALE * WS; - const FVector WarpOffset(WarpX, WarpY, WarpZ); + const FVector3f WarpOffset(WarpX, WarpY, WarpZ); MainPos += WarpOffset; FinePos += WarpOffset; } @@ -4161,7 +4291,7 @@ namespace float NoisedZ = WorldZ; if (LP.TerraceNoiseDisplacement > 0.0f) { - const float DispNoise = HFractal3D(FVector( + const float DispNoise = HFractal3D(FVector3f( WorldX * 0.04f + VoxelHash::SeedOffset(SeedU, 31.1f), WorldY * 0.04f + VoxelHash::SeedOffset(SeedU, 37.3f), WorldZ * 0.02f + VoxelHash::SeedOffset(SeedU, 41.7f) @@ -4432,7 +4562,7 @@ namespace ? (WorldZ / P.VerticalScale) : WorldZ; // Fréquence en Z à 0.15× celle de XY ⇒ les motifs s'étirent horizontalement. - const float OverhangNoise = HFractal3D(FVector( + const float OverhangNoise = HFractal3D(FVector3f( WorldX * LP.OverhangFrequency + VoxelHash::SeedOffset(SeedU, 53.1f), WorldY * LP.OverhangFrequency + VoxelHash::SeedOffset(SeedU, 59.3f), EffectiveZ * LP.OverhangFrequency * 0.15f + VoxelHash::SeedOffset(SeedU, 61.7f) @@ -4533,7 +4663,7 @@ namespace const float EffectiveZ = (P.VerticalScale != 1.0f && P.VerticalScale > 0.0f) ? (WorldZ / P.VerticalScale) : WorldZ; - const float VertGrad = VoxelNoise::Perlin3D(FVector( + const float VertGrad = VoxelNoise::Perlin3D(FVector3f( WorldX * 0.05f + VoxelHash::SeedOffset(SeedU, 71.3f), WorldY * 0.05f + VoxelHash::SeedOffset(SeedU, 73.7f), EffectiveZ * 0.15f + VoxelHash::SeedOffset(SeedU, 79.1f) // 3× plus vite en Z @@ -4610,7 +4740,7 @@ namespace ? (WorldZ / P.VerticalScale) : WorldZ; const float SF = LP.ScallopFrequency; - const float ScallopNoise = VoxelNoise::Cellular3D(FVector( + const float ScallopNoise = VoxelNoise::Cellular3D(FVector3f( WorldX * SF + VoxelHash::SeedOffset(SeedU, 83.1f), WorldY * SF + VoxelHash::SeedOffset(SeedU, 89.3f), EffectiveZ * SF + VoxelHash::SeedOffset(SeedU, 97.7f) @@ -4710,45 +4840,21 @@ namespace if (!Cache.Rooms.IsValidIndex(NearestRoomIdx)) { return; } const FCachedRoom& Room = Cache.Rooms[NearestRoomIdx]; - const int32 MaxArches = 3; const FVector VoxPos(WorldX, WorldY, WorldZ); - for (int32 i = 0; i < MaxArches; i++) + for (int32 i = 0; i < 3; i++) { - const uint32 AH = VoxelHash::Mix(Room.Hash ^ (0xA4C400u + (uint32)i * 7369u)); - - if (VoxelHash::ToFloat01(AH) > LP.ArchDensity) { continue; } - - const uint32 AH2 = VoxelHash::Mix(AH ^ 0xA4C4u); - const float ArcCX = Room.Center.X + VoxelHash::ToFloatSigned(AH2) * Room.RadiusXY * 0.3f; - const float ArcCY = Room.Center.Y - + VoxelHash::ToFloatSigned(VoxelHash::Mix(AH2)) * Room.RadiusXY * 0.3f; - - const uint32 AH3 = VoxelHash::Mix(AH2 ^ 0xB41Du); - const float ArcCZ = Room.Center.Z + VoxelHash::ToFloatSigned(AH3) * Room.RadiusZ * 0.4f; - - const uint32 AH4 = VoxelHash::Mix(AH3 ^ 0xCAFEu); - const float Angle = VoxelHash::ToFloat01(AH4) * PI; - const float HalfSpan = Room.RadiusXY - * (0.5f + VoxelHash::ToFloat01(VoxelHash::Mix(AH4)) * 0.35f); - - const float CosA = FMath::Cos(Angle); - const float SinA = FMath::Sin(Angle); - const FVector ArchA(ArcCX - CosA * HalfSpan, ArcCY - SinA * HalfSpan, ArcCZ); - const FVector ArchB(ArcCX + CosA * HalfSpan, ArcCY + SinA * HalfSpan, ArcCZ); - - const uint32 AH5 = VoxelHash::Mix(AH4 ^ 0xF00Du); - const float ArchRadius = FMath::Lerp(LP.ArchMinRadius, LP.ArchMaxRadius, - VoxelHash::ToFloat01(AH5)); - - const float ArchSDF = VoxelSDF::Capsule(VoxPos, ArchA, ArchB, ArchRadius); + const FCachedArch& Arch = Room.Arches[i]; + if (!Arch.bActive) { continue; } + const float ArchSDF = VoxelSDF::Capsule( + VoxPos, Arch.EndpointA, Arch.EndpointB, Arch.Radius); const float ArchBlend = 2.0f; if (ArchSDF < ArchBlend) { float Fill = FMath::Clamp((ArchBlend - ArchSDF) / (ArchBlend * 2.0f), 0.0f, 1.0f); Fill = SmoothStep01(Fill); - InOut.Density += Fill * LP.BaseDensity * 1.5f; + InOut.Density += Fill * Arch.BaseDensity * 1.5f; } } } @@ -4997,45 +5103,22 @@ namespace if (!Cache.Rooms.IsValidIndex(NearestRoomIdx)) { return; } // cf. FCaveArchMod const FCachedRoom& Room = Cache.Rooms[NearestRoomIdx]; - const float Spread = 0.85f; - const int32 MaxPinches = 3; - - for (int32 i = 0; i < MaxPinches; i++) + for (int32 i = 0; i < 3; i++) { - const uint32 PnH = VoxelHash::Mix(Room.Hash ^ (0xF1C400u + (uint32)i * 5417u)); + const FCachedPinch& Pinch = Room.Pinches[i]; + if (!Pinch.bActive) { continue; } + const float DXPn = WorldX - Pinch.CenterX; + const float DYPn = WorldY - Pinch.CenterY; + const float DZPn = WorldZ - Pinch.CenterZ; - if (VoxelHash::ToFloat01(PnH) > LP.PinchDensity) { continue; } + if (FMath::Abs(DXPn) + FMath::Abs(DYPn) + FMath::Abs(DZPn) > Pinch.MaxExtent) { continue; } - const uint32 PnH2 = VoxelHash::Mix(PnH ^ 0xF1C4u); - const float PnX = Room.Center.X + VoxelHash::ToFloatSigned(PnH2) * Room.RadiusXY * Spread; - const float PnY = Room.Center.Y - + VoxelHash::ToFloatSigned(VoxelHash::Mix(PnH2)) * Room.RadiusXY * Spread; + const float Along = DXPn * Pinch.CosAngle + DYPn * Pinch.SinAngle; + const float Across = -DXPn * Pinch.SinAngle + DYPn * Pinch.CosAngle; - const uint32 PnH3 = VoxelHash::Mix(PnH2 ^ 0x5432u); - const float PnZ = Room.Center.Z + VoxelHash::ToFloatSigned(PnH3) * Room.RadiusZ * 0.5f; - - const uint32 PnH4 = VoxelHash::Mix(PnH3 ^ 0x9A3Bu); - const float PnAngle = VoxelHash::ToFloat01(PnH4) * PI; - const float CosPN = FMath::Cos(PnAngle); - const float SinPN = FMath::Sin(PnAngle); - - const float DXPn = WorldX - PnX; - const float DYPn = WorldY - PnY; - const float DZPn = WorldZ - PnZ; - - const float MaxExtent = FMath::Max(LP.PinchLength, LP.PinchStrength) + 5.0f; - if (FMath::Abs(DXPn) + FMath::Abs(DYPn) + FMath::Abs(DZPn) > MaxExtent) { continue; } - - const float Along = DXPn * CosPN + DYPn * SinPN; - const float Across = -DXPn * SinPN + DYPn * CosPN; - - const float HalfLength = LP.PinchLength * 0.5f; - const float HalfNarrow = LP.PinchStrength; - const float HalfVertical = LP.PinchStrength * 1.5f; - - const float NAlong = Along / HalfLength; - const float NAcross = Across / HalfNarrow; - const float NUp = DZPn / HalfVertical; + const float NAlong = Along / Pinch.HalfLength; + const float NAcross = Across / Pinch.HalfNarrow; + const float NUp = DZPn / Pinch.HalfVertical; const float EllipDist = NAlong * NAlong + NAcross * NAcross + NUp * NUp; if (EllipDist < 1.0f) @@ -5044,7 +5127,7 @@ namespace Fill = SmoothStep01(Fill); const float AxisDist = FMath::Sqrt(NAcross * NAcross + NUp * NUp); const float SideFactor = FMath::Clamp(AxisDist * 2.0f, 0.0f, 1.0f); - InOut.Density += Fill * SideFactor * LP.BaseDensity * 1.5f; + InOut.Density += Fill * SideFactor * Pinch.BaseDensity * 1.5f; } } } @@ -5192,7 +5275,7 @@ namespace const float WormZFreq = P.WormFrequency * P.WormHorizontalBias; - const float N1 = FMath::Abs(VoxelNoise::Perlin3D(FVector( + const float N1 = FMath::Abs(VoxelNoise::Perlin3D(FVector3f( WorldX * P.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.0f), WorldY * P.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.7f), EffectiveZ * WormZFreq + VoxelHash::SeedOffset(SeedU, 2.3f) @@ -5202,7 +5285,7 @@ namespace // second Perlin (le cas courant ; sortie bit-identique). Transcrit tel quel. if (N1 >= P.WormThreshold) { return; } - const float N2 = FMath::Abs(VoxelNoise::Perlin3D(FVector( + const float N2 = FMath::Abs(VoxelNoise::Perlin3D(FVector3f( WorldX * P.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.0f) + 137.0f, WorldY * P.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.7f) + 259.0f, EffectiveZ * WormZFreq + VoxelHash::SeedOffset(SeedU, 2.3f) + 431.0f @@ -5440,10 +5523,61 @@ VoxelDensityOps::FRoomBoxDiagnostic VoxelDensityOps::GetLastRoomBoxDiagnostic() return D; } +void VoxelDensityOps::ReportWorkerRoomGraphCacheFootprint() +{ + if (!VoxelDensityProfile::IsEnabled()) + { + return; + } + + uint64 DynamicBytes = 0; + uint64 EntryBytes = 0; + uint64 LargestEntryBytes = 0; + uint64 ValidEntries = 0; + for (int32 Index = 0; Index < RoomGraphCacheSlotCount; ++Index) + { + const FRoomGraphCacheEntry& Entry = GRoomGraphCache[Index]; + if (!Entry.bValid) + { + continue; + } + + ++ValidEntries; + const uint64 EntryDynamicBytes = static_cast(Entry.Cache.GetAllocatedSize()); + const uint64 FullEntryBytes = static_cast(sizeof(FRoomGraphCacheEntry)) + + EntryDynamicBytes; + DynamicBytes += EntryDynamicBytes; + EntryBytes += FullEntryBytes; + LargestEntryBytes = FMath::Max(LargestEntryBytes, FullEntryBytes); + } + + VoxelDensityProfile::SetWorkerRoomGraphCacheFootprint( + RoomGraphCacheSlotCount, + ValidEntries, + static_cast(sizeof(GRoomGraphCache)), + DynamicBytes, + EntryBytes, + LargestEntryBytes, + ValidEntries); +} + //============================================================================= // FVoxelOpStack //============================================================================= +SIZE_T FVoxelOpStack::GetAllocatedSize() const +{ + SIZE_T Bytes = static_cast(Ops.GetAllocatedSize()); + for (const FOpEntry& Entry : Ops) + { + if (Entry.Op.IsValid()) + { + Bytes += FMemory::GetAllocSize(const_cast(Entry.Op.Get())); + } + } + return Bytes; +} + 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 727df71..ea78184 100644 --- a/Source/VoxelForge/Private/VoxelDensityProfile.cpp +++ b/Source/VoxelForge/Private/VoxelDensityProfile.cpp @@ -5,9 +5,57 @@ namespace VoxelDensityProfile namespace { std::atomic GEnabled(false); - std::atomic GCalls[BucketCount]; - std::atomic GCycles[BucketCount]; - std::atomic GCounters[CounterCount]; + + // The old implementation performed a contended atomic fetch_add for every timer and + // counter. A TunnelNetwork sample can close twenty-plus ranges and add several + // candidate counters, so profiling changed the workload it was supposed to measure. + // Keep one accumulator per worker and only aggregate at Snapshot(). Commandlet resets + // happen between completed jobs, so the deliberately light-weight list is sufficient. + struct FThreadState + { + uint64 Calls[BucketCount]{}; + uint64 Cycles[BucketCount]{}; + uint64 Counters[CounterCount]{}; + + uint64 TunnelCacheCapacityEntries = 0; + uint64 TunnelCacheValidEntries = 0; + uint64 TunnelCacheStaticBytes = 0; + uint64 TunnelCacheDynamicBytes = 0; + uint64 TunnelCacheEntryBytes = 0; + uint64 TunnelCacheLargestEntryBytes = 0; + uint64 TunnelCacheLargestWorkerValidEntries = 0; + + uint64 RoomGraphCacheCapacityEntries = 0; + uint64 RoomGraphCacheValidEntries = 0; + uint64 RoomGraphCacheStaticBytes = 0; + uint64 RoomGraphCacheDynamicBytes = 0; + uint64 RoomGraphCacheEntryBytes = 0; + uint64 RoomGraphCacheLargestEntryBytes = 0; + uint64 RoomGraphCacheLargestWorkerValidEntries = 0; + + std::atomic Next{nullptr}; + }; + + std::atomic GThreadStates{nullptr}; + thread_local FThreadState* GThreadState = nullptr; + + FThreadState& GetThreadState() + { + if (GThreadState == nullptr) + { + GThreadState = new FThreadState(); + FThreadState* Head = GThreadStates.load(std::memory_order_relaxed); + do + { + GThreadState->Next.store(Head, std::memory_order_relaxed); + } + while (!GThreadStates.compare_exchange_weak( + Head, GThreadState, + std::memory_order_release, + std::memory_order_relaxed)); + } + return *GThreadState; + } int32 ToIndex(EBucket Bucket) { @@ -30,28 +78,78 @@ namespace VoxelDensityProfile void Reset() { - for (int32 Index = 0; Index < BucketCount; ++Index) + for (FThreadState* State = GThreadStates.load(std::memory_order_acquire); + State != nullptr; + State = State->Next.load(std::memory_order_acquire)) { - GCalls[Index].store(0, std::memory_order_relaxed); - GCycles[Index].store(0, std::memory_order_relaxed); - } - for (int32 Index = 0; Index < CounterCount; ++Index) - { - GCounters[Index].store(0, std::memory_order_relaxed); + for (int32 Index = 0; Index < BucketCount; ++Index) + { + State->Calls[Index] = 0; + State->Cycles[Index] = 0; + } + for (int32 Index = 0; Index < CounterCount; ++Index) + { + State->Counters[Index] = 0; + } } } FSnapshot Snapshot() { FSnapshot Result; - for (int32 Index = 0; Index < BucketCount; ++Index) + for (FThreadState* State = GThreadStates.load(std::memory_order_acquire); + State != nullptr; + State = State->Next.load(std::memory_order_acquire)) { - Result.Calls[Index] = GCalls[Index].load(std::memory_order_relaxed); - Result.Cycles[Index] = GCycles[Index].load(std::memory_order_relaxed); - } - for (int32 Index = 0; Index < CounterCount; ++Index) - { - Result.Counters[Index] = GCounters[Index].load(std::memory_order_relaxed); + for (int32 Index = 0; Index < BucketCount; ++Index) + { + Result.Calls[Index] += State->Calls[Index]; + Result.Cycles[Index] += State->Cycles[Index]; + } + for (int32 Index = 0; Index < CounterCount; ++Index) + { + Result.Counters[Index] += State->Counters[Index]; + } + + const uint64 TunnelWorkerBytes = State->TunnelCacheStaticBytes + + State->TunnelCacheDynamicBytes; + if (State->TunnelCacheCapacityEntries > 0) + { + ++Result.TunnelCacheWorkers; + Result.TunnelCacheCapacityEntries += State->TunnelCacheCapacityEntries; + Result.TunnelCacheValidEntries += State->TunnelCacheValidEntries; + Result.TunnelCacheStaticBytes += State->TunnelCacheStaticBytes; + Result.TunnelCacheDynamicBytes += State->TunnelCacheDynamicBytes; + Result.TunnelCacheEntryBytes += State->TunnelCacheEntryBytes; + Result.TunnelCacheLargestEntryBytes = FMath::Max( + Result.TunnelCacheLargestEntryBytes, + State->TunnelCacheLargestEntryBytes); + Result.TunnelCacheLargestWorkerValidEntries = FMath::Max( + Result.TunnelCacheLargestWorkerValidEntries, + State->TunnelCacheValidEntries); + Result.TunnelCacheLargestWorkerBytes = FMath::Max( + Result.TunnelCacheLargestWorkerBytes, TunnelWorkerBytes); + } + + const uint64 RoomGraphWorkerBytes = State->RoomGraphCacheStaticBytes + + State->RoomGraphCacheDynamicBytes; + if (State->RoomGraphCacheCapacityEntries > 0) + { + ++Result.RoomGraphCacheWorkers; + Result.RoomGraphCacheCapacityEntries += State->RoomGraphCacheCapacityEntries; + Result.RoomGraphCacheValidEntries += State->RoomGraphCacheValidEntries; + Result.RoomGraphCacheStaticBytes += State->RoomGraphCacheStaticBytes; + Result.RoomGraphCacheDynamicBytes += State->RoomGraphCacheDynamicBytes; + Result.RoomGraphCacheEntryBytes += State->RoomGraphCacheEntryBytes; + Result.RoomGraphCacheLargestEntryBytes = FMath::Max( + Result.RoomGraphCacheLargestEntryBytes, + State->RoomGraphCacheLargestEntryBytes); + Result.RoomGraphCacheLargestWorkerValidEntries = FMath::Max( + Result.RoomGraphCacheLargestWorkerValidEntries, + State->RoomGraphCacheValidEntries); + Result.RoomGraphCacheLargestWorkerBytes = FMath::Max( + Result.RoomGraphCacheLargestWorkerBytes, RoomGraphWorkerBytes); + } } return Result; } @@ -61,10 +159,52 @@ namespace VoxelDensityProfile const int32 Index = static_cast(Counter); if (Index >= 0 && Index < CounterCount) { - GCounters[Index].fetch_add(Amount, std::memory_order_relaxed); + GetThreadState().Counters[Index] += Amount; } } + void AddMeasurement(EBucket Bucket, uint64 Cycles, uint64 Calls) + { + const int32 Index = ToIndex(Bucket); + FThreadState& State = GetThreadState(); + State.Calls[Index] += Calls; + State.Cycles[Index] += Cycles; + } + + void SetWorkerTunnelCacheFootprint( + uint64 CapacityEntries, uint64 ValidEntries, + uint64 StaticBytes, uint64 DynamicBytes, + uint64 EntryBytes, uint64 LargestEntryBytes, + uint64 LargestWorkerValidEntries) + { + if (!IsEnabled()) { return; } + FThreadState& State = GetThreadState(); + State.TunnelCacheCapacityEntries = CapacityEntries; + State.TunnelCacheValidEntries = ValidEntries; + State.TunnelCacheStaticBytes = StaticBytes; + State.TunnelCacheDynamicBytes = DynamicBytes; + State.TunnelCacheEntryBytes = EntryBytes; + State.TunnelCacheLargestEntryBytes = LargestEntryBytes; + State.TunnelCacheLargestWorkerValidEntries = LargestWorkerValidEntries; + } + + void SetWorkerRoomGraphCacheFootprint( + uint64 CapacityEntries, uint64 ValidEntries, + uint64 StaticBytes, uint64 DynamicBytes, + uint64 EntryBytes, uint64 LargestEntryBytes, + uint64 LargestWorkerValidEntries) + { + if (!IsEnabled()) { return; } + FThreadState& State = GetThreadState(); + State.RoomGraphCacheCapacityEntries = CapacityEntries; + State.RoomGraphCacheValidEntries = ValidEntries; + State.RoomGraphCacheStaticBytes = StaticBytes; + State.RoomGraphCacheDynamicBytes = DynamicBytes; + State.RoomGraphCacheEntryBytes = EntryBytes; + State.RoomGraphCacheLargestEntryBytes = LargestEntryBytes; + State.RoomGraphCacheLargestWorkerValidEntries = LargestWorkerValidEntries; + } + const TCHAR* CounterName(ECounter Counter) { switch (Counter) @@ -79,6 +219,10 @@ namespace VoxelDensityProfile case ECounter::CaveTunnelEvaluated:return TEXT("CaveTunnelEvaluated"); case ECounter::TunnelCoreCandidates:return TEXT("TunnelCoreCandidates"); case ECounter::TunnelCoreEvaluated:return TEXT("TunnelCoreEvaluated"); + case ECounter::TunnelSupportColumnBuilds:return TEXT("TunnelSupportColumnBuilds"); + case ECounter::TunnelSupportColumnCandidates:return TEXT("TunnelSupportColumnCandidates"); + case ECounter::TunnelSupportFloorQueries:return TEXT("TunnelSupportFloorQueries"); + case ECounter::TunnelSupportFloorChecks:return TEXT("TunnelSupportFloorChecks"); case ECounter::PassageCandidates: return TEXT("PassageCandidates"); case ECounter::PassageEvaluated: return TEXT("PassageEvaluated"); case ECounter::Count: break; @@ -130,6 +274,20 @@ namespace VoxelDensityProfile { TEXT("CaveTunnelFloorOp"), EBucket::CaveTunnelFloorOp }, { TEXT("CaveTunnelAirOp"), EBucket::CaveTunnelAirOp }, { TEXT("XYEdgeSealOp"), EBucket::XYEdgeSealOp }, + { TEXT("DensityPrologue"), EBucket::DensityPrologue }, + { TEXT("DensityCore"), EBucket::DensityCore }, + { TEXT("DensityDisturbances"), EBucket::DensityDisturbances }, + { TEXT("DensityStructuralPosts"), EBucket::DensityStructuralPosts }, + { TEXT("DensityBoundarySeal"), EBucket::DensityBoundarySeal }, + { TEXT("DensityDiffLayer"), EBucket::DensityDiffLayer }, + { TEXT("DensityTail"), EBucket::DensityTail }, + { TEXT("MesherGenerateMesh"), EBucket::MesherGenerateMesh }, + { TEXT("MesherDensityGrid"), EBucket::MesherDensityGrid }, + { TEXT("MesherCellClassification"), EBucket::MesherCellClassification }, + { TEXT("MesherGradientNormals"), EBucket::MesherGradientNormals }, + { TEXT("MesherVertexInterpolation"), EBucket::MesherVertexInterpolation }, + { TEXT("MesherStreamBuilding"), EBucket::MesherStreamBuilding }, + { TEXT("MesherOther"), EBucket::MesherOther }, }; for (const FNameBucket& Entry : Names) { @@ -179,17 +337,34 @@ namespace VoxelDensityProfile case EBucket::CaveTunnelAirOp: return TEXT("CaveTunnelAirOp"); case EBucket::XYEdgeSealOp: return TEXT("XYEdgeSealOp"); case EBucket::OtherOp: return TEXT("OtherOp"); + case EBucket::DensityPrologue: return TEXT("DensityPrologue"); + case EBucket::DensityCore: return TEXT("DensityCore"); + case EBucket::DensityDisturbances:return TEXT("DensityDisturbances"); + case EBucket::DensityStructuralPosts:return TEXT("DensityStructuralPosts"); + case EBucket::DensityBoundarySeal:return TEXT("DensityBoundarySeal"); + case EBucket::DensityDiffLayer: return TEXT("DensityDiffLayer"); + case EBucket::DensityTail: return TEXT("DensityTail"); + case EBucket::MesherGenerateMesh:return TEXT("MesherGenerateMesh"); + case EBucket::MesherDensityGrid:return TEXT("MesherDensityGrid"); + case EBucket::MesherCellClassification:return TEXT("MesherCellClassification"); + case EBucket::MesherGradientNormals:return TEXT("MesherGradientNormals"); + case EBucket::MesherVertexInterpolation:return TEXT("MesherVertexInterpolation"); + case EBucket::MesherStreamBuilding:return TEXT("MesherStreamBuilding"); + case EBucket::MesherOther: return TEXT("MesherOther"); case EBucket::Count: break; } return TEXT("Unknown"); } FScopedTimer::~FScopedTimer() + { + End(); + } + + void FScopedTimer::End() { if (!bActive) { return; } - const int32 Index = ToIndex(Bucket); - GCalls[Index].fetch_add(1, std::memory_order_relaxed); - GCycles[Index].fetch_add(FPlatformTime::Cycles64() - StartCycles, - std::memory_order_relaxed); + AddMeasurement(Bucket, FPlatformTime::Cycles64() - StartCycles, 1); + bActive = false; } } diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 4501647..000a01b 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -181,10 +181,10 @@ thread_local int32 VoxelGenLOD::SampleStep = 1; // 4-wide via SSE (Perlin3D_x4). These thin wrappers keep every call site unchanged. They // sample a DIFFERENT (float hash-gradient) noise field than the old FMath::PerlinNoise3D, // so worlds re-tune once — but the fBm/Ridged math/contracts ([-1,1]) are identical. -static float FractalNoise3D(const FVector& Position, int32 Octaves = 4, - float Lacunarity = 2.0f, float Persistence = 0.5f) +static float FractalNoise3D(const FVector3f& Position, int32 Octaves = 4, + float Lacunarity = 2.0f, float Persistence = 0.5f) { - return VoxelNoise::FBM((float)Position.X, (float)Position.Y, (float)Position.Z, + return VoxelNoise::FBM(Position.X, Position.Y, Position.Z, Octaves, Lacunarity, Persistence); } @@ -199,10 +199,10 @@ static float FractalNoise3D(const FVector& Position, int32 Octaves = 4, // Returns approximately [-1, 1] to match FractalNoise3D's range. // Character: craggy cliffs, natural erosion patterns, sharp corridors. -static float RidgedNoise3D(const FVector& Position, int32 Octaves = 4, - float Lacunarity = 2.0f, float Persistence = 0.5f) +static float RidgedNoise3D(const FVector3f& Position, int32 Octaves = 4, + float Lacunarity = 2.0f, float Persistence = 0.5f) { - return VoxelNoise::Ridged((float)Position.X, (float)Position.Y, (float)Position.Z, + return VoxelNoise::Ridged(Position.X, Position.Y, Position.Z, Octaves, Lacunarity, Persistence); } @@ -225,9 +225,9 @@ static float RidgedNoise3D(const FVector& Position, int32 Octaves = 4, // qui voit déjà `VoxelHash` : la pile d'opérateurs a besoin exactement du même bruit pour la // rugosité (4b, type Cellular) et pour les festons (4f), et deux copies d'une fonction pure finissent // par diverger — c'est littéralement `AUDIT §C1`. Ce forwarder garde les ~3 sites d'appel ci-dessous -// inchangés, comme l'ont fait FractalNoise3D et RidgedNoise3D lors de T2.a. Aucun changement de -// comportement : corps identique, mêmes doubles de `FVector`, même ordre d'opérations. -static float CellularNoise3D(const FVector& Position) +// inchangés, comme l'ont fait FractalNoise3D et RidgedNoise3D lors de T2.a. Le chemin chaud garde +// maintenant les coordonnées en float : la compatibilité d'arrondi historique est abandonnée. +static float CellularNoise3D(const FVector3f& Position) { return VoxelNoise::Cellular3D(Position); } @@ -483,6 +483,16 @@ namespace static thread_local FTunnelCoreCacheState GTunnelCoreCache; + static void BuildTunnelSupportColumnsForChunk( + FChunkSDFCache& Cache, + const FIntVector& ChunkCoord); + + static const FTunnelSupportFloorColumn* FindTunnelSupportColumn( + const FChunkSDFCache& Cache, + int32 X, + int32 Y, + const FTunnelSupportFloorColumn& EmptyColumn); + // A mesher tile samples an expanded grid (-1..Cells+1), so one worker visits several exact // chunk coordinates even while it is generating one tile. The old CP_* cache retained only // the last coordinate and consequently rebuilt the whole TunnelNetwork stack whenever the @@ -491,12 +501,11 @@ namespace // result. Native TunnelNetwork/Underwater states are immutable after construction; the active // pointer below lets the hot sample path read the stored stack and tunnel-core cache without // moving them on every voxel. - // The largest production coarse tile can touch thousands of exact chunk keys once its - // gradient halo is included. A 128-slot direct map repeatedly evicts nearby keys (the LOD - // probe measured 1,734 misses for only 6,859 samples at L4), rebuilding the room/tunnel cache - // in the hot loop. Keep the same deterministic key and open-addressed lookup, but size the - // bounded table for the measured L4 working set rather than the old 128-slot guess. - constexpr int32 TunnelDensityCacheSlotCount = 4096; + // The cache is open-addressed and bounded. The 128-slot budget is evidence-sized: the + // 128³ production export measured at most 102 live tunnel entries on one worker, leaving + // 26 slots of headroom; the old 4,096-slot experiment reserved 3.34 MiB per worker before + // one operator or SDF allocation existed. Collisions only cause a safe deterministic rebuild. + constexpr int32 TunnelDensityCacheSlotCount = 128; struct FTunnelNetworkDensityCacheEntry { @@ -514,6 +523,49 @@ namespace FTunnelCoreCacheState TunnelCore; }; + void ReportTunnelDensityCacheFootprint( + const FTunnelNetworkDensityCacheEntry* Entries) + { + if (!VoxelDensityProfile::IsEnabled() || Entries == nullptr) + { + return; + } + + uint64 DynamicBytes = 0; + uint64 EntryBytes = 0; + uint64 LargestEntryBytes = 0; + uint64 ValidEntries = 0; + for (int32 Index = 0; Index < TunnelDensityCacheSlotCount; ++Index) + { + const FTunnelNetworkDensityCacheEntry& Entry = Entries[Index]; + if (!Entry.bValid) + { + continue; + } + + ++ValidEntries; + const uint64 StackBytes = static_cast(Entry.OpStack.GetAllocatedSize()); + const uint64 TunnelCoreBytes = static_cast( + Entry.TunnelCore.Cache.GetAllocatedSize()); + const uint64 EntryDynamicBytes = StackBytes + TunnelCoreBytes; + const uint64 FullEntryBytes = static_cast( + sizeof(FTunnelNetworkDensityCacheEntry)) + EntryDynamicBytes; + DynamicBytes += EntryDynamicBytes; + EntryBytes += FullEntryBytes; + LargestEntryBytes = FMath::Max(LargestEntryBytes, FullEntryBytes); + } + + VoxelDensityProfile::SetWorkerTunnelCacheFootprint( + TunnelDensityCacheSlotCount, + ValidEntries, + static_cast(sizeof(FTunnelNetworkDensityCacheEntry)) + * TunnelDensityCacheSlotCount, + DynamicBytes, + EntryBytes, + LargestEntryBytes, + ValidEntries); + } + FORCEINLINE int32 TunnelDensityCacheSlot(const FIntVector& Chunk) { uint32 Hash = static_cast(Chunk.X) * 0x9E3779B9u; @@ -547,7 +599,8 @@ namespace const float ChunkMinY = static_cast(ChunkCoord.Y * CHUNK_SIZE); const float ChunkMaxX = ChunkMinX + static_cast(CHUNK_SIZE); const float ChunkMaxY = ChunkMinY + static_cast(CHUNK_SIZE); - const float Expansion = FMath::Max(Params.CaveWarpStrength, 0.0f) + 2.0f; + const float Expansion = FMath::Abs(Params.CaveWarpStrength) + * VOXEL_NOISE_SCALE * 1.5f + 2.0f; const int32 StrateIndex = Manager.GetStrateIndex( (static_cast(ChunkCoord.Z) + 0.5f) * static_cast(CHUNK_SIZE) * VOXEL_SIZE); @@ -558,6 +611,7 @@ namespace ChunkMaxX + Expansion, ChunkMaxY + Expansion, Params, static_cast(Manager.GetWorldSeed()), StrateIndex, nullptr); + BuildTunnelSupportColumnsForChunk(OutState.Cache, ChunkCoord); OutState.OwnerId = OwnerId; OutState.ManagerLifetimeId = ManagerLifetimeId; OutState.Chunk = ChunkCoord; @@ -566,6 +620,67 @@ namespace OutState.bValid = true; } + static void BuildTunnelSupportColumnsForChunk( + FChunkSDFCache& Cache, + const FIntVector& ChunkCoord) + { + const int32 MinX = ChunkCoord.X * CHUNK_SIZE - 1; + const int32 MinY = ChunkCoord.Y * CHUNK_SIZE - 1; + const int32 Cells = CHUNK_SIZE + 2; + Cache.SupportColumnMinX = MinX; + Cache.SupportColumnMinY = MinY; + Cache.SupportColumnCellsX = Cells; + Cache.SupportColumnCellsY = Cells; + Cache.SupportColumnEntries.Init(INDEX_NONE, Cells * Cells); + Cache.SupportColumns.Reset(); + + // Empty columns are represented only by INDEX_NONE in the slot map, so + // each stored column retains the inline bands for actual graph tubes. + for (int32 Y = 0; Y < Cells; ++Y) + { + for (int32 X = 0; X < Cells; ++X) + { + FTunnelSupportFloorColumn Column; + VoxelCaveMorphology::BuildTunnelSupportFloorColumn( + static_cast(MinX + X), + static_cast(MinY + Y), + Cache, + Column); + if (Column.Intervals.Num() == 0) + { + continue; + } + + const int32 EntryIndex = Cache.SupportColumns.Add(MoveTemp(Column)); + Cache.SupportColumnEntries[Y * Cells + X] = EntryIndex; + } + } + } + + static const FTunnelSupportFloorColumn* FindTunnelSupportColumn( + const FChunkSDFCache& Cache, + int32 X, + int32 Y, + const FTunnelSupportFloorColumn& EmptyColumn) + { + if (Cache.SupportColumnCellsX <= 0 + || X < Cache.SupportColumnMinX + || X >= Cache.SupportColumnMinX + Cache.SupportColumnCellsX + || Y < Cache.SupportColumnMinY + || Y >= Cache.SupportColumnMinY + Cache.SupportColumnCellsY) + { + return nullptr; + } + + const int32 Slot = + (Y - Cache.SupportColumnMinY) * Cache.SupportColumnCellsX + + (X - Cache.SupportColumnMinX); + const int32 EntryIndex = Cache.SupportColumnEntries[Slot]; + return EntryIndex == INDEX_NONE + ? &EmptyColumn + : &Cache.SupportColumns[EntryIndex]; + } + struct FVoxelStackParamRefs { const FSlabGenerationParams* Slab = nullptr; @@ -739,6 +854,8 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co { VoxelDensityProfile::FScopedTimer DensityProfileTimer( VoxelDensityProfile::EBucket::GetDensityAt); + VoxelDensityProfile::FScopedTimer DensityPrologueTimer( + VoxelDensityProfile::EBucket::DensityPrologue); // ── STRATE SYSTEM ── // Query per-chunk params from the manager so each strate has different caves. float Result; @@ -762,6 +879,9 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // to reach the next layer. No caves, no spine, no vertical seal — just rock + passage // tube/landing; // the global XY edge seal is applied below for bounded worlds. + DensityPrologueTimer.End(); + VoxelDensityProfile::FScopedTimer DensityCoreTimer( + VoxelDensityProfile::EBucket::DensityCore); float Density = 8.0f; // bedrock solidity (positive = solid) StrateManager->ApplyPassageModifier( Density, WorldX, WorldY, WorldZ, 8.0f, 0.0f); @@ -1161,6 +1281,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co CP_ActiveTunnelDensityCache = &CachedEntry; ActiveOpStack = &CachedEntry.OpStack; ActiveTunnelCoreCache = &CachedEntry.TunnelCore; + ReportTunnelDensityCacheFootprint(CP_TunnelDensityCache); } else if (!bLoadedTunnelDensityCache) { @@ -1170,6 +1291,10 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co } + DensityPrologueTimer.End(); + VoxelDensityProfile::FScopedTimer DensityCoreTimer( + VoxelDensityProfile::EBucket::DensityCore); + // Le seul point d'entrée de la pile dans le chemin de production. Elle rend la convention // MC (négatif = solide) comme les fonctions d'archétype, donc les disturbances et la couche // de diff qui suivent ne voient aucune différence. @@ -1226,21 +1351,28 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co default: // Underwater shares tunnel rock (water table is a render-side overlay). Result = GetDensityWithParams(WorldX, WorldY, WorldZ, CP_Tunnel, - CP_TunnelFP, LayoutVersion); break; + CP_TunnelFP, LayoutVersion, + /*bApplyLegacyStructuralPosts=*/false); break; } + DensityCoreTimer.End(); // Disturbance layer (the "wow" post-process) — cached params, MC convention. The // vertical shaft tree opens a walkable route before this shared pass, so a bridge/ridge // must not refill it. Both native and operator-stack paths use the same marker helper. + VoxelDensityProfile::FScopedTimer DensityDisturbancesTimer( + VoxelDensityProfile::EBucket::DensityDisturbances); const bool bProtectVerticalShaftAir = VoxelPassageGeometry::VerticalShaftConnectorAirMarker(); ApplyDisturbances(Result, WorldX, WorldY, WorldZ, CP_Dist, (uint32)Seed, bProtectVerticalShaftAir); + DensityDisturbancesTimer.End(); // A disturbance is allowed to add visual detail, but it must not refill the landing's // measured air volume or turn its support slab back into a hole. Reassert the landing // air first, then the structural floor, in MC space before the final XY seal. The two // calls use the same landing geometry already evaluated by the legacy and op-stack post. + VoxelDensityProfile::FScopedTimer DensityStructuralPostsTimer( + VoxelDensityProfile::EBucket::DensityStructuralPosts); float LandingBaseDensity = CP_Dist.BaseDensity; #if WITH_EDITOR // A composer parent can deliberately use a different structural base than the authored @@ -1326,12 +1458,26 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // never once per voxel. if (ActiveTunnelCoreCache->bValid) { + const FTunnelSupportFloorColumn* SupportColumn = nullptr; + FTunnelSupportFloorColumn EmptySupportColumn; + const bool bIntegerXY = + WorldX == FMath::FloorToFloat(WorldX) + && WorldY == FMath::FloorToFloat(WorldY); + if (bIntegerXY) + { + const int32 IX = FMath::FloorToInt(WorldX); + const int32 IY = FMath::FloorToInt(WorldY); + SupportColumn = FindTunnelSupportColumn( + ActiveTunnelCoreCache->Cache, IX, IY, EmptySupportColumn); + } + FTunnelCoreWorldEvaluation TunnelCore; { VoxelDensityProfile::FScopedTimer ProfileTimer( VoxelDensityProfile::EBucket::TunnelCorePosts); TunnelCore = VoxelCaveMorphology::EvaluateTunnelCoreWorld( - WorldX, WorldY, WorldZ, ActiveTunnelCoreCache->Cache); + WorldX, WorldY, WorldZ, ActiveTunnelCoreCache->Cache, + SupportColumn); } const bool bTunnelSupportFloor = TunnelCore.bSupportFloor; if (bTunnelSupportFloor) @@ -1362,6 +1508,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co StrateManager->ApplyPassageLandingRoomFloorMC( Result, WorldX, WorldY, WorldZ, LandingBaseDensity); } + DensityStructuralPostsTimer.End(); } else { @@ -1371,16 +1518,23 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // aussi. La calculer une fois évite un CRC par voxel sur un chemin qui n'en a aucun besoin. // `LayoutVersion = 0` : sans `StrateManager` il n'y a pas de layout, donc rien qui puisse // périmer — et l'empreinte constante suffit à distinguer ce cache de tous les autres. + DensityPrologueTimer.End(); + VoxelDensityProfile::FScopedTimer DensityCoreTimer( + VoxelDensityProfile::EBucket::DensityCore); static const FStrateGenerationParams FallbackParams; static const uint32 FallbackFP = FCrc::MemCrc32(&FallbackParams, sizeof(FallbackParams)); Result = GetDensityWithParams(WorldX, WorldY, WorldZ, FallbackParams, FallbackFP, 0); + DensityCoreTimer.End(); } // The edge is the final generated structural invariant. Passage carving is ordered before it // in every archetype/stack, and this MC-facing pass also covers out-of-layout air plus any // disturbance that might otherwise carve back into the shell. Player edits remain separate // below and are deliberately still the user override path. + VoxelDensityProfile::FScopedTimer DensityBoundarySealTimer( + VoxelDensityProfile::EBucket::DensityBoundarySeal); VF_ApplyXYEdgeSealMC(Result, WorldX, WorldY, WorldRadiusVoxels, EdgeSealThickness, 8.0f); + DensityBoundarySealTimer.End(); //========================================================================= // PLAYER MODIFICATIONS (diff layer) @@ -1391,6 +1545,8 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // we subtract the diff offset: // Carve (diff < 0) → Result -= negative → Result increases → more air ✓ // Fill (diff > 0) → Result -= positive → Result decreases → more solid ✓ + VoxelDensityProfile::FScopedTimer DensityDiffLayerTimer( + VoxelDensityProfile::EBucket::DensityDiffLayer); if (DiffLayer && DiffLayer->HasAnyMods()) { // ── PER-CHUNK MOD SNAPSHOT ── @@ -1422,14 +1578,18 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co Result -= UVoxelDiffLayer::EvaluateMods(Slot.Mods, WorldX, WorldY, WorldZ); } } + DensityDiffLayerTimer.End(); + VoxelDensityProfile::FScopedTimer DensityTailTimer( + VoxelDensityProfile::EBucket::DensityTail); VoxelPassageGeometry::ResetVerticalShaftConnectorAirMarker(); return Result; } float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float WorldZ, const FStrateGenerationParams& Params, - uint32 ParamsFingerprint, uint32 LayoutVersion) const + uint32 ParamsFingerprint, uint32 LayoutVersion, + bool bApplyLegacyStructuralPosts) const { //========================================================================= // STRATE DENSITY FUNCTION (Morphology Pipeline) @@ -1505,15 +1665,15 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo // Three independent Perlin fields offset by irrational-ish numbers // so the X/Y/Z warp channels don't correlate with each other. // Single octave to keep per-voxel cost low (3 Perlin calls total). - WarpedX += VoxelNoise::Perlin3D(FVector( + WarpedX += VoxelNoise::Perlin3D(FVector3f( WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.37f), WorldY * WF + 1.3f, EffectiveZ * WF + 5.7f)) * VOXEL_NOISE_SCALE * WS; - WarpedY += VoxelNoise::Perlin3D(FVector( + WarpedY += VoxelNoise::Perlin3D(FVector3f( WorldX * WF + 7.1f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.59f), EffectiveZ * WF + 2.3f)) * VOXEL_NOISE_SCALE * WS; - WarpedZ += VoxelNoise::Perlin3D(FVector( + WarpedZ += VoxelNoise::Perlin3D(FVector3f( WorldX * WF + 11.3f, WorldY * WF + 9.7f, EffectiveZ * WF + VoxelHash::SeedOffset(SeedU, 0.41f))) * VOXEL_NOISE_SCALE * WS; @@ -1619,7 +1779,8 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo const float ChunkMinY = CacheChunkY * (float)CHUNK_SIZE; const float ChunkMaxX = ChunkMinX + (float)CHUNK_SIZE; const float ChunkMaxY = ChunkMinY + (float)CHUNK_SIZE; - const float Expansion = Params.CaveWarpStrength + 2.0f; + const float Expansion = FMath::Abs(Params.CaveWarpStrength) + * VOXEL_NOISE_SCALE * 1.5f + 2.0f; const float SMinX = ChunkMinX - Expansion; const float SMinY = ChunkMinY - Expansion; @@ -1786,12 +1947,12 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo float RF = Params.RoughnessFrequency; // Base noise input positions (with seed offsets for uniqueness) - FVector MainPos( + FVector3f MainPos( WorldX * RF + VoxelHash::SeedOffset(SeedU, 11.3f), WorldY * RF + VoxelHash::SeedOffset(SeedU, 13.7f), EffectiveZ * RF + VoxelHash::SeedOffset(SeedU, 17.1f) ); - FVector FinePos( + FVector3f FinePos( WorldX * RF * 3.0f + VoxelHash::SeedOffset(SeedU, 19.1f) + 2000.0f, WorldY * RF * 3.0f + VoxelHash::SeedOffset(SeedU, 23.7f) + 2500.0f, EffectiveZ * RF * 3.0f + VoxelHash::SeedOffset(SeedU, 29.3f) + 3000.0f @@ -1807,26 +1968,26 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo float WS = Params.DomainWarpStrength; // Sample three independent noise fields for X, Y, Z warp - float WarpX = VoxelNoise::Perlin3D(FVector( + float WarpX = VoxelNoise::Perlin3D(FVector3f( WorldX * WF + VoxelHash::SeedOffset(SeedU, 5.2f), WorldY * WF + VoxelHash::SeedOffset(SeedU, 1.3f), EffectiveZ * WF + VoxelHash::SeedOffset(SeedU, 9.7f) )) * VOXEL_NOISE_SCALE * WS; - float WarpY = VoxelNoise::Perlin3D(FVector( + float WarpY = VoxelNoise::Perlin3D(FVector3f( WorldX * WF + 100.0f + VoxelHash::SeedOffset(SeedU, 7.7f), WorldY * WF + 200.0f + VoxelHash::SeedOffset(SeedU, 3.1f), EffectiveZ * WF + 300.0f )) * VOXEL_NOISE_SCALE * WS; - float WarpZ = VoxelNoise::Perlin3D(FVector( + float WarpZ = VoxelNoise::Perlin3D(FVector3f( WorldX * WF + 400.0f, WorldY * WF + 500.0f + VoxelHash::SeedOffset(SeedU, 11.9f), EffectiveZ * WF + 600.0f + VoxelHash::SeedOffset(SeedU, 13.3f) )) * VOXEL_NOISE_SCALE * WS; // Apply warp to both noise positions - FVector WarpOffset(WarpX, WarpY, WarpZ); + FVector3f WarpOffset(WarpX, WarpY, WarpZ); MainPos += WarpOffset; FinePos += WarpOffset; } @@ -1978,7 +2139,7 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo float NoisedZ = WorldZ; if (Params.TerraceNoiseDisplacement > 0.0f) { - float DispNoise = FractalNoise3D(FVector( + float DispNoise = FractalNoise3D(FVector3f( WorldX * 0.04f + VoxelHash::SeedOffset(SeedU, 31.1f), WorldY * 0.04f + VoxelHash::SeedOffset(SeedU, 37.3f), WorldZ * 0.02f + VoxelHash::SeedOffset(SeedU, 41.7f) @@ -2104,7 +2265,7 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo if (DistFromSurface < OverhangRange) { // Low Z frequency (0.15x of XY) → features extend horizontally - float OverhangNoise = FractalNoise3D(FVector( + float OverhangNoise = FractalNoise3D(FVector3f( WorldX * Params.OverhangFrequency + VoxelHash::SeedOffset(SeedU, 53.1f), WorldY * Params.OverhangFrequency + VoxelHash::SeedOffset(SeedU, 59.3f), EffectiveZ * Params.OverhangFrequency * 0.15f + VoxelHash::SeedOffset(SeedU, 61.7f) @@ -2144,7 +2305,7 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo // Near the surface (CaveSDF ≈ 0), the sign of CaveSDF tells us which // side we're on: negative = inside cave, positive = solid rock. // We use a noise-modulated vertical gradient to detect steep faces. - float VertGrad = VoxelNoise::Perlin3D(FVector( + float VertGrad = VoxelNoise::Perlin3D(FVector3f( WorldX * 0.05f + VoxelHash::SeedOffset(SeedU, 71.3f), WorldY * 0.05f + VoxelHash::SeedOffset(SeedU, 73.7f), EffectiveZ * 0.15f + VoxelHash::SeedOffset(SeedU, 79.1f) // 3x faster in Z → detects vertical features @@ -2185,7 +2346,7 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo { // Cellular noise: returns ~[-1, 1] where positive = cell interior (bowl) float SF = Params.ScallopFrequency; - float ScallopNoise = CellularNoise3D(FVector( + float ScallopNoise = CellularNoise3D(FVector3f( WorldX * SF + VoxelHash::SeedOffset(SeedU, 83.1f), WorldY * SF + VoxelHash::SeedOffset(SeedU, 89.3f), EffectiveZ * SF + VoxelHash::SeedOffset(SeedU, 97.7f) @@ -2212,47 +2373,21 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo && NearestRoomIdx >= 0) { const FCachedRoom& Room = SDFCache.Rooms[NearestRoomIdx]; - const int32 MaxArches = 3; const FVector VoxPos(WorldX, WorldY, WorldZ); - for (int32 i = 0; i < MaxArches; i++) + for (int32 i = 0; i < 3; i++) { - uint32 AH = VoxelHash::Mix(Room.Hash ^ (0xA4C400u + (uint32)i * 7369u)); - - if (VoxelHash::ToFloat01(AH) > Params.ArchDensity) continue; - - // Arch center XY: small offset from room center - uint32 AH2 = VoxelHash::Mix(AH ^ 0xA4C4u); - float ArcCX = Room.Center.X + VoxelHash::ToFloatSigned(AH2) * Room.RadiusXY * 0.3f; - float ArcCY = Room.Center.Y + VoxelHash::ToFloatSigned(VoxelHash::Mix(AH2)) * Room.RadiusXY * 0.3f; - - // Arch height: mid-room Z (bridging the open space) - uint32 AH3 = VoxelHash::Mix(AH2 ^ 0xB41Du); - float ArcCZ = Room.Center.Z + VoxelHash::ToFloatSigned(AH3) * Room.RadiusZ * 0.4f; - - // Arch direction and span: stretch across most of the room - uint32 AH4 = VoxelHash::Mix(AH3 ^ 0xCAFEu); - float Angle = VoxelHash::ToFloat01(AH4) * PI; - float HalfSpan = Room.RadiusXY * (0.5f + VoxelHash::ToFloat01(VoxelHash::Mix(AH4)) * 0.35f); - - float CosA = FMath::Cos(Angle); - float SinA = FMath::Sin(Angle); - FVector ArchA(ArcCX - CosA * HalfSpan, ArcCY - SinA * HalfSpan, ArcCZ); - FVector ArchB(ArcCX + CosA * HalfSpan, ArcCY + SinA * HalfSpan, ArcCZ); - - // Arch thickness - uint32 AH5 = VoxelHash::Mix(AH4 ^ 0xF00Du); - float ArchRadius = FMath::Lerp(Params.ArchMinRadius, Params.ArchMaxRadius, - VoxelHash::ToFloat01(AH5)); - - float ArchSDF = VoxelSDF::Capsule(VoxPos, ArchA, ArchB, ArchRadius); + const FCachedArch& Arch = Room.Arches[i]; + if (!Arch.bActive) { continue; } + const float ArchSDF = VoxelSDF::Capsule( + VoxPos, Arch.EndpointA, Arch.EndpointB, Arch.Radius); const float ArchBlend = 2.0f; if (ArchSDF < ArchBlend) { float Fill = FMath::Clamp((ArchBlend - ArchSDF) / (ArchBlend * 2.0f), 0.0f, 1.0f); Fill = SmoothStep01(Fill); - Density += Fill * Params.BaseDensity * 1.5f; + Density += Fill * Arch.BaseDensity * 1.5f; } } } @@ -2356,58 +2491,32 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo && NearestRoomIdx >= 0) { const FCachedRoom& Room = SDFCache.Rooms[NearestRoomIdx]; - // Pinches on the perimeter of the room (near tunnel entry points) - const float Spread = 0.85f; - const int32 MaxPinches = 3; - - for (int32 i = 0; i < MaxPinches; i++) + for (int32 i = 0; i < 3; i++) { - uint32 PnH = VoxelHash::Mix(Room.Hash ^ (0xF1C400u + (uint32)i * 5417u)); - - if (VoxelHash::ToFloat01(PnH) > Params.PinchDensity) continue; - - // XY: near room perimeter (where tunnels meet the room) - uint32 PnH2 = VoxelHash::Mix(PnH ^ 0xF1C4u); - float PnX = Room.Center.X + VoxelHash::ToFloatSigned(PnH2) * Room.RadiusXY * Spread; - float PnY = Room.Center.Y + VoxelHash::ToFloatSigned(VoxelHash::Mix(PnH2)) * Room.RadiusXY * Spread; - - // Z: mid-height within the room - uint32 PnH3 = VoxelHash::Mix(PnH2 ^ 0x5432u); - float PnZ = Room.Center.Z + VoxelHash::ToFloatSigned(PnH3) * Room.RadiusZ * 0.5f; - - // Pinch direction aligned toward room center (squeezes inward) - uint32 PnH4 = VoxelHash::Mix(PnH3 ^ 0x9A3Bu); - float PnAngle = VoxelHash::ToFloat01(PnH4) * PI; - float CosPN = FMath::Cos(PnAngle); - float SinPN = FMath::Sin(PnAngle); - - float DXPn = WorldX - PnX; - float DYPn = WorldY - PnY; - float DZPn = WorldZ - PnZ; + const FCachedPinch& Pinch = Room.Pinches[i]; + if (!Pinch.bActive) { continue; } + const float DXPn = WorldX - Pinch.CenterX; + const float DYPn = WorldY - Pinch.CenterY; + const float DZPn = WorldZ - Pinch.CenterZ; // Quick reject - float MaxExtent = FMath::Max(Params.PinchLength, Params.PinchStrength) + 5.0f; - if (FMath::Abs(DXPn) + FMath::Abs(DYPn) + FMath::Abs(DZPn) > MaxExtent) continue; + if (FMath::Abs(DXPn) + FMath::Abs(DYPn) + FMath::Abs(DZPn) > Pinch.MaxExtent) continue; - float Along = DXPn * CosPN + DYPn * SinPN; - float Across = -DXPn * SinPN + DYPn * CosPN; + const float Along = DXPn * Pinch.CosAngle + DYPn * Pinch.SinAngle; + const float Across = -DXPn * Pinch.SinAngle + DYPn * Pinch.CosAngle; - float HalfLength = Params.PinchLength * 0.5f; - float HalfNarrow = Params.PinchStrength; - float HalfVertical = Params.PinchStrength * 1.5f; - - float NAlong = Along / HalfLength; - float NAcross = Across / HalfNarrow; - float NUp = DZPn / HalfVertical; - float EllipDist = NAlong * NAlong + NAcross * NAcross + NUp * NUp; + const float NAlong = Along / Pinch.HalfLength; + const float NAcross = Across / Pinch.HalfNarrow; + const float NUp = DZPn / Pinch.HalfVertical; + const float EllipDist = NAlong * NAlong + NAcross * NAcross + NUp * NUp; if (EllipDist < 1.0f) { float Fill = 1.0f - EllipDist; Fill = SmoothStep01(Fill); - float AxisDist = FMath::Sqrt(NAcross * NAcross + NUp * NUp); - float SideFactor = FMath::Clamp(AxisDist * 2.0f, 0.0f, 1.0f); - Density += Fill * SideFactor * Params.BaseDensity * 1.5f; + const float AxisDist = FMath::Sqrt(NAcross * NAcross + NUp * NUp); + const float SideFactor = FMath::Clamp(AxisDist * 2.0f, 0.0f, 1.0f); + Density += Fill * SideFactor * Pinch.BaseDensity * 1.5f; } } } @@ -2480,7 +2589,7 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo { float WormZFreq = Params.WormFrequency * Params.WormHorizontalBias; - float N1 = FMath::Abs(VoxelNoise::Perlin3D(FVector( + float N1 = FMath::Abs(VoxelNoise::Perlin3D(FVector3f( WorldX * Params.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.0f), WorldY * Params.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.7f), EffectiveZ * WormZFreq + VoxelHash::SeedOffset(SeedU, 2.3f) @@ -2490,7 +2599,7 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo // skip the second Perlin entirely (most voxels; bit-identical output). if (N1 < Params.WormThreshold) { - float N2 = FMath::Abs(VoxelNoise::Perlin3D(FVector( + float N2 = FMath::Abs(VoxelNoise::Perlin3D(FVector3f( WorldX * Params.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.0f) + 137.0f, WorldY * Params.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.7f) + 259.0f, EffectiveZ * WormZFreq + VoxelHash::SeedOffset(SeedU, 2.3f) + 431.0f @@ -2523,39 +2632,54 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo //========================================================================= if (StrateManager) { - StrateManager->ApplyPassageModifier( - Density, WorldX, WorldY, WorldZ, - Params.BaseDensity, Params.BoundarySealThickness); + if (bApplyLegacyStructuralPosts) + { + StrateManager->ApplyPassageModifier( + Density, WorldX, WorldY, WorldZ, + Params.BaseDensity, Params.BoundarySealThickness); + } + else + { + // GetDensityAt owns one common MC-space structural tail after either the legacy or + // operator-stack branch. Keep the actual tube carve here, but do not repeat its + // landing air/floor/tunnel-air posts before the shared disturbance/post sequence. + StrateManager->ApplyPassageCarvingOnly( + Density, WorldX, WorldY, WorldZ, + Params.BaseDensity, Params.BoundarySealThickness); + } } - // Terrain operations and passage support floors are allowed to write solid density, but the - // graph's own tunnel floor/core are structural route geometry. Close the rounded bottom first, - // then reopen only the core above its half-voxel air clearance. Both predicates are shared - // with the operator stack and are evaluated from the per-chunk cache, never per-voxel rebuilt. - const bool bTunnelSupportFloor = - VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( - WorldX, WorldY, WorldZ, SDFCache); - if (bTunnelSupportFloor) + if (bApplyLegacyStructuralPosts) { - Density = FMath::Max(Density, FMath::Max(Params.BaseDensity * 2.0f, 1.0f)); - } - const float TunnelCoreSDF = (Params.RoomDensity > 0.0f && Params.RoomSpacing > 0.0f) - ? VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF(WorldX, WorldY, WorldZ, SDFCache) - : FLT_MAX; - if (!bTunnelSupportFloor - && TunnelCoreSDF < -VoxelPassageGeometry::CaveTunnelAirCoreInsetVoxels) - { - Density = FMath::Min( - Density, - -FMath::Max(Params.BaseDensity * 2.0f, 1.0f)); - } - ApplyOriginLandingFloor(Density, WorldX, WorldY, WorldZ, - Params.StrateTopWorldZ, Params.StrateBottomWorldZ, - Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius); + // Terrain operations and passage support floors are allowed to write solid density, but + // the graph's own tunnel floor/core are structural route geometry. Direct callers of + // GetDensityWithParams retain this historical tail; GetDensityAt skips it and applies the + // same predicates once in its common MC-space post section below both generation paths. + const bool bTunnelSupportFloor = + VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( + WorldX, WorldY, WorldZ, SDFCache); + if (bTunnelSupportFloor) + { + Density = FMath::Max(Density, FMath::Max(Params.BaseDensity * 2.0f, 1.0f)); + } + const float TunnelCoreSDF = (Params.RoomDensity > 0.0f && Params.RoomSpacing > 0.0f) + ? VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF(WorldX, WorldY, WorldZ, SDFCache) + : FLT_MAX; + if (!bTunnelSupportFloor + && TunnelCoreSDF < -VoxelPassageGeometry::CaveTunnelAirCoreInsetVoxels) + { + Density = FMath::Min( + Density, + -FMath::Max(Params.BaseDensity * 2.0f, 1.0f)); + } + ApplyOriginLandingFloor(Density, WorldX, WorldY, WorldZ, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius); - // Fourth structural post: the XY edge wins over a passage near the rim. - VF_ApplyXYEdgeSeal(Density, WorldX, WorldY, - WorldRadiusVoxels, EdgeSealThickness, Params.BaseDensity); + // Fourth structural post: the XY edge wins over a passage near the rim. + VF_ApplyXYEdgeSeal(Density, WorldX, WorldY, + WorldRadiusVoxels, EdgeSealThickness, Params.BaseDensity); + } // Convention MC: négatif = solide, positif = air. // La logique interne utilise positif = solide (plus lisible), donc on négate. @@ -2609,7 +2733,7 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ, if (Params.FloorRoughness > 0.0f) { float FF = Params.FloorRoughnessFrequency; - FloorNoise = FractalNoise3D(FVector( + FloorNoise = FractalNoise3D(FVector3f( WorldX * FF + VoxelHash::SeedOffset(SeedU, 7.3f), WorldY * FF + VoxelHash::SeedOffset(SeedU, 11.1f), 0.0f // XY-pur : plus aucune dépendance en Z / no Z dependence @@ -2640,7 +2764,7 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ, if (Params.CeilingRoughness > 0.0f) { float CF = Params.CeilingRoughnessFrequency; - float RawNoise = FractalNoise3D(FVector( + float RawNoise = FractalNoise3D(FVector3f( WorldX * CF + VoxelHash::SeedOffset(SeedU, 17.3f) + 1000.0f, WorldY * CF + VoxelHash::SeedOffset(SeedU, 19.7f) + 2000.0f, 3000.0f // XY-pur : décalage de décorrélation seul / offset only @@ -2872,7 +2996,7 @@ float UVoxelGenerator::GetMazeDensity(float WorldX, float WorldY, float WorldZ, // Wall roughness: perturb the corridor surface. if (Params.SurfaceRoughness > 0.0f && MazeSDF < R + Params.SurfaceRoughness + 2.0f) { - MazeSDF += FractalNoise3D(FVector(WorldX * 0.12f, WorldY * 0.12f, WorldZ * 0.12f), VoxelGenLOD::Eff(3)) + MazeSDF += FractalNoise3D(FVector3f(WorldX * 0.12f, WorldY * 0.12f, WorldZ * 0.12f), VoxelGenLOD::Eff(3)) * VOXEL_NOISE_SCALE * Params.SurfaceRoughness; } @@ -2933,8 +3057,8 @@ float UVoxelGenerator::SampleSurfaceStructuralZ(float WorldX, float WorldY, if (Params.HeightWarpStrength > 0.0f) { const float WF = Params.HeightWarpFrequency; - const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.31f), WorldY * WF + 4.2f, VoxelHash::SeedOffset(SeedU, 1.7f))); - const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 8.6f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.53f), VoxelHash::SeedOffset(SeedU, 2.9f))); + const float wx = VoxelNoise::Perlin3D(FVector3f(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.31f), WorldY * WF + 4.2f, VoxelHash::SeedOffset(SeedU, 1.7f))); + const float wy = VoxelNoise::Perlin3D(FVector3f(WorldX * WF + 8.6f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.53f), VoxelHash::SeedOffset(SeedU, 2.9f))); QX += wx * VOXEL_NOISE_SCALE * Params.HeightWarpStrength; QY += wy * VOXEL_NOISE_SCALE * Params.HeightWarpStrength; } @@ -2944,12 +3068,12 @@ float UVoxelGenerator::SampleSurfaceStructuralZ(float WorldX, float WorldY, const float Relief = SampleRelief(WorldX, WorldY, Params.ReliefFrequency, Params.ReliefContrast); const float M = FMath::Lerp(1.0f, Relief, Params.ReliefStrength); - float Cont = FractalNoise3D(FVector( + float Cont = FractalNoise3D(FVector3f( QX * Params.ContinentFrequency + VoxelHash::SeedOffset(SeedU, 3.1f), QY * Params.ContinentFrequency + VoxelHash::SeedOffset(SeedU, 5.7f), VoxelHash::SeedOffset(SeedU, 0.7f)), 4); // [-1,1] - float Detail = FractalNoise3D(FVector( + float Detail = FractalNoise3D(FVector3f( WorldX * Params.DetailFrequency + 11.0f, WorldY * Params.DetailFrequency + 22.0f, VoxelHash::SeedOffset(SeedU, 1.3f)), 3); // [-1,1] @@ -2957,7 +3081,7 @@ float UVoxelGenerator::SampleSurfaceStructuralZ(float WorldX, float WorldY, float Mountain = 0.0f; if (Params.MountainStrength > 0.0f) { - float Ridge = RidgedNoise3D(FVector( + float Ridge = RidgedNoise3D(FVector3f( QX * Params.MountainFrequency + 99.0f, QY * Params.MountainFrequency + 77.0f, VoxelHash::SeedOffset(SeedU, 0.9f)), 4); // [-1,1] @@ -3072,8 +3196,8 @@ float UVoxelGenerator::ComputeSurfaceCeiling(float WorldX, float WorldY, if (Params.CeilingWarpStrength > 0.0f) { const float WF = Params.CeilingWarpFrequency; - const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.71f), WorldY * WF + 2.3f, VoxelHash::SeedOffset(SeedU, 3.3f))); - const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 6.1f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.19f), VoxelHash::SeedOffset(SeedU, 4.7f))); + const float wx = VoxelNoise::Perlin3D(FVector3f(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.71f), WorldY * WF + 2.3f, VoxelHash::SeedOffset(SeedU, 3.3f))); + const float wy = VoxelNoise::Perlin3D(FVector3f(WorldX * WF + 6.1f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.19f), VoxelHash::SeedOffset(SeedU, 4.7f))); QX += wx * VOXEL_NOISE_SCALE * Params.CeilingWarpStrength; QY += wy * VOXEL_NOISE_SCALE * Params.CeilingWarpStrength; } @@ -3081,7 +3205,7 @@ float UVoxelGenerator::ComputeSurfaceCeiling(float WorldX, float WorldY, // Broad SIGNED swell: raises/lowers the whole cap → big inverted hills and valleys. if (Params.CeilingUndulation > 0.0f) { - const float Swell = FractalNoise3D(FVector( + const float Swell = FractalNoise3D(FVector3f( QX * Params.CeilingUndulationFrequency + VoxelHash::SeedOffset(SeedU, 1.9f), QY * Params.CeilingUndulationFrequency + 13.0f, VoxelHash::SeedOffset(SeedU, 0.5f)), 3); // [-1,1] @@ -3093,14 +3217,14 @@ float UVoxelGenerator::ComputeSurfaceCeiling(float WorldX, float WorldY, float Hang = 0.0f; if (Params.CeilingRoughness > 0.0f) { - Hang += FMath::Abs(FractalNoise3D(FVector( + Hang += FMath::Abs(FractalNoise3D(FVector3f( WorldX * Params.CeilingRoughnessFrequency + 5.0f, WorldY * Params.CeilingRoughnessFrequency + 6.0f, VoxelHash::SeedOffset(SeedU, 2.1f)), 3)) * VOXEL_NOISE_SCALE * Params.CeilingRoughness; } if (Params.CeilingRidgeStrength > 0.0f) { - float Ridge = RidgedNoise3D(FVector( + float Ridge = RidgedNoise3D(FVector3f( QX * Params.CeilingRidgeFrequency + 31.0f, QY * Params.CeilingRidgeFrequency + 47.0f, VoxelHash::SeedOffset(SeedU, 1.1f)), 4); // [-1,1] @@ -3132,7 +3256,7 @@ float UVoxelGenerator::SurfaceDensityFromColumn(float WorldX, float WorldY, floa const uint32 SeedU = (uint32)Seed; const float f = S.OverhangFrequency; // Shelf-shape noise [0,1]; the Z term makes the reach fold/curl with height (ragged, not a lip). - const float Ns = FractalNoise3D(FVector( + const float Ns = FractalNoise3D(FVector3f( WorldX * f + VoxelHash::SeedOffset(SeedU, 17.3f), WorldY * f + VoxelHash::SeedOffset(SeedU, 23.9f), WorldZ * f * S.OverhangZScale + VoxelHash::SeedOffset(SeedU, 5.1f)), 3) * 0.5f + 0.5f; // [0,1] @@ -4146,7 +4270,7 @@ float UVoxelGenerator::SampleRelief(float WorldX, float WorldY, float Frequency, const uint32 SeedU = (uint32)Seed; // Same offsets/octaves as the original SurfaceWorld relief so existing worlds are // unchanged (this is the function that code path now calls). - float R = FractalNoise3D(FVector( + float R = FractalNoise3D(FVector3f( WorldX * Frequency + VoxelHash::SeedOffset(SeedU, 7.3f), WorldY * Frequency + VoxelHash::SeedOffset(SeedU, 2.1f), VoxelHash::SeedOffset(SeedU, 0.5f)), 2) * 0.5f + 0.5f; // [0,1] @@ -4157,7 +4281,7 @@ float UVoxelGenerator::SampleRelief(float WorldX, float WorldY, float Frequency, float UVoxelGenerator::SampleMoisture(float WorldX, float WorldY, float Frequency) const { const uint32 SeedU = (uint32)Seed; - const float N = FractalNoise3D(FVector( + const float N = FractalNoise3D(FVector3f( WorldX * Frequency + VoxelHash::SeedOffset(SeedU, 4.7f), WorldY * Frequency + VoxelHash::SeedOffset(SeedU, 8.9f), VoxelHash::SeedOffset(SeedU, 1.3f)), 2) * 0.5f + 0.5f; // [0,1] @@ -4241,8 +4365,8 @@ FBiomeSample UVoxelGenerator::SampleBiomeAt(float WorldX, float WorldY, const FB { const uint32 SeedU = (uint32)Seed; const float WF = MP.WarpFrequency; - const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.27f), WorldY * WF + 3.1f, VoxelHash::SeedOffset(SeedU, 1.1f))); - const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 7.7f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.61f), VoxelHash::SeedOffset(SeedU, 2.3f))); + const float wx = VoxelNoise::Perlin3D(FVector3f(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.27f), WorldY * WF + 3.1f, VoxelHash::SeedOffset(SeedU, 1.1f))); + const float wy = VoxelNoise::Perlin3D(FVector3f(WorldX * WF + 7.7f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.61f), VoxelHash::SeedOffset(SeedU, 2.3f))); QX += wx * VOXEL_NOISE_SCALE * MP.WarpStrength; QY += wy * VOXEL_NOISE_SCALE * MP.WarpStrength; } @@ -4373,8 +4497,8 @@ FBiomeSample UVoxelGenerator::ResolveBiomeSampleAt(float WorldX, float WorldY, i { const uint32 SeedU = (uint32)Seed; const float WF = MP.WarpFrequency; - const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.27f), WorldY * WF + 3.1f, VoxelHash::SeedOffset(SeedU, 1.1f))); - const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 7.7f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.61f), VoxelHash::SeedOffset(SeedU, 2.3f))); + const float wx = VoxelNoise::Perlin3D(FVector3f(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.27f), WorldY * WF + 3.1f, VoxelHash::SeedOffset(SeedU, 1.1f))); + const float wy = VoxelNoise::Perlin3D(FVector3f(WorldX * WF + 7.7f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.61f), VoxelHash::SeedOffset(SeedU, 2.3f))); QX += wx * VOXEL_NOISE_SCALE * MP.WarpStrength; QY += wy * VOXEL_NOISE_SCALE * MP.WarpStrength; } @@ -4861,7 +4985,7 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float // Wall roughness. if (Params.SurfaceRoughness > 0.0f && CaveSDF < Params.SurfaceRoughness + 4.0f) { - CaveSDF += FractalNoise3D(FVector(WorldX * 0.1f, WorldY * 0.1f, WorldZ * 0.1f), VoxelGenLOD::Eff(3)) + CaveSDF += FractalNoise3D(FVector3f(WorldX * 0.1f, WorldY * 0.1f, WorldZ * 0.1f), VoxelGenLOD::Eff(3)) * VOXEL_NOISE_SCALE * Params.SurfaceRoughness; } @@ -4986,9 +5110,9 @@ float UVoxelGenerator::GetFloatingIslandDensity(float WorldX, float WorldY, floa // NOTE : `SeedOffset` quantifie la clé de site par ×100, donc 0.0007 → site 0. Unique aujourd'hui // (toutes les autres clés du plugin sont ≥ 0.19) ; la prochaine clé sous 0.005 devra en choisir // une autre plutôt que de collisionner en silence. - const float WX = WorldX + FractalNoise3D(FVector(WorldX * 0.04f + VoxelHash::SeedOffset(S, 0.0007f), WorldY * 0.04f, WorldZ * 0.012f), VoxelGenLOD::Eff(3)) + const float WX = WorldX + FractalNoise3D(FVector3f(WorldX * 0.04f + VoxelHash::SeedOffset(S, 0.0007f), WorldY * 0.04f, WorldZ * 0.012f), VoxelGenLOD::Eff(3)) * VOXEL_NOISE_SCALE * WarpAmp; - const float WY = WorldY + FractalNoise3D(FVector(WorldX * 0.04f + 31.0f, WorldY * 0.04f + 7.0f, WorldZ * 0.012f), VoxelGenLOD::Eff(3)) + const float WY = WorldY + FractalNoise3D(FVector3f(WorldX * 0.04f + 31.0f, WorldY * 0.04f + 7.0f, WorldZ * 0.012f), VoxelGenLOD::Eff(3)) * VOXEL_NOISE_SCALE * WarpAmp; // Per-island constants (existence roll, jitter, radius, Z anchor, taper) are pure functions @@ -5075,7 +5199,7 @@ float UVoxelGenerator::GetFloatingIslandDensity(float WorldX, float WorldY, floa // Craggy shells. if (Params.SurfaceRoughness > 0.0f && IslandSDF < Params.SurfaceRoughness + BlendK + 2.0f) { - IslandSDF += FractalNoise3D(FVector(WorldX * 0.08f, WorldY * 0.08f, WorldZ * 0.08f), VoxelGenLOD::Eff(4)) + IslandSDF += FractalNoise3D(FVector3f(WorldX * 0.08f, WorldY * 0.08f, WorldZ * 0.08f), VoxelGenLOD::Eff(4)) * VOXEL_NOISE_SCALE * Params.SurfaceRoughness; } diff --git a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp index 06bcbc2..6593646 100644 --- a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp +++ b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp @@ -3,6 +3,7 @@ #include "VoxelMarchingCubesMesher.h" #include "MarchingCubesTables.h" +#include "VoxelDensityProfile.h" //============================================================================= // MAIN ALGORITHM @@ -17,6 +18,10 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, FVoxelMeshData MeshData; if (OutCaptureGrid) { OutCaptureGrid->Reset(); } if (!Generator) return MeshData; + VoxelDensityProfile::FScopedTimer MesherProfileTimer( + VoxelDensityProfile::EBucket::MesherGenerateMesh); + VoxelDensityProfile::FScopedTimer MesherSetupTimer( + VoxelDensityProfile::EBucket::MesherOther); // Cell size in voxels. No upper clamp: coarse clipmap levels use bigger steps (the EXTENT // grows). Coarse tiles also use FEWER cells (InCellsPerAxis) for cheaper gen. @@ -218,12 +223,16 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, const int32 GzLo = CzLo - 1; // coins CzLo..CzHi+1, gradients ±1 const int32 GzHi = CzHi + 2; // (== -1..GridDim sans bande) + MesherSetupTimer.End(); + // Réutilise le tampon entre tuiles (thread_local) : SetNumUninitialized garde la // capacité, donc plus de malloc/free de ~170 Ko (35³ floats) par tuile. static thread_local TArray DensityGrid; DensityGrid.SetNumUninitialized(MDim * MDim * MDim); // Bande de strate : seules les rangées Z réellement lues (cellules CzLo..CzHi + marges de // gradient) sont échantillonnées — le reste du tampon reste non initialisé et non lu. + VoxelDensityProfile::FScopedTimer MesherDensityGridTimer( + VoxelDensityProfile::EBucket::MesherDensityGrid); for (int32 gz = GzLo; gz <= GzHi; gz++) { if (ShouldAbortWork()) return FVoxelMeshData(); @@ -241,6 +250,7 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, } } } + MesherDensityGridTimer.End(); // ── CAPTURE-DURING-MESHING ── // Si demandé et que la tuile est pleine résolution (CellsPerAxis==CHUNK_SIZE ⇒ Step==1< CapTris; GroundTris.Reset(); CapTris.Reset(); + const bool bProfileMesher = VoxelDensityProfile::IsEnabled(); + uint64 CellClassificationCycles = 0; + uint64 GradientCycles = 0; + uint64 VertexInterpolationCycles = 0; + uint64 StreamBuildingCycles = 0; + uint64 CellClassificationCalls = 0; + uint64 SurfaceCellCalls = 0; for (int32 cz = CzLo; cz <= CzHi; cz++) // bande de strate : cf. CzLo/CzHi plus haut { if (ShouldAbortWork()) return FVoxelMeshData(); @@ -298,6 +315,8 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, if (ShouldAbortWork()) return FVoxelMeshData(); for (int32 cx = 0; cx < CellsPerAxis; cx++) { + const uint64 ClassificationStart = bProfileMesher + ? FPlatformTime::Cycles64() : 0; // PASSE 1 : densités aux 8 coins + index de cas MC SEULEMENT. // ~70% des cellules d'un chunk sont tout-roc ou tout-air (aucune surface) ; // on les rejette ICI, AVANT de payer les 8 positions + 8 gradients (48 lectures @@ -314,9 +333,18 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, if (D >= IsoLevel) CaseIndex |= (1 << i); } - if (MCEdgeTable[CaseIndex] == 0) continue; // Pas de surface ici → skip + const bool bHasSurface = MCEdgeTable[CaseIndex] != 0; + if (bProfileMesher) + { + CellClassificationCycles += FPlatformTime::Cycles64() - ClassificationStart; + ++CellClassificationCalls; + } + if (!bHasSurface) continue; // Pas de surface ici → skip + if (bProfileMesher) { ++SurfaceCellCalls; } // PASSE 2 : positions + gradients aux 8 coins (uniquement si surface présente). + const uint64 GradientStart = bProfileMesher + ? FPlatformTime::Cycles64() : 0; FVector Positions[8]; FVector Gradients[8]; for (int32 i = 0; i < 8; i++) @@ -328,10 +356,16 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, + FVector(GX * Step, GY * Step, GZ * Step) * VOXEL_SIZE; Gradients[i] = GradAt(GX, GY, GZ); } + if (bProfileMesher) + { + GradientCycles += FPlatformTime::Cycles64() - GradientStart; + } // Interpolation des positions + normales sur les arêtes traversées. t = point de // traversée de l'iso entre les deux coins (clampé, milieu si densités quasi-égales) ; // la normale interpole les gradients de coin par le même t. + const uint64 VertexInterpolationStart = bProfileMesher + ? FPlatformTime::Cycles64() : 0; FVector EdgeVertices[12]; FVector EdgeNormals[12]; for (int32 i = 0; i < 12; i++) @@ -348,9 +382,16 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, EdgeNormals[i] = Gradients[A] + T * (Gradients[B] - Gradients[A]); } } + if (bProfileMesher) + { + VertexInterpolationCycles += + FPlatformTime::Cycles64() - VertexInterpolationStart; + } // Génère les triangles avec vertices dédupliqués. // Ordre 0, 2, 1 (pas 0, 1, 2) pour le winding attendu par RealtimeMesh. + const uint64 StreamBuildingStart = bProfileMesher + ? FPlatformTime::Cycles64() : 0; for (int32 i = 0; MCTriTable[CaseIndex][i] != -1; i += 3) { const int32 E0 = MCTriTable[CaseIndex][i]; @@ -369,10 +410,33 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, Dst.Add(Idx2); Dst.Add(Idx1); } + if (bProfileMesher) + { + StreamBuildingCycles += FPlatformTime::Cycles64() - StreamBuildingStart; + } } } } + if (bProfileMesher) + { + VoxelDensityProfile::AddMeasurement( + VoxelDensityProfile::EBucket::MesherCellClassification, + CellClassificationCycles, CellClassificationCalls); + VoxelDensityProfile::AddMeasurement( + VoxelDensityProfile::EBucket::MesherGradientNormals, + GradientCycles, SurfaceCellCalls); + VoxelDensityProfile::AddMeasurement( + VoxelDensityProfile::EBucket::MesherVertexInterpolation, + VertexInterpolationCycles, SurfaceCellCalls); + VoxelDensityProfile::AddMeasurement( + VoxelDensityProfile::EBucket::MesherStreamBuilding, + StreamBuildingCycles, SurfaceCellCalls); + } + + VoxelDensityProfile::FScopedTimer MesherFinalisationTimer( + VoxelDensityProfile::EBucket::MesherOther); + //========================================================================= // SKIRTS — boucheurs de fissures aux coutures de LOD (clipmap) //========================================================================= diff --git a/Source/VoxelForge/Public/VoxelCaveMorphology.h b/Source/VoxelForge/Public/VoxelCaveMorphology.h index fe6c21f..372c101 100644 --- a/Source/VoxelForge/Public/VoxelCaveMorphology.h +++ b/Source/VoxelForge/Public/VoxelCaveMorphology.h @@ -439,16 +439,16 @@ namespace VoxelMazeTopology // point le plus bas qui voit déjà le hash, et le générateur comme la pile d'opérateurs l'appellent. // // Ce corps était `static float CellularNoise3D(const FVector&)` dans VoxelGenerator.cpp, invisible -// à la pile d'opérateurs. Déplacement LITTÉRAL : mêmes opérations, même ordre, même passage par -// `FVector` (donc par des doubles) — l'égalité binaire du portage TunnelNetwork en dépend. -// `UVoxelGenerator`'s copy is now a one-line forwarder; the body moved verbatim. +// à la pile d'opérateurs. Il vit ici pour que les deux chemins partagent la même implémentation. +// The hot overload uses FVector3f: noise coordinates are floats, so no double round-trip is paid +// before the hash/lattice arithmetic. Output compatibility with the old field is not a contract. // // Algorithme : distance au point-feature le plus proche dans une grille hachée. // 1. cellule entière du point 2. voisinage 3×3×3 3. rendre (F2 − F1), normalisé ~[-1, 1] // F2−F1 donne des frontières de cellules lisses avec des arêtes entre elles. namespace VoxelNoise { - FORCEINLINE float Cellular3D(const FVector& Position) + FORCEINLINE float Cellular3D(const FVector3f& Position) { // Integer cell coordinates int32 CellX = FMath::FloorToInt(Position.X); @@ -508,6 +508,11 @@ namespace VoxelNoise // Result is in [0, ~1.0]. Map to [-1, 1] for compatibility with other noise types. return Result * 2.0f - 1.0f; } + + FORCEINLINE float Cellular3D(const FVector& Position) + { + return Cellular3D(FVector3f((float)Position.X, (float)Position.Y, (float)Position.Z)); + } } //============================================================================= @@ -524,6 +529,32 @@ namespace VoxelNoise // With caching: build once, evaluate 32K times with just SDF math. // This is the single biggest CPU performance win for cave generation. +// Room-relative detail geometry whose hash/trigonometry is constant for a cached room. +// These are built once with the room's terrain-op parameters and then only evaluated per voxel. +struct FCachedArch +{ + bool bActive = false; + FVector EndpointA = FVector::ZeroVector; + FVector EndpointB = FVector::ZeroVector; + float Radius = 0.0f; + float BaseDensity = 0.0f; +}; + +struct FCachedPinch +{ + bool bActive = false; + float CenterX = 0.0f; + float CenterY = 0.0f; + float CenterZ = 0.0f; + float CosAngle = 1.0f; + float SinAngle = 0.0f; + float MaxExtent = 0.0f; + float HalfLength = 0.0f; + float HalfNarrow = 0.0f; + float HalfVertical = 0.0f; + float BaseDensity = 0.0f; +}; + // A room in the cache — everything needed for per-voxel SDF evaluation. // Internal details (CellX, CellY) used during cache building are NOT stored here. struct FCachedRoom @@ -568,6 +599,11 @@ struct FCachedRoom FVector ShapeA = FVector::ZeroVector; FVector ShapeB = FVector::ZeroVector; float ShapeR = 0.0f; + + // Hash-derived room details. The old evaluator rebuilt these hashes and angles for every + // surface sample; the cache build is the only place where their geometry can change. + FCachedArch Arches[3]; + FCachedPinch Pinches[3]; }; // A local flat-floor bridge over the overlap of two room bodies. It is not a new graph edge: it @@ -605,11 +641,112 @@ struct FCachedTunnel TArray WorldControlRadii; FVector WorldBoundCenter = FVector::ZeroVector; float WorldBoundRadiusSq = 0.0f; + // Centerline AABBs and scalar influence radii used by the immutable broad phase. The + // AABB test is a cheap lower bound on distance to the whole chain; the existing squared + // sphere test remains the exact conservative second-stage reject. + FVector SDFCenterlineMin = FVector::ZeroVector; + FVector SDFCenterlineMax = FVector::ZeroVector; + float SDFInfluenceRadius = 0.0f; + FVector WorldCenterlineMin = FVector::ZeroVector; + FVector WorldCenterlineMax = FVector::ZeroVector; + float WorldInfluenceRadius = 0.0f; // Bounding sphere for quick per-voxel rejection FVector BoundCenter; // Center of the bounding sphere float BoundRadiusSq; // Squared radius — if voxel is further, skip this tunnel }; +// A deterministic uniform-grid broad phase. Build() is called while a chunk cache is being +// assembled; after that the arrays are immutable and workers only read a bin range. Entries are +// supplied in candidate order, so each bin is already sorted by candidate ID and no runtime sort +// or allocation is needed. A failed/overflowing build leaves bValid=false and callers fall back +// to the exact legacy scan rather than risking a false negative. +struct FCaveSpatialIndexEntry +{ + int32 CandidateIndex = INDEX_NONE; + FVector Min = FVector::ZeroVector; + FVector Max = FVector::ZeroVector; +}; + +struct FCaveSpatialIndexBin +{ + int32 First = 0; + int32 Count = 0; +}; + +struct FCaveSpatialIndex +{ + static constexpr int32 BinSizeVoxels = 16; + + bool bValid = false; + int32 MinBinX = 0; + int32 MinBinY = 0; + int32 MinBinZ = 0; + int32 CellsX = 0; + int32 CellsY = 0; + int32 CellsZ = 0; + TArray 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; + float FloorZ = 0.0f; + float MinZ = 0.0f; + float MaxZ = 0.0f; +}; + +struct FTunnelSupportFloorColumn +{ + TArray> Intervals; + + FTunnelSupportFloorColumn() = default; + + FTunnelSupportFloorColumn(const FTunnelSupportFloorColumn& Other) + : Intervals(Other.Intervals) + { + } + + FTunnelSupportFloorColumn& operator=(const FTunnelSupportFloorColumn& Other) + { + Intervals = Other.Intervals; + return *this; + } + + FTunnelSupportFloorColumn(FTunnelSupportFloorColumn&& Other) + : Intervals(MoveTemp(Other.Intervals)) + { + } + + FTunnelSupportFloorColumn& operator=(FTunnelSupportFloorColumn&& Other) + { + Intervals = MoveTemp(Other.Intervals); + return *this; + } + + void Reset() + { + Intervals.Reset(); + } +}; + // A pre-baked pit shaft — position and dimensions resolved during BuildChunkCache. // // Pits are included in the main SDF evaluation (SmoothMin alongside rooms and @@ -667,6 +804,27 @@ struct FChunkSDFCache TArray Pits; 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; + int32 SupportColumnMinY = 0; + int32 SupportColumnCellsX = 0; + int32 SupportColumnCellsY = 0; + 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. + SIZE_T GetAllocatedSize() const; }; /** Combined world-space tunnel post query; one cache scan supplies both support and air tests. */ @@ -766,7 +924,8 @@ namespace VoxelCaveMorphology VOXELFORGE_API FTunnelCoreWorldEvaluation EvaluateTunnelCoreWorld( float WorldX, float WorldY, float WorldZ, - const FChunkSDFCache& Cache + const FChunkSDFCache& Cache, + const FTunnelSupportFloorColumn* SupportColumn = nullptr ); // True for the finite support slab beneath a world-space graph tunnel. The native density @@ -777,6 +936,30 @@ namespace VoxelCaveMorphology const FChunkSDFCache& Cache ); + // Build/query the same support-floor predicate at column granularity. This changes only + // where the deterministic projection is computed; it does not change the floor interval. + VOXELFORGE_API void BuildTunnelSupportFloorColumn( + float WorldX, float WorldY, + const FChunkSDFCache& Cache, + FTunnelSupportFloorColumn& OutColumn + ); + + VOXELFORGE_API bool IsTunnelSupportFloorColumnZ( + float WorldZ, + const FTunnelSupportFloorColumn& Column + ); + + // Returns the exact projected floor band for one tunnel at this XY column. + // The world evaluator uses FloorZ for its per-tunnel air ownership rule and + // Min/Max for the support slab itself. + VOXELFORGE_API bool GetTunnelSupportFloorColumnBand( + int32 TunnelIndex, + const FTunnelSupportFloorColumn& Column, + float& OutFloorZ, + float& OutMinZ, + float& OutMaxZ + ); + // CONVENIENCE WRAPPER: builds a temporary cache and evaluates in one call. // Use this for one-off queries (debug visualization, single-point sampling). // For chunk generation, use BuildChunkCache + EvaluateSDFCached instead. diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index 879a88a..97af679 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -130,6 +130,10 @@ public: int32 Num() const { return Ops.Num(); } + // Allocator-backed bytes owned by the stack, including each concrete operator allocation. + // The enclosing cache entry adds sizeof(FVoxelOpStack) separately. + SIZE_T GetAllocatedSize() const; + /** Hoist chunk-constant work for every op. Une fois par chunk et par worker. * Non-const : ça MUTE l'état par-chunk des opérateurs, et le prétendre const serait un * mensonge utile qui finirait par masquer une course. */ @@ -449,6 +453,9 @@ namespace VoxelDensityOps VOXELFORGE_API FRoomBoxDiagnostic GetLastRoomBoxDiagnostic(); + /** Current worker-local room-graph cache footprint; profiling/diagnostic use only. */ + VOXELFORGE_API void ReportWorkerRoomGraphCacheFootprint(); + /** * FloatingIslands — 8 ops, et **la pile tourne à l'ENVERS** : * ConstantVoid → IslandBlob → SdfRoughness → SdfFill → [structural post ×4] diff --git a/Source/VoxelForge/Public/VoxelDensityProfile.h b/Source/VoxelForge/Public/VoxelDensityProfile.h index 50611bf..e5c618e 100644 --- a/Source/VoxelForge/Public/VoxelDensityProfile.h +++ b/Source/VoxelForge/Public/VoxelDensityProfile.h @@ -47,6 +47,25 @@ namespace VoxelDensityProfile CaveTunnelAirOp, XYEdgeSealOp, OtherOp, + // Non-overlapping top-level phases. The named operation buckets above are intentionally + // nested diagnostics; these phase buckets are the accounting ledger that must add up to + // GetDensityAt without double-counting a stack op inside its enclosing phase. + DensityPrologue, + DensityCore, + DensityDisturbances, + DensityStructuralPosts, + DensityBoundarySeal, + DensityDiffLayer, + DensityTail, + // Mesher-side accounting. GenerateMesh is the inclusive total; the following buckets + // cover its regular-grid work and its non-grid setup/finalisation. + MesherGenerateMesh, + MesherDensityGrid, + MesherCellClassification, + MesherGradientNormals, + MesherVertexInterpolation, + MesherStreamBuilding, + MesherOther, Count }; @@ -64,6 +83,10 @@ namespace VoxelDensityProfile CaveTunnelEvaluated, TunnelCoreCandidates, TunnelCoreEvaluated, + TunnelSupportColumnBuilds, + TunnelSupportColumnCandidates, + TunnelSupportFloorQueries, + TunnelSupportFloorChecks, PassageCandidates, PassageEvaluated, Count @@ -76,6 +99,29 @@ namespace VoxelDensityProfile uint64 Calls[BucketCount]{}; uint64 Cycles[BucketCount]{}; uint64 Counters[CounterCount]{}; + + // Opt-in worker-cache footprint snapshot. These are current per-thread values (not + // allocation-event counters), so Snapshot() reports the resident cache footprint at the + // end of a profiling run rather than multiplying bytes by cache churn. + uint64 TunnelCacheWorkers = 0; + uint64 TunnelCacheCapacityEntries = 0; + uint64 TunnelCacheValidEntries = 0; + uint64 TunnelCacheStaticBytes = 0; + uint64 TunnelCacheDynamicBytes = 0; + uint64 TunnelCacheEntryBytes = 0; + uint64 TunnelCacheLargestEntryBytes = 0; + uint64 TunnelCacheLargestWorkerValidEntries = 0; + uint64 TunnelCacheLargestWorkerBytes = 0; + + uint64 RoomGraphCacheWorkers = 0; + uint64 RoomGraphCacheCapacityEntries = 0; + uint64 RoomGraphCacheValidEntries = 0; + uint64 RoomGraphCacheStaticBytes = 0; + uint64 RoomGraphCacheDynamicBytes = 0; + uint64 RoomGraphCacheEntryBytes = 0; + uint64 RoomGraphCacheLargestEntryBytes = 0; + uint64 RoomGraphCacheLargestWorkerValidEntries = 0; + uint64 RoomGraphCacheLargestWorkerBytes = 0; }; VOXELFORGE_API void SetEnabled(bool bEnabled); @@ -83,6 +129,18 @@ namespace VoxelDensityProfile VOXELFORGE_API void Reset(); VOXELFORGE_API FSnapshot Snapshot(); VOXELFORGE_API void AddCounter(ECounter Counter, uint64 Amount = 1); + VOXELFORGE_API void AddMeasurement(EBucket Bucket, uint64 Cycles, uint64 Calls = 1); + + VOXELFORGE_API void SetWorkerTunnelCacheFootprint( + uint64 CapacityEntries, uint64 ValidEntries, + uint64 StaticBytes, uint64 DynamicBytes, + uint64 EntryBytes, uint64 LargestEntryBytes, + uint64 LargestWorkerValidEntries); + VOXELFORGE_API void SetWorkerRoomGraphCacheFootprint( + uint64 CapacityEntries, uint64 ValidEntries, + uint64 StaticBytes, uint64 DynamicBytes, + uint64 EntryBytes, uint64 LargestEntryBytes, + uint64 LargestWorkerValidEntries); VOXELFORGE_API const TCHAR* CounterName(ECounter Counter); VOXELFORGE_API EBucket BucketFromName(const TCHAR* Name); VOXELFORGE_API const TCHAR* BucketName(EBucket Bucket); @@ -111,6 +169,10 @@ namespace VoxelDensityProfile ~FScopedTimer(); + // End a range before its lexical scope ends. This lets GetDensityAt split its enclosing + // total into disjoint prologue/core/post phases without moving the large cache block. + void End(); + FScopedTimer(const FScopedTimer&) = delete; FScopedTimer& operator=(const FScopedTimer&) = delete; diff --git a/Source/VoxelForge/Public/VoxelGenerator.h b/Source/VoxelForge/Public/VoxelGenerator.h index 2c2d16d..b895944 100644 --- a/Source/VoxelForge/Public/VoxelGenerator.h +++ b/Source/VoxelForge/Public/VoxelGenerator.h @@ -160,7 +160,8 @@ public: */ float GetDensityWithParams(float WorldX, float WorldY, float WorldZ, const FStrateGenerationParams& Params, - uint32 ParamsFingerprint, uint32 LayoutVersion) const; + uint32 ParamsFingerprint, uint32 LayoutVersion, + bool bApplyLegacyStructuralPosts = true) const; /** * Densité pour une strate Slab (FlatPlain / CrystalChamber). diff --git a/Source/VoxelForge/Public/VoxelNoise.h b/Source/VoxelForge/Public/VoxelNoise.h index 9071f06..78e9cfd 100644 --- a/Source/VoxelForge/Public/VoxelNoise.h +++ b/Source/VoxelForge/Public/VoxelNoise.h @@ -132,6 +132,15 @@ FORCEINLINE float Perlin3D(const FVector& P) return Perlin3D((float)P.X, (float)P.Y, (float)P.Z); } +// Hot density callers already own float voxel coordinates. Keeping this overload separate from +// FVector avoids constructing UE5's double-precision FVector merely to round the coordinates back +// to float at the noise entry point. It is the same scalar formula and remains deterministic on +// every build of the module. +FORCEINLINE float Perlin3D(const FVector3f& P) +{ + return Perlin3D(P.X, P.Y, P.Z); +} + //============================================================================= // 4-WIDE BATCH — the SIMD multiplier. Computes 4 independent Perlin samples. // Inputs are 4-element arrays; unused lanes must be zero-filled by the caller diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp index 773766d..b9689a2 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -173,6 +173,7 @@ struct FExploreArguments bool bRender = true; bool bWalk = true; bool bExport = true; + bool bUseOperatorStack = true; bool bProfileDensity = false; bool bProfileLod = false; bool bFailureFocusRender = false; @@ -212,6 +213,9 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr FParse::Value(*Params, TEXT("archetype="), ArchetypeText); FParse::Value(*Params, TEXT("slot="), OutArguments.Slot); FParse::Value(*Params, TEXT("modes="), ModesText); + int32 UseOperatorStack = 1; + FParse::Value(*Params, TEXT("opstack="), UseOperatorStack); + OutArguments.bUseOperatorStack = UseOperatorStack != 0; OutArguments.bProfileDensity = FParse::Param(*Params, TEXT("profiledensity")); OutArguments.bProfileLod = FParse::Param(*Params, TEXT("profilelod")); FParse::Value(*Params, TEXT("out="), OutText); @@ -528,9 +532,9 @@ struct FExploreWorld Definition->GeneratorType = Index == Arguments.Slot ? Arguments.Archetype : ECaveGeneratorType::TunnelNetwork; - // Exercise the production operator-stack opt-in where it is implemented (Maze), - // while the runtime switch safely falls back for other archetypes. - Definition->bUseOperatorStack = true; + // Explicitly select the production switch so the same synthetic layout, seed and + // mesher invocation can be measured through both density branches. + Definition->bUseOperatorStack = Arguments.bUseOperatorStack; const TSoftObjectPtr SoftDefinition(Definition); Settings->FixedStrates.Add(Index, SoftDefinition); @@ -3270,6 +3274,7 @@ FString BuildManifestJson( 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("surface_roughness_override"), Arguments.bSurfaceRoughnessOverride); if (Arguments.bSurfaceRoughnessOverride) { @@ -3478,6 +3483,7 @@ FString BuildExploreJson( 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("surface_roughness_override"), Arguments.bSurfaceRoughnessOverride); if (Arguments.bSurfaceRoughnessOverride) { @@ -3494,10 +3500,11 @@ FString BuildExploreJson( 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 -modes=%s -out=Saved/VoxelForge/Explore/"), + "-slot=%d -opstack=%d -modes=%s -out=Saved/VoxelForge/Explore/"), Arguments.Seed, ArchetypeName(Arguments.Archetype), Arguments.Slot, + Arguments.bUseOperatorStack ? 1 : 0, *Arguments.CanonicalModes())); Writer->WriteObjectEnd(); @@ -4123,6 +4130,102 @@ int32 UVoxelForgeExploreCommandlet::Main(const FString& Params) static_cast(DensityCalls), DensityTotalUs, DensityCalls > 0 ? DensityTotalUs / static_cast(DensityCalls) : 0.0); + const uint64 CacheTotalStaticBytes = Profile.TunnelCacheStaticBytes + + Profile.RoomGraphCacheStaticBytes; + const uint64 CacheTotalDynamicBytes = Profile.TunnelCacheDynamicBytes + + Profile.RoomGraphCacheDynamicBytes; + const uint64 CacheTotalBytes = CacheTotalStaticBytes + CacheTotalDynamicBytes; + const uint64 CacheWorkers = FMath::Max( + Profile.TunnelCacheWorkers, Profile.RoomGraphCacheWorkers); + const uint64 CacheValidEntries = Profile.TunnelCacheValidEntries + + Profile.RoomGraphCacheValidEntries; + const uint64 CacheEntryBytes = Profile.TunnelCacheEntryBytes + + Profile.RoomGraphCacheEntryBytes; + const uint64 CacheCapacityEntries = Profile.TunnelCacheCapacityEntries + + Profile.RoomGraphCacheCapacityEntries; + const uint64 CacheLargestEntryBytes = FMath::Max( + Profile.TunnelCacheLargestEntryBytes, + Profile.RoomGraphCacheLargestEntryBytes); + const uint64 CacheLargestWorkerBytes = FMath::Max( + Profile.TunnelCacheLargestWorkerBytes, + Profile.RoomGraphCacheLargestWorkerBytes); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCacheMemory] workers=%llu capacity_entries=%llu valid_entries=%llu " + "static_bytes=%llu dynamic_bytes=%llu total_bytes=%llu " + "avg_valid_entry_bytes=%.1f largest_entry_bytes=%llu " + "avg_worker_bytes=%.1f largest_worker_bytes=%llu " + "tunnel_avg_entry_bytes=%.1f tunnel_largest_entry_bytes=%llu " + "tunnel_largest_worker_valid=%llu " + "roomgraph_avg_entry_bytes=%.1f roomgraph_largest_entry_bytes=%llu " + "roomgraph_largest_worker_valid=%llu " + "tunnel_workers=%llu tunnel_capacity=%llu tunnel_valid=%llu " + "tunnel_static=%llu tunnel_dynamic=%llu roomgraph_workers=%llu " + "roomgraph_capacity=%llu roomgraph_valid=%llu roomgraph_static=%llu " + "roomgraph_dynamic=%llu"), + static_cast(CacheWorkers), + static_cast(CacheCapacityEntries), + static_cast(CacheValidEntries), + static_cast(CacheTotalStaticBytes), + static_cast(CacheTotalDynamicBytes), + static_cast(CacheTotalBytes), + CacheValidEntries > 0 + ? static_cast(CacheEntryBytes) / static_cast(CacheValidEntries) + : 0.0, + static_cast(CacheLargestEntryBytes), + CacheWorkers > 0 + ? static_cast(CacheTotalBytes) / static_cast(CacheWorkers) + : 0.0, + static_cast(CacheLargestWorkerBytes), + Profile.TunnelCacheValidEntries > 0 + ? static_cast(Profile.TunnelCacheEntryBytes) + / static_cast(Profile.TunnelCacheValidEntries) + : 0.0, + static_cast(Profile.TunnelCacheLargestEntryBytes), + static_cast(Profile.TunnelCacheLargestWorkerValidEntries), + Profile.RoomGraphCacheValidEntries > 0 + ? static_cast(Profile.RoomGraphCacheEntryBytes) + / static_cast(Profile.RoomGraphCacheValidEntries) + : 0.0, + static_cast(Profile.RoomGraphCacheLargestEntryBytes), + static_cast(Profile.RoomGraphCacheLargestWorkerValidEntries), + static_cast(Profile.TunnelCacheWorkers), + static_cast(Profile.TunnelCacheCapacityEntries), + static_cast(Profile.TunnelCacheValidEntries), + static_cast(Profile.TunnelCacheStaticBytes), + static_cast(Profile.TunnelCacheDynamicBytes), + static_cast(Profile.RoomGraphCacheWorkers), + static_cast(Profile.RoomGraphCacheCapacityEntries), + static_cast(Profile.RoomGraphCacheValidEntries), + static_cast(Profile.RoomGraphCacheStaticBytes), + static_cast(Profile.RoomGraphCacheDynamicBytes)); + + const VoxelDensityProfile::EBucket DensityPhases[] = { + VoxelDensityProfile::EBucket::DensityPrologue, + VoxelDensityProfile::EBucket::DensityCore, + VoxelDensityProfile::EBucket::DensityDisturbances, + VoxelDensityProfile::EBucket::DensityStructuralPosts, + VoxelDensityProfile::EBucket::DensityBoundarySeal, + VoxelDensityProfile::EBucket::DensityDiffLayer, + VoxelDensityProfile::EBucket::DensityTail, + }; + uint64 DensityAttributionCycles = 0; + uint64 DensityAttributionCalls = 0; + for (const VoxelDensityProfile::EBucket Phase : DensityPhases) + { + const int32 PhaseIndex = static_cast(Phase); + DensityAttributionCycles += Profile.Cycles[PhaseIndex]; + DensityAttributionCalls += Profile.Calls[PhaseIndex]; + } + const double DensityAttributionUs = + static_cast(DensityAttributionCycles) * CycleToMicroseconds; + const double DensityUnattributedUs = DensityTotalUs - DensityAttributionUs; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeDensityProfile] attribution_sum_us=%.3f unattributed_us=%.3f " + "coverage=%.2f%% phase_calls=%llu"), + DensityAttributionUs, DensityUnattributedUs, + DensityTotalUs > 0.0 ? DensityAttributionUs * 100.0 / DensityTotalUs : 0.0, + static_cast(DensityAttributionCalls)); + for (int32 Index = 0; Index < VoxelDensityProfile::CounterCount; ++Index) { const VoxelDensityProfile::ECounter Counter = @@ -4151,6 +4254,37 @@ int32 UVoxelForgeExploreCommandlet::Main(const FString& Params) static_cast(Calls), TotalUs, TotalUs / static_cast(Calls)); } + + const VoxelDensityProfile::EBucket MesherBuckets[] = { + VoxelDensityProfile::EBucket::MesherDensityGrid, + VoxelDensityProfile::EBucket::MesherCellClassification, + VoxelDensityProfile::EBucket::MesherGradientNormals, + VoxelDensityProfile::EBucket::MesherVertexInterpolation, + VoxelDensityProfile::EBucket::MesherStreamBuilding, + VoxelDensityProfile::EBucket::MesherOther, + }; + uint64 MesherAttributionCycles = 0; + uint64 MesherAttributionCalls = 0; + for (const VoxelDensityProfile::EBucket Bucket : MesherBuckets) + { + const int32 BucketIndex = static_cast(Bucket); + MesherAttributionCycles += Profile.Cycles[BucketIndex]; + MesherAttributionCalls += Profile.Calls[BucketIndex]; + } + const int32 MesherTotalIndex = static_cast( + VoxelDensityProfile::EBucket::MesherGenerateMesh); + const double MesherTotalUs = static_cast( + Profile.Cycles[MesherTotalIndex]) * CycleToMicroseconds; + const double MesherAttributionUs = static_cast( + MesherAttributionCycles) * CycleToMicroseconds; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeMesherProfile] total_calls=%llu total_us=%.3f " + "attribution_sum_us=%.3f unattributed_us=%.3f coverage=%.2f%% " + "phase_calls=%llu"), + static_cast(Profile.Calls[MesherTotalIndex]), + MesherTotalUs, MesherAttributionUs, MesherTotalUs - MesherAttributionUs, + MesherTotalUs > 0.0 ? MesherAttributionUs * 100.0 / MesherTotalUs : 0.0, + static_cast(MesherAttributionCalls)); VoxelDensityProfile::SetEnabled(false); } return bRequestedModeFailed ? 2 : 0;