Files
VoxelForge/Source/VoxelForge/Private/VoxelContentManager.cpp
T
2026-06-23 08:30:13 +02:00

785 lines
36 KiB
C++

// VoxelContentManager.cpp
// Distance-based world-grid decoration scatter with ASYNC surface marching (no LOD pop, no frame cost)
// + aesthetic water surfaces.
#include "VoxelContentManager.h"
#include "VoxelStrateManager.h"
#include "VoxelStrateDefinition.h"
#include "VoxelBiomeDefinition.h"
#include "VoxelGenerator.h"
#include "VoxelSettings.h"
#include "VoxelCaveMorphology.h" // VoxelHash
#include "Components/StaticMeshComponent.h"
#include "Components/HierarchicalInstancedStaticMeshComponent.h"
#include "Engine/StaticMesh.h"
#include "Engine/World.h"
#include "GameFramework/Actor.h"
#include "Materials/MaterialInterface.h"
#include "Tasks/Task.h"
#include "HAL/PlatformProcess.h"
#include "ProfilingDebugging/CpuProfilerTrace.h" // Unreal Insights scopes
// Global safety cap on spawned decoration ACTORS per cell (across all entries).
// HISM instances are exempt — they're batched render data, capped per entry by MaxPerChunk.
static constexpr int32 GMaxDecorationActorsPerCell = 400;
// One cell = one chunk XY footprint (so DecorationRadiusChunks reads as a radius in chunks, and a
// decoration's per-cell MaxPerChunk keeps its "per chunk" meaning).
static constexpr int32 DECO_CELL_VOXELS = CHUNK_SIZE;
// Count of in-flight decoration march tasks (for the EndPlay drain). File-scope so the worker lambda's
// RAII guard can touch it without keeping the UObject 'this' alive for that alone.
static std::atomic<int32> GActiveDecoTasks{0};
static FORCEINLINE int32 CellChebyshev(const FIntPoint& A, const FIntPoint& B)
{
return FMath::Max(FMath::Abs(A.X - B.X), FMath::Abs(A.Y - B.Y));
}
void UVoxelContentManager::Initialize(AActor* InOwner, UVoxelStrateManager* InStrateManager,
UVoxelGenerator* InGenerator, UVoxelSettings* InSettings, int32 InSeed)
{
Owner = InOwner;
StrateManager = InStrateManager;
Generator = InGenerator;
Settings = InSettings;
Seed = InSeed;
if (!PlaneMesh)
{
PlaneMesh = LoadObject<UStaticMesh>(nullptr, TEXT("/Engine/BasicShapes/Plane.Plane"));
}
}
void UVoxelContentManager::BeginDestroy()
{
// Backstop: stop any worker from touching us. EndPlay → NotifyShutdown should have already drained.
bShuttingDown.store(true, std::memory_order_release);
Super::BeginDestroy();
}
void UVoxelContentManager::NotifyShutdown()
{
bShuttingDown.store(true, std::memory_order_release);
// Wait for in-flight march tasks to finish (they check the flag and bail). Timeout to avoid hangs.
const double Deadline = FPlatformTime::Seconds() + 3.0;
while (GActiveDecoTasks.load(std::memory_order_relaxed) > 0)
{
if (FPlatformTime::Seconds() > Deadline) break;
FPlatformProcess::Yield();
}
FDecoCellResult Discard;
while (DecoResults.Dequeue(Discard)) {}
RegionBuilds.Reset();
CompletedRegions.Reset();
PendingLaunch.Reset();
InFlightCells.Reset();
}
//=============================================================================
// Cell ↔ region helpers (region = RxR cells). Floor-division tiles across the origin (negative cells).
//=============================================================================
int32 UVoxelContentManager::RegionSize() const
{
return FMath::Max(1, Settings ? Settings->DecorationRegionSizeCells : 4);
}
static FORCEINLINE FIntPoint CellToRegion(const FIntPoint& Cell, int32 R)
{
return FIntPoint(FMath::DivideAndRoundDown(Cell.X, R), FMath::DivideAndRoundDown(Cell.Y, R));
}
// Region is desired iff its cell footprint intersects the radius-FarR cell box around the player.
static FORCEINLINE bool IsRegionDesired(const FIntPoint& Region, const FIntPoint& PlayerCell,
int32 FarR, int32 R)
{
const int32 MinX = FMath::DivideAndRoundDown(PlayerCell.X - FarR, R);
const int32 MaxX = FMath::DivideAndRoundDown(PlayerCell.X + FarR, R);
const int32 MinY = FMath::DivideAndRoundDown(PlayerCell.Y - FarR, R);
const int32 MaxY = FMath::DivideAndRoundDown(PlayerCell.Y + FarR, R);
return Region.X >= MinX && Region.X <= MaxX && Region.Y >= MinY && Region.Y <= MaxY;
}
//=============================================================================
// DECORATIONS — distance-based world grid (async march)
//=============================================================================
// Deterministic placement hash. Pure function of cell, column, crossing, entry, seed.
static FORCEINLINE uint32 DecoHash(int32 CX, int32 CY, int32 GX, int32 GY,
int32 Crossing, int32 Entry, uint32 Seed, uint32 Salt)
{
uint32 H = VoxelHash::Cell(CX, CY, Seed ^ Salt);
H = VoxelHash::Mix(H ^ ((uint32)GX * 73856093u) ^ ((uint32)GY * 19349663u));
H = VoxelHash::Mix(H ^ ((uint32)Crossing * 83492791u + 0x9E3779B1u));
H = VoxelHash::Mix(H ^ ((uint32)Entry * 2654435761u + 40503u));
return VoxelHash::Mix(H);
}
void UVoxelContentManager::UpdateDecorations(const FVector& PlayerWorldPos)
{
if (!StrateManager || !Generator || !Settings) return;
AActor* OwnerActor = Owner.Get();
if (!OwnerActor) return;
const FTransform OwnerXf = OwnerActor->GetActorTransform();
// All strate/water/density queries are in actor-LOCAL space (StrateManager assumes actor origin;
// the mesher builds geometry from voxel*VOXEL_SIZE in local space). Bring the player local.
const FVector LocalPlayer = OwnerXf.InverseTransformPosition(PlayerWorldPos);
// Player's strate band (actor-local cm). No strate (inter-strate gap / outside world) ⇒ no decos.
float TopZ, BotZ;
const bool bInStrate = StrateManager->GetStrateUnrealZRange(LocalPlayer.Z, TopZ, BotZ);
const int32 StrateIndex = bInStrate ? StrateManager->GetStrateIndex(LocalPlayer.Z) : INT32_MIN;
if (!bInStrate)
{
ClearAllDecorations();
LastStrateIndex = INT32_MIN;
LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN);
return;
}
const float CellWorld = (float)DECO_CELL_VOXELS * VOXEL_SIZE;
const FIntPoint PlayerCell(
FMath::FloorToInt(LocalPlayer.X / CellWorld),
FMath::FloorToInt(LocalPlayer.Y / CellWorld));
// Shared per-update strate context (a strate is a horizontal slab → same for every cell).
CurrentCtx = FDecoContext();
CurrentCtx.TopVoxelZ = TopZ / VOXEL_SIZE;
CurrentCtx.BottomVoxelZ = BotZ / VOXEL_SIZE;
CurrentCtx.RepChunkZ = FMath::FloorToInt(((TopZ + BotZ) * 0.5f / VOXEL_SIZE) / (float)CHUNK_SIZE);
const FIntVector RepChunk(PlayerCell.X, PlayerCell.Y, CurrentCtx.RepChunkZ);
CurrentCtx.Def = StrateManager->GetStrateForChunk(RepChunk);
CurrentCtx.bSurfaceWorld =
(StrateManager->GetGeneratorTypeForChunk(RepChunk) == ECaveGeneratorType::SurfaceWorld);
{
const float Wv = StrateManager->GetWaterLevelWorldZForChunk(RepChunk);
CurrentCtx.bHasWater = (Wv != -FLT_MAX);
CurrentCtx.WaterLocalZ = CurrentCtx.bHasWater ? Wv * VOXEL_SIZE : -FLT_MAX;
}
// Strate change → wipe + force a full rebuild (bumps epoch so old in-flight tasks are discarded).
if (StrateIndex != LastStrateIndex)
{
ClearAllDecorations();
LastStrateIndex = StrateIndex;
LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN);
}
if (PlayerCell != LastDecoCell)
{
RebuildDesiredCells(PlayerCell);
LastDecoCell = PlayerCell;
}
const int32 FarR = FMath::Max(1, Settings->DecorationRadiusChunks);
LaunchDecoTasks(PlayerCell);
ProcessDecoResults(PlayerCell, FarR);
}
void UVoxelContentManager::RebuildDesiredCells(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, 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);
const int32 R = RegionSize();
// Desired regions = every region whose footprint touches the radius-FarR cell box around the player.
const FIntPoint RMin(FMath::DivideAndRoundDown(PlayerCell.X - FarR, R),
FMath::DivideAndRoundDown(PlayerCell.Y - FarR, R));
const FIntPoint RMax(FMath::DivideAndRoundDown(PlayerCell.X + FarR, R),
FMath::DivideAndRoundDown(PlayerCell.Y + FarR, R));
TSet<FIntPoint> DesiredRegions;
DesiredRegions.Reserve((RMax.X - RMin.X + 1) * (RMax.Y - RMin.Y + 1));
for (int32 ry = RMin.Y; ry <= RMax.Y; ++ry)
for (int32 rx = RMin.X; rx <= RMax.X; ++rx)
{
DesiredRegions.Add(FIntPoint(rx, ry));
}
// Unload loaded regions no longer desired (plain DestroyComponent — no per-instance removal).
{
TArray<FIntPoint> Loaded; DecoRegions.GetKeys(Loaded);
for (const FIntPoint& K : Loaded)
{
if (!DesiredRegions.Contains(K)) ClearDecorationRegion(K);
}
}
// Start a build for each desired region that isn't already loaded or building. In-progress builds are
// LEFT to finish even if they fell out of range (their cell tasks are already off-thread); the apply
// step discards a completed build that is no longer desired (see ProcessDecoResults). Each new build
// 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
FDecoRegionBuild& Build = RegionBuilds.Add(Region);
Build.BuildId = 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));
}
}
// 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)
{
return CellChebyshev(A, PlayerCell) < CellChebyshev(B, PlayerCell);
});
}
void UVoxelContentManager::LaunchDecoTasks(const FIntPoint& PlayerCell)
{
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();
return;
}
AActor* OwnerActor = Owner.Get();
if (!OwnerActor) return;
const FTransform OwnerXf = OwnerActor->GetActorTransform();
const int32 R = RegionSize();
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);
while (PendingLaunch.Num() > 0 && InFlightCells.Num() < MaxConc)
{
const FIntPoint Cell = PendingLaunch[0];
PendingLaunch.RemoveAt(0);
if (InFlightCells.Contains(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);
if (!Build) continue;
const uint32 BuildId = Build->BuildId;
// Resolve the decoration list on the GAME THREAD (GetDominantBiomeAt is game-thread).
const float CX = ((float)Cell.X + 0.5f) * CHUNK_SIZE;
const float CY = ((float)Cell.Y + 0.5f) * CHUNK_SIZE;
const UVoxelBiomeDefinition* Biome = Generator->GetDominantBiomeAt(CX, CY, CurrentCtx.RepChunkZ);
const TArray<FStrateDecoration>& SrcDecos =
(Biome && Biome->Decorations.Num() > 0) ? Biome->Decorations : CurrentCtx.Def->Decorations;
if (SrcDecos.Num() == 0)
{
MarkCellDone(Region, BuildId); // empty cell still counts toward the region's completion
continue;
}
TArray<FStrateDecoration> EntriesCopy = SrcDecos; // snapshot for the worker + the spawner
const FDecoContext Ctx = CurrentCtx; // PODs only used on the worker
const uint32 LocalSeed = (uint32)Seed;
UVoxelGenerator* Gen = Generator;
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,
Entries = MoveTemp(EntriesCopy)]() mutable
{
struct FGuard { ~FGuard() { GActiveDecoTasks.fetch_sub(1, std::memory_order_relaxed); } } Guard;
if (bShuttingDown.load(std::memory_order_relaxed)) return;
FDecoCellResult Result;
Result.Cell = Cell;
Result.BuildId = BuildId;
Result.Entries = MoveTemp(Entries);
BuildCellSpawns(Gen, OwnerXf, Cell, Ctx, Result.Entries, LocalSeed,
Spacing, Step, MaxCross, ColDepth, Result.Spawns);
if (!bShuttingDown.load(std::memory_order_relaxed))
{
DecoResults.Enqueue(MoveTemp(Result));
}
}, UE::Tasks::ETaskPriority::BackgroundNormal);
}
}
// ---- WORKER THREAD: find each column's surface points → spawn commands. ----
void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTransform& OwnerXf,
const FIntPoint& Cell, const FDecoContext& Ctx,
const TArray<FStrateDecoration>& Entries, uint32 InSeed,
int32 Spacing, float Step, int32 MaxCrossings, float ColumnDepth,
TArray<FDecoSpawn>& OutSpawns)
{
if (!Gen || Entries.Num() == 0) return;
const int32 PerAxis = FMath::Max(1, CHUNK_SIZE / Spacing);
const int32 CellOriginVX = Cell.X * CHUNK_SIZE;
const int32 CellOriginVY = Cell.Y * CHUNK_SIZE;
TArray<int32> EntryCount; EntryCount.Init(0, Entries.Num());
int32 TotalActors = 0;
auto D = [&](float VX, float VY, float VZ) { return Gen->GetDensityAt(VX, VY, VZ); };
// Shared: roll every decoration entry at one surface point (voxel XY, voxel Z, outward world normal)
// and append the passing ones to OutSpawns. CrossingIdx salts the hash so stacked surfaces differ.
auto PlaceAtCrossing = [&](float VX, float VY, int32 gx, int32 gy, float ZC,
const FVector& NormalWorld, int32 CrossingIdx)
{
const bool bFloor = NormalWorld.Z > 0.5f;
const bool bCeiling = NormalWorld.Z < -0.5f;
const bool bWall = !bFloor && !bCeiling;
const FVector LocalPos(VX * VOXEL_SIZE, VY * VOXEL_SIZE, ZC * VOXEL_SIZE);
const FVector PosWorld = OwnerXf.TransformPosition(LocalPos);
const bool bBelowWater = Ctx.bHasWater && (LocalPos.Z < Ctx.WaterLocalZ);
for (int32 EntryIdx = 0; EntryIdx < Entries.Num(); ++EntryIdx)
{
const FStrateDecoration& Deco = Entries[EntryIdx];
const bool bInstanced = (Deco.InstancedMesh != nullptr);
if (!bInstanced && !Deco.ActorClass) continue;
if (Deco.SpawnDensity <= 0.0f) continue;
if (EntryCount[EntryIdx] >= Deco.MaxPerChunk) continue;
if (!bInstanced && TotalActors >= GMaxDecorationActorsPerCell) continue;
bool bMatches = true;
switch (Deco.SurfacePlacement)
{
case ESurfaceType::Floor: bMatches = bFloor; break;
case ESurfaceType::Wall: bMatches = bWall; break;
case ESurfaceType::Ceiling: bMatches = bCeiling; break;
default: bMatches = true; break;
}
if (!bMatches) 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;
}
const uint32 H = DecoHash(Cell.X, Cell.Y, gx, gy, CrossingIdx, EntryIdx, InSeed, 0xDEC0u);
if (VoxelHash::ToFloat01(H) > Deco.SpawnDensity) continue;
if (Deco.bRequireWaterRelative && Ctx.bHasWater)
{
if (bBelowWater != Deco.bPlaceBelowWater) continue;
}
const FVector SpawnPos = PosWorld + NormalWorld * Deco.SurfaceOffset;
FQuat BaseQ = Deco.bAlignToSurface
? FRotationMatrix::MakeFromZ(NormalWorld).ToQuat()
: FQuat::Identity;
if (Deco.bRandomYaw)
{
const float Yaw = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x59415721u)) * 2.0f * PI;
const FVector Axis = Deco.bAlignToSurface ? NormalWorld : FVector::UpVector;
BaseQ = FQuat(Axis, Yaw) * BaseQ;
}
const float ScaleT = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x5CA1E000u));
const float Scale = FMath::Lerp(Deco.MinScale, Deco.MaxScale, ScaleT);
FDecoSpawn& Out = OutSpawns.AddDefaulted_GetRef();
Out.EntryIdx = EntryIdx;
Out.bInstanced = bInstanced;
Out.Xf = FTransform(BaseQ, SpawnPos, FVector(Scale));
++EntryCount[EntryIdx];
if (!bInstanced) ++TotalActors;
}
};
for (int32 gy = 0; gy < PerAxis; ++gy)
for (int32 gx = 0; gx < PerAxis; ++gx)
{
// INTEGER voxel jitter: columns land on integer XY so the generator's surface-column cache
// (T1.a, §8.10) applies — FRACTIONAL XY recomputes the noise-heavy heightfield+biome on EVERY
// sample. A 25 cm grid offset is imperceptible; random yaw/scale still breaks up the regularity.
const uint32 HJ = DecoHash(Cell.X, Cell.Y, gx, gy, -1, 0, InSeed, 0x10C0u);
const int32 JX = FMath::Min(Spacing - 1, (int32)(VoxelHash::ToFloat01(HJ) * (float)Spacing));
const int32 JY = FMath::Min(Spacing - 1, (int32)(VoxelHash::ToFloat01(VoxelHash::Mix(HJ ^ 0x68BC21EBu)) * (float)Spacing));
const float VX = (float)(CellOriginVX + gx * Spacing + JX);
const float VY = (float)(CellOriginVY + gy * Spacing + JY);
if (Ctx.bSurfaceWorld)
{
// HEIGHTFIELD ORACLE — O(1)/column instead of marching the whole band. Query the surface
// (+ 4 neighbours for the gradient normals), then verify with ONE density sample so we skip
// columns carved away by passages / the (0,0) spine / player diffs (the oracle is the raw
// heightfield and doesn't know about carving).
float hC, cC, hXp, cXp, hXm, cXm, hYp, cYp, hYm, cYm;
if (!Gen->GetSurfaceHeightAt(VX, VY, Ctx.RepChunkZ, hC, cC)) continue;
Gen->GetSurfaceHeightAt(VX + 1.0f, VY, Ctx.RepChunkZ, hXp, cXp);
Gen->GetSurfaceHeightAt(VX - 1.0f, VY, Ctx.RepChunkZ, hXm, cXm);
Gen->GetSurfaceHeightAt(VX, VY + 1.0f, Ctx.RepChunkZ, hYp, cYp);
Gen->GetSurfaceHeightAt(VX, VY - 1.0f, Ctx.RepChunkZ, hYm, cYm);
// Floor (terrain top): outward normal = (-dH/dx, -dH/dy, 1).
if (hC >= Ctx.BottomVoxelZ && hC <= Ctx.TopVoxelZ && D(VX, VY, hC) <= 0.5f)
{
const float dHdx = (hXp - hXm) * 0.5f;
const float dHdy = (hYp - hYm) * 0.5f;
FVector N = OwnerXf.TransformVectorNoScale(FVector(-dHdx, -dHdy, 1.0f)).GetSafeNormal();
if (N.IsNearlyZero()) N = FVector::UpVector;
PlaceAtCrossing(VX, VY, gx, gy, hC, N, 0);
}
// Sky-cap ceiling underside: outward normal = (dC/dx, dC/dy, -1). Only if open space below.
if (cC > hC + 1.0f && cC <= Ctx.TopVoxelZ && D(VX, VY, cC) <= 0.5f)
{
const float dCdx = (cXp - cXm) * 0.5f;
const float dCdy = (cYp - cYm) * 0.5f;
FVector N = OwnerXf.TransformVectorNoScale(FVector(dCdx, dCdy, -1.0f)).GetSafeNormal();
if (N.IsNearlyZero()) N = FVector::DownVector;
PlaceAtCrossing(VX, VY, gx, gy, cC, N, 1);
}
continue;
}
// NON-SURFACE archetypes (caves/shafts/islands): ray-march the density column for crossings.
float PrevD = D(VX, VY, Ctx.TopVoxelZ);
int32 Crossings = 0;
bool bSeenAir = (PrevD >= 0.0f); // GetDensityAt >= 0 == air
float SolidRun = 0.0f; // contiguous solid voxels since the last open air
for (float Z = Ctx.TopVoxelZ - Step; Z >= Ctx.BottomVoxelZ && Crossings < MaxCrossings; Z -= Step)
{
const float Dz = D(VX, VY, Z);
if ((PrevD >= 0.0f) != (Dz >= 0.0f)) // straddles IsoLevel 0 (air ↔ solid)
{
// Bisection-refine the crossing Z between Z (lo, Dz) and Z+Step (hi, PrevD).
float ZLo = Z, DLo = Dz, ZHi = Z + Step, DHi = PrevD;
for (int32 It = 0; It < 4; ++It)
{
const float ZM = 0.5f * (ZLo + ZHi);
const float DM = D(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(
D(VX + 1.0f, VY, ZC) - D(VX - 1.0f, VY, ZC),
D(VX, VY + 1.0f, ZC) - D(VX, VY - 1.0f, ZC),
D(VX, VY, ZC + 1.0f) - D(VX, VY, ZC - 1.0f));
FVector NormalWorld = OwnerXf.TransformVectorNoScale(LocalGrad).GetSafeNormal();
if (NormalWorld.IsNearlyZero()) NormalWorld = FVector::UpVector;
PlaceAtCrossing(VX, VY, gx, gy, ZC, NormalWorld, Crossings);
++Crossings;
}
// Once past into the open space, stop after a long bedrock run below it (skips solid rock to
// the strate floor; caves reset on each air gap so layered floors are still found).
if (Dz >= 0.0f) { bSeenAir = true; SolidRun = 0.0f; }
else { SolidRun += Step; }
if (bSeenAir && SolidRun > ColumnDepth) break;
PrevD = Dz;
}
}
}
// ---- GAME THREAD: drain finished marches → merge into region builds, apply completed regions budgeted. ----
void UVoxelContentManager::ProcessDecoResults(const FIntPoint& PlayerCell, int32 FarR)
{
// 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.
FDecoCellResult R;
while (DecoResults.Dequeue(R))
{
InFlightCells.Remove(R.Cell); // free the concurrency slot regardless of whether it still matters
MergeCellResult(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.
const int32 R_ = RegionSize();
const int32 Budget = FMath::Max(1, Settings->MaxDecorationCellsPerFrame);
int32 Applied = 0;
while (CompletedRegions.Num() > 0 && Applied < Budget)
{
const FIntPoint Region = CompletedRegions[0];
CompletedRegions.RemoveAt(0);
FDecoRegionBuild* Build = RegionBuilds.Find(Region);
if (!Build) continue; // already cleared
if (!IsRegionDesired(Region, PlayerCell, FarR, R_))
{
RegionBuilds.Remove(Region); // wandered out of range while building → drop it unbuilt
continue;
}
ApplyRegion(Region, *Build);
RegionBuilds.Remove(Region);
++Applied;
}
}
// 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)
{
const FIntPoint Region = CellToRegion(Result.Cell, RegionSize());
FDecoRegionBuild* Build = RegionBuilds.Find(Region);
if (!Build || Build->BuildId != Result.BuildId) return;
for (const FDecoSpawn& S : Result.Spawns)
{
if (!Result.Entries.IsValidIndex(S.EntryIdx)) continue;
const FStrateDecoration& Deco = Result.Entries[S.EntryIdx];
if (S.bInstanced)
{
if (!Deco.InstancedMesh) continue;
// Bucket by MESH so cells (and biomes) sharing a mesh collapse into one region HISM. The first
// contributor sets the render tuning (cull/shadow/scale) for the whole region's instances.
FRegionMeshBucket& Bucket = Build->MeshBuckets.FindOrAdd(Deco.InstancedMesh);
if (Bucket.Xforms.Num() == 0) { Bucket.Deco = Deco; }
Bucket.Xforms.Add(S.Xf);
}
else if (Deco.ActorClass)
{
FRegionActorSpawn& A = Build->ActorSpawns.AddDefaulted_GetRef();
A.ActorClass = Deco.ActorClass;
A.Xf = S.Xf;
}
}
MarkCellDone(Region, Result.BuildId);
}
// Account one cell against its region's remaining-cell count; queue the region for apply once all are in.
void UVoxelContentManager::MarkCellDone(const FIntPoint& Region, uint32 BuildId)
{
FDecoRegionBuild* Build = RegionBuilds.Find(Region);
if (!Build || Build->BuildId != BuildId) return;
if (--Build->CellsRemaining <= 0)
{
CompletedRegions.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)
{
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_DecoApply); // total game-thread cost to apply one region
AActor* OwnerActor = Owner.Get();
if (!OwnerActor) return;
UWorld* World = OwnerActor->GetWorld();
if (!World) return;
FDecoRegionContent& Content = DecoRegions.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.
for (const FRegionActorSpawn& A : Build.ActorSpawns)
{
if (!A.ActorClass) continue;
FActorSpawnParameters SpawnParams;
SpawnParams.Owner = OwnerActor;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
if (AActor* NewActor = World->SpawnActor<AActor>(A.ActorClass, A.Xf, SpawnParams))
{
Content.Actors.Add(NewActor);
}
}
// One HISM per mesh for the ENTIRE region — the render-thread win: hundreds of per-cell components
// collapse to a handful per region, so InitViews walks far fewer primitives every frame.
for (TPair<TWeakObjectPtr<UStaticMesh>, FRegionMeshBucket>& Pair : Build.MeshBuckets)
{
FRegionMeshBucket& Bucket = Pair.Value;
UStaticMesh* Mesh = Pair.Key.Get();
if (!Mesh || Bucket.Xforms.Num() == 0) continue;
const FStrateDecoration& Deco = Bucket.Deco;
UHierarchicalInstancedStaticMeshComponent* HISM =
NewObject<UHierarchicalInstancedStaticMeshComponent>(OwnerActor);
HISM->SetStaticMesh(Mesh);
// STATIC, not Movable: decorations are placed once and never move, so Static lets the renderer
// CACHE their mesh draw commands (they drop out of the per-frame dynamic-primitive gather — the
// dominant render-thread cost once component count was solved) AND lets VSM cache their shadows.
// Mirrors the terrain tile + root mobility rationale (VoxelWorld.cpp). Must precede RegisterComponent.
HISM->SetMobility(EComponentMobility::Static);
HISM->SetCollisionEnabled(ECollisionEnabled::NoCollision);
// Per-entry render tuning — what makes dense groundcover affordable. Set BEFORE RegisterComponent
// so the render proxy is created once with the final state (no rebuild):
// • CullDistance bounds GPU cost — grass is drawn only near the player even when placed thickly.
// • bCastShadow off removes the dominant cost of dense instanced foliage.
HISM->SetCastShadow(Deco.bCastShadow);
if (Deco.CullDistance > 0.0f)
{
const int32 End = FMath::Max(1, (int32)Deco.CullDistance);
const int32 Start = FMath::Max(1, (int32)(Deco.CullDistance * 0.8f));
HISM->SetCullDistances(Start, End); // fade band 0.8x→1.0x, then gone
}
{
// Scene-proxy creation. Now amortised over a whole region rather than churned per cell.
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_DecoHISMRegister);
HISM->RegisterComponent();
HISM->AttachToComponent(OwnerActor->GetRootComponent(),
FAttachmentTransformRules::KeepRelativeTransform);
}
{
// Cluster-tree build over the WHOLE region's instances in one shot (scales with instance
// count, but built once per region instead of once per cell).
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_DecoAddInstances);
HISM->AddInstances(Bucket.Xforms, /*bShouldReturnIndices=*/false, /*bWorldSpace=*/true);
}
Content.Instances.Add(HISM);
}
}
void UVoxelContentManager::ClearDecorationRegion(const FIntPoint& Region)
{
FDecoRegionContent* Content = DecoRegions.Find(Region);
if (!Content) return;
for (const TWeakObjectPtr<AActor>& A : Content->Actors)
{
if (AActor* Act = A.Get()) { Act->Destroy(); }
}
for (const TWeakObjectPtr<UHierarchicalInstancedStaticMeshComponent>& C : Content->Instances)
{
if (UHierarchicalInstancedStaticMeshComponent* Comp = C.Get()) { Comp->DestroyComponent(); }
}
DecoRegions.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();
// 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)) {}
}
//=============================================================================
// WATER — tile-driven, level-0 only
//=============================================================================
void UVoxelContentManager::UpdateWater(const FVector& PlayerWorldPos)
{
if (!StrateManager || !PlaneMesh) return;
AActor* OwnerActor = Owner.Get();
if (!OwnerActor) return;
const FTransform Xf = OwnerActor->GetActorTransform();
const FVector LocalPos = Xf.InverseTransformPosition(PlayerWorldPos);
// Player chunk (voxel→chunk) for the strate / water-level lookup.
const float ChunkWorld = (float)CHUNK_SIZE * VOXEL_SIZE; // 1 chunk footprint in cm
const FIntVector PlayerChunk(
FMath::FloorToInt(LocalPos.X / ChunkWorld),
FMath::FloorToInt(LocalPos.Y / ChunkWorld),
FMath::FloorToInt(LocalPos.Z / ChunkWorld));
const float WaterVoxelZ = StrateManager->GetWaterLevelWorldZForChunk(PlayerChunk);
if (WaterVoxelZ == -FLT_MAX) // current strate has no water → hide the plane
{
if (WaterPlane) { WaterPlane->SetVisibility(false); }
LastWaterZ = -FLT_MAX;
LastWaterCell = FIntPoint(INT32_MIN, INT32_MIN);
return;
}
// Snap the plane centre to a coarse cell so it only repositions when the player crosses it
// (avoids per-frame churn; a uniform plane sliding is invisible anyway as long as the water
// material pans on WORLD position rather than mesh UVs).
const FIntPoint Cell(
FMath::FloorToInt(LocalPos.X / ChunkWorld),
FMath::FloorToInt(LocalPos.Y / ChunkWorld));
if (WaterPlane && WaterPlane->IsVisible() && WaterVoxelZ == LastWaterZ && Cell == LastWaterCell)
return; // nothing changed
if (!WaterPlane)
{
WaterPlane = NewObject<UStaticMeshComponent>(OwnerActor);
WaterPlane->SetStaticMesh(PlaneMesh);
WaterPlane->SetCollisionEnabled(ECollisionEnabled::NoCollision);
WaterPlane->SetCastShadow(false);
WaterPlane->SetMobility(EComponentMobility::Movable); // it follows the player
WaterPlane->RegisterComponent();
WaterPlane->AttachToComponent(OwnerActor->GetRootComponent(),
FAttachmentTransformRules::KeepRelativeTransform);
}
// Cover the whole view to the horizon (one draw, so be generous). Engine plane is 100 uu.
const float CoverWorld = Settings
? (float)FMath::Max(4, Settings->ViewDistanceXY + 4) * 2.0f * ChunkWorld
: 200000.0f;
WaterPlane->SetWorldScale3D(FVector(CoverWorld / 100.0f, CoverWorld / 100.0f, 1.0f));
const FVector LocalCenter(((float)Cell.X + 0.5f) * ChunkWorld,
((float)Cell.Y + 0.5f) * ChunkWorld,
WaterVoxelZ * VOXEL_SIZE);
WaterPlane->SetWorldLocation(Xf.TransformPosition(LocalCenter));
WaterPlane->SetVisibility(true);
if (const UVoxelStrateDefinition* Def = StrateManager->GetStrateForChunk(PlayerChunk))
{
if (Def->WaterMaterial) { WaterPlane->SetMaterial(0, Def->WaterMaterial); }
}
LastWaterZ = WaterVoxelZ;
LastWaterCell = Cell;
}
//=============================================================================
// CLEAR ALL
//=============================================================================
void UVoxelContentManager::ClearAll()
{
ClearAllDecorations();
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;
}