From 30253f0e891dade707ecbf210aee4cbd68b89073 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Wed, 23 Sep 2026 01:49:57 +0200 Subject: [PATCH] fix(streaming): vertical reach follows the strate, and the level below the sheet ring is filled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner marked missing tiles around him in a 64-chunk surface strate with RenderDistanceChunks=768. Cause: the desired-set vertical clamp used the player's small window (bClampViewToStrate on, ViewDistanceUp/Down=5, margin 3), so at chunk Z=0 only [-5..3] was ever requested; the rest of his strate was never asked for. Second cause: with MaxClipLevel=3 and a two-level sheet span the outer ring is level 5, while the marching-cubes loop stopped at 3, leaving level 4 empty. The band now follows the player's whole strate plus margin ([-66..3] in the pinned case) and keeps the small window only when there are no strate bounds (inter-strate gap), so the hidden-rock column the clamp exists to avoid does not return. Bridge shells are emitted up to the level below the sheet ring, filling level 4. Pinned case desired tiles per level, before -> after: l0 343->343, l1 218->218, l2 129->178, l3 80->178, l4 0->178, l5 4801->9586; total 5,571 -> 10,681 (+91.7%). New focused fixture VoxelForgeVerticalStrateReachTest fails before and passes after. Stress repro passes (140.089 m, 4 holds, 0 full stops); strate crossing passes (0 holds, 0 floor overlap). ⚠️ Cost not established: the post-fix perf sample was incomplete and showed a 55.7 s p50 queue wait, which may be the fixture's extreme settings (768 render distance -> 9,586 sheet tiles) or a real backlog. Measure in the owner's world before merging. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ --- .../VoxelForgeVerticalStrateReachTest.cpp | 89 +++++++ .../Private/VoxelClipmapDesiredTiles.h | 176 ++++++++++++++ Source/VoxelForge/Private/VoxelWorld.cpp | 225 +++++------------- Tools/VoxelForgeTest.ps1 | 19 +- ...nalyzeVoxelForgePerfProbes.cpython-312.pyc | Bin 0 -> 26900 bytes 5 files changed, 344 insertions(+), 165 deletions(-) create mode 100644 Source/VoxelForge/Private/Tests/VoxelForgeVerticalStrateReachTest.cpp create mode 100644 Source/VoxelForge/Private/VoxelClipmapDesiredTiles.h create mode 100644 Tools/__pycache__/AnalyzeVoxelForgePerfProbes.cpython-312.pyc diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeVerticalStrateReachTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeVerticalStrateReachTest.cpp new file mode 100644 index 0000000..9815bac --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeVerticalStrateReachTest.cpp @@ -0,0 +1,89 @@ +// Surface-strate streaming must keep the ground visible when the pawn is high in the same strate. + +#if WITH_DEV_AUTOMATION_TESTS + +#include "Misc/AutomationTest.h" + +#include "VoxelClipmapDesiredTiles.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeVerticalStrateReachTest, + "VoxelForge.Streaming.VerticalStrateReach", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +bool FVoxelForgeVerticalStrateReachTest::RunTest(const FString& Parameters) +{ + constexpr int32 StrateBottomZ = -63; + constexpr int32 StrateTopZ = 0; + constexpr int32 PlayerChunkZ = 0; // high above this surface strate's ground + + int32 MinChunkZ = MIN_int32; + int32 MaxChunkZ = MAX_int32; + VoxelClipmapDesiredTiles::ResolveVerticalBounds( + PlayerChunkZ, 5, 5, 3, true, true, + StrateTopZ, StrateBottomZ, MinChunkZ, MaxChunkZ); + TestEqual(TEXT("vertical reach follows the full surface strate plus margin"), MinChunkZ, -66); + TestEqual(TEXT("open-strate reach ends at the surface cap margin"), MaxChunkZ, 3); + + VoxelClipmapDesiredTiles::FParameters Clipmap; + Clipmap.ClipRadius = 3; + Clipmap.MaxClipLevel = 3; + Clipmap.RenderDistanceChunks = 768; + Clipmap.bFarSheetRing = true; + Clipmap.FarSheetSpanLevels = 2; + + TArray Desired; + VoxelClipmapDesiredTiles::Build( + FIntVector(0, 0, PlayerChunkZ), MinChunkZ, MaxChunkZ, Clipmap, Desired); + + TArray CountsByLevel; + CountsByLevel.Init(0, 6); + for (const FVoxelTileKey& Tile : Desired) + { + if (CountsByLevel.IsValidIndex(Tile.Level)) + { + ++CountsByLevel[Tile.Level]; + } + } + + const int32 ExpectedCounts[] = {343, 218, 178, 178, 178, 9586}; + for (int32 Level = 0; Level < static_cast(UE_ARRAY_COUNT(ExpectedCounts)); ++Level) + { + TestEqual(FString::Printf(TEXT("desired tile count at level %d"), Level), + CountsByLevel[Level], ExpectedCounts[Level]); + } + + const auto HasTile = [&Desired](int32 Level, const FIntVector& Coord) + { + return Desired.ContainsByPredicate([Level, &Coord](const FVoxelTileKey& Tile) + { + return Tile.Level == Level && Tile.Coord == Coord; + }); + }; + + TestTrue(TEXT("level 0 tile around the high pawn is present"), + HasTile(0, FIntVector(0, 0, 0))); + TestTrue(TEXT("level 4 bridge tile covering the ground below the pawn is present"), + HasTile(4, FIntVector(0, 0, -3))); + TestTrue(TEXT("adjacent level 4 ground tile is present"), + HasTile(4, FIntVector(1, 0, -3))); + TestTrue(TEXT("outer level 5 sheet reaches the 768-chunk horizon"), + HasTile(5, FIntVector(24, 0, 0))); + TestFalse(TEXT("clamping does not request a full hidden LOD0 column"), + HasTile(0, FIntVector(0, 0, -47))); + + for (int32 Level = 0; Level <= 5; ++Level) + { + TestTrue(FString::Printf(TEXT("clip level %d is non-empty"), Level), + CountsByLevel[Level] > 0); + } + + AddInfo(FString::Printf( + TEXT("center_z=%d strate=[%d..%d] view=[%d..%d] levels=[%d,%d,%d,%d,%d,%d] total=%d"), + PlayerChunkZ, StrateBottomZ, StrateTopZ, MinChunkZ, MaxChunkZ, + CountsByLevel[0], CountsByLevel[1], CountsByLevel[2], CountsByLevel[3], + CountsByLevel[4], CountsByLevel[5], Desired.Num())); + return true; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/VoxelClipmapDesiredTiles.h b/Source/VoxelForge/Private/VoxelClipmapDesiredTiles.h new file mode 100644 index 0000000..18bb989 --- /dev/null +++ b/Source/VoxelForge/Private/VoxelClipmapDesiredTiles.h @@ -0,0 +1,176 @@ +#pragma once + +#include "VoxelTypes.h" + +namespace VoxelClipmapDesiredTiles +{ + struct FParameters + { + int32 ClipRadius = 3; + int32 MaxClipLevel = 4; + int32 RenderDistanceChunks = 0; + bool bFarSheetRing = true; + int32 FarSheetSpanLevels = 2; + }; + + FORCEINLINE int32 FloorDiv(int32 Value, int32 Divisor) + { + return Value >= 0 ? (Value / Divisor) : -(((-Value) + Divisor - 1) / Divisor); + } + + FORCEINLINE FIntVector FloorDiv(const FIntVector& Value, int32 Divisor) + { + return FIntVector(FloorDiv(Value.X, Divisor), FloorDiv(Value.Y, Divisor), + FloorDiv(Value.Z, Divisor)); + } + + FORCEINLINE int32 OuterShellRadius(const FParameters& Parameters, int32 Radius, int32 MaxLevel) + { + if (Parameters.RenderDistanceChunks <= 0) + { + return Radius; + } + return FMath::Max(Radius, + (Parameters.RenderDistanceChunks + (1 << MaxLevel) - 1) >> MaxLevel); + } + + FORCEINLINE void OuterShell(const FParameters& Parameters, int32 Radius, int32 MaxLevel, + int32& OutLevel, int32& OutRadius) + { + OutLevel = MaxLevel; + OutRadius = OuterShellRadius(Parameters, Radius, MaxLevel); + if (Parameters.bFarSheetRing && OutRadius > Radius) + { + OutLevel = MaxLevel + FMath::Clamp(Parameters.FarSheetSpanLevels, 1, 4); + OutRadius = FMath::Max(1, + (Parameters.RenderDistanceChunks + (1 << OutLevel) - 1) >> OutLevel); + } + } + + FORCEINLINE void ResolveVerticalBounds( + int32 CenterChunkZ, int32 ViewDistanceUp, int32 ViewDistanceDown, + int32 StrateViewMarginChunks, bool bClampToStrate, bool bHasStrate, + int32 StrateTopChunkZ, int32 StrateBottomChunkZ, + int32& OutMinChunkZ, int32& OutMaxChunkZ) + { + OutMinChunkZ = MIN_int32; + OutMaxChunkZ = MAX_int32; + if (!bClampToStrate) + { + return; + } + + OutMinChunkZ = CenterChunkZ - ViewDistanceDown; + OutMaxChunkZ = CenterChunkZ + ViewDistanceUp; + if (bHasStrate) + { + OutMinChunkZ = StrateBottomChunkZ - StrateViewMarginChunks; + OutMaxChunkZ = StrateTopChunkZ + StrateViewMarginChunks; + } + } + + FORCEINLINE void Build(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 = OuterShellRadius(Parameters, Radius, MaxLevel); + int32 SheetLevel = MaxLevel; + int32 SheetRadius = OuterRadius; + 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 = 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; + if (LevelRadius > Radius && MinChunkZ != MIN_int32) + { + DeltaZMin = FMath::Max(DeltaZMin, FloorDiv(MinChunkZ, Pow) - CenterLevel.Z); + DeltaZMax = FMath::Min(DeltaZMax, 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 = FloorDiv(Center, SheetPow); + const FIntVector CenterMC = FloorDiv(Center, 1 << MaxMCRingLevel); + const int32 LevelDelta = SheetLevel - MaxMCRingLevel; + + int32 DeltaZMin = -SheetRadius; + int32 DeltaZMax = SheetRadius; + if (MinChunkZ != MIN_int32) + { + DeltaZMin = FMath::Max(DeltaZMin, + FloorDiv(MinChunkZ, SheetPow) - CenterSheet.Z); + DeltaZMax = FMath::Min(DeltaZMax, + 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); + } + } + } + } +} diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index 82dd60c..d230762 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -21,6 +21,7 @@ #include "VoxelCaveMorphology.h" #include "VoxelPassageGeometry.h" #include "VoxelTilePostReach.h" +#include "VoxelClipmapDesiredTiles.h" // IWYU (FPSemantics = Precise ⇒ plus de PCH partagé) : GetPlayerPosition déréférence le pawn, donc // APawn doit être COMPLET — `Casts.h` n'en donne qu'une déclaration avant. APlayerController était // complet par transitivité seulement : on l'inclut explicitement, c'est exactement la fragilité @@ -53,6 +54,8 @@ #include "Serialization/JsonWriter.h" #include "ProfilingDebugging/CpuProfilerTrace.h" // Unreal Insights scopes (Perf 0) +static int32 VF_MaxMarchingCubesLevel(const UVoxelSettings* Settings); + #if WITH_EDITOR #include "VoxelStrateComposer.h" #include "VoxelStrateMeasure.h" @@ -3580,7 +3583,7 @@ void AVoxelWorld::Tick(float DeltaTime) Min.X, Min.Y, Min.Z, Max.X, Max.Y, Max.Z, *FString::SanitizeFloat(TileExtentMetres), *FString::SanitizeFloat(SpanX), *FString::SanitizeFloat(SpanY), *FString::SanitizeFloat(SpanZ), - Settings && Level > Settings->MaxClipLevel ? 1 : 0)); + Settings && Level > VF_MaxMarchingCubesLevel(Settings) ? 1 : 0)); } VoxelForgeStartupTrace::Finish(TEXT("desired_set_satisfied_and_queue_drained")); } @@ -5031,30 +5034,28 @@ static FORCEINLINE FIntVector VF_FloorDiv(const FIntVector& V, int32 D) return FIntVector(VF_FloorDiv(V.X, D), VF_FloorDiv(V.Y, D), VF_FloorDiv(V.Z, D)); } -// RENDER DISTANCE — rayon (en tuiles niveau-MaxLevel) de la coquille EXTERNE : ClipRadius, élargi -// si `RenderDistanceChunks` demande une portée horizontale au-delà du naturel R·2^MaxLevel. Partagé -// par BuildDesiredTiles (le desired set) et IsTileInClipRange (le même horizon pour le cull). -static FORCEINLINE int32 VF_OuterShellRadius(const UVoxelSettings* Settings, int32 R, int32 MaxLevel) -{ - const int32 Dist = Settings ? Settings->RenderDistanceChunks : 0; - if (Dist <= 0) return R; - return FMath::Max(R, (Dist + (1 << MaxLevel) - 1) >> MaxLevel); // ceil(Dist / 2^MaxLevel) -} - -// F18 — la coquille LA PLUS EXTERNE : niveau + rayon. Sans anneau feuille = (MaxLevel, rayon -// render-distance). Avec (`bFarSheetRing` et distance > portée naturelle) = l'anneau FEUILLE : -// niveau MaxLevel + FarSheetSpanLevels (une feuille couvre 2^span empreintes MC par axe), rayon -// re-dérivé à ce niveau. Partagé par BuildDesiredTiles et IsTileInClipRange (même horizon). +// F18 — the outer shell is either the MC render-distance level or the larger sheet ring. When +// the sheet span skips levels, VF_MaxMarchingCubesLevel fills them with MC bridge shells. +// Shared by desired selection and range culling so both use the same outer horizon. static FORCEINLINE void VF_OuterShell(const UVoxelSettings* Settings, int32 R, int32 MaxLevel, int32& OutLevel, int32& OutRadius) { - OutLevel = MaxLevel; - OutRadius = VF_OuterShellRadius(Settings, R, MaxLevel); - if (Settings && Settings->bFarSheetRing && OutRadius > R) - { - OutLevel = MaxLevel + FMath::Clamp(Settings->FarSheetSpanLevels, 1, 4); - OutRadius = FMath::Max(1, (Settings->RenderDistanceChunks + (1 << OutLevel) - 1) >> OutLevel); - } + VoxelClipmapDesiredTiles::FParameters Parameters; + Parameters.RenderDistanceChunks = Settings ? Settings->RenderDistanceChunks : 0; + Parameters.bFarSheetRing = Settings && Settings->bFarSheetRing; + Parameters.FarSheetSpanLevels = Settings ? Settings->FarSheetSpanLevels : 2; + VoxelClipmapDesiredTiles::OuterShell(Parameters, R, MaxLevel, OutLevel, OutRadius); +} + +// When an expanded sheet ring skips LOD levels, keep an MC bridge at every intervening level. +static int32 VF_MaxMarchingCubesLevel(const UVoxelSettings* Settings) +{ + const int32 Radius = Settings ? FMath::Max(1, Settings->ClipRadius) : 3; + const int32 MaxLevel = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4; + int32 OuterLevel = MaxLevel, OuterRadius = Radius; + VF_OuterShell(Settings, Radius, MaxLevel, OuterLevel, OuterRadius); + (void)OuterRadius; + return OuterLevel > MaxLevel ? OuterLevel - 1 : MaxLevel; } void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center, const FVector& PlayerPosition, @@ -5072,128 +5073,37 @@ void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center, const FVector& Pla const int32 R = Settings ? FMath::Max(1, Settings->ClipRadius) : 3; const int32 MaxLevel = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4; - // RENDER DISTANCE (`RenderDistanceChunks`) : la coquille EXTERNE continue au-delà du rayon - // naturel jusqu'à couvrir la distance demandée — en tuiles MC niveau-MaxClipLevel, ou (F18, - // `bFarSheetRing`) en tuiles FEUILLE plus grandes (niveau MaxLevel+span, deux heightfields - // sol/cap au lieu de marching cubes — cf. GenerateSheetMesh). IsTileInClipRange partage - // VF_OuterShell pour que le cull voie le même horizon. - const int32 ROuter = VF_OuterShellRadius(Settings, R, MaxLevel); - int32 SheetLevel = MaxLevel, RSheet = ROuter; - VF_OuterShell(Settings, R, MaxLevel, SheetLevel, RSheet); - const bool bSheetRing = SheetLevel > MaxLevel; + // Render-distance tiles expand horizontally. The sheet ring retains its configured span; + // intermediate levels are emitted as marching-cubes bridge shells below it. + // IsTileInClipRange shares the outer-shell calculation so culling sees this same horizon. + int32 StrateTopZ = 0, StrateBottomZ = 0; + bool bHasStrate = false; + if (StrateManager) + { + bHasStrate = StrateManager->GetStrateChunkZBounds(Center.Z, StrateTopZ, StrateBottomZ); + } - // Strate-aware VERTICAL band (in level-0 chunk-Z). Without this the clipmap generates the - // occluded volume above/below (sealed strates are light-tight, §8.7) AND the full underground - // depth — a huge column of invisible solid rock = the gen lag. So clamp the vertical reach to - // the player's strate ± margin (open-ceiling strates extend UP to the sky-cap). The clipmap - // stays full-reach HORIZONTALLY (the horizon) but limited vertically. ZLo/ZHi span ⇒ no clamp. + // Keep the clamp enabled, but cover the player's complete current strate plus its margin. + // Only when no strate bounds exist (the inter-strate gap) does the small player window apply. int32 ZLo = MIN_int32, ZHi = MAX_int32; - if (Settings && Settings->bClampViewToStrate && StrateManager) + VoxelClipmapDesiredTiles::ResolveVerticalBounds( + Center.Z, + Settings ? Settings->ViewDistanceUp : 5, + Settings ? Settings->ViewDistanceDown : 5, + Settings ? Settings->StrateViewMarginChunks : 3, + Settings && Settings->bClampViewToStrate, + bHasStrate, StrateTopZ, StrateBottomZ, ZLo, ZHi); + + VoxelClipmapDesiredTiles::FParameters TileParameters; + TileParameters.ClipRadius = R; + TileParameters.MaxClipLevel = MaxLevel; + TileParameters.RenderDistanceChunks = Settings ? Settings->RenderDistanceChunks : 0; + TileParameters.bFarSheetRing = Settings && Settings->bFarSheetRing; + TileParameters.FarSheetSpanLevels = Settings ? Settings->FarSheetSpanLevels : 2; + VoxelClipmapDesiredTiles::Build(Center, ZLo, ZHi, TileParameters, DesiredSorted); + for (const FVoxelTileKey& Key : DesiredSorted) { - const int32 Margin = Settings->StrateViewMarginChunks; - ZLo = Center.Z - Settings->ViewDistanceDown; - ZHi = Center.Z + Settings->ViewDistanceUp; - int32 StrTopZ = 0, StrBotZ = 0; - if (StrateManager->GetStrateChunkZBounds(Center.Z, StrTopZ, StrBotZ)) - { - const ECaveGeneratorType GenType = StrateManager->GetGeneratorTypeForChunk(Center); - const bool bOpen = (GenType == ECaveGeneratorType::SurfaceWorld - || GenType == ECaveGeneratorType::FloatingIslands); - ZLo = FMath::Max(ZLo, StrBotZ - Margin); - ZHi = bOpen ? (StrTopZ + Margin) : FMath::Min(ZHi, StrTopZ + Margin); - } - // else: in the bedrock gap → keep the player window (see both sides while descending). - } - - // Concentric shells: level 0 near the player, each coarser level a 2× larger shell beyond. - // A level-L tile is dropped if it's fully covered by the finer (L-1) level's box — that's - // the inner hole, so the shells tile space without big gaps. - for (int32 L = 0; L <= MaxLevel; ++L) - { - const int32 Pow = 1 << L; // level-L tile = 2^L chunks - const FIntVector CL = VF_FloorDiv(Center, Pow); // player's level-L tile coord - const FIntVector CF = (L > 0) ? VF_FloorDiv(Center, Pow >> 1) : FIntVector::ZeroValue; - - // Rayon de CE niveau : R partout, sauf la coquille externe (render distance) — qui, si - // l'anneau FEUILLE est actif (F18), est émise à part plus bas (le niveau MaxLevel reste - // alors à R). Le test "covered by finer" garde R (le niveau plus fin n'est jamais étendu). - const int32 RL = (L == MaxLevel && !bSheetRing) ? ROuter : R; - - // Anneau étendu : le balayage naïf serait (2·RL+1)³ — on restreint dz à la fenêtre de la - // clamp verticale AVANT la boucle (mêmes tuiles retenues : le `continue` Z ci-dessous - // rejetterait tout le reste). Sentinelles MIN/MAX (pas de clamp) ⇒ balayage plein. - int32 DzMin = -RL, DzMax = RL; - if (RL > R && ZLo != MIN_int32) - { - DzMin = FMath::Max(DzMin, VF_FloorDiv(ZLo, Pow) - CL.Z); - DzMax = FMath::Min(DzMax, VF_FloorDiv(ZHi, Pow) - CL.Z); - } - - for (int32 dz = DzMin; dz <= DzMax; ++dz) - for (int32 dy = -RL; dy <= RL; ++dy) - for (int32 dx = -RL; dx <= RL; ++dx) - { - const FIntVector T = CL + FIntVector(dx, dy, dz); - - // Strate-aware vertical clamp: drop tiles whose level-0 chunk-Z footprint doesn't - // overlap [ZLo, ZHi] (the occluded strate above/below / deep underground). - const int32 TZLo = T.Z << L; - const int32 TZHi = ((T.Z + 1) << L) - 1; - if (TZHi < ZLo || TZLo > ZHi) continue; - - if (L > 0) - { - // Covered by the finer level iff the level-(L-1) tiles 2T..2T+1 (per axis) - // all lie inside the finer box [CF-R, CF+R]. - const bool bCovered = - (2 * T.X >= CF.X - R) && (2 * T.X + 1 <= CF.X + R) && - (2 * T.Y >= CF.Y - R) && (2 * T.Y + 1 <= CF.Y + R) && - (2 * T.Z >= CF.Z - R) && (2 * T.Z + 1 <= CF.Z + R); - if (bCovered) continue; - } - const FVoxelTileKey Key(T, L); - DesiredSorted.Add(Key); - DesiredStamped.FindOrAdd(Key) = DesiredStamp; - } - } - - // F18 — ANNEAU FEUILLE : la portée render-distance est couverte par des tuiles feuille - // (niveau SheetLevel > MaxLevel, LoadTile route niveau > MaxClipLevel vers GenerateSheetMesh). - // Trou intérieur = la boîte MC niveau-MaxLevel (rayon R), pas le niveau SheetLevel−1. - if (bSheetRing) - { - const int32 SPow = 1 << SheetLevel; - const FIntVector CS = VF_FloorDiv(Center, SPow); - const FIntVector CM = VF_FloorDiv(Center, 1 << MaxLevel); - const int32 K = SheetLevel - MaxLevel; // 1 feuille = 2^K tuiles MC par axe - - int32 DzMin = -RSheet, DzMax = RSheet; - if (ZLo != MIN_int32) - { - DzMin = FMath::Max(DzMin, VF_FloorDiv(ZLo, SPow) - CS.Z); - DzMax = FMath::Min(DzMax, VF_FloorDiv(ZHi, SPow) - CS.Z); - } - - for (int32 dz = DzMin; dz <= DzMax; ++dz) - for (int32 dy = -RSheet; dy <= RSheet; ++dy) - for (int32 dx = -RSheet; dx <= RSheet; ++dx) - { - const FIntVector T = CS + FIntVector(dx, dy, dz); - const int32 TZLo = T.Z << SheetLevel; - const int32 TZHi = ((T.Z + 1) << SheetLevel) - 1; - if (TZHi < ZLo || TZLo > ZHi) continue; - - // Couverte par la boîte MC (empreinte entièrement dans [CM−R, CM+R] au niveau MaxLevel). - const bool bCovered = - ((T.X << K) >= CM.X - R) && ((((T.X + 1) << K) - 1) <= CM.X + R) && - ((T.Y << K) >= CM.Y - R) && ((((T.Y + 1) << K) - 1) <= CM.Y + R) && - ((T.Z << K) >= CM.Z - R) && ((((T.Z + 1) << K) - 1) <= CM.Z + R); - if (bCovered) continue; - - const FVoxelTileKey Key(T, SheetLevel); - DesiredSorted.Add(Key); - DesiredStamped.FindOrAdd(Key) = DesiredStamp; - } + DesiredStamped.FindOrAdd(Key) = DesiredStamp; } // Streaming anchors (AI / remote players, §9.3): fold each one's small level-0 box into the SAME @@ -5604,10 +5514,10 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition, APaw "\"tile_extent_m\":%s,\"coverage_extent_m\":[%s,%s,%s],\"sheet\":%d"), Summary.Level, Summary.Count, Summary.Min.X, Summary.Min.Y, Summary.Min.Z, - Summary.Max.X, Summary.Max.Y, Summary.Max.Z, - *FString::SanitizeFloat(TileExtentMetres), - *FString::SanitizeFloat(SpanX), *FString::SanitizeFloat(SpanY), *FString::SanitizeFloat(SpanZ), - Settings && Summary.Level > Settings->MaxClipLevel ? 1 : 0)); + Summary.Max.X, Summary.Max.Y, Summary.Max.Z, + *FString::SanitizeFloat(TileExtentMetres), + *FString::SanitizeFloat(SpanX), *FString::SanitizeFloat(SpanY), *FString::SanitizeFloat(SpanZ), + Settings && Summary.Level > VF_MaxMarchingCubesLevel(Settings) ? 1 : 0)); } bStartupTraceDesiredRecorded = true; } @@ -5628,7 +5538,7 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition, APaw // dès que les feuilles sont actives, même si la coupe de contenu MC est désactivée. const bool bSheetsWantBand = Settings && Settings->bFarSheetRing && Settings->RenderDistanceChunks > 0; - const int32 TopMC = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4; + const int32 TopMC = VF_MaxMarchingCubesLevel(Settings); int32 NewLo = MIN_int32, NewHi = MAX_int32; if ((CutMin <= 8 || bSheetsWantBand) && StrateManager) { @@ -5649,7 +5559,7 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition, APaw NewLo, NewHi, MeshBandChunkLo, MeshBandChunkHi, CutMin, CenterChunk.Z); for (const FVoxelTileKey& T : LoadedTiles) { - // Feuilles (niveau > MaxClipLevel) : toujours dépendantes de la bande. + // Sheets (above the last MC bridge level) always depend on the strate band. if (T.Level < CutMin && T.Level <= TopMC) continue; const int32 CLo = T.Coord.Z << T.Level; const int32 CHi = ((T.Coord.Z + 1) << T.Level) - 1; @@ -5667,17 +5577,13 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition, APaw } } - // F18 — TROU XY de l'anneau feuille (voir VoxelWorld.h) : boîte MC niveau-MaxClipLevel - // autour du joueur, rétrécie d'UNE tuile — le raccord feuille↔anneau MC garde une tuile - // MC pleine de recouvrement (même pas d'échantillonnage des deux côtés → discret), et en - // avançant, les tuiles MC de la zone nouvellement découpée étaient déjà desired au - // crossing précédent (chargées avant que le trou ne les découvre). Changement (crossing - // de tuile MaxClipLevel, ~tous les 2^L chunks) ⇒ re-queue des feuilles chevauchant - // l'ancien OU le nouveau trou. + // The sheet XY hole follows the outermost MC bridge level and shrinks by one tile, leaving + // an overlap at the mesh transition. Requeue sheets overlapping either the old or new hole + // when the player crosses a bridge-level tile. { int32 NewMinX = MAX_int32, NewMinY = MAX_int32; int32 NewMaxX = MIN_int32, NewMaxY = MIN_int32; - const int32 TopMCLvl = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4; + const int32 TopMCLvl = VF_MaxMarchingCubesLevel(Settings); if (Settings && Settings->bFarSheetRing && Settings->RenderDistanceChunks > 0) { const int32 RClip = FMath::Max(1, Settings->ClipRadius); @@ -5926,11 +5832,10 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority) const int32 FullRes = Settings ? FMath::Max(1, Settings->FullResClipLevels) : 2; const int32 Extent = CHUNK_SIZE << Tile.Level; - // F18 — tuile FEUILLE : BuildDesiredTiles n'émet des clés au-delà de MaxClipLevel que pour - // l'anneau feuille (render distance) — elles se maillent en deux heightfields (GenerateSheetMesh), - // pas en marching cubes. Densité d'échantillonnage = celle de l'anneau MC niveau-MaxClipLevel - // (le nombre de cellules grandit avec la feuille, plafonné à 128/axe — au-delà le pas grossit). - const int32 TopMC = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4; + // F18 — sheet-ring keys follow the last MC bridge level and mesh as two heightfields + // (GenerateSheetMesh). Their sampling step matches that MC level; cells grow with sheet extent + // up to 128 per axis, after which the step increases. + const int32 TopMC = VF_MaxMarchingCubesLevel(Settings); const bool bSheetTile = Tile.Level > TopMC; int32 Cells = (Tile.Level < FullRes) diff --git a/Tools/VoxelForgeTest.ps1 b/Tools/VoxelForgeTest.ps1 index 7bb952e..61e6324 100644 --- a/Tools/VoxelForgeTest.ps1 +++ b/Tools/VoxelForgeTest.ps1 @@ -43,9 +43,17 @@ $BuildRecord = [ordered]@{ requested = [bool]$Build; status = 'not_requested' } $AssetEvidence = @() $CommandletMetrics = [ordered]@{} $GameMetrics = [ordered]@{} -$SurfaceFallMetrics = [ordered]@{ requested = $Scenario -in @('tests', 'surface-fall'); status = 'not_run' } +$SurfaceFallMetrics = [ordered]@{ + requested = $Scenario -eq 'surface-fall' -or + ($Scenario -eq 'tests' -and [string]::IsNullOrWhiteSpace($TestFilter)) + status = 'not_run' +} $StrateCrossingMetrics = [ordered]@{ requested = $Scenario -eq 'crossing'; status = 'not_run' } -$CollisionGateStressMetrics = [ordered]@{ requested = $Scenario -in @('tests', 'gate-stress'); status = 'not_run' } +$CollisionGateStressMetrics = [ordered]@{ + requested = $Scenario -eq 'gate-stress' -or + ($Scenario -eq 'tests' -and [string]::IsNullOrWhiteSpace($TestFilter)) + status = 'not_run' +} $TestMetrics = @() $ParityEvidence = [ordered]@{ requested = $Scenario -eq 'parity'; status = 'not_run' } $DllEvidence = [ordered]@{ @@ -1038,9 +1046,9 @@ try { Add-Failure "automation tests failed: $failedNames" } - # The runtime collision-gate repro is part of the tests scenario as an integration - # test alongside the 44 pure automation tests. It uses the same isolated host/assets - # and gets its own launch ledger entry and log. + if ([string]::IsNullOrWhiteSpace($TestFilter)) { + # The unfiltered suite includes its collision integration repros. A filtered run is + # bounded to its named automation tests and does not launch unrelated scenarios. $surfaceBaseCvars = [ordered]@{ 'voxel.TestSurfaceFall' = 1 'voxel.TestSurfaceFallSpawnHeightVoxels' = 20 @@ -1071,6 +1079,7 @@ try { $stressReason = [string](Get-JsonValue $CollisionGateStressMetrics 'reason' '') Add-Failure "collision-gate stress repro did not pass: $($CollisionGateStressMetrics.status) $stressReason" } + } } 'parity' { $parityArgs = New-CommonArguments 'commandlet' (Join-Path $RunRoot 'Logs\parity-commandlet.log') (Merge-Cvars $baseCvars) diff --git a/Tools/__pycache__/AnalyzeVoxelForgePerfProbes.cpython-312.pyc b/Tools/__pycache__/AnalyzeVoxelForgePerfProbes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b967c3c84f1bc042052e3dc21c5869714d220e93 GIT binary patch literal 26900 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