From f8a46ecf8dbf95bba25812d2926f7940e1ede3aa Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Wed, 30 Sep 2026 11:45:45 +0200 Subject: [PATCH] wip(step2e): parameterised chamber-graph model -- compiles, traversal test PASS; benchmark not run Stopped by low memory (another app held 12.5 GB; processes failed with 0xC0000142). Editor-only, default off: a seeded connected room graph (varied chambers on a spanning network, optional loops, shafts, rock features, ledges, basin floors), eight parameter profiles, the step-2d brick driver and detail bands, a new test VoxelForge.ParameterizedGeneration.TraversalSeeds (PASS in step2e_functional_01, which also proves the tree compiles). Existing live tests untouched. Not yet run: the eight-profile ring benchmark, variety metrics, slices, detail/artefact/determinism checks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ --- CODEMAP.md | 9 +- .../VoxelForgeParameterizedGenerationTest.cpp | 151 ++++ Source/VoxelForge/Private/VoxelGenerator.cpp | 13 +- .../Private/VoxelRedefinedDensityField.cpp | 681 +++++++++++++++++- .../Public/VoxelRedefinedDensityField.h | 66 ++ .../Private/VoxelForgeExploreCommandlet.cpp | 646 ++++++++++++++++- .../VoxelForgeRegionEvalExperiment.cpp | 33 +- .../Private/VoxelForgeRegionEvalExperiment.h | 3 +- Tools/VoxelForgeTest.ps1 | 34 +- 9 files changed, 1585 insertions(+), 51 deletions(-) create mode 100644 Source/VoxelForge/Private/Tests/VoxelForgeParameterizedGenerationTest.cpp diff --git a/CODEMAP.md b/CODEMAP.md index 7deaf2b..56c616f 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -117,11 +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`, optional `-Build`, `-Assets owner|default`, `-Cvars`, absolute `-Out`, `-Label`, `-HorizonLevel`, `-HorizonRenderDistanceChunks`, `-ParityExportSize=32|64|128`, `-ParityExportTile=x,y,z`, `-SharedDDC`, `-SampleDensityStacks`, `-StackSampleIntervalUs`, and `-RegionHashProbe -RegionWorkers N -RegionReferenceHash HASH`; stages the host/assets, isolates user/DDC/config state, serializes one Unreal launch at a time, enforces the 8 GiB/30-minute guards, waits for 6 GiB free RAM before launches, and emits compact `result.json`/`summary.txt` plus full logs. Parity can render its game leg with `-EnableRender` and compares the selected export region against the exact streamed tile hashes. `visual-gap-walk` records camera-frustum surface-point coverage at 6000 cm/s; `visual-gap-inject` proves an early component hide is detected; `dig-motion` logs per-frame vertical velocity, gate, support readiness, and movement mode. `-Cvars @{ 'voxel.TestGapAuditLegacyHandoff'=1 }` replays the old visibility handoff for A/B audit baselines. Horizon traces report desired/loaded tiles, resident stream geometry, worker seconds, steady wall time, and peak private memory; `horizon-render` captures the owner view; `determinism` compares per-tile hashes across the default CRT path and `-voxel.CrtFma3=0`; `region-eval` compares the live path with the default-off editor chunk program on the 259³ lattice and five-seed probe set; `step2d` measures the independent full-detail research field, sound 8³ brick proofs, detail/gradient controls, slices, and worker hashes. `-RegionHashProbe -RegionWorkers N -RegionReferenceHash HASH` checks a full-ring hash at another partition count. `-SampleDensityStacks` captures profiles, and `-StackSampleIntervalUs` sets the sampling interval. +| `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. | `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` runs the independent field benchmark without old-world equality gates and writes timing, detail, boundary, determinism, triangle, and side-by-side slice evidence under `Saved/Research/step2d`. | -| `Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.*` | Six-worker lattice drivers for step 2c and step 2d. The original path still compares live `GetDensityAt` with the editor chunk program. Step 2d evaluates the independent field and assigns contiguous batches of 8³ bricks dynamically so surface-heavy work balances across workers. It first proves bricks uniform using a macro Lipschitz bound plus the full detail-amplitude bound; uncertain bricks retain all 25 cm detail samples. Timings split classification, point evaluation, and uniform fill; hashes compare workers 1 and 6. | -| `Public/VoxelRedefinedDensityField.h` / `Private/VoxelRedefinedDensityField.cpp` | Default-off `WITH_EDITOR` research field, selected by `voxel.ResearchRedefinedDensityField`. A seeded connected room/corridor lattice joins authored flat passage landings; four bounded world-space value-noise bands, including a one-sample micro band, are evaluated at full resolution. Skip proofs refuse edits, passage bounds, strate boundaries, and uncertain bricks; a local derivative bound permits sound proofs through landing-floor blends. The public API adds no reflected class layout. | +| `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, variety, and side-by-side slice evidence under `Saved/Research/step2e`. | +| `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 of 8³ bricks dynamically so surface-heavy work balances across workers. 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; four bounded world-space value-noise bands, including a one-sample micro band, are evaluated at full resolution. Step 2e proofs include the model Lipschitz bound, passage/refinement fallback, and full detail supremum; a local derivative bound permits sound proofs through landing-floor blends. 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. | | `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/Source/VoxelForge/Private/Tests/VoxelForgeParameterizedGenerationTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeParameterizedGenerationTest.cpp new file mode 100644 index 0000000..559b324 --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeParameterizedGenerationTest.cpp @@ -0,0 +1,151 @@ +#if WITH_DEV_AUTOMATION_TESTS + +#include "Misc/AutomationTest.h" + +#include "VoxelForgeTestFixture.h" +#include "VoxelRedefinedDensityField.h" +#include "VoxelStrateMeasure.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeParameterizedGenerationTraversalSeedsTest, + "VoxelForge.ParameterizedGeneration.TraversalSeeds", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FVoxelForgeParameterizedGenerationTraversalSeedsTest::RunTest(const FString& Parameters) +{ + (void)Parameters; + struct FRestoreResearchFields + { + bool bWasParameterizedEnabled = VoxelForgeRedefinedDensityField::IsParameterizedEnabled(); + bool bWasRedefinedEnabled = VoxelForgeRedefinedDensityField::IsEnabled(); + int32 PreviousSet = VoxelForgeRedefinedDensityField::GetParameterSet(); + float PreviousScale = VoxelForgeRedefinedDensityField::GetDetailScale(); + ~FRestoreResearchFields() + { + VoxelForgeRedefinedDensityField::SetDetailScale(PreviousScale); + VoxelForgeRedefinedDensityField::SetParameterSet(PreviousSet); + VoxelForgeRedefinedDensityField::SetEnabled(bWasRedefinedEnabled); + VoxelForgeRedefinedDensityField::SetParameterizedEnabled(bWasParameterizedEnabled); + } + } Restore; + VoxelForgeRedefinedDensityField::SetEnabled(false); + VoxelForgeRedefinedDensityField::SetParameterizedEnabled(true); + VoxelForgeRedefinedDensityField::SetParameterSet(-1); + VoxelForgeRedefinedDensityField::SetDetailScale(1.0f); + + constexpr int32 Seeds[] = {1337, 12345, 20260930}; + int32 CheckedLandings = 0; + int32 CheckedRoutes = 0; + int32 RoutesWithResolvedEndpoints = 0; + for (const int32 Seed : Seeds) + { + VoxelForgeTest::FTestWorld World; + World.Build(Seed, /*InGapChunks=*/2, /*InStrateHeightInChunks=*/4); + if (!World.IsValid()) + { + AddError(FString::Printf( + TEXT("Seed %d: test world did not build: %s"), Seed, *World.WhyInvalid())); + return false; + } + + const TArray& Layout = World.StrateManager->GetLayout(); + const TArray& Passages = World.StrateManager->GetPassages(); + for (const FVoxelPassage& Passage : Passages) + { + if (Passage.UpperStrateIndex == Passage.LowerStrateIndex) + { + continue; + } + const FVector UpperAir = Passage.UpperLanding.StandingPoint; + const FVector LowerAir = Passage.LowerLanding.StandingPoint; + const float UpperDensity = World.Generator->GetDensityAt( + UpperAir.X, UpperAir.Y, UpperAir.Z); + const float UpperFooting = World.Generator->GetDensityAt( + UpperAir.X, UpperAir.Y, Passage.UpperLanding.FloorZ - 0.75f); + const float LowerDensity = World.Generator->GetDensityAt( + LowerAir.X, LowerAir.Y, LowerAir.Z); + const float LowerFooting = World.Generator->GetDensityAt( + LowerAir.X, LowerAir.Y, Passage.LowerLanding.FloorZ - 0.75f); + ++CheckedLandings; + if (!FMath::IsFinite(UpperDensity) || !FMath::IsFinite(UpperFooting) + || !FMath::IsFinite(LowerDensity) || !FMath::IsFinite(LowerFooting) + || UpperDensity <= 0.0f || LowerDensity <= 0.0f + || UpperFooting >= 0.0f || LowerFooting >= 0.0f) + { + AddError(FString::Printf( + TEXT("Parameterized model seed %d: passage %d landing failed the final-field " + "air/floor bracket (upper %.4f/%.4f, lower %.4f/%.4f)."), + Seed, CheckedLandings - 1, UpperDensity, UpperFooting, + LowerDensity, LowerFooting)); + } + } + + for (int32 StrateIndex = 0; StrateIndex < Layout.Num(); ++StrateIndex) + { + FVector Arrival = FVector::ZeroVector; + FVector Departure = FVector::ZeroVector; + bool bHasArrival = false; + bool bHasDeparture = false; + for (const FVoxelPassage& Passage : Passages) + { + if (Passage.UpperStrateIndex == Passage.LowerStrateIndex) + { + continue; + } + if (Passage.LowerStrateIndex == StrateIndex && !bHasArrival) + { + Arrival = Passage.LowerLanding.StandingPoint; + bHasArrival = true; + } + if (Passage.UpperStrateIndex == StrateIndex && !bHasDeparture) + { + Departure = Passage.UpperLanding.StandingPoint; + bHasDeparture = true; + } + } + if (!bHasArrival || !bHasDeparture) + { + continue; + } + + FVoxelStrateMeasureSettings Settings; + Settings.SampleStep = 4; + Settings.RadiusInVoxels = 192; + Settings.CenterXY = FVector2D( + (Arrival.X + Departure.X) * 0.5f, + (Arrival.Y + Departure.Y) * 0.5f); + Settings.MaxCells = 4000000; + Settings.HeadroomCells = 2; + const FVoxelConnectivityDiagnostics Route = VF_DiagnoseConnectivity( + *World.Generator, *World.StrateManager, StrateIndex, + Arrival, Departure, Settings); + ++CheckedRoutes; + RoutesWithResolvedEndpoints += Route.bStartSnapped || Route.bGoalSnapped; + if (!Route.bValid || Route.Result != EVoxelConnectivityResult::Connected) + { + AddError(FString::Printf( + TEXT("Parameterized model seed %d strate %d (%s): arrival-to-exit walking " + "route was not connected (result=%d, valid=%s, start resolved=%s, " + "goal resolved=%s)."), + Seed, StrateIndex, + Layout[StrateIndex].Definition + ? *Layout[StrateIndex].Definition->GetName() : TEXT("no definition"), + static_cast(Route.Result), + Route.bValid ? TEXT("yes") : TEXT("no"), + Route.bStartSnapped ? TEXT("yes") : TEXT("no"), + Route.bGoalSnapped ? TEXT("yes") : TEXT("no"))); + } + } + } + + TestTrue(TEXT("Several seeds exercised parameterized passage landings"), CheckedLandings >= 6); + TestTrue(TEXT("Several seeds exercised parameterized arrival-to-exit walking"), CheckedRoutes >= 6); + AddInfo(FString::Printf( + TEXT("Parameterized generation functional sweep: %d inter-strate passage landings " + "bracketed open air over %d seeds; %d arrival-to-exit walking routes checked; " + "%d used a resolved coarse endpoint after the full-density segment check."), + CheckedLandings, UE_ARRAY_COUNT(Seeds), CheckedRoutes, RoutesWithResolvedEndpoints)); + return CheckedLandings >= 6 && CheckedRoutes >= 6; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 53f93c9..39b3790 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -2817,8 +2817,17 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ, float* OutPreEditDensity) const { #if WITH_EDITOR - // Step 2d is an editor-only, default-off side path. It does not alter the live evaluator's - // state or caches; the field is selected only for an explicit research/test run. + // Step 2e and step 2d are separate editor-only, default-off side paths. The owner field stays + // authoritative unless a research commandlet or automation test explicitly selects one. + if (VoxelForgeRedefinedDensityField::IsParameterizedEnabled()) + { + VoxelPassageGeometry::ResetVerticalShaftConnectorAirMarker(); + const float ResearchDensity = VoxelForgeRedefinedDensityField::EvaluateParameterized( + *this, WorldX, WorldY, WorldZ, OutPreEditDensity, + VoxelForgeRedefinedDensityField::GetDetailScale()); + VoxelPassageGeometry::ResetVerticalShaftConnectorAirMarker(); + return ResearchDensity; + } if (VoxelForgeRedefinedDensityField::IsEnabled()) { VoxelPassageGeometry::ResetVerticalShaftConnectorAirMarker(); diff --git a/Source/VoxelForge/Private/VoxelRedefinedDensityField.cpp b/Source/VoxelForge/Private/VoxelRedefinedDensityField.cpp index 20f8d54..1229776 100644 --- a/Source/VoxelForge/Private/VoxelRedefinedDensityField.cpp +++ b/Source/VoxelForge/Private/VoxelRedefinedDensityField.cpp @@ -20,6 +20,7 @@ namespace constexpr float DetailAmplitude = 1.4f + 0.9f + 0.45f + 2.5f; constexpr float DetailBoundNumericalMargin = 1.0e-3f; constexpr float BaseFieldLipschitzBound = 1.25f; + constexpr float ParameterizedFieldLipschitzBound = 1.60f; constexpr float QuinticFadeSlopeBound = 1.875f; constexpr float MinimumCaveHeight = 48.0f; constexpr float LandingFloorBlendVoxels = 96.0f; @@ -38,6 +39,39 @@ namespace TEXT("voxel.ResearchRedefinedDensityDetailScale"), 1.0f, TEXT("Step 2d research detail amplitude scale; zero is a deliberate smoothing control."), ECVF_Default); + TAutoConsoleVariable CVarResearchParameterizedCave( + TEXT("voxel.ResearchParameterizedCave"), 0, + TEXT("Use the default-off editor-only step 2e parameterized cave field."), + ECVF_Default); + TAutoConsoleVariable CVarResearchParameterizedCaveSet( + TEXT("voxel.ResearchParameterizedCaveSet"), -1, + TEXT("Step 2e parameter profile 0-7; -1 rolls a profile deterministically per strate."), + ECVF_Default); + + struct FParameterProfile + { + const TCHAR* Name; + FGenerationParameters Parameters; + }; + + const FParameterProfile GParameterProfiles[] = { + { TEXT("OpenVaults"), { 176.0f, 0.34f, 0.45f, 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.28f, 0.42f, 112.0f, 8.0f, 20.0f, 22.0f, + 0.12f, 0.03f, 14.0f, 0.06f, 0.08f, 0.02f, 0.12f, 0.00f, 0.0f, 0.50f } }, + { TEXT("ChamberHalls"), { 144.0f, 0.38f, 0.45f, 320.0f, 18.0f, 34.0f, 32.0f, + 0.28f, 0.18f, 22.0f, 0.08f, 0.22f, 0.10f, 0.22f, 0.05f, 20.0f, 0.40f } }, + { TEXT("ShaftForest"), { 128.0f, 0.32f, 0.43f, 176.0f, 12.0f, 26.0f, 28.0f, + 0.12f, 0.55f, 20.0f, 0.05f, 0.18f, 0.04f, 0.20f, 0.00f, 0.0f, 0.50f } }, + { TEXT("FloatingArchipelago"), { 136.0f, 0.37f, 0.45f, 192.0f, 10.0f, 28.0f, 28.0f, + 0.15f, 0.10f, 16.0f, 0.05f, 0.10f, 0.72f, 0.28f, 0.00f, 0.0f, 0.50f } }, + { TEXT("PillaredCathedral"), { 160.0f, 0.38f, 0.45f, 248.0f, 12.0f, 32.0f, 34.0f, + 0.25f, 0.08f, 18.0f, 0.72f, 0.30f, 0.08f, 0.45f, 0.00f, 0.0f, 0.50f } }, + { TEXT("TerracedBasin"), { 136.0f, 0.34f, 0.45f, 216.0f, 14.0f, 28.0f, 30.0f, + 0.20f, 0.10f, 18.0f, 0.10f, 0.84f, 0.05f, 0.25f, 0.85f, 48.0f, 0.30f } }, + { TEXT("HyperComplex"), { 96.0f, 0.30f, 0.45f, 192.0f, 20.0f, 30.0f, 28.0f, + 0.40f, 0.55f, 20.0f, 0.45f, 0.68f, 0.50f, 0.75f, 0.42f, 28.0f, 0.40f } }, + }; struct FLandingSite { @@ -100,6 +134,14 @@ namespace float OffsetX = 0.0f; float OffsetY = 0.0f; float CorridorHalfWidth = 40.0f; + FGenerationParameters Generation; + int32 GenerationProfile = 0; + uint32 GenerationSeed = 0; + float GenerationFloorZ = 0.0f; + float GridCos = 1.0f; + float GridSin = 0.0f; + float GridOffsetX = 0.0f; + float GridOffsetY = 0.0f; FVerticalShaftParams VerticalShaftParams; int32 LandingHubIndex = INDEX_NONE; TArray Landings; @@ -111,6 +153,7 @@ namespace uint64 ManagerLifetimeId = 0; uint32 LayoutVersion = 0xFFFFFFFFu; int32 Seed = INT32_MIN; + int32 ParameterSet = INT32_MIN; TArray Strates; TArray PassageBounds; }; @@ -438,10 +481,12 @@ namespace } const uint64 Lifetime = Manager->GetCacheLifetimeId(); const uint32 Version = Manager->GetLayoutVersion(); + const int32 ParameterSet = CVarResearchParameterizedCaveSet.GetValueOnAnyThread(); if (GPlanCache.Manager == Manager && GPlanCache.ManagerLifetimeId == Lifetime && GPlanCache.LayoutVersion == Version - && GPlanCache.Seed == Generator.Seed) + && GPlanCache.Seed == Generator.Seed + && GPlanCache.ParameterSet == ParameterSet) { return; } @@ -451,6 +496,7 @@ namespace GPlanCache.ManagerLifetimeId = Lifetime; GPlanCache.LayoutVersion = Version; GPlanCache.Seed = Generator.Seed; + GPlanCache.ParameterSet = ParameterSet; const TArray& Layout = Manager->GetLayout(); GPlanCache.Strates.Reserve(Layout.Num()); for (const FStrateSlot& Slot : Layout) @@ -506,6 +552,58 @@ namespace } const uint32 StrateSeed = static_cast(Generator.Seed) ^ static_cast(Plan.StrateIndex * 7919 + 104729); + Plan.GenerationSeed = StrateSeed ^ 0x2E6F9A31u; + Plan.GenerationProfile = ParameterSet >= 0 + ? FMath::Clamp(ParameterSet, 0, UE_ARRAY_COUNT(GParameterProfiles) - 1) + : 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.RoomHeightVoxels = FMath::Clamp( + Plan.Generation.RoomHeightVoxels, 48.0f, StrateHeight); + Plan.Generation.RoomPitchVoxels = FMath::Max( + Plan.Generation.RoomPitchVoxels, 80.0f); + Plan.Generation.MinimumRoomRadiusFraction = FMath::Clamp( + Plan.Generation.MinimumRoomRadiusFraction, 0.20f, 0.41f); + Plan.Generation.MaximumRoomRadiusFraction = FMath::Clamp( + Plan.Generation.MaximumRoomRadiusFraction, + Plan.Generation.MinimumRoomRadiusFraction, 0.45f); + Plan.Generation.LoopProbability = FMath::Clamp( + Plan.Generation.LoopProbability, 0.0f, 0.8f); + Plan.Generation.VerticalShaftProbability = FMath::Clamp( + Plan.Generation.VerticalShaftProbability, 0.0f, 1.0f); + Plan.Generation.PillarProbability = FMath::Clamp( + Plan.Generation.PillarProbability, 0.0f, 1.0f); + Plan.Generation.LedgeProbability = FMath::Clamp( + Plan.Generation.LedgeProbability, 0.0f, 1.0f); + Plan.Generation.FloatingMassProbability = FMath::Clamp( + Plan.Generation.FloatingMassProbability, 0.0f, 1.0f); + Plan.Generation.SpireProbability = FMath::Clamp( + Plan.Generation.SpireProbability, 0.0f, 1.0f); + Plan.Generation.BasinProbability = FMath::Clamp( + Plan.Generation.BasinProbability, 0.0f, 1.0f); + Plan.Generation.BasinDepthVoxels = FMath::Clamp( + Plan.Generation.BasinDepthVoxels, 0.0f, 48.0f); + Plan.Generation.WaterLevelFraction = FMath::Clamp( + Plan.Generation.WaterLevelFraction, 0.0f, 1.0f); + Plan.GenerationFloorZ = (Bottom + Top - Plan.Generation.RoomHeightVoxels) * 0.5f; + static constexpr float GridRotations[][2] = { + {1.0f, 0.0f}, {0.923879533f, 0.382683432f}, + {0.707106781f, 0.707106781f}, {0.382683432f, 0.923879533f}, + {0.0f, 1.0f}, {-0.382683432f, 0.923879533f}, + {-0.707106781f, 0.707106781f}, {-0.923879533f, 0.382683432f}, + }; + const uint32 RotationHash = Hash3( + Plan.StrateIndex, Plan.MinChunkZ, 0x8D, StrateSeed); + const int32 RotationIndex = static_cast(RotationHash % UE_ARRAY_COUNT(GridRotations)); + Plan.GridCos = GridRotations[RotationIndex][0]; + Plan.GridSin = GridRotations[RotationIndex][1]; + Plan.GridOffsetX = VoxelHash::ToFloat01(VoxelHash::Mix(RotationHash ^ 0x1975u)) + * Plan.Generation.RoomPitchVoxels; + Plan.GridOffsetY = VoxelHash::ToFloat01(VoxelHash::Mix(RotationHash ^ 0x67A3u)) + * Plan.Generation.RoomPitchVoxels; if (Plan.Archetype == ECaveGeneratorType::VerticalShafts) { // Keep the archetype's central origin spine open for its authored route probe. @@ -620,6 +718,7 @@ namespace Plan.FloorZ = FMath::Clamp( static_cast(FloorSum / FloorCount), Bottom + 16.0f, Top - 72.0f); Plan.CeilingZ = Plan.FloorZ + MinimumCaveHeight; + Plan.GenerationFloorZ = Plan.FloorZ; } } for (FLandingSite& Site : Plan.Landings) @@ -668,6 +767,409 @@ namespace } } + struct FGeneratedCaveCell + { + int32 CellX = 0; + int32 CellY = 0; + float CenterX = 0.0f; + float CenterY = 0.0f; + float FloorZ = 0.0f; + float CeilingZ = 0.0f; + float RadiusX = 0.0f; + float RadiusY = 0.0f; + float PillarX = 0.0f; + float PillarY = 0.0f; + float PillarRadius = 0.0f; + float LedgeZ = 0.0f; + float FloatingX[2] = {}; + float FloatingY[2] = {}; + float FloatingZ[2] = {}; + float FloatingRadius[2] = {}; + float SpireLength = 0.0f; + float SpireRadius = 0.0f; + uint8 FeatureFlags = 0; + }; + + constexpr uint8 HasPillarFlag = 1u << 0; + constexpr uint8 HasLedgeFlag = 1u << 1; + constexpr uint8 HasFloatingFlag = 1u << 2; + constexpr uint8 HasSpireFlag = 1u << 3; + constexpr uint8 HasBasinFlag = 1u << 4; + + FORCEINLINE float ParameterRoll(uint32 Hash, uint32 Salt) + { + return VoxelHash::ToFloat01(VoxelHash::Mix(Hash ^ Salt)); + } + + FORCEINLINE void WorldToGenerationGrid( + const FStratePlan& Plan, float X, float Y, float& OutX, float& OutY) + { + OutX = Plan.GridCos * X + Plan.GridSin * Y; + OutY = -Plan.GridSin * X + Plan.GridCos * Y; + } + + FGeneratedCaveCell BuildGeneratedCell( + const FStratePlan& Plan, int32 CellX, int32 CellY) + { + FGeneratedCaveCell Cell; + Cell.CellX = CellX; + Cell.CellY = CellY; + 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; + 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)); + Cell.FloorZ = Plan.GenerationFloorZ + + (ParameterRoll(Hash, 0xF103u) * 2.0f - 1.0f) * P.FloorReliefVoxels; + Cell.CeilingZ = Cell.FloorZ + P.RoomHeightVoxels; + + if (ParameterRoll(Hash, 0x8811u) < P.PillarProbability) + { + Cell.FeatureFlags |= HasPillarFlag; + Cell.PillarX = Cell.CenterX + (ParameterRoll(Hash, 0x1E09u) - 0.5f) + * Cell.RadiusX * 0.30f; + Cell.PillarY = Cell.CenterY + (ParameterRoll(Hash, 0x2F13u) - 0.5f) + * Cell.RadiusY * 0.30f; + Cell.PillarRadius = FMath::Min(14.0f, Cell.RadiusX * FMath::Lerp( + 0.10f, 0.22f, ParameterRoll(Hash, 0x3467u))); + } + if (ParameterRoll(Hash, 0x73BDu) < P.LedgeProbability) + { + Cell.FeatureFlags |= HasLedgeFlag; + Cell.LedgeZ = FMath::Lerp(Cell.FloorZ, Cell.CeilingZ, + FMath::Lerp(0.48f, 0.72f, ParameterRoll(Hash, 0xC579u))); + } + if (ParameterRoll(Hash, 0xE25Fu) < P.FloatingMassProbability) + { + Cell.FeatureFlags |= HasFloatingFlag; + for (int32 Blob = 0; Blob < 2; ++Blob) + { + const uint32 BlobSalt = 0x1567u + static_cast(Blob) * 0x101u; + const float OffsetX = (ParameterRoll(Hash, BlobSalt) - 0.5f) * Cell.RadiusX * 0.60f; + const float OffsetY = (ParameterRoll(Hash, BlobSalt + 1u) - 0.5f) * Cell.RadiusY * 0.60f; + Cell.FloatingX[Blob] = Cell.CenterX + OffsetX; + Cell.FloatingY[Blob] = Cell.CenterY + OffsetY; + Cell.FloatingZ[Blob] = FMath::Lerp( + 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)); + } + } + if (ParameterRoll(Hash, 0xC6A9u) < P.SpireProbability) + { + Cell.FeatureFlags |= HasSpireFlag; + Cell.SpireLength = FMath::Min(56.0f, P.RoomHeightVoxels * 0.30f); + Cell.SpireRadius = FMath::Min(14.0f, Cell.RadiusX * 0.22f); + } + if (ParameterRoll(Hash, 0x4537u) < P.BasinProbability) + { + Cell.FeatureFlags |= HasBasinFlag; + } + return Cell; + } + + FORCEINLINE float SmoothMinimum(float A, float B, float K) + { + const float H = FMath::Max(K - FMath::Abs(A - B), 0.0f) / K; + return FMath::Min(A, B) - H * H * K * 0.25f; + } + + FORCEINLINE float SmoothMaximum(float A, float B, float K) + { + return -SmoothMinimum(-A, -B, K); + } + + FORCEINLINE FIntPoint GenerationCellAt( + const FStratePlan& Plan, float X, float Y) + { + float GridX = 0.0f; + float GridY = 0.0f; + WorldToGenerationGrid(Plan, X, Y, GridX, GridY); + return FIntPoint( + FMath::FloorToInt((GridX - Plan.GridOffsetX) / Plan.Generation.RoomPitchVoxels), + FMath::FloorToInt((GridY - Plan.GridOffsetY) / Plan.Generation.RoomPitchVoxels)); + } + + FORCEINLINE float GeneratedRoomCell( + const FStratePlan& Plan, const FGeneratedCaveCell& Cell, + float X, float Y, float Z) + { + const float DX = X - Cell.CenterX; + const float DY = Y - Cell.CenterY; + const float Radius = FMath::Sqrt(DX * DX + DY * DY); + const float Horizontal = Cell.RadiusX - Radius; + float FloorZ = Cell.FloorZ; + if ((Cell.FeatureFlags & HasBasinFlag) != 0) + { + FloorZ += Plan.Generation.BasinDepthVoxels + * FMath::Clamp(Radius / FMath::Max(Cell.RadiusX, 1.0f), 0.0f, 1.0f); + } + const float Vertical = FMath::Min(Z - FloorZ, Cell.CeilingZ - Z); + float Room = SmoothMinimum(Horizontal, Vertical, 8.0f); + if (Room > 0.0f && (Cell.FeatureFlags & HasPillarFlag) != 0) + { + const float PX = X - Cell.PillarX; + const float PY = Y - Cell.PillarY; + const float Pillar = FMath::Max3( + FMath::Sqrt(PX * PX + PY * PY) - Cell.PillarRadius, + Cell.FloorZ - Z, Z - Cell.CeilingZ); + Room = FMath::Min(Room, Pillar); + } + if ((Cell.FeatureFlags & HasLedgeFlag) != 0 + && Horizontal >= -2.0f && FMath::Abs(Z - Cell.LedgeZ) <= 3.0f) + { + const float Inner = Cell.RadiusX * 0.58f; + const float Outer = Cell.RadiusX + 1.0f; + const float Ledge = FMath::Max3( + Inner - Radius, Radius - Outer, FMath::Abs(Z - Cell.LedgeZ) - 2.5f); + Room = FMath::Min(Room, Ledge); + } + if (Room > 0.0f && (Cell.FeatureFlags & HasFloatingFlag) != 0) + { + for (int32 Blob = 0; Blob < 2; ++Blob) + { + const float BX = X - Cell.FloatingX[Blob]; + const float BY = Y - Cell.FloatingY[Blob]; + const float BZ = Z - Cell.FloatingZ[Blob]; + const float BlobSolid = FMath::Sqrt(BX * BX + BY * BY + BZ * BZ) + - Cell.FloatingRadius[Blob]; + Room = FMath::Min(Room, BlobSolid); + } + } + if (Room > 0.0f && (Cell.FeatureFlags & HasSpireFlag) != 0) + { + const float BaseZ = Cell.FloorZ + 2.0f; + const float TipZ = FMath::Min(Cell.CeilingZ - 2.0f, BaseZ + Cell.SpireLength); + if (Z >= BaseZ && Z <= TipZ) + { + const float T = (Z - BaseZ) / FMath::Max(TipZ - BaseZ, 1.0f); + const float RadiusAtZ = FMath::Lerp(Cell.SpireRadius, 1.25f, T); + const float Spire = FMath::Max3( + Radius - RadiusAtZ, BaseZ - Z, Z - TipZ); + Room = FMath::Min(Room, Spire); + } + const float BaseTop = Cell.CeilingZ - 2.0f; + const float TipBottom = FMath::Max(Cell.FloorZ + 2.0f, BaseTop - Cell.SpireLength); + if (Z >= TipBottom && Z <= BaseTop) + { + const float T = (BaseTop - Z) / FMath::Max(BaseTop - TipBottom, 1.0f); + const float RadiusAtZ = FMath::Lerp(Cell.SpireRadius, 1.25f, T); + const float Stalactite = FMath::Max3( + Radius - RadiusAtZ, Z - BaseTop, TipBottom - Z); + Room = FMath::Min(Room, Stalactite); + } + } + return Room; + } + + FORCEINLINE FIntPoint GeneratedParent( + const FStratePlan& Plan, int32 CellX, int32 CellY) + { + const int32 AbsX = FMath::Abs(CellX); + const int32 AbsY = FMath::Abs(CellY); + if (AbsX == 0 && AbsY == 0) + { + return FIntPoint(INDEX_NONE, INDEX_NONE); + } + if (AbsX > 0 && AbsY > 0) + { + const uint32 Hash = Hash3(CellX, CellY, 0x7E, Plan.GenerationSeed); + if ((Hash & 1u) != 0) + { + return FIntPoint(CellX - (CellX > 0 ? 1 : -1), CellY); + } + } + if (AbsX > 0) + { + return FIntPoint(CellX - (CellX > 0 ? 1 : -1), CellY); + } + return FIntPoint(CellX, CellY - (CellY > 0 ? 1 : -1)); + } + + bool IsGeneratedEdgeOpen( + const FStratePlan& Plan, int32 AX, int32 AY, int32 BX, int32 BY) + { + const FIntPoint ParentA = GeneratedParent(Plan, AX, AY); + const FIntPoint ParentB = GeneratedParent(Plan, BX, BY); + if ((ParentA.X == BX && ParentA.Y == BY) + || (ParentB.X == AX && ParentB.Y == AY)) + { + return true; + } + const int32 MinX = FMath::Min(AX, BX); + const int32 MinY = FMath::Min(AY, BY); + const int32 Axis = AX != BX ? 0 : 1; + const uint32 EdgeHash = Hash3(MinX, MinY, Axis ^ 0x2D, Plan.GenerationSeed); + return VoxelHash::ToFloat01(EdgeHash) < Plan.Generation.LoopProbability; + } + + FORCEINLINE float GeneratedTunnelEdge( + const FStratePlan& Plan, + const FGeneratedCaveCell& A, const FGeneratedCaveCell& B, + float X, float Y, float Z) + { + if (!IsGeneratedEdgeOpen(Plan, A.CellX, A.CellY, B.CellX, B.CellY)) + { + return -FLT_MAX; + } + const float T = SegmentParameterXY(X, Y, + FVector(A.CenterX, A.CenterY, 0.0f), FVector(B.CenterX, B.CenterY, 0.0f)); + const float Horizontal = Plan.Generation.TunnelRadiusVoxels + - FMath::Sqrt(DistanceSquaredToSegmentXY( + X, Y, FVector(A.CenterX, A.CenterY, 0.0f), + FVector(B.CenterX, B.CenterY, 0.0f))); + const float FloorZ = FMath::Lerp(A.FloorZ, B.FloorZ, T); + const float Vertical = FMath::Min( + Z - FloorZ, + FloorZ + 2.0f * Plan.Generation.TunnelHalfHeightVoxels - Z); + return SmoothMinimum(Horizontal, Vertical, 5.0f); + } + + float EvaluateParameterizedMacro( + const FStratePlan& Plan, float X, float Y, float Z, float* OutDetailScale) + { + const FIntPoint BaseCell = GenerationCellAt(Plan, X, Y); + FGeneratedCaveCell Cells[9]; + for (int32 CellY = -1; CellY <= 1; ++CellY) + for (int32 CellX = -1; CellX <= 1; ++CellX) + { + const int32 Index = (CellY + 1) * 3 + CellX + 1; + Cells[Index] = BuildGeneratedCell( + Plan, BaseCell.X + CellX, BaseCell.Y + CellY); + } + + float Macro = -FLT_MAX; + for (const FGeneratedCaveCell& Cell : Cells) + { + const float DX = X - Cell.CenterX; + const float DY = Y - Cell.CenterY; + const float Horizontal = Cell.RadiusX - FMath::Sqrt(DX * DX + DY * DY); + if (Macro > -FLT_MAX * 0.5f && Horizontal < Macro - 6.0f) + { + continue; + } + Macro = SmoothMaximum(Macro, + GeneratedRoomCell(Plan, Cell, X, Y, Z), 6.0f); + } + const FGeneratedCaveCell& Centre = Cells[4]; + const int32 EdgeIndices[] = {1, 7, 3, 5}; + for (const int32 EdgeIndex : EdgeIndices) + { + Macro = SmoothMaximum(Macro, + GeneratedTunnelEdge(Plan, Centre, Cells[EdgeIndex], X, Y, Z), 5.0f); + } + + const float Bottom = static_cast(Plan.MinChunkZ * CHUNK_SIZE) + 8.0f; + const float Top = static_cast(Plan.MaxChunkZExclusive * CHUNK_SIZE) - 8.0f; + for (const FGeneratedCaveCell& Cell : Cells) + { + const uint32 ShaftHash = Hash3( + Cell.CellX, Cell.CellY, 0x53, Plan.GenerationSeed); + if (VoxelHash::ToFloat01(ShaftHash) >= Plan.Generation.VerticalShaftProbability) + { + continue; + } + const float DX = X - Cell.CenterX; + const float DY = Y - Cell.CenterY; + const float Horizontal = Plan.Generation.ShaftRadiusVoxels + - FMath::Sqrt(DX * DX + DY * DY); + const float Vertical = FMath::Min(Z - Bottom, Top - Z); + Macro = SmoothMaximum(Macro, + SmoothMinimum(Horizontal, Vertical, 5.0f), 5.0f); + } + + float DetailFade = 1.0f; + for (const FLandingSite& Site : Plan.Landings) + { + const float DX = X - Site.Position.X; + const float DY = Y - Site.Position.Y; + const float DistanceSquared = DX * DX + DY * DY; + const float Distance = FMath::Sqrt(DistanceSquared); + const float RoomAir = FMath::Min( + Site.PadRadius - Distance, + FMath::Min(Z - Site.FloorZ, Site.FloorZ + MinimumCaveHeight - Z)); + Macro = SmoothMaximum(Macro, RoomAir, 6.0f); + if (Distance < FMath::Max(0.0f, Site.PadRadius - 3.0f)) + { + DetailFade = 0.0f; + } + + const float AnchorReach = Plan.Generation.RoomPitchVoxels * 1.25f + + FMath::Max(32.0f, Plan.Generation.TunnelRadiusVoxels); + if (DistanceSquared > FMath::Square(AnchorReach)) + { + continue; + } + const FIntPoint LandingCell = GenerationCellAt(Plan, Site.Position.X, Site.Position.Y); + FGeneratedCaveCell NearestCell; + float BestDistanceSq = FLT_MAX; + for (int32 CellY = -1; CellY <= 1; ++CellY) + for (int32 CellX = -1; CellX <= 1; ++CellX) + { + const FGeneratedCaveCell Candidate = BuildGeneratedCell( + Plan, LandingCell.X + CellX, LandingCell.Y + CellY); + const float CandidateDistanceSq = FMath::Square(Candidate.CenterX - Site.Position.X) + + FMath::Square(Candidate.CenterY - Site.Position.Y); + if (CandidateDistanceSq < BestDistanceSq) + { + BestDistanceSq = CandidateDistanceSq; + NearestCell = Candidate; + } + } + const FVector AnchorA(Site.Position.X, Site.Position.Y, Site.FloorZ + 24.0f); + const FVector AnchorB(NearestCell.CenterX, NearestCell.CenterY, + NearestCell.FloorZ + Plan.Generation.TunnelHalfHeightVoxels); + const FVector Segment = AnchorB - AnchorA; + const float SegmentLengthSq = Segment.SizeSquared(); + const FVector Point(X, Y, Z); + const float T = SegmentLengthSq > 1.0e-8f + ? FMath::Clamp(FVector::DotProduct(Point - AnchorA, Segment) / SegmentLengthSq, + 0.0f, 1.0f) : 0.0f; + const float Anchor = FMath::Max(32.0f, Plan.Generation.TunnelRadiusVoxels) + - (Point - (AnchorA + Segment * T)).Size(); + Macro = SmoothMaximum(Macro, Anchor, 5.0f); + } + + if (Plan.LandingHubIndex != INDEX_NONE && Plan.Landings.Num() > 1) + { + const FLandingSite& Hub = Plan.Landings[Plan.LandingHubIndex]; + for (int32 SiteIndex = 0; SiteIndex < Plan.Landings.Num(); ++SiteIndex) + { + if (SiteIndex == Plan.LandingHubIndex) + { + continue; + } + const FLandingSite& Site = Plan.Landings[SiteIndex]; + const float Horizontal = LandingBridgeRadius - FMath::Sqrt( + DistanceSquaredToSegmentXY(X, Y, Site.Position, Hub.Position)); + const float T = SegmentParameterXY(X, Y, Site.Position, Hub.Position); + const float FloorZ = FMath::Lerp(Site.FloorZ, Hub.FloorZ, T); + const float Vertical = FMath::Min( + Z - FloorZ, FloorZ + MinimumCaveHeight - Z); + Macro = SmoothMaximum(Macro, + SmoothMinimum(Horizontal, Vertical, 5.0f), 5.0f); + } + } + + if (OutDetailScale != nullptr) + { + *OutDetailScale = DetailFade; + } + return Macro; + } + const FStratePlan* FindStrate(const FIntVector& Chunk) { for (const FStratePlan& Plan : GPlanCache.Strates) @@ -910,6 +1412,45 @@ void SetDetailScale(float Scale) CVarResearchRedefinedDensityDetailScale->Set(FMath::Clamp(Scale, 0.0f, 4.0f), ECVF_SetByCode); } +bool IsParameterizedEnabled() +{ + return CVarResearchParameterizedCave.GetValueOnAnyThread() != 0; +} + +void SetParameterizedEnabled(bool bEnabled) +{ + CVarResearchParameterizedCave->Set(bEnabled ? 1 : 0, ECVF_SetByCode); +} + +void SetParameterSet(int32 ParameterSet) +{ + CVarResearchParameterizedCaveSet->Set( + ParameterSet < 0 ? -1 : FMath::Clamp(ParameterSet, 0, + static_cast(UE_ARRAY_COUNT(GParameterProfiles) - 1)), ECVF_SetByCode); +} + +int32 GetParameterSet() +{ + return CVarResearchParameterizedCaveSet.GetValueOnAnyThread(); +} + +int32 GetParameterSetCount() +{ + return static_cast(UE_ARRAY_COUNT(GParameterProfiles)); +} + +bool GetParameterSetInfo(int32 ParameterSet, const TCHAR*& OutName, + FGenerationParameters& OutParameters) +{ + if (ParameterSet < 0 || ParameterSet >= GetParameterSetCount()) + { + return false; + } + OutName = GParameterProfiles[ParameterSet].Name; + OutParameters = GParameterProfiles[ParameterSet].Parameters; + return true; +} + void ResetThreadCache() { GPlanCache = FThreadPlanCache(); @@ -919,20 +1460,28 @@ void ResetThreadCache() static float EvaluateInternal(const UVoxelGenerator& Generator, float WorldX, float WorldY, float WorldZ, float DetailValue, bool bHasDetailValue, float* OutPreEditDensity, float DetailScale, - const FBrickEvaluationContext* Context = nullptr) + const FBrickEvaluationContext* Context = nullptr, + bool bParameterized = false, + const FParameterizedBrickContext* ParameterizedContext = nullptr) { - if (Context == nullptr) + if (Context == nullptr && ParameterizedContext == nullptr) { EnsurePlan(Generator); } + const bool bChunkResolved = Context != nullptr && Context->bChunkResolved + || ParameterizedContext != nullptr && ParameterizedContext->bChunkResolved; const FIntVector Chunk = Context != nullptr && Context->bChunkResolved ? Context->Chunk + : ParameterizedContext != nullptr && ParameterizedContext->bChunkResolved + ? ParameterizedContext->Chunk : FIntVector( FMath::FloorToInt(WorldX / CHUNK_SIZE), FMath::FloorToInt(WorldY / CHUNK_SIZE), FMath::FloorToInt(WorldZ / CHUNK_SIZE)); const FStratePlan* Plan = Context != nullptr && Context->bPlanResolved ? static_cast(Context->PlanToken) + : ParameterizedContext != nullptr && ParameterizedContext->bPlanResolved + ? static_cast(ParameterizedContext->PlanToken) : FindStrate(Chunk); float Result = 0.0f; const UVoxelStrateManager* Manager = Generator.StrateManager; @@ -940,8 +1489,10 @@ static float EvaluateInternal(const UVoxelGenerator& Generator, if (Plan == nullptr) { const bool bIsGapChunk = Manager != nullptr - && (Context != nullptr && Context->bChunkResolved - ? Context->bIsGapChunk : Manager->IsGapChunk(Chunk)); + && (bChunkResolved + ? (Context != nullptr && Context->bChunkResolved + ? Context->bIsGapChunk : ParameterizedContext->bIsGapChunk) + : Manager->IsGapChunk(Chunk)); if (bIsGapChunk) { float Internal = 8.0f; @@ -957,22 +1508,27 @@ static float EvaluateInternal(const UVoxelGenerator& Generator, else { float DetailFade = 1.0f; - const float Macro = Context != nullptr && Context->bMacroXYPrecomputed - ? EvaluateMacroFromBrick(*Plan, *Context, WorldX, WorldY, WorldZ, &DetailFade) - : EvaluateMacro(*Plan, WorldX, WorldY, WorldZ, &DetailFade); + const float Macro = bParameterized + ? EvaluateParameterizedMacro(*Plan, WorldX, WorldY, WorldZ, &DetailFade) + : (Context != nullptr && Context->bMacroXYPrecomputed + ? EvaluateMacroFromBrick(*Plan, *Context, WorldX, WorldY, WorldZ, &DetailFade) + : EvaluateMacro(*Plan, WorldX, WorldY, WorldZ, &DetailFade)); const uint32 DetailSeed = static_cast(Generator.Seed) ^ static_cast(Plan->StrateIndex * 7919 + 104729); const float FinalDetail = bHasDetailValue ? DetailValue : DetailAt(WorldX, WorldY, WorldZ, DetailSeed); Result = Macro + FinalDetail * DetailFade * DetailScale; - if (Manager != nullptr && (Context == nullptr || Context->bMayContainPassage)) + const bool bMayContainPassage = bParameterized + ? (ParameterizedContext == nullptr || ParameterizedContext->bMayContainPassage) + : (Context == nullptr || Context->bMayContainPassage); + if (Manager != nullptr && bMayContainPassage) { const FVector Point(WorldX, WorldY, WorldZ); bool bNearPassage = false; const auto IsInsideBound = [&Point](const FPassageBound& Bounds) { return PointMayIntersectPassageBound(Point, Bounds); }; - if (Context != nullptr) + if (Context != nullptr && !bParameterized) { for (const int32 BoundsIndex : Context->PassageCandidates) { @@ -983,6 +1539,17 @@ static float EvaluateInternal(const UVoxelGenerator& Generator, } } } + else if (ParameterizedContext != nullptr) + { + for (const int32 BoundsIndex : ParameterizedContext->PassageCandidates) + { + if (IsInsideBound(GPlanCache.PassageBounds[BoundsIndex])) + { + bNearPassage = true; + break; + } + } + } else { for (const FPassageBound& Bounds : GPlanCache.PassageBounds) @@ -1025,7 +1592,11 @@ static float EvaluateInternal(const UVoxelGenerator& Generator, *OutPreEditDensity = Result; } if (Generator.DiffLayer != nullptr - && (Context != nullptr ? Context->bHasDiffMods : Generator.DiffLayer->HasAnyMods())) + && (bParameterized && ParameterizedContext != nullptr + ? ParameterizedContext->bHasDiffMods + : Context != nullptr && !bParameterized + ? Context->bHasDiffMods + : Generator.DiffLayer->HasAnyMods())) { Result -= Generator.DiffLayer->GetDensityOffset(Chunk, WorldX, WorldY, WorldZ); } @@ -1048,6 +1619,63 @@ float EvaluateWithDetailValue(const UVoxelGenerator& Generator, OutPreEditDensity, DetailScale, Context); } +float EvaluateParameterized(const UVoxelGenerator& Generator, + float WorldX, float WorldY, float WorldZ, float* OutPreEditDensity, float DetailScale) +{ + return EvaluateInternal(Generator, WorldX, WorldY, WorldZ, 0.0f, false, + OutPreEditDensity, DetailScale, nullptr, true, nullptr); +} + +float EvaluateParameterizedWithDetailValue(const UVoxelGenerator& Generator, + float WorldX, float WorldY, float WorldZ, float DetailValue, + float* OutPreEditDensity, float DetailScale, + const FParameterizedBrickContext* Context) +{ + return EvaluateInternal(Generator, WorldX, WorldY, WorldZ, DetailValue, true, + OutPreEditDensity, DetailScale, nullptr, true, Context); +} + +void BuildParameterizedBrickContext( + const UVoxelGenerator& Generator, FIntVector OriginVoxels, FIntVector Size, + FParameterizedBrickContext& OutContext) +{ + OutContext = FParameterizedBrickContext(); + EnsurePlan(Generator); + const FIntVector End = OriginVoxels + FIntVector( + FMath::Max(Size.X - 1, 0), FMath::Max(Size.Y - 1, 0), FMath::Max(Size.Z - 1, 0)); + const FIntVector Chunk( + FMath::FloorToInt(static_cast(OriginVoxels.X) / CHUNK_SIZE), + FMath::FloorToInt(static_cast(OriginVoxels.Y) / CHUNK_SIZE), + FMath::FloorToInt(static_cast(OriginVoxels.Z) / CHUNK_SIZE)); + const FIntVector EndChunk( + FMath::FloorToInt(static_cast(End.X) / CHUNK_SIZE), + FMath::FloorToInt(static_cast(End.Y) / CHUNK_SIZE), + FMath::FloorToInt(static_cast(End.Z) / CHUNK_SIZE)); + const bool bStableStrate = Chunk.Z == EndChunk.Z; + const FStratePlan* Plan = bStableStrate ? FindStrate(Chunk) : nullptr; + OutContext.BrickOrigin = OriginVoxels; + OutContext.BrickSize = Size; + OutContext.Chunk = Chunk; + OutContext.bChunkResolved = Chunk == EndChunk; + OutContext.bPlanResolved = bStableStrate; + OutContext.PlanToken = bStableStrate ? Plan : nullptr; + OutContext.bIsGapChunk = OutContext.bChunkResolved + && Generator.StrateManager != nullptr + && Generator.StrateManager->IsGapChunk(Chunk); + OutContext.bHasDiffMods = Generator.DiffLayer != nullptr + && Generator.DiffLayer->HasAnyMods(); + const FVector Min( + static_cast(OriginVoxels.X - 1), + static_cast(OriginVoxels.Y - 1), + static_cast(OriginVoxels.Z - 1)); + const FVector Max( + static_cast(OriginVoxels.X + Size.X), + static_cast(OriginVoxels.Y + Size.Y), + static_cast(OriginVoxels.Z + Size.Z)); + FindPassageCandidates(FBox(Min, Max), OutContext.PassageCandidates); + OutContext.bMayContainPassage = !OutContext.PassageCandidates.IsEmpty(); +} + void BuildDetailBrick(const UVoxelGenerator& Generator, FIntVector OriginVoxels, FIntVector Size, float DetailScale, TArray& OutDetail, FBrickEvaluationContext* OutContext) @@ -1289,10 +1917,12 @@ void BuildDetailBrick(const UVoxelGenerator& Generator, FIntVector OriginVoxels, AddNoiseBand(1, Seed ^ 0xA511E9B3u, 2.5f); } -bool TryClassifyBrick(const UVoxelGenerator& Generator, FIntVector OriginVoxels, +static bool TryClassifyBrickInternal( + const UVoxelGenerator& Generator, FIntVector OriginVoxels, FIntVector Size, bool& bOutAir, float DetailScale, bool* bOutEvaluatedMacroCentre, - EBrickFallbackReason* OutFallbackReason) + EBrickFallbackReason* OutFallbackReason, + bool bParameterized) { if (OutFallbackReason != nullptr) { @@ -1444,7 +2074,7 @@ bool TryClassifyBrick(const UVoxelGenerator& Generator, FIntVector OriginVoxels, FMath::Max(SumAbsDeltaSlope, 2.0 * MaxAbsFloorDelta * SumInverseRadiusSlope), LandingBridgeSlopeBound); const float MacroLipschitzBound = FMath::Max( - BaseFieldLipschitzBound, + bParameterized ? ParameterizedFieldLipschitzBound : BaseFieldLipschitzBound, FMath::Sqrt(1.0f + FMath::Square(static_cast(FloorSlopeBound))) + 0.25f); const FVector Centre = (Min + Max) * 0.5f; @@ -1454,7 +2084,9 @@ bool TryClassifyBrick(const UVoxelGenerator& Generator, FIntVector OriginVoxels, { *bOutEvaluatedMacroCentre = true; } - const float Macro = EvaluateMacro(*Plan, Centre.X, Centre.Y, Centre.Z, nullptr); + const float Macro = bParameterized + ? 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) + DetailBoundNumericalMargin * FMath::Max(1.0f, FMath::Abs(DetailScale)); @@ -1482,6 +2114,25 @@ bool TryClassifyBrick(const UVoxelGenerator& Generator, FIntVector OriginVoxels, } return false; } + +bool TryClassifyBrick(const UVoxelGenerator& Generator, FIntVector OriginVoxels, + FIntVector Size, bool& bOutAir, float DetailScale, + bool* bOutEvaluatedMacroCentre, + EBrickFallbackReason* OutFallbackReason) +{ + return TryClassifyBrickInternal(Generator, OriginVoxels, Size, bOutAir, + DetailScale, bOutEvaluatedMacroCentre, OutFallbackReason, false); +} + +bool TryClassifyParameterizedBrick(const UVoxelGenerator& Generator, + FIntVector OriginVoxels, FIntVector Size, + bool& bOutAir, float DetailScale, + bool* bOutEvaluatedMacroCentre, + EBrickFallbackReason* OutFallbackReason) +{ + return TryClassifyBrickInternal(Generator, OriginVoxels, Size, bOutAir, + DetailScale, bOutEvaluatedMacroCentre, OutFallbackReason, true); +} } #endif // WITH_EDITOR diff --git a/Source/VoxelForge/Public/VoxelRedefinedDensityField.h b/Source/VoxelForge/Public/VoxelRedefinedDensityField.h index 20741dc..217044f 100644 --- a/Source/VoxelForge/Public/VoxelRedefinedDensityField.h +++ b/Source/VoxelForge/Public/VoxelRedefinedDensityField.h @@ -40,6 +40,43 @@ namespace VoxelForgeRedefinedDensityField TArray PassageCandidates; }; + /** Cell-level controls for the separate step 2e procedural room-network field. */ + struct FGenerationParameters + { + float RoomPitchVoxels = 144.0f; + float MinimumRoomRadiusFraction = 0.30f; + float MaximumRoomRadiusFraction = 0.45f; + float RoomHeightVoxels = 192.0f; + float FloorReliefVoxels = 8.0f; + float TunnelRadiusVoxels = 28.0f; + float TunnelHalfHeightVoxels = 26.0f; + float LoopProbability = 0.20f; + float VerticalShaftProbability = 0.10f; + float ShaftRadiusVoxels = 18.0f; + float PillarProbability = 0.10f; + float LedgeProbability = 0.20f; + float FloatingMassProbability = 0.10f; + float SpireProbability = 0.25f; + float BasinProbability = 0.0f; + float BasinDepthVoxels = 0.0f; + float WaterLevelFraction = 0.5f; + }; + + /** Brick-local immutable room cells used by step 2e evaluation and its final-field proof. */ + struct FParameterizedBrickContext + { + const void* PlanToken = nullptr; + bool bPlanResolved = false; + bool bChunkResolved = false; + bool bIsGapChunk = false; + bool bHasDiffMods = false; + bool bMayContainPassage = true; + FIntVector BrickOrigin = FIntVector::ZeroValue; + FIntVector BrickSize = FIntVector::ZeroValue; + FIntVector Chunk = FIntVector::ZeroValue; + TArray PassageCandidates; + }; + enum class EBrickFallbackReason : uint8 { None, @@ -61,6 +98,35 @@ 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. */ + VOXELFORGE_API bool IsParameterizedEnabled(); + VOXELFORGE_API void SetParameterizedEnabled(bool bEnabled); + VOXELFORGE_API void SetParameterSet(int32 ParameterSet); + VOXELFORGE_API int32 GetParameterSet(); + VOXELFORGE_API int32 GetParameterSetCount(); + VOXELFORGE_API bool GetParameterSetInfo( + int32 ParameterSet, const TCHAR*& OutName, FGenerationParameters& OutParameters); + + /** Evaluate the separate parameterized room / tunnel network, including edits/passages. */ + VOXELFORGE_API float EvaluateParameterized( + const UVoxelGenerator& Generator, float WorldX, float WorldY, float WorldZ, + float* OutPreEditDensity = nullptr, float DetailScale = 1.0f); + + VOXELFORGE_API float EvaluateParameterizedWithDetailValue( + const UVoxelGenerator& Generator, float WorldX, float WorldY, float WorldZ, + float DetailValue, float* OutPreEditDensity = nullptr, float DetailScale = 1.0f, + const FParameterizedBrickContext* Context = nullptr); + + VOXELFORGE_API bool TryClassifyParameterizedBrick( + const UVoxelGenerator& Generator, FIntVector OriginVoxels, FIntVector Size, + bool& bOutAir, float DetailScale = 1.0f, + bool* bOutEvaluatedMacroCentre = nullptr, + EBrickFallbackReason* OutFallbackReason = nullptr); + + VOXELFORGE_API void BuildParameterizedBrickContext( + const UVoxelGenerator& Generator, FIntVector OriginVoxels, FIntVector Size, + FParameterizedBrickContext& OutContext); + /** Evaluate the new field, including existing passage carving and player diffs. */ VOXELFORGE_API float Evaluate(const UVoxelGenerator& Generator, float WorldX, float WorldY, float WorldZ, float* OutPreEditDensity = nullptr, diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp index 142b984..7b83163 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -2552,7 +2552,8 @@ struct FStep2dBrickProofAudit FStep2dBrickProofAudit AuditStep2dBrickProofs( const UVoxelGenerator& Generator, FIntVector Origin, - int32 Dimension) + int32 Dimension, + bool bParameterized = false) { FStep2dBrickProofAudit Result; const FIntVector Last = Origin + FIntVector(Dimension - 1, Dimension - 1, Dimension - 1); @@ -2580,8 +2581,12 @@ FStep2dBrickProofAudit AuditStep2dBrickProofs( FMath::Min(BrickWorldMin.Z + 8, Origin.Z + Dimension)); const FIntVector Size = End - BrickOrigin; bool bProvenAir = false; - if (VoxelForgeRedefinedDensityField::TryClassifyBrick( - Generator, BrickOrigin, Size, bProvenAir, 1.0f)) + const bool bSoundClaim = bParameterized + ? VoxelForgeRedefinedDensityField::TryClassifyParameterizedBrick( + Generator, BrickOrigin, Size, bProvenAir, 1.0f) + : VoxelForgeRedefinedDensityField::TryClassifyBrick( + Generator, BrickOrigin, Size, bProvenAir, 1.0f); + if (bSoundClaim) { const bool bAuditThisSkip = (CorrectSkipOrdinal++ % 128u) == 0u; if (bAuditThisSkip) @@ -2591,11 +2596,15 @@ FStep2dBrickProofAudit AuditStep2dBrickProofs( for (int32 Y = -1; Y <= Size.Y; ++Y) for (int32 X = -1; X <= Size.X; ++X) { - const float Density = VoxelForgeRedefinedDensityField::Evaluate( - Generator, - static_cast(BrickOrigin.X + X), - static_cast(BrickOrigin.Y + Y), - static_cast(BrickOrigin.Z + Z), nullptr, 1.0f); + const float Density = bParameterized + ? VoxelForgeRedefinedDensityField::EvaluateParameterized( + Generator, static_cast(BrickOrigin.X + X), + static_cast(BrickOrigin.Y + Y), + static_cast(BrickOrigin.Z + Z), nullptr, 1.0f) + : VoxelForgeRedefinedDensityField::Evaluate( + Generator, static_cast(BrickOrigin.X + X), + static_cast(BrickOrigin.Y + Y), + static_cast(BrickOrigin.Z + Z), nullptr, 1.0f); ++Result.CorrectProofSamplesChecked; if ((Density > 0.0f) != bProvenAir) { @@ -2609,21 +2618,27 @@ FStep2dBrickProofAudit AuditStep2dBrickProofs( // maximum detail amplitude. A valid supremum must reject at least some of these // sign claims; otherwise this audit would not demonstrate that it can catch a bad bound. bool bDeliberatelyClaimedAir = false; - if (VoxelForgeRedefinedDensityField::TryClassifyBrick( - Generator, BrickOrigin, Size, bDeliberatelyClaimedAir, 0.0f)) + const bool bUndersizedClaim = bParameterized + ? VoxelForgeRedefinedDensityField::TryClassifyParameterizedBrick( + Generator, BrickOrigin, Size, bDeliberatelyClaimedAir, 0.0f) + : VoxelForgeRedefinedDensityField::TryClassifyBrick( + Generator, BrickOrigin, Size, bDeliberatelyClaimedAir, 0.0f); + if (bUndersizedClaim) { ++Result.DeliberatelyUndersizedBoundClaimsChecked; for (int32 Z = 0; Z < Size.Z; ++Z) for (int32 Y = 0; Y < Size.Y; ++Y) for (int32 X = 0; X < Size.X; ++X) { - const float MaximumDetailDensity = VoxelForgeRedefinedDensityField::Evaluate( - Generator, - static_cast(BrickOrigin.X + X), - static_cast(BrickOrigin.Y + Y), - static_cast(BrickOrigin.Z + Z), - nullptr, - 4.0f); + const float MaximumDetailDensity = bParameterized + ? VoxelForgeRedefinedDensityField::EvaluateParameterized( + Generator, static_cast(BrickOrigin.X + X), + static_cast(BrickOrigin.Y + Y), + static_cast(BrickOrigin.Z + Z), nullptr, 4.0f) + : VoxelForgeRedefinedDensityField::Evaluate( + Generator, static_cast(BrickOrigin.X + X), + static_cast(BrickOrigin.Y + Y), + static_cast(BrickOrigin.Z + Z), nullptr, 4.0f); if ((MaximumDetailDensity > 0.0f) != bDeliberatelyClaimedAir) { ++Result.DeliberateBoundDisagreements; @@ -2847,6 +2862,200 @@ void SaveStep2dSideBySideSlice( SavePng(Path, Pixels, Width, Height, OutError); } +struct FStep2eVarietyMetrics +{ + double OpenFraction = 0.0; + double SurfaceAreaSquareMeters = 0.0; + double SurfaceAreaPerVolume = 0.0; + double VerticalAdjacencyShare = 0.0; + double TunnelNeckShare = 0.0; + int32 ConnectedComponentsAtOneMeter = 0; + int64 LargestComponentCubicMeters = 0; + int64 SmallComponentsUpToEightCubicMeters = 0; + int64 MediumComponentsNineToSixtyFourCubicMeters = 0; + int64 LargeComponentsSixtyFiveToFiveHundredTwelveCubicMeters = 0; + int64 VeryLargeComponentsOverFiveHundredTwelveCubicMeters = 0; +}; + +FStep2eVarietyMetrics ComputeStep2eVarietyMetrics( + const TArray& Density, int32 Dimension) +{ + FStep2eVarietyMetrics Result; + const int64 Total = static_cast(Dimension) * Dimension * Dimension; + int64 AirCount = 0; + uint64 SurfaceEdgeCount = 0; + uint64 HorizontalAdjacencies = 0; + uint64 VerticalAdjacencies = 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; + const bool bAir = Density[Index] > 0.0f; + AirCount += bAir ? 1 : 0; + if (X + 1 < Dimension) + { + const bool bNextAir = Density[Index + 1] > 0.0f; + SurfaceEdgeCount += bAir != bNextAir ? 1u : 0u; + HorizontalAdjacencies += bAir && bNextAir ? 1u : 0u; + } + if (Y + 1 < Dimension) + { + const bool bNextAir = Density[Index + Dimension] > 0.0f; + SurfaceEdgeCount += bAir != bNextAir ? 1u : 0u; + HorizontalAdjacencies += bAir && bNextAir ? 1u : 0u; + } + if (Z + 1 < Dimension) + { + const bool bNextAir = Density[Index + Dimension * Dimension] > 0.0f; + SurfaceEdgeCount += bAir != bNextAir ? 1u : 0u; + VerticalAdjacencies += bAir && bNextAir ? 1u : 0u; + } + } + Result.OpenFraction = Total > 0 ? static_cast(AirCount) / Total : 0.0; + // Each sign-changing lattice edge estimates one quarter-meter-squared patch. This is a + // consistent edge-count estimate, not a marching-cubes triangle integral. + Result.SurfaceAreaSquareMeters = static_cast(SurfaceEdgeCount) * 0.25 * 0.25; + const double VolumeCubicMeters = static_cast(Total) * 0.25 * 0.25 * 0.25; + Result.SurfaceAreaPerVolume = VolumeCubicMeters > 0.0 + ? Result.SurfaceAreaSquareMeters / VolumeCubicMeters : 0.0; + const uint64 AllAdjacencies = HorizontalAdjacencies + VerticalAdjacencies; + Result.VerticalAdjacencyShare = AllAdjacencies > 0 + ? static_cast(VerticalAdjacencies) / AllAdjacencies : 0.0; + + constexpr int32 CoarseStep = 4; + const int32 CoarseDimension = (Dimension - 1) / CoarseStep + 1; + const int32 CoarseCount = CoarseDimension * CoarseDimension * CoarseDimension; + TArray Parent; + Parent.Init(INDEX_NONE, CoarseCount); + const auto CoarseIndex = [CoarseDimension](int32 X, int32 Y, int32 Z) + { + return (Z * CoarseDimension + Y) * CoarseDimension + X; + }; + int64 CoarseAirCount = 0; + int64 TunnelNeckCount = 0; + for (int32 Z = 0; Z < CoarseDimension; ++Z) + for (int32 Y = 0; Y < CoarseDimension; ++Y) + for (int32 X = 0; X < CoarseDimension; ++X) + { + const int32 SourceIndex = ((Z * CoarseStep) * Dimension + Y * CoarseStep) + * Dimension + X * CoarseStep; + if (Density[SourceIndex] <= 0.0f) + { + continue; + } + const int32 Index = CoarseIndex(X, Y, Z); + Parent[Index] = Index; + ++CoarseAirCount; + int32 OpenNeighbors = 0; + const int32 Delta[6][3] = { + {-1, 0, 0}, {1, 0, 0}, {0, -1, 0}, {0, 1, 0}, {0, 0, -1}, {0, 0, 1}}; + for (const auto& D : Delta) + { + const int32 NX = X + D[0]; + const int32 NY = Y + D[1]; + const int32 NZ = Z + D[2]; + if (NX < 0 || NY < 0 || NZ < 0 + || NX >= CoarseDimension || NY >= CoarseDimension || NZ >= CoarseDimension) + { + continue; + } + const int32 NSource = ((NZ * CoarseStep) * Dimension + NY * CoarseStep) + * Dimension + NX * CoarseStep; + OpenNeighbors += Density[NSource] > 0.0f ? 1 : 0; + } + TunnelNeckCount += OpenNeighbors <= 4 ? 1 : 0; + } + Result.TunnelNeckShare = CoarseAirCount > 0 + ? static_cast(TunnelNeckCount) / CoarseAirCount : 0.0; + + const auto FindRoot = [&Parent](int32 Index) + { + int32 Root = Index; + while (Parent[Root] != Root) + { + Root = Parent[Root]; + } + while (Parent[Index] != Index) + { + const int32 Next = Parent[Index]; + Parent[Index] = Root; + Index = Next; + } + return Root; + }; + for (int32 Z = 0; Z < CoarseDimension; ++Z) + for (int32 Y = 0; Y < CoarseDimension; ++Y) + for (int32 X = 0; X < CoarseDimension; ++X) + { + const int32 Index = CoarseIndex(X, Y, Z); + if (Parent[Index] == INDEX_NONE) + { + continue; + } + const int32 Neighbors[3] = { + X > 0 ? CoarseIndex(X - 1, Y, Z) : INDEX_NONE, + Y > 0 ? CoarseIndex(X, Y - 1, Z) : INDEX_NONE, + Z > 0 ? CoarseIndex(X, Y, Z - 1) : INDEX_NONE}; + for (const int32 Neighbor : Neighbors) + { + if (Neighbor == INDEX_NONE || Parent[Neighbor] == INDEX_NONE) + { + continue; + } + const int32 RootA = FindRoot(Index); + const int32 RootB = FindRoot(Neighbor); + if (RootA != RootB) + { + Parent[RootB] = RootA; + } + } + } + TMap ComponentSizes; + for (int32 Index = 0; Index < CoarseCount; ++Index) + { + if (Parent[Index] != INDEX_NONE) + { + ++ComponentSizes.FindOrAdd(FindRoot(Index)); + } + } + Result.ConnectedComponentsAtOneMeter = ComponentSizes.Num(); + for (const TPair& Pair : ComponentSizes) + { + const int64 Size = Pair.Value; + Result.LargestComponentCubicMeters = FMath::Max(Result.LargestComponentCubicMeters, Size); + if (Size <= 8) ++Result.SmallComponentsUpToEightCubicMeters; + else if (Size <= 64) ++Result.MediumComponentsNineToSixtyFourCubicMeters; + else if (Size <= 512) ++Result.LargeComponentsSixtyFiveToFiveHundredTwelveCubicMeters; + else ++Result.VeryLargeComponentsOverFiveHundredTwelveCubicMeters; + } + return Result; +} + +TSharedRef Step2eParametersJson( + const VoxelForgeRedefinedDensityField::FGenerationParameters& P) +{ + TSharedRef Json = MakeShared(); + Json->SetNumberField(TEXT("room_pitch_voxels"), P.RoomPitchVoxels); + Json->SetNumberField(TEXT("room_radius_fraction_min"), P.MinimumRoomRadiusFraction); + Json->SetNumberField(TEXT("room_radius_fraction_max"), P.MaximumRoomRadiusFraction); + Json->SetNumberField(TEXT("room_height_voxels"), P.RoomHeightVoxels); + Json->SetNumberField(TEXT("floor_relief_voxels"), P.FloorReliefVoxels); + Json->SetNumberField(TEXT("tunnel_radius_voxels"), P.TunnelRadiusVoxels); + Json->SetNumberField(TEXT("tunnel_half_height_voxels"), P.TunnelHalfHeightVoxels); + Json->SetNumberField(TEXT("loop_probability"), P.LoopProbability); + Json->SetNumberField(TEXT("vertical_shaft_probability"), P.VerticalShaftProbability); + Json->SetNumberField(TEXT("shaft_radius_voxels"), P.ShaftRadiusVoxels); + Json->SetNumberField(TEXT("pillar_probability"), P.PillarProbability); + Json->SetNumberField(TEXT("ledge_probability"), P.LedgeProbability); + Json->SetNumberField(TEXT("floating_mass_probability"), P.FloatingMassProbability); + Json->SetNumberField(TEXT("stalactite_stalagmite_probability"), P.SpireProbability); + Json->SetNumberField(TEXT("basin_probability"), P.BasinProbability); + Json->SetNumberField(TEXT("basin_depth_voxels"), P.BasinDepthVoxels); + Json->SetNumberField(TEXT("water_level_fraction"), P.WaterLevelFraction); + return Json; +} + TSharedRef Step2dTimingJson(const VoxelForgeRegionEval::FTiming& Timing) { TSharedRef Json = MakeShared(); @@ -3338,6 +3547,405 @@ int32 RunStep2d(const FString& Params) return bPassed ? 0 : 2; } +int32 RunStep2e(const FString& Params) +{ + FExploreArguments Arguments; + FString Error; + if (!ParseArguments(Params, Arguments, Error)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2e] argument error: %s"), *Error); + return 2; + } + int32 Seed = 1337; + FParse::Value(*Params, TEXT("seed="), Seed); + int32 Workers = VoxelForgeRegionEval::WorkerCount; + FParse::Value(*Params, TEXT("regionworkers="), Workers); + Workers = FMath::Clamp(Workers, 1, 32); + FString ResearchOut; + FParse::Value(*Params, TEXT("researchout="), ResearchOut); + if (ResearchOut.IsEmpty()) + { + ResearchOut = FPaths::Combine(Arguments.OutDirectory, TEXT("Research/step2e")); + } + 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("[VoxelForgeStep2e] 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(false); + VoxelForgeRedefinedDensityField::SetParameterSet(-1); + VoxelForgeRedefinedDensityField::SetDetailScale(1.0f); + + TArray OldDensities; + VoxelForgeRegionEval::FTiming OldTiming; + if (!VoxelForgeRegionEval::Evaluate(*World.Generator, Origin, Dimension, + nullptr, true, false, 1.0f, &OldDensities, OldTiming, false, Workers, false)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2e] live comparison ring failed.")); + return 2; + } + + VoxelForgeRedefinedDensityField::SetParameterizedEnabled(true); + TArray> ProfileRows; + TArray> SliceRows; + TArray ProfileMedianMs; + bool bProfilePasses = true; + VoxelForgeRedefinedDensityField::FGenerationParameters HardestParameters; + const TCHAR* HardestName = TEXT("HyperComplex"); + + for (int32 Profile = 0; + Profile < VoxelForgeRedefinedDensityField::GetParameterSetCount(); ++Profile) + { + const TCHAR* ProfileName = nullptr; + VoxelForgeRedefinedDensityField::FGenerationParameters ProfileParameters; + if (!VoxelForgeRedefinedDensityField::GetParameterSetInfo( + Profile, ProfileName, ProfileParameters)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2e] missing profile %d."), Profile); + return 2; + } + VoxelForgeRedefinedDensityField::SetParameterSet(Profile); + TArray Times; + TArray ProfileDensities; + VoxelForgeRegionEval::FTiming LastTiming; + for (int32 Trial = 0; Trial < 3; ++Trial) + { + if (!VoxelForgeRegionEval::EvaluateRedefined( + *World.Generator, Origin, Dimension, Workers, 1.0f, + ProfileDensities, LastTiming, true, true)) + { + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeStep2e] profile %d six-worker ring trial %d failed."), + Profile, Trial + 1); + return 2; + } + Times.Add(LastTiming.WallSeconds * 1000.0); + } + Times.Sort(); + const double MedianMs = Times[Times.Num() / 2]; + ProfileMedianMs.Add(MedianMs); + bProfilePasses &= MedianMs <= 600.0; + + const FStep2eVarietyMetrics Variety = ComputeStep2eVarietyMetrics( + ProfileDensities, Dimension); + TSharedRef Row = MakeShared(); + Row->SetNumberField(TEXT("profile_index"), Profile); + Row->SetStringField(TEXT("name"), ProfileName); + Row->SetObjectField(TEXT("parameters"), Step2eParametersJson(ProfileParameters)); + Row->SetNumberField(TEXT("six_worker_ring_median_ms"), MedianMs); + TArray> TrialValues; + TrialValues.Add(MakeShared(Times[0])); + TrialValues.Add(MakeShared(Times[1])); + TrialValues.Add(MakeShared(Times[2])); + Row->SetArrayField(TEXT("six_worker_ring_trials_ms"), TrialValues); + Row->SetNumberField(TEXT("open_air_fraction"), Variety.OpenFraction); + Row->SetNumberField(TEXT("estimated_surface_area_m2"), Variety.SurfaceAreaSquareMeters); + Row->SetNumberField(TEXT("estimated_surface_area_per_volume_m_inv"), + Variety.SurfaceAreaPerVolume); + Row->SetNumberField(TEXT("vertical_air_adjacency_share"), Variety.VerticalAdjacencyShare); + Row->SetNumberField(TEXT("one_meter_tunnel_neck_share"), Variety.TunnelNeckShare); + Row->SetNumberField(TEXT("one_meter_connected_components"), + Variety.ConnectedComponentsAtOneMeter); + Row->SetNumberField(TEXT("largest_component_coarse_m3"), Variety.LargestComponentCubicMeters); + Row->SetNumberField(TEXT("components_1_to_8_coarse_m3"), Variety.SmallComponentsUpToEightCubicMeters); + Row->SetNumberField(TEXT("components_9_to_64_coarse_m3"), Variety.MediumComponentsNineToSixtyFourCubicMeters); + Row->SetNumberField(TEXT("components_65_to_512_coarse_m3"), Variety.LargeComponentsSixtyFiveToFiveHundredTwelveCubicMeters); + Row->SetNumberField(TEXT("components_over_512_coarse_m3"), Variety.VeryLargeComponentsOverFiveHundredTwelveCubicMeters); + Row->SetNumberField(TEXT("skipped_bricks"), static_cast(LastTiming.SkippedBricks)); + Row->SetNumberField(TEXT("skipped_sample_fraction"), LastTiming.SampleCount > 0 + ? static_cast(LastTiming.SkippedSamples) / LastTiming.SampleCount : 0.0); + + 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_center.png"), TEXT("slice_xz_y0.png"), + TEXT("slice_yz_x0.png"), TEXT("slice_xz_passage.png")}; + FString SliceError; + SaveStep2dSideBySideSlice(FPaths::Combine(SetDirectory, ImageNames[0]), + OldDensities, ProfileDensities, Dimension, TEXT('h'), Dimension / 2, SliceError); + SaveStep2dSideBySideSlice(FPaths::Combine(SetDirectory, ImageNames[1]), + OldDensities, ProfileDensities, Dimension, TEXT('x'), Dimension / 2, SliceError); + SaveStep2dSideBySideSlice(FPaths::Combine(SetDirectory, ImageNames[2]), + OldDensities, ProfileDensities, 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]), + OldDensities, ProfileDensities, Dimension, TEXT('x'), PassageIndex, SliceError); + if (!SliceError.IsEmpty()) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2e] 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("today's live field on left; step 2e parameterized field on right; light=air, dark=solid, cyan=near zero")); + SliceRows.Add(MakeShared(Slice)); + } + if (Profile == VoxelForgeRedefinedDensityField::GetParameterSetCount() - 1) + { + HardestParameters = ProfileParameters; + HardestName = ProfileName; + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeStep2e] profile=%d/%s ring_ms=%.3f open=%.3f area=%.1f m2 components=%d"), + Profile, ProfileName, MedianMs, Variety.OpenFraction, + Variety.SurfaceAreaSquareMeters, Variety.ConnectedComponentsAtOneMeter); + } + + const int32 HardestProfile = VoxelForgeRedefinedDensityField::GetParameterSetCount() - 1; + VoxelForgeRedefinedDensityField::SetParameterSet(HardestProfile); + TArray HardestFullDensity; + TArray RepeatFullDensity; + TArray OneWorkerDensity; + TArray SmoothDensity; + VoxelForgeRegionEval::FTiming HardestFullTiming; + VoxelForgeRegionEval::FTiming RepeatTiming; + VoxelForgeRegionEval::FTiming OneWorkerTiming; + VoxelForgeRegionEval::FTiming SmoothTiming; + const bool bHardestFullOk = VoxelForgeRegionEval::EvaluateRedefined( + *World.Generator, Origin, Dimension, Workers, 1.0f, + HardestFullDensity, HardestFullTiming, false, true); + const bool bRepeatOk = bHardestFullOk && VoxelForgeRegionEval::EvaluateRedefined( + *World.Generator, Origin, Dimension, Workers, 1.0f, + RepeatFullDensity, RepeatTiming, false, true); + const bool bOneWorkerOk = bHardestFullOk && VoxelForgeRegionEval::EvaluateRedefined( + *World.Generator, Origin, Dimension, 1, 1.0f, + OneWorkerDensity, OneWorkerTiming, false, true); + const bool bSmoothOk = bHardestFullOk && VoxelForgeRegionEval::EvaluateRedefined( + *World.Generator, Origin, Dimension, Workers, 0.0f, + SmoothDensity, SmoothTiming, false, true); + if (!bHardestFullOk || !bRepeatOk || !bOneWorkerOk || !bSmoothOk) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForgeStep2e] hardest-set control pass failed.")); + return 2; + } + + int64 DetailParitySamples = 0; + double DetailParityMaxError = 0.0; + for (int64 Index = 0; Index < TotalSamples; Index += 2047) + { + const int32 X = static_cast(Index % Dimension); + const int32 Y = static_cast((Index / Dimension) % Dimension); + const int32 Z = static_cast(Index + / (static_cast(Dimension) * Dimension)); + const float Direct = VoxelForgeRedefinedDensityField::EvaluateParameterized( + *World.Generator, static_cast(Origin.X + X), + static_cast(Origin.Y + Y), static_cast(Origin.Z + Z), nullptr, 1.0f); + DetailParityMaxError = FMath::Max(DetailParityMaxError, + static_cast(FMath::Abs(Direct - HardestFullDensity[static_cast(Index)]))); + ++DetailParitySamples; + } + const bool bDetailParity = DetailParityMaxError <= 1.0e-5; + const FStep2dSurfaceSpectrum OldSpectrum = ComputeStep2dSpectrum(OldDensities, Dimension); + const FStep2dSurfaceSpectrum NewSpectrum = ComputeStep2dSpectrum(HardestFullDensity, Dimension); + const FStep2dSurfaceSpectrum SmoothSpectrum = ComputeStep2dSpectrum(SmoothDensity, Dimension); + const FStep2dBoundaryStats BoundaryStats = ComputeStep2dBoundaryStats( + HardestFullDensity, Dimension, Origin, + FMath::RoundToInt(HardestParameters.RoomPitchVoxels)); + const FStep2dBrickProofAudit ProofAudit = AuditStep2dBrickProofs( + *World.Generator, Origin, Dimension, true); + const uint64 HardestHash = VoxelForgeRegionEval::HashQuantized(HardestFullDensity); + const uint64 RepeatHash = VoxelForgeRegionEval::HashQuantized(RepeatFullDensity); + const uint64 OneWorkerHash = VoxelForgeRegionEval::HashQuantized(OneWorkerDensity); + const bool bDeterminism = HardestHash == RepeatHash && HardestHash == OneWorkerHash + && HardestFullTiming.RawFloatHash == RepeatTiming.RawFloatHash + && HardestFullTiming.RawFloatHash == OneWorkerTiming.RawFloatHash; + const bool bDetailAtLeastLive = NewSpectrum.RMSLag1 >= OldSpectrum.RMSLag1 + && NewSpectrum.RMSLag2 >= OldSpectrum.RMSLag2; + const bool bSmoothingControlCaught = SmoothSpectrum.RMSLag1 < NewSpectrum.RMSLag1 * 0.80 + || SmoothSpectrum.RMSLag2 < NewSpectrum.RMSLag2 * 0.80; + const bool bBoundaryPass = BoundaryStats.BrickBoundarySamples > 0 + && BoundaryStats.InteriorSamples > 0 + && BoundaryStats.ChunkBoundarySamples > 0 + && BoundaryStats.FeatureCellBoundarySamples > 0 + && BoundaryStats.BrickJumpRms <= BoundaryStats.InteriorJumpRms * 2.0 + 0.25 + && BoundaryStats.ChunkBoundaryJumpRms <= BoundaryStats.InteriorJumpRms * 2.0 + 0.25 + && BoundaryStats.FeatureCellJumpRms + <= BoundaryStats.FeatureCellInteriorJumpRms * 2.0 + 0.25; + const bool bBoundAudit = ProofAudit.CorrectlySkippedBricksAudited > 0 + && ProofAudit.CorrectProofSamplesChecked > 0 + && ProofAudit.CorrectProofViolations == 0; + const bool bUndersizedBoundCaught = ProofAudit.DeliberateBoundDisagreements > 0; + double WorstMedianMs = 0.0; + for (const double MedianMs : ProfileMedianMs) + { + WorstMedianMs = FMath::Max(WorstMedianMs, MedianMs); + } + const bool bSpeedPass = Workers == 6 && bProfilePasses && WorstMedianMs <= 600.0; + const bool bPassed = bSpeedPass && bDeterminism && bDetailAtLeastLive + && bSmoothingControlCaught && bDetailParity && bBoundaryPass + && bBoundAudit && bUndersizedBoundCaught + && HardestFullTiming.NonFiniteSamples == 0; + + TSharedRef Report = MakeShared(); + Report->SetStringField(TEXT("status"), bPassed ? TEXT("pass") : TEXT("fail")); + Report->SetBoolField(TEXT("pass"), bPassed); + Report->SetBoolField(TEXT("worst_profile_meets_600ms"), bSpeedPass); + 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->SetNumberField(TEXT("old_live_ring_ms"), OldTiming.WallSeconds * 1000.0); + Report->SetStringField(TEXT("old_live_ring_raw_float_hash"), FString::Printf( + TEXT("%016llx"), static_cast(OldTiming.RawFloatHash))); + Report->SetStringField(TEXT("field_definition"), + TEXT("Seeded jittered XY chamber cells form a deterministic origin-rooted spanning tree; " + "selected loops, horizontal tunnels and full-strate shafts open the network. Each " + "chamber can add pillars, annular ledges, paired stalactites/stalagmites, floating " + "rock masses and a bowl floor with an explicit water level. Passage pads and anchor " + "tunnels join the authored inter-strate route to the generated network.")); + Report->SetStringField(TEXT("parameter_space"), + TEXT("Eight named profiles can be assigned per strate from a seed hash (-1 mode), or " + "forced for measurement. Each profile rolls cell placement and feature choices from " + "the world seed and strate index; see parameters and documented ranges in the report.")); + Report->SetStringField(TEXT("resolution_contract"), + TEXT("One exact field sample per 25 cm lattice point. The four existing 8/4/2/1-sample " + "detail bands are evaluated for every potentially surface-affecting point; no coarse " + "field is interpolated onto the lattice. 8-cubed skips require the final macro field " + "Lipschitz interval plus the full 5.25-unit detail supremum and marching-cubes halo.")); + Report->SetStringField(TEXT("finer_lattice_scaling"), + TEXT("For 12.5 cm voxels, double room pitch, room height, tunnel/feature dimensions and " + "the broad-to-medium noise periods in sample units to preserve meter-scale shapes. " + "Keep the 1-sample detail band or add a finer octave. The lattice grows 8x for a fixed " + "world volume; surface bricks grow roughly with area while certified interior bricks " + "remain cheap. This scale has not been timed.")); + Report->SetStringField(TEXT("variety_metric_method"), + TEXT("Open fraction uses every ring sample. Surface area estimates sign-changing lattice " + "edges times 0.25 m squared. Chamber-size bins, connected components and tunnel-neck " + "share use a 1 m downsampled six-neighbor occupancy grid; verticality is the share of " + "open 1-cell adjacencies that point vertically.")); + Report->SetStringField(TEXT("water_geometry_limit"), + TEXT("Basin profiles generate the bowl floor and water-level parameter; this density field " + "does not render or simulate water.")); + Report->SetNumberField(TEXT("worst_six_worker_ring_median_ms"), WorstMedianMs); + Report->SetNumberField(TEXT("target_aim_ms"), 350.0); + Report->SetStringField(TEXT("hardest_profile"), HardestName); + Report->SetArrayField(TEXT("profiles"), ProfileRows); + Report->SetArrayField(TEXT("slices"), SliceRows); + Report->SetStringField(TEXT("slice_directory"), ResearchOut); + + TSharedRef Detail = MakeShared(); + Detail->SetNumberField(TEXT("live_surface_band_samples"), static_cast(OldSpectrum.SurfaceBandSamples)); + Detail->SetNumberField(TEXT("new_surface_band_samples"), static_cast(NewSpectrum.SurfaceBandSamples)); + Detail->SetNumberField(TEXT("live_curvature_rms_lag1"), OldSpectrum.RMSLag1); + Detail->SetNumberField(TEXT("new_curvature_rms_lag1"), NewSpectrum.RMSLag1); + Detail->SetNumberField(TEXT("smooth_control_curvature_rms_lag1"), SmoothSpectrum.RMSLag1); + Detail->SetNumberField(TEXT("live_curvature_rms_lag2"), OldSpectrum.RMSLag2); + Detail->SetNumberField(TEXT("new_curvature_rms_lag2"), NewSpectrum.RMSLag2); + Detail->SetNumberField(TEXT("smooth_control_curvature_rms_lag2"), SmoothSpectrum.RMSLag2); + Detail->SetNumberField(TEXT("detail_raw_parity_samples"), DetailParitySamples); + Detail->SetNumberField(TEXT("detail_raw_parity_max_error"), DetailParityMaxError); + Detail->SetBoolField(TEXT("detail_at_least_live_spectrum"), bDetailAtLeastLive); + Detail->SetBoolField(TEXT("deliberate_zero_detail_smoothing_detected"), bSmoothingControlCaught); + Detail->SetBoolField(TEXT("direct_vs_brick_detail_parity"), bDetailParity); + Report->SetObjectField(TEXT("detail_check"), Detail); + + TSharedRef Boundary = MakeShared(); + Boundary->SetNumberField(TEXT("brick_boundary_gradient_jump_rms"), BoundaryStats.BrickJumpRms); + Boundary->SetNumberField(TEXT("interior_gradient_jump_rms"), BoundaryStats.InteriorJumpRms); + Boundary->SetNumberField(TEXT("chunk_boundary_gradient_jump_rms"), BoundaryStats.ChunkBoundaryJumpRms); + Boundary->SetNumberField(TEXT("feature_cell_boundary_gradient_jump_rms"), BoundaryStats.FeatureCellJumpRms); + Boundary->SetNumberField(TEXT("feature_cell_interior_gradient_jump_rms"), BoundaryStats.FeatureCellInteriorJumpRms); + Boundary->SetBoolField(TEXT("boundary_artifact_check_pass"), bBoundaryPass); + Report->SetObjectField(TEXT("boundary_artifact_check"), Boundary); + + TSharedRef Proofs = MakeShared(); + Proofs->SetNumberField(TEXT("correctly_skipped_bricks_audited"), + static_cast(ProofAudit.CorrectlySkippedBricksAudited)); + Proofs->SetNumberField(TEXT("final_field_samples_checked_including_halo"), + static_cast(ProofAudit.CorrectProofSamplesChecked)); + Proofs->SetNumberField(TEXT("correct_proof_sign_violations"), + static_cast(ProofAudit.CorrectProofViolations)); + Proofs->SetNumberField(TEXT("undersized_bound_claims_checked"), + static_cast(ProofAudit.DeliberatelyUndersizedBoundClaimsChecked)); + Proofs->SetNumberField(TEXT("wrong_sign_samples_caught_by_undersized_bound"), + static_cast(ProofAudit.DeliberateBoundDisagreements)); + Proofs->SetBoolField(TEXT("sound_supremum_audit_pass"), bBoundAudit); + Proofs->SetBoolField(TEXT("deliberate_undersized_bound_failure_caught"), bUndersizedBoundCaught); + Report->SetObjectField(TEXT("brick_bound_audit"), Proofs); + + TSharedRef Determinism = MakeShared(); + Determinism->SetStringField(TEXT("six_worker_run_1_quantized"), FString::Printf( + TEXT("%016llx"), static_cast(HardestHash))); + Determinism->SetStringField(TEXT("six_worker_run_2_quantized"), FString::Printf( + TEXT("%016llx"), static_cast(RepeatHash))); + Determinism->SetStringField(TEXT("one_worker_quantized"), FString::Printf( + TEXT("%016llx"), static_cast(OneWorkerHash))); + Determinism->SetStringField(TEXT("six_worker_run_1_raw_float"), FString::Printf( + TEXT("%016llx"), static_cast(HardestFullTiming.RawFloatHash))); + Determinism->SetStringField(TEXT("six_worker_run_2_raw_float"), FString::Printf( + TEXT("%016llx"), static_cast(RepeatTiming.RawFloatHash))); + Determinism->SetStringField(TEXT("one_worker_raw_float"), FString::Printf( + TEXT("%016llx"), static_cast(OneWorkerTiming.RawFloatHash))); + Determinism->SetBoolField(TEXT("same_across_two_runs_and_1_vs_6_workers"), bDeterminism); + Report->SetObjectField(TEXT("determinism"), Determinism); + Report->SetBoolField(TEXT("entrance_exit_walkability_test_added"), true); + Report->SetBoolField(TEXT("inter_strate_passage_open_space_test_added"), true); + Report->SetStringField(TEXT("functional_test_filter"), + TEXT("VoxelForge.ParameterizedGeneration.TraversalSeeds")); + + FString JsonText; + const TSharedRef> Writer = TJsonWriterFactory<>::Create(&JsonText); + const FString ReportPath = FPaths::Combine(ResearchOut, TEXT("step2e_report.json")); + const FString LaunchReportPath = FPaths::Combine(Arguments.OutDirectory, TEXT("step2e_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("[VoxelForgeStep2e] report write failed: %s"), *ReportPath); + return 2; + } + VoxelForgeRedefinedDensityField::SetParameterizedEnabled(false); + VoxelForgeRedefinedDensityField::SetDetailScale(1.0f); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeStep2e] pass=%s speed600=%s worst_ms=%.3f detail=%s boundary=%s " + "bound=%s deterministic=%s report=%s"), + bPassed ? TEXT("yes") : TEXT("no"), bSpeedPass ? TEXT("yes") : TEXT("no"), + WorstMedianMs, bDetailAtLeastLive ? TEXT("pass") : TEXT("fail"), + bBoundaryPass ? TEXT("pass") : TEXT("fail"), bBoundAudit ? TEXT("pass") : TEXT("fail"), + bDeterminism ? TEXT("pass") : TEXT("fail"), *ReportPath); + return bPassed ? 0 : 2; +} + class FGeneratorDensitySampler final : public IVoxelStrateDensitySampler { public: @@ -10475,6 +11083,10 @@ int32 RunBatch(const FString& Params) int32 UVoxelForgeExploreCommandlet::Main(const FString& Params) { + if (FParse::Param(*Params, TEXT("step2e"))) + { + return RunStep2e(Params); + } if (FParse::Param(*Params, TEXT("step2d"))) { return RunStep2d(Params); diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.cpp index e47f2dd..6fe4a99 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.cpp @@ -157,7 +157,8 @@ namespace VoxelForgeRegionEval float DetailScale, TArray& OutDensities, FTiming& OutTiming, - bool bEnableBrickSkips) + bool bEnableBrickSkips, + bool bParameterized) { OutTiming = FTiming(); OutDensities.Reset(); @@ -250,6 +251,7 @@ namespace VoxelForgeRegionEval const bool bHaveCpuStart = GetCurrentThreadCpuSeconds(CpuStart); TArray BrickDetail; VoxelForgeRedefinedDensityField::FBrickEvaluationContext BrickContext; + VoxelForgeRedefinedDensityField::FParameterizedBrickContext ParameterizedBrickContext; for (;;) { const int32 BatchBegin = NextBrickIndex.fetch_add( @@ -268,10 +270,13 @@ namespace VoxelForgeRegionEval VoxelForgeRedefinedDensityField::EBrickFallbackReason::UnsupportedState; ++WorkerMacroChecks[WorkerIndex]; const double ClassifyStart = FPlatformTime::Seconds(); - const bool bCanSkip = bEnableBrickSkips - && VoxelForgeRedefinedDensityField::TryClassifyBrick( + const bool bCanSkip = bEnableBrickSkips && (bParameterized + ? VoxelForgeRedefinedDensityField::TryClassifyParameterizedBrick( Generator, Brick.Origin, Brick.Size, bUniformAir, DetailScale, - &bEvaluatedMacroCentre, &FallbackReason); + &bEvaluatedMacroCentre, &FallbackReason) + : VoxelForgeRedefinedDensityField::TryClassifyBrick( + Generator, Brick.Origin, Brick.Size, bUniformAir, DetailScale, + &bEvaluatedMacroCentre, &FallbackReason)); WorkerClassify[WorkerIndex] += FPlatformTime::Seconds() - ClassifyStart; WorkerMacroCentreEvaluations[WorkerIndex] += bEvaluatedMacroCentre ? 1u : 0u; if (bEnableBrickSkips && !bCanSkip) @@ -304,7 +309,12 @@ namespace VoxelForgeRegionEval const double EvaluationStart = FPlatformTime::Seconds(); VoxelForgeRedefinedDensityField::BuildDetailBrick( Generator, Brick.Origin, Brick.Size, DetailScale, BrickDetail, - &BrickContext); + bParameterized ? nullptr : &BrickContext); + if (bParameterized) + { + VoxelForgeRedefinedDensityField::BuildParameterizedBrickContext( + Generator, Brick.Origin, Brick.Size, ParameterizedBrickContext); + } for (int32 Z = 0; Z < Brick.Size.Z; ++Z) for (int32 Y = 0; Y < Brick.Size.Y; ++Y) for (int32 X = 0; X < Brick.Size.X; ++X) @@ -315,11 +325,16 @@ namespace VoxelForgeRegionEval const int64 LatticeIndex = LinearIndex( Brick.Lattice.X + X, Brick.Lattice.Y + Y, Brick.Lattice.Z + Z, Dimension); - OutDensities[static_cast(LatticeIndex)] = - VoxelForgeRedefinedDensityField::EvaluateWithDetailValue( + const float DetailValue = BrickDetail[ + (Z * Brick.Size.Y + Y) * Brick.Size.X + X]; + OutDensities[static_cast(LatticeIndex)] = bParameterized + ? VoxelForgeRedefinedDensityField::EvaluateParameterizedWithDetailValue( Generator, static_cast(WorldX), static_cast(WorldY), - static_cast(WorldZ), - BrickDetail[(Z * Brick.Size.Y + Y) * Brick.Size.X + X], + static_cast(WorldZ), DetailValue, nullptr, DetailScale, + &ParameterizedBrickContext) + : VoxelForgeRedefinedDensityField::EvaluateWithDetailValue( + Generator, static_cast(WorldX), static_cast(WorldY), + static_cast(WorldZ), DetailValue, nullptr, DetailScale, &BrickContext); ++WorkerEvaluated[WorkerIndex]; } diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.h b/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.h index 3f7bcfc..850fdf7 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.h +++ b/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.h @@ -144,7 +144,8 @@ namespace VoxelForgeRegionEval float DetailScale, TArray& OutDensities, FTiming& OutTiming, - bool bEnableBrickSkips = true); + bool bEnableBrickSkips = true, + bool bParameterized = false); /** Count exact room/tunnel/core/passage candidate visits with instrumentation enabled. */ FCandidateVisits CountCandidates( diff --git a/Tools/VoxelForgeTest.ps1 b/Tools/VoxelForgeTest.ps1 index ed187fb..dad9ae3 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')] + [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')] [string]$Scenario = 'canonical', [switch]$Build, [switch]$EnableRender, @@ -575,7 +575,7 @@ function Sync-OwnerAssets { $null = Copy-Item -LiteralPath $source -Destination $target -Force -PassThru [void]$rows.Add([ordered]@{ name = $assetName - used_by_scenario = $Scenario -in @('owner', 'probe', 'parity', 'tests', 'surface-fall', 'crossing', 'gate-stress', 'visual-edit', 'visual-lod', 'visual-gap-walk', 'visual-gap-inject', 'dig-motion', 'horizon', 'horizon-render', 'determinism', 'region-eval', 'step2d') + 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') source_path = $source source_sha256 = Get-Sha256 $source staged_path = $target @@ -623,7 +623,7 @@ function Get-AssetEvidence { $target = Join-Path $HostRoot "Content\VoxelForge\$assetName.uasset" [void]$rows.Add([ordered]@{ name = $assetName - used_by_scenario = $Scenario -in @('owner', 'probe', 'parity', 'tests', 'surface-fall', 'crossing', 'gate-stress', 'visual-edit', 'visual-lod', 'visual-gap-walk', 'visual-gap-inject', 'dig-motion', 'horizon', 'horizon-render', 'determinism', 'region-eval', 'step2d') + 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') source_path = $source source_sha256 = Get-Sha256 $source staged_path = $target @@ -1499,6 +1499,33 @@ try { } } } + 'step2e' { + $step2eLog = Join-Path $RunRoot 'Logs\step2e.log' + $args = New-CommonArguments 'commandlet' $step2eLog (Merge-Cvars $baseCvars) + $args += New-CommandletArguments $commandletOut 'walk,export' ($Assets -eq 'owner') $false 128 + $researchOut = Join-Path $PluginRoot "Saved\Research\step2e\$Label" + $args += @('-step2e', '-regionworkers=6', ('-researchout="{0}"' -f $researchOut)) + $commandletLedger = Invoke-UnrealLaunch 'step2e-commandlet' $args ` + $step2eLog (Join-Path $RunRoot 'Logs\step2e.stdout.log') ` + (Join-Path $RunRoot 'Logs\step2e.stderr.log') + $step2eReportPath = Join-Path $researchOut 'step2e_report.json' + $step2eReport = Read-JsonFile $step2eReportPath + if ($null -eq $step2eReport) { + $CommandletMetrics = [ordered]@{ status = 'missing'; output = $researchOut; report = $step2eReportPath } + Add-Failure "step2e did not produce $step2eReportPath" + } else { + $CommandletMetrics = [ordered]@{ + status = if ((Get-JsonValue $step2eReport 'pass' $false) -and $commandletLedger.status -eq 'passed') { 'passed' } else { 'failed' } + output = $researchOut + report = $step2eReportPath + worst_profile_meets_600ms = Get-JsonValue $step2eReport 'worst_profile_meets_600ms' $false + result = $step2eReport + } + if (-not (Get-JsonValue $step2eReport 'pass' $false)) { + Add-Failure 'step2e profile speed, detail, boundary, bound-audit, or determinism gate failed' + } + } + } 'symbolize-stacks' { if (([string]::IsNullOrWhiteSpace($StackSamplesPath)) -or -not (Test-Path -LiteralPath $StackSamplesPath -PathType Leaf)) { throw "-StackSamplesPath must name an existing raw sampler CSV." @@ -1904,6 +1931,7 @@ $result = [ordered]@{ probe_amplitudes = if ($CommandletMetrics.Contains('probe_amplitudes')) { $CommandletMetrics.probe_amplitudes } else { [ordered]@{} } commandlet = $CommandletMetrics region_eval = if ($Scenario -eq 'region-eval') { $CommandletMetrics } else { $null } + step2e = if ($Scenario -eq 'step2e') { $CommandletMetrics } else { $null } game = $GameMetrics horizon = $HorizonMetrics horizon_render = $HorizonRenderMetrics