From 2de603cd775f06fa73f0d320aaafc7ce936a5b09 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Sun, 13 Sep 2026 15:06:24 +0200 Subject: [PATCH] perf(generation): size the room cache to the tile; spatial index; memo-key guard Coarse tiles thrashed the per-worker 4-chunk room-cache window: 633 BuildChunkCache calls per LOD3 tile and 1,062 per LOD4 tile. This round was started by one Codex session and finished by a second, after the orchestrating session died at 06:06 and corrupted the first session's record. The second session reviewed and built its predecessor's half-finished tree. - voxel.TileCacheWindow (default 1): the room cache covers the requesting tile's footprint plus a fixed collect margin. It is set with TGuardValue around GenerateMesh and classify, so it never leaks to other work on the thread. Tile windows are capped at 64 chunks per side; unsupported/non-finite params fall back to the legacy window. - Window invariance, argued by Codex: graph decisions depend on at most two bounded MaxTunnelLength hops, and primitives are culled only once their complete bounds are known. So the collect margin 2*MaxTunnelLength + max(room reach, pair/chain/tube reach) does not grow with the window. Caveat: oversized authored terrain-op radii are not yet in the envelope check. - PrepareTunnelCoreCache reuses the graph, rebuilding only the exact support-column table for a crossed chunk. - FChunkSDFSpatialIndex: ordered XY buckets over room/join/tunnel bounds; rejection stays exact and within-bucket order is preserved. Only 9-13% of candidates reach exact evaluation. - static_asserts in VoxelStrateTypes.h: VF_STRATE_PARAM_FIELDS lists 83 fields, and their byte sum + 8 B padding == sizeof(FStrateGenerationParams). This closes the memo key's known gap (it catches omissions and size changes, not same-size replacements or padding-hidden fields). Verified (game path, two runs each, sampler off): - field exact: 841/841 per-tile triangle identity, 669,834 triangles; export OBJ SHA-256 unchanged (b3e5f4c3..9b377); capability 20,830 / 10,909, connected - cache builds: fused 139,187 -> 1,019 (-99.3%), op-stack 60,598 -> 879 - worker CPU: static 58.4 -> 40.8 s (-30%), moving 138.4 -> 121.2 s (-12%) REGRESSION, stated plainly: with the switch on, LOD0 gets slower. Generation p50 is ~9% slower, static 75 -> 82 ms and moving 71 -> 77 ms; request-to-ready p50/p95 is ~5-6% slower. This is consistent across both runs of both cases. The thrash only lived at LOD2+ (LOD0 had 1.66 builds per tile), so the next round gates the tile window by LOD and must bring LOD0 back to switch-off speed while keeping the CPU gain. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y --- CODEMAP.md | 1 + .../Private/VoxelCaveMorphology.cpp | 683 +++++++++++++++--- .../Private/VoxelDensityOpStack.cpp | 337 ++++++--- .../Private/VoxelDensityProfile.cpp | 7 + Source/VoxelForge/Private/VoxelGenerator.cpp | 335 +++++++-- .../Private/VoxelMarchingCubesMesher.cpp | 4 + Source/VoxelForge/Private/VoxelWorld.cpp | 23 +- .../VoxelForge/Public/VoxelCaveMorphology.h | 84 ++- .../VoxelForge/Public/VoxelDensityProfile.h | 11 +- Source/VoxelForge/Public/VoxelGenerator.h | 9 + Source/VoxelForge/Public/VoxelStrateTypes.h | 24 + 11 files changed, 1270 insertions(+), 248 deletions(-) diff --git a/CODEMAP.md b/CODEMAP.md index bafdc4e..9b88938 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -302,6 +302,7 @@ casse cette arithmetique *structurellement* — `BeginPlay` loggue une **Error** | `ProcessPendingChunks` | 301 | Drains ProcessQueue under per-frame budget; reads the per-request cancellation token before removing its map entry, drops obsolete/aborted results, then applies each live result via `ApplyTileResult`. | | `ApplyTileResult` | — | **Shared game-thread apply** for one `FChunkResult` (async drain + sync carve): discards stale generation epochs and keys no longer desired, marks loaded, ingests capture, EMPTY releases the tile's existing component (a re-gen can flip content→empty on band change — old geometry must not linger), else `ApplyMeshToTile`. Returns true iff a visible mesh uploaded (counts the budget). Doesn't touch `PendingTiles` (caller's). | | `FVoxelStackSampler` / `FScopedVoxelStackRegistration` (`VoxelStackSampler.h/.cpp`) | — | **Diagnostic stack-sampling profiler (2026-09-13), off by default.** A sampler thread captures registered generation workers' stacks (`CaptureThreadStackBackTrace`) every N µs and tags each with the tile's LOD. It symbolises after the run (inline frames too) into CSV plus a summary under `Saved/`. Registration is per tile TASK in `LoadTile` (and the export's tasks), never per sample; disabled costs one relaxed atomic load. Game `-voxel.SampleStacks=`, commandlet `-samplestacks[=us]`. ~4-5% overhead at 1 ms, so trust large shares, not single-digit deltas. Report: `PERF-SAMPLED.md`. | +| Room-cache performance (2026-09-13): `VF_FindPlayerFitPointForRoomMemoized` / `GPlayerFitMemo` (`VoxelCaveMorphology.cpp`), `voxel.TileCacheWindow` + `VoxelGenLOD::TileOriginVoxels/TileCellsPerAxis` (`VoxelGenerator.h`), `FChunkSDFSpatialIndex` | — | **Player-fit memo:** exact 97-word key, process-wide, `voxel.PlayerFitMemo`. **Tile cache window:** the room cache covers the requesting tile's footprint plus a fixed collect margin. It is set via `TGuardValue` in `GenerateMesh`/classify, and is window-invariant by construction (the margin does not grow with the tile). **Spatial index:** ordered XY buckets over cache primitives, exact rejection. The `static_assert` in `VoxelStrateTypes.h` ties `VF_STRATE_PARAM_FIELDS` to `FStrateGenerationParams` (83 fields + 8 B padding). | | `GenerateTileResult` | — | **Shared worker-side gen** for one tile (async `LoadTile` task + sync `SyncRemeshTile`): [ClassifyTile (T1.d) + exact validation, only when `voxel.OuterClassifierMode=1`; **default 0 since 2026-09-13**] → `GenerateMesh`/`GenerateSheetMesh` → `BuildTileStreamSet`. Reads Generator/Mesher only → safe on a worker or the game thread; fills `FChunkResult`, no enqueue. | | `SyncRemeshTile` | — | **INSTANT DIG**: level-0 same-frame re-mesh on the game thread (`GenerateTileResult` + `ApplyTileResult` inline, Cells=CHUNK_SIZE/Step=1 + strate band, no capture). Used for the tile under the brush centre so a carve is visible THIS frame; one full-res gen on the game thread. | | `UpdateChunksAroundPosition` | 362 | Builds desired set, cancels pending keys that left it, sorts from the **true pawn position**, submits the occupied/support LOD0 tile and the next tile along the pawn heading as an absolute `BackgroundHigh` prefix, then loads/unloads and handles LOD changes. **Delta cull**: `BuildDesiredTiles` returns the LEAVERS (stamped `DesiredStamped` map, one sweep) — only those + `TransitionHold` are considered per crossing, not every loaded tile. `BuildDesiredTiles` also applies `RenderDistanceChunks`: the outermost shell widens to cover the distance — as level-MaxClipLevel MC tiles, or (F18 `bFarSheetRing`) as a SHEET ring at level MaxClipLevel+span (covered-check vs the MaxClipLevel box; `VF_OuterShell` shared with `IsTileInClipRange` so the cull sees the same horizon), dz pre-clamped to the vertical band. **§9.3 anchors:** also prunes dead `StreamingAnchors` + detects their chunk crossings → rebuilds the desired set when an anchor moves/(un)registers (`bAnchorsMoved`/`bForceDesiredRebuild`), same cadence as player movement. §8.10. | diff --git a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp index eec5b4c..9c6193c 100644 --- a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp +++ b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -1263,6 +1263,394 @@ namespace } +void FChunkSDFSpatialIndex::Reset() +{ + MinChunkX = 0; + MinChunkY = 0; + NumCellsX = 0; + NumCellsY = 0; + bValid = false; + CellOffsets.Reset(); + ItemIndices.Reset(); +} + +void FChunkSDFSpatialIndex::Release() +{ + MinChunkX = 0; + MinChunkY = 0; + NumCellsX = 0; + NumCellsY = 0; + bValid = false; + CellOffsets.Empty(); + ItemIndices.Empty(); +} + +SIZE_T FChunkSDFSpatialIndex::GetAllocatedSize() const +{ + return static_cast(CellOffsets.GetAllocatedSize()) + + static_cast(ItemIndices.GetAllocatedSize()); +} + +bool FChunkSDFSpatialIndex::GetRange( + float WorldX, float WorldY, int32& OutBegin, int32& OutEnd) const +{ + OutBegin = 0; + OutEnd = 0; + if (!bValid + || !FMath::IsFinite(WorldX) || !FMath::IsFinite(WorldY) + || NumCellsX <= 0 || NumCellsY <= 0) + { + return false; + } + + const double ChunkXDouble = FMath::FloorToDouble( + static_cast(WorldX) / static_cast(CHUNK_SIZE)); + const double ChunkYDouble = FMath::FloorToDouble( + static_cast(WorldY) / static_cast(CHUNK_SIZE)); + if (ChunkXDouble < static_cast(MIN_int32) + || ChunkXDouble > static_cast(MAX_int32) + || ChunkYDouble < static_cast(MIN_int32) + || ChunkYDouble > static_cast(MAX_int32)) + { + return false; + } + + const int64 LocalX = static_cast(ChunkXDouble) - static_cast(MinChunkX); + const int64 LocalY = static_cast(ChunkYDouble) - static_cast(MinChunkY); + if (LocalX < 0 || LocalX >= static_cast(NumCellsX) + || LocalY < 0 || LocalY >= static_cast(NumCellsY)) + { + // A point outside the indexed rectangle is uncommon for the requesting tile, but the + // exact fallback is safer than assuming the index covers a malformed/legacy query. + return false; + } + + const int64 Slot = LocalY * static_cast(NumCellsX) + LocalX; + if (Slot < 0 || Slot + 1 >= static_cast(CellOffsets.Num())) + { + return false; + } + + OutBegin = CellOffsets[static_cast(Slot)]; + OutEnd = CellOffsets[static_cast(Slot + 1)]; + return OutBegin >= 0 && OutEnd >= OutBegin && OutEnd <= ItemIndices.Num(); +} + +namespace +{ + struct FVFSpatialBounds + { + float MinX = 0.0f; + float MinY = 0.0f; + float MaxX = 0.0f; + float MaxY = 0.0f; + }; + + static bool VF_SetSpatialBounds( + float CenterX, float CenterY, float Radius, FVFSpatialBounds& OutBounds) + { + if (!FMath::IsFinite(CenterX) || !FMath::IsFinite(CenterY) + || !FMath::IsFinite(Radius) || Radius < 0.0f) + { + return false; + } + OutBounds.MinX = CenterX - Radius; + OutBounds.MinY = CenterY - Radius; + OutBounds.MaxX = CenterX + Radius; + OutBounds.MaxY = CenterY + Radius; + return FMath::IsFinite(OutBounds.MinX) && FMath::IsFinite(OutBounds.MinY) + && FMath::IsFinite(OutBounds.MaxX) && FMath::IsFinite(OutBounds.MaxY) + && OutBounds.MinX <= OutBounds.MaxX && OutBounds.MinY <= OutBounds.MaxY; + } + + static bool VF_SetSpatialAabb( + float MinX, float MinY, float MaxX, float MaxY, + float Radius, FVFSpatialBounds& OutBounds) + { + if (!FMath::IsFinite(MinX) || !FMath::IsFinite(MinY) + || !FMath::IsFinite(MaxX) || !FMath::IsFinite(MaxY) + || !FMath::IsFinite(Radius) || Radius < 0.0f + || MinX > MaxX || MinY > MaxY) + { + return false; + } + OutBounds.MinX = MinX - Radius; + OutBounds.MinY = MinY - Radius; + OutBounds.MaxX = MaxX + Radius; + OutBounds.MaxY = MaxY + Radius; + return FMath::IsFinite(OutBounds.MinX) && FMath::IsFinite(OutBounds.MinY) + && FMath::IsFinite(OutBounds.MaxX) && FMath::IsFinite(OutBounds.MaxY) + && OutBounds.MinX <= OutBounds.MaxX && OutBounds.MinY <= OutBounds.MaxY; + } + + static bool VF_IncludeSpatialBounds( + FVFSpatialBounds& InOut, const FVFSpatialBounds& Other, bool& bHasBounds) + { + if (!bHasBounds) + { + InOut = Other; + bHasBounds = true; + return true; + } + InOut.MinX = FMath::Min(InOut.MinX, Other.MinX); + InOut.MinY = FMath::Min(InOut.MinY, Other.MinY); + InOut.MaxX = FMath::Max(InOut.MaxX, Other.MaxX); + InOut.MaxY = FMath::Max(InOut.MaxY, Other.MaxY); + return true; + } + + template + static void VF_BuildSpatialIndex( + FChunkSDFSpatialIndex& OutIndex, int32 ItemCount, FGetBounds&& GetBounds) + { + OutIndex.Reset(); + if (ItemCount <= 0) + { + return; + } + + // Never turn a malformed authored bound into a giant allocation. A failed build is + // deliberately indistinguishable from the old path to the density evaluator. + constexpr int64 MaxBuckets = 1024 * 1024; + constexpr int64 MaxReferences = 4 * 1024 * 1024; + TArray ItemMinX; + TArray ItemMinY; + TArray ItemMaxX; + TArray ItemMaxY; + ItemMinX.SetNumUninitialized(ItemCount); + ItemMinY.SetNumUninitialized(ItemCount); + ItemMaxX.SetNumUninitialized(ItemCount); + ItemMaxY.SetNumUninitialized(ItemCount); + + FVFSpatialBounds AllBounds; + bool bHasBounds = false; + int32 GlobalMinX = MAX_int32; + int32 GlobalMinY = MAX_int32; + int32 GlobalMaxX = MIN_int32; + int32 GlobalMaxY = MIN_int32; + int64 TotalReferences = 0; + for (int32 ItemIndex = 0; ItemIndex < ItemCount; ++ItemIndex) + { + FVFSpatialBounds Bounds; + if (!GetBounds(ItemIndex, Bounds)) + { + return; + } + + const double MinChunkXDouble = FMath::FloorToDouble( + static_cast(Bounds.MinX) / static_cast(CHUNK_SIZE)); + const double MinChunkYDouble = FMath::FloorToDouble( + static_cast(Bounds.MinY) / static_cast(CHUNK_SIZE)); + const double MaxChunkXDouble = FMath::FloorToDouble( + static_cast(Bounds.MaxX) / static_cast(CHUNK_SIZE)); + const double MaxChunkYDouble = FMath::FloorToDouble( + static_cast(Bounds.MaxY) / static_cast(CHUNK_SIZE)); + if (MinChunkXDouble < static_cast(MIN_int32) + || MinChunkXDouble > static_cast(MAX_int32) + || MinChunkYDouble < static_cast(MIN_int32) + || MinChunkYDouble > static_cast(MAX_int32) + || MaxChunkXDouble < static_cast(MIN_int32) + || MaxChunkXDouble > static_cast(MAX_int32) + || MaxChunkYDouble < static_cast(MIN_int32) + || MaxChunkYDouble > static_cast(MAX_int32)) + { + return; + } + + const int32 MinX = static_cast(MinChunkXDouble); + const int32 MinY = static_cast(MinChunkYDouble); + const int32 MaxX = static_cast(MaxChunkXDouble); + const int32 MaxY = static_cast(MaxChunkYDouble); + if (MinX > MaxX || MinY > MaxY) + { + return; + } + + ItemMinX[ItemIndex] = MinX; + ItemMinY[ItemIndex] = MinY; + ItemMaxX[ItemIndex] = MaxX; + ItemMaxY[ItemIndex] = MaxY; + GlobalMinX = FMath::Min(GlobalMinX, MinX); + GlobalMinY = FMath::Min(GlobalMinY, MinY); + GlobalMaxX = FMath::Max(GlobalMaxX, MaxX); + GlobalMaxY = FMath::Max(GlobalMaxY, MaxY); + TotalReferences += (static_cast(MaxX) - MinX + 1) + * (static_cast(MaxY) - MinY + 1); + if (TotalReferences > MaxReferences) + { + return; + } + VF_IncludeSpatialBounds(AllBounds, Bounds, bHasBounds); + } + + const int64 NumCellsX64 = static_cast(GlobalMaxX) - GlobalMinX + 1; + const int64 NumCellsY64 = static_cast(GlobalMaxY) - GlobalMinY + 1; + if (!bHasBounds || NumCellsX64 <= 0 || NumCellsY64 <= 0 + || NumCellsX64 > MAX_int32 || NumCellsY64 > MAX_int32 + || NumCellsX64 * NumCellsY64 > MaxBuckets) + { + return; + } + + OutIndex.MinChunkX = GlobalMinX; + OutIndex.MinChunkY = GlobalMinY; + OutIndex.NumCellsX = static_cast(NumCellsX64); + OutIndex.NumCellsY = static_cast(NumCellsY64); + const int64 BucketCount = NumCellsX64 * NumCellsY64; + OutIndex.CellOffsets.SetNumZeroed(static_cast(BucketCount + 1)); + + for (int32 ItemIndex = 0; ItemIndex < ItemCount; ++ItemIndex) + { + for (int32 Y = ItemMinY[ItemIndex]; Y <= ItemMaxY[ItemIndex]; ++Y) + { + const int64 Row = static_cast(Y - OutIndex.MinChunkY) + * static_cast(OutIndex.NumCellsX); + for (int32 X = ItemMinX[ItemIndex]; X <= ItemMaxX[ItemIndex]; ++X) + { + const int64 Slot = Row + static_cast(X - OutIndex.MinChunkX); + ++OutIndex.CellOffsets[static_cast(Slot + 1)]; + } + } + } + for (int32 Slot = 0; Slot < static_cast(BucketCount); ++Slot) + { + OutIndex.CellOffsets[Slot + 1] += OutIndex.CellOffsets[Slot]; + } + + OutIndex.ItemIndices.SetNumUninitialized(static_cast(TotalReferences)); + TArray WriteOffsets = OutIndex.CellOffsets; + for (int32 ItemIndex = 0; ItemIndex < ItemCount; ++ItemIndex) + { + for (int32 Y = ItemMinY[ItemIndex]; Y <= ItemMaxY[ItemIndex]; ++Y) + { + const int64 Row = static_cast(Y - OutIndex.MinChunkY) + * static_cast(OutIndex.NumCellsX); + for (int32 X = ItemMinX[ItemIndex]; X <= ItemMaxX[ItemIndex]; ++X) + { + const int64 Slot = Row + static_cast(X - OutIndex.MinChunkX); + OutIndex.ItemIndices[WriteOffsets[static_cast(Slot)]++] = ItemIndex; + } + } + } + OutIndex.bValid = true; + } + + template + static int32 VF_ForEachSpatialCandidate( + const FChunkSDFSpatialIndex& Index, int32 ItemCount, + float WorldX, float WorldY, FVisit&& Visit) + { + int32 Begin = 0; + int32 End = 0; + int32 CandidateCount = 0; + if (Index.GetRange(WorldX, WorldY, Begin, End)) + { + for (int32 Cursor = Begin; Cursor < End; ++Cursor) + { + const int32 ItemIndex = Index.ItemIndices[Cursor]; + if (ItemIndex >= 0 && ItemIndex < ItemCount) + { + ++CandidateCount; + Visit(ItemIndex); + } + } + return CandidateCount; + } + + for (int32 ItemIndex = 0; ItemIndex < ItemCount; ++ItemIndex) + { + ++CandidateCount; + Visit(ItemIndex); + } + return CandidateCount; + } + + static bool VF_GetRoomSpatialBounds(int32, const FCachedRoom& Room, FVFSpatialBounds& OutBounds) + { + return VF_SetSpatialBounds( + static_cast(Room.Center.X), static_cast(Room.Center.Y), + FMath::Sqrt(FMath::Max(Room.CullRadiusSq, 0.0f)), OutBounds); + } + + static bool VF_GetJoinSpatialBounds( + int32, const FCachedRoomFloorJoin& Join, FVFSpatialBounds& OutBounds) + { + return VF_SetSpatialBounds( + static_cast(Join.BoundCenter.X), static_cast(Join.BoundCenter.Y), + FMath::Sqrt(FMath::Max(Join.BoundRadiusSq, 0.0f)), OutBounds); + } + + static bool VF_GetTunnelSpatialBounds( + int32, const FCachedTunnel& Tunnel, FVFSpatialBounds& OutBounds) + { + bool bHasBounds = false; + auto IncludeCenterRadius = [&](float X, float Y, float Radius) -> bool + { + FVFSpatialBounds Bounds; + if (!VF_SetSpatialBounds(X, Y, Radius, Bounds)) + { + return false; + } + return VF_IncludeSpatialBounds(OutBounds, Bounds, bHasBounds); + }; + auto IncludeAabbRadius = [&](const FVector& Min, const FVector& Max, float Radius) -> bool + { + FVFSpatialBounds Bounds; + if (!VF_SetSpatialAabb( + static_cast(Min.X), static_cast(Min.Y), + static_cast(Max.X), static_cast(Max.Y), + FMath::Max(Radius, 0.0f), Bounds)) + { + return false; + } + return VF_IncludeSpatialBounds(OutBounds, Bounds, bHasBounds); + }; + + if (!IncludeCenterRadius( + static_cast(Tunnel.BoundCenter.X), + static_cast(Tunnel.BoundCenter.Y), + FMath::Sqrt(FMath::Max(Tunnel.BoundRadiusSq, 0.0f))) + || !IncludeCenterRadius( + static_cast(Tunnel.WorldBoundCenter.X), + static_cast(Tunnel.WorldBoundCenter.Y), + FMath::Sqrt(FMath::Max(Tunnel.WorldBoundRadiusSq, 0.0f))) + || !IncludeAabbRadius( + Tunnel.SDFCenterlineMin, Tunnel.SDFCenterlineMax, + Tunnel.SDFInfluenceRadius) + || !IncludeAabbRadius( + Tunnel.WorldCenterlineMin, Tunnel.WorldCenterlineMax, + Tunnel.WorldInfluenceRadius)) + { + return false; + } + return bHasBounds; + } + + static bool VF_GetPitSpatialBounds( + int32, const FCachedPit& Pit, FVFSpatialBounds& OutBounds) + { + return VF_SetSpatialBounds( + Pit.CenterX, Pit.CenterY, + FMath::Sqrt(FMath::Max(Pit.BoundXYRadiusSq, 0.0f)), OutBounds); + } + + static bool VF_GetChimneySpatialBounds( + int32, const FCachedChimney& Chimney, FVFSpatialBounds& OutBounds) + { + return VF_SetSpatialBounds( + Chimney.CenterX, Chimney.CenterY, + FMath::Sqrt(FMath::Max(Chimney.BoundXYRadiusSq, 0.0f)), OutBounds); + } + + static bool VF_GetColumnSpatialBounds( + int32, const FCachedColumn& Column, FVFSpatialBounds& OutBounds) + { + return VF_SetSpatialBounds( + Column.CenterX, Column.CenterY, + FMath::Sqrt(FMath::Max(Column.BoundXYRadiusSq, 0.0f)), OutBounds); + } +} + void FChunkSDFCache::Reset() { // Keep the allocations: classifier windows can alternate between empty and @@ -1278,6 +1666,12 @@ void FChunkSDFCache::Reset() Pits.Reset(); Chimneys.Reset(); Columns.Reset(); + RoomSpatialIndex.Reset(); + RoomFloorJoinSpatialIndex.Reset(); + TunnelSpatialIndex.Reset(); + PitSpatialIndex.Reset(); + ChimneySpatialIndex.Reset(); + ColumnSpatialIndex.Reset(); SupportColumnMinX = 0; SupportColumnMinY = 0; SupportColumnCellsX = 0; @@ -1295,6 +1689,12 @@ void FChunkSDFCache::Release() Pits.Empty(); Chimneys.Empty(); Columns.Empty(); + RoomSpatialIndex.Release(); + RoomFloorJoinSpatialIndex.Release(); + TunnelSpatialIndex.Release(); + PitSpatialIndex.Release(); + ChimneySpatialIndex.Release(); + ColumnSpatialIndex.Release(); SupportColumnEntries.Empty(); SupportColumns.Empty(); SupportColumnMinX = 0; @@ -1324,6 +1724,13 @@ VoxelDensityProfile::FCacheMemoryBreakdown FChunkSDFCache::GetAllocatedSizeBreak Breakdown.ColumnsBytes = ArrayBytes(Columns); Breakdown.SupportColumnEntriesBytes = ArrayBytes(SupportColumnEntries); Breakdown.SupportColumnsBytes = ArrayBytes(SupportColumns); + Breakdown.SpatialIndexBytes = static_cast( + RoomSpatialIndex.GetAllocatedSize() + + RoomFloorJoinSpatialIndex.GetAllocatedSize() + + TunnelSpatialIndex.GetAllocatedSize() + + PitSpatialIndex.GetAllocatedSize() + + ChimneySpatialIndex.GetAllocatedSize() + + ColumnSpatialIndex.GetAllocatedSize()); for (const FTunnelSupportFloorColumn& Column : SupportColumns) { Breakdown.SupportColumnIntervalsBytes += ArrayBytes(Column.Intervals); @@ -1485,13 +1892,12 @@ static bool VF_ComputeRoomGraphReach( FMath::Abs(Params.TunnelMaxRadius)) * 1.18f); const float MaxTunnelLength = FMath::Max( Params.MaxTunnelLength, 0.0f); - const float MaxInfluence = FMath::Max( - RoomRadiusEnvelope + FloorReliefEnvelope, - FMath::Abs(Params.TunnelWarpStrength) + TunnelRadiusEnvelope) - + BlendEnvelope * 3.0f; const float DirectRoomReach = VF_RoomShapeReachUpperBound( AllRoomRadiusEnvelope, AllRoomHeightEnvelope) + FloorReliefEnvelope + BlendEnvelope * 3.0f; + const float TunnelInfluence = FMath::Abs(Params.TunnelWarpStrength) + + TunnelRadiusEnvelope + BlendEnvelope * 3.0f; + const float MaxInfluence = FMath::Max(DirectRoomReach, TunnelInfluence); // A fitted tunnel mouth may move by one bounded cave-warp displacement in // each XY axis. The chain then wanders by at most 25.5% of its authored @@ -1532,7 +1938,12 @@ static bool VF_ComputeRoomGraphReach( + TunnelLengthBound * 0.255f + FMath::Max(MaxInfluence, TunnelTubeReach); const float PairZReach = EndpointVertical + TunnelTubeReach; - const float CollectMargin = 2.0f * MaxTunnelLength + MaxInfluence; + // A stored feature can depend on a nearest-neighbour candidate two graph hops away: A's + // candidate B can select C, and both decisions affect the edge emitted near the search box. + // PairAabbReach is the complete endpoint/chain/tube envelope; using it here closes the old + // proof gap where the collect margin only covered the smaller direct influence estimate. + const float CollectMargin = 2.0f * MaxTunnelLength + + FMath::Max(DirectRoomReach, PairAabbReach); if (!FMath::IsFinite(DirectRoomReach) || !FMath::IsFinite(PairAabbReach) @@ -1973,6 +2384,43 @@ static void BakeRoomFeature( FCachedTunnel& Tunnel = Cache.Tunnels[Index]; VF_FinalizeTunnelBroadPhaseBounds(Tunnel, BlendK); } + + VF_BuildSpatialIndex( + Cache.RoomSpatialIndex, Cache.Rooms.Num(), + [&Cache](int32 Index, FVFSpatialBounds& Bounds) + { + return VF_GetRoomSpatialBounds(Index, Cache.Rooms[Index], Bounds); + }); + VF_BuildSpatialIndex( + Cache.RoomFloorJoinSpatialIndex, Cache.RoomFloorJoins.Num(), + [&Cache](int32 Index, FVFSpatialBounds& Bounds) + { + return VF_GetJoinSpatialBounds(Index, Cache.RoomFloorJoins[Index], Bounds); + }); + VF_BuildSpatialIndex( + Cache.TunnelSpatialIndex, Cache.Tunnels.Num(), + [&Cache](int32 Index, FVFSpatialBounds& Bounds) + { + return VF_GetTunnelSpatialBounds(Index, Cache.Tunnels[Index], Bounds); + }); + VF_BuildSpatialIndex( + Cache.PitSpatialIndex, Cache.Pits.Num(), + [&Cache](int32 Index, FVFSpatialBounds& Bounds) + { + return VF_GetPitSpatialBounds(Index, Cache.Pits[Index], Bounds); + }); + VF_BuildSpatialIndex( + Cache.ChimneySpatialIndex, Cache.Chimneys.Num(), + [&Cache](int32 Index, FVFSpatialBounds& Bounds) + { + return VF_GetChimneySpatialBounds(Index, Cache.Chimneys[Index], Bounds); + }); + VF_BuildSpatialIndex( + Cache.ColumnSpatialIndex, Cache.Columns.Num(), + [&Cache](int32 Index, FVFSpatialBounds& Bounds) + { + return VF_GetColumnSpatialBounds(Index, Cache.Columns[Index], Bounds); + }); } namespace { @@ -5190,6 +5638,13 @@ bool VoxelCaveMorphology::MayHaveFeatureInSearchBox( Params, StrateSeed, Reach, bUseZ, SearchMinZ, SearchMaxZ); } +bool VoxelCaveMorphology::IsRoomGraphWindowInvariant( + const FStrateGenerationParams& Params) +{ + FVFRoomGraphReach Reach; + return VF_ComputeRoomGraphReach(Params, Reach); +} + //============================================================================= // PHASE 1: BUILD CHUNK CACHE //============================================================================= @@ -5206,11 +5661,33 @@ void VoxelCaveMorphology::BuildChunkCache( float SearchMaxX, float SearchMaxY, const FStrateGenerationParams& Params, uint32 Seed, int32 StrateIndex, - const TArray* TerrainOps) + const TArray* TerrainOps, + ERoomGraphBuildSite BuildSite) { if (VoxelDensityProfile::AreCountersEnabled()) { VoxelDensityProfile::AddCounter(VoxelDensityProfile::ECounter::SdfCacheBuild); + const VoxelDensityProfile::ECounter SiteCounter = [&]() + { + switch (BuildSite) + { + case ERoomGraphBuildSite::GeneratorTile: + return VoxelDensityProfile::ECounter::RoomGraphBuildGeneratorTile; + case ERoomGraphBuildSite::GeneratorTunnelCore: + return VoxelDensityProfile::ECounter::RoomGraphBuildGeneratorTunnelCore; + case ERoomGraphBuildSite::OpShared: + return VoxelDensityProfile::ECounter::RoomGraphBuildOpShared; + case ERoomGraphBuildSite::OpLocal: + return VoxelDensityProfile::ECounter::RoomGraphBuildOpLocal; + case ERoomGraphBuildSite::ClassifierShared: + return VoxelDensityProfile::ECounter::RoomGraphBuildClassifierShared; + case ERoomGraphBuildSite::ClassifierLocal: + return VoxelDensityProfile::ECounter::RoomGraphBuildClassifierLocal; + default: + return VoxelDensityProfile::ECounter::RoomGraphBuildUnknown; + } + }(); + VoxelDensityProfile::AddCounter(SiteCounter); } VoxelDensityProfile::FScopedTimer ProfileTimer( VoxelDensityProfile::EBucket::RoomGraphBuild); @@ -5224,6 +5701,14 @@ void VoxelCaveMorphology::BuildChunkCache( const float CellSize = Params.RoomSpacing; if (CellSize <= 0.0f) return; + // This reach proof is shared with the feature-free preflight. When it succeeds, the collect + // margin includes the largest direct room reach and the complete pair/chain/tube envelope; + // when it fails, keep the conservative legacy fallback and retain the malformed-input safety + // behavior below. The tile-window optimization is only a field-preserving optimization for + // the finite parameter envelope accepted by VF_ComputeRoomGraphReach. + FVFRoomGraphReach FeatureReach; + const bool bHasFeatureReach = VF_ComputeRoomGraphReach(Params, FeatureReach); + //========================================================================= // INFLUENCE RADII //========================================================================= @@ -5234,6 +5719,10 @@ void VoxelCaveMorphology::BuildChunkCache( // so max(Min, Max) covers either radius without changing the authored roll. const float RoomRadiusEnvelope = FMath::Max( FMath::Abs(Params.MinRoomRadius), FMath::Abs(Params.MaxRoomRadius)); + const float AllRoomRadiusEnvelope = FMath::Max( + RoomRadiusEnvelope, FMath::Abs(Params.OriginRoomRadius)); + const float AllRoomHeightEnvelope = AllRoomRadiusEnvelope + * FMath::Abs(Params.RoomHeightRatio); // 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( @@ -5243,10 +5732,13 @@ void VoxelCaveMorphology::BuildChunkCache( 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 + FloorReliefEnvelope, - FMath::Abs(Params.TunnelWarpStrength) + TunnelRadiusEnvelope - ) + BlendEnvelope * 3.0f; + const float DirectRoomReachFallback = VF_RoomShapeReachUpperBound( + AllRoomRadiusEnvelope, AllRoomHeightEnvelope) + + FloorReliefEnvelope + BlendEnvelope * 3.0f; + const float MaxInfluenceFallback = FMath::Max( + DirectRoomReachFallback, + FMath::Abs(Params.TunnelWarpStrength) + + TunnelRadiusEnvelope + BlendEnvelope * 3.0f); const float MaxTunnelLen = FMath::Max(Params.MaxTunnelLength, 0.0f); @@ -5262,13 +5754,13 @@ void VoxelCaveMorphology::BuildChunkCache( // To DECIDE the graph identically in neighboring chunks, we must see, for every // room that could emit a tunnel touching this chunk, that room's ENTIRE // nearest-neighbor candidate set (all rooms within MaxTunnelLength of it): - // - A tunnel touching the chunk has BOTH endpoints within - // (MaxTunnelLength + MaxInfluence) of the search box (capsule len <= MaxTunnelLength). + // - A tunnel touching the box has both endpoint centers inside the pair envelope. // - Each endpoint's NN candidates lie within MaxTunnelLength of that endpoint. - // => collect within (2 * MaxTunnelLength + MaxInfluence) of the search box. - // Combined with NN candidates being filtered to <= MaxTunnelLength below, this - // makes the backbone decision for any STORED tunnel window-invariant. - const float CollectMargin = 2.0f * MaxTunnelLen + MaxInfluence; + // => collect the pair envelope plus two graph hops. This is independent of the search + // box size, so it remains valid for a whole tile as well as a one-chunk window. + const float CollectMargin = bHasFeatureReach + ? FeatureReach.CollectMargin + : 2.0f * MaxTunnelLen + MaxInfluenceFallback; const float CollectMinX = SearchMinX - CollectMargin; const float CollectMinY = SearchMinY - CollectMargin; const float CollectMaxX = SearchMaxX + CollectMargin; @@ -5389,14 +5881,19 @@ void VoxelCaveMorphology::BuildChunkCache( int32 OriginIdx = -1; if (Params.OriginRoomRadius > 0.0f) { - // Collected when (0,0) is in the COLLECT region (connectivity) OR when the room's - // own body can reach this chunk (store) — its radius may exceed the collect margin. + // For a valid reach proof, the origin's own body is necessarily inside the collect region + // whenever it can reach the search box. Keep the old own-body fallback only for malformed + // or unsupported authored parameters, where the proof deliberately declines to claim + // window invariance. const bool bOriginInCollect = 0.0f >= CollectMinX && 0.0f <= CollectMaxX && 0.0f >= CollectMinY && 0.0f <= CollectMaxY; const float OriginRZ = Params.OriginRoomRadius * Params.RoomHeightRatio; - if (bOriginInCollect || - RoomReachesSearchBox(FVector(0.0f, 0.0f, StrateCenterZ), Params.OriginRoomRadius, OriginRZ)) + if (bOriginInCollect + || (!bHasFeatureReach + && RoomReachesSearchBox( + FVector(0.0f, 0.0f, StrateCenterZ), + Params.OriginRoomRadius, OriginRZ))) { FBuildRoom OriginRoom; OriginRoom.CellX = INT32_MAX; // Sentinel — never matches a real grid cell @@ -5452,8 +5949,7 @@ void VoxelCaveMorphology::BuildChunkCache( // the genuinely feature-free case before the O(N^2) graph and player-fit // bake. The helper repeats only the deterministic graph decisions that can // affect whether a room/tunnel bound reaches this search window. - FVFRoomGraphReach FeatureReach; - if (VF_ComputeRoomGraphReach(Params, FeatureReach) + if (bHasFeatureReach && !VF_MayHaveRoomGraphFeature( BuildRooms, SearchMinX, SearchMinY, SearchMaxX, SearchMaxY, Params, StrateSeed, FeatureReach)) @@ -6525,13 +7021,6 @@ float VoxelCaveMorphology::EvaluateSDFCached( float NearestRoomRawSDF = FLT_MAX; int32 NearestIdx = -1; - if (VoxelDensityProfile::AreCountersEnabled()) - { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::CaveRoomCandidates, - static_cast(Cache.Rooms.Num())); - } - //========================================================================= // Room SDFs //========================================================================= @@ -6627,9 +7116,13 @@ float VoxelCaveMorphology::EvaluateSDFCached( MinSDF = VoxelSDF::SmoothMin(MinSDF, RoomSDF, BlendK); }; - for (int32 RoomIdx = 0; RoomIdx < Cache.Rooms.Num(); ++RoomIdx) + const int32 RoomCandidateCount = VF_ForEachSpatialCandidate( + Cache.RoomSpatialIndex, Cache.Rooms.Num(), WorldX, WorldY, EvaluateRoom); + if (VoxelDensityProfile::AreCountersEnabled()) { - EvaluateRoom(RoomIdx); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::CaveRoomCandidates, + static_cast(RoomCandidateCount)); } //========================================================================== @@ -6653,21 +7146,13 @@ float VoxelCaveMorphology::EvaluateSDFCached( const float JoinSDF = FMath::Max(HorizontalSDF, VerticalSDF); MinSDF = VoxelSDF::SmoothMin(MinSDF, JoinSDF, BlendK); }; - for (int32 JoinIdx = 0; JoinIdx < Cache.RoomFloorJoins.Num(); ++JoinIdx) - { - EvaluateJoin(JoinIdx); - } + VF_ForEachSpatialCandidate( + Cache.RoomFloorJoinSpatialIndex, Cache.RoomFloorJoins.Num(), + WorldX, WorldY, EvaluateJoin); //========================================================================= // Tunnel SDFs //========================================================================= - if (VoxelDensityProfile::AreCountersEnabled()) - { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::CaveTunnelCandidates, - static_cast(Cache.Tunnels.Num())); - } - auto EvaluateTunnel = [&](int32 TunnelIdx) { const FCachedTunnel& Tunnel = Cache.Tunnels[TunnelIdx]; @@ -6702,9 +7187,13 @@ float VoxelCaveMorphology::EvaluateSDFCached( MinSDF = VoxelSDF::SmoothMin(MinSDF, TunnelSDF, BlendK); }; - for (int32 TunnelIdx = 0; TunnelIdx < Cache.Tunnels.Num(); ++TunnelIdx) + const int32 TunnelCandidateCount = VF_ForEachSpatialCandidate( + Cache.TunnelSpatialIndex, Cache.Tunnels.Num(), WorldX, WorldY, EvaluateTunnel); + if (VoxelDensityProfile::AreCountersEnabled()) { - EvaluateTunnel(TunnelIdx); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::CaveTunnelCandidates, + static_cast(TunnelCandidateCount)); } // Write nearest room index for the caller (terrain ops system) @@ -6747,10 +7236,8 @@ float VoxelCaveMorphology::EvaluateTunnelCoreSDF( /*bApplyFloorCut=*/false, &Cache, TunnelIdx).SDF); }; - for (int32 TunnelIdx = 0; TunnelIdx < Cache.Tunnels.Num(); ++TunnelIdx) - { - EvaluateTunnel(TunnelIdx); - } + VF_ForEachSpatialCandidate( + Cache.TunnelSpatialIndex, Cache.Tunnels.Num(), WorldX, WorldY, EvaluateTunnel); return MinSDF; } @@ -6770,13 +7257,6 @@ FTunnelCoreWorldEvaluation VoxelCaveMorphology::EvaluateTunnelCoreWorld( VoxelDensityProfile::AddCounter( VoxelDensityProfile::ECounter::TunnelSupportFloorQueries); } - if (VoxelDensityProfile::AreCountersEnabled()) - { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::TunnelCoreCandidates, - static_cast(Cache.Tunnels.Num())); - } - auto EvaluateTunnel = [&](int32 TunnelIdx) { const FCachedTunnel& Tunnel = Cache.Tunnels[TunnelIdx]; @@ -6877,9 +7357,13 @@ FTunnelCoreWorldEvaluation VoxelCaveMorphology::EvaluateTunnelCoreWorld( Result.SDF = FMath::Min(Result.SDF, TunnelShape.SDF); }; - for (int32 TunnelIdx = 0; TunnelIdx < Cache.Tunnels.Num(); ++TunnelIdx) + const int32 TunnelCandidateCount = VF_ForEachSpatialCandidate( + Cache.TunnelSpatialIndex, Cache.Tunnels.Num(), WorldX, WorldY, EvaluateTunnel); + if (VoxelDensityProfile::AreCountersEnabled()) { - EvaluateTunnel(TunnelIdx); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelCoreCandidates, + static_cast(TunnelCandidateCount)); } return Result; @@ -6898,13 +7382,6 @@ bool VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( { const FVector Pos(WorldX, WorldY, WorldZ); - if (VoxelDensityProfile::AreCountersEnabled()) - { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::TunnelCoreCandidates, - static_cast(Cache.Tunnels.Num())); - } - auto TestTunnel = [&](int32 TunnelIdx) -> bool { const FCachedTunnel& Tunnel = Cache.Tunnels[TunnelIdx]; @@ -6959,18 +7436,57 @@ bool VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( }; bool bSupport = false; - for (int32 TunnelIdx = 0; TunnelIdx < Cache.Tunnels.Num(); ++TunnelIdx) + int32 CandidateCount = 0; + auto TestRange = [&](int32 Begin, int32 End) { - if (TestTunnel(TunnelIdx)) + for (int32 Cursor = Begin; Cursor < End; ++Cursor) { - bSupport = true; - if (VoxelDensityProfile::AreCountersEnabled()) + const int32 TunnelIdx = Cache.TunnelSpatialIndex.ItemIndices[Cursor]; + if (!Cache.Tunnels.IsValidIndex(TunnelIdx)) { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::TunnelCoreEvaluated); + continue; + } + ++CandidateCount; + if (TestTunnel(TunnelIdx)) + { + bSupport = true; + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelCoreEvaluated); + } + break; } - break; } + }; + + int32 Begin = 0; + int32 End = 0; + if (Cache.TunnelSpatialIndex.GetRange(WorldX, WorldY, Begin, End)) + { + TestRange(Begin, End); + } + else + { + for (int32 TunnelIdx = 0; TunnelIdx < Cache.Tunnels.Num() && !bSupport; ++TunnelIdx) + { + ++CandidateCount; + if (TestTunnel(TunnelIdx)) + { + bSupport = true; + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelCoreEvaluated); + } + } + } + } + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelCoreCandidates, + static_cast(CandidateCount)); } return bSupport; } @@ -6990,21 +7506,10 @@ void VoxelCaveMorphology::BuildTunnelSupportFloorColumn( VoxelDensityProfile::ECounter::TunnelSupportColumnBuilds); } - if (VoxelDensityProfile::AreCountersEnabled()) - { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::TunnelSupportColumnCandidates, - static_cast(Cache.Tunnels.Num())); - } - const FVector ColumnPosition(WorldX, WorldY, 0.0f); - for (int32 TunnelIdx = 0; TunnelIdx < Cache.Tunnels.Num(); ++TunnelIdx) + auto EvaluateTunnel = [&](int32 TunnelIdx) { - if (!Cache.Tunnels.IsValidIndex(TunnelIdx)) - { - continue; - } const FCachedTunnel& Tunnel = Cache.Tunnels[TunnelIdx]; const FVFRoomFloorOwnership RoomMouthOwnership = VF_FindWorldTunnelMouthOwnership(ColumnPosition, Tunnel, Cache.SDFBlendRadius); @@ -7014,7 +7519,7 @@ void VoxelCaveMorphology::BuildTunnelSupportFloorColumn( ColumnPosition, Tunnel, /*bWorldChain=*/true, FloorZ, SupportRadius, &Cache, TunnelIdx, &RoomMouthOwnership)) { - continue; + return; } float MinZ = FloorZ - VoxelPassageGeometry::LandingFloorThicknessVoxels; @@ -7029,7 +7534,7 @@ void VoxelCaveMorphology::BuildTunnelSupportFloorColumn( const float Remaining = Tunnel.WorldBoundRadiusSq - (DX * DX + DY * DY); if (Remaining < 0.0f) { - continue; + return; } const float HalfZ = FMath::Sqrt(Remaining); MinZ = FMath::Max(MinZ, static_cast(Tunnel.WorldBoundCenter.Z) - HalfZ); @@ -7045,6 +7550,14 @@ void VoxelCaveMorphology::BuildTunnelSupportFloorColumn( Interval.MinZ = MinZ; Interval.MaxZ = MaxZ; } + }; + const int32 CandidateCount = VF_ForEachSpatialCandidate( + Cache.TunnelSpatialIndex, Cache.Tunnels.Num(), WorldX, WorldY, EvaluateTunnel); + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelSupportColumnCandidates, + static_cast(CandidateCount)); } // Candidate IDs are deterministic and allow the world evaluator to find a diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 64ed49f..1859010 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -3461,7 +3461,8 @@ namespace uint64 ManagerLifetimeId, const TArray* TerrainOps, int32 RegionMinX, int32 RegionMinY, int32 RegionSize, float SearchMinX, float SearchMinY, float SearchMaxX, float SearchMaxY, - const FStrateGenerationParams& Params) + const FStrateGenerationParams& Params, + ERoomGraphBuildSite BuildSite) { for (;;) { @@ -3533,13 +3534,8 @@ namespace MakeShared(); VoxelCaveMorphology::BuildChunkCache( *BuiltCache, SearchMinX, SearchMinY, SearchMaxX, SearchMaxY, - Params, Seed, StrateIndex, TerrainOps); + Params, Seed, StrateIndex, TerrainOps, BuildSite); const double CacheBuildSeconds = FPlatformTime::Seconds() - CacheBuildCallStart; - if (VoxelDensityProfile::AreCountersEnabled()) - { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::SdfCacheBuild); - } const uint64 AllocatedBytes = static_cast(sizeof(FSharedRoomGraphCacheEntry)) + static_cast(sizeof(FChunkSDFCache)) @@ -4121,20 +4117,45 @@ namespace StrateIdx = SI_Index; } - // A coarse LOD samples a new exact 32-voxel chunk at nearly every point. The graph is - // window-invariant, so group those samples into a larger deterministic XY region. The - // step is supplied by the mesher through VoxelGenLOD TLS; ordinary point queries keep - // the original one-chunk window. The cache key retains the exact params/strate/op-pool - // identity, so no blended chunk can borrow another chunk's room graph. + // The graph is window-invariant, so a tile worker can use the exact requesting tile as + // its deterministic XY cache region. The step/origin/cell extent is supplied by the + // mesher through VoxelGenLOD TLS; ordinary point queries keep the historical region + // policy. The cache key retains the exact params/strate/op-pool identity, so no + // blended chunk can borrow another chunk's room graph. const int32 SampleStep = FMath::Max(VoxelGenLOD::SampleStep, 1); - const int32 RegionChunks = SampleStep <= 1 - ? 1 - : (SampleStep >= 32 ? 4 : FMath::Max(4, (SampleStep + 3) / 4)); - const int32 RegionSize = CHUNK_SIZE * RegionChunks; - const int32 RegionCellX = FMath::FloorToInt(WorldX / (float)RegionSize); - const int32 RegionCellY = FMath::FloorToInt(WorldY / (float)RegionSize); - const int32 RegionMinX = RegionCellX * RegionSize; - const int32 RegionMinY = RegionCellY * RegionSize; + FIntVector RequestedTileOrigin = FIntVector::ZeroValue; + int32 RequestedTileStep = SampleStep; + int32 RequestedTileCells = 0; + const bool bUseTileCacheWindow = VoxelGenLOD::IsTileCacheWindowEnabled() + && VoxelGenLOD::GetThreadTileCacheWindow( + RequestedTileOrigin, RequestedTileStep, RequestedTileCells) + && VoxelCaveMorphology::IsRoomGraphWindowInvariant(P); + int32 RegionSize = 0; + int32 RegionMinX = 0; + int32 RegionMinY = 0; + if (bUseTileCacheWindow) + { + RegionSize = static_cast( + static_cast(RequestedTileStep) + * static_cast(RequestedTileCells)); + RegionMinX = RequestedTileOrigin.X; + RegionMinY = RequestedTileOrigin.Y; + } + else + { + const int32 RegionChunks = SampleStep <= 1 + ? 1 + : (SampleStep >= 32 ? 4 : FMath::Max(4, (SampleStep + 3) / 4)); + RegionSize = CHUNK_SIZE * RegionChunks; + const int32 RegionCellX = FMath::FloorToInt(WorldX / (float)RegionSize); + const int32 RegionCellY = FMath::FloorToInt(WorldY / (float)RegionSize); + RegionMinX = RegionCellX * RegionSize; + RegionMinY = RegionCellY * RegionSize; + } + const float CacheExpansion = FMath::Abs(P.CaveWarpStrength) + * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + + (bUseTileCacheWindow ? static_cast(SampleStep) : 0.0f) + + 2.0f; const TArray* TerrainOps = nullptr; if (LiveManager) @@ -4165,17 +4186,16 @@ namespace && S.SharedCacheEntry->RegionSize == RegionSize; if (!bSharedCacheMatches) { - const float Expansion = FMath::Abs(P.CaveWarpStrength) - * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + 2.0f; S.SharedCacheEntry = FindOrBuildSharedRoomGraphCache( SeedU, StrateIdx, ParamsFingerprint, LayoutVersion, ManagerLifetimeId, TerrainOps, RegionMinX, RegionMinY, RegionSize, - (float)RegionMinX - Expansion, - (float)RegionMinY - Expansion, - (float)(RegionMinX + RegionSize) + Expansion, - (float)(RegionMinY + RegionSize) + Expansion, - P); + (float)RegionMinX - CacheExpansion, + (float)RegionMinY - CacheExpansion, + (float)(RegionMinX + RegionSize) + CacheExpansion, + (float)(RegionMinY + RegionSize) + CacheExpansion, + P, + ERoomGraphBuildSite::OpShared); } S.ActiveCache = S.SharedCacheEntry->Cache.Get(); } @@ -4199,11 +4219,6 @@ namespace if (!bCacheMatches) { bRebuiltLocalCache = true; - if (VoxelDensityProfile::AreCountersEnabled()) - { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::SdfCacheBuild); - } LocalCache->bValid = false; LocalCache->Seed = SeedU; LocalCache->StrateIndex = StrateIdx; @@ -4214,15 +4229,14 @@ namespace LocalCache->RegionMinX = RegionMinX; LocalCache->RegionMinY = RegionMinY; LocalCache->RegionSize = RegionSize; - const float Expansion = FMath::Abs(P.CaveWarpStrength) - * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + 2.0f; VoxelCaveMorphology::BuildChunkCache( LocalCache->Cache, - (float)RegionMinX - Expansion, - (float)RegionMinY - Expansion, - (float)(RegionMinX + RegionSize) + Expansion, - (float)(RegionMinY + RegionSize) + Expansion, - P, SeedU, StrateIdx, TerrainOps); + (float)RegionMinX - CacheExpansion, + (float)RegionMinY - CacheExpansion, + (float)(RegionMinX + RegionSize) + CacheExpansion, + (float)(RegionMinY + RegionSize) + CacheExpansion, + P, SeedU, StrateIdx, TerrainOps, + ERoomGraphBuildSite::OpLocal); LocalCache->bValid = true; VoxelDensityOps::ReportWorkerRoomGraphCacheFootprint(); } @@ -4245,16 +4259,17 @@ namespace // mais à des coordonnées NON warpées. Sous un modèle de frames il aurait fallu les sortir // du frame tout en gardant le canal — exprimable, mais tordu. Dans un opérateur unique la // difficulté disparaît : le warp est une variable locale, pas un contexte hérité. - for (const FCachedPit& Pit : GetCache().Pits) + auto EvaluatePit = [&](int32 PitIndex) { + const FCachedPit& Pit = GetCache().Pits[PitIndex]; const float DZ = WorldZ - Pit.TopZ; - if (DZ >= Pit.BlendK) { continue; } - if (-DZ > Pit.Depth + Pit.BlendK) { continue; } + if (DZ >= Pit.BlendK) { return; } + if (-DZ > Pit.Depth + Pit.BlendK) { return; } const float DX = WorldX - Pit.CenterX; const float DY = WorldY - Pit.CenterY; const float XYDistSq = DX * DX + DY * DY; - if (XYDistSq > Pit.BoundXYRadiusSq) { continue; } + if (XYDistSq > Pit.BoundXYRadiusSq) { return; } float PitSDF; if (DZ <= 0.0f) @@ -4271,18 +4286,22 @@ namespace } CaveSDF = VoxelSDF::SmoothMin(CaveSDF, PitSDF, Pit.BlendK); - } + }; + VF_ForEachChunkSDFSpatialCandidate( + GetCache().PitSpatialIndex, GetCache().Pits.Num(), + WorldX, WorldY, EvaluatePit); - for (const FCachedChimney& Chim : GetCache().Chimneys) + auto EvaluateChimney = [&](int32 ChimneyIndex) { + const FCachedChimney& Chim = GetCache().Chimneys[ChimneyIndex]; const float DZ = WorldZ - Chim.BottomZ; - if (-DZ >= Chim.BlendK) { continue; } - if (DZ > Chim.Height + Chim.BlendK) { continue; } + if (-DZ >= Chim.BlendK) { return; } + if (DZ > Chim.Height + Chim.BlendK) { return; } const float DX = WorldX - Chim.CenterX; const float DY = WorldY - Chim.CenterY; const float XYDistSq = DX * DX + DY * DY; - if (XYDistSq > Chim.BoundXYRadiusSq) { continue; } + if (XYDistSq > Chim.BoundXYRadiusSq) { return; } float ChmSDF; if (DZ >= 0.0f) @@ -4298,7 +4317,10 @@ namespace } CaveSDF = VoxelSDF::SmoothMin(CaveSDF, ChmSDF, Chim.BlendK); - } + }; + VF_ForEachChunkSDFSpatialCandidate( + GetCache().ChimneySpatialIndex, GetCache().Chimneys.Num(), + WorldX, WorldY, EvaluateChimney); InOut.Sdf = CaveSDF; @@ -4388,13 +4410,16 @@ namespace const TArray* CacheWindowTerrainOps = nullptr; bool CacheWindowUsesLatticeProof = false; bool CacheWindowTightenWarpProof = false; - FIntVector CacheWindowLatticeOrigin = FIntVector::ZeroValue; int32 CacheWindowLatticeStep = 1; TSharedPtr CacheWindowSharedEntry; int32 CacheWindowSharedRegionMinX = 0; int32 CacheWindowSharedRegionMinY = 0; int32 CacheWindowSharedRegionSize = 0; bool bCacheWindowUsesShared = false; + bool CacheWindowUsesTileCacheWindow = false; + FIntVector CacheWindowTileOrigin = FIntVector::ZeroValue; + int32 CacheWindowTileStep = 0; + int32 CacheWindowTileCells = 0; bool bCacheWindowValid = false; // Tight warp bounds are proofs over a box. Refinement asks about nested child boxes, @@ -4659,6 +4684,10 @@ namespace B.CacheWindowSharedRegionMinY = 0; B.CacheWindowSharedRegionSize = 0; B.bCacheWindowUsesShared = false; + B.CacheWindowUsesTileCacheWindow = false; + B.CacheWindowTileOrigin = FIntVector::ZeroValue; + B.CacheWindowTileStep = 0; + B.CacheWindowTileCells = 0; B.bCacheWindowValid = false; B.bTightWarpEnvelopeValid = false; B.bMayHaveTunnelCoreAir = false; @@ -4753,6 +4782,45 @@ namespace return; } + FIntVector RequestedTileOrigin = FIntVector::ZeroValue; + int32 RequestedTileStep = FMath::Max(Ctx.Step, 1); + int32 RequestedTileCells = 0; + const bool bUseTileCacheWindow = VoxelGenLOD::IsTileCacheWindowEnabled() + && VoxelGenLOD::GetThreadTileCacheWindow( + RequestedTileOrigin, RequestedTileStep, RequestedTileCells) + && VoxelCaveMorphology::IsRoomGraphWindowInvariant(P); + const float RequestedTileExtent = static_cast( + static_cast(RequestedTileStep) + * static_cast(RequestedTileCells)); + // FillChunk includes one sample-step of mesher halo around the requested tile. + // Keep the tile footprint as the shared-cache identity, but let every block in that + // halo reuse the same cache window. The morphology search below includes the same + // coverage, so this widens reuse without changing the set of values evaluated. + const FBox CacheWindowCoverageBox = bUseTileCacheWindow + ? FBox( + FVector( + static_cast(RequestedTileOrigin.X) + - static_cast(RequestedTileStep), + static_cast(RequestedTileOrigin.Y) + - static_cast(RequestedTileStep), + static_cast(RequestedTileOrigin.Z) + - static_cast(RequestedTileStep)), + FVector( + static_cast(RequestedTileOrigin.X) + RequestedTileExtent, + static_cast(RequestedTileOrigin.Y) + RequestedTileExtent, + static_cast(RequestedTileOrigin.Z) + RequestedTileExtent) + + FVector( + static_cast(RequestedTileStep), + static_cast(RequestedTileStep), + static_cast(RequestedTileStep))) + : VoxelBox; + const int64 RequestedTileChunkSpan = bUseTileCacheWindow + ? FMath::Max( + 1, + (static_cast(RequestedTileExtent) + CHUNK_SIZE - 1) + / CHUNK_SIZE) + : 0; + // A coarse tile is allowed to warm the proof, but never by allocating a morphology // cache for the whole tile. At LOD3/4 the root can cover hundreds or thousands of // chunk keys; that is a proof-domain size, not a useful working set. Descendants @@ -4760,7 +4828,10 @@ namespace // is deliberately the only answer here: it cannot authorize an empty-tile skip. constexpr int64 MaxLatticeProofChunkVolume = 512; if (Ctx.bUseLatticeProof - && SpanX * SpanY * SpanZ > MaxLatticeProofChunkVolume) + && SpanX * SpanY * SpanZ > MaxLatticeProofChunkVolume + && !(bUseTileCacheWindow + && RequestedTileChunkSpan <= 64 + && SpanZ <= 16)) { Unknown(); return; @@ -4906,6 +4977,47 @@ namespace return; } } + // A tile cache is a graph cache, not a proof-result cache. Its search envelope must + // therefore be identical for the root and every block/child that asks about the + // tile. In particular, a finite tight warp envelope is allowed to sharpen the + // interval proof below, but it must not make the first classifier block publish a + // smaller morphology cache for later blocks to reuse. + const float CacheWarpX = bUseTileCacheWindow + ? GlobalWarp : FMath::Max((float)WarpEnvelope.X, 0.0f); + const float CacheWarpY = bUseTileCacheWindow + ? GlobalWarp : FMath::Max((float)WarpEnvelope.Y, 0.0f); + const float CacheWarpZ = bUseTileCacheWindow + ? GlobalWarp : FMath::Max((float)WarpEnvelope.Z, 0.0f); + const float CacheSearchMinX = bUseTileCacheWindow + ? static_cast(RequestedTileOrigin.X) + - CacheWarpX + - static_cast(RequestedTileStep) - 2.0f + : BoxMinX - CacheWarpX - 2.0f; + const float CacheSearchMinY = bUseTileCacheWindow + ? static_cast(RequestedTileOrigin.Y) + - CacheWarpY + - static_cast(RequestedTileStep) - 2.0f + : BoxMinY - CacheWarpY - 2.0f; + const float CacheSearchMaxX = bUseTileCacheWindow + ? static_cast(RequestedTileOrigin.X) + RequestedTileExtent + + CacheWarpX + + static_cast(RequestedTileStep) + 2.0f + : BoxMaxX + CacheWarpX + 2.0f; + const float CacheSearchMaxY = bUseTileCacheWindow + ? static_cast(RequestedTileOrigin.Y) + RequestedTileExtent + + CacheWarpY + + static_cast(RequestedTileStep) + 2.0f + : BoxMaxY + CacheWarpY + 2.0f; + const float CacheSearchMinZ = bUseTileCacheWindow + ? static_cast(RequestedTileOrigin.Z) + - CacheWarpZ + - static_cast(RequestedTileStep) - 2.0f + : BoxMinZ - CacheWarpZ - 2.0f; + const float CacheSearchMaxZ = bUseTileCacheWindow + ? static_cast(RequestedTileOrigin.Z) + RequestedTileExtent + + CacheWarpZ + + static_cast(RequestedTileStep) + 2.0f + : BoxMaxZ + CacheWarpZ + 2.0f; // The search window is a superset of every warped point in the box, with the same // two-voxel gradient margin used by Eval's cache path. In lattice mode, retain the // first/root window and reuse it for every refined child. A larger morphology @@ -4920,6 +5032,11 @@ namespace && B.CacheWindowManagerLifetimeId == ManagerLifetimeId && B.CacheWindowTerrainOps == TerrainOps && B.CacheWindowUsesLatticeProof == Ctx.bUseLatticeProof + && B.CacheWindowUsesTileCacheWindow == bUseTileCacheWindow + && (!bUseTileCacheWindow + || (B.CacheWindowTileOrigin == RequestedTileOrigin + && B.CacheWindowTileStep == RequestedTileStep + && B.CacheWindowTileCells == RequestedTileCells)) // A non-tight cache is built from the global warp envelope and is therefore a // superset cache for a later tight retry. The reverse is not safe: a tight cache // may omit primitives needed by the conservative global-envelope query. @@ -4928,9 +5045,11 @@ namespace // envelope cache. A global query must not reuse a tight cache. || Ctx.bTightenWarpProof || !B.CacheWindowTightenWarpProof) + // The graph window is independent of which block's lattice origin requested it; + // retain only the step because it controls the conservative finite-envelope gate + // for non-tile proof windows. && (!Ctx.bUseLatticeProof - || (B.CacheWindowLatticeOrigin == Ctx.LatticeOriginVoxels - && B.CacheWindowLatticeStep == Ctx.Step)) + || B.CacheWindowLatticeStep == Ctx.Step) && (!B.bCacheWindowUsesShared || IsSharedCacheCurrent()) && B.CacheWindowBox.Min.X <= BoxMinX && B.CacheWindowBox.Min.Y <= BoxMinY @@ -4960,15 +5079,17 @@ namespace // primitives to the interval. const bool bFeatureFree = !VoxelCaveMorphology::MayHaveFeatureInSearchBox( - BoxMinX - (float)WarpEnvelope.X - 2.0f, - BoxMinY - (float)WarpEnvelope.Y - 2.0f, - BoxMaxX + (float)WarpEnvelope.X + 2.0f, - BoxMaxY + (float)WarpEnvelope.Y + 2.0f, + CacheSearchMinX, + CacheSearchMinY, + CacheSearchMaxX, + CacheSearchMaxY, P, SeedU, StrateIdx, true, - BoxMinZ - (float)WarpEnvelope.Z - 2.0f, - BoxMaxZ + (float)WarpEnvelope.Z + 2.0f); - bool bUsedSharedCache = false; + CacheSearchMinZ, + CacheSearchMaxZ); const int64 MaxSharedChunkSpan = Ctx.Step >= 32 ? 32 : 16; + bool bUsedSharedCache = false; + const bool bTileWithinSharedBudget = bUseTileCacheWindow + && RequestedTileChunkSpan <= 64; if (!bFeatureFree && Ctx.bUseLatticeProof // Fine LOD0/LOD1 requests are latency-sensitive and each worker already has @@ -4977,16 +5098,27 @@ namespace // build itself is cheap). Coarse samples keep the shared window, where its // larger footprint prevents thousands of per-chunk rebuilds. && Ctx.Step > 1 - && SpanX <= MaxSharedChunkSpan - && SpanY <= MaxSharedChunkSpan) + && (bTileWithinSharedBudget + || (SpanX <= MaxSharedChunkSpan + && SpanY <= MaxSharedChunkSpan))) { - // Coarser LODs retain the four-chunk working set needed to amortise their - // wider sample footprint. Fine LODs take the worker-local path above. - int32 SharedRegionSize = 4 * CHUNK_SIZE; - int32 SharedRegionMinX = 0; - int32 SharedRegionMinY = 0; - bool bCoversBox = false; - for (int32 Attempt = 0; Attempt < 6; ++Attempt) + // Coarser LODs retain a shared immutable cache. With the tile policy its key + // is the exact tile footprint; the fallback keeps the historical expanding + // four-chunk working set for point/non-tile callers. + int32 SharedRegionSize = bUseTileCacheWindow + ? static_cast(RequestedTileExtent) + : 4 * CHUNK_SIZE; + int32 SharedRegionMinX = bUseTileCacheWindow + ? RequestedTileOrigin.X : 0; + int32 SharedRegionMinY = bUseTileCacheWindow + ? RequestedTileOrigin.Y : 0; + bool bCoversBox = bUseTileCacheWindow + ? (CacheWindowCoverageBox.Min.X <= BoxMinX + && CacheWindowCoverageBox.Min.Y <= BoxMinY + && CacheWindowCoverageBox.Max.X >= BoxMaxX + && CacheWindowCoverageBox.Max.Y >= BoxMaxY) + : false; + for (int32 Attempt = 0; !bUseTileCacheWindow && Attempt < 6; ++Attempt) { SharedRegionMinX = FMath::FloorToInt( BoxMinX / (float)SharedRegionSize) * SharedRegionSize; @@ -4999,21 +5131,37 @@ namespace } if (bCoversBox - && SharedRegionSize - <= static_cast(MaxSharedChunkSpan) * CHUNK_SIZE) + && (bUseTileCacheWindow + || SharedRegionSize + <= static_cast(MaxSharedChunkSpan) * CHUNK_SIZE)) { - const float Expansion = FMath::Abs(P.CaveWarpStrength) - * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + 2.0f; TSharedPtr SharedCache = FindOrBuildSharedRoomGraphCache( SeedU, StrateIdx, ParamsFingerprint, LayoutVersion, ManagerLifetimeId, TerrainOps, SharedRegionMinX, SharedRegionMinY, SharedRegionSize, - (float)SharedRegionMinX - Expansion, - (float)SharedRegionMinY - Expansion, - (float)(SharedRegionMinX + SharedRegionSize) + Expansion, - (float)(SharedRegionMinY + SharedRegionSize) + Expansion, - P); + bUseTileCacheWindow + ? CacheSearchMinX + : (float)SharedRegionMinX + - (FMath::Abs(P.CaveWarpStrength) + * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + 2.0f), + bUseTileCacheWindow + ? CacheSearchMinY + : (float)SharedRegionMinY + - (FMath::Abs(P.CaveWarpStrength) + * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + 2.0f), + bUseTileCacheWindow + ? CacheSearchMaxX + : (float)(SharedRegionMinX + SharedRegionSize) + + (FMath::Abs(P.CaveWarpStrength) + * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + 2.0f), + bUseTileCacheWindow + ? CacheSearchMaxY + : (float)(SharedRegionMinY + SharedRegionSize) + + (FMath::Abs(P.CaveWarpStrength) + * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + 2.0f), + P, + ERoomGraphBuildSite::ClassifierShared); B.ActiveCache = SharedCache->Cache.Get(); B.CacheWindowSharedEntry = SharedCache; B.CacheWindowSharedRegionMinX = SharedRegionMinX; @@ -5037,16 +5185,17 @@ namespace { VoxelCaveMorphology::BuildChunkCache( B.Cache, - BoxMinX - (float)WarpEnvelope.X - 2.0f, - BoxMinY - (float)WarpEnvelope.Y - 2.0f, - BoxMaxX + (float)WarpEnvelope.X + 2.0f, - BoxMaxY + (float)WarpEnvelope.Y + 2.0f, - P, SeedU, StrateIdx, TerrainOps); + CacheSearchMinX, + CacheSearchMinY, + CacheSearchMaxX, + CacheSearchMaxY, + P, SeedU, StrateIdx, TerrainOps, + ERoomGraphBuildSite::ClassifierLocal); } B.ActiveCache = &B.Cache; } - B.CacheWindowBox = VoxelBox; + B.CacheWindowBox = CacheWindowCoverageBox; B.CacheWindowStrate = StrateIdx; B.CacheWindowSeed = SeedU; B.CacheWindowFingerprint = ParamsFingerprint; @@ -5054,9 +5203,15 @@ namespace B.CacheWindowManagerLifetimeId = ManagerLifetimeId; B.CacheWindowTerrainOps = TerrainOps; B.CacheWindowUsesLatticeProof = Ctx.bUseLatticeProof; - B.CacheWindowTightenWarpProof = bUsedSharedCache + B.CacheWindowUsesTileCacheWindow = bUseTileCacheWindow; + B.CacheWindowTileOrigin = bUseTileCacheWindow + ? RequestedTileOrigin : FIntVector::ZeroValue; + B.CacheWindowTileStep = bUseTileCacheWindow ? RequestedTileStep : 0; + B.CacheWindowTileCells = bUseTileCacheWindow ? RequestedTileCells : 0; + B.CacheWindowTightenWarpProof = bUseTileCacheWindow + ? false + : bUsedSharedCache ? false : Ctx.bTightenWarpProof; - B.CacheWindowLatticeOrigin = Ctx.LatticeOriginVoxels; B.CacheWindowLatticeStep = Ctx.Step; B.bCacheWindowValid = true; B.CacheWindowCycles = FPlatformTime::Cycles64() - CacheWindowStartCycles; @@ -7887,12 +8042,13 @@ namespace if (!VF_NearCaveSurface(InOut.Sdf, P.SDFBlendRadius)) { return; } if (Rooms == nullptr) { return; } - for (const FCachedColumn& Col : Rooms->GetCache().Columns) + auto EvaluateColumn = [&](int32 ColumnIndex) { + const FCachedColumn& Col = Rooms->GetCache().Columns[ColumnIndex]; const float DX = WorldX - Col.CenterX; const float DY = WorldY - Col.CenterY; const float XYDistSq = DX * DX + DY * DY; - if (XYDistSq > Col.BoundXYRadiusSq) { continue; } + if (XYDistSq > Col.BoundXYRadiusSq) { return; } const float CylSDF = FMath::Sqrt(XYDistSq) - Col.Radius; @@ -7903,7 +8059,10 @@ namespace Fill = SmoothStep01(Fill); InOut.Density += Fill * Col.BaseDensity * 1.5f; } - } + }; + VF_ForEachChunkSDFSpatialCandidate( + Rooms->GetCache().ColumnSpatialIndex, + Rooms->GetCache().Columns.Num(), WorldX, WorldY, EvaluateColumn); } /** The cached column list is the complete source of this op's work. A valid empty list is diff --git a/Source/VoxelForge/Private/VoxelDensityProfile.cpp b/Source/VoxelForge/Private/VoxelDensityProfile.cpp index cf3ac41..d50b3f5 100644 --- a/Source/VoxelForge/Private/VoxelDensityProfile.cpp +++ b/Source/VoxelForge/Private/VoxelDensityProfile.cpp @@ -644,6 +644,13 @@ namespace VoxelDensityProfile case ECounter::TunnelCacheHit: return TEXT("TunnelCacheHit"); case ECounter::TunnelCacheMiss: return TEXT("TunnelCacheMiss"); case ECounter::SdfCacheBuild: return TEXT("SdfCacheBuild"); + case ECounter::RoomGraphBuildGeneratorTile: return TEXT("RoomGraphBuildGeneratorTile"); + case ECounter::RoomGraphBuildGeneratorTunnelCore: return TEXT("RoomGraphBuildGeneratorTunnelCore"); + case ECounter::RoomGraphBuildOpShared: return TEXT("RoomGraphBuildOpShared"); + case ECounter::RoomGraphBuildOpLocal: return TEXT("RoomGraphBuildOpLocal"); + case ECounter::RoomGraphBuildClassifierShared: return TEXT("RoomGraphBuildClassifierShared"); + case ECounter::RoomGraphBuildClassifierLocal: return TEXT("RoomGraphBuildClassifierLocal"); + case ECounter::RoomGraphBuildUnknown: return TEXT("RoomGraphBuildUnknown"); case ECounter::CaveRoomCandidates:return TEXT("CaveRoomCandidates"); case ECounter::CaveRoomEvaluated: return TEXT("CaveRoomEvaluated"); case ECounter::CaveTunnelCandidates:return TEXT("CaveTunnelCandidates"); diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 4b63cc3..54a7827 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -56,6 +56,32 @@ namespace } } + // A tile-sized cache window is the production default. Keep the switch in the generator + // module so native and op-stack sources make the same A/B decision, including when a + // headless harness applies command-line CVars after module startup. + int32 GVoxelForgeTileCacheWindow = 1; + FAutoConsoleVariableRef CVarVoxelForgeTileCacheWindow( + TEXT("voxel.TileCacheWindow"), + GVoxelForgeTileCacheWindow, + TEXT("Use the requesting tile's aligned footprint plus the deterministic warp/halo margin for room caches.")); + bool GVoxelForgeTileCacheWindowSwitchParsed = false; + + void VF_ParseTileCacheWindowSwitch() + { + if (GVoxelForgeTileCacheWindowSwitchParsed) + { + return; + } + GVoxelForgeTileCacheWindowSwitchParsed = true; + int32 CommandLineValue = GVoxelForgeTileCacheWindow; + if (FParse::Value( + FCommandLine::Get(), TEXT("voxel.TileCacheWindow="), CommandLineValue)) + { + GVoxelForgeTileCacheWindow = CommandLineValue; + } + GVoxelForgeTileCacheWindow = GVoxelForgeTileCacheWindow != 0 ? 1 : 0; + } + } //============================================================================= @@ -411,6 +437,8 @@ struct FVoxelDensityBlockSession static thread_local FVoxelDensityBlockSession GVoxelDensityBlockSession; thread_local int32 VoxelGenLOD::SampleStep = 1; +thread_local FIntVector VoxelGenLOD::TileOriginVoxels = FIntVector::ZeroValue; +thread_local int32 VoxelGenLOD::TileCellsPerAxis = 0; int32 VoxelGenLOD::GetThreadOctaveBias() { @@ -432,6 +460,31 @@ void VoxelGenLOD::SetThreadSampleStep(int32 Value) SampleStep = Value; } +bool VoxelGenLOD::IsTileCacheWindowEnabled() +{ + VF_ParseTileCacheWindowSwitch(); + return GVoxelForgeTileCacheWindow != 0; +} + +bool VoxelGenLOD::GetThreadTileCacheWindow( + FIntVector& OutOriginVoxels, int32& OutStep, int32& OutCellsPerAxis) +{ + OutOriginVoxels = TileOriginVoxels; + OutStep = FMath::Max(SampleStep, 1); + OutCellsPerAxis = TileCellsPerAxis; + if (TileCellsPerAxis <= 0) + { + return false; + } + const int64 Extent = static_cast(OutStep) + * static_cast(OutCellsPerAxis); + // The graph build is quadratic in the collected room count. Keep the tile policy bounded + // to a 64-chunk side; the field's LOD4 tile is 32 chunks, while an accidentally giant request + // falls back to the older bounded window instead of turning one worker into an unbounded bake. + return Extent > 0 && Extent <= MAX_int32 + && Extent <= static_cast(CHUNK_SIZE) * 64; +} + // NOTE (T2.a): the fBm/Ridged bodies moved to VoxelNoise.h, where octaves are evaluated // 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, @@ -1175,8 +1228,15 @@ namespace uint64 OwnerId = 0; uint64 ManagerLifetimeId = 0; FIntVector Chunk = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); + FIntVector TileOrigin = FIntVector::ZeroValue; + FIntVector SupportChunk = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); + int32 TileStep = 0; + int32 TileCells = 0; + int32 StrateIndex = INT32_MIN; + uint32 WorldSeed = 0xFFFFFFFFu; uint32 ParamsFingerprint = 0xFFFFFFFFu; uint32 LayoutVersion = 0xFFFFFFFFu; + bool bUsesTileCacheWindow = false; bool bValid = false; }; @@ -1217,6 +1277,7 @@ namespace uint32 TunnelFingerprint = 0xFFFFFFFFu; FStrateDisturbanceParams Disturbance; bool bUseOpStack = false; + bool bUsesTileCacheWindow = false; FVoxelOpStack OpStack; FTunnelCoreCacheState TunnelCore; }; @@ -1289,13 +1350,42 @@ namespace uint64 OwnerId, FTunnelCoreCacheState& OutState) { - if (OutState.bValid + FIntVector RequestedTileOrigin = FIntVector::ZeroValue; + int32 RequestedTileStep = 1; + int32 RequestedTileCells = 0; + const bool bUseTileCacheWindow = VoxelGenLOD::IsTileCacheWindowEnabled() + && VoxelGenLOD::GetThreadTileCacheWindow( + RequestedTileOrigin, RequestedTileStep, RequestedTileCells) + && VoxelCaveMorphology::IsRoomGraphWindowInvariant(Params); + const int32 StrateIndex = Manager.GetStrateIndex( + (static_cast(ChunkCoord.Z) + 0.5f) + * static_cast(CHUNK_SIZE) * VOXEL_SIZE); + const uint32 WorldSeed = static_cast(Manager.GetWorldSeed()); + + const bool bSameGraphWindow = OutState.bValid && OutState.OwnerId == OwnerId && OutState.ManagerLifetimeId == ManagerLifetimeId - && OutState.Chunk == ChunkCoord + && OutState.StrateIndex == StrateIndex + && OutState.WorldSeed == WorldSeed && OutState.ParamsFingerprint == ParamsFingerprint - && OutState.LayoutVersion == LayoutVersion) + && OutState.LayoutVersion == LayoutVersion + && OutState.bUsesTileCacheWindow == bUseTileCacheWindow + && (!bUseTileCacheWindow + ? OutState.Chunk == ChunkCoord + : (OutState.TileOrigin == RequestedTileOrigin + && OutState.TileStep == RequestedTileStep + && OutState.TileCells == RequestedTileCells)); + if (bSameGraphWindow) { + // The graph is shared by the whole tile, but the sparse support table is deliberately + // kept for the currently queried exact chunk. Rebuild only that small table when the + // density cursor crosses a chunk; the room/tunnel graph itself is not rebuilt. + if (OutState.SupportChunk != ChunkCoord) + { + BuildTunnelSupportColumnsForChunk(OutState.Cache, ChunkCoord); + OutState.SupportChunk = ChunkCoord; + } + OutState.Chunk = ChunkCoord; return; } @@ -1303,25 +1393,48 @@ 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::Abs(Params.CaveWarpStrength) + const float WarpMargin = 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); - + float SearchMinX = ChunkMinX - WarpMargin; + float SearchMinY = ChunkMinY - WarpMargin; + float SearchMaxX = ChunkMaxX + WarpMargin; + float SearchMaxY = ChunkMaxY + WarpMargin; + if (bUseTileCacheWindow) + { + const float TileMinX = static_cast(RequestedTileOrigin.X); + const float TileMinY = static_cast(RequestedTileOrigin.Y); + const float TileExtent = static_cast( + static_cast(RequestedTileStep) + * static_cast(RequestedTileCells)); + const float TileHalo = static_cast(RequestedTileStep) + 2.0f; + const float Expansion = FMath::Abs(Params.CaveWarpStrength) + * VOXEL_NOISE_SCALE * 1.5f + TileHalo; + SearchMinX = TileMinX - Expansion; + SearchMinY = TileMinY - Expansion; + SearchMaxX = TileMinX + TileExtent + Expansion; + SearchMaxY = TileMinY + TileExtent + Expansion; + } VoxelCaveMorphology::BuildChunkCache( OutState.Cache, - ChunkMinX - Expansion, ChunkMinY - Expansion, - ChunkMaxX + Expansion, ChunkMaxY + Expansion, - Params, static_cast(Manager.GetWorldSeed()), - StrateIndex, nullptr); + SearchMinX, SearchMinY, SearchMaxX, SearchMaxY, + Params, WorldSeed, + StrateIndex, nullptr, + ERoomGraphBuildSite::GeneratorTunnelCore); BuildTunnelSupportColumnsForChunk( OutState.Cache, ChunkCoord); OutState.OwnerId = OwnerId; OutState.ManagerLifetimeId = ManagerLifetimeId; OutState.Chunk = ChunkCoord; + OutState.TileOrigin = bUseTileCacheWindow + ? RequestedTileOrigin : FIntVector::ZeroValue; + OutState.TileStep = bUseTileCacheWindow ? RequestedTileStep : 0; + OutState.TileCells = bUseTileCacheWindow ? RequestedTileCells : 0; + OutState.SupportChunk = ChunkCoord; + OutState.StrateIndex = StrateIndex; + OutState.WorldSeed = WorldSeed; OutState.ParamsFingerprint = ParamsFingerprint; OutState.LayoutVersion = LayoutVersion; + OutState.bUsesTileCacheWindow = bUseTileCacheWindow; OutState.bValid = true; } @@ -1689,6 +1802,12 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co && StrateManager->UsesOperatorStackForChunk(ChunkCoord); const FIntVector TunnelCacheKey = bShareTunnelStackByZ ? FIntVector(0, 0, ChunkCoord.Z) : ChunkCoord; + FIntVector RequestedTileOrigin = FIntVector::ZeroValue; + int32 RequestedTileStep = 1; + int32 RequestedTileCells = 0; + const bool bTileCacheContext = VoxelGenLOD::IsTileCacheWindowEnabled() + && VoxelGenLOD::GetThreadTileCacheWindow( + RequestedTileOrigin, RequestedTileStep, RequestedTileCells); FVoxelOpStack* ActiveOpStack = &CP_OpStack; FTunnelCoreCacheState* ActiveTunnelCoreCache = >unnelCoreCache; bool bLoadedTunnelDensityCache = false; @@ -1717,6 +1836,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co && Candidate.Chunk == TunnelCacheKey && Candidate.LayoutVersion == LayoutVersion && Candidate.bUseOpStack == bShareTunnelStackByZ + && Candidate.bUsesTileCacheWindow == bTileCacheContext && (Candidate.GenType == ECaveGeneratorType::TunnelNetwork || Candidate.GenType == ECaveGeneratorType::Underwater); if (bKeyMatches) @@ -1998,9 +2118,15 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co || CP_GenType == ECaveGeneratorType::Underwater); #if WITH_EDITOR const bool bCanStoreTunnelDensityCache = bNativeTunnelCacheCandidate - && !CP_UseComposerRegions; + && !CP_UseComposerRegions + // A native direct core uses the worker-global state below. Keeping it in one + // per-exact-chunk entry would move the tile graph out and force the next chunk to + // rebuild it. Operator stacks do not prepare this native core, so they retain the + // existing prepared-stack cache even when the tile context is active. + && (!bTileCacheContext || CP_UseOpStack); #else - const bool bCanStoreTunnelDensityCache = bNativeTunnelCacheCandidate; + const bool bCanStoreTunnelDensityCache = bNativeTunnelCacheCandidate + && (!bTileCacheContext || CP_UseOpStack); #endif if (!bLoadedTunnelDensityCache && bCanStoreTunnelDensityCache) { @@ -2017,6 +2143,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co CachedEntry.TunnelFingerprint = CP_TunnelFP; CachedEntry.Disturbance = CP_Dist; CachedEntry.bUseOpStack = CP_UseOpStack; + CachedEntry.bUsesTileCacheWindow = bTileCacheContext; CachedEntry.OpStack = MoveTemp(CP_OpStack); CachedEntry.TunnelCore = MoveTemp(GTunnelCoreCache); GTunnelCoreCache = FTunnelCoreCacheState(); @@ -2698,8 +2825,46 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo thread_local uint32 CachedFingerprint = 0xFFFFFFFFu; thread_local uint32 CachedLayout = 0xFFFFFFFFu; thread_local bool CachedUsesFusedCacheWindow = false; + thread_local bool CachedUsesTileCacheWindow = false; + thread_local FIntVector CachedTileOrigin = FIntVector::ZeroValue; + thread_local int32 CachedTileStep = 0; + thread_local int32 CachedTileCells = 0; + thread_local uint64 CachedManagerLifetimeId = 0; + thread_local const TArray* CachedTerrainOps = nullptr; const bool bUseFusedCacheWindow = OutTunnelCore != nullptr; + FIntVector RequestedTileOrigin = FIntVector::ZeroValue; + int32 RequestedTileStep = 1; + int32 RequestedTileCells = 0; + const bool bUseTileCacheWindow = VoxelGenLOD::IsTileCacheWindowEnabled() + && VoxelGenLOD::GetThreadTileCacheWindow( + RequestedTileOrigin, RequestedTileStep, RequestedTileCells) + && VoxelCaveMorphology::IsRoomGraphWindowInvariant(Params); + const uint64 CurrentManagerLifetimeId = StrateManager + ? StrateManager->GetCacheLifetimeId() : 0; + const int32 QueryChunkX = FMath::FloorToInt(WorldX / (float)CHUNK_SIZE); + const int32 QueryChunkY = FMath::FloorToInt(WorldY / (float)CHUNK_SIZE); + const int32 QueryChunkZ = FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE); + const int32 CacheRegionChunks = bUseFusedCacheWindow ? 4 : 1; + const int32 CacheRegionSize = CHUNK_SIZE * CacheRegionChunks; + const int32 CacheRegionX = FMath::FloorToInt( + WorldX / static_cast(CacheRegionSize)); + const int32 CacheRegionY = FMath::FloorToInt( + WorldY / static_cast(CacheRegionSize)); + // BuildChunkCache bakes the selected immutable terrain-operation pool into each stored room. + // A tile may cross a strate-definition boundary in XY, so the pool identity is part of the + // cache key just like the generation-parameter fingerprint. The manager lifetime guards an + // allocator-reused definition address after a live layout/world change. + const TArray* TerrainOps = nullptr; + if (StrateManager) + { + UVoxelStrateDefinition* Def = StrateManager->GetStrateForChunk( + FIntVector( + bUseFusedCacheWindow ? QueryChunkX : CacheRegionX, + bUseFusedCacheWindow ? QueryChunkY : CacheRegionY, + QueryChunkZ)); + if (Def) TerrainOps = &Def->TerrainOperations; + } // Index of the room with the smallest (most-inside) SDF for this voxel. // Written by EvaluateSDFCached, read by the terrain ops block to pick the @@ -2739,57 +2904,66 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo const bool bNeedRebuild = StrateIdx != CachedStrate || (uint32)Seed != CachedSeed || ParamsFingerprint != CachedFingerprint || LayoutVersion != CachedLayout || + CurrentManagerLifetimeId != CachedManagerLifetimeId || + TerrainOps != CachedTerrainOps || bUseFusedCacheWindow != CachedUsesFusedCacheWindow || + bUseTileCacheWindow != CachedUsesTileCacheWindow || + (bUseTileCacheWindow + && (RequestedTileOrigin != CachedTileOrigin + || RequestedTileStep != CachedTileStep + || RequestedTileCells != CachedTileCells)) || WarpedX < CachedSMinX || WarpedX > CachedSMaxX || WarpedY < CachedSMinY || WarpedY > CachedSMaxY; if (bNeedRebuild) { // The lowered evaluator uses the same window-invariance contract as the prepared - // room-graph source, but keeps a four-chunk XY window per worker. A tile worker can - // visit neighbouring chunk keys in an arbitrary order; the larger deterministic - // window prevents those jumps from rebuilding the graph for every small tile while - // preserving the pointwise SDF. The public/reference call keeps its historical - // one-chunk window. - const int32 QueryChunkX = FMath::FloorToInt(WorldX / (float)CHUNK_SIZE); - const int32 QueryChunkY = FMath::FloorToInt(WorldY / (float)CHUNK_SIZE); - const int32 CacheRegionChunks = bUseFusedCacheWindow ? 4 : 1; - const int32 CacheRegionSize = CHUNK_SIZE * CacheRegionChunks; - const int32 CacheRegionX = FMath::FloorToInt( - WorldX / static_cast(CacheRegionSize)); - const int32 CacheRegionY = FMath::FloorToInt( - WorldY / static_cast(CacheRegionSize)); + // room-graph source. With the switch enabled, the search box is the exact requesting + // tile footprint plus the one-sample normal halo and deterministic warp margin. A + // tile worker can visit neighbouring chunk keys in an arbitrary order without + // rebuilding the graph at every coarse sample. Point queries without a tile context + // retain the historical fused four-chunk / ordinary one-chunk fallback. const float ChunkMinX = CacheRegionX * (float)CacheRegionSize; const float ChunkMinY = CacheRegionY * (float)CacheRegionSize; const float ChunkMaxX = ChunkMinX + (float)CacheRegionSize; const float ChunkMaxY = ChunkMinY + (float)CacheRegionSize; - const float Expansion = FMath::Abs(Params.CaveWarpStrength) - * VOXEL_NOISE_SCALE * 1.5f + 2.0f; - - const float SMinX = ChunkMinX - Expansion; - const float SMinY = ChunkMinY - Expansion; - const float SMaxX = ChunkMaxX + Expansion; - const float SMaxY = ChunkMaxY + Expansion; - - // Fetch the terrain op probability pool for this chunk's strate. - // BuildChunkCache uses this to hash-roll one terrain op per room. - const TArray* TerrainOps = nullptr; - if (StrateManager) + const float WarpMargin = FMath::Abs(Params.CaveWarpStrength) + * VOXEL_NOISE_SCALE * 1.5f; + float SMinX = ChunkMinX; + float SMinY = ChunkMinY; + float SMaxX = ChunkMaxX; + float SMaxY = ChunkMaxY; + if (bUseTileCacheWindow) { - const int32 ChunkZ = FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE); - UVoxelStrateDefinition* Def = StrateManager->GetStrateForChunk( - FIntVector( - bUseFusedCacheWindow ? QueryChunkX : CacheRegionX, - bUseFusedCacheWindow ? QueryChunkY : CacheRegionY, - ChunkZ)); - if (Def) TerrainOps = &Def->TerrainOperations; + const float TileMinX = static_cast(RequestedTileOrigin.X); + const float TileMinY = static_cast(RequestedTileOrigin.Y); + const float TileExtent = static_cast( + static_cast(RequestedTileStep) + * static_cast(RequestedTileCells)); + const float TileMaxX = TileMinX + TileExtent; + const float TileMaxY = TileMinY + TileExtent; + const float TileHalo = static_cast(RequestedTileStep) + 2.0f; + const float Expansion = WarpMargin + TileHalo; + SMinX = TileMinX - Expansion; + SMinY = TileMinY - Expansion; + SMaxX = TileMaxX + Expansion; + SMaxY = TileMaxY + Expansion; + } + else + { + const float Expansion = WarpMargin + 2.0f; + SMinX = ChunkMinX - Expansion; + SMinY = ChunkMinY - Expansion; + SMaxX = ChunkMaxX + Expansion; + SMaxY = ChunkMaxY + Expansion; } VoxelCaveMorphology::BuildChunkCache( SDFCache, SMinX, SMinY, SMaxX, SMaxY, Params, (uint32)Seed, StrateIdx, - TerrainOps + TerrainOps, + ERoomGraphBuildSite::GeneratorTile ); CachedSMinX = SMinX; CachedSMaxX = SMaxX; @@ -2799,6 +2973,13 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo CachedFingerprint = ParamsFingerprint; CachedLayout = LayoutVersion; CachedUsesFusedCacheWindow = bUseFusedCacheWindow; + CachedUsesTileCacheWindow = bUseTileCacheWindow; + CachedTileOrigin = bUseTileCacheWindow + ? RequestedTileOrigin : FIntVector::ZeroValue; + CachedTileStep = bUseTileCacheWindow ? RequestedTileStep : 0; + CachedTileCells = bUseTileCacheWindow ? RequestedTileCells : 0; + CachedManagerLifetimeId = CurrentManagerLifetimeId; + CachedTerrainOps = TerrainOps; } // Evaluate SDF using cached rooms and tunnels (WARPED coordinates). @@ -2824,16 +3005,17 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo // transition as tunnel-to-room (no hard seam at PitTopZ) // - bNearCaveSurface becomes true inside the shaft — roughness clamp // prevents fill-back, so this is safe - for (const FCachedPit& Pit : SDFCache.Pits) + auto EvaluatePit = [&](int32 PitIndex) { + const FCachedPit& Pit = SDFCache.Pits[PitIndex]; const float DZ = WorldZ - Pit.TopZ; // Negative = below anchor (shaft) - if (DZ >= Pit.BlendK) continue; // Above even the blend fringe - if (-DZ > Pit.Depth + Pit.BlendK) continue; + if (DZ >= Pit.BlendK) return; // Above even the blend fringe + if (-DZ > Pit.Depth + Pit.BlendK) return; - float DX = WorldX - Pit.CenterX; - float DY = WorldY - Pit.CenterY; - float XYDistSq = DX * DX + DY * DY; - if (XYDistSq > Pit.BoundXYRadiusSq) continue; + const float DX = WorldX - Pit.CenterX; + const float DY = WorldY - Pit.CenterY; + const float XYDistSq = DX * DX + DY * DY; + if (XYDistSq > Pit.BoundXYRadiusSq) return; float PitSDF; if (DZ <= 0.0f) @@ -2855,18 +3037,21 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo } CaveSDF = VoxelSDF::SmoothMin(CaveSDF, PitSDF, Pit.BlendK); - } + }; + VF_ForEachChunkSDFSpatialCandidate( + SDFCache.PitSpatialIndex, SDFCache.Pits.Num(), WorldX, WorldY, EvaluatePit); - for (const FCachedChimney& Chim : SDFCache.Chimneys) + auto EvaluateChimney = [&](int32 ChimneyIndex) { + const FCachedChimney& Chim = SDFCache.Chimneys[ChimneyIndex]; const float DZ = WorldZ - Chim.BottomZ; // Positive = above anchor (shaft) - if (-DZ >= Chim.BlendK) continue; // Below even the blend fringe - if (DZ > Chim.Height + Chim.BlendK) continue; + if (-DZ >= Chim.BlendK) return; // Below even the blend fringe + if (DZ > Chim.Height + Chim.BlendK) return; - float DX = WorldX - Chim.CenterX; - float DY = WorldY - Chim.CenterY; - float XYDistSq = DX * DX + DY * DY; - if (XYDistSq > Chim.BoundXYRadiusSq) continue; + const float DX = WorldX - Chim.CenterX; + const float DY = WorldY - Chim.CenterY; + const float XYDistSq = DX * DX + DY * DY; + if (XYDistSq > Chim.BoundXYRadiusSq) return; float ChmSDF; if (DZ >= 0.0f) @@ -2883,7 +3068,10 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo } CaveSDF = VoxelSDF::SmoothMin(CaveSDF, ChmSDF, Chim.BlendK); - } + }; + VF_ForEachChunkSDFSpatialCandidate( + SDFCache.ChimneySpatialIndex, SDFCache.Chimneys.Num(), + WorldX, WorldY, EvaluateChimney); // Convert SDF to density carving: // CaveSDF < 0 means we're inside a room/tunnel/pit → carve to air @@ -3413,14 +3601,15 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo // Columns are now pre-baked into SDFCache.Columns — no NearestRoomIdx needed. // The old per-voxel approach had columns appear/disappear mid-height when // the owning room changed (NearestRoomIdx switch). Pre-baking fixes this. - for (const FCachedColumn& Col : SDFCache.Columns) + auto EvaluateColumn = [&](int32 ColumnIndex) { - float DX = WorldX - Col.CenterX; - float DY = WorldY - Col.CenterY; - float XYDistSq = DX * DX + DY * DY; - if (XYDistSq > Col.BoundXYRadiusSq) continue; + const FCachedColumn& Col = SDFCache.Columns[ColumnIndex]; + const float DX = WorldX - Col.CenterX; + const float DY = WorldY - Col.CenterY; + const float XYDistSq = DX * DX + DY * DY; + if (XYDistSq > Col.BoundXYRadiusSq) return; - float CylSDF = FMath::Sqrt(XYDistSq) - Col.Radius; + const float CylSDF = FMath::Sqrt(XYDistSq) - Col.Radius; const float ColBlend = 3.0f; if (CylSDF < ColBlend) @@ -3429,7 +3618,9 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo Fill = SmoothStep01(Fill); Density += Fill * Col.BaseDensity * 1.5f; } - } + }; + VF_ForEachChunkSDFSpatialCandidate( + SDFCache.ColumnSpatialIndex, SDFCache.Columns.Num(), WorldX, WorldY, EvaluateColumn); //========================================================================= // STEP 4g: DOMES (room-relative hemispherical ceilings) @@ -5068,6 +5259,10 @@ EVoxelTileClass UVoxelGenerator::ClassifyTileUncached( // keyed by their actual lattice step. TGuardValue ClassifySampleStepGuard(VoxelGenLOD::SampleStep, Step); const int32 CPA = FMath::Clamp(CellsPerAxis, 2, CHUNK_SIZE); + TGuardValue ClassifyTileOriginGuard( + VoxelGenLOD::TileOriginVoxels, OriginVoxels); + TGuardValue ClassifyTileCellsGuard( + VoxelGenLOD::TileCellsPerAxis, CPA); const int32 GridDim = CPA + 1; // cell-vertex count: g ∈ [0, CPA] // The mesher's outer one-sample halo is used only for central-difference normals. It cannot diff --git a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp index b1f599a..d956e07 100644 --- a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp +++ b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp @@ -229,6 +229,10 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, const int32 CellsPerAxis = FMath::Clamp(InCellsPerAxis, 2, CHUNK_SIZE); // coarse tiles use fewer const int32 GridDim = CellsPerAxis + 1; const int32 MDim = GridDim + 2; // +1 marge de chaque côté + TGuardValue TileOriginGuard( + VoxelGenLOD::TileOriginVoxels, OriginVoxels); + TGuardValue TileCellsGuard( + VoxelGenLOD::TileCellsPerAxis, CellsPerAxis); // COUPE DE CONTENU PAR STRATE — restreint le maillage (et l'échantillonnage) aux cellules // dont l'intervalle Z chevauche la bande [BandZMinVox, BandZMaxVox] (voxels inclusifs). // Les tuiles à capture ne sont jamais bandées (niveau 0 — garde-fou ci-dessous). diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index e178941..4040d51 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -3859,7 +3859,12 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector "rooms=%d tunnels=%d joins=%d pits=%d chimneys=%d " "whole_mixed=%u whole_solid=%u whole_air=%u final_nodes=%u " "split_nodes=%u max_depth=%u block_builds=%llu block_samples=%llu " - "block_ops=%llu block_active=%llu block_pruned=%llu cache_builds=%llu"), + "block_ops=%llu block_active=%llu block_pruned=%llu cache_builds=%llu " + "cache_generator=%llu cache_tunnel_core=%llu cache_op_shared=%llu " + "cache_op_local=%llu cache_classifier_shared=%llu cache_classifier_local=%llu " + "cache_unknown=%llu room_candidates=%llu room_evaluated=%llu " + "tunnel_candidates=%llu tunnel_evaluated=%llu tunnel_core_candidates=%llu " + "tunnel_core_evaluated=%llu support_column_candidates=%llu"), Tile.Coord.X, Tile.Coord.Y, Tile.Coord.Z, Tile.Level, Step, Cells, bSheetTile ? 1 : 0, Result.bAborted ? 1 : 0, Result.bEmpty ? 1 : 0, ClassifyVerdict, ClassifySeconds, MeshSeconds, StreamSeconds, @@ -3887,7 +3892,21 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector static_cast(CounterDelta(VoxelDensityProfile::ECounter::OpBlockOperators)), static_cast(CounterDelta(VoxelDensityProfile::ECounter::OpBlockActiveOperators)), static_cast(CounterDelta(VoxelDensityProfile::ECounter::OpBlockPrunedOperators)), - static_cast(CounterDelta(VoxelDensityProfile::ECounter::SdfCacheBuild))); + static_cast(CounterDelta(VoxelDensityProfile::ECounter::SdfCacheBuild)), + static_cast(CounterDelta(VoxelDensityProfile::ECounter::RoomGraphBuildGeneratorTile)), + static_cast(CounterDelta(VoxelDensityProfile::ECounter::RoomGraphBuildGeneratorTunnelCore)), + static_cast(CounterDelta(VoxelDensityProfile::ECounter::RoomGraphBuildOpShared)), + static_cast(CounterDelta(VoxelDensityProfile::ECounter::RoomGraphBuildOpLocal)), + static_cast(CounterDelta(VoxelDensityProfile::ECounter::RoomGraphBuildClassifierShared)), + static_cast(CounterDelta(VoxelDensityProfile::ECounter::RoomGraphBuildClassifierLocal)), + static_cast(CounterDelta(VoxelDensityProfile::ECounter::RoomGraphBuildUnknown)), + static_cast(CounterDelta(VoxelDensityProfile::ECounter::CaveRoomCandidates)), + static_cast(CounterDelta(VoxelDensityProfile::ECounter::CaveRoomEvaluated)), + static_cast(CounterDelta(VoxelDensityProfile::ECounter::CaveTunnelCandidates)), + static_cast(CounterDelta(VoxelDensityProfile::ECounter::CaveTunnelEvaluated)), + static_cast(CounterDelta(VoxelDensityProfile::ECounter::TunnelCoreCandidates)), + static_cast(CounterDelta(VoxelDensityProfile::ECounter::TunnelCoreEvaluated)), + static_cast(CounterDelta(VoxelDensityProfile::ECounter::TunnelSupportColumnCandidates))); if (bProfileOps) { diff --git a/Source/VoxelForge/Public/VoxelCaveMorphology.h b/Source/VoxelForge/Public/VoxelCaveMorphology.h index 0e9ee06..da3d388 100644 --- a/Source/VoxelForge/Public/VoxelCaveMorphology.h +++ b/Source/VoxelForge/Public/VoxelCaveMorphology.h @@ -814,6 +814,71 @@ struct FCachedColumn float BoundXYRadiusSq; }; +// Build-site attribution is diagnostic only. It is deliberately passed to BuildChunkCache +// rather than inferred from the caller's stack so the game report can separate the generator, +// ordinary op-source, shared op-source, and classifier fallback windows without changing the +// cache key or the generated field. +enum class ERoomGraphBuildSite : uint8 +{ + Unknown, + GeneratorTile, + GeneratorTunnelCore, + OpShared, + OpLocal, + ClassifierShared, + ClassifierLocal, +}; + +// Immutable XY broad phase for one cached primitive array. Each bucket stores source-array +// indices in ascending order, so switching from a full scan to a bucket scan cannot change the +// order of SmoothMin/Min reductions or the selected nearest-room owner. Invalid/oversized +// bounds leave bValid false and callers deliberately fall back to the exact full scan. +struct FChunkSDFSpatialIndex +{ + int32 MinChunkX = 0; + int32 MinChunkY = 0; + int32 NumCellsX = 0; + int32 NumCellsY = 0; + bool bValid = false; + TArray CellOffsets; + TArray ItemIndices; + + void Reset(); + void Release(); + SIZE_T GetAllocatedSize() const; + bool GetRange(float WorldX, float WorldY, int32& OutBegin, int32& OutEnd) const; +}; + +template +FORCEINLINE int32 VF_ForEachChunkSDFSpatialCandidate( + const FChunkSDFSpatialIndex& Index, int32 ItemCount, + float WorldX, float WorldY, FVisit&& Visit) +{ + int32 Begin = 0; + int32 End = 0; + int32 CandidateCount = 0; + if (Index.GetRange(WorldX, WorldY, Begin, End)) + { + for (int32 Cursor = Begin; Cursor < End; ++Cursor) + { + const int32 ItemIndex = Index.ItemIndices[Cursor]; + if (ItemIndex >= 0 && ItemIndex < ItemCount) + { + ++CandidateCount; + Visit(ItemIndex); + } + } + return CandidateCount; + } + + for (int32 ItemIndex = 0; ItemIndex < ItemCount; ++ItemIndex) + { + ++CandidateCount; + Visit(ItemIndex); + } + return CandidateCount; +} + // The complete SDF cache for a chunk region. // Built once per chunk by BuildChunkCache(), then passed to EvaluateSDFCached() // for every voxel in the chunk. Typically contains 5-15 rooms and 10-30 tunnels. @@ -826,6 +891,15 @@ struct FChunkSDFCache TArray Chimneys; TArray Columns; + // Per-primitive XY filters. The bounds are conservative unions of every exact evaluator + // broad phase, so a failed index build is a performance fallback, never a density fallback. + FChunkSDFSpatialIndex RoomSpatialIndex; + FChunkSDFSpatialIndex RoomFloorJoinSpatialIndex; + FChunkSDFSpatialIndex TunnelSpatialIndex; + FChunkSDFSpatialIndex PitSpatialIndex; + FChunkSDFSpatialIndex ChimneySpatialIndex; + FChunkSDFSpatialIndex ColumnSpatialIndex; + // Sparse immutable support-column table. SupportColumnEntries is a dense // integer-XY slot map whose values index only non-empty SupportColumns. int32 SupportColumnMinX = 0; @@ -919,7 +993,8 @@ namespace VoxelCaveMorphology float SearchMaxX, float SearchMaxY, const FStrateGenerationParams& Params, uint32 Seed, int32 StrateIndex, - const TArray* TerrainOps = nullptr + const TArray* TerrainOps = nullptr, + ERoomGraphBuildSite BuildSite = ERoomGraphBuildSite::Unknown ); // Conservative, geometry-free preflight for BuildChunkCache. It recreates @@ -936,6 +1011,13 @@ namespace VoxelCaveMorphology float SearchMinZ = 0.0f, float SearchMaxZ = 0.0f ); + // True only for the finite parameter envelope for which BuildChunkCache's fixed collect + // margin proves window invariance. Callers may widen the XY store window to a whole tile only + // when this returns true; malformed or unbounded authored input must retain the legacy path. + VOXELFORGE_API bool IsRoomGraphWindowInvariant( + const FStrateGenerationParams& Params + ); + // PHASE 2: Evaluate the SDF at a single world position using cached data. // Loops through cached rooms and tunnels, computes SDF primitives, applies // SmoothMin. Includes per-room/tunnel distance culling to skip primitives diff --git a/Source/VoxelForge/Public/VoxelDensityProfile.h b/Source/VoxelForge/Public/VoxelDensityProfile.h index e268d09..6156e11 100644 --- a/Source/VoxelForge/Public/VoxelDensityProfile.h +++ b/Source/VoxelForge/Public/VoxelDensityProfile.h @@ -106,6 +106,13 @@ namespace VoxelDensityProfile TunnelCacheHit, TunnelCacheMiss, SdfCacheBuild, + RoomGraphBuildGeneratorTile, + RoomGraphBuildGeneratorTunnelCore, + RoomGraphBuildOpShared, + RoomGraphBuildOpLocal, + RoomGraphBuildClassifierShared, + RoomGraphBuildClassifierLocal, + RoomGraphBuildUnknown, CaveRoomCandidates, CaveRoomEvaluated, CaveTunnelCandidates, @@ -183,12 +190,13 @@ namespace VoxelDensityProfile uint64 SupportColumnEntriesBytes = 0; uint64 SupportColumnsBytes = 0; uint64 SupportColumnIntervalsBytes = 0; + uint64 SpatialIndexBytes = 0; uint64 TotalBytes() const { return SlotStorageBytes + OpStackBytes + RoomsBytes + RoomFloorJoinsBytes + TunnelsBytes + PitsBytes + ChimneysBytes + ColumnsBytes + SupportColumnEntriesBytes - + SupportColumnsBytes + SupportColumnIntervalsBytes; + + SupportColumnsBytes + SupportColumnIntervalsBytes + SpatialIndexBytes; } uint64 DynamicBytes() const @@ -209,6 +217,7 @@ namespace VoxelDensityProfile SupportColumnEntriesBytes += Other.SupportColumnEntriesBytes; SupportColumnsBytes += Other.SupportColumnsBytes; SupportColumnIntervalsBytes += Other.SupportColumnIntervalsBytes; + SpatialIndexBytes += Other.SpatialIndexBytes; return *this; } }; diff --git a/Source/VoxelForge/Public/VoxelGenerator.h b/Source/VoxelForge/Public/VoxelGenerator.h index ddb3634..ff183f7 100644 --- a/Source/VoxelForge/Public/VoxelGenerator.h +++ b/Source/VoxelForge/Public/VoxelGenerator.h @@ -53,10 +53,19 @@ namespace VoxelGenLOD // read-only hint for caches that may safely widen their spatial window for coarse tiles. extern thread_local int32 SampleStep; + // Exact lattice footprint of the tile currently being meshed. The cache-window switch + // uses this rather than inferring a region from an individual sample coordinate, so every + // sample in one tile shares one deterministic window even at tile/chunk boundaries. + extern thread_local FIntVector TileOriginVoxels; + extern thread_local int32 TileCellsPerAxis; + VOXELFORGE_API int32 GetThreadOctaveBias(); VOXELFORGE_API void SetThreadOctaveBias(int32 Value); VOXELFORGE_API int32 GetThreadSampleStep(); VOXELFORGE_API void SetThreadSampleStep(int32 Value); + VOXELFORGE_API bool IsTileCacheWindowEnabled(); + VOXELFORGE_API bool GetThreadTileCacheWindow( + FIntVector& OutOriginVoxels, int32& OutStep, int32& OutCellsPerAxis); // Effective octave count for a per-voxel noise call site. // At least 1 octave always survives (the coarse base shape). diff --git a/Source/VoxelForge/Public/VoxelStrateTypes.h b/Source/VoxelForge/Public/VoxelStrateTypes.h index a4c606a..0187480 100644 --- a/Source/VoxelForge/Public/VoxelStrateTypes.h +++ b/Source/VoxelForge/Public/VoxelStrateTypes.h @@ -1117,6 +1117,30 @@ struct VOXELFORGE_API FStrateGenerationParams } }; +// Keep the cache-key/memo-key audit mechanically tied to the struct. C++ has no portable +// reflection for UPROPERTY members, so the X-macro can at least prove that its listed byte sum +// and the completed POD layout still agree. The eight bytes below are the current, intentional +// padding between the three bool/enum members and their following four-byte fields on the UE +// toolchains supported by this plugin. A field added to the struct or the list without its +// counterpart changes one side of this equality and fails here instead of silently changing the +// blend/key coverage. +#define VF_STRATE_PARAM_SIZE_FIELD(Name) \ + + sizeof(((FStrateGenerationParams*)nullptr)->Name) +#define VF_STRATE_PARAM_COUNT_FIELD(Name) + 1 +constexpr SIZE_T VF_StrateParamListedBytes = + 0 VF_STRATE_PARAM_FIELDS(VF_STRATE_PARAM_SIZE_FIELD, VF_STRATE_PARAM_SIZE_FIELD); +constexpr int32 VF_StrateParamListedFieldCount = + 0 VF_STRATE_PARAM_FIELDS(VF_STRATE_PARAM_COUNT_FIELD, VF_STRATE_PARAM_COUNT_FIELD); +#undef VF_STRATE_PARAM_SIZE_FIELD +#undef VF_STRATE_PARAM_COUNT_FIELD + +static_assert( + VF_StrateParamListedFieldCount == 83, + "FStrateGenerationParams field count changed; update the struct and VF_STRATE_PARAM_FIELDS together."); +static_assert( + sizeof(FStrateGenerationParams) == VF_StrateParamListedBytes + 8, + "FStrateGenerationParams and VF_STRATE_PARAM_FIELDS disagree; update both places and recheck the expected padding."); + //============================================================================= // TERRAIN OPERATION REFERENCE //=============================================================================