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
+45
View File
@@ -1337,3 +1337,48 @@ referenced against the cached path), biome blending as the `Mask` combiner, and
reuses `GSurfColCache`. `§C1` still open. 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<FSurfaceColumnSource>` 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.
---
@@ -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 // de biomes. Tous deux arrivent à l'étape 2b, avec le chemin CACHÉ pour référence — c'est le
// seul qui les calcule. // 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; FVoxelOpStack Stack;
VoxelDensityOps::BuildSurfaceStack(Stack, P, World.Settings->Seed, VoxelDensityOps::BuildSurfaceStack(Stack, P, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get()); Gen->OriginSpineRadius, World.StrateManager.Get());
// 1 source + 3 structurels. Pas encore d'overhang : voir l'avertissement ci-dessus. // 1 source + 1 overhang + 3 structurels. L'op overhang est présent mais inerte ici
TestEqual(TEXT("the surface density stack is source + 3 structural (no overhang yet)"), // (amp 0 ⇒ sortie immédiate) — la décomposition ne change pas selon les params.
Stack.Num(), 4); TestEqual(TEXT("the surface density stack is source + overhang + 3 structural"),
Stack.Num(), 5);
FVoxelOpContext Ctx; FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed; Ctx.Seed = (uint32)World.Settings->Seed;
@@ -367,6 +373,113 @@ bool FVoxelForgeHeightStackTest::RunTest(const FString& Parameters)
NumSideDisagree, 0); 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; return true;
} }
+182 -17
View File
@@ -28,8 +28,19 @@
#include "VoxelStrateManager.h" // EvaluateModifierSDF / AnyPassageNearBox #include "VoxelStrateManager.h" // EvaluateModifierSDF / AnyPassageNearBox
#include "VoxelTypes.h" // SmoothStep01, VOXEL_NOISE_SCALE #include "VoxelTypes.h" // SmoothStep01, VOXEL_NOISE_SCALE
#include <atomic> // l'id d'instance non recyclé du mémo de colonne
namespace 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 // RÔLE 1 — SOURCE : ROC CONSTANT / CONSTANT ROCK
//========================================================================= //=========================================================================
@@ -406,12 +417,84 @@ namespace
class FSurfaceColumnSource final : public IVoxelDensityOp class FSurfaceColumnSource final : public IVoxelDensityOp
{ {
public: public:
FSurfaceColumnSource(const FSurfaceGenerationParams& P, int32 Seed) FSurfaceColumnSource(const FSurfaceGenerationParams& InP, int32 Seed)
: P(InP)
{ {
VoxelHeightOps::BuildSurfaceHeightStack(TerrainStack, P, Seed); // Construite à la main (pas via BuildSurfaceHeightStack) pour GARDER le pointeur vers la
VoxelHeightOps::BuildSurfaceCeilingStack(CeilingStack, P, Seed); // 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; } EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; }
void PrepareChunk(const FVoxelOpContext&) override {} void PrepareChunk(const FVoxelOpContext&) override {}
bool IsXYPure() const override { return false; } // voir le bloc ci-dessus 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. // 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 // 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. // cache key is a second thing to get wrong.
const float TerrainZ = TerrainStack.EvalHeight(WorldX, WorldY); const FColumn& C = GetColumn(WorldX, WorldY);
const float CeilSurf = CeilingStack.EvalHeight(WorldX, WorldY);
float Density = TerrainZ - WorldZ; float Density = C.TerrainZ - WorldZ;
Density = FMath::Max(Density, WorldZ - CeilSurf); Density = FMath::Max(Density, WorldZ - C.CeilSurf);
InOut.Density = Density; // Replace : interne, positif = solide InOut.Density = Density; // Replace : interne, positif = solide
} }
@@ -445,8 +527,89 @@ namespace
} }
private: private:
FSurfaceGenerationParams P;
FVoxelHeightStack TerrainStack; FVoxelHeightStack TerrainStack;
FVoxelHeightStack CeilingStack; 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, void BuildSurfaceStack(FVoxelOpStack& OutStack, const FSurfaceGenerationParams& P,
int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager) int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager)
{ {
// ⚠️ PAS ENCORE L'ARCHÉTYPE COMPLET, et il faut le savoir avant de brancher : // ⚠️ PAS ENCORE L'ARCHÉTYPE COMPLET : il manque le **MÉLANGE DE BIOMES** — le sol est
// • pas d'OVERHANG — `FOverhangShelfMod`, le seul op vraiment 3D d'ici, a besoin d'une // évalué pour le biome dominant puis lerpé vers le voisin. En termes de pile c'est le
// donnée PAR COLONNE (amp + direction amont) que `GetSurfaceDensity` ne calcule même // combiner `Mask`, et `§5` en fait le prototype de la Phase 3 : ça mérite sa propre étape,
// pas (il passe `OverhangAmp = 0`). Sa référence est le chemin caché, pas celui-ci ; // pas un paramètre de plus ici. **Ne pas brancher dans un monde à biomes avant.**
// • 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.
// //
// Donc cette pile == `GetSurfaceDensity` exactement, qui est la version SANS overhang et // L'overhang, lui, est là depuis l'étape 2b.
// SANS biomes. C'est ce que le test compare, et c'est pour ça que la comparaison est nette. TUniquePtr<FSurfaceColumnSource> ColumnSource = MakeUnique<FSurfaceColumnSource>(P, Seed);
OutStack.Add(MakeSurfaceColumnSource(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, OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ,
P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager); 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; 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. * Moisture field at a world XY → [0,1]. The second climate axis for biome placement.
*/ */
@@ -299,22 +319,10 @@ private:
FSurfaceGenerationParams& OutSurface, FBiomeContext& OutBiomeCtx, FSurfaceGenerationParams& OutSurface, FBiomeContext& OutBiomeCtx,
TArray<FSurfaceGenerationParams>& OutBiomeParams) const; TArray<FSurfaceGenerationParams>& OutBiomeParams) const;
/** Biome-blended terrain Z + sky-cap ceiling Z for one column (the XY-only surface field). Shared // ComputeSurfaceColumn et SurfaceDensityFromColumn ont été DÉPLACÉES en `public` (voir plus
* by the density column cache (T1.a) and the oracle. F20 phase 2 overhang, resolved per column: // haut) : c'est le seul chemin qui calcule l'overhang, donc la seule référence possible pour
* `OutOverhangAmp` = strength·slope-gate (0 = off), `(OutDirX,OutDirY)` = unit UPHILL gradient dir. */ // VoxelForge.OpStack.SurfaceHeightEquivalence. Une seule déclaration chacune.
void ComputeSurfaceColumn(float WorldX, float WorldY, int32 ChunkZ, // Moved to public above — the only path that computes the overhang, hence the only oracle.
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;
/** (Re)build the per-chunk biome cell grid covering chunk (X,Y) footprint + margin. */ /** (Re)build the per-chunk biome cell grid covering chunk (X,Y) footprint + margin. */
void RebuildBiomeGrid(int32 ChunkX, int32 ChunkY, int32 ChunkZ, void RebuildBiomeGrid(int32 ChunkX, int32 ChunkY, int32 ChunkZ,