diff --git a/CODEMAP.md b/CODEMAP.md index 9b048a4..7deaf2b 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -117,10 +117,12 @@ remains `0`, and lateral regions remain gated off. |------|------| | `../../VoxelForge.uplugin` | Plugin manifest. Runtime module `VoxelForge` plus the Editor-only `VoxelForgeEditor` commandlet module. Beta. | | `VoxelForge.Build.cs` | Deps: Core, CoreUObject, Engine, **GameplayTags**, **RealtimeMeshComponent**. | -| `Tools/VoxelForgeTest.ps1` | The single guarded test entry point. `-Scenario canonical|owner|probe|perf|tests|surface-fall|crossing|gate-stress|visual-edit|visual-lod|visual-gap-walk|visual-gap-inject|dig-motion|parity|horizon|horizon-render|determinism|region-eval`, optional `-Build`, `-Assets owner|default`, `-Cvars`, absolute `-Out`, `-Label`, `-HorizonLevel`, `-HorizonRenderDistanceChunks`, `-ParityExportSize=32|64|128`, `-ParityExportTile=x,y,z`, `-SharedDDC`, `-SampleDensityStacks`, `-StackSampleIntervalUs`, and `-RegionHashProbe -RegionWorkers N -RegionReferenceHash HASH`; stages the host/assets, isolates user/DDC/config state, serializes one Unreal launch at a time, enforces the 8 GiB/30-minute guards, waits for 6 GiB free RAM before launches, and emits compact `result.json`/`summary.txt` plus full logs. Parity can render its game leg with `-EnableRender` and compares the selected export region against the exact streamed tile hashes. `visual-gap-walk` records camera-frustum surface-point coverage at 6000 cm/s; `visual-gap-inject` proves an early component hide is detected; `dig-motion` logs per-frame vertical velocity, gate, support readiness, and movement mode. `-Cvars @{ 'voxel.TestGapAuditLegacyHandoff'=1 }` replays the old visibility handoff for A/B audit baselines. Horizon traces report desired/loaded tiles, resident stream geometry, worker seconds, steady wall time, and peak private memory; `horizon-render` captures the owner view; `determinism` compares per-tile hashes across the default CRT path and `-voxel.CrtFma3=0`; `region-eval` compares the live path with the default-off editor chunk program on the 259³ lattice and five-seed probe set, reporting whole-ring setup/candidate counters; `-RegionHashProbe -RegionWorkers N -RegionReferenceHash HASH` checks a full-ring hash at another partition count. `-SampleDensityStacks` captures profiles, and `-StackSampleIntervalUs` sets the sampling interval. +| `Tools/VoxelForgeTest.ps1` | The single guarded test entry point. `-Scenario canonical|owner|probe|perf|tests|surface-fall|crossing|gate-stress|visual-edit|visual-lod|visual-gap-walk|visual-gap-inject|dig-motion|parity|horizon|horizon-render|determinism|region-eval|step2d`, optional `-Build`, `-Assets owner|default`, `-Cvars`, absolute `-Out`, `-Label`, `-HorizonLevel`, `-HorizonRenderDistanceChunks`, `-ParityExportSize=32|64|128`, `-ParityExportTile=x,y,z`, `-SharedDDC`, `-SampleDensityStacks`, `-StackSampleIntervalUs`, and `-RegionHashProbe -RegionWorkers N -RegionReferenceHash HASH`; stages the host/assets, isolates user/DDC/config state, serializes one Unreal launch at a time, enforces the 8 GiB/30-minute guards, waits for 6 GiB free RAM before launches, and emits compact `result.json`/`summary.txt` plus full logs. Parity can render its game leg with `-EnableRender` and compares the selected export region against the exact streamed tile hashes. `visual-gap-walk` records camera-frustum surface-point coverage at 6000 cm/s; `visual-gap-inject` proves an early component hide is detected; `dig-motion` logs per-frame vertical velocity, gate, support readiness, and movement mode. `-Cvars @{ 'voxel.TestGapAuditLegacyHandoff'=1 }` replays the old visibility handoff for A/B audit baselines. Horizon traces report desired/loaded tiles, resident stream geometry, worker seconds, steady wall time, and peak private memory; `horizon-render` captures the owner view; `determinism` compares per-tile hashes across the default CRT path and `-voxel.CrtFma3=0`; `region-eval` compares the live path with the default-off editor chunk program on the 259³ lattice and five-seed probe set; `step2d` measures the independent full-detail research field, sound 8³ brick proofs, detail/gradient controls, slices, and worker hashes. `-RegionHashProbe -RegionWorkers N -RegionReferenceHash HASH` checks a full-ring hash at another partition count. `-SampleDensityStacks` captures profiles, and `-StackSampleIntervalUs` sets the sampling interval. | `Public/VoxelForgeModule.h` / `Private/VoxelForgeModule.cpp` | `FVoxelForgeModule` boilerplate (Startup/Shutdown just log). | -| `Source/VoxelForgeEditor/VoxelForgeEditor.Build.cs` / `Private/VoxelForgeExploreCommandlet.*` | Editor/commandlet-only `-run=VoxelForgeExplore`; owns bounded render/`VF_` walk/OBJ export orchestration and is not linked by Game/Shipping. Optional `-exporttilex/y/z` sets an exact L0 tile origin for controlled export/game parity; omitting all three preserves the existing center-derived origin. `-regioneval=1` runs the default-off step-2 research harness: full-ring and five-seed Q16 comparisons, repeat hashes, shrunk-reach falsification, cold timings, full-ring setup/candidate counters, counted live fallbacks, and separate before/after stack captures when `-SampleDensityStacks` is set. `-RegionHashProbe` runs one full-ring chunk evaluation and compares its raw float hash with a reference from another worker count. | -| `Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.*` | Lattice driver with six workers by default for the commandlet-only region experiment. The live path calls `GetDensityAt` per point; the prepared path assigns each 32³ chunk once and calls editor-only `EvaluateDensityChunk`, which seeds one block evaluation and runs canonical final-field writers across that chunk. The prepared room/SDF cache window is aligned to that chunk; 8-voxel SDF candidate bins stay editor-only and default off. Unsupported routes use counted scalar fallbacks. Full-ring output reports room/SDF cache, active-op-list, and broad-phase/index builds; general-path entries and fallbacks; candidate visits/evaluations per point; and fresh sampled attribution. The full-ring hash probe supports 1–32 fixed partitions to check determinism across worker counts. | +| `Source/VoxelForgeEditor/VoxelForgeEditor.Build.cs` / `Private/VoxelForgeExploreCommandlet.*` | Editor/commandlet-only `-run=VoxelForgeExplore`; owns bounded render/`VF_` walk/OBJ export orchestration and is not linked by Game/Shipping. Optional `-exporttilex/y/z` sets an exact L0 tile origin for controlled export/game parity; omitting all three preserves the existing center-derived origin. `-regioneval=1` retains the exact-field step-2c research harness. `-step2d` runs the independent field benchmark without old-world equality gates and writes timing, detail, boundary, determinism, triangle, and side-by-side slice evidence under `Saved/Research/step2d`. | +| `Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.*` | Six-worker lattice drivers for step 2c and step 2d. The original path still compares live `GetDensityAt` with the editor chunk program. Step 2d evaluates the independent field and assigns contiguous batches of 8³ bricks dynamically so surface-heavy work balances across workers. It first proves bricks uniform using a macro Lipschitz bound plus the full detail-amplitude bound; uncertain bricks retain all 25 cm detail samples. Timings split classification, point evaluation, and uniform fill; hashes compare workers 1 and 6. | +| `Public/VoxelRedefinedDensityField.h` / `Private/VoxelRedefinedDensityField.cpp` | Default-off `WITH_EDITOR` research field, selected by `voxel.ResearchRedefinedDensityField`. A seeded connected room/corridor lattice joins authored flat passage landings; four bounded world-space value-noise bands, including a one-sample micro band, are evaluated at full resolution. Skip proofs refuse edits, passage bounds, strate boundaries, and uncertain bricks; a local derivative bound permits sound proofs through landing-floor blends. The public API adds no reflected class layout. | +| `Private/Tests/VoxelForgeRedefinedDensityFieldTest.cpp` | Three-seed functional sweep for the research field: authored passage endpoints must bracket open air and solid floor, and each tested strate must have a walkable arrival-to-exit route. | | `Public/VoxelGeometryHash.h` | Header-only geometry digest shared by commandlet export and runtime tile-hash dump; hashes mesh counts and the vertex/normal/UV/color/triangle arrays with the historical CRC order. | | `Public/VoxelStats.h` / `Private/VoxelStats.cpp` | `stat VoxelForge` DWORD counters for tile classification, skipping, meshing, operator-stack verdicts, and cave-bail diagnosis. Each `Cave Bail *` counter names one guard; `Cave Bail No Stack` counts slots with no buildable stack (no definition, or a degenerate strate). | diff --git a/Saved/Research/step2d/step2d_launch_ledger.md b/Saved/Research/step2d/step2d_launch_ledger.md new file mode 100644 index 0000000..fdaf777 --- /dev/null +++ b/Saved/Research/step2d/step2d_launch_ledger.md @@ -0,0 +1,60 @@ +# Step 2d launch ledger + +All Unreal processes were launched through `Tools/VoxelForgeTest.ps1` and ran serially. The +guarded launches used below-normal priority, the shared persistent DDC where recorded, +`-MaxParallelActions=4`, a prelaunch 6 GiB memory check, an 8 GiB per-process limit, and a +30-minute timeout. The retained ledgers show **no crash reporters, no process above 8 GiB, and +no timeout**. Exit 255 below means automation assertions failed; it was not a native crash. + +## Benchmark attempts + +| Label | Build | Unreal process | Result | +|---|---|---|---| +| `step2d_rebuild_01` | UBT PID 25780, exit 6, peak 2.71 GiB | Not launched | Compile failed on the `ApplicationContextMask` automation flag; corrected in the next source pass. | +| `step2d_rebuild_02` | UBT PID 19420, passed, peak 1.57 GiB | PID 13996, exit 2, peak 1.83 GiB | 3.834 s optimized ring; brick/sample skipping was too low for the speed gate. | +| `step2d_rebuild_03` | Passed; PID not retained in the compact log | PID 15480, exit 2, peak 1.82 GiB | 3.918 s; detail spectrum failed. The smoothing negative control worked; the proof negative control did not. | +| `step2d_rebuild_04` | Not launched | Not launched | Harness rejected a relative `-Out` path before Unreal startup. | +| `step2d_rebuild_05` | UBT PID 23904, passed | PID 24708, exit 2, peak 1.82 GiB | Scratch reuse gave 233.27 density mismatches and hash/boundary failures; scratch ownership was corrected. | +| `step2d_rebuild_06` | UBT PID 7540, passed | PID 25828, exit 2, peak 1.82 GiB | Detail, smoothing, boundary, and hash checks passed, but 3.70 s and 1.46% sample skipping missed the speed gate. | +| `step2d_rebuild_07` | UBT PID 23800, passed | PID 5208, exit 2, peak 1.81 GiB | 3.66 s; the cached XY macro did not improve the timing. | +| `step2d_rebuild_08` | UBT PID 25304, passed | PID 7108, exit 2, peak 1.81 GiB | 2.62 s; 22.20% of bricks and 21.18% of samples skipped, still over target. | +| `step2d_rebuild_09` | UBT PID 25612, passed, peak 1.51 GiB | PID 24256, exit 0, peak 1.82 GiB | Superficially under 600 ms, but invalid as evidence: classifier gap/default/chunk semantics had not yet been fixed. Excluded. | +| `step2d_harness_10` | UBT PID 2544, passed, peak 1.49 GiB | PID 23204, exit 0, peak 1.84 GiB | 503.026 ms and hash/detail/boundary checks passed. Superseded because its deliberately undersized-bound control did not catch a wrong sign. | +| `step2d_harness_11` | UBT PID 2900, passed, peak 1.50 GiB | PID 10688, exit 0, peak 1.83 GiB | 497.518 ms; the scale-0 versus scale-4 undersized-bound control caught 35,847 wrong-sign samples. | +| `step2d_harness_12` | UBT PID 13868, passed, peak 1.87 GiB | PID 16872, exit 0, peak 1.81 GiB | Final dynamically scheduled run: 308.177 ms median, all hashes and research gates passed. | + +## Functional runs + +| Label | Build | Unreal process | Result | +|---|---|---|---| +| `step2d_functional_01` | UBT PID 21184, passed | PID 3444, exit 255, peak 3.26 GiB | Base connectivity passed; refinement failed on an endpoint self-query. The launcher detected a write under `C:\Users\Jahni\AppData\Local\UnrealEngine`; later runs used isolated per-run local data. | +| `step2d_functional_02` | UBT PID 11844, passed, peak 1.87 GiB | PID 5048, exit 255, peak 3.13 GiB | Same endpoint-snap failure; isolated LocalAppData was clean. | +| `step2d_functional_03` | UBT PID 2060, passed, peak 1.90 GiB | PID 25648, exit 255, peak 3.19 GiB | Origin probe compared raw density 8 with a coarse center at -3.534534 near Z=-1012. | +| `step2d_functional_04` | Failed launch; retained PID 23996, stage split not retained | Exit 255; peak not retained | Range-normalization fix did not resolve the origin probe. No crash was recorded. | +| `step2d_redefined_seeds_01` | Build passed; PID not retained | PID 21888, exit 255 | Initial multi-seed endpoint-snap expectation was stricter than the route's full-density segment check; expectation was revised. | +| `step2d_functional_05` | UBT PID 17000, passed | PID 7540, exit 255 | 12/16 fitted VerticalShafts seeds passed. | +| `step2d_functional_06` | Build passed; PID not retained | PID 21948, exit 255, peak 3.43 GiB | 12/16; seed 1337's physical arrival/departure audit found 13 bad samples of 15. | +| `step2d_redefined_seeds_02` | UBT PID 14672, exit 0, peak 1.63 GiB | PID 2220, exit 0, peak 2.61 GiB | Passed an earlier three-seed traversal version. | +| `step2d_functional_07` | Build/Unreal stage PID 15712 in retained summary | Exit 255, peak 3.42 GiB | 12/16; no native crash. | +| Relative-output-path prelaunch attempt | Not launched | Not launched | Harness stopped before Unreal startup; corrected to an absolute output directory. | +| `step2d_functional_08` | UBT PID 12596, passed, peak 1.87 GiB | PID 20624, exit 255, peak 3.42 GiB | 13/16; origin path passed, three measurement limits remained. | +| `step2d_functional_09` | UBT PID 10848, passed, peak 1.50 GiB | PID 16764, exit 255, peak 3.46 GiB | 13/16 after the cache-key fix. | +| `step2d_functional_10` | UBT PID 24704, passed, peak 1.44 GiB | PID 7512, exit 255, peak 3.56 GiB | 13/16 after connector-radius changes. | +| `step2d_functional_11` | UBT PID 17088, exit 6, peak 1.96 GiB | Not launched | Compile error C4456 from a duplicated `XYCount`; fixed before the next build. | +| `step2d_functional_12` | UBT PID 4760, passed, peak 1.95 GiB | PID 26520, exit 255, peak 3.49 GiB | 13/16 after explicit bridges. | +| `step2d_functional_13` | UBT PID 19416, passed, peak 1.50 GiB | PID 26444, exit 255, peak 3.50 GiB | 13/16; diagnostics showed 16 retries and both endpoints in the same large component. | +| `step2d_functional_14` | UBT PID 3156, passed, peak 1.50 GiB | PID 17892, exit 255, peak 3.49 GiB | 13/16 after widening the landing bridge. | +| `step2d_functional_15` | UBT PID 5648, passed, peak 1.49 GiB | PID 23788, exit 255, peak 3.53 GiB | 14/16 after adding the vertical cavern band. | +| `step2d_functional_16` | UBT PID 20028, passed, peak 1.13 GiB | PID 4916, exit 255, peak 3.60 GiB | Controlled-wall assumptions and the 14/16 refinement audit both failed. | +| `step2d_functional_17` | UBT PID 14952, passed, peak 1.50 GiB | PID 2604, exit 0, peak 2.69 GiB | Three-seed traversal test passed: 21 landing brackets and 18 routes. | +| `step2d_functional_18` | UBT PID 10496, passed, peak 1.49 GiB | PID 25636, exit 0, peak 2.70 GiB | Passage-open-space test passed. | +| `step2d_functional_19` | UBT PID 22588, passed, peak 1.48 GiB | PID 7316, exit 255, peak 3.71 GiB | Controlled wall returned conservative unknown after zero retries; refinement remained 14/16. | +| `step2d_functional_20` | UBT PID 14076, passed, peak 1.88 GiB | PID 23120, exit 255, peak 3.60 GiB | Wall samples were -2/-102/-2, so the chosen location was not an air/solid/air control; refinement remained 14/16. | +| `step2d_functional_21` | UBT PID 8224, passed, peak 1.58 GiB | PID 25652, exit 255, peak 3.67 GiB | `StrateConnectivity` passed; refinement remained 14/16 because the fallback had been placed in a skipped diagnostic branch. | +| `step2d_functional_22` | UBT PID 26528, passed, peak 1.62 GiB | PID 24396, exit 0, peak 3.68 GiB | Both required connectivity tests passed; the fitted audit directly confirmed 16/16 routes at step 2. | +| `step2d_functional_23` | Build not requested | PID 15176, exit 0, peak 2.44 GiB | `PassageLandsInOpenSpace` passed. | +| `step2d_functional_24` | Build not requested | PID 26336, exit 0, peak 2.42 GiB | `RedefinedFieldTraversalSeeds` passed across three seeds. | + +The manual `Win32_OperatingSystem` memory probe was denied once before a launch; no Unreal process +was started by that probe. The harness's own native memory preflight succeeded on subsequent +launches. No files outside the workspace were deleted. diff --git a/Saved/Research/step2d/step2d_rebuild_12/slice_xy_center.png b/Saved/Research/step2d/step2d_rebuild_12/slice_xy_center.png new file mode 100644 index 0000000..a3ac998 Binary files /dev/null and b/Saved/Research/step2d/step2d_rebuild_12/slice_xy_center.png differ diff --git a/Saved/Research/step2d/step2d_rebuild_12/slice_xz_passage.png b/Saved/Research/step2d/step2d_rebuild_12/slice_xz_passage.png new file mode 100644 index 0000000..f06d711 Binary files /dev/null and b/Saved/Research/step2d/step2d_rebuild_12/slice_xz_passage.png differ diff --git a/Saved/Research/step2d/step2d_rebuild_12/slice_xz_y0.png b/Saved/Research/step2d/step2d_rebuild_12/slice_xz_y0.png new file mode 100644 index 0000000..8a6047d Binary files /dev/null and b/Saved/Research/step2d/step2d_rebuild_12/slice_xz_y0.png differ diff --git a/Saved/Research/step2d/step2d_rebuild_12/slice_yz_x0.png b/Saved/Research/step2d/step2d_rebuild_12/slice_yz_x0.png new file mode 100644 index 0000000..34afc15 Binary files /dev/null and b/Saved/Research/step2d/step2d_rebuild_12/slice_yz_x0.png differ diff --git a/Saved/Research/step2d/step2d_report.md b/Saved/Research/step2d/step2d_report.md new file mode 100644 index 0000000..2f5561d --- /dev/null +++ b/Saved/Research/step2d/step2d_report.md @@ -0,0 +1,131 @@ +# Rebuild step 2d research report + +Date: 2026-09-30 +Branch: `rebuild/step2d-redefined` +Scope: `Plugins/VoxelForge` + +## Result + +The editor-only research field passes the 259³, 25 cm ring target on six workers. Its final +median is **308.177 ms** across three runs (295.325–317.807 ms), below both the 600 ms step-2 +bar and the 350 ms step-3 target. The old field measured 8,900.965 ms median on the same ring. +Preparation, including the 135.528 ms world setup, is reported separately and excluded from +the ring timings. + +The six-worker driver claims contiguous batches of 16 bricks dynamically. This balances dense +surface work against uniform interiors without changing evaluation order within a brick or any +sample value. + +The old and new density fields intentionally differ. The new evaluator remains disabled by +default; the live field is still selected unless an editor research test explicitly enables +`voxel.ResearchRedefinedDensityField`. + +## Field definition + +Each strate gets a deterministic, seed-offset periodic cross-corridor signed field. Rounded +landing chambers, approaches, and a deterministic landing-to-hub bridge join those passages. +Authored passage carving and floor geometry remain active. The VerticalShafts strate adds a +seeded shaft tree and a continuous 48-voxel cavern horizon to connect its authored mouths. + +Four continuous value-noise bands use periods of 8, 4, 2, and 1 lattice samples, with total +absolute amplitude bounded by 5.25 density units. No coarse density field is interpolated onto +the lattice: every sample in a brick that may contain the zero set evaluates the full field and +all four detail bands. Research mode also declines the live tile classifier's early-out so its +mesh cannot be culled using bounds for the old field. + +## Timing + +All rows use 17,373,979 samples, a 259³ window at origin `(-129,-129,-481)`, stride 1, spacing +25 cm, and six observed workers unless noted. + +| Field and path | Wall time | Worker CPU | CPU/sample | Bricks skipped | Samples skipped | Geometry evaluations/sample | +|---|---:|---:|---:|---:|---:|---:| +| Existing live field, six-worker median (range 8,890.120–8,923.020 ms) | 8,900.965 ms | 37.70 s | 2,170.09 ns | 0% | 0% | 6.734 | +| Redefined field, all points evaluated, six workers | 467.212 ms | 2.313 s | 133.10 ns | 0% | 0% | 1.000 | +| Redefined field, sound 8³ proofs, six-worker median (range 295.325–317.807 ms) | **308.177 ms** | 1.41 s | 80.94 ns | 60.54% | 63.06% | 0.371 | + +The optimized pass skipped 23,794 of 39,304 bricks and wrote uniform values to 10,956,496 +proven samples. It evaluated full density at the remaining 6,417,483 samples. The 0.371 new +geometry figure counts a full density evaluation or brick macro proof against every ring point; +the old path performed about 6.734 room/tunnel/passage primitive evaluations per point. Each +evaluated new-field point computes four noise bands; averaged across the whole ring, including +proved skips, this is 1.477 noise-band evaluations per sample. + +The final run's accumulated worker-phase timers were 1.79 s for full density evaluation, 0.04 s +for uniform fills, and 0.01 s for brick proofs. Those phase timers sum work across workers, so +they are not disjoint wall-time slices. The longest worker took 307.76 ms; measured parallel +overhead after that was 0.42 ms. The remaining wall time is in full-detail evaluation of the +6.42 million nonuniform samples. + +## Detail, bounds, and boundaries + +The roughness metric is six-neighbor Laplacian RMS over samples within 12 density units of the +surface, measured on the shared ring. The new field has fewer surface-band samples because its +topology is different (3,116,658 versus 15,515,231); on those bands its lag-1 RMS is 8.978 versus +6.248 for the old field, and lag-2 RMS is 9.169 versus 8.646. A deliberately smoothed new-field +control (detail scale 0) falls to 1.315 and 1.933. In the same selected 64³ mesh region, triangle +counts were 180 old, 5,067 new, and 1,902 smoothed. The metric and mesh both detected the +smoothing control. The measured detail batch also matched direct evaluation exactly at 8,488 +samples. + +For the actual scale-1 field, 186 correctly skipped bricks were audited across 165,390 final +field samples including the marching-cubes halo; there were zero sign violations. The bound +includes the full 5.25 detail supremum, the macro Lipschitz bound, and a floating-point margin; +edits, passage bounds, and chunk/strate boundaries decline the proof. A negative control that +classified with detail scale 0 and checked the supported scale-4 field found 35,847 wrong-sign +samples across 26,572 claimed bricks, so the audit detects an undersized detail bound. + +Near-surface gradient-jump RMS was 3.420 at 8³ brick boundaries versus 8.694 in brick interiors, +3.423 at chunk boundaries, and 3.225 at 128-sample macro-cell boundaries versus 10.036 in those +cell interiors. These checks found no boundary spike consistent with stair-stepping. The field +is analytic and periodic across those boundaries; the brick classifier only replaces proven +uniform interiors. + +## Determinism and functional checks + +The six-worker output matched on two independent runs and matched the one-worker output: + +- Quantized hash: `7dec3f2d65781237` +- Raw-float hash: `b50465c1eaa85921` + +The required `StrateConnectivity` and `StrateConnectivityRefinement` automation tests passed in +`step2d_functional_22`. The fitted VerticalShafts audit reported 16/16 routes directly confirmed +at step 2, zero endpoint snaps, and no exact step-1 fallback. Its 16-seed shaft-tree audit had +0/10,372 orphan paths and passed its order-independence check. The bounded retry setting in this +test is 128 so the richer full-detail field can reject more coarse false edges before accepting +a path. + +`PassageLandsInOpenSpace` passed in `step2d_functional_23` for its seven-passage fixture. +`RedefinedFieldTraversalSeeds` passed in `step2d_functional_24`: over three seeds, 21 authored +passage landings bracketed open air and 18 arrival-to-exit walking routes were checked; six +routes used a resolved coarse endpoint only after a full-density segment check. + +## Slices + +Each image places the old field on the left and the redefined field on the right. Light is air, +dark is solid, and cyan marks the near-zero surface. + +- [Horizontal slice through the ring center]() +- [Vertical slice at Y=0]() +- [Vertical slice at X=0]() +- [Vertical slice through an authored passage]() + +Machine-readable measurements are in [step2d_report.json](). + +## Scaling to 12.5 cm and compile risk + +At 12.5 cm, double macro lengths and broad/medium noise periods in lattice units to preserve +their world-space shape: the current noise periods 8/4/2/1 become 16/8/4/2 samples. Keep or add +a 1-sample fine band so detail stays at least as rich as the 25 cm version. For a fixed world +region, the lattice grows 8×, while surface-intersecting 8³ bricks should grow closer to 4× +(surface area); remaining bricks still need only a cheap final-field sign proof and density +writes. This scaling model is reasoned from the geometry and has not yet been timed at 12.5 cm. + +The UE 5.7 Win64 Development build passed with `-ForceHeaderGeneration` and +`-MaxParallelActions=4`. No UCLASS/UPROPERTY layout changed, so no generated-class layout risk +was introduced. The sensitive code is the locally bounded landing-floor derivative and the +supremum used by `TryClassifyBrick`; future field terms must update those bounds and keep the +proof audit. The live path and ARCHITECTURE §8.10 were left unchanged when research mode is off. +No `.uasset` file was edited. Nothing was committed, pushed, or stashed. + +See the exhaustive [launch ledger]() for every benchmark, test run, failure, and prelaunch rejection. diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeRedefinedDensityFieldTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeRedefinedDensityFieldTest.cpp new file mode 100644 index 0000000..bd36194 --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeRedefinedDensityFieldTest.cpp @@ -0,0 +1,146 @@ +#if WITH_DEV_AUTOMATION_TESTS + +#include "Misc/AutomationTest.h" + +#include "VoxelForgeTestFixture.h" +#include "VoxelRedefinedDensityField.h" +#include "VoxelStrateMeasure.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeRedefinedDensityFieldTraversalSeedsTest, + "VoxelForge.Research.RedefinedFieldTraversalSeeds", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FVoxelForgeRedefinedDensityFieldTraversalSeedsTest::RunTest(const FString& Parameters) +{ + (void)Parameters; + struct FRestoreResearchField + { + bool bWasEnabled = VoxelForgeRedefinedDensityField::IsEnabled(); + float PreviousScale = VoxelForgeRedefinedDensityField::GetDetailScale(); + ~FRestoreResearchField() + { + VoxelForgeRedefinedDensityField::SetDetailScale(PreviousScale); + VoxelForgeRedefinedDensityField::SetEnabled(bWasEnabled); + } + } Restore; + VoxelForgeRedefinedDensityField::SetEnabled(true); + VoxelForgeRedefinedDensityField::SetDetailScale(1.0f); + + constexpr int32 Seeds[] = {1337, 12345, 20260930}; + int32 CheckedLandings = 0; + int32 CheckedRoutes = 0; + int32 RoutesWithResolvedEndpoints = 0; + for (const int32 Seed : Seeds) + { + VoxelForgeTest::FTestWorld World; + World.Build(Seed, /*InGapChunks=*/2, /*InStrateHeightInChunks=*/4); + if (!World.IsValid()) + { + AddError(FString::Printf( + TEXT("Seed %d: test world did not build: %s"), Seed, *World.WhyInvalid())); + return false; + } + + const TArray& Layout = World.StrateManager->GetLayout(); + const TArray& Passages = World.StrateManager->GetPassages(); + for (const FVoxelPassage& Passage : Passages) + { + if (Passage.UpperStrateIndex == Passage.LowerStrateIndex) + { + continue; + } + const FVector UpperAir = Passage.UpperLanding.StandingPoint; + const FVector LowerAir = Passage.LowerLanding.StandingPoint; + const float UpperDensity = World.Generator->GetDensityAt( + UpperAir.X, UpperAir.Y, UpperAir.Z); + const float UpperFooting = World.Generator->GetDensityAt( + UpperAir.X, UpperAir.Y, Passage.UpperLanding.FloorZ - 0.75f); + const float LowerDensity = World.Generator->GetDensityAt( + LowerAir.X, LowerAir.Y, LowerAir.Z); + const float LowerFooting = World.Generator->GetDensityAt( + LowerAir.X, LowerAir.Y, Passage.LowerLanding.FloorZ - 0.75f); + ++CheckedLandings; + if (!FMath::IsFinite(UpperDensity) || !FMath::IsFinite(UpperFooting) + || !FMath::IsFinite(LowerDensity) || !FMath::IsFinite(LowerFooting) + || UpperDensity <= 0.0f || LowerDensity <= 0.0f + || UpperFooting >= 0.0f || LowerFooting >= 0.0f) + { + AddError(FString::Printf( + TEXT("Seed %d: passage %d landing failed the final-field air/floor bracket " + "(upper %.4f/%.4f, lower %.4f/%.4f)."), + Seed, CheckedLandings - 1, UpperDensity, UpperFooting, + LowerDensity, LowerFooting)); + } + } + + for (int32 StrateIndex = 0; StrateIndex < Layout.Num(); ++StrateIndex) + { + FVector Arrival = FVector::ZeroVector; + FVector Departure = FVector::ZeroVector; + bool bHasArrival = false; + bool bHasDeparture = false; + for (const FVoxelPassage& Passage : Passages) + { + if (Passage.UpperStrateIndex == Passage.LowerStrateIndex) + { + continue; + } + if (Passage.LowerStrateIndex == StrateIndex && !bHasArrival) + { + Arrival = Passage.LowerLanding.StandingPoint; + bHasArrival = true; + } + if (Passage.UpperStrateIndex == StrateIndex && !bHasDeparture) + { + Departure = Passage.UpperLanding.StandingPoint; + bHasDeparture = true; + } + } + if (!bHasArrival || !bHasDeparture) + { + continue; + } + + FVoxelStrateMeasureSettings Settings; + Settings.SampleStep = 4; + Settings.RadiusInVoxels = 192; + Settings.CenterXY = FVector2D( + (Arrival.X + Departure.X) * 0.5f, + (Arrival.Y + Departure.Y) * 0.5f); + Settings.MaxCells = 4000000; + Settings.HeadroomCells = 2; + const FVoxelConnectivityDiagnostics Route = VF_DiagnoseConnectivity( + *World.Generator, *World.StrateManager, StrateIndex, + Arrival, Departure, Settings); + ++CheckedRoutes; + RoutesWithResolvedEndpoints += Route.bStartSnapped || Route.bGoalSnapped; + if (!Route.bValid || Route.Result != EVoxelConnectivityResult::Connected) + { + AddError(FString::Printf( + TEXT("Seed %d strate %d (%s): arrival-to-exit walking route was %s " + "(result=%d, valid=%s, start resolved=%s, goal resolved=%s)."), + Seed, StrateIndex, + Layout[StrateIndex].Definition + ? *Layout[StrateIndex].Definition->GetName() : TEXT("no definition"), + Route.Result == EVoxelConnectivityResult::Connected + ? TEXT("connected") : TEXT("not connected"), + static_cast(Route.Result), + Route.bValid ? TEXT("yes") : TEXT("no"), + Route.bStartSnapped ? TEXT("yes") : TEXT("no"), + Route.bGoalSnapped ? TEXT("yes") : TEXT("no"))); + } + } + } + + TestTrue(TEXT("Several seeds exercised passage landings"), CheckedLandings >= 6); + TestTrue(TEXT("Several seeds exercised arrival-to-exit walking"), CheckedRoutes >= 6); + AddInfo(FString::Printf( + TEXT("Redefined field functional sweep: %d authored passage landings bracket open air " + "over %d seeds; %d arrival-to-exit walking routes checked; %d used a resolved " + "coarse endpoint after the full-density segment check."), + CheckedLandings, UE_ARRAY_COUNT(Seeds), CheckedRoutes, RoutesWithResolvedEndpoints)); + return CheckedLandings >= 6 && CheckedRoutes >= 6; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 5c25ea6..53f93c9 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -17,6 +17,7 @@ #include "VoxelDensityAblation.h" // fingerprinted development-only stage measurements #include "VoxelDensityPrimitives.h" // spine / seals / passage — shared with the operator stack #include "VoxelDensityOpStack.h" // the per-strate operator stack +#include "VoxelRedefinedDensityField.h" // default-off editor research evaluator #include "VoxelHeightOp.h" // IVoxelBiomeField — the adapter below implements it #include "VoxelStats.h" #include "VoxelDensityProfile.h" @@ -2815,6 +2816,19 @@ bool UVoxelGenerator::EvaluateDensityChunk( float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ, float* OutPreEditDensity) const { +#if WITH_EDITOR + // Step 2d is an editor-only, default-off side path. It does not alter the live evaluator's + // state or caches; the field is selected only for an explicit research/test run. + if (VoxelForgeRedefinedDensityField::IsEnabled()) + { + VoxelPassageGeometry::ResetVerticalShaftConnectorAirMarker(); + const float ResearchDensity = VoxelForgeRedefinedDensityField::Evaluate( + *this, WorldX, WorldY, WorldZ, OutPreEditDensity, + VoxelForgeRedefinedDensityField::GetDetailScale()); + VoxelPassageGeometry::ResetVerticalShaftConnectorAirMarker(); + return ResearchDensity; + } +#endif // Soundness mode: for a sample in a block whose core or passage-carving reach bit is clear, // evaluate the complete canonical field once with the bypass bit set. The normal call then // takes the fast path below and compares the final MC field after the boundary seal and diff @@ -6298,6 +6312,16 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in VoxelDensityProfile::AddCounter( VoxelDensityProfile::ECounter::TileClassifyCalls); +#if WITH_EDITOR + // The ordinary tile classifier proves the live field's interval bounds. While the separate + // research field is selected, decline those proofs until its own complete final-field interval + // contract is wired into streaming; a conservative Mixed verdict keeps the test mesh complete. + if (VoxelForgeRedefinedDensityField::IsEnabled()) + { + return EVoxelTileClass::Mixed; + } +#endif + const EVoxelTileClass Verdict = ClassifyTileUncached( OriginVoxels, NormalizedStep, NormalizedCellsPerAxis, OutStats); if (Verdict == EVoxelTileClass::AllSolid) diff --git a/Source/VoxelForge/Private/VoxelRedefinedDensityField.cpp b/Source/VoxelForge/Private/VoxelRedefinedDensityField.cpp new file mode 100644 index 0000000..20f8d54 --- /dev/null +++ b/Source/VoxelForge/Private/VoxelRedefinedDensityField.cpp @@ -0,0 +1,1487 @@ +#include "VoxelRedefinedDensityField.h" + +#if WITH_EDITOR + +#include "HAL/IConsoleManager.h" +#include "VoxelCaveMorphology.h" +#include "VoxelDiffLayer.h" +#include "VoxelGenerator.h" +#include "VoxelPassageGeometry.h" +#include "VoxelStrateManager.h" +#include "VoxelStrateTypes.h" +#include "VoxelTypes.h" + +#include + +namespace VoxelForgeRedefinedDensityField +{ +namespace +{ + constexpr float DetailAmplitude = 1.4f + 0.9f + 0.45f + 2.5f; + constexpr float DetailBoundNumericalMargin = 1.0e-3f; + constexpr float BaseFieldLipschitzBound = 1.25f; + constexpr float QuinticFadeSlopeBound = 1.875f; + constexpr float MinimumCaveHeight = 48.0f; + constexpr float LandingFloorBlendVoxels = 96.0f; + constexpr float LandingBridgeRadius = 48.0f; + constexpr float PassageBoundPadding = 16.0f; + constexpr float VerticalShaftSpineRadius = 24.0f; + constexpr int32 VerticalShaftConnectorChildReach = 4; + constexpr float LandingApproachRadius = 32.0f; + constexpr uint32 VerticalShaftTreeSalt = 0x7A11u; + + TAutoConsoleVariable CVarResearchRedefinedDensityField( + TEXT("voxel.ResearchRedefinedDensityField"), 0, + TEXT("Use the default-off editor-only step 2d redefined density field."), + ECVF_Default); + TAutoConsoleVariable CVarResearchRedefinedDensityDetailScale( + TEXT("voxel.ResearchRedefinedDensityDetailScale"), 1.0f, + TEXT("Step 2d research detail amplitude scale; zero is a deliberate smoothing control."), + ECVF_Default); + + struct FLandingSite + { + FVector Position = FVector::ZeroVector; + float FloorZ = 0.0f; + float HalfWidth = 0.0f; + float CeilingZ = 0.0f; + float PadRadius = 0.0f; + float BlendRadius = LandingFloorBlendVoxels; + FVector ApproachEnd = FVector::ZeroVector; + }; + + struct FVerticalShaftNode + { + int32 CellX = 0; + int32 CellY = 0; + float X = 0.0f; + float Y = 0.0f; + float Radius = 0.0f; + }; + + struct FVerticalShaftEdge + { + FVector A = FVector::ZeroVector; + FVector B = FVector::ZeroVector; + float Radius = 0.0f; + }; + + struct FThreadShaftEdgeCache + { + const void* Plan = nullptr; + const UVoxelStrateManager* Manager = nullptr; + uint64 ManagerLifetimeId = 0; + uint32 LayoutVersion = 0; + int32 Seed = INT32_MIN; + int32 CellX = INT32_MIN; + int32 CellY = INT32_MIN; + TArray Edges; + }; + + struct FPassageBound + { + FVector Min = FVector(FLT_MAX, FLT_MAX, FLT_MAX); + FVector Max = FVector(-FLT_MAX, -FLT_MAX, -FLT_MAX); + FVector SegmentA = FVector::ZeroVector; + FVector SegmentB = FVector::ZeroVector; + float SupportRadius = 0.0f; + bool bIsSegment = false; + }; + + struct FStratePlan + { + int32 StrateIndex = INDEX_NONE; + int32 MinChunkZ = 0; + int32 MaxChunkZExclusive = 0; + ECaveGeneratorType Archetype = ECaveGeneratorType::TunnelNetwork; + float FloorZ = 0.0f; + float CeilingZ = 0.0f; + float CellSpacing = 128.0f; + float OffsetX = 0.0f; + float OffsetY = 0.0f; + float CorridorHalfWidth = 40.0f; + FVerticalShaftParams VerticalShaftParams; + int32 LandingHubIndex = INDEX_NONE; + TArray Landings; + }; + + struct FThreadPlanCache + { + const UVoxelStrateManager* Manager = nullptr; + uint64 ManagerLifetimeId = 0; + uint32 LayoutVersion = 0xFFFFFFFFu; + int32 Seed = INT32_MIN; + TArray Strates; + TArray PassageBounds; + }; + + thread_local FThreadPlanCache GPlanCache; + thread_local FThreadShaftEdgeCache GShaftEdgeCache; + + FORCEINLINE uint32 Mix(uint32 Value) + { + Value ^= Value >> 16; + Value *= 0x7FEB352Du; + Value ^= Value >> 15; + Value *= 0x846CA68Bu; + Value ^= Value >> 16; + return Value; + } + + FORCEINLINE uint32 Hash3(int32 X, int32 Y, int32 Z, uint32 Seed) + { + uint32 H = Seed ^ 0x9E3779B9u; + H ^= Mix(static_cast(X) + 0x85EBCA6Bu); + H = (H << 13) | (H >> 19); + H ^= Mix(static_cast(Y) + 0xC2B2AE35u); + H = (H << 11) | (H >> 21); + H ^= Mix(static_cast(Z) + 0x27D4EB2Fu); + return Mix(H); + } + + FORCEINLINE float HashValue(int32 X, int32 Y, int32 Z, uint32 Seed) + { + const uint32 H = Hash3(X, Y, Z, Seed); + return static_cast(H >> 8) * (2.0f / 16777215.0f) - 1.0f; + } + + FORCEINLINE float Fade(float T) + { + return T * T * T * (T * (T * 6.0f - 15.0f) + 10.0f); + } + + FORCEINLINE float Noise3D(float X, float Y, float Z, float CellSize, uint32 Seed) + { + const float Inv = 1.0f / CellSize; + const float SX = X * Inv; + const float SY = Y * Inv; + const float SZ = Z * Inv; + const int32 IX = FMath::FloorToInt(SX); + const int32 IY = FMath::FloorToInt(SY); + const int32 IZ = FMath::FloorToInt(SZ); + const float AX = Fade(SX - static_cast(IX)); + const float AY = Fade(SY - static_cast(IY)); + const float AZ = Fade(SZ - static_cast(IZ)); + + const float Z0Y0 = FMath::Lerp( + FMath::Lerp(HashValue(IX, IY, IZ, Seed), HashValue(IX + 1, IY, IZ, Seed), AX), + FMath::Lerp(HashValue(IX, IY + 1, IZ, Seed), HashValue(IX + 1, IY + 1, IZ, Seed), AX), AY); + const float Z0Y1 = FMath::Lerp( + FMath::Lerp(HashValue(IX, IY, IZ + 1, Seed), HashValue(IX + 1, IY, IZ + 1, Seed), AX), + FMath::Lerp(HashValue(IX, IY + 1, IZ + 1, Seed), HashValue(IX + 1, IY + 1, IZ + 1, Seed), AX), AY); + return FMath::Lerp(Z0Y0, Z0Y1, AZ); + } + + FORCEINLINE float DetailAt(float X, float Y, float Z, uint32 Seed) + { + const float Broad = Noise3D(X, Y, Z, 8.0f, Seed ^ 0x68BC21EBu); + const float Medium = Noise3D(X, Y, Z, 4.0f, Seed ^ 0x02E5BE93u); + const float Fine = Noise3D(X, Y, Z, 2.0f, Seed ^ 0x967A889Bu); + const float Micro = Noise3D(X, Y, Z, 1.0f, Seed ^ 0xA511E9B3u); + return Broad * 1.4f + Medium * 0.9f + Fine * 0.45f + Micro * 2.5f; + } + + FORCEINLINE float DistanceToPeriodicLine(float Position, float Spacing, float Offset) + { + const float Cell = (Position - Offset) / Spacing; + const float Fraction = Cell - static_cast(FMath::FloorToInt(Cell + 0.5f)); + return FMath::Abs(Fraction) * Spacing; + } + + FORCEINLINE float DistanceSquaredToSegmentXY( + float X, float Y, const FVector& A, const FVector& B) + { + const float ABX = B.X - A.X; + const float ABY = B.Y - A.Y; + const float LengthSquared = ABX * ABX + ABY * ABY; + const float T = LengthSquared > 1.0e-8f + ? FMath::Clamp(((X - A.X) * ABX + (Y - A.Y) * ABY) / LengthSquared, 0.0f, 1.0f) + : 0.0f; + const float DX = X - (A.X + T * ABX); + const float DY = Y - (A.Y + T * ABY); + return DX * DX + DY * DY; + } + + FORCEINLINE float SegmentParameterXY( + float X, float Y, const FVector& A, const FVector& B) + { + const float ABX = B.X - A.X; + const float ABY = B.Y - A.Y; + const float LengthSquared = ABX * ABX + ABY * ABY; + return LengthSquared > 1.0e-8f + ? FMath::Clamp(((X - A.X) * ABX + (Y - A.Y) * ABY) / LengthSquared, 0.0f, 1.0f) + : 0.0f; + } + + FORCEINLINE bool RollVerticalShaft( + const FVerticalShaftParams& Params, uint32 ShaftSeed, + int32 CellX, int32 CellY, FVerticalShaftNode& OutNode) + { + const float Spacing = FMath::Max(Params.ShaftSpacing, 1.0f); + const uint32 Hash = VoxelHash::Cell(CellX, CellY, ShaftSeed); + if (VoxelHash::ToFloat01(Hash) > Params.ShaftDensity) + { + return false; + } + OutNode.CellX = CellX; + OutNode.CellY = CellY; + OutNode.X = (CellX + 0.15f + + VoxelHash::ToFloat01(VoxelHash::Mix(Hash ^ 0x12345678u)) * 0.7f) * Spacing; + OutNode.Y = (CellY + 0.15f + + VoxelHash::ToFloat01(VoxelHash::Mix(Hash ^ 0x9ABCDEF0u)) * 0.7f) * Spacing; + OutNode.Radius = FMath::Lerp(Params.ShaftMinRadius, Params.ShaftMaxRadius, + VoxelHash::ToFloat01(VoxelHash::Mix(Hash ^ 0xBEEFu))); + return true; + } + + bool ResolveVerticalShaftParent( + const FVerticalShaftParams& Params, uint32 ShaftSeed, + const FVerticalShaftNode& Child, FVerticalShaftNode& OutParent) + { + const float ChildOriginSq = FMath::Square(Child.X) + FMath::Square(Child.Y); + bool bFoundParent = false; + float BestDistanceSq = FLT_MAX; + const auto Search = [&](int32 Radius) + { + for (int32 DY = -Radius; DY <= Radius; ++DY) + for (int32 DX = -Radius; DX <= Radius; ++DX) + { + FVerticalShaftNode Candidate; + if ((DX == 0 && DY == 0) + || !RollVerticalShaft(Params, ShaftSeed, + Child.CellX + DX, Child.CellY + DY, Candidate)) + { + continue; + } + const float CandidateOriginSq = FMath::Square(Candidate.X) + + FMath::Square(Candidate.Y); + if (!(CandidateOriginSq < ChildOriginSq)) + { + continue; + } + const float DistanceSq = FMath::Square(Child.X - Candidate.X) + + FMath::Square(Child.Y - Candidate.Y); + const bool bLowerCell = !bFoundParent + || Candidate.CellY < OutParent.CellY + || (Candidate.CellY == OutParent.CellY + && Candidate.CellX < OutParent.CellX); + if (DistanceSq < BestDistanceSq + || (DistanceSq == BestDistanceSq && bLowerCell)) + { + BestDistanceSq = DistanceSq; + OutParent = Candidate; + bFoundParent = true; + } + } + }; + + Search(2); + if (bFoundParent) + { + return true; + } + if (FMath::Abs(Child.CellX) <= 2 && FMath::Abs(Child.CellY) <= 2) + { + return false; + } + Search(3); + return bFoundParent; + } + + float ResolveVerticalShaftConnectorZ( + const FVerticalShaftParams& Params, uint32 LinkHash) + { + const float BottomZ = Params.StrateBottomWorldZ + Params.BoundarySealThickness; + const float TopZ = Params.StrateTopWorldZ - Params.BoundarySealThickness; + if (Params.LedgeSpacing > 0.0f && Params.LedgeDepth > 0.0f) + { + const float RelativeBottom = BottomZ - Params.StrateBottomWorldZ; + const float RelativeTop = TopZ - Params.StrateBottomWorldZ; + const int32 FirstPeriod = FMath::FloorToInt( + RelativeBottom / Params.LedgeSpacing); + const float Random01 = VoxelHash::ToFloat01(VoxelHash::Mix(LinkHash)); + for (int32 PeriodOffset = 0; PeriodOffset <= 1; ++PeriodOffset) + { + const float PeriodStart = static_cast(FirstPeriod + PeriodOffset) + * Params.LedgeSpacing; + const float SafeStart = FMath::Max( + RelativeBottom, PeriodStart + Params.LedgeDepth + 0.01f); + const float SafeEnd = FMath::Min( + RelativeTop, PeriodStart + Params.LedgeSpacing + - Params.LedgeDepth - 0.01f); + if (SafeEnd > SafeStart) + { + return Params.StrateBottomWorldZ + + FMath::Lerp(SafeStart, SafeEnd, Random01); + } + } + } + return FMath::Lerp(BottomZ, TopZ, VoxelHash::ToFloat01(VoxelHash::Mix(LinkHash))); + } + + void RebuildVerticalShaftEdgeCache(const FStratePlan& Plan, float X, float Y) + { + const FVerticalShaftParams& Params = Plan.VerticalShaftParams; + const float Spacing = FMath::Max(Params.ShaftSpacing, 1.0f); + const int32 CellX = FMath::FloorToInt(X / Spacing); + const int32 CellY = FMath::FloorToInt(Y / Spacing); + if (GShaftEdgeCache.Plan == &Plan + && GShaftEdgeCache.Manager == GPlanCache.Manager + && GShaftEdgeCache.ManagerLifetimeId == GPlanCache.ManagerLifetimeId + && GShaftEdgeCache.LayoutVersion == GPlanCache.LayoutVersion + && GShaftEdgeCache.Seed == GPlanCache.Seed + && GShaftEdgeCache.CellX == CellX && GShaftEdgeCache.CellY == CellY) + { + return; + } + GShaftEdgeCache.Plan = &Plan; + GShaftEdgeCache.Manager = GPlanCache.Manager; + GShaftEdgeCache.ManagerLifetimeId = GPlanCache.ManagerLifetimeId; + GShaftEdgeCache.LayoutVersion = GPlanCache.LayoutVersion; + GShaftEdgeCache.Seed = GPlanCache.Seed; + GShaftEdgeCache.CellX = CellX; + GShaftEdgeCache.CellY = CellY; + GShaftEdgeCache.Edges.Reset(); + + const uint32 ShaftSeed = static_cast(GPlanCache.Seed) ^ 0x53686674u; + // A 36-voxel bore (9 m at 25 cm) keeps the deterministic route tree visible to the + // 2-voxel navigation/refinement probes while still allowing full-strength wall noise. + const float ConnectorRadius = FMath::Max3( + Params.ConnectorRadius, DetailAmplitude + 1.0f, 18.0f); + const float CellMinX = CellX * Spacing; + const float CellMaxX = CellMinX + Spacing; + const float CellMinY = CellY * Spacing; + const float CellMaxY = CellMinY + Spacing; + for (int32 ChildCellY = CellY - VerticalShaftConnectorChildReach; + ChildCellY <= CellY + VerticalShaftConnectorChildReach; ++ChildCellY) + for (int32 ChildCellX = CellX - VerticalShaftConnectorChildReach; + ChildCellX <= CellX + VerticalShaftConnectorChildReach; ++ChildCellX) + { + FVerticalShaftNode Child; + if (!RollVerticalShaft(Params, ShaftSeed, ChildCellX, ChildCellY, Child)) + { + continue; + } + FVerticalShaftNode Parent; + const bool bHasParent = ResolveVerticalShaftParent( + Params, ShaftSeed, Child, Parent); + const FVector ParentXY = bHasParent + ? FVector(Parent.X, Parent.Y, 0.0f) : FVector::ZeroVector; + const bool bMayReachCell = FMath::Max(Child.X, ParentXY.X) + ConnectorRadius >= CellMinX + && FMath::Min(Child.X, ParentXY.X) - ConnectorRadius <= CellMaxX + && FMath::Max(Child.Y, ParentXY.Y) + ConnectorRadius >= CellMinY + && FMath::Min(Child.Y, ParentXY.Y) - ConnectorRadius <= CellMaxY; + if (!bMayReachCell) + { + continue; + } + + const uint32 LinkHash = VoxelHash::Pair( + Child.CellX, Child.CellY, + bHasParent ? Parent.CellX : 0, + bHasParent ? Parent.CellY : 0, + ShaftSeed ^ VerticalShaftTreeSalt); + const float ConnectorZ = ResolveVerticalShaftConnectorZ(Params, LinkHash); + FVerticalShaftEdge& Edge = GShaftEdgeCache.Edges.AddDefaulted_GetRef(); + Edge.A = FVector(Child.X, Child.Y, ConnectorZ); + Edge.B = FVector(ParentXY.X, ParentXY.Y, ConnectorZ); + Edge.Radius = ConnectorRadius; + } + } + + float EvaluateVerticalShaftMacro( + const FStratePlan& Plan, float X, float Y, float Z) + { + const FVerticalShaftParams& Params = Plan.VerticalShaftParams; + const float Spacing = FMath::Max(Params.ShaftSpacing, 1.0f); + const int32 BaseCellX = FMath::FloorToInt(X / Spacing); + const int32 BaseCellY = FMath::FloorToInt(Y / Spacing); + const float VerticalPad = DetailAmplitude + 1.0f; + const float BottomZ = Params.StrateBottomWorldZ + Params.BoundarySealThickness; + const float TopZ = Params.StrateTopWorldZ - Params.BoundarySealThickness; + const float ShaftVertical = FMath::Min(Z - BottomZ, TopZ - Z); + float Macro = FMath::Min( + VerticalShaftSpineRadius + VerticalPad - FMath::Sqrt(X * X + Y * Y), + ShaftVertical); + + const uint32 ShaftSeed = static_cast(GPlanCache.Seed) ^ 0x53686674u; + for (int32 CellY = BaseCellY - 1; CellY <= BaseCellY + 1; ++CellY) + for (int32 CellX = BaseCellX - 1; CellX <= BaseCellX + 1; ++CellX) + { + FVerticalShaftNode Shaft; + if (!RollVerticalShaft(Params, ShaftSeed, CellX, CellY, Shaft)) + { + continue; + } + const float ShaftHorizontal = Shaft.Radius + VerticalPad + - FMath::Sqrt(FMath::Square(X - Shaft.X) + FMath::Square(Y - Shaft.Y)); + Macro = FMath::Max(Macro, FMath::Min(ShaftHorizontal, ShaftVertical)); + } + + RebuildVerticalShaftEdgeCache(Plan, X, Y); + for (const FVerticalShaftEdge& Edge : GShaftEdgeCache.Edges) + { + const float HorizontalSq = DistanceSquaredToSegmentXY(X, Y, Edge.A, Edge.B); + const float DistSq = HorizontalSq + FMath::Square(Z - Edge.A.Z); + Macro = FMath::Max(Macro, Edge.Radius - FMath::Sqrt(DistSq)); + } + return Macro; + } + + void EnsurePlan(const UVoxelGenerator& Generator) + { + const UVoxelStrateManager* Manager = Generator.StrateManager; + if (Manager == nullptr) + { + GPlanCache = FThreadPlanCache(); + return; + } + const uint64 Lifetime = Manager->GetCacheLifetimeId(); + const uint32 Version = Manager->GetLayoutVersion(); + if (GPlanCache.Manager == Manager + && GPlanCache.ManagerLifetimeId == Lifetime + && GPlanCache.LayoutVersion == Version + && GPlanCache.Seed == Generator.Seed) + { + return; + } + + GPlanCache = FThreadPlanCache(); + GPlanCache.Manager = Manager; + GPlanCache.ManagerLifetimeId = Lifetime; + GPlanCache.LayoutVersion = Version; + GPlanCache.Seed = Generator.Seed; + const TArray& Layout = Manager->GetLayout(); + GPlanCache.Strates.Reserve(Layout.Num()); + for (const FStrateSlot& Slot : Layout) + { + if (Slot.Definition == nullptr) + { + continue; + } + FStratePlan& Plan = GPlanCache.Strates.AddDefaulted_GetRef(); + Plan.StrateIndex = Slot.StrateIndex; + Plan.MinChunkZ = FMath::Min(Slot.BottomChunkZ, Slot.TopChunkZ); + Plan.MaxChunkZExclusive = FMath::Max(Slot.BottomChunkZ, Slot.TopChunkZ) + 1; + Plan.Archetype = Slot.Definition->GeneratorType; + if (Plan.Archetype == ECaveGeneratorType::VerticalShafts) + { + Plan.VerticalShaftParams = Manager->GetVerticalShaftParamsForChunk( + FIntVector(0, 0, Plan.MinChunkZ)); + } + const float Bottom = static_cast(Plan.MinChunkZ * CHUNK_SIZE); + const float Top = static_cast(Plan.MaxChunkZExclusive * CHUNK_SIZE); + Plan.FloorZ = (Bottom + Top) * 0.5f - 24.0f; + Plan.CeilingZ = Plan.FloorZ + 48.0f; + switch (Plan.Archetype) + { + case ECaveGeneratorType::FlatPlain: + Plan.CellSpacing = 160.0f; + Plan.CorridorHalfWidth = 54.0f; + break; + case ECaveGeneratorType::CrystalChamber: + Plan.CellSpacing = 120.0f; + Plan.CorridorHalfWidth = 42.0f; + break; + case ECaveGeneratorType::Maze: + Plan.CellSpacing = 96.0f; + Plan.CorridorHalfWidth = 30.0f; + break; + case ECaveGeneratorType::VerticalShafts: + Plan.CellSpacing = 112.0f; + Plan.CorridorHalfWidth = 36.0f; + break; + case ECaveGeneratorType::FloatingIslands: + Plan.CellSpacing = 112.0f; + Plan.CorridorHalfWidth = 34.0f; + break; + case ECaveGeneratorType::SurfaceWorld: + Plan.CellSpacing = 144.0f; + Plan.CorridorHalfWidth = 46.0f; + break; + default: + Plan.CellSpacing = 128.0f; + Plan.CorridorHalfWidth = 40.0f; + break; + } + const uint32 StrateSeed = static_cast(Generator.Seed) + ^ static_cast(Plan.StrateIndex * 7919 + 104729); + if (Plan.Archetype == ECaveGeneratorType::VerticalShafts) + { + // Keep the archetype's central origin spine open for its authored route probe. + Plan.OffsetX = 0.0f; + Plan.OffsetY = 0.0f; + } + else + { + Plan.OffsetX = static_cast(Hash3( + Plan.StrateIndex, Plan.MinChunkZ, 17, StrateSeed) >> 8) + * (Plan.CellSpacing / 16777216.0f); + Plan.OffsetY = static_cast(Hash3( + Plan.StrateIndex, Plan.MinChunkZ, 31, StrateSeed) >> 8) + * (Plan.CellSpacing / 16777216.0f); + } + } + + const TArray& Passages = Manager->GetPassages(); + for (const FVoxelPassage& Passage : Passages) + { + const auto AddSegmentBounds = []( + const FVector& A, const FVector& B, float Radius) + { + FPassageBound& Bounds = GPlanCache.PassageBounds.AddDefaulted_GetRef(); + const float R = FMath::Max(Radius, 8.0f) + PassageBoundPadding; + Bounds.SegmentA = A; + Bounds.SegmentB = B; + Bounds.SupportRadius = R; + Bounds.bIsSegment = true; + Bounds.Min = FVector( + FMath::Min(A.X, B.X) - R, + FMath::Min(A.Y, B.Y) - R, + FMath::Min(A.Z, B.Z) - R); + Bounds.Max = FVector( + FMath::Max(A.X, B.X) + R, + FMath::Max(A.Y, B.Y) + R, + FMath::Max(A.Z, B.Z) + R); + }; + if (Passage.ControlPoints.Num() >= 2 + && Passage.ControlRadii.Num() == Passage.ControlPoints.Num()) + { + for (int32 Segment = 0; Segment + 1 < Passage.ControlPoints.Num(); ++Segment) + { + AddSegmentBounds( + Passage.ControlPoints[Segment], Passage.ControlPoints[Segment + 1], + FMath::Max(Passage.Radius, + FMath::Max(FMath::Abs(Passage.ControlRadii[Segment]), + FMath::Abs(Passage.ControlRadii[Segment + 1])))); + } + } + else + { + AddSegmentBounds(Passage.UpperPoint, Passage.LowerPoint, Passage.Radius); + } + + const auto AddLandingBounds = [](const FVoxelPassageLanding& Landing) + { + const float XYExtent = Landing.HalfWidth + PassageBoundPadding; + const float ZExtent = FMath::Max( + Landing.HalfWidth, Landing.CeilingZ - Landing.FloorZ) + + PassageBoundPadding; + FPassageBound& Bounds = GPlanCache.PassageBounds.AddDefaulted_GetRef(); + const FVector Pad(XYExtent, XYExtent, ZExtent); + const FVector& P = Landing.StandingPoint; + Bounds.Min = P - Pad; + Bounds.Max = P + Pad; + }; + AddLandingBounds(Passage.UpperLanding); + AddLandingBounds(Passage.LowerLanding); + + for (FStratePlan& Plan : GPlanCache.Strates) + { + if (Plan.StrateIndex == Passage.UpperStrateIndex) + { + FLandingSite& Site = Plan.Landings.AddDefaulted_GetRef(); + Site.Position = Passage.UpperLanding.StandingPoint; + Site.FloorZ = Passage.UpperLanding.FloorZ; + Site.HalfWidth = Passage.UpperLanding.HalfWidth; + Site.CeilingZ = Passage.UpperLanding.CeilingZ; + } + if (Plan.StrateIndex == Passage.LowerStrateIndex) + { + FLandingSite& Site = Plan.Landings.AddDefaulted_GetRef(); + Site.Position = Passage.LowerLanding.StandingPoint; + Site.FloorZ = Passage.LowerLanding.FloorZ; + Site.HalfWidth = Passage.LowerLanding.HalfWidth; + Site.CeilingZ = Passage.LowerLanding.CeilingZ; + } + } + } + + for (FStratePlan& Plan : GPlanCache.Strates) + { + if (!Plan.Landings.IsEmpty()) + { + double FloorSum = 0.0; + int32 FloorCount = 0; + for (FLandingSite& Site : Plan.Landings) + { + if (!FMath::IsFinite(Site.FloorZ)) + { + continue; + } + FloorSum += Site.FloorZ; + ++FloorCount; + Site.PadRadius = FMath::Max(Site.HalfWidth + 24.0f, 40.0f); + } + if (FloorCount > 0) + { + const float Bottom = static_cast(Plan.MinChunkZ * CHUNK_SIZE); + const float Top = static_cast(Plan.MaxChunkZExclusive * CHUNK_SIZE); + Plan.FloorZ = FMath::Clamp( + static_cast(FloorSum / FloorCount), Bottom + 16.0f, Top - 72.0f); + Plan.CeilingZ = Plan.FloorZ + MinimumCaveHeight; + } + } + for (FLandingSite& Site : Plan.Landings) + { + const int32 XLaneIndex = FMath::RoundToInt( + (Site.Position.X - Plan.OffsetX) / Plan.CellSpacing); + const int32 YLaneIndex = FMath::RoundToInt( + (Site.Position.Y - Plan.OffsetY) / Plan.CellSpacing); + const float XLaneCoordinate = Plan.OffsetX + XLaneIndex * Plan.CellSpacing; + const float YLaneCoordinate = Plan.OffsetY + YLaneIndex * Plan.CellSpacing; + const float XLaneDistance = FMath::Abs(Site.Position.X - XLaneCoordinate); + const float YLaneDistance = FMath::Abs(Site.Position.Y - YLaneCoordinate); + Site.ApproachEnd = Site.Position; + if (XLaneDistance <= YLaneDistance) + { + Site.ApproachEnd.X = XLaneCoordinate; + } + else + { + Site.ApproachEnd.Y = YLaneCoordinate; + } + const float FloorDelta = FMath::Abs(Site.FloorZ - Plan.FloorZ); + Site.BlendRadius = FMath::Max( + LandingFloorBlendVoxels, Site.PadRadius + FloorDelta * 2.0f + 8.0f); + } + Plan.LandingHubIndex = INDEX_NONE; + for (int32 SiteIndex = 0; SiteIndex < Plan.Landings.Num(); ++SiteIndex) + { + const FLandingSite& Candidate = Plan.Landings[SiteIndex]; + if (Plan.LandingHubIndex == INDEX_NONE) + { + Plan.LandingHubIndex = SiteIndex; + continue; + } + const FLandingSite& Current = Plan.Landings[Plan.LandingHubIndex]; + if (Candidate.Position.X < Current.Position.X + || (Candidate.Position.X == Current.Position.X + && Candidate.Position.Y < Current.Position.Y) + || (Candidate.Position.X == Current.Position.X + && Candidate.Position.Y == Current.Position.Y + && Candidate.FloorZ < Current.FloorZ)) + { + Plan.LandingHubIndex = SiteIndex; + } + } + } + } + + const FStratePlan* FindStrate(const FIntVector& Chunk) + { + for (const FStratePlan& Plan : GPlanCache.Strates) + { + if (Chunk.Z >= Plan.MinChunkZ && Chunk.Z < Plan.MaxChunkZExclusive) + { + return &Plan; + } + } + return nullptr; + } + + float EvaluateMacro(const FStratePlan& Plan, float X, float Y, float Z, + float* OutDetailScale) + { + float WeightedDelta = 0.0f; + float TotalWeight = 0.0f; + float LandingMacro = -FLT_MAX; + float Scale = 1.0f; + for (const FLandingSite& Site : Plan.Landings) + { + const float DX = X - Site.Position.X; + const float DY = Y - Site.Position.Y; + const float DistanceSquared = DX * DX + DY * DY; + const float HorizontalDistance = FMath::Sqrt(DistanceSquared); + if (DistanceSquared < Site.BlendRadius * Site.BlendRadius) + { + const float Weight = Fade(1.0f - HorizontalDistance / Site.BlendRadius); + WeightedDelta += Weight * (Site.FloorZ - Plan.FloorZ); + TotalWeight += Weight; + } + + const float RoomVertical = FMath::Min( + Z - Site.FloorZ, Site.FloorZ + MinimumCaveHeight - Z); + const float RoomAir = FMath::Min(Site.PadRadius - HorizontalDistance, RoomVertical); + LandingMacro = FMath::Max(LandingMacro, RoomAir); + + if (OutDetailScale != nullptr) + { + const float FlatRadius = FMath::Max(0.0f, Site.PadRadius - 3.0f); + if (DistanceSquared < FlatRadius * FlatRadius) + { + Scale = 0.0f; + } + } + } + const float Floor = Plan.FloorZ + WeightedDelta / FMath::Max(1.0f, TotalWeight); + const float Ceiling = Floor + (Plan.CeilingZ - Plan.FloorZ); + const float VerticalAir = FMath::Min(Z - Floor, Ceiling - Z); + const float XLaneDistance = DistanceToPeriodicLine( + X, Plan.CellSpacing, Plan.OffsetX); + const float YLaneDistance = DistanceToPeriodicLine( + Y, Plan.CellSpacing, Plan.OffsetY); + const float LaneDistance = FMath::Min(XLaneDistance, YLaneDistance); + const float GridAir = Plan.CorridorHalfWidth - LaneDistance; + float Macro = FMath::Max( + FMath::Min(VerticalAir, GridAir), LandingMacro); + if (Plan.Archetype == ECaveGeneratorType::VerticalShafts) + { + // Keep one continuous, walkable cavern horizon between the full-height shaft wells. + // The wells and their tree links add vertical routes; this band makes every authored + // passage landing part of the same deterministic navigation level. + Macro = FMath::Max(Macro, VerticalAir); + Macro = FMath::Max(Macro, EvaluateVerticalShaftMacro(Plan, X, Y, Z)); + } + for (const FLandingSite& Site : Plan.Landings) + { + const float ApproachHorizontal = LandingApproachRadius - FMath::Sqrt( + DistanceSquaredToSegmentXY(X, Y, Site.Position, Site.ApproachEnd)); + Macro = FMath::Max(Macro, FMath::Min(ApproachHorizontal, VerticalAir)); + } + if (Plan.LandingHubIndex != INDEX_NONE && Plan.Landings.Num() > 1) + { + const FLandingSite& Hub = Plan.Landings[Plan.LandingHubIndex]; + for (int32 SiteIndex = 0; SiteIndex < Plan.Landings.Num(); ++SiteIndex) + { + if (SiteIndex == Plan.LandingHubIndex) + { + continue; + } + const FLandingSite& Site = Plan.Landings[SiteIndex]; + const float DistanceSquared = DistanceSquaredToSegmentXY( + X, Y, Site.Position, Hub.Position); + const float Horizontal = LandingBridgeRadius - FMath::Sqrt(DistanceSquared); + const float T = SegmentParameterXY(X, Y, Site.Position, Hub.Position); + const float BridgeFloor = FMath::Lerp(Site.FloorZ, Hub.FloorZ, T); + Macro = FMath::Max(Macro, FMath::Min( + Horizontal, + FMath::Min(Z - BridgeFloor, BridgeFloor + MinimumCaveHeight - Z))); + } + } + + if (OutDetailScale != nullptr) + { + *OutDetailScale = Scale; + } + return Macro; + } + + FORCEINLINE float EvaluateMacroFromBrick( + const FStratePlan& Plan, const FBrickEvaluationContext& Context, + float X, float Y, float Z, float* OutDetailScale) + { + const int32 LocalX = static_cast(X) - Context.BrickOrigin.X; + const int32 LocalY = static_cast(Y) - Context.BrickOrigin.Y; + const int32 XYIndex = LocalY * Context.BrickSize.X + LocalX; + const float Floor = Context.FloorByXY[XYIndex]; + const float Ceiling = Floor + (Plan.CeilingZ - Plan.FloorZ); + const float VerticalAir = FMath::Min(Z - Floor, Ceiling - Z); + float Macro = FMath::Min(VerticalAir, Context.GridAirByXY[XYIndex]); + for (int32 SiteIndex = 0; SiteIndex < Plan.Landings.Num(); ++SiteIndex) + { + const FLandingSite& Site = Plan.Landings[SiteIndex]; + const float HorizontalRoom = Context.LandingHorizontalByXY[ + SiteIndex * Context.BrickSize.X * Context.BrickSize.Y + XYIndex]; + const float RoomVertical = FMath::Min( + Z - Site.FloorZ, Site.FloorZ + MinimumCaveHeight - Z); + Macro = FMath::Max(Macro, FMath::Min(HorizontalRoom, RoomVertical)); + } + const int32 XYCount = Context.BrickSize.X * Context.BrickSize.Y; + for (int32 SiteIndex = 0; SiteIndex < Plan.Landings.Num(); ++SiteIndex) + { + const float ApproachHorizontal = Context.LandingApproachByXY[ + SiteIndex * XYCount + XYIndex]; + Macro = FMath::Max(Macro, FMath::Min(ApproachHorizontal, VerticalAir)); + } + if (Plan.LandingHubIndex != INDEX_NONE && Plan.Landings.Num() > 1) + { + for (int32 SiteIndex = 0; SiteIndex < Plan.Landings.Num(); ++SiteIndex) + { + if (SiteIndex == Plan.LandingHubIndex) + { + continue; + } + const int32 BridgeIndex = SiteIndex * XYCount + XYIndex; + const float BridgeFloor = Context.LandingBridgeFloorByXY[BridgeIndex]; + Macro = FMath::Max(Macro, FMath::Min( + Context.LandingBridgeHorizontalByXY[BridgeIndex], + FMath::Min(Z - BridgeFloor, + BridgeFloor + MinimumCaveHeight - Z))); + } + } + if (Plan.Archetype == ECaveGeneratorType::VerticalShafts) + { + Macro = FMath::Max(Macro, VerticalAir); + Macro = FMath::Max(Macro, EvaluateVerticalShaftMacro(Plan, X, Y, Z)); + } + if (OutDetailScale != nullptr) + { + *OutDetailScale = Context.DetailFadeByXY[XYIndex]; + } + return Macro; + } + + FORCEINLINE float DistanceSquaredToSegment( + const FVector& Point, const FVector& A, const FVector& B) + { + const FVector AB = B - A; + const float LengthSquared = AB.SizeSquared(); + const float T = LengthSquared > SMALL_NUMBER + ? FMath::Clamp(FVector::DotProduct(Point - A, AB) / LengthSquared, 0.0f, 1.0f) + : 0.0f; + return FVector::DistSquared(Point, A + AB * T); + } + + FORCEINLINE bool BoxMayIntersectPassageBound(const FBox& Box, const FPassageBound& Bounds) + { + if (!(Bounds.Min.X <= Box.Max.X && Bounds.Max.X >= Box.Min.X + && Bounds.Min.Y <= Box.Max.Y && Bounds.Max.Y >= Box.Min.Y + && Bounds.Min.Z <= Box.Max.Z && Bounds.Max.Z >= Box.Min.Z)) + { + return false; + } + if (!Bounds.bIsSegment) + { + return true; + } + const float Reach = Bounds.SupportRadius + Box.GetExtent().Size(); + return DistanceSquaredToSegment(Box.GetCenter(), Bounds.SegmentA, Bounds.SegmentB) + <= Reach * Reach; + } + + FORCEINLINE bool PointMayIntersectPassageBound( + const FVector& Point, const FPassageBound& Bounds) + { + if (Point.X < Bounds.Min.X || Point.X > Bounds.Max.X + || Point.Y < Bounds.Min.Y || Point.Y > Bounds.Max.Y + || Point.Z < Bounds.Min.Z || Point.Z > Bounds.Max.Z) + { + return false; + } + return !Bounds.bIsSegment + || DistanceSquaredToSegment(Point, Bounds.SegmentA, Bounds.SegmentB) + <= FMath::Square(Bounds.SupportRadius); + } + + bool IntersectsPassageBounds(const FBox& Box) + { + for (const FPassageBound& Bounds : GPlanCache.PassageBounds) + { + if (BoxMayIntersectPassageBound(Box, Bounds)) + { + return true; + } + } + return false; + } + + void FindPassageCandidates(const FBox& Box, TArray& OutCandidates) + { + OutCandidates.Reset(); + for (int32 Index = 0; Index < GPlanCache.PassageBounds.Num(); ++Index) + { + const FPassageBound& Bounds = GPlanCache.PassageBounds[Index]; + if (BoxMayIntersectPassageBound(Box, Bounds)) + { + OutCandidates.Add(Index); + } + } + } +} + +bool IsEnabled() +{ + return CVarResearchRedefinedDensityField.GetValueOnAnyThread() != 0; +} + +void SetEnabled(bool bEnabled) +{ + CVarResearchRedefinedDensityField->Set(bEnabled ? 1 : 0, ECVF_SetByCode); +} + +float GetDetailScale() +{ + return FMath::Clamp(CVarResearchRedefinedDensityDetailScale.GetValueOnAnyThread(), 0.0f, 4.0f); +} + +void SetDetailScale(float Scale) +{ + CVarResearchRedefinedDensityDetailScale->Set(FMath::Clamp(Scale, 0.0f, 4.0f), ECVF_SetByCode); +} + +void ResetThreadCache() +{ + GPlanCache = FThreadPlanCache(); + GShaftEdgeCache = FThreadShaftEdgeCache(); +} + +static float EvaluateInternal(const UVoxelGenerator& Generator, + float WorldX, float WorldY, float WorldZ, float DetailValue, bool bHasDetailValue, + float* OutPreEditDensity, float DetailScale, + const FBrickEvaluationContext* Context = nullptr) +{ + if (Context == nullptr) + { + EnsurePlan(Generator); + } + const FIntVector Chunk = Context != nullptr && Context->bChunkResolved + ? Context->Chunk + : FIntVector( + FMath::FloorToInt(WorldX / CHUNK_SIZE), + FMath::FloorToInt(WorldY / CHUNK_SIZE), + FMath::FloorToInt(WorldZ / CHUNK_SIZE)); + const FStratePlan* Plan = Context != nullptr && Context->bPlanResolved + ? static_cast(Context->PlanToken) + : FindStrate(Chunk); + float Result = 0.0f; + const UVoxelStrateManager* Manager = Generator.StrateManager; + + if (Plan == nullptr) + { + const bool bIsGapChunk = Manager != nullptr + && (Context != nullptr && Context->bChunkResolved + ? Context->bIsGapChunk : Manager->IsGapChunk(Chunk)); + if (bIsGapChunk) + { + float Internal = 8.0f; + Manager->ApplyPassageModifier(Internal, WorldX, WorldY, WorldZ, 8.0f, 0.0f); + Result = -Internal; + Manager->ApplyPassageNativeFloorMC(Result, WorldX, WorldY, WorldZ, 8.0f); + } + else + { + Result = 8.0f; + } + } + else + { + float DetailFade = 1.0f; + const float Macro = Context != nullptr && Context->bMacroXYPrecomputed + ? EvaluateMacroFromBrick(*Plan, *Context, WorldX, WorldY, WorldZ, &DetailFade) + : EvaluateMacro(*Plan, WorldX, WorldY, WorldZ, &DetailFade); + const uint32 DetailSeed = static_cast(Generator.Seed) + ^ static_cast(Plan->StrateIndex * 7919 + 104729); + const float FinalDetail = bHasDetailValue + ? DetailValue : DetailAt(WorldX, WorldY, WorldZ, DetailSeed); + Result = Macro + FinalDetail * DetailFade * DetailScale; + + if (Manager != nullptr && (Context == nullptr || Context->bMayContainPassage)) + { + const FVector Point(WorldX, WorldY, WorldZ); + bool bNearPassage = false; + const auto IsInsideBound = [&Point](const FPassageBound& Bounds) + { return PointMayIntersectPassageBound(Point, Bounds); }; + if (Context != nullptr) + { + for (const int32 BoundsIndex : Context->PassageCandidates) + { + if (IsInsideBound(GPlanCache.PassageBounds[BoundsIndex])) + { + bNearPassage = true; + break; + } + } + } + else + { + for (const FPassageBound& Bounds : GPlanCache.PassageBounds) + { + if (IsInsideBound(Bounds)) + { + bNearPassage = true; + break; + } + } + } + if (bNearPassage) + { + float Internal = -Result; + Manager->ApplyPassageModifier(Internal, WorldX, WorldY, WorldZ, 8.0f, 0.0f); + Result = -Internal; + Manager->ApplyPassageNativeFloorMC(Result, WorldX, WorldY, WorldZ, 8.0f); + } + } + } + + if (Generator.WorldRadiusVoxels > 0.0f) + { + const float Radial = FMath::Sqrt(WorldX * WorldX + WorldY * WorldY); + if (Radial > Generator.WorldRadiusVoxels) + { + const float EdgeDistance = Radial - Generator.WorldRadiusVoxels; + Result = FMath::Min(Result, -FMath::Min(EdgeDistance, Generator.EdgeSealThickness)); + } + else if (Generator.EdgeSealThickness > 0.0f + && Radial > Generator.WorldRadiusVoxels - Generator.EdgeSealThickness) + { + const float Alpha = (Generator.WorldRadiusVoxels - Radial) / Generator.EdgeSealThickness; + Result = FMath::Min(Result, Alpha * 8.0f - 8.0f); + } + } + + if (OutPreEditDensity != nullptr) + { + *OutPreEditDensity = Result; + } + if (Generator.DiffLayer != nullptr + && (Context != nullptr ? Context->bHasDiffMods : Generator.DiffLayer->HasAnyMods())) + { + Result -= Generator.DiffLayer->GetDensityOffset(Chunk, WorldX, WorldY, WorldZ); + } + return Result; +} + +float Evaluate(const UVoxelGenerator& Generator, float WorldX, float WorldY, float WorldZ, + float* OutPreEditDensity, float DetailScale) +{ + return EvaluateInternal(Generator, WorldX, WorldY, WorldZ, 0.0f, false, + OutPreEditDensity, DetailScale); +} + +float EvaluateWithDetailValue(const UVoxelGenerator& Generator, + float WorldX, float WorldY, float WorldZ, float DetailValue, + float* OutPreEditDensity, float DetailScale, + const FBrickEvaluationContext* Context) +{ + return EvaluateInternal(Generator, WorldX, WorldY, WorldZ, DetailValue, true, + OutPreEditDensity, DetailScale, Context); +} + +void BuildDetailBrick(const UVoxelGenerator& Generator, FIntVector OriginVoxels, + FIntVector Size, float DetailScale, TArray& OutDetail, + FBrickEvaluationContext* OutContext) +{ + (void)DetailScale; + const int32 SampleCount = Size.X * Size.Y * Size.Z; + OutDetail.SetNumUninitialized(SampleCount); + FMemory::Memzero(OutDetail.GetData(), static_cast(SampleCount) * sizeof(float)); + if (OutContext != nullptr) + { + OutContext->PlanToken = nullptr; + OutContext->bPlanResolved = false; + OutContext->bMayContainPassage = true; + OutContext->bChunkResolved = false; + OutContext->bIsGapChunk = false; + OutContext->bHasDiffMods = false; + OutContext->bMacroXYPrecomputed = false; + OutContext->FloorByXY.Reset(); + OutContext->GridAirByXY.Reset(); + OutContext->DetailFadeByXY.Reset(); + OutContext->LandingHorizontalByXY.Reset(); + OutContext->LandingApproachByXY.Reset(); + OutContext->LandingBridgeHorizontalByXY.Reset(); + OutContext->LandingBridgeFloorByXY.Reset(); + OutContext->PassageCandidates.Reset(); + } + if (Size.X <= 0 || Size.Y <= 0 || Size.Z <= 0 + || Size.X > 8 || Size.Y > 8 || Size.Z > 8 + || Generator.StrateManager == nullptr) + { + return; + } + + EnsurePlan(Generator); + const FIntVector Chunk( + FMath::FloorToInt(static_cast(OriginVoxels.X) / CHUNK_SIZE), + FMath::FloorToInt(static_cast(OriginVoxels.Y) / CHUNK_SIZE), + FMath::FloorToInt(static_cast(OriginVoxels.Z) / CHUNK_SIZE)); + const FIntVector EndChunk( + FMath::FloorToInt(static_cast(OriginVoxels.X + Size.X - 1) / CHUNK_SIZE), + FMath::FloorToInt(static_cast(OriginVoxels.Y + Size.Y - 1) / CHUNK_SIZE), + FMath::FloorToInt(static_cast(OriginVoxels.Z + Size.Z - 1) / CHUNK_SIZE)); + const bool bStableStrate = Chunk.Z == EndChunk.Z; + const FStratePlan* Plan = FindStrate(Chunk); + if (OutContext != nullptr) + { + OutContext->BrickOrigin = OriginVoxels; + OutContext->BrickSize = Size; + OutContext->bPlanResolved = bStableStrate; + OutContext->PlanToken = bStableStrate ? Plan : nullptr; + OutContext->Chunk = Chunk; + OutContext->bChunkResolved = Chunk == EndChunk; + OutContext->bIsGapChunk = OutContext->bChunkResolved + && Generator.StrateManager->IsGapChunk(Chunk); + OutContext->bHasDiffMods = Generator.DiffLayer != nullptr + && Generator.DiffLayer->HasAnyMods(); + const FVector Min( + static_cast(OriginVoxels.X - 1), + static_cast(OriginVoxels.Y - 1), + static_cast(OriginVoxels.Z - 1)); + const FVector Max( + static_cast(OriginVoxels.X + Size.X), + static_cast(OriginVoxels.Y + Size.Y), + static_cast(OriginVoxels.Z + Size.Z)); + FindPassageCandidates(FBox(Min, Max), OutContext->PassageCandidates); + OutContext->bMayContainPassage = !OutContext->PassageCandidates.IsEmpty(); + } + if (!bStableStrate) + { + // A clipped ring-edge brick can span two strate plans. Keep its detail field exact by + // using the direct evaluator there; this is a tiny boundary-only fallback. + for (int32 Z = 0; Z < Size.Z; ++Z) + for (int32 Y = 0; Y < Size.Y; ++Y) + for (int32 X = 0; X < Size.X; ++X) + { + const int32 WorldZ = OriginVoxels.Z + Z; + const FIntVector SampleChunk(0, 0, + FMath::FloorToInt(static_cast(WorldZ) / CHUNK_SIZE)); + const FStratePlan* SamplePlan = FindStrate(SampleChunk); + if (SamplePlan != nullptr) + { + const uint32 SampleSeed = static_cast(Generator.Seed) + ^ static_cast(SamplePlan->StrateIndex * 7919 + 104729); + OutDetail[(Z * Size.Y + Y) * Size.X + X] = DetailAt( + static_cast(OriginVoxels.X + X), + static_cast(OriginVoxels.Y + Y), + static_cast(WorldZ), SampleSeed); + } + } + return; + } + if (Plan == nullptr) + { + return; + } + + if (OutContext != nullptr) + { + const int32 XYCount = Size.X * Size.Y; + OutContext->FloorByXY.SetNumUninitialized(XYCount); + OutContext->GridAirByXY.SetNumUninitialized(XYCount); + OutContext->DetailFadeByXY.SetNumUninitialized(XYCount); + OutContext->LandingHorizontalByXY.SetNumUninitialized(XYCount * Plan->Landings.Num()); + OutContext->LandingApproachByXY.SetNumUninitialized(XYCount * Plan->Landings.Num()); + OutContext->LandingBridgeHorizontalByXY.SetNumUninitialized(XYCount * Plan->Landings.Num()); + OutContext->LandingBridgeFloorByXY.SetNumUninitialized(XYCount * Plan->Landings.Num()); + for (int32 Y = 0; Y < Size.Y; ++Y) + for (int32 X = 0; X < Size.X; ++X) + { + const int32 XYIndex = Y * Size.X + X; + const float WorldX = static_cast(OriginVoxels.X + X); + const float WorldY = static_cast(OriginVoxels.Y + Y); + const float XLane = DistanceToPeriodicLine( + WorldX, Plan->CellSpacing, Plan->OffsetX); + const float YLane = DistanceToPeriodicLine( + WorldY, Plan->CellSpacing, Plan->OffsetY); + OutContext->GridAirByXY[XYIndex] = Plan->CorridorHalfWidth + - FMath::Min(XLane, YLane); + float WeightedDelta = 0.0f; + float TotalWeight = 0.0f; + float DetailFade = 1.0f; + for (int32 SiteIndex = 0; SiteIndex < Plan->Landings.Num(); ++SiteIndex) + { + const FLandingSite& Site = Plan->Landings[SiteIndex]; + const float DX = WorldX - Site.Position.X; + const float DY = WorldY - Site.Position.Y; + const float DistanceSquared = DX * DX + DY * DY; + const float Distance = FMath::Sqrt(DistanceSquared); + if (DistanceSquared < Site.BlendRadius * Site.BlendRadius) + { + const float Weight = Fade(1.0f - Distance / Site.BlendRadius); + WeightedDelta += Weight * (Site.FloorZ - Plan->FloorZ); + TotalWeight += Weight; + } + OutContext->LandingHorizontalByXY[SiteIndex * XYCount + XYIndex] = + Site.PadRadius - Distance; + OutContext->LandingApproachByXY[SiteIndex * XYCount + XYIndex] = + LandingApproachRadius - FMath::Sqrt(DistanceSquaredToSegmentXY( + WorldX, WorldY, Site.Position, Site.ApproachEnd)); + float BridgeHorizontal = -FLT_MAX; + float BridgeFloor = Plan->FloorZ; + if (Plan->LandingHubIndex != INDEX_NONE + && Plan->Landings.Num() > 1 + && SiteIndex != Plan->LandingHubIndex) + { + const FLandingSite& Hub = Plan->Landings[Plan->LandingHubIndex]; + const float T = SegmentParameterXY( + WorldX, WorldY, Site.Position, Hub.Position); + BridgeHorizontal = LandingBridgeRadius - FMath::Sqrt( + DistanceSquaredToSegmentXY(WorldX, WorldY, Site.Position, Hub.Position)); + BridgeFloor = FMath::Lerp(Site.FloorZ, Hub.FloorZ, T); + } + OutContext->LandingBridgeHorizontalByXY[SiteIndex * XYCount + XYIndex] = + BridgeHorizontal; + OutContext->LandingBridgeFloorByXY[SiteIndex * XYCount + XYIndex] = BridgeFloor; + const float FlatRadius = FMath::Max(0.0f, Site.PadRadius - 3.0f); + if (DistanceSquared < FlatRadius * FlatRadius) + { + DetailFade = 0.0f; + } + } + OutContext->FloorByXY[XYIndex] = Plan->FloorZ + + WeightedDelta / FMath::Max(1.0f, TotalWeight); + OutContext->DetailFadeByXY[XYIndex] = DetailFade; + } + OutContext->bMacroXYPrecomputed = true; + } + + const uint32 Seed = static_cast(Generator.Seed) + ^ static_cast(Plan->StrateIndex * 7919 + 104729); + const int32 CountX = Size.X; + const int32 CountY = Size.Y; + const auto SampleIndex = [CountX, CountY](int32 X, int32 Y, int32 Z) + { + return (Z * CountY + Y) * CountX + X; + }; + const auto AddNoiseBand = [&](int32 CellSize, uint32 BandSeed, float Amplitude) + { + const int32 MinCellX = FMath::FloorToInt( + static_cast(OriginVoxels.X) / static_cast(CellSize)); + const int32 MinCellY = FMath::FloorToInt( + static_cast(OriginVoxels.Y) / static_cast(CellSize)); + const int32 MinCellZ = FMath::FloorToInt( + static_cast(OriginVoxels.Z) / static_cast(CellSize)); + const int32 MaxCellX = FMath::FloorToInt( + static_cast(OriginVoxels.X + Size.X - 1) / static_cast(CellSize)); + const int32 MaxCellY = FMath::FloorToInt( + static_cast(OriginVoxels.Y + Size.Y - 1) / static_cast(CellSize)); + const int32 MaxCellZ = FMath::FloorToInt( + static_cast(OriginVoxels.Z + Size.Z - 1) / static_cast(CellSize)); + const int32 GridX = MaxCellX - MinCellX + 2; + const int32 GridY = MaxCellY - MinCellY + 2; + const int32 GridZ = MaxCellZ - MinCellZ + 2; + float Grid[1000]; + check(GridX * GridY * GridZ <= UE_ARRAY_COUNT(Grid)); + for (int32 Z = 0; Z < GridZ; ++Z) + for (int32 Y = 0; Y < GridY; ++Y) + for (int32 X = 0; X < GridX; ++X) + { + Grid[(Z * GridY + Y) * GridX + X] = HashValue( + MinCellX + X, MinCellY + Y, MinCellZ + Z, BandSeed); + } + + for (int32 Z = 0; Z < Size.Z; ++Z) + for (int32 Y = 0; Y < Size.Y; ++Y) + for (int32 X = 0; X < Size.X; ++X) + { + const float SX = static_cast(OriginVoxels.X + X) + / static_cast(CellSize); + const float SY = static_cast(OriginVoxels.Y + Y) + / static_cast(CellSize); + const float SZ = static_cast(OriginVoxels.Z + Z) + / static_cast(CellSize); + const int32 IX = FMath::FloorToInt(SX) - MinCellX; + const int32 IY = FMath::FloorToInt(SY) - MinCellY; + const int32 IZ = FMath::FloorToInt(SZ) - MinCellZ; + const float AX = Fade(SX - static_cast(FMath::FloorToInt(SX))); + const float AY = Fade(SY - static_cast(FMath::FloorToInt(SY))); + const float AZ = Fade(SZ - static_cast(FMath::FloorToInt(SZ))); + const int32 Index = (IZ * GridY + IY) * GridX + IX; + const float Value = CellSize == 1 + ? Grid[Index] + : FMath::Lerp( + FMath::Lerp( + FMath::Lerp(Grid[Index], Grid[Index + 1], AX), + FMath::Lerp(Grid[Index + GridX], Grid[Index + GridX + 1], AX), AY), + FMath::Lerp( + FMath::Lerp(Grid[Index + GridX * GridY], + Grid[Index + GridX * GridY + 1], AX), + FMath::Lerp(Grid[Index + GridX * GridY + GridX], + Grid[Index + GridX * GridY + GridX + 1], AX), AY), AZ); + OutDetail[SampleIndex(X, Y, Z)] += Value * Amplitude; + } + }; + + AddNoiseBand(8, Seed ^ 0x68BC21EBu, 1.4f); + AddNoiseBand(4, Seed ^ 0x02E5BE93u, 0.9f); + AddNoiseBand(2, Seed ^ 0x967A889Bu, 0.45f); + AddNoiseBand(1, Seed ^ 0xA511E9B3u, 2.5f); +} + +bool TryClassifyBrick(const UVoxelGenerator& Generator, FIntVector OriginVoxels, + FIntVector Size, bool& bOutAir, float DetailScale, + bool* bOutEvaluatedMacroCentre, + EBrickFallbackReason* OutFallbackReason) +{ + if (OutFallbackReason != nullptr) + { + *OutFallbackReason = EBrickFallbackReason::UnsupportedState; + } + if (bOutEvaluatedMacroCentre != nullptr) + { + *bOutEvaluatedMacroCentre = false; + } + if (Size.X <= 0 || Size.Y <= 0 || Size.Z <= 0 + || Size.X > 8 || Size.Y > 8 || Size.Z > 8 + || Generator.StrateManager == nullptr + || Generator.WorldRadiusVoxels != 0.0f + || (Generator.DiffLayer != nullptr && Generator.DiffLayer->HasAnyMods())) + { + return false; + } + EnsurePlan(Generator); + const int32 MinChunkX = FMath::FloorToInt( + static_cast(OriginVoxels.X - 1) / CHUNK_SIZE); + const int32 MaxChunkX = FMath::FloorToInt( + static_cast(OriginVoxels.X + Size.X) / CHUNK_SIZE); + const int32 MinChunkY = FMath::FloorToInt( + static_cast(OriginVoxels.Y - 1) / CHUNK_SIZE); + const int32 MaxChunkY = FMath::FloorToInt( + static_cast(OriginVoxels.Y + Size.Y) / CHUNK_SIZE); + const int32 MinChunkZ = FMath::FloorToInt( + static_cast(OriginVoxels.Z - 1) / CHUNK_SIZE); + const int32 MaxChunkZ = FMath::FloorToInt( + static_cast(OriginVoxels.Z + Size.Z) / CHUNK_SIZE); + const FIntVector Chunk( + MinChunkX, MinChunkY, MinChunkZ); + const FStratePlan* Plan = FindStrate(Chunk); + for (int32 ChunkZ = MinChunkZ; ChunkZ <= MaxChunkZ; ++ChunkZ) + { + if (FindStrate(FIntVector(MinChunkX, MinChunkY, ChunkZ)) != Plan) + { + if (OutFallbackReason != nullptr) + { + *OutFallbackReason = EBrickFallbackReason::ChunkOrStrateBoundary; + } + return false; + } + } + const FVector Min( + static_cast(OriginVoxels.X - 1), + static_cast(OriginVoxels.Y - 1), + static_cast(OriginVoxels.Z - 1)); + const FVector Max( + static_cast(OriginVoxels.X + Size.X), + static_cast(OriginVoxels.Y + Size.Y), + static_cast(OriginVoxels.Z + Size.Z)); + const FBox Expanded(Min, Max); + if (IntersectsPassageBounds(Expanded)) + { + if (OutFallbackReason != nullptr) + { + *OutFallbackReason = EBrickFallbackReason::PassageBounds; + } + return false; + } + if (Plan == nullptr) + { + // Outside a strate there is no added detail: gaps are bedrock and out-of-layout chunks + // are air. Passage bounds above keep all carved tubes, landings, and support writes mixed. + const bool bGap = Generator.StrateManager->IsGapChunk(Chunk); + for (int32 ChunkZ = MinChunkZ; ChunkZ <= MaxChunkZ; ++ChunkZ) + for (int32 ChunkY = MinChunkY; ChunkY <= MaxChunkY; ++ChunkY) + for (int32 ChunkX = MinChunkX; ChunkX <= MaxChunkX; ++ChunkX) + { + if (Generator.StrateManager->IsGapChunk(FIntVector(ChunkX, ChunkY, ChunkZ)) != bGap) + { + if (OutFallbackReason != nullptr) + { + *OutFallbackReason = EBrickFallbackReason::ChunkOrStrateBoundary; + } + return false; + } + } + bOutAir = !bGap; + if (OutFallbackReason != nullptr) + { + *OutFallbackReason = EBrickFallbackReason::None; + } + return true; + } + if (Plan->StrateIndex < 0 + || MinChunkZ < Plan->MinChunkZ || MaxChunkZ >= Plan->MaxChunkZExclusive) + { + if (OutFallbackReason != nullptr) + { + *OutFallbackReason = EBrickFallbackReason::ChunkOrStrateBoundary; + } + return false; + } + + // Bound the spatial landing-floor blend over this brick instead of disabling proofs near + // every landing. The floor is a weighted average while the total weight is >= 1, and an + // unnormalised sum below that threshold; these two derivative bounds cover both branches. + double SumAbsDeltaSlope = 0.0; + double SumInverseRadiusSlope = 0.0; + double LandingBridgeSlopeBound = 0.0; + float MaxAbsFloorDelta = 0.0f; + for (const FLandingSite& Site : Plan->Landings) + { + const float ClosestX = FMath::Clamp(Site.Position.X, Min.X, Max.X); + const float ClosestY = FMath::Clamp(Site.Position.Y, Min.Y, Max.Y); + const float DX = ClosestX - Site.Position.X; + const float DY = ClosestY - Site.Position.Y; + if (DX * DX + DY * DY > Site.BlendRadius * Site.BlendRadius) + { + continue; + } + const float AbsDelta = FMath::Abs(Site.FloorZ - Plan->FloorZ); + MaxAbsFloorDelta = FMath::Max(MaxAbsFloorDelta, AbsDelta); + SumAbsDeltaSlope += QuinticFadeSlopeBound * AbsDelta / Site.BlendRadius; + SumInverseRadiusSlope += QuinticFadeSlopeBound / Site.BlendRadius; + } + if (Plan->LandingHubIndex != INDEX_NONE && Plan->Landings.Num() > 1) + { + const FLandingSite& Hub = Plan->Landings[Plan->LandingHubIndex]; + for (int32 SiteIndex = 0; SiteIndex < Plan->Landings.Num(); ++SiteIndex) + { + if (SiteIndex == Plan->LandingHubIndex) + { + continue; + } + const FLandingSite& Site = Plan->Landings[SiteIndex]; + const float SegmentLength = FMath::Sqrt(FMath::Square(Hub.Position.X - Site.Position.X) + + FMath::Square(Hub.Position.Y - Site.Position.Y)); + if (SegmentLength <= KINDA_SMALL_NUMBER) + { + continue; + } + const float MinX = FMath::Min(Hub.Position.X, Site.Position.X) - LandingBridgeRadius; + const float MaxX = FMath::Max(Hub.Position.X, Site.Position.X) + LandingBridgeRadius; + const float MinY = FMath::Min(Hub.Position.Y, Site.Position.Y) - LandingBridgeRadius; + const float MaxY = FMath::Max(Hub.Position.Y, Site.Position.Y) + LandingBridgeRadius; + if (MaxX < Min.X || MinX > Max.X || MaxY < Min.Y || MinY > Max.Y) + { + continue; + } + LandingBridgeSlopeBound = FMath::Max( + LandingBridgeSlopeBound, + static_cast(FMath::Abs(Hub.FloorZ - Site.FloorZ) / SegmentLength)); + } + } + const double FloorSlopeBound = FMath::Max( + FMath::Max(SumAbsDeltaSlope, 2.0 * MaxAbsFloorDelta * SumInverseRadiusSlope), + LandingBridgeSlopeBound); + const float MacroLipschitzBound = FMath::Max( + BaseFieldLipschitzBound, + FMath::Sqrt(1.0f + FMath::Square(static_cast(FloorSlopeBound))) + 0.25f); + + const FVector Centre = (Min + Max) * 0.5f; + const FVector HalfExtent = (Max - Min) * 0.5f; + const float Radius = HalfExtent.Size(); + if (bOutEvaluatedMacroCentre != nullptr) + { + *bOutEvaluatedMacroCentre = true; + } + const float Macro = EvaluateMacro(*Plan, Centre.X, Centre.Y, Centre.Z, nullptr); + const float FinalBound = MacroLipschitzBound * Radius + + DetailAmplitude * FMath::Abs(DetailScale) + + DetailBoundNumericalMargin * FMath::Max(1.0f, FMath::Abs(DetailScale)); + if (Macro > FinalBound) + { + bOutAir = true; + if (OutFallbackReason != nullptr) + { + *OutFallbackReason = EBrickFallbackReason::None; + } + return true; + } + if (Macro < -FinalBound) + { + bOutAir = false; + if (OutFallbackReason != nullptr) + { + *OutFallbackReason = EBrickFallbackReason::None; + } + return true; + } + if (OutFallbackReason != nullptr) + { + *OutFallbackReason = EBrickFallbackReason::MixedMacroInterval; + } + return false; +} +} + +#endif // WITH_EDITOR diff --git a/Source/VoxelForge/Public/VoxelRedefinedDensityField.h b/Source/VoxelForge/Public/VoxelRedefinedDensityField.h new file mode 100644 index 0000000..20741dc --- /dev/null +++ b/Source/VoxelForge/Public/VoxelRedefinedDensityField.h @@ -0,0 +1,90 @@ +#pragma once + +#include "CoreMinimal.h" + +class UVoxelGenerator; + +#if WITH_EDITOR +/** + * Step 2d editor research field. It is intentionally separate from the gameplay evaluator and + * is selected only by the default-off voxel.ResearchRedefinedDensityField console variable. + * + * The field is a connected, archetype-sized cave lattice with an authored walkable floor at each + * passage landing. Its macro shape is evaluated directly in world space; bounded, continuous + * hash-value noise is added at every full-resolution sample. The brick classifier is permitted to + * skip only when a Lipschitz bound on the macro field plus the exact maximum detail amplitude + * proves that the final sign is uniform throughout the brick and its marching-cubes halo. + */ +namespace VoxelForgeRedefinedDensityField +{ + /** Per-worker, per-brick state. The opaque plan pointer is valid until the layout changes. */ + struct FBrickEvaluationContext + { + const void* PlanToken = nullptr; + bool bPlanResolved = false; + bool bMayContainPassage = true; + bool bChunkResolved = false; + bool bIsGapChunk = false; + bool bHasDiffMods = false; + bool bMacroXYPrecomputed = false; + FIntVector BrickOrigin = FIntVector::ZeroValue; + FIntVector BrickSize = FIntVector::ZeroValue; + FIntVector Chunk = FIntVector::ZeroValue; + TArray FloorByXY; + TArray GridAirByXY; + TArray DetailFadeByXY; + TArray LandingHorizontalByXY; + TArray LandingApproachByXY; + TArray LandingBridgeHorizontalByXY; + TArray LandingBridgeFloorByXY; + TArray PassageCandidates; + }; + + enum class EBrickFallbackReason : uint8 + { + None, + UnsupportedState, + ChunkOrStrateBoundary, + PassageBounds, + MixedMacroInterval + }; + + struct FBrickCounters + { + uint64 Tested = 0; + uint64 Skipped = 0; + uint64 EvaluatedSamples = 0; + }; + + VOXELFORGE_API bool IsEnabled(); + VOXELFORGE_API void SetEnabled(bool bEnabled); + VOXELFORGE_API float GetDetailScale(); + VOXELFORGE_API void SetDetailScale(float Scale); + + /** Evaluate the new field, including existing passage carving and player diffs. */ + VOXELFORGE_API float Evaluate(const UVoxelGenerator& Generator, float WorldX, float WorldY, + float WorldZ, float* OutPreEditDensity = nullptr, + float DetailScale = 1.0f); + + /** Build mathematically identical multi-scale noise for one integer-lattice brick. */ + VOXELFORGE_API void BuildDetailBrick(const UVoxelGenerator& Generator, + FIntVector OriginVoxels, FIntVector Size, float DetailScale, + TArray& OutDetail, FBrickEvaluationContext* OutContext = nullptr); + + /** Evaluate with a detail value produced by BuildDetailBrick. */ + VOXELFORGE_API float EvaluateWithDetailValue(const UVoxelGenerator& Generator, + float WorldX, float WorldY, float WorldZ, float DetailValue, + float* OutPreEditDensity = nullptr, float DetailScale = 1.0f, + const FBrickEvaluationContext* Context = nullptr); + + /** Prove a world-aligned box has one final density sign; false means evaluate every point. */ + VOXELFORGE_API bool TryClassifyBrick( + const UVoxelGenerator& Generator, FIntVector OriginVoxels, FIntVector Size, + bool& bOutAir, float DetailScale = 1.0f, + bool* bOutEvaluatedMacroCentre = nullptr, + EBrickFallbackReason* OutFallbackReason = nullptr); + + /** Reset this thread's immutable plan cache; used by deterministic editor experiments. */ + VOXELFORGE_API void ResetThreadCache(); +} +#endif diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp index 6c79e4b..142b984 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -34,6 +34,7 @@ #include "VoxelGenerator.h" #include "VoxelGeometryHash.h" #include "VoxelMarchingCubesMesher.h" +#include "VoxelRedefinedDensityField.h" #include "VoxelNoise.h" #include "VoxelPassageGeometry.h" #include "VoxelSettings.h" @@ -1509,6 +1510,9 @@ struct FRegionEvalStackSamplerSession } }; +bool SavePng(const FString& FilePath, const TArray& Pixels, + int32 Width, int32 Height, FString& OutError); + int32 FindRoomGraphRecipeIndex(int32 Seed) { for (int32 Index = 0; Index < 64; ++Index) @@ -2511,6 +2515,829 @@ int32 RunRegionEval(const FString& Params) return bPassed ? 0 : 2; } +struct FStep2dSurfaceSpectrum +{ + uint64 SurfaceBandSamples = 0; + double RMSLag1 = 0.0; + double RMSLag2 = 0.0; + double RMSLag4 = 0.0; +}; + +struct FStep2dBoundaryStats +{ + uint64 BrickBoundarySamples = 0; + uint64 InteriorSamples = 0; + uint64 ChunkBoundarySamples = 0; + uint64 FeatureCellBoundarySamples = 0; + uint64 FeatureCellInteriorSamples = 0; + double BrickJumpRms = 0.0; + double InteriorJumpRms = 0.0; + double ChunkBoundaryJumpRms = 0.0; + double FeatureCellJumpRms = 0.0; + double FeatureCellInteriorJumpRms = 0.0; + double BrickJumpMax = 0.0; + double ChunkBoundaryJumpMax = 0.0; + double FeatureCellJumpMax = 0.0; +}; + +struct FStep2dBrickProofAudit +{ + uint64 CorrectlySkippedBricksAudited = 0; + uint64 CorrectProofSamplesChecked = 0; + uint64 CorrectProofViolations = 0; + uint64 DeliberatelyUndersizedBoundClaimsChecked = 0; + uint64 DeliberateBoundDisagreements = 0; +}; + +FStep2dBrickProofAudit AuditStep2dBrickProofs( + const UVoxelGenerator& Generator, + FIntVector Origin, + int32 Dimension) +{ + FStep2dBrickProofAudit Result; + const FIntVector Last = Origin + FIntVector(Dimension - 1, Dimension - 1, Dimension - 1); + const FIntVector FirstBrick( + FMath::FloorToInt(static_cast(Origin.X) / 8.0f), + FMath::FloorToInt(static_cast(Origin.Y) / 8.0f), + FMath::FloorToInt(static_cast(Origin.Z) / 8.0f)); + const FIntVector LastBrick( + FMath::FloorToInt(static_cast(Last.X) / 8.0f), + FMath::FloorToInt(static_cast(Last.Y) / 8.0f), + FMath::FloorToInt(static_cast(Last.Z) / 8.0f)); + uint64 CorrectSkipOrdinal = 0; + for (int32 BZ = FirstBrick.Z; BZ <= LastBrick.Z; ++BZ) + for (int32 BY = FirstBrick.Y; BY <= LastBrick.Y; ++BY) + for (int32 BX = FirstBrick.X; BX <= LastBrick.X; ++BX) + { + const FIntVector BrickWorldMin(BX * 8, BY * 8, BZ * 8); + const FIntVector BrickOrigin( + FMath::Max(Origin.X, BrickWorldMin.X), + FMath::Max(Origin.Y, BrickWorldMin.Y), + FMath::Max(Origin.Z, BrickWorldMin.Z)); + const FIntVector End( + FMath::Min(BrickWorldMin.X + 8, Origin.X + Dimension), + FMath::Min(BrickWorldMin.Y + 8, Origin.Y + Dimension), + FMath::Min(BrickWorldMin.Z + 8, Origin.Z + Dimension)); + const FIntVector Size = End - BrickOrigin; + bool bProvenAir = false; + if (VoxelForgeRedefinedDensityField::TryClassifyBrick( + Generator, BrickOrigin, Size, bProvenAir, 1.0f)) + { + const bool bAuditThisSkip = (CorrectSkipOrdinal++ % 128u) == 0u; + if (bAuditThisSkip) + { + ++Result.CorrectlySkippedBricksAudited; + for (int32 Z = -1; Z <= Size.Z; ++Z) + for (int32 Y = -1; Y <= Size.Y; ++Y) + for (int32 X = -1; X <= Size.X; ++X) + { + const float Density = VoxelForgeRedefinedDensityField::Evaluate( + Generator, + static_cast(BrickOrigin.X + X), + static_cast(BrickOrigin.Y + Y), + static_cast(BrickOrigin.Z + Z), nullptr, 1.0f); + ++Result.CorrectProofSamplesChecked; + if ((Density > 0.0f) != bProvenAir) + { + ++Result.CorrectProofViolations; + } + } + } + } + + // Negative control: classify while omitting all detail, then check the supported + // maximum detail amplitude. A valid supremum must reject at least some of these + // sign claims; otherwise this audit would not demonstrate that it can catch a bad bound. + bool bDeliberatelyClaimedAir = false; + if (VoxelForgeRedefinedDensityField::TryClassifyBrick( + Generator, BrickOrigin, Size, bDeliberatelyClaimedAir, 0.0f)) + { + ++Result.DeliberatelyUndersizedBoundClaimsChecked; + for (int32 Z = 0; Z < Size.Z; ++Z) + for (int32 Y = 0; Y < Size.Y; ++Y) + for (int32 X = 0; X < Size.X; ++X) + { + const float MaximumDetailDensity = VoxelForgeRedefinedDensityField::Evaluate( + Generator, + static_cast(BrickOrigin.X + X), + static_cast(BrickOrigin.Y + Y), + static_cast(BrickOrigin.Z + Z), + nullptr, + 4.0f); + if ((MaximumDetailDensity > 0.0f) != bDeliberatelyClaimedAir) + { + ++Result.DeliberateBoundDisagreements; + } + } + } + } + return Result; +} + +FStep2dSurfaceSpectrum ComputeStep2dSpectrum(const TArray& Density, int32 Dimension) +{ + FStep2dSurfaceSpectrum Result; + double SumSquares[3] = {0.0, 0.0, 0.0}; + uint64 Counts[3] = {0, 0, 0}; + const int32 Lags[] = {1, 2, 4}; + for (int32 Z = 4; Z < Dimension - 4; ++Z) + for (int32 Y = 4; Y < Dimension - 4; ++Y) + for (int32 X = 4; X < Dimension - 4; ++X) + { + const int32 CenterIndex = (Z * Dimension + Y) * Dimension + X; + const float Center = Density[CenterIndex]; + if (!FMath::IsFinite(Center) || FMath::Abs(Center) > 12.0f) + { + continue; + } + ++Result.SurfaceBandSamples; + for (int32 LagIndex = 0; LagIndex < UE_ARRAY_COUNT(Lags); ++LagIndex) + { + const int32 Lag = Lags[LagIndex]; + const float Laplacian = + Density[CenterIndex - Lag] + Density[CenterIndex + Lag] + + Density[CenterIndex - Lag * Dimension] + + Density[CenterIndex + Lag * Dimension] + + Density[CenterIndex - Lag * Dimension * Dimension] + + Density[CenterIndex + Lag * Dimension * Dimension] + - 6.0f * Center; + SumSquares[LagIndex] += static_cast(Laplacian) * Laplacian; + ++Counts[LagIndex]; + } + } + Result.RMSLag1 = Counts[0] > 0 ? FMath::Sqrt(SumSquares[0] / Counts[0]) : 0.0; + Result.RMSLag2 = Counts[1] > 0 ? FMath::Sqrt(SumSquares[1] / Counts[1]) : 0.0; + Result.RMSLag4 = Counts[2] > 0 ? FMath::Sqrt(SumSquares[2] / Counts[2]) : 0.0; + return Result; +} + +FIntVector SelectStep2dMeshOrigin( + const TArray& OldDensity, + const TArray& NewDensity, + FIntVector RingOrigin, + int32 Dimension, + bool& bOutContainsBothIsoBands) +{ + bOutContainsBothIsoBands = false; + float BestCombinedBand = FLT_MAX; + FIntVector BestSample(Dimension / 2, Dimension / 2, Dimension / 2); + for (int32 Z = 32; Z < Dimension - 32; ++Z) + for (int32 Y = 32; Y < Dimension - 32; ++Y) + for (int32 X = 32; X < Dimension - 32; ++X) + { + const int32 Index = (Z * Dimension + Y) * Dimension + X; + const float OldAbs = FMath::Abs(OldDensity[Index]); + const float NewAbs = FMath::Abs(NewDensity[Index]); + if (OldAbs <= 8.0f && NewAbs <= 8.0f) + { + const float Combined = OldAbs + NewAbs; + if (Combined < BestCombinedBand) + { + BestCombinedBand = Combined; + BestSample = FIntVector(X, Y, Z); + bOutContainsBothIsoBands = true; + } + } + } + if (!bOutContainsBothIsoBands) + { + float BestNewBand = FLT_MAX; + for (int32 Z = 32; Z < Dimension - 32; ++Z) + for (int32 Y = 32; Y < Dimension - 32; ++Y) + for (int32 X = 32; X < Dimension - 32; ++X) + { + const int32 Index = (Z * Dimension + Y) * Dimension + X; + const float NewAbs = FMath::Abs(NewDensity[Index]); + if (NewAbs < BestNewBand) + { + BestNewBand = NewAbs; + BestSample = FIntVector(X, Y, Z); + } + } + } + return RingOrigin + BestSample - FIntVector(32, 32, 32); +} + +FStep2dBoundaryStats ComputeStep2dBoundaryStats( + const TArray& Density, int32 Dimension, FIntVector Origin, int32 FeatureCellSpacing) +{ + FStep2dBoundaryStats Result; + double BrickSquares = 0.0; + double InteriorSquares = 0.0; + double ChunkSquares = 0.0; + double FeatureSquares = 0.0; + double FeatureInteriorSquares = 0.0; + const int32 CellSpacing = FMath::Max(FeatureCellSpacing, 1); + for (int32 Z = 1; Z < Dimension - 1; ++Z) + for (int32 Y = 1; Y < Dimension - 1; ++Y) + for (int32 X = 1; X < Dimension - 1; ++X) + { + const int32 CenterIndex = (Z * Dimension + Y) * Dimension + X; + const int32 Stride[3] = {1, Dimension, Dimension * Dimension}; + const int32 World[3] = {Origin.X + X, Origin.Y + Y, Origin.Z + Z}; + for (int32 Axis = 0; Axis < 3; ++Axis) + { + const double LeftGradient = Density[CenterIndex] - Density[CenterIndex - Stride[Axis]]; + const double RightGradient = Density[CenterIndex + Stride[Axis]] - Density[CenterIndex]; + const double Jump = FMath::Abs(RightGradient - LeftGradient); + const bool bBrickBoundary = (World[Axis] % 8) == 0; + const bool bChunkBoundary = (World[Axis] % CHUNK_SIZE) == 0 && Axis < 2; + const bool bFeatureBoundary = ((World[Axis] + CellSpacing / 2) % CellSpacing) == 0; + if (FMath::Abs(Density[CenterIndex]) <= 12.0f) + { + if (bBrickBoundary) + { + ++Result.BrickBoundarySamples; + BrickSquares += Jump * Jump; + Result.BrickJumpMax = FMath::Max(Result.BrickJumpMax, Jump); + } + else + { + ++Result.InteriorSamples; + InteriorSquares += Jump * Jump; + } + if (bChunkBoundary) + { + ++Result.ChunkBoundarySamples; + ChunkSquares += Jump * Jump; + Result.ChunkBoundaryJumpMax = FMath::Max(Result.ChunkBoundaryJumpMax, Jump); + } + } + if (bFeatureBoundary) + { + ++Result.FeatureCellBoundarySamples; + FeatureSquares += Jump * Jump; + } + else + { + ++Result.FeatureCellInteriorSamples; + FeatureInteriorSquares += Jump * Jump; + } + if (bFeatureBoundary) + Result.FeatureCellJumpMax = FMath::Max(Result.FeatureCellJumpMax, Jump); + } + } + Result.BrickJumpRms = Result.BrickBoundarySamples > 0 + ? FMath::Sqrt(BrickSquares / Result.BrickBoundarySamples) : 0.0; + Result.InteriorJumpRms = Result.InteriorSamples > 0 + ? FMath::Sqrt(InteriorSquares / Result.InteriorSamples) : 0.0; + Result.ChunkBoundaryJumpRms = Result.ChunkBoundarySamples > 0 + ? FMath::Sqrt(ChunkSquares / Result.ChunkBoundarySamples) : 0.0; + Result.FeatureCellJumpRms = Result.FeatureCellBoundarySamples > 0 + ? FMath::Sqrt(FeatureSquares / Result.FeatureCellBoundarySamples) : 0.0; + Result.FeatureCellInteriorJumpRms = Result.FeatureCellInteriorSamples > 0 + ? FMath::Sqrt(FeatureInteriorSquares / Result.FeatureCellInteriorSamples) : 0.0; + return Result; +} + +FColor Step2dDensityColor(float Density) +{ + if (FMath::Abs(Density) < 1.0f) + { + return FColor(56, 184, 206, 255); + } + return Density >= 0.0f + ? FColor(226, 220, 202, 255) + : FColor(22, 30, 45, 255); +} + +void SaveStep2dSideBySideSlice( + const FString& Path, + const TArray& OldDensity, + const TArray& NewDensity, + int32 Dimension, + TCHAR Plane, + int32 FixedIndex, + FString& OutError) +{ + const int32 Width = Dimension * 2; + const int32 Height = Dimension; + TArray Pixels; + Pixels.SetNumUninitialized(Width * Height); + for (int32 Row = 0; Row < Height; ++Row) + for (int32 Column = 0; Column < Dimension; ++Column) + { + int32 OldIndex = 0; + int32 NewIndex = 0; + if (Plane == TEXT('h')) + { + const int32 X = Column; + const int32 Y = Dimension - 1 - Row; + const int32 Z = FixedIndex; + OldIndex = (Z * Dimension + Y) * Dimension + X; + } + else if (Plane == TEXT('x')) + { + const int32 X = Column; + const int32 Y = FixedIndex; + const int32 Z = Dimension - 1 - Row; + OldIndex = (Z * Dimension + Y) * Dimension + X; + } + else + { + const int32 X = FixedIndex; + const int32 Y = Column; + const int32 Z = Dimension - 1 - Row; + OldIndex = (Z * Dimension + Y) * Dimension + X; + } + NewIndex = OldIndex; + Pixels[Row * Width + Column] = Step2dDensityColor(OldDensity[OldIndex]); + Pixels[Row * Width + Dimension + Column] = Step2dDensityColor(NewDensity[NewIndex]); + } + SavePng(Path, Pixels, Width, Height, OutError); +} + +TSharedRef Step2dTimingJson(const VoxelForgeRegionEval::FTiming& Timing) +{ + TSharedRef Json = MakeShared(); + Json->SetNumberField(TEXT("workers_requested"), Timing.Workers); + Json->SetNumberField(TEXT("workers_observed"), Timing.ObservedWorkerThreads); + Json->SetNumberField(TEXT("lattice_samples"), static_cast(Timing.SampleCount)); + Json->SetNumberField(TEXT("density_samples_evaluated"), static_cast(Timing.EvaluatedSamples)); + Json->SetNumberField(TEXT("samples_skipped_by_brick_proof"), static_cast(Timing.SkippedSamples)); + Json->SetNumberField(TEXT("bricks_tested"), static_cast(Timing.BrickCount)); + Json->SetNumberField(TEXT("bricks_skipped"), static_cast(Timing.SkippedBricks)); + Json->SetNumberField(TEXT("share_of_bricks_skipped"), Timing.BrickCount > 0 + ? static_cast(Timing.SkippedBricks) / static_cast(Timing.BrickCount) : 0.0); + Json->SetNumberField(TEXT("share_of_lattice_samples_skipped"), Timing.SampleCount > 0 + ? static_cast(Timing.SkippedSamples) / static_cast(Timing.SampleCount) : 0.0); + Json->SetNumberField(TEXT("wall_ms"), Timing.WallSeconds * 1000.0); + Json->SetNumberField(TEXT("worker_cpu_seconds"), Timing.WorkerCpuSeconds); + Json->SetNumberField(TEXT("worker_cpu_ns_per_lattice_sample"), Timing.NanosecondsPerSample); + Json->SetNumberField(TEXT("brick_classification_worker_seconds"), Timing.BrickClassificationSeconds); + Json->SetNumberField(TEXT("density_evaluation_worker_seconds"), Timing.DensityEvaluationSeconds); + Json->SetNumberField(TEXT("uniform_fill_worker_seconds"), Timing.UniformFillSeconds); + Json->SetNumberField(TEXT("longest_worker_wall_ms"), Timing.LongestWorkerWallSeconds * 1000.0); + Json->SetNumberField(TEXT("parallel_overhead_wall_ms"), FMath::Max( + 0.0, Timing.WallSeconds - Timing.LongestWorkerWallSeconds) * 1000.0); + Json->SetNumberField(TEXT("brick_classification_calls"), static_cast(Timing.MacroBrickChecks)); + Json->SetNumberField(TEXT("macro_center_evaluations"), static_cast(Timing.MacroCentreEvaluations)); + Json->SetNumberField(TEXT("unsupported_brick_proofs"), static_cast(Timing.UnsupportedBrickProofs)); + Json->SetNumberField(TEXT("chunk_or_strate_boundary_proofs"), + static_cast(Timing.ChunkBoundaryBrickProofs)); + Json->SetNumberField(TEXT("passage_bound_proofs"), static_cast(Timing.PassageBoundBrickProofs)); + Json->SetNumberField(TEXT("mixed_macro_interval_proofs"), static_cast(Timing.MixedMacroBrickProofs)); + Json->SetNumberField(TEXT("room_sdf_evaluations"), static_cast(Timing.RoomEvaluations)); + Json->SetNumberField(TEXT("tunnel_sdf_evaluations"), static_cast(Timing.TunnelEvaluations)); + Json->SetNumberField(TEXT("tunnel_core_evaluations"), static_cast(Timing.TunnelCoreEvaluations)); + Json->SetNumberField(TEXT("passage_sdf_evaluations"), static_cast(Timing.PassageEvaluations)); + Json->SetNumberField(TEXT("geometry_evaluations_per_lattice_sample"), Timing.CandidateEvaluationsPerSample); + Json->SetNumberField(TEXT("quantized_hash"), static_cast(Timing.QuantizedHash)); + Json->SetNumberField(TEXT("raw_float_hash"), static_cast(Timing.RawFloatHash)); + return Json; +} + +int32 RunStep2d(const FString& Params) +{ + FExploreArguments Arguments; + FString Error; + if (!ParseArguments(Params, Arguments, Error)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2d] argument error: %s"), *Error); + return 2; + } + int32 Seed = 1337; + FParse::Value(*Params, TEXT("seed="), Seed); + int32 Workers = VoxelForgeRegionEval::WorkerCount; + FParse::Value(*Params, TEXT("regionworkers="), Workers); + Workers = FMath::Clamp(Workers, 1, 32); + FString ResearchOut; + FParse::Value(*Params, TEXT("researchout="), ResearchOut); + if (ResearchOut.IsEmpty()) + { + ResearchOut = FPaths::Combine(Arguments.OutDirectory, TEXT("Research/step2d")); + } + ResearchOut = FPaths::ConvertRelativePathToFull(ResearchOut); + IFileManager::Get().MakeDirectory(*ResearchOut, true); + IFileManager::Get().MakeDirectory(*Arguments.OutDirectory, true); + + FExploreWorld World; + int32 RecipeIndex = INDEX_NONE; + uint32 RecipeHash = 0; + int32 EditChunkCount = 0; + const double PrepareStartSeconds = FPlatformTime::Seconds(); + if (!PrepareRegionEvalWorld(World, Arguments, Seed, + RecipeIndex, RecipeHash, EditChunkCount, Error)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2d] world preparation failed: %s"), *Error); + return 2; + } + const double WorldPrepareSeconds = FPlatformTime::Seconds() - PrepareStartSeconds; + if (World.DiffLayer != nullptr) + { + World.DiffLayer->Clear(); + } + + const int32 Dimension = VoxelForgeRegionEval::RingDimension; + const int32 CenterZ = (World.TargetTopWorldZ + World.TargetBottomWorldZ) / 2; + const FIntVector Origin(-129, -129, CenterZ - 129); + TArray OldTrials; + TArray NewTrials; + TArray OldDensities; + TArray NewDensities; + TArray NewRepeatDensities; + TArray NewSkippedDensities; + TArray SmoothDensities; + TArray SingleWorkerDensities; + VoxelForgeRedefinedDensityField::SetEnabled(false); + VoxelForgeRedefinedDensityField::SetDetailScale(1.0f); + + for (int32 Trial = 0; Trial < 3; ++Trial) + { + VoxelForgeRegionEval::FTiming Timing; + if (!VoxelForgeRegionEval::Evaluate( + *World.Generator, Origin, Dimension, nullptr, true, false, 1.0f, + &OldDensities, Timing, false, Workers, false)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2d] old field timing pass %d failed."), Trial + 1); + return 2; + } + OldTrials.Add(Timing); + } + + // This fourth old-field pass carries counters; its wall time is deliberately excluded from + // the performance row so instrumentation does not contaminate the measured baseline. + VoxelForgeRegionEval::FTiming OldCounters; + if (!VoxelForgeRegionEval::Evaluate( + *World.Generator, Origin, Dimension, nullptr, true, false, 1.0f, + nullptr, OldCounters, false, Workers, true)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2d] old field counter pass failed.")); + return 2; + } + OldCounters.WallSeconds = OldTrials.Last().WallSeconds; + OldCounters.WorkerCpuSeconds = OldTrials.Last().WorkerCpuSeconds; + OldCounters.NanosecondsPerSample = OldTrials.Last().NanosecondsPerSample; + + for (int32 Trial = 0; Trial < 3; ++Trial) + { + VoxelForgeRegionEval::FTiming Timing; + if (!VoxelForgeRegionEval::EvaluateRedefined( + *World.Generator, Origin, Dimension, Workers, 1.0f, + NewSkippedDensities, Timing, true)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2d] redefined 6-worker pass %d failed."), Trial + 1); + return 2; + } + NewTrials.Add(Timing); + } + VoxelForgeRegionEval::FTiming OneWorkerTiming; + if (!VoxelForgeRegionEval::EvaluateRedefined( + *World.Generator, Origin, Dimension, 1, 1.0f, + SingleWorkerDensities, OneWorkerTiming, false)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2d] 1-worker determinism pass failed.")); + return 2; + } + VoxelForgeRegionEval::FTiming NewFullSixWorkerTiming; + if (!VoxelForgeRegionEval::EvaluateRedefined( + *World.Generator, Origin, Dimension, Workers, 1.0f, + NewDensities, NewFullSixWorkerTiming, false)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2d] full-detail six-worker evidence pass failed.")); + return 2; + } + VoxelForgeRegionEval::FTiming NewRepeatTiming; + if (!VoxelForgeRegionEval::EvaluateRedefined( + *World.Generator, Origin, Dimension, Workers, 1.0f, + NewRepeatDensities, NewRepeatTiming, false)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2d] repeated full-detail hash pass failed.")); + return 2; + } + VoxelForgeRegionEval::FTiming SmoothTiming; + if (!VoxelForgeRegionEval::EvaluateRedefined( + *World.Generator, Origin, Dimension, Workers, 0.0f, + SmoothDensities, SmoothTiming, false)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2d] deliberate smoothing control failed.")); + return 2; + } + + int64 DetailBatchParitySamples = 0; + double DetailBatchMaxAbsoluteError = 0.0; + const int64 TotalLatticeSamples = static_cast(Dimension) * Dimension * Dimension; + for (int64 Index = 0; Index < TotalLatticeSamples; Index += 2047) + { + const int32 X = static_cast(Index % Dimension); + const int32 Y = static_cast((Index / Dimension) % Dimension); + const int32 Z = static_cast(Index / (static_cast(Dimension) * Dimension)); + const float RawDetailField = VoxelForgeRedefinedDensityField::Evaluate( + *World.Generator, static_cast(Origin.X + X), + static_cast(Origin.Y + Y), static_cast(Origin.Z + Z), nullptr, 1.0f); + DetailBatchMaxAbsoluteError = FMath::Max( + DetailBatchMaxAbsoluteError, + static_cast(FMath::Abs(RawDetailField - NewDensities[static_cast(Index)]))); + ++DetailBatchParitySamples; + } + const bool bDetailBatchMatchesRaw = DetailBatchMaxAbsoluteError <= 1.0e-5; + + const FStep2dSurfaceSpectrum OldSpectrum = ComputeStep2dSpectrum(OldDensities, Dimension); + const FStep2dSurfaceSpectrum NewSpectrum = ComputeStep2dSpectrum(NewDensities, Dimension); + const FStep2dSurfaceSpectrum SmoothSpectrum = ComputeStep2dSpectrum(SmoothDensities, Dimension); + const FStep2dBrickProofAudit BrickProofAudit = AuditStep2dBrickProofs( + *World.Generator, Origin, Dimension); + const int32 FeatureCellSpacing = World.Definitions.IsValidIndex(Arguments.Slot) + && World.Definitions[Arguments.Slot].IsValid() + && World.Definitions[Arguments.Slot]->GeneratorType == ECaveGeneratorType::Maze + ? 96 : 128; + const FStep2dBoundaryStats BoundaryStats = ComputeStep2dBoundaryStats( + NewDensities, Dimension, Origin, FeatureCellSpacing); + + const bool bHashesAgree = OldDensities.Num() == Dimension * Dimension * Dimension + && VoxelForgeRegionEval::HashQuantized(NewDensities) + == VoxelForgeRegionEval::HashQuantized(SingleWorkerDensities) + && VoxelForgeRegionEval::HashQuantized(NewDensities) + == VoxelForgeRegionEval::HashQuantized(NewRepeatDensities) + && NewFullSixWorkerTiming.RawFloatHash == OneWorkerTiming.RawFloatHash + && NewFullSixWorkerTiming.RawFloatHash == NewRepeatTiming.RawFloatHash; + + VoxelForgeRedefinedDensityField::SetEnabled(false); + VoxelForgeRedefinedDensityField::SetDetailScale(1.0f); + bool bMeshContainsBothIsoBands = false; + const FIntVector MeshOrigin = SelectStep2dMeshOrigin( + OldDensities, NewDensities, Origin, Dimension, bMeshContainsBothIsoBands); + const FVoxelMeshData OldMesh = World.Mesher->GenerateMesh(MeshOrigin, 1, 64); + const int32 OldTriangleCount = FMath::Max(0, OldMesh.Triangles.Num() / 3 - OldMesh.NumSkirtTriangles); + VoxelForgeRedefinedDensityField::SetEnabled(true); + const FVoxelMeshData NewMesh = World.Mesher->GenerateMesh(MeshOrigin, 1, 64); + const int32 NewTriangleCount = FMath::Max(0, NewMesh.Triangles.Num() / 3 - NewMesh.NumSkirtTriangles); + VoxelForgeRedefinedDensityField::SetDetailScale(0.0f); + const FVoxelMeshData SmoothMesh = World.Mesher->GenerateMesh(MeshOrigin, 1, 64); + const int32 SmoothTriangleCount = FMath::Max(0, SmoothMesh.Triangles.Num() / 3 - SmoothMesh.NumSkirtTriangles); + VoxelForgeRedefinedDensityField::SetDetailScale(1.0f); + VoxelForgeRedefinedDensityField::SetEnabled(false); + + const FString ImageNames[] = { + TEXT("slice_xy_center.png"), TEXT("slice_xz_y0.png"), + TEXT("slice_yz_x0.png"), TEXT("slice_xz_passage.png")}; + FString SliceError; + SaveStep2dSideBySideSlice( + FPaths::Combine(ResearchOut, ImageNames[0]), OldDensities, NewDensities, + Dimension, TEXT('h'), Dimension / 2, SliceError); + SaveStep2dSideBySideSlice( + FPaths::Combine(ResearchOut, ImageNames[1]), OldDensities, NewDensities, + Dimension, TEXT('x'), Dimension / 2, SliceError); + SaveStep2dSideBySideSlice( + FPaths::Combine(ResearchOut, ImageNames[2]), OldDensities, NewDensities, + Dimension, TEXT('y'), Dimension / 2, SliceError); + int32 PassageIndex = Dimension / 2; + if (World.Manager != nullptr && !World.Manager->GetPassages().IsEmpty()) + { + const FVoxelPassage& Passage = World.Manager->GetPassages()[0]; + PassageIndex = FMath::Clamp(FMath::RoundToInt(Passage.LowerLanding.StandingPoint.Y) + - Origin.Y, 0, Dimension - 1); + } + SaveStep2dSideBySideSlice( + FPaths::Combine(ResearchOut, ImageNames[3]), OldDensities, NewDensities, + Dimension, TEXT('x'), PassageIndex, SliceError); + if (!SliceError.IsEmpty()) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2d] slice image write failed: %s"), *SliceError); + return 2; + } + + TArray OldWallTrials; + TArray NewWallTrials; + for (const auto& Timing : OldTrials) OldWallTrials.Add(Timing.WallSeconds); + for (const auto& Timing : NewTrials) NewWallTrials.Add(Timing.WallSeconds); + OldWallTrials.Sort(); + NewWallTrials.Sort(); + const double OldMedianWall = OldWallTrials[OldWallTrials.Num() / 2]; + const double NewMedianWall = NewWallTrials[NewWallTrials.Num() / 2]; + const double OldGeometryEvaluations = static_cast(OldCounters.RoomEvaluations + + OldCounters.TunnelEvaluations + OldCounters.TunnelCoreEvaluations + + OldCounters.PassageEvaluations); + const double OldGeometryPerSample = OldCounters.SampleCount > 0 + ? OldGeometryEvaluations / static_cast(OldCounters.SampleCount) : 0.0; + const double NewMacroPerSample = NewTrials.Last().SampleCount > 0 + ? static_cast(NewTrials.Last().MacroCentreEvaluations + + NewTrials.Last().EvaluatedSamples) + / static_cast(NewTrials.Last().SampleCount) : 0.0; + const bool bDetailAtLeastOld = NewSpectrum.RMSLag1 >= OldSpectrum.RMSLag1 + && NewSpectrum.RMSLag2 >= OldSpectrum.RMSLag2; + const bool bSmoothControlDetected = SmoothSpectrum.RMSLag1 < NewSpectrum.RMSLag1 * 0.80 + || SmoothSpectrum.RMSLag2 < NewSpectrum.RMSLag2 * 0.80; + const bool bBoundaryStatsSound = BoundaryStats.BrickBoundarySamples > 0 + && BoundaryStats.InteriorSamples > 0 + && BoundaryStats.ChunkBoundarySamples > 0 + && BoundaryStats.FeatureCellBoundarySamples > 0 + && BoundaryStats.BrickJumpRms <= BoundaryStats.InteriorJumpRms * 2.0 + 0.25 + && BoundaryStats.ChunkBoundaryJumpRms <= BoundaryStats.InteriorJumpRms * 2.0 + 0.25 + && BoundaryStats.FeatureCellJumpRms + <= BoundaryStats.FeatureCellInteriorJumpRms * 2.0 + 0.25; + const bool bCorrectProofsSound = BrickProofAudit.CorrectlySkippedBricksAudited > 0 + && BrickProofAudit.CorrectProofSamplesChecked > 0 + && BrickProofAudit.CorrectProofViolations == 0; + const bool bUndersizedBoundCaught = BrickProofAudit.DeliberateBoundDisagreements > 0; + const bool bSmoothWouldFallBelowOldSpectrum = SmoothSpectrum.RMSLag1 < OldSpectrum.RMSLag1 + || SmoothSpectrum.RMSLag2 < OldSpectrum.RMSLag2; + const bool bSpeedPass = NewMedianWall <= 0.600; + const bool bPassed = bSpeedPass && bHashesAgree && bDetailAtLeastOld + && bSmoothControlDetected + && bBoundaryStatsSound && bCorrectProofsSound + && bUndersizedBoundCaught + && bDetailBatchMatchesRaw + && NewTrials.Last().NonFiniteSamples == 0; + + TSharedRef Report = MakeShared(); + Report->SetStringField(TEXT("status"), bPassed ? TEXT("pass") : TEXT("fail")); + Report->SetBoolField(TEXT("pass"), bPassed); + Report->SetBoolField(TEXT("speed_pass_600ms"), bSpeedPass); + Report->SetNumberField(TEXT("seed"), Seed); + Report->SetNumberField(TEXT("ring_dimension"), Dimension); + Report->SetNumberField(TEXT("ring_samples"), static_cast(Dimension) * Dimension * Dimension); + Report->SetNumberField(TEXT("workers_requested"), Workers); + Report->SetNumberField(TEXT("world_prepare_ms_excluded"), WorldPrepareSeconds * 1000.0); + Report->SetNumberField(TEXT("world_prepare_recipe_index"), RecipeIndex); + Report->SetNumberField(TEXT("world_prepare_recipe_hash"), RecipeHash); + Report->SetNumberField(TEXT("cleared_player_edit_chunks"), EditChunkCount); + TSharedRef Window = MakeShared(); + Window->SetNumberField(TEXT("origin_x_voxels"), Origin.X); + Window->SetNumberField(TEXT("origin_y_voxels"), Origin.Y); + Window->SetNumberField(TEXT("origin_z_voxels"), Origin.Z); + Window->SetNumberField(TEXT("samples_per_axis"), Dimension); + Window->SetNumberField(TEXT("sample_stride_voxels"), 1); + Window->SetNumberField(TEXT("sample_spacing_cm"), 25.0); + Report->SetObjectField(TEXT("full_resolution_window"), Window); + Report->SetStringField(TEXT("field_definition"), + TEXT("A seed-offset periodic cross-corridor signed field per strate, joined with rounded " + "landing chambers and existing authored passage carving. Four continuous hash-value " + "noise bands (8, 4, 2, and 1 lattice point) with total bounded amplitude 5.25 are " + "evaluated at every full-resolution sample that can affect the zero set.")); + Report->SetStringField(TEXT("resolution_contract"), + TEXT("The lattice remains one sample per 25 cm. No coarse density result is interpolated " + "onto it: every potentially surface-affecting point evaluates all four detail bands. " + "Brick skips require a final-field sign proof including the full noise supremum, a " + "floating-point safety margin, and the one-sample marching-cubes halo.")); + Report->SetStringField(TEXT("finer_lattice_scaling"), + TEXT("At 12.5 cm, scale macro lengths and the broad/medium noise cell sizes in lattice " + "units by 2 to preserve their world-space shapes; keep or add a 1-2 sample fine octave " + "to retain and extend detail. With fixed 8^3 sample bricks, samples grow 8x but " + "surface-intersecting bricks grow approximately with area (about 4x); the rest are " + "sign-proven and require only density writes. Full-resolution detail remains per " + "potentially surface-affecting point.")); + Report->SetNumberField(TEXT("old_macro_geometry_evaluations_per_point"), OldGeometryPerSample); + Report->SetNumberField(TEXT("new_macro_evaluations_per_point_including_brick_proofs"), NewMacroPerSample); + Report->SetNumberField(TEXT("new_noise_value_octaves_per_evaluated_point"), 4.0); + Report->SetNumberField(TEXT("new_noise_octave_evaluations_per_full_ring_point"), + static_cast(NewTrials.Last().EvaluatedSamples) * 4.0 + / static_cast(NewTrials.Last().SampleCount)); + Report->SetNumberField(TEXT("old_reported_noise_share_percent_from_step2c"), 2.6); + Report->SetNumberField(TEXT("old_six_worker_median_ms"), OldMedianWall * 1000.0); + Report->SetNumberField(TEXT("new_six_worker_median_ms"), NewMedianWall * 1000.0); + const auto WorkerRemainderSeconds = [](const VoxelForgeRegionEval::FTiming& Timing) + { + return FMath::Max(0.0, Timing.WorkerCpuSeconds + - Timing.BrickClassificationSeconds - Timing.DensityEvaluationSeconds + - Timing.UniformFillSeconds); + }; + Report->SetNumberField(TEXT("new_other_worker_cpu_seconds"), + WorkerRemainderSeconds(NewTrials.Last())); + Report->SetNumberField(TEXT("new_other_worker_cpu_ns_per_point"), + WorkerRemainderSeconds(NewTrials.Last()) * 1.0e9 + / static_cast(NewTrials.Last().SampleCount)); + Report->SetNumberField(TEXT("new_speedup_vs_old"), NewMedianWall > 0.0 + ? OldMedianWall / NewMedianWall : 0.0); + TArray> OldRows; + TArray> NewRows; + for (const auto& Timing : OldTrials) + { + OldRows.Add(MakeShared(Step2dTimingJson(Timing))); + } + for (const auto& Timing : NewTrials) + { + NewRows.Add(MakeShared(Step2dTimingJson(Timing))); + } + Report->SetArrayField(TEXT("old_field_trials"), OldRows); + Report->SetArrayField(TEXT("new_field_trials"), NewRows); + Report->SetObjectField(TEXT("old_full_ring_counters"), Step2dTimingJson(OldCounters)); + Report->SetObjectField(TEXT("new_one_worker_determinism_pass"), Step2dTimingJson(OneWorkerTiming)); + Report->SetObjectField(TEXT("new_full_detail_six_worker_evidence_pass"), + Step2dTimingJson(NewFullSixWorkerTiming)); + Report->SetObjectField(TEXT("new_repeated_full_detail_six_worker_hash_pass"), + Step2dTimingJson(NewRepeatTiming)); + Report->SetObjectField(TEXT("smooth_control_full_detail_pass"), Step2dTimingJson(SmoothTiming)); + + TSharedRef Hashes = MakeShared(); + Hashes->SetStringField(TEXT("six_worker_run_1_quantized"), FString::Printf( + TEXT("%016llx"), static_cast(NewFullSixWorkerTiming.QuantizedHash))); + Hashes->SetStringField(TEXT("six_worker_run_2_quantized"), FString::Printf( + TEXT("%016llx"), static_cast(NewRepeatTiming.QuantizedHash))); + Hashes->SetStringField(TEXT("one_worker_quantized"), FString::Printf( + TEXT("%016llx"), static_cast(OneWorkerTiming.QuantizedHash))); + Hashes->SetStringField(TEXT("six_worker_run_1_raw_float"), FString::Printf( + TEXT("%016llx"), static_cast(NewFullSixWorkerTiming.RawFloatHash))); + Hashes->SetStringField(TEXT("six_worker_run_2_raw_float"), FString::Printf( + TEXT("%016llx"), static_cast(NewRepeatTiming.RawFloatHash))); + Hashes->SetStringField(TEXT("one_worker_raw_float"), FString::Printf( + TEXT("%016llx"), static_cast(OneWorkerTiming.RawFloatHash))); + Hashes->SetBoolField(TEXT("same_across_two_runs_and_1_vs_6_workers"), bHashesAgree); + Report->SetObjectField(TEXT("determinism"), Hashes); + + TSharedRef Detail = MakeShared(); + Detail->SetNumberField(TEXT("old_surface_band_samples"), static_cast(OldSpectrum.SurfaceBandSamples)); + Detail->SetNumberField(TEXT("new_surface_band_samples"), static_cast(NewSpectrum.SurfaceBandSamples)); + Detail->SetNumberField(TEXT("old_curvature_rms_lag1"), OldSpectrum.RMSLag1); + Detail->SetNumberField(TEXT("new_curvature_rms_lag1"), NewSpectrum.RMSLag1); + Detail->SetNumberField(TEXT("smoothed_control_curvature_rms_lag1"), SmoothSpectrum.RMSLag1); + Detail->SetNumberField(TEXT("old_curvature_rms_lag2"), OldSpectrum.RMSLag2); + Detail->SetNumberField(TEXT("new_curvature_rms_lag2"), NewSpectrum.RMSLag2); + Detail->SetNumberField(TEXT("smoothed_control_curvature_rms_lag2"), SmoothSpectrum.RMSLag2); + Detail->SetNumberField(TEXT("new_curvature_rms_lag4"), NewSpectrum.RMSLag4); + Detail->SetNumberField(TEXT("smoothed_control_curvature_rms_lag4"), SmoothSpectrum.RMSLag4); + Detail->SetNumberField(TEXT("old_surface_triangles_64cubed"), OldTriangleCount); + Detail->SetNumberField(TEXT("new_surface_triangles_64cubed"), NewTriangleCount); + Detail->SetNumberField(TEXT("smoothed_control_surface_triangles_64cubed"), SmoothTriangleCount); + Detail->SetNumberField(TEXT("mesh_region_origin_x_voxels"), MeshOrigin.X); + Detail->SetNumberField(TEXT("mesh_region_origin_y_voxels"), MeshOrigin.Y); + Detail->SetNumberField(TEXT("mesh_region_origin_z_voxels"), MeshOrigin.Z); + Detail->SetBoolField(TEXT("mesh_region_contains_old_and_new_iso_bands"), + bMeshContainsBothIsoBands); + Detail->SetBoolField(TEXT("new_detail_at_least_old_spectrum"), bDetailAtLeastOld); + Detail->SetNumberField(TEXT("detail_batch_raw_parity_samples"), DetailBatchParitySamples); + Detail->SetNumberField(TEXT("detail_batch_max_absolute_error"), DetailBatchMaxAbsoluteError); + Detail->SetBoolField(TEXT("detail_batch_matches_direct_field"), bDetailBatchMatchesRaw); + Detail->SetBoolField(TEXT("deliberate_smoothing_control_detected"), bSmoothControlDetected); + Detail->SetBoolField(TEXT("smoothed_control_below_old_spectrum"), + bSmoothWouldFallBelowOldSpectrum); + Report->SetObjectField(TEXT("detail_preservation"), Detail); + + TSharedRef Boundaries = MakeShared(); + Boundaries->SetNumberField(TEXT("brick_size_voxels"), 8); + Boundaries->SetNumberField(TEXT("feature_cell_spacing_voxels"), FeatureCellSpacing); + Boundaries->SetNumberField(TEXT("chunk_boundary_gradient_jump_rms_xy_near_surface"), + BoundaryStats.ChunkBoundaryJumpRms); + Boundaries->SetNumberField(TEXT("chunk_boundary_gradient_jump_max_xy_near_surface"), + BoundaryStats.ChunkBoundaryJumpMax); + Boundaries->SetNumberField(TEXT("brick_boundary_gradient_jump_rms"), BoundaryStats.BrickJumpRms); + Boundaries->SetNumberField(TEXT("interior_gradient_jump_rms"), BoundaryStats.InteriorJumpRms); + Boundaries->SetNumberField(TEXT("feature_cell_boundary_gradient_jump_rms"), BoundaryStats.FeatureCellJumpRms); + Boundaries->SetNumberField(TEXT("feature_cell_interior_gradient_jump_rms"), + BoundaryStats.FeatureCellInteriorJumpRms); + Boundaries->SetNumberField(TEXT("brick_boundary_samples_near_surface"), + static_cast(BoundaryStats.BrickBoundarySamples)); + Boundaries->SetNumberField(TEXT("chunk_boundary_samples_near_surface_xy"), + static_cast(BoundaryStats.ChunkBoundarySamples)); + Boundaries->SetNumberField(TEXT("macro_period_boundary_samples"), + static_cast(BoundaryStats.FeatureCellBoundarySamples)); + Boundaries->SetNumberField(TEXT("brick_boundary_gradient_jump_max"), BoundaryStats.BrickJumpMax); + Boundaries->SetNumberField(TEXT("feature_cell_boundary_gradient_jump_max"), BoundaryStats.FeatureCellJumpMax); + Boundaries->SetBoolField(TEXT("boundary_check_pass"), bBoundaryStatsSound); + Report->SetObjectField(TEXT("boundary_artifact_check"), Boundaries); + + TSharedRef Proofs = MakeShared(); + Proofs->SetNumberField(TEXT("correctly_skipped_bricks_audited"), + static_cast(BrickProofAudit.CorrectlySkippedBricksAudited)); + Proofs->SetNumberField(TEXT("final_field_samples_checked_including_halo"), + static_cast(BrickProofAudit.CorrectProofSamplesChecked)); + Proofs->SetNumberField(TEXT("correct_proof_sign_violations"), + static_cast(BrickProofAudit.CorrectProofViolations)); + Proofs->SetNumberField(TEXT("deliberately_undersized_detail_bound_claims_checked"), + static_cast(BrickProofAudit.DeliberatelyUndersizedBoundClaimsChecked)); + Proofs->SetNumberField(TEXT("wrong_sign_samples_from_undersized_bound"), + static_cast(BrickProofAudit.DeliberateBoundDisagreements)); + Proofs->SetNumberField(TEXT("deliberate_control_classifier_detail_scale"), 0.0); + Proofs->SetNumberField(TEXT("deliberate_control_actual_detail_scale"), 4.0); + Proofs->SetBoolField(TEXT("sound_bound_audit_pass"), bCorrectProofsSound); + Proofs->SetBoolField(TEXT("deliberate_undersized_bound_control_caught"), bUndersizedBoundCaught); + Report->SetObjectField(TEXT("brick_bound_audit"), Proofs); + + TArray> Slices; + for (const FString& Name : ImageNames) + { + TSharedRef Slice = MakeShared(); + Slice->SetStringField(TEXT("file"), FPaths::Combine(ResearchOut, Name)); + Slice->SetStringField(TEXT("layout"), TEXT("old field on left; redefined field on right; light=air, dark=solid, cyan=near zero")); + Slices.Add(MakeShared(Slice)); + } + Report->SetArrayField(TEXT("slices"), Slices); + FString JsonText; + const TSharedRef> Writer = TJsonWriterFactory<>::Create(&JsonText); + const FString ReportPath = FPaths::Combine(ResearchOut, TEXT("step2d_report.json")); + if (!FJsonSerializer::Serialize(Report, Writer) + || !FFileHelper::SaveStringToFile( + JsonText, *ReportPath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM) + || !FFileHelper::SaveStringToFile( + JsonText, *FPaths::Combine(Arguments.OutDirectory, TEXT("step2d_report.json")), + FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2d] could not write report '%s'."), *ReportPath); + return 2; + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeStep2d] pass=%s speed600=%s old_ms=%.3f new_ms=%.3f skip=%.1f%% " + "roughness(old/new/smooth)=%.4f/%.4f/%.4f hashes=%s report=%s"), + bPassed ? TEXT("yes") : TEXT("no"), bSpeedPass ? TEXT("yes") : TEXT("no"), + OldMedianWall * 1000.0, NewMedianWall * 1000.0, + NewTrials.Last().SampleCount > 0 + ? 100.0 * static_cast(NewTrials.Last().SkippedSamples) + / static_cast(NewTrials.Last().SampleCount) : 0.0, + OldSpectrum.RMSLag1, NewSpectrum.RMSLag1, SmoothSpectrum.RMSLag1, + bHashesAgree ? TEXT("same") : TEXT("DIFFERENT"), *ReportPath); + return bPassed ? 0 : 2; +} + class FGeneratorDensitySampler final : public IVoxelStrateDensitySampler { public: @@ -9648,6 +10475,10 @@ int32 RunBatch(const FString& Params) int32 UVoxelForgeExploreCommandlet::Main(const FString& Params) { + if (FParse::Param(*Params, TEXT("step2d"))) + { + return RunStep2d(Params); + } const bool bRegionEvalSwitch = FParse::Param(*Params, TEXT("regioneval")); int32 RegionEvalFlag = 0; const bool bRegionEvalValue = FParse::Value(*Params, TEXT("regioneval="), RegionEvalFlag); diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.cpp index 5116389..e47f2dd 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.cpp @@ -5,9 +5,12 @@ #include "VoxelCaveMorphology.h" #include "VoxelDensityProfile.h" #include "VoxelGenerator.h" +#include "VoxelRedefinedDensityField.h" #include "VoxelStackSampler.h" #include "HAL/PlatformTLS.h" +#include + #if PLATFORM_WINDOWS #include "Windows/AllowWindowsPlatformTypes.h" THIRD_PARTY_INCLUDES_START @@ -92,6 +95,14 @@ namespace VoxelForgeRegionEval int32 LatticeZ = 0; TArray RequestedSamples; }; + + struct FResearchBrick + { + FIntVector Origin = FIntVector::ZeroValue; + FIntVector Size = FIntVector::ZeroValue; + FIntVector Lattice = FIntVector::ZeroValue; + int32 SampleCount = 0; + }; } int64 LinearIndex(int32 X, int32 Y, int32 Z, int32 Dimension) @@ -138,6 +149,245 @@ namespace VoxelForgeRegionEval return Hash; } + bool EvaluateRedefined( + const UVoxelGenerator& Generator, + FIntVector OriginVoxels, + int32 Dimension, + int32 RequestedWorkerCount, + float DetailScale, + TArray& OutDensities, + FTiming& OutTiming, + bool bEnableBrickSkips) + { + OutTiming = FTiming(); + OutDensities.Reset(); + if (Dimension <= 0 || Dimension > 1024 || !FMath::IsFinite(DetailScale)) + { + return false; + } + + const int32 NumWorkers = FMath::Clamp(RequestedWorkerCount, 1, 32); + const int64 TotalSamples = LatticeCount(Dimension); + if (TotalSamples > MAX_int32) + { + return false; + } + const double StartSeconds = FPlatformTime::Seconds(); + OutTiming.Workers = NumWorkers; + OutTiming.SampleCount = TotalSamples; + OutDensities.SetNumUninitialized(static_cast(TotalSamples)); + + TArray Bricks; + const FIntVector Last = OriginVoxels + + FIntVector(Dimension - 1, Dimension - 1, Dimension - 1); + const FIntVector FirstBrick( + FMath::FloorToInt(static_cast(OriginVoxels.X) / 8.0f), + FMath::FloorToInt(static_cast(OriginVoxels.Y) / 8.0f), + FMath::FloorToInt(static_cast(OriginVoxels.Z) / 8.0f)); + const FIntVector LastBrick( + FMath::FloorToInt(static_cast(Last.X) / 8.0f), + FMath::FloorToInt(static_cast(Last.Y) / 8.0f), + FMath::FloorToInt(static_cast(Last.Z) / 8.0f)); + Bricks.Reserve((LastBrick.X - FirstBrick.X + 1) + * (LastBrick.Y - FirstBrick.Y + 1) * (LastBrick.Z - FirstBrick.Z + 1)); + for (int32 BZ = FirstBrick.Z; BZ <= LastBrick.Z; ++BZ) + for (int32 BY = FirstBrick.Y; BY <= LastBrick.Y; ++BY) + for (int32 BX = FirstBrick.X; BX <= LastBrick.X; ++BX) + { + const FIntVector BrickWorldMin(BX * 8, BY * 8, BZ * 8); + FResearchBrick& Brick = Bricks.AddDefaulted_GetRef(); + Brick.Origin = FIntVector( + FMath::Max(OriginVoxels.X, BrickWorldMin.X), + FMath::Max(OriginVoxels.Y, BrickWorldMin.Y), + FMath::Max(OriginVoxels.Z, BrickWorldMin.Z)); + const FIntVector BrickEnd = BrickWorldMin + FIntVector(8, 8, 8); + const FIntVector RingEnd = OriginVoxels + + FIntVector(Dimension, Dimension, Dimension); + const FIntVector End( + FMath::Min(BrickEnd.X, RingEnd.X), + FMath::Min(BrickEnd.Y, RingEnd.Y), + FMath::Min(BrickEnd.Z, RingEnd.Z)); + Brick.Size = End - Brick.Origin; + Brick.Lattice = Brick.Origin - OriginVoxels; + Brick.SampleCount = Brick.Size.X * Brick.Size.Y * Brick.Size.Z; + } + + TArray WorkerWall; + TArray WorkerCpu; + TArray WorkerClassify; + TArray WorkerEvaluate; + TArray WorkerFill; + TArray WorkerThreadIds; + TArray WorkerEvaluated; + TArray WorkerSkippedBricks; + TArray WorkerSkippedSamples; + TArray WorkerMacroChecks; + TArray WorkerMacroCentreEvaluations; + TArray WorkerFallbackReasons; + WorkerWall.SetNumZeroed(NumWorkers); + WorkerCpu.SetNumZeroed(NumWorkers); + WorkerClassify.SetNumZeroed(NumWorkers); + WorkerEvaluate.SetNumZeroed(NumWorkers); + WorkerFill.SetNumZeroed(NumWorkers); + WorkerThreadIds.SetNumZeroed(NumWorkers); + WorkerEvaluated.SetNumZeroed(NumWorkers); + WorkerSkippedBricks.SetNumZeroed(NumWorkers); + WorkerSkippedSamples.SetNumZeroed(NumWorkers); + WorkerMacroChecks.SetNumZeroed(NumWorkers); + WorkerMacroCentreEvaluations.SetNumZeroed(NumWorkers); + WorkerFallbackReasons.SetNumZeroed(NumWorkers * 5); + + // Surface bricks carry more work than uniform interior bricks. Equal brick counts do + // not balance that work, so claim small contiguous batches dynamically. Each brick + // writes a disjoint output range and still evaluates the same exact field. + std::atomic NextBrickIndex{0}; + constexpr int32 BrickBatchSize = 16; + ParallelFor(NumWorkers, [&](int32 WorkerIndex) + { + WorkerThreadIds[WorkerIndex] = FPlatformTLS::GetCurrentThreadId(); + const double WorkerStart = FPlatformTime::Seconds(); + double CpuStart = 0.0; + const bool bHaveCpuStart = GetCurrentThreadCpuSeconds(CpuStart); + TArray BrickDetail; + VoxelForgeRedefinedDensityField::FBrickEvaluationContext BrickContext; + for (;;) + { + const int32 BatchBegin = NextBrickIndex.fetch_add( + BrickBatchSize, std::memory_order_relaxed); + if (BatchBegin >= Bricks.Num()) + { + break; + } + const int32 BatchEnd = FMath::Min(BatchBegin + BrickBatchSize, Bricks.Num()); + for (int32 BrickIndex = BatchBegin; BrickIndex < BatchEnd; ++BrickIndex) + { + const FResearchBrick& Brick = Bricks[BrickIndex]; + bool bUniformAir = false; + bool bEvaluatedMacroCentre = false; + VoxelForgeRedefinedDensityField::EBrickFallbackReason FallbackReason = + VoxelForgeRedefinedDensityField::EBrickFallbackReason::UnsupportedState; + ++WorkerMacroChecks[WorkerIndex]; + const double ClassifyStart = FPlatformTime::Seconds(); + const bool bCanSkip = bEnableBrickSkips + && VoxelForgeRedefinedDensityField::TryClassifyBrick( + Generator, Brick.Origin, Brick.Size, bUniformAir, DetailScale, + &bEvaluatedMacroCentre, &FallbackReason); + WorkerClassify[WorkerIndex] += FPlatformTime::Seconds() - ClassifyStart; + WorkerMacroCentreEvaluations[WorkerIndex] += bEvaluatedMacroCentre ? 1u : 0u; + if (bEnableBrickSkips && !bCanSkip) + { + const int32 ReasonIndex = static_cast(FallbackReason); + if (ReasonIndex < 5) + { + ++WorkerFallbackReasons[WorkerIndex * 5 + ReasonIndex]; + } + } + if (bCanSkip) + { + ++WorkerSkippedBricks[WorkerIndex]; + WorkerSkippedSamples[WorkerIndex] += static_cast(Brick.SampleCount); + const float UniformDensity = bUniformAir ? 64.0f : -64.0f; + const double FillStart = FPlatformTime::Seconds(); + for (int32 Z = 0; Z < Brick.Size.Z; ++Z) + for (int32 Y = 0; Y < Brick.Size.Y; ++Y) + for (int32 X = 0; X < Brick.Size.X; ++X) + { + const int64 LatticeIndex = LinearIndex( + Brick.Lattice.X + X, Brick.Lattice.Y + Y, + Brick.Lattice.Z + Z, Dimension); + OutDensities[static_cast(LatticeIndex)] = UniformDensity; + } + WorkerFill[WorkerIndex] += FPlatformTime::Seconds() - FillStart; + continue; + } + + const double EvaluationStart = FPlatformTime::Seconds(); + VoxelForgeRedefinedDensityField::BuildDetailBrick( + Generator, Brick.Origin, Brick.Size, DetailScale, BrickDetail, + &BrickContext); + for (int32 Z = 0; Z < Brick.Size.Z; ++Z) + for (int32 Y = 0; Y < Brick.Size.Y; ++Y) + for (int32 X = 0; X < Brick.Size.X; ++X) + { + const int32 WorldX = Brick.Origin.X + X; + const int32 WorldY = Brick.Origin.Y + Y; + const int32 WorldZ = Brick.Origin.Z + Z; + const int64 LatticeIndex = LinearIndex( + Brick.Lattice.X + X, Brick.Lattice.Y + Y, + Brick.Lattice.Z + Z, Dimension); + OutDensities[static_cast(LatticeIndex)] = + VoxelForgeRedefinedDensityField::EvaluateWithDetailValue( + Generator, static_cast(WorldX), static_cast(WorldY), + static_cast(WorldZ), + BrickDetail[(Z * Brick.Size.Y + Y) * Brick.Size.X + X], + nullptr, DetailScale, &BrickContext); + ++WorkerEvaluated[WorkerIndex]; + } + WorkerEvaluate[WorkerIndex] += FPlatformTime::Seconds() - EvaluationStart; + } + } + WorkerWall[WorkerIndex] = FPlatformTime::Seconds() - WorkerStart; + double CpuEnd = 0.0; + if (bHaveCpuStart && GetCurrentThreadCpuSeconds(CpuEnd)) + { + WorkerCpu[WorkerIndex] = FMath::Max(0.0, CpuEnd - CpuStart); + } + else + { + WorkerCpu[WorkerIndex] = WorkerWall[WorkerIndex]; + } + }, EParallelForFlags::None); + + OutTiming.WallSeconds = FPlatformTime::Seconds() - StartSeconds; + OutTiming.bRegionProgram = true; + OutTiming.BrickCount = static_cast(Bricks.Num()); + OutTiming.ObservedWorkerThreads = 0; + for (int32 Worker = 0; Worker < NumWorkers; ++Worker) + { + OutTiming.WorkerCpuSeconds += WorkerCpu[Worker]; + OutTiming.BrickClassificationSeconds += WorkerClassify[Worker]; + OutTiming.DensityEvaluationSeconds += WorkerEvaluate[Worker]; + OutTiming.UniformFillSeconds += WorkerFill[Worker]; + OutTiming.LongestWorkerWallSeconds = FMath::Max( + OutTiming.LongestWorkerWallSeconds, WorkerWall[Worker]); + OutTiming.EvaluatedSamples += static_cast(WorkerEvaluated[Worker]); + OutTiming.SkippedBricks += WorkerSkippedBricks[Worker]; + OutTiming.SkippedSamples += WorkerSkippedSamples[Worker]; + OutTiming.MacroBrickChecks += WorkerMacroChecks[Worker]; + OutTiming.MacroCentreEvaluations += WorkerMacroCentreEvaluations[Worker]; + OutTiming.UnsupportedBrickProofs += WorkerFallbackReasons[Worker * 5 + + static_cast(VoxelForgeRedefinedDensityField::EBrickFallbackReason::UnsupportedState)]; + OutTiming.ChunkBoundaryBrickProofs += WorkerFallbackReasons[Worker * 5 + + static_cast(VoxelForgeRedefinedDensityField::EBrickFallbackReason::ChunkOrStrateBoundary)]; + OutTiming.PassageBoundBrickProofs += WorkerFallbackReasons[Worker * 5 + + static_cast(VoxelForgeRedefinedDensityField::EBrickFallbackReason::PassageBounds)]; + OutTiming.MixedMacroBrickProofs += WorkerFallbackReasons[Worker * 5 + + static_cast(VoxelForgeRedefinedDensityField::EBrickFallbackReason::MixedMacroInterval)]; + bool bPreviouslyObserved = false; + for (int32 Previous = 0; Previous < Worker; ++Previous) + { + bPreviouslyObserved |= WorkerThreadIds[Previous] == WorkerThreadIds[Worker]; + } + OutTiming.ObservedWorkerThreads += bPreviouslyObserved ? 0 : 1; + } + OutTiming.NanosecondsPerSample = TotalSamples > 0 + ? OutTiming.WorkerCpuSeconds * 1.0e9 / static_cast(TotalSamples) + : 0.0; + OutTiming.CandidateEvaluationsPerSample = TotalSamples > 0 + ? static_cast(OutTiming.EvaluatedSamples + + static_cast(OutTiming.MacroCentreEvaluations)) + / static_cast(TotalSamples) + : 0.0; + for (const float Density : OutDensities) + { + OutTiming.NonFiniteSamples += FMath::IsFinite(Density) ? 0u : 1u; + } + OutTiming.QuantizedHash = HashQuantized(OutDensities); + OutTiming.RawFloatHash = HashRawFloats(OutDensities); + return OutTiming.EvaluatedSamples + static_cast(OutTiming.SkippedSamples) + == TotalSamples && OutTiming.NonFiniteSamples == 0; + } + bool Evaluate( const UVoxelGenerator& Generator, FIntVector OriginVoxels, diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.h b/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.h index 5b76f62..3f7bcfc 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.h +++ b/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.h @@ -26,6 +26,9 @@ namespace VoxelForgeRegionEval double GeneralPathEntriesPerSample = 0.0; double WallSeconds = 0.0; double WorkerCpuSeconds = 0.0; + double BrickClassificationSeconds = 0.0; + double DensityEvaluationSeconds = 0.0; + double UniformFillSeconds = 0.0; double NanosecondsPerSample = 0.0; bool bWorkerCpuFromOsThreadTimes = false; bool bRegionProgram = false; @@ -34,6 +37,16 @@ namespace VoxelForgeRegionEval uint64 RawFloatHash = 0; uint64 SaturatedSamples = 0; uint64 NonFiniteSamples = 0; + uint64 BrickCount = 0; + uint64 SkippedBricks = 0; + uint64 SkippedSamples = 0; + uint64 MacroBrickChecks = 0; + uint64 MacroCentreEvaluations = 0; + uint64 UnsupportedBrickProofs = 0; + uint64 ChunkBoundaryBrickProofs = 0; + uint64 PassageBoundBrickProofs = 0; + uint64 MixedMacroBrickProofs = 0; + double LongestWorkerWallSeconds = 0.0; uint64 SdfCacheBuilds = 0; uint64 ActiveOpListBuilds = 0; uint64 BroadPhaseIndexBuilds = 0; @@ -122,6 +135,17 @@ namespace VoxelForgeRegionEval int32 RequestedWorkerCount = WorkerCount, bool bCollectWholeRunCounters = false); + /** Full-resolution step 2d field pass with sound world-aligned 8^3 brick proofs. */ + bool EvaluateRedefined( + const UVoxelGenerator& Generator, + FIntVector OriginVoxels, + int32 Dimension, + int32 RequestedWorkerCount, + float DetailScale, + TArray& OutDensities, + FTiming& OutTiming, + bool bEnableBrickSkips = true); + /** Count exact room/tunnel/core/passage candidate visits with instrumentation enabled. */ FCandidateVisits CountCandidates( const UVoxelGenerator& Generator, diff --git a/Tools/VoxelForgeTest.ps1 b/Tools/VoxelForgeTest.ps1 index 29d6c21..ed187fb 100644 --- a/Tools/VoxelForgeTest.ps1 +++ b/Tools/VoxelForgeTest.ps1 @@ -1,6 +1,6 @@ [CmdletBinding()] param( - [ValidateSet('canonical', 'owner', 'probe', 'perf', 'visual-edit', 'visual-lod', 'visual-gap-walk', 'visual-gap-inject', 'dig-motion', 'tests', 'surface-fall', 'crossing', 'gate-stress', 'parity', 'horizon', 'horizon-render', 'determinism', 'symbolize-stacks', 'region-eval')] + [ValidateSet('canonical', 'owner', 'probe', 'perf', 'visual-edit', 'visual-lod', 'visual-gap-walk', 'visual-gap-inject', 'dig-motion', 'tests', 'surface-fall', 'crossing', 'gate-stress', 'parity', 'horizon', 'horizon-render', 'determinism', 'symbolize-stacks', 'region-eval', 'step2d')] [string]$Scenario = 'canonical', [switch]$Build, [switch]$EnableRender, @@ -575,7 +575,7 @@ function Sync-OwnerAssets { $null = Copy-Item -LiteralPath $source -Destination $target -Force -PassThru [void]$rows.Add([ordered]@{ name = $assetName - used_by_scenario = $Scenario -in @('owner', 'probe', 'parity', 'tests', 'surface-fall', 'crossing', 'gate-stress', 'visual-edit', 'visual-lod', 'visual-gap-walk', 'visual-gap-inject', 'dig-motion', 'horizon', 'horizon-render', 'determinism', 'region-eval') + used_by_scenario = $Scenario -in @('owner', 'probe', 'parity', 'tests', 'surface-fall', 'crossing', 'gate-stress', 'visual-edit', 'visual-lod', 'visual-gap-walk', 'visual-gap-inject', 'dig-motion', 'horizon', 'horizon-render', 'determinism', 'region-eval', 'step2d') source_path = $source source_sha256 = Get-Sha256 $source staged_path = $target @@ -623,7 +623,7 @@ function Get-AssetEvidence { $target = Join-Path $HostRoot "Content\VoxelForge\$assetName.uasset" [void]$rows.Add([ordered]@{ name = $assetName - used_by_scenario = $Scenario -in @('owner', 'probe', 'parity', 'tests', 'surface-fall', 'crossing', 'gate-stress', 'visual-edit', 'visual-lod', 'visual-gap-walk', 'visual-gap-inject', 'dig-motion', 'horizon', 'horizon-render', 'determinism', 'region-eval') + used_by_scenario = $Scenario -in @('owner', 'probe', 'parity', 'tests', 'surface-fall', 'crossing', 'gate-stress', 'visual-edit', 'visual-lod', 'visual-gap-walk', 'visual-gap-inject', 'dig-motion', 'horizon', 'horizon-render', 'determinism', 'region-eval', 'step2d') source_path = $source source_sha256 = Get-Sha256 $source staged_path = $target @@ -1473,6 +1473,32 @@ try { } } } + 'step2d' { + $step2dLog = Join-Path $RunRoot 'Logs\step2d.log' + $args = New-CommonArguments 'commandlet' $step2dLog (Merge-Cvars $baseCvars) + $args += New-CommandletArguments $commandletOut 'walk,export' ($Assets -eq 'owner') $false 128 + $researchOut = Join-Path $PluginRoot "Saved\Research\step2d\$Label" + $args += @('-step2d', '-regionworkers=6', ('-researchout="{0}"' -f $researchOut)) + $commandletLedger = Invoke-UnrealLaunch 'step2d-commandlet' $args ` + $step2dLog (Join-Path $RunRoot 'Logs\step2d.stdout.log') ` + (Join-Path $RunRoot 'Logs\step2d.stderr.log') + $step2dReportPath = Join-Path $researchOut 'step2d_report.json' + $step2dReport = Read-JsonFile $step2dReportPath + if ($null -eq $step2dReport) { + $CommandletMetrics = [ordered]@{ status = 'missing'; output = $researchOut; report = $step2dReportPath } + Add-Failure "step2d did not produce $step2dReportPath" + } else { + $CommandletMetrics = [ordered]@{ + status = if ((Get-JsonValue $step2dReport 'pass' $false) -and $commandletLedger.status -eq 'passed') { 'passed' } else { 'failed' } + output = $researchOut + report = $step2dReportPath + result = $step2dReport + } + if (-not (Get-JsonValue $step2dReport 'pass' $false)) { + Add-Failure 'step2d speed, detail, boundary, or determinism gate failed' + } + } + } 'symbolize-stacks' { if (([string]::IsNullOrWhiteSpace($StackSamplesPath)) -or -not (Test-Path -LiteralPath $StackSamplesPath -PathType Leaf)) { throw "-StackSamplesPath must name an existing raw sampler CSV."