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
239 lines
14 KiB
C++
239 lines
14 KiB
C++
// VoxelDensityVolume.h
|
|
// Player-centred DENSITY CLIPMAP — the GPU-bound prerequisite for the mini-sun raymarched
|
|
// shadow system (forward rendering). Density is CPU-only (UVoxelGenerator::GetDensityAt), so to
|
|
// shadow-march on the GPU we stream the density field into a clipmap of 3D textures centred on
|
|
// the player: fine near, coarse far — exactly what shadow rays want (the crisp edge lives near
|
|
// the shaded surface; far along the ray, coarse is invisible).
|
|
//
|
|
// FORMAT (committed): single-channel R8 storing QUANTIZED SIGNED density (solid = high, air =
|
|
// low, iso at ~0.5), trilinear-filterable so the iso crossing stays sub-voxel crisp. A "solidity"
|
|
// MIP pyramid (max-downsample) is built for empty-space skipping in the march. NOT a true SDF:
|
|
// digging is the core verb, and an SDF would need re-distancing (JFA / Eikonal) on every carve &
|
|
// streaming refill, whereas the mip pyramid just re-maxes a few blocks — trivially correct + local.
|
|
//
|
|
// CLIPMAP MODEL: N levels. Level L samples every (1<<L) voxels and holds a Res³ grid of CELLS,
|
|
// TOROIDALLY addressed (data for cell C lives at C mod Res), so recentring on movement only needs
|
|
// to refill the newly-exposed slabs — not the whole volume. A carve marks its voxel box dirty →
|
|
// the overlapping cells refill locally.
|
|
//
|
|
// THREADING: fills run on ONE DEDICATED thread (FVoxelDensityFillRunnable, off the UE::Tasks pool —
|
|
// the old BackgroundLow pool path STARVED behind mesh-gen, ~10 s to resolve). The game thread enqueues
|
|
// FPendingFill (Spsc FillQueue) + triggers FillWakeEvent; the thread re-evaluates GetDensityAt
|
|
// (thread-safe, deterministic — READS the Generator only, checks bFillThreadStop) and returns filled
|
|
// sub-boxes via the Mpsc Results queue; the game thread writes them into the toroidal arrays. Each fill
|
|
// carries a VolumeEpoch — stale results (after a regen/season reset) are discarded. CAPTURE-DURING-
|
|
// MESHING (level 0) short-circuits most fills: the mesher's already-sampled grid is cached by tile
|
|
// coord and blitted on the game thread, so the dedicated thread is only the BACKSTOP (cache misses:
|
|
// vertical strate gaps, cold start, carves). EndPlay → NotifyShutdown → StopFillThread Kill(true)s the
|
|
// thread (blocks until it stops reading the Generator) before UObject teardown. Determinism preserved
|
|
// (GetDensityAt + the diff layer, the only non-deterministic overlay, same as the terrain).
|
|
//
|
|
// STATUS: step 1a = CPU clipmap + worker fills + toroidal streaming + carve dirty + a DEBUG-DRAW
|
|
// visualization (no GPU yet). Step 1b adds the Texture3D upload + the in-material Custom-HLSL march.
|
|
// The GPU-upload seam is marked below (UploadDirtyRegionsToGPU).
|
|
|
|
#pragma once
|
|
|
|
#include "CoreMinimal.h"
|
|
#include "Containers/Queue.h"
|
|
#include "VoxelTypes.h"
|
|
// ⚠️ IWYU, ET CELUI-CI EST PIÉGEUX : `ENABLE_DRAW_DEBUG` est utilisé en `#if` plus bas. Un macro
|
|
// NON DÉFINI vaut 0 dans un `#if` — donc sans cet include le bloc de debug disparaît EN SILENCE au
|
|
// lieu de provoquer une erreur de compilation. Il venait du PCH partagé ; `FPSemantics = Precise`
|
|
// (AUDIT §C9) nous en prive. Défini par DrawDebugHelpers.h (vérifié dans UE 5.7).
|
|
// An UNDEFINED macro evaluates to 0 in an #if, so without this include the debug block vanishes
|
|
// SILENTLY instead of failing the build. Defined by DrawDebugHelpers.h (verified in UE 5.7).
|
|
#include "DrawDebugHelpers.h"
|
|
#include <atomic>
|
|
#include "VoxelDensityVolume.generated.h"
|
|
|
|
class AActor; // IWYU : pointeur / TWeakObjectPtr seulement / pointer-only
|
|
class UVoxelGenerator;
|
|
class UVoxelSettings;
|
|
class UVolumeTexture;
|
|
class FVoxelDensityFillRunnable; // dedicated fill thread (VoxelDensityVolume.cpp)
|
|
class FRunnableThread;
|
|
class FEvent;
|
|
|
|
UCLASS()
|
|
class VOXELFORGE_API UVoxelDensityVolume : public UObject
|
|
{
|
|
GENERATED_BODY()
|
|
|
|
public:
|
|
/** Wire up services. Owner is the AVoxelWorld actor (its transform maps world↔voxel, same as the
|
|
* content manager); Generator supplies GetDensityAt; Settings supplies the clipmap tunables. */
|
|
void Initialize(AActor* InOwner, UVoxelGenerator* InGenerator, UVoxelSettings* InSettings);
|
|
|
|
/** Each Tick: recentre the clipmap on the player, queue fills for newly-exposed cells + any
|
|
* carve-dirtied cells, launch them under the task budget, and drain finished fills. Cheap when
|
|
* the player hasn't crossed a level-0 cell boundary and nothing is dirty. */
|
|
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
|
|
* clipmap cells next Update. GetDensityAt already includes the diff layer, so re-sampling
|
|
* picks the edit up. Cheap + local. */
|
|
void MarkDirtyVoxelBox(const FIntVector& MinVoxel, const FIntVector& MaxVoxel);
|
|
|
|
/** CAPTURE-DURING-MESHING (level-0 only). The mesher already sampled this level-0 tile's density
|
|
* grid while building its mesh; instead of re-evaluating GetDensityAt in a worker fill, we reuse
|
|
* those samples. Grid = CHUNK_SIZE³ R8 (X-fast, then Y, then Z), quantized by VF_QuantizeDensity
|
|
* (bit-identical to a worker fill). Stored in CaptureCache keyed by tile coord and blitted into
|
|
* level 0's toroidal window now (immediate freshness) + on RecenterLevel(0) (so a window scroll
|
|
* fills from the cache, not a re-sample). The worker fill stays as the backstop for cache misses
|
|
* (vertical strate gaps, cold start, evicted tiles). Game-thread only (called from
|
|
* AVoxelWorld::ProcessPendingChunks). Moves Grid. */
|
|
void IngestTileCapture(const FIntVector& L0TileCoord, TArray<uint8>&& Grid);
|
|
|
|
/** True if a level-0 tile's capture could be consumed (inside the shadow window + lead-shell
|
|
* margin, or no window yet). AVoxelWorld checks this BEFORE asking the mesher to capture, so the
|
|
* many level-0 tiles streaming outside the small shadow window don't pay the quantize+copy for a
|
|
* grid IngestTileCapture would refuse anyway. Game-thread only. */
|
|
bool IsTileCaptureUseful(const FIntVector& L0TileCoord) const;
|
|
|
|
/** Season reset / full regen: bump the epoch (drops in-flight fills), drop all data, force a
|
|
* full refill on the next Update. */
|
|
void Reset();
|
|
|
|
/** EndPlay: flag shutdown + spin-wait for in-flight fills (they read the Generator) before the
|
|
* owner tears UObjects down. */
|
|
void NotifyShutdown();
|
|
|
|
virtual void BeginDestroy() override;
|
|
|
|
#if ENABLE_DRAW_DEBUG
|
|
/** Step-1a visual check: draw boxes for solid level-0 cells near the player (gated + capped). */
|
|
void DebugDraw() const;
|
|
#endif
|
|
|
|
//--- GPU accessors (step 1b: the material march / debug visualization sample these) ----------
|
|
/** The R8 volume texture for a clip level (null if GPU upload is off / not yet created). */
|
|
UVolumeTexture* GetLevelTexture(int32 Level) const;
|
|
|
|
/** Shader params for a level: OriginCells (min cell coord), Step (voxels/cell), Res (cells/axis).
|
|
* The material maps WorldPos → local voxel → cell C = floor(localVoxel/Step), then samples at
|
|
* UVW = (C + 0.5)/Res with WRAP addressing (the texture is toroidal). Returns false if the level
|
|
* has no data yet. (Feeds the MPC in 1b-ii.) */
|
|
bool GetLevelShaderParams(int32 Level, FIntVector& OutOriginCells, float& OutStep, int32& OutRes) const;
|
|
|
|
private:
|
|
// One concentric clip level. Step = 1<<L voxels; the grid covers Res cells (= Res*Step voxels).
|
|
struct FClipLevel
|
|
{
|
|
int32 Step = 1; // voxel sampling step (1<<L)
|
|
FIntVector OriginCells = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); // min CELL coord; sentinel = no data
|
|
TArray<uint8> Density; // Res³ R8, toroidally addressed
|
|
bool bHasData = false;
|
|
bool bGPUDirty = false; // CPU data changed → re-upload the texture
|
|
};
|
|
|
|
// A fill request (game-thread queue, drained under the task budget). Box is in CELL coords.
|
|
struct FPendingFill
|
|
{
|
|
int32 Level = 0;
|
|
FIntVector MinCells = FIntVector::ZeroValue;
|
|
FIntVector DimCells = FIntVector::ZeroValue;
|
|
uint32 Epoch = 0;
|
|
};
|
|
|
|
// Worker → game-thread result: one filled sub-box, linear row-major (X fastest, then Y, then Z).
|
|
struct FFillResult
|
|
{
|
|
int32 Level = 0;
|
|
uint32 Epoch = 0;
|
|
FIntVector MinCells = FIntVector::ZeroValue;
|
|
FIntVector DimCells = FIntVector::ZeroValue;
|
|
TArray<uint8> Data;
|
|
};
|
|
|
|
void EnsureAllocated();
|
|
int32 ResPerAxis() const; // clamped Settings->DensityVolumeResolution
|
|
int32 NumLevels() const; // clamped Settings->DensityVolumeLevels
|
|
|
|
// Recentre one level on the player voxel; push fills for the slabs that scrolled into view.
|
|
void RecenterLevel(int32 L, const FIntVector& PlayerVoxel);
|
|
// new\old box subtraction → up to 6 disjoint cell-boxes (the toroidal slabs to refill).
|
|
static void BoxDifference(const FIntVector& NewMin, const FIntVector& NewDim,
|
|
const FIntVector& OldMin, const FIntVector& OldDim,
|
|
TArray<TPair<FIntVector, FIntVector>>& OutBoxes);
|
|
// Split a cell-box into Z-slabs + enqueue as FPendingFills (so no single task is huge).
|
|
void QueueFillSplit(int32 L, const FIntVector& MinCells, const FIntVector& DimCells);
|
|
|
|
// CAPTURE-DURING-MESHING (level 0). Fill a newly-exposed cell box from the capture cache where a
|
|
// tile is present (game-thread memcpy, no GetDensityAt); queue a worker fill for the rest (backstop).
|
|
void FillBoxFromCacheOrQueue(const FIntVector& MinCells, const FIntVector& DimCells);
|
|
// Write a cached tile's in-window cells into level 0's toroidal array (+ GPU dirty). Idempotent.
|
|
bool BlitCaptureToWindow(const FIntVector& L0TileCoord, const TArray<uint8>& Grid);
|
|
// The tile-coord box worth caching: level 0's window +1 tile margin (the lead shell). False = no window yet.
|
|
bool GetCaptureKeepBounds(FIntVector& OutLo, FIntVector& OutHi) const;
|
|
// Drop cache entries whose tile is fully outside the keep bounds.
|
|
void EvictFarCaptures();
|
|
void LaunchPendingFills(); // flush PendingFills onto the dedicated fill thread
|
|
void DrainResults(); // apply finished fills into the toroidal arrays (+ GPU dirty)
|
|
|
|
// DEDICATED FILL THREAD. The volume fill used to run on the shared UE::Tasks pool (BackgroundLow),
|
|
// where it STARVED behind mesh-gen (10 s to resolve shadows at a fresh spot). It now runs on its own
|
|
// thread (off the pool) so it's fast AND never steals a core from mesh-gen. Game thread enqueues
|
|
// FPendingFill (Spsc), the thread re-evaluates GetDensityAt and pushes FFillResult into Results
|
|
// (existing Mpsc, drained on the game thread by DrainResults). Capture still short-circuits most of
|
|
// this (cache hits blit on the game thread, no fill); the thread is the backstop for misses.
|
|
friend class FVoxelDensityFillRunnable;
|
|
void ProcessOneFill(const FPendingFill& F); // RUNS ON THE FILL THREAD (reads Generator only)
|
|
void EnsureFillThread();
|
|
void StopFillThread();
|
|
|
|
// GPU upload (step 1b-i). EnsureTextures (re)creates the per-level R8 volume textures when the
|
|
// resolution / level count changes; UploadDirtyTextures enqueues a render command per dirty level
|
|
// that RHIUpdateTexture3D's the whole level from the CPU array (the array IS the toroidal texture
|
|
// layout, so a full re-upload is correct without wrap-splitting; sub-box upload is a later optim).
|
|
void EnsureTextures();
|
|
void UploadDirtyTextures();
|
|
|
|
static FORCEINLINE uint8 Quantize(float MCDensity); // MC density (neg=solid) → R8 (solid=high)
|
|
static FORCEINLINE int32 FloorDiv(int32 A, int32 B); // true floor division (B>0)
|
|
|
|
TWeakObjectPtr<AActor> Owner;
|
|
|
|
UPROPERTY()
|
|
UVoxelGenerator* Generator = nullptr;
|
|
|
|
UPROPERTY()
|
|
UVoxelSettings* Settings = nullptr;
|
|
|
|
TArray<FClipLevel> Levels;
|
|
int32 AllocatedRes = 0; // resolution the arrays were sized for (realloc on change)
|
|
|
|
// Per-level R8 volume textures (GPU). UPROPERTY so they're GC-kept; contents updated via RHI.
|
|
UPROPERTY()
|
|
TArray<TObjectPtr<UVolumeTexture>> LevelTextures;
|
|
int32 AllocatedTexRes = 0; // resolution the textures were created at (recreate on change)
|
|
|
|
TArray<FPendingFill> PendingFills; // cell-boxes staged on the game thread, flushed to FillQueue
|
|
TQueue<FPendingFill, EQueueMode::Spsc> FillQueue; // game thread → fill thread
|
|
TQueue<FFillResult, EQueueMode::Mpsc> Results; // fill thread enqueues, game thread drains
|
|
|
|
// Dedicated fill thread handles (see EnsureFillThread / StopFillThread / ProcessOneFill).
|
|
FVoxelDensityFillRunnable* FillRunnable = nullptr;
|
|
FRunnableThread* FillThread = nullptr;
|
|
FEvent* FillWakeEvent = nullptr;
|
|
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
|
|
// IngestTileCapture (free — the mesher already sampled it), consumed by RecenterLevel(0) to fill
|
|
// exposed cells without re-sampling. Game-thread only; evicted by window distance (EvictFarCaptures).
|
|
TMap<FIntVector, TArray<uint8>> CaptureCache;
|
|
|
|
std::atomic<bool> bShuttingDown{ false };
|
|
uint32 VolumeEpoch = 1; // bumped on Reset → stale fills discarded
|
|
|
|
FIntVector LastPlayerVoxel = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
|
|
bool bInitialized = false;
|
|
};
|