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();