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:
1 parent
39c306b524
commit
8879045627
14 files changed
+1360
-134
No files matched your search
@@ -416,6 +416,43 @@ bool FVoxelForgeComposerSeasonTest::RunTest(const FString& Parameters)
|
|||||||
== 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
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 &&
|
|
||||||
Pair.Value.Num() > 0)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ApplyModification registers a mod in every chunk its AABB overlaps, so looking up the keys in
|
||||||
|
// the queried range is conservative and proportional to that range. Do not walk ChunkMods:
|
||||||
|
// the edit history can grow without bound while a sealed-solid candidate is being proved.
|
||||||
|
FReadScopeLock Lock(ModsLock);
|
||||||
|
for (int32 CZ = MinChunk.Z;; ++CZ)
|
||||||
|
{
|
||||||
|
for (int32 CY = MinChunk.Y;; ++CY)
|
||||||
|
{
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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 (!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 (AtmosphereManager) AtmosphereManager->Reset();
|
||||||
if (ContentManager) ContentManager->ClearAll();
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!bLayoutRebuilt)
|
if (!bLayoutRebuilt)
|
||||||
@@ -896,13 +969,22 @@ 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())
|
||||||
|
{
|
||||||
|
const FName ChangedProperty = PropertyChangedEvent.GetPropertyName();
|
||||||
|
if (ChangedProperty == GET_MEMBER_NAME_CHECKED(AVoxelWorld, Settings))
|
||||||
|
{
|
||||||
|
RebuildStrates();
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
RegenerateAllChunks();
|
RegenerateAllChunks();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void AVoxelWorld::OnObjectModifiedInEditor(UObject* ModifiedObject)
|
void AVoxelWorld::OnObjectModifiedInEditor(UObject* ModifiedObject)
|
||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user