potential perf regression

This commit is contained in:
2026-07-04 11:30:43 +02:00
parent 6875614002
commit cb61c8b2e4
17 changed files with 2321 additions and 492 deletions
+524 -149
View File
@@ -70,12 +70,23 @@ void UVoxelContentManager::NotifyShutdown()
FPlatformProcess::Yield();
}
DrainDecoResults();
ResetGridBuildState(NearGrid);
ResetGridBuildState(FarGrid);
}
void UVoxelContentManager::DrainDecoResults()
{
FDecoCellResult Discard;
while (DecoResults.Dequeue(Discard)) {}
RegionBuilds.Reset();
CompletedRegions.Reset();
PendingLaunch.Reset();
InFlightCells.Reset();
}
void UVoxelContentManager::ResetGridBuildState(FDecoGrid& G)
{
G.Builds.Reset();
G.Completed.Reset();
G.PendingLaunch.Reset();
G.InFlightCells.Reset();
}
//=============================================================================
@@ -175,12 +186,28 @@ void UVoxelContentManager::UpdateDecorations(const FVector& PlayerWorldPos)
// Strate biome field (XY-global → resolved once; the worker picks the dominant biome per COLUMN).
CurrentCtx.BiomeCtx = StrateManager->GetBiomeContextForChunk(RepChunk);
// Build the decoration palette ONCE for this update. With biomes, concatenate every biome's deco list
// and tag each entry with its context-biome index; the worker resolves a column's biome and rolls only
// the entries it owns → borders follow the warped-Voronoi field, not the 8 m cell grid (Task 1, §8.5).
// Without biomes, fall back to the strate's single list tagged -1 (always matches → legacy behaviour).
CurrentEntries.Reset();
CurrentEntryBiome.Reset();
// Refresh each grid's (tier, radius, spacing) from settings for this update. Radius/spacing are read
// every frame so live edits to the data asset take effect; the grids themselves persist across updates.
NearGrid.Tier = EDecoStreamTier::Near;
NearGrid.Radius = FMath::Max(1, Settings->DecorationNearRadiusChunks);
NearGrid.Spacing = FMath::Clamp(Settings->DecorationSpacingVoxels, 1, CHUNK_SIZE);
FarGrid.Tier = EDecoStreamTier::Far;
FarGrid.Radius = FMath::Max(1, Settings->DecorationRadiusChunks);
FarGrid.Spacing = FMath::Clamp(Settings->DecorationFarSpacingVoxels, 1, CHUNK_SIZE);
// Build the decoration palette ONCE for this update, PARTITIONED by tier. With biomes, concatenate every
// biome's deco list and tag each entry with its context-biome index; the worker resolves a column's biome
// and rolls only the entries it owns → borders follow the warped-Voronoi field, not the 8 m cell grid
// (Task 1, §8.5). Without biomes, fall back to the strate's single list tagged -1 (always matches). Each
// entry routes to NearGrid/FarGrid by its StreamTier, so each grid marches only its own subset.
NearGrid.Entries.Reset(); NearGrid.EntryBiome.Reset();
FarGrid.Entries.Reset(); FarGrid.EntryBiome.Reset();
auto AddEntry = [&](const FStrateDecoration& D, int32 ci)
{
FDecoGrid& G = (D.StreamTier == EDecoStreamTier::Near) ? NearGrid : FarGrid;
G.Entries.Add(D);
G.EntryBiome.Add(ci);
};
if (CurrentCtx.Def)
{
if (CurrentCtx.BiomeCtx.IsValid())
@@ -194,20 +221,12 @@ void UVoxelContentManager::UpdateDecorations(const FVector& PlayerWorldPos)
// index so it only fires inside that biome's columns — no cross-biome bleed).
const TArray<FStrateDecoration>& Src =
(Bio && Bio->Decorations.Num() > 0) ? Bio->Decorations : CurrentCtx.Def->Decorations;
for (const FStrateDecoration& D : Src)
{
CurrentEntries.Add(D);
CurrentEntryBiome.Add(ci);
}
for (const FStrateDecoration& D : Src) { AddEntry(D, ci); }
}
}
else
{
for (const FStrateDecoration& D : CurrentCtx.Def->Decorations)
{
CurrentEntries.Add(D);
CurrentEntryBiome.Add(-1); // no biome field → matches the column's ColBiome (-1)
}
for (const FStrateDecoration& D : CurrentCtx.Def->Decorations) { AddEntry(D, -1); } // -1 → matches ColBiome -1
}
}
@@ -221,22 +240,26 @@ void UVoxelContentManager::UpdateDecorations(const FVector& PlayerWorldPos)
if (PlayerCell != LastDecoCell)
{
RebuildDesiredCells(PlayerCell);
RebuildDesiredCells(NearGrid, PlayerCell);
RebuildDesiredCells(FarGrid, PlayerCell);
LastDecoCell = PlayerCell;
}
const int32 FarR = FMath::Max(1, Settings->DecorationRadiusChunks);
LaunchDecoTasks(PlayerCell);
ProcessDecoResults(PlayerCell, FarR);
// Both grids share ONE concurrency budget; throttle each against the other's current in-flight count.
const int32 MaxConc = Settings->MaxConcurrentDecorationTasks;
LaunchDecoTasks(NearGrid, PlayerCell, FarGrid.InFlightCells.Num(), MaxConc);
LaunchDecoTasks(FarGrid, PlayerCell, NearGrid.InFlightCells.Num(), MaxConc);
ProcessDecoResults(PlayerCell);
}
void UVoxelContentManager::RebuildDesiredCells(const FIntPoint& PlayerCell)
void UVoxelContentManager::RebuildDesiredCells(FDecoGrid& G, const FIntPoint& PlayerCell)
{
// REGION-granular streaming. Decoration cells are grouped into RxR regions; a region is the load/
// unload unit and shares ONE HISM per mesh, so the render thread walks ~R^2 fewer components. A
// REGION-granular streaming, per grid. Decoration cells are grouped into RxR regions; a region is the
// load/unload unit and shares ONE HISM per mesh, so the render thread walks ~R^2 fewer components. A
// region, once desired, marches ALL of its cells (so it is self-contained and NEVER re-streamed in
// place while it stays in range — same no-flicker guarantee the per-cell grid had, now per region).
const int32 FarR = FMath::Max(1, Settings->DecorationRadiusChunks);
// place while it stays in range — same no-flicker guarantee the per-cell grid had, now per region). The
// radius is G.Radius (this grid's tier), so Near and Far stream to different distances independently.
const int32 FarR = G.Radius;
const int32 R = RegionSize();
// Desired regions = every region whose footprint touches the radius-FarR cell box around the player.
@@ -255,10 +278,10 @@ void UVoxelContentManager::RebuildDesiredCells(const FIntPoint& PlayerCell)
// Unload loaded regions no longer desired (plain DestroyComponent — no per-instance removal).
{
TArray<FIntPoint> Loaded; DecoRegions.GetKeys(Loaded);
TArray<FIntPoint> Loaded; G.Regions.GetKeys(Loaded);
for (const FIntPoint& K : Loaded)
{
if (!DesiredRegions.Contains(K)) ClearDecorationRegion(K);
if (!DesiredRegions.Contains(K)) ClearDecorationRegion(G, K);
}
}
@@ -268,39 +291,38 @@ void UVoxelContentManager::RebuildDesiredCells(const FIntPoint& PlayerCell)
// enqueues all RxR of its cells once — a building region is never re-queued (no duplicate launches).
for (const FIntPoint& Region : DesiredRegions)
{
if (DecoRegions.Contains(Region)) continue; // already applied → leave it (no re-stream)
if (RegionBuilds.Contains(Region)) continue; // already marching its cells
if (G.Regions.Contains(Region)) continue; // already applied → leave it (no re-stream)
if (G.Builds.Contains(Region)) continue; // already marching its cells
FDecoRegionBuild& Build = RegionBuilds.Add(Region);
Build.BuildId = NextBuildId++;
FDecoRegionBuild& Build = G.Builds.Add(Region);
Build.BuildId = G.NextBuildId++;
Build.CellsRemaining = R * R;
const int32 BaseX = Region.X * R, BaseY = Region.Y * R;
for (int32 cy = 0; cy < R; ++cy)
for (int32 cx = 0; cx < R; ++cx)
{
PendingLaunch.Add(FIntPoint(BaseX + cx, BaseY + cy));
G.PendingLaunch.Add(FIntPoint(BaseX + cx, BaseY + cy));
}
}
// Nearest-first so the region under the player fills in before the fringe. Stale entries (cells whose
// build was already discarded) are cheaply skipped at launch, so PendingLaunch self-cleans as it drains.
PendingLaunch.Sort([PlayerCell](const FIntPoint& A, const FIntPoint& B)
G.PendingLaunch.Sort([PlayerCell](const FIntPoint& A, const FIntPoint& B)
{
return CellChebyshev(A, PlayerCell) < CellChebyshev(B, PlayerCell);
});
}
void UVoxelContentManager::LaunchDecoTasks(const FIntPoint& PlayerCell)
void UVoxelContentManager::LaunchDecoTasks(FDecoGrid& G, const FIntPoint& PlayerCell, int32 OtherInFlight, int32 MaxConc)
{
if (!CurrentCtx.Def || !Generator) return;
const int32 MaxConc = Settings->MaxConcurrentDecorationTasks;
if (MaxConc <= 0)
{
// Decorations disabled at runtime — drop all queued/pending build state so nothing is stranded.
PendingLaunch.Reset();
RegionBuilds.Reset();
CompletedRegions.Reset();
// Decorations disabled at runtime — drop THIS grid's queued/pending build state so nothing is stranded.
G.PendingLaunch.Reset();
G.Builds.Reset();
G.Completed.Reset();
return;
}
@@ -309,46 +331,54 @@ void UVoxelContentManager::LaunchDecoTasks(const FIntPoint& PlayerCell)
const FTransform OwnerXf = OwnerActor->GetActorTransform();
const int32 R = RegionSize();
const int32 Spacing = FMath::Clamp(Settings->DecorationSpacingVoxels, 1, CHUNK_SIZE);
const int32 Spacing = G.Spacing; // fine (Near) or coarse (Far) — the per-grid column grid
const float Step = (float)FMath::Max(1, Settings->DecorationMarchStepVoxels);
const int32 MaxCross = FMath::Max(1, Settings->DecorationMaxCrossingsPerColumn);
const float ColDepth = (float)FMath::Max(8, Settings->DecorationColumnDepthVoxels);
const EDecoStreamTier GridTier = G.Tier; // stamped on each result so it routes back to this grid
while (PendingLaunch.Num() > 0 && InFlightCells.Num() < MaxConc)
// Throttle against the COMBINED in-flight count (this grid + the other) so both grids share MaxConc.
// Drain from the head by INDEX — RemoveAt(0) per pop shifted the whole array every time (O(N) each,
// quadratic on a long queue); now it's one compaction at the end. A cell still in flight from a
// PREVIOUS build (its build was dropped while the task was airborne — e.g. the MaxConc==0 reset path)
// is DEFERRED instead of dropped: dropping it would leave the NEW build waiting forever for a cell
// that never reports (a permanently blank, never-reapplied region).
int32 Head = 0;
TArray<FIntPoint> Deferred;
while (Head < G.PendingLaunch.Num() && (G.InFlightCells.Num() + OtherInFlight) < MaxConc)
{
const FIntPoint Cell = PendingLaunch[0];
PendingLaunch.RemoveAt(0);
const FIntPoint Cell = G.PendingLaunch[Head++];
if (InFlightCells.Contains(Cell)) continue;
if (G.InFlightCells.Contains(Cell)) { Deferred.Add(Cell); continue; }
// The cell's region build drives completion. If it's gone (region applied or discarded since this
// cell was queued), drop the cell — no range check here: a region intentionally marches all its
// cells (some sit just past FarR), and discarding the build is the only "no longer wanted" signal.
const FIntPoint Region = CellToRegion(Cell, R);
FDecoRegionBuild* Build = RegionBuilds.Find(Region);
FDecoRegionBuild* Build = G.Builds.Find(Region);
if (!Build) continue;
const uint32 BuildId = Build->BuildId;
// The decoration palette (all biomes' lists, flattened + tagged) is built ONCE per update in
// This grid's palette (its tier's entries, flattened + biome-tagged) is built ONCE per update in
// UpdateDecorations; the per-COLUMN biome pick happens on the worker. Snapshot the flat list +
// tags for this cell's task (the biome context rides in Ctx).
if (CurrentEntries.Num() == 0)
if (G.Entries.Num() == 0)
{
MarkCellDone(Region, Cell, BuildId); // empty cell still counts toward the region's completion
MarkCellDone(G, Region, Cell, BuildId); // empty cell still counts toward the region's completion
continue;
}
TArray<FStrateDecoration> EntriesCopy = CurrentEntries; // snapshot for the worker + the spawner
TArray<int32> EntryBiomeCopy = CurrentEntryBiome; // parallel: ctx-biome owner per entry
TArray<FStrateDecoration> EntriesCopy = G.Entries; // snapshot for the worker + the spawner
TArray<int32> EntryBiomeCopy = G.EntryBiome; // parallel: ctx-biome owner per entry
const FDecoContext Ctx = CurrentCtx; // PODs only used on the worker
const uint32 LocalSeed = (uint32)Seed;
UVoxelGenerator* Gen = Generator;
InFlightCells.Add(Cell);
G.InFlightCells.Add(Cell);
GActiveDecoTasks.fetch_add(1, std::memory_order_relaxed);
UE::Tasks::Launch(TEXT("DecoMarch"),
[this, Gen, OwnerXf, Cell, Ctx, LocalSeed, Spacing, Step, MaxCross, ColDepth, BuildId,
[this, Gen, OwnerXf, Cell, Ctx, LocalSeed, Spacing, Step, MaxCross, ColDepth, BuildId, GridTier,
Entries = MoveTemp(EntriesCopy), EntryBiome = MoveTemp(EntryBiomeCopy)]() mutable
{
struct FGuard { ~FGuard() { GActiveDecoTasks.fetch_sub(1, std::memory_order_relaxed); } } Guard;
@@ -358,6 +388,7 @@ void UVoxelContentManager::LaunchDecoTasks(const FIntPoint& PlayerCell)
FDecoCellResult Result;
Result.Cell = Cell;
Result.BuildId = BuildId;
Result.Grid = GridTier;
Result.Entries = MoveTemp(Entries);
BuildCellSpawns(Gen, OwnerXf, Cell, Ctx, Result.Entries, EntryBiome, LocalSeed,
Spacing, Step, MaxCross, ColDepth, Result.Spawns);
@@ -368,6 +399,9 @@ void UVoxelContentManager::LaunchDecoTasks(const FIntPoint& PlayerCell)
}
}, UE::Tasks::ETaskPriority::BackgroundNormal);
}
if (Head > 0) { G.PendingLaunch.RemoveAt(0, Head); }
G.PendingLaunch.Append(Deferred); // retry next update, once the old task frees the cell
}
// ---- WORKER THREAD: find each column's surface points → spawn commands. ----
@@ -387,6 +421,20 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
TArray<int32> EntryCount; EntryCount.Init(0, Entries.Num());
int32 TotalActors = 0;
// Per-entry slope-gate cosines, hoisted out of PlaceAtCrossing (they were recomputed per crossing
// × entry). Same cos of the same angle → bit-identical gating. Sentinel < 0 = gate disabled
// (default angles), so the common case still costs no trig and never rejects.
TArray<float> CosMaxSlope, CosMinSlope;
CosMaxSlope.SetNumUninitialized(Entries.Num());
CosMinSlope.SetNumUninitialized(Entries.Num());
for (int32 e = 0; e < Entries.Num(); ++e)
{
CosMaxSlope[e] = (Entries[e].MaxSlopeAngle < 89.99f)
? FMath::Cos(FMath::DegreesToRadians(Entries[e].MaxSlopeAngle)) : -1.0f;
CosMinSlope[e] = (Entries[e].MinSlopeAngle > 0.01f)
? FMath::Cos(FMath::DegreesToRadians(Entries[e].MinSlopeAngle)) : -1.0f;
}
// Per-COLUMN biome cache: ResolveBiomeSampleAt's noise-heavy cell classification is box-validated
// (one rebuild per chunk footprint), so resolving the dominant biome at every column in this cell is
// cheap. The cache is local to this worker task (determinism-safe — pure function of XY/seed/Ctx).
@@ -438,22 +486,11 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
// wall decals. Applies whenever the point IS a wall (independent of Floor/Wall/Any setting).
if (bWall && Deco.bWallExcludeOverhangs && NormalWorld.Z < 0.0f) continue;
// Surface-tilt gate: tilt = acos(|N.Z|) (0 = flat, 90 = vertical). Skip surfaces steeper than
// MaxSlopeAngle. cos is monotone-decreasing, so |N.Z| < cos(MaxSlope) ⇔ tilt > MaxSlope.
// Guarded so the default (90°, cos = 0) costs no trig and never rejects anything.
if (Deco.MaxSlopeAngle < 89.99f &&
FMath::Abs(NormalWorld.Z) < FMath::Cos(FMath::DegreesToRadians(Deco.MaxSlopeAngle)))
{
continue;
}
// Lower-bound tilt gate (companion to the above): skip surfaces FLATTER than MinSlopeAngle.
// tilt < MinSlope ⇔ |N.Z| > cos(MinSlope). Guarded so the default (0°, cos = 1) never rejects.
if (Deco.MinSlopeAngle > 0.01f &&
FMath::Abs(NormalWorld.Z) > FMath::Cos(FMath::DegreesToRadians(Deco.MinSlopeAngle)))
{
continue;
}
// Surface-tilt gates: tilt = acos(|N.Z|) (0 = flat, 90 = vertical). |N.Z| < cos(MaxSlope) ⇔
// tilt > MaxSlope (skip steeper); |N.Z| > cos(MinSlope) ⇔ tilt < MinSlope (skip flatter).
// Cosines are precomputed per entry above; < 0 = gate disabled (default angles).
if (CosMaxSlope[EntryIdx] >= 0.0f && FMath::Abs(NormalWorld.Z) < CosMaxSlope[EntryIdx]) continue;
if (CosMinSlope[EntryIdx] >= 0.0f && FMath::Abs(NormalWorld.Z) > CosMinSlope[EntryIdx]) continue;
const uint32 H = DecoHash(Cell.X, Cell.Y, gx, gy, CrossingIdx, EntryIdx, InSeed, 0xDEC0u);
if (VoxelHash::ToFloat01(H) > Deco.SpawnDensity) continue;
@@ -601,49 +638,56 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
}
// ---- GAME THREAD: drain finished marches → merge into region builds, apply completed regions budgeted. ----
void UVoxelContentManager::ProcessDecoResults(const FIntPoint& PlayerCell, int32 FarR)
void UVoxelContentManager::ProcessDecoResults(const FIntPoint& PlayerCell)
{
// Drain every finished cell march and fold it into its region build. Merging is cheap (transform
// appends) so it isn't budgeted; the expensive HISM build is budgeted below at region granularity.
// Drain every finished cell march and route it to its grid by Result.Grid, folding it into that grid's
// region build. Merging is cheap (transform appends) so it isn't budgeted; the expensive HISM build is
// budgeted below at region granularity.
FDecoCellResult R;
while (DecoResults.Dequeue(R))
{
InFlightCells.Remove(R.Cell); // free the concurrency slot regardless of whether it still matters
MergeCellResult(R);
FDecoGrid& G = (R.Grid == EDecoStreamTier::Near) ? NearGrid : FarGrid;
G.InFlightCells.Remove(R.Cell); // free the concurrency slot regardless of whether it still matters
MergeCellResult(G, R);
}
// Apply completed regions (one batched HISM-per-mesh build), budgeted. A region whose build finished
// but is no longer desired (player moved on while it marched) is discarded instead of applied — that
// keeps an out-of-range region from flashing in for a frame before the next unload pass.
// Apply completed regions across BOTH grids under ONE shared frame budget (one batched HISM-per-mesh
// build per region). A region whose build finished but is no longer desired (player moved on while it
// marched) is discarded instead of applied — keeps an out-of-range region from flashing in for a frame.
const int32 R_ = RegionSize();
const int32 Budget = FMath::Max(1, Settings->MaxDecorationCellsPerFrame);
int32 Applied = 0;
while (CompletedRegions.Num() > 0 && Applied < Budget)
for (FDecoGrid* GP : { &NearGrid, &FarGrid })
{
const FIntPoint Region = CompletedRegions[0];
CompletedRegions.RemoveAt(0);
FDecoRegionBuild* Build = RegionBuilds.Find(Region);
if (!Build) continue; // already cleared
if (!IsRegionDesired(Region, PlayerCell, FarR, R_))
FDecoGrid& G = *GP;
while (G.Completed.Num() > 0 && Applied < Budget)
{
RegionBuilds.Remove(Region); // wandered out of range while building → drop it unbuilt
continue;
}
const FIntPoint Region = G.Completed[0];
G.Completed.RemoveAt(0);
ApplyRegion(Region, *Build);
RegionBuilds.Remove(Region);
++Applied;
FDecoRegionBuild* Build = G.Builds.Find(Region);
if (!Build) continue; // already cleared
if (!IsRegionDesired(Region, PlayerCell, G.Radius, R_))
{
G.Builds.Remove(Region); // wandered out of range while building → drop it unbuilt
continue;
}
ApplyRegion(G, Region, *Build);
G.Builds.Remove(Region);
++Applied;
}
if (Applied >= Budget) break;
}
}
// Fold one finished cell's spawns into its region build, then mark the cell accounted for. A result whose
// region build is gone or whose BuildId no longer matches (region was cleared + re-marched) is discarded.
void UVoxelContentManager::MergeCellResult(const FDecoCellResult& Result)
void UVoxelContentManager::MergeCellResult(FDecoGrid& G, const FDecoCellResult& Result)
{
const FIntPoint Region = CellToRegion(Result.Cell, RegionSize());
FDecoRegionBuild* Build = RegionBuilds.Find(Region);
FDecoRegionBuild* Build = G.Builds.Find(Region);
if (!Build || Build->BuildId != Result.BuildId)
{
return;
@@ -677,15 +721,15 @@ void UVoxelContentManager::MergeCellResult(const FDecoCellResult& Result)
}
}
MarkCellDone(Region, Result.Cell, Result.BuildId);
MarkCellDone(G, Region, Result.Cell, Result.BuildId);
}
// Account one cell against its region — IDEMPOTENT per cell, so a duplicate task for the same cell can't
// double-decrement and apply the region early (which left a permanently-empty chunk until a regen). Queues
// the region for apply once every distinct cell has reported.
void UVoxelContentManager::MarkCellDone(const FIntPoint& Region, const FIntPoint& Cell, uint32 BuildId)
void UVoxelContentManager::MarkCellDone(FDecoGrid& G, const FIntPoint& Region, const FIntPoint& Cell, uint32 BuildId)
{
FDecoRegionBuild* Build = RegionBuilds.Find(Region);
FDecoRegionBuild* Build = G.Builds.Find(Region);
if (!Build || Build->BuildId != BuildId) return;
bool bAlreadyAccounted = false;
@@ -694,13 +738,13 @@ void UVoxelContentManager::MarkCellDone(const FIntPoint& Region, const FIntPoint
if (--Build->CellsRemaining <= 0)
{
CompletedRegions.Add(Region); // ready for budgeted apply in ProcessDecoResults
G.Completed.Add(Region); // ready for budgeted apply in ProcessDecoResults
}
}
// Build the region's components: one HISM per mesh (all cells merged → one batched AddInstances), actors
// spawned inline. Moves the region into DecoRegions; the build is removed by the caller.
void UVoxelContentManager::ApplyRegion(const FIntPoint& Region, FDecoRegionBuild& Build)
// spawned inline. Moves the region into G.Regions; the build is removed by the caller.
void UVoxelContentManager::ApplyRegion(FDecoGrid& G, const FIntPoint& Region, FDecoRegionBuild& Build)
{
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_DecoApply); // total game-thread cost to apply one region
@@ -709,7 +753,7 @@ void UVoxelContentManager::ApplyRegion(const FIntPoint& Region, FDecoRegionBuild
UWorld* World = OwnerActor->GetWorld();
if (!World) return;
FDecoRegionContent& Content = DecoRegions.Add(Region);
FDecoRegionContent& Content = G.Regions.Add(Region);
// Non-instanced actors — spawn each (no batch path). Decorations live only in the player's strate
// (the march is strate-bounded), so their lights are always legitimately visible — no extra culling.
@@ -773,9 +817,9 @@ void UVoxelContentManager::ApplyRegion(const FIntPoint& Region, FDecoRegionBuild
}
}
void UVoxelContentManager::ClearDecorationRegion(const FIntPoint& Region)
void UVoxelContentManager::ClearDecorationRegion(FDecoGrid& G, const FIntPoint& Region)
{
FDecoRegionContent* Content = DecoRegions.Find(Region);
FDecoRegionContent* Content = G.Regions.Find(Region);
if (!Content) return;
for (const TWeakObjectPtr<AActor>& A : Content->Actors)
@@ -786,22 +830,338 @@ void UVoxelContentManager::ClearDecorationRegion(const FIntPoint& Region)
{
if (UHierarchicalInstancedStaticMeshComponent* Comp = C.Get()) { Comp->DestroyComponent(); }
}
DecoRegions.Remove(Region);
G.Regions.Remove(Region);
}
void UVoxelContentManager::ClearAllDecorations()
{
TArray<FIntPoint> Keys; DecoRegions.GetKeys(Keys);
for (const FIntPoint& K : Keys) ClearDecorationRegion(K);
RegionBuilds.Reset(); // abandon any in-progress builds
CompletedRegions.Reset();
PendingLaunch.Reset();
InFlightCells.Reset();
for (FDecoGrid* GP : { &NearGrid, &FarGrid })
{
FDecoGrid& G = *GP;
TArray<FIntPoint> Keys; G.Regions.GetKeys(Keys);
for (const FIntPoint& K : Keys) ClearDecorationRegion(G, K);
ResetGridBuildState(G); // abandon any in-progress builds
}
// Drain any results already enqueued by in-flight tasks. No epoch bump needed: their BuildIds are now
// gone from RegionBuilds, so any straggler result is discarded on merge; new builds get fresh BuildIds.
FDecoCellResult Discard;
while (DecoResults.Dequeue(Discard)) {}
// gone from the grids' Builds, so any straggler result is discarded on merge; new builds get fresh BuildIds.
DrainDecoResults();
}
//=============================================================================
// LANDMARKS — rare large objects on a coarse hash lattice (the "mini-suns")
//=============================================================================
// Cost scales with the NUMBER of landmarks in range, not the area: cell = SpacingChunks chunks, so a huge
// StreamRadiusChunks is only ~(radius/spacing)² candidates. Placement is synchronous (so few candidates it
// never hitches) and deterministic (hash of cell+entry+seed → pop-free). Strate-bounded like decorations.
// Single-column surface find for a landmark. SurfaceWorld → height oracle (floor TerrainZ / ceiling CeilSurf
// by Surf); else ray-march the strate band top-down for the first crossing whose orientation matches Surf.
bool UVoxelContentManager::FindLandmarkColumn(const UVoxelGenerator* Gen, const FTransform& OwnerXf,
const FDecoContext& Ctx, float VX, float VY, ESurfaceType Surf,
float Step, float ColDepth, float& OutZ, FVector& OutNormal)
{
if (!Gen) return false;
if (Ctx.bSurfaceWorld)
{
float hC, cC;
if (!Gen->GetSurfaceHeightAt(VX, VY, Ctx.RepChunkZ, hC, cC)) return false;
if (Surf == ESurfaceType::Ceiling)
{
if (!(cC > hC + 1.0f && cC <= Ctx.TopVoxelZ)) return false;
float d, cXp, cXm, cYp, cYm;
Gen->GetSurfaceHeightAt(VX + 1.0f, VY, Ctx.RepChunkZ, d, cXp);
Gen->GetSurfaceHeightAt(VX - 1.0f, VY, Ctx.RepChunkZ, d, cXm);
Gen->GetSurfaceHeightAt(VX, VY + 1.0f, Ctx.RepChunkZ, d, cYp);
Gen->GetSurfaceHeightAt(VX, VY - 1.0f, Ctx.RepChunkZ, d, cYm);
FVector N = OwnerXf.TransformVectorNoScale(
FVector((cXp - cXm) * 0.5f, (cYp - cYm) * 0.5f, -1.0f)).GetSafeNormal();
if (N.IsNearlyZero()) N = FVector::DownVector;
OutZ = cC; OutNormal = N; return true;
}
// Floor / Wall / Any → the terrain top.
if (!(hC >= Ctx.BottomVoxelZ && hC <= Ctx.TopVoxelZ)) return false;
if (Gen->GetDensityAt(VX, VY, hC) > 0.5f) return false; // carved away (passage/spine/diff)
float d, hXp, hXm, hYp, hYm;
Gen->GetSurfaceHeightAt(VX + 1.0f, VY, Ctx.RepChunkZ, hXp, d);
Gen->GetSurfaceHeightAt(VX - 1.0f, VY, Ctx.RepChunkZ, hXm, d);
Gen->GetSurfaceHeightAt(VX, VY + 1.0f, Ctx.RepChunkZ, hYp, d);
Gen->GetSurfaceHeightAt(VX, VY - 1.0f, Ctx.RepChunkZ, hYm, d);
FVector N = OwnerXf.TransformVectorNoScale(
FVector(-(hXp - hXm) * 0.5f, -(hYp - hYm) * 0.5f, 1.0f)).GetSafeNormal();
if (N.IsNearlyZero()) N = FVector::UpVector;
OutZ = hC; OutNormal = N; return true;
}
// Cave/shaft/island archetypes: march the column from the top for the first matching crossing.
// Bounded by ColDepth like the decoration march (this runs SYNCHRONOUSLY on the game thread):
// once past open air, a solid run longer than ColDepth means bedrock down to the strate floor —
// stop instead of paying GetDensityAt across the whole remaining band.
float PrevD = Gen->GetDensityAt(VX, VY, Ctx.TopVoxelZ);
bool bSeenAir = (PrevD >= 0.0f);
float SolidRun = 0.0f;
for (float Z = Ctx.TopVoxelZ - Step; Z >= Ctx.BottomVoxelZ; Z -= Step)
{
const float Dz = Gen->GetDensityAt(VX, VY, Z);
if ((PrevD >= 0.0f) != (Dz >= 0.0f)) // air ↔ solid crossing
{
float ZLo = Z, ZHi = Z + Step, DHi = PrevD, DLo = Dz;
for (int32 It = 0; It < 4; ++It)
{
const float ZM = 0.5f * (ZLo + ZHi);
const float DM = Gen->GetDensityAt(VX, VY, ZM);
if ((DM >= 0.0f) == (DHi >= 0.0f)) { ZHi = ZM; DHi = DM; }
else { ZLo = ZM; DLo = DM; }
}
const float Denom = (DLo - DHi);
const float T = (FMath::Abs(Denom) > KINDA_SMALL_NUMBER) ? (DLo / Denom) : 0.5f;
const float ZC = ZLo + (ZHi - ZLo) * T;
const FVector LocalGrad(
Gen->GetDensityAt(VX + 1.0f, VY, ZC) - Gen->GetDensityAt(VX - 1.0f, VY, ZC),
Gen->GetDensityAt(VX, VY + 1.0f, ZC) - Gen->GetDensityAt(VX, VY - 1.0f, ZC),
Gen->GetDensityAt(VX, VY, ZC + 1.0f) - Gen->GetDensityAt(VX, VY, ZC - 1.0f));
FVector N = OwnerXf.TransformVectorNoScale(LocalGrad).GetSafeNormal();
if (N.IsNearlyZero()) N = FVector::UpVector;
const bool bFloor = N.Z > 0.5f;
const bool bCeiling = N.Z < -0.5f;
const bool bWall = !bFloor && !bCeiling;
const bool bMatch =
(Surf == ESurfaceType::Floor && bFloor) ||
(Surf == ESurfaceType::Ceiling && bCeiling) ||
(Surf == ESurfaceType::Wall && bWall) ||
(Surf == ESurfaceType::Any);
if (bMatch) { OutZ = ZC; OutNormal = N; return true; }
}
if (Dz >= 0.0f) { bSeenAir = true; SolidRun = 0.0f; }
else { SolidRun += Step; }
if (bSeenAir && SolidRun > ColDepth) break; // long bedrock below open space → nothing deeper
PrevD = Dz;
}
return false;
}
void UVoxelContentManager::SpawnLandmarkInstance(const FStrateLandmark& L, uint32 H, const FDecoContext& Ctx,
const FTransform& OwnerXf, AActor* OwnerActor,
float LocalX, float LocalY, float Step, float ColDepth,
FLandmarkInstance& Out)
{
if (!Generator) return;
const float VX = LocalX / VOXEL_SIZE;
const float VY = LocalY / VOXEL_SIZE;
// Biome filter (resolved at the candidate XY, same field the density/deco paths use).
if (L.RequiredBiome)
{
const UVoxelBiomeDefinition* Bio = Generator->GetDominantBiomeAt(VX, VY, Ctx.RepChunkZ);
if (Bio != L.RequiredBiome) return; // leaves Out empty → evaluated, nothing placed
}
float ZC; FVector N;
if (!FindLandmarkColumn(Generator, OwnerXf, Ctx, VX, VY, L.SurfacePlacement, Step, ColDepth, ZC, N))
return;
// Surface-tilt gates (acos(|N.Z|); guarded so defaults cost no trig).
if (L.MaxSlopeAngle < 89.99f &&
FMath::Abs(N.Z) < FMath::Cos(FMath::DegreesToRadians(L.MaxSlopeAngle))) return;
if (L.MinSlopeAngle > 0.01f &&
FMath::Abs(N.Z) > FMath::Cos(FMath::DegreesToRadians(L.MinSlopeAngle))) return;
const FVector LocalPos(LocalX, LocalY, ZC * VOXEL_SIZE);
if (L.bRequireWaterRelative && Ctx.bHasWater)
{
const bool bBelowWater = (LocalPos.Z < Ctx.WaterLocalZ);
if (bBelowWater != L.bPlaceBelowWater) return;
}
// Rotation: optional surface-align → fixed offset → per-axis hash random.
FQuat Q = L.bAlignToSurface ? FRotationMatrix::MakeFromZ(N).ToQuat() : FQuat::Identity;
Q = Q * L.RotationOffset.Quaternion();
if (!L.RandomRotation.IsNearlyZero())
{
const float rp = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x1111A1u)) - 0.5f) * L.RandomRotation.Pitch;
const float ry = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x2222B2u)) - 0.5f) * L.RandomRotation.Yaw;
const float rr = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x3333C3u)) - 0.5f) * L.RandomRotation.Roll;
Q = Q * FRotator(rp, ry, rr).Quaternion();
}
const float ScaleT = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x5CA1E777u));
const float Scale = FMath::Lerp(L.MinScale, L.MaxScale, ScaleT);
// World-space position + XYZ offset (e.g. +Z lifts a sun off the sky-cap into the cavern).
const FVector WorldPos = OwnerXf.TransformPosition(LocalPos) + L.LocationOffset;
const FTransform Xf(Q, WorldPos, FVector(Scale));
// Mini-sun light orb: record world-space data for the terrain material's raymarched shadows. Distances
// convert voxels→cm (×VOXEL_SIZE); the emitter radius scales with the instance scale too.
if (L.bIsLightOrb)
{
Out.bIsOrb = true;
Out.Orb.WorldPos = WorldPos;
Out.Orb.Color = L.OrbColor;
Out.Orb.Intensity = L.OrbIntensity;
Out.Orb.RadiusWorld = L.OrbRadiusVoxels * VOXEL_SIZE * Scale;
Out.Orb.FalloffWorld = L.OrbFalloffVoxels * VOXEL_SIZE;
Out.Orb.MaxShadowDistWorld = L.OrbMaxShadowDistanceVoxels * VOXEL_SIZE;
}
if (L.ActorClass)
{
UWorld* World = OwnerActor->GetWorld();
if (!World) return;
FActorSpawnParameters SP;
SP.Owner = OwnerActor;
SP.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
if (AActor* A = World->SpawnActor<AActor>(L.ActorClass, Xf, SP)) { Out.Actor = A; }
return;
}
if (L.InstancedMesh)
{
UStaticMeshComponent* C = NewObject<UStaticMeshComponent>(OwnerActor);
C->SetStaticMesh(L.InstancedMesh);
C->SetMobility(EComponentMobility::Static); // placed once, never moves → cached draw + VSM shadow
C->SetCollisionEnabled(ECollisionEnabled::NoCollision);
C->SetCastShadow(L.bCastShadow);
if (L.CullDistance > 0.0f) { C->SetCullDistance(L.CullDistance); } // 0 = never cull (far sun)
C->SetWorldTransform(Xf);
C->RegisterComponent();
C->AttachToComponent(OwnerActor->GetRootComponent(), FAttachmentTransformRules::KeepWorldTransform);
Out.Component = C;
}
}
void UVoxelContentManager::GetActiveOrbs(TArray<FVoxelActiveOrb>& OutOrbs) const
{
OutOrbs.Reset();
for (const TPair<FIntVector, FLandmarkInstance>& Pair : LandmarkInstances)
{
if (Pair.Value.bIsOrb) { OutOrbs.Add(Pair.Value.Orb); }
}
}
void UVoxelContentManager::DestroyLandmarkInstance(FLandmarkInstance& Inst)
{
if (AActor* A = Inst.Actor.Get()) { A->Destroy(); }
if (UStaticMeshComponent* C = Inst.Component.Get()) { C->DestroyComponent(); }
Inst.Actor = nullptr;
Inst.Component = nullptr;
}
void UVoxelContentManager::ClearAllLandmarks()
{
for (TPair<FIntVector, FLandmarkInstance>& Pair : LandmarkInstances) { DestroyLandmarkInstance(Pair.Value); }
LandmarkInstances.Reset();
}
void UVoxelContentManager::UpdateLandmarks(const FVector& PlayerWorldPos)
{
if (!StrateManager || !Generator || !Settings) return;
AActor* OwnerActor = Owner.Get();
if (!OwnerActor) return;
const FTransform OwnerXf = OwnerActor->GetActorTransform();
const FVector LocalPlayer = OwnerXf.InverseTransformPosition(PlayerWorldPos);
float TopZ, BotZ;
const bool bInStrate = StrateManager->GetStrateUnrealZRange(LocalPlayer.Z, TopZ, BotZ);
const int32 StrateIndex = bInStrate ? StrateManager->GetStrateIndex(LocalPlayer.Z) : INT32_MIN;
if (!bInStrate)
{
if (LandmarkInstances.Num() > 0) { ClearAllLandmarks(); }
LastLandmarkStrate = INT32_MIN;
return;
}
if (StrateIndex != LastLandmarkStrate)
{
ClearAllLandmarks();
LastLandmarkStrate = StrateIndex;
}
const float ChunkWorld = (float)CHUNK_SIZE * VOXEL_SIZE; // one chunk footprint in cm
// Shared strate context (a strate is a horizontal slab → same everywhere this update).
FDecoContext Ctx;
Ctx.TopVoxelZ = TopZ / VOXEL_SIZE;
Ctx.BottomVoxelZ = BotZ / VOXEL_SIZE;
Ctx.RepChunkZ = FMath::FloorToInt(((TopZ + BotZ) * 0.5f / VOXEL_SIZE) / (float)CHUNK_SIZE);
const FIntVector RepChunk(FMath::FloorToInt(LocalPlayer.X / ChunkWorld),
FMath::FloorToInt(LocalPlayer.Y / ChunkWorld), Ctx.RepChunkZ);
const UVoxelStrateDefinition* Def = StrateManager->GetStrateForChunk(RepChunk);
if (!Def || Def->Landmarks.Num() == 0)
{
if (LandmarkInstances.Num() > 0) { ClearAllLandmarks(); }
return;
}
Ctx.Def = Def;
Ctx.bSurfaceWorld = (StrateManager->GetGeneratorTypeForChunk(RepChunk) == ECaveGeneratorType::SurfaceWorld);
{
const float Wv = StrateManager->GetWaterLevelWorldZForChunk(RepChunk);
Ctx.bHasWater = (Wv != -FLT_MAX);
Ctx.WaterLocalZ = Ctx.bHasWater ? Wv * VOXEL_SIZE : -FLT_MAX;
}
const float Step = (float)FMath::Max(1, Settings->DecorationMarchStepVoxels);
const float ColDepth = (float)FMath::Max(8, Settings->DecorationColumnDepthVoxels);
const uint32 LocalSeed = (uint32)Seed;
// Walk each entry's lattice within its radius (a tiny box), spawn newly-entered cells, drop exited ones.
TSet<FIntVector> Desired;
for (int32 EntryIdx = 0; EntryIdx < Def->Landmarks.Num(); ++EntryIdx)
{
const FStrateLandmark& L = Def->Landmarks[EntryIdx];
if (!L.ActorClass && !L.InstancedMesh) continue;
const float SpacingChunks = FMath::Max(1.0f, L.SpacingChunks);
const int32 RadiusChunks = FMath::Max(1, L.StreamRadiusChunks);
const float CellWorld = SpacingChunks * ChunkWorld; // lattice cell size in cm
const float RadiusWorld = (float)RadiusChunks * ChunkWorld;
const float JitterRange = FMath::Clamp(L.JitterFraction, 0.0f, 1.0f);
const FIntPoint PlayerLCell(FMath::FloorToInt(LocalPlayer.X / CellWorld),
FMath::FloorToInt(LocalPlayer.Y / CellWorld));
const int32 CellRange = FMath::CeilToInt((float)RadiusChunks / SpacingChunks);
for (int32 dy = -CellRange; dy <= CellRange; ++dy)
for (int32 dx = -CellRange; dx <= CellRange; ++dx)
{
const FIntPoint LCell(PlayerLCell.X + dx, PlayerLCell.Y + dy);
// Existence roll for this lattice cell + entry.
const uint32 H = DecoHash(LCell.X, LCell.Y, 0, 0, 0, EntryIdx, LocalSeed, 0x1A2D5u);
if (VoxelHash::ToFloat01(H) > L.SpawnProbability) continue;
// Jittered position inside the cell (centred so two neighbours stay ≥ Spacing·(1-Jitter) apart).
const float jx = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x51A3F1u)) - 0.5f) * JitterRange;
const float jy = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x7C2B93u)) - 0.5f) * JitterRange;
const float LocalX = ((float)LCell.X + 0.5f + jx) * CellWorld;
const float LocalY = ((float)LCell.Y + 0.5f + jy) * CellWorld;
// Radius is a true disk (the lattice box corners would otherwise overshoot it).
const float ddx = LocalX - LocalPlayer.X, ddy = LocalY - LocalPlayer.Y;
if (ddx * ddx + ddy * ddy > RadiusWorld * RadiusWorld) continue;
const FIntVector Key(LCell.X, LCell.Y, EntryIdx);
Desired.Add(Key);
if (LandmarkInstances.Contains(Key)) continue; // already evaluated (spawned OR empty)
FLandmarkInstance Inst;
SpawnLandmarkInstance(L, H, Ctx, OwnerXf, OwnerActor, LocalX, LocalY, Step, ColDepth, Inst);
LandmarkInstances.Add(Key, Inst); // stored even if empty → never re-evaluated while in range
}
}
// Drop instances no longer desired (player moved away, strate's list shrank, etc.).
for (auto It = LandmarkInstances.CreateIterator(); It; ++It)
{
if (Desired.Contains(It.Key())) continue;
DestroyLandmarkInstance(It.Value());
It.RemoveCurrent();
}
}
//=============================================================================
@@ -883,14 +1243,16 @@ void UVoxelContentManager::UpdateWater(const FVector& PlayerWorldPos)
void UVoxelContentManager::ClearAll()
{
ClearAllDecorations();
ClearAllLandmarks();
if (WaterPlane) { WaterPlane->DestroyComponent(); WaterPlane = nullptr; }
LastWaterZ = -FLT_MAX;
LastWaterCell = FIntPoint(INT32_MIN, INT32_MIN);
// Force a full decoration rebuild on the next update.
LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN);
LastStrateIndex = INT32_MIN;
// Force a full decoration + landmark rebuild on the next update.
LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN);
LastStrateIndex = INT32_MIN;
LastLandmarkStrate = INT32_MIN;
}
//=============================================================================
@@ -918,50 +1280,63 @@ void UVoxelContentManager::QueryDecoDebugAt(const FVector& LocalPos, bool& bAppl
const float CellMinX = (float)Cell.X * CellWorld, CellMaxX = CellMinX + CellWorld;
const float CellMinY = (float)Cell.Y * CellWorld, CellMaxY = CellMinY + CellWorld;
if (const FDecoRegionContent* Content = DecoRegions.Find(Region))
// Probe BOTH grids: a point is covered by a Far region always, plus a Near region when close. Aggregate
// applied instances + per-cell counts + building state across the two.
for (const FDecoGrid* GP : { &NearGrid, &FarGrid })
{
bApplied = true;
for (const TWeakObjectPtr<UHierarchicalInstancedStaticMeshComponent>& C : Content->Instances)
const FDecoGrid& G = *GP;
if (const FDecoRegionContent* Content = G.Regions.Find(Region))
{
const UHierarchicalInstancedStaticMeshComponent* Comp = C.Get();
if (!Comp) continue;
const int32 N = Comp->GetInstanceCount();
InstanceCount += N;
// Count the ones actually inside the probed cell → tells a blank cell apart from a blank region.
for (int32 i = 0; i < N; ++i)
bApplied = true;
for (const TWeakObjectPtr<UHierarchicalInstancedStaticMeshComponent>& C : Content->Instances)
{
FTransform Xf;
if (!Comp->GetInstanceTransform(i, Xf, /*bWorldSpace=*/false)) continue;
const FVector P = Xf.GetLocation();
if (P.X >= CellMinX && P.X < CellMaxX && P.Y >= CellMinY && P.Y < CellMaxY)
const UHierarchicalInstancedStaticMeshComponent* Comp = C.Get();
if (!Comp) continue;
const int32 N = Comp->GetInstanceCount();
InstanceCount += N;
// Count the ones actually inside the probed cell → tells a blank cell apart from a blank region.
for (int32 i = 0; i < N; ++i)
{
++InstancesInCell;
FTransform Xf;
if (!Comp->GetInstanceTransform(i, Xf, /*bWorldSpace=*/false)) continue;
const FVector P = Xf.GetLocation();
if (P.X >= CellMinX && P.X < CellMaxX && P.Y >= CellMinY && P.Y < CellMaxY)
{
++InstancesInCell;
}
}
}
}
}
if (const FDecoRegionBuild* Build = RegionBuilds.Find(Region))
{
bBuilding = true;
CellsAccounted = Build->AccountedCells.Num();
if (const FDecoRegionBuild* Build = G.Builds.Find(Region))
{
bBuilding = true;
CellsAccounted += Build->AccountedCells.Num();
}
}
// PER-CELL decisive probe: re-run the march for THIS cell synchronously with the current strate
// context (set each UpdateDecorations). Same inputs the worker uses → byte-identical result, so it
// reports exactly what the scatter decides for this cell right now. Only valid when the probed point
// shares the player's current strate (CurrentCtx/CurrentEntries reflect that); else leave -1.
// PER-CELL decisive probe: re-run the march for THIS cell synchronously with the current strate context
// (set each UpdateDecorations), for BOTH grids' palettes summed. Same inputs the worker uses →
// byte-identical result, so it reports exactly what the scatter decides for this cell right now. Only
// valid when the probed point shares the player's current strate (CurrentCtx reflects that); else -1.
AActor* OwnerActor = Owner.Get();
if (Generator && Settings && OwnerActor && CurrentCtx.Def && CurrentEntries.Num() > 0)
if (Generator && Settings && OwnerActor && CurrentCtx.Def)
{
const int32 Spacing = FMath::Clamp(Settings->DecorationSpacingVoxels, 1, CHUNK_SIZE);
const float Step = (float)FMath::Max(1, Settings->DecorationMarchStepVoxels);
const int32 MaxCross = FMath::Max(1, Settings->DecorationMaxCrossingsPerColumn);
const float ColDepth = (float)FMath::Max(8, Settings->DecorationColumnDepthVoxels);
TArray<FDecoSpawn> Spawns;
BuildCellSpawns(Generator, OwnerActor->GetActorTransform(), Cell, CurrentCtx,
CurrentEntries, CurrentEntryBiome, (uint32)Seed,
Spacing, Step, MaxCross, ColDepth, Spawns);
LiveMarchSpawns = Spawns.Num();
int32 Total = 0; bool bAny = false;
for (const FDecoGrid* GP : { &NearGrid, &FarGrid })
{
const FDecoGrid& G = *GP;
if (G.Entries.Num() == 0) continue;
bAny = true;
TArray<FDecoSpawn> Spawns;
BuildCellSpawns(Generator, OwnerActor->GetActorTransform(), Cell, CurrentCtx,
G.Entries, G.EntryBiome, (uint32)Seed,
G.Spacing, Step, MaxCross, ColDepth, Spawns);
Total += Spawns.Num();
}
if (bAny) { LiveMarchSpawns = Total; }
}
}
@@ -0,0 +1,769 @@
// 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);
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);
StopFillThread();
Super::BeginDestroy();
}
void UVoxelDensityVolume::NotifyShutdown()
{
bShuttingDown.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()
{
// 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);
}
//=============================================================================
// Helpers
//=============================================================================
int32 UVoxelDensityVolume::ResPerAxis() const
{
return FMath::Clamp(Settings ? Settings->DensityVolumeResolution : 128, 32, 256);
}
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). FMath::DivideAndRoundDown truncates toward zero for negatives —
// a footgun the content manager hit too (see FloorDivPos there). Cells span the origin, so floor.
return (A >= 0) ? (A / B) : -(((-A) + B - 1) / B);
}
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 = 1 << L;
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;
T->MipGenSettings = TMGS_NoMipmaps; // 1b-i: base mip only; solidity mips come with the march
// 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 (!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 (!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;
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 = 1 << F.Level;
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
@@ -4,77 +4,17 @@
#include "VoxelMarchingCubesMesher.h"
#include "MarchingCubesTables.h"
//=============================================================================
// DENSITY SAMPLING
//=============================================================================
float UVoxelMarchingCubesMesher::GetDensity(const FVoxelChunk& Chunk, int32 X, int32 Y, int32 Z) const
{
// On n'utilise plus de stockage de blocs — densité demandée directement
// au générateur, qui produit la valeur pour TOUTE coordonnée monde.
// Si le générateur manque, le chunk est considéré tout-air (IsoLevel par défaut = 0).
if (!Generator) return 0.0f;
const float WorldX = Chunk.ChunkCoord.X * CHUNK_SIZE + X;
const float WorldY = Chunk.ChunkCoord.Y * CHUNK_SIZE + Y;
const float WorldZ = Chunk.ChunkCoord.Z * CHUNK_SIZE + Z;
return Generator->GetDensityAt(WorldX, WorldY, WorldZ);
}
//=============================================================================
// EDGE INTERPOLATION
//=============================================================================
FVector UVoxelMarchingCubesMesher::InterpolateEdge(
const FVector& P1, const FVector& P2,
float D1, float D2) const
{
// Densités quasi-égales → on prend le milieu (évite division par ~0).
if (FMath::Abs(D2 - D1) < KINDA_SMALL_NUMBER)
{
return (P1 + P2) * 0.5f;
}
// t = 0 → surface en P1; t = 1 → surface en P2.
float T = (IsoLevel - D1) / (D2 - D1);
T = FMath::Clamp(T, 0.0f, 1.0f);
return P1 + T * (P2 - P1);
}
//=============================================================================
// NORMAL (gradient central de densité)
//=============================================================================
FVector UVoxelMarchingCubesMesher::ComputeGradientNormal(float WorldX, float WorldY, float WorldZ) const
{
// Convention: densité négative = solide, positive = air.
// Le gradient pointe solide→air = vers l'extérieur de la surface.
// Pas de négation à faire.
const float Dx = Generator->GetDensityAt(WorldX + GradientOffset, WorldY, WorldZ)
- Generator->GetDensityAt(WorldX - GradientOffset, WorldY, WorldZ);
const float Dy = Generator->GetDensityAt(WorldX, WorldY + GradientOffset, WorldZ)
- Generator->GetDensityAt(WorldX, WorldY - GradientOffset, WorldZ);
const float Dz = Generator->GetDensityAt(WorldX, WorldY, WorldZ + GradientOffset)
- Generator->GetDensityAt(WorldX, WorldY, WorldZ - GradientOffset);
FVector Normal(Dx, Dy, Dz);
Normal.Normalize();
// Fallback si le gradient est dégénéré (zone plate).
if (Normal.IsNearlyZero())
{
Normal = FVector(0.0f, 0.0f, 1.0f);
}
return Normal;
}
//=============================================================================
// MAIN ALGORITHM
//=============================================================================
// (L'ancien trio GetDensity / InterpolateEdge / ComputeGradientNormal a été retiré :
// mort depuis T1.b — la grille pré-échantillonnée fournit positions ET gradients.)
FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, int32 Step, int32 InCellsPerAxis)
FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, int32 Step, int32 InCellsPerAxis,
TArray<uint8>* OutCaptureGrid)
{
FVoxelMeshData MeshData;
if (OutCaptureGrid) { OutCaptureGrid->Reset(); }
if (!Generator) return MeshData;
// Cell size in voxels. No upper clamp: coarse clipmap levels use bigger steps (the EXTENT
@@ -212,6 +152,27 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
}
}
// ── CAPTURE-DURING-MESHING ──
// Si demandé et que la tuile est pleine résolution (CellsPerAxis==CHUNK_SIZE ⇒ Step==1<<Level,
// donc chaque point de grille = exactement une cellule du clipmap de densité), on recopie les
// CHUNK_SIZE³ points INTÉRIEURS (g=0..CHUNK_SIZE-1, on exclut le point frontière +1 — il
// appartient à la tuile voisine — et l'anneau de marge ±1) dans OutCaptureGrid, quantifiés.
// UVoxelDensityVolume réutilise ces octets au lieu de re-sampler GetDensityAt. Pure lecture de
// DensityGrid : la forme de grille, la boucle deux passes, l'anneau de marge et la réutilisation
// thread_local restent intacts (§8.10). Ordre X→Y→Z (x rapide) = layout attendu par l'ingest.
if (OutCaptureGrid && CellsPerAxis == CHUNK_SIZE)
{
OutCaptureGrid->SetNumUninitialized(CHUNK_SIZE * CHUNK_SIZE * CHUNK_SIZE);
uint8* Cap = OutCaptureGrid->GetData();
int32 ci = 0;
for (int32 gz = 0; gz < CHUNK_SIZE; ++gz)
for (int32 gy = 0; gy < CHUNK_SIZE; ++gy)
for (int32 gx = 0; gx < CHUNK_SIZE; ++gx)
{
Cap[ci++] = VF_QuantizeDensity(DensityGrid[((gz + 1) * MDim + (gy + 1)) * MDim + (gx + 1)]);
}
}
// Lecture grille (avec offset de marge) + gradient central depuis la grille.
auto SampleG = [&](int32 gx, int32 gy, int32 gz) -> float
{
@@ -269,9 +230,9 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
Gradients[i] = GradAt(GX, GY, GZ);
}
// Interpolation des positions + normales sur les arêtes traversées. Le t est
// calculé exactement comme InterpolateEdge → positions bit-identiques (topologie
// inchangée) ; la normale interpole les gradients de coin par le même t.
// Interpolation des positions + normales sur les arêtes traversées. t = point de
// traversée de l'iso entre les deux coins (clampé, milieu si densités quasi-égales) ;
// la normale interpole les gradients de coin par le même t.
FVector EdgeVertices[12];
FVector EdgeNormals[12];
for (int32 i = 0; i < 12; i++)
@@ -294,7 +294,6 @@ void UVoxelStrateManager::GeneratePassages()
Passage.ControlRadii.Add(RadiusAt(T));
}
Passage.bHasMidPoint = false;
Passage.UpperPoint = Passage.ControlPoints[0];
Passage.LowerPoint = Passage.ControlPoints.Last();
Passage.Radius = FMath::Max(Cfg.MouthRadius, Cfg.MidRadius); // fallback / bounds
@@ -309,6 +308,7 @@ void UVoxelStrateManager::GeneratePassages()
MaxDistSq = FMath::Max(MaxDistSq, (float)FVector::DistSquared(Center, CP));
const float R = FMath::Sqrt(MaxDistSq) + Passage.Radius + 4.0f;
Passage.BoundCenter = Center;
Passage.BoundRadius = R;
Passage.BoundRadiusSq = R * R;
}
@@ -336,11 +336,11 @@ void UVoxelStrateManager::GeneratePassages()
// past the seal into the interior, so the seal at (0,0) is breached.
Entry.UpperPoint = FVector(0.0f, 0.0f, TopZ + CHUNK_SIZE);
Entry.LowerPoint = FVector(0.0f, 0.0f, TopZ - CHUNK_SIZE);
Entry.bHasMidPoint = false;
{
const FVector C = (Entry.UpperPoint + Entry.LowerPoint) * 0.5f;
const float R = (float)FVector::Dist(C, Entry.UpperPoint) + Entry.Radius + 4.0f;
Entry.BoundCenter = C;
Entry.BoundRadius = R;
Entry.BoundRadiusSq = R * R;
}
Passages.Add(Entry);
@@ -396,7 +396,7 @@ float UVoxelStrateManager::EvaluateModifierSDF(float WorldX, float WorldY, float
for (int32 i = 0; i < Passages.Num(); ++i)
{
const FVoxelPassage& P = Passages[i];
const float Reach = FMath::Sqrt(P.BoundRadiusSq) + ChunkR;
const float Reach = P.BoundRadius + ChunkR;
if (FVector::DistSquared(CCenter, P.BoundCenter) <= Reach * Reach)
{
SL_Nearby.Add(i);
+252 -89
View File
@@ -10,6 +10,11 @@
#include "VoxelBiomeDefinition.h"
#include "VoxelTerrainOpDefinition.h"
#include "VoxelContentManager.h"
#include "VoxelDensityVolume.h"
#include "Materials/MaterialInstanceDynamic.h"
#include "Materials/MaterialParameterCollection.h"
#include "Kismet/KismetMaterialLibrary.h"
#include "Engine/VolumeTexture.h"
#include "VoxelAtmosphereManager.h"
#include "DrawDebugHelpers.h"
#include "IImageWrapper.h"
@@ -89,6 +94,9 @@ void AVoxelWorld::RegenerateAllChunks()
// Decorations/water are keyed per level-0 chunk — clear them all.
if (ContentManager) { ContentManager->ClearAll(); }
// Density volume: bump epoch (drop in-flight fills) + drop data → full refill next Tick.
if (DensityVolume) { DensityVolume->Reset(); }
// Clear pending set — stale tasks will be discarded by the epoch check.
PendingTiles.Empty();
// Tiles are already destroyed above — drop any deferred-teardown keys so the drain doesn't
@@ -249,6 +257,12 @@ void AVoxelWorld::EndPlay(const EEndPlayReason::Type EndPlayReason)
ContentManager->NotifyShutdown();
}
// Stop + drain the density-volume fill tasks (they read the Generator) before UObject teardown.
if (DensityVolume)
{
DensityVolume->NotifyShutdown();
}
// Destroy any spawned atmosphere layer actors.
if (AtmosphereManager)
{
@@ -328,6 +342,13 @@ void AVoxelWorld::BeginPlay()
AtmosphereManager->Initialize(this, StrateManager, Generator);
}
// Density volume — player-centred clipmap streamed to the GPU for mini-sun raymarched shadows.
if (Settings->bEnableDensityVolume)
{
DensityVolume = NewObject<UVoxelDensityVolume>(this);
DensityVolume->Initialize(this, Generator, Settings);
}
#if WITH_EDITOR
// Listen for data asset edits during PIE so live edit can detect
// strate definition changes (PostEditChangeProperty only fires for
@@ -354,14 +375,31 @@ void AVoxelWorld::Tick(float DeltaTime)
// Distance-based decoration streaming (no LOD pop). Cheap no-op unless the player crosses
// a decoration cell boundary or changes strate; otherwise just drains the spawn budget.
{ TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateDecorations); ContentManager->UpdateDecorations(PlayerLastPos); }
// Rare hash-lattice landmarks (the "mini-suns") — cheap at any radius (scales with count, not area).
{ TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateLandmarks); ContentManager->UpdateLandmarks(PlayerLastPos); }
// One strate-global ocean plane following the player (water at every LOD, to the horizon).
{ TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateWater); ContentManager->UpdateWater(PlayerLastPos); }
}
if (DensityVolume)
{
// Density clipmap for mini-sun shadows: recentre + queue/launch/drain worker fills.
// Cheap unless the player crossed a level-0 cell boundary or a carve dirtied cells.
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateDensityVolume);
DensityVolume->Update(PlayerLastPos);
// Push the clipmap transform + nearest orb to the terrain MIDs (the material's shadow march).
UpdateTerrainMaterialParams();
}
// Bounded-directional mini-sun lighting: stream the nearest orbs into the Light Function MPC.
// Independent of the density volume (self-guards on OrbLightMPC); this is the replacement path.
UpdateOrbLightMPC();
}
ProcessPendingChunks();
ProcessUnloadQueue();
#if ENABLE_DRAW_DEBUG
// Density-volume overlay (step 1a): cyan boxes for solid level-0 cells near the player.
if (DensityVolume) { DensityVolume->DebugDraw(); }
// Inter-strate passage overlay (cyan path, green=upper / red=lower endpoints).
// Points are in voxel coords → world units (×VOXEL_SIZE) → actor space.
if (bDebugDrawPassages && StrateManager)
@@ -380,11 +418,6 @@ void AVoxelWorld::Tick(float DeltaTime)
for (int32 j = 0; j < P.ControlPoints.Num() - 1; ++j)
DrawSeg(P.ControlPoints[j], P.ControlPoints[j + 1]);
}
else if (P.bHasMidPoint)
{
DrawSeg(P.UpperPoint, P.MidPoint);
DrawSeg(P.MidPoint, P.LowerPoint);
}
else
{
DrawSeg(P.UpperPoint, P.LowerPoint);
@@ -408,65 +441,6 @@ FVector AVoxelWorld::GetPlayerPosition() const
return FVector::ZeroVector;
}
int32 AVoxelWorld::GetLODForChunk(const FIntVector& ChunkCoord, const FIntVector& CenterChunk) const
{
// Chebyshev distance (max of absolute differences on each axis)
// This gives a cubic LOD zone instead of spherical — simpler and
// matches how chunks are loaded (cubic view distance).
FIntVector Delta = ChunkCoord - CenterChunk;
int32 Distance = FMath::Max3(
FMath::Abs(Delta.X),
FMath::Abs(Delta.Y),
FMath::Abs(Delta.Z)
);
if (Distance <= Settings->LOD0Distance)
{
return 0; // Full resolution
}
else if (Distance <= Settings->LOD1Distance)
{
return 1; // Half resolution
}
else
{
return 2; // Quarter resolution
}
}
int32 AVoxelWorld::LODToStep(int32 LODLevel)
{
// LOD0 → 1, LOD1 → 2, LOD2 → 4
// Using bit shift: 1 << LODLevel
return 1 << FMath::Clamp(LODLevel, 0, 2);
}
bool AVoxelWorld::IsChunkInRange(const FIntVector& ChunkCoord, const FIntVector& CenterChunk) const
{
const int32 ViewXY = Settings->ViewDistanceXY;
const int32 ViewUp = Settings->ViewDistanceUp;
const int32 ViewDown = Settings->ViewDistanceDown;
FIntVector Range = ChunkCoord - CenterChunk;
if ((FMath::Abs(Range.X) <= ViewXY) and (FMath::Abs(Range.Y) <= ViewXY)) {
if (Range.Z > 0)
{
if (FMath::Abs(Range.Z) <= ViewUp)
{
return true;
}
}
else
{
if (FMath::Abs(Range.Z) <= ViewDown)
{
return true;
}
}
}
return false;
}
void AVoxelWorld::ProcessPendingChunks()
{
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ProcessPending);
@@ -509,6 +483,14 @@ void AVoxelWorld::ProcessPendingChunks()
// Mark the tile loaded (even if empty — so we don't re-submit it).
LoadedTiles.Add(DequeuedChunk.Tile);
// CAPTURE-DURING-MESHING: hand the mesher's captured density grid to the clipmap BEFORE the
// empty-tile early-out — all-air / all-solid tiles are exactly the uniform cells the volume
// needs, and they carry a valid CaptureGrid even though they render nothing.
if (DensityVolume && DequeuedChunk.CaptureGrid.Num() > 0)
{
DensityVolume->IngestTileCapture(DequeuedChunk.Tile.Coord, MoveTemp(DequeuedChunk.CaptureGrid));
}
// Empty mesh = all-air tile — nothing to render, but still "loaded".
if (DequeuedChunk.bEmpty || !DequeuedChunk.Streams)
{
@@ -807,13 +789,22 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile)
const int32 Step = FMath::Max(1, Extent / Cells);
const uint32 TaskEpoch = GenerationEpoch;
// CAPTURE-DURING-MESHING: only level-0 full-res tiles map 1:1 onto a density-clipmap level
// (Step == 1<<Level, Cells == CHUNK_SIZE). When the density volume is active, ask the mesher to
// emit the captured R8 grid so the volume reuses it instead of re-sampling GetDensityAt. Gated to
// tiles the volume can actually consume (its shadow window is much smaller than the streaming
// ring) — the rest shouldn't pay the quantize + 32 KB queue payload for a grid it would refuse.
const bool bWantCapture = (Tile.Level == 0) && (Cells == CHUNK_SIZE)
&& DensityVolume != nullptr && Settings && Settings->bEnableDensityVolume
&& DensityVolume->IsTileCaptureUseful(Tile.Coord);
ActiveTaskCount.fetch_add(1, std::memory_order_relaxed);
// BackgroundNormal priority: gen runs on background workers that YIELD to foreground
// (game/render-thread) tasks. Without this, raising MaxConcurrentTasks past the spare
// core count saturates the scheduler and starves the frame (the "over 12 = lag" symptom).
// At background priority the frame keeps its cores; gen just fills in around it.
UE::Tasks::Launch(TEXT("ChunkGen"), [this, Tile, OriginVoxels, Step, Cells, TaskEpoch]()
UE::Tasks::Launch(TEXT("ChunkGen"), [this, Tile, OriginVoxels, Step, Cells, TaskEpoch, bWantCapture]()
{
// RAII: decrement the counter on every exit path.
struct FTaskGuard
@@ -831,7 +822,8 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile)
FVoxelMeshData MeshData;
{
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_GenerateMesh);
MeshData = Mesher->GenerateMesh(OriginVoxels, Step, Cells);
MeshData = Mesher->GenerateMesh(OriginVoxels, Step, Cells,
bWantCapture ? &Result.CaptureGrid : nullptr);
}
// T1.f — build the RMC geometry buffers HERE (worker), not on the game thread. Empty/all-air
@@ -925,6 +917,17 @@ void AVoxelWorld::ApplyMeshToTile(const FVoxelTileKey& Tile, RealtimeMesh::FReal
}
}
// Mini-sun shadows: route the resolved base material through a shared MID that binds the density-volume
// textures + per-frame shadow params (the material marches them for raymarched orb shadows). One MID
// per base material, so all tiles of a base still share one material (no batching cost).
if (DensityVolume && Settings && Settings->bEnableDensityVolume)
{
if (UMaterialInstanceDynamic* MID = GetOrCreateTerrainMID(ChunkMaterial))
{
ChunkMaterial = MID;
}
}
// The geometry stream set was built on the worker (BuildTileStreamSet, T1.f); we just upload it.
// Vertices are world-space; the component sits at the actor origin.
@@ -1015,39 +1018,25 @@ FVoxelBiomeQuery AVoxelWorld::GetBiomeAtWorldLocation(FVector WorldLocation) con
// TERRAIN MODIFICATION — player carving & filling
//=============================================================================
// All brush entry points below build an FVoxelModification and funnel through ApplyModification
// (diff layer + re-mesh). Strength sign convention: NEGATIVE = carve (air), POSITIVE = fill (solid).
void AVoxelWorld::CarveAtPosition(FVector Position, float Radius, float Strength)
{
if (!DiffLayer) return;
// Convert world position (Unreal units) to voxel space.
// VOXEL_SIZE = 25 in VoxelForge, so divide by it.
const FVector VoxelPos = Position / VOXEL_SIZE;
// Carve = negative strength (subtracts density → creates air)
FVoxelModification Mod;
Mod.Center = VoxelPos;
Mod.Center = Position / VOXEL_SIZE; // world cm → voxel space
Mod.Radius = Radius;
Mod.Strength = -FMath::Abs(Strength); // Force negative for carving
TArray<FIntVector> AffectedChunks = DiffLayer->ApplyModification(Mod);
RemeshDirtyChunks(AffectedChunks);
Mod.Strength = -FMath::Abs(Strength); // force negative for carving
ApplyModification(Mod);
}
void AVoxelWorld::FillAtPosition(FVector Position, float Radius, float Strength)
{
if (!DiffLayer) return;
// Convert world position to voxel space
const FVector VoxelPos = Position / VOXEL_SIZE;
// Fill = positive strength (adds density → creates solid)
FVoxelModification Mod;
Mod.Center = VoxelPos;
Mod.Center = Position / VOXEL_SIZE;
Mod.Radius = Radius;
Mod.Strength = FMath::Abs(Strength); // Force positive for filling
TArray<FIntVector> AffectedChunks = DiffLayer->ApplyModification(Mod);
RemeshDirtyChunks(AffectedChunks);
Mod.Strength = FMath::Abs(Strength); // force positive for filling
ApplyModification(Mod);
}
void AVoxelWorld::ApplyModification(const FVoxelModification& Modification)
@@ -1342,6 +1331,180 @@ void AVoxelWorld::RemeshDirtyChunks(const TArray<FIntVector>& DirtyCoords)
LoadTile(Tile);
}
// Density volume: refill the clipmap cells overlapping each carved chunk so the shadow march
// sees the edit (GetDensityAt includes the diff layer). Cheap + local; covers all carve shapes.
if (DensityVolume)
{
for (const FIntVector& Coord : DirtyCoords)
{
const FIntVector MinV = Coord * CHUNK_SIZE;
const FIntVector MaxV = MinV + FIntVector(CHUNK_SIZE, CHUNK_SIZE, CHUNK_SIZE);
DensityVolume->MarkDirtyVoxelBox(MinV, MaxV);
}
}
UE_LOG(LogTemp, Verbose, TEXT("[VoxelWorld] RemeshDirtyChunks: %d coords, %d pending"),
DirtyCoords.Num(), PendingTiles.Num());
}
UVolumeTexture* AVoxelWorld::GetDensityVolumeTexture(int32 Level) const
{
return DensityVolume ? DensityVolume->GetLevelTexture(Level) : nullptr;
}
//=============================================================================
// TERRAIN MATERIAL — density-volume / orb shadow params (MID-driven, see ApplyMeshToTile)
//=============================================================================
UMaterialInstanceDynamic* AVoxelWorld::GetOrCreateTerrainMID(UMaterialInterface* Base)
{
if (!Base) return nullptr;
if (TObjectPtr<UMaterialInstanceDynamic>* Found = TerrainMIDs.Find(Base))
{
return Found->Get();
}
UMaterialInstanceDynamic* MID = UMaterialInstanceDynamic::Create(Base, this);
if (MID)
{
TerrainMIDs.Add(Base, MID);
SetVolumeParamsOnMID(MID); // seed with the current frame's params
}
return MID;
}
void AVoxelWorld::SetVolumeParamsOnMID(UMaterialInstanceDynamic* MID) const
{
if (!MID) return;
// Static FNames — this runs per MID on every param change; no per-call FName construction.
static const FName VolPNames[10] = {
FName("VolP0"), FName("VolP1"), FName("VolP2"), FName("VolP3"), FName("VolP4"),
FName("VolP5"), FName("VolP6"), FName("VolP7"), FName("VolP8"), FName("VolP9") };
static const FName VolTexNames[3] = { FName("VolTex0"), FName("VolTex1"), FName("VolTex2") };
const FLinearColor* TVPs[10] = { &TVP0, &TVP1, &TVP2, &TVP3, &TVP4, &TVP5, &TVP6, &TVP7, &TVP8, &TVP9 };
for (int32 i = 0; i < 10; ++i) { MID->SetVectorParameterValue(VolPNames[i], *TVPs[i]); }
if (DensityVolume)
{
for (int32 L = 0; L < 3; ++L)
{
if (UVolumeTexture* T = DensityVolume->GetLevelTexture(L)) { MID->SetTextureParameterValue(VolTexNames[L], T); }
}
}
}
void AVoxelWorld::UpdateTerrainMaterialParams()
{
if (!DensityVolume || !Settings || !Settings->bEnableDensityVolume) return;
// --- Per-level clipmap transforms (L0 = finest/near; L1-2 = coarser for shadow REACH) ---
// For each level the material maps WorldPos → RelPos = WorldPos - WindowOrigin → cellF = RelPos/Cell →
// toroidal UVW = frac((OriginMod + cellF + 0.5)/Res). OriginMod = OriginCells mod Res precomputed here
// so the shader never touches the large absolute cell coord (no float precision loss). The shader
// derives each level's cell size from L0's (cell_L = L0Cell * 2^L); Res is shared across levels.
const FTransform Xf = GetActorTransform();
int32 Res = 0;
bool bHave = false;
float CellWorldSize = VOXEL_SIZE; // L0 cm per cell
FLinearColor OriginC[3] = { FLinearColor::Black, FLinearColor::Black, FLinearColor::Black };
FLinearColor ModC[3] = { FLinearColor::Black, FLinearColor::Black, FLinearColor::Black };
for (int32 L = 0; L < 3; ++L)
{
FIntVector OriginCells(0, 0, 0);
float StepF = 1.0f;
int32 LRes = 0;
if (DensityVolume->GetLevelShaderParams(L, OriginCells, StepF, LRes) && LRes > 0)
{
const FVector OriginLocalCm = FVector(OriginCells.X, OriginCells.Y, OriginCells.Z) * (StepF * VOXEL_SIZE);
const FVector OW = Xf.TransformPosition(OriginLocalCm);
auto Mod = [LRes](int32 v) { const int32 m = v % LRes; return (float)((m < 0) ? m + LRes : m); };
OriginC[L] = FLinearColor(OW.X, OW.Y, OW.Z, 0.0f);
ModC[L] = FLinearColor(Mod(OriginCells.X), Mod(OriginCells.Y), Mod(OriginCells.Z), 0.0f);
if (L == 0) { bHave = true; Res = LRes; CellWorldSize = VOXEL_SIZE * StepF; }
}
}
// Track whether anything actually changed — the push below enqueues render-thread updates per MID,
// so on the (common) idle frames where the window didn't scroll and the orb didn't change, skip it.
bool bDirty = false;
auto SetTVP = [&bDirty](FLinearColor& Dst, const FLinearColor& V)
{
if (Dst != V) { Dst = V; bDirty = true; }
};
SetTVP(TVP0, OriginC[0]); SetTVP(TVP1, ModC[0]);
SetTVP(TVP6, OriginC[1]); SetTVP(TVP7, ModC[1]);
SetTVP(TVP8, OriginC[2]); SetTVP(TVP9, ModC[2]);
// --- Nearest active orb ---
FVoxelActiveOrb Best;
bool bHaveOrb = false;
if (ContentManager)
{
TArray<FVoxelActiveOrb> Orbs;
ContentManager->GetActiveOrbs(Orbs);
if (Orbs.Num() > 0)
{
const FVector P = GetPlayerPosition();
float BestD = FLT_MAX;
for (const FVoxelActiveOrb& O : Orbs)
{
const float D = FVector::DistSquared(O.WorldPos, P);
if (D < BestD) { BestD = D; Best = O; bHaveOrb = true; }
}
}
}
// All data lives in .xyz (a Vector Parameter only delivers float3 into a Custom node). Intensity is
// premultiplied into the colour; Res / CellWorldSize / Enable go in TVP5.
const float Enable = (bHave && bHaveOrb && Res > 0) ? 1.0f : 0.0f;
const float Steps = (float)FMath::Clamp(Settings->DensityVolumeMarchSteps, 4, 256);
SetTVP(TVP2, FLinearColor(Best.WorldPos.X, Best.WorldPos.Y, Best.WorldPos.Z, 0.0f));
SetTVP(TVP3, FLinearColor(Best.Color.R * Best.Intensity, Best.Color.G * Best.Intensity, Best.Color.B * Best.Intensity, 0.0f));
SetTVP(TVP4, FLinearColor(Best.MaxShadowDistWorld, Best.FalloffWorld, Steps, 0.0f));
SetTVP(TVP5, FLinearColor((float)FMath::Max(Res, 0), CellWorldSize, Enable, 0.0f));
// Re-push if the L0 texture object itself was recreated (resolution change) even when the packed
// params happen to be identical — otherwise the MIDs would keep sampling the dropped texture.
UVolumeTexture* Tex0 = DensityVolume->GetLevelTexture(0);
if (LastBoundVolTex0.Get() != Tex0) { LastBoundVolTex0 = Tex0; bDirty = true; }
if (!bDirty) return;
// Push to every terrain MID (new MIDs are seeded on creation in GetOrCreateTerrainMID).
for (TPair<TObjectPtr<UMaterialInterface>, TObjectPtr<UMaterialInstanceDynamic>>& Pair : TerrainMIDs)
{
SetVolumeParamsOnMID(Pair.Value.Get());
}
}
void AVoxelWorld::UpdateOrbLightMPC()
{
if (!OrbLightMPC || !ContentManager) return;
TArray<FVoxelActiveOrb> Orbs;
ContentManager->GetActiveOrbs(Orbs);
// Nearest-first so Orb0..3 are the 4 closest orbs (the Light Function unions their pools; 4 is
// plenty since only nearby pools are visible and the player sits inside one or two at a time).
const FVector P = GetPlayerPosition();
Orbs.Sort([&P](const FVoxelActiveOrb& A, const FVoxelActiveOrb& B)
{
return FVector::DistSquared(A.WorldPos, P) < FVector::DistSquared(B.WorldPos, P);
});
static const FName OrbNames[4] = { FName("Orb0"), FName("Orb1"), FName("Orb2"), FName("Orb3") };
for (int32 i = 0; i < 4; ++i)
{
// (x,y,z) = orb WORLD position, .w = reach radius in cm (FalloffWorld = how far the pool
// extends). Unused slots = all-zero → radius 0 → the mask yields no pool for them.
FLinearColor V(0.f, 0.f, 0.f, 0.f);
if (i < Orbs.Num())
{
const FVoxelActiveOrb& O = Orbs[i];
V = FLinearColor((float)O.WorldPos.X, (float)O.WorldPos.Y, (float)O.WorldPos.Z, O.FalloffWorld);
}
// Orbs are static once placed, so most frames change nothing — skip the MPC write (it
// dirties the collection's uniform buffer for every material that reads it).
if (LastOrbMPC[i] == V) continue;
LastOrbMPC[i] = V;
UKismetMaterialLibrary::SetVectorParameterValue(this, OrbLightMPC, OrbNames[i], V);
}
}
-34
View File
@@ -1,34 +0,0 @@
// VoxelChunk.h
// Identifiant léger de chunk.
//
// Rôle: dans un monde density-only (pas de blocs), le chunk n'a plus rien
// à stocker — la densité est évaluée à la volée par le générateur à partir
// des coordonnées monde. On garde un struct fin pour:
// - Servir de clé/valeur dans les collections de AVoxelWorld (Chunks, FChunkResult)
// - Fournir l'helper GetWorldPosition() au mesher
// - Laisser une place si on veut cacher des infos par chunk plus tard
// (index de strate, LOD courant, etc.)
#pragma once
#include "CoreMinimal.h"
#include "VoxelTypes.h"
#include "VoxelChunk.generated.h"
USTRUCT(BlueprintType)
struct FVoxelChunk
{
GENERATED_BODY()
// Coordonnée de chunk dans la grille mondiale (peut être négative).
FIntVector ChunkCoord = FIntVector::ZeroValue;
FVoxelChunk() = default;
explicit FVoxelChunk(const FIntVector& InCoord) : ChunkCoord(InCoord) {}
// Coin (0,0,0) du chunk en espace monde (cm).
FVector GetWorldPosition() const
{
return ChunkToWorldPos(ChunkCoord);
}
};
+121 -36
View File
@@ -24,9 +24,17 @@
// 2) WATER — ONE strate-global ocean plane that follows the player (UpdateWater). Terrain pokes
// through it, so it reads as water at every LOD / to the horizon with no per-tile gaps. One draw.
//
// RENDERING PATHS / DISTANCE TIERS per entry (FStrateDecoration): non-instanced ActorClass entries with
// MaxLODLevel==0 are near-only (DecorationActorRadiusChunks — pricey actors stay close); InstancedMesh
// (HISM) entries + MaxLODLevel>=1 actor entries are any-distance (DecorationRadiusChunks).
// TWO STREAMING GRIDS (FStrateDecoration::StreamTier, §8.5). To stay flicker-free the stream RADIUS must
// be a property of the grid, not the entry (mixing radii in one grid would re-stream a region in place as
// the player crosses an entry's radius — the old tier system's flicker bug). So there are exactly two
// self-contained region streams, and an entry picks one:
// • FarGrid — DecorationRadiusChunks radius + DecorationFarSpacingVoxels (COARSE) grid. Default. Cheap
// for rare/large props visible everywhere (sparse marching across the full radius).
// • NearGrid — DecorationNearRadiusChunks radius + DecorationSpacingVoxels (FINE) grid. Dense groundcover
// near the player only; bounding its radius keeps far-region HISM build + memory small.
// Each grid owns its own region/build/queue-routing state (FDecoGrid) and its own subset of the palette;
// the two never share a HISM, so crossing the near boundary loads/unloads a near region without touching
// the far one (no flicker). A given world XY is covered by a far region always, plus a near region when close.
//
// DETERMINISM: same seed + world ⇒ identical placement. Spawning runs on the game thread.
@@ -49,6 +57,19 @@ class UStaticMeshComponent;
class UHierarchicalInstancedStaticMeshComponent;
class UMaterialInterface;
// An active mini-sun light orb (a placed FStrateLandmark with bIsLightOrb). The terrain material marches
// the density volume toward the nearest of these for raymarched shadows. Plain struct (not reflected);
// distances are in WORLD cm (already converted from the landmark's voxel units). See FStrateLandmark.
struct FVoxelActiveOrb
{
FVector WorldPos = FVector::ZeroVector;
FLinearColor Color = FLinearColor::White;
float Intensity = 1.0f;
float RadiusWorld = 400.0f; // cm
float FalloffWorld = 50000.0f; // cm
float MaxShadowDistWorld = 25000.0f; // cm
};
UCLASS()
class VOXELFORGE_API UVoxelContentManager : public UObject
{
@@ -78,6 +99,20 @@ public:
* launch async march tasks (capped), and apply finished results budgeted. Call every Tick. */
void UpdateDecorations(const FVector& PlayerWorldPos);
//--- LANDMARKS (rare large objects on a coarse hash lattice — the "mini-suns") -----------
/** Stream rare landmark objects around the player. Unlike decorations, these sit on a COARSE hash
* lattice (cell = `FStrateLandmark::SpacingChunks` chunks), so cost scales with the number of
* landmarks in range, not the area — a huge StreamRadiusChunks stays cheap (no per-chunk enumeration,
* no freeze). Synchronous game-thread placement (a surface-find runs only when a NEW lattice cell
* enters range; there are very few). Deterministic (hash of cell+entry+seed) → pop-free. Call every
* Tick. Strate-bounded like decorations (wiped + rebuilt on strate change). */
void UpdateLandmarks(const FVector& PlayerWorldPos);
/** Collect the currently-placed mini-sun light orbs (landmarks with bIsLightOrb). Cheap — iterates
* the small LandmarkInstances map. AVoxelWorld picks the nearest to feed the terrain material's
* raymarched shadows. */
void GetActiveOrbs(TArray<FVoxelActiveOrb>& OutOrbs) const;
/** Destroy all spawned content (decorations + water). Regenerate / season reset. Bumps the deco
* epoch so any in-flight march tasks' results are discarded. */
void ClearAll();
@@ -109,6 +144,7 @@ public:
{
FIntPoint Cell = FIntPoint::ZeroValue;
uint32 BuildId = 0; // identity of the region build this cell belongs to
EDecoStreamTier Grid = EDecoStreamTier::Far; // which grid (Near/Far) this result routes back to
TArray<FStrateDecoration> Entries; // snapshot the game thread spawns from (by EntryIdx)
TArray<FDecoSpawn> Spawns;
};
@@ -152,6 +188,29 @@ private:
TSet<FIntPoint> AccountedCells;
};
// All per-grid streaming state, instantiated once per tier (NearGrid / FarGrid). Each grid is a fully
// self-contained region stream: its own loaded regions, in-progress builds, launch/in-flight queues,
// completed list, build-id counter, palette subset, and (radius, spacing) config. The two grids never
// share a HISM, so they load/unload independently with no cross-tier flicker (see the file header).
struct FDecoGrid
{
EDecoStreamTier Tier = EDecoStreamTier::Far; // identity (stamped on results so they route back here)
int32 Radius = 6; // stream radius in cells (= chunks)
int32 Spacing = 4; // march column spacing in voxels (fine for Near, coarse for Far)
TMap<FIntPoint, FDecoRegionContent> Regions; // loaded regions
TMap<FIntPoint, FDecoRegionBuild> Builds; // regions being marched
TArray<FIntPoint> PendingLaunch; // cells awaiting a march task (nearest-first)
TSet<FIntPoint> InFlightCells; // cells with a task in flight
TArray<FIntPoint> Completed; // regions whose last cell landed, awaiting apply
uint32 NextBuildId = 1; // monotonic build id (per grid)
// Palette subset for THIS tier, rebuilt each update. Entries[i] is owned by context-biome
// EntryBiome[i] (-1 = strate fallback, always matches). EntryIdx in a result indexes this snapshot.
TArray<FStrateDecoration> Entries;
TArray<int32> EntryBiome;
};
// Constant per-update strate context (a strate is a horizontal slab → same for every cell). Carries
// only PODs/Z-bounds so it is safe to copy into a worker task (no UObject deref on the worker).
struct FDecoContext
@@ -170,6 +229,19 @@ private:
FBiomeContext BiomeCtx;
};
// One spawned landmark (rare hash-lattice object). Weak — the owner actor keeps it alive. BOTH null
// means the cell was evaluated but placed nothing (gate failed) — kept so we don't re-evaluate it.
struct FLandmarkInstance
{
TWeakObjectPtr<AActor> Actor; // set when the entry uses ActorClass
TWeakObjectPtr<UStaticMeshComponent> Component; // set when the entry uses InstancedMesh
// Mini-sun light orb data (set in SpawnLandmarkInstance when the landmark has bIsLightOrb). The
// terrain material consumes the nearest active orb for raymarched shadows (see GetActiveOrbs).
bool bIsOrb = false;
FVoxelActiveOrb Orb;
};
/** WORKER-THREAD surface find → fills OutSpawns for one cell. SurfaceWorld uses the height oracle
* (cheap, O(1)/column); other archetypes ray-march the density column. No UObject access except
* Generator (thread-safe). Determinism-critical. Resolves the dominant biome PER COLUMN
@@ -182,17 +254,38 @@ private:
int32 Spacing, float Step, int32 MaxCrossings, float ColumnDepth,
TArray<FDecoSpawn>& OutSpawns);
void LaunchDecoTasks(const FIntPoint& PlayerCell);
void ProcessDecoResults(const FIntPoint& PlayerCell, int32 FarR);
void MergeCellResult(const FDecoCellResult& Result); // fold one cell's spawns into its region build
void MarkCellDone(const FIntPoint& Region, const FIntPoint& Cell, uint32 BuildId); // idempotent per-cell accounting
void ApplyRegion(const FIntPoint& Region, FDecoRegionBuild& Build);
void RebuildDesiredCells(const FIntPoint& PlayerCell);
void ClearDecorationRegion(const FIntPoint& Region);
// Each step operates on ONE grid (G = NearGrid or FarGrid). LaunchDecoTasks throttles against the
// COMBINED in-flight count (OtherInFlight = the other grid's in-flight cells) so the two grids share
// one concurrency budget. ProcessDecoResults drains the shared result queue, routing each result to its
// grid by FDecoCellResult::Grid, then applies both grids' completed regions under one frame budget.
void LaunchDecoTasks(FDecoGrid& G, const FIntPoint& PlayerCell, int32 OtherInFlight, int32 MaxConc);
void ProcessDecoResults(const FIntPoint& PlayerCell);
void MergeCellResult(FDecoGrid& G, const FDecoCellResult& Result); // fold one cell's spawns into its region build
void MarkCellDone(FDecoGrid& G, const FIntPoint& Region, const FIntPoint& Cell, uint32 BuildId); // idempotent per-cell accounting
void ApplyRegion(FDecoGrid& G, const FIntPoint& Region, FDecoRegionBuild& Build);
void RebuildDesiredCells(FDecoGrid& G, const FIntPoint& PlayerCell);
void ClearDecorationRegion(FDecoGrid& G, const FIntPoint& Region);
void ClearAllDecorations();
void DrainDecoResults(); // discard every queued march result
static void ResetGridBuildState(FDecoGrid& G); // drop builds/queues (loaded regions untouched)
// Region size in cells, clamped (>=1). Cell↔region math lives in file-static helpers in the .cpp.
int32 RegionSize() const;
//--- LANDMARKS (hash-lattice rare objects) -------------------------------------------------
// Evaluate ONE lattice cell's landmark: biome/surface/slope/water gates, then spawn the actor/mesh.
// Leaves Out empty (null) when the cell is "evaluated but nothing placed" so it is never re-evaluated
// while it stays in range. H = the cell's existence hash (drives jitter/rotation/scale determinism).
void SpawnLandmarkInstance(const FStrateLandmark& L, uint32 H, const FDecoContext& Ctx,
const FTransform& OwnerXf, AActor* OwnerActor,
float LocalX, float LocalY, float Step, float ColDepth, FLandmarkInstance& Out);
void DestroyLandmarkInstance(FLandmarkInstance& Inst);
void ClearAllLandmarks();
// Single-column surface find for a landmark (voxel XY): SurfaceWorld → height oracle, else ray-march the
// strate band for the first crossing whose orientation matches Surf. Fills Z (voxel) + outward world normal.
static bool FindLandmarkColumn(const UVoxelGenerator* Gen, const FTransform& OwnerXf,
const FDecoContext& Ctx, float VX, float VY, ESurfaceType Surf,
float Step, float ColDepth, float& OutZ, FVector& OutNormal);
TWeakObjectPtr<AActor> Owner;
@@ -211,27 +304,22 @@ private:
UPROPERTY()
UStaticMesh* PlaneMesh = nullptr;
// Loaded decoration regions (FIntPoint = region XY). One HISM per mesh per region. Not a UPROPERTY
// (weak ptrs inside; the owner actor keeps the components alive).
TMap<FIntPoint, FDecoRegionContent> DecoRegions;
// The two streaming grids. Each owns its loaded regions, in-progress builds, launch/in-flight queues,
// completed list, build-id counter, palette subset, and (radius, spacing) config — see FDecoGrid. The
// (radius, spacing) are refreshed from VoxelSettings each update; the regions are NOT UPROPERTYs (weak
// ptrs inside; the owner actor keeps the spawned components alive).
FDecoGrid NearGrid;
FDecoGrid FarGrid;
// Regions currently being marched cell-by-cell; merged here until every cell reports, then applied.
TMap<FIntPoint, FDecoRegionBuild> RegionBuilds;
// Cells that are desired but need a march task launched (nearest-first).
TArray<FIntPoint> PendingLaunch;
// Cells with a march task in flight (awaiting a result).
TSet<FIntPoint> InFlightCells;
// Worker tasks enqueue here (Mpsc: many workers, one game-thread consumer).
// Worker tasks enqueue here (Mpsc: many workers, one game-thread consumer). SHARED across both grids;
// each result carries its FDecoCellResult::Grid so ProcessDecoResults routes it to the right grid.
TQueue<FDecoCellResult, EQueueMode::Mpsc> DecoResults;
// Regions whose last cell just landed, awaiting budgeted game-thread apply (HISM build + actor spawn).
TArray<FIntPoint> CompletedRegions;
// Monotonic id stamped on each region build + the cell tasks it launches. A cell result merges only
// if its BuildId still matches the live build for that region → a region that was cleared and later
// re-marched (same coords, new BuildId) never absorbs a stale in-flight cell from its prior life.
uint32 NextBuildId = 1;
// Spawned landmarks, keyed by FIntVector(latticeCellX, latticeCellY, entryIndex) — FIntVector already
// hashes, so no custom key type is needed. An entry with both ptrs null = "evaluated, nothing placed"
// (kept until the cell leaves range so the surface-find isn't repeated). Strate-bounded.
TMap<FIntVector, FLandmarkInstance> LandmarkInstances;
int32 LastLandmarkStrate = INT32_MIN; // strate change → wipe + rebuild landmarks
// Set in BeginDestroy; worker tasks check it before touching us.
std::atomic<bool> bShuttingDown{false};
@@ -241,16 +329,13 @@ private:
int32 LastStrateIndex = INT32_MIN;
// Shared strate context for the current update (recomputed each UpdateDecorations; the launch step
// copies the PODs into each task).
// copies the PODs into each task). Same for both grids — a strate is a horizontal slab.
FDecoContext CurrentCtx;
// Decoration palette for the current update, built ONCE (a strate's biome field is XY-global, so the
// flat list is the same for every cell — only the per-COLUMN biome pick varies). CurrentEntries is the
// concatenation of every biome's decoration list (or the strate's when a biome has none / biomes are
// disabled); CurrentEntryBiome[i] is the context-biome index that owns entry i (-1 = strate fallback,
// always matches). The worker resolves a column's dominant biome and rolls only the entries it owns.
TArray<FStrateDecoration> CurrentEntries;
TArray<int32> CurrentEntryBiome;
// The decoration palette is built ONCE per update (a strate's biome field is XY-global, so the flat
// list is the same for every cell — only the per-COLUMN biome pick varies) and PARTITIONED by tier into
// NearGrid.Entries / FarGrid.Entries (with parallel EntryBiome). Each list is the concatenation of every
// biome's decoration entries of that tier (or the strate's when a biome has none / biomes are disabled).
// Single strate-global ocean plane, repositioned to follow the player (see UpdateWater).
UPROPERTY()
@@ -0,0 +1,223 @@
// 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"
#include <atomic>
#include "VoxelDensityVolume.generated.h"
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);
/** 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 };
// 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;
};
@@ -13,7 +13,6 @@
#include "CoreMinimal.h"
#include "VoxelTypes.h" // Pour FVoxelMeshData, CHUNK_SIZE, VOXEL_SIZE, etc.
#include "VoxelChunk.h"
#include "VoxelGenerator.h"
#include "VoxelMarchingCubesMesher.generated.h"
@@ -31,8 +30,15 @@ public:
* @param CellsPerAxis - Nombre de cellules par axe. Les tuiles GROSSIÈRES en utilisent MOINS
* (gen moins chère, maillage plus grossier au loin) tout en couvrant la
* même étendue (extent = CellsPerAxis*Step). Niveau 0 = CHUNK_SIZE.
* @param OutCaptureGrid - CAPTURE-DURING-MESHING (optionnel). Si non-null ET CellsPerAxis==CHUNK_SIZE
* (tuile pleine résolution, Step==1<<Level, donc 1:1 avec une cellule du clipmap
* de densité), on y recopie les CHUNK_SIZE³ points intérieurs de la grille de
* densité déjà échantillonnée, quantifiés via VF_QuantizeDensity. Cela évite à
* UVoxelDensityVolume de re-sampler GetDensityAt pour ces cellules (le mesher
* les a déjà calculées). Vidé puis rempli ; reste vide si non éligible.
*/
FVoxelMeshData GenerateMesh(FIntVector OriginVoxels, int32 Step = 1, int32 CellsPerAxis = CHUNK_SIZE);
FVoxelMeshData GenerateMesh(FIntVector OriginVoxels, int32 Step = 1, int32 CellsPerAxis = CHUNK_SIZE,
TArray<uint8>* OutCaptureGrid = nullptr);
//=========================================================================
// SERVICES (injectés par AVoxelWorld)
@@ -52,10 +58,6 @@ public:
// Convention MC: densité < IsoLevel = solide, >= = air.
float IsoLevel = 0.0f;
// Distance d'échantillonnage (en voxels) pour calculer la normale par
// différence centrée du gradient. Plus petit = plus détaillé mais bruité.
float GradientOffset = 1.0f;
// SKIRTS — bouchent les fissures aux frontières de tuiles entre niveaux de clipmap voisins
// (résolutions différentes → les iso-surfaces ne se rejoignent pas exactement). Une jupe
// (mur court) est extrudée vers le solide depuis chaque arête de surface posée sur une des 6
@@ -64,19 +66,4 @@ public:
// Profondeur de la jupe, en CELLULES de la tuile (× Step × VOXEL_SIZE). ~2 cellules couvrent
// l'écart vers un voisin un niveau plus grossier (cellule 2×). Monter si des fissures persistent.
float SkirtCells = 2.0f;
protected:
//=========================================================================
// DENSITY + NORMAL SAMPLING
//=========================================================================
// Lit la densité à une position locale (via le générateur en coords monde).
float GetDensity(const FVoxelChunk& Chunk, int32 X, int32 Y, int32 Z) const;
// Normale lissée: gradient central du champ de densité (pointe solide→air).
FVector ComputeGradientNormal(float WorldX, float WorldY, float WorldZ) const;
// Interpolation linéaire le long d'une arête: trouve où la surface
// traverse entre P1 (densité D1) et P2 (densité D2).
FVector InterpolateEdge(const FVector& P1, const FVector& P2, float D1, float D2) const;
};
+80 -35
View File
@@ -81,22 +81,7 @@ public:
int32 CeilingBandChunks = 4;
//=========================================================================
// LOD
//=========================================================================
// Distance en chunks pour LOD0 (pleine résolution, step=1).
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|LOD")
int32 LOD0Distance = 4;
// Distance en chunks pour LOD1 (demi-résolution, step=2). Au-delà → LOD2 (quart-rés,
// step=4). LOD2 = le plus lointain ; ces chunks ne projettent PLUS d'ombre (cf.
// ApplyMeshToChunk) → rapprocher LOD0/LOD1 pousse plus de chunks dans la bande
// LOD2 sans-ombre = moins de draws (levier fps gratuit, à doser visuellement).
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|LOD")
int32 LOD1Distance = 8;
//=========================================================================
// CLIPMAP (chunked-LOD streaming — supersedes the ViewDistance/LOD box above)
// CLIPMAP (chunked-LOD streaming — supersedes the ViewDistance box above)
//=========================================================================
// Streaming loads concentric shells of tiles: level 0 = full-res chunks near the player,
// each coarser level doubles tile size (and reach). Total tile/draw/gen count stays ~flat
@@ -138,13 +123,6 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0.5", ClampMax = "8.0"))
float SkirtCells = 2.0f;
// LEGACY / WATER ONLY. Decorations no longer ride clipmap tiles (see Voxel|Content below —
// they stream on a fixed world grid by distance, so they don't pop on LOD swaps). This now only
// bounds the tile level at which the level-0 WATER plane is considered (water is level-0 anyway,
// so its practical effect is nil). Left in place; safe to ignore.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0", ClampMax = "8"))
int32 ContentMaxLevel = 2;
//=========================================================================
// CONTENT — distance-based decoration grid (no LOD pop)
//=========================================================================
@@ -154,11 +132,19 @@ public:
// density field and snapped to the real surface — so a given prop keeps the SAME world position at
// every LOD (no teleport/pop on tile swaps). Decorations exist only in the player's current strate.
// Far stream radius in cells (= chunks) for "any-distance" entries (instanced/HISM visual props,
// and actor entries with MaxLODLevel >= 1). Bigger = props visible farther + more spawn/march cost.
// FAR-tier stream radius in cells (= chunks): how far FStrateDecoration entries set to EDecoStreamTier::Far
// (the default — trees, landmarks, rare props) stream out. Bigger = props visible farther + more
// spawn/march cost (but the far grid is COARSE — see DecorationFarSpacingVoxels — so far cost is cheap).
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1"))
int32 DecorationRadiusChunks = 6;
// NEAR-tier stream radius in cells (= chunks): how far EDecoStreamTier::Near entries (dense groundcover
// like grass) stream out. Keep this SHORT — near entries use the FINE grid (DecorationSpacingVoxels), so
// their cost is the steep one; bounding their radius keeps the far-region HISM build + memory small.
// (Repurposes the old vestigial DecorationActorRadiusChunks; same default.)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1"))
int32 DecorationNearRadiusChunks = 3;
// Decoration cells are grouped into REGIONS of RxR cells, and ALL placements in a region share ONE
// HISM per mesh (instead of one HISM per cell per mesh). Regions load/unload as a unit, so clearing
// stays a plain DestroyComponent — no per-instance index remapping. This is the render-thread lever:
@@ -168,19 +154,20 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1", ClampMax = "16"))
int32 DecorationRegionSizeCells = 4;
// LEGACY / UNUSED. The near/far tier system was removed (it re-streamed cells at the tier boundary
// as the player moved → decoration flicker). All entries now stream within DecorationRadiusChunks and
// a loaded cell is never re-streamed in place. Kept only to avoid breaking the asset; safe to ignore.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1"))
int32 DecorationActorRadiusChunks = 3;
// Spacing (in voxels) of candidate columns within a cell. MUST divide CHUNK_SIZE (32): 4 → 8×8=64
// columns/cell. Smaller = denser placement potential + more march cost. SpawnDensity then rolls per
// column-crossing (NOTE: this changes the meaning of SpawnDensity vs the old per-vertex scatter —
// expect to re-tune decoration densities once).
// NEAR-tier column spacing (in voxels) within a cell the FINE grid. MUST divide CHUNK_SIZE (32):
// 4 → 8×8=64 columns/cell. Smaller = denser placement potential + more march cost. SpawnDensity rolls
// per column-crossing. Used by EDecoStreamTier::Near entries (and is the legacy single-grid spacing).
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1", ClampMax = "32"))
int32 DecorationSpacingVoxels = 4;
// FAR-tier column spacing (in voxels) within a cell — the COARSE grid. MUST divide CHUNK_SIZE (32):
// 16 → 2×2=4 columns/cell (16× fewer worker ray-marches than a spacing-4 grid). This is the lever that
// makes a RARE prop visible at every distance cheap: the far grid samples sparsely, so supporting a
// low-SpawnDensity landmark across the full radius costs a fraction of the fine grid. DEFAULTS to the
// fine value (4) so existing worlds are byte-identical until you raise it; bump to 816 for cheap far props.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1", ClampMax = "32"))
int32 DecorationFarSpacingVoxels = 4;
// COARSE vertical march step (in voxels) when searching a column for surface crossings. The crossing
// Z is then bisection-refined, so accuracy is independent of this — raise it (4-8) to cut the scan
// cost (the column is ray-marched on a WORKER thread, but a smaller step still means more samples).
@@ -211,6 +198,64 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "0"))
int32 MaxConcurrentDecorationTasks = 4;
//=========================================================================
// LIGHTING — DENSITY VOLUME (mini-sun raymarched shadows)
//=========================================================================
// A player-centred CLIPMAP of the density field, uploaded to the GPU so the terrain
// material can RAYMARCH it toward the "mini-sun" orbs → from-the-orb, crisp, dynamic
// shadows under FORWARD rendering (Lumen/DF off the table). The volume is the load-bearing
// prerequisite: density is CPU-only (GetDensityAt), so we stream it onto the GPU here.
// Concentric levels: level 0 = full-res near the player (step 1), each level up doubles the
// sampling step & reach (fine near / coarse far — exactly what shadow rays want). Filled on
// WORKER threads (re-evaluating GetDensityAt → deterministic, carves auto-picked-up), with
// toroidal incremental refill on movement and localized refill on carve. See VoxelDensityVolume.
// Master switch. OFF = no volume built, no fill tasks, no GPU cost (terrain unlit by orbs).
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Lighting")
bool bEnableDensityVolume = true;
// Per-axis resolution of EACH clip level (cells). Memory per level ≈ Res³ bytes (R8). 128 →
// ~2 MB/level; 192 → ~7 MB; 256 → ~16 MB. Higher = crisper near shadows + bigger startup fill.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Lighting", meta = (ClampMin = "32", ClampMax = "256"))
int32 DensityVolumeResolution = 128;
// Number of concentric clip levels. Level L samples every (1<<L) voxels and covers
// Res·(1<<L) voxels. 3 levels at Res=128 → near 32 m (full-res) out to ~128 m (coarse).
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Lighting", meta = (ClampMin = "1", ClampMax = "5"))
int32 DensityVolumeLevels = 3;
// DEPRECATED / unused: the volume fill no longer runs on the shared UE::Tasks pool (where it
// starved behind mesh-gen). It now runs on ONE dedicated thread off the pool, so there's no task
// budget to cap. Kept only so existing saved assets don't error; safe to ignore.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Lighting", meta = (ClampMin = "1", ClampMax = "16"))
int32 DensityVolumeMaxTasks = 4;
// A fill box is split into Z-slabs of at most this many cells per task, so no single task is
// huge (a full level refill on startup/teleport fans out across workers). Lower = more, smaller
// tasks (better parallelism / latency); higher = fewer, fatter tasks.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Lighting", meta = (ClampMin = "1", ClampMax = "64"))
int32 DensityVolumeFillSlabCells = 8;
// Per-pixel shadow-march step count toward the orb (the terrain material reads this). More = crisper
// occlusion at grazing angles but higher GPU cost. 64 is a sane start; tune against the look/cost.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Lighting", meta = (ClampMin = "4", ClampMax = "256"))
int32 DensityVolumeMarchSteps = 64;
// Upload the clipmap to GPU R8 volume textures (so the terrain material can march it). OFF = the
// CPU volume still streams (debug-draw works) but nothing reaches the GPU — the safe fallback if
// the runtime Texture3D RHI path misbehaves on a given engine build.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Lighting")
bool bDensityVolumeGPUUpload = true;
// DEBUG (step 1a verification, no GPU): draw small boxes for SOLID cells of level 0 within
// DensityVolumeDebugRadiusCells of the player, so you can confirm the volume holds terrain-shaped
// solidity, follows you, and updates on carve — BEFORE the GPU upload + material march land.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Lighting|Debug")
bool bDebugDrawDensityVolume = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Lighting|Debug", meta = (ClampMin = "1", ClampMax = "32"))
int32 DensityVolumeDebugRadiusCells = 6;
//=========================================================================
// RENDERING
//=========================================================================
@@ -323,6 +323,12 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Content")
TArray<FStrateDecoration> Decorations;
// Landmarks: RARE, large, far-visible objects placed on a coarse hash lattice (the underground
// "mini-suns" etc.). Strate-wide; each entry has its own spacing/biome/placement/transform settings.
// Cheap at any radius — see FStrateLandmark / §8.5. (NOT part of the per-chunk decoration grid.)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Content")
TArray<FStrateLandmark> Landmarks;
// Ambient actors: things floating in cave space (fog volumes, particles, lights)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Content")
TArray<FStrateAmbientActor> AmbientActors;
@@ -48,19 +48,10 @@ struct FVoxelPassage
FVector UpperPoint = FVector::ZeroVector; // Entry in upper strate
FVector LowerPoint = FVector::ZeroVector; // Exit in lower strate
// An optional midpoint for non-straight passages (sloped, curved).
// Used by SlopedTunnel and CrackCrevice types. Ignored when ControlPoints is populated.
FVector MidPoint = FVector::ZeroVector;
// Passage dimensions — how wide the carved tunnel is (in voxels).
// Varies by type: VerticalShaft ~7-8, SpiralDescent ~4, CrackCrevice ~2-3, others ~5.
float Radius = 5.0f;
// Whether this passage uses a midpoint (curved/sloped) or is straight.
// Only relevant when ControlPoints is empty — if ControlPoints has entries,
// the passage is evaluated as a capsule chain along those points instead.
bool bHasMidPoint = false;
// The shape/style of this passage. Determines how control points are generated
// and how the passage feels to navigate (shaft, spiral, ledges, crack, etc.).
EVoxelPassageType PassageType = EVoxelPassageType::SlopedTunnel;
@@ -80,7 +71,9 @@ struct FVoxelPassage
// Bounding sphere enclosing the whole passage (+ radius + blend), in voxel coords.
// Computed once in GeneratePassages; lets EvaluateModifierSDF reject far voxels with
// a single squared-distance test instead of walking every segment per voxel.
// BoundRadius (linear) feeds the per-chunk shortlist reach; BoundRadiusSq the per-voxel test.
FVector BoundCenter = FVector::ZeroVector;
float BoundRadius = 0.0f;
float BoundRadiusSq = 0.0f;
};
+189 -6
View File
@@ -16,6 +16,8 @@
#include "GameplayTagContainer.h"
#include "VoxelStrateTypes.generated.h"
class UVoxelBiomeDefinition; // FStrateLandmark::RequiredBiome (optional per-landmark biome filter)
//=============================================================================
// ENUMS
//=============================================================================
@@ -83,6 +85,32 @@ enum class ESurfaceType : uint8
Any UMETA(DisplayName = "Any surface")
};
/**
* EDecoStreamTier — Which of the two decoration streaming grids an entry uses (§8.5).
*
* Decorations stream on a fixed world XY grid by distance (no LOD pop). To keep that flicker-free,
* the STREAM RADIUS is a property of the GRID, never of an entry — mixing radii inside one grid would
* force a region to re-stream in place when the player crosses an entry's radius (the old tier system's
* flicker bug). So there are exactly two grids, and an entry just PICKS one:
*
* Far — full radius (VoxelSettings::DecorationRadiusChunks) + COARSE column spacing
* (DecorationFarSpacingVoxels). The coarse grid is what makes a RARE prop you want visible at
* every distance cheap: the worker ray-march cost scales with column count, and a sparse prop
* does not need the dense near grid. Default — and the far spacing defaults to the fine value,
* so existing assets are byte-identical until you opt in to a coarser far grid. Trees, landmarks.
*
* Near — short radius (DecorationNearRadiusChunks) + FINE column spacing (DecorationSpacingVoxels).
* For dense groundcover (grass, small clutter) that only needs to exist near the player: keeping
* it out of the far regions saves their HISM cluster-tree build + instance memory. Pair with the
* per-entry CullDistance (GPU draw bound) for the full picture.
*/
UENUM(BlueprintType)
enum class EDecoStreamTier : uint8
{
Far UMETA(DisplayName = "Far (full radius, coarse grid — trees/landmarks/rare props)"),
Near UMETA(DisplayName = "Near (short radius, fine grid — dense groundcover)")
};
//=============================================================================
// NOISE TYPE
//=============================================================================
@@ -1702,7 +1730,8 @@ struct VOXELFORGE_API FStrateDecoration
// The actor class to spawn (e.g., BP_Stalactite, BP_CrystalCluster).
// Real actors: lights, logic, interaction. They cost game-thread time per instance —
// keep MaxLODLevel at 0 for these, and prefer InstancedMesh for pure visual props.
// prefer InstancedMesh for pure visual props, and consider the Far tier so the coarse grid keeps
// their spawn count down.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration")
TSubclassOf<AActor> ActorClass;
@@ -1713,11 +1742,13 @@ struct VOXELFORGE_API FStrateDecoration
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration")
UStaticMesh* InstancedMesh = nullptr;
// LEGACY / UNUSED by the world-grid decoration system (§8.5). It once meant a clipmap tile level,
// then a near/far distance tier — both removed. All decorations now stream within a single radius
// (VoxelSettings::DecorationRadiusChunks) and never re-stream in place. Kept to avoid breaking assets.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0", ClampMax = "8"))
int32 MaxLODLevel = 0;
// Which of the two decoration streaming grids this entry uses (§8.5). Far (default) = full radius +
// coarse column grid (cheap for rare/large props visible everywhere); Near = short radius + fine
// column grid (dense groundcover near the player only). The radius/spacing presets live on
// VoxelSettings; this only PICKS a grid. Defaults reproduce the legacy single-radius fine grid until
// you opt into a coarser far spacing or move an entry to Near. (Replaces the old vestigial MaxLODLevel.)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration")
EDecoStreamTier StreamTier = EDecoStreamTier::Far;
// Which surface type this decoration can be placed on
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration")
@@ -1818,6 +1849,158 @@ struct VOXELFORGE_API FStrateDecoration
bool bCastShadow = true;
};
/**
* FStrateLandmark — A RARE, large, far-visible object placed on a coarse HASH LATTICE (§8.5).
*
* This is the right primitive for things like the underground "mini-suns" (in-lore light sources): one
* object per ~`SpacingChunks` lattice cell, so the work scales with how MANY landmarks are in range
* (a handful), NOT with the streamed area. That makes a HUGE stream radius (e.g. visible 16 km out so it
* never pops) cheap — unlike the per-chunk decoration grid, which enumerates every chunk in the disk and
* freezes at large radius. Placement is deterministic (pure hash of cell + entry + seed → no pop, same
* landmark in the same place forever), evaluated synchronously on the game thread only when a NEW lattice
* cell enters range (there are so few candidates this never hitches). Strate-wide (listed on the strate
* definition), with an optional per-landmark biome filter. Foliage-style transform tweaks are exposed.
*/
USTRUCT(BlueprintType)
struct VOXELFORGE_API FStrateLandmark
{
GENERATED_BODY()
// ----- What to spawn (one of these; ActorClass wins if both set) -----
// Real actor — use this for a sun that carries its own LIGHT / logic. Rare, so the per-actor cost is fine.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark")
TSubclassOf<AActor> ActorClass;
// OR a plain static mesh (spawned as one StaticMeshComponent — no actor/tick overhead). An emissive
// material glows at distance without a light. Ignored if ActorClass is set.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark")
UStaticMesh* InstancedMesh = nullptr;
// ----- Rarity / spacing (the hash lattice — this is what makes it cheap) -----
// Average spacing between landmarks, IN CHUNKS. This is the lattice cell size: exactly one candidate is
// considered per SpacingChunks×SpacingChunks cell, so cost scales with (radius/spacing)². This is also
// the primary "distance between two instances" control. Large = rare & far apart.
// 16 → fairly frequent landmarks · 64 → sparse (good default) · 256+ → one every few km
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Spacing", meta = (ClampMin = "1.0"))
float SpacingChunks = 64.0f;
// How far within its cell a candidate may wander (0 = dead-centre grid, 1 = anywhere in the cell).
// The effective MINIMUM spacing between two instances ≈ SpacingChunks·(1 JitterFraction); keep it
// below 1 to preserve a spacing guarantee while still breaking up the grid regularity.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Spacing", meta = (ClampMin = "0.0", ClampMax = "1.0"))
float JitterFraction = 0.5f;
// Probability that a lattice cell actually contains this landmark (0-1). Combine with SpacingChunks for
// "rare AND well-spaced": SpacingChunks sets the grid, SpawnProbability sets how many slots fill.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Spacing", meta = (ClampMin = "0.0", ClampMax = "1.0"))
float SpawnProbability = 1.0f;
// How far out (in chunks) landmarks stream / stay visible. CHEAP to make large here (the lattice means a
// 2048-chunk radius is still only ~(2048/Spacing)² candidates). Set big enough that a massive object
// never pops in at a jarring distance.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Spacing", meta = (ClampMin = "1"))
int32 StreamRadiusChunks = 256;
// ----- Placement restriction (mirrors the base decoration gates) -----
// Optional: only place inside this biome (resolved at the candidate XY). Null = any biome in the strate.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Placement")
UVoxelBiomeDefinition* RequiredBiome = nullptr;
// Which surface to snap to. Suns typically sit on the sky-cap CEILING; set Floor for ground monuments,
// Any for the first surface found. Wall-leaning surfaces are matched by the same normal test as decos.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Placement")
ESurfaceType SurfacePlacement = ESurfaceType::Ceiling;
// Surface-tilt band (deg from flat = acos(|normal.Z|); 0 = flat, 90 = vertical). MaxSlopeAngle rejects
// surfaces STEEPER than it (90 = no filter); MinSlopeAngle rejects surfaces FLATTER than it (0 = none).
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Placement", meta = (ClampMin = "0.0", ClampMax = "90.0"))
float MaxSlopeAngle = 90.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Placement", meta = (ClampMin = "0.0", ClampMax = "90.0"))
float MinSlopeAngle = 0.0f;
// Water-relative gate (ignored unless the strate has a water table): place only below (true) / above
// (false) the water line when bRequireWaterRelative is set.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Placement")
bool bRequireWaterRelative = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Placement", meta = (EditCondition = "bRequireWaterRelative"))
bool bPlaceBelowWater = false;
// ----- Transform tweaks (foliage-style) -----
// Rotate the object so its up-axis follows the surface normal. OFF by default — a sun usually wants to
// stay world-upright regardless of the ceiling tilt. ON makes it lie against the surface.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Transform")
bool bAlignToSurface = false;
// WORLD-space position offset (cm) added after the surface snap. E.g. +Z lifts a sun up off the
// sky-cap into the open cavern; use X/Y to nudge it off the exact column.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Transform")
FVector LocationOffset = FVector::ZeroVector;
// Fixed rotation applied on top of the (optional) surface alignment.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Transform")
FRotator RotationOffset = FRotator::ZeroRotator;
// Per-axis RANDOM rotation range (degrees) — each instance gets a hash-deterministic ±value/2 on each
// axis (Pitch/Yaw/Roll). 0 on an axis = no randomisation there. Yaw alone = spin variety; all three =
// tumbled debris look.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Transform")
FRotator RandomRotation = FRotator::ZeroRotator;
// Uniform scale range (hash-random per instance).
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Transform")
float MinScale = 1.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Transform")
float MaxScale = 1.0f;
// ----- Render tuning (the InstancedMesh / StaticMeshComponent path) -----
// Distance (cm) past which the mesh stops drawing. 0 = NEVER cull (the right choice for a far-visible
// sun). Only affects the InstancedMesh path.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Performance", meta = (ClampMin = "0.0"))
float CullDistance = 0.0f;
// Whether the mesh casts a shadow. Only affects the InstancedMesh path.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Performance")
bool bCastShadow = true;
// ----- MINI-SUN LIGHT ORB (feeds the terrain material's raymarched shadows) -----
// When set, this landmark is also a LIGHT SOURCE: the terrain material marches the density volume
// toward it for from-the-orb, crisp, dynamic shadows (forward rendering). The visible glowing mesh is
// still the InstancedMesh/ActorClass above — this just declares the lighting. NOT a UE light actor.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Light Orb")
bool bIsLightOrb = false;
// Light colour of the orb.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Light Orb", meta = (EditCondition = "bIsLightOrb"))
FLinearColor OrbColor = FLinearColor(1.0f, 0.95f, 0.85f, 1.0f);
// Overall brightness multiplier.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Light Orb", meta = (EditCondition = "bIsLightOrb", ClampMin = "0.0"))
float OrbIntensity = 3.0f;
// Orb emitter radius in VOXELS (visual/softness reference; falloff origin).
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Light Orb", meta = (EditCondition = "bIsLightOrb", ClampMin = "0.0"))
float OrbRadiusVoxels = 16.0f;
// Distance in VOXELS over which the orb's light falls to zero. YOU author this (no inverse-square
// blowout) — bigger = lights a wider area. (1 voxel = 25 cm.)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Light Orb", meta = (EditCondition = "bIsLightOrb", ClampMin = "1.0"))
float OrbFalloffVoxels = 2000.0f;
// Max distance in VOXELS along the shadow ray we test for occlusion (bounds the per-pixel march cost;
// past this the point is treated as lit). Keep ≤ the level-0 volume reach for crisp contact shadows.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Light Orb", meta = (EditCondition = "bIsLightOrb", ClampMin = "1.0"))
float OrbMaxShadowDistanceVoxels = 1000.0f;
};
/**
* FStrateAmbientActor — An actor that spawns in open cave space.
*
+20
View File
@@ -28,6 +28,26 @@ constexpr int32 CHUNK_VOLUME = CHUNK_SIZE * CHUNK_SIZE * CHUNK_SIZE; // 32
constexpr float VOXEL_SIZE = 25.0f;
//=============================================================================
// DENSITY → R8 QUANTIZATION (density clipmap / mini-sun shadows)
//=============================================================================
//
// MC convention: NÉGATIF = solide, POSITIF = air, 0 = isosurface. On encode la densité
// dans un R8 où SOLIDE = HAUT, AIR = BAS, iso ≈ 0.5 (128), clampé ±1 autour de la surface
// (loin de la surface ⇒ sature plein solide / plein air). Le trilinear garde la traversée
// iso sub-voxel nette.
//
// SHARED entre deux producteurs qui DOIVENT rester bit-identiques :
// 1. UVoxelDensityVolume (fill worker re-évaluant GetDensityAt),
// 2. UVoxelMarchingCubesMesher (capture-during-meshing : réutilise la grille déjà
// échantillonnée par le mesher au lieu de re-sampler — voir GenerateMesh OutCaptureGrid).
// Même densité d'entrée ⇒ même octet. Ne PAS dupliquer cette formule ailleurs.
FORCEINLINE uint8 VF_QuantizeDensity(float MCDensity)
{
const float S = FMath::Clamp(0.5f - 0.5f * MCDensity, 0.0f, 1.0f);
return (uint8)FMath::RoundToInt(S * 255.0f);
}
//=============================================================================
// FACE DIRECTIONS
//=============================================================================
+80 -26
View File
@@ -7,7 +7,6 @@
#include "GameFramework/Actor.h"
#include <atomic>
#include "VoxelTypes.h"
#include "VoxelChunk.h"
#include "VoxelGenerator.h"
#include "VoxelMarchingCubesMesher.h"
#include "VoxelSettings.h"
@@ -21,7 +20,11 @@ class URealtimeMeshSimple;
class UVoxelDiffLayer;
class UVoxelContentManager;
class UVoxelAtmosphereManager;
class UVoxelDensityVolume;
class UMaterialParameterCollection;
class UVolumeTexture;
class UMaterialInterface;
class UMaterialInstanceDynamic;
namespace RealtimeMesh { struct FRealtimeMeshStreamSet; } // T1.f — worker-built geometry buffers
/**
@@ -53,6 +56,11 @@ struct FChunkResult
// view the way a game-thread height-oracle sample did (it misclassified coarse far tiles). The
// game thread still gates this to SurfaceWorld strates before applying CeilingMaterial / no-shadow.
bool bIsCeiling = false;
// 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;
};
UCLASS()
@@ -102,6 +110,20 @@ public:
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). */
@@ -249,6 +271,50 @@ public:
UFUNCTION(BlueprintCallable, Category = "Voxel World|Biome")
FVoxelBiomeQuery GetBiomeAtWorldLocation(FVector WorldLocation) const;
//=========================================================================
// 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:
/** 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;
// 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 pushes: MID vector/texture sets and MPC writes each enqueue
// render-thread updates, so skip them entirely on the (common) frames where nothing moved.
TWeakObjectPtr<UVolumeTexture> LastBoundVolTex0; // re-push MIDs if the L0 texture was recreated
FLinearColor LastOrbMPC[4] = { FLinearColor(FLT_MAX, 0, 0, 0), FLinearColor(FLT_MAX, 0, 0, 0),
FLinearColor(FLT_MAX, 0, 0, 0), FLinearColor(FLT_MAX, 0, 0, 0) };
public:
//=========================================================================
// LIVE EDIT (debug tuning in PIE)
//=========================================================================
@@ -409,6 +475,17 @@ public:
*/
void ApplyMeshToTile(const FVoxelTileKey& Tile, RealtimeMesh::FRealtimeMeshStreamSet&& Streams, bool bGeomCeiling);
/** 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. */
void BuildDesiredTiles(const FIntVector& CenterChunkCoord);
@@ -424,31 +501,8 @@ public:
/** Get the current player position (or zero if no player) */
FVector GetPlayerPosition() const;
/** Check if a chunk coordinate is within view distance of a center chunk */
bool IsChunkInRange(const FIntVector& ChunkCoord, const FIntVector& CenterChunk) const;
/**
* Determine LOD level for a chunk based on its distance from the center.
*
* LOD CONCEPT:
* Chunks close to the player get full resolution (LOD0, Step=1).
* Chunks further away get coarser resolution (LOD1=Step 2, LOD2=Step 4).
* This dramatically reduces triangle count for distant terrain without
* visible quality loss (they're far away!).
*
* @param ChunkCoord - The chunk to evaluate
* @param CenterChunk - The player's current chunk
* @return LOD level: 0 (full), 1 (half), 2 (quarter)
*/
int32 GetLODForChunk(const FIntVector& ChunkCoord, const FIntVector& CenterChunk) const;
/**
* Convert LOD level to marching cubes step size.
* LOD0 → Step 1 (every voxel)
* LOD1 → Step 2 (every 2nd voxel)
* LOD2 → Step 4 (every 4th voxel)
*/
static int32 LODToStep(int32 LODLevel);
// (GetLODForChunk / LODToStep / IsChunkInRange removed — dead since the clipmap
// streaming replaced the distance-LOD scheme; the level lives in FVoxelTileKey.)
//=========================================================================
// ASYNC
+2
View File
@@ -31,6 +31,8 @@ public class VoxelForge : ModuleRules
PrivateDependencyModuleNames.AddRange(new string[]
{
"ImageWrapper", // PNG encode for the biome-map preview bake (BakeBiomePreview)
"RHI", // Texture3D create + RHIUpdateTexture3D for the density volume (mini-sun shadows)
"RenderCore", // ENQUEUE_RENDER_COMMAND for the volume upload
});
}
}