feat(world): the XY edge seal -- the fourth structural invariant

VF_ApplyBoundarySeal was purely VERTICAL: it sealed each strate's ceiling
and floor and there was no horizontal equivalent, because until the depths
were bounded the world had no sides. The GDD now says "bounded, but
gigantic", so a player who walked far enough reached unterminated
generation running off into nothing.

Built as a FORCING op, appended automatically and not author-omittable, so
an invented strate cannot forget its own edge. Because a forcing op proves
AllSolid in its band, the outer shell also becomes free to skip in tile
classification -- correctness and a perf win from one change.

  order  spine -> vertical seal -> passage carving -> XY EDGE SEAL
         -> disturbances -> diff
  A near-rim passage may pierce a vertical seal; the XY seal runs after and
  wins. A passage must never punch a hole through the world's edge.

ClassifyBox/ForcedMarginOverBox proved via the closest AABB radius with a
one-voxel margin: 8 boxes proved, 1 tile proved, 600 voxels brute-forced,
0 violations. FXYEdgeSealOp is IsXYPure. Max rim discontinuity .187 against
a 1.0 threshold -- a ramp, not a cylinder wall.

⚠️ WorldRadiusVoxels DEFAULTS TO 0, and that is deliberate. Codex flagged
that DA_Settings and BP_VoxelSettings predate the property and so do not
serialize it -- they inherit the C++ default silently. A default of 8192
would have walled in every existing world on next editor open with nothing
in the asset to explain it. 0 is a bitwise no-op (45 interior comparisons
against the unbounded path prove it). The GDD's intended ~8192 is a value
to set in the asset, deliberately, once. Documented at the declaration.

Codex found four spec errors, three of them mine: VF_ApplyBoundarySeal
lives in VoxelDensityPrimitives.h not VoxelCaveMorphology; the asset
default trap above; and my "density increases through the band" wording
contradicts the MC sign convention (solid is more NEGATIVE) -- the test
measures increasing solidness.

Build clean, 21 succeeded / 0 failed, run and verified by me.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-09-04 01:17:12 +02:00
1 parent 44938f3f98
commit 9d2324879d
19 files changed
+1006 -126

No files matched your search

