From 8e42d6d807791e4d357f58f488ad2a9e7c7a0e5e Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Fri, 11 Sep 2026 04:25:45 +0200 Subject: [PATCH] feat(tunnels): floor authored at BUILD time, room clips tunnel, climb tier added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round the owner asked for, with three corrections from Codex that are worth more than the features. WHAT LANDED - The complete tunnel floor profile is authored into FCachedTunnel during BuildChunkCache, not derived per sample. This was his point: "it should be done at creation time, since it knows its destination." - Room floor ownership blends/clips the tunnel floor at mouths -- his A > B boolean. - The traversal validator gained a climb tier, structured exactly as agreed: bWalkOnlyCanReachDeparture / bClimbCanReachDeparture kept SEPARATE, with WalkOnly / Climb / MineRequired cell counts reported alongside. The comment in the header states the contract: "The primordial capability contract is walk-only... climb is reported as a separate tier and never makes the hard connectivity verdict pass." That is the distinction we wanted: reporting tiers, not permission. - Floor controls exposed under Cave Morphology|Tunnels|Floor. ⚠️ CORRECTION 1 -- LARGE LEDGES ARE NOT CAPABILITY-SAFE YET, so they stay opt-in. default (preference 0) fit 20,804 walk reach 10,884 0 ledges preference 2 fit 20,765 walk reach 10,712 360 ledges Ledges cost 172 walk-reachable cells. Codex kept preference 0 (the old staircase) as the default rather than shipping the owner's preferred look at the price of walkable space, and led its report with that. Correct call. ⚠️ CORRECTION 2 -- THE BACKSTOP STILL CANNOT GO. Support/room fallback counters were zero, but the structural protection clamp fired on 31,017 of 31,017 authored floor samples -- 100%. So the shape is still not correct by construction; the clamp is doing the work. Instrumenting it was the right move: we now have a number instead of an argument. ⚠️ CORRECTION 3 -- THE "X + 2A" CLEARANCE BUDGET IS NOT IMPLEMENTABLE AS I SPECIFIED. Interval propagation bounds FIELD VALUES, not SPATIAL DISPLACEMENT. Deriving A needs a proven Lipschitz/influence bound for every post-operation, which we do not have. Both the owner and I assumed the interval work already gave us this; it does not. The idea is still right, the prerequisite is a displacement bound. MEASURED BY ME: build 0 errors; export 1.365 / 1.357 / 1.420 s (checkpoint was 1.321 / 1.357), flat within noise; 153,346 triangles. ALSO MEASURED THIS ROUND - Edit cost: 8 affected chunks, 195.5 ms total -- generation 195.471 ms, apply 0.059, diff 0.020, queue 0.001. It is full level-0 regeneration; a dirty-mesh path needs partial section support that is unavailable without modifying RMC. - Streaming caps: effective generation cap 14, pending max 14, CAP HIT. Mesh applies per frame 4 (not hit), 2 ms apply budget (not hit), unloads 6 (not hit). ⇒ The generation core cap is what binds during startup fill. - Peak export RSS 1.70 GB (was 19.5 GB before the memory work). - LOD4 empty outliers gone. LOD0 not solved: 4,600 empty tiles at 3 us median, but 11 mixed-classifier fallbacks over 100 ms, max 554 ms -- verdict=Mixed, then the full mesher, producing no triangles. ⚠️ UNVERIFIED AND CONCERNING: the reported in-game meshed times are far worse than 412a7ad's -- p50 498.8 ms vs 115 ms, p95 1.227 s vs 410 ms, max 1.974 s vs 844 ms. The samples differ (9,336 ready events vs 841 requests, different traversal, possibly queue wait included), and the export path is flat, so I do not know whether this is a real regression or a different measurement. I have NOT reproduced it myself. It is the first thing the next round must settle. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb --- .../Private/VoxelCaveMorphology.cpp | 142 ++++++++-- .../Private/VoxelDensityOpStack.cpp | 29 +++ .../Private/VoxelDensityProfile.cpp | 8 + Source/VoxelForge/Private/VoxelGenerator.cpp | 102 ++++++-- .../VoxelForge/Private/VoxelStrateManager.cpp | 12 +- .../VoxelForge/Private/VoxelStrateMeasure.cpp | 246 +++++++++++++++--- Source/VoxelForge/Private/VoxelWorld.cpp | 168 +++++++++++- .../VoxelForge/Public/VoxelCaveMorphology.h | 7 + Source/VoxelForge/Public/VoxelDensityOp.h | 7 + .../VoxelForge/Public/VoxelDensityOpStack.h | 17 ++ .../VoxelForge/Public/VoxelDensityProfile.h | 8 + Source/VoxelForge/Public/VoxelSettings.h | 11 +- Source/VoxelForge/Public/VoxelStrateManager.h | 5 +- Source/VoxelForge/Public/VoxelStrateMeasure.h | 13 + .../Private/VoxelForgeExploreCommandlet.cpp | 26 +- 15 files changed, 706 insertions(+), 95 deletions(-) diff --git a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp index 4741ef4..10c3aea 100644 --- a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp +++ b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -586,6 +586,8 @@ namespace static_cast(B.Y - A.Y)).Size(); Profile.StartFloorZ = FloorA; Profile.EndFloorZ = FloorB; + Profile.NaturalStartFloorZ = FloorA; + Profile.NaturalEndFloorZ = FloorB; Profile.NumSteps = 0; if (bGlobalTerrace && GlobalLedgeCount > 0) @@ -606,7 +608,10 @@ namespace Profile.EndFloorZ = FloorAtControl[0] + TotalDelta * static_cast(EndLevel) / static_cast(GlobalLedgeCount); - Profile.NumSteps = EndLevel > StartLevel ? 1 : 0; + // A ledge is a level transition in either direction. The old one-sided test + // accidentally left descending tunnels as a ramp, which is the exact opposite + // of the authored "few large drops" policy. + Profile.NumSteps = EndLevel != StartLevel ? 1 : 0; } else if (Tunnel.bTunnelFloorTerracingEnabled && PreferredLedges == 0 @@ -634,6 +639,10 @@ namespace if (VoxelDensityProfile::AreCountersEnabled()) { uint64 AuthoredLedges = 0; + uint64 LedgesBelow2 = 0; + uint64 Ledges2To4 = 0; + uint64 Ledges4To8 = 0; + uint64 Ledges8Plus = 0; for (const FTunnelFloorSegmentProfile& Profile : OutProfiles) { if (Profile.NumSteps > 0) @@ -643,6 +652,14 @@ namespace // in both cases. AuthoredLedges += bGlobalTerrace ? 1u : static_cast(Profile.NumSteps); + const float HeightPerLedge = FMath::Abs( + Profile.EndFloorZ - Profile.StartFloorZ) + / static_cast(bGlobalTerrace + ? 1 : FMath::Max(Profile.NumSteps, 1)); + if (HeightPerLedge < 2.0f) { ++LedgesBelow2; } + else if (HeightPerLedge < 4.0f) { ++Ledges2To4; } + else if (HeightPerLedge < 8.0f) { ++Ledges4To8; } + else { ++Ledges8Plus; } } } VoxelDensityProfile::AddCounter( @@ -653,6 +670,18 @@ namespace VoxelDensityProfile::AddCounter( VoxelDensityProfile::ECounter::TunnelFloorProfileLedges, AuthoredLedges); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelFloorProfileLedgesBelow2Voxels, + LedgesBelow2); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelFloorProfileLedges2To4Voxels, + Ledges2To4); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelFloorProfileLedges4To8Voxels, + Ledges4To8); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelFloorProfileLedges8PlusVoxels, + Ledges8Plus); } } @@ -803,6 +832,11 @@ namespace } else { + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelFloorProfileFallbacks); + } const float ProfileT = VF_TunnelFloorProfileT( A, RadiusA, B, RadiusB, T); Terms.BaseFloorZ = FMath::Lerp( @@ -824,15 +858,13 @@ namespace || RoomFloorOwnership->RoomHash == Tunnel.FloorRoomHashB); if (bOwnsThisTunnelMouth) { - // A raised tunnel apron is the demonstrated failure mode. Lower it toward the room - // floor, but never raise an existing tunnel floor into solid space: that one-sided - // ownership rule is conservative for the capability contract and leaves a lower - // tunnel to be handled by the room's own floor union. - const float OwnedFloorZ = FMath::Min( - Terms.BaseFloorZ, RoomFloorOwnership->FloorZ); + // The room is A and the corridor is B: inside the finite mouth hand-off, A owns the + // floor regardless of which endpoint happened to be higher. The old one-sided Min + // only removed a raised tunnel apron; a lower tunnel still won the SmoothMin union + // and left a lowered shelf in the chamber. Interpolate to the room floor in the + // authored apron so both representations agree at the room boundary. Terms.BaseFloorZ = FMath::Lerp( - Terms.BaseFloorZ, - OwnedFloorZ, + Terms.BaseFloorZ, RoomFloorOwnership->FloorZ, FMath::Clamp(RoomFloorOwnership->Weight, 0.0f, 1.0f)); // The room's floor relief is already included in FloorZ. Fade the tunnel relief out // with the ownership blend, so the transition cannot stack two independent floors. @@ -954,8 +986,6 @@ namespace const FVector& B = ControlPoints[SegmentIndex + 1]; const float RadiusA = ControlRadii[SegmentIndex]; const float RadiusB = ControlRadii[SegmentIndex + 1]; - const float SegmentSDF = VoxelSDF::TaperedCapsule( - Position, A, B, RadiusA, RadiusB); float T = 0.0f; float HorizontalDistanceSquared = FLT_MAX; @@ -965,7 +995,9 @@ namespace { // A vertical/degenerate XY segment has no finite floor projection. Retain the // capsule for air, but never invent a support surface there. - Result.SDF = FMath::Min(Result.SDF, SegmentSDF); + Result.SDF = FMath::Min( + Result.SDF, + VoxelSDF::TaperedCapsule(Position, A, B, RadiusA, RadiusB)); continue; } @@ -973,6 +1005,22 @@ namespace A, RadiusA, B, RadiusB, T, SegmentIndex, NumSegments, Tunnel, FloorProfiles, RoomFloorOwnership); + const FTunnelFloorSegmentProfile* Profile = nullptr; + if (FloorProfiles != nullptr + && FloorProfiles->Num() == NumSegments + && FloorProfiles->IsValidIndex(SegmentIndex)) + { + Profile = &(*FloorProfiles)[SegmentIndex]; + } + const float NaturalFloorZ = Profile != nullptr + ? FMath::Lerp( + Profile->NaturalStartFloorZ, + Profile->NaturalEndFloorZ, + FMath::Clamp(T, 0.0f, 1.0f)) + : FMath::Lerp( + VoxelPassageGeometry::TunnelFloorZ(A, FMath::Abs(RadiusA)), + VoxelPassageGeometry::TunnelFloorZ(B, FMath::Abs(RadiusB)), + FMath::Clamp(T, 0.0f, 1.0f)); const float SupportRadius = FMath::Max( FMath::Min(FMath::Abs(RadiusA), FMath::Abs(RadiusB)) - 0.5f, VoxelPassageGeometry::PlayerRadiusVoxels); @@ -984,6 +1032,38 @@ namespace Tunnel.FloorReliefFrequency, ReliefBound); + // The corridor is authored as one shape: translate this tapered capsule so its + // bottom follows the immutable floor profile. A zero shift is deliberately kept on + // the original call, preserving the natural path exactly. The provisional shift + // excludes relief only long enough to decide whether the bounded relief can matter; + // the final shift below includes the actual cached column value. + // Preference 0 is the compatibility profile: keep its established floor field and + // its established capsule so the default archetype does not silently change. A + // positive ledge preference is an explicit authored-shape opt-in; there the baked + // profile owns both the floor and the arch and the capsule follows it at evaluation. + const bool bUseAuthoredProfile = bApplyFloorCut + && Profile != nullptr + && Tunnel.TunnelFloorLedgeCountPreference > 0; + const bool bAnchorCapsule = bUseAuthoredProfile + && FMath::IsFinite(NaturalFloorZ); + const auto EvaluateAnchoredCapsule = [ + &Position, &A, &B, RadiusA, RadiusB, NaturalFloorZ, bAnchorCapsule] + (float FloorZ) -> float + { + const float Shift = FloorZ - NaturalFloorZ; + if (!bAnchorCapsule + || !FMath::IsFinite(Shift) + || FMath::Abs(Shift) <= KINDA_SMALL_NUMBER) + { + return VoxelSDF::TaperedCapsule( + Position, A, B, RadiusA, RadiusB); + } + const FVector Offset(0.0f, 0.0f, Shift); + return VoxelSDF::TaperedCapsule( + Position, A + Offset, B + Offset, RadiusA, RadiusB); + }; + float SegmentSDF = EvaluateAnchoredCapsule(FloorTerms.BaseFloorZ); + bool bEvaluateRelief = bApplyFloorCut; if (bEvaluateRelief && bHasReliefBound) { @@ -1018,6 +1098,15 @@ namespace const float FloorZ = VF_TunnelFloorFromTerms( Position, Tunnel, FloorTerms, CacheIdentity, TunnelIndex, bEvaluateRelief, ReliefColumn); + // Relief belongs to the authored floor as well as to its SmoothMax cut. Re-evaluate + // the translated arch only when the column relief actually changed its anchor; most + // samples retain the cheap provisional result. + if (bAnchorCapsule + && FMath::IsFinite(FloorZ) + && FMath::Abs(FloorZ - FloorTerms.BaseFloorZ) > KINDA_SMALL_NUMBER) + { + SegmentSDF = EvaluateAnchoredCapsule(FloorZ); + } const float SegmentResultSDF = bApplyFloorCut ? VoxelSDF::SmoothMax( SegmentSDF, FloorZ - static_cast(Position.Z), FloorBlend) @@ -5301,6 +5390,11 @@ void VoxelCaveMorphology::BuildChunkCache( CT.ControlPoints, CT.ControlRadii, CT, CT.FloorProfiles); VF_BuildTunnelFloorProfile( CT.WorldControlPoints, CT.WorldControlRadii, CT, CT.WorldFloorProfiles); + CT.bHasCompleteFloorProfile = CT.FloorProfiles.Num() == CT.ControlPoints.Num() - 1 + && CT.FloorProfiles.Num() > 0; + CT.bHasCompleteWorldFloorProfile = CT.WorldFloorProfiles.Num() + == CT.WorldControlPoints.Num() - 1 + && CT.WorldFloorProfiles.Num() > 0; // The floor hand-off is a short apron around each destination, not a second floor // that owns the whole room. Bake the same bounded region in both coordinate spaces; // this keeps the room/tunnel boolean deterministic even though the cave warp is 3D. @@ -5995,15 +6089,15 @@ float VoxelCaveMorphology::EvaluateSDFCached( } // The corridor is a swept floor cut: each tapered capsule segment is intersected with - // its interpolated floor half-space before the tunnel union is blended into rooms. The - // room wins naturally in this union (SmoothMin): a tunnel floor is a solid half-space, - // so it cannot replace the room's air above the room floor. Do not lower the SDF floor - // from a proximity-only mouth test; a warped query can be near an endpoint without being - // inside that endpoint's room, and changing it there loses capability. + // its build-time floor profile before the tunnel union is blended into rooms. The room's + // finite mouth ownership is also passed into that same shape evaluation; this is the SDF + // side of A > B, while the world-space post keeps the disturbance path consistent. + const FVFRoomFloorOwnership RoomMouthOwnership = + VF_FindSDFTunnelMouthOwnership(Pos, Tunnel, BlendK); const float TunnelSDF = VF_EvaluateSweptTunnel( Pos, Tunnel, /*bWorldChain=*/false, BlendK, /*bApplyFloorCut=*/Tunnel.bTunnelFloorEnabled, &Cache, TunnelIdx, - nullptr).SDF; + &RoomMouthOwnership).SDF; MinSDF = VoxelSDF::SmoothMin(MinSDF, TunnelSDF, BlendK); }; @@ -6134,7 +6228,7 @@ FTunnelCoreWorldEvaluation VoxelCaveMorphology::EvaluateTunnelCoreWorld( TunnelIdx, *SupportColumn, FloorZ, SupportMinZ, SupportMaxZ) : VF_ProjectSweptTunnelFloor( Pos, Tunnel, /*bWorldChain=*/true, FloorZ, SupportRadius, - &Cache, TunnelIdx); + &Cache, TunnelIdx, &RoomMouthOwnership); bRaisedTunnelFloor = bRoomOwnsThisTunnel && bProjectedFloor && FloorZ > RoomMouthOwnership.FloorZ @@ -6177,7 +6271,7 @@ FTunnelCoreWorldEvaluation VoxelCaveMorphology::EvaluateTunnelCoreWorld( const FVFTunnelShapeEvaluation TunnelShape = VF_EvaluateSweptTunnel( Pos, Tunnel, bHasWorldChain, Cache.SDFBlendRadius, /*bApplyFloorCut=*/Tunnel.bTunnelFloorEnabled && !bRaisedTunnelFloor, - &Cache, TunnelIdx, nullptr); + &Cache, TunnelIdx, &RoomMouthOwnership); Result.SDF = FMath::Min(Result.SDF, TunnelShape.SDF); }; @@ -6246,7 +6340,7 @@ bool VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( float SupportRadius = 0.0f; if (VF_ProjectSweptTunnelFloor( Pos, Tunnel, bHasWorldChain, ProjectedFloorZ, SupportRadius, - &Cache, TunnelIdx) + &Cache, TunnelIdx, &RoomMouthOwnership) && ProjectedFloorZ > RoomMouthOwnership.FloorZ + KINDA_SMALL_NUMBER) { // Only a raised tunnel apron is clipped. If the tunnel is lower, the room's @@ -6259,7 +6353,7 @@ bool VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( return VF_EvaluateSweptTunnel( Pos, Tunnel, bHasWorldChain, Cache.SDFBlendRadius, /*bApplyFloorCut=*/Tunnel.bTunnelFloorEnabled, &Cache, TunnelIdx, - nullptr).bSupportFloor; + &RoomMouthOwnership).bSupportFloor; }; bool bSupport = false; @@ -6308,11 +6402,13 @@ void VoxelCaveMorphology::BuildTunnelSupportFloorColumn( continue; } const FCachedTunnel& Tunnel = Cache.Tunnels[TunnelIdx]; + const FVFRoomFloorOwnership RoomMouthOwnership = + VF_FindWorldTunnelMouthOwnership(ColumnPosition, Tunnel, Cache.SDFBlendRadius); float FloorZ = 0.0f; float SupportRadius = 0.0f; if (!VF_ProjectSweptTunnelFloor( ColumnPosition, Tunnel, /*bWorldChain=*/true, - FloorZ, SupportRadius, &Cache, TunnelIdx)) + FloorZ, SupportRadius, &Cache, TunnelIdx, &RoomMouthOwnership)) { continue; } diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index b175c4e..b447551 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -4226,6 +4226,35 @@ namespace } InOut.Sdf = CaveSDF; + + // The swept floor is part of the cached tunnel shape, but the common structural tail + // used to discover the same band only after all detail ops had run. Publish that + // build-time ownership now so the stack can preserve the authored support through its + // remaining modifiers without a second floor-writing pass. TryGet... below reuses the + // exact same cached result for the generator's post-disturbance hand-off. + const FTunnelSupportFloorColumn* SupportColumn = GetSupportColumn( + S.LastWorldPosition); + S.LastTunnelCoreWorldEvaluation = VoxelCaveMorphology::EvaluateTunnelCoreWorld( + WorldX, WorldY, WorldZ, GetCache(), SupportColumn); + S.LastTunnelCoreEvaluationPosition = S.LastWorldPosition; + S.bLastTunnelCoreWorldEvaluationValid = true; + if (S.LastTunnelCoreWorldEvaluation.bSupportFloor + || S.LastTunnelCoreWorldEvaluation.bRoomFloor) + { + const float StructuralFloorMinimumDensity = + FMath::Max(P.BaseDensity * 2.0f, 1.0f); + if (FMath::IsFinite(StructuralFloorMinimumDensity) + && StructuralFloorMinimumDensity > 0.0f) + { + InOut.bProtectedStructuralFloor = true; + InOut.StructuralFloorMinimumDensity = StructuralFloorMinimumDensity; + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelAuthoredFloorSamples); + } + } + } } //--------------------------------------------------------------------- diff --git a/Source/VoxelForge/Private/VoxelDensityProfile.cpp b/Source/VoxelForge/Private/VoxelDensityProfile.cpp index 79f0aca..666de63 100644 --- a/Source/VoxelForge/Private/VoxelDensityProfile.cpp +++ b/Source/VoxelForge/Private/VoxelDensityProfile.cpp @@ -416,9 +416,17 @@ namespace VoxelDensityProfile case ECounter::TunnelSupportFloorChecks:return TEXT("TunnelSupportFloorChecks"); case ECounter::TunnelSupportFloorBackstopFires:return TEXT("TunnelSupportFloorBackstopFires"); case ECounter::TunnelRoomFloorBackstopFires:return TEXT("TunnelRoomFloorBackstopFires"); + case ECounter::TunnelAuthoredFloorSamples:return TEXT("TunnelAuthoredFloorSamples"); + case ECounter::TunnelAuthoredFloorProtectionClamps:return TEXT("TunnelAuthoredFloorProtectionClamps"); + case ECounter::TunnelAuthoredFloorDisturbanceSkips:return TEXT("TunnelAuthoredFloorDisturbanceSkips"); + case ECounter::TunnelFloorProfileFallbacks:return TEXT("TunnelFloorProfileFallbacks"); case ECounter::TunnelFloorProfileBuilds:return TEXT("TunnelFloorProfileBuilds"); case ECounter::TunnelFloorProfileSegments:return TEXT("TunnelFloorProfileSegments"); case ECounter::TunnelFloorProfileLedges:return TEXT("TunnelFloorProfileLedges"); + case ECounter::TunnelFloorProfileLedgesBelow2Voxels:return TEXT("TunnelFloorProfileLedgesBelow2Voxels"); + case ECounter::TunnelFloorProfileLedges2To4Voxels:return TEXT("TunnelFloorProfileLedges2To4Voxels"); + case ECounter::TunnelFloorProfileLedges4To8Voxels:return TEXT("TunnelFloorProfileLedges4To8Voxels"); + case ECounter::TunnelFloorProfileLedges8PlusVoxels:return TEXT("TunnelFloorProfileLedges8PlusVoxels"); case ECounter::PassageCandidates: return TEXT("PassageCandidates"); case ECounter::PassageEvaluated: return TEXT("PassageEvaluated"); case ECounter::MesherBlockTests: return TEXT("MesherBlockTests"); diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 746ee00..b20604d 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -295,7 +295,8 @@ 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, bool bProtectVerticalShaftAir) + const FStrateDisturbanceParams& D, uint32 Seed, bool bProtectVerticalShaftAir, + bool bProtectAuthoredTunnelFloor) { VoxelDensityProfile::FScopedTimer ProfileTimer( VoxelDensityProfile::EBucket::ApplyDisturbances); @@ -303,6 +304,20 @@ static void ApplyDisturbances(float& MC, float X, float Y, float Z, const float InnerBot = D.StrateBottomWorldZ + D.BoundarySealThickness; if (Z <= InnerBot || Z >= InnerTop) return; + // A tunnel floor is now authored into the cached shape with its clearance budget. The generic + // disturbance layer is deliberately outside that shape, so it must not reopen or bridge the + // finite support band after the shape has proved it. This is an ownership hand-off, not a + // per-sample natural-floor validation. + if (bProtectAuthoredTunnelFloor) + { + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelAuthoredFloorDisturbanceSkips); + } + return; + } + const float Solid = D.BaseDensity * 2.0f; const float Blend = 3.0f; const FVector P(X, Y, Z); @@ -1410,6 +1425,43 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co } DensityCoreTimer.End(); + // Resolve the graph's authored floor before the disturbance pass. The operator-stack + // source has already computed this exact result while publishing its sample; native + // tunnel generation uses the prepared immutable cache. Keeping the result here means the + // disturbance layer can respect the shape-level floor and the common tail can reuse the + // same query instead of discovering a floor only after it has been damaged. + FTunnelCoreWorldEvaluation PreDisturbanceTunnelCore; + bool bHavePreDisturbanceTunnelCore = false; + if (CP_UseOpStack) + { + bHavePreDisturbanceTunnelCore = ActiveOpStack->TryGetLastTunnelCoreWorldEvaluation( + PreDisturbanceTunnelCore); + } + if (!bHavePreDisturbanceTunnelCore && ActiveTunnelCoreCache->bValid) + { + const FTunnelSupportFloorColumn* SupportColumn = nullptr; + FTunnelSupportFloorColumn EmptySupportColumn; + const bool bIntegerXY = + WorldX == FMath::FloorToFloat(WorldX) + && WorldY == FMath::FloorToFloat(WorldY); + if (bIntegerXY) + { + const int32 IX = FMath::FloorToInt(WorldX); + const int32 IY = FMath::FloorToInt(WorldY); + SupportColumn = FindTunnelSupportColumn( + ActiveTunnelCoreCache->Cache, IX, IY, EmptySupportColumn); + } + + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::TunnelCorePosts); + PreDisturbanceTunnelCore = VoxelCaveMorphology::EvaluateTunnelCoreWorld( + WorldX, WorldY, WorldZ, ActiveTunnelCoreCache->Cache, SupportColumn); + bHavePreDisturbanceTunnelCore = true; + } + const bool bProtectAuthoredTunnelFloor = bHavePreDisturbanceTunnelCore + && (PreDisturbanceTunnelCore.bSupportFloor + || PreDisturbanceTunnelCore.bRoomFloor); + // 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. @@ -1418,7 +1470,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co const bool bProtectVerticalShaftAir = VoxelPassageGeometry::VerticalShaftConnectorAirMarker(); ApplyDisturbances(Result, WorldX, WorldY, WorldZ, CP_Dist, (uint32)Seed, - bProtectVerticalShaftAir); + bProtectVerticalShaftAir, bProtectAuthoredTunnelFloor); DensityDisturbancesTimer.End(); // A disturbance is allowed to add visual detail, but it must not refill the landing's @@ -1504,7 +1556,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius); StrateManager->ApplyPassageStructuralPostsMC( Result, WorldX, WorldY, WorldZ, LandingBaseDensity, - CP_Dist.BoundarySealThickness); + CP_Dist.BoundarySealThickness, bProtectAuthoredTunnelFloor); // 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 @@ -1513,8 +1565,11 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co if (ActiveTunnelCoreCache->bValid || CP_UseOpStack) { FTunnelCoreWorldEvaluation TunnelCore; - bool bHaveTunnelCore = CP_UseOpStack - && ActiveOpStack->TryGetLastTunnelCoreWorldEvaluation(TunnelCore); + bool bHaveTunnelCore = bHavePreDisturbanceTunnelCore; + if (bHaveTunnelCore) + { + TunnelCore = PreDisturbanceTunnelCore; + } if (!bHaveTunnelCore && ActiveTunnelCoreCache->bValid) { const FTunnelSupportFloorColumn* SupportColumn = nullptr; @@ -1540,33 +1595,43 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co if (bHaveTunnelCore) { const bool bTunnelSupportFloor = TunnelCore.bSupportFloor; - if (TunnelCore.bRoomFloor) + if (TunnelCore.bRoomFloor && !bProtectAuthoredTunnelFloor) { + const float StructuralSolidDensity = + -FMath::Max(LandingBaseDensity * 2.0f, 1.0f); if (VoxelDensityProfile::AreCountersEnabled()) { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::TunnelRoomFloorBackstopFires); + if (Result > 0.0f) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelRoomFloorBackstopFires); + } } // A tunnel that penetrates a room cannot reassert its own floor through the // room's floor. The morphology query marks only the finite room support band; // preserve it after the generic post-disturbance air backstop. Result = FMath::Min( Result, - -FMath::Max(LandingBaseDensity * 2.0f, 1.0f)); + StructuralSolidDensity); } // The swept capsule owns the relief profile, while this finite support band is // the conservative post-disturbance backstop that keeps terrain modifiers from // removing the only player support at a mouth or a narrow cross-section. - if (bTunnelSupportFloor) + if (bTunnelSupportFloor && !bProtectAuthoredTunnelFloor) { + const float StructuralSolidDensity = + -FMath::Max(LandingBaseDensity * 2.0f, 1.0f); if (VoxelDensityProfile::AreCountersEnabled()) { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::TunnelSupportFloorBackstopFires); + if (Result > 0.0f) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelSupportFloorBackstopFires); + } } Result = FMath::Min( Result, - -FMath::Max(LandingBaseDensity * 2.0f, 1.0f)); + StructuralSolidDensity); } const float CoreSDF = TunnelCore.SDF; if (!bTunnelSupportFloor @@ -2746,12 +2811,17 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo // operator-stack path; its support band follows the same relieved swept floor. if (bTunnelSupportFloor) { + const float StructuralSolidDensity = + FMath::Max(Params.BaseDensity * 2.0f, 1.0f); if (VoxelDensityProfile::AreCountersEnabled()) { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::TunnelSupportFloorBackstopFires); + if (Density < 0.0f) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelSupportFloorBackstopFires); + } } - Density = FMath::Max(Density, FMath::Max(Params.BaseDensity * 2.0f, 1.0f)); + Density = FMath::Max(Density, StructuralSolidDensity); } const float TunnelCoreSDF = (Params.RoomDensity > 0.0f && Params.RoomSpacing > 0.0f) ? VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF(WorldX, WorldY, WorldZ, SDFCache) diff --git a/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Source/VoxelForge/Private/VoxelStrateManager.cpp index 7b61817..c1c5ba5 100644 --- a/Source/VoxelForge/Private/VoxelStrateManager.cpp +++ b/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -1946,7 +1946,8 @@ static bool VF_IsPassageRoomFloor( void UVoxelStrateManager::ApplyPassageStructuralPostsMC( float& Density, float WorldX, float WorldY, float WorldZ, - float BaseDensity, float SealThickness) const + float BaseDensity, float SealThickness, + bool bProtectAuthoredTunnelFloor) const { VoxelDensityProfile::FScopedTimer ProfileTimer( VoxelDensityProfile::EBucket::PassageStructuralPosts); @@ -2009,7 +2010,12 @@ void UVoxelStrateManager::ApplyPassageStructuralPostsMC( } } - if (!bAnyPassageFloor && MinLandingSDF < FLT_MAX) + // A graph tunnel floor is authored by the room-graph source with its complete clearance + // budget. A neighbouring inter-strate landing must not reopen that finite support band; its + // carve remains valid everywhere else. Legacy callers leave this false and retain the old + // passage-owned behavior. + if (!bProtectAuthoredTunnelFloor + && !bAnyPassageFloor && MinLandingSDF < FLT_MAX) { float InternalDensity = -Density; VF_ApplyPassageLandingCarving( @@ -2022,7 +2028,7 @@ void UVoxelStrateManager::ApplyPassageStructuralPostsMC( Density = FMath::Min(Density, -BaseDensity); } - if (bWalkableAir) + if (bWalkableAir && !bProtectAuthoredTunnelFloor) { Density = FMath::Max( Density, BaseDensity * 2.0f + SealThickness + 4.0f); diff --git a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp index 811ae0c..3df59a2 100644 --- a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp +++ b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp @@ -474,6 +474,15 @@ namespace VoxelStrateMeasurePrivate bool bClimb = false; }; + // Walk is the hard capability contract. The climb policy is used only for the diagnostic + // tier; keeping it explicit at every graph operation prevents a future caller from silently + // turning a climbable ledge into a walk-only connectivity pass. + enum class EPlayerTraversalPolicy : uint8 + { + WalkOnly, + WalkAndClimb, + }; + bool IsEligiblePlayerFitCell( const FSampleGrid& Grid, const TArray& PlayerFitMask, @@ -507,8 +516,6 @@ namespace VoxelStrateMeasurePrivate } const bool bFirstIsHigher = FirstSupportHeight > SecondSupportHeight; - const int32 LowerColumnX = bFirstIsHigher ? SecondX : FirstX; - const int32 LowerColumnY = bFirstIsHigher ? SecondY : FirstY; const int32 HigherColumnX = bFirstIsHigher ? FirstX : SecondX; const int32 HigherColumnY = bFirstIsHigher ? FirstY : SecondY; const float LowerHeight = FMath::Min(FirstSupportHeight, SecondSupportHeight); @@ -521,18 +528,19 @@ namespace VoxelStrateMeasurePrivate } // A climb edge must meet a solid riser, not connect two independently supported - // floating poses across an empty gap. Exclude the top support cell itself: that cell is - // present for every supported platform and is not evidence of a climbable face. - const int32 FirstRiserZ = FMath::Max( - 0, - FMath::FloorToInt((LowerHeight - Grid.MinZ) / Step)); - const int32 LastRiserZ = FMath::Min( - Grid.NumZ - 1, - FMath::CeilToInt((HigherHeight - Grid.MinZ) / Step) - 2); - for (int32 Z = FirstRiserZ; Z <= LastRiserZ; ++Z) + // floating poses across an empty gap. Test the actual vertical interval between the two + // support surfaces in the higher column. The old floor/ceil/-2 indexing skipped the only + // solid sample for short quantised ledges and was especially brittle when the support + // heights came from scalar interpolation rather than an integer cell boundary. + for (int32 Z = 0; Z < Grid.NumZ; ++Z) { - if (Grid.Air[Grid.Index(HigherColumnX, HigherColumnY, Z)] == 0u - || Grid.Air[Grid.Index(LowerColumnX, LowerColumnY, Z)] == 0u) + const float SampleZ = Grid.MinZ + (static_cast(Z) + 0.5f) * Step; + if (SampleZ + KINDA_SMALL_NUMBER < LowerHeight + || SampleZ >= HigherHeight - KINDA_SMALL_NUMBER) + { + continue; + } + if (Grid.Air[Grid.Index(HigherColumnX, HigherColumnY, Z)] == 0u) { return true; } @@ -547,7 +555,8 @@ namespace VoxelStrateMeasurePrivate const FVoxelStrateMeasureSettings* Settings, int32 First, int32 Second, - bool& bOutClimb) + bool& bOutClimb, + EPlayerTraversalPolicy Policy = EPlayerTraversalPolicy::WalkAndClimb) { bOutClimb = false; if (!IsEligiblePlayerFitCell(Grid, PlayerFitMask, First) @@ -605,7 +614,8 @@ namespace VoxelStrateMeasurePrivate { return true; } - if (!Settings->bPlayerClimbingEnabled + if (Policy == EPlayerTraversalPolicy::WalkOnly + || !Settings->bPlayerClimbingEnabled || HeightDelta > ClimbHeightVoxels + KINDA_SMALL_NUMBER) { return false; @@ -622,7 +632,8 @@ namespace VoxelStrateMeasurePrivate const TArray* PlayerFitSupportHeights, const FVoxelStrateMeasureSettings* Settings, int32 Cell, - TArray>& OutNeighbours) + TArray>& OutNeighbours, + EPlayerTraversalPolicy Policy = EPlayerTraversalPolicy::WalkAndClimb) { OutNeighbours.Reset(); if (Cell < 0 || Cell >= Grid.CellCount) @@ -643,7 +654,7 @@ namespace VoxelStrateMeasurePrivate bool bClimb = false; if (IsPlayerTraversalEdge( Grid, PlayerFitMask, PlayerFitSupportHeights, Settings, - Cell, Neighbor, bClimb)) + Cell, Neighbor, bClimb, Policy)) { OutNeighbours.Add({ Neighbor, bClimb }); } @@ -658,7 +669,8 @@ namespace VoxelStrateMeasurePrivate if (Z + 1 < Grid.NumZ) TryAdd(Grid.Index(X, Y, Z + 1)); if (Z > 0) TryAdd(Grid.Index(X, Y, Z - 1)); - if (PlayerFitSupportHeights == nullptr || Settings == nullptr + if (Policy == EPlayerTraversalPolicy::WalkOnly + || PlayerFitSupportHeights == nullptr || Settings == nullptr || !Settings->bPlayerClimbingEnabled) { return; @@ -695,8 +707,8 @@ namespace VoxelStrateMeasurePrivate const int32 Neighbor = Grid.Index(TargetX, TargetY, TargetZ); bool bClimb = false; if (IsPlayerTraversalEdge( - Grid, PlayerFitMask, PlayerFitSupportHeights, Settings, - Cell, Neighbor, bClimb) + Grid, PlayerFitMask, PlayerFitSupportHeights, Settings, + Cell, Neighbor, bClimb, Policy) && bClimb) { OutNeighbours.Add({ Neighbor, true }); @@ -717,7 +729,8 @@ namespace VoxelStrateMeasurePrivate TArray* OutComponentCells, const TArray* EligibilityMask = nullptr, const TArray* PlayerFitSupportHeights = nullptr, - const FVoxelStrateMeasureSettings* TraversalSettings = nullptr) + const FVoxelStrateMeasureSettings* TraversalSettings = nullptr, + EPlayerTraversalPolicy TraversalPolicy = EPlayerTraversalPolicy::WalkAndClimb) { OutComponents.SetNumUninitialized(Grid.CellCount); for (int32 Index = 0; Index < Grid.CellCount; ++Index) @@ -780,7 +793,7 @@ namespace VoxelStrateMeasurePrivate TArray> Neighbours; AppendPlayerTraversalNeighbours( Grid, *EligibilityMask, PlayerFitSupportHeights, - TraversalSettings, Current, Neighbours); + TraversalSettings, Current, Neighbours, TraversalPolicy); for (const FPlayerTraversalNeighbour& Neighbour : Neighbours) { Visit(Neighbour.Cell); @@ -1727,7 +1740,8 @@ namespace VoxelStrateMeasurePrivate nullptr, &PlayerFit, &PlayerFitSupportHeights, - &Settings); + &Settings, + EPlayerTraversalPolicy::WalkOnly); InOutMetrics.NumTraversableComponents = NumComponents; InOutMetrics.LargestTraversableComponentCells = LargestComponentCells; InOutMetrics.TraversableComponentShare = InOutMetrics.NumPlayerFitCells > 0 @@ -2234,7 +2248,8 @@ namespace VoxelStrateMeasurePrivate FCoarseEdge& OutFailedEdge, const TArray* EligibilityMask = nullptr, const TArray* PlayerFitSupportHeights = nullptr, - const FVoxelStrateMeasureSettings* TraversalSettings = nullptr) + const FVoxelStrateMeasureSettings* TraversalSettings = nullptr, + EPlayerTraversalPolicy TraversalPolicy = EPlayerTraversalPolicy::WalkAndClimb) { OutFailedEdge = FCoarseEdge(); @@ -2262,7 +2277,8 @@ namespace VoxelStrateMeasurePrivate bool bClimb = false; if (!IsPlayerTraversalEdge( Grid, *EligibilityMask, PlayerFitSupportHeights, - TraversalSettings, Path[PathIndex - 1], Path[PathIndex], bClimb)) + TraversalSettings, Path[PathIndex - 1], Path[PathIndex], bClimb, + TraversalPolicy)) { OutFailedEdge = MakeCoarseEdge( Path[PathIndex - 1], Path[PathIndex]); @@ -2344,7 +2360,8 @@ namespace VoxelStrateMeasurePrivate TArray& OutPath, const TArray* EligibilityMask = nullptr, const TArray* PlayerFitSupportHeights = nullptr, - const FVoxelStrateMeasureSettings* TraversalSettings = nullptr) + const FVoxelStrateMeasureSettings* TraversalSettings = nullptr, + EPlayerTraversalPolicy TraversalPolicy = EPlayerTraversalPolicy::WalkAndClimb) { const auto IsEligible = [&, EligibilityMask](int32 Cell) { @@ -2396,7 +2413,7 @@ namespace VoxelStrateMeasurePrivate TArray> Neighbours; AppendPlayerTraversalNeighbours( Grid, *EligibilityMask, PlayerFitSupportHeights, - TraversalSettings, Current, Neighbours); + TraversalSettings, Current, Neighbours, TraversalPolicy); for (const FPlayerTraversalNeighbour& Neighbour : Neighbours) { Visit(Neighbour.Cell); @@ -2454,7 +2471,8 @@ namespace VoxelStrateMeasurePrivate int32 MaxRouteRetries, const TArray* EligibilityMask = nullptr, const TArray* PlayerFitSupportHeights = nullptr, - const FVoxelStrateMeasureSettings* TraversalSettings = nullptr) + const FVoxelStrateMeasureSettings* TraversalSettings = nullptr, + EPlayerTraversalPolicy TraversalPolicy = EPlayerTraversalPolicy::WalkAndClimb) { OutStart = -1; OutGoal = -1; @@ -2493,7 +2511,7 @@ namespace VoxelStrateMeasurePrivate { if (!FindCoarsePath( Grid, OutStart, OutGoal, BlockedEdges, Path, EligibilityMask, - PlayerFitSupportHeights, TraversalSettings)) + PlayerFitSupportHeights, TraversalSettings, TraversalPolicy)) { // This is a negative verdict only after all edges excluded by earlier // full-resolution failures leave no coarse route at this resolution. @@ -2506,7 +2524,7 @@ namespace VoxelStrateMeasurePrivate FCoarseEdge FailedEdge; if (FullResolutionPathIsAir( Generator, Sampler, Grid, AVoxel, BVoxel, Path, FailedEdge, - EligibilityMask, PlayerFitSupportHeights, TraversalSettings)) + EligibilityMask, PlayerFitSupportHeights, TraversalSettings, TraversalPolicy)) { return EVoxelConnectivityResult::Connected; } @@ -2579,7 +2597,8 @@ namespace VoxelStrateMeasurePrivate TArray* OutComponents = nullptr, int32* OutNumComponents = nullptr, const TArray* PlayerFitSupportHeights = nullptr, - const FVoxelStrateMeasureSettings* TraversalSettings = nullptr) + const FVoxelStrateMeasureSettings* TraversalSettings = nullptr, + EPlayerTraversalPolicy TraversalPolicy = EPlayerTraversalPolicy::WalkAndClimb) { OutDiagnostics = FVoxelConnectivityDiagnostics(); if (OutComponents != nullptr) @@ -2620,7 +2639,8 @@ namespace VoxelStrateMeasurePrivate nullptr, EligibilityMask, PlayerFitSupportHeights, - TraversalSettings); + TraversalSettings, + TraversalPolicy); if (OutComponents != nullptr) { @@ -2680,6 +2700,115 @@ namespace VoxelStrateMeasurePrivate OutDiagnostics.GoalToStartComponentDistanceCells = ComponentDistances[1]; } + // The hard capability graph is deliberately flooded with walking edges only. This second + // pass contracts each walk-only component to one node and adds only the explicitly supported + // climb edges between nodes. It gives the reporting tiers without allowing a climb to turn a + // failed primordial walk-only verdict into a pass. + void BuildClimbTierFromWalkComponents( + const FSampleGrid& Grid, + const TArray& PlayerFitMask, + const TArray& PlayerFitSupportHeights, + const FVoxelStrateMeasureSettings& Settings, + const TArray& Components, + int32 NumComponents, + int32 StartCell, + int32 GoalCell, + int64 PlayerFitCells, + int64& OutWalkOnlyReachableCells, + int64& OutClimbReachableCells, + int64& OutMineRequiredCells, + bool& bOutClimbCanReach, + int64& OutClimbEdges) + { + OutWalkOnlyReachableCells = 0; + OutClimbReachableCells = 0; + OutMineRequiredCells = 0; + bOutClimbCanReach = false; + OutClimbEdges = 0; + + if (NumComponents <= 0 + || Components.Num() != Grid.CellCount + || !Components.IsValidIndex(StartCell) + || !Components.IsValidIndex(GoalCell)) + { + return; + } + + const int32 StartComponent = Components[StartCell]; + const int32 GoalComponent = Components[GoalCell]; + if (StartComponent < 0 || StartComponent >= NumComponents + || GoalComponent < 0 || GoalComponent >= NumComponents) + { + return; + } + + TArray> CellsByComponent; + CellsByComponent.SetNum(NumComponents); + for (int32 Cell = 0; Cell < Grid.CellCount; ++Cell) + { + const int32 Component = Components[Cell]; + if (Component >= 0 && Component < NumComponents + && IsEligiblePlayerFitCell(Grid, PlayerFitMask, Cell)) + { + CellsByComponent[Component].Add(Cell); + } + } + + OutWalkOnlyReachableCells = CellsByComponent[StartComponent].Num(); + + TArray ReachedComponents; + ReachedComponents.Init(0u, NumComponents); + TArray Queue; + Queue.Reserve(NumComponents); + ReachedComponents[StartComponent] = 1u; + Queue.Add(StartComponent); + + for (int32 QueueIndex = 0; QueueIndex < Queue.Num(); ++QueueIndex) + { + const int32 Component = Queue[QueueIndex]; + for (const int32 Cell : CellsByComponent[Component]) + { + TArray> Neighbours; + AppendPlayerTraversalNeighbours( + Grid, + PlayerFitMask, + &PlayerFitSupportHeights, + &Settings, + Cell, + Neighbours, + EPlayerTraversalPolicy::WalkAndClimb); + for (const FPlayerTraversalNeighbour& Neighbour : Neighbours) + { + if (!Neighbour.bClimb || !Components.IsValidIndex(Neighbour.Cell)) + { + continue; + } + ++OutClimbEdges; + const int32 NeighbourComponent = Components[Neighbour.Cell]; + if (NeighbourComponent >= 0 && NeighbourComponent < NumComponents + && NeighbourComponent != Component + && ReachedComponents[NeighbourComponent] == 0u) + { + ReachedComponents[NeighbourComponent] = 1u; + Queue.Add(NeighbourComponent); + } + } + } + } + + for (int32 Component = 0; Component < NumComponents; ++Component) + { + if (ReachedComponents[Component] != 0u) + { + OutClimbReachableCells += CellsByComponent[Component].Num(); + } + } + bOutClimbCanReach = ReachedComponents[GoalComponent] != 0u; + OutMineRequiredCells = FMath::Max( + 0, + PlayerFitCells - OutClimbReachableCells); + } + EVoxelConnectivityResult QueryConnectivity( const UVoxelGenerator* Generator, const UVoxelStrateManager* Manager, @@ -2809,7 +2938,10 @@ namespace VoxelStrateMeasurePrivate Settings.MaxRouteRetries, bPlayerFitRestricted ? &PlayerFitMask : nullptr, bPlayerFitRestricted ? &PlayerFitSupportHeights : nullptr, - bPlayerFitRestricted ? &Settings : nullptr); + bPlayerFitRestricted ? &Settings : nullptr, + bPlayerFitRestricted + ? EPlayerTraversalPolicy::WalkOnly + : EPlayerTraversalPolicy::WalkAndClimb); if (OutDiagnostics != nullptr) { PopulateConnectivityDiagnostics( @@ -2825,7 +2957,10 @@ namespace VoxelStrateMeasurePrivate nullptr, nullptr, bPlayerFitRestricted ? &PlayerFitSupportHeights : nullptr, - bPlayerFitRestricted ? &Settings : nullptr); + bPlayerFitRestricted ? &Settings : nullptr, + bPlayerFitRestricted + ? EPlayerTraversalPolicy::WalkOnly + : EPlayerTraversalPolicy::WalkAndClimb); if (bPlayerFitRestricted && (Result == EVoxelConnectivityResult::StartCellNotPlayerFit || Result == EVoxelConnectivityResult::GoalCellNotPlayerFit)) @@ -2861,11 +2996,12 @@ namespace VoxelStrateMeasurePrivate const TArray& PlayerFitMask, const TArray* PlayerFitSupportHeights, const FVoxelStrateMeasureSettings* Settings, - int32 Cell) + int32 Cell, + EPlayerTraversalPolicy Policy = EPlayerTraversalPolicy::WalkAndClimb) { TArray> Neighbours; AppendPlayerTraversalNeighbours( - Grid, PlayerFitMask, PlayerFitSupportHeights, Settings, Cell, Neighbours); + Grid, PlayerFitMask, PlayerFitSupportHeights, Settings, Cell, Neighbours, Policy); int32 Count = 0; for (const FPlayerTraversalNeighbour& Neighbour : Neighbours) { @@ -2938,6 +3074,7 @@ namespace VoxelStrateMeasurePrivate const TArray& PlayerFitMask, const TArray* PlayerFitSupportHeights, const FVoxelStrateMeasureSettings* TraversalSettings, + EPlayerTraversalPolicy TraversalPolicy, int32 Start, int32 Goal, float NarrowGapThresholdVoxels, @@ -2977,7 +3114,7 @@ namespace VoxelStrateMeasurePrivate if (PlayerFitSupportHeights != nullptr && TraversalSettings != nullptr && IsPlayerTraversalEdge( Grid, PlayerFitMask, PlayerFitSupportHeights, TraversalSettings, - From, To, bClimb) + From, To, bClimb, TraversalPolicy) && bClimb) { ++InOutReport.ClimbTraversals; @@ -3012,7 +3149,7 @@ namespace VoxelStrateMeasurePrivate TArray> Neighbours; AppendPlayerTraversalNeighbours( Grid, PlayerFitMask, PlayerFitSupportHeights, TraversalSettings, - Current, Neighbours); + Current, Neighbours, TraversalPolicy); bool bAdvanced = false; while (NextDirection.Last() < Neighbours.Num()) { @@ -3043,7 +3180,7 @@ namespace VoxelStrateMeasurePrivate if (Current != Start && Current != Goal && CountPlayerFitNeighbours( Grid, PlayerFitMask, PlayerFitSupportHeights, - TraversalSettings, Current) <= 1) + TraversalSettings, Current, TraversalPolicy) <= 1) { ++InOutReport.DeadEndsEncountered; } @@ -3466,7 +3603,8 @@ bool VF_MeasurePlayerFitWalkWithSampler( Settings.MaxRouteRetries, &PlayerFitMask, &PlayerFitSupportHeights, - &Settings); + &Settings, + EPlayerTraversalPolicy::WalkOnly); FVoxelConnectivityDiagnostics Diagnostics; TArray Components; @@ -3484,7 +3622,8 @@ bool VF_MeasurePlayerFitWalkWithSampler( &Components, &NumComponents, &PlayerFitSupportHeights, - &Settings); + &Settings, + EPlayerTraversalPolicy::WalkOnly); OutReport.bValid = true; OutReport.Result = Result; @@ -3499,12 +3638,34 @@ bool VF_MeasurePlayerFitWalkWithSampler( OutReport.SampledMinY = Grid.MinY; OutReport.SampledMaxY = Grid.MaxY; OutReport.PlayerFitVolumeCells = NumPlayerFitCells; - OutReport.ReachablePlayerFitCells = FMath::Max( + OutReport.WalkOnlyReachablePlayerFitCells = FMath::Max( Diagnostics.StartComponentCells, 0); + OutReport.ReachablePlayerFitCells = OutReport.WalkOnlyReachablePlayerFitCells; + OutReport.bWalkOnlyCanReachDeparture = Result == EVoxelConnectivityResult::Connected; + OutReport.bCanReachDeparture = OutReport.bWalkOnlyCanReachDeparture; + BuildClimbTierFromWalkComponents( + Grid, + PlayerFitMask, + PlayerFitSupportHeights, + Settings, + Components, + NumComponents, + Start, + Goal, + NumPlayerFitCells, + OutReport.WalkOnlyReachablePlayerFitCells, + OutReport.ClimbReachablePlayerFitCells, + OutReport.MineRequiredPlayerFitCells, + OutReport.bClimbCanReachDeparture, + OutReport.ClimbEdges); OutReport.ReachablePlayerFitFraction = OutReport.PlayerFitVolumeCells > 0 ? static_cast(static_cast(OutReport.ReachablePlayerFitCells) / static_cast(OutReport.PlayerFitVolumeCells)) : 0.0f; + OutReport.ClimbReachablePlayerFitFraction = OutReport.PlayerFitVolumeCells > 0 + ? static_cast(static_cast(OutReport.ClimbReachablePlayerFitCells) + / static_cast(OutReport.PlayerFitVolumeCells)) + : 0.0f; OutReport.PlayerFitComponents = NumComponents; OutReport.LargestPlayerFitComponentCells = Diagnostics.LargestComponentCells; OutReport.ArrivalComponentCells = Diagnostics.StartComponentCells; @@ -3524,6 +3685,7 @@ bool VF_MeasurePlayerFitWalkWithSampler( PlayerFitMask, &PlayerFitSupportHeights, &Settings, + EPlayerTraversalPolicy::WalkOnly, Start, Goal, NarrowGapThresholdVoxels, diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index 63ac5fe..e27c5d9 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -31,6 +31,7 @@ #include "IImageWrapperModule.h" #include "Modules/ModuleManager.h" #include "Misc/FileHelper.h" +#include "Misc/CommandLine.h" #include "Misc/Paths.h" #include "HAL/IConsoleManager.h" #include "ProfilingDebugging/CpuProfilerTrace.h" // Unreal Insights scopes (Perf 0) @@ -47,6 +48,18 @@ namespace TEXT("voxel.ProfileTileGeneration"), GVoxelForgeProfileTileGeneration, TEXT("Log worker tile generation time with level/step/cell count.")); + + // Startup command-line cvars can arrive before the game world has spawned AVoxelWorld (or + // before its first tiles have become visible). Keep the diagnostic edit pending until Tick has + // a loaded tile; otherwise a startup measurement silently becomes a no-op or edits the wrong + // position. + int32 GVoxelForgePendingTestModification = 0; + float GVoxelForgePendingModificationRadius = 3.0f; + float GVoxelForgePendingModificationStrength = 10.0f; + bool GVoxelForgeTestModificationCommandLineConsumed = false; + bool GVoxelForgeStreamingBudgetReported = false; + bool GVoxelForgeGenerationCapHitReported = false; + int32 GVoxelForgeMaxPendingTilesObserved = 0; } AVoxelWorld::AVoxelWorld() @@ -1075,7 +1088,52 @@ void AVoxelWorld::Tick(float DeltaTime) // Independent of the density volume (self-guards on OrbLightMPC); this is the replacement path. UpdateOrbLightMPC(); } + // The diagnostic harness uses an explicit command-line token rather than a console command: + // Unreal processes startup ExecCmds before this runtime module's console registrations are + // guaranteed to exist. Reading the command line here is order-independent, and the actual edit + // still waits for a loaded tile and goes through the ordinary modification path below. + if (!GVoxelForgeTestModificationCommandLineConsumed) + { + GVoxelForgeTestModificationCommandLineConsumed = true; + const FString CommandLine(FCommandLine::Get()); + if (CommandLine.Contains(TEXT("-voxel.TestModification=1"), ESearchCase::IgnoreCase)) + { + GVoxelForgePendingModificationRadius = 3.0f; + GVoxelForgePendingModificationStrength = 10.0f; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeTestModification] queued radius=%.2f strength=%.2f"), + GVoxelForgePendingModificationRadius, + GVoxelForgePendingModificationStrength); + GVoxelForgePendingTestModification = 1; + } + } ProcessPendingChunks(); + + // Complete the one-shot diagnostic request only after the player's level-0 centre tile is + // loaded and idle. Waiting for any coarse tile would take the async-neighbour branch and would + // not measure the normal synchronous centre remesh that makes a local edit feel expensive. + FVector ModificationPosition = PlayerLastPos; + if (ModificationPosition == FVector::ZeroVector) + { + ModificationPosition = GetActorLocation(); + } + const FVoxelTileKey ModificationCenterTile( + WorldToChunkCoord(WorldToLocalCm(ModificationPosition)), 0); + if (GVoxelForgePendingTestModification != 0 + && DiffLayer + && LoadedTiles.Contains(ModificationCenterTile) + && !PendingTiles.Contains(ModificationCenterTile)) + { + GVoxelForgePendingTestModification = 0; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeTestModification] applying position=(%.2f,%.2f,%.2f) radius=%.2f strength=%.2f"), + ModificationPosition.X, ModificationPosition.Y, ModificationPosition.Z, + GVoxelForgePendingModificationRadius, GVoxelForgePendingModificationStrength); + CarveAtPosition( + ModificationPosition, + GVoxelForgePendingModificationRadius, + GVoxelForgePendingModificationStrength); + } ProcessUnloadQueue(); #if ENABLE_DRAW_DEBUG @@ -1322,6 +1380,9 @@ void AVoxelWorld::SyncRemeshTile(const FVoxelTileKey& Tile) { if (!Generator || !Mesher || ShouldAbortWork()) return; + const bool bProfileModification = GVoxelForgeProfileTileGeneration != 0; + const uint64 SyncStartCycles = bProfileModification ? FPlatformTime::Cycles64() : 0; + const FIntVector OriginVoxels = Tile.OriginVoxels(); const int32 Cells = CHUNK_SIZE; // level 0 is always full-res (level 0 < FullResClipLevels) const int32 Step = 1; // Extent(=CHUNK_SIZE) / Cells @@ -1341,14 +1402,30 @@ void AVoxelWorld::SyncRemeshTile(const FVoxelTileKey& Tile) } FChunkResult Result; - Result.RequestStartCycles = GVoxelForgeProfileTileGeneration != 0 + Result.RequestStartCycles = bProfileModification ? FPlatformTime::Cycles64() : 0; + const uint64 GenerateStartCycles = bProfileModification ? FPlatformTime::Cycles64() : 0; GenerateTileResult(Tile, OriginVoxels, Step, Cells, GenerationEpoch, /*bWantCapture*/ false, BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi, /*bSheetTile*/ false, /*SheetChunkZ*/ 0, /*Hole*/ 0, 0, 0, 0, Result); // hole unused (not a sheet tile) + const double GenerateSeconds = bProfileModification + ? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - GenerateStartCycles) + : 0.0; + const uint64 ApplyStartCycles = bProfileModification ? FPlatformTime::Cycles64() : 0; ApplyTileResult(Result); + if (bProfileModification) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeSyncRemeshProfile] tile=(%d,%d,%d) empty=%d generation=%.6f " + "apply=%.6f total=%.6f"), + Tile.Coord.X, Tile.Coord.Y, Tile.Coord.Z, + (Result.bEmpty || !Result.Streams) ? 1 : 0, + GenerateSeconds, + FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - ApplyStartCycles), + FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - SyncStartCycles)); + } } void AVoxelWorld::ProcessUnloadQueue() @@ -1717,18 +1794,46 @@ bool AVoxelWorld::IsTileInClipRange(const FVoxelTileKey& Tile, const FIntVector& int32 AVoxelWorld::GetMaxConcurrentTasks() const { - // T2.d — the asset value, capped to the spare LOGICAL cores. BackgroundNormal priority - // (see LoadTile) already stops gen from starving the frame; this cap stops a flat 16 from - // thrashing context switches on small CPUs where 16 > the machine's spare parallelism. + // T2.d — both sides of the cap are authored in Voxel|Streaming. BackgroundNormal priority + // (see LoadTile) already stops gen from starving the frame; this cap stops a flat task count + // from thrashing context switches on small CPUs where it exceeds spare parallelism. const int32 Asset = Settings ? Settings->MaxConcurrentTasks : 16; - const int32 SpareCores = FMath::Max(2, FPlatformMisc::NumberOfCoresIncludingHyperthreads() - 2); - return FMath::Clamp(Asset, 1, SpareCores); + const int32 LogicalCores = FMath::Max( + 1, FPlatformMisc::NumberOfCoresIncludingHyperthreads()); + const int32 AutomaticCap = FMath::Max(2, LogicalCores - 2); + const int32 AuthoredCoreCap = Settings ? Settings->MaxGenerationWorkerCores : 0; + const int32 CoreCap = AuthoredCoreCap > 0 + ? FMath::Clamp(AuthoredCoreCap, 1, LogicalCores) + : AutomaticCap; + return FMath::Clamp(Asset, 1, CoreCap); } void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition) { TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateChunks); const int32 MaxTasks = GetMaxConcurrentTasks(); + if (GVoxelForgeProfileTileGeneration != 0 + && !GVoxelForgeStreamingBudgetReported) + { + const int32 LogicalCores = FMath::Max( + 1, FPlatformMisc::NumberOfCoresIncludingHyperthreads()); + const int32 AutomaticCap = FMath::Max(2, LogicalCores - 2); + const int32 AssetCap = Settings ? Settings->MaxConcurrentTasks : 16; + const int32 AuthoredCoreCap = Settings ? Settings->MaxGenerationWorkerCores : 0; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeStreamingBudget] logical_cores=%d automatic_core_cap=%d " + "authored_core_cap=%d asset_task_cap=%d effective_generation_cap=%d " + "max_mesh_applies=%d max_apply_ms=%.3f max_unloads=%d"), + LogicalCores, + AutomaticCap, + AuthoredCoreCap, + AssetCap, + MaxTasks, + Settings ? Settings->MaxMeshAppliesPerFrame : 4, + Settings ? Settings->MaxMeshApplyMilliseconds : 2.0f, + Settings ? Settings->MaxUnloadsPerFrame : 6); + GVoxelForgeStreamingBudgetReported = true; + } const FIntVector CenterChunk = WorldToChunkCoord(WorldToLocalCm(CenterPosition)); // player's level-0 tile CurrentCenterChunk = CenterChunk; @@ -2013,6 +2118,24 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition) ++Submitted; } + if (GVoxelForgeProfileTileGeneration != 0 + && PendingTiles.Num() > GVoxelForgeMaxPendingTilesObserved) + { + GVoxelForgeMaxPendingTilesObserved = PendingTiles.Num(); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeStreamingBudget] pending_max=%d effective=%d submitted=%d"), + GVoxelForgeMaxPendingTilesObserved, MaxTasks, Submitted); + } + if (GVoxelForgeProfileTileGeneration != 0 + && PendingTiles.Num() >= MaxTasks + && !GVoxelForgeGenerationCapHitReported) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeStreamingBudget] generation_cap_hit=1 pending=%d effective=%d"), + PendingTiles.Num(), MaxTasks); + GVoxelForgeGenerationCapHitReported = true; + } + if (Submitted == 0 && PendingTiles.Num() == 0 && BandRemeshQueue.Num() == 0 && DirtyRemeshQueue.Num() == 0) { // Everything desired is loaded → load-before-unload is satisfied: drop the @@ -2036,6 +2159,14 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority) const int32 MaxTasks = GetMaxConcurrentTasks(); // T2.d — core-clamped if (PendingTiles.Num() >= MaxTasks) { + if (GVoxelForgeProfileTileGeneration != 0 + && !GVoxelForgeGenerationCapHitReported) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeStreamingBudget] generation_cap_hit=1 pending=%d effective=%d"), + PendingTiles.Num(), MaxTasks); + GVoxelForgeGenerationCapHitReported = true; + } return; // Budget full — wait for a task to finish. } PendingTiles.Add(Tile); @@ -2727,7 +2858,13 @@ void AVoxelWorld::FillAtPosition(FVector Position, float Radius, float Strength) void AVoxelWorld::ApplyModification(const FVoxelModification& Modification) { if (!DiffLayer) return; + const bool bProfileModification = GVoxelForgeProfileTileGeneration != 0; + const uint64 ModificationStartCycles = bProfileModification ? FPlatformTime::Cycles64() : 0; + const uint64 DiffStartCycles = bProfileModification ? FPlatformTime::Cycles64() : 0; TArray AffectedChunks = DiffLayer->ApplyModification(Modification); + const double DiffSeconds = bProfileModification + ? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - DiffStartCycles) + : 0.0; // INSTANT DIG FEEL — synchronously re-mesh the level-0 tile the brush CENTRE sits in, so the hole // appears THIS frame right where the player is looking. Neighbour tiles (brush edge) re-mesh async @@ -2743,11 +2880,30 @@ void AVoxelWorld::ApplyModification(const FVoxelModification& Modification) && LoadedTiles.Contains(CenterTile) && !PendingTiles.Contains(CenterTile)) { + const uint64 SyncStartCycles = bProfileModification ? FPlatformTime::Cycles64() : 0; SyncRemeshTile(CenterTile); + if (bProfileModification) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeModificationProfile] center_sync=%.6f"), + FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - SyncStartCycles)); + } bSyncedCenter = true; } + const uint64 QueueStartCycles = bProfileModification ? FPlatformTime::Cycles64() : 0; RemeshDirtyChunks(AffectedChunks, bSyncedCenter ? &CenterTile : nullptr); + if (bProfileModification) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeModificationProfile] affected=%d diff=%.6f queue=%.6f total=%.6f " + "center_synced=%d"), + AffectedChunks.Num(), + DiffSeconds, + FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - QueueStartCycles), + FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - ModificationStartCycles), + bSyncedCenter ? 1 : 0); + } // Remove decorations inside the modified volume so grass doesn't float over a dug hole (or bury under a // fill). Instant + flicker-free (only the affected instances go); the placer already skips carved columns diff --git a/Source/VoxelForge/Public/VoxelCaveMorphology.h b/Source/VoxelForge/Public/VoxelCaveMorphology.h index 4ffebdf..baef9b2 100644 --- a/Source/VoxelForge/Public/VoxelCaveMorphology.h +++ b/Source/VoxelForge/Public/VoxelCaveMorphology.h @@ -629,6 +629,11 @@ struct FTunnelFloorSegmentProfile { float StartFloorZ = 0.0f; float EndFloorZ = 0.0f; + // The unmodified tapered-capsule bottom at the same two control points. Keeping these + // alongside the authored floor lets evaluation translate the arch to the baked floor without + // re-deriving a second profile (and makes the build-time floor/arch relationship explicit). + float NaturalStartFloorZ = 0.0f; + float NaturalEndFloorZ = 0.0f; float ReliefScale = 0.0f; int32 NumSteps = 0; }; @@ -657,6 +662,8 @@ struct FCachedTunnel // malformed/legacy caches fall back to the old local evaluator. TArray FloorProfiles; TArray WorldFloorProfiles; + bool bHasCompleteFloorProfile = false; + bool bHasCompleteWorldFloorProfile = false; // The corridor floor is a swept SmoothMax cut, not a later slab. These are copied from the // same strate fields used by room floors; the seed is derived once from the tunnel pair hash. float FloorReliefStrength = 0.0f; diff --git a/Source/VoxelForge/Public/VoxelDensityOp.h b/Source/VoxelForge/Public/VoxelDensityOp.h index 104ab9e..4b9b5e5 100644 --- a/Source/VoxelForge/Public/VoxelDensityOp.h +++ b/Source/VoxelForge/Public/VoxelDensityOp.h @@ -334,6 +334,13 @@ struct FVoxelOpSample // placées quand aucune primitive n'atteint ce voxel). // FLT_MAX = "no surface nearby" — the initial state, and the early-out placed sources use. float Sdf = FLT_MAX; + + // The room-graph source can author a finite tunnel/room floor band while it is building the + // shape. This structural metadata is not a third composable field: it tells the stack that + // later detail operators must not turn the already-authored support back into air. Carrying it + // with the sample keeps the rule true even when a detail op is unaware of the room graph. + bool bProtectedStructuralFloor = false; + float StructuralFloorMinimumDensity = 0.0f; }; /** diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index aec682c..8697695 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -183,6 +183,23 @@ public: VoxelDensityProfile::FScopedTimer ProfileTimer(Entry.ProfileName); Entry.Op->Eval(WorldX, WorldY, WorldZ, S); } + + // The floor profile is authored by the room-graph source, before any detail op runs. Keep + // that source-level clearance invariant through the complete stack. This is intentionally + // one final clamp, rather than a per-op query or a support-column revalidation: the source + // has already proved the current sample belongs to its finite authored floor band. + if (S.bProtectedStructuralFloor + && FMath::IsFinite(S.StructuralFloorMinimumDensity) + && S.StructuralFloorMinimumDensity > 0.0f) + { + if (S.Density < S.StructuralFloorMinimumDensity + && VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelAuthoredFloorProtectionClamps); + } + S.Density = FMath::Max(S.Density, S.StructuralFloorMinimumDensity); + } return S; } diff --git a/Source/VoxelForge/Public/VoxelDensityProfile.h b/Source/VoxelForge/Public/VoxelDensityProfile.h index b6de189..4fa7d8c 100644 --- a/Source/VoxelForge/Public/VoxelDensityProfile.h +++ b/Source/VoxelForge/Public/VoxelDensityProfile.h @@ -98,9 +98,17 @@ namespace VoxelDensityProfile TunnelSupportFloorChecks, TunnelSupportFloorBackstopFires, TunnelRoomFloorBackstopFires, + TunnelAuthoredFloorSamples, + TunnelAuthoredFloorProtectionClamps, + TunnelAuthoredFloorDisturbanceSkips, + TunnelFloorProfileFallbacks, TunnelFloorProfileBuilds, TunnelFloorProfileSegments, TunnelFloorProfileLedges, + TunnelFloorProfileLedgesBelow2Voxels, + TunnelFloorProfileLedges2To4Voxels, + TunnelFloorProfileLedges4To8Voxels, + TunnelFloorProfileLedges8PlusVoxels, PassageCandidates, PassageEvaluated, MesherBlockTests, diff --git a/Source/VoxelForge/Public/VoxelSettings.h b/Source/VoxelForge/Public/VoxelSettings.h index f11ffbc..34ca8cd 100644 --- a/Source/VoxelForge/Public/VoxelSettings.h +++ b/Source/VoxelForge/Public/VoxelSettings.h @@ -46,6 +46,13 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming") int32 MaxConcurrentTasks = 16; + // Optional hard cap for the generation worker budget. 0 keeps the safe automatic cap of + // logical cores minus two; a positive value makes that cap visible and authorable beside the + // task budget instead of hiding it in AVoxelWorld. The effective task count is still the + // minimum of this value and MaxConcurrentTasks. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "0")) + int32 MaxGenerationWorkerCores = 0; + // Nombre max de meshes appliqués (upload GPU) par frame. Seuls les vrais applies // comptent (chunks vides/périmés se vident gratuitement). À 32³ chaque apply est léger // (~0.1 ms) — tunable en live sur l'asset : montez (8-16) si le remplissage traîne, @@ -282,12 +289,12 @@ public: // frame. A region is applied once ALL its cells have finished marching, in one batched AddInstances // per mesh; this throttles that burst. The expensive ray-march runs async on workers. (Named "Cells" // for asset back-compat — the apply unit is now a region of DecorationRegionSizeCells^2 cells.) - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1")) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "1")) int32 MaxDecorationCellsPerFrame = 2; // Max decoration ray-march tasks in flight at once. Keeps decoration marching from crowding the // mesh-gen workers (which are the streaming bottleneck). 0 disables decorations entirely. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "0")) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "0")) int32 MaxConcurrentDecorationTasks = 4; //========================================================================= diff --git a/Source/VoxelForge/Public/VoxelStrateManager.h b/Source/VoxelForge/Public/VoxelStrateManager.h index df2faef..aa8b135 100644 --- a/Source/VoxelForge/Public/VoxelStrateManager.h +++ b/Source/VoxelForge/Public/VoxelStrateManager.h @@ -398,9 +398,10 @@ public: void ApplyPassageTunnelAirMC(float& Density, float WorldX, float WorldY, float WorldZ, float BaseDensity, float SealThickness) const; - /** Apply the landing/tunnel MC backstop in one cached passage scan after disturbances. */ + /** Apply the landing/tunnel MC post in one cached passage scan after disturbances. */ void ApplyPassageStructuralPostsMC(float& Density, float WorldX, float WorldY, float WorldZ, - float BaseDensity, float SealThickness) const; + float BaseDensity, float SealThickness, + bool bProtectAuthoredTunnelFloor = false) const; /** Re-assert a landing floor after the landing air pass, still before the final XY seal. */ void ApplyPassageLandingFloorMC(float& Density, float WorldX, float WorldY, float WorldZ, diff --git a/Source/VoxelForge/Public/VoxelStrateMeasure.h b/Source/VoxelForge/Public/VoxelStrateMeasure.h index 95f519e..8ea6041 100644 --- a/Source/VoxelForge/Public/VoxelStrateMeasure.h +++ b/Source/VoxelForge/Public/VoxelStrateMeasure.h @@ -311,10 +311,23 @@ struct VOXELFORGE_API FVoxelPlayerFitWalkReport int32 NumRouteRetries = 0; bool bClimbingEnabled = false; float MaxClimbHeightMeters = 0.0f; + // The primordial capability contract is walk-only. The existing reachable fields below + // therefore remain walk-only; climb is reported as a separate tier and never makes the hard + // connectivity verdict pass. + bool bWalkOnlyCanReachDeparture = false; + bool bClimbCanReachDeparture = false; int64 PlayerFitVolumeCells = 0; int64 ReachablePlayerFitCells = 0; + int64 WalkOnlyReachablePlayerFitCells = 0; + int64 ClimbReachablePlayerFitCells = 0; + int64 ClimbEdges = 0; + // Fit poses outside the walk+climb reachable set need an edit/mining action to become + // reachable. This is a reporting remainder, not a promise that arbitrary mining can reach + // every such pose without a separate excavation model. + int64 MineRequiredPlayerFitCells = 0; float ReachablePlayerFitFraction = 0.0f; + float ClimbReachablePlayerFitFraction = 0.0f; int32 SampledNumX = 0; int32 SampledNumY = 0; int32 SampledNumZ = 0; diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp index 0ae5e97..083d9c0 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -4261,7 +4261,7 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("refusal_or_error"), Output.Walk.RefusalReason); Writer->WriteValue(TEXT("window_policy"), Output.Walk.WindowPolicy); Writer->WriteValue(TEXT("agent_policy"), TEXT( - "fixed-order depth-first walk over the exact VF player-fit graph; six-neighbour movement plus supported climb edges; stop at departure")); + "fixed-order depth-first walk over the exact VF player-fit graph; walk-only six-neighbour movement; climb is reported separately and never authorizes the result; stop at departure")); Writer->WriteValue(TEXT("player_fit_volume_scope"), TEXT( "bounded fitted route window; WorldRadiusVoxels=0 has no finite whole-world volume")); Writer->WriteValue(TEXT("max_cells_cap"), Arguments.MaxWalkCells); @@ -4305,6 +4305,8 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("connectivity_result"), ConnectivityResultName(Report.Result)); Writer->WriteValue(TEXT("connectivity_result_code"), static_cast(Report.Result)); Writer->WriteValue(TEXT("can_reach_departure"), Report.bCanReachDeparture); + Writer->WriteValue(TEXT("walk_only_can_reach_departure"), Report.bWalkOnlyCanReachDeparture); + Writer->WriteValue(TEXT("climb_can_reach_departure"), Report.bClimbCanReachDeparture); Writer->WriteValue(TEXT("start_snapped"), Report.bStartSnapped); Writer->WriteValue(TEXT("goal_snapped"), Report.bGoalSnapped); Writer->WriteValue(TEXT("route_retries"), Report.NumRouteRetries); @@ -4313,6 +4315,15 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("player_fit_volume_cells"), Report.PlayerFitVolumeCells); Writer->WriteValue(TEXT("reachable_player_fit_cells"), Report.ReachablePlayerFitCells); Writer->WriteValue(TEXT("reachable_player_fit_fraction"), static_cast(Report.ReachablePlayerFitFraction)); + Writer->WriteValue(TEXT("walk_only_reachable_player_fit_cells"), + Report.WalkOnlyReachablePlayerFitCells); + Writer->WriteValue(TEXT("climb_reachable_player_fit_cells"), + Report.ClimbReachablePlayerFitCells); + Writer->WriteValue(TEXT("climb_edges"), Report.ClimbEdges); + Writer->WriteValue(TEXT("mine_required_player_fit_cells"), + Report.MineRequiredPlayerFitCells); + Writer->WriteValue(TEXT("climb_reachable_player_fit_fraction"), + static_cast(Report.ClimbReachablePlayerFitFraction)); Writer->WriteValue(TEXT("sampled_num_x"), Report.SampledNumX); Writer->WriteValue(TEXT("sampled_num_y"), Report.SampledNumY); Writer->WriteValue(TEXT("sampled_num_z"), Report.SampledNumZ); @@ -4391,6 +4402,10 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("window_policy"), TEXT("origin-inclusive mouth-AABB + margin")); Writer->WriteValue(TEXT("connectivity_result"), ConnectivityResultName(OriginCheckReport.Result)); Writer->WriteValue(TEXT("can_reach_departure"), OriginCheckReport.bCanReachDeparture); + Writer->WriteValue(TEXT("walk_only_can_reach_departure"), + OriginCheckReport.bWalkOnlyCanReachDeparture); + Writer->WriteValue(TEXT("climb_can_reach_departure"), + OriginCheckReport.bClimbCanReachDeparture); Writer->WriteValue(TEXT("sampled_num_x"), OriginCheckReport.SampledNumX); Writer->WriteValue(TEXT("sampled_num_y"), OriginCheckReport.SampledNumY); Writer->WriteValue(TEXT("sampled_num_z"), OriginCheckReport.SampledNumZ); @@ -4404,6 +4419,15 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("reachable_player_fit_cells"), OriginCheckReport.ReachablePlayerFitCells); Writer->WriteValue(TEXT("reachable_player_fit_fraction"), static_cast(OriginCheckReport.ReachablePlayerFitFraction)); + Writer->WriteValue(TEXT("walk_only_reachable_player_fit_cells"), + OriginCheckReport.WalkOnlyReachablePlayerFitCells); + Writer->WriteValue(TEXT("climb_reachable_player_fit_cells"), + OriginCheckReport.ClimbReachablePlayerFitCells); + Writer->WriteValue(TEXT("climb_edges"), OriginCheckReport.ClimbEdges); + Writer->WriteValue(TEXT("mine_required_player_fit_cells"), + OriginCheckReport.MineRequiredPlayerFitCells); + Writer->WriteValue(TEXT("climb_reachable_player_fit_fraction"), + static_cast(OriginCheckReport.ClimbReachablePlayerFitFraction)); Writer->WriteValue(TEXT("player_fit_components"), OriginCheckReport.PlayerFitComponents); Writer->WriteValue(TEXT("arrival_component_cells"), OriginCheckReport.ArrivalComponentCells); Writer->WriteValue(TEXT("departure_component_cells"), OriginCheckReport.DepartureComponentCells);