Files
VoxelForge/Source/VoxelForge/Private/VoxelGenerator.cpp
T
Fr0zkaandClaude Opus 5 383ec86c37 perf(apply): stutter is NOT the game thread -- and the flat floor cannot be relaxed
Two things the owner reported from playing 412a7ad. One is answered, one is a null
result that Codex flagged itself rather than dressing up.

THE FLATTENED TUNNEL FLOORS -- capability-safe, and worth almost nothing
He has now flagged these twice: every tunnel carries a smooth flat strip running in
and out through otherwise craggy rock. The idea was to make the floor CONDITIONAL --
assert it only where the natural rock fails walkability, leave the rock alone
elsewhere. Cheaper and better looking, in theory.

The floor is now skipped only when the exact no-floor field passes the same
player-fit validator at foot offsets 0, -1, +1. Unknown or failed keeps the slab.

  natural-floor skips accepted   1,539 of 1,529,241 tests   = 0.10%
  player-fit cells        20,153 -> 20,153
  reachable cells         10,483 -> 10,483
  arrival -> departure    connected, both
  dihedral p50/p90        2.5429 / 33.8499 deg, IDENTICAL
  mesh time (their batch) 1.757135 -> 1.757140 s

Capability preserved exactly. Visual complaint NOT solved, and no speed gained.

⭐ The useful part is what it PROVED by trying to go further. Every broader policy
lost walkable space:
    analytic-only        fit -217, reachable -195
    tightened analytic   fit  -76, reachable  -76
    exact +/-4 foot      fit  -16, reachable  -16
⇒ The floor cannot be relaxed without changing the walkability contract or adding a
route-aware global proof. That is a real constraint, now measured rather than assumed.

Codex's own words, and it was right to lead with them: "the conservative floor change
is capability-safe, but it does not visibly solve the flattened-strip complaint."

THE STUTTER -- measured, and it is not where we thought
Game-thread apply, meshed tiles, under -nullrhi:

  total apply            p50 0.107 ms   p95 0.153 ms   max 0.273 ms
    stream upload/RMC    p50 0.040      p95 0.088      max 0.124
    component creation   p50 0.019      p95 0.021      max 0.035
    registration         p50 0.010      p95 0.011      max 0.020
    collision config     p50 0.005      p95 0.007      max 0.019

A quarter of a millisecond at worst. The game-thread drain is NOT the hitch.
⇒ Remaining visible stutter is most likely render-thread/RHI work or asynchronous
collision cooking behind RealtimeMeshComponent, which is off-limits for edits.

Added anyway as a cheap safety valve: MaxMeshApplyMilliseconds, default 2 ms of drain
budget per frame. Over 729 apply frames: drain p50/p95/max 0.111/0.182/0.313 ms,
budget hits 0, at most 2 visible applies in a frame. It costs nothing today and caps
a future burst.

Request-to-ready, now timed after game-thread application:
  all     p50 57.1 ms   p95 432.9 ms   max 919.4 ms   -> 2.60 m at 6 m/s
  meshed  p50 134.1 ms  p95 457.4 ms   max 919.4 ms

⚠️ My own verification: 1.197 / 1.131 / 1.118 s vs 1.088-1.102 s on 412a7ad. Mean
+4.7% -- below the 17% noise floor, but slower in all three runs, and the validator
runs 1.5M times to accept 1,539 skips. Geometry moved by 4 triangles (153,740 ->
153,744), which is allowed. If the next round cannot make that validator earn its
place, it should come out.

Build 0 errors. Peak RSS 1.67 GB export / 1.90 GB game. Nothing outside
Plugins/VoxelForge; RealtimeMeshComponent untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
2026-09-10 14:46:29 +02:00

