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

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