feat(maze): a spanning tree -- it is a maze now, not a grid

Jahni: "mostly a cube full of straight pathway along the edges... basically
just a repeating pattern of tunnel connecting to each other, definitely
unwanted". And the measurement: the arrival mouth in a 30-cell pocket while
the network held 1,217. SAME CAUSE -- edge selection was independent hash
gating per +X/+Y/+Z edge, i.e. a random subgraph of the cubic lattice.
Every junction identical, and connectivity left to chance.

Fixed the way VerticalShafts was fixed in 7a20eee: guarantee it by
construction. Each non-origin node hashes ONE parent axis toward
(0,0,0) so Manhattan distance strictly decreases -- a proof of a global
rooted tree, not a probability. Loops are capped at 0.18 x
BranchProbability and 0.10 x Verticality and can never supply connectivity.
⛔ No edge probability was raised; percolation is a phase transition, not a
knob.

  64 seeds x 9,261 nodes:  0 disconnected cells, 0 edge-count violations
  determinism:             20,000 bit-identical checks
  seam freedom:            42 probes, 0 legacy and 0 op-stack mismatches

⭐ AND IT READS AS A MAZE, in numbers rather than adjectives:

  degree   1 (dead ends)  15.9%      mean degree 2.504
  degree   2              36.6%      run length median 1, p90 3, max 18
  degree   3              31.7%
  cubic-grid control:     100% degree-6, 0% dead ends, mean 6.000

Two abreast, derived not nudged: 3 m usable floor / 0.7 = 4.286 m clear
bore, + roughness reach 3.75 voxels => CorridorRadius 12.5 (6.25 m bore),
giving 3.06 m of usable floor. CellSize 64 still leaves a 7.875 m wall
between neighbouring bores.

BranchProbability and Verticality are now LOOP-DETAIL knobs, not
connectivity controls. Documented at their declarations so nobody tunes
them expecting the old meaning.

⚠️ The player-fit law still fails, and Codex named the likely reason in its
caveats: "global tree connectivity does not guarantee connectivity inside
an arbitrary finite cropped measurement window." The tree routes toward the
ORIGIN, and the fitted window is the AABB of the two mouths -- so the
connecting path runs A -> origin -> B and leaves the box. The coarse maze
graph now measures Connected while the fine windowed law does not. That
smells like the sixth measurement artifact of this session, and it is the
next thing to test rather than to assume.

Box audits: existing maze 104 proved / 0 violations; novel fill stack 240
proved / 319,440 voxels / 0 violations. Op-stack bit-identical over 20,000
samples. Perf 1.13 us rebuild, 0.145 us hot call.

Showcase regenerated. Build clean, 32 tests, 30 clean successes, 2 known
deliberate warnings, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K8V9my1PL68pmQb2gutZZV
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-09-06 11:12:52 +02:00
1 parent 4378385b37
commit 2eaceb5984
16 files changed
+977 -177

No files matched your search

