diff --git a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp index 59ee5a4..10474f8 100644 --- a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp +++ b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -102,6 +102,30 @@ namespace // inside the chamber and recreates the broad raised shelf this hand-off is meant to remove. constexpr float VF_TunnelMouthBlendRadiusVoxels = VoxelPassageGeometry::WalkableTunnelLandingApronVoxels; + // The boundary probe describes where the *centreline* may leave the room. Keep one complete + // landing-carve band plus the player radius behind that probe so the tapered end-cap and the + // fitted capsule can overlap the room on the sampled lattice. This is a finite mouth apron, + // not a centre-to-centre tunnel extension. + constexpr float VF_TunnelMouthPlayerFitOverlapVoxels = + VoxelPassageGeometry::LandingCarveBlendVoxels + + VoxelPassageGeometry::PlayerRadiusVoxels; + + static float VF_TunnelMouthInset(float TunnelRadius, float BlendRadius) + { + return FMath::Max( + 0.5f, + FMath::Abs(TunnelRadius) * 0.5f + + FMath::Max(FMath::Abs(BlendRadius) * 0.25f, 0.0f) + + VF_TunnelMouthPlayerFitOverlapVoxels); + } + +#if !UE_BUILD_SHIPPING + static TAutoConsoleVariable CVarVoxelForgeTunnelMouthTrim( + TEXT("voxel.TunnelMouthTrim"), + 0, + TEXT("Development-only room-wall tunnel-mouth trim; 0 keeps the legacy room endpoint."), + ECVF_Default); +#endif enum class EVFFloorReliefFeature : uint8 { @@ -372,8 +396,8 @@ namespace - VoxelPassageGeometry::TunnelFloorZ(A, FMath::Abs(RadiusA))); const float BaseGradient = FloorDelta / HorizontalRun; const float GentleThreshold = VoxelMath::IsFinite( - Tunnel.TunnelFloorGentleSlopeThreshold) - ? FMath::Max(Tunnel.TunnelFloorGentleSlopeThreshold, 0.0f) + Tunnel.TunnelFloorGentleSlopeGradient) + ? FMath::Max(Tunnel.TunnelFloorGentleSlopeGradient, 0.0f) : VoxelPassageGeometry::PlayerWalkableFloorMaxGradient; const float AvailableGradient = GentleThreshold - BaseGradient; if (!(AvailableGradient > 0.0f)) @@ -434,8 +458,8 @@ namespace } const float GentleThreshold = VoxelMath::IsFinite( - Tunnel.TunnelFloorGentleSlopeThreshold) - ? FMath::Max(Tunnel.TunnelFloorGentleSlopeThreshold, 0.0f) + Tunnel.TunnelFloorGentleSlopeGradient) + ? FMath::Max(Tunnel.TunnelFloorGentleSlopeGradient, 0.0f) : VoxelPassageGeometry::PlayerWalkableFloorMaxGradient; const float AvailableGradient = GentleThreshold - BaseGradient; if (!(AvailableGradient > 0.0f)) @@ -873,6 +897,146 @@ 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, @@ -880,6 +1044,7 @@ namespace const FCachedTunnel& Tunnel, float SDFBlendRadius, bool bApplyFloorCut, + bool bUseContinuousCurve, const void* CacheIdentity, int32 TunnelIndex, const FVFFloorReliefColumnKey& ReliefColumn, const TArray* FloorProfiles, @@ -910,11 +1075,16 @@ namespace const FVector& B = ControlPointData[SegmentIndex + 1]; const float RadiusA = ControlRadiusData[SegmentIndex]; const float RadiusB = ControlRadiusData[SegmentIndex + 1]; - float T = 0.0f; float HorizontalDistanceSquared = FLT_MAX; - const bool bHasHorizontalProjection = VF_ProjectTunnelSegmentXY( - Position, A, B, T, HorizontalDistanceSquared); + 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); if (!bHasHorizontalProjection) { // A vertical/degenerate XY segment has no finite floor projection. Retain the @@ -925,6 +1095,10 @@ 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( @@ -957,7 +1131,7 @@ namespace // excludes relief only long enough to decide whether the bounded relief can matter; // the final shift below includes the actual cached column value. // Every complete build-time profile owns the floor. A route that was deliberately - // wound, or that contains an explicit ledge transition, also moves the swept capsule + // wound, or that contains an explicit ledge transition, also moves the swept curve // with that authored floor. A direct smooth ramp keeps the established natural arch // and lets the SmoothMax floor cut provide the walkable D-floor; this avoids changing // headroom in every gentle tunnel merely because a profile is now always present. @@ -967,8 +1141,9 @@ namespace && (Tunnel.bFloorRouteWasWound || Tunnel.bDramaticLedge); const bool bAnchorCapsule = bUseAuthoredProfile && VoxelMath::IsFinite(NaturalFloorZ); - const auto EvaluateAnchoredCapsule = [ - &Position, &A, &B, RadiusA, RadiusB, NaturalFloorZ, bAnchorCapsule] + const auto EvaluateAnchoredShape = [ + &Position, &A, &B, RadiusA, RadiusB, CurvePoint, CurveRadius, + NaturalFloorZ, bAnchorCapsule, bUseContinuousCurve] (float FloorZ) -> float { const float Shift = FloorZ - NaturalFloorZ; @@ -976,14 +1151,18 @@ namespace || !VoxelMath::IsFinite(Shift) || FMath::Abs(Shift) <= KINDA_SMALL_NUMBER) { - return VoxelSDF::TaperedCapsule( - Position, A, B, RadiusA, RadiusB); + return bUseContinuousCurve + ? FVector::Dist(Position, CurvePoint) - CurveRadius + : VoxelSDF::TaperedCapsule( + Position, A, B, RadiusA, RadiusB); } const FVector Offset(0.0f, 0.0f, Shift); - return VoxelSDF::TaperedCapsule( - Position, A + Offset, B + Offset, RadiusA, RadiusB); + return bUseContinuousCurve + ? FVector::Dist(Position, CurvePoint + Offset) - CurveRadius + : VoxelSDF::TaperedCapsule( + Position, A + Offset, B + Offset, RadiusA, RadiusB); }; - float SegmentSDF = EvaluateAnchoredCapsule(FloorTerms.BaseFloorZ); + float SegmentSDF = EvaluateAnchoredShape(FloorTerms.BaseFloorZ); bool bEvaluateRelief = bApplyFloorCut; if (bEvaluateRelief && bHasReliefBound) @@ -1026,7 +1205,7 @@ namespace && VoxelMath::IsFinite(FloorZ) && FMath::Abs(FloorZ - FloorTerms.BaseFloorZ) > KINDA_SMALL_NUMBER) { - SegmentSDF = EvaluateAnchoredCapsule(FloorZ); + SegmentSDF = EvaluateAnchoredShape(FloorZ); } const float SegmentResultSDF = bApplyFloorCut ? VoxelSDF::SmoothMax( @@ -1070,6 +1249,7 @@ namespace return VF_EvaluateSweptTunnelChain( Position, ShapeControlPoints, ShapeControlRadii, Tunnel, SDFBlendRadius, bApplyFloorCut, + bWorldChain, CacheIdentity, TunnelIndex, ReliefColumn, bWorldChain ? &Tunnel.WorldFloorProfiles : &Tunnel.FloorProfiles, RoomFloorOwnership); @@ -1090,6 +1270,7 @@ namespace return VF_EvaluateSweptTunnelChain( Position, Points, Radii, Tunnel, SDFBlendRadius, bApplyFloorCut, + bWorldChain, CacheIdentity, TunnelIndex, ReliefColumn, bWorldChain ? &Tunnel.WorldFloorProfiles : &Tunnel.FloorProfiles, RoomFloorOwnership); @@ -1104,6 +1285,7 @@ namespace return VF_EvaluateSweptTunnelChain( Position, Points, Radii, Tunnel, SDFBlendRadius, bApplyFloorCut, + bWorldChain, CacheIdentity, TunnelIndex, ReliefColumn, bWorldChain ? &Tunnel.WorldFloorProfiles : &Tunnel.FloorProfiles, RoomFloorOwnership); @@ -2109,6 +2291,8 @@ static bool VF_ComputeRoomGraphReach( || Params.TunnelFloorTerraceStepHeight < 0.0f || Params.TunnelFloorMaxLedgeHeight < 0.0f || Params.TunnelFloorGentleSlopeThreshold < 0.0f + || Params.TunnelFloorGentleSlopeThreshold + > VoxelPassageGeometry::TunnelFloorGentleSlopeMaximumDegrees || FMath::Abs(Params.FloorReliefStrength) > 1000000.0f || FMath::Abs(Params.CaveWarpStrength) > 1000000.0f || FMath::Abs(Params.RoomFloorCutMin) > 1000000.0f @@ -2200,11 +2384,15 @@ static bool VF_ComputeRoomGraphReach( // quantising the floor into small risers. Bound the route's possible lateral amplitude with // the same finite endpoint envelope used above. This is intentionally conservative: the // actual chain uses a handful of switchback waves and never exceeds this reach. - const float GentleSlopeThreshold = FMath::Max( - Params.TunnelFloorGentleSlopeThreshold, KINDA_SMALL_NUMBER); + const float GentleSlopeThreshold = VoxelPassageGeometry::TunnelFloorGradientFromDegrees( + Params.TunnelFloorGentleSlopeThreshold); const float MaxFloorDeltaEnvelope = 2.0f * EndpointVertical; - const float RequiredRouteRunEnvelope = MaxFloorDeltaEnvelope - / GentleSlopeThreshold; + const float RequiredRouteRunEnvelope = Params.bTunnelFloorEnabled + ? FMath::Min( + VoxelPassageGeometry::TunnelFloorMaximumRouteLengthVoxels, + MaxFloorDeltaEnvelope / FMath::Max( + GentleSlopeThreshold, KINDA_SMALL_NUMBER)) + : 0.0f; const float OrganicLateralEnvelope = FMath::Min( FMath::Abs(Params.TunnelWarpStrength), TunnelLengthBound * 0.25f); const float RouteLateralEnvelope = FMath::Max( @@ -3335,6 +3523,104 @@ namespace return -InternalDensity; } + static bool VF_FindRoomMouthBoundary( + const FVFRoomLandingSite& Site, + const FStrateGenerationParams& Params, + uint32 Seed, + float StrateTopZ, + float StrateBottomZ, + const FVector& Anchor, + const FVector& Direction, + float TunnelRadius, + float BlendRadius, + FVector& OutMouthXY, + float& OutBoundaryDistance) + { + FVector DirectionXY(Direction.X, Direction.Y, 0.0f); + if (!DirectionXY.Normalize() + || !VoxelMath::IsFinite(Anchor.X) + || !VoxelMath::IsFinite(Anchor.Y) + || !VoxelMath::IsFinite(Site.Center.Z)) + { + return false; + } + + // Probe both the room mid-height and the tunnel-centre height. The latter is important + // for vertically compressed/ellipsoidal rooms: their wall can be closer at the floor + // than at mid-height. Both probes use the exact hashed room shape and cave warp used by + // density evaluation. Positive landing density means air/inside. + const float RoomFloorZ = VF_RoomFloorZFromParts( + Site.Center, Site.RadiusXY, Site.RadiusZ, Site.Hash, Seed, Params); + const float LowerProbeZ = VoxelMath::IsFinite(RoomFloorZ) + ? RoomFloorZ + FMath::Abs(TunnelRadius) + : Site.Center.Z; + const float UpperProbeZ = Site.Center.Z; + const auto IsInside = [&](float Distance) -> bool + { + const FVector Probe = Anchor + DirectionXY * Distance; + const float LowerDensity = VF_EvaluateRoomLandingDensity( + Site, Params, Seed, StrateTopZ, StrateBottomZ, + static_cast(Probe.X), static_cast(Probe.Y), LowerProbeZ); + const float UpperDensity = VF_EvaluateRoomLandingDensity( + Site, Params, Seed, StrateTopZ, StrateBottomZ, + static_cast(Probe.X), static_cast(Probe.Y), UpperProbeZ); + return VoxelMath::IsFinite(LowerDensity) + && LowerDensity > 0.0f + && VoxelMath::IsFinite(UpperDensity) + && UpperDensity > 0.0f; + }; + + if (!IsInside(0.0f)) + { + return false; + } + + const float ShapeReach = VF_RoomShapeReachUpperBound( + Site.RadiusXY, Site.RadiusZ); + const float WarpReach = FMath::Abs(Params.CaveWarpStrength) + * VOXEL_NOISE_SCALE * 2.0f; + float High = ShapeReach + WarpReach + + FMath::Max(8.0f, FMath::Abs(TunnelRadius) + FMath::Abs(BlendRadius)); + if (!VoxelMath::IsFinite(High) || High <= 0.0f) + { + return false; + } + + bool bOutside = !IsInside(High); + for (int32 Expansion = 0; !bOutside && Expansion < 4; ++Expansion) + { + High = FMath::Min(High * 2.0f, 4096.0f); + bOutside = !IsInside(High); + } + if (!bOutside) + { + return false; + } + + float Low = 0.0f; + for (int32 Iteration = 0; Iteration < 24; ++Iteration) + { + const float Mid = (Low + High) * 0.5f; + if (IsInside(Mid)) + { + Low = Mid; + } + else + { + High = Mid; + } + } + + const float MouthInset = VF_TunnelMouthInset(TunnelRadius, BlendRadius); + OutBoundaryDistance = Low; + const float MouthDistance = FMath::Max(0.0f, Low - MouthInset); + OutMouthXY = Anchor + DirectionXY * MouthDistance; + OutMouthXY.Z = Site.Center.Z; + return VoxelMath::IsFinite(OutMouthXY.X) + && VoxelMath::IsFinite(OutMouthXY.Y) + && VoxelMath::IsFinite(OutBoundaryDistance); + } + bool VF_FindPlayerFitPointForRoom( const FStrateGenerationParams& Params, const FVFRoomLandingSite& Site, @@ -3821,7 +4107,6 @@ namespace 1, TEXT("Memoize deterministic room player-fit points; 0 disables the memo."), ECVF_Default); - bool VF_FindPlayerFitPointForRoomMemoized( const FStrateGenerationParams& Params, const FVFRoomLandingSite& Site, @@ -6599,12 +6884,92 @@ void VoxelCaveMorphology::BuildChunkCache( return Room.bHasPlayerFitPoint; }; + #if !UE_BUILD_SHIPPING + const bool bTrimRoomMouth = CVarVoxelForgeTunnelMouthTrim.GetValueOnAnyThread() != 0; + #else + constexpr bool bTrimRoomMouth = false; + #endif + const auto BuildRoomMouth = [ + &BuildRooms, &ResolvePlayerFitPoint, &RoomFloorZFor, + &Params, Seed, BlendK, bTrimRoomMouth](int32 RoomIndex, const FVector& Towards, + float TunnelRadius) -> FVector + { + FBuildRoom& Room = BuildRooms[RoomIndex]; + FVector Anchor = Room.Center; + if (!Room.bIsOrigin + && ResolvePlayerFitPoint(Room, Params, Seed)) + { + Anchor = Room.PlayerFitPoint; + } + + if (!bTrimRoomMouth) + { + // Keep the proven legacy endpoint as the safe default while the room-wall trim + // remains an explicit investigation switch. The endpoint still uses the room's + // deterministic floor and player-fit anchor, so the raw tunnel floor remains the + // authored tunnel surface rather than an added slab. + const float RoomFloorZ = RoomFloorZFor(Room); + const float SafeFloorZ = VoxelMath::IsFinite(RoomFloorZ) + ? RoomFloorZ : Room.Center.Z - Room.RadiusZ; + FVector End = Room.Center + FVector( + 0.0f, 0.0f, SafeFloorZ + FMath::Abs(TunnelRadius) - Room.Center.Z); + FVector WorldEnd = VF_UnwarpCavePoint(End, Params, Seed); + if (!Room.bIsOrigin && Room.bHasPlayerFitPoint) + { + WorldEnd = FVector( + Room.PlayerFitPoint.X, + Room.PlayerFitPoint.Y, + Room.PlayerFitPoint.Z - 1.0f + FMath::Abs(TunnelRadius)); + } + return WorldEnd; + } + + const FVFRoomLandingSite Site{ + Room.Center, + Room.RadiusXY, + Room.RadiusZ, + Room.Hash, + Room.bIsOrigin}; + FVector MouthXY = Anchor; + float BoundaryDistance = 0.0f; + if (!VF_FindRoomMouthBoundary( + Site, Params, Seed, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Anchor, Towards, TunnelRadius, BlendK, + MouthXY, BoundaryDistance)) + { + FVector FallbackDirection(Towards.X, Towards.Y, 0.0f); + if (!FallbackDirection.Normalize()) + { + FallbackDirection = FVector::ForwardVector; + } + const float MouthInset = VF_TunnelMouthInset(TunnelRadius, BlendK); + MouthXY = Anchor + FallbackDirection * FMath::Max( + 0.0f, FMath::Abs(Room.RadiusXY) - MouthInset); + } + (void)BoundaryDistance; + + float MouthFloorZ = VF_RoomFloorZFromParts( + FVector(MouthXY.X, MouthXY.Y, Room.Center.Z), + Room.RadiusXY, Room.RadiusZ, Room.Hash, Seed, Params); + if (!VoxelMath::IsFinite(MouthFloorZ)) + { + MouthFloorZ = RoomFloorZFor(Room); + } + if (!VoxelMath::IsFinite(MouthFloorZ)) + { + MouthFloorZ = Room.Center.Z - Room.RadiusZ; + } + return FVector( + MouthXY.X, MouthXY.Y, MouthFloorZ + FMath::Abs(TunnelRadius)); + }; + for (const FResolvedGraphEdge& GraphEdge : GraphEdges) { const int32 I = GraphEdge.RoomA; const int32 J = GraphEdge.RoomB; - const FBuildRoom& RoomA = BuildRooms[I]; - const FBuildRoom& RoomB = BuildRooms[J]; + FBuildRoom& RoomA = BuildRooms[I]; + FBuildRoom& RoomB = BuildRooms[J]; if (!PossibleTunnelTouchesSearchXY(RoomA, RoomB)) { @@ -6627,62 +6992,24 @@ void VoxelCaveMorphology::BuildChunkCache( const float RadA = FMath::Lerp(Params.TunnelMinRadius, Params.TunnelMaxRadius, FactorA); const float RadB = FMath::Lerp(Params.TunnelMinRadius, Params.TunnelMaxRadius, FactorB); - // --- ENDPOINT FLOOR ALIGNMENT --- - // The old endpoint Z roll entered each room at an arbitrary height. When the two - // rooms had different floor cuts, the tube then met one floor several metres above or - // below the other and the player-fit graph saw a vertical severance. Anchor each tube - // tangent to the two rooms' deterministic floor planes instead. Interior control - // points still receive bounded vertical wander, so this removes the lip without - // turning the whole network into a level grid. - const float RoomFloorA = RoomFloorZFor(RoomA); - const float RoomFloorB = RoomFloorZFor(RoomB); - const float SafeFloorA = VoxelMath::IsFinite(RoomFloorA) - ? RoomFloorA : RoomA.Center.Z - RoomA.RadiusZ; - const float SafeFloorB = VoxelMath::IsFinite(RoomFloorB) - ? RoomFloorB : RoomB.Center.Z - RoomB.RadiusZ; - FVector EndA = RoomA.Center + FVector(0.0f, 0.0f, SafeFloorA - + RadA - RoomA.Center.Z); - FVector EndB = RoomB.Center + FVector(0.0f, 0.0f, SafeFloorB - + RadB - RoomB.Center.Z); - - // The passage landing query is allowed to move inside a warped room until the - // capsule actually fits. Use that same deterministic fit anchor for graph mouths; - // otherwise a room whose centre needs a local correction gets a tunnel mouth at one - // point and the inter-strate/player-fit landing at another. Querying with zero - // lateral budget pins the source site to this exact room centre (the helper still - // performs its bounded local fit search). If a malformed asset cannot produce a - // source fit, retain the analytic room-floor fallback above. PlayerFitPoint is a - // feet point; the tunnel floor's authored support band ends half a voxel below it, - // and the route probe stands one voxel above the tunnel-floor plane. Subtracting a - // full voxel here makes that probe exactly the same feet point as the room landing. - FVector WorldEndA = VF_UnwarpCavePoint(EndA, Params, Seed); - FVector WorldEndB = VF_UnwarpCavePoint(EndB, Params, Seed); - if (!RoomA.bIsOrigin) + // --- ENDPOINT ROOM-WALL TRIM + FLOOR ALIGNMENT --- + // Resolve each endpoint against the actual hashed room boundary along the edge. The + // old endpoint was the room centre/player-fit point, so its capsule necessarily ran + // through the chamber. The trim leaves half a tunnel radius plus the SDF blend inside + // the wall, which preserves a walkable overlap without projecting a floor slab into + // the room. + FVector TunnelDirection( + RoomB.Center.X - RoomA.Center.X, + RoomB.Center.Y - RoomA.Center.Y, + 0.0f); + if (!TunnelDirection.Normalize()) { - const bool bHasPlayerFit = ResolvePlayerFitPoint( - BuildRooms[I], Params, Seed); - if (bHasPlayerFit) - { - WorldEndA = FVector( - RoomA.PlayerFitPoint.X, - RoomA.PlayerFitPoint.Y, - RoomA.PlayerFitPoint.Z - 1.0f + RadA); - EndA = VF_ApplyCaveWarp(WorldEndA, Params, Seed); - } - } - if (!RoomB.bIsOrigin) - { - const bool bHasPlayerFit = ResolvePlayerFitPoint( - BuildRooms[J], Params, Seed); - if (bHasPlayerFit) - { - WorldEndB = FVector( - RoomB.PlayerFitPoint.X, - RoomB.PlayerFitPoint.Y, - RoomB.PlayerFitPoint.Z - 1.0f + RadB); - EndB = VF_ApplyCaveWarp(WorldEndB, Params, Seed); - } + TunnelDirection = FVector::ForwardVector; } + const FVector WorldEndA = BuildRoomMouth(I, TunnelDirection, RadA); + const FVector WorldEndB = BuildRoomMouth(J, -TunnelDirection, RadB); + const FVector EndA = VF_ApplyCaveWarp(WorldEndA, Params, Seed); + const FVector EndB = VF_ApplyCaveWarp(WorldEndB, Params, Seed); // --- BUILD CACHED TUNNEL --- FCachedTunnel CT; @@ -6700,7 +7027,9 @@ void VoxelCaveMorphology::BuildChunkCache( CT.bTunnelFloorTerracingEnabled = Params.bTunnelFloorTerracingEnabled; CT.TunnelFloorTerraceStepHeight = Params.TunnelFloorTerraceStepHeight; CT.TunnelFloorMaxLedgeHeight = Params.TunnelFloorMaxLedgeHeight; - CT.TunnelFloorGentleSlopeThreshold = Params.TunnelFloorGentleSlopeThreshold; + CT.TunnelFloorGentleSlopeGradient = + VoxelPassageGeometry::TunnelFloorGradientFromDegrees( + Params.TunnelFloorGentleSlopeThreshold); CT.TunnelFloorLedgeCountPreference = Params.TunnelFloorLedgeCountPreference; CT.TunnelFloorMaxLedges = Params.TunnelFloorMaxLedges; @@ -6724,10 +7053,8 @@ void VoxelCaveMorphology::BuildChunkCache( const float DirectHorizontalRun = FVector2D( static_cast(WorldEndB.X - WorldEndA.X), static_cast(WorldEndB.Y - WorldEndA.Y)).Size(); - const float GentleThreshold = VoxelMath::IsFinite( - Params.TunnelFloorGentleSlopeThreshold) - ? FMath::Max(Params.TunnelFloorGentleSlopeThreshold, 0.0f) - : VoxelPassageGeometry::PlayerWalkableFloorMaxGradient; + const float GentleThreshold = VoxelPassageGeometry::TunnelFloorGradientFromDegrees( + Params.TunnelFloorGentleSlopeThreshold); const float SafeGentleThreshold = FMath::Max( GentleThreshold, KINDA_SMALL_NUMBER); const bool bDirectSteep = VoxelMath::IsFinite(WorldFloorA) @@ -6784,20 +7111,27 @@ void VoxelCaveMorphology::BuildChunkCache( FMath::Max(BaseSegments, MinSegmentsForLedges), 1, 32); - const bool bNeedsWind = bDirectSteep + // A raw tunnel still needs a continuous, gentle route when its floor relief/ledge + // modifier is disabled. The wind is centreline geometry, not an added floor slab; it + // 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 && !bDramaticLedge && GentleThreshold > KINDA_SMALL_NUMBER; CT.bFloorRouteWasWound = bNeedsWind; const float RequiredHorizontalRun = bNeedsWind - ? FMath::Abs(FloorDelta) / SafeGentleThreshold + ? FMath::Min( + VoxelPassageGeometry::TunnelFloorMaximumRouteLengthVoxels, + FMath::Abs(FloorDelta) / SafeGentleThreshold) : DirectHorizontalRun; const int32 WindWaves = bNeedsWind ? FMath::Clamp( FMath::CeilToInt(RequiredHorizontalRun / FMath::Max(DirectHorizontalRun, 16.0f)), - 1, 6) + 1, VoxelPassageGeometry::TunnelFloorMaximumWindWaves) : 0; - FVector HorizontalAxis( WorldEndB.X - WorldEndA.X, WorldEndB.Y - WorldEndA.Y, 0.0f); FVector PerpA; @@ -7947,25 +8281,42 @@ FTunnelCoreWorldEvaluation VoxelCaveMorphology::EvaluateTunnelCoreWorld( const FCachedTunnel& Tunnel = TunnelData[TunnelIdx]; const bool bHasWorldChain = Tunnel.WorldControlPoints.Num() >= 2 && Tunnel.WorldControlRadii.Num() == Tunnel.WorldControlPoints.Num(); - const FVector& BoundCenter = bHasWorldChain - ? Tunnel.WorldBoundCenter : Tunnel.BoundCenter; - const float BoundRadiusSq = bHasWorldChain - ? Tunnel.WorldBoundRadiusSq : Tunnel.BoundRadiusSq; - const float InfluenceRadius = bHasWorldChain - ? Tunnel.WorldInfluenceRadius : Tunnel.SDFInfluenceRadius; - const FVector& CenterlineMin = bHasWorldChain - ? Tunnel.WorldCenterlineMin : Tunnel.SDFCenterlineMin; - const FVector& CenterlineMax = bHasWorldChain - ? Tunnel.WorldCenterlineMax : Tunnel.SDFCenterlineMax; - if (InfluenceRadius > 0.0f + bool bSDFNear = true; + if (Tunnel.SDFInfluenceRadius > 0.0f && VF_DistanceSquaredToAabb( - WorldX, WorldY, WorldZ, CenterlineMin, CenterlineMax) - > FMath::Square(InfluenceRadius)) + WorldX, WorldY, WorldZ, + Tunnel.SDFCenterlineMin, Tunnel.SDFCenterlineMax) + > FMath::Square(Tunnel.SDFInfluenceRadius)) { - return; + bSDFNear = false; } - if (BoundRadiusSq > 0.0f - && VF_FloatDistSquared(WorldX, WorldY, WorldZ, BoundCenter) > BoundRadiusSq) + if (Tunnel.BoundRadiusSq > 0.0f + && VF_FloatDistSquared( + WorldX, WorldY, WorldZ, Tunnel.BoundCenter) > Tunnel.BoundRadiusSq) + { + bSDFNear = false; + } + bool bWorldNear = false; + if (bHasWorldChain) + { + bWorldNear = true; + if (Tunnel.WorldInfluenceRadius > 0.0f + && VF_DistanceSquaredToAabb( + WorldX, WorldY, WorldZ, + Tunnel.WorldCenterlineMin, Tunnel.WorldCenterlineMax) + > FMath::Square(Tunnel.WorldInfluenceRadius)) + { + bWorldNear = false; + } + if (Tunnel.WorldBoundRadiusSq > 0.0f + && VF_FloatDistSquared( + WorldX, WorldY, WorldZ, Tunnel.WorldBoundCenter) + > Tunnel.WorldBoundRadiusSq) + { + bWorldNear = false; + } + } + if (!bSDFNear && !bWorldNear) { return; } @@ -7979,7 +8330,11 @@ FTunnelCoreWorldEvaluation VoxelCaveMorphology::EvaluateTunnelCoreWorld( VF_FindWorldTunnelMouthOwnership(Pos, Tunnel, Cache.SDFBlendRadius); const bool bRoomOwnsThisTunnel = RoomMouthOwnership.bValid; bool bRaisedTunnelFloor = false; - if (bHasWorldChain) + // 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()) { diff --git a/Source/VoxelForge/Private/VoxelStrateComposer.cpp b/Source/VoxelForge/Private/VoxelStrateComposer.cpp index 6ef60d6..dd673ea 100644 --- a/Source/VoxelForge/Private/VoxelStrateComposer.cpp +++ b/Source/VoxelForge/Private/VoxelStrateComposer.cpp @@ -1139,7 +1139,7 @@ namespace 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("TunnelFloorGentleSlopeThreshold")) return Set(0.0f, 90.0f); 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); diff --git a/Source/VoxelForge/Private/VoxelStrateDefinition.cpp b/Source/VoxelForge/Private/VoxelStrateDefinition.cpp new file mode 100644 index 0000000..6b66f75 --- /dev/null +++ b/Source/VoxelForge/Private/VoxelStrateDefinition.cpp @@ -0,0 +1,74 @@ +// Serialized strate-definition migrations. + +#include "VoxelStrateDefinition.h" + +#include "Serialization/CustomVersion.h" +#include "VoxelPassageGeometry.h" + +namespace +{ + // The field name is retained for package compatibility, but version 1 changes its meaning + // from a raw tangent/gradient to authored degrees. This GUID is local to the plugin. + const FGuid GVoxelForgeStrateSerializationVersion( + 0xA76D5E31, 0x4B2C4F9A, 0xB8C1D204, 0x6E9F7738); + constexpr int32 GVoxelForgeStrateGentleSlopeDegreesVersion = 1; + FCustomVersionRegistration GVoxelForgeStrateSerializationRegistration( + GVoxelForgeStrateSerializationVersion, + GVoxelForgeStrateGentleSlopeDegreesVersion, + TEXT("VoxelForgeStrateSerialization")); + + float LegacyGradientToDegrees(float LegacyGradient) + { + if (!VoxelMath::IsFinite(LegacyGradient) || LegacyGradient <= 0.0f) + { + return 0.0f; + } + if (LegacyGradient >= 4096.0f) + { + return VoxelPassageGeometry::TunnelFloorGentleSlopeMaximumDegrees; + } + + // Invert the old gradient without atan/tan. The same deterministic sine/cosine helper + // used by generation makes this migration independent of the host CRT. + float LowDegrees = 0.0f; + float HighDegrees = VoxelPassageGeometry::TunnelFloorGentleSlopeMaximumDegrees; + for (int32 Iteration = 0; Iteration < 32; ++Iteration) + { + const float MidDegrees = (LowDegrees + HighDegrees) * 0.5f; + float SinValue = 0.0f; + float CosValue = 0.0f; + VoxelMath::DetSinCos( + SinValue, CosValue, MidDegrees * (PI / 180.0f)); + const float MidGradient = FMath::Min( + FMath::Abs(SinValue) / FMath::Max(FMath::Abs(CosValue), 1.0e-3f), + 4096.0f); + if (MidGradient < LegacyGradient) + { + LowDegrees = MidDegrees; + } + else + { + HighDegrees = MidDegrees; + } + } + return FMath::Clamp( + (LowDegrees + HighDegrees) * 0.5f, + 0.0f, + VoxelPassageGeometry::TunnelFloorGentleSlopeMaximumDegrees); + } +} + +void UVoxelStrateDefinition::PostLoad() +{ + Super::PostLoad(); + + if (GetLinkerCustomVersion(GVoxelForgeStrateSerializationVersion) + >= GVoxelForgeStrateGentleSlopeDegreesVersion) + { + return; + } + + const float LegacyGradient = GenerationParams.TunnelFloorGentleSlopeThreshold; + GenerationParams.TunnelFloorGentleSlopeThreshold = + LegacyGradientToDegrees(LegacyGradient); +} diff --git a/Source/VoxelForge/Public/VoxelCaveMorphology.h b/Source/VoxelForge/Public/VoxelCaveMorphology.h index 3b5583c..e768d3a 100644 --- a/Source/VoxelForge/Public/VoxelCaveMorphology.h +++ b/Source/VoxelForge/Public/VoxelCaveMorphology.h @@ -716,7 +716,8 @@ struct FCachedTunnel bool bTunnelFloorTerracingEnabled = true; float TunnelFloorTerraceStepHeight = 1.71f; float TunnelFloorMaxLedgeHeight = 12.0f; - float TunnelFloorGentleSlopeThreshold = 0.9656888f; + // Cached internal gradient derived from the authored degree value. This is not serialized. + float TunnelFloorGentleSlopeGradient = 0.9656888f; int32 TunnelFloorLedgeCountPreference = 0; int32 TunnelFloorMaxLedges = 4096; bool bDramaticLedge = false; diff --git a/Source/VoxelForge/Public/VoxelPassageGeometry.h b/Source/VoxelForge/Public/VoxelPassageGeometry.h index e6692c6..a93770d 100644 --- a/Source/VoxelForge/Public/VoxelPassageGeometry.h +++ b/Source/VoxelForge/Public/VoxelPassageGeometry.h @@ -54,6 +54,47 @@ namespace VoxelPassageGeometry // fallback threshold at which a room-to-room floor must become a terrace. constexpr float PlayerWalkableFloorMaxGradient = 0.9656887748070738f; // tan(44 degrees) + // TunnelFloorGentleSlopeThreshold is authored as degrees. Zero is still a legal UI value, + // but the construction path uses this small positive floor so a malformed/overly strict + // value cannot turn the graph collect window into an unbounded switchback search. + constexpr float TunnelFloorGentleSlopeMinimumDegrees = 1.0f; + constexpr float TunnelFloorGentleSlopeMaximumDegrees = 90.0f; + // The route solver uses at most six half-waves. The canonical tunnel envelope is the default + // 360-voxel tunnel reach plus its endpoint correction; round that direct-run envelope up to + // 640 voxels, then derive the total cap from the same wave limit. This leaves room above the + // proven canonical route while keeping the 0.1-degree collect window finite and bounded. + constexpr int32 TunnelFloorMaximumWindWaves = 6; + constexpr float TunnelFloorMaximumSingleWaveRunVoxels = 640.0f; + constexpr float TunnelFloorMaximumRouteLengthVoxels = + static_cast(TunnelFloorMaximumWindWaves) + * TunnelFloorMaximumSingleWaveRunVoxels; + + FORCEINLINE float EffectiveTunnelFloorGentleSlopeDegrees(float AuthoredDegrees) + { + if (!VoxelMath::IsFinite(AuthoredDegrees)) + { + return 44.0f; + } + return FMath::Clamp( + FMath::Max(AuthoredDegrees, TunnelFloorGentleSlopeMinimumDegrees), + TunnelFloorGentleSlopeMinimumDegrees, + TunnelFloorGentleSlopeMaximumDegrees); + } + + FORCEINLINE float TunnelFloorGradientFromDegrees(float AuthoredDegrees) + { + const float Degrees = EffectiveTunnelFloorGentleSlopeDegrees(AuthoredDegrees); + float SinValue = 0.0f; + float CosValue = 0.0f; + VoxelMath::DetSinCos( + SinValue, CosValue, Degrees * (PI / 180.0f)); + // At 90 degrees the mathematical tangent is unbounded. The route solver only needs a + // finite comparison, so preserve the ordering while keeping every downstream bound finite. + return FMath::Min( + FMath::Abs(SinValue) / FMath::Max(FMath::Abs(CosValue), 1.0e-3f), + 4096.0f); + } + // The walkability contract is about the floor carried by the tube, not only its centreline. // A tapered tube changes that floor by the radius delta, so the construction and the audit // use this same conservative floor delta. diff --git a/Source/VoxelForge/Public/VoxelStrateDefinition.h b/Source/VoxelForge/Public/VoxelStrateDefinition.h index 69d18a2..8c98433 100644 --- a/Source/VoxelForge/Public/VoxelStrateDefinition.h +++ b/Source/VoxelForge/Public/VoxelStrateDefinition.h @@ -48,6 +48,8 @@ class VOXELFORGE_API UVoxelStrateDefinition : public UPrimaryDataAsset GENERATED_BODY() public: + virtual void PostLoad() override; + //========================================================================= // IDENTIFICATION //========================================================================= diff --git a/Source/VoxelForge/Public/VoxelStrateTypes.h b/Source/VoxelForge/Public/VoxelStrateTypes.h index e0bd22d..d567676 100644 --- a/Source/VoxelForge/Public/VoxelStrateTypes.h +++ b/Source/VoxelForge/Public/VoxelStrateTypes.h @@ -709,12 +709,13 @@ struct VOXELFORGE_API FStrateGenerationParams meta = (ClampMin = "0.0")) float TunnelFloorMaxLedgeHeight = 12.0f; - // Floor gradient threshold for a direct route. At or below it, the floor is a smooth ramp. A - // steeper walk-critical edge receives a longer winding route; an eligible edge may receive - // deterministic dramatic ledges instead. The default is tan(44 degrees). + // Floor gradient threshold in degrees for a direct route. At or below it, the floor is a + // smooth ramp. A steeper walk-critical edge receives a longer winding route; an eligible edge + // may receive deterministic dramatic ledges instead. Internally this is converted with + // VoxelMath::DetSinCos; the old serialized tangent value is migrated on asset load. UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels|Floor", - meta = (ClampMin = "0.0")) - float TunnelFloorGentleSlopeThreshold = 0.9656888f; + meta = (ClampMin = "0.0", ClampMax = "90.0", DisplayName = "Gentle Slope Threshold (degrees)")) + float TunnelFloorGentleSlopeThreshold = 44.0f; // Preferred number of large ledges for a steep eligible floor. Zero selects the deterministic // height-driven count; positive values request at least that many transitions, subject to the diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp index 9acbfa3..dcf7ea4 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -34,6 +34,7 @@ #include "VoxelGenerator.h" #include "VoxelMarchingCubesMesher.h" #include "VoxelNoise.h" +#include "VoxelPassageGeometry.h" #include "VoxelSettings.h" #include "VoxelStackSampler.h" #include "VoxelStrateDefinition.h" @@ -213,6 +214,49 @@ bool ParseRenderCamera(const FString& Text, FVector& OutCamera) return true; } +bool ParseDensitySlice( + const FString& Text, + FVector& OutOrigin, + FVector& OutDirection, + int32& OutSamples, + float& OutStep) +{ + TArray Components; + Text.ParseIntoArray(Components, TEXT(","), true); + if (Components.Num() != 8) + { + return false; + } + + const float OriginX = FCString::Atof(*Components[0]); + const float OriginY = FCString::Atof(*Components[1]); + const float OriginZ = FCString::Atof(*Components[2]); + const float DirectionX = FCString::Atof(*Components[3]); + const float DirectionY = FCString::Atof(*Components[4]); + const float DirectionZ = FCString::Atof(*Components[5]); + const int32 Samples = FCString::Atoi(*Components[6]); + const float Step = FCString::Atof(*Components[7]); + if (!FMath::IsFinite(OriginX) || !FMath::IsFinite(OriginY) + || !FMath::IsFinite(OriginZ) || !FMath::IsFinite(DirectionX) + || !FMath::IsFinite(DirectionY) || !FMath::IsFinite(DirectionZ) + || !FMath::IsFinite(Step) || Samples < 2 || Samples > 4096 + || Step <= 0.0f) + { + return false; + } + + FVector Direction(DirectionX, DirectionY, DirectionZ); + if (!Direction.Normalize()) + { + return false; + } + OutOrigin = FVector(OriginX, OriginY, OriginZ); + OutDirection = Direction; + OutSamples = Samples; + OutStep = Step; + return true; +} + bool NeedsOriginInWindow(ECaveGeneratorType Archetype) { // This is the same topology policy as VoxelForgePlayerFitWindow.h. That header is test-only, @@ -261,8 +305,11 @@ float BoundarySealForArchetype( struct FExploreArguments { int32 Seed = 0; + bool bSeedOverride = false; ECaveGeneratorType Archetype = ECaveGeneratorType::Maze; int32 Slot = 4; + FString StrateReference; + FString SettingsReference; bool bRender = true; bool bWalk = true; bool bExport = true; @@ -296,6 +343,8 @@ struct FExploreArguments float BudgetMinutes = DefaultBudgetMinutes; bool bSurfaceRoughnessOverride = false; float SurfaceRoughness = 0.0f; + bool bTunnelWarpStrengthOverride = false; + float TunnelWarpStrength = 0.0f; bool bRoughnessNoiseTypeOverride = false; EVoxelNoiseType RoughnessNoiseType = EVoxelNoiseType::FBM; bool bRoughnessFrequencyOverride = false; @@ -322,6 +371,13 @@ struct FExploreArguments int32 PassageFloorWrites = 1; bool bRenderCameraOverride = false; FVector RenderCamera = FVector::ZeroVector; + bool bRenderTargetOverride = false; + FVector RenderTarget = FVector::ZeroVector; + bool bDensitySlice = false; + FVector DensitySliceOrigin = FVector::ZeroVector; + FVector DensitySliceDirection = FVector::ForwardVector; + int32 DensitySliceSamples = 257; + float DensitySliceStep = 0.5f; bool IsDensityProfilingEnabled() const { @@ -349,9 +405,11 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr FString ModesText = TEXT("render,walk,export"); FString OutText; - FParse::Value(*Params, TEXT("seed="), OutArguments.Seed); + OutArguments.bSeedOverride = FParse::Value(*Params, TEXT("seed="), OutArguments.Seed); FParse::Value(*Params, TEXT("archetype="), ArchetypeText); FParse::Value(*Params, TEXT("slot="), OutArguments.Slot); + FParse::Value(*Params, TEXT("strateref="), OutArguments.StrateReference); + FParse::Value(*Params, TEXT("settingsref="), OutArguments.SettingsReference); FParse::Value(*Params, TEXT("modes="), ModesText); // FParse::Value treats a comma as a command-line separator in some UE commandlet // launch paths. Recover the complete token so both -modes=walk,export and the @@ -427,6 +485,11 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr const bool bSurfaceRoughnessParsed = FParse::Value( *Params, TEXT("surfaceroughness="), OutArguments.SurfaceRoughness); OutArguments.bSurfaceRoughnessOverride = bSurfaceRoughnessSpecified; + const bool bTunnelWarpStrengthSpecified = Params.Contains( + TEXT("tunnelwarp="), ESearchCase::IgnoreCase); + const bool bTunnelWarpStrengthParsed = FParse::Value( + *Params, TEXT("tunnelwarp="), OutArguments.TunnelWarpStrength); + OutArguments.bTunnelWarpStrengthOverride = bTunnelWarpStrengthSpecified; FString RoughnessNoiseTypeText; const bool bRoughnessNoiseTypeSpecified = Params.Contains( TEXT("roughnesstype="), ESearchCase::IgnoreCase); @@ -510,6 +573,53 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr } } OutArguments.bRenderCameraOverride = bRenderCameraSpecified; + + FString RenderTargetText; + const bool bRenderTargetSpecified = Params.Contains( + TEXT("rendertarget="), ESearchCase::IgnoreCase); + bool bRenderTargetParsed = false; + if (bRenderTargetSpecified) + { + const int32 TargetOffset = Params.Find(TEXT("rendertarget="), ESearchCase::IgnoreCase); + if (TargetOffset != INDEX_NONE) + { + RenderTargetText = Params.Mid(TargetOffset + 13); + const int32 TargetEnd = RenderTargetText.Find(TEXT(" ")); + if (TargetEnd != INDEX_NONE) + { + RenderTargetText.LeftInline(TargetEnd); + } + RenderTargetText.TrimQuotesInline(); + bRenderTargetParsed = ParseRenderCamera(RenderTargetText, OutArguments.RenderTarget); + } + } + OutArguments.bRenderTargetOverride = bRenderTargetSpecified; + + FString DensitySliceText; + const bool bDensitySliceSpecified = Params.Contains( + TEXT("densityslice="), ESearchCase::IgnoreCase); + bool bDensitySliceParsed = false; + if (bDensitySliceSpecified) + { + const int32 SliceOffset = Params.Find(TEXT("densityslice="), ESearchCase::IgnoreCase); + if (SliceOffset != INDEX_NONE) + { + DensitySliceText = Params.Mid(SliceOffset + 13); + const int32 SliceEnd = DensitySliceText.Find(TEXT(" ")); + if (SliceEnd != INDEX_NONE) + { + DensitySliceText.LeftInline(SliceEnd); + } + DensitySliceText.TrimQuotesInline(); + bDensitySliceParsed = ParseDensitySlice( + DensitySliceText, + OutArguments.DensitySliceOrigin, + OutArguments.DensitySliceDirection, + OutArguments.DensitySliceSamples, + OutArguments.DensitySliceStep); + } + } + OutArguments.bDensitySlice = bDensitySliceSpecified; int32 FailureFocusRender = 0; FParse::Value(*Params, TEXT("failurefocus="), FailureFocusRender); OutArguments.bFailureFocusRender = FailureFocusRender != 0; @@ -656,6 +766,14 @@ 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.bTunnelWarpStrengthOverride + && (!bTunnelWarpStrengthParsed + || !FMath::IsFinite(OutArguments.TunnelWarpStrength) + || OutArguments.TunnelWarpStrength < 0.0f)) + { + OutError = TEXT("tunnelwarp must be finite and greater than or equal to zero."); + return false; + } if (OutArguments.bRoughnessNoiseTypeOverride && !bRoughnessNoiseTypeParsed) { OutError = TEXT("roughnesstype must be FBM, Ridged, Mixed, or Cellular."); @@ -704,9 +822,11 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr if (OutArguments.bTunnelFloorGentleOverride && (!bTunnelFloorGentleParsed || !FMath::IsFinite(OutArguments.TunnelFloorGentle) - || OutArguments.TunnelFloorGentle < 0.0f)) + || OutArguments.TunnelFloorGentle < 0.0f + || OutArguments.TunnelFloorGentle + > VoxelPassageGeometry::TunnelFloorGentleSlopeMaximumDegrees)) { - OutError = TEXT("tunnelfloorgentle must be finite and non-negative."); + OutError = TEXT("tunnelfloorgentle must be finite degrees in [0,90]."); return false; } if (OutArguments.bTunnelFloorLedgesOverride @@ -756,6 +876,17 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr OutError = TEXT("rendercamera must be three finite comma-separated voxel coordinates."); return false; } + if (OutArguments.bRenderTargetOverride && !bRenderTargetParsed) + { + OutError = TEXT("rendertarget must be three finite comma-separated voxel coordinates."); + return false; + } + if (OutArguments.bDensitySlice && !bDensitySliceParsed) + { + OutError = TEXT( + "densityslice must be origin x,y,z, direction x,y,z, sample count, and positive step."); + return false; + } if (OutText.IsEmpty()) { @@ -946,64 +1077,242 @@ struct FExploreWorld bool Build(const FExploreArguments& Arguments, FString& OutError) { - const int32 NumStrates = Arguments.Slot + 2; + const bool bUseAuthoredSettings = !Arguments.SettingsReference.IsEmpty(); + const bool bUseAuthoredStrate = !Arguments.StrateReference.IsEmpty(); + UVoxelStrateDefinition* AuthoredDefinition = nullptr; + if (bUseAuthoredStrate) + { + AuthoredDefinition = LoadObject( + nullptr, *Arguments.StrateReference); + if (AuthoredDefinition == nullptr) + { + OutError = FString::Printf( + TEXT("Could not load staged strate asset '%s'."), + *Arguments.StrateReference); + return false; + } + } + + if (bUseAuthoredSettings) + { + UVoxelSettings* AuthoredSettings = LoadObject( + nullptr, *Arguments.SettingsReference); + if (AuthoredSettings == nullptr) + { + OutError = FString::Printf( + TEXT("Could not load staged settings asset '%s'."), + *Arguments.SettingsReference); + return false; + } + Settings = TStrongObjectPtr( + DuplicateObject(AuthoredSettings, GetTransientPackage())); + if (!Settings.IsValid()) + { + OutError = TEXT("Could not duplicate the staged settings asset into the transient package."); + return false; + } + } + else + { + Settings = TStrongObjectPtr( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + } + + const int32 NumStrates = bUseAuthoredSettings + ? FMath::Max(Settings->TotalStrates, Arguments.Slot + 2) + : Arguments.Slot + 2; if (NumStrates > MaxSyntheticStrates) { OutError = TEXT("The synthetic layout would exceed the commandlet strate cap."); return false; } - Settings = TStrongObjectPtr( - NewObject(GetTransientPackage(), NAME_None, RF_Transient)); - Settings->Seed = Arguments.Seed; + if (!bUseAuthoredSettings) + { + Settings->Seed = Arguments.Seed; + Settings->TotalStrates = NumStrates; + Settings->InterStrateGapChunks = 0; + Settings->WorldRadiusVoxels = 0.0f; + Settings->EdgeSealThickness = 64.0f; + Settings->OriginSpineRadius = 14.0f; + Settings->bOpenSurfaceEntry = true; + } + else if (Arguments.bSeedOverride) + { + Settings->Seed = Arguments.Seed; + } + // The transient fixed-definition layout may need to extend an authored settings asset + // for a requested interior slot. Keep that duplicate's advertised count consistent; the + // loaded package remains untouched. Settings->TotalStrates = NumStrates; - Settings->InterStrateGapChunks = 0; - Settings->WorldRadiusVoxels = 0.0f; - Settings->EdgeSealThickness = 64.0f; - Settings->OriginSpineRadius = 14.0f; - Settings->bOpenSurfaceEntry = true; + if (bUseAuthoredStrate) + { + // The owner’s snapshot states that every slot uses DA_Strate3. Clone it per slot so + // commandlet overrides never mutate a loaded package or leak between slots. + Settings->StratePool.Reset(); + Settings->FixedStrates.Reset(); + } Definitions.Reserve(NumStrates); for (int32 Index = 0; Index < NumStrates; ++Index) { - UVoxelStrateDefinition* Definition = NewObject( - GetTransientPackage(), NAME_None, RF_Transient); - Definition->StrateName = FText::FromString(FString::Printf( - TEXT("VoxelForgeExplore_%s_slot%d"), - ArchetypeName(Index == Arguments.Slot - ? Arguments.Archetype - : ECaveGeneratorType::TunnelNetwork), - Index)); - Definition->StrateDescription = FText::FromString( - TEXT("Transient, deterministic commandlet definition.")); - // The commandlet's acceptance sweep uses the owner-facing base spacing: - // 4 chunks * 32 voxels * 0.25 m = 32 m per strate. Production assets may be taller; - // this keeps the measured tunnel arithmetic tied to the stated ~32 m descent. - Definition->StrateHeightInChunks = 4; - Definition->TransitionType = EVoxelStrateTransition::Hard; - Definition->GeneratorType = Index == Arguments.Slot - ? Arguments.Archetype - : ECaveGeneratorType::TunnelNetwork; - // Explicitly select the production switch so the same synthetic layout, seed and - // mesher invocation can be measured through both density branches. - Definition->bUseOperatorStack = Arguments.bUseOperatorStack; - if (Arguments.bWormsEnabledOverride) + UVoxelStrateDefinition* Definition = bUseAuthoredStrate + ? DuplicateObject( + AuthoredDefinition, GetTransientPackage()) + : NewObject( + GetTransientPackage(), NAME_None, RF_Transient); + if (Definition == nullptr) { - Definition->bEnableWorms = Arguments.bWormsEnabled; + OutError = FString::Printf( + TEXT("Could not create transient strate definition for slot %d."), Index); + return false; } - if (Arguments.bWormStrengthOverride - && (Definition->GeneratorType == ECaveGeneratorType::TunnelNetwork - || Definition->GeneratorType == ECaveGeneratorType::Underwater)) + if (!bUseAuthoredStrate) { - Definition->GenerationParams.WormStrength = Arguments.WormStrength; + Definition->StrateName = FText::FromString(FString::Printf( + TEXT("VoxelForgeExplore_%s_slot%d"), + ArchetypeName(Index == Arguments.Slot + ? Arguments.Archetype + : ECaveGeneratorType::TunnelNetwork), + Index)); + Definition->StrateDescription = FText::FromString( + TEXT("Transient, deterministic commandlet definition.")); + // The commandlet's acceptance sweep uses the owner-facing base spacing: + // 4 chunks * 32 voxels * 0.25 m = 32 m per strate. Production assets may be taller; + // this keeps the measured tunnel arithmetic tied to the stated ~32 m descent. + Definition->StrateHeightInChunks = 4; + Definition->TransitionType = EVoxelStrateTransition::Hard; + Definition->GeneratorType = Index == Arguments.Slot + ? Arguments.Archetype + : ECaveGeneratorType::TunnelNetwork; + // Explicitly select the production switch so the same synthetic layout, seed and + // mesher invocation can be measured through both density branches. + Definition->bUseOperatorStack = Arguments.bUseOperatorStack; + if (Arguments.bWormsEnabledOverride) + { + Definition->bEnableWorms = Arguments.bWormsEnabled; + } + if (Arguments.bWormStrengthOverride + && (Definition->GeneratorType == ECaveGeneratorType::TunnelNetwork + || Definition->GeneratorType == ECaveGeneratorType::Underwater)) + { + Definition->GenerationParams.WormStrength = Arguments.WormStrength; + } } const TSoftObjectPtr SoftDefinition(Definition); Settings->FixedStrates.Add(Index, SoftDefinition); - Settings->StratePool.Add(SoftDefinition); + if (!bUseAuthoredStrate) + { + Settings->StratePool.Add(SoftDefinition); + } Definitions.Add(TStrongObjectPtr(Definition)); } + auto ApplyTargetOverrides = [&Arguments](UVoxelStrateDefinition* Definition) -> bool + { + if (Definition == nullptr) { return false; } + const ECaveGeneratorType TargetArchetype = Definition->GeneratorType; + if (Arguments.bSurfaceRoughnessOverride) + { + switch (TargetArchetype) + { + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + Definition->SlabParams.FloorRoughness = Arguments.SurfaceRoughness; + Definition->SlabParams.CeilingRoughness = Arguments.SurfaceRoughness; + break; + case ECaveGeneratorType::Maze: + Definition->MazeParams.SurfaceRoughness = Arguments.SurfaceRoughness; + break; + case ECaveGeneratorType::SurfaceWorld: + Definition->SurfaceParams.SurfaceRoughness = Arguments.SurfaceRoughness; + break; + case ECaveGeneratorType::VerticalShafts: + Definition->VerticalShaftParams.SurfaceRoughness = Arguments.SurfaceRoughness; + break; + case ECaveGeneratorType::FloatingIslands: + Definition->FloatingIslandParams.SurfaceRoughness = Arguments.SurfaceRoughness; + break; + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + default: + Definition->GenerationParams.SurfaceRoughness = Arguments.SurfaceRoughness; + break; + } + } + if (Arguments.bRoughnessNoiseTypeOverride + || Arguments.bRoughnessFrequencyOverride) + { + if (TargetArchetype != ECaveGeneratorType::TunnelNetwork + && TargetArchetype != ECaveGeneratorType::Underwater) + { + return false; + } + if (Arguments.bRoughnessNoiseTypeOverride) + { + Definition->GenerationParams.RoughnessNoiseType = Arguments.RoughnessNoiseType; + } + if (Arguments.bRoughnessFrequencyOverride) + { + Definition->GenerationParams.RoughnessFrequency = Arguments.RoughnessFrequency; + } + } + FStrateGenerationParams& TunnelParams = Definition->GenerationParams; + if (Arguments.bTunnelWarpStrengthOverride) + { + if (TargetArchetype != ECaveGeneratorType::TunnelNetwork + && TargetArchetype != ECaveGeneratorType::Underwater) + { + return false; + } + TunnelParams.TunnelWarpStrength = Arguments.TunnelWarpStrength; + } + if (Arguments.bTunnelFloorEnabledOverride) + { + TunnelParams.bTunnelFloorEnabled = Arguments.TunnelFloorEnabled != 0; + } + if (Arguments.bTunnelFloorTerracingOverride) + { + TunnelParams.bTunnelFloorTerracingEnabled = Arguments.TunnelFloorTerracing != 0; + } + if (Arguments.bTunnelFloorStepOverride) + { + TunnelParams.TunnelFloorTerraceStepHeight = Arguments.TunnelFloorStep; + } + if (Arguments.bTunnelFloorMaxLedgeOverride) + { + TunnelParams.TunnelFloorMaxLedgeHeight = Arguments.TunnelFloorMaxLedge; + } + if (Arguments.bTunnelFloorGentleOverride) + { + TunnelParams.TunnelFloorGentleSlopeThreshold = Arguments.TunnelFloorGentle; + } + if (Arguments.bTunnelFloorLedgesOverride) + { + TunnelParams.TunnelFloorLedgeCountPreference = Arguments.TunnelFloorLedges; + } + if (Arguments.bTunnelFloorMaxLedgesOverride) + { + TunnelParams.TunnelFloorMaxLedges = Arguments.TunnelFloorMaxLedges; + } + if (Arguments.bRoomMouthRiseOverride) + { + TunnelParams.RoomMouthRiseStrength = Arguments.RoomMouthRise; + } + if (Arguments.bRoomMouthRiseBlendOverride) + { + TunnelParams.RoomMouthRiseBlendVoxels = Arguments.RoomMouthRiseBlend; + } + return true; + }; + if (!Definitions.IsValidIndex(Arguments.Slot) + || !ApplyTargetOverrides(Definitions[Arguments.Slot].Get())) + { + OutError = TEXT("The target definition rejected a commandlet override."); + return false; + } + Manager = TStrongObjectPtr( NewObject(GetTransientPackage(), NAME_None, RF_Transient)); if (!Manager->Initialize(Settings.Get(), Settings->Seed)) @@ -1028,104 +1337,40 @@ struct FExploreWorld OutError = TEXT("The requested target slot has no resolved definition."); return false; } - if (Arguments.bSurfaceRoughnessOverride) - { - // This is deliberately applied after layout construction and only to the target slot's - // resolved definition. Passage placement and every neighbouring strate retain their - // authored/default values; only the sampled target field gets the experiment knob. - switch (Arguments.Archetype) - { - case ECaveGeneratorType::FlatPlain: - case ECaveGeneratorType::CrystalChamber: - Target.Definition->SlabParams.FloorRoughness = Arguments.SurfaceRoughness; - Target.Definition->SlabParams.CeilingRoughness = Arguments.SurfaceRoughness; - break; - case ECaveGeneratorType::Maze: - Target.Definition->MazeParams.SurfaceRoughness = Arguments.SurfaceRoughness; - break; - case ECaveGeneratorType::SurfaceWorld: - Target.Definition->SurfaceParams.SurfaceRoughness = Arguments.SurfaceRoughness; - break; - case ECaveGeneratorType::VerticalShafts: - Target.Definition->VerticalShaftParams.SurfaceRoughness = Arguments.SurfaceRoughness; - break; - case ECaveGeneratorType::FloatingIslands: - Target.Definition->FloatingIslandParams.SurfaceRoughness = Arguments.SurfaceRoughness; - break; - case ECaveGeneratorType::TunnelNetwork: - case ECaveGeneratorType::Underwater: - default: - Target.Definition->GenerationParams.SurfaceRoughness = Arguments.SurfaceRoughness; - break; - } - } - if (Arguments.bRoughnessNoiseTypeOverride - || Arguments.bRoughnessFrequencyOverride) - { - if (Arguments.Archetype != ECaveGeneratorType::TunnelNetwork - && Arguments.Archetype != ECaveGeneratorType::Underwater) - { - OutError = TEXT( - "roughnesstype/roughnessfrequency are only supported by TunnelNetwork or Underwater."); - return false; - } - if (Arguments.bRoughnessNoiseTypeOverride) - { - Target.Definition->GenerationParams.RoughnessNoiseType = - Arguments.RoughnessNoiseType; - } - if (Arguments.bRoughnessFrequencyOverride) - { - Target.Definition->GenerationParams.RoughnessFrequency = - Arguments.RoughnessFrequency; - } - } - // 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; - } - if (Arguments.bRoomMouthRiseOverride) - { - TargetTunnelParams.RoomMouthRiseStrength = Arguments.RoomMouthRise; - } - if (Arguments.bRoomMouthRiseBlendOverride) - { - TargetTunnelParams.RoomMouthRiseBlendVoxels = Arguments.RoomMouthRiseBlend; - } + const ECaveGeneratorType TargetArchetype = Target.Definition->GeneratorType; + const FStrateGenerationParams& ReportParams = Target.Definition->GenerationParams; + 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 " + "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 " + "layer_spacing=%.9g rib_spacing=%.9g"), + bUseAuthoredStrate ? *Arguments.StrateReference : TEXT("synthetic"), + 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, + ReportParams.TunnelFloorGentleSlopeThreshold, + VoxelPassageGeometry::TunnelFloorGradientFromDegrees( + ReportParams.TunnelFloorGentleSlopeThreshold), + VoxelPassageGeometry::TunnelFloorMaximumRouteLengthVoxels, + VoxelPassageGeometry::TunnelFloorMaximumWindWaves, + ReportParams.TunnelFloorLedgeCountPreference, + ReportParams.TunnelFloorMaxLedges, + ReportParams.TunnelWarpStrength, + Target.Definition->TerrainOperations.Num(), + ReportParams.TerraceStepHeight, + ReportParams.LayerLineSpacing, + ReportParams.RibbingSpacing); TargetBottomWorldZ = Target.BottomChunkZ * CHUNK_SIZE; TargetTopWorldZ = (Target.TopChunkZ + 1) * CHUNK_SIZE; TargetBoundarySealThickness = BoundarySealForArchetype( - *Target.Definition, Arguments.Archetype); + *Target.Definition, TargetArchetype); if (TargetTopWorldZ <= TargetBottomWorldZ) { OutError = TEXT("The target strate has no positive voxel-space height."); @@ -1315,6 +1560,22 @@ struct FExploreExportOutput bool bTruncated = false; }; +struct FExploreDensitySliceOutput +{ + FString Status; + FString RefusalReason; + FString FileName; + int32 SampleCount = 0; + int32 FiniteSampleCount = 0; + int32 NonFiniteSampleCount = 0; + int32 ZeroCrossingCount = 0; + int32 SaturatedSampleCount = 0; + float MinDensity = 0.0f; + float MaxDensity = 0.0f; + float MaxAbsDensity = 0.0f; + double Seconds = 0.0; +}; + struct FExploreProfilerComparison { bool bAvailable = false; @@ -1335,6 +1596,7 @@ struct FExploreRunOutput FExploreRenderOutput Render; FExploreWalkOutput Walk; FExploreExportOutput Export; + FExploreDensitySliceOutput DensitySlice; double BudgetSeconds = 0.0; double ElapsedSeconds = 0.0; double WalkSeconds = 0.0; @@ -2776,7 +3038,12 @@ bool RunRender( OutOutput.bAllCamerasPlayerFit = true; FVector RouteFocus = FVector::ZeroVector; int32 RouteFocusCount = 0; - if (CameraSeedWalk.bHasArrival) + if (Arguments.bRenderTargetOverride) + { + RouteFocus = Arguments.RenderTarget; + RouteFocusCount = 1; + } + else if (CameraSeedWalk.bHasArrival) { RouteFocus += CameraSeedWalk.ArrivalVoxels; ++RouteFocusCount; @@ -4136,6 +4403,7 @@ void WriteTunnelFloorOverrides( { Writer.WriteValue(TEXT("gentle_slope"), static_cast(Arguments.TunnelFloorGentle)); + Writer.WriteValue(TEXT("gentle_slope_units"), TEXT("degrees")); } Writer.WriteValue(TEXT("ledge_count_override"), Arguments.bTunnelFloorLedgesOverride); if (Arguments.bTunnelFloorLedgesOverride) @@ -4434,6 +4702,100 @@ bool RunExport( return !OutOutput.bTruncated; } +bool RunDensitySlice( + const FExploreArguments& Arguments, + FExploreWorld& World, + FExploreDensitySliceOutput& OutOutput, + FExploreBudget& Budget) +{ + OutOutput = FExploreDensitySliceOutput(); + OutOutput.FileName = TEXT("density_slice.csv"); + OutOutput.SampleCount = Arguments.DensitySliceSamples; + const double StartSeconds = FPlatformTime::Seconds(); + FString Csv; + Csv.Reserve(Arguments.DensitySliceSamples * 96); + Csv += TEXT("index,x_voxels,y_voxels,z_voxels,density\r\n"); + + float PreviousDensity = 0.0f; + bool bHavePreviousDensity = false; + float MinDensity = TNumericLimits::Max(); + float MaxDensity = -TNumericLimits::Max(); + float MaxAbsDensity = 0.0f; + for (int32 Index = 0; Index < Arguments.DensitySliceSamples; ++Index) + { + if ((Index & 127) == 0 && Budget.ShouldStop(TEXT("density-slice"))) + { + OutOutput.Status = TEXT("truncated"); + OutOutput.RefusalReason = TEXT("The wall-clock budget stopped the density slice."); + OutOutput.Seconds = FPlatformTime::Seconds() - StartSeconds; + return false; + } + + const FVector Position = Arguments.DensitySliceOrigin + + Arguments.DensitySliceDirection + * (Arguments.DensitySliceStep * static_cast(Index)); + const float Density = World.Generator->GetDensityAt( + Position.X, Position.Y, Position.Z); + Csv += FString::Printf( + TEXT("%d,%.9g,%.9g,%.9g,%.9g\r\n"), + Index, Position.X, Position.Y, Position.Z, Density); + + if (!FMath::IsFinite(Density)) + { + ++OutOutput.NonFiniteSampleCount; + bHavePreviousDensity = false; + continue; + } + ++OutOutput.FiniteSampleCount; + MinDensity = FMath::Min(MinDensity, Density); + MaxDensity = FMath::Max(MaxDensity, Density); + MaxAbsDensity = FMath::Max(MaxAbsDensity, FMath::Abs(Density)); + if (FMath::Abs(Density) >= 0.999f) + { + ++OutOutput.SaturatedSampleCount; + } + if (bHavePreviousDensity + && ((PreviousDensity < 0.0f && Density > 0.0f) + || (PreviousDensity > 0.0f && Density < 0.0f))) + { + ++OutOutput.ZeroCrossingCount; + } + PreviousDensity = Density; + bHavePreviousDensity = true; + } + + if (OutOutput.FiniteSampleCount > 0) + { + OutOutput.MinDensity = MinDensity; + OutOutput.MaxDensity = MaxDensity; + OutOutput.MaxAbsDensity = MaxAbsDensity; + } + 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 density_slice.csv."); + OutOutput.Seconds = FPlatformTime::Seconds() - StartSeconds; + return false; + } + OutOutput.Status = TEXT("ok"); + OutOutput.Seconds = FPlatformTime::Seconds() - StartSeconds; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeDensitySlice] file=%s samples=%d finite=%d non_finite=%d " + "zero_crossings=%d saturated=%d min=%.9g max=%.9g max_abs=%.9g " + "origin=(%.3f,%.3f,%.3f) direction=(%.6f,%.6f,%.6f) step=%.6g"), + *Path, OutOutput.SampleCount, OutOutput.FiniteSampleCount, + OutOutput.NonFiniteSampleCount, OutOutput.ZeroCrossingCount, + OutOutput.SaturatedSampleCount, OutOutput.MinDensity, + OutOutput.MaxDensity, OutOutput.MaxAbsDensity, + Arguments.DensitySliceOrigin.X, Arguments.DensitySliceOrigin.Y, + Arguments.DensitySliceOrigin.Z, Arguments.DensitySliceDirection.X, + Arguments.DensitySliceDirection.Y, Arguments.DensitySliceDirection.Z, + Arguments.DensitySliceStep); + return true; +} + void WritePlayerDimensions(FExploreJsonWriter& Writer, const TCHAR* Key) { Writer.WriteObjectStart(Key); @@ -4459,7 +4821,7 @@ FString BuildExploreJson( TSharedRef Writer = TJsonWriterFactory>::Create(&Json); Writer->WriteObjectStart(); - Writer->WriteValue(TEXT("schema_version"), 6); + Writer->WriteValue(TEXT("schema_version"), 7); Writer->WriteValue(TEXT("tool"), TEXT("VoxelForgeExplore")); Writer->WriteValue(TEXT("read_only_generation"), true); @@ -4534,6 +4896,12 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("sample_stacks_interval_us"), static_cast(Arguments.SampleStacksIntervalUs)); Writer->WriteValue(TEXT("profile_lod"), Arguments.bProfileLod); + Writer->WriteValue(TEXT("density_slice_requested"), Arguments.bDensitySlice); + Writer->WriteValue(TEXT("render_target_override"), Arguments.bRenderTargetOverride); + if (Arguments.bRenderTargetOverride) + { + WriteJsonVector(*Writer, TEXT("render_target_voxels"), Arguments.RenderTarget, 1.0f); + } Writer->WriteValue(TEXT("op_bounds"), Arguments.bOpBounds); Writer->WriteValue(TEXT("budget_minutes"), static_cast(Arguments.BudgetMinutes)); Writer->WriteValue(TEXT("out_directory"), Arguments.OutDirectory); @@ -4549,6 +4917,27 @@ FString BuildExploreJson( Arguments.bBlockEarlyOut ? 1 : 0)); Writer->WriteObjectEnd(); + Writer->WriteObjectStart(TEXT("density_slice")); + Writer->WriteValue(TEXT("status"), Output.DensitySlice.Status); + Writer->WriteValue(TEXT("file"), Output.DensitySlice.FileName); + Writer->WriteValue(TEXT("refusal_or_error"), Output.DensitySlice.RefusalReason); + Writer->WriteValue(TEXT("sample_count"), Output.DensitySlice.SampleCount); + Writer->WriteValue(TEXT("finite_sample_count"), Output.DensitySlice.FiniteSampleCount); + Writer->WriteValue(TEXT("non_finite_sample_count"), Output.DensitySlice.NonFiniteSampleCount); + Writer->WriteValue(TEXT("zero_crossing_count"), Output.DensitySlice.ZeroCrossingCount); + Writer->WriteValue(TEXT("saturated_sample_count"), Output.DensitySlice.SaturatedSampleCount); + Writer->WriteValue(TEXT("min_density"), static_cast(Output.DensitySlice.MinDensity)); + Writer->WriteValue(TEXT("max_density"), static_cast(Output.DensitySlice.MaxDensity)); + Writer->WriteValue(TEXT("max_abs_density"), static_cast(Output.DensitySlice.MaxAbsDensity)); + Writer->WriteValue(TEXT("seconds"), Output.DensitySlice.Seconds); + if (Arguments.bDensitySlice) + { + WriteJsonVector(*Writer, TEXT("origin_voxels"), Arguments.DensitySliceOrigin, 1.0f); + WriteJsonVector(*Writer, TEXT("direction"), Arguments.DensitySliceDirection, 1.0f); + Writer->WriteValue(TEXT("step_voxels"), static_cast(Arguments.DensitySliceStep)); + } + Writer->WriteObjectEnd(); + Writer->WriteObjectStart(TEXT("summary")); const double SummaryMeshSeconds = Arguments.bExport ? Output.Export.MeshSeconds @@ -4615,6 +5004,13 @@ FString BuildExploreJson( Writer->WriteObjectStart(TEXT("tile_ledger")); Writer->WriteValue(TEXT("order"), TEXT("z_then_y_then_x")); Writer->WriteValue(TEXT("tile_cells"), CHUNK_SIZE); + int32 TileLod = 0; + for (int32 SampleStep = Arguments.ExportStep; SampleStep > 1; SampleStep >>= 1) + { + ++TileLod; + } + Writer->WriteValue(TEXT("sample_step"), Arguments.ExportStep); + Writer->WriteValue(TEXT("lod"), TileLod); Writer->WriteValue(TEXT("tile_count"), World.ExploreMeshTileCount); Writer->WriteValue(TEXT("completed_tiles"), World.ExploreMeshTilesCompleted); Writer->WriteArrayStart(TEXT("tiles")); @@ -4629,6 +5025,8 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("x"), TileX); Writer->WriteValue(TEXT("y"), TileY); Writer->WriteValue(TEXT("z"), TileZ); + Writer->WriteValue(TEXT("sample_step"), Arguments.ExportStep); + Writer->WriteValue(TEXT("lod"), TileLod); Writer->WriteValue(TEXT("triangle_count"), World.ExploreTileTriangleCounts[TileIndex]); Writer->WriteValue(TEXT("geometry_hash"), World.ExploreTileGeometryHashes[TileIndex]); Writer->WriteObjectEnd(); @@ -5750,7 +6148,26 @@ int32 RunExploreCase(const FString& Params, FString* OutJson) return 1; } + FExploreRunOutput Output; bool bRequestedModeFailed = false; + if (Arguments.bDensitySlice) + { + const double Start = FPlatformTime::Seconds(); + const bool bDensitySliceOk = !Budget.bTruncated + && RunDensitySlice(Arguments, World, Output.DensitySlice, Budget); + if (!bDensitySliceOk && !Budget.bTruncated) + { + bRequestedModeFailed = true; + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeExplore] density slice failed: %s"), + *Output.DensitySlice.RefusalReason); + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeExplore] density slice %.3fs (%s)"), + FPlatformTime::Seconds() - Start, + bDensitySliceOk ? TEXT("ok") + : (Budget.bTruncated ? TEXT("truncated") : TEXT("error"))); + } if (Arguments.bOpBounds) { const double Start = FPlatformTime::Seconds(); @@ -5837,7 +6254,6 @@ int32 RunExploreCase(const FString& Params, FString* OutJson) } } - FExploreRunOutput Output; if (Budget.ShouldStop(TEXT("setup"))) { UE_LOG(LogTemp, Warning,