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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user