From 383ec86c37d20bb3a7ff828edb190e18ece448c0 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Thu, 10 Sep 2026 14:46:29 +0200 Subject: [PATCH] perf(apply): stutter is NOT the game thread -- and the flat floor cannot be relaxed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the owner reported from playing 412a7ad. One is answered, one is a null result that Codex flagged itself rather than dressing up. THE FLATTENED TUNNEL FLOORS -- capability-safe, and worth almost nothing He has now flagged these twice: every tunnel carries a smooth flat strip running in and out through otherwise craggy rock. The idea was to make the floor CONDITIONAL -- assert it only where the natural rock fails walkability, leave the rock alone elsewhere. Cheaper and better looking, in theory. The floor is now skipped only when the exact no-floor field passes the same player-fit validator at foot offsets 0, -1, +1. Unknown or failed keeps the slab. natural-floor skips accepted 1,539 of 1,529,241 tests = 0.10% player-fit cells 20,153 -> 20,153 reachable cells 10,483 -> 10,483 arrival -> departure connected, both dihedral p50/p90 2.5429 / 33.8499 deg, IDENTICAL mesh time (their batch) 1.757135 -> 1.757140 s Capability preserved exactly. Visual complaint NOT solved, and no speed gained. ⭐ The useful part is what it PROVED by trying to go further. Every broader policy lost walkable space: analytic-only fit -217, reachable -195 tightened analytic fit -76, reachable -76 exact +/-4 foot fit -16, reachable -16 ⇒ The floor cannot be relaxed without changing the walkability contract or adding a route-aware global proof. That is a real constraint, now measured rather than assumed. Codex's own words, and it was right to lead with them: "the conservative floor change is capability-safe, but it does not visibly solve the flattened-strip complaint." THE STUTTER -- measured, and it is not where we thought Game-thread apply, meshed tiles, under -nullrhi: total apply p50 0.107 ms p95 0.153 ms max 0.273 ms stream upload/RMC p50 0.040 p95 0.088 max 0.124 component creation p50 0.019 p95 0.021 max 0.035 registration p50 0.010 p95 0.011 max 0.020 collision config p50 0.005 p95 0.007 max 0.019 A quarter of a millisecond at worst. The game-thread drain is NOT the hitch. ⇒ Remaining visible stutter is most likely render-thread/RHI work or asynchronous collision cooking behind RealtimeMeshComponent, which is off-limits for edits. Added anyway as a cheap safety valve: MaxMeshApplyMilliseconds, default 2 ms of drain budget per frame. Over 729 apply frames: drain p50/p95/max 0.111/0.182/0.313 ms, budget hits 0, at most 2 visible applies in a frame. It costs nothing today and caps a future burst. Request-to-ready, now timed after game-thread application: all p50 57.1 ms p95 432.9 ms max 919.4 ms -> 2.60 m at 6 m/s meshed p50 134.1 ms p95 457.4 ms max 919.4 ms ⚠️ My own verification: 1.197 / 1.131 / 1.118 s vs 1.088-1.102 s on 412a7ad. Mean +4.7% -- below the 17% noise floor, but slower in all three runs, and the validator runs 1.5M times to accept 1,539 skips. Geometry moved by 4 triangles (153,740 -> 153,744), which is allowed. If the next round cannot make that validator earn its place, it should come out. Build 0 errors. Peak RSS 1.67 GB export / 1.90 GB game. Nothing outside Plugins/VoxelForge; RealtimeMeshComponent untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb --- .../Private/VoxelCaveMorphology.cpp | 211 +++++++++++++++++- .../Private/VoxelDensityOpStack.cpp | 46 +++- .../Private/VoxelDensityProfile.cpp | 2 + Source/VoxelForge/Private/VoxelGenerator.cpp | 62 ++++- Source/VoxelForge/Private/VoxelWorld.cpp | 191 ++++++++++++++-- .../VoxelForge/Public/VoxelCaveMorphology.h | 18 +- .../VoxelForge/Public/VoxelDensityOpStack.h | 4 +- .../VoxelForge/Public/VoxelDensityProfile.h | 2 + Source/VoxelForge/Public/VoxelGenerator.h | 3 +- Source/VoxelForge/Public/VoxelSettings.h | 7 + Source/VoxelForge/Public/VoxelWorld.h | 5 +- 11 files changed, 508 insertions(+), 43 deletions(-) diff --git a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp index 45f2383..3fedaaa 100644 --- a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp +++ b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -35,6 +35,17 @@ #include "VoxelStrateTypes.h" namespace { + using FVFPlayerDensitySampler = FTunnelSupportFloorDensitySampler; + + bool VF_ValidatePlayerFitPose( + const FVoxelStrateMeasureSettings& Settings, + const FVector& CandidateFeetPoint, + float StrateTopZ, + float StrateBottomZ, + float BoundarySealThickness, + FVFPlayerDensitySampler SampleDensity, + FVector& OutPoint); + } #include "VoxelNoise.h" // Pure FBM used by the slab landing query #include "VoxelPassageGeometry.h" @@ -72,6 +83,173 @@ namespace return DX * DX + DY * DY + DZ * DZ; } + // A graph tunnel's rounded capsule is a candidate natural floor only in the part of the tube + // where the player's complete support footprint remains inside a sufficiently wide, + // sufficiently flat cross-section. This analytic prefilter only saves density probes; it is + // never the decision by itself. The exact no-floor field is passed through the same player-fit + // validator used by the capability measurement, and failure retains the old support slab. + bool VF_IsNaturalTunnelFloorWalkable( + const FCachedTunnel& Tunnel, + float WorldX, + float WorldY, + float FloorZ, + float SupportRadius, + int32 SegmentIndex, + const FTunnelSupportFloorDensitySampler* NaturalSampler, + float StrateTopZ, + float StrateBottomZ, + float BoundarySealThickness) + { + if (SegmentIndex < 0 + || SegmentIndex + 1 >= Tunnel.WorldControlPoints.Num() + || Tunnel.WorldControlRadii.Num() != Tunnel.WorldControlPoints.Num()) + { + return false; + } + + const FVector& Start = Tunnel.WorldControlPoints[SegmentIndex]; + const FVector& End = Tunnel.WorldControlPoints[SegmentIndex + 1]; + const float StartRadius = FMath::Abs(Tunnel.WorldControlRadii[SegmentIndex]); + const float EndRadius = FMath::Abs(Tunnel.WorldControlRadii[SegmentIndex + 1]); + const FVector2D Delta(End.X - Start.X, End.Y - Start.Y); + const float LengthSquared = Delta.SizeSquared(); + if (!(LengthSquared > KINDA_SMALL_NUMBER) + || !FMath::IsFinite(StartRadius) + || !FMath::IsFinite(EndRadius) + || !FMath::IsFinite(FloorZ) + || !FMath::IsFinite(SupportRadius)) + { + return false; + } + + const FVector2D Query(WorldX, WorldY); + const float T = FMath::Clamp( + FVector2D::DotProduct(Query - FVector2D(Start.X, Start.Y), Delta) + / LengthSquared, + 0.0f, 1.0f); + const FVector2D Closest = FVector2D(Start.X, Start.Y) + Delta * T; + const float CentrelineDistance = (Query - Closest).Size(); + const float TubeRadius = FMath::Lerp(StartRadius, EndRadius, T); + constexpr float CapsuleSafetyMargin = 0.25f; + // The first candidate was still too permissive: the analytic tube was walkable, but + // roughness outside the structural core could remove the last supporting voxels. Only + // the broad, nearly-centreline part of the widest tubes is a certificate strong enough to + // replace the authored floor; all marginal geometry keeps the slab. + constexpr float NaturalMinimumTubeRadius = 7.5f; + constexpr float NaturalCentrelineTolerance = 0.5f; + constexpr float NaturalWallMargin = 2.0f; + constexpr float NaturalMaximumAxialGradient = 0.12f; + const float PlayerRadius = VoxelPassageGeometry::PlayerRadiusVoxels; + const float FootprintRadius = PlayerRadius + CapsuleSafetyMargin; + if (TubeRadius < NaturalMinimumTubeRadius + || CentrelineDistance > NaturalCentrelineTolerance + || !(TubeRadius > 0.0f) + || CentrelineDistance + FootprintRadius + > TubeRadius - NaturalWallMargin + || SupportRadius + KINDA_SMALL_NUMBER < PlayerRadius) + { + return false; + } + + const float FarthestSupportRadius = CentrelineDistance + FootprintRadius; + const float CrossSectionHalfWidth = FMath::Sqrt(FMath::Max( + TubeRadius * TubeRadius + - FarthestSupportRadius * FarthestSupportRadius, + 0.0f)); + if (2.0f * CrossSectionHalfWidth + < VoxelPassageGeometry::PlayerHeightVoxels + + 2.0f * CapsuleSafetyMargin) + { + return false; + } + + // The round floor's steepest support-patch slope is attained at its outer edge. Keep it + // no steeper than the authored 15-degree tunnel contract, rather than using a generous + // engine walkability ceiling merely to make the optimization fire more often. + const float RadialDenominator = FMath::Sqrt(FMath::Max( + TubeRadius * TubeRadius + - FarthestSupportRadius * FarthestSupportRadius, + KINDA_SMALL_NUMBER)); + const float RadialGradient = FarthestSupportRadius / RadialDenominator; + const float AxialGradient = VoxelPassageGeometry::TunnelFloorGradient( + Start, StartRadius, End, EndRadius); + if (!FMath::IsFinite(RadialGradient) + || !FMath::IsFinite(AxialGradient) + || RadialGradient > VoxelPassageGeometry::WalkableTunnelMaxGradient + || AxialGradient > NaturalMaximumAxialGradient) + { + return false; + } + + // Reject bends and segment endpoints where the projected support plane changes abruptly. + // The flat-floor path remains responsible for those transition columns. + constexpr float ProbeDistance = 1.0f; + const FVector ProbePositions[] = { + FVector(WorldX - ProbeDistance, WorldY, 0.0f), + FVector(WorldX + ProbeDistance, WorldY, 0.0f), + FVector(WorldX, WorldY - ProbeDistance, 0.0f), + FVector(WorldX, WorldY + ProbeDistance, 0.0f) + }; + for (const FVector& Probe : ProbePositions) + { + float ProbeFloorZ = 0.0f; + float ProbeSupportRadius = 0.0f; + int32 ProbeSegmentIndex = INDEX_NONE; + if (!VoxelPassageGeometry::ProjectWalkableTunnelFloor( + Tunnel.WorldControlPoints, + Tunnel.WorldControlRadii, + Probe, + ProbeFloorZ, + ProbeSupportRadius, + &ProbeSegmentIndex) + || ProbeSegmentIndex != SegmentIndex + || FMath::Abs(ProbeFloorZ - FloorZ) + > VoxelPassageGeometry::WalkableTunnelMaxGradient + * ProbeDistance + CapsuleSafetyMargin) + { + return false; + } + } + + if (NaturalSampler == nullptr) + { + return false; + } + + FVoxelStrateMeasureSettings FitSettings; + const float MaxStepHeightVoxels = + FVoxelPlayerCapsuleConstants::MaxStepHeightMeters + / FVoxelPlayerCapsuleConstants::VoxelSizeMeters; + // The projected graph floor is the centreline's authored support level, but natural rock + // may leave a legal footing a little above or below it. Search a small fixed offset set so + // that the conditional path does not mistake a vertically displaced natural ledge for a + // failed walkability test. The validator still enforces the actual max-step and capsule + // clearance rules for every trial; no geometry is accepted by this search alone. + constexpr int32 FeetOffsetCount = 3; + const float FeetOffsets[FeetOffsetCount] = { + 0.0f, -1.0f, 1.0f + }; + for (int32 OffsetIndex = 0; OffsetIndex < FeetOffsetCount; ++OffsetIndex) + { + const FVector CandidateFeet( + WorldX, WorldY, + FloorZ + MaxStepHeightVoxels + 0.5f + FeetOffsets[OffsetIndex]); + FVector FitPoint = FVector::ZeroVector; + if (VF_ValidatePlayerFitPose( + FitSettings, + CandidateFeet, + StrateTopZ, + StrateBottomZ, + BoundarySealThickness, + *NaturalSampler, + FitPoint)) + { + return true; + } + } + return false; + } + } void FChunkSDFCache::Reset() @@ -903,8 +1081,6 @@ namespace return true; } - using FVFPlayerDensitySampler = TFunctionRef; - bool VF_GetPlayerFitInteriorBounds( const FVoxelStrateMeasureSettings& Settings, float StrateTopZ, @@ -5114,7 +5290,8 @@ bool VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( Tunnel.WorldControlRadii, Pos, FloorZ, - SupportRadius) + SupportRadius, + nullptr) || WorldZ > FloorZ + VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels || WorldZ < FloorZ - VoxelPassageGeometry::LandingFloorThicknessVoxels) { @@ -5150,7 +5327,11 @@ bool VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( void VoxelCaveMorphology::BuildTunnelSupportFloorColumn( float WorldX, float WorldY, const FChunkSDFCache& Cache, - FTunnelSupportFloorColumn& OutColumn) + FTunnelSupportFloorColumn& OutColumn, + const FTunnelSupportFloorDensitySampler* NaturalSampler, + float NaturalStrateTopZ, + float NaturalStrateBottomZ, + float NaturalBoundarySealThickness) { OutColumn.Reset(); @@ -5183,16 +5364,36 @@ void VoxelCaveMorphology::BuildTunnelSupportFloorColumn( const FVector ColumnPosition(WorldX, WorldY, 0.0f); float FloorZ = 0.0f; float SupportRadius = 0.0f; + int32 SegmentIndex = INDEX_NONE; if (!VoxelPassageGeometry::ProjectWalkableTunnelFloor( Tunnel.WorldControlPoints, Tunnel.WorldControlRadii, ColumnPosition, FloorZ, - SupportRadius)) + SupportRadius, + &SegmentIndex)) { continue; } + if (NaturalSampler != nullptr && VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelSupportFloorNaturalTests); + } + if (VF_IsNaturalTunnelFloorWalkable( + Tunnel, WorldX, WorldY, FloorZ, SupportRadius, SegmentIndex, + NaturalSampler, NaturalStrateTopZ, NaturalStrateBottomZ, + NaturalBoundarySealThickness)) + { + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelSupportFloorNaturalSkips); + } + continue; + } + float MinZ = FloorZ - VoxelPassageGeometry::LandingFloorThicknessVoxels; float MaxZ = FloorZ + VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels; diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 99f03a5..624e974 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -3591,8 +3591,10 @@ namespace { public: FRoomGraphSource(const FStrateGenerationParams& InP, int32 InSeed, - const UVoxelStrateManager* InManager) + const UVoxelStrateManager* InManager, + const UVoxelGenerator* InGenerator = nullptr) : P(InP), Seed(InSeed), SeedU((uint32)InSeed), Manager(InManager) + , Generator(InGenerator) , ManagerLifetimeId(InManager ? InManager->GetCacheLifetimeId() : 0) , ParamsFingerprint(FCrc::MemCrc32(&InP, sizeof(InP))) {} @@ -3791,6 +3793,37 @@ namespace return P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f; } + void BuildSupportColumn( + float WorldX, float WorldY, + const FChunkSDFCache& Cache, + FTunnelSupportFloorColumn& OutColumn) const + { + if (Generator == nullptr) + { + VoxelCaveMorphology::BuildTunnelSupportFloorColumn( + WorldX, WorldY, Cache, OutColumn); + return; + } + + // This is a synchronous, non-owning view of the exact field with only the graph + // support slab disabled. BuildTunnelSupportFloorColumn consumes it immediately and + // retains only the proven interval decision in the worker-local column cache. + const auto SampleWithoutTunnelSupportFloor = + [this](float X, float Y, float Z) -> float + { + return Generator->GetDensityWithParams( + X, Y, Z, P, ParamsFingerprint, LayoutVersion, + /*bApplyLegacyStructuralPosts=*/true, + /*bApplyTunnelSupportFloor=*/false); + }; + FTunnelSupportFloorDensitySampler NaturalSampler = + SampleWithoutTunnelSupportFloor; + VoxelCaveMorphology::BuildTunnelSupportFloorColumn( + WorldX, WorldY, Cache, OutColumn, &NaturalSampler, + P.StrateTopWorldZ, P.StrateBottomWorldZ, + P.BoundarySealThickness); + } + const FTunnelSupportFloorColumn* GetSupportColumn(const FVector& Position) const { FState& S = State(); @@ -3809,7 +3842,7 @@ namespace IX, IY, &Cache, S.SupportColumns.Clock, bNewColumn); if (bNewColumn) { - VoxelCaveMorphology::BuildTunnelSupportFloorColumn( + BuildSupportColumn( static_cast(Position.X), static_cast(Position.Y), Cache, @@ -3818,7 +3851,7 @@ namespace return Column; } - VoxelCaveMorphology::BuildTunnelSupportFloorColumn( + BuildSupportColumn( static_cast(Position.X), static_cast(Position.Y), GetCache(), S.FractionalSupportColumn); return &S.FractionalSupportColumn; @@ -6633,6 +6666,7 @@ namespace int32 Seed; uint32 SeedU; TWeakObjectPtr Manager; + const UVoxelGenerator* Generator = nullptr; uint64 ManagerLifetimeId = 0; uint32 ParamsFingerprint; uint32 LayoutVersion = 0; @@ -9408,7 +9442,8 @@ namespace VoxelDensityOps void BuildTunnelNetworkStack(FVoxelOpStack& OutStack, const FStrateGenerationParams& P, int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager, - bool bAppendStructuralPosts) + bool bAppendStructuralPosts, + const UVoxelGenerator* Generator) { // ⚠️ ÉTAPES A + B + C1 — LA PILE EST COMPLÈTE POUR CET ARCHÉTYPE. // Portés : échelle verticale, roc de base, warp, graphe de salles (+ pits + cheminées), @@ -9452,7 +9487,8 @@ namespace VoxelDensityOps // La source de salles est retenue par pointeur non possédant : les terrasses re-interrogent // son cache SDF en Z±1. Même motif que `FShaftFieldSource` → `FShaftLedgeMod`. - TUniquePtr RoomSource = MakeUnique(P, Seed, StrateManager); + TUniquePtr RoomSource = MakeUnique( + P, Seed, StrateManager, Generator); const FRoomGraphSource* RoomPtr = RoomSource.Get(); OutStack.Add(MakeConstantRockSource(P.BaseDensity)); diff --git a/Source/VoxelForge/Private/VoxelDensityProfile.cpp b/Source/VoxelForge/Private/VoxelDensityProfile.cpp index 388d7b8..89985c0 100644 --- a/Source/VoxelForge/Private/VoxelDensityProfile.cpp +++ b/Source/VoxelForge/Private/VoxelDensityProfile.cpp @@ -414,6 +414,8 @@ namespace VoxelDensityProfile case ECounter::TunnelSupportColumnCandidates:return TEXT("TunnelSupportColumnCandidates"); case ECounter::TunnelSupportFloorQueries:return TEXT("TunnelSupportFloorQueries"); case ECounter::TunnelSupportFloorChecks:return TEXT("TunnelSupportFloorChecks"); + case ECounter::TunnelSupportFloorNaturalTests:return TEXT("TunnelSupportFloorNaturalTests"); + case ECounter::TunnelSupportFloorNaturalSkips:return TEXT("TunnelSupportFloorNaturalSkips"); 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 2d22d4d..5cc115b 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -507,7 +507,11 @@ namespace static void BuildTunnelSupportColumnsForChunk( FChunkSDFCache& Cache, - const FIntVector& ChunkCoord); + const FIntVector& ChunkCoord, + const UVoxelGenerator* Generator, + const FStrateGenerationParams& Params, + uint32 ParamsFingerprint, + uint32 LayoutVersion); static const FTunnelSupportFloorColumn* FindTunnelSupportColumn( const FChunkSDFCache& Cache, @@ -603,6 +607,7 @@ namespace } void PrepareTunnelCoreCache( + const UVoxelGenerator& Generator, const UVoxelStrateManager& Manager, const FIntVector& ChunkCoord, const FStrateGenerationParams& Params, @@ -638,7 +643,9 @@ namespace ChunkMaxX + Expansion, ChunkMaxY + Expansion, Params, static_cast(Manager.GetWorldSeed()), StrateIndex, nullptr); - BuildTunnelSupportColumnsForChunk(OutState.Cache, ChunkCoord); + BuildTunnelSupportColumnsForChunk( + OutState.Cache, ChunkCoord, &Generator, Params, + ParamsFingerprint, LayoutVersion); OutState.OwnerId = OwnerId; OutState.ManagerLifetimeId = ManagerLifetimeId; OutState.Chunk = ChunkCoord; @@ -649,7 +656,11 @@ namespace static void BuildTunnelSupportColumnsForChunk( FChunkSDFCache& Cache, - const FIntVector& ChunkCoord) + const FIntVector& ChunkCoord, + const UVoxelGenerator* Generator, + const FStrateGenerationParams& Params, + uint32 ParamsFingerprint, + uint32 LayoutVersion) { const int32 MinX = ChunkCoord.X * CHUNK_SIZE - 1; const int32 MinY = ChunkCoord.Y * CHUNK_SIZE - 1; @@ -661,6 +672,25 @@ namespace Cache.SupportColumnEntries.Init(INDEX_NONE, Cells * Cells); Cache.SupportColumns.Reset(); + // The conditional floor is tested against the exact density pipeline with only the + // graph support slab disabled. The sampler is synchronous and lives only for this cache + // build; the immutable cache stores the resulting interval decision, never the callback. + const auto SampleWithoutTunnelSupportFloor = + [Generator, &Params, ParamsFingerprint, LayoutVersion]( + float X, float Y, float Z) -> float + { + return Generator != nullptr + ? Generator->GetDensityWithParams( + X, Y, Z, Params, ParamsFingerprint, LayoutVersion, + /*bApplyLegacyStructuralPosts=*/true, + /*bApplyTunnelSupportFloor=*/false) + : 0.0f; + }; + FTunnelSupportFloorDensitySampler NaturalSamplerRef = + SampleWithoutTunnelSupportFloor; + const FTunnelSupportFloorDensitySampler* NaturalSampler = + Generator != nullptr ? &NaturalSamplerRef : nullptr; + // Empty columns are represented only by INDEX_NONE in the slot map, so // each stored column retains the inline bands for actual graph tubes. for (int32 Y = 0; Y < Cells; ++Y) @@ -672,7 +702,11 @@ namespace static_cast(MinX + X), static_cast(MinY + Y), Cache, - Column); + Column, + NaturalSampler, + Params.StrateTopWorldZ, + Params.StrateBottomWorldZ, + Params.BoundarySealThickness); if (Column.Intervals.Num() == 0) { continue; @@ -736,7 +770,8 @@ namespace */ bool VF_BuildOpStackForChunk(ECaveGeneratorType Type, FVoxelStackParamRefs& Refs, int32 Seed, float SpineRadius, const UVoxelStrateManager* SM, - FVoxelOpStack& OutStack, FVoxelOpContext& OutCtx) + FVoxelOpStack& OutStack, FVoxelOpContext& OutCtx, + const UVoxelGenerator* Generator = nullptr) { switch (Type) { @@ -793,7 +828,9 @@ namespace if (!Refs.Tunnel) { return false; } OutCtx.StrateTopWorldZ = Refs.Tunnel->StrateTopWorldZ; OutCtx.StrateBottomWorldZ = Refs.Tunnel->StrateBottomWorldZ; - VoxelDensityOps::BuildTunnelNetworkStack(OutStack, *Refs.Tunnel, Seed, SpineRadius, SM); + VoxelDensityOps::BuildTunnelNetworkStack( + OutStack, *Refs.Tunnel, Seed, SpineRadius, SM, + /*bAppendStructuralPosts=*/true, Generator); return true; default: @@ -1259,7 +1296,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co CP_UseOpStack = VF_BuildOpStackForChunk( CP_GenType, Refs, Seed, OriginSpineRadius, - StrateManager, CP_OpStack, OpCtx); + StrateManager, CP_OpStack, OpCtx, this); // Le test appelle PrepareChunk, pas la production : c'est exactement la divergence // qui rend un opérateur vert en test et faux en jeu. Les sept `PrepareChunk` @@ -1293,7 +1330,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co if (bCanPrepareNativeTunnelCore) { PrepareTunnelCoreCache( - *StrateManager, ChunkCoord, CP_Tunnel, CP_TunnelFP, + *this, *StrateManager, ChunkCoord, CP_Tunnel, CP_TunnelFP, LayoutVersion, ManagerLifetimeId, DensityCacheOwnerId, GTunnelCoreCache); } else @@ -1647,7 +1684,8 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float WorldZ, const FStrateGenerationParams& Params, uint32 ParamsFingerprint, uint32 LayoutVersion, - bool bApplyLegacyStructuralPosts) const + bool bApplyLegacyStructuralPosts, + bool bApplyTunnelSupportFloor) const { //========================================================================= // STRATE DENSITY FUNCTION (Morphology Pipeline) @@ -2713,8 +2751,8 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo // the graph's own tunnel floor/core are structural route geometry. Direct callers of // GetDensityWithParams retain this historical tail; GetDensityAt skips it and applies the // same predicates once in its common MC-space post section below both generation paths. - const bool bTunnelSupportFloor = - VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( + const bool bTunnelSupportFloor = bApplyTunnelSupportFloor + && VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( WorldX, WorldY, WorldZ, SDFCache); if (bTunnelSupportFloor) { @@ -4753,7 +4791,7 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in FVoxelOpStack TileStack; if (!VF_BuildOpStackForChunk(CaveType, Refs, Seed, OriginSpineRadius, - StrateManager, TileStack, OpCtx)) + StrateManager, TileStack, OpCtx, this)) { // Strate dégénérée ou archétype non porté : `GetDensityAt` retomberait sur le `switch`, // donc la pile ne décrit pas ce que le mesher verra. Aucun verdict. diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index 079be10..63ac5fe 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -1172,11 +1172,22 @@ void AVoxelWorld::ProcessPendingChunks() // This prevents stutters from applying too many meshes in one frame. // Budget limits how many VISIBLE mesh applies we do per frame (GPU upload cost). // Empty meshes and stale results are free to drain — don't count them. - const int32 MaxApplies = Settings ? Settings->MaxMeshAppliesPerFrame : 4; + const bool bProfileApply = GVoxelForgeProfileTileGeneration != 0; + const uint64 DrainStartCycles = FPlatformTime::Cycles64(); + const int32 MaxApplies = FMath::Max( + Settings ? Settings->MaxMeshAppliesPerFrame : 4, 1); + const float ApplyBudgetMilliseconds = Settings + ? FMath::Max(Settings->MaxMeshApplyMilliseconds, 0.0f) + : 2.0f; + const double ApplyBudgetSeconds = + static_cast(ApplyBudgetMilliseconds) * 0.001; int32 MeshesApplied = 0; + int32 ResultsDrained = 0; + bool bTimeBudgetHit = false; FChunkResult DequeuedChunk; while (ProcessQueue.Dequeue(DequeuedChunk)) { + ++ResultsDrained; PendingTiles.Remove(DequeuedChunk.Tile); // A worker can observe shutdown after entering the mesher. Do not turn that partial @@ -1190,13 +1201,37 @@ void AVoxelWorld::ProcessPendingChunks() // Only a real (visible) upload counts against the per-frame budget — stale/empty drain free. if (ApplyTileResult(DequeuedChunk)) { - if (++MeshesApplied >= MaxApplies) - { - break; - } + ++MeshesApplied; + } + + // Time-box the entire game-thread drain, not only visible uploads. Empty releases and + // stale-result cleanup normally cost nothing, but a pooled component teardown or a + // backend callback can still be the one result that produces the player's hitch. + if (ApplyBudgetSeconds > 0.0 + && FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - DrainStartCycles) + >= ApplyBudgetSeconds) + { + bTimeBudgetHit = true; + break; + } + if (MeshesApplied >= MaxApplies) + { + break; } } + if (bProfileApply && ResultsDrained > 0) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeApplyFrame] drained=%d meshes=%d elapsed=%.6f " + "max_meshes=%d budget_ms=%.3f budget_hit=%d"), + ResultsDrained, + MeshesApplied, + FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - DrainStartCycles), + MaxApplies, + ApplyBudgetMilliseconds, + bTimeBudgetHit ? 1 : 0); + } } // Game-thread apply for one gen result. Shared by ProcessPendingChunks (async drain) and @@ -1209,13 +1244,21 @@ bool AVoxelWorld::ApplyTileResult(FChunkResult& Result) return false; } - if (GVoxelForgeProfileTileGeneration != 0 && Result.RequestStartCycles != 0) + // "Ready" is deliberately emitted after the game-thread apply, not when the worker result is + // dequeued. This makes request-to-ready include component setup, stream/buffer submission, + // and collision configuration—the point at which the chunk is actually visible/collidable. + const bool bResultEmpty = Result.bEmpty || !Result.Streams; + const auto LogTileReady = [&]() { - UE_LOG(LogTemp, Display, - TEXT("[VoxelForgeTileReady] level=%d empty=%d request_to_ready=%.6f"), - Result.Tile.Level, Result.bEmpty || !Result.Streams ? 1 : 0, - FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - Result.RequestStartCycles)); - } + if (GVoxelForgeProfileTileGeneration != 0 && Result.RequestStartCycles != 0) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeTileReady] level=%d empty=%d request_to_ready=%.6f"), + Result.Tile.Level, bResultEmpty ? 1 : 0, + FPlatformTime::ToSeconds64( + FPlatformTime::Cycles64() - Result.RequestStartCycles)); + } + }; // Mark the tile loaded (even if empty — so we don't re-submit it). LoadedTiles.Add(Result.Tile); @@ -1240,17 +1283,35 @@ bool AVoxelWorld::ApplyTileResult(FChunkResult& Result) // bande déplacée hors de la tuile, ou skip cellule-plus-haute-que-la-bande après un // changement de strate (LoadTile). L'ancien composant doit tomber, sinon sa vieille // géométrie (l'autre strate !) reste affichée. Première gen vide : Find rate, no-op. + const uint64 EmptyReleaseStartCycles = GVoxelForgeProfileTileGeneration != 0 + ? FPlatformTime::Cycles64() : 0; + bool bReleasedComponent = false; if (URealtimeMeshComponent** OldComp = TileComponents.Find(Result.Tile)) { - if (*OldComp) { ReleaseTileComponent(*OldComp); } + if (*OldComp) + { + ReleaseTileComponent(*OldComp); + bReleasedComponent = true; + } TileComponents.Remove(Result.Tile); } + if (GVoxelForgeProfileTileGeneration != 0) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeApplyProfile] level=%d empty=1 component_release=%d total=%.6f"), + Result.Tile.Level, + bReleasedComponent ? 1 : 0, + FPlatformTime::ToSeconds64( + FPlatformTime::Cycles64() - EmptyReleaseStartCycles)); + } + LogTileReady(); return false; } // Apply mesh (GPU upload). The vertex/index buffers were already built (T1.f, on the worker for // the async path or inline for the sync carve path); the game thread only uploads them here. ApplyMeshToTile(Result); + LogTileReady(); return true; } @@ -2291,8 +2352,15 @@ static FRealtimeMeshSectionGroupKey VoxelTileGroupKey() //============================================================================= // Recycler les composants de tuile au lieu de les détruire/recréer. -URealtimeMeshComponent* AVoxelWorld::AcquireTileComponent() +URealtimeMeshComponent* AVoxelWorld::AcquireTileComponent( + double* OutCreationSeconds, + double* OutRegistrationSeconds, + bool* bOutCreated) { + if (OutCreationSeconds != nullptr) { *OutCreationSeconds = 0.0; } + if (OutRegistrationSeconds != nullptr) { *OutRegistrationSeconds = 0.0; } + if (bOutCreated != nullptr) { *bOutCreated = false; } + // Reuse a parked component when one is available — skips NewObject + RegisterComponent // (and the full proxy teardown/GC of a destroy) during fast travel & regen bursts. while (TileComponentPool.Num() > 0) @@ -2305,6 +2373,10 @@ URealtimeMeshComponent* AVoxelWorld::AcquireTileComponent() } } + const bool bMeasureComponentPhases = OutCreationSeconds != nullptr + || OutRegistrationSeconds != nullptr; + const uint64 CreationStartCycles = bMeasureComponentPhases + ? FPlatformTime::Cycles64() : 0; URealtimeMeshComponent* MeshComp = NewObject(this); // Generated once, never moves → Static so RMC's cached static draw path + VSM shadow // caching apply (see the root SetMobility note in BeginPlay). Must be set before register. @@ -2313,8 +2385,21 @@ URealtimeMeshComponent* AVoxelWorld::AcquireTileComponent() MeshComp->SetMobility(EComponentMobility::Static); MeshComp->SetGenerateOverlapEvents(false); // chunks use raycasts, not overlaps MeshComp->SetCanEverAffectNavigation(false); + if (OutCreationSeconds != nullptr) + { + *OutCreationSeconds = FPlatformTime::ToSeconds64( + FPlatformTime::Cycles64() - CreationStartCycles); + } + const uint64 RegistrationStartCycles = bMeasureComponentPhases + ? FPlatformTime::Cycles64() : 0; MeshComp->RegisterComponent(); MeshComp->AttachToComponent(GetRootComponent(), FAttachmentTransformRules::KeepRelativeTransform); + if (OutRegistrationSeconds != nullptr) + { + *OutRegistrationSeconds = FPlatformTime::ToSeconds64( + FPlatformTime::Cycles64() - RegistrationStartCycles); + } + if (bOutCreated != nullptr) { *bOutCreated = true; } return MeshComp; } @@ -2358,6 +2443,9 @@ void AVoxelWorld::ApplyMeshToTile(FChunkResult& Result) { TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ApplyMeshToChunk); + const bool bProfileApply = GVoxelForgeProfileTileGeneration != 0; + const uint64 ApplyStartCycles = bProfileApply ? FPlatformTime::Cycles64() : 0; + // Streams are pre-built on the worker (T1.f) and guaranteed non-empty by the caller // (ProcessPendingChunks skips empty tiles). This path is game-thread-CHEAP: material lookup + // component get/create + the upload + per-section config. No per-vertex work here. @@ -2414,6 +2502,9 @@ void AVoxelWorld::ApplyMeshToTile(FChunkResult& Result) if (UMaterialInstanceDynamic* MID = GetOrCreateTerrainMID(GroundMaterial)) { GroundMaterial = MID; } if (UMaterialInstanceDynamic* MID = GetOrCreateTerrainMID(CeilingMaterial)) { CeilingMaterial = MID; } } + const double MaterialSeconds = bProfileApply + ? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - ApplyStartCycles) + : 0.0; // The geometry stream set was built on the worker (BuildTileStreamSet, T1.f); we just upload it. // Vertices are world-space; the component sits at the actor origin. @@ -2422,11 +2513,22 @@ void AVoxelWorld::ApplyMeshToTile(FChunkResult& Result) // cheap on the game thread (no batching needed). Collision + content are level-0 only. // T2.c: the component comes from the pool when one is parked (see AcquireTileComponent). URealtimeMeshComponent* MeshComp = TileComponents.FindRef(Tile); + const bool bComponentAlreadyPresent = MeshComp != nullptr; + const uint64 ComponentStartCycles = bProfileApply ? FPlatformTime::Cycles64() : 0; + double ComponentCreationSeconds = 0.0; + double ComponentRegistrationSeconds = 0.0; + bool bComponentCreated = false; if (!MeshComp) { - MeshComp = AcquireTileComponent(); + MeshComp = AcquireTileComponent( + bProfileApply ? &ComponentCreationSeconds : nullptr, + bProfileApply ? &ComponentRegistrationSeconds : nullptr, + bProfileApply ? &bComponentCreated : nullptr); TileComponents.Add(Tile, MeshComp); } + const double ComponentSeconds = bProfileApply + ? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - ComponentStartCycles) + : 0.0; // §9.4 RENDER-SKIP — a tile only a CollisionOnly anchor wants (not the player clipmap) cooks its // collision below but is hidden (no draw / VSM). Set every apply (overrides the pool's default- @@ -2438,8 +2540,22 @@ void AVoxelWorld::ApplyMeshToTile(FChunkResult& Result) // calling it unconditionally (as before) orphaned one mesh object per re-apply to the GC. // The RemoveSectionGroup below does the actual geometry clearing on reuse. URealtimeMeshSimple* RTMesh = MeshComp->GetRealtimeMeshAs(); + const uint64 MeshInitStartCycles = bProfileApply ? FPlatformTime::Cycles64() : 0; if (!RTMesh) { RTMesh = MeshComp->InitializeRealtimeMesh(); } - if (!RTMesh) { return; } + const double MeshInitSeconds = bProfileApply + ? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - MeshInitStartCycles) + : 0.0; + if (!RTMesh) + { + if (bProfileApply) + { + UE_LOG(LogTemp, Warning, + TEXT("[VoxelForgeApplyProfile] level=%d empty=0 failed_mesh_init=1 total=%.6f"), + Tile.Level, + FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - ApplyStartCycles)); + } + return; + } // Shadow casting: far (level >= 2) tiles never cast; the sky-cap SECTION never casts either // — otherwise the high rock ceiling shadows the entire terrain below it. F17: shadow is now // PER SECTION, so a mixed tile keeps its ground shadow while its cap stays shadowless. @@ -2447,17 +2563,33 @@ void AVoxelWorld::ApplyMeshToTile(FChunkResult& Result) MeshComp->SetCastShadow(bCastShadow); const FRealtimeMeshSectionGroupKey GroupKey = VoxelTileGroupKey(); + const uint64 RemoveGroupStartCycles = bProfileApply ? FPlatformTime::Cycles64() : 0; RTMesh->RemoveSectionGroup(GroupKey); // clear old geometry on re-mesh // (no-op on a fresh/pooled mesh) + const double RemoveGroupSeconds = bProfileApply + ? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - RemoveGroupStartCycles) + : 0.0; + const uint64 MaterialSlotStartCycles = bProfileApply ? FPlatformTime::Cycles64() : 0; RTMesh->SetupMaterialSlot(0, "Main", GroundMaterial); RTMesh->SetupMaterialSlot(1, "SkyCap", CeilingMaterial); + const double MaterialSlotSeconds = bProfileApply + ? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - MaterialSlotStartCycles) + : 0.0; + // RMC owns both the CPU stream-to-buffer conversion and the render-resource upload behind + // CreateSectionGroup. Keep this as one phase: it is the exact plugin/RMC boundary we can + // budget without modifying RealtimeMeshComponent. + const uint64 StreamUploadStartCycles = bProfileApply ? FPlatformTime::Cycles64() : 0; RTMesh->CreateSectionGroup(GroupKey, MoveTemp(Streams)); + const double StreamUploadSeconds = bProfileApply + ? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - StreamUploadStartCycles) + : 0.0; // RMC casts shadows PER SECTION (FRealtimeMeshSectionConfig::bCastsShadow, default true) — the // component-level UPrimitiveComponent::CastShadow is NOT honored by the RMC proxy, so the real // shadow lever is the section flag. RMC auto-created one section per non-empty polygroup above // (default config already maps material slot = polygroup index); only config sections that // exist — the bHas* flags come from the worker. Collision at level 0 only (T1.c), both groups. + const uint64 CollisionConfigStartCycles = bProfileApply ? FPlatformTime::Cycles64() : 0; if (bHasGroundTris) { FRealtimeMeshSectionConfig GroundConfig(0); @@ -2474,6 +2606,35 @@ void AVoxelWorld::ApplyMeshToTile(FChunkResult& Result) FRealtimeMeshSectionKey::CreateForPolyGroup(GroupKey, 1), CapConfig, /*bShouldCreateCollision*/ bLevel0); } + const double CollisionConfigSeconds = bProfileApply + ? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - CollisionConfigStartCycles) + : 0.0; + + if (bProfileApply) + { + const double TotalSeconds = FPlatformTime::ToSeconds64( + FPlatformTime::Cycles64() - ApplyStartCycles); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeApplyProfile] level=%d empty=0 created=%d pooled=%d " + "material=%.6f component=%.6f component_create=%.6f " + "component_register=%.6f mesh_init=%.6f remove_group=%.6f " + "material_slots=%.6f stream_upload=%.6f create_section_group=%.6f " + "collision_config=%.6f total=%.6f"), + Tile.Level, + bComponentCreated ? 1 : 0, + (!bComponentAlreadyPresent && !bComponentCreated) ? 1 : 0, + MaterialSeconds, + ComponentSeconds, + ComponentCreationSeconds, + ComponentRegistrationSeconds, + MeshInitSeconds, + RemoveGroupSeconds, + MaterialSlotSeconds, + StreamUploadSeconds, + StreamUploadSeconds, + CollisionConfigSeconds, + TotalSeconds); + } // Water is no longer spawned per tile — it's a single player-following ocean plane (UpdateWater, // driven from Tick), so it renders at every LOD and to the horizon with no per-tile gaps. diff --git a/Source/VoxelForge/Public/VoxelCaveMorphology.h b/Source/VoxelForge/Public/VoxelCaveMorphology.h index f67476b..5363623 100644 --- a/Source/VoxelForge/Public/VoxelCaveMorphology.h +++ b/Source/VoxelForge/Public/VoxelCaveMorphology.h @@ -698,6 +698,12 @@ struct FTunnelSupportFloorColumn } }; +// A non-owning probe used only while a support-column is being prepared. The caller supplies the +// exact MC-facing density field with the graph support slab disabled; the morphology layer then +// runs the same player-fit predicate used by the capability measurement. A missing sampler is a +// deliberate safe fallback: retain the authored support band rather than making an unproven skip. +using FTunnelSupportFloorDensitySampler = TFunctionRef; + // A pre-baked pit shaft — position and dimensions resolved during BuildChunkCache. // // Pits are included in the main SDF evaluation (SmoothMin alongside rooms and @@ -903,12 +909,18 @@ namespace VoxelCaveMorphology const FChunkSDFCache& Cache ); - // Build/query the same support-floor predicate at column granularity. This changes only - // where the deterministic projection is computed; it does not change the floor interval. + // Build/query the same support-floor predicate at column granularity. When NaturalSampler is + // supplied, a projected band is omitted only if the exact field with that graph slab disabled + // accepts a player-fit pose at this column. Without that proof the old interval is retained. + // The sampler is synchronous and non-owning; it is never retained by the cache. VOXELFORGE_API void BuildTunnelSupportFloorColumn( float WorldX, float WorldY, const FChunkSDFCache& Cache, - FTunnelSupportFloorColumn& OutColumn + FTunnelSupportFloorColumn& OutColumn, + const FTunnelSupportFloorDensitySampler* NaturalSampler = nullptr, + float NaturalStrateTopZ = 0.0f, + float NaturalStrateBottomZ = 0.0f, + float NaturalBoundarySealThickness = 0.0f ); VOXELFORGE_API bool IsTunnelSupportFloorColumnZ( diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index f8a2c1e..7e4f77f 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -39,6 +39,7 @@ #include "VoxelStrateTypes.h" // FMazeGenerationParams #include "VoxelHeightOp.h" // IVoxelBiomeField — BuildSurfaceStack takes ownership of one +class UVoxelGenerator; class UVoxelStrateManager; struct FVoxelOpStackRecipe; struct FVoxelStrateArchetypeParams; @@ -473,7 +474,8 @@ namespace VoxelDensityOps const FStrateGenerationParams& P, int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager, - bool bAppendStructuralPosts = true); + bool bAppendStructuralPosts = true, + const UVoxelGenerator* Generator = nullptr); /** * DIAGNOSTIC — la ventilation par CLASSE DE PRIMITIVE de la dernière propagation d'intervalle diff --git a/Source/VoxelForge/Public/VoxelDensityProfile.h b/Source/VoxelForge/Public/VoxelDensityProfile.h index f4a8510..6dc6eb2 100644 --- a/Source/VoxelForge/Public/VoxelDensityProfile.h +++ b/Source/VoxelForge/Public/VoxelDensityProfile.h @@ -98,6 +98,8 @@ namespace VoxelDensityProfile TunnelSupportColumnCandidates, TunnelSupportFloorQueries, TunnelSupportFloorChecks, + TunnelSupportFloorNaturalTests, + TunnelSupportFloorNaturalSkips, PassageCandidates, PassageEvaluated, MesherBlockTests, diff --git a/Source/VoxelForge/Public/VoxelGenerator.h b/Source/VoxelForge/Public/VoxelGenerator.h index e27be55..73b938d 100644 --- a/Source/VoxelForge/Public/VoxelGenerator.h +++ b/Source/VoxelForge/Public/VoxelGenerator.h @@ -167,7 +167,8 @@ public: float GetDensityWithParams(float WorldX, float WorldY, float WorldZ, const FStrateGenerationParams& Params, uint32 ParamsFingerprint, uint32 LayoutVersion, - bool bApplyLegacyStructuralPosts = true) const; + bool bApplyLegacyStructuralPosts = true, + bool bApplyTunnelSupportFloor = true) const; /** * Densité pour une strate Slab (FlatPlain / CrystalChamber). diff --git a/Source/VoxelForge/Public/VoxelSettings.h b/Source/VoxelForge/Public/VoxelSettings.h index 1563828..f11ffbc 100644 --- a/Source/VoxelForge/Public/VoxelSettings.h +++ b/Source/VoxelForge/Public/VoxelSettings.h @@ -53,6 +53,13 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming") int32 MaxMeshAppliesPerFrame = 4; + // Wall-clock cap for the complete game-thread result drain, including component teardown and + // collision configuration. The count cap remains the second guard. A zero value disables this + // cap; the default keeps one burst of chunk application below a small frame-time budget without + // changing worker resolution or mesh quality. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "0.0")) + float MaxMeshApplyMilliseconds = 2.0f; + // Max tile teardowns (component destroy + content-actor Destroy) per frame. A fast traversal // culls a whole shell of tiles at once; doing every destroy in one frame is a game-thread spike // ("stuff torn down behind you"). This spreads it. The drain auto-scales up if the backlog grows diff --git a/Source/VoxelForge/Public/VoxelWorld.h b/Source/VoxelForge/Public/VoxelWorld.h index 06a160d..75700c4 100644 --- a/Source/VoxelForge/Public/VoxelWorld.h +++ b/Source/VoxelForge/Public/VoxelWorld.h @@ -206,7 +206,10 @@ public: static constexpr int32 MaxPooledTileComponents = 256; /** Pop a pooled tile component (made visible again) or create + register a fresh one. */ - URealtimeMeshComponent* AcquireTileComponent(); + URealtimeMeshComponent* AcquireTileComponent( + double* OutCreationSeconds = nullptr, + double* OutRegistrationSeconds = nullptr, + bool* bOutCreated = nullptr); /** Park a tile component in the pool (strip geometry/collision, hide) — or destroy it * for real when the pool is full. */