Fix Decoration Placement
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
// VoxelBiomeDefinition.cpp
|
||||
// All data lives in the header; this TU exists so UHT generates the asset's body and
|
||||
// the module has an object file for it.
|
||||
|
||||
#include "VoxelBiomeDefinition.h"
|
||||
@@ -87,19 +87,30 @@ int32 UVoxelContentManager::RegionSize() const
|
||||
return FMath::Max(1, Settings ? Settings->DecorationRegionSizeCells : 4);
|
||||
}
|
||||
|
||||
// TRUE floor division (rounds toward -infinity). DO NOT use FMath::DivideAndRoundDown for cell↔region
|
||||
// mapping: it is `A / B`, which TRUNCATES toward zero for negatives (a UE naming footgun). Cells are
|
||||
// assigned to a region by the exact inverse `region*R + offset`, so the mapping back MUST floor — with
|
||||
// truncation, a negative-coord cell is queued under one region but routed back to another → its march
|
||||
// result is dropped → blank chunk. Truncation == floor for A>=0 and for R==1, which is exactly why only
|
||||
// NEGATIVE coordinates at R>=2 were affected. R (region size) is always > 0 here.
|
||||
static FORCEINLINE int32 FloorDivPos(int32 A, int32 R)
|
||||
{
|
||||
return (A >= 0) ? (A / R) : -(((-A) + R - 1) / R);
|
||||
}
|
||||
|
||||
static FORCEINLINE FIntPoint CellToRegion(const FIntPoint& Cell, int32 R)
|
||||
{
|
||||
return FIntPoint(FMath::DivideAndRoundDown(Cell.X, R), FMath::DivideAndRoundDown(Cell.Y, R));
|
||||
return FIntPoint(FloorDivPos(Cell.X, R), FloorDivPos(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);
|
||||
const int32 MinX = FloorDivPos(PlayerCell.X - FarR, R);
|
||||
const int32 MaxX = FloorDivPos(PlayerCell.X + FarR, R);
|
||||
const int32 MinY = FloorDivPos(PlayerCell.Y - FarR, R);
|
||||
const int32 MaxY = FloorDivPos(PlayerCell.Y + FarR, R);
|
||||
return Region.X >= MinX && Region.X <= MaxX && Region.Y >= MinY && Region.Y <= MaxY;
|
||||
}
|
||||
|
||||
@@ -161,6 +172,44 @@ void UVoxelContentManager::UpdateDecorations(const FVector& PlayerWorldPos)
|
||||
CurrentCtx.bHasWater = (Wv != -FLT_MAX);
|
||||
CurrentCtx.WaterLocalZ = CurrentCtx.bHasWater ? Wv * VOXEL_SIZE : -FLT_MAX;
|
||||
}
|
||||
// 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();
|
||||
if (CurrentCtx.Def)
|
||||
{
|
||||
if (CurrentCtx.BiomeCtx.IsValid())
|
||||
{
|
||||
for (int32 ci = 0; ci < CurrentCtx.BiomeCtx.Biomes.Num(); ++ci)
|
||||
{
|
||||
const int32 StrateBiomeIdx = CurrentCtx.BiomeCtx.Biomes[ci].Index;
|
||||
const UVoxelBiomeDefinition* Bio =
|
||||
CurrentCtx.Def->Biomes.IsValidIndex(StrateBiomeIdx) ? CurrentCtx.Def->Biomes[StrateBiomeIdx] : nullptr;
|
||||
// A biome with no decorations inherits the strate's list (still tagged with THIS biome's
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (const FStrateDecoration& D : CurrentCtx.Def->Decorations)
|
||||
{
|
||||
CurrentEntries.Add(D);
|
||||
CurrentEntryBiome.Add(-1); // no biome field → matches the column's ColBiome (-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strate change → wipe + force a full rebuild (bumps epoch so old in-flight tasks are discarded).
|
||||
if (StrateIndex != LastStrateIndex)
|
||||
@@ -191,10 +240,10 @@ void UVoxelContentManager::RebuildDesiredCells(const FIntPoint& PlayerCell)
|
||||
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));
|
||||
const FIntPoint RMin(FloorDivPos(PlayerCell.X - FarR, R),
|
||||
FloorDivPos(PlayerCell.Y - FarR, R));
|
||||
const FIntPoint RMax(FloorDivPos(PlayerCell.X + FarR, R),
|
||||
FloorDivPos(PlayerCell.Y + FarR, R));
|
||||
|
||||
TSet<FIntPoint> DesiredRegions;
|
||||
DesiredRegions.Reserve((RMax.X - RMin.X + 1) * (RMax.Y - RMin.Y + 1));
|
||||
@@ -280,20 +329,17 @@ void UVoxelContentManager::LaunchDecoTasks(const FIntPoint& PlayerCell)
|
||||
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)
|
||||
// The decoration palette (all biomes' lists, flattened + 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)
|
||||
{
|
||||
MarkCellDone(Region, BuildId); // empty cell still counts toward the region's completion
|
||||
MarkCellDone(Region, Cell, BuildId); // empty cell still counts toward the region's completion
|
||||
continue;
|
||||
}
|
||||
|
||||
TArray<FStrateDecoration> EntriesCopy = SrcDecos; // snapshot for the worker + the spawner
|
||||
TArray<FStrateDecoration> EntriesCopy = CurrentEntries; // snapshot for the worker + the spawner
|
||||
TArray<int32> EntryBiomeCopy = CurrentEntryBiome; // parallel: ctx-biome owner per entry
|
||||
const FDecoContext Ctx = CurrentCtx; // PODs only used on the worker
|
||||
const uint32 LocalSeed = (uint32)Seed;
|
||||
UVoxelGenerator* Gen = Generator;
|
||||
@@ -303,7 +349,7 @@ void UVoxelContentManager::LaunchDecoTasks(const FIntPoint& PlayerCell)
|
||||
|
||||
UE::Tasks::Launch(TEXT("DecoMarch"),
|
||||
[this, Gen, OwnerXf, Cell, Ctx, LocalSeed, Spacing, Step, MaxCross, ColDepth, BuildId,
|
||||
Entries = MoveTemp(EntriesCopy)]() mutable
|
||||
Entries = MoveTemp(EntriesCopy), EntryBiome = MoveTemp(EntryBiomeCopy)]() mutable
|
||||
{
|
||||
struct FGuard { ~FGuard() { GActiveDecoTasks.fetch_sub(1, std::memory_order_relaxed); } } Guard;
|
||||
|
||||
@@ -313,7 +359,7 @@ void UVoxelContentManager::LaunchDecoTasks(const FIntPoint& PlayerCell)
|
||||
Result.Cell = Cell;
|
||||
Result.BuildId = BuildId;
|
||||
Result.Entries = MoveTemp(Entries);
|
||||
BuildCellSpawns(Gen, OwnerXf, Cell, Ctx, Result.Entries, LocalSeed,
|
||||
BuildCellSpawns(Gen, OwnerXf, Cell, Ctx, Result.Entries, EntryBiome, LocalSeed,
|
||||
Spacing, Step, MaxCross, ColDepth, Result.Spawns);
|
||||
|
||||
if (!bShuttingDown.load(std::memory_order_relaxed))
|
||||
@@ -327,7 +373,8 @@ void UVoxelContentManager::LaunchDecoTasks(const FIntPoint& PlayerCell)
|
||||
// ---- 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,
|
||||
const TArray<FStrateDecoration>& Entries,
|
||||
const TArray<int32>& EntryBiome, uint32 InSeed,
|
||||
int32 Spacing, float Step, int32 MaxCrossings, float ColumnDepth,
|
||||
TArray<FDecoSpawn>& OutSpawns)
|
||||
{
|
||||
@@ -340,12 +387,20 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
|
||||
TArray<int32> EntryCount; EntryCount.Init(0, Entries.Num());
|
||||
int32 TotalActors = 0;
|
||||
|
||||
// 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).
|
||||
FChunkBiomeCache BiomeCache;
|
||||
const bool bHasBiomes = Ctx.BiomeCtx.IsValid();
|
||||
|
||||
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.
|
||||
// ColBiome = the column's dominant context-biome index (-1 when biomes are off); an entry is rolled
|
||||
// only if it belongs to that biome (EntryBiome[EntryIdx] == ColBiome) → organic, per-column borders.
|
||||
auto PlaceAtCrossing = [&](float VX, float VY, int32 gx, int32 gy, float ZC,
|
||||
const FVector& NormalWorld, int32 CrossingIdx)
|
||||
const FVector& NormalWorld, int32 CrossingIdx, int32 ColBiome)
|
||||
{
|
||||
const bool bFloor = NormalWorld.Z > 0.5f;
|
||||
const bool bCeiling = NormalWorld.Z < -0.5f;
|
||||
@@ -357,6 +412,10 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
|
||||
|
||||
for (int32 EntryIdx = 0; EntryIdx < Entries.Num(); ++EntryIdx)
|
||||
{
|
||||
// Per-column biome gate: only this column's dominant biome owns its entries. -1-tagged
|
||||
// entries (no biome field) match the -1 ColBiome, so the legacy single-list path is intact.
|
||||
if (EntryBiome[EntryIdx] != ColBiome) continue;
|
||||
|
||||
const FStrateDecoration& Deco = Entries[EntryIdx];
|
||||
const bool bInstanced = (Deco.InstancedMesh != nullptr);
|
||||
if (!bInstanced && !Deco.ActorClass) continue;
|
||||
@@ -374,6 +433,11 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
|
||||
}
|
||||
if (!bMatches) continue;
|
||||
|
||||
// Strict-wall overhang gate: a "wall" point also covers surfaces that lean slightly downward
|
||||
// (N.Z in [-0.5, 0)). For props flagged wall-only-upright, drop those so overhangs don't take
|
||||
// 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.
|
||||
@@ -383,6 +447,14 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
|
||||
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;
|
||||
}
|
||||
|
||||
const uint32 H = DecoHash(Cell.X, Cell.Y, gx, gy, CrossingIdx, EntryIdx, InSeed, 0xDEC0u);
|
||||
if (VoxelHash::ToFloat01(H) > Deco.SpawnDensity) continue;
|
||||
|
||||
@@ -397,7 +469,10 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
|
||||
: FQuat::Identity;
|
||||
if (Deco.bRandomYaw)
|
||||
{
|
||||
const float Yaw = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x59415721u)) * 2.0f * PI;
|
||||
// Roll within [MinYaw, MaxYaw]; the default 0..360 reproduces the legacy full-turn roll
|
||||
// bit-for-bit (Lerp(0,360,t)° == t·2π rad), so existing assets are unchanged.
|
||||
const float YawT = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x59415721u));
|
||||
const float Yaw = FMath::DegreesToRadians(FMath::Lerp(Deco.MinYaw, Deco.MaxYaw, YawT));
|
||||
const FVector Axis = Deco.bAlignToSurface ? NormalWorld : FVector::UpVector;
|
||||
BaseQ = FQuat(Axis, Yaw) * BaseQ;
|
||||
}
|
||||
@@ -425,6 +500,25 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
|
||||
const float VX = (float)(CellOriginVX + gx * Spacing + JX);
|
||||
const float VY = (float)(CellOriginVY + gy * Spacing + JY);
|
||||
|
||||
// Resolve the column's biome ONCE (constant over the column's whole Z range). -1 when the strate
|
||||
// has no biome field → matches the -1-tagged legacy entries.
|
||||
int32 ColBiome = -1;
|
||||
if (bHasBiomes)
|
||||
{
|
||||
const FBiomeSample BS = Gen->ResolveBiomeSampleAt(VX, VY, Ctx.RepChunkZ, Ctx.BiomeCtx, BiomeCache);
|
||||
ColBiome = BS.DominantIndex; // index into Ctx.BiomeCtx.Biomes == the entry tag
|
||||
|
||||
// SOFTEN THE BORDER: a hard dominant pick still switches deco sets on a crisp line. In the
|
||||
// blend band (NeighborWeight rises 0 → ~0.5 toward the shared border) flip a hash-decided
|
||||
// fraction of columns to the NEIGHBOUR biome, so the two deco sets DITHER across the seam
|
||||
// instead of snapping. Deterministic (pure hash of cell/column/seed) → no flicker/perf cost.
|
||||
if (BS.NeighborWeight > 0.0f && BS.NeighborIndex >= 0)
|
||||
{
|
||||
const uint32 HBlend = DecoHash(Cell.X, Cell.Y, gx, gy, -7, 0, InSeed, 0xB1E2u);
|
||||
if (VoxelHash::ToFloat01(HBlend) < BS.NeighborWeight) { ColBiome = BS.NeighborIndex; }
|
||||
}
|
||||
}
|
||||
|
||||
if (Ctx.bSurfaceWorld)
|
||||
{
|
||||
// HEIGHTFIELD ORACLE — O(1)/column instead of marching the whole band. Query the surface
|
||||
@@ -445,7 +539,7 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
|
||||
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);
|
||||
PlaceAtCrossing(VX, VY, gx, gy, hC, N, 0, ColBiome);
|
||||
}
|
||||
|
||||
// Sky-cap ceiling underside: outward normal = (dC/dx, dC/dy, -1). Only if open space below.
|
||||
@@ -455,7 +549,7 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
|
||||
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);
|
||||
PlaceAtCrossing(VX, VY, gx, gy, cC, N, 1, ColBiome);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -491,7 +585,7 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
|
||||
FVector NormalWorld = OwnerXf.TransformVectorNoScale(LocalGrad).GetSafeNormal();
|
||||
if (NormalWorld.IsNearlyZero()) NormalWorld = FVector::UpVector;
|
||||
|
||||
PlaceAtCrossing(VX, VY, gx, gy, ZC, NormalWorld, Crossings);
|
||||
PlaceAtCrossing(VX, VY, gx, gy, ZC, NormalWorld, Crossings, ColBiome);
|
||||
++Crossings;
|
||||
}
|
||||
|
||||
@@ -550,7 +644,16 @@ 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;
|
||||
if (!Build || Build->BuildId != Result.BuildId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Already counted this cell (a duplicate task for the same cell+build landed first) → drop this
|
||||
// result whole, or we'd append its spawns twice (double decorations at the same spots).
|
||||
if (Build->AccountedCells.Contains(Result.Cell))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (const FDecoSpawn& S : Result.Spawns)
|
||||
{
|
||||
@@ -574,14 +677,21 @@ void UVoxelContentManager::MergeCellResult(const FDecoCellResult& Result)
|
||||
}
|
||||
}
|
||||
|
||||
MarkCellDone(Region, Result.BuildId);
|
||||
MarkCellDone(Region, Result.Cell, 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)
|
||||
// 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)
|
||||
{
|
||||
FDecoRegionBuild* Build = RegionBuilds.Find(Region);
|
||||
if (!Build || Build->BuildId != BuildId) return;
|
||||
|
||||
bool bAlreadyAccounted = false;
|
||||
Build->AccountedCells.Add(Cell, &bAlreadyAccounted);
|
||||
if (bAlreadyAccounted) return; // this cell already counted → ignore the duplicate
|
||||
|
||||
if (--Build->CellsRemaining <= 0)
|
||||
{
|
||||
CompletedRegions.Add(Region); // ready for budgeted apply in ProcessDecoResults
|
||||
@@ -782,3 +892,76 @@ void UVoxelContentManager::ClearAll()
|
||||
LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN);
|
||||
LastStrateIndex = INT32_MIN;
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// DIAGNOSTIC — decoration streaming state under a point
|
||||
//=============================================================================
|
||||
|
||||
void UVoxelContentManager::QueryDecoDebugAt(const FVector& LocalPos, bool& bApplied, int32& InstanceCount,
|
||||
bool& bBuilding, int32& CellsAccounted, int32& CellsTotal,
|
||||
int32& LiveMarchSpawns, int32& InstancesInCell) const
|
||||
{
|
||||
bApplied = false; InstanceCount = 0; bBuilding = false; CellsAccounted = 0;
|
||||
LiveMarchSpawns = -1; InstancesInCell = 0;
|
||||
|
||||
const int32 R = RegionSize();
|
||||
CellsTotal = R * R;
|
||||
|
||||
// World-LOCAL XY → deco cell → region (same math the scatter uses).
|
||||
const float CellWorld = (float)DECO_CELL_VOXELS * VOXEL_SIZE;
|
||||
const FIntPoint Cell(FMath::FloorToInt(LocalPos.X / CellWorld),
|
||||
FMath::FloorToInt(LocalPos.Y / CellWorld));
|
||||
const FIntPoint Region = CellToRegion(Cell, R);
|
||||
|
||||
// Actor-LOCAL XY footprint of the probed cell (instances are stored component-local; the HISM sits at
|
||||
// the actor transform with identity relative, so component-local == actor-local cell space).
|
||||
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))
|
||||
{
|
||||
bApplied = true;
|
||||
for (const TWeakObjectPtr<UHierarchicalInstancedStaticMeshComponent>& C : Content->Instances)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
// 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.
|
||||
AActor* OwnerActor = Owner.Get();
|
||||
if (Generator && Settings && OwnerActor && CurrentCtx.Def && CurrentEntries.Num() > 0)
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2433,6 +2433,54 @@ const UVoxelBiomeDefinition* UVoxelGenerator::GetDominantBiomeAt(float WorldX, f
|
||||
return (Def && Def->Biomes.IsValidIndex(StrateBiomeIdx)) ? Def->Biomes[StrateBiomeIdx] : nullptr;
|
||||
}
|
||||
|
||||
void UVoxelGenerator::QueryBiomeAt(float WorldX, float WorldY, int32 ChunkZ, FVoxelBiomeQuery& Out) const
|
||||
{
|
||||
Out = FVoxelBiomeQuery();
|
||||
if (!StrateManager) return;
|
||||
|
||||
const FIntVector Coord(FMath::FloorToInt(WorldX / CHUNK_SIZE),
|
||||
FMath::FloorToInt(WorldY / CHUNK_SIZE), ChunkZ);
|
||||
const FBiomeContext Ctx = StrateManager->GetBiomeContextForChunk(Coord);
|
||||
|
||||
// Climate fields are always meaningful (use the strate's map freqs, or defaults when no biomes).
|
||||
const FBiomeMapParams MP = Ctx.IsValid() ? Ctx.Map : FBiomeMapParams();
|
||||
Out.Relief = SampleRelief(WorldX, WorldY, MP.ReliefFrequency, MP.ReliefContrast);
|
||||
Out.Moisture = SampleMoisture(WorldX, WorldY, MP.MoistureFrequency);
|
||||
|
||||
if (!Ctx.IsValid()) return; // bHasBiomes stays false → BP knows this strate has no biome field
|
||||
Out.bHasBiomes = true;
|
||||
|
||||
const FBiomeSample S = SampleBiomeAt(WorldX, WorldY, Ctx);
|
||||
Out.NeighborWeight = S.NeighborWeight;
|
||||
Out.DominantContextIndex = S.DominantIndex;
|
||||
|
||||
const UVoxelStrateDefinition* Def = StrateManager->GetStrateForChunk(Coord);
|
||||
|
||||
if (Ctx.Biomes.IsValidIndex(S.DominantIndex))
|
||||
{
|
||||
const int32 DomStrateIdx = Ctx.Biomes[S.DominantIndex].Index;
|
||||
if (Def && Def->Biomes.IsValidIndex(DomStrateIdx))
|
||||
{
|
||||
UVoxelBiomeDefinition* Bio = Def->Biomes[DomStrateIdx];
|
||||
Out.DominantBiome = Bio;
|
||||
if (Bio)
|
||||
{
|
||||
Out.DebugColor = Bio->DebugColor;
|
||||
Out.DominantName = Bio->BiomeName.IsEmpty() ? FText::FromName(Bio->GetFName()) : Bio->BiomeName;
|
||||
// Effective list = the biome's decorations, or the strate's when the biome has none
|
||||
// (this is exactly what the per-column scatter falls back to). 0 ⇒ empty band.
|
||||
Out.DominantDecorationCount =
|
||||
(Bio->Decorations.Num() > 0) ? Bio->Decorations.Num() : Def->Decorations.Num();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Ctx.Biomes.IsValidIndex(S.NeighborIndex))
|
||||
{
|
||||
const int32 NbStrateIdx = Ctx.Biomes[S.NeighborIndex].Index;
|
||||
if (Def && Def->Biomes.IsValidIndex(NbStrateIdx)) { Out.NeighborBiome = Def->Biomes[NbStrateIdx]; }
|
||||
}
|
||||
}
|
||||
|
||||
void UVoxelGenerator::GetBiomeMaterialAt(float WorldX, float WorldY, float WorldZ,
|
||||
int32& OutDominantPalette, int32& OutNeighborPalette, float& OutBlendWeight) const
|
||||
{
|
||||
|
||||
@@ -24,6 +24,51 @@ AVoxelWorld::AVoxelWorld()
|
||||
PrimaryActorTick.bCanEverTick = true;
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// T1.f — build the RMC geometry buffers OFF the game thread.
|
||||
//=============================================================================
|
||||
// FRealtimeMeshStreamSet is plain CPU data; the per-vertex/per-triangle builder loop used to run
|
||||
// in ApplyMeshToTile ON THE GAME THREAD, where it was the dominant streaming cost (game thread
|
||||
// >6 ms while moving, GPU/draw idle). It touches ONLY the POD MeshData arrays — no UObject, no
|
||||
// generator — so it's safe on the gen worker. The game thread then just uploads the finished
|
||||
// streams (CreateSectionGroup). Byte-identical geometry; the only thing that moved is WHERE it runs.
|
||||
static void BuildTileStreamSet(RealtimeMesh::FRealtimeMeshStreamSet& Streams, const FVoxelMeshData& MeshData)
|
||||
{
|
||||
RealtimeMesh::TRealtimeMeshBuilderLocal<uint32, FPackedNormal, FVector2DHalf, 1> Builder(Streams);
|
||||
Builder.EnableTangents();
|
||||
Builder.EnableTexCoords();
|
||||
Builder.EnableColors(); // masques matériau F6 (palette biome / pente / fondu) — voir le mesher
|
||||
Builder.EnablePolyGroups();
|
||||
|
||||
const int32 NumVertices = MeshData.Vertices.Num();
|
||||
Builder.ReserveAdditionalVertices(NumVertices);
|
||||
for (int32 i = 0; i < NumVertices; i++)
|
||||
{
|
||||
auto Vertex = Builder.AddVertex((FVector3f)MeshData.Vertices[i]);
|
||||
if (MeshData.Normals.IsValidIndex(i))
|
||||
{
|
||||
Vertex.SetNormalAndTangent((FVector3f)MeshData.Normals[i], FVector3f(1, 0, 0));
|
||||
}
|
||||
if (MeshData.UVs.IsValidIndex(i))
|
||||
{
|
||||
Vertex.SetTexCoord(0, (FVector2f)MeshData.UVs[i]);
|
||||
}
|
||||
if (MeshData.Colors.IsValidIndex(i))
|
||||
{
|
||||
Vertex.SetColor(MeshData.Colors[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const int32 NumIndices = MeshData.Triangles.Num();
|
||||
Builder.ReserveAdditionalTriangles(NumIndices / 3);
|
||||
for (int32 i = 0; i < NumIndices; i += 3)
|
||||
{
|
||||
Builder.AddTriangle((uint32)MeshData.Triangles[i],
|
||||
(uint32)MeshData.Triangles[i + 1],
|
||||
(uint32)MeshData.Triangles[i + 2], 0 /*poly group*/);
|
||||
}
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// LIVE EDIT — regenerate all chunks when params change in the Details panel
|
||||
//=============================================================================
|
||||
@@ -465,13 +510,14 @@ void AVoxelWorld::ProcessPendingChunks()
|
||||
LoadedTiles.Add(DequeuedChunk.Tile);
|
||||
|
||||
// Empty mesh = all-air tile — nothing to render, but still "loaded".
|
||||
if (DequeuedChunk.MeshData.IsEmpty())
|
||||
if (DequeuedChunk.bEmpty || !DequeuedChunk.Streams)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply mesh (GPU upload) — this is the expensive part we budget.
|
||||
ApplyMeshToTile(DequeuedChunk.Tile, DequeuedChunk.MeshData);
|
||||
// Apply mesh (GPU upload) — this is the budgeted part. The vertex/index buffers were
|
||||
// already built on the worker (T1.f); the game thread only uploads them here.
|
||||
ApplyMeshToTile(DequeuedChunk.Tile, MoveTemp(*DequeuedChunk.Streams), DequeuedChunk.bIsCeiling);
|
||||
MeshesApplied++;
|
||||
|
||||
if (MeshesApplied >= MaxApplies)
|
||||
@@ -781,14 +827,43 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile)
|
||||
FChunkResult Result;
|
||||
Result.Tile = Tile;
|
||||
Result.Epoch = TaskEpoch;
|
||||
|
||||
FVoxelMeshData MeshData;
|
||||
{
|
||||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_GenerateMesh);
|
||||
Result.MeshData = Mesher->GenerateMesh(OriginVoxels, Step, Cells);
|
||||
MeshData = Mesher->GenerateMesh(OriginVoxels, Step, Cells);
|
||||
}
|
||||
|
||||
// T1.f — build the RMC geometry buffers HERE (worker), not on the game thread. Empty/all-air
|
||||
// tiles carry no streams (Result.bEmpty stays true) → no component on apply.
|
||||
if (!MeshData.IsEmpty())
|
||||
{
|
||||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_BuildStreams);
|
||||
Result.Streams = MakeShared<RealtimeMesh::FRealtimeMeshStreamSet>();
|
||||
BuildTileStreamSet(*Result.Streams, MeshData);
|
||||
Result.bEmpty = false;
|
||||
|
||||
// Classify ceiling from the ACTUAL mesh normals (smoothed density gradient, solid→air):
|
||||
// a ceiling surface faces DOWN (N.Z < 0), ground faces UP. This is the rendered geometry,
|
||||
// so it can't disagree with the view — unlike the old game-thread height-oracle sample,
|
||||
// which misclassified coarse far tiles (terrain material on the sky-cap underside). Vote
|
||||
// down-vs-up over the verts; near-vertical wall normals (|N.Z| small) abstain. The game
|
||||
// thread gates this to SurfaceWorld strates before it actually swaps material / shadow.
|
||||
// STOPGAP (fable-idea F17): orientation only works while NO CAVES EXIST — down == sky-cap.
|
||||
// A future cave roof is also down-facing; distinguishing it needs a generator-stamped surface
|
||||
// class (CeilSurf vs carve-below-TerrainZ) carried as a polygroup → material slot. See F17.
|
||||
int32 DownVerts = 0, UpVerts = 0;
|
||||
for (const FVector& N : MeshData.Normals)
|
||||
{
|
||||
if (N.Z < -0.1f) { ++DownVerts; }
|
||||
else if (N.Z > 0.1f) { ++UpVerts; }
|
||||
}
|
||||
Result.bIsCeiling = (DownVerts > UpVerts);
|
||||
}
|
||||
|
||||
if (!bShuttingDown.load(std::memory_order_relaxed))
|
||||
{
|
||||
ProcessQueue.Enqueue(Result);
|
||||
ProcessQueue.Enqueue(MoveTemp(Result)); // move: don't copy the geometry payload
|
||||
}
|
||||
}, UE::Tasks::ETaskPriority::BackgroundNormal);
|
||||
}
|
||||
@@ -806,29 +881,24 @@ void AVoxelWorld::UnloadTile(const FVoxelTileKey& Tile)
|
||||
PendingTiles.Remove(Tile);
|
||||
}
|
||||
|
||||
void AVoxelWorld::ApplyMeshToTile(const FVoxelTileKey& Tile, const FVoxelMeshData& MeshData)
|
||||
void AVoxelWorld::ApplyMeshToTile(const FVoxelTileKey& Tile, RealtimeMesh::FRealtimeMeshStreamSet&& Streams, bool bGeomCeiling)
|
||||
{
|
||||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ApplyMeshToChunk);
|
||||
|
||||
if (MeshData.IsEmpty())
|
||||
{
|
||||
// Became empty (e.g. fully carved) — drop any prior component for this tile.
|
||||
if (URealtimeMeshComponent** C = TileComponents.Find(Tile))
|
||||
{
|
||||
if (*C) { (*C)->DestroyComponent(); }
|
||||
TileComponents.Remove(Tile);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Streams are pre-built on the worker (T1.f) and guaranteed non-empty by the caller
|
||||
// (ProcessPendingChunks skips empty tiles). This path is now game-thread-CHEAP: O(1) classify +
|
||||
// material + component get/create + the upload. No per-vertex work here anymore.
|
||||
const bool bLevel0 = (Tile.Level == 0);
|
||||
|
||||
// SKY-CAP CEILING classification (computed once, used for BOTH shadow + material). A tile is a
|
||||
// ceiling tile if its centre sits in the upper half of the open span between the ground surface and
|
||||
// the cap (the tile grid keeps ceiling tiles separate from ground tiles — they're far apart in Z).
|
||||
// The oracle is O(1) and returns false for non-surface strates, so this is cheap at every level.
|
||||
// SKY-CAP CEILING classification (used for BOTH shadow + material). WHAT it is — down-facing geometry
|
||||
// — is decided on the worker by voting the tile's ACTUAL mesh normals (bGeomCeiling); that's the
|
||||
// rendered surface, so it can't disagree with the view the way the old centre height-oracle sample did
|
||||
// (it misclassified coarse far tiles → terrain material on the sky-cap underside). WHETHER a tile may
|
||||
// be a sky-cap ceiling at all is still gated to SurfaceWorld strates: one O(1) oracle probe at the tile
|
||||
// centre (the oracle returns false for non-surface strates), so cave ceilings keep their prior material
|
||||
// + shadow. The probed heights themselves are unused now — only the success/fail gate matters.
|
||||
bool bIsCeiling = false;
|
||||
if (Generator)
|
||||
if (Generator && bGeomCeiling)
|
||||
{
|
||||
const int32 VoxelsPerTile = CHUNK_SIZE << Tile.Level;
|
||||
const FIntVector MinVoxel = Tile.Coord * VoxelsPerTile;
|
||||
@@ -838,11 +908,7 @@ void AVoxelWorld::ApplyMeshToTile(const FVoxelTileKey& Tile, const FVoxelMeshDat
|
||||
const int32 CenterChunkZ = FMath::FloorToInt(CenterZ / (float)CHUNK_SIZE);
|
||||
|
||||
float TerrainZ = 0.0f, CeilSurf = 0.0f;
|
||||
if (Generator->GetSurfaceHeightAt(CenterX, CenterY, CenterChunkZ, TerrainZ, CeilSurf))
|
||||
{
|
||||
const float Mid = (TerrainZ + CeilSurf) * 0.5f;
|
||||
bIsCeiling = (CenterZ > Mid);
|
||||
}
|
||||
bIsCeiling = Generator->GetSurfaceHeightAt(CenterX, CenterY, CenterChunkZ, TerrainZ, CeilSurf);
|
||||
}
|
||||
|
||||
// Material: strate override (by the tile's min-corner chunk coord) else the global default. Ceiling
|
||||
@@ -859,43 +925,8 @@ void AVoxelWorld::ApplyMeshToTile(const FVoxelTileKey& Tile, const FVoxelMeshDat
|
||||
}
|
||||
}
|
||||
|
||||
// Build the geometry stream set. Vertices are world-space; the component sits at the actor origin.
|
||||
RealtimeMesh::FRealtimeMeshStreamSet Streams;
|
||||
{
|
||||
RealtimeMesh::TRealtimeMeshBuilderLocal<uint32, FPackedNormal, FVector2DHalf, 1> Builder(Streams);
|
||||
Builder.EnableTangents();
|
||||
Builder.EnableTexCoords();
|
||||
Builder.EnableColors(); // masques matériau F6 (palette biome / pente / fondu) — voir le mesher
|
||||
Builder.EnablePolyGroups();
|
||||
|
||||
const int32 NumVertices = MeshData.Vertices.Num();
|
||||
Builder.ReserveAdditionalVertices(NumVertices);
|
||||
for (int32 i = 0; i < NumVertices; i++)
|
||||
{
|
||||
auto Vertex = Builder.AddVertex((FVector3f)MeshData.Vertices[i]);
|
||||
if (MeshData.Normals.IsValidIndex(i))
|
||||
{
|
||||
Vertex.SetNormalAndTangent((FVector3f)MeshData.Normals[i], FVector3f(1, 0, 0));
|
||||
}
|
||||
if (MeshData.UVs.IsValidIndex(i))
|
||||
{
|
||||
Vertex.SetTexCoord(0, (FVector2f)MeshData.UVs[i]);
|
||||
}
|
||||
if (MeshData.Colors.IsValidIndex(i))
|
||||
{
|
||||
Vertex.SetColor(MeshData.Colors[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const int32 NumIndices = MeshData.Triangles.Num();
|
||||
Builder.ReserveAdditionalTriangles(NumIndices / 3);
|
||||
for (int32 i = 0; i < NumIndices; i += 3)
|
||||
{
|
||||
Builder.AddTriangle((uint32)MeshData.Triangles[i],
|
||||
(uint32)MeshData.Triangles[i + 1],
|
||||
(uint32)MeshData.Triangles[i + 2], 0 /*poly group*/);
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
|
||||
// One component per tile — the clipmap keeps the total tile count low (~1-2k), so this is
|
||||
// cheap on the game thread (no batching needed). Collision + content are level-0 only.
|
||||
@@ -955,6 +986,31 @@ int32 AVoxelWorld::GetStrateAtPosition(FVector WorldPosition) const
|
||||
return StrateManager->GetStrateIndex(WorldPosition.Z);
|
||||
}
|
||||
|
||||
FVoxelBiomeQuery AVoxelWorld::GetBiomeAtWorldLocation(FVector WorldLocation) const
|
||||
{
|
||||
FVoxelBiomeQuery Out;
|
||||
if (!Generator) return Out;
|
||||
|
||||
// Bring the world point into actor-LOCAL voxel space — the SAME transform the decoration scatter
|
||||
// applies (UpdateDecorations), so the probe agrees with where props actually land.
|
||||
const FVector Local = GetActorTransform().InverseTransformPosition(WorldLocation);
|
||||
const float VX = Local.X / VOXEL_SIZE;
|
||||
const float VY = Local.Y / VOXEL_SIZE;
|
||||
const int32 ChunkZ = FMath::FloorToInt((Local.Z / VOXEL_SIZE) / (float)CHUNK_SIZE);
|
||||
|
||||
Generator->QueryBiomeAt(VX, VY, ChunkZ, Out);
|
||||
|
||||
// Decoration streaming state of the region under this point — discriminates a render drop / empty
|
||||
// march / stuck build / never-requested region for a visibly-bare patch (see FVoxelBiomeQuery).
|
||||
if (ContentManager)
|
||||
{
|
||||
ContentManager->QueryDecoDebugAt(Local, Out.bDecoRegionApplied, Out.DecoAppliedInstances,
|
||||
Out.bDecoRegionBuilding, Out.DecoCellsAccounted, Out.DecoCellsTotal,
|
||||
Out.DecoLiveMarchSpawns, Out.DecoInstancesInCell);
|
||||
}
|
||||
return Out;
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// TERRAIN MODIFICATION — player carving & filling
|
||||
//=============================================================================
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// VoxelBiomeDefinition.h
|
||||
// Data asset defining one biome: where it lives (climate), how it reshapes the base
|
||||
// terrain (modulation), and what content/atmosphere it brings.
|
||||
//
|
||||
// HOW TO USE:
|
||||
// -----------
|
||||
// 1. Content Browser → Miscellaneous → Data Asset → "VoxelBiomeDefinition".
|
||||
// 2. Set its climate box (ReliefMin/Max, MoistureMin/Max), its terrain override, and its
|
||||
// Decorations / atmosphere / water.
|
||||
// 3. Add it to a UVoxelStrateDefinition's Biomes[] list.
|
||||
//
|
||||
// A biome is generator-agnostic: the same asset works inside any archetype strate
|
||||
// (surface biomes today, cave biomes later). The strate's base archetype params are
|
||||
// the baseline; this asset modulates on top.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Engine/DataAsset.h"
|
||||
#include "GameplayTagContainer.h"
|
||||
#include "VoxelBiomeTypes.h"
|
||||
#include "VoxelStrateTypes.h" // FStrateDecoration / FStrateAmbientActor
|
||||
#include "VoxelBiomeDefinition.generated.h"
|
||||
|
||||
/**
|
||||
* UVoxelBiomeDefinition — one biome's identity, placement, terrain modulation and content.
|
||||
*/
|
||||
UCLASS(BlueprintType)
|
||||
class VOXELFORGE_API UVoxelBiomeDefinition : public UPrimaryDataAsset
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
//=========================================================================
|
||||
// IDENTITY
|
||||
//=========================================================================
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Biome")
|
||||
FText BiomeName;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Biome", meta = (MultiLine = true))
|
||||
FText BiomeDescription;
|
||||
|
||||
// Colour used by the 2D biome-preview bake to identify this biome's regions.
|
||||
// Pick something distinct per biome so the baked map is readable.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome")
|
||||
FLinearColor DebugColor = FLinearColor(0.5f, 0.5f, 0.5f, 1.0f);
|
||||
|
||||
//=========================================================================
|
||||
// CLIMATE PLACEMENT
|
||||
//=========================================================================
|
||||
// A biome wins the Voronoi cells whose site climate (relief, moisture) falls inside
|
||||
// — or, failing any match, nearest to — this box. Both axes are [0,1].
|
||||
// Mountains / snow → high ReliefMin. Lush forest → high Moisture.
|
||||
// Desert → low Moisture. Plains → mid relief, any moisture.
|
||||
// Boxes may overlap; ties are broken deterministically per cell.
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Climate", meta = (ClampMin = "0.0", ClampMax = "1.0"))
|
||||
float ReliefMin = 0.0f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Climate", meta = (ClampMin = "0.0", ClampMax = "1.0"))
|
||||
float ReliefMax = 1.0f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Climate", meta = (ClampMin = "0.0", ClampMax = "1.0"))
|
||||
float MoistureMin = 0.0f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Climate", meta = (ClampMin = "0.0", ClampMax = "1.0"))
|
||||
float MoistureMax = 1.0f;
|
||||
|
||||
//=========================================================================
|
||||
// TERRAIN OVERRIDE — a biome is a "mini-strate-variant"
|
||||
//=========================================================================
|
||||
// When enabled, this biome supplies its OWN archetype params for its regions,
|
||||
// replacing the strate's. Heightfield archetypes (SurfaceWorld) blend the output
|
||||
// across biome borders, so even frequency/shape differences stay seamless.
|
||||
// Global/structural fields (strate Z bounds, boundary seal, base density, water
|
||||
// level) are always forced from the strate — a biome can't break seals/connectivity.
|
||||
//
|
||||
// GeneratorType must match the strate this biome is used in; if it doesn't, the
|
||||
// override is ignored and the strate's params are used. (Currently wired: SurfaceWorld.
|
||||
// Other archetypes' param overrides arrive as each is hooked up — see CODEMAP §8.14.)
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Terrain")
|
||||
bool bOverrideTerrain = false;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Terrain",
|
||||
meta = (EditCondition = "bOverrideTerrain"))
|
||||
ECaveGeneratorType GeneratorType = ECaveGeneratorType::SurfaceWorld;
|
||||
|
||||
// Open-sky terrain override (used when bOverrideTerrain && GeneratorType == SurfaceWorld).
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Terrain",
|
||||
meta = (EditCondition = "bOverrideTerrain && GeneratorType == ECaveGeneratorType::SurfaceWorld"))
|
||||
FSurfaceGenerationParams SurfaceParams;
|
||||
|
||||
//=========================================================================
|
||||
// CONTENT PROFILE (consumed by the content / atmosphere managers)
|
||||
//=========================================================================
|
||||
|
||||
// Decorations placed in this biome's regions. When set, these REPLACE the strate's
|
||||
// decorations for chunks whose dominant biome is this one (falls back to the strate
|
||||
// list when empty).
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Content")
|
||||
TArray<FStrateDecoration> Decorations;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Content")
|
||||
TArray<FStrateAmbientActor> AmbientActors;
|
||||
|
||||
//=========================================================================
|
||||
// ATMOSPHERE OVERRIDE (optional — beats the strate's atmosphere for this biome)
|
||||
//=========================================================================
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Atmosphere")
|
||||
bool bOverrideAtmosphere = false;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Atmosphere", meta = (EditCondition = "bOverrideAtmosphere"))
|
||||
FLinearColor FogColor = FLinearColor(0.05f, 0.05f, 0.1f, 1.0f);
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Atmosphere", meta = (EditCondition = "bOverrideAtmosphere", ClampMin = "0.0", ClampMax = "1.0"))
|
||||
float FogDensity = 0.3f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Atmosphere", meta = (EditCondition = "bOverrideAtmosphere"))
|
||||
FLinearColor AmbientLightColor = FLinearColor(0.1f, 0.1f, 0.15f, 1.0f);
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Atmosphere", meta = (EditCondition = "bOverrideAtmosphere", ClampMin = "0.0"))
|
||||
float AmbientLightIntensity = 0.5f;
|
||||
|
||||
//=========================================================================
|
||||
// WATER
|
||||
//=========================================================================
|
||||
|
||||
// Water material override for this biome (null = use the strate's WaterMaterial).
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Water")
|
||||
UMaterialInterface* WaterMaterial = nullptr;
|
||||
|
||||
//=========================================================================
|
||||
// MATERIAL — F6 vertex-colour triplanar palette
|
||||
//=========================================================================
|
||||
// Which palette layer this biome's terrain uses in the master triplanar material.
|
||||
// It is baked into the mesh vertex colour (R channel) at mesh time, so ONE material
|
||||
// re-skins the terrain per biome and cross-fades across biome borders (the neighbour
|
||||
// biome's index + a blend weight ride in the A/B channels). The terrain material is
|
||||
// still the strate's OverrideMaterial / Settings->VoxelMaterial — author it as the
|
||||
// master palette material and switch its layers on this index. 0 = default layer.
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Material", meta = (ClampMin = "0", ClampMax = "255"))
|
||||
int32 MaterialPaletteIndex = 0;
|
||||
|
||||
//=========================================================================
|
||||
// GAMEPLAY TAGS
|
||||
//=========================================================================
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Gameplay")
|
||||
FGameplayTagContainer GameplayTags;
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
// VoxelBiomeTypes.h
|
||||
// Shared vocabulary for the biome system (Stage 1).
|
||||
//
|
||||
// A biome is a "mini-strate-variant": it can carry a FULL archetype param override
|
||||
// (e.g. its own FSurfaceGenerationParams) plus a content profile, placed across the
|
||||
// world by a deterministic, window-invariant XY field (warped Voronoi + climate).
|
||||
//
|
||||
// Resolution discipline (protects CODEMAP §8.4 + §8.10):
|
||||
// - The biome assigned at any XY is a PURE function of world coords + seed + the
|
||||
// strate's biome context. No dependence on the chunk window.
|
||||
// - Heightfield archetypes (Surface) resolve dominant + neighbour biome per voxel and
|
||||
// blend the OUTPUT height — so ANY param difference (even frequencies) stays seamless,
|
||||
// which per-param blending could never do. The expensive climate classification is
|
||||
// cached once per chunk; per voxel is just a warp + 3x3 lookup.
|
||||
// - Structural/global fields (Z bounds, seal, base density, water level) are always
|
||||
// forced from the STRATE so seals / spine / passages / water plane stay intact.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "VoxelBiomeTypes.generated.h"
|
||||
|
||||
class UVoxelBiomeDefinition;
|
||||
|
||||
/**
|
||||
* FBiomeMapParams — controls the world-XY biome field: how big biome regions are,
|
||||
* how organic their borders look, and the two climate fields that ASSIGN a biome to
|
||||
* each Voronoi cell. Lives on UVoxelStrateDefinition (one map per strate).
|
||||
*/
|
||||
USTRUCT(BlueprintType)
|
||||
struct VOXELFORGE_API FBiomeMapParams
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
// Average biome cell size in voxels. Larger = bigger, sweeping biome regions.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Map", meta = (ClampMin = "32.0"))
|
||||
float CellSize = 800.0f;
|
||||
|
||||
// Domain-warp the cell lookup by up to this many voxels → organic, non-hexagonal
|
||||
// borders. 0 = raw Voronoi cells (straight-ish borders).
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Map", meta = (ClampMin = "0.0"))
|
||||
float WarpStrength = 250.0f;
|
||||
|
||||
// Frequency of the border-warp noise. Lower = broader, sweeping border bends.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Map")
|
||||
float WarpFrequency = 0.0018f;
|
||||
|
||||
// Width (voxels) of the smooth blend band between neighbouring biomes. The density
|
||||
// scalars cross-fade across this band so there is no hard wall at a biome border.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Map", meta = (ClampMin = "0.0"))
|
||||
float BorderBlend = 120.0f;
|
||||
|
||||
// ----- Climate fields (assign each cell its biome) -----
|
||||
//
|
||||
// IMPORTANT: for COHERENT geography (mountain ranges, desert regions — not confetti)
|
||||
// the climate must vary much more SLOWLY than CellSize: aim for ~4-6 cells per climate
|
||||
// feature, i.e. ClimateFrequency ≈ 1 / (5 * CellSize). If the climate wavelength is
|
||||
// near or below CellSize, neighbouring cells sample unrelated climate → salt-and-pepper.
|
||||
|
||||
// Relief ("elevation") field, [0,1]. Mountain / snow biomes live where this is high.
|
||||
// Default tuned for CellSize≈800 (wavelength ~3300 ≈ 4 cells). Lower → bigger regions.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Climate")
|
||||
float ReliefFrequency = 0.0003f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Climate", meta = (ClampMin = "0.25", ClampMax = "4.0"))
|
||||
float ReliefContrast = 2.0f;
|
||||
|
||||
// Moisture field, [0,1] — the second climate axis (wet lowlands vs arid). Same
|
||||
// coherence rule as relief: keep the wavelength several cells wide.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Climate")
|
||||
float MoistureFrequency = 0.0004f;
|
||||
};
|
||||
|
||||
/**
|
||||
* Channel selector for the 2D biome-preview bake (AVoxelWorld::BakeBiomePreview).
|
||||
*/
|
||||
UENUM(BlueprintType)
|
||||
enum class EBiomePreviewChannel : uint8
|
||||
{
|
||||
Biome UMETA(DisplayName = "Biome (debug colours)"),
|
||||
Relief UMETA(DisplayName = "Relief / elevation field"),
|
||||
Moisture UMETA(DisplayName = "Moisture field")
|
||||
};
|
||||
|
||||
/**
|
||||
* FVoxelBiomeQuery — a Blueprint-readable snapshot of the biome field at one world point.
|
||||
* Returned by AVoxelWorld::GetBiomeAtWorldLocation so BP can read "which biome is under the
|
||||
* cursor" — the SAME resolution the decoration scatter uses per column. DecorationCount is the
|
||||
* effective number of decorations the dominant biome would place (its own list, or the strate's
|
||||
* when empty): 0 here explains a "band of nothing" — that biome simply has no decorations.
|
||||
*/
|
||||
USTRUCT(BlueprintType)
|
||||
struct VOXELFORGE_API FVoxelBiomeQuery
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
// False when the point's strate has no biome field (then only the climate fields are filled).
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Biome") bool bHasBiomes = false;
|
||||
|
||||
// Dominant biome asset at this point (what a decoration column here primarily uses). May be null.
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Biome") UVoxelBiomeDefinition* DominantBiome = nullptr;
|
||||
|
||||
// Nearest neighbouring biome (the one a border blend fades toward). May be null / same as dominant.
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Biome") UVoxelBiomeDefinition* NeighborBiome = nullptr;
|
||||
|
||||
// Dominant biome display name (falls back to the asset name when BiomeName is unset).
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Biome") FText DominantName;
|
||||
|
||||
// Dominant biome's preview/debug colour (handy to tint a BP debug draw to match the bake).
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Biome") FLinearColor DebugColor = FLinearColor::Black;
|
||||
|
||||
// Border blend weight toward the neighbour: 0 deep inside a cell → ~0.5 at the shared border.
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Biome") float NeighborWeight = 0.0f;
|
||||
|
||||
// Climate fields at this XY, both [0,1] (always filled, even when bHasBiomes is false).
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Biome") float Relief = 0.0f;
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Biome") float Moisture = 0.0f;
|
||||
|
||||
// Effective decoration count for the dominant biome (its list, else the strate's). 0 ⇒ empty band.
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Biome") int32 DominantDecorationCount = 0;
|
||||
|
||||
// Dominant biome's index in the strate's biome context (matches the decoration column tag). -1 = none.
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Biome") int32 DominantContextIndex = -1;
|
||||
|
||||
//--- DECORATION STREAMING STATE of the region covering this point (debug "why is this chunk blank?") ---
|
||||
// Discriminates the three failure modes for a visibly-bare patch:
|
||||
// • bDecoRegionApplied && DecoAppliedInstances > 0 → instances EXIST + are uploaded; if still not
|
||||
// visible it is a RENDER drop (proxy/culling), not data/streaming.
|
||||
// • bDecoRegionApplied && DecoAppliedInstances == 0 → the region applied but the per-column MARCH
|
||||
// produced no spawns here (config/determinism), not a render or streaming bug.
|
||||
// • bDecoRegionBuilding (not applied) → region is STILL marching; if it never finishes
|
||||
// (DecoCellsAccounted stuck < DecoCellsTotal) it is a streaming/accounting leak.
|
||||
// • neither flag set → region was never requested (desired-set / range).
|
||||
|
||||
// True if the region covering this XY is in DecoRegions (applied; HISMs built).
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Biome|Deco") bool bDecoRegionApplied = false;
|
||||
// Total HISM instances actually uploaded across the applied region's components.
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Biome|Deco") int32 DecoAppliedInstances = 0;
|
||||
// True if the region is in RegionBuilds (cells still being marched / merged, not yet applied).
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Biome|Deco") bool bDecoRegionBuilding = false;
|
||||
// Cells accounted vs the region's total (R*R). Building but accounted < total = in progress; if it
|
||||
// never reaches total the region is stuck (the permanent-blank-until-regen signature).
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Biome|Deco") int32 DecoCellsAccounted = 0;
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Biome|Deco") int32 DecoCellsTotal = 0;
|
||||
|
||||
// PER-CELL decisive probe: re-runs the decoration march for the EXACT cell (chunk footprint) under
|
||||
// this point, synchronously, with the current strate context — i.e. how many spawns the deterministic
|
||||
// scatter produces here RIGHT NOW. Region state above is R*R-cell coarse and can't see a single blank
|
||||
// cell; this can. On a visibly-BLANK cell: >0 ⇒ the march works, the spawns were lost in merge/apply or
|
||||
// not drawn (streaming/render bug); 0 ⇒ the march genuinely makes nothing here (data/config, or — if a
|
||||
// regen brings grass back at the same spot — a non-determinism bug). -1 ⇒ couldn't run (no strate
|
||||
// context / point not in the player's current strate).
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Biome|Deco") int32 DecoLiveMarchSpawns = -1;
|
||||
|
||||
// FINAL discriminator, paired with DecoLiveMarchSpawns. Counts the applied region's HISM instances
|
||||
// that actually fall inside THIS cell's footprint (not the whole region). On a blank cell where the
|
||||
// live march says N>0:
|
||||
// • DecoInstancesInCell ≈ N → the instances ARE uploaded here but not visible → RENDER drop.
|
||||
// • DecoInstancesInCell ≈ 0 → the march's spawns never reached the HISM → MERGE/apply loss.
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Biome|Deco") int32 DecoInstancesInCell = 0;
|
||||
};
|
||||
|
||||
//=============================================================================
|
||||
// RUNTIME STRUCTS (plain C++ — not USTRUCT; live in the thread-local hot path)
|
||||
//=============================================================================
|
||||
|
||||
/**
|
||||
* One biome flattened from its asset for fast, asset-free evaluation. The biome field
|
||||
* and the per-chunk cache only ever see these PODs — never a UObject — so they stay
|
||||
* cheap to copy and safe to touch from worker threads.
|
||||
*/
|
||||
struct FBiomeResolved
|
||||
{
|
||||
// Index back into the owning strate's Biomes[] array (for param / content lookup).
|
||||
int32 Index = 0;
|
||||
|
||||
// Climate placement box in (relief, moisture) space, both [0,1].
|
||||
float ReliefMin = 0.0f, ReliefMax = 1.0f;
|
||||
float MoistureMin = 0.0f, MoistureMax = 1.0f;
|
||||
|
||||
// Colour used by the 2D preview bake.
|
||||
FColor DebugColor = FColor::White;
|
||||
|
||||
// Palette layer this biome's terrain uses in the master triplanar material (F6).
|
||||
// Baked into the mesh vertex colour so one material can re-skin per biome. 0 = default.
|
||||
int32 MaterialPaletteIndex = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Everything the biome field needs for one strate. Built once by StrateManager
|
||||
* (GetBiomeContextForChunk) from the strate definition. Empty Biomes ⇒ biomes
|
||||
* disabled for this strate (legacy behaviour, bit-identical output).
|
||||
*/
|
||||
struct FBiomeContext
|
||||
{
|
||||
TArray<FBiomeResolved> Biomes;
|
||||
FBiomeMapParams Map;
|
||||
|
||||
bool IsValid() const { return Biomes.Num() > 0; }
|
||||
};
|
||||
|
||||
/**
|
||||
* Result of a single biome query at a world XY: the dominant biome cell, the nearest
|
||||
* neighbour cell, and a blend weight toward that neighbour (0 deep inside a cell,
|
||||
* → 0.5 at the shared border). Indices are positions into FBiomeContext::Biomes.
|
||||
*/
|
||||
struct FBiomeSample
|
||||
{
|
||||
int32 DominantIndex = -1;
|
||||
int32 NeighborIndex = -1;
|
||||
float NeighborWeight = 0.0f;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-chunk biome cache for the density hot path. The EXPENSIVE part of a biome query
|
||||
* (classifying each Voronoi cell by its climate — several noise samples per cell) is
|
||||
* done ONCE here, into a small grid covering the chunk footprint + margin; per voxel
|
||||
* the resolver then only warps + does a cheap 3x3 lookup + blend.
|
||||
*
|
||||
* VALIDITY IS A WORLD-XY BOX (+ ChunkZ + Seed), NOT a chunk key (CODEMAP §8.10). The
|
||||
* grid covers a halo beyond the chunk, so gradient-normal samples and the +X/+Y chunk
|
||||
* boundary corners stay inside the valid box and DO NOT thrash the noise-heavy rebuild.
|
||||
* The box logic is identical in spirit to the SDF cache in GetDensityWithParams.
|
||||
*/
|
||||
struct FChunkBiomeCache
|
||||
{
|
||||
// World-XY box (voxel coords) over which the cached grid answers correctly.
|
||||
float ValidMinX = 1.0f, ValidMaxX = -1.0f; // start invalid (min > max)
|
||||
float ValidMinY = 0.0f, ValidMaxY = 0.0f;
|
||||
int32 ChunkZ = MIN_int32; // which strate slice this was built for
|
||||
int32 Seed = MIN_int32;
|
||||
bool bActive = false; // does this strate have biomes?
|
||||
|
||||
FBiomeContext Ctx; // resolved biomes + map (for blending)
|
||||
|
||||
// Cell grid (row-major, CellsX × CellsY), each entry = biome index into Ctx.Biomes.
|
||||
int32 BaseCellX = 0, BaseCellY = 0, CellsX = 0, CellsY = 0;
|
||||
TArray<int32> CellBiome;
|
||||
|
||||
bool Contains(float X, float Y, int32 InChunkZ, int32 InSeed) const
|
||||
{
|
||||
return InSeed == Seed && InChunkZ == ChunkZ
|
||||
&& X >= ValidMinX && X <= ValidMaxX
|
||||
&& Y >= ValidMinY && Y <= ValidMaxY;
|
||||
}
|
||||
};
|
||||
@@ -36,6 +36,7 @@
|
||||
#include "Containers/Queue.h"
|
||||
#include "VoxelTypes.h"
|
||||
#include "VoxelStrateTypes.h" // FStrateDecoration (resolved per dominant biome)
|
||||
#include "VoxelBiomeTypes.h" // FBiomeContext (per-column biome resolve on the worker)
|
||||
#include <atomic>
|
||||
#include "VoxelContentManager.generated.h"
|
||||
|
||||
@@ -81,6 +82,14 @@ public:
|
||||
* epoch so any in-flight march tasks' results are discarded. */
|
||||
void ClearAll();
|
||||
|
||||
/** DIAGNOSTIC: report the decoration streaming state of the region covering an actor-LOCAL XY, so a
|
||||
* line-trace probe can tell apart a render drop / an empty march / a stuck build / a never-requested
|
||||
* region for a visibly-bare patch. LocalPos is in actor-local cm (the caller undoes the actor xf).
|
||||
* Game-thread only (reads DecoRegions / RegionBuilds). */
|
||||
void QueryDecoDebugAt(const FVector& LocalPos, bool& bApplied, int32& InstanceCount,
|
||||
bool& bBuilding, int32& CellsAccounted, int32& CellsTotal,
|
||||
int32& LiveMarchSpawns, int32& InstancesInCell) const;
|
||||
|
||||
virtual void BeginDestroy() override; // flag shutdown so in-flight march tasks don't touch us
|
||||
|
||||
/** Flag shutdown + block until in-flight march tasks drain. Call from AVoxelWorld::EndPlay BEFORE
|
||||
@@ -135,6 +144,12 @@ private:
|
||||
TArray<FRegionActorSpawn> ActorSpawns;
|
||||
int32 CellsRemaining = 0; // cells still to account for before this region can apply
|
||||
uint32 BuildId = 0; // unique, monotonic — stale in-flight cell results fail to match
|
||||
// Cells already counted toward completion. A cell can spawn TWO worker tasks (a stale cell from a
|
||||
// discarded build re-enters range, gets re-enqueued, and launches again once its first task frees
|
||||
// the InFlightCells slot). Accounting per-cell here (not a blind --CellsRemaining) makes completion
|
||||
// IDEMPOTENT so the second task can't double-decrement and apply the region before every cell has
|
||||
// actually reported — which left a permanent empty chunk until a regen re-marched it.
|
||||
TSet<FIntPoint> AccountedCells;
|
||||
};
|
||||
|
||||
// Constant per-update strate context (a strate is a horizontal slab → same for every cell). Carries
|
||||
@@ -148,21 +163,29 @@ private:
|
||||
float WaterLocalZ = -FLT_MAX; // water surface, actor-local cm (-FLT_MAX = no water)
|
||||
bool bHasWater = false;
|
||||
bool bSurfaceWorld = false; // heightfield archetype → use the GetSurfaceHeightAt oracle
|
||||
|
||||
// Strate biome field (PODs only → worker-safe). Empty ⇒ biomes disabled for this strate. The
|
||||
// worker resolves the dominant biome PER COLUMN (ResolveBiomeSampleAt) so decoration borders
|
||||
// follow the warped-Voronoi field instead of snapping to the chunk-footprint cell grid (§8.5).
|
||||
FBiomeContext BiomeCtx;
|
||||
};
|
||||
|
||||
/** 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. */
|
||||
* Generator (thread-safe). Determinism-critical. Resolves the dominant biome PER COLUMN
|
||||
* (ResolveBiomeSampleAt via Ctx.BiomeCtx) and rolls only the entries that biome owns — EntryBiome[i]
|
||||
* is the context-biome index for Entries[i] (-1 = strate fallback, always matches). */
|
||||
static void BuildCellSpawns(const UVoxelGenerator* Gen, const FTransform& OwnerXf,
|
||||
const FIntPoint& Cell, const FDecoContext& Ctx,
|
||||
const TArray<FStrateDecoration>& Entries, uint32 InSeed,
|
||||
const TArray<FStrateDecoration>& Entries,
|
||||
const TArray<int32>& EntryBiome, uint32 InSeed,
|
||||
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, uint32 BuildId); // decrement region's remaining-cell count
|
||||
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);
|
||||
@@ -221,6 +244,14 @@ private:
|
||||
// copies the PODs into each task).
|
||||
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;
|
||||
|
||||
// Single strate-global ocean plane, repositioned to follow the player (see UpdateWater).
|
||||
UPROPERTY()
|
||||
UStaticMeshComponent* WaterPlane = nullptr;
|
||||
|
||||
@@ -173,6 +173,14 @@ public:
|
||||
*/
|
||||
const UVoxelBiomeDefinition* GetDominantBiomeAt(float WorldX, float WorldY, int32 ChunkZ) const;
|
||||
|
||||
/**
|
||||
* Rich biome probe at a world XY for a strate slice (ChunkZ): dominant + neighbour biome assets,
|
||||
* climate fields, border blend weight, and the dominant biome's EFFECTIVE decoration count. Mirrors
|
||||
* exactly what the decoration scatter resolves per column, so it is a faithful "what's under here?"
|
||||
* diagnostic (DominantDecorationCount == 0 explains an empty biome region). Game-thread, uncached.
|
||||
*/
|
||||
void QueryBiomeAt(float WorldX, float WorldY, int32 ChunkZ, FVoxelBiomeQuery& Out) const;
|
||||
|
||||
/**
|
||||
* Per-vertex material data for the master triplanar palette material (F6). Resolves the
|
||||
* biome field at a world XY/Z and returns the dominant + neighbour MaterialPaletteIndex
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
// VoxelNoise.h
|
||||
// Float, SIMD-batched gradient-noise core (T2.a). Replaces UE's double-precision
|
||||
// FMath::PerlinNoise3D on the density hot path (~6.6 ms/chunk was all noise math).
|
||||
//
|
||||
// WHY THIS EXISTS
|
||||
// FMath::PerlinNoise3D is double-precision with a permutation-table lookup. The
|
||||
// density field calls it >1M times per surface chunk (fBm octaves + domain warps).
|
||||
// This core is:
|
||||
// - FLOAT (no double math),
|
||||
// - table-free hash-gradient Perlin (pure arithmetic → vectorizes cleanly),
|
||||
// - SIMD-BATCHED across fBm octaves: one FractalNoise/Ridged call evaluates up to
|
||||
// 4 octaves' Perlin samples in one 4-wide SSE pass.
|
||||
//
|
||||
// It is a DIFFERENT noise field than FMath's, so worlds re-tune ONCE (accepted).
|
||||
//
|
||||
// DETERMINISM / CACHES
|
||||
// Perlin3D is a pure function of (x,y,z) exactly like the old call — every box-validity
|
||||
// cache (SDF §8.10, biome, surface column) stays valid. No invariant changes.
|
||||
//
|
||||
// SCALAR vs SIMD
|
||||
// Perlin3D (scalar) and Perlin3D_x4 (SSE) use the IDENTICAL formula, op-for-op, so on
|
||||
// x86 (SSE math == scalar-float math, same IEEE rounding) they produce bit-identical
|
||||
// results. The SSE path is therefore a pure speedup with no second re-tune. If it ever
|
||||
// fails to build on a given toolchain, force the scalar fallback with one line:
|
||||
// #define VF_NOISE_USE_SIMD 0 // (before including this header, or here)
|
||||
// The scalar path alone is still a large win over the old double-precision core.
|
||||
//
|
||||
// REQUIRES: SSE4.1 for the SIMD path (UE5 x64 baseline is SSE4.2 → always available).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
|
||||
#if !defined(VF_NOISE_USE_SIMD)
|
||||
#if PLATFORM_ENABLE_VECTORINTRINSICS && PLATFORM_CPU_X86_FAMILY
|
||||
#define VF_NOISE_USE_SIMD 1
|
||||
#else
|
||||
#define VF_NOISE_USE_SIMD 0
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if VF_NOISE_USE_SIMD
|
||||
#include <immintrin.h> // SSE4.1: _mm_floor_ps / _mm_mullo_epi32 / _mm_blendv_ps
|
||||
#endif
|
||||
|
||||
namespace VoxelNoise
|
||||
{
|
||||
namespace Detail
|
||||
{
|
||||
// Table-free integer hash of a lattice corner → gradient selector. Pure mul/xor/shift
|
||||
// so it vectorizes 1:1 (see the SSE Hash lambda below — must stay in lock-step).
|
||||
FORCEINLINE uint32 HashCorner(int32 ix, int32 iy, int32 iz)
|
||||
{
|
||||
uint32 h = (uint32)ix * 0x9E3779B1u
|
||||
^ (uint32)iy * 0x85EBCA77u
|
||||
^ (uint32)iz * 0xC2B2AE3Du;
|
||||
h ^= h >> 15; h *= 0x2C1B3C6Du;
|
||||
h ^= h >> 12; h *= 0x297A2D39u;
|
||||
h ^= h >> 15;
|
||||
return h;
|
||||
}
|
||||
|
||||
// Quintic fade 6t^5-15t^4+10t^3, factored as t^3 * (t*(6t-15)+10) so the SSE twin
|
||||
// can mirror the exact grouping.
|
||||
FORCEINLINE float Fade(float t)
|
||||
{
|
||||
const float inner = t * (t * 6.0f - 15.0f) + 10.0f;
|
||||
const float t3 = t * t * t;
|
||||
return t3 * inner;
|
||||
}
|
||||
|
||||
// Ken Perlin's 12-gradient dot (hash&15 picks the gradient). Branchy form here;
|
||||
// the SSE twin reproduces it with selects.
|
||||
FORCEINLINE float GradDot(uint32 hash, float x, float y, float z)
|
||||
{
|
||||
const uint32 h = hash & 15u;
|
||||
const float u = (h & 8u) == 0u ? x : y;
|
||||
float v;
|
||||
if ((h & 12u) == 0u) v = y; // h < 4
|
||||
else if ((h & 13u) == 12u) v = x; // h == 12 or 14
|
||||
else v = z;
|
||||
const float ru = (h & 1u) == 0u ? u : -u;
|
||||
const float rv = (h & 2u) == 0u ? v : -v;
|
||||
return ru + rv;
|
||||
}
|
||||
|
||||
FORCEINLINE float Lerp(float a, float b, float t) { return a + t * (b - a); }
|
||||
}
|
||||
|
||||
// Single 3D Perlin sample, ~[-1,1] (typically [-0.7,0.7], same character as the old core
|
||||
// so VOXEL_NOISE_SCALE still applies). Used for all single-sample domain warps.
|
||||
FORCEINLINE float Perlin3D(float x, float y, float z)
|
||||
{
|
||||
using namespace Detail;
|
||||
const float xf = FMath::FloorToFloat(x);
|
||||
const float yf = FMath::FloorToFloat(y);
|
||||
const float zf = FMath::FloorToFloat(z);
|
||||
const int32 X = (int32)xf, Y = (int32)yf, Z = (int32)zf;
|
||||
const float fx = x - xf, fy = y - yf, fz = z - zf;
|
||||
const float su = Fade(fx), sv = Fade(fy), sw = Fade(fz);
|
||||
|
||||
const uint32 h000 = HashCorner(X, Y, Z );
|
||||
const uint32 h100 = HashCorner(X+1, Y, Z );
|
||||
const uint32 h010 = HashCorner(X, Y+1, Z );
|
||||
const uint32 h110 = HashCorner(X+1, Y+1, Z );
|
||||
const uint32 h001 = HashCorner(X, Y, Z+1);
|
||||
const uint32 h101 = HashCorner(X+1, Y, Z+1);
|
||||
const uint32 h011 = HashCorner(X, Y+1, Z+1);
|
||||
const uint32 h111 = HashCorner(X+1, Y+1, Z+1);
|
||||
|
||||
const float fx1 = fx - 1.0f, fy1 = fy - 1.0f, fz1 = fz - 1.0f;
|
||||
const float n000 = GradDot(h000, fx, fy, fz );
|
||||
const float n100 = GradDot(h100, fx1, fy, fz );
|
||||
const float n010 = GradDot(h010, fx, fy1, fz );
|
||||
const float n110 = GradDot(h110, fx1, fy1, fz );
|
||||
const float n001 = GradDot(h001, fx, fy, fz1);
|
||||
const float n101 = GradDot(h101, fx1, fy, fz1);
|
||||
const float n011 = GradDot(h011, fx, fy1, fz1);
|
||||
const float n111 = GradDot(h111, fx1, fy1, fz1);
|
||||
|
||||
const float x00 = Lerp(n000, n100, su);
|
||||
const float x10 = Lerp(n010, n110, su);
|
||||
const float x01 = Lerp(n001, n101, su);
|
||||
const float x11 = Lerp(n011, n111, su);
|
||||
const float y0 = Lerp(x00, x10, sv);
|
||||
const float y1 = Lerp(x01, x11, sv);
|
||||
return Lerp(y0, y1, sw);
|
||||
}
|
||||
|
||||
FORCEINLINE float Perlin3D(const FVector& P)
|
||||
{
|
||||
return Perlin3D((float)P.X, (float)P.Y, (float)P.Z);
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// 4-WIDE BATCH — the SIMD multiplier. Computes 4 independent Perlin samples.
|
||||
// Inputs are 4-element arrays; unused lanes must be zero-filled by the caller
|
||||
// (FBM/Ridged below do). Results written to Out[0..3].
|
||||
//=============================================================================
|
||||
#if VF_NOISE_USE_SIMD
|
||||
FORCEINLINE void Perlin3D_x4(const float* Xs, const float* Ys, const float* Zs, float* Out)
|
||||
{
|
||||
const __m128 x = _mm_loadu_ps(Xs);
|
||||
const __m128 y = _mm_loadu_ps(Ys);
|
||||
const __m128 z = _mm_loadu_ps(Zs);
|
||||
|
||||
const __m128 xf = _mm_floor_ps(x);
|
||||
const __m128 yf = _mm_floor_ps(y);
|
||||
const __m128 zf = _mm_floor_ps(z);
|
||||
|
||||
const __m128i X = _mm_cvttps_epi32(xf);
|
||||
const __m128i Y = _mm_cvttps_epi32(yf);
|
||||
const __m128i Z = _mm_cvttps_epi32(zf);
|
||||
|
||||
const __m128 fx = _mm_sub_ps(x, xf);
|
||||
const __m128 fy = _mm_sub_ps(y, yf);
|
||||
const __m128 fz = _mm_sub_ps(z, zf);
|
||||
|
||||
const __m128 c6 = _mm_set1_ps(6.0f);
|
||||
const __m128 c15 = _mm_set1_ps(15.0f);
|
||||
const __m128 c10 = _mm_set1_ps(10.0f);
|
||||
auto Fade = [&](const __m128 t) -> __m128
|
||||
{
|
||||
const __m128 inner = _mm_add_ps(_mm_mul_ps(t, _mm_sub_ps(_mm_mul_ps(t, c6), c15)), c10);
|
||||
const __m128 t3 = _mm_mul_ps(_mm_mul_ps(t, t), t);
|
||||
return _mm_mul_ps(t3, inner);
|
||||
};
|
||||
const __m128 su = Fade(fx);
|
||||
const __m128 sv = Fade(fy);
|
||||
const __m128 sw = Fade(fz);
|
||||
|
||||
const __m128i one = _mm_set1_epi32(1);
|
||||
const __m128i X1 = _mm_add_epi32(X, one);
|
||||
const __m128i Y1 = _mm_add_epi32(Y, one);
|
||||
const __m128i Z1 = _mm_add_epi32(Z, one);
|
||||
|
||||
const __m128i k1 = _mm_set1_epi32((int32)0x9E3779B1u);
|
||||
const __m128i k2 = _mm_set1_epi32((int32)0x85EBCA77u);
|
||||
const __m128i k3 = _mm_set1_epi32((int32)0xC2B2AE3Du);
|
||||
const __m128i m1 = _mm_set1_epi32((int32)0x2C1B3C6Du);
|
||||
const __m128i m2 = _mm_set1_epi32((int32)0x297A2D39u);
|
||||
auto Hash = [&](const __m128i ix, const __m128i iy, const __m128i iz) -> __m128i
|
||||
{
|
||||
__m128i h = _mm_xor_si128(_mm_xor_si128(_mm_mullo_epi32(ix, k1),
|
||||
_mm_mullo_epi32(iy, k2)),
|
||||
_mm_mullo_epi32(iz, k3));
|
||||
h = _mm_xor_si128(h, _mm_srli_epi32(h, 15)); h = _mm_mullo_epi32(h, m1);
|
||||
h = _mm_xor_si128(h, _mm_srli_epi32(h, 12)); h = _mm_mullo_epi32(h, m2);
|
||||
h = _mm_xor_si128(h, _mm_srli_epi32(h, 15));
|
||||
return h;
|
||||
};
|
||||
|
||||
const __m128 fx1 = _mm_sub_ps(fx, _mm_set1_ps(1.0f));
|
||||
const __m128 fy1 = _mm_sub_ps(fy, _mm_set1_ps(1.0f));
|
||||
const __m128 fz1 = _mm_sub_ps(fz, _mm_set1_ps(1.0f));
|
||||
|
||||
const __m128i i8 = _mm_set1_epi32(8);
|
||||
const __m128i i12 = _mm_set1_epi32(12);
|
||||
const __m128i i13 = _mm_set1_epi32(13);
|
||||
const __m128i i15 = _mm_set1_epi32(15);
|
||||
const __m128i i1 = _mm_set1_epi32(1);
|
||||
const __m128i i2 = _mm_set1_epi32(2);
|
||||
const __m128i izero = _mm_setzero_si128();
|
||||
const __m128 sgn = _mm_set1_ps(-0.0f);
|
||||
auto Grad = [&](const __m128i hash, const __m128 gx, const __m128 gy, const __m128 gz) -> __m128
|
||||
{
|
||||
const __m128i h = _mm_and_si128(hash, i15);
|
||||
// u = (h&8)==0 ? gx : gy
|
||||
const __m128 mU = _mm_castsi128_ps(_mm_cmpeq_epi32(_mm_and_si128(h, i8), izero));
|
||||
const __m128 u = _mm_blendv_ps(gy, gx, mU);
|
||||
// v = (h&12)==0 ? gy : ((h&13)==12 ? gx : gz)
|
||||
const __m128 mLt4 = _mm_castsi128_ps(_mm_cmpeq_epi32(_mm_and_si128(h, i12), izero));
|
||||
const __m128 m1214 = _mm_castsi128_ps(_mm_cmpeq_epi32(_mm_and_si128(h, i13), i12));
|
||||
const __m128 vTmp = _mm_blendv_ps(gz, gx, m1214);
|
||||
const __m128 v = _mm_blendv_ps(vTmp, gy, mLt4);
|
||||
// signs: (h&1)? -u:u + (h&2)? -v:v
|
||||
const __m128 mNegU = _mm_castsi128_ps(_mm_cmpeq_epi32(_mm_and_si128(h, i1), i1));
|
||||
const __m128 mNegV = _mm_castsi128_ps(_mm_cmpeq_epi32(_mm_and_si128(h, i2), i2));
|
||||
const __m128 ru = _mm_blendv_ps(u, _mm_xor_ps(u, sgn), mNegU);
|
||||
const __m128 rv = _mm_blendv_ps(v, _mm_xor_ps(v, sgn), mNegV);
|
||||
return _mm_add_ps(ru, rv);
|
||||
};
|
||||
|
||||
const __m128 n000 = Grad(Hash(X, Y, Z ), fx, fy, fz );
|
||||
const __m128 n100 = Grad(Hash(X1, Y, Z ), fx1, fy, fz );
|
||||
const __m128 n010 = Grad(Hash(X, Y1, Z ), fx, fy1, fz );
|
||||
const __m128 n110 = Grad(Hash(X1, Y1, Z ), fx1, fy1, fz );
|
||||
const __m128 n001 = Grad(Hash(X, Y, Z1), fx, fy, fz1);
|
||||
const __m128 n101 = Grad(Hash(X1, Y, Z1), fx1, fy, fz1);
|
||||
const __m128 n011 = Grad(Hash(X, Y1, Z1), fx, fy1, fz1);
|
||||
const __m128 n111 = Grad(Hash(X1, Y1, Z1), fx1, fy1, fz1);
|
||||
|
||||
auto Lerp = [&](const __m128 a, const __m128 b, const __m128 t) -> __m128
|
||||
{
|
||||
return _mm_add_ps(a, _mm_mul_ps(t, _mm_sub_ps(b, a)));
|
||||
};
|
||||
const __m128 x00 = Lerp(n000, n100, su);
|
||||
const __m128 x10 = Lerp(n010, n110, su);
|
||||
const __m128 x01 = Lerp(n001, n101, su);
|
||||
const __m128 x11 = Lerp(n011, n111, su);
|
||||
const __m128 y0 = Lerp(x00, x10, sv);
|
||||
const __m128 y1 = Lerp(x01, x11, sv);
|
||||
_mm_storeu_ps(Out, Lerp(y0, y1, sw));
|
||||
}
|
||||
#else
|
||||
FORCEINLINE void Perlin3D_x4(const float* Xs, const float* Ys, const float* Zs, float* Out)
|
||||
{
|
||||
for (int32 i = 0; i < 4; ++i) Out[i] = Perlin3D(Xs[i], Ys[i], Zs[i]);
|
||||
}
|
||||
#endif
|
||||
|
||||
//=============================================================================
|
||||
// fBm / Ridged — octaves evaluated 4 at a time through Perlin3D_x4.
|
||||
// Accumulation stays scalar in octave order, so the result is independent of
|
||||
// whether the SIMD or scalar Perlin3D_x4 is used (bit-identical either way).
|
||||
//=============================================================================
|
||||
FORCEINLINE float FBM(float x, float y, float z,
|
||||
int32 Octaves = 4, float Lacunarity = 2.0f, float Persistence = 0.5f)
|
||||
{
|
||||
float Total = 0.0f, MaxValue = 0.0f, Freq = 1.0f, Amp = 1.0f;
|
||||
for (int32 o = 0; o < Octaves; )
|
||||
{
|
||||
const int32 N = FMath::Min(4, Octaves - o);
|
||||
float Xs[4] = {}, Ys[4] = {}, Zs[4] = {}, Out[4];
|
||||
float f = Freq;
|
||||
for (int32 i = 0; i < N; ++i) { Xs[i] = x * f; Ys[i] = y * f; Zs[i] = z * f; f *= Lacunarity; }
|
||||
Perlin3D_x4(Xs, Ys, Zs, Out);
|
||||
for (int32 i = 0; i < N; ++i) { Total += Out[i] * Amp; MaxValue += Amp; Amp *= Persistence; }
|
||||
Freq = f;
|
||||
o += N;
|
||||
}
|
||||
return Total / MaxValue;
|
||||
}
|
||||
|
||||
FORCEINLINE float Ridged(float x, float y, float z,
|
||||
int32 Octaves = 4, float Lacunarity = 2.0f, float Persistence = 0.5f)
|
||||
{
|
||||
// Matches the original RidgedNoise3D fold exactly (NS scale, square, weight feedback).
|
||||
static constexpr float NS = 1.25f;
|
||||
float Total = 0.0f, MaxValue = 0.0f, Freq = 1.0f, Amp = 1.0f, Weight = 1.0f;
|
||||
for (int32 o = 0; o < Octaves; )
|
||||
{
|
||||
const int32 N = FMath::Min(4, Octaves - o);
|
||||
float Xs[4] = {}, Ys[4] = {}, Zs[4] = {}, Out[4];
|
||||
float f = Freq;
|
||||
for (int32 i = 0; i < N; ++i) { Xs[i] = x * f; Ys[i] = y * f; Zs[i] = z * f; f *= Lacunarity; }
|
||||
Perlin3D_x4(Xs, Ys, Zs, Out);
|
||||
for (int32 i = 0; i < N; ++i)
|
||||
{
|
||||
float Nn = Out[i] * NS;
|
||||
Nn = 1.0f - FMath::Abs(Nn); // fold → ridge at zero-crossings
|
||||
Nn = Nn * Nn; // sharpen
|
||||
Nn *= Weight; // detail follows previous ridge
|
||||
Weight = FMath::Clamp(Nn * 2.0f, 0.0f, 1.0f);
|
||||
Total += Nn * Amp; MaxValue += Amp; Amp *= Persistence;
|
||||
}
|
||||
Freq = f;
|
||||
o += N;
|
||||
}
|
||||
return (Total / MaxValue) * 2.0f - 1.0f;
|
||||
}
|
||||
|
||||
} // namespace VoxelNoise
|
||||
@@ -1730,6 +1730,14 @@ struct VOXELFORGE_API FStrateDecoration
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0.0", ClampMax = "90.0"))
|
||||
float MaxSlopeAngle = 90.0f;
|
||||
|
||||
// Minimum surface tilt (degrees from flat) — the LOWER companion to MaxSlopeAngle. Rejects surfaces
|
||||
// FLATTER than this, so a prop can be kept OFF flat ground and restricted to slopes / walls. Same
|
||||
// metric as MaxSlopeAngle: acos(|normal.Z|), 0 = flat, 90 = vertical. Pair the two to band a prop
|
||||
// onto a tilt range (e.g. 30..70 = slopes only, never flats or sheer walls).
|
||||
// 0 → no filter (default) · 45 → slopes & walls only · 70 → near-vertical only
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0.0", ClampMax = "90.0"))
|
||||
float MinSlopeAngle = 0.0f;
|
||||
|
||||
// Chance per valid surface point to spawn this decoration (0-1)
|
||||
// 0.01 = rare, 0.1 = common, 0.5 = very dense
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0.0", ClampMax = "1.0"))
|
||||
@@ -1753,6 +1761,25 @@ struct VOXELFORGE_API FStrateDecoration
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement")
|
||||
bool bRandomYaw = true;
|
||||
|
||||
// When bRandomYaw is set, constrain the random yaw to [MinYaw, MaxYaw] degrees instead of a full
|
||||
// turn. Lets a prop face roughly one way with a little variation (wind-bent grass: 80..100). The
|
||||
// default 0..360 is a full unrestricted turn — byte-identical to the legacy behaviour. Ignored when
|
||||
// bRandomYaw is false.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement",
|
||||
meta = (EditCondition = "bRandomYaw", ClampMin = "0.0", ClampMax = "360.0"))
|
||||
float MinYaw = 0.0f;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement",
|
||||
meta = (EditCondition = "bRandomYaw", ClampMin = "0.0", ClampMax = "360.0"))
|
||||
float MaxYaw = 360.0f;
|
||||
|
||||
// Wall props only: exclude downward-facing OVERHANGS. A "wall" is any surface between floor and
|
||||
// ceiling (|normal.Z| <= 0.5), which still includes surfaces that lean slightly DOWNWARD (overhang
|
||||
// ceilings). For props that must sit on upright walls (vines, wall torches) set this so only normals
|
||||
// with Z >= 0 (vertical or up-leaning) qualify. Ignored unless the point resolves as a wall.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement")
|
||||
bool bWallExcludeOverhangs = false;
|
||||
|
||||
// Offset along the surface normal (world units). Positive = lift off the surface,
|
||||
// negative = sink into it. Useful to embed roots or float crystals slightly.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement")
|
||||
|
||||
@@ -22,6 +22,7 @@ class UVoxelDiffLayer;
|
||||
class UVoxelContentManager;
|
||||
class UVoxelAtmosphereManager;
|
||||
class UMaterialInterface;
|
||||
namespace RealtimeMesh { struct FRealtimeMeshStreamSet; } // T1.f — worker-built geometry buffers
|
||||
|
||||
/**
|
||||
* AVoxelWorld - The main voxel terrain actor
|
||||
@@ -39,8 +40,19 @@ class UMaterialInterface;
|
||||
struct FChunkResult
|
||||
{
|
||||
FVoxelTileKey Tile; // which clipmap tile this mesh is for (carries coord + level)
|
||||
FVoxelMeshData MeshData;
|
||||
// T1.f: the RMC geometry buffers are BUILT ON THE WORKER (BuildTileStreamSet in the gen task)
|
||||
// so the game thread only uploads them — the per-vertex builder loop was the dominant
|
||||
// game-thread cost while moving (the apply drain). TSharedPtr (not a by-value StreamSet) so
|
||||
// FChunkResult stays movable through the MPSC queue with the type only FORWARD-DECLARED here.
|
||||
// Null ⇒ empty/all-air tile (no component).
|
||||
TSharedPtr<RealtimeMesh::FRealtimeMeshStreamSet> Streams;
|
||||
uint32 Epoch = 0; // Generation epoch — discard if stale
|
||||
bool bEmpty = true; // true ⇒ all-air tile (Streams null); still marked loaded so we don't re-submit
|
||||
// Ceiling classification from the ACTUAL mesh normals (down-facing geometry = sky-cap ceiling),
|
||||
// computed on the worker where Normals are free. Authoritative — can't disagree with the rendered
|
||||
// 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;
|
||||
};
|
||||
|
||||
UCLASS()
|
||||
@@ -227,6 +239,16 @@ public:
|
||||
UFUNCTION(BlueprintPure, Category = "Voxel World|Strate")
|
||||
int32 GetStrateAtPosition(FVector WorldPosition) const;
|
||||
|
||||
/**
|
||||
* Probe the biome field at a world location (e.g. a mouse line-trace hit). Returns the dominant +
|
||||
* neighbour biome, the climate fields, the border blend weight, and the dominant biome's decoration
|
||||
* count — the SAME resolution the decoration scatter uses per column. Use it to debug placement:
|
||||
* a returned DominantDecorationCount of 0 means that biome has no decorations (an empty region),
|
||||
* NOT a bug. WorldLocation is full world space (the actor transform is undone internally).
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Voxel World|Biome")
|
||||
FVoxelBiomeQuery GetBiomeAtWorldLocation(FVector WorldLocation) const;
|
||||
|
||||
//=========================================================================
|
||||
// LIVE EDIT (debug tuning in PIE)
|
||||
//=========================================================================
|
||||
@@ -374,16 +396,18 @@ public:
|
||||
void UnloadTile(const FVoxelTileKey& Tile);
|
||||
|
||||
/**
|
||||
* Apply mesh data to a RealtimeMesh component.
|
||||
* Upload a tile's geometry to its RealtimeMesh component (game thread).
|
||||
*
|
||||
* CONCEPT:
|
||||
* - Get or create the mesh component for this chunk
|
||||
* - Set the mesh data (vertices, triangles, etc.)
|
||||
* The vertex/index buffers (Streams) are already BUILT on the worker (T1.f — see
|
||||
* BuildTileStreamSet / FChunkResult), so this only does the game-thread-only work:
|
||||
* ceiling/material resolution, get-or-create the component, CreateSectionGroup(MoveTemp),
|
||||
* and section config (collision/shadow). Never called for empty tiles.
|
||||
*
|
||||
* @param ChunkCoord - Which chunk this mesh belongs to
|
||||
* @param MeshData - The generated mesh data
|
||||
* @param Tile - Which clipmap tile this mesh belongs to
|
||||
* @param Streams - Pre-built RMC geometry buffers (consumed/moved)
|
||||
* @param bGeomCeiling - Worker's geometry-normal ceiling vote (gated to SurfaceWorld here)
|
||||
*/
|
||||
void ApplyMeshToTile(const FVoxelTileKey& Tile, const FVoxelMeshData& MeshData);
|
||||
void ApplyMeshToTile(const FVoxelTileKey& Tile, RealtimeMesh::FRealtimeMeshStreamSet&& Streams, bool bGeomCeiling);
|
||||
|
||||
/** Build the clipmap desired-tile set (concentric shells) around the player tile. */
|
||||
void BuildDesiredTiles(const FIntVector& CenterChunkCoord);
|
||||
|
||||
Reference in New Issue
Block a user