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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaFdXhTdYMkW1xrV6ApT6Y
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-09-15 00:29:46 +02:00
1 parent 39c306b524
commit 8879045627
14 files changed
+1365 -139

No files matched your search

@@ -406,16 +406,53 @@ bool FVoxelForgeComposerSeasonTest::RunTest(const FString& Parameters)
TestTrue(TEXT("legacy manager still initializes with Season unset"), TestTrue(TEXT("legacy manager still initializes with Season unset"),
LegacyManager->Initialize(LegacySettings.Get(), LegacySettings->Seed)); LegacyManager->Initialize(LegacySettings.Get(), LegacySettings->Seed));
TestTrue(TEXT("season unset remains the authored-pool path"), TestTrue(TEXT("season unset remains the authored-pool path"),
LegacySettings->Season.IsNull() LegacySettings->Season.IsNull()
&& LegacyManager->GetNumStrates() == 1 && LegacyManager->GetNumStrates() == 1
&& LegacyManager->GetLayout()[0].Definition == LegacyDefinition.Get() && LegacyManager->GetLayout()[0].Definition == LegacyDefinition.Get()
&& LegacyManager->GetLayout()[0].HeightInChunks == 7 && LegacyManager->GetLayout()[0].HeightInChunks == 7
&& LegacyManager->GetWorldSeed() == LegacySettings->Seed && LegacyManager->GetWorldSeed() == LegacySettings->Seed
&& LegacySettings->GetEffectiveWorldSeed() == LegacySettings->Seed && LegacySettings->GetEffectiveWorldSeed() == LegacySettings->Seed
&& LegacySettings->GetEffectiveOriginSpineRadius() && LegacySettings->GetEffectiveOriginSpineRadius()
== LegacySettings->OriginSpineRadius == LegacySettings->OriginSpineRadius
&& LegacySettings->GetEffectiveWorldRadiusVoxels() && LegacySettings->GetEffectiveWorldRadiusVoxels()
== LegacySettings->WorldRadiusVoxels); == 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<UVoxelSeasonAsset> BrokenSeason(
NewObject<UVoxelSeasonAsset>(GetTransientPackage(), NAME_None, RF_Transient));
FString BrokenReport;
TestTrue(TEXT("broken test season keeps a valid content hash"),
BrokenSeason->SetManifestJson(BrokenManifest.SerializedJson, BrokenReport));
TStrongObjectPtr<UVoxelStrateManager> TransactionalManager(
NewObject<UVoxelStrateManager>(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<UVoxelSeasonAsset>(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<UVoxelSeasonAsset>(SeasonAsset.Get());
}
return true; return true;
} }
@@ -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<int32> StepGuard(VoxelGenLOD::SampleStep, Step);
TGuardValue<FIntVector> OriginGuard(
VoxelGenLOD::TileOriginVoxels, FIntVector::ZeroValue);
TGuardValue<int32> 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<float>(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<int32>(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<float> CoarseContext;
TArray<float> 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<int32> SampleZCopy;
for (const int32 Z : SampleZ) { SampleZCopy.Add(Z); }
TFuture<TArray<float>> FreshFuture = Async(EAsyncExecution::Thread,
[Generator, SampleZCopy = MoveTemp(SampleZCopy)]() mutable
{
TArray<float> Result;
Result.Reserve(SampleZCopy.Num());
for (const int32 Z : SampleZCopy)
{
Result.Add(Generator->GetDensityAt(0.0f, 0.0f, static_cast<float>(Z)));
}
return Result;
});
const TArray<float> 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<UVoxelMarchingCubesMesher> WarmMesher(
NewObject<UVoxelMarchingCubesMesher>(GetTransientPackage(), NAME_None, RF_Transient));
TStrongObjectPtr<UVoxelMarchingCubesMesher> FreshMesher(
NewObject<UVoxelMarchingCubesMesher>(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<float> 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<const uint32*>(&Carved), Filled,
*reinterpret_cast<const uint32*>(&Filled), FreshFilled,
*reinterpret_cast<const uint32*>(&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<float>(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<float>(Index) * 23.0f;
const float Y = -260.0f + static_cast<float>(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<float>(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<float> 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<UVoxelBiomeDefinition> BiomeA(
NewObject<UVoxelBiomeDefinition>(GetTransientPackage(), NAME_None, RF_Transient));
TStrongObjectPtr<UVoxelBiomeDefinition> BiomeB(
NewObject<UVoxelBiomeDefinition>(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<float>(SurfTop),
PaletteA, PaletteAN, BlendA);
B.Generator->GetBiomeMaterialAt(FirstX, FirstY, static_cast<float>(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<UVoxelDiffLayer> Diff(
NewObject<UVoxelDiffLayer>(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<FIntVector> 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<EVoxelBrushShape>(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<UVoxelDiffLayer> ShapeBudget(
NewObject<UVoxelDiffLayer>(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
+137 -23
View File
@@ -31,15 +31,31 @@ static constexpr int32 GMaxCompanionsPerParent = 256;
// decoration's per-cell MaxPerChunk keeps its "per chunk" meaning). // decoration's per-cell MaxPerChunk keeps its "per chunk" meaning).
static constexpr int32 DECO_CELL_VOXELS = CHUNK_SIZE; 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<int32> GActiveDecoTasks{0};
static FORCEINLINE int32 CellChebyshev(const FIntPoint& A, const FIntPoint& B) 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)); 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, void UVoxelContentManager::Initialize(AActor* InOwner, UVoxelStrateManager* InStrateManager,
UVoxelGenerator* InGenerator, UVoxelSettings* InSettings, int32 InSeed) 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. // Backstop: stop any worker from touching us. EndPlay → NotifyShutdown should have already drained.
bShuttingDown.store(true, std::memory_order_release); bShuttingDown.store(true, std::memory_order_release);
JoinDecorationTasks();
Super::BeginDestroy(); Super::BeginDestroy();
} }
@@ -66,9 +83,9 @@ void UVoxelContentManager::NotifyShutdown()
{ {
bShuttingDown.store(true, std::memory_order_release); 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. // A timeout is safe for the editor's pre-mutation probe, but not for UObject destruction:
const double Deadline = FPlatformTime::Seconds() + 3.0; // join this manager's tasks fully before dropping its worker-captured `this`.
WaitForDecorationTasks(Deadline); JoinDecorationTasks();
DrainDecoResults(); DrainDecoResults();
ResetGridBuildState(NearGrid); ResetGridBuildState(NearGrid);
@@ -77,7 +94,7 @@ void UVoxelContentManager::NotifyShutdown()
bool UVoxelContentManager::WaitForDecorationTasks(double Deadline) 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; if (FPlatformTime::Seconds() > Deadline) return false;
FPlatformProcess::Yield(); FPlatformProcess::Yield();
@@ -85,6 +102,14 @@ bool UVoxelContentManager::WaitForDecorationTasks(double Deadline)
return true; return true;
} }
void UVoxelContentManager::JoinDecorationTasks()
{
while (ActiveDecorationTasks.load(std::memory_order_acquire) > 0)
{
FPlatformProcess::Yield();
}
}
void UVoxelContentManager::DrainDecoResults() void UVoxelContentManager::DrainDecoResults()
{ {
FDecoCellResult Discard; 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 // 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. // 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.Tier = EDecoStreamTier::Near;
NearGrid.Radius = FMath::Max(1, Settings->DecorationNearRadiusChunks); NearGrid.Radius = NewNearRadius;
NearGrid.Spacing = FMath::Clamp(Settings->DecorationSpacingVoxels, 1, CHUNK_SIZE); NearGrid.Spacing = NewNearSpacing;
FarGrid.Tier = EDecoStreamTier::Far; FarGrid.Tier = EDecoStreamTier::Far;
FarGrid.Radius = FMath::Max(1, Settings->DecorationRadiusChunks); FarGrid.Radius = NewFarRadius;
FarGrid.Spacing = FMath::Clamp(Settings->DecorationFarSpacingVoxels, 1, CHUNK_SIZE); FarGrid.Spacing = NewFarSpacing;
// Build the decoration palette ONCE for this update, PARTITIONED by tier. With biomes, concatenate every // 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 // 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). // Strate or grid configuration change → wipe + force a full rebuild. This also invalidates
if (StrateIndex != LastStrateIndex) // every old region build before the next stationary-player update can reuse it.
if (StrateIndex != LastStrateIndex || bDecorationGridConfigChanged)
{ {
ClearAllDecorations(); ClearAllDecorations();
LastStrateIndex = StrateIndex; LastStrateIndex = StrateIndex;
LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN); LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN);
} }
LastNearRadius = NewNearRadius;
LastFarRadius = NewFarRadius;
LastNearSpacing = NewNearSpacing;
LastFarSpacing = NewFarSpacing;
LastRegionSize = NewRegionSize;
if (PlayerCell != LastDecoCell) if (PlayerCell != LastDecoCell)
{ {
@@ -295,11 +338,22 @@ void UVoxelContentManager::RebuildDesiredCells(FDecoGrid& G, const FIntPoint& Pl
} }
} }
TArray<FIntPoint> 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 // 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 // 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 // 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). // 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.Regions.Contains(Region)) continue; // already applied → leave it (no re-stream)
if (G.Builds.Contains(Region)) continue; // already marching its cells 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. // 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) 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; UVoxelGenerator* Gen = Generator;
G.InFlightCells.Add(Cell); 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"), UE::Tasks::Launch(TEXT("DecoMarch"),
[this, Gen, OwnerXf, Cell, Ctx, LocalSeed, Spacing, Step, MaxCross, ColDepth, BuildId, GridTier, [this, Gen, OwnerXf, Cell, Ctx, LocalSeed, Spacing, Step, MaxCross, ColDepth, BuildId, GridTier,
Entries = MoveTemp(EntriesCopy), EntryBiome = MoveTemp(EntryBiomeCopy)]() mutable 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; if (bShuttingDown.load(std::memory_order_relaxed)) return;
@@ -788,6 +849,10 @@ void UVoxelContentManager::ProcessDecoResults(const FIntPoint& PlayerCell)
for (FDecoGrid* GP : { &NearGrid, &FarGrid }) for (FDecoGrid* GP : { &NearGrid, &FarGrid })
{ {
FDecoGrid& G = *GP; 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) while (G.Completed.Num() > 0 && Applied < Budget)
{ {
const FIntPoint Region = G.Completed[0]; const FIntPoint Region = G.Completed[0];
@@ -846,9 +911,21 @@ void UVoxelContentManager::MergeCellResult(FDecoGrid& G, const FDecoCellResult&
{ {
if (!Prof.InstancedMesh) continue; if (!Prof.InstancedMesh) continue;
// Bucket by MESH so cells (and biomes) sharing a mesh collapse into one region HISM. The first // 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); 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); Bucket.Xforms.Add(S.Xf);
} }
else if (Prof.ActorClass) else if (Prof.ActorClass)
@@ -893,6 +970,15 @@ void UVoxelContentManager::ApplyRegion(FDecoGrid& G, const FIntPoint& Region, FD
FDecoRegionContent& Content = G.Regions.Add(Region); 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 // 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. // (the march is strate-bounded), so their lights are always legitimately visible — no extra culling.
for (const FRegionActorSpawn& A : Build.ActorSpawns) 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 // 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. // collapse to a handful per region, so InitViews walks far fewer primitives every frame.
for (TPair<TWeakObjectPtr<UStaticMesh>, FRegionMeshBucket>& Pair : Build.MeshBuckets) TArray<TWeakObjectPtr<UStaticMesh>> MeshKeys;
Build.MeshBuckets.GetKeys(MeshKeys);
MeshKeys.Sort([](const TWeakObjectPtr<UStaticMesh>& A, const TWeakObjectPtr<UStaticMesh>& B)
{ {
FRegionMeshBucket& Bucket = Pair.Value; const UStaticMesh* AMesh = A.Get();
UStaticMesh* Mesh = Pair.Key.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<UStaticMesh>& 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; if (!Mesh || Bucket.Xforms.Num() == 0) continue;
const FPlacementProfile& Prof = Bucket.Profile; const FPlacementProfile& Prof = Bucket.Profile;
Bucket.Xforms.Sort(VF_DecoTransformLess);
UHierarchicalInstancedStaticMeshComponent* HISM = UHierarchicalInstancedStaticMeshComponent* HISM =
NewObject<UHierarchicalInstancedStaticMeshComponent>(OwnerActor); NewObject<UHierarchicalInstancedStaticMeshComponent>(OwnerActor);
HISM->SetStaticMesh(Mesh); 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 // 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. // gone from the grids' Builds, so any straggler result is discarded on merge; new builds get fresh BuildIds.
DrainDecoResults(); 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.bHasWater = (Wv != -FLT_MAX);
Ctx.WaterLocalZ = Ctx.bHasWater ? Wv * VOXEL_SIZE : -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 Step = (float)FMath::Max(1, Settings->DecorationMarchStepVoxels);
const float ColDepth = (float)FMath::Max(8, Settings->DecorationColumnDepthVoxels); const float ColDepth = (float)FMath::Max(8, Settings->DecorationColumnDepthVoxels);
@@ -71,6 +71,7 @@ void UVoxelDensityVolume::Initialize(AActor* InOwner, UVoxelGenerator* InGenerat
Generator = InGenerator; Generator = InGenerator;
Settings = InSettings; Settings = InSettings;
bShuttingDown.store(false, std::memory_order_relaxed); bShuttingDown.store(false, std::memory_order_relaxed);
bGenerationPaused.store(false, std::memory_order_relaxed);
bInitialized = true; bInitialized = true;
// Arrays are allocated lazily on the first Update (EnsureAllocated) so a settings change // Arrays are allocated lazily on the first Update (EnsureAllocated) so a settings change
// (resolution / level count) before play picks up cleanly. // (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. // Backstop — EndPlay → NotifyShutdown should already have stopped the fill thread.
bShuttingDown.store(true, std::memory_order_release); bShuttingDown.store(true, std::memory_order_release);
bGenerationPaused.store(true, std::memory_order_release);
StopFillThread(); StopFillThread();
Super::BeginDestroy(); Super::BeginDestroy();
} }
@@ -87,6 +89,7 @@ void UVoxelDensityVolume::BeginDestroy()
void UVoxelDensityVolume::NotifyShutdown() void UVoxelDensityVolume::NotifyShutdown()
{ {
bShuttingDown.store(true, std::memory_order_release); 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 // 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. // 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() 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. // Bump the epoch so any in-flight fill lands stale and is dropped in DrainResults.
++VolumeEpoch; ++VolumeEpoch;
PendingFills.Reset(); PendingFills.Reset();
@@ -118,6 +125,20 @@ void UVoxelDensityVolume::Reset()
LastPlayerVoxel = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); 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 // Helpers
//============================================================================= //=============================================================================
@@ -284,7 +305,8 @@ bool UVoxelDensityVolume::GetLevelShaderParams(int32 Level, FIntVector& OutOrigi
void UVoxelDensityVolume::Update(const FVector& PlayerWorldPos) 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(); AActor* O = Owner.Get();
if (!O) return; if (!O) return;
@@ -557,6 +579,8 @@ void UVoxelDensityVolume::IngestTileCapture(const FIntVector& L0TileCoord, TArra
void UVoxelDensityVolume::EnsureFillThread() void UVoxelDensityVolume::EnsureFillThread()
{ {
if (FillThread) return; if (FillThread) return;
if (bGenerationPaused.load(std::memory_order_acquire)
|| bShuttingDown.load(std::memory_order_acquire)) return;
if (!Settings || !Settings->bEnableDensityVolume) return; if (!Settings || !Settings->bEnableDensityVolume) return;
bFillThreadStop.store(false, std::memory_order_release); bFillThreadStop.store(false, std::memory_order_release);
if (!FillWakeEvent) { FillWakeEvent = FPlatformProcess::GetSynchEventFromPool(false); } // auto-reset if (!FillWakeEvent) { FillWakeEvent = FPlatformProcess::GetSynchEventFromPool(false); } // auto-reset
@@ -588,6 +612,8 @@ void UVoxelDensityVolume::StopFillThread()
void UVoxelDensityVolume::LaunchPendingFills() void UVoxelDensityVolume::LaunchPendingFills()
{ {
if (PendingFills.Num() == 0) return; if (PendingFills.Num() == 0) return;
if (bGenerationPaused.load(std::memory_order_acquire)
|| bShuttingDown.load(std::memory_order_acquire)) return;
EnsureFillThread(); EnsureFillThread();
for (FPendingFill& F : PendingFills) for (FPendingFill& F : PendingFills)
{ {
+205 -35
View File
@@ -3,6 +3,98 @@
#include "VoxelDiffLayer.h" #include "VoxelDiffLayer.h"
namespace
{
std::atomic<uint64> 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<double>(Mod.BoxExtent.X) + static_cast<double>(Mod.Falloff);
const double Ey = static_cast<double>(Mod.BoxExtent.Y) + static_cast<double>(Mod.Falloff);
const double Ez = static_cast<double>(Mod.BoxExtent.Z) + static_cast<double>(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<double>(Mod.Radius) + static_cast<double>(Mod.Falloff);
const double SegmentLength = static_cast<double>(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<double>(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 // BUDGET CONFIGURATION
//============================================================================= //=============================================================================
@@ -19,8 +111,13 @@ void UVoxelDiffLayer::SetBudget(int32 InMaxMods, float InMaxRadius, float InMaxV
bool UVoxelDiffLayer::CanModify(float Radius) const 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) // Check radius limit (always enforced, even if "unlimited" count/volume)
if (Radius > BudgetMaxRadius) if (BudgetMaxRadius > 0.0f && Radius > BudgetMaxRadius)
{ {
return false; return false;
} }
@@ -34,8 +131,12 @@ bool UVoxelDiffLayer::CanModify(float Radius) const
// Check volume limit: would this brush push us over? // Check volume limit: would this brush push us over?
if (BudgetMaxVolume > 0.0f) if (BudgetMaxVolume > 0.0f)
{ {
const float BrushVolume = (4.0f / 3.0f) * PI * Radius * Radius * Radius; const double BrushVolume = (4.0 / 3.0) * PI
if (AccumulatedVolume + BrushVolume > BudgetMaxVolume) * static_cast<double>(Radius) * static_cast<double>(Radius) * static_cast<double>(Radius);
const double NewTotal = static_cast<double>(AccumulatedVolume) + BrushVolume;
if (!VoxelMath::IsFinite(BrushVolume) || BrushVolume > static_cast<double>(FLT_MAX)
|| !VoxelMath::IsFinite(NewTotal)
|| NewTotal > static_cast<double>(BudgetMaxVolume))
{ {
return false; return false;
} }
@@ -44,6 +145,27 @@ bool UVoxelDiffLayer::CanModify(float Radius) const
return true; 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<double>(AccumulatedVolume) + BrushVolume;
if (!VoxelMath::IsFinite(BrushVolume) || BrushVolume > static_cast<double>(FLT_MAX)
|| !VoxelMath::IsFinite(NewTotal)) return false;
if (BudgetMaxVolume > 0.0f && NewTotal > static_cast<double>(BudgetMaxVolume)) return false;
return true;
}
int32 UVoxelDiffLayer::GetRemainingModifications() const int32 UVoxelDiffLayer::GetRemainingModifications() const
{ {
if (BudgetMaxMods <= 0) return -1; // Unlimited if (BudgetMaxMods <= 0) return -1; // Unlimited
@@ -64,11 +186,25 @@ TArray<FIntVector> UVoxelDiffLayer::ApplyModification(const FVoxelModification&
{ {
TArray<FIntVector> AffectedChunks; TArray<FIntVector> AffectedChunks;
// --- Budget enforcement --- // Validate the complete geometry before radius clamping, budget accounting, or AABB math.
// Clamp radius to max allowed // FMath::Min(NaN, limit) and the shape switch's default sphere path would otherwise turn
float ClampedRadius = FMath::Min(Mod.Radius, BudgetMaxRadius); // 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, UE_LOG(LogTemp, Warning,
TEXT("[DiffLayer] Modification REJECTED — budget exceeded (count=%d/%d, volume=%.0f/%.0f)"), TEXT("[DiffLayer] Modification REJECTED — budget exceeded (count=%d/%d, volume=%.0f/%.0f)"),
@@ -76,34 +212,53 @@ TArray<FIntVector> UVoxelDiffLayer::ApplyModification(const FVoxelModification&
return AffectedChunks; // Empty = rejected return AffectedChunks; // Empty = rejected
} }
// Use clamped radius for the actual modification // Complete the world-AABB calculation before mutating the budget counters. Finite input can
FVoxelModification ClampedMod = Mod; // still overflow during Center +/- extent or address a chunk outside FIntVector's domain;
ClampedMod.Radius = ClampedRadius; // 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<double>(WorldCoord) / static_cast<double>(CHUNK_SIZE));
if (!VoxelMath::IsFinite(ChunkCoord)
|| ChunkCoord < static_cast<double>(MIN_int32)
|| ChunkCoord > static_cast<double>(MAX_int32))
{
return false;
}
OutChunk = static_cast<int32>(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<FIntVector>();
}
// Update budget counters // Update budget counters
ModificationCount++; ModificationCount++;
const float BrushVolume = (4.0f / 3.0f) * PI * ClampedRadius * ClampedRadius * ClampedRadius; const double BrushVolume = GetModificationBudgetVolume(ClampedMod);
AccumulatedVolume += BrushVolume; AccumulatedVolume += static_cast<float>(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);
{ {
// Write lock: blocks worker-thread readers (HasModifications / GetDensityOffset) while the map // Write lock: blocks worker-thread readers (HasModifications / GetDensityOffset) while the map
// is mutated/rehashed. AffectedChunks is local; populate it in the same pass. // is mutated/rehashed. AffectedChunks is local; populate it in the same pass.
FWriteScopeLock Lock(ModsLock); 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); FIntVector ChunkCoord(CX, CY, CZ);
@@ -112,8 +267,12 @@ TArray<FIntVector> UVoxelDiffLayer::ApplyModification(const FVoxelModification&
// Track which chunks need re-meshing // Track which chunks need re-meshing
AffectedChunks.Add(ChunkCoord); 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 // 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. // 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; 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 if (MinChunk.X > MaxChunk.X || MinChunk.Y > MaxChunk.Y || MinChunk.Z > MaxChunk.Z)
// chunk its radius overlaps (ApplyModification), so key-in-range ⟺ the mod can reach the range.
FReadScopeLock Lock(ModsLock);
for (const auto& Pair : ChunkMods)
{ {
const FIntVector& C = Pair.Key; return false;
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 && // ApplyModification registers a mod in every chunk its AABB overlaps, so looking up the keys in
Pair.Value.Num() > 0) // 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<FVoxelModification>* 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; return false;
} }
+92 -19
View File
@@ -280,6 +280,8 @@ struct FSurfaceColumnBox
static constexpr int32 Dim = 2 * Halo + 1; static constexpr int32 Dim = 2 * Halo + 1;
int32 BaseX = 0, BaseY = 0; // box origin (voxel coords) int32 BaseX = 0, BaseY = 0; // box origin (voxel coords)
int32 StrateKey = MIN_int32, Seed = MIN_int32; // key: same strate ⇒ identical heightfield params 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 // 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 // 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 // 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]; FSurfaceColumnBox Boxes[NumBoxes];
uint32 Clock = 0; 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. // 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; ++Clock;
for (FSurfaceColumnBox& B : Boxes) 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 && B.LayoutVersion == InLayoutVersion
&& IX >= B.BaseX && IX < B.BaseX + FSurfaceColumnBox::Dim && IX >= B.BaseX && IX < B.BaseX + FSurfaceColumnBox::Dim
&& IY >= B.BaseY && IY < B.BaseY + FSurfaceColumnBox::Dim) && IY >= B.BaseY && IY < B.BaseY + FSurfaceColumnBox::Dim)
@@ -324,6 +329,8 @@ struct FSurfaceColumnCache
Victim->BaseY = IY - FSurfaceColumnBox::Halo; Victim->BaseY = IY - FSurfaceColumnBox::Halo;
Victim->StrateKey = InStrateKey; Victim->StrateKey = InStrateKey;
Victim->Seed = InSeed; Victim->Seed = InSeed;
Victim->OwnerId = InOwnerId;
Victim->ManagerLifetimeId = InManagerLifetimeId;
Victim->LayoutVersion = InLayoutVersion; Victim->LayoutVersion = InLayoutVersion;
Victim->bValid = true; Victim->bValid = true;
Victim->LastUse = Clock; 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: // 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." // "I tweaked the strate asset, regenerated, and one patch kept the old shape."
thread_local uint32 CP_Version = 0xFFFFFFFFu; 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 // 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 // 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é. // 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_TunnelFP = CachedEntry.TunnelFingerprint;
CP_Dist = CachedEntry.Disturbance; CP_Dist = CachedEntry.Disturbance;
CP_UseOpStack = CachedEntry.bUseOpStack; CP_UseOpStack = CachedEntry.bUseOpStack;
CP_UsesTileCacheWindow = CachedEntry.bUsesTileCacheWindow;
CP_UseCustomRecipe = false; CP_UseCustomRecipe = false;
#if WITH_EDITOR #if WITH_EDITOR
CP_UseComposerRegions = false; 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 bOwnerChanged = DensityCacheOwnerId != CP_OwnerId;
const bool bManagerChanged = ManagerLifetimeId != CP_ManagerLifetimeId; 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 if (!bLoadedTunnelDensityCache
&& (bOwnerChanged || bManagerChanged || ChunkCoord != CP_Chunk && (bOwnerChanged || bManagerChanged || ChunkCoord != CP_Chunk
|| LayoutVersion != CP_Version)) || LayoutVersion != CP_Version
|| bTileCacheContext != CP_UsesTileCacheWindow
|| bMovedOutTunnelState))
{ {
CP_ActiveTunnelDensityCache = nullptr; CP_ActiveTunnelDensityCache = nullptr;
@@ -2955,6 +2975,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
CP_ManagerLifetimeId = ManagerLifetimeId; CP_ManagerLifetimeId = ManagerLifetimeId;
CP_Version = LayoutVersion; CP_Version = LayoutVersion;
CP_Chunk = ChunkCoord; CP_Chunk = ChunkCoord;
CP_UsesTileCacheWindow = bTileCacheContext;
CP_GenType = StrateManager->GetGeneratorTypeForChunk(ChunkCoord); CP_GenType = StrateManager->GetGeneratorTypeForChunk(ChunkCoord);
CP_UseCustomRecipe = false; CP_UseCustomRecipe = false;
bool bAllowCustomRecipe = true; 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; const int32 IX = (int32)WorldX, IY = (int32)WorldY;
// XY-keyed LRU box (shared down the whole vertical strate stack). Acquire centres a box on // 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. // 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); const int32 CI = (IY - Box.BaseY) * FSurfaceColumnBox::Dim + (IX - Box.BaseX);
if (!Box.Computed[CI]) if (!Box.Computed[CI])
{ {
@@ -3695,15 +3718,18 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
struct FDiffSlot struct FDiffSlot
{ {
FIntVector Chunk = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); FIntVector Chunk = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
uint64 OwnerId = 0;
uint32 Version = 0; uint32 Version = 0;
TArray<FVoxelModification> Mods; TArray<FVoxelModification> Mods;
}; };
static thread_local FDiffSlot DiffSlots[64]; static thread_local FDiffSlot DiffSlots[64];
const uint64 DiffLayerOwnerId = DiffLayer->GetCacheLifetimeId();
const uint32 V = DiffLayer->GetModsVersion(); const uint32 V = DiffLayer->GetModsVersion();
FDiffSlot& Slot = DiffSlots[(ChunkCoord.X & 3) | ((ChunkCoord.Y & 3) << 2) | ((ChunkCoord.Z & 3) << 4)]; 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.Chunk = ChunkCoord;
Slot.Version = V; Slot.Version = V;
DiffLayer->GetChunkModsSnapshot(ChunkCoord, Slot.Mods); // ONE lock per chunk, not per voxel 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<FSurfaceGenerationParams> OC_BiomeParams; thread_local TArray<FSurfaceGenerationParams> OC_BiomeParams;
thread_local FChunkBiomeCache OC_BiomeCache; thread_local FChunkBiomeCache OC_BiomeCache;
thread_local uint32 OC_Version = 0xFFFFFFFFu; // AUDIT C2 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(); 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_Version = OC_LayoutVersion;
OC_OwnerId = OC_CurrentOwnerId;
OC_ManagerLifetimeId = OC_CurrentManagerLifetimeId;
OC_Chunk = ChunkCoord; OC_Chunk = ChunkCoord;
ResolveSurfaceChunkParams(ChunkCoord, OC_Surface, OC_BiomeCtx, OC_BiomeParams); 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 // 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. // 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 uint32 TC_SeenVersion = 0xFFFFFFFFu;
static thread_local uint64 TC_SeenOwnerId = 0;
static thread_local uint64 TC_SeenManagerLifetimeId = 0;
const uint32 TC_LayoutVersion = StrateManager->GetLayoutVersion(); 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_SeenVersion = TC_LayoutVersion;
TC_SeenOwnerId = TC_OwnerId;
TC_SeenManagerLifetimeId = TC_ManagerLifetimeId;
TC_BiomeCache.Invalidate(); TC_BiomeCache.Invalidate();
} }
int32 NumSlots = 0; int32 NumSlots = 0;
@@ -7208,7 +7254,9 @@ EVoxelTileClass UVoxelGenerator::ClassifyTileUncached(
{ {
FSurfSlot& S = Slots[s]; FSurfSlot& S = Slots[s];
if (S.InteriorZ.Num() == 0) continue; 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); const int32 CI = (Yi - Box.BaseY) * FSurfaceColumnBox::Dim + (Xi - Box.BaseX);
if (!Box.Computed[CI]) 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, 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.ChunkZ = ChunkZ;
Cache.Seed = Seed; Cache.Seed = Seed;
Cache.OwnerId = OwnerId;
Cache.ManagerLifetimeId = ManagerLifetimeId;
Cache.bActive = Ctx.IsValid(); Cache.bActive = Ctx.IsValid();
Cache.Ctx = Ctx; Cache.Ctx = Ctx;
Cache.CellBiome.Reset(); Cache.CellBiome.Reset();
@@ -7506,12 +7557,16 @@ FBiomeSample UVoxelGenerator::ResolveBiomeSampleAt(float WorldX, float WorldY, i
const FBiomeContext& Ctx, FChunkBiomeCache& Cache) const const FBiomeContext& Ctx, FChunkBiomeCache& Cache) const
{ {
FBiomeSample Out; 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). // 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), 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; 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 FBiomeContext BM_Ctx;
thread_local FChunkBiomeCache BM_Cache; thread_local FChunkBiomeCache BM_Cache;
thread_local uint32 BM_Version = 0xFFFFFFFFu; // AUDIT C2 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(); 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_Version = BM_LayoutVersion;
BM_OwnerId = BM_CurrentOwnerId;
BM_ManagerLifetimeId = BM_CurrentManagerLifetimeId;
BM_Chunk = ChunkCoord; BM_Chunk = ChunkCoord;
BM_Ctx = StrateManager->GetBiomeContextForChunk(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, 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_Cross = -1.0f, VS_ConnR = -1.0f, VS_Rough = -1.0f,
VS_SpineR = -1.0f, VS_BotZ = FLT_MAX, VS_TopZ = FLT_MAX, 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 || 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.ShaftDensity != VS_Dens || Params.ShaftMinRadius != VS_MinR || Params.ShaftMaxRadius != VS_MaxR ||
Params.CrossConnectChance != VS_Cross || Params.ConnectorRadius != VS_ConnR || Params.CrossConnectChance != VS_Cross || Params.ConnectorRadius != VS_ConnR ||
Params.SurfaceRoughness != VS_Rough || OriginSpineRadius != VS_SpineR || Params.SurfaceRoughness != VS_Rough || OriginSpineRadius != VS_SpineR ||
Params.StrateBottomWorldZ != VS_BotZ || Params.StrateTopWorldZ != VS_TopZ || 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_CX = CX; VS_CY = CY; VS_Seed = S; VS_Spacing = Spacing;
VS_Dens = Params.ShaftDensity; VS_MinR = Params.ShaftMinRadius; VS_MaxR = Params.ShaftMaxRadius; 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_Rough = Params.SurfaceRoughness; VS_SpineR = OriginSpineRadius;
VS_BotZ = Params.StrateBottomWorldZ; VS_TopZ = Params.StrateTopWorldZ; VS_BotZ = Params.StrateBottomWorldZ; VS_TopZ = Params.StrateTopWorldZ;
VS_Seal = Params.BoundarySealThickness; VS_Seal = Params.BoundarySealThickness;
VS_LedgeSpacing = Params.LedgeSpacing;
VS_LedgeDepth = Params.LedgeDepth;
Shafts.Reset(); Shafts.Reset();
Conns.Reset(); Conns.Reset();
TreeEmitShafts.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 int32 FI_CX = INT32_MAX, FI_CY = INT32_MAX;
thread_local uint32 FI_Seed = 0xFFFFFFFFu; 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, 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 || 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.IslandDensity != FI_Dens || Params.IslandMinRadius != FI_MinR || Params.IslandMaxRadius != FI_MaxR ||
Params.ThicknessRatio != FI_Thick || Params.VerticalJitter != FI_VJit || 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_CX = CX; FI_CY = CY; FI_Seed = S; FI_Spacing = Spacing;
FI_Dens = Params.IslandDensity; FI_MinR = Params.IslandMinRadius; FI_MaxR = Params.IslandMaxRadius; FI_Dens = Params.IslandDensity; FI_MinR = Params.IslandMinRadius; FI_MaxR = Params.IslandMaxRadius;
FI_Thick = Params.ThicknessRatio; FI_VJit = Params.VerticalJitter; FI_Thick = Params.ThicknessRatio; FI_VJit = Params.VerticalJitter;
FI_BotZ = Params.StrateBottomWorldZ; FI_TopZ = Params.StrateTopWorldZ; FI_BotZ = Params.StrateBottomWorldZ; FI_TopZ = Params.StrateTopWorldZ;
FI_Seal = Params.BoundarySealThickness;
Islands.Reset(); Islands.Reset();
for (int32 dy = -1; dy <= 1; dy++) for (int32 dy = -1; dy <= 1; dy++)
@@ -1084,6 +1084,27 @@ bool UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
WorldSeed = SeasonManifest.Seed; 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<FStrateSlot> PreviousLayout = StrateLayout;
const TArray<FVoxelSeasonStrate> PreviousSeasonStrates = SeasonStrates;
const FString PreviousSeasonContentHash = ActiveSeasonContentHash;
#if WITH_EDITOR
const TMap<int32, TSharedPtr<FVoxelStrateComposerSlotOverride>> PreviousComposerOverrides = ComposerOverrides;
#endif
auto RestorePreviousLayout = [&]()
{
StrateLayout = PreviousLayout;
SeasonStrates = PreviousSeasonStrates;
ActiveSeasonContentHash = PreviousSeasonContentHash;
#if WITH_EDITOR
ComposerOverrides = PreviousComposerOverrides;
#endif
};
StrateLayout.Empty(); StrateLayout.Empty();
SeasonStrates.Empty(); SeasonStrates.Empty();
ActiveSeasonContentHash.Reset(); ActiveSeasonContentHash.Reset();
@@ -1185,8 +1206,7 @@ bool UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed)
UE_LOG(LogTemp, Error, UE_LOG(LogTemp, Error,
TEXT("[StrateManager] Season slot %d could not load authored definition '%s'."), TEXT("[StrateManager] Season slot %d could not load authored definition '%s'."),
i, *SeasonStrate.SourceDefinitionPath); i, *SeasonStrate.SourceDefinitionPath);
StrateLayout.Empty(); RestorePreviousLayout();
SeasonStrates.Empty();
return false; return false;
} }
// Keep the authored content/passage/visual bag without mutating the cooked asset, // 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, UE_LOG(LogTemp, Error,
TEXT("[StrateManager] Season slot %d bounds do not describe the declared stacked layout."), i); TEXT("[StrateManager] Season slot %d bounds do not describe the declared stacked layout."), i);
StrateLayout.Empty(); RestorePreviousLayout();
SeasonStrates.Empty();
return false; return false;
} }
SeasonStrates.Add(SeasonStrate); SeasonStrates.Add(SeasonStrate);
@@ -1611,6 +1630,9 @@ bool UVoxelStrateManager::GetRecipeForChunk(
void UVoxelStrateManager::GeneratePassages() void UVoxelStrateManager::GeneratePassages()
{ {
VoxelForgeStartupTrace::FStageScope StartupTraceStage(TEXT("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(); Passages.Empty();
if (StrateLayout.Num() < 1) 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( VoxelForgeStartupTrace::RecordEvent(TEXT("passages_ready"), FString::Printf(
TEXT("\"passages\":%d,\"passages_version\":%u,\"upper_source_fit\":%d,\"lower_source_fit\":%d"), TEXT("\"passages\":%d,\"passages_version\":%u,\"upper_source_fit\":%d,\"lower_source_fit\":%d"),
Passages.Num(), PassagesVersion, NumAimedAtUpperPlayerFit, NumAimedAtLowerPlayerFit)); Passages.Num(), PassagesVersion, NumAimedAtUpperPlayerFit, NumAimedAtLowerPlayerFit));
@@ -5691,7 +5710,7 @@ FStrateGenerationParams UVoxelStrateManager::GetGenerationParams(const FIntVecto
{ {
// GRADIENT TRANSITION: Classic smooth lerp across the blend zone. // 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). // 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); FStrateGenerationParams BelowParams = BuildParamsFromDefinition(BelowSlot.Definition);
BelowParams.StrateTopWorldZ = (float)(BelowSlot.TopChunkZ + 1) * CHUNK_SIZE; BelowParams.StrateTopWorldZ = (float)(BelowSlot.TopChunkZ + 1) * CHUNK_SIZE;
@@ -5709,6 +5728,8 @@ FStrateGenerationParams UVoxelStrateManager::GetGenerationParams(const FIntVecto
case EVoxelStrateTransition::Interleaved: case EVoxelStrateTransition::Interleaved:
{ {
if (EffectiveBlend <= 0) { break; }
// INTERLEAVED TRANSITION: 3D noise warps the effective boundary Z. // INTERLEAVED TRANSITION: 3D noise warps the effective boundary Z.
// //
// Instead of a flat boundary plane, the boundary becomes a wavy 3D surface. // 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: case EVoxelStrateTransition::Gradient:
{ {
if (DistFromTop < EffectiveBlend) if (EffectiveBlend > 0 && DistFromTop < EffectiveBlend)
{ {
FStrateGenerationParams AboveParams = BuildParamsFromDefinition(AboveSlot.Definition); FStrateGenerationParams AboveParams = BuildParamsFromDefinition(AboveSlot.Definition);
AboveParams.StrateTopWorldZ = (float)(AboveSlot.TopChunkZ + 1) * CHUNK_SIZE; AboveParams.StrateTopWorldZ = (float)(AboveSlot.TopChunkZ + 1) * CHUNK_SIZE;
@@ -5805,6 +5826,8 @@ FStrateGenerationParams UVoxelStrateManager::GetGenerationParams(const FIntVecto
case EVoxelStrateTransition::Interleaved: case EVoxelStrateTransition::Interleaved:
{ {
if (EffectiveBlend <= 0) { break; }
const float WarpAmplitude = 2.0f; const float WarpAmplitude = 2.0f;
const int32 CheckRange = EffectiveBlend + FMath::CeilToInt(WarpAmplitude); const int32 CheckRange = EffectiveBlend + FMath::CeilToInt(WarpAmplitude);
+127 -38
View File
@@ -231,6 +231,14 @@ public:
if (World->ContentManager && !World->ContentManager->WaitForDecorationTasks(Deadline)) return; 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; bAcquired = true;
} }
@@ -238,6 +246,10 @@ public:
{ {
if (World) if (World)
{ {
if (bDensityVolumePaused && World->DensityVolume)
{
World->DensityVolume->ResumeAfterGenerationChange();
}
World->bGenerationPaused.store(false, std::memory_order_release); World->bGenerationPaused.store(false, std::memory_order_release);
} }
} }
@@ -247,6 +259,7 @@ public:
private: private:
AVoxelWorld* World = nullptr; AVoxelWorld* World = nullptr;
bool bAcquired = false; bool bAcquired = false;
bool bDensityVolumePaused = false;
}; };
#if WITH_EDITOR #if WITH_EDITOR
@@ -561,6 +574,7 @@ void AVoxelWorld::RegenerateAllChunks()
void AVoxelWorld::RebuildStrates() void AVoxelWorld::RebuildStrates()
{ {
bool bLayoutRebuilt = true; bool bLayoutRebuilt = true;
bool bCreatedStrateManager = false;
{ {
FScopedGenerationPause Guard(this); FScopedGenerationPause Guard(this);
if (!Guard.Acquired()) if (!Guard.Acquired())
@@ -569,14 +583,73 @@ void AVoxelWorld::RebuildStrates()
return; 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<UVoxelStrateManager>(this);
bCreatedStrateManager = true;
}
if (StrateManager && Settings) if (StrateManager && Settings)
{ {
// Re-applies layout + inter-strate gap + passage/spine settings from VoxelSettings. // Re-applies layout + inter-strate gap + passage/spine settings from VoxelSettings.
bLayoutRebuilt = StrateManager->Initialize( bLayoutRebuilt = StrateManager->Initialize(
Settings, Settings->GetEffectiveWorldSeed()); 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<UVoxelDensityVolume>(this);
}
if (DensityVolume && Settings)
{
DensityVolume->Initialize(this, Generator, Settings);
DensityVolume->Reset();
}
if (AtmosphereManager) AtmosphereManager->Reset();
}
} }
if (!bLayoutRebuilt) if (!bLayoutRebuilt)
@@ -896,12 +969,21 @@ void AVoxelWorld::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedE
{ {
Super::PostEditChangeProperty(PropertyChangedEvent); Super::PostEditChangeProperty(PropertyChangedEvent);
// During PIE with live edit on, regenerate when the actor's own properties change // During PIE with live edit on, a Settings reference change needs the full rebind path: the
// (e.g., Settings reference, bLiveEditStrates toggle, etc.) // manager, generator, mesher, diff budget, decorations and density volume each own a snapshot.
// Data asset edits (strate definitions) are handled separately by OnObjectModifiedInEditor. // Data asset edits (and the settings asset itself) are handled separately by
// OnObjectModifiedInEditor.
if (bLiveEditStrates && GetWorld() && GetWorld()->IsPlayInEditor()) 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 // Only react during PIE with live edit enabled
if (!bLiveEditStrates || !GetWorld() || !GetWorld()->IsPlayInEditor()) return; if (!bLiveEditStrates || !GetWorld() || !GetWorld()->IsPlayInEditor()) return;
// Only care about strate definition and terrain op definition edits. // Only care about generation-owned assets. This delegate fires for every UObject modification
// (This delegate fires for EVERY UObject modification in the editor.) // in the editor.
bool bIsRelevant = false; bool bIsRelevant = false;
FString AssetName; FString AssetName;
// Case 1: A strate definition was modified // Case 1: the settings asset itself was modified
if (UVoxelStrateDefinition* ModifiedStrate = Cast<UVoxelStrateDefinition>(ModifiedObject)) if (UVoxelSettings* ModifiedSettings = Cast<UVoxelSettings>(ModifiedObject))
{
if (ModifiedSettings != Settings) return;
bIsRelevant = true;
AssetName = ModifiedSettings->GetName();
}
// Case 2: A strate definition was modified
else if (UVoxelStrateDefinition* ModifiedStrate = Cast<UVoxelStrateDefinition>(ModifiedObject))
{ {
if (!Settings) return; if (!Settings) return;
AssetName = ModifiedStrate->GetName(); 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<UVoxelTerrainOpDefinition>(ModifiedObject)) else if (UVoxelTerrainOpDefinition* ModifiedOp = Cast<UVoxelTerrainOpDefinition>(ModifiedObject))
{ {
if (!Settings || !StrateManager) return; 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<UVoxelBiomeDefinition>(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; if (!bIsRelevant) return;
UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] Live edit: '%s' modified, regenerating..."), UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] Live edit: '%s' modified, regenerating..."),
*AssetName); *AssetName);
// Re-initialize the strate manager so it picks up the changed definition values, // Use the same transactional full rebind as a Settings-reference change. In particular, do not
// then regenerate all chunks with the updated params. // ignore Initialize's return value: an invalid live asset must leave the previous field intact.
{ RebuildStrates();
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();
} }
#endif #endif
@@ -1311,7 +1398,8 @@ void AVoxelWorld::BeginPlay()
// Système de strates — a cooked season takes precedence; otherwise the authored pool path is // Système de strates — a cooked season takes precedence; otherwise the authored pool path is
// exactly the legacy one. // 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<UVoxelStrateManager>(this); StrateManager = NewObject<UVoxelStrateManager>(this);
if (!StrateManager->Initialize(Settings, Settings->GetEffectiveWorldSeed())) if (!StrateManager->Initialize(Settings, Settings->GetEffectiveWorldSeed()))
@@ -5550,13 +5638,14 @@ void AVoxelWorld::ApplyModification(const FVoxelModification& Modification)
bSyncedCenter ? 1 : 0); bSyncedCenter ? 1 : 0);
} }
// Remove decorations inside the modified volume so grass doesn't float over a dug hole (or bury under a // Invalidate the complete decoration build state after a terrain edit. This removes live instances,
// fill). Instant + flicker-free (only the affected instances go); the placer already skips carved columns // abandons in-flight pre-edit marches, and forces the next stationary update to resample the edited
// on any future rebuild. Center/Radius are in voxels → world cm. Box/capsule use their bounding sphere. // 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) if (ContentManager && AffectedChunks.Num() > 0)
{ {
const FVector WorldCenter = Modification.Center * VOXEL_SIZE; // Center was WorldPos/VOXEL_SIZE ContentManager->InvalidateDecorationBuilds();
ContentManager->RemoveDecorationsInSphere(WorldCenter, Modification.Radius * VOXEL_SIZE);
} }
} }
+8 -2
View File
@@ -217,7 +217,7 @@ struct FBiomeSample
* done ONCE here, into a small grid covering the chunk footprint + margin; per voxel * 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. * 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 * 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. * 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. * 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; float ValidMinY = 0.0f, ValidMaxY = 0.0f;
int32 ChunkZ = MIN_int32; // which strate slice this was built for int32 ChunkZ = MIN_int32; // which strate slice this was built for
int32 Seed = MIN_int32; 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? bool bActive = false; // does this strate have biomes?
FBiomeContext Ctx; // resolved biomes + map (for blending) FBiomeContext Ctx; // resolved biomes + map (for blending)
@@ -237,9 +239,11 @@ struct FChunkBiomeCache
int32 BaseCellX = 0, BaseCellY = 0, CellsX = 0, CellsY = 0; int32 BaseCellX = 0, BaseCellY = 0, CellsX = 0, CellsY = 0;
TArray<int32> CellBiome; TArray<int32> 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 return InSeed == Seed && InChunkZ == ChunkZ
&& InOwnerId == OwnerId && InManagerLifetimeId == ManagerLifetimeId
&& X >= ValidMinX && X <= ValidMaxX && X >= ValidMinX && X <= ValidMaxX
&& Y >= ValidMinY && Y <= ValidMaxY; && Y >= ValidMinY && Y <= ValidMaxY;
} }
@@ -254,5 +258,7 @@ struct FChunkBiomeCache
{ {
ValidMinX = 1.0f; ValidMaxX = -1.0f; // min > max ⇒ Contains() is false everywhere ValidMinX = 1.0f; ValidMaxX = -1.0f; // min > max ⇒ Contains() is false everywhere
ChunkZ = MIN_int32; ChunkZ = MIN_int32;
OwnerId = 0;
ManagerLifetimeId = 0;
} }
}; };
@@ -120,6 +120,11 @@ public:
* future rebuild — this patches the LIVE instances. Returns how many were removed. Game-thread. */ * future rebuild — this patches the LIVE instances. Returns how many were removed. Game-thread. */
int32 RemoveDecorationsInSphere(const FVector& WorldCenter, float WorldRadius); 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 /** Destroy all spawned content (decorations + water). Regenerate / season reset. Bumps the deco
* epoch so any in-flight march tasks' results are discarded. */ * epoch so any in-flight march tasks' results are discarded. */
void ClearAll(); void ClearAll();
@@ -179,6 +184,9 @@ private:
FPlacementProfile Profile; // representative profile (mesh + HISM render tuning: cull/shadow) — FPlacementProfile Profile; // representative profile (mesh + HISM render tuning: cull/shadow) —
// may be a decoration entry's OR a companion's (F7). // may be a decoration entry's OR a companion's (F7).
TArray<FTransform> Xforms; TArray<FTransform> Xforms;
int32 SourceEntryIdx = INT32_MAX;
int32 SourceCompanionIdx = INT32_MAX;
int32 SourceSubIdx = INT32_MAX;
}; };
// A non-instanced actor placement, spawned when the region is applied. // A non-instanced actor placement, spawned when the region is applied.
struct FRegionActorSpawn struct FRegionActorSpawn
@@ -287,6 +295,7 @@ private:
void ClearAllDecorations(); void ClearAllDecorations();
void DrainDecoResults(); // discard every queued march result void DrainDecoResults(); // discard every queued march result
static void ResetGridBuildState(FDecoGrid& G); // drop builds/queues (loaded regions untouched) 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. // Region size in cells, clamped (>=1). Cell↔region math lives in file-static helpers in the .cpp.
int32 RegionSize() const; int32 RegionSize() const;
@@ -353,10 +362,18 @@ private:
// Set in BeginDestroy; worker tasks check it before touching us. // Set in BeginDestroy; worker tasks check it before touching us.
std::atomic<bool> bShuttingDown{false}; std::atomic<bool> 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<int32> ActiveDecorationTasks{0};
// Streaming state. INT_MIN sentinels force a full rebuild on the first update / after ClearAll. // Streaming state. INT_MIN sentinels force a full rebuild on the first update / after ClearAll.
FIntPoint LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN); FIntPoint LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN);
int32 LastStrateIndex = 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 // 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. // copies the PODs into each task). Same for both grids — a strate is a horizontal slab.
+2 -1
View File
@@ -1,6 +1,7 @@
// VoxelDensityOp.h // VoxelDensityOp.h
// LE CONTRAT de la pile d'opérateurs de densité / THE density operator stack CONTRACT. // 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 // POURQUOI / WHY
@@ -70,6 +70,12 @@ public:
* the player hasn't crossed a level-0 cell boundary and nothing is dirty. */ * the player hasn't crossed a level-0 cell boundary and nothing is dirty. */
void Update(const FVector& PlayerWorldPos); 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 /** 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 * clipmap cells next Update. GetDensityAt already includes the diff layer, so re-sampling
* picks the edit up. Cheap + local. */ * picks the edit up. Cheap + local. */
@@ -217,6 +223,7 @@ private:
FRunnableThread* FillThread = nullptr; FRunnableThread* FillThread = nullptr;
FEvent* FillWakeEvent = nullptr; FEvent* FillWakeEvent = nullptr;
std::atomic<bool> bFillThreadStop{ false }; std::atomic<bool> bFillThreadStop{ false };
std::atomic<bool> bGenerationPaused{ false };
// CAPTURE-DURING-MESHING (level-0 only): tile coord → CHUNK_SIZE³ R8 captured density. Populated by // 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 // IngestTileCapture (free — the mesher already sampled it), consumed by RecenterLevel(0) to fill
+18 -6
View File
@@ -61,7 +61,8 @@ struct VOXELFORGE_API FVoxelModification
{ {
GENERATED_BODY() 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") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification")
FVector Center = FVector::ZeroVector; FVector Center = FVector::ZeroVector;
@@ -83,7 +84,7 @@ struct VOXELFORGE_API FVoxelModification
meta = (EditCondition = "Shape == EVoxelBrushShape::Box")) meta = (EditCondition = "Shape == EVoxelBrushShape::Box"))
FVector BoxExtent = FVector(5.0f, 5.0f, 5.0f); 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", UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification",
meta = (EditCondition = "Shape == EVoxelBrushShape::Capsule")) meta = (EditCondition = "Shape == EVoxelBrushShape::Capsule"))
FVector CapsuleEnd = FVector::ZeroVector; FVector CapsuleEnd = FVector::ZeroVector;
@@ -93,7 +94,7 @@ struct VOXELFORGE_API FVoxelModification
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification", meta = (ClampMin = "0.1")) UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification", meta = (ClampMin = "0.1"))
float Falloff = 2.0f; 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 void GetWorldBounds(FVector& OutMin, FVector& OutMax) const
{ {
switch (Shape) switch (Shape)
@@ -138,6 +139,9 @@ class VOXELFORGE_API UVoxelDiffLayer : public UObject
GENERATED_BODY() GENERATED_BODY()
public: public:
/** Process-unique identity for worker-local modification snapshots. */
uint64 GetCacheLifetimeId() const;
//========================================================================= //=========================================================================
// BUDGET CONFIGURATION // BUDGET CONFIGURATION
//========================================================================= //=========================================================================
@@ -162,6 +166,10 @@ public:
*/ */
bool CanModify(float Radius) const; 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). * Get remaining modification count (how many more operations the player can do).
* Returns -1 if unlimited. * Returns -1 if unlimited.
@@ -183,7 +191,7 @@ public:
/** /**
* Apply a carve or fill operation. * 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 * Returns the list of affected chunk coordinates so the world knows
* which chunks need re-meshing. * which chunks need re-meshing.
* *
@@ -245,8 +253,8 @@ public:
/** True si un chunk modifié intersecte [MinChunk, MaxChunk] (inclusif). Conservatif par /** True si un chunk modifié intersecte [MinChunk, MaxChunk] (inclusif). Conservatif par
* construction : ApplyModification enregistre le mod dans TOUS les chunks que son rayon * 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 * touche, donc le test par clé de chunk suffit. La recherche parcourt directement les clés de
* mondes édités ont peu de chunks modifiés) — utilisé par ClassifyTile, PAS par voxel. */ * 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; 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 /** Evaluate a mod list at a voxel — the lock-free core shared by GetDensityOffset and the
@@ -269,6 +277,10 @@ public:
int32 GetModifiedChunkCount() const; int32 GetModifiedChunkCount() const;
private: 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<uint64> CacheLifetimeId{0};
//========================================================================= //=========================================================================
// STORAGE // STORAGE
//========================================================================= //=========================================================================
+2 -1
View File
@@ -465,7 +465,8 @@ private:
/** (Re)build the per-chunk biome cell grid covering chunk (X,Y) footprint + margin. */ /** (Re)build the per-chunk biome cell grid covering chunk (X,Y) footprint + margin. */
void RebuildBiomeGrid(int32 ChunkX, int32 ChunkY, int32 ChunkZ, 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 namespace VoxelGenLOD