diff --git a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp index 67de18c..96c83a8 100644 --- a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp +++ b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -33,6 +33,44 @@ #include "VoxelStrateMeasure.h" #include "VoxelTypes.h" // Pour VOXEL_NOISE_SCALE, SmoothStep01 #include "VoxelStrateTypes.h" +#include "HAL/PlatformTime.h" + +namespace +{ + struct FTask4BuildProbeScope + { + FChunkSDFCache& Cache; + const float MinX; + const float MinY; + const float MaxX; + const float MaxY; + const uint64 StartCycles; + const bool bEnabled; + + FTask4BuildProbeScope(FChunkSDFCache& InCache, float InMinX, float InMinY, + float InMaxX, float InMaxY) + : Cache(InCache), MinX(InMinX), MinY(InMinY), MaxX(InMaxX), MaxY(InMaxY) + , StartCycles(FPlatformTime::Cycles64()) + , bEnabled((InMaxX - InMinX) > 120.0f && (InMaxX - InMinX) < 220.0f + && (InMaxY - InMinY) > 120.0f && (InMaxY - InMinY) < 220.0f + && InMinX > -512.0f && InMaxX < 512.0f + && InMinY > -512.0f && InMaxY < 512.0f) + {} + + ~FTask4BuildProbeScope() + { + if (bEnabled) + { + UE_LOG(LogTemp, Warning, + TEXT("[Task4BuildProbe] search=(%.0f,%.0f)-(%.0f,%.0f) rooms=%d joins=%d tunnels=%d pits=%d chimneys=%d ms=%.3f"), + MinX, MinY, MaxX, MaxY, Cache.Rooms.Num(), Cache.RoomFloorJoins.Num(), + Cache.Tunnels.Num(), Cache.Pits.Num(), Cache.Chimneys.Num(), + FPlatformTime::ToMilliseconds64( + FPlatformTime::Cycles64() - StartCycles)); + } + } + }; +} #include "VoxelNoise.h" // Pure FBM used by the slab landing query #include "VoxelPassageGeometry.h" #include "VoxelTerrainOpDefinition.h" @@ -2828,6 +2866,8 @@ void VoxelCaveMorphology::BuildChunkCache( uint32 Seed, int32 StrateIndex, const TArray* TerrainOps) { + FTask4BuildProbeScope Task4BuildProbe(OutCache, SearchMinX, SearchMinY, SearchMaxX, SearchMaxY); + // Clear previous data (arrays keep their allocation for reuse) OutCache.Rooms.Reset(); OutCache.RoomFloorJoins.Reset(); diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index e56584b..14e51e2 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -33,6 +33,10 @@ #include "VoxelStats.h" #include // l'id d'instance non recyclé du mémo de colonne +#include "HAL/CriticalSection.h" +#include "HAL/Event.h" +#include "HAL/PlatformProcess.h" +#include "Misc/ScopeLock.h" namespace { @@ -89,6 +93,180 @@ namespace */ static constexpr float VF_PerlinAbsBound = 1.5f; + struct FVFInterval + { + float Min = 0.0f; + float Max = 0.0f; + }; + + FORCEINLINE FVFInterval VF_AddInterval(const FVFInterval& A, const FVFInterval& B) + { + return {A.Min + B.Min, A.Max + B.Max}; + } + + FORCEINLINE FVFInterval VF_NegateInterval(const FVFInterval& A) + { + return {-A.Max, -A.Min}; + } + + FORCEINLINE FVFInterval VF_MultiplyNonNegativeInterval( + const FVFInterval& A, const FVFInterval& B) + { + const float P0 = A.Min * B.Min; + const float P1 = A.Min * B.Max; + const float P2 = A.Max * B.Min; + const float P3 = A.Max * B.Max; + return {FMath::Min(FMath::Min(P0, P1), FMath::Min(P2, P3)), + FMath::Max(FMath::Max(P0, P1), FMath::Max(P2, P3))}; + } + + /** Exact interval for GradDot on one subdivision of one Perlin cell. */ + FORCEINLINE FVFInterval VF_GradDotInterval( + uint32 Hash, const FVFInterval& X, const FVFInterval& Y, const FVFInterval& Z) + { + const uint32 H = Hash & 15u; + const FVFInterval* Axes[3] = {&X, &Y, &Z}; + const FVFInterval& U = *Axes[(H & 8u) == 0u ? 0 : 1]; + + int32 VAxis = 2; + if ((H & 12u) == 0u) + { + VAxis = 1; + } + else if ((H & 13u) == 12u) + { + VAxis = 0; + } + + const FVFInterval SignedU = (H & 1u) == 0u ? U : VF_NegateInterval(U); + const FVFInterval SignedV = (H & 2u) == 0u + ? *Axes[VAxis] : VF_NegateInterval(*Axes[VAxis]); + return VF_AddInterval(SignedU, SignedV); + } + + /** + * Conservative local envelope for the exact scalar Perlin3D implementation. + * + * Perlin3D is a trilinear blend of eight linear GradDot values in each integer cell. We split + * every intersected cell into two pieces per axis and interval-evaluate the same fade/lerp + * formula. The result is therefore a proof over the whole finite box, not a sample-based + * guess. Large/invalid boxes fall back to the already-proven global envelope. + */ + FORCEINLINE float VF_PerlinAbsBoundOverBox( + const FVector3f& InMin, const FVector3f& InMax) + { + if (!FMath::IsFinite(InMin.X) || !FMath::IsFinite(InMin.Y) + || !FMath::IsFinite(InMin.Z) || !FMath::IsFinite(InMax.X) + || !FMath::IsFinite(InMax.Y) || !FMath::IsFinite(InMax.Z) + || InMin.X > InMax.X || InMin.Y > InMax.Y || InMin.Z > InMax.Z + || FMath::Abs(InMin.X) > 1000000.0f || FMath::Abs(InMax.X) > 1000000.0f + || FMath::Abs(InMin.Y) > 1000000.0f || FMath::Abs(InMax.Y) > 1000000.0f + || FMath::Abs(InMin.Z) > 1000000.0f || FMath::Abs(InMax.Z) > 1000000.0f) + { + return VF_PerlinAbsBound; + } + + const int32 X0 = FMath::FloorToInt(InMin.X); + const int32 Y0 = FMath::FloorToInt(InMin.Y); + const int32 Z0 = FMath::FloorToInt(InMin.Z); + const int32 X1 = FMath::FloorToInt(InMax.X); + const int32 Y1 = FMath::FloorToInt(InMax.Y); + const int32 Z1 = FMath::FloorToInt(InMax.Z); + const int64 CellCount = ((int64)X1 - X0 + 1) + * ((int64)Y1 - Y0 + 1) + * ((int64)Z1 - Z0 + 1); + if (X1 < X0 || Y1 < Y0 || Z1 < Z0 || CellCount <= 0 || CellCount > 64) + { + return VF_PerlinAbsBound; + } + + constexpr int32 Subdivisions = 8; + float Bound = 0.0f; + for (int32 CellZ = Z0; CellZ <= Z1; ++CellZ) + for (int32 CellY = Y0; CellY <= Y1; ++CellY) + for (int32 CellX = X0; CellX <= X1; ++CellX) + { + const float TX0 = FMath::Clamp(InMin.X - (float)CellX, 0.0f, 1.0f); + const float TY0 = FMath::Clamp(InMin.Y - (float)CellY, 0.0f, 1.0f); + const float TZ0 = FMath::Clamp(InMin.Z - (float)CellZ, 0.0f, 1.0f); + const float TX1 = FMath::Clamp(InMax.X - (float)CellX, 0.0f, 1.0f); + const float TY1 = FMath::Clamp(InMax.Y - (float)CellY, 0.0f, 1.0f); + const float TZ1 = FMath::Clamp(InMax.Z - (float)CellZ, 0.0f, 1.0f); + + for (int32 SZ = 0; SZ < Subdivisions; ++SZ) + for (int32 SY = 0; SY < Subdivisions; ++SY) + for (int32 SX = 0; SX < Subdivisions; ++SX) + { + const float AX = TX0 + (TX1 - TX0) * (float)SX / Subdivisions; + const float BX = TX0 + (TX1 - TX0) * (float)(SX + 1) / Subdivisions; + const float AY = TY0 + (TY1 - TY0) * (float)SY / Subdivisions; + const float BY = TY0 + (TY1 - TY0) * (float)(SY + 1) / Subdivisions; + const float AZ = TZ0 + (TZ1 - TZ0) * (float)SZ / Subdivisions; + const float BZ = TZ0 + (TZ1 - TZ0) * (float)(SZ + 1) / Subdivisions; + + const float FX0 = VoxelNoise::Detail::Fade(AX); + const float FX1 = VoxelNoise::Detail::Fade(BX); + const float FY0 = VoxelNoise::Detail::Fade(AY); + const float FY1 = VoxelNoise::Detail::Fade(BY); + const float FZ0 = VoxelNoise::Detail::Fade(AZ); + const float FZ1 = VoxelNoise::Detail::Fade(BZ); + const float WXMin[2] = {1.0f - FX1, FX0}; + const float WXMax[2] = {1.0f - FX0, FX1}; + const float WYMin[2] = {1.0f - FY1, FY0}; + const float WYMax[2] = {1.0f - FY0, FY1}; + const float WZMin[2] = {1.0f - FZ1, FZ0}; + const float WZMax[2] = {1.0f - FZ0, FZ1}; + + float ValueMin = 0.0f; + float ValueMax = 0.0f; + for (int32 CZ = 0; CZ < 2; ++CZ) + for (int32 CY = 0; CY < 2; ++CY) + for (int32 CX = 0; CX < 2; ++CX) + { + const FVFInterval Grad = VF_GradDotInterval( + VoxelNoise::Detail::HashCorner(CellX + CX, CellY + CY, CellZ + CZ), + {AX - (float)CX, BX - (float)CX}, + {AY - (float)CY, BY - (float)CY}, + {AZ - (float)CZ, BZ - (float)CZ}); + const FVFInterval Weight = VF_MultiplyNonNegativeInterval( + {WXMin[CX], WXMax[CX]}, + VF_MultiplyNonNegativeInterval( + {WYMin[CY], WYMax[CY]}, {WZMin[CZ], WZMax[CZ]})); + const FVFInterval Contribution = VF_MultiplyNonNegativeInterval(Weight, Grad); + ValueMin += Contribution.Min; + ValueMax += Contribution.Max; + } + + if (!FMath::IsFinite(ValueMin) || !FMath::IsFinite(ValueMax)) + { + return VF_PerlinAbsBound; + } + Bound = FMath::Max(Bound, FMath::Max(FMath::Abs(ValueMin), FMath::Abs(ValueMax))); + } + } + + // Cover the finite-precision rounding in the nested float lerps. If the interval proof is + // looser than the global theorem, retaining the theorem is both cheaper and safer. + Bound += 0.002f; + return FMath::Min(FMath::Max(Bound, 0.0f), VF_PerlinAbsBound); + } + + FORCEINLINE float VF_PerlinAbsBoundOverWorldBox( + float WorldMinX, float WorldMaxX, float WorldMinY, float WorldMaxY, + float EffectiveMinZ, float EffectiveMaxZ, float Frequency, + float OffsetX, float OffsetY, float OffsetZ) + { + const float X0 = WorldMinX * Frequency + OffsetX; + const float X1 = WorldMaxX * Frequency + OffsetX; + const float Y0 = WorldMinY * Frequency + OffsetY; + const float Y1 = WorldMaxY * Frequency + OffsetY; + const float Z0 = EffectiveMinZ * Frequency + OffsetZ; + const float Z1 = EffectiveMaxZ * Frequency + OffsetZ; + return VF_PerlinAbsBoundOverBox( + FVector3f(FMath::Min(X0, X1), FMath::Min(Y0, Y1), FMath::Min(Z0, Z1)), + FVector3f(FMath::Max(X0, X1), FMath::Max(Y0, Y1), FMath::Max(Z0, Z1))); + } + // Cellular3D returns 2*(F2-F1)-1 without a clamp. Every searched feature point is at most // sqrt(12) from the query's unit cell, so F2-F1 <= sqrt(12) and the positive supremum is // 2*sqrt(12)-1 = 5.9282... . Round that up for float evaluation instead of trusting the @@ -193,6 +371,74 @@ namespace return FMath::Sqrt(DX * DX + DY * DY); } + /** Distance from the nearest exact lattice coordinate in one axis to a point. The continuous + * box proof uses the interval [BoxMin, BoxMax]; the lattice proof uses only + * Origin + integer * Step values inside that interval. Extra is a per-axis displacement + * envelope (the cave warp); subtracting it keeps the result conservative. */ + FORCEINLINE float VF_LatticeAxisDistanceToPoint( + float BoxMin, float BoxMax, float Origin, float Step, float Target, float Extra) + { + if (!(Step > 0.0f) || !FMath::IsFinite(BoxMin) || !FMath::IsFinite(BoxMax) + || !FMath::IsFinite(Origin) || !FMath::IsFinite(Target) + || BoxMin > BoxMax) + { + return FLT_MAX; + } + + const int32 GMin = FMath::CeilToInt((BoxMin - Origin) / Step - 1.0e-4f); + const int32 GMax = FMath::FloorToInt((BoxMax - Origin) / Step + 1.0e-4f); + if (GMin > GMax) { return FLT_MAX; } + + const int32 Near = FMath::FloorToInt((Target - Origin) / Step); + float Distance = FLT_MAX; + auto Consider = [&](int32 G) + { + G = FMath::Clamp(G, GMin, GMax); + Distance = FMath::Min(Distance, + FMath::Abs((Origin + static_cast(G) * Step) - Target)); + }; + Consider(GMin); + Consider(GMax); + Consider(Near); + Consider(Near + 1); + return FMath::Max(0.0f, Distance - FMath::Max(Extra, 0.0f)); + } + + /** Same discrete distance, but to an axis-aligned interval. */ + FORCEINLINE float VF_LatticeAxisDistanceToInterval( + float BoxMin, float BoxMax, float Origin, float Step, + float TargetMin, float TargetMax, float Extra) + { + if (!(Step > 0.0f) || !FMath::IsFinite(TargetMin) || !FMath::IsFinite(TargetMax) + || TargetMin > TargetMax) + { + return FLT_MAX; + } + + const int32 GMin = FMath::CeilToInt((BoxMin - Origin) / Step - 1.0e-4f); + const int32 GMax = FMath::FloorToInt((BoxMax - Origin) / Step + 1.0e-4f); + if (GMin > GMax) { return FLT_MAX; } + + const int32 NearMin = FMath::FloorToInt((TargetMin - Origin) / Step); + const int32 NearMax = FMath::FloorToInt((TargetMax - Origin) / Step); + float Distance = FLT_MAX; + auto Consider = [&](int32 G) + { + G = FMath::Clamp(G, GMin, GMax); + const float Value = Origin + static_cast(G) * Step; + const float Delta = Value < TargetMin ? TargetMin - Value + : Value > TargetMax ? Value - TargetMax : 0.0f; + Distance = FMath::Min(Distance, Delta); + }; + Consider(GMin); + Consider(GMax); + Consider(NearMin); + Consider(NearMin + 1); + Consider(NearMax); + Consider(NearMax + 1); + return FMath::Max(0.0f, Distance - FMath::Max(Extra, 0.0f)); + } + FORCEINLINE float VF_SaturatingAdd(float A, float B) { if (!FMath::IsFinite(A) || !FMath::IsFinite(B)) { return FLT_MAX; } @@ -1631,15 +1877,26 @@ namespace // The room kills AllSolid; its support slab also kills AllAir. Identity when the room // misses the box or the box is wholly outside this strate's safe interior. - EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext&) const override + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, + const FVoxelOpContext& Ctx) const override { - if (!VoxelPassageGeometry::OriginLandingRoomTouchesBox( - VoxelBox, TopZ, BotZ, Seal, Radius)) + const bool bRoomTouches = Ctx.bUseLatticeProof + ? VoxelPassageGeometry::OriginLandingRoomTouchesLattice( + VoxelBox, Ctx.LatticeOriginVoxels, Ctx.Step, + TopZ, BotZ, Seal, Radius) + : VoxelPassageGeometry::OriginLandingRoomTouchesBox( + VoxelBox, TopZ, BotZ, Seal, Radius); + if (!bRoomTouches) { return EVoxelOpEffect::Identity; } - return VoxelPassageGeometry::OriginLandingFloorTouchesBox( - VoxelBox, TopZ, BotZ, Seal, Radius) + const bool bFloorTouches = Ctx.bUseLatticeProof + ? VoxelPassageGeometry::OriginLandingFloorTouchesLattice( + VoxelBox, Ctx.LatticeOriginVoxels, Ctx.Step, + TopZ, BotZ, Seal, Radius) + : VoxelPassageGeometry::OriginLandingFloorTouchesBox( + VoxelBox, TopZ, BotZ, Seal, Radius); + return bFloorTouches ? EVoxelOpEffect::Both : EVoxelOpEffect::CarveOnly; } @@ -1849,30 +2106,69 @@ namespace // ≡ la garde `AnyPassageNearBox` écrite à la main dans ClassifyTile — déjà écrite, ici // simplement branchée au bon endroit au lieu d'être un cas particulier du classifieur. - EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext&) const override + EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, + const FVoxelOpContext& Ctx) const override { const UVoxelStrateManager* LiveManager = Manager.Get(); if (!LiveManager) { return EVoxelOpEffect::Identity; } - if (VoxelPassageGeometry::OriginLandingFloorTouchesBox( - VoxelBox, TopZ, BotZ, Seal, SpineRadius) - || LiveManager->AnyPassageLandingFloorNearBox(VoxelBox.Min, VoxelBox.Max)) + const bool bOriginFloor = Ctx.bUseLatticeProof + ? VoxelPassageGeometry::OriginLandingFloorTouchesLattice( + VoxelBox, Ctx.LatticeOriginVoxels, Ctx.Step, + TopZ, BotZ, Seal, SpineRadius) + : VoxelPassageGeometry::OriginLandingFloorTouchesBox( + VoxelBox, TopZ, BotZ, Seal, SpineRadius); + const bool bPassageFloor = Ctx.bUseLatticeProof + ? LiveManager->AnyPassageLandingFloorNearLattice( + VoxelBox, Ctx.LatticeOriginVoxels, Ctx.Step) + : LiveManager->AnyPassageLandingFloorNearBox( + VoxelBox.Min, VoxelBox.Max); + if (bOriginFloor || bPassageFloor) { // The room carves air, while its support slab force-writes solid. Both // hypotheses must therefore be killed for a box touching that slab. return EVoxelOpEffect::Both; } - return LiveManager->AnyPassageNearBox(VoxelBox.Min, VoxelBox.Max) + const bool bPassageCarve = Ctx.bUseLatticeProof + ? LiveManager->AnyPassageNearLattice( + VoxelBox, Ctx.LatticeOriginVoxels, Ctx.Step) + : LiveManager->AnyPassageNearBox(VoxelBox.Min, VoxelBox.Max); + return bPassageCarve ? EVoxelOpEffect::CarveOnly : EVoxelOpEffect::Identity; } // The current-density Lerp/Min passage carve has no finite delta bound for an arbitrary - // incoming density. Keep that honest where it can affect a box, but expose exact identity - // for the overwhelmingly common no-passage case. - float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override + // incoming density. Once the preceding stack has proved a positive solid margin, + // however, the monotone carve can be bounded exactly on the MC lattice: evaluate the + // largest carve factor, apply it to the lower input bound, and keep the resulting positive + // margin. This preserves the old conservative fallback for non-lattice callers. + float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, + const FVoxelBoxHypotheses& H) const override { - const EVoxelOpEffect Effect = EffectOverBox(VoxelBox, Ctx); - return (Effect == EVoxelOpEffect::CarveOnly || Effect == EVoxelOpEffect::Both) - ? FLT_MAX : 0.0f; + const UVoxelStrateManager* LiveManager = Manager.Get(); + if (!LiveManager) { return 0.0f; } + if (!Ctx.bUseLatticeProof) + { + const EVoxelOpEffect Effect = EffectOverBox(VoxelBox, Ctx); + return (Effect == EVoxelOpEffect::CarveOnly || Effect == EVoxelOpEffect::Both) + ? FLT_MAX : 0.0f; + } + + const float MaxFactor = LiveManager->MaxPassageCarveFactorNearLattice( + VoxelBox, Ctx.LatticeOriginVoxels, Ctx.Step); + if (!(MaxFactor > 0.0f)) { return 0.0f; } + if (!(H.SolidMargin > 0.0f) || !FMath::IsFinite(H.SolidMargin) + || !FMath::IsFinite(Base) || !FMath::IsFinite(Seal)) + { + return FLT_MAX; + } + + const float AirTarget = -(Base * 2.0f + Seal + 4.0f); + if (!FMath::IsFinite(AirTarget)) { return FLT_MAX; } + const float LowerAfterCarve = FMath::Min( + H.SolidMargin, + FMath::Lerp(H.SolidMargin, AirTarget, FMath::Clamp(MaxFactor, 0.0f, 1.0f))); + if (!FMath::IsFinite(LowerAfterCarve)) { return FLT_MAX; } + return FMath::Max(0.0f, H.SolidMargin - LowerAfterCarve); } float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override @@ -2823,7 +3119,12 @@ namespace // 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. + // A coarse tile traverses several deterministic XY search windows while LODs are generated + // concurrently. One mixed table made fine and coarse windows evict one another before the + // next tile could reuse them. Keep a bounded table per region-size class: this is a cache + // partition only, not a change to the graph key or to graph construction. constexpr int32 RoomGraphCacheSlotCount = 128; + constexpr int32 RoomGraphCacheBankCount = 3; struct FRoomGraphCacheEntry { @@ -2837,10 +3138,19 @@ namespace int32 RegionMinX = 0; int32 RegionMinY = 0; int32 RegionSize = 0; + uint32 LastUse = 0; FChunkSDFCache Cache; }; - static thread_local FRoomGraphCacheEntry GRoomGraphCache[RoomGraphCacheSlotCount]; + static thread_local FRoomGraphCacheEntry GRoomGraphCache[ + RoomGraphCacheBankCount][RoomGraphCacheSlotCount]; + static thread_local uint32 GRoomGraphCacheClock = 0; + + FORCEINLINE int32 RoomGraphCacheBank(int32 RegionSize) + { + return RegionSize <= CHUNK_SIZE ? 0 + : (RegionSize <= 2 * CHUNK_SIZE ? 1 : 2); + } FORCEINLINE int32 RoomGraphCacheSlot( uint32 Seed, int32 StrateIndex, uint32 ParamsFingerprint, uint32 LayoutVersion, @@ -2864,17 +3174,22 @@ namespace uint64 ManagerLifetimeId, const TArray* TerrainOps, int32 RegionMinX, int32 RegionMinY, int32 RegionSize) { + const int32 Bank = RoomGraphCacheBank(RegionSize); + const uint32 Use = ++GRoomGraphCacheClock; const int32 StartSlot = RoomGraphCacheSlot( Seed, StrateIndex, ParamsFingerprint, LayoutVersion, ManagerLifetimeId, RegionMinX, RegionMinY, RegionSize); FRoomGraphCacheEntry* ReuseEntry = nullptr; + FRoomGraphCacheEntry* LeastRecentlyUsed = nullptr; for (int32 Probe = 0; Probe < RoomGraphCacheSlotCount; ++Probe) { - FRoomGraphCacheEntry& Candidate = GRoomGraphCache[ + FRoomGraphCacheEntry& Candidate = GRoomGraphCache[Bank][ (StartSlot + Probe) & (RoomGraphCacheSlotCount - 1)]; if (!Candidate.bValid) { - return ReuseEntry != nullptr ? ReuseEntry : &Candidate; + FRoomGraphCacheEntry* Selected = ReuseEntry != nullptr ? ReuseEntry : &Candidate; + Selected->LastUse = Use; + return Selected; } if (Candidate.Seed == Seed && Candidate.StrateIndex == StrateIndex @@ -2886,14 +3201,196 @@ namespace && Candidate.RegionMinY == RegionMinY && Candidate.RegionSize == RegionSize) { + Candidate.LastUse = Use; return &Candidate; } if (ReuseEntry == nullptr && Candidate.ManagerLifetimeId != ManagerLifetimeId) { ReuseEntry = &Candidate; } + if (LeastRecentlyUsed == nullptr || Candidate.LastUse < LeastRecentlyUsed->LastUse) + { + LeastRecentlyUsed = &Candidate; + } + } + FRoomGraphCacheEntry* Selected = ReuseEntry != nullptr + ? ReuseEntry : LeastRecentlyUsed; + Selected->LastUse = Use; + return Selected != nullptr ? Selected : &GRoomGraphCache[Bank][StartSlot]; + } + + // Classification is dispatched across many workers before most of those workers have + // evaluated a density sample. A thread-local cache prevents races, but it also makes every + // worker rebuild the same deterministic collect/NN graph. Keep a small process-local set of + // immutable classifier caches: construction is serialized, publication happens only after + // BuildChunkCache has completed, and each consumer holds a shared reference while refining. + // This cache never stores mutable manager state; all manager/terrain data are read while the + // entry is built and the resulting FChunkSDFCache is read-only thereafter. + struct FSharedRoomGraphCacheEntry + { + 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; + uint32 LastUse = 0; + bool bBuilding = false; + FEvent* ReadyEvent = nullptr; + TSharedPtr Cache; + + ~FSharedRoomGraphCacheEntry() + { + if (ReadyEvent != nullptr) + { + FPlatformProcess::ReturnSynchEventToPool(ReadyEvent); + } + } + }; + + static FCriticalSection GSharedRoomGraphCacheMutex; + static TArray> + GSharedRoomGraphCache; + static uint32 GSharedRoomGraphCacheClock = 0; + constexpr int32 SharedRoomGraphCacheMaxEntries = 128; + + TSharedPtr FindOrBuildSharedRoomGraphCache( + uint32 Seed, int32 StrateIndex, uint32 ParamsFingerprint, uint32 LayoutVersion, + uint64 ManagerLifetimeId, const TArray* TerrainOps, + int32 RegionMinX, int32 RegionMinY, int32 RegionSize, + float SearchMinX, float SearchMinY, float SearchMaxX, float SearchMaxY, + const FStrateGenerationParams& Params) + { + for (;;) + { + TSharedPtr Entry; + bool bBuild = false; + { + FScopeLock Lock(&GSharedRoomGraphCacheMutex); + const uint32 Use = ++GSharedRoomGraphCacheClock; + for (const TSharedPtr& Candidate + : GSharedRoomGraphCache) + { + 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) + { + Candidate->LastUse = Use; + Entry = Candidate; + break; + } + } + + if (!Entry.IsValid()) + { + Entry = MakeShared(); + Entry->Seed = Seed; + Entry->StrateIndex = StrateIndex; + Entry->ParamsFingerprint = ParamsFingerprint; + Entry->LayoutVersion = LayoutVersion; + Entry->ManagerLifetimeId = ManagerLifetimeId; + Entry->TerrainOps = TerrainOps; + Entry->RegionMinX = RegionMinX; + Entry->RegionMinY = RegionMinY; + Entry->RegionSize = RegionSize; + Entry->LastUse = Use; + Entry->bBuilding = true; + Entry->ReadyEvent = FPlatformProcess::GetSynchEventFromPool(true); + GSharedRoomGraphCache.Add(Entry); + bBuild = true; + if (RegionMinX == 0 && RegionMinY == 0 && RegionSize == 128) + { + UE_LOG(LogTemp, Warning, + TEXT("[Task4SharedCache] phase=create key=(%u,%d,%u,%u,%llu,%p,%d,%d,%d)"), + Seed, StrateIndex, ParamsFingerprint, LayoutVersion, + (unsigned long long)ManagerLifetimeId, TerrainOps, + RegionMinX, RegionMinY, RegionSize); + } + } + else if (RegionMinX == 0 && RegionMinY == 0 && RegionSize == 128) + { + UE_LOG(LogTemp, Warning, + TEXT("[Task4SharedCache] phase=found building=%d cache=%d"), + Entry->bBuilding ? 1 : 0, Entry->Cache.IsValid() ? 1 : 0); + } + } + + if (bBuild) + { + if (RegionMinX == 0 && RegionMinY == 0 && RegionSize == 128) + { + UE_LOG(LogTemp, Warning, TEXT("[Task4SharedCache] phase=build_begin")); + } + TSharedPtr BuiltCache = + MakeShared(); + VoxelCaveMorphology::BuildChunkCache( + *BuiltCache, SearchMinX, SearchMinY, SearchMaxX, SearchMaxY, + Params, Seed, StrateIndex, TerrainOps); + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::SdfCacheBuild); + } + + if (RegionMinX == 0 && RegionMinY == 0 && RegionSize == 128) + { + UE_LOG(LogTemp, Warning, TEXT("[Task4SharedCache] phase=build_done")); + } + + { + FScopeLock Lock(&GSharedRoomGraphCacheMutex); + Entry->Cache = BuiltCache; + Entry->bBuilding = false; + Entry->ReadyEvent->Trigger(); + if (RegionMinX == 0 && RegionMinY == 0 && RegionSize == 128) + { + UE_LOG(LogTemp, Warning, + TEXT("[Task4SharedCache] phase=published cache=%d"), + Entry->Cache.IsValid() ? 1 : 0); + } + if (GSharedRoomGraphCache.Num() > SharedRoomGraphCacheMaxEntries) + { + int32 OldestIndex = INDEX_NONE; + for (int32 Index = 0; Index < GSharedRoomGraphCache.Num(); ++Index) + { + const TSharedPtr& Candidate = + GSharedRoomGraphCache[Index]; + if (!Candidate->bBuilding + && (OldestIndex == INDEX_NONE + || Candidate->LastUse + < GSharedRoomGraphCache[OldestIndex]->LastUse)) + { + OldestIndex = Index; + } + } + if (OldestIndex != INDEX_NONE) + { + GSharedRoomGraphCache.RemoveAtSwap( + OldestIndex, 1, EAllowShrinking::No); + } + } + } + VoxelDensityOps::ReportWorkerRoomGraphCacheFootprint(); + return Entry; + } + + // A different worker is building this exact immutable key. Waiting on its event + // avoids duplicate graph work without serializing unrelated region keys. + if (RegionMinX == 0 && RegionMinY == 0 && RegionSize == 128) + { + UE_LOG(LogTemp, Warning, TEXT("[Task4SharedCache] phase=wait")); + } + Entry->ReadyEvent->Wait(); } - return ReuseEntry != nullptr ? ReuseEntry : &GRoomGraphCache[StartSlot]; } //========================================================================= @@ -2970,6 +3467,13 @@ namespace /** The unwarped position belonging to the current voxel. */ FVector LastWorldPosition = FVector::ZeroVector; + // The common generator tail runs after disturbances. The tunnel structural posts + // already evaluate this immutable result inside the stack; retain the exact same + // sample's answer so the tail does not scan the tunnel list a second time. + FTunnelCoreWorldEvaluation LastTunnelCoreWorldEvaluation; + FVector LastTunnelCoreEvaluationPosition = FVector::ZeroVector; + bool bLastTunnelCoreWorldEvaluationValid = false; + // Support-floor projection depends only on XY for one immutable SDF cache. The // mesher traverses Z outside X/Y, so keep a small worker-local LRU of XY boxes rather // than only the immediately previous column; each integer column is then projected @@ -3109,11 +3613,77 @@ namespace return P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f; } + const FTunnelSupportFloorColumn* GetSupportColumn(const FVector& Position) const + { + FState& S = State(); + const bool bIntegerXY = + Position.X == FMath::FloorToFloat(static_cast(Position.X)) + && Position.Y == FMath::FloorToFloat(static_cast(Position.Y)); + if (bIntegerXY) + { + const int32 IX = FMath::FloorToInt(static_cast(Position.X)); + const int32 IY = FMath::FloorToInt(static_cast(Position.Y)); + const FChunkSDFCache& Cache = GetCache(); + FState::FSupportColumnBox& Box = S.SupportColumns.AcquireBox( + IX, IY, &Cache); + bool bNewColumn = false; + FTunnelSupportFloorColumn* Column = Box.AcquireColumn( + IX, IY, &Cache, S.SupportColumns.Clock, bNewColumn); + if (bNewColumn) + { + VoxelCaveMorphology::BuildTunnelSupportFloorColumn( + static_cast(Position.X), + static_cast(Position.Y), + Cache, + *Column); + } + return Column; + } + + VoxelCaveMorphology::BuildTunnelSupportFloorColumn( + static_cast(Position.X), static_cast(Position.Y), + GetCache(), S.FractionalSupportColumn); + return &S.FractionalSupportColumn; + } + float GetTunnelCoreSDF() const { - const FVector& Position = State().LastWorldPosition; - return VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF( - Position.X, Position.Y, Position.Z, GetCache()); + FState& S = State(); + const FVector& Position = S.LastWorldPosition; + const FTunnelSupportFloorColumn* SupportColumn = GetSupportColumn(Position); + S.LastTunnelCoreWorldEvaluation = VoxelCaveMorphology::EvaluateTunnelCoreWorld( + Position.X, Position.Y, Position.Z, GetCache(), SupportColumn); + S.LastTunnelCoreEvaluationPosition = Position; + S.bLastTunnelCoreWorldEvaluationValid = true; + return S.LastTunnelCoreWorldEvaluation.SDF; + } + + bool TryGetLastTunnelCoreWorldEvaluation( + FTunnelCoreWorldEvaluation& OutEvaluation) const override + { + FState& S = State(); + if (!S.bLastTunnelCoreWorldEvaluationValid + || S.LastTunnelCoreEvaluationPosition != S.LastWorldPosition) + { + if (!(P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f)) + { + return false; + } + + // The common generator tail runs after EvalMC. The stack has already evaluated + // the graph source for this exact sample, but the native tunnel-core post is + // intentionally outside the stack because it must remain after disturbances. On + // demand, publish the one combined result here so the tail can reuse it without + // building/scanning a second native cache. + const FVector& Position = S.LastWorldPosition; + const FTunnelSupportFloorColumn* SupportColumn = GetSupportColumn(Position); + S.LastTunnelCoreWorldEvaluation = VoxelCaveMorphology::EvaluateTunnelCoreWorld( + Position.X, Position.Y, Position.Z, GetCache(), SupportColumn); + S.LastTunnelCoreEvaluationPosition = Position; + S.bLastTunnelCoreWorldEvaluationValid = true; + } + OutEvaluation = S.LastTunnelCoreWorldEvaluation; + return true; } /** @@ -3198,6 +3768,7 @@ namespace { FState& S = State(); S.LastWorldPosition = FVector(WorldX, WorldY, WorldZ); + S.bLastTunnelCoreWorldEvaluationValid = false; // ⚠️ REMIS À -1 INCONDITIONNELLEMENT, ce que l'original ne fait pas : chez lui // `NearestRoomIdx` est un `thread_local` qui, quand `RoomDensity <= 0`, garde la valeur @@ -3311,9 +3882,7 @@ namespace const int32 SampleStep = FMath::Max(VoxelGenLOD::SampleStep, 1); const int32 RegionChunks = SampleStep <= 1 ? 1 - : SampleStep >= 32 - ? 4 - : FMath::Clamp((SampleStep + 3) / 4, 1, 4); + : (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); @@ -3466,13 +4035,40 @@ namespace struct FBoxState { FChunkSDFCache Cache; + const FChunkSDFCache* ActiveCache = nullptr; FBox KeyBox = FBox(ForceInit); int32 KeyStrate = INT32_MIN; uint32 KeySeed = 0; uint32 KeyFingerprint = 0xFFFFFFFFu; uint32 KeyLayout = 0xFFFFFFFFu; uint64 KeyManagerLifetimeId = 0; + bool KeyUsesLatticeProof = false; + bool KeyTightenWarpProof = false; + FIntVector KeyLatticeOrigin = FIntVector::ZeroValue; + int32 KeyLatticeStep = 1; bool bValid = false; + + // A refined tile proof asks the same source about a root box and then many child + // boxes. The morphology cache is window-invariant, so keep the root's superset + // cache alive for all children instead of rebuilding the graph once per child. + FBox CacheWindowBox = FBox(ForceInit); + int32 CacheWindowStrate = INT32_MIN; + uint32 CacheWindowSeed = 0; + uint32 CacheWindowFingerprint = 0xFFFFFFFFu; + uint32 CacheWindowLayout = 0xFFFFFFFFu; + uint64 CacheWindowManagerLifetimeId = 0; + const TArray* CacheWindowTerrainOps = nullptr; + bool CacheWindowUsesLatticeProof = false; + bool CacheWindowTightenWarpProof = false; + FIntVector CacheWindowLatticeOrigin = FIntVector::ZeroValue; + int32 CacheWindowLatticeStep = 1; + TSharedPtr CacheWindowSharedEntry; + int32 CacheWindowSharedRegionMinX = 0; + int32 CacheWindowSharedRegionMinY = 0; + int32 CacheWindowSharedRegionSize = 0; + bool bCacheWindowUsesShared = false; + bool bCacheWindowValid = false; + EVoxelOpEffect Verdict = EVoxelOpEffect::Both; FVoxelBoxSdfInterval SdfInterval; @@ -3494,6 +4090,12 @@ namespace return S; } + const FChunkSDFCache& GetBoxCache() const + { + const FBoxState& B = BoxState(); + return B.ActiveCache != nullptr ? *B.ActiveCache : B.Cache; + } + /** * ✅ LA RÉPONSE SPATIALE. La dette annoncée ici pendant tout le portage est payée. * @@ -3556,7 +4158,7 @@ namespace { const FBoxState& B = BoxState(); if (!B.bValid) { return true; } - for (const FCachedRoom& Room : B.Cache.Rooms) + for (const FCachedRoom& Room : GetBoxCache().Rooms) { if (Room.RoomOp != nullptr) { return true; } } @@ -3566,7 +4168,7 @@ namespace bool HasRoomColumnsForLastBox() const { const FBoxState& B = BoxState(); - return !B.bValid || B.Cache.Columns.Num() > 0; + return !B.bValid || GetBoxCache().Columns.Num() > 0; } float RoomColumnFillSupremumForLastBox() const @@ -3575,7 +4177,7 @@ namespace if (!B.bValid) { return FLT_MAX; } float Supremum = 0.0f; - for (const FCachedColumn& Column : B.Cache.Columns) + for (const FCachedColumn& Column : GetBoxCache().Columns) { if (!FMath::IsFinite(Column.BaseDensity) || Column.BaseDensity < 0.0f) { @@ -3607,7 +4209,7 @@ namespace float Supremum = 0.0f; const float BoxMinZ = static_cast(B.KeyBox.Min.Z); if (!FMath::IsFinite(BoxMinZ)) { return FLT_MAX; } - for (const FCachedRoom& Room : B.Cache.Rooms) + for (const FCachedRoom& Room : GetBoxCache().Rooms) { const float CenterZ = static_cast(Room.Center.Z); const float RadiusZ = Room.RadiusZ; @@ -3643,10 +4245,30 @@ namespace { FBoxState& B = BoxState(); const UVoxelStrateManager* LiveManager = Manager.Get(); + const bool bTask4SourceProbe = Ctx.bUseLatticeProof + && Ctx.LatticeOriginVoxels == FIntVector(32, 0, -32) + && Ctx.Step == 1; + const uint64 Task4SourceStartCycles = bTask4SourceProbe + ? FPlatformTime::Cycles64() : 0; + if (bTask4SourceProbe) + { + UE_LOG(LogTemp, Warning, + TEXT("[Task4SourceProbe] phase=enter box=(%.0f,%.0f,%.0f)-(%.0f,%.0f,%.0f) tight=%d"), + (float)VoxelBox.Min.X, (float)VoxelBox.Min.Y, (float)VoxelBox.Min.Z, + (float)VoxelBox.Max.X, (float)VoxelBox.Max.Y, (float)VoxelBox.Max.Z, + Ctx.bTightenWarpProof ? 1 : 0); + } auto Unknown = [&]() { B.bValid = false; + B.ActiveCache = nullptr; + B.CacheWindowSharedEntry.Reset(); + B.CacheWindowSharedRegionMinX = 0; + B.CacheWindowSharedRegionMinY = 0; + B.CacheWindowSharedRegionSize = 0; + B.bCacheWindowUsesShared = false; + B.bCacheWindowValid = false; B.SdfInterval.SetUnknown(); InOut.SetUnknown(); }; @@ -3695,6 +4317,10 @@ namespace if (!(P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f)) { B.bValid = false; + B.ActiveCache = nullptr; + B.CacheWindowSharedEntry.Reset(); + B.bCacheWindowUsesShared = false; + B.bCacheWindowValid = false; B.NumRooms = B.NumTunnels = B.NumPits = B.NumChimneys = 0; B.HitRooms = B.HitTunnels = B.HitPits = B.HitChimneys = 0; B.HitRoomsNoWarp = B.HitTunnelsNoWarp = 0; @@ -3722,7 +4348,19 @@ namespace const int64 SpanX = (int64)CX1 - (int64)CX0 + 1; const int64 SpanY = (int64)CY1 - (int64)CY0 + 1; const int64 SpanZ = (int64)CZ1 - (int64)CZ0 + 1; - if (SpanX <= 0 || SpanY <= 0 || SpanZ <= 0 || SpanX * SpanY * SpanZ > 64) + if (SpanX <= 0 || SpanY <= 0 || SpanZ <= 0) + { + Unknown(); + return; + } + + // The ordinary (non-lattice) proof keeps its original bounded query contract. A + // tile proof is different: its first query is the root box and its descendants are + // all inside that box. Build the root cache once even when the root spans more than + // 64 chunk keys, then let the bounded child boxes use that same superset. This is + // what prevents a step-32 tile from paying one graph bake per coarse sample. + constexpr int64 MaxNonLatticeChunkSpan = 64; + if (!Ctx.bUseLatticeProof && SpanX * SpanY * SpanZ > MaxNonLatticeChunkSpan) { Unknown(); return; @@ -3762,38 +4400,255 @@ namespace if (Def0) { TerrainOps = &Def0->TerrainOperations; } } + if (bTask4SourceProbe) + { + UE_LOG(LogTemp, Warning, + TEXT("[Task4SourceProbe] phase=layout box=(%.0f,%.0f,%.0f)-(%.0f,%.0f,%.0f) chunks=(%d,%d,%d)-(%d,%d,%d) strate=%d"), + BoxMinX, BoxMinY, BoxMinZ, BoxMaxX, BoxMaxY, BoxMaxZ, + CX0, CY0, CZ0, CX1, CY1, CZ1, StrateIdx); + } + const uint32 LV = Ctx.LayoutVersion; + auto IsSharedCacheCurrent = [&]() -> bool + { + const FSharedRoomGraphCacheEntry* Entry = B.CacheWindowSharedEntry.Get(); + return Entry != nullptr + && Entry->Seed == SeedU + && Entry->StrateIndex == StrateIdx + && Entry->ParamsFingerprint == ParamsFingerprint + && Entry->LayoutVersion == LayoutVersion + && Entry->ManagerLifetimeId == ManagerLifetimeId + && Entry->TerrainOps == TerrainOps + && Entry->RegionMinX == B.CacheWindowSharedRegionMinX + && Entry->RegionMinY == B.CacheWindowSharedRegionMinY + && Entry->RegionSize == B.CacheWindowSharedRegionSize; + }; if (B.bValid && B.KeyBox == VoxelBox && B.KeyStrate == StrateIdx && B.KeySeed == SeedU && B.KeyFingerprint == ParamsFingerprint && B.KeyLayout == LV - && B.KeyManagerLifetimeId == ManagerLifetimeId) + && B.KeyManagerLifetimeId == ManagerLifetimeId + && B.KeyUsesLatticeProof == Ctx.bUseLatticeProof + && (!B.bCacheWindowUsesShared || IsSharedCacheCurrent()) + && B.KeyTightenWarpProof == Ctx.bTightenWarpProof + && (!Ctx.bUseLatticeProof + || (B.KeyLatticeOrigin == Ctx.LatticeOriginVoxels + && B.KeyLatticeStep == Ctx.Step))) { + if (bTask4SourceProbe) + { + UE_LOG(LogTemp, Warning, + TEXT("[Task4SourceProbe] phase=box_cache_hit box=(%.0f,%.0f,%.0f)-(%.0f,%.0f,%.0f) ms=%.3f"), + BoxMinX, BoxMinY, BoxMinZ, BoxMaxX, BoxMaxY, BoxMaxZ, + FPlatformTime::ToMilliseconds64( + FPlatformTime::Cycles64() - Task4SourceStartCycles)); + } InOut = B.SdfInterval; return; } - const float Warp = (P.CaveWarpStrength > 0.0f) - ? P.CaveWarpStrength * VOXEL_NOISE_SCALE * VF_PerlinAbsBound - : 0.0f; - if (!Finite(Warp)) - { - Unknown(); - return; - } - - // The search window is a superset of every warped point in the box, with the same - // two-voxel gradient margin used by Eval's cache path. - VoxelCaveMorphology::BuildChunkCache( - B.Cache, - BoxMinX - Warp - 2.0f, BoxMinY - Warp - 2.0f, - BoxMaxX + Warp + 2.0f, BoxMaxY + Warp + 2.0f, - P, SeedU, StrateIdx, TerrainOps); - const float EffectiveMinZ = (P.VerticalScale > 0.0f && P.VerticalScale != 1.0f) ? BoxMinZ / P.VerticalScale : BoxMinZ; const float EffectiveMaxZ = (P.VerticalScale > 0.0f && P.VerticalScale != 1.0f) ? BoxMaxZ / P.VerticalScale : BoxMaxZ; - const FVector QMin(BoxMinX - Warp, BoxMinY - Warp, EffectiveMinZ - Warp); - const FVector QMax(BoxMaxX + Warp, BoxMaxY + Warp, EffectiveMaxZ + Warp); + const float GlobalWarp = (P.CaveWarpStrength > 0.0f) + ? P.CaveWarpStrength * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + : 0.0f; + if (!Finite(GlobalWarp) || !Finite(EffectiveMinZ) || !Finite(EffectiveMaxZ)) + { + Unknown(); + return; + } + + FVector WarpEnvelope(GlobalWarp, GlobalWarp, GlobalWarp); + if (Ctx.bTightenWarpProof && P.CaveWarpStrength > 0.0f + && P.CaveWarpFrequency > 0.0f) + { + const float WarpAmplitude = P.CaveWarpStrength * VOXEL_NOISE_SCALE; + WarpEnvelope.X = WarpAmplitude * VF_PerlinAbsBoundOverWorldBox( + BoxMinX, BoxMaxX, BoxMinY, BoxMaxY, EffectiveMinZ, EffectiveMaxZ, + P.CaveWarpFrequency, VoxelHash::SeedOffset(SeedU, 0.37f), 1.3f, 5.7f); + WarpEnvelope.Y = WarpAmplitude * VF_PerlinAbsBoundOverWorldBox( + BoxMinX, BoxMaxX, BoxMinY, BoxMaxY, EffectiveMinZ, EffectiveMaxZ, + P.CaveWarpFrequency, 7.1f, VoxelHash::SeedOffset(SeedU, 0.59f), 2.3f); + WarpEnvelope.Z = WarpAmplitude * VF_PerlinAbsBoundOverWorldBox( + BoxMinX, BoxMaxX, BoxMinY, BoxMaxY, EffectiveMinZ, EffectiveMaxZ, + P.CaveWarpFrequency, 11.3f, 9.7f, VoxelHash::SeedOffset(SeedU, 0.41f)); + if (!Finite((float)WarpEnvelope.X) || !Finite((float)WarpEnvelope.Y) + || !Finite((float)WarpEnvelope.Z)) + { + Unknown(); + return; + } + } + + // The search window is a superset of every warped point in the box, with the same + // two-voxel gradient margin used by Eval's cache path. In lattice mode, retain the + // first/root window and reuse it for every refined child. A larger morphology + // window is sound for a child because BuildChunkCache's collect/store contract is + // explicitly window-invariant; it is also the only way to keep refinement from + // turning into one graph build per child box. + const bool bWindowKeyMatches = B.bCacheWindowValid + && B.CacheWindowStrate == StrateIdx + && B.CacheWindowSeed == SeedU + && B.CacheWindowFingerprint == ParamsFingerprint + && B.CacheWindowLayout == LV + && B.CacheWindowManagerLifetimeId == ManagerLifetimeId + && B.CacheWindowTerrainOps == TerrainOps + && B.CacheWindowUsesLatticeProof == Ctx.bUseLatticeProof + // A non-tight cache is built from the global warp envelope and is therefore a + // superset cache for a later tight retry. The reverse is not safe: a tight cache + // may omit primitives needed by the conservative global-envelope query. + && (!Ctx.bUseLatticeProof + // Tight queries may use either an equally tight cache or the larger global + // envelope cache. A global query must not reuse a tight cache. + || Ctx.bTightenWarpProof + || !B.CacheWindowTightenWarpProof) + && (!Ctx.bUseLatticeProof + || (B.CacheWindowLatticeOrigin == Ctx.LatticeOriginVoxels + && B.CacheWindowLatticeStep == Ctx.Step)) + && (!B.bCacheWindowUsesShared || IsSharedCacheCurrent()) + && B.CacheWindowBox.Min.X <= BoxMinX + && B.CacheWindowBox.Min.Y <= BoxMinY + && B.CacheWindowBox.Min.Z <= BoxMinZ + && B.CacheWindowBox.Max.X >= BoxMaxX + && B.CacheWindowBox.Max.Y >= BoxMaxY + && B.CacheWindowBox.Max.Z >= BoxMaxZ; + + if (!bWindowKeyMatches) + { + B.ActiveCache = nullptr; + B.CacheWindowSharedEntry.Reset(); + B.CacheWindowSharedRegionMinX = 0; + B.CacheWindowSharedRegionMinY = 0; + B.CacheWindowSharedRegionSize = 0; + B.bCacheWindowUsesShared = false; + + // The morphology builder is window-invariant. For the bounded lattice boxes, + // reuse the worker-local graph cache used by Eval, but at a 128/256-voxel region + // granularity. A classifier root otherwise rebuilds the same collect/NN graph + // for every neighbouring tile before the regular density path has a chance to + // populate its cache. The shared window is always built from the global warp + // envelope, so it is a superset of this tight query and can only add conservative + // primitives to the interval. + bool bUsedSharedCache = false; + if (Ctx.bUseLatticeProof && SpanX <= 8 && SpanY <= 8) + { + int32 SharedRegionSize = 4 * CHUNK_SIZE; + int32 SharedRegionMinX = 0; + int32 SharedRegionMinY = 0; + bool bCoversBox = false; + for (int32 Attempt = 0; Attempt < 3; ++Attempt) + { + SharedRegionMinX = FMath::FloorToInt( + BoxMinX / (float)SharedRegionSize) * SharedRegionSize; + SharedRegionMinY = FMath::FloorToInt( + BoxMinY / (float)SharedRegionSize) * SharedRegionSize; + bCoversBox = BoxMaxX <= (float)(SharedRegionMinX + SharedRegionSize) + && BoxMaxY <= (float)(SharedRegionMinY + SharedRegionSize); + if (bCoversBox) { break; } + SharedRegionSize *= 2; + } + + if (bCoversBox && SharedRegionSize <= 8 * CHUNK_SIZE) + { + const float Expansion = FMath::Abs(P.CaveWarpStrength) + * VOXEL_NOISE_SCALE * VF_PerlinAbsBound + 2.0f; + if (bTask4SourceProbe) + { + UE_LOG(LogTemp, Warning, + TEXT("[Task4SourceProbe] phase=shared_begin region=(%d,%d,%d) ms=%.3f"), + SharedRegionMinX, SharedRegionMinY, SharedRegionSize, + FPlatformTime::ToMilliseconds64( + FPlatformTime::Cycles64() - Task4SourceStartCycles)); + } + TSharedPtr SharedCache = + FindOrBuildSharedRoomGraphCache( + SeedU, StrateIdx, ParamsFingerprint, LayoutVersion, + ManagerLifetimeId, TerrainOps, + SharedRegionMinX, SharedRegionMinY, SharedRegionSize, + (float)SharedRegionMinX - Expansion, + (float)SharedRegionMinY - Expansion, + (float)(SharedRegionMinX + SharedRegionSize) + Expansion, + (float)(SharedRegionMinY + SharedRegionSize) + Expansion, + P); + B.ActiveCache = SharedCache->Cache.Get(); + B.CacheWindowSharedEntry = SharedCache; + B.CacheWindowSharedRegionMinX = SharedRegionMinX; + B.CacheWindowSharedRegionMinY = SharedRegionMinY; + B.CacheWindowSharedRegionSize = SharedRegionSize; + B.bCacheWindowUsesShared = true; + bUsedSharedCache = true; + if (bTask4SourceProbe) + { + UE_LOG(LogTemp, Warning, + TEXT("[Task4SourceProbe] phase=shared_ready box=(%.0f,%.0f,%.0f)-(%.0f,%.0f,%.0f) ms=%.3f rooms=%d tunnels=%d"), + BoxMinX, BoxMinY, BoxMinZ, BoxMaxX, BoxMaxY, BoxMaxZ, + FPlatformTime::ToMilliseconds64( + FPlatformTime::Cycles64() - Task4SourceStartCycles), + SharedCache->Cache->Rooms.Num(), SharedCache->Cache->Tunnels.Num()); + } + } + } + + if (!bUsedSharedCache) + { + VoxelCaveMorphology::BuildChunkCache( + B.Cache, + BoxMinX - (float)WarpEnvelope.X - 2.0f, + BoxMinY - (float)WarpEnvelope.Y - 2.0f, + BoxMaxX + (float)WarpEnvelope.X + 2.0f, + BoxMaxY + (float)WarpEnvelope.Y + 2.0f, + P, SeedU, StrateIdx, TerrainOps); + B.ActiveCache = &B.Cache; + } + + B.CacheWindowBox = VoxelBox; + B.CacheWindowStrate = StrateIdx; + B.CacheWindowSeed = SeedU; + B.CacheWindowFingerprint = ParamsFingerprint; + B.CacheWindowLayout = LV; + B.CacheWindowManagerLifetimeId = ManagerLifetimeId; + B.CacheWindowTerrainOps = TerrainOps; + B.CacheWindowUsesLatticeProof = Ctx.bUseLatticeProof; + B.CacheWindowTightenWarpProof = bUsedSharedCache + ? false : Ctx.bTightenWarpProof; + B.CacheWindowLatticeOrigin = Ctx.LatticeOriginVoxels; + B.CacheWindowLatticeStep = Ctx.Step; + B.bCacheWindowValid = true; + } + else if (B.bCacheWindowUsesShared) + { + B.ActiveCache = B.CacheWindowSharedEntry->Cache.Get(); + } + + // Large root boxes are only a cache warm-up. Their interval is intentionally still + // unknown; proof resumes at bounded descendants where all bounds below are finite. + if (SpanX * SpanY * SpanZ > 64) + { + // Keep the freshly built window alive for the descendants. This is deliberately + // different from Unknown(): the root is not invalid, it is merely too large to + // publish a finite interval in one pass. + B.bValid = false; + B.SdfInterval.SetUnknown(); + InOut.SetUnknown(); + return; + } + + if (bTask4SourceProbe) + { + UE_LOG(LogTemp, Warning, + TEXT("[Task4SourceProbe] phase=bounded box=(%.0f,%.0f,%.0f)-(%.0f,%.0f,%.0f) ms=%.3f rooms=%d tunnels=%d"), + BoxMinX, BoxMinY, BoxMinZ, BoxMaxX, BoxMaxY, BoxMaxZ, + FPlatformTime::ToMilliseconds64( + FPlatformTime::Cycles64() - Task4SourceStartCycles), + GetBoxCache().Rooms.Num(), GetBoxCache().Tunnels.Num()); + } + + const FVector QMin(BoxMinX - (float)WarpEnvelope.X, + BoxMinY - (float)WarpEnvelope.Y, + EffectiveMinZ - (float)WarpEnvelope.Z); + const FVector QMax(BoxMaxX + (float)WarpEnvelope.X, + BoxMaxY + (float)WarpEnvelope.Y, + EffectiveMaxZ + (float)WarpEnvelope.Z); const FVector RMin(BoxMinX, BoxMinY, BoxMinZ); const FVector RMax(BoxMaxX, BoxMaxY, BoxMaxZ); if (!Finite((float)QMin.X) || !Finite((float)QMin.Y) || !Finite((float)QMin.Z) @@ -3803,13 +4658,743 @@ namespace return; } + // In lattice mode the queried box is a proof domain for the exact MC samples, not + // for every continuous point between them. The room/tunnel bounds below therefore + // use the nearest lattice coordinate in each axis and subtract the same per-axis warp + // envelope as the continuous proof. A sub-box containing no lattice point has no + // samples to constrain and contributes the exact SDF identity. + const bool bUseLatticeProof = Ctx.bUseLatticeProof; + const float LatticeOriginX = static_cast(Ctx.LatticeOriginVoxels.X); + const float LatticeOriginY = static_cast(Ctx.LatticeOriginVoxels.Y); + const float LatticeOriginZ = (P.VerticalScale > 0.0f && P.VerticalScale != 1.0f) + ? static_cast(Ctx.LatticeOriginVoxels.Z) / P.VerticalScale + : static_cast(Ctx.LatticeOriginVoxels.Z); + const float LatticeStepXY = static_cast(Ctx.Step); + const float LatticeStepZ = (P.VerticalScale > 0.0f && P.VerticalScale != 1.0f) + ? static_cast(Ctx.Step) / P.VerticalScale + : static_cast(Ctx.Step); + if (bUseLatticeProof) + { + auto HasAxisSample = [](float Min, float Max, float Origin, float Step) -> bool + { + return Step > 0.0f + && FMath::CeilToInt((Min - Origin) / Step - 1.0e-4f) + <= FMath::FloorToInt((Max - Origin) / Step + 1.0e-4f); + }; + if (!HasAxisSample(BoxMinX, BoxMaxX, LatticeOriginX, LatticeStepXY) + || !HasAxisSample(BoxMinY, BoxMaxY, LatticeOriginY, LatticeStepXY) + || !HasAxisSample(BoxMinZ, BoxMaxZ, LatticeOriginZ, LatticeStepZ)) + { + B.bValid = false; + B.ActiveCache = nullptr; + B.CacheWindowSharedEntry.Reset(); + B.bCacheWindowUsesShared = false; + B.bCacheWindowValid = false; + B.SdfInterval.Set(FLT_MAX, FLT_MAX); + InOut = B.SdfInterval; + return; + } + } + + // A tight retry is a proof over the exact lattice the mesher will read. Keep both + // coordinate spaces: the room graph is queried in warped SDF space, while pits and + // chimneys deliberately use the original world-space sample. The old implementation + // retained only an AABB at LOD0; that still admitted a tunnel when the warped samples + // themselves missed it. The exact primitive minima below remove that false overlap + // without changing the lattice or the density evaluator used by the mesher. + TArray ExactLatticeWarpedQueries; + TArray ExactLatticeWorldQueries; + FVector ExactLatticeQueryMin(FLT_MAX, FLT_MAX, FLT_MAX); + FVector ExactLatticeQueryMax(-FLT_MAX, -FLT_MAX, -FLT_MAX); + int32 ExactIX0 = 0, ExactIY0 = 0, ExactIZ0 = 0; + int32 ExactNX = 0, ExactNY = 0, ExactNZ = 0; + struct FExactLatticeBlock + { + int32 IX0 = 0, IX1 = -1; + int32 IY0 = 0, IY1 = -1; + int32 IZ0 = 0, IZ1 = -1; + FVector WarpedMin = FVector(FLT_MAX, FLT_MAX, FLT_MAX); + FVector WarpedMax = FVector(-FLT_MAX, -FLT_MAX, -FLT_MAX); + FVector WorldMin = FVector(FLT_MAX, FLT_MAX, FLT_MAX); + FVector WorldMax = FVector(-FLT_MAX, -FLT_MAX, -FLT_MAX); + }; + TArray> ExactLatticeBlocks; + const float MaxBoxExtent = FMath::Max3( + BoxMaxX - BoxMinX, BoxMaxY - BoxMinY, BoxMaxZ - BoxMinZ); + const float ExactChildExtent = 16.0f * (float)FMath::Max(Ctx.Step, 1); + const bool bTryExactLatticeWarp = bUseLatticeProof && Ctx.bTightenWarpProof + // The root is a cache warm-up. Exact warped lattice distances are only needed + // once the refinement has reduced the domain to a child; this avoids rebuilding + // a 33^3 query list before the eight 17^3 child proofs that actually decide the + // residual solid tile. A non-exact root remains conservative and cannot create a + // skip on its own. + // Strictly smaller excludes the tile root when it is exactly one coarse child + // wide (the common 16-cell LOD0 case). The root is only a cache warm-up; doing + // a full 17^3 warped-query scan there paid the expensive exact primitive loop + // once per empty tile before refinement had had a chance to narrow the box. + && MaxBoxExtent < ExactChildExtent; + if (bTryExactLatticeWarp) + { + const int32 IX0 = FMath::CeilToInt( + (BoxMinX - LatticeOriginX) / LatticeStepXY - 1.0e-4f); + const int32 IY0 = FMath::CeilToInt( + (BoxMinY - LatticeOriginY) / LatticeStepXY - 1.0e-4f); + const int32 IZ0 = FMath::CeilToInt( + (BoxMinZ - static_cast(Ctx.LatticeOriginVoxels.Z)) + / static_cast(Ctx.Step) - 1.0e-4f); + const int32 IX1 = FMath::FloorToInt( + (BoxMaxX - LatticeOriginX) / LatticeStepXY + 1.0e-4f); + const int32 IY1 = FMath::FloorToInt( + (BoxMaxY - LatticeOriginY) / LatticeStepXY + 1.0e-4f); + const int32 IZ1 = FMath::FloorToInt( + (BoxMaxZ - static_cast(Ctx.LatticeOriginVoxels.Z)) + / static_cast(Ctx.Step) + 1.0e-4f); + const int64 Count = ((int64)IX1 - IX0 + 1) + * ((int64)IY1 - IY0 + 1) + * ((int64)IZ1 - IZ0 + 1); + if (IX1 >= IX0 && IY1 >= IY0 && IZ1 >= IZ0 + && Count > 0 && Count <= 100000) + { + ExactIX0 = IX0; + ExactIY0 = IY0; + ExactIZ0 = IZ0; + ExactNX = IX1 - IX0 + 1; + ExactNY = IY1 - IY0 + 1; + ExactNZ = IZ1 - IZ0 + 1; + ExactLatticeWarpedQueries.Reserve(static_cast(Count)); + ExactLatticeWorldQueries.Reserve(static_cast(Count)); + const float WorldOriginZ = static_cast(Ctx.LatticeOriginVoxels.Z); + const float WarpAmplitude = P.CaveWarpStrength * VOXEL_NOISE_SCALE; + for (int32 IZ = IZ0; IZ <= IZ1; ++IZ) + for (int32 IY = IY0; IY <= IY1; ++IY) + for (int32 IX = IX0; IX <= IX1; ++IX) + { + const float WorldX = LatticeOriginX + (float)IX * LatticeStepXY; + const float WorldY = LatticeOriginY + (float)IY * LatticeStepXY; + const float WorldZ = WorldOriginZ + (float)IZ * (float)Ctx.Step; + const float EffectiveZ = (P.VerticalScale > 0.0f + && P.VerticalScale != 1.0f) + ? WorldZ / P.VerticalScale : WorldZ; + ExactLatticeWorldQueries.Add(FVector(WorldX, WorldY, WorldZ)); + FVector Query(WorldX, WorldY, EffectiveZ); + if (P.CaveWarpStrength > 0.0f) + { + const float Frequency = P.CaveWarpFrequency; + Query.X += VoxelNoise::Perlin3D( + WorldX * Frequency + VoxelHash::SeedOffset(SeedU, 0.37f), + WorldY * Frequency + 1.3f, + EffectiveZ * Frequency + 5.7f) * WarpAmplitude; + Query.Y += VoxelNoise::Perlin3D( + WorldX * Frequency + 7.1f, + WorldY * Frequency + VoxelHash::SeedOffset(SeedU, 0.59f), + EffectiveZ * Frequency + 2.3f) * WarpAmplitude; + Query.Z += VoxelNoise::Perlin3D( + WorldX * Frequency + 11.3f, + WorldY * Frequency + 9.7f, + EffectiveZ * Frequency + VoxelHash::SeedOffset(SeedU, 0.41f)) + * WarpAmplitude; + } + ExactLatticeWarpedQueries.Add(Query); + ExactLatticeQueryMin.X = FMath::Min(ExactLatticeQueryMin.X, (float)Query.X); + ExactLatticeQueryMin.Y = FMath::Min(ExactLatticeQueryMin.Y, (float)Query.Y); + ExactLatticeQueryMin.Z = FMath::Min(ExactLatticeQueryMin.Z, (float)Query.Z); + ExactLatticeQueryMax.X = FMath::Max(ExactLatticeQueryMax.X, (float)Query.X); + ExactLatticeQueryMax.Y = FMath::Max(ExactLatticeQueryMax.Y, (float)Query.Y); + ExactLatticeQueryMax.Z = FMath::Max(ExactLatticeQueryMax.Z, (float)Query.Z); + } + + constexpr int32 BlockAxisSize = 2; + ExactLatticeBlocks.Reserve( + ((ExactNX + BlockAxisSize - 1) / BlockAxisSize) + * ((ExactNY + BlockAxisSize - 1) / BlockAxisSize) + * ((ExactNZ + BlockAxisSize - 1) / BlockAxisSize)); + for (int32 IZBlock = ExactIZ0; IZBlock <= IZ1; IZBlock += BlockAxisSize) + for (int32 IYBlock = ExactIY0; IYBlock <= IY1; IYBlock += BlockAxisSize) + for (int32 IXBlock = ExactIX0; IXBlock <= IX1; IXBlock += BlockAxisSize) + { + FExactLatticeBlock& Block = ExactLatticeBlocks.AddDefaulted_GetRef(); + Block.IX0 = IXBlock; + Block.IX1 = FMath::Min(IXBlock + BlockAxisSize, IX1); + Block.IY0 = IYBlock; + Block.IY1 = FMath::Min(IYBlock + BlockAxisSize, IY1); + Block.IZ0 = IZBlock; + Block.IZ1 = FMath::Min(IZBlock + BlockAxisSize, IZ1); + for (int32 IZ = Block.IZ0; IZ <= Block.IZ1; ++IZ) + for (int32 IY = Block.IY0; IY <= Block.IY1; ++IY) + for (int32 IX = Block.IX0; IX <= Block.IX1; ++IX) + { + const int32 FlatIndex = + ((IZ - ExactIZ0) * ExactNY + (IY - ExactIY0)) * ExactNX + + (IX - ExactIX0); + const FVector& Query = ExactLatticeWarpedQueries[FlatIndex]; + const FVector& World = ExactLatticeWorldQueries[FlatIndex]; + Block.WarpedMin.X = FMath::Min(Block.WarpedMin.X, (float)Query.X); + Block.WarpedMin.Y = FMath::Min(Block.WarpedMin.Y, (float)Query.Y); + Block.WarpedMin.Z = FMath::Min(Block.WarpedMin.Z, (float)Query.Z); + Block.WarpedMax.X = FMath::Max(Block.WarpedMax.X, (float)Query.X); + Block.WarpedMax.Y = FMath::Max(Block.WarpedMax.Y, (float)Query.Y); + Block.WarpedMax.Z = FMath::Max(Block.WarpedMax.Z, (float)Query.Z); + Block.WorldMin.X = FMath::Min(Block.WorldMin.X, (float)World.X); + Block.WorldMin.Y = FMath::Min(Block.WorldMin.Y, (float)World.Y); + Block.WorldMin.Z = FMath::Min(Block.WorldMin.Z, (float)World.Z); + Block.WorldMax.X = FMath::Max(Block.WorldMax.X, (float)World.X); + Block.WorldMax.Y = FMath::Max(Block.WorldMax.Y, (float)World.Y); + Block.WorldMax.Z = FMath::Max(Block.WorldMax.Z, (float)World.Z); + } + } + } + } + + const bool bUseExactLatticeWarp = ExactLatticeWarpedQueries.Num() > 0; + const bool bUseExactLatticeBox = !bUseExactLatticeWarp + && ExactLatticeQueryMin.X <= ExactLatticeQueryMax.X + && ExactLatticeQueryMin.Y <= ExactLatticeQueryMax.Y + && ExactLatticeQueryMin.Z <= ExactLatticeQueryMax.Z; + bool bInvalidBound = false; + float ExactSdfLower = FLT_MAX; + float CheapSdfLower = FLT_MAX; + int32 ExactRoomRefinements = 0; + int32 ExactJoinRefinements = 0; + int32 ExactTunnelRefinements = 0; + int32 ExactPitRefinements = 0; + int32 ExactChimneyRefinements = 0; + + auto ExactLatticePointDistance = [&](const FVector& Target) -> float + { + float Distance = FLT_MAX; + for (const FVector& Query : ExactLatticeWarpedQueries) + { + Distance = FMath::Min(Distance, (float)FVector::Dist(Query, Target)); + } + return FMath::Max(0.0f, Distance - 0.001f); + }; + + auto ExactLatticeBoxDistance = [&](const FVector& TargetMin, + const FVector& TargetMax) -> float + { + float Distance = FLT_MAX; + for (const FVector& Query : ExactLatticeWarpedQueries) + { + Distance = FMath::Min( + Distance, VF_DistanceBetweenBoxes(Query, Query, TargetMin, TargetMax)); + } + return FMath::Max(0.0f, Distance - 0.001f); + }; + + auto ForEachExactQueryInWorldBox = [&](const FVector& WorldMin, + const FVector& WorldMax, + auto&& Callback) + { + if (ExactNX <= 0 || ExactNY <= 0 || ExactNZ <= 0) { return; } + const int32 IX0 = FMath::Max( + ExactIX0, + FMath::CeilToInt( + (WorldMin.X - LatticeOriginX) / LatticeStepXY - 1.0e-4f)); + const int32 IY0 = FMath::Max( + ExactIY0, + FMath::CeilToInt( + (WorldMin.Y - LatticeOriginY) / LatticeStepXY - 1.0e-4f)); + const int32 IZ0 = FMath::Max( + ExactIZ0, + FMath::CeilToInt( + (WorldMin.Z - static_cast(Ctx.LatticeOriginVoxels.Z)) + / static_cast(Ctx.Step) - 1.0e-4f)); + const int32 IX1 = FMath::Min( + ExactIX0 + ExactNX - 1, + FMath::FloorToInt( + (WorldMax.X - LatticeOriginX) / LatticeStepXY + 1.0e-4f)); + const int32 IY1 = FMath::Min( + ExactIY0 + ExactNY - 1, + FMath::FloorToInt( + (WorldMax.Y - LatticeOriginY) / LatticeStepXY + 1.0e-4f)); + const int32 IZ1 = FMath::Min( + ExactIZ0 + ExactNZ - 1, + FMath::FloorToInt( + (WorldMax.Z - static_cast(Ctx.LatticeOriginVoxels.Z)) + / static_cast(Ctx.Step) + 1.0e-4f)); + if (IX1 < IX0 || IY1 < IY0 || IZ1 < IZ0) { return; } + + for (int32 IZ = IZ0; IZ <= IZ1; ++IZ) + for (int32 IY = IY0; IY <= IY1; ++IY) + for (int32 IX = IX0; IX <= IX1; ++IX) + { + const int32 FlatIndex = + ((IZ - ExactIZ0) * ExactNY + (IY - ExactIY0)) * ExactNX + + (IX - ExactIX0); + Callback( + ExactLatticeWarpedQueries[FlatIndex], + ExactLatticeWorldQueries[FlatIndex]); + } + }; + + auto EffectiveZRangeToWorld = [&](float MinEffectiveZ, + float MaxEffectiveZ) -> FVector2D + { + if (P.VerticalScale > 0.0f && P.VerticalScale != 1.0f) + { + const float WorldA = MinEffectiveZ * P.VerticalScale; + const float WorldB = MaxEffectiveZ * P.VerticalScale; + return FVector2D(FMath::Min(WorldA, WorldB), FMath::Max(WorldA, WorldB)); + } + return FVector2D(MinEffectiveZ, MaxEffectiveZ); + }; + + auto DistanceBlockToInfiniteLineLower = [&](const FExactLatticeBlock& Block, + const FVector& A, + const FVector& BPoint) -> float + { + const FVector Direction = BPoint - A; + const float LengthSq = Direction.SizeSquared(); + if (!(LengthSq > KINDA_SMALL_NUMBER)) + { + return VF_DistanceBetweenBoxes( + Block.WarpedMin, Block.WarpedMax, A, A); + } + + auto LinearRange = [](float Coefficient, float MinValue, float MaxValue, + float Offset, float& OutMin, float& OutMax) + { + const float AValue = Coefficient * (MinValue - Offset); + const float BValue = Coefficient * (MaxValue - Offset); + OutMin = FMath::Min(AValue, BValue); + OutMax = FMath::Max(AValue, BValue); + }; + auto DistanceToZero = [](float MinValue, float MaxValue) -> float + { + return MinValue > 0.0f ? MinValue + : MaxValue < 0.0f ? -MaxValue : 0.0f; + }; + + float XYMin = 0.0f, XYMax = 0.0f; + float ZYMin = 0.0f, ZYMax = 0.0f; + float XZMin = 0.0f, XZMax = 0.0f; + float YXMin = 0.0f, YXMax = 0.0f; + float ZXMin = 0.0f, ZXMax = 0.0f; + float ZY2Min = 0.0f, ZY2Max = 0.0f; + LinearRange(Direction.Y, Block.WarpedMin.Z, Block.WarpedMax.Z, + (float)A.Z, ZYMin, ZYMax); + LinearRange(Direction.Z, Block.WarpedMin.Y, Block.WarpedMax.Y, + (float)A.Y, YXMin, YXMax); + LinearRange(Direction.Z, Block.WarpedMin.X, Block.WarpedMax.X, + (float)A.X, XZMin, XZMax); + LinearRange(Direction.X, Block.WarpedMin.Z, Block.WarpedMax.Z, + (float)A.Z, ZXMin, ZXMax); + LinearRange(Direction.X, Block.WarpedMin.Y, Block.WarpedMax.Y, + (float)A.Y, XYMin, XYMax); + LinearRange(Direction.Y, Block.WarpedMin.X, Block.WarpedMax.X, + (float)A.X, ZY2Min, ZY2Max); + + const float CrossXMin = ZYMin - YXMax; + const float CrossXMax = ZYMax - YXMin; + const float CrossYMin = XZMin - ZXMax; + const float CrossYMax = XZMax - ZXMin; + const float CrossZMin = XYMin - ZY2Max; + const float CrossZMax = XYMax - ZY2Min; + const float DX = DistanceToZero(CrossXMin, CrossXMax); + const float DY = DistanceToZero(CrossYMin, CrossYMax); + const float DZ = DistanceToZero(CrossZMin, CrossZMax); + return FMath::Sqrt((DX * DX + DY * DY + DZ * DZ) / LengthSq); + }; + + auto LatticePointDistance = [&](const FVector& Target, const FVector& Extra) -> float + { + if (bUseExactLatticeWarp) + { + return ExactLatticePointDistance(Target); + } + if (bUseExactLatticeBox) + { + return VF_DistanceBetweenBoxes( + ExactLatticeQueryMin, ExactLatticeQueryMax, Target, Target); + } + const float DX = VF_LatticeAxisDistanceToPoint( + BoxMinX, BoxMaxX, LatticeOriginX, LatticeStepXY, + static_cast(Target.X), (float)Extra.X); + const float DY = VF_LatticeAxisDistanceToPoint( + BoxMinY, BoxMaxY, LatticeOriginY, LatticeStepXY, + static_cast(Target.Y), (float)Extra.Y); + const float DZ = VF_LatticeAxisDistanceToPoint( + BoxMinZ, BoxMaxZ, LatticeOriginZ, LatticeStepZ, + static_cast(Target.Z), (float)Extra.Z); + return FMath::Sqrt(DX * DX + DY * DY + DZ * DZ); + }; + + auto LatticeBoxDistance = [&](const FVector& TargetMin, + const FVector& TargetMax, + const FVector& Extra) -> float + { + if (bUseExactLatticeWarp) + { + return ExactLatticeBoxDistance(TargetMin, TargetMax); + } + if (bUseExactLatticeBox) + { + return VF_DistanceBetweenBoxes( + ExactLatticeQueryMin, ExactLatticeQueryMax, TargetMin, TargetMax); + } + const float DX = VF_LatticeAxisDistanceToInterval( + BoxMinX, BoxMaxX, LatticeOriginX, LatticeStepXY, + static_cast(TargetMin.X), static_cast(TargetMax.X), + (float)Extra.X); + const float DY = VF_LatticeAxisDistanceToInterval( + BoxMinY, BoxMaxY, LatticeOriginY, LatticeStepXY, + static_cast(TargetMin.Y), static_cast(TargetMax.Y), + (float)Extra.Y); + const float DZ = VF_LatticeAxisDistanceToInterval( + BoxMinZ, BoxMaxZ, LatticeOriginZ, LatticeStepZ, + static_cast(TargetMin.Z), static_cast(TargetMax.Z), + (float)Extra.Z); + return FMath::Sqrt(DX * DX + DY * DY + DZ * DZ); + }; + + // The exact warped lattice list is also an exact AABB superset. Use that cheap + // distance as a first test and pay the primitive-by-query walk only when this bound is + // close enough to affect the source's active threshold. A far primitive can only + // raise the lower bound; retaining its AABB bound is conservative and avoids scanning + // 729 queries for every cached room/tunnel in every refined child. + auto ExactLatticeAabbPointDistance = [&](const FVector& Target) -> float + { + return bUseExactLatticeWarp + && ExactLatticeQueryMin.X <= ExactLatticeQueryMax.X + && ExactLatticeQueryMin.Y <= ExactLatticeQueryMax.Y + && ExactLatticeQueryMin.Z <= ExactLatticeQueryMax.Z + ? VF_DistanceBetweenBoxes( + ExactLatticeQueryMin, ExactLatticeQueryMax, Target, Target) + : FLT_MAX; + }; + auto ExactLatticeAabbBoxDistance = [&](const FVector& TargetMin, + const FVector& TargetMax) -> float + { + return bUseExactLatticeWarp + && ExactLatticeQueryMin.X <= ExactLatticeQueryMax.X + && ExactLatticeQueryMin.Y <= ExactLatticeQueryMax.Y + && ExactLatticeQueryMin.Z <= ExactLatticeQueryMax.Z + ? VF_DistanceBetweenBoxes( + ExactLatticeQueryMin, ExactLatticeQueryMax, TargetMin, TargetMax) + : FLT_MAX; + }; + + // Exact lower bounds for the primitive values at the sampled points. These mirror + // EvaluateSDFCached's broad-phase culls before evaluating the same primitive, so a + // skipped point contributes the identity (FLT_MAX), not a guessed distance. The + // smooth-min fold below is monotone in both operands; folding each primitive's exact + // sampled minimum in the same order is therefore a conservative lower bound for every + // lattice sample, while being much tighter than the AABB distance. + auto ExactRoomLower = [&](const FCachedRoom& Room) -> float + { + float LowerBound = FLT_MAX; + const float CullRadius = FMath::Sqrt(FMath::Max(Room.CullRadiusSq, 0.0f)); + for (const FExactLatticeBlock& Block : ExactLatticeBlocks) + { + if (VF_DistanceBetweenBoxes( + Block.WarpedMin, Block.WarpedMax, + Room.Center, Room.Center) > CullRadius) + { + continue; + } + for (int32 IZ = Block.IZ0; IZ <= Block.IZ1; ++IZ) + for (int32 IY = Block.IY0; IY <= Block.IY1; ++IY) + for (int32 IX = Block.IX0; IX <= Block.IX1; ++IX) + { + const int32 FlatIndex = + ((IZ - ExactIZ0) * ExactNY + (IY - ExactIY0)) * ExactNX + + (IX - ExactIX0); + const FVector& Query = ExactLatticeWarpedQueries[FlatIndex]; + const float DX = (float)Query.X - (float)Room.Center.X; + const float DY = (float)Query.Y - (float)Room.Center.Y; + const float DZ = (float)Query.Z - (float)Room.Center.Z; + if (DX * DX + DY * DY + DZ * DZ > Room.CullRadiusSq) + { + continue; + } + + float RoomSDF = 0.0f; + switch (Room.ShapeType) + { + case 1: + RoomSDF = VoxelSDF::RoundedBox( + Query, Room.Center, Room.ShapeA, Room.ShapeR); + break; + case 2: + RoomSDF = VoxelSDF::Capsule( + Query, Room.ShapeA, Room.ShapeB, Room.ShapeR); + break; + default: + RoomSDF = VoxelSDF::Ellipsoid( + Query, Room.Center, Room.ShapeA); + break; + } + + if (Room.FloorCutZ > -FLT_MAX) + { + float FloorZ = Room.FloorCutZ; + if (Room.FloorReliefStrength > 0.0f) + { + const float RF = Room.FloorReliefFrequency; + const float SF = (float)Room.FloorSeed * 0.00001f; + float N = FMath::PerlinNoise2D( + FVector2D((float)Query.X * RF + SF, + (float)Query.Y * RF + SF * 1.7f)) * 0.65f + + FMath::PerlinNoise2D( + FVector2D((float)Query.X * RF * 2.3f + SF * 3.1f, + (float)Query.Y * RF * 2.3f + SF * 5.3f)) * 0.35f; + N *= VOXEL_NOISE_SCALE; + FloorZ += N * Room.FloorReliefStrength; + } + RoomSDF = VoxelSDF::SmoothMax( + RoomSDF, FloorZ - (float)Query.Z, P.SDFBlendRadius * 0.35f); + } + LowerBound = FMath::Min(LowerBound, RoomSDF); + } + } + return LowerBound; + }; + + auto ExactJoinLower = [&](const FCachedRoomFloorJoin& Join) -> float + { + float LowerBound = FLT_MAX; + const float BoundRadius = FMath::Sqrt(FMath::Max(Join.BoundRadiusSq, 0.0f)); + for (const FExactLatticeBlock& Block : ExactLatticeBlocks) + { + if (VF_DistanceBetweenBoxes( + Block.WarpedMin, Block.WarpedMax, + Join.BoundCenter, Join.BoundCenter) > BoundRadius) + { + continue; + } + for (int32 IZ = Block.IZ0; IZ <= Block.IZ1; ++IZ) + for (int32 IY = Block.IY0; IY <= Block.IY1; ++IY) + for (int32 IX = Block.IX0; IX <= Block.IX1; ++IX) + { + const int32 FlatIndex = + ((IZ - ExactIZ0) * ExactNY + (IY - ExactIY0)) * ExactNX + + (IX - ExactIX0); + const FVector& Query = ExactLatticeWarpedQueries[FlatIndex]; + const float DX = (float)Query.X - (float)Join.BoundCenter.X; + const float DY = (float)Query.Y - (float)Join.BoundCenter.Y; + const float DZ = (float)Query.Z - (float)Join.BoundCenter.Z; + if (DX * DX + DY * DY + DZ * DZ > Join.BoundRadiusSq) + { + continue; + } + const float HorizontalSDF = VoxelSDF::Capsule( + Query, Join.Start, Join.End, Join.Radius); + const float VerticalSDF = FMath::Max( + Join.FloorZ - (float)Query.Z, + (float)Query.Z - Join.CeilingZ); + LowerBound = FMath::Min( + LowerBound, FMath::Max(HorizontalSDF, VerticalSDF)); + } + } + return LowerBound; + }; + + auto ExactTunnelLower = [&](const FCachedTunnel& Tunnel) -> float + { + float LowerBound = FLT_MAX; + const float BoundRadius = FMath::Sqrt(FMath::Max(Tunnel.BoundRadiusSq, 0.0f)); + auto EvaluateSegment = [&](const FVector& A, const FVector& BPoint, + float RadiusA, float RadiusB) + { + // Every point in the block is at least this far from the segment's + // infinite centerline. Subtracting the largest endpoint radius is a + // conservative lower bound for the tapered capsule and lets a good result + // from an earlier segment reject the rest of this segment without changing + // the exact point walk. + const float SegmentRadius = FMath::Max(FMath::Abs(RadiusA), FMath::Abs(RadiusB)); + for (const FExactLatticeBlock& Block : ExactLatticeBlocks) + { + if (Tunnel.SDFInfluenceRadius > 0.0f + && VF_DistanceBetweenBoxes( + Block.WarpedMin, Block.WarpedMax, + Tunnel.SDFCenterlineMin, Tunnel.SDFCenterlineMax) + > Tunnel.SDFInfluenceRadius) + { + continue; + } + if (VF_DistanceBetweenBoxes( + Block.WarpedMin, Block.WarpedMax, + Tunnel.BoundCenter, Tunnel.BoundCenter) + > BoundRadius) + { + continue; + } + if (FMath::IsFinite(LowerBound) + && DistanceBlockToInfiniteLineLower(Block, A, BPoint) + - SegmentRadius >= LowerBound) + { + continue; + } + for (int32 IZ = Block.IZ0; IZ <= Block.IZ1; ++IZ) + for (int32 IY = Block.IY0; IY <= Block.IY1; ++IY) + for (int32 IX = Block.IX0; IX <= Block.IX1; ++IX) + { + const int32 FlatIndex = + ((IZ - ExactIZ0) * ExactNY + (IY - ExactIY0)) * ExactNX + + (IX - ExactIX0); + const FVector& Query = ExactLatticeWarpedQueries[FlatIndex]; + const float DX = (float)Query.X - (float)Tunnel.BoundCenter.X; + const float DY = (float)Query.Y - (float)Tunnel.BoundCenter.Y; + const float DZ = (float)Query.Z - (float)Tunnel.BoundCenter.Z; + if (DX * DX + DY * DY + DZ * DZ > Tunnel.BoundRadiusSq) + { + continue; + } + LowerBound = FMath::Min( + LowerBound, + VoxelSDF::TaperedCapsule( + Query, A, BPoint, RadiusA, RadiusB)); + } + } + }; + + if (Tunnel.ControlPoints.Num() >= 2 + && Tunnel.ControlRadii.Num() == Tunnel.ControlPoints.Num()) + { + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Tunnel.ControlPoints.Num(); + ++SegmentIndex) + { + EvaluateSegment( + Tunnel.ControlPoints[SegmentIndex], + Tunnel.ControlPoints[SegmentIndex + 1], + Tunnel.ControlRadii[SegmentIndex], + Tunnel.ControlRadii[SegmentIndex + 1]); + } + } + else if (Tunnel.bHasMidpoint) + { + EvaluateSegment(Tunnel.EndpointA, Tunnel.Midpoint, + Tunnel.RadiusA, Tunnel.RadiusMid); + EvaluateSegment(Tunnel.Midpoint, Tunnel.EndpointB, + Tunnel.RadiusMid, Tunnel.RadiusB); + } + else + { + EvaluateSegment(Tunnel.EndpointA, Tunnel.EndpointB, + Tunnel.RadiusA, Tunnel.RadiusB); + } + return LowerBound; + }; + + auto ExactPitLower = [&](const FCachedPit& Pit) -> float + { + float LowerBound = FLT_MAX; + const float Radius = FMath::Sqrt(FMath::Max(Pit.BoundXYRadiusSq, 0.0f)); + const FVector CandidateMin( + Pit.CenterX - Radius, Pit.CenterY - Radius, + Pit.TopZ - Pit.Depth - Pit.BlendK); + const FVector CandidateMax( + Pit.CenterX + Radius, Pit.CenterY + Radius, + Pit.TopZ + Pit.BlendK); + ForEachExactQueryInWorldBox( + CandidateMin, CandidateMax, + [&](const FVector&, const FVector& Query) + { + const float DZ = (float)Query.Z - Pit.TopZ; + if (DZ >= Pit.BlendK || -DZ > Pit.Depth + Pit.BlendK) + { + return; + } + const float DX = (float)Query.X - Pit.CenterX; + const float DY = (float)Query.Y - Pit.CenterY; + const float XYDistSq = DX * DX + DY * DY; + if (XYDistSq > Pit.BoundXYRadiusSq) { return; } + float PitSDF; + if (DZ <= 0.0f) + { + float FlareFactor = FMath::Clamp( + 1.0f - (-DZ) / Pit.FlareDist, 0.0f, 1.0f); + FlareFactor *= FlareFactor; + PitSDF = FMath::Sqrt(XYDistSq) + - (Pit.Radius + Pit.FlareExtra * FlareFactor); + } + else + { + PitSDF = FMath::Sqrt(XYDistSq) + - (Pit.Radius + Pit.FlareExtra); + } + LowerBound = FMath::Min(LowerBound, PitSDF); + }); + return LowerBound; + }; + + auto ExactChimneyLower = [&](const FCachedChimney& Chimney) -> float + { + float LowerBound = FLT_MAX; + const float Radius = FMath::Sqrt(FMath::Max(Chimney.BoundXYRadiusSq, 0.0f)); + const FVector CandidateMin( + Chimney.CenterX - Radius, Chimney.CenterY - Radius, + Chimney.BottomZ - Chimney.BlendK); + const FVector CandidateMax( + Chimney.CenterX + Radius, Chimney.CenterY + Radius, + Chimney.BottomZ + Chimney.Height + Chimney.BlendK); + ForEachExactQueryInWorldBox( + CandidateMin, CandidateMax, + [&](const FVector&, const FVector& Query) + { + const float DZ = (float)Query.Z - Chimney.BottomZ; + if (-DZ >= Chimney.BlendK || DZ > Chimney.Height + Chimney.BlendK) + { + return; + } + const float DX = (float)Query.X - Chimney.CenterX; + const float DY = (float)Query.Y - Chimney.CenterY; + const float XYDistSq = DX * DX + DY * DY; + if (XYDistSq > Chimney.BoundXYRadiusSq) { return; } + float ChimneySDF; + if (DZ >= 0.0f) + { + float FlareFactor = FMath::Clamp( + 1.0f - DZ / Chimney.FlareDist, 0.0f, 1.0f); + FlareFactor *= FlareFactor; + ChimneySDF = FMath::Sqrt(XYDistSq) + - (Chimney.Radius + Chimney.FlareExtra * FlareFactor); + } + else + { + ChimneySDF = FMath::Sqrt(XYDistSq) + - (Chimney.Radius + Chimney.FlareExtra); + } + LowerBound = FMath::Min(LowerBound, ChimneySDF); + }); + return LowerBound; + }; + + auto ConsiderExact = [&](float PrimitiveLower, float BlendRadius) + { + if (!Finite(PrimitiveLower) || !Finite(BlendRadius)) + { + bInvalidBound = true; + return; + } + ExactSdfLower = VoxelSDF::SmoothMin( + ExactSdfLower, PrimitiveLower, BlendRadius); + }; + auto ConsiderCheapExact = [&](float PrimitiveLower, float BlendRadius) + { + if (Finite(PrimitiveLower) && Finite(BlendRadius)) + { + CheapSdfLower = VoxelSDF::SmoothMin( + CheapSdfLower, PrimitiveLower, BlendRadius); + } + }; + + auto LatticeXYPointDistance = [&](float X, float Y) -> float + { + const float DX = VF_LatticeAxisDistanceToPoint( + BoxMinX, BoxMaxX, LatticeOriginX, LatticeStepXY, X, 0.0f); + const float DY = VF_LatticeAxisDistanceToPoint( + BoxMinY, BoxMaxY, LatticeOriginY, LatticeStepXY, Y, 0.0f); + return FMath::Sqrt(DX * DX + DY * DY); + }; + const float K = P.SDFBlendRadius; const float WormThreshold = FMath::IsFinite(P.WormNetworkRange) ? FMath::Max(3.0f * K, P.WormNetworkRange) : FLT_MAX; const float ThresholdWithBlend = VF_SaturatingAdd(WormThreshold, K); float Lower = FLT_MAX; bool bAnyPrimitive = false; - bool bInvalidBound = false; auto Consider = [&](float Candidate) { @@ -3826,18 +5411,19 @@ namespace if (Finite(Candidate) && Candidate < ThresholdWithBlend) { ++Count; } }; - B.NumRooms = B.Cache.Rooms.Num(); - B.NumTunnels = B.Cache.Tunnels.Num(); - B.NumPits = B.Cache.Pits.Num(); - B.NumChimneys = B.Cache.Chimneys.Num(); + B.NumRooms = GetBoxCache().Rooms.Num(); + B.NumTunnels = GetBoxCache().Tunnels.Num(); + B.NumPits = GetBoxCache().Pits.Num(); + B.NumChimneys = GetBoxCache().Chimneys.Num(); B.HitRooms = B.HitTunnels = B.HitPits = B.HitChimneys = 0; B.HitRoomsNoWarp = B.HitTunnelsNoWarp = 0; - B.WarpDilation = Warp; + B.WarpDilation = FMath::Max3( + (float)WarpEnvelope.X, (float)WarpEnvelope.Y, (float)WarpEnvelope.Z); const FVector NoWarpMin(BoxMinX, BoxMinY, EffectiveMinZ); const FVector NoWarpMax(BoxMaxX, BoxMaxY, EffectiveMaxZ); - for (const FCachedRoom& Room : B.Cache.Rooms) + for (const FCachedRoom& Room : GetBoxCache().Rooms) { const float CullRadiusSq = Room.CullRadiusSq; if (!Finite((float)Room.Center.X) || !Finite((float)Room.Center.Y) @@ -3849,21 +5435,122 @@ namespace } const float RoomRadius = FMath::Sqrt(CullRadiusSq); - const float RoomLower = VF_DistanceBetweenBoxes( - QMin, QMax, Room.Center, Room.Center) - RoomRadius; + const float FastRoomLower = bUseExactLatticeWarp + ? ExactLatticeAabbPointDistance(Room.Center) - RoomRadius + : (bUseLatticeProof + ? LatticePointDistance(Room.Center, WarpEnvelope) - RoomRadius + : VF_DistanceBetweenBoxes(QMin, QMax, Room.Center, Room.Center) - RoomRadius); + const float RoomLower = bUseExactLatticeWarp + && FastRoomLower < ThresholdWithBlend + ? (ExactRoomRefinements++, ExactRoomLower(Room)) : FastRoomLower; const float RoomLowerNoWarp = VF_DistanceBetweenBoxes( NoWarpMin, NoWarpMax, Room.Center, Room.Center) - RoomRadius; Consider(RoomLower); + if (bUseExactLatticeWarp) { ConsiderCheapExact(FastRoomLower, K); } + if (bUseExactLatticeWarp) { ConsiderExact(RoomLower, K); } CountThreshold(RoomLower, B.HitRooms); CountThreshold(RoomLowerNoWarp, B.HitRoomsNoWarp); } - for (const FCachedTunnel& Tunnel : B.Cache.Tunnels) + // Room-floor joins are part of EvaluateSDFCached's same smooth-min union. They were + // absent from the old box proof, which could only lose skips; include them here so the + // exact retry remains a proof of the complete source rather than just rooms/tunnels. + for (const FCachedRoomFloorJoin& Join : GetBoxCache().RoomFloorJoins) + { + const float JoinRadius = FMath::Sqrt(FMath::Max(Join.BoundRadiusSq, 0.0f)); + const float FastJoinLower = bUseExactLatticeWarp + ? ExactLatticeAabbPointDistance(Join.BoundCenter) - JoinRadius + : VF_DistanceBetweenBoxes( + QMin, QMax, Join.BoundCenter, Join.BoundCenter) - JoinRadius; + const float JoinLower = bUseExactLatticeWarp + && FastJoinLower < ThresholdWithBlend + ? (ExactJoinRefinements++, ExactJoinLower(Join)) : FastJoinLower; + Consider(JoinLower); + if (bUseExactLatticeWarp) { ConsiderCheapExact(FastJoinLower, K); } + if (bUseExactLatticeWarp) { ConsiderExact(JoinLower, K); } + } + + for (const FCachedTunnel& Tunnel : GetBoxCache().Tunnels) { float MaxRadius = FMath::Max( FMath::Abs(Tunnel.RadiusA), FMath::Abs(Tunnel.RadiusB)); float TunnelLower = FLT_MAX; - if (Tunnel.ControlPoints.Num() >= 2 + if (bUseExactLatticeWarp) + { + if (Tunnel.ControlPoints.Num() >= 2 + && Tunnel.ControlRadii.Num() == Tunnel.ControlPoints.Num()) + { + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Tunnel.ControlPoints.Num(); + ++SegmentIndex) + { + const FVector& A = Tunnel.ControlPoints[SegmentIndex]; + const FVector& BPoint = Tunnel.ControlPoints[SegmentIndex + 1]; + MaxRadius = FMath::Max( + MaxRadius, + FMath::Max(FMath::Abs(Tunnel.ControlRadii[SegmentIndex]), + FMath::Abs(Tunnel.ControlRadii[SegmentIndex + 1]))); + const FVector SegmentMin( + FMath::Min(A.X, BPoint.X), + FMath::Min(A.Y, BPoint.Y), + FMath::Min(A.Z, BPoint.Z)); + const FVector SegmentMax( + FMath::Max(A.X, BPoint.X), + FMath::Max(A.Y, BPoint.Y), + FMath::Max(A.Z, BPoint.Z)); + TunnelLower = FMath::Min( + TunnelLower, + ExactLatticeAabbBoxDistance(SegmentMin, SegmentMax)); + } + } + else if (Tunnel.bHasMidpoint) + { + const FVector SegmentMin( + FMath::Min3(Tunnel.EndpointA.X, Tunnel.Midpoint.X, + Tunnel.EndpointB.X), + FMath::Min3(Tunnel.EndpointA.Y, Tunnel.Midpoint.Y, + Tunnel.EndpointB.Y), + FMath::Min3(Tunnel.EndpointA.Z, Tunnel.Midpoint.Z, + Tunnel.EndpointB.Z)); + const FVector SegmentMax( + FMath::Max3(Tunnel.EndpointA.X, Tunnel.Midpoint.X, + Tunnel.EndpointB.X), + FMath::Max3(Tunnel.EndpointA.Y, Tunnel.Midpoint.Y, + Tunnel.EndpointB.Y), + FMath::Max3(Tunnel.EndpointA.Z, Tunnel.Midpoint.Z, + Tunnel.EndpointB.Z)); + MaxRadius = FMath::Max( + MaxRadius, + FMath::Max(FMath::Abs(Tunnel.RadiusMid), + FMath::Max(FMath::Abs(Tunnel.RadiusA), + FMath::Abs(Tunnel.RadiusB)))); + TunnelLower = ExactLatticeAabbBoxDistance(SegmentMin, SegmentMax); + } + else + { + const FVector SegmentMin( + FMath::Min(Tunnel.EndpointA.X, Tunnel.EndpointB.X), + FMath::Min(Tunnel.EndpointA.Y, Tunnel.EndpointB.Y), + FMath::Min(Tunnel.EndpointA.Z, Tunnel.EndpointB.Z)); + const FVector SegmentMax( + FMath::Max(Tunnel.EndpointA.X, Tunnel.EndpointB.X), + FMath::Max(Tunnel.EndpointA.Y, Tunnel.EndpointB.Y), + FMath::Max(Tunnel.EndpointA.Z, Tunnel.EndpointB.Z)); + TunnelLower = ExactLatticeAabbBoxDistance(SegmentMin, SegmentMax); + } + TunnelLower -= MaxRadius; + const float FastTunnelLower = TunnelLower; + if (TunnelLower < ThresholdWithBlend) + { + ++ExactTunnelRefinements; + TunnelLower = ExactTunnelLower(Tunnel); + } + if (bUseExactLatticeWarp) + { + ConsiderCheapExact(FastTunnelLower, K); + } + } + else if (Tunnel.ControlPoints.Num() >= 2 && Tunnel.ControlRadii.Num() == Tunnel.ControlPoints.Num()) { for (int32 SegmentIndex = 0; @@ -3886,7 +5573,9 @@ namespace FMath::Max(A.Z, BPoint.Z)); TunnelLower = FMath::Min( TunnelLower, - VF_DistanceBetweenBoxes(QMin, QMax, SegmentMin, SegmentMax)); + bUseLatticeProof + ? LatticeBoxDistance(SegmentMin, SegmentMax, WarpEnvelope) + : VF_DistanceBetweenBoxes(QMin, QMax, SegmentMin, SegmentMax)); } TunnelLower -= MaxRadius; } @@ -3900,8 +5589,9 @@ namespace FMath::Max(Tunnel.EndpointA.X, Tunnel.EndpointB.X), FMath::Max(Tunnel.EndpointA.Y, Tunnel.EndpointB.Y), FMath::Max(Tunnel.EndpointA.Z, Tunnel.EndpointB.Z)); - TunnelLower = VF_DistanceBetweenBoxes( - QMin, QMax, SegmentMin, SegmentMax) - MaxRadius; + TunnelLower = (bUseLatticeProof + ? LatticeBoxDistance(SegmentMin, SegmentMax, WarpEnvelope) + : VF_DistanceBetweenBoxes(QMin, QMax, SegmentMin, SegmentMax)) - MaxRadius; } const FVector NoWarpSegmentMin( @@ -3916,11 +5606,12 @@ namespace NoWarpMin, NoWarpMax, NoWarpSegmentMin, NoWarpSegmentMax) - MaxRadius; Consider(TunnelLower); + if (bUseExactLatticeWarp) { ConsiderExact(TunnelLower, K); } CountThreshold(TunnelLower, B.HitTunnels); CountThreshold(TunnelLowerNoWarp, B.HitTunnelsNoWarp); } - for (const FCachedPit& Pit : B.Cache.Pits) + for (const FCachedPit& Pit : GetBoxCache().Pits) { if (!(BoxMinZ < Pit.TopZ + Pit.BlendK) || !(BoxMaxZ >= Pit.TopZ - Pit.Depth - Pit.BlendK)) @@ -3928,13 +5619,21 @@ namespace continue; } const float MaxRadius = FMath::Abs(Pit.Radius) + FMath::Abs(Pit.FlareExtra); - const float PitLower = VF_DistanceBoxToPointXY( - FBox(RMin, RMax), Pit.CenterX, Pit.CenterY) - MaxRadius; + const float FastPitLower = bUseExactLatticeWarp + ? LatticeXYPointDistance(Pit.CenterX, Pit.CenterY) - MaxRadius + : (bUseLatticeProof + ? LatticeXYPointDistance(Pit.CenterX, Pit.CenterY) + : VF_DistanceBoxToPointXY(FBox(RMin, RMax), Pit.CenterX, Pit.CenterY)) - MaxRadius; + const float PitLower = bUseExactLatticeWarp + && FastPitLower < ThresholdWithBlend + ? (ExactPitRefinements++, ExactPitLower(Pit)) : FastPitLower; Consider(PitLower); + if (bUseExactLatticeWarp) { ConsiderCheapExact(FastPitLower, Pit.BlendK); } + if (bUseExactLatticeWarp) { ConsiderExact(PitLower, Pit.BlendK); } CountThreshold(PitLower, B.HitPits); } - for (const FCachedChimney& Chimney : B.Cache.Chimneys) + for (const FCachedChimney& Chimney : GetBoxCache().Chimneys) { if (!(BoxMaxZ > Chimney.BottomZ - Chimney.BlendK) || !(BoxMinZ <= Chimney.BottomZ + Chimney.Height + Chimney.BlendK)) @@ -3942,12 +5641,31 @@ namespace continue; } const float MaxRadius = FMath::Abs(Chimney.Radius) + FMath::Abs(Chimney.FlareExtra); - const float ChimneyLower = VF_DistanceBoxToPointXY( - FBox(RMin, RMax), Chimney.CenterX, Chimney.CenterY) - MaxRadius; + const float FastChimneyLower = bUseExactLatticeWarp + ? LatticeXYPointDistance(Chimney.CenterX, Chimney.CenterY) - MaxRadius + : (bUseLatticeProof + ? LatticeXYPointDistance(Chimney.CenterX, Chimney.CenterY) + : VF_DistanceBoxToPointXY(FBox(RMin, RMax), Chimney.CenterX, Chimney.CenterY)) - MaxRadius; + const float ChimneyLower = bUseExactLatticeWarp + && FastChimneyLower < ThresholdWithBlend + ? (ExactChimneyRefinements++, ExactChimneyLower(Chimney)) : FastChimneyLower; Consider(ChimneyLower); + if (bUseExactLatticeWarp) { ConsiderCheapExact(FastChimneyLower, Chimney.BlendK); } + if (bUseExactLatticeWarp) { ConsiderExact(ChimneyLower, Chimney.BlendK); } CountThreshold(ChimneyLower, B.HitChimneys); } + if (bTask4SourceProbe) + { + UE_LOG(LogTemp, Warning, + TEXT("[Task4SourceProbe] box=(%.0f,%.0f,%.0f)-(%.0f,%.0f,%.0f) exact=%d queries=%d rooms=%d joins=%d tunnels=%d ms=%.3f"), + BoxMinX, BoxMinY, BoxMinZ, BoxMaxX, BoxMaxY, BoxMaxZ, + bUseExactLatticeWarp ? 1 : 0, ExactLatticeWarpedQueries.Num(), + GetBoxCache().Rooms.Num(), GetBoxCache().RoomFloorJoins.Num(), GetBoxCache().Tunnels.Num(), + FPlatformTime::ToMilliseconds64( + FPlatformTime::Cycles64() - Task4SourceStartCycles)); + } + if (bInvalidBound) { Unknown(); @@ -3955,7 +5673,11 @@ namespace } FVoxelBoxSdfInterval Own; - if (!bAnyPrimitive) + if (bUseExactLatticeWarp) + { + Own.Set(ExactSdfLower, FLT_MAX); + } + else if (!bAnyPrimitive) { Own.Set(FLT_MAX, FLT_MAX); } @@ -3973,7 +5695,28 @@ namespace B.KeyFingerprint = ParamsFingerprint; B.KeyLayout = LV; B.KeyManagerLifetimeId = ManagerLifetimeId; + B.KeyUsesLatticeProof = bUseLatticeProof; + B.KeyTightenWarpProof = Ctx.bTightenWarpProof; + B.KeyLatticeOrigin = Ctx.LatticeOriginVoxels; + B.KeyLatticeStep = Ctx.Step; B.bValid = true; + if (Ctx.bUseLatticeProof + && Ctx.LatticeOriginVoxels == FIntVector(32, 0, -32) + && Ctx.Step == 1) + { + UE_LOG(LogTemp, Warning, + TEXT("[Task4RoomBoxProbe] box=(%.0f,%.0f,%.0f)-(%.0f,%.0f,%.0f) exact=%d queries=%d rooms=%d joins=%d tunnels=%d pits=%d chimneys=%d lower=%.6g"), + BoxMinX, BoxMinY, BoxMinZ, BoxMaxX, BoxMaxY, BoxMaxZ, + bUseExactLatticeWarp ? 1 : 0, ExactLatticeWarpedQueries.Num(), + GetBoxCache().Rooms.Num(), GetBoxCache().RoomFloorJoins.Num(), GetBoxCache().Tunnels.Num(), + GetBoxCache().Pits.Num(), GetBoxCache().Chimneys.Num(), + bUseExactLatticeWarp ? ExactSdfLower : Lower); + UE_LOG(LogTemp, Warning, + TEXT("[Task4RoomCheapProbe] cheap=%.6g exact=%.6g threshold=%.6g refine=%d,%d,%d,%d,%d"), + CheapSdfLower, ExactSdfLower, ThresholdWithBlend, + ExactRoomRefinements, ExactJoinRefinements, ExactTunnelRefinements, + ExactPitRefinements, ExactChimneyRefinements); + } InOut = Own; } @@ -3983,46 +5726,21 @@ namespace { FState& S = State(); const FVector& Position = S.LastWorldPosition; - const FChunkSDFCache& Cache = GetCache(); if (VoxelDensityProfile::AreCountersEnabled()) { VoxelDensityProfile::AddCounter( VoxelDensityProfile::ECounter::TunnelSupportFloorQueries); } - // Density-grid samples are integer XY. Keep fractional callers exact by using a - // scratch column; they are uncommon gradient/debug queries and must not alias a - // neighbouring integer column. - const bool bIntegerXY = - Position.X == FMath::FloorToFloat(static_cast(Position.X)) - && Position.Y == FMath::FloorToFloat(static_cast(Position.Y)); - if (bIntegerXY) - { - const int32 IX = FMath::FloorToInt(static_cast(Position.X)); - const int32 IY = FMath::FloorToInt(static_cast(Position.Y)); - FState::FSupportColumnBox& Box = S.SupportColumns.AcquireBox(IX, IY, &Cache); - bool bNewColumn = false; - FTunnelSupportFloorColumn* Column = Box.AcquireColumn( - IX, IY, &Cache, S.SupportColumns.Clock, bNewColumn); - if (bNewColumn) - { - VoxelCaveMorphology::BuildTunnelSupportFloorColumn( - static_cast(Position.X), - static_cast(Position.Y), - Cache, - *Column); - } - return VoxelCaveMorphology::IsTunnelSupportFloorColumnZ( + const FTunnelSupportFloorColumn* Column = GetSupportColumn(Position); + const bool bSupport = Column != nullptr + && VoxelCaveMorphology::IsTunnelSupportFloorColumnZ( static_cast(Position.Z), *Column); - } - - VoxelCaveMorphology::BuildTunnelSupportFloorColumn( - static_cast(Position.X), - static_cast(Position.Y), - Cache, - S.FractionalSupportColumn); - return VoxelCaveMorphology::IsTunnelSupportFloorColumnZ( - static_cast(Position.Z), S.FractionalSupportColumn); + S.LastTunnelCoreWorldEvaluation = FTunnelCoreWorldEvaluation(); + S.LastTunnelCoreWorldEvaluation.bSupportFloor = bSupport; + S.LastTunnelCoreEvaluationPosition = Position; + S.bLastTunnelCoreWorldEvaluationValid = true; + return bSupport; } private: @@ -4344,7 +6062,7 @@ namespace { const EVoxelOpEffect Intrinsic = EffectOverBox(VoxelBox, Ctx); if (Intrinsic == EVoxelOpEffect::Identity) { return Intrinsic; } - return VF_CaveBoxIsFar(H, P.SDFBlendRadius) ? EVoxelOpEffect::Identity : Intrinsic; + return IsInactiveFromSdf(H) ? EVoxelOpEffect::Identity : Intrinsic; } /** @@ -4361,13 +6079,13 @@ namespace float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, const FVoxelBoxHypotheses& H) const override { - return VF_CaveBoxIsFar(H, P.SDFBlendRadius) ? 0.0f : MaxCarveOverBox(VoxelBox, Ctx); + return IsInactiveFromSdf(H) ? 0.0f : MaxCarveOverBox(VoxelBox, Ctx); } float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, const FVoxelBoxHypotheses& H) const override { - return VF_CaveBoxIsFar(H, P.SDFBlendRadius) ? 0.0f : MaxFillOverBox(VoxelBox, Ctx); + return IsInactiveFromSdf(H) ? 0.0f : MaxFillOverBox(VoxelBox, Ctx); } /** La borne d'amplitude, en unités de densité. */ @@ -4399,6 +6117,21 @@ namespace } private: + bool IsInactiveFromSdf(const FVoxelBoxHypotheses& H) const + { + if (VF_CaveBoxIsFar(H, P.SDFBlendRadius)) { return true; } + if (!(P.SurfaceRoughness > 0.0f) || !FMath::IsFinite(P.SurfaceRoughness)) + { + return P.SurfaceRoughness <= 0.0f; + } + const float RoughnessDepth = P.SurfaceRoughness * 2.0f; + // Eval's second gate is abs(CaveSDF) < SurfaceRoughness*2. A known positive + // lower endpoint at or beyond that depth therefore proves the op is an identity even + // when the broader shared near-surface gate (SDFBlendRadius*3) remains open. + return FMath::IsFinite(RoughnessDepth) && H.Sdf.IsKnown() + && H.Sdf.Min >= RoughnessDepth; + } + FStrateGenerationParams P; uint32 SeedU; }; @@ -5765,9 +7498,122 @@ namespace float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, const FVoxelBoxHypotheses& H) const override { - return (P.WormNetworkRange > 0.0f && H.Sdf.IsKnown() - && H.Sdf.Min >= P.WormNetworkRange) - ? 0.0f : MaxCarveOverBox(VoxelBox, Ctx); + const float Strength = MaxCarveOverBox(VoxelBox, Ctx); + if (!(Strength > 0.0f) || !FMath::IsFinite(Strength)) + { + return Strength; + } + if (!(P.WormNetworkRange > 0.0f) || !FMath::IsFinite(P.WormNetworkRange) + || !H.Sdf.IsKnown() || !FMath::IsFinite(H.Sdf.Min)) + { + return Strength; + } + + // The classifier's lattice proof is about the exact MC vertices, not the continuum + // between them. On a refined child, evaluate the worm's two threshold noises at + // those vertices once and bound the actual carve factor there. The root is left on + // the cheap interval path; a refined step-32 child has at most 17^3 vertices and the + // explicit count cap keeps this check bounded. The network mask still uses the SDF + // interval's worst case, so this never assumes a room value that the preceding source + // did not prove. + if (Ctx.bUseLatticeProof && Ctx.bTightenWarpProof && Ctx.Step >= 1 + && FMath::IsFinite(P.WormFrequency) + && FMath::IsFinite(P.WormHorizontalBias) + && FMath::IsFinite(P.WormThreshold) + && P.WormThreshold > 0.0f + && FMath::IsFinite(P.VerticalScale)) + { + const int32 Step = FMath::Max(Ctx.Step, 1); + const float OriginX = static_cast(Ctx.LatticeOriginVoxels.X); + const float OriginY = static_cast(Ctx.LatticeOriginVoxels.Y); + const float OriginZ = static_cast(Ctx.LatticeOriginVoxels.Z); + const int32 IX0 = FMath::CeilToInt( + ((float)VoxelBox.Min.X - OriginX) / (float)Step - 1.0e-4f); + const int32 IY0 = FMath::CeilToInt( + ((float)VoxelBox.Min.Y - OriginY) / (float)Step - 1.0e-4f); + const int32 IZ0 = FMath::CeilToInt( + ((float)VoxelBox.Min.Z - OriginZ) / (float)Step - 1.0e-4f); + const int32 IX1 = FMath::FloorToInt( + ((float)VoxelBox.Max.X - OriginX) / (float)Step + 1.0e-4f); + const int32 IY1 = FMath::FloorToInt( + ((float)VoxelBox.Max.Y - OriginY) / (float)Step + 1.0e-4f); + const int32 IZ1 = FMath::FloorToInt( + ((float)VoxelBox.Max.Z - OriginZ) / (float)Step + 1.0e-4f); + const int64 Count = ((int64)IX1 - IX0 + 1) + * ((int64)IY1 - IY0 + 1) + * ((int64)IZ1 - IZ0 + 1); + // A full LOD0 root is intentionally left on the cheap interval path. Refined + // child boxes are at most 17^3 lattice vertices, so their exact threshold bound + // couples the worm's spatial maximum to each child's SDF lower bound without + // evaluating the room graph 35,937 times. + if (IX1 >= IX0 && IY1 >= IY0 && IZ1 >= IZ0 + && Count > 0 && Count <= 10000) + { + float MaxThresholdFactor = 0.0f; + const float WormZFrequency = P.WormFrequency * P.WormHorizontalBias; + for (int32 IZ = IZ0; IZ <= IZ1; ++IZ) + for (int32 IY = IY0; IY <= IY1; ++IY) + for (int32 IX = IX0; IX <= IX1; ++IX) + { + const float WorldX = OriginX + (float)IX * (float)Step; + const float WorldY = OriginY + (float)IY * (float)Step; + const float WorldZ = OriginZ + (float)IZ * (float)Step; + const float EffectiveZ = (P.VerticalScale != 1.0f + && P.VerticalScale > 0.0f) + ? WorldZ / P.VerticalScale : WorldZ; + const FVector3f NoiseBase( + WorldX * P.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.0f), + WorldY * P.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.7f), + EffectiveZ * WormZFrequency + VoxelHash::SeedOffset(SeedU, 2.3f)); + const float N1 = FMath::Abs(VoxelNoise::Perlin3D(NoiseBase) + * VOXEL_NOISE_SCALE); + if (N1 >= P.WormThreshold) { continue; } + + const FVector3f NoiseOffset( + 137.0f, 259.0f, 431.0f); + const float N2 = FMath::Abs(VoxelNoise::Perlin3D( + NoiseBase + NoiseOffset) * VOXEL_NOISE_SCALE); + const float WormValue = N1 + N2; + if (WormValue < P.WormThreshold) + { + MaxThresholdFactor = FMath::Max( + MaxThresholdFactor, + 1.0f - WormValue / P.WormThreshold); + } + } + + if (!(MaxThresholdFactor > 0.0f)) { return 0.0f; } + float MaxNetworkMask = 1.0f; + if (H.Sdf.Min >= P.WormNetworkRange) + { + MaxNetworkMask = 0.0f; + } + else if (H.Sdf.Min > 0.0f) + { + MaxNetworkMask = 1.0f - SmoothStep01( + FMath::Clamp(H.Sdf.Min / P.WormNetworkRange, 0.0f, 1.0f)); + } + return Strength * MaxThresholdFactor + * FMath::Clamp(MaxNetworkMask, 0.0f, 1.0f); + } + } + + // NetworkMask is monotone non-increasing in CaveSDF. The source interval's lower + // endpoint therefore gives a sound upper bound for the mask across the whole box, + // not just the old all-or-nothing test at WormNetworkRange. This matters in deep + // solid: a positive SDF can leave only a fraction of the worm amplitude, enough for + // the constant-rock margin to survive even when the full WormStrength would not. + float MaxNetworkMask = 1.0f; + if (H.Sdf.Min >= P.WormNetworkRange) + { + MaxNetworkMask = 0.0f; + } + else if (H.Sdf.Min > 0.0f) + { + MaxNetworkMask = 1.0f - SmoothStep01( + FMath::Clamp(H.Sdf.Min / P.WormNetworkRange, 0.0f, 1.0f)); + } + return Strength * FMath::Clamp(MaxNetworkMask, 0.0f, 1.0f); } /** Le ver ne REMPLIT jamais : `InOut.Density -= …` avec un terme positif. */ @@ -5957,27 +7803,30 @@ void VoxelDensityOps::ReportWorkerRoomGraphCacheFootprint() uint64 LargestEntryBytes = 0; uint64 ValidEntries = 0; VoxelDensityProfile::FCacheMemoryBreakdown Breakdown; - for (int32 Index = 0; Index < RoomGraphCacheSlotCount; ++Index) + for (int32 Bank = 0; Bank < RoomGraphCacheBankCount; ++Bank) { - const FRoomGraphCacheEntry& Entry = GRoomGraphCache[Index]; - if (!Entry.bValid) + for (int32 Index = 0; Index < RoomGraphCacheSlotCount; ++Index) { - continue; - } + const FRoomGraphCacheEntry& Entry = GRoomGraphCache[Bank][Index]; + if (!Entry.bValid) + { + continue; + } - ++ValidEntries; - Breakdown += Entry.Cache.GetAllocatedSizeBreakdown(); - const uint64 EntryDynamicBytes = static_cast(Entry.Cache.GetAllocatedSize()); - const uint64 FullEntryBytes = static_cast(sizeof(FRoomGraphCacheEntry)) - + EntryDynamicBytes; - DynamicBytes += EntryDynamicBytes; - EntryBytes += FullEntryBytes; - LargestEntryBytes = FMath::Max(LargestEntryBytes, FullEntryBytes); + ++ValidEntries; + Breakdown += Entry.Cache.GetAllocatedSizeBreakdown(); + const uint64 EntryDynamicBytes = static_cast(Entry.Cache.GetAllocatedSize()); + const uint64 FullEntryBytes = static_cast(sizeof(FRoomGraphCacheEntry)) + + EntryDynamicBytes; + DynamicBytes += EntryDynamicBytes; + EntryBytes += FullEntryBytes; + LargestEntryBytes = FMath::Max(LargestEntryBytes, FullEntryBytes); + } } Breakdown.SlotStorageBytes = static_cast(sizeof(GRoomGraphCache)); VoxelDensityProfile::SetWorkerRoomGraphCacheFootprint( - RoomGraphCacheSlotCount, + RoomGraphCacheBankCount * RoomGraphCacheSlotCount, ValidEntries, static_cast(sizeof(GRoomGraphCache)), DynamicBytes, @@ -6004,6 +7853,22 @@ SIZE_T FVoxelOpStack::GetAllocatedSize() const return Bytes; } +bool FVoxelOpStack::TryGetLastTunnelCoreWorldEvaluation( + FTunnelCoreWorldEvaluation& OutEvaluation) const +{ + // The last provider wins in the same way the structural tail is ordered. At present the + // tunnel source is the only provider; walking backwards keeps this safe if a composed stack + // later adds another structural core provider. + for (int32 Index = Ops.Num() - 1; Index >= 0; --Index) + { + if (Ops[Index].Op->TryGetLastTunnelCoreWorldEvaluation(OutEvaluation)) + { + return true; + } + } + return false; +} + void FVoxelOpStack::DiagnoseBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx, int32 SampleStep, TArray& OutDiagnostics, EVoxelTileClass* OutVerdict) const @@ -6648,9 +8513,6 @@ namespace VoxelDensityOps OutStack.Add(MakeUnique(P, RoomPtr)); // 4g — dômes OutStack.Add(MakeUnique(P, RoomPtr)); // 4h — pincement OutStack.Add(MakeUnique(P, RoomPtr)); // fin 4h — biais de sol - // The worm consumes the SDF interval carried by the fold; it has no pointer to, and no - // dependency on, this particular room source. That keeps the same operator safe behind a - // different SDF writer assembled by the composer. OutStack.Add(MakeUnique(P, Seed)); if (bAppendStructuralPosts) diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 0092583..cbcd840 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -522,10 +522,9 @@ 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. - // The cache is open-addressed and bounded. The 128-slot budget is evidence-sized: the - // 128³ production export measured at most 102 live tunnel entries on one worker, leaving - // 26 slots of headroom; the old 4,096-slot experiment reserved 3.34 MiB per worker before - // one operator or SDF allocation existed. Collisions only cause a safe deterministic rebuild. + // The cache is open-addressed and bounded. Keep the worker footprint at the established + // 128-entry cap; the expensive native tunnel-core cache is not prepared for an operator-stack + // chunk because the stack publishes the same result to the common post below. constexpr int32 TunnelDensityCacheSlotCount = 128; struct FTunnelNetworkDensityCacheEntry @@ -983,6 +982,20 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co const uint32 LayoutVersion = StrateManager->GetLayoutVersion(); const uint64 ManagerLifetimeId = StrateManager->GetCacheLifetimeId(); + // TunnelNetwork/Underwater operator-stack parameters are selected from chunk Z only. + // A coarse tile samples thousands of exact XY chunk keys, but those keys describe the + // same immutable stack whenever the selected archetype is operator-stack backed. Share + // that prepared state by Z; the stack still queries the actual world position, and its + // room-graph cache remains keyed by the real XY search window. Legacy tunnel states keep + // the full XYZ key because their native cache is built for one XY window. + const ECaveGeneratorType QueryGeneratorType = + StrateManager->GetGeneratorTypeForChunk(ChunkCoord); + const bool bShareTunnelStackByZ = !bIsGapChunk + && (QueryGeneratorType == ECaveGeneratorType::TunnelNetwork + || QueryGeneratorType == ECaveGeneratorType::Underwater) + && StrateManager->UsesOperatorStackForChunk(ChunkCoord); + const FIntVector TunnelCacheKey = bShareTunnelStackByZ + ? FIntVector(0, 0, ChunkCoord.Z) : ChunkCoord; FVoxelOpStack* ActiveOpStack = &CP_OpStack; FTunnelCoreCacheState* ActiveTunnelCoreCache = >unnelCoreCache; bool bLoadedTunnelDensityCache = false; @@ -995,7 +1008,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co auto FindTunnelDensityCacheEntry = [&](const FIntVector& QueryChunk, FTunnelNetworkDensityCacheEntry*& OutEntry) -> bool { - const int32 StartSlot = TunnelDensityCacheSlot(QueryChunk); + const int32 StartSlot = TunnelDensityCacheSlot(TunnelCacheKey); FTunnelNetworkDensityCacheEntry* ReuseEntry = nullptr; for (int32 Probe = 0; Probe < TunnelDensityCacheSlotCount; ++Probe) { @@ -1008,8 +1021,9 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co continue; } const bool bKeyMatches = Candidate.ManagerLifetimeId == ManagerLifetimeId - && Candidate.Chunk == QueryChunk + && Candidate.Chunk == TunnelCacheKey && Candidate.LayoutVersion == LayoutVersion + && Candidate.bUseOpStack == bShareTunnelStackByZ && (Candidate.GenType == ECaveGeneratorType::TunnelNetwork || Candidate.GenType == ECaveGeneratorType::Underwater); if (bKeyMatches) @@ -1155,23 +1169,6 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // silently fall back to the old one-archetype switch. CP_UseOpStack = CP_UseOpStack || CP_UseComposerRegions; #endif - bool bUseNativeTunnelCore = !CP_UseCustomRecipe - && (CP_GenType == ECaveGeneratorType::TunnelNetwork - || CP_GenType == ECaveGeneratorType::Underwater); -#if WITH_EDITOR - bUseNativeTunnelCore = bUseNativeTunnelCore && !CP_UseComposerRegions; -#endif - if (bUseNativeTunnelCore) - { - PrepareTunnelCoreCache( - *StrateManager, ChunkCoord, CP_Tunnel, CP_TunnelFP, - LayoutVersion, ManagerLifetimeId, DensityCacheOwnerId, GTunnelCoreCache); - } - else - { - GTunnelCoreCache.bValid = false; - } - if (CP_UseOpStack) { CP_OpStack = FVoxelOpStack(); // move-assign : libère l'ancienne pile @@ -1278,6 +1275,31 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co } } + // The legacy path needs its prepared native tunnel-core cache. An operator-stack + // tunnel source publishes the same world-space result after EvalMC, so preparing a + // second full graph/core cache here would only duplicate the work that made coarse + // streaming expensive in the first place. Custom/composer paths retain the legacy + // cache unless their own stack supplies the hand-off. + const bool bNativeTunnelCore = !CP_UseCustomRecipe + && (CP_GenType == ECaveGeneratorType::TunnelNetwork + || CP_GenType == ECaveGeneratorType::Underwater); +#if WITH_EDITOR + const bool bCanPrepareNativeTunnelCore = bNativeTunnelCore + && (!CP_UseOpStack || CP_UseComposerRegions); +#else + const bool bCanPrepareNativeTunnelCore = bNativeTunnelCore && !CP_UseOpStack; +#endif + if (bCanPrepareNativeTunnelCore) + { + PrepareTunnelCoreCache( + *StrateManager, ChunkCoord, CP_Tunnel, CP_TunnelFP, + LayoutVersion, ManagerLifetimeId, DensityCacheOwnerId, GTunnelCoreCache); + } + else + { + GTunnelCoreCache.bValid = false; + } + const bool bNativeTunnelCacheCandidate = !CP_UseCustomRecipe && (CP_GenType == ECaveGeneratorType::TunnelNetwork || CP_GenType == ECaveGeneratorType::Underwater); @@ -1295,7 +1317,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co CachedEntry.bValid = false; CachedEntry.OwnerId = DensityCacheOwnerId; CachedEntry.ManagerLifetimeId = ManagerLifetimeId; - CachedEntry.Chunk = ChunkCoord; + CachedEntry.Chunk = TunnelCacheKey; CachedEntry.LayoutVersion = LayoutVersion; CachedEntry.GenType = CP_GenType; CachedEntry.Tunnel = CP_Tunnel; @@ -1484,57 +1506,64 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // bridge over a graph tunnel. Reassert the native cached tunnel core here, after every // solid floor writer but before the global XY seal. This cache is built once per chunk, // never once per voxel. - if (ActiveTunnelCoreCache->bValid) + if (ActiveTunnelCoreCache->bValid || CP_UseOpStack) { - const FTunnelSupportFloorColumn* SupportColumn = nullptr; - FTunnelSupportFloorColumn EmptySupportColumn; - const bool bIntegerXY = - WorldX == FMath::FloorToFloat(WorldX) - && WorldY == FMath::FloorToFloat(WorldY); - if (bIntegerXY) - { - const int32 IX = FMath::FloorToInt(WorldX); - const int32 IY = FMath::FloorToInt(WorldY); - SupportColumn = FindTunnelSupportColumn( - ActiveTunnelCoreCache->Cache, IX, IY, EmptySupportColumn); - } - FTunnelCoreWorldEvaluation TunnelCore; + bool bHaveTunnelCore = CP_UseOpStack + && ActiveOpStack->TryGetLastTunnelCoreWorldEvaluation(TunnelCore); + if (!bHaveTunnelCore && ActiveTunnelCoreCache->bValid) { + const FTunnelSupportFloorColumn* SupportColumn = nullptr; + FTunnelSupportFloorColumn EmptySupportColumn; + const bool bIntegerXY = + WorldX == FMath::FloorToFloat(WorldX) + && WorldY == FMath::FloorToFloat(WorldY); + if (bIntegerXY) + { + const int32 IX = FMath::FloorToInt(WorldX); + const int32 IY = FMath::FloorToInt(WorldY); + SupportColumn = FindTunnelSupportColumn( + ActiveTunnelCoreCache->Cache, IX, IY, EmptySupportColumn); + } + VoxelDensityProfile::FScopedTimer ProfileTimer( VoxelDensityProfile::EBucket::TunnelCorePosts); TunnelCore = VoxelCaveMorphology::EvaluateTunnelCoreWorld( WorldX, WorldY, WorldZ, ActiveTunnelCoreCache->Cache, SupportColumn); + bHaveTunnelCore = true; } - const bool bTunnelSupportFloor = TunnelCore.bSupportFloor; - if (bTunnelSupportFloor) + if (bHaveTunnelCore) { - Result = FMath::Min( - Result, - -FMath::Max(LandingBaseDensity * 2.0f, 1.0f)); - } - const float CoreSDF = TunnelCore.SDF; - if (!bTunnelSupportFloor - && CoreSDF < -VoxelPassageGeometry::CaveTunnelAirCoreInsetVoxels) - { - Result = FMath::Max( - Result, - FMath::Max(LandingBaseDensity * 2.0f, 1.0f)); - } + const bool bTunnelSupportFloor = TunnelCore.bSupportFloor; + if (bTunnelSupportFloor) + { + Result = FMath::Min( + Result, + -FMath::Max(LandingBaseDensity * 2.0f, 1.0f)); + } + const float CoreSDF = TunnelCore.SDF; + if (!bTunnelSupportFloor + && CoreSDF < -VoxelPassageGeometry::CaveTunnelAirCoreInsetVoxels) + { + Result = FMath::Max( + Result, + FMath::Max(LandingBaseDensity * 2.0f, 1.0f)); + } - // The graph tunnel can overlap an inter-strate landing at a room mouth. Its air - // backstop is allowed to reopen the tunnel, but the landing's proved support floor - // must own the final floor band; otherwise the graph post can erase the only support - // surface at the mouth and leave the player-fit graph with a disconnected pocket. - VF_ApplyOriginLandingFloorMC( - Result, WorldX, WorldY, WorldZ, - CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ, - CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius); - StrateManager->ApplyPassageLandingFloorMC( - Result, WorldX, WorldY, WorldZ, LandingBaseDensity); - StrateManager->ApplyPassageLandingRoomFloorMC( - Result, WorldX, WorldY, WorldZ, LandingBaseDensity); + // The graph tunnel can overlap an inter-strate landing at a room mouth. Its air + // backstop is allowed to reopen the tunnel, but the landing's proved support floor + // must own the final floor band; otherwise the graph post can erase the only support + // surface at the mouth and leave the player-fit graph with a disconnected pocket. + VF_ApplyOriginLandingFloorMC( + Result, WorldX, WorldY, WorldZ, + CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ, + CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius); + StrateManager->ApplyPassageLandingFloorMC( + Result, WorldX, WorldY, WorldZ, LandingBaseDensity); + StrateManager->ApplyPassageLandingRoomFloorMC( + Result, WorldX, WorldY, WorldZ, LandingBaseDensity); + } } DensityStructuralPostsTimer.End(); } @@ -3468,17 +3497,284 @@ bool UVoxelGenerator::GetSurfaceHeightAt(float WorldX, float WorldY, int32 Chunk // disturbances/diff. Tout ce qui n'est pas prouvable ⇒ Mixed (le seul coût d'un // faux Mixed est du CPU ; un faux AllSolid/AllAir serait un trou). +// A full tile can be Mixed even when every smaller region is provably the same class. This is +// particularly common at level 0: broad operator bounds overlap an unbounded carve, while the +// actual tile-sized sub-boxes are all air. Refine only an already-Mixed proof. Every child must +// resolve, and all resolved children must agree; an unresolved or conflicting child remains Mixed. +// This is a proof refinement, never a density/sample shortcut. +static EVoxelTileClass VF_ClassifyBoxRefined(const FVoxelOpStack& Stack, + const FBox& VoxelBox, + const FVoxelOpContext& Context, + const UVoxelGenerator* Generator, + const UVoxelStrateManager* StructuralManager) +{ + // The ordinary proof stays shallow. The finite-domain warp retry is paid only by a + // tile that already failed the cheap proof, and can afford one more split level because + // its purpose is to discharge the LOD0 false-Mixed cases without sampling the field. + const int32 MaxRefinementDepth = Context.bTightenWarpProof ? 5 : 2; + const bool bTask4TimingProbe = Context.bUseLatticeProof + && Context.LatticeOriginVoxels == FIntVector(32, 0, -32) + && Context.Step == 1; + int32 RefineNodeCount = 0; + int32 ExactLeafCount = 0; + int32 ExactSampleCount = 0; + uint64 StackClassifyCycles = 0; + uint64 ExactSampleCycles = 0; + const bool bTask4ExactProbe = Context.bUseLatticeProof + && (Context.LatticeOriginVoxels == FIntVector(-64, -64, -64) + || Context.LatticeOriginVoxels == FIntVector(-64, -96, -64)) + && Context.Step == 1; + auto ClassifyExactLatticeLeaf = [&](const FBox& Box, bool bUseFinalField) -> EVoxelTileClass + { + if (!Context.bUseLatticeProof || Context.Step <= 0) + { + return EVoxelTileClass::Mixed; + } + + const float Step = static_cast(Context.Step); + const FVector Origin(static_cast(Context.LatticeOriginVoxels.X), + static_cast(Context.LatticeOriginVoxels.Y), + static_cast(Context.LatticeOriginVoxels.Z)); + const int32 IX0 = FMath::CeilToInt((static_cast(Box.Min.X) - Origin.X) / Step - 1.0e-4f); + const int32 IY0 = FMath::CeilToInt((static_cast(Box.Min.Y) - Origin.Y) / Step - 1.0e-4f); + const int32 IZ0 = FMath::CeilToInt((static_cast(Box.Min.Z) - Origin.Z) / Step - 1.0e-4f); + const int32 IX1 = FMath::FloorToInt((static_cast(Box.Max.X) - Origin.X) / Step + 1.0e-4f); + const int32 IY1 = FMath::FloorToInt((static_cast(Box.Max.Y) - Origin.Y) / Step + 1.0e-4f); + const int32 IZ1 = FMath::FloorToInt((static_cast(Box.Max.Z) - Origin.Z) / Step + 1.0e-4f); + const int64 Count = ((int64)IX1 - IX0 + 1) + * ((int64)IY1 - IY0 + 1) + * ((int64)IZ1 - IZ0 + 1); + if (IX1 < IX0 || IY1 < IY0 || IZ1 < IZ0 || Count <= 0 || Count > 256) + { + return EVoxelTileClass::Mixed; + } + + bool bAllSolid = true; + bool bAllAir = true; + int32 PositiveCount = 0; + int32 NegativeCount = 0; + int32 ZeroOrInvalidCount = 0; + float MinDensity = FLT_MAX; + float MaxDensity = -FLT_MAX; + for (int32 IZ = IZ0; IZ <= IZ1; ++IZ) + for (int32 IY = IY0; IY <= IY1; ++IY) + for (int32 IX = IX0; IX <= IX1; ++IX) + { + const float WorldX = Origin.X + static_cast(IX) * Step; + const float WorldY = Origin.Y + static_cast(IY) * Step; + const float WorldZ = Origin.Z + static_cast(IZ) * Step; + // The operator stack is the cheap proof field. A passage/landing post is applied + // after that stack by GetDensityAt; for a small child that intersects one of those + // exact lattice modifiers, certify the final MC-facing field instead of trusting a + // core-stack verdict that is deliberately conservative at the post boundary. + const uint64 SampleStartCycles = bTask4TimingProbe ? FPlatformTime::Cycles64() : 0; + const float Density = bUseFinalField + ? -Generator->GetDensityAt(WorldX, WorldY, WorldZ) + : Stack.EvalInternal(WorldX, WorldY, WorldZ); + if (bTask4TimingProbe) + { + ExactSampleCycles += FPlatformTime::Cycles64() - SampleStartCycles; + ++ExactSampleCount; + } + if (!FMath::IsFinite(Density) || Density == 0.0f) + { + ++ZeroOrInvalidCount; + IZ = IZ1; + IY = IY1; + break; + } + MinDensity = FMath::Min(MinDensity, Density); + MaxDensity = FMath::Max(MaxDensity, Density); + if (Density > 0.0f) { ++PositiveCount; bAllAir = false; } + else { ++NegativeCount; bAllSolid = false; } + if (!bAllSolid && !bAllAir) + { + IZ = IZ1; + IY = IY1; + break; + } + } + const EVoxelTileClass Verdict = ZeroOrInvalidCount > 0 || (PositiveCount > 0 && NegativeCount > 0) + ? EVoxelTileClass::Mixed + : (bAllSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir); + if (bTask4ExactProbe) + { + UE_LOG(LogTemp, Warning, + TEXT("[Task4ExactProbe] depth_box=(%.0f,%.0f,%.0f)-(%.0f,%.0f,%.0f) count=%lld pos=%d neg=%d zero=%d min=%.6g max=%.6g verdict=%d"), + (float)Box.Min.X, (float)Box.Min.Y, (float)Box.Min.Z, + (float)Box.Max.X, (float)Box.Max.Y, (float)Box.Max.Z, + (long long)Count, PositiveCount, NegativeCount, ZeroOrInvalidCount, + MinDensity, MaxDensity, static_cast(Verdict)); + } + return Verdict; + }; + auto Refine = [&](auto&& Self, const FBox& Box, int32 Depth) -> EVoxelTileClass + { + ++RefineNodeCount; + const uint64 StackStartCycles = bTask4TimingProbe ? FPlatformTime::Cycles64() : 0; + const EVoxelTileClass Whole = Stack.ClassifyBox(Box, Context); + if (bTask4TimingProbe) + { + StackClassifyCycles += FPlatformTime::Cycles64() - StackStartCycles; + } + const bool bHasStructuralCandidate = StructuralManager != nullptr + && (StructuralManager->AnyPassageNearLattice( + Box, Context.LatticeOriginVoxels, Context.Step) + || StructuralManager->AnyPassageLandingFloorNearLattice( + Box, Context.LatticeOriginVoxels, Context.Step) + || StructuralManager->AnyOriginLandingNearLattice( + Box, Context.LatticeOriginVoxels, Context.Step) + || StructuralManager->AnyOriginLandingFloorNearLattice( + Box, Context.LatticeOriginVoxels, Context.Step)); + // A resolved core box already includes the conservative passage carve bound. Only a + // Mixed core box needs the post-disturbance/floor evaluator; applying it to every resolved + // child would turn one narrow landing into hundreds of tiny GetDensityAt walks. + const bool bNeedsFinalField = Generator != nullptr + && bHasStructuralCandidate + && Whole == EVoxelTileClass::Mixed; + const bool bTask4RefineProbe = Context.bUseLatticeProof + && (Context.LatticeOriginVoxels == FIntVector(-96, 96, -96) + || Context.LatticeOriginVoxels == FIntVector(-32, 64, -64) + || Context.LatticeOriginVoxels == FIntVector(-64, -64, -64) + || Context.LatticeOriginVoxels == FIntVector(-64, -96, -64)) + && Context.Step == 1; + if (bTask4RefineProbe) + { + UE_LOG(LogTemp, Warning, + TEXT("[Task4RefineProbe] depth=%d box=(%.0f,%.0f,%.0f)-(%.0f,%.0f,%.0f) verdict=%d"), + Depth, (float)Box.Min.X, (float)Box.Min.Y, (float)Box.Min.Z, + (float)Box.Max.X, (float)Box.Max.Y, (float)Box.Max.Z, + static_cast(Whole)); + } + if (Whole != EVoxelTileClass::Mixed) + { + return Whole; + } + // At this size the box contains at most 5^3 exact MC vertices. Evaluating those same + // vertices is a small, sound certificate for the lattice field the mesher will consume; + // it is not a lower-resolution fallback and never changes GenerateMesh's sample count. + // This discharges the last conservative SDF interval misses without walking another + // refinement level or rebuilding the morphology bounds for every half-box. + if (Depth >= 3) + { + ++ExactLeafCount; + const EVoxelTileClass Exact = ClassifyExactLatticeLeaf(Box, bNeedsFinalField); + if (Exact != EVoxelTileClass::Mixed) + { + return Exact; + } + // The final-field leaf is deliberately a conservative sampling certificate. A + // mixed result means this child really contains both final signs (or an invalid + // sample); splitting it again cannot prove the parent uniform and only repeats the + // expensive post-aware evaluator. + if (bNeedsFinalField) + { + return EVoxelTileClass::Mixed; + } + } + if (Depth >= MaxRefinementDepth) + { + return bNeedsFinalField ? EVoxelTileClass::Mixed : Whole; + } + + const FVector Mid = (Box.Min + Box.Max) * 0.5f; + bool bSawSolid = false; + bool bSawAir = false; + for (int32 Z = 0; Z < 2; ++Z) + for (int32 Y = 0; Y < 2; ++Y) + for (int32 X = 0; X < 2; ++X) + { + const FVector ChildMin( + X == 0 ? Box.Min.X : Mid.X, + Y == 0 ? Box.Min.Y : Mid.Y, + Z == 0 ? Box.Min.Z : Mid.Z); + const FVector ChildMax( + X == 0 ? Mid.X : Box.Max.X, + Y == 0 ? Mid.Y : Box.Max.Y, + Z == 0 ? Mid.Z : Box.Max.Z); + const EVoxelTileClass Child = Self(Self, FBox(ChildMin, ChildMax), Depth + 1); + if (Child == EVoxelTileClass::Mixed) + { + return EVoxelTileClass::Mixed; + } + bSawSolid |= Child == EVoxelTileClass::AllSolid; + bSawAir |= Child == EVoxelTileClass::AllAir; + if (bSawSolid && bSawAir) + { + return EVoxelTileClass::Mixed; + } + } + return bSawSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir; + }; + const EVoxelTileClass Result = Refine(Refine, VoxelBox, 0); + if (bTask4TimingProbe) + { + UE_LOG(LogTemp, Warning, + TEXT("[Task4TimingProbe] origin=(%d,%d,%d) step=%d nodes=%d exact_leaves=%d samples=%d stack_ms=%.3f sample_ms=%.3f result=%d"), + Context.LatticeOriginVoxels.X, Context.LatticeOriginVoxels.Y, + Context.LatticeOriginVoxels.Z, Context.Step, + RefineNodeCount, ExactLeafCount, ExactSampleCount, + FPlatformTime::ToMilliseconds64(StackClassifyCycles), + FPlatformTime::ToMilliseconds64(ExactSampleCycles), + static_cast(Result)); + } + return Result; +} + +// The ordinary proof is deliberately cheap. Only a box that remains Mixed after its bounded +// spatial refinement pays for the finite-domain warp interval; this keeps the common empty-tile +// path at its existing cost while giving the few conservative misses a second sound chance. +static EVoxelTileClass VF_ClassifyBoxWithWarpRetry(const FVoxelOpStack& Stack, + const FBox& VoxelBox, + const FVoxelOpContext& Context, + const UVoxelGenerator* Generator = nullptr, + const UVoxelStrateManager* StructuralManager = nullptr) +{ + const EVoxelTileClass Initial = VF_ClassifyBoxRefined( + Stack, VoxelBox, Context, Generator, StructuralManager); + // A Mixed fine tile is not evidence of geometry: it can be the conservative result of a + // coarse operator interval. Keep the bounded retry for every LOD; skipping it recreates + // the false-Mixed empty tiles that send the mesher through the expensive path. + if (Initial != EVoxelTileClass::Mixed + || Context.bTightenWarpProof) + { + return Initial; + } + + FVoxelOpContext TightContext = Context; + TightContext.bTightenWarpProof = true; + return VF_ClassifyBoxRefined( + Stack, VoxelBox, TightContext, Generator, StructuralManager); +} + EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis) const { // Mêmes clamps que GenerateMesh — le verdict doit couvrir le treillis réellement échantillonné. Step = FMath::Max(1, Step); + // ClassifyTile runs before GenerateMesh installs its LOD TLS. The room-graph source uses this + // value only to choose a sound, window-invariant cache region; leaving the previous worker + // value (normally 1) makes an LOD4 proof rebuild fine per-chunk graph windows across the + // coarse tile. Scope it exactly like the mesher does so standalone classifier calls are also + // keyed by their actual lattice step. + TGuardValue ClassifySampleStepGuard(VoxelGenLOD::SampleStep, Step); const int32 CPA = FMath::Clamp(CellsPerAxis, 2, CHUNK_SIZE); - const int32 GridDim = CPA + 1; // g ∈ [-1, GridDim] par axe (marge des normales incluse) + const int32 GridDim = CPA + 1; // cell-vertex count: g ∈ [0, CPA] - // Boîte voxel englobant tous les échantillons du treillis. - const int32 MinX = OriginVoxels.X - Step, MaxX = OriginVoxels.X + GridDim * Step; - const int32 MinY = OriginVoxels.Y - Step, MaxY = OriginVoxels.Y + GridDim * Step; - const int32 MinZ = OriginVoxels.Z - Step, MaxZ = OriginVoxels.Z + GridDim * Step; + // The mesher's outer one-sample halo is used only for central-difference normals. It cannot + // create a marching-cubes cell: every case index reads vertices g ∈ [0, CPA]. Classify those + // actual cell vertices, not the halo. Including g=-1/CPA+1 made a coarse tile look Mixed when + // the halo crossed a surface outside the tile while every cell in the tile was uniform. + const int32 MinX = OriginVoxels.X, MaxX = OriginVoxels.X + CPA * Step; + const int32 MinY = OriginVoxels.Y, MaxY = OriginVoxels.Y + CPA * Step; + const int32 MinZ = OriginVoxels.Z, MaxZ = OriginVoxels.Z + CPA * Step; + const bool bTask4Probe = + (OriginVoxels == FIntVector(-96, 96, -96) && Step == 1) + || (OriginVoxels == FIntVector(-32, 64, -64) && Step == 1) + || (OriginVoxels == FIntVector(-64, -64, -64) && Step == 1) + || (OriginVoxels == FIntVector(-64, -96, -64) && Step == 1) + || (OriginVoxels == FIntVector(0, 32, -96) && Step == 2) + || (OriginVoxels == FIntVector(-32, -32, -32) && Step == 8) + || (OriginVoxels == FIntVector(-96, -32, -32) && Step == 16); // Division entière PLANCHER (les coords négatives tronquent vers 0 en C++ — pas floor). auto FloorDivC = [](int32 A, int32 B) -> int32 @@ -3535,16 +3831,17 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in int32 RecipeBottomChunkZ = 0; if (!StrateManager->GetStrateChunkZBounds( RecipeMinChunkZ, RecipeTopChunkZ, RecipeBottomChunkZ) - || RecipeMinChunkZ < RecipeBottomChunkZ - || RecipeMaxChunkZ > RecipeTopChunkZ) + || RecipeMinChunkZ < RecipeBottomChunkZ + || RecipeMaxChunkZ > RecipeTopChunkZ) { return EVoxelTileClass::Mixed; } - const int32 RecipeMinChunkX = FloorDivC(MinX, CHUNK_SIZE); - const int32 RecipeMaxChunkX = FloorDivC(MaxX, CHUNK_SIZE); - const int32 RecipeMinChunkY = FloorDivC(MinY, CHUNK_SIZE); - const int32 RecipeMaxChunkY = FloorDivC(MaxY, CHUNK_SIZE); + // Recipes and disturbances are selected from the strate slot's Z only. The manager + // copies the recipe arrays into the output parameters, so repeating this check for every + // XY key in the sampled box turns one coarse tile into thousands of identical deep copies. + // Keep one representative per distinct Z: this is equivalent for the APIs above and still + // rejects a tile when any touched Z changes recipe, bounds, or disturbance state. for (int32 CZ = RecipeMinChunkZ; CZ <= RecipeMaxChunkZ; ++CZ) { int32 TouchedTop = 0; @@ -3554,33 +3851,27 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in { return EVoxelTileClass::Mixed; } - for (int32 CY = RecipeMinChunkY; CY <= RecipeMaxChunkY; ++CY) + int32 TouchedSeed = 0; + ECaveGeneratorType TouchedArchetype = ECaveGeneratorType::TunnelNetwork; + FVoxelStrateArchetypeParams TouchedParams; + FVoxelOpStackRecipe TouchedRecipe; + const FIntVector TouchedChunk(0, 0, CZ); + if (!StrateManager->GetRecipeForChunk( + TouchedChunk, TouchedSeed, TouchedArchetype, + TouchedParams, TouchedRecipe) + || TouchedSeed != RecipeSeed + || TouchedArchetype != RecipeArchetype) { - for (int32 CX = RecipeMinChunkX; CX <= RecipeMaxChunkX; ++CX) - { - int32 TouchedSeed = 0; - ECaveGeneratorType TouchedArchetype = ECaveGeneratorType::TunnelNetwork; - FVoxelStrateArchetypeParams TouchedParams; - FVoxelOpStackRecipe TouchedRecipe; - const FIntVector TouchedChunk(CX, CY, CZ); - if (!StrateManager->GetRecipeForChunk( - TouchedChunk, TouchedSeed, TouchedArchetype, - TouchedParams, TouchedRecipe) - || TouchedSeed != RecipeSeed - || TouchedArchetype != RecipeArchetype) - { - return EVoxelTileClass::Mixed; - } + return EVoxelTileClass::Mixed; + } - const FStrateDisturbanceParams Disturbances = - StrateManager->GetDisturbanceParamsForChunk(TouchedChunk); - if (Disturbances.ChasmDensity > 0.0f - || Disturbances.BridgeDensity > 0.0f - || Disturbances.RidgeDensity > 0.0f) - { - return EVoxelTileClass::Mixed; - } - } + const FStrateDisturbanceParams Disturbances = + StrateManager->GetDisturbanceParamsForChunk(TouchedChunk); + if (Disturbances.ChasmDensity > 0.0f + || Disturbances.BridgeDensity > 0.0f + || Disturbances.RidgeDensity > 0.0f) + { + return EVoxelTileClass::Mixed; } } @@ -3593,13 +3884,17 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in return EVoxelTileClass::Mixed; } RecipeContext.ChunkCoord = FIntVector( - RecipeMinChunkX, RecipeMinChunkY, RecipeMinChunkZ); + FloorDivC(MinX, CHUNK_SIZE), FloorDivC(MinY, CHUNK_SIZE), RecipeMinChunkZ); RecipeContext.Step = Step; RecipeContext.LayoutVersion = StrateManager->GetLayoutVersion(); RecipeContext.WorldRadiusVoxels = WorldRadiusVoxels; RecipeContext.EdgeSealThickness = EdgeSealThickness; + RecipeContext.bUseLatticeProof = true; + RecipeContext.LatticeOriginVoxels = OriginVoxels; RecipeStack.PrepareChunk(RecipeContext); - return RecipeStack.ClassifyBox(TileVoxelBox, RecipeContext); + const EVoxelTileClass RecipeVerdict = + VF_ClassifyBoxWithWarpRetry(RecipeStack, TileVoxelBox, RecipeContext); + return RecipeVerdict; } #if WITH_EDITOR @@ -3687,46 +3982,18 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in RegionContext.LayoutVersion = StrateManager->GetLayoutVersion(); RegionContext.WorldRadiusVoxels = WorldRadiusVoxels; RegionContext.EdgeSealThickness = EdgeSealThickness; + RegionContext.bUseLatticeProof = true; + RegionContext.LatticeOriginVoxels = OriginVoxels; RegionStack.PrepareChunk(RegionContext); - return RegionStack.ClassifyBox(TileVoxelBox, RegionContext); + const EVoxelTileClass RegionVerdict = + VF_ClassifyBoxWithWarpRetry(RegionStack, TileVoxelBox, RegionContext); + return RegionVerdict; } #endif bool bCanSolid = true; // "tout le treillis est solide" encore prouvable bool bCanAir = true; // "tout le treillis est air" encore prouvable - - // ── Gardes "carveurs d'air" : cassent AllSolid, jamais AllAir (ils n'ajoutent pas de roche). ── - if (StrateManager->AnyPassageNearBox(FVector(MinX, MinY, MinZ), FVector(MaxX, MaxY, MaxZ))) - { - bCanSolid = false; - } - // A landing is bidirectional geometry: its chamber kills the AllSolid hypothesis, while its - // guaranteed support slab kills AllAir. Without this second guard a tile covering only the - // floor could be classified uniformly air and the mesher would skip the solid support that - // keeps the player from falling through it. The operator-stack path returns Both for the - // same box, so the hand-written legacy fold must carry the identical hypothesis kill here. - if (StrateManager->AnyPassageLandingFloorNearBox( - FVector(MinX, MinY, MinZ), FVector(MaxX, MaxY, MaxZ))) - { - bCanAir = false; - } - if (StrateManager->AnyOriginLandingNearBox( - FVector(MinX, MinY, MinZ), FVector(MaxX, MaxY, MaxZ))) - { - // The origin post is a room carve, not a full-height spine. Its conservative manager - // guard is per tile, so it retains the hot-path invariant while covering every strate's - // room AABB and its four-voxel carve blend. - bCanSolid = false; - } - if (StrateManager->AnyOriginLandingFloorNearBox( - FVector(MinX, MinY, MinZ), FVector(MaxX, MaxY, MaxZ))) - { - // The same origin room also owns a guaranteed support slab, so a tile touching only that - // slab cannot be proved uniformly air. - bCanAir = false; - } - // ── Catégorisation par Z du treillis : gap bedrock = solide ; hors layout = air constant ; // SurfaceWorld = test colonne ; un slot cave opt-in = verdict de pile sur sa sous-boîte. ── struct FSurfSlot @@ -3780,7 +4047,7 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in int32 UnusedTopCZ = 0, UnusedBotCZ = 0; return StrateManager->GetStrateChunkZBounds(Z, UnusedTopCZ, UnusedBotCZ); }; - for (int32 g = -1; g <= GridDim; ++g) + for (int32 g = 0; g <= CPA; ++g) { const int32 Zi = OriginVoxels.Z + g * Step; const int32 ChunkZ = FloorDivC(Zi, CHUNK_SIZE); @@ -3965,10 +4232,65 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in if (bInBand) { bCanAir = false; } else { S.InteriorZ.Add(Z); } } - if (!bCanSolid && !bCanAir) return EVoxelTileClass::Mixed; + if (!bCanSolid && !bCanAir) { return EVoxelTileClass::Mixed; } } - //========================================================================= + if (bTask4Probe) + { + UE_LOG(LogTemp, Warning, + TEXT("[Task4Probe] phase=z origin=(%d,%d,%d) step=%d solid=%d air=%d cave=%d gap=%d surface=%d out=%d cave_z=(%d,%d)"), + OriginVoxels.X, OriginVoxels.Y, OriginVoxels.Z, Step, + bCanSolid ? 1 : 0, bCanAir ? 1 : 0, bAnyCave ? 1 : 0, + bAnyGap ? 1 : 0, bAnySurface ? 1 : 0, bAnyOutOfLayout ? 1 : 0, + CaveMinZ, CaveMaxZ); + } + + // Out-of-layout is a constant-air field. Passage tubes and origin rooms can only carve that + // field, so they cannot invalidate AllAir; only their guaranteed support floors can add solid + // voxels. Do this after the cheap Z categorisation so open-air LOD tiles avoid the exact + // passage-carve lattice walk and the room scan entirely. A floor-only tile remains Mixed and + // is meshed normally, preserving the support backstops. + if (bAnyOutOfLayout && !bAnyCave && !bAnySurface && !bAnyGap) + { + if (StrateManager->AnyPassageLandingFloorNearLattice( + TileVoxelBox, OriginVoxels, Step) + || StrateManager->AnyOriginLandingFloorNearLattice( + TileVoxelBox, OriginVoxels, Step)) + { + return EVoxelTileClass::Mixed; + } + return EVoxelTileClass::AllAir; + } + + // ── Structural passage/origin guards for non-open-air categories. ── + // Carvers kill AllSolid but never add rock; landing and origin support floors kill AllAir. + if (StrateManager->AnyPassageNearLattice( + TileVoxelBox, OriginVoxels, Step)) + { + bCanSolid = false; + } + if (StrateManager->AnyPassageLandingFloorNearLattice( + TileVoxelBox, OriginVoxels, Step)) + { + bCanAir = false; + } + if (StrateManager->AnyOriginLandingNearLattice( + TileVoxelBox, OriginVoxels, Step)) + { + bCanSolid = false; + } + if (StrateManager->AnyOriginLandingFloorNearLattice( + TileVoxelBox, OriginVoxels, Step)) + { + bCanAir = false; + } + if (bTask4Probe) + { + UE_LOG(LogTemp, Warning, + TEXT("[Task4Probe] phase=guards origin=(%d,%d,%d) step=%d solid=%d air=%d"), + OriginVoxels.X, OriginVoxels.Y, OriginVoxels.Z, Step, + bCanSolid ? 1 : 0, bCanAir ? 1 : 0); + } // ── LE PLIAGE DE LA PILE D'OPÉRATEURS, POUR LES ARCHÉTYPES DE CAVE ── //========================================================================= // ⚠️ ERREUR ICI = TROU, PAS RÉGRESSION. Un verdict non-Mixed fait SAUTER `GenerateMesh` : pas @@ -4017,10 +4339,14 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in // represented by this stack; their hypotheses were folded separately in the Z pass. const int32 CZ0 = FloorDivC(CaveMinZ, CHUNK_SIZE), CZ1 = FloorDivC(CaveMaxZ, CHUNK_SIZE); - // Une tuile très étalée (Step élevé) toucherait trop de chunks pour que cette vérification - // reste bon marché. Au-delà, `Mixed` — on renonce au gain, jamais à la sûreté. + // Keep the parameter identity check bounded, but do not use the old 27-key cutoff here: + // LOD4 legitimately covers 19^3 = 6,859 chunk keys. Returning Mixed at that point sent + // the tile straight to GenerateMesh, where GetDensityAt rebuilt one exact-chunk state for + // almost every coarse sample. The operator proof now shares its root morphology cache + // across refined children, so this scan is cheap compared with the fallback it prevents. const int64 NumChunkCoords = (int64)(CX1 - CX0 + 1) * (int64)(CY1 - CY0 + 1) * (int64)(CZ1 - CZ0 + 1); - if (NumChunkCoords > 27) + constexpr int64 MaxClassifiedChunkCoords = 262144; + if (NumChunkCoords > MaxClassifiedChunkCoords) { INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams); return EVoxelTileClass::Mixed; @@ -4144,6 +4470,9 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in OpCtx.Step = Step; OpCtx.WorldRadiusVoxels = WorldRadiusVoxels; OpCtx.EdgeSealThickness = EdgeSealThickness; + OpCtx.bUseLatticeProof = true; + OpCtx.bTightenWarpProof = Step <= 2; + OpCtx.LatticeOriginVoxels = OriginVoxels; FVoxelOpStack TileStack; if (!VF_BuildOpStackForChunk(CaveType, Refs, Seed, OriginSpineRadius, @@ -4158,15 +4487,90 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in const FBox CaveBox(FVector((float)MinX, (float)MinY, (float)CaveMinZ), FVector((float)MaxX, (float)MaxY, (float)CaveMaxZ)); - const EVoxelTileClass StackVerdict = TileStack.ClassifyBox(CaveBox, OpCtx); + const EVoxelTileClass StackVerdict = VF_ClassifyBoxWithWarpRetry( + TileStack, CaveBox, OpCtx, this, StrateManager); + if (bTask4Probe) + { + UE_LOG(LogTemp, Warning, + TEXT("[Task4Probe] phase=stack origin=(%d,%d,%d) step=%d verdict=%d solid=%d air=%d"), + OriginVoxels.X, OriginVoxels.Y, OriginVoxels.Z, Step, + static_cast(StackVerdict), bCanSolid ? 1 : 0, bCanAir ? 1 : 0); + } if (StackVerdict == EVoxelTileClass::Mixed) { + if (bTask4Probe) + { + int32 SolidKiller = INDEX_NONE; + int32 AirKiller = INDEX_NONE; + const EVoxelTileClass Attributed = TileStack.ClassifyBoxAttributed( + CaveBox, OpCtx, SolidKiller, AirKiller); + UE_LOG(LogTemp, Warning, + TEXT("[Task4Probe] stack_attributed origin=(%d,%d,%d) step=%d verdict=%d solid_killer=%d:%s air_killer=%d:%s"), + OriginVoxels.X, OriginVoxels.Y, OriginVoxels.Z, Step, + static_cast(Attributed), SolidKiller, + TileStack.GetOpDebugName(SolidKiller), AirKiller, + TileStack.GetOpDebugName(AirKiller)); + + int32 FinalPositive = 0; + int32 FinalNegative = 0; + int32 FinalZeroOrInvalid = 0; + float FinalMin = FLT_MAX; + float FinalMax = -FLT_MAX; + for (int32 Z = MinZ; Z <= MaxZ; Z += Step) + for (int32 Y = MinY; Y <= MaxY; Y += Step) + for (int32 X = MinX; X <= MaxX; X += Step) + { + const float FinalDensity = GetDensityAt((float)X, (float)Y, (float)Z); + if (!FMath::IsFinite(FinalDensity) || FinalDensity == 0.0f) + { + ++FinalZeroOrInvalid; + } + else if (FinalDensity > 0.0f) + { + ++FinalPositive; + FinalMin = FMath::Min(FinalMin, FinalDensity); + FinalMax = FMath::Max(FinalMax, FinalDensity); + } + else + { + ++FinalNegative; + FinalMin = FMath::Min(FinalMin, FinalDensity); + FinalMax = FMath::Max(FinalMax, FinalDensity); + } + } + UE_LOG(LogTemp, Warning, + TEXT("[Task4Probe] final_samples origin=(%d,%d,%d) step=%d pos=%d neg=%d zero=%d min=%.6g max=%.6g"), + OriginVoxels.X, OriginVoxels.Y, OriginVoxels.Z, Step, + FinalPositive, FinalNegative, FinalZeroOrInvalid, FinalMin, FinalMax); + } INC_DWORD_STAT(STAT_VoxelForgeCaveBailStackVerdict); return EVoxelTileClass::Mixed; } - if (StackVerdict == EVoxelTileClass::AllSolid) { bCanAir = false; } - else { bCanSolid = false; } + const bool bCaveOnlyTile = bAnyCave + && !bAnyGap && !bAnySurface && !bAnyOutOfLayout + && CaveMinZ == MinZ && CaveMaxZ == MaxZ; + if (bCaveOnlyTile && StackVerdict == EVoxelTileClass::AllSolid) + { + // The stack verdict covers the complete cave-only MC lattice. Its exact structural + // post path was supplied above, so a broad conservative passage guard must not force + // this already-certified solid tile through GenerateMesh. + bCanSolid = true; + bCanAir = false; + } + else if (bCaveOnlyTile && StackVerdict == EVoxelTileClass::AllAir) + { + bCanSolid = false; + bCanAir = true; + } + else if (StackVerdict == EVoxelTileClass::AllSolid) + { + bCanAir = false; + } + else + { + bCanSolid = false; + } // Le verdict cave se plie avec gap=solide, hors-layout=air, seals surface=solide. Si les // deux hypothèses sont mortes ici, les catégories se contredisent : ce n'est PAS un échec @@ -4240,18 +4644,28 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in && (Slots[0].InteriorZ.Num() > 0 || (NumSlots > 1 && Slots[1].InteriorZ.Num() > 0)); if (bNeedColumns) { - const int32 PreStride = FMath::Max(1, (GridDim + 1) / 4); - for (int32 gy = -1; gy <= GridDim; gy += PreStride) - for (int32 gx = -1; gx <= GridDim; gx += PreStride) + const int32 PreStride = FMath::Max(1, GridDim / 4); + for (int32 gy = 0; gy <= CPA; gy += PreStride) + for (int32 gx = 0; gx <= CPA; gx += PreStride) if (!TestColumn(gx, gy)) return EVoxelTileClass::Mixed; - for (int32 gy = -1; gy <= GridDim; ++gy) - for (int32 gx = -1; gx <= GridDim; ++gx) + for (int32 gy = 0; gy <= CPA; ++gy) + for (int32 gx = 0; gx <= CPA; ++gx) if (!TestColumn(gx, gy)) return EVoxelTileClass::Mixed; } // Ici exactement UNE hypothèse doit survivre (chaque point testé en tue une ; les tuiles // sans point intérieur ont tué AllAir via gap/seal). Égalité = prudence → Mixed. - if (bCanSolid == bCanAir) return EVoxelTileClass::Mixed; + if (bCanSolid == bCanAir) + { + if (bTask4Probe) + { + UE_LOG(LogTemp, Warning, + TEXT("[Task4Probe] phase=final origin=(%d,%d,%d) step=%d verdict=0 solid=%d air=%d"), + OriginVoxels.X, OriginVoxels.Y, OriginVoxels.Z, Step, + bCanSolid ? 1 : 0, bCanAir ? 1 : 0); + } + return EVoxelTileClass::Mixed; + } if (bAnyCave) { // Compte seulement les verdicts FINAUX qui sautent réellement une tuile. Une pile peut avoir @@ -4261,6 +4675,13 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in if (bCanSolid) { INC_DWORD_STAT(STAT_VoxelForgeTilesOpStackSolid); } else { INC_DWORD_STAT(STAT_VoxelForgeTilesOpStackAir); } } + if (bTask4Probe) + { + UE_LOG(LogTemp, Warning, + TEXT("[Task4Probe] phase=final origin=(%d,%d,%d) step=%d verdict=%d solid=%d air=%d"), + OriginVoxels.X, OriginVoxels.Y, OriginVoxels.Z, Step, + bCanSolid ? 1 : 2, bCanSolid ? 1 : 0, bCanAir ? 1 : 0); + } return bCanSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir; } diff --git a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp index 8324be4..a9422fc 100644 --- a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp +++ b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp @@ -227,6 +227,85 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, MesherSetupTimer.End(); + // Optional 8x8x8-cell block proof. The tile classifier has already returned Mixed, but + // that is a conservative answer over the whole tile. A tile can still be uniformly solid + // (or uniformly air) when its smaller blocks are each provable. Do this before allocating + // or filling the density grid: the grid is only needed when at least one block can contain + // a surface. A whole-tile early return is allowed only when every relevant block has the + // SAME verdict; adjacent uniform blocks with opposite signs can contain a surface between + // them. This is a proof-only optimization: ClassifyTile is conservative and no samples or + // resolution are removed from a tile that reaches the mesher. + TArray BlockSkip; + int32 BlocksPerAxis = 0; + if (bUseBlockEarlyOut && !OutCaptureGrid && Step == 1) + { + constexpr int32 BlockCells = 8; + BlocksPerAxis = (CellsPerAxis + BlockCells - 1) / BlockCells; + BlockSkip.Init(0, BlocksPerAxis * BlocksPerAxis * BlocksPerAxis); + bool bHaveUniformBlock = false; + EVoxelTileClass FirstUniformVerdict = EVoxelTileClass::Mixed; + bool bAllRelevantBlocksUniform = true; + bool bAllRelevantBlocksSame = true; + + for (int32 BlockZ = 0; BlockZ < BlocksPerAxis; ++BlockZ) + { + const int32 BlockMinZ = BlockZ * BlockCells; + const int32 BlockMaxZ = FMath::Min(CellsPerAxis - 1, + BlockMinZ + BlockCells - 1); + if (BlockMaxZ < CzLo || BlockMinZ > CzHi) + { + continue; + } + for (int32 BlockY = 0; BlockY < BlocksPerAxis; ++BlockY) + { + for (int32 BlockX = 0; BlockX < BlocksPerAxis; ++BlockX) + { + const FIntVector BlockOrigin = OriginVoxels + FIntVector( + BlockX * BlockCells * Step, + BlockY * BlockCells * Step, + BlockMinZ * Step); + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::MesherBlockTests); + } + const EVoxelTileClass Verdict = Generator->ClassifyTile( + BlockOrigin, Step, BlockCells); + if (Verdict == EVoxelTileClass::AllSolid + || Verdict == EVoxelTileClass::AllAir) + { + BlockSkip[((BlockZ * BlocksPerAxis) + BlockY) * BlocksPerAxis + BlockX] = 1; + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter( + Verdict == EVoxelTileClass::AllSolid + ? VoxelDensityProfile::ECounter::MesherBlockAllSolid + : VoxelDensityProfile::ECounter::MesherBlockAllAir); + } + if (!bHaveUniformBlock) + { + FirstUniformVerdict = Verdict; + bHaveUniformBlock = true; + } + else if (FirstUniformVerdict != Verdict) + { + bAllRelevantBlocksSame = false; + } + } + else + { + bAllRelevantBlocksUniform = false; + } + } + } + } + + if (bHaveUniformBlock && bAllRelevantBlocksUniform && bAllRelevantBlocksSame) + { + return MeshData; + } + } + // Réutilise le tampon entre tuiles (thread_local) : SetNumUninitialized garde la // capacité, donc plus de malloc/free de ~170 Ko (35³ floats) par tuile. static thread_local TArray DensityGrid; @@ -285,58 +364,6 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, } MesherDensityGridTimer.End(); - // Optional 8x8x8-cell block early-out. ClassifyTile uses the same one-point halo and the - // same cave/disturbance/diff-layer guards as production tile loading. It is deliberately - // performed after the density grid is complete: skipping only the Marching Cubes work keeps - // the grid, capture buffer, geometry, normals, and all density-side effects identical. - TArray BlockSkip; - int32 BlocksPerAxis = 0; - if (bUseBlockEarlyOut) - { - constexpr int32 BlockCells = 8; - BlocksPerAxis = (CellsPerAxis + BlockCells - 1) / BlockCells; - BlockSkip.Init(0, BlocksPerAxis * BlocksPerAxis * BlocksPerAxis); - for (int32 BlockZ = 0; BlockZ < BlocksPerAxis; ++BlockZ) - { - const int32 BlockMinZ = BlockZ * BlockCells; - const int32 BlockMaxZ = FMath::Min(CellsPerAxis - 1, - BlockMinZ + BlockCells - 1); - if (BlockMaxZ < CzLo || BlockMinZ > CzHi) - { - continue; - } - for (int32 BlockY = 0; BlockY < BlocksPerAxis; ++BlockY) - { - for (int32 BlockX = 0; BlockX < BlocksPerAxis; ++BlockX) - { - const FIntVector BlockOrigin = OriginVoxels + FIntVector( - BlockX * BlockCells * Step, - BlockY * BlockCells * Step, - BlockMinZ * Step); - if (VoxelDensityProfile::AreCountersEnabled()) - { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::MesherBlockTests); - } - const EVoxelTileClass Verdict = Generator->ClassifyTile( - BlockOrigin, Step, BlockCells); - if (Verdict == EVoxelTileClass::AllSolid - || Verdict == EVoxelTileClass::AllAir) - { - BlockSkip[((BlockZ * BlocksPerAxis) + BlockY) * BlocksPerAxis + BlockX] = 1; - if (VoxelDensityProfile::AreCountersEnabled()) - { - VoxelDensityProfile::AddCounter( - Verdict == EVoxelTileClass::AllSolid - ? VoxelDensityProfile::ECounter::MesherBlockAllSolid - : VoxelDensityProfile::ECounter::MesherBlockAllAir); - } - } - } - } - } - } - // ── CAPTURE-DURING-MESHING ── // Si demandé et que la tuile est pleine résolution (CellsPerAxis==CHUNK_SIZE ⇒ Step==1< Max) + { + return false; + } + const float InvStep = 1.0f / static_cast(Step); + OutFirst = FMath::CeilToInt((Min - Origin) * InvStep - 1.0e-4f); + OutLast = FMath::FloorToInt((Max - Origin) * InvStep + 1.0e-4f); + return OutFirst <= OutLast; + } + + static bool VF_LatticeAxisNearestDistance( + float Min, float Max, float Origin, int32 Step, float Target, float& OutDistance) + { + int32 First = 0, Last = -1; + if (!VF_LatticeAxisRange(Min, Max, Origin, Step, First, Last)) + { + return false; + } + const int32 Nearest = FMath::Clamp( + FMath::RoundToInt((Target - Origin) / static_cast(Step)), First, Last); + OutDistance = FMath::Abs( + Origin + static_cast(Nearest * Step) - Target); + return true; + } + + static bool VF_LatticeBoxTouchesSphere( + const FBox& Box, const FIntVector& Origin, int32 Step, + const FVector& Center, float Radius) + { + float DX = 0.0f, DY = 0.0f, DZ = 0.0f; + if (!VF_LatticeAxisNearestDistance( + (float)Box.Min.X, (float)Box.Max.X, (float)Origin.X, + Step, (float)Center.X, DX) + || !VF_LatticeAxisNearestDistance( + (float)Box.Min.Y, (float)Box.Max.Y, (float)Origin.Y, + Step, (float)Center.Y, DY) + || !VF_LatticeAxisNearestDistance( + (float)Box.Min.Z, (float)Box.Max.Z, (float)Origin.Z, + Step, (float)Center.Z, DZ)) + { + return false; + } + return DX * DX + DY * DY + DZ * DZ <= FMath::Square(Radius); + } + + static bool VF_AnyPassageBoundTouchesLattice( + const TArray& Passages, + const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step) + { + if (Step <= 0 || !VoxelBox.IsValid) + { + // An invalid query must never manufacture an identity proof. + return true; + } + + for (const FVoxelPassage& Passage : Passages) + { + const float Reach = Passage.BoundRadius + 4.0f; + if (!FMath::IsFinite(Reach) || Reach < 0.0f) + { + // Invalid generated geometry is an unknown, not an empty passage set. + return true; + } + if (VF_LatticeBoxTouchesSphere( + VoxelBox, LatticeOrigin, Step, Passage.BoundCenter, Reach)) + { + return true; + } + } + return false; + } + + // MaxPassageCarveFactorNearLattice is queried once for the root and once for every refined + // child. The child lattice is a strict subset of the root lattice, so retaining the exact + // carve factor at each root point is a sound memo: a child reads the same values the old + // implementation would have recomputed, in the same deterministic order. The cache is + // worker-local because classifier calls can run concurrently and no mutable state may be + // published through the immutable strate manager. + struct FPassageLatticeBoundCache + { + const UVoxelStrateManager* Owner = nullptr; + uint64 OwnerLifetimeId = 0; + uint32 Version = 0xFFFFFFFFu; + FIntVector Origin = FIntVector::ZeroValue; + int32 Step = 0; + int32 FirstX = 0, LastX = -1; + int32 FirstY = 0, LastY = -1; + int32 FirstZ = 0, LastZ = -1; + bool bValid = false; + bool bNoCandidate = false; + TArray Factors; + + void Reset() + { + Owner = nullptr; + OwnerLifetimeId = 0; + Version = 0xFFFFFFFFu; + Origin = FIntVector::ZeroValue; + Step = 0; + FirstX = 0; LastX = -1; + FirstY = 0; LastY = -1; + FirstZ = 0; LastZ = -1; + bValid = false; + bNoCandidate = false; + Factors.Reset(); + } + + void Begin(const UVoxelStrateManager* InOwner, uint64 InLifetimeId, + uint32 InVersion, const FIntVector& InOrigin, int32 InStep, + int32 InFirstX, int32 InLastX, + int32 InFirstY, int32 InLastY, + int32 InFirstZ, int32 InLastZ, + bool bInNoCandidate) + { + Owner = InOwner; + OwnerLifetimeId = InLifetimeId; + Version = InVersion; + Origin = InOrigin; + Step = InStep; + FirstX = InFirstX; LastX = InLastX; + FirstY = InFirstY; LastY = InLastY; + FirstZ = InFirstZ; LastZ = InLastZ; + bValid = true; + bNoCandidate = bInNoCandidate; + Factors.Reset(); + } + + bool Contains(const UVoxelStrateManager* InOwner, uint64 InLifetimeId, + uint32 InVersion, const FIntVector& InOrigin, int32 InStep, + int32 InFirstX, int32 InLastX, + int32 InFirstY, int32 InLastY, + int32 InFirstZ, int32 InLastZ) const + { + return bValid && Owner == InOwner && OwnerLifetimeId == InLifetimeId + && Version == InVersion && Origin == InOrigin && Step == InStep + && InFirstX >= FirstX && InLastX <= LastX + && InFirstY >= FirstY && InLastY <= LastY + && InFirstZ >= FirstZ && InLastZ <= LastZ; + } + + int32 Index(int32 IX, int32 IY, int32 IZ) const + { + const int32 DimX = LastX - FirstX + 1; + const int32 DimY = LastY - FirstY + 1; + return ((IZ - FirstZ) * DimY + (IY - FirstY)) * DimX + (IX - FirstX); + } + + float MaxIn(const int32 InFirstX, const int32 InLastX, + const int32 InFirstY, const int32 InLastY, + const int32 InFirstZ, const int32 InLastZ) const + { + if (bNoCandidate) { return 0.0f; } + + float Result = 0.0f; + for (int32 IZ = InFirstZ; IZ <= InLastZ; ++IZ) + { + for (int32 IY = InFirstY; IY <= InLastY; ++IY) + { + for (int32 IX = InFirstX; IX <= InLastX; ++IX) + { + Result = FMath::Max(Result, Factors[Index(IX, IY, IZ)]); + if (Result >= 1.0f) + { + return Result; + } + } + } + } + return Result; + } + }; + + FPassageLatticeBoundCache& VF_GetPassageLatticeBoundCache() + { + thread_local FPassageLatticeBoundCache Cache; + return Cache; + } + + static bool VF_LatticeBoxTouchesAABB( + const FBox& Box, const FIntVector& Origin, int32 Step, + float MinX, float MaxX, float MinY, float MaxY, float MinZ, float MaxZ) + { + return VoxelPassageGeometry::LatticeAxisHasSampleInInterval( + FMath::Max((float)Box.Min.X, MinX), + FMath::Min((float)Box.Max.X, MaxX), + (float)Origin.X, Step) + && VoxelPassageGeometry::LatticeAxisHasSampleInInterval( + FMath::Max((float)Box.Min.Y, MinY), + FMath::Min((float)Box.Max.Y, MaxY), + (float)Origin.Y, Step) + && VoxelPassageGeometry::LatticeAxisHasSampleInInterval( + FMath::Max((float)Box.Min.Z, MinZ), + FMath::Min((float)Box.Max.Z, MaxZ), + (float)Origin.Z, Step); + } +} + +bool UVoxelStrateManager::AnyPassageNearLattice( + const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step) const +{ + // This is deliberately only the conservative spatial candidate test. The old implementation + // evaluated the complete modifier SDF here and then evaluated it a second time in + // MaxPassageCarveFactorNearLattice during the same fold. A bound hit is sufficient to keep + // the carve hypothesis alive; MaxPassageCarveFactorNearLattice supplies the exact lattice + // amplitude before the fold can preserve AllSolid. + return VF_AnyPassageBoundTouchesLattice( + Passages, VoxelBox, LatticeOrigin, Step); +} + +float UVoxelStrateManager::MaxPassageCarveFactorNearLattice( + const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step) const +{ + // The passage bound is only a cheap candidate test. It encloses an entire multi-segment + // passage, so using it as the final lattice proof turns a long tunnel into a false positive + // for every tile inside its enclosing sphere. That is particularly damaging at LOD0: the + // classifier then falls through to a full density grid even when every sampled point is rock. + // + // Once a bound reaches this lattice, ask the same exact modifier SDF used by + // ApplyPassageCarvingOnly at the actual MC samples. This is still a proof, not a sampling + // shortcut: the operator is evaluated at precisely the points the mesher will evaluate, and + // a non-finite result remains conservative. The common no-candidate path stays O(P), while a + // false-positive bound pays one exact lattice walk and can then be classified as Identity. + if (Step <= 0 || !VoxelBox.IsValid) + { + return 1.0f; + } + + int32 FirstX = 0, LastX = -1; + int32 FirstY = 0, LastY = -1; + int32 FirstZ = 0, LastZ = -1; + if (!VF_LatticeAxisRange( + VoxelBox.Min.X, VoxelBox.Max.X, (float)LatticeOrigin.X, Step, FirstX, LastX) + || !VF_LatticeAxisRange( + VoxelBox.Min.Y, VoxelBox.Max.Y, (float)LatticeOrigin.Y, Step, FirstY, LastY) + || !VF_LatticeAxisRange( + VoxelBox.Min.Z, VoxelBox.Max.Z, (float)LatticeOrigin.Z, Step, FirstZ, LastZ)) + { + return 1.0f; + } + + FPassageLatticeBoundCache& Cache = VF_GetPassageLatticeBoundCache(); + const uint64 LifetimeId = GetCacheLifetimeId(); + const uint32 Version = GetLayoutVersion(); + if (Cache.Contains( + this, LifetimeId, Version, LatticeOrigin, Step, + FirstX, LastX, FirstY, LastY, FirstZ, LastZ)) + { + return Cache.MaxIn(FirstX, LastX, FirstY, LastY, FirstZ, LastZ); + } + + if (!VF_AnyPassageBoundTouchesLattice( + Passages, VoxelBox, LatticeOrigin, Step)) + { + Cache.Begin( + this, LifetimeId, Version, LatticeOrigin, Step, + FirstX, LastX, FirstY, LastY, FirstZ, LastZ, + /*bInNoCandidate*/ true); + return 0.0f; + } + + constexpr float PassageCarveThreshold = 4.0f; + auto CarveFactorFromSDF = [](float ModifierSDF) + { + if (!(ModifierSDF < PassageCarveThreshold)) + { + return 0.0f; + } + float CarveFactor = FMath::Clamp( + (PassageCarveThreshold - ModifierSDF) + / (PassageCarveThreshold * 2.0f), + 0.0f, 1.0f); + return SmoothStep01(CarveFactor); + }; + + const int64 DimX = static_cast(LastX) - FirstX + 1; + const int64 DimY = static_cast(LastY) - FirstY + 1; + const int64 DimZ = static_cast(LastZ) - FirstZ + 1; + const int64 SampleCount = DimX * DimY * DimZ; + constexpr int64 MaxCachedLatticeSamples = 65536; + if (SampleCount > 0 && SampleCount <= MaxCachedLatticeSamples) + { + Cache.Begin( + this, LifetimeId, Version, LatticeOrigin, Step, + FirstX, LastX, FirstY, LastY, FirstZ, LastZ, + /*bInNoCandidate*/ false); + Cache.Factors.SetNumUninitialized(static_cast(SampleCount)); + + float MaxCarveFactor = 0.0f; + for (int32 IZ = FirstZ; IZ <= LastZ; ++IZ) + { + const float Z = (float)LatticeOrigin.Z + (float)(IZ * Step); + for (int32 IY = FirstY; IY <= LastY; ++IY) + { + const float Y = (float)LatticeOrigin.Y + (float)(IY * Step); + for (int32 IX = FirstX; IX <= LastX; ++IX) + { + const float X = (float)LatticeOrigin.X + (float)(IX * Step); + const float ModifierSDF = EvaluateModifierSDF(X, Y, Z); + const float CarveFactor = FMath::IsFinite(ModifierSDF) + ? CarveFactorFromSDF(ModifierSDF) : 1.0f; + Cache.Factors[Cache.Index(IX, IY, IZ)] = CarveFactor; + MaxCarveFactor = FMath::Max(MaxCarveFactor, CarveFactor); + if (MaxCarveFactor >= 1.0f) + { + // The remaining values are still filled below. Descendant queries need + // the complete exact lattice, not merely this query's maximum. + for (int32 RemainingZ = IZ; RemainingZ <= LastZ; ++RemainingZ) + { + const float RemainingWorldZ = + (float)LatticeOrigin.Z + (float)(RemainingZ * Step); + const int32 StartY = RemainingZ == IZ ? IY : FirstY; + for (int32 RemainingY = StartY; RemainingY <= LastY; ++RemainingY) + { + const float RemainingWorldY = + (float)LatticeOrigin.Y + (float)(RemainingY * Step); + const int32 StartX = + (RemainingZ == IZ && RemainingY == IY) ? IX + 1 : FirstX; + for (int32 RemainingX = StartX; RemainingX <= LastX; ++RemainingX) + { + const float RemainingWorldX = + (float)LatticeOrigin.X + (float)(RemainingX * Step); + const float RemainingSDF = EvaluateModifierSDF( + RemainingWorldX, RemainingWorldY, RemainingWorldZ); + Cache.Factors[Cache.Index(RemainingX, RemainingY, RemainingZ)] = + FMath::IsFinite(RemainingSDF) + ? CarveFactorFromSDF(RemainingSDF) : 1.0f; + } + } + } + return MaxCarveFactor; + } + } + } + } + return MaxCarveFactor; + } + + // Very large or unusual external queries do not enter the bounded worker memo. Keep the + // original exact walk for them; refusing the cache must never change the proof. + Cache.Reset(); + float MaxCarveFactor = 0.0f; + for (int32 IZ = FirstZ; IZ <= LastZ; ++IZ) + { + const float Z = (float)LatticeOrigin.Z + (float)(IZ * Step); + for (int32 IY = FirstY; IY <= LastY; ++IY) + { + const float Y = (float)LatticeOrigin.Y + (float)(IY * Step); + for (int32 IX = FirstX; IX <= LastX; ++IX) + { + const float X = (float)LatticeOrigin.X + (float)(IX * Step); + const float ModifierSDF = EvaluateModifierSDF(X, Y, Z); + if (!FMath::IsFinite(ModifierSDF)) + { + return 1.0f; + } + MaxCarveFactor = FMath::Max( + MaxCarveFactor, CarveFactorFromSDF(ModifierSDF)); + if (MaxCarveFactor >= 1.0f) + { + return MaxCarveFactor; + } + } + } + } + return MaxCarveFactor; +} + +bool UVoxelStrateManager::AnyPassageLandingFloorNearLattice( + const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step) const +{ + for (const FVoxelPassage& Passage : Passages) + { + const FVoxelPassageLanding* Landings[] = { + &Passage.UpperLanding, &Passage.LowerLanding }; + for (const FVoxelPassageLanding* Landing : Landings) + { + if (Landing->HalfWidth <= 0.0f || Landing->FloorThickness <= 0.0f) + { + continue; + } + constexpr float Pad = 1.0f; + if (VF_LatticeBoxTouchesAABB( + VoxelBox, LatticeOrigin, Step, + Landing->StandingPoint.X - Landing->HalfWidth - Pad, + Landing->StandingPoint.X + Landing->HalfWidth + Pad, + Landing->StandingPoint.Y - Landing->HalfWidth - Pad, + Landing->StandingPoint.Y + Landing->HalfWidth + Pad, + Landing->FloorZ - Landing->FloorThickness - Pad, + Landing->FloorZ + Pad)) + { + return true; + } + } + + if (Passage.bWalkableTunnelContract + && Passage.ControlPoints.Num() >= 2 + && Passage.ControlRadii.Num() == Passage.ControlPoints.Num()) + { + constexpr float Pad = 1.0f; + for (int32 SegmentIndex = 0; + SegmentIndex + 1 < Passage.ControlPoints.Num(); ++SegmentIndex) + { + const FVector& A = Passage.ControlPoints[SegmentIndex]; + const FVector& B = Passage.ControlPoints[SegmentIndex + 1]; + const float SupportRadius = FMath::Max( + FMath::Min( + FMath::Abs(Passage.ControlRadii[SegmentIndex]), + FMath::Abs(Passage.ControlRadii[SegmentIndex + 1])) - 0.5f, + VoxelPassageGeometry::PlayerRadiusVoxels); + const float FloorA = VoxelPassageGeometry::TunnelFloorZ( + A, Passage.ControlRadii[SegmentIndex]); + const float FloorB = VoxelPassageGeometry::TunnelFloorZ( + B, Passage.ControlRadii[SegmentIndex + 1]); + if (VF_LatticeBoxTouchesAABB( + VoxelBox, LatticeOrigin, Step, + FMath::Min(A.X, B.X) - SupportRadius - Pad, + FMath::Max(A.X, B.X) + SupportRadius + Pad, + FMath::Min(A.Y, B.Y) - SupportRadius - Pad, + FMath::Max(A.Y, B.Y) + SupportRadius + Pad, + FMath::Min(FloorA, FloorB) + - VoxelPassageGeometry::LandingFloorThicknessVoxels - Pad, + FMath::Max(FloorA, FloorB) + Pad)) + { + return true; + } + } + } + } + return false; +} + bool UVoxelStrateManager::AnyOriginLandingNearBox( const FVector& MinVoxel, const FVector& MaxVoxel) const { @@ -2199,6 +2637,44 @@ bool UVoxelStrateManager::AnyOriginLandingFloorNearBox( return false; } +bool UVoxelStrateManager::AnyOriginLandingNearLattice( + const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step) const +{ + if (OriginSpineRadius <= 0.0f) return false; + for (const FStrateSlot& Slot : StrateLayout) + { + if (Slot.Definition == nullptr) continue; + const float TopZ = (static_cast(Slot.TopChunkZ) + 1.0f) * CHUNK_SIZE; + const float BottomZ = static_cast(Slot.BottomChunkZ) * CHUNK_SIZE; + const float Seal = VF_BoundarySealThicknessForDefinition(*Slot.Definition); + if (VoxelPassageGeometry::OriginLandingRoomTouchesLattice( + VoxelBox, LatticeOrigin, Step, TopZ, BottomZ, Seal, OriginSpineRadius)) + { + return true; + } + } + return false; +} + +bool UVoxelStrateManager::AnyOriginLandingFloorNearLattice( + const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step) const +{ + if (OriginSpineRadius <= 0.0f) return false; + for (const FStrateSlot& Slot : StrateLayout) + { + if (Slot.Definition == nullptr) continue; + const float TopZ = (static_cast(Slot.TopChunkZ) + 1.0f) * CHUNK_SIZE; + const float BottomZ = static_cast(Slot.BottomChunkZ) * CHUNK_SIZE; + const float Seal = VF_BoundarySealThicknessForDefinition(*Slot.Definition); + if (VoxelPassageGeometry::OriginLandingFloorTouchesLattice( + VoxelBox, LatticeOrigin, Step, TopZ, BottomZ, Seal, OriginSpineRadius)) + { + return true; + } + } + return false; +} + //============================================================================= // QUERIES //============================================================================= diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index c46af7c..f91f217 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -1207,6 +1207,14 @@ bool AVoxelWorld::ApplyTileResult(FChunkResult& Result) return false; } + if (GVoxelForgeProfileTileGeneration != 0 && Result.RequestStartCycles != 0) + { + 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)); + } + // Mark the tile loaded (even if empty — so we don't re-submit it). LoadedTiles.Add(Result.Tile); @@ -1270,6 +1278,8 @@ void AVoxelWorld::SyncRemeshTile(const FVoxelTileKey& Tile) } FChunkResult Result; + Result.RequestStartCycles = GVoxelForgeProfileTileGeneration != 0 + ? FPlatformTime::Cycles64() : 0; GenerateTileResult(Tile, OriginVoxels, Step, Cells, GenerationEpoch, /*bWantCapture*/ false, BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi, /*bSheetTile*/ false, /*SheetChunkZ*/ 0, @@ -1966,6 +1976,8 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority) return; // Budget full — wait for a task to finish. } PendingTiles.Add(Tile); + const uint64 RequestStartCycles = GVoxelForgeProfileTileGeneration != 0 + ? FPlatformTime::Cycles64() : 0; const FIntVector OriginVoxels = Tile.OriginVoxels(); // min corner, voxel coords @@ -2019,6 +2031,7 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority) FChunkResult Empty; Empty.Tile = Tile; Empty.Epoch = TaskEpoch; + Empty.RequestStartCycles = RequestStartCycles; ProcessQueue.Enqueue(MoveTemp(Empty)); return; } @@ -2048,6 +2061,7 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority) FChunkResult Empty; Empty.Tile = Tile; Empty.Epoch = TaskEpoch; + Empty.RequestStartCycles = RequestStartCycles; Empty.BandChunkLo = BandChunkLo; Empty.BandChunkHi = BandChunkHi; ProcessQueue.Enqueue(MoveTemp(Empty)); @@ -2069,7 +2083,8 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority) : UE::Tasks::ETaskPriority::BackgroundNormal; UE::Tasks::Launch(TEXT("ChunkGen"), [this, Tile, OriginVoxels, Step, Cells, TaskEpoch, bWantCapture, BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi, - bSheetTile, SheetChunkZ, HoleMinX, HoleMinY, HoleMaxX, HoleMaxY]() + bSheetTile, SheetChunkZ, HoleMinX, HoleMinY, HoleMaxX, HoleMaxY, + RequestStartCycles]() { // RAII: decrement the counter on every exit path. struct FTaskGuard @@ -2084,6 +2099,7 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority) GenerateTileResult(Tile, OriginVoxels, Step, Cells, TaskEpoch, bWantCapture, BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi, bSheetTile, SheetChunkZ, HoleMinX, HoleMinY, HoleMaxX, HoleMaxY, Result); + Result.RequestStartCycles = RequestStartCycles; if (!ShouldAbortWork()) { @@ -2103,14 +2119,21 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector { const bool bProfileTile = GVoxelForgeProfileTileGeneration != 0; const double TileStartSeconds = bProfileTile ? FPlatformTime::Seconds() : 0.0; + double ClassifySeconds = 0.0; + double MeshSeconds = 0.0; + double StreamSeconds = 0.0; + int32 ClassifyVerdict = -1; // Mixed = 0, AllSolid = 1, AllAir = 2 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); + TEXT("[VoxelForgeTileProfile] tile=(%d,%d,%d) level=%d step=%d cells=%d sheet=%d aborted=%d empty=%d " + "verdict=%d classify=%.6f mesh=%.6f streams=%.6f seconds=%.6f"), + Tile.Coord.X, Tile.Coord.Y, Tile.Coord.Z, Tile.Level, Step, Cells, + bSheetTile ? 1 : 0, Result.bAborted ? 1 : 0, + Result.bEmpty ? 1 : 0, ClassifyVerdict, ClassifySeconds, MeshSeconds, StreamSeconds, + FPlatformTime::Seconds() - TileStartSeconds); } }; @@ -2151,7 +2174,10 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector { TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ClassifyTile); INC_DWORD_STAT(STAT_VoxelForgeTilesClassified); + const double ClassifyStartSeconds = bProfileTile ? FPlatformTime::Seconds() : 0.0; const EVoxelTileClass Verdict = Generator->ClassifyTile(OriginVoxels, Step, Cells); + ClassifySeconds = bProfileTile ? FPlatformTime::Seconds() - ClassifyStartSeconds : 0.0; + ClassifyVerdict = static_cast(Verdict); if (ShouldAbortWork()) { AbortResult(); @@ -2178,12 +2204,14 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector if (!bTrivialEmpty) { TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_GenerateMesh); + const double MeshStartSeconds = bProfileTile ? FPlatformTime::Seconds() : 0.0; MeshData = bSheetTile ? Mesher->GenerateSheetMesh(OriginVoxels, Step, Cells, SheetChunkZ, HoleMinX, HoleMinY, HoleMaxX, HoleMaxY) : Mesher->GenerateMesh(OriginVoxels, Step, Cells, bWantCapture ? &Result.CaptureGrid : nullptr, BandVoxLo, BandVoxHi); + MeshSeconds = bProfileTile ? FPlatformTime::Seconds() - MeshStartSeconds : 0.0; INC_DWORD_STAT(STAT_VoxelForgeTilesMeshed); if (ShouldAbortWork()) { @@ -2198,8 +2226,10 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector if (!MeshData.IsEmpty()) { TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_BuildStreams); + const double StreamStartSeconds = bProfileTile ? FPlatformTime::Seconds() : 0.0; Result.Streams = MakeShared(); BuildTileStreamSet(*Result.Streams, MeshData); + StreamSeconds = bProfileTile ? FPlatformTime::Seconds() - StreamStartSeconds : 0.0; if (ShouldAbortWork()) { AbortResult(); diff --git a/Source/VoxelForge/Public/VoxelDensityOp.h b/Source/VoxelForge/Public/VoxelDensityOp.h index 02e85da..104ab9e 100644 --- a/Source/VoxelForge/Public/VoxelDensityOp.h +++ b/Source/VoxelForge/Public/VoxelDensityOp.h @@ -102,6 +102,7 @@ #include "VoxelTypes.h" // CHUNK_SIZE, EVoxelTileClass struct FBiomeContext; +struct FTunnelCoreWorldEvaluation; //============================================================================= // LES QUATRE RÔLES / THE FOUR ROLES @@ -284,6 +285,18 @@ struct FVoxelOpContext float StrateTopWorldZ = 0.0f; float StrateBottomWorldZ = 0.0f; + // ClassifyTile may ask box bounds about the exact marching-cubes lattice rather than the + // continuous volume between samples. Production voxel evaluation leaves this disabled; + // when enabled, operators may tighten a conservative bound only for points + // LatticeOriginVoxels + n * Step that lie inside the queried box. + bool bUseLatticeProof = false; + FIntVector LatticeOriginVoxels = FIntVector::ZeroValue; + + // A mixed tile may opt into a tighter, still conservative finite-box bound for domain-warped + // sources. The normal classifier leaves this off; ClassifyTile retries only the few boxes + // that the cheap global proof could not resolve. + bool bTightenWarpProof = false; + // null = cette strate n'a pas de champ de biome. const FBiomeContext* Biome = nullptr; }; @@ -457,6 +470,14 @@ public: */ virtual void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const = 0; + /** Optional hand-off for the common structural tail. The default keeps custom operators + * conservative: the caller uses its canonical cache path when no source publishes a result. */ + virtual bool TryGetLastTunnelCoreWorldEvaluation( + FTunnelCoreWorldEvaluation& OutEvaluation) const + { + return false; + } + /** * CONSERVATIF. The two-argument method is the intrinsic fallback. The state-aware overload * below is what the stack calls: it sees the SDF interval already produced by earlier ops. @@ -717,7 +738,9 @@ FORCEINLINE void VF_ForceHypotheses(FVoxelBoxHypotheses& H, EVoxelTileClass Forc * c'est la propriété de sûreté, et le pliage numérique ne l'affaiblit pas : une marge ne peut que * DESCENDRE, jamais remonter, en dehors d'un opérateur forçant. * - * ⚠️ `MaxCarve` / `MaxFill` valent `FLT_MAX` par défaut = « amplitude inconnue ». La soustraction + * ⚠️ `MaxCarve` / `MaxFill` valent `FLT_MAX` par défaut = « amplitude inconnue ». Une borne finie + * nulle signifie au contraire « identité prouvée » pour cette direction : l'opérateur peut être + * géométriquement présent mais ne modifie aucun échantillon de la boîte. La soustraction * fait alors passer la marge très en dessous de zéro et l'hypothèse meurt, exactement comme la * version purement directionnelle de ce pliage. Aucun opérateur existant ne change de verdict. * (Arithmétique volontairement laissée en float sans garde : `0 − FLT_MAX` vaut `−FLT_MAX`, @@ -733,21 +756,67 @@ FORCEINLINE void VF_FoldEffect(FVoxelBoxHypotheses& H, EVoxelOpEffect Effect, break; case EVoxelOpEffect::CarveOnly: + // A finite zero bound is a proved identity for this direction. This matters for + // state-aware structural guards: a passage may be geometrically present in a box while + // its exact lattice carve factor is zero, and that must not kill an otherwise valid + // AllSolid hypothesis merely because its margin is zero/unknown. + if (MaxCarve == 0.0f) + { + break; + } + if (!(MaxCarve >= 0.0f)) + { + H.bCanBeAllSolid = false; + H.SolidMargin = -FLT_MAX; + break; + } H.SolidMargin -= MaxCarve; if (!(H.SolidMargin > 0.0f)) { H.bCanBeAllSolid = false; } break; case EVoxelOpEffect::FillOnly: + if (MaxFill == 0.0f) + { + break; + } + if (!(MaxFill >= 0.0f)) + { + H.bCanBeAllAir = false; + H.AirMargin = -FLT_MAX; + break; + } H.AirMargin -= MaxFill; if (!(H.AirMargin > 0.0f)) { H.bCanBeAllAir = false; } break; case EVoxelOpEffect::Both: default: - H.SolidMargin -= MaxCarve; - if (!(H.SolidMargin > 0.0f)) { H.bCanBeAllSolid = false; } - H.AirMargin -= MaxFill; - if (!(H.AirMargin > 0.0f)) { H.bCanBeAllAir = false; } + if (MaxCarve != 0.0f) + { + if (!(MaxCarve >= 0.0f)) + { + H.bCanBeAllSolid = false; + H.SolidMargin = -FLT_MAX; + } + else + { + H.SolidMargin -= MaxCarve; + if (!(H.SolidMargin > 0.0f)) { H.bCanBeAllSolid = false; } + } + } + if (MaxFill != 0.0f) + { + if (!(MaxFill >= 0.0f)) + { + H.bCanBeAllAir = false; + H.AirMargin = -FLT_MAX; + } + else + { + H.AirMargin -= MaxFill; + if (!(H.AirMargin > 0.0f)) { H.bCanBeAllAir = false; } + } + } break; } } diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index 4ff41a4..0d07bb4 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -174,6 +174,10 @@ public: return -EvalInternal(WorldX, WorldY, WorldZ); } + /** Return a same-sample structural-core result published by a source in this stack. */ + bool TryGetLastTunnelCoreWorldEvaluation( + FTunnelCoreWorldEvaluation& OutEvaluation) const; + /** * Le pliage générique qui remplacera les gardes écrites à la main dans ClassifyTile. * Voir `VF_FoldOp` (VoxelDensityOp.h) pour la sémantique — en particulier pourquoi un @@ -182,10 +186,25 @@ public: EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const { FVoxelBoxHypotheses H; + const bool bTask4OpProbe = Ctx.bUseLatticeProof + && Ctx.LatticeOriginVoxels == FIntVector(32, 0, -32) + && Ctx.Step == 1; for (const FOpEntry& Entry : Ops) { + const uint64 Task4OpStartCycles = bTask4OpProbe + ? FPlatformTime::Cycles64() : 0; VF_FoldOp(H, *Entry.Op, VoxelBox, Ctx, (Entry.Writes & VoxelOpChannels::Sdf) != 0); + if (bTask4OpProbe) + { + UE_LOG(LogTemp, Warning, + TEXT("[Task4OpProbe] box=(%.0f,%.0f,%.0f)-(%.0f,%.0f,%.0f) op=%s ms=%.3f"), + (float)VoxelBox.Min.X, (float)VoxelBox.Min.Y, (float)VoxelBox.Min.Z, + (float)VoxelBox.Max.X, (float)VoxelBox.Max.Y, (float)VoxelBox.Max.Z, + Entry.Op->DebugName(), + FPlatformTime::ToMilliseconds64( + FPlatformTime::Cycles64() - Task4OpStartCycles)); + } // Do not early-out on a dead hypothesis: a later forcing structural post may // deliberately overwrite it (the XY edge seal is appended after passage tube/landing // carving and support). diff --git a/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h b/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h index d79dd99..dc83592 100644 --- a/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h +++ b/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h @@ -147,9 +147,12 @@ public: // l'écart vers un voisin un niveau plus grossier (cellule 2×). Monter si des fissures persistent. float SkirtCells = 2.0f; - // Optional conservative block classifier. When enabled, 8x8x8 cell regions proven uniformly - // solid or air by UVoxelGenerator::ClassifyTile skip the marching-cubes cell loop. The density - // grid is still produced in full, so this switch cannot change samples or geometry. + // Conservative 8x8x8 block classifier. Uniform blocks skip the marching-cubes cell loop; + // when every relevant block has the same verdict, the density grid is skipped as well. + // ClassifyTile is proof-only, so no samples, resolution, or geometry are removed. + // The tile classifier is the cheap, whole-tile proof. Keep the optional 8^3-block refinement + // available for focused diagnostics, but leave it disabled in streaming: repeating a proof on + // every mixed tile can cost more than the density grid it is meant to avoid. bool bUseBlockEarlyOut = false; // T2.b — LOD-aware octave reduction (opt-in, copied from UVoxelSettings::LODOctaveDrop). diff --git a/Source/VoxelForge/Public/VoxelPassageGeometry.h b/Source/VoxelForge/Public/VoxelPassageGeometry.h index b052a51..206a036 100644 --- a/Source/VoxelForge/Public/VoxelPassageGeometry.h +++ b/Source/VoxelForge/Public/VoxelPassageGeometry.h @@ -433,6 +433,73 @@ namespace VoxelPassageGeometry return MaxA >= MinB && MinA <= MaxB; } + /** True when the exact MC lattice has at least one sample in a closed interval. */ + FORCEINLINE bool LatticeAxisHasSampleInInterval( + float Min, float Max, float Origin, int32 Step) + { + if (Step <= 0 || !FMath::IsFinite(Min) || !FMath::IsFinite(Max) + || !FMath::IsFinite(Origin) || Min > Max) + { + return false; + } + const float InvStep = 1.0f / static_cast(Step); + const int32 First = FMath::CeilToInt( + (Min - Origin) * InvStep - 1.0e-4f); + const int32 Last = FMath::FloorToInt( + (Max - Origin) * InvStep + 1.0e-4f); + return First <= Last; + } + + FORCEINLINE bool OriginLandingRoomTouchesLattice( + const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step, + float StrateTopZ, float StrateBottomZ, float SealThickness, float OriginRadius) + { + if (!VoxelBox.IsValid) return false; + const FOriginLandingGeometry Geometry = BuildOriginLandingGeometry( + StrateTopZ, StrateBottomZ, SealThickness, OriginRadius); + if (!Geometry.bValid) return false; + const float Pad = LandingCarveBlendVoxels; + return LatticeAxisHasSampleInInterval( + FMath::Max((float)VoxelBox.Min.X, -Geometry.HalfWidth - Pad), + FMath::Min((float)VoxelBox.Max.X, Geometry.HalfWidth + Pad), + (float)LatticeOrigin.X, Step) + && LatticeAxisHasSampleInInterval( + FMath::Max((float)VoxelBox.Min.Y, -Geometry.HalfWidth - Pad), + FMath::Min((float)VoxelBox.Max.Y, Geometry.HalfWidth + Pad), + (float)LatticeOrigin.Y, Step) + && LatticeAxisHasSampleInInterval( + FMath::Max((float)VoxelBox.Min.Z, Geometry.FloorZ - Pad), + FMath::Min((float)VoxelBox.Max.Z, Geometry.CeilingZ + Pad), + (float)LatticeOrigin.Z, Step); + } + + FORCEINLINE bool OriginLandingFloorTouchesLattice( + const FBox& VoxelBox, const FIntVector& LatticeOrigin, int32 Step, + float StrateTopZ, float StrateBottomZ, float SealThickness, float OriginRadius) + { + if (!VoxelBox.IsValid) return false; + const FOriginLandingGeometry Geometry = BuildOriginLandingGeometry( + StrateTopZ, StrateBottomZ, SealThickness, OriginRadius); + if (!Geometry.bValid) return false; + + constexpr float ProofPad = 1.0f; + const float FloorHalfWidth = FMath::Max( + Geometry.HalfWidth - 1.0f, 0.0f); + return LatticeAxisHasSampleInInterval( + FMath::Max((float)VoxelBox.Min.X, -FloorHalfWidth - ProofPad), + FMath::Min((float)VoxelBox.Max.X, FloorHalfWidth + ProofPad), + (float)LatticeOrigin.X, Step) + && LatticeAxisHasSampleInInterval( + FMath::Max((float)VoxelBox.Min.Y, -FloorHalfWidth - ProofPad), + FMath::Min((float)VoxelBox.Max.Y, FloorHalfWidth + ProofPad), + (float)LatticeOrigin.Y, Step) + && LatticeAxisHasSampleInInterval( + FMath::Max((float)VoxelBox.Min.Z, + Geometry.FloorZ - Geometry.FloorThickness - ProofPad), + FMath::Min((float)VoxelBox.Max.Z, Geometry.FloorZ + ProofPad), + (float)LatticeOrigin.Z, Step); + } + /** Conservative influence box for the origin room's air carve. */ FORCEINLINE bool OriginLandingRoomTouchesBox( const FBox& VoxelBox, diff --git a/Source/VoxelForge/Public/VoxelStrateManager.h b/Source/VoxelForge/Public/VoxelStrateManager.h index d9cc4da..ddaf8a6 100644 --- a/Source/VoxelForge/Public/VoxelStrateManager.h +++ b/Source/VoxelForge/Public/VoxelStrateManager.h @@ -420,10 +420,25 @@ public: /** Conservative box guard for the floor fill; unlike a tube it can affect the all-air proof. */ bool AnyPassageLandingFloorNearBox(const FVector& MinVoxel, const FVector& MaxVoxel) const; + /** Exact-lattice counterparts used by ClassifyTile's MC-grid proof. */ + bool AnyPassageNearLattice(const FBox& VoxelBox, + const FIntVector& LatticeOrigin, int32 Step) const; + /** Exact maximum of the passage carve factor on the mesher lattice; 0 means no tube/landing + * carve reaches a sampled point. Invalid geometry returns 1 conservatively. */ + float MaxPassageCarveFactorNearLattice(const FBox& VoxelBox, + const FIntVector& LatticeOrigin, int32 Step) const; + bool AnyPassageLandingFloorNearLattice(const FBox& VoxelBox, + const FIntVector& LatticeOrigin, int32 Step) const; + /** Conservative box guards for the per-strate (0,0) landing rooms and their floor slabs. */ bool AnyOriginLandingNearBox(const FVector& MinVoxel, const FVector& MaxVoxel) const; bool AnyOriginLandingFloorNearBox(const FVector& MinVoxel, const FVector& MaxVoxel) const; + bool AnyOriginLandingNearLattice(const FBox& VoxelBox, + const FIntVector& LatticeOrigin, int32 Step) const; + bool AnyOriginLandingFloorNearLattice(const FBox& VoxelBox, + const FIntVector& LatticeOrigin, int32 Step) const; + /** Get all generated passages (for debug display). */ const TArray& GetPassages() const { return Passages; } diff --git a/Source/VoxelForge/Public/VoxelWorld.h b/Source/VoxelForge/Public/VoxelWorld.h index 306228d..06a160d 100644 --- a/Source/VoxelForge/Public/VoxelWorld.h +++ b/Source/VoxelForge/Public/VoxelWorld.h @@ -84,6 +84,9 @@ struct FChunkResult // Null ⇒ empty/all-air tile (no component). TSharedPtr Streams; uint32 Epoch = 0; // Generation epoch — discard if stale + // Monotonic request timestamp used only for streaming telemetry. It is carried through the + // worker queue so the profile can report request-to-ready, not just worker generation time. + uint64 RequestStartCycles = 0; 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