From c277931a085f36af2b512166f8e73d99cf98b2e1 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Mon, 27 Jul 2026 16:51:18 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20SurfaceWorld=20complete=20=E2=80=94=20b?= =?UTF-8?q?iome=20blending=20wired,=20guard=20removed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two biome checks added last commit printed nothing on success, so a passing run was indistinguishable from a block that never executed — the exact flaw I flagged twice this session and then wrote myself. Both now report their coverage. Step 2c closes SurfaceWorld: - FSurfaceColumnSource takes per-biome params and an OWNED IVoxelBiomeField. Empty params leaves the original path bit-for-bit unchanged. - The field is owned by the stack rather than borrowed: the adapter points at GetDensityAt's thread_local biome context and cache, and the stack is itself thread_local rebuilt in the same refetch block, so all three live and die together. Structural ownership beats a convention the next reader has to infer. - The overhang amp blends across biomes — Lerp(Amp(PD), Amp(PN), W) with slope and threshold from the dominant only, as ComputeSurfaceColumn does. Interpolating the slope would be meaningless; it measures the terrain rather than configuring it. - FGeneratorBiomeField lives in VoxelGenerator.cpp, on the side that knows the generator. The op sees a capability, never an owner — which is what lets it become an asset in Phase 3. - The no-biome guard is removed from UsesOperatorStackForChunk. Also: the two constructors now delegate to one body with one id counter. The first draft had two competing counters, one tagged with a high bit to avoid collision, which is a smell rather than a design. 5 of 8 archetypes ported: Maze, FlatPlain, CrystalChamber, SurfaceWorld. UNVERIFIED: not compiled. Co-Authored-By: Claude Opus 5 --- OPSTACK-PROGRESS.md | 46 ++++++ .../Tests/VoxelForgeHeightStackTest.cpp | 17 ++ .../Private/VoxelDensityOpStack.cpp | 148 ++++++++++++++---- Source/VoxelForge/Private/VoxelGenerator.cpp | 74 +++++++-- .../VoxelForge/Private/VoxelStrateManager.cpp | 19 +-- .../VoxelForge/Public/VoxelDensityOpStack.h | 21 ++- 6 files changed, 267 insertions(+), 58 deletions(-) diff --git a/OPSTACK-PROGRESS.md b/OPSTACK-PROGRESS.md index 78bf736..14bc051 100644 --- a/OPSTACK-PROGRESS.md +++ b/OPSTACK-PROGRESS.md @@ -1585,3 +1585,49 @@ and the generator-side adapter wraps `ResolveBiomeSampleAt`. That drops the biom `FConstantRockSource` + `FSdfRoughnessMod` + `FSdfCarve` and should be the cheapest port yet. --- + +## 2026-07-27 — SurfaceWorld COMPLETE (biomes included). 5 of 8 archetypes ported. + +All 10 tests green again. **But the two new biome checks printed nothing** — because I wrote them to +report only on failure. **That is the exact flaw I flagged twice in this session** (the +`WaterLevelRelative` early-out, the overhang window count) and then committed myself: a silent pass +is indistinguishable from a check that never ran. Both now `AddInfo` their coverage, so the next run +shows the blend actually executed over 2000 (weight, point) pairs. + +### Step 2c — the density side, and SurfaceWorld is closed + +- **`FSurfaceColumnSource` takes per-biome params + an owned `IVoxelBiomeField`.** Empty params ⇒ + the original path, bit-for-bit unchanged (which is why the existing tests should stay green). +- **The field is OWNED by the stack, not borrowed.** The real adapter points at `GetDensityAt`'s + `thread_local` `CP_BiomeCtx` / `CP_BiomeCache`; the stack is itself `thread_local` and rebuilt in + the *same* refetch block, so all three are born and die together on one thread. Making ownership + structural beats leaving survival to a convention the next reader has to infer. +- **The overhang amp now blends across biomes** — `Lerp(Amp(PD), Amp(PN), W)` with the slope and + threshold from the **dominant** only, exactly as `ComputeSurfaceColumn` does. Interpolating the + *slope* would be meaningless: it is a measurement of the terrain, not a setting. +- **`FGeneratorBiomeField` lives in `VoxelGenerator.cpp`,** on the side that knows the generator. + That is the whole point of the interface — the op sees a *capability*, never an owner, which is + what lets it become an asset in Phase 3. +- **The biome guard is gone** from `UsesOperatorStackForChunk`. + +Also cleaned up while there: the two constructors now **delegate to one body with one id counter** +instead of each initialising separately (two init paths is two places to forget a member — the first +draft already had two competing counters, one of them tagged with a high bit to avoid collision, +which is a smell rather than a design). + +**5 of 8 archetypes ported:** Maze · FlatPlain · CrystalChamber · SurfaceWorld. + +**UNVERIFIED:** not compiled. Likely spots: the delegating constructor; `TUniquePtr` +as a defaulted parameter in the public header; `VoxelHeightOp.h` newly included by +`VoxelDensityOpStack.h` (a public→public include); the `case` block now needing braces for its local +declarations; and `FGeneratorBiomeField` being defined before `UVoxelGenerator`'s member functions +while calling `ResolveBiomeSampleAt`. + +**What to try after the build:** tick `bUseOperatorStack` on a SurfaceWorld strate **with biomes** +now. Both paths compute the same function, so expect it identical — biome borders included, which is +the case that was guarded off until now. + +**Next single action:** build. Then `VerticalShafts` (§6) — it reuses `FConstantRockSource`, +`FSdfRoughnessMod` and `FSdfCarve` unchanged from Maze, so it should be the cheapest of the eight. + +--- diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeHeightStackTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeHeightStackTest.cpp index c22f6a2..4031706 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeHeightStackTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeHeightStackTest.cpp @@ -557,6 +557,18 @@ bool FVoxelForgeHeightStackTest::RunTest(const FString& Parameters) TestEqual(TEXT("biome blend: heights lerp between the two biomes' full stacks, bit-exactly"), NumWrong, 0); + // ⚠️ Rapporter le SUCCÈS, pas seulement l'échec. Un `TestEqual` qui passe n'écrit rien, et + // une vérification silencieuse est indiscernable d'une vérification qui n'a jamais tourné — + // exactement le piège signalé pour `WaterLevelRelative` et la fenêtre d'overhang, dans + // lequel ce bloc-ci était tombé au premier jet. Le compte rend l'exécution visible. + // Report success, not just failure: a silent pass is indistinguishable from a check that + // never ran. + AddInfo(FString::Printf( + TEXT("Biome blend: %d (weight, point) pairs across weights 0/0.25/0.5/0.75/1.0 all match ") + TEXT("Lerp of the two biomes' full height stacks bit-exactly. Weight 0 returns the ") + TEXT("dominant untouched and weight 1 the neighbour, so the lerp is not inverted."), + (int32)UE_ARRAY_COUNT(Weights) * 400)); + if (NumWrong > 0) { AddError(FString::Printf( @@ -584,6 +596,11 @@ bool FVoxelForgeHeightStackTest::RunTest(const FString& Parameters) } TestEqual(TEXT("biome ceiling SELECTS the dominant (never blends), even at weight 1"), NumCeilWrong, 0); + + AddInfo(TEXT("Biome ceiling: 200 points at neighbour-weight 1.0 still return the ") + TEXT("DOMINANT biome's sky cap, i.e. it selects rather than blends -- the " + "original's behaviour, and the case a \"blend everything\" refactor would " + "silently break.")); } } diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index d3f8115..6dab3ae 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -417,8 +417,59 @@ namespace class FSurfaceColumnSource final : public IVoxelDensityOp { public: + /** + * @param PerBiome vide ⇒ pas de biomes, chemin d'origine inchangé. Non vide ⇒ le sol est + * mélangé et le plafond sélectionné par `InField`. + * @param InField **POSSÉDÉ** — délibérément, plutôt qu'un pointeur nu. L'adaptateur réel + * pointe vers des `thread_local` du générateur ; lier sa durée de vie à + * celle de la pile (elle-même `thread_local`, reconstruite au même moment) + * rend la question de survie structurelle au lieu de la laisser à une + * convention que le prochain lecteur devrait deviner. + * OWNED on purpose rather than borrowed: tying its lifetime to the stack's + * makes the survival question structural instead of conventional. + */ + FSurfaceColumnSource(const FSurfaceGenerationParams& InP, int32 Seed, + const TArray& PerBiome, + TUniquePtr InField) + : P(InP), BiomeParams(PerBiome), Field(MoveTemp(InField)) + { + if (BiomeParams.Num() > 0) + { + // Chemin BIOMES : une pile complète par biome, sol mélangé / plafond sélectionné. + TerrainStack.Add(VoxelHeightOps::MakeBiomeBlendHeightSource(BiomeParams, Seed, Field.Get())); + CeilingStack.Add(VoxelHeightOps::MakeBiomeSelectCeilingSource(BiomeParams, Seed, Field.Get())); + + // Un champ structurel PAR BIOME : la pente de l'overhang doit venir du champ du + // biome DOMINANT (l'original échantillonne `*PD`), pas d'un champ moyen. + PerBiomeStructural.Reserve(BiomeParams.Num()); + for (const FSurfaceGenerationParams& BP : BiomeParams) + { + const IVoxelHeightOp* Raw = nullptr; + PerBiomeStructural.Add(VoxelHeightOps::MakeStructuralHeightSource(BP, Seed, &Raw)); + } + Structural = PerBiomeStructural.Num() > 0 ? PerBiomeStructural[0].Get() : nullptr; + } + else + { + BuildSingleBiome(InP, Seed); + } + + // Identité unique et NON RECYCLÉE — la clé du mémo par colonne. `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 l'interdit. + // Assignée ICI et nulle part ailleurs : l'autre constructeur délègue à celui-ci. + // A unique, never-recycled id — assigned here only; the other ctor delegates. + static std::atomic NextId{ 1 }; + InstanceId = NextId.fetch_add(1, std::memory_order_relaxed); + } + + /** Sans biomes — délègue, pour qu'il n'existe qu'UN corps de construction et UN compteur + * d'identité. Deux constructeurs qui s'initialisent chacun de leur côté, c'est deux + * endroits où oublier un membre. */ FSurfaceColumnSource(const FSurfaceGenerationParams& InP, int32 Seed) - : P(InP) + : FSurfaceColumnSource(InP, Seed, TArray(), nullptr) {} + + void BuildSingleBiome(const FSurfaceGenerationParams& InP, int32 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 @@ -430,15 +481,6 @@ namespace 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`. @@ -483,17 +525,48 @@ namespace // 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) + // Quel jeu de params gouverne cette colonne ? Sans biomes, `P`. Avec, le DOMINANT + // pour la pente et le seuil, et une interpolation de l'AMPLITUDE vers le voisin — + // exactement ce que fait `ComputeSurfaceColumn` (`Lerp(Amp(PD), Amp(PN), W)`, pente + // depuis `*PD` seul). Interpoler la pente n'aurait pas de sens : c'est une mesure du + // terrain, pas un réglage. + const FSurfaceGenerationParams* PD = &P; + const FSurfaceGenerationParams* PN = nullptr; + float W = 0.0f; + const IVoxelHeightOp* SlopeField = Structural; + + if (BiomeParams.Num() > 0 && Field) { - 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 FVoxelBiomeWeights BW = Field->SampleAt(WorldX, WorldY); + const int32 Di = BiomeParams.IsValidIndex(BW.Dominant) ? BW.Dominant : 0; + PD = &BiomeParams[Di]; + if (PerBiomeStructural.IsValidIndex(Di)) { SlopeField = PerBiomeStructural[Di].Get(); } + if (BW.NeighborWeight > 0.0f && BiomeParams.IsValidIndex(BW.Neighbor)) + { + PN = &BiomeParams[BW.Neighbor]; + W = BW.NeighborWeight; + } + } + + const bool bAnyOverhang = (PD->OverhangStrength > 0.0f) + || (PN && PN->OverhangStrength > 0.0f); + + if (bAnyOverhang && SlopeField != nullptr) + { + const float SD = FMath::Clamp(PD->OverhangReach, 4.0f, 16.0f); + const float Z0 = SampleStructuralOf(SlopeField, WorldX, WorldY); + const float GX = (SampleStructuralOf(SlopeField, WorldX + SD, WorldY) - Z0) / SD; + const float GY = (SampleStructuralOf(SlopeField, 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] + auto Amp = [Slope](const FSurfaceGenerationParams& Q) -> float + { + if (Q.OverhangStrength <= 0.0f) { return 0.0f; } + const float Thr = FMath::Max(Q.OverhangSlopeThreshold, 0.05f); + const float Gate = FMath::Clamp((Slope - Thr) / Thr, 0.0f, 1.0f); + return Q.OverhangStrength * Gate; // [0,1] + }; + C.OverhangAmp = (W > 0.0f && PN) ? FMath::Lerp(Amp(*PD), Amp(*PN), W) : Amp(*PD); // 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. @@ -503,11 +576,25 @@ namespace return S.C; } - /** Le champ structurel nu — l'overhang s'en sert pour emprunter la roche amont. */ + /** Le champ structurel nu — l'overhang s'en sert pour emprunter la roche amont. + * Avec des biomes, c'est celui du biome DOMINANT en ce point (l'original emprunte à `*PD`). */ float SampleStructural(float WorldX, float WorldY) const { + const IVoxelHeightOp* Src = Structural; + if (BiomeParams.Num() > 0 && Field) + { + const FVoxelBiomeWeights BW = Field->SampleAt(WorldX, WorldY); + const int32 Di = PerBiomeStructural.IsValidIndex(BW.Dominant) ? BW.Dominant : 0; + if (PerBiomeStructural.IsValidIndex(Di)) { Src = PerBiomeStructural[Di].Get(); } + } + return SampleStructuralOf(Src, WorldX, WorldY); + } + + static float SampleStructuralOf(const IVoxelHeightOp* Src, float WorldX, float WorldY) + { + if (!Src) { return 0.0f; } FVoxelHeightSample S; - Structural->Eval(WorldX, WorldY, S); + Src->Eval(WorldX, WorldY, S); return S.Height; } @@ -549,6 +636,12 @@ namespace FVoxelHeightStack TerrainStack; FVoxelHeightStack CeilingStack; const IVoxelHeightOp* Structural = nullptr; // NON possédant : la pile terrain le possède + + // Chemin BIOMES. Vide ⇒ chemin d'origine, inchangé bit pour bit. + TArray BiomeParams; + TUniquePtr Field; // POSSÉDÉ (voir le constructeur) + TArray> PerBiomeStructural; // pente d'overhang par biome + uint64 InstanceId = 0; }; @@ -1060,15 +1153,14 @@ namespace VoxelDensityOps } void BuildSurfaceStack(FVoxelOpStack& OutStack, const FSurfaceGenerationParams& P, - int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager) + int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager, + const TArray& PerBiomeParams, + TUniquePtr BiomeField) { - // ⚠️ 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.** - // - // L'overhang, lui, est là depuis l'étape 2b. - TUniquePtr ColumnSource = MakeUnique(P, Seed); + // ARCHÉTYPE COMPLET depuis l'étape 2c : vide + overhang + mélange de biomes. + // `PerBiomeParams` vide ⇒ chemin sans biomes, strictement inchangé. + TUniquePtr ColumnSource = + MakeUnique(P, Seed, PerBiomeParams, MoveTemp(BiomeField)); const FSurfaceColumnSource* ColumnPtr = ColumnSource.Get(); OutStack.Add(MoveTemp(ColumnSource)); diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index d4ab32a..bbb506b 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -15,6 +15,51 @@ #include "VoxelNoise.h" // T2.a: float, SIMD-batched gradient-noise core #include "VoxelDensityPrimitives.h" // spine / seal / 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 + +//============================================================================= +// 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 @@ -580,24 +625,33 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co break; case ECaveGeneratorType::SurfaceWorld: - // Même garde dégénérée que les autres, et une SECONDE garde : la pile ne sait - // pas encore mélanger les biomes. `UsesOperatorStackForChunk` refuse déjà les - // strates à biomes, mais un `CP_BiomeCtx` valide ici voudrait dire que les deux - // sources d'information se contredisent — auquel cas on retombe sur le `switch`, - // parce qu'un monde non porté est récupérable et un monde faux ne l'est pas. - // A valid biome context here would mean the two sources of truth disagree; fall - // back rather than generate a world with seams at every biome border. - if (CP_Surface.StrateTopWorldZ - CP_Surface.StrateBottomWorldZ <= 0.0f - || CP_BiomeCtx.IsValid()) + { + if (CP_Surface.StrateTopWorldZ - CP_Surface.StrateBottomWorldZ <= 0.0f) { CP_UseOpStack = false; break; } OpCtx.StrateTopWorldZ = CP_Surface.StrateTopWorldZ; OpCtx.StrateBottomWorldZ = CP_Surface.StrateBottomWorldZ; + + // 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` : c'est ce + // qui lui permet de devenir un asset en Phase 3 sans traîner le générateur. + // Built here, on the side that knows the generator, and handed to the stack. + TUniquePtr Field; + TArray PerBiome; + if (CP_BiomeCtx.IsValid() && CP_SurfaceBiomeParams.Num() > 0) + { + PerBiome = CP_SurfaceBiomeParams; + Field = MakeUnique( + this, &CP_BiomeCtx, &CP_BiomeCache, ChunkCoord.Z); + } + VoxelDensityOps::BuildSurfaceStack(CP_OpStack, CP_Surface, Seed, - OriginSpineRadius, StrateManager); + OriginSpineRadius, StrateManager, + PerBiome, MoveTemp(Field)); break; + } default: // UsesOperatorStackForChunk ne rend true que pour les archétypes portés, donc // on ne devrait jamais arriver ici. Si ça arrive, retomber sur le `switch` diff --git a/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Source/VoxelForge/Private/VoxelStrateManager.cpp index 774a9bd..196bcfa 100644 --- a/Source/VoxelForge/Private/VoxelStrateManager.cpp +++ b/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -576,19 +576,12 @@ bool UVoxelStrateManager::UsesOperatorStackForChunk(const FIntVector& ChunkCoord case ECaveGeneratorType::CrystalChamber: return true; // UNE seule pile (BuildSlabStack) case ECaveGeneratorType::SurfaceWorld: - // ⚠️ PORTÉ, MAIS PAS AVEC LES BIOMES. `BuildSurfaceStack` évalue UN jeu de params ; le - // chemin d'origine évalue le biome dominant puis interpole les HAUTEURS vers le voisin - // dans la bande de frontière (le combiner `Mask`, prototype de la Phase 3 — §5). Sans lui, - // une strate à biomes perdrait ses transitions : pas un décalage subtil, une couture nette - // à chaque frontière de biome. - // - // Donc la garde est ici, dans la MÊME fonction que la liste des archétypes portés, plutôt - // que dispersée dans `GetDensityAt` : « cette strate peut-elle prendre la pile ? » reste - // une seule question, posée à un seul endroit. - // Ported, but NOT with biomes: the stack evaluates ONE param set, while the original blends - // heights toward the neighbouring biome. Without the Mask combiner a biome strate would lose - // its transitions — a hard seam at every biome border, not a subtle shift. - return Def->Biomes.Num() == 0; + // ✅ La garde « pas de biomes » est TOMBÉE (étape 2c) : le combiner `Mask` existe, donc une + // strate à biomes mélange bien ses hauteurs comme le chemin d'origine. Les trois archétypes + // du dessus plus celui-ci font 5 des 8 portés. + // The no-biome guard is GONE: the Mask combiner exists, so a biome strate blends its heights + // exactly as the original path does. + return true; default: return false; } diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index 96e34d4..f4b518c 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -29,6 +29,7 @@ #include "CoreMinimal.h" #include "VoxelDensityOp.h" #include "VoxelStrateTypes.h" // FMazeGenerationParams +#include "VoxelHeightOp.h" // IVoxelBiomeField — BuildSurfaceStack takes ownership of one class UVoxelStrateManager; @@ -200,16 +201,22 @@ namespace VoxelDensityOps int32 Seed); /** - * SurfaceWorld — ⚠️ PAS ENCORE COMPLET, et c'est délibéré. Équivaut exactement à - * `GetSurfaceDensity`, c.-à-d. la version **sans overhang** et **sans mélange de biomes** : - * • l'overhang a besoin d'une donnée par colonne que `GetSurfaceDensity` ne calcule pas - * (il passe `OverhangAmp = 0`) — sa référence est le chemin caché ; - * • le mélange de biomes est le combiner `Mask`, prototype de la Phase 3 (§5). - * Les deux arrivent à l'étape 2b. Ne pas brancher dans un monde à biomes avant. + * SurfaceWorld, COMPLET : colonne (sol + voûte) → densité, overhang 3D, post structurel, et le + * mélange de biomes quand `PerBiomeParams` est non vide. + * + * @param PerBiomeParams vide ⇒ pas de biomes, chemin d'origine strictement inchangé. Non vide + * ⇒ une pile de hauteur COMPLÈTE par biome, sol mélangé / voûte + * sélectionnée, amplitude d'overhang interpolée (§5, combiner `Mask`). + * @param BiomeField **transféré** à la pile, qui le possède. Doit répondre pour les mêmes + * indices que `PerBiomeParams`. `nullptr` avec des params non vides ⇒ + * biome 0 partout (dégradation sûre, pas un crash). */ VOXELFORGE_API void BuildSurfaceStack(FVoxelOpStack& OutStack, const FSurfaceGenerationParams& P, int32 Seed, float SpineRadius, - const UVoxelStrateManager* StrateManager); + const UVoxelStrateManager* StrateManager, + const TArray& PerBiomeParams = + TArray(), + TUniquePtr BiomeField = nullptr); /** * FlatPlain ET CrystalChamber — la même pile, **sans branchement sur le type** :