diff --git a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp index e0bd713..55579df 100644 --- a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp +++ b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -28,6 +28,7 @@ // this chunk are kept for the per-voxel loop, so hot-path cost stays low. #include "VoxelCaveMorphology.h" +#include "VoxelDensityProfile.h" #include "VoxelDensityPrimitives.h" #include "VoxelStrateMeasure.h" #include "VoxelTypes.h" // Pour VOXEL_NOISE_SCALE, SmoothStep01 @@ -3643,6 +3644,16 @@ float VoxelCaveMorphology::EvaluateSDFCached( float NearestRoomRawSDF = FLT_MAX; int32 NearestIdx = -1; + if (VoxelDensityProfile::IsEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::CaveRoomCandidates, + static_cast(Cache.Rooms.Num())); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::CaveTunnelCandidates, + static_cast(Cache.Tunnels.Num())); + } + //========================================================================= // Room SDFs //========================================================================= @@ -3653,6 +3664,11 @@ float VoxelCaveMorphology::EvaluateSDFCached( // --- DISTANCE CULL --- const float DistSq = FVector::DistSquared(Pos, Room.Center); if (DistSq > Room.CullRadiusSq) continue; + if (VoxelDensityProfile::IsEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::CaveRoomEvaluated); + } // --- SHAPE (pre-baked in BuildChunkCache — no per-voxel hash roll / trig) --- float RoomSDF; @@ -3721,6 +3737,11 @@ float VoxelCaveMorphology::EvaluateSDFCached( // --- BOUNDING SPHERE CULL --- const float DistSq = FVector::DistSquared(Pos, Tunnel.BoundCenter); if (DistSq > Tunnel.BoundRadiusSq) continue; + if (VoxelDensityProfile::IsEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::CaveTunnelEvaluated); + } float TunnelSDF; @@ -3830,12 +3851,19 @@ float VoxelCaveMorphology::EvaluateTunnelCoreSDF( return MinSDF; } -float VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF( +FTunnelCoreWorldEvaluation VoxelCaveMorphology::EvaluateTunnelCoreWorld( float WorldX, float WorldY, float WorldZ, const FChunkSDFCache& Cache) { const FVector Pos(WorldX, WorldY, WorldZ); - float MinSDF = FLT_MAX; + FTunnelCoreWorldEvaluation Result; + + if (VoxelDensityProfile::IsEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelCoreCandidates, + static_cast(Cache.Tunnels.Num())); + } for (const FCachedTunnel& Tunnel : Cache.Tunnels) { @@ -3850,6 +3878,11 @@ float VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF( { continue; } + if (VoxelDensityProfile::IsEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelCoreEvaluated); + } const TArray& ControlPoints = bHasWorldChain ? Tunnel.WorldControlPoints : Tunnel.ControlPoints; @@ -3859,9 +3892,20 @@ float VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF( { float FloorZ = 0.0f; float SupportRadius = 0.0f; - if (VoxelPassageGeometry::ProjectWalkableTunnelFloor( - ControlPoints, ControlRadii, Pos, FloorZ, SupportRadius) + const bool bProjectedFloor = VoxelPassageGeometry::ProjectWalkableTunnelFloor( + ControlPoints, ControlRadii, Pos, FloorZ, SupportRadius); + const bool bWithinWorldBounds = Tunnel.WorldBoundRadiusSq <= 0.0f + || FVector::DistSquared(Pos, Tunnel.WorldBoundCenter) + <= Tunnel.WorldBoundRadiusSq; + if (bProjectedFloor && bWithinWorldBounds && WorldZ <= FloorZ + + VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels + && WorldZ >= FloorZ + - VoxelPassageGeometry::LandingFloorThicknessVoxels) + { + Result.bSupportFloor = true; + } + if (bProjectedFloor && WorldZ <= FloorZ + VoxelPassageGeometry::WalkableTunnelFloorAirClearanceVoxels) { // The finite floor slab owns the bottom of this tunnel. Another intersecting @@ -3877,8 +3921,8 @@ float VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF( SegmentIndex + 1 < ControlPoints.Num(); ++SegmentIndex) { - MinSDF = FMath::Min( - MinSDF, + Result.SDF = FMath::Min( + Result.SDF, VoxelSDF::TaperedCapsule( Pos, ControlPoints[SegmentIndex], @@ -3889,28 +3933,35 @@ float VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF( } else if (Tunnel.bHasMidpoint) { - MinSDF = FMath::Min( - MinSDF, + Result.SDF = FMath::Min( + Result.SDF, VoxelSDF::TaperedCapsule( Pos, Tunnel.EndpointA, Tunnel.Midpoint, Tunnel.RadiusA, Tunnel.RadiusMid)); - MinSDF = FMath::Min( - MinSDF, + Result.SDF = FMath::Min( + Result.SDF, VoxelSDF::TaperedCapsule( Pos, Tunnel.Midpoint, Tunnel.EndpointB, Tunnel.RadiusMid, Tunnel.RadiusB)); } else { - MinSDF = FMath::Min( - MinSDF, + Result.SDF = FMath::Min( + Result.SDF, VoxelSDF::TaperedCapsule( Pos, Tunnel.EndpointA, Tunnel.EndpointB, Tunnel.RadiusA, Tunnel.RadiusB)); } } - return MinSDF; + return Result; +} + +float VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF( + float WorldX, float WorldY, float WorldZ, + const FChunkSDFCache& Cache) +{ + return EvaluateTunnelCoreWorld(WorldX, WorldY, WorldZ, Cache).SDF; } bool VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( @@ -3918,6 +3969,13 @@ bool VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( const FChunkSDFCache& Cache) { const FVector Pos(WorldX, WorldY, WorldZ); + + if (VoxelDensityProfile::IsEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelCoreCandidates, + static_cast(Cache.Tunnels.Num())); + } for (const FCachedTunnel& Tunnel : Cache.Tunnels) { if (Tunnel.WorldControlPoints.Num() < 2 @@ -3944,6 +4002,11 @@ bool VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( || FVector::DistSquared(Pos, Tunnel.WorldBoundCenter) <= Tunnel.WorldBoundRadiusSq) { + if (VoxelDensityProfile::IsEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelCoreEvaluated); + } return true; } } diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index f3c8856..7540ade 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -340,7 +340,6 @@ namespace EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} - void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override { if (!(Strength > 0.0f)) { return; } @@ -1816,7 +1815,9 @@ namespace { if (const UVoxelStrateManager* LiveManager = Manager.Get()) { - LiveManager->ApplyPassageModifier(InOut.Density, X, Y, Z, Base, Seal); + // Landing/tunnel support is a shared MC-space post after disturbances. Calling the + // legacy full modifier here repeats those same writers before the shared post. + LiveManager->ApplyPassageCarvingOnly(InOut.Density, X, Y, Z, Base, Seal); } // Keep the source and op-stack operation order identical: the origin room is carved // before the boundary seal, then its support floor is reasserted after the passage @@ -2780,6 +2781,85 @@ namespace uint32 Salt; }; + // The mesher's coarse samples are sparse enough that one sample can land in a different + // exact 32-voxel chunk every time. Keeping one SDF cache inside every exact-chunk op-stack + // therefore turns L4 into thousands of BuildChunkCache calls. These worker-local entries + // share the immutable room graph cache by its complete deterministic input key. A wider + // region is safe because BuildChunkCache's collect/store construction is window-invariant; + // the key still includes every value that can change the graph or its terrain-op rolls. + constexpr int32 RoomGraphCacheSlotCount = 128; + + struct FRoomGraphCacheEntry + { + bool bValid = false; + uint32 Seed = 0; + int32 StrateIndex = INT32_MIN; + uint32 ParamsFingerprint = 0xFFFFFFFFu; + uint32 LayoutVersion = 0xFFFFFFFFu; + uint64 ManagerLifetimeId = 0; + const TArray* TerrainOps = nullptr; + int32 RegionMinX = 0; + int32 RegionMinY = 0; + int32 RegionSize = 0; + FChunkSDFCache Cache; + }; + + static thread_local FRoomGraphCacheEntry GRoomGraphCache[RoomGraphCacheSlotCount]; + + FORCEINLINE int32 RoomGraphCacheSlot( + uint32 Seed, int32 StrateIndex, uint32 ParamsFingerprint, uint32 LayoutVersion, + uint64 ManagerLifetimeId, int32 RegionMinX, int32 RegionMinY, int32 RegionSize) + { + uint32 Hash = Seed * 0x9E3779B9u; + Hash ^= static_cast(StrateIndex) * 0x85EBCA6Bu; + Hash ^= ParamsFingerprint * 0xC2B2AE35u; + Hash ^= LayoutVersion * 0x27D4EB2Fu; + Hash ^= static_cast(ManagerLifetimeId) + ^ static_cast(ManagerLifetimeId >> 32); + Hash ^= static_cast(RegionMinX) * 0x165667B1u; + Hash ^= static_cast(RegionMinY) * 0xD3A2646Cu; + Hash ^= static_cast(RegionSize) * 0xFD7046C5u; + Hash ^= Hash >> 16; + return static_cast(Hash & (RoomGraphCacheSlotCount - 1)); + } + + FRoomGraphCacheEntry* FindRoomGraphCacheEntry( + uint32 Seed, int32 StrateIndex, uint32 ParamsFingerprint, uint32 LayoutVersion, + uint64 ManagerLifetimeId, const TArray* TerrainOps, + int32 RegionMinX, int32 RegionMinY, int32 RegionSize) + { + const int32 StartSlot = RoomGraphCacheSlot( + Seed, StrateIndex, ParamsFingerprint, LayoutVersion, ManagerLifetimeId, + RegionMinX, RegionMinY, RegionSize); + FRoomGraphCacheEntry* ReuseEntry = nullptr; + for (int32 Probe = 0; Probe < RoomGraphCacheSlotCount; ++Probe) + { + FRoomGraphCacheEntry& Candidate = GRoomGraphCache[ + (StartSlot + Probe) & (RoomGraphCacheSlotCount - 1)]; + if (!Candidate.bValid) + { + return ReuseEntry != nullptr ? ReuseEntry : &Candidate; + } + if (Candidate.Seed == Seed + && Candidate.StrateIndex == StrateIndex + && Candidate.ParamsFingerprint == ParamsFingerprint + && Candidate.LayoutVersion == LayoutVersion + && Candidate.ManagerLifetimeId == ManagerLifetimeId + && Candidate.TerrainOps == TerrainOps + && Candidate.RegionMinX == RegionMinX + && Candidate.RegionMinY == RegionMinY + && Candidate.RegionSize == RegionSize) + { + return &Candidate; + } + if (ReuseEntry == nullptr && Candidate.ManagerLifetimeId != ManagerLifetimeId) + { + ReuseEntry = &Candidate; + } + } + return ReuseEntry != nullptr ? ReuseEntry : &GRoomGraphCache[StartSlot]; + } + //========================================================================= // RÔLE 1 — SOURCE : GRAPHE DE SALLES / ROOM GRAPH (TunnelNetwork) //========================================================================= @@ -2833,29 +2913,17 @@ namespace // Dans l'original tout cela vit dans des `thread_local` d'une seule fonction de 1080 lignes ; // ici la source les possède et les expose. // - // ⚠️ `static` (donc PARTAGÉ ENTRE INSTANCES), pas un membre : c'est exactement ce que fait - // l'original, et le contrôle 3 du test en DÉPEND — deux piles construites côte à côte se - // partagent ce cache, et c'est l'empreinte de params dans la clé (pas une copie par pile) qui - // les empêche de se servir mutuellement leurs salles. En faire un membre ferait passer ce - // contrôle pour de mauvaises raisons. - // - // ⚠️ MÊME MOTIF QUE `FOverhangShelfMod` ← `FSurfaceColumnSource` et `FShaftLedgeMod` ← - // `FShaftFieldSource` : un opérateur possède l'état, les autres le lisent par pointeur non - // possédant remis à la construction. C'est un motif ÉTABLI dans ce fichier, pas une invention. - // - // Per-worker state, deliberately `static` (shared between instances) because that is what the - // original does and what the test's stale-cache check rests on. Detail ops read it through a - // non-owning pointer, the same way the surface and shaft ports already do. + // The state belongs to this source instance. The outer tunnel cache retains the immutable + // op stack, so retaining the source's prepared SDF window with that stack avoids rebuilding + // the room graph every time a coarse tile switches between cached chunk keys. It remains + // worker-local because each op stack lives in the thread-local density cache. struct FState { + // Fallback only for the invalid-owner path. Normal evaluations point at the worker-local + // shared cache bank below, so switching exact chunk op-stacks does not rebuild the room + // graph merely because the stack object changed. FChunkSDFCache Cache; - float CachedSMinX = 1.0f, CachedSMaxX = -1.0f; // invalide au départ (min > max) - float CachedSMinY = 0.0f, CachedSMaxY = 0.0f; - int32 CachedStrate = INT32_MIN; - uint32 CachedSeed = 0; - uint32 CachedFingerprint = 0xFFFFFFFFu; - uint32 CachedLayout = 0xFFFFFFFFu; - uint64 CachedManagerLifetimeId = 0; + const FChunkSDFCache* ActiveCache = nullptr; /** La salle de SDF minimal pour le dernier voxel évalué. -1 = aucune. */ int32 NearestRoom = -1; @@ -2874,14 +2942,23 @@ namespace bool bLocalParamsValid = false; }; - static FState& State() + FState& State() const { - thread_local FState S; - return S; + // The prepared SDF data lives in the worker-local keyed cache bank above. This state + // is only scratch for the current EvalSample: Eval resets NearestRoom and the local + // parameter memo before its detail ops consume them. A single TLS slot is therefore + // safe even when this worker switches between immutable source instances, and avoids + // a map lookup on every sample. A mutable member is not safe because stacks can be + // shared by concurrent readers during generation. + static thread_local FState Scratch; + return Scratch; } /** Le cache que la source vient de bâtir/servir pour ce voxel. Lu par les colonnes (4d). */ - const FChunkSDFCache& GetCache() const { return State().Cache; } + const FChunkSDFCache& GetCache() const + { + return State().ActiveCache != nullptr ? *State().ActiveCache : State().Cache; + } /** L'index de la salle la plus proche pour le dernier voxel évalué. -1 = aucune. * Lu par les arches, les dômes, le pincement et le biais de sol. */ @@ -2896,7 +2973,7 @@ namespace { const FVector& Position = State().LastWorldPosition; return VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF( - Position.X, Position.Y, Position.Z, State().Cache); + Position.X, Position.Y, Position.Z, GetCache()); } /** @@ -2933,9 +3010,10 @@ namespace if (!S.bLocalParamsValid) { S.LocalParams = P; - if (S.NearestRoom >= 0 && S.Cache.Rooms.IsValidIndex(S.NearestRoom)) + const FChunkSDFCache& Cache = GetCache(); + if (S.NearestRoom >= 0 && Cache.Rooms.IsValidIndex(S.NearestRoom)) { - const FCachedRoom& NR = S.Cache.Rooms[S.NearestRoom]; + const FCachedRoom& NR = Cache.Rooms[S.NearestRoom]; if (NR.RoomOp) { // N'écrit que les champs propres au type de l'op ; tout le reste garde la @@ -2964,7 +3042,7 @@ namespace */ float ProbeSdfUnwarped(float X, float Y, float Z) const { - return VoxelCaveMorphology::EvaluateSDFCached(X, Y, Z, State().Cache, P.SDFBlendRadius); + return VoxelCaveMorphology::EvaluateSDFCached(X, Y, Z, GetCache(), P.SDFBlendRadius); } /** Le Z « effectif » : `VerticalScale` étire le monde AVANT le bruit. Pure fonction de Z et @@ -3002,11 +3080,16 @@ namespace if (ManagerLifetimeId != 0 && LiveManager == nullptr) { S.Cache = FChunkSDFCache(); + S.ActiveCache = &S.Cache; InOut.Sdf = FLT_MAX; return; } - if (!(P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f)) { return; } // Sdf reste FLT_MAX + if (!(P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f)) + { + S.ActiveCache = &S.Cache; + return; // Sdf reste FLT_MAX + } const float EffectiveZ = EffZ(WorldZ); @@ -3078,51 +3161,78 @@ namespace StrateIdx = SI_Index; } - const bool bNeedRebuild = - StrateIdx != S.CachedStrate || SeedU != S.CachedSeed || - ParamsFingerprint != S.CachedFingerprint || LayoutVersion != S.CachedLayout || - ManagerLifetimeId != S.CachedManagerLifetimeId || - WarpedX < S.CachedSMinX || WarpedX > S.CachedSMaxX || - WarpedY < S.CachedSMinY || WarpedY > S.CachedSMaxY; + // A coarse LOD samples a new exact 32-voxel chunk at nearly every point. The graph is + // window-invariant, so group those samples into a larger deterministic XY region. The + // step is supplied by the mesher through VoxelGenLOD TLS; ordinary point queries keep + // the original one-chunk window. The cache key retains the exact params/strate/op-pool + // identity, so no blended chunk can borrow another chunk's room graph. + const int32 SampleStep = FMath::Max(VoxelGenLOD::SampleStep, 1); + const int32 RegionChunks = SampleStep <= 1 + ? 1 + : SampleStep >= 32 + ? 4 + : FMath::Clamp((SampleStep + 3) / 4, 1, 4); + const int32 RegionSize = CHUNK_SIZE * RegionChunks; + const int32 RegionCellX = FMath::FloorToInt(WorldX / (float)RegionSize); + const int32 RegionCellY = FMath::FloorToInt(WorldY / (float)RegionSize); + const int32 RegionMinX = RegionCellX * RegionSize; + const int32 RegionMinY = RegionCellY * RegionSize; - if (bNeedRebuild) + const TArray* TerrainOps = nullptr; + if (LiveManager) { - const int32 CacheChunkX = FMath::FloorToInt(WorldX / (float)CHUNK_SIZE); - const int32 CacheChunkY = FMath::FloorToInt(WorldY / (float)CHUNK_SIZE); - const float ChunkMinX = CacheChunkX * (float)CHUNK_SIZE; - const float ChunkMinY = CacheChunkY * (float)CHUNK_SIZE; - const float ChunkMaxX = ChunkMinX + (float)CHUNK_SIZE; - const float ChunkMaxY = ChunkMinY + (float)CHUNK_SIZE; - const float Expansion = P.CaveWarpStrength + 2.0f; - - const float SMinX = ChunkMinX - Expansion; - const float SMinY = ChunkMinY - Expansion; - const float SMaxX = ChunkMaxX + Expansion; - const float SMaxY = ChunkMaxY + Expansion; - - const TArray* TerrainOps = nullptr; - if (LiveManager) - { - const int32 ChunkZ = FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE); - UVoxelStrateDefinition* Def = LiveManager->GetStrateForChunk( - FIntVector(CacheChunkX, CacheChunkY, ChunkZ)); - if (Def) { TerrainOps = &Def->TerrainOperations; } - } - - VoxelCaveMorphology::BuildChunkCache( - S.Cache, SMinX, SMinY, SMaxX, SMaxY, P, SeedU, StrateIdx, TerrainOps); - - S.CachedSMinX = SMinX; S.CachedSMaxX = SMaxX; - S.CachedSMinY = SMinY; S.CachedSMaxY = SMaxY; - S.CachedStrate = StrateIdx; - S.CachedSeed = SeedU; - S.CachedFingerprint = ParamsFingerprint; - S.CachedLayout = LayoutVersion; - S.CachedManagerLifetimeId = ManagerLifetimeId; + const int32 ChunkX = FMath::FloorToInt(WorldX / (float)CHUNK_SIZE); + const int32 ChunkY = FMath::FloorToInt(WorldY / (float)CHUNK_SIZE); + const int32 ChunkZ = FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE); + UVoxelStrateDefinition* Def = LiveManager->GetStrateForChunk( + FIntVector(ChunkX, ChunkY, ChunkZ)); + if (Def) { TerrainOps = &Def->TerrainOperations; } } + FRoomGraphCacheEntry* SharedCache = FindRoomGraphCacheEntry( + SeedU, StrateIdx, ParamsFingerprint, LayoutVersion, ManagerLifetimeId, + TerrainOps, RegionMinX, RegionMinY, RegionSize); + const bool bCacheMatches = SharedCache->bValid + && SharedCache->Seed == SeedU + && SharedCache->StrateIndex == StrateIdx + && SharedCache->ParamsFingerprint == ParamsFingerprint + && SharedCache->LayoutVersion == LayoutVersion + && SharedCache->ManagerLifetimeId == ManagerLifetimeId + && SharedCache->TerrainOps == TerrainOps + && SharedCache->RegionMinX == RegionMinX + && SharedCache->RegionMinY == RegionMinY + && SharedCache->RegionSize == RegionSize; + if (!bCacheMatches) + { + if (VoxelDensityProfile::IsEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::SdfCacheBuild); + } + SharedCache->bValid = false; + SharedCache->Seed = SeedU; + SharedCache->StrateIndex = StrateIdx; + SharedCache->ParamsFingerprint = ParamsFingerprint; + SharedCache->LayoutVersion = LayoutVersion; + SharedCache->ManagerLifetimeId = ManagerLifetimeId; + SharedCache->TerrainOps = TerrainOps; + SharedCache->RegionMinX = RegionMinX; + SharedCache->RegionMinY = RegionMinY; + SharedCache->RegionSize = RegionSize; + const float Expansion = P.CaveWarpStrength + 2.0f; + VoxelCaveMorphology::BuildChunkCache( + SharedCache->Cache, + (float)RegionMinX - Expansion, + (float)RegionMinY - Expansion, + (float)(RegionMinX + RegionSize) + Expansion, + (float)(RegionMinY + RegionSize) + Expansion, + P, SeedU, StrateIdx, TerrainOps); + SharedCache->bValid = true; + } + S.ActiveCache = &SharedCache->Cache; + float CaveSDF = VoxelCaveMorphology::EvaluateSDFCached( - WarpedX, WarpedY, WarpedZ, S.Cache, P.SDFBlendRadius, &S.NearestRoom); + WarpedX, WarpedY, WarpedZ, GetCache(), P.SDFBlendRadius, &S.NearestRoom); //--------------------------------------------------------------- // PITS & CHEMINÉES — coordonnées RÉELLES, SmoothMin dans le même canal SDF @@ -3132,7 +3242,7 @@ namespace // mais à des coordonnées NON warpées. Sous un modèle de frames il aurait fallu les sortir // du frame tout en gardant le canal — exprimable, mais tordu. Dans un opérateur unique la // difficulté disparaît : le warp est une variable locale, pas un contexte hérité. - for (const FCachedPit& Pit : S.Cache.Pits) + for (const FCachedPit& Pit : GetCache().Pits) { const float DZ = WorldZ - Pit.TopZ; if (DZ >= Pit.BlendK) { continue; } @@ -3160,7 +3270,7 @@ namespace CaveSDF = VoxelSDF::SmoothMin(CaveSDF, PitSDF, Pit.BlendK); } - for (const FCachedChimney& Chim : S.Cache.Chimneys) + for (const FCachedChimney& Chim : GetCache().Chimneys) { const float DZ = WorldZ - Chim.BottomZ; if (-DZ >= Chim.BlendK) { continue; } @@ -3641,7 +3751,7 @@ namespace { const FVector& Position = State().LastWorldPosition; return VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( - Position.X, Position.Y, Position.Z, State().Cache); + Position.X, Position.Y, Position.Z, GetCache()); } private: @@ -3820,6 +3930,7 @@ namespace EVoxelOpChannelMask ChannelWrites() const override { return VoxelOpChannels::Density; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} + const TCHAR* DebugName() const override { return TEXT("CaveRoughnessMod"); } void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override { @@ -4017,6 +4128,7 @@ namespace EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} + const TCHAR* DebugName() const override { return TEXT("CaveTerraceMod"); } void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override { @@ -4127,6 +4239,7 @@ namespace EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} + const TCHAR* DebugName() const override { return TEXT("LayerLineMod"); } void Eval(float, float, float WorldZ, FVoxelOpSample& InOut) const override { @@ -4216,6 +4329,7 @@ namespace EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} + const TCHAR* DebugName() const override { return TEXT("RibbingMod"); } void Eval(float, float, float WorldZ, FVoxelOpSample& InOut) const override { @@ -4300,6 +4414,7 @@ namespace EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} + const TCHAR* DebugName() const override { return TEXT("CaveOverhangMod"); } void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override { @@ -4401,6 +4516,7 @@ namespace EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} + const TCHAR* DebugName() const override { return TEXT("CaveCliffMod"); } void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override { @@ -4476,6 +4592,7 @@ namespace EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} + const TCHAR* DebugName() const override { return TEXT("ScallopMod"); } void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override { @@ -4568,6 +4685,7 @@ namespace EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} + const TCHAR* DebugName() const override { return TEXT("CaveArchMod"); } void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override { @@ -4685,6 +4803,7 @@ namespace EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} + const TCHAR* DebugName() const override { return TEXT("RoomColumnMod"); } void Eval(float WorldX, float WorldY, float, FVoxelOpSample& InOut) const override { @@ -4749,6 +4868,7 @@ namespace EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} + const TCHAR* DebugName() const override { return TEXT("DomeMod"); } void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override { @@ -4857,6 +4977,7 @@ namespace EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} + const TCHAR* DebugName() const override { return TEXT("PinchMod"); } void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override { @@ -4977,6 +5098,7 @@ namespace EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} + const TCHAR* DebugName() const override { return TEXT("FloorBiasMod"); } void Eval(float, float, float WorldZ, FVoxelOpSample& InOut) const override { @@ -5815,10 +5937,9 @@ namespace VoxelDensityOps OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ, P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager, false); - OutStack.Add(MakeUnique(RoomPtr, P.BaseDensity)); - OutStack.Add(MakeUnique( - RoomPtr, P.BaseDensity, - VoxelPassageGeometry::CaveTunnelAirCoreInsetVoxels)); + // The generator's shared MC post evaluates the prepared tunnel-core cache after + // disturbances. Keeping these pre-disturbance graph scans here duplicated both the + // support and air queries for every voxel; the feature remains in that shared post. OutStack.Add(MakeUnique(P.BaseDensity)); } } diff --git a/Source/VoxelForge/Private/VoxelDensityProfile.cpp b/Source/VoxelForge/Private/VoxelDensityProfile.cpp new file mode 100644 index 0000000..727df71 --- /dev/null +++ b/Source/VoxelForge/Private/VoxelDensityProfile.cpp @@ -0,0 +1,195 @@ +#include "VoxelDensityProfile.h" + +namespace VoxelDensityProfile +{ + namespace + { + std::atomic GEnabled(false); + std::atomic GCalls[BucketCount]; + std::atomic GCycles[BucketCount]; + std::atomic GCounters[CounterCount]; + + int32 ToIndex(EBucket Bucket) + { + const int32 Index = static_cast(Bucket); + return (Index >= 0 && Index < BucketCount) + ? Index + : static_cast(EBucket::OtherOp); + } + } + + void SetEnabled(bool bEnabled) + { + GEnabled.store(bEnabled, std::memory_order_relaxed); + } + + bool IsEnabled() + { + return GEnabled.load(std::memory_order_relaxed); + } + + void Reset() + { + for (int32 Index = 0; Index < BucketCount; ++Index) + { + GCalls[Index].store(0, std::memory_order_relaxed); + GCycles[Index].store(0, std::memory_order_relaxed); + } + for (int32 Index = 0; Index < CounterCount; ++Index) + { + GCounters[Index].store(0, std::memory_order_relaxed); + } + } + + FSnapshot Snapshot() + { + FSnapshot Result; + for (int32 Index = 0; Index < BucketCount; ++Index) + { + Result.Calls[Index] = GCalls[Index].load(std::memory_order_relaxed); + Result.Cycles[Index] = GCycles[Index].load(std::memory_order_relaxed); + } + for (int32 Index = 0; Index < CounterCount; ++Index) + { + Result.Counters[Index] = GCounters[Index].load(std::memory_order_relaxed); + } + return Result; + } + + void AddCounter(ECounter Counter, uint64 Amount) + { + const int32 Index = static_cast(Counter); + if (Index >= 0 && Index < CounterCount) + { + GCounters[Index].fetch_add(Amount, std::memory_order_relaxed); + } + } + + const TCHAR* CounterName(ECounter Counter) + { + switch (Counter) + { + case ECounter::TunnelCacheLookup: return TEXT("TunnelCacheLookup"); + case ECounter::TunnelCacheHit: return TEXT("TunnelCacheHit"); + case ECounter::TunnelCacheMiss: return TEXT("TunnelCacheMiss"); + case ECounter::SdfCacheBuild: return TEXT("SdfCacheBuild"); + case ECounter::CaveRoomCandidates:return TEXT("CaveRoomCandidates"); + case ECounter::CaveRoomEvaluated: return TEXT("CaveRoomEvaluated"); + case ECounter::CaveTunnelCandidates:return TEXT("CaveTunnelCandidates"); + case ECounter::CaveTunnelEvaluated:return TEXT("CaveTunnelEvaluated"); + case ECounter::TunnelCoreCandidates:return TEXT("TunnelCoreCandidates"); + case ECounter::TunnelCoreEvaluated:return TEXT("TunnelCoreEvaluated"); + case ECounter::PassageCandidates: return TEXT("PassageCandidates"); + case ECounter::PassageEvaluated: return TEXT("PassageEvaluated"); + case ECounter::Count: break; + } + return TEXT("Unknown"); + } + + EBucket BucketFromName(const TCHAR* Name) + { + if (Name == nullptr) { return EBucket::OtherOp; } + + struct FNameBucket + { + const TCHAR* Name; + EBucket Bucket; + }; + static const FNameBucket Names[] = + { + { TEXT("ApplyDisturbances"), EBucket::ApplyDisturbances }, + { TEXT("PassageModifier"), EBucket::PassageModifier }, + { TEXT("PassageLandingAir"), EBucket::PassageLandingAir }, + { TEXT("PassageLandingFloor"), EBucket::PassageLandingFloor }, + { TEXT("PassageLandingRoomFloor"), EBucket::PassageLandingRoomFloor }, + { TEXT("PassageTunnelAir"), EBucket::PassageTunnelAir }, + { TEXT("PassageStructuralPosts"), EBucket::PassageStructuralPosts }, + { TEXT("TunnelCoreSupport"), EBucket::TunnelCoreSupport }, + { TEXT("TunnelCoreSdf"), EBucket::TunnelCoreSdf }, + { TEXT("TunnelCorePosts"), EBucket::TunnelCorePosts }, + { TEXT("StructuralTail"), EBucket::StructuralTail }, + { TEXT("RoomGraphSource"), EBucket::RoomGraphSource }, + { TEXT("SdfCarve"), EBucket::SdfCarve }, + { TEXT("SdfConvertOp"), EBucket::SdfCarve }, + { TEXT("CaveRoughnessMod"), EBucket::CaveRoughnessMod }, + { TEXT("CaveTerraceMod"), EBucket::CaveTerraceMod }, + { TEXT("LayerLineMod"), EBucket::LayerLineMod }, + { TEXT("RibbingMod"), EBucket::RibbingMod }, + { TEXT("CaveOverhangMod"), EBucket::CaveOverhangMod }, + { TEXT("CaveCliffMod"), EBucket::CaveCliffMod }, + { TEXT("ScallopMod"), EBucket::ScallopMod }, + { TEXT("CaveArchMod"), EBucket::CaveArchMod }, + { TEXT("RoomColumnMod"), EBucket::RoomColumnMod }, + { TEXT("DomeMod"), EBucket::DomeMod }, + { TEXT("PinchMod"), EBucket::PinchMod }, + { TEXT("FloorBiasMod"), EBucket::FloorBiasMod }, + { TEXT("WormFieldSource"), EBucket::WormFieldSource }, + { TEXT("OriginSpineOp"), EBucket::OriginSpineOp }, + { TEXT("BoundarySealOp"), EBucket::BoundarySealOp }, + { TEXT("PassageCarveOp"), EBucket::PassageCarveOp }, + { TEXT("CaveTunnelFloorOp"), EBucket::CaveTunnelFloorOp }, + { TEXT("CaveTunnelAirOp"), EBucket::CaveTunnelAirOp }, + { TEXT("XYEdgeSealOp"), EBucket::XYEdgeSealOp }, + }; + for (const FNameBucket& Entry : Names) + { + if (FCString::Strcmp(Name, Entry.Name) == 0) + { + return Entry.Bucket; + } + } + return EBucket::OtherOp; + } + + const TCHAR* BucketName(EBucket Bucket) + { + switch (Bucket) + { + case EBucket::GetDensityAt: return TEXT("GetDensityAt"); + case EBucket::ApplyDisturbances:return TEXT("ApplyDisturbances"); + case EBucket::PassageModifier: return TEXT("PassageModifier"); + case EBucket::PassageLandingAir:return TEXT("PassageLandingAir"); + case EBucket::PassageLandingFloor:return TEXT("PassageLandingFloor"); + case EBucket::PassageLandingRoomFloor:return TEXT("PassageLandingRoomFloor"); + case EBucket::PassageTunnelAir:return TEXT("PassageTunnelAir"); + case EBucket::PassageStructuralPosts:return TEXT("PassageStructuralPosts"); + case EBucket::TunnelCoreSupport:return TEXT("TunnelCoreSupport"); + case EBucket::TunnelCoreSdf: return TEXT("TunnelCoreSdf"); + case EBucket::TunnelCorePosts: return TEXT("TunnelCorePosts"); + case EBucket::StructuralTail: return TEXT("StructuralTail"); + case EBucket::RoomGraphSource: return TEXT("RoomGraphSource"); + case EBucket::SdfCarve: return TEXT("SdfCarve"); + case EBucket::CaveRoughnessMod: return TEXT("CaveRoughnessMod"); + case EBucket::CaveTerraceMod: return TEXT("CaveTerraceMod"); + case EBucket::LayerLineMod: return TEXT("LayerLineMod"); + case EBucket::RibbingMod: return TEXT("RibbingMod"); + case EBucket::CaveOverhangMod: return TEXT("CaveOverhangMod"); + case EBucket::CaveCliffMod: return TEXT("CaveCliffMod"); + case EBucket::ScallopMod: return TEXT("ScallopMod"); + case EBucket::CaveArchMod: return TEXT("CaveArchMod"); + case EBucket::RoomColumnMod: return TEXT("RoomColumnMod"); + case EBucket::DomeMod: return TEXT("DomeMod"); + case EBucket::PinchMod: return TEXT("PinchMod"); + case EBucket::FloorBiasMod: return TEXT("FloorBiasMod"); + case EBucket::WormFieldSource: return TEXT("WormFieldSource"); + case EBucket::OriginSpineOp: return TEXT("OriginSpineOp"); + case EBucket::BoundarySealOp: return TEXT("BoundarySealOp"); + case EBucket::PassageCarveOp: return TEXT("PassageCarveOp"); + case EBucket::CaveTunnelFloorOp:return TEXT("CaveTunnelFloorOp"); + case EBucket::CaveTunnelAirOp: return TEXT("CaveTunnelAirOp"); + case EBucket::XYEdgeSealOp: return TEXT("XYEdgeSealOp"); + case EBucket::OtherOp: return TEXT("OtherOp"); + case EBucket::Count: break; + } + return TEXT("Unknown"); + } + + FScopedTimer::~FScopedTimer() + { + if (!bActive) { return; } + const int32 Index = ToIndex(Bucket); + GCalls[Index].fetch_add(1, std::memory_order_relaxed); + GCycles[Index].fetch_add(FPlatformTime::Cycles64() - StartCycles, + std::memory_order_relaxed); + } +} diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index a850439..4501647 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -17,6 +17,7 @@ #include "VoxelDensityOpStack.h" // OPSTACK Phase 1: the opt-in per-strate operator stack #include "VoxelHeightOp.h" // IVoxelBiomeField — the adapter below implements it #include "VoxelStats.h" +#include "VoxelDensityProfile.h" #if WITH_EDITOR #include "VoxelStrateComposer.h" @@ -174,6 +175,7 @@ static thread_local FSurfaceColumnCache GSurfColCache; // T2.b — per-thread octave bias for the tile being meshed (see VoxelGenerator.h). // 0 = full quality; set by the mesher per tile from Step + Settings->LODOctaveDrop. thread_local int32 VoxelGenLOD::OctaveBias = 0; +thread_local int32 VoxelGenLOD::SampleStep = 1; // NOTE (T2.a): the fBm/Ridged bodies moved to VoxelNoise.h, where octaves are evaluated // 4-wide via SSE (Perlin3D_x4). These thin wrappers keep every call site unchanged. They @@ -273,6 +275,8 @@ static FORCEINLINE void ApplyOriginLandingFloor(float& Density, static void ApplyDisturbances(float& MC, float X, float Y, float Z, const FStrateDisturbanceParams& D, uint32 Seed, bool bProtectVerticalShaftAir) { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::ApplyDisturbances); const float InnerTop = D.StrateTopWorldZ - D.BoundarySealThickness; const float InnerBot = D.StrateBottomWorldZ + D.BoundarySealThickness; if (Z <= InnerBot || Z >= InnerTop) return; @@ -487,7 +491,12 @@ namespace // result. Native TunnelNetwork/Underwater states are immutable after construction; the active // pointer below lets the hot sample path read the stored stack and tunnel-core cache without // moving them on every voxel. - constexpr int32 TunnelDensityCacheSlotCount = 128; + // The largest production coarse tile can touch thousands of exact chunk keys once its + // gradient halo is included. A 128-slot direct map repeatedly evicts nearby keys (the LOD + // probe measured 1,734 misses for only 6,859 samples at L4), rebuilding the room/tunnel cache + // in the hot loop. Keep the same deterministic key and open-addressed lookup, but size the + // bounded table for the measured L4 working set rather than the old 128-slot guess. + constexpr int32 TunnelDensityCacheSlotCount = 4096; struct FTunnelNetworkDensityCacheEntry { @@ -728,6 +737,8 @@ void UVoxelGenerator::InitializeSettings(const UVoxelSettings* Settings) float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) const { + VoxelDensityProfile::FScopedTimer DensityProfileTimer( + VoxelDensityProfile::EBucket::GetDensityAt); // ── STRATE SYSTEM ── // Query per-chunk params from the manager so each strate has different caves. float Result; @@ -827,20 +838,62 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co FVoxelOpStack* ActiveOpStack = &CP_OpStack; FTunnelCoreCacheState* ActiveTunnelCoreCache = >unnelCoreCache; bool bLoadedTunnelDensityCache = false; + FTunnelNetworkDensityCacheEntry* TunnelDensityCacheEntryForWrite = nullptr; + + // The table is open-addressed rather than one-slot direct-mapped. A direct map is very + // cheap until a coarse tile's regular lattice happens to put two chunk keys in the same + // bucket; then the two keys evict each other on every row and rebuild the prepared graph. + // Probe deterministically to the first empty/stale slot, retaining the bounded memory cap. + auto FindTunnelDensityCacheEntry = [&](const FIntVector& QueryChunk, + FTunnelNetworkDensityCacheEntry*& OutEntry) -> bool + { + const int32 StartSlot = TunnelDensityCacheSlot(QueryChunk); + FTunnelNetworkDensityCacheEntry* ReuseEntry = nullptr; + for (int32 Probe = 0; Probe < TunnelDensityCacheSlotCount; ++Probe) + { + const int32 Slot = (StartSlot + Probe) & (TunnelDensityCacheSlotCount - 1); + FTunnelNetworkDensityCacheEntry& Candidate = CP_TunnelDensityCache[Slot]; + if (!Candidate.bValid || Candidate.OwnerId != DensityCacheOwnerId) + { + if (ReuseEntry == nullptr) { ReuseEntry = &Candidate; } + if (!Candidate.bValid) { break; } + continue; + } + const bool bKeyMatches = Candidate.ManagerLifetimeId == ManagerLifetimeId + && Candidate.Chunk == QueryChunk + && Candidate.LayoutVersion == LayoutVersion + && (Candidate.GenType == ECaveGeneratorType::TunnelNetwork + || Candidate.GenType == ECaveGeneratorType::Underwater); + if (bKeyMatches) + { + OutEntry = &Candidate; + return true; + } + } + OutEntry = ReuseEntry != nullptr + ? ReuseEntry + : &CP_TunnelDensityCache[StartSlot]; + return false; + }; if (!bIsGapChunk) { - FTunnelNetworkDensityCacheEntry& CachedEntry = - CP_TunnelDensityCache[TunnelDensityCacheSlot(ChunkCoord)]; - const bool bCacheKeyMatches = CachedEntry.bValid - && CachedEntry.OwnerId == DensityCacheOwnerId - && CachedEntry.ManagerLifetimeId == ManagerLifetimeId - && CachedEntry.Chunk == ChunkCoord - && CachedEntry.LayoutVersion == LayoutVersion - && (CachedEntry.GenType == ECaveGeneratorType::TunnelNetwork - || CachedEntry.GenType == ECaveGeneratorType::Underwater); + if (VoxelDensityProfile::IsEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelCacheLookup); + } + FTunnelNetworkDensityCacheEntry* CachedEntryPtr = nullptr; + const bool bCacheKeyMatches = FindTunnelDensityCacheEntry(ChunkCoord, CachedEntryPtr); + FTunnelNetworkDensityCacheEntry& CachedEntry = *CachedEntryPtr; + TunnelDensityCacheEntryForWrite = CachedEntryPtr; if (bCacheKeyMatches) { + if (VoxelDensityProfile::IsEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelCacheHit); + } if (CP_ActiveTunnelDensityCache != &CachedEntry) { CP_OwnerId = CachedEntry.OwnerId; @@ -862,6 +915,11 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co ActiveTunnelCoreCache = &CachedEntry.TunnelCore; bLoadedTunnelDensityCache = true; } + else if (VoxelDensityProfile::IsEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::TunnelCacheMiss); + } } const bool bOwnerChanged = DensityCacheOwnerId != CP_OwnerId; @@ -1083,8 +1141,9 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co #endif if (!bLoadedTunnelDensityCache && bCanStoreTunnelDensityCache) { - FTunnelNetworkDensityCacheEntry& CachedEntry = - CP_TunnelDensityCache[TunnelDensityCacheSlot(ChunkCoord)]; + FTunnelNetworkDensityCacheEntry& CachedEntry = TunnelDensityCacheEntryForWrite + ? *TunnelDensityCacheEntryForWrite + : CP_TunnelDensityCache[TunnelDensityCacheSlot(ChunkCoord)]; CachedEntry.bValid = false; CachedEntry.OwnerId = DensityCacheOwnerId; CachedEntry.ManagerLifetimeId = ManagerLifetimeId; @@ -1253,34 +1312,13 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co Result, WorldX, WorldY, WorldZ, CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ, CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius); - StrateManager->ApplyPassageLandingAirMC( - Result, WorldX, WorldY, WorldZ, LandingBaseDensity, - CP_Dist.BoundarySealThickness); VF_ApplyOriginLandingFloorMC( Result, WorldX, WorldY, WorldZ, CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ, CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius); - StrateManager->ApplyPassageLandingFloorMC( - Result, WorldX, WorldY, WorldZ, LandingBaseDensity); - // A walkable tunnel's guaranteed air core is the final structural writer above its - // floor. This prevents a nearby passage's floor slab from capping the active route; - // the tunnel-floor predicate is false on the active floor itself, so support remains. - StrateManager->ApplyPassageTunnelAirMC( + StrateManager->ApplyPassageStructuralPostsMC( Result, WorldX, WorldY, WorldZ, LandingBaseDensity, CP_Dist.BoundarySealThickness); - VF_ApplyOriginLandingAirMC( - Result, WorldX, WorldY, WorldZ, - CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ, - CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius); - StrateManager->ApplyPassageLandingAirMC( - Result, WorldX, WorldY, WorldZ, LandingBaseDensity, - CP_Dist.BoundarySealThickness); - VF_ApplyOriginLandingFloorMC( - Result, WorldX, WorldY, WorldZ, - CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ, - CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius); - StrateManager->ApplyPassageLandingRoomFloorMC( - Result, WorldX, WorldY, WorldZ, LandingBaseDensity); // Disturbance features are authored as a generic MC-space post and may add a ridge or // bridge over a graph tunnel. Reassert the native cached tunnel core here, after every @@ -1288,17 +1326,21 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // never once per voxel. if (ActiveTunnelCoreCache->bValid) { - const bool bTunnelSupportFloor = - VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( + FTunnelCoreWorldEvaluation TunnelCore; + { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::TunnelCorePosts); + TunnelCore = VoxelCaveMorphology::EvaluateTunnelCoreWorld( WorldX, WorldY, WorldZ, ActiveTunnelCoreCache->Cache); + } + const bool bTunnelSupportFloor = TunnelCore.bSupportFloor; if (bTunnelSupportFloor) { Result = FMath::Min( Result, -FMath::Max(LandingBaseDensity * 2.0f, 1.0f)); } - const float CoreSDF = VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF( - WorldX, WorldY, WorldZ, ActiveTunnelCoreCache->Cache); + const float CoreSDF = TunnelCore.SDF; if (!bTunnelSupportFloor && CoreSDF < -VoxelPassageGeometry::CaveTunnelAirCoreInsetVoxels) { @@ -1563,6 +1605,11 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo if (bNeedRebuild) { + if (VoxelDensityProfile::IsEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::SdfCacheBuild); + } // Center the search box on this query's chunk. Search area = chunk XY extent // + CaveWarpStrength margin (covers warp displacement) + gradient sampling. // MaxInfluence (room/tunnel reach) is added internally by BuildChunkCache. diff --git a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp index 36e5611..06bcbc2 100644 --- a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp +++ b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp @@ -29,6 +29,7 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, const int32 OctaveBias = (LODOctaveDrop > 0 && Step > 1) ? LODOctaveDrop * (int32)FMath::FloorLog2((uint32)Step) : 0; TGuardValue OctaveBiasGuard(VoxelGenLOD::OctaveBias, OctaveBias); + TGuardValue SampleStepGuard(VoxelGenLOD::SampleStep, Step); // World-cm origin of the tile's min corner (positions are built relative to this). const FVector ChunkWorldPos = FVector(OriginVoxels) * VOXEL_SIZE; @@ -225,8 +226,10 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, // gradient) sont échantillonnées — le reste du tampon reste non initialisé et non lu. for (int32 gz = GzLo; gz <= GzHi; gz++) { + if (ShouldAbortWork()) return FVoxelMeshData(); for (int32 gy = -1; gy <= GridDim; gy++) { + if (ShouldAbortWork()) return FVoxelMeshData(); for (int32 gx = -1; gx <= GridDim; gx++) { // World voxel = tile origin + grid offset scaled by the cell size (Step). @@ -289,8 +292,10 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, CapTris.Reset(); for (int32 cz = CzLo; cz <= CzHi; cz++) // bande de strate : cf. CzLo/CzHi plus haut { + if (ShouldAbortWork()) return FVoxelMeshData(); for (int32 cy = 0; cy < CellsPerAxis; cy++) { + if (ShouldAbortWork()) return FVoxelMeshData(); for (int32 cx = 0; cx < CellsPerAxis; cx++) { // PASSE 1 : densités aux 8 coins + index de cas MC SEULEMENT. @@ -498,6 +503,7 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateSheetMesh(FIntVector OriginVox SheetCols.SetNumUninitialized(MDim * MDim); for (int32 gy = -1; gy <= GridDim; ++gy) { + if (ShouldAbortWork()) return FVoxelMeshData(); for (int32 gx = -1; gx <= GridDim; ++gx) { float Tz = 0.0f, Cz = 0.0f; @@ -572,6 +578,7 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateSheetMesh(FIntVector OriginVox for (int32 cy = 0; cy < CellsXY; ++cy) { + if (ShouldAbortWork()) return FVoxelMeshData(); for (int32 cx = 0; cx < CellsXY; ++cx) { if (CellInHole(cx, cy)) continue; // couverte par les coquilles MC proches @@ -638,6 +645,7 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateSheetMesh(FIntVector OriginVox // (pas dans le trou XY) — sinon mur flottant sans surface. for (int32 c = 0; c < CellsXY; ++c) { + if (ShouldAbortWork()) return FVoxelMeshData(); for (int32 Pass = 0; Pass < 2; ++Pass) { const bool bCap = (Pass == 1); diff --git a/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Source/VoxelForge/Private/VoxelStrateManager.cpp index 2fe76e7..e4e5724 100644 --- a/Source/VoxelForge/Private/VoxelStrateManager.cpp +++ b/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -8,6 +8,7 @@ #include "VoxelTypes.h" // For CHUNK_SIZE, VOXEL_SIZE, WorldToChunkCoord #include "VoxelCaveMorphology.h" // For VoxelSDF and VoxelHash #include "VoxelDensityPrimitives.h" // Shared passage carve polarity/strength +#include "VoxelDensityProfile.h" // Opt-in targeted per-voxel attribution #include "VoxelTerrainOpDefinition.h" // For UVoxelTerrainOpDefinition::ApplyTo #include "VoxelBiomeDefinition.h" // For UVoxelBiomeDefinition (biome context flatten) #include "UObject/UObjectGlobals.h" @@ -1687,6 +1688,12 @@ float UVoxelStrateManager::EvaluateModifierSDF(float WorldX, float WorldY, float // The shortlist is rebuilt once for a (manager, version, chunk) and is shared by the tube, // landing, and floor paths. No source-fit stencil or topology search is allowed below it. const TArray& Nearby = VF_GetNearbyPassages(this, ChunkCoord); + if (VoxelDensityProfile::IsEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::PassageCandidates, + static_cast(Nearby.Num())); + } if (Nearby.Num() == 0) return FLT_MAX; // no passage near this chunk → no carve @@ -1707,6 +1714,11 @@ float UVoxelStrateManager::EvaluateModifierSDF(float WorldX, float WorldY, float // capsule chain unconditionally — the dominant lag source once passages became // 12-segment worms. Now far passages cost a single squared-distance compare. if (FVector::DistSquared(Pos, P.BoundCenter) > P.BoundRadiusSq) continue; + if (VoxelDensityProfile::IsEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::PassageEvaluated); + } if (P.ControlPoints.Num() >= 2) { @@ -1748,6 +1760,8 @@ void UVoxelStrateManager::ApplyPassageModifier( float& Density, float WorldX, float WorldY, float WorldZ, float BaseDensity, float SealThickness) const { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageModifier); const float ModSDF = EvaluateModifierSDF(WorldX, WorldY, WorldZ); VF_ApplyPassageCarving(Density, ModSDF, BaseDensity, SealThickness); ApplyPassageLandingAir(Density, WorldX, WorldY, WorldZ, BaseDensity, SealThickness); @@ -1781,10 +1795,22 @@ void UVoxelStrateManager::ApplyPassageModifier( ApplyPassageTunnelAir(Density, WorldX, WorldY, WorldZ, BaseDensity, SealThickness); } +void UVoxelStrateManager::ApplyPassageCarvingOnly( + float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity, float SealThickness) const +{ + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageModifier); + const float ModSDF = EvaluateModifierSDF(WorldX, WorldY, WorldZ); + VF_ApplyPassageCarving(Density, ModSDF, BaseDensity, SealThickness); +} + void UVoxelStrateManager::ApplyPassageLandingAir( float& Density, float WorldX, float WorldY, float WorldZ, float BaseDensity, float SealThickness) const { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageLandingAir); const FIntVector ChunkCoord( FMath::FloorToInt(WorldX / (float)CHUNK_SIZE), FMath::FloorToInt(WorldY / (float)CHUNK_SIZE), @@ -1846,6 +1872,8 @@ void UVoxelStrateManager::ApplyPassageTunnelAir( float& Density, float WorldX, float WorldY, float WorldZ, float BaseDensity, float SealThickness) const { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageTunnelAir); const FVector Position(WorldX, WorldY, WorldZ); int32 PassageIndex = INDEX_NONE; float FloorZ = 0.0f; @@ -1874,6 +1902,8 @@ void UVoxelStrateManager::ApplyPassageTunnelAirMC( void UVoxelStrateManager::ApplyPassageLandingFloorMC( float& Density, float WorldX, float WorldY, float WorldZ, float BaseDensity) const { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageLandingFloor); const FIntVector ChunkCoord( FMath::FloorToInt(WorldX / (float)CHUNK_SIZE), FMath::FloorToInt(WorldY / (float)CHUNK_SIZE), @@ -1914,10 +1944,102 @@ static bool VF_IsPassageRoomFloor( <= FMath::Max(Landing.HalfWidth - 1.0f, 0.0f); } +void UVoxelStrateManager::ApplyPassageStructuralPostsMC( + float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity, float SealThickness) const +{ + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageStructuralPosts); + + const FIntVector ChunkCoord( + FMath::FloorToInt(WorldX / (float)CHUNK_SIZE), + FMath::FloorToInt(WorldY / (float)CHUNK_SIZE), + FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE)); + const TArray& Nearby = VF_GetNearbyPassages(this, ChunkCoord); + if (Nearby.Num() == 0) return; + + const FVector Position(WorldX, WorldY, WorldZ); + const bool bSuppressFloor = + VoxelPassageGeometry::VerticalShaftConnectorAirMarker(); + bool bAnyPassageFloor = false; + bool bAnyRoomFloor = false; + bool bWalkableAir = false; + float MinLandingSDF = FLT_MAX; + + // The four old MC backstops all walked the same immutable per-chunk shortlist. Gather their + // predicates in one pass; the writes below retain the old order (landing air, landing floor, + // tunnel air, room floor), including the vertical-shaft floor suppression marker. + for (const int32 PassageIndex : Nearby) + { + if (!Passages.IsValidIndex(PassageIndex)) continue; + const FVoxelPassage& Passage = Passages[PassageIndex]; + + if (!bSuppressFloor + && (VF_IsPassageLandingFloor(Position, Passage.UpperLanding) + || VF_IsPassageLandingFloor(Position, Passage.LowerLanding) + || VF_IsWalkableTunnelFloor(Passage, Position))) + { + bAnyPassageFloor = true; + } + + if (!bSuppressFloor + && (VF_IsPassageRoomFloor(Position, Passage.UpperLanding) + || VF_IsPassageRoomFloor(Position, Passage.LowerLanding))) + { + bAnyRoomFloor = true; + } + + if (!bWalkableAir + && FVector::DistSquared(Position, Passage.BoundCenter) <= Passage.BoundRadiusSq + && VF_IsWalkableTunnelAir(Passage, Position)) + { + bWalkableAir = true; + } + + if (FVector::DistSquared(Position, Passage.BoundCenter) <= Passage.BoundRadiusSq) + { + const FVoxelPassageLanding* Landings[] = { + &Passage.UpperLanding, &Passage.LowerLanding }; + for (const FVoxelPassageLanding* Landing : Landings) + { + MinLandingSDF = FMath::Min( + MinLandingSDF, + VF_EvaluatePassageLandingSDF(Position, *Landing)); + } + } + } + + if (!bAnyPassageFloor && MinLandingSDF < FLT_MAX) + { + float InternalDensity = -Density; + VF_ApplyPassageLandingCarving( + InternalDensity, MinLandingSDF, BaseDensity, SealThickness); + Density = -InternalDensity; + } + + if (bAnyPassageFloor) + { + Density = FMath::Min(Density, -BaseDensity); + } + + if (bWalkableAir) + { + Density = FMath::Max( + Density, BaseDensity * 2.0f + SealThickness + 4.0f); + } + + if (bAnyRoomFloor) + { + Density = FMath::Min(Density, -BaseDensity); + } +} + void UVoxelStrateManager::ApplyPassageLandingRoomFloorMC( float& Density, float WorldX, float WorldY, float WorldZ, float BaseDensity) const { + VoxelDensityProfile::FScopedTimer ProfileTimer( + VoxelDensityProfile::EBucket::PassageLandingRoomFloor); const FIntVector ChunkCoord( FMath::FloorToInt(WorldX / (float)CHUNK_SIZE), FMath::FloorToInt(WorldY / (float)CHUNK_SIZE), diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index 4b8b6be..c46af7c 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -31,6 +31,7 @@ #include "Modules/ModuleManager.h" #include "Misc/FileHelper.h" #include "Misc/Paths.h" +#include "HAL/IConsoleManager.h" #include "ProfilingDebugging/CpuProfilerTrace.h" // Unreal Insights scopes (Perf 0) #if WITH_EDITOR @@ -38,6 +39,15 @@ #include "VoxelStrateMeasure.h" #endif +namespace +{ + int32 GVoxelForgeProfileTileGeneration = 0; + FAutoConsoleVariableRef CVarVoxelForgeProfileTileGeneration( + TEXT("voxel.ProfileTileGeneration"), + GVoxelForgeProfileTileGeneration, + TEXT("Log worker tile generation time with level/step/cell count.")); +} + AVoxelWorld::AVoxelWorld() { PrimaryActorTick.bCanEverTick = true; @@ -855,7 +865,10 @@ void AVoxelWorld::OnObjectModifiedInEditor(UObject* ModifiedObject) void AVoxelWorld::EndPlay(const EEndPlayReason::Type EndPlayReason) { // Signal all async tasks to bail out ASAP + const double EndPlayStartSeconds = FPlatformTime::Seconds(); bShuttingDown.store(true, std::memory_order_release); + UE_LOG(LogTemp, Display, TEXT("[VoxelWorld] EndPlay: shutdown signalled; waiting for %d tasks"), + ActiveTaskCount.load(std::memory_order_relaxed)); // Wait for every running task before destroying UObjects. A timeout here is unsafe: a task // already inside GenerateTileResult may still be evaluating a cached op stack that points at @@ -872,6 +885,8 @@ void AVoxelWorld::EndPlay(const EEndPlayReason::Type EndPlayReason) } FPlatformProcess::Yield(); // Give CPU to other threads } + UE_LOG(LogTemp, Display, TEXT("[VoxelWorld] EndPlay: workers drained in %.6fs"), + FPlatformTime::Seconds() - EndPlayStartSeconds); // Drain any queued results FChunkResult Discard; @@ -965,6 +980,7 @@ void AVoxelWorld::BeginPlay() Generator->InitializeSettings(Settings); Mesher->SetGenerator(Generator); + Mesher->SetShutdownFlag(&bShuttingDown); Mesher->bGenerateSkirts = Settings->bGenerateSkirts; Mesher->SkirtCells = Settings->SkirtCells; Mesher->LODOctaveDrop = Settings->LODOctaveDrop; // T2.b — 0 = off @@ -1161,6 +1177,13 @@ void AVoxelWorld::ProcessPendingChunks() { PendingTiles.Remove(DequeuedChunk.Tile); + // A worker can observe shutdown after entering the mesher. Do not turn that partial + // result into an all-air tile: it must remain eligible for a future generation epoch. + if (DequeuedChunk.bAborted) + { + continue; + } + // ApplyTileResult does epoch check, mark-loaded, capture ingest, empty-release / mesh upload. // Only a real (visible) upload counts against the per-frame budget — stale/empty drain free. if (ApplyTileResult(DequeuedChunk)) @@ -1179,7 +1202,7 @@ void AVoxelWorld::ProcessPendingChunks() bool AVoxelWorld::ApplyTileResult(FChunkResult& Result) { // Discard results from a previous generation epoch (stale). - if (Result.Epoch != GenerationEpoch) + if (Result.bAborted || Result.Epoch != GenerationEpoch) { return false; } @@ -2078,11 +2101,44 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector int32 HoleMinX, int32 HoleMinY, int32 HoleMaxX, int32 HoleMaxY, FChunkResult& Result) { + const bool bProfileTile = GVoxelForgeProfileTileGeneration != 0; + const double TileStartSeconds = bProfileTile ? FPlatformTime::Seconds() : 0.0; + auto EmitTileProfile = [&]() + { + if (bProfileTile) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeTileProfile] level=%d step=%d cells=%d sheet=%d aborted=%d empty=%d seconds=%.6f"), + Tile.Level, Step, Cells, bSheetTile ? 1 : 0, Result.bAborted ? 1 : 0, + Result.bEmpty ? 1 : 0, FPlatformTime::Seconds() - TileStartSeconds); + } + }; + Result.Tile = Tile; Result.Epoch = Epoch; + Result.bAborted = false; + Result.bEmpty = true; + Result.Streams.Reset(); + Result.CaptureGrid.Reset(); Result.BandChunkLo = BandChunkLo; // strate content cut (MIN/MAX = uncut) Result.BandChunkHi = BandChunkHi; + // Shared by async workers and the synchronous carve path. An interrupted result is empty but + // explicitly discarded, never an all-air tile that gets marked loaded. + auto AbortResult = [&]() + { + Result.bAborted = true; + Result.bEmpty = true; + Result.Streams.Reset(); + Result.CaptureGrid.Reset(); + }; + if (ShouldAbortWork()) + { + AbortResult(); + EmitTileProfile(); + return; + } + // T1.d — TRIVIAL-TILE REJECT: ~84 % des tuiles générées sortaient vides (tout-roc / // tout-air) en payant quand même le pré-échantillonnage complet. Le classifieur prouve // (bornes exactes sur le treillis du mesher + gardes conservatives) qu'une tuile est @@ -2096,6 +2152,12 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ClassifyTile); INC_DWORD_STAT(STAT_VoxelForgeTilesClassified); const EVoxelTileClass Verdict = Generator->ClassifyTile(OriginVoxels, Step, Cells); + if (ShouldAbortWork()) + { + AbortResult(); + EmitTileProfile(); + return; + } if (Verdict == EVoxelTileClass::AllSolid) { INC_DWORD_STAT(STAT_VoxelForgeTilesSkippedAllSolid); @@ -2123,6 +2185,12 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector bWantCapture ? &Result.CaptureGrid : nullptr, BandVoxLo, BandVoxHi); INC_DWORD_STAT(STAT_VoxelForgeTilesMeshed); + if (ShouldAbortWork()) + { + AbortResult(); + EmitTileProfile(); + return; + } } // T1.f — build the RMC geometry buffers HERE (worker), not on the game thread. Empty/all-air @@ -2132,6 +2200,12 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_BuildStreams); Result.Streams = MakeShared(); BuildTileStreamSet(*Result.Streams, MeshData); + if (ShouldAbortWork()) + { + AbortResult(); + EmitTileProfile(); + return; + } Result.bEmpty = false; // F17 — the mesher classified every triangle semantically (sky-cap vs ground, per @@ -2143,6 +2217,8 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector Result.bHasCeilingTris = MeshData.NumCeilingTriangles > 0; Result.bHasGroundTris = NumTris > MeshData.NumCeilingTriangles; } + + EmitTileProfile(); } // Section-group key shared by every tile component ("the" tile geometry group). diff --git a/Source/VoxelForge/Public/VoxelCaveMorphology.h b/Source/VoxelForge/Public/VoxelCaveMorphology.h index 4c93559..fe6c21f 100644 --- a/Source/VoxelForge/Public/VoxelCaveMorphology.h +++ b/Source/VoxelForge/Public/VoxelCaveMorphology.h @@ -669,6 +669,13 @@ struct FChunkSDFCache TArray Columns; }; +/** Combined world-space tunnel post query; one cache scan supplies both support and air tests. */ +struct FTunnelCoreWorldEvaluation +{ + float SDF = FLT_MAX; + bool bSupportFloor = false; +}; + //============================================================================= // CAVE MORPHOLOGY EVALUATOR //============================================================================= @@ -757,6 +764,11 @@ namespace VoxelCaveMorphology const FChunkSDFCache& Cache ); + VOXELFORGE_API FTunnelCoreWorldEvaluation EvaluateTunnelCoreWorld( + float WorldX, float WorldY, float WorldZ, + const FChunkSDFCache& Cache + ); + // True for the finite support slab beneath a world-space graph tunnel. The native density // path and the operator stack use this same predicate before reopening the air core, keeping // the rounded capsule's bottom from becoming an unsupported point. diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index b181cb6..879a88a 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -35,6 +35,7 @@ #include "CoreMinimal.h" #include "VoxelDensityOp.h" +#include "VoxelDensityProfile.h" #include "VoxelStrateTypes.h" // FMazeGenerationParams #include "VoxelHeightOp.h" // IVoxelBiomeField — BuildSurfaceStack takes ownership of one @@ -85,6 +86,7 @@ private: bool bAdditive = false; EVoxelOpResourceMask RequiredResources = VoxelOpResources::None; EVoxelOpResourceMask ProvidedResources = VoxelOpResources::None; + const TCHAR* ProfileName = nullptr; }; public: @@ -121,6 +123,7 @@ public: Entry.bAdditive = Entry.Op->IsAdditive(); Entry.RequiredResources = Entry.Op->RequiredResources(); Entry.ProvidedResources = Entry.Op->ProvidedResources(); + Entry.ProfileName = Entry.Op->DebugName(); } Ops.Add(MoveTemp(Entry)); } @@ -153,7 +156,11 @@ public: FVoxelOpSample EvalSample(float WorldX, float WorldY, float WorldZ) const { FVoxelOpSample S; - for (const FOpEntry& Entry : Ops) { Entry.Op->Eval(WorldX, WorldY, WorldZ, S); } + for (const FOpEntry& Entry : Ops) + { + VoxelDensityProfile::FScopedTimer ProfileTimer(Entry.ProfileName); + Entry.Op->Eval(WorldX, WorldY, WorldZ, S); + } return S; } diff --git a/Source/VoxelForge/Public/VoxelDensityProfile.h b/Source/VoxelForge/Public/VoxelDensityProfile.h new file mode 100644 index 0000000..50611bf --- /dev/null +++ b/Source/VoxelForge/Public/VoxelDensityProfile.h @@ -0,0 +1,122 @@ +// Opt-in density-path profiling used by the editor commandlet and targeted PIE diagnostics. +// Disabled by default so the shipping density path pays only one predictable branch in the +// scoped timers below. + +#pragma once + +#include "CoreMinimal.h" +#include "HAL/PlatformTime.h" + +#include + +namespace VoxelDensityProfile +{ + enum class EBucket : uint8 + { + GetDensityAt, + ApplyDisturbances, + PassageModifier, + PassageLandingAir, + PassageLandingFloor, + PassageLandingRoomFloor, + PassageTunnelAir, + PassageStructuralPosts, + TunnelCoreSupport, + TunnelCoreSdf, + TunnelCorePosts, + StructuralTail, + RoomGraphSource, + SdfCarve, + CaveRoughnessMod, + CaveTerraceMod, + LayerLineMod, + RibbingMod, + CaveOverhangMod, + CaveCliffMod, + ScallopMod, + CaveArchMod, + RoomColumnMod, + DomeMod, + PinchMod, + FloorBiasMod, + WormFieldSource, + OriginSpineOp, + BoundarySealOp, + PassageCarveOp, + CaveTunnelFloorOp, + CaveTunnelAirOp, + XYEdgeSealOp, + OtherOp, + Count + }; + + constexpr int32 BucketCount = static_cast(EBucket::Count); + + enum class ECounter : uint8 + { + TunnelCacheLookup, + TunnelCacheHit, + TunnelCacheMiss, + SdfCacheBuild, + CaveRoomCandidates, + CaveRoomEvaluated, + CaveTunnelCandidates, + CaveTunnelEvaluated, + TunnelCoreCandidates, + TunnelCoreEvaluated, + PassageCandidates, + PassageEvaluated, + Count + }; + + constexpr int32 CounterCount = static_cast(ECounter::Count); + + struct FSnapshot + { + uint64 Calls[BucketCount]{}; + uint64 Cycles[BucketCount]{}; + uint64 Counters[CounterCount]{}; + }; + + VOXELFORGE_API void SetEnabled(bool bEnabled); + VOXELFORGE_API bool IsEnabled(); + VOXELFORGE_API void Reset(); + VOXELFORGE_API FSnapshot Snapshot(); + VOXELFORGE_API void AddCounter(ECounter Counter, uint64 Amount = 1); + VOXELFORGE_API const TCHAR* CounterName(ECounter Counter); + VOXELFORGE_API EBucket BucketFromName(const TCHAR* Name); + VOXELFORGE_API const TCHAR* BucketName(EBucket Bucket); + + class FScopedTimer final + { + public: + explicit FScopedTimer(EBucket InBucket) + : Bucket(InBucket) + , bActive(IsEnabled()) + , StartCycles(bActive ? FPlatformTime::Cycles64() : 0) + { + } + + explicit FScopedTimer(const TCHAR* InName) + : Bucket(EBucket::OtherOp) + , bActive(IsEnabled()) + , StartCycles(0) + { + if (bActive) + { + Bucket = BucketFromName(InName); + StartCycles = FPlatformTime::Cycles64(); + } + } + + ~FScopedTimer(); + + FScopedTimer(const FScopedTimer&) = delete; + FScopedTimer& operator=(const FScopedTimer&) = delete; + + private: + EBucket Bucket = EBucket::OtherOp; + bool bActive = false; + uint64 StartCycles = 0; + }; +} diff --git a/Source/VoxelForge/Public/VoxelGenerator.h b/Source/VoxelForge/Public/VoxelGenerator.h index d7506ec..2c2d16d 100644 --- a/Source/VoxelForge/Public/VoxelGenerator.h +++ b/Source/VoxelForge/Public/VoxelGenerator.h @@ -45,6 +45,11 @@ namespace VoxelGenLOD // (generator + mesher) are inside this module, so no export is needed anyway. extern thread_local int32 OctaveBias; + // Sampling step of the tile currently being meshed on this worker. GetDensityAt itself has + // no Step parameter because callers outside the mesher are point queries; this TLS is only a + // read-only hint for caches that may safely widen their spatial window for coarse tiles. + extern thread_local int32 SampleStep; + // Effective octave count for a per-voxel noise call site. // At least 1 octave always survives (the coarse base shape). FORCEINLINE int32 Eff(int32 Octaves) { return FMath::Max(1, Octaves - OctaveBias); } diff --git a/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h b/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h index d95f44c..bbbce38 100644 --- a/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h +++ b/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h @@ -12,6 +12,7 @@ #pragma once #include "CoreMinimal.h" +#include #include "VoxelTypes.h" // Pour FVoxelMeshData, CHUNK_SIZE, VOXEL_SIZE, etc. #include "VoxelGenerator.h" #include "VoxelMarchingCubesMesher.generated.h" @@ -88,6 +89,20 @@ public: void SetGenerator(const UVoxelGenerator* InGenerator) { Generator = InGenerator; } + // AVoxelWorld installs its bShuttingDown flag here. The commandlet path leaves it null. + // This is intentionally read-only: workers may observe shutdown, but never mutate world state. + void SetShutdownFlag(const std::atomic* InShutdownFlag) { ShutdownFlag = InShutdownFlag; } + +private: + FORCEINLINE bool ShouldAbortWork() const + { + return ShutdownFlag && ShutdownFlag->load(std::memory_order_relaxed); + } + + const std::atomic* ShutdownFlag = nullptr; + +public: + //========================================================================= // SETTINGS //========================================================================= diff --git a/Source/VoxelForge/Public/VoxelStrateManager.h b/Source/VoxelForge/Public/VoxelStrateManager.h index 9d8ea7d..d9cc4da 100644 --- a/Source/VoxelForge/Public/VoxelStrateManager.h +++ b/Source/VoxelForge/Public/VoxelStrateManager.h @@ -378,6 +378,10 @@ public: void ApplyPassageModifier(float& Density, float WorldX, float WorldY, float WorldZ, float BaseDensity, float SealThickness) const; + /** Apply only the passage SDF carve. The shared MC post owns landing/tunnel support writes. */ + void ApplyPassageCarvingOnly(float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity, float SealThickness) const; + /** Reapply the landing-only air carve in internal density (used after visual disturbances). */ void ApplyPassageLandingAir(float& Density, float WorldX, float WorldY, float WorldZ, float BaseDensity, float SealThickness) const; @@ -394,6 +398,10 @@ public: void ApplyPassageTunnelAirMC(float& Density, float WorldX, float WorldY, float WorldZ, float BaseDensity, float SealThickness) const; + /** Apply the landing/tunnel MC backstop in one cached passage scan after disturbances. */ + void ApplyPassageStructuralPostsMC(float& Density, float WorldX, float WorldY, float WorldZ, + float BaseDensity, float SealThickness) const; + /** Re-assert a landing floor after the landing air pass, still before the final XY seal. */ void ApplyPassageLandingFloorMC(float& Density, float WorldX, float WorldY, float WorldZ, float BaseDensity) const; diff --git a/Source/VoxelForge/Public/VoxelWorld.h b/Source/VoxelForge/Public/VoxelWorld.h index 8c74486..306228d 100644 --- a/Source/VoxelForge/Public/VoxelWorld.h +++ b/Source/VoxelForge/Public/VoxelWorld.h @@ -84,6 +84,7 @@ struct FChunkResult // Null ⇒ empty/all-air tile (no component). TSharedPtr Streams; uint32 Epoch = 0; // Generation epoch — discard if stale + bool bAborted = false; // Worker observed shutdown; never mark this tile loaded bool bEmpty = true; // true ⇒ all-air tile (Streams null); still marked loaded so we don't re-submit // F17 — the mesher classifies every triangle semantically (sky-cap = down-facing near the // column's CeilSurf; overhangs/cave roofs stay ground) and packs them as two contiguous runs diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp index 945b402..773766d 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -21,6 +21,7 @@ #include "UObject/StrongObjectPtr.h" #include "UObject/UObjectGlobals.h" #include "VoxelDiffLayer.h" +#include "VoxelDensityProfile.h" #include "VoxelGenerator.h" #include "VoxelMarchingCubesMesher.h" #include "VoxelNoise.h" @@ -172,6 +173,8 @@ struct FExploreArguments bool bRender = true; bool bWalk = true; bool bExport = true; + bool bProfileDensity = false; + bool bProfileLod = false; bool bFailureFocusRender = false; FString OutDirectory; @@ -209,6 +212,8 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr FParse::Value(*Params, TEXT("archetype="), ArchetypeText); FParse::Value(*Params, TEXT("slot="), OutArguments.Slot); FParse::Value(*Params, TEXT("modes="), ModesText); + OutArguments.bProfileDensity = FParse::Param(*Params, TEXT("profiledensity")); + OutArguments.bProfileLod = FParse::Param(*Params, TEXT("profilelod")); FParse::Value(*Params, TEXT("out="), OutText); FParse::Value(*Params, TEXT("renderwidth="), OutArguments.RenderWidth); FParse::Value(*Params, TEXT("renderheight="), OutArguments.RenderHeight); @@ -3840,6 +3845,12 @@ int32 UVoxelForgeExploreCommandlet::Main(const FString& Params) UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] %s"), *Error); return 1; } + + if (Arguments.bProfileDensity) + { + VoxelDensityProfile::Reset(); + VoxelDensityProfile::SetEnabled(true); + } if (!IFileManager::Get().MakeDirectory(*Arguments.OutDirectory, true)) { UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] Could not create output directory '%s'."), @@ -3860,6 +3871,61 @@ int32 UVoxelForgeExploreCommandlet::Main(const FString& Params) } const double SetupSeconds = FPlatformTime::Seconds() - SetupStartSeconds; + if (Arguments.bProfileLod) + { + const FIntVector ProbeOrigin(0, 0, World.TargetBottomWorldZ); + const int32 FullRes = World.Settings + ? FMath::Max(1, World.Settings->FullResClipLevels) + : 2; + const int32 CoarseCells = World.Settings + ? FMath::Clamp(World.Settings->CoarseTileCells, 4, CHUNK_SIZE) + : 16; + const int32 CutMin = World.Settings + ? World.Settings->StrateContentCutMinLevel + : 9; + for (int32 Level = 0; Level <= 4; ++Level) + { + // Match AVoxelWorld::LoadTile exactly: the probe is a production tile at this LOD, + // including the configured coarse sample count. Using 1<= FullResClipLevels. + const int32 Extent = CHUNK_SIZE << Level; + const int32 Cells = Level < FullRes ? CHUNK_SIZE : CoarseCells; + const int32 Step = FMath::Max(1, Extent / Cells); + VoxelDensityProfile::Reset(); + VoxelDensityProfile::SetEnabled(true); + const double Start = FPlatformTime::Seconds(); + const bool bBanded = Level >= CutMin; + const FVoxelMeshData Mesh = World.Mesher->GenerateMesh( + ProbeOrigin, Step, Cells, nullptr, + bBanded ? World.TargetBottomWorldZ : INT32_MIN, + bBanded ? World.TargetTopWorldZ - 1 : INT32_MAX); + const double Seconds = FPlatformTime::Seconds() - Start; + const VoxelDensityProfile::FSnapshot Profile = VoxelDensityProfile::Snapshot(); + const int32 DensityIndex = static_cast(VoxelDensityProfile::EBucket::GetDensityAt); + const uint64 Calls = Profile.Calls[DensityIndex]; + const uint64 Lookups = Profile.Counters[static_cast(VoxelDensityProfile::ECounter::TunnelCacheLookup)]; + const uint64 Hits = Profile.Counters[static_cast(VoxelDensityProfile::ECounter::TunnelCacheHit)]; + const uint64 SdfBuilds = Profile.Counters[static_cast(VoxelDensityProfile::ECounter::SdfCacheBuild)]; + const double DensityUs = static_cast(Profile.Cycles[DensityIndex]) + * FPlatformTime::GetSecondsPerCycle64() * 1.0e6; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeLODProfile] level=%d step=%d cells=%d banded=%d seconds=%.6f density_calls=%llu " + "density_us_per_call=%.3f sdf_builds=%llu cache_lookups=%llu cache_hits=%llu cache_misses=%llu " + "hit_rate=%.3f vertices=%d triangles=%d mesh_empty=%d"), + Level, Step, Cells, bBanded ? 1 : 0, Seconds, + static_cast(Calls), + Calls > 0 ? DensityUs / static_cast(Calls) : 0.0, + static_cast(SdfBuilds), + static_cast(Lookups), + static_cast(Hits), + static_cast(Profile.Counters[static_cast(VoxelDensityProfile::ECounter::TunnelCacheMiss)]), + Lookups > 0 ? static_cast(Hits) / static_cast(Lookups) : 0.0, + Mesh.Vertices.Num(), Mesh.Triangles.Num() / 3, + Mesh.IsEmpty() ? 1 : 0); + } + VoxelDensityProfile::SetEnabled(false); + } + FExploreRunOutput Output; if (Budget.ShouldStop(TEXT("setup"))) { @@ -4044,5 +4110,48 @@ int32 UVoxelForgeExploreCommandlet::Main(const FString& Params) FPlatformTime::Seconds() - MainStartSeconds, *ReportPath, (bPayloadRepeatEqual && bFinalRepeatEqual) ? TEXT("yes") : TEXT("no")); + + if (Arguments.bProfileDensity) + { + const VoxelDensityProfile::FSnapshot Profile = VoxelDensityProfile::Snapshot(); + const double CycleToMicroseconds = FPlatformTime::GetSecondsPerCycle64() * 1.0e6; + const uint64 DensityCalls = Profile.Calls[static_cast(VoxelDensityProfile::EBucket::GetDensityAt)]; + const uint64 DensityCycles = Profile.Cycles[static_cast(VoxelDensityProfile::EBucket::GetDensityAt)]; + const double DensityTotalUs = static_cast(DensityCycles) * CycleToMicroseconds; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeDensityProfile] total calls=%llu total_us=%.3f us_per_call=%.6f"), + static_cast(DensityCalls), DensityTotalUs, + DensityCalls > 0 ? DensityTotalUs / static_cast(DensityCalls) : 0.0); + + for (int32 Index = 0; Index < VoxelDensityProfile::CounterCount; ++Index) + { + const VoxelDensityProfile::ECounter Counter = + static_cast(Index); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeDensityProfile] counter=%s value=%llu per_density_call=%.3f"), + VoxelDensityProfile::CounterName(Counter), + static_cast(Profile.Counters[Index]), + DensityCalls > 0 + ? static_cast(Profile.Counters[Index]) + / static_cast(DensityCalls) + : 0.0); + } + + for (int32 Index = 0; Index < VoxelDensityProfile::BucketCount; ++Index) + { + const uint64 Calls = Profile.Calls[Index]; + if (Calls == 0 || Index == static_cast(VoxelDensityProfile::EBucket::GetDensityAt)) + { + continue; + } + const double TotalUs = static_cast(Profile.Cycles[Index]) * CycleToMicroseconds; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeDensityProfile] op=%s calls=%llu total_us=%.3f us_per_call=%.6f"), + VoxelDensityProfile::BucketName(static_cast(Index)), + static_cast(Calls), TotalUs, + TotalUs / static_cast(Calls)); + } + VoxelDensityProfile::SetEnabled(false); + } return bRequestedModeFailed ? 2 : 0; }