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; } }