diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeClipmapNoOverlapTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeClipmapNoOverlapTest.cpp new file mode 100644 index 0000000..0d02b01 --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeClipmapNoOverlapTest.cpp @@ -0,0 +1,385 @@ +#if WITH_DEV_AUTOMATION_TESTS + +#include "Misc/AutomationTest.h" + +#include "VoxelClipmapDesiredTiles.h" + +namespace +{ + using VoxelClipmapDesiredTiles::FParameters; + + struct FOverlapMeasure + { + uint64 PairCount = 0; + uint64 PairwiseVolumeChunkCubed = 0; + }; + + static FOverlapMeasure MeasureOverlaps(const TArray& Tiles) + { + TSet TileSet; + int32 MaxLevel = 0; + for (const FVoxelTileKey& Tile : Tiles) + { + TileSet.Add(Tile); + MaxLevel = FMath::Max(MaxLevel, Tile.Level); + } + + FOverlapMeasure Result; + for (const FVoxelTileKey& Fine : Tiles) + { + for (int32 CoarseLevel = Fine.Level + 1; CoarseLevel <= MaxLevel; ++CoarseLevel) + { + const int32 Divisor = 1 << (CoarseLevel - Fine.Level); + const FVoxelTileKey Coarse( + VoxelClipmapDesiredTiles::FloorDiv(Fine.Coord, Divisor), CoarseLevel); + if (TileSet.Contains(Coarse)) + { + ++Result.PairCount; + Result.PairwiseVolumeChunkCubed += (uint64(1) << (3 * Fine.Level)); + } + } + } + return Result; + } + + static bool IsFootprintCovered(const FVoxelTileKey& Tile, + const TSet& CoveringTiles, + int32 MaxLevel, int32 MinChunkZ, int32 MaxChunkZ) + { + const int32 TileMinZ = Tile.Coord.Z * (1 << Tile.Level); + const int32 TileMaxZ = (Tile.Coord.Z + 1) * (1 << Tile.Level) - 1; + if (TileMaxZ < MinChunkZ || TileMinZ > MaxChunkZ) + { + return true; + } + + if (CoveringTiles.Contains(Tile)) + { + return true; + } + + // A coarser dyadic tile covers this whole footprint when it is an ancestor. + for (int32 ParentLevel = Tile.Level + 1; ParentLevel <= MaxLevel; ++ParentLevel) + { + const int32 Divisor = 1 << (ParentLevel - Tile.Level); + const FVoxelTileKey Parent( + VoxelClipmapDesiredTiles::FloorDiv(Tile.Coord, Divisor), ParentLevel); + if (CoveringTiles.Contains(Parent)) + { + return true; + } + } + + if (Tile.Level == 0) + { + return Tile.Coord.Z < MinChunkZ || Tile.Coord.Z > MaxChunkZ; + } + + const int32 ChildLevel = Tile.Level - 1; + const FIntVector ChildBase = Tile.Coord * 2; + for (int32 Z = 0; Z < 2; ++Z) + for (int32 Y = 0; Y < 2; ++Y) + for (int32 X = 0; X < 2; ++X) + { + if (!IsFootprintCovered( + FVoxelTileKey(ChildBase + FIntVector(X, Y, Z), ChildLevel), + CoveringTiles, MaxLevel, MinChunkZ, MaxChunkZ)) + { + return false; + } + } + return true; + } + + static bool CoversEveryLegacyTile(const TArray& Legacy, + const TArray& Current, + int32 MinChunkZ, int32 MaxChunkZ) + { + TSet CurrentSet; + int32 MaxLevel = 0; + for (const FVoxelTileKey& Tile : Current) + { + CurrentSet.Add(Tile); + MaxLevel = FMath::Max(MaxLevel, Tile.Level); + } + for (const FVoxelTileKey& Tile : Legacy) + { + if (!IsFootprintCovered(Tile, CurrentSet, MaxLevel, MinChunkZ, MaxChunkZ)) + { + return false; + } + } + return true; + } + + // Frozen copy of the 7486c29 selector. It is the coverage oracle and keeps the regression + // test able to prove that an exact tiling still covers every volume the old selector covered. + static void Build7486c29(const FIntVector& Center, int32 MinChunkZ, int32 MaxChunkZ, + const FParameters& Parameters, TArray& OutTiles) + { + OutTiles.Reset(); + const int32 Radius = FMath::Max(1, Parameters.ClipRadius); + const int32 MaxLevel = FMath::Clamp(Parameters.MaxClipLevel, 0, 8); + const int32 OuterRadius = VoxelClipmapDesiredTiles::OuterShellRadius( + Parameters, Radius, MaxLevel); + int32 SheetLevel = MaxLevel; + int32 SheetRadius = OuterRadius; + VoxelClipmapDesiredTiles::OuterShell( + Parameters, Radius, MaxLevel, SheetLevel, SheetRadius); + const bool bSheetRing = SheetLevel > MaxLevel; + const int32 MaxMCRingLevel = bSheetRing ? SheetLevel - 1 : MaxLevel; + + for (int32 Level = 0; Level <= MaxMCRingLevel; ++Level) + { + const int32 Pow = 1 << Level; + const FIntVector CenterLevel = VoxelClipmapDesiredTiles::FloorDiv(Center, Pow); + const FIntVector CenterFiner = Level > 0 + ? VoxelClipmapDesiredTiles::FloorDiv(Center, Pow >> 1) + : FIntVector::ZeroValue; + const int32 LevelRadius = (Level == MaxLevel && !bSheetRing) + ? OuterRadius : Radius; + + int32 DeltaZMin = -LevelRadius; + int32 DeltaZMax = LevelRadius; + if (LevelRadius > Radius && MinChunkZ != MIN_int32) + { + DeltaZMin = FMath::Max( + DeltaZMin, VoxelClipmapDesiredTiles::FloorDiv(MinChunkZ, Pow) - CenterLevel.Z); + DeltaZMax = FMath::Min( + DeltaZMax, VoxelClipmapDesiredTiles::FloorDiv(MaxChunkZ, Pow) - CenterLevel.Z); + } + + for (int32 DeltaZ = DeltaZMin; DeltaZ <= DeltaZMax; ++DeltaZ) + for (int32 DeltaY = -LevelRadius; DeltaY <= LevelRadius; ++DeltaY) + for (int32 DeltaX = -LevelRadius; DeltaX <= LevelRadius; ++DeltaX) + { + const FIntVector Tile = CenterLevel + FIntVector(DeltaX, DeltaY, DeltaZ); + const int32 TileMinZ = Tile.Z << Level; + const int32 TileMaxZ = ((Tile.Z + 1) << Level) - 1; + if (TileMaxZ < MinChunkZ || TileMinZ > MaxChunkZ) + { + continue; + } + + if (Level > 0) + { + const bool bCovered = + (2 * Tile.X >= CenterFiner.X - Radius) + && (2 * Tile.X + 1 <= CenterFiner.X + Radius) + && (2 * Tile.Y >= CenterFiner.Y - Radius) + && (2 * Tile.Y + 1 <= CenterFiner.Y + Radius) + && (2 * Tile.Z >= CenterFiner.Z - Radius) + && (2 * Tile.Z + 1 <= CenterFiner.Z + Radius); + if (bCovered) + { + continue; + } + } + OutTiles.Emplace(Tile, Level); + } + } + + if (bSheetRing) + { + const int32 SheetPow = 1 << SheetLevel; + const FIntVector CenterSheet = VoxelClipmapDesiredTiles::FloorDiv(Center, SheetPow); + const FIntVector CenterMC = VoxelClipmapDesiredTiles::FloorDiv( + Center, 1 << MaxMCRingLevel); + const int32 LevelDelta = SheetLevel - MaxMCRingLevel; + + int32 DeltaZMin = -SheetRadius; + int32 DeltaZMax = SheetRadius; + if (MinChunkZ != MIN_int32) + { + DeltaZMin = FMath::Max(DeltaZMin, + VoxelClipmapDesiredTiles::FloorDiv(MinChunkZ, SheetPow) - CenterSheet.Z); + DeltaZMax = FMath::Min(DeltaZMax, + VoxelClipmapDesiredTiles::FloorDiv(MaxChunkZ, SheetPow) - CenterSheet.Z); + } + + for (int32 DeltaZ = DeltaZMin; DeltaZ <= DeltaZMax; ++DeltaZ) + for (int32 DeltaY = -SheetRadius; DeltaY <= SheetRadius; ++DeltaY) + for (int32 DeltaX = -SheetRadius; DeltaX <= SheetRadius; ++DeltaX) + { + const FIntVector Tile = CenterSheet + FIntVector(DeltaX, DeltaY, DeltaZ); + const int32 TileMinZ = Tile.Z << SheetLevel; + const int32 TileMaxZ = ((Tile.Z + 1) << SheetLevel) - 1; + if (TileMaxZ < MinChunkZ || TileMinZ > MaxChunkZ) + { + continue; + } + + const bool bCovered = + ((Tile.X << LevelDelta) >= CenterMC.X - Radius) + && ((((Tile.X + 1) << LevelDelta) - 1) <= CenterMC.X + Radius) + && ((Tile.Y << LevelDelta) >= CenterMC.Y - Radius) + && ((((Tile.Y + 1) << LevelDelta) - 1) <= CenterMC.Y + Radius) + && ((Tile.Z << LevelDelta) >= CenterMC.Z - Radius) + && ((((Tile.Z + 1) << LevelDelta) - 1) <= CenterMC.Z + Radius); + if (!bCovered) + { + OutTiles.Emplace(Tile, SheetLevel); + } + } + } + } + + static FString DescribeMeasure(const FOverlapMeasure& Measure) + { + return FString::Printf(TEXT("pairs=%llu pairwise_volume_chunk3=%llu"), + static_cast(Measure.PairCount), + static_cast(Measure.PairwiseVolumeChunkCubed)); + } +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeClipmapNoOverlapTest, + "VoxelForge.Streaming.ClipmapDesiredTilesNoOverlapCoverage", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +bool FVoxelForgeClipmapNoOverlapTest::RunTest(const FString& Parameters) +{ + (void)Parameters; + + FParameters Owner; + Owner.ClipRadius = 3; + Owner.MaxClipLevel = 5; + Owner.RenderDistanceChunks = 768; + Owner.bFarSheetRing = false; + + FParameters Defaults; + + TArray LegacyOwner, CurrentOwner, LegacyDefaults, CurrentDefaults; + Build7486c29(FIntVector::ZeroValue, MIN_int32, MAX_int32, Owner, LegacyOwner); + VoxelClipmapDesiredTiles::Build( + FIntVector::ZeroValue, MIN_int32, MAX_int32, Owner, CurrentOwner); + Build7486c29(FIntVector::ZeroValue, MIN_int32, MAX_int32, Defaults, LegacyDefaults); + VoxelClipmapDesiredTiles::Build( + FIntVector::ZeroValue, MIN_int32, MAX_int32, Defaults, CurrentDefaults); + + const FOverlapMeasure LegacyOwnerOverlap = MeasureOverlaps(LegacyOwner); + const FOverlapMeasure CurrentOwnerOverlap = MeasureOverlaps(CurrentOwner); + const FOverlapMeasure LegacyDefaultOverlap = MeasureOverlaps(LegacyDefaults); + const FOverlapMeasure CurrentDefaultOverlap = MeasureOverlaps(CurrentDefaults); + + AddInfo(FString::Printf( + TEXT("owner center=(0,0,0): before tiles=%d %s; after tiles=%d %s"), + LegacyOwner.Num(), *DescribeMeasure(LegacyOwnerOverlap), + CurrentOwner.Num(), *DescribeMeasure(CurrentOwnerOverlap))); + AddInfo(FString::Printf( + TEXT("FParameters defaults center=(0,0,0): before tiles=%d %s; after tiles=%d %s"), + LegacyDefaults.Num(), *DescribeMeasure(LegacyDefaultOverlap), + CurrentDefaults.Num(), *DescribeMeasure(CurrentDefaultOverlap))); + + TestTrue(TEXT("7486c29 owner overlap measurement matches the checked source geometry"), + LegacyOwnerOverlap.PairCount == 635 + && LegacyOwnerOverlap.PairwiseVolumeChunkCubed == 594487); + TestTrue(TEXT("7486c29 FParameters-default overlap measurement matches the checked source geometry"), + LegacyDefaultOverlap.PairCount == 508 + && LegacyDefaultOverlap.PairwiseVolumeChunkCubed == 74295); + TestTrue(TEXT("owner desired set has no overlapping level pairs"), + CurrentOwnerOverlap.PairCount == 0 && CurrentOwnerOverlap.PairwiseVolumeChunkCubed == 0); + TestTrue(TEXT("default desired set has no overlapping level pairs"), + CurrentDefaultOverlap.PairCount == 0 && CurrentDefaultOverlap.PairwiseVolumeChunkCubed == 0); + TestTrue(TEXT("owner coverage is identical or larger than 7486c29"), + CoversEveryLegacyTile(LegacyOwner, CurrentOwner, MIN_int32, MAX_int32)); + TestTrue(TEXT("default coverage is identical or larger than 7486c29"), + CoversEveryLegacyTile(LegacyDefaults, CurrentDefaults, MIN_int32, MAX_int32)); + + struct FSweepSettings + { + int32 Radius; + int32 MaxLevel; + int32 RenderDistance; + bool bSheet; + int32 SheetSpan; + }; + static const FSweepSettings SweepSettings[] = { + {1, 0, 0, false, 2}, + {2, 2, 0, false, 2}, + {3, 4, 0, false, 2}, + {2, 3, 49, false, 2}, + {2, 3, 49, true, 2}, + {2, 4, 87, true, 3}, + }; + + int32 SweptCases = 0; + int32 OverlapCases = 0; + int32 CoverageFailures = 0; + uint64 TotalOverlapPairs = 0; + FString FirstFailure; + const int32 CenterMin = -3; + const int32 CenterMax = 3; + for (int32 Z = CenterMin; Z <= CenterMax; ++Z) + for (int32 Y = CenterMin; Y <= CenterMax; ++Y) + for (int32 X = CenterMin; X <= CenterMax; ++X) + for (const FSweepSettings& Sweep : SweepSettings) + { + FParameters P; + P.ClipRadius = Sweep.Radius; + P.MaxClipLevel = Sweep.MaxLevel; + P.RenderDistanceChunks = Sweep.RenderDistance; + P.bFarSheetRing = Sweep.bSheet; + P.FarSheetSpanLevels = Sweep.SheetSpan; + + // Exercise the unbounded view, a tight local clamp, and a clamp crossing a tile boundary. + for (int32 ClampCase = 0; ClampCase < 3; ++ClampCase) + { + int32 MinZ = MIN_int32; + int32 MaxZ = MAX_int32; + if (ClampCase == 1) + { + MinZ = Z - 2; + MaxZ = Z + 3; + } + else if (ClampCase == 2) + { + const int32 Band = VoxelClipmapDesiredTiles::FloorDiv(Z, 1 << Sweep.MaxLevel); + MinZ = (Band << Sweep.MaxLevel) - 1; + MaxZ = MinZ + 6; + } + + const FIntVector Center(X, Y, Z); + TArray Legacy, Current; + Build7486c29(Center, MinZ, MaxZ, P, Legacy); + VoxelClipmapDesiredTiles::Build(Center, MinZ, MaxZ, P, Current); + ++SweptCases; + + const FOverlapMeasure CurrentOverlap = MeasureOverlaps(Current); + if (CurrentOverlap.PairCount != 0) + { + ++OverlapCases; + TotalOverlapPairs += CurrentOverlap.PairCount; + if (FirstFailure.IsEmpty()) + { + FirstFailure = FString::Printf( + TEXT("overlap center=(%d,%d,%d) radius=%d level=%d distance=%d sheet=%d clamp=%d: %s"), + X, Y, Z, Sweep.Radius, Sweep.MaxLevel, Sweep.RenderDistance, + Sweep.bSheet ? 1 : 0, ClampCase, *DescribeMeasure(CurrentOverlap)); + } + } + if (!CoversEveryLegacyTile(Legacy, Current, MinZ, MaxZ)) + { + ++CoverageFailures; + if (FirstFailure.IsEmpty()) + { + FirstFailure = FString::Printf( + TEXT("coverage center=(%d,%d,%d) radius=%d level=%d distance=%d sheet=%d clamp=%d"), + X, Y, Z, Sweep.Radius, Sweep.MaxLevel, Sweep.RenderDistance, + Sweep.bSheet ? 1 : 0, ClampCase); + } + } + } + } + + AddInfo(FString::Printf( + TEXT("sweep cases=%d centers=343 settings=%d clamps=3 overlap_cases=%d overlap_pairs=%llu coverage_failures=%d first=%s"), + SweptCases, UE_ARRAY_COUNT(SweepSettings), OverlapCases, + static_cast(TotalOverlapPairs), CoverageFailures, + FirstFailure.IsEmpty() ? TEXT("none") : *FirstFailure)); + TestEqual(TEXT("all swept desired sets have zero overlapping pairs"), OverlapCases, 0); + TestEqual(TEXT("all swept desired sets cover the legacy footprint"), CoverageFailures, 0); + return true; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeDensityBlockEquivalenceTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeDensityBlockEquivalenceTest.cpp new file mode 100644 index 0000000..92c9e41 --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeDensityBlockEquivalenceTest.cpp @@ -0,0 +1,592 @@ +#if WITH_DEV_AUTOMATION_TESTS + +#include "Misc/AutomationTest.h" + +#include "HAL/IConsoleManager.h" +#include "VoxelClipmapDesiredTiles.h" +#include "VoxelDensityOpStack.h" +#include "VoxelForgeTestFixture.h" + +namespace +{ + // The marching-cubes grid contains 32 cells, with one sample of halo on each side. + constexpr int32 MesherGridSize = 35; + constexpr int32 ArchetypeCount = 8; + constexpr int32 PassageGridCount = 2; + constexpr int32 GridSetCount = ArchetypeCount + PassageGridCount; + + static bool SameFloatBits(float A, float B) + { + return FMemory::Memcmp(&A, &B, sizeof(float)) == 0; + } + + static FIntVector MakeMesherGridOrigin(const FIntVector& TargetChunk, int32 Level) + { + const int32 TileSpanChunks = 1 << Level; + const FIntVector TileCoord = VoxelClipmapDesiredTiles::FloorDiv( + TargetChunk, TileSpanChunks); + const int32 Step = 1 << Level; + return TileCoord * (CHUNK_SIZE << Level) - FIntVector(Step, Step, Step); + } + + struct FGridComparison + { + int64 Samples = 0; + int32 Mismatches = 0; + FString FirstMismatch; + TArray BlockDensities; + }; + + static bool BuildProductionStackForArchetype( + VoxelForgeTest::FTestWorld& World, ECaveGeneratorType Type, + const FIntVector& ChunkCoord, FVoxelOpStack& OutStack, FVoxelOpContext& OutContext) + { + const int32 Seed = World.Settings->GetEffectiveWorldSeed(); + const float SpineRadius = World.Settings->GetEffectiveOriginSpineRadius(); + const UVoxelStrateManager* Manager = World.StrateManager.Get(); + OutContext = FVoxelOpContext(); + OutContext.ChunkCoord = ChunkCoord; + OutContext.Seed = static_cast(Seed); + OutContext.LayoutVersion = Manager->GetLayoutVersion(); + OutContext.WorldRadiusVoxels = World.Settings->GetEffectiveWorldRadiusVoxels(); + OutContext.EdgeSealThickness = World.Settings->EdgeSealThickness; + + switch (Type) + { + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + { + const FSlabGenerationParams P = Manager->GetSlabParamsForChunk(ChunkCoord); + OutContext.StrateTopWorldZ = P.StrateTopWorldZ; + OutContext.StrateBottomWorldZ = P.StrateBottomWorldZ; + VoxelDensityOps::BuildSlabStack(OutStack, P, Seed, SpineRadius, Manager); + return true; + } + case ECaveGeneratorType::Maze: + { + const FMazeGenerationParams P = Manager->GetMazeParamsForChunk(ChunkCoord); + OutContext.StrateTopWorldZ = P.StrateTopWorldZ; + OutContext.StrateBottomWorldZ = P.StrateBottomWorldZ; + VoxelDensityOps::BuildMazeStack(OutStack, P, Seed, SpineRadius, Manager); + return true; + } + case ECaveGeneratorType::SurfaceWorld: + { + const FSurfaceGenerationParams P = Manager->GetSurfaceParamsForChunk(ChunkCoord); + OutContext.StrateTopWorldZ = P.StrateTopWorldZ; + OutContext.StrateBottomWorldZ = P.StrateBottomWorldZ; + // The generator-grid comparison below covers the resolved biome route. This builder + // exercises the production stack's base surface branch directly. + VoxelDensityOps::BuildSurfaceStack( + OutStack, P, Seed, SpineRadius, Manager, TArray(), nullptr); + return true; + } + case ECaveGeneratorType::VerticalShafts: + { + const FVerticalShaftParams P = Manager->GetVerticalShaftParamsForChunk(ChunkCoord); + OutContext.StrateTopWorldZ = P.StrateTopWorldZ; + OutContext.StrateBottomWorldZ = P.StrateBottomWorldZ; + VoxelDensityOps::BuildVerticalShaftStack(OutStack, P, Seed, SpineRadius, Manager); + return true; + } + case ECaveGeneratorType::FloatingIslands: + { + const FFloatingIslandParams P = Manager->GetFloatingIslandParamsForChunk(ChunkCoord); + OutContext.StrateTopWorldZ = P.StrateTopWorldZ; + OutContext.StrateBottomWorldZ = P.StrateBottomWorldZ; + VoxelDensityOps::BuildFloatingIslandStack(OutStack, P, Seed, SpineRadius, Manager); + return true; + } + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + { + const FStrateGenerationParams P = Manager->GetGenerationParams(ChunkCoord); + OutContext.StrateTopWorldZ = P.StrateTopWorldZ; + OutContext.StrateBottomWorldZ = P.StrateBottomWorldZ; + VoxelDensityOps::BuildTunnelNetworkStack(OutStack, P, Seed, SpineRadius, Manager); + return true; + } + default: + return false; + } + } + + static bool FindChunkSampleRange(int32 AxisOrigin, int32 Step, int32 AxisSize, + int32 TargetChunk, int32& OutMin, int32& OutMax) + { + OutMin = INT32_MAX; + OutMax = INT32_MIN; + for (int32 Index = 0; Index < AxisSize; ++Index) + { + const int32 Coordinate = AxisOrigin + Index * Step; + if (FMath::FloorToInt(static_cast(Coordinate) / CHUNK_SIZE) == TargetChunk) + { + OutMin = FMath::Min(OutMin, Index); + OutMax = FMath::Max(OutMax, Index); + } + } + return OutMin != INT32_MAX; + } + + static FGridComparison CompareStackChunkBlockWithScalar( + FVoxelOpStack& Stack, FIntVector GridOrigin, int32 Step, + const FIntVector& TargetChunk) + { + FGridComparison Result; + int32 MinX = 0, MaxX = 0, MinY = 0, MaxY = 0, MinZ = 0, MaxZ = 0; + if (!FindChunkSampleRange(GridOrigin.X, Step, MesherGridSize, TargetChunk.X, MinX, MaxX) + || !FindChunkSampleRange(GridOrigin.Y, Step, MesherGridSize, TargetChunk.Y, MinY, MaxY) + || !FindChunkSampleRange(GridOrigin.Z, Step, MesherGridSize, TargetChunk.Z, MinZ, MaxZ)) + { + Result.FirstMismatch = TEXT("mesher grid has no samples in the stack's target chunk"); + return Result; + } + + const int32 SizeX = MaxX - MinX + 1; + const int32 SizeY = MaxY - MinY + 1; + const int32 SizeZ = MaxZ - MinZ + 1; + const FIntVector Origin = GridOrigin + FIntVector(MinX * Step, MinY * Step, MinZ * Step); + const FIntVector Last = Origin + FIntVector( + (SizeX - 1) * Step, (SizeY - 1) * Step, (SizeZ - 1) * Step); + const FBox Box( + FVector((float)Origin.X, (float)Origin.Y, (float)Origin.Z), + FVector((float)Last.X, (float)Last.Y, (float)Last.Z)); + + TArray ActiveOps; + const int32 ActiveCount = Stack.BuildActiveOpList(Box, Step, Origin, ActiveOps); + TArray Samples; + Samples.SetNumUninitialized(SizeX * SizeY * SizeZ); + for (FVoxelOpSample& Sample : Samples) { Sample = FVoxelOpSample(); } + + FVoxelOpBlock Block; + Block.OriginVoxels = Origin; + Block.Step = Step; + Block.SizeX = SizeX; + Block.SizeY = SizeY; + Block.SizeZ = SizeZ; + Block.Samples = Samples.GetData(); + Stack.EvalBlock(Block, ActiveOps); + + for (int32 Z = 0; Z < SizeZ; ++Z) + for (int32 Y = 0; Y < SizeY; ++Y) + for (int32 X = 0; X < SizeX; ++X) + { + const FIntVector SamplePos = Origin + FIntVector(X * Step, Y * Step, Z * Step); + const float BlockDensity = Block.At(X, Y, Z).Density; + const float ScalarDensity = Stack.EvalInternal( + (float)SamplePos.X, (float)SamplePos.Y, (float)SamplePos.Z); + Result.BlockDensities.Add(BlockDensity); + ++Result.Samples; + if (!SameFloatBits(BlockDensity, ScalarDensity)) + { + ++Result.Mismatches; + if (Result.FirstMismatch.IsEmpty()) + { + Result.FirstMismatch = FString::Printf( + TEXT("box=[(%d,%d,%d)..(%d,%d,%d)] active_ops=%d/%d sample=(%d,%d,%d) block=%.9g scalar=%.9g delta=%.9g"), + Origin.X, Origin.Y, Origin.Z, Last.X, Last.Y, Last.Z, + ActiveCount, Stack.Num(), SamplePos.X, SamplePos.Y, SamplePos.Z, + BlockDensity, ScalarDensity, BlockDensity - ScalarDensity); + } + } + } + return Result; + } + + static FGridComparison CompareGeneratorBlockWithScalar( + UVoxelGenerator& Generator, FIntVector Origin, int32 Step) + { + FGridComparison Result; + const int32 Count = MesherGridSize * MesherGridSize * MesherGridSize; + Result.BlockDensities.SetNumUninitialized(Count); + Generator.BeginDensityBlock(Origin, Step, MesherGridSize, MesherGridSize, MesherGridSize); + int32 Index = 0; + for (int32 Z = 0; Z < MesherGridSize; ++Z) + for (int32 Y = 0; Y < MesherGridSize; ++Y) + for (int32 X = 0; X < MesherGridSize; ++X, ++Index) + { + const FIntVector SamplePos = Origin + FIntVector(X * Step, Y * Step, Z * Step); + Result.BlockDensities[Index] = Generator.GetDensityAt( + (float)SamplePos.X, (float)SamplePos.Y, (float)SamplePos.Z); + } + Generator.EndDensityBlock(); + + Index = 0; + for (int32 Z = 0; Z < MesherGridSize; ++Z) + for (int32 Y = 0; Y < MesherGridSize; ++Y) + for (int32 X = 0; X < MesherGridSize; ++X, ++Index) + { + const FIntVector SamplePos = Origin + FIntVector(X * Step, Y * Step, Z * Step); + const float ScalarDensity = Generator.GetDensityAt( + (float)SamplePos.X, (float)SamplePos.Y, (float)SamplePos.Z); + ++Result.Samples; + if (!SameFloatBits(Result.BlockDensities[Index], ScalarDensity)) + { + ++Result.Mismatches; + if (Result.FirstMismatch.IsEmpty()) + { + Result.FirstMismatch = FString::Printf( + TEXT("sample=(%d,%d,%d) block=%.9g scalar=%.9g delta=%.9g"), + SamplePos.X, SamplePos.Y, SamplePos.Z, Result.BlockDensities[Index], + ScalarDensity, Result.BlockDensities[Index] - ScalarDensity); + } + } + } + return Result; + } + + static int32 FindGapChunkZ(const VoxelForgeTest::FTestWorld& World) + { + const int32 Min = World.BottomChunkZ - 8; + const int32 Max = World.TopChunkZ + 8; + for (int32 ChunkZ = Min; ChunkZ <= Max; ++ChunkZ) + { + if (World.StrateManager->IsGapChunk(FIntVector(0, 0, ChunkZ))) + { + return ChunkZ; + } + } + return MIN_int32; + } + + static int32 CountGapSamples(const VoxelForgeTest::FTestWorld& World, + FIntVector Origin, int32 Step) + { + TSet SampleChunkZs; + for (int32 Z = 0; Z < MesherGridSize; ++Z) + { + SampleChunkZs.Add(FMath::FloorToInt( + static_cast(Origin.Z + Z * Step) / CHUNK_SIZE)); + } + int32 Count = 0; + for (const int32 ChunkZ : SampleChunkZs) + { + if (World.StrateManager->IsGapChunk(FIntVector(0, 0, ChunkZ))) + { + ++Count; + } + } + return Count; + } + + struct FScopedConsoleIntOverride + { + IConsoleVariable* Variable = nullptr; + int32 OriginalValue = 0; + + explicit FScopedConsoleIntOverride(const TCHAR* Name, int32 Value) + : Variable(IConsoleManager::Get().FindConsoleVariable(Name)) + { + if (Variable != nullptr) + { + OriginalValue = Variable->GetInt(); + Variable->Set(Value, ECVF_SetByCode); + } + } + + ~FScopedConsoleIntOverride() + { + if (Variable != nullptr) + { + Variable->Set(OriginalValue, ECVF_SetByCode); + } + } + }; +} + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeDensityBlockEquivalenceTest, + "VoxelForge.Density.OperatorBlockMatchesScalarGrid", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +bool FVoxelForgeDensityBlockEquivalenceTest::RunTest(const FString& Parameters) +{ + (void)Parameters; + + // The production mesher initializes these once at startup. This headless test does so too, + // then temporarily disables the fused TunnelNetwork evaluator so the real per-chunk + // BeginDensityBlock -> BuildActiveOpList -> EvalBlock path is compared against scalar output. + VoxelGenLOD::InitializeConsoleSwitches(); + FScopedConsoleIntOverride FusedEvaluatorOverride(TEXT("voxel.UseFusedEvaluator"), 0); + if (FusedEvaluatorOverride.Variable == nullptr) + { + AddError(TEXT("voxel.UseFusedEvaluator cvar is unavailable; block route was not forced")); + return false; + } + + static const int32 Seeds[] = {1337, 9176, 17015, 24854, 32693, 40532}; + static const ECaveGeneratorType Archetypes[ArchetypeCount] = { + ECaveGeneratorType::TunnelNetwork, + ECaveGeneratorType::FlatPlain, + ECaveGeneratorType::CrystalChamber, + ECaveGeneratorType::Maze, + ECaveGeneratorType::SurfaceWorld, + ECaveGeneratorType::VerticalShafts, + ECaveGeneratorType::FloatingIslands, + ECaveGeneratorType::Underwater, + }; + + int64 GeneratorSamples = 0; + int32 GeneratorMismatches = 0; + FString FirstGeneratorMismatch; + int64 DirectStackSamples = 0; + int32 DirectStackMismatches = 0; + FString FirstDirectStackMismatch; + int32 DirectStackBlocks = 0; + int32 GeneratorBlocks = 0; + int32 ProductionArchetypeBlocks = 0; + int32 BoundaryBlocks = 0; + int32 NearOriginBlocks = 0; + int32 FarOriginBlocks = 0; + int32 GapCoverageBlocks = 0; + int32 PassageFocusedBlocks = 0; + int32 SpecialGapCoverageBlocks = 0; + int32 EditedSamplesChanged = 0; + int32 AcceptedEdits = 0; + int32 ArchetypeLevelCounts[ArchetypeCount][6] = {}; + + for (int32 SeedIndex = 0; SeedIndex < UE_ARRAY_COUNT(Seeds); ++SeedIndex) + { + VoxelForgeTest::FTestWorld World; + World.Build(Seeds[SeedIndex], /*gap chunks=*/2, /*operator stack=*/true); + if (!World.IsValid()) + { + AddError(World.WhyInvalid()); + return false; + } + // The fixture uses coarse-LOD edits up to 64 voxels wide. Keep the actual diff layer in + // the route while allowing those bounded test brushes. + World.DiffLayer->SetBudget(/*max mods=*/0, /*max radius=*/256.0f, + /*max accumulated volume=*/0.0f); + + const TArray& Slots = World.StrateManager->GetLayout(); + const TArray& Passages = World.StrateManager->GetPassages(); + if (Slots.Num() < ArchetypeCount || Passages.IsEmpty()) + { + AddError(FString::Printf( + TEXT("seed %d is missing an archetype slot or passage fixture"), Seeds[SeedIndex])); + return false; + } + const int32 GapChunkZ = FindGapChunkZ(World); + if (GapChunkZ == MIN_int32) + { + AddError(FString::Printf(TEXT("seed %d produced no gap chunk fixture"), Seeds[SeedIndex])); + return false; + } + + FIntVector GridOrigins[GridSetCount]; + int32 GridSteps[GridSetCount] = {}; + int32 GridArchetypes[GridSetCount] = {}; + FGridComparison BaseResults[GridSetCount]; + + // One complete mesher lattice for each production archetype per seed. Rotating level by + // seed gives every archetype all levels 0..5 over the six seeds. The sample grids touch a + // strate edge, and cycle through origin, near-origin, and both far-field signs. + for (int32 ArchetypeIndex = 0; ArchetypeIndex < ArchetypeCount; ++ArchetypeIndex) + { + const int32 Level = (SeedIndex + ArchetypeIndex) % 6; + const int32 Step = 1 << Level; + const FStrateSlot& Slot = Slots[ArchetypeIndex]; + const int32 TargetChunkZ = (SeedIndex & 1) ? Slot.BottomChunkZ : Slot.TopChunkZ; + FIntVector TargetChunk; + switch ((SeedIndex + ArchetypeIndex) % 4) + { + case 0: TargetChunk = FIntVector(0, 0, TargetChunkZ); break; + case 1: TargetChunk = FIntVector(128, 96, TargetChunkZ); ++FarOriginBlocks; break; + case 2: TargetChunk = FIntVector(8, -8, TargetChunkZ); ++NearOriginBlocks; break; + default: TargetChunk = FIntVector(-128, -96, TargetChunkZ); ++FarOriginBlocks; break; + } + if ((SeedIndex + ArchetypeIndex) % 4 == 0) { ++NearOriginBlocks; } + + const int32 GridIndex = ArchetypeIndex; + GridOrigins[GridIndex] = MakeMesherGridOrigin(TargetChunk, Level); + GridSteps[GridIndex] = Step; + GridArchetypes[GridIndex] = ArchetypeIndex; + + FVoxelOpStack Stack; + FVoxelOpContext Context; + if (!BuildProductionStackForArchetype( + World, Archetypes[ArchetypeIndex], TargetChunk, Stack, Context)) + { + AddError(FString::Printf( + TEXT("seed %d archetype %d production stack could not be built"), + Seeds[SeedIndex], ArchetypeIndex)); + return false; + } + Context.Step = 1; + Stack.PrepareChunk(Context); + const FGridComparison DirectComparison = CompareStackChunkBlockWithScalar( + Stack, GridOrigins[GridIndex], Step, TargetChunk); + DirectStackSamples += DirectComparison.Samples; + DirectStackMismatches += DirectComparison.Mismatches; + ++DirectStackBlocks; + if (FirstDirectStackMismatch.IsEmpty() && DirectComparison.Mismatches > 0) + { + FirstDirectStackMismatch = FString::Printf( + TEXT("seed=%d archetype=%d level=%d chunk=%s %s"), + Seeds[SeedIndex], ArchetypeIndex, Level, *TargetChunk.ToString(), + *DirectComparison.FirstMismatch); + } + + ++ArchetypeLevelCounts[ArchetypeIndex][Level]; + ++ProductionArchetypeBlocks; + ++BoundaryBlocks; + + if (!World.StrateManager->UsesOperatorStackForChunk(TargetChunk) + || World.StrateManager->GetGeneratorTypeForChunk(TargetChunk) + != Archetypes[ArchetypeIndex]) + { + AddError(FString::Printf( + TEXT("seed %d archetype %d target chunk did not route to its production operator stack"), + Seeds[SeedIndex], ArchetypeIndex)); + return false; + } + } + + // Passage-mouth lattices centered in a real inter-strate gap exercise the routed + // TunnelNetwork/Underwater block implementation through boundaries and bedrock holes. + for (int32 PassageGrid = 0; PassageGrid < PassageGridCount; ++PassageGrid) + { + const int32 GridIndex = ArchetypeCount + PassageGrid; + const int32 ArchetypeIndex = PassageGrid == 0 ? 0 : 7; + const int32 Level = (SeedIndex + (PassageGrid == 0 ? 0 : 7)) % 6; + const int32 Step = 1 << Level; + const FVoxelPassage& Passage = Passages[(SeedIndex + PassageGrid) % Passages.Num()]; + const FVector Focus = PassageGrid == 0 ? Passage.UpperPoint : Passage.LowerPoint; + const FIntVector TargetChunk( + FMath::FloorToInt(Focus.X / CHUNK_SIZE), + FMath::FloorToInt(Focus.Y / CHUNK_SIZE), + GapChunkZ); + GridOrigins[GridIndex] = MakeMesherGridOrigin(TargetChunk, Level); + GridSteps[GridIndex] = Step; + GridArchetypes[GridIndex] = ArchetypeIndex; + ++PassageFocusedBlocks; + if (CountGapSamples(World, GridOrigins[GridIndex], Step) > 0) + { + ++GapCoverageBlocks; + ++SpecialGapCoverageBlocks; + } + } + + // No-edit grids. In the two canonical network archetypes this enters the actual worker + // block session, which partitions each mesher grid by resolved chunk before running the + // active operator list. The six legacy density routes still run their production scalar + // evaluator, as they do outside the block-safe network branch. + for (int32 GridIndex = 0; GridIndex < GridSetCount; ++GridIndex) + { + BaseResults[GridIndex] = CompareGeneratorBlockWithScalar( + *World.Generator.Get(), GridOrigins[GridIndex], GridSteps[GridIndex]); + GeneratorSamples += BaseResults[GridIndex].Samples; + GeneratorMismatches += BaseResults[GridIndex].Mismatches; + ++GeneratorBlocks; + if (FirstGeneratorMismatch.IsEmpty() && BaseResults[GridIndex].Mismatches > 0) + { + FirstGeneratorMismatch = FString::Printf( + TEXT("seed=%d archetype=%d level=%d edited=0 origin=%s %s"), + Seeds[SeedIndex], GridArchetypes[GridIndex], + FMath::FloorLog2(GridSteps[GridIndex]), *GridOrigins[GridIndex].ToString(), + *BaseResults[GridIndex].FirstMismatch); + } + } + + // Put one real diff-layer box edit in every mesher grid, then compare the same exact + // density lattices again. The scalar pass below runs with these player edits present. + for (int32 GridIndex = 0; GridIndex < GridSetCount; ++GridIndex) + { + const int32 Step = GridSteps[GridIndex]; + FVoxelModification Edit; + Edit.Shape = EVoxelBrushShape::Box; + Edit.Center = FVector(GridOrigins[GridIndex]) + FVector(17 * Step); + Edit.BoxExtent = FVector(2.0f * Step); + Edit.Falloff = 1.0f; + Edit.Strength = -13.0f; + const TArray ChangedChunks = World.DiffLayer->ApplyModification(Edit); + if (ChangedChunks.IsEmpty()) + { + AddError(FString::Printf( + TEXT("seed %d edit for grid %d archetype %d was rejected"), + Seeds[SeedIndex], GridIndex, GridArchetypes[GridIndex])); + return false; + } + ++AcceptedEdits; + } + + for (int32 GridIndex = 0; GridIndex < GridSetCount; ++GridIndex) + { + const FGridComparison Edited = CompareGeneratorBlockWithScalar( + *World.Generator.Get(), GridOrigins[GridIndex], GridSteps[GridIndex]); + GeneratorSamples += Edited.Samples; + GeneratorMismatches += Edited.Mismatches; + ++GeneratorBlocks; + if (FirstGeneratorMismatch.IsEmpty() && Edited.Mismatches > 0) + { + FirstGeneratorMismatch = FString::Printf( + TEXT("seed=%d archetype=%d level=%d edited=1 origin=%s %s"), + Seeds[SeedIndex], GridArchetypes[GridIndex], + FMath::FloorLog2(GridSteps[GridIndex]), *GridOrigins[GridIndex].ToString(), + *Edited.FirstMismatch); + } + + const int32 SharedCount = FMath::Min( + Edited.BlockDensities.Num(), BaseResults[GridIndex].BlockDensities.Num()); + for (int32 SampleIndex = 0; SampleIndex < SharedCount; ++SampleIndex) + { + if (!SameFloatBits(Edited.BlockDensities[SampleIndex], + BaseResults[GridIndex].BlockDensities[SampleIndex])) + { + ++EditedSamplesChanged; + } + } + } + } + + bool bEveryArchetypeCoveredEveryLevel = true; + for (int32 ArchetypeIndex = 0; ArchetypeIndex < ArchetypeCount; ++ArchetypeIndex) + { + for (int32 Level = 0; Level < 6; ++Level) + { + bEveryArchetypeCoveredEveryLevel &= ArchetypeLevelCounts[ArchetypeIndex][Level] > 0; + } + } + + AddInfo(FString::Printf( + TEXT("BeginDensityBlock grid vs scalar GetDensityAt: seeds=%d archetype_grids=%d all_archetype_levels=%d boundary_grids=%d near_origin=%d far_origin=%d blocks=%d samples=%lld mismatches=%d gap_grids=%d passage_focused=%d accepted_edits=%d edited_samples_changed=%d first=%s"), + UE_ARRAY_COUNT(Seeds), ProductionArchetypeBlocks, + bEveryArchetypeCoveredEveryLevel ? 1 : 0, BoundaryBlocks, NearOriginBlocks, + FarOriginBlocks, GeneratorBlocks, static_cast(GeneratorSamples), + GeneratorMismatches, GapCoverageBlocks, PassageFocusedBlocks, AcceptedEdits, + EditedSamplesChanged, + FirstGeneratorMismatch.IsEmpty() ? TEXT("none") : *FirstGeneratorMismatch)); + AddInfo(FString::Printf( + TEXT("chunk-local direct EvalBlock vs EvalInternal: blocks=%d mesher_samples=%lld mismatches=%d first=%s"), + DirectStackBlocks, static_cast(DirectStackSamples), + DirectStackMismatches, + FirstDirectStackMismatch.IsEmpty() ? TEXT("none") : *FirstDirectStackMismatch)); + + TestEqual(TEXT("all eight production operator archetypes get six complete mesher grids"), + ProductionArchetypeBlocks, + static_cast(UE_ARRAY_COUNT(Seeds)) * ArchetypeCount); + TestTrue(TEXT("every production operator archetype covers every meshing level 0..5"), + bEveryArchetypeCoveredEveryLevel); + TestEqual(TEXT("all production archetype grids touch a strate boundary"), + BoundaryBlocks, ProductionArchetypeBlocks); + TestTrue(TEXT("test includes near-origin and both-sign far-field grids"), + NearOriginBlocks > 0 && FarOriginBlocks > 0); + TestEqual(TEXT("each passage-focused grid includes a real gap sample"), + SpecialGapCoverageBlocks, + static_cast(UE_ARRAY_COUNT(Seeds)) * PassageGridCount); + TestEqual(TEXT("passage-focused grids cover both network archetypes per seed"), + PassageFocusedBlocks, + static_cast(UE_ARRAY_COUNT(Seeds)) * PassageGridCount); + TestEqual(TEXT("all block-session density grids match scalar output bit-for-bit"), + GeneratorMismatches, 0); + TestEqual(TEXT("all production stack chunk blocks match scalar bit-for-bit"), + DirectStackMismatches, 0); + TestEqual(TEXT("one valid player edit was applied to every tested grid"), + AcceptedEdits, static_cast(UE_ARRAY_COUNT(Seeds)) * GridSetCount); + TestTrue(TEXT("player edits alter samples consumed by the mesher"), + EditedSamplesChanged > 0); + return true; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/VoxelClipmapDesiredTiles.h b/Source/VoxelForge/Private/VoxelClipmapDesiredTiles.h index 18bb989..c3b2876 100644 --- a/Source/VoxelForge/Private/VoxelClipmapDesiredTiles.h +++ b/Source/VoxelForge/Private/VoxelClipmapDesiredTiles.h @@ -24,6 +24,27 @@ namespace VoxelClipmapDesiredTiles FloorDiv(Value.Z, Divisor)); } + FORCEINLINE int32 CeilDiv(int32 Value, int32 Divisor) + { + return -FloorDiv(-Value, Divisor); + } + + FORCEINLINE void AlignedRingBounds(const FIntVector& Center, int32 Radius, int32 Alignment, + FIntVector& OutMin, FIntVector& OutMax) + { + const auto AlignAxis = [Radius, Alignment](int32 CenterAxis, + int32& OutAxisMin, int32& OutAxisMax) + { + const int32 BaseMin = CenterAxis - Radius; + const int32 BaseEnd = CenterAxis + Radius + 1; + OutAxisMin = FloorDiv(BaseMin, Alignment) * Alignment; + OutAxisMax = CeilDiv(BaseEnd, Alignment) * Alignment - 1; + }; + AlignAxis(Center.X, OutMin.X, OutMax.X); + AlignAxis(Center.Y, OutMin.Y, OutMax.Y); + AlignAxis(Center.Z, OutMin.Z, OutMax.Z); + } + FORCEINLINE int32 OuterShellRadius(const FParameters& Parameters, int32 Radius, int32 MaxLevel) { if (Parameters.RenderDistanceChunks <= 0) @@ -81,18 +102,35 @@ namespace VoxelClipmapDesiredTiles OuterShell(Parameters, Radius, MaxLevel, SheetLevel, SheetRadius); const bool bSheetRing = SheetLevel > MaxLevel; const int32 MaxMCRingLevel = bSheetRing ? SheetLevel - 1 : MaxLevel; + FIntVector PreviousRingMin = FIntVector::ZeroValue; + FIntVector PreviousRingMax = FIntVector::ZeroValue; + FIntVector MCRingMin = FIntVector::ZeroValue; + FIntVector MCRingMax = FIntVector::ZeroValue; for (int32 Level = 0; Level <= MaxMCRingLevel; ++Level) { const int32 Pow = 1 << Level; const FIntVector CenterLevel = FloorDiv(Center, Pow); - const FIntVector CenterFiner = Level > 0 - ? FloorDiv(Center, Pow >> 1) : FIntVector::ZeroValue; const int32 LevelRadius = (Level == MaxLevel && !bSheetRing) ? OuterRadius : Radius; - int32 DeltaZMin = -LevelRadius; - int32 DeltaZMax = LevelRadius; + // Every finer shell must end on a complete child-pair boundary so a coarser tile is + // either wholly inside it or wholly outside it. The final MC ring uses the sheet's + // grouping width when a sheet ring follows it; otherwise the ordinary LOD boundary + // only needs pair alignment. + const int32 RingAlignment = Level < MaxMCRingLevel + ? 2 + : (bSheetRing ? (1 << (SheetLevel - MaxMCRingLevel)) : 1); + FIntVector RingMin, RingMax; + AlignedRingBounds(CenterLevel, LevelRadius, RingAlignment, RingMin, RingMax); + if (Level == MaxMCRingLevel) + { + MCRingMin = RingMin; + MCRingMax = RingMax; + } + + int32 DeltaZMin = RingMin.Z - CenterLevel.Z; + int32 DeltaZMax = RingMax.Z - CenterLevel.Z; if (LevelRadius > Radius && MinChunkZ != MIN_int32) { DeltaZMin = FMath::Max(DeltaZMin, FloorDiv(MinChunkZ, Pow) - CenterLevel.Z); @@ -100,8 +138,10 @@ namespace VoxelClipmapDesiredTiles } for (int32 DeltaZ = DeltaZMin; DeltaZ <= DeltaZMax; ++DeltaZ) - for (int32 DeltaY = -LevelRadius; DeltaY <= LevelRadius; ++DeltaY) - for (int32 DeltaX = -LevelRadius; DeltaX <= LevelRadius; ++DeltaX) + for (int32 DeltaY = RingMin.Y - CenterLevel.Y; + DeltaY <= RingMax.Y - CenterLevel.Y; ++DeltaY) + for (int32 DeltaX = RingMin.X - CenterLevel.X; + DeltaX <= RingMax.X - CenterLevel.X; ++DeltaX) { const FIntVector Tile = CenterLevel + FIntVector(DeltaX, DeltaY, DeltaZ); const int32 TileMinZ = Tile.Z << Level; @@ -114,12 +154,12 @@ namespace VoxelClipmapDesiredTiles if (Level > 0) { const bool bCovered = - (2 * Tile.X >= CenterFiner.X - Radius) - && (2 * Tile.X + 1 <= CenterFiner.X + Radius) - && (2 * Tile.Y >= CenterFiner.Y - Radius) - && (2 * Tile.Y + 1 <= CenterFiner.Y + Radius) - && (2 * Tile.Z >= CenterFiner.Z - Radius) - && (2 * Tile.Z + 1 <= CenterFiner.Z + Radius); + (2 * Tile.X >= PreviousRingMin.X) + && (2 * Tile.X + 1 <= PreviousRingMax.X) + && (2 * Tile.Y >= PreviousRingMin.Y) + && (2 * Tile.Y + 1 <= PreviousRingMax.Y) + && (2 * Tile.Z >= PreviousRingMin.Z) + && (2 * Tile.Z + 1 <= PreviousRingMax.Z); if (bCovered) { continue; @@ -128,14 +168,17 @@ namespace VoxelClipmapDesiredTiles OutTiles.Emplace(Tile, Level); } + + PreviousRingMin = RingMin; + PreviousRingMax = RingMax; } if (bSheetRing) { const int32 SheetPow = 1 << SheetLevel; const FIntVector CenterSheet = FloorDiv(Center, SheetPow); - const FIntVector CenterMC = FloorDiv(Center, 1 << MaxMCRingLevel); const int32 LevelDelta = SheetLevel - MaxMCRingLevel; + const int32 MCPerSheet = 1 << LevelDelta; int32 DeltaZMin = -SheetRadius; int32 DeltaZMax = SheetRadius; @@ -160,12 +203,12 @@ namespace VoxelClipmapDesiredTiles } const bool bCovered = - ((Tile.X << LevelDelta) >= CenterMC.X - Radius) - && ((((Tile.X + 1) << LevelDelta) - 1) <= CenterMC.X + Radius) - && ((Tile.Y << LevelDelta) >= CenterMC.Y - Radius) - && ((((Tile.Y + 1) << LevelDelta) - 1) <= CenterMC.Y + Radius) - && ((Tile.Z << LevelDelta) >= CenterMC.Z - Radius) - && ((((Tile.Z + 1) << LevelDelta) - 1) <= CenterMC.Z + Radius); + (Tile.X * MCPerSheet >= MCRingMin.X) + && ((Tile.X + 1) * MCPerSheet - 1 <= MCRingMax.X) + && (Tile.Y * MCPerSheet >= MCRingMin.Y) + && ((Tile.Y + 1) * MCPerSheet - 1 <= MCRingMax.Y) + && (Tile.Z * MCPerSheet >= MCRingMin.Z) + && ((Tile.Z + 1) * MCPerSheet - 1 <= MCRingMax.Z); if (!bCovered) { OutTiles.Emplace(Tile, SheetLevel); diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index beb53e3..0a66e80 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -3407,6 +3407,16 @@ namespace // unknown/evicted proof data therefore falls back to sampling and cannot change the mesh. constexpr uint64 SharedRoomGraphCacheBudgetBytes = 256ull * 1024ull * 1024ull; + static TSharedPtr + SnapshotSharedRoomGraphCache( + const TSharedPtr& Entry) + { + FScopeLock Lock(&GSharedRoomGraphCacheMutex); + return Entry.IsValid() + ? Entry->Cache + : TSharedPtr(); + } + int32 SharedRoomGraphCacheBuildBank(int32 RegionSize) { return RegionSize <= 2 * CHUNK_SIZE ? 0 : 1; @@ -3593,7 +3603,10 @@ namespace Entry->RegionSize = RegionSize; Entry->LastUse = 0; Entry->AllocatedBytes = AllocatedBytes; - Entry->Cache = BuiltCache; + { + FScopeLock Lock(&GSharedRoomGraphCacheMutex); + Entry->Cache = BuiltCache; + } } else { @@ -3647,7 +3660,7 @@ namespace return Entry; } - if (Entry->Cache.IsValid()) + if (SnapshotSharedRoomGraphCache(Entry).IsValid()) { return Entry; } @@ -4119,9 +4132,13 @@ namespace if (SampleStep > 1) { + TSharedPtr SharedRoomGraphCache = + S.SharedCacheEntry.IsValid() + ? SnapshotSharedRoomGraphCache(S.SharedCacheEntry) + : TSharedPtr(); const bool bSharedCacheMatches = S.SharedCacheEntry.IsValid() - && S.SharedCacheEntry->Cache.IsValid() + && SharedRoomGraphCache.IsValid() && S.SharedCacheEntry->Seed == SeedU && S.SharedCacheEntry->StrateIndex == StrateIdx && S.SharedCacheEntry->ParamsFingerprint == ParamsFingerprint @@ -4143,8 +4160,9 @@ namespace (float)(RegionMinY + RegionSize) + CacheExpansion, P, ERoomGraphBuildSite::OpShared); + SharedRoomGraphCache = SnapshotSharedRoomGraphCache(S.SharedCacheEntry); } - S.ActiveCache = S.SharedCacheEntry->Cache.Get(); + S.ActiveCache = SharedRoomGraphCache.Get(); } else { @@ -5109,7 +5127,9 @@ namespace * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + 2.0f), P, ERoomGraphBuildSite::ClassifierShared); - B.ActiveCache = SharedCache->Cache.Get(); + const TSharedPtr + SharedRoomGraphCache = SnapshotSharedRoomGraphCache(SharedCache); + B.ActiveCache = SharedRoomGraphCache.Get(); B.CacheWindowSharedEntry = SharedCache; B.CacheWindowSharedRegionMinX = SharedRegionMinX; B.CacheWindowSharedRegionMinY = SharedRegionMinY; @@ -5165,7 +5185,8 @@ namespace } else if (B.bCacheWindowUsesShared) { - B.ActiveCache = B.CacheWindowSharedEntry->Cache.Get(); + B.ActiveCache = SnapshotSharedRoomGraphCache( + B.CacheWindowSharedEntry).Get(); } // Large root boxes are only a cache warm-up. Their interval is intentionally still // unknown; proof resumes at bounded descendants where all bounds below are finite. diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index 6cc5806..b8cd023 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -592,6 +592,8 @@ void AVoxelWorld::RegenerateAllChunks() bAllChunksLoaded = false; DesiredSorted.Reset(); CriticalDesiredTiles.Reset(); + DesiredTransitionDescendants.Reset(); + DesiredTransitionMaxLevel = 0; DesiredStamped.Reset(); ++DesiredEpoch; TransitionHold.Reset(); @@ -1421,6 +1423,7 @@ void AVoxelWorld::EndPlay(const EEndPlayReason::Type EndPlayReason) VoxelForgeStartupTrace::Finish(TEXT("end_play_before_steady_state")); } + LogReadyTransitionVisibilityMeasure(); Super::EndPlay(EndPlayReason); } @@ -1429,6 +1432,17 @@ void AVoxelWorld::BeginPlay() VoxelForgeStartupTrace::BeginFromCommandLine(); VoxelForgeStartupTrace::FStageScope StartupTraceStage(TEXT("BeginPlay")); Super::BeginPlay(); + int32 MeasureReadyTransitionVisibility = 0; + FParse::Value(FCommandLine::Get(), + TEXT("voxel.MeasureReadyTransitionVisibility="), MeasureReadyTransitionVisibility); + bMeasureReadyTransitionVisibility = MeasureReadyTransitionVisibility != 0; + ReadyTransitionVisibilitySamples.Reset(); + FarFieldSubmitSamples.Reset(); + if (bMeasureReadyTransitionVisibility) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeReadyVisibilityMeasure] enabled=1 instrumentation=command_line")); + } VoxelCaveMorphology::ConfigurePlayerFitMemoFromCommandLine(); VoxelCaveMorphology::ResetPlayerFitMemoStats(); bShuttingDown.store(false, std::memory_order_relaxed); @@ -3724,6 +3738,90 @@ void AVoxelWorld::LogVisualStalenessAudit() VisualAuditLODOverlapMaxPairs); } +void AVoxelWorld::LogReadyTransitionVisibilityMeasure() +{ + if (!bMeasureReadyTransitionVisibility) + { + return; + } + + if (!ReadyTransitionVisibilitySamples.IsEmpty()) + { + TArray Sorted = ReadyTransitionVisibilitySamples; + Sorted.Sort([](const FReadyTransitionVisibilitySample& A, + const FReadyTransitionVisibilitySample& B) + { + return A.Cycles < B.Cycles; + }); + const int32 P50Index = Sorted.Num() / 2; + const int32 P95Index = FMath::Clamp( + FMath::CeilToInt(static_cast(Sorted.Num()) * 0.95f) - 1, + 0, Sorted.Num() - 1); + const FReadyTransitionVisibilitySample& P95 = Sorted[P95Index]; + const FReadyTransitionVisibilitySample& Max = Sorted.Last(); + + int32 MinComponents = MAX_int32, MaxComponents = 0; + int32 MinDesired = MAX_int32, MaxDesired = 0; + int64 TotalComponents = 0, TotalDesired = 0; + for (const FReadyTransitionVisibilitySample& Sample : Sorted) + { + MinComponents = FMath::Min(MinComponents, Sample.TileComponentCount); + MaxComponents = FMath::Max(MaxComponents, Sample.TileComponentCount); + MinDesired = FMath::Min(MinDesired, Sample.DesiredTileCount); + MaxDesired = FMath::Max(MaxDesired, Sample.DesiredTileCount); + TotalComponents += Sample.TileComponentCount; + TotalDesired += Sample.DesiredTileCount; + } + + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeReadyVisibilityMeasure] samples=%d p50_ms=%.6f p95_ms=%.6f max_ms=%.6f " + "p95_components=%d p95_desired=%d components_range=%d..%d desired_range=%d..%d " + "components_mean=%.1f desired_mean=%.1f"), + Sorted.Num(), + FPlatformTime::ToSeconds64(Sorted[P50Index].Cycles) * 1000.0, + FPlatformTime::ToSeconds64(P95.Cycles) * 1000.0, + FPlatformTime::ToSeconds64(Max.Cycles) * 1000.0, + P95.TileComponentCount, P95.DesiredTileCount, + MinComponents, MaxComponents, MinDesired, MaxDesired, + static_cast(TotalComponents) / Sorted.Num(), + static_cast(TotalDesired) / Sorted.Num()); + } + + if (!FarFieldSubmitSamples.IsEmpty()) + { + TArray Sorted = FarFieldSubmitSamples; + Sorted.Sort([](const FFarFieldSubmitSample& A, const FFarFieldSubmitSample& B) + { + return A.Cycles < B.Cycles; + }); + const int32 P50Index = Sorted.Num() / 2; + const int32 P95Index = FMath::Clamp( + FMath::CeilToInt(static_cast(Sorted.Num()) * 0.95f) - 1, + 0, Sorted.Num() - 1); + const FFarFieldSubmitSample& P95 = Sorted[P95Index]; + const FFarFieldSubmitSample& Max = Sorted.Last(); + + int32 MinDesired = MAX_int32, MaxDesired = 0; + int32 MinLevel = MAX_int32, MaxLevel = 0; + for (const FFarFieldSubmitSample& Sample : Sorted) + { + MinDesired = FMath::Min(MinDesired, Sample.DesiredTileCount); + MaxDesired = FMath::Max(MaxDesired, Sample.DesiredTileCount); + MinLevel = FMath::Min(MinLevel, Sample.MaxDesiredLevel); + MaxLevel = FMath::Max(MaxLevel, Sample.MaxDesiredLevel); + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeFarFieldSubmitMeasure] samples=%d p50_ms=%.6f p95_ms=%.6f max_ms=%.6f " + "p95_desired=%d p95_max_level=%d desired_range=%d..%d levels=%d..%d"), + Sorted.Num(), + FPlatformTime::ToSeconds64(Sorted[P50Index].Cycles) * 1000.0, + FPlatformTime::ToSeconds64(P95.Cycles) * 1000.0, + FPlatformTime::ToSeconds64(Max.Cycles) * 1000.0, + P95.DesiredTileCount, P95.MaxDesiredLevel, + MinDesired, MaxDesired, MinLevel, MaxLevel); + } +} + void AVoxelWorld::Tick(float DeltaTime) { Super::Tick(DeltaTime); @@ -3932,9 +4030,9 @@ void AVoxelWorld::Tick(float DeltaTime) } // Complete the one-shot diagnostic request only after the player's level-0 centre tile is - // loaded and idle. Waiting for any coarse tile would take the async-neighbour branch and would - // not measure the normal synchronous centre remesh that makes a local edit feel expensive. - // No pawn is a distinct state from a pawn at (0,0,0); keep the diagnostic fallback for + // loaded and idle. The edit fixture selects an already-visible level-0 geometry tile, so it + // measures the normal synchronous centre remesh without depending on a coarse overlay being + // visible. No pawn is distinct from a pawn at (0,0,0); retain the actor-location fallback for // headless/editor use without making the world-origin pawn skip streaming. FVector ModificationPosition = bHasPlayer ? PlayerLastPos : GetActorLocation(); FVoxelTileKey ModificationCenterTile( @@ -3962,17 +4060,8 @@ void AVoxelWorld::Tick(float DeltaTime) } if (GVoxelForgeTestModificationLoadedGeometryTarget) { - bool bHasVisibleCoarseOverlay = false; - for (const TPair& Pair : TileComponents) - { - if (Pair.Key.Level > 0 && Pair.Value && Pair.Value->IsVisible() - && VF_TileFootprintsOverlap(CandidateTile, Pair.Key)) - { - bHasVisibleCoarseOverlay = true; - break; - } - } - if (!bHasVisibleCoarseOverlay) + URealtimeMeshComponent* CandidateComponent = TileComponents.FindRef(CandidateTile); + if (!CandidateComponent || !CandidateComponent->IsVisible()) { continue; } @@ -5575,12 +5664,32 @@ void AVoxelWorld::HideOverlappingDesiredVisuals(const FVoxelTileKey& RetiredTile void AVoxelWorld::ReconcileReadyTransitionVisibility() { + const bool bMeasure = bMeasureReadyTransitionVisibility; + const uint64 MeasureStartCycles = bMeasure ? FPlatformTime::Cycles64() : 0; + const int32 ComponentCountAtStart = bMeasure ? TileComponents.Num() : 0; + const int32 DesiredCountAtStart = bMeasure ? DesiredSorted.Num() : 0; + const auto RecordMeasure = [this, bMeasure, MeasureStartCycles, + ComponentCountAtStart, DesiredCountAtStart]() + { + if (!bMeasure) + { + return; + } + FReadyTransitionVisibilitySample& Sample = + ReadyTransitionVisibilitySamples.AddDefaulted_GetRef(); + Sample.Cycles = FPlatformTime::Cycles64() - MeasureStartCycles; + Sample.TileComponentCount = ComponentCountAtStart; + Sample.DesiredTileCount = DesiredCountAtStart; + }; + if (bAllChunksLoaded && TransitionHold.Num() == 0 && PendingUnload.Num() == 0) { + RecordMeasure(); return; } if (DesiredSorted.Num() == 0 || TileComponents.Num() == 0) { + RecordMeasure(); return; } @@ -5590,6 +5699,11 @@ void AVoxelWorld::ReconcileReadyTransitionVisibility() // queue below. TArray ReadyToHide; ReadyToHide.Reserve(TileComponents.Num()); + auto GetAncestorAtLevel = [](const FVoxelTileKey& Tile, int32 AncestorLevel) + { + const int32 Divisor = 1 << (AncestorLevel - Tile.Level); + return FVoxelTileKey(VoxelClipmapDesiredTiles::FloorDiv(Tile.Coord, Divisor), AncestorLevel); + }; for (const TPair& Pair : TileComponents) { const FVoxelTileKey& OldTile = Pair.Key; @@ -5606,9 +5720,12 @@ void AVoxelWorld::ReconcileReadyTransitionVisibility() bool bHasReplacement = false; bool bAllReplacementsLoaded = true; - for (const FVoxelTileKey& NewTile : DesiredSorted) + // A coarser desired tile can only overlap this tile by containing it. Test those exact + // ancestors directly, then test desired finer tiles from the prebuilt ancestor index. + for (int32 Level = OldTile.Level + 1; Level <= DesiredTransitionMaxLevel; ++Level) { - if (!VF_TileFootprintsOverlap(OldTile, NewTile)) + const FVoxelTileKey NewTile = GetAncestorAtLevel(OldTile, Level); + if (!IsDesired(NewTile)) { continue; } @@ -5619,6 +5736,21 @@ void AVoxelWorld::ReconcileReadyTransitionVisibility() break; } } + if (bAllReplacementsLoaded) + { + if (const TArray* NewDescendants = DesiredTransitionDescendants.Find(OldTile)) + { + for (const FVoxelTileKey& NewTile : *NewDescendants) + { + bHasReplacement = true; + if (!LoadedTiles.Contains(NewTile)) + { + bAllReplacementsLoaded = false; + break; + } + } + } + } if (bHasReplacement && bAllReplacementsLoaded) { ReadyToHide.Add(OldTile); @@ -5634,6 +5766,26 @@ void AVoxelWorld::ReconcileReadyTransitionVisibility() // only part of the transition. Reveal them only after every overlapping retired component has // entered PendingUnload (and was therefore hidden above). Collision visibility is independent: // the component remains queryable until the budgeted UnloadTile call. + TSet VisibleRetiredTiles; + TSet VisibleRetiredAncestors; + int32 MaxVisibleRetiredLevel = DesiredTransitionMaxLevel; + for (const TPair& OldPair : TileComponents) + { + const FVoxelTileKey& OldTile = OldPair.Key; + URealtimeMeshComponent* OldComp = OldPair.Value; + if (IsDesired(OldTile) || PendingUnload.Contains(OldTile) + || !OldComp || !OldComp->IsVisible()) + { + continue; + } + VisibleRetiredTiles.Add(OldTile); + MaxVisibleRetiredLevel = FMath::Max(MaxVisibleRetiredLevel, OldTile.Level); + for (int32 Level = OldTile.Level + 1; Level <= DesiredTransitionMaxLevel; ++Level) + { + VisibleRetiredAncestors.Add(GetAncestorAtLevel(OldTile, Level)); + } + } + for (const TPair& NewPair : TileComponents) { const FVoxelTileKey& NewTile = NewPair.Key; @@ -5643,19 +5795,13 @@ void AVoxelWorld::ReconcileReadyTransitionVisibility() continue; } - bool bBlockedByVisibleRetiredTile = false; - for (const TPair& OldPair : TileComponents) + bool bBlockedByVisibleRetiredTile = VisibleRetiredAncestors.Contains(NewTile); + for (int32 Level = NewTile.Level + 1; + !bBlockedByVisibleRetiredTile && Level <= MaxVisibleRetiredLevel; ++Level) { - const FVoxelTileKey& OldTile = OldPair.Key; - if (IsDesired(OldTile) || PendingUnload.Contains(OldTile) - || !OldPair.Value || !OldPair.Value->IsVisible()) - { - continue; - } - if (VF_TileFootprintsOverlap(NewTile, OldTile)) + if (VisibleRetiredTiles.Contains(GetAncestorAtLevel(NewTile, Level))) { bBlockedByVisibleRetiredTile = true; - break; } } if (!bBlockedByVisibleRetiredTile) @@ -5663,6 +5809,8 @@ void AVoxelWorld::ReconcileReadyTransitionVisibility() NewComp->SetVisibility(true); } } + + RecordMeasure(); } // Integer floor-division (correct for negatives), scalar + vector. @@ -5855,6 +6003,36 @@ void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center, const FVector& Pla } } + // Build a dyadic ancestor index once per desired-set epoch. Different-level tile overlap is + // containment because every tile footprint is power-of-two aligned. This lets the per-frame + // transition pass inspect only actual overlapping candidates. + DesiredTransitionDescendants.Reset(); + DesiredTransitionMaxLevel = 0; + for (const FVoxelTileKey& Key : DesiredSorted) + { + DesiredTransitionMaxLevel = FMath::Max(DesiredTransitionMaxLevel, Key.Level); + } + for (const TPair& Pair : TileComponents) + { + DesiredTransitionMaxLevel = FMath::Max(DesiredTransitionMaxLevel, Pair.Key.Level); + } + // A worker from the previous desired epoch can finish late with a component at a level no + // longer present in TileComponents when this index is built. Index through the supported + // maximum sheet level (MaxClipLevel 8 + FarSheetSpanLevels 4) so that such a retired tile still + // finds all of its desired descendants when the result is reconciled. + constexpr int32 MaxSupportedTileLevel = 12; + const int32 DescendantIndexMaxLevel = FMath::Max( + DesiredTransitionMaxLevel, MaxSupportedTileLevel); + for (const FVoxelTileKey& Key : DesiredSorted) + { + for (int32 Level = Key.Level + 1; Level <= DescendantIndexMaxLevel; ++Level) + { + const int32 Divisor = 1 << (Level - Key.Level); + const FVoxelTileKey Ancestor(VF_FloorDiv(Key.Coord, Divisor), Level); + DesiredTransitionDescendants.FindOrAdd(Ancestor).Add(Key); + } + } + if (VoxelForgeStartupTrace::IsActive()) { int32 LOD0Count = 0; @@ -6433,6 +6611,8 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition, APaw // FAR FIELD — preserve the clipmap's level ordering explicitly. DesiredSorted is // distance-sorted for the critical/near work, but a level pass prevents a level-5 horizon // tile from being admitted ahead of an unfinished level-1/2 bridge tile. + const uint64 FarFieldMeasureStartCycles = bMeasureReadyTransitionVisibility + ? FPlatformTime::Cycles64() : 0; int32 MaxDesiredLevel = 0; for (const FVoxelTileKey& T : DesiredSorted) { @@ -6451,6 +6631,13 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition, APaw } if (PendingTiles.Num() >= CoarseTaskCap) break; } + if (bMeasureReadyTransitionVisibility) + { + FFarFieldSubmitSample& Sample = FarFieldSubmitSamples.AddDefaulted_GetRef(); + Sample.Cycles = FPlatformTime::Cycles64() - FarFieldMeasureStartCycles; + Sample.DesiredTileCount = DesiredSorted.Num(); + Sample.MaxDesiredLevel = MaxDesiredLevel; + } // Bande de strate changée : re-gen budgétée des tuiles grossières concernées (le vieux // mesh reste visible jusqu'au résultat — même schéma que RemeshDirtyChunks). diff --git a/Source/VoxelForge/Public/VoxelWorld.h b/Source/VoxelForge/Public/VoxelWorld.h index d8a9b09..3e2eb87 100644 --- a/Source/VoxelForge/Public/VoxelWorld.h +++ b/Source/VoxelForge/Public/VoxelWorld.h @@ -560,6 +560,7 @@ private: void MaybeFinishHeadlessStreamingTest(); void UpdateVisualStalenessAudit(); void LogVisualStalenessAudit(); + void LogReadyTransitionVisibilityMeasure(); void RecordTileHash(const FVoxelTileKey& Tile, const FIntVector& OriginVoxels, int32 Step, int32 Cells, int32 BandChunkLo, int32 BandChunkHi, bool bSheetTile, const FVoxelMeshData& MeshData); @@ -989,10 +990,30 @@ public: bool bSampleStacksStarted = false; TArray DesiredSorted; // critical floor prefix, then true-pawn distance-first TArray CriticalDesiredTiles; // occupied/support tile, then heading tile + // Ancestor -> desired descendants, rebuilt with DesiredSorted. Reconciliation uses this + // index to test transition coverage without rescanning the full desired set per old tile. + TMap> DesiredTransitionDescendants; + int32 DesiredTransitionMaxLevel = 0; uint32 DesiredEpoch = 0; // increments whenever DesiredStamped is rebuilt int32 ObsoleteTileAbortCount = 0; // cumulative per-world moving-session cancellation count float PeakObservedPawnSpeedCmPerSecond = 0.0f; + struct FReadyTransitionVisibilitySample + { + uint64 Cycles = 0; + int32 TileComponentCount = 0; + int32 DesiredTileCount = 0; + }; + struct FFarFieldSubmitSample + { + uint64 Cycles = 0; + int32 DesiredTileCount = 0; + int32 MaxDesiredLevel = 0; + }; + bool bMeasureReadyTransitionVisibility = false; + TArray ReadyTransitionVisibilitySamples; + TArray FarFieldSubmitSamples; + // Clean performance accounting. LOD 0..8 covers every marching-cubes clip level; sheet tiles // above the configured MC range never enter the outer classifier. static constexpr int32 TrackedClassifierLODCount = 9;