diff --git a/OPSTACK-PROGRESS.md b/OPSTACK-PROGRESS.md index 9f6a026..e935771 100644 --- a/OPSTACK-PROGRESS.md +++ b/OPSTACK-PROGRESS.md @@ -1337,3 +1337,48 @@ referenced against the cached path), biome blending as the `Mask` combiner, and reuses `GSurfColCache`. `§C1` still open. --- + +## 2026-07-27 — step 2b: the overhang, the one op that could NOT live in height space. + +Step 2a came back green on all four counts, including the density-side bridge +(`FSurfaceColumnSource` bit-identical to `GetSurfaceDensity` over 20 000 samples). + +**`FOverhangShelfMod` is the boundary 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 — it is the only SurfaceWorld op that could not have gone into `VoxelHeightOp.h`. The +line between the two spaces falls where the code changes nature, not where it was convenient. + +**The per-column problem, and how it is solved.** The overhang needs `TerrainZ` plus a per-column +gate (`OverhangAmp`, `DirX`, `DirY`) that the source computes. Three ways to get it, two bad: +- recompute the height stack per voxel — correct but pays the cliff's four resamples per lip voxel; +- add a third channel to `FVoxelOpSample` — a per-voxel slot for a **column** property, and + archetype-specific pollution of a shared contract (`§11` has this open, unresolved); +- **chosen:** the source memoises the column and the overhang reads it, same as `cliff → structural`. + +**The memo key is the part worth getting right.** Keyed on `(InstanceId, X, Y)` where `InstanceId` +comes from a monotonic atomic counter — **not** on `this`. A freed stack and a newly allocated one +can share an address; a counter that never goes backwards cannot collide. Since the stack evaluates +every Z of a column at the same XY, the hit rate is ~1, so this also recovers the per-column reuse +without inventing a second cross-chunk cache. + +**Two functions exposed** (`ComputeSurfaceColumn`, `SurfaceDensityFromColumn`) because they are the +**only** oracle for the overhang — `GetSurfaceDensity` passes `OverhangAmp = 0` and computes none. +Their private declarations were removed; two declarations of one member will not compile. + +**The test's third pass is written to avoid measuring nothing.** A uniform Z draw over the whole +strate would almost never land in the overhang window, so the test would pass green having never run +the op — the same trap as `WaterLevelRelative` in the height pass. Half the samples are now placed +*inside* the window deliberately, the count is reported, and it warns if it is zero. + +**Still missing before wiring: BIOME BLENDING.** The ground is evaluated for the dominant biome and +lerped toward the neighbour — the `Mask` combiner, and `§5` calls it the Phase 3 prototype. **Do not +tick `bUseOperatorStack` on a SurfaceWorld strate with biomes until then.** + +**UNVERIFIED:** step 2b is not compiled. Likely spots: `std::atomic` include; `HFractal3D` newly +added to the density TU; `MakeUnique` then `MoveTemp` into the stack while +keeping a raw pointer; and the two newly-public generator methods. + +**Next single action:** build. Then biome blending as the `Mask` combiner + the wiring that reuses +`GSurfColCache`. `§C1` still open. + +--- diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeHeightStackTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeHeightStackTest.cpp index c9bd1c7..d017315 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeHeightStackTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeHeightStackTest.cpp @@ -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 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; } diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index c83126d..77b26f4 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -28,8 +28,19 @@ #include "VoxelStrateManager.h" // EvaluateModifierSDF / AnyPassageNearBox #include "VoxelTypes.h" // SmoothStep01, VOXEL_NOISE_SCALE +#include // 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 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 ColumnSource = MakeUnique(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(P, Seed, ColumnPtr)); OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager); diff --git a/Source/VoxelForge/Public/VoxelGenerator.h b/Source/VoxelForge/Public/VoxelGenerator.h index fbeceef..ccf836d 100644 --- a/Source/VoxelForge/Public/VoxelGenerator.h +++ b/Source/VoxelForge/Public/VoxelGenerator.h @@ -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& 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& 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& 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,