diff --git a/CODEMAP.md b/CODEMAP.md index 8579b32..34b615d 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -447,11 +447,11 @@ context. It never touches a generator, operator stack, manager, cache, or mutabl `VF_IsPassageLandingFloor` (h:~56/~189, implemented near `.cpp:2059`) turn each inter-strate mouth into a deterministic rounded room plus a hard support slab. The room is sized from the player capsule (minimum 12 voxels / 3 m flat floor, 12 voxels / 3 m clear height, and at least 14 voxels / -3.5 m authored width); every end also gets a flat-floor connector to the guaranteed `(0,0)` root -network because the pure source query is local rather than a component proof. The manager's shared -thread-local passage shortlist serves tube, room, connector, and floor evaluation; no landing -search occurs per voxel. `GeneratePassages` stores the full descriptors and the order-independence -test compares them bit-for-bit. +3.5 m authored width); the `(0,0)` origin landing is enlarged for the reserved future shaft, while +inter-strate mouths remain local rooms and deliberately have no landing-to-root connector road. +The manager's shared thread-local passage shortlist serves tube, room, and floor evaluation; no +landing search occurs per voxel. `GeneratePassages` stores the full descriptors and the +order-independence test compares them bit-for-bit. Performance note (h:209-220): caching rooms/tunnels once per chunk instead of per voxel is the single biggest CPU win. @@ -484,18 +484,19 @@ groups by generator type. **`Public/VoxelStrateManager.h` + `.cpp`** — `UVoxelStrateManager : UObject` (h:108). Maps depth→strate at runtime; owns passages. -- `FVoxelPassage` (h:39): standing endpoints, radius, type, control points, and upper/lower landing descriptors (room, floor, door, and root connector). +- `FVoxelPassage` (h:39): standing endpoints, radius, type, wandering control points, and + upper/lower local landing descriptors (room, floor, and door; no root connector). - `FStrateSlot` (h:84): definition + chunk-Z range + index. | Method | .cpp line | Role | |--------|-----------|------| | `Initialize` | 10 | Keeps the existing sequencing through `GeneratePassages`, but selects one slot source: a validated cooked season (exact ordered bounds/vector/recipe) or the unchanged authored fixed+sorted/shuffled pool. Assigned-invalid seasons fail closed. Authored source definitions are duplicated as content bags; generated slots use deterministic C++ defaults and zero auto-passages because schema 2 does not store passage configuration. | -| `GeneratePassages` | 247 | Deterministic passages between consecutive strates (per-strate `PassageConfig::Style` control points; auto passages retain the existing `EVoxelPassageType` default); placement and shape values are independently salted hashes of seed + boundary slot + connection index. Each mouth independently queries its own strate's pure player-fit `VF_SuggestLandingPoint` with its contract seed (cave room salt, otherwise world seed), may move within its archetype-specific budget, then receives a body-sized room/floor and a connector to the common per-strate origin-root annular hub. Unsupported/no-fit answers preserve the old random reach; a deterministic level dog-leg is inserted when the final ramp needs it to stay ≤44°. All descriptors and bounds are generated without reading another passage. | -| `EvaluateModifierSDF` | 357 | SDF of passage tubes plus landing rooms/connectors at a point. A per-chunk `thread_local` shortlist (`PassagesVersion`-stamped) → far chunks return `FLT_MAX` without walking `Passages`; the same cache serves floor membership. §8.10. | -| `ApplyPassageModifier` | 1319 | Shared legacy/op-stack passage post: carves tube and landing/connector air through the idempotent landing primitive, then restores the landing/connector support slab in internal positive-solid density. | +| `GeneratePassages` | 247 | Deterministic passages between consecutive strates (per-strate `PassageConfig::Style` control points; auto passages retain the existing `EVoxelPassageType` default); placement and shape values are independently salted hashes of seed + boundary slot + connection index. Each mouth independently queries its own strate's pure player-fit `VF_SuggestLandingPoint` with its contract seed (cave room salt, otherwise world seed), may move within its archetype-specific budget, then receives a body-sized local room/floor. The origin landing is reserved for the future shaft; no landing-to-root road is generated. Unsupported/no-fit answers preserve the old random reach; a deterministic level dog-leg is inserted when the final ramp needs it to stay ≤44°. All descriptors and bounds are generated without reading another passage. | +| `EvaluateModifierSDF` | 357 | SDF of passage tubes plus local landing rooms at a point. A per-chunk `thread_local` shortlist (`PassagesVersion`-stamped) → far chunks return `FLT_MAX` without walking `Passages`; the same cache serves floor membership. §8.10. | +| `ApplyPassageModifier` | 1319 | Shared legacy/op-stack passage post: carves tube and local landing air through the idempotent landing primitive, then restores the local landing support slab in internal positive-solid density. | | `VF_ApplyPassageLandingCarving` | — | Idempotent density-independent landing-air threshold shared by the structural pass and the post-disturbance MC backstop; keeps legacy and operator-stack results bit-identical. | | `ApplyPassageLandingFloorMC` | 1347 | Reasserts the same support slab after MC-space disturbances and before the final XY edge seal. | | `AnyPassageNearBox` | — | Conservative sphere-vs-AABB test of every passage's bound against a voxel box (+carve blend pad). Per TILE (ClassifyTile guard), never per voxel. | -| `AnyPassageLandingFloorNearBox` | — | Conservative room/connector-floor AABB guard. It kills the `AllAir` hypothesis wherever a proved landing floor can occur; the op-stack returns `Both` for the same box. | +| `AnyPassageLandingFloorNearBox` | — | Conservative local-landing-floor AABB guard. It kills the `AllAir` hypothesis wherever a proved landing floor can occur; the op-stack returns `Both` for the same box. | | `FindSlotIndexForChunkZ` | 427 | Z → layout index. | | `GetStrateAt` / `GetStrateIndex` | 443 / 455 | World-Z queries. | | `GetLayoutVersion` | h:161 (inline) | Layout/passage generation counter (= `PassagesVersion`, bumped by every `Initialize` and by the editor composer override). Hot-path callers key `thread_local` memos on it (strate-index memo in `GetDensityWithParams`, passage shortlist) so live changes never serve stale data. | @@ -714,7 +715,7 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor | `VoxelForgeComposerSeasonTest.cpp` | `VoxelForge.Composer.Season` | Existing compose/review/393,216-sample round trip plus schema hash tamper rejection, two independent runtime manager/generator instances, season-authoritative seed/spine/radius, unset-season regression, and brute-force verification of every sampled non-Mixed recipe tile verdict (up to 64). | | `VoxelForgeComposerCorpusFreeTest.cpp` | `VoxelForge.Composer.CorpusFree` | Shares each structure recipe across today's corpus blend, naive independent uniform rolls, and constraint-sampled rolls. The completed equal-arm world-scale run uses **16 candidates per arm**, step 4 / radius 256 / `MaxCells=8,000,000`, fixed passage-law mouths, and 40 box probes per candidate. Result: **10/16, 9/16, 8/16** survival; survivor feature-scale ranges are **8..268**, **8..60**, **8..92** voxels. Box checks: **185/246,235**, **260/346,060**, **299/397,969** proved/voxels, **0 violations** in every arm; constraint parameter and box-stop violations were also 0. | | `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, landing descriptors, control geometry, and bounds bit-for-bit. | -| `VoxelForgePassageOpenSpaceTest.cpp` | `VoxelForge.Determinism.PassageLandsInOpenSpace` | Uses the real fixture density path to check both generated mouths against independent source queries, then audits all 14 inter-strate landing ends: body-derived room dimensions, final-density flat-floor slope, explicit root-network join, seal containment, hot-call/rebuild timing, and targeted `ClassifyTile` box soundness. Reports source-fit/refused mouths and per-archetype landing slopes; fails on any floor, join, seal, or box violation. | +| `VoxelForgePassageOpenSpaceTest.cpp` | `VoxelForge.Determinism.PassageLandsInOpenSpace` | Uses the real fixture density path to check both generated mouths against independent source queries, then audits all inter-strate landing ends: body-derived room dimensions, final-density flat-floor slope, local floor/join geometry, seal containment, hot-call/rebuild timing, and targeted `ClassifyTile` box soundness. Reports source-fit/refused mouths and per-archetype landing slopes; fails on any floor, join, seal, or box violation. | | `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 uses the bounded 4-chunk density fixture, fits each measurement AABB to the arrival/departure mouth pair plus three shaft spacings (the prescribed local tree fallback), and caps the fitted control at 120,000,000 cells. It reports exact before/after seed-6 cell counts, reruns every negative with the fitted margin doubled, measures tree orphan candidates/path reachability and roughness-bubble proxies, asserts mouths remain inside the deterministic shaft feature core (the exact axis is an unsupported open cylinder), checks source/mirror physical paths, and requires 16/16 effective arrival→departure results across the 16 VerticalShafts seeds. Margin-binding negatives are reported as measurement limits, never as gap findings. The shaft seam test re-evaluates cell-boundary positions after warming distinct neighbouring chunk contexts. | | `VoxelForgeMazeSeamTest.cpp` | `VoxelForge.Generation.MazeSeamFreedom` | Re-evaluates **42** cell-boundary probes after warming six different neighbouring chunk contexts in both legacy and operator-stack paths. Latest result: **0 legacy mismatches, 0 operator mismatches**. The Maze source's 2×2×2 lower-node window is deliberately local; no wide collect is involved. | | `VoxelForgeWorldEdgeSealTest.cpp` | `VoxelForge.Generation.WorldEdgeSeal` | Positive samples outside the radius across every fixture strate, bit-identical interior negative control against radius 0, monotonic smooth-ramp check, radius-0 no-op, radial `ClassifyBox`/T1.d proofs brute-forced for every reported voxel, and synthetic plus generated near-rim passage coverage. Emits one aggregate summary with proved-box and voxel counts. | @@ -743,7 +744,7 @@ Stage order (negative=solid throughout). Each stage's anchor: | 4h — Pinch | 1142 | Passage bottlenecks. | | 5 — Worm tunnels | 1241 | abs(noise1)+abs(noise2), masked by distance-to-network (`WormNetworkRange`: braids hugging rooms/tunnels, no far-field speckle; 0 = legacy unmasked). | | 6 — Boundary seal | 1274 | Solid top/bottom shells (`ApplyBoundarySeal`). | -| 7 — Inter-strate passages + landings | `GetDensityWithParams`: 2217; `GetSlabDensity`: 2434+ | Carve passage tubes and deterministic room/connectors, restore their support floors, then leave the final XY seal to the outer MC post (`ApplyPassageModifier` / `ApplyPassageLandingFloorMC`). | +| 7 — Inter-strate passages + landings | `GetDensityWithParams`: 2217; `GetSlabDensity`: 2434+ | Carve wandering passage tubes and deterministic local landing rooms, restore their support floors, then leave the final XY seal to the outer MC post (`ApplyPassageModifier` / `ApplyPassageLandingFloorMC`). | ### 4.2 `GetSlabDensity` (FlatPlain / CrystalChamber) — VoxelGenerator.cpp:1306 | Step | Line | What | diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeLayoutOrderIndependenceTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeLayoutOrderIndependenceTest.cpp index be0c343..819ece5 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeLayoutOrderIndependenceTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeLayoutOrderIndependenceTest.cpp @@ -141,21 +141,11 @@ namespace return SameVectorBits(A.StandingPoint, B.StandingPoint) && SameVectorBits(A.DoorPoint, B.DoorPoint) && SameVectorBits(A.DoorDirection, B.DoorDirection) - && SameVectorBits(A.ConnectorStart, B.ConnectorStart) - && SameVectorBits(A.ConnectorControl, B.ConnectorControl) - && SameVectorBits(A.ConnectorEnd, B.ConnectorEnd) && VoxelForgeTest::BitEqual(A.FloorZ, B.FloorZ) && VoxelForgeTest::BitEqual(A.CeilingZ, B.CeilingZ) && VoxelForgeTest::BitEqual(A.HalfWidth, B.HalfWidth) && VoxelForgeTest::BitEqual(A.FloorThickness, B.FloorThickness) - && VoxelForgeTest::BitEqual(A.ConnectorRadius, B.ConnectorRadius) - && VoxelForgeTest::BitEqual(A.ConnectorCeilingZ, B.ConnectorCeilingZ) - && VoxelForgeTest::BitEqual(A.RootFloorZ, B.RootFloorZ) - && VoxelForgeTest::BitEqual(A.RootCeilingZ, B.RootCeilingZ) - && VoxelForgeTest::BitEqual(A.RootSpineRadius, B.RootSpineRadius) - && A.bSourcePlayerFit == B.bSourcePlayerFit - && A.bHasNetworkConnector == B.bHasNetworkConnector - && A.bHasConnectorBend == B.bHasConnectorBend; + && A.bSourcePlayerFit == B.bSourcePlayerFit; } bool SamePassage(const FVoxelPassage& A, const FVoxelPassage& B, FString& OutMismatch, diff --git a/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp index 5ba2fc7..9748a0b 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgePassageOpenSpaceTest.cpp @@ -492,7 +492,6 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) int32 NumLandingEnds = 0; int32 NumLandingFloorsPassed = 0; int32 NumLandingFloorFailures = 0; - int32 NumLandingNetworkJoins = 0; int32 NumLandingSealFailures = 0; int32 NumLandingFloorBoxes = 0; int32 NumLandingBoxProofs = 0; @@ -500,17 +499,12 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) int32 NumLandingBoxAllAir = 0; int32 NumLandingBoxViolations = 0; int32 NumLandingSourceFit = 0; - int32 NumLandingConnectorSlopesPassed = 0; - int32 NumLandingConnectorSlopeFailures = 0; float WorstLandingFloorGradient = 0.0f; - float WorstLandingConnectorGradient = 0.0f; FString FirstLandingFailure; FString LandingSlopeReport; FWalkableTunnelAudit TunnelAudit; constexpr float LandingWalkableAngleDegrees = VoxelPassageGeometry::WalkableTunnelMaxGradientDegrees; - constexpr float LandingWalkableSlope = - VoxelPassageGeometry::WalkableTunnelMaxGradient; for (const FVoxelPassage& Passage : Passages) { @@ -985,65 +979,10 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) FMath::DegreesToRadians(LandingWalkableAngleDegrees)); } - float ConnectorGradient = -1.0f; - bool bConnectorSlopePassed = false; - if (Landing.bHasNetworkConnector - && FMath::IsFinite(Landing.ConnectorStart.X) - && FMath::IsFinite(Landing.ConnectorStart.Y) - && FMath::IsFinite(Landing.ConnectorEnd.X) - && FMath::IsFinite(Landing.ConnectorEnd.Y) - && (!Landing.bHasConnectorBend - || (FMath::IsFinite(Landing.ConnectorControl.X) - && FMath::IsFinite(Landing.ConnectorControl.Y))) - && FMath::IsFinite(Landing.RootFloorZ) - && FMath::IsFinite(Landing.FloorZ)) - { - const FVector2D ConnectorStart( - Landing.ConnectorStart.X, Landing.ConnectorStart.Y); - const FVector2D ConnectorEnd( - Landing.ConnectorEnd.X, Landing.ConnectorEnd.Y); - const float DirectLength = (ConnectorEnd - ConnectorStart).Size(); - if (Landing.bHasConnectorBend) - { - const FVector2D ConnectorControl( - Landing.ConnectorControl.X, Landing.ConnectorControl.Y); - const float FinalLegLength = (ConnectorEnd - ConnectorControl).Size(); - const float RampLength = FMath::Max( - FinalLegLength - (Landing.RootSpineRadius > 0.0f - ? VoxelPassageGeometry::RootOverlapVoxels : 0.0f), - KINDA_SMALL_NUMBER); - ConnectorGradient = FMath::Abs( - Landing.RootFloorZ - Landing.FloorZ) / RampLength; - } - else - { - const float RampLength = FMath::Max( - DirectLength - FMath::Max(Landing.HalfWidth - 1.0f, 0.0f) - - (Landing.RootSpineRadius > 0.0f - ? VoxelPassageGeometry::RootOverlapVoxels : 0.0f), - KINDA_SMALL_NUMBER); - ConnectorGradient = FMath::Abs( - Landing.RootFloorZ - Landing.FloorZ) / RampLength; - } - bConnectorSlopePassed = FMath::IsFinite(ConnectorGradient) - && ConnectorGradient <= LandingWalkableSlope + KINDA_SMALL_NUMBER; - } - - if (bConnectorSlopePassed) - { - ++NumLandingConnectorSlopesPassed; - WorstLandingConnectorGradient = FMath::Max( - WorstLandingConnectorGradient, ConnectorGradient); - } - else - { - ++NumLandingConnectorSlopeFailures; - RecordFailure(FString::Printf( - TEXT("landing at strate %d has connector slope %.6f above the %.6f walkable limit"), - StrateIndex, ConnectorGradient, LandingWalkableSlope)); - } - - const bool bLandingSlopePassed = bFloorPassed && bConnectorSlopePassed; + // Landing rooms are intentionally local now. Their flat floors are the only landing + // contract; the removed radial root connectors were the source of the straight roads + // through unrelated rooms. + const bool bLandingSlopePassed = bFloorPassed; if (bLandingSlopePassed) { ++NumLandingFloorsPassed; @@ -1076,16 +1015,6 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) { ++NumLandingSourceFit; } - if (Landing.bHasNetworkConnector) - { - ++NumLandingNetworkJoins; - } - else - { - RecordFailure(FString::Printf( - TEXT("landing at strate %d has neither a source fit nor a deterministic network connector"), - StrateIndex)); - } // Exercise the same box shortcut that can otherwise erase a support slab. A uniform // verdict is allowed only if every exact ClassifyTile lattice sample agrees with the @@ -1146,15 +1075,12 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) const FString LandingArchetype = ArchetypeName(Definition->GeneratorType); LandingSlopeReport += FString::Printf( - TEXT("%s[%d:%s]=floor:%s(%.6f),connector:%s(%.6f)%s "), + TEXT("%s[%d:%s]=flat-floor:%s(%.6f) "), *LandingArchetype, StrateIndex, EndIndex == 0 ? TEXT("upper") : TEXT("lower"), bFloorPassed ? TEXT("PASS") : TEXT("FAIL"), - LandingGradient, - bConnectorSlopePassed ? TEXT("PASS") : TEXT("FAIL"), - ConnectorGradient, - Landing.bHasConnectorBend ? TEXT("[bend]") : TEXT("")); + LandingGradient); } } @@ -1237,18 +1163,12 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) NumUnsupported, NumSupportedWithoutPoint)); AddInfo(FString::Printf( - TEXT("Landing geometry: %d/%d ends passed the final-density floor test (worst room gradient %.6f), connector slopes %d/%d (worst %.6f; walkable limit %.6f), source-fit anchors %d/%d, explicit root-network joins %d/%d, seal violations %d, geometry/floor failures %d."), + TEXT("Landing geometry: %d/%d ends passed the final-density flat-floor test (worst gradient %.6f), source-fit anchors %d/%d, seal violations %d, geometry/floor failures %d."), NumLandingFloorsPassed, NumLandingEnds, WorstLandingFloorGradient, - NumLandingConnectorSlopesPassed, - NumLandingEnds, - WorstLandingConnectorGradient, - LandingWalkableSlope, NumLandingSourceFit, NumLandingEnds, - NumLandingNetworkJoins, - NumLandingEnds, NumLandingSealFailures, NumLandingFloorFailures)); if (!LandingSlopeReport.IsEmpty()) @@ -1372,9 +1292,6 @@ bool FVoxelForgePassageLandsInOpenSpaceTest::RunTest(const FString& Parameters) return NumInterStratePassages > 0 && NumLandingEnds == NumInterStratePassages * 2 && NumLandingFloorsPassed == NumLandingEnds - && NumLandingConnectorSlopesPassed == NumLandingEnds - && NumLandingConnectorSlopeFailures == 0 - && NumLandingNetworkJoins == NumLandingEnds && NumLandingSealFailures == 0 && NumLandingFloorFailures == 0 && NumLandingBoxViolations == 0 diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeScaleDiagnosisTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeScaleDiagnosisTest.cpp index 0326fd2..f0a5b64 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeScaleDiagnosisTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeScaleDiagnosisTest.cpp @@ -420,16 +420,23 @@ namespace for (int32 TunnelIndex = 0; TunnelIndex < Cache.Tunnels.Num(); ++TunnelIndex) { const FCachedTunnel& Tunnel = Cache.Tunnels[TunnelIndex]; - if (Tunnel.bHasMidpoint) + if (Tunnel.ControlPoints.Num() >= 2 + && Tunnel.ControlRadii.Num() == Tunnel.ControlPoints.Num()) { - VF_ConsiderTunnelSegment( - World, Params, Core, Tunnel.EndpointA, Tunnel.Midpoint, - Tunnel.RadiusA, Tunnel.RadiusMid, - FString::Printf(TEXT("tunnel[%d].A-mid"), TunnelIndex), OutBest); - VF_ConsiderTunnelSegment( - World, Params, Core, Tunnel.Midpoint, Tunnel.EndpointB, - Tunnel.RadiusMid, Tunnel.RadiusB, - FString::Printf(TEXT("tunnel[%d].mid-B"), TunnelIndex), OutBest); + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Tunnel.ControlPoints.Num(); + ++SegmentIndex) + { + VF_ConsiderTunnelSegment( + World, Params, Core, + Tunnel.ControlPoints[SegmentIndex], + Tunnel.ControlPoints[SegmentIndex + 1], + Tunnel.ControlRadii[SegmentIndex], + Tunnel.ControlRadii[SegmentIndex + 1], + FString::Printf(TEXT("tunnel[%d].segment%d"), + TunnelIndex, SegmentIndex), + OutBest); + } } else { diff --git a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp index 848906a..e0bd713 100644 --- a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp +++ b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -8,9 +8,9 @@ // Called PER VOXEL using the cached data. // // FEATURES: -// - Origin room: guaranteed large room at (0,0) per strate — the hub / (0,0) spine +// - Origin room: guaranteed finite landing room at (0,0) per strate — reserved for the spine // - Hash-based rooms: ellipsoid, rounded box, and capsule shapes -// - Tunnels: tapered capsules with curved paths (midpoint warping) +// - Tunnels: tapered capsules with deterministic wandering control-point chains // - Horizontal bias: tunnels prefer horizontal connections // - Endpoint Z offset: tunnels enter rooms at different heights // - Per-room/tunnel distance culling: skip SDFs that can't affect this voxel @@ -50,6 +50,9 @@ struct FBuildRoom bool bIsOrigin; // True for the origin room at (0,0) bool bStore; // True if this room can affect a voxel in THIS chunk // (collected for connectivity decisions either way). + FVector PlayerFitPoint = FVector::ZeroVector; + bool bHasPlayerFitPoint = false; + bool bPlayerFitAttempted = false; }; //============================================================================= @@ -479,20 +482,146 @@ namespace bool bOrigin = false; }; - float VF_RoomFloorZ( - const FVFRoomLandingSite& Site, + FVector VF_ApplyCaveWarp( + const FVector& WorldPoint, + const FStrateGenerationParams& Params, + uint32 Seed) + { + float EffectiveZ = WorldPoint.Z; + if (Params.VerticalScale > 0.0f && Params.VerticalScale != 1.0f) + { + EffectiveZ = WorldPoint.Z / Params.VerticalScale; + } + + FVector Warped(WorldPoint.X, WorldPoint.Y, EffectiveZ); + if (Params.CaveWarpStrength <= 0.0f) + { + return Warped; + } + + const float Frequency = Params.CaveWarpFrequency; + const float Strength = Params.CaveWarpStrength; + Warped.X += VoxelNoise::Perlin3D( + WorldPoint.X * Frequency + VoxelHash::SeedOffset(Seed, 0.37f), + WorldPoint.Y * Frequency + 1.3f, + EffectiveZ * Frequency + 5.7f) + * VOXEL_NOISE_SCALE * Strength; + Warped.Y += VoxelNoise::Perlin3D( + WorldPoint.X * Frequency + 7.1f, + WorldPoint.Y * Frequency + VoxelHash::SeedOffset(Seed, 0.59f), + EffectiveZ * Frequency + 2.3f) + * VOXEL_NOISE_SCALE * Strength; + Warped.Z += VoxelNoise::Perlin3D( + WorldPoint.X * Frequency + 11.3f, + WorldPoint.Y * Frequency + 9.7f, + EffectiveZ * Frequency + VoxelHash::SeedOffset(Seed, 0.41f)) + * VOXEL_NOISE_SCALE * Strength; + return Warped; + } + + FVector VF_UnwarpCavePoint( + const FVector& TargetSDFPoint, + const FStrateGenerationParams& Params, + uint32 Seed) + { + FVector WorldPoint = TargetSDFPoint; + const float ZScale = Params.VerticalScale > 0.0f + ? Params.VerticalScale : 1.0f; + if (ZScale != 1.0f) + { + WorldPoint.Z *= ZScale; + } + if (Params.CaveWarpStrength <= 0.0f) + { + return WorldPoint; + } + + // The default warp is a contraction at its authored frequency. A fixed eight-step + // Newton/Picard correction is enough to put a world landing back on the exact cached SDF + // point, while keeping the query allocation-free and deterministic. + for (int32 Iteration = 0; Iteration < 8; ++Iteration) + { + const FVector Mapped = VF_ApplyCaveWarp(WorldPoint, Params, Seed); + const FVector Error = TargetSDFPoint - Mapped; + if (!Error.ContainsNaN() + && FMath::IsFinite(Error.X) + && FMath::IsFinite(Error.Y) + && FMath::IsFinite(Error.Z)) + { + WorldPoint.X += Error.X; + WorldPoint.Y += Error.Y; + WorldPoint.Z += Error.Z * ZScale; + } + } + return WorldPoint; + } + + float VF_RoomFloorZFromParts( + const FVector& RoomCenter, + float RoomRadiusXY, + float RoomRadiusZ, + uint32 RoomHash, + uint32 WarpSeed, const FStrateGenerationParams& Params) { - if (Params.RoomFloorCutMin >= 1.0f && Params.RoomFloorCutMax >= 1.0f) - { - return Site.Center.Z - Site.RadiusZ; - } - const float Roll = VoxelHash::ToFloat01(VoxelHash::Mix(Site.Hash ^ 0xF100F2u)); + const float Roll = VoxelHash::ToFloat01(VoxelHash::Mix(RoomHash ^ 0xF100F2u)); const float FloorCut = FMath::Lerp( FMath::Min(Params.RoomFloorCutMin, Params.RoomFloorCutMax), FMath::Max(Params.RoomFloorCutMin, Params.RoomFloorCutMax), Roll); - return Site.Center.Z - Site.RadiusZ * FloorCut; + float FloorZ = FloorCut < 1.0f + ? RoomCenter.Z - RoomRadiusZ * FloorCut + : RoomCenter.Z - RoomRadiusZ; + if (FloorCut < 1.0f && Params.FloorReliefStrength > 0.0f) + { + // Match the evaluator's domain-warped XY input at the room centre. The local landing + // query still searches a bounded vertical band for the support patch around this + // estimate because the capsule covers neighbouring XY samples too. + const float EffectiveZ = Params.VerticalScale > 0.0f + ? RoomCenter.Z / Params.VerticalScale : RoomCenter.Z; + float SampleX = RoomCenter.X; + float SampleY = RoomCenter.Y; + if (Params.CaveWarpStrength > 0.0f) + { + const float Frequency = Params.CaveWarpFrequency; + const float Strength = Params.CaveWarpStrength; + SampleX += VoxelNoise::Perlin3D( + RoomCenter.X * Frequency + VoxelHash::SeedOffset(WarpSeed, 0.37f), + RoomCenter.Y * Frequency + 1.3f, + EffectiveZ * Frequency + 5.7f) + * VOXEL_NOISE_SCALE * Strength; + SampleY += VoxelNoise::Perlin3D( + RoomCenter.X * Frequency + 7.1f, + RoomCenter.Y * Frequency + VoxelHash::SeedOffset(WarpSeed, 0.59f), + EffectiveZ * Frequency + 2.3f) + * VOXEL_NOISE_SCALE * Strength; + } + + const float Frequency = Params.FloorReliefFrequency; + const float FloorSeed = static_cast( + VoxelHash::Mix(RoomHash ^ 0xF100F1u)) * 0.00001f; + float Noise = FMath::PerlinNoise2D(FVector2D( + SampleX * Frequency + FloorSeed, + SampleY * Frequency + FloorSeed * 1.7f)) * 0.65f; + Noise += FMath::PerlinNoise2D(FVector2D( + SampleX * Frequency * 2.3f + FloorSeed * 3.1f, + SampleY * Frequency * 2.3f + FloorSeed * 5.3f)) * 0.35f; + FloorZ += Noise * VOXEL_NOISE_SCALE * Params.FloorReliefStrength; + } + // Keep this in the same (unwarped SDF) coordinate space as FCachedRoom::FloorCutZ. The + // production caller warps the query position before EvaluateSDFCached, and the exact fit + // predicate below samples that same warped field. This value is only a bounded search + // anchor; it must not pre-apply the Z warp or the room would be shifted twice. + return FloorZ; + } + + float VF_RoomFloorZ( + const FVFRoomLandingSite& Site, + uint32 WarpSeed, + const FStrateGenerationParams& Params) + { + return VF_RoomFloorZFromParts( + Site.Center, Site.RadiusXY, Site.RadiusZ, Site.Hash, WarpSeed, Params); } float VF_EvaluateRoomLandingDensity( @@ -505,33 +634,8 @@ namespace float WorldY, float WorldZ) { - float EffectiveZ = WorldZ; - if (Params.VerticalScale > 0.0f && Params.VerticalScale != 1.0f) - { - EffectiveZ = WorldZ / Params.VerticalScale; - } - - FVector Position(WorldX, WorldY, EffectiveZ); - if (Params.CaveWarpStrength > 0.0f) - { - const float Frequency = Params.CaveWarpFrequency; - const float Strength = Params.CaveWarpStrength; - Position.X += VoxelNoise::Perlin3D( - WorldX * Frequency + VoxelHash::SeedOffset(Seed, 0.37f), - WorldY * Frequency + 1.3f, - EffectiveZ * Frequency + 5.7f) - * VOXEL_NOISE_SCALE * Strength; - Position.Y += VoxelNoise::Perlin3D( - WorldX * Frequency + 7.1f, - WorldY * Frequency + VoxelHash::SeedOffset(Seed, 0.59f), - EffectiveZ * Frequency + 2.3f) - * VOXEL_NOISE_SCALE * Strength; - Position.Z += VoxelNoise::Perlin3D( - WorldX * Frequency + 11.3f, - WorldY * Frequency + 9.7f, - EffectiveZ * Frequency + VoxelHash::SeedOffset(Seed, 0.41f)) - * VOXEL_NOISE_SCALE * Strength; - } + const FVector Position = VF_ApplyCaveWarp( + FVector(WorldX, WorldY, WorldZ), Params, static_cast(Seed)); const uint32 ShapeHash = VoxelHash::Mix(Site.Hash ^ 0xDEADBEEFu); const float ShapeRoll = Site.bOrigin ? 0.0f : VoxelHash::ToFloat01(ShapeHash); @@ -597,6 +701,109 @@ namespace return -InternalDensity; } + bool VF_FindPlayerFitPointForRoom( + const FStrateGenerationParams& Params, + const FVFRoomLandingSite& Site, + float StrateTopZ, + float StrateBottomZ, + uint32 WarpSeed, + FVector& OutPoint) + { + OutPoint = FVector::ZeroVector; + const float FloorZ = VF_RoomFloorZ(Site, WarpSeed, Params); + if (!FMath::IsFinite(FloorZ)) + { + return false; + } + + const float MaxStepHeightVoxels = + FVoxelPlayerCapsuleConstants::MaxStepHeightMeters + / FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + const float FloorReliefBound = FMath::Abs(Params.FloorReliefStrength) + * VOXEL_NOISE_SCALE; + const float CaveWarpBound = FMath::Abs(Params.CaveWarpStrength) + * VOXEL_NOISE_SCALE; + const int32 ReliefSearch = FMath::Clamp( + FMath::CeilToInt(FloorReliefBound + CaveWarpBound) + 8, + 8, + 128); + FVoxelStrateMeasureSettings FitSettings; + const auto SampleRoom = [&](float X, float Y, float Z) + { + return VF_EvaluateRoomLandingDensity( + Site, Params, WarpSeed, + StrateTopZ, StrateBottomZ, X, Y, Z); + }; + + const int32 LocalExtent = 0; + TArray> LocalProbes; + for (int32 LocalY = -LocalExtent; LocalY <= LocalExtent; LocalY += 2) + { + for (int32 LocalX = -LocalExtent; LocalX <= LocalExtent; LocalX += 2) + { + if (static_cast(LocalX * LocalX + LocalY * LocalY) + > FMath::Square(Site.RadiusXY * 0.75f)) + { + continue; + } + LocalProbes.Add(FIntPoint(LocalX, LocalY)); + } + } + if ((LocalExtent & 1) != 0) + { + LocalProbes.Add(FIntPoint::ZeroValue); + } + LocalProbes.Sort([](const FIntPoint& A, const FIntPoint& B) + { + const int32 ADistanceSq = A.X * A.X + A.Y * A.Y; + const int32 BDistanceSq = B.X * B.X + B.Y * B.Y; + if (ADistanceSq != BDistanceSq) + { + return ADistanceSq < BDistanceSq; + } + return A.X != B.X ? A.X < B.X : A.Y < B.Y; + }); + + for (const FIntPoint& LocalProbe : LocalProbes) + { + const FVector CandidateFeetBase = VF_UnwarpCavePoint( + FVector( + Site.Center.X + static_cast(LocalProbe.X), + Site.Center.Y + static_cast(LocalProbe.Y), + FloorZ + MaxStepHeightVoxels + 0.5f), + Params, + WarpSeed); + for (int32 OffsetIndex = 0; + OffsetIndex <= ReliefSearch * 2; + ++OffsetIndex) + { + const int32 SignedOffset = OffsetIndex == 0 + ? 0 + : ((OffsetIndex & 1) != 0 + ? -((OffsetIndex + 1) / 2) + : OffsetIndex / 2); + if (FMath::Abs(SignedOffset) > ReliefSearch) + { + continue; + } + const FVector CandidateFeet = CandidateFeetBase + + FVector(0.0f, 0.0f, static_cast(SignedOffset)); + if (VF_ValidatePlayerFitPose( + FitSettings, + CandidateFeet, + StrateTopZ, + StrateBottomZ, + Params.BoundarySealThickness, + SampleRoom, + OutPoint)) + { + return true; + } + } + } + return false; + } + float VF_EvaluateSlabLandingDensity( const FSlabGenerationParams& Params, uint32 Seed, @@ -864,7 +1071,8 @@ namespace // recherche est bornée : un voisinage vide signifie "pas de réponse", jamais un point inventé. bool VF_FindNearestHashRoomLandingPoint( const FStrateGenerationParams& Params, - int32 Seed, + int32 RoomSeed, + int32 WarpSeed, float StrateTopZ, float StrateBottomZ, float WorldX, @@ -935,10 +1143,19 @@ namespace int32 BestCellY = 0; bool bFound = false; + struct FRoomCandidate + { + FVFRoomLandingSite Site; + float DistSq = FLT_MAX; + int32 CellX = 0; + int32 CellY = 0; + }; + TArray> Candidates; + auto ConsiderCell = [&](int32 CellX, int32 CellY) { // Keep this roll byte-for-byte aligned with BuildChunkCache's room placement. - const uint32 CellHash = VoxelHash::Cell(CellX, CellY, (uint32)Seed); + const uint32 CellHash = VoxelHash::Cell(CellX, CellY, (uint32)RoomSeed); if (VoxelHash::ToFloat01(CellHash) >= Params.RoomDensity) return; const float JitterX = VoxelHash::ToFloat01(VoxelHash::Mix(CellHash ^ 0x12345678u)); @@ -952,6 +1169,25 @@ namespace const float DY = RoomY - WorldY; const float DistSq = DX * DX + DY * DY; + FRoomCandidate Candidate; + Candidate.Site = FVFRoomLandingSite{ + FVector(RoomX, RoomY, RoomZ), + 0.0f, + 0.0f, + CellHash, + false }; + const float CandidateSizeFactor = VoxelHash::ToFloat01( + VoxelHash::Mix(CellHash ^ 0xFEDCBA98u)); + Candidate.Site.RadiusXY = FMath::Lerp( + FMath::Min(Params.MinRoomRadius, Params.MaxRoomRadius), + FMath::Max(Params.MinRoomRadius, Params.MaxRoomRadius), + CandidateSizeFactor); + Candidate.Site.RadiusZ = Candidate.Site.RadiusXY * Params.RoomHeightRatio; + Candidate.DistSq = DistSq; + Candidate.CellX = CellX; + Candidate.CellY = CellY; + Candidates.Add(Candidate); + const bool bCloser = DistSq < BestDistSq; const bool bTie = DistSq == BestDistSq && (CellX < BestCellX || (CellX == BestCellX && CellY < BestCellY)); @@ -1053,38 +1289,133 @@ namespace // The budget is bounded on purpose: passages sit at a deliberate distance from the (0,0) // spine so a lost player can find them, and an unbounded snap would dissolve that silently. // Over budget => decline, and the caller keeps its old random reach. - if (BestDistSq > MaxLateralSnap * MaxLateralSnap) - { - return false; - } - - const FVFRoomLandingSite Site{ - FVector(BestRoomX, BestRoomY, BestZ), - BestRadiusXY, - BestRadiusZ, - BestHash, - false }; - const float FloorZ = VF_RoomFloorZ(Site, Params); - FVoxelStrateMeasureSettings FitSettings; + const float MaxLateralSnapSq = MaxLateralSnap * MaxLateralSnap; const float MaxStepHeightVoxels = FVoxelPlayerCapsuleConstants::MaxStepHeightMeters / FVoxelPlayerCapsuleConstants::VoxelSizeMeters; - const FVector CandidateFeet( - Site.Center.X, Site.Center.Y, FloorZ + MaxStepHeightVoxels + 0.5f); - const auto SampleRoom = [&](float X, float Y, float Z) + // The room-centre estimate is analytic, but production evaluates the room after the + // bounded XYZ cave warp. A support stencil must be allowed to descend through that + // displacement as well as the authored floor relief; otherwise a valid room can be + // incorrectly downgraded to a random mouth whenever its shape bottom is more than one + // player step below the nominal cut. + const float FloorReliefBound = FMath::Abs(Params.FloorReliefStrength) + * VOXEL_NOISE_SCALE; + const float CaveWarpBound = FMath::Abs(Params.CaveWarpStrength) + * VOXEL_NOISE_SCALE; + const int32 ReliefSearch = FMath::Clamp( + FMath::CeilToInt(FloorReliefBound + CaveWarpBound) + 8, + 8, + 128); + FVoxelStrateMeasureSettings FitSettings; + + // The nearest room is preferred, but it is not allowed to turn a local relief/shape + // quirk into a random landing. Test every deterministic candidate inside the lateral + // budget, nearest first, and return only after the full source fit stencil succeeds. + Candidates.Sort([](const FRoomCandidate& A, const FRoomCandidate& B) { - return VF_EvaluateRoomLandingDensity( - Site, Params, static_cast(Seed), - StrateTopZ, StrateBottomZ, X, Y, Z); - }; - return VF_ValidatePlayerFitPose( - FitSettings, - CandidateFeet, - StrateTopZ, - StrateBottomZ, - Params.BoundarySealThickness, - SampleRoom, - OutPoint); + if (A.DistSq != B.DistSq) + { + return A.DistSq < B.DistSq; + } + return A.CellX != B.CellX ? A.CellX < B.CellX : A.CellY < B.CellY; + }); + + for (const FRoomCandidate& Candidate : Candidates) + { + if (Candidate.DistSq > MaxLateralSnapSq) + { + continue; + } + + const FVFRoomLandingSite& Site = Candidate.Site; + const float FloorZ = VF_RoomFloorZ(Site, static_cast(WarpSeed), Params); + const auto SampleRoom = [&](float X, float Y, float Z) + { + return VF_EvaluateRoomLandingDensity( + Site, Params, static_cast(WarpSeed), + StrateTopZ, StrateBottomZ, X, Y, Z); + }; + // Cave warp can move the unwarped room centre close to a wall. Probe a deterministic + // coarse disk around that centre, nearest first, while leaving the final decision to + // the exact capsule/stencil test. The disk stays well inside the authored room so it + // cannot silently turn an adjacent room into this room's landing. + const int32 LocalExtent = FMath::Max( + 2, + FMath::FloorToInt(FMath::Min( + Site.RadiusXY * 0.75f, + FMath::Max(CaveWarpBound + 4.0f, 8.0f)))); + TArray> LocalProbes; + for (int32 LocalY = -LocalExtent; LocalY <= LocalExtent; LocalY += 2) + { + for (int32 LocalX = -LocalExtent; LocalX <= LocalExtent; LocalX += 2) + { + if (static_cast(LocalX * LocalX + LocalY * LocalY) + > FMath::Square(Site.RadiusXY * 0.75f)) + { + continue; + } + LocalProbes.Add(FIntPoint(LocalX, LocalY)); + } + } + if ((LocalExtent & 1) != 0) + { + LocalProbes.Add(FIntPoint::ZeroValue); + } + LocalProbes.Sort([](const FIntPoint& A, const FIntPoint& B) + { + const int32 ADistanceSq = A.X * A.X + A.Y * A.Y; + const int32 BDistanceSq = B.X * B.X + B.Y * B.Y; + if (ADistanceSq != BDistanceSq) + { + return ADistanceSq < BDistanceSq; + } + return A.X != B.X ? A.X < B.X : A.Y < B.Y; + }); + + for (const FIntPoint& LocalProbe : LocalProbes) + { + const FVector CandidateFeetBase = VF_UnwarpCavePoint( + FVector( + Site.Center.X + static_cast(LocalProbe.X), + Site.Center.Y + static_cast(LocalProbe.Y), + FloorZ + MaxStepHeightVoxels + 0.5f), + Params, + static_cast(WarpSeed)); + + // The production room floor includes bounded hash relief and the remaining local + // warp variation. Search a deterministic vertical band around the analytic anchor + // so valid rooms are never downgraded merely because the centre estimate is off by + // a few voxels. + for (int32 OffsetIndex = 0; + OffsetIndex <= ReliefSearch * 2; + ++OffsetIndex) + { + const int32 SignedOffset = OffsetIndex == 0 + ? 0 + : ((OffsetIndex & 1) != 0 + ? -((OffsetIndex + 1) / 2) + : OffsetIndex / 2); + if (FMath::Abs(SignedOffset) > ReliefSearch) + { + continue; + } + const FVector CandidateFeet = CandidateFeetBase + + FVector(0.0f, 0.0f, static_cast(SignedOffset)); + if (VF_ValidatePlayerFitPose( + FitSettings, + CandidateFeet, + StrateTopZ, + StrateBottomZ, + Params.BoundarySealThickness, + SampleRoom, + OutPoint)) + { + return true; + } + } + } + } + return false; } // The slab source is an XY height band. Evaluate the same two pure height fields as @@ -1650,6 +1981,14 @@ namespace SampleShaft, OutPoint)) { + if (Seed == 14 && FMath::IsNearlyEqual(StrateBottomZ, -736.0f)) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeExplore][TemporaryVerticalLanding] axis=(%.3f,%.3f) " + "safeRadius=%.3f candidate=(%.3f,%.3f,%.3f)"), + BestAxisX, BestAxisY, BestSafeRadius, + CandidateFeet.X, CandidateFeet.Y, CandidateFeet.Z); + } return true; } } @@ -2060,123 +2399,14 @@ namespace { constexpr float VF_LandingCarveSafetyMargin = VoxelPassageGeometry::SealSafetyMarginVoxels; +} - struct FVoxelPassageLandingConnectorProjection - { - FVector2D Closest = FVector2D::ZeroVector; - float DistanceAlong = 0.0f; - float TotalLength = 0.0f; - float SegmentLength = 0.0f; - float SegmentParameter = 0.0f; - int32 SegmentIndex = 0; - }; - - FVoxelPassageLandingConnectorProjection VF_ProjectPassageLandingConnector( - const FVector& Position, - const FVoxelPassageLanding& Landing) - { - const FVector2D A(Landing.ConnectorStart.X, Landing.ConnectorStart.Y); - const FVector2D Control(Landing.ConnectorControl.X, Landing.ConnectorControl.Y); - const FVector2D B(Landing.ConnectorEnd.X, Landing.ConnectorEnd.Y); - const FVector2D P(Position.X, Position.Y); - const bool bBend = Landing.bHasConnectorBend; - const FVector2D FirstEnd = bBend ? Control : B; - const FVector2D FirstVector = FirstEnd - A; - const FVector2D SecondVector = B - Control; - const float FirstLength = FirstVector.Size(); - const float SecondLength = bBend ? SecondVector.Size() : 0.0f; - - FVoxelPassageLandingConnectorProjection Result; - Result.TotalLength = FirstLength + SecondLength; - Result.Closest = A; - Result.SegmentLength = FirstLength; - if (Result.TotalLength <= KINDA_SMALL_NUMBER) - { - return Result; - } - - float BestDistanceSquared = FLT_MAX; - const auto ProjectSegment = [&](const FVector2D& SegmentStart, - const FVector2D& SegmentEnd, - float SegmentOffset, - int32 SegmentIndex) - { - const FVector2D Segment = SegmentEnd - SegmentStart; - const float LengthSquared = Segment.SizeSquared(); - const float Parameter = LengthSquared > KINDA_SMALL_NUMBER - ? FMath::Clamp(FVector2D::DotProduct(P - SegmentStart, Segment) - / LengthSquared, 0.0f, 1.0f) - : 0.0f; - const FVector2D Candidate = SegmentStart + Segment * Parameter; - const float DistanceSquared = (P - Candidate).SizeSquared(); - if (DistanceSquared < BestDistanceSquared - || (DistanceSquared == BestDistanceSquared - && SegmentIndex < Result.SegmentIndex)) - { - BestDistanceSquared = DistanceSquared; - Result.Closest = Candidate; - Result.DistanceAlong = SegmentOffset - + FMath::Sqrt(FMath::Max(LengthSquared, 0.0f)) * Parameter; - Result.SegmentLength = FMath::Sqrt(FMath::Max(LengthSquared, 0.0f)); - Result.SegmentParameter = Parameter; - Result.SegmentIndex = SegmentIndex; - } - }; - - ProjectSegment(A, FirstEnd, 0.0f, 0); - if (bBend) - { - ProjectSegment(Control, B, FirstLength, 1); - } - return Result; - } - - float VF_PassageLandingConnectorFloorParameter( - const FVoxelPassageLandingConnectorProjection& Projection, - const FVoxelPassageLanding& Landing) - { - if (Projection.TotalLength <= KINDA_SMALL_NUMBER) - { - return 0.0f; - } - - if (Landing.bHasConnectorBend) - { - // The first leg is deliberately level with the room. The second leg carries the - // complete rise and ends with a short level overlap inside the root landing room. - if (Projection.SegmentIndex == 0) - { - return 0.0f; - } - const float RootFlatLength = Landing.RootSpineRadius > 0.0f - ? VoxelPassageGeometry::RootOverlapVoxels : 0.0f; - const float RampLength = FMath::Max( - Projection.SegmentLength - RootFlatLength, KINDA_SMALL_NUMBER); - return FMath::Clamp( - Projection.SegmentParameter * Projection.SegmentLength / RampLength, - 0.0f, 1.0f); - } - - // The direct zero-rise case remains a single segment. Keep the old end aprons for - // descriptors produced by older cooked data, even though new passages use the bend when - // a non-zero rise needs a guaranteed slope. - const float SourceFlatLength = FMath::Max(Landing.HalfWidth - 1.0f, 0.0f); - const float RootFlatLength = Landing.RootSpineRadius > 0.0f - ? VoxelPassageGeometry::RootOverlapVoxels : 0.0f; - const float SourceDistance = FMath::Clamp( - SourceFlatLength, 0.0f, Projection.TotalLength * 0.49f); - const float RootDistance = FMath::Clamp( - Projection.TotalLength - RootFlatLength, - Projection.TotalLength * 0.51f, Projection.TotalLength); - if (RootDistance <= SourceDistance + KINDA_SMALL_NUMBER) - { - return FMath::Clamp( - Projection.DistanceAlong / Projection.TotalLength, 0.0f, 1.0f); - } - return FMath::Clamp( - (Projection.DistanceAlong - SourceDistance) - / (RootDistance - SourceDistance), 0.0f, 1.0f); - } +FVector VoxelCaveMorphology::ApplyCaveWarp( + const FVector& WorldPoint, + const FStrateGenerationParams& Params, + uint32 Seed) +{ + return VF_ApplyCaveWarp(WorldPoint, Params, Seed); } FVoxelPassageLanding VF_BuildPassageLanding( @@ -2186,10 +2416,7 @@ FVoxelPassageLanding VF_BuildPassageLanding( float StrateTopZ, float StrateBottomZ, float BoundarySealThickness, - bool bSourcePlayerFit, - bool bHasNetworkConnector, - const FVector& NetworkPoint, - float RootSpineRadius) + bool bSourcePlayerFit) { FVoxelPassageLanding Landing; @@ -2213,11 +2440,7 @@ FVoxelPassageLanding VF_BuildPassageLanding( Landing.CeilingZ = Landing.FloorZ + DesiredHeight; Landing.FloorThickness = VoxelPassageGeometry::LandingFloorThicknessVoxels; Landing.DoorDirection = Direction; - Landing.RootSpineRadius = FMath::Max(RootSpineRadius, 0.0f); Landing.bSourcePlayerFit = bSourcePlayerFit; - Landing.bHasNetworkConnector = false; - Landing.RootFloorZ = Landing.FloorZ; - Landing.RootCeilingZ = Landing.CeilingZ; // Keep the complete room and its floor strictly inside the vertical seal. Normal authored // strates have far more room than this bound; the clamp is a fail-safe for a source pose near @@ -2257,125 +2480,6 @@ FVoxelPassageLanding VF_BuildPassageLanding( + Direction * FMath::Max(0.0f, Landing.HalfWidth - 1.0f); Landing.DoorPoint.Z = Landing.FloorZ + SafeMouthRadius; - if (bHasNetworkConnector - && FMath::IsFinite(NetworkPoint.X) - && FMath::IsFinite(NetworkPoint.Y) - && FMath::IsFinite(NetworkPoint.Z)) - { - // The connector is a swept rectangular ramp. Its source end keeps the authored landing - // floor; its root end is the common, deterministic interior floor supplied by the - // manager. This is what joins two mouths in one strate when their source floors sit at - // opposite sides of the strate. The manager chooses the common level inside the same - // seal-safe interval as this builder, so no source query or passage order can affect it. - // Five voxels = 1.25 m half-width / 2.5 m clear width. This is the smallest whole-voxel - // root corridor that leaves a 1.36-voxel player capsule a two-voxel fit margin on - // the support lattice, while the landing itself remains the 3 m turn floor. Its separate - // 12-voxel (3 m) clear height gives the 1.76 m capsule more than 1 m of headroom while the - // player walks out to the origin landing room. The floor rise is linear along the - // horizontal run; if the direct run is too short, a level dog-leg makes the final ramp - // satisfy the same 15 degree walkable-tunnel limit by construction. - Landing.ConnectorRadius = VoxelPassageGeometry::ConnectorRadiusVoxels; - const float ConnectorHeight = VoxelPassageGeometry::ConnectorHeightVoxels; - Landing.ConnectorCeilingZ = Landing.FloorZ + ConnectorHeight; - Landing.RootFloorZ = NetworkPoint.Z; - Landing.RootCeilingZ = Landing.RootFloorZ + ConnectorHeight; - - // Clamp the supplied common level again at the geometry boundary. This is deliberately - // a clamp, not a per-passage repair: all callers derive NetworkPoint.Z from the strate's - // bounds, and malformed tiny strates remain sealed rather than producing a root breach. - if (FMath::IsFinite(InnerBottom) && FMath::IsFinite(InnerTop) - && InnerTop > InnerBottom) - { - const float RootMinFloor = InnerBottom + Landing.FloorThickness - + VF_LandingCarveSafetyMargin; - const float RootMaxFloor = InnerTop - ConnectorHeight - - VF_LandingCarveSafetyMargin; - if (RootMaxFloor >= RootMinFloor) - { - Landing.RootFloorZ = FMath::Clamp( - Landing.RootFloorZ, RootMinFloor, RootMaxFloor); - Landing.RootCeilingZ = Landing.RootFloorZ + ConnectorHeight; - } - else - { - // There is no seal-safe interval for a full landing connector in a malformed - // tiny strate. Fail closed: a partial ramp that reaches a boundary is worse than - // an explicit unanswered landing, and normal authored strates never enter this - // branch. - return Landing; - } - } - else - { - return Landing; - } - - const float ConnectorZ = (Landing.FloorZ + Landing.ConnectorCeilingZ) * 0.5f; - const float RootConnectorZ = (Landing.RootFloorZ + Landing.RootCeilingZ) * 0.5f; - // The origin is now a finite landing room, not a vertical air column. End the connector - // at the requested room centre so its floor meets the room floor directly. The room's - // own rounded-box SDF and floor post provide the complete volume; no annular shaft hub is - // synthesized here, and no floor is omitted at the centre. - const FVector2D RootEntryXY(NetworkPoint.X, NetworkPoint.Y); - Landing.ConnectorStart = FVector( - Landing.StandingPoint.X, Landing.StandingPoint.Y, ConnectorZ); - Landing.ConnectorEnd = FVector(RootEntryXY.X, RootEntryXY.Y, RootConnectorZ); - - // A direct source-to-root ramp is usually gentle, but its length is not a contract: a - // legal source pose can be close to the spine while its floor is near a strate face. Add a - // deterministic level dog-leg whenever the direct run cannot meet the 15-degree law. - // The final leg is the only rising part, and its extra horizontal run includes the root - // room overlap. This makes the slope a construction guarantee for every seed, rather - // than a property observed in today's showcase seeds. WorldRadiusVoxels is deliberately - // zero, so the bounded world rim cannot be approached by this lateral safety run. - constexpr float WalkableSlope = - VoxelPassageGeometry::WalkableTunnelMaxGradient; - constexpr float RootFlatLength = VoxelPassageGeometry::RootOverlapVoxels; - const FVector2D SourceXY(Landing.ConnectorStart.X, Landing.ConnectorStart.Y); - const FVector2D RootXY(Landing.ConnectorEnd.X, Landing.ConnectorEnd.Y); - const FVector2D Direct = RootXY - SourceXY; - const float DirectLength = Direct.Size(); - const float FloorRise = FMath::Abs(Landing.RootFloorZ - Landing.FloorZ); - const float RequiredRampLength = FloorRise / WalkableSlope; - const float SourceFlatLength = FMath::Max(Landing.HalfWidth - 1.0f, 0.0f); - if (FloorRise > KINDA_SMALL_NUMBER - && (DirectLength <= KINDA_SMALL_NUMBER - || DirectLength < RequiredRampLength + RootFlatLength + SourceFlatLength)) - { - FVector2D Along = Direct; - if (!Along.Normalize()) - { - Along = FVector2D(Direction.X, Direction.Y); - if (!Along.Normalize()) - { - Along = FVector2D(1.0f, 0.0f); - } - } - const FVector2D Perpendicular(-Along.Y, Along.X); - const float DogLegLength = RequiredRampLength + RootFlatLength + 1.0f; - const FVector2D ControlXY = RootXY + Perpendicular * DogLegLength; - Landing.ConnectorControl = FVector(ControlXY.X, ControlXY.Y, ConnectorZ); - Landing.bHasConnectorBend = true; - } - else if (FloorRise > KINDA_SMALL_NUMBER) - { - // Keep the same straight geometry when it already satisfies the bound. The explicit - // false flag is part of the descriptor so the evaluator and order-independence test - // cannot infer a different topology from an uninitialised control point. - Landing.ConnectorControl = FVector::ZeroVector; - Landing.bHasConnectorBend = false; - } - else - { - Landing.ConnectorControl = FVector( - (Landing.ConnectorStart.X + Landing.ConnectorEnd.X) * 0.5f, - (Landing.ConnectorStart.Y + Landing.ConnectorEnd.Y) * 0.5f, - ConnectorZ); - Landing.bHasConnectorBend = false; - } - Landing.bHasNetworkConnector = true; - } - return Landing; } @@ -2412,34 +2516,6 @@ float VF_EvaluatePassageLandingSDF( VoxelSDF::RoundedBox(Position, RoomCenter, RoomHalfExtent, RoomRounding), Landing.FloorZ - Position.Z); - if (Landing.bHasNetworkConnector && Landing.ConnectorRadius > 0.0f - && FMath::IsFinite(Landing.ConnectorCeilingZ) - && Landing.ConnectorCeilingZ > Landing.FloorZ - && FMath::IsFinite(Landing.RootFloorZ) - && FMath::IsFinite(Landing.RootCeilingZ) - && Landing.RootCeilingZ > Landing.RootFloorZ) - { - const FVoxelPassageLandingConnectorProjection Projection = - VF_ProjectPassageLandingConnector(Position, Landing); - const float FloorT = VF_PassageLandingConnectorFloorParameter(Projection, Landing); - const float HorizontalSDF = (FVector2D(Position.X, Position.Y) - - Projection.Closest).Size() - - Landing.ConnectorRadius; - const float ConnectorFloorZ = FMath::Lerp( - Landing.FloorZ, Landing.RootFloorZ, FloorT); - const float ConnectorCeilingZ = FMath::Lerp( - Landing.ConnectorCeilingZ, Landing.RootCeilingZ, FloorT); - const float VerticalSDF = FMath::Max( - ConnectorFloorZ - Position.Z, - Position.Z - ConnectorCeilingZ); - // A swept box with a flat source leg and a measured-slope final ramp, not a round bore: - // the floor normal is deterministic and bounded, while the fixed twelve-voxel clear height - // follows the route. The source and root ends are exactly the two flat landing floors. - const float ConnectorSDF = FMath::Max(HorizontalSDF, VerticalSDF); - LandingSDF = VoxelSDF::SmoothMin(LandingSDF, ConnectorSDF, 3.0f); - - } - return LandingSDF; } @@ -2475,34 +2551,6 @@ bool VF_IsPassageLandingFloor( } } - if (Landing.bHasNetworkConnector && Landing.ConnectorRadius > 0.0f - && FMath::IsFinite(Landing.RootFloorZ) - && FMath::IsFinite(Landing.RootCeilingZ) - && Landing.RootCeilingZ > Landing.RootFloorZ) - { - const FVoxelPassageLandingConnectorProjection Projection = - VF_ProjectPassageLandingConnector(Position, Landing); - const FVector2D& Closest = Projection.Closest; - const float FloorT = VF_PassageLandingConnectorFloorParameter(Projection, Landing); - const float ConnectorFloorZ = FMath::Lerp( - Landing.FloorZ, Landing.RootFloorZ, FloorT); - if (IsInFloorBand(Position.Z, ConnectorFloorZ, Landing.FloorThickness)) - { - // Keep connector support inset by half a voxel from its clear width. The landing - // chamber—not this narrow transit leg—owns the 3 m turn floor; this inset preserves - // a solid walking strip without capping a neighbouring shaft at its edge. - const float FloorRadius = FMath::Max( - Landing.ConnectorRadius - 0.5f, - FVoxelPlayerCapsuleConstants::RadiusVoxels); - if ((FVector2D(Position.X, Position.Y) - Closest).SizeSquared() - <= FMath::Square(FloorRadius)) - { - return true; - } - } - - } - return false; } @@ -2516,13 +2564,17 @@ bool VF_SuggestLandingPoint( float DesiredX, float DesiredY, float MaxLateralSnap, - FVector& OutPoint) + FVector& OutPoint, + int32 WorldSeed) { switch (Archetype) { case ECaveGeneratorType::TunnelNetwork: case ECaveGeneratorType::Underwater: - return VF_FindNearestHashRoomLandingPoint(CaveParams, Seed, StrateTopZ, StrateBottomZ, + return VF_FindNearestHashRoomLandingPoint( + CaveParams, Seed, + WorldSeed == MIN_int32 ? Seed : WorldSeed, + StrateTopZ, StrateBottomZ, DesiredX, DesiredY, MaxLateralSnap, OutPoint); case ECaveGeneratorType::FlatPlain: @@ -2551,13 +2603,17 @@ bool VF_SuggestLandingPoint( float DesiredX, float DesiredY, float MaxLateralSnap, - FVector& OutPoint) + FVector& OutPoint, + int32 WorldSeed) { switch (Archetype) { case ECaveGeneratorType::TunnelNetwork: case ECaveGeneratorType::Underwater: - return VF_FindNearestHashRoomLandingPoint(CaveParams, Seed, StrateTopZ, StrateBottomZ, + return VF_FindNearestHashRoomLandingPoint( + CaveParams, Seed, + WorldSeed == MIN_int32 ? Seed : WorldSeed, + StrateTopZ, StrateBottomZ, DesiredX, DesiredY, MaxLateralSnap, OutPoint); case ECaveGeneratorType::FlatPlain: @@ -2595,7 +2651,7 @@ bool VF_SuggestLandingPoint( //============================================================================= // Collects all rooms in the COLLECT region, computes a window-invariant // nearest-neighbor backbone for connectivity, decides tunnel connections, and -// pre-computes all tunnel geometry (radii, Z offsets, midpoint warping, bounding +// pre-computes all tunnel geometry (radii, floor-aligned wandering chains, bounding // spheres). Only rooms/tunnels relevant to the chunk (STORE region) are kept. // // The result is stored in OutCache and reused for every voxel in the chunk. @@ -2610,6 +2666,7 @@ void VoxelCaveMorphology::BuildChunkCache( { // Clear previous data (arrays keep their allocation for reuse) OutCache.Rooms.Reset(); + OutCache.RoomFloorJoins.Reset(); OutCache.Tunnels.Reset(); OutCache.Pits.Reset(); OutCache.Chimneys.Reset(); @@ -2716,7 +2773,8 @@ void VoxelCaveMorphology::BuildChunkCache( TArray> BuildRooms; // --- ORIGIN ROOM --- - // Guaranteed large room at (0, 0) in each strate — the (0,0) descent spine hub. + // Guaranteed large origin landing at (0, 0) in each strate — the future shaft landing and + // graph root room. Inter-strate passage mouths are placed elsewhere and have no radial road. // Collected whenever (0,0) is inside the COLLECT region so it participates in the // connectivity decision; only stored if it can reach this chunk. int32 OriginIdx = -1; @@ -2783,6 +2841,15 @@ void VoxelCaveMorphology::BuildChunkCache( const int32 NumRooms = BuildRooms.Num(); if (NumRooms == 0) return; + // Keep the room floor calculation in one place so tunnel mouths and overlap joins use the + // same deterministic support plane as the emitted room. It includes the room-centre relief; + // the evaluator still applies the spatial relief field per voxel around that anchor. + const auto RoomFloorZFor = [&Params, Seed](const FBuildRoom& Room) -> float + { + return VF_RoomFloorZFromParts( + Room.Center, Room.RadiusXY, Room.RadiusZ, Room.Hash, Seed, Params); + }; + //========================================================================= // Window-invariant guaranteed backbone //========================================================================= @@ -2919,6 +2986,34 @@ void VoxelCaveMorphology::BuildChunkCache( TArray> RoomConnected; RoomConnected.Init(false, NumRooms); + const auto ResolvePlayerFitPoint = [](FBuildRoom& Room, const FStrateGenerationParams& InParams, + uint32 InWorldSeed) -> bool + { + if (Room.bIsOrigin) + { + return false; + } + if (Room.bPlayerFitAttempted) + { + return Room.bHasPlayerFitPoint; + } + Room.bPlayerFitAttempted = true; + const FVFRoomLandingSite Site{ + Room.Center, + Room.RadiusXY, + Room.RadiusZ, + Room.Hash, + false}; + Room.bHasPlayerFitPoint = VF_FindPlayerFitPointForRoom( + InParams, + Site, + InParams.StrateTopWorldZ, + InParams.StrateBottomWorldZ, + InWorldSeed, + Room.PlayerFitPoint); + return Room.bHasPlayerFitPoint; + }; + for (int32 I = 0; I < NumRooms; I++) { for (int32 J = I + 1; J < NumRooms; J++) @@ -2979,14 +3074,60 @@ void VoxelCaveMorphology::BuildChunkCache( const float RadA = FMath::Lerp(Params.TunnelMinRadius, Params.TunnelMaxRadius, FactorA); const float RadB = FMath::Lerp(Params.TunnelMinRadius, Params.TunnelMaxRadius, FactorB); - // --- ENDPOINT Z OFFSET --- - const float ZOffsetA = VoxelHash::ToFloatSigned(VoxelHash::Mix(TunnelHash ^ 0xA1B2C3D4u)) - * RoomA.RadiusZ * Params.TunnelEndpointZOffset; - const float ZOffsetB = VoxelHash::ToFloatSigned(VoxelHash::Mix(TunnelHash ^ 0xD4C3B2A1u)) - * RoomB.RadiusZ * Params.TunnelEndpointZOffset; + // --- ENDPOINT FLOOR ALIGNMENT --- + // The old endpoint Z roll entered each room at an arbitrary height. When the two + // rooms had different floor cuts, the tube then met one floor several metres above or + // below the other and the player-fit graph saw a vertical severance. Anchor each tube + // tangent to the two rooms' deterministic floor planes instead. Interior control + // points still receive bounded vertical wander, so this removes the lip without + // turning the whole network into a level grid. + const float RoomFloorA = RoomFloorZFor(RoomA); + const float RoomFloorB = RoomFloorZFor(RoomB); + const float SafeFloorA = FMath::IsFinite(RoomFloorA) + ? RoomFloorA : RoomA.Center.Z - RoomA.RadiusZ; + const float SafeFloorB = FMath::IsFinite(RoomFloorB) + ? RoomFloorB : RoomB.Center.Z - RoomB.RadiusZ; + FVector EndA = RoomA.Center + FVector(0.0f, 0.0f, SafeFloorA + + RadA - RoomA.Center.Z); + FVector EndB = RoomB.Center + FVector(0.0f, 0.0f, SafeFloorB + + RadB - RoomB.Center.Z); - FVector EndA = RoomA.Center + FVector(0.0f, 0.0f, ZOffsetA); - FVector EndB = RoomB.Center + FVector(0.0f, 0.0f, ZOffsetB); + // The passage landing query is allowed to move inside a warped room until the + // capsule actually fits. Use that same deterministic fit anchor for graph mouths; + // otherwise a room whose centre needs a local correction gets a tunnel mouth at one + // point and the inter-strate/player-fit landing at another. Querying with zero + // lateral budget pins the source site to this exact room centre (the helper still + // performs its bounded local fit search). If a malformed asset cannot produce a + // source fit, retain the analytic room-floor fallback above. PlayerFitPoint is a + // feet point; the tunnel floor's authored support band ends half a voxel below it, + // and the route probe stands one voxel above the tunnel-floor plane. Subtracting a + // full voxel here makes that probe exactly the same feet point as the room landing. + FVector WorldEndA = VF_UnwarpCavePoint(EndA, Params, Seed); + FVector WorldEndB = VF_UnwarpCavePoint(EndB, Params, Seed); + if (!RoomA.bIsOrigin) + { + if (ResolvePlayerFitPoint( + BuildRooms[I], Params, Seed)) + { + WorldEndA = FVector( + RoomA.PlayerFitPoint.X, + RoomA.PlayerFitPoint.Y, + RoomA.PlayerFitPoint.Z - 1.0f + RadA); + EndA = VF_ApplyCaveWarp(WorldEndA, Params, Seed); + } + } + if (!RoomB.bIsOrigin) + { + if (ResolvePlayerFitPoint( + BuildRooms[J], Params, Seed)) + { + WorldEndB = FVector( + RoomB.PlayerFitPoint.X, + RoomB.PlayerFitPoint.Y, + RoomB.PlayerFitPoint.Z - 1.0f + RadB); + EndB = VF_ApplyCaveWarp(WorldEndB, Params, Seed); + } + } // --- BUILD CACHED TUNNEL --- FCachedTunnel CT; @@ -2995,56 +3136,280 @@ void VoxelCaveMorphology::BuildChunkCache( CT.RadiusA = RadA; CT.RadiusB = RadB; - // --- PATH WARPING --- - const float TunnelLength = FVector::Dist(EndA, EndB); + // A room edge is not a straight capsule between cell centres. Build a deterministic + // chain of hash-jittered control points instead. The chain is keyed only by the pair + // hash, so every chunk that collects this edge reconstructs the same path; the wide + // collect region above keeps that topology seam-safe. The envelope is zero at both + // mouths so the room/tunnel join remains anchored, while the interior is allowed to + // wander in the horizontal plane perpendicular to the tunnel axis. + // + // The cache stores the graph in SDF coordinates, but the player walks in world + // coordinates. Building the chain directly in SDF space made the non-linear cave warp + // bend the floor and could turn a modest authored slope into a vertical severance. + // Pick the exact world-space mouth anchors by inversion, lay out the wandering chain + // there, then map each control point back into SDF space. This keeps the generated + // route deterministic and seam-safe while making its walkable floor the authored + // world-space interpolation between the two room floors. + const float TunnelLength = FVector::Dist(WorldEndA, WorldEndB); + const int32 WanderSegments = TunnelLength > 1.0f + ? FMath::Clamp(FMath::CeilToInt(TunnelLength / 48.0f), 3, 12) + : 1; + CT.ControlPoints.Reserve(WanderSegments + 1); + CT.ControlRadii.Reserve(WanderSegments + 1); + CT.WorldControlPoints.Reserve(WanderSegments + 1); + CT.WorldControlRadii.Reserve(WanderSegments + 1); - if (Params.TunnelWarpStrength > 0.0f && TunnelLength > 1.0f) + FVector HorizontalAxis( + WorldEndB.X - WorldEndA.X, WorldEndB.Y - WorldEndA.Y, 0.0f); + FVector PerpA; + if (HorizontalAxis.Normalize()) { - FVector TunnelDir = (EndB - EndA).GetSafeNormal(); - FVector PerpH = FVector(-TunnelDir.Y, TunnelDir.X, 0.0f); - - float MaxWarp = FMath::Min(Params.TunnelWarpStrength, TunnelLength * 0.25f); - float WarpH = VoxelHash::ToFloatSigned(VoxelHash::Mix(TunnelHash ^ 0x1234ABCDu)) * MaxWarp; - float WarpV = VoxelHash::ToFloatSigned(VoxelHash::Mix(TunnelHash ^ 0x5678EF01u)) * MaxWarp * 0.3f; - - FVector Mid = (EndA + EndB) * 0.5f; - Mid += PerpH * WarpH + FVector(0.0f, 0.0f, WarpV); - - CT.Midpoint = Mid; - CT.RadiusMid = (RadA + RadB) * 0.5f; - CT.bHasMidpoint = true; - - // Bounding sphere: encloses all 3 control points + max radius - CT.BoundCenter = (EndA + Mid + EndB) / 3.0f; - float MaxR = FMath::Max3(RadA, RadB, CT.RadiusMid) + BlendK; - float DistA = FVector::Dist(CT.BoundCenter, EndA); - float DistM = FVector::Dist(CT.BoundCenter, Mid); - float DistB = FVector::Dist(CT.BoundCenter, EndB); - float BoundR = FMath::Max3(DistA, DistM, DistB) + MaxR; - CT.BoundRadiusSq = BoundR * BoundR; + PerpA = FVector(-HorizontalAxis.Y, HorizontalAxis.X, 0.0f); } else { - CT.Midpoint = FVector::ZeroVector; - CT.RadiusMid = 0.0f; - CT.bHasMidpoint = false; - - // Bounding sphere: encloses both endpoints + max radius - CT.BoundCenter = (EndA + EndB) * 0.5f; - float MaxR = FMath::Max(RadA, RadB) + BlendK; - float HalfLen = TunnelLength * 0.5f; - float BoundR = HalfLen + MaxR; - CT.BoundRadiusSq = BoundR * BoundR; + PerpA = FVector::RightVector; } - // Store only if this tunnel can actually reach a voxel in this chunk. - if (SphereTouchesSearchXY(CT.BoundCenter, CT.BoundRadiusSq)) + const float MaxWander = FMath::Min( + FMath::Max(Params.TunnelWarpStrength, 0.0f), TunnelLength * 0.25f); + const float MaxRadiusVariation = 0.18f; + float PreviousSide = 0.0f; + for (int32 ControlIndex = 0; + ControlIndex <= WanderSegments; + ++ControlIndex) + { + const float T = static_cast(ControlIndex) + / static_cast(WanderSegments); + const float Envelope = FMath::Sin(T * PI); + FVector WorldControlPoint = FMath::Lerp(WorldEndA, WorldEndB, T); + const float RadiusBase = FMath::Lerp(RadA, RadB, T); + float ControlRadius = RadiusBase; + + if (ControlIndex > 0 && ControlIndex < WanderSegments) + { + const uint32 PointHash = VoxelHash::Mix( + TunnelHash ^ (0xBADC0DEu + + static_cast(ControlIndex) * 0x9E3779B9u)); + const float RawSide = VoxelHash::ToFloatSigned( + VoxelHash::Mix(PointHash ^ 0x13579BDFu)); + // A short deterministic low-pass keeps the chain organic instead of making + // every control point a sharp alternating zig-zag. + const float Side = RawSide * 0.65f + PreviousSide * 0.35f; + WorldControlPoint += PerpA * (Side * MaxWander * Envelope); + PreviousSide = Side; + + const float RadiusNoise = VoxelHash::ToFloatSigned( + VoxelHash::Mix(PointHash ^ 0x5A17EADu)); + ControlRadius = FMath::Max( + 0.5f, + RadiusBase * (1.0f + RadiusNoise * MaxRadiusVariation * Envelope)); + } + else if (ControlIndex == 0) + { + PreviousSide = 0.0f; + } + + // Pin the endpoints after all arithmetic. This is the seam and room-mouth + // contract: no residual sin(PI) or interpolation rounding moves a mouth. + if (ControlIndex == 0) + { + WorldControlPoint = WorldEndA; + ControlRadius = RadA; + } + else if (ControlIndex == WanderSegments) + { + WorldControlPoint = WorldEndB; + ControlRadius = RadB; + } + CT.WorldControlPoints.Add(WorldControlPoint); + CT.WorldControlRadii.Add(ControlRadius); + const FVector ControlPoint = (ControlIndex == 0) + ? EndA + : ((ControlIndex == WanderSegments) + ? EndB + : VF_ApplyCaveWarp(WorldControlPoint, Params, Seed)); + CT.ControlPoints.Add(ControlPoint); + CT.ControlRadii.Add(ControlRadius); + } + + CT.Midpoint = CT.ControlPoints.Num() > 2 + ? CT.ControlPoints[CT.ControlPoints.Num() / 2] + : FVector::ZeroVector; + CT.RadiusMid = CT.ControlRadii.Num() > 2 + ? CT.ControlRadii[CT.ControlRadii.Num() / 2] + : 0.0f; + CT.bHasMidpoint = CT.ControlPoints.Num() > 2; + + // Bounding sphere: enclose every control point and the widest local tube. The bound + // is built from the complete chain, not a midpoint approximation, so culling cannot + // clip a bend at a chunk edge. + CT.BoundCenter = FVector::ZeroVector; + for (const FVector& Point : CT.ControlPoints) + { + CT.BoundCenter += Point; + } + CT.BoundCenter /= static_cast(FMath::Max(CT.ControlPoints.Num(), 1)); + float MaxTunnelRadius = 0.0f; + float BoundR = 0.0f; + for (int32 PointIndex = 0; PointIndex < CT.ControlPoints.Num(); ++PointIndex) + { + MaxTunnelRadius = FMath::Max( + MaxTunnelRadius, FMath::Abs(CT.ControlRadii[PointIndex])); + BoundR = FMath::Max( + BoundR, FVector::Dist(CT.BoundCenter, CT.ControlPoints[PointIndex])); + } + BoundR += MaxTunnelRadius + BlendK; + CT.BoundRadiusSq = BoundR * BoundR; + + // Keep a separate bound for the world-space structural backstop. The SDF and world + // chains have the same topology but their cave-warped coordinates do not share a + // useful rejection sphere. + CT.WorldBoundCenter = FVector::ZeroVector; + for (const FVector& Point : CT.WorldControlPoints) + { + CT.WorldBoundCenter += Point; + } + CT.WorldBoundCenter /= static_cast( + FMath::Max(CT.WorldControlPoints.Num(), 1)); + float WorldBoundR = 0.0f; + for (int32 PointIndex = 0; + PointIndex < CT.WorldControlPoints.Num(); + ++PointIndex) + { + WorldBoundR = FMath::Max( + WorldBoundR, + FVector::Dist(CT.WorldBoundCenter, CT.WorldControlPoints[PointIndex])); + } + WorldBoundR += MaxTunnelRadius + BlendK; + CT.WorldBoundRadiusSq = WorldBoundR * WorldBoundR; + + // Store if either representation can reach a voxel in this chunk. The warped SDF + // chain is needed by the room morphology; the world chain is needed by the final + // walkable-air backstop. Their bounds differ by design, so testing only the former + // could make a world-space route disappear at a chunk edge. + if (SphereTouchesSearchXY(CT.BoundCenter, CT.BoundRadiusSq) + || SphereTouchesSearchXY(CT.WorldBoundCenter, CT.WorldBoundRadiusSq)) { OutCache.Tunnels.Add(CT); } } } + //========================================================================== + // FLATTEN INTERSECTING ROOMS + //========================================================================== + // A spherical room union is not automatically walkable when the two rooms' floor cuts land + // at different Z values. For every overlapping, connected room pair, add a short flat bridge + // only inside their actual horizontal overlap. This is a floor repair, not a graph edge: it + // does not connect a landing to the origin and it never creates a corridor through unrelated + // cells. The common plane is the higher of the two existing floors, so it does not cut below a + // room's authored support; the bridge is admitted only when the shared player/headroom volume + // still fits below both room ceilings. + OutCache.RoomFloorJoins.Reserve(NumRooms / 2); + const float JoinRequiredHeight = + VoxelPassageGeometry::PlayerHeightVoxels + + VoxelPassageGeometry::HeadroomVoxels; + const float JoinMinimumRadius = + VoxelPassageGeometry::PlayerRadiusVoxels + 0.5f; + const float JoinCentreClearance = + VoxelPassageGeometry::PlayerRadiusVoxels + 2.0f; + for (int32 I = 0; I < NumRooms; ++I) + { + const FBuildRoom& RoomA = BuildRooms[I]; + if (!RoomA.bStore || !RoomConnected[I]) continue; + + for (int32 J = I + 1; J < NumRooms; ++J) + { + const FBuildRoom& RoomB = BuildRooms[J]; + if (!RoomB.bStore || !RoomConnected[J]) continue; + + const FVector2D DeltaXY( + RoomB.Center.X - RoomA.Center.X, + RoomB.Center.Y - RoomA.Center.Y); + const float DistanceXY = DeltaXY.Size(); + if (DistanceXY <= KINDA_SMALL_NUMBER + || DistanceXY >= RoomA.RadiusXY + RoomB.RadiusXY) + { + continue; + } + + const float VerticalReach = RoomA.RadiusZ + RoomB.RadiusZ; + if (FMath::Abs(RoomB.Center.Z - RoomA.Center.Z) >= VerticalReach) + { + continue; + } + + const float FloorA = RoomFloorZFor(RoomA); + const float FloorB = RoomFloorZFor(RoomB); + const float CommonFloor = FMath::Max(FloorA, FloorB); + const float SharedCeiling = FMath::Min( + RoomA.Center.Z + RoomA.RadiusZ, + RoomB.Center.Z + RoomB.RadiusZ); + if (!FMath::IsFinite(CommonFloor) + || !FMath::IsFinite(SharedCeiling) + || SharedCeiling - CommonFloor < JoinRequiredHeight) + { + continue; + } + + // Along the centre-centre line, the two projected disks overlap over this interval. + // Trim both ends so the bridge remains a local room-edge join and leaves each room's + // player-fit centre untouched for the pure landing query. + const float OverlapStart = FMath::Max( + 0.0f, DistanceXY - RoomB.RadiusXY); + const float OverlapEnd = FMath::Min( + DistanceXY, RoomA.RadiusXY); + const float OverlapLength = OverlapEnd - OverlapStart; + if (OverlapLength < 2.0f * JoinCentreClearance) + { + continue; + } + + const float EdgeInset = FMath::Min(2.0f, OverlapLength * 0.2f); + const float StartDistance = FMath::Max( + OverlapStart + EdgeInset, JoinCentreClearance); + const float EndDistance = FMath::Min( + OverlapEnd - EdgeInset, DistanceXY - JoinCentreClearance); + if (EndDistance <= StartDistance) + { + continue; + } + + const float JoinRadius = FMath::Min( + FMath::Min(RoomA.RadiusXY, RoomB.RadiusXY) * 0.35f, + (EndDistance - StartDistance) * 0.5f); + if (!FMath::IsFinite(JoinRadius) || JoinRadius < JoinMinimumRadius) + { + continue; + } + + const FVector2D AxisXY = DeltaXY / DistanceXY; + const float JoinCentreZ = CommonFloor + 0.5f * JoinRequiredHeight; + FCachedRoomFloorJoin Join; + Join.Start = FVector( + RoomA.Center.X + AxisXY.X * StartDistance, + RoomA.Center.Y + AxisXY.Y * StartDistance, + JoinCentreZ); + Join.End = FVector( + RoomA.Center.X + AxisXY.X * EndDistance, + RoomA.Center.Y + AxisXY.Y * EndDistance, + JoinCentreZ); + Join.Radius = JoinRadius; + Join.FloorZ = CommonFloor; + Join.CeilingZ = CommonFloor + JoinRequiredHeight; + Join.BoundCenter = (Join.Start + Join.End) * 0.5f; + const float BoundRadius = 0.5f * FVector::Dist(Join.Start, Join.End) + + Join.Radius + BlendK; + Join.BoundRadiusSq = BoundRadius * BoundRadius; + + if (SphereTouchesSearchXY(Join.BoundCenter, Join.BoundRadiusSq)) + { + OutCache.RoomFloorJoins.Add(MoveTemp(Join)); + } + } + } + //========================================================================= // Copy STORE-relevant rooms to cache (cull radii + per-room terrain ops), // then pre-bake their pits / chimneys / columns. @@ -3076,7 +3441,7 @@ void VoxelCaveMorphology::BuildChunkCache( if (!BR.bStore) continue; // Far room — collected for connectivity only // Sealed-bubble cull: a room no tunnel ever reaches would be an isolated air - // pocket — don't carve it at all. The origin room is always kept (spine hub). + // pocket — don't carve it at all. The origin landing is always kept (future shaft/root). if (!RoomConnected[RoomIdx] && !BR.bIsOrigin) continue; FCachedRoom CR; @@ -3327,6 +3692,27 @@ float VoxelCaveMorphology::EvaluateSDFCached( MinSDF = VoxelSDF::SmoothMin(MinSDF, RoomSDF, BlendK); } + //========================================================================== + // Flattened room joins + //========================================================================== + // These short bridges exist only where two room bodies already overlap. Their floor is a + // single horizontal plane, so a player crossing the shared volume cannot meet the tall lip + // produced by two independent floor cuts. They are deliberately not reported as rooms and do + // not participate in nearest-room terrain-op ownership. + for (const FCachedRoomFloorJoin& Join : Cache.RoomFloorJoins) + { + const float DistSq = FVector::DistSquared(Pos, Join.BoundCenter); + if (DistSq > Join.BoundRadiusSq) continue; + + const float HorizontalSDF = VoxelSDF::Capsule( + Pos, Join.Start, Join.End, Join.Radius); + const float VerticalSDF = FMath::Max( + Join.FloorZ - Pos.Z, + Pos.Z - Join.CeilingZ); + const float JoinSDF = FMath::Max(HorizontalSDF, VerticalSDF); + MinSDF = VoxelSDF::SmoothMin(MinSDF, JoinSDF, BlendK); + } + //========================================================================= // Tunnel SDFs //========================================================================= @@ -3338,9 +3724,30 @@ float VoxelCaveMorphology::EvaluateSDFCached( float TunnelSDF; - if (Tunnel.bHasMidpoint) + if (Tunnel.ControlPoints.Num() >= 2 + && Tunnel.ControlRadii.Num() == Tunnel.ControlPoints.Num()) { - // Two-segment curved tunnel: A→Mid and Mid→B + // A wandering chain is evaluated segment-by-segment. Min keeps the shared control + // point watertight and preserves the old capsule union semantics; the outer + // SmoothMin is still the room/tunnel blend. + TunnelSDF = FLT_MAX; + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Tunnel.ControlPoints.Num(); + ++SegmentIndex) + { + TunnelSDF = FMath::Min( + TunnelSDF, + VoxelSDF::TaperedCapsule( + Pos, + Tunnel.ControlPoints[SegmentIndex], + Tunnel.ControlPoints[SegmentIndex + 1], + Tunnel.ControlRadii[SegmentIndex], + Tunnel.ControlRadii[SegmentIndex + 1])); + } + } + else if (Tunnel.bHasMidpoint) + { + // Backward-compatible descriptor path for older cooked caches. float SegA = VoxelSDF::TaperedCapsule(Pos, Tunnel.EndpointA, Tunnel.Midpoint, Tunnel.RadiusA, Tunnel.RadiusMid); float SegB = VoxelSDF::TaperedCapsule(Pos, Tunnel.Midpoint, Tunnel.EndpointB, @@ -3349,7 +3756,7 @@ float VoxelCaveMorphology::EvaluateSDFCached( } else { - // Straight single-segment tunnel + // Backward-compatible straight descriptor path. TunnelSDF = VoxelSDF::TaperedCapsule(Pos, Tunnel.EndpointA, Tunnel.EndpointB, Tunnel.RadiusA, Tunnel.RadiusB); } @@ -3366,6 +3773,183 @@ float VoxelCaveMorphology::EvaluateSDFCached( return MinSDF; } +float VoxelCaveMorphology::EvaluateTunnelCoreSDF( + float WorldX, float WorldY, float WorldZ, + const FChunkSDFCache& Cache) +{ + const FVector Pos(WorldX, WorldY, WorldZ); + float MinSDF = FLT_MAX; + + for (const FCachedTunnel& Tunnel : Cache.Tunnels) + { + if (FVector::DistSquared(Pos, Tunnel.BoundCenter) > Tunnel.BoundRadiusSq) + { + continue; + } + + if (Tunnel.ControlPoints.Num() >= 2 + && Tunnel.ControlRadii.Num() == Tunnel.ControlPoints.Num()) + { + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Tunnel.ControlPoints.Num(); + ++SegmentIndex) + { + MinSDF = FMath::Min( + MinSDF, + VoxelSDF::TaperedCapsule( + Pos, + Tunnel.ControlPoints[SegmentIndex], + Tunnel.ControlPoints[SegmentIndex + 1], + Tunnel.ControlRadii[SegmentIndex], + Tunnel.ControlRadii[SegmentIndex + 1])); + } + } + else if (Tunnel.bHasMidpoint) + { + MinSDF = FMath::Min( + MinSDF, + VoxelSDF::TaperedCapsule( + Pos, Tunnel.EndpointA, Tunnel.Midpoint, + Tunnel.RadiusA, Tunnel.RadiusMid)); + MinSDF = FMath::Min( + MinSDF, + VoxelSDF::TaperedCapsule( + Pos, Tunnel.Midpoint, Tunnel.EndpointB, + Tunnel.RadiusMid, Tunnel.RadiusB)); + } + else + { + MinSDF = FMath::Min( + MinSDF, + VoxelSDF::TaperedCapsule( + Pos, Tunnel.EndpointA, Tunnel.EndpointB, + Tunnel.RadiusA, Tunnel.RadiusB)); + } + } + + return MinSDF; +} + +float VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF( + float WorldX, float WorldY, float WorldZ, + const FChunkSDFCache& Cache) +{ + const FVector Pos(WorldX, WorldY, WorldZ); + float MinSDF = FLT_MAX; + + for (const FCachedTunnel& Tunnel : Cache.Tunnels) + { + const bool bHasWorldChain = Tunnel.WorldControlPoints.Num() >= 2 + && Tunnel.WorldControlRadii.Num() == Tunnel.WorldControlPoints.Num(); + const FVector& BoundCenter = bHasWorldChain + ? Tunnel.WorldBoundCenter : Tunnel.BoundCenter; + const float BoundRadiusSq = bHasWorldChain + ? Tunnel.WorldBoundRadiusSq : Tunnel.BoundRadiusSq; + if (BoundRadiusSq > 0.0f + && FVector::DistSquared(Pos, BoundCenter) > BoundRadiusSq) + { + continue; + } + + const TArray& ControlPoints = bHasWorldChain + ? Tunnel.WorldControlPoints : Tunnel.ControlPoints; + const TArray& ControlRadii = bHasWorldChain + ? Tunnel.WorldControlRadii : Tunnel.ControlRadii; + if (bHasWorldChain) + { + float FloorZ = 0.0f; + float SupportRadius = 0.0f; + if (VoxelPassageGeometry::ProjectWalkableTunnelFloor( + ControlPoints, ControlRadii, Pos, FloorZ, SupportRadius) + && WorldZ <= FloorZ + + VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels) + { + // The finite floor slab owns the bottom of this tunnel. Another intersecting + // tunnel may still win below its own floor; the loop continues so that air + // ownership remains the union of all valid tunnel cores. + continue; + } + } + if (ControlPoints.Num() >= 2 + && ControlRadii.Num() == ControlPoints.Num()) + { + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < ControlPoints.Num(); + ++SegmentIndex) + { + MinSDF = FMath::Min( + MinSDF, + VoxelSDF::TaperedCapsule( + Pos, + ControlPoints[SegmentIndex], + ControlPoints[SegmentIndex + 1], + ControlRadii[SegmentIndex], + ControlRadii[SegmentIndex + 1])); + } + } + else if (Tunnel.bHasMidpoint) + { + MinSDF = FMath::Min( + MinSDF, + VoxelSDF::TaperedCapsule( + Pos, Tunnel.EndpointA, Tunnel.Midpoint, + Tunnel.RadiusA, Tunnel.RadiusMid)); + MinSDF = FMath::Min( + MinSDF, + VoxelSDF::TaperedCapsule( + Pos, Tunnel.Midpoint, Tunnel.EndpointB, + Tunnel.RadiusMid, Tunnel.RadiusB)); + } + else + { + MinSDF = FMath::Min( + MinSDF, + VoxelSDF::TaperedCapsule( + Pos, Tunnel.EndpointA, Tunnel.EndpointB, + Tunnel.RadiusA, Tunnel.RadiusB)); + } + } + + return MinSDF; +} + +bool VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( + float WorldX, float WorldY, float WorldZ, + const FChunkSDFCache& Cache) +{ + const FVector Pos(WorldX, WorldY, WorldZ); + for (const FCachedTunnel& Tunnel : Cache.Tunnels) + { + if (Tunnel.WorldControlPoints.Num() < 2 + || Tunnel.WorldControlRadii.Num() != Tunnel.WorldControlPoints.Num()) + { + continue; + } + + float FloorZ = 0.0f; + float SupportRadius = 0.0f; + if (!VoxelPassageGeometry::ProjectWalkableTunnelFloor( + Tunnel.WorldControlPoints, + Tunnel.WorldControlRadii, + Pos, + FloorZ, + SupportRadius) + || WorldZ > FloorZ + VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels + || WorldZ < FloorZ - VoxelPassageGeometry::LandingFloorThicknessVoxels) + { + continue; + } + + if (Tunnel.WorldBoundRadiusSq <= 0.0f + || FVector::DistSquared(Pos, Tunnel.WorldBoundCenter) + <= Tunnel.WorldBoundRadiusSq) + { + return true; + } + } + return false; +} + //============================================================================= // CONVENIENCE WRAPPER (backward compatible) //============================================================================= diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 643136e..078870b 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -196,42 +196,6 @@ namespace return A + B; } - // Structural tree capsules must meet shaft axes without landing inside a ledge band. This is - // the same pure helper as the generator path: one deterministic safe interval is shared by the - // whole strate, so a link cannot be blocked when it crosses an unrelated shaft's ledge. - FORCEINLINE float VF_SelectVerticalTreeConnectorZ( - const FVerticalShaftParams& Params, - float BottomZ, - float TopZ, - uint32 LinkHash) - { - if (Params.LedgeSpacing > 0.0f && Params.LedgeDepth > 0.0f) - { - const float Period = Params.LedgeSpacing; - const float RelativeBottom = BottomZ - Params.StrateBottomWorldZ; - const float RelativeTop = TopZ - Params.StrateBottomWorldZ; - const int32 FirstPeriod = FMath::FloorToInt(RelativeBottom / Period); - const float Random01 = VoxelHash::ToFloat01(VoxelHash::Mix(LinkHash)); - - for (int32 PeriodOffset = 0; PeriodOffset <= 1; ++PeriodOffset) - { - const float PeriodStart = static_cast(FirstPeriod + PeriodOffset) * Period; - const float SafeStart = FMath::Max( - RelativeBottom, PeriodStart + Params.LedgeDepth + 0.01f); - const float SafeEnd = FMath::Min( - RelativeTop, PeriodStart + Period - Params.LedgeDepth - 0.01f); - if (SafeEnd > SafeStart) - { - return Params.StrateBottomWorldZ - + FMath::Lerp(SafeStart, SafeEnd, Random01); - } - } - } - - return FMath::Lerp(BottomZ, TopZ, - VoxelHash::ToFloat01(VoxelHash::Mix(LinkHash))); - } - //========================================================================= // RÔLE 1 — SOURCE : CHAMP CONSTANT / CONSTANT FIELD (roc ET vide) //========================================================================= @@ -1856,7 +1820,7 @@ namespace } // Keep the source and op-stack operation order identical: the origin room is carved // before the boundary seal, then its support floor is reasserted after the passage - // post so no connector can remove the standing surface. + // post so no passage-air post can remove the standing surface. VF_ApplyOriginLandingFloor( InOut.Density, X, Y, Z, TopZ, BotZ, Seal, Base, SpineRadius); } @@ -1925,7 +1889,13 @@ namespace void PrepareChunk(const FVoxelOpContext&) override {} struct FShaft { float X, Y, R; int32 CellX, CellY; bool bOriginSpine; }; - struct FConn { FVector A, B; float Radius; }; + struct FConn + { + FVector A, B; + float Radius = 0.0f; + float FloorZ = 0.0f; + bool bWalkableFloor = false; + }; // ⚠️ 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 @@ -2178,17 +2148,26 @@ namespace 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 = VF_SelectVerticalTreeConnectorZ( - P, BottomZ, TopZ, LinkHash); + const float Zc = VoxelPassageGeometry::VerticalShaftConnectorCenterZ( + P.StrateBottomWorldZ, + BottomZ, + TopZ, + P.LedgeSpacing, + P.LedgeDepth, + TreeRadius); + const float FloorZ = VoxelPassageGeometry::VerticalShaftConnectorFloorZ( + P.StrateBottomWorldZ, + BottomZ, + TopZ, + P.LedgeSpacing, + P.LedgeDepth, + TreeRadius); const FVector ParentPoint = Parent != nullptr ? FVector(Parent->X, Parent->Y, Zc) : FVector(0.0f, 0.0f, Zc); const FConn Conn{ - FVector(Child.X, Child.Y, Zc), ParentPoint, TreeRadius }; + FVector(Child.X, Child.Y, Zc), ParentPoint, TreeRadius, + FloorZ, true }; if (ConnectorMayReachCell(Conn)) { Cache.Conns.Add(Conn); @@ -2310,7 +2289,8 @@ namespace const float ConnectorR = bSpineConnector ? SpineConnectorRadius() : P.ConnectorRadius; const FConn Conn{ - FVector(A.X, A.Y, Zc), FVector(B.X, B.Y, Zc), ConnectorR }; + FVector(A.X, A.Y, Zc), FVector(B.X, B.Y, Zc), ConnectorR, + 0.0f, false }; if (ConnectorMayReachCell(Conn)) { Cache.Conns.Add(Conn); @@ -2404,6 +2384,109 @@ namespace const FShaftFieldSource* Field; // NON possédant : la pile possède la source }; + // The deterministic shaft tree is a walkable network as well as an air topology. Reassert + // its air core after partial shaft ledges, then add the same inset floor band as the native + // path at the common first ledge level. This lets a player-fit route cross between shafts + // without restoring the removed landing-to-origin roads. + class FShaftConnectorAirMod final : public IVoxelDensityOp + { + public: + explicit FShaftConnectorAirMod(const FShaftFieldSource* InField, float InBaseDensity) + : Field(InField), BaseDensity(InBaseDensity) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::Density; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::ShaftGeometry; } + bool IsAdditive() const override { return false; } + void PrepareChunk(const FVoxelOpContext&) override {} + + void Eval(float WorldX, float WorldY, float WorldZ, + FVoxelOpSample& InOut) const override + { + if (Field == nullptr) return; + const FShaftFieldSource::FCells& Cells = Field->GetCellsAt(WorldX, WorldY); + static thread_local bool bLoggedTemporaryShaftConnectors = false; + if (!bLoggedTemporaryShaftConnectors + && FMath::IsNearlyEqual(WorldZ, -700.0f) + && WorldX > -180.0f && WorldX < -170.0f + && WorldY > -48.0f && WorldY < -38.0f) + { + bLoggedTemporaryShaftConnectors = true; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeExplore][TemporaryShaftConnectors] query=(%.3f,%.3f,%.3f) count=%d"), + WorldX, WorldY, WorldZ, Cells.Conns.Num()); + for (const FShaftFieldSource::FConn& DebugConn : Cells.Conns) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeExplore][TemporaryShaftConnectors] A=(%.3f,%.3f,%.3f) " + "B=(%.3f,%.3f,%.3f) radius=%.3f floor=%.3f walk=%d"), + DebugConn.A.X, DebugConn.A.Y, DebugConn.A.Z, + DebugConn.B.X, DebugConn.B.Y, DebugConn.B.Z, + DebugConn.Radius, DebugConn.FloorZ, + DebugConn.bWalkableFloor ? 1 : 0); + } + } + for (const FShaftFieldSource::FConn& Conn : Cells.Conns) + { + if (!Conn.bWalkableFloor) continue; + VoxelPassageGeometry::VF_ApplyVerticalShaftConnectorAir( + InOut.Density, WorldX, WorldY, WorldZ, + Conn.A, Conn.B, Conn.Radius, Conn.FloorZ, BaseDensity); + } + } + + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return Field != nullptr ? EVoxelOpEffect::Both : EVoxelOpEffect::Identity; + } + + const TCHAR* DebugName() const override { return TEXT("ShaftConnectorAirOp"); } + + private: + const FShaftFieldSource* Field = nullptr; // NON possédant : la pile possède la source + float BaseDensity = 8.0f; + }; + + class FShaftConnectorFloorMod final : public IVoxelDensityOp + { + public: + FShaftConnectorFloorMod(const FShaftFieldSource* InField, float InBaseDensity) + : Field(InField), BaseDensity(InBaseDensity) {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::Density; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::ShaftGeometry; } + bool IsAdditive() const override { return false; } + void PrepareChunk(const FVoxelOpContext&) override {} + + void Eval(float WorldX, float WorldY, float WorldZ, + FVoxelOpSample& InOut) const override + { + if (Field == nullptr) return; + const FShaftFieldSource::FCells& Cells = Field->GetCellsAt(WorldX, WorldY); + for (const FShaftFieldSource::FConn& Conn : Cells.Conns) + { + if (!Conn.bWalkableFloor) continue; + VoxelPassageGeometry::VF_ApplyVerticalShaftConnectorFloor( + InOut.Density, WorldX, WorldY, WorldZ, + Conn.A, Conn.B, Conn.Radius, Conn.FloorZ, BaseDensity); + } + } + + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return Field != nullptr ? EVoxelOpEffect::FillOnly : EVoxelOpEffect::Identity; + } + + const TCHAR* DebugName() const override { return TEXT("ShaftConnectorFloorOp"); } + + private: + const FShaftFieldSource* Field = nullptr; // NON possédant : la pile possède la source + float BaseDensity = 8.0f; + }; + //========================================================================= // RÔLE 1 — SOURCE : ÎLES FLOTTANTES / FLOATING ISLAND BLOBS //========================================================================= @@ -2774,6 +2857,12 @@ namespace /** La salle de SDF minimal pour le dernier voxel évalué. -1 = aucune. */ int32 NearestRoom = -1; + /** The warped SDF-space position belonging to the current voxel. */ + FVector LastWarpedPosition = FVector::ZeroVector; + + /** The unwarped position belonging to the current voxel. */ + FVector LastWorldPosition = FVector::ZeroVector; + /** ÉTAPE C1 — les params de la strate avec l'op de CETTE salle appliqué par-dessus. * Mémo par voxel : invalidé au début de chaque `Eval`, calculé au PREMIER modificateur * qui le demande. C'est ce qui reproduit le coût de l'original (une copie de struct par @@ -2795,6 +2884,18 @@ namespace * Lu par les arches, les dômes, le pincement et le biais de sol. */ int32 GetNearestRoomIdx() const { return State().NearestRoom; } + bool HasTunnelAirGeometry() const + { + return P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f; + } + + float GetTunnelCoreSDF() const + { + const FVector& Position = State().LastWorldPosition; + return VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF( + Position.X, Position.Y, Position.Z, State().Cache); + } + /** * ÉTAPE C1 — L'OVERRIDE D'OP PAR SALLE. Les params de la strate avec l'op de terrain tiré * pour la salle la plus proche appliqué par-dessus. **ONZE des douze modificateurs de détail @@ -2875,6 +2976,7 @@ namespace void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override { FState& S = State(); + S.LastWorldPosition = FVector(WorldX, WorldY, WorldZ); // ⚠️ REMIS À -1 INCONDITIONNELLEMENT, ce que l'original ne fait pas : chez lui // `NearestRoomIdx` est un `thread_local` qui, quand `RoomDensity <= 0`, garde la valeur @@ -2919,6 +3021,7 @@ namespace WorldY * WF + 9.7f, EffectiveZ * WF + VoxelHash::SeedOffset(SeedU, 0.41f))) * VOXEL_NOISE_SCALE * WS; } + S.LastWarpedPosition = FVector(WarpedX, WarpedY, WarpedZ); //--------------------------------------------------------------- // LE CACHE PAR BOÎTE DE RECHERCHE @@ -3388,37 +3491,61 @@ namespace for (const FCachedTunnel& Tunnel : B.Cache.Tunnels) { - const float MaxRadius = FMath::Max3( - FMath::Abs(Tunnel.RadiusA), FMath::Abs(Tunnel.RadiusB), - Tunnel.bHasMidpoint ? FMath::Abs(Tunnel.RadiusMid) : 0.0f); - const FVector SegmentAMin( - FMath::Min(Tunnel.EndpointA.X, Tunnel.bHasMidpoint ? Tunnel.Midpoint.X : Tunnel.EndpointB.X), - FMath::Min(Tunnel.EndpointA.Y, Tunnel.bHasMidpoint ? Tunnel.Midpoint.Y : Tunnel.EndpointB.Y), - FMath::Min(Tunnel.EndpointA.Z, Tunnel.bHasMidpoint ? Tunnel.Midpoint.Z : Tunnel.EndpointB.Z)); - const FVector SegmentAMax( - FMath::Max(Tunnel.EndpointA.X, Tunnel.bHasMidpoint ? Tunnel.Midpoint.X : Tunnel.EndpointB.X), - FMath::Max(Tunnel.EndpointA.Y, Tunnel.bHasMidpoint ? Tunnel.Midpoint.Y : Tunnel.EndpointB.Y), - FMath::Max(Tunnel.EndpointA.Z, Tunnel.bHasMidpoint ? Tunnel.Midpoint.Z : Tunnel.EndpointB.Z)); - - float TunnelLower = VF_DistanceBetweenBoxes( - QMin, QMax, SegmentAMin, SegmentAMax) - MaxRadius; - if (Tunnel.bHasMidpoint) + float MaxRadius = FMath::Max( + FMath::Abs(Tunnel.RadiusA), FMath::Abs(Tunnel.RadiusB)); + float TunnelLower = FLT_MAX; + if (Tunnel.ControlPoints.Num() >= 2 + && Tunnel.ControlRadii.Num() == Tunnel.ControlPoints.Num()) { - const FVector SegmentBMin( - FMath::Min(Tunnel.Midpoint.X, Tunnel.EndpointB.X), - FMath::Min(Tunnel.Midpoint.Y, Tunnel.EndpointB.Y), - FMath::Min(Tunnel.Midpoint.Z, Tunnel.EndpointB.Z)); - const FVector SegmentBMax( - FMath::Max(Tunnel.Midpoint.X, Tunnel.EndpointB.X), - FMath::Max(Tunnel.Midpoint.Y, Tunnel.EndpointB.Y), - FMath::Max(Tunnel.Midpoint.Z, Tunnel.EndpointB.Z)); - TunnelLower = FMath::Min( - TunnelLower, - VF_DistanceBetweenBoxes(QMin, QMax, SegmentBMin, SegmentBMax) - MaxRadius); + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Tunnel.ControlPoints.Num(); + ++SegmentIndex) + { + const FVector& A = Tunnel.ControlPoints[SegmentIndex]; + const FVector& BPoint = Tunnel.ControlPoints[SegmentIndex + 1]; + MaxRadius = FMath::Max( + MaxRadius, + FMath::Max(FMath::Abs(Tunnel.ControlRadii[SegmentIndex]), + FMath::Abs(Tunnel.ControlRadii[SegmentIndex + 1]))); + const FVector SegmentMin( + FMath::Min(A.X, BPoint.X), + FMath::Min(A.Y, BPoint.Y), + FMath::Min(A.Z, BPoint.Z)); + const FVector SegmentMax( + FMath::Max(A.X, BPoint.X), + FMath::Max(A.Y, BPoint.Y), + FMath::Max(A.Z, BPoint.Z)); + TunnelLower = FMath::Min( + TunnelLower, + VF_DistanceBetweenBoxes(QMin, QMax, SegmentMin, SegmentMax)); + } + TunnelLower -= MaxRadius; + } + else + { + const FVector SegmentMin( + FMath::Min(Tunnel.EndpointA.X, Tunnel.EndpointB.X), + FMath::Min(Tunnel.EndpointA.Y, Tunnel.EndpointB.Y), + FMath::Min(Tunnel.EndpointA.Z, Tunnel.EndpointB.Z)); + const FVector SegmentMax( + FMath::Max(Tunnel.EndpointA.X, Tunnel.EndpointB.X), + FMath::Max(Tunnel.EndpointA.Y, Tunnel.EndpointB.Y), + FMath::Max(Tunnel.EndpointA.Z, Tunnel.EndpointB.Z)); + TunnelLower = VF_DistanceBetweenBoxes( + QMin, QMax, SegmentMin, SegmentMax) - MaxRadius; } + const FVector NoWarpSegmentMin( + FMath::Min(Tunnel.EndpointA.X, Tunnel.EndpointB.X), + FMath::Min(Tunnel.EndpointA.Y, Tunnel.EndpointB.Y), + FMath::Min(Tunnel.EndpointA.Z, Tunnel.EndpointB.Z)); + const FVector NoWarpSegmentMax( + FMath::Max(Tunnel.EndpointA.X, Tunnel.EndpointB.X), + FMath::Max(Tunnel.EndpointA.Y, Tunnel.EndpointB.Y), + FMath::Max(Tunnel.EndpointA.Z, Tunnel.EndpointB.Z)); const float TunnelLowerNoWarp = VF_DistanceBetweenBoxes( - NoWarpMin, NoWarpMax, SegmentAMin, SegmentAMax) - MaxRadius; + NoWarpMin, NoWarpMax, NoWarpSegmentMin, NoWarpSegmentMax) + - MaxRadius; Consider(TunnelLower); CountThreshold(TunnelLower, B.HitTunnels); CountThreshold(TunnelLowerNoWarp, B.HitTunnelsNoWarp); @@ -3482,6 +3609,13 @@ namespace const TCHAR* DebugName() const override { return TEXT("RoomGraphSource"); } + bool IsTunnelSupportFloor() const + { + const FVector& Position = State().LastWorldPosition; + return VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( + Position.X, Position.Y, Position.Z, State().Cache); + } + private: FStrateGenerationParams P; int32 Seed; @@ -3491,6 +3625,99 @@ namespace uint32 LayoutVersion = 0; }; + // The graph tunnel floor closes the rounded bottom of the core into a finite, walkable support + // slab. It is separate from the inter-strate passage floor: this owns only the wandering + // intra-strate graph edges and does not create any new corridor. + class FCaveTunnelFloorOp final : public IVoxelDensityOp + { + public: + FCaveTunnelFloorOp(const FRoomGraphSource* InRooms, float InBase) + : Rooms(InRooms), Base(InBase) + {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::StructuralPost; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::None; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override + { + return VoxelOpResources::RoomGeometry; + } + bool IsAdditive() const override { return false; } + void PrepareChunk(const FVoxelOpContext&) override {} + + void Eval(float, float, float, FVoxelOpSample& InOut) const override + { + if (Rooms != nullptr && Rooms->IsTunnelSupportFloor()) + { + InOut.Density = FMath::Max( + InOut.Density, + FMath::Max(Base * 2.0f, 1.0f)); + } + } + + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return Rooms != nullptr && Rooms->HasTunnelAirGeometry() + ? EVoxelOpEffect::Both + : EVoxelOpEffect::Identity; + } + + const TCHAR* DebugName() const override { return TEXT("CaveTunnelFloorOp"); } + + private: + const FRoomGraphSource* Rooms = nullptr; + float Base = 8.0f; + }; + + // A graph tunnel's raw SDF is the structural air contract for the wandering chain. It runs + // after the passage post because a different passage can project a solid support floor onto + // the same voxel; the core keeps its own half-voxel support band and restores the route above + // it. FRoomGraphSource evaluates this post against the cached world-space chain, so the cave + // warp cannot turn a walkable floor ramp into a vertical break. + class FCaveTunnelAirOp final : public IVoxelDensityOp + { + public: + FCaveTunnelAirOp(const FRoomGraphSource* InRooms, float InBase, float InInset) + : Rooms(InRooms), Base(InBase), Inset(InInset) + {} + + EVoxelOpRole GetRole() const override { return EVoxelOpRole::StructuralPost; } + EVoxelOpChannelMask ChannelReads() const override { return VoxelOpChannels::Density; } + EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } + EVoxelOpResourceMask RequiredResources() const override + { + return VoxelOpResources::RoomGeometry; + } + bool IsAdditive() const override { return false; } + void PrepareChunk(const FVoxelOpContext&) override {} + + void Eval(float, float, float, FVoxelOpSample& InOut) const override + { + if (Rooms != nullptr + && !Rooms->IsTunnelSupportFloor() + && Rooms->GetTunnelCoreSDF() < -Inset) + { + InOut.Density = FMath::Min( + InOut.Density, + -FMath::Max(Base * 2.0f, 1.0f)); + } + } + + EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override + { + return Rooms != nullptr && Rooms->HasTunnelAirGeometry() + ? EVoxelOpEffect::CarveOnly + : EVoxelOpEffect::Identity; + } + + const TCHAR* DebugName() const override { return TEXT("CaveTunnelAirOp"); } + + private: + const FRoomGraphSource* Rooms = nullptr; + float Base = 8.0f; + float Inset = 2.0f; + }; + //========================================================================= // LE GATE DE BOÎTE DES MODIFICATEURS DE CAVE //========================================================================= @@ -5215,7 +5442,8 @@ bool FVoxelOpStack::ValidateChannelOrder(FString* OutError) const void FVoxelOpStack::AppendStructuralPost(float StrateTopWorldZ, float StrateBottomWorldZ, float SealThickness, float BaseDensity, float SpineRadius, - const UVoxelStrateManager* StrateManager) + const UVoxelStrateManager* StrateManager, + bool bAppendEdgeSeal) { // ORDRE NON NÉGOCIABLE : la spine creuse l'intérieur (et ne touche JAMAIS les bandes de seal), // le seal vertical re-solidifie ses bandes, les passages (tube + landing + support) percent @@ -5226,7 +5454,10 @@ void FVoxelOpStack::AppendStructuralPost(float StrateTopWorldZ, float StrateBott Add(MakeUnique(StrateTopWorldZ, StrateBottomWorldZ, SealThickness, BaseDensity)); Add(MakeUnique(StrateManager, BaseDensity, SealThickness, StrateTopWorldZ, StrateBottomWorldZ, SpineRadius)); - Add(MakeUnique(BaseDensity)); + if (bAppendEdgeSeal) + { + Add(MakeUnique(BaseDensity)); + } } //============================================================================= @@ -5466,6 +5697,8 @@ namespace VoxelDensityOps OutStack.Add(MakeSdfRoughnessMod(P.SurfaceRoughness, 0.1f, 3, P.SurfaceRoughness + 4.0f)); OutStack.Add(MakeSdfCarve(CarveBlend, P.BaseDensity)); OutStack.Add(MakeUnique(P, ShaftPtr)); + OutStack.Add(MakeUnique(ShaftPtr, P.BaseDensity)); + OutStack.Add(MakeUnique(ShaftPtr, P.BaseDensity)); if (bAppendStructuralPosts) { @@ -5552,7 +5785,12 @@ namespace VoxelDensityOps { OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, P.BoundarySealThickness, P.BaseDensity, - SpineRadius, StrateManager); + SpineRadius, StrateManager, false); + OutStack.Add(MakeUnique(RoomPtr, P.BaseDensity)); + OutStack.Add(MakeUnique( + RoomPtr, P.BaseDensity, + VoxelPassageGeometry::CaveTunnelAirCoreInsetVoxels)); + OutStack.Add(MakeUnique(P.BaseDensity)); } } diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 8f33bf0..388f649 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -230,44 +230,6 @@ static float CellularNoise3D(const FVector& Position) return VoxelNoise::Cellular3D(Position); } -// Structural tree capsules must meet shaft axes without landing inside a ledge band. A single -// deterministic safe interval is enough for the whole tree: every shaft in one strate shares the -// same ledge phase, and keeping every tree link in that interval also keeps crossings of unrelated -// shafts out of their additive ledges. The fallback is only for malformed settings with no safe -// interval; the stock params always take the proven branch. -static float VF_SelectVerticalTreeConnectorZ( - const FVerticalShaftParams& Params, - float BottomZ, - float TopZ, - uint32 LinkHash) -{ - if (Params.LedgeSpacing > 0.0f && Params.LedgeDepth > 0.0f) - { - const float Period = Params.LedgeSpacing; - const float RelativeBottom = BottomZ - Params.StrateBottomWorldZ; - const float RelativeTop = TopZ - Params.StrateBottomWorldZ; - const int32 FirstPeriod = FMath::FloorToInt(RelativeBottom / Period); - const float Random01 = VoxelHash::ToFloat01(VoxelHash::Mix(LinkHash)); - - for (int32 PeriodOffset = 0; PeriodOffset <= 1; ++PeriodOffset) - { - const float PeriodStart = static_cast(FirstPeriod + PeriodOffset) * Period; - const float SafeStart = FMath::Max( - RelativeBottom, PeriodStart + Params.LedgeDepth + 0.01f); - const float SafeEnd = FMath::Min( - RelativeTop, PeriodStart + Period - Params.LedgeDepth - 0.01f); - if (SafeEnd > SafeStart) - { - return Params.StrateBottomWorldZ - + FMath::Lerp(SafeStart, SafeEnd, Random01); - } - } - } - - return FMath::Lerp(BottomZ, TopZ, - VoxelHash::ToFloat01(VoxelHash::Mix(LinkHash))); -} - //============================================================================= // DENSITY PIPELINE HELPERS (partagés entre TunnelNetwork et Slab) //============================================================================= @@ -309,7 +271,7 @@ static FORCEINLINE void ApplyOriginLandingFloor(float& Density, // it works uniformly for every generator type. Stays inside the seal bands so it can // never breach a strate boundary. All features are hash-placed and deterministic. static void ApplyDisturbances(float& MC, float X, float Y, float Z, - const FStrateDisturbanceParams& D, uint32 Seed) + const FStrateDisturbanceParams& D, uint32 Seed, bool bProtectVerticalShaftAir) { const float InnerTop = D.StrateTopWorldZ - D.BoundarySealThickness; const float InnerBot = D.StrateBottomWorldZ + D.BoundarySealThickness; @@ -418,7 +380,10 @@ static void ApplyDisturbances(float& MC, float X, float Y, float Z, { float f = FMath::Clamp((Blend - sdf) / (Blend * 2.0f), 0.0f, 1.0f); f = SmoothStep01(f); - MC = FMath::Min(MC, -f * Solid); // force solid + if (!bProtectVerticalShaftAir) + { + MC = FMath::Min(MC, -f * Solid); // force solid + } } } @@ -465,7 +430,10 @@ static void ApplyDisturbances(float& MC, float X, float Y, float Z, f = SmoothStep01(f) * zFade; best = FMath::Max(best, f); } - if (best > 0.0f) MC = FMath::Min(MC, -best * Solid); // force solid + if (best > 0.0f && !bProtectVerticalShaftAir) + { + MC = FMath::Min(MC, -best * Solid); // force solid + } } } } @@ -498,6 +466,57 @@ namespace // address. Relaxed ordering is sufficient: this allocates uniqueness, it publishes no data. std::atomic GNextDensityCacheOwnerId { 0 }; + struct FTunnelCoreCacheState + { + FChunkSDFCache Cache; + uint64 OwnerId = 0; + FIntVector Chunk = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); + uint32 ParamsFingerprint = 0xFFFFFFFFu; + uint32 LayoutVersion = 0xFFFFFFFFu; + bool bValid = false; + }; + + static thread_local FTunnelCoreCacheState GTunnelCoreCache; + void PrepareTunnelCoreCache( + const UVoxelStrateManager& Manager, + const FIntVector& ChunkCoord, + const FStrateGenerationParams& Params, + uint32 ParamsFingerprint, + uint32 LayoutVersion, + uint64 OwnerId, + FTunnelCoreCacheState& OutState) + { + if (OutState.bValid + && OutState.OwnerId == OwnerId + && OutState.Chunk == ChunkCoord + && OutState.ParamsFingerprint == ParamsFingerprint + && OutState.LayoutVersion == LayoutVersion) + { + return; + } + + const float ChunkMinX = static_cast(ChunkCoord.X * CHUNK_SIZE); + const float ChunkMinY = static_cast(ChunkCoord.Y * CHUNK_SIZE); + const float ChunkMaxX = ChunkMinX + static_cast(CHUNK_SIZE); + const float ChunkMaxY = ChunkMinY + static_cast(CHUNK_SIZE); + const float Expansion = FMath::Max(Params.CaveWarpStrength, 0.0f) + 2.0f; + const int32 StrateIndex = Manager.GetStrateIndex( + (static_cast(ChunkCoord.Z) + 0.5f) + * static_cast(CHUNK_SIZE) * VOXEL_SIZE); + + VoxelCaveMorphology::BuildChunkCache( + OutState.Cache, + ChunkMinX - Expansion, ChunkMinY - Expansion, + ChunkMaxX + Expansion, ChunkMaxY + Expansion, + Params, static_cast(Manager.GetWorldSeed()), + StrateIndex, nullptr); + OutState.OwnerId = OwnerId; + OutState.Chunk = ChunkCoord; + OutState.ParamsFingerprint = ParamsFingerprint; + OutState.LayoutVersion = LayoutVersion; + OutState.bValid = true; + } + struct FVoxelStackParamRefs { const FSlabGenerationParams* Slab = nullptr; @@ -673,6 +692,10 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // Query per-chunk params from the manager so each strate has different caves. float Result; + // A vertical tree connector may need to stay open through the shared disturbance and + // structural-post tail below. This is one bit for this density call, not cached voxel data. + VoxelPassageGeometry::ResetVerticalShaftConnectorAirMarker(); + FIntVector ChunkCoord( FMath::FloorToInt(WorldX / CHUNK_SIZE), FMath::FloorToInt(WorldY / CHUNK_SIZE), @@ -831,6 +854,23 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // silently fall back to the old one-archetype switch. CP_UseOpStack = CP_UseOpStack || CP_UseComposerRegions; #endif + bool bUseNativeTunnelCore = !CP_UseCustomRecipe + && (CP_GenType == ECaveGeneratorType::TunnelNetwork + || CP_GenType == ECaveGeneratorType::Underwater); +#if WITH_EDITOR + bUseNativeTunnelCore = bUseNativeTunnelCore && !CP_UseComposerRegions; +#endif + if (bUseNativeTunnelCore) + { + PrepareTunnelCoreCache( + *StrateManager, ChunkCoord, CP_Tunnel, CP_TunnelFP, + LayoutVersion, DensityCacheOwnerId, GTunnelCoreCache); + } + else + { + GTunnelCoreCache.bValid = false; + } + if (CP_UseOpStack) { CP_OpStack = FVoxelOpStack(); // move-assign : libère l'ancienne pile @@ -997,8 +1037,13 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co CP_TunnelFP, LayoutVersion); break; } - // Disturbance layer (the "wow" post-process) — cached params, MC convention. - ApplyDisturbances(Result, WorldX, WorldY, WorldZ, CP_Dist, (uint32)Seed); + // Disturbance layer (the "wow" post-process) — cached params, MC convention. The + // vertical shaft tree opens a walkable route before this shared pass, so a bridge/ridge + // must not refill it. Both native and operator-stack paths use the same marker helper. + const bool bProtectVerticalShaftAir = + VoxelPassageGeometry::VerticalShaftConnectorAirMarker(); + ApplyDisturbances(Result, WorldX, WorldY, WorldZ, CP_Dist, (uint32)Seed, + bProtectVerticalShaftAir); // A disturbance is allowed to add visual detail, but it must not refill the landing's // measured air volume or turn its support slab back into a hole. Reassert the landing @@ -1103,6 +1148,45 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius); StrateManager->ApplyPassageLandingRoomFloorMC( Result, WorldX, WorldY, WorldZ, LandingBaseDensity); + + // Disturbance features are authored as a generic MC-space post and may add a ridge or + // bridge over a graph tunnel. Reassert the native cached tunnel core here, after every + // solid floor writer but before the global XY seal. This cache is built once per chunk, + // never once per voxel. + if (GTunnelCoreCache.bValid) + { + const bool bTunnelSupportFloor = + VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( + WorldX, WorldY, WorldZ, GTunnelCoreCache.Cache); + if (bTunnelSupportFloor) + { + Result = FMath::Min( + Result, + -FMath::Max(LandingBaseDensity * 2.0f, 1.0f)); + } + const float CoreSDF = VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF( + WorldX, WorldY, WorldZ, GTunnelCoreCache.Cache); + if (!bTunnelSupportFloor + && CoreSDF < -VoxelPassageGeometry::CaveTunnelAirCoreInsetVoxels) + { + Result = FMath::Max( + Result, + FMath::Max(LandingBaseDensity * 2.0f, 1.0f)); + } + + // The graph tunnel can overlap an inter-strate landing at a room mouth. Its air + // backstop is allowed to reopen the tunnel, but the landing's proved support floor + // must own the final floor band; otherwise the graph post can erase the only support + // surface at the mouth and leave the player-fit graph with a disconnected pocket. + VF_ApplyOriginLandingFloorMC( + Result, WorldX, WorldY, WorldZ, + CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ, + CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius); + StrateManager->ApplyPassageLandingFloorMC( + Result, WorldX, WorldY, WorldZ, LandingBaseDensity); + StrateManager->ApplyPassageLandingRoomFloorMC( + Result, WorldX, WorldY, WorldZ, LandingBaseDensity); + } } else { @@ -1164,6 +1248,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co } } + VoxelPassageGeometry::ResetVerticalShaftConnectorAirMarker(); return Result; } @@ -2262,6 +2347,28 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo Density, WorldX, WorldY, WorldZ, Params.BaseDensity, Params.BoundarySealThickness); } + + // Terrain operations and passage support floors are allowed to write solid density, but the + // graph's own tunnel floor/core are structural route geometry. Close the rounded bottom first, + // then reopen only the core above its half-voxel air clearance. Both predicates are shared + // with the operator stack and are evaluated from the per-chunk cache, never per-voxel rebuilt. + const bool bTunnelSupportFloor = + VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( + WorldX, WorldY, WorldZ, SDFCache); + if (bTunnelSupportFloor) + { + Density = FMath::Max(Density, FMath::Max(Params.BaseDensity * 2.0f, 1.0f)); + } + const float TunnelCoreSDF = (Params.RoomDensity > 0.0f && Params.RoomSpacing > 0.0f) + ? VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF(WorldX, WorldY, WorldZ, SDFCache) + : FLT_MAX; + if (!bTunnelSupportFloor + && TunnelCoreSDF < -VoxelPassageGeometry::CaveTunnelAirCoreInsetVoxels) + { + Density = FMath::Min( + Density, + -FMath::Max(Params.BaseDensity * 2.0f, 1.0f)); + } ApplyOriginLandingFloor(Density, WorldX, WorldY, WorldZ, Params.StrateTopWorldZ, Params.StrateBottomWorldZ, Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius); @@ -4271,7 +4378,13 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float // them. That matters: a capsule from a child to a parent two or three cells away must be // present in the cells along its segment, not only in the cache centred on the child. struct FLocalShaft { float X, Y, R; int32 CellX, CellY; bool bOriginSpine; }; - struct FLocalConn { FVector A, B; float Radius; }; + struct FLocalConn + { + FVector A, B; + float Radius = 0.0f; + float FloorZ = 0.0f; + bool bWalkableFloor = false; + }; thread_local TArray> Shafts; thread_local TArray> Conns; thread_local TArray> TreeEmitShafts; @@ -4396,17 +4509,26 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float 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 = VF_SelectVerticalTreeConnectorZ( - Params, BottomZ, TopZ, LinkHash); + const float Zc = VoxelPassageGeometry::VerticalShaftConnectorCenterZ( + Params.StrateBottomWorldZ, + BottomZ, + TopZ, + Params.LedgeSpacing, + Params.LedgeDepth, + TreeConnectorRadius); + const float FloorZ = VoxelPassageGeometry::VerticalShaftConnectorFloorZ( + Params.StrateBottomWorldZ, + BottomZ, + TopZ, + Params.LedgeSpacing, + Params.LedgeDepth, + TreeConnectorRadius); const FVector ParentPoint = Parent != nullptr ? FVector(Parent->X, Parent->Y, Zc) : FVector(0.0f, 0.0f, Zc); const FLocalConn Conn{ - FVector(Child.X, Child.Y, Zc), ParentPoint, TreeConnectorRadius }; + FVector(Child.X, Child.Y, Zc), ParentPoint, TreeConnectorRadius, + FloorZ, true }; if (ConnectorMayReachCell(Conn)) { Conns.Add(Conn); @@ -4531,7 +4653,8 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float RoughnessReach + 1.0f) : Params.ConnectorRadius; const FLocalConn Conn{ - FVector(A.X, A.Y, Zc), FVector(B.X, B.Y, Zc), ConnectorR }; + FVector(A.X, A.Y, Zc), FVector(B.X, B.Y, Zc), ConnectorR, + 0.0f, false }; if (ConnectorMayReachCell(Conn)) { Conns.Add(Conn); @@ -4598,6 +4721,29 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float } } + // The drainage tree is a walkable shaft network, not only a connected air SDF. Reassert its + // air core after partial shaft ledges, then emit support bands at the common first ledge level + // so every parent edge can be traversed by the player-fit stencil. This remains local to + // shaft edges; no landing-to-origin road is added. + for (const FLocalConn& C : Conns) + { + if (C.bWalkableFloor) + { + VoxelPassageGeometry::VF_ApplyVerticalShaftConnectorAir( + Density, WorldX, WorldY, WorldZ, + C.A, C.B, C.Radius, C.FloorZ, Params.BaseDensity); + } + } + for (const FLocalConn& C : Conns) + { + if (C.bWalkableFloor) + { + VoxelPassageGeometry::VF_ApplyVerticalShaftConnectorFloor( + Density, WorldX, WorldY, WorldZ, + C.A, C.B, C.Radius, C.FloorZ, Params.BaseDensity); + } + } + ApplyOriginSpine(Density, WorldX, WorldY, WorldZ, Params.StrateTopWorldZ, Params.StrateBottomWorldZ, Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius); diff --git a/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Source/VoxelForge/Private/VoxelStrateManager.cpp index 5710053..a8611ea 100644 --- a/Source/VoxelForge/Private/VoxelStrateManager.cpp +++ b/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -269,9 +269,10 @@ static bool VF_IsAnyPassageFloorAt( continue; } const FVoxelPassage& Passage = Passages[PassageIndex]; - if (VF_IsPassageLandingFloor(Position, Passage.UpperLanding) + if (!VoxelPassageGeometry::VerticalShaftConnectorAirMarker() + && (VF_IsPassageLandingFloor(Position, Passage.UpperLanding) || VF_IsPassageLandingFloor(Position, Passage.LowerLanding) - || VF_IsWalkableTunnelFloor(Passage, Position)) + || VF_IsWalkableTunnelFloor(Passage, Position))) { return true; } @@ -826,8 +827,6 @@ void UVoxelStrateManager::GeneratePassages() int32 TotalPassages = 0; int32 NumAimedAtUpperPlayerFit = 0; int32 NumAimedAtLowerPlayerFit = 0; - int32 NumUpperRootConnectors = 0; - int32 NumLowerRootConnectors = 0; TSet NoQueryArchetypes; const auto ArchetypeName = [](ECaveGeneratorType Archetype) @@ -915,39 +914,6 @@ void UVoxelStrateManager::GeneratePassages() } }; - const auto CommonRootFloorFor = [&](float StrateTopZ, float StrateBottomZ, - float BoundarySealThickness) -> float - { - // The two mouth queries may legitimately return floors near opposite strate faces. The - // root is the origin landing room's floor, not a hidden continuous shaft and not a - // passage-order-dependent midpoint. Reusing the same top-anchored geometry here makes - // every connector meet the (0,0) room at its actual support level. - const VoxelPassageGeometry::FOriginLandingGeometry OriginLanding = - VoxelPassageGeometry::BuildOriginLandingGeometry( - StrateTopZ, StrateBottomZ, BoundarySealThickness, OriginSpineRadius); - if (OriginLanding.bValid) - { - return OriginLanding.FloorZ; - } - - // Malformed tiny strates still fail closed. This fallback is only for the descriptor - // builder's diagnostic path; normal authored strates always satisfy the room interval. - const float Seal = FMath::Max(BoundarySealThickness, 0.0f); - const float InnerBottom = StrateBottomZ + Seal; - const float InnerTop = StrateTopZ - Seal; - const float ConnectorHeight = FMath::Max( - 12.0f, FVoxelPlayerCapsuleConstants::HeightVoxels + 4.0f); - constexpr float PassageBlendSafety = 4.0f; - const float MinimumFloor = InnerBottom + 3.0f + PassageBlendSafety; - const float MaximumFloor = InnerTop - ConnectorHeight - PassageBlendSafety; - if (FMath::IsFinite(MinimumFloor) && FMath::IsFinite(MaximumFloor) - && MaximumFloor >= MinimumFloor) - { - return 0.5f * (MinimumFloor + MaximumFloor); - } - return 0.5f * (InnerBottom + InnerTop); - }; - //========================================================================= // INTER-STRATE PASSAGES: tunnels connecting consecutive strates. // Each passage is randomly assigned one of 5 types, which determines @@ -974,6 +940,17 @@ void UVoxelStrateManager::GeneratePassages() const float DistLo = FMath::Min(Cfg.DistanceMin, Cfg.DistanceMax); const float DistHi = FMath::Max(Cfg.DistanceMin, Cfg.DistanceMax); + // The origin room owns the future straight shaft. Keep the progression mouth outside its + // carve/blend envelope, then choose a deterministic point in the authored annulus. If an + // asset has an invalid inner radius, repair only that radius; the outer authored radius is + // preserved unless it is too small to contain the required inner edge. + const float OriginLandingHalfWidth = + VoxelPassageGeometry::LandingHalfWidthForRadius(OriginSpineRadius); + const float MinimumDescentDistance = FMath::Max( + OriginLandingHalfWidth + VoxelPassageGeometry::LandingCarveBlendVoxels, + VoxelPassageGeometry::MinimumTurnFloorWidthVoxels); + const float PlacementLo = FMath::Max(DistLo, MinimumDescentDistance); + const float PlacementHi = FMath::Max(DistHi, PlacementLo); const int32 Conns = FMath::Max(0, Cfg.Connections); for (int32 c = 0; c < Conns; c++) @@ -1010,9 +987,11 @@ void UVoxelStrateManager::GeneratePassages() break; } - // PLACEMENT: random angle, distance from the (0,0) spine within config range. + // PLACEMENT: random angle, distance from the (0,0) spine within a deterministic + // annulus. It is deliberately not the spine: that room is reserved for the future + // surface shaft, while this passage opens a separate room in the strate network. const float Angle = PassageRandom01(PassageSaltAngle) * (2.0f * PI); - const float Distance = PassageRandomRange(DistLo, DistHi, PassageSaltDistance); + const float Distance = PassageRandomRange(PlacementLo, PlacementHi, PassageSaltDistance); const float PX = FMath::Cos(Angle) * Distance; const float PY = FMath::Sin(Angle) * Distance; @@ -1049,7 +1028,8 @@ void UVoxelStrateManager::GeneratePassages() PX, PY, MaxLateralSnapFor(*UpperDef), - SuggestedUpperPoint); + SuggestedUpperPoint, + CachedSeed); if (bAimedUpperAtPlayerFitPoint) { @@ -1088,7 +1068,8 @@ void UVoxelStrateManager::GeneratePassages() PX, PY, MaxLateralSnap, - SuggestedLowerPoint); + SuggestedLowerPoint, + CachedSeed); if (bAimedLowerAtPlayerFitPoint) { @@ -1289,10 +1270,9 @@ void UVoxelStrateManager::GeneratePassages() : FVector::ZeroVector, PassageRandom01(PassageSaltLowerDoor) * 2.0f * PI); - // A source-fit answer proves only a local pose; none of the pure queries performs a - // flood fill over the live strate. Every mouth therefore receives the same explicit - // flat-floor connector to the deterministic root, including answered mouths. This - // is what makes the join guaranteed rather than merely probable. + // A source-fit answer is the landing itself. Keep it local to the requested room: the + // (0,0) landing is reserved for the future straight shaft, so no radial connector is + // synthesized here. Passage.UpperLanding = VF_BuildPassageLanding( FVector(UpperX, UpperY, TopZ), Cfg.MouthRadius, @@ -1300,13 +1280,7 @@ void UVoxelStrateManager::GeneratePassages() UpperTopZ, UpperBottomZ, UpperDef ? BoundarySealThicknessFor(*UpperDef) : 0.0f, - bAimedUpperAtPlayerFitPoint, - /*bHasNetworkConnector=*/true, - FVector(0.0f, 0.0f, - CommonRootFloorFor( - UpperTopZ, UpperBottomZ, - UpperDef ? BoundarySealThicknessFor(*UpperDef) : 0.0f)), - OriginSpineRadius); + bAimedUpperAtPlayerFitPoint); Passage.LowerLanding = VF_BuildPassageLanding( FVector(LowerX, LowerY, BottomZ), Cfg.MouthRadius, @@ -1314,17 +1288,9 @@ void UVoxelStrateManager::GeneratePassages() LowerTopZ, (float)(Lower.BottomChunkZ) * CHUNK_SIZE, LowerDef ? BoundarySealThicknessFor(*LowerDef) : 0.0f, - bAimedLowerAtPlayerFitPoint, - /*bHasNetworkConnector=*/true, - FVector(0.0f, 0.0f, - CommonRootFloorFor( - LowerTopZ, (float)(Lower.BottomChunkZ) * CHUNK_SIZE, - LowerDef ? BoundarySealThicknessFor(*LowerDef) : 0.0f)), - OriginSpineRadius); + bAimedLowerAtPlayerFitPoint); Passage.UpperPoint = Passage.UpperLanding.StandingPoint; Passage.LowerPoint = Passage.LowerLanding.StandingPoint; - if (Passage.UpperLanding.bHasNetworkConnector) ++NumUpperRootConnectors; - if (Passage.LowerLanding.bHasNetworkConnector) ++NumLowerRootConnectors; if (Cfg.Style == EVoxelPassageStyle::Straight && Passage.UpperLanding.HalfWidth > 0.0f @@ -1554,14 +1520,11 @@ void UVoxelStrateManager::GeneratePassages() } // Fallback / bounds if an invalid authored width produced no positive profile. Passage.Radius = FMath::Max(Passage.Radius, FMath::Max(Cfg.MouthRadius, Cfg.MidRadius)); - Passage.Radius = FMath::Max(Passage.Radius, - FMath::Max(Passage.UpperLanding.ConnectorRadius, - Passage.LowerLanding.ConnectorRadius)); - // Bounding sphere over the tube, both rooms, both floor slabs, and both optional root - // connectors (+ widest radius + blend) for culling. Under-sizing this sphere would - // cull a real landing and leave a sealed pocket, so the room's full box diagonal is - // included rather than treating the standing anchor as a point. + // Bounding sphere over the tube, both rooms, and both floor slabs (+ widest radius + + // blend) for culling. Under-sizing this sphere would cull a real landing and leave a + // sealed pocket, so the room's full box diagonal is included rather than treating the + // standing anchor as a point. { FVector BoundsMin(FLT_MAX, FLT_MAX, FLT_MAX); FVector BoundsMax(-FLT_MAX, -FLT_MAX, -FLT_MAX); @@ -1590,31 +1553,6 @@ void UVoxelStrateManager::GeneratePassages() + 0.25f * FMath::Square(Height)) + Landing.FloorThickness + 4.0f; IncludePoint(RoomCenter, RoomRadius); - if (Landing.bHasNetworkConnector) - { - const float HubRadius = Landing.RootSpineRadius - + Landing.ConnectorRadius; - const float HubHalfHeight = 0.5f * FMath::Max( - Landing.RootCeilingZ - Landing.RootFloorZ, 0.0f); - IncludePoint(FVector(0.0f, 0.0f, - (Landing.RootFloorZ + Landing.RootCeilingZ) * 0.5f), - FMath::Sqrt(FMath::Square(HubRadius) - + FMath::Square(HubHalfHeight)) - + Landing.FloorThickness + 4.0f); - const float ConnectorHalfHeight = FMath::Max( - 0.5f * FMath::Max( - Landing.ConnectorCeilingZ - Landing.FloorZ, 0.0f), - Landing.ConnectorRadius); - IncludePoint(Landing.ConnectorStart, - ConnectorHalfHeight + 4.0f); - if (Landing.bHasConnectorBend) - { - IncludePoint(Landing.ConnectorControl, - ConnectorHalfHeight + 4.0f); - } - IncludePoint(Landing.ConnectorEnd, - ConnectorHalfHeight + 4.0f); - } }; IncludeLanding(Passage.UpperLanding); IncludeLanding(Passage.LowerLanding); @@ -1646,13 +1584,11 @@ void UVoxelStrateManager::GeneratePassages() } UE_LOG(LogTemp, Log, - TEXT("[StrateManager] Passage landings: upper %d/%d and lower %d/%d source-fit; root floor connectors %d/%d; %d/%d mouth queries fell back to random reach (archetypes with no query: %s)."), + TEXT("[StrateManager] Passage landings: upper %d/%d and lower %d/%d source-fit; %d/%d mouth queries fell back to random reach (archetypes with no query: %s)."), NumAimedAtUpperPlayerFit, TotalPassages, NumAimedAtLowerPlayerFit, TotalPassages, - NumUpperRootConnectors, - NumLowerRootConnectors, (TotalPassages * 2) - NumAimedAtUpperPlayerFit - NumAimedAtLowerPlayerFit, TotalPassages * 2, *NoQueryList); @@ -1686,7 +1622,9 @@ void UVoxelStrateManager::GeneratePassages() // Keep the legacy type: this is the explicitly opted-in above-ground vertical opening, // not the default inter-strate descent style. Entry.PassageType = EVoxelPassageType::VerticalShaft; - Entry.Radius = FMath::Max(OriginSpineRadius * 0.7f, 4.0f); + // Match the reserved future shaft envelope exactly. The entry is only opened through + // the top seal into the finite landing room; lower strate seals remain untouched. + Entry.Radius = FMath::Max(OriginSpineRadius, 1.0f); Entry.UpperPoint = FVector(0.0f, 0.0f, TopZ + CHUNK_SIZE); // The capsule's lower tangent meets the room ceiling; it never bores through the // room's floor or creates an origin column in strate 0. @@ -1779,8 +1717,8 @@ float UVoxelStrateManager::EvaluateModifierSDF(float WorldX, float WorldY, float } // A landing is a real room with a hard flat-floor half-space, not a sphere around the - // tube endpoint. The optional connector is the same fixed SDF geometry selected during - // GeneratePassages; it never performs a source query here. + // tube endpoint. It is the same fixed SDF geometry selected during GeneratePassages; it + // never performs a source query here and does not synthesize a radial root connector. const float UpperLandingSDF = VF_EvaluatePassageLandingSDF(Pos, P.UpperLanding); const float LowerLandingSDF = VF_EvaluatePassageLandingSDF(Pos, P.LowerLanding); MinSDF = VoxelSDF::SmoothMin(MinSDF, UpperLandingSDF, BlendK); @@ -1807,9 +1745,10 @@ void UVoxelStrateManager::ApplyPassageModifier( for (const int32 PassageIndex : Nearby) { const FVoxelPassage& Passage = Passages[PassageIndex]; - if (VF_IsPassageLandingFloor(Position, Passage.UpperLanding) + if (!VoxelPassageGeometry::VerticalShaftConnectorAirMarker() + && (VF_IsPassageLandingFloor(Position, Passage.UpperLanding) || VF_IsPassageLandingFloor(Position, Passage.LowerLanding) - || VF_IsWalkableTunnelFloor(Passage, Position)) + || VF_IsWalkableTunnelFloor(Passage, Position))) { // This is the one bidirectional part of PassageCarveOp: a floor is a proved solid // support slab. It is deliberately applied after the air carve so a tube can never @@ -1928,9 +1867,10 @@ void UVoxelStrateManager::ApplyPassageLandingFloorMC( for (const int32 PassageIndex : Nearby) { const FVoxelPassage& Passage = Passages[PassageIndex]; - if (VF_IsPassageLandingFloor(Position, Passage.UpperLanding) - || VF_IsPassageLandingFloor(Position, Passage.LowerLanding) - || VF_IsWalkableTunnelFloor(Passage, Position)) + if (!VoxelPassageGeometry::VerticalShaftConnectorAirMarker() + && (VF_IsPassageLandingFloor(Position, Passage.UpperLanding) + || VF_IsPassageLandingFloor(Position, Passage.LowerLanding) + || VF_IsWalkableTunnelFloor(Passage, Position))) { // Result is in MC convention here (negative = solid). This reassertion is the // structural floor backstop after the optional MC-space disturbance layer. @@ -1971,8 +1911,9 @@ void UVoxelStrateManager::ApplyPassageLandingRoomFloorMC( for (const int32 PassageIndex : Nearby) { const FVoxelPassage& Passage = Passages[PassageIndex]; - if (VF_IsPassageRoomFloor(Position, Passage.UpperLanding) - || VF_IsPassageRoomFloor(Position, Passage.LowerLanding)) + if (!VoxelPassageGeometry::VerticalShaftConnectorAirMarker() + && (VF_IsPassageRoomFloor(Position, Passage.UpperLanding) + || VF_IsPassageRoomFloor(Position, Passage.LowerLanding))) { Density = FMath::Min(Density, -BaseDensity); break; @@ -2030,50 +1971,6 @@ bool UVoxelStrateManager::AnyPassageLandingFloorNearBox( return true; } - // The fallback connector carries the same support slab away from the room. Its - // segment is not covered by the room AABB above; omitting it would let a tile that - // contains only the connector floor prove AllAir and erase the guaranteed join. - if (Landing->bHasNetworkConnector && Landing->ConnectorRadius > 0.0f - && FMath::IsFinite(Landing->ConnectorStart.X) - && FMath::IsFinite(Landing->ConnectorStart.Y) - && FMath::IsFinite(Landing->ConnectorStart.Z) - && FMath::IsFinite(Landing->ConnectorEnd.X) - && FMath::IsFinite(Landing->ConnectorEnd.Y) - && FMath::IsFinite(Landing->ConnectorEnd.Z)) - { - const float ConnectorPad = Landing->ConnectorRadius + Pad; - float ConnectorMinX = FMath::Min( - Landing->ConnectorStart.X, Landing->ConnectorEnd.X) - ConnectorPad; - float ConnectorMaxX = FMath::Max( - Landing->ConnectorStart.X, Landing->ConnectorEnd.X) + ConnectorPad; - float ConnectorMinY = FMath::Min( - Landing->ConnectorStart.Y, Landing->ConnectorEnd.Y) - ConnectorPad; - float ConnectorMaxY = FMath::Max( - Landing->ConnectorStart.Y, Landing->ConnectorEnd.Y) + ConnectorPad; - if (Landing->bHasConnectorBend) - { - ConnectorMinX = FMath::Min( - ConnectorMinX, Landing->ConnectorControl.X - ConnectorPad); - ConnectorMaxX = FMath::Max( - ConnectorMaxX, Landing->ConnectorControl.X + ConnectorPad); - ConnectorMinY = FMath::Min( - ConnectorMinY, Landing->ConnectorControl.Y - ConnectorPad); - ConnectorMaxY = FMath::Max( - ConnectorMaxY, Landing->ConnectorControl.Y + ConnectorPad); - } - const float ConnectorMinZ = FMath::Min( - Landing->FloorZ, Landing->RootFloorZ) - - Landing->FloorThickness - Pad; - const float ConnectorMaxZ = FMath::Max( - Landing->FloorZ, Landing->RootFloorZ) + Pad; - if (ConnectorMaxX >= MinVoxel.X && ConnectorMinX <= MaxVoxel.X - && ConnectorMaxY >= MinVoxel.Y && ConnectorMinY <= MaxVoxel.Y - && ConnectorMaxZ >= MinVoxel.Z && ConnectorMinZ <= MaxVoxel.Z) - { - return true; - } - - } } // The default tube also owns a three-voxel support band. Its conservative segment AABB diff --git a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp index 2d57e2a..e914f62 100644 --- a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp +++ b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp @@ -7,6 +7,7 @@ #include "VoxelGenerator.h" #include "VoxelStrateManager.h" #include "VoxelTypes.h" +#include "Async/ParallelFor.h" namespace VoxelStrateMeasurePrivate { @@ -388,21 +389,26 @@ namespace VoxelStrateMeasurePrivate OutGrid.Density.SetNumUninitialized(OutGrid.CellCount); } - bool bSawNonFiniteDensity = false; - int32 CellIndex = 0; - for (int32 Z = 0; Z < OutGrid.NumZ; ++Z) + // Each Z slab writes a disjoint contiguous region. GetDensityAt is a const, read-only + // query used concurrently by the mesher, and custom samplers are required to provide + // the same const/thread-safe contract. The non-finite flag is also per-cell so the + // diagnostic reduction below never depends on task completion order. + TArray NonFiniteFlags; + NonFiniteFlags.Init(0u, OutGrid.CellCount); + ParallelFor(OutGrid.NumZ, [&](int32 Z) { for (int32 Y = 0; Y < OutGrid.NumY; ++Y) { for (int32 X = 0; X < OutGrid.NumX; ++X) { + const int32 CellIndex = OutGrid.Index(X, Y, Z); const FVector Sample = OutGrid.CellCenter(X, Y, Z); const float Density = Sampler != nullptr ? Sampler->SampleDensity(Sample.X, Sample.Y, Sample.Z) : Generator->GetDensityAt(Sample.X, Sample.Y, Sample.Z); if (!FMath::IsFinite(Density)) { - bSawNonFiniteDensity = true; + NonFiniteFlags[CellIndex] = 1u; } // MC convention in this codebase: negative is solid, positive is air. @@ -410,11 +416,16 @@ namespace VoxelStrateMeasurePrivate { OutGrid.Density[CellIndex] = Density; } - OutGrid.Air[CellIndex++] = Density > 0.0f ? 1u : 0u; + OutGrid.Air[CellIndex] = Density > 0.0f ? 1u : 0u; } } - } + }); + bool bSawNonFiniteDensity = false; + for (int32 CellIndex = 0; CellIndex < OutGrid.CellCount; ++CellIndex) + { + bSawNonFiniteDensity |= NonFiniteFlags[CellIndex] != 0u; + } if (bSawNonFiniteDensity) { return Refuse(OutReason, TEXT("At least one density sample was non-finite.")); @@ -552,54 +563,104 @@ namespace VoxelStrateMeasurePrivate void BuildManhattanDistanceToSolid(const FSampleGrid& Grid, TArray& OutDistances) { - // Two raster passes with 6-neighbour Manhattan relaxation. The result is an approximation - // of distance-to-solid in the original field: each coarse grid unit is SampleStep VOXELS. + // Two wavefront passes with 6-neighbour Manhattan relaxation. A cell depends only on the + // immediately preceding diagonal in the forward pass (or following diagonal in the + // reverse pass), so each diagonal is safe to process in parallel while the diagonal + // barriers preserve the exact raster result. Each coarse grid unit is SampleStep VOXELS. constexpr int32 Infinity = INT32_MAX; OutDistances.SetNumUninitialized(Grid.CellCount); - for (int32 Index = 0; Index < Grid.CellCount; ++Index) + ParallelFor(Grid.CellCount, [&](int32 Index) { OutDistances[Index] = Grid.Air[Index] == 0u ? 0 : Infinity; - } + }); - auto Relax = [&](int32 Current, int32 Neighbor) + const int32 LastDiagonal = Grid.NumX + Grid.NumY + Grid.NumZ - 3; + const auto ProcessDiagonal = [&](int32 Diagonal, bool bForward) { - if (OutDistances[Neighbor] == Infinity) + const int32 MinZ = FMath::Max( + 0, Diagonal - (Grid.NumX - 1) - (Grid.NumY - 1)); + const int32 MaxZ = FMath::Min(Grid.NumZ - 1, Diagonal); + if (MinZ > MaxZ) { return; } - const int32 Candidate = OutDistances[Neighbor] == INT32_MAX - 1 - ? Infinity : OutDistances[Neighbor] + 1; - OutDistances[Current] = FMath::Min(OutDistances[Current], Candidate); + + int32 DiagonalCellCount = 0; + for (int32 Z = MinZ; Z <= MaxZ; ++Z) + { + const int32 MinY = FMath::Max( + 0, Diagonal - Z - (Grid.NumX - 1)); + const int32 MaxY = FMath::Min(Grid.NumY - 1, Diagonal - Z); + DiagonalCellCount += FMath::Max(0, MaxY - MinY + 1); + } + + const auto ProcessZ = [&](int32 Z) + { + const int32 MinY = FMath::Max( + 0, Diagonal - Z - (Grid.NumX - 1)); + const int32 MaxY = FMath::Min(Grid.NumY - 1, Diagonal - Z); + for (int32 Y = MinY; Y <= MaxY; ++Y) + { + const int32 X = Diagonal - Z - Y; + const int32 Current = Grid.Index(X, Y, Z); + if (Grid.Air[Current] == 0u) + { + continue; + } + + const auto Relax = [&](int32 Neighbor) + { + if (OutDistances[Neighbor] == Infinity) + { + return; + } + const int32 Candidate = OutDistances[Neighbor] == INT32_MAX - 1 + ? Infinity : OutDistances[Neighbor] + 1; + OutDistances[Current] = FMath::Min( + OutDistances[Current], Candidate); + }; + + if (bForward) + { + if (X > 0) Relax(Grid.Index(X - 1, Y, Z)); + if (Y > 0) Relax(Grid.Index(X, Y - 1, Z)); + if (Z > 0) Relax(Grid.Index(X, Y, Z - 1)); + } + else + { + if (X + 1 < Grid.NumX) Relax(Grid.Index(X + 1, Y, Z)); + if (Y + 1 < Grid.NumY) Relax(Grid.Index(X, Y + 1, Z)); + if (Z + 1 < Grid.NumZ) Relax(Grid.Index(X, Y, Z + 1)); + } + } + }; + + // Tiny edge diagonals are cheaper to execute directly; substantial diagonals get + // one task per Z row, with every row writing a disjoint set of cells. + const int32 ZCount = MaxZ - MinZ + 1; + if (ZCount > 1 && DiagonalCellCount >= 256) + { + ParallelFor(ZCount, [&](int32 ZOffset) + { + ProcessZ(MinZ + ZOffset); + }); + } + else + { + for (int32 Z = MinZ; Z <= MaxZ; ++Z) + { + ProcessZ(Z); + } + } }; - for (int32 Z = 0; Z < Grid.NumZ; ++Z) + for (int32 Diagonal = 0; Diagonal <= LastDiagonal; ++Diagonal) { - for (int32 Y = 0; Y < Grid.NumY; ++Y) - { - for (int32 X = 0; X < Grid.NumX; ++X) - { - const int32 Current = Grid.Index(X, Y, Z); - if (Grid.Air[Current] == 0u) continue; - if (X > 0) Relax(Current, Grid.Index(X - 1, Y, Z)); - if (Y > 0) Relax(Current, Grid.Index(X, Y - 1, Z)); - if (Z > 0) Relax(Current, Grid.Index(X, Y, Z - 1)); - } - } + ProcessDiagonal(Diagonal, true); } - - for (int32 Z = Grid.NumZ - 1; Z >= 0; --Z) + for (int32 Diagonal = LastDiagonal; Diagonal >= 0; --Diagonal) { - for (int32 Y = Grid.NumY - 1; Y >= 0; --Y) - { - for (int32 X = Grid.NumX - 1; X >= 0; --X) - { - const int32 Current = Grid.Index(X, Y, Z); - if (Grid.Air[Current] == 0u) continue; - if (X + 1 < Grid.NumX) Relax(Current, Grid.Index(X + 1, Y, Z)); - if (Y + 1 < Grid.NumY) Relax(Current, Grid.Index(X, Y + 1, Z)); - if (Z + 1 < Grid.NumZ) Relax(Current, Grid.Index(X, Y, Z + 1)); - } - } + ProcessDiagonal(Diagonal, false); } } @@ -1092,14 +1153,23 @@ namespace VoxelStrateMeasurePrivate } OutPlayerFit.Init(0u, Grid.CellCount); - TArray SupportHeights; - SupportHeights.SetNumUninitialized(SupportOffsets.Num()); - TArray SupportAnchorZs; - SupportAnchorZs.SetNumUninitialized(SupportOffsets.Num()); - TArray bSupported; - bSupported.Init(0u, SupportOffsets.Num()); - for (int32 Z = 1; Z < Grid.NumZ; ++Z) + TArray PlayerFitCounts; + PlayerFitCounts.Init(0, Grid.NumZ); + + // A candidate pose only reads the immutable sampled grid and writes its own mask cell. + // Give each Z slab private stencil scratch, then reduce the per-slab counts in Z order + // below. This removes the old shared scratch/count race without changing any predicate. + ParallelFor(FMath::Max(Grid.NumZ - 1, 0), [&](int32 SliceIndex) { + const int32 Z = SliceIndex + 1; + TArray SupportHeights; + SupportHeights.SetNumUninitialized(SupportOffsets.Num()); + TArray SupportAnchorZs; + SupportAnchorZs.SetNumUninitialized(SupportOffsets.Num()); + TArray bSupported; + bSupported.Init(0u, SupportOffsets.Num()); + int64 NumPlayerFitInSlice = 0; + for (int32 Y = 0; Y < Grid.NumY; ++Y) { for (int32 X = 0; X < Grid.NumX; ++X) @@ -1188,14 +1258,18 @@ namespace VoxelStrateMeasurePrivate { continue; } - if (OutPlayerFit[Current] != 0u) - { - continue; - } OutPlayerFit[Current] = 1u; - ++OutNumPlayerFitCells; + ++NumPlayerFitInSlice; } } + PlayerFitCounts[Z] = NumPlayerFitInSlice; + }); + + // Fixed-index reduction is intentional: even though the terms are integral, keeping the + // reduction serial documents the determinism rule shared by the floating metrics below. + for (int32 Z = 1; Z < Grid.NumZ; ++Z) + { + OutNumPlayerFitCells += PlayerFitCounts[Z]; } return true; } @@ -1211,11 +1285,16 @@ namespace VoxelStrateMeasurePrivate const int32 PlaneCells = Grid.NumX * Grid.NumY; constexpr int32 Infinity = INT32_MAX / 4; - TArray Distances; - Distances.SetNumUninitialized(PlaneCells); - int32 MinimumDistance = Infinity; - for (int32 Z = 0; Z < Grid.NumZ; ++Z) + TArray PerSliceMinimum; + PerSliceMinimum.Init(Infinity, Grid.NumZ); + + // Each horizontal distance transform is independent. Keep its mutable raster in the + // worker and publish one integer result per Z slice; the final minimum is reduced in + // ascending Z order for an explicit deterministic reduction. + ParallelFor(Grid.NumZ, [&](int32 Z) { + TArray Distances; + Distances.SetNumUninitialized(PlaneCells); for (int32 Y = 0; Y < Grid.NumY; ++Y) { for (int32 X = 0; X < Grid.NumX; ++X) @@ -1269,6 +1348,7 @@ namespace VoxelStrateMeasurePrivate } } + int32 SliceMinimum = Infinity; for (int32 Y = 0; Y < Grid.NumY; ++Y) { for (int32 X = 0; X < Grid.NumX; ++X) @@ -1276,11 +1356,18 @@ namespace VoxelStrateMeasurePrivate const int32 Cell = Grid.Index(X, Y, Z); if (PlayerFit[Cell] != 0u) { - MinimumDistance = FMath::Min( - MinimumDistance, Distances[X + Grid.NumX * Y]); + SliceMinimum = FMath::Min( + SliceMinimum, Distances[X + Grid.NumX * Y]); } } } + PerSliceMinimum[Z] = SliceMinimum; + }); + + int32 MinimumDistance = Infinity; + for (int32 Z = 0; Z < Grid.NumZ; ++Z) + { + MinimumDistance = FMath::Min(MinimumDistance, PerSliceMinimum[Z]); } return MinimumDistance == Infinity diff --git a/Source/VoxelForge/Public/VoxelCaveMorphology.h b/Source/VoxelForge/Public/VoxelCaveMorphology.h index fc1bc19..4c93559 100644 --- a/Source/VoxelForge/Public/VoxelCaveMorphology.h +++ b/Source/VoxelForge/Public/VoxelCaveMorphology.h @@ -10,17 +10,17 @@ // infinite worlds without storing anything. // // ROOMS: -// - Origin room: guaranteed large room at (0,0) per strate — the hub +// - Origin room: guaranteed large room at (0,0) per strate — the future shaft landing // - Hash rooms: ellipsoids, rounded boxes, or elongated capsules // - All blended with smooth-min for organic junctions // // TUNNELS: // - Connect pairs of rooms based on distance and probability // - Tapered (variable min/max radius at each endpoint) -// - Curved (midpoint displaced perpendicular to tunnel direction) +// - Curved wandering chains with deterministic perpendicular control-point jitter // - Horizontal bias (vertical connections penalized) -// - Endpoint Z offset (tunnels enter rooms at different heights) -// - Origin room forces connections to all nearby rooms +// - Floor-aligned endpoints and variable radii preserve walkable room joins +// - The origin room is not joined to landings by radial connector roads // // PIPELINE POSITION: // Step 3 (cave warp) bends the SDF query coordinates before we evaluate. @@ -48,14 +48,10 @@ enum class ECaveGeneratorType : uint8; * `FloorZ = StandingPoint.Z - 0.5`. The chamber is deliberately separate from the tube endpoint * so a sloped/vertical tube never presents the player with a hole as its first standing position. * - * A source query answer selects the landing's XY/Z anchor. Because that pure query does not - * flood-fill the live network, every inter-strate end also gets a deterministic root connector - * (`bHasNetworkConnector`). Its flat floor ramps to the same common interior root level for that - * strate, so both mouths enter the finite landing room at (0,0) even when their authored floors - * are at opposite sides of the strate. `RootSpineRadius` supplies the small endpoint overlap that - * joins the connector to that room; it does not create a vertical shaft. If the direct run would - * be steeper than the named 15 degree tunnel contract, the connector uses a deterministic level - * dog-leg before its final ramp; the bend is geometry, not a per-voxel search. + * A source query answer selects the landing's XY/Z anchor. The landing is deliberately local to + * that answer: it is a flat-floored room where the inter-strate tube meets a legal player-fit + * pose. The (0,0) landing is the only place reserved for the future straight surface shaft; + * progression mouths do not receive hidden radial roads to that spine room. */ struct VOXELFORGE_API FVoxelPassageLanding { @@ -63,23 +59,12 @@ struct VOXELFORGE_API FVoxelPassageLanding FVector DoorPoint = FVector::ZeroVector; FVector DoorDirection = FVector(1.0f, 0.0f, 0.0f); - FVector ConnectorStart = FVector::ZeroVector; - FVector ConnectorControl = FVector::ZeroVector; - FVector ConnectorEnd = FVector::ZeroVector; - float FloorZ = 0.0f; float CeilingZ = 0.0f; float HalfWidth = 0.0f; float FloorThickness = 3.0f; - float ConnectorRadius = 0.0f; - float ConnectorCeilingZ = 0.0f; - float RootFloorZ = 0.0f; - float RootCeilingZ = 0.0f; - float RootSpineRadius = 0.0f; bool bSourcePlayerFit = false; - bool bHasNetworkConnector = false; - bool bHasConnectorBend = false; }; //============================================================================= @@ -202,17 +187,14 @@ VOXELFORGE_API FVoxelPassageLanding VF_BuildPassageLanding( float StrateTopZ, float StrateBottomZ, float BoundarySealThickness, - bool bSourcePlayerFit, - bool bHasNetworkConnector, - const FVector& NetworkPoint, - float RootSpineRadius = 0.0f); + bool bSourcePlayerFit); -/** Signed distance of the room + optional root connector. Negative means passage air. */ +/** Signed distance of the local landing room. Negative means passage air. */ VOXELFORGE_API float VF_EvaluatePassageLandingSDF( const FVector& Position, const FVoxelPassageLanding& Landing); -/** True when a point belongs to the guaranteed solid floor slab of the landing/connector. */ +/** True when a point belongs to the guaranteed solid floor slab of the landing room. */ VOXELFORGE_API bool VF_IsPassageLandingFloor( const FVector& Position, const FVoxelPassageLanding& Landing); @@ -537,7 +519,7 @@ namespace VoxelNoise // - Determine room existence, position, size // - Run O(N²) nearest-neighbor backbone // - Decide O(N²) tunnel connections with hash lookups -// - Compute tunnel radii, Z offsets, midpoint warping +// - Compute tunnel radii, floor-aligned endpoints, and wandering control chains // // With caching: build once, evaluate 32K times with just SDF math. // This is the single biggest CPU performance win for cave generation. @@ -588,19 +570,41 @@ struct FCachedRoom float ShapeR = 0.0f; }; -// A pre-computed tunnel segment — all connection decisions and hash-derived -// properties (radius, Z offset, midpoint warp) are resolved during cache build. +// A local flat-floor bridge over the overlap of two room bodies. It is not a new graph edge: it +// only gives an existing spherical-room intersection one shared walkable floor level, removing +// the high lip that otherwise appears when the two room floor cuts disagree. +struct FCachedRoomFloorJoin +{ + FVector Start; + FVector End; + float Radius = 0.0f; + float FloorZ = 0.0f; + float CeilingZ = 0.0f; + FVector BoundCenter; + float BoundRadiusSq = 0.0f; +}; + +// A pre-computed wandering tunnel chain — all connection decisions and hash-derived properties +// (radii, Z offsets, control points) are resolved during cache build. struct FCachedTunnel { - FVector EndpointA; // First room's connection point (with Z offset) - FVector EndpointB; // Second room's connection point (with Z offset) + FVector EndpointA; // First room's floor-tangent connection point in SDF coordinates + FVector EndpointB; // Second room's floor-tangent connection point in SDF coordinates float RadiusA; // Tube radius at endpoint A float RadiusB; // Tube radius at endpoint B - // Warped midpoint — only used if bHasMidpoint is true. - // Creates a two-segment curved path instead of a straight tube. + // Compatibility summary of the chain's first/middle/last points. These fields are retained + // for diagnostics and old cache consumers; EvaluateSDFCached uses ControlPoints when present. FVector Midpoint; - float RadiusMid; // Radius at the midpoint (average of A and B) - bool bHasMidpoint; // True when TunnelWarpStrength > 0 and tunnel is long enough + float RadiusMid; + bool bHasMidpoint; + TArray ControlPoints; + TArray ControlRadii; + // World-space copy used by the final structural air backstop. Evaluating this copy avoids + // turning a gentle authored floor ramp into a vertical break when the cave warp is nonlinear. + TArray WorldControlPoints; + TArray WorldControlRadii; + FVector WorldBoundCenter = FVector::ZeroVector; + float WorldBoundRadiusSq = 0.0f; // Bounding sphere for quick per-voxel rejection FVector BoundCenter; // Center of the bounding sphere float BoundRadiusSq; // Squared radius — if voxel is further, skip this tunnel @@ -657,7 +661,8 @@ struct FCachedColumn // for every voxel in the chunk. Typically contains 5-15 rooms and 10-30 tunnels. struct FChunkSDFCache { - TArray Rooms; + TArray Rooms; + TArray RoomFloorJoins; TArray Tunnels; TArray Pits; TArray Chimneys; @@ -681,6 +686,15 @@ namespace VoxelCaveMorphology return VoxelHash::Mix(WorldSeed ^ (uint32)(StrateIndex * 7919 + 104729)); } + // Map a world-space query to the SDF coordinate space used by the room/tunnel cache. Keeping + // this transform in the morphology module lets the generator's post-disturbance structural + // check share the exact same warp as the source and the landing query. + VOXELFORGE_API FVector ApplyCaveWarp( + const FVector& WorldPoint, + const FStrateGenerationParams& Params, + uint32 Seed + ); + // PHASE 1: Build the SDF cache for a rectangular region. // Collects all rooms, computes nearest-neighbor backbone, decides tunnel // connections, and pre-computes all tunnel geometry (radii, offsets, midpoints). @@ -697,7 +711,7 @@ namespace VoxelCaveMorphology // @param Seed — world seed // @param StrateIndex — which strate (offsets hash for variety) // @param TerrainOps — optional probability pool; null = no per-room ops - void BuildChunkCache( + VOXELFORGE_API void BuildChunkCache( FChunkSDFCache& OutCache, float SearchMinX, float SearchMinY, float SearchMaxX, float SearchMaxY, @@ -720,13 +734,37 @@ namespace VoxelCaveMorphology // per-room terrain op assigned to this voxel's room. // (Room shape variety is baked into FCachedRoom by BuildChunkCache — no per-voxel roll.) // @return negative = inside cave, positive = solid rock - float EvaluateSDFCached( + VOXELFORGE_API float EvaluateSDFCached( float WorldX, float WorldY, float WorldZ, const FChunkSDFCache& Cache, float SDFBlendRadius, int32* OutNearestRoomIdx = nullptr ); + // Evaluate the raw union of cached graph-tunnel capsules. This is intentionally separate from + // the SmoothMin morphology: the generator uses it after terrain/passage posts to reassert the + // tunnel's walkable air core without reintroducing a connector or changing room ownership. + VOXELFORGE_API float EvaluateTunnelCoreSDF( + float WorldX, float WorldY, float WorldZ, + const FChunkSDFCache& Cache + ); + + // Evaluate the final structural air contract against the world-space wandering chain. Room + // SDFs remain evaluated in warped coordinates; only this post uses world coordinates so its + // walkable floor follows the authored chain exactly. + VOXELFORGE_API float EvaluateTunnelCoreWorldSDF( + float WorldX, float WorldY, float WorldZ, + const FChunkSDFCache& Cache + ); + + // True for the finite support slab beneath a world-space graph tunnel. The native density + // path and the operator stack use this same predicate before reopening the air core, keeping + // the rounded capsule's bottom from becoming an unsupported point. + VOXELFORGE_API bool IsTunnelSupportFloorWorldPoint( + float WorldX, float WorldY, float WorldZ, + const FChunkSDFCache& Cache + ); + // CONVENIENCE WRAPPER: builds a temporary cache and evaluates in one call. // Use this for one-off queries (debug visualization, single-point sampling). // For chunk generation, use BuildChunkCache + EvaluateSDFCached instead. @@ -748,7 +786,9 @@ namespace VoxelCaveMorphology * clear capsule volume. The local source stencil deliberately does not claim flood-fill * connectivity or a measured component size; it selects a topology-backed feature core instead. * For TunnelNetwork/Underwater, Seed must be the strate-specific room seed returned by - * VoxelCaveMorphology::MakeStrateSeed; for slab, maze, shaft, and island archetypes, Seed is the + * VoxelCaveMorphology::MakeStrateSeed; the optional WorldSeed selects the production cave-warp + * field and should be the generator world seed. If omitted, the room seed is used for both for + * backward-compatible standalone queries. For slab, maze, shaft, and island archetypes, Seed is the * generator world seed consumed by their source. SurfaceWorld deliberately has no answer here: * its production height can be selected by manager-owned biome context and per-chunk state, * which this re-entrant query cannot inspect. @@ -772,7 +812,8 @@ VOXELFORGE_API bool VF_SuggestLandingPoint( float DesiredX, float DesiredY, float MaxLateralSnap, - FVector& OutPoint + FVector& OutPoint, + int32 WorldSeed = MIN_int32 ); /** @@ -797,5 +838,6 @@ VOXELFORGE_API bool VF_SuggestLandingPoint( float DesiredX, float DesiredY, float MaxLateralSnap, - FVector& OutPoint + FVector& OutPoint, + int32 WorldSeed = MIN_int32 ); diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index 57e1e4e..b181cb6 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -250,7 +250,7 @@ public: /** * RÔLE 4 — ajoute les invariants de monde, dans l'ordre fixe, à la fin de la pile. - * spine (0,0) → seal de frontière → carve de passage → seal de limite XY. + * spine (0,0) → seal de frontière → carve de passage → [posts spécifiques] → seal de limite XY. * * ⚠️ La couche de diff (édits joueur) n'est PAS ici : elle vit dans `GetDensityAt`, APRÈS la * négation MC, avec les disturbances. Elle rejoindra la pile quand les disturbances seront @@ -266,7 +266,8 @@ public: */ VOXELFORGE_API void AppendStructuralPost(float StrateTopWorldZ, float StrateBottomWorldZ, float SealThickness, float BaseDensity, float SpineRadius, - const UVoxelStrateManager* StrateManager); + const UVoxelStrateManager* StrateManager, + bool bAppendEdgeSeal = true); private: TArray Ops; @@ -387,10 +388,10 @@ namespace VoxelDensityOps bool bAppendStructuralPosts = true); /** - * TunnelNetwork — **COMPLET, 20 ops** : + * TunnelNetwork — **COMPLET** : * ConstantRock → RoomGraph(warp + pits + cheminées) → SdfCarve → CaveRoughness(4b) * → Terrace → LayerLines → Ribbing → Overhang → Cliff → Scallop → Arch → RoomColumn(4d) - * → Dome(4g) → Pinch(4h) → FloorBias → Worms → [structural ×4] + * → Dome(4g) → Pinch(4h) → FloorBias → Worms → [spine, boundary, passage, graph-air, XY seal] * * L'override d'op PAR SALLE (étape C1) n'ajoute aucun opérateur : `FRoomGraphSource` publie * `LocalParams()` — les params de la strate avec l'op de la salle la plus proche appliqué — et diff --git a/Source/VoxelForge/Public/VoxelDensityPrimitives.h b/Source/VoxelForge/Public/VoxelDensityPrimitives.h index 98ef313..9495a3c 100644 --- a/Source/VoxelForge/Public/VoxelDensityPrimitives.h +++ b/Source/VoxelForge/Public/VoxelDensityPrimitives.h @@ -141,7 +141,10 @@ FORCEINLINE void VF_ApplyOriginSpine(float& Density, float WorldX, float WorldY, && FMath::Abs(WorldX) <= FMath::Max(Geometry.HalfWidth - 1.0f, 0.0f) && FMath::Abs(WorldY) <= FMath::Max(Geometry.HalfWidth - 1.0f, 0.0f)) { - Density = FMath::Max(Density, BaseDensity); + if (!VoxelPassageGeometry::VerticalShaftConnectorAirMarker()) + { + Density = FMath::Max(Density, BaseDensity); + } } } @@ -176,7 +179,10 @@ FORCEINLINE void VF_ApplyOriginLandingFloor(float& Density, && FMath::Abs(WorldX) <= FMath::Max(Geometry.HalfWidth - 1.0f, 0.0f) && FMath::Abs(WorldY) <= FMath::Max(Geometry.HalfWidth - 1.0f, 0.0f)) { - Density = FMath::Max(Density, BaseDensity); + if (!VoxelPassageGeometry::VerticalShaftConnectorAirMarker()) + { + Density = FMath::Max(Density, BaseDensity); + } } } diff --git a/Source/VoxelForge/Public/VoxelPassageGeometry.h b/Source/VoxelForge/Public/VoxelPassageGeometry.h index e77c35f..b052a51 100644 --- a/Source/VoxelForge/Public/VoxelPassageGeometry.h +++ b/Source/VoxelForge/Public/VoxelPassageGeometry.h @@ -24,6 +24,11 @@ namespace VoxelPassageGeometry constexpr float SealSafetyMarginVoxels = 4.0f; constexpr float RoomRoundingVoxels = 1.5f; constexpr float LandingCarveBlendVoxels = 4.0f; + // The origin landing is reserved for the same straight spine that the vertical-shaft + // generator uses: OriginSpineRadius on each side, with a three-voxel / 0.75 m floor ledge + // around it after the support patch's one-voxel inset. At the default radius (14 voxels) this + // is a 28-voxel / 7 m future shaft diameter. + constexpr float OriginShaftFloorClearanceVoxels = 4.0f; // Level tunnel apron outside each room-floor edge. It covers the room/tunnel SDF blend and // keeps the ramp from starting while the route is still over the room's square support patch. constexpr float WalkableTunnelLandingApronVoxels = 6.0f; // 1.5 m @@ -33,18 +38,16 @@ namespace VoxelPassageGeometry // ownership band: the floor writer owns the plane, while the air writer owns the capsule // volume above it even when two overlapping passage projections differ by a fraction. constexpr float WalkableTunnelFloorAirClearanceVoxels = 0.5f; + // The graph-tunnel post owns the interior above its floor after terrain and passage-floor + // writers have run. The support-floor predicate is also an exclusion mask for the air post, + // so overlapping graph tubes cannot erase one another's walkable support band. + constexpr float CaveTunnelAirCoreInsetVoxels = 0.5f; // A 15 degree ramp is comfortably below the 44 degree engine walkability ceiling. The // latter is a scramble limit, not a sensible default for a tunnel the player walks through. constexpr float WalkableTunnelMaxGradientDegrees = 15.0f; constexpr float WalkableTunnelMaxGradient = 0.2679491924311227f; // tan(15 degrees) - // The network connector retains the §6.5 single-file dimensions: 2.5 m clear width and 3 m - // clear height. These values are in voxels because all passage descriptors are voxel-space. - constexpr float ConnectorRadiusVoxels = 5.0f; - constexpr float ConnectorHeightVoxels = 12.0f; - constexpr float RootOverlapVoxels = 2.25f; - // The walkability contract is about the floor carried by the tube, not only its centreline. // A tapered tube changes that floor by the radius delta, so the construction and the audit // use this same conservative floor delta. @@ -53,6 +56,204 @@ namespace VoxelPassageGeometry return Centreline.Z - FMath::Abs(Radius); } + // Vertical-shaft tree links are round air capsules, but the player-fit graph needs a + // deterministic support plane to walk between shafts. Keep that plane on the first + // seal-safe ledge shared by the whole strate, so every tree edge has the same walk level. + // This is shaft-network support, not an origin landing connector. + FORCEINLINE float VerticalShaftConnectorFloorThickness(float Radius) + { + return FMath::Min( + LandingFloorThicknessVoxels, + FMath::Max(0.5f, FMath::Abs(Radius) * 0.5f)); + } + + FORCEINLINE float VerticalShaftConnectorFloorZ( + float StrateBottomZ, + float InteriorBottomZ, + float InteriorTopZ, + float LedgeSpacing, + float LedgeDepth, + float ConnectorRadius) + { + const float FloorThickness = VerticalShaftConnectorFloorThickness(ConnectorRadius); + const float MinimumFloor = InteriorBottomZ + + FloorThickness + SealSafetyMarginVoxels; + const float MaximumFloor = InteriorTopZ + - PlayerHeightVoxels - SealSafetyMarginVoxels; + if (!FMath::IsFinite(StrateBottomZ) + || !FMath::IsFinite(InteriorBottomZ) + || !FMath::IsFinite(InteriorTopZ) + || !FMath::IsFinite(LedgeSpacing) + || !FMath::IsFinite(LedgeDepth) + || !FMath::IsFinite(MinimumFloor) + || !FMath::IsFinite(MaximumFloor) + || !(MaximumFloor > MinimumFloor)) + { + return 0.5f * (InteriorBottomZ + InteriorTopZ); + } + + if (LedgeSpacing > 0.0f && LedgeDepth > 0.0f) + { + const float RelativeBottom = InteriorBottomZ - StrateBottomZ; + const int32 FirstPeriod = FMath::FloorToInt(RelativeBottom / LedgeSpacing); + // The small fixed bound keeps malformed authored values from turning generation into + // an unbounded search. Normal shaft strates find the first or second period. + constexpr int32 MaxPeriods = 64; + for (int32 PeriodOffset = 0; PeriodOffset < MaxPeriods; ++PeriodOffset) + { + const float PeriodStart = static_cast(FirstPeriod + PeriodOffset) + * LedgeSpacing; + const float Candidate = StrateBottomZ + PeriodStart + LedgeDepth; + if (Candidate > MinimumFloor && Candidate < MaximumFloor) + { + return Candidate; + } + } + } + + return FMath::Clamp( + 0.5f * (InteriorBottomZ + InteriorTopZ), MinimumFloor, MaximumFloor); + } + + FORCEINLINE float VerticalShaftConnectorCenterZ( + float StrateBottomZ, + float InteriorBottomZ, + float InteriorTopZ, + float LedgeSpacing, + float LedgeDepth, + float ConnectorRadius) + { + const float SafeRadius = FMath::Abs(ConnectorRadius); + const float FloorThickness = VerticalShaftConnectorFloorThickness(SafeRadius); + return VerticalShaftConnectorFloorZ( + StrateBottomZ, InteriorBottomZ, InteriorTopZ, + LedgeSpacing, LedgeDepth, SafeRadius) + + SafeRadius - FloorThickness; + } + + /** Add a flat, inset support band below one horizontal shaft-tree capsule. */ + FORCEINLINE void VF_ApplyVerticalShaftConnectorFloor( + float& Density, + float WorldX, + float WorldY, + float WorldZ, + const FVector& Start, + const FVector& End, + float Radius, + float FloorZ, + float BaseDensity) + { + const float SafeRadius = FMath::Abs(Radius); + const float FloorThickness = VerticalShaftConnectorFloorThickness(SafeRadius); + const float FloorRadius = FMath::Max(0.5f, SafeRadius - 0.5f); + if (!(SafeRadius > 0.0f) + || !(BaseDensity > 0.0f) + || !FMath::IsFinite(WorldX) + || !FMath::IsFinite(WorldY) + || !FMath::IsFinite(WorldZ) + || !FMath::IsFinite(FloorZ) + || !FMath::IsFinite(Start.X) || !FMath::IsFinite(Start.Y) + || !FMath::IsFinite(End.X) || !FMath::IsFinite(End.Y)) + { + return; + } + + const FVector2D A(Start.X, Start.Y); + const FVector2D Delta(End.X - Start.X, End.Y - Start.Y); + const float LengthSquared = Delta.SizeSquared(); + const float T = LengthSquared > KINDA_SMALL_NUMBER + ? FMath::Clamp( + FVector2D::DotProduct(FVector2D(WorldX, WorldY) - A, Delta) + / LengthSquared, + 0.0f, 1.0f) + : 0.0f; + const FVector2D Closest = A + Delta * T; + if ((FVector2D(WorldX, WorldY) - Closest).SizeSquared() + > FMath::Square(FloorRadius)) + { + return; + } + + if (WorldZ <= FloorZ + KINDA_SMALL_NUMBER + && WorldZ > FloorZ - FloorThickness) + { + Density = FMath::Max(Density, BaseDensity); + } + } + + // ApplyVerticalShaftConnectorAir runs before the generator's shared disturbance pass. Keep + // this one-bit, call-local hand-off so a bridge/ridge cannot refill the core it just opened. + // It is thread-local state, never a per-voxel cache, and is reset around every density query. + FORCEINLINE bool& VerticalShaftConnectorAirMarker() + { + static thread_local bool bMarked = false; + return bMarked; + } + + FORCEINLINE void ResetVerticalShaftConnectorAirMarker() + { + VerticalShaftConnectorAirMarker() = false; + } + + /** Reassert the walkable air core above one horizontal shaft-tree floor band. */ + FORCEINLINE void VF_ApplyVerticalShaftConnectorAir( + float& Density, + float WorldX, + float WorldY, + float WorldZ, + const FVector& Start, + const FVector& End, + float Radius, + float FloorZ, + float BaseDensity) + { + const float SafeRadius = FMath::Abs(Radius); + // Leave a one-voxel wall band for the roughness/solid shell, while retaining more than + // enough width for the 1.36-voxel player capsule and its support stencil. + const float CoreRadius = FMath::Max(0.5f, SafeRadius - 1.0f); + if (!(SafeRadius > 0.0f) + || !(BaseDensity > 0.0f) + || !FMath::IsFinite(WorldX) + || !FMath::IsFinite(WorldY) + || !FMath::IsFinite(WorldZ) + || !FMath::IsFinite(FloorZ) + || !FMath::IsFinite(Start.X) || !FMath::IsFinite(Start.Y) + || !FMath::IsFinite(Start.Z) + || !FMath::IsFinite(End.X) || !FMath::IsFinite(End.Y) + || !FMath::IsFinite(End.Z)) + { + return; + } + + // The exact floor sample belongs to the air core's lower ownership edge. The floor + // writer only owns the three voxels below FloorZ, so clearing from the half-voxel band + // above it cannot remove support. + if (WorldZ + KINDA_SMALL_NUMBER + < FloorZ + WalkableTunnelFloorAirClearanceVoxels) + { + return; + } + + const FVector Delta = End - Start; + const float LengthSquared = Delta.SizeSquared(); + const float T = LengthSquared > KINDA_SMALL_NUMBER + ? FMath::Clamp(FVector::DotProduct(FVector(WorldX, WorldY, WorldZ) - Start, Delta) + / LengthSquared, 0.0f, 1.0f) + : 0.0f; + const FVector Closest = Start + Delta * T; + if ((FVector(WorldX, WorldY, WorldZ) - Closest).SizeSquared() + > FMath::Square(CoreRadius)) + { + return; + } + + // Internal convention: negative is air, positive is solid. This is intentionally a + // post-ledge write; a partial shaft shelf must not cap the tree route's player-height + // envelope after the capsule has already carved it. + VerticalShaftConnectorAirMarker() = true; + Density = FMath::Min(Density, -BaseDensity); + } + FORCEINLINE float TunnelFloorGradient( const FVector& Start, float StartRadius, const FVector& End, float EndRadius) @@ -194,7 +395,14 @@ namespace VoxelPassageGeometry return Result; } - Result.HalfWidth = LandingHalfWidthForRadius(OriginRadius); + // Keep a solid walking ledge around the future shaft, not merely a room whose wall is + // tangent to it. The shaft diameter is 2 * OriginRadius; the extra four voxels leave a + // three-voxel / 0.75 m floor margin after the support patch's one-voxel inset at the + // authored 25 cm scale. + Result.HalfWidth = FMath::Max( + LandingHalfWidthForRadius(OriginRadius), + FMath::Max(FMath::Abs(OriginRadius), 1.0f) + + OriginShaftFloorClearanceVoxels); const float DesiredHeight = LandingHeightForRadius(OriginRadius); const float SafeSeal = FMath::Max(SealThickness, 0.0f); const float InnerBottom = StrateBottomZ + SafeSeal; diff --git a/Source/VoxelForge/Public/VoxelSettings.h b/Source/VoxelForge/Public/VoxelSettings.h index 33e36f0..1563828 100644 --- a/Source/VoxelForge/Public/VoxelSettings.h +++ b/Source/VoxelForge/Public/VoxelSettings.h @@ -393,9 +393,10 @@ public: //========================================================================= // Authored radius (voxels) of the guaranteed (0,0) landing room in EVERY strate, regardless - // of archetype. The §6.5 body-sized formulas turn it into a flat-floored room; this setting - // no longer bores a vertical column. 0 = disabled. The room is top-anchored inside each - // strate, and the seals remain solid until a progression passage is opened. + // of archetype. The §6.5 body-sized formulas turn it into a flat-floored room reserved for a + // future straight shaft of diameter 2 * this radius. At the default 14-voxel radius that is + // 28 voxels / 7 m, with a three-voxel / 0.75 m floor ledge around it. This setting does not bore the + // shaft today: lower seals remain solid until a progression passage is opened. // 10-14 → compact landing room (default) · 20+ → broad landing room UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Spine", meta = (ClampMin = "0.0")) float OriginSpineRadius = 14.0f; diff --git a/Source/VoxelForge/Public/VoxelStrateMeasure.h b/Source/VoxelForge/Public/VoxelStrateMeasure.h index 5bd04b3..30aa539 100644 --- a/Source/VoxelForge/Public/VoxelStrateMeasure.h +++ b/Source/VoxelForge/Public/VoxelStrateMeasure.h @@ -35,7 +35,12 @@ struct VOXELFORGE_API FVoxelPlayerCapsuleConstants static constexpr float HeightVoxels = VoxelPassageGeometry::PlayerHeightVoxels; }; -/** Read-only density source used by the offline Tier 2 pass for a materialised custom stack. */ +/** + * Read-only density source used by the offline Tier 2 pass for a materialised custom stack. + * + * SampleDensity is called concurrently during grid sampling; implementations must be const, + * re-entrant, and safe for concurrent read-only calls, just like UVoxelGenerator::GetDensityAt. + */ class VOXELFORGE_API IVoxelStrateDensitySampler { public: @@ -70,7 +75,7 @@ struct VOXELFORGE_API FVoxelStrateMeasureSettings TOptional CoverPointB; float CoverMarginVoxels = 48.0f; // Origin-rooted topologies can route from one mouth to the other through the finite (0,0) - // landing room and its explicitly carved connector network. + // landing room and its local room/join geometry. There is no hidden radial connector network. // When true, include (0,0) in the fitted AABB before applying MaxCells. A caller must keep // this enabled for that topology; an over-cap route window is then refused honestly instead // of being cropped back to a mouth-only box. diff --git a/Source/VoxelForge/Public/VoxelStrateTypes.h b/Source/VoxelForge/Public/VoxelStrateTypes.h index c56e61e..026dda5 100644 --- a/Source/VoxelForge/Public/VoxelStrateTypes.h +++ b/Source/VoxelForge/Public/VoxelStrateTypes.h @@ -620,8 +620,8 @@ struct VOXELFORGE_API FStrateGenerationParams // How much tunnel paths curve (in voxels of sideways displacement). // Without this, tunnels are straight lines between rooms. - // This adds a hash-derived midpoint offset to each tunnel, creating - // unique curves — some bend left, some right, some are nearly straight. + // This adds deterministic hash-jittered control points along each tunnel, creating + // unique wandering chains — some bend left, some right, some are nearly straight. // Capped at 25% of tunnel length to prevent kinky short tunnels. // 0 → perfectly straight tunnels (artificial look) // 8-12→ gentle natural curves @@ -642,21 +642,20 @@ struct VOXELFORGE_API FStrateGenerationParams // Topology of the guaranteed tunnel network. // true → each room links to its best candidate among rooms CLOSER to (0,0): the whole // network becomes a tree rooted at the origin room — every cave is reachable - // from the spine hub, tunnels flow inward like tributaries (intentional descent - // structure). TunnelDensity still adds loops on top. + // from the graph root, tunnels flow inward like tributaries (intentional descent + // structure). This graph root is not a radial landing-to-hub road. TunnelDensity + // still adds loops on top. // false → legacy nearest-neighbor pairing: organic scattered clusters, but connectivity // between clusters is NOT guaranteed (isolated pockets are common). UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels") bool bTunnelsFlowTowardOrigin = true; - // How much tunnel endpoints shift up/down within rooms (0-1). - // Fraction of the room's vertical radius. Each tunnel endpoint gets - // a hash-derived Z offset, so tunnels enter rooms at different heights. - // Combined with terrain ops (cliffs, terraces), this creates multi-level rooms - // where one tunnel exits at the upper ledge and another at the lower floor. - // 0.0 → all tunnels connect at room center height - // 0.25 → endpoints use a restrained quarter-height offset (default) - // 1.0 → full range — tunnels can enter near floor or ceiling + // Vertical wander amount for tunnel control points (0-1). Endpoints are always tangent to + // their room's deterministic floor so a room join cannot be vertically severed; this value + // controls only the interior chain's bounded up/down variation. + // 0.0 → level floor-to-floor chain + // 0.25 → restrained vertical wander (default) + // 1.0 → strongest interior vertical variation UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels", meta = (ClampMin = "0.0", ClampMax = "1.0")) float TunnelEndpointZOffset = 0.25f; @@ -1787,15 +1786,15 @@ struct VOXELFORGE_API FStratePassageConfig // ----- WIDTH (tapers along the length) ----- // Radius at the two mouths (entry/exit) and at the middle. Equal = uniform tube; - // The walkable default is deliberately single-file: 3.0 m diameter at mouths and 2.5 m at - // the waist (6/5 voxel radii at 25 cm per voxel). The §6.5 landing room remains wider for - // turning. + // The walkable default is 1.5x the original comfortable profile: 4.5 m diameter at mouths + // and 3.75 m at the waist (9/7.5 voxel radii at 25 cm per voxel). The §6.5 landing room + // remains wider for turning. // Mouth > Mid = chambers at the ends with a squeeze between; Mid > Mouth = a bulge. UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Width", meta = (ClampMin = "1.0")) - float MouthRadius = 6.0f; + float MouthRadius = 9.0f; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Width", meta = (ClampMin = "1.0")) - float MidRadius = 5.0f; + float MidRadius = 7.5f; // ----- LENGTH ----- // How far the tunnel reaches INTO each strate (voxels). Auto-capped to the interior; diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp index e1797e4..5199f2e 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -22,6 +22,7 @@ #include "VoxelDiffLayer.h" #include "VoxelGenerator.h" #include "VoxelMarchingCubesMesher.h" +#include "VoxelNoise.h" #include "VoxelSettings.h" #include "VoxelStrateDefinition.h" #include "VoxelStrateManager.h" @@ -32,21 +33,21 @@ namespace { using FExploreJsonWriter = TJsonWriter>; -constexpr int32 DefaultRenderWidth = 192; -constexpr int32 DefaultRenderHeight = 128; +constexpr int32 DefaultRenderWidth = 512; +constexpr int32 DefaultRenderHeight = 288; constexpr float DefaultRenderStepVoxels = 0.25f; -constexpr float DefaultRenderMaxDistanceVoxels = 256.0f; +constexpr float DefaultRenderMaxDistanceVoxels = 160.0f; constexpr int32 DefaultExportSize = 128; constexpr int32 DefaultMaxWalkCells = 32000000; constexpr int32 MaxSyntheticStrates = 64; constexpr int32 MaxRenderPixels = 1048576; -constexpr int64 MaxRenderDensitySamples = 200000000ll; +constexpr int64 MaxRenderDensitySamples = 400000000ll; constexpr int32 MaxExportSize = 128; constexpr int64 MaxWalkWorkingBytes = 768ll * 1024ll * 1024ll; -// The exact fine-grid walk retains Air + Density + player-fit bits, and can transiently hold -// Parent + Queue + Path or Visited + NarrowCell + DFS stacks. 20 bytes/cell is a conservative -// preflight figure for those arrays, including allocator slack/headroom. -constexpr int64 EstimatedWalkBytesPerCell = 20ll; +// The exact fine-grid walk retains Air + Density + player-fit bits + non-finite flags, and can +// transiently hold Parent + Queue + Path or Visited + NarrowCell + DFS stacks. 21 bytes/cell is a +// conservative preflight figure for those arrays, including allocator slack/headroom. +constexpr int64 EstimatedWalkBytesPerCell = 21ll; constexpr int64 MaxExportWorkingBytes = 1024ll * 1024ll * 1024ll; const TCHAR* ArchetypeName(ECaveGeneratorType Archetype) @@ -475,6 +476,8 @@ struct FExploreRenderFrame { FString FileName; FString Purpose = TEXT("overview"); + FString CameraSeedSource = TEXT("player_fit"); + FVector CameraSeedPoseVoxels = FVector::ZeroVector; FVector CameraVoxels = FVector::ZeroVector; FVector TargetVoxels = FVector::ZeroVector; int32 HitPixels = 0; @@ -494,6 +497,9 @@ struct FExploreRenderOutput float MaxDistanceVoxels = 0.0f; int64 EstimatedFixedDensitySamples = 0; FString StepRationale; + FString CameraSeedPolicy; + int32 CameraSeedCount = 0; + bool bAllCamerasPlayerFit = false; TArray Frames; }; @@ -888,6 +894,7 @@ bool SavePng( bool RunRender( const FExploreArguments& Arguments, const FExploreWorld& World, + const FExploreWalkOutput& CameraSeedWalk, FExploreRenderOutput& OutOutput) { OutOutput = FExploreRenderOutput(); @@ -910,17 +917,120 @@ bool RunRender( "fixed 0.25-voxel default step (override with -renderstep), detects density sign crossings, " "and refines each crossing with 10 bisection iterations; it never sphere-traces."); - const float MiddleZ = 0.5f * static_cast(World.TargetBottomWorldZ + World.TargetTopWorldZ); - const FVector Target(0.0f, 0.0f, MiddleZ); - const FVector CameraPositions[] = { - FVector(0.0f, -192.0f, MiddleZ + 32.0f), - FVector(192.0f, -160.0f, MiddleZ + 64.0f), - FVector(-160.0f, 128.0f, MiddleZ + 96.0f), + OutOutput.CameraSeedPolicy = TEXT( + "Every camera origin is a density-checked pose from the exact player-fit walk mask: " + "arrival, departure, last reached, and agent-final poses are considered in fixed order; " + "duplicate poses are removed. The target is offset toward the fitted route so the view is " + "from inside the walkable space."); + + TArray, TInlineAllocator<4>> CameraSeeds; + const auto AddCameraSeed = [&](bool bAvailable, const FVector& Position, const TCHAR* Source) + { + if (!bAvailable + || !FMath::IsFinite(Position.X) + || !FMath::IsFinite(Position.Y) + || !FMath::IsFinite(Position.Z)) + { + return; + } + + // The walk mask is authoritative, but this guard catches stale/corrupt report data before + // a render can silently start inside rock. + const float Density = World.Generator->GetDensityAt(Position.X, Position.Y, Position.Z); + if (!FMath::IsFinite(Density) || !(Density > 0.0f)) + { + return; + } + for (const TPair& Existing : CameraSeeds) + { + if (Existing.Key.Equals(Position, 0.001f)) + { + return; + } + } + CameraSeeds.Emplace(Position, FString(Source)); }; - for (int32 ViewIndex = 0; ViewIndex < UE_ARRAY_COUNT(CameraPositions); ++ViewIndex) + AddCameraSeed( + CameraSeedWalk.bHasArrival, + CameraSeedWalk.ArrivalVoxels, + TEXT("arrival_player_fit")); + AddCameraSeed( + CameraSeedWalk.bHasDeparture, + CameraSeedWalk.DepartureVoxels, + TEXT("departure_player_fit")); + AddCameraSeed( + CameraSeedWalk.Report.bHasLastReachedPosition, + CameraSeedWalk.Report.LastReachedVoxels, + TEXT("last_reached_player_fit")); + AddCameraSeed( + CameraSeedWalk.Report.bHasAgentFinalPosition, + CameraSeedWalk.Report.AgentFinalVoxels, + TEXT("agent_final_player_fit")); + + if (CameraSeeds.Num() == 0) { - const FVector CameraPosition = CameraPositions[ViewIndex]; + OutOutput.Status = TEXT("refused"); + OutOutput.RefusalReason = TEXT( + "The player-fit seed pass produced no density-positive pose; refusing an inside-rock camera."); + return false; + } + + OutOutput.CameraSeedCount = CameraSeeds.Num(); + OutOutput.bAllCamerasPlayerFit = true; + FVector RouteFocus = FVector::ZeroVector; + int32 RouteFocusCount = 0; + if (CameraSeedWalk.bHasArrival) + { + RouteFocus += CameraSeedWalk.ArrivalVoxels; + ++RouteFocusCount; + } + if (CameraSeedWalk.bHasDeparture) + { + RouteFocus += CameraSeedWalk.DepartureVoxels; + ++RouteFocusCount; + } + if (RouteFocusCount > 0) + { + RouteFocus /= static_cast(RouteFocusCount); + } + else + { + RouteFocus = CameraSeeds[0].Key; + } + + const FVector KeyLightDirection = FVector(-0.35f, -0.45f, 0.82f).GetSafeNormal(); + const FVector FillLightDirection = FVector(0.65f, 0.30f, 0.70f).GetSafeNormal(); + constexpr float AmbientFill = 0.62f; + constexpr float KeyLightStrength = 0.50f; + constexpr float FillLightStrength = 0.24f; + + for (int32 ViewIndex = 0; ViewIndex < CameraSeeds.Num(); ++ViewIndex) + { + const FVector CameraSeedPose = CameraSeeds[ViewIndex].Key; + const FVector CameraPosition = CameraSeedPose + + FVector::UpVector * (FVoxelPlayerCapsuleConstants::HalfHeightVoxels - 0.5f); + const float CameraDensity = World.Generator->GetDensityAt( + CameraPosition.X, CameraPosition.Y, CameraPosition.Z); + if (!FMath::IsFinite(CameraDensity) || !(CameraDensity > 0.0f)) + { + OutOutput.Status = TEXT("refused"); + OutOutput.RefusalReason = TEXT( + "The eye-height offset of a player-fit camera entered solid density."); + return false; + } + FVector LookDirection = RouteFocus - CameraPosition; + LookDirection.Z = 0.0f; + LookDirection.Normalize(); + if (LookDirection.IsNearlyZero()) + { + LookDirection = FVector( + ViewIndex == 1 ? -0.6f : 0.8f, + ViewIndex == 2 ? 0.7f : -0.4f, + 0.18f).GetSafeNormal(); + } + // Keep the camera at the fitted pose, while aiming at roughly eye level in the route. + const FVector Target = CameraPosition + LookDirection * 96.0f + FVector::UpVector * 8.0f; FExploreCameraBasis Basis; if (!BuildCameraBasis( CameraPosition, @@ -936,9 +1046,8 @@ bool RunRender( } TArray Pixels; - Pixels.Init(FColor(7, 11, 18, 255), Arguments.RenderWidth * Arguments.RenderHeight); + Pixels.Init(FColor(12, 18, 28, 255), Arguments.RenderWidth * Arguments.RenderHeight); int32 HitPixels = 0; - const FVector LightDirection = FVector(-0.35f, -0.45f, 0.82f).GetSafeNormal(); for (int32 PixelY = 0; PixelY < Arguments.RenderHeight; ++PixelY) { for (int32 PixelX = 0; PixelX < Arguments.RenderWidth; ++PixelX) @@ -965,11 +1074,16 @@ bool RunRender( } ++HitPixels; - const float Diffuse = FMath::Max(0.0f, FVector::DotProduct(Normal, LightDirection)); - const float Brightness = 0.18f + 0.82f * Diffuse; - const uint8 RockR = static_cast(FMath::Clamp(54.0f * Brightness, 0.0f, 255.0f)); - const uint8 RockG = static_cast(FMath::Clamp(72.0f * Brightness, 0.0f, 255.0f)); - const uint8 RockB = static_cast(FMath::Clamp(92.0f * Brightness, 0.0f, 255.0f)); + const float Key = FMath::Max( + 0.0f, FVector::DotProduct(Normal, KeyLightDirection)); + const float Fill = FMath::Max( + 0.0f, FVector::DotProduct(Normal, FillLightDirection)); + const float Brightness = FMath::Clamp( + AmbientFill + KeyLightStrength * Key + FillLightStrength * Fill, + 0.0f, 1.25f); + const uint8 RockR = static_cast(FMath::Clamp(100.0f * Brightness, 0.0f, 255.0f)); + const uint8 RockG = static_cast(FMath::Clamp(128.0f * Brightness, 0.0f, 255.0f)); + const uint8 RockB = static_cast(FMath::Clamp(160.0f * Brightness, 0.0f, 255.0f)); PutPixel( Pixels, Arguments.RenderWidth, @@ -992,6 +1106,9 @@ bool RunRender( FExploreRenderFrame& Frame = OutOutput.Frames.AddDefaulted_GetRef(); Frame.FileName = FString::Printf(TEXT("render_%02d.png"), ViewIndex); + Frame.Purpose = CameraSeeds[ViewIndex].Value; + Frame.CameraSeedSource = CameraSeeds[ViewIndex].Value; + Frame.CameraSeedPoseVoxels = CameraSeedPose; Frame.CameraVoxels = CameraPosition; Frame.TargetVoxels = Target; Frame.HitPixels = HitPixels; @@ -1086,17 +1203,12 @@ bool RunFailureBoundaryRender( } const float SegmentLength = Segment.Size(); const float ViewSideOffset = FMath::Min(64.0f, FMath::Max(16.0f, 0.25f * SegmentLength)); - const FVector CameraCandidates[][3] = { - { - AgentFinal - SegmentDirection * 4.0f, - bHaveLastReached ? LastReached : AgentFinal, - AgentFinal, - }, - { - TargetComponentCell + SegmentDirection * 4.0f, - TargetComponentCell, - TargetComponentCell, - }, + // Failure-focus views are still required to originate from the fitted graph. The old + // implementation nudged the camera four voxels toward/away from the boundary and could put + // the viewpoint in the very rock that this diagnostic is meant to explain. + const FVector CameraCandidates[] = { + AgentFinal, + bHaveLastReached ? LastReached : AgentFinal, }; const FVector ViewTargets[] = { Focus + ViewSide * ViewSideOffset, @@ -1105,16 +1217,17 @@ bool RunFailureBoundaryRender( for (int32 ViewIndex = 0; ViewIndex < UE_ARRAY_COUNT(ViewTargets); ++ViewIndex) { - FVector CameraPosition = CameraCandidates[ViewIndex][2]; - for (int32 CandidateIndex = 0; CandidateIndex < 3; ++CandidateIndex) + const FVector CameraSeedPose = CameraCandidates[ViewIndex]; + const FVector CameraPosition = CameraSeedPose + + FVector::UpVector * (FVoxelPlayerCapsuleConstants::HalfHeightVoxels - 0.5f); + const float CameraDensity = World.Generator->GetDensityAt( + CameraPosition.X, CameraPosition.Y, CameraPosition.Z); + if (!FMath::IsFinite(CameraDensity) || !(CameraDensity > 0.0f)) { - const FVector Candidate = CameraCandidates[ViewIndex][CandidateIndex]; - const float Density = World.Generator->GetDensityAt(Candidate.X, Candidate.Y, Candidate.Z); - if (FMath::IsFinite(Density) && Density > 0.0f) - { - CameraPosition = Candidate; - break; - } + UE_LOG(LogTemp, Warning, + TEXT("[VoxelForgeExplore] refusing failure-focus camera %d: fitted pose is not air."), + ViewIndex); + return false; } const float FailureMaxDistanceVoxels = FMath::Max( Arguments.RenderMaxDistanceVoxels, @@ -1134,9 +1247,13 @@ bool RunFailureBoundaryRender( } TArray Pixels; - Pixels.Init(FColor(7, 11, 18, 255), Arguments.RenderWidth * Arguments.RenderHeight); + Pixels.Init(FColor(12, 18, 28, 255), Arguments.RenderWidth * Arguments.RenderHeight); int32 HitPixels = 0; - const FVector LightDirection = FVector(-0.35f, -0.45f, 0.82f).GetSafeNormal(); + const FVector KeyLightDirection = FVector(-0.35f, -0.45f, 0.82f).GetSafeNormal(); + const FVector FillLightDirection = FVector(0.65f, 0.30f, 0.70f).GetSafeNormal(); + constexpr float AmbientFill = 0.62f; + constexpr float KeyLightStrength = 0.50f; + constexpr float FillLightStrength = 0.24f; for (int32 PixelY = 0; PixelY < Arguments.RenderHeight; ++PixelY) { for (int32 PixelX = 0; PixelX < Arguments.RenderWidth; ++PixelX) @@ -1163,11 +1280,16 @@ bool RunFailureBoundaryRender( } ++HitPixels; - const float Diffuse = FMath::Max(0.0f, FVector::DotProduct(Normal, LightDirection)); - const float Brightness = 0.18f + 0.82f * Diffuse; - const uint8 RockR = static_cast(FMath::Clamp(54.0f * Brightness, 0.0f, 255.0f)); - const uint8 RockG = static_cast(FMath::Clamp(72.0f * Brightness, 0.0f, 255.0f)); - const uint8 RockB = static_cast(FMath::Clamp(92.0f * Brightness, 0.0f, 255.0f)); + const float Key = FMath::Max( + 0.0f, FVector::DotProduct(Normal, KeyLightDirection)); + const float Fill = FMath::Max( + 0.0f, FVector::DotProduct(Normal, FillLightDirection)); + const float Brightness = FMath::Clamp( + AmbientFill + KeyLightStrength * Key + FillLightStrength * Fill, + 0.0f, 1.25f); + const uint8 RockR = static_cast(FMath::Clamp(100.0f * Brightness, 0.0f, 255.0f)); + const uint8 RockG = static_cast(FMath::Clamp(128.0f * Brightness, 0.0f, 255.0f)); + const uint8 RockB = static_cast(FMath::Clamp(160.0f * Brightness, 0.0f, 255.0f)); PutPixel( Pixels, Arguments.RenderWidth, @@ -1243,6 +1365,10 @@ bool RunFailureBoundaryRender( FExploreRenderFrame& Frame = InOutOutput.Frames.AddDefaulted_GetRef(); Frame.FileName = FString::Printf(TEXT("failure_boundary_%02d.png"), ViewIndex); Frame.Purpose = TEXT("agent_final_red_target_component_nearest_green_last_reached_orange"); + Frame.CameraSeedSource = bHaveLastReached && ViewIndex == 1 + ? TEXT("last_reached_player_fit") + : TEXT("agent_final_player_fit"); + Frame.CameraSeedPoseVoxels = CameraSeedPose; Frame.CameraVoxels = CameraPosition; Frame.TargetVoxels = ViewTargets[ViewIndex]; Frame.HitPixels = HitPixels; @@ -1273,8 +1399,8 @@ bool RunWalk( { OutOutput = FExploreWalkOutput(); OutOutput.WindowPolicy = NeedsOriginInWindow(Arguments.Archetype) - ? TEXT("origin-inclusive mouth-AABB + margin") - : TEXT("mouth-AABB + margin"); + ? TEXT("origin-inclusive target-strate passage-envelope AABB + margin") + : TEXT("target-strate passage-envelope AABB + margin"); const int64 EstimatedWorkingBytes = static_cast(Arguments.MaxWalkCells) * EstimatedWalkBytesPerCell; @@ -1308,6 +1434,57 @@ bool RunWalk( OutOutput.bHasArrival = true; OutOutput.bHasDeparture = true; + // Temporary seed-14 probe while diagnosing the fitted VerticalShafts junction. This is + // intentionally narrow and will be removed after the floor/tree seam is corrected. + if (Arguments.Archetype == ECaveGeneratorType::VerticalShafts && Arguments.Seed == 14) + { + const FVector ProbeA = OutOutput.ArrivalVoxels; + const FVector ProbeB(-100.8f, -26.9f, ProbeA.Z); + for (int32 Index = 0; Index <= 10; ++Index) + { + const float T = static_cast(Index) / 10.0f; + const FVector P = FMath::Lerp(ProbeA, ProbeB, T); + const float Dm704 = World.Generator->GetDensityAt(P.X, P.Y, -704.0f); + const float Dm702 = World.Generator->GetDensityAt(P.X, P.Y, -702.0f); + const float Dm7005 = World.Generator->GetDensityAt(P.X, P.Y, -700.5f); + const float Dm700 = World.Generator->GetDensityAt(P.X, P.Y, -700.0f); + const float Dm6995 = World.Generator->GetDensityAt(P.X, P.Y, -699.5f); + const float Dm697 = World.Generator->GetDensityAt(P.X, P.Y, -697.0f); + float MinBody = FLT_MAX; + int32 NumSolidBody = 0; + int32 SolidOffsetX = 0; + int32 SolidOffsetY = 0; + int32 SolidBodyRow = -1; + for (int32 OffsetY = -1; OffsetY <= 1; ++OffsetY) + { + for (int32 OffsetX = -1; OffsetX <= 1; ++OffsetX) + { + for (int32 BodyRow = 0; BodyRow < 8; ++BodyRow) + { + const float BodyDensity = World.Generator->GetDensityAt( + P.X + static_cast(OffsetX), + P.Y + static_cast(OffsetY), + -699.5f + static_cast(BodyRow)); + if (BodyDensity < MinBody) + { + MinBody = BodyDensity; + SolidOffsetX = OffsetX; + SolidOffsetY = OffsetY; + SolidBodyRow = BodyRow; + } + NumSolidBody += BodyDensity <= 0.0f ? 1 : 0; + } + } + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeExplore][TemporaryShaftProbe] t=%.2f xy=(%.2f,%.2f) " + "d[-704,-702,-700.5,-700,-699.5,-697]=[%.3f,%.3f,%.3f,%.3f,%.3f,%.3f] " + "bodyMin=%.3f bodySolid=%d/72 firstMinOffset=(%d,%d) row=%d"), + T, P.X, P.Y, Dm704, Dm702, Dm7005, Dm700, Dm6995, Dm697, + MinBody, NumSolidBody, SolidOffsetX, SolidOffsetY, SolidBodyRow); + } + } + FVoxelStrateMeasureSettings Settings; Settings.SampleStep = 1; Settings.MaxCells = Arguments.MaxWalkCells; @@ -1316,9 +1493,86 @@ bool RunWalk( Settings.CoverPointB = FVector2D(OutOutput.DepartureVoxels.X, OutOutput.DepartureVoxels.Y); Settings.CoverMarginVoxels = FMath::CeilToFloat( Settings.PlayerCapsuleRadiusVoxels) + 2.0f; + + // A mouth-AABB is not a landing window: the landing door and its local apron can legitimately + // leave that box before the player reaches the next room. Expand the same two-point box just + // enough to contain the selected production mouth envelope. This is still a bounded, + // deterministic measurement window; the central inter-strate descent belongs to the adjacent + // strata and is not folded into this intra-strate walk question. + const float WindowMinX = FMath::Min(OutOutput.ArrivalVoxels.X, OutOutput.DepartureVoxels.X); + const float WindowMaxX = FMath::Max(OutOutput.ArrivalVoxels.X, OutOutput.DepartureVoxels.X); + const float WindowMinY = FMath::Min(OutOutput.ArrivalVoxels.Y, OutOutput.DepartureVoxels.Y); + const float WindowMaxY = FMath::Max(OutOutput.ArrivalVoxels.Y, OutOutput.DepartureVoxels.Y); + const auto IncludeTargetPassageEnvelope = [&]( + const FVoxelPassage* Passage, + const FVoxelPassageLanding& Landing) + { + if (Passage == nullptr) + { + return; + } + + const auto IncludeXY = [&](const FVector& Point, float Radius) + { + if (!FMath::IsFinite(Point.X) || !FMath::IsFinite(Point.Y) + || !FMath::IsFinite(Point.Z) || !FMath::IsFinite(Radius)) + { + return; + } + const float AbsRadius = FMath::Abs(Radius); + // Include control points whose centreline belongs to the measured strate. A control + // point just outside the boundary belongs to the adjacent strate's descent; pulling + // that whole switchback into this bounded walk box can multiply the sampled volume + // without improving the intra-strate mouth-to-mouth question. + if (Point.Z < static_cast(World.TargetBottomWorldZ) + || Point.Z > static_cast(World.TargetTopWorldZ)) + { + return; + } + const float ExcessX = FMath::Max( + 0.0f, + FMath::Max( + WindowMinX - (Point.X + AbsRadius), + (Point.X - AbsRadius) - WindowMaxX)); + const float ExcessY = FMath::Max( + 0.0f, + FMath::Max( + WindowMinY - (Point.Y + AbsRadius), + (Point.Y - AbsRadius) - WindowMaxY)); + Settings.CoverMarginVoxels = FMath::Max( + Settings.CoverMarginVoxels, + FMath::Max(ExcessX, ExcessY)); + }; + + IncludeXY( + Landing.StandingPoint, + Landing.HalfWidth + VoxelPassageGeometry::LandingCarveBlendVoxels); + // The first/last three points are the production mouth, apron, and first slope section. + // Do not pull the whole inter-strate switchback into the bounded target-strate test. + const int32 NumLocalPoints = FMath::Min(3, Passage->ControlPoints.Num()); + const bool bUseUpperEnd = &Landing == &Passage->UpperLanding; + const int32 FirstPoint = bUseUpperEnd + ? 0 : FMath::Max(0, Passage->ControlPoints.Num() - NumLocalPoints); + const int32 LastPoint = bUseUpperEnd + ? NumLocalPoints : Passage->ControlPoints.Num(); + for (int32 PointIndex = FirstPoint; PointIndex < LastPoint; ++PointIndex) + { + const float Radius = Passage->ControlRadii.IsValidIndex(PointIndex) + ? Passage->ControlRadii[PointIndex] : Passage->Radius; + IncludeXY(Passage->ControlPoints[PointIndex], Radius + + VoxelPassageGeometry::LandingCarveBlendVoxels); + } + }; + IncludeTargetPassageEnvelope( + ArrivalPassage, + ArrivalPassage != nullptr ? ArrivalPassage->LowerLanding : FVoxelPassageLanding()); + IncludeTargetPassageEnvelope( + DeparturePassage, + DeparturePassage != nullptr ? DeparturePassage->UpperLanding : FVoxelPassageLanding()); Settings.bIncludeOriginInCoverWindow = NeedsOriginInWindow(Arguments.Archetype); TArray LandingRoomProbePoints; + auto AppendLandingRoomProbes = [&LandingRoomProbePoints]( const FVoxelPassageLanding& Landing) { @@ -1386,9 +1640,12 @@ bool RunWalk( } const int32 ArrivalProbeCount = ArrivalPassage != nullptr ? 5 : 0; const int32 DepartureProbeCount = DeparturePassage != nullptr ? 5 : 0; + const int32 LandingProbeCount = ArrivalProbeCount + DepartureProbeCount; OutOutput.ArrivalLandingRoomProbeCount = ArrivalProbeCount; OutOutput.DepartureLandingRoomProbeCount = DepartureProbeCount; - for (int32 ProbeIndex = 0; ProbeIndex < OutOutput.Report.ComponentProbePlayerFit.Num(); + for (int32 ProbeIndex = 0; + ProbeIndex < FMath::Min(LandingProbeCount, + OutOutput.Report.ComponentProbePlayerFit.Num()); ++ProbeIndex) { const bool bPlayerFit = OutOutput.Report.ComponentProbePlayerFit[ProbeIndex] != 0u; @@ -1408,7 +1665,6 @@ bool RunWalk( bInArrivalComponent ? 1 : 0; } } - OutOutput.Status = TEXT("ok"); return true; } @@ -1829,11 +2085,15 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("bisection_iterations"), Output.Render.BisectionIterations); Writer->WriteValue(TEXT("human_marker_height_m"), 1.76); Writer->WriteValue(TEXT("step_rationale"), Output.Render.StepRationale); + Writer->WriteValue(TEXT("camera_seed_policy"), Output.Render.CameraSeedPolicy); + Writer->WriteValue(TEXT("camera_seed_count"), Output.Render.CameraSeedCount); + Writer->WriteValue(TEXT("all_cameras_player_fit"), Output.Render.bAllCamerasPlayerFit); Writer->WriteArrayStart(TEXT("images")); for (const FExploreRenderFrame& Frame : Output.Render.Frames) { Writer->WriteObjectStart(); Writer->WriteValue(TEXT("purpose"), Frame.Purpose); + Writer->WriteValue(TEXT("camera_seed_source"), Frame.CameraSeedSource); Writer->WriteValue(TEXT("file"), Frame.FileName); WriteJsonVector(*Writer, TEXT("camera_voxels"), Frame.CameraVoxels, 1.0f); WriteJsonVector(*Writer, TEXT("target_voxels"), Frame.TargetVoxels, 1.0f); @@ -2102,16 +2362,8 @@ int32 UVoxelForgeExploreCommandlet::Main(const FString& Params) FExploreRunOutput Output; bool bRequestedModeFailed = false; - if (Arguments.bRender) - { - const double Start = FPlatformTime::Seconds(); - if (!RunRender(Arguments, World, Output.Render)) - { - bRequestedModeFailed = true; - } - UE_LOG(LogTemp, Display, TEXT("[VoxelForgeExplore] render %.3fs (%s)"), - FPlatformTime::Seconds() - Start, *Output.Render.Status); - } + FExploreWalkOutput RenderSeedWalk; + const FExploreWalkOutput* CameraSeedWalk = nullptr; if (Arguments.bWalk) { const double Start = FPlatformTime::Seconds(); @@ -2121,6 +2373,31 @@ int32 UVoxelForgeExploreCommandlet::Main(const FString& Params) } UE_LOG(LogTemp, Display, TEXT("[VoxelForgeExplore] walk %.3fs (%s)"), FPlatformTime::Seconds() - Start, *Output.Walk.Status); + CameraSeedWalk = &Output.Walk; + } + else if (Arguments.bRender) + { + // A render-only invocation still has to obey the inside-walkable-space contract. Run the + // same focused walk as a private seed pass; it is intentionally omitted from the render-only + // JSON so the selected mode remains render. + const double Start = FPlatformTime::Seconds(); + if (!RunWalk(Arguments, World, RenderSeedWalk)) + { + bRequestedModeFailed = true; + } + UE_LOG(LogTemp, Display, TEXT("[VoxelForgeExplore] render camera seed walk %.3fs (%s)"), + FPlatformTime::Seconds() - Start, *RenderSeedWalk.Status); + CameraSeedWalk = &RenderSeedWalk; + } + if (Arguments.bRender) + { + const double Start = FPlatformTime::Seconds(); + if (CameraSeedWalk == nullptr || !RunRender(Arguments, World, *CameraSeedWalk, Output.Render)) + { + bRequestedModeFailed = true; + } + UE_LOG(LogTemp, Display, TEXT("[VoxelForgeExplore] render %.3fs (%s)"), + FPlatformTime::Seconds() - Start, *Output.Render.Status); } if (Arguments.bFailureFocusRender && Arguments.bRender && Arguments.bWalk) { diff --git a/VoxelForge.uplugin b/VoxelForge.uplugin index a24a5e6..db9b059 100644 --- a/VoxelForge.uplugin +++ b/VoxelForge.uplugin @@ -14,6 +14,12 @@ "IsBetaVersion": true, "IsExperimentalVersion": false, "Installed": false, + "Plugins": [ + { + "Name": "RealtimeMeshComponent", + "Enabled": true + } + ], "Modules": [ { "Name": "VoxelForge",