feat: SurfaceWorld step 2b — the overhang shelf, the one op that is genuinely 3D

Step 2a green on all four counts, including FSurfaceColumnSource bit-identical to
GetSurfaceDensity over 20000 samples.

FOverhangShelfMod is the case that justifies where the two spaces were split: its
uphill reach grows with altitude (Frac = (Z - TerrainZ) / OverhangHeight), so it is
essentially Z-dependent and could not have lived in VoxelHeightOp.h. The boundary
falls where the code changes nature.

It needs TerrainZ plus a per-column gate (amp, uphill dir) the source computes.
Recomputing per voxel would pay the cliff's four resamples per lip voxel; adding a
third channel to FVoxelOpSample would put a COLUMN property in a per-voxel slot and
pollute a shared contract (section 11 has that open). Instead the source memoises
the column and the overhang reads it — the same shape as cliff -> structural.

The memo is keyed on (InstanceId, X, Y) with InstanceId from a monotonic atomic
counter, not on `this`: a freed stack and a newly allocated one can share an address,
a never-decreasing counter cannot collide. The stack evaluates every Z of a column at
one XY, so the hit rate is ~1 and this recovers per-column reuse without inventing a
second cross-chunk cache.

ComputeSurfaceColumn and SurfaceDensityFromColumn are now public: they are the only
oracle for the overhang, since GetSurfaceDensity passes OverhangAmp = 0. Private
declarations removed.

The test's third pass places half its samples inside the overhang window on purpose —
a uniform Z draw would almost never hit it and the test would pass having never run
the op, the same trap as WaterLevelRelative in the height pass. The in-window count is
reported and warns at zero.

