diff --git a/OPSTACK-PROGRESS.md b/OPSTACK-PROGRESS.md index ce14c63..78bf736 100644 --- a/OPSTACK-PROGRESS.md +++ b/OPSTACK-PROGRESS.md @@ -1530,3 +1530,58 @@ that could actually flip a sign. Recorded in `AUDIT §C9`. (the `Mask` combiner = biome blending, `§5`'s Phase 3 prototype), then `VerticalShafts` (`§6`). --- + +## 2026-07-27 — the `Mask` combiner (biome blending) in height space. §5's Phase 3 prototype. + +All 10 tests green on the previous build, and the reworded digest confirms the point that mattered: + +``` +NearIso profile over 115000 samples: 2 within 1e-4, 0 within 1e-5, 0 within 1e-6 +``` + +**Zero in the tight band** — no sampled voxel sits close enough to the isosurface for a libm +difference to flip its side. The residual `§C9` library half is real in principle and, on this grid, +carries no measured risk. The wide band's "2" was the ~100× over-statement, exactly as predicted. + +### The design decision worth recording: `IVoxelBiomeField` + +Biome blending needs to ask "which biome is at this XY?", and the real answer is a warped Voronoi +with a per-chunk cache living on `UVoxelGenerator`. **The op must not hold a `UVoxelGenerator*`** — +Phase 3 wants operators to become *assets*, and an op that owns a generator pointer never can. + +So the op depends on `IVoxelBiomeField`, a two-line interface returning `(dominant, neighbour, +weight)`. The adapter that knows the generator stays on the generator's side. This is the same move +as `cliff → structural source`: depend on the *capability*, not on the owner. + +### The combiner itself + +`FBiomeBlendHeightSource` — **one complete height stack per biome**, heights lerped in the border +band. Each biome's stack computes its **own** relief `M` and gates its **own** terrace with it, +which is exactly what the original does (two independent full `ComputeSurfaceTerrainZ` calls, only +the OUTPUTS blended). Blending *heights* rather than *params* is what keeps borders continuous +across any param difference — interpolating params would drag a terrace through intermediate states +that mean nothing. + +**The ceiling SELECTS instead of blending**, because the original takes the dominant biome's ceiling +alone. Reproduced as-is rather than "improved": a blended sky cap would change the world's +silhouette, and a port is not where that gets decided. + +### Tested against a synthetic field, on purpose + +The real Voronoi resolver has its own coverage; a synthetic field lets the weight sweep 0 → 1 +**continuously**, which is where an inverted lerp (`1-w` for `w`) hides — it looks right in the +middle and wrong only at the ends. Two deliberately dissimilar biomes, five weights × 400 points, +bit-exact against `FMath::Lerp` of the two full stacks. Plus a check that the ceiling still returns +the dominant's at weight 1.0, which is the case a "blend everything" refactor would silently break. + +**UNVERIFIED:** not compiled. Likely spots: the local `class FFixedWeightField` declared inside a +function body and used as an interface; `TArray` (move-only element type — needs +`MoveTemp` on insert, which it has). + +**Next single action:** build. Then the density-side integration — `FSurfaceColumnSource` takes the +per-biome params + field, blends the overhang amp (`Lerp(Amp(PD), Amp(PN), W)` with slope from PD), +and the generator-side adapter wraps `ResolveBiomeSampleAt`. That drops the biome guard in +`UsesOperatorStackForChunk` and finishes SurfaceWorld. Then `VerticalShafts` (§6), which reuses +`FConstantRockSource` + `FSdfRoughnessMod` + `FSdfCarve` and should be the cheapest port yet. + +--- diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeHeightStackTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeHeightStackTest.cpp index d017315..c22f6a2 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeHeightStackTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeHeightStackTest.cpp @@ -480,6 +480,113 @@ bool FVoxelForgeHeightStackTest::RunTest(const FString& Parameters) } } + //========================================================================= + // LE COMBINER `Mask` — mélange de biomes (§5 : le prototype de la Phase 3) + //========================================================================= + // Testé contre un champ de biomes SYNTHÉTIQUE plutôt que contre le résolveur Voronoï réel, et + // c'est le bon choix ici : le vrai résolveur est déjà couvert par ses propres tests, alors + // qu'un champ synthétique permet de balayer le poids de 0 à 1 de façon CONTINUE et de vérifier + // l'identité `blend(w) == lerp(A, B, w)` sur toute la plage — y compris les deux bouts, où une + // erreur d'inversion (`1-w` au lieu de `w`) se cache le mieux. + // + // Tested against a SYNTHETIC field rather than the real Voronoi resolver: the resolver has its + // own tests, while a synthetic field lets the weight be swept continuously from 0 to 1, which is + // where an inverted lerp hides. + { + // Deux jeux de params franchement différents : si le mélange était un no-op, ou prenait le + // mauvais côté, l'écart serait énorme plutôt que subtil. + FSurfaceGenerationParams A = Defaults; + FSurfaceGenerationParams B = Defaults; + A.ElevationRange = 40.0f; A.MountainStrength = 0.2f; + B.ElevationRange = 12.0f; B.MountainStrength = 0.9f; + B.BaseGroundRelative = FMath::Clamp(A.BaseGroundRelative + 0.15f, 0.0f, 1.0f); + + TArray PerBiome; + PerBiome.Add(A); + PerBiome.Add(B); + + /** Champ synthétique : biome 0 dominant, biome 1 voisin, poids imposé par le test. */ + class FFixedWeightField final : public IVoxelBiomeField + { + public: + float W = 0.0f; + FVoxelBiomeWeights SampleAt(float, float) const override + { + FVoxelBiomeWeights Out; + Out.Dominant = 0; Out.Neighbor = 1; Out.NeighborWeight = W; + return Out; + } + }; + FFixedWeightField FieldA; + + // Les deux piles de référence, non mélangées. + FVoxelHeightStack StackA, StackB; + VoxelHeightOps::BuildSurfaceHeightStack(StackA, A, World.Settings->Seed); + VoxelHeightOps::BuildSurfaceHeightStack(StackB, B, World.Settings->Seed); + + FVoxelHeightStack Blended; + Blended.Add(VoxelHeightOps::MakeBiomeBlendHeightSource(PerBiome, World.Settings->Seed, &FieldA)); + + const float Weights[] = { 0.0f, 0.25f, 0.5f, 0.75f, 1.0f }; + int32 NumWrong = 0; + float WorstDelta = 0.0f; + + for (const float W : Weights) + { + FieldA.W = W; + for (int32 i = 0; i < 400; ++i) + { + const float X = (float)((i % 20) * 11); + const float Y = (float)((i / 20) * 13); + + const float HA = StackA.EvalHeight(X, Y); + const float HB = StackB.EvalHeight(X, Y); + // ⚠️ L'attendu doit reproduire la MÊME expression que l'op, `FMath::Lerp` compris : + // écrire `HA + (HB - HA) * W` à la place testerait l'algèbre, pas le code. + const float Expect = (W > 0.0f) ? FMath::Lerp(HA, HB, W) : HA; + const float Got = Blended.EvalHeight(X, Y); + + if (!BitEqual(Expect, Got)) + { + ++NumWrong; + WorstDelta = FMath::Max(WorstDelta, FMath::Abs(Expect - Got)); + } + } + } + + TestEqual(TEXT("biome blend: heights lerp between the two biomes' full stacks, bit-exactly"), + NumWrong, 0); + + if (NumWrong > 0) + { + AddError(FString::Printf( + TEXT("Biome blend wrong on %d of 2000 (weight, point) pairs, worst |delta| %.6g. ") + TEXT("Check: is the lerp toward the NEIGHBOUR (weight 0 must give the dominant ") + TEXT("untouched, weight 1 the neighbour), and does each biome's stack compute its ") + TEXT("OWN relief for its OWN terrace gate rather than sharing the dominant's?"), + NumWrong, WorstDelta)); + } + + // Le plafond SÉLECTIONNE au lieu de mélanger — comportement d'origine, reproduit tel quel. + { + FieldA.W = 1.0f; // le voisin l'emporterait si le plafond mélangeait + FVoxelHeightStack CeilSel; + CeilSel.Add(VoxelHeightOps::MakeBiomeSelectCeilingSource(PerBiome, World.Settings->Seed, &FieldA)); + + FVoxelHeightStack CeilDominant; + VoxelHeightOps::BuildSurfaceCeilingStack(CeilDominant, A, World.Settings->Seed); + + int32 NumCeilWrong = 0; + for (int32 i = 0; i < 200; ++i) + { + const float X = (float)((i % 20) * 11), Y = (float)((i / 20) * 13); + if (!BitEqual(CeilSel.EvalHeight(X, Y), CeilDominant.EvalHeight(X, Y))) { ++NumCeilWrong; } + } + TestEqual(TEXT("biome ceiling SELECTS the dominant (never blends), even at weight 1"), + NumCeilWrong, 0); + } + } + return true; } diff --git a/Source/VoxelForge/Private/VoxelHeightOpStack.cpp b/Source/VoxelForge/Private/VoxelHeightOpStack.cpp index 4dae318..bc063ec 100644 --- a/Source/VoxelForge/Private/VoxelHeightOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelHeightOpStack.cpp @@ -353,12 +353,85 @@ namespace } + //========================================================================= + // COMBINER `Mask` — MÉLANGE DE BIOMES / BIOME BLEND + //========================================================================= + // Une pile complète par biome ; le champ dit lequel domine ; on interpole les HAUTEURS. + // + // ⚠️ Chaque pile calcule SON PROPRE relief `M` en interne et l'utilise pour son propre gate de + // terrace — exactement comme l'original, où `ComputeSurfaceTerrainZ(X, Y, *PD)` et + // `(…, *PN)` sont deux appels complets et indépendants dont seules les SORTIES sont mêlées. + // Le canal `Relief` qui ressort ici est celui du DOMINANT : il est informatif, personne en aval + // ne s'en sert pour re-gater quoi que ce soit. + // + // Each biome stack computes its own relief internally and gates its own terrace with it, exactly + // as the original makes two independent full calls and blends only the OUTPUTS. + class FBiomeBlendHeightSource final : public IVoxelHeightOp + { + public: + FBiomeBlendHeightSource(const TArray& PerBiome, int32 Seed, + const IVoxelBiomeField* InField, bool bCeilingOnly) + : Field(InField) + { + Stacks.Reserve(PerBiome.Num()); + for (const FSurfaceGenerationParams& BP : PerBiome) + { + FVoxelHeightStack S; + if (bCeilingOnly) { VoxelHeightOps::BuildSurfaceCeilingStack(S, BP, Seed); } + else { VoxelHeightOps::BuildSurfaceHeightStack(S, BP, Seed); } + Stacks.Add(MoveTemp(S)); + } + bBlend = !bCeilingOnly; // le plafond SÉLECTIONNE, il ne mélange pas + } + + void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const override + { + if (Stacks.Num() == 0) { return; } + + FVoxelBiomeWeights W; + if (Field) { W = Field->SampleAt(WorldX, WorldY); } + + const int32 D = Stacks.IsValidIndex(W.Dominant) ? W.Dominant : 0; + InOut = Stacks[D].EvalSample(WorldX, WorldY); + + // Le plafond ne se mélange pas (voir la fabrique) ; le sol si, et seulement dans la + // bande de frontière où le poids est non nul. + if (bBlend && W.NeighborWeight > 0.0f && Stacks.IsValidIndex(W.Neighbor)) + { + const float HN = Stacks[W.Neighbor].EvalHeight(WorldX, WorldY); + InOut.Height = FMath::Lerp(InOut.Height, HN, W.NeighborWeight); + } + } + + float MaxDisplacement() const override { return FLT_MAX; } // source composite + + private: + TArray Stacks; + const IVoxelBiomeField* Field; + bool bBlend = true; + }; +} + //============================================================================= // FABRIQUES / FACTORIES //============================================================================= namespace VoxelHeightOps { + TUniquePtr MakeBiomeBlendHeightSource( + const TArray& PerBiomeParams, int32 Seed, + const IVoxelBiomeField* Field) + { + return MakeUnique(PerBiomeParams, Seed, Field, /*bCeilingOnly*/false); + } + + TUniquePtr MakeBiomeSelectCeilingSource( + const TArray& PerBiomeParams, int32 Seed, + const IVoxelBiomeField* Field) + { + return MakeUnique(PerBiomeParams, Seed, Field, /*bCeilingOnly*/true); + } + TUniquePtr MakeSkyCapHeightSource(const FSurfaceGenerationParams& P, int32 Seed) { return MakeUnique(P, Seed); diff --git a/Source/VoxelForge/Public/VoxelHeightOp.h b/Source/VoxelForge/Public/VoxelHeightOp.h index 1119789..48a67e2 100644 --- a/Source/VoxelForge/Public/VoxelHeightOp.h +++ b/Source/VoxelForge/Public/VoxelHeightOp.h @@ -79,6 +79,41 @@ struct FVoxelHeightSample float Relief = 1.0f; }; +/** + * Le résultat d'une requête de champ de biome en un XY : qui domine, qui est le voisin, et à quel + * poids on va vers lui dans la bande de frontière. + */ +struct FVoxelBiomeWeights +{ + int32 Dominant = 0; + int32 Neighbor = -1; + float NeighborWeight = 0.0f; // 0 ⇒ pas de mélange, le dominant seul +}; + +/** + * LE CHAMP DE BIOMES, VU COMME UNE INTERFACE — et c'est délibérément une interface, pas un pointeur + * vers `UVoxelGenerator`. + * + * Le résolveur de biome réel est une Voronoï warpée avec un cache par chunk, qui vit sur le + * générateur. Un opérateur ne doit PAS en dépendre : la Phase 3 veut que les opérateurs deviennent + * des DONNÉES (des assets), et un op qui tient un `UVoxelGenerator*` ne peut pas le devenir. En + * passant par cette interface, l'adaptateur qui connaît le générateur reste du côté du générateur, + * et l'opérateur ne connaît qu'« un truc qui répond (dominant, voisin, poids) en XY ». + * + * Deliberately an interface rather than a UVoxelGenerator*: Phase 3 wants ops to become data, and an + * op holding a generator pointer never can. The adapter that knows the generator stays on the + * generator's side; the op only knows "something that answers (dominant, neighbour, weight) at XY". + */ +class IVoxelBiomeField +{ +public: + virtual ~IVoxelBiomeField() = default; + + /** PURE en XY, et bit-identique quel que soit le thread ou l'ordre — même contrat que le reste + * de l'espace-hauteur, puisque le résultat alimente le cache de colonne T1.a. */ + virtual FVoxelBiomeWeights SampleAt(float WorldX, float WorldY) const = 0; +}; + /** * Un opérateur d'espace-hauteur. Trois différences avec `IVoxelDensityOp`, toutes voulues : * • pas de Z d'entrée — la pile en PRODUIT un ; @@ -209,4 +244,30 @@ namespace VoxelHeightOps * plafond n'a pas d'équivalent des quatre modificateurs du sol. */ VOXELFORGE_API void BuildSurfaceCeilingStack(FVoxelHeightStack& OutStack, const FSurfaceGenerationParams& P, int32 Seed); + + /** + * LE COMBINER `Mask` — mélange de biomes, et `OPSTACK-DECOMPOSITION §5` en fait le prototype + * de la Phase 3 entière : « unifier strates et biomes » EST ce mécanisme, généralisé. + * + * Une pile de hauteur COMPLÈTE par biome, plus un champ qui dit lequel domine en (X,Y). Ce sont + * les **HAUTEURS** qui sont interpolées, pas les params — c'est ce que fait déjà le code + * d'origine, et c'est ce qui rend les frontières continues quelle que soit la différence de + * params entre deux biomes (interpoler des params ferait passer un terrace de « fort » à + * « faible » à travers des états intermédiaires qui n'ont de sens pour personne). + * + * Each biome gets a COMPLETE height stack; the HEIGHTS are lerped, not the params — which is + * what keeps borders continuous across any param difference. + * + * @param Field non possédé, doit survivre à la pile. `nullptr` ⇒ biome 0 partout. + */ + VOXELFORGE_API TUniquePtr MakeBiomeBlendHeightSource( + const TArray& PerBiomeParams, int32 Seed, + const IVoxelBiomeField* Field); + + /** Idem pour le plafond — mais le plafond N'EST PAS mélangé : le code d'origine prend celui du + * biome DOMINANT seul. Reproduit tel quel, pas « amélioré » : une voûte interpolée changerait + * la silhouette du monde et ce portage n'est pas l'endroit pour décider ça. */ + VOXELFORGE_API TUniquePtr MakeBiomeSelectCeilingSource( + const TArray& PerBiomeParams, int32 Seed, + const IVoxelBiomeField* Field); }