Files
VoxelForge/Source/VoxelForge/Private/VoxelDensityVolume.cpp
T
Fr0zkaandClaude Opus 5.5 135957831b fix: sanitizer sweep across all generators; bound signed shifts/division/overflow at coordinate limits (L61); R31
New verify.py sanitize tier (Clang address,undefined,float-cast-overflow; GCC address,undefined;
-fno-sanitize-recover) runs 19 cases per compiler at LOD0-8 across every generator, passages and the
crossing: 0 runtime reports; a planted signed-overflow control is caught by both. A static scan fixed
INT_MIN negation in floor division, unbounded signed LOD shifts and coordinate-times-extent overflow in
VoxelClipmapDesiredTiles.h and VoxelTypes.h (plugin and standalone), and VoxelWorld.cpp, VoxelContentManager.cpp
and VoxelDensityVolume.cpp (plugin only, uncompiled here). Supported-input output unchanged (version 211).
fast/medium/full PASS. Research R31 (self-checked): far-LOD 4-8 cut plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016dhxM43dnWvUu2B5Ph1iG1
2026-10-26 05:19:18 +01:00

805 lines
35 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// VoxelDensityVolume.cpp — see VoxelDensityVolume.h for the design.
// Step 1a: CPU density clipmap + worker fills + toroidal streaming + carve dirty + debug draw.
// The GPU upload (UploadDirtyRegionToGPU) is the step-1b seam and is a no-op here.
#include "VoxelDensityVolume.h"
#include "VoxelGenerator.h"
#include "VoxelSettings.h"
#include "GameFramework/Actor.h"
#include "HAL/Runnable.h"
#include "HAL/RunnableThread.h"
#include "HAL/Event.h"
#include "HAL/PlatformProcess.h"
#include "Math/UnrealMathUtility.h"
#include "Engine/VolumeTexture.h"
#include "TextureResource.h"
#include "RenderingThread.h" // ENQUEUE_RENDER_COMMAND
#include "RHICommandList.h" // FRHICommandListImmediate::UpdateTexture3D
#if ENABLE_DRAW_DEBUG
#include "DrawDebugHelpers.h"
#endif
// Dedicated fill thread: drains FillQueue (Spsc, game thread → here), re-evaluates GetDensityAt via
// the owner's ProcessOneFill, pushes FFillResult into the owner's Results (Mpsc, drained game-side).
// Sleeps on FillWakeEvent when idle. Off the UE::Tasks pool by design — so volume fills run at full
// speed on their own core WITHOUT contending with mesh-gen (the old BackgroundLow path starved).
class FVoxelDensityFillRunnable : public FRunnable
{
public:
explicit FVoxelDensityFillRunnable(UVoxelDensityVolume* InOwner) : Owner(InOwner) {}
virtual uint32 Run() override
{
while (!Owner->bFillThreadStop.load(std::memory_order_acquire))
{
UVoxelDensityVolume::FPendingFill F;
bool bDidWork = false;
while (Owner->FillQueue.Dequeue(F))
{
bDidWork = true;
Owner->ProcessOneFill(F);
if (Owner->bFillThreadStop.load(std::memory_order_relaxed)) break;
}
// Sleep until the game thread queues more (or asks us to stop). The Trigger() always
// follows the Enqueue(), so a trigger landing here is latched by the auto-reset event →
// no missed wakeup.
if (!bDidWork && Owner->FillWakeEvent)
{
Owner->FillWakeEvent->Wait();
}
}
return 0;
}
virtual void Stop() override
{
Owner->bFillThreadStop.store(true, std::memory_order_release);
if (Owner->FillWakeEvent) { Owner->FillWakeEvent->Trigger(); }
}
private:
UVoxelDensityVolume* Owner;
};
//=============================================================================
// Lifecycle
//=============================================================================
void UVoxelDensityVolume::Initialize(AActor* InOwner, UVoxelGenerator* InGenerator, UVoxelSettings* InSettings)
{
Owner = InOwner;
Generator = InGenerator;
Settings = InSettings;
bShuttingDown.store(false, std::memory_order_relaxed);
bGenerationPaused.store(false, std::memory_order_relaxed);
bInitialized = true;
// Arrays are allocated lazily on the first Update (EnsureAllocated) so a settings change
// (resolution / level count) before play picks up cleanly.
}
void UVoxelDensityVolume::BeginDestroy()
{
// Backstop — EndPlay → NotifyShutdown should already have stopped the fill thread.
bShuttingDown.store(true, std::memory_order_release);
bGenerationPaused.store(true, std::memory_order_release);
StopFillThread();
Super::BeginDestroy();
}
void UVoxelDensityVolume::NotifyShutdown()
{
bShuttingDown.store(true, std::memory_order_release);
bGenerationPaused.store(true, std::memory_order_release);
// Stop the dedicated fill thread — Kill(true) blocks until Run() returns, so it can't read the
// Generator after this (the owner tears UObjects down next). Then drop any queued/finished work.
StopFillThread();
FFillResult Discard;
while (Results.Dequeue(Discard)) {}
PendingFills.Reset();
CaptureCache.Empty();
}
void UVoxelDensityVolume::Reset()
{
// Reset can be called by a live-edit/regeneration path, not only after EndPlay. Join first so
// no fill thread can still read Generator while the old epoch and queued work are discarded.
StopFillThread();
// Bump the epoch so any in-flight fill lands stale and is dropped in DrainResults.
++VolumeEpoch;
PendingFills.Reset();
FFillResult Discard;
while (Results.Dequeue(Discard)) {}
// Drop all data → next Update full-refills every level (origin sentinel + bHasData false).
for (FClipLevel& Lv : Levels)
{
Lv.OriginCells = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
Lv.bHasData = false;
Lv.bGPUDirty = true; // upload the cleared (zero) data; the refill then re-uploads real data
if (Lv.Density.Num() > 0) { FMemory::Memzero(Lv.Density.GetData(), Lv.Density.Num()); }
}
CaptureCache.Empty(); // pre-reset grids belong to the old world (epoch bumped)
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
//=============================================================================
int32 UVoxelDensityVolume::ResPerAxis() const
{
return FMath::Clamp(Settings ? Settings->DensityVolumeResolution : 256, 32, 512);
}
int32 UVoxelDensityVolume::NumLevels() const
{
return FMath::Clamp(Settings ? Settings->DensityVolumeLevels : 3, 1, 5);
}
FORCEINLINE int32 UVoxelDensityVolume::FloorDiv(int32 A, int32 B)
{
// True floor division (B > 0). Remainder adjustment handles INT_MIN without negating it.
if (B <= 0) { return 0; }
const int32 Quotient = A / B;
const int32 Remainder = A % B;
return Remainder < 0 ? Quotient - 1 : Quotient;
}
FORCEINLINE uint8 UVoxelDensityVolume::Quantize(float MCDensity)
{
// Single source of truth (VoxelTypes.h) — MUST stay bit-identical with the mesher's
// capture-during-meshing path (UVoxelMarchingCubesMesher::GenerateMesh OutCaptureGrid),
// so an ingested tile capture equals a worker fill of the same cells byte-for-byte.
return VF_QuantizeDensity(MCDensity);
}
void UVoxelDensityVolume::EnsureAllocated()
{
const int32 Res = ResPerAxis();
const int32 N = NumLevels();
if (AllocatedRes == Res && Levels.Num() == N) return; // already sized
Levels.Reset();
Levels.SetNum(N);
const int32 Count = Res * Res * Res;
for (int32 L = 0; L < N; ++L)
{
FClipLevel& Lv = Levels[L];
Lv.Step = static_cast<int32>(uint32{1}
<< static_cast<uint32>(FMath::Clamp(L, 0, 8)));
Lv.OriginCells = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
Lv.bHasData = false;
Lv.Density.SetNumZeroed(Count); // start all-air (0)
}
AllocatedRes = Res;
LastPlayerVoxel = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); // force a recenter
EnsureTextures();
}
//=============================================================================
// GPU upload (step 1b-i) — per-level R8 volume textures + full re-upload of dirty levels
//=============================================================================
void UVoxelDensityVolume::EnsureTextures()
{
if (!Settings || !Settings->bEnableDensityVolume || !Settings->bDensityVolumeGPUUpload) return;
const int32 Res = ResPerAxis();
const int32 N = NumLevels();
if (LevelTextures.Num() == N && AllocatedTexRes == Res) return; // already sized
for (TObjectPtr<UVolumeTexture>& T : LevelTextures)
{
if (T) { T->ReleaseResource(); }
}
LevelTextures.Reset();
LevelTextures.SetNum(N);
const int64 Count = (int64)Res * Res * Res;
for (int32 L = 0; L < N; ++L)
{
UVolumeTexture* T = NewObject<UVolumeTexture>(this);
T->SRGB = false;
T->Filter = TF_Trilinear; // smooth iso crossing (sub-voxel crisp edge)
T->CompressionSettings = TC_Grayscale; // single-channel
T->NeverStream = true;
#if WITH_EDITORONLY_DATA
// MipGenSettings n'existe que dans les builds éditeur (WITH_EDITORONLY_DATA) — c'est un hint
// pour le mip-builder du cooker. En packagé le PlatformData construit ici n'a qu'UN mip de
// toute façon (1b-i: base mip only; solidity mips come with the march).
T->MipGenSettings = TMGS_NoMipmaps;
#endif
// Runtime platform data: one R8 (PF_G8) mip, zero-initialised. NOTE (UE5.7 API surface — flag if
// the build rejects any of these): FTexturePlatformData / SetNumSlices / SetPlatformData /
// FTexture2DMipMap(with SizeZ for volumes). If the names drifted, this whole GPU path is gated by
// bDensityVolumeGPUUpload — turn it off to fall back to the validated CPU volume while we fix it.
FTexturePlatformData* PD = new FTexturePlatformData();
PD->SizeX = Res;
PD->SizeY = Res;
PD->PixelFormat = PF_G8;
PD->SetNumSlices(Res);
FTexture2DMipMap* Mip = new FTexture2DMipMap();
Mip->SizeX = Res;
Mip->SizeY = Res;
Mip->SizeZ = Res;
Mip->BulkData.Lock(LOCK_READ_WRITE);
void* Dst = Mip->BulkData.Realloc(Count);
FMemory::Memzero(Dst, Count);
Mip->BulkData.Unlock();
PD->Mips.Add(Mip);
T->SetPlatformData(PD);
T->UpdateResource();
LevelTextures[L] = T;
}
AllocatedTexRes = Res;
// New textures are zeroed → mark every level dirty so the current CPU data uploads.
for (FClipLevel& Lv : Levels) { Lv.bGPUDirty = true; }
}
void UVoxelDensityVolume::UploadDirtyTextures()
{
if (!Settings || !Settings->bDensityVolumeGPUUpload) return;
EnsureTextures(); // cheap early-out when sized; covers bDensityVolumeGPUUpload toggled ON at runtime
const int32 Res = ResPerAxis();
for (int32 L = 0; L < Levels.Num(); ++L)
{
FClipLevel& Lv = Levels[L];
if (!Lv.bGPUDirty) continue;
if (!LevelTextures.IsValidIndex(L) || !LevelTextures[L]) continue;
FTextureResource* Resource = LevelTextures[L]->GetResource();
if (!Resource) continue;
Lv.bGPUDirty = false;
// The CPU toroidal array IS the texture's memory layout (texel (tx,ty,tz) = array[(tz*Res+ty)*Res+tx]),
// so a FULL re-upload from it is correct with no wrap-splitting. ~Res³ bytes/level (2 MB at 128) — only
// when the level actually changed (idle = no upload). Sub-box upload (with toroidal wrap-splitting) is
// a later optimisation. Copy the source for the render thread (the CPU array keeps mutating).
TArray<uint8> Src = Lv.Density;
ENQUEUE_RENDER_COMMAND(VoxelDensityVolumeUpload)(
[Resource, SrcData = MoveTemp(Src), Res](FRHICommandListImmediate& RHICmdList) mutable
{
FRHITexture* Tex = Resource->GetTextureRHI();
if (!Tex) return;
const FUpdateTextureRegion3D Region(0, 0, 0, 0, 0, 0, Res, Res, Res);
// R8: row pitch = Res bytes, depth (slice) pitch = Res*Res bytes.
RHICmdList.UpdateTexture3D(Tex, 0, Region, (uint32)Res, (uint32)(Res * Res), SrcData.GetData());
});
}
}
UVolumeTexture* UVoxelDensityVolume::GetLevelTexture(int32 Level) const
{
return LevelTextures.IsValidIndex(Level) ? LevelTextures[Level].Get() : nullptr;
}
bool UVoxelDensityVolume::GetLevelShaderParams(int32 Level, FIntVector& OutOriginCells, float& OutStep, int32& OutRes) const
{
if (!Levels.IsValidIndex(Level) || !Levels[Level].bHasData) return false;
OutOriginCells = Levels[Level].OriginCells;
OutStep = (float)Levels[Level].Step;
OutRes = ResPerAxis();
return true;
}
//=============================================================================
// Update — recentre, queue, launch, drain
//=============================================================================
void UVoxelDensityVolume::Update(const FVector& PlayerWorldPos)
{
if (bGenerationPaused.load(std::memory_order_acquire)
|| !bInitialized || !Settings || !Settings->bEnableDensityVolume || !Generator) return;
AActor* O = Owner.Get();
if (!O) return;
EnsureAllocated();
// Player world → actor-local voxel coords (GetDensityAt is in actor-local voxel space, same as the
// decoration march). The actor is Static at the origin, but do it properly via the transform.
const FTransform Xf = O->GetActorTransform();
const FVector Local = Xf.InverseTransformPosition(PlayerWorldPos);
const FIntVector PlayerVoxel(
FMath::RoundToInt(Local.X / VOXEL_SIZE),
FMath::RoundToInt(Local.Y / VOXEL_SIZE),
FMath::RoundToInt(Local.Z / VOXEL_SIZE));
if (PlayerVoxel != LastPlayerVoxel)
{
LastPlayerVoxel = PlayerVoxel;
const int32 N = Levels.Num();
for (int32 L = 0; L < N; ++L)
{
RecenterLevel(L, PlayerVoxel); // cheap no-op for a level whose origin didn't move
}
}
LaunchPendingFills(); // drain the queue under the task budget
DrainResults(); // apply finished worker fills into the toroidal arrays (marks levels GPU-dirty)
UploadDirtyTextures(); // push changed levels to the GPU (render-thread RHIUpdateTexture3D)
}
void UVoxelDensityVolume::RecenterLevel(int32 L, const FIntVector& PlayerVoxel)
{
if (!Levels.IsValidIndex(L)) return;
FClipLevel& Lv = Levels[L];
const int32 Res = ResPerAxis();
const int32 Step = Lv.Step;
const int32 Half = Res / 2;
const FIntVector PlayerCell(FloorDiv(PlayerVoxel.X, Step),
FloorDiv(PlayerVoxel.Y, Step),
FloorDiv(PlayerVoxel.Z, Step));
FIntVector NewOrigin = PlayerCell - FIntVector(Half, Half, Half);
// LEVEL 0 is capture-fed (capture-during-meshing). Snap the window origin to the level-0 TILE grid
// (CHUNK_SIZE cells) so newly-exposed slabs align to whole captured tiles, and the window only
// scrolls on CHUNK crossings (≈CHUNK_SIZE× fewer recenters + GPU re-uploads than the per-voxel
// path). The player still stays ≥CHUNK_SIZE cells from any window edge, so the small origin offset
// is invisible to the shadow march. Coarser levels keep the per-cell worker-fill path unchanged.
const bool bCapture = (L == 0);
if (bCapture)
{
NewOrigin = FIntVector(FloorDiv(NewOrigin.X, CHUNK_SIZE) * CHUNK_SIZE,
FloorDiv(NewOrigin.Y, CHUNK_SIZE) * CHUNK_SIZE,
FloorDiv(NewOrigin.Z, CHUNK_SIZE) * CHUNK_SIZE);
}
const FIntVector Dim(Res, Res, Res);
if (Lv.bHasData && NewOrigin == Lv.OriginCells) return; // didn't move → nothing to refill
if (!Lv.bHasData)
{
Lv.OriginCells = NewOrigin;
Lv.bHasData = true; // toroidal slots are stale until the fills land (transient)
if (bCapture) { FillBoxFromCacheOrQueue(NewOrigin, Dim); EvictFarCaptures(); }
else { QueueFillSplit(L, NewOrigin, Dim); }
return;
}
// Incremental: refill only the slabs that scrolled into view (new window minus old window). The
// toroidal slots for cells still in view keep their valid data — no copy/move needed.
TArray<TPair<FIntVector, FIntVector>> Boxes;
BoxDifference(NewOrigin, Dim, Lv.OriginCells, Dim, Boxes);
Lv.OriginCells = NewOrigin;
for (const TPair<FIntVector, FIntVector>& B : Boxes)
{
if (bCapture) FillBoxFromCacheOrQueue(B.Key, B.Value);
else QueueFillSplit(L, B.Key, B.Value);
}
if (bCapture) EvictFarCaptures();
}
void UVoxelDensityVolume::BoxDifference(const FIntVector& NewMin, const FIntVector& NewDim,
const FIntVector& OldMin, const FIntVector& OldDim,
TArray<TPair<FIntVector, FIntVector>>& OutBoxes)
{
const FIntVector NMax = NewMin + NewDim; // exclusive
const FIntVector OMax = OldMin + OldDim;
const FIntVector IMin(FMath::Max(NewMin.X, OldMin.X), FMath::Max(NewMin.Y, OldMin.Y), FMath::Max(NewMin.Z, OldMin.Z));
const FIntVector IMax(FMath::Min(NMax.X, OMax.X), FMath::Min(NMax.Y, OMax.Y), FMath::Min(NMax.Z, OMax.Z));
// No overlap → the whole new window is new.
if (IMin.X >= IMax.X || IMin.Y >= IMax.Y || IMin.Z >= IMax.Z)
{
OutBoxes.Add(TPair<FIntVector, FIntVector>(NewMin, NewDim));
return;
}
auto Add = [&](int32 x0, int32 x1, int32 y0, int32 y1, int32 z0, int32 z1)
{
if (x1 > x0 && y1 > y0 && z1 > z0)
{
OutBoxes.Add(TPair<FIntVector, FIntVector>(FIntVector(x0, y0, z0), FIntVector(x1 - x0, y1 - y0, z1 - z0)));
}
};
// X slabs span the full new Y,Z; Y slabs span the overlap X + full new Z; Z slabs span the overlap X,Y.
// Together these are disjoint and cover (new \ old) exactly.
Add(NewMin.X, IMin.X, NewMin.Y, NMax.Y, NewMin.Z, NMax.Z);
Add(IMax.X, NMax.X, NewMin.Y, NMax.Y, NewMin.Z, NMax.Z);
Add(IMin.X, IMax.X, NewMin.Y, IMin.Y, NewMin.Z, NMax.Z);
Add(IMin.X, IMax.X, IMax.Y, NMax.Y, NewMin.Z, NMax.Z);
Add(IMin.X, IMax.X, IMin.Y, IMax.Y, NewMin.Z, IMin.Z);
Add(IMin.X, IMax.X, IMin.Y, IMax.Y, IMax.Z, NMax.Z);
}
void UVoxelDensityVolume::QueueFillSplit(int32 L, const FIntVector& MinCells, const FIntVector& DimCells)
{
if (DimCells.X <= 0 || DimCells.Y <= 0 || DimCells.Z <= 0) return;
const int32 Slab = FMath::Clamp(Settings ? Settings->DensityVolumeFillSlabCells : 8, 1, 64);
for (int32 z0 = 0; z0 < DimCells.Z; z0 += Slab)
{
const int32 dz = FMath::Min(Slab, DimCells.Z - z0);
FPendingFill F;
F.Level = L;
F.MinCells = FIntVector(MinCells.X, MinCells.Y, MinCells.Z + z0);
F.DimCells = FIntVector(DimCells.X, DimCells.Y, dz);
F.Epoch = VolumeEpoch;
PendingFills.Add(MoveTemp(F));
}
}
//=============================================================================
// Capture-during-meshing (level-0): cache-fed fills, ingest, eviction
//=============================================================================
void UVoxelDensityVolume::FillBoxFromCacheOrQueue(const FIntVector& MinCells, const FIntVector& DimCells)
{
if (DimCells.X <= 0 || DimCells.Y <= 0 || DimCells.Z <= 0) return;
const FIntVector BoxMax = MinCells + DimCells; // exclusive (level 0: cell coord == voxel coord)
// Iterate the level-0 tiles overlapping the box (a tile spans CHUNK_SIZE cells). Cached tiles blit
// straight from the captured grid (no GetDensityAt); uncached tiles fall back to a worker fill of
// just the box∩tile region (cold start, vertical strate gaps, evicted tiles).
const FIntVector TMin(FloorDiv(MinCells.X, CHUNK_SIZE), FloorDiv(MinCells.Y, CHUNK_SIZE), FloorDiv(MinCells.Z, CHUNK_SIZE));
const FIntVector TMax(FloorDiv(BoxMax.X - 1, CHUNK_SIZE), FloorDiv(BoxMax.Y - 1, CHUNK_SIZE), FloorDiv(BoxMax.Z - 1, CHUNK_SIZE));
for (int32 tz = TMin.Z; tz <= TMax.Z; ++tz)
for (int32 ty = TMin.Y; ty <= TMax.Y; ++ty)
for (int32 tx = TMin.X; tx <= TMax.X; ++tx)
{
const FIntVector T(tx, ty, tz);
if (const TArray<uint8>* Grid = CaptureCache.Find(T))
{
BlitCaptureToWindow(T, *Grid); // writes all of T's in-window cells (idempotent)
}
else
{
const FIntVector Org = T * CHUNK_SIZE;
const FIntVector IMin(FMath::Max(MinCells.X, Org.X), FMath::Max(MinCells.Y, Org.Y), FMath::Max(MinCells.Z, Org.Z));
const FIntVector IMax(FMath::Min(BoxMax.X, Org.X + CHUNK_SIZE),
FMath::Min(BoxMax.Y, Org.Y + CHUNK_SIZE),
FMath::Min(BoxMax.Z, Org.Z + CHUNK_SIZE)); // exclusive
QueueFillSplit(0, IMin, FIntVector(IMax.X - IMin.X, IMax.Y - IMin.Y, IMax.Z - IMin.Z));
}
}
}
bool UVoxelDensityVolume::BlitCaptureToWindow(const FIntVector& L0TileCoord, const TArray<uint8>& Grid)
{
if (!Levels.IsValidIndex(0)) return false;
FClipLevel& Lv = Levels[0];
if (!Lv.bHasData) return false;
if (Grid.Num() < CHUNK_SIZE * CHUNK_SIZE * CHUNK_SIZE) return false;
const int32 Res = ResPerAxis();
const FIntVector W0 = Lv.OriginCells;
const FIntVector W1 = Lv.OriginCells + FIntVector(Res, Res, Res); // exclusive
const FIntVector Org = L0TileCoord * CHUNK_SIZE; // tile min cell == min voxel (step 1)
// Clamp the tile to the window once (per axis); reject if fully outside.
const int32 cx0 = FMath::Max(W0.X, Org.X), cx1 = FMath::Min(W1.X, Org.X + CHUNK_SIZE);
const int32 cy0 = FMath::Max(W0.Y, Org.Y), cy1 = FMath::Min(W1.Y, Org.Y + CHUNK_SIZE);
const int32 cz0 = FMath::Max(W0.Z, Org.Z), cz1 = FMath::Min(W1.Z, Org.Z + CHUNK_SIZE);
if (cx0 >= cx1 || cy0 >= cy1 || cz0 >= cz1) return false;
// Toroidal walk: one modulo per ROW, then the X run increments tx and wraps manually (this is on
// the game thread and batches a whole tile per chunk crossing — per-cell modulo would spike).
uint8* RESTRICT Dst = Lv.Density.GetData();
const uint8* RESTRICT Src = Grid.GetData();
const int32 tx0 = ((cx0 % Res) + Res) % Res;
const int32 gx0 = cx0 - Org.X;
for (int32 cz = cz0; cz < cz1; ++cz)
{
const int32 tz = ((cz % Res) + Res) % Res;
const int32 gz = cz - Org.Z;
for (int32 cy = cy0; cy < cy1; ++cy)
{
const int32 ty = ((cy % Res) + Res) % Res;
const int32 DstRow = (tz * Res + ty) * Res;
const int32 SrcRow = (gz * CHUNK_SIZE + (cy - Org.Y)) * CHUNK_SIZE;
int32 tx = tx0, gx = gx0;
for (int32 cx = cx0; cx < cx1; ++cx)
{
Dst[DstRow + tx] = Src[SrcRow + gx];
++gx;
if (++tx == Res) tx = 0;
}
}
}
Lv.bGPUDirty = true;
return true;
}
bool UVoxelDensityVolume::GetCaptureKeepBounds(FIntVector& OutLo, FIntVector& OutHi) const
{
if (!Levels.IsValidIndex(0) || !Levels[0].bHasData) return false;
const int32 Res = ResPerAxis();
const FIntVector W0 = Levels[0].OriginCells;
// Tiles overlapping the window, +1 tile margin (keep the lead shell so a just-loaded tile isn't
// dropped before the window scrolls onto it).
OutLo = FIntVector(FloorDiv(W0.X, CHUNK_SIZE) - 1, FloorDiv(W0.Y, CHUNK_SIZE) - 1, FloorDiv(W0.Z, CHUNK_SIZE) - 1);
OutHi = FIntVector(FloorDiv(W0.X + Res - 1, CHUNK_SIZE) + 1, FloorDiv(W0.Y + Res - 1, CHUNK_SIZE) + 1, FloorDiv(W0.Z + Res - 1, CHUNK_SIZE) + 1);
return true;
}
void UVoxelDensityVolume::EvictFarCaptures()
{
if (CaptureCache.Num() == 0) return;
FIntVector TLo, THi;
if (!GetCaptureKeepBounds(TLo, THi)) return;
for (auto It = CaptureCache.CreateIterator(); It; ++It)
{
const FIntVector& T = It.Key();
if (T.X < TLo.X || T.X > THi.X || T.Y < TLo.Y || T.Y > THi.Y || T.Z < TLo.Z || T.Z > THi.Z)
{
It.RemoveCurrent();
}
}
}
bool UVoxelDensityVolume::IsTileCaptureUseful(const FIntVector& L0TileCoord) const
{
FIntVector TLo, THi;
if (!GetCaptureKeepBounds(TLo, THi)) return true; // no window yet → keep (cold start)
return L0TileCoord.X >= TLo.X && L0TileCoord.X <= THi.X
&& L0TileCoord.Y >= TLo.Y && L0TileCoord.Y <= THi.Y
&& L0TileCoord.Z >= TLo.Z && L0TileCoord.Z <= THi.Z;
}
void UVoxelDensityVolume::IngestTileCapture(const FIntVector& L0TileCoord, TArray<uint8>&& Grid)
{
if (!bInitialized || !Settings || !Settings->bEnableDensityVolume) return;
if (Grid.Num() < CHUNK_SIZE * CHUNK_SIZE * CHUNK_SIZE) return;
// Only cache tiles inside the keep bounds (window + lead-shell margin). The level-0 STREAMING ring
// is much larger than the shadow window — most streamed tiles can never blit and would only sit in
// the cache (32 KB each) until the next recenter evicted them. Same policy EvictFarCaptures applies.
// (LoadTile already pre-gates the capture with this test; this re-check is authoritative in case
// the window scrolled while the tile's gen task was in flight.)
if (!IsTileCaptureUseful(L0TileCoord)) return;
// Store (overwrite) — the cache is RecenterLevel(0)'s fill source and survives until the tile
// scrolls out of the window. Blit now so cells already in view refresh immediately (a tile that
// finished after the window exposed it, or a re-mesh after a carve).
TArray<uint8>& Slot = CaptureCache.FindOrAdd(L0TileCoord);
Slot = MoveTemp(Grid);
BlitCaptureToWindow(L0TileCoord, Slot);
}
void UVoxelDensityVolume::EnsureFillThread()
{
if (FillThread) return;
if (bGenerationPaused.load(std::memory_order_acquire)
|| bShuttingDown.load(std::memory_order_acquire)) return;
if (!Settings || !Settings->bEnableDensityVolume) return;
bFillThreadStop.store(false, std::memory_order_release);
if (!FillWakeEvent) { FillWakeEvent = FPlatformProcess::GetSynchEventFromPool(false); } // auto-reset
FillRunnable = new FVoxelDensityFillRunnable(this);
FillThread = FRunnableThread::Create(FillRunnable, TEXT("VoxelDensityFill"), 0, TPri_Normal);
if (!FillThread) // creation failed → don't leak the runnable; fills just won't drain (no crash)
{
delete FillRunnable;
FillRunnable = nullptr;
}
}
void UVoxelDensityVolume::StopFillThread()
{
bFillThreadStop.store(true, std::memory_order_release);
if (FillWakeEvent) { FillWakeEvent->Trigger(); } // wake it so it sees the stop
if (FillThread)
{
FillThread->Kill(true); // calls Stop() + blocks until Run() returns (no more Generator reads)
delete FillThread;
FillThread = nullptr;
}
if (FillRunnable) { delete FillRunnable; FillRunnable = nullptr; }
if (FillWakeEvent) { FPlatformProcess::ReturnSynchEventToPool(FillWakeEvent); FillWakeEvent = nullptr; }
FPendingFill Discard;
while (FillQueue.Dequeue(Discard)) {}
}
void UVoxelDensityVolume::LaunchPendingFills()
{
if (PendingFills.Num() == 0) return;
if (bGenerationPaused.load(std::memory_order_acquire)
|| bShuttingDown.load(std::memory_order_acquire)) return;
EnsureFillThread();
for (FPendingFill& F : PendingFills)
{
FillQueue.Enqueue(MoveTemp(F)); // Spsc: game thread is the only producer
}
PendingFills.Reset();
if (FillWakeEvent) { FillWakeEvent->Trigger(); } // wake the fill thread (after the Enqueues)
}
// RUNS ON THE FILL THREAD. Reads only the Generator (thread-safe, deterministic — same contract as the
// old worker tasks) and pushes the result into the Mpsc Results queue. Step = 1<<Level (never read from
// the game-thread-mutated Levels array). Bails on shutdown so the Generator can be torn down after Kill.
void UVoxelDensityVolume::ProcessOneFill(const FPendingFill& F)
{
UVoxelGenerator* Gen = Generator;
if (!Gen) return;
if (bShuttingDown.load(std::memory_order_relaxed) || bFillThreadStop.load(std::memory_order_relaxed)) return;
const int32 Step = static_cast<int32>(uint32{1}
<< static_cast<uint32>(FMath::Clamp(F.Level, 0, 8)));
FFillResult R;
R.Level = F.Level;
R.Epoch = F.Epoch;
R.MinCells = F.MinCells;
R.DimCells = F.DimCells;
const int32 Count = F.DimCells.X * F.DimCells.Y * F.DimCells.Z;
if (Count <= 0) return;
R.Data.SetNumUninitialized(Count);
int32 i = 0;
for (int32 z = 0; z < F.DimCells.Z; ++z)
{
if (bFillThreadStop.load(std::memory_order_relaxed)) return; // periodic bail on big boxes
const float WZ = (float)((F.MinCells.Z + z) * Step);
for (int32 y = 0; y < F.DimCells.Y; ++y)
{
const float WY = (float)((F.MinCells.Y + y) * Step);
for (int32 x = 0; x < F.DimCells.X; ++x)
{
const float WX = (float)((F.MinCells.X + x) * Step);
R.Data[i++] = Quantize(Gen->GetDensityAt(WX, WY, WZ));
}
}
}
if (bFillThreadStop.load(std::memory_order_relaxed)) return;
Results.Enqueue(MoveTemp(R));
}
void UVoxelDensityVolume::DrainResults()
{
const int32 Res = ResPerAxis();
FFillResult R;
while (Results.Dequeue(R))
{
if (R.Epoch != VolumeEpoch) continue; // stale (regen/season reset) → drop
if (!Levels.IsValidIndex(R.Level)) continue;
FClipLevel& Lv = Levels[R.Level];
if (!Lv.bHasData) continue;
const FIntVector W0 = Lv.OriginCells;
const FIntVector W1 = Lv.OriginCells + FIntVector(Res, Res, Res); // exclusive
int32 i = 0;
for (int32 z = 0; z < R.DimCells.Z; ++z)
{
const int32 cz = R.MinCells.Z + z;
for (int32 y = 0; y < R.DimCells.Y; ++y)
{
const int32 cy = R.MinCells.Y + y;
for (int32 x = 0; x < R.DimCells.X; ++x)
{
const int32 cx = R.MinCells.X + x;
const uint8 v = R.Data[i++];
// Skip cells that scrolled out of the window since launch — their toroidal slot now
// belongs to a different cell (which has its own pending fill). In-window cells own
// their slot, so writing is always correct. (A pre-carve fill landing after the
// carve's own re-fill is a rare 1-frame transient — both sample GetDensityAt incl.
// the diff, so it self-heals on the next refill of that cell.)
if (cx < W0.X || cx >= W1.X || cy < W0.Y || cy >= W1.Y || cz < W0.Z || cz >= W1.Z) continue;
const int32 tx = ((cx % Res) + Res) % Res;
const int32 ty = ((cy % Res) + Res) % Res;
const int32 tz = ((cz % Res) + Res) % Res;
Lv.Density[(tz * Res + ty) * Res + tx] = v;
}
}
}
Lv.bGPUDirty = true; // a fill landed → re-upload this level to the GPU next UploadDirtyTextures
}
}
//=============================================================================
// Carve invalidation
//=============================================================================
void UVoxelDensityVolume::MarkDirtyVoxelBox(const FIntVector& MinVoxel, const FIntVector& MaxVoxel)
{
if (!bInitialized || !Settings || !Settings->bEnableDensityVolume) return;
const int32 Res = ResPerAxis();
const int32 N = Levels.Num();
// Capture invalidation (level 0): the cached grids hold PRE-carve density. Drop the ones the carve
// touched so a later RecenterLevel(0) can't blit stale rock over the edit. The worker fill queued
// below is the backstop until RemeshDirtyChunks re-meshes the tile and re-ingests a fresh (post-
// diff) capture. ±1 tile margin to match the carve-falloff bleed used for the cell box below.
if (CaptureCache.Num() > 0)
{
const FIntVector TLo(FloorDiv(MinVoxel.X, CHUNK_SIZE) - 1, FloorDiv(MinVoxel.Y, CHUNK_SIZE) - 1, FloorDiv(MinVoxel.Z, CHUNK_SIZE) - 1);
const FIntVector THi(FloorDiv(MaxVoxel.X, CHUNK_SIZE) + 1, FloorDiv(MaxVoxel.Y, CHUNK_SIZE) + 1, FloorDiv(MaxVoxel.Z, CHUNK_SIZE) + 1);
for (int32 tz = TLo.Z; tz <= THi.Z; ++tz)
for (int32 ty = TLo.Y; ty <= THi.Y; ++ty)
for (int32 tx = TLo.X; tx <= THi.X; ++tx)
{
CaptureCache.Remove(FIntVector(tx, ty, tz));
}
}
for (int32 L = 0; L < N; ++L)
{
FClipLevel& Lv = Levels[L];
if (!Lv.bHasData) continue;
const int32 Step = Lv.Step;
// Voxel box → cell box, with a ±1 cell margin (carve falloff bleeds past the exact box).
FIntVector CMin(FloorDiv(MinVoxel.X, Step) - 1, FloorDiv(MinVoxel.Y, Step) - 1, FloorDiv(MinVoxel.Z, Step) - 1);
FIntVector CMax(FloorDiv(MaxVoxel.X, Step) + 1, FloorDiv(MaxVoxel.Y, Step) + 1, FloorDiv(MaxVoxel.Z, Step) + 1); // inclusive
// Clip to the level's current window [Origin, Origin+Res).
const FIntVector W0 = Lv.OriginCells;
const FIntVector W1 = Lv.OriginCells + FIntVector(Res, Res, Res); // exclusive
CMin = FIntVector(FMath::Max(CMin.X, W0.X), FMath::Max(CMin.Y, W0.Y), FMath::Max(CMin.Z, W0.Z));
CMax = FIntVector(FMath::Min(CMax.X, W1.X - 1), FMath::Min(CMax.Y, W1.Y - 1), FMath::Min(CMax.Z, W1.Z - 1));
if (CMax.X < CMin.X || CMax.Y < CMin.Y || CMax.Z < CMin.Z) continue; // no overlap with this level
QueueFillSplit(L, CMin, FIntVector(CMax.X - CMin.X + 1, CMax.Y - CMin.Y + 1, CMax.Z - CMin.Z + 1));
}
}
//=============================================================================
// Debug visualization (step 1a verification — no GPU)
//=============================================================================
#if ENABLE_DRAW_DEBUG
void UVoxelDensityVolume::DebugDraw() const
{
if (!Settings || !Settings->bDebugDrawDensityVolume) return;
AActor* O = Owner.Get();
if (!O || Levels.Num() == 0) return;
const FClipLevel& Lv = Levels[0]; // level 0 = step 1 → cell coord == voxel coord
if (!Lv.bHasData) return;
UWorld* W = O->GetWorld();
if (!W) return;
const int32 Res = ResPerAxis();
const FTransform Xf = O->GetActorTransform();
const int32 R = FMath::Clamp(Settings->DensityVolumeDebugRadiusCells, 1, 32);
// Draw a THIN horizontal slab through the player (not a full 3D ball) — far cheaper and it reads
// as the cave silhouette around you. A full sphere of DrawDebugBox is thousands of boxes/frame =
// tens of thousands of line segments → big game-thread lag. The volume itself is off-thread.
const int32 ZBand = 2; // ±2 cells (5 layers) around the player
const FIntVector PC = LastPlayerVoxel; // level-0 cell == voxel
const FIntVector W0 = Lv.OriginCells;
const FIntVector W1 = Lv.OriginCells + FIntVector(Res, Res, Res);
const float Half = VOXEL_SIZE * 0.5f;
int32 Drawn = 0;
const int32 Cap = 4000; // bound the debug-draw cost
for (int32 dz = -ZBand; dz <= ZBand; ++dz)
for (int32 dy = -R; dy <= R; ++dy)
for (int32 dx = -R; dx <= R; ++dx)
{
const int32 cx = PC.X + dx, cy = PC.Y + dy, cz = PC.Z + dz;
if (cx < W0.X || cx >= W1.X || cy < W0.Y || cy >= W1.Y || cz < W0.Z || cz >= W1.Z) continue;
const int32 tx = ((cx % Res) + Res) % Res;
const int32 ty = ((cy % Res) + Res) % Res;
const int32 tz = ((cz % Res) + Res) % Res;
if (Lv.Density[(tz * Res + ty) * Res + tx] <= 128) continue; // air (iso ≈ 128)
const FVector LocalCenter((cx + 0.5f) * VOXEL_SIZE, (cy + 0.5f) * VOXEL_SIZE, (cz + 0.5f) * VOXEL_SIZE);
const FVector WorldC = Xf.TransformPosition(LocalCenter);
DrawDebugBox(W, WorldC, FVector(Half), FColor::Cyan, false, -1.0f, 0, 1.0f);
if (++Drawn >= Cap) return;
}
}
#endif