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:
2026-07-27 17:50:36 +02:00
parent f5d5a03ad1
commit 96e75abe57
11 changed files with 901 additions and 35 deletions
@@ -0,0 +1,311 @@
// VoxelForgeOpStackIslandTest.cpp
// FloatingIslands — le portage qui fait tourner la pile À L'ENVERS.
// FloatingIslands — the port that runs the stack BACKWARDS.
//
// CE QUE CELUI-CI PROUVE EN PLUS DES AUTRES
// `VerticalShaftEquivalence` a mesuré la réutilisation À L'IDENTIQUE : trois opérateurs de Maze
// repris sans une ligne de changement. Celui-ci mesure quelque chose de plus fort, et de plus
// risqué pour l'abstraction : **la réutilisation PAR INVERSION**.
//
// Les quatre archétypes déjà portés partent tous de ROC et CREUSENT. FloatingIslands part du VIDE
// et REMPLIT. Si l'axe abstrait choisi (le SIGNE de la densité, convention interne positif = solide)
// est le bon, alors les deux extrémités de la pile doivent être les MÊMES opérateurs au signe près :
//
// FConstantFieldSource(+Base) ←→ FConstantFieldSource(-Base)
// FSdfConvertOp(Sign = -1) ←→ FSdfConvertOp(Sign = +1)
//
// Et c'est le cas : le seul opérateur neuf de ce portage est le blob d'île. Un archétype qui se
// réutilise en s'INVERSANT est une preuve plus forte qu'un archétype qui se réutilise à l'identique
// — le premier dit que l'abstraction a trouvé le bon axe, le second seulement que deux archétypes
// se ressemblaient.
//
// ET LE VERDICT DE BOÎTE : c'est ici que `ClassifyBox` peut rendre **AllAir** pour la première fois
// de tout le plugin. Une strate d'îles flottantes est, par construction, surtout vide ; aucun
// archétype de grotte n'a jamais su prouver « tout air » (`OPSTACK-DECOMPOSITION §7`). Le test
// compte les deux verdicts SÉPARÉMENT, parce qu'un total agrégé masquerait exactement ce gain-là.
//
// LA BARRE : bit à bit, comme les autres depuis `FPSemantics = Precise` (AUDIT §C9/§C10).
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "Async/ParallelFor.h"
#include "HAL/PlatformMisc.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelDensityOpStack.h"
#include <atomic>
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeOpStackIslandTest,
"VoxelForge.OpStack.FloatingIslandEquivalence",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
constexpr int32 NumIslandSamples = 20000;
/**
* Les défauts génèrent bien des îles, mais un test qui les prend tels quels laisse la question
* « les échantillons sont-ils VRAIMENT tombés dedans ? » à la chance du seed. On force donc une
* densité d'îles haute, et surtout un `TopFlatten < 1` — la branche du dôme de bord est le seul
* endroit où `TopHalf` et `Edge²` interviennent, et elle est silencieusement morte à 1.0.
* (Même piège que `WaterLevelRelative` et la fenêtre d'overhang : un paramètre au repos est un
* opérateur non testé.)
*/
void EnableIslandFeatures(FFloatingIslandParams& P)
{
P.IslandDensity = 0.75f; // des îles dans presque chaque cellule du 3×3
P.TopFlatten = 0.55f; // < 1 ⇒ la branche du dôme de bord s'exécute
P.SurfaceRoughness = 4.0f; // la rugosité SDF partagée avec Maze et VerticalShafts
P.VerticalJitter = 0.6f; // des îles à des hauteurs différentes
P.ThicknessRatio = 0.7f;
}
}
bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
FTestWorld World;
World.Build();
if (!World.IsValid())
{
AddError(World.WhyInvalid());
return false;
}
const UVoxelGenerator* Gen = World.Generator.Get();
int32 TopVoxelZ = 0, BottomVoxelZ = 0;
if (!World.GetSlotVoxelZRange(FTestWorld::SlotFloatingIsland, TopVoxelZ, BottomVoxelZ))
{
AddError(TEXT("The fixture layout has no FloatingIslands slot. Check FTestWorld::Build's ")
TEXT("Archetypes[] against FTestWorld::SlotFloatingIsland."));
return false;
}
const int32 MidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE;
FFloatingIslandParams P = World.StrateManager->GetFloatingIslandParamsForChunk(
FIntVector(0, 0, MidChunkZ));
if (P.StrateTopWorldZ - P.StrateBottomWorldZ <= 0.0f)
{
AddError(TEXT("The FloatingIslands strate has degenerate Z bounds, which sends ")
TEXT("GetFloatingIslandDensity down its early-out. The op stack has none by design."));
return false;
}
EnableIslandFeatures(P);
FVoxelOpStack Stack;
VoxelDensityOps::BuildFloatingIslandStack(Stack, P, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
// void + blobs + roughness + fill + 3 structurels.
TestEqual(TEXT("the island stack is decomposed into 7 ops"), Stack.Num(), 7);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
Ctx.StrateTopWorldZ = P.StrateTopWorldZ;
Ctx.StrateBottomWorldZ = P.StrateBottomWorldZ;
Stack.PrepareChunk(Ctx);
TArray<FVector> Points;
Points.Reserve(NumIslandSamples);
{
FRandomStream Rng(60186);
for (int32 i = 0; i < NumIslandSamples; ++i)
{
Points.Add(FVector(
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
(float)Rng.RandRange(BottomVoxelZ, TopVoxelZ)));
}
}
//=========================================================================
// 1. ÉQUIVALENCE
//=========================================================================
// On compte SÉPARÉMENT le solide d'intérieur et le solide de seal : sur cet archétype la
// quasi-totalité du volume est de l'air, donc un « N solides » agrégé serait dominé par les
// deux bandes de seal et ne dirait RIEN sur les îles elles-mêmes.
const float InnerBot = P.StrateBottomWorldZ + P.BoundarySealThickness;
const float InnerTop = P.StrateTopWorldZ - P.BoundarySealThickness;
int32 NumDiff = 0, NumSideDisagree = 0, WorstIdx = -1;
int32 NumInsideIsland = 0, NumOpenVoid = 0;
float WorstDelta = 0.0f;
for (int32 i = 0; i < NumIslandSamples; ++i)
{
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
const float Old = Gen->GetFloatingIslandDensity(X, Y, Z, P);
const float New = Stack.EvalMC(X, Y, Z);
const bool bInterior = (Z > InnerBot && Z < InnerTop);
if (bInterior && Old < 0.0f) { ++NumInsideIsland; } // solide loin des seals ⇒ une île
if (bInterior && Old >= 0.0f) { ++NumOpenVoid; }
if (!BitEqual(Old, New))
{
++NumDiff;
const float D = FMath::Abs(Old - New);
if (D > WorstDelta) { WorstDelta = D; WorstIdx = i; }
}
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSideDisagree; }
}
if (NumDiff == 0)
{
AddInfo(FString::Printf(
TEXT("FloatingIslands: bit-identical across %d samples (%d inside island rock away from ")
TEXT("the seal bands, %d in open void, so the void source, the blobs, the roughness and ")
TEXT("the fill were all exercised). The stack runs BACKWARDS -- void source + fill ")
TEXT("instead of rock source + carve -- using the SAME operators with the opposite ")
TEXT("sign. Only the blob source is new (OPSTACK-PLAN 2.5)."),
NumIslandSamples, NumInsideIsland, NumOpenVoid));
}
else
{
AddError(FString::Printf(
TEXT("FloatingIslands: %d of %d samples differ (largest |delta| %.9g at (%.0f, %.0f, ")
TEXT("%.0f)); %d cross the isosurface. Since /fp:precise the bar is bit-identity, so ")
TEXT("this is a real port error. Check, in order: the C1 warp fix (BOTH paths must now ")
TEXT("use VoxelHash::SeedOffset(S, 0.0007f) -- if only one was changed, EVERY warped ")
TEXT("sample differs), then the SdfConvert SIGN (+1 fills, -1 carves), then the 'Isld' ")
TEXT("salt (0x49736C64), the roughness frequency (0.08 / 4 octaves here, NOT Maze's ")
TEXT("0.12 / 3), the per-island TaperEnd and TopFlatten dome branch, and the ")
TEXT("SmoothMin blend K = max(SDFBlendRadius, 0.01)."),
NumDiff, NumIslandSamples, WorstDelta,
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f,
NumSideDisagree));
}
TestEqual(TEXT("no sample lands on the opposite side of the isosurface"), NumSideDisagree, 0);
if (NumInsideIsland == 0)
{
AddWarning(TEXT("No sample landed inside island rock away from the seal bands, so the blob ")
TEXT("source and the fill were never meaningfully exercised -- the equivalence ")
TEXT("above then only proves that two empty voids agree. Raise IslandDensity or ")
TEXT("IslandMaxRadius."));
}
//=========================================================================
// 2. INVARIANCE DE FENÊTRE
//=========================================================================
// La source garde un cache 3×3 `thread_local` dont la clé est le jeu de params — et cette clé
// inclut délibérément `BoundarySealThickness`, que l'original omet alors que `SpreadZ` le lit
// (voir la note dans FIslandBlobSource::GetCells).
{
std::atomic<int32> Impure{ 0 };
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
TArray<float> Ref;
Ref.SetNumUninitialized(NumIslandSamples);
for (int32 i = 0; i < NumIslandSamples; ++i)
{
Ref[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
}
ParallelFor(NumBlocks, [&](int32 Block)
{
TArray<int32> LocalOrder;
BuildShuffledOrder(NumIslandSamples, 3300 + Block, LocalOrder);
for (const int32 i : LocalOrder)
{
const float V = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
if (!BitEqual(V, Ref[i])) { Impure.fetch_add(1, std::memory_order_relaxed); }
}
});
TestEqual(TEXT("the island stack is window-invariant across order and threads"),
Impure.load(), 0);
}
//=========================================================================
// 3. LE VERDICT DE BOÎTE — et la première preuve « AllAir » du plugin
//=========================================================================
{
int32 NumProvedSolid = 0, NumProvedAir = 0, NumMixed = 0, NumUnsound = 0;
FRandomStream Rng(24680);
for (int32 t = 0; t < 60; ++t)
{
const int32 Step = 1, Cells = 8;
const int32 Extent = Step * Cells;
const FIntVector Origin(
Rng.RandRange(-6, 6) * Extent,
Rng.RandRange(-6, 6) * Extent,
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
const int32 GridDim = Cells + 1;
const FBox Box(
FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step),
FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step));
const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Ctx);
if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; }
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
if (bClaimsSolid) { ++NumProvedSolid; } else { ++NumProvedAir; }
for (int32 gz = -1; gz <= GridDim; ++gz)
for (int32 gy = -1; gy <= GridDim; ++gy)
for (int32 gx = -1; gx <= GridDim; ++gx)
{
const float X = (float)(Origin.X + gx * Step);
const float Y = (float)(Origin.Y + gy * Step);
const float Z = (float)(Origin.Z + gz * Step);
const float D = Stack.EvalMC(X, Y, Z);
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
{
if (NumUnsound == 0)
{
AddError(FString::Printf(
TEXT("HOLE: the island stack claimed %s for the box at (%d,%d,%d) but ")
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. Suspects, in ")
TEXT("order: the blob source's Pad (does it cover the WARP amplitude ")
TEXT("AND the roughness AND the fill blend AND the SmoothMin dip?), ")
TEXT("then the Z bound -- note there is NO lower bound, a thin thread ")
TEXT("of matter hangs below each island down the axis, so only the ")
TEXT("TOP may be used to reject."),
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
Origin.X, Origin.Y, Origin.Z, X, Y, Z, D,
(D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
}
++NumUnsound;
gz = gy = gx = GridDim + 1;
}
}
}
TestEqual(TEXT("every box verdict the island stack emits survives brute force"), NumUnsound, 0);
AddInfo(FString::Printf(
TEXT("Box verdicts over 60 FloatingIslands tiles: %d proved AllSolid, %d proved AllAir, ")
TEXT("%d Mixed. Today's ClassifyTile proves ZERO of these. The AllAir count is the new ")
TEXT("thing: no cave archetype has ever been able to prove 'all air', and a floating-")
TEXT("island strate is mostly exactly that (OPSTACK-DECOMPOSITION 7)."),
NumProvedSolid, NumProvedAir, NumMixed));
if (NumProvedAir == 0)
{
AddWarning(TEXT("Zero tiles proved AllAir. The stack is still SOUND, but the whole perf ")
TEXT("argument for this archetype rests on that verdict, so it is worth ")
TEXT("knowing it did not fire. Most likely the blob source's Pad is so wide ")
TEXT("that every box finds an island within reach -- the same pessimism ")
TEXT("VerticalShafts has (0 of 60), for the same reason."));
}
}
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
+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)
{
+24 -1
View File
@@ -664,6 +664,20 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
VoxelDensityOps::BuildVerticalShaftStack(CP_OpStack, CP_Vert, Seed,
OriginSpineRadius, StrateManager);
break;
case ECaveGeneratorType::FloatingIslands:
// Même garde de strate dégénérée : GetFloatingIslandDensity court-circuite sur
// `return 1.0f` (= air) quand la hauteur est nulle ou négative.
if (CP_Float.StrateTopWorldZ - CP_Float.StrateBottomWorldZ <= 0.0f)
{
CP_UseOpStack = false;
break;
}
OpCtx.StrateTopWorldZ = CP_Float.StrateTopWorldZ;
OpCtx.StrateBottomWorldZ = CP_Float.StrateBottomWorldZ;
VoxelDensityOps::BuildFloatingIslandStack(CP_OpStack, CP_Float, Seed,
OriginSpineRadius, StrateManager);
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`
@@ -3428,7 +3442,16 @@ float UVoxelGenerator::GetFloatingIslandDensity(float WorldX, float WorldY, floa
// organic instead of perfect circles. Computed once per voxel and shared by all nearby
// islands (each samples a different part of the field → distinct silhouettes).
const float WarpAmp = (Params.IslandMinRadius + Params.IslandMaxRadius) * 0.5f * 0.35f;
const float WX = WorldX + FractalNoise3D(FVector(WorldX * 0.04f + (float)S * 0.0007f, WorldY * 0.04f, WorldZ * 0.012f), VoxelGenLOD::Eff(3))
// ⚠️ AUDIT §C1 — DERNIER SITE DU PLUGIN, trouvé en portant cet archétype (2026-07-28). Le
// balayage du 2026-07-27 cherchait le motif `SeedF * K` et celui-ci s'écrit `(float)S * K`, donc
// il a survécu : à Seed = 2e9 le terme atteint ~1.4e6, où l'ULP du float vaut 0.125 contre un pas
// de 0.04 par voxel — le warp s'aplatit et les îles redeviennent des cercles parfaits. Corrigé
// dans les DEUX chemins (ici et FIslandBlobSource) en une passe, pour que le test d'équivalence
// reste un oracle valable.
// NOTE : `SeedOffset` quantifie la clé de site par ×100, donc 0.0007 → site 0. Unique aujourd'hui
// (toutes les autres clés du plugin sont ≥ 0.19) ; la prochaine clé sous 0.005 devra en choisir
// une autre plutôt que de collisionner en silence.
const float WX = WorldX + FractalNoise3D(FVector(WorldX * 0.04f + VoxelHash::SeedOffset(S, 0.0007f), WorldY * 0.04f, WorldZ * 0.012f), VoxelGenLOD::Eff(3))
* VOXEL_NOISE_SCALE * WarpAmp;
const float WY = WorldY + FractalNoise3D(FVector(WorldX * 0.04f + 31.0f, WorldY * 0.04f + 7.0f, WorldZ * 0.012f), VoxelGenLOD::Eff(3))
* VOXEL_NOISE_SCALE * WarpAmp;
@@ -585,6 +585,11 @@ bool UVoxelStrateManager::UsesOperatorStackForChunk(const FIntVector& ChunkCoord
case ECaveGeneratorType::VerticalShafts: return true; // Phase 2 — 3 ops repris de Maze tels quels
case ECaveGeneratorType::FloatingIslands:
// Phase 2 — la pile qui tourne à l'ENVERS : source de VIDE + fill, au lieu de source de ROC
// + carve, avec les MÊMES opérateurs au signe près. 6 des 8 portés.
return true;
default: return false;
}
}
+40 -7
View File
@@ -5,18 +5,24 @@
// ⚠️ CECI ALIMENTE LE JEU, MAIS SEULEMENT SUR OPT-IN (depuis OPSTACK-PLAN §4, Phase 1, point 3).
// `UVoxelGenerator::GetDensityAt` construit la pile par chunk et l'évalue à la place du `switch`
// UNIQUEMENT quand `UVoxelStrateManager::UsesOperatorStackForChunk` rend true — c.-à-d. quand la
// strate a coché `bUseOperatorStack` ET que son archétype figure dans la liste des portés (Maze
// seul aujourd'hui). Toute autre strate passe encore par le `switch`, inchangé.
// strate a coché `bUseOperatorStack` ET que son archétype figure dans la liste des portés :
// **Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands (6 sur 8)**.
// Toute autre strate passe encore par le `switch`, inchangé.
// `ClassifyTile` n'est PAS branché : il utilise toujours ses gardes écrites à la main, pas
// `ClassifyBox`. C'est la Phase 2.
//
// THIS FEEDS THE GAME, BUT ONLY BEHIND AN OPT-IN. GetDensityAt builds the stack per chunk and
// evaluates it instead of the switch only when UsesOperatorStackForChunk returns true (strate
// ticked bUseOperatorStack AND its archetype is ported — Maze only, today). ClassifyTile is NOT
// wired: it still uses its hand-written guards rather than ClassifyBox. That is Phase 2.
// evaluates it instead of the switch only when UsesOperatorStackForChunk returns true (strate ticked
// bUseOperatorStack AND its archetype ported — 6 of 8). ClassifyTile is NOT wired: it still uses its
// hand-written guards rather than ClassifyBox. That is Phase 2.
//
// ⛔ NE JAMAIS faire tourner les deux chemins dans le même monde, ni les comparer pour l'égalité :
// le résidu de ~1 ULP est INHÉRENT et documenté (AUDIT-2026-07 §C10). La barre est visuelle (§2.6).
// ⛔ NE JAMAIS faire tourner les deux chemins dans le même monde.
// ⚠️ EN REVANCHE, LES COMPARER EST DEVENU LÉGITIME — cette ligne disait l'inverse et elle est
// périmée. `AUDIT §C10` (le résidu ~1 ULP) est CLOS depuis `FPSemantics = Precise` : les cinq tests
// d'équivalence comparent bit à bit et sont verts. Ils ne sont plus des contrôles de FIDÉLITÉ (la
// barre `§2.6.1` n'exige aucune ressemblance avec l'ancien monde) mais des oracles de
// CORRECTION DE PORTAGE — une faute de transcription reste un vrai bug, et l'ancienne fonction est
// le moyen le moins cher de l'attraper.
//
// POURQUOI CETTE FORME / WHY THIS SHAPE
// La question à laquelle la Phase 1 doit répondre n'est pas « est-ce que ça marche ? » mais
@@ -161,6 +167,12 @@ namespace VoxelDensityOps
* Racine de TunnelNetwork, Maze, VerticalShafts et des gaps de bedrock. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeConstantRockSource(float BaseDensity);
/** Rôle 1 — le MÊME opérateur au signe près : `Density = -BaseDensity`, un grand vide ouvert.
* `ClassifyBox` → **AllAir**, ce qu'aucune source n'avait encore su rendre — c'est ce qui rend
* une strate d'îles flottantes (surtout vide) sautable là où aucune île n'arrive. Racine de
* FloatingIslands. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeConstantVoidSource(float BaseDensity);
/** Rôle 1 — les couloirs de Maze : capsules sur les arêtes ouvertes d'un treillis 3D.
* Écrit le canal SDF uniquement. Identité d'arête = hash(nœud inférieur, axe), donc deux
* chunks adjacents NE PEUVENT PAS être en désaccord : pas de cache de chunk, pas de région
@@ -184,6 +196,11 @@ namespace VoxelDensityOps
* Les six mêmes lignes apparaissent aujourd'hui dans TunnelNetwork, Maze et VerticalShafts. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity);
/** Rôle 2 — la même conversion, signe opposé : REMPLIT du solide là où le SDF est à l'intérieur.
* C'est ce que fait FloatingIslands (`Density += Fill·Base·2`), et la multiplication par ±1
* étant exacte en IEEE-754, le chemin carve reste bit pour bit ce qu'il était. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSdfFill(float Blend, float BaseDensity);
/** Rôle 1 — la dalle : surface de sol + surface de plafond → champ de vide. **XY-PUR** depuis
* OPSTACK-DECOMPOSITION §3.1 (le terme en Z des deux bruits est parti), ce qui lui donne un
* `ClassifyBox` EXACT sans échantillonnage : les deux surfaces vivent dans des bandes en Z
@@ -243,6 +260,22 @@ namespace VoxelDensityOps
int32 Seed, float SpineRadius,
const UVoxelStrateManager* StrateManager);
/**
* FloatingIslands — 7 ops, et **la pile tourne à l'ENVERS** :
* ConstantVoid → IslandBlob → SdfRoughness → SdfFill → [structural post ×3]
*
* Les quatre archétypes portés jusqu'ici partent de ROC et CREUSENT ; celui-ci part du VIDE et
* REMPLIT. Aucune des deux extrémités n'a demandé d'opérateur neuf — `FConstantFieldSource` et
* `FSdfConvertOp` sont les mêmes classes au signe près, et `FSdfRoughnessMod` est repris sans
* une ligne de changement (4ᵉ archétype). Seul le blob d'île est nouveau.
*
* The stack that runs backwards: void source + fill instead of rock source + carve, using the
* SAME operators with the opposite sign.
*/
VOXELFORGE_API void BuildFloatingIslandStack(FVoxelOpStack& OutStack, const FFloatingIslandParams& P,
int32 Seed, float SpineRadius,
const UVoxelStrateManager* StrateManager);
/**
* La pile Maze complète, décomposée — PAS un `FMazeOp` monolithique :
* ConstantRockSource → LatticeCorridorSource → SdfRoughnessMod → SdfCarve → [structural post]