@@ -151,10 +151,9 @@ bool FVoxelForgeClassifyTileTest::RunTest(const FString& Parameters)
AddInfo(FString::Printf(
TEXT("ClassifyTile verdicts over %d scanned tiles: Mixed %d, AllSolid %d, AllAir %d ")
TEXT("(brute-forcing %d of them). NOTE: cave archetypes always return Mixed today — see ")
TEXT("VoxelGenerator.cpp \"archétype cave [...] pas prouvable en v1\". A low non-Mixed count ")
TEXT("is expected and is exactly the tile-skipping prize OPSTACK-PLAN wants EffectOverBox ")
TEXT("to unlock."),
TEXT("(brute-forcing %d of them). Cave archetypes now use the opt-in operator-stack ")
TEXT("ClassifyBox fold; the global XY edge proof also skips proven outer-shell tiles. A low ")
TEXT("non-Mixed count is expected: every uncertain case remains Mixed for safety."),
NumTilesScanned, NumMixed, NumAllSolid, NumAllAir, ToVerify.Num()));
if (ToVerify.Num() == 0)
@@ -315,10 +315,10 @@ bool FVoxelForgeHeightStackTest::RunTest(const FString& Parameters)
VoxelDensityOps::BuildSurfaceStack(Stack, P, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
// 1 source + 1 overhang + 3 structurels. L'op overhang est présent mais inerte ici
// 1 source + 1 overhang + 4 structurels. L'op overhang est présent mais inerte ici
// (amp 0 ⇒ sortie immédiate) — la décomposition ne change pas selon les params.
TestEqual(TEXT("the surface density stack is source + overhang + 3 structural"),
Stack.Num(), 5);
TestEqual(TEXT("the surface density stack is source + overhang + 4 structural"),
Stack.Num(), 6);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
@@ -103,8 +103,8 @@ bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters)
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);
// void + blobs + roughness + fill + 4 structurels.
TestEqual(TEXT("the island stack is decomposed into 8 ops"), Stack.Num(), 8);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
@@ -140,8 +140,8 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
// La décomposition doit être une DÉCOMPOSITION. Un `FMazeOp` monolithique passerait tous les
// tests numériques ci-dessous et aurait pourtant raté l'objet entier du refactor (§2.5).
TestEqual(TEXT("the Maze stack is decomposed, not wrapped (rock + corridors + roughness + carve + 3 structural)"),
Stack.Num(), 7);
TestEqual(TEXT("the Maze stack is decomposed, not wrapped (rock + corridors + roughness + carve + 4 structural)"),
Stack.Num(), 8);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
@@ -91,8 +91,8 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
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);
// rock + shafts + roughness + carve + ledges + 4 structurels.
TestEqual(TEXT("the shaft stack is decomposed into 9 ops"), Stack.Num(), 9);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
@@ -103,9 +103,9 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
VoxelDensityOps::BuildSlabStack(Stack, SlabParams, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
// La décomposition doit rester une DÉCOMPOSITION : vide + colonnes + 3 structurels.
TestEqual(*FString::Printf(TEXT("%s decomposes into void + columns + 3 structural"), SlotName),
Stack.Num(), 5);
// La décomposition doit rester une DÉCOMPOSITION : vide + colonnes + 4 structurels.
TestEqual(*FString::Printf(TEXT("%s decomposes into void + columns + 4 structural"), SlotName),
Stack.Num(), 6);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
@@ -464,11 +464,11 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
VoxelDensityOps::BuildTunnelNetworkStack(Stack, P, World.Settings->Seed,
Gen->OriginSpineRadius, World.StrateManager.Get());
// rock + roomgraph + carve + **les 12 modificateurs de détail** + worms + 3 structurels = 19.
// rock + roomgraph + carve + **les 12 modificateurs de détail** + worms + 4 structurels = 20.
// L'étape C n'ajoute AUCUN opérateur — elle change ce que onze d'entre eux LISENT — donc ce
// nombre DOIT bouger à chaque groupe de l'étape B — c'est un compteur de progression, pas une
// formalité : une pile qui ne grandit pas est une pile dont l'opérateur n'a pas été ajouté.
TestEqual(TEXT("the stage-A+B tunnel stack is decomposed into 19 ops"), Stack.Num(), 19);
TestEqual(TEXT("the stage-A+B tunnel stack is decomposed into 20 ops"), Stack.Num(), 20);
FVoxelOpContext Ctx;
Ctx.Seed = (uint32)World.Settings->Seed;
@@ -1408,7 +1408,7 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
UWCtx.StrateBottomWorldZ = UP.StrateBottomWorldZ;
UWStack.PrepareChunk(UWCtx);
TestEqual(TEXT("the Underwater stack is the tunnel stack: same 19 ops"), UWStack.Num(), 19);
TestEqual(TEXT("the Underwater stack is the tunnel stack: same 20 ops"), UWStack.Num(), 20);
// 8 → 24 chunks, comme le balayage principal. Le premier run vert a rapporté **0 de 2000
// échantillons en grotte ouverte** là où le slot TunnelNetwork en avait 16,7 % : trop peu de
@@ -2471,6 +2471,228 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet
*ConnectivityProbeText(SeedReport.DepartureToSpine),
*ConnectivityProbeText(SeedReport.ArrivalToDeparture));
}
// PART 0: resolve the two remaining step-2 negatives without changing generation. The
// 12M cap is deliberately high enough for the 320x320x112 step-1/radius-160 fallback,
// but low enough that the requested 384x384x112 step-1/radius-192 attempt is explicitly
// reported as a MaxCells refusal rather than silently changing the measurement budget.
struct FSeedResolutionRow
{
int32 SampleStep = 0;
int32 RadiusInVoxels = 0;
bool bMaxCellsRefused = false;
FVoxelStrateMetrics Metrics;
FVoxelConnectivityDiagnostics Diagnostics;
};
static constexpr int32 ResolutionSeeds[] = {6, 14};
static constexpr FRefinementCase ResolutionCases[] = {
{2, 192},
{1, 192},
{2, 128},
{1, 128},
};
constexpr int32 ResolutionMaxCells = 12000000;
Summary += TEXT(
"PART 0 — residual VerticalShafts seed refinement (seeds 6 and 14; margin derived; "
"MaxCells=12000000):\n");
Summary += TEXT(
" seed | SampleStep | RadiusInVoxels | verdict | components | largest share | retries | "
"arrival mouth (x,y,z; component share) | departure mouth (x,y,z; component share)\n");
for (const int32 Seed : ResolutionSeeds)
{
FTestWorld ResolutionWorld;
ResolutionWorld.Build(Seed, /*InGapChunks=*/2);
if (!ResolutionWorld.IsValid())
{
AddError(FString::Printf(
TEXT("HARD FAILURE: Part 0 seed %d could not build the VerticalShafts fixture."),
Seed));
bAllChecksPassed = false;
continue;
}
FVector ResolutionArrivalPoint = FVector::ZeroVector;
FVector ResolutionDeparturePoint = FVector::ZeroVector;
if (!FindChainMouths(
ResolutionWorld.StrateManager->GetPassages(),
VerticalShaftsIndex,
ResolutionArrivalPoint,
ResolutionDeparturePoint))
{
AddError(FString::Printf(
TEXT("HARD FAILURE: Part 0 seed %d does not have exactly one arrival and "
"departure mouth."),
Seed));
bAllChecksPassed = false;
continue;
}
TArray<FSeedResolutionRow> ResolutionRows;
ResolutionRows.Reserve(UE_ARRAY_COUNT(ResolutionCases) + 1);
for (const FRefinementCase& TestCase : ResolutionCases)
{
FSeedResolutionRow& Row = ResolutionRows.AddDefaulted_GetRef();
Row.SampleStep = TestCase.SampleStep;
Row.RadiusInVoxels = TestCase.RadiusInVoxels;
FVoxelStrateMeasureSettings RowSettings = DiagnosticSettings;
RowSettings.SampleStep = TestCase.SampleStep;
RowSettings.RadiusInVoxels = TestCase.RadiusInVoxels;
RowSettings.MaxCells = ResolutionMaxCells;
RowSettings.InteriorMarginVoxels = -1;
Row.Metrics = VF_MeasureStrate(
*ResolutionWorld.Generator,
*ResolutionWorld.StrateManager,
VerticalShaftsIndex,
RowSettings);
const bool bMaxCellsRefusal = !Row.Metrics.bValid
&& Row.Metrics.RefusalReason.Contains(TEXT("MaxCells"));
Row.bMaxCellsRefused = bMaxCellsRefusal;
Row.Diagnostics = VF_DiagnoseConnectivity(
*ResolutionWorld.Generator,
*ResolutionWorld.StrateManager,
VerticalShaftsIndex,
ResolutionArrivalPoint,
ResolutionDeparturePoint,
RowSettings);
const bool bExpectedFallbackRefusal = TestCase.SampleStep == 1
&& TestCase.RadiusInVoxels == 192;
if (!Row.Metrics.bValid
&& !(bExpectedFallbackRefusal && bMaxCellsRefusal))
{
AddError(FString::Printf(
TEXT("HARD FAILURE: Part 0 seed %d row step=%d radius=%d was not a "
"bounded measurement (metrics valid=%s reason='%s')."),
Seed,
Row.SampleStep,
Row.RadiusInVoxels,
Row.Metrics.bValid ? TEXT("true") : TEXT("false"),
*Row.Metrics.RefusalReason));
bAllChecksPassed = false;
}
if (Row.Metrics.bValid
&& !Row.Diagnostics.bValid
&& Row.Diagnostics.Result != EVoxelConnectivityResult::OutOfWindow)
{
AddError(FString::Printf(
TEXT("HARD FAILURE: Part 0 seed %d row step=%d radius=%d produced an "
"invalid connectivity diagnostic with result %s."),
Seed,
Row.SampleStep,
Row.RadiusInVoxels,
ConnectivityResultName(Row.Diagnostics.Result)));
bAllChecksPassed = false;
}
}
// If the requested fine grid is over the cap, perform the specified radius-control
// substitution. This row is the one used to decide whether step refinement changes
// the verdict; the refused 192 row remains in the report as evidence of the cap.
if (ResolutionRows.IsValidIndex(1) && ResolutionRows[1].bMaxCellsRefused)
{
FSeedResolutionRow& FallbackRow = ResolutionRows.AddDefaulted_GetRef();
FallbackRow.SampleStep = 1;
FallbackRow.RadiusInVoxels = 160;
FVoxelStrateMeasureSettings FallbackSettings = DiagnosticSettings;
FallbackSettings.SampleStep = 1;
FallbackSettings.RadiusInVoxels = 160;
FallbackSettings.MaxCells = ResolutionMaxCells;
FallbackSettings.InteriorMarginVoxels = -1;
FallbackRow.Metrics = VF_MeasureStrate(
*ResolutionWorld.Generator,
*ResolutionWorld.StrateManager,
VerticalShaftsIndex,
FallbackSettings);
FallbackRow.Diagnostics = VF_DiagnoseConnectivity(
*ResolutionWorld.Generator,
*ResolutionWorld.StrateManager,
VerticalShaftsIndex,
ResolutionArrivalPoint,
ResolutionDeparturePoint,
FallbackSettings);
if (!FallbackRow.Metrics.bValid)
{
AddError(FString::Printf(
TEXT("HARD FAILURE: Part 0 seed %d step=1 radius=160 fallback was not "
"a bounded measurement (metrics valid=%s reason='%s')."),
Seed,
FallbackRow.Metrics.bValid ? TEXT("true") : TEXT("false"),
*FallbackRow.Metrics.RefusalReason));
bAllChecksPassed = false;
}
if (FallbackRow.Metrics.bValid
&& !FallbackRow.Diagnostics.bValid
&& FallbackRow.Diagnostics.Result != EVoxelConnectivityResult::OutOfWindow)
{
AddError(FString::Printf(
TEXT("HARD FAILURE: Part 0 seed %d step=1 radius=160 produced an "
"invalid connectivity diagnostic with result %s."),
Seed,
ConnectivityResultName(FallbackRow.Diagnostics.Result)));
bAllChecksPassed = false;
}
}
for (const FSeedResolutionRow& Row : ResolutionRows)
{
const bool bUsable = Row.Metrics.bValid && Row.Diagnostics.bValid;
const bool bOutOfWindow = Row.Diagnostics.Result == EVoxelConnectivityResult::OutOfWindow;
const FString Verdict = Row.bMaxCellsRefused
? TEXT("EXCEEDS_MAXCELLS")
: (Row.Metrics.bValid && bOutOfWindow)
? TEXT("OUT_OF_WINDOW")
: bUsable
? ConnectivityResultName(Row.Diagnostics.Result)
: TEXT("UNUSABLE");
const FString Components = Row.Metrics.bValid
? FString::Printf(TEXT("%d"), Row.Metrics.NumAirComponents)
: TEXT("N/A");
const FString LargestShare = Row.Metrics.bValid
? FString::Printf(TEXT("%.9g"), Row.Metrics.LargestComponentShare)
: TEXT("N/A");
const FString Retries = Row.Diagnostics.bValid || (Row.Metrics.bValid && bOutOfWindow)
? FString::Printf(TEXT("%d"), Row.Diagnostics.NumRouteRetries)
: TEXT("N/A");
const FString ArrivalShare = bUsable
? FString::Printf(TEXT("%.9g"), Row.Diagnostics.StartComponentShare)
: TEXT("N/A");
const FString DepartureShare = bUsable
? FString::Printf(TEXT("%.9g"), Row.Diagnostics.GoalComponentShare)
: TEXT("N/A");
Summary += FString::Printf(
TEXT(" %d | %d | %d | %s | %s | %s | %s | (%.3f, %.3f, %.3f; %s) | "
"(%.3f, %.3f, %.3f; %s)%s\n"),
Seed,
Row.SampleStep,
Row.RadiusInVoxels,
*Verdict,
*Components,
*LargestShare,
*Retries,
ResolutionArrivalPoint.X,
ResolutionArrivalPoint.Y,
ResolutionArrivalPoint.Z,
*ArrivalShare,
ResolutionDeparturePoint.X,
ResolutionDeparturePoint.Y,
ResolutionDeparturePoint.Z,
*DepartureShare,
Row.bMaxCellsRefused
? TEXT(" [requested row refused; use step=1/radius=160 below]")
: TEXT(""));
}
}
Summary += TEXT(
" Part 0 interpretation: step=1/radius=192 is attempted first; when it exceeds the "
"12M measurement cap, step=1/radius=160 is the prescribed radius control.\n");
}
else
{
@@ -79,6 +79,8 @@ namespace VoxelForgeTest
NewObject<UVoxelSettings>(GetTransientPackage(), NAME_None, RF_Transient));
Settings->Seed = InSeed;
Settings->InterStrateGapChunks = InGapChunks;
// WorldRadiusVoxels/EdgeSealThickness intentionally inherit the UVoxelSettings defaults;
// WorldEdgeSeal overrides them explicitly when it needs a small test world or radius 0.
// Une strate par archétype. PINNED via FixedStrates, pas via le pool : Initialize()
// mélange le pool avec le seed, ce qui rendrait la correspondance archétype → Z
@@ -0,0 +1,339 @@
// VoxelForgeWorldEdgeSealTest.cpp
// Falsifiable coverage for the bounded-world XY edge seal.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelDensityOpStack.h"
#include "VoxelDensityPrimitives.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeWorldEdgeSealTest,
"VoxelForge.Generation.WorldEdgeSeal",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
constexpr float TestWorldRadius = 256.0f;
constexpr float TestEdgeThickness = 64.0f;
constexpr float RimJumpThreshold = 1.0f;
void SetEdgeSettings(VoxelForgeTest::FTestWorld& World, float Radius)
{
World.Settings->WorldRadiusVoxels = Radius;
World.Settings->EdgeSealThickness = TestEdgeThickness;
World.Generator->InitializeSettings(World.Settings.Get());
}
void ConfigureNearRimPassages(VoxelForgeTest::FTestWorld& World)
{
for (TStrongObjectPtr<UVoxelStrateDefinition>& DefPtr : World.Definitions)
{
if (!DefPtr.IsValid()) continue;
FStratePassageConfig& Cfg = DefPtr->PassageConfig;
Cfg.Connections = 1;
Cfg.Style = EVoxelPassageStyle::Straight;
Cfg.MouthRadius = 6.0f;
Cfg.MidRadius = 6.0f;
Cfg.ReachMin = 40.0f;
Cfg.ReachMax = 40.0f;
Cfg.DistanceMin = 240.0f;
Cfg.DistanceMax = 240.0f;
Cfg.Wander = 0.0f;
Cfg.Segments = 4;
Cfg.VerticalWobble = 0.0f;
}
World.Reinitialize();
}
bool IsSolidMC(const UVoxelGenerator& Generator, const FVector& Point)
{
return Generator.GetDensityAt(Point.X, Point.Y, Point.Z) < 0.0f;
}
}
bool FVoxelForgeWorldEdgeSealTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
int32 NumFailures = 0;
FString FailureKinds;
auto Fail = [&](const TCHAR* Kind)
{
++NumFailures;
if (!FailureKinds.IsEmpty()) FailureKinds += TEXT(", ");
FailureKinds += Kind;
};
// The ordinary fixture deliberately uses the requested default, so existing op-stack and
// legacy tests exercise the setting without having to know about the new post.
FTestWorld Legacy;
Legacy.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/false);
FTestWorld StackWorld;
StackWorld.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true);
FTestWorld Unbounded;
Unbounded.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/false);
if (!Legacy.IsValid() || !StackWorld.IsValid() || !Unbounded.IsValid())
{
AddError(Legacy.IsValid() && StackWorld.IsValid() && Unbounded.IsValid()
? TEXT("Unexpected edge-seal fixture state")
: (!Legacy.IsValid() ? Legacy.WhyInvalid()
: (!StackWorld.IsValid() ? StackWorld.WhyInvalid() : Unbounded.WhyInvalid())));
return false;
}
SetEdgeSettings(Legacy, TestWorldRadius);
SetEdgeSettings(StackWorld, TestWorldRadius);
SetEdgeSettings(Unbounded, 0.0f);
//==========================================================================
// POSITIVE CONTROL — far outside the radius, across several Z strata.
//==========================================================================
const FVector FarXY[] = {
FVector(384.0f, 0.0f, 0.0f),
FVector(-384.0f, 0.0f, 0.0f),
FVector(0.0f, 384.0f, 0.0f),
FVector(0.0f, -384.0f, 0.0f),
FVector(320.0f, 320.0f, 0.0f),
};
TArray<float> StrateZ;
for (int32 Slot = 0; Slot < 8; ++Slot)
{
int32 TopZ = 0, BottomZ = 0;
if (Legacy.GetSlotVoxelZRange(Slot, TopZ, BottomZ))
{
StrateZ.Add((float)((TopZ + BottomZ) / 2));
}
}
StrateZ.Add(Legacy.MidVoxelZ());
int32 NumPositiveSamples = 0;
for (const FVector& XY : FarXY)
{
for (const float Z : StrateZ)
{
const FVector P(XY.X, XY.Y, Z);
++NumPositiveSamples;
if (!IsSolidMC(*Legacy.Generator, P)) Fail(TEXT("positive-legacy"));
if (!IsSolidMC(*StackWorld.Generator, P)) Fail(TEXT("positive-opstack"));
}
}
//==========================================================================
// NEGATIVE CONTROL — the interior must be bit-identical to radius == 0.
//==========================================================================
const FVector InteriorXY[] = {
FVector(0.0f, 0.0f, 0.0f),
FVector(64.0f, -80.0f, 0.0f),
FVector(128.0f, 0.0f, 0.0f),
FVector(0.0f, -128.0f, 0.0f),
FVector(100.0f, 100.0f, 0.0f),
};
int32 NumInteriorComparisons = 0;
for (const FVector& XY : InteriorXY)
{
for (const float Z : StrateZ)
{
const FVector P(XY.X, XY.Y, Z);
const float BoundedD = Legacy.Generator->GetDensityAt(P.X, P.Y, P.Z);
const float UnboundedD = Unbounded.Generator->GetDensityAt(P.X, P.Y, P.Z);
++NumInteriorComparisons;
if (!BitEqual(BoundedD, UnboundedD)) Fail(TEXT("interior-not-no-op"));
}
}
//==========================================================================
// RIM SMOOTHNESS — measure solidness (the negative MC density magnitude), not its sign.
//==========================================================================
float MaxAdjacentSolidnessJump = 0.0f;
int32 NumRimDirections = 0;
const FVector RimDirections[] = {
FVector(1.0f, 0.0f, 0.0f),
FVector(0.0f, 1.0f, 0.0f),
FVector(-1.0f, 0.0f, 0.0f),
FVector(0.0f, -1.0f, 0.0f),
FVector(0.70710678f, 0.70710678f, 0.0f),
};
for (const FVector& Dir : RimDirections)
{
++NumRimDirections;
float PreviousSolidness = 0.0f;
bool bHasPrevious = false;
for (int32 R = (int32)(TestWorldRadius - TestEdgeThickness - 4.0f);
R <= (int32)(TestWorldRadius + 4.0f); ++R)
{
float InternalDensity = 0.0f;
VF_ApplyXYEdgeSeal(InternalDensity, Dir.X * (float)R, Dir.Y * (float)R,
TestWorldRadius, TestEdgeThickness, 8.0f);
const float Solidness = InternalDensity;
if (bHasPrevious)
{
MaxAdjacentSolidnessJump = FMath::Max(
MaxAdjacentSolidnessJump, FMath::Abs(Solidness - PreviousSolidness));
if (Solidness + KINDA_SMALL_NUMBER < PreviousSolidness)
Fail(TEXT("rim-not-monotonic"));
}
PreviousSolidness = Solidness;
bHasPrevious = true;
}
}
if (MaxAdjacentSolidnessJump > RimJumpThreshold) Fail(TEXT("rim-discontinuity"));
// Explicitly verify the true no-op contract, including the MC-facing wrapper.
float NoOpDensity = 3.25f;
const float NoOpBefore = NoOpDensity;
VF_ApplyXYEdgeSealMC(NoOpDensity, 100000.0f, -100000.0f,
0.0f, TestEdgeThickness, 8.0f);
if (!BitEqual(NoOpDensity, NoOpBefore)) Fail(TEXT("radius-zero-not-no-op"));
//==========================================================================
// CLASSIFYBOX — force boxes in the ramp and beyond the radius, then brute-force every voxel.
//==========================================================================
int32 TopShaftZ = 0, BottomShaftZ = 0;
if (!StackWorld.GetSlotVoxelZRange(FTestWorld::SlotVerticalShafts, TopShaftZ, BottomShaftZ))
{
Fail(TEXT("missing-shaft-slot"));
}
int32 NumBoxesProved = 0;
int64 NumVoxelsChecked = 0;
if (TopShaftZ > BottomShaftZ)
{
const float BoxZ0 = (float)(BottomShaftZ + 8);
const float BoxZ1 = (float)FMath::Min(BottomShaftZ + 10, TopShaftZ - 8);
const FBox ProvedBoxes[] = {
// Ramp boxes: closest radius is 240, safely past the 1-voxel proof margin.
FBox(FVector(240.0f, -2.0f, BoxZ0), FVector(244.0f, 2.0f, BoxZ1)),
FBox(FVector(-244.0f, -2.0f, BoxZ0), FVector(-240.0f, 2.0f, BoxZ1)),
FBox(FVector(-2.0f, 240.0f, BoxZ0), FVector(2.0f, 244.0f, BoxZ1)),
FBox(FVector(170.0f, 170.0f, BoxZ0), FVector(174.0f, 174.0f, BoxZ1)),
// Forced-solid boxes beyond the world radius.
FBox(FVector(320.0f, -2.0f, BoxZ0), FVector(324.0f, 2.0f, BoxZ1)),
FBox(FVector(-324.0f, -2.0f, BoxZ0), FVector(-320.0f, 2.0f, BoxZ1)),
FBox(FVector(-2.0f, 320.0f, BoxZ0), FVector(2.0f, 324.0f, BoxZ1)),
FBox(FVector(226.0f, 226.0f, BoxZ0), FVector(230.0f, 230.0f, BoxZ1)),
};
const int32 MidChunkZ = FMath::FloorToInt((BoxZ0 + BoxZ1) * 0.5f / CHUNK_SIZE);
const FVerticalShaftParams ShaftParams = StackWorld.StrateManager->GetVerticalShaftParamsForChunk(
FIntVector(0, 0, MidChunkZ));
FVoxelOpStack Stack;
VoxelDensityOps::BuildVerticalShaftStack(
Stack, ShaftParams, StackWorld.Settings->Seed,
StackWorld.Generator->OriginSpineRadius, StackWorld.StrateManager.Get());
FVoxelOpContext Ctx;
Ctx.ChunkCoord = FIntVector(0, 0, MidChunkZ);
Ctx.Seed = (uint32)StackWorld.Settings->Seed;
Ctx.LayoutVersion = StackWorld.StrateManager->GetLayoutVersion();
Ctx.StrateTopWorldZ = ShaftParams.StrateTopWorldZ;
Ctx.StrateBottomWorldZ = ShaftParams.StrateBottomWorldZ;
Ctx.WorldRadiusVoxels = TestWorldRadius;
Ctx.EdgeSealThickness = TestEdgeThickness;
Stack.PrepareChunk(Ctx);
for (const FBox& Box : ProvedBoxes)
{
if (Stack.ClassifyBox(Box, Ctx) != EVoxelTileClass::AllSolid)
{
Fail(TEXT("classifybox-missed-forced-band"));
continue;
}
++NumBoxesProved;
const int32 X0 = FMath::CeilToInt(Box.Min.X);
const int32 X1 = FMath::FloorToInt(Box.Max.X);
const int32 Y0 = FMath::CeilToInt(Box.Min.Y);
const int32 Y1 = FMath::FloorToInt(Box.Max.Y);
const int32 Z0 = FMath::CeilToInt(Box.Min.Z);
const int32 Z1 = FMath::FloorToInt(Box.Max.Z);
for (int32 Z = Z0; Z <= Z1; ++Z)
for (int32 Y = Y0; Y <= Y1; ++Y)
for (int32 X = X0; X <= X1; ++X)
{
++NumVoxelsChecked;
if (!IsSolidMC(*StackWorld.Generator, FVector((float)X, (float)Y, (float)Z)))
Fail(TEXT("classifybox-false-allsolid"));
}
}
}
// The T1.d path must be able to skip an edge tile without knowing which Z category it hits.
const EVoxelTileClass EdgeTile = StackWorld.Generator->ClassifyTile(
FIntVector(320, 0, BottomShaftZ + 8), 1, 8);
int32 NumTilesProved = (EdgeTile == EVoxelTileClass::AllSolid) ? 1 : 0;
if (EdgeTile != EVoxelTileClass::AllSolid) Fail(TEXT("classifytile-missed-edge"));
//==========================================================================
// PASSAGE ORDER — a near-rim passage carve is followed by the edge force.
//==========================================================================
float NearRimInternalDensity = 8.0f;
VF_ApplyPassageCarving(NearRimInternalDensity, 0.0f, 8.0f, TestEdgeThickness);
const float PassageOnlyDensity = NearRimInternalDensity;
VF_ApplyXYEdgeSeal(NearRimInternalDensity, TestWorldRadius - 16.0f, 0.0f,
TestWorldRadius, TestEdgeThickness, 8.0f);
int32 NumNearRimPassageChecks = 1;
if (!(PassageOnlyDensity < 0.0f && NearRimInternalDensity > 0.0f))
Fail(TEXT("passage-breaches-rim"));
// Try the same construction through the real deterministic passage generator. It is diagnostic
// coverage only: the synthetic helper case above is the hard assertion even if landing-site
// adjustment moves every generated mouth out of this small test rim.
FTestWorld GeneratedPassageWorld;
GeneratedPassageWorld.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/false);
FTestWorld GeneratedPassageStackWorld;
GeneratedPassageStackWorld.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true);
auto CheckGeneratedPassages = [&](FTestWorld& World, const TCHAR* FailureKind)
{
if (!World.IsValid())
{
Fail(FailureKind);
return;
}
ConfigureNearRimPassages(World);
SetEdgeSettings(World, TestWorldRadius);
for (const FVoxelPassage& Passage : World.StrateManager->GetPassages())
{
TArray<FVector> Candidates = Passage.ControlPoints;
Candidates.Add(Passage.UpperPoint);
Candidates.Add(Passage.LowerPoint);
for (const FVector& P : Candidates)
{
if (P.X * P.X + P.Y * P.Y <= FMath::Square(TestWorldRadius - TestEdgeThickness)) continue;
++NumNearRimPassageChecks;
if (!IsSolidMC(*World.Generator, P)) Fail(FailureKind);
break;
}
}
};
CheckGeneratedPassages(GeneratedPassageWorld, TEXT("generated-passage-breaches-rim"));
CheckGeneratedPassages(GeneratedPassageStackWorld, TEXT("generated-opstack-passage-breaches-rim"));
// One sorted summary block; individual samples intentionally never log.
AddInfo(FString::Printf(
TEXT("WORLD_EDGE_SEAL SUMMARY\n")
TEXT(" classify_box_proved=%d\n")
TEXT(" classify_tile_proved=%d\n")
TEXT(" interior_comparisons=%d\n")
TEXT(" near_rim_passage_checks=%d\n")
TEXT(" positive_sample_points=%d (checked in both paths)\n")
TEXT(" rim_directions=%d\n")
TEXT(" rim_max_adjacent_solidness_jump=%.6f (threshold=%.6f)\n")
TEXT(" voxels_checked=%lld\n")
TEXT(" failures=%d (%s)"),
NumBoxesProved, NumTilesProved, NumInteriorComparisons, NumNearRimPassageChecks,
NumPositiveSamples, NumRimDirections, MaxAdjacentSolidnessJump, RimJumpThreshold,
(long long)NumVoxelsChecked, NumFailures, *FailureKinds));
if (NumFailures > 0)
{
AddError(FString::Printf(TEXT("WorldEdgeSeal aggregate checks failed: %d (%s)"),
NumFailures, *FailureKinds));
}
return NumFailures == 0;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -2,7 +2,7 @@
// Les opérateurs concrets de la Phase 1 : la décomposition de Maze + le post-traitement structurel.
// The concrete Phase 1 operators: the Maze decomposition + the structural post-process.
//
// ⚠️ AUCUN de ces opérateurs n'alimente le jeu. Voir l'en-tête de VoxelDensityOpStack.h.
// Ces opérateurs alimentent le jeu derrière l'opt-in décrit dans l'en-tête de VoxelDensityOpStack.h.
//
// FIDÉLITÉ / FIDELITY
// Chaque corps ci-dessous est une transcription LITTÉRALE du bloc correspondant de
@@ -20,7 +20,7 @@
#include "VoxelDensityOpStack.h"
#include "VoxelDensityPrimitives.h" // VF_ApplyOriginSpine / Seal / PassageCarving
#include "VoxelDensityPrimitives.h" // VF_ApplyOriginSpine / seals / PassageCarving
#include "VoxelCaveMorphology.h" // VoxelSDF::Capsule, VoxelHash
#include "VoxelGenerator.h" // VoxelGenLOD::Eff
#include "VoxelHeightOp.h" // FVoxelHeightStack — SurfaceWorld's two height stacks
@@ -1406,6 +1406,65 @@ namespace
float TopZ, BotZ, Thickness, Base;
};
//=========================================================================
// RÔLE 4 — STRUCTUREL : SCELLEMENT DE LA LIMITE XY (opérateur FORÇANT)
//=========================================================================
class FXYEdgeSealOp final : public IVoxelDensityOp
{
public:
explicit FXYEdgeSealOp(float InBase)
: Base(InBase) {}
EVoxelOpRole GetRole() const override { return EVoxelOpRole::StructuralPost; }
void PrepareChunk(const FVoxelOpContext& Ctx) override
{
// The settings are global, but PrepareChunk is the stack's one per-chunk hand-off.
// Keeping the prepared copy makes Eval hot and keeps the op independent of the asset.
WorldRadius = Ctx.WorldRadiusVoxels;
Thickness = Ctx.EdgeSealThickness;
}
void Eval(float X, float Y, float, FVoxelOpSample& InOut) const override
{
VF_ApplyXYEdgeSeal(InOut.Density, X, Y, WorldRadius, Thickness, Base);
}
/**
* The seal is forcing, not merely FillOnly: once every point in the box is at least one
* voxel into the ramp (or beyond the radius), the result is positive-solid regardless of
* all preceding source/carve operators.
*/
EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override
{
return VF_XYEdgeSealBoxIsForcedSolid(
VoxelBox, Ctx.WorldRadiusVoxels, Ctx.EdgeSealThickness, Base)
? EVoxelTileClass::AllSolid : EVoxelTileClass::Mixed;
}
float ForcedMarginOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override
{
return VF_XYEdgeSealForcedMarginOverBox(
VoxelBox, Ctx.WorldRadiusVoxels, Ctx.EdgeSealThickness, Base);
}
// In the inner world it is Identity; a box that reaches the radial band can only gain rock.
EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override
{
return VF_XYEdgeSealBoxTouchesBand(
VoxelBox, Ctx.WorldRadiusVoxels, Ctx.EdgeSealThickness)
? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity;
}
bool IsXYPure() const override { return true; }
const TCHAR* DebugName() const override { return TEXT("XYEdgeSealOp"); }
private:
float Base = 8.0f;
mutable float WorldRadius = 8192.0f;
mutable float Thickness = 64.0f;
};
//=========================================================================
// RÔLE 4 — STRUCTUREL : CARVE DE PASSAGE
//=========================================================================
@@ -4294,12 +4353,15 @@ void FVoxelOpStack::AppendStructuralPost(float StrateTopWorldZ, float StrateBott
float SealThickness, float BaseDensity, float SpineRadius,
const UVoxelStrateManager* StrateManager)
{
// ORDRE NON NÉGOCIABLE, et c'est l'ordre que les six fonctions de densité utilisent déjà :
// la spine creuse l'intérieur (et ne touche JAMAIS les bandes de seal), le seal re-solidifie
// ses bandes, les passages percent tout — seal compris —, le joueur gagne en dernier.
// ORDRE NON NÉGOCIABLE : la spine creuse l'intérieur (et ne touche JAMAIS les bandes de seal),
// le seal vertical re-solidifie ses bandes, les passages percent les seals, puis la limite XY
// gagne sur tout ce qui précède. Ainsi, même un passage placé dans la rampe ou au-delà du rayon
// ne peut pas ouvrir la coque extérieure. Les éditions joueur restent le dernier post de
// GetDensityAt, hors de cette pile, comme avant.
Add(MakeUnique<FOriginSpineOp>(StrateTopWorldZ, StrateBottomWorldZ, SealThickness, BaseDensity, SpineRadius));
Add(MakeUnique<FBoundarySealOp>(StrateTopWorldZ, StrateBottomWorldZ, SealThickness, BaseDensity));
Add(MakeUnique<FPassageCarveOp>(StrateManager, BaseDensity, SealThickness));
Add(MakeUnique<FXYEdgeSealOp>(BaseDensity));
}
//=============================================================================
+58 -12
View File
@@ -13,7 +13,7 @@
#include "VoxelDiffLayer.h"
#include "VoxelBiomeDefinition.h"
#include "VoxelNoise.h" // T2.a: float, SIMD-batched gradient-noise core
#include "VoxelDensityPrimitives.h" // spine / seal / passage — shared with the operator stack
#include "VoxelDensityPrimitives.h" // spine / seals / passage — shared with the operator stack
#include "VoxelDensityOpStack.h" // OPSTACK Phase 1: the opt-in per-strate operator stack
#include "VoxelHeightOp.h" // IVoxelBiomeField — the adapter below implements it
#include "VoxelStats.h"
@@ -231,13 +231,13 @@ static float CellularNoise3D(const FVector& Position)
//=============================================================================
// Les CORPS de ces trois helpers ont déménagé dans Public/VoxelDensityPrimitives.h : la pile
// d'opérateurs (VoxelDensityOpStack) a besoin exactement des mêmes, et deux copies de trois
// INVARIANTS de monde (descente possible, seals qui tiennent, passages qui percent) finiraient par
// d'opérateurs (VoxelDensityOpStack) a besoin exactement des mêmes, et deux copies des INVARIANTS
// de monde (descente possible, seals qui tiennent, passages qui percent, bord fermé) finiraient par
// diverger. Ces trois lignes gardent les noms locaux pour que les ~20 sites d'appel ci-dessous ne
// bougent pas d'un caractère — le déplacement ne change AUCUN comportement.
//
// The BODIES moved to Public/VoxelDensityPrimitives.h; the operator stack needs the same three, and
// two copies of three world invariants would eventually drift. These forwarders keep the local names
// The BODIES moved to Public/VoxelDensityPrimitives.h; the operator stack needs the same primitives,
// and duplicate world invariants would eventually drift. These forwarders keep the local names
// so not one of the ~20 call sites below changes. No behavioural change.
//
// Convention INTERNE ici : positif = SOLIDE. La négation vers MC se fait sur le `return`.
@@ -558,10 +558,12 @@ UVoxelGenerator::UVoxelGenerator()
void UVoxelGenerator::InitializeSettings(const UVoxelSettings* Settings)
{
// Seul le seed est copié ici. Tout le reste (params de cave, transitions,
// blendings) vient des strate definitions via le StrateManager.
// Les paramètres globaux sont copiés ici une seule fois : le chemin voxel ne doit pas
// déréférencer l'asset de settings.
Seed = Settings ? Settings->Seed : 0;
OriginSpineRadius = Settings ? Settings->OriginSpineRadius : 14.0f;
WorldRadiusVoxels = Settings ? Settings->WorldRadiusVoxels : 0.0f;
EdgeSealThickness = Settings ? Settings->EdgeSealThickness : 64.0f;
}
float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) const
@@ -580,10 +582,12 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
{
// SOLID BEDROCK gap between two strates. The auto-carved passages still tunnel
// through it, but the (0,0) descent stays solid here so the player digs the gap
// to reach the next layer. No caves, no spine, no seal — just rock + passages.
// to reach the next layer. No caves, no spine, no vertical seal — just rock + passages;
// the global XY edge seal is applied below for bounded worlds.
float Density = 8.0f; // bedrock solidity (positive = solid)
const float ModSDF = StrateManager->EvaluateModifierSDF(WorldX, WorldY, WorldZ);
ApplyPassageCarving(Density, ModSDF, 8.0f, 0.0f);
VF_ApplyXYEdgeSeal(Density, WorldX, WorldY, WorldRadiusVoxels, EdgeSealThickness, 8.0f);
Result = -Density;
}
else if (StrateManager)
@@ -687,6 +691,8 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
OpCtx.ChunkCoord = ChunkCoord;
OpCtx.Seed = (uint32)Seed;
OpCtx.LayoutVersion = LayoutVersion;
OpCtx.WorldRadiusVoxels = WorldRadiusVoxels;
OpCtx.EdgeSealThickness = EdgeSealThickness;
// ⚠️ LE MAPPING VIT DANS `VF_BuildOpStackForChunk` (haut de ce fichier) ET NULLE
// PART AILLEURS — `ClassifyTile` appelle la MÊME fabrique. Un verdict de tuile issu
@@ -807,6 +813,12 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
Result = GetDensityWithParams(WorldX, WorldY, WorldZ, FallbackParams, FallbackFP, 0);
}
// The edge is the final generated structural invariant. Passage carving is ordered before it
// in every archetype/stack, and this MC-facing pass also covers out-of-layout air plus any
// disturbance that might otherwise carve back into the shell. Player edits remain separate
// below and are deliberately still the user override path.
VF_ApplyXYEdgeSealMC(Result, WorldX, WorldY, WorldRadiusVoxels, EdgeSealThickness, 8.0f);
//=========================================================================
// PLAYER MODIFICATIONS (diff layer)
//=========================================================================
@@ -873,7 +885,8 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo
// 4h. Pinch/Bottleneck: ellipsoidal passage narrowing for chokepoints
// 5. Worm tunnels: additional organic connectivity
// 6. Boundary seal: solid rock at strate top/bottom
// 7. Modifiers: passages between strates (punch through seals)
// 7. Modifiers: passages between strates (punch through vertical seals)
// 8. XY edge seal: global bounded-world shell wins over passages at the rim
//
// Convention: positive density = solid, negative = air (internally).
// At the end, we negate for the MC table (negative = solid there).
@@ -1945,6 +1958,10 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo
ApplyPassageCarving(Density, ModSDF, Params.BaseDensity, Params.BoundarySealThickness);
}
// Fourth structural post: the XY edge wins over a passage near the rim.
VF_ApplyXYEdgeSeal(Density, WorldX, WorldY,
WorldRadiusVoxels, EdgeSealThickness, Params.BaseDensity);
// Convention MC: négatif = solide, positif = air.
// La logique interne utilise positif = solide (plus lisible), donc on négate.
return -Density;
@@ -2146,7 +2163,7 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ,
}
//=========================================================================
// STEP 5: BOUNDARY SEAL + STEP 6: PASSAGES
// STEP 5: BOUNDARY SEAL + STEP 6: PASSAGES + STEP 7: XY EDGE SEAL
//=========================================================================
// Même logique que TunnelNetwork — factorisée dans les helpers ci-dessus.
ApplyOriginSpine(Density, WorldX, WorldY, WorldZ,
@@ -2163,6 +2180,9 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ,
ApplyPassageCarving(Density, ModSDF, Params.BaseDensity, Params.BoundarySealThickness);
}
VF_ApplyXYEdgeSeal(Density, WorldX, WorldY,
WorldRadiusVoxels, EdgeSealThickness, Params.BaseDensity);
// Convention MC: négatif = solide.
return -Density;
}
@@ -2276,6 +2296,9 @@ float UVoxelGenerator::GetMazeDensity(float WorldX, float WorldY, float WorldZ,
ApplyPassageCarving(Density, ModSDF, Params.BaseDensity, Params.BoundarySealThickness);
}
VF_ApplyXYEdgeSeal(Density, WorldX, WorldY,
WorldRadiusVoxels, EdgeSealThickness, Params.BaseDensity);
return -Density;
}
@@ -2536,6 +2559,9 @@ float UVoxelGenerator::SurfaceDensityFromColumn(float WorldX, float WorldY, floa
ApplyPassageCarving(Density, ModSDF, S.BaseDensity, S.BoundarySealThickness);
}
VF_ApplyXYEdgeSeal(Density, WorldX, WorldY,
WorldRadiusVoxels, EdgeSealThickness, S.BaseDensity);
return -Density;
}
@@ -2681,8 +2707,6 @@ bool UVoxelGenerator::GetSurfaceHeightAt(float WorldX, float WorldY, int32 Chunk
EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis) const
{
if (!StrateManager) return EVoxelTileClass::Mixed;
// Mêmes clamps que GenerateMesh — le verdict doit couvrir le treillis réellement échantillonné.
Step = FMath::Max(1, Step);
const int32 CPA = FMath::Clamp(CellsPerAxis, 2, CHUNK_SIZE);
@@ -2708,6 +2732,20 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
if (DiffLayer->HasAnyModInChunkRange(MinChunk, MaxChunk)) return EVoxelTileClass::Mixed;
}
// The bounded shell is global: it also covers gaps and out-of-layout air, so prove it before
// consulting the strate layout. The final MC edge pass uses a positive base, hence 1.0 is a
// deliberately conservative proof input here; the stack op performs the same proof with the
// archetype base for its own sub-box.
const FBox TileVoxelBox(FVector((float)MinX, (float)MinY, (float)MinZ),
FVector((float)MaxX, (float)MaxY, (float)MaxZ));
if (VF_XYEdgeSealBoxIsForcedSolid(TileVoxelBox, WorldRadiusVoxels,
EdgeSealThickness, 1.0f))
{
return EVoxelTileClass::AllSolid;
}
if (!StrateManager) return EVoxelTileClass::Mixed;
bool bCanSolid = true; // "tout le treillis est solide" encore prouvable
bool bCanAir = true; // "tout le treillis est air" encore prouvable
@@ -3140,6 +3178,8 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
OpCtx.Seed = (uint32)Seed;
OpCtx.LayoutVersion = TC_LayoutVersion;
OpCtx.Step = Step;
OpCtx.WorldRadiusVoxels = WorldRadiusVoxels;
OpCtx.EdgeSealThickness = EdgeSealThickness;
FVoxelOpStack TileStack;
if (!VF_BuildOpStackForChunk(CaveType, Refs, Seed, OriginSpineRadius,
@@ -3940,6 +3980,9 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float
ApplyPassageCarving(Density, ModSDF, Params.BaseDensity, Params.BoundarySealThickness);
}
VF_ApplyXYEdgeSeal(Density, WorldX, WorldY,
WorldRadiusVoxels, EdgeSealThickness, Params.BaseDensity);
return -Density;
}
@@ -4097,5 +4140,8 @@ float UVoxelGenerator::GetFloatingIslandDensity(float WorldX, float WorldY, floa
ApplyPassageCarving(Density, ModSDF, Params.BaseDensity, Params.BoundarySealThickness);
}
VF_ApplyXYEdgeSeal(Density, WorldX, WorldY,
WorldRadiusVoxels, EdgeSealThickness, Params.BaseDensity);
return -Density;
}
+24 -20
View File
@@ -31,7 +31,7 @@
// 2. COMBINER — comment deux champs fusionnent (min/max/smooth/mask). C'EST le rôle qui
// achète la composition ; sans lui il n'y a pas de refactor.
// 3. DETAIL MODIFIER — l'ancien système, rétrogradé à un rôle sur quatre. Inchangé.
// 4. STRUCTURAL POST — spine (0,0) → seal → passages → diff layer. Des INVARIANTS de monde,
// 4. STRUCTURAL POST — spine (0,0) → seal vertical → passages → seal XY → diff layer. Des INVARIANTS de monde,
// pas des choix créatifs : toujours ajoutés, dans cet ordre, jamais
// omissibles par l'auteur.
//
@@ -60,27 +60,23 @@
// LE GROS LOT PERF : les strates de grotte ne sautent AUCUNE tuile aujourd'hui
// THE PERF PRIZE: cave strates skip ZERO tiles today
// ─────────────────────────────────────────────────────────────────────────────────────────
// ClassifyTile ne sait prouver que les gaps de bedrock et SurfaceWorld ; tout le reste tombe sur
// `return EVoxelTileClass::Mixed; // archétype cave […] pas prouvable en v1`. TunnelNetwork, Maze,
// VerticalShafts, FloatingIslands, FlatPlain, CrystalChamber et Underwater ne captent donc RIEN du
// gain T1.d (84 % des générations vides, −44 % de CPU worker). Écrire un prouveur sur mesure par
// archétype a toujours été trop cher — EffectOverBox EST le mécanisme générique qui le rend gratuit :
// une source à graphe de salles qui rend Identity quand aucune borne de salle ni de tunnel n'atteint
// la boîte rend le bedrock profond sautable pour la première fois.
// ClassifyTile conserve ses preuves exactes pour les gaps de bedrock et SurfaceWorld ; les
// archétypes de cave opt-in passent maintenant par le même pliage `ClassifyBox`. Le post XY fournit
// en plus la première preuve globale de coque : une tuile entièrement dans la bande forcée peut être
// sautée sans connaître l'archétype ou le Z.
//
// **Traiter cela comme un livrable explicite de chaque portage, pas comme un effet de bord.**
//
// ─────────────────────────────────────────────────────────────────────────────────────────
// ÉTAT / STATUS
// ─────────────────────────────────────────────────────────────────────────────────────────
// Phase 1. Le premier archétype (Maze) EST porté, dans VoxelDensityOpStack.{h,cpp} — mais
// **GetDensityAt et ClassifyTile ne sont PAS touchés** : le `switch` reste le seul chemin qui
// alimente le jeu. La pile est validée par un test qui la compare à GetMazeDensity point par point.
// Le branchement dans GetDensityAt attend un build vert.
// Les huit archétypes ont maintenant une pile derrière l'opt-in `bUseOperatorStack`, et ClassifyTile
// utilise cette pile pour les slots de cave opt-in. Le chemin legacy reste disponible pour les
// strates non opt-in; les tests d'équivalence servent de garde de portage.
//
// Phase 1. Maze IS ported (VoxelDensityOpStack.{h,cpp}) but **GetDensityAt and ClassifyTile are NOT
// touched** — the switch is still the only path feeding the game. The stack is validated by a test
// that compares it to GetMazeDensity point by point. Wiring it in waits for a green build.
// All eight archetypes now have an operator-stack path behind `bUseOperatorStack`, and ClassifyTile
// uses that stack for opt-in cave slots. The legacy switch remains available for opt-out strates;
// equivalence tests are port-correctness guards.
//
// NOTE sur les UENUM : ces types sont volontairement du C++ nu (pas d'UHT, pas de .generated.h).
// Ils deviendront UENUM/USTRUCT en Phase 3, quand les opérateurs deviendront des data assets et
@@ -99,7 +95,7 @@ struct FBiomeContext;
//=============================================================================
// Le rôle n'est pas décoratif : le compilateur de pile s'en sert pour ORDONNER. Les
// StructuralPost sont toujours ajoutés en dernier, dans l'ordre fixe spine → seal → passage →
// diff, quoi que l'auteur ait assemblé. Un auteur ne peut pas les omettre : la descente doit
// seal XY → diff, quoi que l'auteur ait assemblé. Un auteur ne peut pas les omettre : la descente doit
// rester possible, les seals doivent tenir, les passages doivent percer, les éditions du joueur
// gagnent toujours.
enum class EVoxelOpRole : uint8
@@ -210,6 +206,12 @@ struct FVoxelOpContext
// DOIT faire partie de toute clé de cache. Voir AUDIT C2.
uint32 LayoutVersion = 0;
// Global XY world-edge invariant. These are copied from UVoxelSettings by the generator and
// travel with the chunk context so the automatically appended edge op cannot be omitted by a
// builder. Radius 0 is the legacy unbounded/no-op mode.
float WorldRadiusVoxels = 8192.0f;
float EdgeSealThickness = 64.0f;
// Bornes Z de la strate en coords VOXEL (pas cm).
float StrateTopWorldZ = 0.0f;
float StrateBottomWorldZ = 0.0f;
@@ -386,8 +388,8 @@ public:
*
* Deux familles répondent autre chose que Mixed :
* • les SOURCES (rôle 1) — elles posent le champ, donc elles le savent par construction ;
* • les op STRUCTURELS forçants — typiquement ApplyBoundarySeal, qui à l'intérieur de sa
* bande fait `Max(Density, SealFactor·BaseDensity)` avec SealFactor > 0 : le résultat est
* • les op STRUCTURELS forçants — ApplyBoundarySeal et ApplyXYEdgeSeal — qui, dans leur bande
* prouvée, font `Max(Density, SealFactor·BaseDensity)` avec SealFactor > 0 : le résultat est
* solide garanti quoi qu'il y ait eu avant. C'est exactement ce que ClassifyTile encode
* aujourd'hui avec « bande de seal ⇒ bCanAir = false » — et un simple FillOnly ne suffirait
* PAS à le reproduire (voir VF_FoldOp plus bas).
@@ -397,8 +399,8 @@ public:
* échantillonnant ses colonnes sur le treillis exact, exactement comme aujourd'hui.
*
* FORCING OPS. Some ops do not *move* the input density, they *overwrite* it. Default Mixed =
* "I don't know", always safe. The boundary seal is the non-source example, and it is the
* reason this method exists at all rather than being folded into EffectOverBox.
* "I don't know", always safe. The vertical and XY edge seals are the non-source examples, and
* they are the reason this method exists at all rather than being folded into EffectOverBox.
*/
virtual EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
{
@@ -451,6 +453,8 @@ public:
// ApplyOriginSpine → CarveOnly (tue AllSolid) ≡ le test cercle/boîte XY
// ApplyBoundarySeal → ClassifyBox = AllSolid DANS sa bande (opérateur forçant),
// FillOnly ailleurs ≡ « bande de seal ⇒ bCanAir=false »
// ApplyXYEdgeSeal → ClassifyBox = AllSolid DANS sa bande radiale (forçant) ; il gagne
// après les passages, donc la coque extérieure reste fermée.
// disturbances chasms → CarveOnly ≡ « ChasmDensity > 0 ⇒ bCanSolid=false »
// disturbances ponts/arêtes → FillOnly ≡ « Bridge/RidgeDensity > 0 ⇒ bCanAir=false »
// diff layer → Both si des mods touchent la boîte, sinon Identity
+24 -21
View File
@@ -6,15 +6,16 @@
// `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, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands (6 sur 8)**.
// **Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands, TunnelNetwork,
// Underwater (8 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.
// `ClassifyTile` est branché pour les archétypes de cave opt-in : il construit la même pile et
// plie `ClassifyBox`; les gaps et SurfaceWorld gardent leurs preuves exactes dédiées.
//
// 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 ported — 6 of 8). ClassifyTile is NOT wired: it still uses its
// hand-written guards rather than ClassifyBox. That is Phase 2.
// bUseOperatorStack AND its archetype is ported — all 8). ClassifyTile uses the same stack for cave
// archetypes; gaps and SurfaceWorld retain their exact hand-written proofs.
//
// ⛔ 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
@@ -43,7 +44,7 @@ class UVoxelStrateManager;
* FVoxelOpStack — une liste ordonnée d'opérateurs + le pliage de verdict de boîte.
*
* PROPRIÉTÉ (rôle 4) : les opérateurs STRUCTURELS sont ajoutés par `AppendStructuralPost` et
* l'ordre spine → seal → passage est garanti par cette fonction, pas par l'auteur. Un auteur ne
* l'ordre spine → seal → passage → seal XY est garanti par cette fonction, pas par l'auteur. Un auteur ne
* peut pas les omettre ni les réordonner — ce sont des invariants de monde (la descente doit rester
* possible, les seals doivent tenir, les passages doivent percer).
*
@@ -122,7 +123,7 @@ public:
/**
* Le pliage générique qui remplacera les gardes écrites à la main dans ClassifyTile.
* Voir `VF_FoldOp` (VoxelDensityOp.h) pour la sémantique — en particulier pourquoi un
* opérateur FORÇANT (le seal dans sa bande) écrase ce que la pile avait conclu avant lui.
* opérateur FORÇANT (les seals dans leurs bandes) écrase ce que la pile avait conclu avant lui.
*/
EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
{
@@ -130,7 +131,8 @@ public:
for (const TUniquePtr<IVoxelDensityOp>& Op : Ops)
{
VF_FoldOp(H, *Op, VoxelBox, Ctx);
if (H.IsDead()) { return EVoxelTileClass::Mixed; } // early-out : plus rien à prouver
// Do not early-out on a dead hypothesis: a later forcing structural post may
// deliberately overwrite it (the XY edge seal is appended after passage carving).
}
return H.Resolve();
}
@@ -138,8 +140,8 @@ public:
/**
* LE MÊME PLIAGE, MAIS QUI DIT **QUI** A TUÉ CHAQUE HYPOTHÈSE. Diagnostic, réservé aux tests.
*
* ⚠️ IL DOIT RENDRE EXACTEMENT LE MÊME VERDICT QUE `ClassifyBox` — même boucle, même early-out,
* même ordre. Un diagnostic qui emprunte un chemin légèrement différent de celui qu'il explique
* ⚠️ IL DOIT RENDRE EXACTEMENT LE MÊME VERDICT QUE `ClassifyBox` — même boucle, même ordre,
* y compris le fait de laisser un post forçant ultérieur ressusciter une hypothèse morte. Un diagnostic qui emprunte un chemin légèrement différent de celui qu'il explique
* est pire que pas de diagnostic : il envoie chercher le bug ailleurs. Si l'un des deux change,
* l'autre change avec lui.
*
@@ -148,8 +150,8 @@ public:
* lisible s'obtient par `GetOpDebugName(index)`.
*
* Same fold, but it reports WHICH op killed each hypothesis. Must stay verdict-identical to
* ClassifyBox — a diagnostic that takes a slightly different path sends you hunting in the
* wrong place.
* ClassifyBox, including later forcing posts that may overwrite a dead hypothesis — a
* diagnostic that takes a slightly different path sends you hunting in the wrong place.
*/
EVoxelTileClass ClassifyBoxAttributed(const FBox& VoxelBox, const FVoxelOpContext& Ctx,
int32& OutSolidKiller, int32& OutAirKiller) const
@@ -168,7 +170,6 @@ public:
if (bSolidBefore && !H.bCanBeAllSolid && OutSolidKiller == INDEX_NONE) { OutSolidKiller = i; }
if (bAirBefore && !H.bCanBeAllAir && OutAirKiller == INDEX_NONE) { OutAirKiller = i; }
if (H.IsDead()) { return EVoxelTileClass::Mixed; }
}
return H.Resolve();
}
@@ -181,7 +182,7 @@ public:
/**
* RÔLE 4 — ajoute les invariants de monde, dans l'ordre fixe, à la fin de la pile.
* spine (0,0) → seal de frontière → carve de passage.
* spine (0,0) → seal de frontière → carve de passage → seal de limite XY.
*
* ⚠️ La couche de diff (édits joueur) n'est PAS ici : elle vit dans `GetDensityAt`, APRÈS la
* négation MC, avec les disturbances. Elle rejoindra la pile quand les disturbances seront
@@ -192,6 +193,8 @@ public:
* It joins the stack when disturbances are ported. Harmless while the stack feeds nothing.
*
* @param StrateManager peut être nullptr → pas de carve de passage (comme le fallback actuel).
* Le seal XY reste toujours présent ; son rayon/thickness viennent de Ctx
* au moment de PrepareChunk (0 = monde non borné).
*/
VOXELFORGE_API void AppendStructuralPost(float StrateTopWorldZ, float StrateBottomWorldZ,
float SealThickness, float BaseDensity, float SpineRadius,
@@ -286,7 +289,7 @@ namespace VoxelDensityOps
/**
* FlatPlain ET CrystalChamber — la même pile, **sans branchement sur le type** :
* SlabVoidSource → GridColumnMod → [structural post ×3]
* SlabVoidSource → GridColumnMod → [structural post ×4]
*
* C'est le premier vrai gain du refactor (OPSTACK-PLAN §4) : deux des huit archétypes
* disparaissent dans un opérateur, et leur différence redevient ce qu'elle était déjà dans
@@ -297,8 +300,8 @@ namespace VoxelDensityOps
const UVoxelStrateManager* StrateManager);
/**
* VerticalShafts — 8 ops, et **TROIS viennent de Maze sans une ligne de changement** :
* ConstantRock → ShaftField → SdfRoughness → SdfCarve → ShaftLedge → [structural post ×3]
* VerticalShafts — 9 ops, et **TROIS viennent de Maze sans une ligne de changement** :
* ConstantRock → ShaftField → SdfRoughness → SdfCarve → ShaftLedge → [structural post ×4]
*
* 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
@@ -310,10 +313,10 @@ namespace VoxelDensityOps
const UVoxelStrateManager* StrateManager);
/**
* TunnelNetwork — **COMPLET, 19 ops** :
* TunnelNetwork — **COMPLET, 20 ops** :
* ConstantRock → RoomGraph(warp + pits + cheminées) → SdfCarve → CaveRoughness(4b)
* → Terrace → LayerLines → Ribbing → Overhang → Cliff → Scallop → Arch → RoomColumn(4d)
* → Dome(4g) → Pinch(4h) → FloorBias → Worms → [structural ×3]
* → Dome(4g) → Pinch(4h) → FloorBias → Worms → [structural ×4]
*
* L'override d'op PAR SALLE (étape C1) n'ajoute aucun opérateur : `FRoomGraphSource` publie
* `LocalParams()` — les params de la strate avec l'op de la salle la plus proche appliqué — et
@@ -363,8 +366,8 @@ namespace VoxelDensityOps
VOXELFORGE_API FRoomBoxDiagnostic GetLastRoomBoxDiagnostic();
/**
* FloatingIslands — 7 ops, et **la pile tourne à l'ENVERS** :
* ConstantVoid → IslandBlob → SdfRoughness → SdfFill → [structural post ×3]
* FloatingIslands — 8 ops, et **la pile tourne à l'ENVERS** :
* ConstantVoid → IslandBlob → SdfRoughness → SdfFill → [structural post ×4]
*
* 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
+153 -20
View File
@@ -1,27 +1,26 @@
// VoxelDensityPrimitives.h
// Les trois post-traitements STRUCTURELS partagés par chaque archétype.
// The three STRUCTURAL post-processes every archetype shares.
// Les quatre post-traitements STRUCTURELS partagés par chaque archétype.
// The four STRUCTURAL post-processes every archetype shares.
//
// POURQUOI CE FICHIER EXISTE / WHY THIS FILE EXISTS
// Ces trois fonctions étaient `static` dans VoxelGenerator.cpp et appelées à l'identique par les six
// fonctions de densité. La pile d'opérateurs a besoin des MÊMES, donc elles montent ici : UNE copie,
// partagée par le générateur et par les opérateurs. Dupliquer serait garantir qu'elles divergent —
// et ce sont des INVARIANTS de monde (la descente doit rester possible, les seals doivent tenir, les
// passages doivent percer), pas des choix créatifs.
// Les trois primitives verticales étaient `static` dans VoxelGenerator.cpp et appelées à l'identique
// par les six fonctions de densité. La pile d'opérateurs a besoin des MÊMES, donc elles montent ici :
// UNE copie partagée. Le scellement XY est ajouté dans ce même fichier pour que les deux chemins
// partagent aussi la nouvelle invariance — les invariants de monde ne sont pas des choix créatifs.
//
// They were `static` in VoxelGenerator.cpp and called identically by all six density functions. The
// operator stack needs the same ones, so they move here: ONE copy, shared. Duplicating would
// guarantee divergence, and these are world INVARIANTS, not creative choices.
// The three vertical primitives were `static` in VoxelGenerator.cpp and called identically by all six
// density functions. The operator stack needs the same ones, so they move here: ONE shared copy. The
// XY seal is added beside them so both paths share the new invariant too.
//
// ⚠️ CONVENTION DE SIGNE — la source n°1 de confusion du plugin.
// Ces trois fonctions travaillent en convention INTERNE : **positif = SOLIDE, négatif = AIR**.
// Ces primitives travaillent en convention INTERNE : **positif = SOLIDE, négatif = AIR**.
// C'est la convention dans laquelle chaque fonction d'archétype est écrite ; la négation vers la
// convention marching-cubes (négatif = solide) se fait UNE FOIS, sur le `return`.
// SIGN CONVENTION: these work in INTERNAL convention — **positive = SOLID**. The negate to MC
// convention happens ONCE, at the caller's return.
//
// Aucun changement de comportement en les déplaçant : corps identiques, FORCEINLINE au lieu de
// static, mêmes appelants. / No behavioural change: identical bodies, FORCEINLINE instead of static.
// Les trois corps déplacés restent identiques; le quatrième est la nouvelle frontière globale.
// The three moved bodies remain identical; the fourth is the new global boundary.
#pragma once
@@ -117,14 +116,148 @@ FORCEINLINE void VF_ApplyOriginSpine(float& Density, float WorldX, float WorldY,
}
//=============================================================================
// PORTÉES / REACHES — les rayons dont ClassifyTile et EffectOverBox ont besoin
// XY EDGE SEAL / SCELLEMENT DE LA LIMITE XY
//=============================================================================
// Les constantes de blend ci-dessus (3.0 pour la spine, 4.0 pour les passages) sont dupliquées à la
// main dans ClassifyTile aujourd'hui. Les nommer ici pour qu'un futur test de bornes ne puisse pas
// les désynchroniser. / The blend constants above are hand-duplicated inside ClassifyTile today.
// Naming them here so a future bounds test cannot let the two drift apart.
// The bounded-world rim is a radial solid ramp in actor-space XY. This helper uses the same
// internal convention as VF_ApplyBoundarySeal (positive = solid); the MC-facing wrapper below
// negates it so the output convention remains negative = solid.
//
// Radius == 0 is deliberately checked before any arithmetic: it is a true legacy no-op, not a
// nearly-invisible setting-dependent perturbation. The common interior case also returns from a
// squared-distance test and pays no sqrt.
FORCEINLINE void VF_ApplyXYEdgeSeal(float& Density, float WorldX, float WorldY,
float WorldRadiusVoxels, float Thickness, float BaseDensity)
{
if (!(WorldRadiusVoxels > 0.0f) || !(Thickness > 0.0f) || !(BaseDensity > 0.0f)) return;
if (!FMath::IsFinite(WorldX) || !FMath::IsFinite(WorldY)
|| !FMath::IsFinite(WorldRadiusVoxels) || !FMath::IsFinite(Thickness)
|| !FMath::IsFinite(BaseDensity)) return;
const float InnerRadius = WorldRadiusVoxels - Thickness;
const float InnerRadiusSq = InnerRadius * InnerRadius;
const float DistSq = WorldX * WorldX + WorldY * WorldY;
const float RadiusSq = WorldRadiusVoxels * WorldRadiusVoxels;
// The overwhelmingly common case: well inside the bounded world.
if (InnerRadius > 0.0f && DistSq <= InnerRadiusSq) return;
// Keep the outer-boundary comparison in squared space too. This makes the exact force branch
// and the ClassifyBox proof use the same predicate: at or beyond R the result is BaseDensity,
// with no sqrt-rounding ambiguity in the forced-solid margin.
if (DistSq >= RadiusSq)
{
Density = FMath::Max(Density, BaseDensity);
return;
}
const float DistXY = FMath::Sqrt(DistSq);
// 0 at the inner edge, 1 at the world radius; smoothstep removes a cylindrical cliff.
const float SealFactor = SmoothStep01(FMath::Clamp(
(DistXY - InnerRadius) / Thickness, 0.0f, 1.0f));
// At/beyond WorldRadiusVoxels the branch above already forced exactly BaseDensity.
Density = FMath::Max(Density, SealFactor * BaseDensity);
}
/** MC-facing form used after post-processes that already operate in the output convention. */
FORCEINLINE void VF_ApplyXYEdgeSealMC(float& Density, float WorldX, float WorldY,
float WorldRadiusVoxels, float Thickness, float BaseDensity)
{
if (!(WorldRadiusVoxels > 0.0f) || !(Thickness > 0.0f)) return;
float InternalDensity = -Density;
VF_ApplyXYEdgeSeal(InternalDensity, WorldX, WorldY,
WorldRadiusVoxels, Thickness, BaseDensity);
Density = -InternalDensity;
}
//=============================================================================
// XY EDGE PROOF / PREUVE DE BOITE
//=============================================================================
// The closest point of an AABB to the XY origin gives the minimum radius over the whole box.
// The helpers below are shared by ClassifyBox, ClassifyTile's global shell early-out, and the
// soundness test. They intentionally require one voxel of radial margin in the ramp: at the
// mathematical inner edge the helper is a true no-op, so claiming AllSolid there would be false.
namespace VoxelDensityReach
{
constexpr float SpineBlend = 3.0f;
constexpr float PassageBlend = 4.0f;
constexpr float SpineBlend = 3.0f;
constexpr float PassageBlend = 4.0f;
constexpr float EdgeProofMargin = 1.0f;
}
FORCEINLINE bool VF_IsValidXYEdgeSealProofInput(const FBox& VoxelBox,
float WorldRadiusVoxels, float Thickness, float BaseDensity)
{
return VoxelBox.IsValid
&& FMath::IsFinite(VoxelBox.Min.X) && FMath::IsFinite(VoxelBox.Min.Y)
&& FMath::IsFinite(VoxelBox.Max.X) && FMath::IsFinite(VoxelBox.Max.Y)
&& FMath::IsFinite(WorldRadiusVoxels) && FMath::IsFinite(Thickness)
&& FMath::IsFinite(BaseDensity)
&& WorldRadiusVoxels > 0.0f && Thickness > 0.0f && BaseDensity > 0.0f;
}
FORCEINLINE float VF_XYEdgeClosestRadiusSq(const FBox& VoxelBox)
{
const float ClosestX = (VoxelBox.Min.X > 0.0f) ? VoxelBox.Min.X
: (VoxelBox.Max.X < 0.0f) ? VoxelBox.Max.X : 0.0f;
const float ClosestY = (VoxelBox.Min.Y > 0.0f) ? VoxelBox.Min.Y
: (VoxelBox.Max.Y < 0.0f) ? VoxelBox.Max.Y : 0.0f;
return ClosestX * ClosestX + ClosestY * ClosestY;
}
FORCEINLINE float VF_XYEdgeFarthestRadiusSq(const FBox& VoxelBox)
{
const float FarthestX = FMath::Max(FMath::Abs(VoxelBox.Min.X), FMath::Abs(VoxelBox.Max.X));
const float FarthestY = FMath::Max(FMath::Abs(VoxelBox.Min.Y), FMath::Abs(VoxelBox.Max.Y));
return FarthestX * FarthestX + FarthestY * FarthestY;
}
/**
* Return the actual positive lower-bound margin imposed by the edge seal over a box, or 0 when
* the box is not provably forced. The ramp bound is intentionally reduced by 2× after the
* one-voxel proof margin, so float evaluation error can only make this estimate too small.
*/
FORCEINLINE float VF_XYEdgeSealForcedMarginOverBox(const FBox& VoxelBox,
float WorldRadiusVoxels, float Thickness, float BaseDensity)
{
if (!VF_IsValidXYEdgeSealProofInput(VoxelBox, WorldRadiusVoxels, Thickness, BaseDensity)) return 0.0f;
const float ClosestRadiusSq = VF_XYEdgeClosestRadiusSq(VoxelBox);
const float RadiusSq = WorldRadiusVoxels * WorldRadiusVoxels;
if (ClosestRadiusSq >= RadiusSq)
{
// The per-voxel helper takes the >= WorldRadiusVoxels branch and forces BaseDensity.
return BaseDensity;
}
const float InnerRadius = WorldRadiusVoxels - Thickness;
const float ProofRadius = InnerRadius + VoxelDensityReach::EdgeProofMargin;
if (!(Thickness > VoxelDensityReach::EdgeProofMargin)
|| !(ClosestRadiusSq >= ProofRadius * ProofRadius)) return 0.0f;
const float GuaranteedFactor = SmoothStep01(
FMath::Clamp(VoxelDensityReach::EdgeProofMargin / Thickness, 0.0f, 1.0f));
return BaseDensity * GuaranteedFactor * 0.5f;
}
FORCEINLINE bool VF_XYEdgeSealBoxIsForcedSolid(const FBox& VoxelBox,
float WorldRadiusVoxels, float Thickness, float BaseDensity)
{
return VF_XYEdgeSealForcedMarginOverBox(
VoxelBox, WorldRadiusVoxels, Thickness, BaseDensity) > 0.0f;
}
FORCEINLINE bool VF_XYEdgeSealBoxTouchesBand(const FBox& VoxelBox,
float WorldRadiusVoxels, float Thickness)
{
if (!(WorldRadiusVoxels > 0.0f) || !(Thickness > 0.0f)
|| !FMath::IsFinite(WorldRadiusVoxels) || !FMath::IsFinite(Thickness)) return false;
const float InnerRadius = WorldRadiusVoxels - Thickness;
return VF_XYEdgeFarthestRadiusSq(VoxelBox) > InnerRadius * InnerRadius;
}
//=============================================================================
// PORTÉES / REACHES — les rayons dont ClassifyTile et EffectOverBox ont besoin
//=============================================================================
// The constants are named above so a future bounds test cannot let them drift from the hot path.
+10 -3
View File
@@ -58,7 +58,7 @@ namespace VoxelGenLOD
* UVoxelGenerator
*
* Objet UObject léger — ne stocke pas de données lourdes.
* Tient juste un pointeur sur les settings (pour le seed) et les services
* Tient les paramètres globaux copiés depuis les settings (seed, spine, bord XY) et les services
* dont il a besoin (StrateManager pour les params par chunk, DiffLayer pour
* les carves du joueur).
*/
@@ -73,8 +73,8 @@ public:
//=========================================================================
// SEED (source unique: Settings->Seed)
//=========================================================================
// On copie juste le seed au démarrage pour éviter un déréférencement
// par voxel. Tout le reste vient des params de strate.
// Les paramètres globaux sont copiés au démarrage pour éviter un déréférencement par voxel.
// Les formes créatives viennent ensuite des params de strate.
int32 Seed = 0;
// Radius (voxels) of the guaranteed open vertical landing column carved at world
@@ -82,6 +82,13 @@ public:
// 0 disables the spine carve.
float OriginSpineRadius = 14.0f;
// Radius (voxels) of the bounded world in actor-space XY, measured from (0,0).
// 0 keeps the legacy unbounded behavior and is a true no-op for the edge seal.
float WorldRadiusVoxels = 8192.0f;
// Width (voxels) of the smooth solid ramp at the XY world edge.
float EdgeSealThickness = 64.0f;
//=========================================================================
// SERVICES (injectés par AVoxelWorld::BeginPlay)
//=========================================================================
+22
View File
@@ -179,6 +179,28 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0", ClampMax = "3"))
int32 LODOctaveDrop = 0;
//==========================================================================
// WORLD BOUNDS (bounded gigantic world in actor-space XY)
//==========================================================================
// Radius (voxels) of the bounded world in XY, measured from actor-space (0,0).
// 0 = unbounded (legacy behaviour, no edge seal).
//
// ⚠️ DEFAULTS TO 0 ON PURPOSE, and it is not the value the GDD wants.
// Existing DA_Settings / BP_VoxelSettings assets predate this property, so they do NOT serialize
// it — they silently inherit whatever C++ default is written here. A non-zero default would
// therefore wall in every existing world the next time the editor opens, with nothing in the
// asset to show why. The GDD calls for "bounded, but gigantic": ~8192 voxels (2.05 km) is the
// intended production value. SET IT IN THE ASSET, deliberately, once.
//
// FR : 0 par défaut EXPRÈS — les assets existants n'ont pas cette propriété et héritent
// silencieusement de la valeur C++. La valeur de production visée (~8192) se règle dans l'asset.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|World Bounds", meta = (ClampMin = "0.0"))
float WorldRadiusVoxels = 0.0f;
// Solid ramp thickness at the radial world edge, in voxels.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|World Bounds", meta = (ClampMin = "0.0"))
float EdgeSealThickness = 64.0f;
//=========================================================================
// CONTENT — distance-based decoration grid (no LOD pop)
//=========================================================================