diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7774760..66c4216 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/CODEMAP.md b/CODEMAP.md index b7d9cc1..4324c3f 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -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) diff --git a/COMPOSER-NOTES.md b/COMPOSER-NOTES.md index 796f55a..08f425a 100644 --- a/COMPOSER-NOTES.md +++ b/COMPOSER-NOTES.md @@ -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 diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeComposerShowcaseTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeComposerShowcaseTest.cpp index bc266a2..cace7bf 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeComposerShowcaseTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeComposerShowcaseTest.cpp @@ -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"); diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeMazeSeamTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeMazeSeamTest.cpp new file mode 100644 index 0000000..a9789c5 --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeMazeSeamTest.cpp @@ -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& 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(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& 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 LegacyProbes; + TArray 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 diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp index d41ffe0..ca8d713 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp @@ -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 @@ -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 EdgeKeys; + TArray 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(FMath::Min(A, B)); + const uint32 Hi = static_cast(FMath::Max(A, B)); + return (static_cast(Lo) << 32) | static_cast(Hi); + } + + void MazeGraphAddEdge(TArray>& Adjacency, TSet& 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> Adjacency; + Adjacency.SetNum(Out.NumNodes); + TSet Seen; + const uint32 Salt = static_cast(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 Visited; + Visited.Init(0, Out.NumNodes); + TArray 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 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& DegreeCounts, + const TArray& RunLengths) + { + int64 TotalDegree = 0; + int64 DegreeSum = 0; + for (int32 Degree = 0; Degree < DegreeCounts.Num(); ++Degree) + { + TotalDegree += DegreeCounts[Degree]; + DegreeSum += Degree * DegreeCounts[Degree]; + } + + TArray 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(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(DegreeSum) / static_cast(TotalDegree) : 0.0, + TotalDegree > 0 ? static_cast(DegreeCounts.IsValidIndex(1) ? DegreeCounts[1] : 0) + / static_cast(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 DegreeCounts; + DegreeCounts.Init(0, 8); + TArray 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(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(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(DegreeSum) / static_cast(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(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(CellX) + 0.37f) * PerfCellSize; + LastY = (static_cast(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(NumRebuildSamples), + NumHotSamples, + HotSeconds * 1.0e6 / static_cast(NumHotSamples), + static_cast(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); diff --git a/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp index 2c40122..b2ddaf9 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp @@ -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; diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeScaleDiagnosisTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeScaleDiagnosisTest.cpp index 2d4adcc..97e77f3 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeScaleDiagnosisTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeScaleDiagnosisTest.cpp @@ -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(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 diff --git a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp index 940638b..56012d9 100644 --- a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp +++ b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -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(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); } } } diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index cb3326c..45b4a50 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -424,14 +424,16 @@ namespace //========================================================================= // RÔLE 1 — SOURCE : COULOIRS SUR TREILLIS 3D / 3D LATTICE CORRIDORS //========================================================================= - // Chaque nœud du treillis est au centre d'une cellule ; l'arête vers son voisin +X/+Y/+Z est - // « ouverte » quand un hash de (nœud inférieur, axe) passe BranchProbability (Verticality pour - // Z). Le couloir est une capsule fine. + // Chaque nœud du treillis est au centre d'une cellule. Il choisit exactement un parent parmi + // les axes qui le rapprochent de l'origine ; l'arête parent abaisse donc |X|+|Y|+|Z| de un. + // Le résultat est un arbre couvrant de tout le treillis infini. Quelques arêtes supplémentaires + // sont hashées comme des boucles, avec des probabilités plafonnées qui ne participent jamais à + // la connectivité. Le couloir est une capsule. // - // ⚠️ LA propriété qui fait de Maze le bon premier portage : l'identité d'une arête est - // (nœud INFÉRIEUR, axe). Deux chunks adjacents calculent donc littéralement le même hash pour - // l'arête qu'ils partagent — ils NE PEUVENT PAS être en désaccord. Pas de cache de chunk, pas - // de région COLLECT, pas de discipline d'invariance de fenêtre à maintenir (AUDIT §6.4). + // La règle est locale et seam-safe : les huit nœuds-enfants du halo {-1,0}³ suffisent à émettre + // toutes les arêtes qui peuvent toucher la cellule évaluée. Chaque décision est une fonction + // pure du nœud, de l'axe et de la seed ; il n'y a ni collect global ni dépendance à la fenêtre + // de chunk. class FLatticeCorridorSource final : public IVoxelDensityOp { public: @@ -530,13 +532,6 @@ namespace return FVector((X + 0.5f) * CellSize, (Y + 0.5f) * CellSize, (Z + 0.5f) * CellSize); } - bool EdgeOpen(int32 X, int32 Y, int32 Z, uint32 AxisSalt, float Threshold) const - { - uint32 H = VoxelHash::Cell(X, Y, Salt ^ AxisSalt); - H ^= VoxelHash::Mix((uint32)(Z * 73856093) ^ AxisSalt); - return VoxelHash::ToFloat01(VoxelHash::Mix(H)) < Threshold; - } - // Sur-approximation volontaire : boîte englobante du segment contre la boîte élargie. // Un test capsule/AABB exact serait plus serré ; il coûterait plus cher pour un gain nul // ici, car la réponse ne sert qu'à un rejet grossier par tuile. @@ -548,9 +543,10 @@ namespace } /** - * Le cache par CELLULE, repris tel quel de GetMazeDensity. Il est `thread_local` et non + * Le cache par CELLULE, partagé avec le même contrat que GetMazeDensity. Il est `thread_local` et non * membre parce que la pile est PARTAGÉE entre workers en lecture — un membre mutable serait - * une course. C'est aussi exactement ce que fait le code d'aujourd'hui. + * une course. La reconstruction seule inspecte le petit voisinage ; Eval ne refait aucune + * décision de parent ou de boucle. * * ⚠️ PHASE 3 : quand les opérateurs deviendront des assets partagés, il faudra un objet * d'état PAR WORKER plutôt que ce `thread_local` (qui est global à la fonction, donc partagé @@ -571,7 +567,9 @@ namespace MZ_Branch = BranchProbability; MZ_Vert = Verticality; MZ_Edges.Reset(); - // Nodes in {-1,0} per axis cover every edge that can reach this voxel's cell. + // Canonical lower-node edges in {-1,0} per axis cover every corridor that can + // reach this voxel's cell. IsOpenEdge checks both endpoints, including a +1 + // node's parent choice, without needing a wider cache window. for (int32 dz = -1; dz <= 0; dz++) for (int32 dy = -1; dy <= 0; dy++) for (int32 dx = -1; dx <= 0; dx++) @@ -579,12 +577,24 @@ namespace const int32 nx = Cell.X + dx, ny = Cell.Y + dy, nz = Cell.Z + dz; const FVector A = NodeCenter(nx, ny, nz); - if (EdgeOpen(nx, ny, nz, 0xA1u, BranchProbability)) + if (VoxelMazeTopology::IsOpenEdge( + nx, ny, nz, VoxelMazeTopology::EAxis::X, + Salt, BranchProbability, Verticality)) + { MZ_Edges.Add({ A, NodeCenter(nx + 1, ny, nz) }); - if (EdgeOpen(nx, ny, nz, 0xB2u, BranchProbability)) + } + if (VoxelMazeTopology::IsOpenEdge( + nx, ny, nz, VoxelMazeTopology::EAxis::Y, + Salt, BranchProbability, Verticality)) + { MZ_Edges.Add({ A, NodeCenter(nx, ny + 1, nz) }); - if (EdgeOpen(nx, ny, nz, 0xC3u, Verticality)) + } + if (VoxelMazeTopology::IsOpenEdge( + nx, ny, nz, VoxelMazeTopology::EAxis::Z, + Salt, BranchProbability, Verticality)) + { MZ_Edges.Add({ A, NodeCenter(nx, ny, nz + 1) }); + } } } return MZ_Edges; @@ -1293,14 +1303,15 @@ namespace // en float. Passer directement des floats saute cet aller-retour, et sous /fp:fast // les deux chemins ne s'arrondissent pas au même endroit : ~1 ULP d'écart sur le SDF, // qui ressort en 1 ULP sur la densité finale. Reproduire le détour, c'est reproduire - // l'arrondi. HYPOTHÈSE NON ENCORE VÉRIFIÉE : elle prédit que MazeEquivalence passe de - // 454 écarts à 0. Si le prochain run montre encore des écarts, c'est que la divergence - // vient d'ailleurs (candidat suivant : contraction FMA entre unités de compilation). + // l'arrondi. VERIFIE par MazeEquivalence : le détour conserve l'identité binaire avec + // GetMazeDensity (0 écart sur 20,000 échantillons). Si cela change, le candidat suivant + // est une contraction FMA entre unités de compilation. // - // THE FVector ROUND-TRIP IS DELIBERATE — do not "simplify" it. The original goes - // float -> double (FVector is double in UE5) -> float; going straight through floats - // skips a rounding step, and under /fp:fast the two paths round in different places. - // Reproducing the detour reproduces the rounding. + // THE FVector ROUND-TRIP IS DELIBERATE — do not "simplify" it. MazeEquivalence + // verifies the resulting path against GetMazeDensity (0 differences / 20,000 samples). + // The original goes float -> double (FVector is double in UE5) -> float; going straight + // through floats skips a rounding step, and under /fp:fast the two paths round in + // different places. Reproducing the detour reproduces the rounding. const FVector NoisePos(WorldX * Frequency, WorldY * Frequency, WorldZ * Frequency); InOut.Sdf += VoxelNoise::FBM((float)NoisePos.X, (float)NoisePos.Y, (float)NoisePos.Z, VoxelGenLOD::Eff(BaseOctaves), 2.0f, 0.5f) diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 05d3e44..5a915bf 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -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> MZ_Edges; thread_local FIntVector MZ_Cell(INT32_MAX, INT32_MAX, INT32_MAX); @@ -2431,15 +2430,8 @@ float UVoxelGenerator::GetMazeDensity(float WorldX, float WorldY, float WorldZ, { return FVector((X + 0.5f) * CS, (Y + 0.5f) * CS, (Z + 0.5f) * CS); }; - // Deterministic hash of a 3D lattice edge, keyed on its lower node + axis salt. - auto EdgeOpen = [S](int32 X, int32 Y, int32 Z, uint32 AxisSalt, float Threshold) -> bool - { - uint32 H = VoxelHash::Cell(X, Y, S ^ AxisSalt); - H ^= VoxelHash::Mix((uint32)(Z * 73856093) ^ AxisSalt); - return VoxelHash::ToFloat01(VoxelHash::Mix(H)) < Threshold; - }; - - // Nodes in {-1,0} per axis cover every edge that can reach this voxel's cell. + // Canonical lower-node edges in {-1,0} per axis cover every corridor that can reach this + // voxel's cell. IsOpenEdge checks both endpoints, including a +1 node's parent choice. for (int32 dz = -1; dz <= 0; dz++) for (int32 dy = -1; dy <= 0; dy++) for (int32 dx = -1; dx <= 0; dx++) @@ -2447,12 +2439,24 @@ float UVoxelGenerator::GetMazeDensity(float WorldX, float WorldY, float WorldZ, const int32 nx = CX + dx, ny = CY + dy, nz = CZ + dz; const FVector A = NodeCenter(nx, ny, nz); - if (EdgeOpen(nx, ny, nz, 0xA1u, Params.BranchProbability)) + if (VoxelMazeTopology::IsOpenEdge( + nx, ny, nz, VoxelMazeTopology::EAxis::X, + S, Params.BranchProbability, Params.Verticality)) + { MZ_Edges.Add({ A, NodeCenter(nx + 1, ny, nz) }); - if (EdgeOpen(nx, ny, nz, 0xB2u, Params.BranchProbability)) + } + if (VoxelMazeTopology::IsOpenEdge( + nx, ny, nz, VoxelMazeTopology::EAxis::Y, + S, Params.BranchProbability, Params.Verticality)) + { MZ_Edges.Add({ A, NodeCenter(nx, ny + 1, nz) }); - if (EdgeOpen(nx, ny, nz, 0xC3u, Params.Verticality)) + } + if (VoxelMazeTopology::IsOpenEdge( + nx, ny, nz, VoxelMazeTopology::EAxis::Z, + S, Params.BranchProbability, Params.Verticality)) + { MZ_Edges.Add({ A, NodeCenter(nx, ny, nz + 1) }); + } } } diff --git a/Source/VoxelForge/Private/VoxelStrateComposer.cpp b/Source/VoxelForge/Private/VoxelStrateComposer.cpp index 3f11d12..9248017 100644 --- a/Source/VoxelForge/Private/VoxelStrateComposer.cpp +++ b/Source/VoxelForge/Private/VoxelStrateComposer.cpp @@ -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(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 diff --git a/Source/VoxelForge/Public/VoxelCaveMorphology.h b/Source/VoxelForge/Public/VoxelCaveMorphology.h index 552ad6c..1925dcd 100644 --- a/Source/VoxelForge/Public/VoxelCaveMorphology.h +++ b/Source/VoxelForge/Public/VoxelCaveMorphology.h @@ -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(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(H % static_cast(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 //============================================================================= diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index 913b333..fccc6c8 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -292,9 +292,10 @@ namespace VoxelDensityOps VOXELFORGE_API TUniquePtr 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. */ diff --git a/Source/VoxelForge/Public/VoxelGenerator.h b/Source/VoxelForge/Public/VoxelGenerator.h index 2c85e07..451ba67 100644 --- a/Source/VoxelForge/Public/VoxelGenerator.h +++ b/Source/VoxelForge/Public/VoxelGenerator.h @@ -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; diff --git a/Source/VoxelForge/Public/VoxelStrateTypes.h b/Source/VoxelForge/Public/VoxelStrateTypes.h index 9eab7d0..29acd83 100644 --- a/Source/VoxelForge/Public/VoxelStrateTypes.h +++ b/Source/VoxelForge/Public/VoxelStrateTypes.h @@ -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;