diff --git a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp index 03f14b7..4741ef4 100644 --- a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp +++ b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -90,6 +90,11 @@ namespace // is only used to skip a floor evaluation when the existing SmoothMax is provably the identity; // it never changes a value that could affect the isosurface. constexpr float VF_FloorReliefNoiseAbsBound = 1.01f; + // A room owns a short, build-authored mouth apron. The full room radius is intentionally + // not used here: doing so lets a centre-to-centre tunnel floor overwrite the room floor far + // inside the chamber and recreates the broad raised shelf this hand-off is meant to remove. + constexpr float VF_TunnelMouthBlendRadiusVoxels = + VoxelPassageGeometry::WalkableTunnelLandingApronVoxels; enum class EVFFloorReliefFeature : uint8 { @@ -443,6 +448,326 @@ namespace 0.0f, VF_MaxTunnelFloorReliefScale); } + FORCEINLINE float VF_TunnelFloorReliefScaleForGradient( + const FVector& A, const FVector& B, + int32 SegmentIndex, int32 NumSegments, + const FCachedTunnel& Tunnel, + float BaseGradient, bool bTerraced) + { + if (bTerraced + || !(Tunnel.FloorReliefStrength > 0.0f) + || !FMath::IsFinite(Tunnel.FloorReliefStrength) + || !FMath::IsFinite(Tunnel.FloorReliefFrequency)) + { + return 0.0f; + } + + const float HorizontalRun = FVector2D( + static_cast(B.X - A.X), static_cast(B.Y - A.Y)).Size(); + if (!(HorizontalRun > KINDA_SMALL_NUMBER) + || !FMath::IsFinite(BaseGradient) + || BaseGradient < 0.0f) + { + return 0.0f; + } + + const float AvailableGradient = + VoxelPassageGeometry::PlayerWalkableFloorMaxGradient - BaseGradient; + if (!(AvailableGradient > 0.0f)) + { + return 0.0f; + } + + const float Frequency = FMath::Abs(Tunnel.FloorReliefFrequency); + const float Amplitude = FMath::Abs(Tunnel.FloorReliefStrength) + * VOXEL_NOISE_SCALE; + const float OctaveFrequencyWeight = 0.65f + 0.35f * 2.3f; + const float NoiseGradientBound = + 1.4142135623730951f * VF_Perlin2DPartialAbsBound + * OctaveFrequencyWeight * Frequency * Amplitude; + const int32 LandingEnvelopeCount = (SegmentIndex == 0 ? 1 : 0) + + (SegmentIndex + 1 == NumSegments ? 1 : 0); + const float EnvelopeDerivativeBound = LandingEnvelopeCount > 0 + ? (1.5f * static_cast(LandingEnvelopeCount) + / VoxelPassageGeometry::WalkableTunnelLandingApronVoxels) + * Amplitude + : 0.0f; + const float ReliefGradientBound = NoiseGradientBound + + EnvelopeDerivativeBound; + if (!(ReliefGradientBound > KINDA_SMALL_NUMBER) + || !FMath::IsFinite(ReliefGradientBound)) + { + return 0.0f; + } + + return FMath::Clamp( + FMath::Min( + VF_MaxTunnelFloorReliefScale, + AvailableGradient / ReliefGradientBound), + 0.0f, VF_MaxTunnelFloorReliefScale); + } + + static void VF_BuildTunnelFloorProfile( + const TArray& ControlPoints, + const TArray& ControlRadii, + const FCachedTunnel& Tunnel, + TArray& OutProfiles) + { + OutProfiles.Reset(); + if (ControlPoints.Num() < 2 + || ControlRadii.Num() != ControlPoints.Num()) + { + return; + } + + const int32 NumSegments = ControlPoints.Num() - 1; + OutProfiles.SetNum(NumSegments); + TArray> FloorAtControl; + FloorAtControl.SetNum(ControlPoints.Num()); + for (int32 Index = 0; Index < ControlPoints.Num(); ++Index) + { + FloorAtControl[Index] = VoxelPassageGeometry::TunnelFloorZ( + ControlPoints[Index], FMath::Abs(ControlRadii[Index])); + } + + const float GentleThreshold = FMath::IsFinite( + Tunnel.TunnelFloorGentleSlopeThreshold) + ? FMath::Max(Tunnel.TunnelFloorGentleSlopeThreshold, 0.0f) + : VoxelPassageGeometry::PlayerWalkableFloorMaxGradient; + const float LegacyStepHeight = FMath::IsFinite( + Tunnel.TunnelFloorTerraceStepHeight) + ? FMath::Max(Tunnel.TunnelFloorTerraceStepHeight, 0.0f) + : 0.0f; + const int32 MaxLedges = FMath::Clamp( + Tunnel.TunnelFloorMaxLedges, 1, 4096); + const int32 PreferredLedges = FMath::Max( + Tunnel.TunnelFloorLedgeCountPreference, 0); + + bool bGlobalTerrace = false; + int32 GlobalLedgeCount = 0; + if (Tunnel.bTunnelFloorTerracingEnabled && PreferredLedges > 0) + { + float TotalHorizontalRun = 0.0f; + for (int32 SegmentIndex = 0; SegmentIndex < NumSegments; ++SegmentIndex) + { + TotalHorizontalRun += FVector2D( + static_cast(ControlPoints[SegmentIndex + 1].X + - ControlPoints[SegmentIndex].X), + static_cast(ControlPoints[SegmentIndex + 1].Y + - ControlPoints[SegmentIndex].Y)).Size(); + } + const float TotalFloorDelta = FMath::Abs( + FloorAtControl.Last() - FloorAtControl[0]); + bGlobalTerrace = TotalHorizontalRun > KINDA_SMALL_NUMBER + && TotalFloorDelta > TotalHorizontalRun * GentleThreshold; + if (bGlobalTerrace) + { + const float MaxLedgeHeight = FMath::IsFinite( + Tunnel.TunnelFloorMaxLedgeHeight) + ? FMath::Max(Tunnel.TunnelFloorMaxLedgeHeight, KINDA_SMALL_NUMBER) + : KINDA_SMALL_NUMBER; + const int32 HeightDrivenCount = FMath::CeilToInt( + TotalFloorDelta / MaxLedgeHeight); + GlobalLedgeCount = FMath::Clamp( + FMath::Max(PreferredLedges, HeightDrivenCount), + 1, FMath::Min(MaxLedges, NumSegments)); + } + } + + for (int32 SegmentIndex = 0; SegmentIndex < NumSegments; ++SegmentIndex) + { + FTunnelFloorSegmentProfile& Profile = OutProfiles[SegmentIndex]; + const FVector& A = ControlPoints[SegmentIndex]; + const FVector& B = ControlPoints[SegmentIndex + 1]; + const float FloorA = FloorAtControl[SegmentIndex]; + const float FloorB = FloorAtControl[SegmentIndex + 1]; + const float HorizontalRun = FVector2D( + static_cast(B.X - A.X), + static_cast(B.Y - A.Y)).Size(); + Profile.StartFloorZ = FloorA; + Profile.EndFloorZ = FloorB; + Profile.NumSteps = 0; + + if (bGlobalTerrace && GlobalLedgeCount > 0) + { + // Place at most one transition at a control-point boundary. Rounding the + // normalized control index distributes the few requested ledges over the full + // chain and, because the count is capped by NumSegments, never skips a level. + const int32 StartLevel = FMath::RoundToInt( + static_cast(SegmentIndex * GlobalLedgeCount) + / static_cast(NumSegments)); + const int32 EndLevel = FMath::RoundToInt( + static_cast((SegmentIndex + 1) * GlobalLedgeCount) + / static_cast(NumSegments)); + const float TotalDelta = FloorAtControl.Last() - FloorAtControl[0]; + Profile.StartFloorZ = FloorAtControl[0] + + TotalDelta * static_cast(StartLevel) + / static_cast(GlobalLedgeCount); + Profile.EndFloorZ = FloorAtControl[0] + + TotalDelta * static_cast(EndLevel) + / static_cast(GlobalLedgeCount); + Profile.NumSteps = EndLevel > StartLevel ? 1 : 0; + } + else if (Tunnel.bTunnelFloorTerracingEnabled + && PreferredLedges == 0 + && HorizontalRun > KINDA_SMALL_NUMBER + && LegacyStepHeight > KINDA_SMALL_NUMBER + && FMath::Abs(FloorB - FloorA) + > HorizontalRun * GentleThreshold) + { + Profile.NumSteps = FMath::Clamp( + FMath::CeilToInt(FMath::Abs(FloorB - FloorA) + / LegacyStepHeight), + 1, MaxLedges); + } + + const bool bTransition = Profile.NumSteps > 0 + && !FMath::IsNearlyEqual(Profile.StartFloorZ, Profile.EndFloorZ); + const float ProfileGradient = (!bTransition && HorizontalRun > KINDA_SMALL_NUMBER) + ? FMath::Abs(Profile.EndFloorZ - Profile.StartFloorZ) / HorizontalRun + : 0.0f; + Profile.ReliefScale = VF_TunnelFloorReliefScaleForGradient( + A, B, SegmentIndex, NumSegments, Tunnel, + ProfileGradient, bTransition); + } + + if (VoxelDensityProfile::AreCountersEnabled()) + { + uint64 AuthoredLedges = 0; + for (const FTunnelFloorSegmentProfile& Profile : OutProfiles) + { + if (Profile.NumSteps > 0) + { + // In whole-chain mode NumSteps is a transition marker. Compatibility mode + // retains the old per-segment count, so report the actual authored step count + // in both cases. + AuthoredLedges += bGlobalTerrace + ? 1u : static_cast(Profile.NumSteps); + } + } + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelFloorProfileBuilds); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelFloorProfileSegments, + static_cast(OutProfiles.Num())); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelFloorProfileLedges, + AuthoredLedges); + } + } + + struct FVFRoomFloorOwnership + { + bool bValid = false; + float FloorZ = 0.0f; + float Weight = 0.0f; + uint32 RoomHash = 0; + }; + + static FVFRoomFloorOwnership VF_FindTunnelMouthOwnership( + const FVector& Position, + const TArray& ControlPoints, + const TArray& ControlRadii, + float MouthBlendRadiusA, + float MouthBlendRadiusB, + float MouthFloorZA, + float MouthFloorZB, + uint32 FloorRoomHashA, + uint32 FloorRoomHashB, + float SDFBlendRadius) + { + FVFRoomFloorOwnership Result; + if (ControlPoints.Num() < 2 + || ControlRadii.Num() != ControlPoints.Num()) + { + return Result; + } + + const float Fade = FMath::Max( + FMath::Max(SDFBlendRadius, 0.0f) * 0.35f, + 1.0f); + float BestScore = FLT_MAX; + for (int32 Endpoint = 0; Endpoint < 2; ++Endpoint) + { + const int32 ControlIndex = Endpoint == 0 + ? 0 : ControlPoints.Num() - 1; + const FVector& Mouth = ControlPoints[ControlIndex]; + const float DX = static_cast(Position.X - Mouth.X); + const float DY = static_cast(Position.Y - Mouth.Y); + const float Distance = FMath::Sqrt(DX * DX + DY * DY); + const float BlendRadius = FMath::Max( + Endpoint == 0 ? MouthBlendRadiusA : MouthBlendRadiusB, + 0.0f); + const float RelativeDistance = Distance - BlendRadius; + const float Weight = RelativeDistance <= 0.0f + ? 1.0f + : (RelativeDistance < Fade + ? 1.0f - SmoothStep01(RelativeDistance / Fade) + : 0.0f); + if (!(Weight > 0.0f)) + { + continue; + } + + // Prefer the nearest normalized mouth. Endpoint order is the deterministic tie-break + // when two large rooms overlap the same tunnel entrance. + const float Score = Distance / FMath::Max(BlendRadius, 1.0f); + if (Score >= BestScore) + { + continue; + } + const float DefaultFloorZ = static_cast(Mouth.Z) + - FMath::Abs(ControlRadii[ControlIndex]); + const float StoredFloorZ = Endpoint == 0 ? MouthFloorZA : MouthFloorZB; + const float FloorZ = StoredFloorZ > -FLT_MAX + && FMath::IsFinite(StoredFloorZ) + ? StoredFloorZ + : DefaultFloorZ; + if (!FMath::IsFinite(FloorZ)) + { + continue; + } + BestScore = Score; + Result.bValid = true; + Result.FloorZ = FloorZ; + Result.Weight = Weight; + Result.RoomHash = Endpoint == 0 ? FloorRoomHashA : FloorRoomHashB; + } + return Result; + } + + static FVFRoomFloorOwnership VF_FindSDFTunnelMouthOwnership( + const FVector& Position, + const FCachedTunnel& Tunnel, + float SDFBlendRadius) + { + if (!Tunnel.bHasFloorRoomOwnership) + { + return FVFRoomFloorOwnership(); + } + return VF_FindTunnelMouthOwnership( + Position, Tunnel.ControlPoints, Tunnel.ControlRadii, + Tunnel.SDFMouthBlendRadiusA, Tunnel.SDFMouthBlendRadiusB, + Tunnel.SDFMouthFloorZA, Tunnel.SDFMouthFloorZB, + Tunnel.FloorRoomHashA, Tunnel.FloorRoomHashB, SDFBlendRadius); + } + + static FVFRoomFloorOwnership VF_FindWorldTunnelMouthOwnership( + const FVector& Position, + const FCachedTunnel& Tunnel, + float SDFBlendRadius) + { + if (!Tunnel.bHasFloorRoomOwnership) + { + return FVFRoomFloorOwnership(); + } + return VF_FindTunnelMouthOwnership( + Position, Tunnel.WorldControlPoints, Tunnel.WorldControlRadii, + Tunnel.WorldMouthBlendRadiusA, Tunnel.WorldMouthBlendRadiusB, + Tunnel.WorldMouthFloorZA, Tunnel.WorldMouthFloorZB, + Tunnel.FloorRoomHashA, Tunnel.FloorRoomHashB, SDFBlendRadius); + } + struct FVFTunnelFloorTerms { float BaseFloorZ = 0.0f; @@ -454,15 +779,66 @@ namespace const FVector& A, float RadiusA, const FVector& B, float RadiusB, float T, int32 SegmentIndex, int32 NumSegments, - const FCachedTunnel& Tunnel) + const FCachedTunnel& Tunnel, + const TArray* FloorProfiles, + const FVFRoomFloorOwnership* RoomFloorOwnership) { FVFTunnelFloorTerms Terms; - const float ProfileT = VF_TunnelFloorProfileT( - A, RadiusA, B, RadiusB, T); - Terms.BaseFloorZ = FMath::Lerp( - VoxelPassageGeometry::TunnelFloorZ(A, FMath::Abs(RadiusA)), - VoxelPassageGeometry::TunnelFloorZ(B, FMath::Abs(RadiusB)), - ProfileT); + if (FloorProfiles != nullptr + && FloorProfiles->Num() == NumSegments + && FloorProfiles->IsValidIndex(SegmentIndex)) + { + const FTunnelFloorSegmentProfile& Profile = (*FloorProfiles)[SegmentIndex]; + const float ClampedT = FMath::Clamp(T, 0.0f, 1.0f); + const float ProfileT = Profile.NumSteps > 0 + ? (ClampedT >= 1.0f + ? 1.0f + : FMath::FloorToFloat(ClampedT + * static_cast(Profile.NumSteps)) + / static_cast(Profile.NumSteps)) + : ClampedT; + Terms.BaseFloorZ = FMath::Lerp( + Profile.StartFloorZ, Profile.EndFloorZ, ProfileT); + Terms.ReliefScale = Profile.ReliefScale; + } + else + { + const float ProfileT = VF_TunnelFloorProfileT( + A, RadiusA, B, RadiusB, T); + Terms.BaseFloorZ = FMath::Lerp( + VoxelPassageGeometry::TunnelFloorZ(A, FMath::Abs(RadiusA)), + VoxelPassageGeometry::TunnelFloorZ(B, FMath::Abs(RadiusB)), + ProfileT); + Terms.ReliefScale = VF_TunnelFloorReliefScale( + A, RadiusA, B, RadiusB, SegmentIndex, NumSegments, Tunnel); + } + + // The room is the owner of its own floor volume. A tunnel capsule penetrates past the + // mouth into that room; without this reconciliation its floor cut can leave a raised or + // lowered apron in the room. Blend only across the SDF mouth band so the tunnel remains + // unchanged in open corridor space. + const bool bOwnsThisTunnelMouth = RoomFloorOwnership != nullptr + && RoomFloorOwnership->bValid + && Tunnel.bHasFloorRoomOwnership + && (RoomFloorOwnership->RoomHash == Tunnel.FloorRoomHashA + || RoomFloorOwnership->RoomHash == Tunnel.FloorRoomHashB); + if (bOwnsThisTunnelMouth) + { + // A raised tunnel apron is the demonstrated failure mode. Lower it toward the room + // floor, but never raise an existing tunnel floor into solid space: that one-sided + // ownership rule is conservative for the capability contract and leaves a lower + // tunnel to be handled by the room's own floor union. + const float OwnedFloorZ = FMath::Min( + Terms.BaseFloorZ, RoomFloorOwnership->FloorZ); + Terms.BaseFloorZ = FMath::Lerp( + Terms.BaseFloorZ, + OwnedFloorZ, + FMath::Clamp(RoomFloorOwnership->Weight, 0.0f, 1.0f)); + // The room's floor relief is already included in FloorZ. Fade the tunnel relief out + // with the ownership blend, so the transition cannot stack two independent floors. + Terms.ReliefScale *= 1.0f - FMath::Clamp( + RoomFloorOwnership->Weight, 0.0f, 1.0f); + } const float HorizontalRun = FVector2D( static_cast(B.X - A.X), static_cast(B.Y - A.Y)).Size(); @@ -481,8 +857,6 @@ namespace / VoxelPassageGeometry::WalkableTunnelLandingApronVoxels, 0.0f, 1.0f)); } - Terms.ReliefScale = VF_TunnelFloorReliefScale( - A, RadiusA, B, RadiusB, SegmentIndex, NumSegments, Tunnel); return Terms; } @@ -513,11 +887,14 @@ namespace const FCachedTunnel& Tunnel, const void* CacheIdentity, int32 TunnelIndex, bool bEvaluateRelief, - const FVFFloorReliefColumnKey& Column) + const FVFFloorReliefColumnKey& Column, + const TArray* FloorProfiles, + const FVFRoomFloorOwnership* RoomFloorOwnership) { const FVFTunnelFloorTerms Terms = VF_TunnelFloorTerms( A, RadiusA, B, RadiusB, - T, SegmentIndex, NumSegments, Tunnel); + T, SegmentIndex, NumSegments, Tunnel, FloorProfiles, + RoomFloorOwnership); return VF_TunnelFloorFromTerms( Position, Tunnel, Terms, CacheIdentity, TunnelIndex, bEvaluateRelief, Column); @@ -556,7 +933,9 @@ namespace float SDFBlendRadius, bool bApplyFloorCut, const void* CacheIdentity, int32 TunnelIndex, - const FVFFloorReliefColumnKey& ReliefColumn) + const FVFFloorReliefColumnKey& ReliefColumn, + const TArray* FloorProfiles, + const FVFRoomFloorOwnership* RoomFloorOwnership) { FVFTunnelShapeEvaluation Result; if (ControlPoints.Num() < 2 @@ -592,7 +971,8 @@ namespace const FVFTunnelFloorTerms FloorTerms = VF_TunnelFloorTerms( A, RadiusA, B, RadiusB, - T, SegmentIndex, NumSegments, Tunnel); + T, SegmentIndex, NumSegments, Tunnel, FloorProfiles, + RoomFloorOwnership); const float SupportRadius = FMath::Max( FMath::Min(FMath::Abs(RadiusA), FMath::Abs(RadiusB)) - 0.5f, VoxelPassageGeometry::PlayerRadiusVoxels); @@ -623,6 +1003,17 @@ namespace + VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels; bEvaluateRelief = !bFloorCannotAffect || !bCannotBeSupport; } + if (bApplyFloorCut && RoomFloorOwnership != nullptr + && RoomFloorOwnership->bValid + && Tunnel.bHasFloorRoomOwnership + && (RoomFloorOwnership->RoomHash == Tunnel.FloorRoomHashA + || RoomFloorOwnership->RoomHash == Tunnel.FloorRoomHashB) + && RoomFloorOwnership->Weight > 0.0f) + { + // Room ownership can move the floor even when the uncapped tunnel floor is + // outside its own relief bound. Keep the exact room relief in that mouth band. + bEvaluateRelief = true; + } const float FloorZ = VF_TunnelFloorFromTerms( Position, Tunnel, FloorTerms, @@ -633,7 +1024,8 @@ namespace : SegmentSDF; Result.SDF = FMath::Min(Result.SDF, SegmentResultSDF); - if (SegmentSDF <= 0.0f + if (bApplyFloorCut && Tunnel.bTunnelFloorEnabled + && SegmentSDF <= 0.0f && HorizontalDistanceSquared <= FMath::Square(SupportRadius) && Position.Z >= FloorZ - VoxelPassageGeometry::LandingFloorThicknessVoxels @@ -652,7 +1044,8 @@ namespace bool bWorldChain, float SDFBlendRadius, bool bApplyFloorCut, - const void* CacheIdentity, int32 TunnelIndex) + const void* CacheIdentity, int32 TunnelIndex, + const FVFRoomFloorOwnership* RoomFloorOwnership = nullptr) { const FVFFloorReliefColumnKey ReliefColumn = VF_MakeFloorReliefColumnKey( @@ -667,7 +1060,9 @@ namespace return VF_EvaluateSweptTunnelChain( Position, ShapeControlPoints, ShapeControlRadii, Tunnel, SDFBlendRadius, bApplyFloorCut, - CacheIdentity, TunnelIndex, ReliefColumn); + CacheIdentity, TunnelIndex, ReliefColumn, + bWorldChain ? &Tunnel.WorldFloorProfiles : &Tunnel.FloorProfiles, + RoomFloorOwnership); } if (Tunnel.bHasMidpoint) @@ -685,7 +1080,9 @@ namespace return VF_EvaluateSweptTunnelChain( Position, Points, Radii, Tunnel, SDFBlendRadius, bApplyFloorCut, - CacheIdentity, TunnelIndex, ReliefColumn); + CacheIdentity, TunnelIndex, ReliefColumn, + bWorldChain ? &Tunnel.WorldFloorProfiles : &Tunnel.FloorProfiles, + RoomFloorOwnership); } TArray Points; @@ -697,7 +1094,9 @@ namespace return VF_EvaluateSweptTunnelChain( Position, Points, Radii, Tunnel, SDFBlendRadius, bApplyFloorCut, - CacheIdentity, TunnelIndex, ReliefColumn); + CacheIdentity, TunnelIndex, ReliefColumn, + bWorldChain ? &Tunnel.WorldFloorProfiles : &Tunnel.FloorProfiles, + RoomFloorOwnership); } static bool VF_ProjectSweptTunnelFloor( @@ -706,8 +1105,13 @@ namespace bool bWorldChain, float& OutFloorZ, float& OutSupportRadius, - const void* CacheIdentity, int32 TunnelIndex) + 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)); @@ -746,7 +1150,9 @@ namespace ControlPoints[SegmentIndex + 1], RadiusB, T, SegmentIndex, NumSegments, Position, Tunnel, CacheIdentity, TunnelIndex, /*bEvaluateRelief=*/true, - ReliefColumn); + ReliefColumn, + bWorldChain ? &Tunnel.WorldFloorProfiles : &Tunnel.FloorProfiles, + RoomFloorOwnership); OutSupportRadius = FMath::Max( FMath::Min(FMath::Abs(RadiusA), FMath::Abs(RadiusB)) - 0.5f, VoxelPassageGeometry::PlayerRadiusVoxels); @@ -768,6 +1174,11 @@ void FChunkSDFCache::Reset() // populated regions on adjacent requests. Rooms.Reset(); RoomFloorJoins.Reset(); + for (FCachedTunnel& Tunnel : Tunnels) + { + Tunnel.FloorProfiles.Reset(); + Tunnel.WorldFloorProfiles.Reset(); + } Tunnels.Reset(); Pits.Reset(); Chimneys.Reset(); @@ -829,6 +1240,8 @@ VoxelDensityProfile::FCacheMemoryBreakdown FChunkSDFCache::GetAllocatedSizeBreak Breakdown.TunnelsBytes += ArrayBytes(Tunnel.ControlRadii); Breakdown.TunnelsBytes += ArrayBytes(Tunnel.WorldControlPoints); Breakdown.TunnelsBytes += ArrayBytes(Tunnel.WorldControlRadii); + Breakdown.TunnelsBytes += ArrayBytes(Tunnel.FloorProfiles); + Breakdown.TunnelsBytes += ArrayBytes(Tunnel.WorldFloorProfiles); } return Breakdown; @@ -4773,6 +5186,16 @@ void VoxelCaveMorphology::BuildChunkCache( CT.FloorReliefStrength = Params.FloorReliefStrength; CT.FloorReliefFrequency = Params.FloorReliefFrequency; CT.FloorSeed = VoxelHash::Mix(TunnelHash ^ 0xF100F1u); + 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; + CT.TunnelFloorGentleSlopeThreshold = Params.TunnelFloorGentleSlopeThreshold; + CT.TunnelFloorLedgeCountPreference = Params.TunnelFloorLedgeCountPreference; + CT.TunnelFloorMaxLedges = Params.TunnelFloorMaxLedges; // A room edge is not a straight capsule between cell centres. Build a deterministic // chain of hash-jittered control points instead. The chain is keyed only by the pair @@ -4871,6 +5294,44 @@ void VoxelCaveMorphology::BuildChunkCache( CT.ControlRadii.Add(ControlRadius); } + // Author the complete floor profile once, after both representations of the chain + // are known. Evaluation now only projects onto this immutable profile; no sample can + // independently decide how many terraces the tunnel needs. + VF_BuildTunnelFloorProfile( + CT.ControlPoints, CT.ControlRadii, CT, CT.FloorProfiles); + VF_BuildTunnelFloorProfile( + CT.WorldControlPoints, CT.WorldControlRadii, CT, CT.WorldFloorProfiles); + // The floor hand-off is a short apron around each destination, not a second floor + // that owns the whole room. Bake the same bounded region in both coordinate spaces; + // this keeps the room/tunnel boolean deterministic even though the cave warp is 3D. + CT.SDFMouthBlendRadiusA = FMath::Min( + FMath::Max(RoomA.RadiusXY, 0.0f), VF_TunnelMouthBlendRadiusVoxels); + CT.SDFMouthBlendRadiusB = FMath::Min( + FMath::Max(RoomB.RadiusXY, 0.0f), VF_TunnelMouthBlendRadiusVoxels); + CT.WorldMouthBlendRadiusA = CT.SDFMouthBlendRadiusA; + CT.WorldMouthBlendRadiusB = CT.SDFMouthBlendRadiusB; + if (CT.FloorProfiles.Num() == CT.ControlPoints.Num() - 1 + && CT.FloorProfiles.Num() > 0) + { + CT.SDFMouthFloorZA = CT.FloorProfiles[0].StartFloorZ; + CT.SDFMouthFloorZB = CT.FloorProfiles.Last().EndFloorZ; + } + if (CT.WorldFloorProfiles.Num() == CT.WorldControlPoints.Num() - 1 + && CT.WorldFloorProfiles.Num() > 0) + { + CT.WorldMouthFloorZA = CT.WorldFloorProfiles[0].StartFloorZ; + CT.WorldMouthFloorZB = CT.WorldFloorProfiles.Last().EndFloorZ; + } + else + { + CT.WorldMouthFloorZA = static_cast( + CT.WorldControlPoints[0].Z) + - FMath::Abs(CT.WorldControlRadii[0]); + CT.WorldMouthFloorZB = static_cast( + CT.WorldControlPoints.Last().Z) + - FMath::Abs(CT.WorldControlRadii.Last()); + } + CT.Midpoint = CT.ControlPoints.Num() > 2 ? CT.ControlPoints[CT.ControlPoints.Num() / 2] : FVector::ZeroVector; @@ -5420,7 +5881,7 @@ float VoxelCaveMorphology::EvaluateSDFCached( { case 1: RoomSDF = VoxelSDF::RoundedBox(Pos, Room.Center, Room.ShapeA, Room.ShapeR); break; case 2: RoomSDF = VoxelSDF::Capsule(Pos, Room.ShapeA, Room.ShapeB, Room.ShapeR); break; - default: RoomSDF = VoxelSDF::Ellipsoid(Pos, Room.Center, Room.ShapeA); break; + default: RoomSDF = VoxelSDF::Ellipsoid(Pos, Room.Center, Room.ShapeA); break; } // Soft floor: SmoothMax of the room SDF and the floor half-space. @@ -5534,10 +5995,15 @@ float VoxelCaveMorphology::EvaluateSDFCached( } // The corridor is a swept floor cut: each tapered capsule segment is intersected with - // its interpolated floor half-space before the tunnel union is blended into rooms. + // its interpolated floor half-space before the tunnel union is blended into rooms. The + // room wins naturally in this union (SmoothMin): a tunnel floor is a solid half-space, + // so it cannot replace the room's air above the room floor. Do not lower the SDF floor + // from a proximity-only mouth test; a warped query can be near an endpoint without being + // inside that endpoint's room, and changing it there loses capability. const float TunnelSDF = VF_EvaluateSweptTunnel( Pos, Tunnel, /*bWorldChain=*/false, BlendK, - /*bApplyFloorCut=*/true, &Cache, TunnelIdx).SDF; + /*bApplyFloorCut=*/Tunnel.bTunnelFloorEnabled, &Cache, TunnelIdx, + nullptr).SDF; MinSDF = VoxelSDF::SmoothMin(MinSDF, TunnelSDF, BlendK); }; @@ -5648,6 +6114,10 @@ FTunnelCoreWorldEvaluation VoxelCaveMorphology::EvaluateTunnelCoreWorld( VoxelDensityProfile::ECounter::TunnelCoreEvaluated); } + const FVFRoomFloorOwnership RoomMouthOwnership = + VF_FindWorldTunnelMouthOwnership(Pos, Tunnel, Cache.SDFBlendRadius); + const bool bRoomOwnsThisTunnel = RoomMouthOwnership.bValid; + bool bRaisedTunnelFloor = false; if (bHasWorldChain) { if (SupportColumn != nullptr && VoxelDensityProfile::AreCountersEnabled()) @@ -5665,7 +6135,25 @@ FTunnelCoreWorldEvaluation VoxelCaveMorphology::EvaluateTunnelCoreWorld( : VF_ProjectSweptTunnelFloor( Pos, Tunnel, /*bWorldChain=*/true, FloorZ, SupportRadius, &Cache, TunnelIdx); - if (bProjectedFloor + 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 @@ -5675,20 +6163,21 @@ FTunnelCoreWorldEvaluation VoxelCaveMorphology::EvaluateTunnelCoreWorld( { Result.bSupportFloor = true; } - if (bProjectedFloor && WorldZ <= FloorZ + 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 diagnostic is what distinguishes a coordinate-space - // mismatch from a genuine swept-floor capability loss. - return; + // 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; } } const FVFTunnelShapeEvaluation TunnelShape = VF_EvaluateSweptTunnel( Pos, Tunnel, bHasWorldChain, Cache.SDFBlendRadius, - /*bApplyFloorCut=*/true, &Cache, TunnelIdx); + /*bApplyFloorCut=*/Tunnel.bTunnelFloorEnabled && !bRaisedTunnelFloor, + &Cache, TunnelIdx, nullptr); Result.SDF = FMath::Min(Result.SDF, TunnelShape.SDF); }; @@ -5749,9 +6238,28 @@ bool VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( return false; } + const FVFRoomFloorOwnership RoomMouthOwnership = + VF_FindWorldTunnelMouthOwnership(Pos, Tunnel, Cache.SDFBlendRadius); + if (RoomMouthOwnership.bValid) + { + float ProjectedFloorZ = 0.0f; + float SupportRadius = 0.0f; + if (VF_ProjectSweptTunnelFloor( + Pos, Tunnel, bHasWorldChain, ProjectedFloorZ, SupportRadius, + &Cache, TunnelIdx) + && ProjectedFloorZ > RoomMouthOwnership.FloorZ + KINDA_SMALL_NUMBER) + { + // Only a raised tunnel apron is clipped. If the tunnel is lower, the room's + // SmoothMax floor already wins naturally and retaining this support band is + // capability-safe for legacy callers. + return false; + } + } + return VF_EvaluateSweptTunnel( Pos, Tunnel, bHasWorldChain, Cache.SDFBlendRadius, - /*bApplyFloorCut=*/true, &Cache, TunnelIdx).bSupportFloor; + /*bApplyFloorCut=*/Tunnel.bTunnelFloorEnabled, &Cache, TunnelIdx, + nullptr).bSupportFloor; }; bool bSupport = false; @@ -5791,6 +6299,8 @@ void VoxelCaveMorphology::BuildTunnelSupportFloorColumn( static_cast(Cache.Tunnels.Num())); } + const FVector ColumnPosition(WorldX, WorldY, 0.0f); + for (int32 TunnelIdx = 0; TunnelIdx < Cache.Tunnels.Num(); ++TunnelIdx) { if (!Cache.Tunnels.IsValidIndex(TunnelIdx)) @@ -5798,7 +6308,6 @@ void VoxelCaveMorphology::BuildTunnelSupportFloorColumn( continue; } const FCachedTunnel& Tunnel = Cache.Tunnels[TunnelIdx]; - const FVector ColumnPosition(WorldX, WorldY, 0.0f); float FloorZ = 0.0f; float SupportRadius = 0.0f; if (!VF_ProjectSweptTunnelFloor( diff --git a/Source/VoxelForge/Private/VoxelDensityProfile.cpp b/Source/VoxelForge/Private/VoxelDensityProfile.cpp index b221e2f..79f0aca 100644 --- a/Source/VoxelForge/Private/VoxelDensityProfile.cpp +++ b/Source/VoxelForge/Private/VoxelDensityProfile.cpp @@ -414,6 +414,11 @@ namespace VoxelDensityProfile case ECounter::TunnelSupportColumnCandidates:return TEXT("TunnelSupportColumnCandidates"); case ECounter::TunnelSupportFloorQueries:return TEXT("TunnelSupportFloorQueries"); case ECounter::TunnelSupportFloorChecks:return TEXT("TunnelSupportFloorChecks"); + case ECounter::TunnelSupportFloorBackstopFires:return TEXT("TunnelSupportFloorBackstopFires"); + case ECounter::TunnelRoomFloorBackstopFires:return TEXT("TunnelRoomFloorBackstopFires"); + case ECounter::TunnelFloorProfileBuilds:return TEXT("TunnelFloorProfileBuilds"); + case ECounter::TunnelFloorProfileSegments:return TEXT("TunnelFloorProfileSegments"); + case ECounter::TunnelFloorProfileLedges:return TEXT("TunnelFloorProfileLedges"); case ECounter::PassageCandidates: return TEXT("PassageCandidates"); case ECounter::PassageEvaluated: return TEXT("PassageEvaluated"); case ECounter::MesherBlockTests: return TEXT("MesherBlockTests"); diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 7b4d080..746ee00 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -1540,11 +1540,30 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co if (bHaveTunnelCore) { const bool bTunnelSupportFloor = TunnelCore.bSupportFloor; + if (TunnelCore.bRoomFloor) + { + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelRoomFloorBackstopFires); + } + // A tunnel that penetrates a room cannot reassert its own floor through the + // room's floor. The morphology query marks only the finite room support band; + // preserve it after the generic post-disturbance air backstop. + Result = FMath::Min( + Result, + -FMath::Max(LandingBaseDensity * 2.0f, 1.0f)); + } // The swept capsule owns the relief profile, while this finite support band is // the conservative post-disturbance backstop that keeps terrain modifiers from // removing the only player support at a mouth or a narrow cross-section. if (bTunnelSupportFloor) { + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelSupportFloorBackstopFires); + } Result = FMath::Min( Result, -FMath::Max(LandingBaseDensity * 2.0f, 1.0f)); @@ -2727,6 +2746,11 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo // operator-stack path; its support band follows the same relieved swept floor. if (bTunnelSupportFloor) { + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelSupportFloorBackstopFires); + } Density = FMath::Max(Density, FMath::Max(Params.BaseDensity * 2.0f, 1.0f)); } const float TunnelCoreSDF = (Params.RoomDensity > 0.0f && Params.RoomSpacing > 0.0f) diff --git a/Source/VoxelForge/Private/VoxelStrateComposer.cpp b/Source/VoxelForge/Private/VoxelStrateComposer.cpp index 9248017..6ef60d6 100644 --- a/Source/VoxelForge/Private/VoxelStrateComposer.cpp +++ b/Source/VoxelForge/Private/VoxelStrateComposer.cpp @@ -1137,6 +1137,9 @@ namespace if (FieldName == TEXT("TunnelWarpStrength")) return Set(0.0f, H); if (FieldName == TEXT("TunnelHorizontalBias")) return Set(0.0f, 1.0f); if (FieldName == TEXT("TunnelEndpointZOffset")) return Set(0.0f, 1.0f); + if (FieldName == TEXT("TunnelFloorTerraceStepHeight")) return Set(0.25f, FMath::Max(2.0f, HQuarter)); + if (FieldName == TEXT("TunnelFloorMaxLedgeHeight")) return Set(1.0f, FMath::Max(4.0f, HQuarter)); + if (FieldName == TEXT("TunnelFloorGentleSlopeThreshold")) return Set(0.0f, 1.5f); if (FieldName == TEXT("SDFBlendRadius")) return Set(2.0f, FMath::Max(4.0f, H * 0.15f)); if (FieldName == TEXT("WaterLevelRelative")) return Set(0.0f, 1.0f); if (FieldName == TEXT("CaveWarpStrength")) return Set(0.0f, HHalf); @@ -1368,6 +1371,28 @@ 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; + return true; + } + if (FCString::Strcmp(FieldName, TEXT("TunnelFloorLedgeCountPreference")) == 0) + { + Params.TunnelFloorLedgeCountPreference = CategoryRng.RandRange(0, 3); + return true; + } + if (FCString::Strcmp(FieldName, TEXT("TunnelFloorMaxLedges")) == 0) + { + Params.TunnelFloorMaxLedges = CategoryRng.RandRange(1, 8); + return true; + } if (FCString::Strcmp(FieldName, TEXT("RoughnessNoiseType")) == 0) { Params.RoughnessNoiseType = static_cast(CategoryRng.RandRange(0, 3)); diff --git a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp index 4be0ab3..811ae0c 100644 --- a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp +++ b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp @@ -468,6 +468,244 @@ namespace VoxelStrateMeasurePrivate OutX = InPlane - OutY * Grid.NumX; } + struct FPlayerTraversalNeighbour + { + int32 Cell = INDEX_NONE; + bool bClimb = false; + }; + + bool IsEligiblePlayerFitCell( + const FSampleGrid& Grid, + const TArray& PlayerFitMask, + int32 Cell) + { + return Cell >= 0 + && Cell < Grid.CellCount + && Grid.Air[Cell] != 0u + && PlayerFitMask.IsValidIndex(Cell) + && PlayerFitMask[Cell] != 0u; + } + + bool IsClimbableSurfaceEdge( + const FSampleGrid& Grid, + int32 First, + int32 Second, + float FirstSupportHeight, + float SecondSupportHeight) + { + int32 FirstX = 0; + int32 FirstY = 0; + int32 FirstZ = 0; + DecodeIndex(Grid, First, FirstX, FirstY, FirstZ); + int32 SecondX = 0; + int32 SecondY = 0; + int32 SecondZ = 0; + DecodeIndex(Grid, Second, SecondX, SecondY, SecondZ); + if (FMath::Abs(FirstX - SecondX) + FMath::Abs(FirstY - SecondY) != 1) + { + return false; + } + + const bool bFirstIsHigher = FirstSupportHeight > SecondSupportHeight; + const int32 LowerColumnX = bFirstIsHigher ? SecondX : FirstX; + const int32 LowerColumnY = bFirstIsHigher ? SecondY : FirstY; + const int32 HigherColumnX = bFirstIsHigher ? FirstX : SecondX; + const int32 HigherColumnY = bFirstIsHigher ? FirstY : SecondY; + const float LowerHeight = FMath::Min(FirstSupportHeight, SecondSupportHeight); + const float HigherHeight = FMath::Max(FirstSupportHeight, SecondSupportHeight); + const float Step = static_cast(Grid.SampleStep); + if (!FMath::IsFinite(Step) || Step <= 0.0f + || !FMath::IsFinite(LowerHeight) || !FMath::IsFinite(HigherHeight)) + { + return false; + } + + // A climb edge must meet a solid riser, not connect two independently supported + // floating poses across an empty gap. Exclude the top support cell itself: that cell is + // present for every supported platform and is not evidence of a climbable face. + const int32 FirstRiserZ = FMath::Max( + 0, + FMath::FloorToInt((LowerHeight - Grid.MinZ) / Step)); + const int32 LastRiserZ = FMath::Min( + Grid.NumZ - 1, + FMath::CeilToInt((HigherHeight - Grid.MinZ) / Step) - 2); + for (int32 Z = FirstRiserZ; Z <= LastRiserZ; ++Z) + { + if (Grid.Air[Grid.Index(HigherColumnX, HigherColumnY, Z)] == 0u + || Grid.Air[Grid.Index(LowerColumnX, LowerColumnY, Z)] == 0u) + { + return true; + } + } + return false; + } + + bool IsPlayerTraversalEdge( + const FSampleGrid& Grid, + const TArray& PlayerFitMask, + const TArray* PlayerFitSupportHeights, + const FVoxelStrateMeasureSettings* Settings, + int32 First, + int32 Second, + bool& bOutClimb) + { + bOutClimb = false; + if (!IsEligiblePlayerFitCell(Grid, PlayerFitMask, First) + || !IsEligiblePlayerFitCell(Grid, PlayerFitMask, Second)) + { + return false; + } + if (PlayerFitSupportHeights == nullptr || Settings == nullptr) + { + // Callers that do not carry support data retain the historical six-neighbour graph. + return true; + } + if (!PlayerFitSupportHeights->IsValidIndex(First) + || !PlayerFitSupportHeights->IsValidIndex(Second)) + { + return false; + } + + int32 FirstX = 0; + int32 FirstY = 0; + int32 FirstZ = 0; + DecodeIndex(Grid, First, FirstX, FirstY, FirstZ); + int32 SecondX = 0; + int32 SecondY = 0; + int32 SecondZ = 0; + DecodeIndex(Grid, Second, SecondX, SecondY, SecondZ); + const int32 DeltaX = FMath::Abs(FirstX - SecondX); + const int32 DeltaY = FMath::Abs(FirstY - SecondY); + const int32 DeltaZ = FMath::Abs(FirstZ - SecondZ); + if (DeltaX + DeltaY == 0 && DeltaZ == 1) + { + return true; + } + if (DeltaX + DeltaY != 1) + { + return false; + } + + const float FirstHeight = (*PlayerFitSupportHeights)[First]; + const float SecondHeight = (*PlayerFitSupportHeights)[Second]; + if (!FMath::IsFinite(FirstHeight) || !FMath::IsFinite(SecondHeight)) + { + return false; + } + const float VoxelSize = FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + const float StepHeightVoxels = Settings->PlayerMaxStepHeightMeters / VoxelSize; + const float ClimbHeightVoxels = Settings->PlayerMaxClimbHeightMeters / VoxelSize; + if (!FMath::IsFinite(StepHeightVoxels) || StepHeightVoxels < 0.0f + || !FMath::IsFinite(ClimbHeightVoxels) || ClimbHeightVoxels < 0.0f) + { + return false; + } + const float HeightDelta = FMath::Abs(FirstHeight - SecondHeight); + if (HeightDelta <= StepHeightVoxels + KINDA_SMALL_NUMBER) + { + return true; + } + if (!Settings->bPlayerClimbingEnabled + || HeightDelta > ClimbHeightVoxels + KINDA_SMALL_NUMBER) + { + return false; + } + + bOutClimb = IsClimbableSurfaceEdge( + Grid, First, Second, FirstHeight, SecondHeight); + return bOutClimb; + } + + void AppendPlayerTraversalNeighbours( + const FSampleGrid& Grid, + const TArray& PlayerFitMask, + const TArray* PlayerFitSupportHeights, + const FVoxelStrateMeasureSettings* Settings, + int32 Cell, + TArray>& OutNeighbours) + { + OutNeighbours.Reset(); + if (Cell < 0 || Cell >= Grid.CellCount) + { + return; + } + + int32 X = 0; + int32 Y = 0; + int32 Z = 0; + DecodeIndex(Grid, Cell, X, Y, Z); + auto TryAdd = [&](int32 Neighbor) + { + if (Neighbor == INDEX_NONE || Neighbor == Cell) + { + return; + } + bool bClimb = false; + if (IsPlayerTraversalEdge( + Grid, PlayerFitMask, PlayerFitSupportHeights, Settings, + Cell, Neighbor, bClimb)) + { + OutNeighbours.Add({ Neighbor, bClimb }); + } + }; + + // Preserve the old direction order for ordinary movement. Extra climb edges are appended + // in a fixed direction/height order, so the route and all derived counts are stable. + if (X + 1 < Grid.NumX) TryAdd(Grid.Index(X + 1, Y, Z)); + if (X > 0) TryAdd(Grid.Index(X - 1, Y, Z)); + if (Y + 1 < Grid.NumY) TryAdd(Grid.Index(X, Y + 1, Z)); + if (Y > 0) TryAdd(Grid.Index(X, Y - 1, Z)); + if (Z + 1 < Grid.NumZ) TryAdd(Grid.Index(X, Y, Z + 1)); + if (Z > 0) TryAdd(Grid.Index(X, Y, Z - 1)); + + if (PlayerFitSupportHeights == nullptr || Settings == nullptr + || !Settings->bPlayerClimbingEnabled) + { + return; + } + const float VoxelSize = FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + const float ClimbHeightVoxels = Settings->PlayerMaxClimbHeightMeters / VoxelSize; + if (!FMath::IsFinite(ClimbHeightVoxels) || ClimbHeightVoxels <= 0.0f) + { + return; + } + const int32 SearchCells = FMath::Min( + 4096, + FMath::Max(1, FMath::CeilToInt(ClimbHeightVoxels))); + const int32 DirectionX[4] = { 1, -1, 0, 0 }; + const int32 DirectionY[4] = { 0, 0, 1, -1 }; + for (int32 Direction = 0; Direction < 4; ++Direction) + { + const int32 TargetX = X + DirectionX[Direction]; + const int32 TargetY = Y + DirectionY[Direction]; + if (TargetX < 0 || TargetX >= Grid.NumX + || TargetY < 0 || TargetY >= Grid.NumY) + { + continue; + } + for (int32 OffsetZ = -SearchCells; OffsetZ <= SearchCells; ++OffsetZ) + { + if (OffsetZ == 0) + { + continue; + } + const int32 TargetZ = Z + OffsetZ; + if (TargetZ >= 0 && TargetZ < Grid.NumZ) + { + const int32 Neighbor = Grid.Index(TargetX, TargetY, TargetZ); + bool bClimb = false; + if (IsPlayerTraversalEdge( + Grid, PlayerFitMask, PlayerFitSupportHeights, Settings, + Cell, Neighbor, bClimb) + && bClimb) + { + OutNeighbours.Add({ Neighbor, true }); + } + } + } + } + } + void FloodFillAir( const FSampleGrid& Grid, TArray& OutComponents, @@ -477,7 +715,9 @@ namespace VoxelStrateMeasurePrivate int32& OutLargestComponentLowestCell, int32& OutNumComponentsAtLeast1Pct, TArray* OutComponentCells, - const TArray* EligibilityMask = nullptr) + const TArray* EligibilityMask = nullptr, + const TArray* PlayerFitSupportHeights = nullptr, + const FVoxelStrateMeasureSettings* TraversalSettings = nullptr) { OutComponents.SetNumUninitialized(Grid.CellCount); for (int32 Index = 0; Index < Grid.CellCount; ++Index) @@ -533,13 +773,29 @@ namespace VoxelStrateMeasurePrivate } }; - // Fixed order keeps every derived value independent of container iteration order. - if (X + 1 < Grid.NumX) Visit(Grid.Index(X + 1, Y, Z)); - if (X > 0) Visit(Grid.Index(X - 1, Y, Z)); - if (Y + 1 < Grid.NumY) Visit(Grid.Index(X, Y + 1, Z)); - if (Y > 0) Visit(Grid.Index(X, Y - 1, Z)); - if (Z + 1 < Grid.NumZ) Visit(Grid.Index(X, Y, Z + 1)); - if (Z > 0) Visit(Grid.Index(X, Y, Z - 1)); + if (EligibilityMask != nullptr + && PlayerFitSupportHeights != nullptr + && TraversalSettings != nullptr) + { + TArray> Neighbours; + AppendPlayerTraversalNeighbours( + Grid, *EligibilityMask, PlayerFitSupportHeights, + TraversalSettings, Current, Neighbours); + for (const FPlayerTraversalNeighbour& Neighbour : Neighbours) + { + Visit(Neighbour.Cell); + } + } + else + { + // Fixed order keeps every derived value independent of container iteration order. + if (X + 1 < Grid.NumX) Visit(Grid.Index(X + 1, Y, Z)); + if (X > 0) Visit(Grid.Index(X - 1, Y, Z)); + if (Y + 1 < Grid.NumY) Visit(Grid.Index(X, Y + 1, Z)); + if (Y > 0) Visit(Grid.Index(X, Y - 1, Z)); + if (Z + 1 < Grid.NumZ) Visit(Grid.Index(X, Y, Z + 1)); + if (Z > 0) Visit(Grid.Index(X, Y, Z - 1)); + } } const int64 ComponentCells64 = static_cast(ComponentCells); @@ -1079,10 +1335,15 @@ namespace VoxelStrateMeasurePrivate const FVoxelStrateMeasureSettings& Settings, TArray& OutPlayerFit, int64& OutNumPlayerFitCells, - FString& OutReason) + FString& OutReason, + TArray* OutPlayerFitSupportHeights = nullptr) { OutPlayerFit.Reset(); OutNumPlayerFitCells = 0; + if (OutPlayerFitSupportHeights != nullptr) + { + OutPlayerFitSupportHeights->Reset(); + } if (Grid.SampleStep != 1) { return Refuse( @@ -1155,6 +1416,10 @@ namespace VoxelStrateMeasurePrivate } OutPlayerFit.Init(0u, Grid.CellCount); + if (OutPlayerFitSupportHeights != nullptr) + { + OutPlayerFitSupportHeights->Init(-FLT_MAX, Grid.CellCount); + } TArray PlayerFitCounts; PlayerFitCounts.Init(0, Grid.NumZ); @@ -1261,6 +1526,10 @@ namespace VoxelStrateMeasurePrivate continue; } OutPlayerFit[Current] = 1u; + if (OutPlayerFitSupportHeights != nullptr) + { + (*OutPlayerFitSupportHeights)[Current] = SupportHeight; + } ++NumPlayerFitInSlice; } } @@ -1411,7 +1680,8 @@ namespace VoxelStrateMeasurePrivate const FSampleGrid& Grid, const FVoxelStrateMeasureSettings& Settings, FVoxelStrateMetrics& InOutMetrics, - TArray* OutPlayerFitMask = nullptr) + TArray* OutPlayerFitMask = nullptr, + TArray* OutPlayerFitSupportHeights = nullptr) { InOutMetrics.bPlayerFitResolved = false; InOutMetrics.PlayerFitRefusalReason.Reset(); @@ -1423,9 +1693,11 @@ namespace VoxelStrateMeasurePrivate InOutMetrics.MinimumPlayerClearanceVoxels = 0.0f; TArray PlayerFit; + TArray PlayerFitSupportHeights; if (!BuildPlayerFitMask( Grid, Settings, PlayerFit, InOutMetrics.NumPlayerFitCells, - InOutMetrics.PlayerFitRefusalReason)) + InOutMetrics.PlayerFitRefusalReason, + &PlayerFitSupportHeights)) { if (OutPlayerFitMask != nullptr) { @@ -1453,7 +1725,9 @@ namespace VoxelStrateMeasurePrivate LargestComponentLowestCell, NumComponentsAtLeast1Pct, nullptr, - &PlayerFit); + &PlayerFit, + &PlayerFitSupportHeights, + &Settings); InOutMetrics.NumTraversableComponents = NumComponents; InOutMetrics.LargestTraversableComponentCells = LargestComponentCells; InOutMetrics.TraversableComponentShare = InOutMetrics.NumPlayerFitCells > 0 @@ -1466,13 +1740,18 @@ namespace VoxelStrateMeasurePrivate { *OutPlayerFitMask = MoveTemp(PlayerFit); } + if (OutPlayerFitSupportHeights != nullptr) + { + *OutPlayerFitSupportHeights = MoveTemp(PlayerFitSupportHeights); + } } void BuildPlayerFitConnectivityMetrics( const FSampleGrid& Grid, const FVoxelStrateMeasureSettings& Settings, FVoxelStrateMetrics& OutMetrics, - TArray& OutPlayerFitMask) + TArray& OutPlayerFitMask, + TArray& OutPlayerFitSupportHeights) { OutMetrics = FVoxelStrateMetrics(); OutMetrics.ResolvedMarginVoxels = Grid.ResolvedMarginVoxels; @@ -1492,7 +1771,9 @@ namespace VoxelStrateMeasurePrivate ? static_cast(static_cast(OutMetrics.NumAir) / static_cast(OutMetrics.NumSampled)) : 0.0f; - BuildPlayerFitMetrics(Grid, Settings, OutMetrics, &OutPlayerFitMask); + BuildPlayerFitMetrics( + Grid, Settings, OutMetrics, &OutPlayerFitMask, + &OutPlayerFitSupportHeights); OutMetrics.bValid = OutMetrics.NumSampled > 0; } @@ -1951,7 +2232,9 @@ namespace VoxelStrateMeasurePrivate const FVector& B, const TArray& Path, FCoarseEdge& OutFailedEdge, - const TArray* EligibilityMask = nullptr) + const TArray* EligibilityMask = nullptr, + const TArray* PlayerFitSupportHeights = nullptr, + const FVoxelStrateMeasureSettings* TraversalSettings = nullptr) { OutFailedEdge = FCoarseEdge(); @@ -1972,6 +2255,20 @@ namespace VoxelStrateMeasurePrivate } return false; } + if (PathIndex > 0 + && PlayerFitSupportHeights != nullptr + && TraversalSettings != nullptr) + { + bool bClimb = false; + if (!IsPlayerTraversalEdge( + Grid, *EligibilityMask, PlayerFitSupportHeights, + TraversalSettings, Path[PathIndex - 1], Path[PathIndex], bClimb)) + { + OutFailedEdge = MakeCoarseEdge( + Path[PathIndex - 1], Path[PathIndex]); + return false; + } + } } int32 StartX = 0; int32 StartY = 0; @@ -2045,7 +2342,9 @@ namespace VoxelStrateMeasurePrivate int32 Goal, const TArray& BlockedEdges, TArray& OutPath, - const TArray* EligibilityMask = nullptr) + const TArray* EligibilityMask = nullptr, + const TArray* PlayerFitSupportHeights = nullptr, + const FVoxelStrateMeasureSettings* TraversalSettings = nullptr) { const auto IsEligible = [&, EligibilityMask](int32 Cell) { @@ -2090,14 +2389,30 @@ namespace VoxelStrateMeasurePrivate } }; - // The order is fixed so the recovered route is deterministic. This is a real BFS - // parent chain, not just a component-membership answer. - if (X + 1 < Grid.NumX) Visit(Grid.Index(X + 1, Y, Z)); - if (X > 0) Visit(Grid.Index(X - 1, Y, Z)); - if (Y + 1 < Grid.NumY) Visit(Grid.Index(X, Y + 1, Z)); - if (Y > 0) Visit(Grid.Index(X, Y - 1, Z)); - if (Z + 1 < Grid.NumZ) Visit(Grid.Index(X, Y, Z + 1)); - if (Z > 0) Visit(Grid.Index(X, Y, Z - 1)); + if (EligibilityMask != nullptr + && PlayerFitSupportHeights != nullptr + && TraversalSettings != nullptr) + { + TArray> Neighbours; + AppendPlayerTraversalNeighbours( + Grid, *EligibilityMask, PlayerFitSupportHeights, + TraversalSettings, Current, Neighbours); + for (const FPlayerTraversalNeighbour& Neighbour : Neighbours) + { + Visit(Neighbour.Cell); + } + } + else + { + // The order is fixed so the recovered route is deterministic. This is a real BFS + // parent chain, not just a component-membership answer. + if (X + 1 < Grid.NumX) Visit(Grid.Index(X + 1, Y, Z)); + if (X > 0) Visit(Grid.Index(X - 1, Y, Z)); + if (Y + 1 < Grid.NumY) Visit(Grid.Index(X, Y + 1, Z)); + if (Y > 0) Visit(Grid.Index(X, Y - 1, Z)); + if (Z + 1 < Grid.NumZ) Visit(Grid.Index(X, Y, Z + 1)); + if (Z > 0) Visit(Grid.Index(X, Y, Z - 1)); + } } if (Parent[Goal] == -2) @@ -2137,7 +2452,9 @@ namespace VoxelStrateMeasurePrivate bool& bOutGoalSnapped, int32& OutNumRouteRetries, int32 MaxRouteRetries, - const TArray* EligibilityMask = nullptr) + const TArray* EligibilityMask = nullptr, + const TArray* PlayerFitSupportHeights = nullptr, + const FVoxelStrateMeasureSettings* TraversalSettings = nullptr) { OutStart = -1; OutGoal = -1; @@ -2175,7 +2492,8 @@ namespace VoxelStrateMeasurePrivate for (;;) { if (!FindCoarsePath( - Grid, OutStart, OutGoal, BlockedEdges, Path, EligibilityMask)) + Grid, OutStart, OutGoal, BlockedEdges, Path, EligibilityMask, + PlayerFitSupportHeights, TraversalSettings)) { // This is a negative verdict only after all edges excluded by earlier // full-resolution failures leave no coarse route at this resolution. @@ -2188,7 +2506,7 @@ namespace VoxelStrateMeasurePrivate FCoarseEdge FailedEdge; if (FullResolutionPathIsAir( Generator, Sampler, Grid, AVoxel, BVoxel, Path, FailedEdge, - EligibilityMask)) + EligibilityMask, PlayerFitSupportHeights, TraversalSettings)) { return EVoxelConnectivityResult::Connected; } @@ -2259,7 +2577,9 @@ namespace VoxelStrateMeasurePrivate FVoxelConnectivityDiagnostics& OutDiagnostics, const TArray* EligibilityMask = nullptr, TArray* OutComponents = nullptr, - int32* OutNumComponents = nullptr) + int32* OutNumComponents = nullptr, + const TArray* PlayerFitSupportHeights = nullptr, + const FVoxelStrateMeasureSettings* TraversalSettings = nullptr) { OutDiagnostics = FVoxelConnectivityDiagnostics(); if (OutComponents != nullptr) @@ -2298,7 +2618,9 @@ namespace VoxelStrateMeasurePrivate LargestComponentLowestCell, NumComponentsAtLeast1Pct, nullptr, - EligibilityMask); + EligibilityMask, + PlayerFitSupportHeights, + TraversalSettings); if (OutComponents != nullptr) { @@ -2449,6 +2771,7 @@ namespace VoxelStrateMeasurePrivate } TArray PlayerFitMask; + TArray PlayerFitSupportHeights; FVoxelStrateMetrics PlayerMetrics; if (bPlayerFitRestricted) { @@ -2457,7 +2780,8 @@ namespace VoxelStrateMeasurePrivate // Manhattan feature transform here: those values belong to the separate legacy // measurement pass, and recomputing them for every fine candidate made the gate // needlessly expensive without changing its answer. - BuildPlayerFitConnectivityMetrics(Grid, Settings, PlayerMetrics, PlayerFitMask); + BuildPlayerFitConnectivityMetrics( + Grid, Settings, PlayerMetrics, PlayerFitMask, PlayerFitSupportHeights); if (OutPlayerMetrics != nullptr) { *OutPlayerMetrics = PlayerMetrics; @@ -2483,7 +2807,9 @@ namespace VoxelStrateMeasurePrivate bOutGoalSnapped, OutNumRouteRetries, Settings.MaxRouteRetries, - bPlayerFitRestricted ? &PlayerFitMask : nullptr); + bPlayerFitRestricted ? &PlayerFitMask : nullptr, + bPlayerFitRestricted ? &PlayerFitSupportHeights : nullptr, + bPlayerFitRestricted ? &Settings : nullptr); if (OutDiagnostics != nullptr) { PopulateConnectivityDiagnostics( @@ -2495,7 +2821,11 @@ namespace VoxelStrateMeasurePrivate bOutGoalSnapped, OutNumRouteRetries, *OutDiagnostics, - bPlayerFitRestricted ? &PlayerFitMask : nullptr); + bPlayerFitRestricted ? &PlayerFitMask : nullptr, + nullptr, + nullptr, + bPlayerFitRestricted ? &PlayerFitSupportHeights : nullptr, + bPlayerFitRestricted ? &Settings : nullptr); if (bPlayerFitRestricted && (Result == EVoxelConnectivityResult::StartCellNotPlayerFit || Result == EVoxelConnectivityResult::GoalCellNotPlayerFit)) @@ -2526,30 +2856,20 @@ namespace VoxelStrateMeasurePrivate } } - bool IsEligiblePlayerFitCell( - const FSampleGrid& Grid, - const TArray& PlayerFitMask, - int32 Cell) - { - return Cell >= 0 - && Cell < Grid.CellCount - && Grid.Air[Cell] != 0u - && PlayerFitMask[Cell] != 0u; - } - int32 CountPlayerFitNeighbours( const FSampleGrid& Grid, const TArray& PlayerFitMask, + const TArray* PlayerFitSupportHeights, + const FVoxelStrateMeasureSettings* Settings, int32 Cell) { + TArray> Neighbours; + AppendPlayerTraversalNeighbours( + Grid, PlayerFitMask, PlayerFitSupportHeights, Settings, Cell, Neighbours); int32 Count = 0; - for (int32 Direction = 0; Direction < 6; ++Direction) + for (const FPlayerTraversalNeighbour& Neighbour : Neighbours) { - if (IsEligiblePlayerFitCell( - Grid, PlayerFitMask, GetSixNeighbour(Grid, Cell, Direction))) - { - ++Count; - } + ++Count; } return Count; } @@ -2616,6 +2936,8 @@ namespace VoxelStrateMeasurePrivate void WalkPlayerFitGraph( const FSampleGrid& Grid, const TArray& PlayerFitMask, + const TArray* PlayerFitSupportHeights, + const FVoxelStrateMeasureSettings* TraversalSettings, int32 Start, int32 Goal, float NarrowGapThresholdVoxels, @@ -2651,6 +2973,15 @@ namespace VoxelStrateMeasurePrivate auto RecordTraversal = [&](int32 From, int32 To) { ++InOutReport.AgentGraphTraversals; + bool bClimb = false; + if (PlayerFitSupportHeights != nullptr && TraversalSettings != nullptr + && IsPlayerTraversalEdge( + Grid, PlayerFitMask, PlayerFitSupportHeights, TraversalSettings, + From, To, bClimb) + && bClimb) + { + ++InOutReport.ClimbTraversals; + } auto IsNarrowCell = [&](int32 Cell) { if (NarrowCell[Cell] == 255u) @@ -2678,11 +3009,15 @@ namespace VoxelStrateMeasurePrivate break; } + TArray> Neighbours; + AppendPlayerTraversalNeighbours( + Grid, PlayerFitMask, PlayerFitSupportHeights, TraversalSettings, + Current, Neighbours); bool bAdvanced = false; - while (NextDirection.Last() < 6) + while (NextDirection.Last() < Neighbours.Num()) { const int32 Direction = NextDirection.Last()++; - const int32 Neighbour = GetSixNeighbour(Grid, Current, Direction); + const int32 Neighbour = Neighbours[Direction].Cell; if (!IsEligiblePlayerFitCell(Grid, PlayerFitMask, Neighbour) || Visited[Neighbour] != 0u) { @@ -2706,7 +3041,9 @@ namespace VoxelStrateMeasurePrivate // A degree-one pose is an actual topological dead end. A higher-degree pose with all // branches already visited is merely a DFS backtrack through a loop/junction. if (Current != Start && Current != Goal - && CountPlayerFitNeighbours(Grid, PlayerFitMask, Current) <= 1) + && CountPlayerFitNeighbours( + Grid, PlayerFitMask, PlayerFitSupportHeights, + TraversalSettings, Current) <= 1) { ++InOutReport.DeadEndsEncountered; } @@ -3048,6 +3385,8 @@ bool VF_MeasurePlayerFitWalkWithSampler( OutReport.CapsuleWidthMeters = 2.0f * OutReport.CapsuleRadiusMeters; OutReport.CapsuleHeightMeters = 2.0f * Settings.PlayerCapsuleHalfHeightVoxels * FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + OutReport.bClimbingEnabled = Settings.bPlayerClimbingEnabled; + OutReport.MaxClimbHeightMeters = Settings.PlayerMaxClimbHeightMeters; OutReport.NarrowGapThresholdMeters = 1.5f * OutReport.CapsuleWidthMeters; OutReport.NarrowGapDefinition = TEXT( "widest axis-aligned horizontal air span at each traversed fit-graph cell"); @@ -3096,13 +3435,15 @@ bool VF_MeasurePlayerFitWalkWithSampler( } TArray PlayerFitMask; + TArray PlayerFitSupportHeights; int64 NumPlayerFitCells = 0; if (!BuildPlayerFitMask( Grid, Settings, PlayerFitMask, NumPlayerFitCells, - RefusalReason)) + RefusalReason, + &PlayerFitSupportHeights)) { OutReport.RefusalReason = RefusalReason; return false; @@ -3123,7 +3464,9 @@ bool VF_MeasurePlayerFitWalkWithSampler( OutReport.bGoalSnapped, NumRouteRetries, Settings.MaxRouteRetries, - &PlayerFitMask); + &PlayerFitMask, + &PlayerFitSupportHeights, + &Settings); FVoxelConnectivityDiagnostics Diagnostics; TArray Components; @@ -3139,7 +3482,9 @@ bool VF_MeasurePlayerFitWalkWithSampler( Diagnostics, &PlayerFitMask, &Components, - &NumComponents); + &NumComponents, + &PlayerFitSupportHeights, + &Settings); OutReport.bValid = true; OutReport.Result = Result; @@ -3177,6 +3522,8 @@ bool VF_MeasurePlayerFitWalkWithSampler( WalkPlayerFitGraph( Grid, PlayerFitMask, + &PlayerFitSupportHeights, + &Settings, Start, Goal, NarrowGapThresholdVoxels, diff --git a/Source/VoxelForge/Public/VoxelCaveMorphology.h b/Source/VoxelForge/Public/VoxelCaveMorphology.h index e097ffd..4ffebdf 100644 --- a/Source/VoxelForge/Public/VoxelCaveMorphology.h +++ b/Source/VoxelForge/Public/VoxelCaveMorphology.h @@ -621,6 +621,18 @@ struct FCachedRoomFloorJoin float BoundRadiusSq = 0.0f; }; +// One immutable floor segment authored while the tunnel chain is built. NumSteps == 0 is a +// continuous segment; a positive value quantizes the interpolation to that many whole-chain +// ledge intervals. ReliefScale is baked with the same derivative bound as the old evaluator, so +// evaluation only reads this profile and never decides terrace count per voxel. +struct FTunnelFloorSegmentProfile +{ + float StartFloorZ = 0.0f; + float EndFloorZ = 0.0f; + float ReliefScale = 0.0f; + int32 NumSteps = 0; +}; + // A pre-computed wandering tunnel chain — all connection decisions and hash-derived properties // (radii, Z offsets, control points) are resolved during cache build. struct FCachedTunnel @@ -640,11 +652,43 @@ struct FCachedTunnel // turning a gentle authored floor ramp into a vertical break when the cave warp is nonlinear. TArray WorldControlPoints; TArray WorldControlRadii; + // Build-time floor authoring. SDF and world chains each keep their own profile because cave + // warp changes the control-point Z values. The normal path has one entry per chain segment; + // malformed/legacy caches fall back to the old local evaluator. + TArray FloorProfiles; + TArray WorldFloorProfiles; // The corridor floor is a swept SmoothMax cut, not a later slab. These are copied from the // same strate fields used by room floors; the seed is derived once from the tunnel pair hash. float FloorReliefStrength = 0.0f; float FloorReliefFrequency = 0.015f; uint32 FloorSeed = 0; + // Only the two rooms that actually form this edge may own its mouth floor. A nearby unrelated + // room must not clip a tunnel merely because the two shapes overlap in a crowded graph. + bool bHasFloorRoomOwnership = false; + uint32 FloorRoomHashA = 0; + uint32 FloorRoomHashB = 0; + // World-space mouth ownership is resolved from the baked chain, not by comparing a warped + // query against an SDF-space room. These values make the post-disturbance hand-off exact in + // the same coordinate space in which the player and the support backstop are evaluated. + // The room's full radius is deliberately not used as the ownership region. A tunnel's + // floor should hand off to the room over a short mouth apron, not overwrite the room floor + // across the entire chamber. These are the build-time capped hand-off radii in both query + // coordinate spaces. + float SDFMouthBlendRadiusA = 0.0f; + float SDFMouthBlendRadiusB = 0.0f; + float WorldMouthBlendRadiusA = 0.0f; + float WorldMouthBlendRadiusB = 0.0f; + float SDFMouthFloorZA = -FLT_MAX; + 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; + float TunnelFloorGentleSlopeThreshold = 0.9656888f; + int32 TunnelFloorLedgeCountPreference = 0; + int32 TunnelFloorMaxLedges = 4096; FVector WorldBoundCenter = FVector::ZeroVector; float WorldBoundRadiusSq = 0.0f; // Centerline AABBs and scalar influence radii used by the immutable broad phase. The @@ -794,6 +838,9 @@ struct FTunnelCoreWorldEvaluation { float SDF = FLT_MAX; 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. + bool bRoomFloor = false; }; //============================================================================= diff --git a/Source/VoxelForge/Public/VoxelDensityProfile.h b/Source/VoxelForge/Public/VoxelDensityProfile.h index 259fb21..b6de189 100644 --- a/Source/VoxelForge/Public/VoxelDensityProfile.h +++ b/Source/VoxelForge/Public/VoxelDensityProfile.h @@ -96,6 +96,11 @@ namespace VoxelDensityProfile TunnelSupportColumnCandidates, TunnelSupportFloorQueries, TunnelSupportFloorChecks, + TunnelSupportFloorBackstopFires, + TunnelRoomFloorBackstopFires, + TunnelFloorProfileBuilds, + TunnelFloorProfileSegments, + TunnelFloorProfileLedges, PassageCandidates, PassageEvaluated, MesherBlockTests, diff --git a/Source/VoxelForge/Public/VoxelStrateMeasure.h b/Source/VoxelForge/Public/VoxelStrateMeasure.h index 3f9970f..95f519e 100644 --- a/Source/VoxelForge/Public/VoxelStrateMeasure.h +++ b/Source/VoxelForge/Public/VoxelStrateMeasure.h @@ -103,6 +103,13 @@ struct VOXELFORGE_API FVoxelStrateMeasureSettings // A support patch is sampled at the integer columns covered by the capsule radius. 0.75 with // the default 1.36-voxel radius requires 4 of 5 columns, including the centre column. float PlayerSupportPatchMinCoverageFraction = 0.75f; + + // Traversal is still constrained by the exact player-fit mask, but a planned climbing + // mechanic may cross a higher supported neighbouring column. This is a measurement policy, + // not a runtime movement change. The surface remains required to be supported; the extra + // height only changes whether that supported edge is traversable. + bool bPlayerClimbingEnabled = true; + float PlayerMaxClimbHeightMeters = 3.0f; }; /** @@ -302,6 +309,8 @@ struct VOXELFORGE_API FVoxelPlayerFitWalkReport bool bStartSnapped = false; bool bGoalSnapped = false; int32 NumRouteRetries = 0; + bool bClimbingEnabled = false; + float MaxClimbHeightMeters = 0.0f; int64 PlayerFitVolumeCells = 0; int64 ReachablePlayerFitCells = 0; @@ -320,6 +329,7 @@ struct VOXELFORGE_API FVoxelPlayerFitWalkReport // branch edge is counted twice when the agent backtracks, which makes this an instrument of // traversal experience rather than a shortest-path length. int64 AgentGraphTraversals = 0; + int64 ClimbTraversals = 0; int64 DeadEndsEncountered = 0; float DeadEndsPer100m = 0.0f; float DistanceTravelledMeters = 0.0f; diff --git a/Source/VoxelForge/Public/VoxelStrateTypes.h b/Source/VoxelForge/Public/VoxelStrateTypes.h index 026dda5..727568b 100644 --- a/Source/VoxelForge/Public/VoxelStrateTypes.h +++ b/Source/VoxelForge/Public/VoxelStrateTypes.h @@ -283,6 +283,13 @@ enum class EVoxelStrateTransition : uint8 LERPF(TunnelHorizontalBias) \ SNAPF(bTunnelsFlowTowardOrigin) \ LERPF(TunnelEndpointZOffset) \ + SNAPF(bTunnelFloorEnabled) \ + SNAPF(bTunnelFloorTerracingEnabled) \ + LERPF(TunnelFloorTerraceStepHeight) \ + LERPF(TunnelFloorMaxLedgeHeight) \ + LERPF(TunnelFloorGentleSlopeThreshold) \ + SNAPF(TunnelFloorLedgeCountPreference) \ + SNAPF(TunnelFloorMaxLedges) \ LERPF(SDFBlendRadius) \ LERPF(WaterLevelRelative) \ /* Cave warp */ \ @@ -659,6 +666,55 @@ struct VOXELFORGE_API FStrateGenerationParams UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels", meta = (ClampMin = "0.0", ClampMax = "1.0")) float TunnelEndpointZOffset = 0.25f; + // ===== TUNNEL FLOOR AUTHORING ===== + // + // The corridor floor is authored into the swept tunnel during cache construction. These + // controls are intentionally separate from FCaveTerraceMod::TerraceStepHeight: that field + // 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") + bool bTunnelFloorEnabled = true; + + // Enables the floor profile's terrace decisions. With the default legacy ledge preference of + // zero, this preserves the existing per-segment walkable staircase. A positive preference + // selects the build-time whole-chain ledge policy. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels|Floor") + bool bTunnelFloorTerracingEnabled = true; + + // Maximum riser height used by the compatibility/per-segment profile, in voxels. The default + // is the previous 1.8 voxel step with its 0.95 safety margin (1.71 voxels). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels|Floor", + meta = (ClampMin = "0.0")) + float TunnelFloorTerraceStepHeight = 1.71f; + + // Maximum height of one authored ledge in the whole-chain profile, in voxels. This may exceed + // the walking step because ledges are climbable surfaces under the measurement envelope. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels|Floor", + meta = (ClampMin = "0.0")) + float TunnelFloorMaxLedgeHeight = 12.0f; + + // Whole-chain floor gradient below which the tunnel descends continuously instead of placing + // ledges. The default is tan(44 degrees), matching the walkable-floor bound. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels|Floor", + meta = (ClampMin = "0.0")) + float TunnelFloorGentleSlopeThreshold = 0.9656888f; + + // Preferred number of large ledges for a steep whole-chain floor. Zero is the compatibility + // mode (the old per-segment step-count rule); positive values request that many transitions, + // subject to the max-height and available control-segment constraints. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels|Floor", + meta = (ClampMin = "0", ClampMax = "32")) + int32 TunnelFloorLedgeCountPreference = 0; + + // Safety bound on the number of transitions emitted by either floor profile. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels|Floor", + meta = (ClampMin = "1", ClampMax = "4096")) + int32 TunnelFloorMaxLedges = 4096; + // ===== SDF BLEND (junction smoothness) ===== // Smooth union blend radius. Controls how rounded the junctions diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp index c891971..0ae5e97 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -223,6 +223,20 @@ struct FExploreArguments float BudgetMinutes = DefaultBudgetMinutes; bool bSurfaceRoughnessOverride = false; float SurfaceRoughness = 0.0f; + bool bTunnelFloorEnabledOverride = false; + int32 TunnelFloorEnabled = 1; + bool bTunnelFloorTerracingOverride = false; + int32 TunnelFloorTerracing = 1; + bool bTunnelFloorStepOverride = false; + float TunnelFloorStep = 0.0f; + bool bTunnelFloorMaxLedgeOverride = false; + float TunnelFloorMaxLedge = 0.0f; + bool bTunnelFloorGentleOverride = false; + float TunnelFloorGentle = 0.0f; + bool bTunnelFloorLedgesOverride = false; + int32 TunnelFloorLedges = 0; + bool bTunnelFloorMaxLedgesOverride = false; + int32 TunnelFloorMaxLedges = 0; FString CanonicalModes() const { @@ -303,6 +317,41 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr const bool bSurfaceRoughnessParsed = FParse::Value( *Params, TEXT("surfaceroughness="), OutArguments.SurfaceRoughness); OutArguments.bSurfaceRoughnessOverride = bSurfaceRoughnessSpecified; + const bool bTunnelFloorEnabledSpecified = Params.Contains( + TEXT("tunnelfloor="), ESearchCase::IgnoreCase); + const bool bTunnelFloorEnabledParsed = FParse::Value( + *Params, TEXT("tunnelfloor="), OutArguments.TunnelFloorEnabled); + OutArguments.bTunnelFloorEnabledOverride = bTunnelFloorEnabledSpecified; + const bool bTunnelFloorTerracingSpecified = Params.Contains( + TEXT("tunnelfloorterracing="), ESearchCase::IgnoreCase); + const bool bTunnelFloorTerracingParsed = FParse::Value( + *Params, TEXT("tunnelfloorterracing="), OutArguments.TunnelFloorTerracing); + OutArguments.bTunnelFloorTerracingOverride = bTunnelFloorTerracingSpecified; + const bool bTunnelFloorStepSpecified = Params.Contains( + TEXT("tunnelfloorstep="), ESearchCase::IgnoreCase); + const bool bTunnelFloorStepParsed = FParse::Value( + *Params, TEXT("tunnelfloorstep="), OutArguments.TunnelFloorStep); + OutArguments.bTunnelFloorStepOverride = bTunnelFloorStepSpecified; + const bool bTunnelFloorMaxLedgeSpecified = Params.Contains( + TEXT("tunnelfloormaxledge="), ESearchCase::IgnoreCase); + const bool bTunnelFloorMaxLedgeParsed = FParse::Value( + *Params, TEXT("tunnelfloormaxledge="), OutArguments.TunnelFloorMaxLedge); + OutArguments.bTunnelFloorMaxLedgeOverride = bTunnelFloorMaxLedgeSpecified; + const bool bTunnelFloorGentleSpecified = Params.Contains( + TEXT("tunnelfloorgentle="), ESearchCase::IgnoreCase); + const bool bTunnelFloorGentleParsed = FParse::Value( + *Params, TEXT("tunnelfloorgentle="), OutArguments.TunnelFloorGentle); + OutArguments.bTunnelFloorGentleOverride = bTunnelFloorGentleSpecified; + const bool bTunnelFloorLedgesSpecified = Params.Contains( + TEXT("tunnelfloorledges="), ESearchCase::IgnoreCase); + const bool bTunnelFloorLedgesParsed = FParse::Value( + *Params, TEXT("tunnelfloorledges="), OutArguments.TunnelFloorLedges); + OutArguments.bTunnelFloorLedgesOverride = bTunnelFloorLedgesSpecified; + const bool bTunnelFloorMaxLedgesSpecified = Params.Contains( + TEXT("tunnelfloormaxledges="), ESearchCase::IgnoreCase); + const bool bTunnelFloorMaxLedgesParsed = FParse::Value( + *Params, TEXT("tunnelfloormaxledges="), OutArguments.TunnelFloorMaxLedges); + OutArguments.bTunnelFloorMaxLedgesOverride = bTunnelFloorMaxLedgesSpecified; int32 FailureFocusRender = 0; FParse::Value(*Params, TEXT("failurefocus="), FailureFocusRender); OutArguments.bFailureFocusRender = FailureFocusRender != 0; @@ -439,6 +488,62 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr OutError = TEXT("surfaceroughness must be finite and greater than or equal to zero."); return false; } + if (OutArguments.bTunnelFloorEnabledOverride + && (!bTunnelFloorEnabledParsed + || (OutArguments.TunnelFloorEnabled != 0 + && OutArguments.TunnelFloorEnabled != 1))) + { + OutError = TEXT("tunnelfloor must be 0 or 1."); + return false; + } + if (OutArguments.bTunnelFloorTerracingOverride + && (!bTunnelFloorTerracingParsed + || (OutArguments.TunnelFloorTerracing != 0 + && OutArguments.TunnelFloorTerracing != 1))) + { + OutError = TEXT("tunnelfloorterracing must be 0 or 1."); + return false; + } + if (OutArguments.bTunnelFloorStepOverride + && (!bTunnelFloorStepParsed + || !FMath::IsFinite(OutArguments.TunnelFloorStep) + || OutArguments.TunnelFloorStep < 0.0f)) + { + OutError = TEXT("tunnelfloorstep must be finite and non-negative."); + return false; + } + if (OutArguments.bTunnelFloorMaxLedgeOverride + && (!bTunnelFloorMaxLedgeParsed + || !FMath::IsFinite(OutArguments.TunnelFloorMaxLedge) + || OutArguments.TunnelFloorMaxLedge <= 0.0f)) + { + OutError = TEXT("tunnelfloormaxledge must be finite and greater than zero."); + return false; + } + if (OutArguments.bTunnelFloorGentleOverride + && (!bTunnelFloorGentleParsed + || !FMath::IsFinite(OutArguments.TunnelFloorGentle) + || OutArguments.TunnelFloorGentle < 0.0f)) + { + OutError = TEXT("tunnelfloorgentle must be finite and non-negative."); + return false; + } + if (OutArguments.bTunnelFloorLedgesOverride + && (!bTunnelFloorLedgesParsed + || OutArguments.TunnelFloorLedges < 0 + || OutArguments.TunnelFloorLedges > 4096)) + { + OutError = TEXT("tunnelfloorledges must be an integer in [0,4096]."); + return false; + } + if (OutArguments.bTunnelFloorMaxLedgesOverride + && (!bTunnelFloorMaxLedgesParsed + || OutArguments.TunnelFloorMaxLedges < 1 + || OutArguments.TunnelFloorMaxLedges > 4096)) + { + OutError = TEXT("tunnelfloormaxledges must be an integer in [1,4096]."); + return false; + } if (OutText.IsEmpty()) { @@ -711,6 +816,40 @@ struct FExploreWorld break; } } + // Floor controls are applied only to the requested target definition. They are + // commandlet overrides for measured archetype comparisons; authored assets still carry + // the defaults/rolled values in FStrateGenerationParams. + FStrateGenerationParams& TargetTunnelParams = Target.Definition->GenerationParams; + if (Arguments.bTunnelFloorEnabledOverride) + { + TargetTunnelParams.bTunnelFloorEnabled = + Arguments.TunnelFloorEnabled != 0; + } + if (Arguments.bTunnelFloorTerracingOverride) + { + TargetTunnelParams.bTunnelFloorTerracingEnabled = + Arguments.TunnelFloorTerracing != 0; + } + if (Arguments.bTunnelFloorStepOverride) + { + TargetTunnelParams.TunnelFloorTerraceStepHeight = Arguments.TunnelFloorStep; + } + if (Arguments.bTunnelFloorMaxLedgeOverride) + { + TargetTunnelParams.TunnelFloorMaxLedgeHeight = Arguments.TunnelFloorMaxLedge; + } + if (Arguments.bTunnelFloorGentleOverride) + { + TargetTunnelParams.TunnelFloorGentleSlopeThreshold = Arguments.TunnelFloorGentle; + } + if (Arguments.bTunnelFloorLedgesOverride) + { + TargetTunnelParams.TunnelFloorLedgeCountPreference = Arguments.TunnelFloorLedges; + } + if (Arguments.bTunnelFloorMaxLedgesOverride) + { + TargetTunnelParams.TunnelFloorMaxLedges = Arguments.TunnelFloorMaxLedges; + } TargetBottomWorldZ = Target.BottomChunkZ * CHUNK_SIZE; TargetTopWorldZ = (Target.TopChunkZ + 1) * CHUNK_SIZE; TargetBoundarySealThickness = BoundarySealForArchetype( @@ -3615,6 +3754,52 @@ void WriteSurfaceReliefMetrics( Writer.WriteObjectEnd(); } +void WriteTunnelFloorOverrides( + FExploreJsonWriter& Writer, + const FExploreArguments& Arguments) +{ + Writer.WriteObjectStart(TEXT("tunnel_floor_overrides")); + Writer.WriteValue(TEXT("enabled_override"), Arguments.bTunnelFloorEnabledOverride); + if (Arguments.bTunnelFloorEnabledOverride) + { + Writer.WriteValue(TEXT("enabled"), Arguments.TunnelFloorEnabled != 0); + } + Writer.WriteValue(TEXT("terracing_override"), Arguments.bTunnelFloorTerracingOverride); + if (Arguments.bTunnelFloorTerracingOverride) + { + Writer.WriteValue(TEXT("terracing"), Arguments.TunnelFloorTerracing != 0); + } + Writer.WriteValue(TEXT("step_height_override"), Arguments.bTunnelFloorStepOverride); + if (Arguments.bTunnelFloorStepOverride) + { + Writer.WriteValue(TEXT("step_height_voxels"), + static_cast(Arguments.TunnelFloorStep)); + } + Writer.WriteValue(TEXT("max_ledge_height_override"), Arguments.bTunnelFloorMaxLedgeOverride); + if (Arguments.bTunnelFloorMaxLedgeOverride) + { + Writer.WriteValue(TEXT("max_ledge_height_voxels"), + static_cast(Arguments.TunnelFloorMaxLedge)); + } + Writer.WriteValue(TEXT("gentle_slope_override"), Arguments.bTunnelFloorGentleOverride); + if (Arguments.bTunnelFloorGentleOverride) + { + Writer.WriteValue(TEXT("gentle_slope"), + static_cast(Arguments.TunnelFloorGentle)); + } + Writer.WriteValue(TEXT("ledge_count_override"), Arguments.bTunnelFloorLedgesOverride); + if (Arguments.bTunnelFloorLedgesOverride) + { + Writer.WriteValue(TEXT("ledge_count"), Arguments.TunnelFloorLedges); + } + Writer.WriteValue(TEXT("max_ledges_override"), Arguments.bTunnelFloorMaxLedgesOverride); + if (Arguments.bTunnelFloorMaxLedgesOverride) + { + Writer.WriteValue(TEXT("max_ledges"), Arguments.TunnelFloorMaxLedges); + } + Writer.WriteObjectEnd(); +} + FString BuildManifestJson( const FExploreArguments& Arguments, const FExploreExportOutput& Export) @@ -3623,7 +3808,7 @@ FString BuildManifestJson( TSharedRef Writer = TJsonWriterFactory>::Create(&Json); Writer->WriteObjectStart(); - Writer->WriteValue(TEXT("schema_version"), 4); + Writer->WriteValue(TEXT("schema_version"), 6); Writer->WriteValue(TEXT("format"), TEXT("OBJ")); Writer->WriteValue(TEXT("mesh_file"), Export.MeshFileName); Writer->WriteValue(TEXT("mesh_positions_units"), TEXT("metres")); @@ -3650,6 +3835,7 @@ FString BuildManifestJson( Writer->WriteValue(TEXT("archetype"), ArchetypeName(Arguments.Archetype)); Writer->WriteValue(TEXT("slot"), Arguments.Slot); Writer->WriteValue(TEXT("operator_stack"), Arguments.bUseOperatorStack); + WriteTunnelFloorOverrides(*Writer, Arguments); Writer->WriteValue(TEXT("surface_roughness_override"), Arguments.bSurfaceRoughnessOverride); if (Arguments.bSurfaceRoughnessOverride) { @@ -3842,6 +4028,8 @@ void WritePlayerDimensions(FExploreJsonWriter& Writer, const TCHAR* Key) Writer.WriteValue(TEXT("capsule_half_height_m"), static_cast(FVoxelPlayerCapsuleConstants::HalfHeightCentimeters) / 100.0); Writer.WriteValue(TEXT("capsule_height_m"), static_cast(2.0f * FVoxelPlayerCapsuleConstants::HalfHeightCentimeters) / 100.0); Writer.WriteValue(TEXT("max_step_height_m"), static_cast(FVoxelPlayerCapsuleConstants::MaxStepHeightMeters)); + Writer.WriteValue(TEXT("climbing_enabled"), true); + Writer.WriteValue(TEXT("max_climb_height_m"), 3.0); Writer.WriteValue(TEXT("walkable_floor_angle_degrees"), static_cast(FVoxelPlayerCapsuleConstants::WalkableFloorAngleDegrees)); Writer.WriteObjectEnd(); } @@ -3855,9 +4043,9 @@ FString BuildExploreJson( { FString Json; TSharedRef Writer = - TJsonWriterFactory>::Create(&Json); + TJsonWriterFactory>::Create(&Json); Writer->WriteObjectStart(); - Writer->WriteValue(TEXT("schema_version"), 4); + Writer->WriteValue(TEXT("schema_version"), 6); Writer->WriteValue(TEXT("tool"), TEXT("VoxelForgeExplore")); Writer->WriteValue(TEXT("read_only_generation"), true); @@ -3866,6 +4054,7 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("archetype"), ArchetypeName(Arguments.Archetype)); Writer->WriteValue(TEXT("slot"), Arguments.Slot); Writer->WriteValue(TEXT("operator_stack"), Arguments.bUseOperatorStack); + WriteTunnelFloorOverrides(*Writer, Arguments); Writer->WriteValue(TEXT("surface_roughness_override"), Arguments.bSurfaceRoughnessOverride); if (Arguments.bSurfaceRoughnessOverride) { @@ -4072,7 +4261,7 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("refusal_or_error"), Output.Walk.RefusalReason); Writer->WriteValue(TEXT("window_policy"), Output.Walk.WindowPolicy); Writer->WriteValue(TEXT("agent_policy"), TEXT( - "fixed-order depth-first walk over the exact VF player-fit six-neighbour graph; stop at departure")); + "fixed-order depth-first walk over the exact VF player-fit graph; six-neighbour movement plus supported climb edges; stop at departure")); Writer->WriteValue(TEXT("player_fit_volume_scope"), TEXT( "bounded fitted route window; WorldRadiusVoxels=0 has no finite whole-world volume")); Writer->WriteValue(TEXT("max_cells_cap"), Arguments.MaxWalkCells); @@ -4119,6 +4308,8 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("start_snapped"), Report.bStartSnapped); Writer->WriteValue(TEXT("goal_snapped"), Report.bGoalSnapped); Writer->WriteValue(TEXT("route_retries"), Report.NumRouteRetries); + Writer->WriteValue(TEXT("climbing_enabled"), Report.bClimbingEnabled); + Writer->WriteValue(TEXT("max_climb_height_m"), static_cast(Report.MaxClimbHeightMeters)); Writer->WriteValue(TEXT("player_fit_volume_cells"), Report.PlayerFitVolumeCells); Writer->WriteValue(TEXT("reachable_player_fit_cells"), Report.ReachablePlayerFitCells); Writer->WriteValue(TEXT("reachable_player_fit_fraction"), static_cast(Report.ReachablePlayerFitFraction)); @@ -4133,6 +4324,7 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("sampled_min_y"), static_cast(Report.SampledMinY)); Writer->WriteValue(TEXT("sampled_max_y_exclusive"), static_cast(Report.SampledMaxY)); Writer->WriteValue(TEXT("agent_graph_traversals"), Report.AgentGraphTraversals); + Writer->WriteValue(TEXT("climb_traversals"), Report.ClimbTraversals); Writer->WriteValue(TEXT("distance_travelled_m"), static_cast(Report.DistanceTravelledMeters)); Writer->WriteValue(TEXT("straight_line_m"), static_cast(Report.StraightLineMeters)); Writer->WriteValue(TEXT("tortuosity"), static_cast(Report.Tortuosity)); @@ -5668,6 +5860,12 @@ bool BuildBatchCaseParams( JsonBool(Case, TEXT("op_bounds"), bOpBounds); bool bFailureFocus = false; JsonBool(Case, TEXT("failure_focus_render"), bFailureFocus); + bool bTunnelFloorEnabled = true; + const bool bHasTunnelFloorEnabled = JsonBool( + Case, TEXT("tunnel_floor_enabled"), bTunnelFloorEnabled); + bool bTunnelFloorTerracing = true; + const bool bHasTunnelFloorTerracing = JsonBool( + Case, TEXT("tunnel_floor_terracing"), bTunnelFloorTerracing); OutParams = FString::Printf( TEXT("-seed=%d -archetype=%s -slot=%d -modes=%s -opstack=%d " @@ -5679,6 +5877,17 @@ bool BuildBatchCaseParams( bBlockEarlyOut ? 1 : 0, MeshMinBatchSize, bFailureFocus ? 1 : 0, *CaseOutDirectory); + if (bHasTunnelFloorEnabled) + { + OutParams += FString::Printf( + TEXT(" -tunnelfloor=%d"), bTunnelFloorEnabled ? 1 : 0); + } + if (bHasTunnelFloorTerracing) + { + OutParams += FString::Printf( + TEXT(" -tunnelfloorterracing=%d"), bTunnelFloorTerracing ? 1 : 0); + } + if (bProfileDensity) { OutParams += bProfileDensityFull @@ -5711,6 +5920,9 @@ bool BuildBatchCaseParams( { TEXT("render_step"), TEXT("renderstep") }, { TEXT("render_max_distance"), TEXT("rendermaxdistance") }, { TEXT("surface_roughness"), TEXT("surfaceroughness") }, + { TEXT("tunnel_floor_step_height"), TEXT("tunnelfloorstep") }, + { TEXT("tunnel_floor_max_ledge_height"), TEXT("tunnelfloormaxledge") }, + { TEXT("tunnel_floor_gentle_slope"), TEXT("tunnelfloorgentle") }, }; for (const auto& Field : FloatFields) { @@ -5720,6 +5932,19 @@ bool BuildBatchCaseParams( } } + const TPair IntegerFloorFields[] = { + { TEXT("tunnel_floor_ledge_count"), TEXT("tunnelfloorledges") }, + { TEXT("tunnel_floor_max_ledges"), TEXT("tunnelfloormaxledges") }, + }; + for (const auto& Field : IntegerFloorFields) + { + if (JsonNumber(Case, Field.Key, Number)) + { + OutParams += FString::Printf(TEXT(" -%s=%d"), + Field.Value, FMath::RoundToInt(Number)); + } + } + return true; }