fix(world): drain workers before mutating strate layout/passages (VF-01)

Initialize does StrateLayout.Empty() and Passages.Empty()/Add() -- freeing and
reallocating both -- with no guard, while mesher workers read them through
AnyPassageNearBox, EvaluateModifierSDF and FindSlotIndexForChunkZ. The epoch is
bumped AFTER, so previous-epoch workers are live during the mutation; the epoch
rejects a finished result, it cannot make a read of a freed allocation safe.
Same class as the DiffLayer.ChunkMods carve-vs-stream AV that ModsLock fixed.

Chose the drain over the two alternatives:
  - FRWLock on the arrays: correct, exact in-repo precedent, but a read lock on
    the per-voxel hot path (~43k/tile) would contaminate the perf measurement
    CODEX-TASK-001/002 are queued to take. Worst possible timing.
  - Immutable generation snapshot (Sol's proposal): right long-term, refactors
    the whole StrateManager API surface. A design conversation, not an agent's
    unprompted call.
  - Drain: zero hot-path cost, reuses the machinery EndPlay already proved, and
    its stall lands only on human-initiated editor actions, never during play.

FScopedGenerationPause raises a new bGenerationPaused (deliberately NOT
bShuttingDown, which means teardown) and drains both reader populations: chunk
tasks via ActiveTaskCount and decoration tasks via a new
WaitForDecorationTasks, whose refactor also removed NotifyShutdown's duplicate
spin loop. On timeout it does NOT mutate -- logs an Error and leaves the world
consistent. A timeout that proceeds is what the bug already does.

RebuildStrates, OnObjectModifiedInEditor and ChangeSeed cannot reach Initialize
without the pause. BeginPlay untouched (no tasks yet). EndPlay untouched --
VF-02's 3s timeout is a separate deliberate decision.

Verified: four files, no density/mesher file, so terrain cannot move.
ShouldAbortWork replaced three existing checks, adding none. No worker path
waits on the game thread, so the drain is bounded.

Not built -- Jahni builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 17:51:19 +02:00
parent bc0bf83c64
commit 49a9959aed
6 changed files with 349 additions and 50 deletions
@@ -68,17 +68,23 @@ void UVoxelContentManager::NotifyShutdown()
// Wait for in-flight march tasks to finish (they check the flag and bail). Timeout to avoid hangs.
const double Deadline = FPlatformTime::Seconds() + 3.0;
while (GActiveDecoTasks.load(std::memory_order_relaxed) > 0)
{
if (FPlatformTime::Seconds() > Deadline) break;
FPlatformProcess::Yield();
}
WaitForDecorationTasks(Deadline);
DrainDecoResults();
ResetGridBuildState(NearGrid);
ResetGridBuildState(FarGrid);
}
bool UVoxelContentManager::WaitForDecorationTasks(double Deadline)
{
while (GActiveDecoTasks.load(std::memory_order_relaxed) > 0)
{
if (FPlatformTime::Seconds() > Deadline) return false;
FPlatformProcess::Yield();
}
return true;
}
void UVoxelContentManager::DrainDecoResults()
{
FDecoCellResult Discard;
+111 -45
View File
@@ -88,6 +88,45 @@ static void BuildTileStreamSet(RealtimeMesh::FRealtimeMeshStreamSet& Streams, co
}
}
class FScopedGenerationPause
{
public:
explicit FScopedGenerationPause(AVoxelWorld* InWorld)
: World(InWorld)
{
if (!World) return;
World->bGenerationPaused.store(true, std::memory_order_release);
// The game thread owns this gate; workers only read Generator/Mesher and enqueue results.
// La barrière est prise sur le thread de jeu ; les workers ne font qu'énumérer et Enqueue.
const double Deadline = FPlatformTime::Seconds() + 5.0;
while (World->ActiveTaskCount.load(std::memory_order_relaxed) > 0)
{
if (FPlatformTime::Seconds() > Deadline) return;
FPlatformProcess::Yield();
}
if (World->ContentManager && !World->ContentManager->WaitForDecorationTasks(Deadline)) return;
bAcquired = true;
}
~FScopedGenerationPause()
{
if (World)
{
World->bGenerationPaused.store(false, std::memory_order_release);
}
}
bool Acquired() const { return bAcquired; }
private:
AVoxelWorld* World = nullptr;
bool bAcquired = false;
};
//=============================================================================
// LIVE EDIT — regenerate all chunks when params change in the Details panel
//=============================================================================
@@ -139,13 +178,22 @@ void AVoxelWorld::RegenerateAllChunks()
void AVoxelWorld::RebuildStrates()
{
if (StrateManager && Settings)
{
// Re-applies layout + inter-strate gap + passage/spine settings from VoxelSettings.
StrateManager->Initialize(Settings, Settings->Seed);
FScopedGenerationPause Guard(this);
if (!Guard.Acquired())
{
UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] RebuildStrates: generation pause timed out; no mutation applied."));
return;
}
if (StrateManager && Settings)
{
// Re-applies layout + inter-strate gap + passage/spine settings from VoxelSettings.
StrateManager->Initialize(Settings, Settings->Seed);
}
if (AtmosphereManager) AtmosphereManager->Reset();
if (ContentManager) ContentManager->ClearAll();
}
if (AtmosphereManager) AtmosphereManager->Reset();
if (ContentManager) ContentManager->ClearAll();
// Reload all chunks against the rebuilt strate data.
RegenerateAllChunks();
@@ -304,13 +352,22 @@ void AVoxelWorld::OnObjectModifiedInEditor(UObject* ModifiedObject)
// Re-initialize the strate manager so it picks up the changed definition values,
// then regenerate all chunks with the updated params.
if (StrateManager)
{
StrateManager->Initialize(Settings, Settings->Seed);
}
if (Generator)
{
Generator->InitializeSettings(Settings);
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->Seed);
}
if (Generator)
{
Generator->InitializeSettings(Settings);
}
}
RegenerateAllChunks();
@@ -635,7 +692,7 @@ bool AVoxelWorld::ApplyTileResult(FChunkResult& Result)
// is refilled from the diff via MarkDirtyVoxelBox in RemeshDirtyChunks).
void AVoxelWorld::SyncRemeshTile(const FVoxelTileKey& Tile)
{
if (!Generator || !Mesher || bShuttingDown.load(std::memory_order_relaxed)) return;
if (!Generator || !Mesher || ShouldAbortWork()) return;
const FIntVector OriginVoxels = Tile.OriginVoxels();
const int32 Cells = CHUNK_SIZE; // level 0 is always full-res (level 0 < FullResClipLevels)
@@ -1464,14 +1521,14 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority)
~FTaskGuard() { Counter.fetch_sub(1, std::memory_order_relaxed); }
} Guard{ActiveTaskCount};
if (bShuttingDown.load(std::memory_order_relaxed)) return;
if (ShouldAbortWork()) return;
FChunkResult Result;
GenerateTileResult(Tile, OriginVoxels, Step, Cells, TaskEpoch, bWantCapture,
BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi,
bSheetTile, SheetChunkZ, HoleMinX, HoleMinY, HoleMaxX, HoleMaxY, Result);
if (!bShuttingDown.load(std::memory_order_relaxed))
if (!ShouldAbortWork())
{
ProcessQueue.Enqueue(MoveTemp(Result)); // move: don't copy the geometry payload
}
@@ -2072,42 +2129,51 @@ void AVoxelWorld::ChangeSeed(int32 NewSeed)
const int32 OldSeed = Settings->Seed;
const int32 OldSeason = Settings->CurrentSeason;
// 1. Update seed in Settings (the authoritative source)
Settings->Seed = NewSeed;
// 2. Increment season counter
Settings->CurrentSeason++;
// 3. Push new seed to Generator
if (Generator)
{
Generator->InitializeSettings(Settings);
}
FScopedGenerationPause Guard(this);
if (!Guard.Acquired())
{
UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] ChangeSeed: generation pause timed out; no mutation applied."));
return;
}
// 4. Rebuild strate layout with the new seed.
// Strate assignments and passages all change.
if (StrateManager)
{
StrateManager->Initialize(Settings, NewSeed);
}
// 1. Update seed in Settings (the authoritative source)
Settings->Seed = NewSeed;
// 5. Clear all player modifications — carvings from the old world are meaningless
if (DiffLayer)
{
DiffLayer->Clear();
}
// 2. Increment season counter
Settings->CurrentSeason++;
// 5b. Update content placement seed so the new world scatters differently.
if (ContentManager)
{
ContentManager->SetSeed(NewSeed);
ContentManager->ClearAll();
}
// 3. Push new seed to Generator
if (Generator)
{
Generator->InitializeSettings(Settings);
}
// 5c. Reset atmosphere — strate layout changed, re-apply on next Tick.
if (AtmosphereManager)
{
AtmosphereManager->Reset();
// 4. Rebuild strate layout with the new seed.
// Strate assignments and passages all change.
if (StrateManager)
{
StrateManager->Initialize(Settings, NewSeed);
}
// 5. Clear all player modifications — carvings from the old world are meaningless
if (DiffLayer)
{
DiffLayer->Clear();
}
// 5b. Update content placement seed so the new world scatters differently.
if (ContentManager)
{
ContentManager->SetSeed(NewSeed);
ContentManager->ClearAll();
}
// 5c. Reset atmosphere — strate layout changed, re-apply on next Tick.
if (AtmosphereManager)
{
AtmosphereManager->Reset();
}
}
// 6. Unload all existing chunks and let Tick reload them with new generation
@@ -138,6 +138,10 @@ public:
* UObject teardown (worker tasks read the Generator). */
void NotifyShutdown();
/** Wait until in-flight decoration march tasks drain before a generation mutation. Deadline is absolute.
* Attend la fin des tâches de décoration avant une mutation de génération ; échéance absolue. */
bool WaitForDecorationTasks(double Deadline);
//--- async-task plumbing (public so the worker lambda can reach them) -----
/** One placement decided off-thread; spawned on the game thread from FDecoCellResult::Entries. */
struct FDecoSpawn
+13
View File
@@ -25,6 +25,7 @@ class UMaterialParameterCollection;
class UVolumeTexture;
class UMaterialInterface;
class UMaterialInstanceDynamic;
class FScopedGenerationPause;
namespace RealtimeMesh { struct FRealtimeMeshStreamSet; } // T1.f — worker-built geometry buffers
/**
@@ -373,6 +374,8 @@ public:
UVolumeTexture* GetDensityVolumeTexture(int32 Level = 0) const;
private:
friend class FScopedGenerationPause;
/** Get/create the shared MID wrapping a base terrain material (binds volume textures + shadow params).
* Returns Base unchanged-wrapped, or nullptr if Base is null. */
UMaterialInstanceDynamic* GetOrCreateTerrainMID(UMaterialInterface* Base);
@@ -677,9 +680,19 @@ public:
// Set to true during EndPlay — async tasks check this before accessing UObjects
std::atomic<bool> bShuttingDown{false};
// Set during editor-driven generation mutations; distinct from teardown/shutdown semantics.
// Active pendant les mutations de génération lancées par l'éditeur, sans signifier la destruction.
std::atomic<bool> bGenerationPaused{false};
// Number of async tasks currently running — EndPlay waits for this to reach 0
std::atomic<int32> ActiveTaskCount{0};
FORCEINLINE bool ShouldAbortWork() const
{
return bShuttingDown.load(std::memory_order_relaxed)
|| bGenerationPaused.load(std::memory_order_relaxed);
}
// Player's level-0 tile coord (= chunk coord). The desired set is rebuilt when this changes.
FIntVector CurrentCenterChunk = FIntVector::ZeroValue;