From 58e61cdefa3c3e2da724cc8394e2f39e227d5fe6 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Sun, 30 Aug 2026 16:52:57 +0200 Subject: [PATCH] feat(shafts): the spine joins its neighbours -- necessary, not sufficient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jahni chose: make the (0,0) column join the shafts it passes, so VerticalShafts behaves like the other seven archetypes. Built, and it works as far as it goes -- the spine is now the LARGEST air component in 13 of 16 seeds (share up to 0.81, was 0.096 and never largest). ⚠️ IT DOES NOT MEET THE ACCEPTANCE CRITERION. arrival->departure is still 1/16. Reported as a failure rather than dressed up, because the criterion was the measured law, not "the code was written". The test now prints ACCEPTANCE arrival->departure: FAIL (1/16 seeds pass) so it cannot be quietly forgotten. Why it is insufficient, and it is structural: the spine only joins its own 3x3 cell neighbourhood, while passage mouths land 60-200 voxels from (0,0). The departure network is unreachable from the spine in all 16 seeds. Joining the hub to its neighbours does not help if the rest of the graph never reaches the hub. Part A diagnosis (seed 1337, margin 0, step 2, radius 192): baseline 30 components, largest .220 roughness 0 17 components, largest .226 connector radius 8 29 components, largest .229 H1 is real but partial: roughness fragments the space (13 of the 30 components are noise pockets), yet widening connectors past the proven roughness bound barely moves it. So roughness is NOT what severs the network -- the largest component sits at ~0.22 in every variant, which is the signature of a graph below its percolation threshold. The lever is topology, not radius. H2 (the hardcoded 1.6 pair cutoff) went untested. Implementation notes: the spine is added ONLY as a connector endpoint, not as a carving shaft -- VF_ApplyOriginSpine remains sole owner -- and is excluded from ledge placement. Spine connectors use radius 6.625, above the proven roughness supremum of 5.625 (sup|FBM| = 1.5, not 1.0). SpineRadius is threaded through BuildVerticalShaftStack, not hardcoded. Op-stack mirror bit-identical over 20,000 samples, 0 box-verdict violations. LayoutOrderIndependence green. Build clean, 19 succeeded / 0 warnings / 0 failed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Bp7FxoQcSoiuhxjuLr6aBM --- ARCHITECTURE.md | 17 ++++ CODEMAP.md | 5 +- .../VoxelForgeStrateConnectivityTest.cpp | 44 +++++++--- .../Private/VoxelDensityOpStack.cpp | 81 +++++++++++++++---- Source/VoxelForge/Private/VoxelGenerator.cpp | 40 ++++++--- 5 files changed, 147 insertions(+), 40 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 41a065f..01573a1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -45,12 +45,25 @@ what the world IS, and both paths compute it. column at XY (0,0) in every strate's **interior** (seals untouched). Radius = `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. - 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. + ### 8.3 Disturbance layer (the "wow" post-process) `FStrateDisturbanceParams` (on the definition, all archetypes). `ApplyDisturbances` (VoxelGenerator.cpp static, **MC convention**) runs in `GetDensityAt` after dispatch: @@ -324,6 +337,10 @@ 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. - **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 27ff29d..2be0fbe 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -151,10 +151,11 @@ 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. | | `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`. | -| `VoxelDensityOps::BuildVerticalShaftStack` | — | 8 ops, and **three are Maze's reused unchanged** (`ConstantRock`, `SdfRoughness`, `SdfCarve`) with different tuning (freq 0.1 vs 0.12, window `rough+4` vs `R+rough+2`). The measured proof of `OPSTACK-PLAN §2.5`'s reuse claim. | +| `VoxelDensityOps::BuildVerticalShaftStack` | — | 8 ops, and **three are Maze's reused unchanged** (`ConstantRock`, `SdfRoughness`, `SdfCarve`) with different tuning (freq 0.1 vs 0.12, window `rough+4` vs `R+rough+2`). Passes the runtime spine radius into `FShaftFieldSource`, which mirrors the generator's connector-only origin endpoint and roughness-safe spine capsule. The measured proof of `OPSTACK-PLAN §2.5`'s reuse claim. | | `FRoomGraphSource` (internal) | 1 | TunnelNetwork's SDF spine. **CALLS `BuildChunkCache`/`EvaluateSDFCached` — does not transcribe them**: that is where §8.4's two-region discipline lives and a copy would fork it. Owns the cave warp (scope = this op alone; pits/chimneys read *unwarped* coords, which is why no FRAME op was needed). Its cache key adds a **params CRC + LayoutVersion**; the original lacked them until AUDIT §C2 was fixed (2026-07-28) and now carries them too. **`EffectOverBox` ANSWERS SPATIALLY** since 2026-07-28 — this is the T1.d switch (measured: **6 of 40 tiles proved AllSolid at production defaults, 7986 voxels brute-forced, 0 violations**). It builds the cache for the queried box into a *second* per-worker cache (never `FState::Cache`), then applies a **disjunction** per primitive: it doesn't matter if it **fails its cull** *or* if **its own SDF stays ≥ `T+K`** over the box. Cull wins for rooms (`Rmax+3K` < `Rmax+T+K`); the threshold wins hugely for tunnels, whose cull is a capsule *bounding sphere* (~107 radius for a 200-long tube of radius 7). ⚠️ **`Identity` therefore means `Sdf ≥ T`, not `Sdf == FLT_MAX`**, with `T = max(3·SDFBlendRadius, WormNetworkRange)` — **any new consumer of the `Sdf` channel must have a threshold ≤ T or be added to that max**, or it gets tiles with no geometry and no collision. The `−K` slack covers any number of primitives because `SmoothMin`'s penalty is exactly 0 once `\|A−B\| ≥ K`. Pits/chimneys use the cull only; columns are **not** tested (their sole consumer gates on `Sdf`, so the test was redundant). Verdict memoised per box; warp dilation uses a **provable** `\|Perlin3D\| ≤ 2`. | | `FWormFieldSource` (internal) | 1 | Fielded 3D-noise threshold carve, masked by distance to the room network (reads `InOut.Sdf` *after* pits/chimneys). **`EffectOverBox` INHERITS `FRoomGraphSource`'s verdict** since 2026-07-28 — its `Eval` sets `NetworkMask = 0` when `CaveSDF >= WormNetworkRange`, which `FLT_MAX` always satisfies, so where the room source proves `Identity` the worm doesn't execute at all. ⚠️ This was **the** blocker: `BaseDensity = 8` < `WormStrength = 10` **by default** (the field comment requires it), so an unconditional `CarveOnly` drove `SolidMargin` negative on every tile in the world and no room-source proof could survive behind it. Deliberately **not** `VF_NoCaveOverBox` — that helper answers "identity" for a null `Rooms`, which is wrong for an op that could sit behind a different SDF writer. | | `VoxelDensityOps::BuildTunnelNetworkStack` | — | **COMPLETE, 19 ops** — the biggest port in the plugin (~1080 lines), done in three stages: SDF spine (A) → the twelve detail modifiers of 4b–4h (B) → the per-room op override (C). Serves **TunnelNetwork and Underwater** from one builder. Operator order is the original's, line for line, and it is load-bearing (`FFloorBiasMod` exists to undo what `FCaveRoughnessMod` did to floors). | @@ -486,7 +487,7 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor | `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. | +| `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. | ## 4. The density pipeline (most-edited hot path) diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp index eebe674..3d62c62 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeStrateConnectivityTest.cpp @@ -1714,7 +1714,7 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet SweepSeconds); Summary += TEXT( - "Part A — margin artifact check (SampleStep 2, RadiusInVoxels 192; explicit margins):\n"); + "Resolution control — margin artifact check (SampleStep 2, RadiusInVoxels 192; explicit margins):\n"); Summary += TEXT( " margin | verdict | components | largest share | sampled cells | Z window | endpoint snaps\n"); for (const FRefinementSweepRow& Row : MarginRows) @@ -1737,7 +1737,7 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet *SnapText); } Summary += FString::Printf( - TEXT("Part A verdict: %s. Margin sweep wall-clock: %.3f seconds.\n"), + TEXT("Resolution-control verdict: %s. Margin sweep wall-clock: %.3f seconds.\n"), bMarginArtifact ? TEXT("ARTIFACT — at least one margin-0/2/4 query is CONNECTED; the default margin excluded the connection") : TEXT("FINDING SURVIVES — no margin-0/2/4 query is CONNECTED; the full window is exonerated"), @@ -1828,10 +1828,10 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet NumMouthToSpineQueries, NumMouthToSpineCoarseLied); Summary += TEXT( - "Interpretation: the spine is relevant and usually a backbone, but it is not a universal " - "connector. The VerticalShafts result supports a placement-first choice for this defect " - "because most fixture mouths reach the spine; it does not support treating the spine as " - "a guarantee for every archetype.\n"); + "Interpretation: the structural spine remains a continuous column. After the " + "VerticalShafts connector change, the seed-1337 spine is the largest component, but the " + "distant shaft networks at the measured passage mouths are not thereby guaranteed to join; " + "the 16-seed arrival->departure sweep below is the acceptance measurement.\n"); Summary += TEXT( "Ordering-dependency hazard: making passage i's departure mouth depend on passage i-1's " "arrival mouth would make layout generation a sequence. That conflicts with " @@ -1839,7 +1839,7 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet "coupled implementation would be expected to break that test. No such dependency was " "implemented here.\n"); - if (!bMarginArtifact && bMarginSweepUsable) + if (bMarginSweepUsable) { const FVoxelConnectivityDiagnostics Diagnostics = VF_DiagnoseConnectivity( *World.Generator, @@ -1868,7 +1868,7 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet const float CoarseStraightLineCells = MouthStraightLine / static_cast(DiagnosticSettings.SampleStep); - Summary += TEXT("Part B — VerticalShafts diagnosis (seed 1337; margin 0; step 2; radius 192):\n"); + Summary += TEXT("Part C — VerticalShafts post-fix measurement (seed 1337; margin 0; step 2; radius 192):\n"); Summary += FString::Printf( TEXT(" arrival LowerPoint: (%.3f, %.3f, %.3f); departure UpperPoint: " "(%.3f, %.3f, %.3f); XY separation %.3f voxels; Z delta %.3f voxels.\n"), @@ -1904,6 +1904,20 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet Diagnostics.bStartSnapped ? TEXT("yes") : TEXT("no"), Diagnostics.bGoalSnapped ? TEXT("yes") : TEXT("no")); + if (MarginRows.Num() > 0 + && SpineReports.IsValidIndex(VerticalShaftsIndex)) + { + const FVoxelStrateMetrics& VerticalMetrics = MarginRows[0].Metrics; + const FSpineDiagnosisReport& VerticalSpine = SpineReports[VerticalShaftsIndex]; + Summary += FString::Printf( + TEXT(" Part C VerticalShafts topology: components=%d; largest share=%.9g; " + "spine component share=%.9g; spine-is-largest=%s.\n"), + VerticalMetrics.NumAirComponents, + VerticalMetrics.LargestComponentShare, + VerticalSpine.SpineComponent.GoalComponentShare, + VerticalSpine.SpineComponent.bGoalComponentIsLargest ? TEXT("YES") : TEXT("NO")); + } + static constexpr int32 SeedCases[] = { 1337, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, @@ -2038,6 +2052,12 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet SeedFailures, SeedArrivalToDepartureSnapEvents, SeedSweepSeconds); + Summary += FString::Printf( + TEXT(" ACCEPTANCE arrival->departure: %s (%d/%d seeds pass).\n"), + SeedPasses == UE_ARRAY_COUNT(SeedCases) && SeedFailures == 0 + ? TEXT("PASS") : TEXT("FAIL"), + SeedPasses, + UE_ARRAY_COUNT(SeedCases)); Summary += FString::Printf( TEXT(" spine column sweep: full-window run=%d/%d, one-open-run=%d/%d, " "component-largest=%d/%d, no open component=%d; component share range " @@ -2110,11 +2130,9 @@ bool FVoxelForgeStrateConnectivityRefinementTest::RunTest(const FString& Paramet } else { - Summary += FString::Printf( - TEXT("Part B diagnosis and multi-seed sweep: SKIPPED because Part A %s.\n"), - bMarginArtifact - ? TEXT("found the connection in the previously excluded band") - : TEXT("did not produce a usable four-row measurement/query set")); + Summary += TEXT( + "Part C post-fix measurement and multi-seed sweep: SKIPPED because the margin " + "measurement set was not usable.\n"); } AddInfo(Summary); diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 33b863b..b78f362 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -1465,15 +1465,16 @@ namespace class FShaftFieldSource final : public IVoxelDensityOp { public: - FShaftFieldSource(const FVerticalShaftParams& InP, int32 Seed, float InExtraReach) + FShaftFieldSource(const FVerticalShaftParams& InP, int32 Seed, float InExtraReach, + float InSpineRadius) : P(InP), Salt((uint32)Seed ^ 0x53686674u) // 'Shft' — identique à GetVerticalShaftDensity - , ExtraReach(InExtraReach) {} + , ExtraReach(InExtraReach), SpineRadius(InSpineRadius) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } void PrepareChunk(const FVoxelOpContext&) override {} - struct FShaft { float X, Y, R; }; - struct FConn { FVector A, B; }; + struct FShaft { float X, Y, R; 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 // moment où le COMPILATEUR lit la SIGNATURE — les corps de méthodes sont différés, pas les @@ -1483,8 +1484,8 @@ namespace // compiler reads the SIGNATURE — bodies are deferred, return types are not. struct FCells { - TArray> Shafts; - TArray> Conns; + TArray> Shafts; + TArray> Conns; }; void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override @@ -1494,6 +1495,7 @@ namespace float CaveSDF = FLT_MAX; for (const FShaft& Sh : C.Shafts) { + if (Sh.bOriginSpine) continue; // VF_ApplyOriginSpine owns the structural column. const float DX = WorldX - Sh.X; const float DY = WorldY - Sh.Y; CaveSDF = FMath::Min(CaveSDF, FMath::Sqrt(DX * DX + DY * DY) - Sh.R); @@ -1501,7 +1503,7 @@ namespace const FVector Pos(WorldX, WorldY, WorldZ); for (const FConn& Cn : C.Conns) { - CaveSDF = FMath::Min(CaveSDF, VoxelSDF::Capsule(Pos, Cn.A, Cn.B, P.ConnectorRadius)); + CaveSDF = FMath::Min(CaveSDF, VoxelSDF::Capsule(Pos, Cn.A, Cn.B, Cn.Radius)); } InOut.Sdf = CaveSDF; } @@ -1518,7 +1520,8 @@ namespace // L'enveloppe doit couvrir les deux bornes de `Lerp(ShaftMinRadius, ShaftMaxRadius, t)`, // pas `ShaftMaxRadius` seul si l'asset inverse les paramètres. The bound must cover // both radius endpoints before adding connector and downstream reach. - const float Pad = FMath::Max3(P.ShaftMinRadius, P.ShaftMaxRadius, P.ConnectorRadius) + ExtraReach; + const float Pad = FMath::Max3(P.ShaftMinRadius, P.ShaftMaxRadius, + SpineConnectorRadius()) + ExtraReach; const FBox Padded = VoxelBox.ExpandBy(Pad); const float Spacing = FMath::Max(P.ShaftSpacing, 1.0f); @@ -1588,7 +1591,16 @@ namespace if (RollShaft(cx, cy, Sh)) { Near.Add(Sh); } } - const float ConnReach = P.ConnectorRadius + ExtraReach; + // Match GetCells' rule conservatively: any query cell in the central 3x3 may see + // a spine connector. This is a superset for a box, so it can only make the tile + // less likely to be classified AllSolid. + if (SpineRadius > 0.0f + && QX0 <= 1 && QX1 >= -1 + && QY0 <= 1 && QY1 >= -1) + { + Near.Add({0.0f, 0.0f, SpineRadius, true}); + } + const float BottomZ = P.StrateBottomWorldZ + P.BoundarySealThickness; const float TopZ = P.StrateTopWorldZ - P.BoundarySealThickness; @@ -1612,7 +1624,15 @@ namespace const uint32 PH = VoxelHash::Pair( FMath::RoundToInt(A.X), FMath::RoundToInt(A.Y), FMath::RoundToInt(B.X), FMath::RoundToInt(B.Y), Salt ^ 0xC04Eu); - if (VoxelHash::ToFloat01(PH) >= P.CrossConnectChance) { continue; } + const bool bSpineConnector = A.bOriginSpine || B.bOriginSpine; + if (VoxelHash::ToFloat01(PH) >= P.CrossConnectChance) + { + continue; + } + + const float ConnRadius = bSpineConnector + ? SpineConnectorRadius() : P.ConnectorRadius; + const float ConnReach = ConnRadius + ExtraReach; const float Zc = FMath::Lerp(BottomZ, TopZ, VoxelHash::ToFloat01(VoxelHash::Mix(PH))); @@ -1644,9 +1664,20 @@ 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.bOriginSpine = false; return true; } + /** Spine links use a radius above the repository's proven sup|FBM|=1.5 roughness + * envelope, so the roughness pass cannot pinch their centreline shut. */ + float SpineConnectorRadius() const + { + constexpr float RoughnessAbsBound = 1.5f; + const float RoughnessReach = FMath::Max(P.SurfaceRoughness, 0.0f) + * VOXEL_NOISE_SCALE * RoughnessAbsBound; + 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 * 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. */ @@ -1660,18 +1691,21 @@ namespace thread_local int32 VS_CX = INT32_MAX, VS_CY = INT32_MAX; thread_local uint32 VS_Salt = 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_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_Seal = -1.0f; if (CX != VS_CX || CY != VS_CY || Salt != VS_Salt || Spacing != VS_Spacing || P.ShaftDensity != VS_Dens || P.ShaftMinRadius != VS_MinR || P.ShaftMaxRadius != VS_MaxR || - P.CrossConnectChance != VS_Cross || + P.CrossConnectChance != VS_Cross || P.ConnectorRadius != VS_ConnR || + P.SurfaceRoughness != VS_Rough || SpineRadius != VS_SpineR || P.StrateBottomWorldZ != VS_BotZ || P.StrateTopWorldZ != VS_TopZ || P.BoundarySealThickness != VS_Seal) { VS_CX = CX; VS_CY = CY; VS_Salt = Salt; VS_Spacing = Spacing; VS_Dens = P.ShaftDensity; VS_MinR = P.ShaftMinRadius; VS_MaxR = P.ShaftMaxRadius; - VS_Cross = P.CrossConnectChance; + VS_Cross = P.CrossConnectChance; VS_ConnR = P.ConnectorRadius; + VS_Rough = P.SurfaceRoughness; VS_SpineR = SpineRadius; VS_BotZ = P.StrateBottomWorldZ; VS_TopZ = P.StrateTopWorldZ; VS_Seal = P.BoundarySealThickness; Cache.Shafts.Reset(); @@ -1684,6 +1718,12 @@ namespace if (RollShaft(CX + dx, CY + dy, Sh)) { 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}); + } + if (P.CrossConnectChance > 0.0f && Cache.Shafts.Num() >= 2) { const float BottomZ = P.StrateBottomWorldZ + P.BoundarySealThickness; @@ -1699,10 +1739,16 @@ namespace const uint32 PH = VoxelHash::Pair( FMath::RoundToInt(A.X), FMath::RoundToInt(A.Y), FMath::RoundToInt(B.X), FMath::RoundToInt(B.Y), Salt ^ 0xC04Eu); - if (VoxelHash::ToFloat01(PH) >= P.CrossConnectChance) { continue; } + const bool bSpineConnector = A.bOriginSpine || B.bOriginSpine; + if (VoxelHash::ToFloat01(PH) >= P.CrossConnectChance) + { + continue; + } const float Zc = FMath::Lerp(BottomZ, TopZ, VoxelHash::ToFloat01(VoxelHash::Mix(PH))); - Cache.Conns.Add({ FVector(A.X, A.Y, Zc), FVector(B.X, B.Y, Zc) }); + const float ConnectorR = bSpineConnector + ? SpineConnectorRadius() : P.ConnectorRadius; + Cache.Conns.Add({ FVector(A.X, A.Y, Zc), FVector(B.X, B.Y, Zc), ConnectorR }); } } } @@ -1712,6 +1758,7 @@ namespace FVerticalShaftParams P; uint32 Salt; float ExtraReach; + float SpineRadius; }; //========================================================================= @@ -1748,6 +1795,7 @@ namespace float BestSq = FLT_MAX; for (const FShaftFieldSource::FShaft& Sh : C.Shafts) { + if (Sh.bOriginSpine) continue; // ledges belong to real shafts, not the post. const float D2 = FMath::Square(WorldX - Sh.X) + FMath::Square(WorldY - Sh.Y); if (D2 < BestSq) { BestSq = D2; Near = &Sh; } } @@ -4198,7 +4246,8 @@ namespace VoxelDensityOps const float ExtraReach = FMath::Abs(P.SurfaceRoughness) * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + CarveBlend + 1.0f; - TUniquePtr ShaftSource = MakeUnique(P, Seed, ExtraReach); + TUniquePtr ShaftSource = + MakeUnique(P, Seed, ExtraReach, SpineRadius); const FShaftFieldSource* ShaftPtr = ShaftSource.Get(); OutStack.Add(MakeConstantRockSource(P.BaseDensity)); diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 1b8a9db..098689b 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -3701,23 +3701,26 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float // 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; }; - struct FLocalConn { FVector A, B; }; - thread_local TArray> Shafts; - thread_local TArray> Conns; + struct FLocalShaft { float X, Y, R; bool bOriginSpine; }; + struct FLocalConn { FVector A, B; float Radius; }; + thread_local TArray> Shafts; + 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_BotZ = FLT_MAX, VS_TopZ = FLT_MAX; + VS_Cross = -1.0f, VS_ConnR = -1.0f, VS_Rough = -1.0f, + VS_SpineR = -1.0f, VS_BotZ = FLT_MAX, VS_TopZ = FLT_MAX; 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.CrossConnectChance != VS_Cross || Params.ConnectorRadius != VS_ConnR || + Params.SurfaceRoughness != VS_Rough || OriginSpineRadius != VS_SpineR || Params.StrateBottomWorldZ != VS_BotZ || Params.StrateTopWorldZ != VS_TopZ) { 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_Cross = Params.CrossConnectChance; VS_ConnR = Params.ConnectorRadius; + VS_Rough = Params.SurfaceRoughness; VS_SpineR = OriginSpineRadius; VS_BotZ = Params.StrateBottomWorldZ; VS_TopZ = Params.StrateTopWorldZ; Shafts.Reset(); Conns.Reset(); @@ -3737,9 +3740,19 @@ 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.bOriginSpine = false; Shafts.Add(Sh); } + // The structural (0,0) spine is not re-carved here. It participates only as a + // connector endpoint. The local 3x3 set is origin-adjacent when its centre cell is in + // the central 3x3; the structural post remains the sole owner of the vertical column. + const bool bOriginNearby = FMath::Abs(CX) <= 1 && FMath::Abs(CY) <= 1; + if (bOriginNearby && OriginSpineRadius > 0.0f) + { + Shafts.Add({0.0f, 0.0f, OriginSpineRadius, true}); + } + // Horizontal connectors between nearby shaft pairs (hash-gated). if (Params.CrossConnectChance > 0.0f && Shafts.Num() >= 2) { @@ -3750,6 +3763,7 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float { const FLocalShaft& A = Shafts[i]; const FLocalShaft& B = Shafts[j]; + const bool bSpineConnector = A.bOriginSpine || B.bOriginSpine; const float DSq = FMath::Square(A.X - B.X) + FMath::Square(A.Y - B.Y); if (DSq > FMath::Square(Spacing * 1.6f)) continue; // only neighbours @@ -3760,7 +3774,13 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float if (VoxelHash::ToFloat01(PH) >= Params.CrossConnectChance) continue; const float Zc = FMath::Lerp(BottomZ, TopZ, VoxelHash::ToFloat01(VoxelHash::Mix(PH))); - Conns.Add({ FVector(A.X, A.Y, Zc), FVector(B.X, B.Y, Zc) }); + const float ConnectorR = bSpineConnector + ? FMath::Max( + Params.ConnectorRadius, + FMath::Max(Params.SurfaceRoughness, 0.0f) + * VOXEL_NOISE_SCALE * 1.5f + 1.0f) + : Params.ConnectorRadius; + Conns.Add({ FVector(A.X, A.Y, Zc), FVector(B.X, B.Y, Zc), ConnectorR }); } } } @@ -3770,13 +3790,14 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float // Vertical shafts as infinite cylinders (boundary seal handles the ends). for (const FLocalShaft& Sh : Shafts) { + if (Sh.bOriginSpine) continue; // VF_ApplyOriginSpine owns the structural column. const float DX = WorldX - Sh.X; const float DY = WorldY - Sh.Y; CaveSDF = FMath::Min(CaveSDF, FMath::Sqrt(DX * DX + DY * DY) - Sh.R); } for (const FLocalConn& C : Conns) { - CaveSDF = FMath::Min(CaveSDF, VoxelSDF::Capsule(Pos, C.A, C.B, Params.ConnectorRadius)); + CaveSDF = FMath::Min(CaveSDF, VoxelSDF::Capsule(Pos, C.A, C.B, C.Radius)); } // Wall roughness. @@ -3807,6 +3828,7 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float const FLocalShaft* Near = nullptr; float BestSq = FLT_MAX; for (const FLocalShaft& Sh : Shafts) { + if (Sh.bOriginSpine) continue; // ledges belong to real shafts, not the post. const float D2 = FMath::Square(WorldX - Sh.X) + FMath::Square(WorldY - Sh.Y); if (D2 < BestSq) { BestSq = D2; Near = &Sh; } }