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:
@@ -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
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user