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

@@ -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);
}
}
}