Files
VoxelForge/Source/VoxelForge/Public/VoxelWorld.h
T
Fr0zkaandClaude Opus 5.5 85a803b873 wip(streaming): LOD rings tile exactly once, cache race, reconcile 104x -- gate stress FAILS 3/4
- Clipmap: finer rings snap to child-pair boundaries, so no two desired tiles of different
  levels overlap. Owner config 635 overlapping pairs -> 0; LOD0 ring 343 -> 512 tiles.
  New test sweeps 6,174 cases; fails on 7486c29 as intended, passes here.
- Shared room-graph cache: Entry->Cache read/written only under the mutex (race fix).
- ReconcileReadyTransitionVisibility indexed: moving p95 32.0 ms -> 0.31 ms.
- Block-vs-scalar density equivalence test: 5.1M samples bit-identical; catches an injected
  optimistic EffectOverBox (5,612 mismatches).

UNVALIDATED for merge: collision-gate stress passed 1 of 4 runs on this code (fails at 4.56 m,
support collision unready at start); it passed on 7486c29. No control run yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ
2026-09-25 02:47:54 +02:00

1286 lines
69 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.
// VoxelWorld.h
// The main world manager - orchestrates chunk loading, generation, meshing, and rendering
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "HAL/CriticalSection.h"
#include <atomic>
#include "VoxelTypes.h"
#include "VoxelGenerator.h"
#include "VoxelMarchingCubesMesher.h"
#include "VoxelSettings.h"
#include "VoxelStrateManager.h"
#include "VoxelDiffLayer.h"
#include "VoxelWorld.generated.h"
// Forward declaration
class URealtimeMeshComponent;
class URealtimeMesh;
class URealtimeMeshSimple;
class UBodySetup;
class APawn;
class UPawnMovementComponent;
class UCharacterMovementComponent;
class UVoxelDiffLayer;
class UVoxelContentManager;
class UVoxelAtmosphereManager;
class UVoxelDensityVolume;
class UMaterialParameterCollection;
class UVolumeTexture;
class UMaterialInterface;
class UMaterialInstanceDynamic;
class FScopedGenerationPause;
class ACameraActor;
namespace RealtimeMesh { struct FRealtimeMeshStreamSet; } // T1.f — worker-built geometry buffers
/** Per-request cancellation state. The game thread flips the flag when a pending tile leaves the
* desired set; workers only read it and return an aborted result through the normal MPSC queue. */
struct FVoxelTileCancellationState
{
std::atomic<bool> bObsolete{false};
};
/**
* How a streaming anchor wants its tiles built (ARCHITECTURE §9.3). The local player is an implicit
* FullVisual anchor (the clipmap). Extra anchors — AI, and later remote players — are registered so
* their surroundings stream too (so they physically exist away from the local camera).
*
* `CollisionOnly` tiles cook collision but are hidden (`SetVisibility(false)`) — no draw / VSM cost —
* UNLESS the player clipmap also wants that exact level-0 tile (then it renders normally). This is the
* §9.4 render-skip: it kills the cost of terrain around AI/remote players far from the local camera.
* (Geometry streams are still built on the worker — off the frame; a deeper "no stream build at all"
* collision-only path is a later optimization.)
*/
UENUM(BlueprintType)
enum class EVoxelAnchorPolicy : uint8
{
CollisionOnly UMETA(DisplayName = "Collision Only"), // enough to stand / pathfind / hit
FullVisual UMETA(DisplayName = "Full Visual") // rendered too (a second local viewpoint)
};
/** A non-player streaming center: keep a small box of level-0 tiles loaded around Actor so it has
* collision wherever it is. Plain internal struct (weak ptr is GC-safe without reflection). */
struct FVoxelStreamingAnchor
{
TWeakObjectPtr<AActor> Actor;
EVoxelAnchorPolicy Policy = EVoxelAnchorPolicy::CollisionOnly;
// Thin box around the anchor (level-0 chunks). Default = the chunk it's in + 1 on each horizontal
// edge (3×3) + 1 chunk BELOW (ground safety when its capsule sits near a chunk's bottom); nothing
// above (a grounded NPC doesn't need it — raise ZAbove for flyers). Empty tiles in the box are ~free.
int32 XYRadiusChunks = 1; // horizontal Chebyshev radius (0 = its own column)
int32 ZBelowChunks = 1; // chunks below the anchor's chunk
int32 ZAboveChunks = 0; // chunks above
FIntVector LastChunk = FIntVector(MAX_int32, MAX_int32, MAX_int32); // move detection (rebuild trigger)
};
/**
* AVoxelWorld - The main voxel terrain actor
*
* RESPONSIBILITIES:
* - Track which chunks should be loaded based on player position
* - Create/destroy chunks as player moves
* - Coordinate generation and meshing
* - Manage mesh components for rendering
*
* LIFECYCLE:
* - BeginPlay: Initialize generator, mesher
* - Tick: Update chunks around player position
*/
enum class EVoxelTileWorkPriority : uint8
{
Horizon = 0,
LOD0 = 1,
Edited = 2,
CollisionCritical = 3
};
struct FChunkResult
{
FVoxelTileKey Tile; // which clipmap tile this mesh is for (carries coord + level)
// T1.f: the RMC geometry buffers are BUILT ON THE WORKER (BuildTileStreamSet in the gen task)
// so the game thread only uploads them — the per-vertex builder loop was the dominant
// game-thread cost while moving (the apply drain). TSharedPtr (not a by-value StreamSet) so
// FChunkResult stays movable through the MPSC queue with the type only FORWARD-DECLARED here.
// Null ⇒ no-surface tile (uniform all-air/all-solid or an empty content band; no component).
TSharedPtr<RealtimeMesh::FRealtimeMeshStreamSet> Streams;
uint32 Epoch = 0; // Generation epoch — discard if stale
// Monotonic request timestamp used for the low-overhead streaming latency summary and the
// opt-in profile. It is carried through the worker queue so the report can separate scheduler
// wait from generation time.
uint64 RequestStartCycles = 0;
// Worker timing boundaries for separating scheduler wait from generation and result/apply wait.
// These are captured with low overhead for the clean streaming-latency summary; expensive
// per-tile profiling remains opt-in.
uint64 GenerationStartCycles = 0;
uint64 GenerationEndCycles = 0;
uint64 ApplyStartCycles = 0;
EVoxelTileWorkPriority WorkPriority = EVoxelTileWorkPriority::Horizon;
int32 ClassifyVerdict = -1; double ClassifySeconds = 0.0; double MeshSeconds = 0.0; double StreamSeconds = 0.0; int32 NumTriangles = 0; int32 NumCeilingTriangles = 0;
int32 NumVertices = 0;
uint64 GeometryBytes = 0;
bool bSealedSolidProof = false; // true when the cheap gap/seal proof skipped meshing
bool bOutOfLayoutAirProof = false; // true when the exact constant-air guard skipped meshing
// Clean, low-overhead work counters. These are carried with the result so the worker can
// count exact validation and mesher density calls locally, then publish one aggregate update.
int64 ValidationDensityCalls = 0;
int64 MesherDensityCalls = 0;
bool bAborted = false; // Worker observed shutdown or cancellation; never mark this tile loaded
bool bObsolete = false; // Worker observed per-tile desired-set cancellation
uint32 DesiredEpoch = 0; // Desired-set epoch captured at request time
uint32 ModificationEpoch = 0; // Diff-layer revision captured at request time
bool bRecordStreamingLatency = true;
bool bEmpty = true; // true ⇒ no surface (uniform all-air/all-solid or empty band); still loaded
// F17 — the mesher classifies every triangle semantically (sky-cap = down-facing near the
// column's CeilSurf; overhangs/cave roofs stay ground) and packs them as two contiguous runs
// (ground then cap) in the index buffer → polygroups 0/1 → two RMC sections with their own
// material + shadow flag. These tell the apply path which sections exist (RMC only creates a
// section for a non-empty polygroup — configuring a missing one is invalid).
bool bHasGroundTris = false;
bool bHasCeilingTris = false;
// STRATE CONTENT CUT — the chunk-Z band this tile was MESHED with (MIN/MAX = uncut). The
// apply path clamps its strate/material lookups into it (a coarse tile's raw min corner can
// sit in a strate whose content was cut out of the mesh entirely).
int32 BandChunkLo = MIN_int32;
int32 BandChunkHi = MAX_int32;
// CAPTURE-DURING-MESHING: the tile's CHUNK_SIZE³ R8 density grid, captured by the mesher (no extra
// GetDensityAt). Non-empty only for capture-eligible tiles (level 0, full-res). The game thread hands
// it to UVoxelDensityVolume::IngestTileCapture so the density clipmap reuses the mesher's samples
// instead of re-sampling. Moved (not copied) through the MPSC queue. See UVoxelDensityVolume.
TArray<uint8> CaptureGrid;
};
/** One worker-produced mesh identity emitted only by the explicit parity harness. */
struct FVoxelTileHashDumpRecord
{
FVoxelTileKey Tile;
FIntVector OriginVoxels = FIntVector::ZeroValue;
int32 Step = 1;
int32 Cells = 0;
int32 BandChunkLo = MIN_int32;
int32 BandChunkHi = MAX_int32;
bool bSheetTile = false;
bool bEmpty = true;
int32 NumTriangles = 0;
FString GeometryHash;
};
/** Resident CPU geometry payload associated with one currently loaded tile. */
struct FLoadedTileGeometryStats
{
uint64 Vertices = 0;
uint64 Triangles = 0;
uint64 GeometryBytes = 0;
};
UCLASS()
class VOXELFORGE_API AVoxelWorld : public AActor
{
GENERATED_BODY()
public:
AVoxelWorld();
//=========================================================================
// SETTINGS
//=========================================================================
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel World")
UVoxelSettings* Settings;
//=========================================================================
// COMPONENTS & REFERENCES
//=========================================================================
/** The terrain generator */
UPROPERTY()
UVoxelGenerator* Generator;
/** The mesh builder */
UPROPERTY()
UVoxelMarchingCubesMesher* Mesher;
/** The strate manager — maps depth to strate definitions */
UPROPERTY()
UVoxelStrateManager* StrateManager;
/** Player terrain modifications (carving & filling).
* Stores density diffs on top of procedural terrain.
* Created in BeginPlay, passed to Generator for density evaluation. */
UPROPERTY()
UVoxelDiffLayer* DiffLayer;
/** Spawns per-chunk decorations/actors from the strate content pools and the
* aesthetic water surfaces. Created in BeginPlay. */
UPROPERTY()
UVoxelContentManager* ContentManager;
/** Drives per-strate fog + ambient + persistent ceiling/floor layer actors
* (e.g. seas of clouds) based on the strate the player is in. Created in BeginPlay
* when bManageAtmosphere is true. */
UPROPERTY()
UVoxelAtmosphereManager* AtmosphereManager;
/** Player-centred density CLIPMAP streamed onto the GPU for the mini-sun raymarched shadow
* system (forward rendering). Created in BeginPlay when Settings->bEnableDensityVolume is on.
* Filled on worker threads (re-evaluating GetDensityAt), recentred toroidally as the player
* moves, refilled locally on carve. See UVoxelDensityVolume. */
UPROPERTY()
UVoxelDensityVolume* DensityVolume;
/** Shared Material Instance Dynamics that bind the density-volume textures + per-frame shadow params
* (clipmap transform + nearest orb) onto the terrain material(s). Keyed by BASE material so every
* tile of a given base shares ONE MID (no batching cost). Created lazily in ApplyMeshToTile,
* refreshed each Tick by UpdateTerrainMaterialParams. */
UPROPERTY()
TMap<TObjectPtr<UMaterialInterface>, TObjectPtr<UMaterialInstanceDynamic>> TerrainMIDs;
/** When true, VoxelForge spawns & drives its own height fog + skylight + ceiling/floor
* layer actors from each strate's settings. Turn OFF if you manage fog/lighting
* yourself in the level (avoids a duplicate ExponentialHeightFog). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel World")
bool bManageAtmosphere = true;
//=========================================================================
// CHUNK STORAGE
//=========================================================================
//=========================================================================
// CLIPMAP TILE STORAGE (chunked-LOD)
//=========================================================================
// A level-L tile spans (CHUNK_SIZE<<L) voxels meshed at step (1<<L) → constant 32³-cell
// mesh, one component, one draw, covering 8^L× the volume. Streaming loads concentric
// shells (level 0 near, coarser far), so total tile count stays low (~1-2k) regardless
// of view distance. That low count is WHY each tile can have its own component without a
// game-thread problem (this supersedes the earlier region-batching). Collision + content
// are level-0 only.
/** Tiles whose result was successfully applied — INCLUDING empty/all-air tiles, so we never
* re-submit them. A non-empty level-0 tile is not collision-ready until its RMC cook callback
* has added it to CollisionReadyTiles. */
TSet<FVoxelTileKey> LoadedTiles;
/** Level-0 tiles whose latest submitted RMC collision body has finished cooking and was
* installed. This is the authoritative collision-readiness set used by the pawn gate; mesh
* submission and LoadedTiles are intentionally not sufficient. */
TSet<FVoxelTileKey> CollisionReadyTiles;
/** Level-0 tiles whose applied result is known to contain no surface (uniform empty/solid).
* They need no BodySetup themselves, but the pawn gate may walk past them while looking for
* the first resolved collidable support tile. */
TSet<FVoxelTileKey> CollisionNotRequiredTiles;
/** Subset of CollisionNotRequiredTiles proved all-solid. A support probe inside one of these
* tiles searches upward for the boundary tile; all-air/empty tiles search downward. */
TSet<FVoxelTileKey> CollisionSolidTiles;
struct FPendingCollisionCook
{
TWeakObjectPtr<URealtimeMesh> Mesh;
uint64 SubmissionId = 0;
uint64 MeshSubmittedCycles = 0;
uint64 CollisionSubmittedCycles = 0;
bool bBodyUpdatedEventSeen = false;
uint64 BodyUpdatedEventCycles = 0;
// Game-thread-only timing copied from the generation result for the clean LOD0 summary.
uint64 RequestStartCycles = 0;
uint64 GenerationStartCycles = 0;
uint64 GenerationEndCycles = 0;
uint64 ApplyStartCycles = 0;
bool bRecordStreamingLatency = false;
};
/** One serialised completion record per tile. The serial prevents a late RMC future from
* making a remeshed tile, or a pooled component reused by another tile, appear ready. */
TMap<FVoxelTileKey, FPendingCollisionCook> PendingCollisionCooks;
uint64 NextCollisionSubmissionId = 0;
TMap<URealtimeMesh*, FDelegateHandle> CollisionBodyUpdatedHandles;
/** Render component per NON-empty loaded tile (GC-safe via actor ownership; not UPROPERTY
* because FVoxelTileKey isn't a USTRUCT key). */
TMap<FVoxelTileKey, URealtimeMeshComponent*> TileComponents;
/** T2.c — COMPONENT POOL. Unloading a tile parks its component here (geometry + collision
* stripped via RemoveSectionGroup, hidden, still registered) instead of DestroyComponent;
* ApplyMeshToTile pops from here instead of NewObject + RegisterComponent. Kills the
* create/register/GC churn of fast travel and regen bursts. Same GC-safety rationale as
* TileComponents (registered components are owned by the actor). Bounded — overflow is
* destroyed for real. */
TArray<URealtimeMeshComponent*> TileComponentPool;
static constexpr int32 MaxPooledTileComponents = 256;
/** Pop a pooled tile component (made visible again) or create + register a fresh one. */
URealtimeMeshComponent* AcquireTileComponent(
double* OutCreationSeconds = nullptr,
double* OutRegistrationSeconds = nullptr,
bool* bOutCreated = nullptr);
/** Park a tile component in the pool (strip geometry/collision, hide) — or destroy it
* for real when the pool is full. */
void ReleaseTileComponent(URealtimeMeshComponent* Comp);
/** T2.d — the effective concurrent gen-task budget: the asset's MaxConcurrentTasks,
* capped to (logical cores − 2) so small CPUs don't thrash on a flat 16 (background
* priority stops frame starvation, not the context-switch overhead). */
int32 GetMaxConcurrentTasks() const;
/** Reserve task slots for collision-critical/edit/LOD0 work; far work uses the remainder. */
int32 GetNearTaskReserve(int32 MaxTasks) const;
//=========================================================================
// TERRAIN MODIFICATION (player carving & filling)
//=========================================================================
/**
* Carve terrain at a world position (remove rock, create air).
* Applies a spherical brush that subtracts density.
*
* @param Position - World-space center of the carve brush
* @param Radius - Brush radius in voxels (default 3)
* @param Strength - How aggressively to carve (default 10, higher = deeper)
*/
UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification")
void CarveAtPosition(FVector Position, float Radius = 3.0f, float Strength = 10.0f);
/**
* Fill terrain at a world position (add rock, seal holes).
* Applies a spherical brush that adds density.
*
* @param Position - World-space center of the fill brush
* @param Radius - Brush radius in voxels (default 3)
* @param Strength - How aggressively to fill (default 10, higher = more solid)
*/
UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification")
void FillAtPosition(FVector Position, float Radius = 3.0f, float Strength = 10.0f);
/**
* Box brush carve/fill. Position is world-space (Unreal units); ExtentVoxels is the
* box half-size in voxels. Strength magnitude controls aggressiveness (sign forced).
*/
UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification")
void CarveBox(FVector Position, FVector ExtentVoxels, float Strength = 12.0f);
UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification")
void FillBox(FVector Position, FVector ExtentVoxels, float Strength = 12.0f);
/**
* Capsule brush carve/fill between two world-space points (Unreal units), with a
* tube radius in voxels. Great for boring tunnels or laying solid pillars/walls.
*/
UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification")
void CarveCapsule(FVector WorldA, FVector WorldB, float RadiusVoxels = 3.0f, float Strength = 12.0f);
UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification")
void FillCapsule(FVector WorldA, FVector WorldB, float RadiusVoxels = 3.0f, float Strength = 12.0f);
/**
* Apply a fully-specified modification (any shape). Centers/endpoints are expected
* in VOXEL coordinates here (this is the low-level entry the helpers build on).
* Returns nothing; re-meshes affected chunks.
*/
UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification")
void ApplyModification(const FVoxelModification& Modification);
/**
* Clear all player modifications (e.g., on season reset).
* Regenerates all currently loaded chunks to restore procedural terrain.
*/
UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification")
void ClearAllModifications();
//=========================================================================
// SEED / SEASON MANAGEMENT
//=========================================================================
/**
* Change the world seed and regenerate everything.
*
* This is the "season reset" operation:
* 1. Updates the seed in Settings, Generator, and StrateManager
* 2. Clears ALL player modifications (carvings are meaningless in a new world)
* 3. Resets the elevator depth (player must re-discover strates)
* 4. Increments the season counter
* 5. Unloads all chunks and lets Tick reload them with the new seed
*
* The game layer is responsible for calling this at season boundaries,
* saving/loading the season counter, and any pre-reset cleanup (inventory, etc.)
*
* @param NewSeed - The new world seed
*/
UFUNCTION(BlueprintCallable, Category = "Voxel World|Season")
void ChangeSeed(int32 NewSeed);
/**
* Get the current world seed.
*/
UFUNCTION(BlueprintPure, Category = "Voxel World|Season")
int32 GetCurrentSeed() const;
/**
* Get the current season number (incremented each time ChangeSeed is called).
*/
UFUNCTION(BlueprintPure, Category = "Voxel World|Season")
int32 GetCurrentSeason() const;
/** Reviewed manifest digest to replicate/compare at join; empty on the legacy authored path. */
UFUNCTION(BlueprintPure, Category = "Voxel World|Season")
FString GetCurrentSeasonContentHash() const;
//=========================================================================
// STRATE QUERIES (gameplay integration)
//=========================================================================
/**
* Get which strate index a world position is in.
*
* @param WorldPosition - Position in world space (Unreal units)
* @return Strate index (0 = topmost), or -1 if outside all strates
*/
//=========================================================================
// ESPACE MONDE <-> ESPACE ACTEUR / WORLD <-> ACTOR SPACE
//=========================================================================
// Le champ de densite est defini en espace ACTEUR : (0,0) est l origine de CET acteur, pas
// celle du monde Unreal. Tout point venant d Unreal (position du pion, hit de trace, ancre de
// streaming) DOIT passer par ces helpers avant de toucher quoi que ce soit cote voxel.
//
// The density field is authored in ACTOR space: (0,0) is THIS actor s origin, not Unreal s
// world origin. Any point arriving from Unreal (pawn position, trace hit, streaming anchor)
// MUST go through these helpers before touching anything voxel-side.
//
// INVARIANT VERIFIABLE PAR GREP / GREP-CHECKABLE INVARIANT:
// aucun `/ VOXEL_SIZE` applique a un parametre nomme `World*` hors de ces fonctions.
// no `/ VOXEL_SIZE` applied to a parameter named `World*` outside these functions.
// C est la vraie garantie : le risque n est pas un site rate parmi ceux deja trouves, c est
// le SITE SUIVANT que personne ne verra. The risk is not a missed site among those already
// found - it is the NEXT one nobody notices.
//
// La racine est Static (voir BeginPlay) => la transform ne bouge pas en jeu, donc la relire a
// chaque appel est gratuit et ne demande aucune invalidation.
// The root is Static (see BeginPlay), so the transform cannot change during play: re-reading
// it per call is free and needs no invalidation.
/** World cm -> actor-local cm. */
UFUNCTION(BlueprintPure, Category = "Voxel World|Space")
FVector WorldToLocalCm(FVector WorldPos) const;
/** World cm -> actor-local VOXEL coords (what every density / diff-layer API expects). */
UFUNCTION(BlueprintPure, Category = "Voxel World|Space")
FVector WorldToLocalVoxel(FVector WorldPos) const;
/** Actor-local VOXEL coords -> world cm (placing actors, debug draws, UI markers). */
UFUNCTION(BlueprintPure, Category = "Voxel World|Space")
FVector LocalVoxelToWorld(FVector VoxelPos) const;
UFUNCTION(BlueprintPure, Category = "Voxel World|Strate")
int32 GetStrateAtPosition(FVector WorldPosition) const;
/**
* Probe the biome field at a world location (e.g. a mouse line-trace hit). Returns the dominant +
* neighbour biome, the climate fields, the border blend weight, and the dominant biome's decoration
* count — the SAME resolution the decoration scatter uses per column. Use it to debug placement:
* a returned DominantDecorationCount of 0 means that biome has no decorations (an empty region),
* NOT a bug. WorldLocation is full world space (the actor transform is undone internally).
*/
UFUNCTION(BlueprintCallable, Category = "Voxel World|Biome")
FVoxelBiomeQuery GetBiomeAtWorldLocation(FVector WorldLocation) const;
/**
* SurfaceWorld ground finder for self-arranging prefabs (ruins/set-pieces authored as Blueprints):
* the terrain surface + sky-cap ceiling world-Z under WorldLocation's XY, WITHOUT any line trace or
* streamed collision. Deterministic and available before the area meshes. Returns false (outs = the
* input Z) when the point isn't a SurfaceWorld heightfield (cave strates) — fall back to a trace there.
* Does NOT account for passage/spine carving; re-check with a trace if the spot might be carved.
*/
UFUNCTION(BlueprintCallable, Category = "Voxel World|Query")
bool GetVoxelSurfaceHeightAt(FVector WorldLocation, float& OutSurfaceWorldZ, float& OutCeilingWorldZ) const;
//=========================================================================
// STREAMING ANCHORS — keep terrain (collision) loaded around actors that aren't the local player
// (AI now; remote players later). ARCHITECTURE §9.3. Registering an actor folds a small box of
// level-0 tiles around it into the desired set, so it has ground to stand on / pathfind / be hit
// even far from the camera. Idempotent per actor (re-register updates the policy/radius).
//=========================================================================
/** Start streaming terrain around Actor. Default box = its chunk + 1 horizontal ring + 1 chunk below
* (ground safety), nothing above. Empty tiles are ~free (trivial-tile reject); with the CollisionOnly
* policy the SOLID tiles cook collision but don't render (§9.4). Raise ZAbove for flyers/tall NPCs. */
UFUNCTION(BlueprintCallable, Category = "Voxel World|Streaming")
void RegisterStreamingAnchor(AActor* Actor,
EVoxelAnchorPolicy Policy = EVoxelAnchorPolicy::CollisionOnly,
int32 XYRadiusChunks = 1,
int32 ZBelowChunks = 1,
int32 ZAboveChunks = 0);
/** Stop streaming terrain around Actor. Its tiles are released by the normal delta cull. */
UFUNCTION(BlueprintCallable, Category = "Voxel World|Streaming")
void UnregisterStreamingAnchor(AActor* Actor);
//=========================================================================
// LIGHTING — DENSITY VOLUME (debug / material wiring)
//=========================================================================
/** The GPU R8 density volume texture for a clip level (0 = finest, near the player). Null until the
* volume has streamed in / if GPU upload is off. STEP 1b-i validation: in a debug BP, create a
* dynamic material instance of a Volume-Texture-sampling material and SetTextureParameterValue from
* this — you should see the density field, centred on the player, updating as you move & carve. */
UFUNCTION(BlueprintCallable, Category = "Voxel World|Lighting")
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);
/** Recompute the packed volume/orb shader params (TVP0..4) from the density volume + nearest orb, and
* push them (and the volume textures) onto every terrain MID. Called each Tick. */
void UpdateTerrainMaterialParams();
/** Apply the current TVP0..4 + level-0 volume texture to one MID (also used on MID creation). */
void SetVolumeParamsOnMID(UMaterialInstanceDynamic* MID) const;
/** Explicitly opt-in headless streaming validation. This is a command-line-only harness path;
* normal gameplay never moves the pawn here. It supplies a repeatable moving session and lets
* -nullrhi request a graceful EndPlay so the clean latency summary is flushed. */
void ConfigureHeadlessStreamingTest();
void AdvanceHeadlessStreamingTest(FVector& InOutPlayerPosition,
FVector& InOutPlayerHeading, APawn* PlayerPawn);
void AdvanceHeadlessCollisionGateStressTest(FVector& InOutPlayerPosition,
FVector& InOutPlayerHeading, APawn* PlayerPawn);
void ObserveHeadlessCollisionGateStressTest(const FVector& PlayerPosition, APawn* PlayerPawn);
void AdvanceHeadlessSurfaceFallTest(FVector& InOutPlayerPosition,
FVector& InOutPlayerHeading, APawn* PlayerPawn);
void InitializeHeadlessStrateCrossingTest();
void AdvanceHeadlessStrateCrossingTest(FVector& InOutPlayerPosition,
FVector& InOutPlayerHeading, APawn* PlayerPawn);
void ObserveHeadlessStrateCrossingTest(const FVector& PlayerPosition, APawn* PlayerPawn);
void MaybeFinishHeadlessStrateCrossingTest();
void MaybeFinishHeadlessSurfaceFallTest();
void MaybeFinishHeadlessCollisionGateStressTest();
void MaybeFinishHeadlessStreamingTest();
void UpdateVisualStalenessAudit();
void LogVisualStalenessAudit();
void LogReadyTransitionVisibilityMeasure();
void RecordTileHash(const FVoxelTileKey& Tile, const FIntVector& OriginVoxels,
int32 Step, int32 Cells, int32 BandChunkLo, int32 BandChunkHi,
bool bSheetTile, const FVoxelMeshData& MeshData);
void WriteTileHashDump();
// Packed shader params, recomputed each Tick. ALL meaningful data is in .xyz — a material Vector
// Parameter only delivers float3 (RGB) into a Custom node (the alpha is dropped), so we never use .w.
// TVP0 = L0 WindowOrigin.xyz (world cm) TVP1 = L0 OriginMod.xyz (cells)
// TVP2 = OrbPos.xyz (world cm) TVP3 = OrbColor.rgb * OrbIntensity (premultiplied)
// TVP4 = (OrbMaxDist, OrbFalloff, MarchSteps) TVP5 = (Res, L0 CellWorldSize, OrbEnable)
// TVP6 = L1 WindowOrigin.xyz TVP7 = L1 OriginMod.xyz
// TVP8 = L2 WindowOrigin.xyz TVP9 = L2 OriginMod.xyz
// Coarser levels' cell size is derived in-shader (cell_L = L0Cell * 2^L); Res is shared.
FLinearColor TVP0 = FLinearColor::Black, TVP1 = FLinearColor::Black, TVP2 = FLinearColor::Black,
TVP3 = FLinearColor::Black, TVP4 = FLinearColor::Black, TVP5 = FLinearColor::Black,
TVP6 = FLinearColor::Black, TVP7 = FLinearColor::Black,
TVP8 = FLinearColor::Black, TVP9 = FLinearColor::Black;
// Change-detection for the per-Tick MID pushes (MIDs OWN their param values, so skip-if-identical
// is safe there — unlike the MPC, whose world instance can reset behind our back; see
// UpdateOrbLightMPC, which deliberately rewrites every frame).
TWeakObjectPtr<UVolumeTexture> LastBoundVolTex0; // re-push MIDs if the L0 texture was recreated
public:
//=========================================================================
// LIVE EDIT (debug tuning in PIE)
//=========================================================================
/** When true, editing your Strate Definition data assets during PIE
* will automatically regenerate all chunks so you see the result live.
* Also adds a "Regenerate" button in Details for manual refresh. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Live Edit")
bool bLiveEditStrates = false;
#if WITH_EDITORONLY_DATA
/** Seed passed to the deterministic offline composer roll. This does not change Settings->Seed. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Live Edit|Composer")
int32 ComposerSeed = 0;
/** Candidate row passed to VF_RollStrateCandidate (the offline tests use the same index). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Live Edit|Composer", meta = (ClampMin = "0"))
int32 ComposerCandidateIndex = 0;
/** Existing layout slot to replace; 0 is the topmost strate. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Live Edit|Composer", meta = (ClampMin = "0"))
int32 ComposerTargetStrateIndex = 0;
/** False rolls one native parameter vector; true rolls the structure recipe and its six blocks. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Live Edit|Composer")
bool bComposerRollStructure = false;
#endif
#if WITH_EDITOR
/** Roll and apply the selected offline composer candidate to one live PIE slot. */
UFUNCTION(CallInEditor, Category = "Live Edit|Composer")
void ApplyComposerCandidate();
#endif
/** Click to force-regenerate all chunks right now (useful during PIE). */
UFUNCTION(CallInEditor, BlueprintCallable, Category = "Live Edit")
void RegenerateAllChunks();
/** Re-read ALL of VoxelSettings (strate layout, inter-strate gap, passages, spine)
* and rebuild from scratch, then regenerate every chunk. Use this after changing
* passage / gap / spine settings so they apply live without restarting PIE —
* RegenerateAllChunks alone keeps the existing layout & passages. */
UFUNCTION(CallInEditor, BlueprintCallable, Category = "Live Edit")
void RebuildStrates();
/** F2 — DETERMINISM VALIDATOR (run during PIE, takes ~a second). Samples a band of
* densities at a chunk boundary near the player through TWO cache-window alignments
* (thread_local chunk caches warmed from the left chunk, then from the right one, same
* points re-sampled) plus a same-alignment repeat. Every delta MUST be exactly 0 —
* anything else is a window-invariance regression (ARCHITECTURE §8.4). Logs the verdict
* and the first offending voxel. Run it after any "bit-identical" hot-path refactor. */
UFUNCTION(CallInEditor, BlueprintCallable, Category = "Live Edit")
void ValidateDeterminism();
//=========================================================================
// EDITOR BRUSH (manual carve/fill from the Details panel, works in PIE)
//=========================================================================
// The diff layer only exists while playing, so these buttons act during PIE.
/** World-space center (Unreal units) for the editor carve/fill buttons below. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Editor Brush")
FVector EditorBrushCenter = FVector::ZeroVector;
/** Brush radius in voxels for the editor buttons. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Editor Brush", meta = (ClampMin = "0.5"))
float EditorBrushRadius = 6.0f;
/** Brush strength for the editor buttons (sign forced by the button). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Editor Brush", meta = (ClampMin = "0.1"))
float EditorBrushStrength = 12.0f;
/** Carve a sphere at EditorBrushCenter using the brush settings above. */
UFUNCTION(CallInEditor, BlueprintCallable, Category = "Editor Brush")
void EditorCarveSphere();
/** Fill a sphere at EditorBrushCenter using the brush settings above. */
UFUNCTION(CallInEditor, BlueprintCallable, Category = "Editor Brush")
void EditorFillSphere();
/** Generation counter — incremented each time we regenerate.
* Async tasks carry this value; stale results are discarded. */
uint32 GenerationEpoch = 0;
/** Draw the inter-strate passages each frame (lines along the path + endpoint
* spheres) so you can see where they spawned and verify they carve. PIE only. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel World|Debug")
bool bDebugDrawPassages = false;
//=========================================================================
// BIOME MAP PREVIEW (bake the XY biome field to a PNG — works without PIE)
//=========================================================================
// Tune biome layout (cell size, warp, climate boxes) without flying around: set
// the strate + window, click Bake, open Saved/BiomePreview.png. Uses a transient
// generator seeded from VoxelSettings, so it works in the editor with no PIE.
/** The strate whose Biomes[] + BiomeMapParams to preview. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome Preview")
UVoxelStrateDefinition* BiomePreviewStrate = nullptr;
/** Width/height of the sampled window in VOXELS (centred on BiomePreviewCenter). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome Preview", meta = (ClampMin = "1.0"))
float BiomePreviewWorldSize = 16000.0f;
/** Centre of the preview window in voxel coords (X,Y). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome Preview")
FVector2D BiomePreviewCenter = FVector2D::ZeroVector;
/** Output image resolution (pixels per side). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome Preview", meta = (ClampMin = "64", ClampMax = "2048"))
int32 BiomePreviewResolution = 512;
/** Which field to visualise: biome debug colours, relief, or moisture. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome Preview")
EBiomePreviewChannel BiomePreviewChannel = EBiomePreviewChannel::Biome;
/** Bake the selected channel to Saved/BiomePreview.png. */
UFUNCTION(CallInEditor, BlueprintCallable, Category = "Biome Preview")
void BakeBiomePreview();
#if WITH_EDITOR
virtual void PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) override;
/** Called by FCoreUObjectDelegates::OnObjectModified when ANY UObject is edited.
* We filter for UVoxelStrateDefinition changes and regenerate if live edit is on. */
void OnObjectModifiedInEditor(UObject* ModifiedObject);
/** Handle to unbind the delegate when EndPlay is called */
FDelegateHandle OnObjectModifiedHandle;
#endif
//=========================================================================
// ACTOR LIFECYCLE
//=========================================================================
virtual void BeginPlay() override;
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
virtual void Tick(float DeltaTime) override;
//=========================================================================
// CHUNK MANAGEMENT - YOU IMPLEMENT THESE
//=========================================================================
/**
* Update which chunks are loaded based on a world position.
*
* CONCEPT:
* - Figure out which chunk the position is in (the "center")
* - Determine which chunks SHOULD exist (within view distance)
* - Load any chunks that should exist but don't
* - Unload any chunks that exist but shouldn't
*
* @param CenterPosition - Usually the player's position
*/
void UpdateChunksAroundPosition(const FVector& CenterPosition, APawn* PlayerPawn,
const FVector& PlayerHeading);
/**
* Load a single chunk at the given coordinate.
*
* CONCEPT:
* - Create chunk data
* - Generate terrain
* - Build mesh
* - Create visual component
*
* @param ChunkCoord - Which chunk to load
*/
void LoadTile(const FVoxelTileKey& Tile,
EVoxelTileWorkPriority WorkPriority = EVoxelTileWorkPriority::Horizon);
/**
* Worker-side gen for one tile: classify → GenerateMesh/GenerateSheetMesh → BuildTileStreamSet.
* Fills Result (no enqueue, no bookkeeping). Called from the async ChunkGen task AND from the
* synchronous carve path (SyncRemeshTile) — reads Generator/Mesher only, so it's safe on either
* thread. See LoadTile for how the parameters are derived.
*/
void GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector& OriginVoxels,
int32 Step, int32 Cells, uint32 Epoch, bool bWantCapture,
int32 BandVoxLo, int32 BandVoxHi, int32 BandChunkLo, int32 BandChunkHi,
bool bSheetTile, int32 SheetChunkZ,
int32 HoleMinX, int32 HoleMinY, int32 HoleMaxX, int32 HoleMaxY,
FChunkResult& Result,
const std::atomic<bool>* ObsoleteFlag = nullptr);
/**
* Game-thread apply for one gen result (shared by ProcessPendingChunks + SyncRemeshTile):
* epoch check, ingest capture, then either release the tile's component (empty) or submit a mesh
* through ApplyMeshToTile. Returns true iff a VISIBLE mesh was submitted (counts against the
* apply budget); collision readiness is a later RMC completion.
* Does NOT touch PendingTiles — the caller owns that. A non-empty result counts as applied only
* after mesh submission succeeds; collision readiness arrives asynchronously afterwards.
*/
bool ApplyTileResult(FChunkResult& Result);
/**
* Same-frame level-0 re-mesh on the game thread: gen + apply INLINE so a player carve is visible
* THIS frame (no async round-trip). Used for the tile under the brush centre; neighbours re-mesh
* async (prioritised) via RemeshDirtyChunks. One full-res tile gen on the game thread — bounded.
*/
void SyncRemeshTile(const FVoxelTileKey& Tile);
/**
* Unload a single chunk.
*
* CONCEPT:
* - Remove and destroy the mesh component
* - Remove chunk data from storage
*
* @param ChunkCoord - Which chunk to unload
*/
void UnloadTile(const FVoxelTileKey& Tile);
/**
* Upload a tile's geometry to its RealtimeMesh component (game thread).
*
* The vertex/index buffers (Streams) are already BUILT on the worker (T1.f — see
* BuildTileStreamSet / FChunkResult), so this only does the game-thread-only work:
* material resolution, get-or-create the component, CreateSectionGroup(MoveTemp),
* and per-section config (collision/shadow). Never called for empty tiles.
*
* F17 — the streams carry TWO polygroups (0 = ground, 1 = sky-cap ceiling, classified
* semantically per triangle on the worker): RMC auto-creates one section per non-empty
* group, so a coarse tile spanning both the terrain and the cap gets BOTH materials
* (the old whole-tile vote painted the loser with the winner's material).
*
* Takes the whole FChunkResult (tile key, streams — consumed/moved —, per-group flags and
* the strate content band the mesh was cut to; material lookups clamp into that band).
* Never called for empty results.
*/
bool ApplyMeshToTile(FChunkResult& Result);
/** Mini-sun lighting (bounded directional). Each frame writes the nearest 4 active orbs' WORLD
* positions (+ reach radius in .w) into OrbLightMPC's Orb0..3 vector params; the Directional
* Light's Light Function material reads them to mask its contribution into a pool around each
* orb. No-op until OrbLightMPC is assigned. Replaces the density-volume raymarch. */
void UpdateOrbLightMPC();
/** The Material Parameter Collection (MPC_VoxelOrbs) the orb Light Function reads. Assign in the
* AVoxelWorld details. Params expected: Vector Orb0,Orb1,Orb2,Orb3 = (x,y,z, reachRadiusCm). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Lighting")
UMaterialParameterCollection* OrbLightMPC = nullptr;
/** Build the clipmap desired-tile set (concentric shells) around the player tile.
* OutLeavers = les tuiles désirées au crossing PRÉCÉDENT qui ne le sont plus — les seuls
* candidats au cull (delta), au lieu de re-scanner TOUTES les tuiles chargées par crossing. */
void BuildDesiredTiles(const FIntVector& CenterChunkCoord, const FVector& PlayerPosition,
APawn* PlayerPawn, const FVector& PlayerHeading,
TArray<FVoxelTileKey>& OutLeavers);
void CancelObsoleteTileWork();
/** True if a tile's world footprint is still within the outermost clip shell (so a
* not-desired loaded tile there is mid-LOD-transition and must wait for its replacement,
* vs. one that has left the view entirely and can be culled immediately). */
bool IsTileInClipRange(const FVoxelTileKey& Tile, const FIntVector& CenterChunkCoord) const;
//=========================================================================
// HELPERS
//=========================================================================
/** Return the current player position and distinguish "no pawn" from a valid origin position. */
bool TryGetPlayerPosition(FVector& OutPosition, APawn** OutPawn = nullptr) const;
/** Compatibility wrapper for callers that only need the location. A real pawn at the origin
* therefore returns FVector::ZeroVector just like any other valid position. */
FVector GetPlayerPosition() const;
/** Level-0 tile under the pawn's feet. This is deliberately separate from the actor-center tile:
* the floor that catches a falling pawn is what must be cooked before movement is released. */
bool GetPlayerSupportTile(APawn* Pawn, const FVector& PlayerPosition,
FVoxelTileKey& OutSupportTile) const;
bool IsTileCollisionReady(const FVoxelTileKey& Tile) const;
bool IsCollisionTileUnavailable(const FVoxelTileKey& Tile,
const TCHAR*& OutReason) const;
bool IsCollisionReadyFromSupportTile(const FVoxelTileKey& CandidateTile,
FVoxelTileKey* OutSupportTile = nullptr,
const TCHAR** OutUnavailableReason = nullptr) const;
bool IsPlayerSupportCollisionReady(APawn* Pawn, const FVector& PlayerPosition,
FVoxelTileKey* OutSupportTile = nullptr,
const TCHAR** OutUnavailableReason = nullptr) const;
bool IsPawnCapsuleOverlappingPendingFloor(APawn* Pawn, const FVector& PlayerPosition,
FVoxelTileKey* OutPendingTile = nullptr) const;
void RequestCollisionGateSupportTile(const FVoxelTileKey& SupportTile);
/** Cancel only downward pawn motion while the tile beneath its feet is unresolved. Horizontal
* input and movement remain live. The world tick is installed as the pawn prerequisite in the
* implementation (and directly on its movement component) so this check runs before
* CharacterMovementComponent physics. */
void UpdatePawnCollisionGate(APawn* Pawn, const FVector& PlayerPosition, float DeltaTime);
void EngagePawnCollisionGate(APawn* Pawn);
void ReleasePawnCollisionGate();
/** Called by the RMC collision future after async cook + BodySetup installation. */
void HandleTileCollisionCookComplete(const FVoxelTileKey& Tile, uint64 SubmissionId,
URealtimeMesh* Mesh, uint8 Result);
void BindRealtimeMeshCollisionEvent(URealtimeMesh* Mesh);
void UnbindRealtimeMeshCollisionEvent(URealtimeMesh* Mesh);
void HandleRealtimeMeshCollisionBodyUpdated(URealtimeMesh* Mesh, UBodySetup* BodySetup);
void RecordLOD0ReadySample(uint64 RequestStartCycles, uint64 GenerationStartCycles,
uint64 GenerationEndCycles, uint64 ApplyStartCycles,
uint64 ReadyCycles, uint64 CollisionSubmittedCycles);
void RecordLOD0RingReadyIfComplete();
void LogStreamingLatencySummary() const;
// (GetLODForChunk / LODToStep / IsChunkInRange removed — dead since the clipmap
// streaming replaced the distance-LOD scheme; the level lives in FVoxelTileKey.)
//=========================================================================
// ASYNC
//=========================================================================
// MPSC: up to MaxConcurrentTasks ChunkGen worker threads Enqueue concurrently,
// the game thread (ProcessPendingChunks) is the only consumer. The default Spsc
// mode is NOT safe for multiple producers — concurrent Enqueues race on the tail
// link and silently drop results, which leaks PendingChunkCoord slots until the
// budget is exhausted and streaming stalls permanently. Mpsc guards the producer side.
// Result queues mirror the scheduling order: collision support first, then edited tiles,
// then ordinary LOD0, then the horizon. This keeps a support result from waiting behind a
// completed near-field backlog while preserving one consumer on the game thread.
TQueue<FChunkResult, EQueueMode::Mpsc> CriticalProcessQueue;
TQueue<FChunkResult, EQueueMode::Mpsc> EditedProcessQueue;
TQueue<FChunkResult, EQueueMode::Mpsc> NearProcessQueue;
TQueue<FChunkResult, EQueueMode::Mpsc> ProcessQueue;
TSet<FVoxelTileKey> PendingTiles; // tiles with a gen task in flight
// Game-thread-owned map; workers retain the shared token and only read its atomic flag.
TMap<FVoxelTileKey, TSharedPtr<FVoxelTileCancellationState, ESPMode::ThreadSafe>>
PendingTileCancellation;
// STRATE CONTENT CUT (see UVoxelSettings::StrateContentCutMinLevel) — the player-strate
// chunk-Z band coarse tiles are meshed to (MIN/MAX sentinels = no cut, e.g. in the gap).
// Updated each crossing in UpdateChunksAroundPosition; on change (strate transition) the
// loaded coarse tiles whose content depends on it are re-queued through BandRemeshQueue
// (drained by the budgeted submit loop — LoadTile re-gens in place, no visual pop).
int32 MeshBandChunkLo = MIN_int32;
int32 MeshBandChunkHi = MAX_int32;
TSet<FVoxelTileKey> BandRemeshQueue;
// DIG RESPONSIVENESS — tiles at every LOD touched by a player carve/fill that still need an
// async re-mesh (the synchronously-remeshed centre tile is excluded). Drained FIRST in the submit
// loop (ahead of streaming + band) and launched at BackgroundHigh so a dig never waits behind
// streaming. A tile that's in flight is marked obsolete and KEPT queued so a stale pre-carve
// result cannot publish and the post-carve result is admitted immediately.
TSet<FVoxelTileKey> DirtyRemeshQueue;
// F18 — TROU XY de l'anneau feuille (voxels ; Max EXCLUSIF ; sentinelles MAX/MIN = pas de
// trou) : la zone couverte par les coquilles MC (boîte niveau-MaxClipLevel autour du joueur,
// rétrécie d'une tuile pour garder un anneau de recouvrement au raccord) est DÉCOUPÉE des
// feuilles — sinon une feuille partiellement couverte recouvre le terrain proche avec son
// échantillonnage grossier. Mis à jour par crossing ; changement ⇒ re-queue des feuilles
// chevauchantes via BandRemeshQueue (re-gen en place).
int32 SheetHoleMinXVox = MAX_int32, SheetHoleMinYVox = MAX_int32;
int32 SheetHoleMaxXVox = MIN_int32, SheetHoleMaxYVox = MIN_int32;
// 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 std::atomic<bool>* ObsoleteFlag = nullptr) const
{
return bShuttingDown.load(std::memory_order_relaxed)
|| bGenerationPaused.load(std::memory_order_relaxed)
|| (ObsoleteFlag != nullptr
&& ObsoleteFlag->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;
// Non-player streaming anchors (AI now, remote players later — ARCHITECTURE §9.3). BuildDesiredTiles
// folds each anchor's small level-0 box into the desired set (same DesiredStamped machinery → the
// delta cull releases an anchor's tiles automatically when it moves away / is unregistered). The
// desired-set rebuild also fires when any anchor crosses a chunk boundary (UpdateChunksAroundPosition).
TArray<FVoxelStreamingAnchor> StreamingAnchors;
// Adds each anchor's level-0 tiles to DesiredSorted/DesiredStamped (dedup vs the player clipmap)
// and records the ones ONLY a CollisionOnly anchor wants in CollisionOnlyTiles.
void AddAnchorDesiredTiles();
// Forces a desired-set rebuild next Tick even if neither the player nor an anchor crossed a tile
// boundary (set by UnregisterStreamingAnchor so a removed anchor's tiles get culled).
bool bForceDesiredRebuild = false;
// §9.4 RENDER-SKIP — level-0 tiles wanted ONLY by CollisionOnly anchors (no player-clipmap / no
// FullVisual desirer this crossing): they cook collision but are hidden. Rebuilt each crossing in
// BuildDesiredTiles. `Prev` lets ReconcileAnchorTileVisibility toggle just the DELTA on already-
// loaded tiles when a tile flips render↔collision-only (player walks toward/away from a cluster),
// without an O(loaded) scan. Both empty when there are no CollisionOnly anchors → zero cost.
TSet<FVoxelTileKey> CollisionOnlyTiles;
TSet<FVoxelTileKey> PrevCollisionOnlyTiles;
void ReconcileAnchorTileVisibility();
// --- Streaming work-avoidance (perf) ---
// The desired tile set only changes when the player/anchor crosses a level-0 tile boundary or
// a rebuild is forced. We cache it and only rebuild/cull/sort then, and go idle once every
// desired tile is streamed in — so a stationary player costs ~nothing per frame.
FIntVector LastUpdateCenter = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
bool bAllChunksLoaded = false;
bool bStartupTraceDesiredRecorded = false;
bool bSampleStacksStarted = false;
TArray<FVoxelTileKey> DesiredSorted; // critical floor prefix, then true-pawn distance-first
TArray<FVoxelTileKey> CriticalDesiredTiles; // occupied/support tile, then heading tile
// Ancestor -> desired descendants, rebuilt with DesiredSorted. Reconciliation uses this
// index to test transition coverage without rescanning the full desired set per old tile.
TMap<FVoxelTileKey, TArray<FVoxelTileKey>> DesiredTransitionDescendants;
int32 DesiredTransitionMaxLevel = 0;
uint32 DesiredEpoch = 0; // increments whenever DesiredStamped is rebuilt
int32 ObsoleteTileAbortCount = 0; // cumulative per-world moving-session cancellation count
float PeakObservedPawnSpeedCmPerSecond = 0.0f;
struct FReadyTransitionVisibilitySample
{
uint64 Cycles = 0;
int32 TileComponentCount = 0;
int32 DesiredTileCount = 0;
};
struct FFarFieldSubmitSample
{
uint64 Cycles = 0;
int32 DesiredTileCount = 0;
int32 MaxDesiredLevel = 0;
};
bool bMeasureReadyTransitionVisibility = false;
TArray<FReadyTransitionVisibilitySample> ReadyTransitionVisibilitySamples;
TArray<FFarFieldSubmitSample> FarFieldSubmitSamples;
// Clean performance accounting. LOD 0..8 covers every marching-cubes clip level; sheet tiles
// above the configured MC range never enter the outer classifier.
static constexpr int32 TrackedClassifierLODCount = 9;
std::atomic<uint64> TotalWorkerGenerationCycles{0};
std::atomic<uint64> TotalWorkerGenerationTasks{0};
std::atomic<uint64> TotalObsoleteWorkerCycles{0};
std::atomic<uint64> TotalObsoleteWorkerTasks{0};
std::atomic<uint64> TotalValidationDensityCalls{0};
std::atomic<uint64> TotalMesherDensityCalls{0};
std::atomic<uint64> TotalSealedSolidProofCandidates{0};
std::atomic<uint64> TotalSealedSolidProofSkips{0};
std::atomic<uint64> TotalOutOfLayoutAirProofSkips{0};
std::atomic<uint64> OuterClassifierCallsByLOD[TrackedClassifierLODCount]{};
std::atomic<uint64> OuterClassifierVerdictsByLOD[TrackedClassifierLODCount][3]{};
// Low-overhead game-thread geometry totals used to prove that sampler toggling does not alter
// the applied tile/triangle stream. These are measurement counters, not generation inputs.
uint64 AppliedTileCount = 0;
uint64 AppliedVisibleTileCount = 0;
uint64 AppliedTriangleCount = 0;
uint64 AppliedVisibleTileCountByLevel[TrackedClassifierLODCount]{};
uint64 AppliedTriangleCountByLevel[TrackedClassifierLODCount]{};
uint64 AppliedBandTileCountByLevel[TrackedClassifierLODCount]{};
TMap<FVoxelTileKey, FLoadedTileGeometryStats> LoadedTileGeometry;
// Diff revision captured by tile requests. A result from before an edit is never allowed to
// replace a tile whose footprint intersects that edit; the old visual/collision state remains
// until a post-edit result is applied.
struct FModificationRevision
{
uint32 Epoch = 0;
FVector MinVoxel = FVector::ZeroVector;
FVector MaxVoxel = FVector::ZeroVector;
};
uint32 ModificationEpoch = 0;
FVector LastModificationMinVoxel = FVector::ZeroVector;
FVector LastModificationMaxVoxel = FVector::ZeroVector;
TArray<FModificationRevision> ModificationRevisions;
TMap<FVoxelTileKey, uint32> AppliedModificationEpoch;
struct FStreamingLatencySample
{
double RequestToReadySeconds = 0.0;
double QueueWaitSeconds = 0.0;
double GenerationSeconds = 0.0;
double CollisionCookSeconds = 0.0;
};
TArray<FStreamingLatencySample> LOD0ReadySamples;
// Trace-only ring timing: each desired-set rebuild starts a new epoch. A ring is complete
// when every current desired level-0 key has reached LoadedTiles and no level-0 request for
// that epoch remains pending. This measures visible near-field readiness independently of the
// much larger horizon settle point.
uint64 DesiredEpochStartCycles = 0;
FIntVector DesiredEpochCenterChunk = FIntVector::ZeroValue;
uint32 LastLOD0RingReadyEpoch = MAX_uint32;
// Headless validation hook. These are intentionally non-UPROPERTY state and are enabled only
// by explicit command-line switches; they do not alter ordinary player movement or streaming.
bool bHeadlessStreamingTestMovement = false;
bool bHeadlessStreamingTestExitOnSteady = false;
bool bHeadlessStreamingTestExitRequested = false;
bool bStartupTraceThroughCrossing = false;
bool bHeadlessStreamingTestCenterOverride = false;
FVector HeadlessStreamingTestCenterVoxel = FVector::ZeroVector;
int32 HeadlessStreamingTestMoveAttempts = 0;
double HeadlessStreamingTestDistanceCm = 0.0;
float HeadlessStreamingTestSpeedCmPerSecond = 0.0f;
float HeadlessStreamingTestStartSeconds = 0.0f;
float HeadlessStreamingTestExitSeconds = 0.0f;
FVector HeadlessStreamingTestDirection = FVector::XAxisVector;
FVector HeadlessStreamingTestLastActualPosition = FVector::ZeroVector;
double HeadlessStreamingTestBeginSeconds = 0.0;
double HeadlessStreamingTestLastElapsedSeconds = 0.0;
// Transient owner-view render hook. Enabled only by explicit voxel.TestCeilingView arguments;
// the camera and smaller test radius are held in memory and never saved to a package.
bool bTestCeilingViewEnabled = false;
bool bTestCeilingCaptureRequested = false;
TWeakObjectPtr<ACameraActor> TestCeilingCamera;
FVector TestCeilingCameraWorldPosition = FVector::ZeroVector;
FRotator TestCeilingCameraRotation = FRotator::ZeroRotator;
double TestCeilingViewBeginSeconds = 0.0;
float TestCeilingCaptureDelaySeconds = 18.0f;
// Explicit high-speed collision-gate repro. The streamer stays centred at the launch tile while
// the character uses ordinary swept movement across the still-pending desired set. The test
// distinguishes a gate hold from a full horizontal stop so a fall-only gate can be validated.
bool bHeadlessCollisionGateStressTest = false;
bool bHeadlessCollisionGateStressTestStartPlaced = false;
bool bHeadlessCollisionGateStressTestStarted = false;
bool bHeadlessCollisionGateStressTestPassed = false;
bool bHeadlessCollisionGateStressTestFailed = false;
bool bHeadlessCollisionGateStressTestExitRequested = false;
bool bHeadlessCollisionGateStressTestGateWasEngaged = false;
bool bHeadlessCollisionGateStressTestFullStop = false;
float HeadlessCollisionGateStressTestSpeedCmPerSecond = 6000.0f;
float HeadlessCollisionGateStressTestStartDelaySeconds = 0.1f;
float HeadlessCollisionGateStressTestDurationSeconds = 2.0f;
float HeadlessCollisionGateStressTestTimeoutSeconds = 15.0f;
double HeadlessCollisionGateStressTestBeginSeconds = 0.0;
double HeadlessCollisionGateStressTestStartSeconds = 0.0;
double HeadlessCollisionGateStressTestGateStartSeconds = 0.0;
double HeadlessCollisionGateStressTestFullStopStartSeconds = 0.0;
double HeadlessCollisionGateStressTestDistanceCm = 0.0;
double HeadlessCollisionGateStressTestTotalGateDurationSeconds = 0.0;
double HeadlessCollisionGateStressTestMaxGateDurationSeconds = 0.0;
double HeadlessCollisionGateStressTestFullStopDurationSeconds = 0.0;
int32 HeadlessCollisionGateStressTestGateHoldCount = 0;
int32 HeadlessCollisionGateStressTestFullStopCount = 0;
FVector HeadlessCollisionGateStressTestDirection = FVector::XAxisVector;
FVector HeadlessCollisionGateStressTestLastActualPosition = FVector::ZeroVector;
FString HeadlessCollisionGateStressTestFailureReason;
// Explicit owner-layout collision-gate repro. This is a command-line-only harness path: it
// places the possessed character above the authored single-strate surface and lets ordinary
// CharacterMovement gravity carry it to the generated ground.
bool bHeadlessSurfaceFallTest = false;
bool bHeadlessSurfaceFallTestStartPlaced = false;
bool bHeadlessSurfaceFallTestPassed = false;
bool bHeadlessSurfaceFallTestFailed = false;
bool bHeadlessSurfaceFallTestExitRequested = false;
bool bHeadlessSurfaceFallTestPredictionProbeInjected = false;
bool bHeadlessSurfaceFallTestPredictionProbeCleared = false;
float HeadlessSurfaceFallTestSpawnHeightVoxels = 20.0f;
float HeadlessSurfaceFallTestGroundToleranceVoxels = 4.0f;
float HeadlessSurfaceFallTestTimeoutSeconds = 8.0f;
float HeadlessSurfaceFallTestPlayerHalfHeightVoxels = 3.52f;
float HeadlessSurfaceFallTestTerrainZ = 0.0f;
double HeadlessSurfaceFallTestBeginSeconds = 0.0;
double HeadlessSurfaceFallTestStartSeconds = 0.0;
double HeadlessSurfaceFallTestDistanceCm = 0.0;
FVector HeadlessSurfaceFallTestStartPosition = FVector::ZeroVector;
FVector HeadlessSurfaceFallTestLastActualPosition = FVector::ZeroVector;
FVector HeadlessSurfaceFallTestPredictionProbePosition = FVector::ZeroVector;
FString HeadlessSurfaceFallTestFailureReason;
// Explicit parity evidence. Workers append under the lock; EndPlay writes the compact JSON
// after ActiveTaskCount has reached zero, so the dump is a complete worker-side snapshot.
bool bTileHashDumpEnabled = false;
FString TileHashDumpPath;
FCriticalSection TileHashDumpMutex;
TArray<FVoxelTileHashDumpRecord> TileHashDumpRecords;
// Real inter-strate crossing validation. This is enabled only by -voxel.TestStrateCrossing=1;
// the route is copied from UVoxelStrateManager's generated passage and movement is supplied to
// the ordinary pawn movement component. The initial placement onto the upper landing is test
// setup; every point after that is reached through swept character movement and the collision gate.
bool bHeadlessStrateCrossingTest = false;
bool bHeadlessStrateCrossingTestStartPlaced = false;
bool bHeadlessStrateCrossingTestStarted = false;
bool bHeadlessStrateCrossingTestPassed = false;
bool bHeadlessStrateCrossingTestFailed = false;
bool bHeadlessStrateCrossingTestExitRequested = false;
bool bHeadlessStrateCrossingTestGateWasEngaged = false;
bool bHeadlessStrateCrossingTestInitialSupportReported = false;
bool bHeadlessStrateCrossingTestPendingFloorOverlap = false;
int32 HeadlessStrateCrossingTestRequestedPassageIndex = INDEX_NONE;
int32 HeadlessStrateCrossingTestPassageIndex = INDEX_NONE;
int32 HeadlessStrateCrossingTestUpperStrateIndex = INDEX_NONE;
int32 HeadlessStrateCrossingTestLowerStrateIndex = INDEX_NONE;
int32 HeadlessStrateCrossingTestRouteTargetIndex = 1;
int32 HeadlessStrateCrossingTestLastReportedStrate = INDEX_NONE;
float HeadlessStrateCrossingTestSpeedCmPerSecond = 800.0f;
float HeadlessStrateCrossingTestStartDelaySeconds = 1.0f;
float HeadlessStrateCrossingTestTimeoutSeconds = 120.0f;
float HeadlessStrateCrossingTestMaxGateSeconds = 10.0f;
float HeadlessStrateCrossingTestRouteTargetRadiusVoxels = 2.0f;
float HeadlessStrateCrossingTestPlayerHalfHeightVoxels = 3.52f;
float HeadlessStrateCrossingTestMinFloorClearanceVoxels = 1000000.0f;
double HeadlessStrateCrossingTestBeginSeconds = 0.0;
double HeadlessStrateCrossingTestStartSeconds = 0.0;
double HeadlessStrateCrossingTestGateStartSeconds = 0.0;
double HeadlessStrateCrossingTestMaxGateDurationSeconds = 0.0;
double HeadlessStrateCrossingTestTotalGateDurationSeconds = 0.0;
FVector HeadlessStrateCrossingTestLastActualPosition = FVector::ZeroVector;
FString HeadlessStrateCrossingTestFailureReason;
TArray<FVector> HeadlessStrateCrossingTestRoute;
// Explicit visual-staleness evidence. Mode 1 audits an edit; mode 2 audits an LOD transition.
// These fields are command-line-only diagnostics and are inert in ordinary gameplay.
bool bVisualStalenessAudit = false;
int32 VisualStalenessAuditMode = 0;
bool bVisualStalenessAuditExitLogged = false;
uint32 VisualAuditEditEpoch = 0;
FVector VisualAuditEditMinVoxel = FVector::ZeroVector;
FVector VisualAuditEditMaxVoxel = FVector::ZeroVector;
double VisualAuditEditBeginSeconds = 0.0;
double VisualAuditEditStaleDurationSeconds = -1.0;
int32 VisualAuditEditMaxStaleTiles = 0;
bool bVisualAuditEditSawStale = false;
bool bVisualAuditEditResolved = false;
double VisualAuditLODOverlapStartSeconds = 0.0;
double VisualAuditLODOverlapMaxSeconds = 0.0;
int32 VisualAuditLODOverlapEvents = 0;
int32 VisualAuditLODOverlapMaxPairs = 0;
// PLAYER COLLISION GATE — only the downward component is held while the level-0 collision body
// beneath the pawn's feet is unresolved. Horizontal movement and player control remain live.
TWeakObjectPtr<APawn> PawnTickPrerequisite;
TWeakObjectPtr<UPawnMovementComponent> PawnMovementTickPrerequisite;
TWeakObjectPtr<APawn> CollisionGatedPawn;
TWeakObjectPtr<UPawnMovementComponent> CollisionGatedPawnMovement;
TWeakObjectPtr<UCharacterMovementComponent> CollisionGatedCharacterMovement;
bool bCollisionGateEnabled = true;
float SavedCharacterGravityScale = 1.0f;
bool bSavedCharacterGravityScale = false;
bool bPawnCollisionGateEngaged = false;
double PawnGateEngagedAtSeconds = 0.0;
bool bPawnGateUnavailableReported = false;
bool bPawnGateUnsupportedReported = false;
FVoxelTileKey PawnGateRequestedSupportTile;
bool bPawnGateSupportRequestReported = false;
// Desired-set membership STAMPÉE : clé → numéro du dernier crossing où la tuile était désirée.
// BuildDesiredTiles upserte le stamp courant puis balaie la map UNE fois : les entrées à stamp
// périmé sont les "leavers" (retirées + renvoyées). Le cull ne considère que ces leavers + la
// TransitionHold — fini le scan O(toutes-les-tuiles-chargées) à chaque crossing (le spike
// CullTiles ~1.6 ms/crossing de la trace 2026-07-05).
TMap<FVoxelTileKey, uint32> DesiredStamped;
uint32 DesiredStamp = 0;
bool IsDesired(const FVoxelTileKey& T) const
{
const uint32* S = DesiredStamped.Find(T);
return S && *S == DesiredStamp;
}
// Tuiles chargées qui ont quitté le desired set mais sont RETENUES (load-before-unload : leur
// remplacement n'est pas encore complet, ou le backlog a sauté le test de recouvrement).
// Re-considérées par BUDGET tournant (curseur sur la queue, ~256/crossing) — re-scanner TOUTE
// la hold par crossing redevient le vieux scan O(loaded) dès que le streaming ne "settle"
// jamais (mesuré 2.47 ms/crossing en packagé). Garder une tuile plus longtemps est toujours
// hole-safe. Le set est la MEMBERSHIP autoritaire ; la queue peut contenir des clés périmées
// (retirées paresseusement au scan). Le "settled cull" reste le filet de sécurité plein-scan.
TSet<FVoxelTileKey> TransitionHold;
TArray<FVoxelTileKey> TransitionHoldQueue;
int32 TransitionHoldCursor = 0;
void AddToTransitionHold(const FVoxelTileKey& T)
{
bool bAlready = false;
TransitionHold.Add(T, &bAlready);
if (!bAlready) { TransitionHoldQueue.Add(T); }
}
// Tiles approved for removal but whose teardown (component destroy + content actor Destroy())
// is spread across frames. Unbudgeted, a fast traversal culls a whole shell's worth of tiles in
// ONE frame → a game-thread teardown spike. Drained by ProcessUnloadQueue (catch-up scaled).
TSet<FVoxelTileKey> PendingUnload;
void ProcessPendingChunks();
void ProcessUnloadQueue(); // budgeted teardown drain (see PendingUnload)
void QueuePendingUnload(const FVoxelTileKey& Tile);
void HideOverlappingDesiredVisuals(const FVoxelTileKey& RetiredTile);
void ReconcileReadyTransitionVisibility();
/**
* Re-queue loaded chunks for async re-generation + re-meshing.
* Used after terrain modifications: the old mesh stays visible until
* the new async result comes back, so there's no visual pop.
*
* Chunks that aren't currently loaded are ignored (they'll generate
* fresh with the diff layer when loaded normally).
*
* @param DirtyCoords - Chunk coordinates that need re-meshing
* @param ExcludeTile - optional level-0 tile already handled synchronously this frame
* (SyncRemeshTile): skipped for the async re-queue, but still marked dirty
* for the density volume.
*/
void RemeshDirtyChunks(const TArray<FIntVector>& DirtyCoords, const FVoxelTileKey* ExcludeTile = nullptr);
};