diff --git a/CODEMAP.md b/CODEMAP.md index 133c28b..0573da5 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -117,12 +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|step2d|step2e`, 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` retains the earlier independent field; `step2e` measures eight room-network profiles on the 259³ ring, emits variety metrics and live/new slices, and checks detail, boundaries, bounds, and 1-vs-6-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. +| `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|step2e|step2f|step2f-capture`, optional `-Build`, `-Assets owner|default`, `-Cvars`, absolute `-Out`, `-Label`, `-HorizonLevel`, `-HorizonRenderDistanceChunks`, `-Step2fProfile=0..7`, `-Step2fCaptureView=inside|wall-closeup`, `-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; `step2f-capture` selects one of eight parameter sets, captures an in-game owner view, and saves it under `Saved/Research/step2f/captures` (pass the persistent render DDC with `-SharedDDC`); `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` retains the earlier independent field; `step2e` retains its earlier benchmark; `step2f` compares frozen step 2e and the coherent field for all eight profiles on the same 259³ ring, applies geometry gates, and checks sound bounds, seams, and 1-vs-6-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` retains the exact-field step-2c research harness. `-step2d` retains the earlier independent field benchmark; `-step2e` measures eight room-network profiles and writes timing, per-profile parameter ranges, nominal chamber-volume quantiles, vertical-run and tunnel metrics, and side-by-side live/new slice evidence under `Saved/Research/step2e`. The report records 12.5 cm scaling guidance and notes that basin water levels are parameters without water rendering or simulation. Its hardest-profile gates include full-detail parity, seam injection, sound and undersized-bound controls, and repeated / 1-vs-6-worker hash checks. | -| `Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.*` | Six-worker lattice drivers for step 2c, step 2d, and step 2e. The original path still compares live `GetDensityAt` with the editor chunk program. Research modes assign contiguous batches dynamically so surface-heavy work balances across workers; step 2e uses 4³ proof bricks and stages each evaluated brick before copying contiguous X rows, while step 2d retains 8³. Each skip requires a macro Lipschitz bound plus the complete detail-amplitude supremum; uncertain bricks retain all 25 cm detail samples. Timings split classification, point evaluation, and uniform fill; step 2e hashes compare workers 1 and 6. | -| `Public/VoxelRedefinedDensityField.h` / `Private/VoxelRedefinedDensityField.cpp` | Two default-off `WITH_EDITOR` research fields, selected by `voxel.ResearchRedefinedDensityField` (step 2d) and `voxel.ResearchParameterizedCave` (step 2e). Step 2e deterministically assigns one of eight parameter profiles per strate, then builds jittered rooms and a root-connected tunnel graph with optional loops and shafts, pillars, ledges, floating masses, stalactites/stalagmites, and basin floors. The room evaluator checks the containing cell first, then cardinal and diagonal neighbors, with horizontal/vertical upper-bound rejection before feature work. A padded thread-local generated-cell cache is reused across adjacent proof bricks, and XY-dependent room/tunnel distances are reused through each vertical column while four bounded world-space value-noise bands, including a one-sample micro band, remain evaluated at full resolution. Step 2e proofs include the model Lipschitz interval, passage/refinement fallback, and full detail supremum; a local derivative bound permits sound proofs through landing-floor blends. The basin has a water-level parameter, but this field does not render or simulate water. The public API adds no reflected class layout. | -| `Private/Tests/VoxelForgeParameterizedGenerationTest.cpp` | Separate three-seed functional sweep for the step 2e field; passage landing air/floor brackets and arrival-to-exit walking routes are checked without changing the live-world tests. | +| `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` and `-step2e` retain their earlier benchmarks. `-step2f` compares frozen step 2e and the coherent field for all eight profiles on the same 259³ ring, counts sub-1 m³ connected components, measures mesh dihedrals, detail spectrum, LOD surface distance, chamber-pitch autocorrelation, roof/floor coverage, and six-worker timing. It also runs the sound/undersized-bound audit, seam injection, and 1-vs-6-worker hash checks and writes profile slices and JSON under `Saved/Research/step2f`. | +| `Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.*` | Six-worker lattice drivers for step 2c through step 2f. The original path still compares live `GetDensityAt` with the editor chunk program. Research modes assign contiguous batches dynamically so surface-heavy work balances across workers; steps 2e and 2f use 4³ proof bricks and stage each evaluated brick before copying contiguous X rows, while step 2d retains 8³. Each skip requires a macro Lipschitz bound plus the complete final-detail supremum; uncertain bricks retain all 25 cm detail samples. Timings split classification, point evaluation, and uniform fill; step 2f compares all profiles and hashes workers 1 and 6. | +| `Public/VoxelRedefinedDensityField.h` / `Private/VoxelRedefinedDensityField.cpp` | Two default-off `WITH_EDITOR` research fields, selected by `voxel.ResearchRedefinedDensityField` (step 2d) and `voxel.ResearchParameterizedCave` (steps 2e/2f). Step 2f retains frozen 2e parameters for its rejection audit, while the new profiles use strongly jittered, individually rotated elliptical rooms capped against nearest neighbors, leaving a solid web joined by narrow tree/loop tunnels and capped shafts. Room heights and feature rolls vary by cell; ledges, occasional pillars, stalactites/stalagmites, basin floors, and bounded hanging masses remain features. Point and brick detail sampling use the same 32/16/8-sample bands and smooth ridge response; the eight-sample shortest wavelength and 4.2-unit final-field supremum are preserved. The thread-local cell cache and per-column distance reuse remain. In an editor game with this field enabled, sealed-solid, out-of-layout, and outer-classifier verdicts are declined without changing their console switches. No reflected class layout or serialized assets change. | +| `Private/Tests/VoxelForgeParameterizedGenerationTest.cpp` | Separate three-seed functional sweep for the step 2f parameterized field; passage landing air/floor brackets and arrival-to-exit walking routes are checked without changing the live-world tests. | | `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/step2f/reject/ingame_flakes_closeup.png b/Saved/Research/step2f/reject/ingame_flakes_closeup.png new file mode 100644 index 0000000..f40465a Binary files /dev/null and b/Saved/Research/step2f/reject/ingame_flakes_closeup.png differ diff --git a/Saved/Research/step2f/reject/ingame_pillar_forest.png b/Saved/Research/step2f/reject/ingame_pillar_forest.png new file mode 100644 index 0000000..2e6e81b Binary files /dev/null and b/Saved/Research/step2f/reject/ingame_pillar_forest.png differ diff --git a/Source/VoxelForge/Private/VoxelRedefinedDensityField.cpp b/Source/VoxelForge/Private/VoxelRedefinedDensityField.cpp index b932874..13fcb4a 100644 --- a/Source/VoxelForge/Private/VoxelRedefinedDensityField.cpp +++ b/Source/VoxelForge/Private/VoxelRedefinedDensityField.cpp @@ -12,12 +12,14 @@ #include "VoxelTypes.h" #include +#include namespace VoxelForgeRedefinedDensityField { namespace { - constexpr float DetailAmplitude = 1.4f + 0.9f + 0.45f + 2.5f; + constexpr float LegacyDetailAmplitude = 1.4f + 0.9f + 0.45f + 2.5f; + constexpr float CoherentDetailAmplitude = 2.0f + 1.2f + 0.6f + 0.4f; constexpr float DetailBoundNumericalMargin = 1.0e-3f; constexpr float BaseFieldLipschitzBound = 1.25f; constexpr float ParameterizedFieldLipschitzBound = 1.60f; @@ -54,7 +56,8 @@ namespace FGenerationParameters Parameters; }; - const FParameterProfile GParameterProfiles[] = { + // Frozen step 2e profiles are retained only for the step 2f before/after audit. + const FParameterProfile GLegacyStep2eProfiles[] = { { TEXT("OpenVaults"), { 176.0f, 0.62f, 0.76f, 340.0f, 12.0f, 40.0f, 36.0f, 0.35f, 0.14f, 22.0f, 0.04f, 0.12f, 0.08f, 0.15f, 0.00f, 0.0f, 0.50f } }, { TEXT("TightNetwork"), { 104.0f, 0.52f, 0.64f, 256.0f, 8.0f, 20.0f, 22.0f, @@ -73,6 +76,38 @@ namespace 0.40f, 0.55f, 20.0f, 0.45f, 0.68f, 0.50f, 0.75f, 0.42f, 28.0f, 0.40f } }, }; + const FParameterProfile GParameterProfiles[] = { + { TEXT("OpenVaults"), { 176.0f, 0.31f, 0.40f, 132.0f, 8.0f, 22.0f, 18.0f, + 0.12f, 0.08f, 16.0f, 0.02f, 0.06f, 0.04f, 0.22f, 0.00f, 0.0f, 0.50f } }, + { TEXT("TightNetwork"), { 104.0f, 0.27f, 0.35f, 104.0f, 5.0f, 18.0f, 14.0f, + 0.28f, 0.12f, 15.0f, 0.02f, 0.06f, 0.02f, 0.14f, 0.00f, 0.0f, 0.50f } }, + { TEXT("ChamberHalls"), { 144.0f, 0.32f, 0.41f, 136.0f, 10.0f, 21.0f, 17.0f, + 0.18f, 0.10f, 16.0f, 0.03f, 0.06f, 0.06f, 0.20f, 0.05f, 16.0f, 0.40f } }, + { TEXT("ShaftForest"), { 128.0f, 0.28f, 0.36f, 120.0f, 7.0f, 19.0f, 15.0f, + 0.16f, 0.42f, 20.0f, 0.03f, 0.06f, 0.03f, 0.22f, 0.00f, 0.0f, 0.50f } }, + { TEXT("FloatingArchipelago"), { 136.0f, 0.29f, 0.37f, 116.0f, 6.0f, 19.0f, 15.0f, + 0.12f, 0.08f, 15.0f, 0.02f, 0.06f, 0.08f, 0.20f, 0.00f, 0.0f, 0.50f } }, + { TEXT("PillaredCathedral"), { 160.0f, 0.31f, 0.40f, 132.0f, 8.0f, 22.0f, 17.0f, + 0.20f, 0.08f, 16.0f, 0.16f, 0.06f, 0.04f, 0.22f, 0.00f, 0.0f, 0.50f } }, + { TEXT("TerracedBasin"), { 136.0f, 0.30f, 0.38f, 124.0f, 8.0f, 20.0f, 16.0f, + 0.14f, 0.10f, 16.0f, 0.04f, 0.06f, 0.04f, 0.18f, 0.46f, 24.0f, 0.30f } }, + { TEXT("HyperComplex"), { 96.0f, 0.28f, 0.37f, 104.0f, 9.0f, 18.0f, 14.0f, + 0.26f, 0.32f, 17.0f, 0.08f, 0.06f, 0.05f, 0.18f, 0.12f, 12.0f, 0.40f } }, + }; + + std::atomic GUseLegacyStep2eAuditMode{false}; + + FORCEINLINE bool IsLegacyStep2eAuditMode() + { + return GUseLegacyStep2eAuditMode.load(std::memory_order_relaxed); + } + + FORCEINLINE float CurrentDetailAmplitude(bool bParameterized) + { + return bParameterized && !IsLegacyStep2eAuditMode() + ? CoherentDetailAmplitude : LegacyDetailAmplitude; + } + struct FLandingSite { FVector Position = FVector::ZeroVector; @@ -161,6 +196,7 @@ namespace uint32 LayoutVersion = 0xFFFFFFFFu; int32 Seed = INT32_MIN; int32 ParameterSet = INT32_MIN; + bool bLegacyStep2eAuditMode = false; TArray Strates; TArray PassageBounds; }; @@ -223,13 +259,26 @@ namespace return FMath::Lerp(Z0Y0, Z0Y1, AZ); } - FORCEINLINE float DetailAt(float X, float Y, float Z, uint32 Seed) + FORCEINLINE float DetailAt( + float X, float Y, float Z, uint32 Seed, bool bCoherentStep2f) { - 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; + if (!bCoherentStep2f || IsLegacyStep2eAuditMode()) + { + 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; + } + + // The shortest wavelength is eight lattice samples (two meters). Three + // interpolated scales and the smooth ridge response keep broad folds + // while excluding the lattice-scale noise used by the frozen 2e field. + const float Broad = Noise3D(X, Y, Z, 32.0f, Seed ^ 0x68BC21EBu); + const float Medium = Noise3D(X, Y, Z, 16.0f, Seed ^ 0x02E5BE93u); + const float Fine = Noise3D(X, Y, Z, 8.0f, Seed ^ 0x967A889Bu); + const float SmoothRidge = FMath::Sqrt(Fine * Fine + 0.0625f) - 0.25f; + return Broad * 2.0f + Medium * 1.2f + Fine * 0.6f + SmoothRidge * 0.4f; } FORCEINLINE float DistanceToPeriodicLine(float Position, float Spacing, float Offset) @@ -398,7 +447,7 @@ namespace // 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); + Params.ConnectorRadius, LegacyDetailAmplitude + 1.0f, 18.0f); const float CellMinX = CellX * Spacing; const float CellMaxX = CellMinX + Spacing; const float CellMinY = CellY * Spacing; @@ -447,7 +496,7 @@ namespace 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 VerticalPad = LegacyDetailAmplitude + 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); @@ -491,11 +540,13 @@ namespace const uint64 Lifetime = Manager->GetCacheLifetimeId(); const uint32 Version = Manager->GetLayoutVersion(); const int32 ParameterSet = CVarResearchParameterizedCaveSet.GetValueOnAnyThread(); + const bool bLegacyStep2eAuditMode = IsLegacyStep2eAuditMode(); if (GPlanCache.Manager == Manager && GPlanCache.ManagerLifetimeId == Lifetime && GPlanCache.LayoutVersion == Version && GPlanCache.Seed == Generator.Seed - && GPlanCache.ParameterSet == ParameterSet) + && GPlanCache.ParameterSet == ParameterSet + && GPlanCache.bLegacyStep2eAuditMode == bLegacyStep2eAuditMode) { return; } @@ -507,6 +558,7 @@ namespace GPlanCache.LayoutVersion = Version; GPlanCache.Seed = Generator.Seed; GPlanCache.ParameterSet = ParameterSet; + GPlanCache.bLegacyStep2eAuditMode = bLegacyStep2eAuditMode; const TArray& Layout = Manager->GetLayout(); GPlanCache.Strates.Reserve(Layout.Num()); for (const FStrateSlot& Slot : Layout) @@ -568,9 +620,12 @@ namespace : static_cast(Hash3( Plan.StrateIndex, Plan.MinChunkZ, 0x2E, StrateSeed) % UE_ARRAY_COUNT(GParameterProfiles)); - Plan.Generation = GParameterProfiles[Plan.GenerationProfile].Parameters; - const float StrateHeight = FMath::Max( - 48.0f, Top - Bottom - 8.0f); + Plan.Generation = (bLegacyStep2eAuditMode + ? GLegacyStep2eProfiles[Plan.GenerationProfile] + : GParameterProfiles[Plan.GenerationProfile]).Parameters; + const float StrateHeight = bLegacyStep2eAuditMode + ? FMath::Max(48.0f, Top - Bottom - 8.0f) + : FMath::Max(48.0f, Top - Bottom - 64.0f); Plan.Generation.RoomHeightVoxels = FMath::Clamp( Plan.Generation.RoomHeightVoxels, 48.0f, StrateHeight); Plan.Generation.RoomPitchVoxels = FMath::Max( @@ -792,6 +847,8 @@ namespace float CeilingZ = 0.0f; float RadiusX = 0.0f; float RadiusY = 0.0f; + float RoomCos = 1.0f; + float RoomSin = 0.0f; float PillarX = 0.0f; float PillarY = 0.0f; float PillarRadius = 0.0f; @@ -800,6 +857,8 @@ namespace float FloatingY[2] = {}; float FloatingZ[2] = {}; float FloatingRadius[2] = {}; + float FloatingRadiusY[2] = {}; + float FloatingRadiusZ[2] = {}; float SpireLength = 0.0f; float SpireRadius = 0.0f; uint8 FeatureFlags = 0; @@ -861,6 +920,19 @@ namespace OutY = -Plan.GridSin * X + Plan.GridCos * Y; } + FORCEINLINE void GeneratedCellGridCenter( + const FStratePlan& Plan, int32 CellX, int32 CellY, + float& OutX, float& OutY, bool bLegacyStep2eAuditMode) + { + const uint32 Hash = Hash3(CellX, CellY, Plan.StrateIndex, Plan.GenerationSeed); + const float JitterScale = bLegacyStep2eAuditMode ? 0.08f : 1.00f; + const float JitterX = (ParameterRoll(Hash, 0x91A2u) - 0.5f) * JitterScale; + const float JitterY = (ParameterRoll(Hash, 0xB4C3u) - 0.5f) * JitterScale; + const float Pitch = Plan.Generation.RoomPitchVoxels; + OutX = Plan.GridOffsetX + (CellX + 0.5f + JitterX) * Pitch; + OutY = Plan.GridOffsetY + (CellY + 0.5f + JitterY) * Pitch; + } + FGeneratedCaveCell BuildGeneratedCell( const FStratePlan& Plan, int32 CellX, int32 CellY) { @@ -870,18 +942,48 @@ namespace const FGenerationParameters& P = Plan.Generation; const float Pitch = P.RoomPitchVoxels; const uint32 Hash = Hash3(CellX, CellY, Plan.StrateIndex, Plan.GenerationSeed); - const float JitterX = (ParameterRoll(Hash, 0x91A2u) - 0.5f) * 0.08f; - const float JitterY = (ParameterRoll(Hash, 0xB4C3u) - 0.5f) * 0.08f; - const float LocalX = Plan.GridOffsetX + (CellX + 0.5f + JitterX) * Pitch; - const float LocalY = Plan.GridOffsetY + (CellY + 0.5f + JitterY) * Pitch; + const bool bLegacyStep2eAuditMode = IsLegacyStep2eAuditMode(); + float LocalX = 0.0f; + float LocalY = 0.0f; + GeneratedCellGridCenter( + Plan, CellX, CellY, LocalX, LocalY, bLegacyStep2eAuditMode); Cell.CenterX = Plan.GridCos * LocalX - Plan.GridSin * LocalY; Cell.CenterY = Plan.GridSin * LocalX + Plan.GridCos * LocalY; const float RadiusFraction = FMath::Lerp( P.MinimumRoomRadiusFraction, P.MaximumRoomRadiusFraction, ParameterRoll(Hash, 0xA61Bu)); Cell.RadiusX = Pitch * RadiusFraction; - Cell.RadiusY = Cell.RadiusX * FMath::Lerp( - 0.82f, 0.98f, ParameterRoll(Hash, 0x27C5u)); + if (!bLegacyStep2eAuditMode) + { + float NearestCenterSpacing = FLT_MAX; + for (int32 NeighborY = -1; NeighborY <= 1; ++NeighborY) + for (int32 NeighborX = -1; NeighborX <= 1; ++NeighborX) + { + if (NeighborX == 0 && NeighborY == 0) + { + continue; + } + float NeighborLocalX = 0.0f; + float NeighborLocalY = 0.0f; + GeneratedCellGridCenter(Plan, CellX + NeighborX, CellY + NeighborY, + NeighborLocalX, NeighborLocalY, false); + NearestCenterSpacing = FMath::Min(NearestCenterSpacing, + FMath::Sqrt(FMath::Square(NeighborLocalX - LocalX) + + FMath::Square(NeighborLocalY - LocalY))); + } + // Keep a measurable rock web between chambers. Their graph edges open only narrow + // connecting tunnels, so the room lattice no longer merges into a forest of pillars. + Cell.RadiusX = FMath::Min(Cell.RadiusX, NearestCenterSpacing * 0.43f); + const float Angle = ParameterRoll(Hash, 0xE17Du) * 6.283185307179586f; + VoxelMath::DetSinCos(Cell.RoomSin, Cell.RoomCos, Angle); + Cell.RadiusY = Cell.RadiusX * FMath::Lerp( + 0.64f, 0.94f, ParameterRoll(Hash, 0x27C5u)); + } + else + { + Cell.RadiusY = Cell.RadiusX * FMath::Lerp( + 0.82f, 0.98f, ParameterRoll(Hash, 0x27C5u)); + } if (VoxelHash::ToFloat01(Hash3(CellX, CellY, 0x53, Plan.GenerationSeed)) < P.VerticalShaftProbability) { @@ -905,7 +1007,11 @@ namespace } Cell.FloorZ = Plan.GenerationFloorZ + (ParameterRoll(Hash, 0xF103u) * 2.0f - 1.0f) * P.FloorReliefVoxels; - Cell.CeilingZ = Cell.FloorZ + P.RoomHeightVoxels; + const float RoomHeight = bLegacyStep2eAuditMode + ? P.RoomHeightVoxels + : P.RoomHeightVoxels * FMath::Lerp( + 0.82f, 1.12f, ParameterRoll(Hash, 0x55A9u)); + Cell.CeilingZ = Cell.FloorZ + RoomHeight; if (ParameterRoll(Hash, 0x8811u) < P.PillarProbability) { @@ -926,7 +1032,8 @@ namespace if (ParameterRoll(Hash, 0xE25Fu) < P.FloatingMassProbability) { Cell.FeatureFlags |= HasFloatingFlag; - for (int32 Blob = 0; Blob < 2; ++Blob) + const int32 BlobCount = bLegacyStep2eAuditMode ? 2 : 1; + for (int32 Blob = 0; Blob < BlobCount; ++Blob) { const uint32 BlobSalt = 0x1567u + static_cast(Blob) * 0x101u; const float OffsetX = (ParameterRoll(Hash, BlobSalt) - 0.5f) * Cell.RadiusX * 0.60f; @@ -937,8 +1044,19 @@ namespace Cell.FloorZ + P.RoomHeightVoxels * 0.28f, Cell.CeilingZ - P.RoomHeightVoxels * 0.28f, ParameterRoll(Hash, BlobSalt + 2u)); - Cell.FloatingRadius[Blob] = Cell.RadiusX * FMath::Lerp( - 0.07f, 0.15f, ParameterRoll(Hash, BlobSalt + 3u)); + Cell.FloatingRadius[Blob] = bLegacyStep2eAuditMode + ? Cell.RadiusX * FMath::Lerp( + 0.07f, 0.15f, ParameterRoll(Hash, BlobSalt + 3u)) + : FMath::Clamp(Cell.RadiusX * FMath::Lerp( + 0.16f, 0.28f, ParameterRoll(Hash, BlobSalt + 3u)), 8.0f, 18.0f); + Cell.FloatingRadiusY[Blob] = bLegacyStep2eAuditMode + ? Cell.FloatingRadius[Blob] + : Cell.FloatingRadius[Blob] * FMath::Lerp( + 0.62f, 1.16f, ParameterRoll(Hash, BlobSalt + 4u)); + Cell.FloatingRadiusZ[Blob] = bLegacyStep2eAuditMode + ? Cell.FloatingRadius[Blob] + : Cell.FloatingRadius[Blob] * FMath::Lerp( + 0.58f, 1.62f, ParameterRoll(Hash, BlobSalt + 5u)); } } if (ParameterRoll(Hash, 0xC6A9u) < P.SpireProbability) @@ -1014,7 +1132,7 @@ namespace FORCEINLINE float GeneratedRoomCell( const FGeneratedCaveCell& Cell, - float Z, float Radius, float Horizontal, + float X, float Y, float Z, float Radius, float Horizontal, const FParameterizedColumnContext& Column, int32 CellIndex) { const float FloorZ = Column.RoomFloors[CellIndex]; @@ -1043,16 +1161,44 @@ namespace } if ((Cell.FeatureFlags & HasFloatingFlag) != 0) { - for (int32 Blob = 0; Blob < 2; ++Blob) + const int32 BlobCount = IsLegacyStep2eAuditMode() ? 2 : 1; + for (int32 Blob = 0; Blob < BlobCount; ++Blob) { - if (Room <= -Cell.FloatingRadius[Blob]) + const float FloatingSupportRadius = IsLegacyStep2eAuditMode() + ? Cell.FloatingRadius[Blob] + : FMath::Max3(Cell.FloatingRadius[Blob], + Cell.FloatingRadiusY[Blob], Cell.FloatingRadiusZ[Blob]); + if (Room <= -FloatingSupportRadius) { continue; } const float BZ = Z - Cell.FloatingZ[Blob]; - const float BlobSolid = FMath::Sqrt( - Column.FloatingXYDistanceSquared[CellIndex][Blob] + BZ * BZ) - - Cell.FloatingRadius[Blob]; + float BlobSolid = 0.0f; + if (IsLegacyStep2eAuditMode()) + { + BlobSolid = FMath::Sqrt( + Column.FloatingXYDistanceSquared[CellIndex][Blob] + BZ * BZ) + - Cell.FloatingRadius[Blob]; + } + else + { + const float BX = X - Cell.FloatingX[Blob]; + const float BY = Y - Cell.FloatingY[Blob]; + const float RadiusX = FMath::Max(Cell.FloatingRadius[Blob], 1.0f); + const float RadiusY = FMath::Max(Cell.FloatingRadiusY[Blob], 1.0f); + const float RadiusZ = FMath::Max(Cell.FloatingRadiusZ[Blob], 1.0f); + const float ScaledLength = FMath::Sqrt( + FMath::Square(BX / RadiusX) + + FMath::Square(BY / RadiusY) + + FMath::Square(BZ / RadiusZ)); + const float ScaledGradient = FMath::Sqrt( + FMath::Square(BX / FMath::Square(RadiusX)) + + FMath::Square(BY / FMath::Square(RadiusY)) + + FMath::Square(BZ / FMath::Square(RadiusZ))); + BlobSolid = ScaledGradient > 1.0e-3f + ? ScaledLength * (ScaledLength - 1.0f) / ScaledGradient + : -FMath::Min3(RadiusX, RadiusY, RadiusZ); + } if (BlobSolid < Room) { Room = BlobSolid; @@ -1212,7 +1358,20 @@ namespace const float DY = Y - OutColumn.Cells[Index]->CenterY; const float DistanceSquared = DX * DX + DY * DY; OutColumn.RoomDistanceSquared[Index] = DistanceSquared; - OutColumn.RoomRadii[Index] = FMath::Sqrt(DistanceSquared); + if (IsLegacyStep2eAuditMode()) + { + OutColumn.RoomRadii[Index] = FMath::Sqrt(DistanceSquared); + } + else + { + const FGeneratedCaveCell& Cell = *OutColumn.Cells[Index]; + const float LocalX = Cell.RoomCos * DX + Cell.RoomSin * DY; + const float LocalY = -Cell.RoomSin * DX + Cell.RoomCos * DY; + const float ScaledY = LocalY * Cell.RadiusX + / FMath::Max(Cell.RadiusY, 1.0f); + OutColumn.RoomRadii[Index] = FMath::Sqrt( + LocalX * LocalX + ScaledY * ScaledY); + } OutColumn.RoomHorizontals[Index] = OutColumn.Cells[Index]->RadiusX - OutColumn.RoomRadii[Index]; @@ -1347,7 +1506,7 @@ namespace } } Macro = SmoothMaximum(Macro, - GeneratedRoomCell(Cell, Z, + GeneratedRoomCell(Cell, X, Y, Z, ColumnContext->RoomRadii[Index], ColumnContext->RoomHorizontals[Index], *ColumnContext, Index), 6.0f); @@ -1384,7 +1543,10 @@ namespace } const float Horizontal = Plan.Generation.ShaftRadiusVoxels - ColumnContext->RoomRadii[Index]; - const float Vertical = FMath::Min(Z - Bottom, Top - Z); + const float ShaftInset = IsLegacyStep2eAuditMode() + ? 0.0f : FMath::Min(48.0f, (Top - Bottom) * 0.20f); + const float Vertical = FMath::Min( + Z - (Bottom + ShaftInset), (Top - ShaftInset) - Z); Macro = SmoothMaximum(Macro, SmoothMinimum(Horizontal, Vertical, 5.0f), 5.0f); } @@ -1458,6 +1620,19 @@ namespace } } + if (!IsLegacyStep2eAuditMode()) + { + // Keep a four-meter roof and floor through rooms, shafts, and authored passage + // modifiers. The same cap is applied again after passage edits in EvaluateInternal. + constexpr float StrateSealThickness = 16.0f; + const float StrateBottom = static_cast(Plan.MinChunkZ * CHUNK_SIZE); + const float StrateTop = static_cast(Plan.MaxChunkZExclusive * CHUNK_SIZE); + const float BoundaryLimit = FMath::Min( + Z - StrateBottom - StrateSealThickness, + StrateTop - StrateSealThickness - Z); + Macro = FMath::Min(Macro, BoundaryLimit); + } + if (OutDetailScale != nullptr) { *OutDetailScale = DetailFade; @@ -1524,9 +1699,6 @@ namespace 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)); } @@ -1717,6 +1889,11 @@ void SetParameterizedEnabled(bool bEnabled) CVarResearchParameterizedCave->Set(bEnabled ? 1 : 0, ECVF_SetByCode); } +void SetLegacyStep2eAuditMode(bool bEnabled) +{ + GUseLegacyStep2eAuditMode.store(bEnabled, std::memory_order_relaxed); +} + void SetParameterSet(int32 ParameterSet) { CVarResearchParameterizedCaveSet->Set( @@ -1741,8 +1918,10 @@ bool GetParameterSetInfo(int32 ParameterSet, const TCHAR*& OutName, { return false; } - OutName = GParameterProfiles[ParameterSet].Name; - OutParameters = GParameterProfiles[ParameterSet].Parameters; + const FParameterProfile& Profile = IsLegacyStep2eAuditMode() + ? GLegacyStep2eProfiles[ParameterSet] : GParameterProfiles[ParameterSet]; + OutName = Profile.Name; + OutParameters = Profile.Parameters; return true; } @@ -1817,7 +1996,7 @@ static float EvaluateInternal(const UVoxelGenerator& Generator, const uint32 DetailSeed = static_cast(Generator.Seed) ^ static_cast(Plan->StrateIndex * 7919 + 104729); const float FinalDetail = bHasDetailValue - ? DetailValue : DetailAt(WorldX, WorldY, WorldZ, DetailSeed); + ? DetailValue : DetailAt(WorldX, WorldY, WorldZ, DetailSeed, bParameterized); Result = Macro + FinalDetail * DetailFade * DetailScale; const bool bMayContainPassage = bParameterized @@ -1888,6 +2067,17 @@ static float EvaluateInternal(const UVoxelGenerator& Generator, } } + if (bParameterized && Plan != nullptr && !IsLegacyStep2eAuditMode()) + { + constexpr float StrateSealThickness = 16.0f; + const float StrateBottom = static_cast(Plan->MinChunkZ * CHUNK_SIZE); + const float StrateTop = static_cast(Plan->MaxChunkZExclusive * CHUNK_SIZE); + const float BoundaryLimit = FMath::Min( + WorldZ - StrateBottom - StrateSealThickness, + StrateTop - StrateSealThickness - WorldZ); + Result = FMath::Min(Result, BoundaryLimit); + } + if (OutPreEditDensity != nullptr) { *OutPreEditDensity = Result; @@ -1927,6 +2117,24 @@ float EvaluateParameterized(const UVoxelGenerator& Generator, OutPreEditDensity, DetailScale, nullptr, true, nullptr); } +float EvaluateParameterizedDetail( + const UVoxelGenerator& Generator, float WorldX, float WorldY, float WorldZ) +{ + EnsurePlan(Generator); + const FIntVector Chunk( + FMath::FloorToInt(WorldX / CHUNK_SIZE), + FMath::FloorToInt(WorldY / CHUNK_SIZE), + FMath::FloorToInt(WorldZ / CHUNK_SIZE)); + const FStratePlan* Plan = FindStrate(Chunk); + if (Plan == nullptr) + { + return 0.0f; + } + const uint32 DetailSeed = static_cast(Generator.Seed) + ^ static_cast(Plan->StrateIndex * 7919 + 104729); + return DetailAt(WorldX, WorldY, WorldZ, DetailSeed, true); +} + float EvaluateParameterizedWithDetailValue(const UVoxelGenerator& Generator, float WorldX, float WorldY, float WorldZ, float DetailValue, float* OutPreEditDensity, float DetailScale, @@ -2076,7 +2284,7 @@ void BuildParameterizedBrickContext( void BuildDetailBrick(const UVoxelGenerator& Generator, FIntVector OriginVoxels, FIntVector Size, float DetailScale, TArray& OutDetail, - FBrickEvaluationContext* OutContext) + FBrickEvaluationContext* OutContext, bool bParameterized) { (void)DetailScale; const int32 SampleCount = Size.X * Size.Y * Size.Z; @@ -2160,7 +2368,7 @@ void BuildDetailBrick(const UVoxelGenerator& Generator, FIntVector OriginVoxels, OutDetail[(Z * Size.Y + Y) * Size.X + X] = DetailAt( static_cast(OriginVoxels.X + X), static_cast(OriginVoxels.Y + Y), - static_cast(WorldZ), SampleSeed); + static_cast(WorldZ), SampleSeed, bParameterized); } } return; @@ -2250,8 +2458,13 @@ void BuildDetailBrick(const UVoxelGenerator& Generator, FIntVector OriginVoxels, { return (Z * CountY + Y) * CountX + X; }; - const auto AddNoiseBand = [&](int32 CellSize, uint32 BandSeed, float Amplitude) + const auto AddNoiseBand = [&](int32 CellSize, uint32 BandSeed, float Amplitude, + TArray* OutBand) { + if (OutBand != nullptr) + { + OutBand->SetNumUninitialized(SampleCount); + } const int32 MinCellX = FMath::FloorToInt( static_cast(OriginVoxels.X) / static_cast(CellSize)); const int32 MinCellY = FMath::FloorToInt( @@ -2330,14 +2543,39 @@ void BuildDetailBrick(const UVoxelGenerator& Generator, FIntVector OriginVoxels, 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; + const int32 OutputIndex = SampleIndex(X, Y, Z); + if (OutBand != nullptr) + { + (*OutBand)[OutputIndex] = Value; + } + else + { + OutDetail[OutputIndex] += 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); + if (bParameterized && !IsLegacyStep2eAuditMode()) + { + TArray Broad; + TArray Medium; + TArray Fine; + AddNoiseBand(32, Seed ^ 0x68BC21EBu, 1.0f, &Broad); + AddNoiseBand(16, Seed ^ 0x02E5BE93u, 1.0f, &Medium); + AddNoiseBand(8, Seed ^ 0x967A889Bu, 1.0f, &Fine); + for (int32 Index = 0; Index < SampleCount; ++Index) + { + const float Ridge = FMath::Sqrt(Fine[Index] * Fine[Index] + 0.0625f) - 0.25f; + OutDetail[Index] = Broad[Index] * 2.0f + Medium[Index] * 1.2f + + Fine[Index] * 0.6f + Ridge * 0.4f; + } + return; + } + + AddNoiseBand(8, Seed ^ 0x68BC21EBu, 1.4f, nullptr); + AddNoiseBand(4, Seed ^ 0x02E5BE93u, 0.9f, nullptr); + AddNoiseBand(2, Seed ^ 0x967A889Bu, 0.45f, nullptr); + AddNoiseBand(1, Seed ^ 0xA511E9B3u, 2.5f, nullptr); } static bool TryClassifyBrickInternal( @@ -2511,7 +2749,7 @@ static bool TryClassifyBrickInternal( ? EvaluateParameterizedMacro(*Plan, Centre.X, Centre.Y, Centre.Z, nullptr) : EvaluateMacro(*Plan, Centre.X, Centre.Y, Centre.Z, nullptr); const float FinalBound = MacroLipschitzBound * Radius - + DetailAmplitude * FMath::Abs(DetailScale) + + CurrentDetailAmplitude(bParameterized) * FMath::Abs(DetailScale) + DetailBoundNumericalMargin * FMath::Max(1.0f, FMath::Abs(DetailScale)); if (Macro > FinalBound) { diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index a823958..4b66f65 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -74,10 +74,20 @@ static bool VF_TileFootprintsOverlap(const FVoxelTileKey& A, const FVoxelTileKey #if WITH_EDITOR #include "VoxelStrateComposer.h" #include "VoxelStrateMeasure.h" +#include "VoxelRedefinedDensityField.h" #endif namespace { + bool IsParameterizedResearchFieldActive() + { +#if WITH_EDITOR + return VoxelForgeRedefinedDensityField::IsParameterizedEnabled(); +#else + return false; +#endif + } + int32 GVoxelForgeCollisionGate = 1; FAutoConsoleVariableRef CVarVoxelForgeCollisionGate( TEXT("voxel.CollisionGate"), @@ -2197,7 +2207,8 @@ void AVoxelWorld::BeginPlay() Mesher->bGenerateSkirts = Settings->bGenerateSkirts; Mesher->SkirtCells = Settings->SkirtCells; Mesher->LODOctaveDrop = Settings->LODOctaveDrop; // T2.b — 0 = off - Mesher->bUseBlockEarlyOut = GVoxelForgeUseBlockEarlyOut != 0; + Mesher->bUseBlockEarlyOut = GVoxelForgeUseBlockEarlyOut != 0 + && !IsParameterizedResearchFieldActive(); UE_LOG(LogTemp, Display, TEXT("[VoxelForgeClassifierConfig] outer_mode=%d sealed_solid_proof=%d nested_block_early_out=%d"), @@ -10368,7 +10379,8 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector // même pour les cellules uniformes, et ces tuiles sont rares (fenêtre d'ombre). // (Gate IsoLevel == 0 : les verdicts du classifieur supposent l'iso MC à zéro exactement.) bool bTrivialEmpty = false; - if (GVoxelForgeSealedSolidProof != 0 + if (!IsParameterizedResearchFieldActive() + && GVoxelForgeSealedSolidProof != 0 && !bWantCapture && SharedDensityOverride == nullptr && Generator && Mesher && Mesher->IsoLevel == 0.0f) @@ -10411,7 +10423,8 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector return; } const int32 OuterClassifierMode = FMath::Clamp(GVoxelForgeOuterClassifierMode, 0, 1); - if (!bTrivialEmpty && OuterClassifierMode != 0 + if (!IsParameterizedResearchFieldActive() + && !bTrivialEmpty && OuterClassifierMode != 0 && !bWantCapture && SharedDensityOverride == nullptr && Generator && Mesher && Mesher->IsoLevel == 0.0f) { diff --git a/Source/VoxelForge/Public/VoxelRedefinedDensityField.h b/Source/VoxelForge/Public/VoxelRedefinedDensityField.h index 64ea265..f855be7 100644 --- a/Source/VoxelForge/Public/VoxelRedefinedDensityField.h +++ b/Source/VoxelForge/Public/VoxelRedefinedDensityField.h @@ -40,7 +40,7 @@ namespace VoxelForgeRedefinedDensityField TArray PassageCandidates; }; - /** Cell-level controls for the separate step 2e procedural room-network field. */ + /** Cell-level controls for the separate editor-only parameterized cave field. */ struct FGenerationParameters { float RoomPitchVoxels = 144.0f; @@ -101,9 +101,11 @@ namespace VoxelForgeRedefinedDensityField VOXELFORGE_API float GetDetailScale(); VOXELFORGE_API void SetDetailScale(float Scale); - /** Step 2e is editor-only and default-off. -1 rolls one of eight profiles per strate. */ + /** The parameterized cave field is editor-only and default-off. -1 rolls per strate. */ VOXELFORGE_API bool IsParameterizedEnabled(); VOXELFORGE_API void SetParameterizedEnabled(bool bEnabled); + /** Select the frozen step 2e implementation while the step 2f audit measures its gates. */ + VOXELFORGE_API void SetLegacyStep2eAuditMode(bool bEnabled); VOXELFORGE_API void SetParameterSet(int32 ParameterSet); VOXELFORGE_API int32 GetParameterSet(); VOXELFORGE_API int32 GetParameterSetCount(); @@ -115,6 +117,10 @@ namespace VoxelForgeRedefinedDensityField const UVoxelGenerator& Generator, float WorldX, float WorldY, float WorldZ, float* OutPreEditDensity = nullptr, float DetailScale = 1.0f); + /** Return the active parameterized detail signal for the band-limit research gate. */ + VOXELFORGE_API float EvaluateParameterizedDetail( + const UVoxelGenerator& Generator, float WorldX, float WorldY, float WorldZ); + VOXELFORGE_API float EvaluateParameterizedWithDetailValue( const UVoxelGenerator& Generator, float WorldX, float WorldY, float WorldZ, float DetailValue, float* OutPreEditDensity = nullptr, float DetailScale = 1.0f, @@ -144,7 +150,8 @@ namespace VoxelForgeRedefinedDensityField /** 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); + TArray& OutDetail, FBrickEvaluationContext* OutContext = nullptr, + bool bParameterized = false); /** Evaluate with a detail value produced by BuildDetailBrick. */ VOXELFORGE_API float EvaluateWithDetailValue(const UVoxelGenerator& Generator, diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp index e286eac..4102898 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -3165,6 +3165,481 @@ FStep2eVarietyMetrics ComputeStep2eVarietyMetrics( return Result; } +struct FStep2fFragmentMetrics +{ + int32 AirComponents = 0; + int32 SolidComponents = 0; + int64 AirCrumbsBelowOneCubicMeter = 0; + int64 SolidCrumbsBelowOneCubicMeter = 0; + int64 AirCrumbVoxelSamples = 0; + int64 SolidCrumbVoxelSamples = 0; + int32 LargestAirComponentVoxels = 0; + int32 LargestSolidComponentVoxels = 0; +}; + +FStep2fFragmentMetrics ComputeStep2fFragmentMetrics( + const TArray& Density, int32 Dimension) +{ + FStep2fFragmentMetrics Result; + const int32 Count = Density.Num(); + if (Count <= 0 || Count != Dimension * Dimension * Dimension) + { + return Result; + } + TArray Parent; + Parent.Init(-1, Count); + const auto FindRoot = [&Parent](int32 Index) + { + int32 Root = Index; + while (Parent[Root] >= 0) + { + Root = Parent[Root]; + } + while (Index != Root && Parent[Index] >= 0) + { + const int32 Next = Parent[Index]; + Parent[Index] = Root; + Index = Next; + } + return Root; + }; + const auto Join = [&Parent, &FindRoot](int32 A, int32 B) + { + int32 RootA = FindRoot(A); + int32 RootB = FindRoot(B); + if (RootA == RootB) + { + return; + } + if (Parent[RootA] > Parent[RootB]) + { + Swap(RootA, RootB); + } + Parent[RootA] += Parent[RootB]; + Parent[RootB] = RootA; + }; + for (int32 Z = 0; Z < Dimension; ++Z) + for (int32 Y = 0; Y < Dimension; ++Y) + for (int32 X = 0; X < Dimension; ++X) + { + const int32 Index = (Z * Dimension + Y) * Dimension + X; + const bool bAir = Density[Index] > 0.0f; + // Use 26-neighbor connectivity on the sign lattice. Diagonally adjacent + // samples belong to the same continuous marching-cubes component; mesh + // islands and folded triangles are measured separately below. + for (int32 DeltaZ = -1; DeltaZ <= 0; ++DeltaZ) + for (int32 DeltaY = -1; DeltaY <= 1; ++DeltaY) + for (int32 DeltaX = -1; DeltaX <= 1; ++DeltaX) + { + if (DeltaZ == 0 && (DeltaY > 0 || (DeltaY == 0 && DeltaX >= 0))) + { + continue; + } + const int32 NeighborX = X + DeltaX; + const int32 NeighborY = Y + DeltaY; + const int32 NeighborZ = Z + DeltaZ; + if (NeighborX < 0 || NeighborY < 0 || NeighborZ < 0 + || NeighborX >= Dimension || NeighborY >= Dimension + || NeighborZ >= Dimension) + { + continue; + } + const int32 Neighbor = (NeighborZ * Dimension + NeighborY) * Dimension + + NeighborX; + if ((Density[Neighbor] > 0.0f) == bAir) + { + Join(Index, Neighbor); + } + } + } + constexpr int32 VoxelsPerCubicMeter = 4 * 4 * 4; + for (int32 Index = 0; Index < Count; ++Index) + { + if (Parent[Index] >= 0) + { + continue; + } + const int32 ComponentVoxels = -Parent[Index]; + const bool bAir = Density[Index] > 0.0f; + if (bAir) + { + ++Result.AirComponents; + Result.LargestAirComponentVoxels = FMath::Max( + Result.LargestAirComponentVoxels, ComponentVoxels); + if (ComponentVoxels < VoxelsPerCubicMeter) + { + ++Result.AirCrumbsBelowOneCubicMeter; + Result.AirCrumbVoxelSamples += ComponentVoxels; + } + } + else + { + ++Result.SolidComponents; + Result.LargestSolidComponentVoxels = FMath::Max( + Result.LargestSolidComponentVoxels, ComponentVoxels); + if (ComponentVoxels < VoxelsPerCubicMeter) + { + ++Result.SolidCrumbsBelowOneCubicMeter; + Result.SolidCrumbVoxelSamples += ComponentVoxels; + } + } + } + return Result; +} + +struct FStep2fGridSignatureMetrics +{ + double PitchCorrelationPeak = 0.0; + double NeighborCorrelationPeak = 0.0; + double PitchPeakExcess = 0.0; +}; + +FStep2fGridSignatureMetrics ComputeStep2fGridSignature( + const TArray& Density, int32 Dimension, float PitchVoxels) +{ + FStep2fGridSignatureMetrics Result; + constexpr int32 SampleStep = 4; + int64 AirCount = 0; + int64 SampleCount = 0; + for (int32 Z = 0; Z < Dimension; Z += SampleStep) + for (int32 Y = 0; Y < Dimension; Y += SampleStep) + for (int32 X = 0; X < Dimension; X += SampleStep) + { + AirCount += Density[(Z * Dimension + Y) * Dimension + X] > 0.0f ? 1 : 0; + ++SampleCount; + } + const double Mean = SampleCount > 0 + ? static_cast(AirCount) / SampleCount : 0.0; + const double Variance = Mean * (1.0 - Mean); + if (Variance <= 1.0e-6) + { + return Result; + } + const auto CorrelationAtRadius = [&](float Radius) + { + double BestCorrelation = -1.0; + for (int32 Direction = 0; Direction < 8; ++Direction) + { + const float Angle = static_cast(Direction) * 3.14159265358979323846f / 8.0f; + const int32 DX = FMath::RoundToInt(Radius * FMath::Cos(Angle)); + const int32 DY = FMath::RoundToInt(Radius * FMath::Sin(Angle)); + if (DX == 0 && DY == 0) + { + continue; + } + double Covariance = 0.0; + int64 Pairs = 0; + for (int32 Z = 0; Z < Dimension; Z += SampleStep) + for (int32 Y = 0; Y < Dimension; Y += SampleStep) + { + const int32 OtherY = Y + DY; + if (OtherY < 0 || OtherY >= Dimension) + { + continue; + } + for (int32 X = 0; X < Dimension; X += SampleStep) + { + const int32 OtherX = X + DX; + if (OtherX < 0 || OtherX >= Dimension) + { + continue; + } + const bool bAirA = Density[(Z * Dimension + Y) * Dimension + X] > 0.0f; + const bool bAirB = Density[(Z * Dimension + OtherY) * Dimension + OtherX] > 0.0f; + Covariance += (static_cast(bAirA) - Mean) + * (static_cast(bAirB) - Mean); + ++Pairs; + } + } + if (Pairs > 0) + { + BestCorrelation = FMath::Max( + BestCorrelation, Covariance / (static_cast(Pairs) * Variance)); + } + } + return BestCorrelation; + }; + Result.PitchCorrelationPeak = CorrelationAtRadius(PitchVoxels); + Result.NeighborCorrelationPeak = FMath::Max( + CorrelationAtRadius(PitchVoxels * 0.82f), + CorrelationAtRadius(PitchVoxels * 1.18f)); + Result.PitchPeakExcess = Result.PitchCorrelationPeak - Result.NeighborCorrelationPeak; + return Result; +} + +struct FStep2fDetailSpectrum +{ + double Rms = 0.0; + double LatticeScaleEnergyRatio = 0.0; +}; + +FStep2fDetailSpectrum ComputeStep2fDetailSpectrum(const TArray& Detail, int32 Dimension) +{ + FStep2fDetailSpectrum Result; + if (Detail.Num() != Dimension * Dimension * Dimension || Detail.IsEmpty()) + { + return Result; + } + double Mean = 0.0; + for (const float Value : Detail) + { + Mean += Value; + } + Mean /= Detail.Num(); + double Variance = 0.0; + for (const float Value : Detail) + { + Variance += FMath::Square(static_cast(Value) - Mean); + } + Variance /= Detail.Num(); + double DifferenceEnergy = 0.0; + int64 DifferenceCount = 0; + for (int32 Z = 0; Z < Dimension; ++Z) + for (int32 Y = 0; Y < Dimension; ++Y) + for (int32 X = 0; X < Dimension; ++X) + { + const int32 Index = (Z * Dimension + Y) * Dimension + X; + if (X + 1 < Dimension) + { + DifferenceEnergy += FMath::Square( + static_cast(Detail[Index + 1] - Detail[Index])); + ++DifferenceCount; + } + if (Y + 1 < Dimension) + { + DifferenceEnergy += FMath::Square( + static_cast(Detail[Index + Dimension] - Detail[Index])); + ++DifferenceCount; + } + if (Z + 1 < Dimension) + { + DifferenceEnergy += FMath::Square( + static_cast(Detail[Index + Dimension * Dimension] - Detail[Index])); + ++DifferenceCount; + } + } + Result.Rms = FMath::Sqrt(Variance); + Result.LatticeScaleEnergyRatio = Variance > 1.0e-8 && DifferenceCount > 0 + ? DifferenceEnergy / DifferenceCount / (2.0 * Variance) : 0.0; + return Result; +} + +struct FStep2fEdgeUse +{ + FVector FirstNormal = FVector::ZeroVector; + FVector SecondNormal = FVector::ZeroVector; + int32 Count = 0; +}; + +struct FStep2fMeshMetrics +{ + int32 TriangleCount = 0; + int64 ManifoldEdgeCount = 0; + double ShareDihedralsOver45Degrees = 0.0; + double P95DihedralDegrees = 0.0; +}; + +float Step2fPointTriangleDistanceSquared( + const FVector& Point, const FVector& A, const FVector& B, const FVector& C) +{ + const FVector AB = B - A; + const FVector AC = C - A; + const FVector AP = Point - A; + const FVector Normal = FVector::CrossProduct(AB, AC); + const double NormalSquared = Normal.SizeSquared(); + const double D00 = FVector::DotProduct(AB, AB); + const double D01 = FVector::DotProduct(AB, AC); + const double D11 = FVector::DotProduct(AC, AC); + const double D20 = FVector::DotProduct(AP, AB); + const double D21 = FVector::DotProduct(AP, AC); + const double Denominator = D00 * D11 - D01 * D01; + if (NormalSquared > 1.0e-8 && Denominator > 1.0e-8) + { + const double V = (D11 * D20 - D01 * D21) / Denominator; + const double W = (D00 * D21 - D01 * D20) / Denominator; + if (V >= 0.0 && W >= 0.0 && V + W <= 1.0) + { + const double PlaneDistance = FVector::DotProduct(AP, Normal) + / FMath::Sqrt(NormalSquared); + return FMath::Square(PlaneDistance); + } + } + const auto SegmentDistanceSquared = [&Point](const FVector& Start, const FVector& End) + { + const FVector Segment = End - Start; + const double LengthSquared = Segment.SizeSquared(); + const double T = LengthSquared > 1.0e-8 + ? FMath::Clamp(FVector::DotProduct(Point - Start, Segment) / LengthSquared, + 0.0, 1.0) + : 0.0; + return FVector::DistSquared(Point, Start + Segment * T); + }; + return FMath::Min3( + SegmentDistanceSquared(A, B), + SegmentDistanceSquared(B, C), + SegmentDistanceSquared(C, A)); +} + +FIntVector SelectStep2fMeshOrigin( + const TArray& Density, FIntVector RingOrigin, int32 Dimension) +{ + constexpr int32 PhysicalTileVoxels = 64; + constexpr int32 ScoreStepVoxels = 4; + constexpr int32 CandidateStepVoxels = 32; + const int32 MaximumStart = Dimension - 1 - PhysicalTileVoxels; + if (Density.Num() != Dimension * Dimension * Dimension || MaximumStart < 0) + { + return RingOrigin; + } + + TArray> Starts; + for (int32 Start = 0; Start <= MaximumStart; Start += CandidateStepVoxels) + { + Starts.Add(Start); + } + if (Starts.IsEmpty() || Starts.Last() != MaximumStart) + { + Starts.Add(MaximumStart); + } + + int64 BestSurfaceEdgeCount = -1; + FIntVector BestStart(MaximumStart / 2, MaximumStart / 2, MaximumStart / 2); + for (const int32 StartZ : Starts) + for (const int32 StartY : Starts) + for (const int32 StartX : Starts) + { + int64 SurfaceEdgeCount = 0; + for (int32 Z = 0; Z <= PhysicalTileVoxels; Z += ScoreStepVoxels) + for (int32 Y = 0; Y <= PhysicalTileVoxels; Y += ScoreStepVoxels) + for (int32 X = 0; X <= PhysicalTileVoxels; X += ScoreStepVoxels) + { + const int32 SampleX = StartX + X; + const int32 SampleY = StartY + Y; + const int32 SampleZ = StartZ + Z; + const int32 Index = (SampleZ * Dimension + SampleY) * Dimension + SampleX; + const bool bAir = Density[Index] > 0.0f; + if (X < PhysicalTileVoxels) + { + SurfaceEdgeCount += bAir != (Density[Index + ScoreStepVoxels] > 0.0f) ? 1 : 0; + } + if (Y < PhysicalTileVoxels) + { + SurfaceEdgeCount += bAir + != (Density[Index + ScoreStepVoxels * Dimension] > 0.0f) ? 1 : 0; + } + if (Z < PhysicalTileVoxels) + { + SurfaceEdgeCount += bAir + != (Density[Index + ScoreStepVoxels * Dimension * Dimension] > 0.0f) + ? 1 : 0; + } + } + if (SurfaceEdgeCount > BestSurfaceEdgeCount) + { + BestSurfaceEdgeCount = SurfaceEdgeCount; + BestStart = FIntVector(StartX, StartY, StartZ); + } + } + return RingOrigin + BestStart; +} + +FStep2fMeshMetrics ComputeStep2fMeshMetrics(const FVoxelMeshData& Mesh) +{ + FStep2fMeshMetrics Result; + Result.TriangleCount = Mesh.Triangles.Num() / 3; + TMap EdgeUses; + EdgeUses.Reserve(Mesh.Triangles.Num()); + const auto AddEdge = [&EdgeUses](int32 A, int32 B, const FVector& Normal) + { + if (A > B) + { + Swap(A, B); + } + const uint64 Key = (static_cast(static_cast(A)) << 32) + | static_cast(B); + FStep2fEdgeUse& Use = EdgeUses.FindOrAdd(Key); + if (Use.Count == 0) + { + Use.FirstNormal = Normal; + } + else if (Use.Count == 1) + { + Use.SecondNormal = Normal; + } + ++Use.Count; + }; + for (int32 Triangle = 0; Triangle + 2 < Mesh.Triangles.Num(); Triangle += 3) + { + const int32 IA = Mesh.Triangles[Triangle]; + const int32 IB = Mesh.Triangles[Triangle + 1]; + const int32 IC = Mesh.Triangles[Triangle + 2]; + if (!Mesh.Vertices.IsValidIndex(IA) || !Mesh.Vertices.IsValidIndex(IB) + || !Mesh.Vertices.IsValidIndex(IC)) + { + continue; + } + FVector Normal = FVector::CrossProduct( + Mesh.Vertices[IB] - Mesh.Vertices[IA], + Mesh.Vertices[IC] - Mesh.Vertices[IA]); + if (!Normal.Normalize()) + { + continue; + } + AddEdge(IA, IB, Normal); + AddEdge(IB, IC, Normal); + AddEdge(IC, IA, Normal); + } + TArray Angles; + Angles.Reserve(EdgeUses.Num()); + int64 Over45 = 0; + for (const TPair& Pair : EdgeUses) + { + const FStep2fEdgeUse& Use = Pair.Value; + if (Use.Count != 2) + { + continue; + } + const double Dot = FMath::Clamp( + FMath::Abs(FVector::DotProduct(Use.FirstNormal, Use.SecondNormal)), 0.0, 1.0); + const float AngleDegrees = FMath::RadiansToDegrees( + FMath::Acos(static_cast(Dot))); + Angles.Add(AngleDegrees); + Over45 += AngleDegrees > 45.0f ? 1 : 0; + } + Result.ManifoldEdgeCount = Angles.Num(); + if (!Angles.IsEmpty()) + { + Angles.Sort(); + Result.ShareDihedralsOver45Degrees = + static_cast(Over45) / Angles.Num(); + const int32 P95Index = FMath::Clamp( + FMath::CeilToInt(0.95f * Angles.Num()) - 1, 0, Angles.Num() - 1); + Result.P95DihedralDegrees = Angles[P95Index]; + } + return Result; +} + +struct FStep2fStructureMetrics +{ + int32 StratesChecked = 0; + int64 RoofSamples = 0; + int64 RoofOpenings = 0; + int64 FloorSamples = 0; + int64 FloorOpenings = 0; +}; + +struct FStep2fShapeSnapshot +{ + FStep2eVarietyMetrics Variety; + FStep2fFragmentMetrics Fragments; + FStep2fGridSignatureMetrics Grid; + FStep2fDetailSpectrum Detail; + FStep2fMeshMetrics Mesh; + int32 Lod1VertexCount = 0; + double Lod1P95SurfaceErrorVoxels = 0.0; + FStep2fStructureMetrics Structure; +}; + TSharedRef Step2eParametersJson( const VoxelForgeRedefinedDensityField::FGenerationParameters& P) { @@ -3773,6 +4248,7 @@ int32 RunStep2e(const FString& Params) UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2e] argument error: %s"), *Error); return 2; } + VoxelForgeRedefinedDensityField::SetLegacyStep2eAuditMode(true); int32 Seed = 1337; FParse::Value(*Params, TEXT("seed="), Seed); int32 Workers = VoxelForgeRegionEval::WorkerCount; @@ -4226,6 +4702,7 @@ int32 RunStep2e(const FString& Params) return 2; } VoxelForgeRedefinedDensityField::SetParameterizedEnabled(false); + VoxelForgeRedefinedDensityField::SetLegacyStep2eAuditMode(false); VoxelForgeRedefinedDensityField::SetDetailScale(1.0f); UE_LOG(LogTemp, Display, TEXT("[VoxelForgeStep2e] pass=%s speed600=%s worst_ms=%.3f detail=%s boundary=%s " @@ -4237,6 +4714,794 @@ int32 RunStep2e(const FString& Params) return bPassed ? 0 : 2; } +int32 RunStep2f(const FString& Params) +{ + FExploreArguments Arguments; + FString Error; + if (!ParseArguments(Params, Arguments, Error)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2f] argument error: %s"), *Error); + return 2; + } + int32 Seed = 1337; + int32 Workers = VoxelForgeRegionEval::WorkerCount; + FParse::Value(*Params, TEXT("seed="), Seed); + 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/step2f")); + } + 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 PrepareStart = FPlatformTime::Seconds(); + if (!PrepareRegionEvalWorld(World, Arguments, Seed, + RecipeIndex, RecipeHash, EditChunkCount, Error)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2f] world preparation failed: %s"), *Error); + return 2; + } + const double WorldPrepareSeconds = FPlatformTime::Seconds() - PrepareStart; + if (World.DiffLayer != nullptr) + { + World.DiffLayer->Clear(); + } + + const int32 Dimension = VoxelForgeRegionEval::RingDimension; + const int64 TotalSamples = static_cast(Dimension) * Dimension * Dimension; + const int32 CenterZ = (World.TargetTopWorldZ + World.TargetBottomWorldZ) / 2; + const FIntVector Origin(-129, -129, CenterZ - 129); + VoxelForgeRedefinedDensityField::SetEnabled(false); + VoxelForgeRedefinedDensityField::SetParameterizedEnabled(true); + VoxelForgeRedefinedDensityField::SetDetailScale(1.0f); + VoxelForgeRedefinedDensityField::SetParameterSet(-1); + + TArray> ProfileRows; + TArray> SliceRows; + double WorstStep2eMs = 0.0; + double WorstStep2fMs = 0.0; + bool bStep2eCrumbsRejected = true; + bool bStep2fCrumbGatePassed = true; + bool bStep2eFlakesRejected = true; + bool bStep2fFlakeGatePassed = true; + bool bStep2eBandLimitRejected = true; + bool bStep2fBandLimitPassed = true; + bool bStep2eLodRejected = true; + bool bStep2fLodPassed = true; + bool bStep2eGridRejected = true; + bool bStep2fGridPassed = true; + bool bStep2eStructureRejected = true; + bool bStep2fStructurePassed = true; + double WorstStep2fSolidFraction = 0.0; + double BestStep2fSolidFraction = 1.0; + int64 WorstStep2eCrumbs = 0; + int64 WorstStep2fCrumbs = 0; + double WorstStep2eDihedralShare = 0.0; + double WorstStep2fDihedralShare = 0.0; + double WorstStep2eDihedralP95 = 0.0; + double WorstStep2fDihedralP95 = 0.0; + double WorstStep2eDetailEnergy = 0.0; + double WorstStep2fDetailEnergy = 0.0; + double BestStep2eDetailRms = MAX_dbl; + double BestStep2fDetailRms = MAX_dbl; + double WorstStep2eLodDistance = 0.0; + double WorstStep2fLodDistance = 0.0; + double WorstStep2ePitchCorrelation = 0.0; + double WorstStep2fPitchCorrelation = 0.0; + double WorstStep2ePitchExcess = -1.0; + double WorstStep2fPitchExcess = -1.0; + double LowestStep2eSolidFraction = 1.0; + double LowestStep2fSolidFraction = 1.0; + int64 WorstStep2eRoofOpenings = 0; + int64 WorstStep2eFloorOpenings = 0; + int64 WorstStep2fRoofOpenings = 0; + int64 WorstStep2fFloorOpenings = 0; + + const auto MeasureShape = [&](const TArray& Density, + const VoxelForgeRedefinedDensityField::FGenerationParameters& P) + { + FStep2fShapeSnapshot Result; + Result.Variety = ComputeStep2eVarietyMetrics(Density, Dimension); + Result.Fragments = ComputeStep2fFragmentMetrics(Density, Dimension); + Result.Grid = ComputeStep2fGridSignature(Density, Dimension, P.RoomPitchVoxels); + + constexpr int32 DetailDimension = 32; + const FIntVector DetailOrigin = Origin + FIntVector( + (Dimension - DetailDimension) / 2, + (Dimension - DetailDimension) / 2, + (Dimension - DetailDimension) / 2); + TArray DetailSamples; + DetailSamples.SetNumUninitialized(DetailDimension * DetailDimension * DetailDimension); + for (int32 Z = 0; Z < DetailDimension; ++Z) + for (int32 Y = 0; Y < DetailDimension; ++Y) + for (int32 X = 0; X < DetailDimension; ++X) + { + const int32 Index = (Z * DetailDimension + Y) * DetailDimension + X; + DetailSamples[Index] = VoxelForgeRedefinedDensityField::EvaluateParameterizedDetail( + *World.Generator, + static_cast(DetailOrigin.X + X), + static_cast(DetailOrigin.Y + Y), + static_cast(DetailOrigin.Z + Z)); + } + Result.Detail = ComputeStep2fDetailSpectrum(DetailSamples, DetailDimension); + + const FIntVector MeshOrigin = SelectStep2fMeshOrigin( + Density, Origin, Dimension); + const bool bPreviousSkirts = World.Mesher->bGenerateSkirts; + World.Mesher->bGenerateSkirts = false; + FVoxelMeshData FineMesh; + // GenerateMesh clamps cells per tile to CHUNK_SIZE. Eight level-0 tiles + // cover the same 64-sample cube as the one level-1 tile below. + for (int32 TileZ = 0; TileZ < 2; ++TileZ) + for (int32 TileY = 0; TileY < 2; ++TileY) + for (int32 TileX = 0; TileX < 2; ++TileX) + { + const FIntVector TileOrigin = MeshOrigin + FIntVector( + TileX * CHUNK_SIZE, TileY * CHUNK_SIZE, TileZ * CHUNK_SIZE); + const FVoxelMeshData TileMesh = World.Mesher->GenerateMesh( + TileOrigin, 1, CHUNK_SIZE); + const int32 VertexOffset = FineMesh.Vertices.Num(); + FineMesh.Vertices.Append(TileMesh.Vertices); + for (const int32 Index : TileMesh.Triangles) + { + FineMesh.Triangles.Add(VertexOffset + Index); + } + } + const FVoxelMeshData CoarseMesh = World.Mesher->GenerateMesh( + MeshOrigin, 2, CHUNK_SIZE); + World.Mesher->bGenerateSkirts = bPreviousSkirts; + Result.Mesh = ComputeStep2fMeshMetrics(FineMesh); + Result.Lod1VertexCount = CoarseMesh.Vertices.Num(); + TArray LodErrors; + LodErrors.Reserve(CoarseMesh.Vertices.Num()); + + // L0 triangle bounds are inserted into a one-meter spatial grid. A + // coarse vertex only needs nearby cells unless its distance is already + // large enough to fail the LOD gate; expanding the search makes the + // reported value exact even for badly diverged surfaces. + constexpr float LodBucketSizeCm = VOXEL_SIZE * 4.0f; + TMap> FineTriangleBuckets; + FineTriangleBuckets.Reserve(FineMesh.Triangles.Num()); + for (int32 Triangle = 0; Triangle + 2 < FineMesh.Triangles.Num(); Triangle += 3) + { + const int32 IA = FineMesh.Triangles[Triangle]; + const int32 IB = FineMesh.Triangles[Triangle + 1]; + const int32 IC = FineMesh.Triangles[Triangle + 2]; + if (!FineMesh.Vertices.IsValidIndex(IA) + || !FineMesh.Vertices.IsValidIndex(IB) + || !FineMesh.Vertices.IsValidIndex(IC)) + { + continue; + } + const FVector& A = FineMesh.Vertices[IA]; + const FVector& B = FineMesh.Vertices[IB]; + const FVector& C = FineMesh.Vertices[IC]; + const FVector Min = A.ComponentMin(B).ComponentMin(C); + const FVector Max = A.ComponentMax(B).ComponentMax(C); + const FIntVector MinBucket( + FMath::FloorToInt(Min.X / LodBucketSizeCm), + FMath::FloorToInt(Min.Y / LodBucketSizeCm), + FMath::FloorToInt(Min.Z / LodBucketSizeCm)); + const FIntVector MaxBucket( + FMath::FloorToInt(Max.X / LodBucketSizeCm), + FMath::FloorToInt(Max.Y / LodBucketSizeCm), + FMath::FloorToInt(Max.Z / LodBucketSizeCm)); + const int32 TriangleIndex = Triangle / 3; + for (int32 Z = MinBucket.Z; Z <= MaxBucket.Z; ++Z) + for (int32 Y = MinBucket.Y; Y <= MaxBucket.Y; ++Y) + for (int32 X = MinBucket.X; X <= MaxBucket.X; ++X) + { + FineTriangleBuckets.FindOrAdd(FIntVector(X, Y, Z)).Add(TriangleIndex); + } + } + for (const FVector& VertexWorldCm : CoarseMesh.Vertices) + { + const FIntVector CentreBucket( + FMath::FloorToInt(VertexWorldCm.X / LodBucketSizeCm), + FMath::FloorToInt(VertexWorldCm.Y / LodBucketSizeCm), + FMath::FloorToInt(VertexWorldCm.Z / LodBucketSizeCm)); + float NearestDistanceSquared = MAX_flt; + TSet TestedTriangles; + for (int32 Radius = 1; Radius <= 64; ++Radius) + { + for (int32 Z = CentreBucket.Z - Radius; Z <= CentreBucket.Z + Radius; ++Z) + for (int32 Y = CentreBucket.Y - Radius; Y <= CentreBucket.Y + Radius; ++Y) + for (int32 X = CentreBucket.X - Radius; X <= CentreBucket.X + Radius; ++X) + { + const TArray* Candidates = FineTriangleBuckets.Find(FIntVector(X, Y, Z)); + if (Candidates == nullptr) + { + continue; + } + for (const int32 TriangleIndex : *Candidates) + { + if (TestedTriangles.Contains(TriangleIndex)) + { + continue; + } + TestedTriangles.Add(TriangleIndex); + const int32 Triangle = TriangleIndex * 3; + const FVector& A = FineMesh.Vertices[FineMesh.Triangles[Triangle]]; + const FVector& B = FineMesh.Vertices[FineMesh.Triangles[Triangle + 1]]; + const FVector& C = FineMesh.Vertices[FineMesh.Triangles[Triangle + 2]]; + NearestDistanceSquared = FMath::Min( + NearestDistanceSquared, + Step2fPointTriangleDistanceSquared(VertexWorldCm, A, B, C)); + } + } + const float SearchBoundCm = Radius * LodBucketSizeCm; + if (NearestDistanceSquared <= FMath::Square(SearchBoundCm)) + { + break; + } + } + const float SurfaceDistanceVoxels = FMath::IsFinite(NearestDistanceSquared) + ? FMath::Sqrt(NearestDistanceSquared) / VOXEL_SIZE + : MAX_flt; + LodErrors.Add(SurfaceDistanceVoxels); + } + if (!LodErrors.IsEmpty()) + { + LodErrors.Sort(); + const int32 P95Index = FMath::Clamp( + FMath::CeilToInt(0.95f * LodErrors.Num()) - 1, + 0, LodErrors.Num() - 1); + Result.Lod1P95SurfaceErrorVoxels = LodErrors[P95Index]; + } + + if (World.Manager != nullptr) + { + const TArray& Layout = World.Manager->GetLayout(); + constexpr int32 XYStep = 4; // 1 m coverage grid + constexpr int32 ShellOffsets[] = {1, 5, 9}; + for (const FStrateSlot& Slot : Layout) + { + if (Slot.Definition == nullptr) + { + continue; + } + ++Result.Structure.StratesChecked; + const int32 Bottom = FMath::Min(Slot.BottomChunkZ, Slot.TopChunkZ) * CHUNK_SIZE; + const int32 Top = (FMath::Max(Slot.BottomChunkZ, Slot.TopChunkZ) + 1) * CHUNK_SIZE; + for (int32 Y = Origin.Y; Y < Origin.Y + Dimension; Y += XYStep) + for (int32 X = Origin.X; X < Origin.X + Dimension; X += XYStep) + for (const int32 Offset : ShellOffsets) + { + const float FloorDensity = VoxelForgeRedefinedDensityField::EvaluateParameterized( + *World.Generator, static_cast(X), static_cast(Y), + static_cast(Bottom + Offset), nullptr, 1.0f); + const float RoofDensity = VoxelForgeRedefinedDensityField::EvaluateParameterized( + *World.Generator, static_cast(X), static_cast(Y), + static_cast(Top - 1 - Offset), nullptr, 1.0f); + ++Result.Structure.FloorSamples; + ++Result.Structure.RoofSamples; + Result.Structure.FloorOpenings += FloorDensity > 0.0f ? 1 : 0; + Result.Structure.RoofOpenings += RoofDensity > 0.0f ? 1 : 0; + } + } + } + return Result; + }; + + const auto ShapeJson = [](const FStep2fShapeSnapshot& Shape, double MedianMs, + const FStep2eVarietyMetrics& Variety) + { + TSharedRef Json = MakeShared(); + Json->SetNumberField(TEXT("six_worker_ring_median_ms"), MedianMs); + Json->SetNumberField(TEXT("solid_fraction"), 1.0 - Variety.OpenFraction); + Json->SetNumberField(TEXT("air_fraction"), Variety.OpenFraction); + Json->SetNumberField(TEXT("air_components_25cm_grid"), + Shape.Fragments.AirComponents); + Json->SetNumberField(TEXT("solid_components_25cm_grid"), + Shape.Fragments.SolidComponents); + Json->SetNumberField(TEXT("air_crumbs_below_1m3"), + static_cast(Shape.Fragments.AirCrumbsBelowOneCubicMeter)); + Json->SetNumberField(TEXT("solid_crumbs_below_1m3"), + static_cast(Shape.Fragments.SolidCrumbsBelowOneCubicMeter)); + Json->SetNumberField(TEXT("air_crumb_voxel_samples"), + static_cast(Shape.Fragments.AirCrumbVoxelSamples)); + Json->SetNumberField(TEXT("solid_crumb_voxel_samples"), + static_cast(Shape.Fragments.SolidCrumbVoxelSamples)); + Json->SetNumberField(TEXT("largest_air_component_voxels"), + Shape.Fragments.LargestAirComponentVoxels); + Json->SetNumberField(TEXT("largest_solid_component_voxels"), + Shape.Fragments.LargestSolidComponentVoxels); + Json->SetNumberField(TEXT("one_meter_downsampled_air_components"), + Variety.ConnectedComponentsAtOneMeter); + Json->SetNumberField(TEXT("pitch_autocorrelation_peak"), Shape.Grid.PitchCorrelationPeak); + Json->SetNumberField(TEXT("nearby_autocorrelation_peak"), Shape.Grid.NeighborCorrelationPeak); + Json->SetNumberField(TEXT("pitch_autocorrelation_excess"), Shape.Grid.PitchPeakExcess); + Json->SetNumberField(TEXT("detail_rms"), Shape.Detail.Rms); + Json->SetNumberField(TEXT("detail_lattice_scale_energy_ratio"), + Shape.Detail.LatticeScaleEnergyRatio); + Json->SetNumberField(TEXT("mesh_triangles_l0"), Shape.Mesh.TriangleCount); + Json->SetNumberField(TEXT("mesh_manifold_edges_l0"), + static_cast(Shape.Mesh.ManifoldEdgeCount)); + Json->SetNumberField(TEXT("mesh_dihedral_share_over_45deg"), + Shape.Mesh.ShareDihedralsOver45Degrees); + Json->SetNumberField(TEXT("mesh_dihedral_p95_deg"), Shape.Mesh.P95DihedralDegrees); + Json->SetNumberField(TEXT("mesh_vertices_l1"), Shape.Lod1VertexCount); + Json->SetNumberField(TEXT("lod1_p95_surface_distance_voxels"), + Shape.Lod1P95SurfaceErrorVoxels); + Json->SetNumberField(TEXT("strates_checked"), Shape.Structure.StratesChecked); + Json->SetNumberField(TEXT("roof_samples"), static_cast(Shape.Structure.RoofSamples)); + Json->SetNumberField(TEXT("roof_openings"), static_cast(Shape.Structure.RoofOpenings)); + Json->SetNumberField(TEXT("floor_samples"), static_cast(Shape.Structure.FloorSamples)); + Json->SetNumberField(TEXT("floor_openings"), static_cast(Shape.Structure.FloorOpenings)); + Json->SetNumberField(TEXT("estimated_surface_area_m2"), Variety.SurfaceAreaSquareMeters); + Json->SetNumberField(TEXT("open_air_fraction"), Variety.OpenFraction); + return Json; + }; + + for (int32 Profile = 0; + Profile < VoxelForgeRedefinedDensityField::GetParameterSetCount(); ++Profile) + { + VoxelForgeRedefinedDensityField::SetParameterSet(Profile); + VoxelForgeRedefinedDensityField::SetLegacyStep2eAuditMode(true); + const TCHAR* ProfileName = nullptr; + VoxelForgeRedefinedDensityField::FGenerationParameters LegacyParameters; + if (!VoxelForgeRedefinedDensityField::GetParameterSetInfo( + Profile, ProfileName, LegacyParameters)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2f] missing legacy profile %d."), Profile); + return 2; + } + TArray LegacyDensity; + TArray LegacyTimes; + VoxelForgeRegionEval::FTiming LegacyLastTiming; + for (int32 Trial = 0; Trial < 3; ++Trial) + { + if (!VoxelForgeRegionEval::EvaluateRedefined( + *World.Generator, Origin, Dimension, Workers, 1.0f, + LegacyDensity, LegacyLastTiming, true, true)) + { + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeStep2f] profile %d step 2e ring trial %d failed."), + Profile, Trial + 1); + return 2; + } + LegacyTimes.Add(LegacyLastTiming.WallSeconds * 1000.0); + } + LegacyTimes.Sort(); + const double LegacyMedianMs = LegacyTimes[LegacyTimes.Num() / 2]; + const FStep2fShapeSnapshot LegacyShape = MeasureShape( + LegacyDensity, LegacyParameters); + const FStep2eVarietyMetrics LegacyVariety = LegacyShape.Variety; + WorstStep2eMs = FMath::Max(WorstStep2eMs, LegacyMedianMs); + const int64 LegacyCrumbs = LegacyShape.Fragments.AirCrumbsBelowOneCubicMeter + + LegacyShape.Fragments.SolidCrumbsBelowOneCubicMeter; + WorstStep2eCrumbs = FMath::Max(WorstStep2eCrumbs, LegacyCrumbs); + bStep2eCrumbsRejected &= LegacyCrumbs > 0; + WorstStep2eDihedralShare = FMath::Max( + WorstStep2eDihedralShare, LegacyShape.Mesh.ShareDihedralsOver45Degrees); + WorstStep2eDihedralP95 = FMath::Max( + WorstStep2eDihedralP95, LegacyShape.Mesh.P95DihedralDegrees); + bStep2eFlakesRejected |= LegacyShape.Mesh.ShareDihedralsOver45Degrees > 0.25 + || LegacyShape.Mesh.P95DihedralDegrees > 70.0; + WorstStep2eDetailEnergy = FMath::Max( + WorstStep2eDetailEnergy, LegacyShape.Detail.LatticeScaleEnergyRatio); + BestStep2eDetailRms = FMath::Min(BestStep2eDetailRms, LegacyShape.Detail.Rms); + bStep2eBandLimitRejected &= LegacyShape.Detail.LatticeScaleEnergyRatio > 0.20; + WorstStep2eLodDistance = FMath::Max( + WorstStep2eLodDistance, LegacyShape.Lod1P95SurfaceErrorVoxels); + bStep2eLodRejected |= LegacyShape.Lod1P95SurfaceErrorVoxels > 3.5; + WorstStep2ePitchCorrelation = FMath::Max( + WorstStep2ePitchCorrelation, LegacyShape.Grid.PitchCorrelationPeak); + WorstStep2ePitchExcess = FMath::Max( + WorstStep2ePitchExcess, LegacyShape.Grid.PitchPeakExcess); + bStep2eGridRejected |= LegacyShape.Grid.PitchCorrelationPeak > 0.22 + || LegacyShape.Grid.PitchPeakExcess > 0.08; + const double LegacySolidFraction = 1.0 - LegacyVariety.OpenFraction; + LowestStep2eSolidFraction = FMath::Min(LowestStep2eSolidFraction, LegacySolidFraction); + WorstStep2eRoofOpenings = FMath::Max( + WorstStep2eRoofOpenings, LegacyShape.Structure.RoofOpenings); + WorstStep2eFloorOpenings = FMath::Max( + WorstStep2eFloorOpenings, LegacyShape.Structure.FloorOpenings); + bStep2eStructureRejected &= + LegacySolidFraction < 0.25 + || LegacyShape.Structure.RoofOpenings > 0 + || LegacyShape.Structure.FloorOpenings > 0; + + VoxelForgeRedefinedDensityField::SetLegacyStep2eAuditMode(false); + VoxelForgeRedefinedDensityField::FGenerationParameters NewParameters; + if (!VoxelForgeRedefinedDensityField::GetParameterSetInfo( + Profile, ProfileName, NewParameters)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2f] missing new profile %d."), Profile); + return 2; + } + TArray NewDensity; + TArray NewTimes; + VoxelForgeRegionEval::FTiming NewLastTiming; + for (int32 Trial = 0; Trial < 3; ++Trial) + { + if (!VoxelForgeRegionEval::EvaluateRedefined( + *World.Generator, Origin, Dimension, Workers, 1.0f, + NewDensity, NewLastTiming, true, true)) + { + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeStep2f] profile %d step 2f ring trial %d failed."), + Profile, Trial + 1); + return 2; + } + NewTimes.Add(NewLastTiming.WallSeconds * 1000.0); + } + NewTimes.Sort(); + const double NewMedianMs = NewTimes[NewTimes.Num() / 2]; + const FStep2fShapeSnapshot NewShape = MeasureShape(NewDensity, NewParameters); + const FStep2eVarietyMetrics NewVariety = NewShape.Variety; + WorstStep2fMs = FMath::Max(WorstStep2fMs, NewMedianMs); + const double NewSolidFraction = 1.0 - NewVariety.OpenFraction; + const int64 NewCrumbs = NewShape.Fragments.AirCrumbsBelowOneCubicMeter + + NewShape.Fragments.SolidCrumbsBelowOneCubicMeter; + WorstStep2fCrumbs = FMath::Max(WorstStep2fCrumbs, NewCrumbs); + WorstStep2fDihedralShare = FMath::Max( + WorstStep2fDihedralShare, NewShape.Mesh.ShareDihedralsOver45Degrees); + WorstStep2fDihedralP95 = FMath::Max( + WorstStep2fDihedralP95, NewShape.Mesh.P95DihedralDegrees); + WorstStep2fDetailEnergy = FMath::Max( + WorstStep2fDetailEnergy, NewShape.Detail.LatticeScaleEnergyRatio); + BestStep2fDetailRms = FMath::Min(BestStep2fDetailRms, NewShape.Detail.Rms); + WorstStep2fLodDistance = FMath::Max( + WorstStep2fLodDistance, NewShape.Lod1P95SurfaceErrorVoxels); + WorstStep2fPitchCorrelation = FMath::Max( + WorstStep2fPitchCorrelation, NewShape.Grid.PitchCorrelationPeak); + WorstStep2fPitchExcess = FMath::Max( + WorstStep2fPitchExcess, NewShape.Grid.PitchPeakExcess); + LowestStep2fSolidFraction = FMath::Min(LowestStep2fSolidFraction, NewSolidFraction); + WorstStep2fRoofOpenings = FMath::Max( + WorstStep2fRoofOpenings, NewShape.Structure.RoofOpenings); + WorstStep2fFloorOpenings = FMath::Max( + WorstStep2fFloorOpenings, NewShape.Structure.FloorOpenings); + WorstStep2fSolidFraction = FMath::Max(WorstStep2fSolidFraction, NewSolidFraction); + BestStep2fSolidFraction = FMath::Min(BestStep2fSolidFraction, NewSolidFraction); + bStep2fCrumbGatePassed &= NewShape.Fragments.AirCrumbsBelowOneCubicMeter == 0 + && NewShape.Fragments.SolidCrumbsBelowOneCubicMeter == 0; + bStep2fFlakeGatePassed &= NewShape.Mesh.TriangleCount > 0 + && NewShape.Mesh.ManifoldEdgeCount > 0 + && NewShape.Mesh.ShareDihedralsOver45Degrees <= 0.25 + && NewShape.Mesh.P95DihedralDegrees <= 70.0; + bStep2fBandLimitPassed &= NewShape.Detail.LatticeScaleEnergyRatio <= 0.20 + && NewShape.Detail.Rms >= 0.40; + bStep2fLodPassed &= NewShape.Lod1VertexCount > 0 + && NewShape.Lod1P95SurfaceErrorVoxels <= 3.5; + bStep2fGridPassed &= NewShape.Grid.PitchCorrelationPeak <= 0.40 + && NewShape.Grid.PitchPeakExcess <= 0.08; + bStep2fStructurePassed &= NewSolidFraction >= 0.25 && NewSolidFraction <= 0.88 + && NewShape.Structure.StratesChecked > 0 + && NewShape.Structure.RoofSamples > 0 + && NewShape.Structure.FloorSamples > 0 + && NewShape.Structure.RoofOpenings == 0 + && NewShape.Structure.FloorOpenings == 0; + + TSharedRef Row = MakeShared(); + Row->SetNumberField(TEXT("profile_index"), Profile); + Row->SetStringField(TEXT("name"), ProfileName); + Row->SetObjectField(TEXT("step2e"), ShapeJson( + LegacyShape, LegacyMedianMs, LegacyVariety)); + Row->SetObjectField(TEXT("step2f"), ShapeJson( + NewShape, NewMedianMs, NewVariety)); + Row->SetObjectField(TEXT("step2e_parameters"), Step2eParametersJson(LegacyParameters)); + Row->SetObjectField(TEXT("step2f_parameters"), Step2eParametersJson(NewParameters)); + TArray> LegacyTrialValues; + TArray> NewTrialValues; + for (const double Time : LegacyTimes) + { + LegacyTrialValues.Add(MakeShared(Time)); + } + for (const double Time : NewTimes) + { + NewTrialValues.Add(MakeShared(Time)); + } + Row->SetArrayField(TEXT("step2e_six_worker_ring_trials_ms"), LegacyTrialValues); + Row->SetArrayField(TEXT("step2f_six_worker_ring_trials_ms"), NewTrialValues); + TSharedRef NewTimingBreakdown = MakeShared(); + NewTimingBreakdown->SetNumberField(TEXT("last_trial_wall_ms"), + NewLastTiming.WallSeconds * 1000.0); + NewTimingBreakdown->SetNumberField(TEXT("last_trial_worker_cpu_seconds"), + NewLastTiming.WorkerCpuSeconds); + NewTimingBreakdown->SetNumberField(TEXT("sum_worker_brick_classification_seconds"), + NewLastTiming.BrickClassificationSeconds); + NewTimingBreakdown->SetNumberField(TEXT("sum_worker_density_evaluation_seconds"), + NewLastTiming.DensityEvaluationSeconds); + NewTimingBreakdown->SetNumberField(TEXT("sum_worker_uniform_fill_seconds"), + NewLastTiming.UniformFillSeconds); + NewTimingBreakdown->SetNumberField(TEXT("longest_worker_wall_seconds"), + NewLastTiming.LongestWorkerWallSeconds); + NewTimingBreakdown->SetNumberField(TEXT("evaluated_samples"), + static_cast(NewLastTiming.EvaluatedSamples)); + NewTimingBreakdown->SetNumberField(TEXT("skipped_samples"), + static_cast(NewLastTiming.SkippedSamples)); + NewTimingBreakdown->SetNumberField(TEXT("macro_centre_evaluations"), + static_cast(NewLastTiming.MacroCentreEvaluations)); + Row->SetObjectField(TEXT("step2f_last_trial_timing_breakdown"), NewTimingBreakdown); + FString SafeName(ProfileName); + SafeName.ToLowerInline(); + const FString SetName = FString::Printf(TEXT("set_%02d_%s"), Profile, *SafeName); + const FString SetDirectory = FPaths::Combine(ResearchOut, SetName); + IFileManager::Get().MakeDirectory(*SetDirectory, true); + const FString ImageNames[] = { + TEXT("slice_xy_2e_vs_2f.png"), TEXT("slice_xz_2e_vs_2f.png"), + TEXT("slice_yz_2e_vs_2f.png"), TEXT("slice_xz_passage_2e_vs_2f.png")}; + FString SliceError; + SaveStep2dSideBySideSlice(FPaths::Combine(SetDirectory, ImageNames[0]), + LegacyDensity, NewDensity, Dimension, TEXT('h'), Dimension / 2, SliceError); + SaveStep2dSideBySideSlice(FPaths::Combine(SetDirectory, ImageNames[1]), + LegacyDensity, NewDensity, Dimension, TEXT('x'), Dimension / 2, SliceError); + SaveStep2dSideBySideSlice(FPaths::Combine(SetDirectory, ImageNames[2]), + LegacyDensity, NewDensity, Dimension, TEXT('y'), Dimension / 2, SliceError); + int32 PassageIndex = Dimension / 2; + if (World.Manager != nullptr && !World.Manager->GetPassages().IsEmpty()) + { + PassageIndex = FMath::Clamp(FMath::RoundToInt( + World.Manager->GetPassages()[0].LowerLanding.StandingPoint.Y) - Origin.Y, + 0, Dimension - 1); + } + SaveStep2dSideBySideSlice(FPaths::Combine(SetDirectory, ImageNames[3]), + LegacyDensity, NewDensity, Dimension, TEXT('x'), PassageIndex, SliceError); + if (!SliceError.IsEmpty()) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2f] slice write failed for %s: %s"), + ProfileName, *SliceError); + return 2; + } + Row->SetStringField(TEXT("slice_directory"), SetDirectory); + ProfileRows.Add(MakeShared(Row)); + for (const FString& ImageName : ImageNames) + { + TSharedRef Slice = MakeShared(); + Slice->SetStringField(TEXT("profile"), ProfileName); + Slice->SetStringField(TEXT("file"), FPaths::Combine(SetDirectory, ImageName)); + Slice->SetStringField(TEXT("layout"), + TEXT("step 2e density on left; step 2f density on right; light=air, dark=solid, cyan=near zero")); + SliceRows.Add(MakeShared(Slice)); + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeStep2f] profile=%d/%s old_ms=%.2f new_ms=%.2f " + "solid=%.3f crumbs=%lld/%lld flakes=%.3f/%.3f grid=%.3f/%.3f " + "detail=%.3f lod95=%.2f roof=%lld floor=%lld"), + Profile, ProfileName, LegacyMedianMs, NewMedianMs, NewSolidFraction, + static_cast(NewShape.Fragments.AirCrumbsBelowOneCubicMeter), + static_cast(NewShape.Fragments.SolidCrumbsBelowOneCubicMeter), + NewShape.Mesh.ShareDihedralsOver45Degrees, NewShape.Detail.LatticeScaleEnergyRatio, + NewShape.Grid.PitchCorrelationPeak, NewShape.Grid.PitchPeakExcess, + NewShape.Detail.Rms, NewShape.Lod1P95SurfaceErrorVoxels, + static_cast(NewShape.Structure.RoofOpenings), + static_cast(NewShape.Structure.FloorOpenings)); + } + + const int32 HardestProfile = VoxelForgeRedefinedDensityField::GetParameterSetCount() - 1; + VoxelForgeRedefinedDensityField::SetLegacyStep2eAuditMode(false); + VoxelForgeRedefinedDensityField::SetParameterSet(HardestProfile); + TArray HardestFullDensity; + TArray RepeatDensity; + TArray OneWorkerDensity; + VoxelForgeRegionEval::FTiming HardestTiming; + VoxelForgeRegionEval::FTiming RepeatTiming; + VoxelForgeRegionEval::FTiming OneWorkerTiming; + const bool bHardestFullOk = VoxelForgeRegionEval::EvaluateRedefined( + *World.Generator, Origin, Dimension, Workers, 1.0f, + HardestFullDensity, HardestTiming, false, true); + const bool bRepeatOk = bHardestFullOk && VoxelForgeRegionEval::EvaluateRedefined( + *World.Generator, Origin, Dimension, Workers, 1.0f, + RepeatDensity, RepeatTiming, false, true); + const bool bOneWorkerOk = bHardestFullOk && VoxelForgeRegionEval::EvaluateRedefined( + *World.Generator, Origin, Dimension, 1, 1.0f, + OneWorkerDensity, OneWorkerTiming, false, true); + if (!bHardestFullOk || !bRepeatOk || !bOneWorkerOk) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2f] determinism ring failed.")); + return 2; + } + const uint64 HardestHash = VoxelForgeRegionEval::HashQuantized(HardestFullDensity); + const uint64 RepeatHash = VoxelForgeRegionEval::HashQuantized(RepeatDensity); + const uint64 OneWorkerHash = VoxelForgeRegionEval::HashQuantized(OneWorkerDensity); + const bool bDeterminism = HardestHash == RepeatHash && HardestHash == OneWorkerHash + && Step2eSameWorkerRun(HardestTiming, RepeatTiming) + && Step2eSameWorkerRun(HardestTiming, OneWorkerTiming); + const FStep2dBrickProofAudit ProofAudit = AuditStep2dBrickProofs( + *World.Generator, Origin, Dimension, true); + const bool bBoundAudit = ProofAudit.CorrectlySkippedBricksAudited > 0 + && ProofAudit.CorrectProofSamplesChecked > 0 + && ProofAudit.CorrectProofViolations == 0; + const bool bUndersizedBoundCaught = ProofAudit.DeliberateBoundDisagreements > 0; + const FStep2dBoundaryStats BoundaryStats = ComputeStep2dBoundaryStats( + HardestFullDensity, Dimension, Origin, 128); + TArray SeamFailureDensity = HardestFullDensity; + int64 DeliberateSeamSamplesMutated = 0; + 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 Index = (Z * Dimension + Y) * Dimension + X; + if (((Origin.X + X) % 8) == 0 && FMath::Abs(HardestFullDensity[Index]) <= 12.0f) + { + SeamFailureDensity[Index + 1] += 64.0f; + ++DeliberateSeamSamplesMutated; + } + } + const FStep2dBoundaryStats SeamFailureStats = ComputeStep2dBoundaryStats( + SeamFailureDensity, Dimension, Origin, 128); + const bool bBoundaryPass = Step2eBoundaryStatsPass(BoundaryStats); + const bool bDeliberateSeamCaught = DeliberateSeamSamplesMutated > 0 + && !Step2eBoundaryStatsPass(SeamFailureStats); + + const bool bAllGeometryGatesRejectStep2e = bStep2eCrumbsRejected + && bStep2eFlakesRejected && bStep2eBandLimitRejected && bStep2eLodRejected + && bStep2eGridRejected && bStep2eStructureRejected; + const bool bAllGeometryGatesPassStep2f = bStep2fCrumbGatePassed + && bStep2fFlakeGatePassed && bStep2fBandLimitPassed && bStep2fLodPassed + && bStep2fGridPassed && bStep2fStructurePassed; + const bool bSpeedPass = Workers == 6 && WorstStep2fMs <= 600.0; + const bool bPassed = bAllGeometryGatesRejectStep2e && bAllGeometryGatesPassStep2f + && bSpeedPass && bDeterminism && bBoundAudit && bUndersizedBoundCaught + && bBoundaryPass && bDeliberateSeamCaught && HardestTiming.NonFiniteSamples == 0; + + TSharedRef Gates = MakeShared(); + const auto AddGate = [&Gates](const TCHAR* Name, const TCHAR* Threshold, + bool bLegacyRejected, bool bNewPassed, + const TSharedRef& LegacyNumbers, + const TSharedRef& NewNumbers) + { + TSharedRef Gate = MakeShared(); + Gate->SetStringField(TEXT("threshold"), Threshold); + Gate->SetBoolField(TEXT("step2e_rejected"), bLegacyRejected); + Gate->SetBoolField(TEXT("step2f_passed"), bNewPassed); + Gate->SetObjectField(TEXT("step2e_worst_profile_numbers"), LegacyNumbers); + Gate->SetObjectField(TEXT("step2f_worst_profile_numbers"), NewNumbers); + Gates->SetObjectField(Name, Gate); + }; + TSharedRef Step2eCrumbNumbers = MakeShared(); + Step2eCrumbNumbers->SetNumberField(TEXT("crumbs_below_1m3_max"), WorstStep2eCrumbs); + TSharedRef Step2fCrumbNumbers = MakeShared(); + Step2fCrumbNumbers->SetNumberField(TEXT("crumbs_below_1m3_max"), WorstStep2fCrumbs); + AddGate(TEXT("crumbs"), TEXT("zero 25 cm-grid connected components smaller than 64 voxels (about 1 m3)"), + bStep2eCrumbsRejected, bStep2fCrumbGatePassed, Step2eCrumbNumbers, Step2fCrumbNumbers); + TSharedRef Step2eFlakeNumbers = MakeShared(); + Step2eFlakeNumbers->SetNumberField(TEXT("max_dihedral_share_over_45deg"), WorstStep2eDihedralShare); + Step2eFlakeNumbers->SetNumberField(TEXT("max_dihedral_p95_deg"), WorstStep2eDihedralP95); + TSharedRef Step2fFlakeNumbers = MakeShared(); + Step2fFlakeNumbers->SetNumberField(TEXT("max_dihedral_share_over_45deg"), WorstStep2fDihedralShare); + Step2fFlakeNumbers->SetNumberField(TEXT("max_dihedral_p95_deg"), WorstStep2fDihedralP95); + AddGate(TEXT("flakes"), TEXT("L0 mesh dihedral share over 45 degrees <= 0.25; p95 <= 70 degrees"), + bStep2eFlakesRejected, bStep2fFlakeGatePassed, Step2eFlakeNumbers, Step2fFlakeNumbers); + TSharedRef Step2eDetailNumbers = MakeShared(); + Step2eDetailNumbers->SetNumberField(TEXT("max_lattice_scale_energy_ratio"), WorstStep2eDetailEnergy); + Step2eDetailNumbers->SetNumberField(TEXT("min_detail_rms"), BestStep2eDetailRms); + TSharedRef Step2fDetailNumbers = MakeShared(); + Step2fDetailNumbers->SetNumberField(TEXT("max_lattice_scale_energy_ratio"), WorstStep2fDetailEnergy); + Step2fDetailNumbers->SetNumberField(TEXT("min_detail_rms"), BestStep2fDetailRms); + AddGate(TEXT("band_limited_detail"), TEXT("detail RMS >= 0.40 and normalized lag-1 difference energy <= 0.20"), + bStep2eBandLimitRejected, bStep2fBandLimitPassed, Step2eDetailNumbers, Step2fDetailNumbers); + TSharedRef Step2eLodNumbers = MakeShared(); + Step2eLodNumbers->SetNumberField(TEXT("max_l1_p95_surface_distance_voxels"), WorstStep2eLodDistance); + TSharedRef Step2fLodNumbers = MakeShared(); + Step2fLodNumbers->SetNumberField(TEXT("max_l1_p95_surface_distance_voxels"), WorstStep2fLodDistance); + AddGate(TEXT("lod_stability"), TEXT("L1 mesh p95 estimated distance from the L0 isosurface <= 3.5 lattice samples"), + bStep2eLodRejected, bStep2fLodPassed, Step2eLodNumbers, Step2fLodNumbers); + TSharedRef Step2eGridNumbers = MakeShared(); + Step2eGridNumbers->SetNumberField(TEXT("max_pitch_autocorrelation"), WorstStep2ePitchCorrelation); + Step2eGridNumbers->SetNumberField(TEXT("max_pitch_peak_excess"), WorstStep2ePitchExcess); + TSharedRef Step2fGridNumbers = MakeShared(); + Step2fGridNumbers->SetNumberField(TEXT("max_pitch_autocorrelation"), WorstStep2fPitchCorrelation); + Step2fGridNumbers->SetNumberField(TEXT("max_pitch_peak_excess"), WorstStep2fPitchExcess); + AddGate(TEXT("grid_signature"), TEXT("pitch autocorrelation <= 0.40 and pitch excess over nearby lags <= 0.08"), + bStep2eGridRejected, bStep2fGridPassed, Step2eGridNumbers, Step2fGridNumbers); + TSharedRef Step2eStructureNumbers = MakeShared(); + Step2eStructureNumbers->SetNumberField(TEXT("minimum_solid_fraction"), LowestStep2eSolidFraction); + Step2eStructureNumbers->SetNumberField(TEXT("max_roof_openings"), WorstStep2eRoofOpenings); + Step2eStructureNumbers->SetNumberField(TEXT("max_floor_openings"), WorstStep2eFloorOpenings); + TSharedRef Step2fStructureNumbers = MakeShared(); + Step2fStructureNumbers->SetNumberField(TEXT("minimum_solid_fraction"), LowestStep2fSolidFraction); + Step2fStructureNumbers->SetNumberField(TEXT("max_roof_openings"), WorstStep2fRoofOpenings); + Step2fStructureNumbers->SetNumberField(TEXT("max_floor_openings"), WorstStep2fFloorOpenings); + AddGate(TEXT("roof_floor_and_solid_share"), TEXT("solid fraction 0.25..0.88; zero air samples in 1 m roof/floor shells"), + bStep2eStructureRejected, bStep2fStructurePassed, + Step2eStructureNumbers, Step2fStructureNumbers); + + TSharedRef Report = MakeShared(); + Report->SetStringField(TEXT("status"), bPassed ? TEXT("pass") : TEXT("fail")); + Report->SetBoolField(TEXT("pass"), bPassed); + Report->SetBoolField(TEXT("geometry_gates_reject_step2e"), bAllGeometryGatesRejectStep2e); + Report->SetBoolField(TEXT("geometry_gates_pass_step2f"), bAllGeometryGatesPassStep2f); + Report->SetBoolField(TEXT("worst_profile_meets_600ms"), bSpeedPass); + Report->SetNumberField(TEXT("step2e_worst_six_worker_ring_ms"), WorstStep2eMs); + Report->SetNumberField(TEXT("step2f_worst_six_worker_ring_ms"), WorstStep2fMs); + Report->SetNumberField(TEXT("step2f_solid_fraction_min"), BestStep2fSolidFraction); + Report->SetNumberField(TEXT("step2f_solid_fraction_max"), WorstStep2fSolidFraction); + Report->SetNumberField(TEXT("seed"), Seed); + Report->SetNumberField(TEXT("ring_dimension"), Dimension); + Report->SetNumberField(TEXT("ring_samples"), static_cast(TotalSamples)); + Report->SetNumberField(TEXT("workers_requested"), Workers); + Report->SetNumberField(TEXT("world_prepare_ms_excluded"), WorldPrepareSeconds * 1000.0); + Report->SetStringField(TEXT("field_definition"), + TEXT("Rooms use strongly jittered cells with per-room radii, axis ratios and heights. " + "A conservative nearest-neighbor cap leaves a solid web between chambers; a rooted " + "tree of narrow tunnels and capped shafts joins them. Sparse ledges, pillars, " + "stalactites and large hanging masses remain per-cell rolls. The point and " + "brick detail samplers share three 32/16/8-sample bands with an eight-sample " + "shortest period and a smooth ridge response.")); + Report->SetStringField(TEXT("component_method"), + TEXT("26-neighbor connected components are counted on the exact 25 cm sign lattice; " + "components smaller than 64 samples (< 1 m3) are reported separately for air and solid. " + "The existing one-meter downsampled air component count is also reported.")); + Report->SetStringField(TEXT("mesh_method"), + TEXT("Eight level-0 tiles and one level-1 tile cover the same 64-sample cube selected " + "for maximum coarse-sampled surface crossings. " + "The share of manifold triangle edges whose geometric dihedral exceeds 45 degrees " + "and its p95 are measured. Each level-1 vertex is compared with nearby level-0 " + "triangles using exact point-to-triangle distance; the p95 is reported in lattice samples.")); + Report->SetObjectField(TEXT("gates"), Gates); + Report->SetArrayField(TEXT("profiles"), ProfileRows); + Report->SetArrayField(TEXT("slices"), SliceRows); + Report->SetStringField(TEXT("slice_directory"), ResearchOut); + + TSharedRef Existing = MakeShared(); + Existing->SetBoolField(TEXT("sound_brick_proofs"), bBoundAudit); + Existing->SetNumberField(TEXT("correctly_skipped_bricks_audited"), + static_cast(ProofAudit.CorrectlySkippedBricksAudited)); + Existing->SetNumberField(TEXT("final_field_samples_checked"), + static_cast(ProofAudit.CorrectProofSamplesChecked)); + Existing->SetNumberField(TEXT("correct_proof_sign_violations"), + static_cast(ProofAudit.CorrectProofViolations)); + Existing->SetNumberField(TEXT("undersized_bound_claims_checked"), + static_cast(ProofAudit.DeliberatelyUndersizedBoundClaimsChecked)); + Existing->SetNumberField(TEXT("wrong_sign_samples_caught_by_undersized_bound"), + static_cast(ProofAudit.DeliberateBoundDisagreements)); + Existing->SetBoolField(TEXT("deliberate_undersized_bound_failure_caught"), + bUndersizedBoundCaught); + Existing->SetBoolField(TEXT("seam_gradient_gate"), bBoundaryPass); + Existing->SetBoolField(TEXT("deliberate_seam_failure_caught"), bDeliberateSeamCaught); + Existing->SetNumberField(TEXT("seam_failure_samples_mutated"), + static_cast(DeliberateSeamSamplesMutated)); + Existing->SetNumberField(TEXT("brick_boundary_gradient_jump_rms"), BoundaryStats.BrickJumpRms); + Existing->SetNumberField(TEXT("brick_interior_gradient_jump_rms"), BoundaryStats.InteriorJumpRms); + Existing->SetBoolField(TEXT("one_and_six_worker_determinism"), bDeterminism); + Existing->SetStringField(TEXT("six_worker_hash"), FString::Printf( + TEXT("%016llx"), static_cast(HardestHash))); + Existing->SetStringField(TEXT("repeat_six_worker_hash"), FString::Printf( + TEXT("%016llx"), static_cast(RepeatHash))); + Existing->SetStringField(TEXT("one_worker_hash"), FString::Printf( + TEXT("%016llx"), static_cast(OneWorkerHash))); + Existing->SetStringField(TEXT("functional_test_filter"), + TEXT("VoxelForge.ParameterizedGeneration.TraversalSeeds")); + Report->SetObjectField(TEXT("existing_checks"), Existing); + + FString JsonText; + const TSharedRef> Writer = TJsonWriterFactory<>::Create(&JsonText); + const FString ReportPath = FPaths::Combine(ResearchOut, TEXT("step2f_report.json")); + const FString LaunchReportPath = FPaths::Combine(Arguments.OutDirectory, TEXT("step2f_report.json")); + if (!FJsonSerializer::Serialize(Report, Writer) + || !FFileHelper::SaveStringToFile(JsonText, *ReportPath, + FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM) + || !FFileHelper::SaveStringToFile(JsonText, *LaunchReportPath, + FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2f] report write failed: %s"), *ReportPath); + return 2; + } + VoxelForgeRedefinedDensityField::SetParameterizedEnabled(false); + VoxelForgeRedefinedDensityField::SetLegacyStep2eAuditMode(false); + VoxelForgeRedefinedDensityField::SetDetailScale(1.0f); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeStep2f] pass=%s gates_2e_rejected=%s gates_2f_passed=%s " + "speed600=%s old_worst_ms=%.2f new_worst_ms=%.2f report=%s"), + bPassed ? TEXT("yes") : TEXT("no"), + bAllGeometryGatesRejectStep2e ? TEXT("yes") : TEXT("no"), + bAllGeometryGatesPassStep2f ? TEXT("yes") : TEXT("no"), + bSpeedPass ? TEXT("yes") : TEXT("no"), + WorstStep2eMs, WorstStep2fMs, *ReportPath); + return bPassed ? 0 : 2; +} + class FGeneratorDensitySampler final : public IVoxelStrateDensitySampler { public: @@ -11374,6 +12639,10 @@ int32 RunBatch(const FString& Params) int32 UVoxelForgeExploreCommandlet::Main(const FString& Params) { + if (FParse::Param(*Params, TEXT("step2f"))) + { + return RunStep2f(Params); + } if (FParse::Param(*Params, TEXT("step2e"))) { return RunStep2e(Params); diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.cpp index efebfe0..5ad7656 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.cpp @@ -312,7 +312,7 @@ namespace VoxelForgeRegionEval const double EvaluationStart = FPlatformTime::Seconds(); VoxelForgeRedefinedDensityField::BuildDetailBrick( Generator, Brick.Origin, Brick.Size, DetailScale, BrickDetail, - bParameterized ? nullptr : &BrickContext); + bParameterized ? nullptr : &BrickContext, bParameterized); if (bParameterized) { VoxelForgeRedefinedDensityField::BuildParameterizedBrickContext( diff --git a/Tools/VoxelForgeTest.ps1 b/Tools/VoxelForgeTest.ps1 index dad9ae3..dcc292d 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', 'step2d', 'step2e')] + [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', 'step2e', 'step2f', 'step2f-capture')] [string]$Scenario = 'canonical', [switch]$Build, [switch]$EnableRender, @@ -22,6 +22,10 @@ param( [int]$HorizonLevel = 5, [ValidateRange(1, 100000)] [int]$HorizonRenderDistanceChunks = 768, + [ValidateRange(0, 7)] + [int]$Step2fProfile = 0, + [ValidateSet('inside', 'wall-closeup')] + [string]$Step2fCaptureView = 'inside', [ValidateSet(32, 64, 128)] [int]$ParityExportSize = 128, [string]$ParityExportTile = '', @@ -62,12 +66,12 @@ $AssetEvidence = @() $CommandletMetrics = [ordered]@{} $GameMetrics = [ordered]@{} $HorizonMetrics = [ordered]@{ - requested = $Scenario -in @('horizon', 'horizon-render') + requested = $Scenario -in @('horizon', 'horizon-render', 'step2f-capture') status = 'not_run' level = $HorizonLevel render_distance_chunks = $HorizonRenderDistanceChunks } -$HorizonRenderMetrics = [ordered]@{ requested = $Scenario -eq 'horizon-render'; status = 'not_run' } +$HorizonRenderMetrics = [ordered]@{ requested = $Scenario -in @('horizon-render', 'step2f-capture'); status = 'not_run' } $DeterminismEvidence = [ordered]@{ requested = $Scenario -eq 'determinism'; status = 'not_run' } $SurfaceFallMetrics = [ordered]@{ requested = $Scenario -eq 'surface-fall' -or @@ -575,7 +579,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', 'step2d', 'step2e') + 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', 'step2e', 'step2f', 'step2f-capture') source_path = $source source_sha256 = Get-Sha256 $source staged_path = $target @@ -623,7 +627,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', 'step2d', 'step2e') + 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', 'step2e', 'step2f', 'step2f-capture') source_path = $source source_sha256 = Get-Sha256 $source staged_path = $target @@ -1526,6 +1530,33 @@ try { } } } + 'step2f' { + $step2fLog = Join-Path $RunRoot 'Logs\step2f.log' + $args = New-CommonArguments 'commandlet' $step2fLog (Merge-Cvars $baseCvars) + $args += New-CommandletArguments $commandletOut 'walk,export' ($Assets -eq 'owner') $false 128 + $researchOut = Join-Path $PluginRoot "Saved\Research\step2f\$Label" + $args += @('-step2f', '-regionworkers=6', ('-researchout="{0}"' -f $researchOut)) + $commandletLedger = Invoke-UnrealLaunch 'step2f-commandlet' $args ` + $step2fLog (Join-Path $RunRoot 'Logs\step2f.stdout.log') ` + (Join-Path $RunRoot 'Logs\step2f.stderr.log') + $step2fReportPath = Join-Path $researchOut 'step2f_report.json' + $step2fReport = Read-JsonFile $step2fReportPath + if ($null -eq $step2fReport) { + $CommandletMetrics = [ordered]@{ status = 'missing'; output = $researchOut; report = $step2fReportPath } + Add-Failure "step2f did not produce $step2fReportPath" + } else { + $CommandletMetrics = [ordered]@{ + status = if ((Get-JsonValue $step2fReport 'pass' $false) -and $commandletLedger.status -eq 'passed') { 'passed' } else { 'failed' } + output = $researchOut + report = $step2fReportPath + worst_profile_meets_600ms = Get-JsonValue $step2fReport 'worst_profile_meets_600ms' $false + result = $step2fReport + } + if (-not (Get-JsonValue $step2fReport 'pass' $false)) { + Add-Failure 'step2f geometry gates, 2e rejection audit, speed, sound proofs, seams, or determinism failed' + } + } + } 'symbolize-stacks' { if (([string]::IsNullOrWhiteSpace($StackSamplesPath)) -or -not (Test-Path -LiteralPath $StackSamplesPath -PathType Leaf)) { throw "-StackSamplesPath must name an existing raw sampler CSV." @@ -1591,6 +1622,47 @@ try { Add-Failure "horizon render did not produce a capture: $($HorizonRenderMetrics.status)" } } + 'step2f-capture' { + $tracePath = Join-Path $RunRoot 'step2f_capture_trace.jsonl' + $baseCvars['voxel.TestRenderDistanceChunks'] = $HorizonRenderDistanceChunks + $baseCvars['voxel.TestMaxClipLevel'] = $HorizonLevel + $baseCvars['voxel.StartupTraceFile'] = $tracePath + $baseCvars['voxel.StartupTraceMaxTiles'] = 16384 + $baseCvars['voxel.TestCeilingView'] = 1 + $baseCvars['voxel.TestCeilingCaptureDelaySeconds'] = 60 + $baseCvars['voxel.TestExitSeconds'] = 120 + $baseCvars['voxel.OuterClassifierMode'] = 0 + $baseCvars['voxel.ResearchParameterizedCave'] = 1 + $baseCvars['voxel.ResearchParameterizedCaveSet'] = $Step2fProfile + $renderLog = Join-Path $RunRoot 'Logs\step2f-capture.log' + $args = New-CommonArguments 'game' $renderLog (Merge-Cvars $baseCvars) $true + $gameLedger = Invoke-UnrealLaunch "step2f-profile-$Step2fProfile-$Step2fCaptureView" $args ` + $renderLog (Join-Path $RunRoot 'Logs\step2f-capture.stdout.log') ` + (Join-Path $RunRoot 'Logs\step2f-capture.stderr.log') + $GameMetrics = Get-StreamingMetrics $renderLog + $HorizonMetrics = Get-HorizonMetrics $tracePath $gameLedger $GameMetrics + $HorizonRenderMetrics = Get-HorizonRenderMetrics $gameLedger $renderLog + if ($HorizonMetrics.status -ne 'ok') { + Add-Failure "step2f capture render trace did not settle: $($HorizonMetrics.status)" + } + if ($HorizonRenderMetrics.status -ne 'passed') { + Add-Failure "step2f profile $Step2fProfile in-game capture failed: $($HorizonRenderMetrics.status)" + } else { + $profileNames = @('OpenVaults', 'TightNetwork', 'ChamberHalls', 'ShaftForest', 'FloatingArchipelago', 'PillaredCathedral', 'TerracedBasin', 'HyperComplex') + $profileName = $profileNames[$Step2fProfile] + $captureDirectory = Join-Path $PluginRoot 'Saved\Research\step2f\captures' + Ensure-Directory $captureDirectory + $captureName = '{0}_{1}.png' -f $profileName, $Step2fCaptureView + $capturePath = Join-Path $captureDirectory $captureName + $image = @($HorizonRenderMetrics.images | Sort-Object modified_utc | Select-Object -Last 1) + if ($image.Count -eq 0 -or -not (Test-Path -LiteralPath $image[0].path -PathType Leaf)) { + Add-Failure "step2f profile $Step2fProfile image path was missing after capture" + } else { + Copy-Item -LiteralPath $image[0].path -Destination $capturePath -Force + $HorizonRenderMetrics.saved_capture = $capturePath + } + } + } 'determinism' { $defaultDump = Join-Path $RunRoot 'tile_hashes_default.json' $crtFma3OffDump = Join-Path $RunRoot 'tile_hashes_crt_fma3_off.json' @@ -1932,6 +2004,7 @@ $result = [ordered]@{ commandlet = $CommandletMetrics region_eval = if ($Scenario -eq 'region-eval') { $CommandletMetrics } else { $null } step2e = if ($Scenario -eq 'step2e') { $CommandletMetrics } else { $null } + step2f = if ($Scenario -eq 'step2f') { $CommandletMetrics } else { $null } game = $GameMetrics horizon = $HorizonMetrics horizon_render = $HorizonRenderMetrics