From 8879045627c8917d0e44459cee224e70d17a7ad3 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Tue, 15 Sep 2026 00:29:46 +0200 Subject: [PATCH] fix(correctness): cold-audit fixes -- tile-cache determinism bug, cache identities, lifetimes Correctness round on AUDIT-COLD-2026-09-14.md (Codex; key fixes reviewed by me). #7 (determinism, introduced by the tile-cache work): the refetch guard in GetDensityAt now rebuilds on `bTileCacheContext != CP_UsesTileCacheWindow` or when the worker-local stack/core were moved into a cache entry (`CP_ActiveTunnelDensityCache != nullptr`). A cache miss can no longer evaluate a moved-from, empty operator stack. Test (VoxelForgeCorrectnessRoundTest): the pre-fix binary CRASHED in the context-switch case (negative control); the fixed binary passes with 0/5 coarse and fine mismatches, and warm vs fresh origin-tile meshes are identical. Also fixed, each with a focused test that passes: - #3/#8: worker diff/surface/biome caches carry owner/layer identity (no cross-world reuse); #9: the shaft key gains LedgeSpacing/LedgeDepth and the island key BoundarySealThickness; #14: clearing passages bumps PassagesVersion; #21: HasAnyModInChunkRange is proportional to the queried range (the sealed-rock proof calls it per candidate). - #1: the density-volume fill thread is paused/drained with generation; #2: each content manager joins its own decoration tasks before destruction; #15: non-finite/invalid modification geometry is rejected; #20: zero-width transition blend is guarded at runtime. - Also #4-#6, #10-#13, #16-#18, #22. #19 is valid C++20 and unchanged. Verified: canonical export b3e5f4c3..9b377; capability 20,830 / 10,909; fresh trace 841/841 identical, 0 origin-column mismatches; static mesher calls 18,078,130. Performance: two pairs disagree in sign, and the means are final -2.5% static / -0.8% moving; no repeatable regression. Known and NOT caused by this round: ClassifyTileSoundness and PassageLandsInOpenSpace were already failing (15/17 in the grouped suite). Queued for investigation. Ledger: first determinism launch hit the DDC fatal (crash folder 57DB6ADC, 23:21); the retry exited 1 on the two known failures; one malformed multi-test launch was inconclusive; one malformed trace launch was stopped; one launch with an unquoted project path found no test and its retry passed. Everything else passed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y --- .../Tests/VoxelForgeComposerSeasonTest.cpp | 45 +- .../Tests/VoxelForgeCorrectnessRoundTest.cpp | 650 ++++++++++++++++++ .../Private/VoxelContentManager.cpp | 160 ++++- .../VoxelForge/Private/VoxelDensityVolume.cpp | 28 +- Source/VoxelForge/Private/VoxelDiffLayer.cpp | 240 ++++++- Source/VoxelForge/Private/VoxelGenerator.cpp | 111 ++- .../VoxelForge/Private/VoxelStrateManager.cpp | 41 +- Source/VoxelForge/Private/VoxelWorld.cpp | 165 ++++- Source/VoxelForge/Public/VoxelBiomeTypes.h | 10 +- .../VoxelForge/Public/VoxelContentManager.h | 17 + Source/VoxelForge/Public/VoxelDensityOp.h | 3 +- Source/VoxelForge/Public/VoxelDensityVolume.h | 7 + Source/VoxelForge/Public/VoxelDiffLayer.h | 24 +- Source/VoxelForge/Public/VoxelGenerator.h | 3 +- 14 files changed, 1365 insertions(+), 139 deletions(-) create mode 100644 Source/VoxelForge/Private/Tests/VoxelForgeCorrectnessRoundTest.cpp diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerSeasonTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerSeasonTest.cpp index 2206689..6e47814 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeComposerSeasonTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerSeasonTest.cpp @@ -406,16 +406,53 @@ bool FVoxelForgeComposerSeasonTest::RunTest(const FString& Parameters) TestTrue(TEXT("legacy manager still initializes with Season unset"), LegacyManager->Initialize(LegacySettings.Get(), LegacySettings->Seed)); TestTrue(TEXT("season unset remains the authored-pool path"), - LegacySettings->Season.IsNull() - && LegacyManager->GetNumStrates() == 1 + LegacySettings->Season.IsNull() + && LegacyManager->GetNumStrates() == 1 && LegacyManager->GetLayout()[0].Definition == LegacyDefinition.Get() && LegacyManager->GetLayout()[0].HeightInChunks == 7 && LegacyManager->GetWorldSeed() == LegacySettings->Seed && LegacySettings->GetEffectiveWorldSeed() == LegacySettings->Seed && LegacySettings->GetEffectiveOriginSpineRadius() == LegacySettings->OriginSpineRadius - && LegacySettings->GetEffectiveWorldRadiusVoxels() - == LegacySettings->WorldRadiusVoxels); + && LegacySettings->GetEffectiveWorldRadiusVoxels() + == LegacySettings->WorldRadiusVoxels); + + // Transactional live-rebuild regression: make a hash-valid manifest whose first authored + // definition cannot load. Initialize must reject it without destroying the prior valid layout, + // season hash, or passage-version snapshot. + if (Manifest.Strates.Num() > 0) + { + FVoxelSeasonManifest BrokenManifest = Manifest; + BrokenManifest.Strates[0].SourceDefinitionPath = + TEXT("/Game/VoxelForge/DefinitelyMissing/Definition.Definition"); + BrokenManifest.ContentHash = VF_ComputeVoxelSeasonManifestContentHash(BrokenManifest); + BrokenManifest.SerializedJson = VF_SerializeVoxelSeasonManifest(BrokenManifest); + + TStrongObjectPtr BrokenSeason( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + FString BrokenReport; + TestTrue(TEXT("broken test season keeps a valid content hash"), + BrokenSeason->SetManifestJson(BrokenManifest.SerializedJson, BrokenReport)); + + TStrongObjectPtr TransactionalManager( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + TestTrue(TEXT("transactional manager starts from a valid layout"), + TransactionalManager->Initialize(RuntimeSettings.Get(), RuntimeSettings->GetEffectiveWorldSeed())); + const int32 PreviousNumStrates = TransactionalManager->GetNumStrates(); + const uint32 PreviousPassagesVersion = TransactionalManager->GetLayoutVersion(); + const FString PreviousSeasonHash = TransactionalManager->GetSeasonContentHash(); + + RuntimeSettings->Season = TSoftObjectPtr(BrokenSeason.Get()); + TestFalse(TEXT("invalid live season is rejected"), + TransactionalManager->Initialize(RuntimeSettings.Get(), RuntimeSettings->GetEffectiveWorldSeed())); + TestEqual(TEXT("invalid live season preserves the previous layout"), + TransactionalManager->GetNumStrates(), PreviousNumStrates); + TestEqual(TEXT("invalid live season preserves the previous passage version"), + TransactionalManager->GetLayoutVersion(), PreviousPassagesVersion); + TestEqual(TEXT("invalid live season preserves the previous content hash"), + TransactionalManager->GetSeasonContentHash(), PreviousSeasonHash); + RuntimeSettings->Season = TSoftObjectPtr(SeasonAsset.Get()); + } return true; } diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeCorrectnessRoundTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeCorrectnessRoundTest.cpp new file mode 100644 index 0000000..b3a3986 --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeCorrectnessRoundTest.cpp @@ -0,0 +1,650 @@ +// Correctness-round regression coverage for the cold-audit fixes. + +#if WITH_DEV_AUTOMATION_TESTS + +#include "Misc/AutomationTest.h" +#include "Async/Async.h" + +#include "VoxelForgeTestFixture.h" +#include "VoxelBiomeDefinition.h" +#include "VoxelMarchingCubesMesher.h" + +namespace +{ + using namespace VoxelForgeTest; + + float FloatFromBits(uint32 Bits) + { + float Value = 0.0f; + FMemory::Memcpy(&Value, &Bits, sizeof(Value)); + return Value; + } + + bool SameVector(const FVector& A, const FVector& B) + { + return BitEqual(A.X, B.X) && BitEqual(A.Y, B.Y) && BitEqual(A.Z, B.Z); + } + + bool SameVector2D(const FVector2D& A, const FVector2D& B) + { + return BitEqual(A.X, B.X) && BitEqual(A.Y, B.Y); + } + + bool SameMesh(const FVoxelMeshData& A, const FVoxelMeshData& B, FString& OutWhy) + { + if (A.NumCeilingTriangles != B.NumCeilingTriangles) + { + OutWhy = FString::Printf(TEXT("NumCeilingTriangles %d != %d"), + A.NumCeilingTriangles, B.NumCeilingTriangles); + return false; + } + + if (A.Vertices.Num() != B.Vertices.Num() + || A.Triangles.Num() != B.Triangles.Num() + || A.UVs.Num() != B.UVs.Num() + || A.Normals.Num() != B.Normals.Num() + || A.Colors.Num() != B.Colors.Num()) + { + OutWhy = FString::Printf( + TEXT("array sizes differ: V %d/%d T %d/%d UV %d/%d N %d/%d C %d/%d"), + A.Vertices.Num(), B.Vertices.Num(), A.Triangles.Num(), B.Triangles.Num(), + A.UVs.Num(), B.UVs.Num(), A.Normals.Num(), B.Normals.Num(), + A.Colors.Num(), B.Colors.Num()); + return false; + } + + for (int32 Index = 0; Index < A.Vertices.Num(); ++Index) + { + if (!SameVector(A.Vertices[Index], B.Vertices[Index])) + { + OutWhy = FString::Printf(TEXT("vertex %d differs"), Index); + return false; + } + if (!SameVector2D(A.UVs[Index], B.UVs[Index])) + { + OutWhy = FString::Printf(TEXT("UV %d differs"), Index); + return false; + } + if (!SameVector(A.Normals[Index], B.Normals[Index])) + { + OutWhy = FString::Printf(TEXT("normal %d differs"), Index); + return false; + } + if (A.Colors[Index] != B.Colors[Index]) + { + OutWhy = FString::Printf(TEXT("colour %d differs"), Index); + return false; + } + } + for (int32 Index = 0; Index < A.Triangles.Num(); ++Index) + { + if (A.Triangles[Index] != B.Triangles[Index]) + { + OutWhy = FString::Printf(TEXT("triangle index %d differs"), Index); + return false; + } + } + return true; + } + + float SampleWithTileContext(const UVoxelGenerator& Generator, int32 Z, + int32 Step, int32 Cells, bool* bOutCacheContext = nullptr) + { + TGuardValue StepGuard(VoxelGenLOD::SampleStep, Step); + TGuardValue OriginGuard( + VoxelGenLOD::TileOriginVoxels, FIntVector::ZeroValue); + TGuardValue CellsGuard(VoxelGenLOD::TileCellsPerAxis, Cells); + if (bOutCacheContext) + { + FIntVector ContextOrigin; + int32 ContextStep = 1; + int32 ContextCells = 0; + *bOutCacheContext = VoxelGenLOD::IsTileCacheWindowEnabled(false) + && VoxelGenLOD::GetThreadTileCacheWindow( + ContextOrigin, ContextStep, ContextCells); + } + return Generator.GetDensityAt(0.0f, 0.0f, static_cast(Z)); + } + + bool IsFiniteGenerationParams(const FStrateGenerationParams& Params) + { + return VoxelMath::IsFinite(Params.BaseDensity) + && VoxelMath::IsFinite(Params.VerticalScale) + && VoxelMath::IsFinite(Params.WormFrequency) + && VoxelMath::IsFinite(Params.WormThreshold) + && VoxelMath::IsFinite(Params.WormStrength) + && VoxelMath::IsFinite(Params.SurfaceRoughness) + && VoxelMath::IsFinite(Params.BoundarySealThickness) + && VoxelMath::IsFinite(Params.StrateTopWorldZ) + && VoxelMath::IsFinite(Params.StrateBottomWorldZ); + } +} + +//============================================================================= +// #7 — tile-cache context switch and moved-from tunnel state +//============================================================================= + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeTileCacheContextDeterminismTest, + "VoxelForge.Determinism.TileCacheContextSwitch", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +bool FVoxelForgeTileCacheContextDeterminismTest::RunTest(const FString& Parameters) +{ + FTestWorld World; + World.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true); + if (!World.IsValid()) + { + AddError(World.WhyInvalid()); + return false; + } + + const FIntVector OriginChunk = FIntVector::ZeroValue; + AddInfo(FString::Printf( + TEXT("#7 fixture origin chunk: type=%d uses_op_stack=%d z_span=[%d,%d]."), + static_cast(World.StrateManager->GetGeneratorTypeForChunk(OriginChunk)), + World.StrateManager->UsesOperatorStackForChunk(OriginChunk) ? 1 : 0, + World.BottomChunkZ, World.TopChunkZ)); + + const UVoxelGenerator* Generator = World.Generator.Get(); + const int32 SampleZ[] = { 0, 8, 16, 24, 31 }; + TArray CoarseContext; + TArray FineContext; + bool bCoarseCacheContext = false; + bool bFineCacheContext = false; + CoarseContext.Reserve(UE_ARRAY_COUNT(SampleZ)); + FineContext.Reserve(UE_ARRAY_COUNT(SampleZ)); + + // Step 16/Cells 16 enables the op-stack tile-window context. Step 1 disables it. The + // point remains in chunk (0,0,0), including the origin column that shares a Z cache entry. + for (const int32 Z : SampleZ) + { + CoarseContext.Add(SampleWithTileContext( + *Generator, Z, 16, 16, &bCoarseCacheContext)); + } + for (const int32 Z : SampleZ) + { + FineContext.Add(SampleWithTileContext( + *Generator, Z, 1, 32, &bFineCacheContext)); + } + + TArray SampleZCopy; + for (const int32 Z : SampleZ) { SampleZCopy.Add(Z); } + TFuture> FreshFuture = Async(EAsyncExecution::Thread, + [Generator, SampleZCopy = MoveTemp(SampleZCopy)]() mutable + { + TArray Result; + Result.Reserve(SampleZCopy.Num()); + for (const int32 Z : SampleZCopy) + { + Result.Add(Generator->GetDensityAt(0.0f, 0.0f, static_cast(Z))); + } + return Result; + }); + const TArray FreshThread = FreshFuture.Get(); + + int32 CoarseMismatches = 0; + int32 FineMismatches = 0; + bool bObservedNonZero = false; + for (int32 Index = 0; Index < FreshThread.Num(); ++Index) + { + bObservedNonZero |= FMath::Abs(FreshThread[Index]) > KINDA_SMALL_NUMBER; + if (!BitEqual(CoarseContext[Index], FreshThread[Index])) { ++CoarseMismatches; } + if (!BitEqual(FineContext[Index], FreshThread[Index])) { ++FineMismatches; } + } + + TestTrue(TEXT("the origin-column samples exercise a non-empty operator-stack field"), + bObservedNonZero); + TestEqual(TEXT("coarse tile-window context matches a fresh thread"), CoarseMismatches, 0); + TestEqual(TEXT("switching to the non-window context matches a fresh thread"), + FineMismatches, 0); + AddInfo(FString::Printf( + TEXT("#7 origin-column context switch: %d samples, coarse mismatches=%d, fine mismatches=%d; " + "all points are in chunk (0,0,0), cache_context coarse=%d fine=%d."), + FreshThread.Num(), CoarseMismatches, FineMismatches, + bCoarseCacheContext ? 1 : 0, bFineCacheContext ? 1 : 0)); + + // Exercise the game-path ordering: a coarse tile warms the moved-out shared entry, then a + // level-0 tile at the same origin is compared with a fresh world's level-0 output. + FTestWorld WarmWorld; + FTestWorld FreshWorld; + WarmWorld.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true); + FreshWorld.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true); + if (!WarmWorld.IsValid() || !FreshWorld.IsValid()) + { + AddError(WarmWorld.IsValid() && FreshWorld.IsValid() + ? TEXT("Unexpected per-tile fixture state") + : (!WarmWorld.IsValid() ? WarmWorld.WhyInvalid() : FreshWorld.WhyInvalid())); + return false; + } + + TStrongObjectPtr WarmMesher( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + TStrongObjectPtr FreshMesher( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + WarmMesher->SetGenerator(WarmWorld.Generator.Get()); + FreshMesher->SetGenerator(FreshWorld.Generator.Get()); + + int64 CoarseSamples = 0; + WarmMesher->GenerateMesh(FIntVector::ZeroValue, 16, 16, nullptr, + INT32_MIN, INT32_MAX, &CoarseSamples); + int64 WarmFineSamples = 0; + int64 FreshFineSamples = 0; + const FVoxelMeshData WarmFine = WarmMesher->GenerateMesh( + FIntVector::ZeroValue, 1, CHUNK_SIZE, nullptr, INT32_MIN, INT32_MAX, + &WarmFineSamples); + const FVoxelMeshData FreshFine = FreshMesher->GenerateMesh( + FIntVector::ZeroValue, 1, CHUNK_SIZE, nullptr, INT32_MIN, INT32_MAX, + &FreshFineSamples); + + TestTrue(TEXT("the per-tile identity check actually meshed the level-0 origin tile"), + WarmFineSamples > 0 && FreshFineSamples > 0); + FString MeshDifference; + TestTrue(TEXT("coarse-then-fine game path equals a fresh fine tile"), + SameMesh(WarmFine, FreshFine, MeshDifference)); + if (!MeshDifference.IsEmpty()) + { + AddInfo(FString::Printf(TEXT("Per-tile difference detail: %s"), *MeshDifference)); + } + AddInfo(FString::Printf( + TEXT("#7 game-path tile identity: coarse samples=%lld, warm fine=%lld, fresh fine=%lld, " + "warm vertices=%d, fresh vertices=%d."), + (long long)CoarseSamples, (long long)WarmFineSamples, (long long)FreshFineSamples, + WarmFine.Vertices.Num(), FreshFine.Vertices.Num())); + return true; +} + +//============================================================================= +// #3 — diff-layer identity in the worker-local snapshot slots +//============================================================================= + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeDiffLayerIdentityTest, + "VoxelForge.Determinism.DiffLayerIdentity", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +bool FVoxelForgeDiffLayerIdentityTest::RunTest(const FString& Parameters) +{ + FTestWorld CarvedWorld; + FTestWorld FilledWorld; + CarvedWorld.Build(); + FilledWorld.Build(); + if (!CarvedWorld.IsValid() || !FilledWorld.IsValid()) + { + AddError(CarvedWorld.IsValid() && FilledWorld.IsValid() + ? TEXT("Unexpected diff identity fixture state") + : (!CarvedWorld.IsValid() ? CarvedWorld.WhyInvalid() : FilledWorld.WhyInvalid())); + return false; + } + + const FVector P(0.0f, 0.0f, 0.0f); + FVoxelModification Carve; + Carve.Center = P; + Carve.Radius = 8.0f; + Carve.Strength = -64.0f; + Carve.Shape = EVoxelBrushShape::Sphere; + FVoxelModification Fill = Carve; + Fill.Strength = 64.0f; + + TestTrue(TEXT("the carve layer accepted its edit"), + CarvedWorld.DiffLayer->ApplyModification(Carve).Num() > 0); + TestTrue(TEXT("the fill layer accepted its edit"), + FilledWorld.DiffLayer->ApplyModification(Fill).Num() > 0); + TestEqual(TEXT("the two different layers have the same chunk/version key"), + CarvedWorld.DiffLayer->GetModsVersion(), FilledWorld.DiffLayer->GetModsVersion()); + TestTrue(TEXT("the two layers have distinct worker-cache identities"), + CarvedWorld.DiffLayer->GetCacheLifetimeId() + != FilledWorld.DiffLayer->GetCacheLifetimeId()); + + // Warm the carve snapshot, then sample the fill on the same thread. A cache keyed only by + // (chunk, version) returns the carve's list for the fill layer here. + const float Carved = CarvedWorld.Generator->GetDensityAt(P.X, P.Y, P.Z); + const float Filled = FilledWorld.Generator->GetDensityAt(P.X, P.Y, P.Z); + + const UVoxelGenerator* FilledGenerator = FilledWorld.Generator.Get(); + TFuture FreshFilledFuture = Async(EAsyncExecution::Thread, + [FilledGenerator, P]() + { + return FilledGenerator->GetDensityAt(P.X, P.Y, P.Z); + }); + const float FreshFilled = FreshFilledFuture.Get(); + + TestTrue(TEXT("the two edits produce different density values at their common centre"), + !BitEqual(Carved, FreshFilled)); + TestTrue(TEXT("the second layer does not inherit the first layer's snapshot"), + BitEqual(Filled, FreshFilled)); + AddInfo(FString::Printf( + TEXT("#3 same-thread diff snapshots: carve=%.9g [0x%08X], fill=%.9g [0x%08X], " + "fresh fill=%.9g [0x%08X]."), + Carved, *reinterpret_cast(&Carved), Filled, + *reinterpret_cast(&Filled), FreshFilled, + *reinterpret_cast(&FreshFilled))); + return true; +} + +//============================================================================= +// #8 — surface/biome worker-cache identities +//============================================================================= + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeSurfaceBiomeCacheIdentityTest, + "VoxelForge.Determinism.SurfaceBiomeCacheIdentity", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +bool FVoxelForgeSurfaceBiomeCacheIdentityTest::RunTest(const FString& Parameters) +{ + FTestWorld A; + FTestWorld B; + A.Build(); + B.Build(); + if (!A.IsValid() || !B.IsValid()) + { + AddError(A.IsValid() && B.IsValid() + ? TEXT("Unexpected surface cache fixture state") + : (!A.IsValid() ? A.WhyInvalid() : B.WhyInvalid())); + return false; + } + + // The fixture normally gives each world a unique layout version. Bring A forward by one + // rebuild so the two managers intentionally share (seed, chunk, layout version); only their + // process-unique owner identities may distinguish their worker caches. + A.Reinitialize(); + TestEqual(TEXT("the cache-collision worlds intentionally share a layout version"), + A.StrateManager->GetLayoutVersion(), B.StrateManager->GetLayoutVersion()); + TestTrue(TEXT("the cache-collision worlds have distinct manager identities"), + A.StrateManager->GetCacheLifetimeId() != B.StrateManager->GetCacheLifetimeId()); + TestTrue(TEXT("the cache-collision worlds have distinct generator instances"), + A.Generator.Get() != B.Generator.Get()); + + int32 SurfTop = 0, SurfBottom = 0; + if (!A.GetSlotVoxelZRange(FTestWorld::SlotSurfaceWorld, SurfTop, SurfBottom)) + { + AddError(TEXT("The fixture has no SurfaceWorld slot.")); + return false; + } + const int32 SurfChunkZ = FMath::FloorToInt( + static_cast(SurfTop + SurfBottom) * 0.5f / CHUNK_SIZE); + + UVoxelStrateDefinition* BSurface = B.Definitions[FTestWorld::SlotSurfaceWorld].Get(); + BSurface->SurfaceParams.ElevationRange *= 3.0f; + BSurface->SurfaceParams.MountainStrength = 1.0f; + BSurface->SurfaceParams.ContinentFrequency *= 1.7f; + BSurface->SurfaceParams.BaseGroundRelative = FMath::Clamp( + BSurface->SurfaceParams.BaseGroundRelative + 0.12f, 0.0f, 1.0f); + + bool bFoundDifferentSurface = false; + float FirstX = 0.0f, FirstY = 0.0f, HeightA = 0.0f, HeightB = 0.0f; + for (int32 Index = 0; Index < 96 && !bFoundDifferentSurface; ++Index) + { + const float X = 180.0f + static_cast(Index) * 23.0f; + const float Y = -260.0f + static_cast(Index) * 19.0f; + float CeilA = 0.0f, CeilB = 0.0f; + if (!A.Generator->GetSurfaceHeightAt(X, Y, SurfChunkZ, HeightA, CeilA) + || !B.Generator->GetSurfaceHeightAt(X, Y, SurfChunkZ, HeightB, CeilB)) + { + continue; + } + if (FMath::Abs(HeightA - HeightB) > 1.0f) + { + bFoundDifferentSurface = true; + FirstX = X; + FirstY = Y; + } + } + TestTrue(TEXT("the two surface definitions produce a distinct heightfield"), + bFoundDifferentSurface); + if (!bFoundDifferentSurface) { return false; } + + // Warm A immediately before B for each probe. Before the owner/manager key fix, B's integer + // XY column lookup reuses A's column because all former key fields are deliberately equal. + int32 NumBatches = 0; + int32 NumBStale = 0; + int32 NumAReferenceDifference = 0; + for (int32 Offset = -12; Offset <= 12; ++Offset) + { + const float Z = FMath::FloorToFloat((HeightA + HeightB) * 0.5f) + + static_cast(Offset); + const float ValueA = A.Generator->GetDensityAt(FirstX, FirstY, Z); + const float ValueB = B.Generator->GetDensityAt(FirstX, FirstY, Z); + const UVoxelGenerator* BGenerator = B.Generator.Get(); + TFuture FreshFuture = Async(EAsyncExecution::Thread, + [BGenerator, FirstX, FirstY, Z]() + { + return BGenerator->GetDensityAt(FirstX, FirstY, Z); + }); + const float FreshB = FreshFuture.Get(); + ++NumBatches; + if (!BitEqual(ValueB, FreshB)) { ++NumBStale; } + if (!BitEqual(ValueA, FreshB)) { ++NumAReferenceDifference; } + } + + TestTrue(TEXT("the surface probe includes a distinct A/B field value"), + NumAReferenceDifference > 0); + TestEqual(TEXT("the surface cache never returns the other world's column"), NumBStale, 0); + + // Exercise the biome grid and vertex-palette path with the same intentionally colliding + // layout key. One full-climate biome per world makes the expected palette unambiguous. + TStrongObjectPtr BiomeA( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + TStrongObjectPtr BiomeB( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + BiomeA->ReliefMin = 0.0f; BiomeA->ReliefMax = 1.0f; + BiomeA->MoistureMin = 0.0f; BiomeA->MoistureMax = 1.0f; + BiomeA->MaterialPaletteIndex = 17; + BiomeB->ReliefMin = 0.0f; BiomeB->ReliefMax = 1.0f; + BiomeB->MoistureMin = 0.0f; BiomeB->MoistureMax = 1.0f; + BiomeB->MaterialPaletteIndex = 91; + A.Definitions[FTestWorld::SlotSurfaceWorld]->Biomes.Add(BiomeA.Get()); + B.Definitions[FTestWorld::SlotSurfaceWorld]->Biomes.Add(BiomeB.Get()); + + int32 PaletteA = 0, PaletteAN = 0; + int32 PaletteB = 0, PaletteBN = 0; + float BlendA = 0.0f, BlendB = 0.0f; + A.Generator->GetBiomeMaterialAt(FirstX, FirstY, static_cast(SurfTop), + PaletteA, PaletteAN, BlendA); + B.Generator->GetBiomeMaterialAt(FirstX, FirstY, static_cast(SurfTop), + PaletteB, PaletteBN, BlendB); + TestEqual(TEXT("world A resolves its own biome palette"), PaletteA, 17); + TestEqual(TEXT("world B resolves its own biome palette"), PaletteB, 91); + TestEqual(TEXT("world A's neighbour palette is its own palette"), PaletteAN, 17); + TestEqual(TEXT("world B's neighbour palette is its own palette"), PaletteBN, 91); + + // Public cache contract: the identity fields are part of Contains(), not just a comment or + // a caller convention. This also remains a cheap unit check for future cache refactors. + FChunkBiomeCache Cache; + Cache.ValidMinX = -10.0f; Cache.ValidMaxX = 10.0f; + Cache.ValidMinY = -10.0f; Cache.ValidMaxY = 10.0f; + Cache.ChunkZ = SurfChunkZ; Cache.Seed = 1337; + Cache.OwnerId = 101; Cache.ManagerLifetimeId = 202; + TestTrue(TEXT("biome cache accepts the exact owner and manager identity"), + Cache.Contains(0.0f, 0.0f, SurfChunkZ, 1337, 101, 202)); + TestFalse(TEXT("biome cache rejects a different generator identity"), + Cache.Contains(0.0f, 0.0f, SurfChunkZ, 1337, 102, 202)); + TestFalse(TEXT("biome cache rejects a different manager identity"), + Cache.Contains(0.0f, 0.0f, SurfChunkZ, 1337, 101, 203)); + return true; +} + +//============================================================================= +// #14 — passage-version invalidation when the new layout is empty +//============================================================================= + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeEmptyPassageVersionTest, + "VoxelForge.Determinism.EmptyPassageVersion", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +bool FVoxelForgeEmptyPassageVersionTest::RunTest(const FString& Parameters) +{ + FTestWorld World; + World.Build(); + if (!World.IsValid()) + { + AddError(World.WhyInvalid()); + return false; + } + + // Prime the worker-local paths that keep nearby-passage shortlists, then rebuild to an empty + // layout. The old early return cleared Passages but did not bump PassagesVersion. + World.Generator->ClassifyTile(FIntVector::ZeroValue, 1, 8); + const uint32 Before = World.StrateManager->GetLayoutVersion(); + World.Settings->TotalStrates = 0; + World.Settings->StratePool.Reset(); + World.Settings->FixedStrates.Reset(); + const bool bInitialized = World.StrateManager->Initialize(World.Settings.Get(), World.Settings->Seed); + + TestTrue(TEXT("empty settings still complete the empty layout rebuild"), bInitialized); + TestEqual(TEXT("the rebuilt layout is empty"), World.StrateManager->GetNumStrates(), 0); + TestTrue(TEXT("clearing passages bumps the worker invalidation version"), + World.StrateManager->GetLayoutVersion() != Before); + TestEqual(TEXT("the empty layout has no passages"), World.StrateManager->GetPassages().Num(), 0); + AddInfo(FString::Printf(TEXT("#14 passage version %u -> %u on empty rebuild."), + Before, World.StrateManager->GetLayoutVersion())); + return true; +} + +//============================================================================= +// #20 — zero blend is a valid runtime value and must remain finite +//============================================================================= + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeZeroTransitionBlendTest, + "VoxelForge.Determinism.ZeroTransitionBlend", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +bool FVoxelForgeZeroTransitionBlendTest::RunTest(const FString& Parameters) +{ + FTestWorld World; + World.Build(); + if (!World.IsValid()) + { + AddError(World.WhyInvalid()); + return false; + } + for (int32 Index = 0; Index < World.Definitions.Num(); ++Index) + { + UVoxelStrateDefinition* Definition = World.Definitions[Index].Get(); + Definition->TransitionBlendChunks = 0; + Definition->TransitionType = (Index & 1) == 0 + ? EVoxelStrateTransition::Gradient : EVoxelStrateTransition::Interleaved; + } + World.Reinitialize(); + + int32 NumQueries = 0; + int32 NumNonFinite = 0; + for (const FStrateSlot& Slot : World.StrateManager->GetLayout()) + { + const int32 BoundaryChunks[] = { Slot.BottomChunkZ, Slot.TopChunkZ }; + for (const int32 ChunkZ : BoundaryChunks) + { + const FStrateGenerationParams Params = + World.StrateManager->GetGenerationParams(FIntVector(0, 0, ChunkZ)); + ++NumQueries; + if (!IsFiniteGenerationParams(Params)) { ++NumNonFinite; } + } + } + TestEqual(TEXT("zero-blend generation parameters remain finite at every boundary"), + NumNonFinite, 0); + AddInfo(FString::Printf(TEXT("#20 queried %d zero-blend boundary parameter sets."), NumQueries)); + return true; +} + +//============================================================================= +// #15 and #21 — malformed API input and direct per-chunk range lookup +//============================================================================= + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeDiffApiBoundaryTest, + "VoxelForge.Determinism.DiffApiBoundary", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +bool FVoxelForgeDiffApiBoundaryTest::RunTest(const FString& Parameters) +{ + TStrongObjectPtr Diff( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + Diff->SetBudget(0, 50.0f, 0.0f); + + const float NaN = FloatFromBits(0x7FC12345u); + const float Inf = FloatFromBits(0x7F800000u); + TestFalse(TEXT("CanModify rejects zero radius"), Diff->CanModify(0.0f)); + TestFalse(TEXT("CanModify rejects NaN radius"), Diff->CanModify(NaN)); + TestFalse(TEXT("CanModify rejects infinity radius"), Diff->CanModify(Inf)); + + auto ExpectRejected = [&](const TCHAR* Label, const FVoxelModification& Mod) + { + const int32 Before = Diff->GetTotalModificationCount(); + const TArray Touched = Diff->ApplyModification(Mod); + TestEqual(FString::Printf(TEXT("%s returns no affected chunks"), Label), Touched.Num(), 0); + TestEqual(FString::Printf(TEXT("%s does not update stored entries"), Label), + Diff->GetTotalModificationCount(), Before); + }; + + FVoxelModification Mod; + Mod.Radius = 0.0f; + ExpectRejected(TEXT("zero radius"), Mod); + Mod = FVoxelModification(); + Mod.Radius = NaN; + ExpectRejected(TEXT("NaN radius"), Mod); + Mod = FVoxelModification(); + Mod.Center.X = Inf; + ExpectRejected(TEXT("infinite centre"), Mod); + Mod = FVoxelModification(); + Mod.Strength = NaN; + ExpectRejected(TEXT("NaN strength"), Mod); + Mod = FVoxelModification(); + Mod.Shape = EVoxelBrushShape::Box; + Mod.BoxExtent.X = -1.0f; + ExpectRejected(TEXT("negative box extent"), Mod); + Mod = FVoxelModification(); + Mod.Shape = EVoxelBrushShape::Capsule; + Mod.CapsuleEnd.Y = Inf; + ExpectRejected(TEXT("infinite capsule endpoint"), Mod); + Mod = FVoxelModification(); + Mod.Shape = static_cast(255); + ExpectRejected(TEXT("unknown brush shape"), Mod); + + // Shape-aware budget regression: the old implementation billed both of these as a sphere using + // Radius alone, so each incorrectly fit under a 1,000-voxel cumulative budget. + TStrongObjectPtr ShapeBudget( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + ShapeBudget->SetBudget(0, 50.0f, 1000.0f); + FVoxelModification LongCapsule; + LongCapsule.Shape = EVoxelBrushShape::Capsule; + LongCapsule.Center = FVector::ZeroVector; + LongCapsule.CapsuleEnd = FVector(0.0f, 1000.0f, 0.0f); + LongCapsule.Radius = 3.0f; + LongCapsule.Falloff = 2.0f; + TestFalse(TEXT("a long capsule is charged for its swept volume"), + ShapeBudget->CanModify(LongCapsule)); + TestEqual(TEXT("the long capsule is not stored under the shape budget"), + ShapeBudget->ApplyModification(LongCapsule).Num(), 0); + + FVoxelModification LargeBox; + LargeBox.Shape = EVoxelBrushShape::Box; + LargeBox.BoxExtent = FVector(5.0f); + LargeBox.Radius = 5.0f; + TestFalse(TEXT("a box is charged for its expanded box volume"), + ShapeBudget->CanModify(LargeBox)); + TestEqual(TEXT("the large box is not stored under the shape budget"), + ShapeBudget->ApplyModification(LargeBox).Num(), 0); + + FVoxelModification Valid; + Valid.Center = FVector(3.0f * CHUNK_SIZE + 4.0f, + 4.0f * CHUNK_SIZE + 4.0f, 2.0f); + Valid.Radius = 3.0f; + Valid.Strength = -4.0f; + TestTrue(TEXT("a valid modification remains accepted after malformed inputs"), + Diff->ApplyModification(Valid).Num() > 0); + + const FIntVector Hit(3, 4, 0); + TestTrue(TEXT("range lookup finds the directly grouped affected chunk"), + Diff->HasAnyModInChunkRange(Hit, Hit)); + TestFalse(TEXT("range lookup rejects a far unmodified chunk"), + Diff->HasAnyModInChunkRange(FIntVector(30, 30, 30), FIntVector(30, 30, 30))); + TestFalse(TEXT("range lookup rejects reversed bounds"), + Diff->HasAnyModInChunkRange(FIntVector(4, 4, 0), FIntVector(3, 4, 0))); + TestTrue(TEXT("the valid edit left a non-empty diff layer"), Diff->HasAnyMods()); + return true; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/VoxelContentManager.cpp b/Source/VoxelForge/Private/VoxelContentManager.cpp index 4070b59..1bd6adc 100644 --- a/Source/VoxelForge/Private/VoxelContentManager.cpp +++ b/Source/VoxelForge/Private/VoxelContentManager.cpp @@ -31,15 +31,31 @@ static constexpr int32 GMaxCompanionsPerParent = 256; // decoration's per-cell MaxPerChunk keeps its "per chunk" meaning). static constexpr int32 DECO_CELL_VOXELS = CHUNK_SIZE; -// Count of in-flight decoration march tasks (for the EndPlay drain). File-scope so the worker lambda's -// RAII guard can touch it without keeping the UObject 'this' alive for that alone. -static std::atomic GActiveDecoTasks{0}; - static FORCEINLINE int32 CellChebyshev(const FIntPoint& A, const FIntPoint& B) { return FMath::Max(FMath::Abs(A.X - B.X), FMath::Abs(A.Y - B.Y)); } +static bool VF_DecoTransformLess(const FTransform& A, const FTransform& B) +{ + // A transform is the complete deterministic identity of a placement. Comparing the matrix + // avoids the q/-q representation ambiguity of raw quaternions and gives the final region order + // the same result regardless of which worker cell completed first. + const FMatrix AM = A.ToMatrixWithScale(); + const FMatrix BM = B.ToMatrixWithScale(); + for (int32 Row = 0; Row < 4; ++Row) + { + for (int32 Col = 0; Col < 4; ++Col) + { + if (AM.M[Row][Col] != BM.M[Row][Col]) + { + return AM.M[Row][Col] < BM.M[Row][Col]; + } + } + } + return false; +} + void UVoxelContentManager::Initialize(AActor* InOwner, UVoxelStrateManager* InStrateManager, UVoxelGenerator* InGenerator, UVoxelSettings* InSettings, int32 InSeed) { @@ -59,6 +75,7 @@ void UVoxelContentManager::BeginDestroy() { // Backstop: stop any worker from touching us. EndPlay → NotifyShutdown should have already drained. bShuttingDown.store(true, std::memory_order_release); + JoinDecorationTasks(); Super::BeginDestroy(); } @@ -66,9 +83,9 @@ void UVoxelContentManager::NotifyShutdown() { bShuttingDown.store(true, std::memory_order_release); - // Wait for in-flight march tasks to finish (they check the flag and bail). Timeout to avoid hangs. - const double Deadline = FPlatformTime::Seconds() + 3.0; - WaitForDecorationTasks(Deadline); + // A timeout is safe for the editor's pre-mutation probe, but not for UObject destruction: + // join this manager's tasks fully before dropping its worker-captured `this`. + JoinDecorationTasks(); DrainDecoResults(); ResetGridBuildState(NearGrid); @@ -77,7 +94,7 @@ void UVoxelContentManager::NotifyShutdown() bool UVoxelContentManager::WaitForDecorationTasks(double Deadline) { - while (GActiveDecoTasks.load(std::memory_order_relaxed) > 0) + while (ActiveDecorationTasks.load(std::memory_order_relaxed) > 0) { if (FPlatformTime::Seconds() > Deadline) return false; FPlatformProcess::Yield(); @@ -85,6 +102,14 @@ bool UVoxelContentManager::WaitForDecorationTasks(double Deadline) return true; } +void UVoxelContentManager::JoinDecorationTasks() +{ + while (ActiveDecorationTasks.load(std::memory_order_acquire) > 0) + { + FPlatformProcess::Yield(); + } +} + void UVoxelContentManager::DrainDecoResults() { FDecoCellResult Discard; @@ -198,12 +223,24 @@ void UVoxelContentManager::UpdateDecorations(const FVector& PlayerWorldPos) // Refresh each grid's (tier, radius, spacing) from settings for this update. Radius/spacing are read // every frame so live edits to the data asset take effect; the grids themselves persist across updates. + // A stationary player still needs a rebuild when one of these values changes: otherwise already + // loaded regions remain outside the new radius (or retain the old sampling density) forever. + const int32 NewNearRadius = FMath::Max(1, Settings->DecorationNearRadiusChunks); + const int32 NewFarRadius = FMath::Max(1, Settings->DecorationRadiusChunks); + const int32 NewNearSpacing = FMath::Clamp(Settings->DecorationSpacingVoxels, 1, CHUNK_SIZE); + const int32 NewFarSpacing = FMath::Clamp(Settings->DecorationFarSpacingVoxels, 1, CHUNK_SIZE); + const int32 NewRegionSize = RegionSize(); + const bool bDecorationGridConfigChanged = + NearGrid.Radius != NewNearRadius || FarGrid.Radius != NewFarRadius + || NearGrid.Spacing != NewNearSpacing || FarGrid.Spacing != NewFarSpacing + || LastRegionSize != NewRegionSize; + NearGrid.Tier = EDecoStreamTier::Near; - NearGrid.Radius = FMath::Max(1, Settings->DecorationNearRadiusChunks); - NearGrid.Spacing = FMath::Clamp(Settings->DecorationSpacingVoxels, 1, CHUNK_SIZE); + NearGrid.Radius = NewNearRadius; + NearGrid.Spacing = NewNearSpacing; FarGrid.Tier = EDecoStreamTier::Far; - FarGrid.Radius = FMath::Max(1, Settings->DecorationRadiusChunks); - FarGrid.Spacing = FMath::Clamp(Settings->DecorationFarSpacingVoxels, 1, CHUNK_SIZE); + FarGrid.Radius = NewFarRadius; + FarGrid.Spacing = NewFarSpacing; // Build the decoration palette ONCE for this update, PARTITIONED by tier. With biomes, concatenate every // biome's deco list and tag each entry with its context-biome index; the worker resolves a column's biome @@ -240,13 +277,19 @@ void UVoxelContentManager::UpdateDecorations(const FVector& PlayerWorldPos) } } - // Strate change → wipe + force a full rebuild (bumps epoch so old in-flight tasks are discarded). - if (StrateIndex != LastStrateIndex) + // Strate or grid configuration change → wipe + force a full rebuild. This also invalidates + // every old region build before the next stationary-player update can reuse it. + if (StrateIndex != LastStrateIndex || bDecorationGridConfigChanged) { ClearAllDecorations(); LastStrateIndex = StrateIndex; LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN); } + LastNearRadius = NewNearRadius; + LastFarRadius = NewFarRadius; + LastNearSpacing = NewNearSpacing; + LastFarSpacing = NewFarSpacing; + LastRegionSize = NewRegionSize; if (PlayerCell != LastDecoCell) { @@ -295,11 +338,22 @@ void UVoxelContentManager::RebuildDesiredCells(FDecoGrid& G, const FIntPoint& Pl } } + TArray DesiredRegionList; + DesiredRegionList.Reserve(DesiredRegions.Num()); + for (const FIntPoint& Region : DesiredRegions) + { + DesiredRegionList.Add(Region); + } + DesiredRegionList.Sort([](const FIntPoint& A, const FIntPoint& B) + { + return (A.Y != B.Y) ? (A.Y < B.Y) : (A.X < B.X); + }); + // Start a build for each desired region that isn't already loaded or building. In-progress builds are // LEFT to finish even if they fell out of range (their cell tasks are already off-thread); the apply // step discards a completed build that is no longer desired (see ProcessDecoResults). Each new build // enqueues all RxR of its cells once — a building region is never re-queued (no duplicate launches). - for (const FIntPoint& Region : DesiredRegions) + for (const FIntPoint& Region : DesiredRegionList) { if (G.Regions.Contains(Region)) continue; // already applied → leave it (no re-stream) if (G.Builds.Contains(Region)) continue; // already marching its cells @@ -320,7 +374,10 @@ void UVoxelContentManager::RebuildDesiredCells(FDecoGrid& G, const FIntPoint& Pl // build was already discarded) are cheaply skipped at launch, so PendingLaunch self-cleans as it drains. G.PendingLaunch.Sort([PlayerCell](const FIntPoint& A, const FIntPoint& B) { - return CellChebyshev(A, PlayerCell) < CellChebyshev(B, PlayerCell); + const int32 AD = CellChebyshev(A, PlayerCell); + const int32 BD = CellChebyshev(B, PlayerCell); + if (AD != BD) return AD < BD; + return (A.Y != B.Y) ? (A.Y < B.Y) : (A.X < B.X); }); } @@ -385,13 +442,17 @@ void UVoxelContentManager::LaunchDecoTasks(FDecoGrid& G, const FIntPoint& Player UVoxelGenerator* Gen = Generator; G.InFlightCells.Add(Cell); - GActiveDecoTasks.fetch_add(1, std::memory_order_relaxed); + ActiveDecorationTasks.fetch_add(1, std::memory_order_relaxed); UE::Tasks::Launch(TEXT("DecoMarch"), [this, Gen, OwnerXf, Cell, Ctx, LocalSeed, Spacing, Step, MaxCross, ColDepth, BuildId, GridTier, Entries = MoveTemp(EntriesCopy), EntryBiome = MoveTemp(EntryBiomeCopy)]() mutable { - struct FGuard { ~FGuard() { GActiveDecoTasks.fetch_sub(1, std::memory_order_relaxed); } } Guard; + struct FGuard + { + UVoxelContentManager* Owner; + ~FGuard() { Owner->ActiveDecorationTasks.fetch_sub(1, std::memory_order_release); } + } Guard{this}; if (bShuttingDown.load(std::memory_order_relaxed)) return; @@ -788,6 +849,10 @@ void UVoxelContentManager::ProcessDecoResults(const FIntPoint& PlayerCell) for (FDecoGrid* GP : { &NearGrid, &FarGrid }) { FDecoGrid& G = *GP; + G.Completed.Sort([](const FIntPoint& A, const FIntPoint& B) + { + return (A.Y != B.Y) ? (A.Y < B.Y) : (A.X < B.X); + }); while (G.Completed.Num() > 0 && Applied < Budget) { const FIntPoint Region = G.Completed[0]; @@ -846,9 +911,21 @@ void UVoxelContentManager::MergeCellResult(FDecoGrid& G, const FDecoCellResult& { if (!Prof.InstancedMesh) continue; // Bucket by MESH so cells (and biomes) sharing a mesh collapse into one region HISM. The first - // contributor sets the render tuning (cull/shadow/scale) for the whole region's instances. + // contributor in deterministic source order sets the render tuning (cull/shadow/scale) for the + // whole region's instances. Worker completion order must not choose that representative. FRegionMeshBucket& Bucket = Build->MeshBuckets.FindOrAdd(Prof.InstancedMesh); - if (Bucket.Xforms.Num() == 0) { Bucket.Profile = Prof; } + const bool bEarlierSource = + S.EntryIdx < Bucket.SourceEntryIdx + || (S.EntryIdx == Bucket.SourceEntryIdx && S.CompanionIdx < Bucket.SourceCompanionIdx) + || (S.EntryIdx == Bucket.SourceEntryIdx && S.CompanionIdx == Bucket.SourceCompanionIdx + && S.SubIdx < Bucket.SourceSubIdx); + if (Bucket.Xforms.Num() == 0 || bEarlierSource) + { + Bucket.Profile = Prof; + Bucket.SourceEntryIdx = S.EntryIdx; + Bucket.SourceCompanionIdx = S.CompanionIdx; + Bucket.SourceSubIdx = S.SubIdx; + } Bucket.Xforms.Add(S.Xf); } else if (Prof.ActorClass) @@ -893,6 +970,15 @@ void UVoxelContentManager::ApplyRegion(FDecoGrid& G, const FIntPoint& Region, FD FDecoRegionContent& Content = G.Regions.Add(Region); + Build.ActorSpawns.Sort([](const FRegionActorSpawn& A, const FRegionActorSpawn& B) + { + const FString AClass = A.ActorClass ? A.ActorClass->GetPathName() : FString(); + const FString BClass = B.ActorClass ? B.ActorClass->GetPathName() : FString(); + const int32 ClassCompare = FCString::Strcmp(*AClass, *BClass); + if (ClassCompare != 0) return ClassCompare < 0; + return VF_DecoTransformLess(A.Xf, B.Xf); + }); + // Non-instanced actors — spawn each (no batch path). Decorations live only in the player's strate // (the march is strate-bounded), so their lights are always legitimately visible — no extra culling. for (const FRegionActorSpawn& A : Build.ActorSpawns) @@ -909,13 +995,27 @@ void UVoxelContentManager::ApplyRegion(FDecoGrid& G, const FIntPoint& Region, FD // One HISM per mesh for the ENTIRE region — the render-thread win: hundreds of per-cell components // collapse to a handful per region, so InitViews walks far fewer primitives every frame. - for (TPair, FRegionMeshBucket>& Pair : Build.MeshBuckets) + TArray> MeshKeys; + Build.MeshBuckets.GetKeys(MeshKeys); + MeshKeys.Sort([](const TWeakObjectPtr& A, const TWeakObjectPtr& B) { - FRegionMeshBucket& Bucket = Pair.Value; - UStaticMesh* Mesh = Pair.Key.Get(); + const UStaticMesh* AMesh = A.Get(); + const UStaticMesh* BMesh = B.Get(); + const FString APath = AMesh ? AMesh->GetPathName() : FString(); + const FString BPath = BMesh ? BMesh->GetPathName() : FString(); + return FCString::Strcmp(*APath, *BPath) < 0; + }); + for (const TWeakObjectPtr& MeshKey : MeshKeys) + { + FRegionMeshBucket* BucketPtr = Build.MeshBuckets.Find(MeshKey); + if (!BucketPtr) continue; + FRegionMeshBucket& Bucket = *BucketPtr; + UStaticMesh* Mesh = MeshKey.Get(); if (!Mesh || Bucket.Xforms.Num() == 0) continue; const FPlacementProfile& Prof = Bucket.Profile; + Bucket.Xforms.Sort(VF_DecoTransformLess); + UHierarchicalInstancedStaticMeshComponent* HISM = NewObject(OwnerActor); HISM->SetStaticMesh(Mesh); @@ -998,6 +1098,16 @@ void UVoxelContentManager::ClearAllDecorations() // Drain any results already enqueued by in-flight tasks. No epoch bump needed: their BuildIds are now // gone from the grids' Builds, so any straggler result is discarded on merge; new builds get fresh BuildIds. DrainDecoResults(); + LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN); +} + +void UVoxelContentManager::InvalidateDecorationBuilds() +{ + // A terrain edit changes the worker's surface oracle. Clear both the applied regions and the + // in-progress build maps as one operation; any late result then fails the BuildId lookup and cannot + // resurrect pre-edit content. The next UpdateDecorations rebuilds the desired set even if the player + // has not moved. + ClearAllDecorations(); } //============================================================================= @@ -1307,6 +1417,10 @@ void UVoxelContentManager::UpdateLandmarks(const FVector& PlayerWorldPos) Ctx.bHasWater = (Wv != -FLT_MAX); Ctx.WaterLocalZ = Ctx.bHasWater ? Wv * VOXEL_SIZE : -FLT_MAX; } + // Landmarks use the same per-column biome gates as decorations. Keep the complete POD biome + // context in the worker/game-thread placement context; a null/default context would silently + // bypass the authored RequiredBiome/Conditions decisions for landmarks. + Ctx.BiomeCtx = StrateManager->GetBiomeContextForChunk(RepChunk); const float Step = (float)FMath::Max(1, Settings->DecorationMarchStepVoxels); const float ColDepth = (float)FMath::Max(8, Settings->DecorationColumnDepthVoxels); diff --git a/Source/VoxelForge/Private/VoxelDensityVolume.cpp b/Source/VoxelForge/Private/VoxelDensityVolume.cpp index d24f195..a887bea 100644 --- a/Source/VoxelForge/Private/VoxelDensityVolume.cpp +++ b/Source/VoxelForge/Private/VoxelDensityVolume.cpp @@ -71,6 +71,7 @@ void UVoxelDensityVolume::Initialize(AActor* InOwner, UVoxelGenerator* InGenerat Generator = InGenerator; Settings = InSettings; bShuttingDown.store(false, std::memory_order_relaxed); + bGenerationPaused.store(false, std::memory_order_relaxed); bInitialized = true; // Arrays are allocated lazily on the first Update (EnsureAllocated) so a settings change // (resolution / level count) before play picks up cleanly. @@ -80,6 +81,7 @@ void UVoxelDensityVolume::BeginDestroy() { // Backstop — EndPlay → NotifyShutdown should already have stopped the fill thread. bShuttingDown.store(true, std::memory_order_release); + bGenerationPaused.store(true, std::memory_order_release); StopFillThread(); Super::BeginDestroy(); } @@ -87,6 +89,7 @@ void UVoxelDensityVolume::BeginDestroy() void UVoxelDensityVolume::NotifyShutdown() { bShuttingDown.store(true, std::memory_order_release); + bGenerationPaused.store(true, std::memory_order_release); // Stop the dedicated fill thread — Kill(true) blocks until Run() returns, so it can't read the // Generator after this (the owner tears UObjects down next). Then drop any queued/finished work. @@ -100,6 +103,10 @@ void UVoxelDensityVolume::NotifyShutdown() void UVoxelDensityVolume::Reset() { + // Reset can be called by a live-edit/regeneration path, not only after EndPlay. Join first so + // no fill thread can still read Generator while the old epoch and queued work are discarded. + StopFillThread(); + // Bump the epoch so any in-flight fill lands stale and is dropped in DrainResults. ++VolumeEpoch; PendingFills.Reset(); @@ -118,6 +125,20 @@ void UVoxelDensityVolume::Reset() LastPlayerVoxel = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); } +void UVoxelDensityVolume::PauseForGenerationChange() +{ + bGenerationPaused.store(true, std::memory_order_release); + StopFillThread(); +} + +void UVoxelDensityVolume::ResumeAfterGenerationChange() +{ + if (!bShuttingDown.load(std::memory_order_acquire)) + { + bGenerationPaused.store(false, std::memory_order_release); + } +} + //============================================================================= // Helpers //============================================================================= @@ -284,7 +305,8 @@ bool UVoxelDensityVolume::GetLevelShaderParams(int32 Level, FIntVector& OutOrigi void UVoxelDensityVolume::Update(const FVector& PlayerWorldPos) { - if (!bInitialized || !Settings || !Settings->bEnableDensityVolume || !Generator) return; + if (bGenerationPaused.load(std::memory_order_acquire) + || !bInitialized || !Settings || !Settings->bEnableDensityVolume || !Generator) return; AActor* O = Owner.Get(); if (!O) return; @@ -557,6 +579,8 @@ void UVoxelDensityVolume::IngestTileCapture(const FIntVector& L0TileCoord, TArra void UVoxelDensityVolume::EnsureFillThread() { if (FillThread) return; + if (bGenerationPaused.load(std::memory_order_acquire) + || bShuttingDown.load(std::memory_order_acquire)) return; if (!Settings || !Settings->bEnableDensityVolume) return; bFillThreadStop.store(false, std::memory_order_release); if (!FillWakeEvent) { FillWakeEvent = FPlatformProcess::GetSynchEventFromPool(false); } // auto-reset @@ -588,6 +612,8 @@ void UVoxelDensityVolume::StopFillThread() void UVoxelDensityVolume::LaunchPendingFills() { if (PendingFills.Num() == 0) return; + if (bGenerationPaused.load(std::memory_order_acquire) + || bShuttingDown.load(std::memory_order_acquire)) return; EnsureFillThread(); for (FPendingFill& F : PendingFills) { diff --git a/Source/VoxelForge/Private/VoxelDiffLayer.cpp b/Source/VoxelForge/Private/VoxelDiffLayer.cpp index 7170dcd..33716d7 100644 --- a/Source/VoxelForge/Private/VoxelDiffLayer.cpp +++ b/Source/VoxelForge/Private/VoxelDiffLayer.cpp @@ -3,6 +3,98 @@ #include "VoxelDiffLayer.h" +namespace +{ + std::atomic GNextDiffLayerLifetimeId{0}; + + bool IsFiniteVector(const FVector& Value) + { + return VoxelMath::IsFinite(Value.X) + && VoxelMath::IsFinite(Value.Y) + && VoxelMath::IsFinite(Value.Z); + } + + bool IsValidModificationGeometry(const FVoxelModification& Mod) + { + if (!IsFiniteVector(Mod.Center) || !IsFiniteVector(Mod.BoxExtent) + || !IsFiniteVector(Mod.CapsuleEnd) + || !VoxelMath::IsFinite(Mod.Radius) + || !VoxelMath::IsFinite(Mod.Strength) + || !VoxelMath::IsFinite(Mod.Falloff) + || Mod.Radius <= 0.0f || Mod.Falloff < 0.0f) + { + return false; + } + + switch (Mod.Shape) + { + case EVoxelBrushShape::Sphere: + return true; + case EVoxelBrushShape::Box: + return Mod.BoxExtent.X > 0.0f + && Mod.BoxExtent.Y > 0.0f + && Mod.BoxExtent.Z > 0.0f; + case EVoxelBrushShape::Capsule: + return true; // A coincident pair of endpoints is a valid spherical capsule. + default: + return false; + } + } + + double GetModificationBudgetVolume(const FVoxelModification& Mod) + { + switch (Mod.Shape) + { + case EVoxelBrushShape::Box: + { + // The SDF's falloff is a rounded outer band, so the expanded axis-aligned box is a + // conservative charge and remains cheap to compute. + const double Ex = static_cast(Mod.BoxExtent.X) + static_cast(Mod.Falloff); + const double Ey = static_cast(Mod.BoxExtent.Y) + static_cast(Mod.Falloff); + const double Ez = static_cast(Mod.BoxExtent.Z) + static_cast(Mod.Falloff); + return 8.0 * Ex * Ey * Ez; + } + case EVoxelBrushShape::Capsule: + { + // Charge the full capsule including its falloff shell: cylinder + two hemispheres. + const double OuterRadius = static_cast(Mod.Radius) + static_cast(Mod.Falloff); + const double SegmentLength = static_cast(FVector::Dist(Mod.Center, Mod.CapsuleEnd)); + return PI * OuterRadius * OuterRadius * SegmentLength + + (4.0 / 3.0) * PI * OuterRadius * OuterRadius * OuterRadius; + } + case EVoxelBrushShape::Sphere: + default: + { + const double Radius = static_cast(Mod.Radius); + return (4.0 / 3.0) * PI * Radius * Radius * Radius; + } + } + } +} + +uint64 UVoxelDiffLayer::GetCacheLifetimeId() const +{ + uint64 LifetimeId = CacheLifetimeId.load(std::memory_order_acquire); + if (LifetimeId != 0) + { + return LifetimeId; + } + + const uint64 NewLifetimeId = GNextDiffLayerLifetimeId.fetch_add(1, std::memory_order_relaxed) + 1; + if (CacheLifetimeId.compare_exchange_strong( + LifetimeId, + NewLifetimeId, + std::memory_order_release, + std::memory_order_acquire)) + { + return NewLifetimeId; + } + + // Another worker initialized the identity first. Its value is written into LifetimeId by + // compare_exchange_strong on failure. + return LifetimeId; +} + //============================================================================= // BUDGET CONFIGURATION //============================================================================= @@ -19,8 +111,13 @@ void UVoxelDiffLayer::SetBudget(int32 InMaxMods, float InMaxRadius, float InMaxV bool UVoxelDiffLayer::CanModify(float Radius) const { + if (!VoxelMath::IsFinite(Radius) || Radius <= 0.0f) + { + return false; + } + // Check radius limit (always enforced, even if "unlimited" count/volume) - if (Radius > BudgetMaxRadius) + if (BudgetMaxRadius > 0.0f && Radius > BudgetMaxRadius) { return false; } @@ -34,8 +131,12 @@ bool UVoxelDiffLayer::CanModify(float Radius) const // Check volume limit: would this brush push us over? if (BudgetMaxVolume > 0.0f) { - const float BrushVolume = (4.0f / 3.0f) * PI * Radius * Radius * Radius; - if (AccumulatedVolume + BrushVolume > BudgetMaxVolume) + const double BrushVolume = (4.0 / 3.0) * PI + * static_cast(Radius) * static_cast(Radius) * static_cast(Radius); + const double NewTotal = static_cast(AccumulatedVolume) + BrushVolume; + if (!VoxelMath::IsFinite(BrushVolume) || BrushVolume > static_cast(FLT_MAX) + || !VoxelMath::IsFinite(NewTotal) + || NewTotal > static_cast(BudgetMaxVolume)) { return false; } @@ -44,6 +145,27 @@ bool UVoxelDiffLayer::CanModify(float Radius) const return true; } +bool UVoxelDiffLayer::CanModify(const FVoxelModification& Mod) const +{ + if (!IsValidModificationGeometry(Mod)) return false; + + // Radius is the radial cap for spheres/capsules. A box's largest half-extent is its analogous + // brush radius; checking it separately prevents a direct API caller from hiding a huge box behind + // an unrelated small Radius field. + const float GeometryRadius = (Mod.Shape == EVoxelBrushShape::Box) + ? FMath::Max3(Mod.BoxExtent.X, Mod.BoxExtent.Y, Mod.BoxExtent.Z) + : Mod.Radius; + if (BudgetMaxRadius > 0.0f && GeometryRadius > BudgetMaxRadius) return false; + if (BudgetMaxMods > 0 && ModificationCount >= BudgetMaxMods) return false; + + const double BrushVolume = GetModificationBudgetVolume(Mod); + const double NewTotal = static_cast(AccumulatedVolume) + BrushVolume; + if (!VoxelMath::IsFinite(BrushVolume) || BrushVolume > static_cast(FLT_MAX) + || !VoxelMath::IsFinite(NewTotal)) return false; + if (BudgetMaxVolume > 0.0f && NewTotal > static_cast(BudgetMaxVolume)) return false; + return true; +} + int32 UVoxelDiffLayer::GetRemainingModifications() const { if (BudgetMaxMods <= 0) return -1; // Unlimited @@ -64,11 +186,25 @@ TArray UVoxelDiffLayer::ApplyModification(const FVoxelModification& { TArray AffectedChunks; - // --- Budget enforcement --- - // Clamp radius to max allowed - float ClampedRadius = FMath::Min(Mod.Radius, BudgetMaxRadius); + // Validate the complete geometry before radius clamping, budget accounting, or AABB math. + // FMath::Min(NaN, limit) and the shape switch's default sphere path would otherwise turn + // malformed API input into either a counter update or an invalid chunk range. + if (!IsValidModificationGeometry(Mod)) + { + UE_LOG(LogTemp, Warning, + TEXT("[DiffLayer] Modification REJECTED — non-finite or invalid brush geometry")); + return AffectedChunks; + } - if (!CanModify(ClampedRadius)) + // --- Budget enforcement --- + // Preserve the historical radius clamp for spheres/capsules when a finite per-brush cap is set; + // the shape-aware check below still charges the resulting geometry and rejects oversized boxes. + const float ClampedRadius = (BudgetMaxRadius > 0.0f) + ? FMath::Min(Mod.Radius, BudgetMaxRadius) : Mod.Radius; + FVoxelModification ClampedMod = Mod; + ClampedMod.Radius = ClampedRadius; + + if (!CanModify(ClampedMod)) { UE_LOG(LogTemp, Warning, TEXT("[DiffLayer] Modification REJECTED — budget exceeded (count=%d/%d, volume=%.0f/%.0f)"), @@ -76,34 +212,53 @@ TArray UVoxelDiffLayer::ApplyModification(const FVoxelModification& return AffectedChunks; // Empty = rejected } - // Use clamped radius for the actual modification - FVoxelModification ClampedMod = Mod; - ClampedMod.Radius = ClampedRadius; + // Complete the world-AABB calculation before mutating the budget counters. Finite input can + // still overflow during Center +/- extent or address a chunk outside FIntVector's domain; + // neither case is a modification that this layer can store safely. + FVector BoundsMin, BoundsMax; + ClampedMod.GetWorldBounds(BoundsMin, BoundsMax); + auto WorldToChunk = [](float WorldCoord, int32& OutChunk) -> bool + { + if (!VoxelMath::IsFinite(WorldCoord)) + { + return false; + } + const double ChunkCoord = FMath::FloorToDouble( + static_cast(WorldCoord) / static_cast(CHUNK_SIZE)); + if (!VoxelMath::IsFinite(ChunkCoord) + || ChunkCoord < static_cast(MIN_int32) + || ChunkCoord > static_cast(MAX_int32)) + { + return false; + } + OutChunk = static_cast(ChunkCoord); + return true; + }; + + int32 MinCX = 0, MaxCX = 0, MinCY = 0, MaxCY = 0, MinCZ = 0, MaxCZ = 0; + if (!WorldToChunk(BoundsMin.X, MinCX) || !WorldToChunk(BoundsMax.X, MaxCX) + || !WorldToChunk(BoundsMin.Y, MinCY) || !WorldToChunk(BoundsMax.Y, MaxCY) + || !WorldToChunk(BoundsMin.Z, MinCZ) || !WorldToChunk(BoundsMax.Z, MaxCZ)) + { + UE_LOG(LogTemp, Warning, + TEXT("[DiffLayer] Modification REJECTED — brush bounds are not representable")); + return TArray(); + } // Update budget counters ModificationCount++; - const float BrushVolume = (4.0f / 3.0f) * PI * ClampedRadius * ClampedRadius * ClampedRadius; - AccumulatedVolume += BrushVolume; - - // Find every chunk the brush can touch, from its shape-aware world AABB. - FVector BoundsMin, BoundsMax; - ClampedMod.GetWorldBounds(BoundsMin, BoundsMax); - const int32 MinCX = FMath::FloorToInt(BoundsMin.X / CHUNK_SIZE); - const int32 MaxCX = FMath::FloorToInt(BoundsMax.X / CHUNK_SIZE); - const int32 MinCY = FMath::FloorToInt(BoundsMin.Y / CHUNK_SIZE); - const int32 MaxCY = FMath::FloorToInt(BoundsMax.Y / CHUNK_SIZE); - const int32 MinCZ = FMath::FloorToInt(BoundsMin.Z / CHUNK_SIZE); - const int32 MaxCZ = FMath::FloorToInt(BoundsMax.Z / CHUNK_SIZE); + const double BrushVolume = GetModificationBudgetVolume(ClampedMod); + AccumulatedVolume += static_cast(BrushVolume); { // Write lock: blocks worker-thread readers (HasModifications / GetDensityOffset) while the map // is mutated/rehashed. AffectedChunks is local; populate it in the same pass. FWriteScopeLock Lock(ModsLock); - for (int32 CZ = MinCZ; CZ <= MaxCZ; CZ++) + for (int32 CZ = MinCZ;; ++CZ) { - for (int32 CY = MinCY; CY <= MaxCY; CY++) + for (int32 CY = MinCY;; ++CY) { - for (int32 CX = MinCX; CX <= MaxCX; CX++) + for (int32 CX = MinCX;; ++CX) { FIntVector ChunkCoord(CX, CY, CZ); @@ -112,8 +267,12 @@ TArray UVoxelDiffLayer::ApplyModification(const FVoxelModification& // Track which chunks need re-meshing AffectedChunks.Add(ChunkCoord); + + if (CX == MaxCX) break; } + if (CY == MaxCY) break; } + if (CZ == MaxCZ) break; } // Publish: subsequent readers must now take the lock instead of fast-rejecting, and // worker-side snapshots keyed on the version re-copy their chunk's list. @@ -171,19 +330,30 @@ bool UVoxelDiffLayer::HasAnyModInChunkRange(const FIntVector& MinChunk, const FI { if (!bHasAnyMods.load(std::memory_order_acquire)) return false; - // Walk the modified-chunk KEYS under one read lock. Conservative: a mod is registered in every - // chunk its radius overlaps (ApplyModification), so key-in-range ⟺ the mod can reach the range. - FReadScopeLock Lock(ModsLock); - for (const auto& Pair : ChunkMods) + if (MinChunk.X > MaxChunk.X || MinChunk.Y > MaxChunk.Y || MinChunk.Z > MaxChunk.Z) { - const FIntVector& C = Pair.Key; - if (C.X >= MinChunk.X && C.X <= MaxChunk.X && - C.Y >= MinChunk.Y && C.Y <= MaxChunk.Y && - C.Z >= MinChunk.Z && C.Z <= MaxChunk.Z && - Pair.Value.Num() > 0) + return false; + } + + // ApplyModification registers a mod in every chunk its AABB overlaps, so looking up the keys in + // the queried range is conservative and proportional to that range. Do not walk ChunkMods: + // the edit history can grow without bound while a sealed-solid candidate is being proved. + FReadScopeLock Lock(ModsLock); + for (int32 CZ = MinChunk.Z;; ++CZ) + { + for (int32 CY = MinChunk.Y;; ++CY) { - return true; + for (int32 CX = MinChunk.X;; ++CX) + { + if (const TArray* Mods = ChunkMods.Find(FIntVector(CX, CY, CZ))) + { + if (Mods->Num() > 0) return true; + } + if (CX == MaxChunk.X) break; + } + if (CY == MaxChunk.Y) break; } + if (CZ == MaxChunk.Z) break; } return false; } diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index a2841d6..bfc33d8 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -280,6 +280,8 @@ struct FSurfaceColumnBox static constexpr int32 Dim = 2 * Halo + 1; int32 BaseX = 0, BaseY = 0; // box origin (voxel coords) int32 StrateKey = MIN_int32, Seed = MIN_int32; // key: same strate ⇒ identical heightfield params + uint64 OwnerId = 0; // generator/world identity + uint64 ManagerLifetimeId = 0; // strate-manager lifetime identity // AUDIT C2, étendu : StrateKey vaut round(StrateBottomWorldZ), donc une édition à chaud qui // change les params de terrain SANS déplacer la strate (fréquence de bruit, hauteur de // montagne, un biome…) laisse la clé identique et sert des colonnes périmées. C'est la forme @@ -301,14 +303,17 @@ struct FSurfaceColumnCache FSurfaceColumnBox Boxes[NumBoxes]; uint32 Clock = 0; - // Return the box covering (IX,IY) for this (StrateKey,Seed,LayoutVersion); allocate by + // Return the box covering (IX,IY) for this (owner,manager,StrateKey,Seed,LayoutVersion); allocate by // evicting the LRU box on miss. - FSurfaceColumnBox& Acquire(int32 IX, int32 IY, int32 InStrateKey, int32 InSeed, uint32 InLayoutVersion) + FSurfaceColumnBox& Acquire(int32 IX, int32 IY, int32 InStrateKey, int32 InSeed, + uint32 InLayoutVersion, uint64 InOwnerId, + uint64 InManagerLifetimeId) { ++Clock; for (FSurfaceColumnBox& B : Boxes) { - if (B.bValid && B.StrateKey == InStrateKey && B.Seed == InSeed + if (B.bValid && B.OwnerId == InOwnerId && B.ManagerLifetimeId == InManagerLifetimeId + && B.StrateKey == InStrateKey && B.Seed == InSeed && B.LayoutVersion == InLayoutVersion && IX >= B.BaseX && IX < B.BaseX + FSurfaceColumnBox::Dim && IY >= B.BaseY && IY < B.BaseY + FSurfaceColumnBox::Dim) @@ -324,6 +329,8 @@ struct FSurfaceColumnCache Victim->BaseY = IY - FSurfaceColumnBox::Halo; Victim->StrateKey = InStrateKey; Victim->Seed = InSeed; + Victim->OwnerId = InOwnerId; + Victim->ManagerLifetimeId = InManagerLifetimeId; Victim->LayoutVersion = InLayoutVersion; Victim->bValid = true; Victim->LastUse = Clock; @@ -2798,6 +2805,10 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // The key MUST include the layout version, not just the chunk coord. Symptom without it: // "I tweaked the strate asset, regenerated, and one patch kept the old shape." thread_local uint32 CP_Version = 0xFFFFFFFFu; + // The tunnel cache key also includes whether the current tile requested the coarse + // tile-window context. A cache hit moves the prepared stack/core out of these TLS values; + // when the other context misses, this bit must force the rebuild guard below to run. + thread_local bool CP_UsesTileCacheWindow = false; // OPSTACK Phase 1 — la pile d'opérateurs, construite dans le MÊME bloc de refetch que les // params (donc même clé owner+chunk+version, aucune logique d'invalidation en plus). Vide tant que // la strate n'a pas coché `bUseOperatorStack` ET que son archétype n'est pas porté. @@ -2916,6 +2927,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co CP_TunnelFP = CachedEntry.TunnelFingerprint; CP_Dist = CachedEntry.Disturbance; CP_UseOpStack = CachedEntry.bUseOpStack; + CP_UsesTileCacheWindow = CachedEntry.bUsesTileCacheWindow; CP_UseCustomRecipe = false; #if WITH_EDITOR CP_UseComposerRegions = false; @@ -2935,9 +2947,17 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co const bool bOwnerChanged = DensityCacheOwnerId != CP_OwnerId; const bool bManagerChanged = ManagerLifetimeId != CP_ManagerLifetimeId; + // A cache hit may have moved CP_OpStack/GTunnelCoreCache into the shared entry. If the + // next lookup misses (including an eviction/collision for the same shared Z key), the + // fallback TLS objects are moved-from and must never be evaluated as if they were valid. + // The active-entry pointer is the explicit moved-out marker; a miss therefore forces the + // normal rebuild even when the shared key happens to equal ChunkCoord at the origin. + const bool bMovedOutTunnelState = CP_ActiveTunnelDensityCache != nullptr; if (!bLoadedTunnelDensityCache && (bOwnerChanged || bManagerChanged || ChunkCoord != CP_Chunk - || LayoutVersion != CP_Version)) + || LayoutVersion != CP_Version + || bTileCacheContext != CP_UsesTileCacheWindow + || bMovedOutTunnelState)) { CP_ActiveTunnelDensityCache = nullptr; @@ -2955,6 +2975,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co CP_ManagerLifetimeId = ManagerLifetimeId; CP_Version = LayoutVersion; CP_Chunk = ChunkCoord; + CP_UsesTileCacheWindow = bTileCacheContext; CP_GenType = StrateManager->GetGeneratorTypeForChunk(ChunkCoord); CP_UseCustomRecipe = false; bool bAllowCustomRecipe = true; @@ -3305,7 +3326,9 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co const int32 IX = (int32)WorldX, IY = (int32)WorldY; // XY-keyed LRU box (shared down the whole vertical strate stack). Acquire centres a box on // the first sample so the rest of the chunk's queries — incl. the ±Step margin ring — hit. - FSurfaceColumnBox& Box = GSurfColCache.Acquire(IX, IY, CP_StrateKey, Seed, LayoutVersion); + FSurfaceColumnBox& Box = GSurfColCache.Acquire( + IX, IY, CP_StrateKey, Seed, LayoutVersion, + DensityCacheOwnerId, ManagerLifetimeId); const int32 CI = (IY - Box.BaseY) * FSurfaceColumnBox::Dim + (IX - Box.BaseX); if (!Box.Computed[CI]) { @@ -3695,15 +3718,18 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co struct FDiffSlot { FIntVector Chunk = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); + uint64 OwnerId = 0; uint32 Version = 0; TArray Mods; }; static thread_local FDiffSlot DiffSlots[64]; + const uint64 DiffLayerOwnerId = DiffLayer->GetCacheLifetimeId(); const uint32 V = DiffLayer->GetModsVersion(); FDiffSlot& Slot = DiffSlots[(ChunkCoord.X & 3) | ((ChunkCoord.Y & 3) << 2) | ((ChunkCoord.Z & 3) << 4)]; - if (Slot.Chunk != ChunkCoord || Slot.Version != V) + if (Slot.OwnerId != DiffLayerOwnerId || Slot.Chunk != ChunkCoord || Slot.Version != V) { + Slot.OwnerId = DiffLayerOwnerId; Slot.Chunk = ChunkCoord; Slot.Version = V; DiffLayer->GetChunkModsSnapshot(ChunkCoord, Slot.Mods); // ONE lock per chunk, not per voxel @@ -5768,12 +5794,24 @@ bool UVoxelGenerator::GetSurfaceHeightAt(float WorldX, float WorldY, int32 Chunk thread_local TArray OC_BiomeParams; thread_local FChunkBiomeCache OC_BiomeCache; thread_local uint32 OC_Version = 0xFFFFFFFFu; // AUDIT C2 + thread_local uint64 OC_OwnerId = 0; + thread_local uint64 OC_ManagerLifetimeId = 0; const uint32 OC_LayoutVersion = StrateManager->GetLayoutVersion(); - if (ChunkCoord != OC_Chunk || OC_LayoutVersion != OC_Version) + const uint64 OC_CurrentOwnerId = DensityCacheOwnerId; + const uint64 OC_CurrentManagerLifetimeId = StrateManager->GetCacheLifetimeId(); + if (ChunkCoord != OC_Chunk || OC_LayoutVersion != OC_Version + || OC_CurrentOwnerId != OC_OwnerId + || OC_CurrentManagerLifetimeId != OC_ManagerLifetimeId) { - if (OC_LayoutVersion != OC_Version) { OC_BiomeCache.Invalidate(); } + if (OC_LayoutVersion != OC_Version || OC_CurrentOwnerId != OC_OwnerId + || OC_CurrentManagerLifetimeId != OC_ManagerLifetimeId) + { + OC_BiomeCache.Invalidate(); + } OC_Version = OC_LayoutVersion; + OC_OwnerId = OC_CurrentOwnerId; + OC_ManagerLifetimeId = OC_CurrentManagerLifetimeId; OC_Chunk = ChunkCoord; ResolveSurfaceChunkParams(ChunkCoord, OC_Surface, OC_BiomeCtx, OC_BiomeParams); } @@ -6655,10 +6693,18 @@ EVoxelTileClass UVoxelGenerator::ClassifyTileUncached( // AUDIT C2 — même discipline que le chemin densité : la grille de biome du classifieur survit // d'un appel à l'autre et sa boîte de validité ne dit rien du contexte qui l'a produite. static thread_local uint32 TC_SeenVersion = 0xFFFFFFFFu; + static thread_local uint64 TC_SeenOwnerId = 0; + static thread_local uint64 TC_SeenManagerLifetimeId = 0; const uint32 TC_LayoutVersion = StrateManager->GetLayoutVersion(); - if (TC_LayoutVersion != TC_SeenVersion) + const uint64 TC_OwnerId = DensityCacheOwnerId; + const uint64 TC_ManagerLifetimeId = StrateManager->GetCacheLifetimeId(); + if (TC_LayoutVersion != TC_SeenVersion + || TC_OwnerId != TC_SeenOwnerId + || TC_ManagerLifetimeId != TC_SeenManagerLifetimeId) { TC_SeenVersion = TC_LayoutVersion; + TC_SeenOwnerId = TC_OwnerId; + TC_SeenManagerLifetimeId = TC_ManagerLifetimeId; TC_BiomeCache.Invalidate(); } int32 NumSlots = 0; @@ -7208,7 +7254,9 @@ EVoxelTileClass UVoxelGenerator::ClassifyTileUncached( { FSurfSlot& S = Slots[s]; if (S.InteriorZ.Num() == 0) continue; - FSurfaceColumnBox& Box = GSurfColCache.Acquire(Xi, Yi, S.StrateKey, Seed, TC_LayoutVersion); + FSurfaceColumnBox& Box = GSurfColCache.Acquire( + Xi, Yi, S.StrateKey, Seed, TC_LayoutVersion, + TC_OwnerId, TC_ManagerLifetimeId); const int32 CI = (Yi - Box.BaseY) * FSurfaceColumnBox::Dim + (Xi - Box.BaseX); if (!Box.Computed[CI]) { @@ -7447,10 +7495,13 @@ FBiomeSample UVoxelGenerator::SampleBiomeAt(float WorldX, float WorldY, const FB } void UVoxelGenerator::RebuildBiomeGrid(int32 ChunkX, int32 ChunkY, int32 ChunkZ, - const FBiomeContext& Ctx, FChunkBiomeCache& Cache) const + const FBiomeContext& Ctx, FChunkBiomeCache& Cache, + uint64 OwnerId, uint64 ManagerLifetimeId) const { Cache.ChunkZ = ChunkZ; Cache.Seed = Seed; + Cache.OwnerId = OwnerId; + Cache.ManagerLifetimeId = ManagerLifetimeId; Cache.bActive = Ctx.IsValid(); Cache.Ctx = Ctx; Cache.CellBiome.Reset(); @@ -7506,12 +7557,16 @@ FBiomeSample UVoxelGenerator::ResolveBiomeSampleAt(float WorldX, float WorldY, i const FBiomeContext& Ctx, FChunkBiomeCache& Cache) const { FBiomeSample Out; + const uint64 OwnerId = DensityCacheOwnerId; + const uint64 ManagerLifetimeId = StrateManager + ? StrateManager->GetCacheLifetimeId() : 0; // Box-validated rebuild (perf-only; result is the pure function of XY either way). - if (!Cache.Contains(WorldX, WorldY, ChunkZ, Seed)) + if (!Cache.Contains(WorldX, WorldY, ChunkZ, Seed, OwnerId, ManagerLifetimeId)) { RebuildBiomeGrid(FMath::FloorToInt(WorldX / CHUNK_SIZE), - FMath::FloorToInt(WorldY / CHUNK_SIZE), ChunkZ, Ctx, Cache); + FMath::FloorToInt(WorldY / CHUNK_SIZE), ChunkZ, Ctx, Cache, + OwnerId, ManagerLifetimeId); } if (!Cache.bActive) return Out; @@ -7659,12 +7714,24 @@ void UVoxelGenerator::GetBiomeMaterialAt(float WorldX, float WorldY, float World thread_local FBiomeContext BM_Ctx; thread_local FChunkBiomeCache BM_Cache; thread_local uint32 BM_Version = 0xFFFFFFFFu; // AUDIT C2 + thread_local uint64 BM_OwnerId = 0; + thread_local uint64 BM_ManagerLifetimeId = 0; const uint32 BM_LayoutVersion = StrateManager->GetLayoutVersion(); - if (ChunkCoord != BM_Chunk || BM_LayoutVersion != BM_Version) + const uint64 BM_CurrentOwnerId = DensityCacheOwnerId; + const uint64 BM_CurrentManagerLifetimeId = StrateManager->GetCacheLifetimeId(); + if (ChunkCoord != BM_Chunk || BM_LayoutVersion != BM_Version + || BM_CurrentOwnerId != BM_OwnerId + || BM_CurrentManagerLifetimeId != BM_ManagerLifetimeId) { - if (BM_LayoutVersion != BM_Version) { BM_Cache.Invalidate(); } + if (BM_LayoutVersion != BM_Version || BM_CurrentOwnerId != BM_OwnerId + || BM_CurrentManagerLifetimeId != BM_ManagerLifetimeId) + { + BM_Cache.Invalidate(); + } BM_Version = BM_LayoutVersion; + BM_OwnerId = BM_CurrentOwnerId; + BM_ManagerLifetimeId = BM_CurrentManagerLifetimeId; BM_Chunk = ChunkCoord; BM_Ctx = StrateManager->GetBiomeContextForChunk(ChunkCoord); } @@ -7727,14 +7794,15 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float thread_local float VS_Spacing = -1.0f, VS_Dens = -1.0f, VS_MinR = -1.0f, VS_MaxR = -1.0f, VS_Cross = -1.0f, VS_ConnR = -1.0f, VS_Rough = -1.0f, VS_SpineR = -1.0f, VS_BotZ = FLT_MAX, VS_TopZ = FLT_MAX, - VS_Seal = -1.0f; + VS_Seal = -1.0f, VS_LedgeSpacing = -1.0f, VS_LedgeDepth = -1.0f; if (CX != VS_CX || CY != VS_CY || S != VS_Seed || Spacing != VS_Spacing || Params.ShaftDensity != VS_Dens || Params.ShaftMinRadius != VS_MinR || Params.ShaftMaxRadius != VS_MaxR || Params.CrossConnectChance != VS_Cross || Params.ConnectorRadius != VS_ConnR || Params.SurfaceRoughness != VS_Rough || OriginSpineRadius != VS_SpineR || Params.StrateBottomWorldZ != VS_BotZ || Params.StrateTopWorldZ != VS_TopZ || - Params.BoundarySealThickness != VS_Seal) + Params.BoundarySealThickness != VS_Seal + || Params.LedgeSpacing != VS_LedgeSpacing || Params.LedgeDepth != VS_LedgeDepth) { VS_CX = CX; VS_CY = CY; VS_Seed = S; VS_Spacing = Spacing; VS_Dens = Params.ShaftDensity; VS_MinR = Params.ShaftMinRadius; VS_MaxR = Params.ShaftMaxRadius; @@ -7742,6 +7810,8 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float VS_Rough = Params.SurfaceRoughness; VS_SpineR = OriginSpineRadius; VS_BotZ = Params.StrateBottomWorldZ; VS_TopZ = Params.StrateTopWorldZ; VS_Seal = Params.BoundarySealThickness; + VS_LedgeSpacing = Params.LedgeSpacing; + VS_LedgeDepth = Params.LedgeDepth; Shafts.Reset(); Conns.Reset(); TreeEmitShafts.Reset(); @@ -8154,17 +8224,20 @@ float UVoxelGenerator::GetFloatingIslandDensity(float WorldX, float WorldY, floa thread_local int32 FI_CX = INT32_MAX, FI_CY = INT32_MAX; thread_local uint32 FI_Seed = 0xFFFFFFFFu; thread_local float FI_Spacing = -1.0f, FI_Dens = -1.0f, FI_MinR = -1.0f, FI_MaxR = -1.0f, - FI_Thick = -1.0f, FI_VJit = -1.0f, FI_BotZ = FLT_MAX, FI_TopZ = FLT_MAX; + FI_Thick = -1.0f, FI_VJit = -1.0f, FI_BotZ = FLT_MAX, FI_TopZ = FLT_MAX, + FI_Seal = -1.0f; if (CX != FI_CX || CY != FI_CY || S != FI_Seed || Spacing != FI_Spacing || Params.IslandDensity != FI_Dens || Params.IslandMinRadius != FI_MinR || Params.IslandMaxRadius != FI_MaxR || Params.ThicknessRatio != FI_Thick || Params.VerticalJitter != FI_VJit || - Params.StrateBottomWorldZ != FI_BotZ || Params.StrateTopWorldZ != FI_TopZ) + Params.StrateBottomWorldZ != FI_BotZ || Params.StrateTopWorldZ != FI_TopZ + || Params.BoundarySealThickness != FI_Seal) { FI_CX = CX; FI_CY = CY; FI_Seed = S; FI_Spacing = Spacing; FI_Dens = Params.IslandDensity; FI_MinR = Params.IslandMinRadius; FI_MaxR = Params.IslandMaxRadius; FI_Thick = Params.ThicknessRatio; FI_VJit = Params.VerticalJitter; FI_BotZ = Params.StrateBottomWorldZ; FI_TopZ = Params.StrateTopWorldZ; + FI_Seal = Params.BoundarySealThickness; Islands.Reset(); for (int32 dy = -1; dy <= 1; dy++) diff --git a/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Source/VoxelForge/Private/VoxelStrateManager.cpp index 21727ec..387acc7 100644 --- a/Source/VoxelForge/Private/VoxelStrateManager.cpp +++ b/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -1084,6 +1084,27 @@ bool UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) WorldSeed = SeasonManifest.Seed; } + // Keep a valid live layout intact until every season/layout validation step below has + // succeeded. RebuildStrates is called from live edit while old meshes are still visible; + // returning false after clearing these members would leave those meshes backed by an empty or + // partial manager. The copies are small (one entry per strate) and this path is not part of + // worker generation. + const TArray PreviousLayout = StrateLayout; + const TArray PreviousSeasonStrates = SeasonStrates; + const FString PreviousSeasonContentHash = ActiveSeasonContentHash; +#if WITH_EDITOR + const TMap> PreviousComposerOverrides = ComposerOverrides; +#endif + auto RestorePreviousLayout = [&]() + { + StrateLayout = PreviousLayout; + SeasonStrates = PreviousSeasonStrates; + ActiveSeasonContentHash = PreviousSeasonContentHash; +#if WITH_EDITOR + ComposerOverrides = PreviousComposerOverrides; +#endif + }; + StrateLayout.Empty(); SeasonStrates.Empty(); ActiveSeasonContentHash.Reset(); @@ -1185,8 +1206,7 @@ bool UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) UE_LOG(LogTemp, Error, TEXT("[StrateManager] Season slot %d could not load authored definition '%s'."), i, *SeasonStrate.SourceDefinitionPath); - StrateLayout.Empty(); - SeasonStrates.Empty(); + RestorePreviousLayout(); return false; } // Keep the authored content/passage/visual bag without mutating the cooked asset, @@ -1228,8 +1248,7 @@ bool UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) { UE_LOG(LogTemp, Error, TEXT("[StrateManager] Season slot %d bounds do not describe the declared stacked layout."), i); - StrateLayout.Empty(); - SeasonStrates.Empty(); + RestorePreviousLayout(); return false; } SeasonStrates.Add(SeasonStrate); @@ -1611,6 +1630,9 @@ bool UVoxelStrateManager::GetRecipeForChunk( void UVoxelStrateManager::GeneratePassages() { VoxelForgeStartupTrace::FStageScope StartupTraceStage(TEXT("GeneratePassages")); + // Invalidate worker-local passage shortlists even when the new layout is empty. Returning + // before this bump would leave a nearby-passage TLS list pointing at the cleared array. + ++PassagesVersion; Passages.Empty(); if (StrateLayout.Num() < 1) @@ -2473,9 +2495,6 @@ void UVoxelStrateManager::GeneratePassages() } } - // Invalidate any thread_local per-chunk passage shortlists (see EvaluateModifierSDF). - ++PassagesVersion; - VoxelForgeStartupTrace::RecordEvent(TEXT("passages_ready"), FString::Printf( TEXT("\"passages\":%d,\"passages_version\":%u,\"upper_source_fit\":%d,\"lower_source_fit\":%d"), Passages.Num(), PassagesVersion, NumAimedAtUpperPlayerFit, NumAimedAtLowerPlayerFit)); @@ -5691,7 +5710,7 @@ FStrateGenerationParams UVoxelStrateManager::GetGenerationParams(const FIntVecto { // GRADIENT TRANSITION: Classic smooth lerp across the blend zone. // Alpha goes from 0 (at the outer edge of the zone) to 1 (right at boundary). - if (DistFromBottom < EffectiveBlend) + if (EffectiveBlend > 0 && DistFromBottom < EffectiveBlend) { FStrateGenerationParams BelowParams = BuildParamsFromDefinition(BelowSlot.Definition); BelowParams.StrateTopWorldZ = (float)(BelowSlot.TopChunkZ + 1) * CHUNK_SIZE; @@ -5709,6 +5728,8 @@ FStrateGenerationParams UVoxelStrateManager::GetGenerationParams(const FIntVecto case EVoxelStrateTransition::Interleaved: { + if (EffectiveBlend <= 0) { break; } + // INTERLEAVED TRANSITION: 3D noise warps the effective boundary Z. // // Instead of a flat boundary plane, the boundary becomes a wavy 3D surface. @@ -5788,7 +5809,7 @@ FStrateGenerationParams UVoxelStrateManager::GetGenerationParams(const FIntVecto case EVoxelStrateTransition::Gradient: { - if (DistFromTop < EffectiveBlend) + if (EffectiveBlend > 0 && DistFromTop < EffectiveBlend) { FStrateGenerationParams AboveParams = BuildParamsFromDefinition(AboveSlot.Definition); AboveParams.StrateTopWorldZ = (float)(AboveSlot.TopChunkZ + 1) * CHUNK_SIZE; @@ -5805,6 +5826,8 @@ FStrateGenerationParams UVoxelStrateManager::GetGenerationParams(const FIntVecto case EVoxelStrateTransition::Interleaved: { + if (EffectiveBlend <= 0) { break; } + const float WarpAmplitude = 2.0f; const int32 CheckRange = EffectiveBlend + FMath::CeilToInt(WarpAmplitude); diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index 21d328d..17096ff 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -231,6 +231,14 @@ public: if (World->ContentManager && !World->ContentManager->WaitForDecorationTasks(Deadline)) return; + // DensityVolume owns a separate FRunnableThread; the world task counter and decoration + // counter cannot observe it. Stop/join it before mutating the generator or manager. + if (World->DensityVolume) + { + World->DensityVolume->PauseForGenerationChange(); + bDensityVolumePaused = true; + } + bAcquired = true; } @@ -238,6 +246,10 @@ public: { if (World) { + if (bDensityVolumePaused && World->DensityVolume) + { + World->DensityVolume->ResumeAfterGenerationChange(); + } World->bGenerationPaused.store(false, std::memory_order_release); } } @@ -247,6 +259,7 @@ public: private: AVoxelWorld* World = nullptr; bool bAcquired = false; + bool bDensityVolumePaused = false; }; #if WITH_EDITOR @@ -561,6 +574,7 @@ void AVoxelWorld::RegenerateAllChunks() void AVoxelWorld::RebuildStrates() { bool bLayoutRebuilt = true; + bool bCreatedStrateManager = false; { FScopedGenerationPause Guard(this); if (!Guard.Acquired()) @@ -569,14 +583,73 @@ void AVoxelWorld::RebuildStrates() return; } + // A live Settings reference can change from no strata to a populated pool/season. Create + // the manager lazily in that case; Initialize itself is transactional, so an existing + // valid manager remains untouched when the new asset is invalid. + if (!StrateManager && Settings + && (!Settings->Season.IsNull() || Settings->StratePool.Num() > 0 + || Settings->FixedStrates.Num() > 0)) + { + StrateManager = NewObject(this); + bCreatedStrateManager = true; + } + if (StrateManager && Settings) { // Re-applies layout + inter-strate gap + passage/spine settings from VoxelSettings. bLayoutRebuilt = StrateManager->Initialize( Settings, Settings->GetEffectiveWorldSeed()); } - if (AtmosphereManager) AtmosphereManager->Reset(); - if (ContentManager) ContentManager->ClearAll(); + + if (!bLayoutRebuilt) + { + // A newly-created manager has no valid previous state to preserve. Do not leave the + // generator wired to a failed temporary object when this was the first live setup. + if (bCreatedStrateManager) { StrateManager = nullptr; } + } + else + { + // Rebind every consumer of the settings snapshot while generation is paused. The + // manager layout alone is not sufficient: world radius, edge sealing, seed, mesher + // policy, edit budget, decoration state, and the optional density volume all cache + // settings-derived values independently. + if (Generator) + { + Generator->InitializeSettings(Settings); + Generator->SetStrateManager(StrateManager); + Generator->SetDiffLayer(DiffLayer); + } + if (Mesher && Settings) + { + Mesher->bGenerateSkirts = Settings->bGenerateSkirts; + Mesher->SkirtCells = Settings->SkirtCells; + Mesher->LODOctaveDrop = Settings->LODOctaveDrop; + Mesher->bUseBlockEarlyOut = GVoxelForgeUseBlockEarlyOut != 0; + } + if (DiffLayer && Settings) + { + DiffLayer->SetBudget(Settings->MaxModifications, + Settings->MaxBrushRadius, + Settings->MaxTotalVolume); + } + if (ContentManager) + { + ContentManager->Initialize( + this, StrateManager, Generator, Settings, + Settings ? Settings->GetEffectiveWorldSeed() : 0); + ContentManager->ClearAll(); + } + if (Settings && Settings->bEnableDensityVolume && !DensityVolume) + { + DensityVolume = NewObject(this); + } + if (DensityVolume && Settings) + { + DensityVolume->Initialize(this, Generator, Settings); + DensityVolume->Reset(); + } + if (AtmosphereManager) AtmosphereManager->Reset(); + } } if (!bLayoutRebuilt) @@ -896,12 +969,21 @@ void AVoxelWorld::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedE { Super::PostEditChangeProperty(PropertyChangedEvent); - // During PIE with live edit on, regenerate when the actor's own properties change - // (e.g., Settings reference, bLiveEditStrates toggle, etc.) - // Data asset edits (strate definitions) are handled separately by OnObjectModifiedInEditor. + // During PIE with live edit on, a Settings reference change needs the full rebind path: the + // manager, generator, mesher, diff budget, decorations and density volume each own a snapshot. + // Data asset edits (and the settings asset itself) are handled separately by + // OnObjectModifiedInEditor. if (bLiveEditStrates && GetWorld() && GetWorld()->IsPlayInEditor()) { - RegenerateAllChunks(); + const FName ChangedProperty = PropertyChangedEvent.GetPropertyName(); + if (ChangedProperty == GET_MEMBER_NAME_CHECKED(AVoxelWorld, Settings)) + { + RebuildStrates(); + } + else + { + RegenerateAllChunks(); + } } } @@ -910,13 +992,20 @@ void AVoxelWorld::OnObjectModifiedInEditor(UObject* ModifiedObject) // Only react during PIE with live edit enabled if (!bLiveEditStrates || !GetWorld() || !GetWorld()->IsPlayInEditor()) return; - // Only care about strate definition and terrain op definition edits. - // (This delegate fires for EVERY UObject modification in the editor.) + // Only care about generation-owned assets. This delegate fires for every UObject modification + // in the editor. bool bIsRelevant = false; FString AssetName; - // Case 1: A strate definition was modified - if (UVoxelStrateDefinition* ModifiedStrate = Cast(ModifiedObject)) + // Case 1: the settings asset itself was modified + if (UVoxelSettings* ModifiedSettings = Cast(ModifiedObject)) + { + if (ModifiedSettings != Settings) return; + bIsRelevant = true; + AssetName = ModifiedSettings->GetName(); + } + // Case 2: A strate definition was modified + else if (UVoxelStrateDefinition* ModifiedStrate = Cast(ModifiedObject)) { if (!Settings) return; AssetName = ModifiedStrate->GetName(); @@ -935,7 +1024,7 @@ void AVoxelWorld::OnObjectModifiedInEditor(UObject* ModifiedObject) } } } - // Case 2: A terrain op definition was modified — check if any strate references it + // Case 3: A terrain op definition was modified — check if any strate references it else if (UVoxelTerrainOpDefinition* ModifiedOp = Cast(ModifiedObject)) { if (!Settings || !StrateManager) return; @@ -966,33 +1055,31 @@ void AVoxelWorld::OnObjectModifiedInEditor(UObject* ModifiedObject) } } } + // Case 4: a biome definition can change both terrain parameters and decoration/material + // resolution. Treat it like the owning strate definition, but only when this world references it. + else if (UVoxelBiomeDefinition* ModifiedBiome = Cast(ModifiedObject)) + { + if (!Settings || !StrateManager) return; + AssetName = ModifiedBiome->GetName(); + + for (const FStrateSlot& Slot : StrateManager->GetLayout()) + { + if (Slot.Definition && Slot.Definition->Biomes.Contains(ModifiedBiome)) + { + bIsRelevant = true; + break; + } + } + } if (!bIsRelevant) return; UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] Live edit: '%s' modified, regenerating..."), *AssetName); - // Re-initialize the strate manager so it picks up the changed definition values, - // then regenerate all chunks with the updated params. - { - FScopedGenerationPause Guard(this); - if (!Guard.Acquired()) - { - UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] OnObjectModifiedInEditor: generation pause timed out; no mutation applied.")); - return; - } - - if (StrateManager) - { - StrateManager->Initialize(Settings, Settings->GetEffectiveWorldSeed()); - } - if (Generator) - { - Generator->InitializeSettings(Settings); - } - } - - RegenerateAllChunks(); + // Use the same transactional full rebind as a Settings-reference change. In particular, do not + // ignore Initialize's return value: an invalid live asset must leave the previous field intact. + RebuildStrates(); } #endif @@ -1311,7 +1398,8 @@ void AVoxelWorld::BeginPlay() // Système de strates — a cooked season takes precedence; otherwise the authored pool path is // exactly the legacy one. - if (!Settings->Season.IsNull() || Settings->StratePool.Num() > 0) + if (!Settings->Season.IsNull() || Settings->StratePool.Num() > 0 + || Settings->FixedStrates.Num() > 0) { StrateManager = NewObject(this); if (!StrateManager->Initialize(Settings, Settings->GetEffectiveWorldSeed())) @@ -5550,13 +5638,14 @@ void AVoxelWorld::ApplyModification(const FVoxelModification& Modification) bSyncedCenter ? 1 : 0); } - // Remove decorations inside the modified volume so grass doesn't float over a dug hole (or bury under a - // fill). Instant + flicker-free (only the affected instances go); the placer already skips carved columns - // on any future rebuild. Center/Radius are in voxels → world cm. Box/capsule use their bounding sphere. + // Invalidate the complete decoration build state after a terrain edit. This removes live instances, + // abandons in-flight pre-edit marches, and forces the next stationary update to resample the edited + // field. Clearing all regions is deliberately conservative: FVoxelModification is actor-local voxel + // geometry and may be a box/capsule, while decoration transforms are world-space; a partial sphere + // conversion here would risk leaving floaters or allowing an old result to resurrect one. if (ContentManager && AffectedChunks.Num() > 0) { - const FVector WorldCenter = Modification.Center * VOXEL_SIZE; // Center was WorldPos/VOXEL_SIZE - ContentManager->RemoveDecorationsInSphere(WorldCenter, Modification.Radius * VOXEL_SIZE); + ContentManager->InvalidateDecorationBuilds(); } } diff --git a/Source/VoxelForge/Public/VoxelBiomeTypes.h b/Source/VoxelForge/Public/VoxelBiomeTypes.h index 772fb6d..275efa8 100644 --- a/Source/VoxelForge/Public/VoxelBiomeTypes.h +++ b/Source/VoxelForge/Public/VoxelBiomeTypes.h @@ -217,7 +217,7 @@ struct FBiomeSample * done ONCE here, into a small grid covering the chunk footprint + margin; per voxel * the resolver then only warps + does a cheap 3x3 lookup + blend. * - * VALIDITY IS A WORLD-XY BOX (+ ChunkZ + Seed), NOT a chunk key (CODEMAP §8.10). The + * VALIDITY IS A WORLD-XY BOX (+ ChunkZ + Seed + owner identities), NOT a chunk key (CODEMAP §8.10). The * grid covers a halo beyond the chunk, so gradient-normal samples and the +X/+Y chunk * boundary corners stay inside the valid box and DO NOT thrash the noise-heavy rebuild. * The box logic is identical in spirit to the SDF cache in GetDensityWithParams. @@ -229,6 +229,8 @@ struct FChunkBiomeCache float ValidMinY = 0.0f, ValidMaxY = 0.0f; int32 ChunkZ = MIN_int32; // which strate slice this was built for int32 Seed = MIN_int32; + uint64 OwnerId = 0; // generator/world identity + uint64 ManagerLifetimeId = 0; // strate-manager lifetime identity bool bActive = false; // does this strate have biomes? FBiomeContext Ctx; // resolved biomes + map (for blending) @@ -237,9 +239,11 @@ struct FChunkBiomeCache int32 BaseCellX = 0, BaseCellY = 0, CellsX = 0, CellsY = 0; TArray CellBiome; - bool Contains(float X, float Y, int32 InChunkZ, int32 InSeed) const + bool Contains(float X, float Y, int32 InChunkZ, int32 InSeed, + uint64 InOwnerId, uint64 InManagerLifetimeId) const { return InSeed == Seed && InChunkZ == ChunkZ + && InOwnerId == OwnerId && InManagerLifetimeId == ManagerLifetimeId && X >= ValidMinX && X <= ValidMaxX && Y >= ValidMinY && Y <= ValidMaxY; } @@ -254,5 +258,7 @@ struct FChunkBiomeCache { ValidMinX = 1.0f; ValidMaxX = -1.0f; // min > max ⇒ Contains() is false everywhere ChunkZ = MIN_int32; + OwnerId = 0; + ManagerLifetimeId = 0; } }; diff --git a/Source/VoxelForge/Public/VoxelContentManager.h b/Source/VoxelForge/Public/VoxelContentManager.h index 613c8c1..b9185a8 100644 --- a/Source/VoxelForge/Public/VoxelContentManager.h +++ b/Source/VoxelForge/Public/VoxelContentManager.h @@ -120,6 +120,11 @@ public: * future rebuild — this patches the LIVE instances. Returns how many were removed. Game-thread. */ int32 RemoveDecorationsInSphere(const FVector& WorldCenter, float WorldRadius); + /** Invalidate decoration regions after a terrain edit. Existing regions and in-flight builds are + * discarded together, so an old worker result cannot resurrect pre-edit content; the next update + * rebuilds the desired cells from the edited density field. Game-thread. */ + void InvalidateDecorationBuilds(); + /** Destroy all spawned content (decorations + water). Regenerate / season reset. Bumps the deco * epoch so any in-flight march tasks' results are discarded. */ void ClearAll(); @@ -179,6 +184,9 @@ private: FPlacementProfile Profile; // representative profile (mesh + HISM render tuning: cull/shadow) — // may be a decoration entry's OR a companion's (F7). TArray Xforms; + int32 SourceEntryIdx = INT32_MAX; + int32 SourceCompanionIdx = INT32_MAX; + int32 SourceSubIdx = INT32_MAX; }; // A non-instanced actor placement, spawned when the region is applied. struct FRegionActorSpawn @@ -287,6 +295,7 @@ private: void ClearAllDecorations(); void DrainDecoResults(); // discard every queued march result static void ResetGridBuildState(FDecoGrid& G); // drop builds/queues (loaded regions untouched) + void JoinDecorationTasks(); // real per-manager lifetime join // Region size in cells, clamped (>=1). Cell↔region math lives in file-static helpers in the .cpp. int32 RegionSize() const; @@ -353,10 +362,18 @@ private: // Set in BeginDestroy; worker tasks check it before touching us. std::atomic bShuttingDown{false}; + // Per-manager task count. A process-global count would make one world's shutdown wait on + // another world's decoration work and cannot prove this manager's UObject is safe to tear down. + std::atomic ActiveDecorationTasks{0}; // Streaming state. INT_MIN sentinels force a full rebuild on the first update / after ClearAll. FIntPoint LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN); int32 LastStrateIndex = INT32_MIN; + int32 LastNearRadius = INT32_MIN; + int32 LastFarRadius = INT32_MIN; + int32 LastNearSpacing = INT32_MIN; + int32 LastFarSpacing = INT32_MIN; + int32 LastRegionSize = INT32_MIN; // Shared strate context for the current update (recomputed each UpdateDecorations; the launch step // copies the PODs into each task). Same for both grids — a strate is a horizontal slab. diff --git a/Source/VoxelForge/Public/VoxelDensityOp.h b/Source/VoxelForge/Public/VoxelDensityOp.h index 02a5f18..ed94c74 100644 --- a/Source/VoxelForge/Public/VoxelDensityOp.h +++ b/Source/VoxelForge/Public/VoxelDensityOp.h @@ -1,6 +1,7 @@ // VoxelDensityOp.h // LE CONTRAT de la pile d'opérateurs de densité / THE density operator stack CONTRACT. -// Phase 1 de OPSTACK-PLAN.md. HEADER SEUL — rien n'est encore branché dans GetDensityAt. +// Phase 1 de OPSTACK-PLAN.md. The contract is implemented by the opt-in stack path in +// UVoxelGenerator::GetDensityAt; the legacy archetype switch remains for non-opt-in strates. // // ───────────────────────────────────────────────────────────────────────────────────────── // POURQUOI / WHY diff --git a/Source/VoxelForge/Public/VoxelDensityVolume.h b/Source/VoxelForge/Public/VoxelDensityVolume.h index 6167c79..f410edb 100644 --- a/Source/VoxelForge/Public/VoxelDensityVolume.h +++ b/Source/VoxelForge/Public/VoxelDensityVolume.h @@ -70,6 +70,12 @@ public: * the player hasn't crossed a level-0 cell boundary and nothing is dirty. */ void Update(const FVector& PlayerWorldPos); + /** Stop/join the dedicated fill worker before a generation/layout mutation. */ + void PauseForGenerationChange(); + + /** Allow the dedicated fill worker to restart lazily on the next Update. */ + void ResumeAfterGenerationChange(); + /** A carve/fill touched this VOXEL box (inclusive, voxel coords) → refill the overlapping * clipmap cells next Update. GetDensityAt already includes the diff layer, so re-sampling * picks the edit up. Cheap + local. */ @@ -217,6 +223,7 @@ private: FRunnableThread* FillThread = nullptr; FEvent* FillWakeEvent = nullptr; std::atomic bFillThreadStop{ false }; + std::atomic bGenerationPaused{ false }; // CAPTURE-DURING-MESHING (level-0 only): tile coord → CHUNK_SIZE³ R8 captured density. Populated by // IngestTileCapture (free — the mesher already sampled it), consumed by RecenterLevel(0) to fill diff --git a/Source/VoxelForge/Public/VoxelDiffLayer.h b/Source/VoxelForge/Public/VoxelDiffLayer.h index 5e075db..21bb834 100644 --- a/Source/VoxelForge/Public/VoxelDiffLayer.h +++ b/Source/VoxelForge/Public/VoxelDiffLayer.h @@ -61,7 +61,8 @@ struct VOXELFORGE_API FVoxelModification { GENERATED_BODY() - // World-space center of the brush (endpoint A for Capsule). + // Actor-local voxel-space center of the brush (endpoint A for Capsule). The AVoxelWorld helper + // converts world centimetres to this space before calling the diff layer. UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification") FVector Center = FVector::ZeroVector; @@ -83,7 +84,7 @@ struct VOXELFORGE_API FVoxelModification meta = (EditCondition = "Shape == EVoxelBrushShape::Box")) FVector BoxExtent = FVector(5.0f, 5.0f, 5.0f); - // Capsule second endpoint in world voxel coords (Capsule shape only). + // Capsule second endpoint in actor-local voxel coords (Capsule shape only). UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification", meta = (EditCondition = "Shape == EVoxelBrushShape::Capsule")) FVector CapsuleEnd = FVector::ZeroVector; @@ -93,7 +94,7 @@ struct VOXELFORGE_API FVoxelModification UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification", meta = (ClampMin = "0.1")) float Falloff = 2.0f; - // Conservative world-space AABB the brush can affect (used to find overlapping chunks). + // Conservative voxel-space AABB the brush can affect (used to find overlapping chunks). void GetWorldBounds(FVector& OutMin, FVector& OutMax) const { switch (Shape) @@ -138,6 +139,9 @@ class VOXELFORGE_API UVoxelDiffLayer : public UObject GENERATED_BODY() public: + /** Process-unique identity for worker-local modification snapshots. */ + uint64 GetCacheLifetimeId() const; + //========================================================================= // BUDGET CONFIGURATION //========================================================================= @@ -162,6 +166,10 @@ public: */ bool CanModify(float Radius) const; + /** Shape-aware budget check used by ApplyModification. Boxes charge their affected outer box and + * capsules charge their full swept volume, so a long tunnel cannot be billed as a small sphere. */ + bool CanModify(const FVoxelModification& Mod) const; + /** * Get remaining modification count (how many more operations the player can do). * Returns -1 if unlimited. @@ -183,7 +191,7 @@ public: /** * Apply a carve or fill operation. * - * Stores the modification in every chunk the brush sphere overlaps. + * Stores the modification in every chunk the brush volume overlaps. * Returns the list of affected chunk coordinates so the world knows * which chunks need re-meshing. * @@ -245,8 +253,8 @@ public: /** True si un chunk modifié intersecte [MinChunk, MaxChunk] (inclusif). Conservatif par * construction : ApplyModification enregistre le mod dans TOUS les chunks que son rayon - * touche, donc le test par clé de chunk suffit. Une passe de lecture sur les clés (les - * mondes édités ont peu de chunks modifiés) — utilisé par ClassifyTile, PAS par voxel. */ + * touche, donc le test par clé de chunk suffit. La recherche parcourt directement les clés de + * la plage demandée, pas tout l'historique des chunks — utilisé par ClassifyTile, PAS par voxel. */ bool HasAnyModInChunkRange(const FIntVector& MinChunk, const FIntVector& MaxChunk) const; /** Evaluate a mod list at a voxel — the lock-free core shared by GetDensityOffset and the @@ -269,6 +277,10 @@ public: int32 GetModifiedChunkCount() const; private: + // Never reused for the lifetime of the process: a recycled UObject address must not make a + // worker-local snapshot from another diff layer look current when its chunk/version match. + mutable std::atomic CacheLifetimeId{0}; + //========================================================================= // STORAGE //========================================================================= diff --git a/Source/VoxelForge/Public/VoxelGenerator.h b/Source/VoxelForge/Public/VoxelGenerator.h index 9aaf15e..9d0b9f7 100644 --- a/Source/VoxelForge/Public/VoxelGenerator.h +++ b/Source/VoxelForge/Public/VoxelGenerator.h @@ -465,7 +465,8 @@ private: /** (Re)build the per-chunk biome cell grid covering chunk (X,Y) footprint + margin. */ void RebuildBiomeGrid(int32 ChunkX, int32 ChunkY, int32 ChunkZ, - const FBiomeContext& Ctx, FChunkBiomeCache& Cache) const; + const FBiomeContext& Ctx, FChunkBiomeCache& Cache, + uint64 OwnerId, uint64 ManagerLifetimeId) const; }; namespace VoxelGenLOD