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

+36 -12
View File
@@ -11,7 +11,7 @@
A large A-to-Z expansion. The world is a stack of strates the player descends through;
each strate can be a fundamentally different *archetype*, connected at (0,0).
### 8.0 Human-scale generation contract (2026-09-05)
### 8.0 Human-scale generation contract (2026-09-06)
All authored lengths are voxel-space values, converted at the boundary by `0.25 m/voxel`. The body
reference is a **0.34 m radius, 1.76 m tall** capsule (`1.36 × 7.04` voxels). The default strate
@@ -23,7 +23,7 @@ The default size families are deliberately independent:
| Feature | Default metre derivation | Coupling kept sound |
|---------|--------------------------|---------------------|
| Room network | radius 4-10 m (8-20 m diameters), origin radius 12 m (24 m hub), cell spacing 32 m | `MaxRoomRadius 40 < RoomSpacing/2 64`; a 6 m radial margin remains around the largest room |
| Round passages | 1.5-2 m radius (3-4 m bores); maze corridor radius 2 m (4 m bore) | a 1.76 m body fits vertically with headroom; Maze `8 < CellSize/2 32` |
| Round passages | 1.5-2 m radius (3-4 m bores); maze corridor radius 3.125 m (6.25 m nominal bore) | `0.7 × 2 × (12.5−3.75) × 0.25 = 3.0625 m` usable floor after worst roughness; Maze `12.5 < CellSize/2 32` |
| Slab | floor at 16 m, ceiling at 38.4 m, 22.4 m open span | floor/ceiling noise and 4-8 m column diameters remain inside the open span |
| SurfaceWorld | 20 m terrain relief, 12 m warp, 60.8 m sky cap | terrain features are bounded below the 64 m strate envelope |
| VerticalShafts | 4-7 m shaft diameters, 3 m connector bore, 8 m ledge interval | shaft spacing is 20 m; connector/ledge values are derived from the roughness envelope |
@@ -32,7 +32,7 @@ The default size families are deliberately independent:
For wall detail, `VOXEL_NOISE_SCALE=1.25` and the proven `sup|fBM|=1.5` give a roughness reach of
`1.875 × SurfaceRoughness` voxels. The default ratios are **0.625** against the minimum tunnel
radius, **0.469** against the Maze corridor radius, **0.469/0.625** against shaft/connector radii,
radius, **0.300** against the Maze corridor radius, **0.469/0.625** against shaft/connector radii,
**0.234** against the minimum island radius (**0.484** including its 6-voxel blend), and
**0.625/0.938** against slab column radius for floor/ceiling roughness. SurfaceWorld is a
heightfield, so its **0.047** ratio is against its 80-voxel relief scale, not a fictitious radial
@@ -46,14 +46,15 @@ accessibility reference (915 mm clear walking width and larger passing/turning s
keeping natural chambers heterogeneous and allowing 20 m-plus cathedral volumes.
Validation is deliberately split by cost: bounded regression fixtures remain 4 chunks, while the
production definition, season defaults, and owner-facing showcase use 8 chunks. The final focused
showcase (`WorldScaleShowcaseProductionFinal`) exhausted 256 rolls, measured 82 eligible single-region
rolls, skipped 174 multi-region rolls while lateral regions remain gated, and found **0/8** complete
hard-gate survivor set at fine step 1. It still produced diagnostic cards (7 fine renders, 1 refusal,
1 blank). The full `VoxelForge` run (`WorldScaleFullFinal3`) recorded **28 successes, 2 warning-only,
0 failures, 0 not-run**; all box-verdict violation counts were **0**, `WorldRadiusVoxels` stayed **0**,
and operator-stack equivalence paths remained bit-identical. The two authored-corpus relation findings
(2/12) are conservative experiment-envelope findings, not box-verdict violations.
production definition, season defaults, and owner-facing showcase use 8 chunks. The historical
pre-tree production-height sweep exhausted 256 rolls and found **0/8** complete hard-gate survivors;
its 7 fine renders, 1 refusal, and 1 blank are retained as the scale baseline. The final
`VoxelForge` run (`VoxelForgeFullMazeTreeFinal3`) recorded **30 clean successes, 2 warning-bearing,
0 failures, 0 not-run** in **2,523.978 s**; its regenerated showcase produced **6 fine renders, 2
refusals, and 0 blanks**. All box-verdict violation counts were **0**, `WorldRadiusVoxels` stayed
**0**, lateral regions stayed gated, and operator-stack equivalence paths remained bit-identical.
The two authored-corpus relation findings (2/12) are conservative experiment-envelope findings,
not box-verdict violations.
### 8.1 Archetypes (`ECaveGeneratorType`, VoxelStrateTypes.h)
Each archetype has its own param `USTRUCT` (on `UVoxelStrateDefinition`, EditCondition-gated
@@ -64,7 +65,7 @@ by `GeneratorType`) and its own density function in `VoxelGenerator.cpp`, dispat
|-----------|---------------|------------|------|
| TunnelNetwork | `FStrateGenerationParams` | `GetDensityWithParams` | rooms+tunnels (original) |
| FlatPlain / CrystalChamber | `FSlabGenerationParams` | `GetSlabDensity` | floor/ceiling void (original) |
| Maze | `FMazeGenerationParams` | `GetMazeDensity` | tight corridors on a 3D lattice (per-voxel, no cache; edge = lower node + axis hash) |
| Maze | `FMazeGenerationParams` | `GetMazeDensity` | origin-directed spanning-tree corridors on a 3D lattice, with capped loop edges and a thread-local per-cell capsule cache |
| SurfaceWorld | `FSurfaceGenerationParams` | `GetSurfaceDensity` | heightfield terrain: domain-warped continents+ridged mtns+detail, a low-freq **relief map** (`M`) that scales mountains/elevation for plains↔highland variety, **F20 heightfield terrain ops** (`Surface|Ops` — all default off ⇒ byte-identical): **Cliff** (slope-gated steepening — where the analytic structural slope > `CliffSlopeThreshold`, push the height away from the local mean by `CliffSharpness` ⇒ gentle slopes become sheer walls/canyon faces that hug real steep ground, gentle areas untouched; 4 structural resamples only when enabled), **Terrace** (relief-gated plateau quantize + `TerraceHardness` soft-round↔crisp-mesa), **LayerLines** (sedimentary sine shelves, slope-expressed) — pure per-column height remaps in the single oracle `ComputeSurfaceTerrainZ` (`SampleSurfaceStructuralZ` = pre-op raw height, re-sampled at an XY offset for Cliff's slope), biome-selected + border-blended for free via each biome's `SurfaceParams` + the height output-lerp; plus **phase-2 Overhang** (the first VOLUMETRIC op — real jutting shelves like a cliff lip): in `SurfaceDensityFromColumn`, for AIR voxels in the window `(TerrainZ, TerrainZ+OverhangHeight]` above a steep slope, the heightfield is re-sampled UPHILL (toward the cliff) by a reach that GROWS with height and unioned in. Low in the window the shift is ~0 (borrows nearby low rock ⇒ stays air over the void); high up it reaches the far cliff (solid) ⇒ a shelf attached to the cliff, tapering out over the void with air underneath. Per-column `OverhangAmp`(=strength·slope-gate) + unit uphill dir `(DirX,DirY)` are resolved once in `ComputeSurfaceColumn` — the gradient sampled at the REACH scale (`OverhangReach`) so a point out over the void can "see" the cliff to know which way is uphill — and cached on `FSurfaceColumn`. It's genuine 3D (per-voxel structural re-eval), so it's gated hard to steep overhang columns; the union only ADDS rock (never removes), capped at `TerrainZ+OverhangHeight`, so `ClassifyTile` forces Mixed only in `(TerrainZ, TerrainZ+OverhangMargin]` (upward-only; margin = max `OverhangHeight`) — a shelf never holes a trivially-skipped tile, and only the thin cliff-edge band of air tiles is woken (NOT far-field like phase-3 spikes/holes). Overhang undersides classify as ground rock by the F17 rule (down-facing but below `TerrainZ`). Known v1 limit: applies at all LODs (Step-agnostic) — may alias far; gate to fine tiles later. Beaches at water line, high sky-cap ceiling. The cap is shapeable terrain in its own right (`ComputeSurfaceCeiling`, `Surface|Sky` params: `CeilingUndulation` broad inverted hills/valleys, `CeilingRidgeStrength` hanging ridgelines, `CeilingRoughness`+freq fine bumps, `CeilingWarp*`) — defaults (strengths 0, freq 0.04) = old flat-ish cap. (`Surface|Macro` params = the cheap precursor to biomes; `ReliefStrength=0` ⇒ old uniform terrain.) **Sky-cap vs ground is a PER-TRIANGLE surface class (F17), not a per-tile verdict**: the mesher classifies each unique vertex on the **worker** — only down-facing verts (`N.Z<-0.1`) pay a memoized `GetSurfaceHeightAt` column query; nearer `CeilSurf` ⇒ sky-cap, nearer `TerrainZ` ⇒ terrain overhang stays ground (a future SurfaceWorld cave roof — down-facing but below `TerrainZ` — also lands ground by the same rule; non-surface strates always ground). Triangles take the majority class of their 3 verts and are packed as **two contiguous index runs** (ground then cap, `FVoxelMeshData::NumCeilingTriangles`; skirts inherit their source triangle's run) → RMC **polygroups 0/1** → one **section per non-empty group** (`ApplyMeshToTile`, material slot = group index): slot 0 = strate `OverrideMaterial`/default (min-corner chunk), slot 1 = `CeilingMaterial` resolved at the tile's **TOP chunk** (mid as gap fallback, then min; fallback: ground material) — a coarse tile is 2^level chunks tall, so its min corner can sit in a lower strate/gap while the cap belongs to the strate above (this was the "far cap = ground material" residue), so the shadowless overhead rock is tinted separately instead of reading flat/bright. Shadow is **per section** via `FRealtimeMeshSectionConfig::bCastsShadow` (NOT the component `SetCastShadow` — RMC's proxy ignores the component flag; this is also why level≥2 far tiles only stopped casting once the section flag was wired): ground casts at level≤1, the cap section never casts, so the rock ceiling never shadows the terrain below it. History: v1 was a game-thread centre height-oracle (misclassified coarse far tiles → terrain material on the cap underside); v2 a whole-tile worker normal VOTE — which painted **mixed coarse tiles** (one far tile spanning terrain AND cap) entirely with the winner's material, and put sky material under terrain overhangs. The per-triangle class fixes both and is the identity channel caves/F8 will reuse. |
| VerticalShafts | `FVerticalShaftParams` | `GetVerticalShaftDensity` | full-height shafts + horizontal connectors + partial ledges |
| FloatingIslands | `FFloatingIslandParams` | `GetFloatingIslandDensity` | asymmetric islands: flat land top + underside tapering to a point, lobed (domain-warped) outline, in an open void |
@@ -203,6 +204,23 @@ Provided per chunk by `StrateManager::GetDisturbanceParamsForChunk`.
**If you add a connectivity rule with longer edges, the COLLECT region must still cover the
max edge reach, and decisions must not depend on the stored window.**
#### Maze topology — local spanning tree, optional loops
Maze uses a different, strictly local connectivity contract. A non-origin lattice node `(x,y,z)`
hash-selects one parent from the axes whose coordinate is non-zero, taking one step toward
`(0,0,0)`. The parent step reduces `|x|+|y|+|z|` by one, so the infinite undirected parent graph
has one root, no cycles, and every cell is reachable by construction. A canonical lower-node plus
axis edge is open when either endpoint selects the other as parent. Horizontal and vertical loop
edges are optional visual detail, capped at `0.18 * BranchProbability` and `0.10 * Verticality`;
they are never needed for the connectivity proof.
The evaluation window is exactly the lower nodes in `{-1,0}³`, an 8-node / 2×2×2 local halo. The
edge predicate checks the adjacent `+1` endpoint's parent locally, so it does not need a wider
collect region and cannot disagree at a chunk boundary. Both `GetMazeDensity` and
`MakeLatticeCorridorSource` rebuild those decisions in their thread-local per-cell cache; the
voxel loop evaluates only cached capsule SDFs. This is deliberately independent of
`WorldRadiusVoxels` (which remains `0`) and the still-gated lateral-region system.
The editor composer walk-through preserves this invariant by avoiding a layout rebuild. AVoxelWorld::ApplyComposerCandidate
pauses and drains active generation, installs one temporary override on an existing slot, increments
the manager's layout/passages version, and then reuses RegenerateAllChunks to bump GenerationEpoch.
@@ -521,6 +539,12 @@ driven by `EditorBrush*` props.
by that halo, so no parent decision depends on the evaluation cell. No cell or pair hash, widened
neighbourhood scan, or tree construction may move into the per-voxel loop; the operator-stack
source follows the same cache contract.
- **Maze field cache**: parent/loop decisions are rebuilt only when the thread-local cell, seed, or
loop parameters change. The rebuild evaluates the 24 canonical lower-node/axis edges in the
local `{-1,0}³` window and emits at most 24 capsules; the per-voxel loop performs no hash,
parent, loop, or neighbourhood work. The focused audit measured **1.436 μs** per forced rebuild
and **0.150 μs** per hot call in the focused audit; the final namespace run measured **1.130 μs**
rebuild and **0.145 μs** hot on the same machine.
- **Per-chunk param cache** in `GetDensityAt`: GenType + param struct + disturbance cached
thread-locally by `(DensityCacheOwnerId, ChunkCoord, LayoutVersion)`; the process-unique owner ID
prevents cross-world reuse while adding only one `uint64` compare per voxel. Don't remove the owner
+23 -11
View File
@@ -73,12 +73,13 @@ what generator type, and is there a passage/elevator SDF near here?"
Paths relative to `Source/VoxelForge/`. `Public/` = headers, `Private/` = impl.
### 3.0 Human-scale generation contract (2026-09-05)
### 3.0 Human-scale generation contract (2026-09-06)
`VoxelStrateTypes.h` is now authored against the confirmed player capsule, not against naked voxel
counts: one voxel is **0.25 m**, the capsule is **1.36 voxels / 0.34 m radius** and **7.04 voxels /
1.76 m tall**, and the default strate envelope is **8 chunks / 256 voxels / 64 m**. Core defaults are
therefore: room diameters **8-20 m**, origin hub **24 m**, room cells **32 m**, round tunnel bores
**3-4 m**, maze cells **16 m** with a **4 m** bore, slab open span **22.4 m**, shaft diameters
**3-4 m**, maze cells **16 m** with a **6.25 m nominal bore** (**3.125 m** radius; **3.0625 m**
worst-case usable floor after roughness), slab open span **22.4 m**, shaft diameters
**4-7 m**, island diameters **12-24 m**, and ordinary inter-strate bores **3-4 m**. These are
different derivations, not a global multiplier: passage widths follow the 2.5-4 m walking/fight
target, room/cell spacing preserves a rock web, and the 64 m vertical envelope reserves space for
@@ -86,7 +87,7 @@ tall rooms, surface sky, and vertical traversal.
The reachable roughness envelope is `roughness * VOXEL_NOISE_SCALE * 1.5`, or `roughness * 1.875`
voxels. The new defaults keep it below the relevant feature radius: **0.625** of the minimum
TunnelNetwork tunnel radius, **0.469** of Maze corridor radius, **0.469/0.625** of shaft/connector
TunnelNetwork tunnel radius, **0.300** of Maze corridor radius, **0.469/0.625** of shaft/connector
radii, **0.234** of the minimum island radius (**0.484** including the 6-voxel island blend), and
**0.625/0.938** of slab column radius for floor/ceiling roughness. SurfaceWorld has no radial cave
feature, so its ratio is reported against the 80-voxel terrain-relief scale (**0.047**).
@@ -99,6 +100,15 @@ provide a 915 mm clear walking-width floor, not a game-space target; and [NPS Le
dimensions](https://www.nps.gov/grba/learn/nature/lehman-caves-dimensions.htm) show why natural
chambers need a wide range rather than one universal volume ratio.
Maze topology is an origin-directed spanning tree, not independent edge percolation. Every
non-origin lattice node chooses one hashed parent from the non-zero axes that point toward
`(0,0,0)`; Manhattan distance therefore falls by exactly one on every parent step, so every node
reaches the root. Optional horizontal and vertical loop rolls are capped at `0.18 * BranchProbability`
and `0.10 * Verticality`; removing them cannot disconnect the tree. A voxel cell evaluates the
canonical lower-node `+X/+Y/+Z` edges in its local `{-1,0}³` (**2×2×2 = 8-node**) window, and each
edge checks both endpoints' parent choices. No wider collect region is needed, `WorldRadiusVoxels`
remains `0`, and lateral regions remain gated off.
### 3.1 Module & build
| File | Role |
|------|------|
@@ -174,7 +184,7 @@ bit. They are port-correctness oracles, not fidelity checks: the acceptance bar
| `FVoxelOpStack::AppendStructuralPost` | 4 | Appends spine → vertical seal → passage → XY edge seal **in that fixed order**. An author cannot omit or reorder them. The diff layer is NOT here yet — it still lives in `GetDensityAt` after the MC negate, with disturbances. |
| `VoxelDensityOps::MakeConstantRockSource` | 1 | `Density = BaseDensity`. `ClassifyBox` → **AllSolid**, exact and free. Shared by TunnelNetwork, Maze, VerticalShafts and bedrock gaps. Class is `FConstantFieldSource` (one class, two factories). |
| `VoxelDensityOps::MakeConstantVoidSource` | 1 | The **same class, negated**: `Density = -BaseDensity`, and `ClassifyBox` → **AllAir** — the first source in the plugin that can prove it. FloatingIslands' root; that verdict is what makes a mostly-empty island strate skippable. |
| `VoxelDensityOps::MakeLatticeCorridorSource` | 1 | Maze corridors, SDF channel. Edge identity = `hash(lower node, axis)` ⇒ adjacent chunks cannot disagree (AUDIT §6.4's preferred pattern). The source reports its own conservative SDF interval; a later converter supplies its own carve/fill response, so the source is independent of the chosen consumer. |
| `VoxelDensityOps::MakeLatticeCorridorSource` | 1 | Maze corridors, SDF channel. Each canonical lower-node/axis edge is open when it is a parent edge in the origin-directed tree or passes a capped loop roll; both endpoints are hashed locally, so adjacent chunks cannot disagree. The source reports its own conservative SDF interval; a later converter supplies its own carve/fill response, so the source is independent of the chosen consumer. |
| `VoxelDensityOps::MakeSdfRoughnessMod` | 3 | Wall roughness in **SDF** space (Maze/Shafts/Islands variant). TunnelNetwork's density-space roughness is a **different op** — see OPSTACK-DECOMPOSITION §1. |
| `VoxelDensityOps::MakeSdfCarve` | 2 | SDF → density carve. The same six lines currently copied in three archetypes. Class is `FSdfConvertOp(Sign = -1)`. |
| `VoxelDensityOps::MakeSdfFill` | 2 | The same op with `Sign = +1` — FloatingIslands' `Density += Fill·Base·2`. ±1 multiplication is exact in IEEE-754, so the carve path is bit-for-bit unchanged by the generalisation. |
@@ -197,7 +207,7 @@ bit. They are port-correctness oracles, not fidelity checks: the acceptance bar
| `FRoomGraphSource::LocalParams()` | — | **The per-room op override** (DECOMPOSITION §2's "no clean home"). Strate params + the nearest room's `UVoxelTerrainOpDefinition::ApplyTo`, memoised once per voxel and read by eleven modifiers. One op owns the shared state, the rest read it — the same pattern as `FOverhangShelfMod` ← `FSurfaceColumnSource`. The detail consumers use the propagated interval for their shared near/far gate; unknown remains conservative, and the per-room parameter override is never used to claim an unproved box identity. |
| `FIslandBlobSource` (internal) | 1 | Hash-placed tapered flat-top blobs, `SmoothMin`'d, in a **domain-warped XY frame** (the warp stays inside the op — see the deviation note vs DECOMPOSITION §7). SDF channel only. The source publishes an interval covering the full authored radius envelope (`max(MinRadius, MaxRadius)`), the two-axis warp·**√2** envelope, top surface and SmoothMin dip; the fill converter makes its own decision. **No lower Z bound exists** — a hairline thread of matter hangs below each island down its axis, so only the TOP may reject. |
| `VoxelDensityOps::BuildFloatingIslandStack` | — | 8 ops, and **the stack runs backwards**: void source + fill instead of rock source + carve, using the *same* classes with the opposite sign. Only the blob source is new. Reuse by **inversion** — a stronger result than reuse by identity, since it says the abstract axis (the density sign) is the right one. |
| `VoxelDensityOps::BuildMazeStack` | — | The 8-op Maze stack (4 structural posts included). If this ever becomes one op, the refactor failed its own test (§2.5). Callers must skip it on a **degenerate strate** (top−bottom ≤ 0): `GetMazeDensity` early-outs to air there and the stack has no such early-out by design — `GetDensityAt` falls back to the `switch`. |
| `VoxelDensityOps::BuildMazeStack` | — | The 8-op Maze stack (4 structural posts included). Its lattice source mirrors the origin-directed tree + capped loops and rebuilds the 2×2×2 lower-node window once per cell; the per-voxel loop only evaluates cached capsules. If this ever becomes one op, the refactor failed its own test (§2.5). Callers must skip it on a **degenerate strate** (top−bottom ≤ 0): `GetMazeDensity` early-outs to air there and the stack has no such early-out by design — `GetDensityAt` falls back to the `switch`. |
#### Lateral region parent (Tier 4d)
@@ -588,10 +598,11 @@ composition/review work is editor/build-box code; parsing and recipe materialisa
region manifests remain rejected because their cross-seam law is only 9/16. The focused
season test currently reports **24 generated / 19 hard-gate survivors / 6 selected / 18 unselected or
rejected**, with **4 vacuous**, **1 primordial-law budget**, **13 policy-not-selected**, and **393,216**
round-trip density samples bit-identical. The specified UE 5.7 editor build succeeded and the full
`VoxelForge` namespace report `WorldScaleFullFinal3` recorded **28 succeeded, 2 warning-only,
0 failed, 0 not run** in **1,704.010 s**. The later focused production-height showcase was rerun
after the final audit-label correction and is recorded separately below.
round-trip density samples bit-identical. The specified UE 5.7 editor build succeeded and the final
`VoxelForge` namespace report `VoxelForgeFullMazeTreeFinal3` recorded **30 clean successes, 2
warning-bearing, 0 failed, 0 not run** in **2,523.978 s**. The regenerated showcase produced **6 fine
renders, 2 refusals, and 0 blanks**; all box-verdict violations remained zero. The later focused
production-height showcase history is recorded separately below.
**`Public/VoxelSeasonAsset.h` + `Private/VoxelSeasonAsset.cpp`** — `UVoxelSeasonAsset :
UPrimaryDataAsset`, the cookable carrier. Its editor button imports the reviewable JSON, stores the exact
@@ -659,7 +670,7 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor
| `VoxelForgeOpStackTunnelTest.cpp` | `VoxelForge.OpStack.TunnelNetworkSpineEquivalence` | **Stage A of the last port.** Zeroes the 13 detail-op amplitudes so the *original* takes the path stage A ported — that is what makes an incomplete stack verifiable now. Samples in **clusters** (24 chunks × 250 points), because the SDF cache rebuilds when a query leaves its box and uniform sampling would rebuild per point on both paths. Check 3 (two param sets, A/B interleaved) compares each stack **to itself alone, never to the original** — the original would fail it, see AUDIT §C2. Asserts **zero** box verdicts, which is the honest stage-A result. |
| `VoxelForgeOpStackShaftTest.cpp` | `VoxelForge.OpStack.VerticalShaftEquivalence` | The port that tests **reuse**, not fidelity: three of the five ops are Maze's, unchanged. Forces connectors + ledges on, because both are off or negligible at defaults and a resting param is an untested operator. The source and mirror now share a deterministic inner-3×3-emitted drainage tree whose parents come from rebuild-only ±2/±3 windows in a wider direct-indexed halo (default 9×9 emit / 15×15 roll), plus the old random links. Tree connector Z is exact and ledge-free; tree and random capsules are spatially culled from the cached rebuild. **Fixed 2026-07-29:** its box query used to return `CarveOnly` because a shaft merely *existed* within a `Spacing*1.6` halo — true almost everywhere at `ShaftSpacing 55 / ShaftDensity 0.6`, hence **0 of 60** tiles. It now publishes an isolated interval for tree and random capsules (same row-major cell order ⇒ same `VoxelHash::Pair`, so symmetry of `Pair()` is not assumed), with **Z exact** and XY conservative; the ledge and converter decide their own effects. The sampler was also widened from ±48 voxels to ±440. Every proved tile is brute-forced over its full lattice, and the test reports rebuild-vs-hot cache cost. |
| `VoxelForgeOpStackIslandTest.cpp` | `VoxelForge.OpStack.FloatingIslandEquivalence` | The port that runs the stack **backwards** — void + fill vs rock + carve, same classes with the opposite sign. Counts interior-solid and open-void samples separately (on this archetype an aggregate "N solid" is dominated by the seal bands and says nothing about the islands). Counts `AllSolid` and `AllAir` verdicts **separately** too: `AllAir` is the one no cave archetype could ever prove, and it is the entire perf argument here. |
| `VoxelForgeOpStackMazeTest.cpp` | `VoxelForge.OpStack.MazeEquivalence` | **Phase 1's load-bearing test.** The 7-op Maze stack vs `GetMazeDensity` over 20k points (aiming for bit-identity; a side-of-iso disagreement is the hard fail), plus purity across workers and brute force on every box verdict the stack emits. Reports how many tiles the stack can prove uniform — today's `ClassifyTile` proves **zero** for any cave archetype. |
| `VoxelForgeOpStackMazeTest.cpp` | `VoxelForge.OpStack.MazeEquivalence` | **Phase 1's load-bearing test.** The 8-op Maze stack vs `GetMazeDensity` over 20k points (bit-identity; a side-of-iso disagreement is the hard fail), plus purity across workers, a 64-seed spanning-tree/connectivity audit, corridor morphology, cache rebuild-vs-hot timing, and brute force on every box verdict the stack emits. Latest audit: **0 tree edge-count violations, 0 tree-disconnected, 0 Maze-disconnected**; interior degrees `d1=15.91%`, `d2=36.60%`, `d3=31.73%`, `d4=12.95%`, `d5=2.56%`, `d6=0.25%`, mean **2.504** versus a cubic grid's degree-6/mean-6.000; runs `min=1 / median=1 / p90=3 / max=18`. Latest focused cache timing: **1.436 μs rebuild**, **0.150 μs hot call**; final namespace timing: **1.130 μs rebuild**, **0.145 μs hot call**. Latest box audit: **104 proved, 152 Mixed, 138,424 voxels, 0 violations**. |
| `VoxelForgeStrateParamCoverageTest.cpp` | `VoxelForge.Determinism.StrateParamBlendCoverage` | **The X-macro guard** (added 2026-08-17). `FStrateGenerationParams::Lerp` blends the hand-written `VF_STRATE_PARAM_FIELDS` list, **not** the struct — so a field added to one and not the other compiles, tests green, and silently takes its **default** inside every Gradient/Interleaved transition band. This expands the X-macro a **third** way (after LERP and SNAP), into a name list, and diffs it against the struct's UObject reflection. Pure shape test: no fixture, no world, instant. `GExemptFieldNames` is **empty** — every reflected field is covered today, and any exemption must be written down as a decision. Stakes rise with the world composer, which intends to invent parameter sets through this same `Lerp` (`COMPOSER-NOTES.md`). |
| `VoxelForgeComposerParameterRollTest.cpp` | `VoxelForge.Composer.ParameterRoll` | Asset-Registry corpus audit + complete per-archetype spread/exclusion/clamp table; asserts bit-identical deterministic rerolls and same-archetype parents; measures 64 transient candidate strates with `VF_MeasureStrate` plus the exact unsnapped arrival→departure law; brute-forces every rolled production box verdict. World-scale run: **1,422 applications / 168 distinct fields**; **36/64 survival**, 60 non-vacuous, 58 largest-component, 40 exact-law passes; **1,411 Mixed + 731 AllSolid + 418 AllAir = 1,149 proved boxes**, **1,529,319 lattice voxels checked, 0 violations**, 148.387 s. |
| ″ | `VoxelForge.Composer.TerrainDetailLiveness` | Fixed 4,096-point `GetDensityAt` lattice, legacy and operator-stack paths; changes one terrain-detail group at a time with an empty terrain-op pool. Proves 9 live groups / 23 fields and 3 dead groups / 11 fields; all 24 rows match. |
@@ -670,7 +681,8 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor
| `VoxelForgeComposerCorpusFreeTest.cpp` | `VoxelForge.Composer.CorpusFree` | Shares each structure recipe across today's corpus blend, naive independent uniform rolls, and constraint-sampled rolls. The completed equal-arm world-scale run uses **16 candidates per arm**, step 4 / radius 256 / `MaxCells=8,000,000`, fixed passage-law mouths, and 40 box probes per candidate. Result: **10/16, 9/16, 8/16** survival; survivor feature-scale ranges are **8..268**, **8..60**, **8..92** voxels. Box checks: **185/246,235**, **260/346,060**, **299/397,969** proved/voxels, **0 violations** in every arm; constraint parameter and box-stop violations were also 0. |
| `VoxelForgeLayoutOrderIndependenceTest.cpp` | `VoxelForge.Determinism.LayoutOrderIndependence` | Builds a known transient soft-pointer pool, then rebuilds it in original, reversed, and swapped orders. Requires a non-empty layout and passage set, and compares every slot's definition/Z/height plus passage endpoints, radius, type, control geometry, and bounds bit-for-bit. |
| `VoxelForgePassageOpenSpaceTest.cpp` | `VoxelForge.Determinism.PassageLandsInOpenSpace` | Uses the real fixture density path to check both generated mouths against independent source queries. Answered mouths must match the pure player-fit point within the float envelope; the lower mouth also keeps the 16-point ring and source-footing assertions outside the carve/blend band. This is a local-fit/open-space proxy, not a flood-fill proof. Reports answered/refused mouths and archetypes; fails if either side is vacuous. |
| `VoxelForgeStrateConnectivityTest.cpp` | `VoxelForge.Generation.StrateConnectivity` / `VoxelForge.Generation.StrateConnectivityRefinement` / `VoxelForge.Generation.VerticalShaftSeamFreedom` | Bounded strate metrics with density-polarity and solid-gap controls, deterministic route rechecks, and refinement sweeps. The refinement test uses the bounded 4-chunk density fixture, fits each measurement AABB to the arrival/departure mouth pair plus three shaft spacings (the prescribed local tree fallback), and caps the fitted control at 120,000,000 cells. It reports exact before/after seed-6 cell counts, reruns every negative with the fitted margin doubled, measures tree orphan candidates/path reachability and roughness-bubble proxies, asserts mouths remain inside the deterministic shaft feature core (the exact axis is an unsupported open cylinder), checks source/mirror physical paths, and requires 16/16 effective arrival→departure results across the 16 VerticalShafts seeds. Margin-binding negatives are reported as measurement limits, never as gap findings. The seam test re-evaluates cell-boundary positions after warming distinct neighbouring chunk contexts in both legacy and operator-stack paths. |
| `VoxelForgeStrateConnectivityTest.cpp` | `VoxelForge.Generation.StrateConnectivity` / `VoxelForge.Generation.StrateConnectivityRefinement` / `VoxelForge.Generation.VerticalShaftSeamFreedom` | Bounded strate metrics with density-polarity and solid-gap controls, deterministic route rechecks, and refinement sweeps. The refinement test uses the bounded 4-chunk density fixture, fits each measurement AABB to the arrival/departure mouth pair plus three shaft spacings (the prescribed local tree fallback), and caps the fitted control at 120,000,000 cells. It reports exact before/after seed-6 cell counts, reruns every negative with the fitted margin doubled, measures tree orphan candidates/path reachability and roughness-bubble proxies, asserts mouths remain inside the deterministic shaft feature core (the exact axis is an unsupported open cylinder), checks source/mirror physical paths, and requires 16/16 effective arrival→departure results across the 16 VerticalShafts seeds. Margin-binding negatives are reported as measurement limits, never as gap findings. The shaft seam test re-evaluates cell-boundary positions after warming distinct neighbouring chunk contexts. |
| `VoxelForgeMazeSeamTest.cpp` | `VoxelForge.Generation.MazeSeamFreedom` | Re-evaluates **42** cell-boundary probes after warming six different neighbouring chunk contexts in both legacy and operator-stack paths. Latest result: **0 legacy mismatches, 0 operator mismatches**. The Maze source's 2×2×2 lower-node window is deliberately local; no wide collect is involved. |
| `VoxelForgeWorldEdgeSealTest.cpp` | `VoxelForge.Generation.WorldEdgeSeal` | Positive samples outside the radius across every fixture strate, bit-identical interior negative control against radius 0, monotonic smooth-ramp check, radius-0 no-op, radial `ClassifyBox`/T1.d proofs brute-forced for every reported voxel, and synthetic plus generated near-rim passage coverage. Emits one aggregate summary with proved-box and voxel counts. |
## 4. The density pipeline (most-edited hot path)
+92 -14
View File
@@ -392,6 +392,9 @@ covers each archetype plus the shared passage/disturbance and optional transport
The requested maze test was run first on the same seed/index and a mouth-fitted step-1 ROI. The
radius is the tunnel radius; the bore is twice the radius:
This is the **pre-spanning-tree radius sweep**. It remains useful as historical evidence about
mouth/fine-resolution behaviour, but it is not a connectivity result for the new topology.
| Corridor radius | Bore | Player-fit fraction | Traversable share | Minimum clearance | Arrival→departure |
|----------------:|-----:|--------------------:|------------------:|------------------:|-------------------|
| 4 vox / 1.00 m | 2.00 m | 0.009007 | 0.078358 | 1 voxel / 0.25 m | NotConnected |
@@ -406,16 +409,20 @@ failure is not explained by the nominal tunnel diameter alone: the generated fie
floor/cap interaction, and/or resolution still need separate diagnosis.
The new defaults use separate derivations: rooms are 8-20 m diameters with a 24 m origin hub and
32 m cells; round passages are 3-4 m bores; Maze is a 16 m cell with a 4 m bore; slabs reserve a
22.4 m floor-to-ceiling span; shaft diameters are 4-7 m with 3 m connectors; islands are 12-24 m
diameters on 28 m cells; ordinary passages are 3-4 m bores and reach 8-24 m. `RoomSpacing` keeps
the largest room 6 m radially clear of the next cell, `CorridorRadius` is 8/32 = 0.25 of Maze half-cell,
and `StrateHeightInChunks` grows from 4 to 8 because 17 m tall rooms, 20 m terrain relief,
vertical shafts, and a high cap do not share a comfortable 32 m envelope. No global multiplier was
used. The roughness reach is `1.875 × roughness` voxels: the new default ratios are
**0.625** (minimum tunnel), **0.469** (Maze), **0.469/0.625** (shaft/connector), **0.234** island
radius (**0.484** including blend), **0.625/0.938** slab column for floor/ceiling, and **0.047**
against SurfaceWorld's 80-voxel relief scale.
32 m cells; round passages are 3-4 m bores; Maze is a 16 m cell with a 6.25 m nominal bore;
slabs reserve a 22.4 m floor-to-ceiling span; shaft diameters are 4-7 m with 3 m connectors;
islands are 12-24 m diameters on 28 m cells; ordinary passages are 3-4 m bores and reach 8-24 m.
`RoomSpacing` keeps the largest room 6 m radially clear of the next cell. For Maze, the target is
3 m of usable two-abreast floor: `3 / 0.7 = 4.286 m` clear diameter, or an 8.571-voxel clear
radius; adding the proven 3.75-voxel roughness reach gives 12.321 voxels, rounded to
`CorridorRadius=12.5`. Thus the worst-case usable floor is
`0.7 × 2 × (12.5−3.75) × 0.25 = 3.0625 m`, and the roughness ratio is `3.75/12.5 = 0.300`.
The 64-voxel cell leaves `64−2×(12.5+3.75)=31.5` voxels (7.875 m) of worst-case wall
between neighbouring bores. `StrateHeightInChunks` grows from 4 to 8 because 17 m tall rooms,
20 m terrain relief, and a high cap do not share a comfortable 32 m envelope. No global multiplier
was used. The other default ratios remain **0.625** (minimum tunnel), **0.469/0.625**
(shaft/connector), **0.234** island radius (**0.484** including blend), **0.625/0.938** slab
column for floor/ceiling, and **0.047** against SurfaceWorld's 80-voxel relief scale.
The architecture sanity check uses [Epic's Unreal level-blockout guidance](https://dev.epicgames.com/documentation/en-us/unreal-engine/designer-01-project-setup-and-level-blockout-in-unreal-engine) (player-sized reference, 2-3 m halls and 3-4 m heights as starting guidance), the [2010 ADA Standards](https://www.ada.gov/law-and-regs/design-standards/2010-stds/) (915 mm clear walking width and larger passing/turning spaces), and [NPS Lehman Cave dimensions](https://www.nps.gov/grba/learn/nature/lehman-caves-dimensions.htm) (real chambers ranging from passages to 20 m-plus vertical volumes). These are sanity anchors, not claims that one universal ratio makes a good cave.
@@ -713,7 +720,7 @@ checks that question rather than assuming it.
| Tunnel reach | Neighboring-room horizontal jitter can be `1.7S` in each axis, hence `1.7S√2`; vertical center separation can be `2*R*RoomHeightRatio`. Derive `MaxTunnelLength` as at least the Euclidean sum of those bounds. This matches the generator’s distance gate. | `VoxelCaveMorphology.cpp:1388` and `:1502` reject out-of-reach links; implemented/validated at `VoxelStrateComposer.cpp:1557-1564` and `:3114-3121`. |
| Room vertical fit | `BoundarySealThickness + max(MaxRoomRadius,OriginRoomRadius)*RoomHeightRatio < H/2`, because room centers are restricted to the seal-free interval and the room Z radius is `Radius*RoomHeightRatio`. | `VoxelCaveMorphology.cpp:137-140` and `:248-250` establish the room/seal buffer; validated at `VoxelStrateComposer.cpp:3106-3112`. |
| Slab floor/ceiling | Worst floor is `H*FloorRelativeHeight + 1.25*FloorRoughness`; worst ceiling is `H*CeilingRelativeHeight - 1.25*CeilingRoughness`. Derive both relative heights from seal + clearance and require a two-voxel void. | `VoxelCaveMorphology.cpp:329-362` and `VoxelGenerator.cpp:2175-2212`; implemented/validated at `VoxelStrateComposer.cpp:1602-1626` and `:3143-3185`. |
| Maze corridor | Derive `CorridorRadius = CellSize*(0.12..0.24)` so it leaves lattice wall; cap roughness below `CorridorRadius+2` so the centerline remains carveable. | `VoxelCaveMorphology.cpp:428-458` bounds the lattice; `VoxelGenerator.cpp:2412-2432` applies roughness and subtracts `2*BaseDensity`; implemented/validated at `VoxelStrateComposer.cpp:1629-1642` and `:3188-3216`. |
| Maze corridor | For a 25 cm voxel, derive `3/0.7 = 4.286 m` clear diameter for two-abreast floor, hence an 8.571-voxel clear radius. Add the proven `SurfaceRoughness*VOXEL_NOISE_SCALE*1.5 = 3.75` voxels and round to `CorridorRadius=12.5`; require `CellSize=64` so the worst-case bore gap remains `64−2*(12.5+3.75)=31.5` voxels. | `VoxelCaveMorphology.cpp` bounds the lattice and applies the roughness-safe landing; `VoxelGenerator.cpp` applies roughness and subtracts `2*BaseDensity`; implemented/validated at `VoxelStrateComposer.cpp:1629-1642` and `:3188-3216`. |
| Surface ground/cap and height features | The structural height is bounded by `ElevationRange*(1+MountainStrength)+2*1.25*SurfaceRoughness`. Add conservative budgets for cliff displacement (`CliffStrength*CliffSharpness` times that spread), terrace step, layer depth, and beach width. Derive `BaseGroundRelative` above the bottom seal and `CeilingRelative` below the top seal with cap roughness, undulation, ridge, overhang height, and two-voxel headroom. Cliff is sampled as a scalar and reduced only when this derived height budget cannot contain it. | `VoxelGenerator.cpp:2470-2517` is the structural height equation; `:2537-2557` is the cliff remap; `:2607-2654` is the cap; `:2670-2686` caps overhang height. Implemented/validated at `VoxelStrateComposer.cpp:1645-1724` and `:3219-3269`. |
| Vertical shafts and ledges | Require `ShaftMaxRadius≤ShaftSpacing/2`; require `ShaftMinRadius > 1.5*1.25*SurfaceRoughness + 0.25`; derive `ConnectorRadius > 1.5*1.25*SurfaceRoughness + 1`; derive ledge spacing greater than `2*(LedgeDepth + floor-clearance)`. | `VoxelCaveMorphology.cpp:628-654` moves landings out of roughness/ledge bands; `VoxelGenerator.cpp:3944-3954` derives tree roughness reach and `:4209-4214` applies ledges; implemented/validated at `VoxelStrateComposer.cpp:1730-1755` and `:3272-3310`. |
| Floating islands | Require `IslandMaxRadius≤IslandSpacing/2`; derive island radius and thickness so `BoundarySealThickness + max(0.2R,ThicknessRatio*R) < H/2`; require the minimum radius to exceed roughness plus SDF blend. | `VoxelCaveMorphology.cpp:852-935` computes island radius, underside, warp, and blend pad; `VoxelGenerator.cpp:4268-4344` starts with void and fills the island; implemented/validated at `VoxelStrateComposer.cpp:1757-1780` and `:3313-3355`. |
@@ -963,7 +970,7 @@ cave design
(https://store.steampowered.com/news/posts/?appgroupname=Deep+Rock+Galactic&appids=548430&enddate=1729500950&feed=steam_community_announcements),
and Team Cherry map design (https://www.pcgamer.com/how-to-design-a-great-metroidvania-map/).
#### Final post-scale measurement (2026-09-05)
#### Historical post-scale measurement before the Maze spanning-tree correction (2026-09-05)
The new defaults are applied in `VoxelStrateTypes.h`; the production definition, season defaults, and
the owner-facing showcase use `StrateHeightInChunks=8` (64 m). The bounded test fixture remains at
@@ -1027,8 +1034,10 @@ The default derivations are not a global multiplier:
* FlatPlain/CrystalChamber: floor `0.25×256=64 vox=16 m`, ceiling `0.60×256=153.6 vox=38.4 m`,
leaving `22.4 m` before roughness. Columns are `8..16 vox` radii (4-8 m diameters) on 96-voxel
(24 m) cells. The two names intentionally retain the same slab generator and parameter block.
* Maze: `CellSize=64 vox=16 m`; `CorridorRadius=8 vox=2 m`, hence a 4 m bore; the corridor radius
is `8/32=0.25` of the half-cell, leaving a 6 m centre-to-wall radial margin before other geometry.
* Maze: `CellSize=64 vox=16 m`; `CorridorRadius=12.5 vox=3.125 m`, hence a 6.25 m nominal bore.
The 3.75-voxel proven roughness reach leaves an 8.75-voxel effective radius and
`0.7×(2×8.75×0.25)=3.0625 m` of usable floor; the worst-case neighbouring-bore wall is
`64−2×16.25=31.5 vox=7.875 m`.
* SurfaceWorld: `ElevationRange=80 vox=20 m`, base ground `0.25×256=16 m`, warp `48 vox=12 m`,
sky cap `0.95×256=243.2 vox=60.8 m`; terrace/layer/beach/overhang values are separately
2 m / 2 m / 3 m / 4 m reach + 6 m height optional features.
@@ -1669,6 +1678,75 @@ passed; the full `VoxelForge` namespace finished with **29 successes, 2 known wa
0 not-run** in 2,388.35 s. The warnings remain the gated lateral-region 9/16 law and the known
TunnelNetwork op-stack outside-gate sample count; box violations stayed zero.
### 12.2 2026-09-06 Maze spanning-tree correction
The Maze lattice no longer uses independent per-edge hash gating. Each non-origin cell chooses one
parent from the non-zero coordinate axes and steps one unit toward the origin. The parent reduces
`|x|+|y|+|z|` by exactly one, so the undirected parent edges form one infinite rooted tree: no cycle
can exist, and following parents reaches `(0,0,0)` from every cell. A canonical lower node plus
axis edge is emitted when either endpoint selects the other. Optional loop edges use capped hash
rolls (`0.18 × BranchProbability` horizontally and `0.10 × Verticality` vertically); they add
visual choice without carrying the connectivity guarantee.
This is locally computable. The evaluator's exact window is the eight lower nodes in
`{-1,0}³` (a 2×2×2 node window); the adjacent `+1` endpoint is one of the two endpoints of each
canonical edge, so its parent decision is still inside that same bounded window. The source and
op-stack cache use the identical predicate. No wide collect was needed, and no parent decision
depends on the stored window or chunk origin. `WorldRadiusVoxels` remains `0`; lateral regions
remain gated off.
The focused topology audit sampled **64 seeds** over a centred radius-10 graph (**9,261 cells per
seed**). With loops disabled, every graph had exactly `N−1` parent edges and **0 edge-count or
disconnected-cell violations**. With default loop settings, disconnected cells were also **0**.
The default sample added **103,297** optional loop edges in total. Repeating the same seed produced
bit-identical edge keys across **20,000** checks.
The output is measurably unlike the old cubic grid. Across **216,000 interior cells** and all
64 seeds, the degree distribution was `d0=0, d1=34,371 (15.9%), d2=79,046, d3=68,545,
d4=27,965, d5=5,531, d6=542`, with mean degree **2.504**. Maximal degree-2 corridor runs were
`n=464,360`, minimum **1**, median **1**, p90 **3**, maximum **18**. The cubic-grid control was
`d6=100%`, `d1=0%`, mean **6.000**. The tree therefore supplies real dead ends, varied runs,
and non-uniform junctions even before the optional loops are considered.
The seam test `VoxelForge.Generation.MazeSeamFreedom` passed **42/42 boundary probes** with
**0 legacy mismatches and 0 operator mismatches**. It explicitly records the 2×2×2 local window
and “no wide collect” contract.
The player-fit law is still not connected, and was not tuned toward. The pre-tree baseline at the
same style of fine measurement was **5,759 fit cells / 82 components / largest 1,217 / arrival 30 /
departure 1,217 / gap 121.598 voxels**, `NotConnectedAtThisResolution`. The new spanning-tree
showcase candidate (seed `0`, candidate `57`) measured **2,453 / 72 / 473 / arrival 100 /
departure 415 / gap 149.730 voxels**, also `NotConnectedAtThisResolution`; its coarse lattice
verdict was `Connected`. This separates the proven corridor-graph law from the remaining physical
player-fit problem: the gap is at the passage mouths/fine floor-resolution interaction (and must
not be “fixed” by raising edge probability), not evidence that the lattice graph is probabilistically
fragmented. The other archetypes retain their own independent fit findings.
The 3 m two-abreast radius derivation is explicit: `3/0.7 = 4.286 m` clear diameter, or an
8.571-voxel clear radius at 0.25 m/voxel. Add the proven roughness reach
`2×1.25×1.5 = 3.75 voxels`, giving 12.321 voxels; round up to `CorridorRadius=12.5`.
The effective floor diameter is `2×(12.5−3.75)×0.25 = 4.375 m`, so usable floor is
`0.7×4.375 = 3.0625 m`. With `CellSize=64`, worst-case neighbouring-bore wall is
`64−2×(12.5+3.75)=31.5 voxels = 7.875 m`.
Box audits stayed sound after adding guaranteed air: the existing Maze box audit proved **104
uniform boxes**, classified **152 Mixed**, sampled **138,424 voxels**, and found **0 violations**;
the novel fill audit covered **240 boxes** (`0 AllSolid`, `240 AllAir`) plus **16 Mixed** cases,
**319,440 voxels**, and **0 violations**. The focused audit measured **1.436 μs** per forced cache
rebuild and **0.150 μs/call** for 20,000 hot evaluations; the final namespace run measured
**1.130 μs** and **0.145 μs/call**, respectively, on the validation machine. Parent/loop work stays
in the thread-local rebuild.
The regenerated owner-facing artifact is
`E:/Projet Unreal/VoxelM/Saved/VoxelForge/Showcase/index.html`; the selected Maze card is
`candidate_1003_plan.png` (with matching `candidate_1003_vertical.png` and fine ROI variants).
The post-fix showcase rendered **6 fine views**, refused **2**, and produced **0 blank** cards.
The final full namespace verification is `Saved/AutomationReports/VoxelForgeFullMazeTreeFinal3/index.json`:
**32 tests, 30 clean successes, 2 warning-bearing successes, 0 failures, 0 not-run** in **2,523.978 s**.
The two warnings are the pre-existing gated lateral-region result and the known TunnelNetwork
outside-gate sample count; neither is a Maze or box-soundness failure.
### ⚠️ Correct the record: commit `1ab8c0c` overstated what worked
It reported *"3 checked, 48/48 ring samples air"* and read as Tier 1 working. A million-seed sweep later
showed the room query left the landing point **OUTSIDE the selected room 92.795% of the time**. The
@@ -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,14 +577,26 @@ 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,14 +2439,26 @@ 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) });
}
}
}
const float R = FMath::Max(Params.CorridorRadius, 0.5f);
float MazeSDF = FLT_MAX;
@@ -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;