From 4fa3931176bba4ab430c43a72cdfbded38f075d4 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Thu, 17 Sep 2026 02:48:20 +0200 Subject: [PATCH] fix(tunnels): ribs removed at the cause; authored tunnel floor removed; slope acts on the route The owner proved in game that the tunnel core caused the ribs (voxel.TunnelAblateTunnelCore=1 removes them). Root cause: the core's final reassertion replaced any sufficiently interior sample with a fixed +16 air density, destroying the distance gradient, so marching cubes snapped the surface to the voxel grid (all 385 centre samples saturated). The core now reasserts the continuous -Core.SDF, with bottom polarity from the swept shape. The Catmull-Rom sweep from 0231072 is reverted (it cost 18-30% and did not help); the linear tapered-capsule sweep is restored. Owner decisions implemented: bTunnelFloorEnabled has no runtime effect (reflected only for asset compatibility, ignored on PostLoad), and the authored slab / graph support-floor paths are detached from generation, so the tunnel's own carved shape is the floor. Winding and dramatic ledges no longer depend on the floor switch. Degrees authoring, its migration, and the 3840-voxel route cap stay. Mouth trim stays behind voxel.TunnelMouthTrim (default off). Evidence: along owner tunnel 15, 385 samples at 0.25 voxel, periodic surface amplitude floor 3.55 -> 0.50 cm, wall 16.9 -> 1.10 cm, ceiling 8.80 -> 0.69 cm (acceptance < 2 cm); 0/385 samples saturated. Matched-camera render: ribs absent. Canonical walk: Connected, player-fit 41,184 (was 20,830), walk-only 13,879 (was 10,909), mouth gap 0; mesh identical with -voxel.CrtFma3=0. TilePostReachProof, DetSinCos, strate connectivity 16/16, strate crossing: pass. Perf, one S/M/S/M pair vs a rebuilt bf9384a DLL: static 26.10 vs 25.41 s (+2.7%), moving 55.34 vs 53.08 s (+4.2%), request p95 about equal. Treat as a possible small cost to re-measure, not as noise. Not merged into experimental: waiting for the owner's in-game look. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ --- .../Private/VoxelCaveMorphology.cpp | 412 ++------ .../Private/VoxelDensityOpStack.cpp | 308 +----- Source/VoxelForge/Private/VoxelGenerator.cpp | 292 ++---- .../Private/VoxelStrateComposer.cpp | 7 - .../Private/VoxelStrateDefinition.cpp | 5 + .../VoxelForge/Private/VoxelStrateManager.cpp | 15 +- .../VoxelForge/Public/VoxelCaveMorphology.h | 49 +- Source/VoxelForge/Public/VoxelDensityOp.h | 16 +- Source/VoxelForge/Public/VoxelStrateManager.h | 3 +- Source/VoxelForge/Public/VoxelStrateTypes.h | 13 +- .../Private/VoxelForgeExploreCommandlet.cpp | 986 +++++++++++++++++- 11 files changed, 1244 insertions(+), 862 deletions(-) diff --git a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp index 10474f8..d122928 100644 --- a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp +++ b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -82,6 +82,9 @@ namespace { float SDF = FLT_MAX; bool bSupportFloor = false; + bool bHasSweptFloor = false; + float SweptFloorZ = -FLT_MAX; + float SweptFloorRadius = 0.0f; }; // FMath::PerlinNoise2D is the same two-octave field used by room floors. The derivative bound @@ -621,6 +624,11 @@ namespace struct FVFRoomFloorOwnership { bool bValid = false; + // The room-floor blend is intentionally short, but the tunnel capsule and the fitted + // player envelope can still overlap the room beyond that blend. This separate hand-off + // bit prevents the graph tunnel's lower-side polarity from turning that overlap into a + // wall without extending the room floor itself into the corridor. + bool bSuppressTunnelBottom = false; float FloorZ = 0.0f; float Weight = 0.0f; uint32 RoomHash = 0; @@ -670,7 +678,13 @@ namespace : (RelativeDistance < Fade ? 1.0f - SmoothStep01(RelativeDistance / Fade) : 0.0f); - if (!(Weight > 0.0f)) + const float EndpointRadius = FMath::Abs(ControlRadiusData[ControlIndex]); + const float BottomHandoffRadius = BlendRadius + + VF_TunnelMouthPlayerFitOverlapVoxels + + EndpointRadius; + const bool bInBottomHandoff = VoxelMath::IsFinite(BottomHandoffRadius) + && Distance <= BottomHandoffRadius; + if (!(Weight > 0.0f) && !bInBottomHandoff) { continue; } @@ -694,7 +708,8 @@ namespace continue; } BestScore = Score; - Result.bValid = true; + Result.bValid = Weight > 0.0f; + Result.bSuppressTunnelBottom = bInBottomHandoff; Result.FloorZ = FloorZ; Result.Weight = Weight; Result.RoomHash = Endpoint == 0 ? FloorRoomHashA : FloorRoomHashB; @@ -897,146 +912,6 @@ namespace return true; } - // A world-chain handoff is sampled after the cave-warped SDF chain has already carved the - // tunnel. It must follow the same continuous centreline, not union another tapered capsule - // at every control point. A fixed Catmull-Rom span gives adjacent spans a shared tangent; - // the fixed projection iterations keep the result deterministic and bounded. - struct FVFTunnelCubicSpan - { - FVector P0 = FVector::ZeroVector; - FVector P1 = FVector::ZeroVector; - FVector P2 = FVector::ZeroVector; - FVector P3 = FVector::ZeroVector; - float R0 = 0.0f; - float R1 = 0.0f; - float R2 = 0.0f; - float R3 = 0.0f; - - FORCEINLINE FVector Position(float T) const - { - const float T2 = T * T; - const float T3 = T2 * T; - return (P1 * 2.0f - + (P2 - P0) * T - + (P0 * 2.0f - P1 * 5.0f + P2 * 4.0f - P3) * T2 - + (-P0 + P1 * 3.0f - P2 * 3.0f + P3) * T3) * 0.5f; - } - - FORCEINLINE FVector FirstDerivative(float T) const - { - const float T2 = T * T; - return ((P2 - P0) - + (P0 * 2.0f - P1 * 5.0f + P2 * 4.0f - P3) * (2.0f * T) - + (-P0 + P1 * 3.0f - P2 * 3.0f + P3) * (3.0f * T2)) * 0.5f; - } - - FORCEINLINE FVector SecondDerivative(float T) const - { - return ((P0 * 2.0f - P1 * 5.0f + P2 * 4.0f - P3) * 2.0f - + (-P0 + P1 * 3.0f - P2 * 3.0f + P3) * (6.0f * T)) * 0.5f; - } - - FORCEINLINE float Radius(float T) const - { - const float T2 = T * T; - const float T3 = T2 * T; - const float Value = (R1 * 2.0f - + (R2 - R0) * T - + (R0 * 2.0f - R1 * 5.0f + R2 * 4.0f - R3) * T2 - + (-R0 + R1 * 3.0f - R2 * 3.0f + R3) * T3) * 0.5f; - return FMath::Max(Value, 0.5f); - } - }; - - static FVFTunnelCubicSpan VF_MakeTunnelCubicSpan( - const TArray& ControlPoints, - const TArray& ControlRadii, - int32 SegmentIndex) - { - const int32 LastPoint = ControlPoints.Num() - 1; - const FVector& P1 = ControlPoints[SegmentIndex]; - const FVector& P2 = ControlPoints[SegmentIndex + 1]; - const FVector P0 = SegmentIndex > 0 - ? ControlPoints[SegmentIndex - 1] : P1 + (P1 - P2); - const FVector P3 = SegmentIndex + 2 <= LastPoint - ? ControlPoints[SegmentIndex + 2] : P2 + (P2 - P1); - const float R1 = ControlRadii[SegmentIndex]; - const float R2 = ControlRadii[SegmentIndex + 1]; - const float R0 = SegmentIndex > 0 - ? ControlRadii[SegmentIndex - 1] : R1 + (R1 - R2); - const float R3 = SegmentIndex + 2 <= LastPoint - ? ControlRadii[SegmentIndex + 2] : R2 + (R2 - R1); - - FVFTunnelCubicSpan Span; - Span.P0 = P0; - Span.P1 = P1; - Span.P2 = P2; - Span.P3 = P3; - Span.R0 = R0; - Span.R1 = R1; - Span.R2 = R2; - Span.R3 = R3; - return Span; - } - - static bool VF_ProjectTunnelCubicXY( - const FVector& Position, - const FVFTunnelCubicSpan& Span, - float& OutT, - float& OutDistanceSquared) - { - float BestT = 0.0f; - float BestDistanceSquared = FLT_MAX; - auto Consider = [&](float T) - { - const FVector Candidate = Span.Position(T); - const float DX = static_cast(Candidate.X - Position.X); - const float DY = static_cast(Candidate.Y - Position.Y); - const float DistanceSquared = DX * DX + DY * DY; - if (DistanceSquared < BestDistanceSquared) - { - BestDistanceSquared = DistanceSquared; - BestT = T; - } - }; - - constexpr int32 InitialSamples = 8; - for (int32 Sample = 0; Sample <= InitialSamples; ++Sample) - { - Consider(static_cast(Sample) / InitialSamples); - } - float CandidateT = BestT; - for (int32 Iteration = 0; Iteration < 6; ++Iteration) - { - const FVector CurvePoint = Span.Position(CandidateT); - const FVector First = Span.FirstDerivative(CandidateT); - const FVector Second = Span.SecondDerivative(CandidateT); - const float DX = static_cast(CurvePoint.X - Position.X); - const float DY = static_cast(CurvePoint.Y - Position.Y); - const float FirstX = static_cast(First.X); - const float FirstY = static_cast(First.Y); - const float SecondX = static_cast(Second.X); - const float SecondY = static_cast(Second.Y); - const float Numerator = DX * FirstX + DY * FirstY; - const float Denominator = FirstX * FirstX + FirstY * FirstY - + DX * SecondX + DY * SecondY; - if (!VoxelMath::IsFinite(Numerator) - || !VoxelMath::IsFinite(Denominator) - || FMath::Abs(Denominator) <= KINDA_SMALL_NUMBER) - { - break; - } - CandidateT = FMath::Clamp( - CandidateT - Numerator / Denominator, 0.0f, 1.0f); - Consider(CandidateT); - } - - OutT = BestT; - OutDistanceSquared = BestDistanceSquared; - return VoxelMath::IsFinite(OutT) - && VoxelMath::IsFinite(OutDistanceSquared); - } - static FVFTunnelShapeEvaluation VF_EvaluateSweptTunnelChain( const FVector& Position, const TArray& ControlPoints, @@ -1044,7 +919,6 @@ namespace const FCachedTunnel& Tunnel, float SDFBlendRadius, bool bApplyFloorCut, - bool bUseContinuousCurve, const void* CacheIdentity, int32 TunnelIndex, const FVFFloorReliefColumnKey& ReliefColumn, const TArray* FloorProfiles, @@ -1077,14 +951,8 @@ namespace const float RadiusB = ControlRadiusData[SegmentIndex + 1]; float T = 0.0f; float HorizontalDistanceSquared = FLT_MAX; - const FVFTunnelCubicSpan Span = bUseContinuousCurve - ? VF_MakeTunnelCubicSpan(ControlPoints, ControlRadii, SegmentIndex) - : FVFTunnelCubicSpan(); - const bool bHasHorizontalProjection = bUseContinuousCurve - ? VF_ProjectTunnelCubicXY( - Position, Span, T, HorizontalDistanceSquared) - : VF_ProjectTunnelSegmentXY( - Position, A, B, T, HorizontalDistanceSquared); + const bool bHasHorizontalProjection = VF_ProjectTunnelSegmentXY( + Position, A, B, T, HorizontalDistanceSquared); if (!bHasHorizontalProjection) { // A vertical/degenerate XY segment has no finite floor projection. Retain the @@ -1095,10 +963,6 @@ namespace continue; } - const FVector CurvePoint = bUseContinuousCurve - ? Span.Position(T) : FMath::Lerp(A, B, T); - const float CurveRadius = bUseContinuousCurve - ? Span.Radius(T) : FMath::Lerp(RadiusA, RadiusB, T); const FTunnelFloorSegmentProfile* Profile = FloorProfileData != nullptr ? FloorProfileData + SegmentIndex : nullptr; const FVFTunnelFloorTerms FloorTerms = VF_TunnelFloorTerms( @@ -1137,13 +1001,12 @@ namespace // headroom in every gentle tunnel merely because a profile is now always present. const bool bUseAuthoredProfile = bApplyFloorCut && Profile != nullptr - && Tunnel.bTunnelFloorEnabled && (Tunnel.bFloorRouteWasWound || Tunnel.bDramaticLedge); const bool bAnchorCapsule = bUseAuthoredProfile && VoxelMath::IsFinite(NaturalFloorZ); const auto EvaluateAnchoredShape = [ - &Position, &A, &B, RadiusA, RadiusB, CurvePoint, CurveRadius, - NaturalFloorZ, bAnchorCapsule, bUseContinuousCurve] + &Position, &A, &B, RadiusA, RadiusB, + NaturalFloorZ, bAnchorCapsule] (float FloorZ) -> float { const float Shift = FloorZ - NaturalFloorZ; @@ -1151,16 +1014,12 @@ namespace || !VoxelMath::IsFinite(Shift) || FMath::Abs(Shift) <= KINDA_SMALL_NUMBER) { - return bUseContinuousCurve - ? FVector::Dist(Position, CurvePoint) - CurveRadius - : VoxelSDF::TaperedCapsule( - Position, A, B, RadiusA, RadiusB); + return VoxelSDF::TaperedCapsule( + Position, A, B, RadiusA, RadiusB); } const FVector Offset(0.0f, 0.0f, Shift); - return bUseContinuousCurve - ? FVector::Dist(Position, CurvePoint + Offset) - CurveRadius - : VoxelSDF::TaperedCapsule( - Position, A + Offset, B + Offset, RadiusA, RadiusB); + return VoxelSDF::TaperedCapsule( + Position, A + Offset, B + Offset, RadiusA, RadiusB); }; float SegmentSDF = EvaluateAnchoredShape(FloorTerms.BaseFloorZ); @@ -1211,18 +1070,26 @@ namespace ? VoxelSDF::SmoothMax( SegmentSDF, FloorZ - static_cast(Position.Z), FloorBlend) : SegmentSDF; - Result.SDF = FMath::Min(Result.SDF, SegmentResultSDF); - - if (bApplyFloorCut && Tunnel.bTunnelFloorEnabled - && SegmentSDF <= 0.0f - && HorizontalDistanceSquared <= FMath::Square(SupportRadius) - && Position.Z >= FloorZ - - VoxelPassageGeometry::LandingFloorThicknessVoxels - && Position.Z <= FloorZ - + VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels) + if (SegmentResultSDF < Result.SDF) { - Result.bSupportFloor = true; + Result.SDF = SegmentResultSDF; + // This is shape metadata, not a second floor field. It records which side of + // the winning swept SDF is its own bottom so the final MC hand-off can restore + // that side when a room's generic air field would otherwise erase it. + Result.bHasSweptFloor = bApplyFloorCut + && VoxelMath::IsFinite(FloorZ) + && VoxelMath::IsFinite(SupportRadius) + && VoxelMath::IsFinite(HorizontalDistanceSquared) + && HorizontalDistanceSquared <= FMath::Square(SupportRadius) + // The room owns the finite mouth apron. Do not let the tunnel's bottom + // polarity turn that overlap into a wall; the corridor interior remains + // owned by this swept shape. + && !(RoomFloorOwnership != nullptr + && RoomFloorOwnership->bSuppressTunnelBottom); + Result.SweptFloorZ = Result.bHasSweptFloor ? FloorZ : -FLT_MAX; + Result.SweptFloorRadius = Result.bHasSweptFloor ? SupportRadius : 0.0f; } + } return Result; } @@ -1249,7 +1116,6 @@ namespace return VF_EvaluateSweptTunnelChain( Position, ShapeControlPoints, ShapeControlRadii, Tunnel, SDFBlendRadius, bApplyFloorCut, - bWorldChain, CacheIdentity, TunnelIndex, ReliefColumn, bWorldChain ? &Tunnel.WorldFloorProfiles : &Tunnel.FloorProfiles, RoomFloorOwnership); @@ -1270,7 +1136,6 @@ namespace return VF_EvaluateSweptTunnelChain( Position, Points, Radii, Tunnel, SDFBlendRadius, bApplyFloorCut, - bWorldChain, CacheIdentity, TunnelIndex, ReliefColumn, bWorldChain ? &Tunnel.WorldFloorProfiles : &Tunnel.FloorProfiles, RoomFloorOwnership); @@ -1285,7 +1150,6 @@ namespace return VF_EvaluateSweptTunnelChain( Position, Points, Radii, Tunnel, SDFBlendRadius, bApplyFloorCut, - bWorldChain, CacheIdentity, TunnelIndex, ReliefColumn, bWorldChain ? &Tunnel.WorldFloorProfiles : &Tunnel.FloorProfiles, RoomFloorOwnership); @@ -1300,10 +1164,6 @@ namespace const void* CacheIdentity, int32 TunnelIndex, const FVFRoomFloorOwnership* RoomFloorOwnership = nullptr) { - if (!Tunnel.bTunnelFloorEnabled) - { - return false; - } const FVFFloorReliefColumnKey ReliefColumn = VF_MakeFloorReliefColumnKey( static_cast(Position.X), static_cast(Position.Y)); @@ -1780,12 +1640,6 @@ void FChunkSDFCache::Reset() PitSpatialIndex.Reset(); ChimneySpatialIndex.Reset(); ColumnSpatialIndex.Reset(); - SupportColumnMinX = 0; - SupportColumnMinY = 0; - SupportColumnCellsX = 0; - SupportColumnCellsY = 0; - SupportColumnEntries.Reset(); - SupportColumns.Reset(); SDFBlendRadius = 0.0f; } @@ -1803,12 +1657,6 @@ void FChunkSDFCache::Release() PitSpatialIndex.Release(); ChimneySpatialIndex.Release(); ColumnSpatialIndex.Release(); - SupportColumnEntries.Empty(); - SupportColumns.Empty(); - SupportColumnMinX = 0; - SupportColumnMinY = 0; - SupportColumnCellsX = 0; - SupportColumnCellsY = 0; } SIZE_T FChunkSDFCache::GetAllocatedSize() const @@ -1830,8 +1678,6 @@ VoxelDensityProfile::FCacheMemoryBreakdown FChunkSDFCache::GetAllocatedSizeBreak Breakdown.PitsBytes = ArrayBytes(Pits); Breakdown.ChimneysBytes = ArrayBytes(Chimneys); Breakdown.ColumnsBytes = ArrayBytes(Columns); - Breakdown.SupportColumnEntriesBytes = ArrayBytes(SupportColumnEntries); - Breakdown.SupportColumnsBytes = ArrayBytes(SupportColumns); Breakdown.SpatialIndexBytes = static_cast( RoomSpatialIndex.GetAllocatedSize() + RoomFloorJoinSpatialIndex.GetAllocatedSize() @@ -1839,11 +1685,6 @@ VoxelDensityProfile::FCacheMemoryBreakdown FChunkSDFCache::GetAllocatedSizeBreak + PitSpatialIndex.GetAllocatedSize() + ChimneySpatialIndex.GetAllocatedSize() + ColumnSpatialIndex.GetAllocatedSize()); - for (const FTunnelSupportFloorColumn& Column : SupportColumns) - { - Breakdown.SupportColumnIntervalsBytes += ArrayBytes(Column.Intervals); - } - for (const FCachedTunnel& Tunnel : Tunnels) { Breakdown.TunnelsBytes += ArrayBytes(Tunnel.ControlPoints); @@ -2387,12 +2228,10 @@ static bool VF_ComputeRoomGraphReach( const float GentleSlopeThreshold = VoxelPassageGeometry::TunnelFloorGradientFromDegrees( Params.TunnelFloorGentleSlopeThreshold); const float MaxFloorDeltaEnvelope = 2.0f * EndpointVertical; - const float RequiredRouteRunEnvelope = Params.bTunnelFloorEnabled - ? FMath::Min( - VoxelPassageGeometry::TunnelFloorMaximumRouteLengthVoxels, - MaxFloorDeltaEnvelope / FMath::Max( - GentleSlopeThreshold, KINDA_SMALL_NUMBER)) - : 0.0f; + const float RequiredRouteRunEnvelope = FMath::Min( + VoxelPassageGeometry::TunnelFloorMaximumRouteLengthVoxels, + MaxFloorDeltaEnvelope / FMath::Max( + GentleSlopeThreshold, KINDA_SMALL_NUMBER)); const float OrganicLateralEnvelope = FMath::Min( FMath::Abs(Params.TunnelWarpStrength), TunnelLengthBound * 0.25f); const float RouteLateralEnvelope = FMath::Max( @@ -7023,7 +6862,6 @@ void VoxelCaveMorphology::BuildChunkCache( CT.bHasFloorRoomOwnership = true; CT.FloorRoomHashA = RoomA.Hash; CT.FloorRoomHashB = RoomB.Hash; - CT.bTunnelFloorEnabled = Params.bTunnelFloorEnabled; CT.bTunnelFloorTerracingEnabled = Params.bTunnelFloorTerracingEnabled; CT.TunnelFloorTerraceStepHeight = Params.TunnelFloorTerraceStepHeight; CT.TunnelFloorMaxLedgeHeight = Params.TunnelFloorMaxLedgeHeight; @@ -7067,8 +6905,7 @@ void VoxelCaveMorphology::BuildChunkCache( const uint32 LedgeChoiceHash = VoxelHash::Mix( TunnelHash ^ 0xC1A0E5EDu); constexpr float DramaticLedgeChance = 0.35f; - const bool bDramaticLedge = Params.bTunnelFloorEnabled - && Params.bTunnelFloorTerracingEnabled + const bool bDramaticLedge = Params.bTunnelFloorTerracingEnabled && bDirectSteep && bGraphLedgeEligible && VoxelHash::ToFloat01(LedgeChoiceHash) < DramaticLedgeChance; @@ -7116,8 +6953,7 @@ void VoxelCaveMorphology::BuildChunkCache( // supplies the horizontal run for the authored floor plane and keeps the room-to-room // graph connected. The rings observed in the owner asset came from the separate // world-core handoff exposing span joins, not from this smooth route decision. - const bool bNeedsWind = Params.bTunnelFloorEnabled - && bDirectSteep + const bool bNeedsWind = bDirectSteep && !bDramaticLedge && GentleThreshold > KINDA_SMALL_NUMBER; CT.bFloorRouteWasWound = bNeedsWind; @@ -7984,7 +7820,8 @@ float VoxelCaveMorphology::EvaluateSDFCached( const FChunkSDFCache& Cache, float SDFBlendRadius, int32* OutNearestRoomIdx, - bool bUseSpatialIndex) + bool bUseSpatialIndex, + const FVector* WorldTunnelPosition) { VoxelDensityProfile::FScopedTimer ProfileTimer( VoxelDensityProfile::EBucket::RoomGraphSdf); @@ -8144,20 +7981,41 @@ float VoxelCaveMorphology::EvaluateSDFCached( //========================================================================= // Tunnel SDFs //========================================================================= + // Keep the tunnel source in the authored world frame when the caller supplies it. Rooms + // intentionally remain at the warped query position; the world-chain tunnel is the same + // continuous field used by the core hand-off below, so the source cannot form a second, + // differently-shaped surface around the route. + const bool bUseWorldTunnel = WorldTunnelPosition != nullptr; + const FVector TunnelPosition = bUseWorldTunnel ? *WorldTunnelPosition : Pos; auto EvaluateTunnel = [&](int32 TunnelIdx) { const FCachedTunnel& Tunnel = TunnelData[TunnelIdx]; - if (Tunnel.SDFInfluenceRadius > 0.0f + const bool bHasWorldChain = Tunnel.WorldControlPoints.Num() >= 2 + && Tunnel.WorldControlRadii.Num() == Tunnel.WorldControlPoints.Num(); + const bool bUseWorldChain = bUseWorldTunnel && bHasWorldChain; + const FVector& QueryPosition = bUseWorldChain ? TunnelPosition : Pos; + const FVector& CenterlineMin = bUseWorldChain + ? Tunnel.WorldCenterlineMin : Tunnel.SDFCenterlineMin; + const FVector& CenterlineMax = bUseWorldChain + ? Tunnel.WorldCenterlineMax : Tunnel.SDFCenterlineMax; + const float InfluenceRadius = bUseWorldChain + ? Tunnel.WorldInfluenceRadius : Tunnel.SDFInfluenceRadius; + const FVector& BoundCenter = bUseWorldChain + ? Tunnel.WorldBoundCenter : Tunnel.BoundCenter; + const float BoundRadiusSq = bUseWorldChain + ? Tunnel.WorldBoundRadiusSq : Tunnel.BoundRadiusSq; + if (InfluenceRadius > 0.0f && VF_DistanceSquaredToAabb( - WorldX, WorldY, WorldZ, - Tunnel.SDFCenterlineMin, Tunnel.SDFCenterlineMax) - > FMath::Square(Tunnel.SDFInfluenceRadius)) + QueryPosition.X, QueryPosition.Y, QueryPosition.Z, + CenterlineMin, CenterlineMax) + > FMath::Square(InfluenceRadius)) { return; } // --- BOUNDING SPHERE CULL --- - const float DistSq = VF_FloatDistSquared(WorldX, WorldY, WorldZ, Tunnel.BoundCenter); - if (DistSq > Tunnel.BoundRadiusSq) return; + const float DistSq = VF_FloatDistSquared( + QueryPosition.X, QueryPosition.Y, QueryPosition.Z, BoundCenter); + if (DistSq > BoundRadiusSq) return; if (VoxelDensityProfile::AreCountersEnabled()) { VoxelDensityProfile::AddCounter( @@ -8168,11 +8026,12 @@ float VoxelCaveMorphology::EvaluateSDFCached( // 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 FVFRoomFloorOwnership RoomMouthOwnership = bUseWorldChain + ? VF_FindWorldTunnelMouthOwnership(QueryPosition, Tunnel, BlendK) + : VF_FindSDFTunnelMouthOwnership(Pos, Tunnel, BlendK); const float TunnelSDF = VF_EvaluateSweptTunnel( - Pos, Tunnel, /*bWorldChain=*/false, BlendK, - /*bApplyFloorCut=*/Tunnel.bTunnelFloorEnabled, &Cache, TunnelIdx, + QueryPosition, Tunnel, bUseWorldChain, BlendK, + /*bApplyFloorCut=*/true, &Cache, TunnelIdx, &RoomMouthOwnership).SDF; MinSDF = VoxelSDF::SmoothMin(MinSDF, TunnelSDF, BlendK); @@ -8182,7 +8041,8 @@ float VoxelCaveMorphology::EvaluateSDFCached( if (!VoxelDensityAblation::IsTunnelSDFOff()) { TunnelCandidateCount = VF_ForEachSpatialCandidate( - Cache.TunnelSpatialIndex, Cache.Tunnels.Num(), WorldX, WorldY, EvaluateTunnel, + Cache.TunnelSpatialIndex, Cache.Tunnels.Num(), TunnelPosition.X, TunnelPosition.Y, + EvaluateTunnel, bUseSpatialIndex); } if (VoxelDensityProfile::AreCountersEnabled()) @@ -8260,7 +8120,14 @@ FTunnelCoreWorldEvaluation VoxelCaveMorphology::EvaluateTunnelCoreWorld( // GenerateMesh installs the exact tile/block reach mask after building the complete tile // cache. Point queries retain the canonical all-reachable TLS default; debug proof calls set // the bypass bit so the same sample can be evaluated as the reference field. - if (!VoxelGenLOD::IsTilePostReachBypassActive() + // A reach mask is meaningful only inside the mesher's proven tile session. Point queries + // (including the owner-value probe and gameplay queries) must retain the canonical core even + // if another caller left a conservative flag in this worker's TLS. The mesher enables the + // proof bit only after it has installed the exact tile-window decision, so this guard cannot + // turn an ordinary query into a skipped core. + if (VoxelGenLOD::bTileCoreReachProofEnabled + && VoxelGenLOD::bTileReachDiagnosticTile + && !VoxelGenLOD::IsTilePostReachBypassActive() && !VoxelGenLOD::IsTunnelCoreReachable()) { return FTunnelCoreWorldEvaluation(); @@ -8328,73 +8195,23 @@ FTunnelCoreWorldEvaluation VoxelCaveMorphology::EvaluateTunnelCoreWorld( const FVFRoomFloorOwnership RoomMouthOwnership = VF_FindWorldTunnelMouthOwnership(Pos, Tunnel, Cache.SDFBlendRadius); - const bool bRoomOwnsThisTunnel = RoomMouthOwnership.bValid; - bool bRaisedTunnelFloor = false; - // The world chain is used only for its finite support-floor ownership band. Its air - // surface follows a continuous sweep, while the ordinary cave-warped chain remains the - // authoritative carved tunnel. Evaluating a second per-span capsule union creates a - // segmented zero set at every control-span join. - if (bHasWorldChain && Tunnel.bTunnelFloorEnabled) - { - if (SupportColumn != nullptr && VoxelDensityProfile::AreCountersEnabled()) - { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::TunnelSupportFloorChecks); - } - float FloorZ = 0.0f; - float SupportRadius = 0.0f; - float SupportMinZ = 0.0f; - float SupportMaxZ = 0.0f; - const bool bProjectedFloor = SupportColumn != nullptr - ? VoxelCaveMorphology::GetTunnelSupportFloorColumnBand( - TunnelIdx, *SupportColumn, FloorZ, SupportMinZ, SupportMaxZ) - : VF_ProjectSweptTunnelFloor( - Pos, Tunnel, /*bWorldChain=*/true, FloorZ, SupportRadius, - &Cache, TunnelIdx, &RoomMouthOwnership); - bRaisedTunnelFloor = bRoomOwnsThisTunnel - && bProjectedFloor - && FloorZ > RoomMouthOwnership.FloorZ - + KINDA_SMALL_NUMBER; - if (bRaisedTunnelFloor - && WorldZ <= RoomMouthOwnership.FloorZ - + VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels) - { - if (WorldZ > RoomMouthOwnership.FloorZ - - VoxelPassageGeometry::LandingFloorThicknessVoxels) - { - Result.bRoomFloor = true; - } - // A raised tunnel apron is the artifact being clipped. Keep the room floor in - // its finite support band and do not reopen the room below it. - return; - } - if (!bRaisedTunnelFloor - && bProjectedFloor - && WorldZ <= FloorZ - + VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels - && (SupportColumn == nullptr - ? WorldZ >= FloorZ - - VoxelPassageGeometry::LandingFloorThicknessVoxels - : WorldZ >= SupportMinZ && WorldZ <= SupportMaxZ)) - { - Result.bSupportFloor = true; - } - if (!bRaisedTunnelFloor - && bProjectedFloor && WorldZ <= FloorZ - + VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels) - { - // Preserve the established ownership contract while the SDF-space evaluator is - // still raw: the floor owns the lower half of the corridor, and the capsule owns - // only the air above it. This is a shape-level ownership hand-off, not a later slab. - return; - } - } + // The world chain is the authored tunnel shape. Its floor is evaluated by the same + // SmoothMax profile as the cave-warped chain; there is no second support-column field and + // no hard floor hand-off at a segment boundary. Keep the legacy argument source-compatible + // for callers compiled against the previous probe API, but deliberately ignore it. + (void)SupportColumn; const FVFTunnelShapeEvaluation TunnelShape = VF_EvaluateSweptTunnel( Pos, Tunnel, bHasWorldChain, Cache.SDFBlendRadius, - /*bApplyFloorCut=*/Tunnel.bTunnelFloorEnabled && !bRaisedTunnelFloor, + /*bApplyFloorCut=*/true, &Cache, TunnelIdx, &RoomMouthOwnership); - Result.SDF = FMath::Min(Result.SDF, TunnelShape.SDF); + if (TunnelShape.SDF < Result.SDF) + { + Result.SDF = TunnelShape.SDF; + Result.bHasSweptFloor = TunnelShape.bHasSweptFloor; + Result.SweptFloorZ = TunnelShape.SweptFloorZ; + Result.SweptFloorRadius = TunnelShape.SweptFloorRadius; + } }; const int32 TunnelCandidateCount = VF_ForEachSpatialCandidate( @@ -8604,8 +8421,7 @@ bool VoxelCaveMorphology::AnyTunnelCoreWorldNearLattice( float ProfileFloorDelta = 0.0f; const TArray& Profiles = bHasWorldChain ? Tunnel.WorldFloorProfiles : Tunnel.FloorProfiles; - if (Tunnel.bTunnelFloorEnabled - && Profiles.Num() == ChainPoints.Num() - 1) + if (Profiles.Num() == ChainPoints.Num() - 1) { for (int32 SegmentIndex = 0; SegmentIndex < Profiles.Num(); ++SegmentIndex) @@ -8632,11 +8448,9 @@ bool VoxelCaveMorphology::AnyTunnelCoreWorldNearLattice( } if (bHasTightChain) { - const float FloorPad = Tunnel.bTunnelFloorEnabled - ? FMath::Max( - VoxelPassageGeometry::LandingFloorThicknessVoxels, - FMath::Max(Cache.SDFBlendRadius, 0.0f) * 0.35f) - : 0.0f; + const float FloorPad = FMath::Max( + VoxelPassageGeometry::LandingFloorThicknessVoxels, + FMath::Max(Cache.SDFBlendRadius, 0.0f) * 0.35f); const float SegmentPad = MaxRadius + FloorPad + ProfileFloorDelta + ReliefBound; if (!VoxelMath::IsFinite(SegmentPad)) @@ -8667,8 +8481,8 @@ bool VoxelCaveMorphology::AnyTunnelCoreWorldNearLattice( } } - if (!bTightTouch && Tunnel.bTunnelFloorEnabled - && bHasWorldChain && Tunnel.bHasFloorRoomOwnership) + if (!bTightTouch && bHasWorldChain + && Tunnel.bHasFloorRoomOwnership) { const float MouthHorizontal = MouthReach; const float MouthVertical = FMath::Max( @@ -8792,7 +8606,7 @@ bool VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( return VF_EvaluateSweptTunnel( Pos, Tunnel, bHasWorldChain, Cache.SDFBlendRadius, - /*bApplyFloorCut=*/Tunnel.bTunnelFloorEnabled, &Cache, TunnelIdx, + /*bApplyFloorCut=*/true, &Cache, TunnelIdx, &RoomMouthOwnership).bSupportFloor; }; diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 0b43b28..264e9e4 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -3748,110 +3748,6 @@ namespace FVector LastTunnelCoreEvaluationPosition = FVector::ZeroVector; bool bLastTunnelCoreWorldEvaluationValid = false; - // Support-floor projection depends only on XY for one immutable SDF cache. The - // mesher traverses Z outside X/Y, so keep a small worker-local LRU of XY boxes rather - // than only the immediately previous column; each integer column is then projected - // once and both structural posts reuse its interval for every Z sample. - struct FSupportColumnBox - { - static constexpr int32 Halo = CHUNK_SIZE + 8; - static constexpr int32 Dim = 2 * Halo + 1; - - const FChunkSDFCache* Cache = nullptr; - int32 BaseX = 0; - int32 BaseY = 0; - uint32 LastUse = 0; - bool bValid = false; - TArray SlotToEntry; - TArray Columns; - - void Clear() - { - Cache = nullptr; - bValid = false; - LastUse = 0; - SlotToEntry.Reset(); - Columns.Reset(); - } - - FTunnelSupportFloorColumn* AcquireColumn( - int32 X, int32 Y, const FChunkSDFCache* InCache, - uint32 InUse, bool& bOutNew) - { - bOutNew = false; - if (!bValid || Cache != InCache - || X < BaseX || X >= BaseX + Dim - || Y < BaseY || Y >= BaseY + Dim) - { - Cache = InCache; - BaseX = X - Halo; - BaseY = Y - Halo; - LastUse = InUse; - bValid = true; - SlotToEntry.Init(INDEX_NONE, Dim * Dim); - Columns.Reset(); - } - else - { - LastUse = InUse; - } - - const int32 Slot = (Y - BaseY) * Dim + (X - BaseX); - int32& EntryIndex = SlotToEntry[Slot]; - if (EntryIndex == INDEX_NONE) - { - EntryIndex = Columns.AddDefaulted(); - bOutNew = true; - } - return &Columns[EntryIndex]; - } - }; - - struct FSupportColumnCache - { - static constexpr int32 NumBoxes = 4; - FSupportColumnBox Boxes[NumBoxes]; - uint32 Clock = 0; - - void Invalidate() - { - for (FSupportColumnBox& Box : Boxes) - { - Box.Clear(); - } - Clock = 0; - } - - FSupportColumnBox& AcquireBox( - int32 X, int32 Y, const FChunkSDFCache* Cache) - { - ++Clock; - for (FSupportColumnBox& Box : Boxes) - { - if (Box.bValid && Box.Cache == Cache - && X >= Box.BaseX && X < Box.BaseX + FSupportColumnBox::Dim - && Y >= Box.BaseY && Y < Box.BaseY + FSupportColumnBox::Dim) - { - Box.LastUse = Clock; - return Box; - } - } - - FSupportColumnBox* Victim = &Boxes[0]; - for (FSupportColumnBox& Box : Boxes) - { - if (!Box.bValid || Box.LastUse < Victim->LastUse) - { - Victim = &Box; - } - } - Victim->Clear(); - return *Victim; - } - } SupportColumns; - - FTunnelSupportFloorColumn FractionalSupportColumn; - /** É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 @@ -3906,49 +3802,6 @@ namespace S.bLocalParamsValid = false; } - void BuildSupportColumn( - float WorldX, float WorldY, - const FChunkSDFCache& Cache, - FTunnelSupportFloorColumn& OutColumn) const - { - VoxelCaveMorphology::BuildTunnelSupportFloorColumn( - WorldX, WorldY, Cache, OutColumn, - VoxelGenLOD::ShouldUseSpatialIndex(false)); - } - - const FTunnelSupportFloorColumn* GetSupportColumn(const FVector& Position) const - { - FState& S = State(); - const bool bIntegerXY = - Position.X == FMath::FloorToFloat(static_cast(Position.X)) - && Position.Y == FMath::FloorToFloat(static_cast(Position.Y)); - if (bIntegerXY) - { - const int32 IX = FMath::FloorToInt(static_cast(Position.X)); - const int32 IY = FMath::FloorToInt(static_cast(Position.Y)); - const FChunkSDFCache& Cache = GetCache(); - FState::FSupportColumnBox& Box = S.SupportColumns.AcquireBox( - IX, IY, &Cache); - bool bNewColumn = false; - FTunnelSupportFloorColumn* Column = Box.AcquireColumn( - IX, IY, &Cache, S.SupportColumns.Clock, bNewColumn); - if (bNewColumn) - { - BuildSupportColumn( - static_cast(Position.X), - static_cast(Position.Y), - Cache, - *Column); - } - return Column; - } - - BuildSupportColumn( - static_cast(Position.X), static_cast(Position.Y), - GetCache(), S.FractionalSupportColumn); - return &S.FractionalSupportColumn; - } - bool TryGetLastTunnelCoreWorldEvaluation( FTunnelCoreWorldEvaluation& OutEvaluation) const override { @@ -3971,9 +3824,8 @@ namespace // demand, publish the one combined result here so the tail can reuse it without // building/scanning a second native cache. const FVector& Position = S.LastWorldPosition; - const FTunnelSupportFloorColumn* SupportColumn = GetSupportColumn(Position); S.LastTunnelCoreWorldEvaluation = VoxelCaveMorphology::EvaluateTunnelCoreWorld( - Position.X, Position.Y, Position.Z, GetCache(), SupportColumn, + Position.X, Position.Y, Position.Z, GetCache(), nullptr, VoxelGenLOD::ShouldUseSpatialIndex(false)); S.LastTunnelCoreEvaluationPosition = Position; S.bLastTunnelCoreWorldEvaluationValid = true; @@ -4093,7 +3945,6 @@ namespace S.Cache = FChunkSDFCache(); S.SharedCacheEntry.Reset(); S.ActiveCache = &S.Cache; - S.SupportColumns.Invalidate(); InOut.Sdf = FLT_MAX; return; } @@ -4102,7 +3953,6 @@ namespace { S.SharedCacheEntry.Reset(); S.ActiveCache = &S.Cache; - S.SupportColumns.Invalidate(); return; // Sdf reste FLT_MAX } @@ -4258,8 +4108,6 @@ namespace + (bUseTileCacheWindow ? static_cast(SampleStep) : 0.0f) + 2.0f; - const FChunkSDFCache* PreviousActiveCache = S.ActiveCache; - bool bRebuiltLocalCache = false; if (SampleStep > 1) { const bool bSharedCacheMatches = @@ -4308,7 +4156,6 @@ namespace && LocalCache->RegionSize == RegionSize; if (!bCacheMatches) { - bRebuiltLocalCache = true; LocalCache->bValid = false; LocalCache->Seed = SeedU; LocalCache->StrateIndex = StrateIdx; @@ -4332,15 +4179,11 @@ namespace } S.ActiveCache = &LocalCache->Cache; } - if (bRebuiltLocalCache || S.ActiveCache != PreviousActiveCache) - { - S.SupportColumns.Invalidate(); - } - const bool bUseSpatialIndex = VoxelGenLOD::ShouldUseSpatialIndex(false); + const FVector WorldTunnelPosition(WorldX, WorldY, WorldZ); float CaveSDF = VoxelCaveMorphology::EvaluateSDFCached( WarpedX, WarpedY, WarpedZ, GetCache(), P.SDFBlendRadius, - &S.NearestRoom, bUseSpatialIndex); + &S.NearestRoom, bUseSpatialIndex, &WorldTunnelPosition); //--------------------------------------------------------------- // PITS & CHEMINÉES — coordonnées RÉELLES, SmoothMin dans le même canal SDF @@ -4424,45 +4267,31 @@ 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. + // Publish the same continuous tunnel-core field that the common generator tail will + // reassert after generic writers. The stack does not publish or protect a separate + // support slab. if (!VoxelDensityAblation::IsTunnelCoreOff()) { - const FTunnelSupportFloorColumn* SupportColumn = GetSupportColumn( - S.LastWorldPosition); S.LastTunnelCoreWorldEvaluation = VoxelCaveMorphology::EvaluateTunnelCoreWorld( - WorldX, WorldY, WorldZ, GetCache(), SupportColumn, + WorldX, WorldY, WorldZ, GetCache(), nullptr, bUseSpatialIndex); 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 (VoxelMath::IsFinite(StructuralFloorMinimumDensity) - && StructuralFloorMinimumDensity > 0.0f) - { - InOut.bProtectedStructuralFloor = true; - InOut.StructuralFloorMinimumDensity = StructuralFloorMinimumDensity; - if (VoxelDensityProfile::AreCountersEnabled()) - { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::TunnelAuthoredFloorSamples); - } - } - } - // These are the per-sample hand-offs needed by an op-major block. The scalar path - // continues to use FState directly; publishing them here only makes the dependency - // explicit for a later modifier and does not alter Density/Sdf. + // Keep the hand-off for block evaluation compatibility. Both floor flags are + // permanently false; the authored floor is part of the tunnel shape itself. InOut.bHasTunnelCoreWorldEvaluation = true; InOut.TunnelCoreWorldSDF = S.LastTunnelCoreWorldEvaluation.SDF; - InOut.bTunnelCoreSupportFloor = S.LastTunnelCoreWorldEvaluation.bSupportFloor; - InOut.bTunnelCoreRoomFloor = S.LastTunnelCoreWorldEvaluation.bRoomFloor; + InOut.bTunnelCoreSupportFloor = false; + InOut.bTunnelCoreRoomFloor = false; + InOut.bHasTunnelCoreSweptFloor = + S.LastTunnelCoreWorldEvaluation.bHasSweptFloor; + InOut.TunnelCoreSweptFloorZ = + S.LastTunnelCoreWorldEvaluation.SweptFloorZ; + InOut.TunnelCoreSweptFloorRadius = + S.LastTunnelCoreWorldEvaluation.SweptFloorRadius; + InOut.bProtectedStructuralFloor = false; + InOut.StructuralFloorMinimumDensity = 0.0f; } // These cache hand-offs remain valid when tunnel core is off; detail operators still @@ -6384,8 +6213,8 @@ namespace // A post-air sample must lie within a tapered capsule's maximum radius of one // world-chain segment. The segment AABB expanded by that radius is a cheap // superset of the exact capsule; unlike the old enclosing sphere it rejects a - // long, thin tunnel from most unrelated LOD0 boxes. Support uses its own - // projected-floor AABB, because AllAir is killed by the finite floor band. + // long, thin tunnel from most unrelated LOD0 boxes. The floor is part of this + // same continuous field, so no second support-band candidate is needed. if (Ctx.bUseLatticeProof) { const float WorldLatticeOriginX = @@ -6441,53 +6270,8 @@ namespace Result |= 1u; } - if (bWorldChain) - { - const float NaturalFloorA = VoxelPassageGeometry::TunnelFloorZ( - A, SafeRadiusA); - const float NaturalFloorB = VoxelPassageGeometry::TunnelFloorZ( - BPoint, SafeRadiusB); - float FloorMin = FMath::Min(NaturalFloorA, NaturalFloorB); - float FloorMax = FMath::Max(NaturalFloorA, NaturalFloorB); - const TArray* FloorProfiles = - &Tunnel.WorldFloorProfiles; - if (FloorProfiles->Num() == Tunnel.WorldControlPoints.Num() - 1 - && SegmentIndex >= 0 - && SegmentIndex < FloorProfiles->Num()) - { - // The interval proof must include an explicit ledge's vertical - // riser. The profile transition is evaluated at the segment - // boundary, so its endpoint interval is a conservative superset - // of both plateau sides and remains valid for the lattice proof. - const FTunnelFloorSegmentProfile& Profile = - (*FloorProfiles)[SegmentIndex]; - FloorMin = FMath::Min(FloorMin, FMath::Min( - Profile.StartFloorZ, Profile.EndFloorZ)); - FloorMax = FMath::Max(FloorMax, FMath::Max( - Profile.StartFloorZ, Profile.EndFloorZ)); - } - const float SupportRadius = FMath::Max( - FMath::Min(SafeRadiusA, SafeRadiusB) - 0.5f, - VoxelPassageGeometry::PlayerRadiusVoxels); - if (!Finite(FloorMin) || !Finite(FloorMax) - || !Finite(SupportRadius)) - { - Result = 3u; - return; - } - if (TouchesLatticeAABB( - FMath::Min(A.X, BPoint.X) - SupportRadius, - FMath::Max(A.X, BPoint.X) + SupportRadius, - FMath::Min(A.Y, BPoint.Y) - SupportRadius, - FMath::Max(A.Y, BPoint.Y) + SupportRadius, - FloorMin - - VoxelPassageGeometry::LandingFloorThicknessVoxels, - FloorMax - + VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels)) - { - Result |= 2u; - } - } + (void)bWorldChain; + (void)SegmentIndex; }; if (bHasWorldChain) @@ -6607,8 +6391,6 @@ namespace const uint8 TailMask = MayHaveTunnelCoreTail(Tunnel); B.bMayHaveTunnelCoreAir = B.bMayHaveTunnelCoreAir || (TailMask & 1u) != 0; - B.bMayHaveTunnelSupportFloor = B.bMayHaveTunnelSupportFloor - || (TailMask & 2u) != 0; B.bMayHaveTunnelCoreTail = B.bMayHaveTunnelCoreTail || TailMask != 0u; float MaxRadius = FMath::Max( @@ -6750,18 +6532,15 @@ namespace CountThreshold(TunnelLowerNoWarp, B.HitTunnelsNoWarp); } - if ((B.bMayHaveTunnelCoreAir || B.bMayHaveTunnelSupportFloor) + if (B.bMayHaveTunnelCoreAir && bUseLatticeProof && ExactLatticeWorldQueries.Num() > 0) { // The bound test above is only a broad candidate. The actual post is a - // discrete writer: it opens a point when the world-core SDF is below the inset - // without a support floor, or makes it solid in the support-floor band. Keep the - // two polarities separate: an AllSolid box only needs air, while an AllAir box - // only needs support. This is still a proof of the final lattice, never a density - // shortcut; if an exact query is invalid, keep both conservative candidates. + // continuous writer: it opens a point when the world-core SDF is negative. This + // is still a proof of the final lattice, never a density shortcut; if an exact + // query is invalid, keep the conservative candidate. bool bExactAir = false; - bool bExactSupport = false; bool bExactTailUnknown = false; TArray> ExactTailDomains; bool bExactTailDomainCullSafe = true; @@ -6799,7 +6578,7 @@ namespace VoxelBox.Min, VoxelBox.Max, [&](const FVector&, const FVector& World) { - if ((bExactAir && bExactSupport) || bExactTailUnknown) return; + if (bExactAir || bExactTailUnknown) return; ++B.ExactTailQueries; if (bExactTailDomainCullSafe && ExactTailDomains.Num() > 0) { @@ -6853,33 +6632,15 @@ namespace } else { - bExactSupport |= (State & 0x02u) != 0; bExactAir |= (State & 0x01u) != 0; } return; } - FTunnelSupportFloorColumn EmptySupportColumn; - const FTunnelSupportFloorColumn* SupportColumn = nullptr; - const bool bIntegerXY = - World.X == FMath::FloorToFloat(static_cast(World.X)) - && World.Y == FMath::FloorToFloat(static_cast(World.Y)); - if (bIntegerXY) - { - SupportColumn = GetSupportColumn(World); - } - else - { - VoxelCaveMorphology::BuildTunnelSupportFloorColumn( - static_cast(World.X), static_cast(World.Y), - GetBoxCache(), EmptySupportColumn, - VoxelGenLOD::ShouldUseSpatialIndex(false)); - SupportColumn = &EmptySupportColumn; - } const FTunnelCoreWorldEvaluation Evaluation = VoxelCaveMorphology::EvaluateTunnelCoreWorld( static_cast(World.X), static_cast(World.Y), - static_cast(World.Z), GetBoxCache(), SupportColumn, + static_cast(World.Z), GetBoxCache(), nullptr, VoxelGenLOD::ShouldUseSpatialIndex(false)); ++B.ExactTailEvaluated; if (!VoxelMath::IsFinite(Evaluation.SDF)) @@ -6889,15 +6650,12 @@ namespace } else { - const bool bAir = !Evaluation.bSupportFloor - && Evaluation.SDF - < -VoxelPassageGeometry::CaveTunnelAirCoreInsetVoxels; - bExactSupport |= Evaluation.bSupportFloor; + const bool bAir = Evaluation.SDF < 0.0f; bExactAir |= bAir; if (CachedTailState != nullptr) { *CachedTailState = static_cast( - 0x80u | (Evaluation.bSupportFloor ? 0x02u : 0u) + 0x80u | (bAir ? 0x01u : 0u)); } } @@ -6906,8 +6664,8 @@ namespace if (!bExactTailUnknown) { B.bMayHaveTunnelCoreAir = bExactAir; - B.bMayHaveTunnelSupportFloor = bExactSupport; - B.bMayHaveTunnelCoreTail = bExactAir || bExactSupport; + B.bMayHaveTunnelSupportFloor = false; + B.bMayHaveTunnelCoreTail = bExactAir; } } diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index d823049..b94a478 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -1021,8 +1021,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, bool bProtectVerticalShaftAir, - bool bProtectAuthoredTunnelFloor) + const FStrateDisturbanceParams& D, uint32 Seed, bool bProtectVerticalShaftAir) { if (VoxelDensityAblation::IsDisturbancesOff()) { @@ -1034,20 +1033,6 @@ 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); @@ -2301,7 +2286,6 @@ namespace uint64 ManagerLifetimeId = 0; FIntVector Chunk = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); FIntVector TileOrigin = FIntVector::ZeroValue; - FIntVector SupportChunk = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); int32 TileStep = 0; int32 TileCells = 0; int32 StrateIndex = INT32_MIN; @@ -2314,17 +2298,6 @@ namespace static thread_local FTunnelCoreCacheState GTunnelCoreCache; - static void BuildTunnelSupportColumnsForChunk( - FChunkSDFCache& Cache, - const FIntVector& ChunkCoord, - bool bUseSpatialIndex); - - static const FTunnelSupportFloorColumn* FindTunnelSupportColumn( - const FChunkSDFCache& Cache, - int32 X, - int32 Y, - const FTunnelSupportFloorColumn& EmptyColumn); - // A mesher tile samples an expanded grid (-1..Cells+1), so one worker visits several exact // chunk coordinates even while it is generating one tile. The old CP_* cache retained only // the last coordinate and consequently rebuilt the whole TunnelNetwork stack whenever the @@ -2507,15 +2480,6 @@ namespace && OutState.TileCells == RequestedTileCells)); if (bSameGraphWindow) { - // The graph is shared by the whole tile, but the sparse support table is deliberately - // kept for the currently queried exact chunk. Rebuild only that small table when the - // density cursor crosses a chunk; the room/tunnel graph itself is not rebuilt. - if (OutState.SupportChunk != ChunkCoord) - { - BuildTunnelSupportColumnsForChunk( - OutState.Cache, ChunkCoord, bUseSpatialIndex); - OutState.SupportChunk = ChunkCoord; - } OutState.Chunk = ChunkCoord; return; } @@ -2552,8 +2516,6 @@ namespace StrateIndex, nullptr, ERoomGraphBuildSite::GeneratorTunnelCore); UpdateTunnelCoreTileReach(OutState.Cache, bUseTileCacheWindow); - BuildTunnelSupportColumnsForChunk( - OutState.Cache, ChunkCoord, bUseSpatialIndex); OutState.OwnerId = OwnerId; OutState.ManagerLifetimeId = ManagerLifetimeId; OutState.Chunk = ChunkCoord; @@ -2561,7 +2523,6 @@ namespace ? RequestedTileOrigin : FIntVector::ZeroValue; OutState.TileStep = bUseTileCacheWindow ? RequestedTileStep : 0; OutState.TileCells = bUseTileCacheWindow ? RequestedTileCells : 0; - OutState.SupportChunk = ChunkCoord; OutState.StrateIndex = StrateIndex; OutState.WorldSeed = WorldSeed; OutState.ParamsFingerprint = ParamsFingerprint; @@ -2570,69 +2531,6 @@ namespace OutState.bValid = true; } - static void BuildTunnelSupportColumnsForChunk( - FChunkSDFCache& Cache, - const FIntVector& ChunkCoord, - bool bUseSpatialIndex) - { - const int32 MinX = ChunkCoord.X * CHUNK_SIZE - 1; - const int32 MinY = ChunkCoord.Y * CHUNK_SIZE - 1; - const int32 Cells = CHUNK_SIZE + 2; - Cache.SupportColumnMinX = MinX; - Cache.SupportColumnMinY = MinY; - Cache.SupportColumnCellsX = Cells; - Cache.SupportColumnCellsY = Cells; - Cache.SupportColumnEntries.Init(INDEX_NONE, Cells * Cells); - Cache.SupportColumns.Reset(); - - // Empty columns are represented only by INDEX_NONE in the slot map, so - // each stored column retains the inline bands for actual graph tubes. - for (int32 Y = 0; Y < Cells; ++Y) - { - for (int32 X = 0; X < Cells; ++X) - { - FTunnelSupportFloorColumn Column; - VoxelCaveMorphology::BuildTunnelSupportFloorColumn( - static_cast(MinX + X), - static_cast(MinY + Y), - Cache, - Column, - bUseSpatialIndex); - if (Column.Intervals.Num() == 0) - { - continue; - } - - const int32 EntryIndex = Cache.SupportColumns.Add(MoveTemp(Column)); - Cache.SupportColumnEntries[Y * Cells + X] = EntryIndex; - } - } - } - - static const FTunnelSupportFloorColumn* FindTunnelSupportColumn( - const FChunkSDFCache& Cache, - int32 X, - int32 Y, - const FTunnelSupportFloorColumn& EmptyColumn) - { - if (Cache.SupportColumnCellsX <= 0 - || X < Cache.SupportColumnMinX - || X >= Cache.SupportColumnMinX + Cache.SupportColumnCellsX - || Y < Cache.SupportColumnMinY - || Y >= Cache.SupportColumnMinY + Cache.SupportColumnCellsY) - { - return nullptr; - } - - const int32 Slot = - (Y - Cache.SupportColumnMinY) * Cache.SupportColumnCellsX - + (X - Cache.SupportColumnMinX); - const int32 EntryIndex = Cache.SupportColumnEntries[Slot]; - return EntryIndex == INDEX_NONE - ? &EmptyColumn - : &Cache.SupportColumns[EntryIndex]; - } - struct FVoxelStackParamRefs { const FSlabGenerationParams* Slab = nullptr; @@ -2842,7 +2740,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co VoxelDensityProfile::EBucket::DensityPrologue); // ── STRATE SYSTEM ── // Query per-chunk params from the manager so each strate has different caves. - float Result; + float Result = 0.0f; // 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. @@ -3391,6 +3289,9 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co float BlockTunnelCoreSDF = FLT_MAX; bool bBlockTunnelCoreSupportFloor = false; bool bBlockTunnelCoreRoomFloor = false; + bool bBlockHasTunnelCoreSweptFloor = false; + float BlockTunnelCoreSweptFloorZ = -FLT_MAX; + float BlockTunnelCoreSweptFloorRadius = 0.0f; bool bUsedFusedEvaluator = false; FTunnelCoreWorldEvaluation FusedTunnelCore; @@ -3457,6 +3358,12 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co BlockTunnelCoreSDF = BlockCoreSample.TunnelCoreWorldSDF; bBlockTunnelCoreSupportFloor = BlockCoreSample.bTunnelCoreSupportFloor; bBlockTunnelCoreRoomFloor = BlockCoreSample.bTunnelCoreRoomFloor; + bBlockHasTunnelCoreSweptFloor = + BlockCoreSample.bHasTunnelCoreSweptFloor; + BlockTunnelCoreSweptFloorZ = + BlockCoreSample.TunnelCoreSweptFloorZ; + BlockTunnelCoreSweptFloorRadius = + BlockCoreSample.TunnelCoreSweptFloorRadius; } } if (!bUsedFusedEvaluator) @@ -3550,41 +3457,30 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co PreDisturbanceTunnelCore.SDF = BlockTunnelCoreSDF; PreDisturbanceTunnelCore.bSupportFloor = bBlockTunnelCoreSupportFloor; PreDisturbanceTunnelCore.bRoomFloor = bBlockTunnelCoreRoomFloor; + PreDisturbanceTunnelCore.bHasSweptFloor = + bBlockHasTunnelCoreSweptFloor; + PreDisturbanceTunnelCore.SweptFloorZ = BlockTunnelCoreSweptFloorZ; + PreDisturbanceTunnelCore.SweptFloorRadius = + BlockTunnelCoreSweptFloorRadius; bHavePreDisturbanceTunnelCore = true; } else { - bHavePreDisturbanceTunnelCore = ActiveOpStack->TryGetLastTunnelCoreWorldEvaluation( + bHavePreDisturbanceTunnelCore = ActiveOpStack->TryGetLastTunnelCoreWorldEvaluation( PreDisturbanceTunnelCore); } } if (!VoxelDensityAblation::IsTunnelCoreOff() && !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, + WorldX, WorldY, WorldZ, ActiveTunnelCoreCache->Cache, nullptr, VoxelGenLOD::ShouldUseSpatialIndex( CP_UseOpStack ? bUsedFusedEvaluator : true)); 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 @@ -3594,7 +3490,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, bProtectAuthoredTunnelFloor); + bProtectVerticalShaftAir); DensityDisturbancesTimer.End(); // A disturbance is allowed to add visual detail, but it must not refill the landing's @@ -3670,6 +3566,27 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co break; } } + auto ApplyTunnelCoreShape = [&](const FTunnelCoreWorldEvaluation& Core) + { + if (!VoxelMath::IsFinite(Core.SDF)) + { + return; + } + const bool bOwnsSweptBottom = + Core.bHasSweptFloor + && VoxelMath::IsFinite(Core.SweptFloorZ) + && VoxelMath::IsFinite(Core.SweptFloorRadius) + && WorldZ <= Core.SweptFloorZ + + VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels; + if (bOwnsSweptBottom) + { + Result = FMath::Min(Result, -Core.SDF); + } + else if (Core.SDF < 0.0f) + { + Result = FMath::Max(Result, -Core.SDF); + } + }; // Origin rooms are structural too. Reassert their air before either support writer so a // bridge/ridge cannot plug a landing, while the two floor writers remain last. { @@ -3693,7 +3610,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co VoxelDensityProfile::EBucket::PassageStructuralPosts); StrateManager->ApplyPassageStructuralPostsMC( Result, WorldX, WorldY, WorldZ, LandingBaseDensity, - CP_Dist.BoundarySealThickness, bProtectAuthoredTunnelFloor); + CP_Dist.BoundarySealThickness); } // 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 @@ -3705,8 +3622,10 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co const bool bTunnelCoreBypass = VoxelGenLOD::IsTilePostReachBypassActive(); auto ApplyGraphLandingFloors = [&]() { - // These are passage/origin landing posts, not tunnel-core writes. Keep them alive when - // the core bit is clear because their own per-tile reach bits may still be set. + // These are passage/origin landing posts, not tunnel-core writes. Their air and + // structural post already ran before disturbances; the tunnel shape below reopens + // its own continuous interior after the disturbance pass. Keep only the floor + // reassertions here, in the existing order. { VoxelDensityProfile::FScopedTimer ProfileTimer( VoxelDensityProfile::EBucket::PassageLandingFloor); @@ -3733,7 +3652,9 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co { FTunnelCoreWorldEvaluation TunnelCore; bool bHaveTunnelCore = bHavePreDisturbanceTunnelCore; - if (!bTunnelCoreReachable && !bTunnelCoreBypass) + if (VoxelGenLOD::bTileCoreReachProofEnabled + && VoxelGenLOD::bTileReachDiagnosticTile + && !bTunnelCoreReachable && !bTunnelCoreBypass) { // The fused/source hand-off is also empty on the skipped path. Do not let the // default object turn this into a second tail evaluation. @@ -3746,83 +3667,26 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co if (!bHaveTunnelCore && ActiveTunnelCoreCache->bValid && (bTunnelCoreReachable || bTunnelCoreBypass)) { - 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); TunnelCore = VoxelCaveMorphology::EvaluateTunnelCoreWorld( WorldX, WorldY, WorldZ, ActiveTunnelCoreCache->Cache, - SupportColumn, + nullptr, VoxelGenLOD::ShouldUseSpatialIndex( CP_UseOpStack ? bUsedFusedEvaluator : true)); bHaveTunnelCore = true; } if (bHaveTunnelCore) { - const bool bTunnelSupportFloor = TunnelCore.bSupportFloor; - // The graph floor is now composed here, after disturbances and passage writers. - // The source has already baked the floor profile and published the exact same - // core result; this final ownership step only changes an actually-air sample. - // Previously the operator stack raised every authored floor sample to a strong - // internal density before those writers ran, which made the protection counter - // fire for solid samples as well as genuine breaches. - if (TunnelCore.bRoomFloor) - { - const float StructuralSolidDensity = - -FMath::Max(LandingBaseDensity * 2.0f, 1.0f); - if (VoxelDensityProfile::AreCountersEnabled()) - { - 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 every generic post-disturbance writer. - Result = FMath::Min( - Result, - StructuralSolidDensity); - } - // The swept capsule owns the relief profile. This finite support band is now the - // final composition of that authored shape, after every writer, rather than a - // pre-disturbance clamp in FVoxelOpStack::EvalSample. - if (bTunnelSupportFloor) - { - const float StructuralSolidDensity = - -FMath::Max(LandingBaseDensity * 2.0f, 1.0f); - if (VoxelDensityProfile::AreCountersEnabled()) - { - if (Result > 0.0f) - { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::TunnelSupportFloorBackstopFires); - } - } - Result = FMath::Min( - Result, - StructuralSolidDensity); - } - const float CoreSDF = TunnelCore.SDF; - if (!bTunnelSupportFloor - && CoreSDF < -VoxelPassageGeometry::CaveTunnelAirCoreInsetVoxels) - { - Result = FMath::Max( - Result, - FMath::Max(LandingBaseDensity * 2.0f, 1.0f)); - } + // Reassert the actual swept-core field after generic writers. The old tail used a + // fixed positive air value once the core was deeper than an inset; that hard + // switch discarded the SDF magnitude and made marching-cubes intersections snap + // to the sample lattice. The source now contains the same continuous world-chain + // tunnel field. Union its interior air back over disturbances, and use the same + // signed field only on the bottom side identified by the swept shape itself. This + // restores the tunnel's carved floor when the surrounding room is already air, + // without adding an authored slab or turning the whole room into tunnel geometry. + ApplyTunnelCoreShape(TunnelCore); } if (bHaveTunnelCore || (!bTunnelCoreReachable && !bTunnelCoreBypass)) @@ -4292,11 +4156,13 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo // Also writes NearestRoomIdx — the room with minimum SDF contribution // at this voxel's position. Used by terrain ops below to pick per-room params. NearestRoomIdx = -1; + const FVector WorldTunnelPosition(WorldX, WorldY, WorldZ); CaveSDF = VoxelCaveMorphology::EvaluateSDFCached( WarpedX, WarpedY, WarpedZ, SDFCache, Params.SDFBlendRadius, &NearestRoomIdx, - bUseSpatialIndex + bUseSpatialIndex, + &WorldTunnelPosition ); // ── PIT & CHIMNEY SDF INTEGRATION ── @@ -4640,12 +4506,14 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo // // We use the cached SDF so this costs two extra SDF evaluations per voxel, // only when near a surface — the common case is cheap (DistFromSurface > TerraceRange). + const FVector WorldTunnelPositionZp1(WorldX, WorldY, WorldZ + 1.0f); + const FVector WorldTunnelPositionZm1(WorldX, WorldY, WorldZ - 1.0f); float SDF_Zp1 = VoxelCaveMorphology::EvaluateSDFCached( WorldX, WorldY, WorldZ + 1.0f, SDFCache, Params.SDFBlendRadius, - nullptr, bUseSpatialIndex); + nullptr, bUseSpatialIndex, &WorldTunnelPositionZp1); float SDF_Zm1 = VoxelCaveMorphology::EvaluateSDFCached( WorldX, WorldY, WorldZ - 1.0f, SDFCache, Params.SDFBlendRadius, - nullptr, bUseSpatialIndex); + nullptr, bUseSpatialIndex, &WorldTunnelPositionZm1); float GZ = (SDF_Zp1 - SDF_Zm1) * 0.5f; // Normalized vertical component: 1 = perfectly horizontal surface (floor/ceiling) // 0 = perfectly vertical surface (wall) @@ -5208,41 +5076,19 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo if (bApplyLegacyStructuralPosts) { - // Terrain operations and passage support floors are allowed to write solid density, but - // the graph's own tunnel floor/core are structural route geometry. Direct callers of - // GetDensityWithParams retain this historical tail; GetDensityAt skips it and applies the - // same predicates once in its common MC-space post section below both generation paths. + // Direct callers retain the legacy structural tail. The tunnel core itself is still + // composed here as a continuous field; it is not converted into a fixed-density slab. const bool bTunnelCoreEnabled = !VoxelDensityAblation::IsTunnelCoreOff(); - const bool bTunnelSupportFloor = bTunnelCoreEnabled - && VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( - WorldX, WorldY, WorldZ, SDFCache, bUseSpatialIndex); - // Keep the direct legacy entry point capability-safe for callers outside the canonical - // 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()) - { - if (Density < 0.0f) - { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::TunnelSupportFloorBackstopFires); - } - } - Density = FMath::Max(Density, StructuralSolidDensity); - } const float TunnelCoreSDF = bTunnelCoreEnabled && (Params.RoomDensity > 0.0f && Params.RoomSpacing > 0.0f) ? VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF( WorldX, WorldY, WorldZ, SDFCache, bUseSpatialIndex) : FLT_MAX; - if (!bTunnelSupportFloor - && TunnelCoreSDF < -VoxelPassageGeometry::CaveTunnelAirCoreInsetVoxels) + if (VoxelMath::IsFinite(TunnelCoreSDF) && TunnelCoreSDF < 0.0f) { Density = FMath::Min( Density, - -FMath::Max(Params.BaseDensity * 2.0f, 1.0f)); + TunnelCoreSDF); } ApplyOriginLandingFloor(Density, WorldX, WorldY, WorldZ, Params.StrateTopWorldZ, Params.StrateBottomWorldZ, diff --git a/Source/VoxelForge/Private/VoxelStrateComposer.cpp b/Source/VoxelForge/Private/VoxelStrateComposer.cpp index dd673ea..0f48550 100644 --- a/Source/VoxelForge/Private/VoxelStrateComposer.cpp +++ b/Source/VoxelForge/Private/VoxelStrateComposer.cpp @@ -1371,13 +1371,6 @@ namespace Params.bTunnelsFlowTowardOrigin = CategoryRng.FRand() < 0.5f; return true; } - if (FCString::Strcmp(FieldName, TEXT("bTunnelFloorEnabled")) == 0) - { - // The floor is a capability feature, not a corpus-free variation axis. Keep it on - // for every generated candidate so a roll cannot manufacture an invalid bare tube. - Params.bTunnelFloorEnabled = true; - return true; - } if (FCString::Strcmp(FieldName, TEXT("bTunnelFloorTerracingEnabled")) == 0) { Params.bTunnelFloorTerracingEnabled = CategoryRng.FRand() < 0.75f; diff --git a/Source/VoxelForge/Private/VoxelStrateDefinition.cpp b/Source/VoxelForge/Private/VoxelStrateDefinition.cpp index 6b66f75..e8ee7fe 100644 --- a/Source/VoxelForge/Private/VoxelStrateDefinition.cpp +++ b/Source/VoxelForge/Private/VoxelStrateDefinition.cpp @@ -62,6 +62,11 @@ void UVoxelStrateDefinition::PostLoad() { Super::PostLoad(); + // The field remains reflected solely so old packages deserialize without shifting the + // following members. Tunnel geometry owns the floor now, so every serialized legacy value + // is normalized to the ignored compatibility default before any runtime copy is made. + GenerationParams.bTunnelFloorEnabled = true; + if (GetLinkerCustomVersion(GVoxelForgeStrateSerializationVersion) >= GVoxelForgeStrateGentleSlopeDegreesVersion) { diff --git a/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Source/VoxelForge/Private/VoxelStrateManager.cpp index a00ce29..83e4ee4 100644 --- a/Source/VoxelForge/Private/VoxelStrateManager.cpp +++ b/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -2967,8 +2967,7 @@ static bool VF_IsPassageRoomFloor( void UVoxelStrateManager::ApplyPassageStructuralPostsMC( float& Density, float WorldX, float WorldY, float WorldZ, - float BaseDensity, float SealThickness, - bool bProtectAuthoredTunnelFloor) const + float BaseDensity, float SealThickness) const { const bool bDisableStructuralPosts = VoxelDensityAblation::IsPassageStructuralPostsOff(); const bool bDisableLandingPosts = VoxelDensityAblation::IsLandingPostsOff(); @@ -2989,7 +2988,7 @@ void UVoxelStrateManager::ApplyPassageStructuralPostsMC( VoxelGenLOD::bTilePostReachBypass = true; ApplyPassageStructuralPostsMC( CanonicalDensity, WorldX, WorldY, WorldZ, - BaseDensity, SealThickness, bProtectAuthoredTunnelFloor); + BaseDensity, SealThickness); VoxelGenLOD::bTilePostReachBypass = PreviousBypass; } if (!bLandingReachable && !bStructuralReachable) @@ -3076,11 +3075,9 @@ void UVoxelStrateManager::ApplyPassageStructuralPostsMC( } } - // 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 (!bDisableLandingPosts && !bProtectAuthoredTunnelFloor + // The graph tunnel no longer exposes a separate support slab. Inter-strate landing carving + // remains independent and is still composed here when its own reach proof allows it. + if (!bDisableLandingPosts && !bAnyLandingFloor && !bLegacyTunnelSupportFloor && MinLandingSDF < FLT_MAX) { float InternalDensity = -Density; @@ -3094,7 +3091,7 @@ void UVoxelStrateManager::ApplyPassageStructuralPostsMC( Density = FMath::Min(Density, -BaseDensity); } - if (bWalkableAir && !bProtectAuthoredTunnelFloor) + if (bWalkableAir) { Density = FMath::Max( Density, BaseDensity * 2.0f + SealThickness + 4.0f); diff --git a/Source/VoxelForge/Public/VoxelCaveMorphology.h b/Source/VoxelForge/Public/VoxelCaveMorphology.h index e768d3a..f985cc8 100644 --- a/Source/VoxelForge/Public/VoxelCaveMorphology.h +++ b/Source/VoxelForge/Public/VoxelCaveMorphology.h @@ -712,7 +712,6 @@ struct FCachedTunnel float SDFMouthFloorZB = -FLT_MAX; float WorldMouthFloorZA = -FLT_MAX; float WorldMouthFloorZB = -FLT_MAX; - bool bTunnelFloorEnabled = true; bool bTunnelFloorTerracingEnabled = true; float TunnelFloorTerraceStepHeight = 1.71f; float TunnelFloorMaxLedgeHeight = 12.0f; @@ -915,17 +914,8 @@ struct FChunkSDFCache FChunkSDFSpatialIndex ChimneySpatialIndex; FChunkSDFSpatialIndex ColumnSpatialIndex; - // Sparse immutable support-column table. SupportColumnEntries is a dense - // integer-XY slot map whose values index only non-empty SupportColumns. - int32 SupportColumnMinX = 0; - int32 SupportColumnMinY = 0; - int32 SupportColumnCellsX = 0; - int32 SupportColumnCellsY = 0; - TArray SupportColumnEntries; - TArray SupportColumns; - // Kept with the cache because the world-space structural evaluator has no params argument. - // A zero value is a valid hard-intersection fallback for legacy/empty caches. + // A zero value is a valid fallback for legacy/empty caches. float SDFBlendRadius = 0.0f; // Allocator-backed bytes owned by this cache, including the nested tunnel chains. The returned @@ -943,14 +933,20 @@ struct FChunkSDFCache VoxelDensityProfile::FCacheMemoryBreakdown GetAllocatedSizeBreakdown() const; }; -/** Combined world-space tunnel post query; one cache scan supplies both support and air tests. */ +/** World-space tunnel shape query. Legacy support-floor fields remain source-compatible but are + * no longer produced or consumed by the graph-tunnel generation path. */ struct FTunnelCoreWorldEvaluation { float SDF = FLT_MAX; + // Deprecated compatibility output. The separate graph-tunnel support slab was removed. bool bSupportFloor = false; - // The room floor owns the overlap band. The common post uses this marker to restore - // the room's support after the raw tunnel-air backstop has run. + // Deprecated compatibility output. Room/landing floors are composed by their own posts. bool bRoomFloor = false; + // Shape-level floor ownership. This is not an authored slab: it identifies the bottom side + // of the swept tunnel SDF so a room that is already air cannot erase the tunnel's own floor. + bool bHasSweptFloor = false; + float SweptFloorZ = -FLT_MAX; + float SweptFloorRadius = 0.0f; }; //============================================================================= @@ -1040,13 +1036,17 @@ namespace VoxelCaveMorphology // SmoothMin. Includes per-room/tunnel distance culling to skip primitives // that are too far to contribute. // - // @param WorldX, WorldY, WorldZ — position in voxel coordinates (may be warped) + // @param WorldX, WorldY, WorldZ — room/query position in voxel coordinates (may be warped) // @param Cache — pre-built cache from BuildChunkCache // @param SDFBlendRadius — SmoothMin blend radius (from Params.SDFBlendRadius) // @param OutNearestRoomIdx — optional out: index of the room with minimum SDF // contribution. -1 if no room passed the cull test. // Used by the terrain ops system to look up the // per-room terrain op assigned to this voxel's room. + // @param WorldTunnelPosition — optional unwarped position for the tunnel primitive. When + // supplied, rooms remain at the query position but the tunnel + // uses its authored world chain. Null preserves the legacy + // all-query behavior. // (Room shape variety is baked into FCachedRoom by BuildChunkCache — no per-voxel roll.) // @return negative = inside cave, positive = solid rock VOXELFORGE_API float EvaluateSDFCached( @@ -1054,7 +1054,8 @@ namespace VoxelCaveMorphology const FChunkSDFCache& Cache, float SDFBlendRadius, int32* OutNearestRoomIdx = nullptr, - bool bUseSpatialIndex = true + bool bUseSpatialIndex = true, + const FVector* WorldTunnelPosition = nullptr ); // Evaluate the raw union of cached graph-tunnel capsules. This is intentionally separate from @@ -1078,6 +1079,7 @@ namespace VoxelCaveMorphology VOXELFORGE_API FTunnelCoreWorldEvaluation EvaluateTunnelCoreWorld( float WorldX, float WorldY, float WorldZ, const FChunkSDFCache& Cache, + // Deprecated legacy argument; graph-tunnel evaluation ignores the old support column. const FTunnelSupportFloorColumn* SupportColumn = nullptr, bool bUseSpatialIndex = true ); @@ -1090,18 +1092,16 @@ namespace VoxelCaveMorphology float ReachScale = 1.0f ); - // 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. + // Deprecated compatibility query for the removed graph-tunnel support slab. Production + // generation does not call this predicate; the tunnel's own swept shape owns its floor. VOXELFORGE_API bool IsTunnelSupportFloorWorldPoint( float WorldX, float WorldY, float WorldZ, const FChunkSDFCache& Cache, bool bUseSpatialIndex = true ); - // Build/query the same support-floor predicate at column granularity. The column stores its - // finite support interval, so every Z sample in a chunk reuses the same deterministic - // projection and never re-runs a natural-floor validator. + // Deprecated compatibility helper for callers that still inspect the old support-band + // diagnostic. It is detached from production density generation. VOXELFORGE_API void BuildTunnelSupportFloorColumn( float WorldX, float WorldY, const FChunkSDFCache& Cache, @@ -1114,9 +1114,8 @@ namespace VoxelCaveMorphology const FTunnelSupportFloorColumn& Column ); - // Returns the exact projected floor band for one tunnel at this XY column. - // The world evaluator uses FloorZ for its per-tunnel air ownership rule and - // Min/Max for the support slab itself. + // Returns the legacy projected band for one tunnel at this XY column. The world evaluator no + // longer uses it to write a support slab. VOXELFORGE_API bool GetTunnelSupportFloorColumnBand( int32 TunnelIndex, const FTunnelSupportFloorColumn& Column, diff --git a/Source/VoxelForge/Public/VoxelDensityOp.h b/Source/VoxelForge/Public/VoxelDensityOp.h index ed94c74..9d06bc9 100644 --- a/Source/VoxelForge/Public/VoxelDensityOp.h +++ b/Source/VoxelForge/Public/VoxelDensityOp.h @@ -337,10 +337,8 @@ struct FVoxelOpSample // 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. + // Deprecated structural-floor compatibility fields. Graph tunnels now carry their floor in + // the swept shape itself; the production stack does not use these fields to write a slab. bool bProtectedStructuralFloor = false; float StructuralFloorMinimumDensity = 0.0f; @@ -350,14 +348,16 @@ struct FVoxelOpSample const FChunkSDFCache* RoomCache = nullptr; int32 NearestRoomIndex = INDEX_NONE; - // The common generator tail consumes these values after the block has left the stack. They - // are the small, exact subset of FTunnelCoreWorldEvaluation that the tail needs; keeping them - // in the sample avoids re-running the world-space tunnel scan or consulting a last-sample TLS - // value after an op-major evaluation. + // The common generator tail consumes the continuous swept-shape metadata after the block has + // left the stack. Keeping it in the sample avoids re-running the world-space tunnel scan or + // consulting a last-sample TLS value after an op-major evaluation. bool bHasTunnelCoreWorldEvaluation = false; float TunnelCoreWorldSDF = FLT_MAX; bool bTunnelCoreSupportFloor = false; bool bTunnelCoreRoomFloor = false; + bool bHasTunnelCoreSweptFloor = false; + float TunnelCoreSweptFloorZ = -FLT_MAX; + float TunnelCoreSweptFloorRadius = 0.0f; }; /** diff --git a/Source/VoxelForge/Public/VoxelStrateManager.h b/Source/VoxelForge/Public/VoxelStrateManager.h index c6f57be..63371dc 100644 --- a/Source/VoxelForge/Public/VoxelStrateManager.h +++ b/Source/VoxelForge/Public/VoxelStrateManager.h @@ -429,8 +429,7 @@ public: /** 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, - bool bProtectAuthoredTunnelFloor = false) const; + float BaseDensity, float SealThickness) const; /** * Compose the build-authored passage D-floor after generic MC writers. This is the native diff --git a/Source/VoxelForge/Public/VoxelStrateTypes.h b/Source/VoxelForge/Public/VoxelStrateTypes.h index d567676..e43b88c 100644 --- a/Source/VoxelForge/Public/VoxelStrateTypes.h +++ b/Source/VoxelForge/Public/VoxelStrateTypes.h @@ -285,7 +285,6 @@ enum class EVoxelStrateTransition : uint8 LERPF(TunnelHorizontalBias) \ SNAPF(bTunnelsFlowTowardOrigin) \ LERPF(TunnelEndpointZOffset) \ - SNAPF(bTunnelFloorEnabled) \ SNAPF(bTunnelFloorTerracingEnabled) \ LERPF(TunnelFloorTerraceStepHeight) \ LERPF(TunnelFloorMaxLedgeHeight) \ @@ -686,10 +685,10 @@ struct VOXELFORGE_API FStrateGenerationParams // terraces walls after morphology, while these fields describe the tunnel's own floor profile. // FloorReliefStrength/Frequency above are shared with room floors and are reused here. - // Whether the swept tunnel receives its authored floor cut. Disable only for an archetype that - // deliberately wants the bare capsule; the structural support backstop remains a measured - // fallback for malformed/legacy caches and is not a replacement for this shape. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels|Floor") + // Deprecated compatibility field. The tunnel's carved geometry always owns its floor; + // serialized values continue to load but are deliberately ignored. + UPROPERTY(meta = (DeprecatedProperty, + DeprecationMessage = "Ignored; tunnel floor is always authored by tunnel geometry.")) bool bTunnelFloorEnabled = true; // Enables deterministic selection of a few dramatic ledges on steep graph-redundant or leaf @@ -1137,10 +1136,10 @@ constexpr int32 VF_StrateParamListedFieldCount = #undef VF_STRATE_PARAM_COUNT_FIELD static_assert( - VF_StrateParamListedFieldCount == 83, + VF_StrateParamListedFieldCount == 82, "FStrateGenerationParams field count changed; update the struct and VF_STRATE_PARAM_FIELDS together."); static_assert( - sizeof(FStrateGenerationParams) == VF_StrateParamListedBytes + 8, + sizeof(FStrateGenerationParams) == VF_StrateParamListedBytes + 9, "FStrateGenerationParams and VF_STRATE_PARAM_FIELDS disagree; update both places and recheck the expected padding."); //============================================================================= diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp index dcf7ea4..9e8c370 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -44,6 +44,7 @@ #include #include +#include namespace { @@ -378,6 +379,7 @@ struct FExploreArguments FVector DensitySliceDirection = FVector::ForwardVector; int32 DensitySliceSamples = 257; float DensitySliceStep = 0.5f; + bool bTunnelCoreProbe = false; bool IsDensityProfilingEnabled() const { @@ -391,6 +393,7 @@ struct FExploreArguments if (bWalk) Result += TEXT("walk,"); if (bExport) Result += TEXT("export,"); if (bOpBounds) Result += TEXT("opbounds,"); + if (bTunnelCoreProbe) Result += TEXT("tunnelcoreprobe,"); if (Result.EndsWith(TEXT(","))) { Result.LeftChopInline(1); @@ -453,6 +456,7 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr } OutArguments.bProfileLod = FParse::Param(*Params, TEXT("profilelod")); OutArguments.bOpBounds = FParse::Param(*Params, TEXT("opbounds")); + OutArguments.bTunnelCoreProbe = FParse::Param(*Params, TEXT("tunnelcoreprobe")); FParse::Value(*Params, TEXT("out="), OutText); FParse::Value(*Params, TEXT("renderwidth="), OutArguments.RenderWidth); FParse::Value(*Params, TEXT("renderheight="), OutArguments.RenderHeight); @@ -652,6 +656,10 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr { OutArguments.bOpBounds = true; } + else if (Mode == TEXT("tunnelcoreprobe")) + { + OutArguments.bTunnelCoreProbe = true; + } else if (Mode == TEXT("render")) { OutArguments.bRender = true; @@ -667,12 +675,12 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr else { OutError = FString::Printf( - TEXT("Unknown mode '%s'. Expected render, walk, export, opbounds, or all."), *Mode); + TEXT("Unknown mode '%s'. Expected render, walk, export, opbounds, tunnelcoreprobe, or all."), *Mode); return false; } } if (!OutArguments.bRender && !OutArguments.bWalk && !OutArguments.bExport - && !OutArguments.bOpBounds) + && !OutArguments.bOpBounds && !OutArguments.bTunnelCoreProbe) { OutError = TEXT("At least one mode must be selected."); return false; @@ -1270,7 +1278,9 @@ struct FExploreWorld } if (Arguments.bTunnelFloorEnabledOverride) { - TunnelParams.bTunnelFloorEnabled = Arguments.TunnelFloorEnabled != 0; + // Retain the command-line spelling for old probes, but do not feed the removed + // floor switch into generation. The serialized property has the same + // compatibility-only treatment in UVoxelStrateDefinition::PostLoad. } if (Arguments.bTunnelFloorTerracingOverride) { @@ -1342,7 +1352,7 @@ struct FExploreWorld UE_LOG(LogTemp, Display, TEXT("[VoxelForgeExplore][OwnerAssets] strate=%s settings=%s slot=%d " "seed=%d height_chunks=%d archetype=%s opstack=%d " - "floor_enabled=%d floor_terracing=%d floor_step=%.9g max_ledge=%.9g " + "floor_switch_ignored=1 floor_terracing=%d floor_step=%.9g max_ledge=%.9g " "gentle_slope_degrees=%.9g gentle_gradient=%.9g route_cap=%.9g " "wind_wave_cap=%d " "ledge_preference=%d max_ledges=%d tunnel_warp=%.9g terrain_ops=%d terrace_step=%.9g " @@ -1351,7 +1361,6 @@ struct FExploreWorld bUseAuthoredSettings ? *Arguments.SettingsReference : TEXT("synthetic"), Arguments.Slot, Settings->Seed, Target.Definition->StrateHeightInChunks, ArchetypeName(TargetArchetype), Target.Definition->bUseOperatorStack ? 1 : 0, - ReportParams.bTunnelFloorEnabled ? 1 : 0, ReportParams.bTunnelFloorTerracingEnabled ? 1 : 0, ReportParams.TunnelFloorTerraceStepHeight, ReportParams.TunnelFloorMaxLedgeHeight, @@ -1576,6 +1585,58 @@ struct FExploreDensitySliceOutput double Seconds = 0.0; }; +struct FExploreTunnelCoreProbeAxis +{ + FString Name; + int32 ValidCoreCrossings = 0; + int32 ValidFinalCrossings = 0; + float CoreMeanCrossingVoxels = 0.0f; + float FinalMeanCrossingVoxels = 0.0f; + float CoreMinCrossingVoxels = 0.0f; + float CoreMaxCrossingVoxels = 0.0f; + float FinalMinCrossingVoxels = 0.0f; + float FinalMaxCrossingVoxels = 0.0f; + float CorePeriodicAmplitudeVoxels = 0.0f; + float CorePeriodicPeriodVoxels = 0.0f; + float FinalPeriodicAmplitudeVoxels = 0.0f; + float FinalPeriodicPeriodVoxels = 0.0f; + int32 CorePeriodicMeasuredSamples = 0; + float CorePeriodicMeasuredLengthVoxels = 0.0f; + int32 FinalPeriodicMeasuredSamples = 0; + float FinalPeriodicMeasuredLengthVoxels = 0.0f; + TArray CoreCrossingsVoxels; + TArray FinalCrossingsVoxels; +}; + +struct FExploreTunnelCoreProbeOutput +{ + FString Status; + FString RefusalReason; + FString FileName; + int32 TunnelIndex = INDEX_NONE; + int32 SampleCount = 0; + int32 CoreCenterFiniteSampleCount = 0; + int32 CoreCenterZeroCrossingCount = 0; + int32 FinalCenterFiniteSampleCount = 0; + int32 FinalCenterZeroCrossingCount = 0; + int32 FinalCenterSaturatedSampleCount = 0; + float CoreCenterMin = 0.0f; + float CoreCenterMax = 0.0f; + float FinalCenterMin = 0.0f; + float FinalCenterMax = 0.0f; + float FinalCenterMaxAbs = 0.0f; + float StepVoxels = 0.25f; + float LengthVoxels = 0.0f; + FVector AxisStart = FVector::ZeroVector; + FVector AxisEnd = FVector::ZeroVector; + FVector AxisDirection = FVector::ForwardVector; + FVector WallDirection = FVector::RightVector; + FExploreTunnelCoreProbeAxis Floor; + FExploreTunnelCoreProbeAxis Wall; + FExploreTunnelCoreProbeAxis Ceiling; + double Seconds = 0.0; +}; + struct FExploreProfilerComparison { bool bAvailable = false; @@ -1597,6 +1658,7 @@ struct FExploreRunOutput FExploreWalkOutput Walk; FExploreExportOutput Export; FExploreDensitySliceOutput DensitySlice; + FExploreTunnelCoreProbeOutput TunnelCoreProbe; double BudgetSeconds = 0.0; double ElapsedSeconds = 0.0; double WalkSeconds = 0.0; @@ -4376,11 +4438,12 @@ void WriteTunnelFloorOverrides( const FExploreArguments& Arguments) { Writer.WriteObjectStart(TEXT("tunnel_floor_overrides")); - Writer.WriteValue(TEXT("enabled_override"), Arguments.bTunnelFloorEnabledOverride); + Writer.WriteValue(TEXT("legacy_enabled_override"), Arguments.bTunnelFloorEnabledOverride); if (Arguments.bTunnelFloorEnabledOverride) { - Writer.WriteValue(TEXT("enabled"), Arguments.TunnelFloorEnabled != 0); + Writer.WriteValue(TEXT("legacy_enabled_value"), Arguments.TunnelFloorEnabled != 0); } + Writer.WriteValue(TEXT("enabled_ignored"), true); Writer.WriteValue(TEXT("terracing_override"), Arguments.bTunnelFloorTerracingOverride); if (Arguments.bTunnelFloorTerracingOverride) { @@ -4796,6 +4859,865 @@ bool RunDensitySlice( return true; } +void ComputeTunnelCoreProbeMetrics( + const TArray& Samples, + float StepVoxels, + int32& OutValidCount, + float& OutMean, + float& OutMin, + float& OutMax, + float& OutPeriodicAmplitudeVoxels, + float& OutPeriodicPeriodVoxels, + int32& OutPeriodicMeasuredSamples, + float& OutPeriodicMeasuredLengthVoxels) +{ + OutValidCount = 0; + OutMean = 0.0f; + OutMin = 0.0f; + OutMax = 0.0f; + OutPeriodicAmplitudeVoxels = 0.0f; + OutPeriodicPeriodVoxels = 0.0f; + OutPeriodicMeasuredSamples = 0; + OutPeriodicMeasuredLengthVoxels = 0.0f; + if (Samples.Num() < 4 || !FMath::IsFinite(StepVoxels) || StepVoxels <= 0.0f) + { + return; + } + + double Sum = 0.0; + for (int32 Index = 0; Index < Samples.Num(); ++Index) + { + const float Value = Samples[Index]; + if (!FMath::IsFinite(Value)) + { + continue; + } + ++OutValidCount; + Sum += Value; + if (OutValidCount == 1) + { + OutMin = Value; + OutMax = Value; + } + else + { + OutMin = FMath::Min(OutMin, Value); + OutMax = FMath::Max(OutMax, Value); + } + } + if (OutValidCount < 4) + { + return; + } + + const double Count = static_cast(OutValidCount); + OutMean = static_cast(Sum / Count); + + // Find the longest contiguous surface run. A missing radial crossing is not a zero-valued + // sample: it is a hole in the measurement and must not be bridged before the spectral pass. + int32 BestRunStart = INDEX_NONE; + int32 BestRunEnd = INDEX_NONE; + int32 RunStart = INDEX_NONE; + for (int32 Index = 0; Index <= Samples.Num(); ++Index) + { + const bool bFinite = Index < Samples.Num() && FMath::IsFinite(Samples[Index]); + if (bFinite) + { + if (RunStart == INDEX_NONE) + { + RunStart = Index; + } + continue; + } + if (RunStart != INDEX_NONE + && (BestRunStart == INDEX_NONE + || Index - RunStart > BestRunEnd - BestRunStart + 1)) + { + BestRunStart = RunStart; + BestRunEnd = Index - 1; + } + RunStart = INDEX_NONE; + } + const int32 BestRunLength = BestRunStart != INDEX_NONE + ? BestRunEnd - BestRunStart + 1 + : 0; + // The requested acceptance window is at least 40 voxels. Keep the raw crossing statistics + // above for shorter runs, but explicitly mark their periodic result as unmeasured. + constexpr float RequiredLengthVoxels = 40.0f; + const int32 RequiredSampleCount = FMath::CeilToInt(RequiredLengthVoxels / StepVoxels) + 1; + if (BestRunLength < FMath::Max(4, RequiredSampleCount)) + { + return; + } + + // Remove only the slow authored floor/radius trend. A 32-voxel median window is wider than + // the 1..16 voxel rib band, so a genuine rib train survives while a single mouth transition + // or route slope follows the local baseline. Discarding the half-window at each edge keeps + // the asymmetric endpoint windows out of the spectral result. + constexpr float TrendWindowVoxels = 32.0f; + const int32 HalfWindowSamples = FMath::Max( + 1, FMath::RoundToInt(0.5f * TrendWindowVoxels / StepVoxels)); + const int32 AnalysisStart = BestRunStart + HalfWindowSamples; + const int32 AnalysisEnd = BestRunEnd - HalfWindowSamples; + const int32 AnalysisCount = AnalysisEnd - AnalysisStart + 1; + if (AnalysisCount < 4) + { + return; + } + + OutPeriodicMeasuredSamples = AnalysisCount; + OutPeriodicMeasuredLengthVoxels = static_cast(AnalysisCount - 1) * StepVoxels; + + constexpr float MaxRibPeriodVoxels = 16.0f; + const int32 MinRibFrequency = FMath::Max( + 1, FMath::CeilToInt(StepVoxels * static_cast(AnalysisCount) + / MaxRibPeriodVoxels)); + const int32 MaxFrequency = AnalysisCount / 2; + double BestAmplitude = 0.0; + int32 BestFrequency = 0; + for (int32 Frequency = MinRibFrequency; Frequency <= MaxFrequency; ++Frequency) + { + double Real = 0.0; + double Imaginary = 0.0; + for (int32 Index = AnalysisStart; Index <= AnalysisEnd; ++Index) + { + TArray> Window; + const int32 WindowStart = FMath::Max(BestRunStart, Index - HalfWindowSamples); + const int32 WindowEnd = FMath::Min(BestRunEnd, Index + HalfWindowSamples); + Window.Reserve(WindowEnd - WindowStart + 1); + for (int32 WindowIndex = WindowStart; WindowIndex <= WindowEnd; ++WindowIndex) + { + Window.Add(Samples[WindowIndex]); + } + Window.Sort(); + const int32 Middle = Window.Num() / 2; + const double Median = (Window.Num() & 1) != 0 + ? static_cast(Window[Middle]) + : 0.5 * (static_cast(Window[Middle - 1]) + + static_cast(Window[Middle])); + const double Residual = static_cast(Samples[Index]) - Median; + const int32 AnalysisIndex = Index - AnalysisStart; + const double Angle = 2.0 * PI * static_cast(Frequency) + * static_cast(AnalysisIndex) / static_cast(AnalysisCount); + Real += Residual * FMath::Cos(static_cast(Angle)); + Imaginary -= Residual * FMath::Sin(static_cast(Angle)); + } + const double Amplitude = 2.0 * FMath::Sqrt(Real * Real + Imaginary * Imaginary) + / static_cast(AnalysisCount); + if (Amplitude > BestAmplitude) + { + BestAmplitude = Amplitude; + BestFrequency = Frequency; + } + } + OutPeriodicAmplitudeVoxels = static_cast(BestAmplitude); + OutPeriodicPeriodVoxels = BestFrequency > 0 + ? StepVoxels * static_cast(AnalysisCount) + / static_cast(BestFrequency) + : 0.0f; +} + +bool RunTunnelCoreProbe( + const FExploreArguments& Arguments, + FExploreWorld& World, + FExploreTunnelCoreProbeOutput& OutOutput, + FExploreBudget& Budget) +{ + OutOutput = FExploreTunnelCoreProbeOutput(); + OutOutput.FileName = TEXT("tunnel_core_probe.csv"); + OutOutput.StepVoxels = 0.25f; + const double StartSeconds = FPlatformTime::Seconds(); + + if (!World.Manager.IsValid() || !World.Generator.IsValid() + || !World.Settings.IsValid() + || !World.Definitions.IsValidIndex(Arguments.Slot)) + { + OutOutput.Status = TEXT("error"); + OutOutput.RefusalReason = TEXT("The owner-value probe has no complete transient world."); + return false; + } + + const int32 TargetBottomChunkZ = FMath::FloorToInt( + static_cast(World.TargetBottomWorldZ) / static_cast(CHUNK_SIZE)); + const int32 TargetTopChunkZ = FMath::FloorToInt( + (static_cast(World.TargetTopWorldZ) - KINDA_SMALL_NUMBER) + / static_cast(CHUNK_SIZE)); + const float TargetMinZ = static_cast(World.TargetBottomWorldZ); + const float TargetMaxZ = static_cast(World.TargetTopWorldZ); + constexpr float TargetBandPadding = 8.0f; + constexpr float RouteParameterCheckStep = 4.0f; + + // The owner path asks GetGenerationParams for the exact (x,y,z) chunk of every sample. A + // cache built from an arbitrary interior Z can therefore be a different graph in a boundary + // blend band; that was the source of the earlier probe's FLT_MAX/core-only result. Build the + // small set of candidate graphs used by the target Z chunks, and retain only a complete chain + // whose route and radial measurement band resolve to the same parameter fingerprint that was + // used to bake the graph. The field measured below is then the same field the game queries. + auto GenerationParamsFingerprint = [](const FStrateGenerationParams& InParams) -> uint32 + { + return FCrc::MemCrc32(&InParams, sizeof(InParams)); + }; + + FChunkSDFCache Cache; + FStrateGenerationParams Params; + int32 StrateIndex = INDEX_NONE; + int32 QueryChunkZ = INDEX_NONE; + const FCachedTunnel* BestTunnel = nullptr; + float BestLength = 0.0f; + int32 BestTunnelIndex = INDEX_NONE; + bool bBestTunnelFullyInTargetStrate = false; + int32 BestTargetBandPointCount = 0; + uint32 BestParamsFingerprint = 0xFFFFFFFFu; + const uint32 WorldSeed = static_cast(World.Settings->Seed); + + for (int32 CandidateChunkZ = TargetBottomChunkZ; + CandidateChunkZ <= TargetTopChunkZ; + ++CandidateChunkZ) + { + if (Budget.ShouldStop(TEXT("tunnel-core-probe-cache"))) + { + OutOutput.Status = TEXT("truncated"); + OutOutput.RefusalReason = TEXT("The wall-clock budget stopped candidate cache construction."); + OutOutput.Seconds = FPlatformTime::Seconds() - StartSeconds; + return false; + } + + const FIntVector CandidateChunk(0, 0, CandidateChunkZ); + const FStrateGenerationParams CandidateParams = + World.Manager->GetGenerationParams(CandidateChunk); + const int32 CandidateStrateIndex = World.Manager->GetStrateIndex( + (static_cast(CandidateChunk.Z) + 0.5f) + * static_cast(CHUNK_SIZE) * VOXEL_SIZE); + if (CandidateStrateIndex < 0 + || !(CandidateParams.RoomDensity > 0.0f) + || !(CandidateParams.RoomSpacing > 0.0f)) + { + continue; + } + + const UVoxelStrateDefinition* CandidateDefinition = + World.Manager->GetStrateForChunk(CandidateChunk); + const TArray* CandidateTerrainOps = CandidateDefinition != nullptr + ? &CandidateDefinition->TerrainOperations : nullptr; + const uint32 CandidateFingerprint = GenerationParamsFingerprint(CandidateParams); + + // The owner render region is centred near the origin. Keep each candidate cache broad + // enough for the local graph, exactly like the ordinary point-query cache window. + FChunkSDFCache CandidateCache; + VoxelCaveMorphology::BuildChunkCache( + CandidateCache, -256.0f, -256.0f, 256.0f, 256.0f, + CandidateParams, WorldSeed, CandidateStrateIndex, + CandidateTerrainOps, ERoomGraphBuildSite::GeneratorTunnelCore); + + int32 CandidateBestTunnelIndex = INDEX_NONE; + float CandidateBestLength = 0.0f; + int32 CandidateBestTargetSampleCount = 0; + for (int32 TunnelIndex = 0; TunnelIndex < CandidateCache.Tunnels.Num(); ++TunnelIndex) + { + const FCachedTunnel& Tunnel = CandidateCache.Tunnels[TunnelIndex]; + const TArray& Points = Tunnel.WorldControlPoints.Num() >= 2 + && Tunnel.WorldControlRadii.Num() == Tunnel.WorldControlPoints.Num() + ? Tunnel.WorldControlPoints : Tunnel.ControlPoints; + const TArray& Radii = Tunnel.WorldControlRadii.Num() == Points.Num() + ? Tunnel.WorldControlRadii : Tunnel.ControlRadii; + if (Points.Num() < 2 || Radii.Num() != Points.Num()) + { + continue; + } + + TArray CandidateCumulativeLengths; + CandidateCumulativeLengths.SetNumZeroed(Points.Num()); + bool bFinite = true; + float Length = 0.0f; + float MaxRadius = 0.0f; + for (int32 PointIndex = 0; PointIndex + 1 < Points.Num(); ++PointIndex) + { + const float SegmentLength = FVector::Dist( + Points[PointIndex], Points[PointIndex + 1]); + if (!FMath::IsFinite(SegmentLength)) + { + bFinite = false; + break; + } + Length += SegmentLength; + CandidateCumulativeLengths[PointIndex + 1] = Length; + MaxRadius = FMath::Max(MaxRadius, FMath::Abs(Radii[PointIndex])); + } + if (Points.Num() > 0) + { + MaxRadius = FMath::Max(MaxRadius, FMath::Abs(Radii.Last())); + } + if (!bFinite || Length < 40.0f) + { + continue; + } + + auto PointAtCandidateDistance = [&](float Distance) -> FVector + { + const float ClampedDistance = FMath::Clamp(Distance, 0.0f, Length); + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Points.Num(); + ++SegmentIndex) + { + if (ClampedDistance <= CandidateCumulativeLengths[SegmentIndex + 1] + || SegmentIndex + 2 == Points.Num()) + { + const float SegmentLength = CandidateCumulativeLengths[SegmentIndex + 1] + - CandidateCumulativeLengths[SegmentIndex]; + const float Alpha = SegmentLength > KINDA_SMALL_NUMBER + ? (ClampedDistance - CandidateCumulativeLengths[SegmentIndex]) + / SegmentLength : 0.0f; + return FMath::Lerp(Points[SegmentIndex], Points[SegmentIndex + 1], Alpha); + } + } + return Points.Last(); + }; + + const float RadialParameterReach = FMath::Clamp( + MaxRadius + 12.0f, 12.0f, 64.0f); + bool bHasTargetSample = false; + bool bRouteMatchesParams = true; + int32 TargetSampleCount = 0; + const int32 ParameterSampleCount = FMath::CeilToInt( + Length / RouteParameterCheckStep); + for (int32 SampleIndex = 0; + SampleIndex <= ParameterSampleCount; + ++SampleIndex) + { + const float Distance = FMath::Min( + Length, static_cast(SampleIndex) * RouteParameterCheckStep); + const FVector Point = PointAtCandidateDistance(Distance); + const bool bInTargetBand = Point.Z >= TargetMinZ - TargetBandPadding + && Point.Z <= TargetMaxZ + TargetBandPadding; + bHasTargetSample |= bInTargetBand; + TargetSampleCount += bInTargetBand ? 1 : 0; + if (!bInTargetBand) + { + bRouteMatchesParams = false; + break; + } + + const FIntVector PointChunk( + FMath::FloorToInt(Point.X / static_cast(CHUNK_SIZE)), + FMath::FloorToInt(Point.Y / static_cast(CHUNK_SIZE)), + FMath::FloorToInt(Point.Z / static_cast(CHUNK_SIZE))); + if (GenerationParamsFingerprint( + World.Manager->GetGenerationParams(PointChunk)) != CandidateFingerprint) + { + bRouteMatchesParams = false; + break; + } + + // The floor, wall and ceiling roots are measured around this same centerline. + // Reject a route whose radial band crosses into a different parameter blend; a + // single cache cannot represent that discontinuous query context. + for (float RadialOffset = -RadialParameterReach; + RadialOffset <= RadialParameterReach + KINDA_SMALL_NUMBER; + RadialOffset += 8.0f) + { + const FVector RadialPoint = Point + FVector(0.0f, 0.0f, RadialOffset); + const FIntVector RadialChunk( + FMath::FloorToInt(RadialPoint.X / static_cast(CHUNK_SIZE)), + FMath::FloorToInt(RadialPoint.Y / static_cast(CHUNK_SIZE)), + FMath::FloorToInt(RadialPoint.Z / static_cast(CHUNK_SIZE))); + if (GenerationParamsFingerprint( + World.Manager->GetGenerationParams(RadialChunk)) + != CandidateFingerprint) + { + bRouteMatchesParams = false; + break; + } + } + if (!bRouteMatchesParams) + { + break; + } + } + + if (bHasTargetSample && bRouteMatchesParams + && (TargetSampleCount > CandidateBestTargetSampleCount + || (TargetSampleCount == CandidateBestTargetSampleCount + && Length > CandidateBestLength))) + { + CandidateBestTunnelIndex = TunnelIndex; + CandidateBestLength = Length; + CandidateBestTargetSampleCount = TargetSampleCount; + } + } + + if (CandidateBestTunnelIndex != INDEX_NONE + && CandidateBestLength > BestLength) + { + Cache = MoveTemp(CandidateCache); + Params = CandidateParams; + StrateIndex = CandidateStrateIndex; + QueryChunkZ = CandidateChunkZ; + BestLength = CandidateBestLength; + BestTunnelIndex = CandidateBestTunnelIndex; + BestTargetBandPointCount = CandidateBestTargetSampleCount; + bBestTunnelFullyInTargetStrate = true; + BestParamsFingerprint = CandidateFingerprint; + } + } + + BestTunnel = Cache.Tunnels.IsValidIndex(BestTunnelIndex) + ? &Cache.Tunnels[BestTunnelIndex] : nullptr; + if (BestTunnel == nullptr || BestLength < 40.0f) + { + OutOutput.Status = TEXT("error"); + OutOutput.RefusalReason = FString::Printf( + TEXT("No cached owner tunnel had a 40-voxel span with stable game params " + "and a stable radial band (best=%.3f)."), BestLength); + OutOutput.Seconds = FPlatformTime::Seconds() - StartSeconds; + return false; + } + + const TArray& Points = BestTunnel->WorldControlPoints.Num() >= 2 + && BestTunnel->WorldControlRadii.Num() == BestTunnel->WorldControlPoints.Num() + ? BestTunnel->WorldControlPoints : BestTunnel->ControlPoints; + TArray CumulativeLengths; + CumulativeLengths.SetNumZeroed(Points.Num()); + for (int32 PointIndex = 0; PointIndex + 1 < Points.Num(); ++PointIndex) + { + CumulativeLengths[PointIndex + 1] = CumulativeLengths[PointIndex] + + FVector::Dist(Points[PointIndex], Points[PointIndex + 1]); + } + + const float ProbeStep = OutOutput.StepVoxels; + const int32 MaxProbeSamples = 4097; + float ProbeStart = 0.0f; + float ProbeLength = FMath::Min( + BestLength, static_cast(MaxProbeSamples - 1) * ProbeStep); + + auto PointAtDistance = [&](float Distance) -> FVector + { + const float ClampedDistance = FMath::Clamp(Distance, 0.0f, BestLength); + for (int32 SegmentIndex = 0; SegmentIndex + 1 < Points.Num(); ++SegmentIndex) + { + if (ClampedDistance <= CumulativeLengths[SegmentIndex + 1] + || SegmentIndex + 2 == Points.Num()) + { + const float SegmentLength = CumulativeLengths[SegmentIndex + 1] + - CumulativeLengths[SegmentIndex]; + const float Alpha = SegmentLength > KINDA_SMALL_NUMBER + ? (ClampedDistance - CumulativeLengths[SegmentIndex]) / SegmentLength : 0.0f; + return FMath::Lerp(Points[SegmentIndex], Points[SegmentIndex + 1], Alpha); + } + } + return Points.Last(); + }; + + // Keep a long, endpoint-inset window on the selected tunnel chain. The DFT is intended to + // expose a repeated radial surface displacement, so using the entire wandering chain would + // mix mouth overlap and changes in the local frame into that signal. The center window stays + // on the same owner-parameter band already validated above and is at least 40 voxels long. + const float MaximumWindowLength = static_cast(MaxProbeSamples - 1) * ProbeStep; + if (BestLength >= 56.0f) + { + ProbeLength = FMath::Min(96.0f, FMath::Min(BestLength - 16.0f, MaximumWindowLength)); + if (ProbeLength >= 40.0f) + { + ProbeStart = FMath::Clamp( + (BestLength - ProbeLength) * 0.5f, 8.0f, BestLength - ProbeLength - 8.0f); + } + } + if (ProbeStart + ProbeLength > BestLength) + { + ProbeStart = 0.0f; + ProbeLength = FMath::Min(BestLength, MaximumWindowLength); + } + const int32 SampleCount = FMath::FloorToInt(ProbeLength / ProbeStep) + 1; + ProbeLength = static_cast(SampleCount - 1) * ProbeStep; + OutOutput.TunnelIndex = BestTunnelIndex; + OutOutput.SampleCount = SampleCount; + OutOutput.LengthVoxels = ProbeLength; + + FVector AxisDirection = PointAtDistance(ProbeStart + ProbeLength) + - PointAtDistance(ProbeStart); + AxisDirection.Z = 0.0f; + if (!AxisDirection.Normalize()) + { + float LongestSegment = 0.0f; + for (int32 SegmentIndex = 0; SegmentIndex + 1 < Points.Num(); ++SegmentIndex) + { + FVector Candidate = Points[SegmentIndex + 1] - Points[SegmentIndex]; + Candidate.Z = 0.0f; + const float SegmentLength = Candidate.Size(); + if (SegmentLength > LongestSegment) + { + LongestSegment = SegmentLength; + AxisDirection = Candidate / SegmentLength; + } + } + } + if (!AxisDirection.Normalize()) + { + AxisDirection = FVector::ForwardVector; + } + const FVector WallDirection(-AxisDirection.Y, AxisDirection.X, 0.0f); + OutOutput.AxisStart = PointAtDistance(ProbeStart); + OutOutput.AxisEnd = PointAtDistance(ProbeStart + ProbeLength); + OutOutput.AxisDirection = AxisDirection; + OutOutput.WallDirection = WallDirection; + + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeTunnelCoreProbe] cache_tunnels=%d strate_index=%d " + "query_chunk_z=%d target_z=[%.3f,%.3f] params_z=[%.3f,%.3f] selected=%d " + "params_fp=0x%08X window_start=%.3f window_length=%.3f " + "selected_fully_in_target=%d target_points=%d"), + Cache.Tunnels.Num(), StrateIndex, QueryChunkZ, TargetMinZ, TargetMaxZ, + Params.StrateBottomWorldZ, Params.StrateTopWorldZ, BestTunnelIndex, + BestParamsFingerprint, ProbeStart, ProbeLength, + bBestTunnelFullyInTargetStrate ? 1 : 0, BestTargetBandPointCount); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeTunnelCoreProbe] selected_endpoints a=(%.3f,%.3f,%.3f) " + "b=(%.3f,%.3f,%.3f) world_min=(%.3f,%.3f,%.3f) " + "world_max=(%.3f,%.3f,%.3f) world_radius=%.3f"), + BestTunnel->EndpointA.X, BestTunnel->EndpointA.Y, BestTunnel->EndpointA.Z, + BestTunnel->EndpointB.X, BestTunnel->EndpointB.Y, BestTunnel->EndpointB.Z, + BestTunnel->WorldCenterlineMin.X, BestTunnel->WorldCenterlineMin.Y, + BestTunnel->WorldCenterlineMin.Z, BestTunnel->WorldCenterlineMax.X, + BestTunnel->WorldCenterlineMax.Y, BestTunnel->WorldCenterlineMax.Z, + FMath::Sqrt(FMath::Max(BestTunnel->WorldBoundRadiusSq, 0.0f))); + + float MaxRadius = 0.0f; + const TArray& Radii = BestTunnel->WorldControlRadii.Num() == Points.Num() + ? BestTunnel->WorldControlRadii : BestTunnel->ControlRadii; + for (const float Radius : Radii) + { + MaxRadius = FMath::Max(MaxRadius, FMath::Abs(Radius)); + } + const float MaxRadialDistance = FMath::Clamp(MaxRadius + 12.0f, 12.0f, 64.0f); + + auto FindZeroCrossing = [&](const FVector& Origin, const FVector& Direction, + bool bCoreSDF, float& OutDistance) -> bool + { + auto Evaluate = [&](float Distance) -> float + { + const FVector Position = Origin + Direction * Distance; + return bCoreSDF + ? VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF( + Position.X, Position.Y, Position.Z, Cache, true) + : World.Generator->GetDensityAt(Position.X, Position.Y, Position.Z); + }; + float PreviousDistance = 0.0f; + float PreviousValue = Evaluate(0.0f); + bool bHavePrevious = FMath::IsFinite(PreviousValue); + if (bHavePrevious && FMath::Abs(PreviousValue) <= 1.0e-4f) + { + OutDistance = 0.0f; + return true; + } + const int32 RadialSteps = FMath::CeilToInt(MaxRadialDistance / ProbeStep); + for (int32 StepIndex = 1; StepIndex <= RadialSteps; ++StepIndex) + { + const float CurrentDistance = FMath::Min( + MaxRadialDistance, static_cast(StepIndex) * ProbeStep); + const float CurrentValue = Evaluate(CurrentDistance); + if (!FMath::IsFinite(CurrentValue)) + { + // A transient invalid query must split the finite run, not poison every + // subsequent sample. The floor ray can cross a chunk/parameter seam where + // one exact fractional query is unavailable; later finite samples still form a + // valid zero-crossing interval and are deliberately allowed to restart it. + bHavePrevious = false; + continue; + } + if (!bHavePrevious) + { + PreviousDistance = CurrentDistance; + PreviousValue = CurrentValue; + bHavePrevious = true; + if (FMath::Abs(CurrentValue) <= 1.0e-4f) + { + OutDistance = CurrentDistance; + return true; + } + continue; + } + const bool bCrossed = (PreviousValue < 0.0f && CurrentValue >= 0.0f) + || (PreviousValue > 0.0f && CurrentValue <= 0.0f); + if (bCrossed) + { + float Low = PreviousDistance; + float High = CurrentDistance; + float LowValue = PreviousValue; + for (int32 Iteration = 0; Iteration < 24; ++Iteration) + { + const float Mid = (Low + High) * 0.5f; + const float MidValue = Evaluate(Mid); + if (!FMath::IsFinite(MidValue)) + { + break; + } + if (FMath::Abs(MidValue) <= 1.0e-5f) + { + Low = Mid; + High = Mid; + break; + } + if ((LowValue < 0.0f && MidValue < 0.0f) + || (LowValue > 0.0f && MidValue > 0.0f)) + { + Low = Mid; + LowValue = MidValue; + } + else + { + High = Mid; + } + } + OutDistance = (Low + High) * 0.5f; + return true; + } + PreviousDistance = CurrentDistance; + PreviousValue = CurrentValue; + } + return false; + }; + + OutOutput.Floor.Name = TEXT("floor"); + OutOutput.Wall.Name = TEXT("wall"); + OutOutput.Ceiling.Name = TEXT("ceiling"); + OutOutput.Floor.CoreCrossingsVoxels.Reserve(SampleCount); + OutOutput.Floor.FinalCrossingsVoxels.Reserve(SampleCount); + OutOutput.Wall.CoreCrossingsVoxels.Reserve(SampleCount); + OutOutput.Wall.FinalCrossingsVoxels.Reserve(SampleCount); + OutOutput.Ceiling.CoreCrossingsVoxels.Reserve(SampleCount); + OutOutput.Ceiling.FinalCrossingsVoxels.Reserve(SampleCount); + + FString Csv; + Csv.Reserve(SampleCount * 180); + Csv += TEXT("sample,axis_distance_voxels,center_x,center_y,center_z,core_center_sdf,final_center_density," + "floor_core_zero,floor_final_zero,wall_core_zero,wall_final_zero," + "ceiling_core_zero,ceiling_final_zero\r\n"); + float PreviousCoreCenter = 0.0f; + float PreviousFinalCenter = 0.0f; + bool bHavePreviousCoreCenter = false; + bool bHavePreviousFinalCenter = false; + float MaxGeneratorCoreDelta = 0.0f; + int32 GeneratorCoreMismatchCount = 0; + const uint32 ProbeLayoutVersion = World.Manager->GetLayoutVersion(); + for (int32 SampleIndex = 0; SampleIndex < SampleCount; ++SampleIndex) + { + if ((SampleIndex & 127) == 0 && Budget.ShouldStop(TEXT("tunnel-core-probe"))) + { + OutOutput.Status = TEXT("truncated"); + OutOutput.RefusalReason = TEXT("The wall-clock budget stopped the tunnel-core probe."); + OutOutput.Seconds = FPlatformTime::Seconds() - StartSeconds; + return false; + } + const float AxisDistance = static_cast(SampleIndex) * ProbeStep; + const FVector Center = PointAtDistance(ProbeStart + AxisDistance); + const float CoreCenter = VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF( + Center.X, Center.Y, Center.Z, Cache, true); + const float FinalCenter = World.Generator->GetDensityAt(Center.X, Center.Y, Center.Z); + FTunnelCoreWorldEvaluation GeneratorCoreEvaluation; + World.Generator->GetDensityWithParams( + Center.X, Center.Y, Center.Z, Params, BestParamsFingerprint, ProbeLayoutVersion, + /*bApplyLegacyStructuralPosts=*/false, &GeneratorCoreEvaluation, + /*bCollectFusedDiagnostics=*/false); + if (FMath::IsFinite(CoreCenter) && FMath::IsFinite(GeneratorCoreEvaluation.SDF)) + { + const float CoreDelta = FMath::Abs(CoreCenter - GeneratorCoreEvaluation.SDF); + MaxGeneratorCoreDelta = FMath::Max(MaxGeneratorCoreDelta, CoreDelta); + if (CoreDelta > 1.0e-3f) + { + ++GeneratorCoreMismatchCount; + } + } + if (SampleIndex == 0 || SampleIndex == SampleCount / 2 || SampleIndex + 1 == SampleCount) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeTunnelCoreProbe] generator_core_compare sample=%d " + "probe_sdf=%.6g generator_sdf=%.6g generator_mc=%.6g final_mc=%.6g"), + SampleIndex, CoreCenter, GeneratorCoreEvaluation.SDF, + -GeneratorCoreEvaluation.SDF, FinalCenter); + } + if (FMath::IsFinite(CoreCenter)) + { + ++OutOutput.CoreCenterFiniteSampleCount; + OutOutput.CoreCenterMin = OutOutput.CoreCenterFiniteSampleCount == 1 + ? CoreCenter : FMath::Min(OutOutput.CoreCenterMin, CoreCenter); + OutOutput.CoreCenterMax = OutOutput.CoreCenterFiniteSampleCount == 1 + ? CoreCenter : FMath::Max(OutOutput.CoreCenterMax, CoreCenter); + if (bHavePreviousCoreCenter + && ((PreviousCoreCenter < 0.0f && CoreCenter > 0.0f) + || (PreviousCoreCenter > 0.0f && CoreCenter < 0.0f))) + { + ++OutOutput.CoreCenterZeroCrossingCount; + } + PreviousCoreCenter = CoreCenter; + bHavePreviousCoreCenter = true; + } + if (FMath::IsFinite(FinalCenter)) + { + ++OutOutput.FinalCenterFiniteSampleCount; + OutOutput.FinalCenterMin = OutOutput.FinalCenterFiniteSampleCount == 1 + ? FinalCenter : FMath::Min(OutOutput.FinalCenterMin, FinalCenter); + OutOutput.FinalCenterMax = OutOutput.FinalCenterFiniteSampleCount == 1 + ? FinalCenter : FMath::Max(OutOutput.FinalCenterMax, FinalCenter); + OutOutput.FinalCenterMaxAbs = FMath::Max( + OutOutput.FinalCenterMaxAbs, FMath::Abs(FinalCenter)); + // The old hand-off wrote a fixed MC-air value (normally +16, with the reciprocal + // solid plateau at -8 in other definitions). Do not call every ordinary SDF sample + // "saturated"; count only the known clamp plateaus so the probe distinguishes the + // old hard switch from the continuous signed field. + const bool bKnownClampPlateau = + FMath::Abs(FinalCenter - 8.0f) <= 1.0e-3f + || FMath::Abs(FinalCenter + 8.0f) <= 1.0e-3f + || FMath::Abs(FinalCenter - 16.0f) <= 1.0e-3f + || FMath::Abs(FinalCenter + 16.0f) <= 1.0e-3f; + if (bKnownClampPlateau) + { + ++OutOutput.FinalCenterSaturatedSampleCount; + } + if (bHavePreviousFinalCenter + && ((PreviousFinalCenter < 0.0f && FinalCenter > 0.0f) + || (PreviousFinalCenter > 0.0f && FinalCenter < 0.0f))) + { + ++OutOutput.FinalCenterZeroCrossingCount; + } + PreviousFinalCenter = FinalCenter; + bHavePreviousFinalCenter = true; + } + + const FVector RadialDirections[3] = { + FVector::DownVector, WallDirection, FVector::UpVector }; + FExploreTunnelCoreProbeAxis* Axes[3] = { + &OutOutput.Floor, &OutOutput.Wall, &OutOutput.Ceiling }; + float Roots[3][2]; + for (int32 AxisIndex = 0; AxisIndex < 3; ++AxisIndex) + { + Roots[AxisIndex][0] = std::numeric_limits::quiet_NaN(); + Roots[AxisIndex][1] = std::numeric_limits::quiet_NaN(); + FindZeroCrossing(Center, RadialDirections[AxisIndex], true, Roots[AxisIndex][0]); + FindZeroCrossing(Center, RadialDirections[AxisIndex], false, Roots[AxisIndex][1]); + Axes[AxisIndex]->CoreCrossingsVoxels.Add(Roots[AxisIndex][0]); + Axes[AxisIndex]->FinalCrossingsVoxels.Add(Roots[AxisIndex][1]); + } + if (SampleIndex == 50 || SampleIndex == 302) + { + const float DiagnosticDistances[] = { 0.0f, 4.0f, 8.0f, 10.0f, 11.0f, + 12.0f, 13.0f, 16.0f, 20.0f, 24.0f, 28.0f, 32.0f }; + for (int32 AxisIndex = 0; AxisIndex < 3; ++AxisIndex) + { + FString Values; + for (const float Distance : DiagnosticDistances) + { + const FVector Position = Center + RadialDirections[AxisIndex] * Distance; + const float CoreValue = VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF( + Position.X, Position.Y, Position.Z, Cache, true); + FTunnelCoreWorldEvaluation GenericCoreEvaluation; + const float GenericValue = World.Generator->GetDensityWithParams( + Position.X, Position.Y, Position.Z, Params, BestParamsFingerprint, + ProbeLayoutVersion, /*bApplyLegacyStructuralPosts=*/false, + &GenericCoreEvaluation, /*bCollectFusedDiagnostics=*/false); + const float FinalValue = World.Generator->GetDensityAt( + Position.X, Position.Y, Position.Z); + Values += FString::Printf( + TEXT(" %.0f:(%.3g,%.3g,%.3g)"), Distance, CoreValue, + GenericValue, FinalValue); + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeTunnelCoreProbe] radial_diagnostic sample=%d axis=%s " + "distance:(core_sdf,generic_mc,final_mc)%s"), + SampleIndex, *Axes[AxisIndex]->Name, *Values); + } + } + Csv += FString::Printf( + TEXT("%d,%.9g,%.9g,%.9g,%.9g,%.9g,%.9g,%.9g,%.9g,%.9g,%.9g,%.9g,%.9g\r\n"), + SampleIndex, AxisDistance, Center.X, Center.Y, Center.Z, + CoreCenter, FinalCenter, + Roots[0][0], Roots[0][1], Roots[1][0], Roots[1][1], Roots[2][0], Roots[2][1]); + } + + FExploreTunnelCoreProbeAxis* Axes[3] = { + &OutOutput.Floor, &OutOutput.Wall, &OutOutput.Ceiling }; + for (FExploreTunnelCoreProbeAxis* Axis : Axes) + { + ComputeTunnelCoreProbeMetrics( + Axis->CoreCrossingsVoxels, ProbeStep, + Axis->ValidCoreCrossings, Axis->CoreMeanCrossingVoxels, + Axis->CoreMinCrossingVoxels, Axis->CoreMaxCrossingVoxels, + Axis->CorePeriodicAmplitudeVoxels, Axis->CorePeriodicPeriodVoxels, + Axis->CorePeriodicMeasuredSamples, Axis->CorePeriodicMeasuredLengthVoxels); + ComputeTunnelCoreProbeMetrics( + Axis->FinalCrossingsVoxels, ProbeStep, + Axis->ValidFinalCrossings, Axis->FinalMeanCrossingVoxels, + Axis->FinalMinCrossingVoxels, Axis->FinalMaxCrossingVoxels, + Axis->FinalPeriodicAmplitudeVoxels, Axis->FinalPeriodicPeriodVoxels, + Axis->FinalPeriodicMeasuredSamples, Axis->FinalPeriodicMeasuredLengthVoxels); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeTunnelCoreProbe] axis=%s samples=%d final_valid=%d " + "rib_periodic_amplitude=%.6g_voxels=%.6g_cm period=%.6g_voxels " + "measured=%.6g_voxels/%d core_valid=%d core_rib_periodic_amplitude=%.6g_voxels=%.6g_cm period=%.6g_voxels " + "measured=%.6g_voxels/%d"), + *Axis->Name, SampleCount, Axis->ValidFinalCrossings, + Axis->FinalPeriodicAmplitudeVoxels, + Axis->FinalPeriodicAmplitudeVoxels * 25.0f, + Axis->FinalPeriodicPeriodVoxels, + Axis->FinalPeriodicMeasuredLengthVoxels, Axis->FinalPeriodicMeasuredSamples, + Axis->ValidCoreCrossings, Axis->CorePeriodicAmplitudeVoxels, + Axis->CorePeriodicAmplitudeVoxels * 25.0f, + Axis->CorePeriodicPeriodVoxels, + Axis->CorePeriodicMeasuredLengthVoxels, Axis->CorePeriodicMeasuredSamples); + } + + const FString Path = FPaths::Combine(Arguments.OutDirectory, OutOutput.FileName); + if (!FFileHelper::SaveStringToFile( + Csv, *Path, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)) + { + OutOutput.Status = TEXT("error"); + OutOutput.RefusalReason = TEXT("Could not write tunnel_core_probe.csv."); + OutOutput.Seconds = FPlatformTime::Seconds() - StartSeconds; + return false; + } + OutOutput.Status = TEXT("ok"); + OutOutput.Seconds = FPlatformTime::Seconds() - StartSeconds; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeTunnelCoreProbe] tunnel=%d samples=%d step=%.3f length=%.3f " + "final_center_finite=%d known_clamp_plateau=%d range=[%.6g,%.6g] " + "core_center_finite=%d range=[%.6g,%.6g] file=%s"), + OutOutput.TunnelIndex, OutOutput.SampleCount, OutOutput.StepVoxels, + OutOutput.LengthVoxels, OutOutput.FinalCenterFiniteSampleCount, + OutOutput.FinalCenterSaturatedSampleCount, OutOutput.FinalCenterMin, + OutOutput.FinalCenterMax, OutOutput.CoreCenterFiniteSampleCount, + OutOutput.CoreCenterMin, OutOutput.CoreCenterMax, *Path); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeTunnelCoreProbe] generator_core_compare_max_abs_delta=%.6g " + "mismatched_samples=%d tolerance=0.001"), + MaxGeneratorCoreDelta, GeneratorCoreMismatchCount); + return true; +} + +void WriteTunnelCoreProbeAxis( + FExploreJsonWriter& Writer, + const FExploreTunnelCoreProbeAxis& Axis, + const TCHAR* Key) +{ + Writer.WriteObjectStart(Key); + Writer.WriteValue(TEXT("valid_core_crossings"), Axis.ValidCoreCrossings); + Writer.WriteValue(TEXT("valid_final_crossings"), Axis.ValidFinalCrossings); + Writer.WriteValue(TEXT("core_mean_crossing_voxels"), static_cast(Axis.CoreMeanCrossingVoxels)); + Writer.WriteValue(TEXT("final_mean_crossing_voxels"), static_cast(Axis.FinalMeanCrossingVoxels)); + Writer.WriteValue(TEXT("core_min_crossing_voxels"), static_cast(Axis.CoreMinCrossingVoxels)); + Writer.WriteValue(TEXT("core_max_crossing_voxels"), static_cast(Axis.CoreMaxCrossingVoxels)); + Writer.WriteValue(TEXT("final_min_crossing_voxels"), static_cast(Axis.FinalMinCrossingVoxels)); + Writer.WriteValue(TEXT("final_max_crossing_voxels"), static_cast(Axis.FinalMaxCrossingVoxels)); + Writer.WriteValue(TEXT("core_periodic_amplitude_voxels"), static_cast(Axis.CorePeriodicAmplitudeVoxels)); + Writer.WriteValue(TEXT("core_periodic_amplitude_cm"), static_cast(Axis.CorePeriodicAmplitudeVoxels) * 25.0); + Writer.WriteValue(TEXT("core_periodic_period_voxels"), static_cast(Axis.CorePeriodicPeriodVoxels)); + Writer.WriteValue(TEXT("core_periodic_measured_samples"), Axis.CorePeriodicMeasuredSamples); + Writer.WriteValue(TEXT("core_periodic_measured_length_voxels"), static_cast(Axis.CorePeriodicMeasuredLengthVoxels)); + Writer.WriteValue(TEXT("final_periodic_amplitude_voxels"), static_cast(Axis.FinalPeriodicAmplitudeVoxels)); + Writer.WriteValue(TEXT("final_periodic_amplitude_cm"), static_cast(Axis.FinalPeriodicAmplitudeVoxels) * 25.0); + Writer.WriteValue(TEXT("final_periodic_period_voxels"), static_cast(Axis.FinalPeriodicPeriodVoxels)); + Writer.WriteValue(TEXT("final_periodic_measured_samples"), Axis.FinalPeriodicMeasuredSamples); + Writer.WriteValue(TEXT("final_periodic_measured_length_voxels"), static_cast(Axis.FinalPeriodicMeasuredLengthVoxels)); + Writer.WriteObjectEnd(); +} + void WritePlayerDimensions(FExploreJsonWriter& Writer, const TCHAR* Key) { Writer.WriteObjectStart(Key); @@ -4881,6 +5803,7 @@ FString BuildExploreJson( if (Arguments.bWalk) Writer->WriteValue(TEXT("walk")); if (Arguments.bExport) Writer->WriteValue(TEXT("export")); if (Arguments.bOpBounds) Writer->WriteValue(TEXT("opbounds")); + if (Arguments.bTunnelCoreProbe) Writer->WriteValue(TEXT("tunnelcoreprobe")); Writer->WriteArrayEnd(); Writer->WriteValue(TEXT("failure_focus_render"), Arguments.bFailureFocusRender); Writer->WriteValue(TEXT("render_step_voxels"), static_cast(Arguments.RenderStepVoxels)); @@ -4897,6 +5820,7 @@ FString BuildExploreJson( static_cast(Arguments.SampleStacksIntervalUs)); Writer->WriteValue(TEXT("profile_lod"), Arguments.bProfileLod); Writer->WriteValue(TEXT("density_slice_requested"), Arguments.bDensitySlice); + Writer->WriteValue(TEXT("tunnel_core_probe_requested"), Arguments.bTunnelCoreProbe); Writer->WriteValue(TEXT("render_target_override"), Arguments.bRenderTargetOverride); if (Arguments.bRenderTargetOverride) { @@ -4938,6 +5862,35 @@ FString BuildExploreJson( } Writer->WriteObjectEnd(); + Writer->WriteObjectStart(TEXT("tunnel_core_probe")); + Writer->WriteValue(TEXT("status"), Output.TunnelCoreProbe.Status); + Writer->WriteValue(TEXT("file"), Output.TunnelCoreProbe.FileName); + Writer->WriteValue(TEXT("refusal_or_error"), Output.TunnelCoreProbe.RefusalReason); + Writer->WriteValue(TEXT("tunnel_index"), Output.TunnelCoreProbe.TunnelIndex); + Writer->WriteValue(TEXT("sample_count"), Output.TunnelCoreProbe.SampleCount); + Writer->WriteValue(TEXT("step_voxels"), static_cast(Output.TunnelCoreProbe.StepVoxels)); + Writer->WriteValue(TEXT("length_voxels"), static_cast(Output.TunnelCoreProbe.LengthVoxels)); + WriteJsonVector(*Writer, TEXT("axis_start_voxels"), Output.TunnelCoreProbe.AxisStart, 1.0f); + WriteJsonVector(*Writer, TEXT("axis_end_voxels"), Output.TunnelCoreProbe.AxisEnd, 1.0f); + WriteJsonVector(*Writer, TEXT("axis_direction"), Output.TunnelCoreProbe.AxisDirection, 1.0f); + WriteJsonVector(*Writer, TEXT("wall_direction"), Output.TunnelCoreProbe.WallDirection, 1.0f); + Writer->WriteValue(TEXT("core_center_finite_sample_count"), Output.TunnelCoreProbe.CoreCenterFiniteSampleCount); + Writer->WriteValue(TEXT("core_center_zero_crossing_count"), Output.TunnelCoreProbe.CoreCenterZeroCrossingCount); + Writer->WriteValue(TEXT("final_center_finite_sample_count"), Output.TunnelCoreProbe.FinalCenterFiniteSampleCount); + Writer->WriteValue(TEXT("final_center_zero_crossing_count"), Output.TunnelCoreProbe.FinalCenterZeroCrossingCount); + Writer->WriteValue(TEXT("final_center_saturated_sample_count"), Output.TunnelCoreProbe.FinalCenterSaturatedSampleCount); + Writer->WriteValue(TEXT("core_center_min"), static_cast(Output.TunnelCoreProbe.CoreCenterMin)); + Writer->WriteValue(TEXT("core_center_max"), static_cast(Output.TunnelCoreProbe.CoreCenterMax)); + Writer->WriteValue(TEXT("final_center_min"), static_cast(Output.TunnelCoreProbe.FinalCenterMin)); + Writer->WriteValue(TEXT("final_center_max"), static_cast(Output.TunnelCoreProbe.FinalCenterMax)); + Writer->WriteValue(TEXT("final_center_max_abs"), static_cast(Output.TunnelCoreProbe.FinalCenterMaxAbs)); + Writer->WriteValue(TEXT("periodic_method"), TEXT("32-voxel local-median detrend over the longest contiguous >=40-voxel run, single-sided DFT peak in the <=16-voxel band; amplitudes are sinusoid amplitudes")); + WriteTunnelCoreProbeAxis(*Writer, Output.TunnelCoreProbe.Floor, TEXT("floor")); + WriteTunnelCoreProbeAxis(*Writer, Output.TunnelCoreProbe.Wall, TEXT("wall")); + WriteTunnelCoreProbeAxis(*Writer, Output.TunnelCoreProbe.Ceiling, TEXT("ceiling")); + Writer->WriteValue(TEXT("seconds"), Output.TunnelCoreProbe.Seconds); + Writer->WriteObjectEnd(); + Writer->WriteObjectStart(TEXT("summary")); const double SummaryMeshSeconds = Arguments.bExport ? Output.Export.MeshSeconds @@ -6150,6 +7103,25 @@ int32 RunExploreCase(const FString& Params, FString* OutJson) FExploreRunOutput Output; bool bRequestedModeFailed = false; + if (Arguments.bTunnelCoreProbe) + { + const double Start = FPlatformTime::Seconds(); + const bool bProbeOk = !Budget.bTruncated + && RunTunnelCoreProbe(Arguments, World, Output.TunnelCoreProbe, Budget); + if (!bProbeOk && !Budget.bTruncated) + { + bRequestedModeFailed = true; + } + if (bProbeOk && !Budget.bTruncated) + { + Budget.CompleteMode(TEXT("tunnelcoreprobe")); + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeExplore] tunnel core probe %.3fs (%s)"), + FPlatformTime::Seconds() - Start, + bProbeOk ? TEXT("ok") + : (Budget.bTruncated ? TEXT("truncated") : TEXT("error"))); + } if (Arguments.bDensitySlice) { const double Start = FPlatformTime::Seconds();