Still missing before wiring: biome blending (the Mask combiner, section 5's Phase 3
prototype). Do not tick bUseOperatorStack on a SurfaceWorld strate with biomes yet.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 16:23:27 +02:00
parent 7cd2bed237
commit 4dc55b1af3
4 changed files with 368 additions and 37 deletions
@@ -304,15 +304,21 @@ bool FVoxelForgeHeightStackTest::RunTest(const FString& Parameters)
// de biomes. Tous deux arrivent à l'étape 2b, avec le chemin CACHÉ pour référence — c'est le
// seul qui les calcule.
{
const FSurfaceGenerationParams& P = AllOps;
// ⚠️ `OverhangStrength = 0` EXPLICITEMENT : `GetSurfaceDensity` passe `OverhangAmp = 0`,
// donc il n'en calcule aucun. Comparer une pile qui en produit à une référence qui n'en
// produit pas ferait échouer le test pour la seule raison que la référence est incomplète.
// L'overhang a sa propre passe juste en dessous, avec le bon oracle.
FSurfaceGenerationParams P = AllOps;
P.OverhangStrength = 0.0f;
FVoxelOpStack Stack;
VoxelDensityOps::BuildSurfaceStack(Stack, P, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
// 1 source + 3 structurels. Pas encore d'overhang : voir l'avertissement ci-dessus.
TestEqual(TEXT("the surface density stack is source + 3 structural (no overhang yet)"),
Stack.Num(), 4);
// 1 source + 1 overhang + 3 structurels. L'op overhang est présent mais inerte ici
// (amp 0 ⇒ sortie immédiate) — la décomposition ne change pas selon les params.
TestEqual(TEXT("the surface density stack is source + overhang + 3 structural"),
Stack.Num(), 5);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
@@ -367,6 +373,113 @@ bool FVoxelForgeHeightStackTest::RunTest(const FString& Parameters)
NumSideDisagree, 0);
}
//=========================================================================
// ÉTAPE 2b — L'OVERHANG, contre le SEUL oracle qui le calcule
//=========================================================================
// `GetSurfaceDensity` passe `OverhangAmp = 0`. La seule référence est donc le chemin caché :
// `ComputeSurfaceColumn` (qui résout le gate et la direction amont par colonne) suivi de
// `SurfaceDensityFromColumn` (qui applique l'union par voxel). Les deux viennent d'être
// exposées pour ça.
//
// C'est aussi la passe qui vérifie le MÉMO DE COLONNE de `FSurfaceColumnSource` : l'op overhang
// lit la colonne produite par la source, et s'ils divergeaient d'un XY, l'union se ferait au
// mauvais endroit. Un mémo mal clé se verrait ici.
{
FSurfaceGenerationParams P = AllOps;
P.OverhangStrength = 0.8f;
P.OverhangSlopeThreshold = 0.12f;
P.OverhangHeight = 14.0f;
P.OverhangReach = 10.0f;
P.OverhangFrequency = 0.05f;
P.OverhangZScale = 0.6f;
FVoxelOpStack Stack;
VoxelDensityOps::BuildSurfaceStack(Stack, P, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
Ctx.StrateTopWorldZ = P.StrateTopWorldZ;
Ctx.StrateBottomWorldZ = P.StrateBottomWorldZ;
Stack.PrepareChunk(Ctx);
// Pas de biomes : contexte vide ⇒ ComputeSurfaceColumn retombe sur BaseSurface pour les
// deux côtés, poids 0. C'est exactement ce que la pile fait aujourd'hui.
FBiomeContext EmptyCtx;
TArray<FSurfaceGenerationParams> NoBiomeParams;
FChunkBiomeCache BiomeCache;
int32 NumDiff = 0, NumSideDisagree = 0, NumInWindow = 0;
float WorstDelta = 0.0f;
FRandomStream Rng(1337);
for (int32 i = 0; i < NumHeightSamples; ++i)
{
const float X = (float)Rng.RandRange(-4 * CHUNK_SIZE, 4 * CHUNK_SIZE);
const float Y = (float)Rng.RandRange(-4 * CHUNK_SIZE, 4 * CHUNK_SIZE);
float TerrainZ = 0.0f, CeilSurf = 0.0f, Amp = 0.0f, DirX = 0.0f, DirY = 0.0f;
Gen->ComputeSurfaceColumn(X, Y, MidChunkZ, P, EmptyCtx, NoBiomeParams, BiomeCache,
TerrainZ, CeilSurf, Amp, DirX, DirY);
// Échantillonner DANS la fenêtre d'overhang la moitié du temps : un tirage uniforme sur
// toute la strate la raterait presque toujours, et le test serait vert sans avoir
// exercé l'op une seule fois — le même piège que `WaterLevelRelative` plus haut.
float Z;
if ((i & 1) && Amp > 0.0f)
{
Z = TerrainZ + P.OverhangHeight * ((float)(i % 97) / 97.0f);
++NumInWindow;
}
else
{
Z = (float)Rng.RandRange(BottomVoxelZ, TopVoxelZ);
}
const float Old = Gen->SurfaceDensityFromColumn(X, Y, Z, TerrainZ, CeilSurf,
Amp, DirX, DirY, P);
const float New = Stack.EvalMC(X, Y, Z);
if (!BitEqual(Old, New))
{
++NumDiff;
WorstDelta = FMath::Max(WorstDelta, FMath::Abs(Old - New));
}
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSideDisagree; }
}
if (NumDiff == 0)
{
AddInfo(FString::Printf(
TEXT("Overhang: bit-identical to SurfaceDensityFromColumn across %d samples, %d of ")
TEXT("them deliberately inside the overhang window. The per-column memo hands the ")
TEXT("source's column to the overhang op correctly."),
NumHeightSamples, NumInWindow));
}
else
{
AddError(FString::Printf(
TEXT("Overhang: %d of %d samples differ (largest |delta| %.9g); %d cross the ")
TEXT("isosurface; %d samples were inside the window. Check, in order: the column ")
TEXT("memo key (does the overhang op see the SAME column as the source?), the ")
TEXT("window gate (Z > TerrainZ && Z <= TerrainZ + OverhangHeight), Frac and the ")
TEXT("ShiftV > 0.5 threshold, the shelf noise offsets (17.3/23.9/5.1 with the ")
TEXT("OverhangZScale Z term), and that the shift resamples the STRUCTURAL height."),
NumDiff, NumHeightSamples, WorstDelta, NumSideDisagree, NumInWindow));
}
TestEqual(TEXT("overhang: no sample lands on the opposite side of the isosurface"),
NumSideDisagree, 0);
if (NumInWindow == 0)
{
AddWarning(TEXT("No sample landed inside the overhang window, so the op was never ")
TEXT("actually exercised. Raise OverhangStrength or lower ")
TEXT("OverhangSlopeThreshold until this is well above zero."));
}
}
return true;
}
+182 -17
View File
@@ -28,8 +28,19 @@
#include "VoxelStrateManager.h" // EvaluateModifierSDF / AnyPassageNearBox
#include "VoxelTypes.h" // SmoothStep01, VOXEL_NOISE_SCALE
#include <atomic> // l'id d'instance non recyclé du mémo de colonne
namespace
{
/** La même enveloppe que `FractalNoise3D` de VoxelGenerator.cpp (qui y est `static`, donc
* invisible ici). Le détour par `FVector` est délibéré — voir l'en-tête de ce fichier. */
FORCEINLINE float HFractal3D(const FVector& Position, int32 Octaves = 4,
float Lacunarity = 2.0f, float Persistence = 0.5f)
{
return VoxelNoise::FBM((float)Position.X, (float)Position.Y, (float)Position.Z,
Octaves, Lacunarity, Persistence);
}
//=========================================================================
// RÔLE 1 — SOURCE : ROC CONSTANT / CONSTANT ROCK
//=========================================================================
@@ -406,12 +417,84 @@ namespace
class FSurfaceColumnSource final : public IVoxelDensityOp
{
public:
FSurfaceColumnSource(const FSurfaceGenerationParams& P, int32 Seed)
FSurfaceColumnSource(const FSurfaceGenerationParams& InP, int32 Seed)
: P(InP)
{
VoxelHeightOps::BuildSurfaceHeightStack(TerrainStack, P, Seed);
VoxelHeightOps::BuildSurfaceCeilingStack(CeilingStack, P, Seed);
// Construite à la main (pas via BuildSurfaceHeightStack) pour GARDER le pointeur vers la
// source structurelle : l'overhang en a besoin, pour son gradient de pente comme pour
// son ré-échantillonnage amont. Même dépendance que le cliff, même raison.
TerrainStack.Add(VoxelHeightOps::MakeStructuralHeightSource(InP, Seed, &Structural));
TerrainStack.Add(VoxelHeightOps::MakeCliffHeightMod(InP, Structural));
TerrainStack.Add(VoxelHeightOps::MakeTerraceHeightMod(InP));
TerrainStack.Add(VoxelHeightOps::MakeLayerLineHeightMod(InP));
TerrainStack.Add(VoxelHeightOps::MakeBeachHeightMod(InP));
VoxelHeightOps::BuildSurfaceCeilingStack(CeilingStack, InP, Seed);
// Identité unique et NON RECYCLÉE. Clé du mémo par colonne ci-dessous : `this` ne
// suffirait pas — une pile détruite puis une autre allouée à la même adresse avec
// d'autres params donnerait un faux positif silencieux. Un compteur qui ne redescend
// jamais rend ça impossible.
// A unique, never-recycled id: `this` would allow a freed-then-reallocated stack to
// collide with the previous one's memo. A monotonic counter cannot.
static std::atomic<uint64> NextId{ 1 };
InstanceId = NextId.fetch_add(1, std::memory_order_relaxed);
}
/** La colonne complète, exactement les cinq sorties de `ComputeSurfaceColumn`.
* Mémoïsée par (instance, X, Y) : la pile évalue tous les Z d'une colonne au même XY, donc
* le taux de succès est ~1 et l'overhang lit la MÊME colonne que la source, par
* construction plutôt que par convention. */
struct FColumn { float TerrainZ, CeilSurf, OverhangAmp, DirX, DirY; };
const FColumn& GetColumn(float WorldX, float WorldY) const
{
thread_local FColumn C{};
thread_local uint64 CachedId = 0;
thread_local float CachedX = FLT_MAX, CachedY = FLT_MAX;
if (CachedId != InstanceId || CachedX != WorldX || CachedY != WorldY)
{
CachedId = InstanceId; CachedX = WorldX; CachedY = WorldY;
C.TerrainZ = TerrainStack.EvalHeight(WorldX, WorldY);
C.CeilSurf = CeilingStack.EvalHeight(WorldX, WorldY);
C.OverhangAmp = 0.0f; C.DirX = 0.0f; C.DirY = 0.0f;
// Gate d'overhang par colonne : pente issue d'une différence AVANT du champ
// STRUCTUREL, à l'échelle de la portée mais CLAMPÉE à [4,16]. Sans ce clamp, une
// grande `Reach` moyenne la pente sur une énorme portée et lit même une vraie
// falaise comme plate — le bug « grande Reach = rien ». Transcrit tel quel.
if (P.OverhangStrength > 0.0f && Structural != nullptr)
{
const float SD = FMath::Clamp(P.OverhangReach, 4.0f, 16.0f);
const float Z0 = SampleStructural(WorldX, WorldY);
const float GX = (SampleStructural(WorldX + SD, WorldY) - Z0) / SD;
const float GY = (SampleStructural(WorldX, WorldY + SD) - Z0) / SD;
const float Slope = FMath::Sqrt(GX * GX + GY * GY);
const float Thr = FMath::Max(P.OverhangSlopeThreshold, 0.05f);
const float Gate = FMath::Clamp((Slope - Thr) / Thr, 0.0f, 1.0f);
C.OverhangAmp = P.OverhangStrength * Gate; // [0,1]
// Direction amont unitaire (le gradient pointe vers le haut). Dégénérée sur le
// plat — mais l'amplitude y vaut 0 de toute façon.
if (Slope > KINDA_SMALL_NUMBER) { C.DirX = GX / Slope; C.DirY = GY / Slope; }
}
}
return C;
}
/** Le champ structurel nu — l'overhang s'en sert pour emprunter la roche amont. */
float SampleStructural(float WorldX, float WorldY) const
{
FVoxelHeightSample S;
Structural->Eval(WorldX, WorldY, S);
return S.Height;
}
const FSurfaceGenerationParams& GetParams() const { return P; }
EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; }
void PrepareChunk(const FVoxelOpContext&) override {}
bool IsXYPure() const override { return false; } // voir le bloc ci-dessus
@@ -426,11 +509,10 @@ namespace
// existant plutôt que d'en inventer un second.
// No per-column memo here on purpose: T1.a already exists one level up, and a second
// cache key is a second thing to get wrong.
const float TerrainZ = TerrainStack.EvalHeight(WorldX, WorldY);
const float CeilSurf = CeilingStack.EvalHeight(WorldX, WorldY);
const FColumn& C = GetColumn(WorldX, WorldY);
float Density = TerrainZ - WorldZ;
Density = FMath::Max(Density, WorldZ - CeilSurf);
float Density = C.TerrainZ - WorldZ;
Density = FMath::Max(Density, WorldZ - C.CeilSurf);
InOut.Density = Density; // Replace : interne, positif = solide
}
@@ -445,8 +527,89 @@ namespace
}
private:
FSurfaceGenerationParams P;
FVoxelHeightStack TerrainStack;
FVoxelHeightStack CeilingStack;
const IVoxelHeightOp* Structural = nullptr; // NON possédant : la pile terrain le possède
uint64 InstanceId = 0;
};
//=========================================================================
// RÔLE 3 — MODIFIER : ÉTAGÈRE D'OVERHANG / OVERHANG SHELF (le seul op vraiment 3D)
//=========================================================================
// Pour les voxels d'AIR dans une fenêtre juste au-dessus d'une pente raide, ré-échantillonne le
// heightfield EN AMONT (vers la falaise) d'une distance qui CROÎT avec la hauteur, et fait
// l'union de cette roche → la roche du haut de falaise déborde AU-DESSUS du vide, avec de l'air
// EN DESSOUS : un vrai surplomb.
//
// ⚠️ Il dépend de Z de façon essentielle — `Frac` fait varier la portée avec l'altitude. C'est
// le seul op de SurfaceWorld qui ne pouvait PAS vivre en espace-hauteur, et c'est exactement
// pour ça que la frontière entre les deux espaces est utile : elle est passée là où le code
// change de nature, pas là où c'était commode.
//
// The one op here that genuinely depends on Z (the uphill reach grows with height), which is
// precisely why it could not live in height space. The boundary between the two spaces falls
// where the code changes nature.
class FOverhangShelfMod final : public IVoxelDensityOp
{
public:
FOverhangShelfMod(const FSurfaceGenerationParams& InP, int32 Seed,
const FSurfaceColumnSource* InColumn)
: P(InP), SeedF((float)Seed), Column(InColumn) {}
EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; }
void PrepareChunk(const FVoxelOpContext&) override {}
bool IsXYPure() const override { return false; } // franchement non : voir `Frac`
void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override
{
if (Column == nullptr || P.OverhangHeight <= 0.0f) { return; }
// Même colonne que la source, garantie par le mémo : pas une seconde évaluation.
const FSurfaceColumnSource::FColumn& C = Column->GetColumn(WorldX, WorldY);
if (C.OverhangAmp <= 0.0f) { return; }
// Gate dur, transcrit : seulement les voxels d'air dans `OverhangHeight` du sol local.
if (!(WorldZ > C.TerrainZ && WorldZ <= C.TerrainZ + P.OverhangHeight)) { return; }
const float f = P.OverhangFrequency;
// Bruit de forme d'étagère [0,1] ; le terme en Z fait onduler la portée avec la hauteur
// (déchiqueté, pas une lèvre lisse).
const float Ns = HFractal3D(FVector(
WorldX * f + SeedF * 17.3f,
WorldY * f + SeedF * 23.9f,
WorldZ * f * P.OverhangZScale + SeedF * 5.1f), 3) * 0.5f + 0.5f; // [0,1]
// LA CLÉ : la portée amont CROÎT avec la hauteur dans la fenêtre (Frac : 0 au sol → 1
// au plafond de la fenêtre). En bas le décalage est minuscule ⇒ on emprunte de la roche
// basse voisine ⇒ ça reste de l'AIR au-dessus du vide ; en haut le décalage atteint la
// falaise ⇒ solide ⇒ la lèvre se pose dessus avec de l'air DESSOUS = un vrai surplomb.
const float Frac = (WorldZ - C.TerrainZ) / P.OverhangHeight;
const float ShiftV = P.OverhangReach * C.OverhangAmp * Frac * Ns;
if (ShiftV > 0.5f)
{
// On emprunte la hauteur STRUCTURELLE amont (pas la surface complète avec ops) :
// le dessous de l'étagère n'a pas besoin du raffinement cliff/terrace, et ça évite
// de relancer les 4 resamples du cliff par voxel de lèvre.
const float ShiftedTZ = Column->SampleStructural(WorldX + C.DirX * ShiftV,
WorldY + C.DirY * ShiftV);
InOut.Density = FMath::Max(InOut.Density, ShiftedTZ - WorldZ); // union
}
}
// N'ajoute que du solide (`Max`) ⇒ tue AllAir, jamais AllSolid. Conservateur : on ne sait
// pas sans échantillonner si une colonne d'overhang touche la boîte, donc FillOnly partout
// où l'archétype peut en produire, Identity quand il est éteint.
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
{
return (P.OverhangStrength > 0.0f && P.OverhangHeight > 0.0f)
? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity;
}
private:
FSurfaceGenerationParams P;
float SeedF;
const FSurfaceColumnSource* Column; // NON possédant : la pile possède la source
};
//=========================================================================
@@ -876,17 +1039,19 @@ namespace VoxelDensityOps
void BuildSurfaceStack(FVoxelOpStack& OutStack, const FSurfaceGenerationParams& P,
int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager)
{
// ⚠️ PAS ENCORE L'ARCHÉTYPE COMPLET, et il faut le savoir avant de brancher :
// • pas d'OVERHANG — `FOverhangShelfMod`, le seul op vraiment 3D d'ici, a besoin d'une
// donnée PAR COLONNE (amp + direction amont) que `GetSurfaceDensity` ne calcule même
// pas (il passe `OverhangAmp = 0`). Sa référence est le chemin caché, pas celui-ci ;
// • pas de MÉLANGE DE BIOMES — le sol est évalué pour le biome dominant puis lerpé vers
// le voisin. En termes de pile c'est le combiner `Mask`, et c'est le prototype de la
// Phase 3 (§5) : ça mérite sa propre étape, pas un paramètre de plus ici.
// ⚠️ PAS ENCORE L'ARCHÉTYPE COMPLET : il manque le **MÉLANGE DE BIOMES** — le sol est
// évalué pour le biome dominant puis lerpé vers le voisin. En termes de pile c'est le
// combiner `Mask`, et `§5` en fait le prototype de la Phase 3 : ça mérite sa propre étape,
// pas un paramètre de plus ici. **Ne pas brancher dans un monde à biomes avant.**
//
// Donc cette pile == `GetSurfaceDensity` exactement, qui est la version SANS overhang et
// SANS biomes. C'est ce que le test compare, et c'est pour ça que la comparaison est nette.
OutStack.Add(MakeSurfaceColumnSource(P, Seed));
// L'overhang, lui, est là depuis l'étape 2b.
TUniquePtr<FSurfaceColumnSource> ColumnSource = MakeUnique<FSurfaceColumnSource>(P, Seed);
const FSurfaceColumnSource* ColumnPtr = ColumnSource.Get();
OutStack.Add(MoveTemp(ColumnSource));
// L'overhang lit la colonne de la source (mémo partagé, même XY par construction). Même
// motif que cliff → structural : un modificateur qui a besoin de ce que la source a produit.
OutStack.Add(MakeUnique<FOverhangShelfMod>(P, Seed, ColumnPtr));
OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ,
P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager);
+24 -16
View File
@@ -188,6 +188,26 @@ public:
*/
float ComputeSurfaceTerrainZ(float WorldX, float WorldY, const FSurfaceGenerationParams& Params) const;
/**
* La colonne de surface : terrain Z, plafond, et le gate d'OVERHANG résolu par colonne
* (amplitude + direction amont). PUBLIQUES toutes deux pour la même raison que ci-dessus :
* c'est le seul chemin qui calcule l'overhang — `GetSurfaceDensity` passe `OverhangAmp = 0` —
* donc c'est la seule référence possible pour `FOverhangShelfMod`.
* Public because this is the ONLY path that computes the overhang (GetSurfaceDensity passes 0),
* so it is the only possible reference for the ported op.
*/
void 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;
/** Le combine par voxel : colonne → densité, overhang compris, puis le post structurel. */
float SurfaceDensityFromColumn(float WorldX, float WorldY, float WorldZ,
float TerrainZ, float CeilSurf,
float OverhangAmp, float DirX, float DirY,
const FSurfaceGenerationParams& S) const;
/**
* Moisture field at a world XY → [0,1]. The second climate axis for biome placement.
*/
@@ -299,22 +319,10 @@ private:
FSurfaceGenerationParams& OutSurface, FBiomeContext& OutBiomeCtx,
TArray<FSurfaceGenerationParams>& OutBiomeParams) const;
/** Biome-blended terrain Z + sky-cap ceiling Z for one column (the XY-only surface field). Shared
* by the density column cache (T1.a) and the oracle. F20 phase 2 overhang, resolved per column:
* `OutOverhangAmp` = strength·slope-gate (0 = off), `(OutDirX,OutDirY)` = unit UPHILL gradient dir. */
void 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;
/** Final SurfaceWorld density from a column's precomputed terrain Z + ceiling: the cheap per-voxel
* Z-combine + F20 overhang shelf (warped-terrain union, uphill dir) + origin spine + seal + passages.
* The XY-only work (terrain/ceiling/overhang amp+dir) is done once per column and cached (T1.a). */
float SurfaceDensityFromColumn(float WorldX, float WorldY, float WorldZ,
float TerrainZ, float CeilSurf,
float OverhangAmp, float DirX, float DirY,
const FSurfaceGenerationParams& Structural) const;
// ComputeSurfaceColumn et SurfaceDensityFromColumn ont été DÉPLACÉES en `public` (voir plus
// haut) : c'est le seul chemin qui calcule l'overhang, donc la seule référence possible pour
// VoxelForge.OpStack.SurfaceHeightEquivalence. Une seule déclaration chacune.
// Moved to public above — the only path that computes the overhang, hence the only oracle.
/** (Re)build the per-chunk biome cell grid covering chunk (X,Y) footprint + margin. */
void RebuildBiomeGrid(int32 ChunkX, int32 ChunkY, int32 ChunkZ,