This commit is contained in:
2026-07-26 02:11:11 +02:00
parent cb61c8b2e4
commit 69fa73e07e
20 changed files with 3945 additions and 1071 deletions
+58 -20
View File
@@ -101,7 +101,7 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h).
| Group | Fields (line) | | Group | Fields (line) |
|-------|---------------| |-------|---------------|
| Streaming | `ViewDistanceXY=16`, `ViewDistanceUp/Down=5`, `MaxConcurrentTasks=16`, `MaxMeshAppliesPerFrame=4` (defaults — actual values live on the data asset) | | 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) | | Lighting | `bEnableDensityVolume` + DensityVolume* tunables (§3.11 density clipmap / mini-sun shadows) |
| Rendering | `VoxelMaterial` (61) | | Rendering | `VoxelMaterial` (61) |
| Strates | `Seed` (69), `CurrentSeason=1` (73), `StratePool` (78), `FixedStrates` map (83), `TotalStrates=10` (87) | | 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. | | `AVoxelWorld()` ctor | 12 | Enables Tick. |
| `RegenerateAllChunks()` | 21 | Bumps epoch, unloads all → Tick reloads. CallInEditor button. | | `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. | | `PostEditChangeProperty` | 45 | Editor live-edit hook. |
| `OnObjectModifiedInEditor` | 58 | Regenerates when a strate asset is edited (if `bLiveEditStrates`). | | `OnObjectModifiedInEditor` | 58 | Regenerates when a strate asset is edited (if `bLiveEditStrates`). |
| `EndPlay` | 140 | Sets `bShuttingDown`, **waits for `ActiveTaskCount`→0**, unbinds delegate. | | `EndPlay` | 140 | Sets `bShuttingDown`, **waits for `ActiveTaskCount`→0**, unbinds delegate. |
| `BeginPlay` | 177 | Constructs Generator/Mesher/StrateManager/DiffLayer, wires services, seeds. | | `BeginPlay` | 177 | Constructs Generator/Mesher/StrateManager/DiffLayer, wires services, seeds. |
| `Tick` | 220 | `UpdateChunksAroundPosition(player)` + `ProcessPendingChunks()`. | | `Tick` | 220 | `UpdateChunksAroundPosition(player)` + `ProcessPendingChunks()`. |
| `GetPlayerPosition` | 231 | Pawn position or zero. (`GetLODForChunk`/`LODToStep`/`IsChunkInRange` removed — dead since the clipmap.) | | `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. | | `ProcessPendingChunks` | 301 | Drains ProcessQueue under per-frame budget; applies each via `ApplyTileResult`. |
| `UpdateChunksAroundPosition` | 362 | Builds desired set, sorts by distance, loads/unloads, handles LOD changes. | | `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). |
| `LoadChunk` | 445 | Budget check → `UE::Tasks::Launch` background gen+mesh; RAII task guard. | | `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. |
| `UnloadChunk` | 493 | Destroys mesh component + map entries. | | `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. |
| `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. | | `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. |
| `ChunkToRegion` / `ChunkSectionGroupName` / `RemoveChunkFromRegion` / `DestroyIndividualChunkComponent` | — | Plumbing for the batched far-chunk scheme. | | `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. | | `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?"). | | `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. | | `ClearAllModifications` | 726 | Clears diff layer, regenerates. |
| `ChangeSeed` | 740 | **Season reset**: new seed everywhere, clear diffs, bump season, reload. | | `ChangeSeed` | 740 | **Season reset**: new seed everywhere, clear diffs, bump season, reload. |
| `GetCurrentSeed` / `GetCurrentSeason` | 784 / 789 | Accessors. | | `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` ### 3.6 Density generator — `Public/VoxelGenerator.h` + `Private/VoxelGenerator.cpp`
`UVoxelGenerator : UObject` — lightweight; holds `Seed`, and injected services `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. | | **`GetDensityAt`** | 218 | **Entry point.** Picks strate + generator type, dispatches, adds diff offset. |
| **`GetDensityWithParams`** | 277 | TunnelNetwork pipeline (~1000 lines). See §4. | | **`GetDensityWithParams`** | 277 | TunnelNetwork pipeline (~1000 lines). See §4. |
| **`GetSlabDensity`** | 1306 | FlatPlain/CrystalChamber pipeline. See §4.2. | | **`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. | | `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. | | `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. | | `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. | | `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. | | `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` ### 3.7 Cave morphology (SDF rooms/tunnels) — `Public/VoxelCaveMorphology.h` + `.cpp`
Header is rich with inline docs. Two namespaces + a per-chunk cache system. 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`: - `namespace VoxelCaveMorphology`:
| Function | .cpp line | Role | | 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. | | `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` | 589 | **Phase 2** (per voxel): SmoothMin over cached rooms/tunnels; returns nearest room idx for terrain-op lookup. | | `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. | | `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 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. | | `EVoxelNoiseType` | 99 | FBM/Ridged/Mixed/Cellular. |
| `ECaveGeneratorType` | 146 | TunnelNetwork / FlatPlain / CrystalChamber. | | `ECaveGeneratorType` | 146 | TunnelNetwork / FlatPlain / CrystalChamber. |
| `EVoxelStrateTransition` | 183 | Gradient / Hard / Interleaved boundary blends. | | `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. | | `FStrateTerrainOpEntry` | 965 | Soft-ptr to a terrain op + Weight + Probability. |
| **`FSlabGenerationParams`** | 1019 | Floor/ceiling heights, roughness, columns, seal — for slab generators. | | **`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` **`Public/VoxelStrateDefinition.h`** — `UVoxelStrateDefinition : UPrimaryDataAsset`
(line 36). One asset = one strate *type*. Fields: identity, `StrateHeightInChunks`(60), (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`. | | `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). | | `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. | | `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. | | `FindSlotIndexForChunkZ` | 427 | Z → layout index. |
| `GetStrateAt` / `GetStrateIndex` | 443 / 455 | World-Z queries. | | `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. | | `GetStrateForChunk` | 466 | Chunk → definition. |
| `GetGeneratorTypeForChunk` | 476 | Chunk → generator type. | | `GetGeneratorTypeForChunk` | 476 | Chunk → generator type. |
| `GetSlabParamsForChunk` | 490 | Slab params with runtime Z bounds (no blend — slabs use Hard). | | `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. | | `ApplyModification` | 63 | Enforces budget, stores in all overlapped chunks, returns dirty coords. |
| `GetDensityOffset` | 131 | Per-voxel combined diff (smoothstep falloff, additive). | | `GetDensityOffset` | 131 | Per-voxel combined diff (smoothstep falloff, additive). |
| `HasModifications` | 160 | Fast reject for hot path. | | `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). | | `Clear` | 170 | Wipe all (season reset). |
| `GetTotalModificationCount` / `GetModifiedChunkCount` | 182 / 192 | Stats. | | `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.) removed — since T1.b the pre-sampled grid supplies positions AND gradients inline.)
| Method | .cpp line | Role | | 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 **`Public/MarchingCubesTables.h`** — `EdgeTable` + `TriTable` reference data (Paul
Bourke). Cube corner/edge layout documented at top (lines 7-37). Rarely needs editing. 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) ### 3.11 Per-chunk content & per-strate atmosphere (2026 redesign — see §8)
| File | Role | | 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/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). | | `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. | | How chunks stream in/out | `UpdateChunksAroundPosition` VoxelWorld.cpp:362. |
| Async threading / stale-result handling | `LoadChunk` :445, `ProcessPendingChunks` :301, Epoch logic. | | 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`. | | 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. | | Worm tunnel behavior | `GetDensityWithParams` Step 5, VoxelGenerator.cpp:1241. |
| Strate stacking / which strate where | `UVoxelStrateManager::Initialize` :10. | | 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). | | Passages between strates | `GeneratePassages` :146 + `EvaluateModifierSDF` :371 + `ApplyPassageCarving` (Generator.cpp:197). |
| Player carve/fill | `CarveAtPosition`/`FillAtPosition` VoxelWorld.cpp:691/709 → `UVoxelDiffLayer::ApplyModification` :63. | | Player carve/fill | `CarveAtPosition`/`FillAtPosition` VoxelWorld.cpp:691/709 → `UVoxelDiffLayer::ApplyModification` :63. |
| Mesh smoothness / normals | Grid-gradient in `GenerateMesh` (`GradAt` lambda), `IsoLevel` (h). | | 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) 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** spine, disturbances, content/atmosphere, biomes, and the **performance invariants**
(`§8.10` — read before optimizing the hot path) now live in **[ARCHITECTURE.md](ARCHITECTURE.md)**. (`§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.
+106 -106
View File
@@ -49,6 +49,44 @@ struct FBuildRoom
// (collected for connectivity decisions either way). // (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 <typename FEmit>
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 // 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; float MaxExtent = FMath::Max(BR.RadiusXY * 1.5f, BR.RadiusZ) + BlendK * 3.0f;
CR.CullRadiusSq = MaxExtent * MaxExtent; 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]. // 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. // 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. // Sentinel -FLT_MAX means "no cut" so the per-voxel check is a single compare.
@@ -604,110 +677,67 @@ void VoxelCaveMorphology::BuildChunkCache(
OpParams = FStrateGenerationParams{}; OpParams = FStrateGenerationParams{};
CR.RoomOp->ApplyTo(OpParams, CR.RoomOpWeight); CR.RoomOp->ApplyTo(OpParams, CR.RoomOpWeight);
// PITS // PITS — downward shafts anchored in the room's lower half.
if (OpParams.PitDensity > 0.0f) BakeRoomFeature(CR, /*Max*/2, OpParams.PitDensity,
{ 0xDE1A7Eu, 6271u, 0xABCDu, 0x5EEDu,
const int32 MaxPits = 2; /*XYScale*/0.6f, OpParams.PitMinRadius, OpParams.PitMaxRadius,
for (int32 i = 0; i < MaxPits; i++) [&](float PX, float PY, float PitRadius, uint32 PH3)
{ {
uint32 PH = VoxelHash::Mix(BR.Hash ^ (0xDE1A7Eu + (uint32)i * 6271u)); const uint32 PH4 = VoxelHash::Mix(PH3 ^ 0xF00Du);
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;
FCachedPit Pit; FCachedPit Pit;
Pit.CenterX = PX; Pit.CenterX = PX;
Pit.CenterY = PY; 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.Radius = PitRadius;
Pit.Depth = OpParams.PitDepth; Pit.Depth = OpParams.PitDepth;
Pit.FlareDist = PitRadius * 2.0f; Pit.FlareDist = PitRadius * 2.0f;
Pit.FlareExtra = PitRadius * 1.0f; Pit.FlareExtra = PitRadius * 1.0f;
Pit.BaseDensity = Params.BaseDensity; Pit.BaseDensity = Params.BaseDensity;
Pit.BlendK = Params.SDFBlendRadius; 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; Pit.BoundXYRadiusSq = MaxXYR * MaxXYR;
OutCache.Pits.Add(Pit); OutCache.Pits.Add(Pit);
} });
}
// CHIMNEYS // CHIMNEYS — mirror of pits: upward tubes anchored in the room's upper half.
if (OpParams.ChimneyDensity > 0.0f) BakeRoomFeature(CR, /*Max*/2, OpParams.ChimneyDensity,
{ 0xC4F007u, 7919u, 0x1337u, 0xCAFEu,
const int32 MaxChimneys = 2; /*XYScale*/0.6f, OpParams.ChimneyMinRadius, OpParams.ChimneyMaxRadius,
for (int32 i = 0; i < MaxChimneys; i++) [&](float CX, float CY, float ChmRadius, uint32 CH3)
{ {
uint32 CH = VoxelHash::Mix(BR.Hash ^ (0xC4F007u + (uint32)i * 7919u)); const uint32 CH4 = VoxelHash::Mix(CH3 ^ 0xD00Du);
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;
FCachedChimney Chim; FCachedChimney Chim;
Chim.CenterX = CX; Chim.CenterX = CX;
Chim.CenterY = CY; 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.Radius = ChmRadius;
Chim.Height = OpParams.ChimneyHeight; Chim.Height = OpParams.ChimneyHeight;
Chim.FlareDist = ChmRadius * 2.0f; Chim.FlareDist = ChmRadius * 2.0f;
Chim.FlareExtra = ChmRadius * 1.0f; Chim.FlareExtra = ChmRadius * 1.0f;
Chim.BaseDensity = Params.BaseDensity; Chim.BaseDensity = Params.BaseDensity;
Chim.BlendK = Params.SDFBlendRadius; 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; Chim.BoundXYRadiusSq = MaxXYR * MaxXYR;
OutCache.Chimneys.Add(Chim); OutCache.Chimneys.Add(Chim);
} });
}
// COLUMNS // COLUMNS — full-height solid cylinders (no Z anchor, no flare).
if (OpParams.ColumnDensity > 0.0f) BakeRoomFeature(CR, /*Max*/4, OpParams.ColumnDensity,
{ 0xC01C01u, 3571u, 0x1A2B3Cu, 0xBEEFu,
const int32 MaxCols = 4; /*XYScale*/0.75f, OpParams.ColumnMinRadius, OpParams.ColumnMaxRadius,
for (int32 i = 0; i < MaxCols; i++) [&](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; FCachedColumn Col;
Col.CenterX = ColX; Col.CenterX = ColX;
Col.CenterY = ColY; Col.CenterY = ColY;
Col.Radius = ColR; Col.Radius = ColR;
Col.BaseDensity = Params.BaseDensity; Col.BaseDensity = Params.BaseDensity;
float MaxXYR = ColR + 6.0f; const float MaxXYR = ColR + 6.0f;
Col.BoundXYRadiusSq = MaxXYR * MaxXYR; Col.BoundXYRadiusSq = MaxXYR * MaxXYR;
OutCache.Columns.Add(Col); OutCache.Columns.Add(Col);
} });
}
} }
} }
@@ -723,7 +753,6 @@ float VoxelCaveMorphology::EvaluateSDFCached(
float WorldX, float WorldY, float WorldZ, float WorldX, float WorldY, float WorldZ,
const FChunkSDFCache& Cache, const FChunkSDFCache& Cache,
float SDFBlendRadius, float SDFBlendRadius,
float RoomShapeVariety,
int32* OutNearestRoomIdx) int32* OutNearestRoomIdx)
{ {
float MinSDF = FLT_MAX; float MinSDF = FLT_MAX;
@@ -748,42 +777,13 @@ float VoxelCaveMorphology::EvaluateSDFCached(
const float DistSq = FVector::DistSquared(Pos, Room.Center); const float DistSq = FVector::DistSquared(Pos, Room.Center);
if (DistSq > Room.CullRadiusSq) continue; if (DistSq > Room.CullRadiusSq) continue;
// --- SHAPE SELECTION --- // --- SHAPE (pre-baked in BuildChunkCache — no per-voxel hash roll / trig) ---
float RoomSDF; float RoomSDF;
const uint32 ShapeHash = VoxelHash::Mix(Room.Hash ^ 0xDEADBEEFu); switch (Room.ShapeType)
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)
{ {
// ROUNDED BOX: angular chamber with smooth corners case 1: RoomSDF = VoxelSDF::RoundedBox(Pos, Room.Center, Room.ShapeA, Room.ShapeR); break;
FVector HalfExtent( case 2: RoomSDF = VoxelSDF::Capsule(Pos, Room.ShapeA, Room.ShapeB, Room.ShapeR); break;
Room.RadiusXY * 0.8f, default: RoomSDF = VoxelSDF::Ellipsoid(Pos, Room.Center, Room.ShapeA); break;
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);
} }
// Soft floor: SmoothMax of the room SDF and the floor half-space. // Soft floor: SmoothMax of the room SDF and the floor half-space.
@@ -883,6 +883,6 @@ float VoxelCaveMorphology::EvaluateSDF(
return EvaluateSDFCached( return EvaluateSDFCached(
WorldX, WorldY, WorldZ, WorldX, WorldY, WorldZ,
TempCache, Params.SDFBlendRadius, Params.RoomShapeVariety TempCache, Params.SDFBlendRadius
); );
} }
+420 -135
View File
@@ -23,6 +23,10 @@
// HISM instances are exempt — they're batched render data, capped per entry by MaxPerChunk. // HISM instances are exempt — they're batched render data, capped per entry by MaxPerChunk.
static constexpr int32 GMaxDecorationActorsPerCell = 400; 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 // 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). // decoration's per-cell MaxPerChunk keeps its "per chunk" meaning).
static constexpr int32 DECO_CELL_VOXELS = CHUNK_SIZE; static constexpr int32 DECO_CELL_VOXELS = CHUNK_SIZE;
@@ -429,10 +433,10 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
CosMinSlope.SetNumUninitialized(Entries.Num()); CosMinSlope.SetNumUninitialized(Entries.Num());
for (int32 e = 0; e < Entries.Num(); ++e) for (int32 e = 0; e < Entries.Num(); ++e)
{ {
CosMaxSlope[e] = (Entries[e].MaxSlopeAngle < 89.99f) CosMaxSlope[e] = (Entries[e].Profile.MaxSlopeAngle < 89.99f)
? FMath::Cos(FMath::DegreesToRadians(Entries[e].MaxSlopeAngle)) : -1.0f; ? FMath::Cos(FMath::DegreesToRadians(Entries[e].Profile.MaxSlopeAngle)) : -1.0f;
CosMinSlope[e] = (Entries[e].MinSlopeAngle > 0.01f) CosMinSlope[e] = (Entries[e].Profile.MinSlopeAngle > 0.01f)
? FMath::Cos(FMath::DegreesToRadians(Entries[e].MinSlopeAngle)) : -1.0f; ? FMath::Cos(FMath::DegreesToRadians(Entries[e].Profile.MinSlopeAngle)) : -1.0f;
} }
// Per-COLUMN biome cache: ResolveBiomeSampleAt's noise-heavy cell classification is box-validated // 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); }; 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) // 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. // 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 // 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; if (EntryBiome[EntryIdx] != ColBiome) continue;
const FStrateDecoration& Deco = Entries[EntryIdx]; const FStrateDecoration& Deco = Entries[EntryIdx];
const bool bInstanced = (Deco.InstancedMesh != nullptr); const bool bInstanced = (Deco.Profile.InstancedMesh != nullptr);
if (!bInstanced && !Deco.ActorClass) continue; if (!bInstanced && !Deco.Profile.ActorClass) continue;
if (Deco.SpawnDensity <= 0.0f) continue; if (Deco.SpawnDensity <= 0.0f) continue;
if (EntryCount[EntryIdx] >= Deco.MaxPerChunk) continue; if (EntryCount[EntryIdx] >= Deco.MaxPerChunk) continue;
if (!bInstanced && TotalActors >= GMaxDecorationActorsPerCell) continue; if (!bInstanced && TotalActors >= GMaxDecorationActorsPerCell) continue;
bool bMatches = true; bool bMatches = true;
switch (Deco.SurfacePlacement) switch (Deco.Profile.SurfacePlacement)
{ {
case ESurfaceType::Floor: bMatches = bFloor; break; case ESurfaceType::Floor: bMatches = bFloor; break;
case ESurfaceType::Wall: bMatches = bWall; 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 // 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 // (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). // 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) ⇔ // 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). // 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); const uint32 H = DecoHash(Cell.X, Cell.Y, gx, gy, CrossingIdx, EntryIdx, InSeed, 0xDEC0u);
if (VoxelHash::ToFloat01(H) > Deco.SpawnDensity) continue; 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; // F7 aware placement: relational conditions (relief/moisture/biome-border). Opt-in per entry —
FQuat BaseQ = Deco.bAlignToSurface // 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() ? FRotationMatrix::MakeFromZ(NormalWorld).ToQuat()
: FQuat::Identity; : 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 const float rp = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x1111A1u)) - 0.5f) * Deco.Profile.RandomRotation.Pitch;
// bit-for-bit (Lerp(0,360,t)° == t·2π rad), so existing assets are unchanged. const float ry = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x2222B2u)) - 0.5f) * Deco.Profile.RandomRotation.Yaw;
const float YawT = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x59415721u)); const float rr = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x3333C3u)) - 0.5f) * Deco.Profile.RandomRotation.Roll;
const float Yaw = FMath::DegreesToRadians(FMath::Lerp(Deco.MinYaw, Deco.MaxYaw, YawT)); BaseQ = BaseQ * FRotator(rp, ry, rr).Quaternion();
const FVector Axis = Deco.bAlignToSurface ? NormalWorld : FVector::UpVector;
BaseQ = FQuat(Axis, Yaw) * BaseQ;
} }
const float ScaleT = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x5CA1E000u)); 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(); FDecoSpawn& Out = OutSpawns.AddDefaulted_GetRef();
Out.EntryIdx = EntryIdx; Out.EntryIdx = EntryIdx;
@@ -522,6 +554,96 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
Out.Xf = FTransform(BaseQ, SpawnPos, FVector(Scale)); Out.Xf = FTransform(BaseQ, SpawnPos, FVector(Scale));
++EntryCount[EntryIdx]; ++EntryCount[EntryIdx];
if (!bInstanced) ++TotalActors; 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; if (!Result.Entries.IsValidIndex(S.EntryIdx)) continue;
const FStrateDecoration& Deco = Result.Entries[S.EntryIdx]; 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 (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 // 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. // contributor sets the render tuning (cull/shadow/scale) for the whole region's instances.
FRegionMeshBucket& Bucket = Build->MeshBuckets.FindOrAdd(Deco.InstancedMesh); FRegionMeshBucket& Bucket = Build->MeshBuckets.FindOrAdd(Prof.InstancedMesh);
if (Bucket.Xforms.Num() == 0) { Bucket.Deco = Deco; } if (Bucket.Xforms.Num() == 0) { Bucket.Profile = Prof; }
Bucket.Xforms.Add(S.Xf); Bucket.Xforms.Add(S.Xf);
} }
else if (Deco.ActorClass) else if (Prof.ActorClass)
{ {
FRegionActorSpawn& A = Build->ActorSpawns.AddDefaulted_GetRef(); FRegionActorSpawn& A = Build->ActorSpawns.AddDefaulted_GetRef();
A.ActorClass = Deco.ActorClass; A.ActorClass = Prof.ActorClass;
A.Xf = S.Xf; A.Xf = S.Xf;
} }
} }
@@ -776,7 +908,7 @@ void UVoxelContentManager::ApplyRegion(FDecoGrid& G, const FIntPoint& Region, FD
FRegionMeshBucket& Bucket = Pair.Value; FRegionMeshBucket& Bucket = Pair.Value;
UStaticMesh* Mesh = Pair.Key.Get(); UStaticMesh* Mesh = Pair.Key.Get();
if (!Mesh || Bucket.Xforms.Num() == 0) continue; if (!Mesh || Bucket.Xforms.Num() == 0) continue;
const FStrateDecoration& Deco = Bucket.Deco; const FPlacementProfile& Prof = Bucket.Profile;
UHierarchicalInstancedStaticMeshComponent* HISM = UHierarchicalInstancedStaticMeshComponent* HISM =
NewObject<UHierarchicalInstancedStaticMeshComponent>(OwnerActor); NewObject<UHierarchicalInstancedStaticMeshComponent>(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): // 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. // • 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. // • bCastShadow off removes the dominant cost of dense instanced foliage.
HISM->SetCastShadow(Deco.bCastShadow); HISM->SetCastShadow(Prof.bCastShadow);
if (Deco.CullDistance > 0.0f) if (Prof.CullDistance > 0.0f)
{ {
const int32 End = FMath::Max(1, (int32)Deco.CullDistance); const int32 End = FMath::Max(1, (int32)Prof.CullDistance);
const int32 Start = FMath::Max(1, (int32)(Deco.CullDistance * 0.8f)); const int32 Start = FMath::Max(1, (int32)(Prof.CullDistance * 0.8f));
HISM->SetCullDistances(Start, End); // fade band 0.8x→1.0x, then gone 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); 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<FIntVector, FLandmarkInstance>& LP : LandmarkInstances)
{
if (LP.Value.SuppressRadiusWorld > 0.0f)
{
RemoveInstancesInContent(Content, LP.Value.SuppressCenter, LP.Value.SuppressRadiusWorld);
}
}
}
} }
void UVoxelContentManager::ClearDecorationRegion(FDecoGrid& G, const FIntPoint& Region) void UVoxelContentManager::ClearDecorationRegion(FDecoGrid& G, const FIntPoint& Region)
@@ -945,93 +1092,111 @@ bool UVoxelContentManager::FindLandmarkColumn(const UVoxelGenerator* Gen, const
return false; 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<AActor>(P.ActorClass, OutXf, SP)) { Out.Actor = A; }
return true;
}
if (P.InstancedMesh)
{
UStaticMeshComponent* C = NewObject<UStaticMeshComponent>(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, void UVoxelContentManager::SpawnLandmarkInstance(const FStrateLandmark& L, uint32 H, const FDecoContext& Ctx,
const FTransform& OwnerXf, AActor* OwnerActor, const FTransform& OwnerXf, AActor* OwnerActor,
float LocalX, float LocalY, float Step, float ColDepth, float LocalX, float LocalY, float Step, float ColDepth,
FLandmarkInstance& Out) FLandmarkInstance& Out)
{ {
if (!Generator) return; FTransform Xf;
const float VX = LocalX / VOXEL_SIZE; if (!SpawnFromProfile(L.Profile, H, Ctx, OwnerXf, OwnerActor, LocalX, LocalY, Step, ColDepth, Xf, Out))
const float VY = LocalY / VOXEL_SIZE; return; // Out stays empty → evaluated, nothing placed
// Biome filter (resolved at the candidate XY, same field the density/deco paths use). // Mini-sun light orb (landmark-only): record world-space data for the terrain material's raymarched
if (L.RequiredBiome) // shadows. Distances convert voxels→cm (×VOXEL_SIZE); the emitter radius scales with the instance too.
{
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.
if (L.bIsLightOrb) if (L.bIsLightOrb)
{ {
const float Scale = Xf.GetScale3D().X;
Out.bIsOrb = true; Out.bIsOrb = true;
Out.Orb.WorldPos = WorldPos; Out.Orb.WorldPos = Xf.GetLocation();
Out.Orb.Color = L.OrbColor; Out.Orb.Color = L.OrbColor;
Out.Orb.Intensity = L.OrbIntensity; Out.Orb.Intensity = L.OrbIntensity;
Out.Orb.RadiusWorld = L.OrbRadiusVoxels * VOXEL_SIZE * Scale; Out.Orb.RadiusWorld = L.OrbRadiusVoxels * VOXEL_SIZE * Scale;
Out.Orb.FalloffWorld = L.OrbFalloffVoxels * VOXEL_SIZE; Out.Orb.FalloffWorld = L.OrbFalloffVoxels * VOXEL_SIZE;
Out.Orb.MaxShadowDistWorld = L.OrbMaxShadowDistanceVoxels * 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<AActor>(L.ActorClass, Xf, SP)) { Out.Actor = A; }
return;
}
if (L.InstancedMesh)
{
UStaticMeshComponent* C = NewObject<UStaticMeshComponent>(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<FVoxelActiveOrb>& OutOrbs) const void UVoxelContentManager::GetActiveOrbs(TArray<FVoxelActiveOrb>& OutOrbs) const
@@ -1057,6 +1222,38 @@ void UVoxelContentManager::ClearAllLandmarks()
LandmarkInstances.Reset(); LandmarkInstances.Reset();
} }
int32 UVoxelContentManager::RemoveInstancesInContent(FDecoRegionContent& Content, const FVector& Center, float Radius)
{
int32 Removed = 0;
for (TWeakObjectPtr<UHierarchicalInstancedStaticMeshComponent>& 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<int32> 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<FIntPoint, FDecoRegionContent>& RP : G->Regions)
{
Removed += RemoveInstancesInContent(RP.Value, WorldCenter, WorldRadius);
}
}
return Removed;
}
void UVoxelContentManager::UpdateLandmarks(const FVector& PlayerWorldPos) void UVoxelContentManager::UpdateLandmarks(const FVector& PlayerWorldPos)
{ {
if (!StrateManager || !Generator || !Settings) return; 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 float ColDepth = (float)FMath::Max(8, Settings->DecorationColumnDepthVoxels);
const uint32 LocalSeed = (uint32)Seed; const uint32 LocalSeed = (uint32)Seed;
// Walk each entry's lattice within its radius (a tiny box), spawn newly-entered cells, drop exited ones. // ---- Gather candidates (a small set) across every entry + anchor mode. Local XY (actor-space cm) is
TSet<FIntVector> Desired; // 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<FCand> 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) for (int32 EntryIdx = 0; EntryIdx < Def->Landmarks.Num(); ++EntryIdx)
{ {
const FStrateLandmark& L = Def->Landmarks[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 int32 RadiusChunks = FMath::Max(1, L.StreamRadiusChunks); const float RadiusWorld = (float)RadiusChunks * ChunkWorld;
const float CellWorld = SpacingChunks * ChunkWorld; // lattice cell size in cm const float GatherWorld = RadiusWorld + MaxExclWorld; // widened so all conflictors are gathered
const float RadiusWorld = (float)RadiusChunks * ChunkWorld; const float ExclWorld = FMath::Max(0.0f, L.ExclusionRadiusChunks) * ChunkWorld;
const float JitterRange = FMath::Clamp(L.JitterFraction, 0.0f, 1.0f); if (ExclWorld > 0.0f) bAnyExclusion = true;
const FIntPoint PlayerLCell(FMath::FloorToInt(LocalPlayer.X / CellWorld), if (L.AnchorMode == ELandmarkAnchor::HashLattice)
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)
{ {
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. for (int32 dy = -CellRange; dy <= CellRange; ++dy)
const uint32 H = DecoHash(LCell.X, LCell.Y, 0, 0, 0, EntryIdx, LocalSeed, 0x1A2D5u); for (int32 dx = -CellRange; dx <= CellRange; ++dx)
if (VoxelHash::ToFloat01(H) > L.SpawnProbability) continue; {
const FIntPoint LCell(PlayerLCell.X + dx, PlayerLCell.Y + dy);
// Jittered position inside the cell (centred so two neighbours stay ≥ Spacing·(1-Jitter) apart). // Existence roll (salt 0x1A2D5u kept from the original landmark path → positions unchanged).
const float jx = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x51A3F1u)) - 0.5f) * JitterRange; const uint32 H = DecoHash(LCell.X, LCell.Y, 0, 0, 0, EntryIdx, LocalSeed, 0x1A2D5u);
const float jy = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x7C2B93u)) - 0.5f) * JitterRange; if (VoxelHash::ToFloat01(H) > L.SpawnProbability) continue;
const float LocalX = ((float)LCell.X + 0.5f + jx) * CellWorld;
const float LocalY = ((float)LCell.Y + 0.5f + jy) * CellWorld;
// Radius is a true disk (the lattice box corners would otherwise overshoot it). const float jx = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x51A3F1u)) - 0.5f) * JitterRange;
const float ddx = LocalX - LocalPlayer.X, ddy = LocalY - LocalPlayer.Y; const float jy = (VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x7C2B93u)) - 0.5f) * JitterRange;
if (ddx * ddx + ddy * ddy > RadiusWorld * RadiusWorld) continue; 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); const float ddx = LocalX - LocalPlayer.X, ddy = LocalY - LocalPlayer.Y;
Desired.Add(Key); const float DistSq = ddx * ddx + ddy * ddy;
if (LandmarkInstances.Contains(Key)) continue; // already evaluated (spawned OR empty) if (DistSq > GatherWorld * GatherWorld) continue;
FLandmarkInstance Inst; Cands.Add({ FIntVector(LCell.X, LCell.Y, EntryIdx), EntryIdx, H, LocalX, LocalY, ExclWorld, L.Priority,
SpawnLandmarkInstance(L, H, Ctx, OwnerXf, OwnerActor, LocalX, LocalY, Step, ColDepth, Inst); DistSq <= RadiusWorld * RadiusWorld });
LandmarkInstances.Add(Key, Inst); // stored even if empty → never re-evaluated while in range }
}
else // PassageMouth — enumerate the finite passage list, keep endpoints that land in THIS strate.
{
const TArray<FVoxelPassage>& 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<FIntVector> 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) for (auto It = LandmarkInstances.CreateIterator(); It; ++It)
{ {
if (Desired.Contains(It.Key())) continue; if (Desired.Contains(It.Key())) continue;
@@ -196,7 +196,12 @@ void UVoxelDensityVolume::EnsureTextures()
T->Filter = TF_Trilinear; // smooth iso crossing (sub-voxel crisp edge) T->Filter = TF_Trilinear; // smooth iso crossing (sub-voxel crisp edge)
T->CompressionSettings = TC_Grayscale; // single-channel T->CompressionSettings = TC_Grayscale; // single-channel
T->NeverStream = true; 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 // 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 / // the build rejects any of these): FTexturePlatformData / SetNumSlices / SetPlatformData /
+47 -4
View File
@@ -115,8 +115,10 @@ TArray<FIntVector> 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); bHasAnyMods.store(true, std::memory_order_release);
ModsVersion.fetch_add(1, std::memory_order_release);
} }
UE_LOG(LogTemp, Log, UE_LOG(LogTemp, Log,
@@ -142,13 +144,53 @@ float UVoxelDiffLayer::GetDensityOffset(const FIntVector& ChunkCoord,
if (!bHasAnyMods.load(std::memory_order_acquire)) return 0.0f; 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 // 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); FReadScopeLock Lock(ModsLock);
// Fast path: if this chunk has no modifications, return 0
const TArray<FVoxelModification>* Mods = ChunkMods.Find(ChunkCoord); const TArray<FVoxelModification>* Mods = ChunkMods.Find(ChunkCoord);
if (!Mods || Mods->Num() == 0) return 0.0f; if (!Mods || Mods->Num() == 0) return 0.0f;
return EvaluateMods(*Mods, WorldX, WorldY, WorldZ);
}
void UVoxelDiffLayer::GetChunkModsSnapshot(const FIntVector& ChunkCoord, TArray<FVoxelModification>& Out) const
{
Out.Reset();
if (!bHasAnyMods.load(std::memory_order_acquire)) return;
FReadScopeLock Lock(ModsLock);
if (const TArray<FVoxelModification>* 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<FVoxelModification>& Mods,
float WorldX, float WorldY, float WorldZ)
{
float TotalOffset = 0.0f; float TotalOffset = 0.0f;
const FVector Pos(WorldX, WorldY, WorldZ); 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); return 1.0f - T * T * (3.0f - 2.0f * T);
}; };
for (const FVoxelModification& Mod : *Mods) for (const FVoxelModification& Mod : Mods)
{ {
float Falloff = 0.0f; float Falloff = 0.0f;
@@ -229,6 +271,7 @@ void UVoxelDiffLayer::Clear()
FWriteScopeLock Lock(ModsLock); FWriteScopeLock Lock(ModsLock);
bHasAnyMods.store(false, std::memory_order_release); bHasAnyMods.store(false, std::memory_order_release);
ChunkMods.Empty(); 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 // Reset budget counters — player gets a fresh budget after clear/season reset
File diff suppressed because it is too large Load Diff
@@ -11,7 +11,8 @@
// mort depuis T1.b — la grille pré-échantillonnée fournit positions ET gradients.) // mort depuis T1.b — la grille pré-échantillonnée fournit positions ET gradients.)
FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, int32 Step, int32 InCellsPerAxis, FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, int32 Step, int32 InCellsPerAxis,
TArray<uint8>* OutCaptureGrid) TArray<uint8>* OutCaptureGrid,
int32 BandZMinVox, int32 BandZMaxVox)
{ {
FVoxelMeshData MeshData; FVoxelMeshData MeshData;
if (OutCaptureGrid) { OutCaptureGrid->Reset(); } if (OutCaptureGrid) { OutCaptureGrid->Reset(); }
@@ -21,6 +22,14 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
// grows). Coarse tiles also use FEWER cells (InCellsPerAxis) for cheaper gen. // grows). Coarse tiles also use FEWER cells (InCellsPerAxis) for cheaper gen.
Step = FMath::Max(1, Step); 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<int32> OctaveBiasGuard(VoxelGenLOD::OctaveBias, OctaveBias);
// World-cm origin of the tile's min corner (positions are built relative to this). // World-cm origin of the tile's min corner (positions are built relative to this).
const FVector ChunkWorldPos = FVector(OriginVoxels) * VOXEL_SIZE; const FVector ChunkWorldPos = FVector(OriginVoxels) * VOXEL_SIZE;
@@ -35,6 +44,22 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
static thread_local TMap<FIntVector, int32> VertexMap; static thread_local TMap<FIntVector, int32> VertexMap;
VertexMap.Reset(); 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<uint8> VertexClasses; // 0 = sol, 1 = sky-cap
static thread_local TMap<FIntVector, FVector2f> 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) — // 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 // plus d'échantillonnage de densité par vertex. RawNormal pointe solide→air ; on la
// normalise ici (fallback up si dégénérée). // 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); const uint8 Bc = (uint8)FMath::Clamp(FMath::RoundToInt(BlendW * 255.0f), 0, 255);
MeshData.Colors.Add(FColor(R, G, Bc, A)); 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; return NewIndex;
}; };
@@ -132,11 +196,34 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
const int32 GridDim = CellsPerAxis + 1; const int32 GridDim = CellsPerAxis + 1;
const int32 MDim = GridDim + 2; // +1 marge de chaque côté 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 // Réutilise le tampon entre tuiles (thread_local) : SetNumUninitialized garde la
// capacité, donc plus de malloc/free de ~170 Ko (35³ floats) par tuile. // capacité, donc plus de malloc/free de ~170 Ko (35³ floats) par tuile.
static thread_local TArray<float> DensityGrid; static thread_local TArray<float> DensityGrid;
DensityGrid.SetNumUninitialized(MDim * MDim * MDim); 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++) 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. // 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 // 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. // 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<int32> GroundTris;
static thread_local TArray<int32> 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++) 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 Idx1 = GetOrCreateVertex(EdgeVertices[E1], EdgeNormals[E1]);
const int32 Idx2 = GetOrCreateVertex(EdgeVertices[E2], EdgeNormals[E2]); const int32 Idx2 = GetOrCreateVertex(EdgeVertices[E2], EdgeNormals[E2]);
MeshData.Triangles.Add(Idx0); // F17 — vote majoritaire (≥ 2 vertex sky-cap ⇒ triangle sky-cap).
MeshData.Triangles.Add(Idx2); const int32 CapVotes = (int32)VertexClasses[Idx0]
MeshData.Triangles.Add(Idx1); + (int32)VertexClasses[Idx1]
+ (int32)VertexClasses[Idx2];
TArray<int32>& 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 // É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 // 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. // (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 ExtentCm = (float)(CellsPerAxis * Step) * VOXEL_SIZE;
const float MinX = ChunkWorldPos.X, MinY = ChunkWorldPos.Y, MinZ = ChunkWorldPos.Z; const float MinX = ChunkWorldPos.X, MinY = ChunkWorldPos.Y, MinZ = ChunkWorldPos.Z;
@@ -305,34 +404,261 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels,
return Idx; return Idx;
}; };
// On ajoute en itérant : on fige le nombre de triangles de surface et on n'ajoute qu'au-delà. // F17 — jupes émises PAR SEAU : chaque jupe hérite la classe (donc le polygroup /
const int32 BaseTriNum = MeshData.Triangles.Num(); // matériau) de son triangle source ; ajouter les jupes après coup casserait les runs
for (int32 t = 0; t + 2 < BaseTriNum; t += 3) // d'indices contigus par groupe qu'exige RMC.
auto EmitSkirts = [&](TArray<int32>& Tris)
{ {
const int32 Tri[3] = { MeshData.Triangles[t], MeshData.Triangles[t + 1], MeshData.Triangles[t + 2] }; // On ajoute en itérant : on fige le nombre de triangles de surface et on n'ajoute qu'au-delà.
for (int32 e = 0; e < 3; ++e) const int32 BaseTriNum = Tris.Num();
for (int32 t = 0; t + 2 < BaseTriNum; t += 3)
{ {
const int32 iA = Tri[e], iB = Tri[(e + 1) % 3]; const int32 Tri[3] = { Tris[t], Tris[t + 1], Tris[t + 2] };
// COPIES par valeur — AddSkirtVert réalloue Vertices/Normals (invaliderait des refs). for (int32 e = 0; e < 3; ++e)
const FVector PA = MeshData.Vertices[iA]; {
const FVector PB = MeshData.Vertices[iB]; const int32 iA = Tri[e], iB = Tri[(e + 1) % 3];
if (!OnBoundaryPlane(PA, PB)) continue; // 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 NA = MeshData.Normals[iA];
const FVector NB = MeshData.Normals[iB]; const FVector NB = MeshData.Normals[iB];
const FColor CA = MeshData.Colors[iA]; const FColor CA = MeshData.Colors[iA];
const FColor CB = MeshData.Colors[iB]; const FColor CB = MeshData.Colors[iB];
const int32 iA2 = AddSkirtVert(PA - NA * SkirtDepth, NA, CA); const int32 iA2 = AddSkirtVert(PA - NA * SkirtDepth, NA, CA);
const int32 iB2 = AddSkirtVert(PB - NB * SkirtDepth, NB, CB); const int32 iB2 = AddSkirtVert(PB - NB * SkirtDepth, NB, CB);
// Quad (iA, iB, iB2, iA2) → 2 triangles, émis dans LES DEUX orientations. // Quad (iA, iB, iB2, iA2) → 2 triangles, émis dans LES DEUX orientations.
MeshData.Triangles.Add(iA); MeshData.Triangles.Add(iB); MeshData.Triangles.Add(iB2); Tris.Add(iA); Tris.Add(iB); Tris.Add(iB2);
MeshData.Triangles.Add(iA); MeshData.Triangles.Add(iB2); MeshData.Triangles.Add(iA2); Tris.Add(iA); Tris.Add(iB2); Tris.Add(iA2);
MeshData.Triangles.Add(iA); MeshData.Triangles.Add(iB2); MeshData.Triangles.Add(iB); Tris.Add(iA); Tris.Add(iB2); Tris.Add(iB);
MeshData.Triangles.Add(iA); MeshData.Triangles.Add(iA2); MeshData.Triangles.Add(iB2); 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<FVector2f> 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<int32> GroundIdx;
static thread_local TArray<int32> 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<int32> SheetGroundTris;
static thread_local TArray<int32> 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<int32>& 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<int32>& 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; return MeshData;
} }
@@ -452,6 +452,27 @@ float UVoxelStrateManager::EvaluateModifierSDF(float WorldX, float WorldY, float
return MinSDF; 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 // QUERIES
//============================================================================= //=============================================================================
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -251,6 +251,18 @@ struct FCachedRoom
// Intensity scale for this room's op (from FStrateTerrainOpEntry::Weight). // Intensity scale for this room's op (from FStrateTerrainOpEntry::Weight).
// 1.0 = use op as configured, 0.5 = half intensity, 2.0 = double. // 1.0 = use op as configured, 0.5 = half intensity, 2.0 = double.
float RoomOpWeight = 1.0f; 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 // 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 WorldX, WorldY, WorldZ — position in voxel coordinates (may be warped)
// @param Cache — pre-built cache from BuildChunkCache // @param Cache — pre-built cache from BuildChunkCache
// @param SDFBlendRadius — SmoothMin blend radius (from Params.SDFBlendRadius) // @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 // @param OutNearestRoomIdx — optional out: index of the room with minimum SDF
// contribution. -1 if no room passed the cull test. // contribution. -1 if no room passed the cull test.
// Used by the terrain ops system to look up the // Used by the terrain ops system to look up the
// per-room terrain op assigned to this voxel's room. // 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 // @return negative = inside cave, positive = solid rock
float EvaluateSDFCached( float EvaluateSDFCached(
float WorldX, float WorldY, float WorldZ, float WorldX, float WorldY, float WorldZ,
const FChunkSDFCache& Cache, const FChunkSDFCache& Cache,
float SDFBlendRadius, float SDFBlendRadius,
float RoomShapeVariety,
int32* OutNearestRoomIdx = nullptr int32* OutNearestRoomIdx = nullptr
); );
+25 -1
View File
@@ -113,6 +113,11 @@ public:
* raymarched shadows. */ * raymarched shadows. */
void GetActiveOrbs(TArray<FVoxelActiveOrb>& OutOrbs) const; void GetActiveOrbs(TArray<FVoxelActiveOrb>& 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 /** Destroy all spawned content (decorations + water). Regenerate / season reset. Bumps the deco
* epoch so any in-flight march tasks' results are discarded. */ * epoch so any in-flight march tasks' results are discarded. */
void ClearAll(); void ClearAll();
@@ -136,6 +141,8 @@ public:
struct FDecoSpawn struct FDecoSpawn
{ {
int32 EntryIdx = 0; 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; bool bInstanced = false;
FTransform Xf = FTransform::Identity; 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. // Instanced transforms for one mesh, accumulated across all of a region's cells → one batched HISM.
struct FRegionMeshBucket 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<FTransform> Xforms; TArray<FTransform> Xforms;
}; };
// A non-instanced actor placement, spawned when the region is applied. // 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). // terrain material consumes the nearest active orb for raymarched shadows (see GetActiveOrbs).
bool bIsOrb = false; bool bIsOrb = false;
FVoxelActiveOrb Orb; 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 /** 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, void SpawnLandmarkInstance(const FStrateLandmark& L, uint32 H, const FDecoContext& Ctx,
const FTransform& OwnerXf, AActor* OwnerActor, const FTransform& OwnerXf, AActor* OwnerActor,
float LocalX, float LocalY, float Step, float ColDepth, FLandmarkInstance& Out); 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 DestroyLandmarkInstance(FLandmarkInstance& Inst);
void ClearAllLandmarks(); 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 // 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. // 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, static bool FindLandmarkColumn(const UVoxelGenerator* Gen, const FTransform& OwnerXf,
@@ -318,6 +341,7 @@ private:
// Spawned landmarks, keyed by FIntVector(latticeCellX, latticeCellY, entryIndex) — FIntVector already // 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" // 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. // (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<FIntVector, FLandmarkInstance> LandmarkInstances; TMap<FIntVector, FLandmarkInstance> LandmarkInstances;
int32 LastLandmarkStrate = INT32_MIN; // strate change → wipe + rebuild landmarks int32 LastLandmarkStrate = INT32_MIN; // strate change → wipe + rebuild landmarks
+31
View File
@@ -225,6 +225,36 @@ public:
*/ */
bool HasModifications(const FIntVector& ChunkCoord) const; 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<FVoxelModification>& 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<FVoxelModification>& Mods,
float WorldX, float WorldY, float WorldZ);
//========================================================================= //=========================================================================
// MANAGEMENT // MANAGEMENT
//========================================================================= //=========================================================================
@@ -256,6 +286,7 @@ private:
TMap<FIntVector, TArray<FVoxelModification>> ChunkMods; TMap<FIntVector, TArray<FVoxelModification>> ChunkMods;
mutable FRWLock ModsLock; mutable FRWLock ModsLock;
std::atomic<bool> bHasAnyMods{ false }; std::atomic<bool> bHasAnyMods{ false };
std::atomic<uint32> ModsVersion{ 1 }; // see the snapshot API above
//========================================================================= //=========================================================================
// BUDGET TRACKING // BUDGET TRACKING
+87 -5
View File
@@ -20,6 +20,54 @@ class UVoxelStrateManager;
class UVoxelDiffLayer; class UVoxelDiffLayer;
class UVoxelBiomeDefinition; 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 * UVoxelGenerator
* *
@@ -194,6 +242,16 @@ public:
int32& OutDominantPalette, int32& OutNeighborPalette, int32& OutDominantPalette, int32& OutNeighborPalette,
float& OutBlendWeight) const; 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<FTerrainCondition>& 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 * 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 * 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, bool GetSurfaceHeightAt(float WorldX, float WorldY, int32 ChunkZ,
float& OutTerrainZ, float& OutCeilSurf) const; 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: private:
/** Pick the biome (index into Ctx.Biomes) for a Voronoi site, by its climate. */ /** 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; 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. */ * per-XY; the part that's evaluated per biome and blended in GetSurfaceDensity. */
float ComputeSurfaceTerrainZ(float WorldX, float WorldY, const FSurfaceGenerationParams& Params) const; 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). */ /** 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; float ComputeSurfaceCeiling(float WorldX, float WorldY, const FSurfaceGenerationParams& Params) const;
@@ -224,17 +303,20 @@ private:
TArray<FSurfaceGenerationParams>& OutBiomeParams) const; TArray<FSurfaceGenerationParams>& OutBiomeParams) const;
/** Biome-blended terrain Z + sky-cap ceiling Z for one column (the XY-only surface field). Shared /** 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, void ComputeSurfaceColumn(float WorldX, float WorldY, int32 ChunkZ,
const FSurfaceGenerationParams& BaseSurface, const FBiomeContext& BiomeCtx, const FSurfaceGenerationParams& BaseSurface, const FBiomeContext& BiomeCtx,
const TArray<FSurfaceGenerationParams>& BiomeParams, FChunkBiomeCache& BiomeCache, const TArray<FSurfaceGenerationParams>& 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 /** Final SurfaceWorld density from a column's precomputed terrain Z + ceiling: the cheap per-voxel
* cheap per-voxel Z-combine + origin spine + boundary seal + passage carving. The * Z-combine + F20 overhang shelf (warped-terrain union, uphill dir) + origin spine + seal + passages.
* XY-only work (terrain/ceiling) is done once per column and cached (T1.a). */ * 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 SurfaceDensityFromColumn(float WorldX, float WorldY, float WorldZ,
float TerrainZ, float CeilSurf, float TerrainZ, float CeilSurf,
float OverhangAmp, float DirX, float DirY,
const FSurfaceGenerationParams& Structural) const; const FSurfaceGenerationParams& Structural) const;
/** (Re)build the per-chunk biome cell grid covering chunk (X,Y) footprint + margin. */ /** (Re)build the per-chunk biome cell grid covering chunk (X,Y) footprint + margin. */
@@ -36,9 +36,47 @@ public:
* densité déjà échantillonnée, quantifiés via VF_QuantizeDensity. Cela évite à * densité déjà échantillonnée, quantifiés via VF_QuantizeDensity. Cela évite à
* UVoxelDensityVolume de re-sampler GetDensityAt pour ces cellules (le mesher * UVoxelDensityVolume de re-sampler GetDensityAt pour ces cellules (le mesher
* les a déjà calculées). Vidé puis rempli ; reste vide si non éligible. * 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, FVoxelMeshData GenerateMesh(FIntVector OriginVoxels, int32 Step = 1, int32 CellsPerAxis = CHUNK_SIZE,
TArray<uint8>* OutCaptureGrid = nullptr); TArray<uint8>* 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) // 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 // 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. // l'écart vers un voisin un niveau plus grossier (cellule 2×). Monter si des fissures persistent.
float SkirtCells = 2.0f; 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;
}; };
+53
View File
@@ -65,6 +65,20 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "0")) UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "0"))
int32 StrateViewMarginChunks = 3; 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) // 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 × // 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. // 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")) UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "4", ClampMax = "32"))
int32 CoarseTileCells = 16; 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. // 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. // A short wall is extruded into the solid from each surface edge on the tile's outer faces.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap")
@@ -123,6 +166,16 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0.5", ClampMax = "8.0")) UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0.5", ClampMax = "8.0"))
float SkirtCells = 2.0f; 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) // CONTENT — distance-based decoration grid (no LOD pop)
//========================================================================= //=========================================================================
@@ -326,6 +326,8 @@ public:
// Landmarks: RARE, large, far-visible objects placed on a coarse hash lattice (the underground // 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. // "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.) // 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") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Content")
TArray<FStrateLandmark> Landmarks; TArray<FStrateLandmark> Landmarks;
@@ -155,6 +155,11 @@ public:
UFUNCTION(BlueprintCallable, Category = "Strate") UFUNCTION(BlueprintCallable, Category = "Strate")
int32 GetStrateIndex(float WorldZ) const; 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. * Get the strate definition for a specific chunk coordinate.
* *
@@ -279,6 +284,13 @@ public:
*/ */
float EvaluateModifierSDF(float WorldX, float WorldY, float WorldZ) const; 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). */ /** Get all generated passages (for debug display). */
const TArray<FVoxelPassage>& GetPassages() const { return Passages; } const TArray<FVoxelPassage>& GetPassages() const { return Passages; }
+559 -287
View File
@@ -16,7 +16,7 @@
#include "GameplayTagContainer.h" #include "GameplayTagContainer.h"
#include "VoxelStrateTypes.generated.h" #include "VoxelStrateTypes.generated.h"
class UVoxelBiomeDefinition; // FStrateLandmark::RequiredBiome (optional per-landmark biome filter) class UVoxelBiomeDefinition; // FPlacementProfile::RequiredBiome (optional per-entry biome filter)
//============================================================================= //=============================================================================
// ENUMS // ENUMS
@@ -243,6 +243,113 @@ enum class EVoxelStrateTransition : uint8
// GENERATION PARAMS // 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. * FStrateGenerationParams — Cave generation parameters for one strate.
* *
@@ -924,97 +1031,10 @@ struct VOXELFORGE_API FStrateGenerationParams
float Alpha) float Alpha)
{ {
FStrateGenerationParams Result; FStrateGenerationParams Result;
// Rock // One assignment per field, expanded from VF_STRATE_PARAM_FIELDS (defined
Result.BaseDensity = FMath::Lerp(A.BaseDensity, B.BaseDensity, Alpha); // above the struct). Bit-identical to the old hand-written list — same
Result.VerticalScale = FMath::Lerp(A.VerticalScale, B.VerticalScale, Alpha); // FMath::Lerp calls, same Alpha-0.5 snap for discrete fields.
// Worm tunnels VF_STRATE_PARAM_FIELDS(VF_PARAM_LERP, VF_PARAM_SNAP)
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);
return Result; return Result;
} }
}; };
@@ -1364,6 +1384,93 @@ struct VOXELFORGE_API FSurfaceGenerationParams
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Macro", meta = (ClampMin = "1.0")) UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Macro", meta = (ClampMin = "1.0"))
float TerraceHeight = 12.0f; 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 -----
// Water table height as a fraction of strate height (0 = no water). Valleys below // 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 // 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<AActor> 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<FTerrainCondition> 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<FDecoSubCompanion> SubCompanions;
};
/** /**
* FStrateDecoration — One decoration type that can spawn on surfaces. * FStrateDecoration — One decoration type that can spawn on surfaces.
* *
* Decorations are actors placed ON the cave surface (stalactites on ceilings, * Placed ON the cave surface (stalactites on ceilings, mushrooms on floors, crystals on walls).
* mushrooms on floors, crystals on walls, etc.). * All the placement/transform/render settings live on the shared `Profile`; this struct adds only
* The decoration placer (future system) reads these entries from the active * decoration's own DISTRIBUTION fields (per-column dense grid, spawn density, per-chunk cap).
* strate definition and spawns actors accordingly.
*/ */
USTRUCT(BlueprintType) USTRUCT(BlueprintType)
struct VOXELFORGE_API FStrateDecoration struct VOXELFORGE_API FStrateDecoration
{ {
GENERATED_BODY() GENERATED_BODY()
// The actor class to spawn (e.g., BP_Stalactite, BP_CrystalCluster). FStrateDecoration()
// 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 // Decoration defaults that differ from FPlacementProfile's neutral defaults: variety scale range
// their spawn count down. // and a full random yaw (reproduces the legacy bRandomYaw = true, MinYaw/MaxYaw = 0..360 look).
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration") Profile.MinScale = 0.8f;
TSubclassOf<AActor> ActorClass; Profile.MaxScale = 1.2f;
Profile.RandomRotation.Yaw = 360.0f;
}
// INSTANCED path: if set, this entry renders as batched HISM instances instead of // Shared placement/transform/render settings.
// spawning ActorClass (which is then ignored). No tick, no per-actor overhead, UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ShowOnlyInnerProperties))
// engine-culled — orders of magnitude cheaper. Use for everything that doesn't need FPlacementProfile Profile;
// logic/lights/interaction; an emissive material still glows at distance without a light.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration")
UStaticMesh* InstancedMesh = nullptr;
// Which of the two decoration streaming grids this entry uses (§8.5). Far (default) = full radius + // 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 // 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 // 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 // VoxelSettings; this only PICKS a grid.
// you opt into a coarser far spacing or move an entry to Near. (Replaces the old vestigial MaxLODLevel.)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration")
EDecoStreamTier StreamTier = EDecoStreamTier::Far; EDecoStreamTier StreamTier = EDecoStreamTier::Far;
// Which surface type this decoration can be placed on // Chance per valid surface point to spawn this decoration (0-1).
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration") // 0.01 = rare, 0.1 = common, 0.5 = very dense.
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
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0.0", ClampMax = "1.0")) UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0.0", ClampMax = "1.0"))
float SpawnDensity = 0.05f; 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). // 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; int32 MaxPerChunk = 40;
// Only place where this is below the strate water line (true) or above it (false). // Cluster satellites scattered around each placed instance of THIS decoration (F7 relational placement) —
// Ignored unless RequireWaterRelative is set. Lets you put seaweed underwater and // e.g. a tree → rocks + mushrooms. Deterministic, one level, inherits this entry's surface point.
// grass above water in the same strate. UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Companions")
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement") TArray<FDecoCompanion> Companions;
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;
}; };
/** /**
* 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 * 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 * 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() 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") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark")
TSubclassOf<AActor> ActorClass; ELandmarkAnchor AnchorMode = ELandmarkAnchor::HashLattice;
// OR a plain static mesh (spawned as one StaticMeshComponent — no actor/tick overhead). An emissive // ----- Hash lattice (AnchorMode == HashLattice — scattered placement; cheap at any radius) -----
// material glows at distance without a light. Ignored if ActorClass is set.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark")
UStaticMesh* InstancedMesh = nullptr;
// ----- Rarity / spacing (the hash lattice — this is what makes it cheap) ----- // 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
// Average spacing between landmarks, IN CHUNKS. This is the lattice cell size: exactly one candidate is UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Lattice",
// considered per SpacingChunks×SpacingChunks cell, so cost scales with (radius/spacing)². This is also meta = (EditCondition = "AnchorMode == ELandmarkAnchor::HashLattice", EditConditionHides, ClampMin = "1.0"))
// 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"))
float SpacingChunks = 64.0f; float SpacingChunks = 64.0f;
// How far within its cell a candidate may wander (0 = dead-centre grid, 1 = anywhere in the cell). // How far within its cell a candidate may wander (0 = dead-centre, 1 = anywhere). Min spacing between two
// The effective MINIMUM spacing between two instances ≈ SpacingChunks·(1 JitterFraction); keep it // ≈ SpacingChunks·(1 JitterFraction).
// below 1 to preserve a spacing guarantee while still breaking up the grid regularity. UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Lattice",
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Spacing", meta = (ClampMin = "0.0", ClampMax = "1.0")) meta = (EditCondition = "AnchorMode == ELandmarkAnchor::HashLattice", EditConditionHides, ClampMin = "0.0", ClampMax = "1.0"))
float JitterFraction = 0.5f; float JitterFraction = 0.5f;
// Probability that a lattice cell actually contains this landmark (0-1). Combine with SpacingChunks for // Probability that a lattice cell actually contains this instance (0-1).
// "rare AND well-spaced": SpacingChunks sets the grid, SpawnProbability sets how many slots fill. UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Lattice",
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Spacing", meta = (ClampMin = "0.0", ClampMax = "1.0")) meta = (EditCondition = "AnchorMode == ELandmarkAnchor::HashLattice", EditConditionHides, ClampMin = "0.0", ClampMax = "1.0"))
float SpawnProbability = 1.0f; float SpawnProbability = 1.0f;
// How far out (in chunks) landmarks stream / stay visible. CHEAP to make large here (the lattice means a // ----- Passage mouths (AnchorMode == PassageMouth — at the passages threading this strate) -----
// 2048-chunk radius is still only ~(2048/Spacing)² candidates). Set big enough that a massive object
// never pops in at a jarring distance. // Place at the DESCENT mouth — where a passage LEAVES this strate downward (the hole going down; e.g. a
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Spacing", meta = (ClampMin = "1")) // 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; 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. // A placed instance suppresses OTHERS whose anchor falls within this radius (chunks) so two never overlap.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Placement") // 0 = OFF (the default — pure scatter; mini-suns don't exclude). Conflicts resolve deterministically:
UVoxelBiomeDefinition* RequiredBiome = nullptr; // 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, // Higher wins an exclusion conflict (a major shrine outranks scattered ruins).
// Any for the first surface found. Wall-leaning surfaces are matched by the same normal test as decos. UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Exclusion")
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Placement") int32 Priority = 0;
ESurfaceType SurfacePlacement = ESurfaceType::Ceiling;
// Surface-tilt band (deg from flat = acos(|normal.Z|); 0 = flat, 90 = vertical). MaxSlopeAngle rejects // ----- Decoration footprint (clear groundcover under the object so it doesn't clip through) -----
// 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;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Placement", meta = (ClampMin = "0.0", ClampMax = "90.0")) // Remove decorations (grass etc.) within a radius of this landmark, so foliage doesn't poke through a
float MinSlopeAngle = 0.0f; // 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 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Footprint",
// (false) the water line when bRequireWaterRelative is set. meta = (EditCondition = "bSuppressDecorationsUnder", ClampMin = "0.0"))
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Placement") float SuppressRadiusChunks = 2.0f;
bool bRequireWaterRelative = false;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Landmark|Placement", meta = (EditCondition = "bRequireWaterRelative")) // (Placement gates, transform tweaks, and cull/shadow render tuning live on the shared `Profile` above —
bool bPlaceBelowWater = false; // see FPlacementProfile. Landmark-specific defaults: Ceiling surface + no surface-align.)
// ----- 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;
// ----- MINI-SUN LIGHT ORB (feeds the terrain material's raymarched shadows) ----- // ----- 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 // When set, this landmark is also a LIGHT SOURCE: the terrain material marches the density volume
+7
View File
@@ -231,6 +231,12 @@ struct FVoxelMeshData
TArray<FColor> Colors; // Masques matériau F6 (R=palette biome dominant, G=pente, TArray<FColor> Colors; // Masques matériau F6 (R=palette biome dominant, G=pente,
// B=poids de fondu de bordure, A=palette biome voisin) // 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() void Clear()
{ {
Vertices.Empty(); Vertices.Empty();
@@ -238,6 +244,7 @@ struct FVoxelMeshData
UVs.Empty(); UVs.Empty();
Normals.Empty(); Normals.Empty();
Colors.Empty(); Colors.Empty();
NumCeilingTriangles = 0;
} }
bool IsEmpty() const { return Vertices.Num() == 0; } bool IsEmpty() const { return Vertices.Num() == 0; }
+232 -20
View File
@@ -27,6 +27,39 @@ class UMaterialInterface;
class UMaterialInstanceDynamic; class UMaterialInstanceDynamic;
namespace RealtimeMesh { struct FRealtimeMeshStreamSet; } // T1.f — worker-built geometry buffers 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<AActor> 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 * AVoxelWorld - The main voxel terrain actor
* *
@@ -51,11 +84,18 @@ struct FChunkResult
TSharedPtr<RealtimeMesh::FRealtimeMeshStreamSet> Streams; TSharedPtr<RealtimeMesh::FRealtimeMeshStreamSet> Streams;
uint32 Epoch = 0; // Generation epoch — discard if stale 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 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), // F17 — the mesher classifies every triangle semantically (sky-cap = down-facing near the
// computed on the worker where Normals are free. Authoritative — can't disagree with the rendered // column's CeilSurf; overhangs/cave roofs stay ground) and packs them as two contiguous runs
// view the way a game-thread height-oracle sample did (it misclassified coarse far tiles). The // (ground then cap) in the index buffer → polygroups 0/1 → two RMC sections with their own
// game thread still gates this to SurfaceWorld strates before applying CeilingMaterial / no-shadow. // material + shadow flag. These tell the apply path which sections exist (RMC only creates a
bool bIsCeiling = false; // 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 // 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 // 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 // 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). */ * because FVoxelTileKey isn't a USTRUCT key). */
TMap<FVoxelTileKey, URealtimeMeshComponent*> TileComponents; TMap<FVoxelTileKey, URealtimeMeshComponent*> 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<URealtimeMeshComponent*> 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) // TERRAIN MODIFICATION (player carving & filling)
@@ -271,6 +330,37 @@ public:
UFUNCTION(BlueprintCallable, Category = "Voxel World|Biome") UFUNCTION(BlueprintCallable, Category = "Voxel World|Biome")
FVoxelBiomeQuery GetBiomeAtWorldLocation(FVector WorldLocation) const; 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) // LIGHTING — DENSITY VOLUME (debug / material wiring)
//========================================================================= //=========================================================================
@@ -307,11 +397,10 @@ private:
TVP6 = FLinearColor::Black, TVP7 = FLinearColor::Black, TVP6 = FLinearColor::Black, TVP7 = FLinearColor::Black,
TVP8 = FLinearColor::Black, TVP9 = FLinearColor::Black; TVP8 = FLinearColor::Black, TVP9 = FLinearColor::Black;
// Change-detection for the per-Tick pushes: MID vector/texture sets and MPC writes each enqueue // Change-detection for the per-Tick MID pushes (MIDs OWN their param values, so skip-if-identical
// render-thread updates, so skip them entirely on the (common) frames where nothing moved. // is safe there — unlike the MPC, whose world instance can reset behind our back; see
// UpdateOrbLightMPC, which deliberately rewrites every frame).
TWeakObjectPtr<UVolumeTexture> LastBoundVolTex0; // re-push MIDs if the L0 texture was recreated TWeakObjectPtr<UVolumeTexture> 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: public:
@@ -336,6 +425,15 @@ public:
UFUNCTION(CallInEditor, BlueprintCallable, Category = "Live Edit") UFUNCTION(CallInEditor, BlueprintCallable, Category = "Live Edit")
void RebuildStrates(); 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) // EDITOR BRUSH (manual carve/fill from the Details panel, works in PIE)
//========================================================================= //=========================================================================
@@ -448,7 +546,35 @@ public:
* *
* @param ChunkCoord - Which chunk to load * @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. * Unload a single chunk.
@@ -466,14 +592,19 @@ public:
* *
* The vertex/index buffers (Streams) are already BUILT on the worker (T1.f — see * 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: * BuildTileStreamSet / FChunkResult), so this only does the game-thread-only work:
* ceiling/material resolution, get-or-create the component, CreateSectionGroup(MoveTemp), * material resolution, get-or-create the component, CreateSectionGroup(MoveTemp),
* and section config (collision/shadow). Never called for empty tiles. * and per-section config (collision/shadow). Never called for empty tiles.
* *
* @param Tile - Which clipmap tile this mesh belongs to * F17 — the streams carry TWO polygroups (0 = ground, 1 = sky-cap ceiling, classified
* @param Streams - Pre-built RMC geometry buffers (consumed/moved) * semantically per triangle on the worker): RMC auto-creates one section per non-empty
* @param bGeomCeiling - Worker's geometry-normal ceiling vote (gated to SurfaceWorld here) * 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 /** 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 * 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") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Lighting")
UMaterialParameterCollection* OrbLightMPC = nullptr; UMaterialParameterCollection* OrbLightMPC = nullptr;
/** Build the clipmap desired-tile set (concentric shells) around the player tile. */ /** Build the clipmap desired-tile set (concentric shells) around the player tile.
void BuildDesiredTiles(const FIntVector& CenterChunkCoord); * 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<FVoxelTileKey>& OutLeavers);
/** True if a tile's world footprint is still within the outermost clip shell (so a /** 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, * not-desired loaded tile there is mid-LOD-transition and must wait for its replacement,
@@ -515,6 +648,32 @@ public:
TQueue<FChunkResult, EQueueMode::Mpsc> ProcessQueue; TQueue<FChunkResult, EQueueMode::Mpsc> ProcessQueue;
TSet<FVoxelTileKey> PendingTiles; // tiles with a gen task in flight TSet<FVoxelTileKey> 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<FVoxelTileKey> 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<FVoxelTileKey> 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 // Set to true during EndPlay — async tasks check this before accessing UObjects
std::atomic<bool> bShuttingDown{false}; std::atomic<bool> bShuttingDown{false};
@@ -524,6 +683,27 @@ public:
// Player's level-0 tile coord (= chunk coord). The desired set is rebuilt when this changes. // Player's level-0 tile coord (= chunk coord). The desired set is rebuilt when this changes.
FIntVector CurrentCenterChunk = FIntVector::ZeroValue; 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<FVoxelStreamingAnchor> 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<FVoxelTileKey> CollisionOnlyTiles;
TSet<FVoxelTileKey> PrevCollisionOnlyTiles;
void ReconcileAnchorTileVisibility();
// --- Streaming work-avoidance (perf) --- // --- Streaming work-avoidance (perf) ---
// The desired tile set only changes when the player crosses a level-0 tile boundary. // 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 // 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); FIntVector LastUpdateCenter = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
bool bAllChunksLoaded = false; bool bAllChunksLoaded = false;
TArray<FVoxelTileKey> DesiredSorted; // desired tiles, nearest-first TArray<FVoxelTileKey> DesiredSorted; // desired tiles, nearest-first
TSet<FVoxelTileKey> 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<FVoxelTileKey, uint32> 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<FVoxelTileKey> TransitionHold;
TArray<FVoxelTileKey> 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()) // 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 // 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). * fresh with the diff layer when loaded normally).
* *
* @param DirtyCoords - Chunk coordinates that need re-meshing * @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<FIntVector>& DirtyCoords); void RemeshDirtyChunks(const TArray<FIntVector>& DirtyCoords, const FVoxelTileKey* ExcludeTile = nullptr);
}; };