Another pass
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -95,21 +95,28 @@ TArray<FIntVector> UVoxelDiffLayer::ApplyModification(const FVoxelModification&
|
||||
const int32 MinCZ = FMath::FloorToInt(BoundsMin.Z / CHUNK_SIZE);
|
||||
const int32 MaxCZ = FMath::FloorToInt(BoundsMax.Z / CHUNK_SIZE);
|
||||
|
||||
for (int32 CZ = MinCZ; CZ <= MaxCZ; CZ++)
|
||||
{
|
||||
for (int32 CY = MinCY; CY <= MaxCY; CY++)
|
||||
// Write lock: blocks worker-thread readers (HasModifications / GetDensityOffset) while the map
|
||||
// is mutated/rehashed. AffectedChunks is local; populate it in the same pass.
|
||||
FWriteScopeLock Lock(ModsLock);
|
||||
for (int32 CZ = MinCZ; CZ <= MaxCZ; CZ++)
|
||||
{
|
||||
for (int32 CX = MinCX; CX <= MaxCX; CX++)
|
||||
for (int32 CY = MinCY; CY <= MaxCY; CY++)
|
||||
{
|
||||
FIntVector ChunkCoord(CX, CY, CZ);
|
||||
for (int32 CX = MinCX; CX <= MaxCX; CX++)
|
||||
{
|
||||
FIntVector ChunkCoord(CX, CY, CZ);
|
||||
|
||||
// Store the modification in this chunk's list
|
||||
ChunkMods.FindOrAdd(ChunkCoord).Add(ClampedMod);
|
||||
// Store the modification in this chunk's list
|
||||
ChunkMods.FindOrAdd(ChunkCoord).Add(ClampedMod);
|
||||
|
||||
// Track which chunks need re-meshing
|
||||
AffectedChunks.Add(ChunkCoord);
|
||||
// Track which chunks need re-meshing
|
||||
AffectedChunks.Add(ChunkCoord);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Publish: subsequent readers must now take the lock instead of fast-rejecting.
|
||||
bHasAnyMods.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
UE_LOG(LogTemp, Log,
|
||||
@@ -131,6 +138,13 @@ TArray<FIntVector> UVoxelDiffLayer::ApplyModification(const FVoxelModification&
|
||||
float UVoxelDiffLayer::GetDensityOffset(const FIntVector& ChunkCoord,
|
||||
float WorldX, float WorldY, float WorldZ) const
|
||||
{
|
||||
// Lock-free fast reject: no carves anywhere -> nothing to offset.
|
||||
if (!bHasAnyMods.load(std::memory_order_acquire)) return 0.0f;
|
||||
|
||||
// Hold the read lock for the whole body: Mods points INTO the map and is dereferenced through the
|
||||
// falloff loop below, so the map must not be rehashed by a concurrent writer meanwhile.
|
||||
FReadScopeLock Lock(ModsLock);
|
||||
|
||||
// Fast path: if this chunk has no modifications, return 0
|
||||
const TArray<FVoxelModification>* Mods = ChunkMods.Find(ChunkCoord);
|
||||
if (!Mods || Mods->Num() == 0) return 0.0f;
|
||||
@@ -194,6 +208,10 @@ float UVoxelDiffLayer::GetDensityOffset(const FIntVector& ChunkCoord,
|
||||
|
||||
bool UVoxelDiffLayer::HasModifications(const FIntVector& ChunkCoord) const
|
||||
{
|
||||
// Lock-free fast reject: no carves anywhere -> the map is empty, skip lock + lookup entirely.
|
||||
if (!bHasAnyMods.load(std::memory_order_acquire)) return false;
|
||||
|
||||
FReadScopeLock Lock(ModsLock); // worker threads read concurrently; serialised vs. writers
|
||||
const TArray<FVoxelModification>* Mods = ChunkMods.Find(ChunkCoord);
|
||||
return Mods && Mods->Num() > 0;
|
||||
}
|
||||
@@ -205,7 +223,13 @@ bool UVoxelDiffLayer::HasModifications(const FIntVector& ChunkCoord) const
|
||||
void UVoxelDiffLayer::Clear()
|
||||
{
|
||||
int32 Count = GetTotalModificationCount();
|
||||
ChunkMods.Empty();
|
||||
{
|
||||
// Write lock: workers may be mid-read. Flip the fast-path flag false BEFORE emptying so any
|
||||
// reader that loads it afterwards skips the map without locking.
|
||||
FWriteScopeLock Lock(ModsLock);
|
||||
bHasAnyMods.store(false, std::memory_order_release);
|
||||
ChunkMods.Empty();
|
||||
}
|
||||
|
||||
// Reset budget counters — player gets a fresh budget after clear/season reset
|
||||
ModificationCount = 0;
|
||||
@@ -216,6 +240,7 @@ void UVoxelDiffLayer::Clear()
|
||||
|
||||
int32 UVoxelDiffLayer::GetTotalModificationCount() const
|
||||
{
|
||||
FReadScopeLock Lock(ModsLock);
|
||||
int32 Total = 0;
|
||||
for (const auto& Pair : ChunkMods)
|
||||
{
|
||||
@@ -226,5 +251,6 @@ int32 UVoxelDiffLayer::GetTotalModificationCount() const
|
||||
|
||||
int32 UVoxelDiffLayer::GetModifiedChunkCount() const
|
||||
{
|
||||
FReadScopeLock Lock(ModsLock);
|
||||
return ChunkMods.Num();
|
||||
}
|
||||
|
||||
@@ -22,24 +22,68 @@
|
||||
// the column (terrain Z + ceiling Z) once per integer XY and reuse it down the column.
|
||||
// Used ONLY for integer-XY queries (the density grid); fractional queries (gradient
|
||||
// normals at interpolated vertices) fall through and compute directly → bit-identical.
|
||||
// Validity is a world-XY BOX (chunk footprint) + ChunkZ + Seed, same discipline as the
|
||||
// SDF/biome caches (§8.10) so the +X/+Y boundary plane doesn't thrash it.
|
||||
// The surface heightfield (TerrainZ + sky-cap CeilSurf) is a PURE function of (worldX, worldY,
|
||||
// seed, strate) — ZERO Z dependence: the climate/Voronoi fields are pure-XY (see ResolveBiomeSampleAt:
|
||||
// "result is the pure function of XY either way") and surface params are constant within a strate (Hard
|
||||
// transitions). So a column computed for one chunk is valid for EVERY chunk stacked above/below it in
|
||||
// the same strate. This cache is therefore keyed by (XY box, StrateKey, Seed) — NOT ChunkZ — and held as
|
||||
// a small LRU of boxes so the whole vertical view-distance stack (and XY neighbours interleaved by the
|
||||
// task scheduler) reuse one another's heavy column noise instead of each recomputing it ~once per
|
||||
// vertical chunk in view. Validity stays a world-XY BOX (chunk footprint + margin) so the ±1 gradient /
|
||||
// +X/+Y boundary samples don't thrash it (same discipline as the SDF/biome caches, §8.10).
|
||||
struct FSurfaceColumn { float TerrainZ = 0.0f; float CeilSurf = 0.0f; };
|
||||
|
||||
struct FSurfaceColumnCache
|
||||
struct FSurfaceColumnBox
|
||||
{
|
||||
// Box covers a chunk footprint + a margin on every side (≥ the LOD margin ring the
|
||||
// mesher samples for grid-based normals, ±Step ≤ 4, plus slack). Symmetric around the
|
||||
// first (rebuild-triggering) sample so the whole chunk's column queries stay in one box.
|
||||
// Covers a chunk footprint + a margin on every side (≥ the LOD margin ring the mesher samples for
|
||||
// grid-based normals). Symmetric around the first (allocating) sample so a whole chunk's column
|
||||
// queries — including the ±Step margin ring — stay inside one box.
|
||||
static constexpr int32 Halo = CHUNK_SIZE + 8;
|
||||
static constexpr int32 Dim = 2 * Halo + 1;
|
||||
int32 BaseX = 0, BaseY = 0; // box origin (voxel coords)
|
||||
int32 ChunkZ = MIN_int32, Seed = MIN_int32;
|
||||
int32 BaseX = 0, BaseY = 0; // box origin (voxel coords)
|
||||
int32 StrateKey = MIN_int32, Seed = MIN_int32; // key: same strate ⇒ identical heightfield params
|
||||
uint32 LastUse = 0; // LRU stamp
|
||||
bool bValid = false;
|
||||
FSurfaceColumn Cols[Dim * Dim];
|
||||
bool Computed[Dim * Dim];
|
||||
};
|
||||
|
||||
struct FSurfaceColumnCache
|
||||
{
|
||||
// A handful of boxes keeps the player's column + a few XY neighbours warm across interleaved tasks,
|
||||
// so vertical reuse survives whatever order the worker pulls chunks in. ~59 KB/box.
|
||||
static constexpr int32 NumBoxes = 6;
|
||||
FSurfaceColumnBox Boxes[NumBoxes];
|
||||
uint32 Clock = 0;
|
||||
|
||||
// Return the box covering (IX,IY) for this (StrateKey,Seed); allocate by evicting the LRU box on miss.
|
||||
FSurfaceColumnBox& Acquire(int32 IX, int32 IY, int32 InStrateKey, int32 InSeed)
|
||||
{
|
||||
++Clock;
|
||||
for (FSurfaceColumnBox& B : Boxes)
|
||||
{
|
||||
if (B.bValid && B.StrateKey == InStrateKey && B.Seed == InSeed
|
||||
&& IX >= B.BaseX && IX < B.BaseX + FSurfaceColumnBox::Dim
|
||||
&& IY >= B.BaseY && IY < B.BaseY + FSurfaceColumnBox::Dim)
|
||||
{
|
||||
B.LastUse = Clock;
|
||||
return B;
|
||||
}
|
||||
}
|
||||
// Miss → reuse the least-recently-used box, recentred on this sample.
|
||||
FSurfaceColumnBox* Victim = &Boxes[0];
|
||||
for (FSurfaceColumnBox& B : Boxes) { if (B.LastUse < Victim->LastUse) Victim = &B; }
|
||||
Victim->BaseX = IX - FSurfaceColumnBox::Halo;
|
||||
Victim->BaseY = IY - FSurfaceColumnBox::Halo;
|
||||
Victim->StrateKey = InStrateKey;
|
||||
Victim->Seed = InSeed;
|
||||
Victim->bValid = true;
|
||||
Victim->LastUse = Clock;
|
||||
FMemory::Memzero(Victim->Computed, sizeof(Victim->Computed));
|
||||
return *Victim;
|
||||
}
|
||||
};
|
||||
|
||||
//=============================================================================
|
||||
// FRACTAL NOISE (fBm — fractional Brownian motion)
|
||||
//=============================================================================
|
||||
@@ -403,7 +447,11 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
||||
thread_local FBiomeContext CP_BiomeCtx;
|
||||
thread_local FChunkBiomeCache CP_BiomeCache;
|
||||
thread_local TArray<FSurfaceGenerationParams> CP_SurfaceBiomeParams;
|
||||
thread_local FSurfaceColumnCache CP_SurfCol; // T1.a per-column surface cache
|
||||
thread_local FSurfaceColumnCache CP_SurfCol; // T1.a per-column surface cache (XY-keyed LRU)
|
||||
// Discriminates the surface cache by strate: same strate ⇒ identical heightfield params ⇒ columns
|
||||
// are shareable across the whole vertical chunk stack. Taken from the params themselves
|
||||
// (StrateBottomWorldZ is unique per stacked strate) so the key can never disagree with CP_Surface.
|
||||
thread_local int32 CP_StrateKey = MIN_int32;
|
||||
|
||||
if (ChunkCoord != CP_Chunk)
|
||||
{
|
||||
@@ -417,37 +465,9 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
||||
case ECaveGeneratorType::Maze:
|
||||
CP_Maze = StrateManager->GetMazeParamsForChunk(ChunkCoord); break;
|
||||
case ECaveGeneratorType::SurfaceWorld:
|
||||
{
|
||||
CP_Surface = StrateManager->GetSurfaceParamsForChunk(ChunkCoord);
|
||||
CP_BiomeCtx = StrateManager->GetBiomeContextForChunk(ChunkCoord);
|
||||
|
||||
// Per-biome surface params: each biome's override (when it overrides
|
||||
// SurfaceWorld) else the strate's, with STRUCTURAL fields forced from the
|
||||
// strate (Z bounds, seal, base, water level) so seals/spine/water stay intact.
|
||||
CP_SurfaceBiomeParams.Reset();
|
||||
if (CP_BiomeCtx.IsValid())
|
||||
{
|
||||
const UVoxelStrateDefinition* Def = StrateManager->GetStrateForChunk(ChunkCoord);
|
||||
CP_SurfaceBiomeParams.Reserve(CP_BiomeCtx.Biomes.Num());
|
||||
for (const FBiomeResolved& BR : CP_BiomeCtx.Biomes)
|
||||
{
|
||||
FSurfaceGenerationParams P = CP_Surface; // strate base (+ structural)
|
||||
const UVoxelBiomeDefinition* B =
|
||||
(Def && Def->Biomes.IsValidIndex(BR.Index)) ? Def->Biomes[BR.Index] : nullptr;
|
||||
if (B && B->bOverrideTerrain && B->GeneratorType == ECaveGeneratorType::SurfaceWorld)
|
||||
{
|
||||
P = B->SurfaceParams; // biome shape
|
||||
P.StrateTopWorldZ = CP_Surface.StrateTopWorldZ;
|
||||
P.StrateBottomWorldZ = CP_Surface.StrateBottomWorldZ;
|
||||
P.BoundarySealThickness = CP_Surface.BoundarySealThickness;
|
||||
P.BaseDensity = CP_Surface.BaseDensity;
|
||||
P.WaterLevelRelative = CP_Surface.WaterLevelRelative; // shared water plane
|
||||
}
|
||||
CP_SurfaceBiomeParams.Add(P);
|
||||
}
|
||||
}
|
||||
ResolveSurfaceChunkParams(ChunkCoord, CP_Surface, CP_BiomeCtx, CP_SurfaceBiomeParams);
|
||||
CP_StrateKey = FMath::RoundToInt(CP_Surface.StrateBottomWorldZ);
|
||||
break;
|
||||
}
|
||||
case ECaveGeneratorType::VerticalShafts:
|
||||
CP_Vert = StrateManager->GetVerticalShaftParamsForChunk(ChunkCoord); break;
|
||||
case ECaveGeneratorType::FloatingIslands:
|
||||
@@ -467,63 +487,29 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
||||
Result = GetMazeDensity(WorldX, WorldY, WorldZ, CP_Maze); break;
|
||||
case ECaveGeneratorType::SurfaceWorld:
|
||||
{
|
||||
// The XY-only surface field (biome-blended terrain Z + sky-cap ceiling) for one
|
||||
// column. Resolves the biome sample here so it's cached per column too.
|
||||
auto ComputeColumn = [&](float X, float Y) -> FSurfaceColumn
|
||||
{
|
||||
const FSurfaceGenerationParams* PD = &CP_Surface;
|
||||
const FSurfaceGenerationParams* PN = &CP_Surface;
|
||||
float W = 0.0f;
|
||||
if (CP_BiomeCtx.IsValid() && CP_SurfaceBiomeParams.Num() > 0)
|
||||
{
|
||||
const FBiomeSample Smp = ResolveBiomeSampleAt(X, Y, ChunkCoord.Z, CP_BiomeCtx, CP_BiomeCache);
|
||||
const int32 Di = CP_SurfaceBiomeParams.IsValidIndex(Smp.DominantIndex) ? Smp.DominantIndex : 0;
|
||||
PD = &CP_SurfaceBiomeParams[Di];
|
||||
if (Smp.NeighborWeight > 0.0f && CP_SurfaceBiomeParams.IsValidIndex(Smp.NeighborIndex))
|
||||
{
|
||||
PN = &CP_SurfaceBiomeParams[Smp.NeighborIndex];
|
||||
W = Smp.NeighborWeight;
|
||||
}
|
||||
}
|
||||
FSurfaceColumn Col;
|
||||
Col.TerrainZ = ComputeSurfaceTerrainZ(X, Y, *PD);
|
||||
if (W > 0.0f) Col.TerrainZ = FMath::Lerp(Col.TerrainZ, ComputeSurfaceTerrainZ(X, Y, *PN), W);
|
||||
Col.CeilSurf = ComputeSurfaceCeiling(X, Y, *PD);
|
||||
return Col;
|
||||
};
|
||||
|
||||
FSurfaceColumn Col;
|
||||
// Integer XY (the density grid) → reuse the column down its whole Z extent.
|
||||
// Integer XY (the density grid) → reuse the column down its whole Z extent (T1.a).
|
||||
// Fractional XY (gradient-normal samples) → compute directly (no cache key).
|
||||
if (WorldX == FMath::FloorToFloat(WorldX) && WorldY == FMath::FloorToFloat(WorldY))
|
||||
{
|
||||
const int32 IX = (int32)WorldX, IY = (int32)WorldY;
|
||||
const bool bInBox = CP_SurfCol.bValid
|
||||
&& CP_SurfCol.ChunkZ == ChunkCoord.Z && CP_SurfCol.Seed == Seed
|
||||
&& IX >= CP_SurfCol.BaseX && IX < CP_SurfCol.BaseX + FSurfaceColumnCache::Dim
|
||||
&& IY >= CP_SurfCol.BaseY && IY < CP_SurfCol.BaseY + FSurfaceColumnCache::Dim;
|
||||
if (!bInBox)
|
||||
// XY-keyed LRU box (shared down the whole vertical strate stack). Acquire centres a box on
|
||||
// the first sample so the rest of the chunk's queries — incl. the ±Step margin ring — hit.
|
||||
FSurfaceColumnBox& Box = CP_SurfCol.Acquire(IX, IY, CP_StrateKey, Seed);
|
||||
const int32 CI = (IY - Box.BaseY) * FSurfaceColumnBox::Dim + (IX - Box.BaseX);
|
||||
if (!Box.Computed[CI])
|
||||
{
|
||||
// Centre the box on this (first / boundary-crossing) sample so the rest of
|
||||
// the chunk's column queries — including the ±Step margin ring — fall inside.
|
||||
CP_SurfCol.BaseX = IX - FSurfaceColumnCache::Halo;
|
||||
CP_SurfCol.BaseY = IY - FSurfaceColumnCache::Halo;
|
||||
CP_SurfCol.ChunkZ = ChunkCoord.Z;
|
||||
CP_SurfCol.Seed = Seed;
|
||||
CP_SurfCol.bValid = true;
|
||||
FMemory::Memzero(CP_SurfCol.Computed, sizeof(CP_SurfCol.Computed));
|
||||
ComputeSurfaceColumn(WorldX, WorldY, ChunkCoord.Z, CP_Surface, CP_BiomeCtx,
|
||||
CP_SurfaceBiomeParams, CP_BiomeCache,
|
||||
Box.Cols[CI].TerrainZ, Box.Cols[CI].CeilSurf);
|
||||
Box.Computed[CI] = true;
|
||||
}
|
||||
const int32 CI = (IY - CP_SurfCol.BaseY) * FSurfaceColumnCache::Dim + (IX - CP_SurfCol.BaseX);
|
||||
if (!CP_SurfCol.Computed[CI])
|
||||
{
|
||||
CP_SurfCol.Cols[CI] = ComputeColumn(WorldX, WorldY);
|
||||
CP_SurfCol.Computed[CI] = true;
|
||||
}
|
||||
Col = CP_SurfCol.Cols[CI];
|
||||
Col = Box.Cols[CI];
|
||||
}
|
||||
else
|
||||
{
|
||||
Col = ComputeColumn(WorldX, WorldY);
|
||||
ComputeSurfaceColumn(WorldX, WorldY, ChunkCoord.Z, CP_Surface, CP_BiomeCtx,
|
||||
CP_SurfaceBiomeParams, CP_BiomeCache, Col.TerrainZ, Col.CeilSurf);
|
||||
}
|
||||
|
||||
Result = SurfaceDensityFromColumn(WorldX, WorldY, WorldZ, Col.TerrainZ, Col.CeilSurf, CP_Surface);
|
||||
@@ -1997,15 +1983,52 @@ float UVoxelGenerator::ComputeSurfaceCeiling(float WorldX, float WorldY,
|
||||
{
|
||||
const float H = Params.StrateTopWorldZ - Params.StrateBottomWorldZ;
|
||||
const float SeedF = (float)Seed;
|
||||
const float CeilZ = Params.StrateBottomWorldZ + H * Params.CeilingRelative;
|
||||
float CeilNoise = 0.0f;
|
||||
float CeilZ = Params.StrateBottomWorldZ + H * Params.CeilingRelative;
|
||||
|
||||
// Domain-warp the broad/ridge query coords so ceiling ridgelines and valleys wind
|
||||
// organically (mirrors the ground heightfield's HeightWarp). Fine bumps below stay on the
|
||||
// true XY so detail remains crisp and uncorrelated. 0 ⇒ no warp.
|
||||
float QX = WorldX, QY = WorldY;
|
||||
if (Params.CeilingWarpStrength > 0.0f)
|
||||
{
|
||||
const float WF = Params.CeilingWarpFrequency;
|
||||
const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + SeedF * 0.71f, WorldY * WF + 2.3f, SeedF * 3.3f));
|
||||
const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 6.1f, WorldY * WF + SeedF * 0.19f, SeedF * 4.7f));
|
||||
QX += wx * VOXEL_NOISE_SCALE * Params.CeilingWarpStrength;
|
||||
QY += wy * VOXEL_NOISE_SCALE * Params.CeilingWarpStrength;
|
||||
}
|
||||
|
||||
// Broad SIGNED swell: raises/lowers the whole cap → big inverted hills and valleys.
|
||||
if (Params.CeilingUndulation > 0.0f)
|
||||
{
|
||||
const float Swell = FractalNoise3D(FVector(
|
||||
QX * Params.CeilingUndulationFrequency + SeedF * 1.9f,
|
||||
QY * Params.CeilingUndulationFrequency + 13.0f,
|
||||
SeedF * 0.5f), 3); // [-1,1]
|
||||
CeilZ += Swell * VOXEL_NOISE_SCALE * Params.CeilingUndulation;
|
||||
}
|
||||
|
||||
// Downward hang: everything here is >= 0 so features only protrude into the void (never
|
||||
// punch up into the seal). Fine bumps + sharp ridged blades sum together.
|
||||
float Hang = 0.0f;
|
||||
if (Params.CeilingRoughness > 0.0f)
|
||||
{
|
||||
CeilNoise = FMath::Abs(FractalNoise3D(FVector(
|
||||
WorldX * 0.04f + 5.0f, WorldY * 0.04f + 6.0f, SeedF * 2.1f), 3))
|
||||
* VOXEL_NOISE_SCALE * Params.CeilingRoughness;
|
||||
Hang += FMath::Abs(FractalNoise3D(FVector(
|
||||
WorldX * Params.CeilingRoughnessFrequency + 5.0f,
|
||||
WorldY * Params.CeilingRoughnessFrequency + 6.0f,
|
||||
SeedF * 2.1f), 3)) * VOXEL_NOISE_SCALE * Params.CeilingRoughness;
|
||||
}
|
||||
return CeilZ - CeilNoise;
|
||||
if (Params.CeilingRidgeStrength > 0.0f)
|
||||
{
|
||||
float Ridge = RidgedNoise3D(FVector(
|
||||
QX * Params.CeilingRidgeFrequency + 31.0f,
|
||||
QY * Params.CeilingRidgeFrequency + 47.0f,
|
||||
SeedF * 1.1f), 4); // [-1,1]
|
||||
Ridge = Ridge * 0.5f + 0.5f; // [0,1] hanging ridgelines
|
||||
Hang += Ridge * Params.CeilingRidgeStrength;
|
||||
}
|
||||
|
||||
return CeilZ - Hang;
|
||||
}
|
||||
|
||||
float UVoxelGenerator::SurfaceDensityFromColumn(float WorldX, float WorldY, float WorldZ,
|
||||
@@ -2034,6 +2057,94 @@ float UVoxelGenerator::SurfaceDensityFromColumn(float WorldX, float WorldY, floa
|
||||
return -Density;
|
||||
}
|
||||
|
||||
void UVoxelGenerator::ResolveSurfaceChunkParams(const FIntVector& ChunkCoord,
|
||||
FSurfaceGenerationParams& OutSurface, FBiomeContext& OutBiomeCtx,
|
||||
TArray<FSurfaceGenerationParams>& OutBiomeParams) const
|
||||
{
|
||||
OutSurface = StrateManager->GetSurfaceParamsForChunk(ChunkCoord);
|
||||
OutBiomeCtx = StrateManager->GetBiomeContextForChunk(ChunkCoord);
|
||||
|
||||
// Per-biome surface params: each biome's override (when it overrides SurfaceWorld) else the
|
||||
// strate's, with STRUCTURAL fields forced from the strate (Z bounds, seal, base, water level)
|
||||
// so seals/spine/water stay intact.
|
||||
OutBiomeParams.Reset();
|
||||
if (OutBiomeCtx.IsValid())
|
||||
{
|
||||
const UVoxelStrateDefinition* Def = StrateManager->GetStrateForChunk(ChunkCoord);
|
||||
OutBiomeParams.Reserve(OutBiomeCtx.Biomes.Num());
|
||||
for (const FBiomeResolved& BR : OutBiomeCtx.Biomes)
|
||||
{
|
||||
FSurfaceGenerationParams P = OutSurface; // strate base (+ structural)
|
||||
const UVoxelBiomeDefinition* B =
|
||||
(Def && Def->Biomes.IsValidIndex(BR.Index)) ? Def->Biomes[BR.Index] : nullptr;
|
||||
if (B && B->bOverrideTerrain && B->GeneratorType == ECaveGeneratorType::SurfaceWorld)
|
||||
{
|
||||
P = B->SurfaceParams; // biome shape
|
||||
P.StrateTopWorldZ = OutSurface.StrateTopWorldZ;
|
||||
P.StrateBottomWorldZ = OutSurface.StrateBottomWorldZ;
|
||||
P.BoundarySealThickness = OutSurface.BoundarySealThickness;
|
||||
P.BaseDensity = OutSurface.BaseDensity;
|
||||
P.WaterLevelRelative = OutSurface.WaterLevelRelative; // shared water plane
|
||||
}
|
||||
OutBiomeParams.Add(P);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void UVoxelGenerator::ComputeSurfaceColumn(float WorldX, float WorldY, int32 ChunkZ,
|
||||
const FSurfaceGenerationParams& BaseSurface, const FBiomeContext& BiomeCtx,
|
||||
const TArray<FSurfaceGenerationParams>& BiomeParams, FChunkBiomeCache& BiomeCache,
|
||||
float& OutTerrainZ, float& OutCeilSurf) const
|
||||
{
|
||||
const FSurfaceGenerationParams* PD = &BaseSurface;
|
||||
const FSurfaceGenerationParams* PN = &BaseSurface;
|
||||
float W = 0.0f;
|
||||
if (BiomeCtx.IsValid() && BiomeParams.Num() > 0)
|
||||
{
|
||||
const FBiomeSample Smp = ResolveBiomeSampleAt(WorldX, WorldY, ChunkZ, BiomeCtx, BiomeCache);
|
||||
const int32 Di = BiomeParams.IsValidIndex(Smp.DominantIndex) ? Smp.DominantIndex : 0;
|
||||
PD = &BiomeParams[Di];
|
||||
if (Smp.NeighborWeight > 0.0f && BiomeParams.IsValidIndex(Smp.NeighborIndex))
|
||||
{
|
||||
PN = &BiomeParams[Smp.NeighborIndex];
|
||||
W = Smp.NeighborWeight;
|
||||
}
|
||||
}
|
||||
OutTerrainZ = ComputeSurfaceTerrainZ(WorldX, WorldY, *PD);
|
||||
if (W > 0.0f) OutTerrainZ = FMath::Lerp(OutTerrainZ, ComputeSurfaceTerrainZ(WorldX, WorldY, *PN), W);
|
||||
OutCeilSurf = ComputeSurfaceCeiling(WorldX, WorldY, *PD);
|
||||
}
|
||||
|
||||
bool UVoxelGenerator::GetSurfaceHeightAt(float WorldX, float WorldY, int32 ChunkZ,
|
||||
float& OutTerrainZ, float& OutCeilSurf) const
|
||||
{
|
||||
if (!StrateManager) return false;
|
||||
const FIntVector ChunkCoord(
|
||||
FMath::FloorToInt(WorldX / (float)CHUNK_SIZE),
|
||||
FMath::FloorToInt(WorldY / (float)CHUNK_SIZE),
|
||||
ChunkZ);
|
||||
if (StrateManager->GetGeneratorTypeForChunk(ChunkCoord) != ECaveGeneratorType::SurfaceWorld)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Own per-chunk cache (independent of GetDensityAt's CP_*) so interleaved gen/deco tasks on the
|
||||
// same worker don't thrash each other. All columns of one deco cell share one chunk ⇒ resolve once.
|
||||
thread_local FIntVector OC_Chunk(INT32_MAX, INT32_MAX, INT32_MAX);
|
||||
thread_local FSurfaceGenerationParams OC_Surface;
|
||||
thread_local FBiomeContext OC_BiomeCtx;
|
||||
thread_local TArray<FSurfaceGenerationParams> OC_BiomeParams;
|
||||
thread_local FChunkBiomeCache OC_BiomeCache;
|
||||
if (ChunkCoord != OC_Chunk)
|
||||
{
|
||||
OC_Chunk = ChunkCoord;
|
||||
ResolveSurfaceChunkParams(ChunkCoord, OC_Surface, OC_BiomeCtx, OC_BiomeParams);
|
||||
}
|
||||
ComputeSurfaceColumn(WorldX, WorldY, ChunkZ, OC_Surface, OC_BiomeCtx, OC_BiomeParams, OC_BiomeCache,
|
||||
OutTerrainZ, OutCeilSurf);
|
||||
return true;
|
||||
}
|
||||
|
||||
float UVoxelGenerator::GetSurfaceDensity(float WorldX, float WorldY, float WorldZ,
|
||||
const FSurfaceGenerationParams& ParamsD,
|
||||
const FSurfaceGenerationParams& ParamsN,
|
||||
@@ -2322,6 +2433,43 @@ const UVoxelBiomeDefinition* UVoxelGenerator::GetDominantBiomeAt(float WorldX, f
|
||||
return (Def && Def->Biomes.IsValidIndex(StrateBiomeIdx)) ? Def->Biomes[StrateBiomeIdx] : nullptr;
|
||||
}
|
||||
|
||||
void UVoxelGenerator::GetBiomeMaterialAt(float WorldX, float WorldY, float WorldZ,
|
||||
int32& OutDominantPalette, int32& OutNeighborPalette, float& OutBlendWeight) const
|
||||
{
|
||||
OutDominantPalette = 0;
|
||||
OutNeighborPalette = 0;
|
||||
OutBlendWeight = 0.0f;
|
||||
if (!StrateManager) return;
|
||||
|
||||
const FIntVector ChunkCoord(
|
||||
FMath::FloorToInt(WorldX / CHUNK_SIZE),
|
||||
FMath::FloorToInt(WorldY / CHUNK_SIZE),
|
||||
FMath::FloorToInt(WorldZ / CHUNK_SIZE));
|
||||
|
||||
// Per-chunk biome context cache, mirroring GetDensityAt: mesher vertices cluster by chunk,
|
||||
// so the (cheap) flatten + (noise-heavy, box-validated) grid stay warm across a tile.
|
||||
thread_local FIntVector BM_Chunk(INT32_MAX, INT32_MAX, INT32_MAX);
|
||||
thread_local FBiomeContext BM_Ctx;
|
||||
thread_local FChunkBiomeCache BM_Cache;
|
||||
if (ChunkCoord != BM_Chunk)
|
||||
{
|
||||
BM_Chunk = ChunkCoord;
|
||||
BM_Ctx = StrateManager->GetBiomeContextForChunk(ChunkCoord);
|
||||
}
|
||||
if (!BM_Ctx.IsValid()) return; // strate has no biomes → default palette
|
||||
|
||||
const FBiomeSample S = ResolveBiomeSampleAt(WorldX, WorldY, ChunkCoord.Z, BM_Ctx, BM_Cache);
|
||||
if (BM_Ctx.Biomes.IsValidIndex(S.DominantIndex))
|
||||
{
|
||||
OutDominantPalette = BM_Ctx.Biomes[S.DominantIndex].MaterialPaletteIndex;
|
||||
// Neighbour defaults to the dominant so an interior vertex blends to itself (no seam).
|
||||
OutNeighborPalette = BM_Ctx.Biomes.IsValidIndex(S.NeighborIndex)
|
||||
? BM_Ctx.Biomes[S.NeighborIndex].MaterialPaletteIndex
|
||||
: OutDominantPalette;
|
||||
OutBlendWeight = S.NeighborWeight; // 0 inside a cell → ~0.5 at the border
|
||||
}
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// VERTICAL-SHAFT GENERATOR (ECaveGeneratorType::VerticalShafts)
|
||||
//=============================================================================
|
||||
|
||||
@@ -72,14 +72,17 @@ FVector UVoxelMarchingCubesMesher::ComputeGradientNormal(float WorldX, float Wor
|
||||
// MAIN ALGORITHM
|
||||
//=============================================================================
|
||||
|
||||
FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(const FVoxelChunk& Chunk, int32 Step)
|
||||
FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, int32 Step, int32 InCellsPerAxis)
|
||||
{
|
||||
FVoxelMeshData MeshData;
|
||||
if (!Generator) return MeshData;
|
||||
|
||||
// Step valide = puissance de 2 dans [1, 4]
|
||||
Step = FMath::Clamp(Step, 1, 4);
|
||||
// Cell size in voxels. No upper clamp: coarse clipmap levels use bigger steps (the EXTENT
|
||||
// grows). Coarse tiles also use FEWER cells (InCellsPerAxis) for cheaper gen.
|
||||
Step = FMath::Max(1, Step);
|
||||
|
||||
const FVector ChunkWorldPos = Chunk.GetWorldPosition();
|
||||
// World-cm origin of the tile's min corner (positions are built relative to this).
|
||||
const FVector ChunkWorldPos = FVector(OriginVoxels) * VOXEL_SIZE;
|
||||
|
||||
//=========================================================================
|
||||
// VERTEX DEDUPLICATION MAP
|
||||
@@ -87,7 +90,10 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(const FVoxelChunk& Chunk,
|
||||
// Clé = position quantifiée au 0.01 unité (FIntVector).
|
||||
// Valeur = index dans MeshData.Vertices.
|
||||
// Les vertices partagés permettent des normales lisses et réduisent le count ~3x.
|
||||
TMap<FIntVector, int32> VertexMap;
|
||||
// Réutilisé d'une tuile à l'autre (thread_local) : Reset garde les buckets alloués →
|
||||
// plus de (ré)allocation de hash-map par tuile (chaque worker a son propre exemplaire).
|
||||
static thread_local TMap<FIntVector, int32> VertexMap;
|
||||
VertexMap.Reset();
|
||||
|
||||
// Normale fournie par l'appelant (gradient lu dans la grille de densité, T1.b) —
|
||||
// plus d'échantillonnage de densité par vertex. RawNormal pointe solide→air ; on la
|
||||
@@ -120,6 +126,24 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(const FVoxelChunk& Chunk,
|
||||
// UVs planaires — le triplanar mapping se fait dans le matériau.
|
||||
MeshData.UVs.Add(FVector2D(WorldPos.X / VOXEL_SIZE, WorldPos.Y / VOXEL_SIZE));
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// VERTEX COLOUR — masques pour le matériau triplanar maître (F6).
|
||||
// R = index de palette du biome DOMINANT (0-255) — re-skin par biome.
|
||||
// G = pente (0 = sol/plafond plat, 1 = paroi verticale) — roche sur falaises.
|
||||
// B = poids de fondu de bordure (0 au cœur d'un biome → ~0.5 à la frontière).
|
||||
// A = index de palette du biome VOISIN — le matériau lerp(R,A) par B → bords sans couture.
|
||||
// La hauteur/snow-line se déduit de WorldPosition.Z dans le matériau (pas besoin de canal).
|
||||
// Coût: une résolution biome par vertex UNIQUE (déduplication) ; nul si la strate n'a pas
|
||||
// de biomes (GetBiomeMaterialAt sort en O(1) → palette 0 partout).
|
||||
int32 PalD = 0, PalN = 0; float BlendW = 0.0f;
|
||||
Generator->GetBiomeMaterialAt(WorldPos.X / VOXEL_SIZE, WorldPos.Y / VOXEL_SIZE,
|
||||
WorldPos.Z / VOXEL_SIZE, PalD, PalN, BlendW);
|
||||
const uint8 R = (uint8)FMath::Clamp(PalD, 0, 255);
|
||||
const uint8 A = (uint8)FMath::Clamp(PalN, 0, 255);
|
||||
const uint8 G = (uint8)FMath::Clamp(FMath::RoundToInt((1.0f - FMath::Abs((float)Normal.Z)) * 255.0f), 0, 255);
|
||||
const uint8 Bc = (uint8)FMath::Clamp(FMath::RoundToInt(BlendW * 255.0f), 0, 255);
|
||||
MeshData.Colors.Add(FColor(R, G, Bc, A));
|
||||
|
||||
return NewIndex;
|
||||
};
|
||||
|
||||
@@ -164,11 +188,13 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(const FVoxelChunk& Chunk,
|
||||
// échantillons monde purs qu'un chunk voisin, donc les normales restent continues aux bords
|
||||
// de chunk (pas de couture de shading). La géométrie est inchangée au bit près (mêmes
|
||||
// positions d'arête) ; seules les normales changent.
|
||||
const int32 CellsPerAxis = CHUNK_SIZE / Step;
|
||||
const int32 CellsPerAxis = FMath::Clamp(InCellsPerAxis, 2, CHUNK_SIZE); // coarse tiles use fewer
|
||||
const int32 GridDim = CellsPerAxis + 1;
|
||||
const int32 MDim = GridDim + 2; // +1 marge de chaque côté
|
||||
|
||||
TArray<float> DensityGrid;
|
||||
// Réutilise le tampon entre tuiles (thread_local) : SetNumUninitialized garde la
|
||||
// capacité, donc plus de malloc/free de ~170 Ko (35³ floats) par tuile.
|
||||
static thread_local TArray<float> DensityGrid;
|
||||
DensityGrid.SetNumUninitialized(MDim * MDim * MDim);
|
||||
for (int32 gz = -1; gz <= GridDim; gz++)
|
||||
{
|
||||
@@ -176,8 +202,12 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(const FVoxelChunk& Chunk,
|
||||
{
|
||||
for (int32 gx = -1; gx <= GridDim; gx++)
|
||||
{
|
||||
// World voxel = tile origin + grid offset scaled by the cell size (Step).
|
||||
DensityGrid[((gz + 1) * MDim + (gy + 1)) * MDim + (gx + 1)] =
|
||||
GetDensity(Chunk, gx * Step, gy * Step, gz * Step);
|
||||
Generator->GetDensityAt(
|
||||
OriginVoxels.X + gx * Step,
|
||||
OriginVoxels.Y + gy * Step,
|
||||
OriginVoxels.Z + gz * Step);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,35 +238,37 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(const FVoxelChunk& Chunk,
|
||||
{
|
||||
for (int32 cx = 0; cx < CellsPerAxis; cx++)
|
||||
{
|
||||
// Densités + positions + gradients aux 8 coins (lus dans la grille).
|
||||
// PASSE 1 : densités aux 8 coins + index de cas MC SEULEMENT.
|
||||
// ~70% des cellules d'un chunk sont tout-roc ou tout-air (aucune surface) ;
|
||||
// on les rejette ICI, AVANT de payer les 8 positions + 8 gradients (48 lectures
|
||||
// grille + maths vectorielles). Sortie bit-identique : positions et gradients ne
|
||||
// servent qu'aux cellules réellement traversées par l'isosurface.
|
||||
float Densities[8];
|
||||
int32 CaseIndex = 0;
|
||||
for (int32 i = 0; i < 8; i++)
|
||||
{
|
||||
const float D = SampleG(cx + CornerOffsets[i].X,
|
||||
cy + CornerOffsets[i].Y,
|
||||
cz + CornerOffsets[i].Z);
|
||||
Densities[i] = D;
|
||||
if (D >= IsoLevel) CaseIndex |= (1 << i);
|
||||
}
|
||||
|
||||
if (MCEdgeTable[CaseIndex] == 0) continue; // Pas de surface ici → skip
|
||||
|
||||
// PASSE 2 : positions + gradients aux 8 coins (uniquement si surface présente).
|
||||
FVector Positions[8];
|
||||
FVector Gradients[8];
|
||||
|
||||
for (int32 i = 0; i < 8; i++)
|
||||
{
|
||||
const int32 GX = cx + CornerOffsets[i].X;
|
||||
const int32 GY = cy + CornerOffsets[i].Y;
|
||||
const int32 GZ = cz + CornerOffsets[i].Z;
|
||||
|
||||
Densities[i] = SampleG(GX, GY, GZ);
|
||||
Positions[i] = ChunkWorldPos
|
||||
Positions[i] = ChunkWorldPos
|
||||
+ FVector(GX * Step, GY * Step, GZ * Step) * VOXEL_SIZE;
|
||||
Gradients[i] = GradAt(GX, GY, GZ);
|
||||
Gradients[i] = GradAt(GX, GY, GZ);
|
||||
}
|
||||
|
||||
// Index de cas MC (8 bits, un par coin)
|
||||
int32 CaseIndex = 0;
|
||||
for (int32 i = 0; i < 8; i++)
|
||||
{
|
||||
if (Densities[i] >= IsoLevel)
|
||||
{
|
||||
CaseIndex |= (1 << i);
|
||||
}
|
||||
}
|
||||
|
||||
if (MCEdgeTable[CaseIndex] == 0) continue; // Pas de surface ici
|
||||
|
||||
// Interpolation des positions + normales sur les arêtes traversées. Le t est
|
||||
// calculé exactement comme InterpolateEdge → positions bit-identiques (topologie
|
||||
// inchangée) ; la normale interpole les gradients de coin par le même t.
|
||||
@@ -276,5 +308,70 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(const FVoxelChunk& Chunk,
|
||||
}
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// SKIRTS — boucheurs de fissures aux coutures de LOD (clipmap)
|
||||
//=========================================================================
|
||||
// Deux tuiles de niveaux voisins maillent à des résolutions différentes : leurs iso-surfaces
|
||||
// ne se rejoignent pas parfaitement le long de la face partagée → une fine fissure traversante.
|
||||
// On la scelle en suspendant une courte « jupe » (mur) sous chaque arête de surface posée sur
|
||||
// l'une des 6 faces externes de la tuile, extrudée VERS LE SOLIDE le long de la normale inversée
|
||||
// sur ~une taille de cellule. Là où la surface du voisin est décalée, les jupes des deux tuiles
|
||||
// se recouvrent dans la roche et ferment le trou ; ailleurs la jupe est enterrée et invisible.
|
||||
// Émis en DOUBLE FACE (deux orientations) pour s'afficher quel que soit le côté caméra / le
|
||||
// matériau. Une arête de surface posée sur une face de frontière a sa coordonnée d'axe EXACTE
|
||||
// (l'interpolation MC garde fixe l'axe de la face) → comparaison flottante exacte fiable.
|
||||
if (bGenerateSkirts && MeshData.Triangles.Num() > 0)
|
||||
{
|
||||
const float ExtentCm = (float)(CellsPerAxis * Step) * VOXEL_SIZE;
|
||||
const float MinX = ChunkWorldPos.X, MinY = ChunkWorldPos.Y, MinZ = ChunkWorldPos.Z;
|
||||
const float MaxX = MinX + ExtentCm, MaxY = MinY + ExtentCm, MaxZ = MinZ + ExtentCm;
|
||||
const float SkirtDepth = FMath::Max(1.0f, SkirtCells) * (float)Step * VOXEL_SIZE;
|
||||
|
||||
auto OnBoundaryPlane = [&](const FVector& A, const FVector& B) -> bool
|
||||
{
|
||||
return (A.X == MinX && B.X == MinX) || (A.X == MaxX && B.X == MaxX)
|
||||
|| (A.Y == MinY && B.Y == MinY) || (A.Y == MaxY && B.Y == MaxY)
|
||||
|| (A.Z == MinZ && B.Z == MinZ) || (A.Z == MaxZ && B.Z == MaxZ);
|
||||
};
|
||||
|
||||
auto AddSkirtVert = [&](const FVector& Pos, const FVector& Nrm, const FColor& Col) -> int32
|
||||
{
|
||||
const int32 Idx = MeshData.Vertices.Num();
|
||||
MeshData.Vertices.Add(Pos);
|
||||
MeshData.Normals.Add(Nrm);
|
||||
MeshData.UVs.Add(FVector2D(Pos.X / VOXEL_SIZE, Pos.Y / VOXEL_SIZE));
|
||||
MeshData.Colors.Add(Col); // hérite la couleur du vertex source (parallèle aux autres tableaux)
|
||||
return Idx;
|
||||
};
|
||||
|
||||
// On ajoute en itérant : on fige le nombre de triangles de surface et on n'ajoute qu'au-delà.
|
||||
const int32 BaseTriNum = MeshData.Triangles.Num();
|
||||
for (int32 t = 0; t + 2 < BaseTriNum; t += 3)
|
||||
{
|
||||
const int32 Tri[3] = { MeshData.Triangles[t], MeshData.Triangles[t + 1], MeshData.Triangles[t + 2] };
|
||||
for (int32 e = 0; e < 3; ++e)
|
||||
{
|
||||
const int32 iA = Tri[e], iB = Tri[(e + 1) % 3];
|
||||
// COPIES par valeur — AddSkirtVert réalloue Vertices/Normals (invaliderait des refs).
|
||||
const FVector PA = MeshData.Vertices[iA];
|
||||
const FVector PB = MeshData.Vertices[iB];
|
||||
if (!OnBoundaryPlane(PA, PB)) continue;
|
||||
|
||||
const FVector NA = MeshData.Normals[iA];
|
||||
const FVector NB = MeshData.Normals[iB];
|
||||
const FColor CA = MeshData.Colors[iA];
|
||||
const FColor CB = MeshData.Colors[iB];
|
||||
const int32 iA2 = AddSkirtVert(PA - NA * SkirtDepth, NA, CA);
|
||||
const int32 iB2 = AddSkirtVert(PB - NB * SkirtDepth, NB, CB);
|
||||
|
||||
// Quad (iA, iB, iB2, iA2) → 2 triangles, émis dans LES DEUX orientations.
|
||||
MeshData.Triangles.Add(iA); MeshData.Triangles.Add(iB); MeshData.Triangles.Add(iB2);
|
||||
MeshData.Triangles.Add(iA); MeshData.Triangles.Add(iB2); MeshData.Triangles.Add(iA2);
|
||||
MeshData.Triangles.Add(iA); MeshData.Triangles.Add(iB2); MeshData.Triangles.Add(iB);
|
||||
MeshData.Triangles.Add(iA); MeshData.Triangles.Add(iA2); MeshData.Triangles.Add(iB2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return MeshData;
|
||||
}
|
||||
|
||||
@@ -348,6 +348,9 @@ void UVoxelStrateManager::GeneratePassages()
|
||||
UE_LOG(LogTemp, Log, TEXT("[StrateManager] Surface entry shaft at (0,0) topZ=%.0f R=%.1f"),
|
||||
TopZ, Entry.Radius);
|
||||
}
|
||||
|
||||
// Invalidate any thread_local per-chunk passage shortlists (see EvaluateModifierSDF).
|
||||
++PassagesVersion;
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
@@ -356,6 +359,53 @@ void UVoxelStrateManager::GeneratePassages()
|
||||
|
||||
float UVoxelStrateManager::EvaluateModifierSDF(float WorldX, float WorldY, float WorldZ) const
|
||||
{
|
||||
//=========================================================================
|
||||
// PER-CHUNK PASSAGE SHORTLIST
|
||||
//=========================================================================
|
||||
// This runs PER VOXEL (35³ per tile). The vast majority of chunks are nowhere near a
|
||||
// descent passage, yet every voxel still walked the WHOLE Passages array just to reject
|
||||
// each one on a squared-distance test (Passages.Num() × 35³ rejects per tile, all wasted).
|
||||
// Cache, per chunk, the shortlist of passages whose bounds actually reach this chunk —
|
||||
// usually EMPTY → instant FLT_MAX return (no carve). Indices (not pointers) + a version
|
||||
// stamp keep it safe across a GeneratePassages rebuild. Output is bit-identical: the
|
||||
// shortlist is a conservative superset (chunk bounding sphere vs each passage bound).
|
||||
thread_local FIntVector SL_Chunk(INT32_MAX, INT32_MAX, INT32_MAX);
|
||||
thread_local uint32 SL_Version = 0xFFFFFFFFu;
|
||||
thread_local TArray<int32> SL_Nearby;
|
||||
|
||||
const FIntVector ChunkCoord(
|
||||
FMath::FloorToInt(WorldX / (float)CHUNK_SIZE),
|
||||
FMath::FloorToInt(WorldY / (float)CHUNK_SIZE),
|
||||
FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE));
|
||||
|
||||
if (ChunkCoord != SL_Chunk || SL_Version != PassagesVersion)
|
||||
{
|
||||
SL_Chunk = ChunkCoord;
|
||||
SL_Version = PassagesVersion;
|
||||
SL_Nearby.Reset();
|
||||
|
||||
// Chunk bounding sphere (centre + half-diagonal), padded by the blend radius. A passage
|
||||
// is kept iff its bounding sphere overlaps the chunk's — i.e. some voxel here could be
|
||||
// inside its per-voxel reject radius. √3/2 · CHUNK_SIZE ≈ 0.866 · size.
|
||||
const FVector CCenter(
|
||||
(ChunkCoord.X + 0.5f) * (float)CHUNK_SIZE,
|
||||
(ChunkCoord.Y + 0.5f) * (float)CHUNK_SIZE,
|
||||
(ChunkCoord.Z + 0.5f) * (float)CHUNK_SIZE);
|
||||
const float ChunkR = (float)CHUNK_SIZE * 0.8660254f + 3.0f; // +BlendK
|
||||
|
||||
for (int32 i = 0; i < Passages.Num(); ++i)
|
||||
{
|
||||
const FVoxelPassage& P = Passages[i];
|
||||
const float Reach = FMath::Sqrt(P.BoundRadiusSq) + ChunkR;
|
||||
if (FVector::DistSquared(CCenter, P.BoundCenter) <= Reach * Reach)
|
||||
{
|
||||
SL_Nearby.Add(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (SL_Nearby.Num() == 0) return FLT_MAX; // no passage near this chunk → no carve
|
||||
|
||||
float MinSDF = FLT_MAX;
|
||||
const float BlendK = 3.0f; // Smooth blend for passage junctions
|
||||
|
||||
@@ -365,8 +415,9 @@ float UVoxelStrateManager::EvaluateModifierSDF(float WorldX, float WorldY, float
|
||||
// entry is a simple straight tube. A bounding-sphere reject skips far passages.
|
||||
//=========================================================================
|
||||
const FVector Pos(WorldX, WorldY, WorldZ);
|
||||
for (const FVoxelPassage& P : Passages)
|
||||
for (int32 PIdx : SL_Nearby)
|
||||
{
|
||||
const FVoxelPassage& P = Passages[PIdx];
|
||||
// BOUNDING-SPHERE REJECT: skip passages this voxel can't possibly be inside.
|
||||
// EvaluateModifierSDF runs PER VOXEL and used to evaluate every passage's full
|
||||
// capsule chain unconditionally — the dominant lag source once passages became
|
||||
@@ -454,6 +505,22 @@ UVoxelStrateDefinition* UVoxelStrateManager::GetStrateForChunk(const FIntVector&
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool UVoxelStrateManager::GetStrateChunkZBounds(int32 ChunkZ, int32& OutTopChunkZ, int32& OutBottomChunkZ) const
|
||||
{
|
||||
// Strate-aware vertical streaming. Returns the chunk-Z span of the strate containing
|
||||
// ChunkZ; false if ChunkZ is in the inter-strate gap (or outside the layout) — there the
|
||||
// caller leaves the vertical view UNCLAMPED, since the gap is a brief see-both-sides
|
||||
// descent transition. TopChunkZ > BottomChunkZ (Z decreases downward).
|
||||
const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkZ);
|
||||
if (SlotIdx < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
OutTopChunkZ = StrateLayout[SlotIdx].TopChunkZ;
|
||||
OutBottomChunkZ = StrateLayout[SlotIdx].BottomChunkZ;
|
||||
return true;
|
||||
}
|
||||
|
||||
ECaveGeneratorType UVoxelStrateManager::GetGeneratorTypeForChunk(const FIntVector& ChunkCoord) const
|
||||
{
|
||||
// Look up which slot this chunk falls into.
|
||||
@@ -559,6 +626,7 @@ FBiomeContext UVoxelStrateManager::GetBiomeContextForChunk(const FIntVector& Chu
|
||||
R.ReliefMin = B->ReliefMin; R.ReliefMax = B->ReliefMax;
|
||||
R.MoistureMin = B->MoistureMin; R.MoistureMax = B->MoistureMax;
|
||||
R.DebugColor = B->DebugColor.ToFColor(true);
|
||||
R.MaterialPaletteIndex = B->MaterialPaletteIndex;
|
||||
Out.Biomes.Add(R);
|
||||
}
|
||||
return Out;
|
||||
|
||||
@@ -34,18 +34,21 @@ void AVoxelWorld::RegenerateAllChunks()
|
||||
// ProcessPendingChunks will discard any result with an old epoch.
|
||||
GenerationEpoch++;
|
||||
|
||||
// Collect all loaded chunk coords
|
||||
TArray<FIntVector> AllCoords;
|
||||
ChunkMeshes.GetKeys(AllCoords);
|
||||
// Tear down every tile component, then clear all tile state. Components are GC-safe via
|
||||
// actor ownership; destroying them here is immediate.
|
||||
const int32 Count = LoadedTiles.Num();
|
||||
for (auto& Pair : TileComponents) { if (Pair.Value) Pair.Value->DestroyComponent(); }
|
||||
TileComponents.Empty();
|
||||
LoadedTiles.Empty();
|
||||
|
||||
// Unload every chunk (destroys mesh components + data)
|
||||
for (const FIntVector& Coord : AllCoords)
|
||||
{
|
||||
UnloadChunk(Coord);
|
||||
}
|
||||
// Decorations/water are keyed per level-0 chunk — clear them all.
|
||||
if (ContentManager) { ContentManager->ClearAll(); }
|
||||
|
||||
// Clear pending set — stale tasks will be discarded by epoch check
|
||||
PendingChunkCoord.Empty();
|
||||
// Clear pending set — stale tasks will be discarded by the epoch check.
|
||||
PendingTiles.Empty();
|
||||
// Tiles are already destroyed above — drop any deferred-teardown keys so the drain doesn't
|
||||
// try to UnloadTile coords that no longer exist.
|
||||
PendingUnload.Empty();
|
||||
|
||||
// Reset streaming state so the next Tick rebuilds the desired set and reloads.
|
||||
LastUpdateCenter = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
|
||||
@@ -53,8 +56,8 @@ void AVoxelWorld::RegenerateAllChunks()
|
||||
DesiredSorted.Reset();
|
||||
DesiredSet.Reset();
|
||||
|
||||
// Tick will reload all chunks on the next frame with fresh params.
|
||||
UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] RegenerateAllChunks (epoch %u): unloaded %d chunks"), GenerationEpoch, AllCoords.Num());
|
||||
// Tick will reload all tiles on the next frame with fresh params.
|
||||
UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] RegenerateAllChunks (epoch %u): cleared %d tiles"), GenerationEpoch, Count);
|
||||
}
|
||||
|
||||
void AVoxelWorld::RebuildStrates()
|
||||
@@ -192,7 +195,14 @@ void AVoxelWorld::EndPlay(const EEndPlayReason::Type EndPlayReason)
|
||||
// Drain any queued results
|
||||
FChunkResult Discard;
|
||||
while (ProcessQueue.Dequeue(Discard)) {}
|
||||
PendingChunkCoord.Empty();
|
||||
PendingTiles.Empty();
|
||||
PendingUnload.Empty();
|
||||
|
||||
// Stop + drain the decoration march tasks (they read the Generator) before UObject teardown.
|
||||
if (ContentManager)
|
||||
{
|
||||
ContentManager->NotifyShutdown();
|
||||
}
|
||||
|
||||
// Destroy any spawned atmosphere layer actors.
|
||||
if (AtmosphereManager)
|
||||
@@ -223,12 +233,28 @@ void AVoxelWorld::BeginPlay()
|
||||
return;
|
||||
}
|
||||
|
||||
// Tiles never move once generated, so make the actor root STATIC. RMC already requests the
|
||||
// cached static DRAW path (section group DrawType defaults to Static), but a Movable parent
|
||||
// forces every child back to Movable — which also defeats Virtual Shadow Map caching (Movable
|
||||
// geometry re-renders its shadow every frame; that's the cost that made us turn VSM off). With
|
||||
// a Static root + Static tile components, draws cache AND VSM can cache the terrain's shadows,
|
||||
// so VSM can be turned back on cheaply. Content decoration HISMs are likewise Static (placed once,
|
||||
// never move). Movable children (atmosphere fog/sky, water planes — these follow the player) under a
|
||||
// Static root are allowed. NOTE: a Static actor can't be moved in-editor —
|
||||
// VoxelWorld is expected to sit at the origin.
|
||||
if (USceneComponent* Root = GetRootComponent())
|
||||
{
|
||||
Root->SetMobility(EComponentMobility::Static);
|
||||
}
|
||||
|
||||
// Générateur + mesher (UObjects légers)
|
||||
Generator = NewObject<UVoxelGenerator>(this);
|
||||
Mesher = NewObject<UVoxelMarchingCubesMesher>(this);
|
||||
|
||||
Generator->InitializeSettings(Settings);
|
||||
Mesher->SetGenerator(Generator);
|
||||
Mesher->bGenerateSkirts = Settings->bGenerateSkirts;
|
||||
Mesher->SkirtCells = Settings->SkirtCells;
|
||||
|
||||
// Système de strates — piloté par le pool et les fixed entries dans Settings.
|
||||
if (Settings->StratePool.Num() > 0)
|
||||
@@ -246,9 +272,9 @@ void AVoxelWorld::BeginPlay()
|
||||
DiffLayer->SetBudget(Settings->MaxModifications, Settings->MaxBrushRadius, Settings->MaxTotalVolume);
|
||||
Generator->SetDiffLayer(DiffLayer);
|
||||
|
||||
// Content manager — scatters decorations/actors + water per chunk as they stream in.
|
||||
// Content manager — distance-based decoration grid (no LOD pop) + level-0 water planes.
|
||||
ContentManager = NewObject<UVoxelContentManager>(this);
|
||||
ContentManager->Initialize(this, StrateManager, Generator, Settings->Seed);
|
||||
ContentManager->Initialize(this, StrateManager, Generator, Settings, Settings->Seed);
|
||||
|
||||
// Atmosphere manager — per-strate fog + ambient + persistent ceiling/floor layers.
|
||||
if (bManageAtmosphere && StrateManager)
|
||||
@@ -269,6 +295,7 @@ void AVoxelWorld::BeginPlay()
|
||||
void AVoxelWorld::Tick(float DeltaTime)
|
||||
{
|
||||
Super::Tick(DeltaTime);
|
||||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_Tick); // game-thread streaming orchestration breakdown
|
||||
FVector PlayerLastPos = GetPlayerPosition();
|
||||
|
||||
if ((PlayerLastPos != FVector::ZeroVector)) {
|
||||
@@ -279,11 +306,15 @@ void AVoxelWorld::Tick(float DeltaTime)
|
||||
}
|
||||
if (ContentManager)
|
||||
{
|
||||
// Strate light culling — no-op unless the player changed strate.
|
||||
ContentManager->SetActiveStrate(GetStrateAtPosition(PlayerLastPos));
|
||||
// Distance-based decoration streaming (no LOD pop). Cheap no-op unless the player crosses
|
||||
// a decoration cell boundary or changes strate; otherwise just drains the spawn budget.
|
||||
{ TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateDecorations); ContentManager->UpdateDecorations(PlayerLastPos); }
|
||||
// One strate-global ocean plane following the player (water at every LOD, to the horizon).
|
||||
{ TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateWater); ContentManager->UpdateWater(PlayerLastPos); }
|
||||
}
|
||||
}
|
||||
ProcessPendingChunks();
|
||||
ProcessUnloadQueue();
|
||||
|
||||
#if ENABLE_DRAW_DEBUG
|
||||
// Inter-strate passage overlay (cyan path, green=upper / red=lower endpoints).
|
||||
@@ -393,6 +424,7 @@ bool AVoxelWorld::IsChunkInRange(const FIntVector& ChunkCoord, const FIntVector&
|
||||
|
||||
void AVoxelWorld::ProcessPendingChunks()
|
||||
{
|
||||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ProcessPending);
|
||||
// This runs on the game thread, called from Tick.
|
||||
//
|
||||
// STEPS:
|
||||
@@ -421,7 +453,7 @@ void AVoxelWorld::ProcessPendingChunks()
|
||||
FChunkResult DequeuedChunk;
|
||||
while (ProcessQueue.Dequeue(DequeuedChunk))
|
||||
{
|
||||
PendingChunkCoord.Remove(DequeuedChunk.ChunkCoord);
|
||||
PendingTiles.Remove(DequeuedChunk.Tile);
|
||||
|
||||
// Discard results from a previous generation epoch (stale).
|
||||
if (DequeuedChunk.Epoch != GenerationEpoch)
|
||||
@@ -429,18 +461,17 @@ void AVoxelWorld::ProcessPendingChunks()
|
||||
continue;
|
||||
}
|
||||
|
||||
// Always register the chunk as loaded (even if empty — so we don't re-generate it).
|
||||
Chunks.Add(DequeuedChunk.ChunkCoord, DequeuedChunk.Chunk);
|
||||
ChunkLODs.Add(DequeuedChunk.ChunkCoord, DequeuedChunk.LODLevel);
|
||||
// Mark the tile loaded (even if empty — so we don't re-submit it).
|
||||
LoadedTiles.Add(DequeuedChunk.Tile);
|
||||
|
||||
// Empty mesh = all air chunk — nothing to render, but still "loaded".
|
||||
// Empty mesh = all-air tile — nothing to render, but still "loaded".
|
||||
if (DequeuedChunk.MeshData.IsEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply mesh (GPU upload) — this is the expensive part we budget.
|
||||
ApplyMeshToChunk(DequeuedChunk.ChunkCoord, DequeuedChunk.MeshData);
|
||||
ApplyMeshToTile(DequeuedChunk.Tile, DequeuedChunk.MeshData);
|
||||
MeshesApplied++;
|
||||
|
||||
if (MeshesApplied >= MaxApplies)
|
||||
@@ -451,145 +482,294 @@ void AVoxelWorld::ProcessPendingChunks()
|
||||
|
||||
}
|
||||
|
||||
void AVoxelWorld::ProcessUnloadQueue()
|
||||
{
|
||||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ProcessUnload);
|
||||
// Budgeted teardown: destroy at most a few tiles' components + content actors per frame, so a
|
||||
// fast traversal's whole-shell cull (dozens of UnloadTile in one frame) doesn't spike the game
|
||||
// thread. The budget auto-scales with the backlog (PendingUnload/4) so it never falls far behind.
|
||||
if (PendingUnload.Num() == 0) return;
|
||||
|
||||
// Drain the floor budget, scaling up with the backlog so we never fall far behind, but capped
|
||||
// at 4× the floor so a huge backlog (extreme speed) can't itself become a one-frame spike — the
|
||||
// excess just lingers a few more frames (it's all behind the player, out of view).
|
||||
const int32 Floor = Settings ? FMath::Max(1, Settings->MaxUnloadsPerFrame) : 6;
|
||||
int32 DestroyBudget = FMath::Clamp(PendingUnload.Num() / 4, Floor, Floor * 4);
|
||||
|
||||
TArray<FVoxelTileKey> Dequeued; // removed from the queue this frame (destroyed OR cancelled)
|
||||
for (const FVoxelTileKey& T : PendingUnload)
|
||||
{
|
||||
if (DesiredSet.Contains(T))
|
||||
{
|
||||
// Re-desired before its turn (player reversed) — keep it; it's still loaded, just drop
|
||||
// it from the queue. Doesn't count against the destroy budget.
|
||||
Dequeued.Add(T);
|
||||
continue;
|
||||
}
|
||||
UnloadTile(T);
|
||||
Dequeued.Add(T);
|
||||
if (--DestroyBudget <= 0) break;
|
||||
}
|
||||
for (const FVoxelTileKey& T : Dequeued) PendingUnload.Remove(T);
|
||||
}
|
||||
|
||||
// Integer floor-division (correct for negatives), scalar + vector.
|
||||
static FORCEINLINE int32 VF_FloorDiv(int32 V, int32 D)
|
||||
{
|
||||
return V >= 0 ? (V / D) : -(((-V) + D - 1) / D);
|
||||
}
|
||||
static FORCEINLINE FIntVector VF_FloorDiv(const FIntVector& V, int32 D)
|
||||
{
|
||||
return FIntVector(VF_FloorDiv(V.X, D), VF_FloorDiv(V.Y, D), VF_FloorDiv(V.Z, D));
|
||||
}
|
||||
|
||||
void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center)
|
||||
{
|
||||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_BuildDesiredTiles);
|
||||
DesiredSorted.Reset();
|
||||
DesiredSet.Reset();
|
||||
|
||||
const int32 R = Settings ? FMath::Max(1, Settings->ClipRadius) : 3;
|
||||
const int32 MaxLevel = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4;
|
||||
|
||||
// Strate-aware VERTICAL band (in level-0 chunk-Z). Without this the clipmap generates the
|
||||
// occluded volume above/below (sealed strates are light-tight, §8.7) AND the full underground
|
||||
// depth — a huge column of invisible solid rock = the gen lag. So clamp the vertical reach to
|
||||
// the player's strate ± margin (open-ceiling strates extend UP to the sky-cap). The clipmap
|
||||
// stays full-reach HORIZONTALLY (the horizon) but limited vertically. ZLo/ZHi span ⇒ no clamp.
|
||||
int32 ZLo = MIN_int32, ZHi = MAX_int32;
|
||||
if (Settings && Settings->bClampViewToStrate && StrateManager)
|
||||
{
|
||||
const int32 Margin = Settings->StrateViewMarginChunks;
|
||||
ZLo = Center.Z - Settings->ViewDistanceDown;
|
||||
ZHi = Center.Z + Settings->ViewDistanceUp;
|
||||
int32 StrTopZ = 0, StrBotZ = 0;
|
||||
if (StrateManager->GetStrateChunkZBounds(Center.Z, StrTopZ, StrBotZ))
|
||||
{
|
||||
const ECaveGeneratorType GenType = StrateManager->GetGeneratorTypeForChunk(Center);
|
||||
const bool bOpen = (GenType == ECaveGeneratorType::SurfaceWorld
|
||||
|| GenType == ECaveGeneratorType::FloatingIslands);
|
||||
ZLo = FMath::Max(ZLo, StrBotZ - Margin);
|
||||
ZHi = bOpen ? (StrTopZ + Margin) : FMath::Min(ZHi, StrTopZ + Margin);
|
||||
}
|
||||
// else: in the bedrock gap → keep the player window (see both sides while descending).
|
||||
}
|
||||
|
||||
// Concentric shells: level 0 near the player, each coarser level a 2× larger shell beyond.
|
||||
// A level-L tile is dropped if it's fully covered by the finer (L-1) level's box — that's
|
||||
// the inner hole, so the shells tile space without big gaps.
|
||||
for (int32 L = 0; L <= MaxLevel; ++L)
|
||||
{
|
||||
const int32 Pow = 1 << L; // level-L tile = 2^L chunks
|
||||
const FIntVector CL = VF_FloorDiv(Center, Pow); // player's level-L tile coord
|
||||
const FIntVector CF = (L > 0) ? VF_FloorDiv(Center, Pow >> 1) : FIntVector::ZeroValue;
|
||||
|
||||
for (int32 dz = -R; dz <= R; ++dz)
|
||||
for (int32 dy = -R; dy <= R; ++dy)
|
||||
for (int32 dx = -R; dx <= R; ++dx)
|
||||
{
|
||||
const FIntVector T = CL + FIntVector(dx, dy, dz);
|
||||
|
||||
// Strate-aware vertical clamp: drop tiles whose level-0 chunk-Z footprint doesn't
|
||||
// overlap [ZLo, ZHi] (the occluded strate above/below / deep underground).
|
||||
const int32 TZLo = T.Z << L;
|
||||
const int32 TZHi = ((T.Z + 1) << L) - 1;
|
||||
if (TZHi < ZLo || TZLo > ZHi) continue;
|
||||
|
||||
if (L > 0)
|
||||
{
|
||||
// Covered by the finer level iff the level-(L-1) tiles 2T..2T+1 (per axis)
|
||||
// all lie inside the finer box [CF-R, CF+R].
|
||||
const bool bCovered =
|
||||
(2 * T.X >= CF.X - R) && (2 * T.X + 1 <= CF.X + R) &&
|
||||
(2 * T.Y >= CF.Y - R) && (2 * T.Y + 1 <= CF.Y + R) &&
|
||||
(2 * T.Z >= CF.Z - R) && (2 * T.Z + 1 <= CF.Z + R);
|
||||
if (bCovered) continue;
|
||||
}
|
||||
const FVoxelTileKey Key(T, L);
|
||||
DesiredSorted.Add(Key);
|
||||
DesiredSet.Add(Key);
|
||||
}
|
||||
}
|
||||
|
||||
// Nearest-first (by tile-centre distance to the player), so the closest tiles stream first.
|
||||
const FVector PlayerVoxel = (FVector(Center) + FVector(0.5f, 0.5f, 0.5f)) * (float)CHUNK_SIZE;
|
||||
DesiredSorted.Sort([&PlayerVoxel](const FVoxelTileKey& A, const FVoxelTileKey& B)
|
||||
{
|
||||
const FVector CA = A.CenterCm() / VOXEL_SIZE;
|
||||
const FVector CB = B.CenterCm() / VOXEL_SIZE;
|
||||
return FVector::DistSquared(CA, PlayerVoxel) < FVector::DistSquared(CB, PlayerVoxel);
|
||||
});
|
||||
}
|
||||
|
||||
bool AVoxelWorld::IsTileInClipRange(const FVoxelTileKey& Tile, const FIntVector& Center) const
|
||||
{
|
||||
// In range = the tile's centre falls within the OUTERMOST shell (level MaxLevel ± R). A
|
||||
// loaded-but-not-desired tile in range is mid-LOD-transition (wait for its replacement);
|
||||
// one out of range has left the view entirely (cull immediately).
|
||||
const int32 R = Settings ? FMath::Max(1, Settings->ClipRadius) : 3;
|
||||
const int32 MaxLevel = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4;
|
||||
const int32 PowMax = 1 << MaxLevel;
|
||||
const FIntVector CMax = VF_FloorDiv(Center, PowMax);
|
||||
|
||||
const FVector CV = Tile.CenterCm() / VOXEL_SIZE; // tile centre in voxels
|
||||
const int32 SizeMax = CHUNK_SIZE * PowMax;
|
||||
const FIntVector TMax(
|
||||
VF_FloorDiv(FMath::FloorToInt(CV.X), SizeMax),
|
||||
VF_FloorDiv(FMath::FloorToInt(CV.Y), SizeMax),
|
||||
VF_FloorDiv(FMath::FloorToInt(CV.Z), SizeMax));
|
||||
|
||||
return FMath::Abs(TMax.X - CMax.X) <= R
|
||||
&& FMath::Abs(TMax.Y - CMax.Y) <= R
|
||||
&& FMath::Abs(TMax.Z - CMax.Z) <= R;
|
||||
}
|
||||
|
||||
void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition)
|
||||
{
|
||||
//
|
||||
// TOOLS:
|
||||
// - WorldToChunkCoord(Position) - convert world pos to chunk coord
|
||||
// - Chunks.Contains(Coord) - check if chunk exists
|
||||
// - TArray<FIntVector> to build a list
|
||||
// - Chunks is a TMap you can iterate with: for (auto& Pair : Chunks)
|
||||
// where Pair.Key is the coordinate and Pair.Value is the chunk
|
||||
const int32 ViewXY = Settings->ViewDistanceXY;
|
||||
const int32 ViewUp = Settings->ViewDistanceUp;
|
||||
const int32 ViewDown = Settings->ViewDistanceDown;
|
||||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateChunks);
|
||||
const int32 MaxTasks = Settings ? Settings->MaxConcurrentTasks : 16;
|
||||
|
||||
const FIntVector CenterChunk = WorldToChunkCoord(CenterPosition);
|
||||
const FIntVector CenterChunk = WorldToChunkCoord(CenterPosition); // player's level-0 tile
|
||||
CurrentCenterChunk = CenterChunk;
|
||||
|
||||
//=========================================================================
|
||||
// ONLY rebuild the desired set when the player crosses a chunk boundary.
|
||||
// Standing still (or moving within a chunk) costs nothing here.
|
||||
// Rebuild the desired tile set only when the player crosses a level-0 tile boundary.
|
||||
//=========================================================================
|
||||
if (CenterChunk != LastUpdateCenter)
|
||||
{
|
||||
LastUpdateCenter = CenterChunk;
|
||||
bAllChunksLoaded = false;
|
||||
|
||||
DesiredSorted.Reset();
|
||||
DesiredSet.Reset();
|
||||
BuildDesiredTiles(CenterChunk);
|
||||
|
||||
for (int32 OffsetX = -ViewXY; OffsetX <= ViewXY; OffsetX++)
|
||||
for (int32 OffsetY = -ViewXY; OffsetY <= ViewXY; OffsetY++)
|
||||
for (int32 OffsetZ = -ViewDown; OffsetZ <= ViewUp; OffsetZ++)
|
||||
// Cull loaded tiles that are no longer desired — STRICT LOAD-BEFORE-UNLOAD so crossing a
|
||||
// shell boundary NEVER leaves a hole and we never drop a tile before its replacement exists:
|
||||
// - out of clip range → left the view entirely, no replacement coming → cull now.
|
||||
// - in range (mid-LOD-transition) → cull ONLY once EVERY desired tile that overlaps its
|
||||
// footprint is loaded. A coarse tile is replaced by several finer tiles; the old
|
||||
// center-owner check culled it as soon as the ONE tile over its centre was ready, so the
|
||||
// not-yet-ready edges flashed a hole. Checking the whole covering set fixes that: the old
|
||||
// tile stays at its current resolution until the better mesh is fully in, then drops.
|
||||
auto FootprintsOverlap = [](const FVoxelTileKey& A, const FVoxelTileKey& B) -> bool
|
||||
{
|
||||
const FIntVector C = CenterChunk + FIntVector(OffsetX, OffsetY, OffsetZ);
|
||||
if (IsChunkInRange(C, CenterChunk))
|
||||
const int32 ea = A.ExtentVoxels(), eb = B.ExtentVoxels();
|
||||
const FIntVector aMin = A.OriginVoxels(), bMin = B.OriginVoxels();
|
||||
return aMin.X < bMin.X + eb && bMin.X < aMin.X + ea
|
||||
&& aMin.Y < bMin.Y + eb && bMin.Y < aMin.Y + ea
|
||||
&& aMin.Z < bMin.Z + eb && bMin.Z < aMin.Z + ea;
|
||||
};
|
||||
// Only a desired tile that ISN'T loaded yet can block a cull (an old tile must stay until its
|
||||
// replacement is in). That set is small — just the few newly-needed tiles this crossing — so
|
||||
// build it ONCE and test each candidate against it (was: scan ALL of DesiredSorted per tile,
|
||||
// an O(loaded×desired) game-thread spike when fast movement turns many tiles non-desired).
|
||||
// "Every covering desired tile loaded" ⟺ "no unloaded desired tile overlaps T" — equivalent.
|
||||
TArray<FVoxelTileKey> DesiredPending;
|
||||
for (const FVoxelTileKey& D : DesiredSorted)
|
||||
{
|
||||
if (!LoadedTiles.Contains(D)) DesiredPending.Add(D);
|
||||
}
|
||||
auto ReplacementsReady = [&](const FVoxelTileKey& T) -> bool
|
||||
{
|
||||
for (const FVoxelTileKey& D : DesiredPending)
|
||||
{
|
||||
DesiredSorted.Add(C);
|
||||
DesiredSet.Add(C);
|
||||
if (FootprintsOverlap(T, D)) return false; // a covering tile isn't ready → keep T
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Nearest-first so the closest chunks stream in before distant ones.
|
||||
DesiredSorted.Sort([&CenterChunk](const FIntVector& A, const FIntVector& B)
|
||||
{
|
||||
const FIntVector DA = A - CenterChunk;
|
||||
const FIntVector DB = B - CenterChunk;
|
||||
return (DA.X * DA.X + DA.Y * DA.Y + DA.Z * DA.Z)
|
||||
< (DB.X * DB.X + DB.Y * DB.Y + DB.Z * DB.Z);
|
||||
});
|
||||
// ReplacementsReady is O(DesiredPending); calling it for every loaded tile is O(loaded × pending),
|
||||
// which goes QUADRATIC exactly when streaming falls behind (sprinting → DesiredPending balloons) —
|
||||
// the measured 53 ms/cross CullTiles spike and a death spiral (behind → stall → further behind).
|
||||
// KEEPING a transition tile longer is always hole-safe (the conservative direction), so when pending
|
||||
// is large we skip the overlap test and just keep in-range transition tiles; the settled cull (once
|
||||
// bAllChunksLoaded) + ProcessUnloadQueue reclaim them when streaming catches up. Out-of-range tiles
|
||||
// still cull unconditionally (bounds memory). This caps the cull at O(loaded) and breaks the spiral.
|
||||
const bool bDoOverlapCull = DesiredPending.Num() <= 48;
|
||||
|
||||
// Cull pass — O(loaded) thanks to the TSet (was O(loaded × desired)).
|
||||
TArray<FIntVector> ChunksToRemove;
|
||||
for (const auto& Pair : Chunks)
|
||||
TArray<FVoxelTileKey> ToRemove;
|
||||
auto Consider = [&](const FVoxelTileKey& T)
|
||||
{
|
||||
if (!DesiredSet.Contains(Pair.Key))
|
||||
{
|
||||
ChunksToRemove.Add(Pair.Key);
|
||||
}
|
||||
}
|
||||
for (const FIntVector& C : ChunksToRemove)
|
||||
if (DesiredSet.Contains(T)) return;
|
||||
// T comes from TileComponents keys then LoadedTiles-not-in-TileComponents → never twice.
|
||||
if (!IsTileInClipRange(T, CenterChunk)) { ToRemove.Add(T); return; } // left the view → cull now
|
||||
if (bDoOverlapCull && ReplacementsReady(T)) ToRemove.Add(T); // transition → cull when safe
|
||||
};
|
||||
{
|
||||
UnloadChunk(C);
|
||||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_CullTiles);
|
||||
for (const auto& Pair : TileComponents) Consider(Pair.Key);
|
||||
for (const FVoxelTileKey& T : LoadedTiles) { if (!TileComponents.Contains(T)) Consider(T); }
|
||||
// Defer the actual teardown — ProcessUnloadQueue spreads it across frames so a fast
|
||||
// traversal's whole-shell cull doesn't destroy dozens of components + actors in one frame.
|
||||
for (const FVoxelTileKey& T : ToRemove) PendingUnload.Add(T);
|
||||
}
|
||||
|
||||
// LOD reconciliation for chunks that stayed in view is handled by the persistent
|
||||
// budgeted loop below — NOT as a one-shot here. Doing it once on the boundary-cross
|
||||
// frame stranded any chunk skipped by a full task budget at its stale LOD; the
|
||||
// persistent loop retries on later frames until every chunk sits at its target LOD.
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// Submit pending work (budgeted). Runs each frame until everything desired is
|
||||
// streamed in AT ITS TARGET LOD, then goes idle until the player moves again.
|
||||
// Handles BOTH missing chunks (load) and loaded-but-wrong-LOD chunks (hot-swap
|
||||
// re-mesh) in one persistent, budget-retried pass — so nothing is stranded.
|
||||
// Submit pending work (budgeted, nearest-first). Once everything desired is loaded,
|
||||
// do the "settled" cull of the deferred LOD-transition tiles, then go idle.
|
||||
//=========================================================================
|
||||
if (!bAllChunksLoaded)
|
||||
{
|
||||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_SubmitTiles);
|
||||
int32 Submitted = 0;
|
||||
for (const FIntVector& C : DesiredSorted)
|
||||
for (const FVoxelTileKey& T : DesiredSorted)
|
||||
{
|
||||
// Budget full → stop submitting this frame. Pending > 0 keeps us out of the
|
||||
// idle state below, so we resume next frame (nearest-first, so closest first).
|
||||
if (PendingChunkCoord.Num() >= MaxTasks) break;
|
||||
if (PendingChunkCoord.Contains(C)) continue; // already in flight
|
||||
|
||||
bool bNeedsWork;
|
||||
if (Chunks.Contains(C))
|
||||
{
|
||||
// Loaded — re-mesh only if its LOD no longer matches the current center.
|
||||
// Hot-swap (LoadChunk, never unload-first): the old mesh stays visible
|
||||
// until the new one lands, so no hole/pop during the swap.
|
||||
const int32 DesiredLOD = GetLODForChunk(C, CurrentCenterChunk);
|
||||
const int32* CurrentLOD = ChunkLODs.Find(C);
|
||||
bNeedsWork = (CurrentLOD && *CurrentLOD != DesiredLOD);
|
||||
}
|
||||
else
|
||||
{
|
||||
bNeedsWork = true; // not loaded yet
|
||||
}
|
||||
if (!bNeedsWork) continue;
|
||||
|
||||
LoadChunk(C);
|
||||
if (PendingTiles.Num() >= MaxTasks) break;
|
||||
if (PendingTiles.Contains(T)) continue; // in flight
|
||||
if (LoadedTiles.Contains(T)) continue; // already loaded (level is in the key — no LOD remesh)
|
||||
LoadTile(T);
|
||||
++Submitted;
|
||||
}
|
||||
|
||||
// Idle only after a FULL scan submitted nothing and nothing is in flight — i.e.
|
||||
// every desired chunk is loaded at its target LOD. (We only break early when the
|
||||
// budget is full, which leaves Pending > 0, so this can't fire prematurely.)
|
||||
if (Submitted == 0 && PendingChunkCoord.Num() == 0)
|
||||
if (Submitted == 0 && PendingTiles.Num() == 0)
|
||||
{
|
||||
// Everything desired is loaded → load-before-unload is satisfied: drop the
|
||||
// deferred (in-range, not-desired) transition tiles now. No holes.
|
||||
for (const auto& Pair : TileComponents)
|
||||
if (!DesiredSet.Contains(Pair.Key)) PendingUnload.Add(Pair.Key);
|
||||
for (const FVoxelTileKey& T : LoadedTiles)
|
||||
if (!DesiredSet.Contains(T) && !TileComponents.Contains(T)) PendingUnload.Add(T);
|
||||
// Teardown is drained by ProcessUnloadQueue (budgeted) — single spike-free path.
|
||||
|
||||
bAllChunksLoaded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void AVoxelWorld::LoadChunk(const FIntVector& ChunkCoord)
|
||||
void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile)
|
||||
{
|
||||
if (PendingChunkCoord.Contains(ChunkCoord)) return;
|
||||
if (PendingTiles.Contains(Tile)) return;
|
||||
|
||||
const int32 MaxTasks = Settings ? Settings->MaxConcurrentTasks : 16;
|
||||
if (PendingChunkCoord.Num() >= MaxTasks)
|
||||
if (PendingTiles.Num() >= MaxTasks)
|
||||
{
|
||||
return; // Budget plein — on attend qu'une tâche finisse
|
||||
return; // Budget full — wait for a task to finish.
|
||||
}
|
||||
PendingChunkCoord.Add(ChunkCoord);
|
||||
PendingTiles.Add(Tile);
|
||||
|
||||
const int32 LODLevel = GetLODForChunk(ChunkCoord, CurrentCenterChunk);
|
||||
const int32 Step = LODToStep(LODLevel);
|
||||
const FIntVector OriginVoxels = Tile.OriginVoxels(); // min corner, voxel coords
|
||||
|
||||
// Epoch capturé pour détecter les résultats périmés (après un RegenerateAll)
|
||||
// Coarse levels mesh with FEWER cells → much cheaper gen (blockier far field, which is far
|
||||
// away + skirts hide the seams). Near levels (< FullResClipLevels) stay full CHUNK_SIZE cells.
|
||||
// Extent stays CHUNK_SIZE<<Level (so the shell tiling is unchanged) — only the cell count and
|
||||
// step change: Step = Extent / Cells.
|
||||
const int32 FullRes = Settings ? FMath::Max(1, Settings->FullResClipLevels) : 2;
|
||||
const int32 Cells = (Tile.Level < FullRes)
|
||||
? CHUNK_SIZE
|
||||
: (Settings ? FMath::Clamp(Settings->CoarseTileCells, 4, CHUNK_SIZE) : 16);
|
||||
const int32 Extent = CHUNK_SIZE << Tile.Level;
|
||||
const int32 Step = FMath::Max(1, Extent / Cells);
|
||||
const uint32 TaskEpoch = GenerationEpoch;
|
||||
|
||||
ActiveTaskCount.fetch_add(1, std::memory_order_relaxed);
|
||||
|
||||
UE::Tasks::Launch(TEXT("ChunkGen"), [this, ChunkCoord, Step, LODLevel, TaskEpoch]()
|
||||
// BackgroundNormal priority: gen runs on background workers that YIELD to foreground
|
||||
// (game/render-thread) tasks. Without this, raising MaxConcurrentTasks past the spare
|
||||
// core count saturates the scheduler and starves the frame (the "over 12 = lag" symptom).
|
||||
// At background priority the frame keeps its cores; gen just fills in around it.
|
||||
UE::Tasks::Launch(TEXT("ChunkGen"), [this, Tile, OriginVoxels, Step, Cells, TaskEpoch]()
|
||||
{
|
||||
// RAII: décrément du compteur quel que soit le chemin de sortie
|
||||
// RAII: decrement the counter on every exit path.
|
||||
struct FTaskGuard
|
||||
{
|
||||
std::atomic<int32>& Counter;
|
||||
@@ -598,251 +778,169 @@ void AVoxelWorld::LoadChunk(const FIntVector& ChunkCoord)
|
||||
|
||||
if (bShuttingDown.load(std::memory_order_relaxed)) return;
|
||||
|
||||
// Le mesher lit la densité directement depuis Generator — pas de
|
||||
// "generate chunk" préliminaire: le chunk est juste un wrapper de coord.
|
||||
const FVoxelChunk Chunk(ChunkCoord);
|
||||
|
||||
FChunkResult Result;
|
||||
Result.ChunkCoord = ChunkCoord;
|
||||
Result.Chunk = Chunk;
|
||||
Result.LODLevel = LODLevel;
|
||||
Result.Epoch = TaskEpoch;
|
||||
Result.Tile = Tile;
|
||||
Result.Epoch = TaskEpoch;
|
||||
{
|
||||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_GenerateMesh);
|
||||
Result.MeshData = Mesher->GenerateMesh(Chunk, Step);
|
||||
Result.MeshData = Mesher->GenerateMesh(OriginVoxels, Step, Cells);
|
||||
}
|
||||
|
||||
if (!bShuttingDown.load(std::memory_order_relaxed))
|
||||
{
|
||||
ProcessQueue.Enqueue(Result);
|
||||
}
|
||||
});
|
||||
}, UE::Tasks::ETaskPriority::BackgroundNormal);
|
||||
}
|
||||
|
||||
void AVoxelWorld::UnloadChunk(const FIntVector& ChunkCoord)
|
||||
void AVoxelWorld::UnloadTile(const FVoxelTileKey& Tile)
|
||||
{
|
||||
// Destroy any spawned content (decorations + water) before the mesh goes.
|
||||
if (ContentManager)
|
||||
// Water + decorations are no longer tile-bound (water is one player-following ocean plane via
|
||||
// UpdateWater; decorations stream by distance via UpdateDecorations) — nothing to clear per tile.
|
||||
if (URealtimeMeshComponent** Comp = TileComponents.Find(Tile))
|
||||
{
|
||||
ContentManager->ClearChunk(ChunkCoord);
|
||||
}
|
||||
|
||||
if (ChunkMeshes.Contains(ChunkCoord)) {
|
||||
ChunkMeshes[ChunkCoord]->DestroyComponent();
|
||||
ChunkMeshes.Remove(ChunkCoord);
|
||||
Chunks.Remove(ChunkCoord);
|
||||
ChunkLODs.Remove(ChunkCoord);
|
||||
if (*Comp) { (*Comp)->DestroyComponent(); }
|
||||
TileComponents.Remove(Tile);
|
||||
}
|
||||
LoadedTiles.Remove(Tile);
|
||||
PendingTiles.Remove(Tile);
|
||||
}
|
||||
|
||||
void AVoxelWorld::ApplyMeshToChunk(const FIntVector& ChunkCoord, const FVoxelMeshData& MeshData)
|
||||
void AVoxelWorld::ApplyMeshToTile(const FVoxelTileKey& Tile, const FVoxelMeshData& MeshData)
|
||||
{
|
||||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ApplyMeshToChunk);
|
||||
|
||||
//==========================================================================
|
||||
// STEP 1: EARLY EXIT IF NO MESH DATA
|
||||
//==========================================================================
|
||||
// If the chunk is all air (empty), there's nothing to render.
|
||||
// MeshData.IsEmpty() returns true if Vertices array has 0 elements.
|
||||
if (MeshData.IsEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
//==========================================================================
|
||||
// STEP 2: GET OR CREATE THE MESH COMPONENT
|
||||
//==========================================================================
|
||||
// Each chunk needs a URealtimeMeshComponent to be visible in the world.
|
||||
// We store these in the ChunkMeshes map, keyed by chunk coordinate.
|
||||
URealtimeMeshComponent* MeshComp = nullptr;
|
||||
|
||||
// Check if we already have a mesh component for this chunk
|
||||
if (ChunkMeshes.Contains(ChunkCoord))
|
||||
{
|
||||
// Reuse existing component (chunk is being updated, not created)
|
||||
MeshComp = ChunkMeshes[ChunkCoord];
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create a brand new mesh component for this chunk
|
||||
// NewObject<T>(Outer) creates a new UObject of type T
|
||||
// 'this' (AVoxelWorld) is the "outer" - it owns this component
|
||||
MeshComp = NewObject<URealtimeMeshComponent>(this);
|
||||
|
||||
// 40k+ chunk components hammer the GAME THREAD, not the GPU. Kill per-component
|
||||
// bookkeeping the engine would otherwise do every frame for each of them:
|
||||
// - overlap events: chunks never use overlap callbacks (gameplay uses raycasts
|
||||
// against the LOD0 collision), and UpdateOverlaps over tens of thousands of
|
||||
// components is a classic game-thread sink.
|
||||
// - navigation: terrain isn't navmesh-driven here.
|
||||
// (The real fix for component COUNT is the chunk-LOD clipmap; this trims the
|
||||
// per-component cost meanwhile and survives that refactor.)
|
||||
MeshComp->SetGenerateOverlapEvents(false);
|
||||
MeshComp->SetCanEverAffectNavigation(false);
|
||||
|
||||
// RegisterComponent() tells Unreal "this component is ready to use"
|
||||
// Without this, the component won't tick, render, or do anything
|
||||
MeshComp->RegisterComponent();
|
||||
|
||||
// Attach to our actor so it moves with us and inherits our transform
|
||||
MeshComp->AttachToComponent(GetRootComponent(), FAttachmentTransformRules::KeepRelativeTransform);
|
||||
|
||||
// Store in our map so we can find it later
|
||||
ChunkMeshes.Add(ChunkCoord, MeshComp);
|
||||
}
|
||||
|
||||
// Safety check
|
||||
if (!MeshComp)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
//==========================================================================
|
||||
// STEP 3: INITIALIZE THE REALTIMEMESH ASSET
|
||||
//==========================================================================
|
||||
// Component = "the renderer", MeshAsset = "the data"
|
||||
// InitializeRealtimeMesh creates or returns the asset
|
||||
URealtimeMeshSimple* RTMesh = MeshComp->InitializeRealtimeMesh<URealtimeMeshSimple>();
|
||||
if (!RTMesh)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
//==========================================================================
|
||||
// STEP 4: CREATE KEYS FOR THE MESH STRUCTURE
|
||||
//==========================================================================
|
||||
// RealtimeMesh hierarchy: Mesh -> LODs -> SectionGroups -> Sections
|
||||
//
|
||||
// LOD = Level of Detail (LOD0 = highest detail, closest to camera)
|
||||
// SectionGroup = A group of mesh sections (one per chunk)
|
||||
// Section = Actual triangle data with a material
|
||||
|
||||
const FRealtimeMeshLODKey LOD0(0); // Highest detail
|
||||
|
||||
// GroupKey = identifies our section group
|
||||
const FRealtimeMeshSectionGroupKey GroupKey =
|
||||
FRealtimeMeshSectionGroupKey::Create(LOD0, FName("ChunkMesh"));
|
||||
|
||||
// SectionKey = identifies the mesh section within group
|
||||
const FRealtimeMeshSectionKey SectionKey =
|
||||
FRealtimeMeshSectionKey::CreateForPolyGroup(GroupKey, 0);
|
||||
|
||||
//==========================================================================
|
||||
// STEP 5: REMOVE OLD GEOMETRY
|
||||
//==========================================================================
|
||||
// Clear previous data before adding new (important for chunk updates)
|
||||
RTMesh->RemoveSectionGroup(GroupKey);
|
||||
|
||||
//==========================================================================
|
||||
// STEP 6: CREATE THE MESH BUILDER
|
||||
//==========================================================================
|
||||
// StreamSet = container for vertex data, indices, normals, UVs, etc.
|
||||
// Builder = helper to easily add vertices and triangles
|
||||
//
|
||||
// Template: <IndexType, NormalType, UVType, NumUVChannels>
|
||||
RealtimeMesh::FRealtimeMeshStreamSet Streams;
|
||||
RealtimeMesh::TRealtimeMeshBuilderLocal<uint32, FPackedNormal, FVector2DHalf, 1> Builder(Streams);
|
||||
|
||||
// Enable data streams we'll use
|
||||
Builder.EnableTangents(); // Pour le normal mapping
|
||||
Builder.EnableTexCoords(); // Pour les UVs
|
||||
Builder.EnablePolyGroups(); // Pour l'assignation du matériau
|
||||
|
||||
//==========================================================================
|
||||
// STEP 7: ADD ALL VERTICES
|
||||
//==========================================================================
|
||||
|
||||
const int32 NumVertices = MeshData.Vertices.Num();
|
||||
Builder.ReserveAdditionalVertices(NumVertices); // Pre-allocate for speed
|
||||
|
||||
for (int32 i = 0; i < NumVertices; i++)
|
||||
{
|
||||
const FVector3f Position = (FVector3f)MeshData.Vertices[i];
|
||||
auto Vertex = Builder.AddVertex(Position);
|
||||
|
||||
if (MeshData.Normals.IsValidIndex(i))
|
||||
// Became empty (e.g. fully carved) — drop any prior component for this tile.
|
||||
if (URealtimeMeshComponent** C = TileComponents.Find(Tile))
|
||||
{
|
||||
const FVector3f Normal = (FVector3f)MeshData.Normals[i];
|
||||
const FVector3f Tangent = FVector3f(1, 0, 0);
|
||||
Vertex.SetNormalAndTangent(Normal, Tangent);
|
||||
if (*C) { (*C)->DestroyComponent(); }
|
||||
TileComponents.Remove(Tile);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (MeshData.UVs.IsValidIndex(i))
|
||||
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.
|
||||
bool bIsCeiling = false;
|
||||
if (Generator)
|
||||
{
|
||||
const int32 VoxelsPerTile = CHUNK_SIZE << Tile.Level;
|
||||
const FIntVector MinVoxel = Tile.Coord * VoxelsPerTile;
|
||||
const float CenterX = (float)MinVoxel.X + VoxelsPerTile * 0.5f;
|
||||
const float CenterY = (float)MinVoxel.Y + VoxelsPerTile * 0.5f;
|
||||
const float CenterZ = (float)MinVoxel.Z + VoxelsPerTile * 0.5f;
|
||||
const int32 CenterChunkZ = FMath::FloorToInt(CenterZ / (float)CHUNK_SIZE);
|
||||
|
||||
float TerrainZ = 0.0f, CeilSurf = 0.0f;
|
||||
if (Generator->GetSurfaceHeightAt(CenterX, CenterY, CenterChunkZ, TerrainZ, CeilSurf))
|
||||
{
|
||||
Vertex.SetTexCoord(0, (FVector2f)MeshData.UVs[i]);
|
||||
const float Mid = (TerrainZ + CeilSurf) * 0.5f;
|
||||
bIsCeiling = (CenterZ > Mid);
|
||||
}
|
||||
}
|
||||
|
||||
//==========================================================================
|
||||
// STEP 8: ADD ALL TRIANGLES
|
||||
//==========================================================================
|
||||
// Each triangle = 3 vertex indices
|
||||
// Our quads = 2 triangles = 6 indices each
|
||||
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 // PolyGroup 0 = material slot 0
|
||||
);
|
||||
}
|
||||
|
||||
//==========================================================================
|
||||
// STEP 9: UPLOAD TO GPU
|
||||
//==========================================================================
|
||||
// MoveTemp = transfer ownership efficiently (no copy)
|
||||
RTMesh->CreateSectionGroup(GroupKey, MoveTemp(Streams));
|
||||
|
||||
//==========================================================================
|
||||
// STEP 10: CONFIGURE SECTION
|
||||
//==========================================================================
|
||||
FRealtimeMeshSectionConfig SectionConfig(0); // Material slot 0
|
||||
SectionConfig.bIsVisible = true;
|
||||
|
||||
// Pick the material: use the strate's override if available, else the global default.
|
||||
UMaterialInterface* ChunkMaterial = Settings->VoxelMaterial;
|
||||
// Material: strate override (by the tile's min-corner chunk coord) else the global default. Ceiling
|
||||
// tiles take the strate's CeilingMaterial when set (the rocky "night sky" overhead reads flat/bright
|
||||
// otherwise, since it casts no shadow).
|
||||
UMaterialInterface* ChunkMaterial = Settings ? Settings->VoxelMaterial : nullptr;
|
||||
if (StrateManager)
|
||||
{
|
||||
const FIntVector ChunkCoord = Tile.Coord * (1 << Tile.Level); // level-0-equivalent min corner
|
||||
if (UVoxelStrateDefinition* StrateDef = StrateManager->GetStrateForChunk(ChunkCoord))
|
||||
{
|
||||
if (StrateDef->OverrideMaterial)
|
||||
{
|
||||
ChunkMaterial = StrateDef->OverrideMaterial;
|
||||
}
|
||||
if (StrateDef->OverrideMaterial) { ChunkMaterial = StrateDef->OverrideMaterial; }
|
||||
if (bIsCeiling && StrateDef->CeilingMaterial) { ChunkMaterial = StrateDef->CeilingMaterial; }
|
||||
}
|
||||
}
|
||||
|
||||
RTMesh->SetupMaterialSlot(0, "Main", ChunkMaterial);
|
||||
|
||||
// Collision only at LOD0. Distant chunks are unreachable by definition (the LOD
|
||||
// reconciliation loop §8.10 hot-swaps a chunk to LOD0 before the player can touch it),
|
||||
// so cooking Chaos tri-mesh collision for LOD1/2 is pure waste — kills the cook cost +
|
||||
// collision memory for the large majority of loaded chunks. (T1.c)
|
||||
const int32 ChunkLOD = ChunkLODs.FindRef(ChunkCoord);
|
||||
const bool bWantCollision = (ChunkLOD == 0);
|
||||
RTMesh->UpdateSectionConfig(SectionKey, SectionConfig, bWantCollision);
|
||||
|
||||
// Shadow casting: the FARTHEST tier (LOD2) does NOT cast shadows. Each shadow-casting
|
||||
// chunk adds a SECOND draw call in the shadow-depth pass (~doubles the draw cost of the
|
||||
// terrain), and crisp shadows on distant blocky LOD2 geometry aren't worth it. LOD0/1
|
||||
// keep shadows. Conservative first cut — widen to `<= 0` (LOD1 too) if still draw-bound.
|
||||
// Re-applied every time because LOD hot-swaps reuse the same component.
|
||||
MeshComp->SetCastShadow(ChunkLOD <= 1);
|
||||
|
||||
//==========================================================================
|
||||
// STEP 11: POPULATE CONTENT (decorations + water)
|
||||
//==========================================================================
|
||||
// Runs on the game thread (ProcessPendingChunks calls us here), so spawning
|
||||
// actors is safe. PopulateChunk clears any prior content for this coord first,
|
||||
// so re-meshing after a carve refreshes the scatter cleanly.
|
||||
if (ContentManager)
|
||||
// Build the geometry stream set. Vertices are world-space; the component sits at the actor origin.
|
||||
RealtimeMesh::FRealtimeMeshStreamSet Streams;
|
||||
{
|
||||
ContentManager->PopulateChunk(ChunkCoord, MeshData, ChunkLODs.FindRef(ChunkCoord));
|
||||
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*/);
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
URealtimeMeshComponent* MeshComp = TileComponents.FindRef(Tile);
|
||||
if (!MeshComp)
|
||||
{
|
||||
MeshComp = NewObject<URealtimeMeshComponent>(this);
|
||||
// Generated once, never moves → Static so RMC's cached static draw path + VSM shadow
|
||||
// caching apply (see the root SetMobility note in BeginPlay). Must be set before register.
|
||||
// Re-mesh on carve recreates the section-group proxy (RMC's Static path already does this),
|
||||
// which is fine for an infrequent action.
|
||||
MeshComp->SetMobility(EComponentMobility::Static);
|
||||
MeshComp->SetGenerateOverlapEvents(false); // chunks use raycasts, not overlaps
|
||||
MeshComp->SetCanEverAffectNavigation(false);
|
||||
MeshComp->RegisterComponent();
|
||||
MeshComp->AttachToComponent(GetRootComponent(), FAttachmentTransformRules::KeepRelativeTransform);
|
||||
TileComponents.Add(Tile, MeshComp);
|
||||
}
|
||||
|
||||
URealtimeMeshSimple* RTMesh = MeshComp->InitializeRealtimeMesh<URealtimeMeshSimple>();
|
||||
if (!RTMesh) { return; }
|
||||
// Shadow casting: far (level >= 2) tiles never cast; the SurfaceWorld SKY-CAP CEILING never casts
|
||||
// either — otherwise the high rock ceiling shadows the entire terrain below it (one mesh, so we
|
||||
// can't split it). bIsCeiling was classified above via the O(1) oracle.
|
||||
const bool bCastShadow = (Tile.Level <= 1) && !bIsCeiling;
|
||||
MeshComp->SetCastShadow(bCastShadow);
|
||||
|
||||
const FRealtimeMeshSectionGroupKey GroupKey =
|
||||
FRealtimeMeshSectionGroupKey::Create(FRealtimeMeshLODKey(0), FName("Tile"));
|
||||
RTMesh->RemoveSectionGroup(GroupKey); // clear old geometry on re-mesh
|
||||
RTMesh->SetupMaterialSlot(0, "Main", ChunkMaterial);
|
||||
RTMesh->CreateSectionGroup(GroupKey, MoveTemp(Streams));
|
||||
|
||||
FRealtimeMeshSectionConfig SectionConfig(0);
|
||||
// RMC casts shadows PER SECTION (FRealtimeMeshSectionConfig::bCastsShadow, default true) — the
|
||||
// component-level UPrimitiveComponent::CastShadow is NOT honored by the RMC proxy. So the real
|
||||
// shadow lever is here: drive the section flag from the same decision (far tiles + sky-cap ceiling
|
||||
// → no cast). SetCastShadow above is kept only to keep the component flag consistent.
|
||||
SectionConfig.bCastsShadow = bCastShadow;
|
||||
RTMesh->UpdateSectionConfig(
|
||||
FRealtimeMeshSectionKey::CreateForPolyGroup(GroupKey, 0),
|
||||
SectionConfig, /*bShouldCreateCollision*/ bLevel0); // collision at level 0 only (T1.c)
|
||||
|
||||
// Water is no longer spawned per tile — it's a single player-following ocean plane (UpdateWater,
|
||||
// driven from Tick), so it renders at every LOD and to the horizon with no per-tile gaps.
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
@@ -1173,56 +1271,21 @@ void AVoxelWorld::RemeshDirtyChunks(const TArray<FIntVector>& DirtyCoords)
|
||||
// Chunks that aren't loaded are ignored — when they eventually load
|
||||
// through normal streaming, they'll include the diff layer automatically.
|
||||
|
||||
// Edits only affect LEVEL-0 tiles (collision + visible detail are full-res near the player;
|
||||
// coarse far tiles sample too sparsely to show small carves, and pick up the diff naturally
|
||||
// when they next stream). Re-queue the loaded level-0 tile for each dirty coord — LoadTile
|
||||
// re-runs gen (density includes the DiffLayer via GetDensityAt) and ProcessPendingChunks
|
||||
// updates the existing component in place (old mesh stays visible until then, no pop).
|
||||
const int32 MaxTasks = Settings ? Settings->MaxConcurrentTasks : 16;
|
||||
for (const FIntVector& Coord : DirtyCoords)
|
||||
{
|
||||
// Only remesh chunks that are actually loaded
|
||||
if (!Chunks.Contains(Coord)) continue;
|
||||
|
||||
// Skip if already queued for generation (avoid double-submit)
|
||||
if (PendingChunkCoord.Contains(Coord)) continue;
|
||||
|
||||
// Check concurrent task budget
|
||||
const int32 MaxTasks = Settings ? Settings->MaxConcurrentTasks : 16;
|
||||
if (PendingChunkCoord.Num() >= MaxTasks) break;
|
||||
|
||||
PendingChunkCoord.Add(Coord);
|
||||
|
||||
// Use existing LOD for this chunk (it hasn't moved, just modified)
|
||||
const int32* CurrentLOD = ChunkLODs.Find(Coord);
|
||||
const int32 LODLevel = CurrentLOD ? *CurrentLOD : 0;
|
||||
const int32 Step = LODToStep(LODLevel);
|
||||
const uint32 TaskEpoch = GenerationEpoch;
|
||||
|
||||
ActiveTaskCount.fetch_add(1, std::memory_order_relaxed);
|
||||
|
||||
UE::Tasks::Launch(TEXT("ChunkRemesh"), [this, Coord, Step, LODLevel, TaskEpoch]()
|
||||
{
|
||||
struct FTaskGuard
|
||||
{
|
||||
std::atomic<int32>& Counter;
|
||||
~FTaskGuard() { Counter.fetch_sub(1, std::memory_order_relaxed); }
|
||||
} Guard{ActiveTaskCount};
|
||||
|
||||
if (bShuttingDown.load(std::memory_order_relaxed)) return;
|
||||
|
||||
// Re-mesh: la densité inclut automatiquement le DiffLayer
|
||||
// (carves du joueur) puisque le générateur le consulte dans GetDensityAt.
|
||||
const FVoxelChunk Chunk(Coord);
|
||||
|
||||
FChunkResult Result;
|
||||
Result.ChunkCoord = Coord;
|
||||
Result.Chunk = Chunk;
|
||||
Result.LODLevel = LODLevel;
|
||||
Result.Epoch = TaskEpoch;
|
||||
Result.MeshData = Mesher->GenerateMesh(Chunk, Step);
|
||||
|
||||
if (!bShuttingDown.load(std::memory_order_relaxed))
|
||||
{
|
||||
ProcessQueue.Enqueue(Result);
|
||||
}
|
||||
});
|
||||
const FVoxelTileKey Tile(Coord, 0);
|
||||
if (!LoadedTiles.Contains(Tile)) continue; // only re-mesh loaded full-res tiles
|
||||
if (PendingTiles.Contains(Tile)) continue; // already queued
|
||||
if (PendingTiles.Num() >= MaxTasks) break; // task budget
|
||||
LoadTile(Tile);
|
||||
}
|
||||
|
||||
UE_LOG(LogTemp, Verbose, TEXT("[VoxelWorld] RemeshDirtyChunks: %d coords, %d queued"),
|
||||
DirtyCoords.Num(), PendingChunkCoord.Num());
|
||||
UE_LOG(LogTemp, Verbose, TEXT("[VoxelWorld] RemeshDirtyChunks: %d coords, %d pending"),
|
||||
DirtyCoords.Num(), PendingTiles.Num());
|
||||
}
|
||||
|
||||
@@ -1,40 +1,48 @@
|
||||
// VoxelContentManager.h
|
||||
// Per-chunk content population: deterministic decoration/actor scatter from the
|
||||
// strate content pools, plus self-contained aesthetic water surfaces.
|
||||
// Decoration scatter (distance-based world grid, ASYNC surface march) + aesthetic water surfaces.
|
||||
//
|
||||
// LIFECYCLE (driven by AVoxelWorld on the GAME THREAD):
|
||||
// - PopulateChunk(coord, meshdata) after a chunk's mesh is applied
|
||||
// - ClearChunk(coord) when a chunk unloads / is re-meshed
|
||||
// - ClearAll() on regenerate / season reset
|
||||
// - SetActiveStrate(playerStrate) each Tick (no-op unless the strate changed)
|
||||
// TWO INDEPENDENT SUBSYSTEMS:
|
||||
//
|
||||
// DETERMINISM: every placement decision is a pure hash of (chunk, surface index,
|
||||
// entry index, seed), so the same world re-populates identically. Spawning itself
|
||||
// must run on the game thread (UWorld::SpawnActor), which it does — ApplyMeshToChunk
|
||||
// is game-thread.
|
||||
// 1) DECORATIONS — distance-based WORLD GRID (the no-pop system).
|
||||
// Decorations are placed on a fixed world XY cell grid (1 cell = 1 chunk footprint) and streamed
|
||||
// by DISTANCE from the player, completely decoupled from clipmap tiles / LOD. Each candidate column
|
||||
// is ray-marched vertically through the player's strate Z-band via UVoxelGenerator::GetDensityAt and
|
||||
// snapped to the real surface crossings. Every placement is a pure hash of (cell, column, crossing,
|
||||
// entry, seed) + the density surface snap → a prop sits at the SAME world position no matter which
|
||||
// LOD tile meshes the ground under it: NO pop/teleport on LOD swaps.
|
||||
//
|
||||
// RENDERING PATHS per decoration entry (FStrateDecoration):
|
||||
// - ActorClass → real actors (lights, logic, interaction). Keep MaxLODLevel=0.
|
||||
// - InstancedMesh → HISM instances batched per chunk: no tick, no per-actor cost,
|
||||
// engine-culled. Safe to allow at LOD 1-2 so visual props don't
|
||||
// pop out with LOD0 (emissive materials still glow at distance).
|
||||
// THREADING (critical): the column ray-march is EXPENSIVE (many GetDensityAt per column). It runs on
|
||||
// a WORKER thread (UE::Tasks, like mesh gen — GetDensityAt is thread-safe), producing a list of spawn
|
||||
// commands. Only the SpawnActor/AddInstance (which MUST be game-thread) happens on the game thread,
|
||||
// budgeted. The game thread never does decoration density math. Flow (UpdateDecorations each Tick):
|
||||
// - recompute desired cell set on player cell-boundary / strate change
|
||||
// - LaunchDecoTasks: fire async march tasks (capped by MaxConcurrentDecorationTasks)
|
||||
// - ProcessDecoResults: drain finished tasks' results, apply (spawn) budgeted, epoch-guarded
|
||||
// Decorations exist ONLY in the player's current strate (march is strate-bounded) → a strate change
|
||||
// wipes + rebuilds them, and there is no cross-strate light bleed to cull.
|
||||
//
|
||||
// STRATE LIGHT CULLING: a light in another strate can never legitimately reach the
|
||||
// player (seals + bedrock gap are solid), but a shadowless light BLEEDS through rock
|
||||
// and a shadowed one pays full cost to render black. So light components on spawned
|
||||
// decoration actors are only visible while the player is in the SAME strate — the
|
||||
// analytical form of occlusion culling, computed once per strate change.
|
||||
// 2) WATER — ONE strate-global ocean plane that follows the player (UpdateWater). Terrain pokes
|
||||
// through it, so it reads as water at every LOD / to the horizon with no per-tile gaps. One draw.
|
||||
//
|
||||
// RENDERING PATHS / DISTANCE TIERS per entry (FStrateDecoration): non-instanced ActorClass entries with
|
||||
// MaxLODLevel==0 are near-only (DecorationActorRadiusChunks — pricey actors stay close); InstancedMesh
|
||||
// (HISM) entries + MaxLODLevel>=1 actor entries are any-distance (DecorationRadiusChunks).
|
||||
//
|
||||
// DETERMINISM: same seed + world ⇒ identical placement. Spawning runs on the game thread.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Containers/Queue.h"
|
||||
#include "VoxelTypes.h"
|
||||
#include "VoxelStrateTypes.h" // FStrateDecoration (resolved per dominant biome)
|
||||
#include <atomic>
|
||||
#include "VoxelContentManager.generated.h"
|
||||
|
||||
class UVoxelStrateManager;
|
||||
class UVoxelStrateDefinition;
|
||||
class UVoxelGenerator;
|
||||
class UVoxelSettings;
|
||||
class UStaticMesh;
|
||||
class UStaticMeshComponent;
|
||||
class UHierarchicalInstancedStaticMeshComponent;
|
||||
@@ -47,39 +55,121 @@ class VOXELFORGE_API UVoxelContentManager : public UObject
|
||||
|
||||
public:
|
||||
/** Wire up services. Owner is the AVoxelWorld actor that owns spawned content.
|
||||
* Generator supplies the dominant-biome query for per-biome content selection. */
|
||||
* Generator supplies GetDensityAt (surface snapping) + GetDominantBiomeAt (per-biome content).
|
||||
* Settings supplies the decoration grid tunables (radii / spacing / march / budget). */
|
||||
void Initialize(AActor* InOwner, UVoxelStrateManager* InStrateManager,
|
||||
UVoxelGenerator* InGenerator, int32 InSeed);
|
||||
UVoxelGenerator* InGenerator, UVoxelSettings* InSettings, int32 InSeed);
|
||||
|
||||
/** Update the seed used for placement hashing (season reset). */
|
||||
void SetSeed(int32 InSeed) { Seed = InSeed; }
|
||||
|
||||
/** Populate decorations + water for a chunk. Clears any previous content first.
|
||||
* Each decoration entry spawns only while LOD <= its MaxLODLevel; water at any LOD. */
|
||||
void PopulateChunk(const FIntVector& ChunkCoord, const FVoxelMeshData& MeshData, int32 LODLevel = 0);
|
||||
//--- WATER (single strate-global ocean plane, follows the player) ---------
|
||||
/** Maintain ONE large water plane at the player strate's water level, centred on the player
|
||||
* (snapped to a coarse grid). Terrain pokes through it, so it reads as water at EVERY LOD and
|
||||
* out to the horizon with no per-tile gaps (the old per-tile planes only appeared on tiles that
|
||||
* had terrain geometry in the water band → gaps over deep water + level-0 only). One draw, cheap.
|
||||
* Call every Tick. Uses the strate's WaterMaterial (per-biome override not applied to the global
|
||||
* plane). */
|
||||
void UpdateWater(const FVector& PlayerWorldPos);
|
||||
|
||||
/** Destroy all spawned content (actors + instances + water plane) for one chunk. */
|
||||
void ClearChunk(const FIntVector& ChunkCoord);
|
||||
//--- DECORATIONS (distance-based world grid, async march) ----------------
|
||||
/** Stream decoration cells around the player: recompute the desired set on cell/strate change,
|
||||
* launch async march tasks (capped), and apply finished results budgeted. Call every Tick. */
|
||||
void UpdateDecorations(const FVector& PlayerWorldPos);
|
||||
|
||||
/** Destroy all spawned content for every chunk. */
|
||||
/** Destroy all spawned content (decorations + water). Regenerate / season reset. Bumps the deco
|
||||
* epoch so any in-flight march tasks' results are discarded. */
|
||||
void ClearAll();
|
||||
|
||||
/** Player strate changed → toggle decoration lights (strict: lights only live in the
|
||||
* player's strate). Cheap no-op while the strate stays the same — call every Tick. */
|
||||
void SetActiveStrate(int32 PlayerStrateIndex);
|
||||
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
|
||||
* UObject teardown (worker tasks read the Generator). */
|
||||
void NotifyShutdown();
|
||||
|
||||
//--- async-task plumbing (public so the worker lambda can reach them) -----
|
||||
/** One placement decided off-thread; spawned on the game thread from FDecoCellResult::Entries. */
|
||||
struct FDecoSpawn
|
||||
{
|
||||
int32 EntryIdx = 0;
|
||||
bool bInstanced = false;
|
||||
FTransform Xf = FTransform::Identity;
|
||||
};
|
||||
/** A finished cell march: a snapshot of the resolved decoration list + the spawn commands. */
|
||||
struct FDecoCellResult
|
||||
{
|
||||
FIntPoint Cell = FIntPoint::ZeroValue;
|
||||
uint32 BuildId = 0; // identity of the region build this cell belongs to
|
||||
TArray<FStrateDecoration> Entries; // snapshot the game thread spawns from (by EntryIdx)
|
||||
TArray<FDecoSpawn> Spawns;
|
||||
};
|
||||
|
||||
private:
|
||||
void SpawnDecorations(const FIntVector& ChunkCoord, const FVoxelMeshData& MeshData,
|
||||
const TArray<FStrateDecoration>& Decorations, int32 LODLevel,
|
||||
TArray<TWeakObjectPtr<AActor>>& Out);
|
||||
void SpawnWater(const FIntVector& ChunkCoord, const UVoxelStrateDefinition* Def,
|
||||
UMaterialInterface* WaterMaterial);
|
||||
// Per-REGION spawned content (weak — actors live in the level, components owned by the owner actor).
|
||||
// A region groups RxR cells (DecorationRegionSizeCells); ALL placements in the region share ONE HISM
|
||||
// per mesh, so the render thread walks ~R^2 fewer components. Regions load/unload as a unit, so
|
||||
// clearing is a plain DestroyComponent — no per-instance RemoveInstances index remapping.
|
||||
struct FDecoRegionContent
|
||||
{
|
||||
TArray<TWeakObjectPtr<AActor>> Actors;
|
||||
TArray<TWeakObjectPtr<UHierarchicalInstancedStaticMeshComponent>> Instances;
|
||||
};
|
||||
|
||||
/** Strate index a chunk belongs to (center-Z lookup). */
|
||||
int32 GetChunkStrateIndex(const FIntVector& ChunkCoord) const;
|
||||
// Instanced transforms for one mesh, accumulated across all of a region's cells → one batched HISM.
|
||||
struct FRegionMeshBucket
|
||||
{
|
||||
FStrateDecoration Deco; // representative entry (mesh + HISM render tuning: cull/shadow/scale)
|
||||
TArray<FTransform> Xforms;
|
||||
};
|
||||
// A non-instanced actor placement, spawned when the region is applied.
|
||||
struct FRegionActorSpawn
|
||||
{
|
||||
TSubclassOf<AActor> ActorClass;
|
||||
FTransform Xf = FTransform::Identity;
|
||||
};
|
||||
// A region being assembled: cell march results merged in as they land; APPLIED (HISMs built, actors
|
||||
// spawned) only once every cell has reported, so the whole region is one batched build per mesh.
|
||||
struct FDecoRegionBuild
|
||||
{
|
||||
TMap<TWeakObjectPtr<UStaticMesh>, FRegionMeshBucket> MeshBuckets;
|
||||
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
|
||||
};
|
||||
|
||||
// Constant per-update strate context (a strate is a horizontal slab → same for every cell). Carries
|
||||
// only PODs/Z-bounds so it is safe to copy into a worker task (no UObject deref on the worker).
|
||||
struct FDecoContext
|
||||
{
|
||||
const UVoxelStrateDefinition* Def = nullptr; // game-thread only (biome/decoration resolve)
|
||||
int32 RepChunkZ = 0; // representative chunk-Z for biome lookups
|
||||
float TopVoxelZ = 0.0f; // strate band, voxel coords (march range)
|
||||
float BottomVoxelZ = 0.0f;
|
||||
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
|
||||
};
|
||||
|
||||
/** 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. */
|
||||
static void BuildCellSpawns(const UVoxelGenerator* Gen, const FTransform& OwnerXf,
|
||||
const FIntPoint& Cell, const FDecoContext& Ctx,
|
||||
const TArray<FStrateDecoration>& Entries, uint32 InSeed,
|
||||
int32 Spacing, float Step, int32 MaxCrossings, float ColumnDepth,
|
||||
TArray<FDecoSpawn>& OutSpawns);
|
||||
|
||||
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 ApplyRegion(const FIntPoint& Region, FDecoRegionBuild& Build);
|
||||
void RebuildDesiredCells(const FIntPoint& PlayerCell);
|
||||
void ClearDecorationRegion(const FIntPoint& Region);
|
||||
void ClearAllDecorations();
|
||||
// Region size in cells, clamped (>=1). Cell↔region math lives in file-static helpers in the .cpp.
|
||||
int32 RegionSize() const;
|
||||
|
||||
/** Show/hide every light component on a decoration actor. */
|
||||
static void SetActorLightsEnabled(AActor* Actor, bool bEnabled);
|
||||
|
||||
TWeakObjectPtr<AActor> Owner;
|
||||
|
||||
@@ -89,23 +179,54 @@ private:
|
||||
UPROPERTY()
|
||||
UVoxelGenerator* Generator = nullptr;
|
||||
|
||||
int32 Seed = 0;
|
||||
UPROPERTY()
|
||||
UVoxelSettings* Settings = nullptr;
|
||||
|
||||
// Player's current strate (INT32_MIN until first SetActiveStrate). Lights on spawned
|
||||
// actors are enabled iff their chunk's strate matches this.
|
||||
int32 ActiveStrateIndex = INT32_MIN;
|
||||
int32 Seed = 0;
|
||||
|
||||
// Engine unit plane (/Engine/BasicShapes/Plane) reused for every water surface.
|
||||
UPROPERTY()
|
||||
UStaticMesh* PlaneMesh = nullptr;
|
||||
|
||||
// Spawned decoration/ambient actors per chunk (weak — they live in the level).
|
||||
TMap<FIntVector, TArray<TWeakObjectPtr<AActor>>> SpawnedActors;
|
||||
// Loaded decoration regions (FIntPoint = region XY). One HISM per mesh per region. Not a UPROPERTY
|
||||
// (weak ptrs inside; the owner actor keeps the components alive).
|
||||
TMap<FIntPoint, FDecoRegionContent> DecoRegions;
|
||||
|
||||
// HISM components per chunk (weak — registered components are owned by the actor).
|
||||
TMap<FIntVector, TArray<TWeakObjectPtr<UHierarchicalInstancedStaticMeshComponent>>> ChunkInstances;
|
||||
// Regions currently being marched cell-by-cell; merged here until every cell reports, then applied.
|
||||
TMap<FIntPoint, FDecoRegionBuild> RegionBuilds;
|
||||
|
||||
// Water surface component per chunk.
|
||||
// Cells that are desired but need a march task launched (nearest-first).
|
||||
TArray<FIntPoint> PendingLaunch;
|
||||
// Cells with a march task in flight (awaiting a result).
|
||||
TSet<FIntPoint> InFlightCells;
|
||||
|
||||
// Worker tasks enqueue here (Mpsc: many workers, one game-thread consumer).
|
||||
TQueue<FDecoCellResult, EQueueMode::Mpsc> DecoResults;
|
||||
// Regions whose last cell just landed, awaiting budgeted game-thread apply (HISM build + actor spawn).
|
||||
TArray<FIntPoint> CompletedRegions;
|
||||
|
||||
// Monotonic id stamped on each region build + the cell tasks it launches. A cell result merges only
|
||||
// if its BuildId still matches the live build for that region → a region that was cleared and later
|
||||
// re-marched (same coords, new BuildId) never absorbs a stale in-flight cell from its prior life.
|
||||
uint32 NextBuildId = 1;
|
||||
|
||||
// Set in BeginDestroy; worker tasks check it before touching us.
|
||||
std::atomic<bool> bShuttingDown{false};
|
||||
|
||||
// Streaming state. INT_MIN sentinels force a full rebuild on the first update / after ClearAll.
|
||||
FIntPoint LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN);
|
||||
int32 LastStrateIndex = INT32_MIN;
|
||||
|
||||
// Shared strate context for the current update (recomputed each UpdateDecorations; the launch step
|
||||
// copies the PODs into each task).
|
||||
FDecoContext CurrentCtx;
|
||||
|
||||
// Single strate-global ocean plane, repositioned to follow the player (see UpdateWater).
|
||||
UPROPERTY()
|
||||
TMap<FIntVector, UStaticMeshComponent*> WaterPlanes;
|
||||
UStaticMeshComponent* WaterPlane = nullptr;
|
||||
|
||||
// Cached state so UpdateWater is a cheap no-op when neither the water level nor the snapped
|
||||
// player cell changed since last frame.
|
||||
float LastWaterZ = -FLT_MAX; // strate water level (voxel Z)
|
||||
FIntPoint LastWaterCell = FIntPoint(INT32_MIN, INT32_MIN); // snapped XY grid cell
|
||||
};
|
||||
|
||||
@@ -26,7 +26,10 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "HAL/CriticalSection.h" // FRWLock
|
||||
#include "Misc/ScopeRWLock.h" // FReadScopeLock / FWriteScopeLock
|
||||
#include "VoxelTypes.h"
|
||||
#include <atomic>
|
||||
#include "VoxelDiffLayer.generated.h"
|
||||
|
||||
/**
|
||||
@@ -243,7 +246,16 @@ private:
|
||||
// Modifications grouped by chunk coordinate.
|
||||
// Each chunk's list contains ALL modifications that overlap it
|
||||
// (including mods centered in neighboring chunks whose radius reaches here).
|
||||
//
|
||||
// THREADING: written on the game thread (ApplyModification / Clear) but read by MANY mesher
|
||||
// WORKER threads (GetDensityAt -> HasModifications / GetDensityOffset). TMap is not thread-safe —
|
||||
// a read landing during a write's rehash reads freed hash memory (EXCEPTION_ACCESS_VIOLATION in
|
||||
// TSet::FindId). All access to ChunkMods MUST hold ModsLock (read lock for queries, write lock for
|
||||
// mutation). bHasAnyMods is a lock-free fast reject: while it's false (the common streaming case,
|
||||
// no carves) readers skip the map AND the lock entirely, so unmodified worlds pay nothing.
|
||||
TMap<FIntVector, TArray<FVoxelModification>> ChunkMods;
|
||||
mutable FRWLock ModsLock;
|
||||
std::atomic<bool> bHasAnyMods{ false };
|
||||
|
||||
//=========================================================================
|
||||
// BUDGET TRACKING
|
||||
|
||||
@@ -173,6 +173,30 @@ public:
|
||||
*/
|
||||
const UVoxelBiomeDefinition* GetDominantBiomeAt(float WorldX, float WorldY, int32 ChunkZ) 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
|
||||
* and the border blend weight (0 deep in a cell → 0.5 at the border). The mesher packs
|
||||
* these into vertex colour so one material re-skins terrain per biome and cross-fades
|
||||
* across biome borders. No biomes ⇒ Dominant=Neighbour=0, Weight=0 (default palette).
|
||||
* Thread-safe: own per-chunk thread_local context + box cache (clustered tile queries
|
||||
* stay warm). Window-invariant (ResolveBiomeSampleAt is bit-identical to SampleBiomeAt).
|
||||
*/
|
||||
void GetBiomeMaterialAt(float WorldX, float WorldY, float WorldZ,
|
||||
int32& OutDominantPalette, int32& OutNeighborPalette,
|
||||
float& OutBlendWeight) const;
|
||||
|
||||
/**
|
||||
* SurfaceWorld HEIGHT ORACLE: the terrain surface Z + sky-cap ceiling Z (voxel coords) at a world
|
||||
* XY for the given strate slice (ChunkZ), WITHOUT ray-marching the density column. Returns false
|
||||
* (outs untouched) when the chunk is NOT a SurfaceWorld heightfield — callers fall back to marching.
|
||||
* Shares the density path's surface helpers, so a decoration snapped to OutTerrainZ sits exactly on
|
||||
* the rendered ground. Does NOT include passage/spine/seal carving — verify with one GetDensityAt at
|
||||
* the result if a column might be carved. Thread-safe (own per-chunk thread_local cache).
|
||||
*/
|
||||
bool GetSurfaceHeightAt(float WorldX, float WorldY, int32 ChunkZ,
|
||||
float& OutTerrainZ, float& OutCeilSurf) const;
|
||||
|
||||
private:
|
||||
/** Pick the biome (index into Ctx.Biomes) for a Voronoi site, by its climate. */
|
||||
int32 ClassifyBiomeAtSite(float SiteX, float SiteY, const FBiomeContext& Ctx, uint32 SiteHash) const;
|
||||
@@ -184,6 +208,20 @@ private:
|
||||
/** The SurfaceWorld sky-cap ceiling surface Z at a world XY (also pure per-XY). */
|
||||
float ComputeSurfaceCeiling(float WorldX, float WorldY, const FSurfaceGenerationParams& Params) const;
|
||||
|
||||
/** Resolve a chunk's SurfaceWorld params (strate base + per-biome overrides, structural fields
|
||||
* forced from the strate). Shared by GetDensityAt's per-chunk cache and the GetSurfaceHeightAt
|
||||
* oracle so both produce the SAME surface. */
|
||||
void ResolveSurfaceChunkParams(const FIntVector& ChunkCoord,
|
||||
FSurfaceGenerationParams& OutSurface, FBiomeContext& OutBiomeCtx,
|
||||
TArray<FSurfaceGenerationParams>& OutBiomeParams) const;
|
||||
|
||||
/** Biome-blended terrain Z + sky-cap ceiling Z for one column (the XY-only surface field). Shared
|
||||
* by the density column cache (T1.a) and the oracle. */
|
||||
void ComputeSurfaceColumn(float WorldX, float WorldY, int32 ChunkZ,
|
||||
const FSurfaceGenerationParams& BaseSurface, const FBiomeContext& BiomeCtx,
|
||||
const TArray<FSurfaceGenerationParams>& BiomeParams, FChunkBiomeCache& BiomeCache,
|
||||
float& OutTerrainZ, float& OutCeilSurf) const;
|
||||
|
||||
/** Final SurfaceWorld density from a column's precomputed terrain Z + ceiling: the
|
||||
* cheap per-voxel Z-combine + origin spine + boundary seal + passage carving. The
|
||||
* XY-only work (terrain/ceiling) is done once per column and cached (T1.a). */
|
||||
|
||||
@@ -24,13 +24,15 @@ class VOXELFORGE_API UVoxelMarchingCubesMesher : public UObject
|
||||
|
||||
public:
|
||||
/**
|
||||
* Génère le mesh d'un chunk.
|
||||
* Génère le mesh d'une TUILE de clipmap (voir FVoxelTileKey).
|
||||
*
|
||||
* @param Chunk - Le chunk à mesher (on n'utilise que ChunkCoord pour
|
||||
* calculer les positions monde; la densité vient du générateur).
|
||||
* @param Step - Pas d'échantillonnage (1=LOD0, 2=LOD1, 4=LOD2).
|
||||
* @param OriginVoxels - Coin min de la tuile en coords VOXEL (= Tile.OriginVoxels()).
|
||||
* @param Step - Taille de cellule en voxels. La tuile couvre CellsPerAxis*Step voxels.
|
||||
* @param CellsPerAxis - Nombre de cellules par axe. Les tuiles GROSSIÈRES en utilisent MOINS
|
||||
* (gen moins chère, maillage plus grossier au loin) tout en couvrant la
|
||||
* même étendue (extent = CellsPerAxis*Step). Niveau 0 = CHUNK_SIZE.
|
||||
*/
|
||||
FVoxelMeshData GenerateMesh(const FVoxelChunk& Chunk, int32 Step = 1);
|
||||
FVoxelMeshData GenerateMesh(FIntVector OriginVoxels, int32 Step = 1, int32 CellsPerAxis = CHUNK_SIZE);
|
||||
|
||||
//=========================================================================
|
||||
// SERVICES (injectés par AVoxelWorld)
|
||||
@@ -54,6 +56,15 @@ public:
|
||||
// différence centrée du gradient. Plus petit = plus détaillé mais bruité.
|
||||
float GradientOffset = 1.0f;
|
||||
|
||||
// SKIRTS — bouchent les fissures aux frontières de tuiles entre niveaux de clipmap voisins
|
||||
// (résolutions différentes → les iso-surfaces ne se rejoignent pas exactement). Une jupe
|
||||
// (mur court) est extrudée vers le solide depuis chaque arête de surface posée sur une des 6
|
||||
// faces externes de la tuile. Voir GenerateMesh.
|
||||
bool bGenerateSkirts = true;
|
||||
// Profondeur de la jupe, en CELLULES de la tuile (× Step × VOXEL_SIZE). ~2 cellules couvrent
|
||||
// l'écart vers un voisin un niveau plus grossier (cellule 2×). Monter si des fissures persistent.
|
||||
float SkirtCells = 2.0f;
|
||||
|
||||
protected:
|
||||
//=========================================================================
|
||||
// DENSITY + NORMAL SAMPLING
|
||||
|
||||
@@ -43,6 +43,43 @@ public:
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming")
|
||||
int32 MaxMeshAppliesPerFrame = 4;
|
||||
|
||||
// Max tile teardowns (component destroy + content-actor Destroy) per frame. A fast traversal
|
||||
// culls a whole shell of tiles at once; doing every destroy in one frame is a game-thread spike
|
||||
// ("stuff torn down behind you"). This spreads it. The drain auto-scales up if the backlog grows
|
||||
// (PendingUnload/4) so it never falls far behind — raise the floor if teardown lags at high speed.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming")
|
||||
int32 MaxUnloadsPerFrame = 6;
|
||||
|
||||
// Strate-aware VERTICAL streaming: don't load chunks into a strate you can't see.
|
||||
// Seals + the inter-strate bedrock gap are light-tight (§8.7), so the strate above/below
|
||||
// the one you're in is fully occluded — loading it just wastes scene primitives (the
|
||||
// game-thread cost). When ON, the vertical view is clamped to the player's strate ±
|
||||
// StrateViewMarginChunks. In the bedrock GAP itself the view is NOT clamped (you can see
|
||||
// both sides through the descent passage there), so the next strate streams in ahead.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming")
|
||||
bool bClampViewToStrate = true;
|
||||
|
||||
// Chunks of vertical look-ahead PAST the strate seal (each side) when bClampViewToStrate
|
||||
// is on: keeps the descent tunnel / immediate transition loaded and acts as the trailing
|
||||
// buffer of the strate you're leaving (it sheds naturally as you descend out of view).
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "0"))
|
||||
int32 StrateViewMarginChunks = 3;
|
||||
|
||||
// Open-world SKY reach: the sky-cap ceiling of an open strate (SurfaceWorld / FloatingIslands)
|
||||
// is FAR, so the ceiling BAND is streamed across a wider horizontal radius = ViewDistanceXY ×
|
||||
// this, so the sky reaches toward the horizon instead of being a patch over the player's head.
|
||||
// Only the ceiling band (top CeilingBandChunks of the strate) gets the wide radius — the empty
|
||||
// air below it stays at base XY. Cost grows ~ multiplier² (more far chunks/draws; gen is cheap
|
||||
// and batching keeps the game-thread cost low). 1 = off. Live-tunable on the asset — push to 4
|
||||
// if the draw budget allows; a proper to-the-horizon view (terrain too) is the chunk-LOD clipmap.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "1"))
|
||||
int32 CeilingViewMultiplier = 2;
|
||||
|
||||
// How many chunks down from the strate top get the wide CeilingViewMultiplier radius (covers
|
||||
// the sky-cap slab). Bigger = no gaps if the ceiling surface dips, but more far chunks.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "1"))
|
||||
int32 CeilingBandChunks = 4;
|
||||
|
||||
//=========================================================================
|
||||
// LOD
|
||||
//=========================================================================
|
||||
@@ -58,6 +95,122 @@ public:
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|LOD")
|
||||
int32 LOD1Distance = 8;
|
||||
|
||||
//=========================================================================
|
||||
// CLIPMAP (chunked-LOD streaming — supersedes the ViewDistance/LOD box above)
|
||||
//=========================================================================
|
||||
// Streaming loads concentric shells of tiles: level 0 = full-res chunks near the player,
|
||||
// each coarser level doubles tile size (and reach). Total tile/draw/gen count stays ~flat
|
||||
// regardless of how far you see — so this is the "see to the horizon cheaply" system.
|
||||
// Total reach ≈ ClipRadius × CHUNK_SIZE × 2^MaxClipLevel voxels.
|
||||
// Near full-res reach ≈ ClipRadius × CHUNK_SIZE voxels.
|
||||
|
||||
// Half-extent (in tiles, per axis) of EACH level's shell. Bigger = more tiles per level
|
||||
// (more detail / overlap) but more components. 3 → a 7×7×7 box per level (minus the inner
|
||||
// hole filled by the finer level).
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "1"))
|
||||
int32 ClipRadius = 3;
|
||||
|
||||
// Coarsest LOD level. 0 = full-res only (no clipmap). Each level up doubles tile size &
|
||||
// reach at ~constant cost — raise this to see much farther for cheap. (Level L step = 2^L.)
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0", ClampMax = "8"))
|
||||
int32 MaxClipLevel = 4;
|
||||
|
||||
// How many near levels mesh at FULL resolution (CHUNK_SIZE cells). Levels at or above this
|
||||
// use the cheaper CoarseTileCells → MUCH faster gen for the far field (it's far, so the
|
||||
// extra blockiness is hidden, and skirts cover the seams). 2 = levels 0,1 crisp; set to 1
|
||||
// for the fastest gen (slightly harder LOD0→1 transition).
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "1"))
|
||||
int32 FullResClipLevels = 2;
|
||||
|
||||
// Cells per axis for COARSE tiles (levels >= FullResClipLevels). Lower = cheaper gen, blockier
|
||||
// far terrain. 16 = ~⅛ the gen samples of a full 32-cell tile; 8 = ~1/64. Tile EXTENT is
|
||||
// unchanged (the shells still tile) — only the mesh resolution within a far tile drops.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "4", ClampMax = "32"))
|
||||
int32 CoarseTileCells = 16;
|
||||
|
||||
// SKIRTS — seal the thin cracks where neighbouring clipmap shells (different resolutions) meet.
|
||||
// A short wall is extruded into the solid from each surface edge on the tile's outer faces.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap")
|
||||
bool bGenerateSkirts = true;
|
||||
|
||||
// Skirt depth in tile CELLS (× the tile's step). ~2 covers the gap to a one-level-coarser
|
||||
// neighbour (2× cell). Raise if cracks still show; lower if skirts peek out on convex edges.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0.5", ClampMax = "8.0"))
|
||||
float SkirtCells = 2.0f;
|
||||
|
||||
// LEGACY / WATER ONLY. Decorations no longer ride clipmap tiles (see Voxel|Content below —
|
||||
// they stream on a fixed world grid by distance, so they don't pop on LOD swaps). This now only
|
||||
// bounds the tile level at which the level-0 WATER plane is considered (water is level-0 anyway,
|
||||
// so its practical effect is nil). Left in place; safe to ignore.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0", ClampMax = "8"))
|
||||
int32 ContentMaxLevel = 2;
|
||||
|
||||
//=========================================================================
|
||||
// CONTENT — distance-based decoration grid (no LOD pop)
|
||||
//=========================================================================
|
||||
// Decorations are placed on a fixed WORLD XY cell grid (1 cell = 1 chunk footprint) and streamed
|
||||
// by DISTANCE from the player, independent of which clipmap tile (LOD) currently meshes the ground
|
||||
// under them. Each candidate column is ray-marched through the player's strate via the generator's
|
||||
// density field and snapped to the real surface — so a given prop keeps the SAME world position at
|
||||
// every LOD (no teleport/pop on tile swaps). Decorations exist only in the player's current strate.
|
||||
|
||||
// Far stream radius in cells (= chunks) for "any-distance" entries (instanced/HISM visual props,
|
||||
// and actor entries with MaxLODLevel >= 1). Bigger = props visible farther + more spawn/march cost.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1"))
|
||||
int32 DecorationRadiusChunks = 6;
|
||||
|
||||
// Decoration cells are grouped into REGIONS of RxR cells, and ALL placements in a region share ONE
|
||||
// HISM per mesh (instead of one HISM per cell per mesh). Regions load/unload as a unit, so clearing
|
||||
// stays a plain DestroyComponent — no per-instance index remapping. This is the render-thread lever:
|
||||
// component count (which InitViews walks every frame) drops by ~R^2. R=4 → ~16 regions in a radius-6
|
||||
// disk vs 169 cells (~10x fewer components). Bigger R = fewer components but coarser pop-in + heavier
|
||||
// one-shot cluster-tree build per region (apply is budgeted, so the build is amortised). MUST be >= 1.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1", ClampMax = "16"))
|
||||
int32 DecorationRegionSizeCells = 4;
|
||||
|
||||
// LEGACY / UNUSED. The near/far tier system was removed (it re-streamed cells at the tier boundary
|
||||
// as the player moved → decoration flicker). All entries now stream within DecorationRadiusChunks and
|
||||
// a loaded cell is never re-streamed in place. Kept only to avoid breaking the asset; safe to ignore.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1"))
|
||||
int32 DecorationActorRadiusChunks = 3;
|
||||
|
||||
// Spacing (in voxels) of candidate columns within a cell. MUST divide CHUNK_SIZE (32): 4 → 8×8=64
|
||||
// columns/cell. Smaller = denser placement potential + more march cost. SpawnDensity then rolls per
|
||||
// column-crossing (NOTE: this changes the meaning of SpawnDensity vs the old per-vertex scatter —
|
||||
// expect to re-tune decoration densities once).
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1", ClampMax = "32"))
|
||||
int32 DecorationSpacingVoxels = 4;
|
||||
|
||||
// COARSE vertical march step (in voxels) when searching a column for surface crossings. The crossing
|
||||
// Z is then bisection-refined, so accuracy is independent of this — raise it (4-8) to cut the scan
|
||||
// cost (the column is ray-marched on a WORKER thread, but a smaller step still means more samples).
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1", ClampMax = "8"))
|
||||
int32 DecorationMarchStepVoxels = 2;
|
||||
|
||||
// Max surface crossings placed per column (caps cave columns that pierce many floors/ceilings;
|
||||
// surface worlds have 1). Bounds worst-case march/spawn cost.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1", ClampMax = "16"))
|
||||
int32 DecorationMaxCrossingsPerColumn = 4;
|
||||
|
||||
// Stop marching a column after this many voxels of CONTIGUOUS SOLID once it has already entered the
|
||||
// open space — i.e. cap how far into bedrock BELOW the floor (or below a cavern) we keep scanning.
|
||||
// The top cap/seal and the air above the ground are always marched first (this only trims the dead
|
||||
// rock underneath), so surface worlds still get their ground and caves still find layered floors.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "8"))
|
||||
int32 DecorationColumnDepthVoxels = 160;
|
||||
|
||||
// Budget: how many completed decoration REGIONS to APPLY (game-thread HISM build + SpawnActor) per
|
||||
// frame. A region is applied once ALL its cells have finished marching, in one batched AddInstances
|
||||
// per mesh; this throttles that burst. The expensive ray-march runs async on workers. (Named "Cells"
|
||||
// for asset back-compat — the apply unit is now a region of DecorationRegionSizeCells^2 cells.)
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1"))
|
||||
int32 MaxDecorationCellsPerFrame = 2;
|
||||
|
||||
// Max decoration ray-march tasks in flight at once. Keeps decoration marching from crowding the
|
||||
// mesh-gen workers (which are the streaming bottleneck). 0 disables decorations entirely.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "0"))
|
||||
int32 MaxConcurrentDecorationTasks = 4;
|
||||
|
||||
//=========================================================================
|
||||
// RENDERING
|
||||
//=========================================================================
|
||||
|
||||
@@ -60,7 +60,9 @@ public:
|
||||
// How many chunks tall this strate is.
|
||||
// Each strate can have a different height!
|
||||
// Big open caverns = 6-8 chunks, tight tunnels = 3, vertical shaft = 12+
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Dimensions", meta = (ClampMin = "1", ClampMax = "32"))
|
||||
// Surface/sky worlds want a high sky-cap ceiling, so they go much taller.
|
||||
// 1 chunk = 32 voxels x 25cm = 8 m, so 256 chunks ~= 2 km of vertical strate.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Dimensions", meta = (ClampMin = "1", ClampMax = "256"))
|
||||
int32 StrateHeightInChunks = 4;
|
||||
|
||||
//=========================================================================
|
||||
@@ -238,6 +240,14 @@ public:
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Visuals")
|
||||
UMaterialInterface* OverrideMaterial = nullptr;
|
||||
|
||||
// Material for the SKY-CAP CEILING tiles of a surface-like strate (the rocky "night sky" overhead).
|
||||
// Null = ceiling uses the same material as the ground (OverrideMaterial / default VoxelMaterial).
|
||||
// The ceiling casts no shadow, so under it the ground material reads as flat/bright — give the
|
||||
// ceiling its own darker/tinted material (or a tinted instance of the terrain material) to sell the
|
||||
// "lit from within, dark rock overhead" look. Applies only to tiles classified as ceiling.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Visuals")
|
||||
UMaterialInterface* CeilingMaterial = nullptr;
|
||||
|
||||
// Fog color for this strate (used by a future fog system or post-process)
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Visuals")
|
||||
FLinearColor FogColor = FLinearColor(0.05f, 0.05f, 0.1f, 1.0f);
|
||||
|
||||
@@ -170,6 +170,14 @@ public:
|
||||
*/
|
||||
UVoxelStrateDefinition* GetStrateForChunk(const FIntVector& ChunkCoord) const;
|
||||
|
||||
/**
|
||||
* Strate-aware vertical streaming: fills the chunk-Z span of the strate containing
|
||||
* ChunkZ. Returns false if ChunkZ is in the inter-strate bedrock gap (or outside the
|
||||
* layout) — the caller then leaves the vertical view unclamped (the gap is a brief
|
||||
* see-both-sides descent transition). TopChunkZ > BottomChunkZ (Z decreases downward).
|
||||
*/
|
||||
bool GetStrateChunkZBounds(int32 ChunkZ, int32& OutTopChunkZ, int32& OutBottomChunkZ) const;
|
||||
|
||||
/**
|
||||
* Get generation params for a chunk, with boundary blending.
|
||||
*
|
||||
@@ -293,6 +301,11 @@ protected:
|
||||
// Passages connecting consecutive strates
|
||||
TArray<FVoxelPassage> Passages;
|
||||
|
||||
// Bumped every time Passages is rebuilt (GeneratePassages). EvaluateModifierSDF keeps a
|
||||
// thread_local per-chunk shortlist of nearby passages and uses this to invalidate it when
|
||||
// the passage set changes — so stale indices are never read after a rebuild.
|
||||
uint32 PassagesVersion = 0;
|
||||
|
||||
// How many chunks at strate boundaries are blended (transition zone)
|
||||
int32 BlendChunks = 2;
|
||||
|
||||
|
||||
@@ -1358,6 +1358,46 @@ struct VOXELFORGE_API FSurfaceGenerationParams
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0"))
|
||||
float CeilingRoughness = 6.0f;
|
||||
|
||||
// Frequency of the fine downward bumpiness above. Higher = smaller, denser bumps.
|
||||
// (Was hard-coded to 0.04 — exposed so the cap detail scale is tunable.)
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0"))
|
||||
float CeilingRoughnessFrequency = 0.04f;
|
||||
|
||||
// Broad ceiling undulation (voxels): a low-frequency SIGNED swell that raises and lowers
|
||||
// the WHOLE sky-cap, giving big inverted "hills and valleys" overhead — the ceiling reads
|
||||
// like terrain instead of a flat lid. Independent of the fine bumps. 0 = level cap height.
|
||||
// 0 → flat cap (old look)
|
||||
// 30 → gentle rolling ceiling
|
||||
// 80+ → dramatic overhead valleys and rises
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0"))
|
||||
float CeilingUndulation = 0.0f;
|
||||
|
||||
// Frequency of the broad undulation. Lower = larger, sweeping ceiling valleys.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0"))
|
||||
float CeilingUndulationFrequency = 0.004f;
|
||||
|
||||
// Ridged hanging formations (voxels): sharp downward ridges/blades carved into the cap —
|
||||
// the "valley-like ridges" / inverted-mountain-range look. Adds to the downward hang.
|
||||
// 0 → none
|
||||
// 20 → clear ridgelines across the ceiling
|
||||
// 50+ → dramatic hanging ranges
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0"))
|
||||
float CeilingRidgeStrength = 0.0f;
|
||||
|
||||
// Frequency of the ridged ceiling formations. Lower = broader ridges.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0"))
|
||||
float CeilingRidgeFrequency = 0.02f;
|
||||
|
||||
// Domain-warp the ceiling ridge/undulation query by this many voxels so ridgelines wind
|
||||
// organically instead of looking like axis-aligned noise. 0 = no warp (fine bumps are
|
||||
// unaffected, like the ground heightfield's detail layer).
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0"))
|
||||
float CeilingWarpStrength = 0.0f;
|
||||
|
||||
// Frequency of the ceiling warp noise. Lower = broader bends.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0"))
|
||||
float CeilingWarpFrequency = 0.01f;
|
||||
|
||||
// Solid shell thickness at strate top/bottom (voxels).
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Boundary", meta = (ClampMin = "0.0"))
|
||||
float BoundarySealThickness = 4.0f;
|
||||
@@ -1673,17 +1713,23 @@ struct VOXELFORGE_API FStrateDecoration
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration")
|
||||
UStaticMesh* InstancedMesh = nullptr;
|
||||
|
||||
// Spawn while the chunk's LOD <= this (0 = LOD0 only, the old behaviour).
|
||||
// Lets instanced visual props persist on LOD1-2 chunks instead of popping out with
|
||||
// LOD0. NOTE: placement samples the LOD's mesh vertices, so instances re-scatter
|
||||
// slightly on LOD transitions (masked by the terrain's own LOD pop).
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0", ClampMax = "2"))
|
||||
// LEGACY / UNUSED by the world-grid decoration system (§8.5). It once meant a clipmap tile level,
|
||||
// then a near/far distance tier — both removed. All decorations now stream within a single radius
|
||||
// (VoxelSettings::DecorationRadiusChunks) and never re-stream in place. Kept to avoid breaking assets.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0", ClampMax = "8"))
|
||||
int32 MaxLODLevel = 0;
|
||||
|
||||
// Which surface type this decoration can be placed on
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration")
|
||||
ESurfaceType SurfacePlacement = ESurfaceType::Any;
|
||||
|
||||
// Maximum surface tilt (degrees from flat) this decoration tolerates. The surface tilt is
|
||||
// acos(|normal.Z|): 0 = perfectly flat floor/ceiling, 90 = vertical wall. Grass on gentle
|
||||
// ground → ~30-40; rock/lichen that clings to slopes → 90 (no filter, the default).
|
||||
// 90 → place anywhere (default, no filter) · 35 → grass that avoids cliffs · 15 → flats only
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0.0", ClampMax = "90.0"))
|
||||
float MaxSlopeAngle = 90.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"))
|
||||
@@ -1725,6 +1771,24 @@ struct VOXELFORGE_API FStrateDecoration
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement",
|
||||
meta = (EditCondition = "bRequireWaterRelative"))
|
||||
bool bPlaceBelowWater = false;
|
||||
|
||||
// ----- Performance (HISM render tuning — only affects the InstancedMesh path) -----
|
||||
|
||||
// Distance (world units / cm) past which instances stop rendering. This is THE lever that makes
|
||||
// DENSE groundcover affordable: grass can be placed thickly but only drawn near the player, so the
|
||||
// GPU cost is bounded by area-within-cull, not by the whole streaming radius. 0 = never cull (the
|
||||
// default — correct for trees / large props you want visible to the horizon).
|
||||
// 0 → no cull (props, trees)
|
||||
// 2000 → ~20 m, typical grass / small ground clutter
|
||||
// 4000 → ~40 m, taller plants you want visible a bit further
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Performance", meta = (ClampMin = "0.0"))
|
||||
float CullDistance = 0.0f;
|
||||
|
||||
// Whether these instances cast dynamic shadows. Dense instanced shadows are the single biggest cost
|
||||
// of heavy foliage — turn this OFF for grass / small clutter (an unlit-from-below tuft loses almost
|
||||
// nothing visually). Leave ON for trees and anything large enough that its shadow reads as grounding.
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Performance")
|
||||
bool bCastShadow = true;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -152,6 +152,48 @@ inline float SmoothStep01(float x)
|
||||
// FMath::PerlinNoise3D — ce facteur remet à ~[-1, 1] pour les formules de densité.
|
||||
constexpr float VOXEL_NOISE_SCALE = 1.25f;
|
||||
|
||||
//=============================================================================
|
||||
// CLIPMAP TILE KEY
|
||||
//=============================================================================
|
||||
//
|
||||
// A "tile" generalises a chunk for the chunked-LOD clipmap. A level-L tile spans
|
||||
// (CHUNK_SIZE << Level) voxels per axis and is meshed at step (1 << Level), so it always
|
||||
// produces a constant CHUNK_SIZE³-cell mesh (one component, one draw) but covers 8^Level ×
|
||||
// the volume. Level 0 = a full-resolution chunk; each level up doubles linear size.
|
||||
// Streaming loads concentric shells: level 0 near the player, coarser levels farther out,
|
||||
// so chunk/draw count stays ~flat regardless of view distance.
|
||||
|
||||
struct FVoxelTileKey
|
||||
{
|
||||
FIntVector Coord = FIntVector::ZeroValue; // in units of (CHUNK_SIZE << Level) voxels
|
||||
int32 Level = 0; // 0 = full res
|
||||
|
||||
FVoxelTileKey() = default;
|
||||
FVoxelTileKey(const FIntVector& InCoord, int32 InLevel) : Coord(InCoord), Level(InLevel) {}
|
||||
|
||||
// Cell size (sampling step) in voxels, and tile extent in voxels per axis.
|
||||
int32 StepVoxels() const { return 1 << Level; }
|
||||
int32 ExtentVoxels() const { return CHUNK_SIZE << Level; }
|
||||
|
||||
// Min-corner of the tile in VOXEL coords (what the mesher takes).
|
||||
FIntVector OriginVoxels() const { return Coord * (CHUNK_SIZE << Level); }
|
||||
|
||||
// Tile centre in world cm (for distance sorting / LOD selection).
|
||||
FVector CenterCm() const
|
||||
{
|
||||
const double Ext = (double)(CHUNK_SIZE << Level) * (double)VOXEL_SIZE;
|
||||
return FVector((Coord.X + 0.5) * Ext, (Coord.Y + 0.5) * Ext, (Coord.Z + 0.5) * Ext);
|
||||
}
|
||||
|
||||
bool operator==(const FVoxelTileKey& O) const { return Level == O.Level && Coord == O.Coord; }
|
||||
bool operator!=(const FVoxelTileKey& O) const { return !(*this == O); }
|
||||
};
|
||||
|
||||
FORCEINLINE uint32 GetTypeHash(const FVoxelTileKey& K)
|
||||
{
|
||||
return HashCombine(GetTypeHash(K.Coord), ::GetTypeHash(K.Level));
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// MESH DATA
|
||||
//=============================================================================
|
||||
@@ -166,6 +208,8 @@ struct FVoxelMeshData
|
||||
TArray<int32> Triangles; // Indices, 3 par triangle
|
||||
TArray<FVector2D> UVs; // Coords de texture (une par vertex)
|
||||
TArray<FVector> Normals; // Normale lissée (gradient de densité)
|
||||
TArray<FColor> Colors; // Masques matériau F6 (R=palette biome dominant, G=pente,
|
||||
// B=poids de fondu de bordure, A=palette biome voisin)
|
||||
|
||||
void Clear()
|
||||
{
|
||||
@@ -173,6 +217,7 @@ struct FVoxelMeshData
|
||||
Triangles.Empty();
|
||||
UVs.Empty();
|
||||
Normals.Empty();
|
||||
Colors.Empty();
|
||||
}
|
||||
|
||||
bool IsEmpty() const { return Vertices.Num() == 0; }
|
||||
|
||||
@@ -21,6 +21,7 @@ class URealtimeMeshSimple;
|
||||
class UVoxelDiffLayer;
|
||||
class UVoxelContentManager;
|
||||
class UVoxelAtmosphereManager;
|
||||
class UMaterialInterface;
|
||||
|
||||
/**
|
||||
* AVoxelWorld - The main voxel terrain actor
|
||||
@@ -37,11 +38,9 @@ class UVoxelAtmosphereManager;
|
||||
*/
|
||||
struct FChunkResult
|
||||
{
|
||||
FIntVector ChunkCoord;
|
||||
FVoxelChunk Chunk;
|
||||
FVoxelTileKey Tile; // which clipmap tile this mesh is for (carries coord + level)
|
||||
FVoxelMeshData MeshData;
|
||||
int32 LODLevel = 0; // 0=full, 1=half, 2=quarter resolution
|
||||
uint32 Epoch = 0; // Generation epoch — discard if stale
|
||||
uint32 Epoch = 0; // Generation epoch — discard if stale
|
||||
};
|
||||
|
||||
UCLASS()
|
||||
@@ -101,12 +100,22 @@ public:
|
||||
// CHUNK STORAGE
|
||||
//=========================================================================
|
||||
|
||||
/** All currently loaded chunks, keyed by chunk coordinate */
|
||||
TMap<FIntVector, FVoxelChunk> Chunks;
|
||||
//=========================================================================
|
||||
// CLIPMAP TILE STORAGE (chunked-LOD)
|
||||
//=========================================================================
|
||||
// A level-L tile spans (CHUNK_SIZE<<L) voxels meshed at step (1<<L) → constant 32³-cell
|
||||
// mesh, one component, one draw, covering 8^L× the volume. Streaming loads concentric
|
||||
// shells (level 0 near, coarser far), so total tile count stays low (~1-2k) regardless
|
||||
// of view distance. That low count is WHY each tile can have its own component without a
|
||||
// game-thread problem (this supersedes the earlier region-batching). Collision + content
|
||||
// are level-0 only.
|
||||
|
||||
/** Mesh components for each chunk, keyed by chunk coordinate */
|
||||
UPROPERTY()
|
||||
TMap<FIntVector, URealtimeMeshComponent*> ChunkMeshes;
|
||||
/** Tiles fully loaded — INCLUDING empty/all-air tiles, so we never re-submit them. */
|
||||
TSet<FVoxelTileKey> LoadedTiles;
|
||||
|
||||
/** Render component per NON-empty loaded tile (GC-safe via actor ownership; not UPROPERTY
|
||||
* because FVoxelTileKey isn't a USTRUCT key). */
|
||||
TMap<FVoxelTileKey, URealtimeMeshComponent*> TileComponents;
|
||||
|
||||
|
||||
|
||||
@@ -351,7 +360,7 @@ public:
|
||||
*
|
||||
* @param ChunkCoord - Which chunk to load
|
||||
*/
|
||||
void LoadChunk(const FIntVector& ChunkCoord);
|
||||
void LoadTile(const FVoxelTileKey& Tile);
|
||||
|
||||
/**
|
||||
* Unload a single chunk.
|
||||
@@ -362,7 +371,7 @@ public:
|
||||
*
|
||||
* @param ChunkCoord - Which chunk to unload
|
||||
*/
|
||||
void UnloadChunk(const FIntVector& ChunkCoord);
|
||||
void UnloadTile(const FVoxelTileKey& Tile);
|
||||
|
||||
/**
|
||||
* Apply mesh data to a RealtimeMesh component.
|
||||
@@ -374,7 +383,15 @@ public:
|
||||
* @param ChunkCoord - Which chunk this mesh belongs to
|
||||
* @param MeshData - The generated mesh data
|
||||
*/
|
||||
void ApplyMeshToChunk(const FIntVector& ChunkCoord, const FVoxelMeshData& MeshData);
|
||||
void ApplyMeshToTile(const FVoxelTileKey& Tile, const FVoxelMeshData& MeshData);
|
||||
|
||||
/** Build the clipmap desired-tile set (concentric shells) around the player tile. */
|
||||
void BuildDesiredTiles(const FIntVector& CenterChunkCoord);
|
||||
|
||||
/** True if a tile's world footprint is still within the outermost clip shell (so a
|
||||
* not-desired loaded tile there is mid-LOD-transition and must wait for its replacement,
|
||||
* vs. one that has left the view entirely and can be culled immediately). */
|
||||
bool IsTileInClipRange(const FVoxelTileKey& Tile, const FIntVector& CenterChunkCoord) const;
|
||||
|
||||
//=========================================================================
|
||||
// HELPERS
|
||||
@@ -418,7 +435,7 @@ public:
|
||||
// link and silently drop results, which leaks PendingChunkCoord slots until the
|
||||
// budget is exhausted and streaming stalls permanently. Mpsc guards the producer side.
|
||||
TQueue<FChunkResult, EQueueMode::Mpsc> ProcessQueue;
|
||||
TSet<FIntVector> PendingChunkCoord;
|
||||
TSet<FVoxelTileKey> PendingTiles; // tiles with a gen task in flight
|
||||
|
||||
// Set to true during EndPlay — async tasks check this before accessing UObjects
|
||||
std::atomic<bool> bShuttingDown{false};
|
||||
@@ -426,22 +443,25 @@ public:
|
||||
// Number of async tasks currently running — EndPlay waits for this to reach 0
|
||||
std::atomic<int32> ActiveTaskCount{0};
|
||||
|
||||
// Last known player chunk coord — used by LoadChunk to compute LOD
|
||||
// Player's level-0 tile coord (= chunk coord). The desired set is rebuilt when this changes.
|
||||
FIntVector CurrentCenterChunk = FIntVector::ZeroValue;
|
||||
|
||||
// Track current LOD per loaded chunk (for LOD transitions)
|
||||
TMap<FIntVector, int32> ChunkLODs;
|
||||
|
||||
// --- Streaming work-avoidance (perf) ---
|
||||
// The desired chunk set only changes when the player crosses a chunk boundary.
|
||||
// We cache it and only rebuild/cull/sort on a real move, and go idle once every
|
||||
// desired chunk is streamed in — so a stationary player costs ~nothing per frame.
|
||||
// The desired tile set only changes when the player crosses a level-0 tile boundary.
|
||||
// We cache it and only rebuild/cull/sort on a real move, and go idle once every desired
|
||||
// tile is streamed in — so a stationary player costs ~nothing per frame.
|
||||
FIntVector LastUpdateCenter = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
|
||||
bool bAllChunksLoaded = false;
|
||||
TArray<FIntVector> DesiredSorted; // desired coords, nearest-first
|
||||
TSet<FIntVector> DesiredSet; // O(1) membership for the cull pass
|
||||
TArray<FVoxelTileKey> DesiredSorted; // desired tiles, nearest-first
|
||||
TSet<FVoxelTileKey> DesiredSet; // O(1) membership for the cull pass
|
||||
|
||||
// Tiles approved for removal but whose teardown (component destroy + content actor Destroy())
|
||||
// is spread across frames. Unbudgeted, a fast traversal culls a whole shell's worth of tiles in
|
||||
// ONE frame → a game-thread teardown spike. Drained by ProcessUnloadQueue (catch-up scaled).
|
||||
TSet<FVoxelTileKey> PendingUnload;
|
||||
|
||||
void ProcessPendingChunks();
|
||||
void ProcessUnloadQueue(); // budgeted teardown drain (see PendingUnload)
|
||||
|
||||
/**
|
||||
* Re-queue loaded chunks for async re-generation + re-meshing.
|
||||
|
||||
Reference in New Issue
Block a user