feat: port FloatingIslands — the stack that runs backwards

6 of 8 archetypes ported. This one starts from VOID and FILLS where the
other four start from ROCK and CARVE, which is what it was worth doing:
neither end of the pile needed a new operator, only the opposite sign.

  FConstantRockSource -> FConstantFieldSource(+/-Base)   AllSolid <-> AllAir
  FSdfCarveOp         -> FSdfConvertOp(Sign = +/-1)      carve    <-> fill
  FSdfRoughnessMod                                       4th archetype, unchanged

Only the island blob source is new. Multiplying by +/-1 is exact in
IEEE-754, so the three already-green ports are bit-for-bit untouched.

ClassifyBox can return AllAir for the first time in the plugin, and an
island strate is by construction mostly empty — the test counts AllSolid
and AllAir separately so an aggregate cannot hide whether that fired.

Two bounds that would have been holes if assumed rather than derived:
the island bound is one-sided (a hairline thread of matter hangs below
each island down its axis, so only the TOP may reject), and the domain
warp displaces X and Y independently, so the pad needs WarpAmp*sqrt(2).

Also: AUDIT C1 was NOT closed. The 2026-07-27 sweep matched `SeedF * K`
and this archetype's warp spells it `(float)S * K`, so one site survived
— at seed 2e9 the warp flattens and every island snaps back to a perfect
circle. Fixed in both paths in one pass so the equivalence test stays a
valid oracle. Expect island silhouettes to change at large seeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-07-27 17:50:36 +02:00
1 parent f5d5a03ad1
commit 96e75abe57
11 files changed
+901 -35

No files matched your search

