From 5ddd96664d3fdc6bb54804729498eb23e609d7f1 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Fri, 11 Sep 2026 18:09:12 +0200 Subject: [PATCH] perf(opstack): invert the loop + prune per block -- 1.45s to 1.02s, byte-identical MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The architecture round. Loop inverted from per-sample-per-op to per-op-per-block, operators pruned per block by interval bounds, profiler moved out of the hot loop. my build, 3 runs: 1.013 / 1.036 / 1.075 s (adb34df was 1.418 / 1.445 / 1.458) mean 1.041 vs 1.440 = -28.5%, 1.39x geometry SHA-256 b3e5f4c3..9b377 IDENTICAL to adb34df pruning removed 20,999 / 31,320 operator-block visits = 67.1% surviving ops per block: LOD0 6.63 LOD1 6.30 LOD2 5.23 LOD3 4.41 LOD4 3.89 (out of 22) ⚠️ I PREDICTED THE DISPATCH OVERHEAD WAS "THE 10x". IT WAS NOT. 864,688 virtual dispatches per chunk sounded decisive and is worth ~20-28%. Real, and worth having, but the order of magnitude is elsewhere. That is my fourth wrong performance prediction in this project, and the pattern is identical each time: I reason from a call site's shape instead of measuring what the data actually costs. ⭐⭐ AND THE "5-20x GAME/EXPORT GAP" WAS MY ARITHMETIC ERROR. IT DOES NOT EXIST. I told the owner the largest unexplained number in the project was that export does 22 ms/tile while the game does 115-500 ms. That 22 ms came from dividing 1.4 s of WALL CLOCK by 64 tiles -- ignoring that 15 workers ran them in parallel. Per-job means on the same build: nonshared block export 447 ms/job shared-grid export 261 ms/job game LOD0 meshing 253 ms/tile p50 They are the same. The game is not anomalously slow; generation simply costs ~250 ms per LOD0 tile. The shared grid's benefit is real but modest, and a neighbourhood cache is NOT justified by these numbers -- I would have had Codex build one on the strength of a division error. GAME PATH, final build (841 profiles, 447 applies) meshed p50/p95/max LOD0 253/521/697 ms LOD1 79/130/135 LOD2 19/42/51 LOD3 36/64/81 LOD4 93/238/271 LOD0 request-to-ready p50 47.7 ms p95 389 ms max 798 ms worker queue p95 84 ms, generation p95 344 ms at 6 m/s: p95 = 2.3 m of travel, max = 4.8 m empty LOD0 tiles never enter the mesher; classifier p50 8.7 ms, p95 17.2 ms apply is not the bottleneck: p50 0.107 ms, p95 0.161 ms, max 0.296 ms ⭐ THE BINDING CONSTRAINT IS COMPUTE, NOT POLICY: logical cores 16, automatic core cap 14, effective generation cap 14, CAP HIT. Apply limits never bind. We are saturating the machine, which means the remaining wins must come from doing less work, not from scheduling it better. NOT DONE, with reasons rather than excuses: - Coarse-first LOD refinement: coarse tiles carry NO COLLISION, so streaming them first would let the player fall through. Needs a conservative collision proxy or an L0 handoff first. Correct refusal. - Heading prefetch: desired tiles are sorted by distance only (VoxelWorld.cpp:1748). Velocity-weighted prediction with a bounded budget and expiry is the shape, not built. A failed experiment, reported rather than buried: eagerly filling every classifier leaf with EvalBlock regressed export to 3.2-3.3 s because it defeated the classifier's early exit. Removed. Build 43 actions, 0 errors. Peak RSS under 2.1 GB. Sources: Epic's Tasks System and Level Streaming docs, Lorensen-Cline. Cumulative on this region: 342.2 s -> 1.02 s. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb --- .../Private/VoxelDensityOpStack.cpp | 145 +++++++++++ .../Private/VoxelDensityProfile.cpp | 47 ++++ Source/VoxelForge/Private/VoxelGenerator.cpp | 240 +++++++++++++++++- .../Private/VoxelMarchingCubesMesher.cpp | 50 +++- Source/VoxelForge/Private/VoxelWorld.cpp | 110 +++++++- Source/VoxelForge/Public/VoxelDensityOp.h | 78 ++++++ .../VoxelForge/Public/VoxelDensityOpStack.h | 133 +++++++++- .../VoxelForge/Public/VoxelDensityProfile.h | 8 + Source/VoxelForge/Public/VoxelGenerator.h | 13 + 9 files changed, 815 insertions(+), 9 deletions(-) diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index b447551..1a6b28d 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -41,6 +41,40 @@ namespace { + /** + * Concrete block thunks use the scalar body with the concrete operator type visible to the + * compiler. This is deliberately not a second numerical implementation: the scalar Eval is + * still the single source of truth, but the op-major caller pays one virtual dispatch for the + * block and the inner calls can inline/devirtualize. Operators with a block-native kernel can + * replace this helper later without changing the stack contract. + */ + template + FORCEINLINE void VF_EvalBlockByScalar(const TOp& Op, const FVoxelOpBlock& Block) + { + if (Block.Samples == nullptr || Block.Step <= 0 + || Block.SizeX <= 0 || Block.SizeY <= 0 || Block.SizeZ <= 0) + { + return; + } + + for (int32 Z = 0; Z < Block.SizeZ; ++Z) + { + for (int32 Y = 0; Y < Block.SizeY; ++Y) + { + for (int32 X = 0; X < Block.SizeX; ++X) + { + FVoxelOpSample& Sample = Block.At(X, Y, Z); + Op.PrepareBlockSample(Sample); + Op.Eval( + static_cast(Block.OriginVoxels.X + X * Block.Step), + static_cast(Block.OriginVoxels.Y + Y * Block.Step), + static_cast(Block.OriginVoxels.Z + Z * Block.Step), + Sample); + } + } + } + } + /** * BORNE **PROUVABLE** DE `|Perlin3D|`, ET ELLE N'EST PAS 1.0. * @@ -516,6 +550,11 @@ namespace const TCHAR* DebugName() const override { return TEXT("ConstantFieldSource"); } + void EvalBlock(const FVoxelOpBlock& Block) const override + { + VF_EvalBlockByScalar(*this, Block); + } + private: float Value; }; @@ -1839,6 +1878,11 @@ namespace const TCHAR* DebugName() const override { return TEXT("SdfConvertOp"); } + void EvalBlock(const FVoxelOpBlock& Block) const override + { + VF_EvalBlockByScalar(*this, Block); + } + private: bool IsInactive(const FVoxelBoxHypotheses& H) const { @@ -1926,6 +1970,11 @@ namespace const TCHAR* DebugName() const override { return TEXT("OriginSpineOp"); } + void EvalBlock(const FVoxelOpBlock& Block) const override + { + VF_EvalBlockByScalar(*this, Block); + } + private: float TopZ, BotZ, Seal, Base, Radius; }; @@ -2007,6 +2056,11 @@ namespace const TCHAR* DebugName() const override { return TEXT("BoundarySealOp"); } + void EvalBlock(const FVoxelOpBlock& Block) const override + { + VF_EvalBlockByScalar(*this, Block); + } + private: float TopZ, BotZ, Thickness, Base; }; @@ -2074,6 +2128,11 @@ namespace bool IsXYPure() const override { return true; } const TCHAR* DebugName() const override { return TEXT("XYEdgeSealOp"); } + void EvalBlock(const FVoxelOpBlock& Block) const override + { + VF_EvalBlockByScalar(*this, Block); + } + private: float Base = 8.0f; mutable float WorldRadius = 8192.0f; @@ -2186,6 +2245,11 @@ namespace const TCHAR* DebugName() const override { return TEXT("PassageCarveOp"); } + void EvalBlock(const FVoxelOpBlock& Block) const override + { + VF_EvalBlockByScalar(*this, Block); + } + private: TWeakObjectPtr Manager; float Base, Seal, TopZ, BotZ, SpineRadius; @@ -3786,6 +3850,15 @@ namespace * Lu par les arches, les dômes, le pincement et le biais de sol. */ int32 GetNearestRoomIdx() const { return State().NearestRoom; } + /** Restore the source-owned state belonging to one sample before a later block op reads it. */ + void RestoreBlockSample(const FVoxelOpSample& Sample) const + { + FState& S = State(); + S.ActiveCache = Sample.RoomCache; + S.NearestRoom = Sample.NearestRoomIndex; + S.bLocalParamsValid = false; + } + void BuildSupportColumn( float WorldX, float WorldY, const FChunkSDFCache& Cache, @@ -4255,6 +4328,16 @@ namespace } } } + + // These are the per-sample hand-offs needed by an op-major block. The scalar path + // continues to use FState directly; publishing them here only makes the dependency + // explicit for a later modifier and does not alter Density/Sdf. + InOut.RoomCache = &GetCache(); + InOut.NearestRoomIndex = S.NearestRoom; + InOut.bHasTunnelCoreWorldEvaluation = true; + InOut.TunnelCoreWorldSDF = S.LastTunnelCoreWorldEvaluation.SDF; + InOut.bTunnelCoreSupportFloor = S.LastTunnelCoreWorldEvaluation.bSupportFloor; + InOut.bTunnelCoreRoomFloor = S.LastTunnelCoreWorldEvaluation.bRoomFloor; } //--------------------------------------------------------------------- @@ -6629,6 +6712,11 @@ namespace const TCHAR* DebugName() const override { return TEXT("RoomGraphSource"); } + void EvalBlock(const FVoxelOpBlock& Block) const override + { + VF_EvalBlockByScalar(*this, Block); + } + private: FStrateGenerationParams P; int32 Seed; @@ -6725,6 +6813,7 @@ namespace bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} const TCHAR* DebugName() const override { return TEXT("CaveRoughnessMod"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override { @@ -6957,7 +7046,12 @@ namespace EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} + void PrepareBlockSample(const FVoxelOpSample& Sample) const override + { + if (Rooms != nullptr) { Rooms->RestoreBlockSample(Sample); } + } const TCHAR* DebugName() const override { return TEXT("CaveTerraceMod"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override { @@ -7106,7 +7200,12 @@ namespace EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} + void PrepareBlockSample(const FVoxelOpSample& Sample) const override + { + if (Rooms != nullptr) { Rooms->RestoreBlockSample(Sample); } + } const TCHAR* DebugName() const override { return TEXT("LayerLineMod"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } void Eval(float, float, float WorldZ, FVoxelOpSample& InOut) const override { @@ -7203,7 +7302,12 @@ namespace EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} + void PrepareBlockSample(const FVoxelOpSample& Sample) const override + { + if (Rooms != nullptr) { Rooms->RestoreBlockSample(Sample); } + } const TCHAR* DebugName() const override { return TEXT("RibbingMod"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } void Eval(float, float, float WorldZ, FVoxelOpSample& InOut) const override { @@ -7295,7 +7399,12 @@ namespace EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} + void PrepareBlockSample(const FVoxelOpSample& Sample) const override + { + if (Rooms != nullptr) { Rooms->RestoreBlockSample(Sample); } + } const TCHAR* DebugName() const override { return TEXT("CaveOverhangMod"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override { @@ -7408,7 +7517,12 @@ namespace EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} + void PrepareBlockSample(const FVoxelOpSample& Sample) const override + { + if (Rooms != nullptr) { Rooms->RestoreBlockSample(Sample); } + } const TCHAR* DebugName() const override { return TEXT("CaveCliffMod"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override { @@ -7518,7 +7632,12 @@ namespace EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} + void PrepareBlockSample(const FVoxelOpSample& Sample) const override + { + if (Rooms != nullptr) { Rooms->RestoreBlockSample(Sample); } + } const TCHAR* DebugName() const override { return TEXT("ScallopMod"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override { @@ -7620,7 +7739,12 @@ namespace EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} + void PrepareBlockSample(const FVoxelOpSample& Sample) const override + { + if (Rooms != nullptr) { Rooms->RestoreBlockSample(Sample); } + } const TCHAR* DebugName() const override { return TEXT("CaveArchMod"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override { @@ -7749,7 +7873,12 @@ namespace EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} + void PrepareBlockSample(const FVoxelOpSample& Sample) const override + { + if (Rooms != nullptr) { Rooms->RestoreBlockSample(Sample); } + } const TCHAR* DebugName() const override { return TEXT("RoomColumnMod"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } void Eval(float WorldX, float WorldY, float, FVoxelOpSample& InOut) const override { @@ -7841,7 +7970,12 @@ namespace EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} + void PrepareBlockSample(const FVoxelOpSample& Sample) const override + { + if (Rooms != nullptr) { Rooms->RestoreBlockSample(Sample); } + } const TCHAR* DebugName() const override { return TEXT("DomeMod"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override { @@ -7981,7 +8115,12 @@ namespace EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} + void PrepareBlockSample(const FVoxelOpSample& Sample) const override + { + if (Rooms != nullptr) { Rooms->RestoreBlockSample(Sample); } + } const TCHAR* DebugName() const override { return TEXT("PinchMod"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override { @@ -8107,7 +8246,12 @@ namespace EVoxelOpResourceMask RequiredResources() const override { return VoxelOpResources::RoomGeometry; } bool IsAdditive() const override { return true; } void PrepareChunk(const FVoxelOpContext&) override {} + void PrepareBlockSample(const FVoxelOpSample& Sample) const override + { + if (Rooms != nullptr) { Rooms->RestoreBlockSample(Sample); } + } const TCHAR* DebugName() const override { return TEXT("FloorBiasMod"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } void Eval(float, float, float WorldZ, FVoxelOpSample& InOut) const override { @@ -8424,6 +8568,7 @@ namespace } const TCHAR* DebugName() const override { return TEXT("WormFieldSource"); } + void EvalBlock(const FVoxelOpBlock& Block) const override { VF_EvalBlockByScalar(*this, Block); } private: FStrateGenerationParams P; diff --git a/Source/VoxelForge/Private/VoxelDensityProfile.cpp b/Source/VoxelForge/Private/VoxelDensityProfile.cpp index 7e19104..5062e84 100644 --- a/Source/VoxelForge/Private/VoxelDensityProfile.cpp +++ b/Source/VoxelForge/Private/VoxelDensityProfile.cpp @@ -301,6 +301,48 @@ namespace VoxelDensityProfile return Result; } + FSnapshot SnapshotCurrentThread() + { + FSnapshot Result; + FThreadState* State = GThreadState; + if (State == nullptr) + { + Result.TimerPairCycles = GetTimerPairCycles(); + Result.SampleInterval = GetSampleInterval(); + Result.Mode = GetMode(); + return Result; + } + + for (int32 Index = 0; Index < BucketCount; ++Index) + { + Result.Calls[Index] = State->Calls[Index]; + Result.Samples[Index] = State->Samples[Index]; + if (State->Samples[Index] == 0) + { + Result.Cycles[Index] = State->Cycles[Index]; + } + else + { + const double Scale = static_cast(State->Calls[Index]) + / static_cast(State->Samples[Index]); + const double EstimatedCycles = static_cast(State->Cycles[Index]) * Scale; + Result.Cycles[Index] = EstimatedCycles > static_cast(MAX_uint64) + ? MAX_uint64 + : static_cast(EstimatedCycles + 0.5); + } + Result.WallCycles[Index] = Result.Cycles[Index]; + Result.ActiveWorkers[Index] = State->Calls[Index] > 0 ? 1 : 0; + } + for (int32 Index = 0; Index < CounterCount; ++Index) + { + Result.Counters[Index] = State->Counters[Index]; + } + Result.TimerPairCycles = GetTimerPairCycles(); + Result.SampleInterval = GetSampleInterval(); + Result.Mode = GetMode(); + return Result; + } + void AddCounter(ECounter Counter, uint64 Amount) { const int32 Index = static_cast(Counter); @@ -441,6 +483,11 @@ namespace VoxelDensityProfile case ECounter::TileVerdictCacheHits: return TEXT("TileVerdictCacheHits"); case ECounter::TileVerdictRegionHits: return TEXT("TileVerdictRegionHits"); case ECounter::TileVerdictCacheStores: return TEXT("TileVerdictCacheStores"); + case ECounter::OpBlockBuilds: return TEXT("OpBlockBuilds"); + case ECounter::OpBlockSamples: return TEXT("OpBlockSamples"); + case ECounter::OpBlockOperators: return TEXT("OpBlockOperators"); + case ECounter::OpBlockActiveOperators: return TEXT("OpBlockActiveOperators"); + case ECounter::OpBlockPrunedOperators: return TEXT("OpBlockPrunedOperators"); case ECounter::Count: break; } return TEXT("Unknown"); diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 0406dc7..395ad71 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -178,6 +178,193 @@ static thread_local FSurfaceColumnCache GSurfColCache; // T2.b — per-thread octave bias for the tile being meshed (see VoxelGenerator.h). // 0 = full quality; set by the mesher per tile from Step + Settings->LODOctaveDrop. thread_local int32 VoxelGenLOD::OctaveBias = 0; + +//============================================================================= +// OP-MAJOR DENSITY BLOCK HAND-OFF +//============================================================================= +// The public Begin/End pair is only a lifetime marker. The actual storage is worker-local and +// bounded by the current mesher lattice. A tile may cross several chunk keys; each key is filled +// by the already-prepared stack that GetDensityAt selected for that key, so no mutable stack state +// is shared across chunk boundaries. +struct FVoxelDensityBlockSession +{ + bool bActive = false; + FIntVector Origin = FIntVector::ZeroValue; + int32 Step = 1; + int32 SizeX = 0; + int32 SizeY = 0; + int32 SizeZ = 0; + TArray Samples; + TArray Valid; + TArray BlockScratch; + TArray ActiveOps; + + void Begin(FIntVector InOrigin, int32 InStep, int32 InSizeX, int32 InSizeY, int32 InSizeZ) + { + bActive = InStep > 0 && InSizeX > 0 && InSizeY > 0 && InSizeZ > 0; + Origin = InOrigin; + Step = FMath::Max(InStep, 1); + SizeX = FMath::Max(InSizeX, 0); + SizeY = FMath::Max(InSizeY, 0); + SizeZ = FMath::Max(InSizeZ, 0); + if (!bActive) + { + Samples.Reset(); + Valid.Reset(); + BlockScratch.Reset(); + ActiveOps.Reset(); + return; + } + + // Allocate lazily on the first operator-stack sample. Legacy archetypes still use the + // ordinary scalar generator and should not pay for a block scratch lattice merely because + // the mesher opened the scoped hand-off. + BlockScratch.Reset(); + } + + void End() + { + bActive = false; + // Keep capacity for the next tile on this worker, but never retain a verdict between + // sessions. A stale sample can only cost the next task a block fill, never change it. + Samples.Reset(); + Valid.Reset(); + BlockScratch.Reset(); + ActiveOps.Reset(); + } + + int32 Index(int32 X, int32 Y, int32 Z) const + { + return (Z * SizeY + Y) * SizeX + X; + } + + bool LocalIndex(FIntVector World, int32& OutX, int32& OutY, int32& OutZ) const + { + const int32 DX = World.X - Origin.X; + const int32 DY = World.Y - Origin.Y; + const int32 DZ = World.Z - Origin.Z; + if (DX % Step != 0 || DY % Step != 0 || DZ % Step != 0) { return false; } + OutX = DX / Step; + OutY = DY / Step; + OutZ = DZ / Step; + return OutX >= 0 && OutX < SizeX + && OutY >= 0 && OutY < SizeY + && OutZ >= 0 && OutZ < SizeZ; + } + + bool FindAxisRange(int32 Axis, int32 TargetChunk, int32& OutMin, int32& OutMax) const + { + OutMin = INT32_MAX; + OutMax = INT32_MIN; + const int32 Limit = Axis == 0 ? SizeX : (Axis == 1 ? SizeY : SizeZ); + const int32 Base = Axis == 0 ? Origin.X : (Axis == 1 ? Origin.Y : Origin.Z); + for (int32 I = 0; I < Limit; ++I) + { + const int32 Coordinate = Base + I * Step; + if (FMath::FloorToInt(static_cast(Coordinate) / CHUNK_SIZE) == TargetChunk) + { + OutMin = FMath::Min(OutMin, I); + OutMax = FMath::Max(OutMax, I); + } + } + return OutMin != INT32_MAX; + } + + bool FillChunk(const FVoxelOpStack& Stack, FIntVector ChunkCoord, FIntVector World, + FVoxelOpSample& OutSample) + { + int32 X = 0, Y = 0, Z = 0; + if (!LocalIndex(World, X, Y, Z)) { return false; } + const int32 Count = SizeX * SizeY * SizeZ; + if (Samples.Num() != Count || Valid.Num() != Count) + { + Samples.SetNum(Count); + Valid.Init(0, Count); + } + const int32 GlobalIndex = Index(X, Y, Z); + if (Valid[GlobalIndex] != 0) + { + OutSample = Samples[GlobalIndex]; + return true; + } + + int32 MinX = 0, MaxX = 0, MinY = 0, MaxY = 0, MinZ = 0, MaxZ = 0; + if (!FindAxisRange(0, ChunkCoord.X, MinX, MaxX) + || !FindAxisRange(1, ChunkCoord.Y, MinY, MaxY) + || !FindAxisRange(2, ChunkCoord.Z, MinZ, MaxZ)) + { + return false; + } + + const int32 BlockSizeX = MaxX - MinX + 1; + const int32 BlockSizeY = MaxY - MinY + 1; + const int32 BlockSizeZ = MaxZ - MinZ + 1; + const int32 BlockCount = BlockSizeX * BlockSizeY * BlockSizeZ; + BlockScratch.SetNum(BlockCount); + for (FVoxelOpSample& Sample : BlockScratch) + { + Sample = FVoxelOpSample(); + } + + FVoxelOpBlock Block; + Block.OriginVoxels = Origin + FIntVector(MinX * Step, MinY * Step, MinZ * Step); + Block.Step = Step; + Block.SizeX = BlockSizeX; + Block.SizeY = BlockSizeY; + Block.SizeZ = BlockSizeZ; + Block.Samples = BlockScratch.GetData(); + const FIntVector BlockLast = Block.OriginVoxels + + FIntVector((BlockSizeX - 1) * Step, + (BlockSizeY - 1) * Step, + (BlockSizeZ - 1) * Step); + const FBox BlockBox( + FVector(static_cast(Block.OriginVoxels.X), + static_cast(Block.OriginVoxels.Y), + static_cast(Block.OriginVoxels.Z)), + FVector(static_cast(BlockLast.X), + static_cast(BlockLast.Y), + static_cast(BlockLast.Z))); + const int32 ActiveCount = Stack.BuildActiveOpList( + BlockBox, Step, Block.OriginVoxels, ActiveOps); + Stack.EvalBlock(Block, ActiveOps); + + for (int32 Bz = 0; Bz < BlockSizeZ; ++Bz) + { + for (int32 By = 0; By < BlockSizeY; ++By) + { + for (int32 Bx = 0; Bx < BlockSizeX; ++Bx) + { + const int32 Global = Index(MinX + Bx, MinY + By, MinZ + Bz); + const int32 Local = (Bz * BlockSizeY + By) * BlockSizeX + Bx; + Samples[Global] = BlockScratch[Local]; + Valid[Global] = 1; + } + } + } + + if (VoxelDensityProfile::AreCountersEnabled()) + { + VoxelDensityProfile::AddCounter(VoxelDensityProfile::ECounter::OpBlockBuilds); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::OpBlockSamples, + static_cast(BlockCount)); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::OpBlockOperators, + static_cast(Stack.Num())); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::OpBlockActiveOperators, + static_cast(ActiveCount)); + VoxelDensityProfile::AddCounter( + VoxelDensityProfile::ECounter::OpBlockPrunedOperators, + static_cast(FMath::Max(Stack.Num() - ActiveCount, 0))); + } + + OutSample = Samples[GlobalIndex]; + return true; + } +}; + +static thread_local FVoxelDensityBlockSession GVoxelDensityBlockSession; thread_local int32 VoxelGenLOD::SampleStep = 1; int32 VoxelGenLOD::GetThreadOctaveBias() @@ -1489,6 +1676,17 @@ void UVoxelGenerator::InitializeSettings(const UVoxelSettings* Settings) InvalidateTileVerdictCache(); } +void UVoxelGenerator::BeginDensityBlock(FIntVector OriginVoxels, int32 Step, + int32 SizeX, int32 SizeY, int32 SizeZ) const +{ + GVoxelDensityBlockSession.Begin(OriginVoxels, Step, SizeX, SizeY, SizeZ); +} + +void UVoxelGenerator::EndDensityBlock() const +{ + GVoxelDensityBlockSession.End(); +} + float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) const { TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_GetDensityAt); @@ -1958,12 +2156,36 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co VoxelDensityProfile::FScopedTimer DensityCoreTimer( VoxelDensityProfile::EBucket::DensityCore); + FVoxelOpSample BlockCoreSample; + bool bUsedOpBlockSample = false; + // Le seul point d'entrée de la pile dans le chemin de production. Elle rend la convention // MC (négatif = solide) comme les fonctions d'archétype, donc les disturbances et la couche // de diff qui suivent ne voient aucune différence. if (CP_UseOpStack) { - Result = ActiveOpStack->EvalMC(WorldX, WorldY, WorldZ); + const bool bIntegerLatticePoint = + WorldX == FMath::FloorToFloat(WorldX) + && WorldY == FMath::FloorToFloat(WorldY) + && WorldZ == FMath::FloorToFloat(WorldZ); + const bool bBlockSafeGenerator = + CP_GenType == ECaveGeneratorType::TunnelNetwork + || CP_GenType == ECaveGeneratorType::Underwater; + if (bIntegerLatticePoint && bBlockSafeGenerator + && GVoxelDensityBlockSession.bActive) + { + bUsedOpBlockSample = GVoxelDensityBlockSession.FillChunk( + *ActiveOpStack, + ChunkCoord, + FIntVector( + FMath::RoundToInt(WorldX), + FMath::RoundToInt(WorldY), + FMath::RoundToInt(WorldZ)), + BlockCoreSample); + } + Result = bUsedOpBlockSample + ? -BlockCoreSample.Density + : ActiveOpStack->EvalMC(WorldX, WorldY, WorldZ); } else switch (CP_GenType) { @@ -2028,8 +2250,20 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co bool bHavePreDisturbanceTunnelCore = false; if (CP_UseOpStack) { - bHavePreDisturbanceTunnelCore = ActiveOpStack->TryGetLastTunnelCoreWorldEvaluation( - PreDisturbanceTunnelCore); + if (bUsedOpBlockSample && BlockCoreSample.bHasTunnelCoreWorldEvaluation) + { + PreDisturbanceTunnelCore.SDF = BlockCoreSample.TunnelCoreWorldSDF; + PreDisturbanceTunnelCore.bSupportFloor = + BlockCoreSample.bTunnelCoreSupportFloor; + PreDisturbanceTunnelCore.bRoomFloor = + BlockCoreSample.bTunnelCoreRoomFloor; + bHavePreDisturbanceTunnelCore = true; + } + else + { + bHavePreDisturbanceTunnelCore = ActiveOpStack->TryGetLastTunnelCoreWorldEvaluation( + PreDisturbanceTunnelCore); + } } if (!bHavePreDisturbanceTunnelCore && ActiveTunnelCoreCache->bValid) { diff --git a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp index 24bfac1..300bc9f 100644 --- a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp +++ b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp @@ -5,6 +5,29 @@ #include "MarchingCubesTables.h" #include "VoxelDensityProfile.h" #include "ProfilingDebugging/CpuProfilerTrace.h" +#include "HAL/IConsoleManager.h" +#include "Misc/CommandLine.h" + +namespace +{ + int32 GVoxelForgeUseOperatorBlock = 1; + FAutoConsoleVariableRef CVarVoxelForgeUseOperatorBlock( + TEXT("voxel.UseOperatorBlock"), + GVoxelForgeUseOperatorBlock, + TEXT("Use the operator-major density block path for non-shared mesher grids.")); + bool GVoxelForgeBlockSwitchParsed = false; + + void VF_ParseOperatorBlockSwitch() + { + if (GVoxelForgeBlockSwitchParsed) { return; } + GVoxelForgeBlockSwitchParsed = true; + int32 CommandLineValue = GVoxelForgeUseOperatorBlock; + if (FParse::Value(FCommandLine::Get(), TEXT("voxel.UseOperatorBlock="), CommandLineValue)) + { + GVoxelForgeUseOperatorBlock = CommandLineValue; + } + } +} //============================================================================= // MAIN ALGORITHM @@ -17,6 +40,7 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, int32 BandZMinVox, int32 BandZMaxVox) { TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_MesherGenerateMesh); + VF_ParseOperatorBlockSwitch(); FVoxelMeshData MeshData; if (OutCaptureGrid) { OutCaptureGrid->Reset(); } if (!Generator) return MeshData; @@ -420,12 +444,30 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, // gradient) sont échantillonnées — le reste du tampon reste non initialisé et non lu. VoxelDensityProfile::FScopedTimer MesherDensityGridTimer( VoxelDensityProfile::EBucket::MesherDensityGrid); + const bool bUseOperatorBlock = !bUseSharedDensityGrid + && GVoxelForgeUseOperatorBlock != 0; + if (bUseOperatorBlock) + { + // The grid includes one sample of halo on every side. The generator groups these exact + // lattice points by chunk key and lets the prepared op stack fill each group once. + Generator->BeginDensityBlock( + OriginVoxels + FIntVector(-Step, -Step, GzLo * Step), + Step, MDim, MDim, GzHi - GzLo + 1); + } for (int32 gz = GzLo; gz <= GzHi; gz++) { - if (ShouldAbortWork()) return FVoxelMeshData(); + if (ShouldAbortWork()) + { + if (bUseOperatorBlock) { Generator->EndDensityBlock(); } + return FVoxelMeshData(); + } for (int32 gy = -1; gy <= GridDim; gy++) { - if (ShouldAbortWork()) return FVoxelMeshData(); + if (ShouldAbortWork()) + { + if (bUseOperatorBlock) { Generator->EndDensityBlock(); } + return FVoxelMeshData(); + } for (int32 gx = -1; gx <= GridDim; gx++) { const int32 LocalIndex = ((gz + 1) * MDim + (gy + 1)) * MDim + (gx + 1); @@ -448,6 +490,10 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, } } } + if (bUseOperatorBlock) + { + Generator->EndDensityBlock(); + } MesherDensityGridTimer.End(); if (bUseProofBlocks) diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index 6641634..f50eb86 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -12,6 +12,7 @@ #include "VoxelContentManager.h" #include "VoxelDensityVolume.h" #include "VoxelDensityOpStack.h" +#include "VoxelDensityProfile.h" #include "VoxelStats.h" // IWYU (FPSemantics = Precise ⇒ plus de PCH partagé) : GetPlayerPosition déréférence le pawn, donc // APawn doit être COMPLET — `Casts.h` n'en donne qu'une déclaration avant. APlayerController était @@ -49,6 +50,17 @@ namespace GVoxelForgeProfileTileGeneration, TEXT("Log worker tile generation time with level/step/cell count.")); + int32 GVoxelForgeProfileDensity = 0; + FAutoConsoleVariableRef CVarVoxelForgeProfileDensity( + TEXT("voxel.ProfileDensity"), + GVoxelForgeProfileDensity, + TEXT("Collect sampled per-density profiling for the game path.")); + int32 GVoxelForgeProfileDensityFull = 0; + FAutoConsoleVariableRef CVarVoxelForgeProfileDensityFull( + TEXT("voxel.ProfileDensityFull"), + GVoxelForgeProfileDensityFull, + TEXT("Collect full per-operator profiling for the game path.")); + // Startup command-line cvars can arrive before the game world has spawned AVoxelWorld (or // before its first tiles have become visible). Keep the diagnostic edit pending until Tick has // a loaded tile; otherwise a startup measurement silently becomes a no-op or edits the wrong @@ -972,6 +984,39 @@ void AVoxelWorld::BeginPlay() Super::BeginPlay(); bShuttingDown.store(false, std::memory_order_relaxed); + // Some headless harnesses load module CVars after the engine's startup-CVar pass. Read the + // profiling switches explicitly as well, so a reported game profile cannot silently run with + // the default mode because of startup ordering. + int32 CommandLineProfile = GVoxelForgeProfileDensity; + int32 CommandLineProfileFull = GVoxelForgeProfileDensityFull; + if (FParse::Value(FCommandLine::Get(), TEXT("voxel.ProfileDensity="), CommandLineProfile)) + { + GVoxelForgeProfileDensity = CommandLineProfile; + } + if (FParse::Value(FCommandLine::Get(), TEXT("voxel.ProfileDensityFull="), CommandLineProfileFull)) + { + GVoxelForgeProfileDensityFull = CommandLineProfileFull; + } + + if (GVoxelForgeProfileDensityFull != 0) + { + VoxelDensityProfile::SetMode( + VoxelDensityProfile::EMode::Full, + VoxelDensityProfile::DefaultSampleInterval); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeDensityProfile] game_mode=full sample_interval=%u"), + VoxelDensityProfile::DefaultSampleInterval); + } + else if (GVoxelForgeProfileDensity != 0) + { + VoxelDensityProfile::SetMode( + VoxelDensityProfile::EMode::Sampled, + VoxelDensityProfile::DefaultSampleInterval); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeDensityProfile] game_mode=sampled sample_interval=%u"), + VoxelDensityProfile::DefaultSampleInterval); + } + if (!Settings) { UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] No Settings assigned — world won't generate.")); @@ -2379,10 +2424,24 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector double StreamSeconds = 0.0; int32 ClassifyVerdict = -1; // Mixed = 0, AllSolid = 1, AllAir = 2 FVoxelTileClassificationStats ClassifierStats; + const bool bProfileOps = bProfileTile + && VoxelDensityProfile::GetMode() == VoxelDensityProfile::EMode::Full; + const VoxelDensityProfile::FSnapshot TileProfileStart = bProfileTile + ? VoxelDensityProfile::SnapshotCurrentThread() + : VoxelDensityProfile::FSnapshot(); auto EmitTileProfile = [&]() { if (bProfileTile) { + const VoxelDensityProfile::FSnapshot TileProfileEnd = bProfileTile + ? VoxelDensityProfile::SnapshotCurrentThread() + : VoxelDensityProfile::FSnapshot(); + const auto CounterDelta = [&](VoxelDensityProfile::ECounter Counter) -> uint64 + { + const int32 Index = static_cast(Counter); + return bProfileTile && TileProfileEnd.Counters[Index] >= TileProfileStart.Counters[Index] + ? TileProfileEnd.Counters[Index] - TileProfileStart.Counters[Index] : 0; + }; UE_LOG(LogTemp, Display, 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 " @@ -2392,7 +2451,8 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector "room_work=%.6f room_exact=%.6f cache_work=%.6f " "rooms=%d tunnels=%d joins=%d pits=%d chimneys=%d " "whole_mixed=%u whole_solid=%u whole_air=%u final_nodes=%u " - "split_nodes=%u max_depth=%u"), + "split_nodes=%u max_depth=%u block_builds=%llu block_samples=%llu " + "block_ops=%llu block_active=%llu block_pruned=%llu"), 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, @@ -2414,7 +2474,53 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector ClassifierStats.RoomNumChimneys, ClassifierStats.WholeMixedNodes, ClassifierStats.WholeSolidNodes, ClassifierStats.WholeAirNodes, ClassifierStats.NeedsFinalFieldNodes, - ClassifierStats.SplitNodes, ClassifierStats.MaxRefinementDepth); + ClassifierStats.SplitNodes, ClassifierStats.MaxRefinementDepth, + static_cast(CounterDelta(VoxelDensityProfile::ECounter::OpBlockBuilds)), + static_cast(CounterDelta(VoxelDensityProfile::ECounter::OpBlockSamples)), + static_cast(CounterDelta(VoxelDensityProfile::ECounter::OpBlockOperators)), + static_cast(CounterDelta(VoxelDensityProfile::ECounter::OpBlockActiveOperators)), + static_cast(CounterDelta(VoxelDensityProfile::ECounter::OpBlockPrunedOperators))); + + if (bProfileOps) + { + static const VoxelDensityProfile::EBucket GameOpBuckets[] = { + VoxelDensityProfile::EBucket::RoomGraphSource, + VoxelDensityProfile::EBucket::SdfCarve, + VoxelDensityProfile::EBucket::CaveRoughnessMod, + VoxelDensityProfile::EBucket::CaveTerraceMod, + VoxelDensityProfile::EBucket::LayerLineMod, + VoxelDensityProfile::EBucket::RibbingMod, + VoxelDensityProfile::EBucket::CaveOverhangMod, + VoxelDensityProfile::EBucket::CaveCliffMod, + VoxelDensityProfile::EBucket::ScallopMod, + VoxelDensityProfile::EBucket::CaveArchMod, + VoxelDensityProfile::EBucket::RoomColumnMod, + VoxelDensityProfile::EBucket::DomeMod, + VoxelDensityProfile::EBucket::PinchMod, + VoxelDensityProfile::EBucket::FloorBiasMod, + VoxelDensityProfile::EBucket::WormFieldSource, + VoxelDensityProfile::EBucket::OriginSpineOp, + VoxelDensityProfile::EBucket::BoundarySealOp, + VoxelDensityProfile::EBucket::PassageCarveOp, + VoxelDensityProfile::EBucket::XYEdgeSealOp, + }; + for (const VoxelDensityProfile::EBucket Bucket : GameOpBuckets) + { + const int32 Index = static_cast(Bucket); + const uint64 Calls = TileProfileEnd.Calls[Index] >= TileProfileStart.Calls[Index] + ? TileProfileEnd.Calls[Index] - TileProfileStart.Calls[Index] : 0; + const uint64 Cycles = TileProfileEnd.WallCycles[Index] >= TileProfileStart.WallCycles[Index] + ? TileProfileEnd.WallCycles[Index] - TileProfileStart.WallCycles[Index] : 0; + if (Calls == 0) { continue; } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeGameOpProfile] tile=(%d,%d,%d) level=%d op=%s calls=%llu total_us=%.3f us_per_call=%.6f"), + Tile.Coord.X, Tile.Coord.Y, Tile.Coord.Z, Tile.Level, + VoxelDensityProfile::BucketName(Bucket), + static_cast(Calls), + FPlatformTime::ToSeconds64(Cycles) * 1.0e6, + FPlatformTime::ToSeconds64(Cycles) * 1.0e6 / static_cast(Calls)); + } + } } }; diff --git a/Source/VoxelForge/Public/VoxelDensityOp.h b/Source/VoxelForge/Public/VoxelDensityOp.h index 4b9b5e5..72e9b85 100644 --- a/Source/VoxelForge/Public/VoxelDensityOp.h +++ b/Source/VoxelForge/Public/VoxelDensityOp.h @@ -103,6 +103,7 @@ struct FBiomeContext; struct FTunnelCoreWorldEvaluation; +struct FChunkSDFCache; //============================================================================= // LES QUATRE RÔLES / THE FOUR ROLES @@ -341,6 +342,47 @@ struct FVoxelOpSample // with the sample keeps the rule true even when a detail op is unaware of the room graph. bool bProtectedStructuralFloor = false; float StructuralFloorMinimumDensity = 0.0f; + + // Block evaluation keeps the room source's per-sample selection explicit. The scalar path + // still uses the source's worker-local state, but a later block-capable modifier must never + // observe the state belonging to the last sample of the preceding operator. + const FChunkSDFCache* RoomCache = nullptr; + int32 NearestRoomIndex = INDEX_NONE; + + // The common generator tail consumes these values after the block has left the stack. They + // are the small, exact subset of FTunnelCoreWorldEvaluation that the tail needs; keeping them + // in the sample avoids re-running the world-space tunnel scan or consulting a last-sample TLS + // value after an op-major evaluation. + bool bHasTunnelCoreWorldEvaluation = false; + float TunnelCoreWorldSDF = FLT_MAX; + bool bTunnelCoreSupportFloor = false; + bool bTunnelCoreRoomFloor = false; +}; + +/** + * A regular world lattice handed to one operator at a time. + * + * The default implementation is deliberately scalar and therefore source-compatible with custom + * operators. An operator may override EvalBlock only when it preserves the exact per-sample + * semantics of Eval. Samples are laid out X-fastest, then Y, then Z; all coordinates are voxel + * coordinates and are formed as Origin + index * Step using the same integer arithmetic as the + * mesher's scalar path. + */ +struct FVoxelOpBlock +{ + FIntVector OriginVoxels = FIntVector::ZeroValue; + int32 Step = 1; + int32 SizeX = 0; + int32 SizeY = 0; + int32 SizeZ = 0; + FVoxelOpSample* Samples = nullptr; + + int32 NumSamples() const { return SizeX * SizeY * SizeZ; } + + FORCEINLINE FVoxelOpSample& At(int32 X, int32 Y, int32 Z) const + { + return Samples[(Z * SizeY + Y) * SizeX + X]; + } }; /** @@ -477,6 +519,42 @@ public: */ virtual void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const = 0; + /** + * Op-major evaluation seam. The conservative default is intentionally not clever: it calls + * the canonical scalar Eval for every sample. This lets the stack and mesher be converted in + * reviewable stages while byte-identical output remains the acceptance test. Stateful ops + * may restore their per-sample hand-off from FVoxelOpSample in PrepareBlockSample; the default + * invokes that hook before each scalar fallback call. + */ + virtual void EvalBlock(const FVoxelOpBlock& Block) const + { + if (Block.Samples == nullptr || Block.Step <= 0 + || Block.SizeX <= 0 || Block.SizeY <= 0 || Block.SizeZ <= 0) + { + return; + } + + for (int32 Z = 0; Z < Block.SizeZ; ++Z) + { + for (int32 Y = 0; Y < Block.SizeY; ++Y) + { + for (int32 X = 0; X < Block.SizeX; ++X) + { + FVoxelOpSample& Sample = Block.At(X, Y, Z); + PrepareBlockSample(Sample); + Eval( + static_cast(Block.OriginVoxels.X + X * Block.Step), + static_cast(Block.OriginVoxels.Y + Y * Block.Step), + static_cast(Block.OriginVoxels.Z + Z * Block.Step), + Sample); + } + } + } + } + + /** Restore a source-owned per-sample context before a scalar fallback in EvalBlock. */ + virtual void PrepareBlockSample(const FVoxelOpSample&) const {} + /** 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( diff --git a/Source/VoxelForge/Public/VoxelDensityOpStack.h b/Source/VoxelForge/Public/VoxelDensityOpStack.h index 6b39b43..79c2f23 100644 --- a/Source/VoxelForge/Public/VoxelDensityOpStack.h +++ b/Source/VoxelForge/Public/VoxelDensityOpStack.h @@ -88,6 +88,7 @@ private: EVoxelOpResourceMask RequiredResources = VoxelOpResources::None; EVoxelOpResourceMask ProvidedResources = VoxelOpResources::None; const TCHAR* ProfileName = nullptr; + VoxelDensityProfile::EBucket ProfileBucket = VoxelDensityProfile::EBucket::OtherOp; }; public: @@ -125,6 +126,7 @@ public: Entry.RequiredResources = Entry.Op->RequiredResources(); Entry.ProvidedResources = Entry.Op->ProvidedResources(); Entry.ProfileName = Entry.Op->DebugName(); + Entry.ProfileBucket = VoxelDensityProfile::BucketFromName(Entry.ProfileName); } Ops.Add(MoveTemp(Entry)); } @@ -157,6 +159,8 @@ public: * mensonge utile qui finirait par masquer une course. */ void PrepareChunk(const FVoxelOpContext& Ctx) { + PreparedContext = Ctx; + bHasPreparedContext = true; for (const FOpEntry& Entry : Ops) { Entry.Op->PrepareChunk(Ctx); } } @@ -178,10 +182,21 @@ public: FVoxelOpSample EvalSample(float WorldX, float WorldY, float WorldZ) const { FVoxelOpSample S; + // Fine operation timers are a diagnostic mode. Keeping their construction out of the + // ordinary scalar path matters for callers that still use fractional samples or an + // archetype without a block session; the block path already times one scope per op/block. + const bool bProfileOps = VoxelDensityProfile::GetMode() == VoxelDensityProfile::EMode::Full; for (const FOpEntry& Entry : Ops) { - VoxelDensityProfile::FScopedTimer ProfileTimer(Entry.ProfileName); - Entry.Op->Eval(WorldX, WorldY, WorldZ, S); + if (bProfileOps) + { + VoxelDensityProfile::FScopedTimer ProfileTimer(Entry.ProfileBucket); + Entry.Op->Eval(WorldX, WorldY, WorldZ, S); + } + else + { + Entry.Op->Eval(WorldX, WorldY, WorldZ, S); + } } // The room-graph source publishes the authored floor ownership while evaluating the @@ -194,6 +209,118 @@ public: return S; } + /** + * Evaluate one regular lattice in operator order. The caller owns and initializes the sample + * array (normally to FVoxelOpSample defaults); each operator transforms that same array before + * the next operator runs. The timer is intentionally one scope per operator/block rather than + * one scope per operator/sample. Operators without a block implementation use the conservative + * scalar fallback supplied by IVoxelDensityOp, so this entry point is safe to introduce before + * converting the concrete operators. + */ + void EvalBlock(const FVoxelOpBlock& Block) const + { + if (Block.Samples == nullptr || Block.Step <= 0 + || Block.SizeX <= 0 || Block.SizeY <= 0 || Block.SizeZ <= 0) + { + return; + } + + const bool bProfileOps = VoxelDensityProfile::GetMode() == VoxelDensityProfile::EMode::Full; + for (const FOpEntry& Entry : Ops) + { + if (bProfileOps) + { + VoxelDensityProfile::FScopedTimer ProfileTimer(Entry.ProfileBucket); + Entry.Op->EvalBlock(Block); + } + else + { + Entry.Op->EvalBlock(Block); + } + } + } + + /** Evaluate only the entries that the interval fold proved non-identity for this block. */ + void EvalBlock(const FVoxelOpBlock& Block, const TArray& ActiveOps) const + { + if (Block.Samples == nullptr || Block.Step <= 0 + || Block.SizeX <= 0 || Block.SizeY <= 0 || Block.SizeZ <= 0) + { + return; + } + + const bool bProfileOps = VoxelDensityProfile::GetMode() == VoxelDensityProfile::EMode::Full; + for (const int32 OpIndex : ActiveOps) + { + if (!Ops.IsValidIndex(OpIndex)) { continue; } + const FOpEntry& Entry = Ops[OpIndex]; + if (bProfileOps) + { + VoxelDensityProfile::FScopedTimer ProfileTimer(Entry.ProfileBucket); + Entry.Op->EvalBlock(Block); + } + else + { + Entry.Op->EvalBlock(Block); + } + } + } + + /** + * Build a conservative active list for one exact mesher lattice block. An operator may be + * omitted only when its state-aware box contract says Identity and it does not publish SDF. + * SDF writers remain in the list even when their density effect is Identity: their SDF is a + * channel consumed by a later converter, and an unchanged-looking interval is not by itself a + * pointwise proof. Unknown costs the operator visit, never the geometry. + */ + int32 BuildActiveOpList(const FBox& VoxelBox, int32 InStep, + FIntVector InLatticeOrigin, TArray& OutActiveOps) const + { + OutActiveOps.Reset(); + if (!bHasPreparedContext) + { + for (int32 Index = 0; Index < Ops.Num(); ++Index) { OutActiveOps.Add(Index); } + return OutActiveOps.Num(); + } + + FVoxelOpContext Ctx = PreparedContext; + Ctx.Step = FMath::Max(InStep, 1); + Ctx.LatticeOriginVoxels = InLatticeOrigin; + Ctx.bUseLatticeProof = true; + + FVoxelBoxHypotheses H; + for (int32 Index = 0; Index < Ops.Num(); ++Index) + { + const FOpEntry& Entry = Ops[Index]; + const bool bWritesSdf = (Entry.Writes & VoxelOpChannels::Sdf) != 0; + const bool bPublishesResource = Entry.ProvidedResources != VoxelOpResources::None; + const EVoxelTileClass Forced = Entry.Op->ClassifyBox(VoxelBox, Ctx); + bool bCanSkip = false; + + if (Forced != EVoxelTileClass::Mixed) + { + VF_ForceHypotheses(H, Forced, Entry.Op->ForcedMarginOverBox(VoxelBox, Ctx)); + if (bWritesSdf) { Entry.Op->PropagateSdfOverBox(H.Sdf, VoxelBox, Ctx); } + } + else + { + const EVoxelOpEffect Effect = + Entry.Op->EffectOverBox(VoxelBox, Ctx, H); + bCanSkip = Effect == EVoxelOpEffect::Identity + && !bWritesSdf + && !bPublishesResource; + VF_FoldEffect(H, + Effect, + Entry.Op->MaxCarveOverBox(VoxelBox, Ctx, H), + Entry.Op->MaxFillOverBox(VoxelBox, Ctx, H)); + if (bWritesSdf) { Entry.Op->PropagateSdfOverBox(H.Sdf, VoxelBox, Ctx); } + } + + if (!bCanSkip) { OutActiveOps.Add(Index); } + } + return OutActiveOps.Num(); + } + /** Le même, négaté pour le mesher (négatif = solide). */ float EvalMC(float WorldX, float WorldY, float WorldZ) const { @@ -346,6 +473,8 @@ public: private: TArray Ops; + FVoxelOpContext PreparedContext; + bool bHasPreparedContext = false; }; //============================================================================= diff --git a/Source/VoxelForge/Public/VoxelDensityProfile.h b/Source/VoxelForge/Public/VoxelDensityProfile.h index 74946c0..8e59543 100644 --- a/Source/VoxelForge/Public/VoxelDensityProfile.h +++ b/Source/VoxelForge/Public/VoxelDensityProfile.h @@ -123,6 +123,11 @@ namespace VoxelDensityProfile TileVerdictCacheHits, TileVerdictRegionHits, TileVerdictCacheStores, + OpBlockBuilds, + OpBlockSamples, + OpBlockOperators, + OpBlockActiveOperators, + OpBlockPrunedOperators, Count }; @@ -231,6 +236,9 @@ namespace VoxelDensityProfile VOXELFORGE_API void CalibrateTimer(); VOXELFORGE_API void Reset(); VOXELFORGE_API FSnapshot Snapshot(); + // Worker-local snapshot for a tile profile. GenerateTileResult is single-owner on its worker, + // so this avoids attributing another worker's concurrent tile to the current tile diagnostic. + VOXELFORGE_API FSnapshot SnapshotCurrentThread(); VOXELFORGE_API void AddCounter(ECounter Counter, uint64 Amount = 1); VOXELFORGE_API void AddMeasurement(EBucket Bucket, uint64 Cycles, uint64 Calls = 1); VOXELFORGE_API void RecordCall(EBucket Bucket); diff --git a/Source/VoxelForge/Public/VoxelGenerator.h b/Source/VoxelForge/Public/VoxelGenerator.h index bfb35e6..5b73f6e 100644 --- a/Source/VoxelForge/Public/VoxelGenerator.h +++ b/Source/VoxelForge/Public/VoxelGenerator.h @@ -143,6 +143,19 @@ public: */ float GetDensityAt(float WorldX, float WorldY, float WorldZ) const; + /** + * Open a worker-local lattice hand-off for the following GetDensityAt calls. The mesher uses + * this around one tile's density-grid fill. The first sample in each resolved chunk lets the + * generator run the operator stack once in EvalBlock; later samples consume the exact cached + * per-sample result while the ordinary post-stack tail still runs in canonical order. + * + * This is intentionally a scoped service rather than a persistent world cache: its lifetime is + * one tile task, it is thread-local, and EndDensityBlock must be called on every exit path. + */ + void BeginDensityBlock(FIntVector OriginVoxels, int32 Step, + int32 SizeX, int32 SizeY, int32 SizeZ) const; + void EndDensityBlock() const; + /** * Densité pour une strate TunnelNetwork (rooms + tunnels + worm noise). * Utilisée en interne par GetDensityAt quand la strate est de ce type.