diff --git a/CODEMAP.md b/CODEMAP.md index f6d1fc1..4c66bdf 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -117,10 +117,10 @@ remains `0`, and lateral regions remain gated off. |------|------| | `../../VoxelForge.uplugin` | Plugin manifest. Runtime module `VoxelForge` plus the Editor-only `VoxelForgeEditor` commandlet module. Beta. | | `VoxelForge.Build.cs` | Deps: Core, CoreUObject, Engine, **GameplayTags**, **RealtimeMeshComponent**. | -| `Tools/VoxelForgeTest.ps1` | The single guarded test entry point. `-Scenario canonical|owner|probe|perf|tests|surface-fall|crossing|gate-stress|visual-edit|visual-lod|visual-gap-walk|visual-gap-inject|dig-motion|parity|horizon|horizon-render|determinism|region-eval`, optional `-Build`, `-Assets owner|default`, `-Cvars`, absolute `-Out`, `-Label`, `-HorizonLevel`, `-HorizonRenderDistanceChunks`, `-ParityExportSize=32|64|128`, `-ParityExportTile=x,y,z`, and `-SharedDDC`; stages the host/assets, isolates user/DDC/config state, serializes one Unreal launch at a time, enforces the 8 GiB/30-minute guards, waits for 6 GiB free RAM before launches, and emits compact `result.json`/`summary.txt` plus full logs. Parity can render its game leg with `-EnableRender` and compares the selected export region against the exact streamed tile hashes. `visual-gap-walk` records camera-frustum surface-point coverage at 6000 cm/s; `visual-gap-inject` proves an early component hide is detected; `dig-motion` logs per-frame vertical velocity, gate, support readiness, and movement mode. `-Cvars @{ 'voxel.TestGapAuditLegacyHandoff'=1 }` replays the old visibility handoff for A/B audit baselines. Horizon traces report desired/loaded tiles, resident stream geometry, worker seconds, steady wall time, and peak private memory; `horizon-render` captures the owner view; `determinism` compares per-tile hashes across the default CRT path and `-voxel.CrtFma3=0`; `region-eval` runs the isolated step-2 lattice comparison and candidate-bin ablation. +| `Tools/VoxelForgeTest.ps1` | The single guarded test entry point. `-Scenario canonical|owner|probe|perf|tests|surface-fall|crossing|gate-stress|visual-edit|visual-lod|visual-gap-walk|visual-gap-inject|dig-motion|parity|horizon|horizon-render|determinism|region-eval`, optional `-Build`, `-Assets owner|default`, `-Cvars`, absolute `-Out`, `-Label`, `-HorizonLevel`, `-HorizonRenderDistanceChunks`, `-ParityExportSize=32|64|128`, `-ParityExportTile=x,y,z`, `-SharedDDC`, `-SampleDensityStacks`, `-StackSampleIntervalUs`, and `-RegionHashProbe -RegionWorkers N -RegionReferenceHash HASH`; stages the host/assets, isolates user/DDC/config state, serializes one Unreal launch at a time, enforces the 8 GiB/30-minute guards, waits for 6 GiB free RAM before launches, and emits compact `result.json`/`summary.txt` plus full logs. Parity can render its game leg with `-EnableRender` and compares the selected export region against the exact streamed tile hashes. `visual-gap-walk` records camera-frustum surface-point coverage at 6000 cm/s; `visual-gap-inject` proves an early component hide is detected; `dig-motion` logs per-frame vertical velocity, gate, support readiness, and movement mode. `-Cvars @{ 'voxel.TestGapAuditLegacyHandoff'=1 }` replays the old visibility handoff for A/B audit baselines. Horizon traces report desired/loaded tiles, resident stream geometry, worker seconds, steady wall time, and peak private memory; `horizon-render` captures the owner view; `determinism` compares per-tile hashes across the default CRT path and `-voxel.CrtFma3=0`; `region-eval` compares the live path with the default-off editor brick program on the 259³ lattice and five-seed probe set; `-RegionHashProbe -RegionWorkers N -RegionReferenceHash HASH` checks a full-ring hash at another partition count. `-SampleDensityStacks` captures profiles, and `-StackSampleIntervalUs` sets the sampling interval. | `Public/VoxelForgeModule.h` / `Private/VoxelForgeModule.cpp` | `FVoxelForgeModule` boilerplate (Startup/Shutdown just log). | -| `Source/VoxelForgeEditor/VoxelForgeEditor.Build.cs` / `Private/VoxelForgeExploreCommandlet.*` | Editor/commandlet-only `-run=VoxelForgeExplore`; owns bounded render/`VF_` walk/OBJ export orchestration and is not linked by Game/Shipping. Optional `-exporttilex/y/z` sets an exact L0 tile origin for controlled export/game parity; omitting all three preserves the existing center-derived origin. `-regioneval=1` runs the default-off step-2 research harness against live `GetDensityAt`. | -| `Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.*` | Six-partition lattice driver for the commandlet-only step-2 experiment: cold 259³ timing, bitwise repeat hashes, fixed 16-bit quantized comparisons, and exact candidate-visit attribution. It calls the live final-field API in both modes; the prepared mode selects editor-only 8³ SDF candidate bins. | +| `Source/VoxelForgeEditor/VoxelForgeEditor.Build.cs` / `Private/VoxelForgeExploreCommandlet.*` | Editor/commandlet-only `-run=VoxelForgeExplore`; owns bounded render/`VF_` walk/OBJ export orchestration and is not linked by Game/Shipping. Optional `-exporttilex/y/z` sets an exact L0 tile origin for controlled export/game parity; omitting all three preserves the existing center-derived origin. `-regioneval=1` runs the default-off step-2 research harness: full-ring and five-seed Q16 comparisons, repeat hashes, shrunk-reach falsification, cold timings, counted live fallbacks, and separate before/after stack captures when `-SampleDensityStacks` is set. `-RegionHashProbe` runs one full-ring brick evaluation and compares its raw float hash with a reference from another worker count. | +| `Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.*` | Lattice driver with six workers by default for the commandlet-only region experiment. The live path calls `GetDensityAt` per point; the brick path groups the lattice into aligned 8³ regions and calls the editor-only `EvaluateDensityBrick` once per region, counts scalar fallback points, and compares the exact final field. The operator block uses prepared SDF candidate bins and retains canonical per-point post-stack writer order. The full-ring hash probe supports 1–32 fixed partitions to check determinism across worker counts. | | `Public/VoxelGeometryHash.h` | Header-only geometry digest shared by commandlet export and runtime tile-hash dump; hashes mesh counts and the vertex/normal/UV/color/triangle arrays with the historical CRC order. | | `Public/VoxelStats.h` / `Private/VoxelStats.cpp` | `stat VoxelForge` DWORD counters for tile classification, skipping, meshing, operator-stack verdicts, and cave-bail diagnosis. Each `Cave Bail *` counter names one guard; `Cave Bail No Stack` counts slots with no buildable stack (no definition, or a degenerate strate). | @@ -363,6 +363,7 @@ This is **where terrain shape lives.** | `ApplyBoundarySeal` (static) | 996 | Solidifies strate top/bottom shells. | | `InitializeSettings` | 2691 | Copies the effective seed, spine radius, and global XY world-bound settings from `UVoxelSettings` (the season owns all three when assigned); radius 0 preserves the unbounded field. | | **`GetDensityAt`** | 2712 | **Entry point.** Picks strate + generator type and, on the per-chunk refetch, builds the chunk's operator stack (editor composer → cooked-season recipe → native archetype stack); evaluates it (TunnelNetwork/Underwater through the fused `GetDensityWithParams`), then adds disturbances and structural posts. Optional `OutPreEditDensity` receives the exact final procedural field immediately before the player DiffLayer tail, allowing the near-player dig cache to replay edits without regenerating the operator stack. Its ordinary one-argument behavior is unchanged. A degenerate strate has no stack and is air; `CP_*` state remains keyed by `(DensityCacheOwnerId, ChunkCoord, LayoutVersion)`. | +| **`EvaluateDensityBrick`** | 2728 | **Editor-only, default-off research API.** Accepts a lattice region contained by one 8-voxel bin and one 32-voxel chunk. Routes once, reuses `FVoxelDensityBlockSession::FillChunk` / `EvalBlock` for supported TunnelNetwork and Underwater operator stacks, and applies the existing post-stack writers across all brick points before returning. Unsupported routes use scalar `GetDensityAt` and return their fallback count. The API is absent from non-editor builds; streaming and gameplay continue through the original scalar API. | | **`GetDensityWithParams`** | 3749 | TunnelNetwork pipeline (~1000 lines) — the fused evaluator of the TunnelNetwork/Underwater stack in `GetDensityAt`. See §4. ⚠️ Takes **required** `ParamsFingerprint` + `LayoutVersion` since the AUDIT §C2 fix (2026-07-28) — they go into the SDF cache key so a chunk can no longer be evaluated against a neighbour's rooms. Callers compute the CRC **once per chunk** (`CP_TunnelFP`), never per voxel. The optional `voxel.WormBlockSkip` path (default 0) uses `VF_TryGetWormBlockSkip` + a worker-local 4³ lattice-block cache to prove `|N1| >= WormThreshold` and remove both worm noise calls only; it never changes `NetworkMask` or the fallback field path. | | `SampleSurfaceStructuralZ` | — | **F20:** the RAW SurfaceWorld heightfield (continents+mountains+detail), BEFORE any terrain op; returns terrain Z + relief M. Cliff re-samples it at an XY offset for a cheap analytic slope. | | `ComputeSurfaceTerrainZ` / `ComputeSurfaceColumn` | — | SurfaceWorld heightfield → terrain Z (+ sky-cap ceiling and overhang gate per column); biome **output-blend** lerps dominant/neighbour heights (`ParamsD`/`ParamsN`/weight). **F20 surface ops** (`FSurfaceGenerationParams`, biome-selected + slope/relief-conditioned, all default off): Cliff (slope-gated STEEPENING — push height from local mean where steep ⇒ sheer walls; 4 structural resamples only when on), Terrace (relief-gated + `TerraceHardness`), LayerLines (sedimentary shelves) — pure per-column height REMAPS applied here so the single height oracle stays consistent (MC/ClassifyTile/deco/BP bridge). **Phase 2 OVERHANG** (volumetric — real jutting shelves): in the stack's `FOverhangShelfMod`, for AIR voxels in a window `(TerrainZ, TerrainZ+OverhangHeight]` above a steep slope, the heightfield is re-sampled UPHILL (toward the cliff) by a reach that GROWS with height (tiny low ⇒ air over the void, full high ⇒ borrows the far cliff rock) and unioned in ⇒ a shelf attached to the cliff, tapering out over the void with air beneath (the sketch). Per-column `OverhangAmp`(=strength·slope-gate) + unit uphill `(DirX,DirY)` resolved once in `ComputeSurfaceColumn` (gradient sampled at the REACH scale so a spot over the void can see the cliff), cached on `FSurfaceColumn`. Genuine 3D (per-voxel structural re-eval, gated to steep overhang columns). Off ⇒ byte-identical. The field-preserving LOD0 pass removes only needless temporaries/default construction; every output remains bit-identical. §8.14. | diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index b71a754..4ea1b35 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -641,6 +641,8 @@ thread_local int32 VoxelGenLOD::OctaveBias = 0; struct FVoxelDensityBlockSession { bool bActive = false; + bool bBatchRequested = false; + bool bBatchSucceeded = false; FIntVector Origin = FIntVector::ZeroValue; int32 Step = 1; int32 SizeX = 0; @@ -650,6 +652,7 @@ struct FVoxelDensityBlockSession TArray Valid; TArray BlockScratch; TArray ActiveOps; + TArray BatchResults; void Begin(FIntVector InOrigin, int32 InStep, int32 InSizeX, int32 InSizeY, int32 InSizeZ) { @@ -665,6 +668,9 @@ struct FVoxelDensityBlockSession Valid.Reset(); BlockScratch.Reset(); ActiveOps.Reset(); + BatchResults.Reset(); + bBatchRequested = false; + bBatchSucceeded = false; return; } @@ -672,6 +678,9 @@ struct FVoxelDensityBlockSession // (gaps, out-of-layout, degenerate strates) use the ordinary scalar path and should not // pay for a block scratch lattice merely because the mesher opened the scoped hand-off. BlockScratch.Reset(); + BatchResults.Reset(); + bBatchRequested = false; + bBatchSucceeded = false; } void End() @@ -683,6 +692,9 @@ struct FVoxelDensityBlockSession Valid.Reset(); BlockScratch.Reset(); ActiveOps.Reset(); + BatchResults.Reset(); + bBatchRequested = false; + bBatchSucceeded = false; } int32 Index(int32 X, int32 Y, int32 Z) const @@ -2709,6 +2721,77 @@ void UVoxelGenerator::EndDensityBlock() const GVoxelDensityBlockSession.End(); } +#if WITH_EDITOR +bool UVoxelGenerator::EvaluateDensityBrick( + FIntVector OriginVoxels, int32 SizeX, int32 SizeY, int32 SizeZ, + TArray& OutDensities, int32& OutFallbackSamples) const +{ + OutDensities.Reset(); + OutFallbackSamples = 0; + const int64 SampleCount64 = static_cast(SizeX) * SizeY * SizeZ; + if (SizeX <= 0 || SizeY <= 0 || SizeZ <= 0 + || SizeX > 8 || SizeY > 8 || SizeZ > 8 || SampleCount64 > 512) + { + return false; + } + + const FIntVector Last = OriginVoxels + FIntVector(SizeX - 1, SizeY - 1, SizeZ - 1); + const auto SameCell = [](int32 A, int32 B, int32 CellSize) + { + return FMath::FloorToInt(static_cast(A) / static_cast(CellSize)) + == FMath::FloorToInt(static_cast(B) / static_cast(CellSize)); + }; + if (!SameCell(OriginVoxels.X, Last.X, 8) + || !SameCell(OriginVoxels.Y, Last.Y, 8) + || !SameCell(OriginVoxels.Z, Last.Z, 8) + || !SameCell(OriginVoxels.X, Last.X, CHUNK_SIZE) + || !SameCell(OriginVoxels.Y, Last.Y, CHUNK_SIZE) + || !SameCell(OriginVoxels.Z, Last.Z, CHUNK_SIZE)) + { + return false; + } + + const int32 SampleCount = static_cast(SampleCount64); + BeginDensityBlock(OriginVoxels, 1, SizeX, SizeY, SizeZ); + GVoxelDensityBlockSession.bBatchRequested = true; + GVoxelDensityBlockSession.bBatchSucceeded = false; + GVoxelDensityBlockSession.BatchResults.SetNumUninitialized(SampleCount); + + const float FirstDensity = GetDensityAt( + static_cast(OriginVoxels.X), + static_cast(OriginVoxels.Y), + static_cast(OriginVoxels.Z)); + if (GVoxelDensityBlockSession.bBatchSucceeded) + { + OutDensities.SetNumUninitialized(SampleCount); + FMemory::Memcpy( + OutDensities.GetData(), GVoxelDensityBlockSession.BatchResults.GetData(), + static_cast(SampleCount) * sizeof(float)); + EndDensityBlock(); + return OutDensities.Num() == SampleCount; + } + + // Recipes without the editor brick port retain the canonical point evaluator. The first + // call already produced point zero; the remaining samples are explicit counted fallbacks. + GVoxelDensityBlockSession.bBatchRequested = false; + OutDensities.SetNumUninitialized(SampleCount); + OutDensities[0] = FirstDensity; + for (int32 Index = 1; Index < SampleCount; ++Index) + { + const int32 X = Index % SizeX; + const int32 Y = (Index / SizeX) % SizeY; + const int32 Z = Index / (SizeX * SizeY); + OutDensities[Index] = GetDensityAt( + static_cast(OriginVoxels.X + X), + static_cast(OriginVoxels.Y + Y), + static_cast(OriginVoxels.Z + Z)); + } + OutFallbackSamples = SampleCount; + EndDensityBlock(); + return true; +} +#endif + float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ, float* OutPreEditDensity) const { @@ -2789,6 +2872,12 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ, bIsGapChunk = StrateManager->IsGapChunk(ChunkCoord); } +#if WITH_EDITOR + bool bEvaluateDensityBatch = false; + int32 BatchSampleCount = 0; + int32 DensityBatchCount = 1; + bool bBrickHasDiffMods = false; +#endif if (StrateManager && bIsGapChunk) { // SOLID BEDROCK gap between two strates. The auto-carved passages still tunnel @@ -3361,6 +3450,160 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ, } DensityCoreTimer.End(); +#if WITH_EDITOR + // The editor region program asks this one routed sample to fill every point in the + // already-evaluated operator block. Its bricks are contained by one 8-voxel research + // bin and one 32-voxel chunk, so the stack, params, route, and cache key above are shared. + BatchSampleCount = GVoxelDensityBlockSession.SizeX + * GVoxelDensityBlockSession.SizeY * GVoxelDensityBlockSession.SizeZ; + bEvaluateDensityBatch = GVoxelDensityBlockSession.bBatchRequested + && bUsedOpBlockSample && !bUsedFusedEvaluator && CP_UseOpStack + && !bIsGapChunk && StrateManager != nullptr + && (CP_GenType == ECaveGeneratorType::TunnelNetwork + || CP_GenType == ECaveGeneratorType::Underwater) + && GVoxelDensityBlockSession.Samples.Num() == BatchSampleCount + && GVoxelDensityBlockSession.BatchResults.Num() == BatchSampleCount; + DensityBatchCount = bEvaluateDensityBatch ? BatchSampleCount : 1; + bool bBrickHasDisturbanceCandidate = true; + bool bBrickHasPassagePostCandidate = true; + if (bEvaluateDensityBatch) + { + const FIntVector BrickLast = GVoxelDensityBlockSession.Origin + FIntVector( + (GVoxelDensityBlockSession.SizeX - 1) * GVoxelDensityBlockSession.Step, + (GVoxelDensityBlockSession.SizeY - 1) * GVoxelDensityBlockSession.Step, + (GVoxelDensityBlockSession.SizeZ - 1) * GVoxelDensityBlockSession.Step); + const FBox BrickBox( + FVector(GVoxelDensityBlockSession.Origin.X, + GVoxelDensityBlockSession.Origin.Y, + GVoxelDensityBlockSession.Origin.Z), + FVector(BrickLast.X, BrickLast.Y, BrickLast.Z)); + bBrickHasDisturbanceCandidate = !VoxelDensityAblation::IsDisturbancesOff() + && VF_AnyDisturbanceCanTouchBox( + BrickBox, CP_Dist, static_cast(Seed)); + + const bool bAllPassagePostsOff = + VoxelDensityAblation::IsPassageStructuralPostsOff() + && VoxelDensityAblation::IsNativeFloorOff() + && VoxelDensityAblation::IsLandingPostsOff(); + if (bAllPassagePostsOff) + { + bBrickHasPassagePostCandidate = false; + } + else if (!VoxelDensityAblation::IsPassageCarvingOff()) + { + bBrickHasPassagePostCandidate = StrateManager->AnyPassageNearBox( + BrickBox.Min, BrickBox.Max); + } + bBrickHasDiffMods = DiffLayer != nullptr && DiffLayer->HasAnyMods(); + } + const bool bApplyBrickDisturbances = + !bEvaluateDensityBatch || bBrickHasDisturbanceCandidate; + const bool bApplyBrickPassagePosts = + !bEvaluateDensityBatch || bBrickHasPassagePostCandidate; +#else + const bool bApplyBrickDisturbances = true; + const bool bApplyBrickPassagePosts = true; +#endif + // Chunk parameters and composer/custom recipe structure are fixed by the route above. + // Resolve the landing writer's base once for the whole brick, including edge bricks. + float BrickLandingBaseDensity = CP_Dist.BaseDensity; +#if WITH_EDITOR + if (CP_UseComposerRegions && CP_ComposerRegions.bHasGlobalStructuralParams) + { + BrickLandingBaseDensity = CP_ComposerRegions.BaseDensity; + } + else +#endif + if (CP_UseCustomRecipe) + { + switch (CP_CustomRecipe.StructuralParamBlock) + { + case EVoxelStrateParamBlock::TunnelNetwork: + BrickLandingBaseDensity = CP_CustomParams.TunnelNetworkParams.BaseDensity; + break; + case EVoxelStrateParamBlock::Slab: + BrickLandingBaseDensity = CP_CustomParams.SlabParams.BaseDensity; + break; + case EVoxelStrateParamBlock::Maze: + BrickLandingBaseDensity = CP_CustomParams.MazeParams.BaseDensity; + break; + case EVoxelStrateParamBlock::Surface: + BrickLandingBaseDensity = CP_CustomParams.SurfaceParams.BaseDensity; + break; + case EVoxelStrateParamBlock::VerticalShaft: + BrickLandingBaseDensity = CP_CustomParams.VerticalShaftParams.BaseDensity; + break; + case EVoxelStrateParamBlock::FloatingIsland: + BrickLandingBaseDensity = CP_CustomParams.FloatingIslandParams.BaseDensity; + break; + default: + break; + } + } + else + { + switch (CP_GenType) + { + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + BrickLandingBaseDensity = CP_Slab.BaseDensity; + break; + case ECaveGeneratorType::Maze: + BrickLandingBaseDensity = CP_Maze.BaseDensity; + break; + case ECaveGeneratorType::SurfaceWorld: + BrickLandingBaseDensity = CP_Surface.BaseDensity; + break; + case ECaveGeneratorType::VerticalShafts: + BrickLandingBaseDensity = CP_Vert.BaseDensity; + break; + case ECaveGeneratorType::FloatingIslands: + BrickLandingBaseDensity = CP_Float.BaseDensity; + break; + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + default: + BrickLandingBaseDensity = CP_Tunnel.BaseDensity; + break; + } + } + for (int32 DensityBatchIndex = 0; +#if WITH_EDITOR + DensityBatchIndex < DensityBatchCount; +#else + DensityBatchIndex < 1; +#endif + ++DensityBatchIndex) + { +#if WITH_EDITOR + if (bEvaluateDensityBatch) + { + const int32 BatchX = DensityBatchIndex % GVoxelDensityBlockSession.SizeX; + const int32 BatchY = (DensityBatchIndex / GVoxelDensityBlockSession.SizeX) + % GVoxelDensityBlockSession.SizeY; + const int32 BatchZ = DensityBatchIndex + / (GVoxelDensityBlockSession.SizeX * GVoxelDensityBlockSession.SizeY); + WorldX = static_cast(GVoxelDensityBlockSession.Origin.X + + BatchX * GVoxelDensityBlockSession.Step); + WorldY = static_cast(GVoxelDensityBlockSession.Origin.Y + + BatchY * GVoxelDensityBlockSession.Step); + WorldZ = static_cast(GVoxelDensityBlockSession.Origin.Z + + BatchZ * GVoxelDensityBlockSession.Step); + + const FVoxelOpSample& BatchSample = + GVoxelDensityBlockSession.Samples[DensityBatchIndex]; + Result = -BatchSample.Density; + bUsedOpBlockSample = true; + bBlockHasTunnelCore = BatchSample.bHasTunnelCoreWorldEvaluation; + BlockTunnelCoreSDF = BatchSample.TunnelCoreWorldSDF; + bBlockTunnelCoreRoomFloor = BatchSample.bTunnelCoreRoomFloor; + bBlockHasTunnelCoreSweptFloor = BatchSample.bHasTunnelCoreSweptFloor; + BlockTunnelCoreSweptFloorZ = BatchSample.TunnelCoreSweptFloorZ; + BlockTunnelCoreSweptFloorRadius = BatchSample.TunnelCoreSweptFloorRadius; + bUsedFusedEvaluator = false; + bRoomOwnsBottomValid = false; + } +#endif // Resolve the graph's authored floor before the disturbance pass. The operator-stack // source has already computed this exact result while publishing its sample; native // tunnel generation uses the prepared immutable cache. Keeping the result here means the @@ -3420,8 +3663,11 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ, VoxelDensityProfile::EBucket::DensityDisturbances); const bool bProtectVerticalShaftAir = VoxelPassageGeometry::VerticalShaftConnectorAirMarker(); - ApplyDisturbances(Result, WorldX, WorldY, WorldZ, CP_Dist, (uint32)Seed, - bProtectVerticalShaftAir); + if (bApplyBrickDisturbances) + { + ApplyDisturbances(Result, WorldX, WorldY, WorldZ, CP_Dist, (uint32)Seed, + bProtectVerticalShaftAir); + } DensityDisturbancesTimer.End(); // A disturbance is allowed to add visual detail, but it must not refill the landing's @@ -3432,71 +3678,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ, VoxelDensityProfile::EBucket::DensityStructuralPosts); VoxelDensityProfile::FScopedTimer StructuralTailTimer( VoxelDensityProfile::EBucket::StructuralTail); - float LandingBaseDensity = CP_Dist.BaseDensity; -#if WITH_EDITOR - // A composer parent can deliberately use a different structural base than the authored - // definition. The landing floor was already applied by PassageCarveOp with that parent - // base; use the same value for this post-disturbance MC backstop or live-vs-direct stack - // evaluation would differ exactly on floor voxels. - if (CP_UseComposerRegions && CP_ComposerRegions.bHasGlobalStructuralParams) - { - LandingBaseDensity = CP_ComposerRegions.BaseDensity; - } - else -#endif - if (CP_UseCustomRecipe) - { - switch (CP_CustomRecipe.StructuralParamBlock) - { - case EVoxelStrateParamBlock::TunnelNetwork: - LandingBaseDensity = CP_CustomParams.TunnelNetworkParams.BaseDensity; - break; - case EVoxelStrateParamBlock::Slab: - LandingBaseDensity = CP_CustomParams.SlabParams.BaseDensity; - break; - case EVoxelStrateParamBlock::Maze: - LandingBaseDensity = CP_CustomParams.MazeParams.BaseDensity; - break; - case EVoxelStrateParamBlock::Surface: - LandingBaseDensity = CP_CustomParams.SurfaceParams.BaseDensity; - break; - case EVoxelStrateParamBlock::VerticalShaft: - LandingBaseDensity = CP_CustomParams.VerticalShaftParams.BaseDensity; - break; - case EVoxelStrateParamBlock::FloatingIsland: - LandingBaseDensity = CP_CustomParams.FloatingIslandParams.BaseDensity; - break; - default: - break; - } - } - else - { - switch (CP_GenType) - { - case ECaveGeneratorType::FlatPlain: - case ECaveGeneratorType::CrystalChamber: - LandingBaseDensity = CP_Slab.BaseDensity; - break; - case ECaveGeneratorType::Maze: - LandingBaseDensity = CP_Maze.BaseDensity; - break; - case ECaveGeneratorType::SurfaceWorld: - LandingBaseDensity = CP_Surface.BaseDensity; - break; - case ECaveGeneratorType::VerticalShafts: - LandingBaseDensity = CP_Vert.BaseDensity; - break; - case ECaveGeneratorType::FloatingIslands: - LandingBaseDensity = CP_Float.BaseDensity; - break; - case ECaveGeneratorType::TunnelNetwork: - case ECaveGeneratorType::Underwater: - default: - LandingBaseDensity = CP_Tunnel.BaseDensity; - break; - } - } + const float LandingBaseDensity = BrickLandingBaseDensity; auto ApplyTunnelCoreShape = [&](const FTunnelCoreWorldEvaluation& Core) { if (!VoxelMath::IsFinite(Core.SDF)) @@ -3538,6 +3720,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ, CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ, CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius); } + if (bApplyBrickPassagePosts) { VoxelDensityProfile::FScopedTimer ProfileTimer( VoxelDensityProfile::EBucket::PassageStructuralPosts); @@ -3567,12 +3750,14 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ, CP_Dist.StrateTopWorldZ, CP_Dist.StrateBottomWorldZ, CP_Dist.BoundarySealThickness, LandingBaseDensity, OriginSpineRadius); } + if (bApplyBrickPassagePosts) { VoxelDensityProfile::FScopedTimer ProfileTimer( VoxelDensityProfile::EBucket::PassageLandingFloor); StrateManager->ApplyPassageLandingFloorMC( Result, WorldX, WorldY, WorldZ, LandingBaseDensity); } + if (bApplyBrickPassagePosts) { VoxelDensityProfile::FScopedTimer ProfileTimer( VoxelDensityProfile::EBucket::PassageLandingRoomFloor); @@ -3647,6 +3832,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ, // composed from the immutable construction-time profile after generic writers and the // graph overlap, so the legacy passage support slab can be disabled without losing the // floor to a disturbance refill. + if (bApplyBrickPassagePosts) { VoxelDensityProfile::FScopedTimer ProfileTimer( VoxelDensityProfile::EBucket::PassageLandingFloor); @@ -3655,6 +3841,13 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ, } StructuralTailTimer.End(); DensityStructuralPostsTimer.End(); +#if WITH_EDITOR + if (bEvaluateDensityBatch) + { + GVoxelDensityBlockSession.BatchResults[DensityBatchIndex] = Result; + } +#endif + } } else { @@ -3673,6 +3866,33 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ, DensityCoreTimer.End(); } +#if WITH_EDITOR + const int32 FinalDensityBatchCount = bEvaluateDensityBatch ? BatchSampleCount : 1; +#else + const int32 FinalDensityBatchCount = 1; +#endif + for (int32 FinalDensityBatchIndex = 0; + FinalDensityBatchIndex < FinalDensityBatchCount; + ++FinalDensityBatchIndex) + { +#if WITH_EDITOR + if (bEvaluateDensityBatch) + { + const int32 BatchX = FinalDensityBatchIndex % GVoxelDensityBlockSession.SizeX; + const int32 BatchY = (FinalDensityBatchIndex / GVoxelDensityBlockSession.SizeX) + % GVoxelDensityBlockSession.SizeY; + const int32 BatchZ = FinalDensityBatchIndex + / (GVoxelDensityBlockSession.SizeX * GVoxelDensityBlockSession.SizeY); + WorldX = static_cast(GVoxelDensityBlockSession.Origin.X + + BatchX * GVoxelDensityBlockSession.Step); + WorldY = static_cast(GVoxelDensityBlockSession.Origin.Y + + BatchY * GVoxelDensityBlockSession.Step); + WorldZ = static_cast(GVoxelDensityBlockSession.Origin.Z + + BatchZ * GVoxelDensityBlockSession.Step); + Result = GVoxelDensityBlockSession.BatchResults[FinalDensityBatchIndex]; + VoxelPassageGeometry::ResetVerticalShaftConnectorAirMarker(); + } +#endif // The edge is the final generated structural invariant. Passage carving is ordered before it // in every archetype/stack, and this MC-facing pass also covers out-of-layout air plus any // disturbance that might otherwise carve back into the shell. Player edits remain separate @@ -3704,7 +3924,13 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ, { *OutPreEditDensity = Result; } - if (DiffLayer && DiffLayer->HasAnyMods()) + const bool bDiffLayerHasModsForSample = DiffLayer != nullptr && +#if WITH_EDITOR + (bEvaluateDensityBatch ? bBrickHasDiffMods : DiffLayer->HasAnyMods()); +#else + DiffLayer->HasAnyMods(); +#endif + if (DiffLayer && bDiffLayerHasModsForSample) { // ── PER-CHUNK MOD SNAPSHOT ── // The old HasModifications + GetDensityOffset pair took the diff layer's RWLock + a TMap find @@ -3737,8 +3963,23 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ, { Result -= UVoxelDiffLayer::EvaluateMods(Slot.Mods, WorldX, WorldY, WorldZ); } + } + DensityDiffLayerTimer.End(); + +#if WITH_EDITOR + if (bEvaluateDensityBatch) + { + GVoxelDensityBlockSession.BatchResults[FinalDensityBatchIndex] = Result; + } +#endif } - DensityDiffLayerTimer.End(); + +#if WITH_EDITOR + if (bEvaluateDensityBatch) + { + GVoxelDensityBlockSession.bBatchSucceeded = true; + } +#endif VoxelDensityProfile::FScopedTimer DensityTailTimer( VoxelDensityProfile::EBucket::DensityTail); diff --git a/Source/VoxelForge/Public/VoxelGenerator.h b/Source/VoxelForge/Public/VoxelGenerator.h index 5da3619..56fdb98 100644 --- a/Source/VoxelForge/Public/VoxelGenerator.h +++ b/Source/VoxelForge/Public/VoxelGenerator.h @@ -180,6 +180,13 @@ public: int32 SizeX, int32 SizeY, int32 SizeZ) const; void EndDensityBlock() const; +#if WITH_EDITOR + /** Editor-only region-program entry point; unsupported routes report live point fallbacks. */ + bool EvaluateDensityBrick(FIntVector OriginVoxels, int32 SizeX, int32 SizeY, int32 SizeZ, + TArray& OutDensities, + int32& OutFallbackSamples) const; +#endif + /** * Prove that the mesher's core lattice is entirely solid from the cheap, structural rules * that own inter-strate gaps and boundary seals. This never samples density. It is intended diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp index 684e034..984c5d0 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -1475,6 +1475,40 @@ struct FRegionEvalPass FString EndedUtc; }; +struct FRegionEvalStackSamplerSession +{ + bool bStarted = false; + + ~FRegionEvalStackSamplerSession() + { + if (bStarted) + { + FVoxelStackSampler::Get().StopAndWrite(); + } + } + + bool Start(uint32 IntervalUs, const FString& OutputDirectory, + const TCHAR* Scenario = TEXT("region_eval_live_ring")) + { + bStarted = FVoxelStackSampler::Get().Start( + IntervalUs, OutputDirectory, Scenario); + return bStarted; + } + + bool Stop(FVoxelStackSampler::FSummary& OutSummary) + { + if (!bStarted) + { + return false; + } + OutSummary = FVoxelStackSampler::Get().StopAndWrite(); + bStarted = false; + return OutSummary.bOutputWritten + && OutSummary.RegisteredThreadSamples > 0 + && OutSummary.RetainedSamples > 0; + } +}; + int32 FindRoomGraphRecipeIndex(int32 Seed) { for (int32 Index = 0; Index < 64; ++Index) @@ -1724,6 +1758,7 @@ bool RunRegionEvalPass( const FExploreArguments& BaseArguments, int32 Seed, bool bPrepared, + bool bBrickProgram, float ReachScale, bool bFullRing, const TArray* RequestedIndices, @@ -1731,7 +1766,8 @@ bool RunRegionEvalPass( bool bCountCandidates, bool bKeepDensities, FRegionEvalPass& OutPass, - FString& OutError) + FString& OutError, + int32 RequestedWorkerCount = VoxelForgeRegionEval::WorkerCount) { OutPass = FRegionEvalPass(); FExploreWorld World; @@ -1773,7 +1809,7 @@ bool RunRegionEvalPass( bFullRing ? nullptr : &OutPass.SampleIndices, bFullRing, bPrepared, ReachScale, bKeepDensities ? &OutPass.Densities : nullptr, - OutPass.Timing); + OutPass.Timing, bBrickProgram, RequestedWorkerCount); OutPass.EndedUtc = FDateTime::UtcNow().ToIso8601(); if (!bEvaluated) { @@ -1799,19 +1835,27 @@ bool RunRegionEvalPass( TSharedRef RegionEvalTimingJson(const FRegionEvalPass& Pass, bool bPrepared) { TSharedRef Json = MakeShared(); - Json->SetStringField(TEXT("path"), bPrepared ? TEXT("prepared_8cube_bins") : TEXT("live_GetDensityAt")); + Json->SetStringField(TEXT("path"), Pass.Timing.bBrickProgram + ? TEXT("editor_region_brick_program") + : (bPrepared ? TEXT("prepared_8cube_bins") : TEXT("live_GetDensityAt"))); Json->SetStringField(TEXT("started_utc"), Pass.StartedUtc); Json->SetStringField(TEXT("ended_utc"), Pass.EndedUtc); Json->SetNumberField(TEXT("worker_count"), Pass.Timing.Workers); Json->SetNumberField(TEXT("observed_worker_threads"), Pass.Timing.ObservedWorkerThreads); Json->SetNumberField(TEXT("samples"), static_cast(Pass.Timing.SampleCount)); + Json->SetNumberField(TEXT("evaluated_brick_points"), static_cast(Pass.Timing.EvaluatedSamples)); + Json->SetNumberField(TEXT("brick_count"), static_cast(Pass.Timing.BrickCount)); + Json->SetNumberField(TEXT("fallback_points"), static_cast(Pass.Timing.FallbackSamples)); + Json->SetNumberField(TEXT("fallback_share"), Pass.Timing.FallbackShare); Json->SetNumberField(TEXT("world_setup_seconds_before_density_ring"), Pass.WorldPrepareSeconds); Json->SetNumberField(TEXT("wall_seconds"), Pass.Timing.WallSeconds); Json->SetNumberField(TEXT("worker_cpu_seconds"), Pass.Timing.WorkerCpuSeconds); Json->SetNumberField(TEXT("worker_cpu_ns_per_sample"), Pass.Timing.NanosecondsPerSample); - Json->SetStringField(TEXT("worker_cpu_source"), Pass.Timing.bWorkerCpuFromOsThreadTimes - ? TEXT("summed Windows GetThreadTimes user+kernel CPU across six fixed work partitions") - : TEXT("summed worker elapsed fallback; OS thread CPU counters unavailable")); + const FString WorkerCpuSource = Pass.Timing.bWorkerCpuFromOsThreadTimes + ? FString::Printf(TEXT("summed Windows GetThreadTimes user+kernel CPU across %d fixed work partitions"), + Pass.Timing.Workers) + : TEXT("summed worker elapsed fallback; OS thread CPU counters unavailable"); + Json->SetStringField(TEXT("worker_cpu_source"), WorkerCpuSource); Json->SetStringField(TEXT("quantized_hash_fnv1a64"), FString::Printf(TEXT("%016llx"), static_cast(Pass.Timing.QuantizedHash))); Json->SetStringField(TEXT("raw_float_hash_fnv1a64"), @@ -1910,8 +1954,112 @@ int32 RunRegionEval(const FString& Params) int32 RequestedSamples = 262144; FParse::Value(*Params, TEXT("regionsamples="), RequestedSamples); RequestedSamples = FMath::Clamp(RequestedSamples, 32768, 1000000); + int32 RequestedWorkerCount = VoxelForgeRegionEval::WorkerCount; + FParse::Value(*Params, TEXT("regionworkers="), RequestedWorkerCount); + RequestedWorkerCount = FMath::Clamp(RequestedWorkerCount, 1, 32); IFileManager::Get().MakeDirectory(*Arguments.OutDirectory, true); + if (FParse::Param(*Params, TEXT("regionhashprobe"))) + { + FRegionEvalStackSamplerSession WorkerHashStackSampler; + if (Arguments.bSampleStacks + && !WorkerHashStackSampler.Start( + Arguments.SampleStacksIntervalUs, Arguments.OutDirectory, + TEXT("region_eval_brick_worker_hash"))) + { + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeRegionEval] worker hash stack sampler could not be started.")); + return 2; + } + + FRegionEvalPass WorkerHashPass; + const bool bHashPassSucceeded = RunRegionEvalPass( + Arguments, 1337, true, true, 1.0f, true, nullptr, + RequestedSamples, false, true, WorkerHashPass, Error, + RequestedWorkerCount); + bool bStackCaptureSucceeded = true; + if (Arguments.bSampleStacks) + { + FVoxelStackSampler::FSummary StackSummary; + bStackCaptureSucceeded = WorkerHashStackSampler.Stop(StackSummary); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeRegionEval] worker-hash stack capture registered=%llu retained=%llu " + "raw=%s summary=%s"), + static_cast(StackSummary.RegisteredThreadSamples), + static_cast(StackSummary.RetainedSamples), + *StackSummary.RawSamplesPath, *StackSummary.SummaryPath); + } + if (!bHashPassSucceeded || !bStackCaptureSucceeded) + { + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeRegionEval] worker hash probe failed: %s"), + Error.IsEmpty() ? TEXT("stack capture retained no worker samples") : *Error); + return 2; + } + + FString ReferenceRawHash; + FParse::Value(*Params, TEXT("regionreferencehash="), ReferenceRawHash); + const FString ActualRawHash = FString::Printf(TEXT("%016llx"), + static_cast(WorkerHashPass.Timing.RawFloatHash)); + const bool bHasReferenceHash = !ReferenceRawHash.IsEmpty(); + const bool bMatchesReference = bHasReferenceHash + && ActualRawHash.Equals(ReferenceRawHash, ESearchCase::IgnoreCase); + const bool bProbePassed = bMatchesReference + && WorkerHashPass.Timing.SampleCount + == static_cast(VoxelForgeRegionEval::RingDimension) + * VoxelForgeRegionEval::RingDimension * VoxelForgeRegionEval::RingDimension + && WorkerHashPass.Timing.SaturatedSamples == 0 + && WorkerHashPass.Timing.NonFiniteSamples == 0; + + TSharedRef ProbeReport = MakeShared(); + ProbeReport->SetBoolField(TEXT("pass"), bProbePassed); + ProbeReport->SetBoolField(TEXT("worker_count_hash_probe"), true); + ProbeReport->SetNumberField(TEXT("worker_count"), WorkerHashPass.Timing.Workers); + ProbeReport->SetNumberField(TEXT("observed_worker_threads"), + WorkerHashPass.Timing.ObservedWorkerThreads); + ProbeReport->SetNumberField(TEXT("samples"), WorkerHashPass.Timing.SampleCount); + ProbeReport->SetNumberField(TEXT("brick_count"), WorkerHashPass.Timing.BrickCount); + ProbeReport->SetNumberField(TEXT("fallback_points"), WorkerHashPass.Timing.FallbackSamples); + ProbeReport->SetNumberField(TEXT("fallback_share"), WorkerHashPass.Timing.FallbackShare); + ProbeReport->SetNumberField(TEXT("wall_seconds"), WorkerHashPass.Timing.WallSeconds); + ProbeReport->SetNumberField(TEXT("worker_cpu_seconds"), WorkerHashPass.Timing.WorkerCpuSeconds); + ProbeReport->SetStringField(TEXT("reference_raw_float_hash_fnv1a64"), ReferenceRawHash); + ProbeReport->SetStringField(TEXT("raw_float_hash_fnv1a64"), ActualRawHash); + ProbeReport->SetBoolField(TEXT("matches_reference"), bMatchesReference); + + FString ProbeJsonText; + TSharedRef> ProbeWriter = TJsonWriterFactory<>::Create(&ProbeJsonText); + if (!FJsonSerializer::Serialize(ProbeReport, ProbeWriter) + || !FFileHelper::SaveStringToFile( + ProbeJsonText, *FPaths::Combine(Arguments.OutDirectory, TEXT("region_eval.json")), + FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)) + { + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeRegionEval] could not write worker hash probe report.")); + return 2; + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeRegionEval] worker hash probe workers=%d observed=%d samples=%lld " + "raw=%s reference_match=%s report=%s"), + WorkerHashPass.Timing.Workers, WorkerHashPass.Timing.ObservedWorkerThreads, + static_cast(WorkerHashPass.Timing.SampleCount), *ActualRawHash, + bMatchesReference ? TEXT("yes") : TEXT("no"), + *FPaths::Combine(Arguments.OutDirectory, TEXT("region_eval.json"))); + return bProbePassed ? 0 : 2; + } + + // Region-eval returns before the normal Explore sampler setup. Capture only its two cold live + // ring trials so prepared-path and multi-seed work cannot pollute the baseline attribution. + FRegionEvalStackSamplerSession StackSamplerSession; + if (Arguments.bSampleStacks + && !StackSamplerSession.Start( + Arguments.SampleStacksIntervalUs, Arguments.OutDirectory)) + { + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeRegionEval] requested stack sampler could not be started.")); + return 2; + } + const int32 Seeds[] = { 1337, 7331, 19427, 30241, 41011 }; TArray> SeedReports; TArray> OldTimingReports; @@ -1938,8 +2086,8 @@ int32 RunRegionEval(const FString& Params) { FRegionEvalPass Pass; const bool bOk = RunRegionEvalPass( - Arguments, Seeds[0], false, 1.0f, true, nullptr, - RequestedSamples, Trial == 0, true, Pass, Error); + Arguments, Seeds[0], false, false, 1.0f, true, nullptr, + RequestedSamples, Trial == 0 && !Arguments.bSampleStacks, true, Pass, Error); if (!bOk) { UE_LOG(LogTemp, Error, TEXT("[VoxelForgeRegionEval] live full-ring trial failed: %s"), *Error); @@ -1964,6 +2112,22 @@ int32 RunRegionEval(const FString& Params) } OldTimingReports.Add(MakeShared(RegionEvalTimingJson(Pass, false))); } + if (Arguments.bSampleStacks) + { + FVoxelStackSampler::FSummary StackSummary; + if (!StackSamplerSession.Stop(StackSummary)) + { + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeRegionEval] stack capture retained no live-ring worker samples.")); + return 2; + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeRegionEval] live-ring stack capture registered=%llu retained=%llu " + "raw=%s summary=%s"), + static_cast(StackSummary.RegisteredThreadSamples), + static_cast(StackSummary.RetainedSamples), + *StackSummary.RawSamplesPath, *StackSummary.SummaryPath); + } if (PrimarySampleIndices.IsEmpty()) { UE_LOG(LogTemp, Error, TEXT("[VoxelForgeRegionEval] primary statistical sample set is empty.")); @@ -1974,7 +2138,7 @@ int32 RunRegionEval(const FString& Params) { FRegionEvalPass Pass; const bool bOk = RunRegionEvalPass( - Arguments, Seeds[0], true, 1.0f, true, nullptr, + Arguments, Seeds[0], true, true, 1.0f, true, nullptr, RequestedSamples, Trial == 0, true, Pass, Error); if (!bOk) { @@ -2003,11 +2167,46 @@ int32 RunRegionEval(const FString& Params) // Statistical fixed-lattice comparisons across four additional independent seeds. The sample // set is a deterministic affine permutation of the entire 259^3 ring plus dense strata, // passage-floor, landing, carve, and fill probes. + if (Arguments.bSampleStacks) + { + FRegionEvalStackSamplerSession BrickStackSamplerSession; + if (!BrickStackSamplerSession.Start( + Arguments.SampleStacksIntervalUs, Arguments.OutDirectory, + TEXT("region_eval_brick_ring"))) + { + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeRegionEval] brick stack sampler could not be started.")); + return 2; + } + FRegionEvalPass ProfiledBrickPass; + if (!RunRegionEvalPass( + Arguments, Seeds[0], true, true, 1.0f, true, nullptr, + RequestedSamples, false, false, ProfiledBrickPass, Error)) + { + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeRegionEval] brick attribution run failed: %s"), *Error); + return 2; + } + FVoxelStackSampler::FSummary BrickStackSummary; + if (!BrickStackSamplerSession.Stop(BrickStackSummary)) + { + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeRegionEval] brick stack capture retained no worker samples.")); + return 2; + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeRegionEval] brick stack capture registered=%llu retained=%llu " + "raw=%s summary=%s"), + static_cast(BrickStackSummary.RegisteredThreadSamples), + static_cast(BrickStackSummary.RetainedSamples), + *BrickStackSummary.RawSamplesPath, *BrickStackSummary.SummaryPath); + } + for (int32 SeedIndex = 1; SeedIndex < UE_ARRAY_COUNT(Seeds); ++SeedIndex) { FRegionEvalPass OldPass; if (!RunRegionEvalPass( - Arguments, Seeds[SeedIndex], false, 1.0f, false, nullptr, + Arguments, Seeds[SeedIndex], false, false, 1.0f, false, nullptr, RequestedSamples, true, true, OldPass, Error)) { UE_LOG(LogTemp, Error, TEXT("[VoxelForgeRegionEval] seed %d live sample failed: %s"), @@ -2017,7 +2216,7 @@ int32 RunRegionEval(const FString& Params) CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS); FRegionEvalPass NewPass; if (!RunRegionEvalPass( - Arguments, Seeds[SeedIndex], true, 1.0f, false, &OldPass.SampleIndices, + Arguments, Seeds[SeedIndex], true, true, 1.0f, false, &OldPass.SampleIndices, RequestedSamples, true, true, NewPass, Error)) { UE_LOG(LogTemp, Error, TEXT("[VoxelForgeRegionEval] seed %d prepared sample failed: %s"), @@ -2051,7 +2250,7 @@ int32 RunRegionEval(const FString& Params) // commandlet pass and every worker resets it to 1.0 afterward. FRegionEvalPass ShrunkPass; if (!RunRegionEvalPass( - Arguments, Seeds[0], true, 0.50f, false, &PrimarySampleIndices, + Arguments, Seeds[0], true, true, 0.50f, false, &PrimarySampleIndices, RequestedSamples, false, true, ShrunkPass, Error)) { UE_LOG(LogTemp, Error, TEXT("[VoxelForgeRegionEval] deliberate reach-shrink run failed: %s"), *Error); @@ -2110,7 +2309,7 @@ int32 RunRegionEval(const FString& Params) TSharedRef Report = MakeShared(); Report->SetNumberField(TEXT("schema_version"), 1); Report->SetStringField(TEXT("route"), TEXT("editor-only explore commandlet; required Unreal composer, manager, and generator types")); - Report->SetStringField(TEXT("evaluator"), TEXT("live GetDensityAt final field with editor-only 8^3 SDF room/join/tunnel candidate bins")); + Report->SetStringField(TEXT("evaluator"), TEXT("editor-only 8^3 region brick program; block-evaluated composer operator core and canonical per-point final-field writers; unsupported routes use counted live fallbacks")); Report->SetStringField(TEXT("comparison_quantization"), TEXT("signed int16; round ties away from zero; multiply density by 16; saturate outside [-2048, 2047.9375] density voxels")); Report->SetNumberField(TEXT("worker_count"), VoxelForgeRegionEval::WorkerCount); Report->SetNumberField(TEXT("ring_dimension"), VoxelForgeRegionEval::RingDimension); diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.cpp index c5ef28f..d02634c 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.cpp @@ -5,6 +5,7 @@ #include "VoxelCaveMorphology.h" #include "VoxelDensityProfile.h" #include "VoxelGenerator.h" +#include "VoxelStackSampler.h" #include "HAL/PlatformTLS.h" #if PLATFORM_WINDOWS @@ -73,6 +74,24 @@ namespace VoxelForgeRegionEval { return static_cast(Dimension) * Dimension * Dimension; } + + struct FRegionBrickSample + { + int32 LocalIndex = 0; + int32 OutputIndex = 0; + }; + + struct FRegionBrick + { + FIntVector Origin = FIntVector::ZeroValue; + int32 SizeX = 0; + int32 SizeY = 0; + int32 SizeZ = 0; + int32 LatticeX = 0; + int32 LatticeY = 0; + int32 LatticeZ = 0; + TArray RequestedSamples; + }; } int64 LinearIndex(int32 X, int32 Y, int32 Z, int32 Dimension) @@ -128,9 +147,13 @@ namespace VoxelForgeRegionEval bool bPrepared, float ReachScale, TArray* OutDensities, - FTiming& OutTiming) + FTiming& OutTiming, + bool bBrickProgram, + int32 RequestedWorkerCount) { OutTiming = FTiming(); + const int32 NumWorkers = FMath::Clamp(RequestedWorkerCount, 1, 32); + OutTiming.Workers = NumWorkers; if (Dimension <= 0 || Dimension > 4096 || (bFullRing && SampleIndices != nullptr) || (!bFullRing && SampleIndices == nullptr)) @@ -145,6 +168,94 @@ namespace VoxelForgeRegionEval return false; } const double StartSeconds = FPlatformTime::Seconds(); + TArray Bricks; + TArray WorkBricks; + if (bBrickProgram) + { + const int32 LastX = OriginVoxels.X + Dimension - 1; + const int32 LastY = OriginVoxels.Y + Dimension - 1; + const int32 LastZ = OriginVoxels.Z + Dimension - 1; + const int32 FirstCellX = FMath::FloorToInt(static_cast(OriginVoxels.X) / 8.0f); + const int32 FirstCellY = FMath::FloorToInt(static_cast(OriginVoxels.Y) / 8.0f); + const int32 FirstCellZ = FMath::FloorToInt(static_cast(OriginVoxels.Z) / 8.0f); + const int32 LastCellX = FMath::FloorToInt(static_cast(LastX) / 8.0f); + const int32 LastCellY = FMath::FloorToInt(static_cast(LastY) / 8.0f); + const int32 LastCellZ = FMath::FloorToInt(static_cast(LastZ) / 8.0f); + const int32 CellsX = LastCellX - FirstCellX + 1; + const int32 CellsY = LastCellY - FirstCellY + 1; + const int32 CellsZ = LastCellZ - FirstCellZ + 1; + Bricks.Reserve(CellsX * CellsY * CellsZ); + for (int32 CellZ = FirstCellZ; CellZ <= LastCellZ; ++CellZ) + for (int32 CellY = FirstCellY; CellY <= LastCellY; ++CellY) + for (int32 CellX = FirstCellX; CellX <= LastCellX; ++CellX) + { + FRegionBrick& Brick = Bricks.AddDefaulted_GetRef(); + const int32 CellMinX = CellX * 8; + const int32 CellMinY = CellY * 8; + const int32 CellMinZ = CellZ * 8; + Brick.Origin = FIntVector( + FMath::Max(CellMinX, OriginVoxels.X), + FMath::Max(CellMinY, OriginVoxels.Y), + FMath::Max(CellMinZ, OriginVoxels.Z)); + const int32 EndX = FMath::Min(CellMinX + 8, LastX + 1); + const int32 EndY = FMath::Min(CellMinY + 8, LastY + 1); + const int32 EndZ = FMath::Min(CellMinZ + 8, LastZ + 1); + Brick.SizeX = EndX - Brick.Origin.X; + Brick.SizeY = EndY - Brick.Origin.Y; + Brick.SizeZ = EndZ - Brick.Origin.Z; + Brick.LatticeX = Brick.Origin.X - OriginVoxels.X; + Brick.LatticeY = Brick.Origin.Y - OriginVoxels.Y; + Brick.LatticeZ = Brick.Origin.Z - OriginVoxels.Z; + } + + if (bFullRing) + { + WorkBricks.Reserve(Bricks.Num()); + for (int32 BrickIndex = 0; BrickIndex < Bricks.Num(); ++BrickIndex) + { + WorkBricks.Add(BrickIndex); + } + } + else + { + WorkBricks.Reserve(Bricks.Num()); + for (int32 OutputIndex = 0; OutputIndex < SampleIndices->Num(); ++OutputIndex) + { + const int64 LatticeIndex = (*SampleIndices)[OutputIndex]; + if (LatticeIndex < 0 || LatticeIndex >= TotalLatticeSamples) + { + return false; + } + const int32 X = static_cast(LatticeIndex % Dimension); + const int32 Y = static_cast((LatticeIndex / Dimension) % Dimension); + const int32 Z = static_cast(LatticeIndex + / (static_cast(Dimension) * Dimension)); + const int32 WorldX = OriginVoxels.X + X; + const int32 WorldY = OriginVoxels.Y + Y; + const int32 WorldZ = OriginVoxels.Z + Z; + const int32 CellX = FMath::FloorToInt(static_cast(WorldX) / 8.0f); + const int32 CellY = FMath::FloorToInt(static_cast(WorldY) / 8.0f); + const int32 CellZ = FMath::FloorToInt(static_cast(WorldZ) / 8.0f); + const int32 BrickIndex = ((CellZ - FirstCellZ) * CellsY + + (CellY - FirstCellY)) * CellsX + (CellX - FirstCellX); + FRegionBrick& Brick = Bricks[BrickIndex]; + const int32 LocalX = WorldX - Brick.Origin.X; + const int32 LocalY = WorldY - Brick.Origin.Y; + const int32 LocalZ = WorldZ - Brick.Origin.Z; + FRegionBrickSample& BrickSample = Brick.RequestedSamples.AddDefaulted_GetRef(); + BrickSample.LocalIndex = + (LocalZ * Brick.SizeY + LocalY) * Brick.SizeX + LocalX; + BrickSample.OutputIndex = OutputIndex; + } + for (int32 BrickIndex = 0; BrickIndex < Bricks.Num(); ++BrickIndex) + { + if (!Bricks[BrickIndex].RequestedSamples.IsEmpty()) + { + WorkBricks.Add(BrickIndex); + } + } + } + } if (OutDensities != nullptr) { if (SampleCount > MAX_int32) @@ -157,24 +268,78 @@ namespace VoxelForgeRegionEval const int32 OriginX = OriginVoxels.X; const int32 OriginY = OriginVoxels.Y; const int32 OriginZ = OriginVoxels.Z; - double WorkerSeconds[WorkerCount] = {}; - double WorkerCpuSeconds[WorkerCount] = {}; - bool WorkerCpuFromOsThreadTimes[WorkerCount] = {}; + TArray WorkerSeconds; + TArray WorkerCpuSeconds; + TArray WorkerCpuFromOsThreadTimes; + TArray WorkerThreadIds; + TArray WorkerNonFinite; + TArray WorkerEvaluatedSamples; + TArray WorkerFallbackSamples; + TArray WorkerEvaluationFailures; + WorkerSeconds.SetNumZeroed(NumWorkers); + WorkerCpuSeconds.SetNumZeroed(NumWorkers); + WorkerCpuFromOsThreadTimes.SetNumZeroed(NumWorkers); + WorkerThreadIds.SetNumZeroed(NumWorkers); + WorkerNonFinite.SetNumZeroed(NumWorkers); + WorkerEvaluatedSamples.SetNumZeroed(NumWorkers); + WorkerFallbackSamples.SetNumZeroed(NumWorkers); + WorkerEvaluationFailures.SetNumZeroed(NumWorkers); bool bAllWorkerCpuFromOsThreadTimes = true; - uint32 WorkerThreadIds[WorkerCount] = {}; - uint64 WorkerNonFinite[WorkerCount] = {}; - ParallelFor(WorkerCount, [&](int32 WorkerIndex) + ParallelFor(NumWorkers, [&](int32 WorkerIndex) { + // Region-eval workers are not launched through AVoxelWorld::LoadTile, so they do not + // inherit its task-level sampler registration. Keep this registration scoped to the + // research worker partition; with sampling disabled it is one relaxed flag load. + FScopedVoxelStackRegistration StackRegistration; VoxelCaveMorphology::SetResearchBrickBinningEnabled(bPrepared); VoxelCaveMorphology::SetResearchBrickRoomReachScale(ReachScale); WorkerThreadIds[WorkerIndex] = FPlatformTLS::GetCurrentThreadId(); - const int64 Begin = (SampleCount * WorkerIndex) / WorkerCount; - const int64 End = (SampleCount * (WorkerIndex + 1)) / WorkerCount; + const int64 WorkCount = bBrickProgram ? WorkBricks.Num() : SampleCount; + const int64 Begin = (WorkCount * WorkerIndex) / NumWorkers; + const int64 End = (WorkCount * (WorkerIndex + 1)) / NumWorkers; const double WorkerStart = FPlatformTime::Seconds(); double WorkerCpuStart = 0.0; const bool bHaveWorkerCpuStart = GetCurrentThreadCpuSeconds(WorkerCpuStart); + TArray BrickDensities; for (int64 OutputIndex = Begin; OutputIndex < End; ++OutputIndex) { + if (bBrickProgram) + { + const FRegionBrick& Brick = Bricks[WorkBricks[static_cast(OutputIndex)]]; + int32 BrickFallbackSamples = 0; + if (!Generator.EvaluateDensityBrick( + Brick.Origin, Brick.SizeX, Brick.SizeY, Brick.SizeZ, + BrickDensities, BrickFallbackSamples) + || BrickDensities.Num() != Brick.SizeX * Brick.SizeY * Brick.SizeZ) + { + ++WorkerEvaluationFailures[WorkerIndex]; + continue; + } + const int32 BrickSampleCount = BrickDensities.Num(); + WorkerEvaluatedSamples[WorkerIndex] += static_cast(BrickSampleCount); + WorkerFallbackSamples[WorkerIndex] += static_cast(BrickFallbackSamples); + if (bFullRing && OutDensities != nullptr) + { + for (int32 Bz = 0; Bz < Brick.SizeZ; ++Bz) + for (int32 By = 0; By < Brick.SizeY; ++By) + for (int32 Bx = 0; Bx < Brick.SizeX; ++Bx) + { + const int32 LocalIndex = (Bz * Brick.SizeY + By) * Brick.SizeX + Bx; + const int64 LatticeIndex = LinearIndex( + Brick.LatticeX + Bx, Brick.LatticeY + By, + Brick.LatticeZ + Bz, Dimension); + (*OutDensities)[static_cast(LatticeIndex)] = BrickDensities[LocalIndex]; + } + } + else if (OutDensities != nullptr) + { + for (const FRegionBrickSample& Sample : Brick.RequestedSamples) + { + (*OutDensities)[Sample.OutputIndex] = BrickDensities[Sample.LocalIndex]; + } + } + continue; + } const int64 LatticeIndex = bFullRing ? OutputIndex : (*SampleIndices)[static_cast(OutputIndex)]; if (LatticeIndex < 0 || LatticeIndex >= TotalLatticeSamples) @@ -212,11 +377,20 @@ namespace VoxelForgeRegionEval OutTiming.WallSeconds = FPlatformTime::Seconds() - StartSeconds; OutTiming.SampleCount = SampleCount; - for (int32 WorkerIndex = 0; WorkerIndex < WorkerCount; ++WorkerIndex) + OutTiming.bBrickProgram = bBrickProgram; + OutTiming.BrickCount = bBrickProgram ? WorkBricks.Num() : 0; + for (int32 WorkerIndex = 0; WorkerIndex < NumWorkers; ++WorkerIndex) { OutTiming.WorkerCpuSeconds += WorkerCpuSeconds[WorkerIndex]; - bAllWorkerCpuFromOsThreadTimes &= WorkerCpuFromOsThreadTimes[WorkerIndex]; + bAllWorkerCpuFromOsThreadTimes &= WorkerCpuFromOsThreadTimes[WorkerIndex] != 0; OutTiming.NonFiniteSamples += WorkerNonFinite[WorkerIndex]; + OutTiming.EvaluatedSamples += bBrickProgram + ? static_cast(WorkerEvaluatedSamples[WorkerIndex]) : 0; + OutTiming.FallbackSamples += static_cast(WorkerFallbackSamples[WorkerIndex]); + if (WorkerEvaluationFailures[WorkerIndex] != 0) + { + return false; + } bool bAlreadyCounted = false; for (int32 PriorWorker = 0; PriorWorker < WorkerIndex; ++PriorWorker) { @@ -225,6 +399,11 @@ namespace VoxelForgeRegionEval OutTiming.ObservedWorkerThreads += bAlreadyCounted ? 0 : 1; } OutTiming.bWorkerCpuFromOsThreadTimes = bAllWorkerCpuFromOsThreadTimes; + if (OutTiming.EvaluatedSamples > 0) + { + OutTiming.FallbackShare = static_cast(OutTiming.FallbackSamples) + / static_cast(OutTiming.EvaluatedSamples); + } OutTiming.NanosecondsPerSample = OutTiming.SampleCount > 0 ? OutTiming.WorkerCpuSeconds * 1.0e9 / static_cast(OutTiming.SampleCount) : 0.0; diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.h b/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.h index 6c714e6..6fd5cab 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.h +++ b/Source/VoxelForgeEditor/Private/VoxelForgeRegionEvalExperiment.h @@ -17,10 +17,15 @@ namespace VoxelForgeRegionEval int32 Workers = WorkerCount; int32 ObservedWorkerThreads = 0; int64 SampleCount = 0; + int64 EvaluatedSamples = 0; + int64 BrickCount = 0; + int64 FallbackSamples = 0; + double FallbackShare = 0.0; double WallSeconds = 0.0; double WorkerCpuSeconds = 0.0; double NanosecondsPerSample = 0.0; bool bWorkerCpuFromOsThreadTimes = false; + bool bBrickProgram = false; uint64 QuantizedHash = 0; uint64 RawFloatHash = 0; uint64 SaturatedSamples = 0; @@ -67,7 +72,9 @@ namespace VoxelForgeRegionEval bool bPrepared, float ReachScale, TArray* OutDensities, - FTiming& OutTiming); + FTiming& OutTiming, + bool bBrickProgram = false, + int32 RequestedWorkerCount = WorkerCount); /** Count exact room/tunnel/core/passage candidate visits with instrumentation enabled. */ FCandidateVisits CountCandidates( diff --git a/Tools/VoxelForgeTest.ps1 b/Tools/VoxelForgeTest.ps1 index aae7449..29d6c21 100644 --- a/Tools/VoxelForgeTest.ps1 +++ b/Tools/VoxelForgeTest.ps1 @@ -4,6 +4,13 @@ param( [string]$Scenario = 'canonical', [switch]$Build, [switch]$EnableRender, + [switch]$SampleDensityStacks, + [ValidateRange(1, 1000000)] + [int]$StackSampleIntervalUs = 1000, + [switch]$RegionHashProbe, + [ValidateRange(1, 32)] + [int]$RegionWorkers = 6, + [string]$RegionReferenceHash = '', [ValidateSet('owner', 'default')] [string]$Assets = 'owner', [hashtable]$Cvars = @{}, @@ -1430,6 +1437,17 @@ try { $args = New-CommonArguments 'commandlet' $regionLog (Merge-Cvars $baseCvars) $args += New-CommandletArguments $commandletOut 'walk,export' ($Assets -eq 'owner') $false 128 $args += @('-regioneval', '-regionsamples=262144') + if ($SampleDensityStacks) { $args += "-samplestacks=$StackSampleIntervalUs" } + if ($RegionHashProbe) { + if ([string]::IsNullOrWhiteSpace($RegionReferenceHash)) { + throw '-RegionHashProbe requires -RegionReferenceHash from a reference full-ring run.' + } + $args += @( + '-regionhashprobe', + "-regionworkers=$RegionWorkers", + "-regionreferencehash=$RegionReferenceHash" + ) + } $commandletLedger = Invoke-UnrealLaunch 'region-eval-commandlet' $args ` $regionLog (Join-Path $RunRoot 'Logs\region-eval.stdout.log') ` (Join-Path $RunRoot 'Logs\region-eval.stderr.log')