+327 -20
View File
@@ -42,14 +42,24 @@ namespace
}
//=========================================================================
// RÔLE 1 — SOURCE : ROC CONSTANT / CONSTANT ROCK
// RÔLE 1 — SOURCE : CHAMP CONSTANT / CONSTANT FIELD (roc ET vide)
//=========================================================================
// `float Density = Params.BaseDensity; // start solid` — la première ligne de TunnelNetwork,
// de Maze ET de VerticalShafts. Trois archétypes, une ligne, désormais un opérateur.
class FConstantRockSource final : public IVoxelDensityOp
// de Maze ET de VerticalShafts. Et `float Density = -Params.BaseDensity; // open air (void)` —
// la première ligne de FloatingIslands. **C'est le MÊME opérateur au signe près**, et le signe
// n'est pas un détail : il décide du verdict de boîte de départ (AllSolid contre AllAir), donc
// de ce que la strate saura sauter.
//
// Quatre archétypes, une ligne, un opérateur. Deux fabriques (`MakeConstantRockSource` /
// `MakeConstantVoidSource`) parce que « roc » et « vide » sont ce que l'auteur veut DIRE ; la
// classe, elle, n'a aucune raison d'exister en deux exemplaires.
//
// One operator, two factories: rock and void are the same constant field with opposite signs,
// and the sign is what decides the starting box verdict (AllSolid vs AllAir).
class FConstantFieldSource final : public IVoxelDensityOp
{
public:
explicit FConstantRockSource(float InBaseDensity) : BaseDensity(InBaseDensity) {}
explicit FConstantFieldSource(float InValue) : Value(InValue) {}
EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; }
void PrepareChunk(const FVoxelOpContext&) override {}
@@ -57,23 +67,28 @@ namespace
void Eval(float, float, float, FVoxelOpSample& InOut) const override
{
InOut.Density = BaseDensity; // Replace : racine de pile, ignore l'entrée
InOut.Density = Value; // Replace : racine de pile, ignore l'entrée
}
// Exact et gratuit : une constante positive est solide partout. C'est ce qui donne aux
// strates de grotte une hypothèse AllSolid de départ — elles n'en ont jamais eu.
// Exact et gratuit, dans les DEUX sens (convention interne : positif = solide).
// Positif ⇒ AllSolid : c'est ce qui donne aux strates de grotte une hypothèse de départ
// qu'elles n'ont jamais eue. Négatif ⇒ AllAir : c'est ce qui rend une strate d'îles
// flottantes — un grand vide surtout vide — sautable là où aucune île n'arrive.
EVoxelTileClass ClassifyBox(const FBox&, const FVoxelOpContext&) const override
{
return (BaseDensity > 0.0f) ? EVoxelTileClass::AllSolid : EVoxelTileClass::Mixed;
if (Value > 0.0f) { return EVoxelTileClass::AllSolid; }
if (Value < 0.0f) { return EVoxelTileClass::AllAir; }
return EVoxelTileClass::Mixed; // exactement 0 : le mesher le compte du côté AIR,
// mais un champ nul n'est pas une hypothèse utile.
}
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
{
return EVoxelOpEffect::Both; // jamais atteint : ClassifyBox répond avant
return EVoxelOpEffect::Both; // jamais atteint sauf Value == 0 : ClassifyBox répond avant
}
private:
float BaseDensity;
float Value;
};
//=========================================================================
@@ -1003,13 +1018,26 @@ namespace
};
//=========================================================================
// RÔLE 2 — COMBINER : SDF → DENSITÉ (CARVE)
// RÔLE 2 — COMBINER : SDF → DENSITÉ (CARVE et FILL)
//=========================================================================
// Les six mêmes lignes dans TunnelNetwork, Maze et VerticalShafts. Une fois ici, plus jamais.
class FSdfCarveOp final : public IVoxelDensityOp
// Les six mêmes lignes dans TunnelNetwork, Maze, VerticalShafts — et FloatingIslands, où le
// SEUL changement est `Density += Fill·Base·2` au lieu de `Density -= Carve·Base·2`.
//
// Un archétype qui CREUSE dans du roc et un archétype qui REMPLIT du vide sont donc le même
// opérateur au signe près, exactement comme la source constante au-dessus. C'est la symétrie
// que le `switch` ne pouvait pas montrer : les deux blocs y sont à 900 lignes l'un de l'autre.
//
// ⚠️ `Sign` vaut ±1.0f et rien d'autre. La multiplication par ±1 est EXACTE en IEEE-754, donc
// `D += (-1·F)·B·2` rend bit pour bit ce que `D -= F·B·2` rendait — l'égalité binaire des trois
// portages déjà verts en dépend.
//
// Same operator, opposite sign. Multiplying by ±1 is exact in IEEE-754, so the carve path is
// bit-for-bit what it was before this generalisation — the three green ports depend on that.
class FSdfConvertOp final : public IVoxelDensityOp
{
public:
FSdfCarveOp(float InBlend, float InBaseDensity) : Blend(InBlend), BaseDensity(InBaseDensity) {}
FSdfConvertOp(float InBlend, float InBaseDensity, float InSign)
: Blend(InBlend), BaseDensity(InBaseDensity), Sign(InSign) {}
EVoxelOpRole GetRole() const override { return EVoxelOpRole::Combiner; }
void PrepareChunk(const FVoxelOpContext&) override {}
@@ -1017,9 +1045,9 @@ namespace
void Eval(float, float, float, FVoxelOpSample& InOut) const override
{
if (InOut.Sdf >= Blend) { return; }
float Carve = FMath::Clamp((Blend - InOut.Sdf) / (Blend * 2.0f), 0.0f, 1.0f);
Carve = SmoothStep01(Carve);
InOut.Density -= Carve * BaseDensity * 2.0f; // interne : baisser = vers l'air
float T = FMath::Clamp((Blend - InOut.Sdf) / (Blend * 2.0f), 0.0f, 1.0f);
T = SmoothStep01(T);
InOut.Density += Sign * T * BaseDensity * 2.0f; // interne : monter = vers le solide
}
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
@@ -1028,7 +1056,7 @@ namespace
}
private:
float Blend, BaseDensity;
float Blend, BaseDensity, Sign;
};
//=========================================================================
@@ -1429,6 +1457,240 @@ namespace
const FShaftFieldSource* Field; // NON possédant : la pile possède la source
};
//=========================================================================
// RÔLE 1 — SOURCE : ÎLES FLOTTANTES / FLOATING ISLAND BLOBS
//=========================================================================
// Le SEUL archétype dont la source est de l'AIR : `FConstantFieldSource(-BaseDensity)` pose un
// grand vide, et cet opérateur y suspend des blobs. C'est ce qui en fait le bon test de
// composition — tous les autres portages partent de roc et creusent.
//
// FORME D'UNE ÎLE : une dalle assez plate au-dessus du centre (`TopHalf = 0.20·Rxy`) et un
// dessous qui s'effile vers une pointe (`ThicknessRatio·Rxy`). C'est l'asymétrie qui se lit
// comme une île flottante plutôt que comme une sphère.
//
// ⚠️ ÉCART ASSUMÉ AVEC `OPSTACK-DECOMPOSITION §7`, qui décrivait un `FRAME IslandWarp` enveloppant
// la source. Le warp reste À L'INTÉRIEUR de l'opérateur, et c'est délibéré : `§7` compte trois
// usages de frames (îles, caves de TunnelNetwork, tunnels), mais **deux d'entre eux ne sont pas
// encore portés**. Inventer l'infrastructure de frame pour son unique utilisateur actuel, c'est
// la concevoir contre un seul exemple — précisément ce que ce refactor a évité jusqu'ici en
// n'abstrayant qu'à la deuxième occurrence (cf. `IVoxelBiomeField`, né d'un besoin réel).
// À reprendre quand TunnelNetwork arrivera avec le deuxième usage réel.
//
// The warp stays INSIDE the op against §7's FRAME suggestion: two of the three frame users are
// not ported yet, and designing the abstraction against a single example is what this refactor
// has deliberately avoided. Revisit when TunnelNetwork brings the second real use.
class FIslandBlobSource final : public IVoxelDensityOp
{
public:
FIslandBlobSource(const FFloatingIslandParams& InP, int32 Seed, float InExtraReach)
: P(InP), Salt((uint32)Seed ^ 0x49736C64u) // 'Isld' — identique à GetFloatingIslandDensity
, ExtraReach(InExtraReach) {}
EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; }
void PrepareChunk(const FVoxelOpContext&) override {}
struct FIsland { float X, Y, Rxy, TopHalf, TopZ, BotZ, TaperEnd; };
// ⚠️ DÉCLARÉE ICI, avant toute fonction qui la renvoie — même piège que `FShaftFieldSource`
// (C4430 : les corps de méthodes sont différés, les types de retour non).
struct FCells { TArray<FIsland, TInlineAllocator<9>> Islands; };
void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override
{
const float BlendK = FMath::Max(P.SDFBlendRadius, 0.01f);
const FCells& C = GetCells(WorldX, WorldY);
// CONTOUR IRRÉGULIER : on déforme la requête HORIZONTALE pour que les bords des îles
// soient lobés au lieu d'être des cercles parfaits. Calculé une fois par voxel et
// partagé par toutes les îles proches — chacune échantillonne une autre partie du champ,
// d'où des silhouettes distinctes.
const float WarpAmp = (P.IslandMinRadius + P.IslandMaxRadius) * 0.5f * 0.35f;
const float WX = WorldX + HFractal3D(FVector(WorldX * 0.04f + VoxelHash::SeedOffset(Salt, 0.0007f),
WorldY * 0.04f, WorldZ * 0.012f), VoxelGenLOD::Eff(3))
* VOXEL_NOISE_SCALE * WarpAmp;
const float WY = WorldY + HFractal3D(FVector(WorldX * 0.04f + 31.0f, WorldY * 0.04f + 7.0f,
WorldZ * 0.012f), VoxelGenLOD::Eff(3))
* VOXEL_NOISE_SCALE * WarpAmp;
float IslandSDF = FLT_MAX;
for (const FIsland& Isl : C.Islands)
{
// Distance horizontale dans le repère DÉFORMÉ, donc le contour n'est pas un cercle.
const float Dxw = WX - Isl.X, Dyw = WY - Isl.Y;
const float DistXY = FMath::Sqrt(Dxw * Dxw + Dyw * Dyw);
// Enveloppe de rayon par la hauteur : pleine largeur en haut, resserrée jusqu'à une
// pointe en bas (taper SmoothStep).
const float Hgt = FMath::Clamp((WorldZ - Isl.BotZ) / FMath::Max(Isl.TopZ - Isl.BotZ, 1.0f),
0.0f, 1.0f);
const float Taper = SmoothStep01(FMath::Clamp(Hgt / Isl.TaperEnd, 0.0f, 1.0f));
const float Env = Isl.Rxy * Taper;
// Surface du dessus : plate par défaut ; les bords retombent en dôme si TopFlatten < 1.
float TopSurf = Isl.TopZ;
if (P.TopFlatten < 1.0f)
{
const float Edge = FMath::Clamp(DistXY / FMath::Max(Isl.Rxy, 1.0f), 0.0f, 1.0f);
TopSurf = Isl.TopZ - (1.0f - P.TopFlatten) * Isl.TopHalf * 2.0f * Edge * Edge;
}
// Pseudo-SDF : dehors si au-delà de l'enveloppe radiale OU au-dessus du dessus.
const float Sdf = FMath::Max(DistXY - Env, WorldZ - TopSurf);
IslandSDF = VoxelSDF::SmoothMin(IslandSDF, Sdf, BlendK);
}
InOut.Sdf = IslandSDF;
}
/**
* `FillOnly` si une île peut atteindre la boîte, `Identity` sinon — et sur une strate d'îles
* `Identity` est le cas COURANT, ce qui est tout l'intérêt : combiné à l'`AllAir` de la
* source constante, c'est la première fois qu'un archétype de grotte peut prouver « tout air »
* (`OPSTACK-DECOMPOSITION §7`).
*
* BORNE, et pourquoi elle est sûre dans les deux directions :
* • en XY, `Sdf ≥ DistXY − Rxy` (l'enveloppe ne dépasse jamais `Rxy`), et le warp déplace
* le POINT de `WarpAmp · VOXEL_NOISE_SCALE · √2` au plus (FBM ∈ [−1,1] sur DEUX axes
* indépendants — voir la note √2 dans le corps) ;
* • en Z, `Sdf ≥ WorldZ − TopSurf ≥ WorldZ − TopZ`, donc au-dessus du sommet + marge il
* n'y a plus rien à faire. **En dessous, il n'y a PAS de borne** : sous une île, le SDF
* vaut ≈ `DistXY` à toute profondeur, donc un mince fil de matière descend le long de
* l'axe. C'est le comportement de l'original ; le confondre avec « rien en dessous »
* serait un TROU, et c'est pourquoi seule la borne HAUTE est testée.
* • `ExtraReach` couvre l'aval (rugosité, blend du fill, creux du SmoothMin ≤ K/6).
*/
EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext&) const override
{
// ⚠️ √2, PAS 1×. Le warp déplace X et Y par DEUX échantillons de bruit INDÉPENDANTS,
// chacun borné par `WarpAmp · VOXEL_NOISE_SCALE`. Le déplacement du POINT est donc la
// diagonale, `WarpMax·√2`, et non `WarpMax`. Une marge à 1× serait fausse de 41 % dans
// le pire cas — c'est-à-dire un trou dans le coin exact où les deux bruits saturent
// ensemble. Rare, et c'est précisément ce qui rendrait le bug injoignable en test.
// TWO independent noise samples ⇒ the point displacement is the diagonal, not one axis.
constexpr float Sqrt2 = 1.4142136f;
const float WarpMax = (P.IslandMinRadius + P.IslandMaxRadius) * 0.5f * 0.35f
* VOXEL_NOISE_SCALE * Sqrt2;
const float Pad = ExtraReach + FMath::Abs(WarpMax);
const float MaxR = FMath::Max(P.IslandMinRadius, P.IslandMaxRadius);
const float Spacing = FMath::Max(P.IslandSpacing, 1.0f);
const FBox Padded = VoxelBox.ExpandBy(MaxR + Pad);
const int32 CX0 = FMath::FloorToInt((float)Padded.Min.X / Spacing);
const int32 CX1 = FMath::FloorToInt((float)Padded.Max.X / Spacing);
const int32 CY0 = FMath::FloorToInt((float)Padded.Min.Y / Spacing);
const int32 CY1 = FMath::FloorToInt((float)Padded.Max.Y / Spacing);
for (int32 cy = CY0; cy <= CY1; ++cy)
for (int32 cx = CX0; cx <= CX1; ++cx)
{
FIsland Isl;
if (!RollIsland(cx, cy, Isl)) { continue; }
// Entièrement au-dessus du sommet de l'île (+ marge) ⇒ hors d'atteinte.
if ((float)VoxelBox.Min.Z > Isl.TopZ + Pad) { continue; }
const float R = Isl.Rxy + Pad;
const float QX = FMath::Max(0.0f, FMath::Max((float)VoxelBox.Min.X - Isl.X,
Isl.X - (float)VoxelBox.Max.X));
const float QY = FMath::Max(0.0f, FMath::Max((float)VoxelBox.Min.Y - Isl.Y,
Isl.Y - (float)VoxelBox.Max.Y));
if (QX * QX + QY * QY < R * R) { return EVoxelOpEffect::FillOnly; }
}
return EVoxelOpEffect::Identity;
}
private:
/** Tirage d'une cellule. PURE en (cellule, seed, params) ⇒ `Eval` et `EffectOverBox` ne
* peuvent pas voir des îles différentes. Transcription littérale du bloc de cuisson de
* `GetFloatingIslandDensity`. */
bool RollIsland(int32 nx, int32 ny, FIsland& Out) const
{
const float H = P.StrateTopWorldZ - P.StrateBottomWorldZ;
const float Spacing = FMath::Max(P.IslandSpacing, 1.0f);
const float MidZ = (P.StrateTopWorldZ + P.StrateBottomWorldZ) * 0.5f;
const uint32 Hh = VoxelHash::Cell(nx, ny, Salt);
if (VoxelHash::ToFloat01(Hh) > P.IslandDensity) { return false; }
const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x12345678u));
const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x9ABCDEF0u));
Out.X = (nx + 0.15f + JX * 0.7f) * Spacing;
Out.Y = (ny + 0.15f + JY * 0.7f) * Spacing;
Out.Rxy = FMath::Lerp(P.IslandMinRadius, P.IslandMaxRadius,
VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x5A5Au)));
// PROFIL ASYMÉTRIQUE : dalle de terre au-dessus, dessous qui s'effile en pointe.
Out.TopHalf = Out.Rxy * 0.20f;
const float UnderDepth = Out.Rxy * FMath::Max(P.ThicknessRatio, 0.25f);
const float SpreadZ = FMath::Max(H * 0.5f - FMath::Max(Out.TopHalf, UnderDepth)
- P.BoundarySealThickness, 0.0f) * P.VerticalJitter;
const float Cz = MidZ + VoxelHash::ToFloatSigned(VoxelHash::Mix(Hh ^ 0xB17Du)) * SpreadZ;
Out.TopZ = Cz + Out.TopHalf;
Out.BotZ = Cz - UnderDepth;
// Netteté du taper par île (point d'arrivée du SmoothStep) → silhouettes variées.
Out.TaperEnd = FMath::Lerp(0.45f, 0.7f, VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x7A1Eu)));
return true;
}
/**
* Le voisinage 3×3, mémoïsé par worker — la même cuisson `thread_local` que l'original.
*
* ⚠️ LA CLÉ INCLUT `BoundarySealThickness`, QUE L'ORIGINAL OMET. `SpreadZ` s'en sert
* (`H·0.5 − max(TopHalf, UnderDepth) − Seal`), donc dans `GetFloatingIslandDensity` une
* édition à chaud qui ne change QUE l'épaisseur de seal sert des îles périmées. Même famille
* que `AUDIT §C2` et que la régression d'overhang du 2026-07-27 : une clé de cache
* incomplète ne se voit pas, elle produit du terrain plausible. Ajouter le champ ne coûte
* qu'un recalcul, jamais une valeur différente — donc l'égalité binaire tient.
*
* The key includes BoundarySealThickness, which the original omits although SpreadZ reads it.
* Adding it can only cost a recompute, never change a value — bit-equality is unaffected.
*/
const FCells& GetCells(float WorldX, float WorldY) const
{
const float Spacing = FMath::Max(P.IslandSpacing, 1.0f);
const int32 CX = FMath::FloorToInt(WorldX / Spacing);
const int32 CY = FMath::FloorToInt(WorldY / Spacing);
thread_local FCells Cache;
thread_local int32 FI_CX = INT32_MAX, FI_CY = INT32_MAX;
thread_local uint32 FI_Salt = 0xFFFFFFFFu;
thread_local float FI_Spacing = -1.0f, FI_Dens = -1.0f, FI_MinR = -1.0f, FI_MaxR = -1.0f,
FI_Thick = -1.0f, FI_VJit = -1.0f, FI_Seal = -1.0f,
FI_BotZ = FLT_MAX, FI_TopZ = FLT_MAX;
if (CX != FI_CX || CY != FI_CY || Salt != FI_Salt || Spacing != FI_Spacing ||
P.IslandDensity != FI_Dens || P.IslandMinRadius != FI_MinR ||
P.IslandMaxRadius != FI_MaxR || P.ThicknessRatio != FI_Thick ||
P.VerticalJitter != FI_VJit || P.BoundarySealThickness != FI_Seal ||
P.StrateBottomWorldZ != FI_BotZ || P.StrateTopWorldZ != FI_TopZ)
{
FI_CX = CX; FI_CY = CY; FI_Salt = Salt; FI_Spacing = Spacing;
FI_Dens = P.IslandDensity; FI_MinR = P.IslandMinRadius; FI_MaxR = P.IslandMaxRadius;
FI_Thick = P.ThicknessRatio; FI_VJit = P.VerticalJitter;
FI_Seal = P.BoundarySealThickness;
FI_BotZ = P.StrateBottomWorldZ; FI_TopZ = P.StrateTopWorldZ;
Cache.Islands.Reset();
for (int32 dy = -1; dy <= 1; dy++)
for (int32 dx = -1; dx <= 1; dx++)
{
FIsland Isl;
if (RollIsland(CX + dx, CY + dy, Isl)) { Cache.Islands.Add(Isl); }
}
}
return Cache;
}
FFloatingIslandParams P;
uint32 Salt;
float ExtraReach;
};
} // ⚠️ FIN DU NAMESPACE ANONYME — TOUT NOUVEL OPÉRATEUR SE MET AU-DESSUS DE CETTE LIGNE.
// Même piège que dans VoxelHeightOpStack.cpp : s'ancrer sur une bannière située plus bas
// (« FVoxelOpStack », « FABRIQUES ») insère la classe HORS du namespace anonyme, et l'accolade
@@ -1459,7 +1721,14 @@ namespace VoxelDensityOps
{
TUniquePtr<IVoxelDensityOp> MakeConstantRockSource(float BaseDensity)
{
return MakeUnique<FConstantRockSource>(BaseDensity);
return MakeUnique<FConstantFieldSource>(BaseDensity);
}
TUniquePtr<IVoxelDensityOp> MakeConstantVoidSource(float BaseDensity)
{
// `float Density = -Params.BaseDensity; // start as open air (void)` — la négation unaire
// est exacte, donc c'est littéralement la première ligne de GetFloatingIslandDensity.
return MakeUnique<FConstantFieldSource>(-BaseDensity);
}
TUniquePtr<IVoxelDensityOp> MakeLatticeCorridorSource(const FMazeGenerationParams& P, int32 Seed, float ExtraReach)
@@ -1475,7 +1744,12 @@ namespace VoxelDensityOps
TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity)
{
return MakeUnique<FSdfCarveOp>(Blend, BaseDensity);
return MakeUnique<FSdfConvertOp>(Blend, BaseDensity, -1.0f);
}
TUniquePtr<IVoxelDensityOp> MakeSdfFill(float Blend, float BaseDensity)
{
return MakeUnique<FSdfConvertOp>(Blend, BaseDensity, +1.0f);
}
TUniquePtr<IVoxelDensityOp> MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed)
@@ -1563,6 +1837,39 @@ namespace VoxelDensityOps
P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager);
}
void BuildFloatingIslandStack(FVoxelOpStack& OutStack, const FFloatingIslandParams& P,
int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager)
{
// ⚠️ LA PILE QUI S'INVERSE, et c'est la mesure que ce portage-ci ajoute : les quatre autres
// archétypes partent de ROC et CREUSENT ; celui-ci part du VIDE et REMPLIT. Aucune des deux
// extrémités n'a demandé un opérateur neuf — la source constante et la conversion SDF→densité
// sont les MÊMES classes, au signe près (`FConstantFieldSource`, `FSdfConvertOp`). Un
// opérateur qui se réutilise en s'inversant est une preuve plus forte qu'un opérateur qui se
// réutilise à l'identique : ça veut dire que l'axe abstrait (le signe de la densité) est le
// bon, pas seulement que deux archétypes se ressemblaient.
//
// The stack that runs BACKWARDS: four archetypes start from rock and carve, this one starts
// from void and fills — and neither end needed a new operator, only the opposite sign.
const float BlendK = FMath::Max(P.SDFBlendRadius, 0.01f);
// Portée que la source doit déclarer pour la paire source+fill : la rugosité peut abaisser
// le SDF de `Rough·VOXEL_NOISE_SCALE` (FBM ∈ [-1,1]), le SmoothMin de `K/6` de plus, et le
// fill s'applique dès `Sdf < BlendK`. Sur-estimer coûte du CPU ; sous-estimer serait un trou.
const float ExtraReach = FMath::Abs(P.SurfaceRoughness) * VOXEL_NOISE_SCALE
+ BlendK * 2.0f + 1.0f;
OutStack.Add(MakeConstantVoidSource(P.BaseDensity));
OutStack.Add(MakeUnique<FIslandBlobSource>(P, Seed, ExtraReach));
// Fréquence 0.08 et 4 octaves — les constantes de `GetFloatingIslandDensity`. Quatrième
// archétype à réutiliser cet opérateur (Maze 0.12/3, VerticalShafts 0.1/3).
OutStack.Add(MakeSdfRoughnessMod(P.SurfaceRoughness, 0.08f, 4,
P.SurfaceRoughness + BlendK + 2.0f));
OutStack.Add(MakeSdfFill(BlendK, P.BaseDensity));
OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ,
P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager);
}
void BuildMazeStack(FVoxelOpStack& OutStack, const FMazeGenerationParams& P,
int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager)
{