@@ -732,10 +732,10 @@ namespace
TEXT("8 chunks / 64 m: supports several 16 m maze levels"));
Add(TEXT("Maze"), TEXT("CellSize"), 64.0f,
TEXT("16 m lattice cells, matching ordinary fight-space scale"));
Add(TEXT("Maze"), TEXT("CorridorRadius"), 8.0f,
TEXT("2 m radius / 4 m circular bore: upper fight-space target"));
Add(TEXT("Maze"), TEXT("CorridorRadius"), 12.5f,
TEXT("3.125 m radius / 6.25 m nominal bore; 3 m usable floor survives worst roughness"));
Add(TEXT("Maze"), TEXT("SurfaceRoughness"), 2.0f,
TEXT("0.5 m texture; bounded reach is 0.94 m, below the 2 m radius"));
TEXT("0.5 m texture; proven bounded reach is 0.94 m, below the 3.125 m radius"));
Add(TEXT("Maze"), TEXT("BoundarySealThickness"), 4.0f,
TEXT("1 m solid boundary"));
@@ -889,7 +889,7 @@ namespace
"(%.3f including 6-voxel blend).\n"),
TunnelReach / 6.0f, 6.0f, TunnelReach / 28.0f,
SlabFloorReach / 12.0f, SlabCeilingReach / 12.0f,
MazeReach / 8.0f, 8.0f, TunnelReach / 80.0f,
MazeReach / 12.5f, 12.5f, TunnelReach / 80.0f,
ShaftReach / 8.0f, ShaftReach / 6.0f,
IslandReach / 24.0f, (IslandReach + 6.0f) / 24.0f);
}
@@ -1097,7 +1097,7 @@ namespace
const FVoxelStrateMeasureSettings FineSettings = VF_MakeFinePlayerFitSettings(
BaseSettings, FinePreviewSettings, ArrivalPoint, DeparturePoint);
const float RadiusSweep[] = { 4.0f, 6.0f, 8.0f, 10.0f, 12.0f };
const float RadiusSweep[] = { 6.0f, 8.0f, 10.0f, 12.0f, 12.5f };
OutReport = TEXT(
"Part A Maze corridor-radius sweep (same seed/index, fitted step-1 ROI; "
"radius voxels -> bore metres):\n");
@@ -0,0 +1,165 @@
// VoxelForgeMazeSeamTest.cpp
// The Maze parent rule must be independent of which neighbouring chunk was evaluated first.
#if WITH_DEV_AUTOMATION_TESTS
#include "Misc/AutomationTest.h"
#include "VoxelCaveMorphology.h"
#include "VoxelForgeTestFixture.h"
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
FVoxelForgeMazeSeamTest,
"VoxelForge.Generation.MazeSeamFreedom",
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
namespace
{
void AddMazeBoundaryProbes(const FMazeGenerationParams& Params, int32 BottomVoxelZ,
int32 TopVoxelZ, TArray<FVector>& OutProbes)
{
const float CellSize = FMath::Max(Params.CellSize, 1.0f);
const int32 MidCellZ = FMath::FloorToInt(
0.5f * (Params.StrateTopWorldZ + Params.StrateBottomWorldZ) / CellSize);
const float MidZ = (MidCellZ + 0.5f) * CellSize;
if (MidZ <= BottomVoxelZ || MidZ >= TopVoxelZ)
{
return;
}
const int32 CellCoordinates[] = { -5, -2, -1, 0, 1, 2, 5 };
const float Offsets[] = { -0.25f, 0.0f, 0.25f };
for (const int32 CellX : CellCoordinates)
{
for (const float Offset : Offsets)
{
OutProbes.Add(FVector(CellX * CellSize + Offset, 7.25f, MidZ));
OutProbes.Add(FVector(7.25f, CellX * CellSize + Offset, MidZ));
}
}
for (const int32 CellZ : CellCoordinates)
{
for (const float Offset : Offsets)
{
const float Z = CellZ * CellSize + Offset;
if (Z > BottomVoxelZ && Z < TopVoxelZ)
{
OutProbes.Add(FVector(7.25f, -11.5f, Z));
}
}
}
}
void WarmNeighbouringChunks(const VoxelForgeTest::FTestWorld& World,
const FVector& Probe, int32 Variant)
{
const float Delta = static_cast<float>(CHUNK_SIZE * 3);
const FVector Offsets[] = {
FVector(Variant == 0 ? -Delta : Delta, Variant == 1 ? -Delta : Delta, 0.0f),
FVector(Variant == 2 ? Delta : -Delta, Variant == 3 ? Delta : -Delta, 0.0f),
FVector(Variant == 4 ? Delta : -Delta, Variant == 5 ? -Delta : Delta, 0.0f),
};
for (const FVector& Offset : Offsets)
{
World.Generator->GetDensityAt(
Probe.X + Offset.X, Probe.Y + Offset.Y, Probe.Z + Offset.Z);
}
}
int32 CountContextMismatches(const VoxelForgeTest::FTestWorld& World,
const TArray<FVector>& Probes, int32& OutFirstProbe,
float& OutFirstBefore, float& OutFirstAfter)
{
int32 Mismatches = 0;
OutFirstProbe = INDEX_NONE;
OutFirstBefore = 0.0f;
OutFirstAfter = 0.0f;
for (int32 Index = 0; Index < Probes.Num(); ++Index)
{
const FVector& Probe = Probes[Index];
WarmNeighbouringChunks(World, Probe, Index % 6);
const float Before = World.Generator->GetDensityAt(Probe.X, Probe.Y, Probe.Z);
WarmNeighbouringChunks(World, Probe, (Index + 3) % 6);
const float After = World.Generator->GetDensityAt(Probe.X, Probe.Y, Probe.Z);
if (!VoxelForgeTest::BitEqual(Before, After))
{
if (OutFirstProbe == INDEX_NONE)
{
OutFirstProbe = Index;
OutFirstBefore = Before;
OutFirstAfter = After;
}
++Mismatches;
}
}
return Mismatches;
}
}
bool FVoxelForgeMazeSeamTest::RunTest(const FString& Parameters)
{
using namespace VoxelForgeTest;
FTestWorld LegacyWorld;
LegacyWorld.Build(7331, 2, false, 4);
FTestWorld StackWorld;
StackWorld.Build(7331, 2, true, 4);
if (!LegacyWorld.IsValid() || !StackWorld.IsValid())
{
AddError(LegacyWorld.IsValid() ? StackWorld.WhyInvalid() : LegacyWorld.WhyInvalid());
return false;
}
int32 LegacyTop = 0, LegacyBottom = 0;
int32 StackTop = 0, StackBottom = 0;
if (!LegacyWorld.GetSlotVoxelZRange(FTestWorld::SlotMaze, LegacyTop, LegacyBottom)
|| !StackWorld.GetSlotVoxelZRange(FTestWorld::SlotMaze, StackTop, StackBottom))
{
AddError(TEXT("The seam fixture has no Maze slot."));
return false;
}
const int32 LegacyMidChunkZ = ((LegacyTop + LegacyBottom) / 2) / CHUNK_SIZE;
const int32 StackMidChunkZ = ((StackTop + StackBottom) / 2) / CHUNK_SIZE;
const FMazeGenerationParams LegacyParams = LegacyWorld.StrateManager->GetMazeParamsForChunk(
FIntVector(0, 0, LegacyMidChunkZ));
const FMazeGenerationParams StackParams = StackWorld.StrateManager->GetMazeParamsForChunk(
FIntVector(0, 0, StackMidChunkZ));
TArray<FVector> LegacyProbes;
TArray<FVector> StackProbes;
AddMazeBoundaryProbes(LegacyParams, LegacyBottom, LegacyTop, LegacyProbes);
AddMazeBoundaryProbes(StackParams, StackBottom, StackTop, StackProbes);
TestEqual(TEXT("legacy and operator seam tests have the same probe count"),
LegacyProbes.Num(), StackProbes.Num());
int32 LegacyFirst = INDEX_NONE, StackFirst = INDEX_NONE;
float LegacyBefore = 0.0f, LegacyAfter = 0.0f;
float StackBefore = 0.0f, StackAfter = 0.0f;
const int32 LegacyMismatches = CountContextMismatches(
LegacyWorld, LegacyProbes, LegacyFirst, LegacyBefore, LegacyAfter);
const int32 StackMismatches = CountContextMismatches(
StackWorld, StackProbes, StackFirst, StackBefore, StackAfter);
TestEqual(TEXT("legacy Maze has no chunk-context seam mismatches"), LegacyMismatches, 0);
TestEqual(TEXT("operator-stack Maze has no chunk-context seam mismatches"), StackMismatches, 0);
TestTrue(TEXT("Maze seam test sampled cell-boundary probes"), LegacyProbes.Num() > 0);
if (LegacyFirst != INDEX_NONE)
{
AddError(FString::Printf(TEXT("Legacy Maze seam mismatch at probe %d: %.9g -> %.9g"),
LegacyFirst, LegacyBefore, LegacyAfter));
}
if (StackFirst != INDEX_NONE)
{
AddError(FString::Printf(TEXT("Operator Maze seam mismatch at probe %d: %.9g -> %.9g"),
StackFirst, StackBefore, StackAfter));
}
AddInfo(FString::Printf(
TEXT("Maze seam freedom: %d boundary probes, legacy mismatches=%d, operator mismatches=%d; "
"local window=2x2x2 child nodes ({-1,0}^3), no wide collect."),
LegacyProbes.Num(), LegacyMismatches, StackMismatches));
return true;
}
#endif // WITH_DEV_AUTOMATION_TESTS
@@ -4,7 +4,7 @@
//
// CE QUE LA PHASE 1 DEVAIT PROUVER / WHAT PHASE 1 HAD TO PROVE
// Le déclencheur d'arrêt de `OPSTACK-PLAN §4` : **« est-ce que la séparation source / modifier tombe
// naturellement du code existant ? »** Réponse mesurée : oui. Maze se décompose en sept opérateurs
// naturellement du code existant ? »** Réponse mesurée : oui. Maze se décompose en huit opérateurs
// sans contorsion, le SDF est reproduit BIT POUR BIT, et aucun échantillon ne change de côté de
// l'isosurface.
//
@@ -29,7 +29,8 @@
// ─────────────────────────────────────────────────────────────────────────────────────────
// ⚠️ LE PLANCHER ULP (HISTORIQUE) — lire ceci avant de « corriger » un écart résiduel
// ─────────────────────────────────────────────────────────────────────────────────────────
// La pile reproduit `GetMazeDensity` à ~1-2 ULP près sur ~2 % des échantillons (ceux qui tombent
// (HISTORIQUE, avant `FPSemantics = Precise`) la pile reproduisait `GetMazeDensity` à ~1-2 ULP près
// sur ~2 % des échantillons (ceux qui tombent
// dans la coquille de blend du SDF, où `Blend - Sdf` annule catastrophiquement et amplifie le
// dernier arrondi). **Zéro échantillon ne traverse l'isosurface**, donc pas un triangle ne bouge.
//
@@ -73,9 +74,11 @@
#include "Misc/AutomationTest.h"
#include "Async/ParallelFor.h"
#include "HAL/PlatformMisc.h"
#include "HAL/PlatformTime.h"
#include "VoxelForgeTestFixture.h"
#include "VoxelDensityOpStack.h"
#include "VoxelCaveMorphology.h"
#include <atomic>
@@ -89,6 +92,236 @@ namespace
constexpr int32 NumMazeSamples = 20000;
constexpr int32 NumMazeBoxTests = 256;
struct FMazeGraphAudit
{
int32 NumNodes = 0;
int32 NumEdges = 0;
int32 NumDisconnected = 0;
TArray<uint64> EdgeKeys;
TArray<int32> RunLengths;
int64 DegreeCounts[8] = {};
};
int32 MazeGraphIndex(int32 X, int32 Y, int32 Z, int32 Radius)
{
const int32 Dim = Radius * 2 + 1;
return (X + Radius) + Dim * ((Y + Radius) + Dim * (Z + Radius));
}
uint64 MazeGraphEdgeKey(int32 A, int32 B)
{
const uint32 Lo = static_cast<uint32>(FMath::Min(A, B));
const uint32 Hi = static_cast<uint32>(FMath::Max(A, B));
return (static_cast<uint64>(Lo) << 32) | static_cast<uint64>(Hi);
}
void MazeGraphAddEdge(TArray<TArray<int32>>& Adjacency, TSet<uint64>& Seen,
int32 A, int32 B)
{
if (A == B) { return; }
const uint64 Key = MazeGraphEdgeKey(A, B);
if (!Seen.Contains(Key))
{
Seen.Add(Key);
Adjacency[A].Add(B);
Adjacency[B].Add(A);
}
}
FMazeGraphAudit AuditMazeGraph(const FMazeGenerationParams& Params, int32 Seed,
int32 Radius, int32 MetricRadius)
{
FMazeGraphAudit Out;
const int32 Dim = Radius * 2 + 1;
Out.NumNodes = Dim * Dim * Dim;
TArray<TArray<int32>> Adjacency;
Adjacency.SetNum(Out.NumNodes);
TSet<uint64> Seen;
const uint32 Salt = static_cast<uint32>(Seed) ^ 0x4D617A65u;
// One parent per node. In this centered finite audit the parent of every sampled node is
// also sampled because it lowers Manhattan distance to the origin.
for (int32 Z = -Radius; Z <= Radius; ++Z)
for (int32 Y = -Radius; Y <= Radius; ++Y)
for (int32 X = -Radius; X <= Radius; ++X)
{
FIntVector Parent;
if (VoxelMazeTopology::TryGetParent(X, Y, Z, Salt, Parent))
{
MazeGraphAddEdge(Adjacency, Seen,
MazeGraphIndex(X, Y, Z, Radius),
MazeGraphIndex(Parent.X, Parent.Y, Parent.Z, Radius));
}
}
// Add only the optional non-tree edges. The tree count is therefore observable directly,
// and the same routine can audit the authored loop knobs without treating them as a
// connectivity requirement.
for (int32 Z = -Radius; Z <= Radius; ++Z)
for (int32 Y = -Radius; Y <= Radius; ++Y)
for (int32 X = -Radius; X <= Radius; ++X)
{
const int32 A = MazeGraphIndex(X, Y, Z, Radius);
if (X < Radius && VoxelMazeTopology::IsLoopEdgeOpen(
X, Y, Z, VoxelMazeTopology::EAxis::X, Salt,
Params.BranchProbability, Params.Verticality))
{
MazeGraphAddEdge(Adjacency, Seen, A,
MazeGraphIndex(X + 1, Y, Z, Radius));
}
if (Y < Radius && VoxelMazeTopology::IsLoopEdgeOpen(
X, Y, Z, VoxelMazeTopology::EAxis::Y, Salt,
Params.BranchProbability, Params.Verticality))
{
MazeGraphAddEdge(Adjacency, Seen, A,
MazeGraphIndex(X, Y + 1, Z, Radius));
}
if (Z < Radius && VoxelMazeTopology::IsLoopEdgeOpen(
X, Y, Z, VoxelMazeTopology::EAxis::Z, Salt,
Params.BranchProbability, Params.Verticality))
{
MazeGraphAddEdge(Adjacency, Seen, A,
MazeGraphIndex(X, Y, Z + 1, Radius));
}
}
Out.NumEdges = Seen.Num();
Out.EdgeKeys.Reserve(Seen.Num());
for (const uint64 Key : Seen) { Out.EdgeKeys.Add(Key); }
Out.EdgeKeys.Sort();
TArray<uint8> Visited;
Visited.Init(0, Out.NumNodes);
TArray<int32> Pending;
Pending.Add(MazeGraphIndex(0, 0, 0, Radius));
Visited[Pending[0]] = 1;
while (Pending.Num() > 0)
{
const int32 Current = Pending.Pop(EAllowShrinking::No);
for (const int32 Next : Adjacency[Current])
{
if (!Visited[Next])
{
Visited[Next] = 1;
Pending.Add(Next);
}
}
}
for (const uint8 WasVisited : Visited)
{
if (!WasVisited) { ++Out.NumDisconnected; }
}
// Report degree only for an interior window so the finite audit boundary cannot invent
// dead ends. Degree 1 is the dead-end count; degree 6 would be the regular cubic grid.
for (int32 Z = -MetricRadius; Z <= MetricRadius; ++Z)
for (int32 Y = -MetricRadius; Y <= MetricRadius; ++Y)
for (int32 X = -MetricRadius; X <= MetricRadius; ++X)
{
const int32 Degree = Adjacency[ MazeGraphIndex(X, Y, Z, Radius) ].Num();
++Out.DegreeCounts[FMath::Min(Degree, 7)];
}
// A run is a maximal sequence of degree-2 edges between junctions/dead ends. The graph
// is sampled with a larger halo than the reported degree window; truncation only affects
// the outermost runs, never the connectivity assertion.
TSet<uint64> UsedRunEdges;
for (int32 Start = 0; Start < Adjacency.Num(); ++Start)
{
if (Adjacency[Start].Num() == 2) { continue; }
for (const int32 First : Adjacency[Start])
{
const uint64 FirstKey = MazeGraphEdgeKey(Start, First);
if (UsedRunEdges.Contains(FirstKey)) { continue; }
int32 Length = 1;
UsedRunEdges.Add(FirstKey);
int32 Previous = Start;
int32 Current = First;
while (Adjacency[Current].Num() == 2)
{
const int32 Next = Adjacency[Current][0] == Previous
? Adjacency[Current][1] : Adjacency[Current][0];
const uint64 EdgeKey = MazeGraphEdgeKey(Current, Next);
if (UsedRunEdges.Contains(EdgeKey)) { break; }
UsedRunEdges.Add(EdgeKey);
++Length;
Previous = Current;
Current = Next;
}
Out.RunLengths.Add(Length);
}
}
// A completely degree-2 cycle is not expected for this tree-plus-loops graph, but keep
// the distribution total honest if a future loop policy creates one.
for (int32 Start = 0; Start < Adjacency.Num(); ++Start)
for (const int32 First : Adjacency[Start])
{
const uint64 FirstKey = MazeGraphEdgeKey(Start, First);
if (UsedRunEdges.Contains(FirstKey)) { continue; }
int32 Length = 1;
UsedRunEdges.Add(FirstKey);
int32 Previous = Start;
int32 Current = First;
while (true)
{
int32 Next = INDEX_NONE;
for (const int32 Candidate : Adjacency[Current])
{
if (Candidate != Previous) { Next = Candidate; break; }
}
if (Next == INDEX_NONE) { break; }
const uint64 EdgeKey = MazeGraphEdgeKey(Current, Next);
if (UsedRunEdges.Contains(EdgeKey)) { break; }
UsedRunEdges.Add(EdgeKey);
++Length;
Previous = Current;
Current = Next;
}
Out.RunLengths.Add(Length);
}
return Out;
}
FString DescribeMazeDistribution(const TArray<int64>& DegreeCounts,
const TArray<int32>& RunLengths)
{
int64 TotalDegree = 0;
int64 DegreeSum = 0;
for (int32 Degree = 0; Degree < DegreeCounts.Num(); ++Degree)
{
TotalDegree += DegreeCounts[Degree];
DegreeSum += Degree * DegreeCounts[Degree];
}
TArray<int32> SortedRuns = RunLengths;
SortedRuns.Sort();
const int32 Median = SortedRuns.Num() > 0 ? SortedRuns[SortedRuns.Num() / 2] : 0;
const int32 P90 = SortedRuns.Num() > 0
? SortedRuns[FMath::Min(SortedRuns.Num() - 1,
FMath::FloorToInt(SortedRuns.Num() * 0.90f))]
: 0;
int32 MaxRun = 0;
for (const int32 Run : SortedRuns) { MaxRun = FMath::Max(MaxRun, Run); }
FString DegreeText;
for (int32 Degree = 0; Degree < DegreeCounts.Num(); ++Degree)
{
if (Degree > 0) { DegreeText += TEXT(", "); }
DegreeText += FString::Printf(TEXT("d%d=%lld"), Degree,
static_cast<long long>(DegreeCounts[Degree]));
}
return FString::Printf(
TEXT("degrees{%s}, mean=%.3f, dead_end_fraction=%.3f; runs n=%d min=%d "
"median=%d p90=%d max=%d"),
*DegreeText,
TotalDegree > 0 ? static_cast<double>(DegreeSum) / static_cast<double>(TotalDegree) : 0.0,
TotalDegree > 0 ? static_cast<double>(DegreeCounts.IsValidIndex(1) ? DegreeCounts[1] : 0)
/ static_cast<double>(TotalDegree) : 0.0,
SortedRuns.Num(), SortedRuns.Num() > 0 ? SortedRuns[0] : 0,
Median, P90, MaxRun);
}
/** Les params Maze de la strate Maze de la fixture, bornes Z de runtime comprises. */
bool ResolveMazeParams(const VoxelForgeTest::FTestWorld& World, FMazeGenerationParams& Out,
int32& OutTopVoxelZ, int32& OutBottomVoxelZ)
@@ -133,6 +366,94 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
return false;
}
//==========================================================================
// TOPOLOGY — construction proof, determinism, and maze morphology
//==========================================================================
{
constexpr int32 NumTopologySeeds = 64;
constexpr int32 GraphRadius = 10;
constexpr int32 MetricRadius = 7;
FMazeGenerationParams TreeParams = MazeParams;
TreeParams.BranchProbability = 0.0f;
TreeParams.Verticality = 0.0f;
int32 TreeEdgeCountViolations = 0;
int32 TreeDisconnected = 0;
int32 MazeDisconnected = 0;
int32 DeterminismFailures = 0;
int64 OptionalEdges = 0;
TArray<int64> DegreeCounts;
DegreeCounts.Init(0, 8);
TArray<int32> RunLengths;
for (int32 Seed = 0; Seed < NumTopologySeeds; ++Seed)
{
const FMazeGraphAudit Tree = AuditMazeGraph(
TreeParams, Seed, GraphRadius, MetricRadius);
const FMazeGraphAudit Maze = AuditMazeGraph(
MazeParams, Seed, GraphRadius, MetricRadius);
const FMazeGraphAudit Repeat = AuditMazeGraph(
MazeParams, Seed, GraphRadius, MetricRadius);
if (Tree.NumEdges != Tree.NumNodes - 1) { ++TreeEdgeCountViolations; }
TreeDisconnected += Tree.NumDisconnected;
MazeDisconnected += Maze.NumDisconnected;
OptionalEdges += static_cast<int64>(Maze.NumEdges - (Maze.NumNodes - 1));
if (Maze.EdgeKeys != Repeat.EdgeKeys) { ++DeterminismFailures; }
for (int32 Degree = 0; Degree < DegreeCounts.Num(); ++Degree)
{
DegreeCounts[Degree] += Maze.DegreeCounts[Degree];
}
RunLengths.Append(Maze.RunLengths);
}
TestEqual(TEXT("origin-directed parent graph has exactly N-1 edges with loops disabled"),
TreeEdgeCountViolations, 0);
TestEqual(TEXT("origin-directed parent graph has zero disconnected cells"),
TreeDisconnected, 0);
TestEqual(TEXT("Maze corridor graph has zero disconnected cells across 64 seeds"),
MazeDisconnected, 0);
TestEqual(TEXT("Maze topology is deterministic for repeated same-seed builds"),
DeterminismFailures, 0);
int64 TotalDegree = 0;
int64 DegreeSum = 0;
for (int32 Degree = 0; Degree < DegreeCounts.Num(); ++Degree)
{
TotalDegree += DegreeCounts[Degree];
DegreeSum += static_cast<int64>(Degree) * DegreeCounts[Degree];
}
TestTrue(TEXT("Maze topology has actual degree-1 dead ends"),
DegreeCounts.IsValidIndex(1) && DegreeCounts[1] > 0);
TestTrue(TEXT("Maze topology has varied corridor run lengths"),
RunLengths.Num() > 0 && RunLengths[0] >= 1
&& [&RunLengths]()
{
int32 MinRun = MAX_int32, MaxRun = 0;
for (const int32 Run : RunLengths)
{
MinRun = FMath::Min(MinRun, Run);
MaxRun = FMath::Max(MaxRun, Run);
}
return MinRun < MaxRun;
}());
TestTrue(TEXT("Maze mean junction degree stays below a cubic grid's degree 6"),
TotalDegree > 0
&& static_cast<double>(DegreeSum) / static_cast<double>(TotalDegree) < 6.0);
AddInfo(FString::Printf(
TEXT("Maze topology: %d seeds, centered graph radius %d (%d nodes); tree edges "
"N-1 violations=%d, tree disconnected=%d, Maze disconnected=%d, optional "
"loop edges=%lld; %s; cubic-grid control: degree6=100.000%%, degree1=0.000%%, "
"mean=6.000 (no dead ends)."),
NumTopologySeeds, GraphRadius,
(GraphRadius * 2 + 1) * (GraphRadius * 2 + 1) * (GraphRadius * 2 + 1),
TreeEdgeCountViolations, TreeDisconnected, MazeDisconnected,
static_cast<long long>(OptionalEdges),
*DescribeMazeDistribution(DegreeCounts, RunLengths)));
}
const UVoxelGenerator* Gen = World.Generator.Get();
FVoxelOpStack Stack;
@@ -259,12 +580,54 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
Impure.load(), 0);
}
//==========================================================================
// CACHE COST — topology decisions belong to the rebuild, not the voxel loop
//==========================================================================
{
constexpr int32 NumRebuildSamples = 256;
constexpr int32 NumHotSamples = 20000;
const float PerfCellSize = FMath::Max(MazeParams.CellSize, 1.0f);
const float PerfZ = 0.5f * (MazeParams.StrateBottomWorldZ + MazeParams.StrateTopWorldZ);
volatile float Sink = 0.0f;
float LastX = 0.0f;
float LastY = 0.0f;
const double RebuildStart = FPlatformTime::Seconds();
for (int32 i = 0; i < NumRebuildSamples; ++i)
{
const int32 CellX = (i % 16) - 8;
const int32 CellY = (i / 16) - 8;
LastX = (static_cast<float>(CellX) + 0.37f) * PerfCellSize;
LastY = (static_cast<float>(CellY) + 0.61f) * PerfCellSize;
Sink += Stack.EvalMC(LastX, LastY, PerfZ);
}
const double RebuildSeconds = FPlatformTime::Seconds() - RebuildStart;
const double HotStart = FPlatformTime::Seconds();
for (int32 i = 0; i < NumHotSamples; ++i)
{
Sink += Stack.EvalMC(LastX, LastY, PerfZ);
}
const double HotSeconds = FPlatformTime::Seconds() - HotStart;
AddInfo(FString::Printf(
TEXT("Maze cache perf: %d forced cell rebuilds at %.3f us/call and %d hot calls "
"at %.3f us/call (8 child nodes + capped loop rolls only on rebuild; capsule "
"SDFs only on hot calls; sink=%.9g)."),
NumRebuildSamples,
RebuildSeconds * 1.0e6 / static_cast<double>(NumRebuildSamples),
NumHotSamples,
HotSeconds * 1.0e6 / static_cast<double>(NumHotSamples),
static_cast<float>(Sink)));
}
//=========================================================================
// LE VERDICT DE BOÎTE — le vrai prix perf : Maze n'a JAMAIS su sauter une tuile.
// LE VERDICT DE BOÎTE — le prix perf de l'intervalle SDF conservatif.
//=========================================================================
// ClassifyTile renvoie Mixed pour tout archétype de grotte, donc TunnelNetwork, Maze,
// VerticalShafts, FloatingIslands, FlatPlain, CrystalChamber et Underwater ne captent RIEN du
// gain T1.d. Tout nombre > 0 ici est du saut de tuile que Maze n'a jamais eu.
// gain T1.d. Tout nombre > 0 ici est le saut de tuile propre à la pile, pas une décision du
// classifieur historique.
{
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0, NumBruteSamples = 0;
FRandomStream Rng(24680);
@@ -72,10 +72,12 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters)
using namespace VoxelForgeTest;
FTestWorld World;
// Seed 11791 deliberately puts the passage mouths over a Maze corridor, a VerticalShafts
// Seed 12345 deliberately puts the passage mouths over a Maze corridor, a VerticalShafts
// shaft, and a FloatingIslands blob. SurfaceWorld remains a deliberate query refusal because
// its production height can be biome/context-selected by the manager.
World.Build(/*InSeed=*/11791);
// its production height can be biome/context-selected by the manager. This fixture seed was
// repinned after the Maze topology/radius change; it is coverage selection, not generation
// tuning.
World.Build(/*InSeed=*/12345);
if (!World.IsValid())
{
AddError(World.WhyInvalid());
@@ -344,7 +346,7 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters)
SuggestedPoint.X, SuggestedPoint.Y, SuggestedPoint.Z, P);
const float FloorProbeZ = SuggestedPoint.Z
- FMath::Max(P.CorridorRadius, 0.5f)
- P.SurfaceRoughness * VOXEL_NOISE_SCALE - 3.0f;
- P.SurfaceRoughness * VOXEL_NOISE_SCALE * 1.5f - 3.0f;
FootingDensity = SourceOnlyGenerator->GetMazeDensity(
SuggestedPoint.X, SuggestedPoint.Y, FloorProbeZ, P);
break;
@@ -19,11 +19,11 @@ namespace
{
constexpr int32 DiagnosisSeed = 0;
constexpr int32 MazeCandidateIndex = 50;
constexpr int32 TunnelCandidateIndex = 61;
// These are the current seed-0 showcase identities from the bounded roll set. The previous
// report's VerticalShafts identity was seed 0 / index 34; index 15 belongs to another family
// in the current corpus and would silently leave the shaft probe on fixture defaults.
constexpr int32 ShaftCandidateIndex = 34;
// These are single-region seed-0 identities after the Maze default/range change updates the
// corpus content hash. Keep the probes on real candidates rather than silently testing fixture
// defaults when a historical index moves to another family.
constexpr int32 TunnelCandidateIndex = 4;
constexpr int32 ShaftCandidateIndex = 6;
constexpr int32 RadialSampleMaxVoxels = 48;
constexpr int32 FineDiagnosisMaxCells = 4000000;
@@ -207,14 +207,6 @@ namespace
World.Generator->WorldRadiusVoxels, World.Generator->EdgeSealThickness,
World.StrateManager->GetLayoutVersion());
auto EdgeOpen = [Salt](int32 X, int32 Y, int32 Z,
uint32 AxisSalt, float Threshold)
{
uint32 H = VoxelHash::Cell(X, Y, Salt ^ AxisSalt);
H ^= VoxelHash::Mix(static_cast<uint32>(Z * 73856093) ^ AxisSalt);
return VoxelHash::ToFloat01(VoxelHash::Mix(H)) < Threshold;
};
auto FinalDensity = [&World](const FVector& Point)
{
return World.Generator->GetDensityAt(Point.X, Point.Y, Point.Z);
@@ -243,16 +235,23 @@ namespace
(NodeZ + 0.5f) * CellSize);
const float DistanceSq = A.X * A.X + A.Y * A.Y;
struct FAxisSpec { uint32 Salt; float Threshold; FVector Delta; const TCHAR* Name; };
struct FAxisSpec
{
VoxelMazeTopology::EAxis Axis;
FVector Delta;
const TCHAR* Name;
};
const FAxisSpec Axes[] = {
{ 0xA1u, Params.BranchProbability,
{ VoxelMazeTopology::EAxis::X,
FVector(CellSize, 0.0f, 0.0f), TEXT("X") },
{ 0xB2u, Params.BranchProbability,
{ VoxelMazeTopology::EAxis::Y,
FVector(0.0f, CellSize, 0.0f), TEXT("Y") },
};
for (const FAxisSpec& Axis : Axes)
{
if (!EdgeOpen(NodeX, NodeY, NodeZ, Axis.Salt, Axis.Threshold))
if (!VoxelMazeTopology::IsOpenEdge(
NodeX, NodeY, NodeZ, Axis.Axis, Salt,
Params.BranchProbability, Params.Verticality))
{
continue;
}
@@ -1028,14 +1027,14 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters)
const TCHAR* Label;
};
const FShowcaseSelection Selections[] = {
{ ECaveGeneratorType::CrystalChamber, 0, 36, FTestWorld::SlotFlatPlain, TEXT("CrystalChamber") },
{ ECaveGeneratorType::FlatPlain, 0, 11, FTestWorld::SlotFlatPlain, TEXT("FlatPlain") },
{ ECaveGeneratorType::FloatingIslands, 0, 15, FTestWorld::SlotFlatPlain, TEXT("FloatingIslands") },
{ ECaveGeneratorType::CrystalChamber, 0, 28, FTestWorld::SlotFlatPlain, TEXT("CrystalChamber") },
{ ECaveGeneratorType::FlatPlain, 0, 37, FTestWorld::SlotFlatPlain, TEXT("FlatPlain") },
{ ECaveGeneratorType::FloatingIslands, 0, 9, FTestWorld::SlotFlatPlain, TEXT("FloatingIslands") },
{ ECaveGeneratorType::Maze, 0, 50, FTestWorld::SlotMaze, TEXT("Maze") },
{ ECaveGeneratorType::SurfaceWorld, 0, 10, FTestWorld::SlotSurfaceWorld, TEXT("SurfaceWorld") },
{ ECaveGeneratorType::TunnelNetwork, 0, 61, FTestWorld::SlotFlatPlain, TEXT("TunnelNetwork") },
{ ECaveGeneratorType::Underwater, 0, 63, FTestWorld::SlotFlatPlain, TEXT("Underwater") },
{ ECaveGeneratorType::VerticalShafts, 0, 34, FTestWorld::SlotFlatPlain, TEXT("VerticalShafts") },
{ ECaveGeneratorType::SurfaceWorld, 0, 27, FTestWorld::SlotSurfaceWorld, TEXT("SurfaceWorld") },
{ ECaveGeneratorType::TunnelNetwork, 0, 4, FTestWorld::SlotFlatPlain, TEXT("TunnelNetwork") },
{ ECaveGeneratorType::Underwater, 0, 16, FTestWorld::SlotFlatPlain, TEXT("Underwater") },
{ ECaveGeneratorType::VerticalShafts, 0, 6, FTestWorld::SlotFlatPlain, TEXT("VerticalShafts") },
};
// Baseline rows from the pre-fix fitted-window report. The supplied VerticalShafts row is
@@ -706,14 +706,6 @@ namespace
(Y + 0.5f) * CellSize,
(Z + 0.5f) * CellSize);
};
auto EdgeOpen = [SeedU](int32 X, int32 Y, int32 Z,
uint32 AxisSalt, float Threshold)
{
uint32 H = VoxelHash::Cell(X, Y, SeedU ^ AxisSalt);
H ^= VoxelHash::Mix((uint32)(Z * 73856093) ^ AxisSalt);
return VoxelHash::ToFloat01(VoxelHash::Mix(H)) < Threshold;
};
const float Radius = FMath::Max(Params.CorridorRadius, 0.5f);
float MazeSDF = FLT_MAX;
for (int32 DZ = -1; DZ <= 0; ++DZ)
@@ -726,21 +718,27 @@ namespace
const int32 Y = CellY + DY;
const int32 Z = CellZ + DZ;
const FVector A = NodeCenter(X, Y, Z);
if (EdgeOpen(X, Y, Z, 0xA1u, Params.BranchProbability))
if (VoxelMazeTopology::IsOpenEdge(
X, Y, Z, VoxelMazeTopology::EAxis::X,
SeedU, Params.BranchProbability, Params.Verticality))
{
MazeSDF = FMath::Min(
MazeSDF,
VoxelSDF::Capsule(
Position, A, NodeCenter(X + 1, Y, Z), Radius));
}
if (EdgeOpen(X, Y, Z, 0xB2u, Params.BranchProbability))
if (VoxelMazeTopology::IsOpenEdge(
X, Y, Z, VoxelMazeTopology::EAxis::Y,
SeedU, Params.BranchProbability, Params.Verticality))
{
MazeSDF = FMath::Min(
MazeSDF,
VoxelSDF::Capsule(
Position, A, NodeCenter(X, Y + 1, Z), Radius));
}
if (EdgeOpen(X, Y, Z, 0xC3u, Params.Verticality))
if (VoxelMazeTopology::IsOpenEdge(
X, Y, Z, VoxelMazeTopology::EAxis::Z,
SeedU, Params.BranchProbability, Params.Verticality))
{
MazeSDF = FMath::Min(
MazeSDF,
@@ -1229,9 +1227,9 @@ namespace
OutPoint);
}
// Maze corridors are thin 3D lattice edges. Only a horizontal edge is a useful landing
// source: a vertical edge can prove air, but cannot prove a place to stand. The edge set and
// the Z levels below are the same hash contract as GetMazeDensity, with no cache involved.
// Maze corridors are lattice edges. Only a horizontal edge is a useful landing source: a
// vertical edge can prove air, but cannot prove a place to stand. The edge set and Z levels
// below use the same origin-directed tree + loop contract as GetMazeDensity, with no cache.
bool VF_SuggestMazeLandingPoint(
const FMazeGenerationParams& Params,
int32 Seed,
@@ -1259,7 +1257,7 @@ namespace
|| !FMath::IsFinite(Params.BaseDensity)
|| Params.CellSize <= 0.0f
|| Params.CorridorRadius <= 0.0f
|| Params.BranchProbability <= 0.0f || Params.BranchProbability > 1.0f
|| Params.BranchProbability < 0.0f || Params.BranchProbability > 1.0f
|| Params.Verticality < 0.0f || Params.Verticality > 1.0f
|| Params.SurfaceRoughness < 0.0f
|| Params.BoundarySealThickness < 0.0f
@@ -1270,7 +1268,10 @@ namespace
const float CellSize = FMath::Max(Params.CellSize, 1.0f);
const float Radius = FMath::Max(Params.CorridorRadius, 0.5f);
const float RoughnessBound = Params.SurfaceRoughness * VOXEL_NOISE_SCALE;
// VoxelNoise::FBM has the proven absolute bound 1.5. This is the displacement that a
// corridor radius must survive; using the nominal [-1,1] label here would certify a
// landing inside a wall at the extremum.
const float RoughnessBound = Params.SurfaceRoughness * VOXEL_NOISE_SCALE * 1.5f;
// A strict interior of the tube is needed. At the MC zero surface the source's smooth
// carve is only half applied, so stopping at the nominal radius would be an air guess.
@@ -1315,13 +1316,16 @@ namespace
int32 BestAxis = INT32_MAX;
bool bFound = false;
auto ConsiderEdge = [&](int32 CellX, int32 CellY, int32 CellZ,
uint32 AxisSalt, float Threshold, int32 Axis)
auto ConsiderEdge = [&](int32 CellX, int32 CellY, int32 CellZ, int32 Axis)
{
// This is the exact lower-node + axis hash used by GetMazeDensity::EdgeOpen.
uint32 H = VoxelHash::Cell(CellX, CellY, SeedU ^ AxisSalt);
H ^= VoxelHash::Mix((uint32)(CellZ * 73856093) ^ AxisSalt);
if (VoxelHash::ToFloat01(VoxelHash::Mix(H)) >= Threshold) return;
const VoxelMazeTopology::EAxis EdgeAxis =
static_cast<VoxelMazeTopology::EAxis>(Axis);
if (!VoxelMazeTopology::IsOpenEdge(
CellX, CellY, CellZ, EdgeAxis, SeedU,
Params.BranchProbability, Params.Verticality))
{
return;
}
const float NodeX = (CellX + 0.5f) * CellSize;
const float NodeY = (CellY + 0.5f) * CellSize;
@@ -1376,10 +1380,8 @@ namespace
{
for (int32 CellX = BaseCellX - SearchRadius; CellX <= BaseCellX + SearchRadius; ++CellX)
{
ConsiderEdge(CellX, CellY, CellZ, 0xA1u,
Params.BranchProbability, 0);
ConsiderEdge(CellX, CellY, CellZ, 0xB2u,
Params.BranchProbability, 1);
ConsiderEdge(CellX, CellY, CellZ, 0);
ConsiderEdge(CellX, CellY, CellZ, 1);
}
}
}
@@ -424,14 +424,16 @@ namespace
//=========================================================================
// RÔLE 1 — SOURCE : COULOIRS SUR TREILLIS 3D / 3D LATTICE CORRIDORS
//=========================================================================
// Chaque nœud du treillis est au centre d'une cellule ; l'arête vers son voisin +X/+Y/+Z est
// « ouverte » quand un hash de (nœud inférieur, axe) passe BranchProbability (Verticality pour
// Z). Le couloir est une capsule fine.
// Chaque nœud du treillis est au centre d'une cellule. Il choisit exactement un parent parmi
// les axes qui le rapprochent de l'origine ; l'arête parent abaisse donc |X|+|Y|+|Z| de un.
// Le résultat est un arbre couvrant de tout le treillis infini. Quelques arêtes supplémentaires
// sont hashées comme des boucles, avec des probabilités plafonnées qui ne participent jamais à
// la connectivité. Le couloir est une capsule.
//
// ⚠️ LA propriété qui fait de Maze le bon premier portage : l'identité d'une arête est
// (nœud INFÉRIEUR, axe). Deux chunks adjacents calculent donc littéralement le même hash pour
// l'arête qu'ils partagent — ils NE PEUVENT PAS être en désaccord. Pas de cache de chunk, pas
// de région COLLECT, pas de discipline d'invariance de fenêtre à maintenir (AUDIT §6.4).
// La règle est locale et seam-safe : les huit nœuds-enfants du halo {-1,0}³ suffisent à émettre
// toutes les arêtes qui peuvent toucher la cellule évaluée. Chaque décision est une fonction
// pure du nœud, de l'axe et de la seed ; il n'y a ni collect global ni dépendance à la fenêtre
// de chunk.
class FLatticeCorridorSource final : public IVoxelDensityOp
{
public:
@@ -530,13 +532,6 @@ namespace
return FVector((X + 0.5f) * CellSize, (Y + 0.5f) * CellSize, (Z + 0.5f) * CellSize);
}
bool EdgeOpen(int32 X, int32 Y, int32 Z, uint32 AxisSalt, float Threshold) const
{
uint32 H = VoxelHash::Cell(X, Y, Salt ^ AxisSalt);
H ^= VoxelHash::Mix((uint32)(Z * 73856093) ^ AxisSalt);
return VoxelHash::ToFloat01(VoxelHash::Mix(H)) < Threshold;
}
// Sur-approximation volontaire : boîte englobante du segment contre la boîte élargie.
// Un test capsule/AABB exact serait plus serré ; il coûterait plus cher pour un gain nul
// ici, car la réponse ne sert qu'à un rejet grossier par tuile.
@@ -548,9 +543,10 @@ namespace
}
/**
* Le cache par CELLULE, repris tel quel de GetMazeDensity. Il est `thread_local` et non
* Le cache par CELLULE, partagé avec le même contrat que GetMazeDensity. Il est `thread_local` et non
* membre parce que la pile est PARTAGÉE entre workers en lecture — un membre mutable serait
* une course. C'est aussi exactement ce que fait le code d'aujourd'hui.
* une course. La reconstruction seule inspecte le petit voisinage ; Eval ne refait aucune
* décision de parent ou de boucle.
*
* ⚠️ PHASE 3 : quand les opérateurs deviendront des assets partagés, il faudra un objet
* d'état PAR WORKER plutôt que ce `thread_local` (qui est global à la fonction, donc partagé
@@ -571,7 +567,9 @@ namespace
MZ_Branch = BranchProbability; MZ_Vert = Verticality;
MZ_Edges.Reset();
// Nodes in {-1,0} per axis cover every edge that can reach this voxel's cell.
// Canonical lower-node edges in {-1,0} per axis cover every corridor that can
// reach this voxel's cell. IsOpenEdge checks both endpoints, including a +1
// node's parent choice, without needing a wider cache window.
for (int32 dz = -1; dz <= 0; dz++)
for (int32 dy = -1; dy <= 0; dy++)
for (int32 dx = -1; dx <= 0; dx++)
@@ -579,12 +577,24 @@ namespace
const int32 nx = Cell.X + dx, ny = Cell.Y + dy, nz = Cell.Z + dz;
const FVector A = NodeCenter(nx, ny, nz);
if (EdgeOpen(nx, ny, nz, 0xA1u, BranchProbability))
if (VoxelMazeTopology::IsOpenEdge(
nx, ny, nz, VoxelMazeTopology::EAxis::X,
Salt, BranchProbability, Verticality))
{
MZ_Edges.Add({ A, NodeCenter(nx + 1, ny, nz) });
if (EdgeOpen(nx, ny, nz, 0xB2u, BranchProbability))
}
if (VoxelMazeTopology::IsOpenEdge(
nx, ny, nz, VoxelMazeTopology::EAxis::Y,
Salt, BranchProbability, Verticality))
{
MZ_Edges.Add({ A, NodeCenter(nx, ny + 1, nz) });
if (EdgeOpen(nx, ny, nz, 0xC3u, Verticality))
}
if (VoxelMazeTopology::IsOpenEdge(
nx, ny, nz, VoxelMazeTopology::EAxis::Z,
Salt, BranchProbability, Verticality))
{
MZ_Edges.Add({ A, NodeCenter(nx, ny, nz + 1) });
}
}
}
return MZ_Edges;
@@ -1293,14 +1303,15 @@ namespace
// en float. Passer directement des floats saute cet aller-retour, et sous /fp:fast
// les deux chemins ne s'arrondissent pas au même endroit : ~1 ULP d'écart sur le SDF,
// qui ressort en 1 ULP sur la densité finale. Reproduire le détour, c'est reproduire
// l'arrondi. HYPOTHÈSE NON ENCORE VÉRIFIÉE : elle prédit que MazeEquivalence passe de
// 454 écarts à 0. Si le prochain run montre encore des écarts, c'est que la divergence
// vient d'ailleurs (candidat suivant : contraction FMA entre unités de compilation).
// l'arrondi. VERIFIE par MazeEquivalence : le détour conserve l'identité binaire avec
// GetMazeDensity (0 écart sur 20,000 échantillons). Si cela change, le candidat suivant
// est une contraction FMA entre unités de compilation.
//
// THE FVector ROUND-TRIP IS DELIBERATE — do not "simplify" it. The original goes
// float -> double (FVector is double in UE5) -> float; going straight through floats
// skips a rounding step, and under /fp:fast the two paths round in different places.
// Reproducing the detour reproduces the rounding.
// THE FVector ROUND-TRIP IS DELIBERATE — do not "simplify" it. MazeEquivalence
// verifies the resulting path against GetMazeDensity (0 differences / 20,000 samples).
// The original goes float -> double (FVector is double in UE5) -> float; going straight
// through floats skips a rounding step, and under /fp:fast the two paths round in
// different places. Reproducing the detour reproduces the rounding.
const FVector NoisePos(WorldX * Frequency, WorldY * Frequency, WorldZ * Frequency);
InOut.Sdf += VoxelNoise::FBM((float)NoisePos.X, (float)NoisePos.Y, (float)NoisePos.Z,
VoxelGenLOD::Eff(BaseOctaves), 2.0f, 0.5f)
+25 -21
View File
@@ -2387,11 +2387,11 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ,
//=============================================================================
// MAZE GENERATOR (ECaveGeneratorType::Maze)
//=============================================================================
// Solid rock carved by a deterministic 3D lattice of corridors. Each lattice node
// sits at a cell center; an edge to its +X/+Y/+Z neighbour is "open" when a hash of
// (lower node, axis) passes BranchProbability (Verticality for Z edges). The corridor
// is a thin capsule. Edge identity is the lower node + axis, so two adjacent chunks
// always agree — no cache needed, evaluated over the few nearby nodes per voxel.
// Solid rock carved by a deterministic 3D lattice of corridors. Each non-origin lattice node
// chooses one parent toward (0,0,0), which is a spanning tree by construction. A small, capped
// hash-gated loop set adds alternate routes without being needed for connectivity. The local
// source evaluates the eight child nodes in the current cell's {-1,0} halo, so chunk seams cannot
// change an edge decision.
float UVoxelGenerator::GetMazeDensity(float WorldX, float WorldY, float WorldZ,
const FMazeGenerationParams& Params) const
@@ -2409,10 +2409,9 @@ float UVoxelGenerator::GetMazeDensity(float WorldX, float WorldY, float WorldZ,
const int32 CY = FMath::FloorToInt(WorldY / CS);
const int32 CZ = FMath::FloorToInt(WorldZ / CS);
// The open-edge set reachable from this voxel's cell is a pure function of (cell, seed,
// probabilities) yet was re-hashed PER VOXEL (8 nodes × 3 edges = 24 hash rolls). Bake the
// open edges' capsule endpoints once per cell (thread_local); the per-voxel work is just
// the capsule SDFs. Rebuilds only on a cell crossing / param change — bit-identical.
// The edge set reachable from this voxel's cell is a pure function of (cell, seed, params).
// Bake the parent and optional loop capsule endpoints once per cell (thread_local); the
// per-voxel work is just the capsule SDFs. Rebuilds only on a cell crossing / param change.
struct FMazeEdge { FVector A, B; };
thread_local TArray<FMazeEdge, TInlineAllocator<24>> MZ_Edges;
thread_local FIntVector MZ_Cell(INT32_MAX, INT32_MAX, INT32_MAX);
@@ -2431,15 +2430,8 @@ float UVoxelGenerator::GetMazeDensity(float WorldX, float WorldY, float WorldZ,
{
return FVector((X + 0.5f) * CS, (Y + 0.5f) * CS, (Z + 0.5f) * CS);
};
// Deterministic hash of a 3D lattice edge, keyed on its lower node + axis salt.
auto EdgeOpen = [S](int32 X, int32 Y, int32 Z, uint32 AxisSalt, float Threshold) -> bool
{
uint32 H = VoxelHash::Cell(X, Y, S ^ AxisSalt);
H ^= VoxelHash::Mix((uint32)(Z * 73856093) ^ AxisSalt);
return VoxelHash::ToFloat01(VoxelHash::Mix(H)) < Threshold;
};
// Nodes in {-1,0} per axis cover every edge that can reach this voxel's cell.
// Canonical lower-node edges in {-1,0} per axis cover every corridor that can reach this
// voxel's cell. IsOpenEdge checks both endpoints, including a +1 node's parent choice.
for (int32 dz = -1; dz <= 0; dz++)
for (int32 dy = -1; dy <= 0; dy++)
for (int32 dx = -1; dx <= 0; dx++)
@@ -2447,12 +2439,24 @@ float UVoxelGenerator::GetMazeDensity(float WorldX, float WorldY, float WorldZ,
const int32 nx = CX + dx, ny = CY + dy, nz = CZ + dz;
const FVector A = NodeCenter(nx, ny, nz);
if (EdgeOpen(nx, ny, nz, 0xA1u, Params.BranchProbability))
if (VoxelMazeTopology::IsOpenEdge(
nx, ny, nz, VoxelMazeTopology::EAxis::X,
S, Params.BranchProbability, Params.Verticality))
{
MZ_Edges.Add({ A, NodeCenter(nx + 1, ny, nz) });
if (EdgeOpen(nx, ny, nz, 0xB2u, Params.BranchProbability))
}
if (VoxelMazeTopology::IsOpenEdge(
nx, ny, nz, VoxelMazeTopology::EAxis::Y,
S, Params.BranchProbability, Params.Verticality))
{
MZ_Edges.Add({ A, NodeCenter(nx, ny + 1, nz) });
if (EdgeOpen(nx, ny, nz, 0xC3u, Params.Verticality))
}
if (VoxelMazeTopology::IsOpenEdge(
nx, ny, nz, VoxelMazeTopology::EAxis::Z,
S, Params.BranchProbability, Params.Verticality))
{
MZ_Edges.Add({ A, NodeCenter(nx, ny, nz + 1) });
}
}
}
@@ -1203,8 +1203,8 @@ namespace
return false;
case ECaveGeneratorType::Maze:
if (FieldName == TEXT("CellSize")) return Set(48.0f, FMath::Max(64.0f, FMath::Min(96.0f, H * 0.75f)));
if (FieldName == TEXT("CorridorRadius")) return Set(6.0f, 8.0f);
if (FieldName == TEXT("CellSize")) return Set(64.0f, FMath::Max(64.0f, FMath::Min(96.0f, H * 0.75f)));
if (FieldName == TEXT("CorridorRadius")) return Set(10.0f, 12.5f);
if (FieldName == TEXT("BranchProbability")) return Set(0.05f, 1.0f);
if (FieldName == TEXT("Verticality")) return Set(0.0f, 1.0f);
if (FieldName == TEXT("SurfaceRoughness")) return Set(0.5f, FMath::Max(3.0f, H * 0.02f));
@@ -1661,15 +1661,18 @@ namespace
float StrateHeightInVoxels)
{
const float H = FMath::Max(StrateHeightInVoxels, static_cast<float>(CHUNK_SIZE));
// Body-derived lattice: the fight corridor is 3-4 m wide (radius 6-8 voxels),
// while the lattice cell is 12-16 m (48-64 voxels). Thus a corridor radius is
// about one eighth of a cell, far below CellSize/2, leaving a real rock wall.
// Body-derived lattice: a 3 m usable floor needs a 4.286 m clear diameter. At 25 cm per
// voxel that is 17.143 voxels, radius 8.571. The proven 3.75-voxel roughness envelope is
// added to the authored radius, then rounded up to 12.5: the worst-case usable floor is
// still 0.7 * (2 * (12.5 - 3.75) * 0.25) = 3.0625 m. CellSize stays 64-96 voxels, so
// radius remains well below CellSize/2 and a real rock wall survives.
P.CellSize = VF_CorpusFreeU(
Rng, 48.0f, FMath::Max(64.0f, FMath::Min(96.0f, H * 0.75f)));
Rng, 64.0f, FMath::Max(64.0f, FMath::Min(96.0f, H * 0.75f)));
P.CorridorRadius = VF_CorpusFreeU(
Rng, 6.0f, FMath::Min(8.0f, P.CellSize * 0.20f));
Rng, 10.0f, FMath::Min(12.5f, P.CellSize * 0.20f));
const float RoughnessCap = FMath::Max(
0.0f, (P.CorridorRadius + 2.0f) / (VOXEL_NOISE_SCALE * 1.25f));
0.0f, (P.CorridorRadius - 2.0f)
/ (VOXEL_NOISE_SCALE * 1.5f));
P.SurfaceRoughness = VF_CorpusFreeU(Rng, 0.5f, RoughnessCap * 0.60f);
P.BoundarySealThickness = VF_CorpusFreeU(
Rng, FMath::Min(3.0f, H * 0.02f), FMath::Min(8.0f, H * 0.08f));
@@ -5070,11 +5073,11 @@ bool VF_ValidateStrateCorpusFreeConstraints(
return Fail(TEXT("CorridorRadius must leave lattice walls"),
P.CorridorRadius, P.CellSize * 0.25f);
}
if (P.SurfaceRoughness * VOXEL_NOISE_SCALE
if (P.SurfaceRoughness * VOXEL_NOISE_SCALE * 1.5f
>= P.CorridorRadius + 2.0f)
{
return Fail(TEXT("maze roughness must not erase the corridor centreline"),
P.SurfaceRoughness * VOXEL_NOISE_SCALE,
P.SurfaceRoughness * VOXEL_NOISE_SCALE * 1.5f,
P.CorridorRadius + 2.0f);
}
if (P.BoundarySealThickness < 0.0f
@@ -247,6 +247,141 @@ namespace VoxelHash
}
}
//=============================================================================
// MAZE TOPOLOGY — ORIGIN-DIRECTED SPANNING TREE
//=============================================================================
// A Maze node never waits for a probabilistic edge to connect it to the network. Every node
// except the origin chooses exactly one parent among the coordinate axes that point toward the
// origin. The chosen parent lowers |X|+|Y|+|Z| by one, so following parents terminates at (0,0,0)
// and the undirected parent edges are a spanning tree of the infinite lattice.
//
// The optional loop rolls below are deliberately capped and are never used for the parent edge.
// They add alternate routes and visual irregularity, but deleting every loop still leaves the
// complete tree. This is the locality contract: the parent is a function of the child node and
// seed only; an emitted voxel cell needs the eight child nodes in its one-cell {-1,0} halo.
namespace VoxelMazeTopology
{
enum class EAxis : uint8
{
X = 0,
Y = 1,
Z = 2,
};
// These are authoring knobs for optional loops, not connectivity probabilities. The scales
// keep old authored BranchProbability/Verticality values useful without allowing a lattice
// full of redundant parallel edges to turn the tree back into a grid.
constexpr float HorizontalLoopScale = 0.18f;
constexpr float VerticalLoopScale = 0.10f;
FORCEINLINE uint32 NodeHash(int32 X, int32 Y, int32 Z, uint32 Salt, uint32 DomainSalt)
{
uint32 H = VoxelHash::Cell(X, Y, Salt ^ DomainSalt);
H ^= VoxelHash::Mix(static_cast<uint32>(Z) * 73856093u ^ DomainSalt);
return VoxelHash::Mix(H);
}
FORCEINLINE uint32 AxisSalt(EAxis Axis)
{
switch (Axis)
{
case EAxis::X: return 0xA1u;
case EAxis::Y: return 0xB2u;
case EAxis::Z: return 0xC3u;
default: return 0xA1u;
}
}
FORCEINLINE bool TryGetParent(int32 X, int32 Y, int32 Z, uint32 Salt,
FIntVector& OutParent)
{
if (X == 0 && Y == 0 && Z == 0)
{
return false;
}
EAxis Available[3];
int32 Steps[3];
int32 NumAvailable = 0;
if (X != 0)
{
Available[NumAvailable] = EAxis::X;
Steps[NumAvailable++] = X > 0 ? -1 : 1;
}
if (Y != 0)
{
Available[NumAvailable] = EAxis::Y;
Steps[NumAvailable++] = Y > 0 ? -1 : 1;
}
if (Z != 0)
{
Available[NumAvailable] = EAxis::Z;
Steps[NumAvailable++] = Z > 0 ? -1 : 1;
}
const uint32 H = NodeHash(X, Y, Z, Salt, 0xD4E1A5E1u);
const int32 ParentIndex = static_cast<int32>(H % static_cast<uint32>(NumAvailable));
const EAxis ParentAxis = Available[ParentIndex];
const int32 ParentStep = Steps[ParentIndex];
OutParent = FIntVector(X, Y, Z);
switch (ParentAxis)
{
case EAxis::X: OutParent.X += ParentStep; break;
case EAxis::Y: OutParent.Y += ParentStep; break;
case EAxis::Z: OutParent.Z += ParentStep; break;
default: break;
}
return true;
}
FORCEINLINE FIntVector AxisNeighbour(int32 X, int32 Y, int32 Z, EAxis Axis)
{
switch (Axis)
{
case EAxis::X: return FIntVector(X + 1, Y, Z);
case EAxis::Y: return FIntVector(X, Y + 1, Z);
case EAxis::Z: return FIntVector(X, Y, Z + 1);
default: return FIntVector(X, Y, Z);
}
}
FORCEINLINE bool IsTreeEdge(int32 X, int32 Y, int32 Z, EAxis Axis, uint32 Salt)
{
const FIntVector A(X, Y, Z);
const FIntVector B = AxisNeighbour(X, Y, Z, Axis);
FIntVector Parent;
return (TryGetParent(A.X, A.Y, A.Z, Salt, Parent) && Parent == B)
|| (TryGetParent(B.X, B.Y, B.Z, Salt, Parent) && Parent == A);
}
FORCEINLINE float LoopProbability(EAxis Axis, float BranchProbability, float Verticality)
{
return Axis == EAxis::Z
? FMath::Clamp(Verticality, 0.0f, 1.0f) * VerticalLoopScale
: FMath::Clamp(BranchProbability, 0.0f, 1.0f) * HorizontalLoopScale;
}
FORCEINLINE bool IsLoopEdgeOpen(int32 X, int32 Y, int32 Z, EAxis Axis, uint32 Salt,
float BranchProbability, float Verticality)
{
if (IsTreeEdge(X, Y, Z, Axis, Salt))
{
return false;
}
const float Probability = LoopProbability(Axis, BranchProbability, Verticality);
return Probability > 0.0f
&& VoxelHash::ToFloat01(NodeHash(X, Y, Z, Salt, AxisSalt(Axis))) < Probability;
}
FORCEINLINE bool IsOpenEdge(int32 X, int32 Y, int32 Z, EAxis Axis, uint32 Salt,
float BranchProbability, float Verticality)
{
return IsTreeEdge(X, Y, Z, Axis, Salt)
|| IsLoopEdgeOpen(X, Y, Z, Axis, Salt, BranchProbability, Verticality);
}
}
//=============================================================================
// BRUIT CELLULAIRE / CELLULAR (WORLEY) NOISE — 3D
//=============================================================================
@@ -292,9 +292,10 @@ namespace VoxelDensityOps
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeConstantVoidSource(float BaseDensity);
/** Rôle 1 — les couloirs de Maze : capsules sur les arêtes ouvertes d'un treillis 3D.
* Écrit le canal SDF uniquement. Identité d'arête = hash(nœud inférieur, axe), donc deux
* chunks adjacents NE PEUVENT PAS être en désaccord : pas de cache de chunk, pas de région
* COLLECT, zéro risque de couture (AUDIT §6.4 — le motif à préférer). */
* Écrit le canal SDF uniquement. Chaque nœud hors origine choisit un parent vers l'origine;
* les arêtes canoniques (nœud inférieur + axe) testent localement les deux parents. La source
* met en cache les décisions de la fenêtre {-1,0}³ par cellule; les chunks adjacents voient
* donc exactement le même graphe, sans collect global ni couture. */
/** The source answers only for its own SDF field. Its box query publishes an SDF interval;
* each later converter or modifier consumes that interval through the generic fold. It does
* not know, and must not answer for, any downstream operator. */
+3 -2
View File
@@ -165,8 +165,9 @@ public:
const FSlabGenerationParams& Params) const;
/**
* Densité pour une strate Maze — couloirs étroits sur un treillis 3D déterministe.
* Pas de cache: évalué par voxel sur les quelques cellules voisines.
* Densité pour une strate Maze — couloirs sur un treillis 3D déterministe, avec les décisions
* parent/boucle reconstruites par cellule dans un cache thread-local; le hot path ne parcourt
* que les capsules déjà émises.
*/
float GetMazeDensity(float WorldX, float WorldY, float WorldZ,
const FMazeGenerationParams& Params) const;
+14 -14
View File
@@ -1263,11 +1263,11 @@ struct VOXELFORGE_API FSlabGenerationParams
/**
* FMazeGenerationParams — tight, branching corridors on a deterministic 3D lattice.
*
* Each lattice node sits at a cell center. An edge to a +X / +Y / +Z neighbour exists
* when a symmetric pair-hash passes BranchProbability (Z edges additionally gated by
* Verticality). Corridors are thin capsules carved through solid rock. Because edges
* are decided by a pure symmetric hash of the two cells, the maze is identical in every
* chunk with no caching needed — evaluated per-voxel over the few nearby cells.
* Each lattice node sits at a cell center and chooses one parent among the axes that point toward
* the origin. That parent edge is always present, so the lattice is a spanning tree: following
* parents strictly lowers |X|+|Y|+|Z| and ends at (0,0,0). A capped hash roll adds optional
* horizontal/vertical loops for alternate routes; loops are visual detail, never the connectivity
* proof. The evaluator needs only the current cell's one-cell {-1,0} child-node halo.
*/
USTRUCT(BlueprintType)
struct VOXELFORGE_API FMazeGenerationParams
@@ -1279,19 +1279,19 @@ struct VOXELFORGE_API FMazeGenerationParams
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Maze", meta = (ClampMin = "8.0"))
float CellSize = 64.0f;
// Corridor tube radius in voxels. Keep well below CellSize/2 to leave walls.
// 4-5 → marginal passage · 8 → 2 m radius / 4 m bore (default) · 10+ → broad hall
// Corridor tube radius in voxels. Keep below CellSize/2 to leave a real lattice wall.
// 8.6 → 3 m nominal usable floor at 25 cm/voxel; 12.5 → the same after the
// proven 3.75-voxel roughness envelope (default, 6.25 m nominal bore).
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Maze", meta = (ClampMin = "1.0"))
float CorridorRadius = 8.0f;
float CorridorRadius = 12.5f;
// Probability that a horizontal edge between adjacent cells is open (0-1).
// Lower = more dead ends and a more maze-like feel; higher = more open/connected.
// 0.45 → sparse, lots of dead ends · 0.7 → connected maze (default) · 0.9 → very open
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Maze", meta = (ClampMin = "0.05", ClampMax = "1.0"))
// Legacy authoring knob for optional horizontal loop edges (0-1). Parent edges are always
// present, so this no longer controls connectivity. The runtime caps it at 0.18 * this value.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Maze", meta = (ClampMin = "0.0", ClampMax = "1.0"))
float BranchProbability = 0.7f;
// Probability that a vertical (Z) edge between stacked cells is open (0-1).
// 0 = single-level maze; higher = multi-storey with vertical shafts between levels.
// Legacy authoring knob for optional vertical loop edges (0-1). The spanning tree still reaches
// every Z level; the runtime caps this loop chance at 0.10 * Verticality.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Maze", meta = (ClampMin = "0.0", ClampMax = "1.0"))
float Verticality = 0.3f;