feat: the Mask combiner — biome blending in height space
Biome blending needs to ask which biome is at an XY, and the real answer is a warped Voronoi with a per-chunk cache on UVoxelGenerator. The op must not hold a generator pointer — Phase 3 wants ops to become assets, and one that owns a generator never can. So it depends on IVoxelBiomeField, a two-line interface returning (dominant, neighbour, weight), and the adapter that knows the generator stays on the generator's side. Same move as cliff -> structural: depend on the capability, not the owner. FBiomeBlendHeightSource holds one complete height stack per biome and lerps the HEIGHTS in the border band. Each biome's stack computes its own relief and gates its own terrace, exactly as the original makes two independent full calls and blends only the outputs. Blending heights rather than params is what keeps borders continuous across any param difference. The ceiling SELECTS the dominant instead of blending, because that is what the original does. Reproduced as-is rather than improved — a blended sky cap changes the world's silhouette and a port is not where that gets decided. Tested against a synthetic field rather than the real resolver: the resolver has its own coverage, while a synthetic field sweeps the weight 0 -> 1 continuously, which is where an inverted lerp hides. Five weights x 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. UNVERIFIED: not compiled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<FSurfaceGenerationParams> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<FSurfaceGenerationParams>& 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<FVoxelHeightStack> Stacks;
|
||||
const IVoxelBiomeField* Field;
|
||||
bool bBlend = true;
|
||||
};
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// FABRIQUES / FACTORIES
|
||||
//=============================================================================
|
||||
|
||||
namespace VoxelHeightOps
|
||||
{
|
||||
TUniquePtr<IVoxelHeightOp> MakeBiomeBlendHeightSource(
|
||||
const TArray<FSurfaceGenerationParams>& PerBiomeParams, int32 Seed,
|
||||
const IVoxelBiomeField* Field)
|
||||
{
|
||||
return MakeUnique<FBiomeBlendHeightSource>(PerBiomeParams, Seed, Field, /*bCeilingOnly*/false);
|
||||
}
|
||||
|
||||
TUniquePtr<IVoxelHeightOp> MakeBiomeSelectCeilingSource(
|
||||
const TArray<FSurfaceGenerationParams>& PerBiomeParams, int32 Seed,
|
||||
const IVoxelBiomeField* Field)
|
||||
{
|
||||
return MakeUnique<FBiomeBlendHeightSource>(PerBiomeParams, Seed, Field, /*bCeilingOnly*/true);
|
||||
}
|
||||
|
||||
TUniquePtr<IVoxelHeightOp> MakeSkyCapHeightSource(const FSurfaceGenerationParams& P, int32 Seed)
|
||||
{
|
||||
return MakeUnique<FSkyCapHeightSource>(P, Seed);
|
||||
|
||||
@@ -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<IVoxelHeightOp> MakeBiomeBlendHeightSource(
|
||||
const TArray<FSurfaceGenerationParams>& 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<IVoxelHeightOp> MakeBiomeSelectCeilingSource(
|
||||
const TArray<FSurfaceGenerationParams>& PerBiomeParams, int32 Seed,
|
||||
const IVoxelBiomeField* Field);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user