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 //=============================================================================