diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5ebaa3e..dac2cc7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -981,20 +981,23 @@ generic biome field), not just SurfaceWorld. Empty `Biomes[]` ⇒ all-zero colou - **Status:** C++ ✅ BUILT & WORKING (ticked 2026-07-27). The master material graph is still editor-side work and is deliberately NOT ticked — that half is Jahni's, not the code's. -### 8.16 Explorer commandlet — one authoritative field, three consumers +### 8.16 Explorer commandlet — one authoritative field, one sampled grid, three consumers `-run=VoxelForgeExplore` lives in the separate `VoxelForgeEditor` module, so the commandlet and its PNG/JSON/OBJ writers are editor/commandlet-only and cannot enter a Game or Shipping link. It creates only transient settings, definitions, manager, generator, diff layer, and mesher objects; no authored asset, live manager, generator, or diff layer is mutated. The transient world keeps `WorldRadiusVoxels=0` and `InterStrateGapChunks=0`, preserving the lateral-region gate. -- **Render.** The renderer samples `UVoxelGenerator::GetDensityAt` at a fixed 0.25-voxel default - step (overrideable down to 0.125), detects the MC density sign crossing, and refines it with ten - bisection iterations. It never treats density as an SDF and never sphere-traces. Each of three - deterministic views overlays a projected 1.76 m human-height marker; the JSON records the step, - metre conversion, refinement count, and marker projection status. +- **Render.** Render obtains the player-fit seed/mask from the shared measurement pass, then calls + the canonical `UVoxelMarchingCubesMesher` once per 32³ tile for the bounded region. The aggregate + mesh is merged in fixed Z/Y/X tile order, indexed into a deterministic CPU BVH, and rasterised + from eight player-fit viewpoints with a depth buffer and fixed Lambert lights. The per-pixel path + never calls `GetDensityAt`; `-renderstep` and the old bisection fields remain only as compatibility + metadata. Each view overlays a projected 1.76 m human-height marker and the JSON records mesh, + acceleration, per-view raster timings, and marker projection status. - **Walk.** `VF_MeasurePlayerFitWalkWithSampler` builds the existing step-1 capsule/floor-fit stencil - and uses the same six-neighbour/full-resolution route gate as the existing diagnostics. Its + and captures that exact sampled grid plus player-fit mask for the run. It uses the same + six-neighbour/full-resolution route gate as the existing diagnostics. Its deterministic depth-first agent walk counts metres travelled (including branch backtracking), dead ends per 100 m, the arrival component's reachable fit volume, and traversed edges touching a narrow-gap proxy below 1.5× the 0.68 m capsule width. The proxy is explicitly axis-aligned air span; @@ -1002,19 +1005,26 @@ asset, live manager, generator, or diff layer is mutated. The transient world ke `MaxCells` refusal at a 32,000,000-cell default cap, with a conservative 20-byte/cell estimate against a documented 768 MiB working-memory budget. Reachable fit volume is explicitly the bounded fitted route window because `WorldRadiusVoxels=0` deliberately has no finite whole-world volume. -- **Export.** `UVoxelMarchingCubesMesher::GenerateMesh` is the only mesher. Because that canonical - entry point deliberately clamps one call to a 32³ tile, a 128³ request is four-by-four-by-four - canonical calls whose returned vertices/triangles are streamed with an index offset; no second - mesher or density evaluator exists. The bounded result is metre-space OBJ with source winding, - voxel-space UVs, normals, and ground/ceiling material groups, accompanied by a manifest containing seed, - archetype, slot, bounds, and player dimensions. Tile skirts are disabled at the export boundary - because they are a render seam aid that would extend beyond the manifest box. A conservative - preflight cap is checked before the canonical mesher is called. +- **Export.** `UVoxelMarchingCubesMesher::GenerateMesh` is still the only mesher. Because that + canonical entry point deliberately clamps one call to a 32³ tile, a 128³ request is four-by-four- + by-four canonical calls. Their returned vertices/triangles are merged once and the same aggregate + is consumed by both OBJ export and render; no second mesher or density evaluator exists. Export + reuses the walk's grid hand-off when a walk/render mode is present. The bounded result is metre-space + OBJ with source winding, voxel-space UVs, normals, and a manifest containing seed, archetype, slot, + bounds, and player dimensions. Tile skirts are disabled at the export boundary because they are a + render seam aid that would extend beyond the manifest box. A conservative preflight cap is checked + before the canonical mesher is called. + +- **Budget.** The default wall-clock budget is 25 minutes; `-budget=` may lower or raise it up to a + hard 30-minute maximum. Setup, sampling, meshing, acceleration, rasterisation, and export check + the budget and report `truncated`, `truncated_during`, and `completed_modes` while retaining any + completed artifacts. The report and export manifest use fixed-order writers and serialize twice in-process to assert the -deterministic contract. Timings are console diagnostics only; putting wall-clock durations in the report would -break byte identity. The browser viewer remains a separate display-only consumer: it must load this -OBJ/manifest and never evaluate density or generate geometry. +deterministic contract. The fixed tile merge, BVH ordering, and raster traversal are deterministic; +timings are intentionally diagnostic metadata and do not affect world or image content. The browser +viewer remains a separate display-only consumer: it must load this OBJ/manifest and never evaluate +density or generate geometry. ## 9. Multiplayer model (listen-server first, dedicated-friendly) diff --git a/CODEMAP.md b/CODEMAP.md index 34b615d..f901cdb 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -397,20 +397,23 @@ No UObject state, cache, actor, world, or PIE is required. | Symbol | Role | |--------|------| | `FVoxelStrateMeasureSettings` / `FVoxelStrateMetrics` | Plain settings/result structs for bounded strate sampling and derived measurements; callers may override the interior margin (including zero to sample the seal) or fit the XY window to two points with a margin, and results identify the resolved window/dimensions/Z range, deterministic air-component facts, walkable floor-area columns/fraction, projected surface components/share, and median clearance. | -| `FVoxelStrateSampleGrid` | Optional one-pass `density > 0` `Air` polarity plus exact scalar `Density` capture exported by measurement functions; bounded by `MaxCells`, consumed by editor/automation previews, never retained on ordinary metrics calls. | +| `FVoxelStrateSampleGrid` | Optional one-pass `density > 0` `Air` polarity plus exact scalar `Density` capture; player-fit walks may also export the exact `PlayerFitMask` and count. Bounded by `MaxCells`, shared by explorer render/walk/export, and never retained on ordinary metrics calls. | | `VF_MeasureStrate` | One-grid/one-flood-fill strate metrics; resolves either the legacy centered square or the two-point fitted AABB, and refuses invalid bounds or a grid over `MaxCells`. | | `VF_AreConnected` | Coarse 6-connected BFS plus deterministic blocked-edge retries, with a full-resolution air recheck for every candidate route and explicit endpoint-solid, out-of-window, `NotConnectedAtThisResolution`, `CoarseLiedBudgetExhausted` (unknown), and connected outcomes; diagnostics include endpoint component facts and the retry count from that same grid. | | `VF_DiagnoseConnectivity` | The same query plus each mouth's air-component size/share and exact geometric distance to the nearest cell of the other component; proximity is measured in coarse-cell coordinates and does not claim a route through solid. | -| `FVoxelPlayerFitWalkReport` / `VF_MeasurePlayerFitWalkWithSampler` | Editor-only experiential instrument over the existing exact `VF_` capsule/floor-fit mask: deterministic graph-walk distance, dead ends, reachable fit volume, and a labelled narrow-gap proxy; no second movement model. | +| `FVoxelPlayerFitWalkReport` / `VF_MeasurePlayerFitWalkWithSampler` | Editor-only experiential instrument over the existing exact `VF_` capsule/floor-fit mask: deterministic graph-walk distance, dead ends, reachable fit volume, and a labelled narrow-gap proxy; optionally exports the one sampled grid/mask for other explorer consumers; no second movement model. | `VoxelForgeExploreCommandlet` builds a transient, fixed-definition world and calls the production -`UVoxelGenerator::GetDensityAt` and `UVoxelMarchingCubesMesher::GenerateMesh` paths. `render` uses -fixed density samples (default 0.25 voxel) with bisection at sign crossings instead of sphere tracing; -`walk` uses `VF_MeasurePlayerFitWalkWithSampler`; `export` calls the canonical 32³ mesher in a -deterministic tile grid (four tiles per axis for the 128³ default), streams only its returned tile -arrays into metre-space OBJ plus `manifest.json` (canonical UVs remain voxel-space), and never retains the full region mesh. Output JSON/manifest are emitted by fixed-order writers and compared with a second -serialization before it is written. The commandlet keeps `WorldRadiusVoxels=0`, does not touch an -authored asset or diff layer, and refuses bounded-grid/export requests before allocating them. +`UVoxelGenerator::GetDensityAt` and `UVoxelMarchingCubesMesher::GenerateMesh` paths. `walk` (or the +private seed walk used by render-only runs) captures one exact sampled grid and player-fit mask; +render and export reuse that hand-off. `render` calls the canonical 32³ mesher in a deterministic +tile grid (four tiles per axis for the 128³ default), merges the region once, builds a deterministic +CPU BVH, and rasterises eight viewpoints from the mesh without per-pixel density calls. `export` +uses that same canonical aggregate for metre-space OBJ plus `manifest.json` (canonical UVs remain +voxel-space). Output JSON/manifest are emitted by fixed-order writers and compared with a second +serialization before they are written. The commandlet keeps `WorldRadiusVoxels=0`, does not touch an +authored asset or diff layer, and enforces the default 25-minute / maximum 30-minute wall-clock +budget, reporting partial artifacts and completed modes on truncation. ### 3.7 Cave morphology (SDF rooms/tunnels) — `Public/VoxelCaveMorphology.h` + `.cpp` Header is rich with inline docs. Two namespaces + a per-chunk cache system. diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeScaleDiagnosisTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeScaleDiagnosisTest.cpp index f0a5b64..5434119 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeScaleDiagnosisTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeScaleDiagnosisTest.cpp @@ -19,12 +19,16 @@ namespace { constexpr int32 DiagnosisSeed = 0; - constexpr int32 MazeCandidateIndex = 50; - // These are single-region seed-0 identities after the Maze default/range change updates the - // corpus content hash. Keep the probes on real candidates rather than silently testing fixture - // defaults when a historical index moves to another family. - constexpr int32 TunnelCandidateIndex = 4; - constexpr int32 ShaftCandidateIndex = 6; + // These are single-region seed-0 identities in the current corpus asset/default set. Keep the + // probes on real candidates rather than silently testing fixture defaults when a field changes. + constexpr int32 CrystalCandidateIndex = 50; + constexpr int32 FlatCandidateIndex = 28; + constexpr int32 FloatingCandidateIndex = 4; + constexpr int32 MazeCandidateIndex = 9; + constexpr int32 SurfaceCandidateIndex = 11; + constexpr int32 TunnelCandidateIndex = 20; + constexpr int32 UnderwaterCandidateIndex = 6; + constexpr int32 ShaftCandidateIndex = 10; constexpr int32 RadialSampleMaxVoxels = 48; constexpr int32 FineDiagnosisMaxCells = 4000000; @@ -845,6 +849,8 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters) return false; } + // The corpus asset changed after d97373c; keep the current-corpus identity probes explicit + // while the historical H3 rows below remain comparison baselines. FTestWorld World; World.Build(DiagnosisSeed, 2, true, 8); if (!World.IsValid()) @@ -864,11 +870,14 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters) Corpus, DiagnosisSeed, TunnelCandidateIndex, false); const FVoxelStrateComposerCandidate ShaftCandidate = VF_RollStrateCandidate( Corpus, DiagnosisSeed, ShaftCandidateIndex, false); - TestTrue(TEXT("seed 0 index 50 is a valid Maze candidate"), + TestTrue(FString::Printf(TEXT("seed %d index %d is a valid Maze candidate"), + DiagnosisSeed, MazeCandidateIndex), MazeCandidate.bValid && MazeCandidate.Archetype == ECaveGeneratorType::Maze); - TestTrue(TEXT("seed 0 index 61 is a valid TunnelNetwork candidate"), + TestTrue(FString::Printf(TEXT("seed %d index %d is a valid TunnelNetwork candidate"), + DiagnosisSeed, TunnelCandidateIndex), TunnelCandidate.bValid && TunnelCandidate.Archetype == ECaveGeneratorType::TunnelNetwork); - TestTrue(TEXT("seed 0 index 34 is a valid VerticalShafts candidate"), + TestTrue(FString::Printf(TEXT("seed %d index %d is a valid VerticalShafts candidate"), + DiagnosisSeed, ShaftCandidateIndex), ShaftCandidate.bValid && ShaftCandidate.Archetype == ECaveGeneratorType::VerticalShafts); // H1 — direct GetDensityAt radial crossings. The Maze is the fourth one-based layout slot @@ -1151,6 +1160,8 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters) PartABase.InteriorMarginVoxels = -1; FGeneratorSampler GeneratorSampler(*World.Generator); + const FString MazeDiagnosisLabel = FString::Printf( + TEXT("Maze seed=%d index=%d slot=4"), DiagnosisSeed, MazeCandidateIndex); auto RunPartAWindow = [&](const FString& WindowLabel, const FVoxelStrateMeasureSettings& Settings) { @@ -1161,7 +1172,7 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters) VF_BoundarySealForCandidate(MazeCandidate), ArrivalPoint, DeparturePoint, Settings, &Metrics); VF_ReportPlayerFitRow( - *this, TEXT("Maze seed=0 index=50 slot=4"), WindowLabel, + *this, *MazeDiagnosisLabel, WindowLabel, Settings.SampleStep, Metrics, Diagnostics); return TTuple( MoveTemp(Metrics), Diagnostics); @@ -1213,9 +1224,8 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters) } } - // H3 — the eight fixed showcase identities, measured at step 1 on the topology-aware fitted - // window. Origin-rooted families include (0,0) before the MaxCells refusal check. - // The Maze identity is intentionally the requested seed 0 / index 50 / fourth one-based slot. + // H3 — the eight fixed current-corpus identities, measured at step 1 on the topology-aware + // fitted window. Origin-rooted families include (0,0) before the MaxCells refusal check. struct FShowcaseSelection { ECaveGeneratorType Archetype; @@ -1225,14 +1235,14 @@ bool FVoxelForgeScaleDiagnosisTest::RunTest(const FString& Parameters) const TCHAR* Label; }; const FShowcaseSelection Selections[] = { - { ECaveGeneratorType::CrystalChamber, 0, 28, FTestWorld::SlotFlatPlain, TEXT("CrystalChamber") }, - { ECaveGeneratorType::FlatPlain, 0, 37, FTestWorld::SlotFlatPlain, TEXT("FlatPlain") }, - { ECaveGeneratorType::FloatingIslands, 0, 9, FTestWorld::SlotFlatPlain, TEXT("FloatingIslands") }, - { ECaveGeneratorType::Maze, 0, 50, FTestWorld::SlotMaze, TEXT("Maze") }, - { ECaveGeneratorType::SurfaceWorld, 0, 27, FTestWorld::SlotSurfaceWorld, TEXT("SurfaceWorld") }, - { ECaveGeneratorType::TunnelNetwork, 0, 4, FTestWorld::SlotFlatPlain, TEXT("TunnelNetwork") }, - { ECaveGeneratorType::Underwater, 0, 16, FTestWorld::SlotFlatPlain, TEXT("Underwater") }, - { ECaveGeneratorType::VerticalShafts, 0, 6, FTestWorld::SlotFlatPlain, TEXT("VerticalShafts") }, + { ECaveGeneratorType::CrystalChamber, 0, CrystalCandidateIndex, FTestWorld::SlotFlatPlain, TEXT("CrystalChamber") }, + { ECaveGeneratorType::FlatPlain, 0, FlatCandidateIndex, FTestWorld::SlotFlatPlain, TEXT("FlatPlain") }, + { ECaveGeneratorType::FloatingIslands, 0, FloatingCandidateIndex, FTestWorld::SlotFlatPlain, TEXT("FloatingIslands") }, + { ECaveGeneratorType::Maze, 0, MazeCandidateIndex, FTestWorld::SlotMaze, TEXT("Maze") }, + { ECaveGeneratorType::SurfaceWorld, 0, SurfaceCandidateIndex, FTestWorld::SlotSurfaceWorld, TEXT("SurfaceWorld") }, + { ECaveGeneratorType::TunnelNetwork, 0, TunnelCandidateIndex, FTestWorld::SlotFlatPlain, TEXT("TunnelNetwork") }, + { ECaveGeneratorType::Underwater, 0, UnderwaterCandidateIndex,FTestWorld::SlotFlatPlain, TEXT("Underwater") }, + { ECaveGeneratorType::VerticalShafts, 0, ShaftCandidateIndex, FTestWorld::SlotFlatPlain, TEXT("VerticalShafts") }, }; // Baseline rows from the pre-fix fitted-window report. The supplied VerticalShafts row is diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 388f649..a32d546 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -477,6 +477,41 @@ namespace }; static thread_local FTunnelCoreCacheState GTunnelCoreCache; + + // A mesher tile samples an expanded grid (-1..Cells+1), so one worker visits several exact + // chunk coordinates even while it is generating one tile. The old CP_* cache retained only + // the last coordinate and consequently rebuilt the whole TunnelNetwork stack whenever the + // sampling cursor crossed a chunk boundary. Keep a deterministic direct-mapped set of states + // instead: collisions only cause a safe rebuild, never a stale read or an order-dependent + // result. Native TunnelNetwork/Underwater states are immutable after construction; the active + // pointer below lets the hot sample path read the stored stack and tunnel-core cache without + // moving them on every voxel. + constexpr int32 TunnelDensityCacheSlotCount = 128; + + struct FTunnelNetworkDensityCacheEntry + { + bool bValid = false; + uint64 OwnerId = 0; + FIntVector Chunk = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); + uint32 LayoutVersion = 0xFFFFFFFFu; + ECaveGeneratorType GenType = ECaveGeneratorType::TunnelNetwork; + FStrateGenerationParams Tunnel; + uint32 TunnelFingerprint = 0xFFFFFFFFu; + FStrateDisturbanceParams Disturbance; + bool bUseOpStack = false; + FVoxelOpStack OpStack; + FTunnelCoreCacheState TunnelCore; + }; + + FORCEINLINE int32 TunnelDensityCacheSlot(const FIntVector& Chunk) + { + uint32 Hash = static_cast(Chunk.X) * 0x9E3779B9u; + Hash ^= static_cast(Chunk.Y) * 0x85EBCA6Bu; + Hash ^= static_cast(Chunk.Z) * 0xC2B2AE35u; + Hash ^= Hash >> 16; + return static_cast(Hash & (TunnelDensityCacheSlotCount - 1)); + } + void PrepareTunnelCoreCache( const UVoxelStrateManager& Manager, const FIntVector& ChunkCoord, @@ -702,7 +737,9 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co FMath::FloorToInt(WorldZ / CHUNK_SIZE) ); - if (StrateManager && StrateManager->IsGapChunk(ChunkCoord)) + const bool bIsGapChunk = StrateManager && StrateManager->IsGapChunk(ChunkCoord); + + if (StrateManager && bIsGapChunk) { // SOLID BEDROCK gap between two strates. The auto-carved passages still tunnel // through it, but the (0,0) descent stays solid here so the player digs the gap @@ -762,6 +799,8 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // la strate n'a pas coché `bUseOperatorStack` ET que son archétype n'est pas porté. thread_local FVoxelOpStack CP_OpStack; thread_local bool CP_UseOpStack = false; + thread_local FTunnelNetworkDensityCacheEntry CP_TunnelDensityCache[TunnelDensityCacheSlotCount]; + thread_local FTunnelNetworkDensityCacheEntry* CP_ActiveTunnelDensityCache = nullptr; // Cooked-season recipes and editor candidate recipes share this immutable worker-local // hand-off. Recipe materialisation is runtime; rolling/selection remains editor-only. thread_local bool CP_UseCustomRecipe = false; @@ -778,9 +817,50 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co #endif const uint32 LayoutVersion = StrateManager->GetLayoutVersion(); - const bool bOwnerChanged = DensityCacheOwnerId != CP_OwnerId; - if (bOwnerChanged || ChunkCoord != CP_Chunk || LayoutVersion != CP_Version) + FVoxelOpStack* ActiveOpStack = &CP_OpStack; + FTunnelCoreCacheState* ActiveTunnelCoreCache = >unnelCoreCache; + bool bLoadedTunnelDensityCache = false; + + if (!bIsGapChunk) { + FTunnelNetworkDensityCacheEntry& CachedEntry = + CP_TunnelDensityCache[TunnelDensityCacheSlot(ChunkCoord)]; + const bool bCacheKeyMatches = CachedEntry.bValid + && CachedEntry.OwnerId == DensityCacheOwnerId + && CachedEntry.Chunk == ChunkCoord + && CachedEntry.LayoutVersion == LayoutVersion + && (CachedEntry.GenType == ECaveGeneratorType::TunnelNetwork + || CachedEntry.GenType == ECaveGeneratorType::Underwater); + if (bCacheKeyMatches) + { + if (CP_ActiveTunnelDensityCache != &CachedEntry) + { + CP_OwnerId = CachedEntry.OwnerId; + CP_Version = CachedEntry.LayoutVersion; + CP_Chunk = CachedEntry.Chunk; + CP_GenType = CachedEntry.GenType; + CP_Tunnel = CachedEntry.Tunnel; + CP_TunnelFP = CachedEntry.TunnelFingerprint; + CP_Dist = CachedEntry.Disturbance; + CP_UseOpStack = CachedEntry.bUseOpStack; + CP_UseCustomRecipe = false; +#if WITH_EDITOR + CP_UseComposerRegions = false; +#endif + CP_ActiveTunnelDensityCache = &CachedEntry; + } + ActiveOpStack = &CachedEntry.OpStack; + ActiveTunnelCoreCache = &CachedEntry.TunnelCore; + bLoadedTunnelDensityCache = true; + } + } + + const bool bOwnerChanged = DensityCacheOwnerId != CP_OwnerId; + if (!bLoadedTunnelDensityCache + && (bOwnerChanged || ChunkCoord != CP_Chunk || LayoutVersion != CP_Version)) + { + CP_ActiveTunnelDensityCache = nullptr; + // La grille de biome est validée par une BOÎTE XY, qui ne dit rien du FBiomeContext // ayant servi à classer ses cellules : sur un changement de version elle est périmée // même si la boîte couvre encore la requête. Même invalidation quand le propriétaire @@ -976,6 +1056,43 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co } } } + + const bool bNativeTunnelCacheCandidate = !CP_UseCustomRecipe + && (CP_GenType == ECaveGeneratorType::TunnelNetwork + || CP_GenType == ECaveGeneratorType::Underwater); +#if WITH_EDITOR + const bool bCanStoreTunnelDensityCache = bNativeTunnelCacheCandidate + && !CP_UseComposerRegions; +#else + const bool bCanStoreTunnelDensityCache = bNativeTunnelCacheCandidate; +#endif + if (!bLoadedTunnelDensityCache && bCanStoreTunnelDensityCache) + { + FTunnelNetworkDensityCacheEntry& CachedEntry = + CP_TunnelDensityCache[TunnelDensityCacheSlot(ChunkCoord)]; + CachedEntry.bValid = false; + CachedEntry.OwnerId = DensityCacheOwnerId; + CachedEntry.Chunk = ChunkCoord; + CachedEntry.LayoutVersion = LayoutVersion; + CachedEntry.GenType = CP_GenType; + CachedEntry.Tunnel = CP_Tunnel; + CachedEntry.TunnelFingerprint = CP_TunnelFP; + CachedEntry.Disturbance = CP_Dist; + CachedEntry.bUseOpStack = CP_UseOpStack; + CachedEntry.OpStack = MoveTemp(CP_OpStack); + CachedEntry.TunnelCore = MoveTemp(GTunnelCoreCache); + GTunnelCoreCache = FTunnelCoreCacheState(); + CachedEntry.bValid = true; + CP_ActiveTunnelDensityCache = &CachedEntry; + ActiveOpStack = &CachedEntry.OpStack; + ActiveTunnelCoreCache = &CachedEntry.TunnelCore; + } + else if (!bLoadedTunnelDensityCache) + { + ActiveOpStack = &CP_OpStack; + ActiveTunnelCoreCache = >unnelCoreCache; + } + } // Le seul point d'entrée de la pile dans le chemin de production. Elle rend la convention @@ -983,7 +1100,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // de diff qui suivent ne voient aucune différence. if (CP_UseOpStack) { - Result = CP_OpStack.EvalMC(WorldX, WorldY, WorldZ); + Result = ActiveOpStack->EvalMC(WorldX, WorldY, WorldZ); } else switch (CP_GenType) { @@ -1153,11 +1270,11 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // bridge over a graph tunnel. Reassert the native cached tunnel core here, after every // solid floor writer but before the global XY seal. This cache is built once per chunk, // never once per voxel. - if (GTunnelCoreCache.bValid) + if (ActiveTunnelCoreCache->bValid) { const bool bTunnelSupportFloor = VoxelCaveMorphology::IsTunnelSupportFloorWorldPoint( - WorldX, WorldY, WorldZ, GTunnelCoreCache.Cache); + WorldX, WorldY, WorldZ, ActiveTunnelCoreCache->Cache); if (bTunnelSupportFloor) { Result = FMath::Min( @@ -1165,7 +1282,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co -FMath::Max(LandingBaseDensity * 2.0f, 1.0f)); } const float CoreSDF = VoxelCaveMorphology::EvaluateTunnelCoreWorldSDF( - WorldX, WorldY, WorldZ, GTunnelCoreCache.Cache); + WorldX, WorldY, WorldZ, ActiveTunnelCoreCache->Cache); if (!bTunnelSupportFloor && CoreSDF < -VoxelPassageGeometry::CaveTunnelAirCoreInsetVoxels) { diff --git a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp index e914f62..4be0ab3 100644 --- a/Source/VoxelForge/Private/VoxelStrateMeasure.cpp +++ b/Source/VoxelForge/Private/VoxelStrateMeasure.cpp @@ -455,6 +455,8 @@ namespace VoxelStrateMeasurePrivate // only when it explicitly requested a capture. Destination.Air = MoveTemp(Source.Air); Destination.Density = MoveTemp(Source.Density); + Destination.PlayerFitMask.Reset(); + Destination.PlayerFitCellCount = 0; } void DecodeIndex(const FSampleGrid& Grid, int32 Index, int32& OutX, int32& OutY, int32& OutZ) @@ -1374,6 +1376,37 @@ namespace VoxelStrateMeasurePrivate ? 0.0f : static_cast(MinimumDistance); } + int64 CountAirCells( + const FSampleGrid& Grid, + const TArray* EligibilityMask = nullptr) + { + TArray AirCountsByZ; + AirCountsByZ.Init(0, Grid.NumZ); + ParallelFor(Grid.NumZ, [&](int32 Z) + { + int64 Count = 0; + for (int32 Y = 0; Y < Grid.NumY; ++Y) + { + for (int32 X = 0; X < Grid.NumX; ++X) + { + const int32 Cell = Grid.Index(X, Y, Z); + Count += Grid.Air[Cell] != 0u + && (EligibilityMask == nullptr || (*EligibilityMask)[Cell] != 0u) + ? 1 + : 0; + } + } + AirCountsByZ[Z] = Count; + }); + + int64 Total = 0; + for (int32 Z = 0; Z < Grid.NumZ; ++Z) + { + Total += AirCountsByZ[Z]; + } + return Total; + } + void BuildPlayerFitMetrics( const FSampleGrid& Grid, const FVoxelStrateMeasureSettings& Settings, @@ -1453,10 +1486,7 @@ namespace VoxelStrateMeasurePrivate OutMetrics.SampledMinY = Grid.MinY; OutMetrics.SampledMaxY = Grid.MaxY; OutMetrics.NumSampled = Grid.CellCount; - for (const uint8 bAir : Grid.Air) - { - OutMetrics.NumAir += bAir != 0u ? 1 : 0; - } + OutMetrics.NumAir = CountAirCells(Grid); OutMetrics.NumSolid = static_cast(Grid.CellCount) - OutMetrics.NumAir; OutMetrics.AirFraction = OutMetrics.NumSampled > 0 ? static_cast(static_cast(OutMetrics.NumAir) @@ -1487,11 +1517,7 @@ namespace VoxelStrateMeasurePrivate TArray Components; int32 NumComponents = 0; - int64 NumAir = 0; - for (const uint8 bAir : Grid.Air) - { - NumAir += bAir != 0u ? 1 : 0; - } + const int64 NumAir = CountAirCells(Grid); int64 LargestComponentCells = 0; int32 LargestComponentLowestCell = INDEX_NONE; @@ -1537,8 +1563,21 @@ namespace VoxelStrateMeasurePrivate TArray WalkableColumns; WalkableColumns.Init(0u, static_cast(XYColumnCount64)); - for (int32 Z = 0; Z < Grid.NumZ; ++Z) + // Candidate poses read only the immutable sampled field. Keep every worker's clearance + // values and compact column flags private, then merge by fixed Z order so the median and + // projected surface graph remain deterministic. + TArray WalkableCountsByZ; + WalkableCountsByZ.Init(0, Grid.NumZ); + TArray> ClearanceValuesByZ; + ClearanceValuesByZ.SetNum(Grid.NumZ); + TArray> WalkableColumnsByZ; + WalkableColumnsByZ.SetNum(Grid.NumZ); + ParallelFor(Grid.NumZ, [&](int32 Z) { + TArray& SliceClearanceValues = ClearanceValuesByZ[Z]; + TArray& SliceWalkableColumns = WalkableColumnsByZ[Z]; + SliceWalkableColumns.Init(0u, static_cast(XYColumnCount64)); + int64 SliceWalkableCount = 0; for (int32 Y = 0; Y < Grid.NumY; ++Y) { for (int32 X = 0; X < Grid.NumX; ++X) @@ -1563,8 +1602,8 @@ namespace VoxelStrateMeasurePrivate } if (!bHasHeadroom) continue; - ++NumWalkable; - WalkableColumns[X + Grid.NumX * Y] = 1u; + ++SliceWalkableCount; + SliceWalkableColumns[X + Grid.NumX * Y] = 1u; int32 ClearanceCells = 0; while (static_cast(Z) + ClearanceCells < Grid.NumZ && Grid.Air[Grid.Index(X, Y, Z + ClearanceCells)] != 0u) @@ -1573,10 +1612,25 @@ namespace VoxelStrateMeasurePrivate } const int64 ClearanceVoxels = static_cast(ClearanceCells) * Settings.SampleStep; - ClearanceValues.Add(ClearanceVoxels > INT32_MAX + SliceClearanceValues.Add(ClearanceVoxels > INT32_MAX ? INT32_MAX : static_cast(ClearanceVoxels)); } } + WalkableCountsByZ[Z] = SliceWalkableCount; + }); + + for (int32 Z = 0; Z < Grid.NumZ; ++Z) + { + NumWalkable += WalkableCountsByZ[Z]; + const TArray& SliceWalkableColumns = WalkableColumnsByZ[Z]; + for (int32 Column = 0; Column < SliceWalkableColumns.Num(); ++Column) + { + if (SliceWalkableColumns[Column] != 0u) + { + WalkableColumns[Column] = 1u; + } + } + ClearanceValues.Append(ClearanceValuesByZ[Z]); } InOutMetrics.WalkableFraction = NumAir > 0 @@ -1654,15 +1708,29 @@ namespace VoxelStrateMeasurePrivate TArray Distances; BuildManhattanDistanceToSolid(Grid, Distances); + TArray> FeatureScalesByZ; + FeatureScalesByZ.SetNum(Grid.NumZ); + ParallelFor(Grid.NumZ, [&](int32 Z) + { + TArray& SliceFeatureScales = FeatureScalesByZ[Z]; + for (int32 Y = 0; Y < Grid.NumY; ++Y) + { + for (int32 X = 0; X < Grid.NumX; ++X) + { + const int32 Index = Grid.Index(X, Y, Z); + if (Grid.Air[Index] != 0u) + { + SliceFeatureScales.Add(static_cast(Distances[Index]) + * static_cast(Settings.SampleStep)); + } + } + } + }); TArray FeatureScales; FeatureScales.Reserve(static_cast(NumAir)); - for (int32 Index = 0; Index < Grid.CellCount; ++Index) + for (int32 Z = 0; Z < Grid.NumZ; ++Z) { - if (Grid.Air[Index] != 0u) - { - FeatureScales.Add(static_cast(Distances[Index]) - * static_cast(Settings.SampleStep)); - } + FeatureScales.Append(FeatureScalesByZ[Z]); } InOutMetrics.MedianFeatureScale = MedianFloat(FeatureScales); } @@ -2220,13 +2288,7 @@ namespace VoxelStrateMeasurePrivate int64 LargestComponentCells = 0; int32 LargestComponentLowestCell = INDEX_NONE; int32 NumComponentsAtLeast1Pct = 0; - int64 NumAir = 0; - for (int32 Index = 0; Index < Grid.CellCount; ++Index) - { - NumAir += Grid.Air[Index] != 0u - && (EligibilityMask == nullptr || (*EligibilityMask)[Index] != 0u) - ? 1 : 0; - } + const int64 NumAir = CountAirCells(Grid, EligibilityMask); FloodFillAir( Grid, Components, @@ -2284,10 +2346,16 @@ namespace VoxelStrateMeasurePrivate OutDiagnostics.StartComponentCells == LargestComponentCells; OutDiagnostics.bGoalComponentIsLargest = OutDiagnostics.GoalComponentCells == LargestComponentCells; - OutDiagnostics.StartToGoalComponentDistanceCells = DistanceToComponent( - Grid, Start, GoalComponent, Components); - OutDiagnostics.GoalToStartComponentDistanceCells = DistanceToComponent( - Grid, Goal, StartComponent, Components); + TArray ComponentDistances; + ComponentDistances.Init(0.0f, 2); + ParallelFor(2, [&](int32 DistanceIndex) + { + ComponentDistances[DistanceIndex] = DistanceIndex == 0 + ? DistanceToComponent(Grid, Start, GoalComponent, Components) + : DistanceToComponent(Grid, Goal, StartComponent, Components); + }); + OutDiagnostics.StartToGoalComponentDistanceCells = ComponentDistances[0]; + OutDiagnostics.GoalToStartComponentDistanceCells = ComponentDistances[1]; } EVoxelConnectivityResult QueryConnectivity( @@ -2702,7 +2770,12 @@ FVoxelStrateMetrics VF_MeasureStrate( Result.SampledMaxX = Grid.MaxX; Result.SampledMinY = Grid.MinY; Result.SampledMaxY = Grid.MaxY; - VoxelStrateMeasurePrivate::BuildMetricsFromGrid(Grid, Settings, Result); + TArray PlayerFitMask; + VoxelStrateMeasurePrivate::BuildMetricsFromGrid( + Grid, + Settings, + Result, + OutSampleGrid != nullptr ? &PlayerFitMask : nullptr); Result.bValid = Result.NumSampled > 0; if (!Result.bValid) { @@ -2711,6 +2784,8 @@ FVoxelStrateMetrics VF_MeasureStrate( if (OutSampleGrid != nullptr && Result.bValid) { VoxelStrateMeasurePrivate::ExportSampleGrid(Grid, *OutSampleGrid); + OutSampleGrid->PlayerFitMask = MoveTemp(PlayerFitMask); + OutSampleGrid->PlayerFitCellCount = Result.NumPlayerFitCells; } return Result; } @@ -2748,7 +2823,12 @@ FVoxelStrateMetrics VF_MeasureStrateWithSampler( Result.SampledMaxX = Grid.MaxX; Result.SampledMinY = Grid.MinY; Result.SampledMaxY = Grid.MaxY; - VoxelStrateMeasurePrivate::BuildMetricsFromGrid(Grid, Settings, Result); + TArray PlayerFitMask; + VoxelStrateMeasurePrivate::BuildMetricsFromGrid( + Grid, + Settings, + Result, + OutSampleGrid != nullptr ? &PlayerFitMask : nullptr); Result.bValid = Result.NumSampled > 0; if (!Result.bValid) { @@ -2757,6 +2837,8 @@ FVoxelStrateMetrics VF_MeasureStrateWithSampler( if (OutSampleGrid != nullptr && Result.bValid) { VoxelStrateMeasurePrivate::ExportSampleGrid(Grid, *OutSampleGrid); + OutSampleGrid->PlayerFitMask = MoveTemp(PlayerFitMask); + OutSampleGrid->PlayerFitCellCount = Result.NumPlayerFitCells; } return Result; } @@ -2951,11 +3033,16 @@ bool VF_MeasurePlayerFitWalkWithSampler( const FVector& BVoxel, const FVoxelStrateMeasureSettings& Settings, FVoxelPlayerFitWalkReport& OutReport, - const TArray* ComponentProbePoints) + const TArray* ComponentProbePoints, + FVoxelStrateSampleGrid* OutSampleGrid) { using namespace VoxelStrateMeasurePrivate; OutReport = FVoxelPlayerFitWalkReport(); + if (OutSampleGrid != nullptr) + { + *OutSampleGrid = FVoxelStrateSampleGrid(); + } OutReport.CapsuleRadiusMeters = Settings.PlayerCapsuleRadiusVoxels * FVoxelPlayerCapsuleConstants::VoxelSizeMeters; OutReport.CapsuleWidthMeters = 2.0f * OutReport.CapsuleRadiusMeters; @@ -3271,6 +3358,13 @@ bool VF_MeasurePlayerFitWalkWithSampler( { OutReport.RefusalReason = Diagnostics.RefusalReason; } + + if (OutSampleGrid != nullptr) + { + ExportSampleGrid(Grid, *OutSampleGrid); + OutSampleGrid->PlayerFitMask = MoveTemp(PlayerFitMask); + OutSampleGrid->PlayerFitCellCount = NumPlayerFitCells; + } return true; } diff --git a/Source/VoxelForge/Public/VoxelStrateMeasure.h b/Source/VoxelForge/Public/VoxelStrateMeasure.h index 30aa539..3f9970f 100644 --- a/Source/VoxelForge/Public/VoxelStrateMeasure.h +++ b/Source/VoxelForge/Public/VoxelStrateMeasure.h @@ -142,6 +142,12 @@ struct VOXELFORGE_API FVoxelStrateSampleGrid // empty on legacy/binary-only callers. This is never used by generation. TArray Density; + // 1 = the exact player-fit pose predicate accepted this cell, 0 = rejected. This is + // populated when a player-fit walk explicitly captures its shared grid. It is kept beside + // Air so render, walk, and diagnostics can consume one sampled field and one fit mask. + TArray PlayerFitMask; + int64 PlayerFitCellCount = 0; + FORCEINLINE int32 Index(int32 X, int32 Y, int32 Z) const { return static_cast( @@ -163,6 +169,19 @@ struct VOXELFORGE_API FVoxelStrateSampleGrid { return IsValid() && Density.Num() == CellCount; } + + bool HasPlayerFitMask() const + { + return IsValid() && PlayerFitMask.Num() == CellCount; + } + + bool ContainsPoint(const FVector& Point) const + { + return IsValid() + && Point.X >= MinX && Point.X < MaxX + && Point.Y >= MinY && Point.Y < MaxY + && Point.Z >= MinZ && Point.Z < MaxZ; + } }; /** Metrics derived from one coarse grid and its single air flood fill. */ @@ -510,4 +529,5 @@ VOXELFORGE_API bool VF_MeasurePlayerFitWalkWithSampler( const FVector& BVoxel, const FVoxelStrateMeasureSettings& Settings, FVoxelPlayerFitWalkReport& OutReport, - const TArray* ComponentProbePoints = nullptr); + const TArray* ComponentProbePoints = nullptr, + FVoxelStrateSampleGrid* OutSampleGrid = nullptr); diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp index 5199f2e..945b402 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -1,6 +1,7 @@ #include "VoxelForgeExploreCommandlet.h" #include "CoreMinimal.h" +#include "Async/ParallelFor.h" #include "Commandlets/Commandlet.h" #include "Containers/StringConv.h" #include "HAL/FileManager.h" @@ -29,6 +30,9 @@ #include "VoxelStrateMeasure.h" #include "VoxelTypes.h" +#include +#include + namespace { using FExploreJsonWriter = TJsonWriter>; @@ -39,6 +43,8 @@ constexpr float DefaultRenderStepVoxels = 0.25f; constexpr float DefaultRenderMaxDistanceVoxels = 160.0f; constexpr int32 DefaultExportSize = 128; constexpr int32 DefaultMaxWalkCells = 32000000; +constexpr float DefaultBudgetMinutes = 25.0f; +constexpr float MaxBudgetMinutes = 30.0f; constexpr int32 MaxSyntheticStrates = 64; constexpr int32 MaxRenderPixels = 1048576; constexpr int64 MaxRenderDensitySamples = 400000000ll; @@ -175,6 +181,9 @@ struct FExploreArguments float RenderMaxDistanceVoxels = DefaultRenderMaxDistanceVoxels; int32 ExportSize = DefaultExportSize; int32 MaxWalkCells = DefaultMaxWalkCells; + float BudgetMinutes = DefaultBudgetMinutes; + bool bSurfaceRoughnessOverride = false; + float SurfaceRoughness = 0.0f; FString CanonicalModes() const { @@ -207,6 +216,12 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr FParse::Value(*Params, TEXT("rendermaxdistance="), OutArguments.RenderMaxDistanceVoxels); FParse::Value(*Params, TEXT("exportsize="), OutArguments.ExportSize); FParse::Value(*Params, TEXT("maxwalkcells="), OutArguments.MaxWalkCells); + FParse::Value(*Params, TEXT("budget="), OutArguments.BudgetMinutes); + const bool bSurfaceRoughnessSpecified = Params.Contains( + TEXT("surfaceroughness="), ESearchCase::IgnoreCase); + const bool bSurfaceRoughnessParsed = FParse::Value( + *Params, TEXT("surfaceroughness="), OutArguments.SurfaceRoughness); + OutArguments.bSurfaceRoughnessOverride = bSurfaceRoughnessSpecified; int32 FailureFocusRender = 0; FParse::Value(*Params, TEXT("failurefocus="), FailureFocusRender); OutArguments.bFailureFocusRender = FailureFocusRender != 0; @@ -290,22 +305,9 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr OutError = TEXT("rendermaxdistance must be finite and in (0,2048] voxels."); return false; } - const int64 StepsPerRay = static_cast(FMath::Max( - 1, - FMath::CeilToInt( - OutArguments.RenderMaxDistanceVoxels / OutArguments.RenderStepVoxels))); - const int64 EstimatedRenderSamples = static_cast(OutArguments.RenderWidth) - * static_cast(OutArguments.RenderHeight) - * 3ll - * StepsPerRay; - if (EstimatedRenderSamples > MaxRenderDensitySamples) - { - OutError = FString::Printf( - TEXT("render request would take about %lld fixed density samples, over the cap %lld."), - static_cast(EstimatedRenderSamples), - static_cast(MaxRenderDensitySamples)); - return false; - } + // Render no longer budgets density samples: pixels are rasterised from the one canonical + // mesh. Keep the legacy renderstep/maxdistance arguments for compatible invocations and use + // only maxdistance as the raster depth clip. if (OutArguments.ExportSize < CHUNK_SIZE || OutArguments.ExportSize > MaxExportSize || OutArguments.ExportSize % CHUNK_SIZE != 0) @@ -322,6 +324,27 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr OutError = TEXT("maxwalkcells must be greater than zero."); return false; } + if (!FMath::IsFinite(OutArguments.BudgetMinutes) + || OutArguments.BudgetMinutes <= 0.0f + || OutArguments.BudgetMinutes > MaxBudgetMinutes) + { + OutError = FString::Printf( + TEXT("budget must be finite and in (0,%g] minutes."), MaxBudgetMinutes); + return false; + } + if (OutArguments.bSurfaceRoughnessOverride + && !bSurfaceRoughnessParsed) + { + OutError = TEXT("surfaceroughness must be a finite number greater than or equal to zero."); + return false; + } + if (OutArguments.bSurfaceRoughnessOverride + && (!FMath::IsFinite(OutArguments.SurfaceRoughness) + || OutArguments.SurfaceRoughness < 0.0f)) + { + OutError = TEXT("surfaceroughness must be finite and greater than or equal to zero."); + return false; + } if (OutText.IsEmpty()) { @@ -340,6 +363,92 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr return true; } +struct FExploreBudget +{ + double StartSeconds = 0.0; + double LimitSeconds = 0.0; + bool bTruncated = false; + FString TruncatedDuring; + TArray CompletedModes; + + FExploreBudget(double InStartSeconds, double InLimitSeconds) + : StartSeconds(InStartSeconds) + , LimitSeconds(InLimitSeconds) + { + } + + double ElapsedSeconds() const + { + return FPlatformTime::Seconds() - StartSeconds; + } + + double RemainingSeconds() const + { + return LimitSeconds - ElapsedSeconds(); + } + + bool ShouldStop(const TCHAR* Phase) + { + if (bTruncated) + { + return true; + } + + // Leave a small deterministic cleanup window for the current phase to close files and + // emit the report. The owner-facing hard ceiling is still enforced by the argument cap at + // 30 minutes; the default 25-minute budget leaves substantially more headroom. + constexpr double CleanupReserveSeconds = 0.25; + if (RemainingSeconds() <= CleanupReserveSeconds) + { + bTruncated = true; + TruncatedDuring = Phase != nullptr ? FString(Phase) : TEXT("unknown"); + UE_LOG(LogTemp, Warning, + TEXT("[VoxelForgeExplore] budget reached during %s; keeping completed artifacts."), + *TruncatedDuring); + return true; + } + return false; + } + + void CompleteMode(const TCHAR* Mode) + { + if (Mode == nullptr || bTruncated) + { + return; + } + CompletedModes.AddUnique(FString(Mode)); + } +}; + +struct FExploreMeshTriangle +{ + FVector A = FVector::ZeroVector; + FVector B = FVector::ZeroVector; + FVector C = FVector::ZeroVector; + FVector NormalA = FVector::UpVector; + FVector NormalB = FVector::UpVector; + FVector NormalC = FVector::UpVector; + FVector Min = FVector::ZeroVector; + FVector Max = FVector::ZeroVector; + FVector Centre = FVector::ZeroVector; + FVector Normal = FVector::UpVector; +}; + +struct FExploreMeshBvhNode +{ + FVector Min = FVector::ZeroVector; + FVector Max = FVector::ZeroVector; + int32 Left = INDEX_NONE; + int32 Right = INDEX_NONE; + int32 FirstTriangle = 0; + int32 TriangleCount = 0; + + bool IsLeaf() const + { + return Left == INDEX_NONE && Right == INDEX_NONE; + } +}; + struct FExploreWorld { TStrongObjectPtr Settings; @@ -349,6 +458,27 @@ struct FExploreWorld TStrongObjectPtr Mesher; TArray> Definitions; + // One canonical mesh cache for this commandlet run. Render and export consume these exact + // triangles; neither path is allowed to ask the density field once per pixel. + FVoxelMeshData ExploreMesh; + FIntVector ExploreMeshOrigin = FIntVector::ZeroValue; + int32 ExploreMeshSize = 0; + int32 ExploreMeshTileCount = 0; + int32 ExploreMeshTilesCompleted = 0; + double ExploreMeshSeconds = 0.0; + bool bExploreMeshBuilt = false; + bool bExploreMeshComplete = false; + FString ExploreMeshError; + + // A deterministic CPU acceleration structure over the canonical triangles. It is built once + // after meshing and reused by every viewpoint; no density query is reachable from raster. + TArray ExploreTriangles; + TArray ExploreTriangleOrder; + TArray ExploreBvhNodes; + int32 ExploreBvhRoot = INDEX_NONE; + double ExploreAccelerationSeconds = 0.0; + bool bExploreAccelerationBuilt = false; + int32 TargetBottomWorldZ = 0; int32 TargetTopWorldZ = 0; float TargetBoundarySealThickness = 4.0f; @@ -422,6 +552,37 @@ struct FExploreWorld OutError = TEXT("The requested target slot has no resolved definition."); return false; } + if (Arguments.bSurfaceRoughnessOverride) + { + // This is deliberately applied after layout construction and only to the target slot's + // resolved definition. Passage placement and every neighbouring strate retain their + // authored/default values; only the sampled target field gets the experiment knob. + switch (Arguments.Archetype) + { + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + Target.Definition->SlabParams.FloorRoughness = Arguments.SurfaceRoughness; + Target.Definition->SlabParams.CeilingRoughness = Arguments.SurfaceRoughness; + break; + case ECaveGeneratorType::Maze: + Target.Definition->MazeParams.SurfaceRoughness = Arguments.SurfaceRoughness; + break; + case ECaveGeneratorType::SurfaceWorld: + Target.Definition->SurfaceParams.SurfaceRoughness = Arguments.SurfaceRoughness; + break; + case ECaveGeneratorType::VerticalShafts: + Target.Definition->VerticalShaftParams.SurfaceRoughness = Arguments.SurfaceRoughness; + break; + case ECaveGeneratorType::FloatingIslands: + Target.Definition->FloatingIslandParams.SurfaceRoughness = Arguments.SurfaceRoughness; + break; + case ECaveGeneratorType::TunnelNetwork: + case ECaveGeneratorType::Underwater: + default: + Target.Definition->GenerationParams.SurfaceRoughness = Arguments.SurfaceRoughness; + break; + } + } TargetBottomWorldZ = Target.BottomChunkZ * CHUNK_SIZE; TargetTopWorldZ = (Target.TopChunkZ + 1) * CHUNK_SIZE; TargetBoundarySealThickness = BoundarySealForArchetype( @@ -483,6 +644,7 @@ struct FExploreRenderFrame int32 HitPixels = 0; bool bScaleMarkerProjected = false; bool bFailureMarkersProjected = false; + bool bPartial = false; }; struct FExploreRenderOutput @@ -496,10 +658,19 @@ struct FExploreRenderOutput int32 BisectionIterations = 0; float MaxDistanceVoxels = 0.0f; int64 EstimatedFixedDensitySamples = 0; + int64 LegacyTwoViewEstimatedDensitySamples = 0; FString StepRationale; FString CameraSeedPolicy; int32 CameraSeedCount = 0; bool bAllCamerasPlayerFit = false; + double MeshSeconds = 0.0; + double RasterSeconds = 0.0; + double FirstViewRasterSeconds = 0.0; + double AdditionalViewRasterSeconds = 0.0; + int32 RequestedViewpointCount = 8; + bool bMeshComplete = false; + double MeshAccelerationSeconds = 0.0; + bool bTruncated = false; TArray Frames; }; @@ -525,6 +696,55 @@ struct FExploreWalkOutput int32 DepartureLandingRoomProbeCount = 0; int32 DepartureLandingRoomPlayerFitProbeCount = 0; int32 DepartureLandingRoomArrivalComponentProbeCount = 0; + // Captured by the one walk pass and consumed by render camera selection. These are internal + // hand-off buffers, not a second source of truth for the field. + FVoxelStrateSampleGrid SharedSampleGrid; + TArray CameraSeedPoses; + TArray CameraSeedSources; +}; + +struct FExploreMeshMetrics +{ + int32 VertexCount = 0; + int32 TriangleCount = 0; + int32 SharedEdgeCount = 0; + int32 DihedralSampleCount = 0; + int32 NormalComparisonTriangleCount = 0; + float EdgeLengthP50Meters = 0.0f; + float EdgeLengthP99Meters = 0.0f; + float EdgeLengthMaxMeters = 0.0f; + float DihedralP50Degrees = 0.0f; + float DihedralP75Degrees = 0.0f; + float DihedralP90Degrees = 0.0f; + float DihedralP95Degrees = 0.0f; + float DihedralP99Degrees = 0.0f; + float DihedralFractionOver20Degrees = 0.0f; + float DihedralFractionOver40Degrees = 0.0f; + float FaceNormalVsMeanVertexNormalP50Degrees = 0.0f; + float FaceNormalVsMeanVertexNormalP90Degrees = 0.0f; +}; + +struct FExploreMeshEdgeKey +{ + FIntVector First = FIntVector::ZeroValue; + FIntVector Second = FIntVector::ZeroValue; + + bool operator==(const FExploreMeshEdgeKey& Other) const + { + return First == Other.First && Second == Other.Second; + } +}; + +FORCEINLINE uint32 GetTypeHash(const FExploreMeshEdgeKey& Key) +{ + return HashCombine(GetTypeHash(Key.First), GetTypeHash(Key.Second)); +} + +struct FExploreMeshEdgeIncident +{ + int32 IncidentCount = 0; + int32 FirstTriangle = INDEX_NONE; + int32 SecondTriangle = INDEX_NONE; }; struct FExploreExportOutput @@ -536,12 +756,16 @@ struct FExploreExportOutput FIntVector RegionOrigin = FIntVector::ZeroValue; int32 RegionSize = 0; int32 MesherTileCount = 0; + int32 MesherTilesCompleted = 0; int64 EstimatedWorkingBytes = 0; int64 WorkingMemoryCapBytes = MaxExportWorkingBytes; int64 MeshArrayBytes = 0; int64 MeshFileSizeBytes = 0; int32 VertexCount = 0; int32 TriangleCount = 0; + FExploreMeshMetrics SurfaceMetrics; + double MeshSeconds = 0.0; + bool bTruncated = false; }; struct FExploreRunOutput @@ -549,8 +773,19 @@ struct FExploreRunOutput FExploreRenderOutput Render; FExploreWalkOutput Walk; FExploreExportOutput Export; + double BudgetSeconds = 0.0; + double ElapsedSeconds = 0.0; + bool bTruncated = false; + FString TruncatedDuring; + TArray CompletedModes; }; +bool ValidateCanonicalTile(const FVoxelMeshData& MeshData, FString& OutError); +bool MeasureExploreMesh( + const FVoxelMeshData& MeshData, + FExploreMeshMetrics& OutMetrics, + FString& OutError); + bool FindMouths( const UVoxelStrateManager& Manager, int32 TargetSlot, @@ -626,123 +861,6 @@ bool BuildCameraBasis( return OutBasis.Aspect > 0.0f && OutBasis.TanHalfFov > 0.0f; } -FVector MakeRayDirection( - const FExploreCameraBasis& Basis, - int32 PixelX, - int32 PixelY, - int32 Width, - int32 Height) -{ - const float NormalizedX = (2.0f * (static_cast(PixelX) + 0.5f) - / static_cast(Width)) - 1.0f; - const float NormalizedY = 1.0f - (2.0f * (static_cast(PixelY) + 0.5f) - / static_cast(Height)); - return (Basis.Forward - + Basis.Right * (NormalizedX * Basis.Aspect * Basis.TanHalfFov) - + Basis.Up * (NormalizedY * Basis.TanHalfFov)).GetSafeNormal(); -} - -bool TraceDensityRay( - const UVoxelGenerator& Generator, - const FVector& Origin, - const FVector& Direction, - float FixedStepVoxels, - float MaxDistanceVoxels, - int32 BisectionIterations, - FVector& OutHitPoint, - FVector& OutNormal) -{ - float PreviousDensity = Generator.GetDensityAt(Origin.X, Origin.Y, Origin.Z); - if (!FMath::IsFinite(PreviousDensity)) - { - return false; - } - if (PreviousDensity == 0.0f) - { - OutHitPoint = Origin; - OutNormal = FVector::UpVector; - return true; - } - - const int32 NumSteps = FMath::Max( - 1, - FMath::CeilToInt(MaxDistanceVoxels / FixedStepVoxels)); - float PreviousDistance = 0.0f; - for (int32 StepIndex = 1; StepIndex <= NumSteps; ++StepIndex) - { - const float CurrentDistance = FMath::Min( - MaxDistanceVoxels, - static_cast(StepIndex) * FixedStepVoxels); - const FVector CurrentPoint = Origin + Direction * CurrentDistance; - const float CurrentDensity = Generator.GetDensityAt( - CurrentPoint.X, CurrentPoint.Y, CurrentPoint.Z); - if (!FMath::IsFinite(CurrentDensity)) - { - return false; - } - - const bool bPreviousAir = PreviousDensity > 0.0f; - const bool bCurrentAir = CurrentDensity > 0.0f; - if (bPreviousAir != bCurrentAir || CurrentDensity == 0.0f) - { - float LowDistance = PreviousDistance; - float HighDistance = CurrentDistance; - const bool bLowAir = bPreviousAir; - for (int32 Iteration = 0; Iteration < BisectionIterations; ++Iteration) - { - const float MidDistance = 0.5f * (LowDistance + HighDistance); - const FVector MidPoint = Origin + Direction * MidDistance; - const float MidDensity = Generator.GetDensityAt( - MidPoint.X, MidPoint.Y, MidPoint.Z); - if (!FMath::IsFinite(MidDensity)) - { - return false; - } - if ((MidDensity > 0.0f) == bLowAir) - { - LowDistance = MidDistance; - } - else - { - HighDistance = MidDistance; - } - } - - const float HitDistance = 0.5f * (LowDistance + HighDistance); - OutHitPoint = Origin + Direction * HitDistance; - const float NormalStep = 0.5f; - const float DXP = Generator.GetDensityAt( - OutHitPoint.X + NormalStep, OutHitPoint.Y, OutHitPoint.Z); - const float DXN = Generator.GetDensityAt( - OutHitPoint.X - NormalStep, OutHitPoint.Y, OutHitPoint.Z); - const float DYP = Generator.GetDensityAt( - OutHitPoint.X, OutHitPoint.Y + NormalStep, OutHitPoint.Z); - const float DYN = Generator.GetDensityAt( - OutHitPoint.X, OutHitPoint.Y - NormalStep, OutHitPoint.Z); - const float DZP = Generator.GetDensityAt( - OutHitPoint.X, OutHitPoint.Y, OutHitPoint.Z + NormalStep); - const float DZN = Generator.GetDensityAt( - OutHitPoint.X, OutHitPoint.Y, OutHitPoint.Z - NormalStep); - if (!FMath::IsFinite(DXP) || !FMath::IsFinite(DXN) - || !FMath::IsFinite(DYP) || !FMath::IsFinite(DYN) - || !FMath::IsFinite(DZP) || !FMath::IsFinite(DZN)) - { - return false; - } - OutNormal = FVector(DXP - DXN, DYP - DYN, DZP - DZN).GetSafeNormal(); - if (OutNormal.IsNearlyZero()) - { - OutNormal = FVector::UpVector; - } - return true; - } - - PreviousDistance = CurrentDistance; - PreviousDensity = CurrentDensity; - } - return false; -} - void PutPixel(TArray& Pixels, int32 Width, int32 Height, int32 X, int32 Y, const FColor& Color) { if (X >= 0 && X < Width && Y >= 0 && Y < Height) @@ -891,11 +1009,767 @@ bool SavePng( return true; } +FIntVector ChooseExploreMeshOrigin( + const FExploreArguments& Arguments, + const FExploreWalkOutput* CameraSeedWalk, + int32 MeshSize, + int32 TargetMiddleWorldZ) +{ + FVector Centre(0.0f, 0.0f, static_cast(TargetMiddleWorldZ)); + if (CameraSeedWalk != nullptr && CameraSeedWalk->CameraSeedPoses.Num() > 0) + { + Centre = CameraSeedWalk->CameraSeedPoses[0]; + } + + const auto AlignTileOrigin = [MeshSize](float CentreCoordinate) + { + return FMath::FloorToInt( + (CentreCoordinate - 0.5f * static_cast(MeshSize)) + / static_cast(CHUNK_SIZE)) + * CHUNK_SIZE; + }; + return FIntVector( + AlignTileOrigin(Centre.X), + AlignTileOrigin(Centre.Y), + AlignTileOrigin(Centre.Z)); +} + +bool AppendCanonicalMesh( + FVoxelMeshData& Destination, + const FVoxelMeshData& Source, + FString& OutError) +{ + if (!ValidateCanonicalTile(Source, OutError)) + { + return false; + } + + const int32 VertexOffset = Destination.Vertices.Num(); + for (const int32 Index : Source.Triangles) + { + if (Index < 0 || Index >= Source.Vertices.Num()) + { + OutError = TEXT("Canonical mesher returned an out-of-range triangle index."); + return false; + } + } + + Destination.Vertices.Append(Source.Vertices); + Destination.Normals.Append(Source.Normals); + Destination.UVs.Append(Source.UVs); + Destination.Colors.Append(Source.Colors); + Destination.Triangles.Reserve(Destination.Triangles.Num() + Source.Triangles.Num()); + for (const int32 Index : Source.Triangles) + { + Destination.Triangles.Add(Index + VertexOffset); + } + // A multi-tile aggregate interleaves each tile's ground/cap runs. The CPU rasterizer uses + // flat geometry and the OBJ path is material-neutral, so there is deliberately no invented + // global ceiling run here. + Destination.NumCeilingTriangles = 0; + return true; +} + +bool EnsureExploreMesh( + const FExploreArguments& Arguments, + FExploreWorld& World, + const FExploreWalkOutput* CameraSeedWalk, + FExploreBudget& Budget, + FString& OutError) +{ + if (World.bExploreMeshBuilt) + { + OutError = World.ExploreMeshError; + return World.bExploreMeshComplete; + } + + World.bExploreMeshBuilt = true; + World.bExploreMeshComplete = false; + World.ExploreMeshError.Reset(); + World.ExploreMesh.Clear(); + World.ExploreMeshSize = Arguments.ExportSize; + const int32 TilesPerAxis = Arguments.ExportSize / CHUNK_SIZE; + World.ExploreMeshTileCount = TilesPerAxis * TilesPerAxis * TilesPerAxis; + World.ExploreMeshTilesCompleted = 0; + World.ExploreMeshOrigin = ChooseExploreMeshOrigin( + Arguments, + CameraSeedWalk, + World.ExploreMeshSize, + FMath::FloorToInt(0.5f * static_cast( + World.TargetBottomWorldZ + World.TargetTopWorldZ))); + World.ExploreMeshSeconds = 0.0; + + if (Budget.ShouldStop(TEXT("mesh"))) + { + World.ExploreMeshError = TEXT("The wall-clock budget elapsed before meshing began."); + OutError = World.ExploreMeshError; + return false; + } + + // This is the same canonical UVoxelMarchingCubesMesher used by export and runtime chunk + // generation. Skirts are presentation geometry for LOD seams and would extend outside the + // bounded explorer region, so the shared mesh uses the export setting. + World.Mesher->bGenerateSkirts = false; + + const double MeshStartSeconds = FPlatformTime::Seconds(); + if (Budget.ShouldStop(TEXT("mesh"))) + { + World.ExploreMeshError = TEXT("The wall-clock budget elapsed before mesh tiles began."); + OutError = World.ExploreMeshError; + World.ExploreMeshSeconds = FPlatformTime::Seconds() - MeshStartSeconds; + return false; + } + + // Generate each tile through the canonical mesher on worker threads, then append strictly in + // Z/Y/X order. The mesher's scratch buffers and the generator's hot caches are thread-local; + // the fixed merge order keeps the aggregate mesh and all downstream raster/export bytes + // deterministic even though tile completion order is not. + TArray TileMeshes; + TileMeshes.SetNum(World.ExploreMeshTileCount); + TArray TileErrors; + TileErrors.SetNum(World.ExploreMeshTileCount); + TArray TileCompleted; + TileCompleted.Init(0, World.ExploreMeshTileCount); + std::atomic bTileWorkCancelled(false); + const double TileWorkDeadline = Budget.StartSeconds + Budget.LimitSeconds - 0.25; + ParallelFor( + World.ExploreMeshTileCount, + [&](int32 LinearTileIndex) + { + if (bTileWorkCancelled.load(std::memory_order_relaxed) + || FPlatformTime::Seconds() >= TileWorkDeadline) + { + bTileWorkCancelled.store(true, std::memory_order_relaxed); + return; + } + + const int32 TileX = LinearTileIndex % TilesPerAxis; + const int32 TileY = (LinearTileIndex / TilesPerAxis) % TilesPerAxis; + const int32 TileZ = LinearTileIndex / (TilesPerAxis * TilesPerAxis); + const FIntVector TileOrigin = World.ExploreMeshOrigin + + FIntVector(TileX * CHUNK_SIZE, TileY * CHUNK_SIZE, TileZ * CHUNK_SIZE); + TileMeshes[LinearTileIndex] = World.Mesher->GenerateMesh( + TileOrigin, + 1, + CHUNK_SIZE); + + FString TileError; + if (!ValidateCanonicalTile(TileMeshes[LinearTileIndex], TileError)) + { + TileErrors[LinearTileIndex] = MoveTemp(TileError); + bTileWorkCancelled.store(true, std::memory_order_relaxed); + return; + } + TileCompleted[LinearTileIndex] = 1; + }); + + int32 CompletedTiles = 0; + FString MeshError; + for (; CompletedTiles < World.ExploreMeshTileCount; ++CompletedTiles) + { + if (!TileCompleted[CompletedTiles]) + { + break; + } + if (!AppendCanonicalMesh( + World.ExploreMesh, + TileMeshes[CompletedTiles], + MeshError)) + { + World.ExploreMeshError = MeshError; + OutError = MeshError; + World.ExploreMeshTilesCompleted = CompletedTiles; + World.ExploreMeshSeconds = FPlatformTime::Seconds() - MeshStartSeconds; + return false; + } + World.ExploreMeshTilesCompleted = CompletedTiles + 1; + } + + if (CompletedTiles < World.ExploreMeshTileCount) + { + if (!TileErrors[CompletedTiles].IsEmpty()) + { + World.ExploreMeshError = TileErrors[CompletedTiles]; + OutError = World.ExploreMeshError; + World.ExploreMeshSeconds = FPlatformTime::Seconds() - MeshStartSeconds; + return false; + } + + Budget.ShouldStop(TEXT("mesh")); + World.ExploreMeshError = FString::Printf( + TEXT("Mesh was truncated after %d/%d canonical tiles."), + CompletedTiles, + World.ExploreMeshTileCount); + OutError = World.ExploreMeshError; + World.ExploreMeshSeconds = FPlatformTime::Seconds() - MeshStartSeconds; + return false; + } + + World.ExploreMeshSeconds = FPlatformTime::Seconds() - MeshStartSeconds; + World.bExploreMeshComplete = true; + OutError.Reset(); + return true; +} + +float ExploreAxisValue(const FVector& Value, int32 Axis) +{ + switch (Axis) + { + case 1: return Value.Y; + case 2: return Value.Z; + default: return Value.X; + } +} + +FVector ExploreTriangleMin(const FVector& A, const FVector& B, const FVector& C) +{ + return FVector( + FMath::Min3(A.X, B.X, C.X), + FMath::Min3(A.Y, B.Y, C.Y), + FMath::Min3(A.Z, B.Z, C.Z)); +} + +FVector ExploreTriangleMax(const FVector& A, const FVector& B, const FVector& C) +{ + return FVector( + FMath::Max3(A.X, B.X, C.X), + FMath::Max3(A.Y, B.Y, C.Y), + FMath::Max3(A.Z, B.Z, C.Z)); +} + +bool EnsureExploreAcceleration( + const FExploreArguments& Arguments, + FExploreWorld& World, + FExploreBudget& Budget, + FString& OutError) +{ + if (World.bExploreAccelerationBuilt) + { + OutError.Reset(); + return true; + } + + World.ExploreTriangles.Reset(); + World.ExploreTriangleOrder.Reset(); + World.ExploreBvhNodes.Reset(); + World.ExploreBvhRoot = INDEX_NONE; + World.ExploreAccelerationSeconds = 0.0; + const double AccelerationStartSeconds = FPlatformTime::Seconds(); + + if (!World.bExploreMeshComplete) + { + OutError = TEXT("Cannot build mesh acceleration before the canonical mesh is complete."); + return false; + } + if (Budget.ShouldStop(TEXT("mesh-acceleration"))) + { + OutError = TEXT("The wall-clock budget elapsed before mesh acceleration began."); + return false; + } + + const FVoxelMeshData& Mesh = World.ExploreMesh; + if (Mesh.Triangles.Num() % 3 != 0) + { + OutError = TEXT("Canonical mesh triangle index data is not divisible by three."); + return false; + } + + World.ExploreTriangles.Reserve(Mesh.Triangles.Num() / 3); + for (int32 TriangleIndex = 0; + TriangleIndex + 2 < Mesh.Triangles.Num(); + TriangleIndex += 3) + { + if ((TriangleIndex & 16383) == 0 + && Budget.ShouldStop(TEXT("mesh-acceleration"))) + { + OutError = TEXT("Mesh acceleration was truncated before its triangle cache completed."); + World.ExploreTriangles.Reset(); + return false; + } + + const int32 IndexA = Mesh.Triangles[TriangleIndex]; + const int32 IndexB = Mesh.Triangles[TriangleIndex + 1]; + const int32 IndexC = Mesh.Triangles[TriangleIndex + 2]; + if (!Mesh.Vertices.IsValidIndex(IndexA) + || !Mesh.Vertices.IsValidIndex(IndexB) + || !Mesh.Vertices.IsValidIndex(IndexC)) + { + OutError = TEXT("Canonical mesh acceleration saw an out-of-range triangle index."); + World.ExploreTriangles.Reset(); + return false; + } + + const FVector A = Mesh.Vertices[IndexA] / VOXEL_SIZE; + const FVector B = Mesh.Vertices[IndexB] / VOXEL_SIZE; + const FVector C = Mesh.Vertices[IndexC] / VOXEL_SIZE; + if (!FMath::IsFinite(A.X) || !FMath::IsFinite(A.Y) || !FMath::IsFinite(A.Z) + || !FMath::IsFinite(B.X) || !FMath::IsFinite(B.Y) || !FMath::IsFinite(B.Z) + || !FMath::IsFinite(C.X) || !FMath::IsFinite(C.Y) || !FMath::IsFinite(C.Z)) + { + OutError = TEXT("Canonical mesh acceleration saw a non-finite vertex."); + World.ExploreTriangles.Reset(); + return false; + } + + const FVector Cross = FVector::CrossProduct(B - A, C - A); + if (Cross.SizeSquared() <= SMALL_NUMBER) + { + // Degenerate canonical triangles have no raster contribution. Keep them out of the + // acceleration structure without changing the source mesh or its export semantics. + continue; + } + + FExploreMeshTriangle& Triangle = World.ExploreTriangles.AddDefaulted_GetRef(); + Triangle.A = A; + Triangle.B = B; + Triangle.C = C; + Triangle.NormalA = Mesh.Normals[IndexA].GetSafeNormal(); + Triangle.NormalB = Mesh.Normals[IndexB].GetSafeNormal(); + Triangle.NormalC = Mesh.Normals[IndexC].GetSafeNormal(); + Triangle.Min = ExploreTriangleMin(A, B, C); + Triangle.Max = ExploreTriangleMax(A, B, C); + Triangle.Centre = (A + B + C) / 3.0f; + Triangle.Normal = Cross.GetSafeNormal(); + } + + if (World.ExploreTriangles.Num() == 0) + { + // A valid empty mesh is still a complete mesh render: every pixel remains the explicit + // background colour. The root sentinel lets the rasterizer handle this deterministically. + World.ExploreAccelerationSeconds = FPlatformTime::Seconds() - AccelerationStartSeconds; + World.bExploreAccelerationBuilt = true; + OutError.Reset(); + return true; + } + + World.ExploreTriangleOrder.SetNumUninitialized(World.ExploreTriangles.Num()); + for (int32 TriangleIndex = 0; + TriangleIndex < World.ExploreTriangleOrder.Num(); + ++TriangleIndex) + { + World.ExploreTriangleOrder[TriangleIndex] = TriangleIndex; + } + + World.ExploreBvhNodes.Reserve(World.ExploreTriangles.Num() / 4 + 2); + bool bBuildFailed = false; + TFunction BuildNode; + BuildNode = [&](int32 Start, int32 Count) -> int32 + { + if (bBuildFailed || Count <= 0) + { + return INDEX_NONE; + } + if (Budget.ShouldStop(TEXT("mesh-acceleration"))) + { + bBuildFailed = true; + return INDEX_NONE; + } + + const int32 NodeIndex = World.ExploreBvhNodes.AddDefaulted(); + FExploreMeshBvhNode Node; + Node.Min = FVector(FLT_MAX, FLT_MAX, FLT_MAX); + Node.Max = FVector(-FLT_MAX, -FLT_MAX, -FLT_MAX); + FVector CentroidMin = FVector(FLT_MAX, FLT_MAX, FLT_MAX); + FVector CentroidMax = FVector(-FLT_MAX, -FLT_MAX, -FLT_MAX); + for (int32 Offset = 0; Offset < Count; ++Offset) + { + const int32 TriangleIndex = World.ExploreTriangleOrder[Start + Offset]; + const FExploreMeshTriangle& Triangle = World.ExploreTriangles[TriangleIndex]; + Node.Min.X = FMath::Min(Node.Min.X, Triangle.Min.X); + Node.Min.Y = FMath::Min(Node.Min.Y, Triangle.Min.Y); + Node.Min.Z = FMath::Min(Node.Min.Z, Triangle.Min.Z); + Node.Max.X = FMath::Max(Node.Max.X, Triangle.Max.X); + Node.Max.Y = FMath::Max(Node.Max.Y, Triangle.Max.Y); + Node.Max.Z = FMath::Max(Node.Max.Z, Triangle.Max.Z); + CentroidMin.X = FMath::Min(CentroidMin.X, Triangle.Centre.X); + CentroidMin.Y = FMath::Min(CentroidMin.Y, Triangle.Centre.Y); + CentroidMin.Z = FMath::Min(CentroidMin.Z, Triangle.Centre.Z); + CentroidMax.X = FMath::Max(CentroidMax.X, Triangle.Centre.X); + CentroidMax.Y = FMath::Max(CentroidMax.Y, Triangle.Centre.Y); + CentroidMax.Z = FMath::Max(CentroidMax.Z, Triangle.Centre.Z); + } + + constexpr int32 LeafTriangleCount = 8; + if (Count <= LeafTriangleCount) + { + Node.FirstTriangle = Start; + Node.TriangleCount = Count; + World.ExploreBvhNodes[NodeIndex] = Node; + return NodeIndex; + } + + const FVector CentroidExtent = CentroidMax - CentroidMin; + int32 SplitAxis = 0; + if (CentroidExtent.Y > CentroidExtent.X + && CentroidExtent.Y >= CentroidExtent.Z) + { + SplitAxis = 1; + } + else if (CentroidExtent.Z > CentroidExtent.X + && CentroidExtent.Z > CentroidExtent.Y) + { + SplitAxis = 2; + } + + int32* OrderBegin = World.ExploreTriangleOrder.GetData() + Start; + int32* OrderEnd = OrderBegin + Count; + std::sort( + OrderBegin, + OrderEnd, + [&](int32 LeftIndex, int32 RightIndex) + { + const float LeftValue = ExploreAxisValue( + World.ExploreTriangles[LeftIndex].Centre, SplitAxis); + const float RightValue = ExploreAxisValue( + World.ExploreTriangles[RightIndex].Centre, SplitAxis); + if (LeftValue != RightValue) + { + return LeftValue < RightValue; + } + return LeftIndex < RightIndex; + }); + + const int32 LeftCount = Count / 2; + const int32 RightCount = Count - LeftCount; + const int32 LeftNode = BuildNode(Start, LeftCount); + const int32 RightNode = BuildNode(Start + LeftCount, RightCount); + if (LeftNode == INDEX_NONE || RightNode == INDEX_NONE) + { + bBuildFailed = true; + return INDEX_NONE; + } + + Node.Left = LeftNode; + Node.Right = RightNode; + World.ExploreBvhNodes[NodeIndex] = Node; + return NodeIndex; + }; + + World.ExploreBvhRoot = BuildNode(0, World.ExploreTriangles.Num()); + if (bBuildFailed || World.ExploreBvhRoot == INDEX_NONE || Budget.bTruncated) + { + World.ExploreTriangles.Reset(); + World.ExploreTriangleOrder.Reset(); + World.ExploreBvhNodes.Reset(); + World.ExploreBvhRoot = INDEX_NONE; + OutError = TEXT("Mesh acceleration was truncated before its BVH completed."); + return false; + } + + World.ExploreAccelerationSeconds = FPlatformTime::Seconds() - AccelerationStartSeconds; + World.bExploreAccelerationBuilt = true; + OutError.Reset(); + return true; +} + +bool ExploreRayAabb( + const FVector& Origin, + const FVector& Direction, + const FVector& BoundsMin, + const FVector& BoundsMax, + float MaxDistance, + float& OutNear) +{ + float NearDistance = 0.0f; + float FarDistance = MaxDistance; + for (int32 Axis = 0; Axis < 3; ++Axis) + { + const float OriginComponent = ExploreAxisValue(Origin, Axis); + const float DirectionComponent = ExploreAxisValue(Direction, Axis); + const float MinComponent = ExploreAxisValue(BoundsMin, Axis); + const float MaxComponent = ExploreAxisValue(BoundsMax, Axis); + if (FMath::Abs(DirectionComponent) <= KINDA_SMALL_NUMBER) + { + if (OriginComponent < MinComponent || OriginComponent > MaxComponent) + { + return false; + } + continue; + } + + const float InverseDirection = 1.0f / DirectionComponent; + float AxisNear = (MinComponent - OriginComponent) * InverseDirection; + float AxisFar = (MaxComponent - OriginComponent) * InverseDirection; + if (AxisNear > AxisFar) + { + Swap(AxisNear, AxisFar); + } + NearDistance = FMath::Max(NearDistance, AxisNear); + FarDistance = FMath::Min(FarDistance, AxisFar); + if (NearDistance > FarDistance) + { + return false; + } + } + + OutNear = NearDistance; + return true; +} + +bool ExploreRayTriangle( + const FVector& Origin, + const FVector& Direction, + const FExploreMeshTriangle& Triangle, + float MaxDistance, + float& OutDistance, + float& OutU, + float& OutV) +{ + const FVector Edge1 = Triangle.B - Triangle.A; + const FVector Edge2 = Triangle.C - Triangle.A; + const FVector Perpendicular = FVector::CrossProduct(Direction, Edge2); + const float Determinant = FVector::DotProduct(Edge1, Perpendicular); + if (FMath::Abs(Determinant) <= KINDA_SMALL_NUMBER) + { + return false; + } + + const float InverseDeterminant = 1.0f / Determinant; + const FVector ToOrigin = Origin - Triangle.A; + const float U = FVector::DotProduct(ToOrigin, Perpendicular) * InverseDeterminant; + if (U < 0.0f || U > 1.0f) + { + return false; + } + + const FVector CrossOrigin = FVector::CrossProduct(ToOrigin, Edge1); + const float V = FVector::DotProduct(Direction, CrossOrigin) * InverseDeterminant; + if (V < 0.0f || U + V > 1.0f) + { + return false; + } + + const float Distance = FVector::DotProduct(Edge2, CrossOrigin) * InverseDeterminant; + if (Distance <= 0.01f || Distance >= MaxDistance) + { + return false; + } + OutDistance = Distance; + OutU = U; + OutV = V; + return true; +} + +bool RasterizeExploreMesh( + const FExploreArguments& Arguments, + FExploreWorld& World, + const FVector& CameraPosition, + const FVector& Target, + FExploreBudget& Budget, + TArray& OutPixels, + int32& OutHitPixels, + bool& bOutTruncated) +{ + OutPixels.Init(FColor(12, 18, 28, 255), Arguments.RenderWidth * Arguments.RenderHeight); + OutHitPixels = 0; + bOutTruncated = false; + + if (!World.bExploreAccelerationBuilt) + { + FString AccelerationError; + if (!EnsureExploreAcceleration(Arguments, World, Budget, AccelerationError)) + { + if (Budget.bTruncated) + { + bOutTruncated = true; + return true; + } + return false; + } + } + + FExploreCameraBasis Basis; + if (!BuildCameraBasis( + CameraPosition, + Target, + 70.0f, + Arguments.RenderWidth, + Arguments.RenderHeight, + Basis)) + { + return false; + } + + const FVector KeyLightDirection = FVector(-0.35f, -0.45f, 0.82f).GetSafeNormal(); + const FVector FillLightDirection = FVector(0.65f, 0.30f, 0.70f).GetSafeNormal(); + constexpr float AmbientFill = 0.62f; + constexpr float KeyLightStrength = 0.50f; + constexpr float FillLightStrength = 0.24f; + + for (int32 PixelY = 0; PixelY < Arguments.RenderHeight; ++PixelY) + { + if (Budget.ShouldStop(TEXT("raster"))) + { + bOutTruncated = true; + return true; + } + + for (int32 PixelX = 0; PixelX < Arguments.RenderWidth; ++PixelX) + { + const float NormalizedX = (2.0f * (static_cast(PixelX) + 0.5f) + / static_cast(Arguments.RenderWidth)) - 1.0f; + const float NormalizedY = 1.0f - (2.0f * (static_cast(PixelY) + 0.5f) + / static_cast(Arguments.RenderHeight)); + const FVector Direction = ( + Basis.Forward + + Basis.Right * (NormalizedX * Basis.Aspect * Basis.TanHalfFov) + + Basis.Up * (NormalizedY * Basis.TanHalfFov)).GetSafeNormal(); + + float ClosestDistance = Arguments.RenderMaxDistanceVoxels; + int32 ClosestTriangleIndex = INDEX_NONE; + float ClosestU = 0.0f; + float ClosestV = 0.0f; + int32 NodeStack[256]; + int32 StackCount = 0; + if (World.ExploreBvhRoot != INDEX_NONE) + { + NodeStack[StackCount++] = World.ExploreBvhRoot; + } + + while (StackCount > 0) + { + const int32 NodeIndex = NodeStack[--StackCount]; + if (!World.ExploreBvhNodes.IsValidIndex(NodeIndex)) + { + continue; + } + + const FExploreMeshBvhNode& Node = World.ExploreBvhNodes[NodeIndex]; + float NodeNear = 0.0f; + if (!ExploreRayAabb( + CameraPosition, + Direction, + Node.Min, + Node.Max, + ClosestDistance, + NodeNear)) + { + continue; + } + + if (Node.IsLeaf()) + { + for (int32 Offset = 0; Offset < Node.TriangleCount; ++Offset) + { + const int32 OrderIndex = Node.FirstTriangle + Offset; + if (!World.ExploreTriangleOrder.IsValidIndex(OrderIndex)) + { + continue; + } + const int32 TriangleIndex = World.ExploreTriangleOrder[OrderIndex]; + if (!World.ExploreTriangles.IsValidIndex(TriangleIndex)) + { + continue; + } + float TriangleDistance = 0.0f; + float TriangleU = 0.0f; + float TriangleV = 0.0f; + if (ExploreRayTriangle( + CameraPosition, + Direction, + World.ExploreTriangles[TriangleIndex], + ClosestDistance, + TriangleDistance, + TriangleU, + TriangleV)) + { + ClosestDistance = TriangleDistance; + ClosestTriangleIndex = TriangleIndex; + ClosestU = TriangleU; + ClosestV = TriangleV; + } + } + continue; + } + + const int32 LeftNodeIndex = Node.Left; + const int32 RightNodeIndex = Node.Right; + float LeftNear = 0.0f; + float RightNear = 0.0f; + const bool bHitLeft = World.ExploreBvhNodes.IsValidIndex(LeftNodeIndex) + && ExploreRayAabb( + CameraPosition, + Direction, + World.ExploreBvhNodes[LeftNodeIndex].Min, + World.ExploreBvhNodes[LeftNodeIndex].Max, + ClosestDistance, + LeftNear); + const bool bHitRight = World.ExploreBvhNodes.IsValidIndex(RightNodeIndex) + && ExploreRayAabb( + CameraPosition, + Direction, + World.ExploreBvhNodes[RightNodeIndex].Min, + World.ExploreBvhNodes[RightNodeIndex].Max, + ClosestDistance, + RightNear); + if (bHitLeft && bHitRight) + { + if (LeftNear < RightNear) + { + if (StackCount + 2 <= UE_ARRAY_COUNT(NodeStack)) + { + NodeStack[StackCount++] = RightNodeIndex; + NodeStack[StackCount++] = LeftNodeIndex; + } + } + else if (StackCount + 2 <= UE_ARRAY_COUNT(NodeStack)) + { + NodeStack[StackCount++] = LeftNodeIndex; + NodeStack[StackCount++] = RightNodeIndex; + } + } + else if (bHitLeft && StackCount < UE_ARRAY_COUNT(NodeStack)) + { + NodeStack[StackCount++] = LeftNodeIndex; + } + else if (bHitRight && StackCount < UE_ARRAY_COUNT(NodeStack)) + { + NodeStack[StackCount++] = RightNodeIndex; + } + } + + if (ClosestTriangleIndex == INDEX_NONE) + { + continue; + } + + const FExploreMeshTriangle& Triangle = World.ExploreTriangles[ClosestTriangleIndex]; + FVector ShadingNormal = ( + Triangle.NormalA * (1.0f - ClosestU - ClosestV) + + Triangle.NormalB * ClosestU + + Triangle.NormalC * ClosestV).GetSafeNormal(); + if (ShadingNormal.IsNearlyZero()) + { + // The canonical mesher supplies non-zero gradient normals. Keep a defensive + // fallback for malformed mesh data without reintroducing camera-facing flips. + ShadingNormal = Triangle.Normal; + } + const float Key = FMath::Max( + 0.0f, FVector::DotProduct(ShadingNormal, KeyLightDirection)); + const float Fill = FMath::Max( + 0.0f, FVector::DotProduct(ShadingNormal, FillLightDirection)); + const float Brightness = FMath::Clamp( + AmbientFill + KeyLightStrength * Key + FillLightStrength * Fill, + 0.0f, + 1.25f); + const int32 PixelIndex = PixelX + Arguments.RenderWidth * PixelY; + OutPixels[PixelIndex] = FColor( + static_cast(FMath::Clamp(100.0f * Brightness, 0.0f, 255.0f)), + static_cast(FMath::Clamp(128.0f * Brightness, 0.0f, 255.0f)), + static_cast(FMath::Clamp(160.0f * Brightness, 0.0f, 255.0f)), + 255); + ++OutHitPixels; + } + } + return true; +} + bool RunRender( const FExploreArguments& Arguments, - const FExploreWorld& World, + FExploreWorld& World, const FExploreWalkOutput& CameraSeedWalk, - FExploreRenderOutput& OutOutput) + FExploreRenderOutput& OutOutput, + FExploreBudget& Budget) { OutOutput = FExploreRenderOutput(); OutOutput.Width = Arguments.RenderWidth; @@ -903,27 +1777,70 @@ bool RunRender( OutOutput.FixedStepVoxels = Arguments.RenderStepVoxels; OutOutput.FixedStepMeters = Arguments.RenderStepVoxels * FVoxelPlayerCapsuleConstants::VoxelSizeMeters; - OutOutput.BisectionIterations = 10; + OutOutput.BisectionIterations = 0; OutOutput.MaxDistanceVoxels = Arguments.RenderMaxDistanceVoxels; - OutOutput.EstimatedFixedDensitySamples = static_cast(Arguments.RenderWidth) + OutOutput.EstimatedFixedDensitySamples = 0; + OutOutput.LegacyTwoViewEstimatedDensitySamples = static_cast(Arguments.RenderWidth) * static_cast(Arguments.RenderHeight) * 3ll * static_cast(FMath::Max( 1, FMath::CeilToInt( - Arguments.RenderMaxDistanceVoxels / Arguments.RenderStepVoxels))); + Arguments.RenderMaxDistanceVoxels / Arguments.RenderStepVoxels))) + * 2ll; + OutOutput.RequestedViewpointCount = 8; OutOutput.StepRationale = TEXT( - "Density has no signed-distance/Lipschitz contract. The renderer therefore advances at a " - "fixed 0.25-voxel default step (override with -renderstep), detects density sign crossings, " - "and refines each crossing with 10 bisection iterations; it never sphere-traces."); + "The renderer meshes the bounded region once with UVoxelMarchingCubesMesher, then CPU " + "rasterises its canonical triangles through a deterministic BVH-assisted depth pass. " + "-renderstep is retained only for legacy invocation compatibility; no render pixel samples " + "the density field."); OutOutput.CameraSeedPolicy = TEXT( - "Every camera origin is a density-checked pose from the exact player-fit walk mask: " - "arrival, departure, last reached, and agent-final poses are considered in fixed order; " - "duplicate poses are removed. The target is offset toward the fitted route so the view is " - "from inside the walkable space."); + "Eight deterministic viewpoints are selected from the one shared player-fit mask. Their " + "eye-height origins are density-checked, and their azimuths are rotated in fixed 45-degree " + "increments so every image is from inside walkable space."); - TArray, TInlineAllocator<4>> CameraSeeds; + FString MeshError; + if (!EnsureExploreMesh(Arguments, World, &CameraSeedWalk, Budget, MeshError)) + { + OutOutput.MeshSeconds = World.ExploreMeshSeconds; + OutOutput.bMeshComplete = World.bExploreMeshComplete; + if (Budget.bTruncated) + { + OutOutput.Status = TEXT("truncated"); + OutOutput.bTruncated = true; + OutOutput.RefusalReason = MeshError; + } + else + { + OutOutput.Status = TEXT("error"); + OutOutput.RefusalReason = MeshError; + } + return false; + } + OutOutput.MeshSeconds = World.ExploreMeshSeconds; + OutOutput.bMeshComplete = true; + FString AccelerationError; + if (!EnsureExploreAcceleration(Arguments, World, Budget, AccelerationError)) + { + OutOutput.MeshAccelerationSeconds = World.ExploreAccelerationSeconds; + if (Budget.bTruncated) + { + OutOutput.Status = TEXT("truncated"); + OutOutput.bTruncated = true; + OutOutput.RefusalReason = AccelerationError; + } + else + { + OutOutput.Status = TEXT("error"); + OutOutput.RefusalReason = AccelerationError; + } + return false; + } + OutOutput.MeshAccelerationSeconds = World.ExploreAccelerationSeconds; + const double RasterStartSeconds = FPlatformTime::Seconds(); + + TArray, TInlineAllocator<8>> CameraSeeds; const auto AddCameraSeed = [&](bool bAvailable, const FVector& Position, const TCHAR* Source) { if (!bAvailable @@ -951,22 +1868,20 @@ bool RunRender( CameraSeeds.Emplace(Position, FString(Source)); }; - AddCameraSeed( - CameraSeedWalk.bHasArrival, - CameraSeedWalk.ArrivalVoxels, - TEXT("arrival_player_fit")); - AddCameraSeed( - CameraSeedWalk.bHasDeparture, - CameraSeedWalk.DepartureVoxels, - TEXT("departure_player_fit")); - AddCameraSeed( - CameraSeedWalk.Report.bHasLastReachedPosition, - CameraSeedWalk.Report.LastReachedVoxels, - TEXT("last_reached_player_fit")); - AddCameraSeed( - CameraSeedWalk.Report.bHasAgentFinalPosition, - CameraSeedWalk.Report.AgentFinalVoxels, - TEXT("agent_final_player_fit")); + for (int32 SeedIndex = 0; SeedIndex < CameraSeedWalk.CameraSeedPoses.Num(); ++SeedIndex) + { + const FString Source = CameraSeedWalk.CameraSeedSources.IsValidIndex(SeedIndex) + ? CameraSeedWalk.CameraSeedSources[SeedIndex] + : FString::Printf(TEXT("player_fit_%02d"), SeedIndex); + AddCameraSeed( + true, + CameraSeedWalk.CameraSeedPoses[SeedIndex], + *Source); + if (CameraSeeds.Num() >= OutOutput.RequestedViewpointCount) + { + break; + } + } if (CameraSeeds.Num() == 0) { @@ -976,6 +1891,17 @@ bool RunRender( return false; } + // A small room can contain fewer than eight distinct lattice cells. Reusing the same valid + // player-fit origin with another deterministic angle is still a valid viewpoint and keeps + // the contract (all camera origins are inside walkable space) without fabricating positions. + while (CameraSeeds.Num() < OutOutput.RequestedViewpointCount) + { + const TPair& Anchor = CameraSeeds[0]; + CameraSeeds.Emplace( + Anchor.Key, + FString::Printf(TEXT("%s_angle_%02d"), *Anchor.Value, CameraSeeds.Num())); + } + OutOutput.CameraSeedCount = CameraSeeds.Num(); OutOutput.bAllCamerasPlayerFit = true; FVector RouteFocus = FVector::ZeroVector; @@ -999,14 +1925,16 @@ bool RunRender( RouteFocus = CameraSeeds[0].Key; } - const FVector KeyLightDirection = FVector(-0.35f, -0.45f, 0.82f).GetSafeNormal(); - const FVector FillLightDirection = FVector(0.65f, 0.30f, 0.70f).GetSafeNormal(); - constexpr float AmbientFill = 0.62f; - constexpr float KeyLightStrength = 0.50f; - constexpr float FillLightStrength = 0.24f; - for (int32 ViewIndex = 0; ViewIndex < CameraSeeds.Num(); ++ViewIndex) { + if (Budget.ShouldStop(TEXT("render"))) + { + OutOutput.Status = TEXT("truncated"); + OutOutput.bTruncated = true; + OutOutput.RefusalReason = TEXT("The wall-clock budget stopped viewpoint generation."); + return false; + } + const FVector CameraSeedPose = CameraSeeds[ViewIndex].Key; const FVector CameraPosition = CameraSeedPose + FVector::UpVector * (FVoxelPlayerCapsuleConstants::HalfHeightVoxels - 0.5f); @@ -1029,8 +1957,31 @@ bool RunRender( ViewIndex == 2 ? 0.7f : -0.4f, 0.18f).GetSafeNormal(); } - // Keep the camera at the fitted pose, while aiming at roughly eye level in the route. + // Keep the camera at the fitted pose while changing the azimuth for each viewpoint. + LookDirection = LookDirection.RotateAngleAxis( + 45.0f * static_cast(ViewIndex), + FVector::UpVector).GetSafeNormal(); const FVector Target = CameraPosition + LookDirection * 96.0f + FVector::UpVector * 8.0f; + + const double ViewStartSeconds = FPlatformTime::Seconds(); + TArray Pixels; + int32 HitPixels = 0; + bool bRasterTruncated = false; + if (!RasterizeExploreMesh( + Arguments, + World, + CameraPosition, + Target, + Budget, + Pixels, + HitPixels, + bRasterTruncated)) + { + OutOutput.Status = TEXT("error"); + OutOutput.RefusalReason = TEXT("Could not construct a mesh raster camera."); + return false; + } + FExploreCameraBasis Basis; if (!BuildCameraBasis( CameraPosition, @@ -1045,55 +1996,6 @@ bool RunRender( return false; } - TArray Pixels; - Pixels.Init(FColor(12, 18, 28, 255), Arguments.RenderWidth * Arguments.RenderHeight); - int32 HitPixels = 0; - for (int32 PixelY = 0; PixelY < Arguments.RenderHeight; ++PixelY) - { - for (int32 PixelX = 0; PixelX < Arguments.RenderWidth; ++PixelX) - { - const FVector RayDirection = MakeRayDirection( - Basis, - PixelX, - PixelY, - Arguments.RenderWidth, - Arguments.RenderHeight); - FVector HitPoint; - FVector Normal; - if (!TraceDensityRay( - *World.Generator, - CameraPosition, - RayDirection, - Arguments.RenderStepVoxels, - Arguments.RenderMaxDistanceVoxels, - OutOutput.BisectionIterations, - HitPoint, - Normal)) - { - continue; - } - - ++HitPixels; - const float Key = FMath::Max( - 0.0f, FVector::DotProduct(Normal, KeyLightDirection)); - const float Fill = FMath::Max( - 0.0f, FVector::DotProduct(Normal, FillLightDirection)); - const float Brightness = FMath::Clamp( - AmbientFill + KeyLightStrength * Key + FillLightStrength * Fill, - 0.0f, 1.25f); - const uint8 RockR = static_cast(FMath::Clamp(100.0f * Brightness, 0.0f, 255.0f)); - const uint8 RockG = static_cast(FMath::Clamp(128.0f * Brightness, 0.0f, 255.0f)); - const uint8 RockB = static_cast(FMath::Clamp(160.0f * Brightness, 0.0f, 255.0f)); - PutPixel( - Pixels, - Arguments.RenderWidth, - Arguments.RenderHeight, - PixelX, - PixelY, - FColor(RockR, RockG, RockB, 255)); - } - } - bool bScaleMarkerProjected = false; DrawScaleMarker( Pixels, @@ -1113,6 +2015,7 @@ bool RunRender( Frame.TargetVoxels = Target; Frame.HitPixels = HitPixels; Frame.bScaleMarkerProjected = bScaleMarkerProjected; + Frame.bPartial = bRasterTruncated; FString Error; if (!SavePng( @@ -1126,10 +2029,34 @@ bool RunRender( OutOutput.RefusalReason = Error; return false; } + + const double ViewSeconds = FPlatformTime::Seconds() - ViewStartSeconds; + if (ViewIndex == 0) + { + OutOutput.FirstViewRasterSeconds = ViewSeconds; + } + + if (bRasterTruncated) + { + OutOutput.Status = TEXT("truncated"); + OutOutput.bTruncated = true; + OutOutput.RefusalReason = TEXT("The wall-clock budget stopped triangle rasterisation."); + break; + } } - OutOutput.Status = TEXT("ok"); - return true; + OutOutput.MeshSeconds = World.ExploreMeshSeconds; + OutOutput.RasterSeconds = FPlatformTime::Seconds() - RasterStartSeconds; + const int32 AdditionalViewCount = FMath::Max(0, OutOutput.Frames.Num() - 1); + OutOutput.AdditionalViewRasterSeconds = AdditionalViewCount > 0 + ? (OutOutput.RasterSeconds - OutOutput.FirstViewRasterSeconds) + / static_cast(AdditionalViewCount) + : 0.0; + if (!OutOutput.bTruncated) + { + OutOutput.Status = TEXT("ok"); + } + return !OutOutput.bTruncated; } void DrawPointMarker( @@ -1175,9 +2102,10 @@ void DrawPointMarker( bool RunFailureBoundaryRender( const FExploreArguments& Arguments, - const FExploreWorld& World, + FExploreWorld& World, const FExploreWalkOutput& Walk, - FExploreRenderOutput& InOutOutput) + FExploreRenderOutput& InOutOutput, + FExploreBudget& Budget) { const FVoxelPlayerFitWalkReport& Report = Walk.Report; if (!Report.bHasAgentFinalPosition || !Report.bHasTargetComponentNearestCell) @@ -1217,6 +2145,15 @@ bool RunFailureBoundaryRender( for (int32 ViewIndex = 0; ViewIndex < UE_ARRAY_COUNT(ViewTargets); ++ViewIndex) { + if (Budget.ShouldStop(TEXT("failure-focus-render"))) + { + InOutOutput.Status = TEXT("truncated"); + InOutOutput.bTruncated = true; + InOutOutput.RefusalReason = TEXT( + "The wall-clock budget stopped failure-focus viewpoint generation."); + return false; + } + const FVector CameraSeedPose = CameraCandidates[ViewIndex]; const FVector CameraPosition = CameraSeedPose + FVector::UpVector * (FVoxelPlayerCapsuleConstants::HalfHeightVoxels - 0.5f); @@ -1229,15 +2166,29 @@ bool RunFailureBoundaryRender( ViewIndex); return false; } - const float FailureMaxDistanceVoxels = FMath::Max( - Arguments.RenderMaxDistanceVoxels, - FMath::Max( - FVector::Dist(CameraPosition, AgentFinal), - FVector::Dist(CameraPosition, TargetComponentCell)) + 32.0f); + const FVector Target = ViewTargets[ViewIndex]; + TArray Pixels; + int32 HitPixels = 0; + bool bRasterTruncated = false; + if (!RasterizeExploreMesh( + Arguments, + World, + CameraPosition, + Target, + Budget, + Pixels, + HitPixels, + bRasterTruncated)) + { + InOutOutput.Status = TEXT("error"); + InOutOutput.RefusalReason = TEXT("Could not construct failure-focus mesh raster."); + return false; + } + FExploreCameraBasis Basis; if (!BuildCameraBasis( CameraPosition, - ViewTargets[ViewIndex], + Target, 70.0f, Arguments.RenderWidth, Arguments.RenderHeight, @@ -1246,60 +2197,6 @@ bool RunFailureBoundaryRender( return false; } - TArray Pixels; - Pixels.Init(FColor(12, 18, 28, 255), Arguments.RenderWidth * Arguments.RenderHeight); - int32 HitPixels = 0; - const FVector KeyLightDirection = FVector(-0.35f, -0.45f, 0.82f).GetSafeNormal(); - const FVector FillLightDirection = FVector(0.65f, 0.30f, 0.70f).GetSafeNormal(); - constexpr float AmbientFill = 0.62f; - constexpr float KeyLightStrength = 0.50f; - constexpr float FillLightStrength = 0.24f; - for (int32 PixelY = 0; PixelY < Arguments.RenderHeight; ++PixelY) - { - for (int32 PixelX = 0; PixelX < Arguments.RenderWidth; ++PixelX) - { - const FVector RayDirection = MakeRayDirection( - Basis, - PixelX, - PixelY, - Arguments.RenderWidth, - Arguments.RenderHeight); - FVector HitPoint; - FVector Normal; - if (!TraceDensityRay( - *World.Generator, - CameraPosition, - RayDirection, - Arguments.RenderStepVoxels, - FailureMaxDistanceVoxels, - InOutOutput.BisectionIterations, - HitPoint, - Normal)) - { - continue; - } - - ++HitPixels; - const float Key = FMath::Max( - 0.0f, FVector::DotProduct(Normal, KeyLightDirection)); - const float Fill = FMath::Max( - 0.0f, FVector::DotProduct(Normal, FillLightDirection)); - const float Brightness = FMath::Clamp( - AmbientFill + KeyLightStrength * Key + FillLightStrength * Fill, - 0.0f, 1.25f); - const uint8 RockR = static_cast(FMath::Clamp(100.0f * Brightness, 0.0f, 255.0f)); - const uint8 RockG = static_cast(FMath::Clamp(128.0f * Brightness, 0.0f, 255.0f)); - const uint8 RockB = static_cast(FMath::Clamp(160.0f * Brightness, 0.0f, 255.0f)); - PutPixel( - Pixels, - Arguments.RenderWidth, - Arguments.RenderHeight, - PixelX, - PixelY, - FColor(RockR, RockG, RockB, 255)); - } - } - bool bAgentProjected = false; bool bTargetProjected = false; bool bLastReachedProjected = false; @@ -1370,10 +2267,22 @@ bool RunFailureBoundaryRender( : TEXT("agent_final_player_fit"); Frame.CameraSeedPoseVoxels = CameraSeedPose; Frame.CameraVoxels = CameraPosition; - Frame.TargetVoxels = ViewTargets[ViewIndex]; + Frame.TargetVoxels = Target; Frame.HitPixels = HitPixels; Frame.bFailureMarkersProjected = bAgentProjected && bTargetProjected && (!bHaveLastReached || bLastReachedProjected); + Frame.bPartial = bRasterTruncated; + + bool bScaleMarkerProjected = false; + DrawScaleMarker( + Pixels, + Arguments.RenderWidth, + Arguments.RenderHeight, + CameraPosition, + Target, + Basis, + bScaleMarkerProjected); + Frame.bScaleMarkerProjected = bScaleMarkerProjected; FString Error; if (!SavePng( @@ -1387,6 +2296,15 @@ bool RunFailureBoundaryRender( InOutOutput.RefusalReason = Error; return false; } + + if (bRasterTruncated) + { + InOutOutput.Status = TEXT("truncated"); + InOutOutput.bTruncated = true; + InOutOutput.RefusalReason = TEXT( + "The wall-clock budget stopped failure-focus rasterisation."); + return false; + } } return true; @@ -1395,7 +2313,8 @@ bool RunFailureBoundaryRender( bool RunWalk( const FExploreArguments& Arguments, const FExploreWorld& World, - FExploreWalkOutput& OutOutput) + FExploreWalkOutput& OutOutput, + FExploreBudget& Budget) { OutOutput = FExploreWalkOutput(); OutOutput.WindowPolicy = NeedsOriginInWindow(Arguments.Archetype) @@ -1413,6 +2332,12 @@ bool RunWalk( static_cast(MaxWalkWorkingBytes)); return false; } + if (Budget.ShouldStop(TEXT("walk"))) + { + OutOutput.Status = TEXT("truncated"); + OutOutput.RefusalReason = TEXT("The wall-clock budget elapsed before walk sampling."); + return false; + } const FVoxelPassage* ArrivalPassage = nullptr; const FVoxelPassage* DeparturePassage = nullptr; @@ -1608,35 +2533,161 @@ bool RunWalk( OutOutput.DepartureVoxels, Settings, OutOutput.Report, - &LandingRoomProbePoints)) + &LandingRoomProbePoints, + &OutOutput.SharedSampleGrid)) { OutOutput.Status = TEXT("refused"); OutOutput.RefusalReason = OutOutput.Report.RefusalReason; return false; } - // Part C is a separate observation window. Keep the Part A mouth-sized result untouched, - // then repeat the same exact fit walk with the route window expanded to include (0,0). This - // is measurement-only: it changes neither the density field nor any generation parameter. - OutOutput.bOriginCheckAttempted = true; - FVoxelStrateMeasureSettings OriginSettings = Settings; - OriginSettings.bIncludeOriginInCoverWindow = true; - OriginSettings.bForceOriginColumnInCoverWindow = true; - if (VF_MeasurePlayerFitWalkWithSampler( - Sampler, - World.TargetBottomWorldZ, - World.TargetTopWorldZ, - World.TargetBoundarySealThickness, - OutOutput.ArrivalVoxels, - OutOutput.DepartureVoxels, - OriginSettings, - OutOutput.OriginCheckReport)) + // Camera seeds are selected from this exact captured mask. The first valid fit cell anchors + // the bounded render mesh; subsequent cells are kept local to that anchor so the mesh region + // contains every requested viewpoint rather than silently rendering a distant empty box. + OutOutput.CameraSeedPoses.Reset(); + OutOutput.CameraSeedSources.Reset(); + FVector CameraAnchor = FVector::ZeroVector; + bool bHaveCameraAnchor = false; + const auto AddCameraSeed = [&](const FVector& Position, const TCHAR* Source) { + if (!FMath::IsFinite(Position.X) || !FMath::IsFinite(Position.Y) + || !FMath::IsFinite(Position.Z) + || !OutOutput.SharedSampleGrid.ContainsPoint(Position)) + { + return; + } + const float Step = static_cast(OutOutput.SharedSampleGrid.SampleStep); + const int32 X = FMath::Clamp( + FMath::FloorToInt((Position.X - OutOutput.SharedSampleGrid.MinX) / Step), + 0, + OutOutput.SharedSampleGrid.NumX - 1); + const int32 Y = FMath::Clamp( + FMath::FloorToInt((Position.Y - OutOutput.SharedSampleGrid.MinY) / Step), + 0, + OutOutput.SharedSampleGrid.NumY - 1); + const int32 Z = FMath::Clamp( + FMath::FloorToInt((Position.Z - OutOutput.SharedSampleGrid.MinZ) / Step), + 0, + OutOutput.SharedSampleGrid.NumZ - 1); + const int32 Cell = OutOutput.SharedSampleGrid.Index(X, Y, Z); + if (!OutOutput.SharedSampleGrid.PlayerFitMask.IsValidIndex(Cell) + || OutOutput.SharedSampleGrid.PlayerFitMask[Cell] == 0u) + { + return; + } + if (!bHaveCameraAnchor) + { + CameraAnchor = Position; + bHaveCameraAnchor = true; + } + if (FVector::DistSquared(Position, CameraAnchor) > FMath::Square(40.0f)) + { + return; + } + for (const FVector& Existing : OutOutput.CameraSeedPoses) + { + if (Existing.Equals(Position, 0.001f)) + { + return; + } + } + OutOutput.CameraSeedPoses.Add(Position); + OutOutput.CameraSeedSources.Add( + Source != nullptr ? FString(Source) + : FString::Printf(TEXT("player_fit_%02d"), + OutOutput.CameraSeedPoses.Num() - 1)); + }; + AddCameraSeed(OutOutput.ArrivalVoxels, TEXT("arrival_player_fit")); + AddCameraSeed(OutOutput.DepartureVoxels, TEXT("departure_player_fit")); + if (OutOutput.Report.bHasLastReachedPosition) + { + AddCameraSeed( + OutOutput.Report.LastReachedVoxels, + TEXT("last_reached_player_fit")); + } + if (OutOutput.Report.bHasAgentFinalPosition) + { + AddCameraSeed( + OutOutput.Report.AgentFinalVoxels, + TEXT("agent_final_player_fit")); + } + if (!bHaveCameraAnchor) + { + for (int32 Cell = 0; + Cell < OutOutput.SharedSampleGrid.PlayerFitMask.Num(); + ++Cell) + { + if (OutOutput.SharedSampleGrid.PlayerFitMask[Cell] == 0u) + { + continue; + } + const int32 Plane = OutOutput.SharedSampleGrid.NumX + * OutOutput.SharedSampleGrid.NumY; + const int32 Z = Cell / Plane; + const int32 InPlane = Cell - Z * Plane; + const int32 Y = InPlane / OutOutput.SharedSampleGrid.NumX; + const int32 X = InPlane - Y * OutOutput.SharedSampleGrid.NumX; + AddCameraSeed( + FVector( + OutOutput.SharedSampleGrid.MinX + + (static_cast(X) + 0.5f) + * OutOutput.SharedSampleGrid.SampleStep, + OutOutput.SharedSampleGrid.MinY + + (static_cast(Y) + 0.5f) + * OutOutput.SharedSampleGrid.SampleStep, + OutOutput.SharedSampleGrid.MinZ + + (static_cast(Z) + 0.5f) + * OutOutput.SharedSampleGrid.SampleStep), + TEXT("player_fit_grid")); + if (bHaveCameraAnchor) + { + break; + } + } + } + if (bHaveCameraAnchor) + { + const int32 NumCandidates = OutOutput.SharedSampleGrid.PlayerFitMask.Num(); + for (int32 Cell = 0; Cell < NumCandidates && OutOutput.CameraSeedPoses.Num() < 8; ++Cell) + { + if (OutOutput.SharedSampleGrid.PlayerFitMask[Cell] == 0u) + { + continue; + } + const int32 Plane = OutOutput.SharedSampleGrid.NumX + * OutOutput.SharedSampleGrid.NumY; + const int32 Z = Cell / Plane; + const int32 InPlane = Cell - Z * Plane; + const int32 Y = InPlane / OutOutput.SharedSampleGrid.NumX; + const int32 X = InPlane - Y * OutOutput.SharedSampleGrid.NumX; + AddCameraSeed( + FVector( + OutOutput.SharedSampleGrid.MinX + + (static_cast(X) + 0.5f) + * OutOutput.SharedSampleGrid.SampleStep, + OutOutput.SharedSampleGrid.MinY + + (static_cast(Y) + 0.5f) + * OutOutput.SharedSampleGrid.SampleStep, + OutOutput.SharedSampleGrid.MinZ + + (static_cast(Z) + 0.5f) + * OutOutput.SharedSampleGrid.SampleStep), + TEXT("player_fit_grid")); + } + } + + // The origin observation is now a view over the shared primary grid. A second, expanded + // walk would defeat the one-grid contract; callers that need origin coverage must request an + // origin-inclusive archetype window, which is already selected before the single sample pass. + OutOutput.bOriginCheckAttempted = true; + if (OutOutput.Report.bOriginColumnInSampledWindow) + { + OutOutput.OriginCheckReport = OutOutput.Report; OutOutput.bOriginCheckAvailable = true; } else { - OutOutput.OriginCheckRefusalReason = OutOutput.OriginCheckReport.RefusalReason; + OutOutput.OriginCheckRefusalReason = TEXT( + "Origin is outside the shared primary sample window; no second grid was sampled."); } const int32 ArrivalProbeCount = ArrivalPassage != nullptr ? 5 : 0; const int32 DepartureProbeCount = DeparturePassage != nullptr ? 5 : 0; @@ -1665,6 +2716,12 @@ bool RunWalk( bInArrivalComponent ? 1 : 0; } } + if (Budget.ShouldStop(TEXT("walk"))) + { + OutOutput.Status = TEXT("truncated"); + OutOutput.RefusalReason = TEXT("The wall-clock budget elapsed after walk sampling."); + return false; + } OutOutput.Status = TEXT("ok"); return true; } @@ -1677,9 +2734,10 @@ int64 EstimateExportWorkingBytes(int32 RegionSize) const int64 TileGridSamples = static_cast(CHUNK_SIZE + 3) * static_cast(CHUNK_SIZE + 3) * static_cast(CHUNK_SIZE + 3); - // Export streams one canonical tile at a time. This preflight covers one mesher tile's scalar - // grid, edge map, mesh arrays, and temporary triangle lists; the region's OBJ is not retained - // in memory. RegionSize is still accepted here to keep the call site's contract explicit. + // This preflight covers one canonical tile's scalar grid, edge map, mesh arrays, and temporary + // triangle lists. The run-level canonical aggregate is retained separately so render and + // export consume the same mesh. RegionSize is still accepted here to keep the call site's + // contract explicit. (void)RegionSize; return TileGridSamples * static_cast(sizeof(float)) + TileCells * 448ll; } @@ -1707,6 +2765,224 @@ bool ValidateCanonicalTile(const FVoxelMeshData& MeshData, FString& OutError) return true; } +bool ExploreMetricVectorIsFinite(const FVector& Value) +{ + return FMath::IsFinite(Value.X) && FMath::IsFinite(Value.Y) && FMath::IsFinite(Value.Z); +} + +FIntVector ExploreMetricWeldPosition(const FVector& PositionCm) +{ + // Mesh positions are Unreal centimetres. 0.01 cm is deliberately much smaller than a + // marching-cubes cell and only absorbs independent-tile floating-point round-off. + constexpr float WeldQuantumCm = 0.01f; + return FIntVector( + FMath::RoundToInt(PositionCm.X / WeldQuantumCm), + FMath::RoundToInt(PositionCm.Y / WeldQuantumCm), + FMath::RoundToInt(PositionCm.Z / WeldQuantumCm)); +} + +FExploreMeshEdgeKey ExploreMetricMakeEdgeKey( + const FVector& PositionA, + const FVector& PositionB) +{ + const FIntVector A = ExploreMetricWeldPosition(PositionA); + const FIntVector B = ExploreMetricWeldPosition(PositionB); + const bool bAFirst = A.X < B.X + || (A.X == B.X && (A.Y < B.Y || (A.Y == B.Y && A.Z <= B.Z))); + return bAFirst + ? FExploreMeshEdgeKey{A, B} + : FExploreMeshEdgeKey{B, A}; +} + +float ExploreMetricPercentile(const TArray& SortedValues, float Quantile) +{ + if (SortedValues.Num() == 0) + { + return 0.0f; + } + const float Position = FMath::Clamp(Quantile, 0.0f, 1.0f) + * static_cast(SortedValues.Num() - 1); + const int32 Lower = FMath::FloorToInt(Position); + const int32 Upper = FMath::Min(Lower + 1, SortedValues.Num() - 1); + return FMath::Lerp(SortedValues[Lower], SortedValues[Upper], Position - static_cast(Lower)); +} + +bool MeasureExploreMesh( + const FVoxelMeshData& MeshData, + FExploreMeshMetrics& OutMetrics, + FString& OutError) +{ + OutMetrics = FExploreMeshMetrics(); + if (!ValidateCanonicalTile(MeshData, OutError)) + { + return false; + } + + const int32 NumTriangles = MeshData.Triangles.Num() / 3; + OutMetrics.VertexCount = MeshData.Vertices.Num(); + OutMetrics.TriangleCount = NumTriangles; + + TArray EdgeLengthsMeters; + TArray DihedralAnglesDegrees; + TArray FaceNormalDivergencesDegrees; + EdgeLengthsMeters.Reserve(NumTriangles * 3); + DihedralAnglesDegrees.Reserve(NumTriangles * 2); + FaceNormalDivergencesDegrees.Reserve(NumTriangles); + + TArray FaceNormals; + FaceNormals.Init(FVector::ZeroVector, NumTriangles); + TArray ValidFaceNormals; + ValidFaceNormals.Init(0, NumTriangles); + TMap EdgeIncidents; + EdgeIncidents.Reserve(FMath::Max(1, NumTriangles * 2)); + + const auto AddEdgeIncident = [&EdgeIncidents]( + const FVector& PositionA, const FVector& PositionB, int32 TriangleIndex) + { + const FExploreMeshEdgeKey Key = ExploreMetricMakeEdgeKey(PositionA, PositionB); + if (Key.First == Key.Second) + { + return; + } + FExploreMeshEdgeIncident& Incident = EdgeIncidents.FindOrAdd(Key); + if (Incident.IncidentCount == 0) + { + Incident.FirstTriangle = TriangleIndex; + } + else if (Incident.IncidentCount == 1) + { + Incident.SecondTriangle = TriangleIndex; + } + Incident.IncidentCount = FMath::Min(Incident.IncidentCount + 1, 3); + }; + + for (int32 TriangleIndex = 0; TriangleIndex < NumTriangles; ++TriangleIndex) + { + const int32 Base = TriangleIndex * 3; + const int32 IndexA = MeshData.Triangles[Base]; + const int32 IndexB = MeshData.Triangles[Base + 1]; + const int32 IndexC = MeshData.Triangles[Base + 2]; + if (!MeshData.Vertices.IsValidIndex(IndexA) + || !MeshData.Vertices.IsValidIndex(IndexB) + || !MeshData.Vertices.IsValidIndex(IndexC) + || !MeshData.Normals.IsValidIndex(IndexA) + || !MeshData.Normals.IsValidIndex(IndexB) + || !MeshData.Normals.IsValidIndex(IndexC)) + { + OutError = TEXT("Surface metric measurement saw an out-of-range mesh index."); + return false; + } + + const FVector& A = MeshData.Vertices[IndexA]; + const FVector& B = MeshData.Vertices[IndexB]; + const FVector& C = MeshData.Vertices[IndexC]; + if (!ExploreMetricVectorIsFinite(A) + || !ExploreMetricVectorIsFinite(B) + || !ExploreMetricVectorIsFinite(C)) + { + OutError = TEXT("Surface metric measurement saw a non-finite vertex."); + return false; + } + + EdgeLengthsMeters.Add(FVector::Dist(A, B) * 0.01f); + EdgeLengthsMeters.Add(FVector::Dist(B, C) * 0.01f); + EdgeLengthsMeters.Add(FVector::Dist(C, A) * 0.01f); + + const FVector Cross = FVector::CrossProduct(B - A, C - A); + if (Cross.SizeSquared() > SMALL_NUMBER) + { + const FVector MeanVertexNormal = ( + MeshData.Normals[IndexA] + + MeshData.Normals[IndexB] + + MeshData.Normals[IndexC]).GetSafeNormal(); + if (ExploreMetricVectorIsFinite(MeanVertexNormal) + && !MeanVertexNormal.IsNearlyZero()) + { + // The canonical marching-cubes table is emitted with the winding required by + // the runtime mesh path, while the density-gradient normals point solid-to-air. + // Orient the geometric face normal to that same outward convention before + // measuring the relief divergence; otherwise a winding convention change would + // report ~180 degrees for an otherwise smooth surface. + FVector FaceNormal = Cross.GetSafeNormal(); + if (FVector::DotProduct(FaceNormal, MeanVertexNormal) < 0.0f) + { + FaceNormal *= -1.0f; + } + FaceNormals[TriangleIndex] = FaceNormal; + ValidFaceNormals[TriangleIndex] = 1; + const float Dot = FMath::Clamp( + FVector::DotProduct(FaceNormal, MeanVertexNormal), -1.0f, 1.0f); + FaceNormalDivergencesDegrees.Add( + FMath::RadiansToDegrees(FMath::Acos(Dot))); + ++OutMetrics.NormalComparisonTriangleCount; + } + } + + AddEdgeIncident(A, B, TriangleIndex); + AddEdgeIncident(B, C, TriangleIndex); + AddEdgeIncident(C, A, TriangleIndex); + } + + for (const TPair& Pair : EdgeIncidents) + { + const FExploreMeshEdgeIncident& Incident = Pair.Value; + if (Incident.IncidentCount != 2) + { + continue; + } + ++OutMetrics.SharedEdgeCount; + if (!ValidFaceNormals.IsValidIndex(Incident.FirstTriangle) + || !ValidFaceNormals.IsValidIndex(Incident.SecondTriangle) + || !ValidFaceNormals[Incident.FirstTriangle] + || !ValidFaceNormals[Incident.SecondTriangle]) + { + continue; + } + const float Dot = FMath::Clamp( + FVector::DotProduct( + FaceNormals[Incident.FirstTriangle], + FaceNormals[Incident.SecondTriangle]), + -1.0f, + 1.0f); + DihedralAnglesDegrees.Add(FMath::RadiansToDegrees(FMath::Acos(Dot))); + } + + EdgeLengthsMeters.Sort(); + DihedralAnglesDegrees.Sort(); + FaceNormalDivergencesDegrees.Sort(); + + OutMetrics.DihedralSampleCount = DihedralAnglesDegrees.Num(); + OutMetrics.EdgeLengthP50Meters = ExploreMetricPercentile(EdgeLengthsMeters, 0.50f); + OutMetrics.EdgeLengthP99Meters = ExploreMetricPercentile(EdgeLengthsMeters, 0.99f); + OutMetrics.EdgeLengthMaxMeters = EdgeLengthsMeters.Num() > 0 + ? EdgeLengthsMeters.Last() : 0.0f; + OutMetrics.DihedralP50Degrees = ExploreMetricPercentile(DihedralAnglesDegrees, 0.50f); + OutMetrics.DihedralP75Degrees = ExploreMetricPercentile(DihedralAnglesDegrees, 0.75f); + OutMetrics.DihedralP90Degrees = ExploreMetricPercentile(DihedralAnglesDegrees, 0.90f); + OutMetrics.DihedralP95Degrees = ExploreMetricPercentile(DihedralAnglesDegrees, 0.95f); + OutMetrics.DihedralP99Degrees = ExploreMetricPercentile(DihedralAnglesDegrees, 0.99f); + if (DihedralAnglesDegrees.Num() > 0) + { + int32 Over20 = 0; + int32 Over40 = 0; + for (const float Angle : DihedralAnglesDegrees) + { + Over20 += Angle > 20.0f ? 1 : 0; + Over40 += Angle > 40.0f ? 1 : 0; + } + OutMetrics.DihedralFractionOver20Degrees = static_cast(Over20) + / static_cast(DihedralAnglesDegrees.Num()); + OutMetrics.DihedralFractionOver40Degrees = static_cast(Over40) + / static_cast(DihedralAnglesDegrees.Num()); + } + OutMetrics.FaceNormalVsMeanVertexNormalP50Degrees = + ExploreMetricPercentile(FaceNormalDivergencesDegrees, 0.50f); + OutMetrics.FaceNormalVsMeanVertexNormalP90Degrees = + ExploreMetricPercentile(FaceNormalDivergencesDegrees, 0.90f); + OutError.Reset(); + return true; +} + bool WriteObjHeader(FArchive& Archive) { bool bOk = WriteUtf8(Archive, TEXT("# VoxelForge authoritative marching-cubes mesh\n")); @@ -1786,6 +3062,101 @@ bool WriteObjTile( return true; } +bool WriteObjMesh( + FArchive& Archive, + const FVoxelMeshData& MeshData, + FExploreBudget& Budget, + FString& OutError, + bool& bOutTruncated, + int32& OutVerticesWritten, + int32& OutTrianglesWritten) +{ + bOutTruncated = false; + OutVerticesWritten = 0; + OutTrianglesWritten = 0; + if (!ValidateCanonicalTile(MeshData, OutError)) + { + return false; + } + + bool bOk = true; + for (int32 VertexIndex = 0; VertexIndex < MeshData.Vertices.Num(); ++VertexIndex) + { + if ((VertexIndex & 255) == 0 && Budget.ShouldStop(TEXT("export-write"))) + { + bOutTruncated = true; + return true; + } + const FVector& Vertex = MeshData.Vertices[VertexIndex]; + bOk = bOk && WriteUtf8(Archive, FString::Printf( + TEXT("v %.9f %.9f %.9f\n"), + Vertex.X / 100.0, + Vertex.Y / 100.0, + Vertex.Z / 100.0)); + ++OutVerticesWritten; + } + for (int32 UVIndex = 0; UVIndex < MeshData.UVs.Num(); ++UVIndex) + { + if ((UVIndex & 255) == 0 && Budget.ShouldStop(TEXT("export-write"))) + { + bOutTruncated = true; + return true; + } + const FVector2D& UV = MeshData.UVs[UVIndex]; + bOk = bOk && WriteUtf8(Archive, FString::Printf( + TEXT("vt %.9f %.9f\n"), UV.X, UV.Y)); + } + for (int32 NormalIndex = 0; NormalIndex < MeshData.Normals.Num(); ++NormalIndex) + { + if ((NormalIndex & 255) == 0 && Budget.ShouldStop(TEXT("export-write"))) + { + bOutTruncated = true; + return true; + } + const FVector& Normal = MeshData.Normals[NormalIndex]; + bOk = bOk && WriteUtf8(Archive, FString::Printf( + TEXT("vn %.9f %.9f %.9f\n"), Normal.X, Normal.Y, Normal.Z)); + } + + const int32 NumTriangles = MeshData.Triangles.Num() / 3; + if (NumTriangles > 0) + { + bOk = bOk && WriteUtf8(Archive, TEXT("usemtl VoxelGround\n")); + } + for (int32 Triangle = 0; Triangle < NumTriangles; ++Triangle) + { + if ((Triangle & 255) == 0 && Budget.ShouldStop(TEXT("export-write"))) + { + bOutTruncated = true; + return true; + } + const int32 Base = Triangle * 3; + const int32 A = MeshData.Triangles[Base]; + const int32 B = MeshData.Triangles[Base + 1]; + const int32 C = MeshData.Triangles[Base + 2]; + if (A < 0 || B < 0 || C < 0 + || A >= MeshData.Vertices.Num() + || B >= MeshData.Vertices.Num() + || C >= MeshData.Vertices.Num()) + { + OutError = TEXT("Canonical aggregate mesh returned an out-of-range triangle index."); + return false; + } + bOk = bOk && WriteUtf8(Archive, FString::Printf( + TEXT("f %d/%d/%d %d/%d/%d %d/%d/%d\n"), + A + 1, A + 1, A + 1, + B + 1, B + 1, B + 1, + C + 1, C + 1, C + 1)); + ++OutTrianglesWritten; + } + if (!bOk || Archive.IsError()) + { + OutError = TEXT("Could not write the canonical aggregate OBJ."); + return false; + } + return true; +} + void WriteJsonVector(FExploreJsonWriter& Writer, const TCHAR* Key, const FVector& Value, float Scale) { Writer.WriteObjectStart(Key); @@ -1827,6 +3198,42 @@ void WriteJsonBounds( Writer.WriteObjectEnd(); } +void WriteSurfaceReliefMetrics( + FExploreJsonWriter& Writer, + const FExploreMeshMetrics& Metrics) +{ + Writer.WriteObjectStart(TEXT("surface_relief_metrics")); + Writer.WriteValue(TEXT("vertex_count"), Metrics.VertexCount); + Writer.WriteValue(TEXT("triangle_count"), Metrics.TriangleCount); + Writer.WriteValue(TEXT("shared_edge_count"), Metrics.SharedEdgeCount); + Writer.WriteValue(TEXT("dihedral_sample_count"), Metrics.DihedralSampleCount); + Writer.WriteValue(TEXT("normal_comparison_triangle_count"), Metrics.NormalComparisonTriangleCount); + Writer.WriteValue(TEXT("vertex_weld_quantum_cm"), 0.01); + Writer.WriteObjectStart(TEXT("triangle_edge_length_m")); + Writer.WriteValue(TEXT("p50"), static_cast(Metrics.EdgeLengthP50Meters)); + Writer.WriteValue(TEXT("p99"), static_cast(Metrics.EdgeLengthP99Meters)); + Writer.WriteValue(TEXT("max"), static_cast(Metrics.EdgeLengthMaxMeters)); + Writer.WriteObjectEnd(); + Writer.WriteObjectStart(TEXT("dihedral_angle_degrees")); + Writer.WriteValue(TEXT("p50"), static_cast(Metrics.DihedralP50Degrees)); + Writer.WriteValue(TEXT("p75"), static_cast(Metrics.DihedralP75Degrees)); + Writer.WriteValue(TEXT("p90"), static_cast(Metrics.DihedralP90Degrees)); + Writer.WriteValue(TEXT("p95"), static_cast(Metrics.DihedralP95Degrees)); + Writer.WriteValue(TEXT("p99"), static_cast(Metrics.DihedralP99Degrees)); + Writer.WriteValue(TEXT("fraction_over_20_degrees"), + static_cast(Metrics.DihedralFractionOver20Degrees)); + Writer.WriteValue(TEXT("fraction_over_40_degrees"), + static_cast(Metrics.DihedralFractionOver40Degrees)); + Writer.WriteObjectEnd(); + Writer.WriteObjectStart(TEXT("face_normal_vs_mean_vertex_normal_degrees")); + Writer.WriteValue(TEXT("p50"), + static_cast(Metrics.FaceNormalVsMeanVertexNormalP50Degrees)); + Writer.WriteValue(TEXT("p90"), + static_cast(Metrics.FaceNormalVsMeanVertexNormalP90Degrees)); + Writer.WriteObjectEnd(); + Writer.WriteObjectEnd(); +} + FString BuildManifestJson( const FExploreArguments& Arguments, const FExploreExportOutput& Export) @@ -1835,7 +3242,7 @@ FString BuildManifestJson( TSharedRef Writer = TJsonWriterFactory>::Create(&Json); Writer->WriteObjectStart(); - Writer->WriteValue(TEXT("schema_version"), 2); + Writer->WriteValue(TEXT("schema_version"), 3); Writer->WriteValue(TEXT("format"), TEXT("OBJ")); Writer->WriteValue(TEXT("mesh_file"), Export.MeshFileName); Writer->WriteValue(TEXT("mesh_positions_units"), TEXT("metres")); @@ -1847,13 +3254,23 @@ FString BuildManifestJson( Writer->WriteValue(TEXT("skirts"), false); Writer->WriteValue(TEXT("vertex_count"), Export.VertexCount); Writer->WriteValue(TEXT("triangle_count"), Export.TriangleCount); + WriteSurfaceReliefMetrics(*Writer, Export.SurfaceMetrics); Writer->WriteValue(TEXT("mesh_file_size_bytes"), Export.MeshFileSizeBytes); Writer->WriteValue(TEXT("mesh_array_bytes_sum_over_tiles"), Export.MeshArrayBytes); Writer->WriteValue(TEXT("canonical_mesher_tile_cells"), CHUNK_SIZE); Writer->WriteValue(TEXT("canonical_mesher_tile_count"), Export.MesherTileCount); + Writer->WriteValue(TEXT("canonical_mesher_tiles_completed"), Export.MesherTilesCompleted); + Writer->WriteValue(TEXT("mesh_seconds"), Export.MeshSeconds); + Writer->WriteValue(TEXT("truncated"), Export.bTruncated); Writer->WriteValue(TEXT("seed"), Arguments.Seed); Writer->WriteValue(TEXT("archetype"), ArchetypeName(Arguments.Archetype)); Writer->WriteValue(TEXT("slot"), Arguments.Slot); + Writer->WriteValue(TEXT("surface_roughness_override"), Arguments.bSurfaceRoughnessOverride); + if (Arguments.bSurfaceRoughnessOverride) + { + Writer->WriteValue(TEXT("surface_roughness"), + static_cast(Arguments.SurfaceRoughness)); + } WriteJsonIntVector(*Writer, TEXT("region_origin_voxels"), Export.RegionOrigin); WriteJsonBounds(*Writer, TEXT("region_bounds_voxels"), Export.RegionOrigin, Export.RegionSize, 0.25f); Writer->WriteObjectStart(TEXT("region_bounds_metres")); @@ -1888,18 +3305,12 @@ FString BuildManifestJson( bool RunExport( const FExploreArguments& Arguments, FExploreWorld& World, - FExploreExportOutput& OutOutput) + const FExploreWalkOutput* SharedSampling, + FExploreExportOutput& OutOutput, + FExploreBudget& Budget) { OutOutput = FExploreExportOutput(); OutOutput.RegionSize = Arguments.ExportSize; - const int32 TilesPerAxis = Arguments.ExportSize / CHUNK_SIZE; - OutOutput.MesherTileCount = TilesPerAxis * TilesPerAxis * TilesPerAxis; - const int32 TargetMiddleZ = FMath::FloorToInt( - 0.5f * static_cast(World.TargetBottomWorldZ + World.TargetTopWorldZ)); - OutOutput.RegionOrigin = FIntVector( - -Arguments.ExportSize / 2, - -Arguments.ExportSize / 2, - TargetMiddleZ - Arguments.ExportSize / 2); OutOutput.EstimatedWorkingBytes = EstimateExportWorkingBytes(Arguments.ExportSize); if (OutOutput.EstimatedWorkingBytes > OutOutput.WorkingMemoryCapBytes) { @@ -1911,10 +3322,20 @@ bool RunExport( return false; } - // Skirts are a tile-rendering seam aid and extend beyond a bounded export box. The mesh is - // still generated by the canonical mesher; disabling only that presentation option keeps the - // browser asset's geometry inside the manifest bounds. - World.Mesher->bGenerateSkirts = false; + FString MeshError; + const bool bMeshComplete = EnsureExploreMesh( + Arguments, World, SharedSampling, Budget, MeshError); + if (!bMeshComplete && !Budget.bTruncated) + { + OutOutput.Status = TEXT("error"); + OutOutput.RefusalReason = MeshError; + return false; + } + OutOutput.RegionSize = World.ExploreMeshSize; + OutOutput.MesherTileCount = World.ExploreMeshTileCount; + OutOutput.MesherTilesCompleted = World.ExploreMeshTilesCompleted; + OutOutput.RegionOrigin = World.ExploreMeshOrigin; + FString Error; OutOutput.MeshFileName = TEXT("geometry.obj"); OutOutput.ManifestFileName = TEXT("manifest.json"); @@ -1933,36 +3354,35 @@ bool RunExport( return false; } - int32 VertexOffset = 0; - for (int32 TileZ = 0; TileZ < TilesPerAxis; ++TileZ) + if (!MeasureExploreMesh(World.ExploreMesh, OutOutput.SurfaceMetrics, Error)) { - for (int32 TileY = 0; TileY < TilesPerAxis; ++TileY) - { - for (int32 TileX = 0; TileX < TilesPerAxis; ++TileX) - { - const FIntVector TileOrigin = OutOutput.RegionOrigin - + FIntVector(TileX * CHUNK_SIZE, TileY * CHUNK_SIZE, TileZ * CHUNK_SIZE); - const FVoxelMeshData TileMesh = World.Mesher->GenerateMesh( - TileOrigin, - 1, - CHUNK_SIZE); - if (!WriteObjTile(*Archive, TileMesh, VertexOffset, Error)) - { - OutOutput.Status = TEXT("error"); - OutOutput.RefusalReason = Error; - return false; - } - const int64 TileArrayBytes = static_cast(TileMesh.Vertices.Num()) * sizeof(FVector) - + static_cast(TileMesh.Normals.Num()) * sizeof(FVector) - + static_cast(TileMesh.UVs.Num()) * sizeof(FVector2D) - + static_cast(TileMesh.Colors.Num()) * sizeof(FColor) - + static_cast(TileMesh.Triangles.Num()) * sizeof(int32); - OutOutput.MeshArrayBytes += TileArrayBytes; - OutOutput.VertexCount += TileMesh.Vertices.Num(); - OutOutput.TriangleCount += TileMesh.Triangles.Num() / 3; - VertexOffset += TileMesh.Vertices.Num(); - } - } + OutOutput.Status = TEXT("error"); + OutOutput.RefusalReason = Error; + return false; + } + OutOutput.MeshArrayBytes = static_cast(World.ExploreMesh.Vertices.Num()) * sizeof(FVector) + + static_cast(World.ExploreMesh.Normals.Num()) * sizeof(FVector) + + static_cast(World.ExploreMesh.UVs.Num()) * sizeof(FVector2D) + + static_cast(World.ExploreMesh.Colors.Num()) * sizeof(FColor) + + static_cast(World.ExploreMesh.Triangles.Num()) * sizeof(int32); + OutOutput.VertexCount = World.ExploreMesh.Vertices.Num(); + OutOutput.TriangleCount = World.ExploreMesh.Triangles.Num() / 3; + OutOutput.MeshSeconds = World.ExploreMeshSeconds; + bool bObjWriteTruncated = false; + int32 VerticesWritten = 0; + int32 TrianglesWritten = 0; + if (!WriteObjMesh( + *Archive, + World.ExploreMesh, + Budget, + Error, + bObjWriteTruncated, + VerticesWritten, + TrianglesWritten)) + { + OutOutput.Status = TEXT("error"); + OutOutput.RefusalReason = Error; + return false; } if (Archive->IsError()) { @@ -1979,6 +3399,19 @@ bool RunExport( return false; } + OutOutput.bTruncated = Budget.bTruncated + || !World.bExploreMeshComplete + || bObjWriteTruncated; + if (OutOutput.bTruncated) + { + OutOutput.VertexCount = VerticesWritten; + OutOutput.TriangleCount = TrianglesWritten; + OutOutput.Status = TEXT("truncated"); + OutOutput.RefusalReason = !World.bExploreMeshComplete + ? MeshError + : TEXT("The wall-clock budget stopped export after writing a partial mesh."); + } + const FString ManifestJson = BuildManifestJson(Arguments, OutOutput); const FString ManifestJsonRepeat = BuildManifestJson(Arguments, OutOutput); if (ManifestJson.IsEmpty() || ManifestJsonRepeat.IsEmpty()) @@ -2002,8 +3435,11 @@ bool RunExport( OutOutput.RefusalReason = TEXT("Could not write export manifest.json."); return false; } - OutOutput.Status = TEXT("ok"); - return true; + if (!OutOutput.bTruncated) + { + OutOutput.Status = TEXT("ok"); + } + return !OutOutput.bTruncated; } void WritePlayerDimensions(FExploreJsonWriter& Writer, const TCHAR* Key) @@ -2029,7 +3465,7 @@ FString BuildExploreJson( TSharedRef Writer = TJsonWriterFactory>::Create(&Json); Writer->WriteObjectStart(); - Writer->WriteValue(TEXT("schema_version"), 2); + Writer->WriteValue(TEXT("schema_version"), 3); Writer->WriteValue(TEXT("tool"), TEXT("VoxelForgeExplore")); Writer->WriteValue(TEXT("read_only_generation"), true); @@ -2037,12 +3473,19 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("seed"), Arguments.Seed); Writer->WriteValue(TEXT("archetype"), ArchetypeName(Arguments.Archetype)); Writer->WriteValue(TEXT("slot"), Arguments.Slot); + Writer->WriteValue(TEXT("surface_roughness_override"), Arguments.bSurfaceRoughnessOverride); + if (Arguments.bSurfaceRoughnessOverride) + { + Writer->WriteValue(TEXT("surface_roughness"), + static_cast(Arguments.SurfaceRoughness)); + } Writer->WriteArrayStart(TEXT("modes")); if (Arguments.bRender) Writer->WriteValue(TEXT("render")); if (Arguments.bWalk) Writer->WriteValue(TEXT("walk")); if (Arguments.bExport) Writer->WriteValue(TEXT("export")); Writer->WriteArrayEnd(); Writer->WriteValue(TEXT("failure_focus_render"), Arguments.bFailureFocusRender); + Writer->WriteValue(TEXT("budget_minutes"), static_cast(Arguments.BudgetMinutes)); Writer->WriteValue(TEXT("out_directory"), Arguments.OutDirectory); Writer->WriteValue(TEXT("canonical_invocation"), FString::Printf( TEXT("UnrealEditor-Cmd VoxelM.uproject -run=VoxelForgeExplore -seed=%d -archetype=%s " @@ -2053,6 +3496,41 @@ FString BuildExploreJson( *Arguments.CanonicalModes())); Writer->WriteObjectEnd(); + Writer->WriteObjectStart(TEXT("run")); + Writer->WriteValue(TEXT("budget_seconds"), Output.BudgetSeconds); + Writer->WriteValue(TEXT("elapsed_seconds"), Output.ElapsedSeconds); + Writer->WriteValue(TEXT("truncated"), Output.bTruncated); + Writer->WriteValue(TEXT("truncated_during"), Output.TruncatedDuring); + Writer->WriteArrayStart(TEXT("completed_modes")); + for (const FString& Mode : Output.CompletedModes) + { + Writer->WriteValue(Mode); + } + Writer->WriteArrayEnd(); + Writer->WriteObjectEnd(); + + Writer->WriteObjectStart(TEXT("shared_sampling")); + const FVoxelStrateSampleGrid* SharedGrid = nullptr; + if (Arguments.bWalk || Arguments.bRender) + { + SharedGrid = &Output.Walk.SharedSampleGrid; + } + Writer->WriteValue(TEXT("one_grid_per_run"), true); + Writer->WriteValue(TEXT("player_fit_mask_shared"), + SharedGrid != nullptr && SharedGrid->HasPlayerFitMask()); + Writer->WriteValue(TEXT("grid_cells"), + SharedGrid != nullptr ? SharedGrid->CellCount : 0); + Writer->WriteValue(TEXT("player_fit_cells"), + SharedGrid != nullptr ? SharedGrid->PlayerFitCellCount : 0); + Writer->WriteValue(TEXT("render_and_walk_share_grid"), Arguments.bRender && Arguments.bWalk); + Writer->WriteValue(TEXT("export_uses_grid"), + Arguments.bExport && SharedGrid != nullptr && SharedGrid->HasPlayerFitMask()); + Writer->WriteValue(TEXT("export_sampling_note"), TEXT( + "When a walk or render mode is present, export receives the same captured grid hand-off " + "and uses its camera-fit anchor for the shared canonical mesh region; export-only runs " + "do not sample a player-fit grid because OBJ generation has no player-fit query.")); + Writer->WriteObjectEnd(); + Writer->WriteObjectStart(TEXT("world")); Writer->WriteValue(TEXT("voxel_size_m"), 0.25); Writer->WriteValue(TEXT("world_radius_voxels"), 0); @@ -2088,6 +3566,20 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("camera_seed_policy"), Output.Render.CameraSeedPolicy); Writer->WriteValue(TEXT("camera_seed_count"), Output.Render.CameraSeedCount); Writer->WriteValue(TEXT("all_cameras_player_fit"), Output.Render.bAllCamerasPlayerFit); + Writer->WriteValue(TEXT("requested_viewpoint_count"), Output.Render.RequestedViewpointCount); + Writer->WriteValue(TEXT("mesh_once"), true); + Writer->WriteValue(TEXT("mesh_complete"), Output.Render.bMeshComplete); + Writer->WriteValue(TEXT("mesh_seconds"), Output.Render.MeshSeconds); + Writer->WriteValue(TEXT("mesh_acceleration_seconds"), Output.Render.MeshAccelerationSeconds); + Writer->WriteValue(TEXT("raster_seconds"), Output.Render.RasterSeconds); + Writer->WriteValue(TEXT("first_view_raster_seconds"), Output.Render.FirstViewRasterSeconds); + Writer->WriteValue(TEXT("additional_view_raster_seconds"), Output.Render.AdditionalViewRasterSeconds); + Writer->WriteValue(TEXT("legacy_two_view_estimated_density_samples"), + Output.Render.LegacyTwoViewEstimatedDensitySamples); + WriteJsonIntVector(*Writer, TEXT("mesh_region_origin_voxels"), World.ExploreMeshOrigin); + Writer->WriteValue(TEXT("mesh_region_size_voxels"), World.ExploreMeshSize); + Writer->WriteValue(TEXT("mesh_vertex_count"), World.ExploreMesh.Vertices.Num()); + Writer->WriteValue(TEXT("mesh_triangle_count"), World.ExploreMesh.Triangles.Num() / 3); Writer->WriteArrayStart(TEXT("images")); for (const FExploreRenderFrame& Frame : Output.Render.Frames) { @@ -2100,6 +3592,7 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("hit_pixels"), Frame.HitPixels); Writer->WriteValue(TEXT("scale_marker_projected"), Frame.bScaleMarkerProjected); Writer->WriteValue(TEXT("failure_markers_projected"), Frame.bFailureMarkersProjected); + Writer->WriteValue(TEXT("partial"), Frame.bPartial); Writer->WriteValue(TEXT("scale_marker_height_m"), 1.76); Writer->WriteObjectEnd(); } @@ -2110,9 +3603,8 @@ FString BuildExploreJson( if (Arguments.bWalk) { const FVoxelPlayerFitWalkReport& Report = Output.Walk.Report; - // The primary report is the mouth-sized arrival/departure window. Origin reachability is - // a separate, explicitly origin-inclusive measurement; expose that report at the legacy - // top-level keys so a sweep cannot accidentally count a window that omitted (0,0). + // The primary report and origin observation are views over the one captured grid. There + // is no second origin-inclusive sampling pass in this run. const FVoxelPlayerFitWalkReport& OriginReport = Output.Walk.bOriginCheckAvailable ? Output.Walk.OriginCheckReport : Report; Writer->WriteObjectStart(TEXT("walk")); @@ -2309,6 +3801,10 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("mesh_file_size_bytes"), Output.Export.MeshFileSizeBytes); Writer->WriteValue(TEXT("vertex_count"), Output.Export.VertexCount); Writer->WriteValue(TEXT("triangle_count"), Output.Export.TriangleCount); + WriteSurfaceReliefMetrics(*Writer, Output.Export.SurfaceMetrics); + Writer->WriteValue(TEXT("mesh_seconds"), Output.Export.MeshSeconds); + Writer->WriteValue(TEXT("truncated"), Output.Export.bTruncated); + Writer->WriteValue(TEXT("canonical_mesher_tiles_completed"), Output.Export.MesherTilesCompleted); Writer->WriteObjectEnd(); } @@ -2351,6 +3847,10 @@ int32 UVoxelForgeExploreCommandlet::Main(const FString& Params) return 1; } + FExploreBudget Budget( + MainStartSeconds, + static_cast(Arguments.BudgetMinutes) * 60.0); + const double SetupStartSeconds = FPlatformTime::Seconds(); FExploreWorld World; if (!World.Build(Arguments, Error)) @@ -2361,19 +3861,29 @@ int32 UVoxelForgeExploreCommandlet::Main(const FString& Params) const double SetupSeconds = FPlatformTime::Seconds() - SetupStartSeconds; FExploreRunOutput Output; + if (Budget.ShouldStop(TEXT("setup"))) + { + UE_LOG(LogTemp, Warning, + TEXT("[VoxelForgeExplore] setup consumed the wall-clock budget; no mode was started.")); + } bool bRequestedModeFailed = false; FExploreWalkOutput RenderSeedWalk; const FExploreWalkOutput* CameraSeedWalk = nullptr; if (Arguments.bWalk) { const double Start = FPlatformTime::Seconds(); - if (!RunWalk(Arguments, World, Output.Walk)) + const bool bWalkOk = RunWalk(Arguments, World, Output.Walk, Budget); + if (!bWalkOk && !Budget.bTruncated) { bRequestedModeFailed = true; } UE_LOG(LogTemp, Display, TEXT("[VoxelForgeExplore] walk %.3fs (%s)"), FPlatformTime::Seconds() - Start, *Output.Walk.Status); CameraSeedWalk = &Output.Walk; + if (bWalkOk && !Budget.bTruncated) + { + Budget.CompleteMode(TEXT("walk")); + } } else if (Arguments.bRender) { @@ -2381,30 +3891,55 @@ int32 UVoxelForgeExploreCommandlet::Main(const FString& Params) // same focused walk as a private seed pass; it is intentionally omitted from the render-only // JSON so the selected mode remains render. const double Start = FPlatformTime::Seconds(); - if (!RunWalk(Arguments, World, RenderSeedWalk)) + const bool bWalkOk = RunWalk(Arguments, World, RenderSeedWalk, Budget); + if (!bWalkOk && !Budget.bTruncated) { bRequestedModeFailed = true; } UE_LOG(LogTemp, Display, TEXT("[VoxelForgeExplore] render camera seed walk %.3fs (%s)"), FPlatformTime::Seconds() - Start, *RenderSeedWalk.Status); CameraSeedWalk = &RenderSeedWalk; + // Keep the private render seed capture available to the run-level shared-grid report; + // the walk object is still omitted from the public JSON when walk was not requested. + Output.Walk = MoveTemp(RenderSeedWalk); + CameraSeedWalk = &Output.Walk; } if (Arguments.bRender) { const double Start = FPlatformTime::Seconds(); - if (CameraSeedWalk == nullptr || !RunRender(Arguments, World, *CameraSeedWalk, Output.Render)) + bool bRenderOk = false; + if (!Budget.bTruncated && CameraSeedWalk != nullptr) + { + bRenderOk = RunRender( + Arguments, World, *CameraSeedWalk, Output.Render, Budget); + } + if (!bRenderOk && !Budget.bTruncated) { bRequestedModeFailed = true; } + if (Budget.bTruncated && Output.Render.Status.IsEmpty()) + { + Output.Render.Status = TEXT("truncated"); + Output.Render.bTruncated = true; + Output.Render.RefusalReason = TEXT("The wall-clock budget stopped before rendering."); + } + if (bRenderOk && !Budget.bTruncated) + { + Budget.CompleteMode(TEXT("render")); + } UE_LOG(LogTemp, Display, TEXT("[VoxelForgeExplore] render %.3fs (%s)"), FPlatformTime::Seconds() - Start, *Output.Render.Status); } - if (Arguments.bFailureFocusRender && Arguments.bRender && Arguments.bWalk) + if (Arguments.bFailureFocusRender && Arguments.bRender && Arguments.bWalk + && !Budget.bTruncated) { const double Start = FPlatformTime::Seconds(); - if (!RunFailureBoundaryRender(Arguments, World, Output.Walk, Output.Render)) + if (!RunFailureBoundaryRender(Arguments, World, Output.Walk, Output.Render, Budget)) { - bRequestedModeFailed = true; + if (!Budget.bTruncated) + { + bRequestedModeFailed = true; + } UE_LOG(LogTemp, Error, TEXT("[VoxelForgeExplore] failure-focus render could not produce a boundary image.")); } @@ -2417,14 +3952,49 @@ int32 UVoxelForgeExploreCommandlet::Main(const FString& Params) if (Arguments.bExport) { const double Start = FPlatformTime::Seconds(); - if (!RunExport(Arguments, World, Output.Export)) + const bool bExportOk = !Budget.bTruncated + && RunExport(Arguments, World, CameraSeedWalk, Output.Export, Budget); + if (!bExportOk && !Budget.bTruncated) { bRequestedModeFailed = true; } + if (Budget.bTruncated && Output.Export.Status.IsEmpty()) + { + Output.Export.Status = TEXT("truncated"); + Output.Export.bTruncated = true; + Output.Export.RefusalReason = TEXT("The wall-clock budget stopped before export."); + } + if (bExportOk && !Budget.bTruncated) + { + Budget.CompleteMode(TEXT("export")); + } UE_LOG(LogTemp, Display, TEXT("[VoxelForgeExplore] export %.3fs (%s)"), FPlatformTime::Seconds() - Start, *Output.Export.Status); } + // A phase can finish just inside its own check and cross the deadline while its artifact is + // being closed. Mark that state before serialising the report so the JSON never claims a full + // run after the budget has actually expired. + Budget.ShouldStop(TEXT("report")); + if (Budget.bTruncated) + { + if (Arguments.bWalk && Output.Walk.Status.IsEmpty()) + { + Output.Walk.Status = TEXT("truncated"); + Output.Walk.RefusalReason = TEXT("The wall-clock budget stopped before walk completion."); + } + UE_LOG(LogTemp, Warning, + TEXT("[VoxelForgeExplore] truncated during %s after %.3fs; completed modes=%s"), + *Budget.TruncatedDuring, + Budget.ElapsedSeconds(), + *FString::Join(Budget.CompletedModes, TEXT(","))); + } + Output.BudgetSeconds = Budget.LimitSeconds; + Output.ElapsedSeconds = FPlatformTime::Seconds() - MainStartSeconds; + Output.bTruncated = Budget.bTruncated; + Output.TruncatedDuring = Budget.TruncatedDuring; + Output.CompletedModes = Budget.CompletedModes; + // Build the payload twice before adding the assertion itself. This is deliberately a string // comparison over a fixed-order writer, not a TMap/pretty-printer whose key order could drift. const FString Payload = BuildExploreJson(Arguments, World, Output, false, 0);