Fix Decoration Placement

This commit is contained in:
2026-06-26 19:15:04 +02:00
parent e6cd852129
commit 6875614002
12 changed files with 1204 additions and 527 deletions
@@ -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"
+214 -31
View File
@@ -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
{
+120 -64
View File
@@ -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;
};
+246
View File
@@ -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;
}
};
+34 -3
View File
@@ -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
+303
View File
@@ -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")
+32 -8
View File
@@ -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);