5935 lines
298 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// VoxelGenerator.cpp
// Champ de densité du monde. Toute la géométrie des grottes sort d'ici.
//
// Convention INTERNE: positif = solide, négatif = air (plus lisible).
// Convention de SORTIE (marching cubes): on négate → négatif = solide.
#include "VoxelGenerator.h"
#include "VoxelSettings.h"
#include "VoxelStrateManager.h"
#include "VoxelStrateDefinition.h"
#include "VoxelTerrainOpDefinition.h"
#include "VoxelCaveMorphology.h"
#include "VoxelDiffLayer.h"
#include "VoxelBiomeDefinition.h"
#include "VoxelNoise.h" // T2.a: float, SIMD-batched gradient-noise core
#include "VoxelDensityPrimitives.h" // spine / seals / passage — shared with the operator stack
#include "VoxelDensityOpStack.h" // OPSTACK Phase 1: the opt-in per-strate operator stack
#include "VoxelHeightOp.h" // IVoxelBiomeField — the adapter below implements it
#include "VoxelStats.h"
#include "VoxelDensityProfile.h"
#include "ProfilingDebugging/CpuProfilerTrace.h"
#include "HAL/PlatformTime.h"
#if WITH_EDITOR
#include "VoxelStrateComposer.h"
#endif
#include <atomic>
//=============================================================================
// L'ADAPTATEUR DE CHAMP DE BIOMES / THE BIOME FIELD ADAPTER
//=============================================================================
// Il vit ICI, du côté qui connaît le générateur, et PAS dans la pile d'opérateurs. C'est tout
// l'intérêt de `IVoxelBiomeField` : le résolveur réel est une Voronoï warpée avec un cache par
// chunk sur `UVoxelGenerator`, et un opérateur qui tiendrait ce pointeur ne pourrait jamais devenir
// un asset (Phase 3). En le confinant ici, l'opérateur ne connaît qu'une capacité, pas un
// propriétaire.
//
// ⚠️ DURÉES DE VIE : cet adaptateur pointe vers les `thread_local` `CP_BiomeCtx` / `CP_BiomeCache`
// de `GetDensityAt`. Il est POSSÉDÉ par la pile, elle-même `thread_local` et reconstruite dans le
// MÊME bloc de refetch que ces deux caches — les trois naissent et meurent ensemble, sur le même
// thread. Un pointeur vers un thread_local depuis un objet thread_local du même bloc est sûr ;
// l'échapper ailleurs ne le serait pas.
//
// Lives here, not in the op stack: the real resolver is generator state, and an op holding that
// pointer could never become an asset. LIFETIME: it points at GetDensityAt's thread_locals and is
// owned by the stack, which is itself thread_local and rebuilt in the same refetch block.
class FGeneratorBiomeField final : public IVoxelBiomeField
{
public:
FGeneratorBiomeField(const UVoxelGenerator* InGen, const FBiomeContext* InCtx,
FChunkBiomeCache* InCache, int32 InChunkZ)
: Gen(InGen), Ctx(InCtx), Cache(InCache), ChunkZ(InChunkZ) {}
FVoxelBiomeWeights SampleAt(float WorldX, float WorldY) const override
{
FVoxelBiomeWeights Out;
if (!Gen || !Ctx || !Cache) { return Out; } // dégradation sûre : biome 0 partout
const FBiomeSample S = Gen->ResolveBiomeSampleAt(WorldX, WorldY, ChunkZ, *Ctx, *Cache);
Out.Dominant = FMath::Max(S.DominantIndex, 0); // -1 ⇒ 0, comme le chemin d'origine
Out.Neighbor = S.NeighborIndex;
Out.NeighborWeight = S.NeighborWeight;
return Out;
}
private:
const UVoxelGenerator* Gen;
const FBiomeContext* Ctx;
FChunkBiomeCache* Cache;
int32 ChunkZ;
};
//=============================================================================
// SURFACE COLUMN CACHE (T1.a) — kill the per-Z heightfield redundancy
//=============================================================================
// The SurfaceWorld heightfield + sky-cap + biome blend are functions of XY ONLY, but
// the mesher samples ~33 Z grid-points per column, each re-running that XY work. We cache
// 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.
// 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).
// F20 phase 2 OVERHANG, resolved once per column (biome-blended, slope-gated) so the per-voxel
// density path is cheap: OverhangAmp = strength · slope-gate (0 ⇒ no overhang here); (DirX,DirY) =
// the UNIT UPHILL direction of the terrain gradient (the lip extends downhill, borrowing rock from
// uphill). See SurfaceDensityFromColumn for the warped-terrain union that makes the shelf.
struct FSurfaceColumn
{
float TerrainZ = 0.0f; float CeilSurf = 0.0f;
float OverhangAmp = 0.0f; float DirX = 0.0f; float DirY = 0.0f;
};
struct FSurfaceColumnBox
{
// 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 StrateKey = MIN_int32, Seed = MIN_int32; // key: same strate ⇒ identical heightfield params
// AUDIT C2, étendu : StrateKey vaut round(StrateBottomWorldZ), donc une édition à chaud qui
// change les params de terrain SANS déplacer la strate (fréquence de bruit, hauteur de
// montagne, un biome…) laisse la clé identique et sert des colonnes périmées. C'est la forme
// la plus visible du bug : « j'ai retouché le terrain et une zone a gardé l'ancienne forme ».
// StrateKey is round(StrateBottomWorldZ), so a live edit that changes terrain params WITHOUT
// moving the strate leaves the key unchanged and serves stale columns.
uint32 LayoutVersion = 0xFFFFFFFFu;
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,LayoutVersion); allocate by
// evicting the LRU box on miss.
FSurfaceColumnBox& Acquire(int32 IX, int32 IY, int32 InStrateKey, int32 InSeed, uint32 InLayoutVersion)
{
++Clock;
for (FSurfaceColumnBox& B : Boxes)
{
if (B.bValid && B.StrateKey == InStrateKey && B.Seed == InSeed
&& B.LayoutVersion == InLayoutVersion
&& 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->LayoutVersion = InLayoutVersion;
Victim->bValid = true;
Victim->LastUse = Clock;
FMemory::Memzero(Victim->Computed, sizeof(Victim->Computed));
return *Victim;
}
};
// Cache de colonnes PARTAGÉ par thread : GetDensityAt (hot path, T1.a) + ClassifyTile (T1.d).
// Les deux stockent des valeurs bit-identiques (même ComputeSurfaceColumn, champs purs en XY),
// donc un ClassifyTile qui rend Mixed laisse ses colonnes chaudes pour le GenerateMesh qui suit.
static thread_local FSurfaceColumnCache GSurfColCache;
//=============================================================================
// FRACTAL NOISE (fBm — fractional Brownian motion)
//=============================================================================
// Empile plusieurs octaves de Perlin: freq x2 et amp /2 à chaque octave.
// Octave 1: freq=f, amp=1.0 → grandes collines lisses
// Octave 2: freq=2f, amp=0.5 → bosses moyennes
// Octave 3: freq=4f, amp=0.25 → petits cailloux
// Lacunarity = x freq par octave (2 = double à chaque fois)
// Persistence = x amp par octave (0.5 = moitié)
// T2.b — per-thread octave bias for the tile being meshed (see VoxelGenerator.h).
// 0 = full quality; set by the mesher per tile from Step + Settings->LODOctaveDrop.
thread_local int32 VoxelGenLOD::OctaveBias = 0;
thread_local int32 VoxelGenLOD::SampleStep = 1;
int32 VoxelGenLOD::GetThreadOctaveBias()
{
return OctaveBias;
}
void VoxelGenLOD::SetThreadOctaveBias(int32 Value)
{
OctaveBias = Value;
}
int32 VoxelGenLOD::GetThreadSampleStep()
{
return SampleStep;
}
void VoxelGenLOD::SetThreadSampleStep(int32 Value)
{
SampleStep = Value;
}
// NOTE (T2.a): the fBm/Ridged bodies moved to VoxelNoise.h, where octaves are evaluated
// 4-wide via SSE (Perlin3D_x4). These thin wrappers keep every call site unchanged. They
// sample a DIFFERENT (float hash-gradient) noise field than the old FMath::PerlinNoise3D,
// so worlds re-tune once — but the fBm/Ridged math/contracts ([-1,1]) are identical.
static float FractalNoise3D(const FVector3f& Position, int32 Octaves = 4,
float Lacunarity = 2.0f, float Persistence = 0.5f)
{
return VoxelNoise::FBM(Position.X, Position.Y, Position.Z,
Octaves, Lacunarity, Persistence);
}
//=============================================================================
// RIDGED MULTIFRACTAL NOISE
//=============================================================================
// Creates sharp, ridge-like features by folding the noise at zero-crossings.
// The absolute value creates "creases" where the noise crosses zero,
// and the 1-abs inverts them into ridges. Weight feedback from each
// octave makes ridges sharper and more detailed.
//
// Returns approximately [-1, 1] to match FractalNoise3D's range.
// Character: craggy cliffs, natural erosion patterns, sharp corridors.
static float RidgedNoise3D(const FVector3f& Position, int32 Octaves = 4,
float Lacunarity = 2.0f, float Persistence = 0.5f)
{
return VoxelNoise::Ridged(Position.X, Position.Y, Position.Z,
Octaves, Lacunarity, Persistence);
}
//=============================================================================
// WORLEY / CELLULAR NOISE (3D)
//=============================================================================
// Distance to nearest feature point in a hash grid.
// Creates rounded, cell-like patterns: bubble walls, grotto pockets,
// honeycomb textures. Very different character from Perlin-based noise.
//
// Algorithm:
// 1. Find which grid cell the point is in
// 2. Check the 3x3x3 neighborhood for feature points
// 3. Return (2nd nearest - 1st nearest) distance, normalized to ~[-1, 1]
//
// Using F2-F1 (difference of two closest distances) gives smooth cell
// boundaries with ridges between cells — more interesting than raw distance.
// Le CORPS a déménagé dans Public/VoxelCaveMorphology.h (namespace VoxelNoise), au plus bas point
// qui voit déjà `VoxelHash` : la pile d'opérateurs a besoin exactement du même bruit pour la
// rugosité (4b, type Cellular) et pour les festons (4f), et deux copies d'une fonction pure finissent
// par diverger — c'est littéralement `AUDIT §C1`. Ce forwarder garde les ~3 sites d'appel ci-dessous
// inchangés, comme l'ont fait FractalNoise3D et RidgedNoise3D lors de T2.a. Le chemin chaud garde
// maintenant les coordonnées en float : la compatibilité d'arrondi historique est abandonnée.
static float CellularNoise3D(const FVector3f& Position)
{
return VoxelNoise::Cellular3D(Position);
}
//=============================================================================
// DENSITY PIPELINE HELPERS (partagés entre TunnelNetwork et Slab)
//=============================================================================
// Les CORPS de ces trois helpers ont déménagé dans Public/VoxelDensityPrimitives.h : la pile
// d'opérateurs (VoxelDensityOpStack) a besoin exactement des mêmes, et deux copies des INVARIANTS
// de monde (descente possible, seals qui tiennent, passages qui percent, bord fermé) finiraient par
// diverger. Ces trois lignes gardent les noms locaux pour que les ~20 sites d'appel ci-dessous ne
// bougent pas d'un caractère — le déplacement ne change AUCUN comportement.
//
// The BODIES moved to Public/VoxelDensityPrimitives.h; the operator stack needs the same primitives,
// and duplicate world invariants would eventually drift. These forwarders keep the local names
// so not one of the ~20 call sites below changes. No behavioural change.
//
// Convention INTERNE ici : positif = SOLIDE. La négation vers MC se fait sur le `return`.
static FORCEINLINE void ApplyBoundarySeal(float& Density, float WorldZ,
float StrateTopZ, float StrateBottomZ, float Thickness, float BaseDensity)
{
VF_ApplyBoundarySeal(Density, WorldZ, StrateTopZ, StrateBottomZ, Thickness, BaseDensity);
}
static FORCEINLINE void ApplyOriginSpine(float& Density, float WorldX, float WorldY, float WorldZ,
float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius)
{
VF_ApplyOriginSpine(Density, WorldX, WorldY, WorldZ,
StrateTopZ, StrateBottomZ, SealThickness, BaseDensity, Radius);
}
static FORCEINLINE void ApplyOriginLandingFloor(float& Density,
float WorldX, float WorldY, float WorldZ,
float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius)
{
VF_ApplyOriginLandingFloor(Density, WorldX, WorldY, WorldZ,
StrateTopZ, StrateBottomZ, SealThickness, BaseDensity, Radius);
}
// DISTURBANCE LAYER — the "wow" post-process. Operates on the FINAL MC density
// (negative = solid, positive = air), AFTER the archetype produced its terrain, so
// it works uniformly for every generator type. Stays inside the seal bands so it can
// never breach a strate boundary. All features are hash-placed and deterministic.
static void ApplyDisturbances(float& MC, float X, float Y, float Z,
const FStrateDisturbanceParams& D, uint32 Seed, bool bProtectVerticalShaftAir)
{
VoxelDensityProfile::FScopedTimer ProfileTimer(
VoxelDensityProfile::EBucket::ApplyDisturbances);
const float InnerTop = D.StrateTopWorldZ - D.BoundarySealThickness;
const float InnerBot = D.StrateBottomWorldZ + D.BoundarySealThickness;
if (Z <= InnerBot || Z >= InnerTop) return;
const float Solid = D.BaseDensity * 2.0f;
const float Blend = 3.0f;
const FVector P(X, Y, Z);
// 2D point-to-segment distance helper (XY plane).
auto Dist2DSeg = [](float px, float py, float ax, float ay, float bx, float by) -> float
{
const float abx = bx - ax, aby = by - ay;
const float apx = px - ax, apy = py - ay;
const float denom = FMath::Max(abx * abx + aby * aby, KINDA_SMALL_NUMBER);
float t = FMath::Clamp((apx * abx + apy * aby) / denom, 0.0f, 1.0f);
const float cx = ax + abx * t, cy = ay + aby * t;
return FMath::Sqrt(FMath::Square(px - cx) + FMath::Square(py - cy));
};
// Each feature's 3×3 lattice candidates (existence roll, jitter, angle trig, Z anchor) are pure
// functions of (cell, seed, params) yet were re-hashed PER VOXEL — including Cos/Sin per bridge/
// ridge candidate. Bake each feature's nearby primitives once per centre cell (thread_local);
// the per-voxel work is just the distance math. Bit-identical (same hashes, same math).
// --- CHASMS: vertical rifts carve open air ---
if (D.ChasmDensity > 0.0f && D.ChasmSpacing > 0.0f)
{
const float Sp = D.ChasmSpacing;
const int32 cx = FMath::FloorToInt(X / Sp), cy = FMath::FloorToInt(Y / Sp);
struct FChasm { float X, Y; };
thread_local TArray<FChasm, TInlineAllocator<9>> Chasms;
thread_local int32 CH_CX = INT32_MAX, CH_CY = INT32_MAX;
thread_local uint32 CH_Seed = 0xFFFFFFFFu;
thread_local float CH_Sp = -1.0f, CH_Dens = -1.0f;
if (cx != CH_CX || cy != CH_CY || Seed != CH_Seed || Sp != CH_Sp || D.ChasmDensity != CH_Dens)
{
CH_CX = cx; CH_CY = cy; CH_Seed = Seed; CH_Sp = Sp; CH_Dens = D.ChasmDensity;
Chasms.Reset();
for (int32 dy = -1; dy <= 1; dy++)
for (int32 dx = -1; dx <= 1; dx++)
{
const int32 nx = cx + dx, ny = cy + dy;
const uint32 h = VoxelHash::Cell(nx, ny, Seed ^ 0x43480001u);
if (VoxelHash::ToFloat01(h) > D.ChasmDensity) continue;
const float jx = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0x12345678u));
const float jy = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0x9ABCDEF0u));
Chasms.Add({ (nx + 0.15f + jx * 0.7f) * Sp, (ny + 0.15f + jy * 0.7f) * Sp });
}
}
float sdf = FLT_MAX;
for (const FChasm& C : Chasms)
{
sdf = FMath::Min(sdf, FMath::Sqrt(FMath::Square(X - C.X) + FMath::Square(Y - C.Y)) - D.ChasmRadius);
}
if (sdf < Blend)
{
float c = FMath::Clamp((Blend - sdf) / (Blend * 2.0f), 0.0f, 1.0f);
c = SmoothStep01(c);
MC = FMath::Max(MC, c * Solid); // force air
}
}
// --- BRIDGES: horizontal solid spans across open space ---
if (D.BridgeDensity > 0.0f && D.BridgeSpacing > 0.0f)
{
const float Sp = D.BridgeSpacing;
const int32 cx = FMath::FloorToInt(X / Sp), cy = FMath::FloorToInt(Y / Sp);
struct FBridge { FVector A, B; };
thread_local TArray<FBridge, TInlineAllocator<9>> Bridges;
thread_local int32 BR_CX = INT32_MAX, BR_CY = INT32_MAX;
thread_local uint32 BR_Seed = 0xFFFFFFFFu;
thread_local float BR_Sp = -1.0f, BR_Dens = -1.0f, BR_Bot = FLT_MAX, BR_Top = FLT_MAX;
if (cx != BR_CX || cy != BR_CY || Seed != BR_Seed || Sp != BR_Sp ||
D.BridgeDensity != BR_Dens || InnerBot != BR_Bot || InnerTop != BR_Top)
{
BR_CX = cx; BR_CY = cy; BR_Seed = Seed; BR_Sp = Sp;
BR_Dens = D.BridgeDensity; BR_Bot = InnerBot; BR_Top = InnerTop;
Bridges.Reset();
for (int32 dy = -1; dy <= 1; dy++)
for (int32 dx = -1; dx <= 1; dx++)
{
const int32 nx = cx + dx, ny = cy + dy;
const uint32 h = VoxelHash::Cell(nx, ny, Seed ^ 0x42520001u);
if (VoxelHash::ToFloat01(h) > D.BridgeDensity) continue;
const float zc = FMath::Lerp(InnerBot + 8.0f, InnerTop - 8.0f,
VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0xB1u)));
const float ang = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0xB2u)) * PI;
const float dxu = FMath::Cos(ang), dyu = FMath::Sin(ang);
const float bx = (nx + 0.5f) * Sp, by = (ny + 0.5f) * Sp;
const float half = Sp * 0.6f;
Bridges.Add({ FVector(bx - dxu * half, by - dyu * half, zc),
FVector(bx + dxu * half, by + dyu * half, zc) });
}
}
float sdf = FLT_MAX;
for (const FBridge& Br : Bridges)
{
sdf = FMath::Min(sdf, VoxelSDF::Capsule(P, Br.A, Br.B, D.BridgeRadius));
}
if (sdf < Blend)
{
float f = FMath::Clamp((Blend - sdf) / (Blend * 2.0f), 0.0f, 1.0f);
f = SmoothStep01(f);
if (!bProtectVerticalShaftAir)
{
MC = FMath::Min(MC, -f * Solid); // force solid
}
}
}
// --- RIDGES: thin solid blades rising from the floor ---
if (D.RidgeDensity > 0.0f && D.RidgeSpacing > 0.0f && D.RidgeHeight > 0.0f)
{
const float Sp = D.RidgeSpacing;
const float TopZ = FMath::Min(InnerBot + D.RidgeHeight, InnerTop);
if (Z < TopZ)
{
const int32 cx = FMath::FloorToInt(X / Sp), cy = FMath::FloorToInt(Y / Sp);
struct FRidge { float AX, AY, BX, BY; };
thread_local TArray<FRidge, TInlineAllocator<9>> Ridges;
thread_local int32 RG_CX = INT32_MAX, RG_CY = INT32_MAX;
thread_local uint32 RG_Seed = 0xFFFFFFFFu;
thread_local float RG_Sp = -1.0f, RG_Dens = -1.0f;
if (cx != RG_CX || cy != RG_CY || Seed != RG_Seed || Sp != RG_Sp || D.RidgeDensity != RG_Dens)
{
RG_CX = cx; RG_CY = cy; RG_Seed = Seed; RG_Sp = Sp; RG_Dens = D.RidgeDensity;
Ridges.Reset();
for (int32 dy = -1; dy <= 1; dy++)
for (int32 dx = -1; dx <= 1; dx++)
{
const int32 nx = cx + dx, ny = cy + dy;
const uint32 h = VoxelHash::Cell(nx, ny, Seed ^ 0x52470001u);
if (VoxelHash::ToFloat01(h) > D.RidgeDensity) continue;
const float ang = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0x9001u)) * PI;
const float dxu = FMath::Cos(ang), dyu = FMath::Sin(ang);
const float bx = (nx + 0.5f) * Sp, by = (ny + 0.5f) * Sp;
const float half = Sp * 0.45f;
Ridges.Add({ bx - dxu * half, by - dyu * half, bx + dxu * half, by + dyu * half });
}
}
float best = -FLT_MAX; // strongest fill across nearby blades
for (const FRidge& R : Ridges)
{
const float d2d = Dist2DSeg(X, Y, R.AX, R.AY, R.BX, R.BY);
const float wallSDF = d2d - D.RidgeThickness; // <0 inside the blade footprint
if (wallSDF >= Blend) continue;
const float zFade = 1.0f - FMath::Clamp((Z - InnerBot) / FMath::Max(D.RidgeHeight, 1.0f), 0.0f, 1.0f);
float f = FMath::Clamp((Blend - wallSDF) / (Blend * 2.0f), 0.0f, 1.0f);
f = SmoothStep01(f) * zFade;
best = FMath::Max(best, f);
}
if (best > 0.0f && !bProtectVerticalShaftAir)
{
MC = FMath::Min(MC, -best * Solid); // force solid
}
}
}
}
//=============================================================================
// LE MAPPING « ARCHÉTYPE → PILE D'OPÉRATEURS » — UNE SEULE DÉFINITION
//=============================================================================
// ⚠️ EXTRAIT DE `GetDensityAt` PARCE QUE `ClassifyTile` EN A BESOIN AUSSI, ET QU'UNE DEUXIÈME COPIE
// SERAIT LA PIRE FORME DE BUG DISPONIBLE ICI.
//
// `ClassifyTile` décide si une tuile est maillée DU TOUT. Si son verdict venait d'une pile
// construite autrement que celle qui produit la densité — ne serait-ce qu'un paramètre de
// construction différent — la tuile serait sautée sur la foi d'un champ qui n'est pas celui que le
// mesher aurait vu. C'est-à-dire un TROU : pas de géométrie, pas de collision, invisible.
// Un commentaire « garder les deux en phase » n'aurait pas suffi ; il fallait qu'il n'y en ait
// qu'une.
//
// Les params ne sont PAS cherchés ici : les deux appelants les ont déjà (le chemin densité les tient
// dans ses `CP_*`, le classifieur les cherche pour son slot). On ne passe que des pointeurs.
//
// ONE definition of the archetype → stack mapping, because ClassifyTile decides whether a tile is
// meshed at all: a verdict from a differently-built stack would be a hole. Params are passed in,
// never fetched here — both callers already have them.
namespace
{
// Une identité monotone évite qu'un worker réutilise les CP_* d'un monde détruit même si
// l'allocateur UObject recycle plus tard la même adresse. Relaxed suffit : on ne publie aucune
// donnée, on alloue seulement une valeur distincte par instance.
// A monotonic identity prevents stale CP_* reuse even if UObject allocation later recycles an
// address. Relaxed ordering is sufficient: this allocates uniqueness, it publishes no data.
std::atomic<uint64> GNextDensityCacheOwnerId { 0 };
struct FTunnelCoreCacheState
{
FChunkSDFCache Cache;
uint64 OwnerId = 0;
uint64 ManagerLifetimeId = 0;
FIntVector Chunk = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
uint32 ParamsFingerprint = 0xFFFFFFFFu;
uint32 LayoutVersion = 0xFFFFFFFFu;
bool bValid = false;
};
static thread_local FTunnelCoreCacheState GTunnelCoreCache;
static void BuildTunnelSupportColumnsForChunk(
FChunkSDFCache& Cache,
const FIntVector& ChunkCoord,
const UVoxelGenerator* Generator,
const FStrateGenerationParams& Params,
uint32 ParamsFingerprint,
uint32 LayoutVersion);
static const FTunnelSupportFloorColumn* FindTunnelSupportColumn(
const FChunkSDFCache& Cache,
int32 X,
int32 Y,
const FTunnelSupportFloorColumn& EmptyColumn);
// A mesher tile samples an expanded grid (-1..Cells+1), so one worker visits several exact
// chunk coordinates even while it is generating one tile. The old CP_* cache retained only
// the last coordinate and consequently rebuilt the whole TunnelNetwork stack whenever the
// sampling cursor crossed a chunk boundary. Keep a deterministic direct-mapped set of states
// instead: collisions only cause a safe rebuild, never a stale read or an order-dependent
// result. Native TunnelNetwork/Underwater states are immutable after construction; the active
// pointer below lets the hot sample path read the stored stack and tunnel-core cache without
// moving them on every voxel.
// The cache is open-addressed and bounded. Keep the worker footprint at the established
// 128-entry cap; the expensive native tunnel-core cache is not prepared for an operator-stack
// chunk because the stack publishes the same result to the common post below.
constexpr int32 TunnelDensityCacheSlotCount = 128;
struct FTunnelNetworkDensityCacheEntry
{
bool bValid = false;
uint64 OwnerId = 0;
uint64 ManagerLifetimeId = 0;
FIntVector Chunk = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
uint32 LayoutVersion = 0xFFFFFFFFu;
ECaveGeneratorType GenType = ECaveGeneratorType::TunnelNetwork;
FStrateGenerationParams Tunnel;
uint32 TunnelFingerprint = 0xFFFFFFFFu;
FStrateDisturbanceParams Disturbance;
bool bUseOpStack = false;
FVoxelOpStack OpStack;
FTunnelCoreCacheState TunnelCore;
};
void ReportTunnelDensityCacheFootprint(
const FTunnelNetworkDensityCacheEntry* Entries)
{
if (!VoxelDensityProfile::IsEnabled() || Entries == nullptr)
{
return;
}
uint64 DynamicBytes = 0;
uint64 EntryBytes = 0;
uint64 LargestEntryBytes = 0;
uint64 ValidEntries = 0;
VoxelDensityProfile::FCacheMemoryBreakdown Breakdown;
for (int32 Index = 0; Index < TunnelDensityCacheSlotCount; ++Index)
{
const FTunnelNetworkDensityCacheEntry& Entry = Entries[Index];
if (!Entry.bValid)
{
continue;
}
++ValidEntries;
const uint64 StackBytes = static_cast<uint64>(Entry.OpStack.GetAllocatedSize());
const uint64 TunnelCoreBytes = static_cast<uint64>(
Entry.TunnelCore.Cache.GetAllocatedSize());
Breakdown.OpStackBytes += StackBytes;
Breakdown += Entry.TunnelCore.Cache.GetAllocatedSizeBreakdown();
const uint64 EntryDynamicBytes = StackBytes + TunnelCoreBytes;
const uint64 FullEntryBytes = static_cast<uint64>(
sizeof(FTunnelNetworkDensityCacheEntry)) + EntryDynamicBytes;
DynamicBytes += EntryDynamicBytes;
EntryBytes += FullEntryBytes;
LargestEntryBytes = FMath::Max(LargestEntryBytes, FullEntryBytes);
}
Breakdown.SlotStorageBytes = static_cast<uint64>(sizeof(FTunnelNetworkDensityCacheEntry))
* TunnelDensityCacheSlotCount;
VoxelDensityProfile::SetWorkerTunnelCacheFootprint(
TunnelDensityCacheSlotCount,
ValidEntries,
static_cast<uint64>(sizeof(FTunnelNetworkDensityCacheEntry))
* TunnelDensityCacheSlotCount,
DynamicBytes,
EntryBytes,
LargestEntryBytes,
ValidEntries,
Breakdown);
}
FORCEINLINE int32 TunnelDensityCacheSlot(const FIntVector& Chunk)
{
uint32 Hash = static_cast<uint32>(Chunk.X) * 0x9E3779B9u;
Hash ^= static_cast<uint32>(Chunk.Y) * 0x85EBCA6Bu;
Hash ^= static_cast<uint32>(Chunk.Z) * 0xC2B2AE35u;
Hash ^= Hash >> 16;
return static_cast<int32>(Hash & (TunnelDensityCacheSlotCount - 1));
}
void PrepareTunnelCoreCache(
const UVoxelGenerator& Generator,
const UVoxelStrateManager& Manager,
const FIntVector& ChunkCoord,
const FStrateGenerationParams& Params,
uint32 ParamsFingerprint,
uint32 LayoutVersion,
uint64 ManagerLifetimeId,
uint64 OwnerId,
FTunnelCoreCacheState& OutState)
{
if (OutState.bValid
&& OutState.OwnerId == OwnerId
&& OutState.ManagerLifetimeId == ManagerLifetimeId
&& OutState.Chunk == ChunkCoord
&& OutState.ParamsFingerprint == ParamsFingerprint
&& OutState.LayoutVersion == LayoutVersion)
{
return;
}
const float ChunkMinX = static_cast<float>(ChunkCoord.X * CHUNK_SIZE);
const float ChunkMinY = static_cast<float>(ChunkCoord.Y * CHUNK_SIZE);
const float ChunkMaxX = ChunkMinX + static_cast<float>(CHUNK_SIZE);
const float ChunkMaxY = ChunkMinY + static_cast<float>(CHUNK_SIZE);
const float Expansion = FMath::Abs(Params.CaveWarpStrength)
* VOXEL_NOISE_SCALE * 1.5f + 2.0f;
const int32 StrateIndex = Manager.GetStrateIndex(
(static_cast<float>(ChunkCoord.Z) + 0.5f)
* static_cast<float>(CHUNK_SIZE) * VOXEL_SIZE);
VoxelCaveMorphology::BuildChunkCache(
OutState.Cache,
ChunkMinX - Expansion, ChunkMinY - Expansion,
ChunkMaxX + Expansion, ChunkMaxY + Expansion,
Params, static_cast<uint32>(Manager.GetWorldSeed()),
StrateIndex, nullptr);
BuildTunnelSupportColumnsForChunk(
OutState.Cache, ChunkCoord, &Generator, Params,
ParamsFingerprint, LayoutVersion);
OutState.OwnerId = OwnerId;
OutState.ManagerLifetimeId = ManagerLifetimeId;
OutState.Chunk = ChunkCoord;
OutState.ParamsFingerprint = ParamsFingerprint;
OutState.LayoutVersion = LayoutVersion;
OutState.bValid = true;
}
static void BuildTunnelSupportColumnsForChunk(
FChunkSDFCache& Cache,
const FIntVector& ChunkCoord,
const UVoxelGenerator* Generator,
const FStrateGenerationParams& Params,
uint32 ParamsFingerprint,
uint32 LayoutVersion)
{
const int32 MinX = ChunkCoord.X * CHUNK_SIZE - 1;
const int32 MinY = ChunkCoord.Y * CHUNK_SIZE - 1;
const int32 Cells = CHUNK_SIZE + 2;
Cache.SupportColumnMinX = MinX;
Cache.SupportColumnMinY = MinY;
Cache.SupportColumnCellsX = Cells;
Cache.SupportColumnCellsY = Cells;
Cache.SupportColumnEntries.Init(INDEX_NONE, Cells * Cells);
Cache.SupportColumns.Reset();
// The conditional floor is tested against the exact density pipeline with only the
// graph support slab disabled. The sampler is synchronous and lives only for this cache
// build; the immutable cache stores the resulting interval decision, never the callback.
const auto SampleWithoutTunnelSupportFloor =
[Generator, &Params, ParamsFingerprint, LayoutVersion](
float X, float Y, float Z) -> float
{
return Generator != nullptr
? Generator->GetDensityWithParams(
X, Y, Z, Params, ParamsFingerprint, LayoutVersion,
/*bApplyLegacyStructuralPosts=*/true,
/*bApplyTunnelSupportFloor=*/false)
: 0.0f;
};
FTunnelSupportFloorDensitySampler NaturalSamplerRef =
SampleWithoutTunnelSupportFloor;
const FTunnelSupportFloorDensitySampler* NaturalSampler =
Generator != nullptr ? &NaturalSamplerRef : nullptr;
// Empty columns are represented only by INDEX_NONE in the slot map, so
// each stored column retains the inline bands for actual graph tubes.
for (int32 Y = 0; Y < Cells; ++Y)
{
for (int32 X = 0; X < Cells; ++X)
{
FTunnelSupportFloorColumn Column;
VoxelCaveMorphology::BuildTunnelSupportFloorColumn(
static_cast<float>(MinX + X),
static_cast<float>(MinY + Y),
Cache,
Column,
NaturalSampler,
Params.StrateTopWorldZ,
Params.StrateBottomWorldZ,
Params.BoundarySealThickness);
if (Column.Intervals.Num() == 0)
{
continue;
}
const int32 EntryIndex = Cache.SupportColumns.Add(MoveTemp(Column));
Cache.SupportColumnEntries[Y * Cells + X] = EntryIndex;
}
}
}
static const FTunnelSupportFloorColumn* FindTunnelSupportColumn(
const FChunkSDFCache& Cache,
int32 X,
int32 Y,
const FTunnelSupportFloorColumn& EmptyColumn)
{
if (Cache.SupportColumnCellsX <= 0
|| X < Cache.SupportColumnMinX
|| X >= Cache.SupportColumnMinX + Cache.SupportColumnCellsX
|| Y < Cache.SupportColumnMinY
|| Y >= Cache.SupportColumnMinY + Cache.SupportColumnCellsY)
{
return nullptr;
}
const int32 Slot =
(Y - Cache.SupportColumnMinY) * Cache.SupportColumnCellsX
+ (X - Cache.SupportColumnMinX);
const int32 EntryIndex = Cache.SupportColumnEntries[Slot];
return EntryIndex == INDEX_NONE
? &EmptyColumn
: &Cache.SupportColumns[EntryIndex];
}
struct FVoxelStackParamRefs
{
const FSlabGenerationParams* Slab = nullptr;
const FMazeGenerationParams* Maze = nullptr;
const FVerticalShaftParams* Vert = nullptr;
const FFloatingIslandParams* Float = nullptr;
const FStrateGenerationParams* Tunnel = nullptr;
// SurfaceWorld uniquement. `Surface == nullptr` ⇒ la fabrique REFUSE cet archétype, ce qui
// est exactement ce que veut `ClassifyTile` : il prouve SurfaceWorld lui-même, sur le
// treillis exact du mesher, et n'a aucune raison de passer par la pile pour ça.
const FSurfaceGenerationParams* Surface = nullptr;
const TArray<FSurfaceGenerationParams>* SurfaceBiomeParams = nullptr;
TUniquePtr<IVoxelBiomeField> BiomeField;
};
/**
* Construit la pile de cet archétype dans `OutStack` et remplit les bornes Z de `OutCtx`.
*
* @return false quand la pile NE DOIT PAS être utilisée — archétype non porté, params absents,
* ou **strate dégénérée**. Ce dernier cas n'est pas de la prudence : cinq fonctions
* d'archétype court-circuitent sur `return 1.0f` (= air) quand la hauteur est nulle,
* et la pile n'a pas cet early-out, par conception. L'appelant retombe sur le `switch`,
* qui EST le comportement de référence. (`GetDensityWithParams`, lui, n'a aucun
* early-out de ce genre : TunnelNetwork/Underwater n'ont donc pas cette garde.)
*/
bool VF_BuildOpStackForChunk(ECaveGeneratorType Type, FVoxelStackParamRefs& Refs,
int32 Seed, float SpineRadius, const UVoxelStrateManager* SM,
FVoxelOpStack& OutStack, FVoxelOpContext& OutCtx,
const UVoxelGenerator* Generator = nullptr)
{
switch (Type)
{
case ECaveGeneratorType::Maze:
if (!Refs.Maze) { return false; }
if (Refs.Maze->StrateTopWorldZ - Refs.Maze->StrateBottomWorldZ <= 0.0f) { return false; }
OutCtx.StrateTopWorldZ = Refs.Maze->StrateTopWorldZ;
OutCtx.StrateBottomWorldZ = Refs.Maze->StrateBottomWorldZ;
VoxelDensityOps::BuildMazeStack(OutStack, *Refs.Maze, Seed, SpineRadius, SM);
return true;
case ECaveGeneratorType::FlatPlain:
case ECaveGeneratorType::CrystalChamber:
// UN SEUL cas pour les deux, comme le `switch` de production : `GetSlabDensity` ne les
// distingue pas non plus. Voir BuildSlabStack.
if (!Refs.Slab) { return false; }
if (Refs.Slab->StrateTopWorldZ - Refs.Slab->StrateBottomWorldZ <= 0.0f) { return false; }
OutCtx.StrateTopWorldZ = Refs.Slab->StrateTopWorldZ;
OutCtx.StrateBottomWorldZ = Refs.Slab->StrateBottomWorldZ;
VoxelDensityOps::BuildSlabStack(OutStack, *Refs.Slab, Seed, SpineRadius, SM);
return true;
case ECaveGeneratorType::SurfaceWorld:
if (!Refs.Surface) { return false; }
if (Refs.Surface->StrateTopWorldZ - Refs.Surface->StrateBottomWorldZ <= 0.0f) { return false; }
OutCtx.StrateTopWorldZ = Refs.Surface->StrateTopWorldZ;
OutCtx.StrateBottomWorldZ = Refs.Surface->StrateBottomWorldZ;
VoxelDensityOps::BuildSurfaceStack(
OutStack, *Refs.Surface, Seed, SpineRadius, SM,
Refs.SurfaceBiomeParams ? *Refs.SurfaceBiomeParams : TArray<FSurfaceGenerationParams>(),
MoveTemp(Refs.BiomeField));
return true;
case ECaveGeneratorType::VerticalShafts:
if (!Refs.Vert) { return false; }
if (Refs.Vert->StrateTopWorldZ - Refs.Vert->StrateBottomWorldZ <= 0.0f) { return false; }
OutCtx.StrateTopWorldZ = Refs.Vert->StrateTopWorldZ;
OutCtx.StrateBottomWorldZ = Refs.Vert->StrateBottomWorldZ;
VoxelDensityOps::BuildVerticalShaftStack(OutStack, *Refs.Vert, Seed, SpineRadius, SM);
return true;
case ECaveGeneratorType::FloatingIslands:
if (!Refs.Float) { return false; }
if (Refs.Float->StrateTopWorldZ - Refs.Float->StrateBottomWorldZ <= 0.0f) { return false; }
OutCtx.StrateTopWorldZ = Refs.Float->StrateTopWorldZ;
OutCtx.StrateBottomWorldZ = Refs.Float->StrateBottomWorldZ;
VoxelDensityOps::BuildFloatingIslandStack(OutStack, *Refs.Float, Seed, SpineRadius, SM);
return true;
case ECaveGeneratorType::Underwater:
case ECaveGeneratorType::TunnelNetwork:
// Underwater EST TunnelNetwork plus un drapeau d'eau consommé côté rendu. Pas de garde
// de strate dégénérée : `GetDensityWithParams` n'a pas d'early-out à reproduire.
if (!Refs.Tunnel) { return false; }
OutCtx.StrateTopWorldZ = Refs.Tunnel->StrateTopWorldZ;
OutCtx.StrateBottomWorldZ = Refs.Tunnel->StrateBottomWorldZ;
VoxelDensityOps::BuildTunnelNetworkStack(
OutStack, *Refs.Tunnel, Seed, SpineRadius, SM,
/*bAppendStructuralPosts=*/true, Generator);
return true;
default:
// `UsesOperatorStackForChunk` ne rend true que pour les archétypes portés (les 8), donc
// on ne devrait jamais arriver ici. Si ça arrive : retomber sur le `switch` plutôt que
// générer du vide — un monde faux est pire qu'un monde non porté.
return false;
}
}
}
#if WITH_EDITOR
bool VF_BuildNativeStrateStackForCandidate(
ECaveGeneratorType Archetype,
const FVoxelStrateArchetypeParams& Params,
int32 InSeed,
float SpineRadius,
float InWorldRadiusVoxels,
float InEdgeSealThickness,
const UVoxelStrateManager* StrateManager,
FVoxelOpStack& OutStack,
FVoxelOpContext& OutContext)
{
FVoxelStackParamRefs Refs;
switch (Archetype)
{
case ECaveGeneratorType::FlatPlain:
case ECaveGeneratorType::CrystalChamber:
Refs.Slab = &Params.SlabParams;
break;
case ECaveGeneratorType::Maze:
Refs.Maze = &Params.MazeParams;
break;
case ECaveGeneratorType::SurfaceWorld:
// The live surface path can additionally carry biome-specific parameter arrays and a
// generator-owned biome field. The editor sanity check deliberately handles the exact
// no-biome/native case only; the caller reports a biome target as unavailable rather than
// comparing unlike fields.
Refs.Surface = &Params.SurfaceParams;
break;
case ECaveGeneratorType::VerticalShafts:
Refs.Vert = &Params.VerticalShaftParams;
break;
case ECaveGeneratorType::FloatingIslands:
Refs.Float = &Params.FloatingIslandParams;
break;
case ECaveGeneratorType::Underwater:
case ECaveGeneratorType::TunnelNetwork:
Refs.Tunnel = &Params.TunnelNetworkParams;
break;
default:
return false;
}
OutContext = FVoxelOpContext();
OutContext.Seed = static_cast<uint32>(InSeed);
OutContext.LayoutVersion = StrateManager != nullptr ? StrateManager->GetLayoutVersion() : 0;
OutContext.WorldRadiusVoxels = InWorldRadiusVoxels;
OutContext.EdgeSealThickness = InEdgeSealThickness;
if (!VF_BuildOpStackForChunk(Archetype, Refs, InSeed, SpineRadius, StrateManager,
OutStack, OutContext))
{
return false;
}
return true;
}
#endif
UVoxelGenerator::UVoxelGenerator()
: DensityCacheOwnerId(GNextDensityCacheOwnerId.fetch_add(1, std::memory_order_relaxed) + 1)
{
}
void UVoxelGenerator::InitializeSettings(const UVoxelSettings* Settings)
{
// Les paramètres globaux sont copiés ici une seule fois : le chemin voxel ne doit pas
// déréférencer l'asset de settings.
Seed = Settings ? Settings->GetEffectiveWorldSeed() : 0;
OriginSpineRadius = Settings ? Settings->GetEffectiveOriginSpineRadius() : 14.0f;
WorldRadiusVoxels = Settings ? Settings->GetEffectiveWorldRadiusVoxels() : 0.0f;
EdgeSealThickness = Settings ? Settings->EdgeSealThickness : 64.0f;
}
float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) const
{
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_GetDensityAt);
VoxelDensityProfile::FScopedTimer DensityProfileTimer(
VoxelDensityProfile::EBucket::GetDensityAt);
VoxelDensityProfile::FScopedTimer DensityPrologueTimer(
VoxelDensityProfile::EBucket::DensityPrologue);
// ── STRATE SYSTEM ──
// Query per-chunk params from the manager so each strate has different caves.
float Result;
// A vertical tree connector may need to stay open through the shared disturbance and
// structural-post tail below. This is one bit for this density call, not cached voxel data.
VoxelPassageGeometry::ResetVerticalShaftConnectorAirMarker();
FIntVector ChunkCoord(
FMath::FloorToInt(WorldX / CHUNK_SIZE),
FMath::FloorToInt(WorldY / CHUNK_SIZE),
FMath::FloorToInt(WorldZ / CHUNK_SIZE)
);
const bool bIsGapChunk = StrateManager && StrateManager->IsGapChunk(ChunkCoord);
if (StrateManager && bIsGapChunk)
{
// SOLID BEDROCK gap between two strates. The auto-carved passages still tunnel
// through it, but the (0,0) descent stays solid here so the player digs the gap
// to reach the next layer. No caves, no spine, no vertical seal — just rock + passage
// tube/landing;
// the global XY edge seal is applied below for bounded worlds.
DensityPrologueTimer.End();
VoxelDensityProfile::FScopedTimer DensityCoreTimer(
VoxelDensityProfile::EBucket::DensityCore);
float Density = 8.0f; // bedrock solidity (positive = solid)
StrateManager->ApplyPassageModifier(
Density, WorldX, WorldY, WorldZ, 8.0f, 0.0f);
VF_ApplyXYEdgeSeal(Density, WorldX, WorldY, WorldRadiusVoxels, EdgeSealThickness, 8.0f);
Result = -Density;
}
else if (StrateManager)
{
// ── PER-CHUNK PARAM CACHE ──
// GetDensityAt runs per voxel AND ~6× more per surface vertex (gradient normals).
// The generator type, the (boundary-blended) param struct, and the disturbance
// params are identical for the whole chunk, yet resolving them re-runs a strate
// lookup + copies large structs (and a ~60-field Lerp for blended cave chunks).
// Cache them thread-locally, keyed by owner + chunk coord + layout version — refetch only
// when one of those integer identities changes.
thread_local uint64 CP_OwnerId = 0;
thread_local FIntVector CP_Chunk(INT32_MAX, INT32_MAX, INT32_MAX);
thread_local ECaveGeneratorType CP_GenType = ECaveGeneratorType::TunnelNetwork;
thread_local FStrateGenerationParams CP_Tunnel;
// AUDIT §C2 — empreinte de `CP_Tunnel`, rafraîchie avec lui. Elle voyage jusqu'à la clé du
// cache SDF de `GetDensityWithParams` pour qu'un chunk ne puisse plus être évalué contre
// les salles d'un chunk voisin aux params blendés différemment.
thread_local uint32 CP_TunnelFP = 0xFFFFFFFFu;
thread_local FSlabGenerationParams CP_Slab;
thread_local FMazeGenerationParams CP_Maze;
thread_local FSurfaceGenerationParams CP_Surface;
thread_local FVerticalShaftParams CP_Vert;
thread_local FFloatingIslandParams CP_Float;
thread_local FStrateDisturbanceParams CP_Dist;
// Biomes (SurfaceWorld for now): CP_BiomeCtx is the cheap per-chunk flatten;
// CP_BiomeCache is the box-validated grid; CP_SurfaceBiomeParams holds each biome's
// resolved surface params (override or strate fallback) parallel to CP_BiomeCtx.Biomes.
thread_local FBiomeContext CP_BiomeCtx;
thread_local FChunkBiomeCache CP_BiomeCache;
thread_local TArray<FSurfaceGenerationParams> CP_SurfaceBiomeParams;
// T1.a per-column surface cache: GSurfColCache (file-scope, shared with ClassifyTile).
// 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;
// AUDIT C2 — la clé DOIT contenir la version de layout, pas seulement le chunk. Après un
// RebuildStrates / une édition à chaud, StrateManager reconstruit le layout et bumpe la
// version ; un worker dont CP_Chunk vaut encore ce chunk sauterait le refetch et
// générerait avec les ANCIENS params. Comme RegenerateAllChunks recharge les MÊMES coords
// de tuile, souvent sur les MÊMES workers, le cas est probable plutôt qu'exotique.
// The key MUST include the layout version, not just the chunk coord. Symptom without it:
// "I tweaked the strate asset, regenerated, and one patch kept the old shape."
thread_local uint32 CP_Version = 0xFFFFFFFFu;
// OPSTACK Phase 1 — la pile d'opérateurs, construite dans le MÊME bloc de refetch que les
// params (donc même clé owner+chunk+version, aucune logique d'invalidation en plus). Vide tant que
// la strate n'a pas coché `bUseOperatorStack` ET que son archétype n'est pas porté.
thread_local FVoxelOpStack CP_OpStack;
thread_local bool CP_UseOpStack = false;
thread_local uint64 CP_ManagerLifetimeId = 0;
thread_local FTunnelNetworkDensityCacheEntry CP_TunnelDensityCache[TunnelDensityCacheSlotCount];
thread_local FTunnelNetworkDensityCacheEntry* CP_ActiveTunnelDensityCache = nullptr;
// Cooked-season recipes and editor candidate recipes share this immutable worker-local
// hand-off. Recipe materialisation is runtime; rolling/selection remains editor-only.
thread_local bool CP_UseCustomRecipe = false;
thread_local int32 CP_CustomRecipeSeed = 0;
thread_local FVoxelStrateArchetypeParams CP_CustomParams;
thread_local FVoxelOpStackRecipe CP_CustomRecipe;
#if WITH_EDITOR
// Editor-only composer override. The candidate recipe is copied into this worker-local
// cache on the same versioned refetch as the native params; no worker reads mutable editor
// state while the world is applying a new candidate.
thread_local bool CP_UseComposerRegions = false;
thread_local int32 CP_ComposerSeed = 0;
thread_local FVoxelStrateRegionManifest CP_ComposerRegions;
#endif
const uint32 LayoutVersion = StrateManager->GetLayoutVersion();
const uint64 ManagerLifetimeId = StrateManager->GetCacheLifetimeId();
// TunnelNetwork/Underwater operator-stack parameters are selected from chunk Z only.
// A coarse tile samples thousands of exact XY chunk keys, but those keys describe the
// same immutable stack whenever the selected archetype is operator-stack backed. Share
// that prepared state by Z; the stack still queries the actual world position, and its
// room-graph cache remains keyed by the real XY search window. Legacy tunnel states keep
// the full XYZ key because their native cache is built for one XY window.
const ECaveGeneratorType QueryGeneratorType =
StrateManager->GetGeneratorTypeForChunk(ChunkCoord);
const bool bShareTunnelStackByZ = !bIsGapChunk
&& (QueryGeneratorType == ECaveGeneratorType::TunnelNetwork
|| QueryGeneratorType == ECaveGeneratorType::Underwater)
&& StrateManager->UsesOperatorStackForChunk(ChunkCoord);
const FIntVector TunnelCacheKey = bShareTunnelStackByZ
? FIntVector(0, 0, ChunkCoord.Z) : ChunkCoord;
FVoxelOpStack* ActiveOpStack = &CP_OpStack;
FTunnelCoreCacheState* ActiveTunnelCoreCache = &GTunnelCoreCache;
bool bLoadedTunnelDensityCache = false;
FTunnelNetworkDensityCacheEntry* TunnelDensityCacheEntryForWrite = nullptr;
// The table is open-addressed rather than one-slot direct-mapped. A direct map is very
// cheap until a coarse tile's regular lattice happens to put two chunk keys in the same
// bucket; then the two keys evict each other on every row and rebuild the prepared graph.
// Probe deterministically to the first empty/stale slot, retaining the bounded memory cap.
auto FindTunnelDensityCacheEntry = [&](const FIntVector& QueryChunk,
FTunnelNetworkDensityCacheEntry*& OutEntry) -> bool
{
const int32 StartSlot = TunnelDensityCacheSlot(TunnelCacheKey);
FTunnelNetworkDensityCacheEntry* ReuseEntry = nullptr;
for (int32 Probe = 0; Probe < TunnelDensityCacheSlotCount; ++Probe)
{
const int32 Slot = (StartSlot + Probe) & (TunnelDensityCacheSlotCount - 1);
FTunnelNetworkDensityCacheEntry& Candidate = CP_TunnelDensityCache[Slot];
if (!Candidate.bValid || Candidate.OwnerId != DensityCacheOwnerId)
{
if (ReuseEntry == nullptr) { ReuseEntry = &Candidate; }
if (!Candidate.bValid) { break; }
continue;
}
const bool bKeyMatches = Candidate.ManagerLifetimeId == ManagerLifetimeId
&& Candidate.Chunk == TunnelCacheKey
&& Candidate.LayoutVersion == LayoutVersion
&& Candidate.bUseOpStack == bShareTunnelStackByZ
&& (Candidate.GenType == ECaveGeneratorType::TunnelNetwork
|| Candidate.GenType == ECaveGeneratorType::Underwater);
if (bKeyMatches)
{
OutEntry = &Candidate;
return true;
}
}
OutEntry = ReuseEntry != nullptr
? ReuseEntry
: &CP_TunnelDensityCache[StartSlot];
return false;
};
if (!bIsGapChunk)
{
if (VoxelDensityProfile::AreCountersEnabled())
{
VoxelDensityProfile::AddCounter(
VoxelDensityProfile::ECounter::TunnelCacheLookup);
}
FTunnelNetworkDensityCacheEntry* CachedEntryPtr = nullptr;
const bool bCacheKeyMatches = FindTunnelDensityCacheEntry(ChunkCoord, CachedEntryPtr);
FTunnelNetworkDensityCacheEntry& CachedEntry = *CachedEntryPtr;
TunnelDensityCacheEntryForWrite = CachedEntryPtr;
if (bCacheKeyMatches)
{
if (VoxelDensityProfile::AreCountersEnabled())
{
VoxelDensityProfile::AddCounter(
VoxelDensityProfile::ECounter::TunnelCacheHit);
}
if (CP_ActiveTunnelDensityCache != &CachedEntry)
{
CP_OwnerId = CachedEntry.OwnerId;
CP_ManagerLifetimeId = CachedEntry.ManagerLifetimeId;
CP_Version = CachedEntry.LayoutVersion;
CP_Chunk = CachedEntry.Chunk;
CP_GenType = CachedEntry.GenType;
CP_Tunnel = CachedEntry.Tunnel;
CP_TunnelFP = CachedEntry.TunnelFingerprint;
CP_Dist = CachedEntry.Disturbance;
CP_UseOpStack = CachedEntry.bUseOpStack;
CP_UseCustomRecipe = false;
#if WITH_EDITOR
CP_UseComposerRegions = false;
#endif
CP_ActiveTunnelDensityCache = &CachedEntry;
}
ActiveOpStack = &CachedEntry.OpStack;
ActiveTunnelCoreCache = &CachedEntry.TunnelCore;
bLoadedTunnelDensityCache = true;
}
else if (VoxelDensityProfile::AreCountersEnabled())
{
VoxelDensityProfile::AddCounter(
VoxelDensityProfile::ECounter::TunnelCacheMiss);
}
}
const bool bOwnerChanged = DensityCacheOwnerId != CP_OwnerId;
const bool bManagerChanged = ManagerLifetimeId != CP_ManagerLifetimeId;
if (!bLoadedTunnelDensityCache
&& (bOwnerChanged || bManagerChanged || ChunkCoord != CP_Chunk
|| LayoutVersion != CP_Version))
{
CP_ActiveTunnelDensityCache = nullptr;
// La grille de biome est validée par une BOÎTE XY, qui ne dit rien du FBiomeContext
// ayant servi à classer ses cellules : sur un changement de version elle est périmée
// même si la boîte couvre encore la requête. Même invalidation quand le propriétaire
// change : deux mondes peuvent partager version et coordonnées, jamais leur contexte.
// The biome grid's XY box says nothing about its context. Owner changes invalidate it
// too: two worlds may share version and coordinates, never cached params/context.
if (bOwnerChanged || bManagerChanged || LayoutVersion != CP_Version)
{
CP_BiomeCache.Invalidate();
}
CP_OwnerId = DensityCacheOwnerId;
CP_ManagerLifetimeId = ManagerLifetimeId;
CP_Version = LayoutVersion;
CP_Chunk = ChunkCoord;
CP_GenType = StrateManager->GetGeneratorTypeForChunk(ChunkCoord);
CP_UseCustomRecipe = false;
bool bAllowCustomRecipe = true;
#if WITH_EDITOR
CP_UseComposerRegions = false;
if (StrateManager->GetComposerRegionOverrideForChunk(
ChunkCoord, CP_ComposerRegions))
{
CP_UseComposerRegions = true;
bAllowCustomRecipe = false;
CP_ComposerSeed = CP_ComposerRegions.Seed;
if (CP_ComposerRegions.Regions.Num() > 0)
{
CP_GenType = CP_ComposerRegions.Regions[0].Archetype;
}
}
#endif
if (bAllowCustomRecipe
&& StrateManager->GetRecipeForChunk(
ChunkCoord, CP_CustomRecipeSeed, CP_GenType,
CP_CustomParams, CP_CustomRecipe))
{
CP_UseCustomRecipe = true;
}
switch (CP_GenType)
{
case ECaveGeneratorType::FlatPlain:
case ECaveGeneratorType::CrystalChamber:
CP_Slab = StrateManager->GetSlabParamsForChunk(ChunkCoord); break;
case ECaveGeneratorType::Maze:
CP_Maze = StrateManager->GetMazeParamsForChunk(ChunkCoord); break;
case ECaveGeneratorType::SurfaceWorld:
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:
CP_Float = StrateManager->GetFloatingIslandParamsForChunk(ChunkCoord); break;
default: // TunnelNetwork / Underwater
CP_Tunnel = StrateManager->GetGenerationParams(ChunkCoord);
// AUDIT §C2 — l'empreinte est calculée ICI, une fois par chunk, au seul endroit où
// les params changent. `FStrateGenerationParams` est du POD pur (aucun TArray /
// FString / pointeur), donc une CRC mémoire ne peut pas donner de FAUX POSITIF ; au
// pire un octet de padding donne un faux MANQUE, c'est-à-dire une reconstruction de
// cache. On se trompe du côté du CPU, jamais du côté d'une salle fausse.
CP_TunnelFP = FCrc::MemCrc32(&CP_Tunnel, sizeof(CP_Tunnel));
break;
}
CP_Dist = StrateManager->GetDisturbanceParamsForChunk(ChunkCoord);
// ── OPSTACK Phase 1 : (re)construire la pile si cette strate l'a demandée. ──
// Une seule branche ajoutée au chemin densité, et elle est FROIDE : la construction est
// par chunk (comme le refetch de params juste au-dessus), jamais par voxel.
CP_UseOpStack = StrateManager->UsesOperatorStackForChunk(ChunkCoord);
CP_UseOpStack = CP_UseOpStack || CP_UseCustomRecipe;
#if WITH_EDITOR
// A composer override is already an explicit, validated stack description. It must
// be evaluated even when the authored definition had the normal operator-stack opt-in
// disabled; otherwise a multi-region candidate would be installed in the manager but
// silently fall back to the old one-archetype switch.
CP_UseOpStack = CP_UseOpStack || CP_UseComposerRegions;
#endif
if (CP_UseOpStack)
{
CP_OpStack = FVoxelOpStack(); // move-assign : libère l'ancienne pile
bool bBuiltSpecialStack = false;
#if WITH_EDITOR
if (CP_UseComposerRegions)
{
bBuiltSpecialStack = true;
FVoxelOpContext RegionContext;
if (VF_BuildStrateRegionStack(
CP_ComposerRegions, OriginSpineRadius, StrateManager,
CP_OpStack, RegionContext, nullptr))
{
RegionContext.ChunkCoord = ChunkCoord;
RegionContext.Step = 1;
RegionContext.LayoutVersion = LayoutVersion;
RegionContext.WorldRadiusVoxels = WorldRadiusVoxels;
RegionContext.EdgeSealThickness = EdgeSealThickness;
CP_OpStack.PrepareChunk(RegionContext);
}
else
{
CP_UseOpStack = false;
}
}
#endif
if (!bBuiltSpecialStack)
{
if (CP_UseCustomRecipe)
{
FVoxelOpContext RecipeContext;
if (VF_BuildStackFromRecipe(
CP_CustomRecipe, CP_CustomParams, CP_CustomRecipeSeed,
OriginSpineRadius, StrateManager, CP_OpStack, RecipeContext, nullptr))
{
RecipeContext.ChunkCoord = ChunkCoord;
RecipeContext.Step = 1;
RecipeContext.LayoutVersion = LayoutVersion;
// The recipe builder supplies the candidate's vertical seal to the
// structural post. The XY edge seal is global and uses live settings.
RecipeContext.WorldRadiusVoxels = WorldRadiusVoxels;
RecipeContext.EdgeSealThickness = EdgeSealThickness;
CP_OpStack.PrepareChunk(RecipeContext);
}
else
{
// Import/load validates recipes before a season becomes active. Keep
// the fallback hole-safe if a future schema changes underneath it.
CP_UseOpStack = false;
}
}
else
{
FVoxelOpContext OpCtx;
OpCtx.ChunkCoord = ChunkCoord;
OpCtx.Seed = (uint32)Seed;
OpCtx.LayoutVersion = LayoutVersion;
OpCtx.WorldRadiusVoxels = WorldRadiusVoxels;
OpCtx.EdgeSealThickness = EdgeSealThickness;
// ⚠️ LE MAPPING VIT DANS `VF_BuildOpStackForChunk` (haut de ce fichier) ET NULLE
// PART AILLEURS — `ClassifyTile` appelle la MÊME fabrique. Un verdict de tuile issu
// d'une pile construite autrement serait un trou. Ici on ne fait que fournir les
// params déjà cherchés juste au-dessus.
FVoxelStackParamRefs Refs;
Refs.Slab = &CP_Slab;
Refs.Maze = &CP_Maze;
Refs.Vert = &CP_Vert;
Refs.Float = &CP_Float;
Refs.Tunnel = &CP_Tunnel;
// SurfaceWorld : le champ de biomes est fabriqué ICI, du côté qui connaît le
// générateur, et TRANSFÉRÉ à la pile. L'opérateur ne voit qu'une `IVoxelBiomeField`,
// ce qui lui permet de devenir un asset en Phase 3 sans traîner le générateur.
TArray<FSurfaceGenerationParams> PerBiome;
if (CP_GenType == ECaveGeneratorType::SurfaceWorld)
{
Refs.Surface = &CP_Surface;
if (CP_BiomeCtx.IsValid() && CP_SurfaceBiomeParams.Num() > 0)
{
PerBiome = CP_SurfaceBiomeParams;
Refs.SurfaceBiomeParams = &PerBiome;
Refs.BiomeField = MakeUnique<FGeneratorBiomeField>(
this, &CP_BiomeCtx, &CP_BiomeCache, ChunkCoord.Z);
}
}
CP_UseOpStack = VF_BuildOpStackForChunk(
CP_GenType, Refs, Seed, OriginSpineRadius,
StrateManager, CP_OpStack, OpCtx, this);
// Le test appelle PrepareChunk, pas la production : c'est exactement la divergence
// qui rend un opérateur vert en test et faux en jeu. Les sept `PrepareChunk`
// actuels sont vides, donc ceci ne change RIEN aujourd'hui — c'est le point : le
// premier opérateur qui hisse vraiment du travail par chunk doit trouver l'appel
// déjà là. `Step` reste 1 : GetDensityAt ne connaît pas le pas d'échantillonnage
// du mesher (voir le contrat T2.b dans VoxelDensityOp.h).
// The test calls PrepareChunk and production did not — the exact divergence that
// makes an op green in test and wrong in game. All seven bodies are empty today,
// which is the point: the first op that hoists real per-chunk work must find the
// call already here.
if (CP_UseOpStack) { CP_OpStack.PrepareChunk(OpCtx); }
}
}
}
// The legacy path needs its prepared native tunnel-core cache. An operator-stack
// tunnel source publishes the same world-space result after EvalMC, so preparing a
// second full graph/core cache here would only duplicate the work that made coarse
// streaming expensive in the first place. Custom/composer paths retain the legacy
// cache unless their own stack supplies the hand-off.
const bool bNativeTunnelCore = !CP_UseCustomRecipe
&& (CP_GenType == ECaveGeneratorType::TunnelNetwork
|| CP_GenType == ECaveGeneratorType::Underwater);
#if WITH_EDITOR
const bool bCanPrepareNativeTunnelCore = bNativeTunnelCore
&& (!CP_UseOpStack || CP_UseComposerRegions);
#else
const bool bCanPrepareNativeTunnelCore = bNativeTunnelCore && !CP_UseOpStack;
#endif
if (bCanPrepareNativeTunnelCore)
{
PrepareTunnelCoreCache(
*this, *StrateManager, ChunkCoord, CP_Tunnel, CP_TunnelFP,
LayoutVersion, ManagerLifetimeId, DensityCacheOwnerId, GTunnelCoreCache);
}
else
{
GTunnelCoreCache.bValid = false;
}
const bool bNativeTunnelCacheCandidate = !CP_UseCustomRecipe
&& (CP_GenType == ECaveGeneratorType::TunnelNetwork
|| CP_GenType == ECaveGeneratorType::Underwater);
#if WITH_EDITOR
const bool bCanStoreTunnelDensityCache = bNativeTunnelCacheCandidate
&& !CP_UseComposerRegions;
#else
const bool bCanStoreTunnelDensityCache = bNativeTunnelCacheCandidate;
#endif
if (!bLoadedTunnelDensityCache && bCanStoreTunnelDensityCache)
{
FTunnelNetworkDensityCacheEntry& CachedEntry = TunnelDensityCacheEntryForWrite
? *TunnelDensityCacheEntryForWrite
: CP_TunnelDensityCache[TunnelDensityCacheSlot(ChunkCoord)];
CachedEntry.bValid = false;
CachedEntry.OwnerId = DensityCacheOwnerId;
CachedEntry.ManagerLifetimeId = ManagerLifetimeId;
CachedEntry.Chunk = TunnelCacheKey;
CachedEntry.LayoutVersion = LayoutVersion;
CachedEntry.GenType = CP_GenType;
CachedEntry.Tunnel = CP_Tunnel;
CachedEntry.TunnelFingerprint = CP_TunnelFP;
CachedEntry.Disturbance = CP_Dist;
CachedEntry.bUseOpStack = CP_UseOpStack;
CachedEntry.OpStack = MoveTemp(CP_OpStack);
CachedEntry.TunnelCore = MoveTemp(GTunnelCoreCache);
GTunnelCoreCache = FTunnelCoreCacheState();
CachedEntry.bValid = true;
CP_ActiveTunnelDensityCache = &CachedEntry;
ActiveOpStack = &CachedEntry.OpStack;
ActiveTunnelCoreCache = &CachedEntry.TunnelCore;
ReportTunnelDensityCacheFootprint(CP_TunnelDensityCache);
}
else if (!bLoadedTunnelDensityCache)
{
ActiveOpStack = &CP_OpStack;
ActiveTunnelCoreCache = &GTunnelCoreCache;
}
}
DensityPrologueTimer.End();
VoxelDensityProfile::FScopedTimer DensityCoreTimer(
VoxelDensityProfile::EBucket::DensityCore);
// Le seul point d'entrée de la pile dans le chemin de production. Elle rend la convention
// MC (négatif = solide) comme les fonctions d'archétype, donc les disturbances et la couche
// de diff qui suivent ne voient aucune différence.
if (CP_UseOpStack)
{
Result = ActiveOpStack->EvalMC(WorldX, WorldY, WorldZ);
}
else switch (CP_GenType)
{
case ECaveGeneratorType::FlatPlain:
case ECaveGeneratorType::CrystalChamber:
Result = GetSlabDensity(WorldX, WorldY, WorldZ, CP_Slab); break;
case ECaveGeneratorType::Maze:
Result = GetMazeDensity(WorldX, WorldY, WorldZ, CP_Maze); break;
case ECaveGeneratorType::SurfaceWorld:
{
FSurfaceColumn Col;
// 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;
// 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 = GSurfColCache.Acquire(IX, IY, CP_StrateKey, Seed, LayoutVersion);
const int32 CI = (IY - Box.BaseY) * FSurfaceColumnBox::Dim + (IX - Box.BaseX);
if (!Box.Computed[CI])
{
ComputeSurfaceColumn(WorldX, WorldY, ChunkCoord.Z, CP_Surface, CP_BiomeCtx,
CP_SurfaceBiomeParams, CP_BiomeCache,
Box.Cols[CI].TerrainZ, Box.Cols[CI].CeilSurf,
Box.Cols[CI].OverhangAmp, Box.Cols[CI].DirX, Box.Cols[CI].DirY);
Box.Computed[CI] = true;
}
Col = Box.Cols[CI];
}
else
{
ComputeSurfaceColumn(WorldX, WorldY, ChunkCoord.Z, CP_Surface, CP_BiomeCtx,
CP_SurfaceBiomeParams, CP_BiomeCache, Col.TerrainZ, Col.CeilSurf,
Col.OverhangAmp, Col.DirX, Col.DirY);
}
Result = SurfaceDensityFromColumn(WorldX, WorldY, WorldZ,
Col.TerrainZ, Col.CeilSurf, Col.OverhangAmp, Col.DirX, Col.DirY, CP_Surface);
break;
}
case ECaveGeneratorType::VerticalShafts:
Result = GetVerticalShaftDensity(WorldX, WorldY, WorldZ, CP_Vert); break;
case ECaveGeneratorType::FloatingIslands:
Result = GetFloatingIslandDensity(WorldX, WorldY, WorldZ, CP_Float); break;
case ECaveGeneratorType::Underwater:
case ECaveGeneratorType::TunnelNetwork:
default:
// Underwater shares tunnel rock (water table is a render-side overlay).
Result = GetDensityWithParams(WorldX, WorldY, WorldZ, CP_Tunnel,
CP_TunnelFP, LayoutVersion,
/*bApplyLegacyStructuralPosts=*/false); break;
}
DensityCoreTimer.End();
// Disturbance layer (the "wow" post-process) — cached params, MC convention. The
// vertical shaft tree opens a walkable route before this shared pass, so a bridge/ridge
// must not refill it. Both native and operator-stack paths use the same marker helper.
VoxelDensityProfile::FScopedTimer DensityDisturbancesTimer(
VoxelDensityProfile::EBucket::DensityDisturbances);
const bool bProtectVerticalShaftAir =
VoxelPassageGeometry::VerticalShaftConnectorAirMarker();
ApplyDisturbances(Result, WorldX, WorldY, WorldZ, CP_Dist, (uint32)Seed,
bProtectVerticalShaftAir);
DensityDisturbancesTimer.End();
// A disturbance is allowed to add visual detail, but it must not refill the landing's
// measured air volume or turn its support slab back into a hole. Reassert the landing
// air first, then the structural floor, in MC space before the final XY seal. The two
// calls use the same landing geometry already evaluated by the legacy and op-stack post.
VoxelDensityProfile::FScopedTimer DensityStructuralPostsTimer(
VoxelDensityProfile::EBucket::DensityStructuralPosts);
float LandingBaseDensity = CP_Dist.BaseDensity;
#if WITH_EDITOR
// A composer parent can deliberately use a different structural base than the authored
// definition. The landing floor was already applied by PassageCarveOp with that parent
// base; use the same value for this post-disturbance MC backstop or live-vs-direct stack
// evaluation would differ exactly on floor voxels.
if (CP_UseComposerRegions && CP_ComposerRegions.bHasGlobalStructuralParams)
{
LandingBaseDensity = CP_ComposerRegions.BaseDensity;
}
else
#endif
if (CP_UseCustomRecipe)
{
switch (CP_CustomRecipe.StructuralParamBlock)
{
case EVoxelStrateParamBlock::TunnelNetwork:
LandingBaseDensity = CP_CustomParams.TunnelNetworkParams.BaseDensity;
break;
case EVoxelStrateParamBlock::Slab:
LandingBaseDensity = CP_CustomParams.SlabParams.BaseDensity;
break;
case EVoxelStrateParamBlock::Maze:
LandingBaseDensity = CP_CustomParams.MazeParams.BaseDensity;
break;
case EVoxelStrateParamBlock::Surface:
LandingBaseDensity = CP_CustomParams.SurfaceParams.BaseDensity;
break;
case EVoxelStrateParamBlock::VerticalShaft:
LandingBaseDensity = CP_CustomParams.VerticalShaftParams.BaseDensity;
break;
case EVoxelStrateParamBlock::FloatingIsland:
LandingBaseDensity = CP_CustomParams.FloatingIslandParams.BaseDensity;
break;
default:
break;
}
}
else
{
switch (CP_GenType)
{
case ECaveGeneratorType::FlatPlain:
case ECaveGeneratorType::CrystalChamber:
LandingBaseDensity = CP_Slab.BaseDensity;
break;
case ECaveGeneratorType::Maze:
LandingBaseDensity = CP_Maze.BaseDensity;
break;
case ECaveGeneratorType::SurfaceWorld:
LandingBaseDensity = CP_Surface.BaseDensity;
break;
case ECaveGeneratorType::VerticalShafts:
LandingBaseDensity = CP_Vert.BaseDensity;
break;
case ECaveGeneratorType::FloatingIslands:
LandingBaseDensity = CP_Float.BaseDensity;
break;
case ECaveGeneratorType::TunnelNetwork:
case ECaveGeneratorType::Underwater:
default:
LandingBaseDensity = CP_Tunnel.BaseDensity;
break;
}
}
// Origin rooms are structural too. Reassert their air before either support writer so a
// bridge/ridge cannot plug a landing, while the two floor writers remain last.
VF_ApplyOriginLandingAirMC(
Result, WorldX, WorldY, WorldZ,
CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ,
CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius);
VF_ApplyOriginLandingFloorMC(
Result, WorldX, WorldY, WorldZ,
CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ,
CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius);
StrateManager->ApplyPassageStructuralPostsMC(
Result, WorldX, WorldY, WorldZ, LandingBaseDensity,
CP_Dist.BoundarySealThickness);
// Disturbance features are authored as a generic MC-space post and may add a ridge or
// bridge over a graph tunnel. Reassert the native cached tunnel core here, after every
// solid floor writer but before the global XY seal. This cache is built once per chunk,
// never once per voxel.
if (ActiveTunnelCoreCache->bValid || CP_UseOpStack)
{
FTunnelCoreWorldEvaluation TunnelCore;
bool bHaveTunnelCore = CP_UseOpStack
&& ActiveOpStack->TryGetLastTunnelCoreWorldEvaluation(TunnelCore);
if (!bHaveTunnelCore && ActiveTunnelCoreCache->bValid)
{
const FTunnelSupportFloorColumn* SupportColumn = nullptr;
FTunnelSupportFloorColumn EmptySupportColumn;
const bool bIntegerXY =
WorldX == FMath::FloorToFloat(WorldX)
&& WorldY == FMath::FloorToFloat(WorldY);
if (bIntegerXY)
{
const int32 IX = FMath::FloorToInt(WorldX);
const int32 IY = FMath::FloorToInt(WorldY);
SupportColumn = FindTunnelSupportColumn(
ActiveTunnelCoreCache->Cache, IX, IY, EmptySupportColumn);
}
VoxelDensityProfile::FScopedTimer ProfileTimer(
VoxelDensityProfile::EBucket::TunnelCorePosts);
TunnelCore = VoxelCaveMorphology::EvaluateTunnelCoreWorld(
WorldX, WorldY, WorldZ, ActiveTunnelCoreCache->Cache,
SupportColumn);
bHaveTunnelCore = true;
}
if (bHaveTunnelCore)
{
const bool bTunnelSupportFloor = TunnelCore.bSupportFloor;
if (bTunnelSupportFloor)
{
Result = FMath::Min(
Result,
-FMath::Max(LandingBaseDensity * 2.0f, 1.0f));
}
const float CoreSDF = TunnelCore.SDF;
if (!bTunnelSupportFloor
&& CoreSDF < -VoxelPassageGeometry::CaveTunnelAirCoreInsetVoxels)
{
Result = FMath::Max(
Result,
FMath::Max(LandingBaseDensity * 2.0f, 1.0f));
}
// The graph tunnel can overlap an inter-strate landing at a room mouth. Its air
// backstop is allowed to reopen the tunnel, but the landing's proved support floor
// must own the final floor band; otherwise the graph post can erase the only support
// surface at the mouth and leave the player-fit graph with a disconnected pocket.
VF_ApplyOriginLandingFloorMC(
Result, WorldX, WorldY, WorldZ,
CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ,
CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius);
StrateManager->ApplyPassageLandingFloorMC(
Result, WorldX, WorldY, WorldZ, LandingBaseDensity);
StrateManager->ApplyPassageLandingRoomFloorMC(
Result, WorldX, WorldY, WorldZ, LandingBaseDensity);
}
}
DensityStructuralPostsTimer.End();
}
else
{
// ── FALLBACK (no strate manager) ──
// Use default TunnelNetwork params — produces generic caves.
// `static` : ces params sont constants (construction par défaut), donc leur empreinte l'est
// aussi. La calculer une fois évite un CRC par voxel sur un chemin qui n'en a aucun besoin.
// `LayoutVersion = 0` : sans `StrateManager` il n'y a pas de layout, donc rien qui puisse
// périmer — et l'empreinte constante suffit à distinguer ce cache de tous les autres.
DensityPrologueTimer.End();
VoxelDensityProfile::FScopedTimer DensityCoreTimer(
VoxelDensityProfile::EBucket::DensityCore);
static const FStrateGenerationParams FallbackParams;
static const uint32 FallbackFP = FCrc::MemCrc32(&FallbackParams, sizeof(FallbackParams));
Result = GetDensityWithParams(WorldX, WorldY, WorldZ, FallbackParams, FallbackFP, 0);
DensityCoreTimer.End();
}
// The edge is the final generated structural invariant. Passage carving is ordered before it
// in every archetype/stack, and this MC-facing pass also covers out-of-layout air plus any
// disturbance that might otherwise carve back into the shell. Player edits remain separate
// below and are deliberately still the user override path.
VoxelDensityProfile::FScopedTimer DensityBoundarySealTimer(
VoxelDensityProfile::EBucket::DensityBoundarySeal);
VF_ApplyXYEdgeSealMC(Result, WorldX, WorldY, WorldRadiusVoxels, EdgeSealThickness, 8.0f);
DensityBoundarySealTimer.End();
//=========================================================================
// PLAYER MODIFICATIONS (diff layer)
//=========================================================================
// Applied LAST — player carving/filling overrides everything.
// The diff layer returns density in internal convention (negative = carve).
// Since Result is already in MC convention (negative = solid, positive = air),
// we subtract the diff offset:
// Carve (diff < 0) → Result -= negative → Result increases → more air ✓
// Fill (diff > 0) → Result -= positive → Result decreases → more solid ✓
VoxelDensityProfile::FScopedTimer DensityDiffLayerTimer(
VoxelDensityProfile::EBucket::DensityDiffLayer);
if (DiffLayer && DiffLayer->HasAnyMods())
{
// ── PER-CHUNK MOD SNAPSHOT ──
// The old HasModifications + GetDensityOffset pair took the diff layer's RWLock + a TMap find
// TWICE per voxel once any carve existed (~86k lock ops per tile task — during carve gameplay,
// exactly when re-mesh latency matters). Snapshot a chunk's mod list ONCE per (chunk, version)
// on this worker and evaluate it lock-free. DIRECT-MAPPED by the chunk coord's low bits (2 bits
// per axis → 64 slots): the mesher's ±1 margin ring touches up to 27 neighbouring chunk coords
// per tile, and any two coords within ±3 of each other land in DIFFERENT slots — so a tile task
// never thrashes its own working set. Version bump (carve / clear) invalidates lazily per slot.
struct FDiffSlot
{
FIntVector Chunk = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
uint32 Version = 0;
TArray<FVoxelModification> Mods;
};
static thread_local FDiffSlot DiffSlots[64];
const uint32 V = DiffLayer->GetModsVersion();
FDiffSlot& Slot = DiffSlots[(ChunkCoord.X & 3) | ((ChunkCoord.Y & 3) << 2) | ((ChunkCoord.Z & 3) << 4)];
if (Slot.Chunk != ChunkCoord || Slot.Version != V)
{
Slot.Chunk = ChunkCoord;
Slot.Version = V;
DiffLayer->GetChunkModsSnapshot(ChunkCoord, Slot.Mods); // ONE lock per chunk, not per voxel
}
if (Slot.Mods.Num() > 0)
{
Result -= UVoxelDiffLayer::EvaluateMods(Slot.Mods, WorldX, WorldY, WorldZ);
}
}
DensityDiffLayerTimer.End();
VoxelDensityProfile::FScopedTimer DensityTailTimer(
VoxelDensityProfile::EBucket::DensityTail);
VoxelPassageGeometry::ResetVerticalShaftConnectorAirMarker();
return Result;
}
float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float WorldZ,
const FStrateGenerationParams& Params,
uint32 ParamsFingerprint, uint32 LayoutVersion,
bool bApplyLegacyStructuralPosts,
bool bApplyTunnelSupportFloor) const
{
//=========================================================================
// STRATE DENSITY FUNCTION (Morphology Pipeline)
//=========================================================================
// The density pipeline for underground caves:
//
// 1. Vertical scale
// 2. Base density (everything starts solid)
// 3. Cave warp: domain warp coordinates before SDF (bends rooms/tunnels organically)
// 4. SDF morphology: rooms + tunnels carve the cave structure (using warped coords)
// 4b. Surface roughness: 3D noise near cave walls (fBM/Ridged/Cellular + domain warp)
// 4c. Terrain operations: terracing, layer lines, ribbing, cliff, scallop, arch, overhangs
// 4d. Columns/Pillars: hash-placed vertical cylinders adding solid rock
// 4e. Pits/Shafts: hash-placed tapered vertical voids carved into cave floors
// 4f. Chimneys/Shafts: hash-placed tapered vertical voids carved UPWARD
// 4g. Domes: hemispherical ceiling sculpting in cave chambers
// 4h. Pinch/Bottleneck: ellipsoidal passage narrowing for chokepoints
// 5. Worm tunnels: additional organic connectivity
// 6. Boundary seal: solid rock at strate top/bottom
// 7. Modifiers: passages between strates (punch through vertical seals)
// 8. XY edge seal: global bounded-world shell wins over passages at the rim
//
// Convention: positive density = solid, negative = air (internally).
// At the end, we negate for the MC table (negative = solid there).
//=========================================================================
const uint32 SeedU = (uint32)Seed;
//=========================================================================
// STEP 1: VERTICAL SCALE
//=========================================================================
float EffectiveZ = WorldZ;
if (Params.VerticalScale != 1.0f && Params.VerticalScale > 0.0f)
{
EffectiveZ = WorldZ / Params.VerticalScale;
}
//=========================================================================
// STEP 2: BASE DENSITY (everything starts solid)
//=========================================================================
float Density = Params.BaseDensity;
//=========================================================================
// STEP 3: CAVE WARP (domain warp the SDF skeleton)
//=========================================================================
// This is the KEY step that makes caves look natural instead of graph-like.
//
// The SDF morphology places rooms as geometric primitives (ellipsoids, boxes)
// connected by straight capsule tunnels. Without warping, you can clearly see
// the room-corridor-room graph structure — it looks artificial.
//
// By warping the world coordinates BEFORE evaluating the SDF, we bend the
// entire cave field. Rooms become irregular blobs, tunnels become winding
// passages. The skeleton is still there as the backbone, but noise pushes
// and pulls it into shapes that look carved by geological forces.
//
// Two octaves:
// - Large: CaveWarpFrequency → broad sweeping bends (room-scale)
// - Medium: 3x frequency, 0.3x strength → wall-scale irregularity
//
// IMPORTANT: Only the SDF query uses warped coordinates. Roughness, terrain
// ops, and hash-placed features use the REAL world position so they stay
// geologically correct (terracing stays horizontal, columns stay vertical, etc.)
float WarpedX = WorldX;
float WarpedY = WorldY;
float WarpedZ = EffectiveZ;
if (Params.CaveWarpStrength > 0.0f)
{
const float WF = Params.CaveWarpFrequency;
const float WS = Params.CaveWarpStrength;
// Three independent Perlin fields offset by irrational-ish numbers
// so the X/Y/Z warp channels don't correlate with each other.
// Single octave to keep per-voxel cost low (3 Perlin calls total).
WarpedX += VoxelNoise::Perlin3D(FVector3f(
WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.37f),
WorldY * WF + 1.3f,
EffectiveZ * WF + 5.7f)) * VOXEL_NOISE_SCALE * WS;
WarpedY += VoxelNoise::Perlin3D(FVector3f(
WorldX * WF + 7.1f,
WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.59f),
EffectiveZ * WF + 2.3f)) * VOXEL_NOISE_SCALE * WS;
WarpedZ += VoxelNoise::Perlin3D(FVector3f(
WorldX * WF + 11.3f,
WorldY * WF + 9.7f,
EffectiveZ * WF + VoxelHash::SeedOffset(SeedU, 0.41f))) * VOXEL_NOISE_SCALE * WS;
}
//=========================================================================
// STEP 4: SDF MORPHOLOGY (rooms + tunnels) — CACHED PER CHUNK
//=========================================================================
// The room list and tunnel connections are IDENTICAL for all voxels in a
// chunk. Without caching, we rebuild them 32,768 times (32³ voxels).
// With thread_local caching: build once, evaluate 32K times with just SDF math.
//
// The cache is keyed on (ChunkX, ChunkY, StrateIndex, Seed). When the key
// changes (new chunk or strate), the cache is rebuilt. thread_local ensures
// each task thread has its own cache — no locking needed.
//
// The warped position is used for SDF evaluation (bends rooms/tunnels),
// but the cache search area is expanded by CaveWarpStrength to ensure
// all reachable rooms are included regardless of warp displacement.
//
// Declared before the RoomDensity if-block so SDFCache and NearestRoomIdx
// are also visible to the terrain ops block further below.
thread_local FChunkSDFCache SDFCache;
// Cache validity is tracked by the SEARCH BOX the cache was built for, NOT by chunk
// equality. Gradient-normal sampling queries at WorldX±1 (and warp displacement) can
// step a voxel outside the chunk; with chunk-equality keying that flipped the key and
// rebuilt the (now expensive) cache every boundary cell. Since the stored rooms cover
// the search box + MaxInfluence, any query INSIDE the box is correct — so we only
// rebuild when the query actually leaves the box. Result: one build per chunk, no thrash.
thread_local float CachedSMinX = 1.0f, CachedSMaxX = -1.0f; // start invalid (min > max)
thread_local float CachedSMinY = 0.0f, CachedSMaxY = 0.0f;
thread_local int32 CachedStrate = INT32_MIN;
thread_local uint32 CachedSeed = 0;
// ⚠️ AUDIT §C2 (corrigé le 2026-07-28). Les deux lignes qui manquaient à cette clé.
// La clé ci-dessus décrit la GÉOMÉTRIE de la fenêtre (boîte, strate, seed) et rien de ce qui
// détermine les PARAMS avec lesquels les salles ont été cuites. Comme `GetGenerationParams`
// blende à l'intérieur d'une strate (`Alpha` = f(chunk Z), et f(chunk XY) aussi en
// `Interleaved`), deux chunks voisins produisent la MÊME clé avec des params DIFFÉRENTS, et le
// deuxième se sert des salles du premier. Non déterministe entre pairs, parce que l'ordre des
// workers décide lequel est « le premier » — exactement ce que §2.6.1 interdit.
//
// Pourquoi ça ne casse PAS l'invariant de perf de §8.10 : la clé reste une BOÎTE, donc les
// sondes de gradient à `WorldX ± 1` ne font toujours pas tourner le cache. Ce qui le fait
// tourner en plus, c'est un changement RÉEL de params — une fois par chunk dans une bande de
// transition, ce qui est le nombre de reconstructions que ce cache aurait toujours dû faire.
thread_local uint32 CachedFingerprint = 0xFFFFFFFFu;
thread_local uint32 CachedLayout = 0xFFFFFFFFu;
// Index of the room with the smallest (most-inside) SDF for this voxel.
// Written by EvaluateSDFCached, read by the terrain ops block to pick the
// per-room terrain op. -1 means no room is nearby (deep-solid or no rooms).
thread_local int32 NearestRoomIdx = -1;
float CaveSDF = FLT_MAX;
if (Params.RoomDensity > 0.0f && Params.RoomSpacing > 0.0f)
{
// Get strate index for unique caves per strate.
// MEMOISED: the index only changes at chunk-Z boundaries, yet this ran the strate layout's
// linear scan PER VOXEL. Key = (chunk-Z, layout version) — the version guards against editor
// rebuilds (RebuildStrates / live edit) serving a stale index. The lookup queries the BAND
// CENTRE so the result is a pure function of the key (order-independent, window-invariant).
int32 StrateIdx = 0;
if (StrateManager)
{
thread_local int32 SI_ChunkZ = INT32_MAX;
thread_local uint32 SI_Version = 0xFFFFFFFFu;
thread_local int32 SI_Index = 0;
const int32 QZ = FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE);
const uint32 LV = StrateManager->GetLayoutVersion();
if (QZ != SI_ChunkZ || LV != SI_Version)
{
SI_ChunkZ = QZ;
SI_Version = LV;
// GetStrateIndex expects Unreal world units (divides by VOXEL_SIZE internally);
// WorldZ here is in voxels, so multiply by VOXEL_SIZE.
SI_Index = StrateManager->GetStrateIndex(((float)QZ + 0.5f) * CHUNK_SIZE * VOXEL_SIZE);
}
StrateIdx = SI_Index;
}
// Rebuild only when the WARPED query (what EvaluateSDFCached uses) leaves the
// cached search box, or the strate/seed changed.
const bool bNeedRebuild =
StrateIdx != CachedStrate || (uint32)Seed != CachedSeed ||
ParamsFingerprint != CachedFingerprint || LayoutVersion != CachedLayout ||
WarpedX < CachedSMinX || WarpedX > CachedSMaxX ||
WarpedY < CachedSMinY || WarpedY > CachedSMaxY;
if (bNeedRebuild)
{
if (VoxelDensityProfile::AreCountersEnabled())
{
VoxelDensityProfile::AddCounter(
VoxelDensityProfile::ECounter::SdfCacheBuild);
}
// Center the search box on this query's chunk. Search area = chunk XY extent
// + CaveWarpStrength margin (covers warp displacement) + gradient sampling.
// MaxInfluence (room/tunnel reach) is added internally by BuildChunkCache.
const int32 CacheChunkX = FMath::FloorToInt(WorldX / (float)CHUNK_SIZE);
const int32 CacheChunkY = FMath::FloorToInt(WorldY / (float)CHUNK_SIZE);
const float ChunkMinX = CacheChunkX * (float)CHUNK_SIZE;
const float ChunkMinY = CacheChunkY * (float)CHUNK_SIZE;
const float ChunkMaxX = ChunkMinX + (float)CHUNK_SIZE;
const float ChunkMaxY = ChunkMinY + (float)CHUNK_SIZE;
const float Expansion = FMath::Abs(Params.CaveWarpStrength)
* VOXEL_NOISE_SCALE * 1.5f + 2.0f;
const float SMinX = ChunkMinX - Expansion;
const float SMinY = ChunkMinY - Expansion;
const float SMaxX = ChunkMaxX + Expansion;
const float SMaxY = ChunkMaxY + Expansion;
// Fetch the terrain op probability pool for this chunk's strate.
// BuildChunkCache uses this to hash-roll one terrain op per room.
const TArray<FStrateTerrainOpEntry>* TerrainOps = nullptr;
if (StrateManager)
{
const int32 ChunkZ = FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE);
UVoxelStrateDefinition* Def = StrateManager->GetStrateForChunk(
FIntVector(CacheChunkX, CacheChunkY, ChunkZ));
if (Def) TerrainOps = &Def->TerrainOperations;
}
VoxelCaveMorphology::BuildChunkCache(
SDFCache,
SMinX, SMinY, SMaxX, SMaxY,
Params, (uint32)Seed, StrateIdx,
TerrainOps
);
CachedSMinX = SMinX; CachedSMaxX = SMaxX;
CachedSMinY = SMinY; CachedSMaxY = SMaxY;
CachedStrate = StrateIdx;
CachedSeed = (uint32)Seed;
CachedFingerprint = ParamsFingerprint;
CachedLayout = LayoutVersion;
}
// Evaluate SDF using cached rooms and tunnels (WARPED coordinates).
// Also writes NearestRoomIdx — the room with minimum SDF contribution
// at this voxel's position. Used by terrain ops below to pick per-room params.
NearestRoomIdx = -1;
CaveSDF = VoxelCaveMorphology::EvaluateSDFCached(
WarpedX, WarpedY, WarpedZ,
SDFCache, Params.SDFBlendRadius,
&NearestRoomIdx
);
// ── PIT & CHIMNEY SDF INTEGRATION ──
// Pits and chimneys are SmoothMin'd into CaveSDF here, using REAL
// (unwarped) world coordinates. This is intentional: pit positions come
// from unwarped room centers, so evaluating them in warped space would
// misalign them. Rooms also use unwarped positions — warping is purely a
// query-coordinate bend, not a data-space transform.
//
// Including pits in CaveSDF means:
// - The CarveFactor block below handles their density naturally
// - SmoothMin at the pit-to-room junction creates the same organic
// transition as tunnel-to-room (no hard seam at PitTopZ)
// - bNearCaveSurface becomes true inside the shaft — roughness clamp
// prevents fill-back, so this is safe
for (const FCachedPit& Pit : SDFCache.Pits)
{
const float DZ = WorldZ - Pit.TopZ; // Negative = below anchor (shaft)
if (DZ >= Pit.BlendK) continue; // Above even the blend fringe
if (-DZ > Pit.Depth + Pit.BlendK) continue;
float DX = WorldX - Pit.CenterX;
float DY = WorldY - Pit.CenterY;
float XYDistSq = DX * DX + DY * DY;
if (XYDistSq > Pit.BoundXYRadiusSq) continue;
float PitSDF;
if (DZ <= 0.0f)
{
// Shaft: tapered cylinder with flared opening
float DepthBelow = -DZ;
float FlareFactor = FMath::Clamp(1.0f - DepthBelow / Pit.FlareDist, 0.0f, 1.0f);
FlareFactor = FlareFactor * FlareFactor;
float EffRadius = Pit.Radius + Pit.FlareExtra * FlareFactor;
PitSDF = FMath::Sqrt(XYDistSq) - EffRadius;
}
else
{
// Above anchor: only the blend fringe matters here.
// We still pass a SDF so SmoothMin can soften the rim from above.
// Treat this zone as a flat disc at TopZ (XY cylinder, no Z factor)
// so the blend fades horizontally into the room floor.
PitSDF = FMath::Sqrt(XYDistSq) - (Pit.Radius + Pit.FlareExtra);
}
CaveSDF = VoxelSDF::SmoothMin(CaveSDF, PitSDF, Pit.BlendK);
}
for (const FCachedChimney& Chim : SDFCache.Chimneys)
{
const float DZ = WorldZ - Chim.BottomZ; // Positive = above anchor (shaft)
if (-DZ >= Chim.BlendK) continue; // Below even the blend fringe
if (DZ > Chim.Height + Chim.BlendK) continue;
float DX = WorldX - Chim.CenterX;
float DY = WorldY - Chim.CenterY;
float XYDistSq = DX * DX + DY * DY;
if (XYDistSq > Chim.BoundXYRadiusSq) continue;
float ChmSDF;
if (DZ >= 0.0f)
{
float FlareFactor = FMath::Clamp(1.0f - DZ / Chim.FlareDist, 0.0f, 1.0f);
FlareFactor = FlareFactor * FlareFactor;
float EffRadius = Chim.Radius + Chim.FlareExtra * FlareFactor;
ChmSDF = FMath::Sqrt(XYDistSq) - EffRadius;
}
else
{
// Below anchor: flat disc blend into room ceiling
ChmSDF = FMath::Sqrt(XYDistSq) - (Chim.Radius + Chim.FlareExtra);
}
CaveSDF = VoxelSDF::SmoothMin(CaveSDF, ChmSDF, Chim.BlendK);
}
// Convert SDF to density carving:
// CaveSDF < 0 means we're inside a room/tunnel/pit → carve to air
// CaveSDF > 0 means we're in solid rock → no change
// The transition zone around SDF=0 gives smooth cave walls
if (CaveSDF < Params.SDFBlendRadius)
{
// Smooth carving factor: 1.0 deep inside cave, 0.0 at blend edge
float CarveFactor = FMath::Clamp(
(Params.SDFBlendRadius - CaveSDF) / FMath::Max(Params.SDFBlendRadius * 2.0f, 1.0f),
0.0f, 1.0f
);
// Smoothstep for less abrupt transitions
CarveFactor = SmoothStep01(CarveFactor);
Density -= CarveFactor * Params.BaseDensity * 2.0f;
}
}
//=========================================================================
// EARLY-OUT: Skip detail work for deep-solid voxels
//=========================================================================
// ~70% of voxels in a chunk are deep inside solid rock, far from any cave.
// Roughness, terrain ops, columns, pits, domes, pinch — ALL of these only
// matter near cave surfaces. Skipping them for deep-solid voxels is the
// single biggest performance win.
//
// We still need to run worm tunnels (they carve independently) and boundary
// seal / modifiers, so we jump to Step 5 instead of returning early.
const float DetailThreshold = Params.SDFBlendRadius * 3.0f;
const bool bNearCaveSurface = (CaveSDF < DetailThreshold) && (CaveSDF < FLT_MAX);
//=========================================================================
// STEP 4b: SURFACE ROUGHNESS (volumetric, SDF-based)
//=========================================================================
// 3D noise near cave surfaces (where SDF ≈ 0) creates rocky detail:
// overhangs, ledges, bumps, cracks. The SDF value directly tells us
// how far from the surface we are — no need for separate floor/ceiling tracking.
//
// The noise type (fBM, Ridged, Mixed) and optional domain warping are
// per-strate settings, so each strate can have fundamentally different
// wall character — smooth lava tubes vs craggy erosion cliffs.
if (bNearCaveSurface && Params.SurfaceRoughness > 0.0f)
{
float RoughnessDepth = Params.SurfaceRoughness * 2.0f;
float DistFromSurface = FMath::Abs(CaveSDF);
if (DistFromSurface < RoughnessDepth)
{
float RF = Params.RoughnessFrequency;
// Base noise input positions (with seed offsets for uniqueness)
FVector3f MainPos(
WorldX * RF + VoxelHash::SeedOffset(SeedU, 11.3f),
WorldY * RF + VoxelHash::SeedOffset(SeedU, 13.7f),
EffectiveZ * RF + VoxelHash::SeedOffset(SeedU, 17.1f)
);
FVector3f FinePos(
WorldX * RF * 3.0f + VoxelHash::SeedOffset(SeedU, 19.1f) + 2000.0f,
WorldY * RF * 3.0f + VoxelHash::SeedOffset(SeedU, 23.7f) + 2500.0f,
EffectiveZ * RF * 3.0f + VoxelHash::SeedOffset(SeedU, 29.3f) + 3000.0f
);
// DOMAIN WARPING: distort noise coordinates with a secondary field.
// This breaks up repetitive patterns — coordinates are "bent" by noise,
// making walls look like they were shaped by flowing water or pressure.
// Each axis uses a different seed offset for independent warping.
if (Params.DomainWarpStrength > 0.0f)
{
float WF = Params.DomainWarpFrequency;
float WS = Params.DomainWarpStrength;
// Sample three independent noise fields for X, Y, Z warp
float WarpX = VoxelNoise::Perlin3D(FVector3f(
WorldX * WF + VoxelHash::SeedOffset(SeedU, 5.2f),
WorldY * WF + VoxelHash::SeedOffset(SeedU, 1.3f),
EffectiveZ * WF + VoxelHash::SeedOffset(SeedU, 9.7f)
)) * VOXEL_NOISE_SCALE * WS;
float WarpY = VoxelNoise::Perlin3D(FVector3f(
WorldX * WF + 100.0f + VoxelHash::SeedOffset(SeedU, 7.7f),
WorldY * WF + 200.0f + VoxelHash::SeedOffset(SeedU, 3.1f),
EffectiveZ * WF + 300.0f
)) * VOXEL_NOISE_SCALE * WS;
float WarpZ = VoxelNoise::Perlin3D(FVector3f(
WorldX * WF + 400.0f,
WorldY * WF + 500.0f + VoxelHash::SeedOffset(SeedU, 11.9f),
EffectiveZ * WF + 600.0f + VoxelHash::SeedOffset(SeedU, 13.3f)
)) * VOXEL_NOISE_SCALE * WS;
// Apply warp to both noise positions
FVector3f WarpOffset(WarpX, WarpY, WarpZ);
MainPos += WarpOffset;
FinePos += WarpOffset;
}
// NOISE TYPE SELECTION: sample the right noise function.
// Octave counts go through VoxelGenLOD::Eff — far tiles (Step>1) drop
// the sub-cell tail octaves (T2.b); LOD0 keeps the full counts.
float RoughNoise, FineNoise;
const int32 Oct3 = VoxelGenLOD::Eff(3);
const int32 Oct2 = VoxelGenLOD::Eff(2);
switch (Params.RoughnessNoiseType)
{
case EVoxelNoiseType::Ridged:
// Ridged multifractal: sharp, craggy features
RoughNoise = RidgedNoise3D(MainPos, Oct3);
FineNoise = RidgedNoise3D(FinePos, Oct2);
break;
case EVoxelNoiseType::Mixed:
// Blend: ridged structure softened by fBM
RoughNoise = FractalNoise3D(MainPos, Oct3) * 0.5f
+ RidgedNoise3D(MainPos, Oct3) * 0.5f;
FineNoise = FractalNoise3D(FinePos, Oct2) * 0.5f
+ RidgedNoise3D(FinePos, Oct2) * 0.5f;
break;
case EVoxelNoiseType::Cellular:
// Worley/cellular: grotto, bubble-like patterns
// Scale down because cellular noise has different frequency behavior
RoughNoise = CellularNoise3D(MainPos);
FineNoise = CellularNoise3D(FinePos);
break;
case EVoxelNoiseType::FBM:
default:
// Standard fBM: smooth, organic
RoughNoise = FractalNoise3D(MainPos, Oct3);
FineNoise = FractalNoise3D(FinePos, Oct2);
break;
}
// Scale by VOXEL_NOISE_SCALE (all noise functions return ~[-1,1] but UE
// Perlin internally returns ~[-0.8,0.8])
RoughNoise *= VOXEL_NOISE_SCALE;
FineNoise *= VOXEL_NOISE_SCALE;
float TotalRough = RoughNoise * Params.SurfaceRoughness
+ FineNoise * Params.SurfaceRoughness * 0.4f;
// Inside definite cave air (CaveSDF < 0), roughness must never add solid back.
// Without this clamp, barely-negative voxels near tunnel junctions or pit rims
// get pushed back to solid by roughness → thin lids, membrane walls, bad seams.
// Roughness can still carve further into walls (negative values), just not fill.
if (CaveSDF < 0.0f)
{
TotalRough = FMath::Min(TotalRough, 0.0f);
}
// Fade out toward cave center (SurfaceFade = 1.0 at surface, 0.0 far away)
float SurfaceFade = 1.0f - (DistFromSurface / RoughnessDepth);
SurfaceFade = SurfaceFade * SurfaceFade; // Quadratic: concentrate near surface
Density += TotalRough * SurfaceFade;
}
}
//=========================================================================
// STEP 4c-4h: TERRAIN OPERATIONS (only near cave surfaces)
//=========================================================================
// All terrain ops only affect density near cave walls.
// Deep-solid voxels skip this entire block (bNearCaveSurface = false).
if (bNearCaveSurface)
{
//=========================================================================
// PER-ROOM TERRAIN PARAMS
//=========================================================================
// Terrain ops are now per-room (not global). NearestRoomIdx was written by
// EvaluateSDFCached above — it's the room with minimum SDF at this voxel.
//
// We copy Params and apply the nearest room's terrain op on top.
// Base Params has all terrain op fields = 0 (disabled) since
// BuildParamsFromDefinition no longer merges them globally.
//
// Result: voxels inside different rooms see different terrain ops.
// Rooms with no assigned op leave terrain fields at 0 → no terrain op. Clean.
FStrateGenerationParams LocalTerrainParams = Params;
if (NearestRoomIdx >= 0 && SDFCache.Rooms.IsValidIndex(NearestRoomIdx))
{
const FCachedRoom& NR = SDFCache.Rooms[NearestRoomIdx];
if (NR.RoomOp)
{
// Writes only the op's specific fields (e.g. TerraceStepHeight for Terrace).
// All other fields keep Params values unchanged.
NR.RoomOp->ApplyTo(LocalTerrainParams, NR.RoomOpWeight);
}
}
// Shadow outer Params inside this block so all terrain op code below
// automatically uses the per-room values without any other changes.
// The shadow is deliberate (per-room override), so silence C4457 here only.
PRAGMA_DISABLE_SHADOW_VARIABLE_WARNINGS
const FStrateGenerationParams& Params = LocalTerrainParams;
PRAGMA_ENABLE_SHADOW_VARIABLE_WARNINGS
// Geological features applied after roughness. These modify the density
// field near cave surfaces to create specific shapes: terracing (step-like
// ledges), layer lines (horizontal grooves), and overhangs (horizontal
// shelf protrusions). Each operation is controlled by params in the strate
// definition — set the main param to 0 to disable any operation.
// --- TERRACING ---
// Creates a staircase pattern on cave walls by offsetting density with
// a smooth staircase function of world Z. The staircase quantizes the
// cave surface into flat shelves connected by short cliff faces.
//
// Math: terracedZ = staircase(worldZ, stepH, hardness)
// offset = terracedZ - worldZ
// Where offset > 0 → more solid (shelf floor to walk on)
// Where offset < 0 → more air (gap under the shelf above)
if (Params.TerraceStepHeight > 0.0f && CaveSDF < FLT_MAX)
{
const float StepH = Params.TerraceStepHeight;
const float DistFromSurface = FMath::Abs(CaveSDF);
const float TerraceRange = StepH * 3.0f; // How far from surface the effect reaches
if (DistFromSurface < TerraceRange)
{
// Surface orientation test: terracing only makes sense on horizontal surfaces
// (cave floors). On vertical walls (pit shafts, tunnel sides) it creates ugly
// horizontal ridges. We sample the SDF gradient in Z by querying Z±1:
// an SDF has ≈unit gradient, so |dSDF/dZ| alone IS the normalized vertical
// component — near 1 = floor/ceiling, near 0 = wall. (Z-only approximation:
// the full 6-sample gradient normalization was 3× the SDF cost for the same
// orientation signal; SmoothMin regions where |∇SDF| < 1 read slightly
// "flatter", shifting where terraces fade on slopes — accepted visual delta.)
//
// We use the cached SDF so this costs two extra SDF evaluations per voxel,
// only when near a surface — the common case is cheap (DistFromSurface > TerraceRange).
float SDF_Zp1 = VoxelCaveMorphology::EvaluateSDFCached(WorldX, WorldY, WorldZ + 1.0f, SDFCache, Params.SDFBlendRadius);
float SDF_Zm1 = VoxelCaveMorphology::EvaluateSDFCached(WorldX, WorldY, WorldZ - 1.0f, SDFCache, Params.SDFBlendRadius);
float GZ = (SDF_Zp1 - SDF_Zm1) * 0.5f;
// Normalized vertical component: 1 = perfectly horizontal surface (floor/ceiling)
// 0 = perfectly vertical surface (wall)
float SurfaceHorizontality = FMath::Clamp(FMath::Abs(GZ), 0.0f, 1.0f);
// Only apply terrace where the surface is mostly horizontal (> ~45 degrees)
// Smooth transition to avoid a hard cutoff at exactly 45 degrees
float TerraceOrientFactor = FMath::Clamp((SurfaceHorizontality - 0.3f) / 0.4f, 0.0f, 1.0f);
// Noise displacement: perturb Z before staircase to break up straight edges
float NoisedZ = WorldZ;
if (Params.TerraceNoiseDisplacement > 0.0f)
{
float DispNoise = FractalNoise3D(FVector3f(
WorldX * 0.04f + VoxelHash::SeedOffset(SeedU, 31.1f),
WorldY * 0.04f + VoxelHash::SeedOffset(SeedU, 37.3f),
WorldZ * 0.02f + VoxelHash::SeedOffset(SeedU, 41.7f)
), VoxelGenLOD::Eff(2)) * VOXEL_NOISE_SCALE;
NoisedZ += DispNoise * Params.TerraceNoiseDisplacement * StepH;
}
// Smooth staircase function:
// K = NoisedZ / StepH (which "step" are we in?)
// Frac = fractional part [0, 1) — position within the step
// Edge controls transition sharpness (narrow edge = sharp cliff face)
float K = NoisedZ / StepH;
float FloorK = FMath::FloorToFloat(K);
float Frac = K - FloorK; // Always [0, 1)
// Build the stair profile: 0 in lower half, 1 in upper half, smooth transition
float Edge = FMath::Lerp(0.45f, 0.02f, Params.TerraceHardness);
float StairValue;
if (Frac < 0.5f - Edge)
{
StairValue = 0.0f; // Lower flat region (below transition)
}
else if (Frac > 0.5f + Edge)
{
StairValue = 1.0f; // Upper flat region (above transition)
}
else
{
// Smoothstep through the transition zone
float T = (Frac - (0.5f - Edge)) / (2.0f * Edge);
StairValue = SmoothStep01(T);
}
// Reconstruct the terraced Z and compute offset from real Z
float TerracedZ = (FloorK + StairValue) * StepH;
float Offset = TerracedZ - NoisedZ;
// Offset range: approximately [-StepH/2, +StepH/2]
// Positive → below a shelf surface → add density (solid floor)
// Negative → above a shelf → subtract density (air gap under next shelf)
// Fade based on distance from cave surface (no effect deep in rock)
float Fade = 1.0f - (DistFromSurface / TerraceRange);
Fade = Fade * Fade; // Quadratic: concentrate near surface
// TerraceOrientFactor suppresses terrace on vertical walls (pit shafts, etc.)
Density += Offset * Fade * TerraceOrientFactor;
}
}
// --- LAYER LINES ---
// Horizontal grooves in cave walls — visible geological strata.
// A sine wave along Z, sharpened to create thin lines, subtracts density
// near cave surfaces. This carves narrow horizontal channels into walls
// at regular intervals, like sedimentary rock layers in cross-section.
if (Params.LayerLineSpacing > 0.0f && CaveSDF < FLT_MAX)
{
const float DistFromSurface = FMath::Abs(CaveSDF);
const float LineRange = Params.LayerLineSpacing * 1.5f; // Influence depth into rock
if (DistFromSurface < LineRange)
{
// Sine wave along Z: peaks at each line position
float LinePhase = WorldZ * (2.0f * PI) / Params.LayerLineSpacing;
float LineValue = FMath::Sin(LinePhase);
// Sharpen to thin grooves: only carve where sine > 0, then cube it.
// sin → max(sin, 0) → pow(_, 3) turns broad sine humps into thin spikes
LineValue = FMath::Max(LineValue, 0.0f);
LineValue = LineValue * LineValue * LineValue; // Cubic sharpening
// Fade near surface
float Fade = 1.0f - (DistFromSurface / LineRange);
Fade = Fade * Fade;
// Subtract density to carve the groove
Density -= LineValue * Params.LayerLineDepth * Fade;
}
}
// --- RIBBING ---
// Parallel ridge patterns on walls/ceiling (like lava tubes).
// Uses the same sine-along-Z approach as layer lines, but ADDS density
// (protruding ribs) instead of subtracting (grooves). The sine is
// half-wave rectified and smoothed to create rounded bumps.
if (Params.RibbingSpacing > 0.0f && CaveSDF < FLT_MAX)
{
const float DistFromSurface = FMath::Abs(CaveSDF);
const float RibRange = Params.RibbingSpacing * 1.5f;
if (DistFromSurface < RibRange)
{
// Sine wave along Z, offset by half-period from layer lines
float RibPhase = WorldZ * (2.0f * PI) / Params.RibbingSpacing + PI * 0.5f;
float RibValue = FMath::Sin(RibPhase);
// Half-wave rectify (only positive → ribs, not grooves) then smooth
RibValue = FMath::Max(RibValue, 0.0f);
RibValue = RibValue * RibValue; // Quadratic: rounder bump profile
// Fade near surface
float Fade = 1.0f - (DistFromSurface / RibRange);
Fade = Fade * Fade;
// Add density to create protruding ribs
Density += RibValue * Params.RibbingDepth * Fade;
}
}
// --- OVERHANGS ---
// Horizontal shelf-like protrusions from cave walls.
// Uses 3D noise with much lower Z frequency than XY, so features extend
// horizontally for long stretches before varying vertically. This creates
// natural rocky overhangs and ledges independent of the terracing system.
//
// Only positive noise values create protrusions (asymmetric: rock extends
// INTO the cave, never away). This gives scattered shelf-like features
// rather than uniform displacement.
if (Params.OverhangStrength > 0.0f && CaveSDF < FLT_MAX)
{
const float DistFromSurface = FMath::Abs(CaveSDF);
const float OverhangRange = Params.OverhangDepth * 2.0f;
if (DistFromSurface < OverhangRange)
{
// Low Z frequency (0.15x of XY) → features extend horizontally
float OverhangNoise = FractalNoise3D(FVector3f(
WorldX * Params.OverhangFrequency + VoxelHash::SeedOffset(SeedU, 53.1f),
WorldY * Params.OverhangFrequency + VoxelHash::SeedOffset(SeedU, 59.3f),
EffectiveZ * Params.OverhangFrequency * 0.15f + VoxelHash::SeedOffset(SeedU, 61.7f)
), VoxelGenLOD::Eff(2)) * VOXEL_NOISE_SCALE;
// Only where noise is positive → protrusions (not recesses)
if (OverhangNoise > 0.0f)
{
float Fade = 1.0f - (DistFromSurface / OverhangRange);
Fade = Fade * Fade;
// Add density = extend solid rock into cave = overhang shelf
Density += OverhangNoise * Params.OverhangDepth
* Params.OverhangStrength * Fade;
}
}
}
// --- CLIFF SHARPENING ---
// Steepens vertical faces by amplifying the Z-axis density gradient.
// Where the cave surface is already somewhat vertical (density changes
// quickly along Z), this pushes it toward a sheer cliff face.
//
// Math: sample density at Z+1 and Z-1, compute vertical gradient.
// Where gradient is steep AND we're near the cave surface:
// If we're in the UPPER portion of the cliff → subtract density (more air)
// If we're in the LOWER portion → add density (more solid)
// This squeezes the transition zone, making it near-vertical.
if (Params.CliffStrength > 0.0f && CaveSDF < FLT_MAX)
{
const float DistFromSurface = FMath::Abs(CaveSDF);
const float CliffRange = 8.0f; // Only affect voxels within 8 of surface
if (DistFromSurface < CliffRange)
{
// Approximate vertical gradient via the CaveSDF sign and position.
// Near the surface (CaveSDF ≈ 0), the sign of CaveSDF tells us which
// side we're on: negative = inside cave, positive = solid rock.
// We use a noise-modulated vertical gradient to detect steep faces.
float VertGrad = VoxelNoise::Perlin3D(FVector3f(
WorldX * 0.05f + VoxelHash::SeedOffset(SeedU, 71.3f),
WorldY * 0.05f + VoxelHash::SeedOffset(SeedU, 73.7f),
EffectiveZ * 0.15f + VoxelHash::SeedOffset(SeedU, 79.1f) // 3x faster in Z → detects vertical features
)) * VOXEL_NOISE_SCALE;
// VertGrad near ±1 means terrain is changing fast vertically.
// Multiply by sign of CaveSDF to get direction:
// Positive result (solid side, gradient pointing up) → add more solid
// Negative result (air side) → carve more air
float CliffEffect = VertGrad * CaveSDF * Params.CliffStrength;
// Only apply where gradient is significant (abs > 0.3)
// and fade with distance from surface
if (FMath::Abs(VertGrad) > 0.3f)
{
float Fade = 1.0f - (DistFromSurface / CliffRange);
Fade = Fade * Fade;
Density += CliffEffect * Fade * 3.0f;
}
}
}
// --- SCALLOP ---
// Water-erosion-like concave patterns on cave walls.
// Uses cellular (Worley) noise near cave surfaces — the distance-to-nearest
// feature point creates natural bowl-shaped indentations. Where the cellular
// noise value is high (far from feature points = center of a cell), we
// subtract density to carve shallow bowls into the wall.
//
// This gives limestone caves their characteristic scalloped appearance —
// rows of smooth, concave depressions covering the walls.
if (Params.ScallopStrength > 0.0f && CaveSDF < FLT_MAX)
{
const float DistFromSurface = FMath::Abs(CaveSDF);
const float ScallopRange = Params.ScallopStrength * 4.0f;
if (DistFromSurface < ScallopRange)
{
// Cellular noise: returns ~[-1, 1] where positive = cell interior (bowl)
float SF = Params.ScallopFrequency;
float ScallopNoise = CellularNoise3D(FVector3f(
WorldX * SF + VoxelHash::SeedOffset(SeedU, 83.1f),
WorldY * SF + VoxelHash::SeedOffset(SeedU, 89.3f),
EffectiveZ * SF + VoxelHash::SeedOffset(SeedU, 97.7f)
));
// Only carve where noise is positive (cell interiors = bowl centers)
if (ScallopNoise > 0.0f)
{
float Fade = 1.0f - (DistFromSurface / ScallopRange);
Fade = Fade * Fade;
// Subtract density to carve concave bowls
Density -= ScallopNoise * Params.ScallopStrength * Fade;
}
}
}
// --- ARCH / BRIDGE (room-relative) ---
// Horizontal rock bridges spanning the room interior.
// Anchored to the nearest room: each arch spans from one side of the room
// to the other at a hash-derived height within the room's Z range.
// Up to MaxArches per room; ArchDensity = probability per slot.
if (Params.ArchDensity > 0.0f && CaveSDF < Params.SDFBlendRadius && CaveSDF < FLT_MAX
&& NearestRoomIdx >= 0)
{
const FCachedRoom& Room = SDFCache.Rooms[NearestRoomIdx];
const FVector VoxPos(WorldX, WorldY, WorldZ);
for (int32 i = 0; i < 3; i++)
{
const FCachedArch& Arch = Room.Arches[i];
if (!Arch.bActive) { continue; }
const float ArchSDF = VoxelSDF::Capsule(
VoxPos, Arch.EndpointA, Arch.EndpointB, Arch.Radius);
const float ArchBlend = 2.0f;
if (ArchSDF < ArchBlend)
{
float Fill = FMath::Clamp((ArchBlend - ArchSDF) / (ArchBlend * 2.0f), 0.0f, 1.0f);
Fill = SmoothStep01(Fill);
Density += Fill * Arch.BaseDensity * 1.5f;
}
}
}
//=========================================================================
// STEP 4d: COLUMNS (pre-baked from BuildChunkCache)
//=========================================================================
// Columns are now pre-baked into SDFCache.Columns — no NearestRoomIdx needed.
// The old per-voxel approach had columns appear/disappear mid-height when
// the owning room changed (NearestRoomIdx switch). Pre-baking fixes this.
for (const FCachedColumn& Col : SDFCache.Columns)
{
float DX = WorldX - Col.CenterX;
float DY = WorldY - Col.CenterY;
float XYDistSq = DX * DX + DY * DY;
if (XYDistSq > Col.BoundXYRadiusSq) continue;
float CylSDF = FMath::Sqrt(XYDistSq) - Col.Radius;
const float ColBlend = 3.0f;
if (CylSDF < ColBlend)
{
float Fill = FMath::Clamp((ColBlend - CylSDF) / (ColBlend * 2.0f), 0.0f, 1.0f);
Fill = SmoothStep01(Fill);
Density += Fill * Col.BaseDensity * 1.5f;
}
}
//=========================================================================
// STEP 4g: DOMES (room-relative hemispherical ceilings)
//=========================================================================
// Domes carve upward from the room's upper area, creating cathedral ceilings.
// DmCenterZ anchored to the room's Z center so the dome sits naturally
// in the ceiling zone rather than floating at arbitrary strate heights.
if (Params.DomeDensity > 0.0f && CaveSDF < Params.SDFBlendRadius && CaveSDF < FLT_MAX
&& NearestRoomIdx >= 0)
{
const FCachedRoom& Room = SDFCache.Rooms[NearestRoomIdx];
// Up to 2 domes per room (large rooms can have multiple cathedral pockets)
const int32 MaxDomes = 2;
for (int32 i = 0; i < MaxDomes; i++)
{
uint32 DH = VoxelHash::Mix(Room.Hash ^ (0xD0AE0u + (uint32)i * 8191u));
if (VoxelHash::ToFloat01(DH) > Params.DomeDensity) continue;
// XY: near room center (domes are wide, keep them centered)
uint32 DH2 = VoxelHash::Mix(DH ^ 0xD0A0u);
float DmX = Room.Center.X + VoxelHash::ToFloatSigned(DH2) * Room.RadiusXY * 0.4f;
float DmY = Room.Center.Y + VoxelHash::ToFloatSigned(VoxelHash::Mix(DH2)) * Room.RadiusXY * 0.4f;
// Radius: cap at room radius so dome fits inside the room
uint32 DH3 = VoxelHash::Mix(DH2 ^ 0x90DEu);
float DmRadius = FMath::Min(
FMath::Lerp(Params.DomeMinRadius, Params.DomeMaxRadius, VoxelHash::ToFloat01(DH3)),
Room.RadiusXY * 0.85f
);
// Dome center Z: upper portion of the room (ceiling area)
uint32 DH4 = VoxelHash::Mix(DH3 ^ 0xCAFEu);
float DmCenterZ = Room.Center.Z + Room.RadiusZ * 0.2f
+ VoxelHash::ToFloat01(DH4) * Room.RadiusZ * 0.3f;
float DmHeight = DmRadius * Params.DomeHeightRatio;
// Quick Z reject
if (WorldZ > DmCenterZ + DmHeight + 3.0f || WorldZ < DmCenterZ - 3.0f) continue;
float DXDm = WorldX - DmX;
float DYDm = WorldY - DmY;
float DZDm = WorldZ - DmCenterZ;
// Only carve upward from the dome center anchor
if (DZDm < 0.0f) continue;
// Half-ellipsoid SDF (upward only)
float NormX = DXDm / DmRadius;
float NormY = DYDm / DmRadius;
float NormZ = DZDm / DmHeight;
float EllipDist = FMath::Sqrt(NormX * NormX + NormY * NormY + NormZ * NormZ) - 1.0f;
float DomeSDF = EllipDist * FMath::Min(DmRadius, DmHeight);
const float DmBlend = 3.0f;
if (DomeSDF < DmBlend)
{
float Carve = FMath::Clamp((DmBlend - DomeSDF) / (DmBlend * 2.0f), 0.0f, 1.0f);
Carve = SmoothStep01(Carve);
Density -= Carve * Params.BaseDensity * 1.5f;
}
}
}
//=========================================================================
// STEP 4h: PINCH / BOTTLENECK (room-relative passage narrowing)
//=========================================================================
// Pinches squeeze a passage from the sides. Placed at the room's perimeter
// area (high-radius offset from center) so they act on tunnel entrances
// and room edges, not the open center of the room.
if (Params.PinchDensity > 0.0f && CaveSDF < Params.SDFBlendRadius && CaveSDF < FLT_MAX
&& NearestRoomIdx >= 0)
{
const FCachedRoom& Room = SDFCache.Rooms[NearestRoomIdx];
for (int32 i = 0; i < 3; i++)
{
const FCachedPinch& Pinch = Room.Pinches[i];
if (!Pinch.bActive) { continue; }
const float DXPn = WorldX - Pinch.CenterX;
const float DYPn = WorldY - Pinch.CenterY;
const float DZPn = WorldZ - Pinch.CenterZ;
// Quick reject
if (FMath::Abs(DXPn) + FMath::Abs(DYPn) + FMath::Abs(DZPn) > Pinch.MaxExtent) continue;
const float Along = DXPn * Pinch.CosAngle + DYPn * Pinch.SinAngle;
const float Across = -DXPn * Pinch.SinAngle + DYPn * Pinch.CosAngle;
const float NAlong = Along / Pinch.HalfLength;
const float NAcross = Across / Pinch.HalfNarrow;
const float NUp = DZPn / Pinch.HalfVertical;
const float EllipDist = NAlong * NAlong + NAcross * NAcross + NUp * NUp;
if (EllipDist < 1.0f)
{
float Fill = 1.0f - EllipDist;
Fill = SmoothStep01(Fill);
const float AxisDist = FMath::Sqrt(NAcross * NAcross + NUp * NUp);
const float SideFactor = FMath::Clamp(AxisDist * 2.0f, 0.0f, 1.0f);
Density += Fill * SideFactor * Pinch.BaseDensity * 1.5f;
}
}
}
//=========================================================================
// FLOOR BIAS
//=========================================================================
// Adds density in the lower portion of rooms to counteract surface roughness
// making floors bumpy and hard to walk on. Works by knowing how far below
// the nearest room center we are — the closer to the floor, the more density
// is added back, smoothing the roughness-induced relief.
//
// Only applies inside cave air (CaveSDF < 0) so it doesn't re-solidify walls.
// The roughness clamp already prevents fill-back in confirmed air; this works
// WITH that to give floors a gentler character than walls/ceilings.
if (Params.FloorBias > 0.0f && NearestRoomIdx >= 0 && CaveSDF < 0.0f)
{
const FCachedRoom& NR = SDFCache.Rooms[NearestRoomIdx];
// NormZ: -1 = at room bottom, 0 = center, +1 = at ceiling
const float NormZ = (WorldZ - NR.Center.Z) / FMath::Max(NR.RadiusZ, 1.0f);
// Only apply below room center (floor zone).
// Quadratic fade: strongest at floor, zero at center.
if (NormZ < 0.0f)
{
float FloorFactor = NormZ * NormZ; // 0 at center, 1 at bottom
Density += FloorFactor * Params.FloorBias;
}
}
} // end bNearCaveSurface (terrain ops)
// Steps 4e / 4f (pits and chimneys) are now handled by the CaveSDF SmoothMin
// block inserted above (between EvaluateSDFCached and the CarveFactor section).
// The CarveFactor system carves them with the same density logic as rooms and
// tunnels. No separate density subtraction here — that would double-carve.
//=========================================================================
// STEP 5: WORM TUNNELS (additional organic connectivity)
//=========================================================================
// Worm tunnels add secondary passages and organic connections
// that the SDF graph doesn't create. They're noise-based, so they
// produce natural winding paths that complement the room-and-corridor structure.
//
// HORIZONTAL BIAS: Z frequency is scaled up so tunnels prefer horizontal paths.
//
// NETWORK MASK: |N1|+|N2| < threshold carves tubes near the intersection of two noise
// zero-surfaces — but wherever the fields merely GRAZE the threshold it leaves pinhole
// pockets, and none of it is connected to anything (the far-field "confetti").
// WormNetworkRange masks the carve by distance to the room/tunnel network (CaveSDF is
// already computed by Step 4 — free): full strength at the network, smooth fade to zero
// at Range. Worms become braids/shortcuts hugging the cave system; no isolated speckle.
// Range = 0 → unmasked legacy behaviour. Bonus: fully-masked voxels skip both Perlins.
if (Params.WormStrength > 0.0f && Params.WormThreshold > 0.0f)
{
float NetworkMask = 1.0f;
if (Params.WormNetworkRange > 0.0f)
{
if (CaveSDF >= Params.WormNetworkRange) // also true when no network (FLT_MAX)
{
NetworkMask = 0.0f;
}
else if (CaveSDF > 0.0f)
{
NetworkMask = 1.0f - SmoothStep01(CaveSDF / Params.WormNetworkRange);
}
}
if (NetworkMask > 0.0f)
{
float WormZFreq = Params.WormFrequency * Params.WormHorizontalBias;
float N1 = FMath::Abs(VoxelNoise::Perlin3D(FVector3f(
WorldX * Params.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.0f),
WorldY * Params.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.7f),
EffectiveZ * WormZFreq + VoxelHash::SeedOffset(SeedU, 2.3f)
)) * VOXEL_NOISE_SCALE);
// N2 >= 0, so if N1 alone already clears the threshold the sum can't carve —
// skip the second Perlin entirely (most voxels; bit-identical output).
if (N1 < Params.WormThreshold)
{
float N2 = FMath::Abs(VoxelNoise::Perlin3D(FVector3f(
WorldX * Params.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.0f) + 137.0f,
WorldY * Params.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.7f) + 259.0f,
EffectiveZ * WormZFreq + VoxelHash::SeedOffset(SeedU, 2.3f) + 431.0f
)) * VOXEL_NOISE_SCALE);
float WormValue = N1 + N2;
if (WormValue < Params.WormThreshold)
{
float t = 1.0f - (WormValue / Params.WormThreshold);
Density -= t * Params.WormStrength * NetworkMask;
}
}
}
}
//=========================================================================
// STEP 6: STRATE BOUNDARY SEAL (haut + bas)
//=========================================================================
ApplyOriginSpine(Density, WorldX, WorldY, WorldZ,
Params.StrateTopWorldZ, Params.StrateBottomWorldZ,
Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius);
ApplyBoundarySeal(Density, WorldZ,
Params.StrateTopWorldZ, Params.StrateBottomWorldZ,
Params.BoundarySealThickness, Params.BaseDensity);
//=========================================================================
// STEP 7: INTER-STRATE PASSAGES (perce le seal)
//=========================================================================
if (StrateManager)
{
if (bApplyLegacyStructuralPosts)
{
StrateManager->ApplyPassageModifier(
Density, WorldX, WorldY, WorldZ,
Params.BaseDensity, Params.BoundarySealThickness);
}
else
{
// GetDensityAt owns one common MC-space structural tail after either the legacy or
// operator-stack branch. Keep the actual tube carve here, but do not repeat its
// landing air/floor/tunnel-air posts before the shared disturbance/post sequence.
StrateManager->ApplyPassageCarvingOnly(
Density, WorldX, WorldY, WorldZ,
Params.BaseDensity, Params.BoundarySealThickness);
}
}
if (bApplyLegacyStructuralPosts)
{
// Terrain operations and passage support floors are allowed to write solid density, but
// the graph's own tunnel floor/core are structural route geometry. Direct callers of
// GetDensityWithParams retain this historical tail; GetDensityAt skips it and applies the
// same predicates once in its common MC-space post section below both generation paths.
const bool bTunnelSupportFloor = bApplyTunnelSupportFloor
&& VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint(
WorldX, WorldY, WorldZ, SDFCache);
if (bTunnelSupportFloor)
{
Density = FMath::Max(Density, FMath::Max(Params.BaseDensity * 2.0f, 1.0f));
}
const float TunnelCoreSDF = (Params.RoomDensity > 0.0f && Params.RoomSpacing > 0.0f)
? VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF(WorldX, WorldY, WorldZ, SDFCache)
: FLT_MAX;
if (!bTunnelSupportFloor
&& TunnelCoreSDF < -VoxelPassageGeometry::CaveTunnelAirCoreInsetVoxels)
{
Density = FMath::Min(
Density,
-FMath::Max(Params.BaseDensity * 2.0f, 1.0f));
}
ApplyOriginLandingFloor(Density, WorldX, WorldY, WorldZ,
Params.StrateTopWorldZ, Params.StrateBottomWorldZ,
Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius);
// Fourth structural post: the XY edge wins over a passage near the rim.
VF_ApplyXYEdgeSeal(Density, WorldX, WorldY,
WorldRadiusVoxels, EdgeSealThickness, Params.BaseDensity);
}
// Convention MC: négatif = solide, positif = air.
// La logique interne utilise positif = solide (plus lisible), donc on négate.
return -Density;
}
//=============================================================================
// SLAB DENSITY (FlatPlain / CrystalChamber generator types)
//=============================================================================
// Produces a large horizontal void between a noisy floor and a noisy ceiling.
// No rooms, no tunnels, no worm noise — just two surfaces with noise displacement.
//
// Key difference from TunnelNetwork: ceiling uses abs(noise) so ALL formations
// point DOWNWARD. This creates the stalactite/crystal hanging-from-above effect.
// Floor uses signed noise for natural ground undulation (hills and valleys).
//
// Columns are placed on a world-space hash grid (no rooms to anchor them to).
float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ,
const FSlabGenerationParams& Params) const
{
const float StrateHeight = Params.StrateTopWorldZ - Params.StrateBottomWorldZ;
// Degenerate strate (zero or inverted bounds) — return solid.
if (StrateHeight <= 0.0f) return 1.0f;
const uint32 SeedU = (uint32)Seed;
//=========================================================================
// STEP 1: FLOOR SURFACE
//=========================================================================
// The floor is at FloorZ + signed noise displacement.
// Signed noise allows both hills (noise > 0 → floor rises) and
// valleys (noise < 0 → floor dips) for natural rolling ground.
//
// ⚠️ XY-PUR / XY-PURE (OPSTACK-DECOMPOSITION §3.1, tranché par Jahni 2026-07-27).
// La 3e coordonnée était `WorldZ * FF * 0.05f` : une hauteur de sol qui dépendait de
// l'altitude d'où on la demandait. Le coefficient était minuscule, donc ça se lisait comme un
// léger étirement vertical plutôt que comme un bug — mais ça bloquait le cache de colonnes T1.a
// et rendait toute classification de boîte inexacte. Constante ⇒ la surface est une vraie
// fonction de (X,Y). Le monde se re-tune une fois : on échantillonne une autre tranche du champ
// de bruit, donc la forme du sol change (elle ne se dégrade pas).
//
// The 3rd coord was WorldZ * FF * 0.05f — a floor height that depended on the altitude you
// asked from. Now a constant, so the surface is a genuine function of (X,Y): the T1.a column
// cache and an exact box verdict both become available. Worlds re-tune once.
const float FloorZ = Params.StrateBottomWorldZ + StrateHeight * Params.FloorRelativeHeight;
float FloorNoise = 0.0f;
if (Params.FloorRoughness > 0.0f)
{
float FF = Params.FloorRoughnessFrequency;
FloorNoise = FractalNoise3D(FVector3f(
WorldX * FF + VoxelHash::SeedOffset(SeedU, 7.3f),
WorldY * FF + VoxelHash::SeedOffset(SeedU, 11.1f),
0.0f // XY-pur : plus aucune dépendance en Z / no Z dependence
), VoxelGenLOD::Eff(3)) * VOXEL_NOISE_SCALE * Params.FloorRoughness;
}
// Actual floor surface Z after noise displacement.
const float FloorSurface = FloorZ + FloorNoise;
//=========================================================================
// STEP 2: CEILING SURFACE (formations hang DOWNWARD)
//=========================================================================
// The ceiling uses abs(noise) so ALL displacement pushes the ceiling DOWN.
// When abs(noise) is high, rock protrudes further into the void — stalactite.
// When abs(noise) is near 0, the ceiling is near the base CeilZ line.
//
// This asymmetry (only downward protrusions, never upward pockets) creates
// the crystal-forest / stalactite silhouette from below.
//
// XY-PUR, même raison que le sol ci-dessus (§3.1). Le `+ 3000.0f` RESTE : ce n'est pas un
// terme en Z, c'est le décalage qui décorrèle le champ du plafond de celui du sol.
// XY-pure for the same reason as the floor. The + 3000.0f STAYS — it is not a Z term, it is
// the offset that decorrelates the ceiling's noise field from the floor's.
const float CeilZ = Params.StrateBottomWorldZ + StrateHeight * Params.CeilingRelativeHeight;
float CeilNoise = 0.0f;
if (Params.CeilingRoughness > 0.0f)
{
float CF = Params.CeilingRoughnessFrequency;
float RawNoise = FractalNoise3D(FVector3f(
WorldX * CF + VoxelHash::SeedOffset(SeedU, 17.3f) + 1000.0f,
WorldY * CF + VoxelHash::SeedOffset(SeedU, 19.7f) + 2000.0f,
3000.0f // XY-pur : décalage de décorrélation seul / offset only
), VoxelGenLOD::Eff(3)) * VOXEL_NOISE_SCALE;
// abs() → formations ONLY hang down, never push ceiling up into solid rock.
// Result: every noise peak creates a downward protrusion (crystal/stalactite).
CeilNoise = FMath::Abs(RawNoise) * Params.CeilingRoughness;
}
// Actual ceiling surface Z (can only move downward due to abs above).
// Clamp so ceiling never drops below floor + 2 voxels of headroom.
// Without this clamp, extreme CeilingRoughness could completely fill the void.
const float CeilSurface = FMath::Max(CeilZ - CeilNoise, FloorSurface + 2.0f);
//=========================================================================
// STEP 3: VOID FIELD → BASE DENSITY
//=========================================================================
// Each voxel is measured against both surfaces:
// DistAboveFloor > 0 → voxel is above the floor (possibly in the void)
// DistBelowCeil > 0 → voxel is below the ceiling (possibly in the void)
//
// VoidField = min of both distances.
// Positive inside the void (between floor and ceiling).
// Negative outside (below floor or above ceiling = solid rock).
//
// Density = -VoidField (internal convention: positive = solid, negative = air).
const float DistAboveFloor = WorldZ - FloorSurface; // + when above floor
const float DistBelowCeil = CeilSurface - WorldZ; // + when below ceiling
const float VoidField = FMath::Min(DistAboveFloor, DistBelowCeil);
float Density = -VoidField; // Negative = air (inside void), positive = solid
//=========================================================================
// STEP 4: COLUMNS (hash-based, world-space grid)
//=========================================================================
// Unlike TunnelNetwork columns (anchored to room centers), slab columns
// are placed on a regular world-space hash grid. They are infinite-height
// cylinders — the void field already defines where solid/air is, so the
// column SDF just adds density everywhere along its XY position.
// The column is only visible where the void field carved air around it.
if (Params.ColumnDensity > 0.0f && Params.ColumnSpacing > 0.0f)
{
const float Spacing = Params.ColumnSpacing;
// Which cell are we in?
const int32 ColCX = FMath::FloorToInt(WorldX / Spacing);
const int32 ColCY = FMath::FloorToInt(WorldY / Spacing);
// The 3×3 neighbourhood's columns (existence roll, jitter, radius) are a pure function of
// (cell, seed, params) yet were re-derived PER VOXEL — 9 hash rolls + mixes in the slab hot
// loop. Bake them once per centre cell (thread_local); rebuild only when the query crosses a
// cell border or the seed/params change. Bit-identical (same hashes, same math).
struct FSlabColumn { float X, Y, R; };
thread_local TArray<FSlabColumn, TInlineAllocator<9>> SC_Cols;
thread_local int32 SC_CX = INT32_MAX, SC_CY = INT32_MAX;
thread_local uint32 SC_Seed = 0xFFFFFFFFu;
thread_local float SC_Spacing = -1.0f, SC_Dens = -1.0f, SC_MinR = -1.0f, SC_MaxR = -1.0f;
if (ColCX != SC_CX || ColCY != SC_CY || (uint32)Seed != SC_Seed || Spacing != SC_Spacing ||
Params.ColumnDensity != SC_Dens || Params.ColumnMinRadius != SC_MinR || Params.ColumnMaxRadius != SC_MaxR)
{
SC_CX = ColCX; SC_CY = ColCY; SC_Seed = (uint32)Seed; SC_Spacing = Spacing;
SC_Dens = Params.ColumnDensity; SC_MinR = Params.ColumnMinRadius; SC_MaxR = Params.ColumnMaxRadius;
SC_Cols.Reset();
for (int32 DY = -1; DY <= 1; DY++)
{
for (int32 DX = -1; DX <= 1; DX++)
{
const int32 NCX = ColCX + DX;
const int32 NCY = ColCY + DY;
// Deterministic: same seed → same column pattern every session.
// XOR with a prime salt so columns don't correlate with room placement.
const uint32 H = VoxelHash::Cell(NCX, NCY, (uint32)Seed ^ 0xC01C01u);
// ColumnDensity is the probability this cell has a column.
if (VoxelHash::ToFloat01(H) > Params.ColumnDensity) continue;
// Jitter the column center within the cell (15%-85% of cell extent)
// to avoid a perfectly regular grid pattern.
const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x12345678u));
const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x9ABCDEF0u));
FSlabColumn& Col = SC_Cols.AddDefaulted_GetRef();
Col.X = (NCX + 0.15f + JX * 0.7f) * Spacing;
Col.Y = (NCY + 0.15f + JY * 0.7f) * Spacing;
// Column radius: hash-derived within configured range.
Col.R = FMath::Lerp(Params.ColumnMinRadius, Params.ColumnMaxRadius,
VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0xBEEFu)));
}
}
}
float ColumnSDF = FLT_MAX;
for (const FSlabColumn& Col : SC_Cols)
{
// 2D cylinder SDF (infinite height — void field handles top/bottom).
const float DX2D = WorldX - Col.X;
const float DY2D = WorldY - Col.Y;
ColumnSDF = FMath::Min(ColumnSDF, FMath::Sqrt(DX2D * DX2D + DY2D * DY2D) - Col.R);
}
// Smoothstep blend zone around the column edge (avoids hard MC aliasing).
const float ColBlend = 2.0f;
if (ColumnSDF < ColBlend && ColumnSDF < FLT_MAX)
{
float Fill = FMath::Clamp((ColBlend - ColumnSDF) / (ColBlend * 2.0f), 0.0f, 1.0f);
Fill = SmoothStep01(Fill); // Smoothstep
Density += Fill * Params.BaseDensity * 1.5f;
}
}
//=========================================================================
// STEP 5: BOUNDARY SEAL + STEP 6: PASSAGES + STEP 7: XY EDGE SEAL
//=========================================================================
// Même logique que TunnelNetwork — factorisée dans les helpers ci-dessus.
ApplyOriginSpine(Density, WorldX, WorldY, WorldZ,
Params.StrateTopWorldZ, Params.StrateBottomWorldZ,
Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius);
ApplyBoundarySeal(Density, WorldZ,
Params.StrateTopWorldZ, Params.StrateBottomWorldZ,
Params.BoundarySealThickness, Params.BaseDensity);
if (StrateManager)
{
StrateManager->ApplyPassageModifier(
Density, WorldX, WorldY, WorldZ,
Params.BaseDensity, Params.BoundarySealThickness);
}
ApplyOriginLandingFloor(Density, WorldX, WorldY, WorldZ,
Params.StrateTopWorldZ, Params.StrateBottomWorldZ,
Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius);
VF_ApplyXYEdgeSeal(Density, WorldX, WorldY,
WorldRadiusVoxels, EdgeSealThickness, Params.BaseDensity);
// Convention MC: négatif = solide.
return -Density;
}
//=============================================================================
// MAZE GENERATOR (ECaveGeneratorType::Maze)
//=============================================================================
// Solid rock carved by a deterministic 3D lattice of corridors. Each non-origin lattice node
// chooses one parent toward (0,0,0), which is a spanning tree by construction. A small, capped
// hash-gated loop set adds alternate routes without being needed for connectivity. The local
// source evaluates the eight child nodes in the current cell's {-1,0} halo, so chunk seams cannot
// change an edge decision.
float UVoxelGenerator::GetMazeDensity(float WorldX, float WorldY, float WorldZ,
const FMazeGenerationParams& Params) const
{
const float StrateHeight = Params.StrateTopWorldZ - Params.StrateBottomWorldZ;
if (StrateHeight <= 0.0f) return 1.0f;
const float CS = FMath::Max(Params.CellSize, 1.0f);
const FVector Pos(WorldX, WorldY, WorldZ);
const uint32 S = (uint32)Seed ^ 0x4D617A65u; // 'Maze'
float Density = Params.BaseDensity; // start solid
const int32 CX = FMath::FloorToInt(WorldX / CS);
const int32 CY = FMath::FloorToInt(WorldY / CS);
const int32 CZ = FMath::FloorToInt(WorldZ / CS);
// The edge set reachable from this voxel's cell is a pure function of (cell, seed, params).
// Bake the parent and optional loop capsule endpoints once per cell (thread_local); the
// per-voxel work is just the capsule SDFs. Rebuilds only on a cell crossing / param change.
struct FMazeEdge { FVector A, B; };
thread_local TArray<FMazeEdge, TInlineAllocator<24>> MZ_Edges;
thread_local FIntVector MZ_Cell(INT32_MAX, INT32_MAX, INT32_MAX);
thread_local uint32 MZ_Seed = 0xFFFFFFFFu;
thread_local float MZ_CS = -1.0f, MZ_Branch = -1.0f, MZ_Vert = -1.0f;
const FIntVector Cell(CX, CY, CZ);
if (Cell != MZ_Cell || S != MZ_Seed || CS != MZ_CS ||
Params.BranchProbability != MZ_Branch || Params.Verticality != MZ_Vert)
{
MZ_Cell = Cell; MZ_Seed = S; MZ_CS = CS;
MZ_Branch = Params.BranchProbability; MZ_Vert = Params.Verticality;
MZ_Edges.Reset();
auto NodeCenter = [CS](int32 X, int32 Y, int32 Z)
{
return FVector((X + 0.5f) * CS, (Y + 0.5f) * CS, (Z + 0.5f) * CS);
};
// Canonical lower-node edges in {-1,0} per axis cover every corridor that can reach this
// voxel's cell. IsOpenEdge checks both endpoints, including a +1 node's parent choice.
for (int32 dz = -1; dz <= 0; dz++)
for (int32 dy = -1; dy <= 0; dy++)
for (int32 dx = -1; dx <= 0; dx++)
{
const int32 nx = CX + dx, ny = CY + dy, nz = CZ + dz;
const FVector A = NodeCenter(nx, ny, nz);
if (VoxelMazeTopology::IsOpenEdge(
nx, ny, nz, VoxelMazeTopology::EAxis::X,
S, Params.BranchProbability, Params.Verticality))
{
MZ_Edges.Add({ A, NodeCenter(nx + 1, ny, nz) });
}
if (VoxelMazeTopology::IsOpenEdge(
nx, ny, nz, VoxelMazeTopology::EAxis::Y,
S, Params.BranchProbability, Params.Verticality))
{
MZ_Edges.Add({ A, NodeCenter(nx, ny + 1, nz) });
}
if (VoxelMazeTopology::IsOpenEdge(
nx, ny, nz, VoxelMazeTopology::EAxis::Z,
S, Params.BranchProbability, Params.Verticality))
{
MZ_Edges.Add({ A, NodeCenter(nx, ny, nz + 1) });
}
}
}
const float R = FMath::Max(Params.CorridorRadius, 0.5f);
float MazeSDF = FLT_MAX;
for (const FMazeEdge& E : MZ_Edges)
{
MazeSDF = FMath::Min(MazeSDF, VoxelSDF::Capsule(Pos, E.A, E.B, R));
}
// Wall roughness: perturb the corridor surface.
if (Params.SurfaceRoughness > 0.0f && MazeSDF < R + Params.SurfaceRoughness + 2.0f)
{
MazeSDF += FractalNoise3D(FVector3f(WorldX * 0.12f, WorldY * 0.12f, WorldZ * 0.12f), VoxelGenLOD::Eff(3))
* VOXEL_NOISE_SCALE * Params.SurfaceRoughness;
}
// Carve air where inside a corridor.
const float Blend = 2.0f;
if (MazeSDF < Blend)
{
float Carve = FMath::Clamp((Blend - MazeSDF) / (Blend * 2.0f), 0.0f, 1.0f);
Carve = SmoothStep01(Carve);
Density -= Carve * Params.BaseDensity * 2.0f;
}
ApplyOriginSpine(Density, WorldX, WorldY, WorldZ,
Params.StrateTopWorldZ, Params.StrateBottomWorldZ,
Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius);
ApplyBoundarySeal(Density, WorldZ,
Params.StrateTopWorldZ, Params.StrateBottomWorldZ,
Params.BoundarySealThickness, Params.BaseDensity);
if (StrateManager)
{
StrateManager->ApplyPassageModifier(
Density, WorldX, WorldY, WorldZ,
Params.BaseDensity, Params.BoundarySealThickness);
}
ApplyOriginLandingFloor(Density, WorldX, WorldY, WorldZ,
Params.StrateTopWorldZ, Params.StrateBottomWorldZ,
Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius);
VF_ApplyXYEdgeSeal(Density, WorldX, WorldY,
WorldRadiusVoxels, EdgeSealThickness, Params.BaseDensity);
return -Density;
}
//=============================================================================
// SURFACE-WORLD GENERATOR (ECaveGeneratorType::SurfaceWorld)
//=============================================================================
// A heightfield terrain (fBM continents + ridged mountains + fine detail) under a
// high solid "sky cap" ceiling, with a flattened beach band around the water line.
// Open air fills the gap between ground and ceiling; water is a render-side overlay.
float UVoxelGenerator::SampleSurfaceStructuralZ(float WorldX, float WorldY,
const FSurfaceGenerationParams& Params, float& OutM) const
{
const float H = Params.StrateTopWorldZ - Params.StrateBottomWorldZ;
const uint32 SeedU = (uint32)Seed;
const float BottomZ = Params.StrateBottomWorldZ;
// --- Heightfield (a function of XY only — Z is a fixed seed slice) ---
const float GroundBase = BottomZ + H * Params.BaseGroundRelative;
// Domain-warp the STRUCTURAL query coords (continents + mountains) so coastlines and
// ridgelines wind organically instead of looking like axis-aligned noise blobs. Detail
// noise stays on the real XY so fine bumps remain crisp and uncorrelated with the warp.
float QX = WorldX, QY = WorldY;
if (Params.HeightWarpStrength > 0.0f)
{
const float WF = Params.HeightWarpFrequency;
const float wx = VoxelNoise::Perlin3D(FVector3f(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.31f), WorldY * WF + 4.2f, VoxelHash::SeedOffset(SeedU, 1.7f)));
const float wy = VoxelNoise::Perlin3D(FVector3f(WorldX * WF + 8.6f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.53f), VoxelHash::SeedOffset(SeedU, 2.9f)));
QX += wx * VOXEL_NOISE_SCALE * Params.HeightWarpStrength;
QY += wy * VOXEL_NOISE_SCALE * Params.HeightWarpStrength;
}
// Macro relief map [0,1]: where this XY sits on the plains <-> mountains spectrum.
// M is the "mountainous-ness"; ReliefStrength=0 → M=1 everywhere (uniform terrain).
const float Relief = SampleRelief(WorldX, WorldY, Params.ReliefFrequency, Params.ReliefContrast);
const float M = FMath::Lerp(1.0f, Relief, Params.ReliefStrength);
float Cont = FractalNoise3D(FVector3f(
QX * Params.ContinentFrequency + VoxelHash::SeedOffset(SeedU, 3.1f),
QY * Params.ContinentFrequency + VoxelHash::SeedOffset(SeedU, 5.7f),
VoxelHash::SeedOffset(SeedU, 0.7f)), 4); // [-1,1]
float Detail = FractalNoise3D(FVector3f(
WorldX * Params.DetailFrequency + 11.0f,
WorldY * Params.DetailFrequency + 22.0f,
VoxelHash::SeedOffset(SeedU, 1.3f)), 3); // [-1,1]
float Mountain = 0.0f;
if (Params.MountainStrength > 0.0f)
{
float Ridge = RidgedNoise3D(FVector3f(
QX * Params.MountainFrequency + 99.0f,
QY * Params.MountainFrequency + 77.0f,
VoxelHash::SeedOffset(SeedU, 0.9f)), 4); // [-1,1]
Ridge = Ridge * 0.5f + 0.5f; // [0,1] peaks
Mountain = Ridge * Params.MountainStrength * M; // mountains rise only in high-relief regions
}
// Plains keep a fraction of the continental swell; highlands get the full range.
const float ContScale = FMath::Lerp(0.45f, 1.0f, M);
float Terrain = GroundBase
+ Cont * Params.ElevationRange * 0.5f * ContScale
+ Mountain * Params.ElevationRange
+ Detail * Params.SurfaceRoughness;
OutM = M;
return Terrain;
}
float UVoxelGenerator::ComputeSurfaceTerrainZ(float WorldX, float WorldY,
const FSurfaceGenerationParams& Params) const
{
// Raw structural ground + the relief "mountainous-ness" M (reused to relief-gate the ops).
float M = 1.0f;
float Terrain = SampleSurfaceStructuralZ(WorldX, WorldY, Params, M);
// --- F20 heightfield terrain ops (all off by default → byte-identical when unset) ---
// CLIFF (slope-conditioned STEEPENING): where the surface is already steep, push the height
// AWAY from the local mean so gentle slopes become sheer walls / canyon faces; gentle ground
// stays untouched. Slope + local mean come from central differences of the STRUCTURAL field
// (no op feedback), so it tracks the real landform. The one op that costs extra samples —
// only when enabled (4 structural resamples, per-column-cheap).
if (Params.CliffStrength > 0.0f)
{
const float D = FMath::Max(Params.CliffSampleDist, 0.5f);
float Ms; // relief scratch (unused — we only need the heights)
const float Zxp = SampleSurfaceStructuralZ(WorldX + D, WorldY, Params, Ms);
const float Zxm = SampleSurfaceStructuralZ(WorldX - D, WorldY, Params, Ms);
const float Zyp = SampleSurfaceStructuralZ(WorldX, WorldY + D, Params, Ms);
const float Zym = SampleSurfaceStructuralZ(WorldX, WorldY - D, Params, Ms);
const float dZdX = (Zxp - Zxm) / (2.0f * D);
const float dZdY = (Zyp - Zym) / (2.0f * D);
const float Slope = FMath::Sqrt(dZdX * dZdX + dZdY * dZdY); // rise per voxel of XY
const float Thr = FMath::Max(Params.CliffSlopeThreshold, 0.05f);
// 0 below the threshold, ramps to 1 within one threshold-width above it.
const float SlopeGate = FMath::Clamp((Slope - Thr) / Thr, 0.0f, 1.0f);
if (SlopeGate > 0.0f)
{
// Local smoothed reference; push the true height away from it → steepen the wall.
const float Ref = 0.25f * (Zxp + Zxm + Zyp + Zym);
const float Gain = Params.CliffStrength * SlopeGate * Params.CliffSharpness;
Terrain += (Terrain - Ref) * Gain;
}
}
// TERRACE (relief-gated): quantize height into plateaus, only in high-relief regions and only
// as strongly as TerraceStrength asks. TerraceHardness controls the riser: soft rounded steps
// (0) → crisp flat-topped mesas with near-vertical walls (1). Layered cliffs/mesas up top,
// smooth lowlands.
if (Params.TerraceStrength > 0.0f && Params.TerraceHeight > 0.0f)
{
const float StepH = Params.TerraceHeight;
const float T = Terrain / StepH;
const float K = FMath::FloorToFloat(T);
const float Frac = T - K; // [0,1) position within the step
// Hardness widens the flat plateau and sharpens the riser: half-transition width goes
// from 0.5 (a smooth S-curve, no plateau) at Hardness=0 to ~0.03 (sharp wall) at 1.
const float W = FMath::Lerp(0.5f, 0.03f, FMath::Clamp(Params.TerraceHardness, 0.0f, 1.0f));
const float Fs = SmoothStep01(FMath::Clamp((Frac - (0.5f - W)) / (2.0f * W), 0.0f, 1.0f));
const float Stepped = (K + Fs) * StepH;
Terrain = FMath::Lerp(Terrain, Stepped, Params.TerraceStrength * M);
}
// LAYER LINES: fine sedimentary shelves cut into slopes (exposed rock strata). Pull the
// surface weakly toward each band plane; reads on slopes, invisible on flats (uniform shift).
if (Params.LayerLineDepth > 0.0f && Params.LayerLineSpacing > 0.0f)
{
const float Phase = Terrain * (2.0f * PI / Params.LayerLineSpacing);
Terrain -= FMath::Sin(Phase) * Params.LayerLineDepth;
}
// Beach: flatten terrain toward the water line within BeachWidth. (Water level is
// strate-global — forced from the strate — so the water plane stays continuous.)
const float H = Params.StrateTopWorldZ - Params.StrateBottomWorldZ;
const float WaterZ = Params.StrateBottomWorldZ + H * Params.WaterLevelRelative;
if (Params.WaterLevelRelative > 0.0f && Params.BeachWidth > 0.0f)
{
const float DAbs = FMath::Abs(Terrain - WaterZ);
if (DAbs < Params.BeachWidth)
{
float T = SmoothStep01(DAbs / Params.BeachWidth);
Terrain = FMath::Lerp(WaterZ, Terrain, T);
}
}
return Terrain;
}
float UVoxelGenerator::ComputeSurfaceCeiling(float WorldX, float WorldY,
const FSurfaceGenerationParams& Params) const
{
const float H = Params.StrateTopWorldZ - Params.StrateBottomWorldZ;
const uint32 SeedU = (uint32)Seed;
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(FVector3f(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.71f), WorldY * WF + 2.3f, VoxelHash::SeedOffset(SeedU, 3.3f)));
const float wy = VoxelNoise::Perlin3D(FVector3f(WorldX * WF + 6.1f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.19f), VoxelHash::SeedOffset(SeedU, 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(FVector3f(
QX * Params.CeilingUndulationFrequency + VoxelHash::SeedOffset(SeedU, 1.9f),
QY * Params.CeilingUndulationFrequency + 13.0f,
VoxelHash::SeedOffset(SeedU, 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)
{
Hang += FMath::Abs(FractalNoise3D(FVector3f(
WorldX * Params.CeilingRoughnessFrequency + 5.0f,
WorldY * Params.CeilingRoughnessFrequency + 6.0f,
VoxelHash::SeedOffset(SeedU, 2.1f)), 3)) * VOXEL_NOISE_SCALE * Params.CeilingRoughness;
}
if (Params.CeilingRidgeStrength > 0.0f)
{
float Ridge = RidgedNoise3D(FVector3f(
QX * Params.CeilingRidgeFrequency + 31.0f,
QY * Params.CeilingRidgeFrequency + 47.0f,
VoxelHash::SeedOffset(SeedU, 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,
float TerrainZ, float CeilSurf,
float OverhangAmp, float DirX, float DirY,
const FSurfaceGenerationParams& S) const
{
// Solid below the terrain surface; solid above the sky-cap ceiling.
float Density = TerrainZ - WorldZ;
Density = FMath::Max(Density, WorldZ - CeilSurf);
// F20 phase 2 — OVERHANG shelf (warped-terrain union): for AIR voxels in a window just above a
// steep slope, re-sample the heightfield UPHILL (toward the cliff) by a height-varying amount and
// union that rock in → the cliff-top rock juts OUT over the void below, self-capping at the cliff's
// height. Genuine 3D (per-voxel re-eval), so it's gated hard: only steep overhang columns (amp>0),
// only air voxels within OverhangHeight of the local ground, only when the shift is ≥ a voxel.
// The lip is capped at TerrainZ+OverhangHeight, which ClassifyTile treats as ambiguous (no holes).
if (OverhangAmp > 0.0f && S.OverhangHeight > 0.0f
&& WorldZ > TerrainZ && WorldZ <= TerrainZ + S.OverhangHeight)
{
const uint32 SeedU = (uint32)Seed;
const float f = S.OverhangFrequency;
// Shelf-shape noise [0,1]; the Z term makes the reach fold/curl with height (ragged, not a lip).
const float Ns = FractalNoise3D(FVector3f(
WorldX * f + VoxelHash::SeedOffset(SeedU, 17.3f),
WorldY * f + VoxelHash::SeedOffset(SeedU, 23.9f),
WorldZ * f * S.OverhangZScale + VoxelHash::SeedOffset(SeedU, 5.1f)), 3) * 0.5f + 0.5f; // [0,1]
// KEY: the uphill reach GROWS with height in the window (Frac: 0 at ground → 1 at the cap). Low
// down the shift is tiny ⇒ borrows nearby low rock ⇒ stays AIR over the void; high up the shift
// reaches the far cliff ⇒ solid ⇒ the lip sits on top with air UNDERNEATH = a real overhang.
const float Frac = (WorldZ - TerrainZ) / S.OverhangHeight; // (0,1] inside the window
const float ShiftV = S.OverhangReach * OverhangAmp * Frac * Ns; // uphill reach (voxels)
if (ShiftV > 0.5f)
{
// Borrow the uphill STRUCTURAL height (not the full op'd surface): the shelf underside doesn't
// need cliff/terrace refinement, and this avoids re-running Cliff's resamples per lip voxel.
float Ms;
const float ShiftedTZ = SampleSurfaceStructuralZ(WorldX + DirX * ShiftV, WorldY + DirY * ShiftV, S, Ms);
Density = FMath::Max(Density, ShiftedTZ - WorldZ); // union: solid where uphill rock covers Z
}
}
// Structural fields (Z bounds, seal, base) are forced equal across biomes → S is safe.
ApplyOriginSpine(Density, WorldX, WorldY, WorldZ,
S.StrateTopWorldZ, S.StrateBottomWorldZ,
S.BoundarySealThickness, S.BaseDensity, OriginSpineRadius);
ApplyBoundarySeal(Density, WorldZ,
S.StrateTopWorldZ, S.StrateBottomWorldZ,
S.BoundarySealThickness, S.BaseDensity);
if (StrateManager)
{
StrateManager->ApplyPassageModifier(
Density, WorldX, WorldY, WorldZ,
S.BaseDensity, S.BoundarySealThickness);
}
ApplyOriginLandingFloor(Density, WorldX, WorldY, WorldZ,
S.StrateTopWorldZ, S.StrateBottomWorldZ,
S.BoundarySealThickness, S.BaseDensity, OriginSpineRadius);
VF_ApplyXYEdgeSeal(Density, WorldX, WorldY,
WorldRadiusVoxels, EdgeSealThickness, S.BaseDensity);
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,
float& OutOverhangAmp, float& OutDirX, float& OutDirY) 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);
// F20 phase 2 — resolve the per-column OVERHANG gate (strength·slope-gate, biome-blended) and the
// UPHILL direction. Slope + gradient come from a forward-diff of the STRUCTURAL field (the real
// landform), computed ONCE here. Off ⇒ amp 0 and the whole block is skipped (no extra samples).
OutOverhangAmp = 0.0f;
OutDirX = 0.0f; OutDirY = 0.0f;
if (PD->OverhangStrength > 0.0f || (W > 0.0f && PN->OverhangStrength > 0.0f))
{
// Sample the gradient at ~reach scale (a spot over the void must "see" the nearby cliff to know
// uphill), but CLAMPED to [4,16] — an un-clamped large Reach would average the slope over a huge
// span and read even a real cliff as flat (killing the gate; the "big Reach = nothing" bug).
const float SD = FMath::Clamp(PD->OverhangReach, 4.0f, 16.0f);
float Ms;
const float Z0 = SampleSurfaceStructuralZ(WorldX, WorldY, *PD, Ms);
const float GX = (SampleSurfaceStructuralZ(WorldX + SD, WorldY, *PD, Ms) - Z0) / SD; // dZ/dX
const float GY = (SampleSurfaceStructuralZ(WorldX, WorldY + SD, *PD, Ms) - Z0) / SD; // dZ/dY
const float Slope = FMath::Sqrt(GX * GX + GY * GY);
auto Amp = [Slope](const FSurfaceGenerationParams& P) -> float
{
if (P.OverhangStrength <= 0.0f) return 0.0f;
const float Thr = FMath::Max(P.OverhangSlopeThreshold, 0.05f);
const float Gate = FMath::Clamp((Slope - Thr) / Thr, 0.0f, 1.0f);
return P.OverhangStrength * Gate; // [0,1]; reach comes from the param at apply time
};
OutOverhangAmp = (W > 0.0f) ? FMath::Lerp(Amp(*PD), Amp(*PN), W) : Amp(*PD);
// Unit UPHILL direction (the gradient points uphill). The shelf borrows rock from uphill and
// extends it downhill over the void. Degenerate on flats — amp is 0 there anyway.
if (Slope > KINDA_SMALL_NUMBER) { OutDirX = GX / Slope; OutDirY = GY / Slope; }
}
}
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;
thread_local uint32 OC_Version = 0xFFFFFFFFu; // AUDIT C2
const uint32 OC_LayoutVersion = StrateManager->GetLayoutVersion();
if (ChunkCoord != OC_Chunk || OC_LayoutVersion != OC_Version)
{
if (OC_LayoutVersion != OC_Version) { OC_BiomeCache.Invalidate(); }
OC_Version = OC_LayoutVersion;
OC_Chunk = ChunkCoord;
ResolveSurfaceChunkParams(ChunkCoord, OC_Surface, OC_BiomeCtx, OC_BiomeParams);
}
float OcAmp, OcDirX, OcDirY; // overhang is a density-only 3D shelf; the height oracle ignores it
ComputeSurfaceColumn(WorldX, WorldY, ChunkZ, OC_Surface, OC_BiomeCtx, OC_BiomeParams, OC_BiomeCache,
OutTerrainZ, OutCeilSurf, OcAmp, OcDirX, OcDirY);
return true;
}
//=============================================================================
// TRIVIAL-TILE CLASSIFICATION (T1.d)
//=============================================================================
// ~84 % des tuiles générées sortent VIDES (tout-roc sous le terrain, tout-air
// au-dessus, cap solide) mais payaient quand même le pré-échantillonnage 33³+
// complet (trace 2026-07-05 : 83 925 GenerateMesh pour 13 227 maillages réels).
// Une 1re tentative (2026-06-26) a été REVERTÉE : borne analytique GLOBALE du
// plafond pas assez conservative (cap bas ⇒ trous dans le toit). Ici on suit la
// prescription du revert : les colonnes sont ÉCHANTILLONNÉES sur le treillis
// exact du mesher (mêmes fonctions ⇒ mêmes floats ⇒ verdict exact, pas une
// estimation), et le cas tout-solide porte des gardes spine/passages/
// disturbances/diff. Tout ce qui n'est pas prouvable ⇒ Mixed (le seul coût d'un
// faux Mixed est du CPU ; un faux AllSolid/AllAir serait un trou).
// A full tile can be Mixed even when every smaller region is provably the same class. This is
// particularly common at level 0: broad operator bounds overlap an unbounded carve, while the
// actual tile-sized sub-boxes are all air. Refine only an already-Mixed proof. Every child must
// resolve, and all resolved children must agree; an unresolved or conflicting child remains Mixed.
// This is a proof refinement, never a density/sample shortcut.
static EVoxelTileClass VF_ClassifyBoxRefined(const FVoxelOpStack& Stack,
const FBox& VoxelBox,
const FVoxelOpContext& Context,
const UVoxelGenerator* Generator,
const UVoxelStrateManager* StructuralManager,
float StructuralBaseDensity = 8.0f,
FVoxelTileClassificationStats* Stats = nullptr)
{
// The ordinary proof stays shallow. The finite-domain warp retry is paid only by a
// tile that already failed the cheap proof, and can afford one more split level because
// its purpose is to discharge the LOD0 false-Mixed cases without sampling the field.
const int32 MaxRefinementDepth = Context.bTightenWarpProof ? 5 : 2;
// Exact certificates are still the mesher's lattice, never a reduced-resolution shortcut.
// Keep the samples local to this one classifier call. The old recursive fallback evaluated
// the same shared lattice vertices once per depth-3 leaf (up to 64,000 calls for one LOD0
// tile); besides being wasteful, that made an empty tile look like a brute-force generation.
// A byte validity plane is intentional: it is transient, bounded by one tile, and must not
// become another process-wide cache.
int32 ExactCacheX0 = 0, ExactCacheY0 = 0, ExactCacheZ0 = 0;
int32 ExactCacheNX = 0, ExactCacheNY = 0, ExactCacheNZ = 0;
TArray<float> ExactCoreValues;
TArray<float> ExactFinalValues;
TArray<uint8> ExactCoreValid;
TArray<uint8> ExactFinalValid;
// A negative structural result is monotone over nested boxes. Compute it once for the root
// lattice and carry that absence through the refinement tree; otherwise every AllSolid child
// repeats the same 33³ post-air scan even though a false root result already excludes all of
// its descendants. A positive result is not cached as absent and keeps the old conservative
// per-child checks.
bool bRootPassageAirKnownAbsent = false;
bool bRootOriginAirKnownAbsent = false;
const bool bUseExactCache = Context.bUseLatticeProof && Context.Step > 0
&& [&]()
{
const float Step = static_cast<float>(Context.Step);
const FVector Origin(static_cast<float>(Context.LatticeOriginVoxels.X),
static_cast<float>(Context.LatticeOriginVoxels.Y),
static_cast<float>(Context.LatticeOriginVoxels.Z));
ExactCacheX0 = FMath::CeilToInt((static_cast<float>(VoxelBox.Min.X) - Origin.X)
/ Step - 1.0e-4f);
ExactCacheY0 = FMath::CeilToInt((static_cast<float>(VoxelBox.Min.Y) - Origin.Y)
/ Step - 1.0e-4f);
ExactCacheZ0 = FMath::CeilToInt((static_cast<float>(VoxelBox.Min.Z) - Origin.Z)
/ Step - 1.0e-4f);
const int32 X1 = FMath::FloorToInt((static_cast<float>(VoxelBox.Max.X) - Origin.X)
/ Step + 1.0e-4f);
const int32 Y1 = FMath::FloorToInt((static_cast<float>(VoxelBox.Max.Y) - Origin.Y)
/ Step + 1.0e-4f);
const int32 Z1 = FMath::FloorToInt((static_cast<float>(VoxelBox.Max.Z) - Origin.Z)
/ Step + 1.0e-4f);
ExactCacheNX = X1 - ExactCacheX0 + 1;
ExactCacheNY = Y1 - ExactCacheY0 + 1;
ExactCacheNZ = Z1 - ExactCacheZ0 + 1;
const int64 Count = static_cast<int64>(ExactCacheNX)
* static_cast<int64>(ExactCacheNY)
* static_cast<int64>(ExactCacheNZ);
if (ExactCacheNX <= 0 || ExactCacheNY <= 0 || ExactCacheNZ <= 0
|| Count <= 0 || Count > 100000)
{
ExactCacheNX = ExactCacheNY = ExactCacheNZ = 0;
return false;
}
const int32 NumVertices = static_cast<int32>(Count);
ExactCoreValues.SetNumUninitialized(NumVertices);
ExactFinalValues.SetNumUninitialized(NumVertices);
ExactCoreValid.Init(0, NumVertices);
ExactFinalValid.Init(0, NumVertices);
return true;
}();
auto GetLatticeBounds = [&](const FBox& Box,
int32& OutX0, int32& OutY0, int32& OutZ0,
int32& OutX1, int32& OutY1, int32& OutZ1,
int64& OutCount) -> bool
{
if (!Context.bUseLatticeProof || Context.Step <= 0)
{
return false;
}
const float Step = static_cast<float>(Context.Step);
const FVector Origin(static_cast<float>(Context.LatticeOriginVoxels.X),
static_cast<float>(Context.LatticeOriginVoxels.Y),
static_cast<float>(Context.LatticeOriginVoxels.Z));
OutX0 = FMath::CeilToInt((static_cast<float>(Box.Min.X) - Origin.X)
/ Step - 1.0e-4f);
OutY0 = FMath::CeilToInt((static_cast<float>(Box.Min.Y) - Origin.Y)
/ Step - 1.0e-4f);
OutZ0 = FMath::CeilToInt((static_cast<float>(Box.Min.Z) - Origin.Z)
/ Step - 1.0e-4f);
OutX1 = FMath::FloorToInt((static_cast<float>(Box.Max.X) - Origin.X)
/ Step + 1.0e-4f);
OutY1 = FMath::FloorToInt((static_cast<float>(Box.Max.Y) - Origin.Y)
/ Step + 1.0e-4f);
OutZ1 = FMath::FloorToInt((static_cast<float>(Box.Max.Z) - Origin.Z)
/ Step + 1.0e-4f);
OutCount = static_cast<int64>(OutX1 - OutX0 + 1)
* static_cast<int64>(OutY1 - OutY0 + 1)
* static_cast<int64>(OutZ1 - OutZ0 + 1);
return OutX1 >= OutX0 && OutY1 >= OutY0 && OutZ1 >= OutZ0 && OutCount > 0;
};
auto EvaluateExactLatticeValue = [&](int32 IX, int32 IY, int32 IZ,
bool bUseFinalField) -> float
{
const float Step = static_cast<float>(Context.Step);
const FVector Origin(static_cast<float>(Context.LatticeOriginVoxels.X),
static_cast<float>(Context.LatticeOriginVoxels.Y),
static_cast<float>(Context.LatticeOriginVoxels.Z));
TArray<float>& Values = bUseFinalField ? ExactFinalValues : ExactCoreValues;
TArray<uint8>& Valid = bUseFinalField ? ExactFinalValid : ExactCoreValid;
int32 CacheIndex = INDEX_NONE;
if (bUseExactCache
&& IX >= ExactCacheX0 && IX < ExactCacheX0 + ExactCacheNX
&& IY >= ExactCacheY0 && IY < ExactCacheY0 + ExactCacheNY
&& IZ >= ExactCacheZ0 && IZ < ExactCacheZ0 + ExactCacheNZ)
{
CacheIndex = (IZ - ExactCacheZ0) * ExactCacheNY * ExactCacheNX
+ (IY - ExactCacheY0) * ExactCacheNX
+ (IX - ExactCacheX0);
if (Valid[CacheIndex] != 0)
{
if (Stats)
{
if (bUseFinalField) { ++Stats->ExactFinalCacheHits; }
else { ++Stats->ExactCoreCacheHits; }
}
return Values[CacheIndex];
}
}
const float WorldX = Origin.X + static_cast<float>(IX) * Step;
const float WorldY = Origin.Y + static_cast<float>(IY) * Step;
const float WorldZ = Origin.Z + static_cast<float>(IZ) * Step;
// The operator stack is the cheap proof field. A passage/landing post is applied after
// that stack by GetDensityAt; for a child intersecting one of those modifiers, certify
// the final MC-facing field rather than trusting the conservative core proof.
const float Density = bUseFinalField
? -Generator->GetDensityAt(WorldX, WorldY, WorldZ)
: Stack.EvalInternal(WorldX, WorldY, WorldZ);
if (Stats)
{
if (bUseFinalField) { ++Stats->ExactFinalSamples; }
else { ++Stats->ExactCoreSamples; }
}
if (CacheIndex != INDEX_NONE)
{
Values[CacheIndex] = Density;
Valid[CacheIndex] = 1;
}
return Density;
};
auto CornersHaveOneSign = [&](const FBox& Box, bool bUseFinalField) -> bool
{
int32 IX0 = 0, IY0 = 0, IZ0 = 0, IX1 = 0, IY1 = 0, IZ1 = 0;
int64 Count = 0;
if (!GetLatticeBounds(Box, IX0, IY0, IZ0, IX1, IY1, IZ1, Count))
{
return false;
}
bool bHaveSign = false;
bool bPositive = false;
const int32 Xs[2] = { IX0, IX1 };
const int32 Ys[2] = { IY0, IY1 };
const int32 Zs[2] = { IZ0, IZ1 };
for (const int32 IZ : Zs)
for (const int32 IY : Ys)
for (const int32 IX : Xs)
{
const float Density = EvaluateExactLatticeValue(IX, IY, IZ, bUseFinalField);
if (!FMath::IsFinite(Density) || Density == 0.0f)
{
return false;
}
const bool bThisPositive = Density > 0.0f;
if (!bHaveSign)
{
bHaveSign = true;
bPositive = bThisPositive;
}
else if (bPositive != bThisPositive)
{
return false;
}
}
return bHaveSign;
};
auto ClassifyExactLatticeLeaf = [&](const FBox& Box, bool bUseFinalField) -> EVoxelTileClass
{
const uint64 ExactStartCycles = Stats ? FPlatformTime::Cycles64() : 0;
int32 IX0 = 0, IY0 = 0, IZ0 = 0, IX1 = 0, IY1 = 0, IZ1 = 0;
int64 Count = 0;
if (!GetLatticeBounds(Box, IX0, IY0, IZ0, IX1, IY1, IZ1, Count)
|| Count > 8192)
{
return EVoxelTileClass::Mixed;
}
if (Stats)
{
if (bUseFinalField) { ++Stats->ExactFinalLeaves; }
else { ++Stats->ExactCoreLeaves; }
}
bool bAllSolid = true;
bool bAllAir = true;
int32 PositiveCount = 0;
int32 NegativeCount = 0;
int32 ZeroOrInvalidCount = 0;
for (int32 IZ = IZ0; IZ <= IZ1; ++IZ)
for (int32 IY = IY0; IY <= IY1; ++IY)
for (int32 IX = IX0; IX <= IX1; ++IX)
{
const float Density = EvaluateExactLatticeValue(IX, IY, IZ, bUseFinalField);
if (!FMath::IsFinite(Density) || Density == 0.0f)
{
++ZeroOrInvalidCount;
IZ = IZ1;
IY = IY1;
break;
}
if (Density > 0.0f) { ++PositiveCount; bAllAir = false; }
else { ++NegativeCount; bAllSolid = false; }
if (!bAllSolid && !bAllAir)
{
IZ = IZ1;
IY = IY1;
break;
}
}
const EVoxelTileClass Verdict = ZeroOrInvalidCount > 0 || (PositiveCount > 0 && NegativeCount > 0)
? EVoxelTileClass::Mixed
: (bAllSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir);
if (Stats)
{
const uint64 Elapsed = FPlatformTime::Cycles64() - ExactStartCycles;
if (bUseFinalField) { Stats->ExactFinalCycles += Elapsed; }
else { Stats->ExactCoreCycles += Elapsed; }
}
return Verdict;
};
auto Refine = [&](auto&& Self, const FBox& Box, int32 Depth) -> EVoxelTileClass
{
if (Stats) { ++Stats->RefineNodes; }
const uint64 StackStartCycles = Stats ? FPlatformTime::Cycles64() : 0;
const EVoxelTileClass Whole = Stack.ClassifyBox(Box, Context);
if (Stats)
{
++Stats->StackBoxCalls;
Stats->StackBoxCycles += FPlatformTime::Cycles64() - StackStartCycles;
switch (Whole)
{
case EVoxelTileClass::Mixed: ++Stats->WholeMixedNodes; break;
case EVoxelTileClass::AllSolid: ++Stats->WholeSolidNodes; break;
case EVoxelTileClass::AllAir: ++Stats->WholeAirNodes; break;
default: break;
}
Stats->MaxRefinementDepth = FMath::Max(
Stats->MaxRefinementDepth, static_cast<uint32>(Depth));
}
// The graph stack is not the complete MC field: GetDensityAt reasserts the walkable
// tunnel core after the stack and after the disturbance/landing posts. The source's
// box audit tells us whether that post can have a tunnel candidate in this exact box.
// Keep this test local to room-graph stacks; the diagnostic is thread-local and would be
// stale for a slab/maze stack. A candidate only widens the work, never grants a skip.
if (Depth == 0 && Context.bUseLatticeProof && StructuralManager != nullptr)
{
bRootPassageAirKnownAbsent = !StructuralManager->AnyPassageAirPostNearLattice(
VoxelBox, Context.LatticeOriginVoxels, Context.Step,
StructuralBaseDensity, Context.EdgeSealThickness);
bRootOriginAirKnownAbsent = !StructuralManager->AnyOriginLandingAirNearLattice(
VoxelBox, Context.LatticeOriginVoxels, Context.Step,
StructuralBaseDensity, Context.EdgeSealThickness);
}
const bool bHasRoomGeometry = Stack.ProvidesResource(VoxelOpResources::RoomGeometry);
const VoxelDensityOps::FRoomBoxDiagnostic RoomBox = bHasRoomGeometry
? VoxelDensityOps::GetLastRoomBoxDiagnostic()
: VoxelDensityOps::FRoomBoxDiagnostic();
if (Stats && bHasRoomGeometry)
{
Stats->RoomTailQueries += RoomBox.ExactTailQueries;
Stats->RoomTailEvaluated += RoomBox.ExactTailEvaluated;
Stats->RoomTailCycles += RoomBox.ExactTailCycles;
Stats->RoomPropagateCycles += RoomBox.PropagateCycles;
Stats->RoomExactPrimitiveCycles += RoomBox.ExactPrimitiveCycles;
Stats->RoomCacheWindowCycles += RoomBox.CacheWindowCycles;
Stats->RoomNumRooms = RoomBox.NumRooms;
Stats->RoomNumTunnels = RoomBox.NumTunnels;
Stats->RoomNumRoomFloorJoins = RoomBox.NumRoomFloorJoins;
Stats->RoomNumPits = RoomBox.NumPits;
Stats->RoomNumChimneys = RoomBox.NumChimneys;
}
const bool bHasTunnelAirCandidate = bHasRoomGeometry
&& RoomBox.bMayHaveTunnelCoreAir;
const bool bHasTunnelSupportCandidate = bHasRoomGeometry
&& RoomBox.bMayHaveTunnelSupportFloor;
// The tail has two polarities. A solid-only tail cannot turn a proven solid child into
// geometry, and an air-only tail cannot turn a proven air child into geometry. Keep the
// uncertain Mixed parent conservative by descending; at the resolved children only the
// polarity that can change the answer is allowed to request a final-field certificate.
const bool bHasTunnelCoreCandidate = Whole == EVoxelTileClass::AllSolid
? bHasTunnelAirCandidate
: Whole == EVoxelTileClass::AllAir
? bHasTunnelSupportCandidate
: false;
// FPassageCarveOp and FOriginSpine are already in the stack. Reusing their broad spatial
// candidates here was the LOD0 bug: a passage that the stack had already proved to miss
// the sampled sign still forced a final-field refinement of every mixed parent. Only the
// writers that run after the stack remain candidates in this section: post-disturbance
// air, and support floors that can add solid. Unknown/non-finite input remains a
// candidate in each exact helper, so this separation cannot authorize a false skip.
// A Mixed core is not a verdict yet. Descend until the interval proof resolves its
// polarity; an air post matters to an AllSolid child, while a solid floor/support post
// matters to an AllAir child. Checking both writers on every Mixed ancestor was the
// remaining LOD0 classifier tax and could not improve the answer before the child split.
const bool bNeedsPostAirCandidate = Whole == EVoxelTileClass::AllSolid;
const bool bPassageAirCandidate = bNeedsPostAirCandidate
&& !bRootPassageAirKnownAbsent
&& StructuralManager != nullptr
&& StructuralManager->AnyPassageAirPostNearLattice(
Box, Context.LatticeOriginVoxels, Context.Step,
StructuralBaseDensity, Context.EdgeSealThickness);
const bool bOriginAirCandidate = bNeedsPostAirCandidate
&& !bRootOriginAirKnownAbsent
&& StructuralManager != nullptr
&& StructuralManager->AnyOriginLandingAirNearLattice(
Box, Context.LatticeOriginVoxels, Context.Step,
StructuralBaseDensity, Context.EdgeSealThickness);
// Floors only add solid. They matter when this core box is already AllAir; on a Mixed
// parent, descending to its children preserves the proof while avoiding a padded floor
// AABB forcing a final-field walk over every mixed ancestor. Ask the exact lattice floor
// predicate only at an AllAir child. It is conservative on malformed data and never
// grants a skip.
const bool bHasCarveCandidate = bPassageAirCandidate || bOriginAirCandidate;
const bool bHasStructuralCandidate = Whole == EVoxelTileClass::AllSolid
? bHasCarveCandidate
: Whole == EVoxelTileClass::AllAir
&& (StructuralManager != nullptr
&& StructuralManager->AnyLandingFloorAtLattice(
Box, Context.LatticeOriginVoxels, Context.Step));
// A resolved core box still needs a finite-lattice certificate when a common MC tail can
// rewrite it (passage/landing posts or the graph tunnel-core backstop). Without this,
// the interval proves the stack's "all solid" field and silently skips a core tunnel
// that GetDensityAt opens afterwards. Boxes with no tail candidate retain the cheap
// interval path.
const bool bNeedsFinalField = Generator != nullptr
&& (bHasStructuralCandidate || bHasTunnelCoreCandidate);
if (Stats && bNeedsFinalField)
{
++Stats->NeedsFinalFieldNodes;
}
// A final-field certificate is exact on the same MC lattice as GenerateMesh. Use it for
// the small block-sized tail-risk boxes, including an already-resolved AllSolid/AllAir
// box. The common streamed tile is deliberately not a final-field sampling job: a
// coarse tile can contain thousands of MC vertices, and its proof must stay interval-only.
// Larger tail-risk boxes remain conservative Mixed until the mesher's normal sampling.
bool bAttemptedExact = false;
const float FinalFieldBoxExtent = FMath::Max3(
static_cast<float>(Box.Max.X - Box.Min.X),
static_cast<float>(Box.Max.Y - Box.Min.Y),
static_cast<float>(Box.Max.Z - Box.Min.Z));
const bool bSmallFinalFieldBox = bNeedsFinalField
&& FinalFieldBoxExtent <= 8.0f;
if (bSmallFinalFieldBox)
{
int32 ExactIX0 = 0, ExactIY0 = 0, ExactIZ0 = 0;
int32 ExactIX1 = 0, ExactIY1 = 0, ExactIZ1 = 0;
int64 ExactCount = 0;
if (GetLatticeBounds(Box, ExactIX0, ExactIY0, ExactIZ0,
ExactIX1, ExactIY1, ExactIZ1, ExactCount)
&& ExactCount <= 8192)
{
bAttemptedExact = true;
const EVoxelTileClass Exact = ClassifyExactLatticeLeaf(Box, true);
if (Exact != EVoxelTileClass::Mixed)
{
return Exact;
}
return EVoxelTileClass::Mixed;
}
}
if (Whole != EVoxelTileClass::Mixed && !bNeedsFinalField)
{
return Whole;
}
// At this size the box contains at most 5^3 exact MC vertices. Evaluating those same
// vertices is a small, sound certificate for the lattice field the mesher will consume;
// it is not a lower-resolution fallback and never changes GenerateMesh's sample count.
// This discharges the last conservative SDF interval misses without walking another
// refinement level or rebuilding the morphology bounds for every half-box.
// The corner check is only a work gate. It is not a proof: matching corners still pay the
// complete exact lattice certificate, so a thin cave or a zero/invalid sample cannot be
// skipped accidentally. For a genuinely uniform LOD0 empty box this collapses the old
// 585-node/64k-sample fallback to at most the 33^3 unique lattice vertices.
// A depth-1 box still contains 17^3 MC vertices; probing its corners and then certifying
// the whole child is more expensive than the interval refinement it was meant to avoid.
// Wait until the depth-3 tail (at most 9^3 vertices) before paying an exact core walk.
const bool bCornersHaveOneSign = !bNeedsFinalField && Depth >= 3
&& CornersHaveOneSign(Box, false);
if (bCornersHaveOneSign)
{
bAttemptedExact = true;
const EVoxelTileClass Exact = ClassifyExactLatticeLeaf(Box, false);
if (Exact != EVoxelTileClass::Mixed)
{
return Exact;
}
}
// A two-voxel unresolved tail is still cheap to certify on the final MC lattice (3^3
// points). Doing this before the last split avoids eight more interval folds for the
// localized passage/post boundary that caused the original LOD0 false-Mixed bail.
if (!bAttemptedExact && Whole == EVoxelTileClass::Mixed && !bNeedsFinalField
&& Context.bUseLatticeProof && Depth >= 4 && FinalFieldBoxExtent <= 2.0f)
{
const EVoxelTileClass ExactFinal = ClassifyExactLatticeLeaf(Box, true);
if (ExactFinal != EVoxelTileClass::Mixed)
{
return ExactFinal;
}
bAttemptedExact = true;
}
if (!bAttemptedExact && Depth >= 3 && (!bNeedsFinalField || bSmallFinalFieldBox))
{
const EVoxelTileClass Exact = ClassifyExactLatticeLeaf(Box, bSmallFinalFieldBox);
if (Exact != EVoxelTileClass::Mixed)
{
return Exact;
}
// The final-field leaf is deliberately a conservative sampling certificate. A
// mixed result means this child really contains both final signs (or an invalid
// sample); splitting it again cannot prove the parent uniform and only repeats the
// expensive post-aware evaluator.
if (bNeedsFinalField)
{
return EVoxelTileClass::Mixed;
}
}
if (Depth >= MaxRefinementDepth)
{
// The operator interval can remain Mixed at a one-voxel tail even though the final
// MC-facing field is uniform after a structural post. Certify that tiny tail on the
// exact lattice before conceding the whole tile to the mesher. This is a proof over
// all corners the mesher would consume; zero/non-finite values remain Mixed.
if (Whole == EVoxelTileClass::Mixed && !bNeedsFinalField
&& Context.bUseLatticeProof && FinalFieldBoxExtent <= 1.0f)
{
const EVoxelTileClass ExactFinal = ClassifyExactLatticeLeaf(Box, true);
if (ExactFinal != EVoxelTileClass::Mixed)
{
return ExactFinal;
}
}
return bNeedsFinalField ? EVoxelTileClass::Mixed : Whole;
}
const FVector Mid = (Box.Min + Box.Max) * 0.5f;
if (Stats) { ++Stats->SplitNodes; }
bool bSawSolid = false;
bool bSawAir = false;
for (int32 Z = 0; Z < 2; ++Z)
for (int32 Y = 0; Y < 2; ++Y)
for (int32 X = 0; X < 2; ++X)
{
const FVector ChildMin(
X == 0 ? Box.Min.X : Mid.X,
Y == 0 ? Box.Min.Y : Mid.Y,
Z == 0 ? Box.Min.Z : Mid.Z);
const FVector ChildMax(
X == 0 ? Mid.X : Box.Max.X,
Y == 0 ? Mid.Y : Box.Max.Y,
Z == 0 ? Mid.Z : Box.Max.Z);
const EVoxelTileClass Child = Self(Self, FBox(ChildMin, ChildMax), Depth + 1);
if (Child == EVoxelTileClass::Mixed)
{
return EVoxelTileClass::Mixed;
}
bSawSolid |= Child == EVoxelTileClass::AllSolid;
bSawAir |= Child == EVoxelTileClass::AllAir;
if (bSawSolid && bSawAir)
{
return EVoxelTileClass::Mixed;
}
}
return bSawSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir;
};
const EVoxelTileClass Result = Refine(Refine, VoxelBox, 0);
return Result;
}
// The ordinary proof is deliberately cheap. Only a box that remains Mixed after its bounded
// spatial refinement pays for the finite-domain warp interval; this keeps the common empty-tile
// path at its existing cost while giving the few conservative misses a second sound chance.
static EVoxelTileClass VF_ClassifyBoxWithWarpRetry(const FVoxelOpStack& Stack,
const FBox& VoxelBox,
const FVoxelOpContext& Context,
const UVoxelGenerator* Generator = nullptr,
const UVoxelStrateManager* StructuralManager = nullptr,
float StructuralBaseDensity = 8.0f,
FVoxelTileClassificationStats* Stats = nullptr)
{
const EVoxelTileClass Initial = VF_ClassifyBoxRefined(
Stack, VoxelBox, Context, Generator, StructuralManager, StructuralBaseDensity, Stats);
// A Mixed fine tile is not evidence of geometry: it can be the conservative result of a
// coarse operator interval. Keep the bounded retry for every LOD; skipping it recreates
// the false-Mixed empty tiles that send the mesher through the expensive path.
if (Initial != EVoxelTileClass::Mixed
|| Context.bTightenWarpProof)
{
return Initial;
}
FVoxelOpContext TightContext = Context;
TightContext.bTightenWarpProof = true;
return VF_ClassifyBoxRefined(
Stack, VoxelBox, TightContext, Generator, StructuralManager, StructuralBaseDensity, Stats);
}
EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels,
int32 Step, int32 CellsPerAxis) const
{
return ClassifyTile(OriginVoxels, Step, CellsPerAxis, nullptr);
}
EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, int32 Step,
int32 CellsPerAxis,
FVoxelTileClassificationStats* OutStats) const
{
if (OutStats) { *OutStats = FVoxelTileClassificationStats(); }
// Mêmes clamps que GenerateMesh — le verdict doit couvrir le treillis réellement échantillonné.
Step = FMath::Max(1, Step);
// ClassifyTile runs before GenerateMesh installs its LOD TLS. The room-graph source uses this
// value only to choose a sound, window-invariant cache region; leaving the previous worker
// value (normally 1) makes an LOD4 proof rebuild fine per-chunk graph windows across the
// coarse tile. Scope it exactly like the mesher does so standalone classifier calls are also
// keyed by their actual lattice step.
TGuardValue<int32> ClassifySampleStepGuard(VoxelGenLOD::SampleStep, Step);
const int32 CPA = FMath::Clamp(CellsPerAxis, 2, CHUNK_SIZE);
const int32 GridDim = CPA + 1; // cell-vertex count: g ∈ [0, CPA]
// The mesher's outer one-sample halo is used only for central-difference normals. It cannot
// create a marching-cubes cell: every case index reads vertices g ∈ [0, CPA]. Classify those
// actual cell vertices, not the halo. Including g=-1/CPA+1 made a coarse tile look Mixed when
// the halo crossed a surface outside the tile while every cell in the tile was uniform.
const int32 MinX = OriginVoxels.X, MaxX = OriginVoxels.X + CPA * Step;
const int32 MinY = OriginVoxels.Y, MaxY = OriginVoxels.Y + CPA * Step;
const int32 MinZ = OriginVoxels.Z, MaxZ = OriginVoxels.Z + CPA * Step;
// Division entière PLANCHER (les coords négatives tronquent vers 0 en C++ — pas floor).
auto FloorDivC = [](int32 A, int32 B) -> int32
{
const int32 Q = A / B, R = A % B;
return (R != 0 && ((R < 0) != (B < 0))) ? Q - 1 : Q;
};
// ── Garde diff layer : un mod joueur peut creuser OU remplir n'importe où. ──
if (DiffLayer && DiffLayer->HasAnyMods())
{
const FIntVector MinChunk(FloorDivC(MinX, CHUNK_SIZE), FloorDivC(MinY, CHUNK_SIZE), FloorDivC(MinZ, CHUNK_SIZE));
const FIntVector MaxChunk(FloorDivC(MaxX, CHUNK_SIZE), FloorDivC(MaxY, CHUNK_SIZE), FloorDivC(MaxZ, CHUNK_SIZE));
if (DiffLayer->HasAnyModInChunkRange(MinChunk, MaxChunk)) return EVoxelTileClass::Mixed;
}
// The bounded shell is global: it also covers gaps and out-of-layout air, so prove it before
// consulting the strate layout. The final MC edge pass uses a positive base, hence 1.0 is a
// deliberately conservative proof input here; the stack op performs the same proof with the
// archetype base for its own sub-box.
const FBox TileVoxelBox(FVector((float)MinX, (float)MinY, (float)MinZ),
FVector((float)MaxX, (float)MaxY, (float)MaxZ));
if (VF_XYEdgeSealBoxIsForcedSolid(TileVoxelBox, WorldRadiusVoxels,
EdgeSealThickness, 1.0f))
{
return EVoxelTileClass::AllSolid;
}
if (!StrateManager) return EVoxelTileClass::Mixed;
// A selected recipe must be classified by the exact same materialised stack that supplies
// density. Search every touched chunk-Z first: the minimum corner may be in a gap or adjacent
// slot, and letting that fall through to the native factory would prove the wrong field.
int32 RecipeSeed = 0;
ECaveGeneratorType RecipeArchetype = ECaveGeneratorType::TunnelNetwork;
FVoxelStrateArchetypeParams RecipeParams;
FVoxelOpStackRecipe Recipe;
bool bTouchesRecipe = false;
const int32 RecipeMinChunkZ = FloorDivC(MinZ, CHUNK_SIZE);
const int32 RecipeMaxChunkZ = FloorDivC(MaxZ, CHUNK_SIZE);
for (int32 CZ = RecipeMinChunkZ; CZ <= RecipeMaxChunkZ; ++CZ)
{
if (StrateManager->GetRecipeForChunk(
FIntVector(0, 0, CZ), RecipeSeed, RecipeArchetype,
RecipeParams, Recipe))
{
bTouchesRecipe = true;
break;
}
}
if (bTouchesRecipe)
{
int32 RecipeTopChunkZ = 0;
int32 RecipeBottomChunkZ = 0;
if (!StrateManager->GetStrateChunkZBounds(
RecipeMinChunkZ, RecipeTopChunkZ, RecipeBottomChunkZ)
|| RecipeMinChunkZ < RecipeBottomChunkZ
|| RecipeMaxChunkZ > RecipeTopChunkZ)
{
return EVoxelTileClass::Mixed;
}
// Recipes and disturbances are selected from the strate slot's Z only. The manager
// copies the recipe arrays into the output parameters, so repeating this check for every
// XY key in the sampled box turns one coarse tile into thousands of identical deep copies.
// Keep one representative per distinct Z: this is equivalent for the APIs above and still
// rejects a tile when any touched Z changes recipe, bounds, or disturbance state.
for (int32 CZ = RecipeMinChunkZ; CZ <= RecipeMaxChunkZ; ++CZ)
{
int32 TouchedTop = 0;
int32 TouchedBottom = 0;
if (!StrateManager->GetStrateChunkZBounds(CZ, TouchedTop, TouchedBottom)
|| TouchedTop != RecipeTopChunkZ || TouchedBottom != RecipeBottomChunkZ)
{
return EVoxelTileClass::Mixed;
}
int32 TouchedSeed = 0;
ECaveGeneratorType TouchedArchetype = ECaveGeneratorType::TunnelNetwork;
FVoxelStrateArchetypeParams TouchedParams;
FVoxelOpStackRecipe TouchedRecipe;
const FIntVector TouchedChunk(0, 0, CZ);
if (!StrateManager->GetRecipeForChunk(
TouchedChunk, TouchedSeed, TouchedArchetype,
TouchedParams, TouchedRecipe)
|| TouchedSeed != RecipeSeed
|| TouchedArchetype != RecipeArchetype)
{
return EVoxelTileClass::Mixed;
}
const FStrateDisturbanceParams Disturbances =
StrateManager->GetDisturbanceParamsForChunk(TouchedChunk);
if (Disturbances.ChasmDensity > 0.0f
|| Disturbances.BridgeDensity > 0.0f
|| Disturbances.RidgeDensity > 0.0f)
{
return EVoxelTileClass::Mixed;
}
}
FVoxelOpStack RecipeStack;
FVoxelOpContext RecipeContext;
if (!VF_BuildStackFromRecipe(
Recipe, RecipeParams, RecipeSeed, OriginSpineRadius,
StrateManager, RecipeStack, RecipeContext, nullptr))
{
return EVoxelTileClass::Mixed;
}
RecipeContext.ChunkCoord = FIntVector(
FloorDivC(MinX, CHUNK_SIZE), FloorDivC(MinY, CHUNK_SIZE), RecipeMinChunkZ);
RecipeContext.Step = Step;
RecipeContext.LayoutVersion = StrateManager->GetLayoutVersion();
RecipeContext.WorldRadiusVoxels = WorldRadiusVoxels;
RecipeContext.EdgeSealThickness = EdgeSealThickness;
RecipeContext.bUseLatticeProof = true;
RecipeContext.LatticeOriginVoxels = OriginVoxels;
RecipeStack.PrepareChunk(RecipeContext);
const EVoxelTileClass RecipeVerdict =
VF_ClassifyBoxWithWarpRetry(RecipeStack, TileVoxelBox, RecipeContext,
nullptr, nullptr, 8.0f, OutStats);
if (RecipeVerdict == EVoxelTileClass::Mixed)
{
}
return RecipeVerdict;
}
#if WITH_EDITOR
// A multi-region composer override is already a complete parent stack: it owns the lateral
// blend and the four global structural posts. Classify it as one field only when the complete
// mesher box stays inside that one strate; otherwise the Z-category pass below deliberately
// returns Mixed. Disturbances and the diff layer remain outside the stack, so either one also
// vetoes a skip.
FVoxelStrateRegionManifest ComposerRegions;
const FIntVector RegionProbeChunk(
FloorDivC(MinX, CHUNK_SIZE), FloorDivC(MinY, CHUNK_SIZE), FloorDivC(MinZ, CHUNK_SIZE));
if (StrateManager->GetComposerRegionOverrideForChunk(RegionProbeChunk, ComposerRegions))
{
int32 RegionTopChunkZ = 0;
int32 RegionBottomChunkZ = 0;
const int32 RegionMinChunkZ = FloorDivC(MinZ, CHUNK_SIZE);
const int32 RegionMaxChunkZ = FloorDivC(MaxZ, CHUNK_SIZE);
if (!StrateManager->GetStrateChunkZBounds(RegionProbeChunk.Z,
RegionTopChunkZ, RegionBottomChunkZ)
|| RegionMinChunkZ < RegionBottomChunkZ
|| RegionMaxChunkZ > RegionTopChunkZ)
{
return EVoxelTileClass::Mixed;
}
// A representative chunk is not enough for a proof: the mesher box can cross an XY
// chunk edge, and disturbances are chunk-local. Require every touched chunk to remain
// in the same slot and to have no active disturbance before delegating the proof to the
// complete lateral stack. Any uncertainty is Mixed; a false uniform answer here can
// skip a real seam or carve.
const int32 RegionMinChunkX = FloorDivC(MinX, CHUNK_SIZE);
const int32 RegionMaxChunkX = FloorDivC(MaxX, CHUNK_SIZE);
const int32 RegionMinChunkY = FloorDivC(MinY, CHUNK_SIZE);
const int32 RegionMaxChunkY = FloorDivC(MaxY, CHUNK_SIZE);
for (int32 CZ = RegionMinChunkZ; CZ <= RegionMaxChunkZ; ++CZ)
{
int32 TouchedTopChunkZ = 0;
int32 TouchedBottomChunkZ = 0;
if (!StrateManager->GetStrateChunkZBounds(
CZ, TouchedTopChunkZ, TouchedBottomChunkZ)
|| TouchedTopChunkZ != RegionTopChunkZ
|| TouchedBottomChunkZ != RegionBottomChunkZ)
{
return EVoxelTileClass::Mixed;
}
for (int32 CY = RegionMinChunkY; CY <= RegionMaxChunkY; ++CY)
{
for (int32 CX = RegionMinChunkX; CX <= RegionMaxChunkX; ++CX)
{
const FIntVector TouchedChunk(CX, CY, CZ);
FVoxelStrateRegionManifest TouchedRegions;
if (!StrateManager->GetComposerRegionOverrideForChunk(
TouchedChunk, TouchedRegions)
|| TouchedRegions.StrateIndex != ComposerRegions.StrateIndex
|| TouchedRegions.Seed != ComposerRegions.Seed
|| TouchedRegions.RegionCount != ComposerRegions.RegionCount
|| TouchedRegions.PartitionSeed != ComposerRegions.PartitionSeed
|| TouchedRegions.LatticeCellSize != ComposerRegions.LatticeCellSize
|| TouchedRegions.BlendWidth != ComposerRegions.BlendWidth)
{
return EVoxelTileClass::Mixed;
}
const FStrateDisturbanceParams Disturbances =
StrateManager->GetDisturbanceParamsForChunk(TouchedChunk);
if (Disturbances.ChasmDensity > 0.0f
|| Disturbances.BridgeDensity > 0.0f
|| Disturbances.RidgeDensity > 0.0f)
{
return EVoxelTileClass::Mixed;
}
}
}
}
FVoxelOpStack RegionStack;
FVoxelOpContext RegionContext;
if (!VF_BuildStrateRegionStack(ComposerRegions, OriginSpineRadius,
StrateManager, RegionStack, RegionContext, nullptr))
{
return EVoxelTileClass::Mixed;
}
RegionContext.ChunkCoord = RegionProbeChunk;
RegionContext.Step = Step;
RegionContext.LayoutVersion = StrateManager->GetLayoutVersion();
RegionContext.WorldRadiusVoxels = WorldRadiusVoxels;
RegionContext.EdgeSealThickness = EdgeSealThickness;
RegionContext.bUseLatticeProof = true;
RegionContext.LatticeOriginVoxels = OriginVoxels;
RegionStack.PrepareChunk(RegionContext);
const EVoxelTileClass RegionVerdict =
VF_ClassifyBoxWithWarpRetry(RegionStack, TileVoxelBox, RegionContext,
nullptr, nullptr, 8.0f, OutStats);
if (RegionVerdict == EVoxelTileClass::Mixed)
{
}
return RegionVerdict;
}
#endif
bool bCanSolid = true; // "tout le treillis est solide" encore prouvable
bool bCanAir = true; // "tout le treillis est air" encore prouvable
// ── Catégorisation par Z du treillis : gap bedrock = solide ; hors layout = air constant ;
// SurfaceWorld = test colonne ; un slot cave opt-in = verdict de pile sur sa sous-boîte. ──
struct FSurfSlot
{
int32 BotChunkZ = INT32_MAX; // identité du slot (borne basse de la strate, en chunks)
int32 RepChunkZ = 0; // chunk Z représentatif pour la résolution des params
int32 StrateKey = MIN_int32; // même clé que GetDensityAt (StrateBottomWorldZ arrondi)
float OverhangMargin = 0.0f; // F20 : hauteur max de corniche (= max OverhangHeight strate+biomes)
FSurfaceGenerationParams Params;
FBiomeContext BiomeCtx;
TArray<FSurfaceGenerationParams> BiomeParams;
TArray<float> InteriorZ; // z hors bandes de seal → testés par colonne
};
// thread_local : les TArray gardent leur capacité d'un appel à l'autre (zéro malloc/tuile).
static thread_local FSurfSlot Slots[2];
static thread_local FChunkBiomeCache TC_BiomeCache; // biome grid du classifieur (valeurs ≡ CP_BiomeCache)
// AUDIT C2 — même discipline que le chemin densité : la grille de biome du classifieur survit
// d'un appel à l'autre et sa boîte de validité ne dit rien du contexte qui l'a produite.
static thread_local uint32 TC_SeenVersion = 0xFFFFFFFFu;
const uint32 TC_LayoutVersion = StrateManager->GetLayoutVersion();
if (TC_LayoutVersion != TC_SeenVersion)
{
TC_SeenVersion = TC_LayoutVersion;
TC_BiomeCache.Invalidate();
}
int32 NumSlots = 0;
// ── T1.d GÉNÉRIQUE : la pile d'opérateurs classe les archétypes de CAVE ──
// Ces valeurs suivent l'UNIQUE slot de cave que la tuile touche. La pile classera seulement la
// sous-boîte Z de ses échantillons ; les catégories gap/surface/hors-layout plient séparément
// leurs hypothèses dans `bCanSolid` / `bCanAir`.
// These values track the ONE cave slot touched by the tile. The stack classifies only its
// sampled Z sub-box; gap/surface/out-of-layout fold their hypotheses separately.
int32 CaveBotChunkZ = INT32_MAX; // identité du slot de cave (borne basse, en chunks)
int32 CaveRepChunkZ = 0;
int32 CaveMinZ = MAX_int32;
int32 CaveMaxZ = MIN_int32;
bool bAnyCave = false;
bool bAnyGap = false;
bool bAnySurface = false;
bool bAnyOutOfLayout = false;
int32 MemoChunkZ = INT32_MAX;
int32 MemoCat = -1; // 0 = gap, 1 = surface, 2 = cave (pile), 3 = hors layout
int32 MemoSlotIdx = -1;
// « Ce chunk Z appartient-il à une strate ? » sous forme publique : `FindSlotIndexForChunkZ`
// est `protected`, `GetStrateChunkZBounds` rend false pour exactement le même cas.
auto VF_ChunkZHasSlot = [&](int32 Z) -> bool
{
int32 UnusedTopCZ = 0, UnusedBotCZ = 0;
return StrateManager->GetStrateChunkZBounds(Z, UnusedTopCZ, UnusedBotCZ);
};
for (int32 g = 0; g <= CPA; ++g)
{
const int32 Zi = OriginVoxels.Z + g * Step;
const int32 ChunkZ = FloorDivC(Zi, CHUNK_SIZE);
if (ChunkZ != MemoChunkZ)
{
MemoChunkZ = ChunkZ;
const FIntVector CC(0, 0, ChunkZ); // les requêtes de layout ne dépendent que de Z
if (StrateManager->IsGapChunk(CC))
{
MemoCat = 0;
bAnyGap = true;
}
//=================================================================
// ⛔ HORS LAYOUT = AIR CONSTANT. C'ÉTAIT LE BLOCAGE DE T1.d.
//=================================================================
// `GetGeneratorTypeForChunk` rend `TunnelNetwork` pour tout chunk hors de la pile de
// strates (« le chemin de repli produit de la roche de toute façon » — CE COMMENTAIRE
// EST FAUX) et `IsGapChunk` rend false au-dessus du sommet (« open air, NOT a gap »).
// Résultat : chaque tuile touchant l'air libre au-dessus du monde entrait dans la
// BRANCHE DE CAVE, n'y trouvait aucun slot, et abandonnait — mesuré en jeu à 83 % des
// tuiles classées (`Cave Bail Not Op Stack No Layout` = 1.58 / 1.90).
//
// La vérité est dans `GetGenerationParams` : hors layout il rend `BaseDensity = -1`,
// `RoomDensity = 0`, `WormStrength = 0` — un champ CONSTANT, donc de l'air, sans salle
// ni ver pour le percer. Une telle tuile est prouvable sans échantillonner.
//
// Out-of-layout is a CONSTANT AIR field, not a cave archetype. Every tile touching the
// open air above the world was being routed into the cave branch and bailing there.
// `GetStrateChunkZBounds` (PUBLIC) rend false exactement quand `FindSlotIndexForChunkZ`
// rend -1 — ce dernier est `protected`, et cette fonction l'utilise déjà deux fois pour
// la même question. Pas de nouvelle surface d'API pour un prédicat qui existe.
// GetStrateChunkZBounds is the public form of "has a layout slot"; the index accessor
// is protected and this function already uses the bounds call twice for the same test.
else if (!VF_ChunkZHasSlot(ChunkZ))
{
MemoCat = 3;
bAnyOutOfLayout = true;
// Les disturbances sont appliquées APRÈS la densité d'archétype et peuvent AJOUTER
// de la roche (ponts, arêtes). Même prudence que les branches gap et cave : si
// l'une peut agir ici, on ne prouve rien. Les chasms ne font que creuser ⇒ ils ne
// menacent pas un verdict d'air.
const FStrateDisturbanceParams DOut = StrateManager->GetDisturbanceParamsForChunk(CC);
if (DOut.BridgeDensity > 0.0f || DOut.RidgeDensity > 0.0f)
{
return EVoxelTileClass::Mixed;
}
}
else if (StrateManager->GetGeneratorTypeForChunk(CC) == ECaveGeneratorType::SurfaceWorld)
{
// Retrouve (ou résout) le slot de strate — l'identité vient des bornes chunk-Z du layout.
int32 TopCZ = 0, BotCZ = 0;
if (!StrateManager->GetStrateChunkZBounds(ChunkZ, TopCZ, BotCZ))
{
return EVoxelTileClass::Mixed;
}
MemoSlotIdx = -1;
for (int32 s = 0; s < NumSlots; ++s)
{
if (Slots[s].BotChunkZ == BotCZ) { MemoSlotIdx = s; break; }
}
if (MemoSlotIdx < 0)
{
if (NumSlots >= 2)
{
return EVoxelTileClass::Mixed; // >2 strates surface dans une tuile : improbable
}
MemoSlotIdx = NumSlots++;
FSurfSlot& S = Slots[MemoSlotIdx];
S.BotChunkZ = BotCZ;
S.RepChunkZ = ChunkZ;
S.InteriorZ.Reset();
ResolveSurfaceChunkParams(CC, S.Params, S.BiomeCtx, S.BiomeParams);
S.StrateKey = FMath::RoundToInt(S.Params.StrateBottomWorldZ);
// F20 : marge d'overhang = hauteur max (OverhangHeight) sur laquelle une corniche
// peut AJOUTER de la roche AU-DESSUS du sol. La corniche est plafonnée à
// TerrainZ+Height ⇒ au-delà, air prouvable ; dans (TerrainZ, TerrainZ+Height], ni air
// ni solide prouvable → colonne forcée en Mixed. (L'union n'enlève jamais de roche ⇒
// sous le terrain reste solide prouvable : marge vers le HAUT uniquement.)
S.OverhangMargin = (S.Params.OverhangStrength > 0.0f) ? S.Params.OverhangHeight : 0.0f;
for (const FSurfaceGenerationParams& BP : S.BiomeParams)
{
if (BP.OverhangStrength > 0.0f)
S.OverhangMargin = FMath::Max(S.OverhangMargin, BP.OverhangHeight);
}
// Disturbances de cette strate : les chasms CREUSENT (cassent AllSolid), les
// ponts/arêtes AJOUTENT de la roche dans l'intérieur (cassent AllAir).
const FStrateDisturbanceParams D = StrateManager->GetDisturbanceParamsForChunk(CC);
if (D.ChasmDensity > 0.0f) bCanSolid = false;
if (D.BridgeDensity > 0.0f || D.RidgeDensity > 0.0f) bCanAir = false;
}
MemoCat = 1;
bAnySurface = true;
}
else
{
// ── ARCHÉTYPE DE CAVE ── Jusqu'ici : `return Mixed`, sans appel. Désormais on tente
// le pliage générique de la pile — mais SEULEMENT sous des conditions vérifiables,
// parce qu'un faux verdict ici est un trou (pas de géométrie, pas de collision).
//
// Condition 1 : la strate doit RÉELLEMENT être générée par la pile. Sinon on
// classerait un champ que le mesher ne produira pas. C'est le même drapeau, lu au
// même endroit, que `GetDensityAt`.
if (!StrateManager->UsesOperatorStackForChunk(CC))
{
// Attribution DIAGNOSTIQUE uniquement : l'ancien compteur mélangeait une
// strate cave entièrement désactivée avec une tuile de frontière qui avait
// rencontré un slot désactivé avant la garde « slot différent » ci-dessous.
// On résout les bornes APRÈS l'échec du même prédicat ; elles ne changent ni
// la condition, ni le point de retour, ni le verdict.
// Diagnostic attribution only: the old counter mixed a wholly disabled cave
// slot with a boundary tile that met a disabled slot before the different-slot
// guard below. Resolve bounds only after the same predicate fails; classification
// control flow and return value stay unchanged.
int32 FailedTopCZ = 0, FailedBotCZ = 0;
if (!StrateManager->GetStrateChunkZBounds(ChunkZ, FailedTopCZ, FailedBotCZ))
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailNotOpStackNoLayout);
}
else
{
const int32 TileMinCZ = FloorDivC(MinZ, CHUNK_SIZE);
const int32 TileMaxCZ = FloorDivC(MaxZ, CHUNK_SIZE);
if (TileMinCZ >= FailedBotCZ && TileMaxCZ <= FailedTopCZ)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailNotOpStackSoleSlot);
}
else
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailNotOpStackBoundaryTile);
}
}
return EVoxelTileClass::Mixed;
}
// Condition 2 : un seul slot de cave par tuile. Deux slots = deux jeux de params =
// deux piles, et une pile ne sait répondre que pour SA strate.
int32 CaveTopCZ = 0, CaveBotCZ = 0;
if (!StrateManager->GetStrateChunkZBounds(ChunkZ, CaveTopCZ, CaveBotCZ))
{
INC_DWORD_STAT(STAT_VoxelForgeCaveMixOutOfLayout);
return EVoxelTileClass::Mixed; // hors layout
}
if (CaveBotChunkZ != INT32_MAX && CaveBotChunkZ != CaveBotCZ)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailTwoCaveSlots);
return EVoxelTileClass::Mixed;
}
CaveBotChunkZ = CaveBotCZ;
CaveRepChunkZ = ChunkZ;
bAnyCave = true;
MemoCat = 2;
}
}
if (MemoCat == 2)
{
// La pile répond après la boucle, sur la sous-boîte Z contenant exactement les
// échantillons cave (XY reste la boîte complète du treillis).
CaveMinZ = FMath::Min(CaveMinZ, Zi);
CaveMaxZ = FMath::Max(CaveMaxZ, Zi);
}
else if (MemoCat == 0)
{
bCanAir = false; // bedrock du gap = solide (le carve des passages est déjà gardé)
}
else if (MemoCat == 3)
{
// Hors layout = air constant (BaseDensity = -1, aucune salle, aucun ver). L'hypothèse
// « tout solide » meurt ; « tout air » survit. Les passages et la spine ne font que
// creuser — ils sont déjà gardés plus haut et ne peuvent pas rendre ce z solide.
// Out of layout = constant air: AllSolid dies, AllAir survives.
bCanSolid = false;
}
else
{
FSurfSlot& S = Slots[MemoSlotIdx];
// Bande de seal ? Mêmes inégalités qu'ApplyBoundarySeal : à l'intérieur, la densité est
// Max(…, SealFactor·BaseDensity) avec SealFactor > 0 ⇒ solide garanti si BaseDensity > 0.
const float Z = (float)Zi;
const float DistTop = S.Params.StrateTopWorldZ - Z;
const float DistBot = Z - S.Params.StrateBottomWorldZ;
const float Th = S.Params.BoundarySealThickness;
const bool bInBand = Th > 0.0f && S.Params.BaseDensity > 0.0f
&& ((DistTop >= 0.0f && DistTop < Th) || (DistBot >= 0.0f && DistBot < Th));
if (bInBand) { bCanAir = false; }
else { S.InteriorZ.Add(Z); }
}
if (!bCanSolid && !bCanAir)
{
return EVoxelTileClass::Mixed;
}
}
// Out-of-layout is a constant-air field. Passage tubes and origin rooms can only carve that
// field, so they cannot invalidate AllAir; only their guaranteed support floors can add solid
// voxels. Do this after the cheap Z categorisation so open-air LOD tiles avoid the exact
// passage-carve lattice walk and the room scan entirely. A floor-only tile remains Mixed and
// is meshed normally, preserving the support backstops.
if (bAnyOutOfLayout && !bAnyCave && !bAnySurface && !bAnyGap)
{
if (StrateManager->AnyPassageLandingFloorNearLattice(
TileVoxelBox, OriginVoxels, Step)
|| StrateManager->AnyOriginLandingFloorNearLattice(
TileVoxelBox, OriginVoxels, Step))
{
return EVoxelTileClass::Mixed;
}
return EVoxelTileClass::AllAir;
}
// ── Structural passage/origin guards for non-open-air categories. ──
// Carvers kill AllSolid but never add rock; landing and origin support floors kill AllAir.
if (StrateManager->AnyPassageNearLattice(
TileVoxelBox, OriginVoxels, Step))
{
bCanSolid = false;
}
if (StrateManager->AnyPassageLandingFloorNearLattice(
TileVoxelBox, OriginVoxels, Step))
{
bCanAir = false;
}
if (StrateManager->AnyOriginLandingNearLattice(
TileVoxelBox, OriginVoxels, Step))
{
bCanSolid = false;
}
if (StrateManager->AnyOriginLandingFloorNearLattice(
TileVoxelBox, OriginVoxels, Step))
{
bCanAir = false;
}
// ── LE PLIAGE DE LA PILE D'OPÉRATEURS, POUR LES ARCHÉTYPES DE CAVE ──
//=========================================================================
// ⚠️ ERREUR ICI = TROU, PAS RÉGRESSION. Un verdict non-Mixed fait SAUTER `GenerateMesh` : pas
// de triangles, pas de collision, invisible jusqu'à ce qu'un joueur tombe au travers. Toutes les
// gardes ci-dessous ÉCHOUENT EN MIXED ; aucune ne donne le bénéfice du doute.
//
// Le verdict lui-même ne peut pas être meilleur que la pile : `FVoxelOpStack::ClassifyBox` plie
// chaque opérateur avec `VF_FoldOp` et rend `Mixed` dès que les deux hypothèses meurent. Ce que
// ce bloc ajoute, c'est la vérification que la pile interrogée est bien CELLE QUI PRODUIRA LA
// DENSITÉ de cette tuile — même fabrique, mêmes params, même drapeau.
if (bAnyCave)
{
// Diagnostic de PRÉSENCE avant les gardes : le signal reste visible même si le pliage rend
// finalement AllSolid/AllAir. Ces compteurs ne sont pas exclusifs entre eux sur une tuile
// très haute ; chacun répond exactement à « cette catégorie était-elle aussi présente ? ».
// Presence diagnostics run before the guards, so a successful fold cannot hide the mix.
// They are not mutually exclusive for a very tall tile; each answers one exact question.
if (bAnyOutOfLayout || bAnyGap || bAnySurface)
{
if (bAnyOutOfLayout) { INC_DWORD_STAT(STAT_VoxelForgeCaveMixOutOfLayout); }
if (bAnyGap) { INC_DWORD_STAT(STAT_VoxelForgeCaveMixGap); }
if (bAnySurface) { INC_DWORD_STAT(STAT_VoxelForgeCaveMixSurfaceWorld); }
}
const FIntVector RepCC(0, 0, CaveRepChunkZ);
const ECaveGeneratorType CaveType = StrateManager->GetGeneratorTypeForChunk(RepCC);
//---------------------------------------------------------------------
// ⚠️ LA GARDE QUI COMPTE : LES PARAMS DOIVENT ÊTRE LES MÊMES SUR TOUTE LA SOUS-BOÎTE CAVE
//---------------------------------------------------------------------
// `GetGenerationParams` et ses homologues BLENDENT les params dans les bandes de transition :
// `Alpha` dépend du chunk Z pour `Gradient`, et du chunk XY EN PLUS pour `Interleaved`. Deux
// chunks d'une même sous-boîte peuvent donc porter des params différents — c'est le constat de
// `AUDIT §C2`, confirmé par lecture le 2026-07-28 — et UNE pile ne peut pas représenter DEUX
// champs. On construit donc les params pour CHAQUE coordonnée de chunk que la boîte touche et
// on exige qu'ils soient identiques bit à bit.
//
// `Memcmp` sur un POD : un padding différent ne peut produire qu'un FAUX ÉCART, donc un
// `Mixed` de trop. On se trompe du côté du CPU, jamais du côté du trou.
const int32 CX0 = FloorDivC(MinX, CHUNK_SIZE), CX1 = FloorDivC(MaxX, CHUNK_SIZE);
const int32 CY0 = FloorDivC(MinY, CHUNK_SIZE), CY1 = FloorDivC(MaxY, CHUNK_SIZE);
// IMPORTANT : les gardes restent complètes, mais seulement sur les chunks où le mesher
// échantillonne réellement CETTE strate cave. Inclure gap/surface/hors-layout ici ferait
// échouer la garde d'archétype avant de pouvoir plier leurs hypothèses indépendantes.
// The guards stay exhaustive over the cave samples. Non-cave chunks are intentionally not
// represented by this stack; their hypotheses were folded separately in the Z pass.
const int32 CZ0 = FloorDivC(CaveMinZ, CHUNK_SIZE), CZ1 = FloorDivC(CaveMaxZ, CHUNK_SIZE);
// Keep the parameter identity check bounded, but do not use the old 27-key cutoff here:
// LOD4 legitimately covers 19^3 = 6,859 chunk keys. Returning Mixed at that point sent
// the tile straight to GenerateMesh, where GetDensityAt rebuilt one exact-chunk state for
// almost every coarse sample. The operator proof now shares its root morphology cache
// across refined children, so this scan is cheap compared with the fallback it prevents.
const int64 NumChunkCoords = (int64)(CX1 - CX0 + 1) * (int64)(CY1 - CY0 + 1) * (int64)(CZ1 - CZ0 + 1);
constexpr int64 MaxClassifiedChunkCoords = 262144;
if (NumChunkCoords > MaxClassifiedChunkCoords)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
return EVoxelTileClass::Mixed;
}
FSlabGenerationParams TileSlab;
FMazeGenerationParams TileMaze;
FVerticalShaftParams TileVert;
FFloatingIslandParams TileFloat;
FStrateGenerationParams TileTunnel;
bool bFirst = true;
for (int32 cz = CZ0; cz <= CZ1; ++cz)
for (int32 cy = CY0; cy <= CY1; ++cy)
for (int32 cx = CX0; cx <= CX1; ++cx)
{
const FIntVector CC(cx, cy, cz);
if (StrateManager->GetGeneratorTypeForChunk(CC) != CaveType)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
return EVoxelTileClass::Mixed; // la boîte déborde sur un autre archétype
}
// Le drapeau doit tenir sur TOUS les chunks de la boîte, pas seulement sur celui qui a
// déclenché la tentative : un seul chunk hors pile invaliderait le verdict.
if (!StrateManager->UsesOperatorStackForChunk(CC))
{
// Le passage Z précédent a déjà accepté l'unique slot cave. Avec le layout actuel
// (prédicat indépendant de X/Y), ce recheck est redondant ; un hit nomme donc
// précisément cette garde tardive au lieu d'être agrégé aux opt-ins désactivés.
// The prior Z pass already accepted the sole cave slot. With the current X/Y-
// independent predicate this recheck is redundant, so attribute it separately.
INC_DWORD_STAT(STAT_VoxelForgeCaveBailNotOpStackRecheck);
return EVoxelTileClass::Mixed;
}
switch (CaveType)
{
case ECaveGeneratorType::FlatPlain:
case ECaveGeneratorType::CrystalChamber:
{
const FSlabGenerationParams Q = StrateManager->GetSlabParamsForChunk(CC);
if (bFirst) { TileSlab = Q; }
else if (FMemory::Memcmp(&Q, &TileSlab, sizeof(Q)) != 0)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
return EVoxelTileClass::Mixed;
}
break;
}
case ECaveGeneratorType::Maze:
{
const FMazeGenerationParams Q = StrateManager->GetMazeParamsForChunk(CC);
if (bFirst) { TileMaze = Q; }
else if (FMemory::Memcmp(&Q, &TileMaze, sizeof(Q)) != 0)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
return EVoxelTileClass::Mixed;
}
break;
}
case ECaveGeneratorType::VerticalShafts:
{
const FVerticalShaftParams Q = StrateManager->GetVerticalShaftParamsForChunk(CC);
if (bFirst) { TileVert = Q; }
else if (FMemory::Memcmp(&Q, &TileVert, sizeof(Q)) != 0)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
return EVoxelTileClass::Mixed;
}
break;
}
case ECaveGeneratorType::FloatingIslands:
{
const FFloatingIslandParams Q = StrateManager->GetFloatingIslandParamsForChunk(CC);
if (bFirst) { TileFloat = Q; }
else if (FMemory::Memcmp(&Q, &TileFloat, sizeof(Q)) != 0)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
return EVoxelTileClass::Mixed;
}
break;
}
case ECaveGeneratorType::Underwater:
case ECaveGeneratorType::TunnelNetwork:
{
const FStrateGenerationParams Q = StrateManager->GetGenerationParams(CC);
if (bFirst) { TileTunnel = Q; }
else if (FMemory::Memcmp(&Q, &TileTunnel, sizeof(Q)) != 0)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
return EVoxelTileClass::Mixed;
}
break;
}
default:
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
return EVoxelTileClass::Mixed; // SurfaceWorld ne peut pas être le type du slot cave
}
bFirst = false;
}
//---------------------------------------------------------------------
// La pile — construite par la MÊME fabrique que `GetDensityAt`.
//---------------------------------------------------------------------
FVoxelStackParamRefs Refs;
Refs.Slab = &TileSlab;
Refs.Maze = &TileMaze;
Refs.Vert = &TileVert;
Refs.Float = &TileFloat;
Refs.Tunnel = &TileTunnel;
// `Refs.Surface` reste nul — la fabrique refuse alors SurfaceWorld, et c'est voulu : cette
// fonction le prouve elle-même sur le treillis EXACT du mesher, ce qu'aucune borne de boîte
// ne fera mieux.
FVoxelOpContext OpCtx;
OpCtx.ChunkCoord = RepCC;
OpCtx.Seed = (uint32)Seed;
OpCtx.LayoutVersion = TC_LayoutVersion;
OpCtx.Step = Step;
OpCtx.WorldRadiusVoxels = WorldRadiusVoxels;
OpCtx.EdgeSealThickness = EdgeSealThickness;
OpCtx.bUseLatticeProof = true;
OpCtx.bTightenWarpProof = Step <= 2;
OpCtx.LatticeOriginVoxels = OriginVoxels;
FVoxelOpStack TileStack;
if (!VF_BuildOpStackForChunk(CaveType, Refs, Seed, OriginSpineRadius,
StrateManager, TileStack, OpCtx, this))
{
// Strate dégénérée ou archétype non porté : `GetDensityAt` retomberait sur le `switch`,
// donc la pile ne décrit pas ce que le mesher verra. Aucun verdict.
INC_DWORD_STAT(STAT_VoxelForgeCaveBailNoStack);
return EVoxelTileClass::Mixed;
}
TileStack.PrepareChunk(OpCtx);
const FBox CaveBox(FVector((float)MinX, (float)MinY, (float)CaveMinZ),
FVector((float)MaxX, (float)MaxY, (float)CaveMaxZ));
const EVoxelTileClass StackVerdict = VF_ClassifyBoxWithWarpRetry(
TileStack, CaveBox, OpCtx, this, StrateManager, TileTunnel.BaseDensity, OutStats);
if (StackVerdict == EVoxelTileClass::Mixed)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailStackVerdict);
return EVoxelTileClass::Mixed;
}
const bool bCaveOnlyTile = bAnyCave
&& !bAnyGap && !bAnySurface && !bAnyOutOfLayout
&& CaveMinZ == MinZ && CaveMaxZ == MaxZ;
if (bCaveOnlyTile && StackVerdict == EVoxelTileClass::AllSolid)
{
// The stack verdict covers the complete cave-only MC lattice. Its exact structural
// post path was supplied above, so a broad conservative passage guard must not force
// this already-certified solid tile through GenerateMesh.
bCanSolid = true;
bCanAir = false;
}
else if (bCaveOnlyTile && StackVerdict == EVoxelTileClass::AllAir)
{
bCanSolid = false;
bCanAir = true;
}
else if (StackVerdict == EVoxelTileClass::AllSolid)
{
bCanAir = false;
}
else
{
bCanSolid = false;
}
// Le verdict cave se plie avec gap=solide, hors-layout=air, seals surface=solide. Si les
// deux hypothèses sont mortes ici, les catégories se contredisent : ce n'est PAS un échec
// de borne de la pile ni une disturbance.
// Fold the cave verdict with gap=solid, out-of-layout=air, and solid surface seals. If both
// hypotheses die here, the categories conflict; this is not a stack-bound/disturbance bail.
if (!bCanSolid && !bCanAir)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailFoldConflict);
return EVoxelTileClass::Mixed;
}
//---------------------------------------------------------------------
// ⚠️ LES DISTURBANCES NE SONT PAS DANS LA PILE (`OPSTACK-DECOMPOSITION §10.2`) :
// `GetDensityAt` les applique APRÈS, sur la densité déjà négatée. Un verdict qui les
// ignorerait serait faux exactement là où elles agissent. Mêmes inégalités que la branche
// SurfaceWorld plus haut, pour la même raison.
//---------------------------------------------------------------------
const FStrateDisturbanceParams D = StrateManager->GetDisturbanceParamsForChunk(RepCC);
if (D.ChasmDensity > 0.0f) { bCanSolid = false; }
if (D.BridgeDensity > 0.0f || D.RidgeDensity > 0.0f) { bCanAir = false; }
if (!bCanSolid && !bCanAir)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailDisturbance);
return EVoxelTileClass::Mixed;
}
}
// ── Balayage des colonnes XY sur le treillis exact du mesher (marge incluse). Une colonne
// tranche chaque z intérieur : air côté MC ⇔ TerrainZ ≤ z ≤ CeilSurf (D = −interne ≥ 0,
// cf. SurfaceDensityFromColumn ; spine/passages ne font QUE de l'air → gardés plus haut).
// Pré-passe clairsemée ~5×5 pour tuer vite les tuiles traversées par la surface, puis
// passe complète — les colonnes recalculées par la pré-passe restent chaudes dans la boîte.
auto TestColumn = [&](int32 gx, int32 gy) -> bool // false ⇒ les deux hypothèses sont mortes
{
const int32 Xi = OriginVoxels.X + gx * Step;
const int32 Yi = OriginVoxels.Y + gy * Step;
for (int32 s = 0; s < NumSlots; ++s)
{
FSurfSlot& S = Slots[s];
if (S.InteriorZ.Num() == 0) continue;
FSurfaceColumnBox& Box = GSurfColCache.Acquire(Xi, Yi, S.StrateKey, Seed, TC_LayoutVersion);
const int32 CI = (Yi - Box.BaseY) * FSurfaceColumnBox::Dim + (Xi - Box.BaseX);
if (!Box.Computed[CI])
{
ComputeSurfaceColumn((float)Xi, (float)Yi, S.RepChunkZ, S.Params, S.BiomeCtx,
S.BiomeParams, TC_BiomeCache,
Box.Cols[CI].TerrainZ, Box.Cols[CI].CeilSurf,
Box.Cols[CI].OverhangAmp, Box.Cols[CI].DirX, Box.Cols[CI].DirY);
Box.Computed[CI] = true;
}
const float T = Box.Cols[CI].TerrainZ;
const float C = Box.Cols[CI].CeilSurf;
const float M = S.OverhangMargin;
for (const float Z : S.InteriorZ)
{
// F20 : juste au-dessus du sol (jusqu'à +OverhangHeight) une corniche peut ajouter de la
// roche ⇒ ni air ni solide prouvable → tuile Mixed. (Vers le haut uniquement : l'union
// n'enlève rien sous le terrain ; le cap n'est pas affecté.)
if (M > 0.0f && Z > T && Z <= T + M) return false;
if (Z >= T && Z <= C) { bCanSolid = false; } // point côté air (surface incluse)
else { bCanAir = false; } // sous le terrain / dans le cap
if (!bCanSolid && !bCanAir) return false;
}
}
return true;
};
const bool bNeedColumns = (NumSlots > 0)
&& (Slots[0].InteriorZ.Num() > 0 || (NumSlots > 1 && Slots[1].InteriorZ.Num() > 0));
if (bNeedColumns)
{
const int32 PreStride = FMath::Max(1, GridDim / 4);
for (int32 gy = 0; gy <= CPA; gy += PreStride)
for (int32 gx = 0; gx <= CPA; gx += PreStride)
if (!TestColumn(gx, gy)) return EVoxelTileClass::Mixed;
for (int32 gy = 0; gy <= CPA; ++gy)
for (int32 gx = 0; gx <= CPA; ++gx)
if (!TestColumn(gx, gy)) return EVoxelTileClass::Mixed;
}
// Ici exactement UNE hypothèse doit survivre (chaque point testé en tue une ; les tuiles
// sans point intérieur ont tué AllAir via gap/seal). Égalité = prudence → Mixed.
if (bCanSolid == bCanAir)
{
return EVoxelTileClass::Mixed;
}
if (bAnyCave)
{
// Compte seulement les verdicts FINAUX qui sautent réellement une tuile. Une pile peut avoir
// prouvé sa sous-boîte cave puis perdre l'hypothèse sur une colonne SurfaceWorld adjacente.
// Count only final verdicts that actually skip a tile; a later surface column may still
// invalidate the hypothesis proved for the cave sub-box.
if (bCanSolid) { INC_DWORD_STAT(STAT_VoxelForgeTilesOpStackSolid); }
else { INC_DWORD_STAT(STAT_VoxelForgeTilesOpStackAir); }
}
return bCanSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir;
}
float UVoxelGenerator::GetSurfaceDensity(float WorldX, float WorldY, float WorldZ,
const FSurfaceGenerationParams& ParamsD,
const FSurfaceGenerationParams& ParamsN,
float NeighborWeight) const
{
if (ParamsD.StrateTopWorldZ - ParamsD.StrateBottomWorldZ <= 0.0f) return 1.0f;
// Heightfield: dominant biome, OUTPUT-BLENDED toward the nearest neighbour in the
// border band. Blending heights (not params) keeps borders seamless across any param
// difference. ParamsD == ParamsN, weight 0 ⇒ single eval (bit-identical, no biomes).
float TerrainZ = ComputeSurfaceTerrainZ(WorldX, WorldY, ParamsD);
if (NeighborWeight > 0.0f)
{
TerrainZ = FMath::Lerp(TerrainZ, ComputeSurfaceTerrainZ(WorldX, WorldY, ParamsN), NeighborWeight);
}
const float CeilSurf = ComputeSurfaceCeiling(WorldX, WorldY, ParamsD);
return SurfaceDensityFromColumn(WorldX, WorldY, WorldZ, TerrainZ, CeilSurf,
/*OverhangAmp*/0.0f, /*DirX*/0.0f, /*DirY*/0.0f, ParamsD);
}
//=============================================================================
// CLIMATE & BIOME FIELDS
//=============================================================================
// Pure functions of world XY (+ seed). The relief field is shared with SurfaceWorld
// terrain so the biome map and the terrain it modulates stay in agreement. The biome
// field is a warped Voronoi whose cells are assigned a biome by climate — coherent
// geography (mountains cluster in high relief), window-invariant by construction.
float UVoxelGenerator::SampleRelief(float WorldX, float WorldY, float Frequency, float Contrast) const
{
const uint32 SeedU = (uint32)Seed;
// Same offsets/octaves as the original SurfaceWorld relief so existing worlds are
// unchanged (this is the function that code path now calls).
float R = FractalNoise3D(FVector3f(
WorldX * Frequency + VoxelHash::SeedOffset(SeedU, 7.3f),
WorldY * Frequency + VoxelHash::SeedOffset(SeedU, 2.1f),
VoxelHash::SeedOffset(SeedU, 0.5f)), 2) * 0.5f + 0.5f; // [0,1]
R = FMath::Clamp((R - 0.5f) * Contrast + 0.5f, 0.0f, 1.0f);
return SmoothStep01(R);
}
float UVoxelGenerator::SampleMoisture(float WorldX, float WorldY, float Frequency) const
{
const uint32 SeedU = (uint32)Seed;
const float N = FractalNoise3D(FVector3f(
WorldX * Frequency + VoxelHash::SeedOffset(SeedU, 4.7f),
WorldY * Frequency + VoxelHash::SeedOffset(SeedU, 8.9f),
VoxelHash::SeedOffset(SeedU, 1.3f)), 2) * 0.5f + 0.5f; // [0,1]
return FMath::Clamp(N, 0.0f, 1.0f);
}
bool UVoxelGenerator::EvaluateTerrainConditions(const TArray<FTerrainCondition>& Conditions,
float WorldX, float WorldY, const FBiomeContext& BiomeCtx) const
{
if (Conditions.Num() == 0) return true; // zero-cost default (the common case)
// Freq/contrast come from the strate's biome map (defaults when the strate has no biomes) — the same
// params SampleRelief/SampleMoisture are given everywhere else, so a condition agrees with the terrain.
const FBiomeMapParams MP = BiomeCtx.IsValid() ? BiomeCtx.Map : FBiomeMapParams();
// Lazily sample each field only if a condition references it (BiomeBorder's Voronoi is the pricey one).
float Relief = 0.0f, Moisture = 0.0f, Border = 0.0f;
bool bHaveRelief = false, bHaveMoisture = false, bHaveBorder = false;
for (const FTerrainCondition& C : Conditions)
{
float V = 0.0f;
switch (C.Type)
{
case ETerrainConditionType::Relief:
if (!bHaveRelief) { Relief = SampleRelief(WorldX, WorldY, MP.ReliefFrequency, MP.ReliefContrast); bHaveRelief = true; }
V = Relief; break;
case ETerrainConditionType::Moisture:
if (!bHaveMoisture) { Moisture = SampleMoisture(WorldX, WorldY, MP.MoistureFrequency); bHaveMoisture = true; }
V = Moisture; break;
case ETerrainConditionType::BiomeBorder:
if (!bHaveBorder) { Border = SampleBiomeAt(WorldX, WorldY, BiomeCtx).NeighborWeight; bHaveBorder = true; }
V = Border; break;
default: break;
}
bool bPass = (V >= C.Min && V <= C.Max);
if (C.bInvert) bPass = !bPass;
if (!bPass) return false; // AND semantics: any failing condition rejects the candidate
}
return true;
}
int32 UVoxelGenerator::ClassifyBiomeAtSite(float SiteX, float SiteY,
const FBiomeContext& Ctx, uint32 SiteHash) const
{
const float R = SampleRelief(SiteX, SiteY, Ctx.Map.ReliefFrequency, Ctx.Map.ReliefContrast);
const float Mo = SampleMoisture(SiteX, SiteY, Ctx.Map.MoistureFrequency);
int32 Best = 0;
float BestScore = FLT_MAX;
for (int32 i = 0; i < Ctx.Biomes.Num(); ++i)
{
const FBiomeResolved& B = Ctx.Biomes[i];
// Distance in climate space to the biome's box (0 when inside it).
const float dr = (R < B.ReliefMin) ? (B.ReliefMin - R)
: (R > B.ReliefMax) ? (R - B.ReliefMax) : 0.0f;
const float dm = (Mo < B.MoistureMin) ? (B.MoistureMin - Mo)
: (Mo > B.MoistureMax) ? (Mo - B.MoistureMax) : 0.0f;
float Score = dr * dr + dm * dm;
// Tiny deterministic jitter so overlapping boxes don't all collapse to biome 0.
Score += VoxelHash::ToFloat01(VoxelHash::Mix(SiteHash ^ (0x9e3779b9u * (uint32)(i + 1)))) * 1.0e-4f;
if (Score < BestScore) { BestScore = Score; Best = i; }
}
return Best;
}
FBiomeSample UVoxelGenerator::SampleBiomeAt(float WorldX, float WorldY, const FBiomeContext& Ctx) const
{
FBiomeSample Out;
if (Ctx.Biomes.Num() == 0) return Out; // biomes disabled
if (Ctx.Biomes.Num() == 1) { Out.DominantIndex = 0; return Out; }
const FBiomeMapParams& MP = Ctx.Map;
const float Cell = FMath::Max(MP.CellSize, 1.0f);
const uint32 S = (uint32)Seed ^ 0x42494f4du; // 'BIOM'
// Domain-warp the query so cell borders wind organically (not a hex grid).
float QX = WorldX, QY = WorldY;
if (MP.WarpStrength > 0.0f)
{
const uint32 SeedU = (uint32)Seed;
const float WF = MP.WarpFrequency;
const float wx = VoxelNoise::Perlin3D(FVector3f(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.27f), WorldY * WF + 3.1f, VoxelHash::SeedOffset(SeedU, 1.1f)));
const float wy = VoxelNoise::Perlin3D(FVector3f(WorldX * WF + 7.7f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.61f), VoxelHash::SeedOffset(SeedU, 2.3f)));
QX += wx * VOXEL_NOISE_SCALE * MP.WarpStrength;
QY += wy * VOXEL_NOISE_SCALE * MP.WarpStrength;
}
const int32 CX = FMath::FloorToInt(QX / Cell);
const int32 CY = FMath::FloorToInt(QY / Cell);
// Worley F1/F2 over the 3x3 neighbourhood of the (warped) cell. With jitter confined
// to [0,1) of a cell, the nearest site is always within this neighbourhood.
float BestD2 = FLT_MAX, SecondD2 = FLT_MAX;
int32 BestBiome = 0, SecondBiome = 0;
for (int32 dy = -1; dy <= 1; ++dy)
for (int32 dx = -1; dx <= 1; ++dx)
{
const int32 nx = CX + dx, ny = CY + dy;
const uint32 h = VoxelHash::Cell(nx, ny, S);
const float jx = VoxelHash::ToFloat01(h);
const float jy = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0x68bc21ebu));
const float sx = (nx + jx) * Cell;
const float sy = (ny + jy) * Cell;
const float ddx = sx - QX, ddy = sy - QY;
const float d2 = ddx * ddx + ddy * ddy;
if (d2 < BestD2)
{
SecondD2 = BestD2; SecondBiome = BestBiome;
BestD2 = d2; BestBiome = ClassifyBiomeAtSite(sx, sy, Ctx, h);
}
else if (d2 < SecondD2)
{
SecondD2 = d2; SecondBiome = ClassifyBiomeAtSite(sx, sy, Ctx, h);
}
}
Out.DominantIndex = BestBiome;
Out.NeighborIndex = SecondBiome;
// Blend weight: 0.5 at the shared border (d1≈d2), fading to 0 a BorderBlend-wide
// band inside the dominant cell. Only blend when the neighbour is a DIFFERENT biome.
if (MP.BorderBlend > 0.0f && SecondD2 < FLT_MAX && BestBiome != SecondBiome)
{
const float d1 = FMath::Sqrt(BestD2);
const float d2 = FMath::Sqrt(SecondD2);
const float t = FMath::Clamp((d2 - d1) / FMath::Max(MP.BorderBlend, 1.0f), 0.0f, 1.0f);
Out.NeighborWeight = 0.5f * (1.0f - SmoothStep01(t));
}
return Out;
}
void UVoxelGenerator::RebuildBiomeGrid(int32 ChunkX, int32 ChunkY, int32 ChunkZ,
const FBiomeContext& Ctx, FChunkBiomeCache& Cache) const
{
Cache.ChunkZ = ChunkZ;
Cache.Seed = Seed;
Cache.bActive = Ctx.IsValid();
Cache.Ctx = Ctx;
Cache.CellBiome.Reset();
if (!Cache.bActive)
{
// Mark the whole chunk footprint valid so non-biome chunks don't rebuild per voxel.
Cache.ValidMinX = (float)ChunkX * CHUNK_SIZE - (float)CHUNK_SIZE;
Cache.ValidMaxX = (float)(ChunkX + 1) * CHUNK_SIZE + (float)CHUNK_SIZE;
Cache.ValidMinY = (float)ChunkY * CHUNK_SIZE - (float)CHUNK_SIZE;
Cache.ValidMaxY = (float)(ChunkY + 1) * CHUNK_SIZE + (float)CHUNK_SIZE;
return;
}
const FBiomeMapParams& MP = Ctx.Map;
const float Cell = FMath::Max(MP.CellSize, 1.0f);
const uint32 S = (uint32)Seed ^ 0x42494f4du; // 'BIOM' (must match SampleBiomeAt)
// Validity halo: one chunk beyond the footprint, so the +X/+Y boundary corners and
// ±1 gradient-normal samples stay inside the valid box (no rebuild thrash, §8.10).
const float Halo = (float)CHUNK_SIZE;
Cache.ValidMinX = (float)ChunkX * CHUNK_SIZE - Halo;
Cache.ValidMaxX = (float)(ChunkX + 1) * CHUNK_SIZE + Halo;
Cache.ValidMinY = (float)ChunkY * CHUNK_SIZE - Halo;
Cache.ValidMaxY = (float)(ChunkY + 1) * CHUNK_SIZE + Halo;
// Cell-grid coverage: the valid box expanded by the max warp displacement + one cell,
// so the 3x3 search around any in-box query's warped point is fully present.
const float CellMargin = MP.WarpStrength * VOXEL_NOISE_SCALE + Cell + 1.0f;
Cache.BaseCellX = FMath::FloorToInt((Cache.ValidMinX - CellMargin) / Cell);
Cache.BaseCellY = FMath::FloorToInt((Cache.ValidMinY - CellMargin) / Cell);
const int32 MaxCellX = FMath::FloorToInt((Cache.ValidMaxX + CellMargin) / Cell);
const int32 MaxCellY = FMath::FloorToInt((Cache.ValidMaxY + CellMargin) / Cell);
Cache.CellsX = MaxCellX - Cache.BaseCellX + 1;
Cache.CellsY = MaxCellY - Cache.BaseCellY + 1;
Cache.CellBiome.SetNumUninitialized(Cache.CellsX * Cache.CellsY);
for (int32 cy = 0; cy < Cache.CellsY; ++cy)
for (int32 cx = 0; cx < Cache.CellsX; ++cx)
{
const int32 nx = Cache.BaseCellX + cx;
const int32 ny = Cache.BaseCellY + cy;
const uint32 h = VoxelHash::Cell(nx, ny, S);
const float jx = VoxelHash::ToFloat01(h);
const float jy = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0x68bc21ebu));
const float sx = (nx + jx) * Cell;
const float sy = (ny + jy) * Cell;
Cache.CellBiome[cy * Cache.CellsX + cx] = ClassifyBiomeAtSite(sx, sy, Ctx, h);
}
}
FBiomeSample UVoxelGenerator::ResolveBiomeSampleAt(float WorldX, float WorldY, int32 ChunkZ,
const FBiomeContext& Ctx, FChunkBiomeCache& Cache) const
{
FBiomeSample Out;
// Box-validated rebuild (perf-only; result is the pure function of XY either way).
if (!Cache.Contains(WorldX, WorldY, ChunkZ, Seed))
{
RebuildBiomeGrid(FMath::FloorToInt(WorldX / CHUNK_SIZE),
FMath::FloorToInt(WorldY / CHUNK_SIZE), ChunkZ, Ctx, Cache);
}
if (!Cache.bActive) return Out;
if (Cache.Ctx.Biomes.Num() == 1) { Out.DominantIndex = 0; return Out; }
const FBiomeMapParams& MP = Cache.Ctx.Map;
const float Cell = FMath::Max(MP.CellSize, 1.0f);
const uint32 S = (uint32)Seed ^ 0x42494f4du;
// Same warp + 3x3 Worley as SampleBiomeAt → identical assignment (matches the preview).
float QX = WorldX, QY = WorldY;
if (MP.WarpStrength > 0.0f)
{
const uint32 SeedU = (uint32)Seed;
const float WF = MP.WarpFrequency;
const float wx = VoxelNoise::Perlin3D(FVector3f(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.27f), WorldY * WF + 3.1f, VoxelHash::SeedOffset(SeedU, 1.1f)));
const float wy = VoxelNoise::Perlin3D(FVector3f(WorldX * WF + 7.7f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.61f), VoxelHash::SeedOffset(SeedU, 2.3f)));
QX += wx * VOXEL_NOISE_SCALE * MP.WarpStrength;
QY += wy * VOXEL_NOISE_SCALE * MP.WarpStrength;
}
const int32 CX = FMath::FloorToInt(QX / Cell);
const int32 CY = FMath::FloorToInt(QY / Cell);
float BestD2 = FLT_MAX, SecondD2 = FLT_MAX;
int32 BestBiome = 0, SecondBiome = 0;
for (int32 dy = -1; dy <= 1; ++dy)
for (int32 dx = -1; dx <= 1; ++dx)
{
const int32 nx = CX + dx, ny = CY + dy;
const uint32 h = VoxelHash::Cell(nx, ny, S);
const float jx = VoxelHash::ToFloat01(h);
const float jy = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0x68bc21ebu));
const float sx = (nx + jx) * Cell;
const float sy = (ny + jy) * Cell;
const float ddx = sx - QX, ddy = sy - QY;
const float d2 = ddx * ddx + ddy * ddy;
// Cached biome index (fallback to classify on the rare margin miss → still correct).
const int32 gx = nx - Cache.BaseCellX;
const int32 gy = ny - Cache.BaseCellY;
const int32 BiomeIdx = (gx >= 0 && gx < Cache.CellsX && gy >= 0 && gy < Cache.CellsY)
? Cache.CellBiome[gy * Cache.CellsX + gx]
: ClassifyBiomeAtSite(sx, sy, Cache.Ctx, h);
if (d2 < BestD2) { SecondD2 = BestD2; SecondBiome = BestBiome; BestD2 = d2; BestBiome = BiomeIdx; }
else if (d2 < SecondD2) { SecondD2 = d2; SecondBiome = BiomeIdx; }
}
Out.DominantIndex = BestBiome;
Out.NeighborIndex = SecondBiome;
if (MP.BorderBlend > 0.0f && SecondD2 < FLT_MAX && BestBiome != SecondBiome)
{
const float d1 = FMath::Sqrt(BestD2);
const float d2 = FMath::Sqrt(SecondD2);
const float t = FMath::Clamp((d2 - d1) / FMath::Max(MP.BorderBlend, 1.0f), 0.0f, 1.0f);
Out.NeighborWeight = 0.5f * (1.0f - SmoothStep01(t));
}
return Out;
}
const UVoxelBiomeDefinition* UVoxelGenerator::GetDominantBiomeAt(float WorldX, float WorldY, int32 ChunkZ) const
{
if (!StrateManager) return nullptr;
const FIntVector Coord(FMath::FloorToInt(WorldX / CHUNK_SIZE),
FMath::FloorToInt(WorldY / CHUNK_SIZE), ChunkZ);
const FBiomeContext Ctx = StrateManager->GetBiomeContextForChunk(Coord);
if (!Ctx.IsValid()) return nullptr;
const FBiomeSample S = SampleBiomeAt(WorldX, WorldY, Ctx);
if (!Ctx.Biomes.IsValidIndex(S.DominantIndex)) return nullptr;
// Map the context position back to the strate's Biomes[] asset.
const int32 StrateBiomeIdx = Ctx.Biomes[S.DominantIndex].Index;
const UVoxelStrateDefinition* Def = StrateManager->GetStrateForChunk(Coord);
return (Def && Def->Biomes.IsValidIndex(StrateBiomeIdx)) ? Def->Biomes[StrateBiomeIdx] : nullptr;
}
void UVoxelGenerator::QueryBiomeAt(float WorldX, float WorldY, int32 ChunkZ, FVoxelBiomeQuery& Out) const
{
Out = FVoxelBiomeQuery();
if (!StrateManager) return;
const FIntVector Coord(FMath::FloorToInt(WorldX / CHUNK_SIZE),
FMath::FloorToInt(WorldY / CHUNK_SIZE), ChunkZ);
const FBiomeContext Ctx = StrateManager->GetBiomeContextForChunk(Coord);
// Climate fields are always meaningful (use the strate's map freqs, or defaults when no biomes).
const FBiomeMapParams MP = Ctx.IsValid() ? Ctx.Map : FBiomeMapParams();
Out.Relief = SampleRelief(WorldX, WorldY, MP.ReliefFrequency, MP.ReliefContrast);
Out.Moisture = SampleMoisture(WorldX, WorldY, MP.MoistureFrequency);
if (!Ctx.IsValid()) return; // bHasBiomes stays false → BP knows this strate has no biome field
Out.bHasBiomes = true;
const FBiomeSample S = SampleBiomeAt(WorldX, WorldY, Ctx);
Out.NeighborWeight = S.NeighborWeight;
Out.DominantContextIndex = S.DominantIndex;
const UVoxelStrateDefinition* Def = StrateManager->GetStrateForChunk(Coord);
if (Ctx.Biomes.IsValidIndex(S.DominantIndex))
{
const int32 DomStrateIdx = Ctx.Biomes[S.DominantIndex].Index;
if (Def && Def->Biomes.IsValidIndex(DomStrateIdx))
{
UVoxelBiomeDefinition* Bio = Def->Biomes[DomStrateIdx];
Out.DominantBiome = Bio;
if (Bio)
{
Out.DebugColor = Bio->DebugColor;
Out.DominantName = Bio->BiomeName.IsEmpty() ? FText::FromName(Bio->GetFName()) : Bio->BiomeName;
// Effective list = the biome's decorations, or the strate's when the biome has none
// (this is exactly what the per-column scatter falls back to). 0 ⇒ empty band.
Out.DominantDecorationCount =
(Bio->Decorations.Num() > 0) ? Bio->Decorations.Num() : Def->Decorations.Num();
}
}
}
if (Ctx.Biomes.IsValidIndex(S.NeighborIndex))
{
const int32 NbStrateIdx = Ctx.Biomes[S.NeighborIndex].Index;
if (Def && Def->Biomes.IsValidIndex(NbStrateIdx)) { Out.NeighborBiome = Def->Biomes[NbStrateIdx]; }
}
}
void UVoxelGenerator::GetBiomeMaterialAt(float WorldX, float WorldY, float WorldZ,
int32& OutDominantPalette, int32& OutNeighborPalette, float& OutBlendWeight) const
{
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;
thread_local uint32 BM_Version = 0xFFFFFFFFu; // AUDIT C2
const uint32 BM_LayoutVersion = StrateManager->GetLayoutVersion();
if (ChunkCoord != BM_Chunk || BM_LayoutVersion != BM_Version)
{
if (BM_LayoutVersion != BM_Version) { BM_Cache.Invalidate(); }
BM_Version = BM_LayoutVersion;
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)
//=============================================================================
// Solid rock carved by hash-placed full-height vertical shafts (cylinders), a deterministic
// drainage tree of horizontal connector tunnels rooted at the origin spine, additive random
// connector loops, and partial ledges inside them. Emphasises climbing and falling.
float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float WorldZ,
const FVerticalShaftParams& Params) const
{
const float StrateHeight = Params.StrateTopWorldZ - Params.StrateBottomWorldZ;
if (StrateHeight <= 0.0f) return 1.0f;
const float Spacing = FMath::Max(Params.ShaftSpacing, 1.0f);
const FVector Pos(WorldX, WorldY, WorldZ);
const uint32 S = (uint32)Seed ^ 0x53686674u; // 'Shft'
float Density = Params.BaseDensity; // start solid
const int32 CX = FMath::FloorToInt(WorldX / Spacing);
const int32 CY = FMath::FloorToInt(WorldY / Spacing);
// The shaft topology is pure, but it is deliberately split into two windows:
// * COLLECT a deterministic halo once during this thread-local cache rebuild. This is the
// source data needed to resolve every 5×5 parent window and the fixed ±3 fallback window.
// * EVALUATE only the INNER 3×3 shaft cylinders. Tree links are collected from a wider
// rebuild-only child window and culled to the current cell before the voxel loop sees
// them. That matters: a capsule from a child to a parent two or three cells away must be
// present in the cells along its segment, not only in the cache centred on the child.
struct FLocalShaft { float X, Y, R; int32 CellX, CellY; bool bOriginSpine; };
struct FLocalConn
{
FVector A, B;
float Radius = 0.0f;
float FloorZ = 0.0f;
bool bWalkableFloor = false;
};
thread_local TArray<FLocalShaft, TInlineAllocator<10>> Shafts;
thread_local TArray<FLocalConn, TInlineAllocator<32>> Conns;
thread_local TArray<FLocalShaft, TInlineAllocator<81>> TreeEmitShafts;
thread_local int32 VS_CX = INT32_MAX, VS_CY = INT32_MAX;
thread_local uint32 VS_Seed = 0xFFFFFFFFu;
thread_local float VS_Spacing = -1.0f, VS_Dens = -1.0f, VS_MinR = -1.0f, VS_MaxR = -1.0f,
VS_Cross = -1.0f, VS_ConnR = -1.0f, VS_Rough = -1.0f,
VS_SpineR = -1.0f, VS_BotZ = FLT_MAX, VS_TopZ = FLT_MAX,
VS_Seal = -1.0f;
if (CX != VS_CX || CY != VS_CY || S != VS_Seed || Spacing != VS_Spacing ||
Params.ShaftDensity != VS_Dens || Params.ShaftMinRadius != VS_MinR || Params.ShaftMaxRadius != VS_MaxR ||
Params.CrossConnectChance != VS_Cross || Params.ConnectorRadius != VS_ConnR ||
Params.SurfaceRoughness != VS_Rough || OriginSpineRadius != VS_SpineR ||
Params.StrateBottomWorldZ != VS_BotZ || Params.StrateTopWorldZ != VS_TopZ ||
Params.BoundarySealThickness != VS_Seal)
{
VS_CX = CX; VS_CY = CY; VS_Seed = S; VS_Spacing = Spacing;
VS_Dens = Params.ShaftDensity; VS_MinR = Params.ShaftMinRadius; VS_MaxR = Params.ShaftMaxRadius;
VS_Cross = Params.CrossConnectChance; VS_ConnR = Params.ConnectorRadius;
VS_Rough = Params.SurfaceRoughness; VS_SpineR = OriginSpineRadius;
VS_BotZ = Params.StrateBottomWorldZ; VS_TopZ = Params.StrateTopWorldZ;
VS_Seal = Params.BoundarySealThickness;
Shafts.Reset();
Conns.Reset();
TreeEmitShafts.Reset();
constexpr int32 EmitRadius = 1; // 3×3: shaft cylinders and random links
constexpr int32 CandidateRadius = 2; // 5×5 parent window around an emitting shaft
constexpr int32 FallbackRadius = 3; // fixed fallback window around an emitting shaft
constexpr uint32 TreeSalt = 0x7A11u;
const float BottomZ = Params.StrateBottomWorldZ + Params.BoundarySealThickness;
const float TopZ = Params.StrateTopWorldZ - Params.BoundarySealThickness;
const float RoughnessReach = FMath::Max(Params.SurfaceRoughness, 0.0f)
* VOXEL_NOISE_SCALE * 1.5f;
// A tree link must remain open at the centreline after the SDF roughness pass. The +1
// margin makes the radius strictly greater than the proven roughness supremum.
const float TreeConnectorRadius = FMath::Max(Params.ConnectorRadius, RoughnessReach + 1.0f);
// A tree capsule can affect a cell even when neither endpoint is in that cell. Keep a
// rebuild-only child halo for that geometric reach, then add the parent-search halo. The
// default is a 4-cell tree-emission radius + 3-cell collection pad = 7-cell radius
// (15×15 rolls; the emitted tree-child box itself is 9×9).
const int32 ConnectorCellPad = FMath::Max(
1, FMath::CeilToInt(TreeConnectorRadius / Spacing));
const int32 TreeEmitRadius = FallbackRadius + ConnectorCellPad;
const int32 CollectRadius = TreeEmitRadius + FallbackRadius;
const int32 CollectSide = CollectRadius * 2 + 1;
TArray<FLocalShaft, TInlineAllocator<225>> ShaftGrid;
TArray<uint8, TInlineAllocator<225>> ShaftPresent;
ShaftGrid.SetNum(CollectSide * CollectSide);
ShaftPresent.Init(0, CollectSide * CollectSide);
// Collect the wider deterministic neighbourhood once. Only the inner cells are retained
// in `Shafts` for the per-voxel cylinder/ledge work; `TreeEmitShafts` is still rebuilt
// before evaluation and its connectors are spatially culled below.
for (int32 dy = -CollectRadius; dy <= CollectRadius; dy++)
for (int32 dx = -CollectRadius; dx <= CollectRadius; dx++)
{
const int32 nx = CX + dx, ny = CY + dy;
const uint32 Hh = VoxelHash::Cell(nx, ny, S);
if (VoxelHash::ToFloat01(Hh) > Params.ShaftDensity) continue;
const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x12345678u));
const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x9ABCDEF0u));
FLocalShaft Sh;
Sh.X = (nx + 0.15f + JX * 0.7f) * Spacing;
Sh.Y = (ny + 0.15f + JY * 0.7f) * Spacing;
Sh.R = FMath::Lerp(Params.ShaftMinRadius, Params.ShaftMaxRadius,
VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0xBEEFu)));
Sh.CellX = nx;
Sh.CellY = ny;
Sh.bOriginSpine = false;
const int32 GridIndex = (dy + CollectRadius) * CollectSide + (dx + CollectRadius);
ShaftGrid[GridIndex] = Sh;
ShaftPresent[GridIndex] = 1;
if (FMath::Abs(dx) <= EmitRadius && FMath::Abs(dy) <= EmitRadius)
{
Shafts.Add(Sh);
}
if (FMath::Abs(dx) <= TreeEmitRadius && FMath::Abs(dy) <= TreeEmitRadius)
{
TreeEmitShafts.Add(Sh);
}
}
auto FindCollectedShaft = [&](int32 CellX, int32 CellY) -> const FLocalShaft*
{
const int32 dx = CellX - CX;
const int32 dy = CellY - CY;
if (FMath::Abs(dx) > CollectRadius || FMath::Abs(dy) > CollectRadius)
{
return nullptr;
}
const int32 GridIndex = (dy + CollectRadius) * CollectSide + (dx + CollectRadius);
return ShaftPresent[GridIndex] ? &ShaftGrid[GridIndex] : nullptr;
};
// The structural (0,0) spine is not re-carved here. It participates only as a
// connector endpoint. The local 3x3 set is origin-adjacent when its centre cell is in
// the central 3x3; the structural post remains the sole owner of the vertical column.
const bool bOriginNearby = FMath::Abs(CX) <= 1 && FMath::Abs(CY) <= 1;
if (bOriginNearby && OriginSpineRadius > 0.0f)
{
Shafts.Add({0.0f, 0.0f, OriginSpineRadius, 0, 0, true});
}
const float CellMinX = static_cast<float>(CX) * Spacing;
const float CellMaxX = static_cast<float>(CX + 1) * Spacing;
const float CellMinY = static_cast<float>(CY) * Spacing;
const float CellMaxY = static_cast<float>(CY + 1) * Spacing;
auto ConnectorMayReachCell = [&](const FLocalConn& Conn)
{
const float MinX = FMath::Min(Conn.A.X, Conn.B.X) - Conn.Radius;
const float MaxX = FMath::Max(Conn.A.X, Conn.B.X) + Conn.Radius;
const float MinY = FMath::Min(Conn.A.Y, Conn.B.Y) - Conn.Radius;
const float MaxY = FMath::Max(Conn.A.Y, Conn.B.Y) + Conn.Radius;
const float GapX = FMath::Max3(CellMinX - MaxX, MinX - CellMaxX, 0.0f);
const float GapY = FMath::Max3(CellMinY - MaxY, MinY - CellMaxY, 0.0f);
return GapX == 0.0f && GapY == 0.0f;
};
auto EmitTreeConnector = [&](const FLocalShaft& Child, const FLocalShaft* Parent)
{
const float Zc = VoxelPassageGeometry::VerticalShaftConnectorCenterZ(
Params.StrateBottomWorldZ,
BottomZ,
TopZ,
Params.LedgeSpacing,
Params.LedgeDepth,
TreeConnectorRadius);
const float FloorZ = VoxelPassageGeometry::VerticalShaftConnectorFloorZ(
Params.StrateBottomWorldZ,
BottomZ,
TopZ,
Params.LedgeSpacing,
Params.LedgeDepth,
TreeConnectorRadius);
const FVector ParentPoint = Parent != nullptr
? FVector(Parent->X, Parent->Y, Zc)
: FVector(0.0f, 0.0f, Zc);
const FLocalConn Conn{
FVector(Child.X, Child.Y, Zc), ParentPoint, TreeConnectorRadius,
FloorZ, true };
if (ConnectorMayReachCell(Conn))
{
Conns.Add(Conn);
}
};
// Structural drainage tree: every real shaft in the rebuild-only child window first
// selects one strictly more-central shaft from its complete 5×5 window. If that window
// has a local minimum, select the nearest shaft in the collected deterministic halo whose
// origin distance is lower; that shaft is already on a strictly descending path by the
// same rule. A shaft whose window reaches the origin uses the spine directly, and the
// final direct-spine fallback covers an unusually empty finite halo. Thus every emitted
// parent edge either strictly decreases origin distance or terminates at the spine: no
// cycles and no orphans. Spatial culling makes the same edge visible throughout its
// capsule, independent of which cache cell is evaluating it.
// The explicit cell-coordinate tie-break makes this independent of array iteration order;
// the synthetic spine is not a candidate shaft.
for (const FLocalShaft& Child : TreeEmitShafts)
{
if (Child.bOriginSpine) continue;
const float ChildOriginSq = FMath::Square(Child.X) + FMath::Square(Child.Y);
const FLocalShaft* Parent = nullptr;
float BestDistanceSq = FLT_MAX;
for (int32 dy = -CandidateRadius; dy <= CandidateRadius; ++dy)
for (int32 dx = -CandidateRadius; dx <= CandidateRadius; ++dx)
{
const FLocalShaft* Candidate = FindCollectedShaft(
Child.CellX + dx, Child.CellY + dy);
if (Candidate == nullptr
|| (Candidate->CellX == Child.CellX && Candidate->CellY == Child.CellY))
{
continue;
}
const float CandidateOriginSq = FMath::Square(Candidate->X)
+ FMath::Square(Candidate->Y);
if (!(CandidateOriginSq < ChildOriginSq))
{
continue;
}
const float DistanceSq = FMath::Square(Child.X - Candidate->X)
+ FMath::Square(Child.Y - Candidate->Y);
const bool bLowerCell = Parent == nullptr
|| Candidate->CellY < Parent->CellY
|| (Candidate->CellY == Parent->CellY && Candidate->CellX < Parent->CellX);
if (DistanceSq < BestDistanceSq
|| (DistanceSq == BestDistanceSq && bLowerCell))
{
BestDistanceSq = DistanceSq;
Parent = Candidate;
}
}
if (Parent == nullptr
&& !(FMath::Abs(Child.CellX) <= CandidateRadius
&& FMath::Abs(Child.CellY) <= CandidateRadius))
{
// This is the specified neighbour-shaft fallback. Restricting it to a lower
// origin distance makes the parent graph well-founded even though the fallback
// candidate need not be in the child's 5×5 window.
for (int32 dy = -FallbackRadius; dy <= FallbackRadius; ++dy)
for (int32 dx = -FallbackRadius; dx <= FallbackRadius; ++dx)
{
const FLocalShaft* Candidate = FindCollectedShaft(
Child.CellX + dx, Child.CellY + dy);
if (Candidate == nullptr
|| (Candidate->CellX == Child.CellX && Candidate->CellY == Child.CellY))
{
continue;
}
const float CandidateOriginSq = FMath::Square(Candidate->X)
+ FMath::Square(Candidate->Y);
if (!(CandidateOriginSq < ChildOriginSq))
{
continue;
}
const float DistanceSq = FMath::Square(Child.X - Candidate->X)
+ FMath::Square(Child.Y - Candidate->Y);
const bool bLowerCell = Parent == nullptr
|| Candidate->CellY < Parent->CellY
|| (Candidate->CellY == Parent->CellY
&& Candidate->CellX < Parent->CellX);
if (DistanceSq < BestDistanceSq
|| (DistanceSq == BestDistanceSq && bLowerCell))
{
BestDistanceSq = DistanceSq;
Parent = Candidate;
}
}
}
EmitTreeConnector(Child, Parent);
}
// Existing probabilistic links remain as texture and loops. They intentionally stay on
// the cached inner 3×3 working set; only the structural tree build needs the 9×9/15×15 halo.
if (Params.CrossConnectChance > 0.0f && Shafts.Num() >= 2)
{
for (int32 i = 0; i < Shafts.Num(); i++)
for (int32 j = i + 1; j < Shafts.Num(); j++)
{
const FLocalShaft& A = Shafts[i];
const FLocalShaft& B = Shafts[j];
const bool bSpineConnector = A.bOriginSpine || B.bOriginSpine;
const float DSq = FMath::Square(A.X - B.X) + FMath::Square(A.Y - B.Y);
if (DSq > FMath::Square(Spacing * 1.6f)) continue; // only neighbours
// Symmetric pair hash from quantised endpoints.
const uint32 PH = VoxelHash::Pair(
FMath::RoundToInt(A.X), FMath::RoundToInt(A.Y),
FMath::RoundToInt(B.X), FMath::RoundToInt(B.Y), S ^ 0xC04Eu);
if (VoxelHash::ToFloat01(PH) >= Params.CrossConnectChance) continue;
const float Zc = FMath::Lerp(BottomZ, TopZ, VoxelHash::ToFloat01(VoxelHash::Mix(PH)));
const float ConnectorR = bSpineConnector
? FMath::Max(
Params.ConnectorRadius,
RoughnessReach + 1.0f)
: Params.ConnectorRadius;
const FLocalConn Conn{
FVector(A.X, A.Y, Zc), FVector(B.X, B.Y, Zc), ConnectorR,
0.0f, false };
if (ConnectorMayReachCell(Conn))
{
Conns.Add(Conn);
}
}
}
}
float CaveSDF = FLT_MAX;
// Vertical shafts as infinite cylinders (boundary seal handles the ends).
for (const FLocalShaft& Sh : Shafts)
{
if (Sh.bOriginSpine) continue; // VF_ApplyOriginSpine owns the structural column.
const float DX = WorldX - Sh.X;
const float DY = WorldY - Sh.Y;
CaveSDF = FMath::Min(CaveSDF, FMath::Sqrt(DX * DX + DY * DY) - Sh.R);
}
for (const FLocalConn& C : Conns)
{
CaveSDF = FMath::Min(CaveSDF, VoxelSDF::Capsule(Pos, C.A, C.B, C.Radius));
}
// Wall roughness.
if (Params.SurfaceRoughness > 0.0f && CaveSDF < Params.SurfaceRoughness + 4.0f)
{
CaveSDF += FractalNoise3D(FVector3f(WorldX * 0.1f, WorldY * 0.1f, WorldZ * 0.1f), VoxelGenLOD::Eff(3))
* VOXEL_NOISE_SCALE * Params.SurfaceRoughness;
}
// Carve air inside shafts/connectors.
const float Blend = 2.0f;
if (CaveSDF < Blend)
{
float Carve = FMath::Clamp((Blend - CaveSDF) / (Blend * 2.0f), 0.0f, 1.0f);
Carve = SmoothStep01(Carve);
Density -= Carve * Params.BaseDensity * 2.0f;
}
// Partial ledges inside shafts: thin shelves on one side at LedgeSpacing intervals,
// leaving the opposite side open so the shaft stays traversable.
if (Params.LedgeSpacing > 0.0f && Params.LedgeDepth > 0.0f && CaveSDF < 0.0f && Shafts.Num() > 0)
{
const float Phase = FMath::Frac((WorldZ - Params.StrateBottomWorldZ) / Params.LedgeSpacing);
const float BandT = FMath::Min(Phase, 1.0f - Phase) * Params.LedgeSpacing; // dist to nearest band
if (BandT < Params.LedgeDepth)
{
// Nearest shaft center → only shelf the +X/+Y half so a climb path remains.
const FLocalShaft* Near = nullptr; float BestSq = FLT_MAX;
for (const FLocalShaft& Sh : Shafts)
{
if (Sh.bOriginSpine) continue; // ledges belong to real shafts, not the post.
const float D2 = FMath::Square(WorldX - Sh.X) + FMath::Square(WorldY - Sh.Y);
if (D2 < BestSq) { BestSq = D2; Near = &Sh; }
}
// The shaft axis is the structural tree's terminal point. Keep the mathematical
// half-plane boundary open: tiny cancellation error must not turn an exact axis
// landing into a solid ledge cap and sever the parent capsule.
if (Near && (WorldX - Near->X) + (WorldY - Near->Y) > 1.0e-3f)
{
float Shelf = 1.0f - SmoothStep01(BandT / Params.LedgeDepth);
Density = FMath::Max(Density, Shelf * Params.BaseDensity);
}
}
}
// The drainage tree is a walkable shaft network, not only a connected air SDF. Reassert its
// air core after partial shaft ledges, then emit support bands at the common first ledge level
// so every parent edge can be traversed by the player-fit stencil. This remains local to
// shaft edges; no landing-to-origin road is added.
for (const FLocalConn& C : Conns)
{
if (C.bWalkableFloor)
{
VoxelPassageGeometry::VF_ApplyVerticalShaftConnectorAir(
Density, WorldX, WorldY, WorldZ,
C.A, C.B, C.Radius, C.FloorZ, Params.BaseDensity);
}
}
for (const FLocalConn& C : Conns)
{
if (C.bWalkableFloor)
{
VoxelPassageGeometry::VF_ApplyVerticalShaftConnectorFloor(
Density, WorldX, WorldY, WorldZ,
C.A, C.B, C.Radius, C.FloorZ, Params.BaseDensity);
}
}
ApplyOriginSpine(Density, WorldX, WorldY, WorldZ,
Params.StrateTopWorldZ, Params.StrateBottomWorldZ,
Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius);
ApplyBoundarySeal(Density, WorldZ,
Params.StrateTopWorldZ, Params.StrateBottomWorldZ,
Params.BoundarySealThickness, Params.BaseDensity);
if (StrateManager)
{
StrateManager->ApplyPassageModifier(
Density, WorldX, WorldY, WorldZ,
Params.BaseDensity, Params.BoundarySealThickness);
}
ApplyOriginLandingFloor(Density, WorldX, WorldY, WorldZ,
Params.StrateTopWorldZ, Params.StrateBottomWorldZ,
Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius);
VF_ApplyXYEdgeSeal(Density, WorldX, WorldY,
WorldRadiusVoxels, EdgeSealThickness, Params.BaseDensity);
return -Density;
}
//=============================================================================
// FLOATING-ISLAND GENERATOR (ECaveGeneratorType::FloatingIslands)
//=============================================================================
// A large open void with hash-placed island blobs (flattened-top ellipsoids, rough
// undersides) floating at jittered heights. Top/bottom sealed so the void encloses.
float UVoxelGenerator::GetFloatingIslandDensity(float WorldX, float WorldY, float WorldZ,
const FFloatingIslandParams& Params) const
{
const float H = Params.StrateTopWorldZ - Params.StrateBottomWorldZ;
if (H <= 0.0f) return 1.0f;
const float Spacing = FMath::Max(Params.IslandSpacing, 1.0f);
const uint32 S = (uint32)Seed ^ 0x49736C64u; // 'Isld'
const float BlendK = FMath::Max(Params.SDFBlendRadius, 0.01f);
float Density = -Params.BaseDensity; // start as open air (void)
const int32 CX = FMath::FloorToInt(WorldX / Spacing);
const int32 CY = FMath::FloorToInt(WorldY / Spacing);
const float MidZ = (Params.StrateTopWorldZ + Params.StrateBottomWorldZ) * 0.5f;
float IslandSDF = FLT_MAX;
// IRREGULAR OUTLINE: domain-warp the horizontal query so island edges are lobed and
// organic instead of perfect circles. Computed once per voxel and shared by all nearby
// islands (each samples a different part of the field → distinct silhouettes).
const float WarpAmp = (Params.IslandMinRadius + Params.IslandMaxRadius) * 0.5f * 0.35f;
// ⚠️ AUDIT §C1 — DERNIER SITE DU PLUGIN, trouvé en portant cet archétype (2026-07-28). Le
// balayage du 2026-07-27 cherchait le motif `SeedF * K` et celui-ci s'écrit `(float)S * K`, donc
// il a survécu : à Seed = 2e9 le terme atteint ~1.4e6, où l'ULP du float vaut 0.125 contre un pas
// de 0.04 par voxel — le warp s'aplatit et les îles redeviennent des cercles parfaits. Corrigé
// dans les DEUX chemins (ici et FIslandBlobSource) en une passe, pour que le test d'équivalence
// reste un oracle valable.
// NOTE : `SeedOffset` quantifie la clé de site par ×100, donc 0.0007 → site 0. Unique aujourd'hui
// (toutes les autres clés du plugin sont ≥ 0.19) ; la prochaine clé sous 0.005 devra en choisir
// une autre plutôt que de collisionner en silence.
const float WX = WorldX + FractalNoise3D(FVector3f(WorldX * 0.04f + VoxelHash::SeedOffset(S, 0.0007f), WorldY * 0.04f, WorldZ * 0.012f), VoxelGenLOD::Eff(3))
* VOXEL_NOISE_SCALE * WarpAmp;
const float WY = WorldY + FractalNoise3D(FVector3f(WorldX * 0.04f + 31.0f, WorldY * 0.04f + 7.0f, WorldZ * 0.012f), VoxelGenLOD::Eff(3))
* VOXEL_NOISE_SCALE * WarpAmp;
// Per-island constants (existence roll, jitter, radius, Z anchor, taper) are pure functions
// of (cell, seed, params) yet were re-hashed PER VOXEL. Bake the 3×3 neighbourhood's islands
// once per centre cell (thread_local); the per-voxel work keeps only the warped-frame
// distance / taper / top-surface math (those genuinely vary per voxel).
struct FLocalIsland { float X, Y, Rxy, TopHalf, TopZ, BotZ, TaperEnd; };
thread_local TArray<FLocalIsland, TInlineAllocator<9>> Islands;
thread_local int32 FI_CX = INT32_MAX, FI_CY = INT32_MAX;
thread_local uint32 FI_Seed = 0xFFFFFFFFu;
thread_local float FI_Spacing = -1.0f, FI_Dens = -1.0f, FI_MinR = -1.0f, FI_MaxR = -1.0f,
FI_Thick = -1.0f, FI_VJit = -1.0f, FI_BotZ = FLT_MAX, FI_TopZ = FLT_MAX;
if (CX != FI_CX || CY != FI_CY || S != FI_Seed || Spacing != FI_Spacing ||
Params.IslandDensity != FI_Dens || Params.IslandMinRadius != FI_MinR || Params.IslandMaxRadius != FI_MaxR ||
Params.ThicknessRatio != FI_Thick || Params.VerticalJitter != FI_VJit ||
Params.StrateBottomWorldZ != FI_BotZ || Params.StrateTopWorldZ != FI_TopZ)
{
FI_CX = CX; FI_CY = CY; FI_Seed = S; FI_Spacing = Spacing;
FI_Dens = Params.IslandDensity; FI_MinR = Params.IslandMinRadius; FI_MaxR = Params.IslandMaxRadius;
FI_Thick = Params.ThicknessRatio; FI_VJit = Params.VerticalJitter;
FI_BotZ = Params.StrateBottomWorldZ; FI_TopZ = Params.StrateTopWorldZ;
Islands.Reset();
for (int32 dy = -1; dy <= 1; dy++)
for (int32 dx = -1; dx <= 1; dx++)
{
const int32 nx = CX + dx, ny = CY + dy;
const uint32 Hh = VoxelHash::Cell(nx, ny, S);
if (VoxelHash::ToFloat01(Hh) > Params.IslandDensity) continue;
const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x12345678u));
const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x9ABCDEF0u));
FLocalIsland& Isl = Islands.AddDefaulted_GetRef();
Isl.X = (nx + 0.15f + JX * 0.7f) * Spacing;
Isl.Y = (ny + 0.15f + JY * 0.7f) * Spacing;
Isl.Rxy = FMath::Lerp(Params.IslandMinRadius, Params.IslandMaxRadius,
VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x5A5Au)));
// ASYMMETRIC ISLAND PROFILE: a fairly flat land slab on TOP, and an underside that
// tapers DOWN to a rough point (the hanging "roots"). ThicknessRatio scales how deep
// the underside hangs. This is what reads as a floating island vs. a sphere.
Isl.TopHalf = Isl.Rxy * 0.20f; // land slab above centre
const float UnderDepth = Isl.Rxy * FMath::Max(Params.ThicknessRatio, 0.25f); // tapering underside
const float SpreadZ = FMath::Max(H * 0.5f - FMath::Max(Isl.TopHalf, UnderDepth) - Params.BoundarySealThickness, 0.0f)
* Params.VerticalJitter;
const float Cz = MidZ + VoxelHash::ToFloatSigned(VoxelHash::Mix(Hh ^ 0xB17Du)) * SpreadZ;
Isl.TopZ = Cz + Isl.TopHalf;
Isl.BotZ = Cz - UnderDepth;
// Per-island taper sharpness (SmoothStep end point) for varied silhouettes.
Isl.TaperEnd = FMath::Lerp(0.45f, 0.7f, VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x7A1Eu)));
}
}
for (const FLocalIsland& Isl : Islands)
{
// Horizontal distance in the WARPED (lobed) frame so the outline isn't a circle.
const float Dxw = WX - Isl.X, Dyw = WY - Isl.Y;
const float DistXY = FMath::Sqrt(Dxw * Dxw + Dyw * Dyw);
// Radius envelope by height: full width across the top, narrowing to a point at the
// bottom tip (SmoothStep taper).
const float Hgt = FMath::Clamp((WorldZ - Isl.BotZ) / FMath::Max(Isl.TopZ - Isl.BotZ, 1.0f), 0.0f, 1.0f);
const float Taper = SmoothStep01(FMath::Clamp(Hgt / Isl.TaperEnd, 0.0f, 1.0f));
const float Env = Isl.Rxy * Taper;
// Top surface: flat by default; dome the edges down when TopFlatten < 1.
float TopSurf = Isl.TopZ;
if (Params.TopFlatten < 1.0f)
{
const float Edge = FMath::Clamp(DistXY / FMath::Max(Isl.Rxy, 1.0f), 0.0f, 1.0f);
TopSurf = Isl.TopZ - (1.0f - Params.TopFlatten) * Isl.TopHalf * 2.0f * Edge * Edge;
}
// Pseudo-SDF: outside if beyond the radial envelope OR above the top surface.
const float Sdf = FMath::Max(DistXY - Env, WorldZ - TopSurf);
IslandSDF = VoxelSDF::SmoothMin(IslandSDF, Sdf, BlendK);
}
// Craggy shells.
if (Params.SurfaceRoughness > 0.0f && IslandSDF < Params.SurfaceRoughness + BlendK + 2.0f)
{
IslandSDF += FractalNoise3D(FVector3f(WorldX * 0.08f, WorldY * 0.08f, WorldZ * 0.08f), VoxelGenLOD::Eff(4))
* VOXEL_NOISE_SCALE * Params.SurfaceRoughness;
}
// Fill solid inside islands.
const float Blend = BlendK;
if (IslandSDF < Blend)
{
float Fill = FMath::Clamp((Blend - IslandSDF) / (Blend * 2.0f), 0.0f, 1.0f);
Fill = SmoothStep01(Fill);
Density += Fill * Params.BaseDensity * 2.0f;
}
ApplyOriginSpine(Density, WorldX, WorldY, WorldZ,
Params.StrateTopWorldZ, Params.StrateBottomWorldZ,
Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius);
ApplyBoundarySeal(Density, WorldZ,
Params.StrateTopWorldZ, Params.StrateBottomWorldZ,
Params.BoundarySealThickness, Params.BaseDensity);
if (StrateManager)
{
StrateManager->ApplyPassageModifier(
Density, WorldX, WorldY, WorldZ,
Params.BaseDensity, Params.BoundarySealThickness);
}
ApplyOriginLandingFloor(Density, WorldX, WorldY, WorldZ,
Params.StrateTopWorldZ, Params.StrateBottomWorldZ,
Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius);
VF_ApplyXYEdgeSeal(Density, WorldX, WorldY,
WorldRadiusVoxels, EdgeSealThickness, Params.BaseDensity);
return -Density;
}