diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 01573a1..aec094a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -46,23 +46,27 @@ what the world IS, and both paths compute it. `UVoxelGenerator::OriginSpineRadius` ← `VoxelSettings::OriginSpineRadius`. Called before every `ApplyBoundarySeal`. - `VerticalShafts` treats that structural column as a **connector endpoint**, not as a second - density primitive: while rebuilding its memoised 3×3 shaft neighbourhood, an origin-adjacent - query adds `(0,0,OriginSpineRadius)` to the local shaft set for pair generation only. The - existing `VoxelHash::Pair` gate and `Spacing*1.6` neighbour cutoff remain in force, so the link - is a pure function of the shaft cell and seed and is independent of evaluation order. Spine - links use a radius above the proven `sup|FBM|=1.5` roughness envelope (`max(ConnectorRadius, - SurfaceRoughness*VOXEL_NOISE_SCALE*1.5 + 1)`); the ordinary connector radius and ordinary shaft - roughness are unchanged. `BuildVerticalShaftStack` receives the same runtime spine radius and - mirrors this source rule; `VF_ApplyOriginSpine` remains the sole owner of the column itself. + density primitive. A thread-local rebuild collects a 7×7 cell window once, emits tree links only + for the inner 3×3 cells, and resolves each parent from its complete 5×5 candidate window. Each + shaft chooses the nearest shaft whose jittered distance to (0,0) is strictly smaller; ties use + the lowest cell index. A local minimum links directly to the spine. The wider collection is + therefore fully contained for every emitted shaft and the parent is independent of chunk or + evaluation-cell boundaries. The existing probabilistic `VoxelHash::Pair` links and + `Spacing*1.6` cutoff remain as texture and loops; they are additive, not the connectivity law. + Tree links use a radius strictly above the proven `sup|FBM|=1.5` roughness envelope + (`max(ConnectorRadius, SurfaceRoughness*VOXEL_NOISE_SCALE*1.5 + 1)`). + `BuildVerticalShaftStack` mirrors the same tree, random links, windows, hashes, and radius; + `VF_ApplyOriginSpine` remains the sole owner of the vertical column itself. - Descent is **player-dug** through the thin seals at (0,0). The single auto-opened connection is the **surface entry shaft** at (0,0) through the top of strate 0 (`GeneratePassages`, `bOpenSurfaceEntry`). - **Hybrid extras:** auto-carved *shortcut* passages per boundary, placed away from (0,0). Now fully **per-strate** — see §8.8 (the upper strate's `PassageConfig` drives count/style/shape). -This connector is local topology, not a promise that independently placed shortcut mouths are -globally connected. The acceptance measurement for `VerticalShafts` remains the actual -arrival→departure flood-fill at the passage endpoints. +The tree is a construction guarantee for the shaft field: every seeded shaft has one inward +edge, and every chain strictly decreases distance to the origin until it reaches a local minimum +that links to the spine. It does not alter passage placement. The acceptance measurement remains +the actual arrival→departure flood-fill at the independently placed passage endpoints. ### 8.3 Disturbance layer (the "wow" post-process) `FStrateDisturbanceParams` (on the definition, all archetypes). `ApplyDisturbances` @@ -337,10 +341,13 @@ driven by `EditorBrush*` props. no loads AND no LOD mismatches outstanding. - **SDF cache** (`GetDensityWithParams`): search-BOX validity, not chunk-key — gradient ±1 sampling must not thrash the (expensive) rebuild. -- **VerticalShafts field cache**: shaft rolls and pair decisions, including the structural-spine - connector endpoint, are rebuilt only when the thread-local centre cell or a geometry-affecting - parameter changes. They must not be re-hashed in the per-voxel loop; the operator-stack source - follows the same cache contract. +- **VerticalShafts field cache**: shaft rolls and connector decisions, including the structural + tree and structural-spine connector endpoint, are rebuilt only when the thread-local centre cell + or a geometry-affecting parameter changes. Each rebuild rolls 49 cells (7×7); it stores/emits + only the inner 3×3 shafts and their capsules for the per-voxel loop. The complete 5×5 parent + windows are contained by that 7×7 collection, so no parent decision depends on the evaluation + cell. No cell or pair hash may move into the per-voxel loop; the operator-stack source follows + the same cache contract. - **Per-chunk param cache** in `GetDensityAt`: GenType + param struct + disturbance cached thread-locally by `(DensityCacheOwnerId, ChunkCoord, LayoutVersion)`; the process-unique owner ID prevents cross-world reuse while adding only one `uint64` compare per voxel. Don't remove the owner diff --git a/CODEMAP.md b/CODEMAP.md index 2be0fbe..ac04cde 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -151,7 +151,7 @@ bit. They are port-correctness oracles, not fidelity checks: the acceptance bar | `VoxelDensityOps::MakeSdfFill` | 2 | The same op with `Sign = +1` — FloatingIslands' `Density += Fill·Base·2`. ±1 multiplication is exact in IEEE-754, so the carve path is bit-for-bit unchanged by the generalisation. | | `VoxelDensityOps::MakeSlabVoidSource` | 1 | Floor surface + ceiling surface → void field. **XY-pure** since §3.1, which is what gives it an **exact `ClassifyBox` with no sampling**: FBM's `[-1,1]` contract bounds both surfaces into known Z bands. Serves FlatPlain **and** CrystalChamber. | | `VoxelDensityOps::MakeGridColumnMod` | 3 | Infinite-height cylinders on a world grid, 3×3 cell memo. Adds solid only ⇒ `FillOnly` when a column reaches the box, `Identity` otherwise — and that `Identity` is what lets the source's `AllAir` verdict survive. | -| `FShaftFieldSource` (internal) | 1 | VerticalShafts' memoised 3×3 shaft/capsule source. In origin-adjacent query cells it adds the runtime `(0,0)` spine radius as a connector-only endpoint; `VoxelHash::Pair` and the existing cutoff/gate remain deterministic. Spine capsules use a `sup|FBM|=1.5` roughness-safe radius, while the structural post still owns the column and the ledge modifier ignores the synthetic endpoint. `EffectOverBox` mirrors the rule conservatively. | +| `FShaftFieldSource` (internal) | 1 | VerticalShafts' memoised inner 3×3 shaft/capsule source. Each rebuild collects 7×7 cells so every emitted inner shaft has its complete 5×5 candidate window; the nearest strictly more-central shaft is the deterministic parent, and a local minimum links to the runtime `(0,0)` spine. Existing `VoxelHash::Pair`/`Spacing*1.6` links remain additive texture/loops. Tree and spine capsules use a radius strictly above `sup|FBM|=1.5`; the structural post still owns the column and the ledge modifier ignores the synthetic endpoint. `EffectOverBox` mirrors both tree and random capsules conservatively. | | `VoxelDensityOps::BuildSlabStack` | — | 5 ops, **no branch on archetype**: FlatPlain and CrystalChamber differ only in defaults, exactly as `GetSlabDensity` already had it. 8 archetypes → 7. | | `FSurfaceColumnSource` (internal) | 1 | The bridge between the two spaces: consumes the ground + sky-cap **height** stacks and produces density. `IsXYPure()` **false** — the heights are XY-pure, a distance to them never is. Owns a **six-box spatial LRU** of direct-indexed per-column cells, keyed by `PrepareChunk` on `(StrateBottomWorldZ, LayoutVersion, Seed, ParamsFingerprint)` so it is **shared down the whole vertical strate stack**, exactly like `GSurfColCache`. Six 81×81 boxes preserve hot columns across interleaved regions at roughly 0.79 MiB TLS before padding (more memory, fewer whole-cache recenter/recompute misses). Fractional XY remains direct-compute. | | `VoxelDensityOps::BuildSurfaceStack` | — | SurfaceWorld, complete: column + overhang + 3 structural, plus biome blending when `PerBiomeParams` is non-empty. Takes ownership of an `IVoxelBiomeField`. | @@ -481,13 +481,13 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor | `VoxelForgeCrossPlatformTest.cpp` | `VoxelForge.Determinism.CrossPlatformDigest` | SHAPE digest (sign of density = the world) + FIELD digest (bit-for-bit) over a fixed integer grid, plus `NearIso` bounding how many samples could flip sign. Reports rather than asserts until pinned. Run on Windows and Linux and compare. | | `VoxelForgeOpStackSlabTest.cpp` | `VoxelForge.OpStack.SlabEquivalence` | **Phase 2's first port.** The same 5-op slab stack vs `GetSlabDensity` over 20k points, run twice — FlatPlain **and** CrystalChamber — which is what demonstrates the two archetypes really are one op. Plus window-invariance and box-verdict brute force. Compares against the reference **as it is now** (post Z-term removal), so green = pure refactor and any visual delta is attributable to §3.1 alone. | | `VoxelForgeOpStackTunnelTest.cpp` | `VoxelForge.OpStack.TunnelNetworkSpineEquivalence` | **Stage A of the last port.** Zeroes the 13 detail-op amplitudes so the *original* takes the path stage A ported — that is what makes an incomplete stack verifiable now. Samples in **clusters** (24 chunks × 250 points), because the SDF cache rebuilds when a query leaves its box and uniform sampling would rebuild per point on both paths. Check 3 (two param sets, A/B interleaved) compares each stack **to itself alone, never to the original** — the original would fail it, see AUDIT §C2. Asserts **zero** box verdicts, which is the honest stage-A result. | -| `VoxelForgeOpStackShaftTest.cpp` | `VoxelForge.OpStack.VerticalShaftEquivalence` | The port that tests **reuse**, not fidelity: three of the five ops are Maze's, unchanged. Forces connectors + ledges on, because both are off or negligible at defaults and a resting param is an untested operator. **Fixed 2026-07-29:** its `EffectOverBox` used to return `CarveOnly` because a shaft merely *existed* within a `Spacing*1.6` halo — true almost everywhere at `ShaftSpacing 55 / ShaftDensity 0.6`, hence **0 of 60** tiles. It now rebuilds the connectors the way `GetCells` does (same row-major cell order ⇒ same `VoxelHash::Pair`, so symmetry of `Pair()` is not assumed; sweeping `[box cells] ± 1` is a superset of any 3×3's pairs) and tests the real capsule, with **Z exact** (horizontal capsule at `Zc`) and XY conservative. The sampler was also widened from ±48 voxels to ±440 — it was under one `ShaftSpacing`, the same trap as the tunnel test's ±32-vs-80. Every proved tile is brute-forced over its full lattice. | +| `VoxelForgeOpStackShaftTest.cpp` | `VoxelForge.OpStack.VerticalShaftEquivalence` | The port that tests **reuse**, not fidelity: three of the five ops are Maze's, unchanged. Forces connectors + ledges on, because both are off or negligible at defaults and a resting param is an untested operator. The source and mirror now share a deterministic inner-3×3-emitted drainage tree whose parents come from rebuild-only 7×7/5×5 windows, plus the old random links. **Fixed 2026-07-29:** its `EffectOverBox` used to return `CarveOnly` because a shaft merely *existed* within a `Spacing*1.6` halo — true almost everywhere at `ShaftSpacing 55 / ShaftDensity 0.6`, hence **0 of 60** tiles. It now tests tree and random capsules (same row-major cell order ⇒ same `VoxelHash::Pair`, so symmetry of `Pair()` is not assumed), with **Z exact** and XY conservative. The sampler was also widened from ±48 voxels to ±440. Every proved tile is brute-forced over its full lattice, and the test reports rebuild-vs-hot cache cost. | | `VoxelForgeOpStackIslandTest.cpp` | `VoxelForge.OpStack.FloatingIslandEquivalence` | The port that runs the stack **backwards** — void + fill vs rock + carve, same classes with the opposite sign. Counts interior-solid and open-void samples separately (on this archetype an aggregate "N solid" is dominated by the seal bands and says nothing about the islands). Counts `AllSolid` and `AllAir` verdicts **separately** too: `AllAir` is the one no cave archetype could ever prove, and it is the entire perf argument here. | | `VoxelForgeOpStackMazeTest.cpp` | `VoxelForge.OpStack.MazeEquivalence` | **Phase 1's load-bearing test.** The 7-op Maze stack vs `GetMazeDensity` over 20k points (aiming for bit-identity; a side-of-iso disagreement is the hard fail), plus purity across workers and brute force on every box verdict the stack emits. Reports how many tiles the stack can prove uniform — today's `ClassifyTile` proves **zero** for any cave archetype. | | `VoxelForgeStrateParamCoverageTest.cpp` | `VoxelForge.Determinism.StrateParamBlendCoverage` | **The X-macro guard** (added 2026-08-17). `FStrateGenerationParams::Lerp` blends the hand-written `VF_STRATE_PARAM_FIELDS` list, **not** the struct — so a field added to one and not the other compiles, tests green, and silently takes its **default** inside every Gradient/Interleaved transition band. This expands the X-macro a **third** way (after LERP and SNAP), into a name list, and diffs it against the struct's UObject reflection. Pure shape test: no fixture, no world, instant. `GExemptFieldNames` is **empty** — every reflected field is covered today, and any exemption must be written down as a decision. Stakes rise with the world composer, which intends to invent parameter sets through this same `Lerp` (`COMPOSER-NOTES.md`). | | `VoxelForgeLayoutOrderIndependenceTest.cpp` | `VoxelForge.Determinism.LayoutOrderIndependence` | Builds a known transient soft-pointer pool, then rebuilds it in original, reversed, and swapped orders. Requires a non-empty layout and passage set, and compares every slot's definition/Z/height plus passage endpoints, radius, type, control geometry, and bounds bit-for-bit. | | `VoxelForgePassageOpenSpaceTest.cpp` | `VoxelForge.Determinism.PassageLandsInOpenSpace` | Uses the real fixture density path to check every generated inter-strate passage whose destination query answers: a 16-point ring outside the mouth's carve/blend band has at least half its samples in destination air, and the endpoint matches the pure open-point result within the mouth's float envelope. This is a connectivity proxy, not a flood-fill proof. Reports checked passages and false/unanswerable archetypes; fails if it inspects zero passages. | -| `VoxelForgeStrateConnectivityTest.cpp` | `VoxelForge.Generation.StrateConnectivity` / `VoxelForge.Generation.StrateConnectivityRefinement` | Bounded strate metrics with density-polarity and solid-gap controls, deterministic route rechecks, and refinement sweeps. The refinement test explicitly checks margins 0/2/4/8 at step 2/radius 192, reports the `(0,0)` spine's full-window/interior vertical run and component for all eight fixture strates plus arrival/departure→spine and arrival→departure enums, then repeats those spine/mouth measurements across 16 VerticalShafts seeds and prints the post-fix acceptance count. | +| `VoxelForgeStrateConnectivityTest.cpp` | `VoxelForge.Generation.StrateConnectivity` / `VoxelForge.Generation.StrateConnectivityRefinement` / `VoxelForge.Generation.VerticalShaftSeamFreedom` | Bounded strate metrics with density-polarity and solid-gap controls, deterministic route rechecks, and refinement sweeps. The refinement test explicitly checks margins 0/2/4/8 at step 2/radius 192, reports components/largest-component share, the `(0,0)` spine's full-window/interior vertical run and component, plus arrival/departure→spine and arrival→departure enums across 16 VerticalShafts seeds. The seam test re-evaluates cell-boundary positions after warming distinct neighbouring chunk contexts in both legacy and operator-stack paths. | ## 4. The density pipeline (most-edited hot path) diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackShaftTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackShaftTest.cpp index 6345df1..73ed705 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackShaftTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackShaftTest.cpp @@ -22,6 +22,7 @@ #include "Misc/AutomationTest.h" #include "Async/ParallelFor.h" +#include "HAL/PlatformTime.h" #include "HAL/PlatformMisc.h" #include "VoxelForgeTestFixture.h" @@ -173,8 +174,9 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters) //========================================================================= // 2. INVARIANCE DE FENÊTRE //========================================================================= - // La source garde un cache 3×3 `thread_local` dont la clé est le jeu de params : c'est - // exactement le genre d'endroit où une clé incomplète produit une couture (AUDIT §C2). + // La source garde un cache inner 3×3 `thread_local`; chaque rebuild collecte 7×7 pour les + // fenêtres d'arbre 5×5. La clé est le jeu de params : c'est exactement le genre d'endroit où + // une clé incomplète produit une couture (AUDIT §C2). { std::atomic Impure{ 0 }; const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores())); @@ -201,6 +203,51 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters) Impure.load(), 0); } + //========================================================================== + // 2b. CACHE COST + //========================================================================== + { + // Force one distinct centre cell per call, then hold one cell constant. This measures the + // widened collection where it belongs (cache rebuild), separately from the hot per-voxel + // path. The implementation deliberately performs 49 cell rolls for the former and zero + // cell rolls for the latter; only the cached inner 3×3/capsule SDFs remain hot. + constexpr int32 NumRebuildSamples = 256; + constexpr int32 NumHotSamples = 20000; + const float PerfSpacing = FMath::Max(P.ShaftSpacing, 1.0f); + const float PerfZ = 0.5f * (P.StrateBottomWorldZ + P.StrateTopWorldZ); + volatile float Sink = 0.0f; + + const double RebuildStart = FPlatformTime::Seconds(); + float LastX = 0.0f; + float LastY = 0.0f; + for (int32 i = 0; i < NumRebuildSamples; ++i) + { + const int32 CellX = (i % 16) - 8; + const int32 CellY = (i / 16) - 8; + LastX = (static_cast(CellX) + 0.37f) * PerfSpacing; + LastY = (static_cast(CellY) + 0.61f) * PerfSpacing; + Sink += Gen->GetVerticalShaftDensity(LastX, LastY, PerfZ, P); + } + const double RebuildSeconds = FPlatformTime::Seconds() - RebuildStart; + + const double HotStart = FPlatformTime::Seconds(); + for (int32 i = 0; i < NumHotSamples; ++i) + { + Sink += Gen->GetVerticalShaftDensity(LastX, LastY, PerfZ, P); + } + const double HotSeconds = FPlatformTime::Seconds() - HotStart; + + AddInfo(FString::Printf( + TEXT("VerticalShafts cache perf: %d forced rebuilds at %.3f us/call and %d hot calls " + "at %.3f us/call (7x7=49 cell rolls only on rebuild; inner 3x3/capsule SDF " + "only on hot calls; sink=%.9g)."), + NumRebuildSamples, + RebuildSeconds * 1.0e6 / static_cast(NumRebuildSamples), + NumHotSamples, + HotSeconds * 1.0e6 / static_cast(NumHotSamples), + static_cast(Sink))); + } + //========================================================================= // 3. LE VERDICT DE BOÎTE //========================================================================= diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp index 3d62c62..4c08c10 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp @@ -19,6 +19,11 @@ IMPLEMENT_SIMPLE_AUTOMATION_TEST( "VoxelForge.Generation.StrateConnectivityRefinement", EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeVerticalShaftSeamTest, + "VoxelForge.Generation.VerticalShaftSeamFreedom", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + namespace { struct FArchetypeReport @@ -448,6 +453,7 @@ namespace FVoxelConnectivityDiagnostics SpineComponent; FVoxelConnectivityDiagnostics ArrivalToSpineFacts; FVoxelConnectivityDiagnostics DepartureToSpineFacts; + FVoxelStrateMetrics Topology; }; bool FindDiagnosticMouths( @@ -613,6 +619,142 @@ namespace } } +bool CheckVerticalShaftSeams( + VoxelForgeTest::FTestWorld& World, + int32& OutChecked, + int32& OutMismatches, + FString& OutFirstMismatch) +{ + OutChecked = 0; + OutMismatches = 0; + OutFirstMismatch.Reset(); + + int32 TopVoxelZ = 0; + int32 BottomVoxelZ = 0; + if (!World.GetSlotVoxelZRange( + VoxelForgeTest::FTestWorld::SlotVerticalShafts, TopVoxelZ, BottomVoxelZ)) + { + return false; + } + + const FVerticalShaftParams P = World.StrateManager->GetVerticalShaftParamsForChunk( + FIntVector(0, 0, (TopVoxelZ + BottomVoxelZ) / (2 * CHUNK_SIZE))); + const float Spacing = FMath::Max(P.ShaftSpacing, 1.0f); + const float Z = static_cast((TopVoxelZ + BottomVoxelZ) / 2); + const UVoxelGenerator* Generator = World.Generator.Get(); + + // A point is sampled immediately on both sides of many shaft-cell boundaries. For each point, + // two calls in distinct chunk contexts warm the per-chunk state in opposite orders before the + // same point is evaluated again. A different parent window would then show up as a bit change. + for (int32 CellY = -3; CellY <= 3; ++CellY) + { + for (int32 CellX = -5; CellX <= 5; ++CellX) + { + const float CellBoundaryX = static_cast(CellX + 1) * Spacing; + const float Y = (static_cast(CellY) + 0.37f) * Spacing; + for (const float X : { CellBoundaryX - 0.25f, CellBoundaryX + 0.25f }) + { + const FVector Probe(X, Y, Z); + const int32 ProbeChunkX = FMath::FloorToInt(X / static_cast(CHUNK_SIZE)); + const int32 ProbeChunkY = FMath::FloorToInt(Y / static_cast(CHUNK_SIZE)); + const FVector ContextA( + static_cast(ProbeChunkX * CHUNK_SIZE + 1), + static_cast(ProbeChunkY * CHUNK_SIZE + 1), Z); + const FVector ContextB( + static_cast((ProbeChunkX + 1) * CHUNK_SIZE + 1), + static_cast((ProbeChunkY - 1) * CHUNK_SIZE + 1), Z); + + const float Reference = Generator->GetDensityAt(Probe.X, Probe.Y, Probe.Z); + if (((CellX + CellY) & 1) == 0) + { + Generator->GetDensityAt(ContextA.X, ContextA.Y, ContextA.Z); + Generator->GetDensityAt(ContextB.X, ContextB.Y, ContextB.Z); + } + else + { + Generator->GetDensityAt(ContextB.X, ContextB.Y, ContextB.Z); + Generator->GetDensityAt(ContextA.X, ContextA.Y, ContextA.Z); + } + const float Got = Generator->GetDensityAt(Probe.X, Probe.Y, Probe.Z); + + ++OutChecked; + if (!SameFloatBits(Reference, Got)) + { + if (OutMismatches == 0) + { + OutFirstMismatch = FString::Printf( + TEXT("at (%.3f, %.3f, %.3f): reference %.9g [0x%08X] vs after " + "neighbouring chunk contexts %.9g [0x%08X]"), + Probe.X, + Probe.Y, + Probe.Z, + Reference, + *reinterpret_cast(&Reference), + Got, + *reinterpret_cast(&Got)); + } + ++OutMismatches; + } + } + } + } + return OutChecked > 0; +} + +bool FVoxelForgeVerticalShaftSeamTest::RunTest(const FString& Parameters) +{ + using namespace VoxelForgeTest; + + bool bAllChecksPassed = true; + FString Summary = TEXT("VerticalShafts seam-freedom check (cell-boundary probes with two " + "neighbouring chunk-context orders):\n"); + for (const bool bUseOperatorStack : { false, true }) + { + FTestWorld World; + World.Build(/*InSeed=*/1337, /*InGapChunks=*/2, bUseOperatorStack); + if (!World.IsValid()) + { + AddError(FString::Printf( + TEXT("HARD FAILURE: could not build the %s seam-test fixture."), + bUseOperatorStack ? TEXT("operator-stack") : TEXT("legacy"))); + bAllChecksPassed = false; + continue; + } + + int32 Checked = 0; + int32 Mismatches = 0; + FString FirstMismatch; + if (!CheckVerticalShaftSeams(World, Checked, Mismatches, FirstMismatch)) + { + AddError(FString::Printf( + TEXT("HARD FAILURE: the %s seam test sampled no VerticalShafts positions."), + bUseOperatorStack ? TEXT("operator-stack") : TEXT("legacy"))); + bAllChecksPassed = false; + continue; + } + + Summary += FString::Printf( + TEXT(" %s path: %d same-position re-evaluations, %d bit mismatches%s%s.\n"), + bUseOperatorStack ? TEXT("operator-stack") : TEXT("legacy"), + Checked, + Mismatches, + Mismatches > 0 ? TEXT("; first ") : TEXT(""), + Mismatches > 0 ? *FirstMismatch : TEXT("")); + if (Mismatches != 0) + { + AddError(FString::Printf( + TEXT("SEAM: %s VerticalShafts density changed after different chunk-context " + "orders: %s"), + bUseOperatorStack ? TEXT("operator-stack") : TEXT("legacy"), + *FirstMismatch)); + bAllChecksPassed = false; + } + } + + AddInfo(Summary); + return bAllChecksPassed; +} + bool FVoxelForgeStrateConnectivityTest::RunTest(const FString& Parameters) { using namespace VoxelForgeTest; @@ -1936,6 +2078,11 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet int32 SeedSpineNoOpenCell = 0; float SeedSpineShareMin = FLT_MAX; float SeedSpineShareMax = -FLT_MAX; + int32 SeedTopologyInvalid = 0; + int32 SeedTopologyComponentsMin = INT32_MAX; + int32 SeedTopologyComponentsMax = 0; + float SeedTopologyLargestShareMin = FLT_MAX; + float SeedTopologyLargestShareMax = -FLT_MAX; TArray SeedReports; SeedReports.Reserve(UE_ARRAY_COUNT(SeedCases)); const double SeedSweepStartSeconds = FPlatformTime::Seconds(); @@ -1972,6 +2119,51 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet } SeedReport.Seed = Seed; + const FVoxelStrateMetrics& Topology = SeedReport.Topology = VF_MeasureStrate( + *SeedWorld.Generator, + *SeedWorld.StrateManager, + VerticalShaftsIndex, + DiagnosticSettings); + const bool bTopologyUsable = + Topology.bValid + && Topology.NumSampled > 0 + && Topology.NumAir > 0 + && Topology.NumSolid > 0 + && Topology.NumAirComponents > 0 + && Topology.LargestComponentCells > 0 + && Topology.LargestComponentCells <= Topology.NumAir + && FMath::IsFinite(Topology.LargestComponentShare) + && Topology.LargestComponentShare >= 0.0f + && Topology.LargestComponentShare <= 1.0f; + if (!bTopologyUsable) + { + ++SeedTopologyInvalid; + AddError(FString::Printf( + TEXT("HARD FAILURE: seed %d VerticalShafts topology measurement is not " + "usable (valid=%s, sampled=%lld, air=%lld, solid=%lld, components=%d, " + "largest share=%.9g, reason='%s')."), + Seed, + Topology.bValid ? TEXT("true") : TEXT("false"), + Topology.NumSampled, + Topology.NumAir, + Topology.NumSolid, + Topology.NumAirComponents, + Topology.LargestComponentShare, + *Topology.RefusalReason)); + bAllChecksPassed = false; + } + else + { + SeedTopologyComponentsMin = FMath::Min( + SeedTopologyComponentsMin, Topology.NumAirComponents); + SeedTopologyComponentsMax = FMath::Max( + SeedTopologyComponentsMax, Topology.NumAirComponents); + SeedTopologyLargestShareMin = FMath::Min( + SeedTopologyLargestShareMin, Topology.LargestComponentShare); + SeedTopologyLargestShareMax = FMath::Max( + SeedTopologyLargestShareMax, Topology.LargestComponentShare); + } + if (!SeedReport.bHasChainArrival || !SeedReport.bHasDeparture || !SeedReport.ArrivalToDeparture.bChecked) { @@ -2030,7 +2222,8 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet } const EVoxelConnectivityResult SeedResult = SeedReport.ArrivalToDeparture.Result; - if (SeedResult == EVoxelConnectivityResult::Connected + if (bTopologyUsable + && SeedResult == EVoxelConnectivityResult::Connected && !SeedReport.ArrivalToDeparture.bStartSnapped && !SeedReport.ArrivalToDeparture.bGoalSnapped) { @@ -2071,6 +2264,15 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet SeedSpineNoOpenCell, SeedSpineShareMin == FLT_MAX ? 0.0f : SeedSpineShareMin, SeedSpineShareMax == -FLT_MAX ? 0.0f : SeedSpineShareMax); + Summary += FString::Printf( + TEXT(" seed topology: components range [%d, %d], largest-component share range " + "[%.9g, %.9g], invalid measurements=%d; spine-component share is reported " + "per seed below.\n"), + SeedTopologyComponentsMin == INT32_MAX ? 0 : SeedTopologyComponentsMin, + SeedTopologyComponentsMax, + SeedTopologyLargestShareMin == FLT_MAX ? 0.0f : SeedTopologyLargestShareMin, + SeedTopologyLargestShareMax == -FLT_MAX ? 0.0f : SeedTopologyLargestShareMax, + SeedTopologyInvalid); Summary += FString::Printf( TEXT(" seed result breakdown — arrival->spine: CONNECTED=%d, " "NOT_CONNECTED_AT_THIS_RESOLUTION=%d, START_CELL_SOLID=%d, GOAL_CELL_SOLID=%d, " @@ -2103,8 +2305,9 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet SeedArrivalToSpineSnapEvents, SeedDepartureToSpineSnapEvents, SeedArrivalToDepartureSnapEvents); - Summary += TEXT(" seed | spine air | full run | one open run | spine share | spine largest | " - "arrival->spine | departure->spine | arrival->departure\n"); + Summary += TEXT(" seed | components | largest share | spine air | spine share | spine largest | " + "full run | one open run | arrival->spine | departure->spine | " + "arrival->departure\n"); for (int32 SeedIndex = 0; SeedIndex < SeedReports.Num(); ++SeedIndex) { const FSpineDiagnosisReport& SeedReport = SeedReports[SeedIndex]; @@ -2115,14 +2318,22 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet const FString SpineLargest = SeedReport.SpineComponent.GoalComponentCells > 0 ? (SeedReport.SpineComponent.bGoalComponentIsLargest ? TEXT("YES") : TEXT("NO")) : TEXT("N/A"); + const FString Components = SeedReport.Topology.bValid + ? FString::Printf(TEXT("%d"), SeedReport.Topology.NumAirComponents) + : TEXT("N/A"); + const FString LargestShare = SeedReport.Topology.bValid + ? FString::Printf(TEXT("%.9g"), SeedReport.Topology.LargestComponentShare) + : TEXT("N/A"); Summary += FString::Printf( - TEXT(" %d | %.9g | %s | %s | %s | %s | %s | %s | %s\n"), + TEXT(" %d | %s | %s | %.9g | %s | %s | %s | %s | %s | %s | %s\n"), SeedReport.Seed, + *Components, + *LargestShare, SeedReport.Spine.AirFraction, - SeedReport.Spine.bFullWindowRun ? TEXT("YES") : TEXT("NO"), - SeedReport.Spine.bAirSamplesFormOneRun ? TEXT("YES") : TEXT("NO"), *SpineShare, *SpineLargest, + SeedReport.Spine.bFullWindowRun ? TEXT("YES") : TEXT("NO"), + SeedReport.Spine.bAirSamplesFormOneRun ? TEXT("YES") : TEXT("NO"), *ConnectivityProbeText(SeedReport.ArrivalToSpine), *ConnectivityProbeText(SeedReport.DepartureToSpine), *ConnectivityProbeText(SeedReport.ArrivalToDeparture)); diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index b78f362..7897d65 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -1444,13 +1444,14 @@ namespace //========================================================================= // RÔLE 1 — SOURCE : PUITS VERTICAUX / VERTICAL SHAFTS //========================================================================= - // Cylindres infinis sur une grille XY jitterée + connecteurs horizontaux entre paires proches, - // ouverts par un hash de paire symétrique. Écrit le canal SDF uniquement. + // Cylindres infinis sur une grille XY jitterée + un arbre de drainage déterministe, complété + // par des connecteurs horizontaux entre paires proches ouverts par un hash de paire. Écrit le + // canal SDF uniquement. // // ⚠️ ÉCART ASSUMÉ AVEC `OPSTACK-DECOMPOSITION §6`, qui suggérait DEUX sources (colonnes XY-pures // + connecteurs) pour que la moitié cylindrique reçoive le traitement du cache de colonne et un // `ClassifyBox` exact en XY. Gardé en UN opérateur, et voici pourquoi : - // • les connecteurs se dérivent de la MÊME liste 3×3 que les puits (il faut les paires), donc + // • les connecteurs se dérivent de la MÊME liste inner 3×3 que les puits (il faut les paires), donc // séparer imposerait soit de rouler les cellules deux fois, soit un cache partagé entre // deux ops — c'est-à-dire la complexité qu'on voulait éviter ; // • le `FShaftLedgeMod` en aval a de toute façon besoin de la liste des puits, donc il faut @@ -1459,9 +1460,10 @@ namespace // gardé : un `EffectOverBox` conservatif qui teste cercles ET capsules, ce que la version // séparée aurait dû faire aussi. À revoir si le profil montre que ça compte. // - // Kept as ONE op against §6's suggestion: the connectors derive from the same 3×3 roll as the - // shafts, and the downstream ledge mod needs the shaft list anyway. What is forfeited is an - // exact XY box verdict on the cylinder half alone. + // Kept as ONE op against §6's suggestion: the per-voxel connectors derive from the same inner + // 3×3 roll as the shafts, while a rebuild-only 7×7 collection resolves each 5×5 tree window; + // the downstream ledge mod needs the inner shaft list anyway. What is forfeited is an exact XY + // box verdict on the cylinder half alone. class FShaftFieldSource final : public IVoxelDensityOp { public: @@ -1473,7 +1475,7 @@ namespace EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } void PrepareChunk(const FVoxelOpContext&) override {} - struct FShaft { float X, Y, R; bool bOriginSpine; }; + struct FShaft { float X, Y, R; int32 CellX, CellY; bool bOriginSpine; }; struct FConn { FVector A, B; float Radius; }; // ⚠️ DÉCLARÉE ICI, avant toute fonction qui la renvoie. Un type imbriqué doit exister au @@ -1485,7 +1487,7 @@ namespace struct FCells { TArray> Shafts; - TArray> Conns; + TArray> Conns; }; void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override @@ -1545,6 +1547,105 @@ namespace if (QX * QX + QY * QY < R * R) { return EVoxelOpEffect::CarveOnly; } } + //----------------------------------------------------------------- + // THE STRUCTURAL TREE — enumerate every inner cell that the box can query, then + // collect its complete 5×5 parent window. This is the EffectOverBox mirror of GetCells: + // it may over-enumerate, but it must never miss a cached tree capsule. + //----------------------------------------------------------------- + constexpr int32 CandidateRadius = 2; + constexpr uint32 TreeSalt = 0x7A11u; + const int32 EmitX0 = FMath::FloorToInt((float)VoxelBox.Min.X / Spacing) - 1; + const int32 EmitX1 = FMath::FloorToInt((float)VoxelBox.Max.X / Spacing) + 1; + const int32 EmitY0 = FMath::FloorToInt((float)VoxelBox.Min.Y / Spacing) - 1; + const int32 EmitY1 = FMath::FloorToInt((float)VoxelBox.Max.Y / Spacing) + 1; + + TArray> TreeCandidates; + TArray> TreeEmit; + for (int32 cy = EmitY0 - CandidateRadius; cy <= EmitY1 + CandidateRadius; ++cy) + for (int32 cx = EmitX0 - CandidateRadius; cx <= EmitX1 + CandidateRadius; ++cx) + { + FShaft Sh; + if (!RollShaft(cx, cy, Sh)) { continue; } + TreeCandidates.Add(Sh); + if (cx >= EmitX0 && cx <= EmitX1 && cy >= EmitY0 && cy <= EmitY1) + { + TreeEmit.Add(Sh); + } + } + + const float BottomZ = P.StrateBottomWorldZ + P.BoundarySealThickness; + const float TopZ = P.StrateTopWorldZ - P.BoundarySealThickness; + const float TreeRadius = SpineConnectorRadius(); + const float RMinZ = (float)VoxelBox.Min.Z, RMaxZ = (float)VoxelBox.Max.Z; + const FVector2D CtrXY(0.5f * (float)(VoxelBox.Min.X + VoxelBox.Max.X), + 0.5f * (float)(VoxelBox.Min.Y + VoxelBox.Max.Y)); + const float HalfDiagXY = 0.5f * FMath::Sqrt( + FMath::Square((float)(VoxelBox.Max.X - VoxelBox.Min.X)) + + FMath::Square((float)(VoxelBox.Max.Y - VoxelBox.Min.Y))); + auto ConnectorMayReachBox = [&](const FConn& Conn) + { + const float ConnReach = Conn.Radius + ExtraReach; + const float Zc = Conn.A.Z; + if (RMinZ > Zc + ConnReach || RMaxZ < Zc - ConnReach) { return false; } + const float DistXY = VF_DistPointSegment2D( + CtrXY, FVector2D(Conn.A.X, Conn.A.Y), FVector2D(Conn.B.X, Conn.B.Y)); + return DistXY - HalfDiagXY < ConnReach; + }; + + for (const FShaft& Child : TreeEmit) + { + const float ChildOriginSq = FMath::Square(Child.X) + FMath::Square(Child.Y); + const FShaft* Parent = nullptr; + float BestDistanceSq = FLT_MAX; + for (const FShaft& Candidate : TreeCandidates) + { + if (Candidate.CellX == Child.CellX && Candidate.CellY == Child.CellY) + { + continue; + } + if (FMath::Abs(Candidate.CellX - Child.CellX) > CandidateRadius + || FMath::Abs(Candidate.CellY - Child.CellY) > CandidateRadius) + { + continue; + } + + const float CandidateOriginSq = FMath::Square(Candidate.X) + + FMath::Square(Candidate.Y); + if (!(CandidateOriginSq < ChildOriginSq)) + { + continue; + } + + const float DistanceSq = FMath::Square(Child.X - Candidate.X) + + FMath::Square(Child.Y - Candidate.Y); + const bool bLowerCell = Parent == nullptr + || Candidate.CellY < Parent->CellY + || (Candidate.CellY == Parent->CellY + && Candidate.CellX < Parent->CellX); + if (DistanceSq < BestDistanceSq + || (DistanceSq == BestDistanceSq && bLowerCell)) + { + BestDistanceSq = DistanceSq; + Parent = &Candidate; + } + } + + const int32 ParentCellX = Parent != nullptr ? Parent->CellX : 0; + const int32 ParentCellY = Parent != nullptr ? Parent->CellY : 0; + const uint32 LinkHash = VoxelHash::Pair( + Child.CellX, Child.CellY, ParentCellX, ParentCellY, Salt ^ TreeSalt); + const float Zc = FMath::Lerp( + P.StrateBottomWorldZ + P.BoundarySealThickness, + P.StrateTopWorldZ - P.BoundarySealThickness, + VoxelHash::ToFloat01(VoxelHash::Mix(LinkHash))); + const FVector ParentPoint = Parent != nullptr + ? FVector(Parent->X, Parent->Y, Zc) + : FVector(0.0f, 0.0f, Zc); + const FConn TreeConn{ + FVector(Child.X, Child.Y, Zc), ParentPoint, TreeRadius }; + if (ConnectorMayReachBox(TreeConn)) { return EVoxelOpEffect::CarveOnly; } + } + //----------------------------------------------------------------- // LES CONNECTEURS — LES VRAIES CAPSULES, PLUS « un puits existe dans le coin » //----------------------------------------------------------------- @@ -1558,11 +1659,11 @@ namespace // construit, et tester la capsule réelle. // // ⚠️ POURQUOI L'ÉNUMÉRATION EST UN SUR-ENSEMBLE (donc sûre). `Eval` lit les connecteurs - // du voisinage 3×3 de la cellule DE SA REQUÊTE. Une paire visible depuis un point de la - // boîte a donc ses deux puits dans un même 3×3 centré sur une cellule que la boîte - // touche ⇒ les deux sont dans [cellules de la boîte] ± 1, qui est exactement la plage - // balayée ici. On peut produire des paires que personne ne voit jamais : c'est du - // `CarveOnly` en trop, pas un trou. + // du voisinage inner 3×3 de la cellule DE SA REQUÊTE. Une paire random visible depuis + // un point de la boîte a donc ses deux puits dans [cellules de la boîte] ± 1, qui est + // exactement la plage balayée ici. Les connecteurs de l'arbre sont testés séparément + // au-dessus avec leur collecte 7×7/émission inner 3×3. On peut produire des paires que + // personne ne voit jamais : c'est du `CarveOnly` en trop, pas un trou. // // ⚠️ ET POURQUOI L'ORDRE (A,B) EST LE MÊME QUE CELUI DE `GetCells`. Le hash de paire est // pris sur (A puis B) dans l'ordre d'insertion, et `GetCells` insère en `(dy, dx)`, @@ -1573,15 +1674,15 @@ namespace // // Was: return CarveOnly as soon as any shaft EXISTED within Spacing*1.6 + Pad, which at // ShaftSpacing 55 / ShaftDensity 0.6 is true everywhere -- 0 of 60 tiles proved. Now it - // rebuilds the connectors the way GetCells does and tests the real capsule. The pair + // rebuilds the random links the way GetCells does and tests the real capsule. The pair // enumeration is a superset (safe), and the row-major cell order reproduces GetCells' // insertion order, so each pair gets the same hash without assuming Pair() is symmetric. if (P.CrossConnectChance > 0.0f) { - const int32 QX0 = FMath::FloorToInt((float)VoxelBox.Min.X / Spacing) - 1; - const int32 QX1 = FMath::FloorToInt((float)VoxelBox.Max.X / Spacing) + 1; - const int32 QY0 = FMath::FloorToInt((float)VoxelBox.Min.Y / Spacing) - 1; - const int32 QY1 = FMath::FloorToInt((float)VoxelBox.Max.Y / Spacing) + 1; + const int32 QX0 = EmitX0; + const int32 QX1 = EmitX1; + const int32 QY0 = EmitY0; + const int32 QY1 = EmitY1; TArray> Near; for (int32 cy = QY0; cy <= QY1; ++cy) @@ -1598,21 +1699,11 @@ namespace && QX0 <= 1 && QX1 >= -1 && QY0 <= 1 && QY1 >= -1) { - Near.Add({0.0f, 0.0f, SpineRadius, true}); + Near.Add({0.0f, 0.0f, SpineRadius, 0, 0, true}); } - const float BottomZ = P.StrateBottomWorldZ + P.BoundarySealThickness; - const float TopZ = P.StrateTopWorldZ - P.BoundarySealThickness; - // Z est traité EXACTEMENT (le connecteur est une capsule horizontale à `Zc`), XY de // façon conservative. Séparer les deux est bien plus serré qu'une demi-diagonale 3D. - const float RMinZ = (float)VoxelBox.Min.Z, RMaxZ = (float)VoxelBox.Max.Z; - const FVector2D CtrXY(0.5f * (float)(VoxelBox.Min.X + VoxelBox.Max.X), - 0.5f * (float)(VoxelBox.Min.Y + VoxelBox.Max.Y)); - const float HalfDiagXY = 0.5f * FMath::Sqrt( - FMath::Square((float)(VoxelBox.Max.X - VoxelBox.Min.X)) + - FMath::Square((float)(VoxelBox.Max.Y - VoxelBox.Min.Y))); - for (int32 i = 0; i < Near.Num(); ++i) for (int32 j = i + 1; j < Near.Num(); ++j) { @@ -1632,17 +1723,11 @@ namespace const float ConnRadius = bSpineConnector ? SpineConnectorRadius() : P.ConnectorRadius; - const float ConnReach = ConnRadius + ExtraReach; - const float Zc = FMath::Lerp(BottomZ, TopZ, VoxelHash::ToFloat01(VoxelHash::Mix(PH))); - if (RMinZ > Zc + ConnReach || RMaxZ < Zc - ConnReach) { continue; } - - const float DistXY = VF_DistPointSegment2D( - CtrXY, FVector2D(A.X, A.Y), FVector2D(B.X, B.Y)); - if (DistXY - HalfDiagXY >= ConnReach) { continue; } - - return EVoxelOpEffect::CarveOnly; + const FConn RandomConn{ + FVector(A.X, A.Y, Zc), FVector(B.X, B.Y, Zc), ConnRadius }; + if (ConnectorMayReachBox(RandomConn)) { return EVoxelOpEffect::CarveOnly; } } } @@ -1664,6 +1749,8 @@ namespace Out.Y = (ny + 0.15f + JY * 0.7f) * Spacing; Out.R = FMath::Lerp(P.ShaftMinRadius, P.ShaftMaxRadius, VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0xBEEFu))); + Out.CellX = nx; + Out.CellY = ny; Out.bOriginSpine = false; return true; } @@ -1678,7 +1765,8 @@ namespace return FMath::Max(P.ConnectorRadius, RoughnessReach + 1.0f); } - /** Le voisinage 3×3 + ses connecteurs, mémoïsés par worker. Clé = cellule + tous les params + /** Le voisinage inner 3×3 + ses connecteurs, mémoïsés par worker. Le rebuild collecte un + * 7×7 temporaire pour résoudre les fenêtres parent 5×5. Clé = cellule + tous les params * qui influent (comme l'original) : stable à travers les reconstructions de pile, ce qui * est la leçon retenue du mémo de colonne de SurfaceWorld. */ const FCells& GetCells(float WorldX, float WorldY) const @@ -1711,23 +1799,101 @@ namespace Cache.Shafts.Reset(); Cache.Conns.Reset(); - for (int32 dy = -1; dy <= 1; dy++) - for (int32 dx = -1; dx <= 1; dx++) + constexpr int32 CollectRadius = 3; // 7×7: inner 3×3 + one 5×5 candidate halo + constexpr int32 EmitRadius = 1; // 3×3: the only cells allowed to emit tree links + constexpr int32 CandidateRadius = 2; // 5×5 parent window around an emitting shaft + constexpr uint32 TreeSalt = 0x7A11u; + + TArray> CollectedShafts; + // Rebuild-only wide collection. `Cache.Shafts` remains the inner 3×3 working set + // used by Eval and the ledge modifier, so the 7×7 cost is not paid per voxel. + for (int32 dy = -CollectRadius; dy <= CollectRadius; dy++) + for (int32 dx = -CollectRadius; dx <= CollectRadius; dx++) { FShaft Sh; - if (RollShaft(CX + dx, CY + dy, Sh)) { Cache.Shafts.Add(Sh); } + if (!RollShaft(CX + dx, CY + dy, Sh)) { continue; } + CollectedShafts.Add(Sh); + if (FMath::Abs(dx) <= EmitRadius && FMath::Abs(dy) <= EmitRadius) + { + Cache.Shafts.Add(Sh); + } } const bool bOriginNearby = FMath::Abs(CX) <= 1 && FMath::Abs(CY) <= 1; if (bOriginNearby && SpineRadius > 0.0f) { - Cache.Shafts.Add({0.0f, 0.0f, SpineRadius, true}); + Cache.Shafts.Add({0.0f, 0.0f, SpineRadius, 0, 0, true}); } + const float BottomZ = P.StrateBottomWorldZ + P.BoundarySealThickness; + const float TopZ = P.StrateTopWorldZ - P.BoundarySealThickness; + const float TreeRadius = SpineConnectorRadius(); + + auto EmitTreeConnector = [&](const FShaft& Child, const FShaft* Parent) + { + const int32 ParentCellX = Parent != nullptr ? Parent->CellX : 0; + const int32 ParentCellY = Parent != nullptr ? Parent->CellY : 0; + const uint32 LinkHash = VoxelHash::Pair( + Child.CellX, Child.CellY, ParentCellX, ParentCellY, Salt ^ TreeSalt); + const float Zc = FMath::Lerp( + BottomZ, TopZ, VoxelHash::ToFloat01(VoxelHash::Mix(LinkHash))); + const FVector ParentPoint = Parent != nullptr + ? FVector(Parent->X, Parent->Y, Zc) + : FVector(0.0f, 0.0f, Zc); + Cache.Conns.Add({ FVector(Child.X, Child.Y, Zc), ParentPoint, TreeRadius }); + }; + + // Structural drainage tree. Every real shaft emitted by this cache chooses the + // nearest strictly more-central shaft in its complete 5×5 window; a local minimum + // drains to the spine. The explicit row-major cell tie-break is independent of + // array iteration order, and the synthetic spine is not a candidate shaft. + for (const FShaft& Child : Cache.Shafts) + { + if (Child.bOriginSpine) continue; + + const float ChildOriginSq = FMath::Square(Child.X) + FMath::Square(Child.Y); + const FShaft* Parent = nullptr; + float BestDistanceSq = FLT_MAX; + for (const FShaft& Candidate : CollectedShafts) + { + if (Candidate.CellX == Child.CellX && Candidate.CellY == Child.CellY) + { + continue; + } + if (FMath::Abs(Candidate.CellX - Child.CellX) > CandidateRadius + || FMath::Abs(Candidate.CellY - Child.CellY) > CandidateRadius) + { + continue; + } + + const float CandidateOriginSq = FMath::Square(Candidate.X) + + FMath::Square(Candidate.Y); + if (!(CandidateOriginSq < ChildOriginSq)) + { + continue; + } + + const float DistanceSq = FMath::Square(Child.X - Candidate.X) + + FMath::Square(Child.Y - Candidate.Y); + const bool bLowerCell = Parent == nullptr + || Candidate.CellY < Parent->CellY + || (Candidate.CellY == Parent->CellY + && Candidate.CellX < Parent->CellX); + if (DistanceSq < BestDistanceSq + || (DistanceSq == BestDistanceSq && bLowerCell)) + { + BestDistanceSq = DistanceSq; + Parent = &Candidate; + } + } + + EmitTreeConnector(Child, Parent); + } + + // Existing random links remain the texture/loop layer and stay on the inner 3×3 + // set. Only the deterministic parent lookup uses the 7×7 collection. if (P.CrossConnectChance > 0.0f && Cache.Shafts.Num() >= 2) { - const float BottomZ = P.StrateBottomWorldZ + P.BoundarySealThickness; - const float TopZ = P.StrateTopWorldZ - P.BoundarySealThickness; for (int32 i = 0; i < Cache.Shafts.Num(); i++) for (int32 j = i + 1; j < Cache.Shafts.Num(); j++) { diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 098689b..e66fd9b 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -3679,9 +3679,9 @@ void UVoxelGenerator::GetBiomeMaterialAt(float WorldX, float WorldY, float World //============================================================================= // VERTICAL-SHAFT GENERATOR (ECaveGeneratorType::VerticalShafts) //============================================================================= -// Solid rock carved by hash-placed full-height vertical shafts (cylinders) with -// occasional horizontal connector tunnels between neighbouring shafts and partial -// ledges inside them. Emphasises climbing and falling. +// Solid rock carved by hash-placed full-height vertical shafts (cylinders), a deterministic +// drainage tree of horizontal connector tunnels rooted at the origin spine, additive random +// connector loops, and partial ledges inside them. Emphasises climbing and falling. float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float WorldZ, const FVerticalShaftParams& Params) const @@ -3698,36 +3698,50 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float const int32 CX = FMath::FloorToInt(WorldX / Spacing); const int32 CY = FMath::FloorToInt(WorldY / Spacing); - // Shafts + cross-connectors of the 3×3 neighbourhood are pure functions of (cell, seed, - // params) yet were re-hashed PER VOXEL (9 cell rolls + a pair hash per shaft pair). Bake - // them once per centre cell (thread_local); per-voxel work = the cylinder/capsule SDFs. - struct FLocalShaft { float X, Y, R; bool bOriginSpine; }; + // The shaft topology is pure, but it is deliberately split into two windows: + // * COLLECT 7×7 cells once during this thread-local cache rebuild. This is the source data + // needed to resolve every 5×5 parent window for the emitted cells below. + // * EMIT/EVALUATE only the INNER 3×3 cells. A shaft in that inner window has its whole + // 5×5 candidate window inside the collected 7×7, so two neighbouring evaluation cells + // compute the same parent for the same shaft. The 7×7 work is therefore rebuild-only; + // the per-voxel loop still sees at most the inner 3×3 shafts plus their cached capsules. + struct FLocalShaft { float X, Y, R; int32 CellX, CellY; bool bOriginSpine; }; struct FLocalConn { FVector A, B; float Radius; }; thread_local TArray> Shafts; - thread_local TArray> Conns; + thread_local TArray> Conns; thread_local int32 VS_CX = INT32_MAX, VS_CY = INT32_MAX; thread_local uint32 VS_Seed = 0xFFFFFFFFu; thread_local float VS_Spacing = -1.0f, VS_Dens = -1.0f, VS_MinR = -1.0f, VS_MaxR = -1.0f, VS_Cross = -1.0f, VS_ConnR = -1.0f, VS_Rough = -1.0f, - VS_SpineR = -1.0f, VS_BotZ = FLT_MAX, VS_TopZ = FLT_MAX; + VS_SpineR = -1.0f, VS_BotZ = FLT_MAX, VS_TopZ = FLT_MAX, + VS_Seal = -1.0f; if (CX != VS_CX || CY != VS_CY || S != VS_Seed || Spacing != VS_Spacing || Params.ShaftDensity != VS_Dens || Params.ShaftMinRadius != VS_MinR || Params.ShaftMaxRadius != VS_MaxR || Params.CrossConnectChance != VS_Cross || Params.ConnectorRadius != VS_ConnR || Params.SurfaceRoughness != VS_Rough || OriginSpineRadius != VS_SpineR || - Params.StrateBottomWorldZ != VS_BotZ || Params.StrateTopWorldZ != VS_TopZ) + Params.StrateBottomWorldZ != VS_BotZ || Params.StrateTopWorldZ != VS_TopZ || + Params.BoundarySealThickness != VS_Seal) { VS_CX = CX; VS_CY = CY; VS_Seed = S; VS_Spacing = Spacing; VS_Dens = Params.ShaftDensity; VS_MinR = Params.ShaftMinRadius; VS_MaxR = Params.ShaftMaxRadius; VS_Cross = Params.CrossConnectChance; VS_ConnR = Params.ConnectorRadius; VS_Rough = Params.SurfaceRoughness; VS_SpineR = OriginSpineRadius; VS_BotZ = Params.StrateBottomWorldZ; VS_TopZ = Params.StrateTopWorldZ; + VS_Seal = Params.BoundarySealThickness; Shafts.Reset(); Conns.Reset(); - // Collect shafts in the 3x3 neighbourhood (XY). - for (int32 dy = -1; dy <= 1; dy++) - for (int32 dx = -1; dx <= 1; dx++) + constexpr int32 CollectRadius = 3; // 7×7: inner 3×3 + one 5×5 candidate halo + constexpr int32 EmitRadius = 1; // 3×3: the only cells allowed to emit tree links + constexpr int32 CandidateRadius = 2; // 5×5 parent window around an emitting shaft + constexpr uint32 TreeSalt = 0x7A11u; + + TArray> CollectedShafts; + // Collect the wider deterministic neighbourhood once. Only the inner cells are retained + // in `Shafts`, because that is the per-voxel working set and the seam-free emission set. + for (int32 dy = -CollectRadius; dy <= CollectRadius; dy++) + for (int32 dx = -CollectRadius; dx <= CollectRadius; dx++) { const int32 nx = CX + dx, ny = CY + dy; const uint32 Hh = VoxelHash::Cell(nx, ny, S); @@ -3740,8 +3754,14 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float Sh.Y = (ny + 0.15f + JY * 0.7f) * Spacing; Sh.R = FMath::Lerp(Params.ShaftMinRadius, Params.ShaftMaxRadius, VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0xBEEFu))); + Sh.CellX = nx; + Sh.CellY = ny; Sh.bOriginSpine = false; - Shafts.Add(Sh); + CollectedShafts.Add(Sh); + if (FMath::Abs(dx) <= EmitRadius && FMath::Abs(dy) <= EmitRadius) + { + Shafts.Add(Sh); + } } // The structural (0,0) spine is not re-carved here. It participates only as a @@ -3750,14 +3770,81 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float const bool bOriginNearby = FMath::Abs(CX) <= 1 && FMath::Abs(CY) <= 1; if (bOriginNearby && OriginSpineRadius > 0.0f) { - Shafts.Add({0.0f, 0.0f, OriginSpineRadius, true}); + Shafts.Add({0.0f, 0.0f, OriginSpineRadius, 0, 0, true}); } - // Horizontal connectors between nearby shaft pairs (hash-gated). + const float BottomZ = Params.StrateBottomWorldZ + Params.BoundarySealThickness; + const float TopZ = Params.StrateTopWorldZ - Params.BoundarySealThickness; + const float RoughnessReach = FMath::Max(Params.SurfaceRoughness, 0.0f) + * VOXEL_NOISE_SCALE * 1.5f; + // A tree link must remain open at the centreline after the SDF roughness pass. The +1 + // margin makes the radius strictly greater than the proven roughness supremum. + const float TreeConnectorRadius = FMath::Max(Params.ConnectorRadius, RoughnessReach + 1.0f); + + auto EmitTreeConnector = [&](const FLocalShaft& Child, const FLocalShaft* Parent) + { + const int32 ParentCellX = Parent != nullptr ? Parent->CellX : 0; + const int32 ParentCellY = Parent != nullptr ? Parent->CellY : 0; + const uint32 LinkHash = VoxelHash::Pair( + Child.CellX, Child.CellY, ParentCellX, ParentCellY, S ^ TreeSalt); + const float Zc = FMath::Lerp(BottomZ, TopZ, + VoxelHash::ToFloat01(VoxelHash::Mix(LinkHash))); + const FVector ParentPoint = Parent != nullptr + ? FVector(Parent->X, Parent->Y, Zc) + : FVector(0.0f, 0.0f, Zc); + Conns.Add({ FVector(Child.X, Child.Y, Zc), ParentPoint, TreeConnectorRadius }); + }; + + // Structural drainage tree: every real shaft in the inner 3×3 selects one strictly + // more-central shaft from its complete 5×5 window. A local minimum has no such parent and + // drains directly to the (0,0) spine. The explicit cell-coordinate tie-break makes this + // independent of array/iteration order; the synthetic spine is not a candidate shaft. + for (const FLocalShaft& Child : Shafts) + { + if (Child.bOriginSpine) continue; + + const float ChildOriginSq = FMath::Square(Child.X) + FMath::Square(Child.Y); + const FLocalShaft* Parent = nullptr; + float BestDistanceSq = FLT_MAX; + for (const FLocalShaft& Candidate : CollectedShafts) + { + if (Candidate.CellX == Child.CellX && Candidate.CellY == Child.CellY) + { + continue; + } + if (FMath::Abs(Candidate.CellX - Child.CellX) > CandidateRadius + || FMath::Abs(Candidate.CellY - Child.CellY) > CandidateRadius) + { + continue; + } + + const float CandidateOriginSq = FMath::Square(Candidate.X) + + FMath::Square(Candidate.Y); + if (!(CandidateOriginSq < ChildOriginSq)) + { + continue; + } + + const float DistanceSq = FMath::Square(Child.X - Candidate.X) + + FMath::Square(Child.Y - Candidate.Y); + const bool bLowerCell = Parent == nullptr + || Candidate.CellY < Parent->CellY + || (Candidate.CellY == Parent->CellY && Candidate.CellX < Parent->CellX); + if (DistanceSq < BestDistanceSq + || (DistanceSq == BestDistanceSq && bLowerCell)) + { + BestDistanceSq = DistanceSq; + Parent = &Candidate; + } + } + + EmitTreeConnector(Child, Parent); + } + + // Existing probabilistic links remain as texture and loops. They intentionally stay on + // the cached inner 3×3 working set; only the structural parent lookup needs the 7×7 halo. if (Params.CrossConnectChance > 0.0f && Shafts.Num() >= 2) { - const float BottomZ = Params.StrateBottomWorldZ + Params.BoundarySealThickness; - const float TopZ = Params.StrateTopWorldZ - Params.BoundarySealThickness; for (int32 i = 0; i < Shafts.Num(); i++) for (int32 j = i + 1; j < Shafts.Num(); j++) { @@ -3777,8 +3864,7 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float const float ConnectorR = bSpineConnector ? FMath::Max( Params.ConnectorRadius, - FMath::Max(Params.SurfaceRoughness, 0.0f) - * VOXEL_NOISE_SCALE * 1.5f + 1.0f) + RoughnessReach + 1.0f) : Params.ConnectorRadius; Conns.Add({ FVector(A.X, A.Y, Zc), FVector(B.X, B.Y, Zc), ConnectorR }); }