From 69fa73e07e8d3b4d7e8719b6a8e0baa3d4803295 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Sun, 26 Jul 2026 02:11:11 +0200 Subject: [PATCH] tmp --- CODEMAP.md | 78 +- .../Private/VoxelCaveMorphology.cpp | 212 +-- .../Private/VoxelContentManager.cpp | 555 +++++-- .../VoxelForge/Private/VoxelDensityVolume.cpp | 7 +- Source/VoxelForge/Private/VoxelDiffLayer.cpp | 51 +- Source/VoxelForge/Private/VoxelGenerator.cpp | 1040 ++++++++++--- .../Private/VoxelMarchingCubesMesher.cpp | 382 ++++- .../VoxelForge/Private/VoxelStrateManager.cpp | 21 + Source/VoxelForge/Private/VoxelWorld.cpp | 1287 ++++++++++++++--- .../VoxelForge/Public/VoxelCaveMorphology.h | 15 +- .../VoxelForge/Public/VoxelContentManager.h | 26 +- Source/VoxelForge/Public/VoxelDiffLayer.h | 31 + Source/VoxelForge/Public/VoxelGenerator.h | 92 +- .../Public/VoxelMarchingCubesMesher.h | 47 +- Source/VoxelForge/Public/VoxelSettings.h | 53 + .../VoxelForge/Public/VoxelStrateDefinition.h | 2 + Source/VoxelForge/Public/VoxelStrateManager.h | 12 + Source/VoxelForge/Public/VoxelStrateTypes.h | 846 +++++++---- Source/VoxelForge/Public/VoxelTypes.h | 7 + Source/VoxelForge/Public/VoxelWorld.h | 252 +++- 20 files changed, 3945 insertions(+), 1071 deletions(-) diff --git a/CODEMAP.md b/CODEMAP.md index ad4e3ea..84745f7 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -101,7 +101,7 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h). | Group | Fields (line) | |-------|---------------| | Streaming | `ViewDistanceXY=16`, `ViewDistanceUp/Down=5`, `MaxConcurrentTasks=16`, `MaxMeshAppliesPerFrame=4` (defaults — actual values live on the data asset) | -| Clipmap | `ClipRadius`, `MaxClipLevel`, `FullResClipLevels`, `CoarseTileCells`, skirts (the old `LOD0/1Distance` + `ContentMaxLevel` were dead → removed) | +| Clipmap | `ClipRadius`, `MaxClipLevel`, `FullResClipLevels`, `CoarseTileCells`, `RenderDistanceChunks` (custom horizontal reach: the outermost shell keeps generating until it covers this many chunks; 0 = off), `bFarSheetRing` + `FarSheetSpanLevels` (F18 — the render-distance ring streams per-surface SHEETS: level MaxClipLevel+span heightfield tiles instead of MC, see `GenerateSheetMesh`), skirts, `LODOctaveDrop` (T2.b octave drop on coarse tiles — 0 = off/byte-identical) (the old `LOD0/1Distance` + `ContentMaxLevel` were dead → removed) | | Lighting | `bEnableDensityVolume` + DensityVolume* tunables (§3.11 density clipmap / mini-sun shadows) | | Rendering | `VoxelMaterial` (61) | | Strates | `Seed` (69), `CurrentSeason=1` (73), `StratePool` (78), `FixedStrates` map (83), `TotalStrates=10` (87) | @@ -120,27 +120,38 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h). |--------|-----------|------| | `AVoxelWorld()` ctor | 12 | Enables Tick. | | `RegenerateAllChunks()` | 21 | Bumps epoch, unloads all → Tick reloads. CallInEditor button. | +| `ValidateDeterminism()` | — | **F2 CallInEditor button (PIE)**: re-samples boundary points under left- vs right-chunk cache warm-ups + a same-alignment repeat; any non-zero delta = window-invariance regression (§8.4). Run after every "bit-identical" hot-path refactor. | +| `GetMaxConcurrentTasks()` | — | T2.d — asset `MaxConcurrentTasks` capped to logical cores − 2 (all three budget checks use it). | | `PostEditChangeProperty` | 45 | Editor live-edit hook. | | `OnObjectModifiedInEditor` | 58 | Regenerates when a strate asset is edited (if `bLiveEditStrates`). | | `EndPlay` | 140 | Sets `bShuttingDown`, **waits for `ActiveTaskCount`→0**, unbinds delegate. | | `BeginPlay` | 177 | Constructs Generator/Mesher/StrateManager/DiffLayer, wires services, seeds. | | `Tick` | 220 | `UpdateChunksAroundPosition(player)` + `ProcessPendingChunks()`. | | `GetPlayerPosition` | 231 | Pawn position or zero. (`GetLODForChunk`/`LODToStep`/`IsChunkInRange` removed — dead since the clipmap.) | -| `ProcessPendingChunks` | 301 | Drains ProcessQueue under per-frame budget; **discards stale epochs**; applies meshes. | -| `UpdateChunksAroundPosition` | 362 | Builds desired set, sorts by distance, loads/unloads, handles LOD changes. | -| `LoadChunk` | 445 | Budget check → `UE::Tasks::Launch` background gen+mesh; RAII task guard. | -| `UnloadChunk` | 493 | Destroys mesh component + map entries. | -| `ApplyMeshToChunk` | — | Upload geometry. **LOD0 → own component (`ChunkMeshes`, collision); LOD1/2 → batched into one component per region (`ChunkRegions`), each chunk a SectionGroup, no collision.** Handles LOD promote/demote between the two. §8.10. | -| `ChunkToRegion` / `ChunkSectionGroupName` / `RemoveChunkFromRegion` / `DestroyIndividualChunkComponent` | — | Plumbing for the batched far-chunk scheme. | +| `ProcessPendingChunks` | 301 | Drains ProcessQueue under per-frame budget; applies each via `ApplyTileResult`. | +| `ApplyTileResult` | — | **Shared game-thread apply** for one `FChunkResult` (async drain + sync carve): discards stale epochs, marks loaded, ingests capture, EMPTY releases the tile's existing component (a re-gen can flip content→empty on band change — old geometry must not linger), else `ApplyMeshToTile`. Returns true iff a visible mesh uploaded (counts the budget). Doesn't touch `PendingTiles` (caller's). | +| `GenerateTileResult` | — | **Shared worker-side gen** for one tile (async `LoadTile` task + sync `SyncRemeshTile`): ClassifyTile (T1.d) → `GenerateMesh`/`GenerateSheetMesh` → `BuildTileStreamSet`. Reads Generator/Mesher only → safe on a worker or the game thread; fills `FChunkResult`, no enqueue. | +| `SyncRemeshTile` | — | **INSTANT DIG**: level-0 same-frame re-mesh on the game thread (`GenerateTileResult` + `ApplyTileResult` inline, Cells=CHUNK_SIZE/Step=1 + strate band, no capture). Used for the tile under the brush centre so a carve is visible THIS frame; one full-res gen on the game thread. | +| `UpdateChunksAroundPosition` | 362 | Builds desired set, sorts by distance, loads/unloads, handles LOD changes. **Delta cull**: `BuildDesiredTiles` returns the LEAVERS (stamped `DesiredStamped` map, one sweep) — only those + `TransitionHold` are considered per crossing, not every loaded tile. `BuildDesiredTiles` also applies `RenderDistanceChunks`: the outermost shell widens to cover the distance — as level-MaxClipLevel MC tiles, or (F18 `bFarSheetRing`) as a SHEET ring at level MaxClipLevel+span (covered-check vs the MaxClipLevel box; `VF_OuterShell` shared with `IsTileInClipRange` so the cull sees the same horizon), dz pre-clamped to the vertical band. **§9.3 anchors:** also prunes dead `StreamingAnchors` + detects their chunk crossings → rebuilds the desired set when an anchor moves/(un)registers (`bAnchorsMoved`/`bForceDesiredRebuild`), same cadence as player movement. §8.10. | +| `BuildDesiredTiles` | ~722 | Builds `DesiredSorted`+`DesiredStamped` (player clipmap shells + F18 sheet ring) then `AddAnchorDesiredTiles()` before the leaver sweep. | +| `AddAnchorDesiredTiles` | — | **§9.3 multi-anchor:** folds each `FVoxelStreamingAnchor`'s thin level-0 box (`XYRadiusChunks`/`ZBelowChunks`/`ZAboveChunks`) into the SAME desired set (deduped by stamp) so AI/remote players keep collision loaded around them; delta cull releases them on move/unregister. **§9.4:** a tile only a CollisionOnly anchor wants (clipmap didn't stamp it) → `CollisionOnlyTiles` → hidden at apply. Zero cost when no anchors. | +| `ReconcileAnchorTileVisibility` | — | **§9.4:** after each rebuild, toggle `SetVisibility` on ALREADY-LOADED tiles that flipped render↔collision-only (diff `CollisionOnlyTiles` vs prev — bounded, no O(loaded) scan; unhide only if still desired). No-op without CollisionOnly anchors. | +| `RegisterStreamingAnchor` / `UnregisterStreamingAnchor` | — | **BlueprintCallable §9.3:** add/remove an actor as a streaming anchor (`EVoxelAnchorPolicy` CollisionOnly/FullVisual + XY/ZBelow/ZAbove box). Idempotent; forces a rebuild next Tick. §9.4: CollisionOnly tiles cook collision but are hidden (no draw/VSM) unless the player clipmap wants them too. | +| `LoadChunk` | 445 | Budget check → `UE::Tasks::Launch` background gen+mesh; RAII task guard. Worker runs `Generator->ClassifyTile` first (T1.d): AllSolid/AllAir ⇒ skip `GenerateMesh`, tile stays empty (capture tiles always generate). STRATE CONTENT CUT: tiles ≥ `StrateContentCutMinLevel` pass the player-strate band (`MeshBandChunkLo/Hi` → voxels) to `GenerateMesh` + stamp it on `FChunkResult::BandChunkLo/Hi`; band change re-queues via `BandRemeshQueue` (see `UpdateChunksAroundPosition`). TOO-COARSE SKIP: if one cell is taller than the band (`Step > band height` — level ≥7 territory) the tile is enqueued EMPTY without launching a task (cell-granular cut could only render garbage). F18: `Tile.Level > MaxClipLevel` = SHEET tile → routed to `GenerateSheetMesh` (band mid-chunk = strate ref; band unarmed ⇒ empty; MC-ring sampling density, cells capped 128/axis; carries the XY hole `SheetHole*Vox` — hole moves ⇒ overlapping sheets re-queue via `BandRemeshQueue`, see `UpdateChunksAroundPosition`). §8.10. | +| `UnloadTile` | — | Clears tile state; the component is PARKED in the pool (T2.c), not destroyed. | +| `ApplyMeshToTile` | — | Upload geometry. One component per tile (clipmap keeps count low; supersedes the old region batching); worker-built streams (T1.f) → `CreateSectionGroup(MoveTemp)`. Reuses the component's existing `URealtimeMesh` (no per-apply mesh alloc). **F17: two polygroups** (0 ground / 1 sky-cap, per-triangle class from the mesher) → RMC auto-section per non-empty group; slot 0 = override/default material, slot 1 = `CeilingMaterial` (fallback ground); config gated by `FChunkResult::bHasGroundTris/bHasCeilingTris`. Takes `FChunkResult&`; strate lookups clamp Z into `Result.BandChunkLo/Hi` (strate content cut) — ground at clamped bottom chunk, cap at clamped top (mid = gap fallback). Collision level-0 only (T1.c) both groups; shadow per SECTION: ground casts at level≤1, cap never. **§9.4:** `SetVisibility(false)` when the tile is in `CollisionOnlyTiles` (anchor-only, hidden — collision still cooks). §8.10 + ARCHITECTURE SurfaceWorld row. | +| `AcquireTileComponent` / `ReleaseTileComponent` | — | **T2.c component pool** (`TileComponentPool`, bounded): park on unload (geometry+collision stripped, hidden, stays registered), pop on apply — no `NewObject`/`RegisterComponent`/GC churn during travel & regen. §8.10. | | `GetStrateAtPosition` | 965 | Gameplay query → strate index. | | `GetBiomeAtWorldLocation` | — | **BlueprintCallable** biome probe at a world point (undoes actor xf → voxel → `Generator::QueryBiomeAt`). Returns `FVoxelBiomeQuery` for BP debug ("what biome / how many decos under the cursor?"). | -| `CarveAtPosition` / `FillAtPosition` | 691 / 709 | Build `FVoxelModification` → DiffLayer → RemeshDirtyChunks. | +| `GetVoxelSurfaceHeightAt` | ~1534 | **BlueprintCallable** ground finder (F7 bridge): world XY → terrain + sky-cap world-Z via `Generator::GetSurfaceHeightAt`, NO trace/collision, deterministic, available before the area meshes → self-arranging prefab/ruin BPs snap their parts to the real ground. False (outs=input Z) on non-SurfaceWorld strates; ignores passage/spine carving. | +| `CarveAtPosition` / `FillAtPosition` | 691 / 709 | Build `FVoxelModification` → `ApplyModification`. | +| `ApplyModification` | ~1581 | Single funnel for all brushes: DiffLayer → **sync-remesh the brush-centre level-0 tile** (`SyncRemeshTile`, instant hole; skipped if that tile is mid-gen — would race a stale in-flight result) → `RemeshDirtyChunks(..., excludeCenter)` for the neighbours → `RemoveDecorationsInSphere`. | | `ClearAllModifications` | 726 | Clears diff layer, regenerates. | | `ChangeSeed` | 740 | **Season reset**: new seed everywhere, clear diffs, bump season, reload. | | `GetCurrentSeed` / `GetCurrentSeason` | 784 / 789 | Accessors. | -| `RemeshDirtyChunks` | 798 | Re-queue loaded chunks for async re-mesh (no visual pop). | +| `RemeshDirtyChunks` | 798 | Queue loaded level-0 dirty tiles onto `DirtyRemeshQueue` (async re-mesh, no pop) + `MarkDirtyVoxelBox` the volume. Optional `ExcludeTile` = the sync'd centre. Drained FIRST in the submit loop at **BackgroundHigh** (ahead of streaming/band) so a dig never waits behind streaming; in-flight tiles stay QUEUED (not dropped) so a stale pre-carve result is corrected once it lands — fixes "hole shows up a beat late / not until I move". | -> **Game-thread profiling (Perf):** `AVoxelWorld::Tick` and its sub-steps are wrapped in `TRACE_CPUPROFILER_EVENT_SCOPE` — `VoxelForge_Tick / UpdateChunks / BuildDesiredTiles / CullTiles / SubmitTiles / ProcessPending / ProcessUnload / UpdateDecorations / UpdateWater`. Capture a `Count/Incl/Excl` Insights timer export and read the `Excl` column to see which step owns the per-frame cost (the actor tick shows as `BP_VoxelWorld_C` if subclassed in BP). `VoxelForge_GenerateMesh` + `VoxelForge_BuildStreams` are worker-side (off the frame): the RMC `FRealtimeMeshStreamSet` is now built on the gen worker (`BuildTileStreamSet`) and carried on `FChunkResult::Streams` (TSharedPtr), so `ApplyMeshToTile` is game-thread-cheap — just material/ceiling resolve + `CreateSectionGroup(MoveTemp)`. See ARCHITECTURE §8.10 "Worker-built StreamSet (T1.f)". +> **Game-thread profiling (Perf):** `AVoxelWorld::Tick` and its sub-steps are wrapped in `TRACE_CPUPROFILER_EVENT_SCOPE` — `VoxelForge_Tick / UpdateChunks / BuildDesiredTiles / CullTiles / SubmitTiles / ProcessPending / ProcessUnload / UpdateDecorations / UpdateWater`. Capture a `Count/Incl/Excl` Insights timer export and read the `Excl` column to see which step owns the per-frame cost (the actor tick shows as `BP_VoxelWorld_C` if subclassed in BP). `VoxelForge_ClassifyTile` (T1.d) / `VoxelForge_GenerateMesh` + `VoxelForge_BuildStreams` are worker-side (off the frame): the RMC `FRealtimeMeshStreamSet` is now built on the gen worker (`BuildTileStreamSet`) and carried on `FChunkResult::Streams` (TSharedPtr), so `ApplyMeshToTile` is game-thread-cheap — just material/ceiling resolve + `CreateSectionGroup(MoveTemp)`. See ARCHITECTURE §8.10 "Worker-built StreamSet (T1.f)". ### 3.6 Density generator — `Public/VoxelGenerator.h` + `Private/VoxelGenerator.cpp` `UVoxelGenerator : UObject` — lightweight; holds `Seed`, and injected services @@ -157,12 +168,26 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h). | **`GetDensityAt`** | 218 | **Entry point.** Picks strate + generator type, dispatches, adds diff offset. | | **`GetDensityWithParams`** | 277 | TunnelNetwork pipeline (~1000 lines). See §4. | | **`GetSlabDensity`** | 1306 | FlatPlain/CrystalChamber pipeline. See §4.2. | -| `ComputeSurfaceTerrainZ` / `GetSurfaceDensity` | — | SurfaceWorld heightfield → terrain Z, then density; biome **output-blend** lerps dominant/neighbour heights (`ParamsD`/`ParamsN`/weight). §8.14. | +| `SampleSurfaceStructuralZ` | — | **F20:** the RAW SurfaceWorld heightfield (continents+mountains+detail), BEFORE any terrain op; returns terrain Z + relief M. Cliff re-samples it at an XY offset for a cheap analytic slope. | +| `ComputeSurfaceTerrainZ` / `GetSurfaceDensity` | — | SurfaceWorld heightfield → terrain Z, then density; biome **output-blend** lerps dominant/neighbour heights (`ParamsD`/`ParamsN`/weight). **F20 surface ops** (`FSurfaceGenerationParams`, biome-selected + slope/relief-conditioned, all default off): Cliff (slope-gated STEEPENING — push height from local mean where steep ⇒ sheer walls; 4 structural resamples only when on), Terrace (relief-gated + `TerraceHardness`), LayerLines (sedimentary shelves) — pure per-column height REMAPS applied here so the single height oracle stays consistent (MC/sheets/ClassifyTile/deco/BP bridge). **Phase 2 OVERHANG** (volumetric — real jutting shelves): in `SurfaceDensityFromColumn`, for AIR voxels in a window `(TerrainZ, TerrainZ+OverhangHeight]` above a steep slope, the heightfield is re-sampled UPHILL (toward the cliff) by a reach that GROWS with height (tiny low ⇒ air over the void, full high ⇒ borrows the far cliff rock) and unioned in ⇒ a shelf attached to the cliff, tapering out over the void with air beneath (the sketch). Per-column `OverhangAmp`(=strength·slope-gate) + unit uphill `(DirX,DirY)` resolved once in `ComputeSurfaceColumn` (gradient sampled at the REACH scale so a spot over the void can see the cliff), cached on `FSurfaceColumn`. Genuine 3D (per-voxel structural re-eval, gated to steep overhang columns). Off ⇒ byte-identical. §8.14. | +| `ClassifyTile` | — | **T1.d trivial-tile reject** (worker, called by `LoadTile` before `GenerateMesh`): proves a tile AllSolid/AllAir on the mesher's exact lattice (gap chunks + SurfaceWorld columns via the SHARED `GSurfColCache`; seal bands; guards: diff mods, passages, spine, disturbances, **F20 overhang** — a column point in `(TerrainZ, TerrainZ+OverhangMargin]` (margin = max `OverhangHeight`) is unprovable ⇒ Mixed, UPWARD only since the shelf union only ADDS rock above ground, so an overhang shelf never holes a trivially-skipped tile) → skip gen. Mixed = generate normally. §8.10. | | `SampleRelief` / `SampleMoisture` | — | Climate fields (pure XY, [0,1]). Relief = shared source of truth for the relief map M. §8.14. | | `SampleBiomeAt` | — | Warped-Voronoi + climate biome query (dominant + neighbour + weight). Reference used by the preview bake + `GetDominantBiomeAt`. §8.14. | | `ResolveBiomeSampleAt` / `RebuildBiomeGrid` | — | Hot-path biome resolve (FBiomeSample) via a box-validated per-chunk cell-grid cache. Bit-identical to `SampleBiomeAt`. §8.14, §8.10. | | `GetDominantBiomeAt` | — | Game-thread query → dominant biome ASSET (content/atmosphere). §8.14. | | `QueryBiomeAt` | — | Rich game-thread biome probe → `FVoxelBiomeQuery` (dominant/neighbour asset, relief/moisture, blend weight, dominant deco count). Diagnostic behind `AVoxelWorld::GetBiomeAtWorldLocation`. §8.14. | +| `EvaluateTerrainConditions` | ~2548 | **F7 aware placement:** AND-evaluate an entry's `FTerrainCondition[]` (relief/moisture/biome-border) at a candidate voxel XY. Empty = true (zero cost). Pure query (SampleRelief/SampleMoisture/SampleBiomeAt) → worker-safe + game-thread; caller passes the strate's `FBiomeContext` (freq/contrast + Voronoi map). Consumed by deco `BuildCellSpawns` + `SpawnLandmarkInstance`; shared core of the future quest FindFeature locator. | + +> **Per-voxel hot-path memos (perf pass 2, all bit-identical — same hashes/math, hoisted per +> chunk/cell):** `GetDensityAt` uses the DiffLayer snapshot cache (§3.9); `GetDensityWithParams` +> memoizes the strate index per (chunkZ, `GetLayoutVersion()`); `thread_local` per-cell lattice +> bakes cover slab columns (`GetSlabDensity` step 4), maze open edges, vertical shafts + +> cross-connectors, floating-island constants, and the disturbance chasms/bridges/ridges; worm +> tunnels short-circuit the 2nd Perlin when N1 ≥ WormThreshold (N2 ≥ 0 ⇒ can't carve). Room +> shapes are pre-baked in `FCachedRoom` (§3.7). **T2.b:** per-voxel fractal call sites take their +> octave count through `VoxelGenLOD::Eff(N)` (VoxelGenerator.h) — a `thread_local` bias set per +> tile by the mesher drops tail octaves on coarse tiles (opt-in `LODOctaveDrop`, default 0 = off); +> XY-field noise (heightfield/ceiling/relief/moisture) deliberately stays un-biased. §8.10. ### 3.7 Cave morphology (SDF rooms/tunnels) — `Public/VoxelCaveMorphology.h` + `.cpp` Header is rich with inline docs. Two namespaces + a per-chunk cache system. @@ -176,8 +201,8 @@ Header is rich with inline docs. Two namespaces + a per-chunk cache system. - `namespace VoxelCaveMorphology`: | Function | .cpp line | Role | |----------|-----------|------| - | `BuildChunkCache` | 47 | **Phase 1** (once/chunk): collect rooms, guaranteed backbone (`bTunnelsFlowTowardOrigin`: tree rooted at the (0,0) hub — every room reachable, links flow inward; false = legacy NN forest), slope-aware link metric (`TunnelHorizontalBias` now applies to backbone too), decide tunnels, **cull zero-connection rooms** (no sealed bubbles), store rooms by their OWN reach (fixes origin-room clipping at `MaxInfluence`), pre-bake pits/chimneys/columns, hash-roll per-room terrain op. | - | `EvaluateSDFCached` | 589 | **Phase 2** (per voxel): SmoothMin over cached rooms/tunnels; returns nearest room idx for terrain-op lookup. | + | `BuildChunkCache` | 47 | **Phase 1** (once/chunk): collect rooms, guaranteed backbone (`bTunnelsFlowTowardOrigin`: tree rooted at the (0,0) hub — every room reachable, links flow inward; false = legacy NN forest), slope-aware link metric (`TunnelHorizontalBias` now applies to backbone too), decide tunnels, **cull zero-connection rooms** (no sealed bubbles), store rooms by their OWN reach (fixes origin-room clipping at `MaxInfluence`), pre-bake pits/chimneys/columns via the shared `BakeRoomFeature` hash-placement skeleton (one gate/XY/radius pattern + per-type Emit lambda), hash-roll per-room terrain op. | + | `EvaluateSDFCached` | 757 | **Phase 2** (per voxel): SmoothMin over cached rooms/tunnels; returns nearest room idx for terrain-op lookup. **Signature changed (perf pass 2): `RoomShapeVariety` param REMOVED** — the shape roll + capsule trig are pre-baked into `FCachedRoom` (`ShapeType/ShapeA/ShapeB/ShapeR`) by `BuildChunkCache`, bit-identical. | | `EvaluateSDF` | 738 | Convenience wrapper (builds temp cache) for one-off queries. | Performance note (h:209-220): caching rooms/tunnels once per chunk instead of per @@ -192,10 +217,13 @@ Header is rich with inline docs. Two namespaces + a per-chunk cache system. | `EVoxelNoiseType` | 99 | FBM/Ridged/Mixed/Cellular. | | `ECaveGeneratorType` | 146 | TunnelNetwork / FlatPlain / CrystalChamber. | | `EVoxelStrateTransition` | 183 | Gradient / Hard / Interleaved boundary blends. | -| **`FStrateGenerationParams`** | 213 | The giant TunnelNetwork param bag (rock, worms, rooms, tunnels, warp, roughness, all terrain-op transport fields, boundary seal). `Lerp()` static at 844 blends two sets at boundaries. | +| **`FStrateGenerationParams`** | ~350 | The giant TunnelNetwork param bag (rock, worms, rooms, tunnels, warp, roughness, all terrain-op transport fields, boundary seal). `Lerp()` static blends two sets at boundaries — it expands the **`VF_STRATE_PARAM_FIELDS` X-macro** (defined just above the struct): **adding a field to the struct? add it to that list** or blends silently reset it to default. | | `FStrateTerrainOpEntry` | 965 | Soft-ptr to a terrain op + Weight + Probability. | | **`FSlabGenerationParams`** | 1019 | Floor/ceiling heights, roughness, columns, seal — for slab generators. | -| `FStrateDecoration` / `FStrateAmbientActor` / `FStrateCreature` | 1160 / 1192 / 1212 | Content spawn entries (consumed by future systems). | +| **`FPlacementProfile`** | ~1747 | **Shared placement vocabulary** for every scatter primitive (`FStrateDecoration`, `FStrateLandmark`, coming `FStrateSetPiece`): spawn (ActorClass/InstancedMesh), Filter gates (surface/slope/overhang/water/RequiredBiome + **F7 awareness `Conditions[]`** — `FTerrainCondition` relief/moisture/biome-border predicates, AND-ed, evaluated by `Generator::EvaluateTerrainConditions`), Transform (align/offsets/RotationOffset+RandomRotation/scale), Render (cull/shadow). Each primitive embeds it as `Profile` + keeps only its own DISTRIBUTION fields. Per-primitive defaults set in each struct's ctor (deco: scale 0.8-1.2 + RandomRotation.Yaw=360; landmark: Ceiling + no align). | +| `FStrateDecoration` / `FStrateLandmark` | ~1830 / ~1900 | `Profile` + distribution: deco = StreamTier/SpawnDensity/MaxPerChunk; landmark = SpacingChunks/JitterFraction/SpawnProbability/StreamRadiusChunks + Light-Orb block. | +| `ELandmarkAnchor` (on `FStrateLandmark`) | ~1940 | F7: `AnchorMode` = HashLattice / PassageMouth + passage toggles + exclusion (`ExclusionRadiusChunks`/`Priority`) folded into `FStrateLandmark` (set-pieces merged in — one primitive, one `Landmarks` list, `UpdateLandmarks`). | +| `FStrateAmbientActor` / `FStrateCreature` | ~2090 / ~2110 | Content spawn entries (consumed by future systems). | **`Public/VoxelStrateDefinition.h`** — `UVoxelStrateDefinition : UPrimaryDataAsset` (line 36). One asset = one strate *type*. Fields: identity, `StrateHeightInChunks`(60), @@ -213,8 +241,10 @@ Maps depth→strate at runtime; owns passages. | `Initialize` | 10 | Builds the stacked layout from settings+seed (fixed slots + shuffled pool), then `GeneratePassages`. | | `GeneratePassages` | 146 | Deterministic passages between consecutive strates (per-type control points). | | `EvaluateModifierSDF` | 357 | SDF of passages at a point (for carving). Per-chunk `thread_local` shortlist (`PassagesVersion`-stamped) → far chunks return `FLT_MAX` without walking `Passages`. §8.10. | +| `AnyPassageNearBox` | — | Conservative sphere-vs-AABB test of every passage's bound against a voxel box (+carve blend pad). Per TILE (ClassifyTile guard), never per voxel. | | `FindSlotIndexForChunkZ` | 427 | Z → layout index. | | `GetStrateAt` / `GetStrateIndex` | 443 / 455 | World-Z queries. | +| `GetLayoutVersion` | h:161 (inline) | Layout/passage generation counter (= `PassagesVersion`, bumped by every `Initialize`). Hot-path callers key `thread_local` memos on it (strate-index memo in `GetDensityWithParams`, passage shortlist) so editor rebuilds never serve stale data. | | `GetStrateForChunk` | 466 | Chunk → definition. | | `GetGeneratorTypeForChunk` | 476 | Chunk → generator type. | | `GetSlabParamsForChunk` | 490 | Slab params with runtime Z bounds (no blend — slabs use Hard). | @@ -251,6 +281,10 @@ atmosphere override, `WaterMaterial`, `MaterialPaletteIndex` (F6 — baked to ve | `ApplyModification` | 63 | Enforces budget, stores in all overlapped chunks, returns dirty coords. | | `GetDensityOffset` | 131 | Per-voxel combined diff (smoothstep falloff, additive). | | `HasModifications` | 160 | Fast reject for hot path. | +| `HasAnyMods` / `GetModsVersion` | h:238 / h:241 (inline) | Lock-free atomics: any-mod-exists flag + monotonic mod-state version (bumped by `ApplyModification`/`Clear`). | +| `GetChunkModsSnapshot` | — | Copy one chunk's mod list under ONE read lock. Workers snapshot per (chunk, version) instead of locking per voxel — `GetDensityAt` keys a `thread_local` 64-slot direct-mapped cache on it (~27 lock ops per tile task instead of ~86k once any carve exists). | +| `HasAnyModInChunkRange` | — | Any modified chunk key in an inclusive chunk box? One key walk under a read lock — ClassifyTile's diff guard (per tile, conservative by construction: mods are stored in every chunk their radius overlaps). | +| `EvaluateMods` (static) | — | Lock-free pure evaluation of a mod list at a voxel — shared core of `GetDensityOffset` and the generator's snapshot path. | | `Clear` | 170 | Wipe all (season reset). | | `GetTotalModificationCount` / `GetModifiedChunkCount` | 182 / 192 | Stats. | @@ -260,7 +294,8 @@ atmosphere override, `WaterMaterial`, `MaterialPaletteIndex` (F6 — baked to ve removed — since T1.b the pre-sampled grid supplies positions AND gradients inline.) | Method | .cpp line | Role | |--------|-----------|------| -| **`GenerateMesh`** | ~15 | The MC loop over cells; `Step` controls LOD sampling. Edge `t` + grid-gradient normals computed inline (`SampleG`/`GradAt`). Optional `OutCaptureGrid` (4th arg) = CAPTURE-DURING-MESHING: when non-null + full-res (`CellsPerAxis==CHUNK_SIZE`), copies the already-sampled `CHUNK_SIZE³` density grid (quantized via `VF_QuantizeDensity`, VoxelTypes.h) so the density clipmap reuses it instead of re-sampling `GetDensityAt`. Pure read of the grid — §8.10 untouched. | +| **`GenerateSheetMesh`** | ~455 | **F18 far-field SHEET** (render-distance ring, `Tile.Level > MaxClipLevel`): two displaced heightfield grids per tile — ground (polygroup 0) + sky-cap (polygroup 1) from `GetSurfaceHeightAt` columns (StrateChunkZ = band mid identifies the strate; non-SurfaceWorld ⇒ empty). Classes true BY CONSTRUCTION (no vote/probes). Same conventions as `GenerateMesh` (world-cm positions, planar UVs, F6 colour masks, −N double-faced perimeter skirts per bucket, ground‖cap + `NumCeilingTriangles`). Margin ring keeps normals continuous between sheets. XY HOLE params: cells fully inside the MC-covered box around the player (`AVoxelWorld::SheetHole*Vox`, shrunk 1 tile for seam overlap) are skipped — a partially-covered sheet must not overlay near terrain; hole-edge cells get no skirt. Carved features (passages/spine/chasms) + diff layer NOT represented — accepted at sheet distance. | +| **`GenerateMesh`** | ~15 | The MC loop over cells; `Step` controls LOD sampling. Sets the T2.b octave bias for the tile (`TGuardValue` on `VoxelGenLOD::OctaveBias`, from `LODOctaveDrop` × log2(Step); 0 at LOD0/off). Edge `t` + grid-gradient normals computed inline (`SampleG`/`GradAt`). Optional `OutCaptureGrid` (4th arg) = CAPTURE-DURING-MESHING: when non-null + full-res (`CellsPerAxis==CHUNK_SIZE`), copies the already-sampled `CHUNK_SIZE³` density grid (quantized via `VF_QuantizeDensity`, VoxelTypes.h) so the density clipmap reuses it instead of re-sampling `GetDensityAt`. Pure read of the grid — §8.10 untouched. **F17 surface class**: each unique vertex is classified sol/sky-cap in `GetOrCreateVertex` (down-facing only → memoized `GetSurfaceHeightAt`, nearer `CeilSurf` = cap); triangles bucket by majority into `GroundTris`/`CapTris` (thread_local), skirts emit per bucket, then `Triangles = ground‖cap` + `FVoxelMeshData::NumCeilingTriangles` (→ polygroups in `BuildTileStreamSet`). Same tris, only index ORDER changes. STRATE CONTENT CUT: optional `BandZMin/MaxVox` params restrict the cz cell loop (+ gz sampling rows) to the player-strate band — coarse straddling tiles mesh ONE strate (kills far-LOD inter-strate aliasing holes); meshed cells bit-identical. | **`Public/MarchingCubesTables.h`** — `EdgeTable` + `TriTable` reference data (Paul Bourke). Cube corner/edge layout documented at top (lines 7-37). Rarely needs editing. @@ -268,7 +303,7 @@ Bourke). Cube corner/edge layout documented at top (lines 7-37). Rarely needs ed ### 3.11 Per-chunk content & per-strate atmosphere (2026 redesign — see §8) | File | Role | |------|------| -| `Public/Private/VoxelContentManager.h/.cpp` | `UVoxelContentManager` — distance-based world-grid decoration scatter (no LOD pop, surface-snapped via `GetDensityAt`) + level-0 water planes. **TWO streaming grids** (`FDecoGrid` Near/Far, picked per entry via `FStrateDecoration::StreamTier`): NearGrid = short radius + fine column grid (groundcover); FarGrid = full radius + coarse grid (cheap rare/large props). **Plus `UpdateLandmarks`** — rare far-visible objects (the "mini-suns") on a coarse HASH LATTICE (`FStrateLandmark`, cell = SpacingChunks chunks → cheap at any radius, no per-chunk freeze); synchronous, deterministic, strate-wide. Owned by `AVoxelWorld`. §8.5. | +| `Public/Private/VoxelContentManager.h/.cpp` | `UVoxelContentManager` — distance-based world-grid decoration scatter (no LOD pop, surface-snapped via `GetDensityAt`) + level-0 water planes. **F7 companions:** each `FStrateDecoration` may list `FDecoCompanion` satellites (rocks/mushrooms around a tree) — emitted per placed parent in `PlaceAtCrossing`, deterministic (pure fn of the parent hash), inherit the parent's surface point; `FDecoSpawn::CompanionIdx` routes each back to its `Companions[ci].Profile` at apply. **TWO streaming grids** (`FDecoGrid` Near/Far, picked per entry via `FStrateDecoration::StreamTier`): NearGrid = short radius + fine column grid (groundcover); FarGrid = full radius + coarse grid (cheap rare/large props). **Plus `UpdateLandmarks`** — rare deliberately-placed objects: mini-suns, ruins, shrines, monuments (`FStrateLandmark`; set-pieces folded in 2026-07-06). Per entry an `AnchorMode`: **HashLattice** (coarse hash lattice, cell = SpacingChunks chunks → cheap at any radius, no per-chunk freeze) or **PassageMouth** (`GetPassages()` endpoints landing in this strate). Gated by `Profile` + `Profile.Conditions`; optional deterministic **exclusion radius** (Priority+hash rank, `ExclusionRadiusChunks`; 0 = off → pure scatter is unchanged, skips the O(n²) resolve). Synchronous, deterministic, strate-wide; spawns via `SpawnFromProfile` (+ orb wrapper). Exclusion is POP-FREE (gathers a `MaxExcl` ring of suppress-only candidates so a piece's fate is player-position-independent). `bSuppressDecorationsUnder`/`SuppressRadiusChunks` clear grass under a footprint (on spawn + re-cleared in `ApplyRegion`). **`RemoveDecorationsInSphere`** (world sphere → both grids' HISMs via `GetInstancesOverlappingSphere`+`RemoveInstances`) is the shared primitive, also called by `AVoxelWorld::ApplyModification` so player digging removes floating grass instantly. Owned by `AVoxelWorld`. §8.5. | | `Public/Private/VoxelAtmosphereManager.h/.cpp` | `UVoxelAtmosphereManager` — per-strate fog/skylight + persistent ceiling/floor layer actors + full `AtmosphereActor` override. Owned by `AVoxelWorld`. §8.6. | | `Public/Private/VoxelDensityVolume.h/.cpp` | `UVoxelDensityVolume` — player-centred DENSITY CLIPMAP (N toroidal R8 levels, fine near / coarse far) streamed to GPU `UVolumeTexture`s for the mini-sun raymarched shadow march. Fills run on ONE dedicated thread (`FVoxelDensityFillRunnable`, off the task pool); level 0 is mostly fed by CAPTURE-DURING-MESHING (mesher grid reuse, gated by `IsTileCaptureUseful` so only tiles near the shadow window pay the capture). Carve → `MarkDirtyVoxelBox` refills locally. `VolumeEpoch` drops stale fills. Owned by `AVoxelWorld` (`bEnableDensityVolume`); shader params pushed via shared per-base-material MIDs (`AVoxelWorld::UpdateTerrainMaterialParams`, change-detected). | @@ -325,10 +360,10 @@ Stage order (negative=solid throughout). Each stage's anchor: | How chunks stream in/out | `UpdateChunksAroundPosition` VoxelWorld.cpp:362. | | Async threading / stale-result handling | `LoadChunk` :445, `ProcessPendingChunks` :301, Epoch logic. | | Add a new cave feature / terrain op | Add enum in `VoxelTerrainOpDefinition.h:36`, params there, `ApplyTo` (.cpp:6), transport fields in `FStrateGenerationParams`, consume it in a new Step inside `GetDensityWithParams`. | -| Tweak room/tunnel shapes | `VoxelCaveMorphology.cpp` `BuildChunkCache` :47 / `EvaluateSDFCached` :589. | +| Tweak room/tunnel shapes | `VoxelCaveMorphology.cpp` `BuildChunkCache` :47 / `EvaluateSDFCached` :757. | | Worm tunnel behavior | `GetDensityWithParams` Step 5, VoxelGenerator.cpp:1241. | | Strate stacking / which strate where | `UVoxelStrateManager::Initialize` :10. | -| Boundary blend between strates | `GetGenerationParams` :515 + `FStrateGenerationParams::Lerp` (StrateTypes.h:844). | +| Boundary blend between strates | `GetGenerationParams` :515 + `FStrateGenerationParams::Lerp` (expands `VF_STRATE_PARAM_FIELDS`, StrateTypes.h — new fields go in that list). | | Passages between strates | `GeneratePassages` :146 + `EvaluateModifierSDF` :371 + `ApplyPassageCarving` (Generator.cpp:197). | | Player carve/fill | `CarveAtPosition`/`FillAtPosition` VoxelWorld.cpp:691/709 → `UVoxelDiffLayer::ApplyModification` :63. | | Mesh smoothness / normals | Grid-gradient in `GenerateMesh` (`GradAt` lambda), `IsoLevel` (h). | @@ -371,4 +406,7 @@ Stage order (negative=solid throughout). Each stage's anchor: Moved out of the codemap to keep this file a fast navigation index. The archetypes, (0,0) spine, disturbances, content/atmosphere, biomes, and the **performance invariants** (`§8.10` — read before optimizing the hot path) now live in **[ARCHITECTURE.md](ARCHITECTURE.md)**. -All `§8.x` cross-references throughout this file point there. +All `§8.x` cross-references throughout this file point there. **`§9` = the MULTIPLAYER model** +(listen-server-first, design-only) — read before touching streaming / carve / AI: terrain is never +replicated (determinism = replicate seed+layout+diff events only), streaming goes multi-anchor +(collision-only vs full-visual policy per anchor), carves are server-authoritative. diff --git a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp index b2fe2ba..1a42701 100644 --- a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp +++ b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -49,6 +49,44 @@ struct FBuildRoom // (collected for connectivity decisions either way). }; +//============================================================================= +// INTERNAL: Shared hash-placement skeleton for the per-room baked features +// (pits / chimneys / columns). Squelette commun de placement par hash — les +// trois boucles de bake étaient des copies quasi identiques de ce motif. +// +// Rolls EXACTLY the hash chain the hand-written loops used (bit-identical): +// H = Mix(RoomHash ^ (SaltBase + i * SaltStep)) → density gate +// H2 = Mix(H ^ Salt2) → XY offset (X: H2, Y: Mix(H2)) +// H3 = Mix(H2 ^ Salt3) → radius lerp [MinRadius, MaxRadius] +// Type-specific work (Z anchor, flare, bounds, struct fill) lives in the Emit +// lambda; it receives H3 so pits/chimneys can chain their 4th hash from it. +//============================================================================= +template +static void BakeRoomFeature( + const FCachedRoom& CR, + int32 MaxCount, float Density, + uint32 SaltBase, uint32 SaltStep, uint32 Salt2, uint32 Salt3, + float XYScale, float MinRadius, float MaxRadius, + FEmit&& Emit) // Emit(X, Y, Radius, H3) +{ + if (Density <= 0.0f) return; + + for (int32 i = 0; i < MaxCount; i++) + { + const uint32 H = VoxelHash::Mix(CR.Hash ^ (SaltBase + (uint32)i * SaltStep)); + if (VoxelHash::ToFloat01(H) > Density) continue; + + const uint32 H2 = VoxelHash::Mix(H ^ Salt2); + const uint32 H3 = VoxelHash::Mix(H2 ^ Salt3); + + const float X = CR.Center.X + VoxelHash::ToFloatSigned(H2) * CR.RadiusXY * XYScale; + const float Y = CR.Center.Y + VoxelHash::ToFloatSigned(VoxelHash::Mix(H2)) * CR.RadiusXY * XYScale; + const float R = FMath::Lerp(MinRadius, MaxRadius, VoxelHash::ToFloat01(H3)); + + Emit(X, Y, R, H3); + } +} + //============================================================================= // PHASE 1: BUILD CHUNK CACHE //============================================================================= @@ -541,6 +579,41 @@ void VoxelCaveMorphology::BuildChunkCache( float MaxExtent = FMath::Max(BR.RadiusXY * 1.5f, BR.RadiusZ) + BlendK * 3.0f; CR.CullRadiusSq = MaxExtent * MaxExtent; + // --- PRE-BAKED SHAPE --- + // Same hash roll + thresholds + capsule trig the evaluator used to redo PER VOXEL; + // done once here → EvaluateSDFCached just switches on ShapeType. Bit-identical output. + { + const uint32 ShapeHash = VoxelHash::Mix(CR.Hash ^ 0xDEADBEEFu); + const float ShapeRoll = CR.bIsOrigin ? 0.0f : VoxelHash::ToFloat01(ShapeHash); + const float BoxThreshold = 1.0f - Params.RoomShapeVariety * 0.5f; + const float CapsuleThreshold = 1.0f - Params.RoomShapeVariety * 0.2f; + + if (ShapeRoll >= BoxThreshold && ShapeRoll < CapsuleThreshold) + { + // ROUNDED BOX: angular chamber with smooth corners + CR.ShapeType = 1; + CR.ShapeA = FVector(CR.RadiusXY * 0.8f, CR.RadiusXY * 0.8f, CR.RadiusZ * 0.8f); + CR.ShapeR = CR.RadiusXY * 0.25f; + } + else if (ShapeRoll >= CapsuleThreshold) + { + // ELONGATED CAPSULE: stretched hall/corridor-room + CR.ShapeType = 2; + const float DirAngle = VoxelHash::ToFloat01(VoxelHash::Mix(CR.Hash ^ 0xCAFEBABEu)) * 2.0f * PI; + const float StretchDist = CR.RadiusXY * 0.7f; + const FVector Dir(FMath::Cos(DirAngle), FMath::Sin(DirAngle), 0.0f); + CR.ShapeA = CR.Center + Dir * StretchDist; + CR.ShapeB = CR.Center - Dir * StretchDist; + CR.ShapeR = FMath::Min(CR.RadiusXY * 0.6f, CR.RadiusZ); + } + else + { + // ELLIPSOID (default): smooth oval chamber + CR.ShapeType = 0; + CR.ShapeA = FVector(CR.RadiusXY, CR.RadiusXY, CR.RadiusZ); + } + } + // Flat floor cut: soft floor plane per room, hash-rolled from [Min, Max]. // SmoothMax applied in EvaluateSDFCached so tunnels/pits don't create hard seams. // Sentinel -FLT_MAX means "no cut" so the per-voxel check is a single compare. @@ -604,110 +677,67 @@ void VoxelCaveMorphology::BuildChunkCache( OpParams = FStrateGenerationParams{}; CR.RoomOp->ApplyTo(OpParams, CR.RoomOpWeight); - // PITS - if (OpParams.PitDensity > 0.0f) - { - const int32 MaxPits = 2; - for (int32 i = 0; i < MaxPits; i++) + // PITS — downward shafts anchored in the room's lower half. + BakeRoomFeature(CR, /*Max*/2, OpParams.PitDensity, + 0xDE1A7Eu, 6271u, 0xABCDu, 0x5EEDu, + /*XYScale*/0.6f, OpParams.PitMinRadius, OpParams.PitMaxRadius, + [&](float PX, float PY, float PitRadius, uint32 PH3) { - uint32 PH = VoxelHash::Mix(BR.Hash ^ (0xDE1A7Eu + (uint32)i * 6271u)); - if (VoxelHash::ToFloat01(PH) > OpParams.PitDensity) continue; - - uint32 PH2 = VoxelHash::Mix(PH ^ 0xABCDu); - uint32 PH3 = VoxelHash::Mix(PH2 ^ 0x5EEDu); - uint32 PH4 = VoxelHash::Mix(PH3 ^ 0xF00Du); - - float PX = CR.Center.X + VoxelHash::ToFloatSigned(PH2) * CR.RadiusXY * 0.6f; - float PY = CR.Center.Y + VoxelHash::ToFloatSigned(VoxelHash::Mix(PH2)) * CR.RadiusXY * 0.6f; - - float PitRadius = FMath::Lerp(OpParams.PitMinRadius, OpParams.PitMaxRadius, - VoxelHash::ToFloat01(PH3)); - - float PitTopZ = CR.Center.Z - CR.RadiusZ * 0.5f - + VoxelHash::ToFloat01(PH4) * CR.RadiusZ * 0.2f; - + const uint32 PH4 = VoxelHash::Mix(PH3 ^ 0xF00Du); FCachedPit Pit; Pit.CenterX = PX; Pit.CenterY = PY; - Pit.TopZ = PitTopZ; + Pit.TopZ = CR.Center.Z - CR.RadiusZ * 0.5f + + VoxelHash::ToFloat01(PH4) * CR.RadiusZ * 0.2f; Pit.Radius = PitRadius; Pit.Depth = OpParams.PitDepth; Pit.FlareDist = PitRadius * 2.0f; Pit.FlareExtra = PitRadius * 1.0f; Pit.BaseDensity = Params.BaseDensity; Pit.BlendK = Params.SDFBlendRadius; - float MaxXYR = PitRadius + PitRadius + Params.SDFBlendRadius + 4.0f; + const float MaxXYR = PitRadius + PitRadius + Params.SDFBlendRadius + 4.0f; Pit.BoundXYRadiusSq = MaxXYR * MaxXYR; OutCache.Pits.Add(Pit); - } - } + }); - // CHIMNEYS - if (OpParams.ChimneyDensity > 0.0f) - { - const int32 MaxChimneys = 2; - for (int32 i = 0; i < MaxChimneys; i++) + // CHIMNEYS — mirror of pits: upward tubes anchored in the room's upper half. + BakeRoomFeature(CR, /*Max*/2, OpParams.ChimneyDensity, + 0xC4F007u, 7919u, 0x1337u, 0xCAFEu, + /*XYScale*/0.6f, OpParams.ChimneyMinRadius, OpParams.ChimneyMaxRadius, + [&](float CX, float CY, float ChmRadius, uint32 CH3) { - uint32 CH = VoxelHash::Mix(BR.Hash ^ (0xC4F007u + (uint32)i * 7919u)); - if (VoxelHash::ToFloat01(CH) > OpParams.ChimneyDensity) continue; - - uint32 CH2 = VoxelHash::Mix(CH ^ 0x1337u); - uint32 CH3 = VoxelHash::Mix(CH2 ^ 0xCAFEu); - uint32 CH4 = VoxelHash::Mix(CH3 ^ 0xD00Du); - - float CX = CR.Center.X + VoxelHash::ToFloatSigned(CH2) * CR.RadiusXY * 0.6f; - float CY = CR.Center.Y + VoxelHash::ToFloatSigned(VoxelHash::Mix(CH2)) * CR.RadiusXY * 0.6f; - - float ChmRadius = FMath::Lerp(OpParams.ChimneyMinRadius, OpParams.ChimneyMaxRadius, - VoxelHash::ToFloat01(CH3)); - - float ChmBottomZ = CR.Center.Z + CR.RadiusZ * 0.5f - - VoxelHash::ToFloat01(CH4) * CR.RadiusZ * 0.2f; - + const uint32 CH4 = VoxelHash::Mix(CH3 ^ 0xD00Du); FCachedChimney Chim; Chim.CenterX = CX; Chim.CenterY = CY; - Chim.BottomZ = ChmBottomZ; + Chim.BottomZ = CR.Center.Z + CR.RadiusZ * 0.5f + - VoxelHash::ToFloat01(CH4) * CR.RadiusZ * 0.2f; Chim.Radius = ChmRadius; Chim.Height = OpParams.ChimneyHeight; Chim.FlareDist = ChmRadius * 2.0f; Chim.FlareExtra = ChmRadius * 1.0f; Chim.BaseDensity = Params.BaseDensity; Chim.BlendK = Params.SDFBlendRadius; - float MaxXYR = ChmRadius + ChmRadius + Params.SDFBlendRadius + 4.0f; + const float MaxXYR = ChmRadius + ChmRadius + Params.SDFBlendRadius + 4.0f; Chim.BoundXYRadiusSq = MaxXYR * MaxXYR; OutCache.Chimneys.Add(Chim); - } - } + }); - // COLUMNS - if (OpParams.ColumnDensity > 0.0f) - { - const int32 MaxCols = 4; - for (int32 i = 0; i < MaxCols; i++) + // COLUMNS — full-height solid cylinders (no Z anchor, no flare). + BakeRoomFeature(CR, /*Max*/4, OpParams.ColumnDensity, + 0xC01C01u, 3571u, 0x1A2B3Cu, 0xBEEFu, + /*XYScale*/0.75f, OpParams.ColumnMinRadius, OpParams.ColumnMaxRadius, + [&](float ColX, float ColY, float ColR, uint32 /*H3*/) { - uint32 H = VoxelHash::Mix(BR.Hash ^ (0xC01C01u + (uint32)i * 3571u)); - if (VoxelHash::ToFloat01(H) > OpParams.ColumnDensity) continue; - - uint32 H2 = VoxelHash::Mix(H ^ 0x1A2B3Cu); - - float ColX = CR.Center.X + VoxelHash::ToFloatSigned(H2) * CR.RadiusXY * 0.75f; - float ColY = CR.Center.Y + VoxelHash::ToFloatSigned(VoxelHash::Mix(H2)) * CR.RadiusXY * 0.75f; - - uint32 H3 = VoxelHash::Mix(H2 ^ 0xBEEFu); - float ColR = FMath::Lerp(OpParams.ColumnMinRadius, OpParams.ColumnMaxRadius, - VoxelHash::ToFloat01(H3)); - FCachedColumn Col; Col.CenterX = ColX; Col.CenterY = ColY; Col.Radius = ColR; Col.BaseDensity = Params.BaseDensity; - float MaxXYR = ColR + 6.0f; + const float MaxXYR = ColR + 6.0f; Col.BoundXYRadiusSq = MaxXYR * MaxXYR; OutCache.Columns.Add(Col); - } - } + }); } } @@ -723,7 +753,6 @@ float VoxelCaveMorphology::EvaluateSDFCached( float WorldX, float WorldY, float WorldZ, const FChunkSDFCache& Cache, float SDFBlendRadius, - float RoomShapeVariety, int32* OutNearestRoomIdx) { float MinSDF = FLT_MAX; @@ -748,42 +777,13 @@ float VoxelCaveMorphology::EvaluateSDFCached( const float DistSq = FVector::DistSquared(Pos, Room.Center); if (DistSq > Room.CullRadiusSq) continue; - // --- SHAPE SELECTION --- + // --- SHAPE (pre-baked in BuildChunkCache — no per-voxel hash roll / trig) --- float RoomSDF; - const uint32 ShapeHash = VoxelHash::Mix(Room.Hash ^ 0xDEADBEEFu); - const float ShapeRoll = Room.bIsOrigin ? 0.0f : VoxelHash::ToFloat01(ShapeHash); - - // Thresholds: Variety=0 → all ellipsoid. Variety=1 → 50/30/20 split. - const float BoxThreshold = 1.0f - RoomShapeVariety * 0.5f; - const float CapsuleThreshold = 1.0f - RoomShapeVariety * 0.2f; - - if (ShapeRoll >= BoxThreshold && ShapeRoll < CapsuleThreshold) + switch (Room.ShapeType) { - // ROUNDED BOX: angular chamber with smooth corners - FVector HalfExtent( - Room.RadiusXY * 0.8f, - Room.RadiusXY * 0.8f, - Room.RadiusZ * 0.8f - ); - float Rounding = Room.RadiusXY * 0.25f; - RoomSDF = VoxelSDF::RoundedBox(Pos, Room.Center, HalfExtent, Rounding); - } - else if (ShapeRoll >= CapsuleThreshold) - { - // ELONGATED CAPSULE: stretched hall/corridor-room - float DirAngle = VoxelHash::ToFloat01(VoxelHash::Mix(Room.Hash ^ 0xCAFEBABEu)) * 2.0f * PI; - float StretchDist = Room.RadiusXY * 0.7f; - FVector Dir(FMath::Cos(DirAngle), FMath::Sin(DirAngle), 0.0f); - FVector EndA = Room.Center + Dir * StretchDist; - FVector EndB = Room.Center - Dir * StretchDist; - float CapsuleR = FMath::Min(Room.RadiusXY * 0.6f, Room.RadiusZ); - RoomSDF = VoxelSDF::Capsule(Pos, EndA, EndB, CapsuleR); - } - else - { - // ELLIPSOID (default): smooth oval chamber - const FVector Radii(Room.RadiusXY, Room.RadiusXY, Room.RadiusZ); - RoomSDF = VoxelSDF::Ellipsoid(Pos, Room.Center, Radii); + case 1: RoomSDF = VoxelSDF::RoundedBox(Pos, Room.Center, Room.ShapeA, Room.ShapeR); break; + case 2: RoomSDF = VoxelSDF::Capsule(Pos, Room.ShapeA, Room.ShapeB, Room.ShapeR); break; + default: RoomSDF = VoxelSDF::Ellipsoid(Pos, Room.Center, Room.ShapeA); break; } // Soft floor: SmoothMax of the room SDF and the floor half-space. @@ -883,6 +883,6 @@ float VoxelCaveMorphology::EvaluateSDF( return EvaluateSDFCached( WorldX, WorldY, WorldZ, - TempCache, Params.SDFBlendRadius, Params.RoomShapeVariety + TempCache, Params.SDFBlendRadius ); } diff --git a/Source/VoxelForge/Private/VoxelContentManager.cpp b/Source/VoxelForge/Private/VoxelContentManager.cpp index 26d72c9..feeef52 100644 --- a/Source/VoxelForge/Private/VoxelContentManager.cpp +++ b/Source/VoxelForge/Private/VoxelContentManager.cpp @@ -23,6 +23,10 @@ // HISM instances are exempt — they're batched render data, capped per entry by MaxPerChunk. static constexpr int32 GMaxDecorationActorsPerCell = 400; +// Hard cap on total companion satellites (level 1 + level 2) spawned per placed PARENT — the safety net that +// makes 2-level nesting impossible to blow up regardless of authored counts. +static constexpr int32 GMaxCompanionsPerParent = 256; + // One cell = one chunk XY footprint (so DecorationRadiusChunks reads as a radius in chunks, and a // decoration's per-cell MaxPerChunk keeps its "per chunk" meaning). static constexpr int32 DECO_CELL_VOXELS = CHUNK_SIZE; @@ -429,10 +433,10 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr CosMinSlope.SetNumUninitialized(Entries.Num()); for (int32 e = 0; e < Entries.Num(); ++e) { - CosMaxSlope[e] = (Entries[e].MaxSlopeAngle < 89.99f) - ? FMath::Cos(FMath::DegreesToRadians(Entries[e].MaxSlopeAngle)) : -1.0f; - CosMinSlope[e] = (Entries[e].MinSlopeAngle > 0.01f) - ? FMath::Cos(FMath::DegreesToRadians(Entries[e].MinSlopeAngle)) : -1.0f; + CosMaxSlope[e] = (Entries[e].Profile.MaxSlopeAngle < 89.99f) + ? FMath::Cos(FMath::DegreesToRadians(Entries[e].Profile.MaxSlopeAngle)) : -1.0f; + CosMinSlope[e] = (Entries[e].Profile.MinSlopeAngle > 0.01f) + ? FMath::Cos(FMath::DegreesToRadians(Entries[e].Profile.MinSlopeAngle)) : -1.0f; } // Per-COLUMN biome cache: ResolveBiomeSampleAt's noise-heavy cell classification is box-validated @@ -443,6 +447,26 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr auto D = [&](float VX, float VY, float VZ) { return Gen->GetDensityAt(VX, VY, VZ); }; + // Build a companion satellite's transform from its profile + per-instance hash at a snapped surface point + // (voxel XY/Z + outward normal). Shared by level-1 and level-2 companions. + auto MakeCompanionXf = [&](const FPlacementProfile& P, uint32 IHash, + float sVX, float sVY, float sZ, const FVector& sN) -> FTransform + { + const FVector Local(sVX * VOXEL_SIZE, sVY * VOXEL_SIZE, sZ * VOXEL_SIZE); + const FVector Pos = OwnerXf.TransformPosition(Local) + sN * P.SurfaceOffset + P.LocationOffset; + FQuat Q = P.bAlignToSurface ? FRotationMatrix::MakeFromZ(sN).ToQuat() : FQuat::Identity; + Q = Q * P.RotationOffset.Quaternion(); + if (!P.RandomRotation.IsNearlyZero()) + { + const float rp = (VoxelHash::ToFloat01(VoxelHash::Mix(IHash ^ 0x1111A1u)) - 0.5f) * P.RandomRotation.Pitch; + const float ry = (VoxelHash::ToFloat01(VoxelHash::Mix(IHash ^ 0x2222B2u)) - 0.5f) * P.RandomRotation.Yaw; + const float rr = (VoxelHash::ToFloat01(VoxelHash::Mix(IHash ^ 0x3333C3u)) - 0.5f) * P.RandomRotation.Roll; + Q = Q * FRotator(rp, ry, rr).Quaternion(); + } + const float Sc = FMath::Lerp(P.MinScale, P.MaxScale, VoxelHash::ToFloat01(VoxelHash::Mix(IHash ^ 0x5CA1E000u))); + return FTransform(Q, Pos, FVector(Sc)); + }; + // Shared: roll every decoration entry at one surface point (voxel XY, voxel Z, outward world normal) // and append the passing ones to OutSpawns. CrossingIdx salts the hash so stacked surfaces differ. // ColBiome = the column's dominant context-biome index (-1 when biomes are off); an entry is rolled @@ -465,14 +489,14 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr if (EntryBiome[EntryIdx] != ColBiome) continue; const FStrateDecoration& Deco = Entries[EntryIdx]; - const bool bInstanced = (Deco.InstancedMesh != nullptr); - if (!bInstanced && !Deco.ActorClass) continue; + const bool bInstanced = (Deco.Profile.InstancedMesh != nullptr); + if (!bInstanced && !Deco.Profile.ActorClass) continue; if (Deco.SpawnDensity <= 0.0f) continue; if (EntryCount[EntryIdx] >= Deco.MaxPerChunk) continue; if (!bInstanced && TotalActors >= GMaxDecorationActorsPerCell) continue; bool bMatches = true; - switch (Deco.SurfacePlacement) + switch (Deco.Profile.SurfacePlacement) { case ESurfaceType::Floor: bMatches = bFloor; break; case ESurfaceType::Wall: bMatches = bWall; break; @@ -484,7 +508,7 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr // Strict-wall overhang gate: a "wall" point also covers surfaces that lean slightly downward // (N.Z in [-0.5, 0)). For props flagged wall-only-upright, drop those so overhangs don't take // wall decals. Applies whenever the point IS a wall (independent of Floor/Wall/Any setting). - if (bWall && Deco.bWallExcludeOverhangs && NormalWorld.Z < 0.0f) continue; + if (bWall && Deco.Profile.bWallExcludeOverhangs && NormalWorld.Z < 0.0f) continue; // Surface-tilt gates: tilt = acos(|N.Z|) (0 = flat, 90 = vertical). |N.Z| < cos(MaxSlope) ⇔ // tilt > MaxSlope (skip steeper); |N.Z| > cos(MinSlope) ⇔ tilt < MinSlope (skip flatter). @@ -495,26 +519,34 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr const uint32 H = DecoHash(Cell.X, Cell.Y, gx, gy, CrossingIdx, EntryIdx, InSeed, 0xDEC0u); if (VoxelHash::ToFloat01(H) > Deco.SpawnDensity) continue; - if (Deco.bRequireWaterRelative && Ctx.bHasWater) + if (Deco.Profile.bRequireWaterRelative && Ctx.bHasWater) { - if (bBelowWater != Deco.bPlaceBelowWater) continue; + if (bBelowWater != Deco.Profile.bPlaceBelowWater) continue; } - const FVector SpawnPos = PosWorld + NormalWorld * Deco.SurfaceOffset; - FQuat BaseQ = Deco.bAlignToSurface + // F7 aware placement: relational conditions (relief/moisture/biome-border). Opt-in per entry — + // skipped entirely when the list is empty. Worker-safe pure query; uses the cell's biome context. + if (Deco.Profile.Conditions.Num() > 0 && + !Gen->EvaluateTerrainConditions(Deco.Profile.Conditions, VX, VY, Ctx.BiomeCtx)) continue; + + const FVector SpawnPos = PosWorld + NormalWorld * Deco.Profile.SurfaceOffset + Deco.Profile.LocationOffset; + // Rotation: optional surface-align → fixed offset → per-axis hash random (same model as landmarks). + // RandomRotation.Yaw defaults to 360 for decoration (see FStrateDecoration ctor) = full random + // heading, reproducing the legacy random-yaw look; the exact per-instance yaw values reshuffle + // once (different hash mix) but the distribution is identical. + FQuat BaseQ = Deco.Profile.bAlignToSurface ? FRotationMatrix::MakeFromZ(NormalWorld).ToQuat() : FQuat::Identity; - if (Deco.bRandomYaw) + BaseQ = BaseQ * Deco.Profile.RotationOffset.Quaternion(); + if (!Deco.Profile.RandomRotation.IsNearlyZero()) { - // Roll within [MinYaw, MaxYaw]; the default 0..360 reproduces the legacy full-turn roll - // bit-for-bit (Lerp(0,360,t)° == t·2π rad), so existing assets are unchanged. - const float YawT = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x59415721u)); - const float Yaw = FMath::DegreesToRadians(FMath::Lerp(Deco.MinYaw, Deco.MaxYaw, YawT)); - const FVector Axis = Deco.bAlignToSurface ? NormalWorld : FVector::UpVector; - BaseQ = FQuat(Axis, Yaw) * BaseQ; + const float rp = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x1111A1u)) - 0.5f) * Deco.Profile.RandomRotation.Pitch; + const float ry = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x2222B2u)) - 0.5f) * Deco.Profile.RandomRotation.Yaw; + const float rr = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x3333C3u)) - 0.5f) * Deco.Profile.RandomRotation.Roll; + BaseQ = BaseQ * FRotator(rp, ry, rr).Quaternion(); } const float ScaleT = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x5CA1E000u)); - const float Scale = FMath::Lerp(Deco.MinScale, Deco.MaxScale, ScaleT); + const float Scale = FMath::Lerp(Deco.Profile.MinScale, Deco.Profile.MaxScale, ScaleT); FDecoSpawn& Out = OutSpawns.AddDefaulted_GetRef(); Out.EntryIdx = EntryIdx; @@ -522,6 +554,96 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr Out.Xf = FTransform(BaseQ, SpawnPos, FVector(Scale)); ++EntryCount[EntryIdx]; if (!bInstanced) ++TotalActors; + + // ---- Companions (F7 relational placement): deterministic cluster satellites around this parent. + // Level 1 re-snaps to the real surface at its own XY; level-2 SubCompanions inherit their L1 + // satellite's point (no re-snap → cheap). A per-parent budget caps the total so nesting can't blow + // up. Pure function of the parent hash H → no "did a tree spawn here?" search. + int32 CompBudget = GMaxCompanionsPerParent; + for (int32 ci = 0; ci < Deco.Companions.Num() && CompBudget > 0; ++ci) + { + const FDecoCompanion& Comp = Deco.Companions[ci]; + const bool bCompInst = (Comp.Profile.InstancedMesh != nullptr); + if (!bCompInst && !Comp.Profile.ActorClass) continue; + + const uint32 CH = VoxelHash::Mix(H ^ (0x00C0FFEEu + (uint32)ci * 0x9E3779B1u)); + if (VoxelHash::ToFloat01(CH) > Comp.Probability) continue; + + const int32 Span = FMath::Max(0, Comp.CountMax - Comp.CountMin); + const int32 Count = Comp.CountMin + (int32)(VoxelHash::ToFloat01(VoxelHash::Mix(CH ^ 0x1234u)) * (float)(Span + 1)); + for (int32 ii = 0; ii < Count; ++ii) + { + if (CompBudget <= 0) break; + if (!bCompInst && TotalActors >= GMaxDecorationActorsPerCell) break; // actor budget + + const uint32 IH = VoxelHash::Mix(CH ^ ((uint32)ii * 0x85EBCA77u + 0x2545F491u)); + const float Ang = VoxelHash::ToFloat01(IH) * 2.0f * PI; + const float RadVox = FMath::Lerp(Comp.RadiusMinVox, Comp.RadiusMaxVox, + VoxelHash::ToFloat01(VoxelHash::Mix(IH ^ 0x77u))); + const float SatVX = VX + FMath::Cos(Ang) * RadVox; // voxel, actor-local + const float SatVY = VY + FMath::Sin(Ang) * RadVox; + + // Optional per-satellite gating (relief/moisture/biome-border at ITS own XY). + if (Comp.Profile.Conditions.Num() > 0 && + !Gen->EvaluateTerrainConditions(Comp.Profile.Conditions, SatVX, SatVY, Ctx.BiomeCtx)) continue; + + // Surface: SNAP to the real ground/ceiling at the satellite XY (kills floaters); or inherit. + float SatZ; FVector SatN; + if (Comp.bSnapToSurface) + { + if (!FindLandmarkColumn(Gen, OwnerXf, Ctx, SatVX, SatVY, Comp.Profile.SurfacePlacement, + Step, ColumnDepth, SatZ, SatN)) continue; // no surface → no floater + } + else { SatZ = ZC; SatN = NormalWorld; } + + FDecoSpawn& CO = OutSpawns.AddDefaulted_GetRef(); + CO.EntryIdx = EntryIdx; + CO.CompanionIdx = ci; + CO.bInstanced = bCompInst; + CO.Xf = MakeCompanionXf(Comp.Profile, IH, SatVX, SatVY, SatZ, SatN); + if (!bCompInst) ++TotalActors; + --CompBudget; + + // ---- Level 2: SubCompanions ON this satellite (moss on a rock). Inherit the L1 satellite's + // snapped point (SatZ, SatN) — no re-snap, so nesting stays cheap. Small disk around it. + for (int32 si = 0; si < Comp.SubCompanions.Num() && CompBudget > 0; ++si) + { + const FDecoSubCompanion& Sub = Comp.SubCompanions[si]; + const bool bSubInst = (Sub.Profile.InstancedMesh != nullptr); + if (!bSubInst && !Sub.Profile.ActorClass) continue; + + const uint32 SH = VoxelHash::Mix(IH ^ (0x0000544Bu + (uint32)si * 0x27D4EB2Fu)); + if (VoxelHash::ToFloat01(SH) > Sub.Probability) continue; + + const int32 SubSpan = FMath::Max(0, Sub.CountMax - Sub.CountMin); + const int32 SubCount = Sub.CountMin + (int32)(VoxelHash::ToFloat01(VoxelHash::Mix(SH ^ 0x1234u)) * (float)(SubSpan + 1)); + for (int32 sj = 0; sj < SubCount; ++sj) + { + if (CompBudget <= 0) break; + if (!bSubInst && TotalActors >= GMaxDecorationActorsPerCell) break; + + const uint32 JH = VoxelHash::Mix(SH ^ ((uint32)sj * 0x85EBCA77u + 0x165667B1u)); + const float SAng = VoxelHash::ToFloat01(JH) * 2.0f * PI; + const float SRadVox = FMath::Lerp(Sub.RadiusMinVox, Sub.RadiusMaxVox, + VoxelHash::ToFloat01(VoxelHash::Mix(JH ^ 0x77u))); + const float SubVX = SatVX + FMath::Cos(SAng) * SRadVox; + const float SubVY = SatVY + FMath::Sin(SAng) * SRadVox; + + if (Sub.Profile.Conditions.Num() > 0 && + !Gen->EvaluateTerrainConditions(Sub.Profile.Conditions, SubVX, SubVY, Ctx.BiomeCtx)) continue; + + FDecoSpawn& SO = OutSpawns.AddDefaulted_GetRef(); + SO.EntryIdx = EntryIdx; + SO.CompanionIdx = ci; + SO.SubIdx = si; + SO.bInstanced = bSubInst; + SO.Xf = MakeCompanionXf(Sub.Profile, JH, SubVX, SubVY, SatZ, SatN); + if (!bSubInst) ++TotalActors; + --CompBudget; + } + } + } + } } }; @@ -703,20 +825,30 @@ void UVoxelContentManager::MergeCellResult(FDecoGrid& G, const FDecoCellResult& { if (!Result.Entries.IsValidIndex(S.EntryIdx)) continue; const FStrateDecoration& Deco = Result.Entries[S.EntryIdx]; + // Resolve which profile owns this spawn: the entry itself (CompanionIdx<0), a level-1 companion, or a + // level-2 sub-companion (F7). Each carries its own mesh/actor + render tuning. + const FPlacementProfile* ProfPtr = &Deco.Profile; + if (S.CompanionIdx >= 0 && Deco.Companions.IsValidIndex(S.CompanionIdx)) + { + const FDecoCompanion& Comp = Deco.Companions[S.CompanionIdx]; + ProfPtr = (S.SubIdx >= 0 && Comp.SubCompanions.IsValidIndex(S.SubIdx)) + ? &Comp.SubCompanions[S.SubIdx].Profile : &Comp.Profile; + } + const FPlacementProfile& Prof = *ProfPtr; if (S.bInstanced) { - if (!Deco.InstancedMesh) continue; + if (!Prof.InstancedMesh) continue; // Bucket by MESH so cells (and biomes) sharing a mesh collapse into one region HISM. The first // contributor sets the render tuning (cull/shadow/scale) for the whole region's instances. - FRegionMeshBucket& Bucket = Build->MeshBuckets.FindOrAdd(Deco.InstancedMesh); - if (Bucket.Xforms.Num() == 0) { Bucket.Deco = Deco; } + FRegionMeshBucket& Bucket = Build->MeshBuckets.FindOrAdd(Prof.InstancedMesh); + if (Bucket.Xforms.Num() == 0) { Bucket.Profile = Prof; } Bucket.Xforms.Add(S.Xf); } - else if (Deco.ActorClass) + else if (Prof.ActorClass) { FRegionActorSpawn& A = Build->ActorSpawns.AddDefaulted_GetRef(); - A.ActorClass = Deco.ActorClass; + A.ActorClass = Prof.ActorClass; A.Xf = S.Xf; } } @@ -776,7 +908,7 @@ void UVoxelContentManager::ApplyRegion(FDecoGrid& G, const FIntPoint& Region, FD FRegionMeshBucket& Bucket = Pair.Value; UStaticMesh* Mesh = Pair.Key.Get(); if (!Mesh || Bucket.Xforms.Num() == 0) continue; - const FStrateDecoration& Deco = Bucket.Deco; + const FPlacementProfile& Prof = Bucket.Profile; UHierarchicalInstancedStaticMeshComponent* HISM = NewObject(OwnerActor); @@ -792,11 +924,11 @@ void UVoxelContentManager::ApplyRegion(FDecoGrid& G, const FIntPoint& Region, FD // so the render proxy is created once with the final state (no rebuild): // • CullDistance bounds GPU cost — grass is drawn only near the player even when placed thickly. // • bCastShadow off removes the dominant cost of dense instanced foliage. - HISM->SetCastShadow(Deco.bCastShadow); - if (Deco.CullDistance > 0.0f) + HISM->SetCastShadow(Prof.bCastShadow); + if (Prof.CullDistance > 0.0f) { - const int32 End = FMath::Max(1, (int32)Deco.CullDistance); - const int32 Start = FMath::Max(1, (int32)(Deco.CullDistance * 0.8f)); + const int32 End = FMath::Max(1, (int32)Prof.CullDistance); + const int32 Start = FMath::Max(1, (int32)(Prof.CullDistance * 0.8f)); HISM->SetCullDistances(Start, End); // fade band 0.8x→1.0x, then gone } @@ -815,6 +947,21 @@ void UVoxelContentManager::ApplyRegion(FDecoGrid& G, const FIntPoint& Region, FD } Content.Instances.Add(HISM); } + + // Landmark footprints (F7): a suppressing landmark cleared whatever decorations existed when it spawned, + // but this region just streamed in FRESH → re-clear under any loaded suppressing landmark overlapping it + // (the per-HISM spatial query early-outs for the non-overlapping majority). Keeps temple floors clear as + // you leave and return. + if (Content.Instances.Num() > 0) + { + for (const TPair& LP : LandmarkInstances) + { + if (LP.Value.SuppressRadiusWorld > 0.0f) + { + RemoveInstancesInContent(Content, LP.Value.SuppressCenter, LP.Value.SuppressRadiusWorld); + } + } + } } void UVoxelContentManager::ClearDecorationRegion(FDecoGrid& G, const FIntPoint& Region) @@ -945,93 +1092,111 @@ bool UVoxelContentManager::FindLandmarkColumn(const UVoxelGenerator* Gen, const return false; } +bool UVoxelContentManager::SpawnFromProfile(const FPlacementProfile& P, uint32 H, const FDecoContext& Ctx, + const FTransform& OwnerXf, AActor* OwnerActor, + float LocalX, float LocalY, float Step, float ColDepth, + FTransform& OutXf, FLandmarkInstance& Out) +{ + if (!Generator) return false; + const float VX = LocalX / VOXEL_SIZE; + const float VY = LocalY / VOXEL_SIZE; + + // Biome filter (resolved at the candidate XY, same field the density/deco paths use). + if (P.RequiredBiome) + { + const UVoxelBiomeDefinition* Bio = Generator->GetDominantBiomeAt(VX, VY, Ctx.RepChunkZ); + if (Bio != P.RequiredBiome) return false; // leaves Out empty → evaluated, nothing placed + } + + float ZC; FVector N; + if (!FindLandmarkColumn(Generator, OwnerXf, Ctx, VX, VY, P.SurfacePlacement, Step, ColDepth, ZC, N)) + return false; + + // Surface-tilt gates (acos(|N.Z|); guarded so defaults cost no trig). + if (P.MaxSlopeAngle < 89.99f && + FMath::Abs(N.Z) < FMath::Cos(FMath::DegreesToRadians(P.MaxSlopeAngle))) return false; + if (P.MinSlopeAngle > 0.01f && + FMath::Abs(N.Z) > FMath::Cos(FMath::DegreesToRadians(P.MinSlopeAngle))) return false; + + const FVector LocalPos(LocalX, LocalY, ZC * VOXEL_SIZE); + if (P.bRequireWaterRelative && Ctx.bHasWater) + { + const bool bBelowWater = (LocalPos.Z < Ctx.WaterLocalZ); + if (bBelowWater != P.bPlaceBelowWater) return false; + } + + // F7 aware placement: relational conditions (relief/moisture/biome-border), evaluated at the candidate + // XY. Opt-in — empty list is free. "A monument only on high mesas / near a biome edge" lives here. + if (P.Conditions.Num() > 0 && + !Generator->EvaluateTerrainConditions(P.Conditions, VX, VY, Ctx.BiomeCtx)) return false; + + // Rotation: optional surface-align → fixed offset → per-axis hash random. + FQuat Q = P.bAlignToSurface ? FRotationMatrix::MakeFromZ(N).ToQuat() : FQuat::Identity; + Q = Q * P.RotationOffset.Quaternion(); + if (!P.RandomRotation.IsNearlyZero()) + { + const float rp = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x1111A1u)) - 0.5f) * P.RandomRotation.Pitch; + const float ry = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x2222B2u)) - 0.5f) * P.RandomRotation.Yaw; + const float rr = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x3333C3u)) - 0.5f) * P.RandomRotation.Roll; + Q = Q * FRotator(rp, ry, rr).Quaternion(); + } + + const float ScaleT = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x5CA1E777u)); + const float Scale = FMath::Lerp(P.MinScale, P.MaxScale, ScaleT); + + // World-space position + XYZ offset (e.g. +Z lifts a sun off the sky-cap into the cavern). + const FVector WorldPos = OwnerXf.TransformPosition(LocalPos) + P.LocationOffset; + OutXf = FTransform(Q, WorldPos, FVector(Scale)); + + if (P.ActorClass) + { + UWorld* World = OwnerActor->GetWorld(); + if (!World) return false; + FActorSpawnParameters SP; + SP.Owner = OwnerActor; + SP.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + if (AActor* A = World->SpawnActor(P.ActorClass, OutXf, SP)) { Out.Actor = A; } + return true; + } + if (P.InstancedMesh) + { + UStaticMeshComponent* C = NewObject(OwnerActor); + C->SetStaticMesh(P.InstancedMesh); + C->SetMobility(EComponentMobility::Static); // placed once, never moves → cached draw + VSM shadow + C->SetCollisionEnabled(ECollisionEnabled::NoCollision); + C->SetCastShadow(P.bCastShadow); + if (P.CullDistance > 0.0f) { C->SetCullDistance(P.CullDistance); } // 0 = never cull (far sun) + C->SetWorldTransform(OutXf); + C->RegisterComponent(); + C->AttachToComponent(OwnerActor->GetRootComponent(), FAttachmentTransformRules::KeepWorldTransform); + Out.Component = C; + return true; + } + return false; +} + void UVoxelContentManager::SpawnLandmarkInstance(const FStrateLandmark& L, uint32 H, const FDecoContext& Ctx, const FTransform& OwnerXf, AActor* OwnerActor, float LocalX, float LocalY, float Step, float ColDepth, FLandmarkInstance& Out) { - if (!Generator) return; - const float VX = LocalX / VOXEL_SIZE; - const float VY = LocalY / VOXEL_SIZE; + FTransform Xf; + if (!SpawnFromProfile(L.Profile, H, Ctx, OwnerXf, OwnerActor, LocalX, LocalY, Step, ColDepth, Xf, Out)) + return; // Out stays empty → evaluated, nothing placed - // Biome filter (resolved at the candidate XY, same field the density/deco paths use). - if (L.RequiredBiome) - { - const UVoxelBiomeDefinition* Bio = Generator->GetDominantBiomeAt(VX, VY, Ctx.RepChunkZ); - if (Bio != L.RequiredBiome) return; // leaves Out empty → evaluated, nothing placed - } - - float ZC; FVector N; - if (!FindLandmarkColumn(Generator, OwnerXf, Ctx, VX, VY, L.SurfacePlacement, Step, ColDepth, ZC, N)) - return; - - // Surface-tilt gates (acos(|N.Z|); guarded so defaults cost no trig). - if (L.MaxSlopeAngle < 89.99f && - FMath::Abs(N.Z) < FMath::Cos(FMath::DegreesToRadians(L.MaxSlopeAngle))) return; - if (L.MinSlopeAngle > 0.01f && - FMath::Abs(N.Z) > FMath::Cos(FMath::DegreesToRadians(L.MinSlopeAngle))) return; - - const FVector LocalPos(LocalX, LocalY, ZC * VOXEL_SIZE); - if (L.bRequireWaterRelative && Ctx.bHasWater) - { - const bool bBelowWater = (LocalPos.Z < Ctx.WaterLocalZ); - if (bBelowWater != L.bPlaceBelowWater) return; - } - - // Rotation: optional surface-align → fixed offset → per-axis hash random. - FQuat Q = L.bAlignToSurface ? FRotationMatrix::MakeFromZ(N).ToQuat() : FQuat::Identity; - Q = Q * L.RotationOffset.Quaternion(); - if (!L.RandomRotation.IsNearlyZero()) - { - const float rp = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x1111A1u)) - 0.5f) * L.RandomRotation.Pitch; - const float ry = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x2222B2u)) - 0.5f) * L.RandomRotation.Yaw; - const float rr = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x3333C3u)) - 0.5f) * L.RandomRotation.Roll; - Q = Q * FRotator(rp, ry, rr).Quaternion(); - } - - const float ScaleT = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x5CA1E777u)); - const float Scale = FMath::Lerp(L.MinScale, L.MaxScale, ScaleT); - - // World-space position + XYZ offset (e.g. +Z lifts a sun off the sky-cap into the cavern). - const FVector WorldPos = OwnerXf.TransformPosition(LocalPos) + L.LocationOffset; - const FTransform Xf(Q, WorldPos, FVector(Scale)); - - // Mini-sun light orb: record world-space data for the terrain material's raymarched shadows. Distances - // convert voxels→cm (×VOXEL_SIZE); the emitter radius scales with the instance scale too. + // Mini-sun light orb (landmark-only): record world-space data for the terrain material's raymarched + // shadows. Distances convert voxels→cm (×VOXEL_SIZE); the emitter radius scales with the instance too. if (L.bIsLightOrb) { + const float Scale = Xf.GetScale3D().X; Out.bIsOrb = true; - Out.Orb.WorldPos = WorldPos; + Out.Orb.WorldPos = Xf.GetLocation(); Out.Orb.Color = L.OrbColor; Out.Orb.Intensity = L.OrbIntensity; Out.Orb.RadiusWorld = L.OrbRadiusVoxels * VOXEL_SIZE * Scale; Out.Orb.FalloffWorld = L.OrbFalloffVoxels * VOXEL_SIZE; Out.Orb.MaxShadowDistWorld = L.OrbMaxShadowDistanceVoxels * VOXEL_SIZE; } - - if (L.ActorClass) - { - UWorld* World = OwnerActor->GetWorld(); - if (!World) return; - FActorSpawnParameters SP; - SP.Owner = OwnerActor; - SP.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; - if (AActor* A = World->SpawnActor(L.ActorClass, Xf, SP)) { Out.Actor = A; } - return; - } - if (L.InstancedMesh) - { - UStaticMeshComponent* C = NewObject(OwnerActor); - C->SetStaticMesh(L.InstancedMesh); - C->SetMobility(EComponentMobility::Static); // placed once, never moves → cached draw + VSM shadow - C->SetCollisionEnabled(ECollisionEnabled::NoCollision); - C->SetCastShadow(L.bCastShadow); - if (L.CullDistance > 0.0f) { C->SetCullDistance(L.CullDistance); } // 0 = never cull (far sun) - C->SetWorldTransform(Xf); - C->RegisterComponent(); - C->AttachToComponent(OwnerActor->GetRootComponent(), FAttachmentTransformRules::KeepWorldTransform); - Out.Component = C; - } } void UVoxelContentManager::GetActiveOrbs(TArray& OutOrbs) const @@ -1057,6 +1222,38 @@ void UVoxelContentManager::ClearAllLandmarks() LandmarkInstances.Reset(); } +int32 UVoxelContentManager::RemoveInstancesInContent(FDecoRegionContent& Content, const FVector& Center, float Radius) +{ + int32 Removed = 0; + for (TWeakObjectPtr& WP : Content.Instances) + { + UHierarchicalInstancedStaticMeshComponent* HISM = WP.Get(); + if (!HISM || HISM->GetInstanceCount() == 0) continue; + // Fast bounds-tested spatial query → the vast majority of region HISMs return empty immediately. + TArray Hits = HISM->GetInstancesOverlappingSphere(Center, Radius, /*bSphereInWorldSpace*/ true); + if (Hits.Num() > 0) + { + HISM->RemoveInstances(Hits); // handles index shifting; marks render state dirty + Removed += Hits.Num(); + } + } + return Removed; +} + +int32 UVoxelContentManager::RemoveDecorationsInSphere(const FVector& WorldCenter, float WorldRadius) +{ + if (WorldRadius <= 0.0f) return 0; + int32 Removed = 0; + for (FDecoGrid* G : { &NearGrid, &FarGrid }) + { + for (TPair& RP : G->Regions) + { + Removed += RemoveInstancesInContent(RP.Value, WorldCenter, WorldRadius); + } + } + return Removed; +} + void UVoxelContentManager::UpdateLandmarks(const FVector& PlayerWorldPos) { if (!StrateManager || !Generator || !Settings) return; @@ -1109,53 +1306,141 @@ void UVoxelContentManager::UpdateLandmarks(const FVector& PlayerWorldPos) const float ColDepth = (float)FMath::Max(8, Settings->DecorationColumnDepthVoxels); const uint32 LocalSeed = (uint32)Seed; - // Walk each entry's lattice within its radius (a tiny box), spawn newly-entered cells, drop exited ones. - TSet Desired; + // ---- Gather candidates (a small set) across every entry + anchor mode. Local XY (actor-space cm) is + // enough for the exclusion test — the surface-find only moves Z. ---- + // bSpawnable = within the entry's real stream radius. POP-FREE EXCLUSION: we gather each entry in + // (radius + MaxExcl) so every conflictor of an in-range candidate is present regardless of player + // position; the extra "ring" candidates only SUPPRESS (never spawn), so a candidate's fate is a pure + // function of (seed, layout) → no edge-of-radius flicker. + struct FCand { FIntVector Key; int32 EntryIdx; uint32 H; float LocalX, LocalY; float ExclWorld; int32 Priority; bool bSpawnable; }; + TArray Cands; + bool bAnyExclusion = false; // pure scatter (all radii 0) skips the O(n²) resolve → same cost as before + + float MaxExclChunks = 0.0f; + for (const FStrateLandmark& LE : Def->Landmarks) { MaxExclChunks = FMath::Max(MaxExclChunks, FMath::Max(0.0f, LE.ExclusionRadiusChunks)); } + const float MaxExclWorld = MaxExclChunks * ChunkWorld; + for (int32 EntryIdx = 0; EntryIdx < Def->Landmarks.Num(); ++EntryIdx) { const FStrateLandmark& L = Def->Landmarks[EntryIdx]; - if (!L.ActorClass && !L.InstancedMesh) continue; + if (!L.Profile.ActorClass && !L.Profile.InstancedMesh) continue; - const float SpacingChunks = FMath::Max(1.0f, L.SpacingChunks); - const int32 RadiusChunks = FMath::Max(1, L.StreamRadiusChunks); - const float CellWorld = SpacingChunks * ChunkWorld; // lattice cell size in cm - const float RadiusWorld = (float)RadiusChunks * ChunkWorld; - const float JitterRange = FMath::Clamp(L.JitterFraction, 0.0f, 1.0f); + const int32 RadiusChunks = FMath::Max(1, L.StreamRadiusChunks); + const float RadiusWorld = (float)RadiusChunks * ChunkWorld; + const float GatherWorld = RadiusWorld + MaxExclWorld; // widened so all conflictors are gathered + const float ExclWorld = FMath::Max(0.0f, L.ExclusionRadiusChunks) * ChunkWorld; + if (ExclWorld > 0.0f) bAnyExclusion = true; - const FIntPoint PlayerLCell(FMath::FloorToInt(LocalPlayer.X / CellWorld), - FMath::FloorToInt(LocalPlayer.Y / CellWorld)); - const int32 CellRange = FMath::CeilToInt((float)RadiusChunks / SpacingChunks); - - for (int32 dy = -CellRange; dy <= CellRange; ++dy) - for (int32 dx = -CellRange; dx <= CellRange; ++dx) + if (L.AnchorMode == ELandmarkAnchor::HashLattice) { - const FIntPoint LCell(PlayerLCell.X + dx, PlayerLCell.Y + dy); + const float SpacingChunks = FMath::Max(1.0f, L.SpacingChunks); + const float CellWorld = SpacingChunks * ChunkWorld; // lattice cell size in cm + const float JitterRange = FMath::Clamp(L.JitterFraction, 0.0f, 1.0f); + const FIntPoint PlayerLCell(FMath::FloorToInt(LocalPlayer.X / CellWorld), + FMath::FloorToInt(LocalPlayer.Y / CellWorld)); + const int32 CellRange = FMath::CeilToInt(((float)RadiusChunks + MaxExclChunks) / SpacingChunks); - // Existence roll for this lattice cell + entry. - const uint32 H = DecoHash(LCell.X, LCell.Y, 0, 0, 0, EntryIdx, LocalSeed, 0x1A2D5u); - if (VoxelHash::ToFloat01(H) > L.SpawnProbability) continue; + for (int32 dy = -CellRange; dy <= CellRange; ++dy) + for (int32 dx = -CellRange; dx <= CellRange; ++dx) + { + const FIntPoint LCell(PlayerLCell.X + dx, PlayerLCell.Y + dy); - // Jittered position inside the cell (centred so two neighbours stay ≥ Spacing·(1-Jitter) apart). - const float jx = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x51A3F1u)) - 0.5f) * JitterRange; - const float jy = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x7C2B93u)) - 0.5f) * JitterRange; - const float LocalX = ((float)LCell.X + 0.5f + jx) * CellWorld; - const float LocalY = ((float)LCell.Y + 0.5f + jy) * CellWorld; + // Existence roll (salt 0x1A2D5u kept from the original landmark path → positions unchanged). + const uint32 H = DecoHash(LCell.X, LCell.Y, 0, 0, 0, EntryIdx, LocalSeed, 0x1A2D5u); + if (VoxelHash::ToFloat01(H) > L.SpawnProbability) continue; - // Radius is a true disk (the lattice box corners would otherwise overshoot it). - const float ddx = LocalX - LocalPlayer.X, ddy = LocalY - LocalPlayer.Y; - if (ddx * ddx + ddy * ddy > RadiusWorld * RadiusWorld) continue; + const float jx = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x51A3F1u)) - 0.5f) * JitterRange; + const float jy = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x7C2B93u)) - 0.5f) * JitterRange; + const float LocalX = ((float)LCell.X + 0.5f + jx) * CellWorld; + const float LocalY = ((float)LCell.Y + 0.5f + jy) * CellWorld; - const FIntVector Key(LCell.X, LCell.Y, EntryIdx); - Desired.Add(Key); - if (LandmarkInstances.Contains(Key)) continue; // already evaluated (spawned OR empty) + const float ddx = LocalX - LocalPlayer.X, ddy = LocalY - LocalPlayer.Y; + const float DistSq = ddx * ddx + ddy * ddy; + if (DistSq > GatherWorld * GatherWorld) continue; - FLandmarkInstance Inst; - SpawnLandmarkInstance(L, H, Ctx, OwnerXf, OwnerActor, LocalX, LocalY, Step, ColDepth, Inst); - LandmarkInstances.Add(Key, Inst); // stored even if empty → never re-evaluated while in range + Cands.Add({ FIntVector(LCell.X, LCell.Y, EntryIdx), EntryIdx, H, LocalX, LocalY, ExclWorld, L.Priority, + DistSq <= RadiusWorld * RadiusWorld }); + } + } + else // PassageMouth — enumerate the finite passage list, keep endpoints that land in THIS strate. + { + const TArray& Passages = StrateManager->GetPassages(); + for (int32 pi = 0; pi < Passages.Num(); ++pi) + { + const FVoxelPassage& Pg = Passages[pi]; + for (int32 side = 0; side < 2; ++side) + { + const bool bDescent = (side == 0); // 0 = this strate is the passage's UPPER (hole DOWN) + if (bDescent && !(L.bAtDescentMouths && Pg.UpperStrateIndex == StrateIndex)) continue; + if (!bDescent && !(L.bAtArrivalMouths && Pg.LowerStrateIndex == StrateIndex)) continue; + + // Passage endpoints are GLOBAL VOXEL coords (same space as the SDF path) → local cm. + const FVector MouthVox = bDescent ? Pg.UpperPoint : Pg.LowerPoint; + const float LocalX = MouthVox.X * VOXEL_SIZE; + const float LocalY = MouthVox.Y * VOXEL_SIZE; + + const float ddx = LocalX - LocalPlayer.X, ddy = LocalY - LocalPlayer.Y; + const float DistSq = ddx * ddx + ddy * ddy; + if (DistSq > GatherWorld * GatherWorld) continue; + + const uint32 H = DecoHash(pi, side, 0, 0, 0, EntryIdx, LocalSeed, 0x5E7C9u); + if (VoxelHash::ToFloat01(H) > L.MouthProbability) continue; + + Cands.Add({ FIntVector(pi, side, EntryIdx), EntryIdx, H, LocalX, LocalY, ExclWorld, L.Priority, + DistSq <= RadiusWorld * RadiusWorld }); + } + } } } - // Drop instances no longer desired (player moved away, strate's list shrank, etc.). + // ---- Resolve exclusion (only if any entry opts in): a candidate is suppressed when a HIGHER-RANKED one's + // disk covers it. Rank = (Priority, then hash) → deterministic. v1 resolves within the in-range set. ---- + TSet Desired; + Desired.Reserve(Cands.Num()); + for (int32 i = 0; i < Cands.Num(); ++i) + { + const FCand& C = Cands[i]; + if (!C.bSpawnable) continue; // ring-only conflictor (gathered for pop-free resolve, never spawned) + if (bAnyExclusion) + { + bool bSuppressed = false; + for (int32 j = 0; j < Cands.Num(); ++j) + { + if (j == i) continue; + const FCand& D = Cands[j]; + if (D.ExclWorld <= 0.0f) continue; + const bool bDOutranks = (D.Priority != C.Priority) ? (D.Priority > C.Priority) : (D.H > C.H); + if (!bDOutranks) continue; + const float dxl = D.LocalX - C.LocalX, dyl = D.LocalY - C.LocalY; + if (dxl * dxl + dyl * dyl < D.ExclWorld * D.ExclWorld) { bSuppressed = true; break; } + } + if (bSuppressed) continue; + } + + Desired.Add(C.Key); + if (LandmarkInstances.Contains(C.Key)) continue; // already evaluated (spawned OR empty) + + FLandmarkInstance Inst; + SpawnLandmarkInstance(Def->Landmarks[C.EntryIdx], C.H, Ctx, OwnerXf, OwnerActor, + C.LocalX, C.LocalY, Step, ColDepth, Inst); + + // Decoration footprint (F7): clear groundcover under a placed suppressing landmark now, and remember + // the footprint so a deco region streaming in near it re-clears too (see ApplyRegion). + const FStrateLandmark& LE = Def->Landmarks[C.EntryIdx]; + if (LE.bSuppressDecorationsUnder && LE.SuppressRadiusChunks > 0.0f + && (Inst.Actor.IsValid() || Inst.Component.IsValid())) + { + const FVector WP = Inst.Actor.IsValid() ? Inst.Actor.Get()->GetActorLocation() + : Inst.Component.Get()->GetComponentLocation(); + Inst.SuppressCenter = WP; + Inst.SuppressRadiusWorld = LE.SuppressRadiusChunks * ChunkWorld; + RemoveDecorationsInSphere(WP, Inst.SuppressRadiusWorld); + } + + LandmarkInstances.Add(C.Key, Inst); // stored even if empty → never re-evaluated while in range + } + + // Drop instances no longer desired (player moved away, list shrank, lost an exclusion conflict). for (auto It = LandmarkInstances.CreateIterator(); It; ++It) { if (Desired.Contains(It.Key())) continue; diff --git a/Source/VoxelForge/Private/VoxelDensityVolume.cpp b/Source/VoxelForge/Private/VoxelDensityVolume.cpp index 10dbe39..d24f195 100644 --- a/Source/VoxelForge/Private/VoxelDensityVolume.cpp +++ b/Source/VoxelForge/Private/VoxelDensityVolume.cpp @@ -196,7 +196,12 @@ void UVoxelDensityVolume::EnsureTextures() T->Filter = TF_Trilinear; // smooth iso crossing (sub-voxel crisp edge) T->CompressionSettings = TC_Grayscale; // single-channel T->NeverStream = true; - T->MipGenSettings = TMGS_NoMipmaps; // 1b-i: base mip only; solidity mips come with the march +#if WITH_EDITORONLY_DATA + // MipGenSettings n'existe que dans les builds éditeur (WITH_EDITORONLY_DATA) — c'est un hint + // pour le mip-builder du cooker. En packagé le PlatformData construit ici n'a qu'UN mip de + // toute façon (1b-i: base mip only; solidity mips come with the march). + T->MipGenSettings = TMGS_NoMipmaps; +#endif // Runtime platform data: one R8 (PF_G8) mip, zero-initialised. NOTE (UE5.7 API surface — flag if // the build rejects any of these): FTexturePlatformData / SetNumSlices / SetPlatformData / diff --git a/Source/VoxelForge/Private/VoxelDiffLayer.cpp b/Source/VoxelForge/Private/VoxelDiffLayer.cpp index 7879774..7170dcd 100644 --- a/Source/VoxelForge/Private/VoxelDiffLayer.cpp +++ b/Source/VoxelForge/Private/VoxelDiffLayer.cpp @@ -115,8 +115,10 @@ TArray UVoxelDiffLayer::ApplyModification(const FVoxelModification& } } } - // Publish: subsequent readers must now take the lock instead of fast-rejecting. + // Publish: subsequent readers must now take the lock instead of fast-rejecting, and + // worker-side snapshots keyed on the version re-copy their chunk's list. bHasAnyMods.store(true, std::memory_order_release); + ModsVersion.fetch_add(1, std::memory_order_release); } UE_LOG(LogTemp, Log, @@ -142,13 +144,53 @@ float UVoxelDiffLayer::GetDensityOffset(const FIntVector& ChunkCoord, if (!bHasAnyMods.load(std::memory_order_acquire)) return 0.0f; // Hold the read lock for the whole body: Mods points INTO the map and is dereferenced through the - // falloff loop below, so the map must not be rehashed by a concurrent writer meanwhile. + // falloff loop, so the map must not be rehashed by a concurrent writer meanwhile. NOTE: hot-path + // callers (the generator) should prefer GetChunkModsSnapshot + EvaluateMods — one lock per chunk + // instead of one per voxel. FReadScopeLock Lock(ModsLock); - // Fast path: if this chunk has no modifications, return 0 const TArray* Mods = ChunkMods.Find(ChunkCoord); if (!Mods || Mods->Num() == 0) return 0.0f; + return EvaluateMods(*Mods, WorldX, WorldY, WorldZ); +} + +void UVoxelDiffLayer::GetChunkModsSnapshot(const FIntVector& ChunkCoord, TArray& Out) const +{ + Out.Reset(); + if (!bHasAnyMods.load(std::memory_order_acquire)) return; + + FReadScopeLock Lock(ModsLock); + if (const TArray* Mods = ChunkMods.Find(ChunkCoord)) + { + Out = *Mods; + } +} + +bool UVoxelDiffLayer::HasAnyModInChunkRange(const FIntVector& MinChunk, const FIntVector& MaxChunk) const +{ + if (!bHasAnyMods.load(std::memory_order_acquire)) return false; + + // Walk the modified-chunk KEYS under one read lock. Conservative: a mod is registered in every + // chunk its radius overlaps (ApplyModification), so key-in-range ⟺ the mod can reach the range. + FReadScopeLock Lock(ModsLock); + for (const auto& Pair : ChunkMods) + { + const FIntVector& C = Pair.Key; + if (C.X >= MinChunk.X && C.X <= MaxChunk.X && + C.Y >= MinChunk.Y && C.Y <= MaxChunk.Y && + C.Z >= MinChunk.Z && C.Z <= MaxChunk.Z && + Pair.Value.Num() > 0) + { + return true; + } + } + return false; +} + +float UVoxelDiffLayer::EvaluateMods(const TArray& Mods, + float WorldX, float WorldY, float WorldZ) +{ float TotalOffset = 0.0f; const FVector Pos(WorldX, WorldY, WorldZ); @@ -159,7 +201,7 @@ float UVoxelDiffLayer::GetDensityOffset(const FIntVector& ChunkCoord, return 1.0f - T * T * (3.0f - 2.0f * T); }; - for (const FVoxelModification& Mod : *Mods) + for (const FVoxelModification& Mod : Mods) { float Falloff = 0.0f; @@ -229,6 +271,7 @@ void UVoxelDiffLayer::Clear() FWriteScopeLock Lock(ModsLock); bHasAnyMods.store(false, std::memory_order_release); ChunkMods.Empty(); + ModsVersion.fetch_add(1, std::memory_order_release); // invalidate worker snapshots } // Reset budget counters — player gets a fresh budget after clear/season reset diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 4b727ae..39f817b 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -31,7 +31,15 @@ // task scheduler) reuse one another's heavy column noise instead of each recomputing it ~once per // vertical chunk in view. Validity stays a world-XY BOX (chunk footprint + margin) so the ±1 gradient / // +X/+Y boundary samples don't thrash it (same discipline as the SDF/biome caches, §8.10). -struct FSurfaceColumn { float TerrainZ = 0.0f; float CeilSurf = 0.0f; }; +// F20 phase 2 OVERHANG, resolved once per column (biome-blended, slope-gated) so the per-voxel +// density path is cheap: OverhangAmp = strength · slope-gate (0 ⇒ no overhang here); (DirX,DirY) = +// the UNIT UPHILL direction of the terrain gradient (the lip extends downhill, borrowing rock from +// uphill). See SurfaceDensityFromColumn for the warped-terrain union that makes the shelf. +struct FSurfaceColumn +{ + float TerrainZ = 0.0f; float CeilSurf = 0.0f; + float OverhangAmp = 0.0f; float DirX = 0.0f; float DirY = 0.0f; +}; struct FSurfaceColumnBox { @@ -84,6 +92,11 @@ struct FSurfaceColumnCache } }; +// Cache de colonnes PARTAGÉ par thread : GetDensityAt (hot path, T1.a) + ClassifyTile (T1.d). +// Les deux stockent des valeurs bit-identiques (même ComputeSurfaceColumn, champs purs en XY), +// donc un ClassifyTile qui rend Mixed laisse ses colonnes chaudes pour le GenerateMesh qui suit. +static thread_local FSurfaceColumnCache GSurfColCache; + //============================================================================= // FRACTAL NOISE (fBm — fractional Brownian motion) //============================================================================= @@ -94,6 +107,10 @@ struct FSurfaceColumnCache // Lacunarity = x freq par octave (2 = double à chaque fois) // Persistence = x amp par octave (0.5 = moitié) +// T2.b — per-thread octave bias for the tile being meshed (see VoxelGenerator.h). +// 0 = full quality; set by the mesher per tile from Step + Settings->LODOctaveDrop. +thread_local int32 VoxelGenLOD::OctaveBias = 0; + // NOTE (T2.a): the fBm/Ridged bodies moved to VoxelNoise.h, where octaves are evaluated // 4-wide via SSE (Perlin3D_x4). These thin wrappers keep every call site unchanged. They // sample a DIFFERENT (float hash-gradient) noise field than the old FMath::PerlinNoise3D, @@ -305,23 +322,42 @@ static void ApplyDisturbances(float& MC, float X, float Y, float Z, return FMath::Sqrt(FMath::Square(px - cx) + FMath::Square(py - cy)); }; + // Each feature's 3×3 lattice candidates (existence roll, jitter, angle trig, Z anchor) are pure + // functions of (cell, seed, params) yet were re-hashed PER VOXEL — including Cos/Sin per bridge/ + // ridge candidate. Bake each feature's nearby primitives once per centre cell (thread_local); + // the per-voxel work is just the distance math. Bit-identical (same hashes, same math). + // --- CHASMS: vertical rifts carve open air --- if (D.ChasmDensity > 0.0f && D.ChasmSpacing > 0.0f) { const float Sp = D.ChasmSpacing; const int32 cx = FMath::FloorToInt(X / Sp), cy = FMath::FloorToInt(Y / Sp); - float sdf = FLT_MAX; - for (int32 dy = -1; dy <= 1; dy++) - for (int32 dx = -1; dx <= 1; dx++) + + struct FChasm { float X, Y; }; + thread_local TArray> Chasms; + thread_local int32 CH_CX = INT32_MAX, CH_CY = INT32_MAX; + thread_local uint32 CH_Seed = 0xFFFFFFFFu; + thread_local float CH_Sp = -1.0f, CH_Dens = -1.0f; + if (cx != CH_CX || cy != CH_CY || Seed != CH_Seed || Sp != CH_Sp || D.ChasmDensity != CH_Dens) { - const int32 nx = cx + dx, ny = cy + dy; - const uint32 h = VoxelHash::Cell(nx, ny, Seed ^ 0x43480001u); - if (VoxelHash::ToFloat01(h) > D.ChasmDensity) continue; - const float jx = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0x12345678u)); - const float jy = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0x9ABCDEF0u)); - const float ccx = (nx + 0.15f + jx * 0.7f) * Sp; - const float ccy = (ny + 0.15f + jy * 0.7f) * Sp; - sdf = FMath::Min(sdf, FMath::Sqrt(FMath::Square(X - ccx) + FMath::Square(Y - ccy)) - D.ChasmRadius); + CH_CX = cx; CH_CY = cy; CH_Seed = Seed; CH_Sp = Sp; CH_Dens = D.ChasmDensity; + Chasms.Reset(); + for (int32 dy = -1; dy <= 1; dy++) + for (int32 dx = -1; dx <= 1; dx++) + { + const int32 nx = cx + dx, ny = cy + dy; + const uint32 h = VoxelHash::Cell(nx, ny, Seed ^ 0x43480001u); + if (VoxelHash::ToFloat01(h) > D.ChasmDensity) continue; + const float jx = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0x12345678u)); + const float jy = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0x9ABCDEF0u)); + Chasms.Add({ (nx + 0.15f + jx * 0.7f) * Sp, (ny + 0.15f + jy * 0.7f) * Sp }); + } + } + + float sdf = FLT_MAX; + for (const FChasm& C : Chasms) + { + sdf = FMath::Min(sdf, FMath::Sqrt(FMath::Square(X - C.X) + FMath::Square(Y - C.Y)) - D.ChasmRadius); } if (sdf < Blend) { @@ -336,22 +372,39 @@ static void ApplyDisturbances(float& MC, float X, float Y, float Z, { const float Sp = D.BridgeSpacing; const int32 cx = FMath::FloorToInt(X / Sp), cy = FMath::FloorToInt(Y / Sp); - float sdf = FLT_MAX; - for (int32 dy = -1; dy <= 1; dy++) - for (int32 dx = -1; dx <= 1; dx++) + + struct FBridge { FVector A, B; }; + thread_local TArray> Bridges; + thread_local int32 BR_CX = INT32_MAX, BR_CY = INT32_MAX; + thread_local uint32 BR_Seed = 0xFFFFFFFFu; + thread_local float BR_Sp = -1.0f, BR_Dens = -1.0f, BR_Bot = FLT_MAX, BR_Top = FLT_MAX; + if (cx != BR_CX || cy != BR_CY || Seed != BR_Seed || Sp != BR_Sp || + D.BridgeDensity != BR_Dens || InnerBot != BR_Bot || InnerTop != BR_Top) { - const int32 nx = cx + dx, ny = cy + dy; - const uint32 h = VoxelHash::Cell(nx, ny, Seed ^ 0x42520001u); - if (VoxelHash::ToFloat01(h) > D.BridgeDensity) continue; - const float zc = FMath::Lerp(InnerBot + 8.0f, InnerTop - 8.0f, - VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0xB1u))); - const float ang = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0xB2u)) * PI; - const float dxu = FMath::Cos(ang), dyu = FMath::Sin(ang); - const float bx = (nx + 0.5f) * Sp, by = (ny + 0.5f) * Sp; - const float half = Sp * 0.6f; - const FVector A(bx - dxu * half, by - dyu * half, zc); - const FVector B(bx + dxu * half, by + dyu * half, zc); - sdf = FMath::Min(sdf, VoxelSDF::Capsule(P, A, B, D.BridgeRadius)); + BR_CX = cx; BR_CY = cy; BR_Seed = Seed; BR_Sp = Sp; + BR_Dens = D.BridgeDensity; BR_Bot = InnerBot; BR_Top = InnerTop; + Bridges.Reset(); + for (int32 dy = -1; dy <= 1; dy++) + for (int32 dx = -1; dx <= 1; dx++) + { + const int32 nx = cx + dx, ny = cy + dy; + const uint32 h = VoxelHash::Cell(nx, ny, Seed ^ 0x42520001u); + if (VoxelHash::ToFloat01(h) > D.BridgeDensity) continue; + const float zc = FMath::Lerp(InnerBot + 8.0f, InnerTop - 8.0f, + VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0xB1u))); + const float ang = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0xB2u)) * PI; + const float dxu = FMath::Cos(ang), dyu = FMath::Sin(ang); + const float bx = (nx + 0.5f) * Sp, by = (ny + 0.5f) * Sp; + const float half = Sp * 0.6f; + Bridges.Add({ FVector(bx - dxu * half, by - dyu * half, zc), + FVector(bx + dxu * half, by + dyu * half, zc) }); + } + } + + float sdf = FLT_MAX; + for (const FBridge& Br : Bridges) + { + sdf = FMath::Min(sdf, VoxelSDF::Capsule(P, Br.A, Br.B, D.BridgeRadius)); } if (sdf < Blend) { @@ -369,19 +422,34 @@ static void ApplyDisturbances(float& MC, float X, float Y, float Z, if (Z < TopZ) { const int32 cx = FMath::FloorToInt(X / Sp), cy = FMath::FloorToInt(Y / Sp); - float best = -FLT_MAX; // strongest fill across nearby blades - for (int32 dy = -1; dy <= 1; dy++) - for (int32 dx = -1; dx <= 1; dx++) + + struct FRidge { float AX, AY, BX, BY; }; + thread_local TArray> Ridges; + thread_local int32 RG_CX = INT32_MAX, RG_CY = INT32_MAX; + thread_local uint32 RG_Seed = 0xFFFFFFFFu; + thread_local float RG_Sp = -1.0f, RG_Dens = -1.0f; + if (cx != RG_CX || cy != RG_CY || Seed != RG_Seed || Sp != RG_Sp || D.RidgeDensity != RG_Dens) { - const int32 nx = cx + dx, ny = cy + dy; - const uint32 h = VoxelHash::Cell(nx, ny, Seed ^ 0x52470001u); - if (VoxelHash::ToFloat01(h) > D.RidgeDensity) continue; - const float ang = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0x9001u)) * PI; - const float dxu = FMath::Cos(ang), dyu = FMath::Sin(ang); - const float bx = (nx + 0.5f) * Sp, by = (ny + 0.5f) * Sp; - const float half = Sp * 0.45f; - const float d2d = Dist2DSeg(X, Y, bx - dxu * half, by - dyu * half, - bx + dxu * half, by + dyu * half); + RG_CX = cx; RG_CY = cy; RG_Seed = Seed; RG_Sp = Sp; RG_Dens = D.RidgeDensity; + Ridges.Reset(); + for (int32 dy = -1; dy <= 1; dy++) + for (int32 dx = -1; dx <= 1; dx++) + { + const int32 nx = cx + dx, ny = cy + dy; + const uint32 h = VoxelHash::Cell(nx, ny, Seed ^ 0x52470001u); + if (VoxelHash::ToFloat01(h) > D.RidgeDensity) continue; + const float ang = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0x9001u)) * PI; + const float dxu = FMath::Cos(ang), dyu = FMath::Sin(ang); + const float bx = (nx + 0.5f) * Sp, by = (ny + 0.5f) * Sp; + const float half = Sp * 0.45f; + Ridges.Add({ bx - dxu * half, by - dyu * half, bx + dxu * half, by + dyu * half }); + } + } + + float best = -FLT_MAX; // strongest fill across nearby blades + for (const FRidge& R : Ridges) + { + const float d2d = Dist2DSeg(X, Y, R.AX, R.AY, R.BX, R.BY); const float wallSDF = d2d - D.RidgeThickness; // <0 inside the blade footprint if (wallSDF >= Blend) continue; const float zFade = 1.0f - FMath::Clamp((Z - InnerBot) / FMath::Max(D.RidgeHeight, 1.0f), 0.0f, 1.0f); @@ -447,7 +515,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co thread_local FBiomeContext CP_BiomeCtx; thread_local FChunkBiomeCache CP_BiomeCache; thread_local TArray CP_SurfaceBiomeParams; - thread_local FSurfaceColumnCache CP_SurfCol; // T1.a per-column surface cache (XY-keyed LRU) + // T1.a per-column surface cache: GSurfColCache (file-scope, shared with ClassifyTile). // Discriminates the surface cache by strate: same strate ⇒ identical heightfield params ⇒ columns // are shareable across the whole vertical chunk stack. Taken from the params themselves // (StrateBottomWorldZ is unique per stacked strate) so the key can never disagree with CP_Surface. @@ -495,13 +563,14 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co const int32 IX = (int32)WorldX, IY = (int32)WorldY; // XY-keyed LRU box (shared down the whole vertical strate stack). Acquire centres a box on // the first sample so the rest of the chunk's queries — incl. the ±Step margin ring — hit. - FSurfaceColumnBox& Box = CP_SurfCol.Acquire(IX, IY, CP_StrateKey, Seed); + FSurfaceColumnBox& Box = GSurfColCache.Acquire(IX, IY, CP_StrateKey, Seed); const int32 CI = (IY - Box.BaseY) * FSurfaceColumnBox::Dim + (IX - Box.BaseX); if (!Box.Computed[CI]) { ComputeSurfaceColumn(WorldX, WorldY, ChunkCoord.Z, CP_Surface, CP_BiomeCtx, CP_SurfaceBiomeParams, CP_BiomeCache, - Box.Cols[CI].TerrainZ, Box.Cols[CI].CeilSurf); + Box.Cols[CI].TerrainZ, Box.Cols[CI].CeilSurf, + Box.Cols[CI].OverhangAmp, Box.Cols[CI].DirX, Box.Cols[CI].DirY); Box.Computed[CI] = true; } Col = Box.Cols[CI]; @@ -509,10 +578,12 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co else { ComputeSurfaceColumn(WorldX, WorldY, ChunkCoord.Z, CP_Surface, CP_BiomeCtx, - CP_SurfaceBiomeParams, CP_BiomeCache, Col.TerrainZ, Col.CeilSurf); + CP_SurfaceBiomeParams, CP_BiomeCache, Col.TerrainZ, Col.CeilSurf, + Col.OverhangAmp, Col.DirX, Col.DirY); } - Result = SurfaceDensityFromColumn(WorldX, WorldY, WorldZ, Col.TerrainZ, Col.CeilSurf, CP_Surface); + Result = SurfaceDensityFromColumn(WorldX, WorldY, WorldZ, + Col.TerrainZ, Col.CeilSurf, Col.OverhangAmp, Col.DirX, Col.DirY, CP_Surface); break; } case ECaveGeneratorType::VerticalShafts: @@ -546,9 +617,36 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // we subtract the diff offset: // Carve (diff < 0) → Result -= negative → Result increases → more air ✓ // Fill (diff > 0) → Result -= positive → Result decreases → more solid ✓ - if (DiffLayer && DiffLayer->HasModifications(ChunkCoord)) + if (DiffLayer && DiffLayer->HasAnyMods()) { - Result -= DiffLayer->GetDensityOffset(ChunkCoord, WorldX, WorldY, WorldZ); + // ── PER-CHUNK MOD SNAPSHOT ── + // The old HasModifications + GetDensityOffset pair took the diff layer's RWLock + a TMap find + // TWICE per voxel once any carve existed (~86k lock ops per tile task — during carve gameplay, + // exactly when re-mesh latency matters). Snapshot a chunk's mod list ONCE per (chunk, version) + // on this worker and evaluate it lock-free. DIRECT-MAPPED by the chunk coord's low bits (2 bits + // per axis → 64 slots): the mesher's ±1 margin ring touches up to 27 neighbouring chunk coords + // per tile, and any two coords within ±3 of each other land in DIFFERENT slots — so a tile task + // never thrashes its own working set. Version bump (carve / clear) invalidates lazily per slot. + struct FDiffSlot + { + FIntVector Chunk = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); + uint32 Version = 0; + TArray Mods; + }; + static thread_local FDiffSlot DiffSlots[64]; + + const uint32 V = DiffLayer->GetModsVersion(); + FDiffSlot& Slot = DiffSlots[(ChunkCoord.X & 3) | ((ChunkCoord.Y & 3) << 2) | ((ChunkCoord.Z & 3) << 4)]; + if (Slot.Chunk != ChunkCoord || Slot.Version != V) + { + Slot.Chunk = ChunkCoord; + Slot.Version = V; + DiffLayer->GetChunkModsSnapshot(ChunkCoord, Slot.Mods); // ONE lock per chunk, not per voxel + } + if (Slot.Mods.Num() > 0) + { + Result -= UVoxelDiffLayer::EvaluateMods(Slot.Mods, WorldX, WorldY, WorldZ); + } } return Result; @@ -683,12 +781,27 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo if (Params.RoomDensity > 0.0f && Params.RoomSpacing > 0.0f) { // Get strate index for unique caves per strate. - // GetStrateIndex expects Unreal world units (divides by VOXEL_SIZE internally), - // but our WorldZ is in voxel coordinates, so multiply by VOXEL_SIZE. + // MEMOISED: the index only changes at chunk-Z boundaries, yet this ran the strate layout's + // linear scan PER VOXEL. Key = (chunk-Z, layout version) — the version guards against editor + // rebuilds (RebuildStrates / live edit) serving a stale index. The lookup queries the BAND + // CENTRE so the result is a pure function of the key (order-independent, window-invariant). int32 StrateIdx = 0; if (StrateManager) { - StrateIdx = StrateManager->GetStrateIndex(WorldZ * VOXEL_SIZE); + thread_local int32 SI_ChunkZ = INT32_MAX; + thread_local uint32 SI_Version = 0xFFFFFFFFu; + thread_local int32 SI_Index = 0; + const int32 QZ = FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE); + const uint32 LV = StrateManager->GetLayoutVersion(); + if (QZ != SI_ChunkZ || LV != SI_Version) + { + SI_ChunkZ = QZ; + SI_Version = LV; + // GetStrateIndex expects Unreal world units (divides by VOXEL_SIZE internally); + // WorldZ here is in voxels, so multiply by VOXEL_SIZE. + SI_Index = StrateManager->GetStrateIndex(((float)QZ + 0.5f) * CHUNK_SIZE * VOXEL_SIZE); + } + StrateIdx = SI_Index; } // Rebuild only when the WARPED query (what EvaluateSDFCached uses) leaves the @@ -746,7 +859,7 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo NearestRoomIdx = -1; CaveSDF = VoxelCaveMorphology::EvaluateSDFCached( WarpedX, WarpedY, WarpedZ, - SDFCache, Params.SDFBlendRadius, Params.RoomShapeVariety, + SDFCache, Params.SDFBlendRadius, &NearestRoomIdx ); @@ -919,23 +1032,27 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo FinePos += WarpOffset; } - // NOISE TYPE SELECTION: sample the right noise function + // NOISE TYPE SELECTION: sample the right noise function. + // Octave counts go through VoxelGenLOD::Eff — far tiles (Step>1) drop + // the sub-cell tail octaves (T2.b); LOD0 keeps the full counts. float RoughNoise, FineNoise; + const int32 Oct3 = VoxelGenLOD::Eff(3); + const int32 Oct2 = VoxelGenLOD::Eff(2); switch (Params.RoughnessNoiseType) { case EVoxelNoiseType::Ridged: // Ridged multifractal: sharp, craggy features - RoughNoise = RidgedNoise3D(MainPos, 3); - FineNoise = RidgedNoise3D(FinePos, 2); + RoughNoise = RidgedNoise3D(MainPos, Oct3); + FineNoise = RidgedNoise3D(FinePos, Oct2); break; case EVoxelNoiseType::Mixed: // Blend: ridged structure softened by fBM - RoughNoise = FractalNoise3D(MainPos, 3) * 0.5f - + RidgedNoise3D(MainPos, 3) * 0.5f; - FineNoise = FractalNoise3D(FinePos, 2) * 0.5f - + RidgedNoise3D(FinePos, 2) * 0.5f; + RoughNoise = FractalNoise3D(MainPos, Oct3) * 0.5f + + RidgedNoise3D(MainPos, Oct3) * 0.5f; + FineNoise = FractalNoise3D(FinePos, Oct2) * 0.5f + + RidgedNoise3D(FinePos, Oct2) * 0.5f; break; case EVoxelNoiseType::Cellular: @@ -948,8 +1065,8 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo case EVoxelNoiseType::FBM: default: // Standard fBM: smooth, organic - RoughNoise = FractalNoise3D(MainPos, 3); - FineNoise = FractalNoise3D(FinePos, 2); + RoughNoise = FractalNoise3D(MainPos, Oct3); + FineNoise = FractalNoise3D(FinePos, Oct2); break; } @@ -1010,7 +1127,10 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo } // Shadow outer Params inside this block so all terrain op code below // automatically uses the per-room values without any other changes. + // The shadow is deliberate (per-room override), so silence C4457 here only. + PRAGMA_DISABLE_SHADOW_VARIABLE_WARNINGS const FStrateGenerationParams& Params = LocalTerrainParams; + PRAGMA_ENABLE_SHADOW_VARIABLE_WARNINGS // Geological features applied after roughness. These modify the density // field near cave surfaces to create specific shapes: terracing (step-like @@ -1037,26 +1157,21 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo { // Surface orientation test: terracing only makes sense on horizontal surfaces // (cave floors). On vertical walls (pit shafts, tunnel sides) it creates ugly - // horizontal ridges. We sample the SDF gradient in Z by querying Z±1 and - // measure how much the SDF changes vertically vs. how much it'd change on a - // perfectly horizontal surface. GradZ near 1 = floor/ceiling, near 0 = wall. + // horizontal ridges. We sample the SDF gradient in Z by querying Z±1: + // an SDF has ≈unit gradient, so |dSDF/dZ| alone IS the normalized vertical + // component — near 1 = floor/ceiling, near 0 = wall. (Z-only approximation: + // the full 6-sample gradient normalization was 3× the SDF cost for the same + // orientation signal; SmoothMin regions where |∇SDF| < 1 read slightly + // "flatter", shifting where terraces fade on slopes — accepted visual delta.) // // We use the cached SDF so this costs two extra SDF evaluations per voxel, // only when near a surface — the common case is cheap (DistFromSurface > TerraceRange). - float SDF_Zp1 = VoxelCaveMorphology::EvaluateSDFCached(WorldX, WorldY, WorldZ + 1.0f, SDFCache, Params.SDFBlendRadius, Params.RoomShapeVariety); - float SDF_Zm1 = VoxelCaveMorphology::EvaluateSDFCached(WorldX, WorldY, WorldZ - 1.0f, SDFCache, Params.SDFBlendRadius, Params.RoomShapeVariety); - // Central difference gradient in Z, approximate magnitude via all-axis samples - float SDF_Xp1 = VoxelCaveMorphology::EvaluateSDFCached(WorldX + 1.0f, WorldY, WorldZ, SDFCache, Params.SDFBlendRadius, Params.RoomShapeVariety); - float SDF_Xm1 = VoxelCaveMorphology::EvaluateSDFCached(WorldX - 1.0f, WorldY, WorldZ, SDFCache, Params.SDFBlendRadius, Params.RoomShapeVariety); - float SDF_Yp1 = VoxelCaveMorphology::EvaluateSDFCached(WorldX, WorldY + 1.0f, WorldZ, SDFCache, Params.SDFBlendRadius, Params.RoomShapeVariety); - float SDF_Ym1 = VoxelCaveMorphology::EvaluateSDFCached(WorldX, WorldY - 1.0f, WorldZ, SDFCache, Params.SDFBlendRadius, Params.RoomShapeVariety); - float GX = (SDF_Xp1 - SDF_Xm1) * 0.5f; - float GY = (SDF_Yp1 - SDF_Ym1) * 0.5f; + float SDF_Zp1 = VoxelCaveMorphology::EvaluateSDFCached(WorldX, WorldY, WorldZ + 1.0f, SDFCache, Params.SDFBlendRadius); + float SDF_Zm1 = VoxelCaveMorphology::EvaluateSDFCached(WorldX, WorldY, WorldZ - 1.0f, SDFCache, Params.SDFBlendRadius); float GZ = (SDF_Zp1 - SDF_Zm1) * 0.5f; - float GLen = FMath::Sqrt(GX*GX + GY*GY + GZ*GZ); // Normalized vertical component: 1 = perfectly horizontal surface (floor/ceiling) // 0 = perfectly vertical surface (wall) - float SurfaceHorizontality = (GLen > KINDA_SMALL_NUMBER) ? FMath::Abs(GZ) / GLen : 0.0f; + float SurfaceHorizontality = FMath::Clamp(FMath::Abs(GZ), 0.0f, 1.0f); // Only apply terrace where the surface is mostly horizontal (> ~45 degrees) // Smooth transition to avoid a hard cutoff at exactly 45 degrees float TerraceOrientFactor = FMath::Clamp((SurfaceHorizontality - 0.3f) / 0.4f, 0.0f, 1.0f); @@ -1068,7 +1183,7 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo WorldX * 0.04f + SeedF * 31.1f, WorldY * 0.04f + SeedF * 37.3f, WorldZ * 0.02f + SeedF * 41.7f - ), 2) * VOXEL_NOISE_SCALE; + ), VoxelGenLOD::Eff(2)) * VOXEL_NOISE_SCALE; NoisedZ += DispNoise * Params.TerraceNoiseDisplacement * StepH; } @@ -1194,7 +1309,7 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo WorldX * Params.OverhangFrequency + SeedF * 53.1f, WorldY * Params.OverhangFrequency + SeedF * 59.3f, EffectiveZ * Params.OverhangFrequency * 0.15f + SeedF * 61.7f - ), 2) * VOXEL_NOISE_SCALE; + ), VoxelGenLOD::Eff(2)) * VOXEL_NOISE_SCALE; // Only where noise is positive → protrusions (not recesses) if (OverhangNoise > 0.0f) @@ -1572,18 +1687,23 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo EffectiveZ * WormZFreq + SeedF * 2.3f )) * VOXEL_NOISE_SCALE); - float N2 = FMath::Abs(VoxelNoise::Perlin3D(FVector( - WorldX * Params.WormFrequency + SeedF + 137.0f, - WorldY * Params.WormFrequency + SeedF * 1.7f + 259.0f, - EffectiveZ * WormZFreq + SeedF * 2.3f + 431.0f - )) * VOXEL_NOISE_SCALE); - - float WormValue = N1 + N2; - - if (WormValue < Params.WormThreshold) + // N2 >= 0, so if N1 alone already clears the threshold the sum can't carve — + // skip the second Perlin entirely (most voxels; bit-identical output). + if (N1 < Params.WormThreshold) { - float t = 1.0f - (WormValue / Params.WormThreshold); - Density -= t * Params.WormStrength * NetworkMask; + float N2 = FMath::Abs(VoxelNoise::Perlin3D(FVector( + WorldX * Params.WormFrequency + SeedF + 137.0f, + WorldY * Params.WormFrequency + SeedF * 1.7f + 259.0f, + EffectiveZ * WormZFreq + SeedF * 2.3f + 431.0f + )) * VOXEL_NOISE_SCALE); + + float WormValue = N1 + N2; + + if (WormValue < Params.WormThreshold) + { + float t = 1.0f - (WormValue / Params.WormThreshold); + Density -= t * Params.WormStrength * NetworkMask; + } } } } @@ -1655,7 +1775,7 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ, WorldX * FF + SeedF * 7.3f, WorldY * FF + SeedF * 11.1f, WorldZ * FF * 0.05f // Very low Z freq → horizontal ground features - ), 3) * VOXEL_NOISE_SCALE * Params.FloorRoughness; + ), VoxelGenLOD::Eff(3)) * VOXEL_NOISE_SCALE * Params.FloorRoughness; } // Actual floor surface Z after noise displacement. @@ -1685,7 +1805,7 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ, WorldX * CF + SeedF * 17.3f + 1000.0f, WorldY * CF + SeedF * 19.7f + 2000.0f, WorldZ * CF * 0.08f + 3000.0f // Low Z freq → formations extend horizontally - ), 3) * VOXEL_NOISE_SCALE; + ), VoxelGenLOD::Eff(3)) * VOXEL_NOISE_SCALE; // abs() → formations ONLY hang down, never push ceiling up into solid rock. // Result: every noise peak creates a downward protrusion (crystal/stalactite). @@ -1733,42 +1853,61 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ, const int32 ColCX = FMath::FloorToInt(WorldX / Spacing); const int32 ColCY = FMath::FloorToInt(WorldY / Spacing); - float ColumnSDF = FLT_MAX; + // The 3×3 neighbourhood's columns (existence roll, jitter, radius) are a pure function of + // (cell, seed, params) yet were re-derived PER VOXEL — 9 hash rolls + mixes in the slab hot + // loop. Bake them once per centre cell (thread_local); rebuild only when the query crosses a + // cell border or the seed/params change. Bit-identical (same hashes, same math). + struct FSlabColumn { float X, Y, R; }; + thread_local TArray> SC_Cols; + thread_local int32 SC_CX = INT32_MAX, SC_CY = INT32_MAX; + thread_local uint32 SC_Seed = 0xFFFFFFFFu; + thread_local float SC_Spacing = -1.0f, SC_Dens = -1.0f, SC_MinR = -1.0f, SC_MaxR = -1.0f; - // Check 3x3 neighborhood so we never miss a column in an adjacent cell. - for (int32 DY = -1; DY <= 1; DY++) + if (ColCX != SC_CX || ColCY != SC_CY || (uint32)Seed != SC_Seed || Spacing != SC_Spacing || + Params.ColumnDensity != SC_Dens || Params.ColumnMinRadius != SC_MinR || Params.ColumnMaxRadius != SC_MaxR) { - for (int32 DX = -1; DX <= 1; DX++) + SC_CX = ColCX; SC_CY = ColCY; SC_Seed = (uint32)Seed; SC_Spacing = Spacing; + SC_Dens = Params.ColumnDensity; SC_MinR = Params.ColumnMinRadius; SC_MaxR = Params.ColumnMaxRadius; + SC_Cols.Reset(); + + for (int32 DY = -1; DY <= 1; DY++) { - int32 NCX = ColCX + DX; - int32 NCY = ColCY + DY; + for (int32 DX = -1; DX <= 1; DX++) + { + const int32 NCX = ColCX + DX; + const int32 NCY = ColCY + DY; - // Deterministic: same seed → same column pattern every session. - // XOR with a prime salt so columns don't correlate with room placement. - uint32 H = VoxelHash::Cell(NCX, NCY, (uint32)Seed ^ 0xC01C01u); + // Deterministic: same seed → same column pattern every session. + // XOR with a prime salt so columns don't correlate with room placement. + const uint32 H = VoxelHash::Cell(NCX, NCY, (uint32)Seed ^ 0xC01C01u); - // ColumnDensity is the probability this cell has a column. - if (VoxelHash::ToFloat01(H) > Params.ColumnDensity) continue; + // ColumnDensity is the probability this cell has a column. + if (VoxelHash::ToFloat01(H) > Params.ColumnDensity) continue; - // Jitter the column center within the cell (15%-85% of cell extent) - // to avoid a perfectly regular grid pattern. - float JX = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x12345678u)); - float JY = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x9ABCDEF0u)); - float ColX = (NCX + 0.15f + JX * 0.7f) * Spacing; - float ColY = (NCY + 0.15f + JY * 0.7f) * Spacing; + // Jitter the column center within the cell (15%-85% of cell extent) + // to avoid a perfectly regular grid pattern. + const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x12345678u)); + const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x9ABCDEF0u)); - // Column radius: hash-derived within configured range. - float ColRadius = FMath::Lerp(Params.ColumnMinRadius, Params.ColumnMaxRadius, - VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0xBEEFu))); - - // 2D cylinder SDF (infinite height — void field handles top/bottom). - float DX2D = WorldX - ColX; - float DY2D = WorldY - ColY; - float CylSDF = FMath::Sqrt(DX2D * DX2D + DY2D * DY2D) - ColRadius; - ColumnSDF = FMath::Min(ColumnSDF, CylSDF); + FSlabColumn& Col = SC_Cols.AddDefaulted_GetRef(); + Col.X = (NCX + 0.15f + JX * 0.7f) * Spacing; + Col.Y = (NCY + 0.15f + JY * 0.7f) * Spacing; + // Column radius: hash-derived within configured range. + Col.R = FMath::Lerp(Params.ColumnMinRadius, Params.ColumnMaxRadius, + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0xBEEFu))); + } } } + float ColumnSDF = FLT_MAX; + for (const FSlabColumn& Col : SC_Cols) + { + // 2D cylinder SDF (infinite height — void field handles top/bottom). + const float DX2D = WorldX - Col.X; + const float DY2D = WorldY - Col.Y; + ColumnSDF = FMath::Min(ColumnSDF, FMath::Sqrt(DX2D * DX2D + DY2D * DY2D) - Col.R); + } + // Smoothstep blend zone around the column edge (avoids hard MC aliasing). const float ColBlend = 2.0f; if (ColumnSDF < ColBlend && ColumnSDF < FLT_MAX) @@ -1826,41 +1965,64 @@ float UVoxelGenerator::GetMazeDensity(float WorldX, float WorldY, float WorldZ, const int32 CY = FMath::FloorToInt(WorldY / CS); const int32 CZ = FMath::FloorToInt(WorldZ / CS); - auto NodeCenter = [CS](int32 X, int32 Y, int32 Z) + // The open-edge set reachable from this voxel's cell is a pure function of (cell, seed, + // probabilities) yet was re-hashed PER VOXEL (8 nodes × 3 edges = 24 hash rolls). Bake the + // open edges' capsule endpoints once per cell (thread_local); the per-voxel work is just + // the capsule SDFs. Rebuilds only on a cell crossing / param change — bit-identical. + struct FMazeEdge { FVector A, B; }; + thread_local TArray> MZ_Edges; + thread_local FIntVector MZ_Cell(INT32_MAX, INT32_MAX, INT32_MAX); + thread_local uint32 MZ_Seed = 0xFFFFFFFFu; + thread_local float MZ_CS = -1.0f, MZ_Branch = -1.0f, MZ_Vert = -1.0f; + + const FIntVector Cell(CX, CY, CZ); + if (Cell != MZ_Cell || S != MZ_Seed || CS != MZ_CS || + Params.BranchProbability != MZ_Branch || Params.Verticality != MZ_Vert) { - return FVector((X + 0.5f) * CS, (Y + 0.5f) * CS, (Z + 0.5f) * CS); - }; - // Deterministic hash of a 3D lattice edge, keyed on its lower node + axis salt. - auto EdgeOpen = [S](int32 X, int32 Y, int32 Z, uint32 AxisSalt, float Threshold) -> bool - { - uint32 H = VoxelHash::Cell(X, Y, S ^ AxisSalt); - H ^= VoxelHash::Mix((uint32)(Z * 73856093) ^ AxisSalt); - return VoxelHash::ToFloat01(VoxelHash::Mix(H)) < Threshold; - }; + MZ_Cell = Cell; MZ_Seed = S; MZ_CS = CS; + MZ_Branch = Params.BranchProbability; MZ_Vert = Params.Verticality; + MZ_Edges.Reset(); + + auto NodeCenter = [CS](int32 X, int32 Y, int32 Z) + { + return FVector((X + 0.5f) * CS, (Y + 0.5f) * CS, (Z + 0.5f) * CS); + }; + // Deterministic hash of a 3D lattice edge, keyed on its lower node + axis salt. + auto EdgeOpen = [S](int32 X, int32 Y, int32 Z, uint32 AxisSalt, float Threshold) -> bool + { + uint32 H = VoxelHash::Cell(X, Y, S ^ AxisSalt); + H ^= VoxelHash::Mix((uint32)(Z * 73856093) ^ AxisSalt); + return VoxelHash::ToFloat01(VoxelHash::Mix(H)) < Threshold; + }; + + // Nodes in {-1,0} per axis cover every edge that can reach this voxel's cell. + for (int32 dz = -1; dz <= 0; dz++) + for (int32 dy = -1; dy <= 0; dy++) + for (int32 dx = -1; dx <= 0; dx++) + { + const int32 nx = CX + dx, ny = CY + dy, nz = CZ + dz; + const FVector A = NodeCenter(nx, ny, nz); + + if (EdgeOpen(nx, ny, nz, 0xA1u, Params.BranchProbability)) + MZ_Edges.Add({ A, NodeCenter(nx + 1, ny, nz) }); + if (EdgeOpen(nx, ny, nz, 0xB2u, Params.BranchProbability)) + MZ_Edges.Add({ A, NodeCenter(nx, ny + 1, nz) }); + if (EdgeOpen(nx, ny, nz, 0xC3u, Params.Verticality)) + MZ_Edges.Add({ A, NodeCenter(nx, ny, nz + 1) }); + } + } const float R = FMath::Max(Params.CorridorRadius, 0.5f); float MazeSDF = FLT_MAX; - - // Nodes in {-1,0} per axis cover every edge that can reach this voxel's cell. - for (int32 dz = -1; dz <= 0; dz++) - for (int32 dy = -1; dy <= 0; dy++) - for (int32 dx = -1; dx <= 0; dx++) + for (const FMazeEdge& E : MZ_Edges) { - const int32 nx = CX + dx, ny = CY + dy, nz = CZ + dz; - const FVector A = NodeCenter(nx, ny, nz); - - if (EdgeOpen(nx, ny, nz, 0xA1u, Params.BranchProbability)) - MazeSDF = FMath::Min(MazeSDF, VoxelSDF::Capsule(Pos, A, NodeCenter(nx + 1, ny, nz), R)); - if (EdgeOpen(nx, ny, nz, 0xB2u, Params.BranchProbability)) - MazeSDF = FMath::Min(MazeSDF, VoxelSDF::Capsule(Pos, A, NodeCenter(nx, ny + 1, nz), R)); - if (EdgeOpen(nx, ny, nz, 0xC3u, Params.Verticality)) - MazeSDF = FMath::Min(MazeSDF, VoxelSDF::Capsule(Pos, A, NodeCenter(nx, ny, nz + 1), R)); + MazeSDF = FMath::Min(MazeSDF, VoxelSDF::Capsule(Pos, E.A, E.B, R)); } // Wall roughness: perturb the corridor surface. if (Params.SurfaceRoughness > 0.0f && MazeSDF < R + Params.SurfaceRoughness + 2.0f) { - MazeSDF += FractalNoise3D(FVector(WorldX * 0.12f, WorldY * 0.12f, WorldZ * 0.12f), 3) + MazeSDF += FractalNoise3D(FVector(WorldX * 0.12f, WorldY * 0.12f, WorldZ * 0.12f), VoxelGenLOD::Eff(3)) * VOXEL_NOISE_SCALE * Params.SurfaceRoughness; } @@ -1897,8 +2059,8 @@ float UVoxelGenerator::GetMazeDensity(float WorldX, float WorldY, float WorldZ, // high solid "sky cap" ceiling, with a flattened beach band around the water line. // Open air fills the gap between ground and ceiling; water is a render-side overlay. -float UVoxelGenerator::ComputeSurfaceTerrainZ(float WorldX, float WorldY, - const FSurfaceGenerationParams& Params) const +float UVoxelGenerator::SampleSurfaceStructuralZ(float WorldX, float WorldY, + const FSurfaceGenerationParams& Params, float& OutM) const { const float H = Params.StrateTopWorldZ - Params.StrateBottomWorldZ; const float SeedF = (float)Seed; @@ -1954,17 +2116,78 @@ float UVoxelGenerator::ComputeSurfaceTerrainZ(float WorldX, float WorldY, + Mountain * Params.ElevationRange + Detail * Params.SurfaceRoughness; - // Plateau/mesa terracing — quantize height into steps, but only in high-relief areas and - // only as strongly as TerraceStrength asks. Layered cliffs/mesas up top, smooth lowlands. + OutM = M; + return Terrain; +} + +float UVoxelGenerator::ComputeSurfaceTerrainZ(float WorldX, float WorldY, + const FSurfaceGenerationParams& Params) const +{ + // Raw structural ground + the relief "mountainous-ness" M (reused to relief-gate the ops). + float M = 1.0f; + float Terrain = SampleSurfaceStructuralZ(WorldX, WorldY, Params, M); + + // --- F20 heightfield terrain ops (all off by default → byte-identical when unset) --- + + // CLIFF (slope-conditioned STEEPENING): where the surface is already steep, push the height + // AWAY from the local mean so gentle slopes become sheer walls / canyon faces; gentle ground + // stays untouched. Slope + local mean come from central differences of the STRUCTURAL field + // (no op feedback), so it tracks the real landform. The one op that costs extra samples — + // only when enabled (4 structural resamples, per-column-cheap). + if (Params.CliffStrength > 0.0f) + { + const float D = FMath::Max(Params.CliffSampleDist, 0.5f); + float Ms; // relief scratch (unused — we only need the heights) + const float Zxp = SampleSurfaceStructuralZ(WorldX + D, WorldY, Params, Ms); + const float Zxm = SampleSurfaceStructuralZ(WorldX - D, WorldY, Params, Ms); + const float Zyp = SampleSurfaceStructuralZ(WorldX, WorldY + D, Params, Ms); + const float Zym = SampleSurfaceStructuralZ(WorldX, WorldY - D, Params, Ms); + const float dZdX = (Zxp - Zxm) / (2.0f * D); + const float dZdY = (Zyp - Zym) / (2.0f * D); + const float Slope = FMath::Sqrt(dZdX * dZdX + dZdY * dZdY); // rise per voxel of XY + + const float Thr = FMath::Max(Params.CliffSlopeThreshold, 0.05f); + // 0 below the threshold, ramps to 1 within one threshold-width above it. + const float SlopeGate = FMath::Clamp((Slope - Thr) / Thr, 0.0f, 1.0f); + if (SlopeGate > 0.0f) + { + // Local smoothed reference; push the true height away from it → steepen the wall. + const float Ref = 0.25f * (Zxp + Zxm + Zyp + Zym); + const float Gain = Params.CliffStrength * SlopeGate * Params.CliffSharpness; + Terrain += (Terrain - Ref) * Gain; + } + } + + // TERRACE (relief-gated): quantize height into plateaus, only in high-relief regions and only + // as strongly as TerraceStrength asks. TerraceHardness controls the riser: soft rounded steps + // (0) → crisp flat-topped mesas with near-vertical walls (1). Layered cliffs/mesas up top, + // smooth lowlands. if (Params.TerraceStrength > 0.0f && Params.TerraceHeight > 0.0f) { - const float Stepped = FMath::RoundToFloat(Terrain / Params.TerraceHeight) * Params.TerraceHeight; + const float StepH = Params.TerraceHeight; + const float T = Terrain / StepH; + const float K = FMath::FloorToFloat(T); + const float Frac = T - K; // [0,1) position within the step + // Hardness widens the flat plateau and sharpens the riser: half-transition width goes + // from 0.5 (a smooth S-curve, no plateau) at Hardness=0 to ~0.03 (sharp wall) at 1. + const float W = FMath::Lerp(0.5f, 0.03f, FMath::Clamp(Params.TerraceHardness, 0.0f, 1.0f)); + const float Fs = SmoothStep01(FMath::Clamp((Frac - (0.5f - W)) / (2.0f * W), 0.0f, 1.0f)); + const float Stepped = (K + Fs) * StepH; Terrain = FMath::Lerp(Terrain, Stepped, Params.TerraceStrength * M); } + // LAYER LINES: fine sedimentary shelves cut into slopes (exposed rock strata). Pull the + // surface weakly toward each band plane; reads on slopes, invisible on flats (uniform shift). + if (Params.LayerLineDepth > 0.0f && Params.LayerLineSpacing > 0.0f) + { + const float Phase = Terrain * (2.0f * PI / Params.LayerLineSpacing); + Terrain -= FMath::Sin(Phase) * Params.LayerLineDepth; + } + // Beach: flatten terrain toward the water line within BeachWidth. (Water level is // strate-global — forced from the strate — so the water plane stays continuous.) - const float WaterZ = BottomZ + H * Params.WaterLevelRelative; + const float H = Params.StrateTopWorldZ - Params.StrateBottomWorldZ; + const float WaterZ = Params.StrateBottomWorldZ + H * Params.WaterLevelRelative; if (Params.WaterLevelRelative > 0.0f && Params.BeachWidth > 0.0f) { const float DAbs = FMath::Abs(Terrain - WaterZ); @@ -2033,12 +2256,44 @@ float UVoxelGenerator::ComputeSurfaceCeiling(float WorldX, float WorldY, float UVoxelGenerator::SurfaceDensityFromColumn(float WorldX, float WorldY, float WorldZ, float TerrainZ, float CeilSurf, + float OverhangAmp, float DirX, float DirY, const FSurfaceGenerationParams& S) const { // Solid below the terrain surface; solid above the sky-cap ceiling. float Density = TerrainZ - WorldZ; Density = FMath::Max(Density, WorldZ - CeilSurf); + // F20 phase 2 — OVERHANG shelf (warped-terrain union): for AIR voxels in a window just above a + // steep slope, re-sample the heightfield UPHILL (toward the cliff) by a height-varying amount and + // union that rock in → the cliff-top rock juts OUT over the void below, self-capping at the cliff's + // height. Genuine 3D (per-voxel re-eval), so it's gated hard: only steep overhang columns (amp>0), + // only air voxels within OverhangHeight of the local ground, only when the shift is ≥ a voxel. + // The lip is capped at TerrainZ+OverhangHeight, which ClassifyTile treats as ambiguous (no holes). + if (OverhangAmp > 0.0f && S.OverhangHeight > 0.0f + && WorldZ > TerrainZ && WorldZ <= TerrainZ + S.OverhangHeight) + { + const float SeedF = (float)Seed; + const float f = S.OverhangFrequency; + // Shelf-shape noise [0,1]; the Z term makes the reach fold/curl with height (ragged, not a lip). + const float Ns = FractalNoise3D(FVector( + WorldX * f + SeedF * 17.3f, + WorldY * f + SeedF * 23.9f, + WorldZ * f * S.OverhangZScale + SeedF * 5.1f), 3) * 0.5f + 0.5f; // [0,1] + // KEY: the uphill reach GROWS with height in the window (Frac: 0 at ground → 1 at the cap). Low + // down the shift is tiny ⇒ borrows nearby low rock ⇒ stays AIR over the void; high up the shift + // reaches the far cliff ⇒ solid ⇒ the lip sits on top with air UNDERNEATH = a real overhang. + const float Frac = (WorldZ - TerrainZ) / S.OverhangHeight; // (0,1] inside the window + const float ShiftV = S.OverhangReach * OverhangAmp * Frac * Ns; // uphill reach (voxels) + if (ShiftV > 0.5f) + { + // Borrow the uphill STRUCTURAL height (not the full op'd surface): the shelf underside doesn't + // need cliff/terrace refinement, and this avoids re-running Cliff's resamples per lip voxel. + float Ms; + const float ShiftedTZ = SampleSurfaceStructuralZ(WorldX + DirX * ShiftV, WorldY + DirY * ShiftV, S, Ms); + Density = FMath::Max(Density, ShiftedTZ - WorldZ); // union: solid where uphill rock covers Z + } + } + // Structural fields (Z bounds, seal, base) are forced equal across biomes → S is safe. ApplyOriginSpine(Density, WorldX, WorldY, WorldZ, S.StrateTopWorldZ, S.StrateBottomWorldZ, @@ -2094,7 +2349,8 @@ void UVoxelGenerator::ResolveSurfaceChunkParams(const FIntVector& ChunkCoord, void UVoxelGenerator::ComputeSurfaceColumn(float WorldX, float WorldY, int32 ChunkZ, const FSurfaceGenerationParams& BaseSurface, const FBiomeContext& BiomeCtx, const TArray& BiomeParams, FChunkBiomeCache& BiomeCache, - float& OutTerrainZ, float& OutCeilSurf) const + float& OutTerrainZ, float& OutCeilSurf, + float& OutOverhangAmp, float& OutDirX, float& OutDirY) const { const FSurfaceGenerationParams* PD = &BaseSurface; const FSurfaceGenerationParams* PN = &BaseSurface; @@ -2113,6 +2369,37 @@ void UVoxelGenerator::ComputeSurfaceColumn(float WorldX, float WorldY, int32 Chu OutTerrainZ = ComputeSurfaceTerrainZ(WorldX, WorldY, *PD); if (W > 0.0f) OutTerrainZ = FMath::Lerp(OutTerrainZ, ComputeSurfaceTerrainZ(WorldX, WorldY, *PN), W); OutCeilSurf = ComputeSurfaceCeiling(WorldX, WorldY, *PD); + + // F20 phase 2 — resolve the per-column OVERHANG gate (strength·slope-gate, biome-blended) and the + // UPHILL direction. Slope + gradient come from a forward-diff of the STRUCTURAL field (the real + // landform), computed ONCE here. Off ⇒ amp 0 and the whole block is skipped (no extra samples). + OutOverhangAmp = 0.0f; + OutDirX = 0.0f; OutDirY = 0.0f; + if (PD->OverhangStrength > 0.0f || (W > 0.0f && PN->OverhangStrength > 0.0f)) + { + // Sample the gradient at ~reach scale (a spot over the void must "see" the nearby cliff to know + // uphill), but CLAMPED to [4,16] — an un-clamped large Reach would average the slope over a huge + // span and read even a real cliff as flat (killing the gate; the "big Reach = nothing" bug). + const float SD = FMath::Clamp(PD->OverhangReach, 4.0f, 16.0f); + float Ms; + const float Z0 = SampleSurfaceStructuralZ(WorldX, WorldY, *PD, Ms); + const float GX = (SampleSurfaceStructuralZ(WorldX + SD, WorldY, *PD, Ms) - Z0) / SD; // dZ/dX + const float GY = (SampleSurfaceStructuralZ(WorldX, WorldY + SD, *PD, Ms) - Z0) / SD; // dZ/dY + const float Slope = FMath::Sqrt(GX * GX + GY * GY); + + auto Amp = [Slope](const FSurfaceGenerationParams& P) -> float + { + if (P.OverhangStrength <= 0.0f) return 0.0f; + const float Thr = FMath::Max(P.OverhangSlopeThreshold, 0.05f); + const float Gate = FMath::Clamp((Slope - Thr) / Thr, 0.0f, 1.0f); + return P.OverhangStrength * Gate; // [0,1]; reach comes from the param at apply time + }; + OutOverhangAmp = (W > 0.0f) ? FMath::Lerp(Amp(*PD), Amp(*PN), W) : Amp(*PD); + + // Unit UPHILL direction (the gradient points uphill). The shelf borrows rock from uphill and + // extends it downhill over the void. Degenerate on flats — amp is 0 there anyway. + if (Slope > KINDA_SMALL_NUMBER) { OutDirX = GX / Slope; OutDirY = GY / Slope; } + } } bool UVoxelGenerator::GetSurfaceHeightAt(float WorldX, float WorldY, int32 ChunkZ, @@ -2140,11 +2427,232 @@ bool UVoxelGenerator::GetSurfaceHeightAt(float WorldX, float WorldY, int32 Chunk OC_Chunk = ChunkCoord; ResolveSurfaceChunkParams(ChunkCoord, OC_Surface, OC_BiomeCtx, OC_BiomeParams); } + float OcAmp, OcDirX, OcDirY; // overhang is a density-only 3D shelf; the height oracle ignores it ComputeSurfaceColumn(WorldX, WorldY, ChunkZ, OC_Surface, OC_BiomeCtx, OC_BiomeParams, OC_BiomeCache, - OutTerrainZ, OutCeilSurf); + OutTerrainZ, OutCeilSurf, OcAmp, OcDirX, OcDirY); return true; } +//============================================================================= +// TRIVIAL-TILE CLASSIFICATION (T1.d) +//============================================================================= +// ~84 % des tuiles générées sortent VIDES (tout-roc sous le terrain, tout-air +// au-dessus, cap solide) mais payaient quand même le pré-échantillonnage 33³+ +// complet (trace 2026-07-05 : 83 925 GenerateMesh pour 13 227 maillages réels). +// Une 1re tentative (2026-06-26) a été REVERTÉE : borne analytique GLOBALE du +// plafond pas assez conservative (cap bas ⇒ trous dans le toit). Ici on suit la +// prescription du revert : les colonnes sont ÉCHANTILLONNÉES sur le treillis +// exact du mesher (mêmes fonctions ⇒ mêmes floats ⇒ verdict exact, pas une +// estimation), et le cas tout-solide porte des gardes spine/passages/ +// disturbances/diff. Tout ce qui n'est pas prouvable ⇒ Mixed (le seul coût d'un +// faux Mixed est du CPU ; un faux AllSolid/AllAir serait un trou). + +EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis) const +{ + if (!StrateManager) return EVoxelTileClass::Mixed; + + // Mêmes clamps que GenerateMesh — le verdict doit couvrir le treillis réellement échantillonné. + Step = FMath::Max(1, Step); + const int32 CPA = FMath::Clamp(CellsPerAxis, 2, CHUNK_SIZE); + const int32 GridDim = CPA + 1; // g ∈ [-1, GridDim] par axe (marge des normales incluse) + + // Boîte voxel englobant tous les échantillons du treillis. + const int32 MinX = OriginVoxels.X - Step, MaxX = OriginVoxels.X + GridDim * Step; + const int32 MinY = OriginVoxels.Y - Step, MaxY = OriginVoxels.Y + GridDim * Step; + const int32 MinZ = OriginVoxels.Z - Step, MaxZ = OriginVoxels.Z + GridDim * Step; + + // Division entière PLANCHER (les coords négatives tronquent vers 0 en C++ — pas floor). + auto FloorDivC = [](int32 A, int32 B) -> int32 + { + const int32 Q = A / B, R = A % B; + return (R != 0 && ((R < 0) != (B < 0))) ? Q - 1 : Q; + }; + + // ── Garde diff layer : un mod joueur peut creuser OU remplir n'importe où. ── + if (DiffLayer && DiffLayer->HasAnyMods()) + { + const FIntVector MinChunk(FloorDivC(MinX, CHUNK_SIZE), FloorDivC(MinY, CHUNK_SIZE), FloorDivC(MinZ, CHUNK_SIZE)); + const FIntVector MaxChunk(FloorDivC(MaxX, CHUNK_SIZE), FloorDivC(MaxY, CHUNK_SIZE), FloorDivC(MaxZ, CHUNK_SIZE)); + if (DiffLayer->HasAnyModInChunkRange(MinChunk, MaxChunk)) return EVoxelTileClass::Mixed; + } + + bool bCanSolid = true; // "tout le treillis est solide" encore prouvable + bool bCanAir = true; // "tout le treillis est air" encore prouvable + + // ── Gardes "carveurs d'air" : cassent AllSolid, jamais AllAir (ils n'ajoutent pas de roche). ── + if (StrateManager->AnyPassageNearBox(FVector(MinX, MinY, MinZ), FVector(MaxX, MaxY, MaxZ))) + { + bCanSolid = false; + } + if (OriginSpineRadius > 0.0f) + { + // Spine (0,0) : colonne XY rayon R + blend 3 (cf. ApplyOriginSpine). Test cercle/boîte XY. + const float Reach = OriginSpineRadius + 3.0f; + const float CX = FMath::Clamp(0.0f, (float)MinX, (float)MaxX); + const float CY = FMath::Clamp(0.0f, (float)MinY, (float)MaxY); + if (CX * CX + CY * CY <= Reach * Reach) bCanSolid = false; + } + + // ── Catégorisation par Z de treillis. v1 : gap bedrock = solide ; SurfaceWorld = test + // colonne ; tout le reste (intérieurs de caves, hors layout) = Mixed immédiat. ── + struct FSurfSlot + { + int32 BotChunkZ = INT32_MAX; // identité du slot (borne basse de la strate, en chunks) + int32 RepChunkZ = 0; // chunk Z représentatif pour la résolution des params + int32 StrateKey = MIN_int32; // même clé que GetDensityAt (StrateBottomWorldZ arrondi) + float OverhangMargin = 0.0f; // F20 : hauteur max de corniche (= max OverhangHeight strate+biomes) + FSurfaceGenerationParams Params; + FBiomeContext BiomeCtx; + TArray BiomeParams; + TArray InteriorZ; // z hors bandes de seal → testés par colonne + }; + // thread_local : les TArray gardent leur capacité d'un appel à l'autre (zéro malloc/tuile). + static thread_local FSurfSlot Slots[2]; + static thread_local FChunkBiomeCache TC_BiomeCache; // biome grid du classifieur (valeurs ≡ CP_BiomeCache) + int32 NumSlots = 0; + + int32 MemoChunkZ = INT32_MAX; + int32 MemoCat = -1; // 0 = gap, 1 = surface + int32 MemoSlotIdx = -1; + for (int32 g = -1; g <= GridDim; ++g) + { + const int32 Zi = OriginVoxels.Z + g * Step; + const int32 ChunkZ = FloorDivC(Zi, CHUNK_SIZE); + if (ChunkZ != MemoChunkZ) + { + MemoChunkZ = ChunkZ; + const FIntVector CC(0, 0, ChunkZ); // les requêtes de layout ne dépendent que de Z + if (StrateManager->IsGapChunk(CC)) + { + MemoCat = 0; + } + else if (StrateManager->GetGeneratorTypeForChunk(CC) == ECaveGeneratorType::SurfaceWorld) + { + // Retrouve (ou résout) le slot de strate — l'identité vient des bornes chunk-Z du layout. + int32 TopCZ = 0, BotCZ = 0; + if (!StrateManager->GetStrateChunkZBounds(ChunkZ, TopCZ, BotCZ)) return EVoxelTileClass::Mixed; + MemoSlotIdx = -1; + for (int32 s = 0; s < NumSlots; ++s) + { + if (Slots[s].BotChunkZ == BotCZ) { MemoSlotIdx = s; break; } + } + if (MemoSlotIdx < 0) + { + if (NumSlots >= 2) return EVoxelTileClass::Mixed; // >2 strates surface dans une tuile : improbable + MemoSlotIdx = NumSlots++; + FSurfSlot& S = Slots[MemoSlotIdx]; + S.BotChunkZ = BotCZ; + S.RepChunkZ = ChunkZ; + S.InteriorZ.Reset(); + ResolveSurfaceChunkParams(CC, S.Params, S.BiomeCtx, S.BiomeParams); + S.StrateKey = FMath::RoundToInt(S.Params.StrateBottomWorldZ); + + // F20 : marge d'overhang = hauteur max (OverhangHeight) sur laquelle une corniche + // peut AJOUTER de la roche AU-DESSUS du sol. La corniche est plafonnée à + // TerrainZ+Height ⇒ au-delà, air prouvable ; dans (TerrainZ, TerrainZ+Height], ni air + // ni solide prouvable → colonne forcée en Mixed. (L'union n'enlève jamais de roche ⇒ + // sous le terrain reste solide prouvable : marge vers le HAUT uniquement.) + S.OverhangMargin = (S.Params.OverhangStrength > 0.0f) ? S.Params.OverhangHeight : 0.0f; + for (const FSurfaceGenerationParams& BP : S.BiomeParams) + { + if (BP.OverhangStrength > 0.0f) + S.OverhangMargin = FMath::Max(S.OverhangMargin, BP.OverhangHeight); + } + + // Disturbances de cette strate : les chasms CREUSENT (cassent AllSolid), les + // ponts/arêtes AJOUTENT de la roche dans l'intérieur (cassent AllAir). + const FStrateDisturbanceParams D = StrateManager->GetDisturbanceParamsForChunk(CC); + if (D.ChasmDensity > 0.0f) bCanSolid = false; + if (D.BridgeDensity > 0.0f || D.RidgeDensity > 0.0f) bCanAir = false; + } + MemoCat = 1; + } + else + { + return EVoxelTileClass::Mixed; // archétype cave / hors layout : pas prouvable en v1 + } + } + + if (MemoCat == 0) + { + bCanAir = false; // bedrock du gap = solide (le carve des passages est déjà gardé) + } + else + { + FSurfSlot& S = Slots[MemoSlotIdx]; + // Bande de seal ? Mêmes inégalités qu'ApplyBoundarySeal : à l'intérieur, la densité est + // Max(…, SealFactor·BaseDensity) avec SealFactor > 0 ⇒ solide garanti si BaseDensity > 0. + const float Z = (float)Zi; + const float DistTop = S.Params.StrateTopWorldZ - Z; + const float DistBot = Z - S.Params.StrateBottomWorldZ; + const float Th = S.Params.BoundarySealThickness; + const bool bInBand = Th > 0.0f && S.Params.BaseDensity > 0.0f + && ((DistTop >= 0.0f && DistTop < Th) || (DistBot >= 0.0f && DistBot < Th)); + if (bInBand) { bCanAir = false; } + else { S.InteriorZ.Add(Z); } + } + if (!bCanSolid && !bCanAir) return EVoxelTileClass::Mixed; + } + + // ── Balayage des colonnes XY sur le treillis exact du mesher (marge incluse). Une colonne + // tranche chaque z intérieur : air côté MC ⇔ TerrainZ ≤ z ≤ CeilSurf (D = −interne ≥ 0, + // cf. SurfaceDensityFromColumn ; spine/passages ne font QUE de l'air → gardés plus haut). + // Pré-passe clairsemée ~5×5 pour tuer vite les tuiles traversées par la surface, puis + // passe complète — les colonnes recalculées par la pré-passe restent chaudes dans la boîte. + auto TestColumn = [&](int32 gx, int32 gy) -> bool // false ⇒ les deux hypothèses sont mortes + { + const int32 Xi = OriginVoxels.X + gx * Step; + const int32 Yi = OriginVoxels.Y + gy * Step; + for (int32 s = 0; s < NumSlots; ++s) + { + FSurfSlot& S = Slots[s]; + if (S.InteriorZ.Num() == 0) continue; + FSurfaceColumnBox& Box = GSurfColCache.Acquire(Xi, Yi, S.StrateKey, Seed); + const int32 CI = (Yi - Box.BaseY) * FSurfaceColumnBox::Dim + (Xi - Box.BaseX); + if (!Box.Computed[CI]) + { + ComputeSurfaceColumn((float)Xi, (float)Yi, S.RepChunkZ, S.Params, S.BiomeCtx, + S.BiomeParams, TC_BiomeCache, + Box.Cols[CI].TerrainZ, Box.Cols[CI].CeilSurf, + Box.Cols[CI].OverhangAmp, Box.Cols[CI].DirX, Box.Cols[CI].DirY); + Box.Computed[CI] = true; + } + const float T = Box.Cols[CI].TerrainZ; + const float C = Box.Cols[CI].CeilSurf; + const float M = S.OverhangMargin; + for (const float Z : S.InteriorZ) + { + // F20 : juste au-dessus du sol (jusqu'à +OverhangHeight) une corniche peut ajouter de la + // roche ⇒ ni air ni solide prouvable → tuile Mixed. (Vers le haut uniquement : l'union + // n'enlève rien sous le terrain ; le cap n'est pas affecté.) + if (M > 0.0f && Z > T && Z <= T + M) return false; + if (Z >= T && Z <= C) { bCanSolid = false; } // point côté air (surface incluse) + else { bCanAir = false; } // sous le terrain / dans le cap + if (!bCanSolid && !bCanAir) return false; + } + } + return true; + }; + + const bool bNeedColumns = (NumSlots > 0) + && (Slots[0].InteriorZ.Num() > 0 || (NumSlots > 1 && Slots[1].InteriorZ.Num() > 0)); + if (bNeedColumns) + { + const int32 PreStride = FMath::Max(1, (GridDim + 1) / 4); + for (int32 gy = -1; gy <= GridDim; gy += PreStride) + for (int32 gx = -1; gx <= GridDim; gx += PreStride) + if (!TestColumn(gx, gy)) return EVoxelTileClass::Mixed; + for (int32 gy = -1; gy <= GridDim; ++gy) + for (int32 gx = -1; gx <= GridDim; ++gx) + if (!TestColumn(gx, gy)) return EVoxelTileClass::Mixed; + } + + // Ici exactement UNE hypothèse doit survivre (chaque point testé en tue une ; les tuiles + // sans point intérieur ont tué AllAir via gap/seal). Égalité = prudence → Mixed. + if (bCanSolid == bCanAir) return EVoxelTileClass::Mixed; + return bCanSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir; +} + float UVoxelGenerator::GetSurfaceDensity(float WorldX, float WorldY, float WorldZ, const FSurfaceGenerationParams& ParamsD, const FSurfaceGenerationParams& ParamsN, @@ -2162,7 +2670,8 @@ float UVoxelGenerator::GetSurfaceDensity(float WorldX, float WorldY, float World } const float CeilSurf = ComputeSurfaceCeiling(WorldX, WorldY, ParamsD); - return SurfaceDensityFromColumn(WorldX, WorldY, WorldZ, TerrainZ, CeilSurf, ParamsD); + return SurfaceDensityFromColumn(WorldX, WorldY, WorldZ, TerrainZ, CeilSurf, + /*OverhangAmp*/0.0f, /*DirX*/0.0f, /*DirY*/0.0f, ParamsD); } //============================================================================= @@ -2196,6 +2705,43 @@ float UVoxelGenerator::SampleMoisture(float WorldX, float WorldY, float Frequenc return FMath::Clamp(N, 0.0f, 1.0f); } +bool UVoxelGenerator::EvaluateTerrainConditions(const TArray& Conditions, + float WorldX, float WorldY, const FBiomeContext& BiomeCtx) const +{ + if (Conditions.Num() == 0) return true; // zero-cost default (the common case) + + // Freq/contrast come from the strate's biome map (defaults when the strate has no biomes) — the same + // params SampleRelief/SampleMoisture are given everywhere else, so a condition agrees with the terrain. + const FBiomeMapParams MP = BiomeCtx.IsValid() ? BiomeCtx.Map : FBiomeMapParams(); + + // Lazily sample each field only if a condition references it (BiomeBorder's Voronoi is the pricey one). + float Relief = 0.0f, Moisture = 0.0f, Border = 0.0f; + bool bHaveRelief = false, bHaveMoisture = false, bHaveBorder = false; + + for (const FTerrainCondition& C : Conditions) + { + float V = 0.0f; + switch (C.Type) + { + case ETerrainConditionType::Relief: + if (!bHaveRelief) { Relief = SampleRelief(WorldX, WorldY, MP.ReliefFrequency, MP.ReliefContrast); bHaveRelief = true; } + V = Relief; break; + case ETerrainConditionType::Moisture: + if (!bHaveMoisture) { Moisture = SampleMoisture(WorldX, WorldY, MP.MoistureFrequency); bHaveMoisture = true; } + V = Moisture; break; + case ETerrainConditionType::BiomeBorder: + if (!bHaveBorder) { Border = SampleBiomeAt(WorldX, WorldY, BiomeCtx).NeighborWeight; bHaveBorder = true; } + V = Border; break; + default: break; + } + + bool bPass = (V >= C.Min && V <= C.Max); + if (C.bInvert) bPass = !bPass; + if (!bPass) return false; // AND semantics: any failing condition rejects the candidate + } + return true; +} + int32 UVoxelGenerator::ClassifyBiomeAtSite(float SiteX, float SiteY, const FBiomeContext& Ctx, uint32 SiteHash) const { @@ -2540,25 +3086,71 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float const int32 CX = FMath::FloorToInt(WorldX / Spacing); const int32 CY = FMath::FloorToInt(WorldY / Spacing); - // Collect shafts in the 3x3 neighbourhood (XY). + // Shafts + cross-connectors of the 3×3 neighbourhood are pure functions of (cell, seed, + // params) yet were re-hashed PER VOXEL (9 cell rolls + a pair hash per shaft pair). Bake + // them once per centre cell (thread_local); per-voxel work = the cylinder/capsule SDFs. struct FLocalShaft { float X, Y, R; }; - TArray> Shafts; + struct FLocalConn { FVector A, B; }; + thread_local TArray> Shafts; + thread_local TArray> Conns; + thread_local int32 VS_CX = INT32_MAX, VS_CY = INT32_MAX; + thread_local uint32 VS_Seed = 0xFFFFFFFFu; + thread_local float VS_Spacing = -1.0f, VS_Dens = -1.0f, VS_MinR = -1.0f, VS_MaxR = -1.0f, + VS_Cross = -1.0f, VS_BotZ = FLT_MAX, VS_TopZ = FLT_MAX; - for (int32 dy = -1; dy <= 1; dy++) - for (int32 dx = -1; dx <= 1; dx++) + if (CX != VS_CX || CY != VS_CY || S != VS_Seed || Spacing != VS_Spacing || + Params.ShaftDensity != VS_Dens || Params.ShaftMinRadius != VS_MinR || Params.ShaftMaxRadius != VS_MaxR || + Params.CrossConnectChance != VS_Cross || + Params.StrateBottomWorldZ != VS_BotZ || Params.StrateTopWorldZ != VS_TopZ) { - const int32 nx = CX + dx, ny = CY + dy; - const uint32 Hh = VoxelHash::Cell(nx, ny, S); - if (VoxelHash::ToFloat01(Hh) > Params.ShaftDensity) continue; + VS_CX = CX; VS_CY = CY; VS_Seed = S; VS_Spacing = Spacing; + VS_Dens = Params.ShaftDensity; VS_MinR = Params.ShaftMinRadius; VS_MaxR = Params.ShaftMaxRadius; + VS_Cross = Params.CrossConnectChance; + VS_BotZ = Params.StrateBottomWorldZ; VS_TopZ = Params.StrateTopWorldZ; + Shafts.Reset(); + Conns.Reset(); - const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x12345678u)); - const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x9ABCDEF0u)); - FLocalShaft Sh; - Sh.X = (nx + 0.15f + JX * 0.7f) * Spacing; - Sh.Y = (ny + 0.15f + JY * 0.7f) * Spacing; - Sh.R = FMath::Lerp(Params.ShaftMinRadius, Params.ShaftMaxRadius, - VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0xBEEFu))); - Shafts.Add(Sh); + // Collect shafts in the 3x3 neighbourhood (XY). + for (int32 dy = -1; dy <= 1; dy++) + for (int32 dx = -1; dx <= 1; dx++) + { + const int32 nx = CX + dx, ny = CY + dy; + const uint32 Hh = VoxelHash::Cell(nx, ny, S); + if (VoxelHash::ToFloat01(Hh) > Params.ShaftDensity) continue; + + const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x12345678u)); + const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x9ABCDEF0u)); + FLocalShaft Sh; + Sh.X = (nx + 0.15f + JX * 0.7f) * Spacing; + Sh.Y = (ny + 0.15f + JY * 0.7f) * Spacing; + Sh.R = FMath::Lerp(Params.ShaftMinRadius, Params.ShaftMaxRadius, + VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0xBEEFu))); + Shafts.Add(Sh); + } + + // Horizontal connectors between nearby shaft pairs (hash-gated). + if (Params.CrossConnectChance > 0.0f && Shafts.Num() >= 2) + { + const float BottomZ = Params.StrateBottomWorldZ + Params.BoundarySealThickness; + const float TopZ = Params.StrateTopWorldZ - Params.BoundarySealThickness; + for (int32 i = 0; i < Shafts.Num(); i++) + for (int32 j = i + 1; j < Shafts.Num(); j++) + { + const FLocalShaft& A = Shafts[i]; + const FLocalShaft& B = Shafts[j]; + const float DSq = FMath::Square(A.X - B.X) + FMath::Square(A.Y - B.Y); + if (DSq > FMath::Square(Spacing * 1.6f)) continue; // only neighbours + + // Symmetric pair hash from quantised endpoints. + const uint32 PH = VoxelHash::Pair( + FMath::RoundToInt(A.X), FMath::RoundToInt(A.Y), + FMath::RoundToInt(B.X), FMath::RoundToInt(B.Y), S ^ 0xC04Eu); + if (VoxelHash::ToFloat01(PH) >= Params.CrossConnectChance) continue; + + const float Zc = FMath::Lerp(BottomZ, TopZ, VoxelHash::ToFloat01(VoxelHash::Mix(PH))); + Conns.Add({ FVector(A.X, A.Y, Zc), FVector(B.X, B.Y, Zc) }); + } + } } float CaveSDF = FLT_MAX; @@ -2570,36 +3162,15 @@ float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float const float DY = WorldY - Sh.Y; CaveSDF = FMath::Min(CaveSDF, FMath::Sqrt(DX * DX + DY * DY) - Sh.R); } - - // Horizontal connectors between nearby shaft pairs (hash-gated). - if (Params.CrossConnectChance > 0.0f && Shafts.Num() >= 2) + for (const FLocalConn& C : Conns) { - const float BottomZ = Params.StrateBottomWorldZ + Params.BoundarySealThickness; - const float TopZ = Params.StrateTopWorldZ - Params.BoundarySealThickness; - for (int32 i = 0; i < Shafts.Num(); i++) - for (int32 j = i + 1; j < Shafts.Num(); j++) - { - const FLocalShaft& A = Shafts[i]; - const FLocalShaft& B = Shafts[j]; - const float DSq = FMath::Square(A.X - B.X) + FMath::Square(A.Y - B.Y); - if (DSq > FMath::Square(Spacing * 1.6f)) continue; // only neighbours - - // Symmetric pair hash from quantised endpoints. - const uint32 PH = VoxelHash::Pair( - FMath::RoundToInt(A.X), FMath::RoundToInt(A.Y), - FMath::RoundToInt(B.X), FMath::RoundToInt(B.Y), S ^ 0xC04Eu); - if (VoxelHash::ToFloat01(PH) >= Params.CrossConnectChance) continue; - - const float Zc = FMath::Lerp(BottomZ, TopZ, VoxelHash::ToFloat01(VoxelHash::Mix(PH))); - CaveSDF = FMath::Min(CaveSDF, VoxelSDF::Capsule(Pos, - FVector(A.X, A.Y, Zc), FVector(B.X, B.Y, Zc), Params.ConnectorRadius)); - } + CaveSDF = FMath::Min(CaveSDF, VoxelSDF::Capsule(Pos, C.A, C.B, Params.ConnectorRadius)); } // Wall roughness. if (Params.SurfaceRoughness > 0.0f && CaveSDF < Params.SurfaceRoughness + 4.0f) { - CaveSDF += FractalNoise3D(FVector(WorldX * 0.1f, WorldY * 0.1f, WorldZ * 0.1f), 3) + CaveSDF += FractalNoise3D(FVector(WorldX * 0.1f, WorldY * 0.1f, WorldZ * 0.1f), VoxelGenLOD::Eff(3)) * VOXEL_NOISE_SCALE * Params.SurfaceRoughness; } @@ -2681,55 +3252,84 @@ float UVoxelGenerator::GetFloatingIslandDensity(float WorldX, float WorldY, floa // organic instead of perfect circles. Computed once per voxel and shared by all nearby // islands (each samples a different part of the field → distinct silhouettes). const float WarpAmp = (Params.IslandMinRadius + Params.IslandMaxRadius) * 0.5f * 0.35f; - const float WX = WorldX + FractalNoise3D(FVector(WorldX * 0.04f + (float)S * 0.0007f, WorldY * 0.04f, WorldZ * 0.012f), 3) + const float WX = WorldX + FractalNoise3D(FVector(WorldX * 0.04f + (float)S * 0.0007f, WorldY * 0.04f, WorldZ * 0.012f), VoxelGenLOD::Eff(3)) * VOXEL_NOISE_SCALE * WarpAmp; - const float WY = WorldY + FractalNoise3D(FVector(WorldX * 0.04f + 31.0f, WorldY * 0.04f + 7.0f, WorldZ * 0.012f), 3) + const float WY = WorldY + FractalNoise3D(FVector(WorldX * 0.04f + 31.0f, WorldY * 0.04f + 7.0f, WorldZ * 0.012f), VoxelGenLOD::Eff(3)) * VOXEL_NOISE_SCALE * WarpAmp; - for (int32 dy = -1; dy <= 1; dy++) - for (int32 dx = -1; dx <= 1; dx++) + // Per-island constants (existence roll, jitter, radius, Z anchor, taper) are pure functions + // of (cell, seed, params) yet were re-hashed PER VOXEL. Bake the 3×3 neighbourhood's islands + // once per centre cell (thread_local); the per-voxel work keeps only the warped-frame + // distance / taper / top-surface math (those genuinely vary per voxel). + struct FLocalIsland { float X, Y, Rxy, TopHalf, TopZ, BotZ, TaperEnd; }; + thread_local TArray> Islands; + thread_local int32 FI_CX = INT32_MAX, FI_CY = INT32_MAX; + thread_local uint32 FI_Seed = 0xFFFFFFFFu; + thread_local float FI_Spacing = -1.0f, FI_Dens = -1.0f, FI_MinR = -1.0f, FI_MaxR = -1.0f, + FI_Thick = -1.0f, FI_VJit = -1.0f, FI_BotZ = FLT_MAX, FI_TopZ = FLT_MAX; + + if (CX != FI_CX || CY != FI_CY || S != FI_Seed || Spacing != FI_Spacing || + Params.IslandDensity != FI_Dens || Params.IslandMinRadius != FI_MinR || Params.IslandMaxRadius != FI_MaxR || + Params.ThicknessRatio != FI_Thick || Params.VerticalJitter != FI_VJit || + Params.StrateBottomWorldZ != FI_BotZ || Params.StrateTopWorldZ != FI_TopZ) { - const int32 nx = CX + dx, ny = CY + dy; - const uint32 Hh = VoxelHash::Cell(nx, ny, S); - if (VoxelHash::ToFloat01(Hh) > Params.IslandDensity) continue; + FI_CX = CX; FI_CY = CY; FI_Seed = S; FI_Spacing = Spacing; + FI_Dens = Params.IslandDensity; FI_MinR = Params.IslandMinRadius; FI_MaxR = Params.IslandMaxRadius; + FI_Thick = Params.ThicknessRatio; FI_VJit = Params.VerticalJitter; + FI_BotZ = Params.StrateBottomWorldZ; FI_TopZ = Params.StrateTopWorldZ; + Islands.Reset(); - const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x12345678u)); - const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x9ABCDEF0u)); - const float IslX = (nx + 0.15f + JX * 0.7f) * Spacing; - const float IslY = (ny + 0.15f + JY * 0.7f) * Spacing; + for (int32 dy = -1; dy <= 1; dy++) + for (int32 dx = -1; dx <= 1; dx++) + { + const int32 nx = CX + dx, ny = CY + dy; + const uint32 Hh = VoxelHash::Cell(nx, ny, S); + if (VoxelHash::ToFloat01(Hh) > Params.IslandDensity) continue; - const float Rxy = FMath::Lerp(Params.IslandMinRadius, Params.IslandMaxRadius, - VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x5A5Au))); + const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x12345678u)); + const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x9ABCDEF0u)); - // ASYMMETRIC ISLAND PROFILE: a fairly flat land slab on TOP, and an underside that - // tapers DOWN to a rough point (the hanging "roots"). ThicknessRatio scales how deep - // the underside hangs. This is what reads as a floating island vs. a sphere. - const float TopHalf = Rxy * 0.20f; // land slab above centre - const float UnderDepth = Rxy * FMath::Max(Params.ThicknessRatio, 0.25f); // tapering underside + FLocalIsland& Isl = Islands.AddDefaulted_GetRef(); + Isl.X = (nx + 0.15f + JX * 0.7f) * Spacing; + Isl.Y = (ny + 0.15f + JY * 0.7f) * Spacing; + Isl.Rxy = FMath::Lerp(Params.IslandMinRadius, Params.IslandMaxRadius, + VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x5A5Au))); - const float SpreadZ = FMath::Max(H * 0.5f - FMath::Max(TopHalf, UnderDepth) - Params.BoundarySealThickness, 0.0f) - * Params.VerticalJitter; - const float Cz = MidZ + VoxelHash::ToFloatSigned(VoxelHash::Mix(Hh ^ 0xB17Du)) * SpreadZ; - const float TopZ = Cz + TopHalf; - const float BotZ = Cz - UnderDepth; + // ASYMMETRIC ISLAND PROFILE: a fairly flat land slab on TOP, and an underside that + // tapers DOWN to a rough point (the hanging "roots"). ThicknessRatio scales how deep + // the underside hangs. This is what reads as a floating island vs. a sphere. + Isl.TopHalf = Isl.Rxy * 0.20f; // land slab above centre + const float UnderDepth = Isl.Rxy * FMath::Max(Params.ThicknessRatio, 0.25f); // tapering underside + const float SpreadZ = FMath::Max(H * 0.5f - FMath::Max(Isl.TopHalf, UnderDepth) - Params.BoundarySealThickness, 0.0f) + * Params.VerticalJitter; + const float Cz = MidZ + VoxelHash::ToFloatSigned(VoxelHash::Mix(Hh ^ 0xB17Du)) * SpreadZ; + Isl.TopZ = Cz + Isl.TopHalf; + Isl.BotZ = Cz - UnderDepth; + + // Per-island taper sharpness (SmoothStep end point) for varied silhouettes. + Isl.TaperEnd = FMath::Lerp(0.45f, 0.7f, VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x7A1Eu))); + } + } + + for (const FLocalIsland& Isl : Islands) + { // Horizontal distance in the WARPED (lobed) frame so the outline isn't a circle. - const float Dxw = WX - IslX, Dyw = WY - IslY; + const float Dxw = WX - Isl.X, Dyw = WY - Isl.Y; const float DistXY = FMath::Sqrt(Dxw * Dxw + Dyw * Dyw); // Radius envelope by height: full width across the top, narrowing to a point at the - // bottom tip (SmoothStep taper). Per-island hash gives slightly different taper sharpness. - const float TaperEnd = FMath::Lerp(0.45f, 0.7f, VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x7A1Eu))); - const float Hgt = FMath::Clamp((WorldZ - BotZ) / FMath::Max(TopZ - BotZ, 1.0f), 0.0f, 1.0f); - const float Taper = SmoothStep01(FMath::Clamp(Hgt / TaperEnd, 0.0f, 1.0f)); - const float Env = Rxy * Taper; + // bottom tip (SmoothStep taper). + const float Hgt = FMath::Clamp((WorldZ - Isl.BotZ) / FMath::Max(Isl.TopZ - Isl.BotZ, 1.0f), 0.0f, 1.0f); + const float Taper = SmoothStep01(FMath::Clamp(Hgt / Isl.TaperEnd, 0.0f, 1.0f)); + const float Env = Isl.Rxy * Taper; // Top surface: flat by default; dome the edges down when TopFlatten < 1. - float TopSurf = TopZ; + float TopSurf = Isl.TopZ; if (Params.TopFlatten < 1.0f) { - const float Edge = FMath::Clamp(DistXY / FMath::Max(Rxy, 1.0f), 0.0f, 1.0f); - TopSurf = TopZ - (1.0f - Params.TopFlatten) * TopHalf * 2.0f * Edge * Edge; + const float Edge = FMath::Clamp(DistXY / FMath::Max(Isl.Rxy, 1.0f), 0.0f, 1.0f); + TopSurf = Isl.TopZ - (1.0f - Params.TopFlatten) * Isl.TopHalf * 2.0f * Edge * Edge; } // Pseudo-SDF: outside if beyond the radial envelope OR above the top surface. @@ -2741,7 +3341,7 @@ float UVoxelGenerator::GetFloatingIslandDensity(float WorldX, float WorldY, floa // Craggy shells. if (Params.SurfaceRoughness > 0.0f && IslandSDF < Params.SurfaceRoughness + BlendK + 2.0f) { - IslandSDF += FractalNoise3D(FVector(WorldX * 0.08f, WorldY * 0.08f, WorldZ * 0.08f), 4) + IslandSDF += FractalNoise3D(FVector(WorldX * 0.08f, WorldY * 0.08f, WorldZ * 0.08f), VoxelGenLOD::Eff(4)) * VOXEL_NOISE_SCALE * Params.SurfaceRoughness; } diff --git a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp index a0a6c3c..36e5611 100644 --- a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp +++ b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp @@ -11,7 +11,8 @@ // mort depuis T1.b — la grille pré-échantillonnée fournit positions ET gradients.) FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, int32 Step, int32 InCellsPerAxis, - TArray* OutCaptureGrid) + TArray* OutCaptureGrid, + int32 BandZMinVox, int32 BandZMaxVox) { FVoxelMeshData MeshData; if (OutCaptureGrid) { OutCaptureGrid->Reset(); } @@ -21,6 +22,14 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, // grows). Coarse tiles also use FEWER cells (InCellsPerAxis) for cheaper gen. Step = FMath::Max(1, Step); + // T2.b — octave bias for THIS tile: drop LODOctaveDrop octaves per Step doubling from + // the generator's per-voxel volumetric noise (sub-cell octaves can't shape a coarse + // isosurface). TGuardValue restores 0 on every exit path, so nothing outside this tile + // (deco snapping, density-volume fill, the next task on this pooled thread) sees a bias. + const int32 OctaveBias = (LODOctaveDrop > 0 && Step > 1) + ? LODOctaveDrop * (int32)FMath::FloorLog2((uint32)Step) : 0; + TGuardValue OctaveBiasGuard(VoxelGenLOD::OctaveBias, OctaveBias); + // World-cm origin of the tile's min corner (positions are built relative to this). const FVector ChunkWorldPos = FVector(OriginVoxels) * VOXEL_SIZE; @@ -35,6 +44,22 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, static thread_local TMap VertexMap; VertexMap.Reset(); + //========================================================================= + // F17 — CLASSE DE SURFACE (sol vs plafond sky-cap), par vertex → par triangle + //========================================================================= + // Le discriminant est SÉMANTIQUE, pas géométrique (fable-idea F17) : un vertex orienté vers + // le bas est un sky-cap seulement s'il est proche de CeilSurf ; proche de TerrainZ c'est un + // surplomb de terrain (reste "sol" — l'ancien vote par tuile mettait le matériau ciel sous + // les surplombs des tuiles majoritairement plafond). Un futur toit de grotte (aussi down- + // facing, mais SOUS TerrainZ) tombera correctement côté "sol/roche" par la même règle. + // Coût : GetSurfaceHeightAt (pile XY complète) UNIQUEMENT pour les vertex down-facing, + // mémoïsé par colonne quantifiée au pas de la grille → ≤ (colonnes touchées) appels ; + // ~zéro sur une tuile de sol pur, borné par lattice² sur une tuile de cap. + static thread_local TArray VertexClasses; // 0 = sol, 1 = sky-cap + static thread_local TMap SurfColMemo; // (Xq,Yq,chunkZ) → (TerrainZ, CeilSurf) ; X=FLT_MAX ⇒ pas SurfaceWorld + VertexClasses.Reset(); + SurfColMemo.Reset(); + // Normale fournie par l'appelant (gradient lu dans la grille de densité, T1.b) — // plus d'échantillonnage de densité par vertex. RawNormal pointe solide→air ; on la // normalise ici (fallback up si dégénérée). @@ -84,6 +109,45 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, const uint8 Bc = (uint8)FMath::Clamp(FMath::RoundToInt(BlendW * 255.0f), 0, 255); MeshData.Colors.Add(FColor(R, G, Bc, A)); + // F17 — classe de surface (voir le bloc en tête de fonction). Down-facing seulement : + // les vertex de sol/falaise (N.Z ≥ -0.1) sont "sol" sans payer la requête colonne. + uint8 SurfClass = 0; + if (Normal.Z < -0.1f) + { + const float Zv = WorldPos.Z / VOXEL_SIZE; + const float StepF = (float)Step; + const FIntVector MemoKey( + FMath::RoundToInt(WorldPos.X / (VOXEL_SIZE * StepF)), + FMath::RoundToInt(WorldPos.Y / (VOXEL_SIZE * StepF)), + FMath::FloorToInt(Zv / (float)CHUNK_SIZE)); + FVector2f* Col = SurfColMemo.Find(MemoKey); + if (!Col) + { + float TerrainZ = 0.0f, CeilSurf = 0.0f; + // Un vertex du cap vit à la FRONTIÈRE HAUTE de la strate : arrondi/interpolation + // peuvent le faire flotter dans le chunk gap/seal juste AU-DESSUS (hors + // SurfaceWorld ⇒ sonde ratée ⇒ faux "sol"). On retente un chunk plus bas. + const bool bSurf = Generator->GetSurfaceHeightAt( + (float)(MemoKey.X * Step), (float)(MemoKey.Y * Step), MemoKey.Z, + TerrainZ, CeilSurf) + || Generator->GetSurfaceHeightAt( + (float)(MemoKey.X * Step), (float)(MemoKey.Y * Step), MemoKey.Z - 1, + TerrainZ, CeilSurf); + Col = &SurfColMemo.Add(MemoKey, bSurf ? FVector2f(TerrainZ, CeilSurf) + : FVector2f(FLT_MAX, -FLT_MAX)); + } + // Plus proche du plafond que du terrain ⇒ sky-cap. (Hors SurfaceWorld : sol.) + // + garde : un vrai vertex de cap est AU niveau du cap (± slop d'interpolation) ; + // loin AU-DESSUS = plancher/toit de la strate d'à côté atteint via le retry chunkZ-1 + // (strates contiguës) → reste sol. + if (Col->X != FLT_MAX && FMath::Abs(Zv - Col->Y) < FMath::Abs(Zv - Col->X) + && Zv <= Col->Y + 2.0f * StepF) + { + SurfClass = 1; + } + } + VertexClasses.Add(SurfClass); + return NewIndex; }; @@ -132,11 +196,34 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, const int32 GridDim = CellsPerAxis + 1; const int32 MDim = GridDim + 2; // +1 marge de chaque côté + // COUPE DE CONTENU PAR STRATE — restreint le maillage (et l'échantillonnage) aux cellules + // dont l'intervalle Z chevauche la bande [BandZMinVox, BandZMaxVox] (voxels inclusifs). + // Les tuiles à capture ne sont jamais bandées (niveau 0 — garde-fou ci-dessous). + if (OutCaptureGrid) { BandZMinVox = INT32_MIN; BandZMaxVox = INT32_MAX; } + int32 CzLo = 0, CzHi = CellsPerAxis - 1; + if (BandZMinVox > INT32_MIN || BandZMaxVox < INT32_MAX) + { + auto FloorDivI = [](int64 A, int64 B) -> int32 + { + const int64 Q = A / B; + return (int32)(Q - (((A % B) != 0 && ((A < 0) != (B < 0))) ? 1 : 0)); + }; + // Cellule c couvre [O.Z + c*Step, O.Z + (c+1)*Step] : première/dernière cellule contenant + // la borne (un contact purement tangent est exclu — sa traversée serait hors bande). + CzLo = FMath::Max(CzLo, FloorDivI((int64)BandZMinVox - OriginVoxels.Z, Step)); + CzHi = FMath::Min(CzHi, FloorDivI((int64)BandZMaxVox - OriginVoxels.Z, Step)); + if (CzHi < CzLo) { return MeshData; } // tuile entièrement hors bande → vide + } + const int32 GzLo = CzLo - 1; // coins CzLo..CzHi+1, gradients ±1 + const int32 GzHi = CzHi + 2; // (== -1..GridDim sans bande) + // Réutilise le tampon entre tuiles (thread_local) : SetNumUninitialized garde la // capacité, donc plus de malloc/free de ~170 Ko (35³ floats) par tuile. static thread_local TArray DensityGrid; DensityGrid.SetNumUninitialized(MDim * MDim * MDim); - for (int32 gz = -1; gz <= GridDim; gz++) + // Bande de strate : seules les rangées Z réellement lues (cellules CzLo..CzHi + marges de + // gradient) sont échantillonnées — le reste du tampon reste non initialisé et non lu. + for (int32 gz = GzLo; gz <= GzHi; gz++) { for (int32 gy = -1; gy <= GridDim; gy++) { @@ -193,7 +280,14 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, // On itère sur les CELLULES (indices de grille), pas sur les voxels monde. // Coin i de la cellule = point de grille (cx+ox, cy+oy, cz+oz) ; coord voxel // monde = ce point × Step. LOD0 Step=1 → full res ; LOD1 Step=2 → ~4× moins. - for (int32 cz = 0; cz < CellsPerAxis; cz++) + // F17 — les triangles sont émis dans DEUX seaux (sol / sky-cap, vote majoritaire des + // classes de vertex) puis concaténés sol-puis-cap : RMC exige un run d'indices contigu + // par polygroup. Géométrie inchangée au bit près — seul l'ORDRE des triangles bouge. + static thread_local TArray GroundTris; + static thread_local TArray CapTris; + GroundTris.Reset(); + CapTris.Reset(); + for (int32 cz = CzLo; cz <= CzHi; cz++) // bande de strate : cf. CzLo/CzHi plus haut { for (int32 cy = 0; cy < CellsPerAxis; cy++) { @@ -261,9 +355,14 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, const int32 Idx1 = GetOrCreateVertex(EdgeVertices[E1], EdgeNormals[E1]); const int32 Idx2 = GetOrCreateVertex(EdgeVertices[E2], EdgeNormals[E2]); - MeshData.Triangles.Add(Idx0); - MeshData.Triangles.Add(Idx2); - MeshData.Triangles.Add(Idx1); + // F17 — vote majoritaire (≥ 2 vertex sky-cap ⇒ triangle sky-cap). + const int32 CapVotes = (int32)VertexClasses[Idx0] + + (int32)VertexClasses[Idx1] + + (int32)VertexClasses[Idx2]; + TArray& Dst = (CapVotes >= 2) ? CapTris : GroundTris; + Dst.Add(Idx0); + Dst.Add(Idx2); + Dst.Add(Idx1); } } } @@ -281,7 +380,7 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, // Émis en DOUBLE FACE (deux orientations) pour s'afficher quel que soit le côté caméra / le // matériau. Une arête de surface posée sur une face de frontière a sa coordonnée d'axe EXACTE // (l'interpolation MC garde fixe l'axe de la face) → comparaison flottante exacte fiable. - if (bGenerateSkirts && MeshData.Triangles.Num() > 0) + if (bGenerateSkirts && (GroundTris.Num() + CapTris.Num()) > 0) { const float ExtentCm = (float)(CellsPerAxis * Step) * VOXEL_SIZE; const float MinX = ChunkWorldPos.X, MinY = ChunkWorldPos.Y, MinZ = ChunkWorldPos.Z; @@ -305,34 +404,261 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, return Idx; }; - // On ajoute en itérant : on fige le nombre de triangles de surface et on n'ajoute qu'au-delà. - const int32 BaseTriNum = MeshData.Triangles.Num(); - for (int32 t = 0; t + 2 < BaseTriNum; t += 3) + // F17 — jupes émises PAR SEAU : chaque jupe hérite la classe (donc le polygroup / + // matériau) de son triangle source ; ajouter les jupes après coup casserait les runs + // d'indices contigus par groupe qu'exige RMC. + auto EmitSkirts = [&](TArray& Tris) { - const int32 Tri[3] = { MeshData.Triangles[t], MeshData.Triangles[t + 1], MeshData.Triangles[t + 2] }; - for (int32 e = 0; e < 3; ++e) + // On ajoute en itérant : on fige le nombre de triangles de surface et on n'ajoute qu'au-delà. + const int32 BaseTriNum = Tris.Num(); + for (int32 t = 0; t + 2 < BaseTriNum; t += 3) { - const int32 iA = Tri[e], iB = Tri[(e + 1) % 3]; - // COPIES par valeur — AddSkirtVert réalloue Vertices/Normals (invaliderait des refs). - const FVector PA = MeshData.Vertices[iA]; - const FVector PB = MeshData.Vertices[iB]; - if (!OnBoundaryPlane(PA, PB)) continue; + const int32 Tri[3] = { Tris[t], Tris[t + 1], Tris[t + 2] }; + for (int32 e = 0; e < 3; ++e) + { + const int32 iA = Tri[e], iB = Tri[(e + 1) % 3]; + // COPIES par valeur — AddSkirtVert réalloue Vertices/Normals (invaliderait des refs). + const FVector PA = MeshData.Vertices[iA]; + const FVector PB = MeshData.Vertices[iB]; + if (!OnBoundaryPlane(PA, PB)) continue; - const FVector NA = MeshData.Normals[iA]; - const FVector NB = MeshData.Normals[iB]; - const FColor CA = MeshData.Colors[iA]; - const FColor CB = MeshData.Colors[iB]; - const int32 iA2 = AddSkirtVert(PA - NA * SkirtDepth, NA, CA); - const int32 iB2 = AddSkirtVert(PB - NB * SkirtDepth, NB, CB); + const FVector NA = MeshData.Normals[iA]; + const FVector NB = MeshData.Normals[iB]; + const FColor CA = MeshData.Colors[iA]; + const FColor CB = MeshData.Colors[iB]; + const int32 iA2 = AddSkirtVert(PA - NA * SkirtDepth, NA, CA); + const int32 iB2 = AddSkirtVert(PB - NB * SkirtDepth, NB, CB); - // Quad (iA, iB, iB2, iA2) → 2 triangles, émis dans LES DEUX orientations. - MeshData.Triangles.Add(iA); MeshData.Triangles.Add(iB); MeshData.Triangles.Add(iB2); - MeshData.Triangles.Add(iA); MeshData.Triangles.Add(iB2); MeshData.Triangles.Add(iA2); - MeshData.Triangles.Add(iA); MeshData.Triangles.Add(iB2); MeshData.Triangles.Add(iB); - MeshData.Triangles.Add(iA); MeshData.Triangles.Add(iA2); MeshData.Triangles.Add(iB2); + // Quad (iA, iB, iB2, iA2) → 2 triangles, émis dans LES DEUX orientations. + Tris.Add(iA); Tris.Add(iB); Tris.Add(iB2); + Tris.Add(iA); Tris.Add(iB2); Tris.Add(iA2); + Tris.Add(iA); Tris.Add(iB2); Tris.Add(iB); + Tris.Add(iA); Tris.Add(iA2); Tris.Add(iB2); + } + } + }; + EmitSkirts(GroundTris); + EmitSkirts(CapTris); + } + + // F17 — concatène sol PUIS sky-cap : un run d'indices contigu par polygroup (voir + // FVoxelMeshData::NumCeilingTriangles). Mêmes triangles qu'avant, seul l'ordre change. + MeshData.Triangles.Reserve(GroundTris.Num() + CapTris.Num()); + MeshData.Triangles.Append(GroundTris); + MeshData.Triangles.Append(CapTris); + MeshData.NumCeilingTriangles = CapTris.Num() / 3; + + return MeshData; +} + +//============================================================================= +// F18 — FEUILLE DE CHAMP LOINTAIN (voir le doc de la déclaration dans le .h) +//============================================================================= +// Deux grilles déplacées (sol + cap) au lieu d'un marching cubes 3D. Conventions IDENTIQUES à +// GenerateMesh : positions monde cm, UVs planaires (voxels), masques F6 par GetBiomeMaterialAt, +// jupes double-face extrudées le long de −N par seau, indices sol‖cap + NumCeilingTriangles. +// Winding : le chemin MC émet (0,2,1) des tables Bourke ⇒ une surface orientée +Z s'écrit +// (x,y) → (x,y+1) → (x+1,y) ; le cap (orienté −Z) inverse. +FVoxelMeshData UVoxelMarchingCubesMesher::GenerateSheetMesh(FIntVector OriginVoxels, int32 StepXY, + int32 CellsXY, int32 StrateChunkZ, + int32 HoleMinXVox, int32 HoleMinYVox, + int32 HoleMaxXVox, int32 HoleMaxYVox) +{ + FVoxelMeshData MeshData; + if (!Generator) return MeshData; + StepXY = FMath::Max(1, StepXY); + CellsXY = FMath::Clamp(CellsXY, 2, 512); + + // TROU XY — cellule [x0, x0+Step]×[y0, y0+Step] entièrement dans le rectangle (Max exclusif) + // ⇒ sautée : la zone est couverte par les coquilles MC (voir VoxelWorld.h SheetHole*). + auto CellInHole = [&](int32 cx, int32 cy) -> bool + { + const int32 X0 = OriginVoxels.X + cx * StepXY; + const int32 Y0 = OriginVoxels.Y + cy * StepXY; + return X0 >= HoleMinXVox && X0 + StepXY <= HoleMaxXVox + && Y0 >= HoleMinYVox && Y0 + StepXY <= HoleMaxYVox; + }; + + const int32 GridDim = CellsXY + 1; // points de grille par axe + const int32 MDim = GridDim + 2; // + anneau de marge ±1 (normales continues entre feuilles) + + // Early-out : la validité SurfaceWorld ne dépend que de ChunkZ (les strates sont des couches + // horizontales) — une sonde suffit. La sentinelle par colonne plus bas reste par prudence. + { + float T0 = 0.0f, C0 = 0.0f; + if (!Generator->GetSurfaceHeightAt((float)OriginVoxels.X, (float)OriginVoxels.Y, + StrateChunkZ, T0, C0)) + { + return MeshData; // pas une strate SurfaceWorld → feuille vide + } + } + + // Colonnes (TerrainZ, CeilSurf) en Z-voxel monde absolu, marge incluse. X==FLT_MAX ⇒ invalide. + static thread_local TArray SheetCols; + SheetCols.SetNumUninitialized(MDim * MDim); + for (int32 gy = -1; gy <= GridDim; ++gy) + { + for (int32 gx = -1; gx <= GridDim; ++gx) + { + float Tz = 0.0f, Cz = 0.0f; + const bool bOk = Generator->GetSurfaceHeightAt( + (float)(OriginVoxels.X + gx * StepXY), (float)(OriginVoxels.Y + gy * StepXY), + StrateChunkZ, Tz, Cz); + SheetCols[(gy + 1) * MDim + (gx + 1)] = bOk ? FVector2f(Tz, Cz) + : FVector2f(FLT_MAX, -FLT_MAX); + } + } + auto Col = [&](int32 gx, int32 gy) -> const FVector2f& + { + return SheetCols[(gy + 1) * MDim + (gx + 1)]; + }; + + // Index de vertex par point de grille (−1 = pas créé), sol et cap séparés — le partage de + // vertex est structurel (grille régulière), pas besoin de map de déduplication. + static thread_local TArray GroundIdx; + static thread_local TArray CapIdx; + GroundIdx.Init(-1, GridDim * GridDim); + CapIdx.Init(-1, GridDim * GridDim); + + auto MakeVert = [&](int32 gx, int32 gy, bool bCap) -> int32 + { + int32& Slot = (bCap ? CapIdx : GroundIdx)[gy * GridDim + gx]; + if (Slot >= 0) return Slot; + + const FVector2f C = Col(gx, gy); + const float H = bCap ? C.Y : C.X; + // Pente par différences centrales sur la grille de hauteurs (voxels/voxels — l'échelle cm + // se simplifie). Voisin invalide ⇒ composante plate (ne devrait pas arriver : cf. early-out). + auto HAt = [&](int32 x, int32 y) -> float + { + const FVector2f& N = Col(x, y); + return (N.X == FLT_MAX) ? H : (bCap ? N.Y : N.X); + }; + const float Sx = (HAt(gx + 1, gy) - HAt(gx - 1, gy)) / (2.0f * (float)StepXY); + const float Sy = (HAt(gx, gy + 1) - HAt(gx, gy - 1)) / (2.0f * (float)StepXY); + // Sol : air au-dessus ⇒ normale vers le haut. Cap : l'air (l'intérieur de la strate) est + // EN-DESSOUS ⇒ normale vers le bas (−∇(z − C) = (Cx, Cy, −1)). + FVector Normal = bCap ? FVector(Sx, Sy, -1.0f) : FVector(-Sx, -Sy, 1.0f); + if (!Normal.Normalize()) + { + Normal = FVector(0.0f, 0.0f, bCap ? -1.0f : 1.0f); + } + + const float Xv = (float)(OriginVoxels.X + gx * StepXY); + const float Yv = (float)(OriginVoxels.Y + gy * StepXY); + const FVector WorldPos(Xv * VOXEL_SIZE, Yv * VOXEL_SIZE, H * VOXEL_SIZE); + + Slot = MeshData.Vertices.Num(); + MeshData.Vertices.Add(WorldPos); + MeshData.Normals.Add(Normal); + MeshData.UVs.Add(FVector2D(Xv, Yv)); // == WorldPos.XY / VOXEL_SIZE (convention MC) + + // Mêmes masques F6 que GenerateMesh (palette biome dominant/voisin, pente, fondu). + int32 PalD = 0, PalN = 0; float BlendW = 0.0f; + Generator->GetBiomeMaterialAt(Xv, Yv, H, PalD, PalN, BlendW); + const uint8 Rr = (uint8)FMath::Clamp(PalD, 0, 255); + const uint8 Aa = (uint8)FMath::Clamp(PalN, 0, 255); + const uint8 Gg = (uint8)FMath::Clamp(FMath::RoundToInt((1.0f - FMath::Abs((float)Normal.Z)) * 255.0f), 0, 255); + const uint8 Bb = (uint8)FMath::Clamp(FMath::RoundToInt(BlendW * 255.0f), 0, 255); + MeshData.Colors.Add(FColor(Rr, Gg, Bb, Aa)); + + return Slot; + }; + + static thread_local TArray SheetGroundTris; + static thread_local TArray SheetCapTris; + SheetGroundTris.Reset(); + SheetCapTris.Reset(); + + for (int32 cy = 0; cy < CellsXY; ++cy) + { + for (int32 cx = 0; cx < CellsXY; ++cx) + { + if (CellInHole(cx, cy)) continue; // couverte par les coquilles MC proches + if (Col(cx, cy).X == FLT_MAX || Col(cx + 1, cy).X == FLT_MAX || + Col(cx, cy + 1).X == FLT_MAX || Col(cx + 1, cy + 1).X == FLT_MAX) + { + continue; + } + + // SOL (polygroup 0) — orienté +Z. + const int32 g00 = MakeVert(cx, cy, false); + const int32 g10 = MakeVert(cx + 1, cy, false); + const int32 g01 = MakeVert(cx, cy + 1, false); + const int32 g11 = MakeVert(cx + 1, cy + 1, false); + SheetGroundTris.Add(g00); SheetGroundTris.Add(g01); SheetGroundTris.Add(g10); + SheetGroundTris.Add(g10); SheetGroundTris.Add(g01); SheetGroundTris.Add(g11); + + // CAP (polygroup 1) — orienté −Z ⇒ winding inversé. + const int32 c00 = MakeVert(cx, cy, true); + const int32 c10 = MakeVert(cx + 1, cy, true); + const int32 c01 = MakeVert(cx, cy + 1, true); + const int32 c11 = MakeVert(cx + 1, cy + 1, true); + SheetCapTris.Add(c00); SheetCapTris.Add(c10); SheetCapTris.Add(c01); + SheetCapTris.Add(c10); SheetCapTris.Add(c11); SheetCapTris.Add(c01); + } + } + + // JUPES périmètre — mêmes règles que le chemin MC : extrusion le long de −N (vers le solide : + // bas pour le sol, HAUT pour le cap), double face, émises PAR SEAU (héritent le polygroup). + // Entre feuilles voisines les coins coïncident (mêmes échantillons monde) → pas de fissure ; + // la jupe couvre surtout la couture feuille ↔ anneau MC et les décalages de niveau. + if (bGenerateSkirts && (SheetGroundTris.Num() + SheetCapTris.Num()) > 0) + { + const float SkirtDepth = FMath::Max(1.0f, SkirtCells) * (float)StepXY * VOXEL_SIZE; + + auto EmitEdgeSkirt = [&](int32 ax, int32 ay, int32 bx, int32 by, bool bCap, TArray& Tris) + { + if (Col(ax, ay).X == FLT_MAX || Col(bx, by).X == FLT_MAX) return; + const int32 iA = MakeVert(ax, ay, bCap); + const int32 iB = MakeVert(bx, by, bCap); + // COPIES par valeur — les Add ci-dessous réallouent Vertices/Normals/Colors. + const FVector PA = MeshData.Vertices[iA], PB = MeshData.Vertices[iB]; + const FVector NA = MeshData.Normals[iA], NB = MeshData.Normals[iB]; + const FColor CA = MeshData.Colors[iA], CB = MeshData.Colors[iB]; + + const int32 iA2 = MeshData.Vertices.Num(); + MeshData.Vertices.Add(PA - NA * SkirtDepth); + MeshData.Normals.Add(NA); + MeshData.UVs.Add(FVector2D(PA.X / VOXEL_SIZE, PA.Y / VOXEL_SIZE)); + MeshData.Colors.Add(CA); + const int32 iB2 = MeshData.Vertices.Num(); + MeshData.Vertices.Add(PB - NB * SkirtDepth); + MeshData.Normals.Add(NB); + MeshData.UVs.Add(FVector2D(PB.X / VOXEL_SIZE, PB.Y / VOXEL_SIZE)); + MeshData.Colors.Add(CB); + + Tris.Add(iA); Tris.Add(iB); Tris.Add(iB2); + Tris.Add(iA); Tris.Add(iB2); Tris.Add(iA2); + Tris.Add(iA); Tris.Add(iB2); Tris.Add(iB); + Tris.Add(iA); Tris.Add(iA2); Tris.Add(iB2); + }; + + // Une jupe n'est émise que si la cellule de bord adjacente a réellement été maillée + // (pas dans le trou XY) — sinon mur flottant sans surface. + for (int32 c = 0; c < CellsXY; ++c) + { + for (int32 Pass = 0; Pass < 2; ++Pass) + { + const bool bCap = (Pass == 1); + TArray& Tris = bCap ? SheetCapTris : SheetGroundTris; + if (!CellInHole(c, 0)) + EmitEdgeSkirt(c, 0, c + 1, 0, bCap, Tris); // bord Y-min + if (!CellInHole(c, CellsXY - 1)) + EmitEdgeSkirt(c, CellsXY, c + 1, CellsXY, bCap, Tris); // bord Y-max + if (!CellInHole(0, c)) + EmitEdgeSkirt(0, c, 0, c + 1, bCap, Tris); // bord X-min + if (!CellInHole(CellsXY - 1, c)) + EmitEdgeSkirt(CellsXY, c, CellsXY, c + 1, bCap, Tris); // bord X-max } } } + // Concatène sol PUIS cap : un run d'indices contigu par polygroup (contrat F17/BuildTileStreamSet). + MeshData.Triangles.Reserve(SheetGroundTris.Num() + SheetCapTris.Num()); + MeshData.Triangles.Append(SheetGroundTris); + MeshData.Triangles.Append(SheetCapTris); + MeshData.NumCeilingTriangles = SheetCapTris.Num() / 3; + return MeshData; } diff --git a/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Source/VoxelForge/Private/VoxelStrateManager.cpp index 3199345..dd5ddb3 100644 --- a/Source/VoxelForge/Private/VoxelStrateManager.cpp +++ b/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -452,6 +452,27 @@ float UVoxelStrateManager::EvaluateModifierSDF(float WorldX, float WorldY, float return MinSDF; } +bool UVoxelStrateManager::AnyPassageNearBox(const FVector& MinVoxel, const FVector& MaxVoxel) const +{ + // Le carve d'un passage atteint ModSDF < PASSAGE_BLEND_RADIUS (4, VoxelGenerator.cpp) au-delà de + // sa surface ; BoundRadius inclut déjà rayon + blend, on re-pad par sécurité (conservatif). + constexpr float CarvePad = 4.0f; + for (const FVoxelPassage& P : Passages) + { + // Point de la boîte le plus proche du centre de la sphère → test sphère/AABB. + const FVector C( + FMath::Clamp(P.BoundCenter.X, MinVoxel.X, MaxVoxel.X), + FMath::Clamp(P.BoundCenter.Y, MinVoxel.Y, MaxVoxel.Y), + FMath::Clamp(P.BoundCenter.Z, MinVoxel.Z, MaxVoxel.Z)); + const float Reach = P.BoundRadius + CarvePad; + if (FVector::DistSquared(C, P.BoundCenter) <= Reach * Reach) + { + return true; + } + } + return false; +} + //============================================================================= // QUERIES //============================================================================= diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index 19bc9b4..ff628af 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -64,13 +64,18 @@ static void BuildTileStreamSet(RealtimeMesh::FRealtimeMeshStreamSet& Streams, co } } - const int32 NumIndices = MeshData.Triangles.Num(); + // F17 — the mesher packs the index buffer as [ground run | sky-cap run] (see + // FVoxelMeshData::NumCeilingTriangles): polygroup 0 = ground, 1 = sky-cap ceiling. + // RMC derives one section per contiguous group run (material slot = group index). + const int32 NumIndices = MeshData.Triangles.Num(); + const int32 FirstCapIndex = NumIndices - MeshData.NumCeilingTriangles * 3; Builder.ReserveAdditionalTriangles(NumIndices / 3); for (int32 i = 0; i < NumIndices; i += 3) { Builder.AddTriangle((uint32)MeshData.Triangles[i], (uint32)MeshData.Triangles[i + 1], - (uint32)MeshData.Triangles[i + 2], 0 /*poly group*/); + (uint32)MeshData.Triangles[i + 2], + (i >= FirstCapIndex) ? 1 : 0 /*poly group*/); } } @@ -85,9 +90,10 @@ void AVoxelWorld::RegenerateAllChunks() GenerationEpoch++; // Tear down every tile component, then clear all tile state. Components are GC-safe via - // actor ownership; destroying them here is immediate. + // actor ownership. T2.c: PARK them instead of destroying — the reload right after this + // is exactly the burst the pool exists for (overflow past the cap is destroyed). const int32 Count = LoadedTiles.Num(); - for (auto& Pair : TileComponents) { if (Pair.Value) Pair.Value->DestroyComponent(); } + for (auto& Pair : TileComponents) { if (Pair.Value) ReleaseTileComponent(Pair.Value); } TileComponents.Empty(); LoadedTiles.Empty(); @@ -102,12 +108,21 @@ void AVoxelWorld::RegenerateAllChunks() // Tiles are already destroyed above — drop any deferred-teardown keys so the drain doesn't // try to UnloadTile coords that no longer exist. PendingUnload.Empty(); + // Re-mesh queues reference now-unloaded tiles — drop them (they'd be skipped anyway). + DirtyRemeshQueue.Empty(); + BandRemeshQueue.Empty(); + // §9.4 collision-only tracking references destroyed tiles — clear (rebuilt on the next crossing). + CollisionOnlyTiles.Empty(); + PrevCollisionOnlyTiles.Empty(); // Reset streaming state so the next Tick rebuilds the desired set and reloads. LastUpdateCenter = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); bAllChunksLoaded = false; DesiredSorted.Reset(); - DesiredSet.Reset(); + DesiredStamped.Reset(); + TransitionHold.Reset(); + TransitionHoldQueue.Reset(); + TransitionHoldCursor = 0; // Tick will reload all tiles on the next frame with fresh params. UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] RegenerateAllChunks (epoch %u): cleared %d tiles"), GenerationEpoch, Count); @@ -129,6 +144,74 @@ void AVoxelWorld::RebuildStrates() UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] RebuildStrates: strate layout + passages rebuilt from settings.")); } +void AVoxelWorld::ValidateDeterminism() +{ + // F2 — window-invariance regression test (§8.4). The density function must return the SAME + // value for a coordinate no matter which chunk's thread_local caches (SDF rooms, strate + // memo, biome grid, surface columns, lattice bakes) happen to be warm. Historically THE + // source of chunk seams — and the invariant every "bit-identical" hot-path refactor claims + // to preserve. This runs on the game thread, whose caches are isolated from the workers. + if (!Generator) + { + UE_LOG(LogTemp, Warning, TEXT("[VoxelForge] ValidateDeterminism: no Generator — run during PIE.")); + return; + } + + const FIntVector CenterChunk = WorldToChunkCoord(GetPlayerPosition()); + + float MaxRepeatDelta = 0.0f; // same alignment sampled twice — must be 0 (statelessness) + float MaxWindowDelta = 0.0f; // left-warmed vs right-warmed — must be 0 (window invariance) + FVector WorstP = FVector::ZeroVector; + int32 Mismatches = 0, Points = 0; + + // Points hugging the X boundary between chunk (CX,CY) and (CX+1,CY): they sit inside BOTH + // chunks' cache search boxes (box = chunk extent + margin), so either alignment may legally + // serve them — exactly the cross-window case that seams when an invariant breaks. + const float BoundaryX = (float)((CenterChunk.X + 1) * CHUNK_SIZE); + for (int32 iy = 0; iy < 16; ++iy) + { + for (int32 iz = 0; iz < 8; ++iz) + { + const float Y = (float)(CenterChunk.Y * CHUNK_SIZE) + (float)iy * 2.0f + 0.5f; + const float Z = (float)(CenterChunk.Z * CHUNK_SIZE) + (float)iz * 4.0f + 0.5f; + for (const float Side : { -0.5f, 0.5f }) // just left / just right of the boundary + { + const float X = BoundaryX + Side; + ++Points; + + // Warm every cache from the LEFT chunk's middle, sample the point twice. + Generator->GetDensityAt(BoundaryX - (float)CHUNK_SIZE * 0.5f, Y, Z); + const float DLeft = Generator->GetDensityAt(X, Y, Z); + const float DLeft2 = Generator->GetDensityAt(X, Y, Z); + + // Re-warm from the RIGHT chunk (rebuilds the boxes centred there), resample. + Generator->GetDensityAt(BoundaryX + (float)CHUNK_SIZE * 0.5f, Y, Z); + const float DRight = Generator->GetDensityAt(X, Y, Z); + + MaxRepeatDelta = FMath::Max(MaxRepeatDelta, FMath::Abs(DLeft - DLeft2)); + const float WDelta = FMath::Abs(DLeft - DRight); + if (WDelta > MaxWindowDelta) + { + MaxWindowDelta = WDelta; + WorstP = FVector(X, Y, Z); + } + if (WDelta > 0.0f) { ++Mismatches; } + } + } + } + + if (MaxWindowDelta == 0.0f && MaxRepeatDelta == 0.0f) + { + UE_LOG(LogTemp, Log, TEXT("[VoxelForge] ValidateDeterminism: OK — %d boundary points at chunk (%d,%d,%d), window delta 0, repeat delta 0."), + Points, CenterChunk.X, CenterChunk.Y, CenterChunk.Z); + } + else + { + UE_LOG(LogTemp, Error, TEXT("[VoxelForge] ValidateDeterminism: FAIL — %d/%d points mismatch, max window delta %.6f (repeat %.6f) at voxel (%.1f, %.1f, %.1f). Window-invariance regression — see ARCHITECTURE §8.4."), + Mismatches, Points, MaxWindowDelta, MaxRepeatDelta, WorstP.X, WorstP.Y, WorstP.Z); + } +} + #if WITH_EDITOR void AVoxelWorld::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) { @@ -250,6 +333,8 @@ void AVoxelWorld::EndPlay(const EEndPlayReason::Type EndPlayReason) while (ProcessQueue.Dequeue(Discard)) {} PendingTiles.Empty(); PendingUnload.Empty(); + DirtyRemeshQueue.Empty(); + BandRemeshQueue.Empty(); // Stop + drain the decoration march tasks (they read the Generator) before UObject teardown. if (ContentManager) @@ -314,6 +399,7 @@ void AVoxelWorld::BeginPlay() Mesher->SetGenerator(Generator); Mesher->bGenerateSkirts = Settings->bGenerateSkirts; Mesher->SkirtCells = Settings->SkirtCells; + Mesher->LODOctaveDrop = Settings->LODOctaveDrop; // T2.b — 0 = off // Système de strates — piloté par le pool et les fixed entries dans Settings. if (Settings->StratePool.Num() > 0) @@ -376,6 +462,7 @@ void AVoxelWorld::Tick(float DeltaTime) // a decoration cell boundary or changes strate; otherwise just drains the spawn budget. { TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateDecorations); ContentManager->UpdateDecorations(PlayerLastPos); } // Rare hash-lattice landmarks (the "mini-suns") — cheap at any radius (scales with count, not area). + // Landmarks now cover F7 set-pieces too (AnchorMode HashLattice/PassageMouth + exclusion). { TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateLandmarks); ContentManager->UpdateLandmarks(PlayerLastPos); } // One strate-global ocean plane following the player (water at every LOD, to the horizon). { TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateWater); ContentManager->UpdateWater(PlayerLastPos); } @@ -474,42 +561,100 @@ void AVoxelWorld::ProcessPendingChunks() { PendingTiles.Remove(DequeuedChunk.Tile); - // Discard results from a previous generation epoch (stale). - if (DequeuedChunk.Epoch != GenerationEpoch) + // ApplyTileResult does epoch check, mark-loaded, capture ingest, empty-release / mesh upload. + // Only a real (visible) upload counts against the per-frame budget — stale/empty drain free. + if (ApplyTileResult(DequeuedChunk)) { - continue; - } - - // Mark the tile loaded (even if empty — so we don't re-submit it). - LoadedTiles.Add(DequeuedChunk.Tile); - - // CAPTURE-DURING-MESHING: hand the mesher's captured density grid to the clipmap BEFORE the - // empty-tile early-out — all-air / all-solid tiles are exactly the uniform cells the volume - // needs, and they carry a valid CaptureGrid even though they render nothing. - if (DensityVolume && DequeuedChunk.CaptureGrid.Num() > 0) - { - DensityVolume->IngestTileCapture(DequeuedChunk.Tile.Coord, MoveTemp(DequeuedChunk.CaptureGrid)); - } - - // Empty mesh = all-air tile — nothing to render, but still "loaded". - if (DequeuedChunk.bEmpty || !DequeuedChunk.Streams) - { - continue; - } - - // Apply mesh (GPU upload) — this is the budgeted part. The vertex/index buffers were - // already built on the worker (T1.f); the game thread only uploads them here. - ApplyMeshToTile(DequeuedChunk.Tile, MoveTemp(*DequeuedChunk.Streams), DequeuedChunk.bIsCeiling); - MeshesApplied++; - - if (MeshesApplied >= MaxApplies) - { - break; + if (++MeshesApplied >= MaxApplies) + { + break; + } } } } +// Game-thread apply for one gen result. Shared by ProcessPendingChunks (async drain) and +// SyncRemeshTile (synchronous carve). Returns true iff a visible mesh was uploaded (budget). +bool AVoxelWorld::ApplyTileResult(FChunkResult& Result) +{ + // Discard results from a previous generation epoch (stale). + if (Result.Epoch != GenerationEpoch) + { + return false; + } + + // Mark the tile loaded (even if empty — so we don't re-submit it). + LoadedTiles.Add(Result.Tile); + + // Une tuile en vol n'est JAMAIS annulée : si le desired set a bougé pendant sa gen, elle + // arrive ici hors desired — le delta cull ne re-scanne plus tout, donc on l'inscrit en + // TransitionHold pour qu'elle soit re-considérée au prochain crossing (ou au settled cull). + if (!IsDesired(Result.Tile)) { AddToTransitionHold(Result.Tile); } + + // CAPTURE-DURING-MESHING: hand the mesher's captured density grid to the clipmap BEFORE the + // empty-tile early-out — all-air / all-solid tiles are exactly the uniform cells the volume + // needs, and they carry a valid CaptureGrid even though they render nothing. + if (DensityVolume && Result.CaptureGrid.Num() > 0) + { + DensityVolume->IngestTileCapture(Result.Tile.Coord, MoveTemp(Result.CaptureGrid)); + } + + // Empty mesh = all-air tile — nothing to render, but still "loaded". + if (Result.bEmpty || !Result.Streams) + { + // Une RE-GEN (BandRemeshQueue / RemeshDirtyChunks) peut passer de "contenu" à "vide" : + // bande déplacée hors de la tuile, ou skip cellule-plus-haute-que-la-bande après un + // changement de strate (LoadTile). L'ancien composant doit tomber, sinon sa vieille + // géométrie (l'autre strate !) reste affichée. Première gen vide : Find rate, no-op. + if (URealtimeMeshComponent** OldComp = TileComponents.Find(Result.Tile)) + { + if (*OldComp) { ReleaseTileComponent(*OldComp); } + TileComponents.Remove(Result.Tile); + } + return false; + } + + // Apply mesh (GPU upload). The vertex/index buffers were already built (T1.f, on the worker for + // the async path or inline for the sync carve path); the game thread only uploads them here. + ApplyMeshToTile(Result); + return true; +} + +// Same-frame level-0 re-mesh on the game thread (see header). Mirrors LoadTile's level-0 parameters +// (Cells = CHUNK_SIZE, Step = 1) + the strate content band; skips density-volume capture (the volume +// is refilled from the diff via MarkDirtyVoxelBox in RemeshDirtyChunks). +void AVoxelWorld::SyncRemeshTile(const FVoxelTileKey& Tile) +{ + if (!Generator || !Mesher || bShuttingDown.load(std::memory_order_relaxed)) return; + + const FIntVector OriginVoxels = Tile.OriginVoxels(); + const int32 Cells = CHUNK_SIZE; // level 0 is always full-res (level 0 < FullResClipLevels) + const int32 Step = 1; // Extent(=CHUNK_SIZE) / Cells + + // STRATE CONTENT CUT — identical to LoadTile (Tile.Level >= CutMin; for a level-0 tile inside the + // player strate the clamp is a no-op, but keep it bit-identical to the async path). Too-coarse + // skip never fires at Step 1. + int32 BandVoxLo = INT32_MIN, BandVoxHi = INT32_MAX; + int32 BandChunkLo = MIN_int32, BandChunkHi = MAX_int32; + const int32 CutMin = Settings ? Settings->StrateContentCutMinLevel : 9; + if (Tile.Level >= CutMin && MeshBandChunkLo != MIN_int32) + { + BandChunkLo = MeshBandChunkLo; + BandChunkHi = MeshBandChunkHi; + BandVoxLo = MeshBandChunkLo * CHUNK_SIZE; + BandVoxHi = (MeshBandChunkHi + 1) * CHUNK_SIZE - 1; + } + + FChunkResult Result; + GenerateTileResult(Tile, OriginVoxels, Step, Cells, GenerationEpoch, /*bWantCapture*/ false, + BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi, + /*bSheetTile*/ false, /*SheetChunkZ*/ 0, + /*Hole*/ 0, 0, 0, 0, Result); // hole unused (not a sheet tile) + + ApplyTileResult(Result); +} + void AVoxelWorld::ProcessUnloadQueue() { TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ProcessUnload); @@ -527,7 +672,7 @@ void AVoxelWorld::ProcessUnloadQueue() TArray Dequeued; // removed from the queue this frame (destroyed OR cancelled) for (const FVoxelTileKey& T : PendingUnload) { - if (DesiredSet.Contains(T)) + if (IsDesired(T)) { // Re-desired before its turn (player reversed) — keep it; it's still loaded, just drop // it from the queue. Doesn't count against the destroy budget. @@ -551,15 +696,53 @@ static FORCEINLINE FIntVector VF_FloorDiv(const FIntVector& V, int32 D) return FIntVector(VF_FloorDiv(V.X, D), VF_FloorDiv(V.Y, D), VF_FloorDiv(V.Z, D)); } -void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center) +// RENDER DISTANCE — rayon (en tuiles niveau-MaxLevel) de la coquille EXTERNE : ClipRadius, élargi +// si `RenderDistanceChunks` demande une portée horizontale au-delà du naturel R·2^MaxLevel. Partagé +// par BuildDesiredTiles (le desired set) et IsTileInClipRange (le même horizon pour le cull). +static FORCEINLINE int32 VF_OuterShellRadius(const UVoxelSettings* Settings, int32 R, int32 MaxLevel) +{ + const int32 Dist = Settings ? Settings->RenderDistanceChunks : 0; + if (Dist <= 0) return R; + return FMath::Max(R, (Dist + (1 << MaxLevel) - 1) >> MaxLevel); // ceil(Dist / 2^MaxLevel) +} + +// F18 — la coquille LA PLUS EXTERNE : niveau + rayon. Sans anneau feuille = (MaxLevel, rayon +// render-distance). Avec (`bFarSheetRing` et distance > portée naturelle) = l'anneau FEUILLE : +// niveau MaxLevel + FarSheetSpanLevels (une feuille couvre 2^span empreintes MC par axe), rayon +// re-dérivé à ce niveau. Partagé par BuildDesiredTiles et IsTileInClipRange (même horizon). +static FORCEINLINE void VF_OuterShell(const UVoxelSettings* Settings, int32 R, int32 MaxLevel, + int32& OutLevel, int32& OutRadius) +{ + OutLevel = MaxLevel; + OutRadius = VF_OuterShellRadius(Settings, R, MaxLevel); + if (Settings && Settings->bFarSheetRing && OutRadius > R) + { + OutLevel = MaxLevel + FMath::Clamp(Settings->FarSheetSpanLevels, 1, 4); + OutRadius = FMath::Max(1, (Settings->RenderDistanceChunks + (1 << OutLevel) - 1) >> OutLevel); + } +} + +void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center, TArray& OutLeavers) { TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_BuildDesiredTiles); DesiredSorted.Reset(); - DesiredSet.Reset(); + OutLeavers.Reset(); + CollisionOnlyTiles.Reset(); // §9.4 — rebuilt by AddAnchorDesiredTiles below + ++DesiredStamp; // les upserts ci-dessous marquent le crossing courant const int32 R = Settings ? FMath::Max(1, Settings->ClipRadius) : 3; const int32 MaxLevel = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4; + // RENDER DISTANCE (`RenderDistanceChunks`) : la coquille EXTERNE continue au-delà du rayon + // naturel jusqu'à couvrir la distance demandée — en tuiles MC niveau-MaxClipLevel, ou (F18, + // `bFarSheetRing`) en tuiles FEUILLE plus grandes (niveau MaxLevel+span, deux heightfields + // sol/cap au lieu de marching cubes — cf. GenerateSheetMesh). IsTileInClipRange partage + // VF_OuterShell pour que le cull voie le même horizon. + const int32 ROuter = VF_OuterShellRadius(Settings, R, MaxLevel); + int32 SheetLevel = MaxLevel, RSheet = ROuter; + VF_OuterShell(Settings, R, MaxLevel, SheetLevel, RSheet); + const bool bSheetRing = SheetLevel > MaxLevel; + // Strate-aware VERTICAL band (in level-0 chunk-Z). Without this the clipmap generates the // occluded volume above/below (sealed strates are light-tight, §8.7) AND the full underground // depth — a huge column of invisible solid rock = the gen lag. So clamp the vertical reach to @@ -592,9 +775,24 @@ void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center) const FIntVector CL = VF_FloorDiv(Center, Pow); // player's level-L tile coord const FIntVector CF = (L > 0) ? VF_FloorDiv(Center, Pow >> 1) : FIntVector::ZeroValue; - for (int32 dz = -R; dz <= R; ++dz) - for (int32 dy = -R; dy <= R; ++dy) - for (int32 dx = -R; dx <= R; ++dx) + // Rayon de CE niveau : R partout, sauf la coquille externe (render distance) — qui, si + // l'anneau FEUILLE est actif (F18), est émise à part plus bas (le niveau MaxLevel reste + // alors à R). Le test "covered by finer" garde R (le niveau plus fin n'est jamais étendu). + const int32 RL = (L == MaxLevel && !bSheetRing) ? ROuter : R; + + // Anneau étendu : le balayage naïf serait (2·RL+1)³ — on restreint dz à la fenêtre de la + // clamp verticale AVANT la boucle (mêmes tuiles retenues : le `continue` Z ci-dessous + // rejetterait tout le reste). Sentinelles MIN/MAX (pas de clamp) ⇒ balayage plein. + int32 DzMin = -RL, DzMax = RL; + if (RL > R && ZLo != MIN_int32) + { + DzMin = FMath::Max(DzMin, VF_FloorDiv(ZLo, Pow) - CL.Z); + DzMax = FMath::Min(DzMax, VF_FloorDiv(ZHi, Pow) - CL.Z); + } + + for (int32 dz = DzMin; dz <= DzMax; ++dz) + for (int32 dy = -RL; dy <= RL; ++dy) + for (int32 dx = -RL; dx <= RL; ++dx) { const FIntVector T = CL + FIntVector(dx, dy, dz); @@ -616,7 +814,63 @@ void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center) } const FVoxelTileKey Key(T, L); DesiredSorted.Add(Key); - DesiredSet.Add(Key); + DesiredStamped.FindOrAdd(Key) = DesiredStamp; + } + } + + // F18 — ANNEAU FEUILLE : la portée render-distance est couverte par des tuiles feuille + // (niveau SheetLevel > MaxLevel, LoadTile route niveau > MaxClipLevel vers GenerateSheetMesh). + // Trou intérieur = la boîte MC niveau-MaxLevel (rayon R), pas le niveau SheetLevel−1. + if (bSheetRing) + { + const int32 SPow = 1 << SheetLevel; + const FIntVector CS = VF_FloorDiv(Center, SPow); + const FIntVector CM = VF_FloorDiv(Center, 1 << MaxLevel); + const int32 K = SheetLevel - MaxLevel; // 1 feuille = 2^K tuiles MC par axe + + int32 DzMin = -RSheet, DzMax = RSheet; + if (ZLo != MIN_int32) + { + DzMin = FMath::Max(DzMin, VF_FloorDiv(ZLo, SPow) - CS.Z); + DzMax = FMath::Min(DzMax, VF_FloorDiv(ZHi, SPow) - CS.Z); + } + + for (int32 dz = DzMin; dz <= DzMax; ++dz) + for (int32 dy = -RSheet; dy <= RSheet; ++dy) + for (int32 dx = -RSheet; dx <= RSheet; ++dx) + { + const FIntVector T = CS + FIntVector(dx, dy, dz); + const int32 TZLo = T.Z << SheetLevel; + const int32 TZHi = ((T.Z + 1) << SheetLevel) - 1; + if (TZHi < ZLo || TZLo > ZHi) continue; + + // Couverte par la boîte MC (empreinte entièrement dans [CM−R, CM+R] au niveau MaxLevel). + const bool bCovered = + ((T.X << K) >= CM.X - R) && ((((T.X + 1) << K) - 1) <= CM.X + R) && + ((T.Y << K) >= CM.Y - R) && ((((T.Y + 1) << K) - 1) <= CM.Y + R) && + ((T.Z << K) >= CM.Z - R) && ((((T.Z + 1) << K) - 1) <= CM.Z + R); + if (bCovered) continue; + + const FVoxelTileKey Key(T, SheetLevel); + DesiredSorted.Add(Key); + DesiredStamped.FindOrAdd(Key) = DesiredStamp; + } + } + + // Streaming anchors (AI / remote players, §9.3): fold each one's small level-0 box into the SAME + // desired set BEFORE the leaver sweep, so the delta cull releases an anchor's tiles automatically + // once it moves away or is unregistered. No-op (zero cost) when there are no anchors. + AddAnchorDesiredTiles(); + + // Balayage UNIQUE de la map : les entrées à stamp périmé viennent de quitter le desired set — + // ce sont les seuls candidats au cull de ce crossing (avec la TransitionHold). On les retire + // ici même (RemoveCurrent est sûr en itérant), la map reste donc == desired set courant. + for (auto It = DesiredStamped.CreateIterator(); It; ++It) + { + if (It.Value() != DesiredStamp) + { + OutLeavers.Add(It.Key()); + It.RemoveCurrent(); } } @@ -630,13 +884,126 @@ void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center) }); } +// Fold every registered anchor's small level-0 box into the current desired set (§9.3). Runs inside +// BuildDesiredTiles after the player clipmap + sheet ring, keyed on the same DesiredStamp so the leaver +// sweep + delta cull handle anchor tiles leaving. Level-0 only (collision lives on level-0 tiles); empty +// tiles in the box are ~free (the trivial-tile reject skips gen). Dedup vs the player clipmap by stamp. +// §9.4: a tile the player clipmap did NOT stamp (bNew) that only a CollisionOnly anchor wants goes into +// CollisionOnlyTiles → hidden at apply. A FullVisual anchor (or the clipmap) forces it rendered. +void AVoxelWorld::AddAnchorDesiredTiles() +{ + for (const FVoxelStreamingAnchor& Anchor : StreamingAnchors) + { + if (!Anchor.Actor.IsValid()) continue; // dead ptr — pruned in UpdateChunksAroundPosition + const FIntVector AC = Anchor.LastChunk; // set this Tick by the move-detection pass + const int32 RXY = FMath::Clamp(Anchor.XYRadiusChunks, 0, 4); // guard the box small + const int32 RZLo = FMath::Clamp(Anchor.ZBelowChunks, 0, 4); + const int32 RZHi = FMath::Clamp(Anchor.ZAboveChunks, 0, 4); + const bool bColl = (Anchor.Policy == EVoxelAnchorPolicy::CollisionOnly); + for (int32 dz = -RZLo; dz <= RZHi; ++dz) + for (int32 dy = -RXY; dy <= RXY; ++dy) + for (int32 dx = -RXY; dx <= RXY; ++dx) + { + const FVoxelTileKey Key(AC + FIntVector(dx, dy, dz), 0); + uint32& S = DesiredStamped.FindOrAdd(Key); + const bool bNew = (S != DesiredStamp); // false ⇒ already desired (clipmap / earlier anchor) + if (bNew) + { + S = DesiredStamp; + DesiredSorted.Add(Key); + } + if (bColl) + { + // Collision-only only if NOTHING full-visual claimed this exact level-0 tile this + // crossing (bNew). If the clipmap or a FullVisual anchor stamped it first, leave it rendered. + if (bNew) { CollisionOnlyTiles.Add(Key); } + } + else + { + CollisionOnlyTiles.Remove(Key); // FullVisual anchor → force rendered (undo a prior coll mark) + } + } + } +} + +// §9.4 — apply visibility flips to ALREADY-LOADED tiles when a tile changed render↔collision-only this +// crossing (player walked toward/away from a CollisionOnly cluster). Bounded by the collision-only set +// (small); a no-op when there are no CollisionOnly anchors. Newly-loaded tiles get their state at apply +// (ApplyMeshToTile reads CollisionOnlyTiles). Called after BuildDesiredTiles rebuilt CollisionOnlyTiles. +void AVoxelWorld::ReconcileAnchorTileVisibility() +{ + if (CollisionOnlyTiles.Num() == 0 && PrevCollisionOnlyTiles.Num() == 0) return; // fast path + + // Became collision-only → hide (if loaded). + for (const FVoxelTileKey& Key : CollisionOnlyTiles) + { + if (!PrevCollisionOnlyTiles.Contains(Key)) + { + if (URealtimeMeshComponent* Comp = TileComponents.FindRef(Key)) { Comp->SetVisibility(false); } + } + } + // Stopped being collision-only → show, but only if still desired (else it's a leaver being culled — + // don't flash it visible on its way out). + for (const FVoxelTileKey& Key : PrevCollisionOnlyTiles) + { + if (!CollisionOnlyTiles.Contains(Key) && IsDesired(Key)) + { + if (URealtimeMeshComponent* Comp = TileComponents.FindRef(Key)) { Comp->SetVisibility(true); } + } + } + PrevCollisionOnlyTiles = CollisionOnlyTiles; +} + +void AVoxelWorld::RegisterStreamingAnchor(AActor* Actor, EVoxelAnchorPolicy Policy, + int32 XYRadiusChunks, int32 ZBelowChunks, int32 ZAboveChunks) +{ + if (!Actor) return; + for (FVoxelStreamingAnchor& Existing : StreamingAnchors) + { + if (Existing.Actor.Get() == Actor) // already registered → update policy/box in place + { + Existing.Policy = Policy; + Existing.XYRadiusChunks = XYRadiusChunks; + Existing.ZBelowChunks = ZBelowChunks; + Existing.ZAboveChunks = ZAboveChunks; + bForceDesiredRebuild = true; + return; + } + } + FVoxelStreamingAnchor A; + A.Actor = Actor; + A.Policy = Policy; + A.XYRadiusChunks = XYRadiusChunks; + A.ZBelowChunks = ZBelowChunks; + A.ZAboveChunks = ZAboveChunks; + // LastChunk stays at its sentinel → next Tick's move detection sets it + triggers the rebuild. + StreamingAnchors.Add(A); + bForceDesiredRebuild = true; +} + +void AVoxelWorld::UnregisterStreamingAnchor(AActor* Actor) +{ + if (!Actor) return; + for (int32 i = StreamingAnchors.Num() - 1; i >= 0; --i) + { + if (StreamingAnchors[i].Actor.Get() == Actor) + { + StreamingAnchors.RemoveAtSwap(i); + bForceDesiredRebuild = true; // rebuild next Tick so its now-unwanted tiles become leavers + } + } +} + bool AVoxelWorld::IsTileInClipRange(const FVoxelTileKey& Tile, const FIntVector& Center) const { - // In range = the tile's centre falls within the OUTERMOST shell (level MaxLevel ± R). A - // loaded-but-not-desired tile in range is mid-LOD-transition (wait for its replacement); - // one out of range has left the view entirely (cull immediately). + // In range = the tile's centre falls within the OUTERMOST shell (VF_OuterShell — the + // render-distance/sheet-ring level+radius, same as BuildDesiredTiles). A loaded-but-not- + // desired tile in range is mid-LOD-transition (wait for its replacement); one out of range + // has left the view entirely (cull immediately). const int32 R = Settings ? FMath::Max(1, Settings->ClipRadius) : 3; - const int32 MaxLevel = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4; + const int32 MaxLevelBase = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4; + int32 MaxLevel = MaxLevelBase, ROuter = R; + VF_OuterShell(Settings, R, MaxLevelBase, MaxLevel, ROuter); const int32 PowMax = 1 << MaxLevel; const FIntVector CMax = VF_FloorDiv(Center, PowMax); @@ -647,88 +1014,262 @@ bool AVoxelWorld::IsTileInClipRange(const FVoxelTileKey& Tile, const FIntVector& VF_FloorDiv(FMath::FloorToInt(CV.Y), SizeMax), VF_FloorDiv(FMath::FloorToInt(CV.Z), SizeMax)); - return FMath::Abs(TMax.X - CMax.X) <= R - && FMath::Abs(TMax.Y - CMax.Y) <= R - && FMath::Abs(TMax.Z - CMax.Z) <= R; + return FMath::Abs(TMax.X - CMax.X) <= ROuter + && FMath::Abs(TMax.Y - CMax.Y) <= ROuter + && FMath::Abs(TMax.Z - CMax.Z) <= ROuter; +} + +int32 AVoxelWorld::GetMaxConcurrentTasks() const +{ + // T2.d — the asset value, capped to the spare LOGICAL cores. BackgroundNormal priority + // (see LoadTile) already stops gen from starving the frame; this cap stops a flat 16 from + // thrashing context switches on small CPUs where 16 > the machine's spare parallelism. + const int32 Asset = Settings ? Settings->MaxConcurrentTasks : 16; + const int32 SpareCores = FMath::Max(2, FPlatformMisc::NumberOfCoresIncludingHyperthreads() - 2); + return FMath::Clamp(Asset, 1, SpareCores); } void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition) { TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateChunks); - const int32 MaxTasks = Settings ? Settings->MaxConcurrentTasks : 16; + const int32 MaxTasks = GetMaxConcurrentTasks(); const FIntVector CenterChunk = WorldToChunkCoord(CenterPosition); // player's level-0 tile CurrentCenterChunk = CenterChunk; + // Streaming anchors (AI / remote players, §9.3): prune dead ones + detect chunk crossings so the + // desired set rebuilds when an anchor moves (its box of collision tiles follows it). Cheap: a few + // WorldToChunkCoord per anchor, and empty (no anchors) is a zero-iteration loop. + bool bAnchorsMoved = false; + for (int32 i = StreamingAnchors.Num() - 1; i >= 0; --i) + { + AActor* A = StreamingAnchors[i].Actor.Get(); + if (!A) + { + StreamingAnchors.RemoveAtSwap(i); // destroyed → drop it; its tiles must be culled + bAnchorsMoved = true; + continue; + } + const FIntVector AC = WorldToChunkCoord(A->GetActorLocation()); + if (AC != StreamingAnchors[i].LastChunk) + { + StreamingAnchors[i].LastChunk = AC; + bAnchorsMoved = true; + } + } + //========================================================================= - // Rebuild the desired tile set only when the player crosses a level-0 tile boundary. + // Rebuild the desired tile set when the player crosses a level-0 tile boundary — or when a + // streaming anchor moved / (un)registered (bAnchorsMoved / bForceDesiredRebuild). //========================================================================= - if (CenterChunk != LastUpdateCenter) + if (CenterChunk != LastUpdateCenter || bAnchorsMoved || bForceDesiredRebuild) { LastUpdateCenter = CenterChunk; bAllChunksLoaded = false; + bForceDesiredRebuild = false; // consumed - BuildDesiredTiles(CenterChunk); + // DELTA CULL: BuildDesiredTiles renvoie les LEAVERS (désirées au crossing précédent, plus + // maintenant). Seuls candidats au cull : ces leavers + la TransitionHold (retenues des + // crossings passés). Fini le re-scan de TOUTES les tuiles chargées à chaque crossing — + // c'était le spike CullTiles ~1.6 ms/crossing (trace 2026-07-05). Les tuiles en vol qui + // finissent hors desired sont capturées à l'apply (ProcessPendingChunks → TransitionHold), + // et le settled cull (tout chargé) reste le filet de sécurité plein-scan. + TArray Leavers; + BuildDesiredTiles(CenterChunk, Leavers); - // Cull loaded tiles that are no longer desired — STRICT LOAD-BEFORE-UNLOAD so crossing a - // shell boundary NEVER leaves a hole and we never drop a tile before its replacement exists: + // §9.4 — toggle visibility on already-loaded tiles that flipped render↔collision-only this + // crossing (a CollisionOnly cluster the player just walked toward/away from). No-op w/o anchors. + ReconcileAnchorTileVisibility(); + + // STRATE CONTENT CUT — the band coarse tiles are meshed to = the player strate's EXACT + // chunk-Z bounds (no margin: that's the view clamp's job — selection vs content). In the + // inter-strate gap: no band (full tiles, you can see both sides through the descent). + // On band change (strate transition), re-queue the loaded coarse tiles whose mesh depends + // on it — fully inside BOTH bands ⇒ identical either way; fully outside both ⇒ empty + // either way; everything else re-gens in place via BandRemeshQueue (no visual pop). + { + const int32 CutMin = Settings ? Settings->StrateContentCutMinLevel : 9; + // F18 — l'anneau feuille dépend aussi de la bande (sa strate de référence) : on l'arme + // dès que les feuilles sont actives, même si la coupe de contenu MC est désactivée. + const bool bSheetsWantBand = Settings && Settings->bFarSheetRing + && Settings->RenderDistanceChunks > 0; + const int32 TopMC = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4; + int32 NewLo = MIN_int32, NewHi = MAX_int32; + if ((CutMin <= 8 || bSheetsWantBand) && StrateManager) + { + int32 StrTopZ = 0, StrBotZ = 0; + if (StrateManager->GetStrateChunkZBounds(CenterChunk.Z, StrTopZ, StrBotZ)) + { + NewLo = StrBotZ; + NewHi = StrTopZ; + } + } + if (NewLo != MeshBandChunkLo || NewHi != MeshBandChunkHi) + { + // Diagnostic volontairement VISIBLE : si cette ligne n'apparaît JAMAIS dans + // l'Output Log, la coupe de contenu ne s'est jamais armée (bounds de strate + // introuvables pour la position du PION → tout se maille plein, comme avant). + UE_LOG(LogTemp, Warning, + TEXT("[VoxelWorld] Strate content band -> chunks [%d..%d] (was [%d..%d]), CutMinLevel=%d, pawn chunk Z=%d"), + NewLo, NewHi, MeshBandChunkLo, MeshBandChunkHi, CutMin, CenterChunk.Z); + for (const FVoxelTileKey& T : LoadedTiles) + { + // Feuilles (niveau > MaxClipLevel) : toujours dépendantes de la bande. + if (T.Level < CutMin && T.Level <= TopMC) continue; + const int32 CLo = T.Coord.Z << T.Level; + const int32 CHi = ((T.Coord.Z + 1) << T.Level) - 1; + const bool bInOld = CLo >= MeshBandChunkLo && CHi <= MeshBandChunkHi; + const bool bInNew = CLo >= NewLo && CHi <= NewHi; + const bool bOutOld = CHi < MeshBandChunkLo || CLo > MeshBandChunkHi; + const bool bOutNew = CHi < NewLo || CLo > NewHi; + if ((bInOld && bInNew) || (bOutOld && bOutNew)) continue; // même contenu + if (!IsDesired(T)) continue; // sera cull, pas re-gen + BandRemeshQueue.Add(T); + } + MeshBandChunkLo = NewLo; + MeshBandChunkHi = NewHi; + bAllChunksLoaded = false; // le drain de BandRemeshQueue vit dans le bloc submit + } + } + + // F18 — TROU XY de l'anneau feuille (voir VoxelWorld.h) : boîte MC niveau-MaxClipLevel + // autour du joueur, rétrécie d'UNE tuile — le raccord feuille↔anneau MC garde une tuile + // MC pleine de recouvrement (même pas d'échantillonnage des deux côtés → discret), et en + // avançant, les tuiles MC de la zone nouvellement découpée étaient déjà desired au + // crossing précédent (chargées avant que le trou ne les découvre). Changement (crossing + // de tuile MaxClipLevel, ~tous les 2^L chunks) ⇒ re-queue des feuilles chevauchant + // l'ancien OU le nouveau trou. + { + int32 NewMinX = MAX_int32, NewMinY = MAX_int32; + int32 NewMaxX = MIN_int32, NewMaxY = MIN_int32; + const int32 TopMCLvl = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4; + if (Settings && Settings->bFarSheetRing && Settings->RenderDistanceChunks > 0) + { + const int32 RClip = FMath::Max(1, Settings->ClipRadius); + const int32 Shrink = FMath::Max(0, RClip - 1); + const int32 ExtM = CHUNK_SIZE << TopMCLvl; // tuile MaxLevel en voxels + const FIntVector CM = VF_FloorDiv(CenterChunk, 1 << TopMCLvl); // tuile MaxLevel du joueur + NewMinX = (CM.X - Shrink) * ExtM; + NewMinY = (CM.Y - Shrink) * ExtM; + NewMaxX = (CM.X + Shrink + 1) * ExtM; // EXCLUSIF + NewMaxY = (CM.Y + Shrink + 1) * ExtM; + } + if (NewMinX != SheetHoleMinXVox || NewMinY != SheetHoleMinYVox + || NewMaxX != SheetHoleMaxXVox || NewMaxY != SheetHoleMaxYVox) + { + for (const FVoxelTileKey& T : LoadedTiles) + { + if (T.Level <= TopMCLvl) continue; // seules les feuilles portent le trou + const int32 Ext = CHUNK_SIZE << T.Level; + const int32 TMinX = T.Coord.X * Ext, TMinY = T.Coord.Y * Ext; + const bool bOldOv = TMinX < SheetHoleMaxXVox && TMinX + Ext > SheetHoleMinXVox + && TMinY < SheetHoleMaxYVox && TMinY + Ext > SheetHoleMinYVox; + const bool bNewOv = TMinX < NewMaxX && TMinX + Ext > NewMinX + && TMinY < NewMaxY && TMinY + Ext > NewMinY; + if ((bOldOv || bNewOv) && IsDesired(T)) BandRemeshQueue.Add(T); + } + SheetHoleMinXVox = NewMinX; SheetHoleMinYVox = NewMinY; + SheetHoleMaxXVox = NewMaxX; SheetHoleMaxYVox = NewMaxY; + bAllChunksLoaded = false; + } + } + + // Cull rules — STRICT LOAD-BEFORE-UNLOAD so crossing a shell boundary NEVER leaves a hole: // - out of clip range → left the view entirely, no replacement coming → cull now. // - in range (mid-LOD-transition) → cull ONLY once EVERY desired tile that overlaps its - // footprint is loaded. A coarse tile is replaced by several finer tiles; the old - // center-owner check culled it as soon as the ONE tile over its centre was ready, so the - // not-yet-ready edges flashed a hole. Checking the whole covering set fixes that: the old - // tile stays at its current resolution until the better mesh is fully in, then drops. - auto FootprintsOverlap = [](const FVoxelTileKey& A, const FVoxelTileKey& B) -> bool - { - const int32 ea = A.ExtentVoxels(), eb = B.ExtentVoxels(); - const FIntVector aMin = A.OriginVoxels(), bMin = B.OriginVoxels(); - return aMin.X < bMin.X + eb && bMin.X < aMin.X + ea - && aMin.Y < bMin.Y + eb && bMin.Y < aMin.Y + ea - && aMin.Z < bMin.Z + eb && bMin.Z < aMin.Z + ea; - }; - // Only a desired tile that ISN'T loaded yet can block a cull (an old tile must stay until its - // replacement is in). That set is small — just the few newly-needed tiles this crossing — so - // build it ONCE and test each candidate against it (was: scan ALL of DesiredSorted per tile, - // an O(loaded×desired) game-thread spike when fast movement turns many tiles non-desired). - // "Every covering desired tile loaded" ⟺ "no unloaded desired tile overlaps T" — equivalent. - TArray DesiredPending; - for (const FVoxelTileKey& D : DesiredSorted) - { - if (!LoadedTiles.Contains(D)) DesiredPending.Add(D); - } - auto ReplacementsReady = [&](const FVoxelTileKey& T) -> bool - { - for (const FVoxelTileKey& D : DesiredPending) - { - if (FootprintsOverlap(T, D)) return false; // a covering tile isn't ready → keep T - } - return true; - }; - - // ReplacementsReady is O(DesiredPending); calling it for every loaded tile is O(loaded × pending), - // which goes QUADRATIC exactly when streaming falls behind (sprinting → DesiredPending balloons) — - // the measured 53 ms/cross CullTiles spike and a death spiral (behind → stall → further behind). - // KEEPING a transition tile longer is always hole-safe (the conservative direction), so when pending - // is large we skip the overlap test and just keep in-range transition tiles; the settled cull (once - // bAllChunksLoaded) + ProcessUnloadQueue reclaim them when streaming catches up. Out-of-range tiles - // still cull unconditionally (bounds memory). This caps the cull at O(loaded) and breaks the spiral. - const bool bDoOverlapCull = DesiredPending.Num() <= 48; - - TArray ToRemove; - auto Consider = [&](const FVoxelTileKey& T) - { - if (DesiredSet.Contains(T)) return; - // T comes from TileComponents keys then LoadedTiles-not-in-TileComponents → never twice. - if (!IsTileInClipRange(T, CenterChunk)) { ToRemove.Add(T); return; } // left the view → cull now - if (bDoOverlapCull && ReplacementsReady(T)) ToRemove.Add(T); // transition → cull when safe - }; + // footprint is loaded (a coarse tile is replaced by several finer tiles — the whole + // covering set must be in before it drops). Otherwise it goes to TransitionHold. { TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_CullTiles); - for (const auto& Pair : TileComponents) Consider(Pair.Key); - for (const FVoxelTileKey& T : LoadedTiles) { if (!TileComponents.Contains(T)) Consider(T); } - // Defer the actual teardown — ProcessUnloadQueue spreads it across frames so a fast - // traversal's whole-shell cull doesn't destroy dozens of components + actors in one frame. - for (const FVoxelTileKey& T : ToRemove) PendingUnload.Add(T); + + auto FootprintsOverlap = [](const FVoxelTileKey& A, const FVoxelTileKey& B) -> bool + { + const int32 ea = A.ExtentVoxels(), eb = B.ExtentVoxels(); + const FIntVector aMin = A.OriginVoxels(), bMin = B.OriginVoxels(); + return aMin.X < bMin.X + eb && bMin.X < aMin.X + ea + && aMin.Y < bMin.Y + eb && bMin.Y < aMin.Y + ea + && aMin.Z < bMin.Z + eb && bMin.Z < aMin.Z + ea; + }; + // "Every covering desired tile loaded" ⟺ "no unloaded desired tile overlaps T". + // Built LAZILY: only needed if an in-range transition candidate actually exists. + TArray DesiredPending; + bool bPendingBuilt = false; + auto EnsurePending = [&]() + { + if (bPendingBuilt) return; + bPendingBuilt = true; + for (const FVoxelTileKey& D : DesiredSorted) + { + if (!LoadedTiles.Contains(D)) DesiredPending.Add(D); + } + }; + auto ReplacementsReady = [&](const FVoxelTileKey& T) -> bool + { + for (const FVoxelTileKey& D : DesiredPending) + { + if (FootprintsOverlap(T, D)) return false; // a covering tile isn't ready → keep T + } + return true; + }; + + // true = la tuile doit être RETENUE (transition en attente de ses remplaçants) ; + // false = rien à retenir (cullée, re-désirée, ou jamais chargée). + auto NeedsHold = [&](const FVoxelTileKey& T) -> bool + { + if (IsDesired(T)) { return false; } // re-désirée + if (!LoadedTiles.Contains(T) && !TileComponents.Contains(T)) + { + return false; // jamais chargée / rien d'appliqué → rien à cull + } + if (!IsTileInClipRange(T, CenterChunk)) // left the view → cull now + { + PendingUnload.Add(T); + return false; + } + // In-range transition. Quand le backlog est gros (sprint), sauter le test de + // recouvrement et RETENIR est la direction hole-safe ; le settled cull ramassera. + EnsurePending(); + if (DesiredPending.Num() <= 48 && ReplacementsReady(T)) + { + PendingUnload.Add(T); + return false; + } + return true; + }; + + for (const FVoxelTileKey& T : Leavers) + { + if (NeedsHold(T)) { AddToTransitionHold(T); } + } + + // Hold : re-évaluation à BUDGET tournant. Re-scanner toute la hold par crossing + // redevient le vieux scan O(loaded) dès que le streaming ne settle jamais (mesuré + // 2.47 ms/crossing packagé) ; retenir plus longtemps est hole-safe, chaque tuile + // repasse sous le curseur en quelques crossings. + int32 HoldBudget = FMath::Min(TransitionHoldQueue.Num(), 256); + while (HoldBudget > 0 && TransitionHoldQueue.Num() > 0) + { + if (TransitionHoldCursor >= TransitionHoldQueue.Num()) { TransitionHoldCursor = 0; } + const FVoxelTileKey T = TransitionHoldQueue[TransitionHoldCursor]; + if (!TransitionHold.Contains(T)) + { + // Clé périmée (déchargée / settled-cullée) — retrait paresseux, ne consomme + // pas le budget (la queue rétrécit ⇒ la boucle termine). + TransitionHoldQueue.RemoveAtSwap(TransitionHoldCursor); + continue; + } + --HoldBudget; + if (!NeedsHold(T)) + { + TransitionHold.Remove(T); + TransitionHoldQueue.RemoveAtSwap(TransitionHoldCursor); + } + else + { + ++TransitionHoldCursor; + } + } + // Teardown différé — ProcessUnloadQueue étale les destructions sur plusieurs frames. } } @@ -740,6 +1281,21 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition) { TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_SubmitTiles); int32 Submitted = 0; + + // DIG RESPONSIVENESS — drain player-carve re-meshes FIRST (ahead of streaming + band) at + // BackgroundHigh, so a dig gets the task budget before any streaming gen. A tile that's still + // in flight is KEPT queued (retried next frame) so its stale pre-carve result is corrected. + for (auto It = DirtyRemeshQueue.CreateIterator(); It; ++It) + { + if (PendingTiles.Num() >= MaxTasks) break; + const FVoxelTileKey T = *It; + if (!LoadedTiles.Contains(T)) { It.RemoveCurrent(); continue; } // unloaded — drop + if (PendingTiles.Contains(T)) { continue; } // in flight — retry after it lands + It.RemoveCurrent(); + LoadTile(T, /*bHighPriority*/ true); + ++Submitted; + } + for (const FVoxelTileKey& T : DesiredSorted) { if (PendingTiles.Num() >= MaxTasks) break; @@ -749,14 +1305,27 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition) ++Submitted; } - if (Submitted == 0 && PendingTiles.Num() == 0) + // Bande de strate changée : re-gen budgétée des tuiles grossières concernées (le vieux + // mesh reste visible jusqu'au résultat — même schéma que RemeshDirtyChunks). + for (auto It = BandRemeshQueue.CreateIterator(); It; ++It) + { + if (PendingTiles.Num() >= MaxTasks) break; + const FVoxelTileKey T = *It; + It.RemoveCurrent(); + if (PendingTiles.Contains(T) || !LoadedTiles.Contains(T)) continue; + LoadTile(T); + ++Submitted; + } + + if (Submitted == 0 && PendingTiles.Num() == 0 && BandRemeshQueue.Num() == 0 && DirtyRemeshQueue.Num() == 0) { // Everything desired is loaded → load-before-unload is satisfied: drop the - // deferred (in-range, not-desired) transition tiles now. No holes. + // deferred (in-range, not-desired) transition tiles now. No holes. Full scan — + // rare (once per settle), and the safety net behind the delta cull above. for (const auto& Pair : TileComponents) - if (!DesiredSet.Contains(Pair.Key)) PendingUnload.Add(Pair.Key); + if (!IsDesired(Pair.Key)) PendingUnload.Add(Pair.Key); for (const FVoxelTileKey& T : LoadedTiles) - if (!DesiredSet.Contains(T) && !TileComponents.Contains(T)) PendingUnload.Add(T); + if (!IsDesired(T) && !TileComponents.Contains(T)) PendingUnload.Add(T); // Teardown is drained by ProcessUnloadQueue (budgeted) — single spike-free path. bAllChunksLoaded = true; @@ -764,11 +1333,11 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition) } } -void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile) +void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority) { if (PendingTiles.Contains(Tile)) return; - const int32 MaxTasks = Settings ? Settings->MaxConcurrentTasks : 16; + const int32 MaxTasks = GetMaxConcurrentTasks(); // T2.d — core-clamped if (PendingTiles.Num() >= MaxTasks) { return; // Budget full — wait for a task to finish. @@ -782,10 +1351,23 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile) // Extent stays CHUNK_SIZE<FullResClipLevels) : 2; - const int32 Cells = (Tile.Level < FullRes) + const int32 Extent = CHUNK_SIZE << Tile.Level; + + // F18 — tuile FEUILLE : BuildDesiredTiles n'émet des clés au-delà de MaxClipLevel que pour + // l'anneau feuille (render distance) — elles se maillent en deux heightfields (GenerateSheetMesh), + // pas en marching cubes. Densité d'échantillonnage = celle de l'anneau MC niveau-MaxClipLevel + // (le nombre de cellules grandit avec la feuille, plafonné à 128/axe — au-delà le pas grossit). + const int32 TopMC = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4; + const bool bSheetTile = Tile.Level > TopMC; + + int32 Cells = (Tile.Level < FullRes) ? CHUNK_SIZE : (Settings ? FMath::Clamp(Settings->CoarseTileCells, 4, CHUNK_SIZE) : 16); - const int32 Extent = CHUNK_SIZE << Tile.Level; + if (bSheetTile) + { + const int32 StepMC = FMath::Max(1, (CHUNK_SIZE << TopMC) / Cells); + Cells = FMath::Clamp(Extent / StepMC, 4, 128); + } const int32 Step = FMath::Max(1, Extent / Cells); const uint32 TaskEpoch = GenerationEpoch; @@ -798,13 +1380,73 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile) && DensityVolume != nullptr && Settings && Settings->bEnableDensityVolume && DensityVolume->IsTileCaptureUseful(Tile.Coord); + // STRATE CONTENT CUT — coarse tiles mesh only the player-strate band (see the band update in + // UpdateChunksAroundPosition + UVoxelSettings::StrateContentCutMinLevel). Chunk band → voxels + // (inclusive). Fine tiles / no band (gap, feature off) mesh full. + int32 BandVoxLo = INT32_MIN, BandVoxHi = INT32_MAX; + int32 BandChunkLo = MIN_int32, BandChunkHi = MAX_int32; + int32 SheetChunkZ = 0; + if (bSheetTile) + { + // F18 — la feuille a besoin de la STRATE de référence (les deux heightfields sont ceux de + // la strate du joueur) : pas de bande armée (gap inter-strates, ou bounds introuvables) + // ⇒ rien à mailler, tuile vide (re-queue automatique via BandRemeshQueue en atterrissant). + if (MeshBandChunkLo == MIN_int32) + { + FChunkResult Empty; + Empty.Tile = Tile; + Empty.Epoch = TaskEpoch; + ProcessQueue.Enqueue(MoveTemp(Empty)); + return; + } + BandChunkLo = MeshBandChunkLo; // pour la résolution matériaux sol/cap dans ApplyMeshToTile + BandChunkHi = MeshBandChunkHi; + SheetChunkZ = MeshBandChunkLo + (MeshBandChunkHi - MeshBandChunkLo) / 2; // chunk au cœur de la strate + } + // F18 — trou XY courant (zone couverte par les coquilles MC, découpée des feuilles). + const int32 HoleMinX = SheetHoleMinXVox, HoleMinY = SheetHoleMinYVox; + const int32 HoleMaxX = SheetHoleMaxXVox, HoleMaxY = SheetHoleMaxYVox; + const int32 CutMin = Settings ? Settings->StrateContentCutMinLevel : 9; + if (!bSheetTile && Tile.Level >= CutMin && MeshBandChunkLo != MIN_int32) + { + BandChunkLo = MeshBandChunkLo; + BandChunkHi = MeshBandChunkHi; + BandVoxLo = MeshBandChunkLo * CHUNK_SIZE; + BandVoxHi = (MeshBandChunkHi + 1) * CHUNK_SIZE - 1; + + // Résidu ultra-grossier (niveaux ≥7) : la coupe est à la granularité de la CELLULE. Si + // UNE cellule (Step voxels de haut) est plus haute que la bande entière, toute cellule + // qui chevauche la bande échantillonne quand même les airs des DEUX strates (mêmes trous + // et mélanges de matériaux qu'avant la coupe) — la tuile ne peut rendre que des artefacts + // ⇒ on n'émet RIEN. Résultat vide via ProcessQueue (bookkeeping normal : PendingTiles, + // LoadedTiles, epoch) ; jamais figé — le changement de bande re-queue via BandRemeshQueue. + if (Step > (BandChunkHi - BandChunkLo + 1) * CHUNK_SIZE) + { + FChunkResult Empty; + Empty.Tile = Tile; + Empty.Epoch = TaskEpoch; + Empty.BandChunkLo = BandChunkLo; + Empty.BandChunkHi = BandChunkHi; + ProcessQueue.Enqueue(MoveTemp(Empty)); + return; + } + } + ActiveTaskCount.fetch_add(1, std::memory_order_relaxed); // BackgroundNormal priority: gen runs on background workers that YIELD to foreground // (game/render-thread) tasks. Without this, raising MaxConcurrentTasks past the spare // core count saturates the scheduler and starves the frame (the "over 12 = lag" symptom). // At background priority the frame keeps its cores; gen just fills in around it. - UE::Tasks::Launch(TEXT("ChunkGen"), [this, Tile, OriginVoxels, Step, Cells, TaskEpoch, bWantCapture]() + // DIG RESPONSIVENESS: player carves launch at BackgroundHigh (bHighPriority) — still a background + // worker (yields to the frame, keeps the invariant) but jumps AHEAD of all pending streaming gen, + // so a dig is never queued behind a shell of streaming tasks. + const UE::Tasks::ETaskPriority TaskPriority = bHighPriority + ? UE::Tasks::ETaskPriority::BackgroundHigh + : UE::Tasks::ETaskPriority::BackgroundNormal; + UE::Tasks::Launch(TEXT("ChunkGen"), [this, Tile, OriginVoxels, Step, Cells, TaskEpoch, bWantCapture, + BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi, + bSheetTile, SheetChunkZ, HoleMinX, HoleMinY, HoleMaxX, HoleMaxY]() { // RAII: decrement the counter on every exit path. struct FTaskGuard @@ -816,48 +1458,137 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile) if (bShuttingDown.load(std::memory_order_relaxed)) return; FChunkResult Result; - Result.Tile = Tile; - Result.Epoch = TaskEpoch; - - FVoxelMeshData MeshData; - { - TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_GenerateMesh); - MeshData = Mesher->GenerateMesh(OriginVoxels, Step, Cells, - bWantCapture ? &Result.CaptureGrid : nullptr); - } - - // T1.f — build the RMC geometry buffers HERE (worker), not on the game thread. Empty/all-air - // tiles carry no streams (Result.bEmpty stays true) → no component on apply. - if (!MeshData.IsEmpty()) - { - TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_BuildStreams); - Result.Streams = MakeShared(); - BuildTileStreamSet(*Result.Streams, MeshData); - Result.bEmpty = false; - - // Classify ceiling from the ACTUAL mesh normals (smoothed density gradient, solid→air): - // a ceiling surface faces DOWN (N.Z < 0), ground faces UP. This is the rendered geometry, - // so it can't disagree with the view — unlike the old game-thread height-oracle sample, - // which misclassified coarse far tiles (terrain material on the sky-cap underside). Vote - // down-vs-up over the verts; near-vertical wall normals (|N.Z| small) abstain. The game - // thread gates this to SurfaceWorld strates before it actually swaps material / shadow. - // STOPGAP (fable-idea F17): orientation only works while NO CAVES EXIST — down == sky-cap. - // A future cave roof is also down-facing; distinguishing it needs a generator-stamped surface - // class (CeilSurf vs carve-below-TerrainZ) carried as a polygroup → material slot. See F17. - int32 DownVerts = 0, UpVerts = 0; - for (const FVector& N : MeshData.Normals) - { - if (N.Z < -0.1f) { ++DownVerts; } - else if (N.Z > 0.1f) { ++UpVerts; } - } - Result.bIsCeiling = (DownVerts > UpVerts); - } + GenerateTileResult(Tile, OriginVoxels, Step, Cells, TaskEpoch, bWantCapture, + BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi, + bSheetTile, SheetChunkZ, HoleMinX, HoleMinY, HoleMaxX, HoleMaxY, Result); if (!bShuttingDown.load(std::memory_order_relaxed)) { ProcessQueue.Enqueue(MoveTemp(Result)); // move: don't copy the geometry payload } - }, UE::Tasks::ETaskPriority::BackgroundNormal); + }, TaskPriority); +} + +// Worker-side gen for one tile (shared by the async ChunkGen task and the synchronous carve path). +// READS Generator/Mesher only — safe on a worker or the game thread. Fills Result; no enqueue. +void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector& OriginVoxels, + int32 Step, int32 Cells, uint32 Epoch, bool bWantCapture, + int32 BandVoxLo, int32 BandVoxHi, int32 BandChunkLo, int32 BandChunkHi, + bool bSheetTile, int32 SheetChunkZ, + int32 HoleMinX, int32 HoleMinY, int32 HoleMaxX, int32 HoleMaxY, + FChunkResult& Result) +{ + Result.Tile = Tile; + Result.Epoch = Epoch; + Result.BandChunkLo = BandChunkLo; // strate content cut (MIN/MAX = uncut) + Result.BandChunkHi = BandChunkHi; + + // T1.d — TRIVIAL-TILE REJECT: ~84 % des tuiles générées sortaient vides (tout-roc / + // tout-air) en payant quand même le pré-échantillonnage complet. Le classifieur prouve + // (bornes exactes sur le treillis du mesher + gardes conservatives) qu'une tuile est + // uniforme → on saute GenerateMesh, Result reste bEmpty. Mixed = génération normale. + // Les tuiles à capture (density volume) génèrent toujours : le volume veut la grille + // même pour les cellules uniformes, et ces tuiles sont rares (fenêtre d'ombre). + // (Gate IsoLevel == 0 : les verdicts du classifieur supposent l'iso MC à zéro exactement.) + bool bTrivialEmpty = false; + if (!bSheetTile && !bWantCapture && Generator && Mesher && Mesher->IsoLevel == 0.0f) + { + TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ClassifyTile); + bTrivialEmpty = (Generator->ClassifyTile(OriginVoxels, Step, Cells) != EVoxelTileClass::Mixed); + } + + // F18 — feuille : deux heightfields sol/cap échantillonnés par colonne (pas de marching + // cubes, pas de classifieur — la classe de surface est vraie par construction). + FVoxelMeshData MeshData; + if (!bTrivialEmpty) + { + TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_GenerateMesh); + MeshData = bSheetTile + ? Mesher->GenerateSheetMesh(OriginVoxels, Step, Cells, SheetChunkZ, + HoleMinX, HoleMinY, HoleMaxX, HoleMaxY) + : Mesher->GenerateMesh(OriginVoxels, Step, Cells, + bWantCapture ? &Result.CaptureGrid : nullptr, + BandVoxLo, BandVoxHi); + } + + // T1.f — build the RMC geometry buffers HERE (worker), not on the game thread. Empty/all-air + // tiles carry no streams (Result.bEmpty stays true) → no component on apply. + if (!MeshData.IsEmpty()) + { + TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_BuildStreams); + Result.Streams = MakeShared(); + BuildTileStreamSet(*Result.Streams, MeshData); + Result.bEmpty = false; + + // F17 — the mesher classified every triangle semantically (sky-cap vs ground, per + // vertex against the column's TerrainZ/CeilSurf) and packed them as two contiguous + // polygroup runs. Here we only record which sections exist for the apply path. + // (Replaces the whole-tile normal VOTE, which painted mixed coarse tiles — terrain + // AND cap in one tile — entirely with the winner's material.) + const int32 NumTris = MeshData.Triangles.Num() / 3; + Result.bHasCeilingTris = MeshData.NumCeilingTriangles > 0; + Result.bHasGroundTris = NumTris > MeshData.NumCeilingTriangles; + } +} + +// Section-group key shared by every tile component ("the" tile geometry group). +static FRealtimeMeshSectionGroupKey VoxelTileGroupKey() +{ + return FRealtimeMeshSectionGroupKey::Create(FRealtimeMeshLODKey(0), FName("Tile")); +} + +//============================================================================= +// TILE COMPONENT POOL (T2.c) +//============================================================================= +// Recycler les composants de tuile au lieu de les détruire/recréer. + +URealtimeMeshComponent* AVoxelWorld::AcquireTileComponent() +{ + // Reuse a parked component when one is available — skips NewObject + RegisterComponent + // (and the full proxy teardown/GC of a destroy) during fast travel & regen bursts. + while (TileComponentPool.Num() > 0) + { + URealtimeMeshComponent* Pooled = TileComponentPool.Pop(); + if (IsValid(Pooled)) + { + Pooled->SetVisibility(true); + return Pooled; + } + } + + URealtimeMeshComponent* MeshComp = NewObject(this); + // Generated once, never moves → Static so RMC's cached static draw path + VSM shadow + // caching apply (see the root SetMobility note in BeginPlay). Must be set before register. + // Re-mesh on carve recreates the section-group proxy (RMC's Static path already does this), + // which is fine for an infrequent action. + MeshComp->SetMobility(EComponentMobility::Static); + MeshComp->SetGenerateOverlapEvents(false); // chunks use raycasts, not overlaps + MeshComp->SetCanEverAffectNavigation(false); + MeshComp->RegisterComponent(); + MeshComp->AttachToComponent(GetRootComponent(), FAttachmentTransformRules::KeepRelativeTransform); + return MeshComp; +} + +void AVoxelWorld::ReleaseTileComponent(URealtimeMeshComponent* Comp) +{ + if (!IsValid(Comp)) { return; } + + if (TileComponentPool.Num() >= MaxPooledTileComponents) + { + Comp->DestroyComponent(); + return; + } + + // Strip the tile's geometry NOW, not at reuse: removing the section group drops its + // sections and their cooked collision, so a parked (hidden) component can't be collided + // with and its render memory is released while it waits. The mesh OBJECT is kept — reuse + // goes through the same RemoveSectionGroup/CreateSectionGroup path as a re-mesh. + if (URealtimeMeshSimple* RTMesh = Comp->GetRealtimeMeshAs()) + { + RTMesh->RemoveSectionGroup(VoxelTileGroupKey()); + } + Comp->SetVisibility(false); + TileComponentPool.Add(Comp); } void AVoxelWorld::UnloadTile(const FVoxelTileKey& Tile) @@ -866,66 +1597,73 @@ void AVoxelWorld::UnloadTile(const FVoxelTileKey& Tile) // UpdateWater; decorations stream by distance via UpdateDecorations) — nothing to clear per tile. if (URealtimeMeshComponent** Comp = TileComponents.Find(Tile)) { - if (*Comp) { (*Comp)->DestroyComponent(); } + if (*Comp) { ReleaseTileComponent(*Comp); } // T2.c — park, don't destroy TileComponents.Remove(Tile); } LoadedTiles.Remove(Tile); PendingTiles.Remove(Tile); + TransitionHold.Remove(Tile); // couvre aussi le settled cull (qui ne tient pas la hold à jour) } -void AVoxelWorld::ApplyMeshToTile(const FVoxelTileKey& Tile, RealtimeMesh::FRealtimeMeshStreamSet&& Streams, bool bGeomCeiling) +void AVoxelWorld::ApplyMeshToTile(FChunkResult& Result) { TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ApplyMeshToChunk); // Streams are pre-built on the worker (T1.f) and guaranteed non-empty by the caller - // (ProcessPendingChunks skips empty tiles). This path is now game-thread-CHEAP: O(1) classify + - // material + component get/create + the upload. No per-vertex work here anymore. + // (ProcessPendingChunks skips empty tiles). This path is game-thread-CHEAP: material lookup + + // component get/create + the upload + per-section config. No per-vertex work here. + const FVoxelTileKey& Tile = Result.Tile; + RealtimeMesh::FRealtimeMeshStreamSet& Streams = *Result.Streams; + const bool bHasGroundTris = Result.bHasGroundTris; + const bool bHasCeilingTris = Result.bHasCeilingTris; const bool bLevel0 = (Tile.Level == 0); - // SKY-CAP CEILING classification (used for BOTH shadow + material). WHAT it is — down-facing geometry - // — is decided on the worker by voting the tile's ACTUAL mesh normals (bGeomCeiling); that's the - // rendered surface, so it can't disagree with the view the way the old centre height-oracle sample did - // (it misclassified coarse far tiles → terrain material on the sky-cap underside). WHETHER a tile may - // be a sky-cap ceiling at all is still gated to SurfaceWorld strates: one O(1) oracle probe at the tile - // centre (the oracle returns false for non-surface strates), so cave ceilings keep their prior material - // + shadow. The probed heights themselves are unused now — only the success/fail gate matters. - bool bIsCeiling = false; - if (Generator && bGeomCeiling) - { - const int32 VoxelsPerTile = CHUNK_SIZE << Tile.Level; - const FIntVector MinVoxel = Tile.Coord * VoxelsPerTile; - const float CenterX = (float)MinVoxel.X + VoxelsPerTile * 0.5f; - const float CenterY = (float)MinVoxel.Y + VoxelsPerTile * 0.5f; - const float CenterZ = (float)MinVoxel.Z + VoxelsPerTile * 0.5f; - const int32 CenterChunkZ = FMath::FloorToInt(CenterZ / (float)CHUNK_SIZE); - - float TerrainZ = 0.0f, CeilSurf = 0.0f; - bIsCeiling = Generator->GetSurfaceHeightAt(CenterX, CenterY, CenterChunkZ, TerrainZ, CeilSurf); - } - - // Material: strate override (by the tile's min-corner chunk coord) else the global default. Ceiling - // tiles take the strate's CeilingMaterial when set (the rocky "night sky" overhead reads flat/bright - // otherwise, since it casts no shadow). - UMaterialInterface* ChunkMaterial = Settings ? Settings->VoxelMaterial : nullptr; + // F17 — materials per POLYGROUP, not per tile. The mesher classified each triangle + // semantically (sky-cap = down-facing near the column's CeilSurf; terrain overhangs and + // future cave roofs stay ground) and packed two contiguous runs → RMC creates one section + // per non-empty group. Ground (group 0): strate override else global default. Sky-cap + // (group 1): the strate's CeilingMaterial when set (the rocky "night sky" overhead reads + // flat/bright otherwise, since it casts no shadow) else same as ground. A coarse tile + // spanning BOTH surfaces now renders both correctly (the old whole-tile vote painted the + // loser with the winner's material — Jahni's "terrain and ceiling become one" artifact). + UMaterialInterface* GroundMaterial = Settings ? Settings->VoxelMaterial : nullptr; + UMaterialInterface* CeilingMaterial = nullptr; if (StrateManager) { - const FIntVector ChunkCoord = Tile.Coord * (1 << Tile.Level); // level-0-equivalent min corner - if (UVoxelStrateDefinition* StrateDef = StrateManager->GetStrateForChunk(ChunkCoord)) + // F17 — a coarse tile is 2^level CHUNKS TALL: its raw min/max corners can sit in a + // NEIGHBOUR strate (or the inter-strate gap → null) that the mesh doesn't even contain + // (strate content cut). Clamp the lookup Zs into the band the tile was MESHED with, then + // resolve: ground at the (clamped) BOTTOM chunk, sky-cap at the (clamped) TOP chunk — + // the cap is by definition the topmost surface in the tile (mid as a gap fallback). + // This was the "far cap renders with the ground material" residue: the min-corner lookup + // missed the surface strate entirely on tall far tiles. + const FIntVector MinChunk = Tile.Coord * (1 << Tile.Level); // level-0-equivalent min corner + const int32 TileChunks = 1 << Tile.Level; + const int32 ZLoC = FMath::Max(MinChunk.Z, Result.BandChunkLo); + const int32 ZHiC = FMath::Min(MinChunk.Z + TileChunks - 1, Result.BandChunkHi); + if (UVoxelStrateDefinition* StrateDef = + StrateManager->GetStrateForChunk(FIntVector(MinChunk.X, MinChunk.Y, ZLoC))) { - if (StrateDef->OverrideMaterial) { ChunkMaterial = StrateDef->OverrideMaterial; } - if (bIsCeiling && StrateDef->CeilingMaterial) { ChunkMaterial = StrateDef->CeilingMaterial; } + if (StrateDef->OverrideMaterial) { GroundMaterial = StrateDef->OverrideMaterial; } + if (StrateDef->CeilingMaterial) { CeilingMaterial = StrateDef->CeilingMaterial; } } + UVoxelStrateDefinition* CapDef = + StrateManager->GetStrateForChunk(FIntVector(MinChunk.X, MinChunk.Y, ZHiC)); + if (!CapDef || !CapDef->CeilingMaterial) + { + CapDef = StrateManager->GetStrateForChunk(FIntVector(MinChunk.X, MinChunk.Y, (ZLoC + ZHiC) / 2)); + } + if (CapDef && CapDef->CeilingMaterial) { CeilingMaterial = CapDef->CeilingMaterial; } } + if (!CeilingMaterial) { CeilingMaterial = GroundMaterial; } - // Mini-sun shadows: route the resolved base material through a shared MID that binds the density-volume - // textures + per-frame shadow params (the material marches them for raymarched orb shadows). One MID - // per base material, so all tiles of a base still share one material (no batching cost). + // Mini-sun shadows: route the resolved base materials through a shared MID that binds the + // density-volume textures + per-frame shadow params (the material marches them for raymarched + // orb shadows). One MID per base material, so tiles of a base still share one material. if (DensityVolume && Settings && Settings->bEnableDensityVolume) { - if (UMaterialInstanceDynamic* MID = GetOrCreateTerrainMID(ChunkMaterial)) - { - ChunkMaterial = MID; - } + if (UMaterialInstanceDynamic* MID = GetOrCreateTerrainMID(GroundMaterial)) { GroundMaterial = MID; } + if (UMaterialInstanceDynamic* MID = GetOrCreateTerrainMID(CeilingMaterial)) { CeilingMaterial = MID; } } // The geometry stream set was built on the worker (BuildTileStreamSet, T1.f); we just upload it. @@ -933,45 +1671,60 @@ void AVoxelWorld::ApplyMeshToTile(const FVoxelTileKey& Tile, RealtimeMesh::FReal // One component per tile — the clipmap keeps the total tile count low (~1-2k), so this is // cheap on the game thread (no batching needed). Collision + content are level-0 only. + // T2.c: the component comes from the pool when one is parked (see AcquireTileComponent). URealtimeMeshComponent* MeshComp = TileComponents.FindRef(Tile); if (!MeshComp) { - MeshComp = NewObject(this); - // Generated once, never moves → Static so RMC's cached static draw path + VSM shadow - // caching apply (see the root SetMobility note in BeginPlay). Must be set before register. - // Re-mesh on carve recreates the section-group proxy (RMC's Static path already does this), - // which is fine for an infrequent action. - MeshComp->SetMobility(EComponentMobility::Static); - MeshComp->SetGenerateOverlapEvents(false); // chunks use raycasts, not overlaps - MeshComp->SetCanEverAffectNavigation(false); - MeshComp->RegisterComponent(); - MeshComp->AttachToComponent(GetRootComponent(), FAttachmentTransformRules::KeepRelativeTransform); + MeshComp = AcquireTileComponent(); TileComponents.Add(Tile, MeshComp); } - URealtimeMeshSimple* RTMesh = MeshComp->InitializeRealtimeMesh(); + // §9.4 RENDER-SKIP — a tile only a CollisionOnly anchor wants (not the player clipmap) cooks its + // collision below but is hidden (no draw / VSM). Set every apply (overrides the pool's default- + // visible state); ReconcileAnchorTileVisibility handles later flips on already-loaded tiles. + MeshComp->SetVisibility(!CollisionOnlyTiles.Contains(Tile)); + + // Reuse the component's existing mesh object when it has one (pooled component or carve + // re-mesh) — InitializeRealtimeMesh allocates a brand-new URealtimeMesh EVERY call, so + // calling it unconditionally (as before) orphaned one mesh object per re-apply to the GC. + // The RemoveSectionGroup below does the actual geometry clearing on reuse. + URealtimeMeshSimple* RTMesh = MeshComp->GetRealtimeMeshAs(); + if (!RTMesh) { RTMesh = MeshComp->InitializeRealtimeMesh(); } if (!RTMesh) { return; } - // Shadow casting: far (level >= 2) tiles never cast; the SurfaceWorld SKY-CAP CEILING never casts - // either — otherwise the high rock ceiling shadows the entire terrain below it (one mesh, so we - // can't split it). bIsCeiling was classified above via the O(1) oracle. - const bool bCastShadow = (Tile.Level <= 1) && !bIsCeiling; + // Shadow casting: far (level >= 2) tiles never cast; the sky-cap SECTION never casts either + // — otherwise the high rock ceiling shadows the entire terrain below it. F17: shadow is now + // PER SECTION, so a mixed tile keeps its ground shadow while its cap stays shadowless. + const bool bCastShadow = (Tile.Level <= 1); MeshComp->SetCastShadow(bCastShadow); - const FRealtimeMeshSectionGroupKey GroupKey = - FRealtimeMeshSectionGroupKey::Create(FRealtimeMeshLODKey(0), FName("Tile")); + const FRealtimeMeshSectionGroupKey GroupKey = VoxelTileGroupKey(); RTMesh->RemoveSectionGroup(GroupKey); // clear old geometry on re-mesh - RTMesh->SetupMaterialSlot(0, "Main", ChunkMaterial); + // (no-op on a fresh/pooled mesh) + RTMesh->SetupMaterialSlot(0, "Main", GroundMaterial); + RTMesh->SetupMaterialSlot(1, "SkyCap", CeilingMaterial); RTMesh->CreateSectionGroup(GroupKey, MoveTemp(Streams)); - FRealtimeMeshSectionConfig SectionConfig(0); // RMC casts shadows PER SECTION (FRealtimeMeshSectionConfig::bCastsShadow, default true) — the - // component-level UPrimitiveComponent::CastShadow is NOT honored by the RMC proxy. So the real - // shadow lever is here: drive the section flag from the same decision (far tiles + sky-cap ceiling - // → no cast). SetCastShadow above is kept only to keep the component flag consistent. - SectionConfig.bCastsShadow = bCastShadow; - RTMesh->UpdateSectionConfig( - FRealtimeMeshSectionKey::CreateForPolyGroup(GroupKey, 0), - SectionConfig, /*bShouldCreateCollision*/ bLevel0); // collision at level 0 only (T1.c) + // component-level UPrimitiveComponent::CastShadow is NOT honored by the RMC proxy, so the real + // shadow lever is the section flag. RMC auto-created one section per non-empty polygroup above + // (default config already maps material slot = polygroup index); only config sections that + // exist — the bHas* flags come from the worker. Collision at level 0 only (T1.c), both groups. + if (bHasGroundTris) + { + FRealtimeMeshSectionConfig GroundConfig(0); + GroundConfig.bCastsShadow = bCastShadow; + RTMesh->UpdateSectionConfig( + FRealtimeMeshSectionKey::CreateForPolyGroup(GroupKey, 0), + GroundConfig, /*bShouldCreateCollision*/ bLevel0); + } + if (bHasCeilingTris) + { + FRealtimeMeshSectionConfig CapConfig(1); + CapConfig.bCastsShadow = false; // the cap never casts (see above) + RTMesh->UpdateSectionConfig( + FRealtimeMeshSectionKey::CreateForPolyGroup(GroupKey, 1), + CapConfig, /*bShouldCreateCollision*/ bLevel0); + } // Water is no longer spawned per tile — it's a single player-following ocean plane (UpdateWater, // driven from Tick), so it renders at every LOD and to the horizon with no per-tile gaps. @@ -1014,6 +1767,27 @@ FVoxelBiomeQuery AVoxelWorld::GetBiomeAtWorldLocation(FVector WorldLocation) con return Out; } +bool AVoxelWorld::GetVoxelSurfaceHeightAt(FVector WorldLocation, float& OutSurfaceWorldZ, float& OutCeilingWorldZ) const +{ + OutSurfaceWorldZ = WorldLocation.Z; // sensible fallback: unchanged Z + OutCeilingWorldZ = WorldLocation.Z; + if (!Generator) return false; + + // Undo the actor transform → voxel space (same convention as GetBiomeAtWorldLocation / the deco scatter). + const FVector Local = GetActorTransform().InverseTransformPosition(WorldLocation); + const float VX = Local.X / VOXEL_SIZE; + const float VY = Local.Y / VOXEL_SIZE; + const int32 ChunkZ = FMath::FloorToInt((Local.Z / VOXEL_SIZE) / (float)CHUNK_SIZE); + + float TerrainVZ, CeilVZ; + if (!Generator->GetSurfaceHeightAt(VX, VY, ChunkZ, TerrainVZ, CeilVZ)) return false; // not a heightfield + + // Voxel Z → actor-local cm → world, keeping the query's XY so a tilted/scaled actor stays consistent. + OutSurfaceWorldZ = GetActorTransform().TransformPosition(FVector(Local.X, Local.Y, TerrainVZ * VOXEL_SIZE)).Z; + OutCeilingWorldZ = GetActorTransform().TransformPosition(FVector(Local.X, Local.Y, CeilVZ * VOXEL_SIZE)).Z; + return true; +} + //============================================================================= // TERRAIN MODIFICATION — player carving & filling //============================================================================= @@ -1043,7 +1817,35 @@ void AVoxelWorld::ApplyModification(const FVoxelModification& Modification) { if (!DiffLayer) return; TArray AffectedChunks = DiffLayer->ApplyModification(Modification); - RemeshDirtyChunks(AffectedChunks); + + // INSTANT DIG FEEL — synchronously re-mesh the level-0 tile the brush CENTRE sits in, so the hole + // appears THIS frame right where the player is looking. Neighbour tiles (brush edge) re-mesh async + // and prioritised (RemeshDirtyChunks → DirtyRemeshQueue @ BackgroundHigh), a frame or two behind — + // imperceptible. One full-res tile gen on the game thread; only for the common case. + // SKIP the sync when the centre tile is already mid-gen (an async task owns it): syncing would race + // the in-flight stale result (which lands with no hole and would clobber ours). Instead let it flow + // through the async queue, which now KEEPS in-flight tiles queued and re-gens them once the stale + // result lands. The centre tile must be loaded to remesh in place (else it streams in with the diff). + const FVoxelTileKey CenterTile(WorldToChunkCoord(Modification.Center * VOXEL_SIZE), 0); + bool bSyncedCenter = false; + if (AffectedChunks.Contains(CenterTile.Coord) + && LoadedTiles.Contains(CenterTile) + && !PendingTiles.Contains(CenterTile)) + { + SyncRemeshTile(CenterTile); + bSyncedCenter = true; + } + + RemeshDirtyChunks(AffectedChunks, bSyncedCenter ? &CenterTile : nullptr); + + // Remove decorations inside the modified volume so grass doesn't float over a dug hole (or bury under a + // fill). Instant + flicker-free (only the affected instances go); the placer already skips carved columns + // on any future rebuild. Center/Radius are in voxels → world cm. Box/capsule use their bounding sphere. + if (ContentManager && AffectedChunks.Num() > 0) + { + const FVector WorldCenter = Modification.Center * VOXEL_SIZE; // Center was WorldPos/VOXEL_SIZE + ContentManager->RemoveDecorationsInSphere(WorldCenter, Modification.Radius * VOXEL_SIZE); + } } void AVoxelWorld::CarveBox(FVector Position, FVector ExtentVoxels, float Strength) @@ -1307,28 +2109,31 @@ int32 AVoxelWorld::GetCurrentSeason() const // REMESH DIRTY CHUNKS — re-queue affected chunks after terrain modification //============================================================================= -void AVoxelWorld::RemeshDirtyChunks(const TArray& DirtyCoords) +void AVoxelWorld::RemeshDirtyChunks(const TArray& DirtyCoords, const FVoxelTileKey* ExcludeTile) { - // For each affected chunk that's currently loaded, re-queue it for - // async generation + meshing. The old mesh stays visible until the - // new result arrives in ProcessPendingChunks, so no visual pop. - // - // Chunks that aren't loaded are ignored — when they eventually load - // through normal streaming, they'll include the diff layer automatically. - // Edits only affect LEVEL-0 tiles (collision + visible detail are full-res near the player; // coarse far tiles sample too sparsely to show small carves, and pick up the diff naturally - // when they next stream). Re-queue the loaded level-0 tile for each dirty coord — LoadTile - // re-runs gen (density includes the DiffLayer via GetDensityAt) and ProcessPendingChunks - // updates the existing component in place (old mesh stays visible until then, no pop). - const int32 MaxTasks = Settings ? Settings->MaxConcurrentTasks : 16; + // when they next stream). Queue each loaded level-0 dirty tile onto DirtyRemeshQueue — drained + // FIRST in the submit loop and launched at BackgroundHigh (ahead of streaming), so a dig never + // waits behind a shell of streaming tasks. LoadTile re-runs gen (density includes the DiffLayer + // via GetDensityAt) and ProcessPendingChunks updates the existing component in place (old mesh + // stays visible until then, no pop). + // + // Tiles that are currently mid-gen are STILL queued (not skipped): their in-flight result was + // sampled BEFORE this carve, so it lands with no hole — keeping the tile queued re-gens it once + // that stale result drains. (The old inline path dropped both over-budget and in-flight tiles, + // which is why a dig could show up a beat late or not until the player moved.) for (const FIntVector& Coord : DirtyCoords) { const FVoxelTileKey Tile(Coord, 0); - if (!LoadedTiles.Contains(Tile)) continue; // only re-mesh loaded full-res tiles - if (PendingTiles.Contains(Tile)) continue; // already queued - if (PendingTiles.Num() >= MaxTasks) break; // task budget - LoadTile(Tile); + if (ExcludeTile && Tile == *ExcludeTile) continue; // handled synchronously this frame + if (!LoadedTiles.Contains(Tile)) continue; // only re-mesh loaded full-res tiles + DirtyRemeshQueue.Add(Tile); + } + if (DirtyRemeshQueue.Num() > 0) + { + // Wake the submit loop even if the streaming set had settled (idle player digging). + bAllChunksLoaded = false; } // Density volume: refill the clipmap cells overlapping each carved chunk so the shadow march @@ -1501,10 +2306,10 @@ void AVoxelWorld::UpdateOrbLightMPC() const FVoxelActiveOrb& O = Orbs[i]; V = FLinearColor((float)O.WorldPos.X, (float)O.WorldPos.Y, (float)O.WorldPos.Z, O.FalloffWorld); } - // Orbs are static once placed, so most frames change nothing — skip the MPC write (it - // dirties the collection's uniform buffer for every material that reads it). - if (LastOrbMPC[i] == V) continue; - LastOrbMPC[i] = V; + // ALWAYS write, even when unchanged. A skip-if-identical cache was tried here and BROKE the + // lighting: the MPC's world INSTANCE can be reset/recreated behind our back (PIE init order, + // asset recompile), and a cached skip then leaves it holding defaults forever. The per-frame + // rewrite is what makes the collection self-healing — and 4 vector writes cost nothing. UKismetMaterialLibrary::SetVectorParameterValue(this, OrbLightMPC, OrbNames[i], V); } } diff --git a/Source/VoxelForge/Public/VoxelCaveMorphology.h b/Source/VoxelForge/Public/VoxelCaveMorphology.h index 6e5539b..a1f9b86 100644 --- a/Source/VoxelForge/Public/VoxelCaveMorphology.h +++ b/Source/VoxelForge/Public/VoxelCaveMorphology.h @@ -251,6 +251,18 @@ struct FCachedRoom // Intensity scale for this room's op (from FStrateTerrainOpEntry::Weight). // 1.0 = use op as configured, 0.5 = half intensity, 2.0 = double. float RoomOpWeight = 1.0f; + + // PRE-BAKED SHAPE (BuildChunkCache). The shape roll, variety thresholds and the capsule's + // Cos/Sin direction used to be re-derived PER VOXEL per room inside EvaluateSDFCached — hash + // mixes + trig in the hottest loop of the plugin for values that are constants of the room. + // Bit-identical to the old per-voxel roll (same hashes, same math, done once per chunk). + // 0 = ellipsoid → ShapeA = radii (x=y=RadiusXY, z=RadiusZ) + // 1 = rounded box → ShapeA = half-extents, ShapeR = corner rounding + // 2 = capsule → ShapeA/ShapeB = world endpoints, ShapeR = tube radius + uint8 ShapeType = 0; + FVector ShapeA = FVector::ZeroVector; + FVector ShapeB = FVector::ZeroVector; + float ShapeR = 0.0f; }; // A pre-computed tunnel segment — all connection decisions and hash-derived @@ -370,17 +382,16 @@ namespace VoxelCaveMorphology // @param WorldX, WorldY, WorldZ — position in voxel coordinates (may be warped) // @param Cache — pre-built cache from BuildChunkCache // @param SDFBlendRadius — SmoothMin blend radius (from Params.SDFBlendRadius) - // @param RoomShapeVariety — shape variety factor (from Params.RoomShapeVariety) // @param OutNearestRoomIdx — optional out: index of the room with minimum SDF // contribution. -1 if no room passed the cull test. // Used by the terrain ops system to look up the // per-room terrain op assigned to this voxel's room. + // (Room shape variety is baked into FCachedRoom by BuildChunkCache — no per-voxel roll.) // @return negative = inside cave, positive = solid rock float EvaluateSDFCached( float WorldX, float WorldY, float WorldZ, const FChunkSDFCache& Cache, float SDFBlendRadius, - float RoomShapeVariety, int32* OutNearestRoomIdx = nullptr ); diff --git a/Source/VoxelForge/Public/VoxelContentManager.h b/Source/VoxelForge/Public/VoxelContentManager.h index 13c43a2..1a260cc 100644 --- a/Source/VoxelForge/Public/VoxelContentManager.h +++ b/Source/VoxelForge/Public/VoxelContentManager.h @@ -113,6 +113,11 @@ public: * raymarched shadows. */ void GetActiveOrbs(TArray& OutOrbs) const; + /** Remove decoration instances inside a world-space sphere (both grids). Used by player digging (grass + * shouldn't float over a hole) and landmark footprints. The placer already skips carved columns on any + * future rebuild — this patches the LIVE instances. Returns how many were removed. Game-thread. */ + int32 RemoveDecorationsInSphere(const FVector& WorldCenter, float WorldRadius); + /** Destroy all spawned content (decorations + water). Regenerate / season reset. Bumps the deco * epoch so any in-flight march tasks' results are discarded. */ void ClearAll(); @@ -136,6 +141,8 @@ public: struct FDecoSpawn { int32 EntryIdx = 0; + int32 CompanionIdx = -1; // -1 = the entry itself; else index into Entries[EntryIdx].Companions (F7) + int32 SubIdx = -1; // when CompanionIdx>=0: -1 = the companion; else its SubCompanions[SubIdx] bool bInstanced = false; FTransform Xf = FTransform::Identity; }; @@ -163,7 +170,8 @@ private: // Instanced transforms for one mesh, accumulated across all of a region's cells → one batched HISM. struct FRegionMeshBucket { - FStrateDecoration Deco; // representative entry (mesh + HISM render tuning: cull/shadow/scale) + FPlacementProfile Profile; // representative profile (mesh + HISM render tuning: cull/shadow) — + // may be a decoration entry's OR a companion's (F7). TArray Xforms; }; // A non-instanced actor placement, spawned when the region is applied. @@ -240,6 +248,11 @@ private: // terrain material consumes the nearest active orb for raymarched shadows (see GetActiveOrbs). bool bIsOrb = false; FVoxelActiveOrb Orb; + + // Decoration footprint (F7): >0 = clear decorations within this world sphere. Set when the landmark + // has bSuppressDecorationsUnder, so a newly-applied deco region can re-clear under it too. + FVector SuppressCenter = FVector::ZeroVector; + float SuppressRadiusWorld = 0.0f; }; /** WORKER-THREAD surface find → fills OutSpawns for one cell. SurfaceWorld uses the height oracle @@ -278,8 +291,18 @@ private: void SpawnLandmarkInstance(const FStrateLandmark& L, uint32 H, const FDecoContext& Ctx, const FTransform& OwnerXf, AActor* OwnerActor, float LocalX, float LocalY, float Step, float ColDepth, FLandmarkInstance& Out); + // Shared spawn core for landmarks AND set-pieces: surface-find + all FPlacementProfile gates (biome/ + // slope/water/Conditions) → transform → spawn actor OR one static mesh into Out. Returns true (OutXf + // = final transform) when placed; false (Out untouched) on any gate fail = "evaluated, nothing placed". + bool SpawnFromProfile(const FPlacementProfile& P, uint32 H, const FDecoContext& Ctx, + const FTransform& OwnerXf, AActor* OwnerActor, + float LocalX, float LocalY, float Step, float ColDepth, + FTransform& OutXf, FLandmarkInstance& Out); void DestroyLandmarkInstance(FLandmarkInstance& Inst); void ClearAllLandmarks(); + // Remove instances within a world sphere from ONE region's HISMs (the per-region core of + // RemoveDecorationsInSphere; also used by ApplyRegion to clear under a landmark footprint). Returns count. + static int32 RemoveInstancesInContent(FDecoRegionContent& Content, const FVector& Center, float Radius); // Single-column surface find for a landmark (voxel XY): SurfaceWorld → height oracle, else ray-march the // strate band for the first crossing whose orientation matches Surf. Fills Z (voxel) + outward world normal. static bool FindLandmarkColumn(const UVoxelGenerator* Gen, const FTransform& OwnerXf, @@ -318,6 +341,7 @@ private: // Spawned landmarks, keyed by FIntVector(latticeCellX, latticeCellY, entryIndex) — FIntVector already // hashes, so no custom key type is needed. An entry with both ptrs null = "evaluated, nothing placed" // (kept until the cell leaves range so the surface-find isn't repeated). Strate-bounded. + // Keyed by FIntVector(cellX|passageIdx, cellY|side, entryIndex) — lattice cell OR passage endpoint. TMap LandmarkInstances; int32 LastLandmarkStrate = INT32_MIN; // strate change → wipe + rebuild landmarks diff --git a/Source/VoxelForge/Public/VoxelDiffLayer.h b/Source/VoxelForge/Public/VoxelDiffLayer.h index ac5e7ea..5e075db 100644 --- a/Source/VoxelForge/Public/VoxelDiffLayer.h +++ b/Source/VoxelForge/Public/VoxelDiffLayer.h @@ -225,6 +225,36 @@ public: */ bool HasModifications(const FIntVector& ChunkCoord) const; + //========================================================================= + // HOT-PATH SNAPSHOT API (per-chunk, lock-amortised) + //========================================================================= + // Once ANY carve exists, calling HasModifications + GetDensityOffset per voxel costs two + // ModsLock acquisitions + two TMap finds per density sample (~86k lock ops per tile task — + // during carve gameplay, exactly when re-mesh latency matters). Instead, a worker snapshots a + // chunk's mod list ONCE per (chunk, version) and evaluates it lock-free via EvaluateMods; the + // version bump on ApplyModification/Clear invalidates worker-side caches. + + /** Lock-free: true once any modification exists anywhere (false = the common streaming case). */ + bool HasAnyMods() const { return bHasAnyMods.load(std::memory_order_acquire); } + + /** Monotonic mod-state version — bumped by ApplyModification and Clear. */ + uint32 GetModsVersion() const { return ModsVersion.load(std::memory_order_acquire); } + + /** Copy this chunk's modification list under ONE read lock (Out emptied if none). */ + void GetChunkModsSnapshot(const FIntVector& ChunkCoord, TArray& Out) const; + + /** True si un chunk modifié intersecte [MinChunk, MaxChunk] (inclusif). Conservatif par + * construction : ApplyModification enregistre le mod dans TOUS les chunks que son rayon + * touche, donc le test par clé de chunk suffit. Une passe de lecture sur les clés (les + * mondes édités ont peu de chunks modifiés) — utilisé par ClassifyTile, PAS par voxel. */ + bool HasAnyModInChunkRange(const FIntVector& MinChunk, const FIntVector& MaxChunk) const; + + /** Evaluate a mod list at a voxel — the lock-free core shared by GetDensityOffset and the + * generator's snapshot path. Pure function (deterministic). Returns the combined offset + * (negative = carve, positive = fill). */ + static float EvaluateMods(const TArray& Mods, + float WorldX, float WorldY, float WorldZ); + //========================================================================= // MANAGEMENT //========================================================================= @@ -256,6 +286,7 @@ private: TMap> ChunkMods; mutable FRWLock ModsLock; std::atomic bHasAnyMods{ false }; + std::atomic ModsVersion{ 1 }; // see the snapshot API above //========================================================================= // BUDGET TRACKING diff --git a/Source/VoxelForge/Public/VoxelGenerator.h b/Source/VoxelForge/Public/VoxelGenerator.h index ab988b1..0783fc0 100644 --- a/Source/VoxelForge/Public/VoxelGenerator.h +++ b/Source/VoxelForge/Public/VoxelGenerator.h @@ -20,6 +20,54 @@ class UVoxelStrateManager; class UVoxelDiffLayer; class UVoxelBiomeDefinition; +//============================================================================= +// LOD-AWARE OCTAVE REDUCTION (T2.b) +//============================================================================= +// At Step=2/4, noise octaves whose wavelength is smaller than the sampling cell +// are pure aliasing cost — they can't shape the coarse isosurface, only shift it +// by sub-cell noise. Réduction d'octaves sur les tuiles lointaines (LOD). +// +// OctaveBias = octaves dropped from PER-VOXEL volumetric noise for the tile +// currently being meshed on THIS thread. Default 0 = full quality; set (via +// TGuardValue) by UVoxelMarchingCubesMesher::GenerateMesh from its Step and the +// opt-in UVoxelSettings::LODOctaveDrop, restored when the tile finishes — so +// game-thread queries, deco snapping and the density-volume fill thread always +// see 0. fBM/Ridged add octaves low→high frequency, so dropping the TAIL keeps +// the coarse shape identical; only sub-cell detail (already unrepresentable at +// that Step) disappears. LOD0 (Step=1) is byte-identical regardless. +// +// Deliberately NOT applied to XY-field noise (heightfield, ceiling, relief, +// moisture): those feed box-validated caches that can outlive a tile task on +// the same thread, and biome/climate must stay LOD-independent. +namespace VoxelGenLOD +{ + // NOT VOXELFORGE_API: MSVC forbids dll-interface on thread_local (C2492). Both users + // (generator + mesher) are inside this module, so no export is needed anyway. + extern thread_local int32 OctaveBias; + + // Effective octave count for a per-voxel noise call site. + // At least 1 octave always survives (the coarse base shape). + FORCEINLINE int32 Eff(int32 Octaves) { return FMath::Max(1, Octaves - OctaveBias); } +} + +//============================================================================= +// TRIVIAL-TILE CLASSIFICATION (T1.d) +//============================================================================= +// Verdict de ClassifyTile pour une tuile AVANT le pré-échantillonnage 33³+ : +// AllSolid / AllAir garantissent que CHAQUE point du treillis du mesher (marge +// ±1 incluse) est du même côté de l'iso ⇒ maillage vide, GenerateMesh est +// sautée. Mixed = "je ne peux pas le prouver" ⇒ génération normale. Un faux +// Mixed coûte juste du CPU ; un faux AllSolid/AllAir ferait un TROU — les +// verdicts ne sont donc émis que sur des bornes exactes (colonnes surface +// échantillonnées au MÊME treillis que le mesher) + gardes conservatives sur +// tout ce qui peut creuser/remplir (spine, passages, disturbances, diff layer). +enum class EVoxelTileClass : uint8 +{ + Mixed, // peut contenir une surface → mesher normalement + AllSolid, // chaque échantillon prouvé solide → maillage vide + AllAir, // chaque échantillon prouvé air → maillage vide +}; + /** * UVoxelGenerator * @@ -194,6 +242,16 @@ public: int32& OutDominantPalette, int32& OutNeighborPalette, float& OutBlendWeight) const; + /** + * F7 AWARE PLACEMENT: evaluate an entry's relational placement conditions at a candidate voxel XY. + * True = ALL conditions pass (AND); empty list = true (zero cost). Pure query of the analytic fields + * (relief/moisture/biome-border) — deterministic + worker-safe. The caller passes the strate's + * already-resolved biome context (freq/contrast + Voronoi map), so no re-resolve. Also the shared core + * of the future quest FindFeature locator (same predicate, run as an outward search). + */ + bool EvaluateTerrainConditions(const TArray& Conditions, + float WorldX, float WorldY, const FBiomeContext& BiomeCtx) const; + /** * SurfaceWorld HEIGHT ORACLE: the terrain surface Z + sky-cap ceiling Z (voxel coords) at a world * XY for the given strate slice (ChunkZ), WITHOUT ray-marching the density column. Returns false @@ -205,6 +263,20 @@ public: bool GetSurfaceHeightAt(float WorldX, float WorldY, int32 ChunkZ, float& OutTerrainZ, float& OutCeilSurf) const; + /** + * T1.d — classification conservative d'une tuile AVANT le pré-échantillonnage du mesher. + * (OriginVoxels, Step, CellsPerAxis) = les MÊMES arguments que GenerateMesh ; le verdict + * porte sur le treillis exact que le mesher échantillonnerait (marge ±1 incluse). + * + * v1 : ne prouve que les chunks GAP (bedrock) et les strates SurfaceWorld — colonnes + * terrain/plafond évaluées par le MÊME ComputeSurfaceColumn que le chemin densité (donc + * bit-identiques), bandes de seal solides, gardes spine/passages/disturbances/diff. + * Tout autre archétype (intérieur de caves) ⇒ Mixed. Worker-safe (lecture seule + + * caches thread_local partagés avec GetDensityAt — un verdict Mixed laisse les colonnes + * chaudes pour la génération qui suit). + */ + EVoxelTileClass ClassifyTile(const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis) const; + private: /** Pick the biome (index into Ctx.Biomes) for a Voronoi site, by its climate. */ int32 ClassifyBiomeAtSite(float SiteX, float SiteY, const FBiomeContext& Ctx, uint32 SiteHash) const; @@ -213,6 +285,13 @@ private: * per-XY; the part that's evaluated per biome and blended in GetSurfaceDensity. */ float ComputeSurfaceTerrainZ(float WorldX, float WorldY, const FSurfaceGenerationParams& Params) const; + /** F20 — the RAW structural heightfield (continents + mountains + detail), BEFORE any + * terrain op (cliff/terrace/layer-lines/beach). Ops in ComputeSurfaceTerrainZ build on + * this; Cliff re-samples it at an XY offset for a cheap analytic slope. OutM = the relief + * "mountainous-ness" [0,1], reused to relief-condition the ops. */ + float SampleSurfaceStructuralZ(float WorldX, float WorldY, + const FSurfaceGenerationParams& Params, float& OutM) const; + /** The SurfaceWorld sky-cap ceiling surface Z at a world XY (also pure per-XY). */ float ComputeSurfaceCeiling(float WorldX, float WorldY, const FSurfaceGenerationParams& Params) const; @@ -224,17 +303,20 @@ private: TArray& OutBiomeParams) const; /** Biome-blended terrain Z + sky-cap ceiling Z for one column (the XY-only surface field). Shared - * by the density column cache (T1.a) and the oracle. */ + * by the density column cache (T1.a) and the oracle. F20 phase 2 overhang, resolved per column: + * `OutOverhangAmp` = strength·slope-gate (0 = off), `(OutDirX,OutDirY)` = unit UPHILL gradient dir. */ void ComputeSurfaceColumn(float WorldX, float WorldY, int32 ChunkZ, const FSurfaceGenerationParams& BaseSurface, const FBiomeContext& BiomeCtx, const TArray& BiomeParams, FChunkBiomeCache& BiomeCache, - float& OutTerrainZ, float& OutCeilSurf) const; + float& OutTerrainZ, float& OutCeilSurf, + float& OutOverhangAmp, float& OutDirX, float& OutDirY) const; - /** Final SurfaceWorld density from a column's precomputed terrain Z + ceiling: the - * cheap per-voxel Z-combine + origin spine + boundary seal + passage carving. The - * XY-only work (terrain/ceiling) is done once per column and cached (T1.a). */ + /** Final SurfaceWorld density from a column's precomputed terrain Z + ceiling: the cheap per-voxel + * Z-combine + F20 overhang shelf (warped-terrain union, uphill dir) + origin spine + seal + passages. + * The XY-only work (terrain/ceiling/overhang amp+dir) is done once per column and cached (T1.a). */ float SurfaceDensityFromColumn(float WorldX, float WorldY, float WorldZ, float TerrainZ, float CeilSurf, + float OverhangAmp, float DirX, float DirY, const FSurfaceGenerationParams& Structural) const; /** (Re)build the per-chunk biome cell grid covering chunk (X,Y) footprint + margin. */ diff --git a/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h b/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h index 84ee231..d95f44c 100644 --- a/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h +++ b/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h @@ -36,9 +36,47 @@ public: * densité déjà échantillonnée, quantifiés via VF_QuantizeDensity. Cela évite à * UVoxelDensityVolume de re-sampler GetDensityAt pour ces cellules (le mesher * les a déjà calculées). Vidé puis rempli ; reste vide si non éligible. + * @param BandZMinVox / BandZMaxVox - COUPE DE CONTENU PAR STRATE (optionnel, voxels Z INCLUSIFS, + * cf. UVoxelSettings::StrateContentCutMinLevel) : seules les cellules dont + * l'intervalle Z chevauche la bande sont maillées. Une tuile grossière qui + * chevauche une frontière de strate ne maille que la strate du joueur — + * supprime les trous d'aliasing (le bouchon seal/gap plus fin que Step + * tombait entre deux points du treillis) et le mélange de matériaux entre + * strates. Les cellules maillées restent identiques au bit près (mêmes + * échantillons monde purs). Jamais combiné avec OutCaptureGrid. */ FVoxelMeshData GenerateMesh(FIntVector OriginVoxels, int32 Step = 1, int32 CellsPerAxis = CHUNK_SIZE, - TArray* OutCaptureGrid = nullptr); + TArray* OutCaptureGrid = nullptr, + int32 BandZMinVox = INT32_MIN, int32 BandZMaxVox = INT32_MAX); + + /** + * F18 — FEUILLE de champ lointain (anneau render-distance, cf. UVoxelSettings::bFarSheetRing). + * Dans une strate ouverte (SurfaceWorld) le champ lointain est exactement DEUX heightfields — + * TerrainZ (sol) et CeilSurf (plafond sky-cap), déjà calculés par colonne par l'oracle + * GetSurfaceHeightAt. On construit donc deux grilles déplacées régulières au lieu d'un marching + * cubes 3D : sol = polygroup 0, cap = polygroup 1 (classes vraies PAR CONSTRUCTION — pas de vote, + * pas de sonde de classification), mêmes conventions que GenerateMesh (positions monde cm, UVs + * planaires, masques couleur F6 biome/pente/fondu, normales du gradient de hauteur, jupes + * périmètre par seau, run d'indices sol‖cap + NumCeilingTriangles). + * + * @param OriginVoxels - Coin min de la tuile (voxels). Seul XY est utilisé (les hauteurs sont absolues). + * @param StepXY - Pas d'échantillonnage XY en voxels (aligné sur l'anneau MC pour la continuité). + * @param CellsXY - Cellules par axe XY (extent = CellsXY × StepXY). + * @param StrateChunkZ - Chunk Z DANS la strate de référence (le cœur de la bande) — identifie la + * strate dont on maille sol+cap. Hors SurfaceWorld ⇒ mesh vide. + * @param HoleMin/MaxX/YVox - TROU XY (voxels, Max EXCLUSIF ; sentinelles MAX/MIN = pas de trou) : + * les cellules ENTIÈREMENT dans ce rectangle (la zone couverte par les + * coquilles MC autour du joueur) sont sautées — sinon la feuille recouvre + * le terrain proche avec son échantillonnage grossier. Les cellules à + * cheval restent (anneau de recouvrement au raccord) ; pas de jupe sur + * les bords du trou (le terrain MC remplit derrière). + * Non couvert (accepté, cf. fable-idea F18) : passages/spine/chasms creusés (le heightfield pur ne + * les contient pas), diff layer — invisibles à distance de feuille, l'anneau MC proche les garde. + */ + FVoxelMeshData GenerateSheetMesh(FIntVector OriginVoxels, int32 StepXY, int32 CellsXY, + int32 StrateChunkZ, + int32 HoleMinXVox = INT32_MAX, int32 HoleMinYVox = INT32_MAX, + int32 HoleMaxXVox = INT32_MIN, int32 HoleMaxYVox = INT32_MIN); //========================================================================= // SERVICES (injectés par AVoxelWorld) @@ -66,4 +104,11 @@ public: // Profondeur de la jupe, en CELLULES de la tuile (× Step × VOXEL_SIZE). ~2 cellules couvrent // l'écart vers un voisin un niveau plus grossier (cellule 2×). Monter si des fissures persistent. float SkirtCells = 2.0f; + + // T2.b — LOD-aware octave reduction (opt-in, copied from UVoxelSettings::LODOctaveDrop). + // Octaves dropped from per-voxel volumetric noise PER Step doubling: a tile at Step=S + // drops LODOctaveDrop * log2(S) octaves (see VoxelGenLOD in VoxelGenerator.h). + // 0 (default) = off — every LOD samples full octaves, byte-identical to before. + // Réduction d'octaves sur les tuiles grossières ; 0 = désactivé. + int32 LODOctaveDrop = 0; }; diff --git a/Source/VoxelForge/Public/VoxelSettings.h b/Source/VoxelForge/Public/VoxelSettings.h index 8952cf2..3221208 100644 --- a/Source/VoxelForge/Public/VoxelSettings.h +++ b/Source/VoxelForge/Public/VoxelSettings.h @@ -65,6 +65,20 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "0")) int32 StrateViewMarginChunks = 3; + // STRATE CONTENT CUT (F17 separation) — a level-L tile is 2^L chunks TALL and can straddle a + // strate boundary: at coarse Steps the thin seal/gap solid between two strates' airs falls + // between lattice points (⇒ holes into the neighbour strate at far LOD) and one tile mixes + // both strates' materials. From this clip level UP, the mesher only meshes cells inside the + // PLAYER's strate Z-band (the other strates are sealed/enclosed ⇒ invisible from here anyway); + // loaded coarse tiles re-queue automatically when the band changes (strate transition). + // Default 0 = cut at EVERY level (tested verdict 2026-07-05: level 0/1 straddler tiles were + // the visible mixers — a higher floor left them mixing and looked like "no improvement"). + // Raise only if the descent/passage transition needs full tiles near the player. 9 = off. + // At ultra-coarse levels where one CELL is taller than the band itself, the tile is skipped + // entirely (see LoadTile) — cell-granular cutting there could only render garbage. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "0", ClampMax = "9")) + int32 StrateContentCutMinLevel = 0; + // Open-world SKY reach: the sky-cap ceiling of an open strate (SurfaceWorld / FloatingIslands) // is FAR, so the ceiling BAND is streamed across a wider horizontal radius = ViewDistanceXY × // this, so the sky reaches toward the horizon instead of being a patch over the player's head. @@ -113,6 +127,35 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "4", ClampMax = "32")) int32 CoarseTileCells = 16; + // RENDER DISTANCE — custom horizontal reach, in CHUNKS (1 chunk = 8 m; 128 ≈ 1 km, 768 ≈ 6 km). + // When > 0 and farther than the natural clipmap reach (ClipRadius × 2^MaxClipLevel chunks), the + // OUTERMOST shell keeps generating level-MaxClipLevel tiles outward until it covers this + // distance. Pick MaxClipLevel = the coarsest level that still renders strates correctly (one + // cell must fit inside a strate band — level ≥7 blanks via the too-coarse skip) and buy the + // remaining horizon here. Cost: the extra ring is all same-level tiles — tile/draw/gen count + // grows with (distance / 2^MaxClipLevel)², so each MaxClipLevel step down quadruples the ring. + // 0 = off (natural reach, byte-identical streaming). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0")) + int32 RenderDistanceChunks = 0; + + // F18 — the render-distance ring streams per-surface SHEETS instead of MC tiles: in an open + // strate the far field is exactly two heightfields (TerrainZ + sky-cap CeilSurf, both already + // computed per column), so each far tile becomes two displaced grids (ground polygroup 0 / + // cap polygroup 1 — same materials), ~3-6× cheaper to generate and far fewer components (one + // sheet spans 2^FarSheetSpanLevels MC-tile footprints per axis). Non-open strates produce + // empty sheets (their far ring was enclosed rock anyway). Carved features (passages, chasms, + // spine) don't show at sheet distance. Needs the strate band armed (StrateContentCutMinLevel + // active); in the inter-strate gap the sheet ring blanks until you land. Off = the ring stays + // MC tiles at level MaxClipLevel (pre-F18 behaviour). Only matters when RenderDistanceChunks > 0. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap") + bool bFarSheetRing = true; + + // Sheet tile size = MaxClipLevel + this many levels (2 → one sheet covers 4×4 MC-tile + // footprints → ~16× fewer far components). Sampling density stays that of the MaxClipLevel + // MC ring (cell count grows instead), capped at 128 cells/axis (beyond, cells coarsen). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "1", ClampMax = "4", EditCondition = "bFarSheetRing")) + int32 FarSheetSpanLevels = 2; + // SKIRTS — seal the thin cracks where neighbouring clipmap shells (different resolutions) meet. // A short wall is extruded into the solid from each surface edge on the tile's outer faces. UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap") @@ -123,6 +166,16 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0.5", ClampMax = "8.0")) float SkirtCells = 2.0f; + // T2.b — LOD-aware octave reduction. Octaves DROPPED from per-voxel volumetric noise + // (roughness, worms displacement, slab/maze/shaft/island detail) per Step doubling on + // coarse tiles: a Step=4 tile drops 2×this. Sub-cell octaves can't shape a coarse + // isosurface — they only cost CPU — so 1 shaves 30-50% off far-tile gen for a sub-cell + // isosurface shift (skirts already stitch bigger LOD seams). LOD0 is NEVER affected. + // 0 = off (every LOD samples full octaves — byte-identical to before this setting). + // Réduction d'octaves sur les tuiles lointaines ; 0 = désactivé, LOD0 jamais touché. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0", ClampMax = "3")) + int32 LODOctaveDrop = 0; + //========================================================================= // CONTENT — distance-based decoration grid (no LOD pop) //========================================================================= diff --git a/Source/VoxelForge/Public/VoxelStrateDefinition.h b/Source/VoxelForge/Public/VoxelStrateDefinition.h index 17de283..9f972c5 100644 --- a/Source/VoxelForge/Public/VoxelStrateDefinition.h +++ b/Source/VoxelForge/Public/VoxelStrateDefinition.h @@ -326,6 +326,8 @@ public: // Landmarks: RARE, large, far-visible objects placed on a coarse hash lattice (the underground // "mini-suns" etc.). Strate-wide; each entry has its own spacing/biome/placement/transform settings. // Cheap at any radius — see FStrateLandmark / §8.5. (NOT part of the per-chunk decoration grid.) + // Landmarks now cover set-pieces too (AnchorMode HashLattice/PassageMouth + exclusion + orb). See + // FStrateLandmark / §8.5 (F7). UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Content") TArray Landmarks; diff --git a/Source/VoxelForge/Public/VoxelStrateManager.h b/Source/VoxelForge/Public/VoxelStrateManager.h index 344055e..9bac9da 100644 --- a/Source/VoxelForge/Public/VoxelStrateManager.h +++ b/Source/VoxelForge/Public/VoxelStrateManager.h @@ -155,6 +155,11 @@ public: UFUNCTION(BlueprintCallable, Category = "Strate") int32 GetStrateIndex(float WorldZ) const; + /** Layout/passage generation counter (bumped by every Initialize → GeneratePassages). + * Hot-path callers key thread_local memos on this so an editor rebuild (RebuildStrates / + * live edit) can never serve a stale strate index or passage shortlist. */ + uint32 GetLayoutVersion() const { return PassagesVersion; } + /** * Get the strate definition for a specific chunk coordinate. * @@ -279,6 +284,13 @@ public: */ float EvaluateModifierSDF(float WorldX, float WorldY, float WorldZ) const; + /** + * True si la sphère englobante d'un passage (élargie du rayon de blend de carve) touche la + * boîte VOXEL [MinVoxel, MaxVoxel]. Test conservatif O(Passages) — utilisé par ClassifyTile + * (rejet des tuiles trivialement pleines) une fois PAR TUILE, jamais par voxel. + */ + bool AnyPassageNearBox(const FVector& MinVoxel, const FVector& MaxVoxel) const; + /** Get all generated passages (for debug display). */ const TArray& GetPassages() const { return Passages; } diff --git a/Source/VoxelForge/Public/VoxelStrateTypes.h b/Source/VoxelForge/Public/VoxelStrateTypes.h index f275011..1bbb7aa 100644 --- a/Source/VoxelForge/Public/VoxelStrateTypes.h +++ b/Source/VoxelForge/Public/VoxelStrateTypes.h @@ -16,7 +16,7 @@ #include "GameplayTagContainer.h" #include "VoxelStrateTypes.generated.h" -class UVoxelBiomeDefinition; // FStrateLandmark::RequiredBiome (optional per-landmark biome filter) +class UVoxelBiomeDefinition; // FPlacementProfile::RequiredBiome (optional per-entry biome filter) //============================================================================= // ENUMS @@ -243,6 +243,113 @@ enum class EVoxelStrateTransition : uint8 // GENERATION PARAMS //============================================================================= +// X-macro field list for FStrateGenerationParams — the ONE place that enumerates +// every param participating in strate-boundary blending (Lerp expands it below). +// ADDING A FIELD TO THE STRUCT? ADD IT HERE TOO — with the old hand-written Lerp, +// a forgotten field silently reset to its default value inside blend zones. +// Liste X-macro des champs blendés aux frontières de strates (une seule source). +// LERPF(Name) — continuous value, FMath::Lerp between the two strates +// SNAPF(Name) — discrete value (bool / int / enum), snaps at Alpha = 0.5 +#define VF_STRATE_PARAM_FIELDS(LERPF, SNAPF) \ + /* Rock */ \ + LERPF(BaseDensity) \ + LERPF(VerticalScale) \ + /* Worm tunnels */ \ + LERPF(WormFrequency) \ + LERPF(WormHorizontalBias) \ + LERPF(WormThreshold) \ + LERPF(WormStrength) \ + LERPF(WormNetworkRange) \ + /* Cave morphology — rooms */ \ + LERPF(RoomSpacing) \ + LERPF(RoomDensity) \ + LERPF(MinRoomRadius) \ + LERPF(MaxRoomRadius) \ + LERPF(RoomHeightRatio) \ + LERPF(RoomShapeVariety) \ + LERPF(RoomFloorCutMin) \ + LERPF(RoomFloorCutMax) \ + LERPF(FloorReliefStrength) \ + LERPF(FloorReliefFrequency) \ + LERPF(OriginRoomRadius) \ + SNAPF(OriginRoomMaxConnections) \ + /* Cave morphology — tunnels */ \ + LERPF(TunnelMinRadius) \ + LERPF(TunnelMaxRadius) \ + LERPF(TunnelDensity) \ + LERPF(MaxTunnelLength) \ + LERPF(TunnelWarpStrength) \ + LERPF(TunnelHorizontalBias) \ + SNAPF(bTunnelsFlowTowardOrigin) \ + LERPF(TunnelEndpointZOffset) \ + LERPF(SDFBlendRadius) \ + LERPF(WaterLevelRelative) \ + /* Cave warp */ \ + LERPF(CaveWarpStrength) \ + LERPF(CaveWarpFrequency) \ + /* Roughness */ \ + LERPF(SurfaceRoughness) \ + LERPF(RoughnessFrequency) \ + /* Boundary seal + runtime Z range */ \ + LERPF(BoundarySealThickness) \ + LERPF(StrateTopWorldZ) \ + LERPF(StrateBottomWorldZ) \ + /* Noise profile */ \ + SNAPF(RoughnessNoiseType) \ + LERPF(DomainWarpStrength) \ + LERPF(DomainWarpFrequency) \ + LERPF(FloorBias) \ + /* Terrain ops — terracing / layer lines / overhangs */ \ + LERPF(TerraceStepHeight) \ + LERPF(TerraceHardness) \ + LERPF(TerraceNoiseDisplacement) \ + LERPF(LayerLineSpacing) \ + LERPF(LayerLineDepth) \ + LERPF(OverhangStrength) \ + LERPF(OverhangDepth) \ + LERPF(OverhangFrequency) \ + /* Ribbing */ \ + LERPF(RibbingSpacing) \ + LERPF(RibbingDepth) \ + /* Cliff */ \ + LERPF(CliffStrength) \ + /* Scallop */ \ + LERPF(ScallopStrength) \ + LERPF(ScallopFrequency) \ + /* Arch */ \ + LERPF(ArchDensity) \ + LERPF(ArchMinRadius) \ + LERPF(ArchMaxRadius) \ + /* Columns */ \ + LERPF(ColumnDensity) \ + LERPF(ColumnMinRadius) \ + LERPF(ColumnMaxRadius) \ + /* Pits */ \ + LERPF(PitDensity) \ + LERPF(PitMinRadius) \ + LERPF(PitMaxRadius) \ + LERPF(PitDepth) \ + /* Chimneys */ \ + LERPF(ChimneyDensity) \ + LERPF(ChimneyMinRadius) \ + LERPF(ChimneyMaxRadius) \ + LERPF(ChimneyHeight) \ + /* Domes */ \ + LERPF(DomeDensity) \ + LERPF(DomeMinRadius) \ + LERPF(DomeMaxRadius) \ + LERPF(DomeHeightRatio) \ + /* Pinch */ \ + LERPF(PinchDensity) \ + LERPF(PinchStrength) \ + LERPF(PinchLength) + +// Per-field expansions used by FStrateGenerationParams::Lerp. They reference the +// locals A / B / Alpha / Result of that function (lexical expansion). Defined at +// file scope so no preprocessor directive sits inside the USTRUCT body (UHT-safe). +#define VF_PARAM_LERP(Name) Result.Name = FMath::Lerp(A.Name, B.Name, Alpha); +#define VF_PARAM_SNAP(Name) Result.Name = (Alpha < 0.5f) ? A.Name : B.Name; + /** * FStrateGenerationParams — Cave generation parameters for one strate. * @@ -924,97 +1031,10 @@ struct VOXELFORGE_API FStrateGenerationParams float Alpha) { FStrateGenerationParams Result; - // Rock - Result.BaseDensity = FMath::Lerp(A.BaseDensity, B.BaseDensity, Alpha); - Result.VerticalScale = FMath::Lerp(A.VerticalScale, B.VerticalScale, Alpha); - // Worm tunnels - Result.WormFrequency = FMath::Lerp(A.WormFrequency, B.WormFrequency, Alpha); - Result.WormHorizontalBias = FMath::Lerp(A.WormHorizontalBias, B.WormHorizontalBias, Alpha); - Result.WormThreshold = FMath::Lerp(A.WormThreshold, B.WormThreshold, Alpha); - Result.WormStrength = FMath::Lerp(A.WormStrength, B.WormStrength, Alpha); - Result.WormNetworkRange = FMath::Lerp(A.WormNetworkRange, B.WormNetworkRange, Alpha); - // Cave morphology - Result.RoomSpacing = FMath::Lerp(A.RoomSpacing, B.RoomSpacing, Alpha); - Result.RoomDensity = FMath::Lerp(A.RoomDensity, B.RoomDensity, Alpha); - Result.MinRoomRadius = FMath::Lerp(A.MinRoomRadius, B.MinRoomRadius, Alpha); - Result.MaxRoomRadius = FMath::Lerp(A.MaxRoomRadius, B.MaxRoomRadius, Alpha); - Result.RoomHeightRatio = FMath::Lerp(A.RoomHeightRatio, B.RoomHeightRatio, Alpha); - Result.RoomShapeVariety = FMath::Lerp(A.RoomShapeVariety, B.RoomShapeVariety, Alpha); - Result.RoomFloorCutMin = FMath::Lerp(A.RoomFloorCutMin, B.RoomFloorCutMin, Alpha); - Result.RoomFloorCutMax = FMath::Lerp(A.RoomFloorCutMax, B.RoomFloorCutMax, Alpha); - Result.FloorReliefStrength = FMath::Lerp(A.FloorReliefStrength, B.FloorReliefStrength, Alpha); - Result.FloorReliefFrequency = FMath::Lerp(A.FloorReliefFrequency, B.FloorReliefFrequency, Alpha); - Result.OriginRoomRadius = FMath::Lerp(A.OriginRoomRadius, B.OriginRoomRadius, Alpha); - Result.OriginRoomMaxConnections = (Alpha < 0.5f) ? A.OriginRoomMaxConnections : B.OriginRoomMaxConnections; - Result.TunnelMinRadius = FMath::Lerp(A.TunnelMinRadius, B.TunnelMinRadius, Alpha); - Result.TunnelMaxRadius = FMath::Lerp(A.TunnelMaxRadius, B.TunnelMaxRadius, Alpha); - Result.TunnelDensity = FMath::Lerp(A.TunnelDensity, B.TunnelDensity, Alpha); - Result.MaxTunnelLength = FMath::Lerp(A.MaxTunnelLength, B.MaxTunnelLength, Alpha); - Result.TunnelWarpStrength = FMath::Lerp(A.TunnelWarpStrength, B.TunnelWarpStrength, Alpha); - Result.TunnelHorizontalBias = FMath::Lerp(A.TunnelHorizontalBias, B.TunnelHorizontalBias, Alpha); - Result.bTunnelsFlowTowardOrigin = (Alpha < 0.5f) ? A.bTunnelsFlowTowardOrigin : B.bTunnelsFlowTowardOrigin; - Result.TunnelEndpointZOffset = FMath::Lerp(A.TunnelEndpointZOffset, B.TunnelEndpointZOffset, Alpha); - Result.SDFBlendRadius = FMath::Lerp(A.SDFBlendRadius, B.SDFBlendRadius, Alpha); - Result.WaterLevelRelative = FMath::Lerp(A.WaterLevelRelative, B.WaterLevelRelative, Alpha); - // Cave warp - Result.CaveWarpStrength = FMath::Lerp(A.CaveWarpStrength, B.CaveWarpStrength, Alpha); - Result.CaveWarpFrequency = FMath::Lerp(A.CaveWarpFrequency, B.CaveWarpFrequency, Alpha); - // Roughness - Result.SurfaceRoughness = FMath::Lerp(A.SurfaceRoughness, B.SurfaceRoughness, Alpha); - Result.RoughnessFrequency = FMath::Lerp(A.RoughnessFrequency, B.RoughnessFrequency, Alpha); - // Boundary seal - Result.BoundarySealThickness = FMath::Lerp(A.BoundarySealThickness, B.BoundarySealThickness, Alpha); - Result.StrateTopWorldZ = FMath::Lerp(A.StrateTopWorldZ, B.StrateTopWorldZ, Alpha); - Result.StrateBottomWorldZ = FMath::Lerp(A.StrateBottomWorldZ, B.StrateBottomWorldZ, Alpha); - // Noise profile - Result.RoughnessNoiseType = (Alpha < 0.5f) ? A.RoughnessNoiseType : B.RoughnessNoiseType; - Result.DomainWarpStrength = FMath::Lerp(A.DomainWarpStrength, B.DomainWarpStrength, Alpha); - Result.DomainWarpFrequency = FMath::Lerp(A.DomainWarpFrequency, B.DomainWarpFrequency, Alpha); - Result.FloorBias = FMath::Lerp(A.FloorBias, B.FloorBias, Alpha); - // Terrain ops - Result.TerraceStepHeight = FMath::Lerp(A.TerraceStepHeight, B.TerraceStepHeight, Alpha); - Result.TerraceHardness = FMath::Lerp(A.TerraceHardness, B.TerraceHardness, Alpha); - Result.TerraceNoiseDisplacement = FMath::Lerp(A.TerraceNoiseDisplacement, B.TerraceNoiseDisplacement, Alpha); - Result.LayerLineSpacing = FMath::Lerp(A.LayerLineSpacing, B.LayerLineSpacing, Alpha); - Result.LayerLineDepth = FMath::Lerp(A.LayerLineDepth, B.LayerLineDepth, Alpha); - Result.OverhangStrength = FMath::Lerp(A.OverhangStrength, B.OverhangStrength, Alpha); - Result.OverhangDepth = FMath::Lerp(A.OverhangDepth, B.OverhangDepth, Alpha); - Result.OverhangFrequency = FMath::Lerp(A.OverhangFrequency, B.OverhangFrequency, Alpha); - // Ribbing - Result.RibbingSpacing = FMath::Lerp(A.RibbingSpacing, B.RibbingSpacing, Alpha); - Result.RibbingDepth = FMath::Lerp(A.RibbingDepth, B.RibbingDepth, Alpha); - // Cliff - Result.CliffStrength = FMath::Lerp(A.CliffStrength, B.CliffStrength, Alpha); - // Scallop - Result.ScallopStrength = FMath::Lerp(A.ScallopStrength, B.ScallopStrength, Alpha); - Result.ScallopFrequency = FMath::Lerp(A.ScallopFrequency, B.ScallopFrequency, Alpha); - // Arch - Result.ArchDensity = FMath::Lerp(A.ArchDensity, B.ArchDensity, Alpha); - Result.ArchMinRadius = FMath::Lerp(A.ArchMinRadius, B.ArchMinRadius, Alpha); - Result.ArchMaxRadius = FMath::Lerp(A.ArchMaxRadius, B.ArchMaxRadius, Alpha); - // Columns - Result.ColumnDensity = FMath::Lerp(A.ColumnDensity, B.ColumnDensity, Alpha); - Result.ColumnMinRadius = FMath::Lerp(A.ColumnMinRadius, B.ColumnMinRadius, Alpha); - Result.ColumnMaxRadius = FMath::Lerp(A.ColumnMaxRadius, B.ColumnMaxRadius, Alpha); - // Pits - Result.PitDensity = FMath::Lerp(A.PitDensity, B.PitDensity, Alpha); - Result.PitMinRadius = FMath::Lerp(A.PitMinRadius, B.PitMinRadius, Alpha); - Result.PitMaxRadius = FMath::Lerp(A.PitMaxRadius, B.PitMaxRadius, Alpha); - Result.PitDepth = FMath::Lerp(A.PitDepth, B.PitDepth, Alpha); - // Chimneys - Result.ChimneyDensity = FMath::Lerp(A.ChimneyDensity, B.ChimneyDensity, Alpha); - Result.ChimneyMinRadius = FMath::Lerp(A.ChimneyMinRadius, B.ChimneyMinRadius, Alpha); - Result.ChimneyMaxRadius = FMath::Lerp(A.ChimneyMaxRadius, B.ChimneyMaxRadius, Alpha); - Result.ChimneyHeight = FMath::Lerp(A.ChimneyHeight, B.ChimneyHeight, Alpha); - // Domes - Result.DomeDensity = FMath::Lerp(A.DomeDensity, B.DomeDensity, Alpha); - Result.DomeMinRadius = FMath::Lerp(A.DomeMinRadius, B.DomeMinRadius, Alpha); - Result.DomeMaxRadius = FMath::Lerp(A.DomeMaxRadius, B.DomeMaxRadius, Alpha); - Result.DomeHeightRatio = FMath::Lerp(A.DomeHeightRatio, B.DomeHeightRatio, Alpha); - // Pinch - Result.PinchDensity = FMath::Lerp(A.PinchDensity, B.PinchDensity, Alpha); - Result.PinchStrength = FMath::Lerp(A.PinchStrength, B.PinchStrength, Alpha); - Result.PinchLength = FMath::Lerp(A.PinchLength, B.PinchLength, Alpha); + // One assignment per field, expanded from VF_STRATE_PARAM_FIELDS (defined + // above the struct). Bit-identical to the old hand-written list — same + // FMath::Lerp calls, same Alpha-0.5 snap for discrete fields. + VF_STRATE_PARAM_FIELDS(VF_PARAM_LERP, VF_PARAM_SNAP) return Result; } }; @@ -1364,6 +1384,93 @@ struct VOXELFORGE_API FSurfaceGenerationParams UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Macro", meta = (ClampMin = "1.0")) float TerraceHeight = 12.0f; + // Terrace edge sharpness (0-1). 0 = soft rounded steps; 1 = crisp flat mesas with near- + // vertical risers. Only matters when TerraceStrength > 0. (F20 — the plateau tops flatten + // and the risers steepen as this rises.) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Macro", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float TerraceHardness = 0.5f; + + // ----- F20 surface terrain ops (heightfield tier — biome-selected, slope/relief aware) ----- + // These reshape the ground HEIGHT as a function of XY. All default OFF (0) so a strate/biome + // that doesn't set them is byte-identical to before. Cheap: pure per-column height remaps + // (no extra 3D density sampling). Each biome carries its own set; the surface blend lerps the + // final heights between the dominant and neighbour biome for free. + + // Sedimentary "layer lines": fine repeating shelves cut into slopes (exposed rock strata). + // Depth = voxels the surface is nudged toward each band plane; 0 = off. Reads on slopes, + // invisible on flats (a flat area shifts uniformly). Pair with a small Spacing for dense + // banding. Un-gated by relief so the geology reads everywhere. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Ops", meta = (ClampMin = "0.0")) + float LayerLineDepth = 0.0f; + + // Vertical spacing between layer lines in voxels (band period). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Ops", meta = (ClampMin = "1.0")) + float LayerLineSpacing = 4.0f; + + // Cliff STEEPENING (0-1 master): where the surface is already STEEP (slope > threshold), + // push the height away from the local mean so gentle slopes become sheer walls / canyon + // faces, while gentle ground stays untouched. 0 = off. This is the slope-CONDITIONED op — + // it hugs real steep terrain instead of scattering cliffs at random. Costs 4 extra structural + // samples per column ONLY when > 0 (the priciest phase-1 op, still per-column-cheap). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Ops", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float CliffStrength = 0.0f; + + // Slope (rise in voxels per voxel of XY) at which cliffs begin. Below this the ground is + // untouched; the effect ramps in above it. ~0.3 = 17°, ~0.5 = 27° (default), ~1.0 = 45°. + // Lower = more of the terrain qualifies as "cliff". + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Ops", meta = (ClampMin = "0.05")) + float CliffSlopeThreshold = 0.5f; + + // Extra steepness multiplier at full effect: how far the height is pushed from the local mean. + // 1 = up to ~2× the local relief on the steepest gated slopes; 3 = dramatic vertical walls. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Ops", meta = (ClampMin = "0.0")) + float CliffSharpness = 2.0f; + + // XY distance (voxels) used to measure the slope / local mean for Cliff. Larger = smoother, + // broader cliff faces; smaller = reacts to finer bumps. Keep a few voxels. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Ops", meta = (ClampMin = "0.5")) + float CliffSampleDist = 2.0f; + + // ----- F20 phase 2: OVERHANG (volumetric — the first true-3D surface op) ----- + // Real jutting rock shelves: for air voxels just above a steep slope, the heightfield is re-sampled + // UPHILL (toward the cliff) by a height-varying amount and unioned in — so cliff rock extends OUT + // over the void below, self-capping at the cliff's height. This is genuine 3D (per-voxel re-eval on + // steep overhang columns only), costlier than the heightfield ops. 0 = off ⇒ byte-identical. + // Biome-selected: each biome's strength blends across borders. + + // Master overhang strength (0-1). 0 = off. Also the biome selector — a biome with 0 has no overhangs. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Ops", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float OverhangStrength = 0.0f; + + // Horizontal REACH (voxels): how far the shelf juts out over the void from the cliff, and the scale + // at which the terrain gradient is measured (so a spot over the void can "see" the cliff). Bigger = + // deeper overhangs reaching further out (and a bit more cost). ~8-20. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Ops", meta = (ClampMin = "0.0")) + float OverhangReach = 12.0f; + + // Vertical HEIGHT (voxels) of the overhang zone above the local ground — where the shelf sits above + // the ground/void directly under it, AND the band ClassifyTile treats as ambiguous (so it never holes + // a trivially-skipped tile). Larger = taller/higher shelves but more woken air tiles near cliffs. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Ops", meta = (ClampMin = "0.0")) + float OverhangHeight = 20.0f; + + // Horizontal frequency of the shelf-shape noise (breaks the reach up so shelves are ragged, not a + // uniform lip). Lower = broader, smoother shelves. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Ops", meta = (ClampMin = "0.0")) + float OverhangFrequency = 0.02f; + + // Vertical frequency RATIO of the shelf noise (× the horizontal frequency). Higher = the shelf folds/ + // curls more as it rises (more dramatic undercuts); near 0 = a flatter lip. ~0.4-0.8. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Ops", meta = (ClampMin = "0.0")) + float OverhangZScale = 0.5f; + + // Slope (rise per voxel of XY, measured over ~Reach) at which overhangs begin. Below this, none. + // Lowered default so it triggers on merely-steep ground, not only near-vertical walls. ~0.2 = 11°, + // ~0.3 = 17° (default), ~0.6 = 31°. NOTE: a DRAMATIC jutting shelf still needs a near-vertical cliff + // (slope » 1) next to a drop — smooth hills can only get subtle folds; use Cliff to MAKE walls first. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Ops", meta = (ClampMin = "0.05")) + float OverhangSlopeThreshold = 0.3f; + // ----- Water ----- // Water table height as a fraction of strate height (0 = no water). Valleys below @@ -1715,142 +1822,319 @@ struct VOXELFORGE_API FStratePassageConfig // CONTENT ENTRY STRUCTS //============================================================================= +/** + * ETerrainConditionType — which analytic terrain field an FTerrainCondition tests. + * All are DERIVED PREDICATES (pure functions of XY + seed + strate), evaluated on demand — NOT stored + * terrain annotations (see the F7 design: "conditions, not annotations"). More types (water-edge, + * relief-peak local-max) land in later passes. + */ +UENUM(BlueprintType) +enum class ETerrainConditionType : uint8 +{ + Relief UMETA(DisplayName = "Relief (elevation 0-1)"), // SampleRelief — peaks/mesas/lowlands + Moisture UMETA(DisplayName = "Moisture (0-1)"), // SampleMoisture — wet/dry + BiomeBorder UMETA(DisplayName = "Biome border (0=deep, ~0.5=edge)"), // near a biome boundary +}; + +/** + * FTerrainCondition — one relational "aware placement" predicate (F7). The candidate point's derived + * field (Relief/Moisture/biome-border weight) must fall in [Min,Max] (or OUTSIDE it when bInvert). + * Multiple conditions on one entry are AND-ed. Empty list = no test (zero cost). Deterministic + + * worker-safe (pure query of the analytic fields, no stored state). Also drives the future quest + * FindFeature locator (same predicate, run as a search). + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FTerrainCondition +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Condition") + ETerrainConditionType Type = ETerrainConditionType::Relief; + + // Inclusive lower/upper bound of the accepted band. All fields read 0..1; BiomeBorder is ~0 deep in a + // biome cell and approaches ~0.5 exactly on a border, so "near a border" ≈ Min 0.35. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Condition", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float Min = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Condition", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float Max = 1.0f; + + // Accept OUTSIDE [Min,Max] instead of inside (avoid peaks, keep off borders, …). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Condition") + bool bInvert = false; +}; + +/** + * FPlacementProfile — shared placement settings for every scatter primitive + * (FStrateDecoration and FStrateLandmark — the latter also covers set-pieces: ruins/shrines/monuments). + * + * Each primitive keeps only its own DISTRIBUTION fields (HOW candidates are enumerated — + * a per-column grid, a hash lattice, an anchor mode). Everything about "can it go here", + * "what spawns", and "how it looks" lives HERE, once, so all three primitives are authored + * with one identical vocabulary. The awareness layer (FTerrainCondition Conditions[]) lands + * in the Filter section in a later pass. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FPlacementProfile +{ + GENERATED_BODY() + + // ----- Spawn (one of these; ActorClass wins if both set) ----- + + // Real actor — lights, logic, interaction. Costs game-thread time per instance: prefer InstancedMesh + // for pure visual props (dense decoration especially — an actor per groundcover instance is ruinous). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement") + TSubclassOf ActorClass; + + // INSTANCED path: if set, renders as batched instances (decoration) or one StaticMeshComponent + // (landmark/set-piece) instead of spawning ActorClass (which is then ignored). No tick, no per-actor + // overhead, engine-culled. An emissive material still glows at distance without a light. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement") + UStaticMesh* InstancedMesh = nullptr; + + // ----- Filter (can it go here) ----- + + // Which surface type this entry snaps to. (Decoration defaults Any; landmarks default Ceiling — set in + // each primitive's constructor.) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Filter") + ESurfaceType SurfacePlacement = ESurfaceType::Any; + + // Surface-tilt band (degrees from flat = acos(|normal.Z|); 0 = flat, 90 = vertical). MaxSlopeAngle + // rejects surfaces STEEPER than it (90 = no filter); MinSlopeAngle rejects surfaces FLATTER than it + // (0 = no filter). Pair them to band a prop onto a tilt range (e.g. 30..70 = slopes only). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Filter", meta = (ClampMin = "0.0", ClampMax = "90.0")) + float MaxSlopeAngle = 90.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Filter", meta = (ClampMin = "0.0", ClampMax = "90.0")) + float MinSlopeAngle = 0.0f; + + // Wall entries only: exclude downward-facing OVERHANGS. A "wall" (|normal.Z| <= 0.5) still includes + // surfaces leaning slightly DOWNWARD; set this so only normals with Z >= 0 (upright walls) qualify. + // Ignored unless the point resolves as a wall. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Filter") + bool bWallExcludeOverhangs = false; + + // Water-relative gate (ignored unless the strate has a water table): place only below (true) / above + // (false) the water line when bRequireWaterRelative is set. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Filter") + bool bRequireWaterRelative = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Filter", + meta = (EditCondition = "bRequireWaterRelative")) + bool bPlaceBelowWater = false; + + // Optional: only place inside this biome (resolved at the candidate XY). Null = any biome in the + // strate. (Decoration entries are already scoped to a biome by being listed under it, so this is + // mostly for the strate-wide landmark / set-piece primitives.) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Filter") + UVoxelBiomeDefinition* RequiredBiome = nullptr; + + // Relational "aware placement" (F7): derived predicates the candidate must satisfy (relief/moisture/ + // biome-border), all AND-ed and evaluated by pure query — temples on peaks, oasis in wet lowlands, + // markers on biome borders. Empty = no test (zero cost). Deterministic + worker-safe. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Filter") + TArray Conditions; + + // ----- Transform ----- + + // Rotate the object so its up-axis follows the surface normal (plants stand up on floors, stalactites + // point down on ceilings). If false, keeps world-up. (Decoration defaults true; landmarks default + // false — set per primitive.) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Transform") + bool bAlignToSurface = true; + + // Offset along the surface normal (cm). Positive = lift off the surface, negative = sink in. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Transform") + float SurfaceOffset = 0.0f; + + // WORLD-space position offset (cm) added after the surface snap (e.g. +Z lifts a sun off the sky-cap). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Transform") + FVector LocationOffset = FVector::ZeroVector; + + // Fixed rotation applied on top of the (optional) surface alignment. Use Yaw here to face a prop + // roughly one way (pair with RandomRotation.Yaw for banded variation — replaces the old MinYaw/MaxYaw). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Transform") + FRotator RotationOffset = FRotator::ZeroRotator; + + // Per-axis RANDOM rotation range (degrees) — each instance gets a hash-deterministic ±value/2 on each + // axis. Yaw alone = spin variety (360 = full random heading, the decoration default); all three = + // tumbled-debris look. 0 on an axis = no randomisation there. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Transform") + FRotator RandomRotation = FRotator::ZeroRotator; + + // Uniform scale range (hash-random per instance). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Transform") + float MinScale = 1.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Transform") + float MaxScale = 1.0f; + + // ----- Render (InstancedMesh / StaticMeshComponent path) ----- + + // Distance (cm) past which the mesh stops drawing. 0 = never cull (correct for trees / far-visible + // suns). THE lever that makes dense groundcover affordable — grass drawn only near the player. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Render", meta = (ClampMin = "0.0")) + float CullDistance = 0.0f; + + // Whether the instances cast dynamic shadows. Dense instanced shadows are the single biggest cost of + // heavy foliage — turn OFF for grass / small clutter, leave ON for trees and large props. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Placement|Render") + bool bCastShadow = true; +}; + +/** + * FDecoSubCompanion — a LEVEL-2 satellite: clutter that spawns ON a level-1 companion (moss on a rock). + * Distinct type (not a self-recursive FDecoCompanion, which UHT can't reflect) so nesting caps at 2 levels. + * Always INHERITS its level-1 parent's snapped surface point (no per-satellite re-solve → nesting stays cheap); + * may still gate on its own Profile.Conditions. Small radii — it sits on its parent. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FDecoSubCompanion +{ + GENERATED_BODY() + + FDecoSubCompanion() + { + Profile.MinScale = 0.8f; + Profile.MaxScale = 1.2f; + Profile.RandomRotation.Yaw = 360.0f; + } + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "SubCompanion", meta = (ShowOnlyInnerProperties)) + FPlacementProfile Profile; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "SubCompanion", meta = (ClampMin = "0.0")) + float RadiusMinVox = 1.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "SubCompanion", meta = (ClampMin = "0.0")) + float RadiusMaxVox = 3.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "SubCompanion", meta = (ClampMin = "0")) + int32 CountMin = 1; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "SubCompanion", meta = (ClampMin = "0")) + int32 CountMax = 2; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "SubCompanion", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float Probability = 1.0f; +}; + +/** + * FDecoCompanion — a level-1 cluster satellite that spawns near each placed instance of its parent decoration + * (F7 relational placement). Deterministic (pure function of the parent's hash → no search), re-snaps to the + * real surface at its own XY (bSnapToSurface), may gate on its own Conditions, and may itself carry LEVEL-2 + * `SubCompanions` (moss on a rock). E.g. a tree lists rocks + mushrooms; a rock lists moss. Two levels max + * (per-parent budget caps the total); deeper nesting is a later concern. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FDecoCompanion +{ + GENERATED_BODY() + + FDecoCompanion() + { + // Same clutter defaults as a decoration entry (variety scale + full random yaw). + Profile.MinScale = 0.8f; + Profile.MaxScale = 1.2f; + Profile.RandomRotation.Yaw = 360.0f; + } + + // What to spawn + how it looks (mesh/actor, transform, render). Same vocabulary as the parent. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Companion", meta = (ShowOnlyInnerProperties)) + FPlacementProfile Profile; + + // Disk (in VOXELS) around the parent that satellites scatter into. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Companion", meta = (ClampMin = "0.0")) + float RadiusMinVox = 2.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Companion", meta = (ClampMin = "0.0")) + float RadiusMaxVox = 6.0f; + + // How many satellites per parent (inclusive range, deterministic per parent). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Companion", meta = (ClampMin = "0")) + int32 CountMin = 1; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Companion", meta = (ClampMin = "0")) + int32 CountMax = 3; + + // Chance this companion type fires at all, per parent (0-1). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Companion", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float Probability = 1.0f; + + // Snap each satellite to the REAL surface at its own XY (fixes floaters on uneven ground; respects the + // companion's own SurfacePlacement). Cheap on SurfaceWorld (height oracle), a short ray-march in caves. + // OFF = inherit the parent's exact height + normal (cheapest — fine only on flat ground). A satellite + // that finds no surface at its spot is simply skipped (no floater). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Companion") + bool bSnapToSurface = true; + + // LEVEL-2 clutter that spawns ON each of THIS companion's satellites (e.g. this = rock, sub = moss). + // Inherits the satellite's surface point (no extra surface find). Capped by the per-parent budget. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Companion") + TArray SubCompanions; +}; + /** * FStrateDecoration — One decoration type that can spawn on surfaces. * - * Decorations are actors placed ON the cave surface (stalactites on ceilings, - * mushrooms on floors, crystals on walls, etc.). - * The decoration placer (future system) reads these entries from the active - * strate definition and spawns actors accordingly. + * Placed ON the cave surface (stalactites on ceilings, mushrooms on floors, crystals on walls). + * All the placement/transform/render settings live on the shared `Profile`; this struct adds only + * decoration's own DISTRIBUTION fields (per-column dense grid, spawn density, per-chunk cap). */ USTRUCT(BlueprintType) struct VOXELFORGE_API FStrateDecoration { GENERATED_BODY() - // The actor class to spawn (e.g., BP_Stalactite, BP_CrystalCluster). - // Real actors: lights, logic, interaction. They cost game-thread time per instance — - // prefer InstancedMesh for pure visual props, and consider the Far tier so the coarse grid keeps - // their spawn count down. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration") - TSubclassOf ActorClass; + FStrateDecoration() + { + // Decoration defaults that differ from FPlacementProfile's neutral defaults: variety scale range + // and a full random yaw (reproduces the legacy bRandomYaw = true, MinYaw/MaxYaw = 0..360 look). + Profile.MinScale = 0.8f; + Profile.MaxScale = 1.2f; + Profile.RandomRotation.Yaw = 360.0f; + } - // INSTANCED path: if set, this entry renders as batched HISM instances instead of - // spawning ActorClass (which is then ignored). No tick, no per-actor overhead, - // engine-culled — orders of magnitude cheaper. Use for everything that doesn't need - // logic/lights/interaction; an emissive material still glows at distance without a light. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration") - UStaticMesh* InstancedMesh = nullptr; + // Shared placement/transform/render settings. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ShowOnlyInnerProperties)) + FPlacementProfile Profile; // Which of the two decoration streaming grids this entry uses (§8.5). Far (default) = full radius + // coarse column grid (cheap for rare/large props visible everywhere); Near = short radius + fine // column grid (dense groundcover near the player only). The radius/spacing presets live on - // VoxelSettings; this only PICKS a grid. Defaults reproduce the legacy single-radius fine grid until - // you opt into a coarser far spacing or move an entry to Near. (Replaces the old vestigial MaxLODLevel.) + // VoxelSettings; this only PICKS a grid. UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration") EDecoStreamTier StreamTier = EDecoStreamTier::Far; - // Which surface type this decoration can be placed on - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration") - ESurfaceType SurfacePlacement = ESurfaceType::Any; - - // Maximum surface tilt (degrees from flat) this decoration tolerates. The surface tilt is - // acos(|normal.Z|): 0 = perfectly flat floor/ceiling, 90 = vertical wall. Grass on gentle - // ground → ~30-40; rock/lichen that clings to slopes → 90 (no filter, the default). - // 90 → place anywhere (default, no filter) · 35 → grass that avoids cliffs · 15 → flats only - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0.0", ClampMax = "90.0")) - float MaxSlopeAngle = 90.0f; - - // Minimum surface tilt (degrees from flat) — the LOWER companion to MaxSlopeAngle. Rejects surfaces - // FLATTER than this, so a prop can be kept OFF flat ground and restricted to slopes / walls. Same - // metric as MaxSlopeAngle: acos(|normal.Z|), 0 = flat, 90 = vertical. Pair the two to band a prop - // onto a tilt range (e.g. 30..70 = slopes only, never flats or sheer walls). - // 0 → no filter (default) · 45 → slopes & walls only · 70 → near-vertical only - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0.0", ClampMax = "90.0")) - float MinSlopeAngle = 0.0f; - - // Chance per valid surface point to spawn this decoration (0-1) - // 0.01 = rare, 0.1 = common, 0.5 = very dense + // Chance per valid surface point to spawn this decoration (0-1). + // 0.01 = rare, 0.1 = common, 0.5 = very dense. UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0.0", ClampMax = "1.0")) float SpawnDensity = 0.05f; - // Random scale range for variety - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration") - float MinScale = 0.8f; - - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration") - float MaxScale = 1.2f; - - // ----- Placement rules ----- - - // Rotate the actor so its up-axis follows the surface normal (stalactites point - // down on ceilings, plants stand up on floors). If false, keeps world-up. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement") - bool bAlignToSurface = true; - - // Apply a deterministic random yaw so instances don't all face the same way. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement") - bool bRandomYaw = true; - - // When bRandomYaw is set, constrain the random yaw to [MinYaw, MaxYaw] degrees instead of a full - // turn. Lets a prop face roughly one way with a little variation (wind-bent grass: 80..100). The - // default 0..360 is a full unrestricted turn — byte-identical to the legacy behaviour. Ignored when - // bRandomYaw is false. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement", - meta = (EditCondition = "bRandomYaw", ClampMin = "0.0", ClampMax = "360.0")) - float MinYaw = 0.0f; - - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement", - meta = (EditCondition = "bRandomYaw", ClampMin = "0.0", ClampMax = "360.0")) - float MaxYaw = 360.0f; - - // Wall props only: exclude downward-facing OVERHANGS. A "wall" is any surface between floor and - // ceiling (|normal.Z| <= 0.5), which still includes surfaces that lean slightly DOWNWARD (overhang - // ceilings). For props that must sit on upright walls (vines, wall torches) set this so only normals - // with Z >= 0 (vertical or up-leaning) qualify. Ignored unless the point resolves as a wall. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement") - bool bWallExcludeOverhangs = false; - - // Offset along the surface normal (world units). Positive = lift off the surface, - // negative = sink into it. Useful to embed roots or float crystals slightly. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement") - float SurfaceOffset = 0.0f; - // Hard cap on how many of THIS decoration spawn per chunk (perf safety). - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement", meta = (ClampMin = "1")) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "1")) int32 MaxPerChunk = 40; - // Only place where this is below the strate water line (true) or above it (false). - // Ignored unless RequireWaterRelative is set. Lets you put seaweed underwater and - // grass above water in the same strate. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement") - bool bRequireWaterRelative = false; - - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement", - meta = (EditCondition = "bRequireWaterRelative")) - bool bPlaceBelowWater = false; - - // ----- Performance (HISM render tuning — only affects the InstancedMesh path) ----- - - // Distance (world units / cm) past which instances stop rendering. This is THE lever that makes - // DENSE groundcover affordable: grass can be placed thickly but only drawn near the player, so the - // GPU cost is bounded by area-within-cull, not by the whole streaming radius. 0 = never cull (the - // default — correct for trees / large props you want visible to the horizon). - // 0 → no cull (props, trees) - // 2000 → ~20 m, typical grass / small ground clutter - // 4000 → ~40 m, taller plants you want visible a bit further - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Performance", meta = (ClampMin = "0.0")) - float CullDistance = 0.0f; - - // Whether these instances cast dynamic shadows. Dense instanced shadows are the single biggest cost - // of heavy foliage — turn this OFF for grass / small clutter (an unlit-from-below tuft loses almost - // nothing visually). Leave ON for trees and anything large enough that its shadow reads as grounding. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Performance") - bool bCastShadow = true; + // Cluster satellites scattered around each placed instance of THIS decoration (F7 relational placement) — + // e.g. a tree → rocks + mushrooms. Deterministic, one level, inherits this entry's surface point. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Companions") + TArray Companions; }; /** - * FStrateLandmark — A RARE, large, far-visible object placed on a coarse HASH LATTICE (§8.5). + * ELandmarkAnchor — how a landmark's candidate anchor points are chosen. Feature-conditioning + * (peaks / wet lowlands / biome edges) rides on TOP of either mode via Profile.Conditions. + */ +UENUM(BlueprintType) +enum class ELandmarkAnchor : uint8 +{ + HashLattice UMETA(DisplayName = "Hash Lattice (scattered)"), // scatter on a coarse lattice + PassageMouth UMETA(DisplayName = "Passage Mouth (at descents)"), // at passage endpoints in this strate +}; + +/** + * FStrateLandmark — a RARE, deliberately-placed object: mini-suns, ruins, shrines, monuments (§8.5, F7). + * The one placement primitive for "notable things you navigate by" (set-pieces folded in here 2026-07-06). * * This is the right primitive for things like the underground "mini-suns" (in-lore light sources): one * object per ~`SpacingChunks` lattice cell, so the work scales with how MANY landmarks are in range @@ -1866,109 +2150,97 @@ struct VOXELFORGE_API FStrateLandmark { GENERATED_BODY() - // ----- What to spawn (one of these; ActorClass wins if both set) ----- + FStrateLandmark() + { + // Landmark defaults that differ from FPlacementProfile's neutral defaults: suns sit on the sky-cap + // ceiling and stay world-upright regardless of the ceiling tilt. + Profile.SurfacePlacement = ESurfaceType::Ceiling; + Profile.bAlignToSurface = false; + } - // Real actor — use this for a sun that carries its own LIGHT / logic. Rare, so the per-actor cost is fine. + // Shared placement/transform/render settings (what to spawn, surface/slope/water gates, transform, + // cull/shadow). A sun that carries its own light/logic goes in Profile.ActorClass; a plain glowing mesh + // in Profile.InstancedMesh. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark", meta = (ShowOnlyInnerProperties)) + FPlacementProfile Profile; + + // How candidate anchor points are chosen. Feature-conditioning (peaks / wet lowlands / biome edges) + // rides on TOP of either mode via Profile.Conditions. UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark") - TSubclassOf ActorClass; + ELandmarkAnchor AnchorMode = ELandmarkAnchor::HashLattice; - // OR a plain static mesh (spawned as one StaticMeshComponent — no actor/tick overhead). An emissive - // material glows at distance without a light. Ignored if ActorClass is set. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark") - UStaticMesh* InstancedMesh = nullptr; + // ----- Hash lattice (AnchorMode == HashLattice — scattered placement; cheap at any radius) ----- - // ----- Rarity / spacing (the hash lattice — this is what makes it cheap) ----- - - // Average spacing between landmarks, IN CHUNKS. This is the lattice cell size: exactly one candidate is - // considered per SpacingChunks×SpacingChunks cell, so cost scales with (radius/spacing)². This is also - // the primary "distance between two instances" control. Large = rare & far apart. - // 16 → fairly frequent landmarks · 64 → sparse (good default) · 256+ → one every few km - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Spacing", meta = (ClampMin = "1.0")) + // Average spacing between instances, IN CHUNKS = the lattice cell size (one candidate per cell, so cost + // scales with (radius/spacing)²). Large = rare & far apart. 16 → frequent · 64 → sparse · 256+ → km-scale + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Lattice", + meta = (EditCondition = "AnchorMode == ELandmarkAnchor::HashLattice", EditConditionHides, ClampMin = "1.0")) float SpacingChunks = 64.0f; - // How far within its cell a candidate may wander (0 = dead-centre grid, 1 = anywhere in the cell). - // The effective MINIMUM spacing between two instances ≈ SpacingChunks·(1 − JitterFraction); keep it - // below 1 to preserve a spacing guarantee while still breaking up the grid regularity. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Spacing", meta = (ClampMin = "0.0", ClampMax = "1.0")) + // How far within its cell a candidate may wander (0 = dead-centre, 1 = anywhere). Min spacing between two + // ≈ SpacingChunks·(1 − JitterFraction). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Lattice", + meta = (EditCondition = "AnchorMode == ELandmarkAnchor::HashLattice", EditConditionHides, ClampMin = "0.0", ClampMax = "1.0")) float JitterFraction = 0.5f; - // Probability that a lattice cell actually contains this landmark (0-1). Combine with SpacingChunks for - // "rare AND well-spaced": SpacingChunks sets the grid, SpawnProbability sets how many slots fill. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Spacing", meta = (ClampMin = "0.0", ClampMax = "1.0")) + // Probability that a lattice cell actually contains this instance (0-1). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Lattice", + meta = (EditCondition = "AnchorMode == ELandmarkAnchor::HashLattice", EditConditionHides, ClampMin = "0.0", ClampMax = "1.0")) float SpawnProbability = 1.0f; - // How far out (in chunks) landmarks stream / stay visible. CHEAP to make large here (the lattice means a - // 2048-chunk radius is still only ~(2048/Spacing)² candidates). Set big enough that a massive object - // never pops in at a jarring distance. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Spacing", meta = (ClampMin = "1")) + // ----- Passage mouths (AnchorMode == PassageMouth — at the passages threading this strate) ----- + + // Place at the DESCENT mouth — where a passage LEAVES this strate downward (the hole going down; e.g. a + // guardian over the descent). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Passage", + meta = (EditCondition = "AnchorMode == ELandmarkAnchor::PassageMouth", EditConditionHides)) + bool bAtDescentMouths = true; + + // Place at the ARRIVAL mouth — where a passage ENTERS this strate from above (where you land; e.g. a + // shrine at the bottom of the climb). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Passage", + meta = (EditCondition = "AnchorMode == ELandmarkAnchor::PassageMouth", EditConditionHides)) + bool bAtArrivalMouths = true; + + // Per-mouth chance to place (0-1). Deterministic. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Passage", + meta = (EditCondition = "AnchorMode == ELandmarkAnchor::PassageMouth", EditConditionHides, ClampMin = "0.0", ClampMax = "1.0")) + float MouthProbability = 1.0f; + + // ----- Shared ----- + + // How far out (in chunks) instances stream / stay visible. Cheap to make large (lattice candidate count + // scales with (radius/spacing)²; the passage list is finite). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark", meta = (ClampMin = "1")) int32 StreamRadiusChunks = 256; - // ----- Placement restriction (mirrors the base decoration gates) ----- + // ----- Exclusion (relational self-awareness — optional) ----- - // Optional: only place inside this biome (resolved at the candidate XY). Null = any biome in the strate. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Placement") - UVoxelBiomeDefinition* RequiredBiome = nullptr; + // A placed instance suppresses OTHERS whose anchor falls within this radius (chunks) so two never overlap. + // 0 = OFF (the default — pure scatter; mini-suns don't exclude). Conflicts resolve deterministically: + // higher Priority wins, ties by hash. (v1 resolves within the streamed set; a fully position-independent, + // pop-free resolve is a planned follow-up.) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Exclusion", meta = (ClampMin = "0.0")) + float ExclusionRadiusChunks = 0.0f; - // Which surface to snap to. Suns typically sit on the sky-cap CEILING; set Floor for ground monuments, - // Any for the first surface found. Wall-leaning surfaces are matched by the same normal test as decos. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Placement") - ESurfaceType SurfacePlacement = ESurfaceType::Ceiling; + // Higher wins an exclusion conflict (a major shrine outranks scattered ruins). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Exclusion") + int32 Priority = 0; - // Surface-tilt band (deg from flat = acos(|normal.Z|); 0 = flat, 90 = vertical). MaxSlopeAngle rejects - // surfaces STEEPER than it (90 = no filter); MinSlopeAngle rejects surfaces FLATTER than it (0 = none). - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Placement", meta = (ClampMin = "0.0", ClampMax = "90.0")) - float MaxSlopeAngle = 90.0f; + // ----- Decoration footprint (clear groundcover under the object so it doesn't clip through) ----- - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Placement", meta = (ClampMin = "0.0", ClampMax = "90.0")) - float MinSlopeAngle = 0.0f; + // Remove decorations (grass etc.) within a radius of this landmark, so foliage doesn't poke through a + // temple floor. Uses the same instance-removal as player digging (cleared on spawn AND when decorations + // stream in near it). A landmark mesh doesn't change density, so this is the only thing that clears under it. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Footprint") + bool bSuppressDecorationsUnder = false; - // Water-relative gate (ignored unless the strate has a water table): place only below (true) / above - // (false) the water line when bRequireWaterRelative is set. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Placement") - bool bRequireWaterRelative = false; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Footprint", + meta = (EditCondition = "bSuppressDecorationsUnder", ClampMin = "0.0")) + float SuppressRadiusChunks = 2.0f; - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Placement", meta = (EditCondition = "bRequireWaterRelative")) - bool bPlaceBelowWater = false; - - // ----- Transform tweaks (foliage-style) ----- - - // Rotate the object so its up-axis follows the surface normal. OFF by default — a sun usually wants to - // stay world-upright regardless of the ceiling tilt. ON makes it lie against the surface. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Transform") - bool bAlignToSurface = false; - - // WORLD-space position offset (cm) added after the surface snap. E.g. +Z lifts a sun up off the - // sky-cap into the open cavern; use X/Y to nudge it off the exact column. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Transform") - FVector LocationOffset = FVector::ZeroVector; - - // Fixed rotation applied on top of the (optional) surface alignment. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Transform") - FRotator RotationOffset = FRotator::ZeroRotator; - - // Per-axis RANDOM rotation range (degrees) — each instance gets a hash-deterministic ±value/2 on each - // axis (Pitch/Yaw/Roll). 0 on an axis = no randomisation there. Yaw alone = spin variety; all three = - // tumbled debris look. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Transform") - FRotator RandomRotation = FRotator::ZeroRotator; - - // Uniform scale range (hash-random per instance). - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Transform") - float MinScale = 1.0f; - - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Transform") - float MaxScale = 1.0f; - - // ----- Render tuning (the InstancedMesh / StaticMeshComponent path) ----- - - // Distance (cm) past which the mesh stops drawing. 0 = NEVER cull (the right choice for a far-visible - // sun). Only affects the InstancedMesh path. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Performance", meta = (ClampMin = "0.0")) - float CullDistance = 0.0f; - - // Whether the mesh casts a shadow. Only affects the InstancedMesh path. - UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Performance") - bool bCastShadow = true; + // (Placement gates, transform tweaks, and cull/shadow render tuning live on the shared `Profile` above — + // see FPlacementProfile. Landmark-specific defaults: Ceiling surface + no surface-align.) // ----- MINI-SUN LIGHT ORB (feeds the terrain material's raymarched shadows) ----- // When set, this landmark is also a LIGHT SOURCE: the terrain material marches the density volume diff --git a/Source/VoxelForge/Public/VoxelTypes.h b/Source/VoxelForge/Public/VoxelTypes.h index 7071ab7..cb74d9f 100644 --- a/Source/VoxelForge/Public/VoxelTypes.h +++ b/Source/VoxelForge/Public/VoxelTypes.h @@ -231,6 +231,12 @@ struct FVoxelMeshData TArray Colors; // Masques matériau F6 (R=palette biome dominant, G=pente, // B=poids de fondu de bordure, A=palette biome voisin) + // F17 — classe de surface par TRIANGLE, portée par l'ORDRE du buffer d'indices : les + // NumCeilingTriangles DERNIERS triangles de Triangles sont la classe "plafond sky-cap" + // (polygroup 1 → slot matériau 1, sans ombre) ; tout ce qui précède est "sol/roche" + // (polygroup 0). Runs contigus exigés par RMC (une section par polygroup). + int32 NumCeilingTriangles = 0; + void Clear() { Vertices.Empty(); @@ -238,6 +244,7 @@ struct FVoxelMeshData UVs.Empty(); Normals.Empty(); Colors.Empty(); + NumCeilingTriangles = 0; } bool IsEmpty() const { return Vertices.Num() == 0; } diff --git a/Source/VoxelForge/Public/VoxelWorld.h b/Source/VoxelForge/Public/VoxelWorld.h index dca9fc4..896da85 100644 --- a/Source/VoxelForge/Public/VoxelWorld.h +++ b/Source/VoxelForge/Public/VoxelWorld.h @@ -27,6 +27,39 @@ class UMaterialInterface; class UMaterialInstanceDynamic; namespace RealtimeMesh { struct FRealtimeMeshStreamSet; } // T1.f — worker-built geometry buffers +/** + * How a streaming anchor wants its tiles built (ARCHITECTURE §9.3). The local player is an implicit + * FullVisual anchor (the clipmap). Extra anchors — AI, and later remote players — are registered so + * their surroundings stream too (so they physically exist away from the local camera). + * + * `CollisionOnly` tiles cook collision but are hidden (`SetVisibility(false)`) — no draw / VSM cost — + * UNLESS the player clipmap also wants that exact level-0 tile (then it renders normally). This is the + * §9.4 render-skip: it kills the cost of terrain around AI/remote players far from the local camera. + * (Geometry streams are still built on the worker — off the frame; a deeper "no stream build at all" + * collision-only path is a later optimization.) + */ +UENUM(BlueprintType) +enum class EVoxelAnchorPolicy : uint8 +{ + CollisionOnly UMETA(DisplayName = "Collision Only"), // enough to stand / pathfind / hit + FullVisual UMETA(DisplayName = "Full Visual") // rendered too (a second local viewpoint) +}; + +/** A non-player streaming center: keep a small box of level-0 tiles loaded around Actor so it has + * collision wherever it is. Plain internal struct (weak ptr is GC-safe without reflection). */ +struct FVoxelStreamingAnchor +{ + TWeakObjectPtr Actor; + EVoxelAnchorPolicy Policy = EVoxelAnchorPolicy::CollisionOnly; + // Thin box around the anchor (level-0 chunks). Default = the chunk it's in + 1 on each horizontal + // edge (3×3) + 1 chunk BELOW (ground safety when its capsule sits near a chunk's bottom); nothing + // above (a grounded NPC doesn't need it — raise ZAbove for flyers). Empty tiles in the box are ~free. + int32 XYRadiusChunks = 1; // horizontal Chebyshev radius (0 = its own column) + int32 ZBelowChunks = 1; // chunks below the anchor's chunk + int32 ZAboveChunks = 0; // chunks above + FIntVector LastChunk = FIntVector(MAX_int32, MAX_int32, MAX_int32); // move detection (rebuild trigger) +}; + /** * AVoxelWorld - The main voxel terrain actor * @@ -51,11 +84,18 @@ struct FChunkResult TSharedPtr Streams; uint32 Epoch = 0; // Generation epoch — discard if stale bool bEmpty = true; // true ⇒ all-air tile (Streams null); still marked loaded so we don't re-submit - // Ceiling classification from the ACTUAL mesh normals (down-facing geometry = sky-cap ceiling), - // computed on the worker where Normals are free. Authoritative — can't disagree with the rendered - // view the way a game-thread height-oracle sample did (it misclassified coarse far tiles). The - // game thread still gates this to SurfaceWorld strates before applying CeilingMaterial / no-shadow. - bool bIsCeiling = false; + // F17 — the mesher classifies every triangle semantically (sky-cap = down-facing near the + // column's CeilSurf; overhangs/cave roofs stay ground) and packs them as two contiguous runs + // (ground then cap) in the index buffer → polygroups 0/1 → two RMC sections with their own + // material + shadow flag. These tell the apply path which sections exist (RMC only creates a + // section for a non-empty polygroup — configuring a missing one is invalid). + bool bHasGroundTris = false; + bool bHasCeilingTris = false; + // STRATE CONTENT CUT — the chunk-Z band this tile was MESHED with (MIN/MAX = uncut). The + // apply path clamps its strate/material lookups into it (a coarse tile's raw min corner can + // sit in a strate whose content was cut out of the mesh entirely). + int32 BandChunkLo = MIN_int32; + int32 BandChunkHi = MAX_int32; // CAPTURE-DURING-MESHING: the tile's CHUNK_SIZE³ R8 density grid, captured by the mesher (no extra // GetDensityAt). Non-empty only for capture-eligible tiles (level 0, full-res). The game thread hands // it to UVoxelDensityVolume::IngestTileCapture so the density clipmap reuses the mesher's samples @@ -151,7 +191,26 @@ public: * because FVoxelTileKey isn't a USTRUCT key). */ TMap TileComponents; + /** T2.c — COMPONENT POOL. Unloading a tile parks its component here (geometry + collision + * stripped via RemoveSectionGroup, hidden, still registered) instead of DestroyComponent; + * ApplyMeshToTile pops from here instead of NewObject + RegisterComponent. Kills the + * create/register/GC churn of fast travel and regen bursts. Same GC-safety rationale as + * TileComponents (registered components are owned by the actor). Bounded — overflow is + * destroyed for real. */ + TArray TileComponentPool; + static constexpr int32 MaxPooledTileComponents = 256; + /** Pop a pooled tile component (made visible again) or create + register a fresh one. */ + URealtimeMeshComponent* AcquireTileComponent(); + + /** Park a tile component in the pool (strip geometry/collision, hide) — or destroy it + * for real when the pool is full. */ + void ReleaseTileComponent(URealtimeMeshComponent* Comp); + + /** T2.d — the effective concurrent gen-task budget: the asset's MaxConcurrentTasks, + * capped to (logical cores − 2) so small CPUs don't thrash on a flat 16 (background + * priority stops frame starvation, not the context-switch overhead). */ + int32 GetMaxConcurrentTasks() const; //========================================================================= // TERRAIN MODIFICATION (player carving & filling) @@ -271,6 +330,37 @@ public: UFUNCTION(BlueprintCallable, Category = "Voxel World|Biome") FVoxelBiomeQuery GetBiomeAtWorldLocation(FVector WorldLocation) const; + /** + * SurfaceWorld ground finder for self-arranging prefabs (ruins/set-pieces authored as Blueprints): + * the terrain surface + sky-cap ceiling world-Z under WorldLocation's XY, WITHOUT any line trace or + * streamed collision. Deterministic and available before the area meshes. Returns false (outs = the + * input Z) when the point isn't a SurfaceWorld heightfield (cave strates) — fall back to a trace there. + * Does NOT account for passage/spine carving; re-check with a trace if the spot might be carved. + */ + UFUNCTION(BlueprintCallable, Category = "Voxel World|Query") + bool GetVoxelSurfaceHeightAt(FVector WorldLocation, float& OutSurfaceWorldZ, float& OutCeilingWorldZ) const; + + //========================================================================= + // STREAMING ANCHORS — keep terrain (collision) loaded around actors that aren't the local player + // (AI now; remote players later). ARCHITECTURE §9.3. Registering an actor folds a small box of + // level-0 tiles around it into the desired set, so it has ground to stand on / pathfind / be hit + // even far from the camera. Idempotent per actor (re-register updates the policy/radius). + //========================================================================= + + /** Start streaming terrain around Actor. Default box = its chunk + 1 horizontal ring + 1 chunk below + * (ground safety), nothing above. Empty tiles are ~free (trivial-tile reject); with the CollisionOnly + * policy the SOLID tiles cook collision but don't render (§9.4). Raise ZAbove for flyers/tall NPCs. */ + UFUNCTION(BlueprintCallable, Category = "Voxel World|Streaming") + void RegisterStreamingAnchor(AActor* Actor, + EVoxelAnchorPolicy Policy = EVoxelAnchorPolicy::CollisionOnly, + int32 XYRadiusChunks = 1, + int32 ZBelowChunks = 1, + int32 ZAboveChunks = 0); + + /** Stop streaming terrain around Actor. Its tiles are released by the normal delta cull. */ + UFUNCTION(BlueprintCallable, Category = "Voxel World|Streaming") + void UnregisterStreamingAnchor(AActor* Actor); + //========================================================================= // LIGHTING — DENSITY VOLUME (debug / material wiring) //========================================================================= @@ -307,11 +397,10 @@ private: TVP6 = FLinearColor::Black, TVP7 = FLinearColor::Black, TVP8 = FLinearColor::Black, TVP9 = FLinearColor::Black; - // Change-detection for the per-Tick pushes: MID vector/texture sets and MPC writes each enqueue - // render-thread updates, so skip them entirely on the (common) frames where nothing moved. + // Change-detection for the per-Tick MID pushes (MIDs OWN their param values, so skip-if-identical + // is safe there — unlike the MPC, whose world instance can reset behind our back; see + // UpdateOrbLightMPC, which deliberately rewrites every frame). TWeakObjectPtr LastBoundVolTex0; // re-push MIDs if the L0 texture was recreated - FLinearColor LastOrbMPC[4] = { FLinearColor(FLT_MAX, 0, 0, 0), FLinearColor(FLT_MAX, 0, 0, 0), - FLinearColor(FLT_MAX, 0, 0, 0), FLinearColor(FLT_MAX, 0, 0, 0) }; public: @@ -336,6 +425,15 @@ public: UFUNCTION(CallInEditor, BlueprintCallable, Category = "Live Edit") void RebuildStrates(); + /** F2 — DETERMINISM VALIDATOR (run during PIE, takes ~a second). Samples a band of + * densities at a chunk boundary near the player through TWO cache-window alignments + * (thread_local chunk caches warmed from the left chunk, then from the right one, same + * points re-sampled) plus a same-alignment repeat. Every delta MUST be exactly 0 — + * anything else is a window-invariance regression (ARCHITECTURE §8.4). Logs the verdict + * and the first offending voxel. Run it after any "bit-identical" hot-path refactor. */ + UFUNCTION(CallInEditor, BlueprintCallable, Category = "Live Edit") + void ValidateDeterminism(); + //========================================================================= // EDITOR BRUSH (manual carve/fill from the Details panel, works in PIE) //========================================================================= @@ -448,7 +546,35 @@ public: * * @param ChunkCoord - Which chunk to load */ - void LoadTile(const FVoxelTileKey& Tile); + void LoadTile(const FVoxelTileKey& Tile, bool bHighPriority = false); + + /** + * Worker-side gen for one tile: classify → GenerateMesh/GenerateSheetMesh → BuildTileStreamSet. + * Fills Result (no enqueue, no bookkeeping). Called from the async ChunkGen task AND from the + * synchronous carve path (SyncRemeshTile) — reads Generator/Mesher only, so it's safe on either + * thread. See LoadTile for how the parameters are derived. + */ + void GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector& OriginVoxels, + int32 Step, int32 Cells, uint32 Epoch, bool bWantCapture, + int32 BandVoxLo, int32 BandVoxHi, int32 BandChunkLo, int32 BandChunkHi, + bool bSheetTile, int32 SheetChunkZ, + int32 HoleMinX, int32 HoleMinY, int32 HoleMaxX, int32 HoleMaxY, + FChunkResult& Result); + + /** + * Game-thread apply for one gen result (shared by ProcessPendingChunks + SyncRemeshTile): + * epoch check, mark loaded, ingest capture, then either release the tile's component (empty) or + * ApplyMeshToTile. Returns true iff a VISIBLE mesh was uploaded (counts against the apply budget). + * Does NOT touch PendingTiles — the caller owns that. + */ + bool ApplyTileResult(FChunkResult& Result); + + /** + * Same-frame level-0 re-mesh on the game thread: gen + apply INLINE so a player carve is visible + * THIS frame (no async round-trip). Used for the tile under the brush centre; neighbours re-mesh + * async (prioritised) via RemeshDirtyChunks. One full-res tile gen on the game thread — bounded. + */ + void SyncRemeshTile(const FVoxelTileKey& Tile); /** * Unload a single chunk. @@ -466,14 +592,19 @@ public: * * The vertex/index buffers (Streams) are already BUILT on the worker (T1.f — see * BuildTileStreamSet / FChunkResult), so this only does the game-thread-only work: - * ceiling/material resolution, get-or-create the component, CreateSectionGroup(MoveTemp), - * and section config (collision/shadow). Never called for empty tiles. + * material resolution, get-or-create the component, CreateSectionGroup(MoveTemp), + * and per-section config (collision/shadow). Never called for empty tiles. * - * @param Tile - Which clipmap tile this mesh belongs to - * @param Streams - Pre-built RMC geometry buffers (consumed/moved) - * @param bGeomCeiling - Worker's geometry-normal ceiling vote (gated to SurfaceWorld here) + * F17 — the streams carry TWO polygroups (0 = ground, 1 = sky-cap ceiling, classified + * semantically per triangle on the worker): RMC auto-creates one section per non-empty + * group, so a coarse tile spanning both the terrain and the cap gets BOTH materials + * (the old whole-tile vote painted the loser with the winner's material). + * + * Takes the whole FChunkResult (tile key, streams — consumed/moved —, per-group flags and + * the strate content band the mesh was cut to; material lookups clamp into that band). + * Never called for empty results. */ - void ApplyMeshToTile(const FVoxelTileKey& Tile, RealtimeMesh::FRealtimeMeshStreamSet&& Streams, bool bGeomCeiling); + void ApplyMeshToTile(FChunkResult& Result); /** Mini-sun lighting (bounded directional). Each frame writes the nearest 4 active orbs' WORLD * positions (+ reach radius in .w) into OrbLightMPC's Orb0..3 vector params; the Directional @@ -486,8 +617,10 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Lighting") UMaterialParameterCollection* OrbLightMPC = nullptr; - /** Build the clipmap desired-tile set (concentric shells) around the player tile. */ - void BuildDesiredTiles(const FIntVector& CenterChunkCoord); + /** Build the clipmap desired-tile set (concentric shells) around the player tile. + * OutLeavers = les tuiles désirées au crossing PRÉCÉDENT qui ne le sont plus — les seuls + * candidats au cull (delta), au lieu de re-scanner TOUTES les tuiles chargées par crossing. */ + void BuildDesiredTiles(const FIntVector& CenterChunkCoord, TArray& OutLeavers); /** True if a tile's world footprint is still within the outermost clip shell (so a * not-desired loaded tile there is mid-LOD-transition and must wait for its replacement, @@ -515,6 +648,32 @@ public: TQueue ProcessQueue; TSet PendingTiles; // tiles with a gen task in flight + // STRATE CONTENT CUT (see UVoxelSettings::StrateContentCutMinLevel) — the player-strate + // chunk-Z band coarse tiles are meshed to (MIN/MAX sentinels = no cut, e.g. in the gap). + // Updated each crossing in UpdateChunksAroundPosition; on change (strate transition) the + // loaded coarse tiles whose content depends on it are re-queued through BandRemeshQueue + // (drained by the budgeted submit loop — LoadTile re-gens in place, no visual pop). + int32 MeshBandChunkLo = MIN_int32; + int32 MeshBandChunkHi = MAX_int32; + TSet BandRemeshQueue; + + // DIG RESPONSIVENESS — loaded level-0 tiles touched by a player carve/fill that still need an + // async re-mesh (the neighbours of the synchronously-remeshed centre tile, plus any tile that was + // mid-gen at carve time). Drained FIRST in the submit loop (ahead of streaming + band) and launched + // at BackgroundHigh so a dig never waits behind streaming. Unlike the old inline RemeshDirtyChunks, + // a tile that's in flight is KEPT queued (not dropped) so the stale pre-carve result is corrected + // once it lands — the source of "the hole shows up a beat late, or not until I move". + TSet DirtyRemeshQueue; + + // F18 — TROU XY de l'anneau feuille (voxels ; Max EXCLUSIF ; sentinelles MAX/MIN = pas de + // trou) : la zone couverte par les coquilles MC (boîte niveau-MaxClipLevel autour du joueur, + // rétrécie d'une tuile pour garder un anneau de recouvrement au raccord) est DÉCOUPÉE des + // feuilles — sinon une feuille partiellement couverte recouvre le terrain proche avec son + // échantillonnage grossier. Mis à jour par crossing ; changement ⇒ re-queue des feuilles + // chevauchantes via BandRemeshQueue (re-gen en place). + int32 SheetHoleMinXVox = MAX_int32, SheetHoleMinYVox = MAX_int32; + int32 SheetHoleMaxXVox = MIN_int32, SheetHoleMaxYVox = MIN_int32; + // Set to true during EndPlay — async tasks check this before accessing UObjects std::atomic bShuttingDown{false}; @@ -524,6 +683,27 @@ public: // Player's level-0 tile coord (= chunk coord). The desired set is rebuilt when this changes. FIntVector CurrentCenterChunk = FIntVector::ZeroValue; + // Non-player streaming anchors (AI now, remote players later — ARCHITECTURE §9.3). BuildDesiredTiles + // folds each anchor's small level-0 box into the desired set (same DesiredStamped machinery → the + // delta cull releases an anchor's tiles automatically when it moves away / is unregistered). The + // desired-set rebuild also fires when any anchor crosses a chunk boundary (UpdateChunksAroundPosition). + TArray StreamingAnchors; + // Adds each anchor's level-0 tiles to DesiredSorted/DesiredStamped (dedup vs the player clipmap) + // and records the ones ONLY a CollisionOnly anchor wants in CollisionOnlyTiles. + void AddAnchorDesiredTiles(); + // Forces a desired-set rebuild next Tick even if neither the player nor an anchor crossed a tile + // boundary (set by UnregisterStreamingAnchor so a removed anchor's tiles get culled). + bool bForceDesiredRebuild = false; + + // §9.4 RENDER-SKIP — level-0 tiles wanted ONLY by CollisionOnly anchors (no player-clipmap / no + // FullVisual desirer this crossing): they cook collision but are hidden. Rebuilt each crossing in + // BuildDesiredTiles. `Prev` lets ReconcileAnchorTileVisibility toggle just the DELTA on already- + // loaded tiles when a tile flips render↔collision-only (player walks toward/away from a cluster), + // without an O(loaded) scan. Both empty when there are no CollisionOnly anchors → zero cost. + TSet CollisionOnlyTiles; + TSet PrevCollisionOnlyTiles; + void ReconcileAnchorTileVisibility(); + // --- Streaming work-avoidance (perf) --- // The desired tile set only changes when the player crosses a level-0 tile boundary. // We cache it and only rebuild/cull/sort on a real move, and go idle once every desired @@ -531,7 +711,36 @@ public: FIntVector LastUpdateCenter = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); bool bAllChunksLoaded = false; TArray DesiredSorted; // desired tiles, nearest-first - TSet DesiredSet; // O(1) membership for the cull pass + + // Desired-set membership STAMPÉE : clé → numéro du dernier crossing où la tuile était désirée. + // BuildDesiredTiles upserte le stamp courant puis balaie la map UNE fois : les entrées à stamp + // périmé sont les "leavers" (retirées + renvoyées). Le cull ne considère que ces leavers + la + // TransitionHold — fini le scan O(toutes-les-tuiles-chargées) à chaque crossing (le spike + // CullTiles ~1.6 ms/crossing de la trace 2026-07-05). + TMap DesiredStamped; + uint32 DesiredStamp = 0; + bool IsDesired(const FVoxelTileKey& T) const + { + const uint32* S = DesiredStamped.Find(T); + return S && *S == DesiredStamp; + } + + // Tuiles chargées qui ont quitté le desired set mais sont RETENUES (load-before-unload : leur + // remplacement n'est pas encore complet, ou le backlog a sauté le test de recouvrement). + // Re-considérées par BUDGET tournant (curseur sur la queue, ~256/crossing) — re-scanner TOUTE + // la hold par crossing redevient le vieux scan O(loaded) dès que le streaming ne "settle" + // jamais (mesuré 2.47 ms/crossing en packagé). Garder une tuile plus longtemps est toujours + // hole-safe. Le set est la MEMBERSHIP autoritaire ; la queue peut contenir des clés périmées + // (retirées paresseusement au scan). Le "settled cull" reste le filet de sécurité plein-scan. + TSet TransitionHold; + TArray TransitionHoldQueue; + int32 TransitionHoldCursor = 0; + void AddToTransitionHold(const FVoxelTileKey& T) + { + bool bAlready = false; + TransitionHold.Add(T, &bAlready); + if (!bAlready) { TransitionHoldQueue.Add(T); } + } // Tiles approved for removal but whose teardown (component destroy + content actor Destroy()) // is spread across frames. Unbudgeted, a fast traversal culls a whole shell's worth of tiles in @@ -550,6 +759,9 @@ public: * fresh with the diff layer when loaded normally). * * @param DirtyCoords - Chunk coordinates that need re-meshing + * @param ExcludeTile - optional level-0 tile already handled synchronously this frame + * (SyncRemeshTile): skipped for the async re-queue, but still marked dirty + * for the density volume. */ - void RemeshDirtyChunks(const TArray& DirtyCoords); + void RemeshDirtyChunks(const TArray& DirtyCoords, const FVoxelTileKey* ExcludeTile = nullptr); };