feat: port VerticalShafts — three ops reused from Maze unchanged
The port that tests the thesis rather than the fidelity. Previous ports asked whether the decomposition reproduces the original; this one asks whether operators actually get reused across archetypes, which is section 2.5's claim and the only reason to do this refactor instead of tidying the switch. ConstantRock, SdfRoughness and SdfCarve are Maze's, reused without a line changed. In the switch, GetMazeDensity and GetVerticalShaftDensity are two ~100-line functions with nothing visibly in common; as operators they are the same three ops with a different source and different tuning (freq 0.1 vs 0.12, window rough+4 vs R+rough+2). New: FShaftFieldSource (infinite cylinders + hash-gated connectors into the SDF channel) and FShaftLedgeMod (banded shelves on the +X/+Y half so the shaft stays climbable). Deviation from section 6, stated: it suggested splitting the source so the XY-pure cylinder half could get an exact box verdict. Kept as one op because the connectors derive from the same 3x3 roll and the ledge mod needs the shaft list anyway, so splitting means rolling twice or sharing a cache between two ops. Forfeited: the exact verdict on the cylinder half. Kept: a conservative EffectOverBox testing circles and connector reach. FShaftLedgeMod gates on the POST-roughness Sdf as the stack left it; re-deriving it would use the pre-roughness value and shift every ledge. Reading the channel rather than recomputing is what the two-channel sample is for. Compile fix: FCells was declared below the functions returning it. Member bodies are deferred, return types are not. Ported: Maze, FlatPlain, CrystalChamber, SurfaceWorld (biomes included), VerticalShafts — 5 of 8. UNVERIFIED: not compiled past the FCells fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
// VoxelForgeOpStackShaftTest.cpp
|
||||
// VerticalShafts — le portage qui teste la RÉUTILISATION, pas seulement la fidélité.
|
||||
// VerticalShafts — the port that tests REUSE, not just fidelity.
|
||||
//
|
||||
// CE QUE CELUI-CI PROUVE EN PLUS DES AUTRES
|
||||
// Les portages précédents demandaient « la décomposition reproduit-elle l'original ? ». Celui-ci
|
||||
// demande **« les opérateurs se RÉUTILISENT-ils vraiment entre archétypes ? »**, qui est la thèse
|
||||
// de `OPSTACK-PLAN §2.5` et la seule raison de faire ce refactor plutôt que de nettoyer le `switch`.
|
||||
//
|
||||
// Trois des cinq opérateurs de VerticalShafts sont ceux de Maze, **repris sans une ligne de
|
||||
// changement** : `ConstantRock`, `SdfRoughness`, `SdfCarve`. Dans le `switch`, `GetMazeDensity` et
|
||||
// `GetVerticalShaftDensity` sont deux fonctions de ~100 lignes qui n'ont rien en commun à l'œil.
|
||||
// En opérateurs, ce sont les mêmes trois ops avec une source différente et d'autres réglages
|
||||
// (fréquence 0.1 au lieu de 0.12, fenêtre `rough + 4` au lieu de `R + rough + 2`).
|
||||
//
|
||||
// **Si ce test passe en bit-à-bit, la réutilisation n'est plus une intention : c'est une mesure.**
|
||||
//
|
||||
// LA BARRE : bit à bit, comme les autres depuis `FPSemantics = Precise` (AUDIT §C9/§C10). Un écart
|
||||
// est une vraie trouvaille, pas du bruit d'arrondi.
|
||||
|
||||
#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(
|
||||
FVoxelForgeOpStackShaftTest,
|
||||
"VoxelForge.OpStack.VerticalShaftEquivalence",
|
||||
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr int32 NumShaftSamples = 20000;
|
||||
|
||||
/** Les ledges et les connecteurs sont éteints ou discrets par défaut. Un test sur les seuls
|
||||
* défauts vérifierait les cylindres et laisserait les DEUX opérateurs intéressants au repos —
|
||||
* le même piège que `WaterLevelRelative` et la fenêtre d'overhang. */
|
||||
void EnableShaftFeatures(FVerticalShaftParams& P)
|
||||
{
|
||||
P.CrossConnectChance = 0.65f; // des connecteurs, donc des capsules dans le SDF
|
||||
P.ConnectorRadius = 3.5f;
|
||||
P.LedgeSpacing = 11.0f; // des étagères, donc l'op forçant s'exécute
|
||||
P.LedgeDepth = 2.5f;
|
||||
P.SurfaceRoughness = 3.0f; // la rugosité SDF partagée avec Maze
|
||||
}
|
||||
}
|
||||
|
||||
bool FVoxelForgeOpStackShaftTest::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::SlotVerticalShafts, TopVoxelZ, BottomVoxelZ))
|
||||
{
|
||||
AddError(TEXT("The fixture layout has no VerticalShafts slot. Check FTestWorld::Build's ")
|
||||
TEXT("Archetypes[] against FTestWorld::SlotVerticalShafts."));
|
||||
return false;
|
||||
}
|
||||
|
||||
const int32 MidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE;
|
||||
FVerticalShaftParams P = World.StrateManager->GetVerticalShaftParamsForChunk(
|
||||
FIntVector(0, 0, MidChunkZ));
|
||||
|
||||
if (P.StrateTopWorldZ - P.StrateBottomWorldZ <= 0.0f)
|
||||
{
|
||||
AddError(TEXT("The VerticalShafts strate has degenerate Z bounds, which sends ")
|
||||
TEXT("GetVerticalShaftDensity down its early-out. The op stack has none by design."));
|
||||
return false;
|
||||
}
|
||||
|
||||
EnableShaftFeatures(P);
|
||||
|
||||
FVoxelOpStack Stack;
|
||||
VoxelDensityOps::BuildVerticalShaftStack(Stack, P, World.Settings->Seed,
|
||||
Gen->OriginSpineRadius, World.StrateManager.Get());
|
||||
|
||||
// rock + shafts + roughness + carve + ledges + 3 structurels.
|
||||
TestEqual(TEXT("the shaft stack is decomposed into 8 ops"), Stack.Num(), 8);
|
||||
|
||||
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(NumShaftSamples);
|
||||
{
|
||||
FRandomStream Rng(80486);
|
||||
for (int32 i = 0; i < NumShaftSamples; ++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
|
||||
//=========================================================================
|
||||
int32 NumDiff = 0, NumSideDisagree = 0, WorstIdx = -1, NumInsideShaft = 0;
|
||||
float WorstDelta = 0.0f;
|
||||
|
||||
for (int32 i = 0; i < NumShaftSamples; ++i)
|
||||
{
|
||||
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
|
||||
|
||||
const float Old = Gen->GetVerticalShaftDensity(X, Y, Z, P);
|
||||
const float New = Stack.EvalMC(X, Y, Z);
|
||||
|
||||
if (Old >= 0.0f) { ++NumInsideShaft; } // air ⇒ dans un puits/connecteur/étagère
|
||||
|
||||
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("VerticalShafts: bit-identical across %d samples (%d of them inside a shaft, so ")
|
||||
TEXT("the cylinders, connectors, roughness, carve and ledges were all exercised). ")
|
||||
TEXT("THREE of the five ops here are Maze's, reused unchanged -- operator reuse across ")
|
||||
TEXT("archetypes is now measured rather than intended (OPSTACK-PLAN 2.5)."),
|
||||
NumShaftSamples, NumInsideShaft));
|
||||
}
|
||||
else
|
||||
{
|
||||
AddError(FString::Printf(
|
||||
TEXT("VerticalShafts: %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 roughness FREQUENCY (0.1 here, ")
|
||||
TEXT("NOT Maze's 0.12) and window (rough + 4, not R + rough + 2), the 'Shft' salt ")
|
||||
TEXT("(0x53686674), the connector pair hash and its Z lerp between sealed bounds, and ")
|
||||
TEXT("the ledge gate reading the POST-roughness Sdf rather than re-deriving it."),
|
||||
NumDiff, NumShaftSamples, 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 (NumInsideShaft == 0)
|
||||
{
|
||||
AddWarning(TEXT("No sample landed inside a shaft, so the source, carve and ledge ops were ")
|
||||
TEXT("never meaningfully exercised. Raise ShaftDensity or ShaftMaxRadius."));
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// 2. INVARIANCE DE FENÊTRE
|
||||
//=========================================================================
|
||||
// La source garde un cache 3×3 `thread_local` dont la clé est le jeu de params : c'est
|
||||
// exactement le genre d'endroit où une clé incomplète produit une couture (AUDIT §C2).
|
||||
{
|
||||
std::atomic<int32> Impure{ 0 };
|
||||
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
|
||||
|
||||
TArray<float> Ref;
|
||||
Ref.SetNumUninitialized(NumShaftSamples);
|
||||
for (int32 i = 0; i < NumShaftSamples; ++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(NumShaftSamples, 2200 + 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 shaft stack is window-invariant across order and threads"),
|
||||
Impure.load(), 0);
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// 3. LE VERDICT DE BOÎTE
|
||||
//=========================================================================
|
||||
{
|
||||
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
|
||||
FRandomStream Rng(13579);
|
||||
|
||||
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; }
|
||||
++NumProved;
|
||||
|
||||
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
|
||||
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 shaft 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 shaft source's ExtraReach (does it cover the roughness ")
|
||||
TEXT("amplitude AND the carve blend?), then the connector sweep (a ")
|
||||
TEXT("connector can reach Spacing*1.6 beyond its cell), then the ledge ")
|
||||
TEXT("op's FillOnly."),
|
||||
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 shaft stack emits survives brute force"), NumUnsound, 0);
|
||||
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("Box verdicts over 60 VerticalShafts tiles: %d proved uniform, %d Mixed. Today's ")
|
||||
TEXT("ClassifyTile proves ZERO of these -- every cave archetype falls through to \"pas ")
|
||||
TEXT("prouvable en v1\"."),
|
||||
NumProved, NumMixed));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||
@@ -1138,6 +1138,264 @@ namespace
|
||||
float Base, Seal;
|
||||
};
|
||||
|
||||
//=========================================================================
|
||||
// RÔLE 1 — SOURCE : PUITS VERTICAUX / VERTICAL SHAFTS
|
||||
//=========================================================================
|
||||
// Cylindres infinis sur une grille XY jitterée + connecteurs horizontaux entre paires proches,
|
||||
// ouverts par un hash de paire symétrique. Écrit le canal SDF uniquement.
|
||||
//
|
||||
// ⚠️ ÉCART ASSUMÉ AVEC `OPSTACK-DECOMPOSITION §6`, qui suggérait DEUX sources (colonnes XY-pures
|
||||
// + connecteurs) pour que la moitié cylindrique reçoive le traitement du cache de colonne et un
|
||||
// `ClassifyBox` exact en XY. Gardé en UN opérateur, et voici pourquoi :
|
||||
// • les connecteurs se dérivent de la MÊME liste 3×3 que les puits (il faut les paires), donc
|
||||
// séparer imposerait soit de rouler les cellules deux fois, soit un cache partagé entre
|
||||
// deux ops — c'est-à-dire la complexité qu'on voulait éviter ;
|
||||
// • le `FShaftLedgeMod` en aval a de toute façon besoin de la liste des puits, donc il faut
|
||||
// l'exposer depuis une source ; l'exposer depuis deux serait pire.
|
||||
// Ce qui est perdu : le verdict de boîte exact sur la seule moitié cylindrique. Ce qui est
|
||||
// gardé : un `EffectOverBox` conservatif qui teste cercles ET capsules, ce que la version
|
||||
// séparée aurait dû faire aussi. À revoir si le profil montre que ça compte.
|
||||
//
|
||||
// Kept as ONE op against §6's suggestion: the connectors derive from the same 3×3 roll as the
|
||||
// shafts, and the downstream ledge mod needs the shaft list anyway. What is forfeited is an
|
||||
// exact XY box verdict on the cylinder half alone.
|
||||
class FShaftFieldSource final : public IVoxelDensityOp
|
||||
{
|
||||
public:
|
||||
FShaftFieldSource(const FVerticalShaftParams& InP, int32 Seed, float InExtraReach)
|
||||
: P(InP), Salt((uint32)Seed ^ 0x53686674u) // 'Shft' — identique à GetVerticalShaftDensity
|
||||
, ExtraReach(InExtraReach) {}
|
||||
|
||||
EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; }
|
||||
void PrepareChunk(const FVoxelOpContext&) override {}
|
||||
|
||||
struct FShaft { float X, Y, R; };
|
||||
struct FConn { FVector A, B; };
|
||||
|
||||
// ⚠️ DÉCLARÉE ICI, avant toute fonction qui la renvoie. Un type imbriqué doit exister au
|
||||
// moment où le COMPILATEUR lit la SIGNATURE — les corps de méthodes sont différés, pas les
|
||||
// types de retour. La mettre en bas de la classe donne un C4430 « int par défaut » suivi
|
||||
// d'une cascade illisible, ce qui masque une cause pourtant triviale.
|
||||
// Declared here, before any function returning it: a nested type must exist when the
|
||||
// compiler reads the SIGNATURE — bodies are deferred, return types are not.
|
||||
struct FCells
|
||||
{
|
||||
TArray<FShaft, TInlineAllocator<9>> Shafts;
|
||||
TArray<FConn, TInlineAllocator<8>> Conns;
|
||||
};
|
||||
|
||||
void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override
|
||||
{
|
||||
const FCells& C = GetCells(WorldX, WorldY);
|
||||
|
||||
float CaveSDF = FLT_MAX;
|
||||
for (const FShaft& Sh : C.Shafts)
|
||||
{
|
||||
const float DX = WorldX - Sh.X;
|
||||
const float DY = WorldY - Sh.Y;
|
||||
CaveSDF = FMath::Min(CaveSDF, FMath::Sqrt(DX * DX + DY * DY) - Sh.R);
|
||||
}
|
||||
const FVector Pos(WorldX, WorldY, WorldZ);
|
||||
for (const FConn& Cn : C.Conns)
|
||||
{
|
||||
CaveSDF = FMath::Min(CaveSDF, VoxelSDF::Capsule(Pos, Cn.A, Cn.B, P.ConnectorRadius));
|
||||
}
|
||||
InOut.Sdf = CaveSDF;
|
||||
}
|
||||
|
||||
/** La liste des puits proches — `FShaftLedgeMod` doit trouver le PLUS PROCHE pour ne
|
||||
* poser d'étagère que sur sa moitié +X/+Y. Même motif que colonne → overhang. */
|
||||
const FCells& GetCellsAt(float WorldX, float WorldY) const { return GetCells(WorldX, WorldY); }
|
||||
|
||||
EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext&) const override
|
||||
{
|
||||
// La source répond pour la paire source+carve (SIMPLIFICATION DE PHASE 1) : `CarveOnly`
|
||||
// si une primitive atteint la boîte, `Identity` sinon. `ExtraReach` couvre la rugosité
|
||||
// et le blend en aval — le sous-estimer serait un TROU.
|
||||
const float Pad = FMath::Max(P.ShaftMaxRadius, P.ConnectorRadius) + ExtraReach;
|
||||
const FBox Padded = VoxelBox.ExpandBy(Pad);
|
||||
|
||||
const float Spacing = FMath::Max(P.ShaftSpacing, 1.0f);
|
||||
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)
|
||||
{
|
||||
FShaft Sh;
|
||||
if (!RollShaft(cx, cy, Sh)) { continue; }
|
||||
// Cercle (rayon + marge) contre le rectangle XY : un cylindre est infini en Z, donc
|
||||
// la question est purement XY.
|
||||
const float R = Sh.R + ExtraReach;
|
||||
const float QX = FMath::Max(0.0f, FMath::Max((float)VoxelBox.Min.X - Sh.X,
|
||||
Sh.X - (float)VoxelBox.Max.X));
|
||||
const float QY = FMath::Max(0.0f, FMath::Max((float)VoxelBox.Min.Y - Sh.Y,
|
||||
Sh.Y - (float)VoxelBox.Max.Y));
|
||||
if (QX * QX + QY * QY < R * R) { return EVoxelOpEffect::CarveOnly; }
|
||||
}
|
||||
|
||||
// ⚠️ Les connecteurs ne sont PAS testés ici, et c'est délibérément conservatif dans le
|
||||
// mauvais sens si on n'y prend pas garde : un connecteur ne peut exister qu'entre deux
|
||||
// puits d'un voisinage, donc si AUCUN puits n'atteint la boîte élargie de `Spacing*1.6`
|
||||
// (la portée max d'une paire), aucun connecteur ne peut l'atteindre non plus.
|
||||
const FBox ConnBox = VoxelBox.ExpandBy(Spacing * 1.6f + Pad);
|
||||
const int32 KX0 = FMath::FloorToInt((float)ConnBox.Min.X / Spacing);
|
||||
const int32 KX1 = FMath::FloorToInt((float)ConnBox.Max.X / Spacing);
|
||||
const int32 KY0 = FMath::FloorToInt((float)ConnBox.Min.Y / Spacing);
|
||||
const int32 KY1 = FMath::FloorToInt((float)ConnBox.Max.Y / Spacing);
|
||||
for (int32 cy = KY0; cy <= KY1; ++cy)
|
||||
for (int32 cx = KX0; cx <= KX1; ++cx)
|
||||
{
|
||||
FShaft Sh;
|
||||
if (RollShaft(cx, cy, Sh)) { return EVoxelOpEffect::CarveOnly; } // prudent
|
||||
}
|
||||
|
||||
return EVoxelOpEffect::Identity;
|
||||
}
|
||||
|
||||
private:
|
||||
/** Tirage d'une cellule. PURE en (cellule, seed, params) ⇒ `Eval` et `EffectOverBox` ne
|
||||
* peuvent pas voir des puits différents. */
|
||||
bool RollShaft(int32 nx, int32 ny, FShaft& Out) const
|
||||
{
|
||||
const float Spacing = FMath::Max(P.ShaftSpacing, 1.0f);
|
||||
const uint32 Hh = VoxelHash::Cell(nx, ny, Salt);
|
||||
if (VoxelHash::ToFloat01(Hh) > P.ShaftDensity) { 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.R = FMath::Lerp(P.ShaftMinRadius, P.ShaftMaxRadius,
|
||||
VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0xBEEFu)));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Le voisinage 3×3 + ses connecteurs, mémoïsés par worker. Clé = cellule + tous les params
|
||||
* qui influent (comme l'original) : stable à travers les reconstructions de pile, ce qui
|
||||
* est la leçon retenue du mémo de colonne de SurfaceWorld. */
|
||||
const FCells& GetCells(float WorldX, float WorldY) const
|
||||
{
|
||||
const float Spacing = FMath::Max(P.ShaftSpacing, 1.0f);
|
||||
const int32 CX = FMath::FloorToInt(WorldX / Spacing);
|
||||
const int32 CY = FMath::FloorToInt(WorldY / Spacing);
|
||||
|
||||
thread_local FCells Cache;
|
||||
thread_local int32 VS_CX = INT32_MAX, VS_CY = INT32_MAX;
|
||||
thread_local uint32 VS_Salt = 0xFFFFFFFFu;
|
||||
thread_local float VS_Spacing = -1.0f, VS_Dens = -1.0f, VS_MinR = -1.0f,
|
||||
VS_MaxR = -1.0f, VS_Cross = -1.0f,
|
||||
VS_BotZ = FLT_MAX, VS_TopZ = FLT_MAX, VS_Seal = -1.0f;
|
||||
|
||||
if (CX != VS_CX || CY != VS_CY || Salt != VS_Salt || Spacing != VS_Spacing ||
|
||||
P.ShaftDensity != VS_Dens || P.ShaftMinRadius != VS_MinR || P.ShaftMaxRadius != VS_MaxR ||
|
||||
P.CrossConnectChance != VS_Cross ||
|
||||
P.StrateBottomWorldZ != VS_BotZ || P.StrateTopWorldZ != VS_TopZ ||
|
||||
P.BoundarySealThickness != VS_Seal)
|
||||
{
|
||||
VS_CX = CX; VS_CY = CY; VS_Salt = Salt; VS_Spacing = Spacing;
|
||||
VS_Dens = P.ShaftDensity; VS_MinR = P.ShaftMinRadius; VS_MaxR = P.ShaftMaxRadius;
|
||||
VS_Cross = P.CrossConnectChance;
|
||||
VS_BotZ = P.StrateBottomWorldZ; VS_TopZ = P.StrateTopWorldZ;
|
||||
VS_Seal = P.BoundarySealThickness;
|
||||
Cache.Shafts.Reset();
|
||||
Cache.Conns.Reset();
|
||||
|
||||
for (int32 dy = -1; dy <= 1; dy++)
|
||||
for (int32 dx = -1; dx <= 1; dx++)
|
||||
{
|
||||
FShaft Sh;
|
||||
if (RollShaft(CX + dx, CY + dy, Sh)) { Cache.Shafts.Add(Sh); }
|
||||
}
|
||||
|
||||
if (P.CrossConnectChance > 0.0f && Cache.Shafts.Num() >= 2)
|
||||
{
|
||||
const float BottomZ = P.StrateBottomWorldZ + P.BoundarySealThickness;
|
||||
const float TopZ = P.StrateTopWorldZ - P.BoundarySealThickness;
|
||||
for (int32 i = 0; i < Cache.Shafts.Num(); i++)
|
||||
for (int32 j = i + 1; j < Cache.Shafts.Num(); j++)
|
||||
{
|
||||
const FShaft& A = Cache.Shafts[i];
|
||||
const FShaft& B = Cache.Shafts[j];
|
||||
const float DSq = FMath::Square(A.X - B.X) + FMath::Square(A.Y - B.Y);
|
||||
if (DSq > FMath::Square(Spacing * 1.6f)) { continue; }
|
||||
|
||||
const uint32 PH = VoxelHash::Pair(
|
||||
FMath::RoundToInt(A.X), FMath::RoundToInt(A.Y),
|
||||
FMath::RoundToInt(B.X), FMath::RoundToInt(B.Y), Salt ^ 0xC04Eu);
|
||||
if (VoxelHash::ToFloat01(PH) >= P.CrossConnectChance) { continue; }
|
||||
|
||||
const float Zc = FMath::Lerp(BottomZ, TopZ, VoxelHash::ToFloat01(VoxelHash::Mix(PH)));
|
||||
Cache.Conns.Add({ FVector(A.X, A.Y, Zc), FVector(B.X, B.Y, Zc) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return Cache;
|
||||
}
|
||||
|
||||
FVerticalShaftParams P;
|
||||
uint32 Salt;
|
||||
float ExtraReach;
|
||||
};
|
||||
|
||||
//=========================================================================
|
||||
// RÔLE 3 — MODIFIER : ÉTAGÈRES DE PUITS / SHAFT LEDGES
|
||||
//=========================================================================
|
||||
// Des tablettes fines à intervalles réguliers en Z, posées UNIQUEMENT sur la moitié +X/+Y du
|
||||
// puits le plus proche — pour que la moitié opposée reste libre et le puits franchissable.
|
||||
// C'est un op FORÇANT (`Max`) : il ne peut qu'ajouter du solide.
|
||||
class FShaftLedgeMod final : public IVoxelDensityOp
|
||||
{
|
||||
public:
|
||||
FShaftLedgeMod(const FVerticalShaftParams& InP, const FShaftFieldSource* InField)
|
||||
: P(InP), Field(InField) {}
|
||||
|
||||
EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; }
|
||||
void PrepareChunk(const FVoxelOpContext&) override {}
|
||||
|
||||
void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override
|
||||
{
|
||||
if (P.LedgeSpacing <= 0.0f || P.LedgeDepth <= 0.0f || Field == nullptr) { return; }
|
||||
// ⚠️ La garde de l'original est `CaveSDF < 0` — le SDF APRÈS rugosité, tel que la pile
|
||||
// l'a laissé. C'est bien `InOut.Sdf` ici, pas une re-évaluation : re-calculer donnerait
|
||||
// le SDF SANS rugosité et déplacerait les étagères.
|
||||
if (InOut.Sdf >= 0.0f) { return; }
|
||||
|
||||
const float Phase = FMath::Frac((WorldZ - P.StrateBottomWorldZ) / P.LedgeSpacing);
|
||||
const float BandT = FMath::Min(Phase, 1.0f - Phase) * P.LedgeSpacing;
|
||||
if (BandT >= P.LedgeDepth) { return; }
|
||||
|
||||
const FShaftFieldSource::FCells& C = Field->GetCellsAt(WorldX, WorldY);
|
||||
if (C.Shafts.Num() == 0) { return; }
|
||||
|
||||
const FShaftFieldSource::FShaft* Near = nullptr;
|
||||
float BestSq = FLT_MAX;
|
||||
for (const FShaftFieldSource::FShaft& Sh : C.Shafts)
|
||||
{
|
||||
const float D2 = FMath::Square(WorldX - Sh.X) + FMath::Square(WorldY - Sh.Y);
|
||||
if (D2 < BestSq) { BestSq = D2; Near = &Sh; }
|
||||
}
|
||||
if (Near && (WorldX - Near->X) + (WorldY - Near->Y) > 0.0f)
|
||||
{
|
||||
const float Shelf = 1.0f - SmoothStep01(BandT / P.LedgeDepth);
|
||||
InOut.Density = FMath::Max(InOut.Density, Shelf * P.BaseDensity);
|
||||
}
|
||||
}
|
||||
|
||||
// N'ajoute que du solide ⇒ tue AllAir, jamais AllSolid.
|
||||
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
|
||||
{
|
||||
return (P.LedgeSpacing > 0.0f && P.LedgeDepth > 0.0f)
|
||||
? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity;
|
||||
}
|
||||
|
||||
private:
|
||||
FVerticalShaftParams P;
|
||||
const FShaftFieldSource* Field; // NON possédant : la pile possède la source
|
||||
};
|
||||
|
||||
} // ⚠️ 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
|
||||
@@ -1236,6 +1494,42 @@ namespace VoxelDensityOps
|
||||
P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager);
|
||||
}
|
||||
|
||||
void BuildVerticalShaftStack(FVoxelOpStack& OutStack, const FVerticalShaftParams& P,
|
||||
int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager)
|
||||
{
|
||||
// ⚠️ LA PREUVE QUE L'ABSTRACTION EST RÉELLE, et elle vaut d'être dite : TROIS des cinq
|
||||
// opérateurs ci-dessous sont ceux de Maze, **repris sans une ligne de changement** —
|
||||
// `ConstantRock`, `SdfRoughness`, `SdfCarve`. Dans le `switch`, Maze et VerticalShafts sont
|
||||
// deux fonctions de ~100 lignes qui n'ont rien en commun à l'œil ; en opérateurs, ce sont
|
||||
// les MÊMES trois ops avec une source différente. C'est exactement ce que `§2.5` prédisait
|
||||
// et ce que la Phase 1 avait parié.
|
||||
//
|
||||
// THREE of the five ops below are Maze's, reused without a line changed. In the switch,
|
||||
// Maze and VerticalShafts are two unrelated ~100-line functions; as operators they are the
|
||||
// same three ops with a different source.
|
||||
constexpr float CarveBlend = 2.0f;
|
||||
|
||||
// Portée que la source doit déclarer pour la paire source+carve : la rugosité peut élargir
|
||||
// le puits (FBM ∈ [-1,1] ⇒ ±Strength·VOXEL_NOISE_SCALE), puis le blend du carve. Sur-estimer
|
||||
// coûte du CPU ; sous-estimer serait un trou.
|
||||
const float ExtraReach = FMath::Abs(P.SurfaceRoughness) * VOXEL_NOISE_SCALE + CarveBlend + 1.0f;
|
||||
|
||||
TUniquePtr<FShaftFieldSource> ShaftSource = MakeUnique<FShaftFieldSource>(P, Seed, ExtraReach);
|
||||
const FShaftFieldSource* ShaftPtr = ShaftSource.Get();
|
||||
|
||||
OutStack.Add(MakeConstantRockSource(P.BaseDensity));
|
||||
OutStack.Add(MoveTemp(ShaftSource));
|
||||
// Fréquence 0.1 et fenêtre `SurfaceRoughness + 4` — les constantes de
|
||||
// `GetVerticalShaftDensity`, PAS celles de Maze (0.12 / `R + rough + 2`). Même opérateur,
|
||||
// réglages différents : c'est le point.
|
||||
OutStack.Add(MakeSdfRoughnessMod(P.SurfaceRoughness, 0.1f, 3, P.SurfaceRoughness + 4.0f));
|
||||
OutStack.Add(MakeSdfCarve(CarveBlend, P.BaseDensity));
|
||||
OutStack.Add(MakeUnique<FShaftLedgeMod>(P, ShaftPtr));
|
||||
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -652,6 +652,18 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
||||
PerBiome, MoveTemp(Field));
|
||||
break;
|
||||
}
|
||||
|
||||
case ECaveGeneratorType::VerticalShafts:
|
||||
if (CP_Vert.StrateTopWorldZ - CP_Vert.StrateBottomWorldZ <= 0.0f)
|
||||
{
|
||||
CP_UseOpStack = false;
|
||||
break;
|
||||
}
|
||||
OpCtx.StrateTopWorldZ = CP_Vert.StrateTopWorldZ;
|
||||
OpCtx.StrateBottomWorldZ = CP_Vert.StrateBottomWorldZ;
|
||||
VoxelDensityOps::BuildVerticalShaftStack(CP_OpStack, CP_Vert, 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`
|
||||
|
||||
@@ -583,6 +583,8 @@ bool UVoxelStrateManager::UsesOperatorStackForChunk(const FIntVector& ChunkCoord
|
||||
// exactly as the original path does.
|
||||
return true;
|
||||
|
||||
case ECaveGeneratorType::VerticalShafts: return true; // Phase 2 — 3 ops repris de Maze tels quels
|
||||
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +230,19 @@ namespace VoxelDensityOps
|
||||
int32 Seed, float SpineRadius,
|
||||
const UVoxelStrateManager* StrateManager);
|
||||
|
||||
/**
|
||||
* VerticalShafts — 8 ops, et **TROIS viennent de Maze sans une ligne de changement** :
|
||||
* ConstantRock → ShaftField → SdfRoughness → SdfCarve → ShaftLedge → [structural post ×3]
|
||||
*
|
||||
* C'est la démonstration que `§2.5` promettait : dans le `switch`, Maze et VerticalShafts sont
|
||||
* deux fonctions de ~100 lignes sans rien de commun à l'œil ; en opérateurs, ce sont les mêmes
|
||||
* trois ops avec une source différente et d'autres réglages (fréquence 0.1 au lieu de 0.12,
|
||||
* fenêtre `rough + 4` au lieu de `R + rough + 2`).
|
||||
*/
|
||||
VOXELFORGE_API void BuildVerticalShaftStack(FVoxelOpStack& OutStack, const FVerticalShaftParams& 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