diff --git a/CODEMAP.md b/CODEMAP.md new file mode 100644 index 0000000..fe519b8 --- /dev/null +++ b/CODEMAP.md @@ -0,0 +1,489 @@ +# VoxelForge — Code Map & Knowledge Index + +> Purpose: a navigation index so anyone (human or AI) can locate and modify code +> without re-reading the whole plugin. Anchors are `File:line` — line numbers drift +> as code is edited, so trust the **symbol name** first and the line as a hint. +> +> Plugin root: `Source/VoxelForge/` · ~8,300 lines of C++ across 22 files. +> Comments in the code are mixed **French + English**. UE module = `VoxelForge` (Runtime). + +--- + +## 1. What this plugin is + +A **density-field voxel terrain** plugin for Unreal Engine, built around underground +**"strates"** (vertical geological layers, each a self-contained mini-world). + +- **No block grid.** Terrain is a continuous scalar density field evaluated on the fly + from world coordinates. Convention: **negative = solid rock, positive = air** + (the Marching Cubes convention used throughout). +- **Marching Cubes** turns the density field into a smooth mesh per 32³ chunk. +- **Strates** stack downward from Z=0. Each strate is a `UVoxelStrateDefinition` data + asset that picks a generator type and a huge bag of cave-shaping params. +- **Async streaming**: chunks load/unload around the player on background tasks; meshes + are applied on the game thread under a per-frame budget. +- **Player edits** (carve/fill) are stored as a *diff layer* added on top of the + procedural density — procedural generation stays deterministic. + +### Core terminology +| Term | Meaning | +|------|---------| +| Chunk | 32×32×32 voxels (`CHUNK_SIZE`). Voxel = 25 cm (`VOXEL_SIZE`). | +| Density | Scalar field. `< 0` solid, `> 0` air, `0` = surface (`IsoLevel`). | +| Strate | Vertical layer of the world, stacked downward. Has its own generator + params. | +| Generator type | Archetype per strate: `TunnelNetwork`, `FlatPlain`, `CrystalChamber`, `Maze`, `SurfaceWorld`, `VerticalShafts`, `FloatingIslands`, `Underwater`. See §8. | +| (0,0) spine | Guaranteed open landing column at world XY (0,0) in every strate; descent is player-dug through the seals. See §8. | +| Terrain op | Optional density modifier (pit, arch, terrace…) attached to a strate. | +| Passage | Carved tunnel connecting two adjacent strates (progression path). | +| Diff layer | Player carve/fill modifications stored on top of procedural density. | +| Epoch | Generation counter; stale async results are discarded on mismatch. | + +--- + +## 2. The big picture — data flow + +``` + AVoxelWorld (actor, orchestrator) + Tick → UpdateChunksAroundPosition + │ (load/unload around player, by distance + LOD) + ▼ + LoadChunk → UE::Tasks::Launch ──────────► background thread + │ + UVoxelMarchingCubesMesher::GenerateMesh(chunk, step) ◄────────────────┘ + │ samples density per cell corner + ▼ + UVoxelGenerator::GetDensityAt(x,y,z) ← THE density entry point + │ asks StrateManager which strate/params/generator-type applies + ├─ TunnelNetwork → GetDensityWithParams() (rooms+tunnels+ops+worms+seal+passages) + │ └─ VoxelCaveMorphology::BuildChunkCache + EvaluateSDFCached (room/tunnel SDF) + ├─ FlatPlain / CrystalChamber → GetSlabDensity() (floor+ceiling+columns+seal+passages) + └─ + UVoxelDiffLayer::GetDensityOffset() (player carve/fill) + │ + ▼ FVoxelMeshData (verts/tris/uvs/normals) + ProcessQueue (lock-free) ──► game thread: ProcessPendingChunks → ApplyMeshToChunk + (RealtimeMeshComponent) +``` + +`UVoxelStrateManager` is the side oracle: "what strate is at this Z, what params, +what generator type, and is there a passage/elevator SDF near here?" + +--- + +## 3. File-by-file reference + +Paths relative to `Source/VoxelForge/`. `Public/` = headers, `Private/` = impl. + +### 3.1 Module & build +| File | Role | +|------|------| +| `../../VoxelForge.uplugin` | Plugin manifest. One Runtime module `VoxelForge`. Beta. | +| `VoxelForge.Build.cs` | Deps: Core, CoreUObject, Engine, **GameplayTags**, **RealtimeMeshComponent**. | +| `Public/VoxelForgeModule.h` / `Private/VoxelForgeModule.cpp` | `FVoxelForgeModule` boilerplate (Startup/Shutdown just log). | + +### 3.2 Foundational types — `Public/VoxelTypes.h` (no UClass, everyone includes it) +| Symbol | Line | Notes | +|--------|------|-------| +| `CHUNK_SIZE` (32), `CHUNK_SIZE_SQUARED`, `CHUNK_VOLUME` | 19-21 | Chunk dimensions. | +| `VOXEL_SIZE` (25.0f cm) | 23 | World scale. | +| `EVoxelFace` enum + `GetFaceDirection` / `GetFaceNormal` | 33-61 | 6 cube faces. | +| `WorldToChunkCoord` / `WorldToLocalCoord` / `ChunkToWorldPos` | 74-104 | Coord-space conversions (handle negatives via floor/positive-modulo). | +| `LocalToIndex` / `IndexToLocal` / `IsValidLocalCoord` | 107-131 | Flat-array 3D↔1D indexing. | +| `SmoothStep01` | 140 | 3x²-2x³ — used everywhere for blends. | +| `VOXEL_NOISE_SCALE` (1.25f) | 147 | Rescales UE PerlinNoise3D to ~[-1,1]. | +| `FVoxelMeshData` struct | 157-173 | Mesher output (Vertices/Triangles/UVs/Normals). Plain C++, not USTRUCT. | + +### 3.3 Chunk identity — `Public/VoxelChunk.h` +`FVoxelChunk` (USTRUCT, line 19): just a `ChunkCoord` + `GetWorldPosition()`. In a +density-only world the chunk stores no voxels — it's a coord wrapper. Room to cache +per-chunk info later. + +### 3.4 Settings — `Public/VoxelSettings.h` +`UVoxelSettings : UPrimaryDataAsset` — the single tuning asset assigned on `AVoxelWorld`. +| Group | Fields (line) | +|-------|---------------| +| Streaming | `ViewDistanceXY=16` (26), `ViewDistanceUp/Down=5` (29/32), `MaxConcurrentTasks=16` (36), `MaxMeshAppliesPerFrame=4` (40) | +| LOD | `LOD0Distance=4` (48), `LOD1Distance=8` (53) | +| Rendering | `VoxelMaterial` (61) | +| Strates | `Seed` (69), `CurrentSeason=1` (73), `StratePool` (78), `FixedStrates` map (83), `TotalStrates=10` (87) | +| Carving budget | `MaxModifications=0` (97), `MaxBrushRadius=15` (102), `MaxTotalVolume=0` (107). 0 = unlimited. | + +### 3.5 World orchestrator — `Public/VoxelWorld.h` + `Private/VoxelWorld.cpp` +`AVoxelWorld : AActor` — owns everything, drives streaming. Also `FChunkResult` struct +(VoxelWorld.h:35) = async task payload (coord, chunk, meshdata, LOD, **Epoch**). + +**Owned objects (UPROPERTY):** `Settings`, `Generator`, `Mesher`, `StrateManager`, +`DiffLayer` (VoxelWorld.h:55-78). **Storage:** `Chunks` map, `ChunkMeshes` map, +`ChunkLODs`, `ProcessQueue` (TQueue), `PendingChunkCoord` (TSet) (VoxelWorld.h:85-312). +**Async state:** `bShuttingDown`, `ActiveTaskCount` (atomics), `GenerationEpoch`. + +| Method | .cpp line | Role | +|--------|-----------|------| +| `AVoxelWorld()` ctor | 12 | Enables Tick. | +| `RegenerateAllChunks()` | 21 | Bumps epoch, unloads all → Tick reloads. CallInEditor button. | +| `PostEditChangeProperty` | 45 | Editor live-edit hook. | +| `OnObjectModifiedInEditor` | 58 | Regenerates when a strate asset is edited (if `bLiveEditStrates`). | +| `EndPlay` | 140 | Sets `bShuttingDown`, **waits for `ActiveTaskCount`→0**, unbinds delegate. | +| `BeginPlay` | 177 | Constructs Generator/Mesher/StrateManager/DiffLayer, wires services, seeds. | +| `Tick` | 220 | `UpdateChunksAroundPosition(player)` + `ProcessPendingChunks()`. | +| `GetPlayerPosition` | 231 | Pawn position or zero. | +| `GetLODForChunk` / `LODToStep` | 242 / 268 | Distance→LOD (0/1/2) → step (1/2/4). | +| `IsChunkInRange` | 275 | View-distance test. | +| `ProcessPendingChunks` | 301 | Drains ProcessQueue under per-frame budget; **discards stale epochs**; applies meshes. | +| `UpdateChunksAroundPosition` | 362 | Builds desired set, sorts by distance, loads/unloads, handles LOD changes. | +| `LoadChunk` | 445 | Budget check → `UE::Tasks::Launch` background gen+mesh; RAII task guard. | +| `UnloadChunk` | 493 | Destroys mesh component + map entries. | +| `ApplyMeshToChunk` | 503 | Get/create RealtimeMeshComponent, upload geometry, assign material. | +| `GetStrateAtPosition` | 679 | Gameplay query → strate index. | +| `CarveAtPosition` / `FillAtPosition` | 691 / 709 | Build `FVoxelModification` → DiffLayer → RemeshDirtyChunks. | +| `ClearAllModifications` | 726 | Clears diff layer, regenerates. | +| `ChangeSeed` | 740 | **Season reset**: new seed everywhere, clear diffs, bump season, reload. | +| `GetCurrentSeed` / `GetCurrentSeason` | 784 / 789 | Accessors. | +| `RemeshDirtyChunks` | 798 | Re-queue loaded chunks for async re-mesh (no visual pop). | + +### 3.6 Density generator — `Public/VoxelGenerator.h` + `Private/VoxelGenerator.cpp` +`UVoxelGenerator : UObject` — lightweight; holds `Seed`, and injected services +`StrateManager` + `DiffLayer` (both nullable). This is **where terrain shape lives.** + +| Symbol | .cpp line | Role | +|--------|-----------|------| +| `FractalNoise3D` (static) | 25 | fBM (layered Perlin). | +| `RidgedNoise3D` (static) | 55 | Ridged multifractal — craggy. | +| `CellularNoise3D` (static) | 101 | Worley/cellular — grotto/scallop. | +| `ApplyBoundarySeal` (static) | 170 | Solidifies strate top/bottom shells. | +| `ApplyPassageCarving` (static) | 197 | Punches passages/elevator through the seal. | +| `InitializeSettings` | 211 | Copies seed from settings. | +| **`GetDensityAt`** | 218 | **Entry point.** Picks strate + generator type, dispatches, adds diff offset. | +| **`GetDensityWithParams`** | 277 | TunnelNetwork pipeline (~1000 lines). See §4. | +| **`GetSlabDensity`** | 1306 | FlatPlain/CrystalChamber pipeline. See §4.2. | + +### 3.7 Cave morphology (SDF rooms/tunnels) — `Public/VoxelCaveMorphology.h` + `.cpp` +Header is rich with inline docs. Two namespaces + a per-chunk cache system. + +- `namespace VoxelSDF` (h:46): `Sphere`, `Ellipsoid`, `Capsule`, `RoundedBox`, + `TaperedCapsule`, `SmoothMin`, `SmoothMax` — all FORCEINLINE SDF primitives. +- `namespace VoxelHash` (h:158): `Mix`, `Cell`, `Pair`, `ToFloat01`, `ToFloatSigned` + — deterministic hashing for room/tunnel placement (no storage, infinite worlds). +- Cache structs (h:224-330): `FCachedRoom`, `FCachedTunnel`, `FCachedPit`, + `FCachedChimney`, `FCachedColumn`, `FChunkSDFCache`. +- `namespace VoxelCaveMorphology`: + | Function | .cpp line | Role | + |----------|-----------|------| + | `BuildChunkCache` | 47 | **Phase 1** (once/chunk): collect rooms, nearest-neighbor backbone, decide tunnels, pre-bake pits/chimneys/columns, hash-roll per-room terrain op. | + | `EvaluateSDFCached` | 589 | **Phase 2** (per voxel): SmoothMin over cached rooms/tunnels; returns nearest room idx for terrain-op lookup. | + | `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 + voxel is the single biggest CPU win. + +### 3.8 Strate system +**`Public/VoxelStrateTypes.h`** — shared structs/enums (1228 lines, the data vocabulary): +| Symbol | Line | Role | +|--------|------|------| +| `EVoxelPassageType` | 38 | Sloped/Vertical/Spiral/Cascading/Crack passage shapes. | +| `ESurfaceType` | 78 | Floor/Wall/Ceiling/Any (decoration placement). | +| `EVoxelNoiseType` | 99 | FBM/Ridged/Mixed/Cellular. | +| `ECaveGeneratorType` | 146 | TunnelNetwork / FlatPlain / CrystalChamber. | +| `EVoxelStrateTransition` | 183 | Gradient / Hard / Interleaved boundary blends. | +| **`FStrateGenerationParams`** | 213 | The giant TunnelNetwork param bag (rock, worms, rooms, tunnels, warp, roughness, all terrain-op transport fields, boundary seal). `Lerp()` static at 844 blends two sets at boundaries. | +| `FStrateTerrainOpEntry` | 965 | Soft-ptr to a terrain op + Weight + Probability. | +| **`FSlabGenerationParams`** | 1019 | Floor/ceiling heights, roughness, columns, seal — for slab generators. | +| `FStrateDecoration` / `FStrateAmbientActor` / `FStrateCreature` | 1160 / 1192 / 1212 | Content spawn entries (consumed by future systems). | + +**`Public/VoxelStrateDefinition.h`** — `UVoxelStrateDefinition : UPrimaryDataAsset` +(line 36). One asset = one strate *type*. Fields: identity, `StrateHeightInChunks`(60), +`TransitionType`(79)/`TransitionBlendChunks`(89), `GeneratorType`(102), +`GenerationParams`(113), `SlabParams`(124), `TerrainOperations`(147), visuals/fog/light, +content lists, audio, `GameplayTags`(223). EditConditions show/hide param groups by generator type. + +**`Public/VoxelStrateManager.h` + `.cpp`** — `UVoxelStrateManager : UObject` (h:108). +Maps depth→strate at runtime; owns passages. +- `FVoxelPassage` (h:39): endpoints, radius, type, control points. +- `FStrateSlot` (h:84): definition + chunk-Z range + index. +| Method | .cpp line | Role | +|--------|-----------|------| +| `Initialize` | 10 | Builds the stacked layout from settings+seed (fixed slots + shuffled pool), then `GeneratePassages`. | +| `GeneratePassages` | 146 | Deterministic passages between consecutive strates (per-type control points). | +| `EvaluateModifierSDF` | 371 | SDF of all passages + elevator shaft at a point (for carving). | +| `FindSlotIndexForChunkZ` | 427 | Z → layout index. | +| `GetStrateAt` / `GetStrateIndex` | 443 / 455 | World-Z queries. | +| `GetStrateForChunk` | 466 | Chunk → definition. | +| `GetGeneratorTypeForChunk` | 476 | Chunk → generator type. | +| `GetSlabParamsForChunk` | 490 | Slab params with runtime Z bounds (no blend — slabs use Hard). | +| `GetGenerationParams` | 515 | **Blended** TunnelNetwork params (handles Gradient/Hard/Interleaved transitions). | +| `BuildParamsFromDefinition` (static) | 771 | Base params + merge all referenced terrain op assets. The one place ops fold into params. | + +**`Public/VoxelTerrainOpDefinition.h` + `.cpp`** — `UVoxelTerrainOpDefinition : UPrimaryDataAsset` +(h:67). One asset = one terrain op. `EVoxelTerrainOpType` (h:36): Terrace, LayerLines, +Ribbing, Cliff, Scallop, Overhang, Arch, Column, Pit, Chimney, Dome, Pinch. Per-type +param groups gated by EditCondition. `ApplyTo(OutParams, Weight)` (.cpp:6) copies only +the active type's fields into `FStrateGenerationParams`, scaled by Weight. + +### 3.9 Player edits — `Public/VoxelDiffLayer.h` + `.cpp` +`UVoxelDiffLayer : UObject` (h:77). Stores `FVoxelModification` (h:43: Center/Radius/Strength; +**negative Strength = carve, positive = fill**) grouped by chunk in `TMap ChunkMods`. +| Method | .cpp line | Role | +|--------|-----------|------| +| `SetBudget` | 10 | From VoxelSettings carving caps. | +| `CanModify` | 20 | Budget check (no consume) — for UI. | +| `GetRemainingModifications` / `GetRemainingVolume` | 47 / 53 | -1 = unlimited. | +| `ApplyModification` | 63 | Enforces budget, stores in all overlapped chunks, returns dirty coords. | +| `GetDensityOffset` | 131 | Per-voxel combined diff (smoothstep falloff, additive). | +| `HasModifications` | 160 | Fast reject for hot path. | +| `Clear` | 170 | Wipe all (season reset). | +| `GetTotalModificationCount` / `GetModifiedChunkCount` | 182 / 192 | Stats. | + +### 3.10 Mesher — `Public/VoxelMarchingCubesMesher.h` + `.cpp` +`UVoxelMarchingCubesMesher : UObject` (h:21). Holds `Generator` ptr, `IsoLevel=0`, +`GradientOffset=1`. +| Method | .cpp line | Role | +|--------|-----------|------| +| `GetDensity` | 11 | Local coord → world → `Generator->GetDensityAt`. | +| `InterpolateEdge` | 28 | Linear edge crossing between two corner densities. | +| `ComputeGradientNormal` | 48 | Central-difference gradient → smooth normal. | +| **`GenerateMesh`** | 75 | The MC loop over cells; `Step` controls LOD sampling. | + +**`Public/MarchingCubesTables.h`** — `EdgeTable` + `TriTable` reference data (Paul +Bourke). Cube corner/edge layout documented at top (lines 7-37). Rarely needs editing. + +### 3.11 Per-chunk content & per-strate atmosphere (2026 redesign — see §8) +| File | Role | +|------|------| +| `Public/Private/VoxelContentManager.h/.cpp` | `UVoxelContentManager` — deterministic decoration scatter (LOD0) + aesthetic water planes, per chunk. 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. | + +> The big 2026 redesign (8 archetypes, (0,0) spine, inter-strate gap, per-strate passages, +> disturbances, content/atmosphere, brush shapes, perf invariants) is documented in **§8** — +> read it first when touching generation/strates/passages. + +--- + +## 4. The density pipeline (most-edited hot path) + +### 4.1 `GetDensityWithParams` (TunnelNetwork) — VoxelGenerator.cpp:277 +Stage order (negative=solid throughout). Each stage's anchor: +| Step | Line | What | +|------|------|------| +| 1 — Vertical scale | 307 | Stretch Z before noise (`VerticalScale`). | +| 2 — Base density | 316 | Everything starts solid at `BaseDensity`. | +| 3 — Cave warp | 321 | Domain-warp the SDF query coords (organic shapes). | +| 4 — SDF morphology | 368 | Rooms+tunnels via `BuildChunkCache`/`EvaluateSDFCached`. | +| 4b — Surface roughness | 564 | Volumetric noise near surfaces (fBM/Ridged/Mixed). | +| 4c–4h — Terrain ops | 688 | Per-room op applied near surfaces. Sub-anchors below. | +| · Terracing | 727 | Step-like ledges. | +| · Layer lines | 823 | Horizontal grooves (sin of Z). | +| · Ribbing | 853 | Parallel ridges (sin of Z). | +| · Overhangs | 882 | Low-Z-freq noise shelves. | +| · Cliff sharpening | 918 | Amplify vertical gradient. | +| · Scallop | 962 | Cellular erosion bowls. | +| · Arch/Bridge | 998 | Hash-placed capsules across voids. | +| 4d — Columns | 1053 | Pre-baked vertical cylinders. | +| 4g — Domes | 1077 | Room-relative hemispherical ceilings. | +| 4h — Pinch | 1142 | Passage bottlenecks. | +| 5 — Worm tunnels | 1241 | abs(noise1)+abs(noise2) connectivity. | +| 6 — Boundary seal | 1274 | Solid top/bottom shells (`ApplyBoundarySeal`). | +| 7 — Inter-strate passages | 1281 | Carve passages/elevator (`ApplyPassageCarving`). | + +### 4.2 `GetSlabDensity` (FlatPlain / CrystalChamber) — VoxelGenerator.cpp:1306 +| Step | Line | What | +|------|------|------| +| 1 — Floor surface | 1317 | Noisy floor height. | +| 2 — Ceiling surface | 1343 | Formations hang downward (`abs(noise)`). | +| 3 — Void→base density | 1379 | Solid outside [floor,ceiling]. | +| 4 — Columns | 1399 | World-space hash grid, full-height. | +| 5+6 — Seal + passages | 1461 | Same seal/passage carving as TunnelNetwork. | + +--- + +## 5. "I want to change X" → go here + +| Goal | Location | +|------|----------| +| Chunk size / voxel scale | `VoxelTypes.h:19-23` (rebuild everything). | +| View distance / task budget / LOD distances | `VoxelSettings.h` (no recompile of logic — data asset). | +| LOD step mapping | `AVoxelWorld::LODToStep` VoxelWorld.cpp:268; `GetLODForChunk` :242. | +| How chunks stream in/out | `UpdateChunksAroundPosition` VoxelWorld.cpp:362. | +| Async threading / stale-result handling | `LoadChunk` :445, `ProcessPendingChunks` :301, Epoch logic. | +| Add a new cave feature / terrain op | Add enum in `VoxelTerrainOpDefinition.h:36`, params there, `ApplyTo` (.cpp:6), transport fields in `FStrateGenerationParams`, consume it in a new Step inside `GetDensityWithParams`. | +| Tweak room/tunnel shapes | `VoxelCaveMorphology.cpp` `BuildChunkCache` :47 / `EvaluateSDFCached` :589. | +| Worm tunnel behavior | `GetDensityWithParams` Step 5, VoxelGenerator.cpp:1241. | +| Strate stacking / which strate where | `UVoxelStrateManager::Initialize` :10. | +| Boundary blend between strates | `GetGenerationParams` :515 + `FStrateGenerationParams::Lerp` (StrateTypes.h:844). | +| Passages between strates | `GeneratePassages` :146 + `EvaluateModifierSDF` :371 + `ApplyPassageCarving` (Generator.cpp:197). | +| Player carve/fill | `CarveAtPosition`/`FillAtPosition` VoxelWorld.cpp:691/709 → `UVoxelDiffLayer::ApplyModification` :63. | +| Mesh smoothness / normals | `UVoxelMarchingCubesMesher::ComputeGradientNormal` :48, `IsoLevel`/`GradientOffset` (h:51/55). | +| New slab/flat-world generator | `GetSlabDensity` Generator.cpp:1306 + `FSlabGenerationParams` (StrateTypes.h:1019). | +| Season reset | `AVoxelWorld::ChangeSeed` :740. | + +--- + +## 6. Conventions & gotchas + +- **Density sign is the #1 source of confusion.** Internally (MC) **negative = solid, + positive = air**. Carve = subtract density (toward positive); Fill = add (toward negative). + `FVoxelModification::Strength` negative = carve. Comments sometimes say the inverse in + different layers — trust the MC convention at the mesher. +- **Coordinate units:** density functions take **voxel coords** (not cm). World↔voxel + conversions live in `VoxelTypes.h`. Mesher converts before calling the generator. +- **Determinism:** all randomness is hash-of-(coord, seed, strateIndex) — no RNG state. + Same seed ⇒ identical world. Player edits are the only non-deterministic overlay. +- **Async safety:** background tasks must check `bShuttingDown` and only touch the + generator/mesher (no UObject mutation). Results return via `ProcessQueue`. `EndPlay` + blocks until `ActiveTaskCount == 0`. +- **Epoch:** every regeneration bumps `GenerationEpoch`; results tagged with an old epoch + are dropped in `ProcessPendingChunks`. Always carry the epoch through new async paths. +- **Generated code** under `Intermediate/` and `Binaries/` is build output — never edit. + `*.generated.h` / `*.gen.cpp` are UHT output for the `UCLASS`/`USTRUCT` above. + +--- + +## 7. Files NOT to touch +`Binaries/`, `Intermediate/` — compiler/UHT output, regenerated on build. +`MarchingCubesTables.h` — canonical reference tables, only change if switching MC variant. + +--- + +## 8. Archetypes, spine, disturbances, content & carving (2026 redesign) + +A large A-to-Z expansion. The world is a stack of strates the player descends through; +each strate can be a fundamentally different *archetype*, connected at (0,0). + +### 8.1 Archetypes (`ECaveGeneratorType`, VoxelStrateTypes.h) +Each archetype has its own param `USTRUCT` (on `UVoxelStrateDefinition`, EditCondition-gated +by `GeneratorType`) and its own density function in `VoxelGenerator.cpp`, dispatched by the +`switch` in `GetDensityAt`. + +| Archetype | Params struct | Density fn | Idea | +|-----------|---------------|------------|------| +| TunnelNetwork | `FStrateGenerationParams` | `GetDensityWithParams` | rooms+tunnels (original) | +| FlatPlain / CrystalChamber | `FSlabGenerationParams` | `GetSlabDensity` | floor/ceiling void (original) | +| Maze | `FMazeGenerationParams` | `GetMazeDensity` | tight corridors on a 3D lattice (per-voxel, no cache; edge = lower node + axis hash) | +| SurfaceWorld | `FSurfaceGenerationParams` | `GetSurfaceDensity` | heightfield terrain (continents+ridged mtns+detail), beaches at water line, high sky-cap ceiling | +| VerticalShafts | `FVerticalShaftParams` | `GetVerticalShaftDensity` | full-height shafts + horizontal connectors + partial ledges | +| FloatingIslands | `FFloatingIslandParams` | `GetFloatingIslandDensity` | asymmetric islands: flat land top + underside tapering to a point, lobed (domain-warped) outline, in an open void | +| Underwater | `FStrateGenerationParams` + water | (reuses `GetDensityWithParams`) | tunnel rock + high water table | + +All density fns share the convention: internal **positive=solid**, apply origin spine → +boundary seal → inter-strate passages, then `return -Density` (MC: negative=solid). +StrateManager provides params per chunk via `GetMaze/Surface/VerticalShaft/FloatingIslandParamsForChunk` +(macro `VF_ARCHETYPE_PARAMS_GETTER`) — no cross-boundary blend (Hard transitions between archetypes). + +### 8.2 (0,0) spine & hybrid connections +- `ApplyOriginSpine` (VoxelGenerator.cpp, static helper) carves a guaranteed open vertical + column at XY (0,0) in every strate's **interior** (seals untouched). Radius = + `UVoxelGenerator::OriginSpineRadius` ← `VoxelSettings::OriginSpineRadius`. Called before + every `ApplyBoundarySeal`. +- Descent is **player-dug** through the thin seals at (0,0). The single auto-opened + connection is the **surface entry shaft** at (0,0) through the top of strate 0 + (`GeneratePassages`, `bOpenSurfaceEntry`). +- **Hybrid extras:** auto-carved *shortcut* passages per boundary, placed away from (0,0). + Now fully **per-strate** — see §8.8 (the upper strate's `PassageConfig` drives count/style/shape). + +### 8.3 Disturbance layer (the "wow" post-process) +`FStrateDisturbanceParams` (on the definition, all archetypes). `ApplyDisturbances` +(VoxelGenerator.cpp static, **MC convention**) runs in `GetDensityAt` after dispatch: +chasms (carve air), bridges (solid spans), ridges (solid blades). Stays inside seal bands. +Provided per chunk by `StrateManager::GetDisturbanceParamsForChunk`. + +### 8.4 Cross-chunk determinism (the seam-prevention invariant) +`BuildChunkCache` (VoxelCaveMorphology.cpp) uses **two regions**: a wide *COLLECT* region +(`2*MaxTunnelLength + MaxInfluence`) over which connectivity is decided (NN filtered to +`<= MaxTunnelLength`, origin cap = deterministic top-N by hash), and a tight *STORE* region +(`+MaxInfluence`) kept for per-voxel eval. This makes the room/tunnel graph window-invariant. +**If you add a connectivity rule with longer edges, the COLLECT region must still cover the +max edge reach, and decisions must not depend on the stored window.** + +### 8.5 Content scatter & water — `VoxelContentManager.h/.cpp` (NEW) +`UVoxelContentManager` (owned by `AVoxelWorld`, game-thread). Chunk lifecycle: +`PopulateChunk(coord, mesh, LOD)` in `ApplyMeshToChunk`, `ClearChunk` in `UnloadChunk`, +`ClearAll`/`SetSeed` in `ChangeSeed`. Deterministic decoration scatter on mesh vertices +(surface-type / water-relative / align-to-normal / random-yaw / scale / `MaxPerChunk`, from +`FStrateDecoration`) — **only at LOD 0** (perf). Aesthetic water = one scaled engine plane +(`/Engine/BasicShapes/Plane`) per water-surface chunk (any LOD), material +`UVoxelStrateDefinition::WaterMaterial`. Water Z: `bHasWater` + `WaterLevelRelative` +(Surface/tunnel params) → `StrateManager::GetWaterLevelWorldZForChunk`. + +### 8.6 Atmosphere — `VoxelAtmosphereManager.h/.cpp` (NEW) +`UVoxelAtmosphereManager` (owned by `AVoxelWorld`, gated by `bManageAtmosphere`). +`UpdateForPlayer(pos)` each Tick, reacts only on strate change. Drives a managed +`UExponentialHeightFogComponent` + movable `USkyLightComponent` from the player's strate +(`FogColor/FogDensity/bVolumetricFog/AmbientLightColor/AmbientLightIntensity`), and spawns +PERSISTENT ceiling/floor "layer" actors (`Def->CeilingLayerActor`/`FloorLayerActor` + ZOffsets ++ rotations) that follow the player in XY — the sky-island sea-of-clouds / two-sided fog. +`Def->AtmosphereActor` (a full BP with your own fog/sky/postprocess) OVERRIDES the managed +fog+sky for that strate. `Reset()` on ChangeSeed/EndPlay. (Skylight ambient underground is +weak — captures a dark scene; fog is the strong visual.) + +### 8.7 Inter-strate bedrock gap +`VoxelSettings::InterStrateGapChunks` (N) inserts N chunks of SOLID bedrock between consecutive +strates (`StrateManager::Initialize` leaves the gap in the layout). `IsGapChunk` detects it; +`GetDensityAt` renders gap chunks as solid + passages only (no caves/spine/seal) so the player +digs (0,0) through the gap to descend. `GetStrateUnrealZRange` gives a strate's cm Z range. + +### 8.8 Inter-strate passages — PER-STRATE (`FStratePassageConfig` on the definition) +Each strate's `PassageConfig` (VoxelStrateTypes.h) controls its descent tunnels to the layer +below: `Connections`, `Style` (`EVoxelPassageStyle`: Straight/Worm/Spiral/Cascading), +`MouthRadius`/`MidRadius` (tapered width → `FVoxelPassage::ControlRadii` + `VoxelSDF::TaperedCapsule`), +`ReachMin/Max` (depth into each strate), `DistanceMin/Max` (from the (0,0) spine), `Wander`, +`Segments`, `VerticalWobble`, Spiral/Cascade params. Built in `StrateManager::GeneratePassages` +as control-point chains. **Worm = independent fBM per horizontal axis** (`PassageFBM` static) +with a flat-top envelope → organic squirm (NOT a 1D zigzag, NOT a same-freq 2-channel spiral). +`EvaluateModifierSDF` (per voxel) **bounding-sphere-culls** each passage +(`FVoxelPassage::BoundCenter/BoundRadiusSq`) — perf-critical. The (0,0) surface entry is a +simple straight tube. Global passage settings were removed from `VoxelSettings`. + +### 8.9 Carving — brush shapes + editor controls +`FVoxelModification` has `EVoxelBrushShape {Sphere,Box,Capsule}` + `BoxExtent`/`CapsuleEnd`/ +`Falloff` + `GetWorldBounds`. `UVoxelDiffLayer::GetDensityOffset` switches per shape; chunk +overlap uses the shape AABB. `AVoxelWorld`: `CarveBox/FillBox/CarveCapsule/FillCapsule/ +ApplyModification` (BlueprintCallable) + `EditorCarveSphere/EditorFillSphere` (CallInEditor) +driven by `EditorBrush*` props. + +### 8.10 Performance invariants (DON'T regress) +- **Streaming** (`UpdateChunksAroundPosition`): rebuild/cull the desired set ONLY when the + player crosses a chunk boundary (`LastUpdateCenter`); use the `DesiredSet` TSet for the cull; + idle via `bAllChunksLoaded`. Stationary player ≈ free. (Old per-frame O(loaded×desired) scan = 22ms.) +- **LOD** changes HOT-SWAP (`LoadChunk` only, never unload-first) → no holes. LOD + reconciliation lives in the PERSISTENT per-frame submit loop (same loop as new-chunk loads), + NOT as a one-shot on the boundary-cross frame — a one-shot drops every chunk past the task + budget and strands it at a stale LOD. Idle (`bAllChunksLoaded`) only when a full scan finds + no loads AND no LOD mismatches outstanding. +- **SDF cache** (`GetDensityWithParams`): search-BOX validity, not chunk-key — gradient ±1 + sampling must not thrash the (expensive) rebuild. +- **Per-chunk param cache** in `GetDensityAt`: GenType + param struct + disturbance cached + thread-locally per chunk; don't move the fetch/blend back to per-voxel. +- **Passage cull** (§8.8) + **morphology two-region** (§8.4): both are per-voxel-cost critical. +- **Mesher density grid** (`GenerateMesh`): sample each grid point ONCE into a flat + `(CHUNK_SIZE/Step + 1)³` array, then the cell loop reads its 8 corners from it. Adjacent + cells share corners, so per-cell sampling calls `GetDensityAt` ~8× too often (262k→36k at + LOD0). Output is bit-identical (`GetDensityAt` is pure in world coords). Don't refactor + back to per-corner `GetDensity` calls inside the cell loop. +- **`ProcessQueue` MUST be `EQueueMode::Mpsc`** (`VoxelWorld.h`): up to `MaxConcurrentTasks` + `ChunkGen` worker threads `Enqueue` concurrently; the game thread is the sole consumer. + The default `Spsc` is single-producer — concurrent enqueues race the tail link and silently + DROP results, leaking `PendingChunkCoord` slots until the budget is exhausted and streaming + stalls for good (intermittent; worst during the completion bursts right after the player moves). + +### 8.11 Live tuning & debug (`AVoxelWorld`, CallInEditor / PIE) +- `RebuildStrates` — re-reads ALL of `VoxelSettings` and rebuilds layout/gap/passages/spine + + regenerates. Use after changing those (plain `RegenerateAllChunks` keeps the old layout/passages). +- `bDebugDrawPassages` — draws every passage (cyan path, green=upper / red=lower endpoints). +- `EditorCarveSphere`/`EditorFillSphere` + `EditorBrush*` props — manual carve/fill in PIE. + +### 8.12 Authoring a strate (data asset) +1. Create `UVoxelStrateDefinition`, pick `GeneratorType` → its param group appears; tune it. +2. `PassageConfig` → how THIS strate connects DOWN (count / style / tapered width / length / placement). +3. `Disturbances` for chasms/bridges/ridges; `bHasWater`+`WaterMaterial`(+`WaterLevelRelative`) for water. +4. Atmosphere: `FogColor/Density`, `AmbientLight*`, `bVolumetricFog`, or a full `AtmosphereActor` BP; + `CeilingLayerActor`/`FloorLayerActor` (+offsets/rotations) for cloud seas. +5. `Decorations`/`AmbientActors` (placement rules) for content + lights. +6. Reference from `VoxelSettings` (`StratePool`/`FixedStrates`). Global knobs there: + `OriginSpineRadius`, `bOpenSurfaceEntry`, `InterStrateGapChunks`, view distances, LOD, carving budget. + +### 8.13 New files this redesign +`Public/Private/VoxelContentManager.h/.cpp` (§8.5) · `Public/Private/VoxelAtmosphereManager.h/.cpp` (§8.6). +Everything else extended existing files: `VoxelStrateTypes.h` (archetype params, disturbance, +`FStratePassageConfig`, enums), `VoxelStrateDefinition.h`, `VoxelGenerator.h/.cpp` (archetype +density fns + spine/disturbance/param-cache), `VoxelStrateManager.h/.cpp` (per-archetype getters, +passages, gap, atmosphere Z helper), `VoxelWorld.h/.cpp` (managers, streaming perf, brush API, +editor buttons), `VoxelDiffLayer.h/.cpp` (brush shapes), `VoxelSettings.h`, `VoxelCaveMorphology.cpp` +(two-region determinism). Status: compiles & runs in-editor. diff --git a/Source/VoxelForge/Private/VoxelAtmosphereManager.cpp b/Source/VoxelForge/Private/VoxelAtmosphereManager.cpp new file mode 100644 index 0000000..a7e3515 --- /dev/null +++ b/Source/VoxelForge/Private/VoxelAtmosphereManager.cpp @@ -0,0 +1,159 @@ +// VoxelAtmosphereManager.cpp + +#include "VoxelAtmosphereManager.h" +#include "VoxelStrateManager.h" +#include "VoxelStrateDefinition.h" +#include "Components/ExponentialHeightFogComponent.h" +#include "Components/SkyLightComponent.h" +#include "GameFramework/Actor.h" +#include "Engine/World.h" + +void UVoxelAtmosphereManager::Initialize(AActor* InOwner, UVoxelStrateManager* InStrateManager) +{ + Owner = InOwner; + StrateManager = InStrateManager; + + if (!InOwner) return; + USceneComponent* Root = InOwner->GetRootComponent(); + + // Managed height fog. Hidden until a strate with fog is entered. + Fog = NewObject(InOwner); + Fog->SetMobility(EComponentMobility::Movable); + Fog->RegisterComponent(); + if (Root) Fog->AttachToComponent(Root, FAttachmentTransformRules::KeepRelativeTransform); + Fog->SetVisibility(false); + + // Managed skylight for controllable ambient (movable so we can change it live). + Sky = NewObject(InOwner); + Sky->SetMobility(EComponentMobility::Movable); + Sky->SourceType = ESkyLightSourceType::SLS_CapturedScene; + Sky->bLowerHemisphereIsBlack = false; // let LowerHemisphereColor act as flat ambient + Sky->RegisterComponent(); + if (Root) Sky->AttachToComponent(Root, FAttachmentTransformRules::KeepRelativeTransform); +} + +void UVoxelAtmosphereManager::UpdateForPlayer(const FVector& PlayerWorldPos) +{ + if (!StrateManager) return; + AActor* O = Owner.Get(); + if (!O) return; + + const int32 Idx = StrateManager->GetStrateIndex(PlayerWorldPos.Z); + const UVoxelStrateDefinition* Def = StrateManager->GetStrateAt(PlayerWorldPos.Z); + + // React only when the player changes strate (cheap on every other frame). + if (Idx != CurrentStrateIndex) + { + CurrentStrateIndex = Idx; + ApplyStrate(Def); + } + + // Anchor the full-atmosphere BP to the player (so any localized volumes/effects + // inside it follow). Global fog/skylight ignore position, so this is harmless. + if (AtmosphereActorInstance) + { + AtmosphereActorInstance->SetActorLocation(PlayerWorldPos); + } + + // Keep the layer actors centered on the player in XY, glued to the strate bounds, + // with the authored rotation applied. + if (Def) + { + float TopZ, BottomZ; + if (StrateManager->GetStrateUnrealZRange(PlayerWorldPos.Z, TopZ, BottomZ)) + { + if (CeilingActor) + { + CeilingActor->SetActorLocationAndRotation( + FVector(PlayerWorldPos.X, PlayerWorldPos.Y, TopZ + Def->CeilingLayerZOffset), + Def->CeilingLayerRotation); + } + if (FloorActor) + { + FloorActor->SetActorLocationAndRotation( + FVector(PlayerWorldPos.X, PlayerWorldPos.Y, BottomZ + Def->FloorLayerZOffset), + Def->FloorLayerRotation); + } + } + } +} + +void UVoxelAtmosphereManager::ApplyStrate(const UVoxelStrateDefinition* Def) +{ + AActor* O = Owner.Get(); + UWorld* W = O ? O->GetWorld() : nullptr; + + FActorSpawnParameters Params; + Params.Owner = O; + Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + + // ── FULL ATMOSPHERE OVERRIDE ── + // If the strate provides its own atmosphere BP, it owns the entire look; spawn it + // and disable the plugin's managed fog/skylight so they don't double up. + if (AtmosphereActorInstance) { AtmosphereActorInstance->Destroy(); AtmosphereActorInstance = nullptr; } + + const bool bUseOverride = (Def && Def->AtmosphereActor); + if (bUseOverride && W) + { + AtmosphereActorInstance = W->SpawnActor(Def->AtmosphereActor, FTransform::Identity, Params); + } + + // ── Managed fog (only when NOT overridden) ── + if (Fog) + { + if (!bUseOverride && Def && Def->FogDensity > 0.0f) + { + Fog->SetVisibility(true); + // FogDensity is authored 0..1; EHF density is tiny — scale into a sane range. + Fog->SetFogDensity(Def->FogDensity * 0.05f); + Fog->SetFogInscatteringColor(Def->FogColor); + Fog->SetVolumetricFog(Def->bVolumetricFog); + } + else + { + Fog->SetVisibility(false); + } + } + + // ── Managed ambient skylight (only when NOT overridden) ── + if (Sky) + { + if (!bUseOverride && Def) + { + Sky->SetIntensity(Def->AmbientLightIntensity); + Sky->SetLightColor(Def->AmbientLightColor); + Sky->SetLowerHemisphereColor(Def->AmbientLightColor); + Sky->RecaptureSky(); + } + else + { + Sky->SetIntensity(0.0f); + } + } + + // ── Ceiling / floor layer actors — destroy old, spawn new for this strate ── + // (Independent of the override — you can have cloud seas with either fog path.) + if (CeilingActor) { CeilingActor->Destroy(); CeilingActor = nullptr; } + if (FloorActor) { FloorActor->Destroy(); FloorActor = nullptr; } + + if (Def && W) + { + if (Def->CeilingLayerActor) + { + CeilingActor = W->SpawnActor(Def->CeilingLayerActor, FTransform::Identity, Params); + } + if (Def->FloorLayerActor) + { + FloorActor = W->SpawnActor(Def->FloorLayerActor, FTransform::Identity, Params); + } + } +} + +void UVoxelAtmosphereManager::Reset() +{ + if (AtmosphereActorInstance) { AtmosphereActorInstance->Destroy(); AtmosphereActorInstance = nullptr; } + if (CeilingActor) { CeilingActor->Destroy(); CeilingActor = nullptr; } + if (FloorActor) { FloorActor->Destroy(); FloorActor = nullptr; } + CurrentStrateIndex = INT32_MIN; + if (Fog) Fog->SetVisibility(false); +} diff --git a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp new file mode 100644 index 0000000..b1ca73f --- /dev/null +++ b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -0,0 +1,814 @@ +// VoxelCaveMorphology.cpp +// Hash-based room/tunnel generation and SDF evaluation. +// +// TWO-PHASE EVALUATION: +// Phase 1 — BuildChunkCache: collects rooms, builds backbone, resolves tunnels. +// Called ONCE per chunk (~1 time per 32³ = 32,768 voxels). +// Phase 2 — EvaluateSDFCached: evaluates room/tunnel SDFs with distance culling. +// Called PER VOXEL using the cached data. +// +// FEATURES: +// - Origin room: guaranteed large room at (0,0) per strate — the hub / (0,0) spine +// - Hash-based rooms: ellipsoid, rounded box, and capsule shapes +// - Tunnels: tapered capsules with curved paths (midpoint warping) +// - Horizontal bias: tunnels prefer horizontal connections +// - Endpoint Z offset: tunnels enter rooms at different heights +// - Per-room/tunnel distance culling: skip SDFs that can't affect this voxel +// +// CROSS-CHUNK DETERMINISM (the invariant that prevents seams): +// The room/tunnel GRAPH must reconstruct IDENTICALLY in every chunk whose voxels a +// tunnel touches. Room geometry is a pure hash of its cell, so that half is trivially +// identical. The fragile half is the EXISTENCE decision (the nearest-neighbor +// backbone), which historically depended on the per-chunk window of collected rooms. +// We now separate two regions (see BuildChunkCache): +// - COLLECT region (wide): every room whose NN-candidate set could influence a +// tunnel touching this chunk. Connectivity is decided over THIS set, so the +// decision is window-invariant. +// - STORE region (tight): only rooms/tunnels that can actually reach a voxel in +// this chunk are kept for the per-voxel loop, so hot-path cost stays low. + +#include "VoxelCaveMorphology.h" +#include "VoxelTypes.h" // Pour VOXEL_NOISE_SCALE, SmoothStep01 +#include "VoxelStrateTypes.h" +#include "VoxelTerrainOpDefinition.h" + +//============================================================================= +// INTERNAL: Full room data used during cache building only. +// The CellX/CellY fields are needed for tunnel pair hashing but NOT for +// per-voxel SDF evaluation, so they don't go into the cached FCachedRoom. +//============================================================================= +struct FBuildRoom +{ + FVector Center; // World position of the room center + float RadiusXY; // Horizontal radius + float RadiusZ; // Vertical radius + int32 CellX, CellY; // Grid cell (needed for pair hash during tunnel decisions) + uint32 Hash; // Cell hash (for shape selection + tunnel property derivation) + bool bIsOrigin; // True for the origin room at (0,0) + bool bStore; // True if this room can affect a voxel in THIS chunk + // (collected for connectivity decisions either way). +}; + +//============================================================================= +// PHASE 1: BUILD CHUNK CACHE +//============================================================================= +// Collects all rooms in the COLLECT region, computes a window-invariant +// nearest-neighbor backbone for connectivity, decides tunnel connections, and +// pre-computes all tunnel geometry (radii, Z offsets, midpoint warping, bounding +// spheres). Only rooms/tunnels relevant to the chunk (STORE region) are kept. +// +// The result is stored in OutCache and reused for every voxel in the chunk. + +void VoxelCaveMorphology::BuildChunkCache( + FChunkSDFCache& OutCache, + float SearchMinX, float SearchMinY, + float SearchMaxX, float SearchMaxY, + const FStrateGenerationParams& Params, + uint32 Seed, int32 StrateIndex, + const TArray* TerrainOps) +{ + // Clear previous data (arrays keep their allocation for reuse) + OutCache.Rooms.Reset(); + OutCache.Tunnels.Reset(); + OutCache.Pits.Reset(); + OutCache.Chimneys.Reset(); + OutCache.Columns.Reset(); + + // Combine world seed with strate index so each strate gets unique caves + const uint32 StrateSeed = VoxelHash::Mix(Seed ^ (uint32)(StrateIndex * 7919 + 104729)); + + const float CellSize = Params.RoomSpacing; + if (CellSize <= 0.0f) return; + + //========================================================================= + // INFLUENCE RADII + //========================================================================= + // MaxInfluence = how far a room body / tunnel TUBE reaches PERPENDICULAR to its + // anchor — NOT its length. A room or tunnel whose anchor lies within MaxInfluence + // of a box can touch a voxel inside that box. + const float MaxInfluence = FMath::Max( + Params.MaxRoomRadius, + Params.TunnelWarpStrength + Params.TunnelMaxRadius + ) + Params.SDFBlendRadius; + + const float MaxTunnelLen = FMath::Max(Params.MaxTunnelLength, 0.0f); + + //========================================================================= + // STORE region — what we keep for the per-voxel loop. + //========================================================================= + // A room/tunnel whose influence overlaps the search box is RELEVANT to this + // chunk. STORE box = search box + MaxInfluence. (Per-voxel culling refines this.) + const float StoreMinX = SearchMinX - MaxInfluence; + const float StoreMinY = SearchMinY - MaxInfluence; + const float StoreMaxX = SearchMaxX + MaxInfluence; + const float StoreMaxY = SearchMaxY + MaxInfluence; + + //========================================================================= + // COLLECT region — what we hash into existence for the connectivity decision. + //========================================================================= + // To DECIDE the graph identically in neighboring chunks, we must see, for every + // room that could emit a tunnel touching this chunk, that room's ENTIRE + // nearest-neighbor candidate set (all rooms within MaxTunnelLength of it): + // - A tunnel touching the chunk has BOTH endpoints within + // (MaxTunnelLength + MaxInfluence) of the search box (capsule len <= MaxTunnelLength). + // - Each endpoint's NN candidates lie within MaxTunnelLength of that endpoint. + // => collect within (2 * MaxTunnelLength + MaxInfluence) of the search box. + // Combined with NN candidates being filtered to <= MaxTunnelLength below, this + // makes the backbone decision for any STORED tunnel window-invariant. + const float CollectMargin = 2.0f * MaxTunnelLen + MaxInfluence; + const float CollectMinX = SearchMinX - CollectMargin; + const float CollectMinY = SearchMinY - CollectMargin; + const float CollectMaxX = SearchMaxX + CollectMargin; + const float CollectMaxY = SearchMaxY + CollectMargin; + + // Convert COLLECT bounds to cell range + const int32 CellMinX = FMath::FloorToInt(CollectMinX / CellSize); + const int32 CellMaxX = FMath::FloorToInt(CollectMaxX / CellSize); + const int32 CellMinY = FMath::FloorToInt(CollectMinY / CellSize); + const int32 CellMaxY = FMath::FloorToInt(CollectMaxY / CellSize); + + //========================================================================= + // Vertical range for room CENTER placement. + //========================================================================= + // Buffer = seal thickness + max room half-height. + // This guarantees the tallest possible room (MaxRoomRadius * RoomHeightRatio) + // fits entirely within the seal boundary — no room gets its ceiling or floor + // cut flat by the seal. Smaller rooms have proportionally more margin. + const float RoomZBuffer = Params.MaxRoomRadius * Params.RoomHeightRatio; + const float StrateMinZ = Params.StrateBottomWorldZ + Params.BoundarySealThickness + RoomZBuffer; + const float StrateMaxZ = Params.StrateTopWorldZ - Params.BoundarySealThickness - RoomZBuffer; + const float StrateRangeZ = StrateMaxZ - StrateMinZ; + const float StrateCenterZ = (StrateMinZ + StrateMaxZ) * 0.5f; + + const float BlendK = Params.SDFBlendRadius; + + // Conservative "this room can reach the STORE box" test. A room's cull sphere + // radius is <= MaxInfluence, and STORE box already includes a MaxInfluence + // margin, so testing the center against the STORE box is a correct superset. + auto CenterInStoreBox = [&](const FVector& C) -> bool + { + return C.X >= StoreMinX && C.X <= StoreMaxX + && C.Y >= StoreMinY && C.Y <= StoreMaxY; + }; + + // Sphere-vs-search-box test in XY (treated as infinite in Z; per-voxel culling + // resolves Z). Used to decide whether a tunnel is worth storing for this chunk. + auto SphereTouchesSearchXY = [&](const FVector& C, float RSq) -> bool + { + const float dx = FMath::Max3((float)(SearchMinX - C.X), 0.0f, (float)(C.X - SearchMaxX)); + const float dy = FMath::Max3((float)(SearchMinY - C.Y), 0.0f, (float)(C.Y - SearchMaxY)); + return (dx * dx + dy * dy) <= RSq; + }; + + // Temporary array with cell coordinates for tunnel connection decisions + TArray> BuildRooms; + + // --- ORIGIN ROOM --- + // Guaranteed large room at (0, 0) in each strate — the (0,0) descent spine hub. + // Collected whenever (0,0) is inside the COLLECT region so it participates in the + // connectivity decision; only stored if it can reach this chunk. + int32 OriginIdx = -1; + if (Params.OriginRoomRadius > 0.0f) + { + if (0.0f >= CollectMinX && 0.0f <= CollectMaxX && + 0.0f >= CollectMinY && 0.0f <= CollectMaxY) + { + FBuildRoom OriginRoom; + OriginRoom.CellX = INT32_MAX; // Sentinel — never matches a real grid cell + OriginRoom.CellY = INT32_MAX; + OriginRoom.Hash = VoxelHash::Cell(0, 0, StrateSeed ^ 0x0A161Cu); + OriginRoom.Center = FVector(0.0f, 0.0f, StrateCenterZ); + OriginRoom.RadiusXY = Params.OriginRoomRadius; + OriginRoom.RadiusZ = Params.OriginRoomRadius * Params.RoomHeightRatio; + OriginRoom.bIsOrigin = true; + OriginRoom.bStore = CenterInStoreBox(OriginRoom.Center); + OriginIdx = BuildRooms.Add(OriginRoom); + } + } + + // --- HASH-BASED ROOMS --- + for (int32 CY = CellMinY; CY <= CellMaxY; CY++) + { + for (int32 CX = CellMinX; CX <= CellMaxX; CX++) + { + // Hash this cell to decide if it has a room + const uint32 CellHash = VoxelHash::Cell(CX, CY, StrateSeed); + const float RoomChance = VoxelHash::ToFloat01(CellHash); + + // Skip empty cells (no room here) + if (RoomChance >= Params.RoomDensity) continue; + + // Room position: jittered within the cell + const float JitterX = VoxelHash::ToFloat01(VoxelHash::Mix(CellHash ^ 0x12345678u)); + const float JitterY = VoxelHash::ToFloat01(VoxelHash::Mix(CellHash ^ 0x9ABCDEF0u)); + const float JitterZ = VoxelHash::ToFloat01(VoxelHash::Mix(CellHash ^ 0x55AA55AAu)); + + FBuildRoom Room; + Room.CellX = CX; + Room.CellY = CY; + Room.Hash = CellHash; + Room.Center.X = (CX + 0.15f + JitterX * 0.7f) * CellSize; + Room.Center.Y = (CY + 0.15f + JitterY * 0.7f) * CellSize; + Room.Center.Z = StrateMinZ + JitterZ * FMath::Max(StrateRangeZ, 1.0f); + + // Room size: lerp between min and max + const float SizeFactor = VoxelHash::ToFloat01(VoxelHash::Mix(CellHash ^ 0xFEDCBA98u)); + Room.RadiusXY = FMath::Lerp(Params.MinRoomRadius, Params.MaxRoomRadius, SizeFactor); + Room.RadiusZ = Room.RadiusXY * Params.RoomHeightRatio; + Room.bIsOrigin = false; + Room.bStore = CenterInStoreBox(Room.Center); + + BuildRooms.Add(Room); + } + } + + const int32 NumRooms = BuildRooms.Num(); + if (NumRooms == 0) return; + + //========================================================================= + // Window-invariant nearest-neighbor backbone + //========================================================================= + // Each room's nearest neighbor (among rooms within MaxTunnelLength) is a + // GUARANTEED tunnel connection. Filtering candidates to <= MaxTunnelLength is + // what makes the result identical across chunks: rooms beyond MaxTunnelLength + // can never be a tunnel anyway, so excluding them removes the only source of + // window dependence. This builds a connected tree backbone; TunnelDensity adds + // loops on top. + TArray> NearestNeighbor; + NearestNeighbor.SetNumUninitialized(NumRooms); + + const float MaxTunnelLenSq = MaxTunnelLen * MaxTunnelLen; + + for (int32 I = 0; I < NumRooms; I++) + { + float BestDistSq = FLT_MAX; + int32 BestJ = -1; + for (int32 J = 0; J < NumRooms; J++) + { + if (I == J) continue; + const float DSq = FVector::DistSquared(BuildRooms[I].Center, BuildRooms[J].Center); + if (DSq > MaxTunnelLenSq) continue; // out of reach — never a tunnel + if (DSq < BestDistSq) + { + BestDistSq = DSq; + BestJ = J; + } + } + NearestNeighbor[I] = BestJ; + } + + //========================================================================= + // ORIGIN CONNECTION CAP (deterministic, order-independent) + //========================================================================= + // OriginRoomMaxConnections caps how many rooms backbone-force into the origin. + // The OLD approach counted connections in pair-loop order, which depended on the + // per-chunk room ordering → non-deterministic across chunks. Instead we gather + // ALL rooms backbone-linked to origin (window-invariant given the COLLECT region), + // rank them by a deterministic pair hash, and keep only the top N as forced. + // The rest are DOWNGRADED to the random TunnelDensity path (connectivity not + // broken, only the "guaranteed" aspect is limited). + TSet OriginDowngraded; + const int32 MaxOriginConn = Params.OriginRoomMaxConnections; + if (OriginIdx >= 0 && MaxOriginConn > 0) + { + // Collect origin backbone candidates with a deterministic ranking key. + TArray, TInlineAllocator<32>> OriginLinks; + for (int32 I = 0; I < NumRooms; I++) + { + if (I == OriginIdx) continue; + const bool bLinked = (NearestNeighbor[I] == OriginIdx) || (NearestNeighbor[OriginIdx] == I); + if (!bLinked) continue; + const uint32 Key = VoxelHash::Pair( + BuildRooms[OriginIdx].CellX, BuildRooms[OriginIdx].CellY, + BuildRooms[I].CellX, BuildRooms[I].CellY, + StrateSeed ^ 0x031A1Eu); + OriginLinks.Add(TPair(Key, I)); + } + // Stable deterministic order by (hash, then index for tie-break). + OriginLinks.Sort([](const TPair& A, const TPair& B) + { + return A.Key != B.Key ? A.Key < B.Key : A.Value < B.Value; + }); + for (int32 R = MaxOriginConn; R < OriginLinks.Num(); ++R) + { + OriginDowngraded.Add(OriginLinks[R].Value); + } + } + + //========================================================================= + // Resolve tunnel connections, pre-compute geometry, store chunk-relevant ones + //========================================================================= + // A tunnel exists if EITHER: + // 1. One is the other's nearest neighbor (backbone — guarantees connectivity), + // and (for origin links) it survived the origin cap, OR + // 2. The pair hash passes TunnelDensity (random extra loops). + // Both must pass the distance check (MaxTunnelLength). Only tunnels whose bounding + // sphere reaches the search box are stored for the per-voxel loop. + for (int32 I = 0; I < NumRooms; I++) + { + for (int32 J = I + 1; J < NumRooms; J++) + { + const FBuildRoom& RoomA = BuildRooms[I]; + const FBuildRoom& RoomB = BuildRooms[J]; + bool bBackbone = (NearestNeighbor[I] == J) || (NearestNeighbor[J] == I); + + // Origin cap: downgrade backbone links beyond the deterministic top-N. + if (bBackbone && (RoomA.bIsOrigin || RoomB.bIsOrigin)) + { + const int32 Other = RoomA.bIsOrigin ? J : I; + if (OriginDowngraded.Contains(Other)) + { + bBackbone = false; // Let TunnelDensity decide instead + } + } + + // --- DISTANCE CHECK --- + const float EuclidDist = FVector::Dist(RoomA.Center, RoomB.Center); + float CheckDist = EuclidDist; + + // Horizontal bias: penalize vertical separation for non-backbone tunnels + if (!bBackbone && Params.TunnelHorizontalBias > 0.0f) + { + const float VertSep = FMath::Abs(RoomA.Center.Z - RoomB.Center.Z); + CheckDist += VertSep * Params.TunnelHorizontalBias * 5.0f; + } + + if (CheckDist > Params.MaxTunnelLength) continue; + + // --- CONNECTION DECISION --- + if (!bBackbone) + { + const uint32 PairHash = VoxelHash::Pair( + RoomA.CellX, RoomA.CellY, + RoomB.CellX, RoomB.CellY, + StrateSeed + ); + const float ConnectChance = VoxelHash::ToFloat01(PairHash); + if (ConnectChance >= Params.TunnelDensity) continue; + } + + // --- TUNNEL HASH (for deriving all tunnel properties) --- + const uint32 TunnelHash = VoxelHash::Pair( + RoomA.CellX, RoomA.CellY, + RoomB.CellX, RoomB.CellY, + StrateSeed ^ 0xDECAF001u + ); + + // --- RADIUS --- + const float FactorA = VoxelHash::ToFloat01(VoxelHash::Mix(TunnelHash ^ 0xBAADF00Du)); + const float FactorB = VoxelHash::ToFloat01(VoxelHash::Mix(TunnelHash ^ 0x8BADF00Du)); + const float RadA = FMath::Lerp(Params.TunnelMinRadius, Params.TunnelMaxRadius, FactorA); + const float RadB = FMath::Lerp(Params.TunnelMinRadius, Params.TunnelMaxRadius, FactorB); + + // --- ENDPOINT Z OFFSET --- + const float ZOffsetA = VoxelHash::ToFloatSigned(VoxelHash::Mix(TunnelHash ^ 0xA1B2C3D4u)) + * RoomA.RadiusZ * Params.TunnelEndpointZOffset; + const float ZOffsetB = VoxelHash::ToFloatSigned(VoxelHash::Mix(TunnelHash ^ 0xD4C3B2A1u)) + * RoomB.RadiusZ * Params.TunnelEndpointZOffset; + + FVector EndA = RoomA.Center + FVector(0.0f, 0.0f, ZOffsetA); + FVector EndB = RoomB.Center + FVector(0.0f, 0.0f, ZOffsetB); + + // --- BUILD CACHED TUNNEL --- + FCachedTunnel CT; + CT.EndpointA = EndA; + CT.EndpointB = EndB; + CT.RadiusA = RadA; + CT.RadiusB = RadB; + + // --- PATH WARPING --- + const float TunnelLength = FVector::Dist(EndA, EndB); + + if (Params.TunnelWarpStrength > 0.0f && TunnelLength > 1.0f) + { + FVector TunnelDir = (EndB - EndA).GetSafeNormal(); + FVector PerpH = FVector(-TunnelDir.Y, TunnelDir.X, 0.0f); + + float MaxWarp = FMath::Min(Params.TunnelWarpStrength, TunnelLength * 0.25f); + float WarpH = VoxelHash::ToFloatSigned(VoxelHash::Mix(TunnelHash ^ 0x1234ABCDu)) * MaxWarp; + float WarpV = VoxelHash::ToFloatSigned(VoxelHash::Mix(TunnelHash ^ 0x5678EF01u)) * MaxWarp * 0.3f; + + FVector Mid = (EndA + EndB) * 0.5f; + Mid += PerpH * WarpH + FVector(0.0f, 0.0f, WarpV); + + CT.Midpoint = Mid; + CT.RadiusMid = (RadA + RadB) * 0.5f; + CT.bHasMidpoint = true; + + // Bounding sphere: encloses all 3 control points + max radius + CT.BoundCenter = (EndA + Mid + EndB) / 3.0f; + float MaxR = FMath::Max3(RadA, RadB, CT.RadiusMid) + BlendK; + float DistA = FVector::Dist(CT.BoundCenter, EndA); + float DistM = FVector::Dist(CT.BoundCenter, Mid); + float DistB = FVector::Dist(CT.BoundCenter, EndB); + float BoundR = FMath::Max3(DistA, DistM, DistB) + MaxR; + CT.BoundRadiusSq = BoundR * BoundR; + } + else + { + CT.Midpoint = FVector::ZeroVector; + CT.RadiusMid = 0.0f; + CT.bHasMidpoint = false; + + // Bounding sphere: encloses both endpoints + max radius + CT.BoundCenter = (EndA + EndB) * 0.5f; + float MaxR = FMath::Max(RadA, RadB) + BlendK; + float HalfLen = TunnelLength * 0.5f; + float BoundR = HalfLen + MaxR; + CT.BoundRadiusSq = BoundR * BoundR; + } + + // Store only if this tunnel can actually reach a voxel in this chunk. + if (SphereTouchesSearchXY(CT.BoundCenter, CT.BoundRadiusSq)) + { + OutCache.Tunnels.Add(CT); + } + } + } + + //========================================================================= + // Copy STORE-relevant rooms to cache (cull radii + per-room terrain ops), + // then pre-bake their pits / chimneys / columns. + //========================================================================= + // Build terrain op pool stats once (outside the per-room loop). + // TerrainOps is the strate's probability pool; each entry has a Probability + // in [0,1]. We do a weighted random draw per room using the room's hash. + // + // PROBABILITY MODEL: + // - Entries are checked cumulatively (like drawing from a bucket). + // - The "no op" slot takes the remaining probability space (if sum < 1.0). + // - If sum >= 1.0, every room gets an op (normalized selection). + float TotalOpProb = 0.0f; + if (TerrainOps) + { + for (const FStrateTerrainOpEntry& E : *TerrainOps) + TotalOpProb += FMath::Max(E.Probability, 0.0f); + } + const float NormFactor = (TotalOpProb > 1.0f) ? (1.0f / TotalOpProb) : 1.0f; + + // Temporary params struct for reading op fields (PitDensity, PitMinRadius, etc.) + FStrateGenerationParams OpParams; + + OutCache.Rooms.Reserve(NumRooms); + + for (const FBuildRoom& BR : BuildRooms) + { + if (!BR.bStore) continue; // Far room — collected for connectivity only + + FCachedRoom CR; + CR.Center = BR.Center; + CR.RadiusXY = BR.RadiusXY; + CR.RadiusZ = BR.RadiusZ; + CR.Hash = BR.Hash; + CR.bIsOrigin = BR.bIsOrigin; + + // Cull radius: max extent the room can reach + blend margin. + // 1.5x accounts for capsule shapes extending beyond nominal radius. + float MaxExtent = FMath::Max(BR.RadiusXY * 1.5f, BR.RadiusZ) + BlendK * 3.0f; + CR.CullRadiusSq = MaxExtent * MaxExtent; + + // Flat floor cut: soft floor plane per room, hash-rolled from [Min, Max]. + // SmoothMax applied in EvaluateSDFCached so tunnels/pits don't create hard seams. + // Sentinel -FLT_MAX means "no cut" so the per-voxel check is a single compare. + { + const float FloorRoll = VoxelHash::ToFloat01(VoxelHash::Mix(BR.Hash ^ 0xF100F2u)); + const float FloorCut = FMath::Lerp( + FMath::Min(Params.RoomFloorCutMin, Params.RoomFloorCutMax), + FMath::Max(Params.RoomFloorCutMin, Params.RoomFloorCutMax), + FloorRoll + ); + + if (FloorCut < 1.0f) + { + CR.FloorCutZ = CR.Center.Z - CR.RadiusZ * FloorCut; + CR.FloorReliefStrength = Params.FloorReliefStrength; + CR.FloorReliefFrequency = Params.FloorReliefFrequency; + CR.FloorSeed = VoxelHash::Mix(BR.Hash ^ 0xF100F1u); + } + else + { + CR.FloorCutZ = -FLT_MAX; + CR.FloorReliefStrength = 0.0f; + CR.FloorReliefFrequency = 0.015f; + CR.FloorSeed = 0; + } + } + + // --- PER-ROOM TERRAIN OP SELECTION --- + if (TerrainOps && TerrainOps->Num() > 0 && TotalOpProb > 0.0f) + { + const uint32 OpHash = VoxelHash::Mix(BR.Hash ^ 0x0FEED00u); + const float Roll = VoxelHash::ToFloat01(OpHash); + + float Cursor = 0.0f; + for (const FStrateTerrainOpEntry& E : *TerrainOps) + { + Cursor += FMath::Max(E.Probability, 0.0f) * NormFactor; + if (Roll < Cursor) + { + const UVoxelTerrainOpDefinition* Op = E.Operation.Get(); + if (Op) + { + CR.RoomOp = Op; + CR.RoomOpWeight = E.Weight; + } + break; + } + } + } + + OutCache.Rooms.Add(CR); + + //--------------------------------------------------------------------- + // PRE-BAKE: PITS, CHIMNEYS, COLUMNS for this room + //--------------------------------------------------------------------- + // Pre-baking makes features independent of NearestRoomIdx (no thin "lid" + // when the owning room flips mid-shaft). Only stored rooms are baked — + // a far room's features can't reach this chunk anyway. + if (!CR.RoomOp) continue; + + OpParams = FStrateGenerationParams{}; + CR.RoomOp->ApplyTo(OpParams, CR.RoomOpWeight); + + // PITS + if (OpParams.PitDensity > 0.0f) + { + const int32 MaxPits = 2; + for (int32 i = 0; i < MaxPits; i++) + { + uint32 PH = VoxelHash::Mix(BR.Hash ^ (0xDE1A7Eu + (uint32)i * 6271u)); + if (VoxelHash::ToFloat01(PH) > OpParams.PitDensity) continue; + + uint32 PH2 = VoxelHash::Mix(PH ^ 0xABCDu); + uint32 PH3 = VoxelHash::Mix(PH2 ^ 0x5EEDu); + uint32 PH4 = VoxelHash::Mix(PH3 ^ 0xF00Du); + + float PX = CR.Center.X + VoxelHash::ToFloatSigned(PH2) * CR.RadiusXY * 0.6f; + float PY = CR.Center.Y + VoxelHash::ToFloatSigned(VoxelHash::Mix(PH2)) * CR.RadiusXY * 0.6f; + + float PitRadius = FMath::Lerp(OpParams.PitMinRadius, OpParams.PitMaxRadius, + VoxelHash::ToFloat01(PH3)); + + float PitTopZ = CR.Center.Z - CR.RadiusZ * 0.5f + + VoxelHash::ToFloat01(PH4) * CR.RadiusZ * 0.2f; + + FCachedPit Pit; + Pit.CenterX = PX; + Pit.CenterY = PY; + Pit.TopZ = PitTopZ; + Pit.Radius = PitRadius; + Pit.Depth = OpParams.PitDepth; + Pit.FlareDist = PitRadius * 2.0f; + Pit.FlareExtra = PitRadius * 1.0f; + Pit.BaseDensity = Params.BaseDensity; + Pit.BlendK = Params.SDFBlendRadius; + float MaxXYR = PitRadius + PitRadius + Params.SDFBlendRadius + 4.0f; + Pit.BoundXYRadiusSq = MaxXYR * MaxXYR; + OutCache.Pits.Add(Pit); + } + } + + // CHIMNEYS + if (OpParams.ChimneyDensity > 0.0f) + { + const int32 MaxChimneys = 2; + for (int32 i = 0; i < MaxChimneys; i++) + { + uint32 CH = VoxelHash::Mix(BR.Hash ^ (0xC4F007u + (uint32)i * 7919u)); + if (VoxelHash::ToFloat01(CH) > OpParams.ChimneyDensity) continue; + + uint32 CH2 = VoxelHash::Mix(CH ^ 0x1337u); + uint32 CH3 = VoxelHash::Mix(CH2 ^ 0xCAFEu); + uint32 CH4 = VoxelHash::Mix(CH3 ^ 0xD00Du); + + float CX = CR.Center.X + VoxelHash::ToFloatSigned(CH2) * CR.RadiusXY * 0.6f; + float CY = CR.Center.Y + VoxelHash::ToFloatSigned(VoxelHash::Mix(CH2)) * CR.RadiusXY * 0.6f; + + float ChmRadius = FMath::Lerp(OpParams.ChimneyMinRadius, OpParams.ChimneyMaxRadius, + VoxelHash::ToFloat01(CH3)); + + float ChmBottomZ = CR.Center.Z + CR.RadiusZ * 0.5f + - VoxelHash::ToFloat01(CH4) * CR.RadiusZ * 0.2f; + + FCachedChimney Chim; + Chim.CenterX = CX; + Chim.CenterY = CY; + Chim.BottomZ = ChmBottomZ; + Chim.Radius = ChmRadius; + Chim.Height = OpParams.ChimneyHeight; + Chim.FlareDist = ChmRadius * 2.0f; + Chim.FlareExtra = ChmRadius * 1.0f; + Chim.BaseDensity = Params.BaseDensity; + Chim.BlendK = Params.SDFBlendRadius; + float MaxXYR = ChmRadius + ChmRadius + Params.SDFBlendRadius + 4.0f; + Chim.BoundXYRadiusSq = MaxXYR * MaxXYR; + OutCache.Chimneys.Add(Chim); + } + } + + // COLUMNS + if (OpParams.ColumnDensity > 0.0f) + { + const int32 MaxCols = 4; + for (int32 i = 0; i < MaxCols; i++) + { + uint32 H = VoxelHash::Mix(BR.Hash ^ (0xC01C01u + (uint32)i * 3571u)); + if (VoxelHash::ToFloat01(H) > OpParams.ColumnDensity) continue; + + uint32 H2 = VoxelHash::Mix(H ^ 0x1A2B3Cu); + + float ColX = CR.Center.X + VoxelHash::ToFloatSigned(H2) * CR.RadiusXY * 0.75f; + float ColY = CR.Center.Y + VoxelHash::ToFloatSigned(VoxelHash::Mix(H2)) * CR.RadiusXY * 0.75f; + + uint32 H3 = VoxelHash::Mix(H2 ^ 0xBEEFu); + float ColR = FMath::Lerp(OpParams.ColumnMinRadius, OpParams.ColumnMaxRadius, + VoxelHash::ToFloat01(H3)); + + FCachedColumn Col; + Col.CenterX = ColX; + Col.CenterY = ColY; + Col.Radius = ColR; + Col.BaseDensity = Params.BaseDensity; + float MaxXYR = ColR + 6.0f; + Col.BoundXYRadiusSq = MaxXYR * MaxXYR; + OutCache.Columns.Add(Col); + } + } + } +} + +//============================================================================= +// PHASE 2: EVALUATE SDF WITH CACHED DATA +//============================================================================= +// Pure SDF math — no hashing, no array building, no backbone computation. +// Just loops through the pre-built rooms and tunnels, evaluates distance, +// and smooth-mins everything together. Distance culling skips primitives +// that are clearly too far to contribute. + +float VoxelCaveMorphology::EvaluateSDFCached( + float WorldX, float WorldY, float WorldZ, + const FChunkSDFCache& Cache, + float SDFBlendRadius, + float RoomShapeVariety, + int32* OutNearestRoomIdx) +{ + float MinSDF = FLT_MAX; + const float BlendK = SDFBlendRadius; + const FVector Pos(WorldX, WorldY, WorldZ); + + // Track which room contributes the smallest (most-inside) raw SDF. + // This is used by the terrain ops system to find the "owning" room for + // this voxel and apply that room's per-room terrain operation. + // We track raw room SDF (before SmoothMin) so tunnel SDFs don't interfere. + float NearestRoomRawSDF = FLT_MAX; + int32 NearestIdx = -1; + + //========================================================================= + // Room SDFs + //========================================================================= + for (int32 RoomIdx = 0; RoomIdx < Cache.Rooms.Num(); ++RoomIdx) + { + const FCachedRoom& Room = Cache.Rooms[RoomIdx]; + + // --- DISTANCE CULL --- + const float DistSq = FVector::DistSquared(Pos, Room.Center); + if (DistSq > Room.CullRadiusSq) continue; + + // --- SHAPE SELECTION --- + float RoomSDF; + const uint32 ShapeHash = VoxelHash::Mix(Room.Hash ^ 0xDEADBEEFu); + const float ShapeRoll = Room.bIsOrigin ? 0.0f : VoxelHash::ToFloat01(ShapeHash); + + // Thresholds: Variety=0 → all ellipsoid. Variety=1 → 50/30/20 split. + const float BoxThreshold = 1.0f - RoomShapeVariety * 0.5f; + const float CapsuleThreshold = 1.0f - RoomShapeVariety * 0.2f; + + if (ShapeRoll >= BoxThreshold && ShapeRoll < CapsuleThreshold) + { + // ROUNDED BOX: angular chamber with smooth corners + FVector HalfExtent( + Room.RadiusXY * 0.8f, + Room.RadiusXY * 0.8f, + Room.RadiusZ * 0.8f + ); + float Rounding = Room.RadiusXY * 0.25f; + RoomSDF = VoxelSDF::RoundedBox(Pos, Room.Center, HalfExtent, Rounding); + } + else if (ShapeRoll >= CapsuleThreshold) + { + // ELONGATED CAPSULE: stretched hall/corridor-room + float DirAngle = VoxelHash::ToFloat01(VoxelHash::Mix(Room.Hash ^ 0xCAFEBABEu)) * 2.0f * PI; + float StretchDist = Room.RadiusXY * 0.7f; + FVector Dir(FMath::Cos(DirAngle), FMath::Sin(DirAngle), 0.0f); + FVector EndA = Room.Center + Dir * StretchDist; + FVector EndB = Room.Center - Dir * StretchDist; + float CapsuleR = FMath::Min(Room.RadiusXY * 0.6f, Room.RadiusZ); + RoomSDF = VoxelSDF::Capsule(Pos, EndA, EndB, CapsuleR); + } + else + { + // ELLIPSOID (default): smooth oval chamber + const FVector Radii(Room.RadiusXY, Room.RadiusXY, Room.RadiusZ); + RoomSDF = VoxelSDF::Ellipsoid(Pos, Room.Center, Radii); + } + + // Soft floor: SmoothMax of the room SDF and the floor half-space. + if (Room.FloorCutZ > -FLT_MAX) + { + float FloorZ = Room.FloorCutZ; + + if (Room.FloorReliefStrength > 0.0f) + { + const float RF = Room.FloorReliefFrequency; + const float SF = (float)Room.FloorSeed * 0.00001f; + + float N = FMath::PerlinNoise2D(FVector2D(Pos.X * RF + SF, Pos.Y * RF + SF * 1.7f)) * 0.65f + + FMath::PerlinNoise2D(FVector2D(Pos.X * RF * 2.3f + SF * 3.1f, Pos.Y * RF * 2.3f + SF * 5.3f)) * 0.35f; + N *= VOXEL_NOISE_SCALE; + FloorZ += N * Room.FloorReliefStrength; + } + + RoomSDF = VoxelSDF::SmoothMax(RoomSDF, FloorZ - Pos.Z, BlendK * 0.35f); + } + + // Track the room whose SDF is smallest (most inside). + if (OutNearestRoomIdx && RoomSDF < NearestRoomRawSDF) + { + NearestRoomRawSDF = RoomSDF; + NearestIdx = RoomIdx; + } + + MinSDF = VoxelSDF::SmoothMin(MinSDF, RoomSDF, BlendK); + } + + //========================================================================= + // Tunnel SDFs + //========================================================================= + for (const FCachedTunnel& Tunnel : Cache.Tunnels) + { + // --- BOUNDING SPHERE CULL --- + const float DistSq = FVector::DistSquared(Pos, Tunnel.BoundCenter); + if (DistSq > Tunnel.BoundRadiusSq) continue; + + float TunnelSDF; + + if (Tunnel.bHasMidpoint) + { + // Two-segment curved tunnel: A→Mid and Mid→B + float SegA = VoxelSDF::TaperedCapsule(Pos, Tunnel.EndpointA, Tunnel.Midpoint, + Tunnel.RadiusA, Tunnel.RadiusMid); + float SegB = VoxelSDF::TaperedCapsule(Pos, Tunnel.Midpoint, Tunnel.EndpointB, + Tunnel.RadiusMid, Tunnel.RadiusB); + TunnelSDF = FMath::Min(SegA, SegB); + } + else + { + // Straight single-segment tunnel + TunnelSDF = VoxelSDF::TaperedCapsule(Pos, Tunnel.EndpointA, Tunnel.EndpointB, + Tunnel.RadiusA, Tunnel.RadiusB); + } + + MinSDF = VoxelSDF::SmoothMin(MinSDF, TunnelSDF, BlendK); + } + + // Write nearest room index for the caller (terrain ops system) + if (OutNearestRoomIdx) + { + *OutNearestRoomIdx = NearestIdx; + } + + return MinSDF; +} + +//============================================================================= +// CONVENIENCE WRAPPER (backward compatible) +//============================================================================= +// Builds a temporary cache for a single point, then evaluates. +// For chunk generation, use BuildChunkCache + EvaluateSDFCached directly. +// The search box around the point only needs a MaxInfluence margin — BuildChunkCache +// internally widens the COLLECT region to (2*MaxTunnelLength + MaxInfluence) so the +// graph it builds is the same one the chunk path would build at this point. + +float VoxelCaveMorphology::EvaluateSDF( + float WorldX, float WorldY, float WorldZ, + const FStrateGenerationParams& Params, + uint32 Seed, int32 StrateIndex) +{ + const float Margin = FMath::Max( + Params.MaxRoomRadius, + Params.TunnelWarpStrength + Params.TunnelMaxRadius + ) + Params.SDFBlendRadius; + + FChunkSDFCache TempCache; + BuildChunkCache( + TempCache, + WorldX - Margin, WorldY - Margin, + WorldX + Margin, WorldY + Margin, + Params, Seed, StrateIndex + ); + + return EvaluateSDFCached( + WorldX, WorldY, WorldZ, + TempCache, Params.SDFBlendRadius, Params.RoomShapeVariety + ); +} diff --git a/Source/VoxelForge/Private/VoxelContentManager.cpp b/Source/VoxelForge/Private/VoxelContentManager.cpp new file mode 100644 index 0000000..a3f9093 --- /dev/null +++ b/Source/VoxelForge/Private/VoxelContentManager.cpp @@ -0,0 +1,248 @@ +// VoxelContentManager.cpp +// Deterministic per-chunk decoration scatter + aesthetic water surfaces. + +#include "VoxelContentManager.h" +#include "VoxelStrateManager.h" +#include "VoxelStrateDefinition.h" +#include "VoxelCaveMorphology.h" // VoxelHash +#include "Components/StaticMeshComponent.h" +#include "Engine/StaticMesh.h" +#include "Engine/World.h" +#include "GameFramework/Actor.h" +#include "Materials/MaterialInterface.h" + +// Global safety cap on spawned decorations per chunk (across all entries). +static constexpr int32 GMaxDecorationsPerChunk = 400; + +void UVoxelContentManager::Initialize(AActor* InOwner, UVoxelStrateManager* InStrateManager, int32 InSeed) +{ + Owner = InOwner; + StrateManager = InStrateManager; + Seed = InSeed; + + // Engine unit plane — reused (scaled) for every water surface. + if (!PlaneMesh) + { + PlaneMesh = LoadObject(nullptr, TEXT("/Engine/BasicShapes/Plane.Plane")); + } +} + +// Deterministic placement hash: pure function of (chunk, surface index, entry, salt, seed). +static FORCEINLINE uint32 PlacementHash(const FIntVector& C, int32 SurfIdx, int32 EntryIdx, uint32 Seed, uint32 Salt) +{ + uint32 H = VoxelHash::Cell(C.X, C.Y, Seed ^ Salt); + H ^= VoxelHash::Mix((uint32)(C.Z * 0x9E3779B1u)); + H ^= VoxelHash::Mix((uint32)SurfIdx * 2654435761u); + H ^= VoxelHash::Mix((uint32)EntryIdx * 40503u + 1u); + return VoxelHash::Mix(H); +} + +void UVoxelContentManager::PopulateChunk(const FIntVector& ChunkCoord, const FVoxelMeshData& MeshData, int32 LODLevel) +{ + // Re-meshing a chunk calls this again — start clean. + ClearChunk(ChunkCoord); + + if (!StrateManager) return; + const UVoxelStrateDefinition* Def = StrateManager->GetStrateForChunk(ChunkCoord); + if (!Def) return; + + // Decorations only on near (full-detail) chunks — they're game-thread actor spawns + // and pointless on distant low-poly chunks. Water is one cheap plane, place it at any LOD. + if (LODLevel == 0) + { + TArray> Spawned; + SpawnDecorations(ChunkCoord, MeshData, Def, Spawned); + if (Spawned.Num() > 0) + { + SpawnedActors.Add(ChunkCoord, MoveTemp(Spawned)); + } + } + + SpawnWater(ChunkCoord, Def); +} + +void UVoxelContentManager::SpawnDecorations(const FIntVector& ChunkCoord, const FVoxelMeshData& MeshData, + const UVoxelStrateDefinition* Def, TArray>& Out) +{ + if (Def->Decorations.Num() == 0) return; + + AActor* OwnerActor = Owner.Get(); + if (!OwnerActor) return; + UWorld* World = OwnerActor->GetWorld(); + if (!World) return; + + const int32 NumVerts = MeshData.Vertices.Num(); + if (NumVerts == 0) return; + + const FTransform OwnerXf = OwnerActor->GetActorTransform(); + + // Water world-Z for the water-relative placement rule (voxel coords → world units). + const float WaterVoxelZ = StrateManager->GetWaterLevelWorldZForChunk(ChunkCoord); + const bool bHasWater = (WaterVoxelZ != -FLT_MAX); + const float WaterWorldZ = bHasWater ? WaterVoxelZ * VOXEL_SIZE : -FLT_MAX; + + int32 TotalSpawned = 0; + + for (int32 DecoIdx = 0; DecoIdx < Def->Decorations.Num(); ++DecoIdx) + { + const FStrateDecoration& Deco = Def->Decorations[DecoIdx]; + if (!Deco.ActorClass) continue; + if (Deco.SpawnDensity <= 0.0f) continue; + + int32 SpawnedThisEntry = 0; + + for (int32 i = 0; i < NumVerts; ++i) + { + if (TotalSpawned >= GMaxDecorationsPerChunk) return; + if (SpawnedThisEntry >= Deco.MaxPerChunk) break; + + const FVector NormalWorld = MeshData.Normals.IsValidIndex(i) + ? OwnerXf.TransformVectorNoScale(MeshData.Normals[i]).GetSafeNormal() + : FVector::UpVector; + + // Surface classification by normal Z. + const bool bFloor = NormalWorld.Z > 0.5f; + const bool bCeiling = NormalWorld.Z < -0.5f; + const bool bWall = !bFloor && !bCeiling; + bool bMatches = true; + switch (Deco.SurfacePlacement) + { + case ESurfaceType::Floor: bMatches = bFloor; break; + case ESurfaceType::Wall: bMatches = bWall; break; + case ESurfaceType::Ceiling: bMatches = bCeiling; break; + case ESurfaceType::Any: bMatches = true; break; + default: bMatches = true; break; + } + if (!bMatches) continue; + + // Deterministic spawn gate. + const uint32 H = PlacementHash(ChunkCoord, i, DecoIdx, (uint32)Seed, 0xDEC0u); + if (VoxelHash::ToFloat01(H) > Deco.SpawnDensity) continue; + + const FVector PosWorld = OwnerXf.TransformPosition(MeshData.Vertices[i]); + + // Water-relative rule. + if (Deco.bRequireWaterRelative && bHasWater) + { + const bool bBelow = PosWorld.Z < WaterWorldZ; + if (bBelow != Deco.bPlaceBelowWater) continue; + } + + // Transform. + const FVector SpawnPos = PosWorld + NormalWorld * Deco.SurfaceOffset; + + FQuat BaseQ = Deco.bAlignToSurface + ? FRotationMatrix::MakeFromZ(NormalWorld).ToQuat() + : FQuat::Identity; + if (Deco.bRandomYaw) + { + const float Yaw = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x59415721u)) * 2.0f * PI; + const FVector Axis = Deco.bAlignToSurface ? NormalWorld : FVector::UpVector; + BaseQ = FQuat(Axis, Yaw) * BaseQ; + } + + const float ScaleT = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x5CA1E000u)); + const float Scale = FMath::Lerp(Deco.MinScale, Deco.MaxScale, ScaleT); + + FActorSpawnParameters SpawnParams; + SpawnParams.Owner = OwnerActor; + SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + + AActor* NewActor = World->SpawnActor( + Deco.ActorClass, + FTransform(BaseQ, SpawnPos, FVector(Scale)), + SpawnParams); + + if (NewActor) + { + Out.Add(NewActor); + ++SpawnedThisEntry; + ++TotalSpawned; + } + } + } +} + +void UVoxelContentManager::SpawnWater(const FIntVector& ChunkCoord, const UVoxelStrateDefinition* Def) +{ + if (!Def->bHasWater || !PlaneMesh) return; + + AActor* OwnerActor = Owner.Get(); + if (!OwnerActor) return; + + const float WaterVoxelZ = StrateManager->GetWaterLevelWorldZForChunk(ChunkCoord); + if (WaterVoxelZ == -FLT_MAX) return; + + // Only the chunk whose vertical span contains the water surface gets a plane. + const float ChunkVoxelZMin = (float)(ChunkCoord.Z) * CHUNK_SIZE; + const float ChunkVoxelZMax = ChunkVoxelZMin + CHUNK_SIZE; + if (WaterVoxelZ < ChunkVoxelZMin || WaterVoxelZ > ChunkVoxelZMax) return; + + // World-space center of this chunk's XY footprint, at the water surface Z. + const float CenterVoxelX = ((float)ChunkCoord.X + 0.5f) * CHUNK_SIZE; + const float CenterVoxelY = ((float)ChunkCoord.Y + 0.5f) * CHUNK_SIZE; + const FVector LocalCenter(CenterVoxelX * VOXEL_SIZE, CenterVoxelY * VOXEL_SIZE, WaterVoxelZ * VOXEL_SIZE); + const FVector WorldCenter = OwnerActor->GetActorTransform().TransformPosition(LocalCenter); + + UStaticMeshComponent* Plane = NewObject(OwnerActor); + Plane->SetStaticMesh(PlaneMesh); + Plane->SetCollisionEnabled(ECollisionEnabled::NoCollision); + Plane->SetCastShadow(false); + Plane->RegisterComponent(); + Plane->AttachToComponent(OwnerActor->GetRootComponent(), FAttachmentTransformRules::KeepRelativeTransform); + Plane->SetWorldLocation(WorldCenter); + + // Engine plane is 100 uu square → scale to one chunk's world footprint. + const float ChunkWorld = (float)CHUNK_SIZE * VOXEL_SIZE; + Plane->SetWorldScale3D(FVector(ChunkWorld / 100.0f, ChunkWorld / 100.0f, 1.0f)); + + if (Def->WaterMaterial) + { + Plane->SetMaterial(0, Def->WaterMaterial); + } + + WaterPlanes.Add(ChunkCoord, Plane); +} + +void UVoxelContentManager::ClearChunk(const FIntVector& ChunkCoord) +{ + if (TArray>* Actors = SpawnedActors.Find(ChunkCoord)) + { + for (const TWeakObjectPtr& A : *Actors) + { + if (AActor* Act = A.Get()) + { + Act->Destroy(); + } + } + SpawnedActors.Remove(ChunkCoord); + } + + if (UStaticMeshComponent** Plane = WaterPlanes.Find(ChunkCoord)) + { + if (*Plane) + { + (*Plane)->DestroyComponent(); + } + WaterPlanes.Remove(ChunkCoord); + } +} + +void UVoxelContentManager::ClearAll() +{ + TArray Coords; + SpawnedActors.GetKeys(Coords); + for (const FIntVector& C : Coords) + { + ClearChunk(C); + } + // Any water planes without decorations. + TArray WaterCoords; + WaterPlanes.GetKeys(WaterCoords); + for (const FIntVector& C : WaterCoords) + { + ClearChunk(C); + } + SpawnedActors.Empty(); + WaterPlanes.Empty(); +} diff --git a/Source/VoxelForge/Private/VoxelDiffLayer.cpp b/Source/VoxelForge/Private/VoxelDiffLayer.cpp new file mode 100644 index 0000000..b1b7f14 --- /dev/null +++ b/Source/VoxelForge/Private/VoxelDiffLayer.cpp @@ -0,0 +1,230 @@ +// VoxelDiffLayer.cpp +// Runtime terrain modification storage and evaluation. + +#include "VoxelDiffLayer.h" + +//============================================================================= +// BUDGET CONFIGURATION +//============================================================================= + +void UVoxelDiffLayer::SetBudget(int32 InMaxMods, float InMaxRadius, float InMaxVolume) +{ + BudgetMaxMods = InMaxMods; + BudgetMaxRadius = InMaxRadius; + BudgetMaxVolume = InMaxVolume; + + UE_LOG(LogTemp, Log, TEXT("[DiffLayer] Budget set: MaxMods=%d, MaxRadius=%.1f, MaxVolume=%.0f (0=unlimited)"), + BudgetMaxMods, BudgetMaxRadius, BudgetMaxVolume); +} + +bool UVoxelDiffLayer::CanModify(float Radius) const +{ + // Check radius limit (always enforced, even if "unlimited" count/volume) + if (Radius > BudgetMaxRadius) + { + return false; + } + + // Check modification count limit + if (BudgetMaxMods > 0 && ModificationCount >= BudgetMaxMods) + { + return false; + } + + // Check volume limit: would this brush push us over? + if (BudgetMaxVolume > 0.0f) + { + const float BrushVolume = (4.0f / 3.0f) * PI * Radius * Radius * Radius; + if (AccumulatedVolume + BrushVolume > BudgetMaxVolume) + { + return false; + } + } + + return true; +} + +int32 UVoxelDiffLayer::GetRemainingModifications() const +{ + if (BudgetMaxMods <= 0) return -1; // Unlimited + return FMath::Max(0, BudgetMaxMods - ModificationCount); +} + +float UVoxelDiffLayer::GetRemainingVolume() const +{ + if (BudgetMaxVolume <= 0.0f) return -1.0f; // Unlimited + return FMath::Max(0.0f, BudgetMaxVolume - AccumulatedVolume); +} + +//============================================================================= +// APPLY MODIFICATION +//============================================================================= + +TArray UVoxelDiffLayer::ApplyModification(const FVoxelModification& Mod) +{ + TArray AffectedChunks; + + // --- Budget enforcement --- + // Clamp radius to max allowed + float ClampedRadius = FMath::Min(Mod.Radius, BudgetMaxRadius); + + if (!CanModify(ClampedRadius)) + { + UE_LOG(LogTemp, Warning, + TEXT("[DiffLayer] Modification REJECTED — budget exceeded (count=%d/%d, volume=%.0f/%.0f)"), + ModificationCount, BudgetMaxMods, AccumulatedVolume, BudgetMaxVolume); + return AffectedChunks; // Empty = rejected + } + + // Use clamped radius for the actual modification + FVoxelModification ClampedMod = Mod; + ClampedMod.Radius = ClampedRadius; + + // Update budget counters + ModificationCount++; + const float BrushVolume = (4.0f / 3.0f) * PI * ClampedRadius * ClampedRadius * ClampedRadius; + AccumulatedVolume += BrushVolume; + + // Find every chunk the brush can touch, from its shape-aware world AABB. + FVector BoundsMin, BoundsMax; + ClampedMod.GetWorldBounds(BoundsMin, BoundsMax); + const int32 MinCX = FMath::FloorToInt(BoundsMin.X / CHUNK_SIZE); + const int32 MaxCX = FMath::FloorToInt(BoundsMax.X / CHUNK_SIZE); + const int32 MinCY = FMath::FloorToInt(BoundsMin.Y / CHUNK_SIZE); + const int32 MaxCY = FMath::FloorToInt(BoundsMax.Y / CHUNK_SIZE); + const int32 MinCZ = FMath::FloorToInt(BoundsMin.Z / CHUNK_SIZE); + const int32 MaxCZ = FMath::FloorToInt(BoundsMax.Z / CHUNK_SIZE); + + for (int32 CZ = MinCZ; CZ <= MaxCZ; CZ++) + { + for (int32 CY = MinCY; CY <= MaxCY; CY++) + { + for (int32 CX = MinCX; CX <= MaxCX; CX++) + { + FIntVector ChunkCoord(CX, CY, CZ); + + // Store the modification in this chunk's list + ChunkMods.FindOrAdd(ChunkCoord).Add(ClampedMod); + + // Track which chunks need re-meshing + AffectedChunks.Add(ChunkCoord); + } + } + } + + UE_LOG(LogTemp, Log, + TEXT("[DiffLayer] Modification #%d at (%.0f, %.0f, %.0f) R=%.1f S=%.1f -> %d chunks (budget: %d/%d mods, %.0f/%.0f vol)"), + ModificationCount, + ClampedMod.Center.X, ClampedMod.Center.Y, ClampedMod.Center.Z, + ClampedMod.Radius, ClampedMod.Strength, + AffectedChunks.Num(), + ModificationCount, BudgetMaxMods, + AccumulatedVolume, BudgetMaxVolume); + + return AffectedChunks; +} + +//============================================================================= +// DENSITY EVALUATION +//============================================================================= + +float UVoxelDiffLayer::GetDensityOffset(const FIntVector& ChunkCoord, + float WorldX, float WorldY, float WorldZ) const +{ + // Fast path: if this chunk has no modifications, return 0 + const TArray* Mods = ChunkMods.Find(ChunkCoord); + if (!Mods || Mods->Num() == 0) return 0.0f; + + float TotalOffset = 0.0f; + const FVector Pos(WorldX, WorldY, WorldZ); + + // smoothstep helper (full strength when t<=0, zero when t>=1). + auto Smooth01 = [](float T) -> float + { + T = FMath::Clamp(T, 0.0f, 1.0f); + return 1.0f - T * T * (3.0f - 2.0f * T); + }; + + for (const FVoxelModification& Mod : *Mods) + { + float Falloff = 0.0f; + + switch (Mod.Shape) + { + case EVoxelBrushShape::Box: + { + // Box SDF (negative inside), then fade over the Falloff band outside. + const FVector Q = (Pos - Mod.Center).GetAbs() - Mod.BoxExtent; + const float Outside = FVector(FMath::Max(Q.X, 0.0f), FMath::Max(Q.Y, 0.0f), FMath::Max(Q.Z, 0.0f)).Size(); + const float Inside = FMath::Min(FMath::Max3(Q.X, Q.Y, Q.Z), 0.0f); + const float S = Outside + Inside; // <=0 inside the box + const float Band = FMath::Max(Mod.Falloff, 0.01f); + if (S >= Band) continue; + Falloff = Smooth01(FMath::Max(S, 0.0f) / Band); + break; + } + case EVoxelBrushShape::Capsule: + { + // Distance to the segment minus the tube radius, faded over Falloff. + const FVector AB = Mod.CapsuleEnd - Mod.Center; + const FVector AP = Pos - Mod.Center; + const float T = FMath::Clamp(FVector::DotProduct(AP, AB) / FMath::Max(FVector::DotProduct(AB, AB), KINDA_SMALL_NUMBER), 0.0f, 1.0f); + const float SegDist = FVector::Dist(Pos, Mod.Center + AB * T); + const float S = SegDist - Mod.Radius; // <=0 inside the tube + const float Band = FMath::Max(Mod.Falloff, 0.01f); + if (S >= Band) continue; + Falloff = Smooth01(FMath::Max(S, 0.0f) / Band); + break; + } + case EVoxelBrushShape::Sphere: + default: + { + const float Dist = FVector::Dist(Pos, Mod.Center); + if (Dist >= Mod.Radius) continue; + Falloff = Smooth01(Dist / Mod.Radius); + break; + } + } + + TotalOffset += Mod.Strength * Falloff; + } + + return TotalOffset; +} + +bool UVoxelDiffLayer::HasModifications(const FIntVector& ChunkCoord) const +{ + const TArray* Mods = ChunkMods.Find(ChunkCoord); + return Mods && Mods->Num() > 0; +} + +//============================================================================= +// MANAGEMENT +//============================================================================= + +void UVoxelDiffLayer::Clear() +{ + int32 Count = GetTotalModificationCount(); + ChunkMods.Empty(); + + // Reset budget counters — player gets a fresh budget after clear/season reset + ModificationCount = 0; + AccumulatedVolume = 0.0f; + + UE_LOG(LogTemp, Log, TEXT("[DiffLayer] Cleared %d modifications, budget reset"), Count); +} + +int32 UVoxelDiffLayer::GetTotalModificationCount() const +{ + int32 Total = 0; + for (const auto& Pair : ChunkMods) + { + Total += Pair.Value.Num(); + } + return Total; +} + +int32 UVoxelDiffLayer::GetModifiedChunkCount() const +{ + return ChunkMods.Num(); +} diff --git a/Source/VoxelForge/Private/VoxelForgeModule.cpp b/Source/VoxelForge/Private/VoxelForgeModule.cpp new file mode 100644 index 0000000..7739a44 --- /dev/null +++ b/Source/VoxelForge/Private/VoxelForgeModule.cpp @@ -0,0 +1,22 @@ +// VoxelForgeModule.cpp +// Module implementation - boilerplate + +#include "VoxelForgeModule.h" + +// This macro registers our module with Unreal +// "VoxelForge" must match the module name in VoxelForge.Build.cs and .uplugin +IMPLEMENT_MODULE(FVoxelForgeModule, VoxelForge) + +void FVoxelForgeModule::StartupModule() +{ + // Called when plugin loads + // We don't need to do anything here for now + UE_LOG(LogTemp, Log, TEXT("VoxelForge module started!")); +} + +void FVoxelForgeModule::ShutdownModule() +{ + // Called when plugin unloads + // Cleanup would go here if we had any global resources + UE_LOG(LogTemp, Log, TEXT("VoxelForge module shutdown.")); +} diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp new file mode 100644 index 0000000..972c61c --- /dev/null +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -0,0 +1,2128 @@ +// VoxelGenerator.cpp +// Champ de densité du monde. Toute la géométrie des grottes sort d'ici. +// +// Convention INTERNE: positif = solide, négatif = air (plus lisible). +// Convention de SORTIE (marching cubes): on négate → négatif = solide. + +#include "VoxelGenerator.h" +#include "VoxelSettings.h" +#include "VoxelStrateManager.h" +#include "VoxelStrateDefinition.h" +#include "VoxelTerrainOpDefinition.h" +#include "VoxelCaveMorphology.h" +#include "VoxelDiffLayer.h" + +//============================================================================= +// FRACTAL NOISE (fBm — fractional Brownian motion) +//============================================================================= +// Empile plusieurs octaves de Perlin: freq x2 et amp /2 à chaque octave. +// Octave 1: freq=f, amp=1.0 → grandes collines lisses +// Octave 2: freq=2f, amp=0.5 → bosses moyennes +// Octave 3: freq=4f, amp=0.25 → petits cailloux +// Lacunarity = x freq par octave (2 = double à chaque fois) +// Persistence = x amp par octave (0.5 = moitié) + +static float FractalNoise3D(const FVector& Position, int32 Octaves = 4, + float Lacunarity = 2.0f, float Persistence = 0.5f) +{ + float Total = 0.0f; + float Frequency = 1.0f; + float Amplitude = 1.0f; + float MaxValue = 0.0f; + + for (int32 i = 0; i < Octaves; i++) + { + Total += FMath::PerlinNoise3D(Position * Frequency) * Amplitude; + MaxValue += Amplitude; + Frequency *= Lacunarity; + Amplitude *= Persistence; + } + + return Total / MaxValue; +} + +//============================================================================= +// RIDGED MULTIFRACTAL NOISE +//============================================================================= +// Creates sharp, ridge-like features by folding the noise at zero-crossings. +// The absolute value creates "creases" where the noise crosses zero, +// and the 1-abs inverts them into ridges. Weight feedback from each +// octave makes ridges sharper and more detailed. +// +// Returns approximately [-1, 1] to match FractalNoise3D's range. +// Character: craggy cliffs, natural erosion patterns, sharp corridors. + +static float RidgedNoise3D(const FVector& Position, int32 Octaves = 4, + float Lacunarity = 2.0f, float Persistence = 0.5f) +{ + // UE's PerlinNoise3D returns ~[-0.8, 0.8]; scale to [-1, 1] + static constexpr float NS = 1.25f; + + float Total = 0.0f; + float Frequency = 1.0f; + float Amplitude = 1.0f; + float MaxValue = 0.0f; + float Weight = 1.0f; // Weight feedback from previous octave + + for (int32 i = 0; i < Octaves; i++) + { + // Sample Perlin and fold at zero → ridge at zero-crossings + float N = FMath::PerlinNoise3D(Position * Frequency) * NS; + N = 1.0f - FMath::Abs(N); // Fold: [−1,1] → [0,1] with ridges at N=0 + N = N * N; // Sharpen ridges (quadratic falloff) + N *= Weight; // Weight by previous octave → detail follows ridges + Weight = FMath::Clamp(N * 2.0f, 0.0f, 1.0f); // Feedback for next octave + + Total += N * Amplitude; + MaxValue += Amplitude; + Frequency *= Lacunarity; + Amplitude *= Persistence; + } + + // Shift from [0, 1] to [-1, 1] to match FractalNoise3D's range + return (Total / MaxValue) * 2.0f - 1.0f; +} + +//============================================================================= +// WORLEY / CELLULAR NOISE (3D) +//============================================================================= +// Distance to nearest feature point in a hash grid. +// Creates rounded, cell-like patterns: bubble walls, grotto pockets, +// honeycomb textures. Very different character from Perlin-based noise. +// +// Algorithm: +// 1. Find which grid cell the point is in +// 2. Check the 3x3x3 neighborhood for feature points +// 3. Return (2nd nearest - 1st nearest) distance, normalized to ~[-1, 1] +// +// Using F2-F1 (difference of two closest distances) gives smooth cell +// boundaries with ridges between cells — more interesting than raw distance. + +static float CellularNoise3D(const FVector& Position) +{ + // Integer cell coordinates + int32 CellX = FMath::FloorToInt(Position.X); + int32 CellY = FMath::FloorToInt(Position.Y); + int32 CellZ = FMath::FloorToInt(Position.Z); + + // Fractional position within cell + float FracX = Position.X - CellX; + float FracY = Position.Y - CellY; + float FracZ = Position.Z - CellZ; + + float F1 = FLT_MAX; // Distance to nearest feature point + float F2 = FLT_MAX; // Distance to 2nd nearest + + // Search 3x3x3 neighborhood + for (int32 DZ = -1; DZ <= 1; DZ++) + { + for (int32 DY = -1; DY <= 1; DY++) + { + for (int32 DX = -1; DX <= 1; DX++) + { + int32 NX = CellX + DX; + int32 NY = CellY + DY; + int32 NZ = CellZ + DZ; + + // Hash the neighbor cell to get a feature point position [0,1) + // Using three different hash mixes for X, Y, Z offsets + uint32 H = VoxelHash::Mix( + (uint32)(NX + 0x7FFFFFFF) + ^ VoxelHash::Mix((uint32)(NY + 0x7FFFFFFF) * 2654435761u) + ^ VoxelHash::Mix((uint32)(NZ + 0x7FFFFFFF) * 374761393u) + ); + + float FPX = (float)DX + VoxelHash::ToFloat01(H) - FracX; + float FPY = (float)DY + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x12345678u)) - FracY; + float FPZ = (float)DZ + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x9ABCDEF0u)) - FracZ; + + float DistSq = FPX * FPX + FPY * FPY + FPZ * FPZ; + + // Track closest two distances + if (DistSq < F1) + { + F2 = F1; + F1 = DistSq; + } + else if (DistSq < F2) + { + F2 = DistSq; + } + } + } + } + + // F2 - F1: smooth cell boundaries with ridges between cells + // Sqrt for actual distance, then normalize to ~[-1, 1] + float Result = FMath::Sqrt(F2) - FMath::Sqrt(F1); + // Result is in [0, ~1.0]. Map to [-1, 1] for compatibility with other noise types. + return Result * 2.0f - 1.0f; +} + +//============================================================================= +// DENSITY PIPELINE HELPERS (partagés entre TunnelNetwork et Slab) +//============================================================================= + +// Seal solide aux bords haut et bas de la strate. Fade smoothstep sur +// `Thickness` voxels depuis chaque bord. N'AJOUTE que de la densité +// (FMath::Max), jamais en enlève → le joueur ne peut jamais percer le seal +// "par accident", seulement via les passages. +static void ApplyBoundarySeal(float& Density, float WorldZ, + float StrateTopZ, float StrateBottomZ, + float Thickness, float BaseDensity) +{ + if (Thickness <= 0.0f) return; + + const float DistTop = StrateTopZ - WorldZ; // + si on est sous le plafond + const float DistBot = WorldZ - StrateBottomZ; // + si on est au-dessus du sol + + if (DistTop >= 0.0f && DistTop < Thickness) + { + float SealFactor = 1.0f - (DistTop / Thickness); + SealFactor = SmoothStep01(SealFactor); + Density = FMath::Max(Density, SealFactor * BaseDensity); + } + if (DistBot >= 0.0f && DistBot < Thickness) + { + float SealFactor = 1.0f - (DistBot / Thickness); + SealFactor = SmoothStep01(SealFactor); + Density = FMath::Max(Density, SealFactor * BaseDensity); + } +} + +// Creuse un passage inter-strates. Évalué APRÈS le seal pour que les passages +// puissent percer à travers le bouchon solide. +// Le rayon de blend hard-codé à 4.0f correspond à l'ancienne valeur — +// à exposer via UVoxelSettings si on veut pouvoir le tweaker. +static void ApplyPassageCarving(float& Density, float ModSDF, + float BaseDensity, float SealThickness) +{ + constexpr float PASSAGE_BLEND_RADIUS = 4.0f; + if (ModSDF >= PASSAGE_BLEND_RADIUS) return; + + float CarveFactor = FMath::Clamp( + (PASSAGE_BLEND_RADIUS - ModSDF) / (PASSAGE_BLEND_RADIUS * 2.0f), + 0.0f, 1.0f); + CarveFactor = SmoothStep01(CarveFactor); + + // FORCE the density toward guaranteed AIR so the passage punches through ANYTHING in + // its path (seals, columns, surface roughness, terrain ops). A plain subtraction can + // be out-paced by stacked density additions, leaving solid plugs mid-tunnel — which is + // why the shaft "didn't go all the way through". Lerp toward a strongly negative target + // and take the min so we only ever make it MORE air (never refill an existing cave). + const float AirTarget = -(BaseDensity * 2.0f + SealThickness + 4.0f); + Density = FMath::Min(Density, FMath::Lerp(Density, AirTarget, CarveFactor)); +} + +// (0,0) DESCENT SPINE — carve a guaranteed open vertical column at world XY (0,0) +// inside the strate INTERIOR (between the top and bottom seals). The seals are left +// intact so the player still has to dig through them to descend — this just makes a +// clean, archetype-independent landing space aligned across every strate. +static void ApplyOriginSpine(float& Density, float WorldX, float WorldY, float WorldZ, + float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius) +{ + if (Radius <= 0.0f) return; + + // Stay within the interior — never touch the seal bands. + const float InnerTop = StrateTopZ - SealThickness; + const float InnerBot = StrateBottomZ + SealThickness; + if (WorldZ <= InnerBot || WorldZ >= InnerTop) return; + + const float DistXY = FMath::Sqrt(WorldX * WorldX + WorldY * WorldY); + const float SDF = DistXY - Radius; // < 0 inside the column + const float Blend = 3.0f; + if (SDF < Blend) + { + float Carve = FMath::Clamp((Blend - SDF) / (Blend * 2.0f), 0.0f, 1.0f); + Carve = SmoothStep01(Carve); + Density -= Carve * (BaseDensity * 2.0f + SealThickness); + } +} + +// DISTURBANCE LAYER — the "wow" post-process. Operates on the FINAL MC density +// (negative = solid, positive = air), AFTER the archetype produced its terrain, so +// it works uniformly for every generator type. Stays inside the seal bands so it can +// never breach a strate boundary. All features are hash-placed and deterministic. +static void ApplyDisturbances(float& MC, float X, float Y, float Z, + const FStrateDisturbanceParams& D, uint32 Seed) +{ + const float InnerTop = D.StrateTopWorldZ - D.BoundarySealThickness; + const float InnerBot = D.StrateBottomWorldZ + D.BoundarySealThickness; + if (Z <= InnerBot || Z >= InnerTop) return; + + const float Solid = D.BaseDensity * 2.0f; + const float Blend = 3.0f; + const FVector P(X, Y, Z); + + // 2D point-to-segment distance helper (XY plane). + auto Dist2DSeg = [](float px, float py, float ax, float ay, float bx, float by) -> float + { + const float abx = bx - ax, aby = by - ay; + const float apx = px - ax, apy = py - ay; + const float denom = FMath::Max(abx * abx + aby * aby, KINDA_SMALL_NUMBER); + float t = FMath::Clamp((apx * abx + apy * aby) / denom, 0.0f, 1.0f); + const float cx = ax + abx * t, cy = ay + aby * t; + return FMath::Sqrt(FMath::Square(px - cx) + FMath::Square(py - cy)); + }; + + // --- CHASMS: vertical rifts carve open air --- + if (D.ChasmDensity > 0.0f && D.ChasmSpacing > 0.0f) + { + const float Sp = D.ChasmSpacing; + const int32 cx = FMath::FloorToInt(X / Sp), cy = FMath::FloorToInt(Y / Sp); + float sdf = FLT_MAX; + for (int32 dy = -1; dy <= 1; dy++) + for (int32 dx = -1; dx <= 1; dx++) + { + const int32 nx = cx + dx, ny = cy + dy; + const uint32 h = VoxelHash::Cell(nx, ny, Seed ^ 0x43480001u); + if (VoxelHash::ToFloat01(h) > D.ChasmDensity) continue; + const float jx = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0x12345678u)); + const float jy = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0x9ABCDEF0u)); + const float ccx = (nx + 0.15f + jx * 0.7f) * Sp; + const float ccy = (ny + 0.15f + jy * 0.7f) * Sp; + sdf = FMath::Min(sdf, FMath::Sqrt(FMath::Square(X - ccx) + FMath::Square(Y - ccy)) - D.ChasmRadius); + } + if (sdf < Blend) + { + float c = FMath::Clamp((Blend - sdf) / (Blend * 2.0f), 0.0f, 1.0f); + c = SmoothStep01(c); + MC = FMath::Max(MC, c * Solid); // force air + } + } + + // --- BRIDGES: horizontal solid spans across open space --- + if (D.BridgeDensity > 0.0f && D.BridgeSpacing > 0.0f) + { + const float Sp = D.BridgeSpacing; + const int32 cx = FMath::FloorToInt(X / Sp), cy = FMath::FloorToInt(Y / Sp); + float sdf = FLT_MAX; + for (int32 dy = -1; dy <= 1; dy++) + for (int32 dx = -1; dx <= 1; dx++) + { + const int32 nx = cx + dx, ny = cy + dy; + const uint32 h = VoxelHash::Cell(nx, ny, Seed ^ 0x42520001u); + if (VoxelHash::ToFloat01(h) > D.BridgeDensity) continue; + const float zc = FMath::Lerp(InnerBot + 8.0f, InnerTop - 8.0f, + VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0xB1u))); + const float ang = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0xB2u)) * PI; + const float dxu = FMath::Cos(ang), dyu = FMath::Sin(ang); + const float bx = (nx + 0.5f) * Sp, by = (ny + 0.5f) * Sp; + const float half = Sp * 0.6f; + const FVector A(bx - dxu * half, by - dyu * half, zc); + const FVector B(bx + dxu * half, by + dyu * half, zc); + sdf = FMath::Min(sdf, VoxelSDF::Capsule(P, A, B, D.BridgeRadius)); + } + if (sdf < Blend) + { + float f = FMath::Clamp((Blend - sdf) / (Blend * 2.0f), 0.0f, 1.0f); + f = SmoothStep01(f); + MC = FMath::Min(MC, -f * Solid); // force solid + } + } + + // --- RIDGES: thin solid blades rising from the floor --- + if (D.RidgeDensity > 0.0f && D.RidgeSpacing > 0.0f && D.RidgeHeight > 0.0f) + { + const float Sp = D.RidgeSpacing; + const float TopZ = FMath::Min(InnerBot + D.RidgeHeight, InnerTop); + if (Z < TopZ) + { + const int32 cx = FMath::FloorToInt(X / Sp), cy = FMath::FloorToInt(Y / Sp); + float best = -FLT_MAX; // strongest fill across nearby blades + for (int32 dy = -1; dy <= 1; dy++) + for (int32 dx = -1; dx <= 1; dx++) + { + const int32 nx = cx + dx, ny = cy + dy; + const uint32 h = VoxelHash::Cell(nx, ny, Seed ^ 0x52470001u); + if (VoxelHash::ToFloat01(h) > D.RidgeDensity) continue; + const float ang = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0x9001u)) * PI; + const float dxu = FMath::Cos(ang), dyu = FMath::Sin(ang); + const float bx = (nx + 0.5f) * Sp, by = (ny + 0.5f) * Sp; + const float half = Sp * 0.45f; + const float d2d = Dist2DSeg(X, Y, bx - dxu * half, by - dyu * half, + bx + dxu * half, by + dyu * half); + const float wallSDF = d2d - D.RidgeThickness; // <0 inside the blade footprint + if (wallSDF >= Blend) continue; + const float zFade = 1.0f - FMath::Clamp((Z - InnerBot) / FMath::Max(D.RidgeHeight, 1.0f), 0.0f, 1.0f); + float f = FMath::Clamp((Blend - wallSDF) / (Blend * 2.0f), 0.0f, 1.0f); + f = SmoothStep01(f) * zFade; + best = FMath::Max(best, f); + } + if (best > 0.0f) MC = FMath::Min(MC, -best * Solid); // force solid + } + } +} + +void UVoxelGenerator::InitializeSettings(const UVoxelSettings* Settings) +{ + // Seul le seed est copié ici. Tout le reste (params de cave, transitions, + // blendings) vient des strate definitions via le StrateManager. + Seed = Settings ? Settings->Seed : 0; + OriginSpineRadius = Settings ? Settings->OriginSpineRadius : 14.0f; +} + +float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) const +{ + // ── STRATE SYSTEM ── + // Query per-chunk params from the manager so each strate has different caves. + float Result; + + FIntVector ChunkCoord( + FMath::FloorToInt(WorldX / CHUNK_SIZE), + FMath::FloorToInt(WorldY / CHUNK_SIZE), + FMath::FloorToInt(WorldZ / CHUNK_SIZE) + ); + + if (StrateManager && StrateManager->IsGapChunk(ChunkCoord)) + { + // SOLID BEDROCK gap between two strates. The auto-carved passages still tunnel + // through it, but the (0,0) descent stays solid here so the player digs the gap + // to reach the next layer. No caves, no spine, no seal — just rock + passages. + float Density = 8.0f; // bedrock solidity (positive = solid) + const float ModSDF = StrateManager->EvaluateModifierSDF(WorldX, WorldY, WorldZ); + ApplyPassageCarving(Density, ModSDF, 8.0f, 0.0f); + Result = -Density; + } + else if (StrateManager) + { + // ── PER-CHUNK PARAM CACHE ── + // GetDensityAt runs per voxel AND ~6× more per surface vertex (gradient normals). + // The generator type, the (boundary-blended) param struct, and the disturbance + // params are identical for the whole chunk, yet resolving them re-runs a strate + // lookup + copies large structs (and a ~60-field Lerp for blended cave chunks). + // Cache them thread-locally, keyed by chunk coord — refetch only on chunk change. + thread_local FIntVector CP_Chunk(INT32_MAX, INT32_MAX, INT32_MAX); + thread_local ECaveGeneratorType CP_GenType = ECaveGeneratorType::TunnelNetwork; + thread_local FStrateGenerationParams CP_Tunnel; + thread_local FSlabGenerationParams CP_Slab; + thread_local FMazeGenerationParams CP_Maze; + thread_local FSurfaceGenerationParams CP_Surface; + thread_local FVerticalShaftParams CP_Vert; + thread_local FFloatingIslandParams CP_Float; + thread_local FStrateDisturbanceParams CP_Dist; + + if (ChunkCoord != CP_Chunk) + { + CP_Chunk = ChunkCoord; + CP_GenType = StrateManager->GetGeneratorTypeForChunk(ChunkCoord); + switch (CP_GenType) + { + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + CP_Slab = StrateManager->GetSlabParamsForChunk(ChunkCoord); break; + case ECaveGeneratorType::Maze: + CP_Maze = StrateManager->GetMazeParamsForChunk(ChunkCoord); break; + case ECaveGeneratorType::SurfaceWorld: + CP_Surface = StrateManager->GetSurfaceParamsForChunk(ChunkCoord); break; + case ECaveGeneratorType::VerticalShafts: + CP_Vert = StrateManager->GetVerticalShaftParamsForChunk(ChunkCoord); break; + case ECaveGeneratorType::FloatingIslands: + CP_Float = StrateManager->GetFloatingIslandParamsForChunk(ChunkCoord); break; + default: // TunnelNetwork / Underwater + CP_Tunnel = StrateManager->GetGenerationParams(ChunkCoord); break; + } + CP_Dist = StrateManager->GetDisturbanceParamsForChunk(ChunkCoord); + } + + switch (CP_GenType) + { + case ECaveGeneratorType::FlatPlain: + case ECaveGeneratorType::CrystalChamber: + Result = GetSlabDensity(WorldX, WorldY, WorldZ, CP_Slab); break; + case ECaveGeneratorType::Maze: + Result = GetMazeDensity(WorldX, WorldY, WorldZ, CP_Maze); break; + case ECaveGeneratorType::SurfaceWorld: + Result = GetSurfaceDensity(WorldX, WorldY, WorldZ, CP_Surface); break; + case ECaveGeneratorType::VerticalShafts: + Result = GetVerticalShaftDensity(WorldX, WorldY, WorldZ, CP_Vert); break; + case ECaveGeneratorType::FloatingIslands: + Result = GetFloatingIslandDensity(WorldX, WorldY, WorldZ, CP_Float); break; + case ECaveGeneratorType::Underwater: + case ECaveGeneratorType::TunnelNetwork: + default: + // Underwater shares tunnel rock (water table is a render-side overlay). + Result = GetDensityWithParams(WorldX, WorldY, WorldZ, CP_Tunnel); break; + } + + // Disturbance layer (the "wow" post-process) — cached params, MC convention. + ApplyDisturbances(Result, WorldX, WorldY, WorldZ, CP_Dist, (uint32)Seed); + } + else + { + // ── FALLBACK (no strate manager) ── + // Use default TunnelNetwork params — produces generic caves. + FStrateGenerationParams FallbackParams; + Result = GetDensityWithParams(WorldX, WorldY, WorldZ, FallbackParams); + } + + //========================================================================= + // PLAYER MODIFICATIONS (diff layer) + //========================================================================= + // Applied LAST — player carving/filling overrides everything. + // The diff layer returns density in internal convention (negative = carve). + // Since Result is already in MC convention (negative = solid, positive = air), + // we subtract the diff offset: + // Carve (diff < 0) → Result -= negative → Result increases → more air ✓ + // Fill (diff > 0) → Result -= positive → Result decreases → more solid ✓ + if (DiffLayer && DiffLayer->HasModifications(ChunkCoord)) + { + Result -= DiffLayer->GetDensityOffset(ChunkCoord, WorldX, WorldY, WorldZ); + } + + return Result; +} + +float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float WorldZ, + const FStrateGenerationParams& Params) const +{ + //========================================================================= + // STRATE DENSITY FUNCTION (Morphology Pipeline) + //========================================================================= + // The density pipeline for underground caves: + // + // 1. Vertical scale + // 2. Base density (everything starts solid) + // 3. Cave warp: domain warp coordinates before SDF (bends rooms/tunnels organically) + // 4. SDF morphology: rooms + tunnels carve the cave structure (using warped coords) + // 4b. Surface roughness: 3D noise near cave walls (fBM/Ridged/Cellular + domain warp) + // 4c. Terrain operations: terracing, layer lines, ribbing, cliff, scallop, arch, overhangs + // 4d. Columns/Pillars: hash-placed vertical cylinders adding solid rock + // 4e. Pits/Shafts: hash-placed tapered vertical voids carved into cave floors + // 4f. Chimneys/Shafts: hash-placed tapered vertical voids carved UPWARD + // 4g. Domes: hemispherical ceiling sculpting in cave chambers + // 4h. Pinch/Bottleneck: ellipsoidal passage narrowing for chokepoints + // 5. Worm tunnels: additional organic connectivity + // 6. Boundary seal: solid rock at strate top/bottom + // 7. Modifiers: passages between strates (punch through seals) + // + // Convention: positive density = solid, negative = air (internally). + // At the end, we negate for the MC table (negative = solid there). + //========================================================================= + + const float SeedF = (float)Seed; + + //========================================================================= + // STEP 1: VERTICAL SCALE + //========================================================================= + float EffectiveZ = WorldZ; + if (Params.VerticalScale != 1.0f && Params.VerticalScale > 0.0f) + { + EffectiveZ = WorldZ / Params.VerticalScale; + } + + //========================================================================= + // STEP 2: BASE DENSITY (everything starts solid) + //========================================================================= + float Density = Params.BaseDensity; + + //========================================================================= + // STEP 3: CAVE WARP (domain warp the SDF skeleton) + //========================================================================= + // This is the KEY step that makes caves look natural instead of graph-like. + // + // The SDF morphology places rooms as geometric primitives (ellipsoids, boxes) + // connected by straight capsule tunnels. Without warping, you can clearly see + // the room-corridor-room graph structure — it looks artificial. + // + // By warping the world coordinates BEFORE evaluating the SDF, we bend the + // entire cave field. Rooms become irregular blobs, tunnels become winding + // passages. The skeleton is still there as the backbone, but noise pushes + // and pulls it into shapes that look carved by geological forces. + // + // Two octaves: + // - Large: CaveWarpFrequency → broad sweeping bends (room-scale) + // - Medium: 3x frequency, 0.3x strength → wall-scale irregularity + // + // IMPORTANT: Only the SDF query uses warped coordinates. Roughness, terrain + // ops, and hash-placed features use the REAL world position so they stay + // geologically correct (terracing stays horizontal, columns stay vertical, etc.) + float WarpedX = WorldX; + float WarpedY = WorldY; + float WarpedZ = EffectiveZ; + + if (Params.CaveWarpStrength > 0.0f) + { + const float WF = Params.CaveWarpFrequency; + const float WS = Params.CaveWarpStrength; + + // Three independent Perlin fields offset by irrational-ish numbers + // so the X/Y/Z warp channels don't correlate with each other. + // Single octave to keep per-voxel cost low (3 Perlin calls total). + WarpedX += FMath::PerlinNoise3D(FVector( + WorldX * WF + SeedF * 0.37f, + WorldY * WF + 1.3f, + EffectiveZ * WF + 5.7f)) * VOXEL_NOISE_SCALE * WS; + WarpedY += FMath::PerlinNoise3D(FVector( + WorldX * WF + 7.1f, + WorldY * WF + SeedF * 0.59f, + EffectiveZ * WF + 2.3f)) * VOXEL_NOISE_SCALE * WS; + WarpedZ += FMath::PerlinNoise3D(FVector( + WorldX * WF + 11.3f, + WorldY * WF + 9.7f, + EffectiveZ * WF + SeedF * 0.41f)) * VOXEL_NOISE_SCALE * WS; + } + + //========================================================================= + // STEP 4: SDF MORPHOLOGY (rooms + tunnels) — CACHED PER CHUNK + //========================================================================= + // The room list and tunnel connections are IDENTICAL for all voxels in a + // chunk. Without caching, we rebuild them 32,768 times (32³ voxels). + // With thread_local caching: build once, evaluate 32K times with just SDF math. + // + // The cache is keyed on (ChunkX, ChunkY, StrateIndex, Seed). When the key + // changes (new chunk or strate), the cache is rebuilt. thread_local ensures + // each task thread has its own cache — no locking needed. + // + // The warped position is used for SDF evaluation (bends rooms/tunnels), + // but the cache search area is expanded by CaveWarpStrength to ensure + // all reachable rooms are included regardless of warp displacement. + // + // Declared before the RoomDensity if-block so SDFCache and NearestRoomIdx + // are also visible to the terrain ops block further below. + thread_local FChunkSDFCache SDFCache; + // Cache validity is tracked by the SEARCH BOX the cache was built for, NOT by chunk + // equality. Gradient-normal sampling queries at WorldX±1 (and warp displacement) can + // step a voxel outside the chunk; with chunk-equality keying that flipped the key and + // rebuilt the (now expensive) cache every boundary cell. Since the stored rooms cover + // the search box + MaxInfluence, any query INSIDE the box is correct — so we only + // rebuild when the query actually leaves the box. Result: one build per chunk, no thrash. + thread_local float CachedSMinX = 1.0f, CachedSMaxX = -1.0f; // start invalid (min > max) + thread_local float CachedSMinY = 0.0f, CachedSMaxY = 0.0f; + thread_local int32 CachedStrate = INT32_MIN; + thread_local uint32 CachedSeed = 0; + + // Index of the room with the smallest (most-inside) SDF for this voxel. + // Written by EvaluateSDFCached, read by the terrain ops block to pick the + // per-room terrain op. -1 means no room is nearby (deep-solid or no rooms). + thread_local int32 NearestRoomIdx = -1; + + float CaveSDF = FLT_MAX; + + if (Params.RoomDensity > 0.0f && Params.RoomSpacing > 0.0f) + { + // Get strate index for unique caves per strate. + // GetStrateIndex expects Unreal world units (divides by VOXEL_SIZE internally), + // but our WorldZ is in voxel coordinates, so multiply by VOXEL_SIZE. + int32 StrateIdx = 0; + if (StrateManager) + { + StrateIdx = StrateManager->GetStrateIndex(WorldZ * VOXEL_SIZE); + } + + // Rebuild only when the WARPED query (what EvaluateSDFCached uses) leaves the + // cached search box, or the strate/seed changed. + const bool bNeedRebuild = + StrateIdx != CachedStrate || (uint32)Seed != CachedSeed || + WarpedX < CachedSMinX || WarpedX > CachedSMaxX || + WarpedY < CachedSMinY || WarpedY > CachedSMaxY; + + if (bNeedRebuild) + { + // Center the search box on this query's chunk. Search area = chunk XY extent + // + CaveWarpStrength margin (covers warp displacement) + gradient sampling. + // MaxInfluence (room/tunnel reach) is added internally by BuildChunkCache. + const int32 CacheChunkX = FMath::FloorToInt(WorldX / (float)CHUNK_SIZE); + const int32 CacheChunkY = FMath::FloorToInt(WorldY / (float)CHUNK_SIZE); + const float ChunkMinX = CacheChunkX * (float)CHUNK_SIZE; + const float ChunkMinY = CacheChunkY * (float)CHUNK_SIZE; + const float ChunkMaxX = ChunkMinX + (float)CHUNK_SIZE; + const float ChunkMaxY = ChunkMinY + (float)CHUNK_SIZE; + const float Expansion = Params.CaveWarpStrength + 2.0f; + + const float SMinX = ChunkMinX - Expansion; + const float SMinY = ChunkMinY - Expansion; + const float SMaxX = ChunkMaxX + Expansion; + const float SMaxY = ChunkMaxY + Expansion; + + // Fetch the terrain op probability pool for this chunk's strate. + // BuildChunkCache uses this to hash-roll one terrain op per room. + const TArray* TerrainOps = nullptr; + if (StrateManager) + { + const int32 ChunkZ = FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE); + UVoxelStrateDefinition* Def = StrateManager->GetStrateForChunk( + FIntVector(CacheChunkX, CacheChunkY, ChunkZ)); + if (Def) TerrainOps = &Def->TerrainOperations; + } + + VoxelCaveMorphology::BuildChunkCache( + SDFCache, + SMinX, SMinY, SMaxX, SMaxY, + Params, (uint32)Seed, StrateIdx, + TerrainOps + ); + + CachedSMinX = SMinX; CachedSMaxX = SMaxX; + CachedSMinY = SMinY; CachedSMaxY = SMaxY; + CachedStrate = StrateIdx; + CachedSeed = (uint32)Seed; + } + + // Evaluate SDF using cached rooms and tunnels (WARPED coordinates). + // Also writes NearestRoomIdx — the room with minimum SDF contribution + // at this voxel's position. Used by terrain ops below to pick per-room params. + NearestRoomIdx = -1; + CaveSDF = VoxelCaveMorphology::EvaluateSDFCached( + WarpedX, WarpedY, WarpedZ, + SDFCache, Params.SDFBlendRadius, Params.RoomShapeVariety, + &NearestRoomIdx + ); + + // ── PIT & CHIMNEY SDF INTEGRATION ── + // Pits and chimneys are SmoothMin'd into CaveSDF here, using REAL + // (unwarped) world coordinates. This is intentional: pit positions come + // from unwarped room centers, so evaluating them in warped space would + // misalign them. Rooms also use unwarped positions — warping is purely a + // query-coordinate bend, not a data-space transform. + // + // Including pits in CaveSDF means: + // - The CarveFactor block below handles their density naturally + // - SmoothMin at the pit-to-room junction creates the same organic + // transition as tunnel-to-room (no hard seam at PitTopZ) + // - bNearCaveSurface becomes true inside the shaft — roughness clamp + // prevents fill-back, so this is safe + for (const FCachedPit& Pit : SDFCache.Pits) + { + const float DZ = WorldZ - Pit.TopZ; // Negative = below anchor (shaft) + if (DZ >= Pit.BlendK) continue; // Above even the blend fringe + if (-DZ > Pit.Depth + Pit.BlendK) continue; + + float DX = WorldX - Pit.CenterX; + float DY = WorldY - Pit.CenterY; + float XYDistSq = DX * DX + DY * DY; + if (XYDistSq > Pit.BoundXYRadiusSq) continue; + + float PitSDF; + if (DZ <= 0.0f) + { + // Shaft: tapered cylinder with flared opening + float DepthBelow = -DZ; + float FlareFactor = FMath::Clamp(1.0f - DepthBelow / Pit.FlareDist, 0.0f, 1.0f); + FlareFactor = FlareFactor * FlareFactor; + float EffRadius = Pit.Radius + Pit.FlareExtra * FlareFactor; + PitSDF = FMath::Sqrt(XYDistSq) - EffRadius; + } + else + { + // Above anchor: only the blend fringe matters here. + // We still pass a SDF so SmoothMin can soften the rim from above. + // Treat this zone as a flat disc at TopZ (XY cylinder, no Z factor) + // so the blend fades horizontally into the room floor. + PitSDF = FMath::Sqrt(XYDistSq) - (Pit.Radius + Pit.FlareExtra); + } + + CaveSDF = VoxelSDF::SmoothMin(CaveSDF, PitSDF, Pit.BlendK); + } + + for (const FCachedChimney& Chim : SDFCache.Chimneys) + { + const float DZ = WorldZ - Chim.BottomZ; // Positive = above anchor (shaft) + if (-DZ >= Chim.BlendK) continue; // Below even the blend fringe + if (DZ > Chim.Height + Chim.BlendK) continue; + + float DX = WorldX - Chim.CenterX; + float DY = WorldY - Chim.CenterY; + float XYDistSq = DX * DX + DY * DY; + if (XYDistSq > Chim.BoundXYRadiusSq) continue; + + float ChmSDF; + if (DZ >= 0.0f) + { + float FlareFactor = FMath::Clamp(1.0f - DZ / Chim.FlareDist, 0.0f, 1.0f); + FlareFactor = FlareFactor * FlareFactor; + float EffRadius = Chim.Radius + Chim.FlareExtra * FlareFactor; + ChmSDF = FMath::Sqrt(XYDistSq) - EffRadius; + } + else + { + // Below anchor: flat disc blend into room ceiling + ChmSDF = FMath::Sqrt(XYDistSq) - (Chim.Radius + Chim.FlareExtra); + } + + CaveSDF = VoxelSDF::SmoothMin(CaveSDF, ChmSDF, Chim.BlendK); + } + + // Convert SDF to density carving: + // CaveSDF < 0 means we're inside a room/tunnel/pit → carve to air + // CaveSDF > 0 means we're in solid rock → no change + // The transition zone around SDF=0 gives smooth cave walls + if (CaveSDF < Params.SDFBlendRadius) + { + // Smooth carving factor: 1.0 deep inside cave, 0.0 at blend edge + float CarveFactor = FMath::Clamp( + (Params.SDFBlendRadius - CaveSDF) / FMath::Max(Params.SDFBlendRadius * 2.0f, 1.0f), + 0.0f, 1.0f + ); + // Smoothstep for less abrupt transitions + CarveFactor = SmoothStep01(CarveFactor); + Density -= CarveFactor * Params.BaseDensity * 2.0f; + } + } + + //========================================================================= + // EARLY-OUT: Skip detail work for deep-solid voxels + //========================================================================= + // ~70% of voxels in a chunk are deep inside solid rock, far from any cave. + // Roughness, terrain ops, columns, pits, domes, pinch — ALL of these only + // matter near cave surfaces. Skipping them for deep-solid voxels is the + // single biggest performance win. + // + // We still need to run worm tunnels (they carve independently) and boundary + // seal / modifiers, so we jump to Step 5 instead of returning early. + const float DetailThreshold = Params.SDFBlendRadius * 3.0f; + const bool bNearCaveSurface = (CaveSDF < DetailThreshold) && (CaveSDF < FLT_MAX); + + //========================================================================= + // STEP 4b: SURFACE ROUGHNESS (volumetric, SDF-based) + //========================================================================= + // 3D noise near cave surfaces (where SDF ≈ 0) creates rocky detail: + // overhangs, ledges, bumps, cracks. The SDF value directly tells us + // how far from the surface we are — no need for separate floor/ceiling tracking. + // + // The noise type (fBM, Ridged, Mixed) and optional domain warping are + // per-strate settings, so each strate can have fundamentally different + // wall character — smooth lava tubes vs craggy erosion cliffs. + if (bNearCaveSurface && Params.SurfaceRoughness > 0.0f) + { + float RoughnessDepth = Params.SurfaceRoughness * 2.0f; + float DistFromSurface = FMath::Abs(CaveSDF); + + if (DistFromSurface < RoughnessDepth) + { + float RF = Params.RoughnessFrequency; + + // Base noise input positions (with seed offsets for uniqueness) + FVector MainPos( + WorldX * RF + SeedF * 11.3f, + WorldY * RF + SeedF * 13.7f, + EffectiveZ * RF + SeedF * 17.1f + ); + FVector FinePos( + WorldX * RF * 3.0f + SeedF * 19.1f + 2000.0f, + WorldY * RF * 3.0f + SeedF * 23.7f + 2500.0f, + EffectiveZ * RF * 3.0f + SeedF * 29.3f + 3000.0f + ); + + // DOMAIN WARPING: distort noise coordinates with a secondary field. + // This breaks up repetitive patterns — coordinates are "bent" by noise, + // making walls look like they were shaped by flowing water or pressure. + // Each axis uses a different seed offset for independent warping. + if (Params.DomainWarpStrength > 0.0f) + { + float WF = Params.DomainWarpFrequency; + float WS = Params.DomainWarpStrength; + + // Sample three independent noise fields for X, Y, Z warp + float WarpX = FMath::PerlinNoise3D(FVector( + WorldX * WF + SeedF * 5.2f, + WorldY * WF + SeedF * 1.3f, + EffectiveZ * WF + SeedF * 9.7f + )) * VOXEL_NOISE_SCALE * WS; + + float WarpY = FMath::PerlinNoise3D(FVector( + WorldX * WF + 100.0f + SeedF * 7.7f, + WorldY * WF + 200.0f + SeedF * 3.1f, + EffectiveZ * WF + 300.0f + )) * VOXEL_NOISE_SCALE * WS; + + float WarpZ = FMath::PerlinNoise3D(FVector( + WorldX * WF + 400.0f, + WorldY * WF + 500.0f + SeedF * 11.9f, + EffectiveZ * WF + 600.0f + SeedF * 13.3f + )) * VOXEL_NOISE_SCALE * WS; + + // Apply warp to both noise positions + FVector WarpOffset(WarpX, WarpY, WarpZ); + MainPos += WarpOffset; + FinePos += WarpOffset; + } + + // NOISE TYPE SELECTION: sample the right noise function + float RoughNoise, FineNoise; + + switch (Params.RoughnessNoiseType) + { + case EVoxelNoiseType::Ridged: + // Ridged multifractal: sharp, craggy features + RoughNoise = RidgedNoise3D(MainPos, 3); + FineNoise = RidgedNoise3D(FinePos, 2); + break; + + case EVoxelNoiseType::Mixed: + // Blend: ridged structure softened by fBM + RoughNoise = FractalNoise3D(MainPos, 3) * 0.5f + + RidgedNoise3D(MainPos, 3) * 0.5f; + FineNoise = FractalNoise3D(FinePos, 2) * 0.5f + + RidgedNoise3D(FinePos, 2) * 0.5f; + break; + + case EVoxelNoiseType::Cellular: + // Worley/cellular: grotto, bubble-like patterns + // Scale down because cellular noise has different frequency behavior + RoughNoise = CellularNoise3D(MainPos); + FineNoise = CellularNoise3D(FinePos); + break; + + case EVoxelNoiseType::FBM: + default: + // Standard fBM: smooth, organic + RoughNoise = FractalNoise3D(MainPos, 3); + FineNoise = FractalNoise3D(FinePos, 2); + break; + } + + // Scale by VOXEL_NOISE_SCALE (all noise functions return ~[-1,1] but UE + // Perlin internally returns ~[-0.8,0.8]) + RoughNoise *= VOXEL_NOISE_SCALE; + FineNoise *= VOXEL_NOISE_SCALE; + + float TotalRough = RoughNoise * Params.SurfaceRoughness + + FineNoise * Params.SurfaceRoughness * 0.4f; + + // Inside definite cave air (CaveSDF < 0), roughness must never add solid back. + // Without this clamp, barely-negative voxels near tunnel junctions or pit rims + // get pushed back to solid by roughness → thin lids, membrane walls, bad seams. + // Roughness can still carve further into walls (negative values), just not fill. + if (CaveSDF < 0.0f) + { + TotalRough = FMath::Min(TotalRough, 0.0f); + } + + // Fade out toward cave center (SurfaceFade = 1.0 at surface, 0.0 far away) + float SurfaceFade = 1.0f - (DistFromSurface / RoughnessDepth); + SurfaceFade = SurfaceFade * SurfaceFade; // Quadratic: concentrate near surface + + Density += TotalRough * SurfaceFade; + } + } + + //========================================================================= + // STEP 4c-4h: TERRAIN OPERATIONS (only near cave surfaces) + //========================================================================= + // All terrain ops only affect density near cave walls. + // Deep-solid voxels skip this entire block (bNearCaveSurface = false). + if (bNearCaveSurface) + { + //========================================================================= + // PER-ROOM TERRAIN PARAMS + //========================================================================= + // Terrain ops are now per-room (not global). NearestRoomIdx was written by + // EvaluateSDFCached above — it's the room with minimum SDF at this voxel. + // + // We copy Params and apply the nearest room's terrain op on top. + // Base Params has all terrain op fields = 0 (disabled) since + // BuildParamsFromDefinition no longer merges them globally. + // + // Result: voxels inside different rooms see different terrain ops. + // Rooms with no assigned op leave terrain fields at 0 → no terrain op. Clean. + FStrateGenerationParams LocalTerrainParams = Params; + if (NearestRoomIdx >= 0 && SDFCache.Rooms.IsValidIndex(NearestRoomIdx)) + { + const FCachedRoom& NR = SDFCache.Rooms[NearestRoomIdx]; + if (NR.RoomOp) + { + // Writes only the op's specific fields (e.g. TerraceStepHeight for Terrace). + // All other fields keep Params values unchanged. + NR.RoomOp->ApplyTo(LocalTerrainParams, NR.RoomOpWeight); + } + } + // Shadow outer Params inside this block so all terrain op code below + // automatically uses the per-room values without any other changes. + const FStrateGenerationParams& Params = LocalTerrainParams; + + // Geological features applied after roughness. These modify the density + // field near cave surfaces to create specific shapes: terracing (step-like + // ledges), layer lines (horizontal grooves), and overhangs (horizontal + // shelf protrusions). Each operation is controlled by params in the strate + // definition — set the main param to 0 to disable any operation. + + // --- TERRACING --- + // Creates a staircase pattern on cave walls by offsetting density with + // a smooth staircase function of world Z. The staircase quantizes the + // cave surface into flat shelves connected by short cliff faces. + // + // Math: terracedZ = staircase(worldZ, stepH, hardness) + // offset = terracedZ - worldZ + // Where offset > 0 → more solid (shelf floor to walk on) + // Where offset < 0 → more air (gap under the shelf above) + if (Params.TerraceStepHeight > 0.0f && CaveSDF < FLT_MAX) + { + const float StepH = Params.TerraceStepHeight; + const float DistFromSurface = FMath::Abs(CaveSDF); + const float TerraceRange = StepH * 3.0f; // How far from surface the effect reaches + + if (DistFromSurface < TerraceRange) + { + // Surface orientation test: terracing only makes sense on horizontal surfaces + // (cave floors). On vertical walls (pit shafts, tunnel sides) it creates ugly + // horizontal ridges. We sample the SDF gradient in Z by querying Z±1 and + // measure how much the SDF changes vertically vs. how much it'd change on a + // perfectly horizontal surface. GradZ near 1 = floor/ceiling, near 0 = wall. + // + // We use the cached SDF so this costs two extra SDF evaluations per voxel, + // only when near a surface — the common case is cheap (DistFromSurface > TerraceRange). + float SDF_Zp1 = VoxelCaveMorphology::EvaluateSDFCached(WorldX, WorldY, WorldZ + 1.0f, SDFCache, Params.SDFBlendRadius, Params.RoomShapeVariety); + float SDF_Zm1 = VoxelCaveMorphology::EvaluateSDFCached(WorldX, WorldY, WorldZ - 1.0f, SDFCache, Params.SDFBlendRadius, Params.RoomShapeVariety); + // Central difference gradient in Z, approximate magnitude via all-axis samples + float SDF_Xp1 = VoxelCaveMorphology::EvaluateSDFCached(WorldX + 1.0f, WorldY, WorldZ, SDFCache, Params.SDFBlendRadius, Params.RoomShapeVariety); + float SDF_Xm1 = VoxelCaveMorphology::EvaluateSDFCached(WorldX - 1.0f, WorldY, WorldZ, SDFCache, Params.SDFBlendRadius, Params.RoomShapeVariety); + float SDF_Yp1 = VoxelCaveMorphology::EvaluateSDFCached(WorldX, WorldY + 1.0f, WorldZ, SDFCache, Params.SDFBlendRadius, Params.RoomShapeVariety); + float SDF_Ym1 = VoxelCaveMorphology::EvaluateSDFCached(WorldX, WorldY - 1.0f, WorldZ, SDFCache, Params.SDFBlendRadius, Params.RoomShapeVariety); + float GX = (SDF_Xp1 - SDF_Xm1) * 0.5f; + float GY = (SDF_Yp1 - SDF_Ym1) * 0.5f; + float GZ = (SDF_Zp1 - SDF_Zm1) * 0.5f; + float GLen = FMath::Sqrt(GX*GX + GY*GY + GZ*GZ); + // Normalized vertical component: 1 = perfectly horizontal surface (floor/ceiling) + // 0 = perfectly vertical surface (wall) + float SurfaceHorizontality = (GLen > KINDA_SMALL_NUMBER) ? FMath::Abs(GZ) / GLen : 0.0f; + // Only apply terrace where the surface is mostly horizontal (> ~45 degrees) + // Smooth transition to avoid a hard cutoff at exactly 45 degrees + float TerraceOrientFactor = FMath::Clamp((SurfaceHorizontality - 0.3f) / 0.4f, 0.0f, 1.0f); + // Noise displacement: perturb Z before staircase to break up straight edges + float NoisedZ = WorldZ; + if (Params.TerraceNoiseDisplacement > 0.0f) + { + float DispNoise = FractalNoise3D(FVector( + WorldX * 0.04f + SeedF * 31.1f, + WorldY * 0.04f + SeedF * 37.3f, + WorldZ * 0.02f + SeedF * 41.7f + ), 2) * VOXEL_NOISE_SCALE; + NoisedZ += DispNoise * Params.TerraceNoiseDisplacement * StepH; + } + + // Smooth staircase function: + // K = NoisedZ / StepH (which "step" are we in?) + // Frac = fractional part [0, 1) — position within the step + // Edge controls transition sharpness (narrow edge = sharp cliff face) + float K = NoisedZ / StepH; + float FloorK = FMath::FloorToFloat(K); + float Frac = K - FloorK; // Always [0, 1) + + // Build the stair profile: 0 in lower half, 1 in upper half, smooth transition + float Edge = FMath::Lerp(0.45f, 0.02f, Params.TerraceHardness); + float StairValue; + if (Frac < 0.5f - Edge) + { + StairValue = 0.0f; // Lower flat region (below transition) + } + else if (Frac > 0.5f + Edge) + { + StairValue = 1.0f; // Upper flat region (above transition) + } + else + { + // Smoothstep through the transition zone + float T = (Frac - (0.5f - Edge)) / (2.0f * Edge); + StairValue = SmoothStep01(T); + } + + // Reconstruct the terraced Z and compute offset from real Z + float TerracedZ = (FloorK + StairValue) * StepH; + float Offset = TerracedZ - NoisedZ; + // Offset range: approximately [-StepH/2, +StepH/2] + // Positive → below a shelf surface → add density (solid floor) + // Negative → above a shelf → subtract density (air gap under next shelf) + + // Fade based on distance from cave surface (no effect deep in rock) + float Fade = 1.0f - (DistFromSurface / TerraceRange); + Fade = Fade * Fade; // Quadratic: concentrate near surface + + // TerraceOrientFactor suppresses terrace on vertical walls (pit shafts, etc.) + Density += Offset * Fade * TerraceOrientFactor; + } + } + + // --- LAYER LINES --- + // Horizontal grooves in cave walls — visible geological strata. + // A sine wave along Z, sharpened to create thin lines, subtracts density + // near cave surfaces. This carves narrow horizontal channels into walls + // at regular intervals, like sedimentary rock layers in cross-section. + if (Params.LayerLineSpacing > 0.0f && CaveSDF < FLT_MAX) + { + const float DistFromSurface = FMath::Abs(CaveSDF); + const float LineRange = Params.LayerLineSpacing * 1.5f; // Influence depth into rock + + if (DistFromSurface < LineRange) + { + // Sine wave along Z: peaks at each line position + float LinePhase = WorldZ * (2.0f * PI) / Params.LayerLineSpacing; + float LineValue = FMath::Sin(LinePhase); + + // Sharpen to thin grooves: only carve where sine > 0, then cube it. + // sin → max(sin, 0) → pow(_, 3) turns broad sine humps into thin spikes + LineValue = FMath::Max(LineValue, 0.0f); + LineValue = LineValue * LineValue * LineValue; // Cubic sharpening + + // Fade near surface + float Fade = 1.0f - (DistFromSurface / LineRange); + Fade = Fade * Fade; + + // Subtract density to carve the groove + Density -= LineValue * Params.LayerLineDepth * Fade; + } + } + + // --- RIBBING --- + // Parallel ridge patterns on walls/ceiling (like lava tubes). + // Uses the same sine-along-Z approach as layer lines, but ADDS density + // (protruding ribs) instead of subtracting (grooves). The sine is + // half-wave rectified and smoothed to create rounded bumps. + if (Params.RibbingSpacing > 0.0f && CaveSDF < FLT_MAX) + { + const float DistFromSurface = FMath::Abs(CaveSDF); + const float RibRange = Params.RibbingSpacing * 1.5f; + + if (DistFromSurface < RibRange) + { + // Sine wave along Z, offset by half-period from layer lines + float RibPhase = WorldZ * (2.0f * PI) / Params.RibbingSpacing + PI * 0.5f; + float RibValue = FMath::Sin(RibPhase); + + // Half-wave rectify (only positive → ribs, not grooves) then smooth + RibValue = FMath::Max(RibValue, 0.0f); + RibValue = RibValue * RibValue; // Quadratic: rounder bump profile + + // Fade near surface + float Fade = 1.0f - (DistFromSurface / RibRange); + Fade = Fade * Fade; + + // Add density to create protruding ribs + Density += RibValue * Params.RibbingDepth * Fade; + } + } + + // --- OVERHANGS --- + // Horizontal shelf-like protrusions from cave walls. + // Uses 3D noise with much lower Z frequency than XY, so features extend + // horizontally for long stretches before varying vertically. This creates + // natural rocky overhangs and ledges independent of the terracing system. + // + // Only positive noise values create protrusions (asymmetric: rock extends + // INTO the cave, never away). This gives scattered shelf-like features + // rather than uniform displacement. + if (Params.OverhangStrength > 0.0f && CaveSDF < FLT_MAX) + { + const float DistFromSurface = FMath::Abs(CaveSDF); + const float OverhangRange = Params.OverhangDepth * 2.0f; + + if (DistFromSurface < OverhangRange) + { + // Low Z frequency (0.15x of XY) → features extend horizontally + float OverhangNoise = FractalNoise3D(FVector( + WorldX * Params.OverhangFrequency + SeedF * 53.1f, + WorldY * Params.OverhangFrequency + SeedF * 59.3f, + EffectiveZ * Params.OverhangFrequency * 0.15f + SeedF * 61.7f + ), 2) * VOXEL_NOISE_SCALE; + + // Only where noise is positive → protrusions (not recesses) + if (OverhangNoise > 0.0f) + { + float Fade = 1.0f - (DistFromSurface / OverhangRange); + Fade = Fade * Fade; + + // Add density = extend solid rock into cave = overhang shelf + Density += OverhangNoise * Params.OverhangDepth + * Params.OverhangStrength * Fade; + } + } + } + + // --- CLIFF SHARPENING --- + // Steepens vertical faces by amplifying the Z-axis density gradient. + // Where the cave surface is already somewhat vertical (density changes + // quickly along Z), this pushes it toward a sheer cliff face. + // + // Math: sample density at Z+1 and Z-1, compute vertical gradient. + // Where gradient is steep AND we're near the cave surface: + // If we're in the UPPER portion of the cliff → subtract density (more air) + // If we're in the LOWER portion → add density (more solid) + // This squeezes the transition zone, making it near-vertical. + if (Params.CliffStrength > 0.0f && CaveSDF < FLT_MAX) + { + const float DistFromSurface = FMath::Abs(CaveSDF); + const float CliffRange = 8.0f; // Only affect voxels within 8 of surface + + if (DistFromSurface < CliffRange) + { + // Approximate vertical gradient via the CaveSDF sign and position. + // Near the surface (CaveSDF ≈ 0), the sign of CaveSDF tells us which + // side we're on: negative = inside cave, positive = solid rock. + // We use a noise-modulated vertical gradient to detect steep faces. + float VertGrad = FMath::PerlinNoise3D(FVector( + WorldX * 0.05f + SeedF * 71.3f, + WorldY * 0.05f + SeedF * 73.7f, + EffectiveZ * 0.15f + SeedF * 79.1f // 3x faster in Z → detects vertical features + )) * VOXEL_NOISE_SCALE; + + // VertGrad near ±1 means terrain is changing fast vertically. + // Multiply by sign of CaveSDF to get direction: + // Positive result (solid side, gradient pointing up) → add more solid + // Negative result (air side) → carve more air + float CliffEffect = VertGrad * CaveSDF * Params.CliffStrength; + + // Only apply where gradient is significant (abs > 0.3) + // and fade with distance from surface + if (FMath::Abs(VertGrad) > 0.3f) + { + float Fade = 1.0f - (DistFromSurface / CliffRange); + Fade = Fade * Fade; + Density += CliffEffect * Fade * 3.0f; + } + } + } + + // --- SCALLOP --- + // Water-erosion-like concave patterns on cave walls. + // Uses cellular (Worley) noise near cave surfaces — the distance-to-nearest + // feature point creates natural bowl-shaped indentations. Where the cellular + // noise value is high (far from feature points = center of a cell), we + // subtract density to carve shallow bowls into the wall. + // + // This gives limestone caves their characteristic scalloped appearance — + // rows of smooth, concave depressions covering the walls. + if (Params.ScallopStrength > 0.0f && CaveSDF < FLT_MAX) + { + const float DistFromSurface = FMath::Abs(CaveSDF); + const float ScallopRange = Params.ScallopStrength * 4.0f; + + if (DistFromSurface < ScallopRange) + { + // Cellular noise: returns ~[-1, 1] where positive = cell interior (bowl) + float SF = Params.ScallopFrequency; + float ScallopNoise = CellularNoise3D(FVector( + WorldX * SF + SeedF * 83.1f, + WorldY * SF + SeedF * 89.3f, + EffectiveZ * SF + SeedF * 97.7f + )); + + // Only carve where noise is positive (cell interiors = bowl centers) + if (ScallopNoise > 0.0f) + { + float Fade = 1.0f - (DistFromSurface / ScallopRange); + Fade = Fade * Fade; + + // Subtract density to carve concave bowls + Density -= ScallopNoise * Params.ScallopStrength * Fade; + } + } + } + + // --- ARCH / BRIDGE (room-relative) --- + // Horizontal rock bridges spanning the room interior. + // Anchored to the nearest room: each arch spans from one side of the room + // to the other at a hash-derived height within the room's Z range. + // Up to MaxArches per room; ArchDensity = probability per slot. + if (Params.ArchDensity > 0.0f && CaveSDF < Params.SDFBlendRadius && CaveSDF < FLT_MAX + && NearestRoomIdx >= 0) + { + const FCachedRoom& Room = SDFCache.Rooms[NearestRoomIdx]; + const int32 MaxArches = 3; + const FVector VoxPos(WorldX, WorldY, WorldZ); + + for (int32 i = 0; i < MaxArches; i++) + { + uint32 AH = VoxelHash::Mix(Room.Hash ^ (0xA4C400u + (uint32)i * 7369u)); + + if (VoxelHash::ToFloat01(AH) > Params.ArchDensity) continue; + + // Arch center XY: small offset from room center + uint32 AH2 = VoxelHash::Mix(AH ^ 0xA4C4u); + float ArcCX = Room.Center.X + VoxelHash::ToFloatSigned(AH2) * Room.RadiusXY * 0.3f; + float ArcCY = Room.Center.Y + VoxelHash::ToFloatSigned(VoxelHash::Mix(AH2)) * Room.RadiusXY * 0.3f; + + // Arch height: mid-room Z (bridging the open space) + uint32 AH3 = VoxelHash::Mix(AH2 ^ 0xB41Du); + float ArcCZ = Room.Center.Z + VoxelHash::ToFloatSigned(AH3) * Room.RadiusZ * 0.4f; + + // Arch direction and span: stretch across most of the room + uint32 AH4 = VoxelHash::Mix(AH3 ^ 0xCAFEu); + float Angle = VoxelHash::ToFloat01(AH4) * PI; + float HalfSpan = Room.RadiusXY * (0.5f + VoxelHash::ToFloat01(VoxelHash::Mix(AH4)) * 0.35f); + + float CosA = FMath::Cos(Angle); + float SinA = FMath::Sin(Angle); + FVector ArchA(ArcCX - CosA * HalfSpan, ArcCY - SinA * HalfSpan, ArcCZ); + FVector ArchB(ArcCX + CosA * HalfSpan, ArcCY + SinA * HalfSpan, ArcCZ); + + // Arch thickness + uint32 AH5 = VoxelHash::Mix(AH4 ^ 0xF00Du); + float ArchRadius = FMath::Lerp(Params.ArchMinRadius, Params.ArchMaxRadius, + VoxelHash::ToFloat01(AH5)); + + float ArchSDF = VoxelSDF::Capsule(VoxPos, ArchA, ArchB, ArchRadius); + + const float ArchBlend = 2.0f; + if (ArchSDF < ArchBlend) + { + float Fill = FMath::Clamp((ArchBlend - ArchSDF) / (ArchBlend * 2.0f), 0.0f, 1.0f); + Fill = SmoothStep01(Fill); + Density += Fill * Params.BaseDensity * 1.5f; + } + } + } + + //========================================================================= + // STEP 4d: COLUMNS (pre-baked from BuildChunkCache) + //========================================================================= + // Columns are now pre-baked into SDFCache.Columns — no NearestRoomIdx needed. + // The old per-voxel approach had columns appear/disappear mid-height when + // the owning room changed (NearestRoomIdx switch). Pre-baking fixes this. + for (const FCachedColumn& Col : SDFCache.Columns) + { + float DX = WorldX - Col.CenterX; + float DY = WorldY - Col.CenterY; + float XYDistSq = DX * DX + DY * DY; + if (XYDistSq > Col.BoundXYRadiusSq) continue; + + float CylSDF = FMath::Sqrt(XYDistSq) - Col.Radius; + + const float ColBlend = 3.0f; + if (CylSDF < ColBlend) + { + float Fill = FMath::Clamp((ColBlend - CylSDF) / (ColBlend * 2.0f), 0.0f, 1.0f); + Fill = SmoothStep01(Fill); + Density += Fill * Col.BaseDensity * 1.5f; + } + } + + //========================================================================= + // STEP 4g: DOMES (room-relative hemispherical ceilings) + //========================================================================= + // Domes carve upward from the room's upper area, creating cathedral ceilings. + // DmCenterZ anchored to the room's Z center so the dome sits naturally + // in the ceiling zone rather than floating at arbitrary strate heights. + if (Params.DomeDensity > 0.0f && CaveSDF < Params.SDFBlendRadius && CaveSDF < FLT_MAX + && NearestRoomIdx >= 0) + { + const FCachedRoom& Room = SDFCache.Rooms[NearestRoomIdx]; + // Up to 2 domes per room (large rooms can have multiple cathedral pockets) + const int32 MaxDomes = 2; + + for (int32 i = 0; i < MaxDomes; i++) + { + uint32 DH = VoxelHash::Mix(Room.Hash ^ (0xD0AE0u + (uint32)i * 8191u)); + + if (VoxelHash::ToFloat01(DH) > Params.DomeDensity) continue; + + // XY: near room center (domes are wide, keep them centered) + uint32 DH2 = VoxelHash::Mix(DH ^ 0xD0A0u); + float DmX = Room.Center.X + VoxelHash::ToFloatSigned(DH2) * Room.RadiusXY * 0.4f; + float DmY = Room.Center.Y + VoxelHash::ToFloatSigned(VoxelHash::Mix(DH2)) * Room.RadiusXY * 0.4f; + + // Radius: cap at room radius so dome fits inside the room + uint32 DH3 = VoxelHash::Mix(DH2 ^ 0x90DEu); + float DmRadius = FMath::Min( + FMath::Lerp(Params.DomeMinRadius, Params.DomeMaxRadius, VoxelHash::ToFloat01(DH3)), + Room.RadiusXY * 0.85f + ); + + // Dome center Z: upper portion of the room (ceiling area) + uint32 DH4 = VoxelHash::Mix(DH3 ^ 0xCAFEu); + float DmCenterZ = Room.Center.Z + Room.RadiusZ * 0.2f + + VoxelHash::ToFloat01(DH4) * Room.RadiusZ * 0.3f; + + float DmHeight = DmRadius * Params.DomeHeightRatio; + + // Quick Z reject + if (WorldZ > DmCenterZ + DmHeight + 3.0f || WorldZ < DmCenterZ - 3.0f) continue; + + float DXDm = WorldX - DmX; + float DYDm = WorldY - DmY; + float DZDm = WorldZ - DmCenterZ; + + // Only carve upward from the dome center anchor + if (DZDm < 0.0f) continue; + + // Half-ellipsoid SDF (upward only) + float NormX = DXDm / DmRadius; + float NormY = DYDm / DmRadius; + float NormZ = DZDm / DmHeight; + float EllipDist = FMath::Sqrt(NormX * NormX + NormY * NormY + NormZ * NormZ) - 1.0f; + float DomeSDF = EllipDist * FMath::Min(DmRadius, DmHeight); + + const float DmBlend = 3.0f; + if (DomeSDF < DmBlend) + { + float Carve = FMath::Clamp((DmBlend - DomeSDF) / (DmBlend * 2.0f), 0.0f, 1.0f); + Carve = SmoothStep01(Carve); + Density -= Carve * Params.BaseDensity * 1.5f; + } + } + } + + //========================================================================= + // STEP 4h: PINCH / BOTTLENECK (room-relative passage narrowing) + //========================================================================= + // Pinches squeeze a passage from the sides. Placed at the room's perimeter + // area (high-radius offset from center) so they act on tunnel entrances + // and room edges, not the open center of the room. + if (Params.PinchDensity > 0.0f && CaveSDF < Params.SDFBlendRadius && CaveSDF < FLT_MAX + && NearestRoomIdx >= 0) + { + const FCachedRoom& Room = SDFCache.Rooms[NearestRoomIdx]; + // Pinches on the perimeter of the room (near tunnel entry points) + const float Spread = 0.85f; + const int32 MaxPinches = 3; + + for (int32 i = 0; i < MaxPinches; i++) + { + uint32 PnH = VoxelHash::Mix(Room.Hash ^ (0xF1C400u + (uint32)i * 5417u)); + + if (VoxelHash::ToFloat01(PnH) > Params.PinchDensity) continue; + + // XY: near room perimeter (where tunnels meet the room) + uint32 PnH2 = VoxelHash::Mix(PnH ^ 0xF1C4u); + float PnX = Room.Center.X + VoxelHash::ToFloatSigned(PnH2) * Room.RadiusXY * Spread; + float PnY = Room.Center.Y + VoxelHash::ToFloatSigned(VoxelHash::Mix(PnH2)) * Room.RadiusXY * Spread; + + // Z: mid-height within the room + uint32 PnH3 = VoxelHash::Mix(PnH2 ^ 0x5432u); + float PnZ = Room.Center.Z + VoxelHash::ToFloatSigned(PnH3) * Room.RadiusZ * 0.5f; + + // Pinch direction aligned toward room center (squeezes inward) + uint32 PnH4 = VoxelHash::Mix(PnH3 ^ 0x9A3Bu); + float PnAngle = VoxelHash::ToFloat01(PnH4) * PI; + float CosPN = FMath::Cos(PnAngle); + float SinPN = FMath::Sin(PnAngle); + + float DXPn = WorldX - PnX; + float DYPn = WorldY - PnY; + float DZPn = WorldZ - PnZ; + + // Quick reject + float MaxExtent = FMath::Max(Params.PinchLength, Params.PinchStrength) + 5.0f; + if (FMath::Abs(DXPn) + FMath::Abs(DYPn) + FMath::Abs(DZPn) > MaxExtent) continue; + + float Along = DXPn * CosPN + DYPn * SinPN; + float Across = -DXPn * SinPN + DYPn * CosPN; + + float HalfLength = Params.PinchLength * 0.5f; + float HalfNarrow = Params.PinchStrength; + float HalfVertical = Params.PinchStrength * 1.5f; + + float NAlong = Along / HalfLength; + float NAcross = Across / HalfNarrow; + float NUp = DZPn / HalfVertical; + float EllipDist = NAlong * NAlong + NAcross * NAcross + NUp * NUp; + + if (EllipDist < 1.0f) + { + float Fill = 1.0f - EllipDist; + Fill = SmoothStep01(Fill); + float AxisDist = FMath::Sqrt(NAcross * NAcross + NUp * NUp); + float SideFactor = FMath::Clamp(AxisDist * 2.0f, 0.0f, 1.0f); + Density += Fill * SideFactor * Params.BaseDensity * 1.5f; + } + } + } + + //========================================================================= + // FLOOR BIAS + //========================================================================= + // Adds density in the lower portion of rooms to counteract surface roughness + // making floors bumpy and hard to walk on. Works by knowing how far below + // the nearest room center we are — the closer to the floor, the more density + // is added back, smoothing the roughness-induced relief. + // + // Only applies inside cave air (CaveSDF < 0) so it doesn't re-solidify walls. + // The roughness clamp already prevents fill-back in confirmed air; this works + // WITH that to give floors a gentler character than walls/ceilings. + if (Params.FloorBias > 0.0f && NearestRoomIdx >= 0 && CaveSDF < 0.0f) + { + const FCachedRoom& NR = SDFCache.Rooms[NearestRoomIdx]; + // NormZ: -1 = at room bottom, 0 = center, +1 = at ceiling + const float NormZ = (WorldZ - NR.Center.Z) / FMath::Max(NR.RadiusZ, 1.0f); + + // Only apply below room center (floor zone). + // Quadratic fade: strongest at floor, zero at center. + if (NormZ < 0.0f) + { + float FloorFactor = NormZ * NormZ; // 0 at center, 1 at bottom + Density += FloorFactor * Params.FloorBias; + } + } + + } // end bNearCaveSurface (terrain ops) + + // Steps 4e / 4f (pits and chimneys) are now handled by the CaveSDF SmoothMin + // block inserted above (between EvaluateSDFCached and the CarveFactor section). + // The CarveFactor system carves them with the same density logic as rooms and + // tunnels. No separate density subtraction here — that would double-carve. + + //========================================================================= + // STEP 5: WORM TUNNELS (additional organic connectivity) + //========================================================================= + // Worm tunnels add secondary passages and organic connections + // that the SDF graph doesn't create. They're noise-based, so they + // produce natural winding paths that complement the room-and-corridor structure. + // + // HORIZONTAL BIAS: Z frequency is scaled up so tunnels prefer horizontal paths. + if (Params.WormStrength > 0.0f && Params.WormThreshold > 0.0f) + { + float WormZFreq = Params.WormFrequency * Params.WormHorizontalBias; + + float N1 = FMath::Abs(FMath::PerlinNoise3D(FVector( + WorldX * Params.WormFrequency + SeedF, + WorldY * Params.WormFrequency + SeedF * 1.7f, + EffectiveZ * WormZFreq + SeedF * 2.3f + )) * VOXEL_NOISE_SCALE); + + float N2 = FMath::Abs(FMath::PerlinNoise3D(FVector( + WorldX * Params.WormFrequency + SeedF + 137.0f, + WorldY * Params.WormFrequency + SeedF * 1.7f + 259.0f, + EffectiveZ * WormZFreq + SeedF * 2.3f + 431.0f + )) * VOXEL_NOISE_SCALE); + + float WormValue = N1 + N2; + + if (WormValue < Params.WormThreshold) + { + float t = 1.0f - (WormValue / Params.WormThreshold); + Density -= t * Params.WormStrength; + } + } + + //========================================================================= + // STEP 6: STRATE BOUNDARY SEAL (haut + bas) + //========================================================================= + ApplyOriginSpine(Density, WorldX, WorldY, WorldZ, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius); + + ApplyBoundarySeal(Density, WorldZ, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Params.BoundarySealThickness, Params.BaseDensity); + + //========================================================================= + // STEP 7: INTER-STRATE PASSAGES (perce le seal) + //========================================================================= + if (StrateManager) + { + const float ModSDF = StrateManager->EvaluateModifierSDF(WorldX, WorldY, WorldZ); + ApplyPassageCarving(Density, ModSDF, Params.BaseDensity, Params.BoundarySealThickness); + } + + // Convention MC: négatif = solide, positif = air. + // La logique interne utilise positif = solide (plus lisible), donc on négate. + return -Density; +} + +//============================================================================= +// SLAB DENSITY (FlatPlain / CrystalChamber generator types) +//============================================================================= +// Produces a large horizontal void between a noisy floor and a noisy ceiling. +// No rooms, no tunnels, no worm noise — just two surfaces with noise displacement. +// +// Key difference from TunnelNetwork: ceiling uses abs(noise) so ALL formations +// point DOWNWARD. This creates the stalactite/crystal hanging-from-above effect. +// Floor uses signed noise for natural ground undulation (hills and valleys). +// +// Columns are placed on a world-space hash grid (no rooms to anchor them to). + +float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ, + const FSlabGenerationParams& Params) const +{ + const float StrateHeight = Params.StrateTopWorldZ - Params.StrateBottomWorldZ; + + // Degenerate strate (zero or inverted bounds) — return solid. + if (StrateHeight <= 0.0f) return 1.0f; + + const float SeedF = (float)Seed; + + //========================================================================= + // STEP 1: FLOOR SURFACE + //========================================================================= + // The floor is at FloorZ + signed noise displacement. + // Signed noise allows both hills (noise > 0 → floor rises) and + // valleys (noise < 0 → floor dips) for natural rolling ground. + // + // Z frequency is set very low (5% of XY) so the floor features are + // broad and horizontal — like natural geological ground, not bumpy walls. + + const float FloorZ = Params.StrateBottomWorldZ + StrateHeight * Params.FloorRelativeHeight; + + float FloorNoise = 0.0f; + if (Params.FloorRoughness > 0.0f) + { + float FF = Params.FloorRoughnessFrequency; + FloorNoise = FractalNoise3D(FVector( + WorldX * FF + SeedF * 7.3f, + WorldY * FF + SeedF * 11.1f, + WorldZ * FF * 0.05f // Very low Z freq → horizontal ground features + ), 3) * VOXEL_NOISE_SCALE * Params.FloorRoughness; + } + + // Actual floor surface Z after noise displacement. + const float FloorSurface = FloorZ + FloorNoise; + + //========================================================================= + // STEP 2: CEILING SURFACE (formations hang DOWNWARD) + //========================================================================= + // The ceiling uses abs(noise) so ALL displacement pushes the ceiling DOWN. + // When abs(noise) is high, rock protrudes further into the void — stalactite. + // When abs(noise) is near 0, the ceiling is near the base CeilZ line. + // + // This asymmetry (only downward protrusions, never upward pockets) creates + // the crystal-forest / stalactite silhouette from below. + // + // Z frequency is also low so formations have horizontal extent — each + // "crystal" or "stalactite" is wide and sweeps across the ceiling, not + // a sharp spike (use high frequency for spike-like features if desired). + + const float CeilZ = Params.StrateBottomWorldZ + StrateHeight * Params.CeilingRelativeHeight; + + float CeilNoise = 0.0f; + if (Params.CeilingRoughness > 0.0f) + { + float CF = Params.CeilingRoughnessFrequency; + float RawNoise = FractalNoise3D(FVector( + WorldX * CF + SeedF * 17.3f + 1000.0f, + WorldY * CF + SeedF * 19.7f + 2000.0f, + WorldZ * CF * 0.08f + 3000.0f // Low Z freq → formations extend horizontally + ), 3) * VOXEL_NOISE_SCALE; + + // abs() → formations ONLY hang down, never push ceiling up into solid rock. + // Result: every noise peak creates a downward protrusion (crystal/stalactite). + CeilNoise = FMath::Abs(RawNoise) * Params.CeilingRoughness; + } + + // Actual ceiling surface Z (can only move downward due to abs above). + // Clamp so ceiling never drops below floor + 2 voxels of headroom. + // Without this clamp, extreme CeilingRoughness could completely fill the void. + const float CeilSurface = FMath::Max(CeilZ - CeilNoise, FloorSurface + 2.0f); + + //========================================================================= + // STEP 3: VOID FIELD → BASE DENSITY + //========================================================================= + // Each voxel is measured against both surfaces: + // DistAboveFloor > 0 → voxel is above the floor (possibly in the void) + // DistBelowCeil > 0 → voxel is below the ceiling (possibly in the void) + // + // VoidField = min of both distances. + // Positive inside the void (between floor and ceiling). + // Negative outside (below floor or above ceiling = solid rock). + // + // Density = -VoidField (internal convention: positive = solid, negative = air). + + const float DistAboveFloor = WorldZ - FloorSurface; // + when above floor + const float DistBelowCeil = CeilSurface - WorldZ; // + when below ceiling + + const float VoidField = FMath::Min(DistAboveFloor, DistBelowCeil); + + float Density = -VoidField; // Negative = air (inside void), positive = solid + + //========================================================================= + // STEP 4: COLUMNS (hash-based, world-space grid) + //========================================================================= + // Unlike TunnelNetwork columns (anchored to room centers), slab columns + // are placed on a regular world-space hash grid. They are infinite-height + // cylinders — the void field already defines where solid/air is, so the + // column SDF just adds density everywhere along its XY position. + // The column is only visible where the void field carved air around it. + if (Params.ColumnDensity > 0.0f && Params.ColumnSpacing > 0.0f) + { + const float Spacing = Params.ColumnSpacing; + + // Which cell are we in? + const int32 ColCX = FMath::FloorToInt(WorldX / Spacing); + const int32 ColCY = FMath::FloorToInt(WorldY / Spacing); + + float ColumnSDF = FLT_MAX; + + // Check 3x3 neighborhood so we never miss a column in an adjacent cell. + for (int32 DY = -1; DY <= 1; DY++) + { + for (int32 DX = -1; DX <= 1; DX++) + { + int32 NCX = ColCX + DX; + int32 NCY = ColCY + DY; + + // Deterministic: same seed → same column pattern every session. + // XOR with a prime salt so columns don't correlate with room placement. + uint32 H = VoxelHash::Cell(NCX, NCY, (uint32)Seed ^ 0xC01C01u); + + // ColumnDensity is the probability this cell has a column. + if (VoxelHash::ToFloat01(H) > Params.ColumnDensity) continue; + + // Jitter the column center within the cell (15%-85% of cell extent) + // to avoid a perfectly regular grid pattern. + float JX = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x12345678u)); + float JY = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x9ABCDEF0u)); + float ColX = (NCX + 0.15f + JX * 0.7f) * Spacing; + float ColY = (NCY + 0.15f + JY * 0.7f) * Spacing; + + // Column radius: hash-derived within configured range. + float ColRadius = FMath::Lerp(Params.ColumnMinRadius, Params.ColumnMaxRadius, + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0xBEEFu))); + + // 2D cylinder SDF (infinite height — void field handles top/bottom). + float DX2D = WorldX - ColX; + float DY2D = WorldY - ColY; + float CylSDF = FMath::Sqrt(DX2D * DX2D + DY2D * DY2D) - ColRadius; + ColumnSDF = FMath::Min(ColumnSDF, CylSDF); + } + } + + // Smoothstep blend zone around the column edge (avoids hard MC aliasing). + const float ColBlend = 2.0f; + if (ColumnSDF < ColBlend && ColumnSDF < FLT_MAX) + { + float Fill = FMath::Clamp((ColBlend - ColumnSDF) / (ColBlend * 2.0f), 0.0f, 1.0f); + Fill = SmoothStep01(Fill); // Smoothstep + Density += Fill * Params.BaseDensity * 1.5f; + } + } + + //========================================================================= + // STEP 5: BOUNDARY SEAL + STEP 6: PASSAGES + //========================================================================= + // Même logique que TunnelNetwork — factorisée dans les helpers ci-dessus. + ApplyOriginSpine(Density, WorldX, WorldY, WorldZ, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius); + + ApplyBoundarySeal(Density, WorldZ, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Params.BoundarySealThickness, Params.BaseDensity); + + if (StrateManager) + { + const float ModSDF = StrateManager->EvaluateModifierSDF(WorldX, WorldY, WorldZ); + ApplyPassageCarving(Density, ModSDF, Params.BaseDensity, Params.BoundarySealThickness); + } + + // Convention MC: négatif = solide. + return -Density; +} + +//============================================================================= +// MAZE GENERATOR (ECaveGeneratorType::Maze) +//============================================================================= +// Solid rock carved by a deterministic 3D lattice of corridors. Each lattice node +// sits at a cell center; an edge to its +X/+Y/+Z neighbour is "open" when a hash of +// (lower node, axis) passes BranchProbability (Verticality for Z edges). The corridor +// is a thin capsule. Edge identity is the lower node + axis, so two adjacent chunks +// always agree — no cache needed, evaluated over the few nearby nodes per voxel. + +float UVoxelGenerator::GetMazeDensity(float WorldX, float WorldY, float WorldZ, + const FMazeGenerationParams& Params) const +{ + const float StrateHeight = Params.StrateTopWorldZ - Params.StrateBottomWorldZ; + if (StrateHeight <= 0.0f) return 1.0f; + + const float CS = FMath::Max(Params.CellSize, 1.0f); + const FVector Pos(WorldX, WorldY, WorldZ); + const uint32 S = (uint32)Seed ^ 0x4D617A65u; // 'Maze' + + float Density = Params.BaseDensity; // start solid + + const int32 CX = FMath::FloorToInt(WorldX / CS); + const int32 CY = FMath::FloorToInt(WorldY / CS); + const int32 CZ = FMath::FloorToInt(WorldZ / CS); + + auto NodeCenter = [CS](int32 X, int32 Y, int32 Z) + { + return FVector((X + 0.5f) * CS, (Y + 0.5f) * CS, (Z + 0.5f) * CS); + }; + // Deterministic hash of a 3D lattice edge, keyed on its lower node + axis salt. + auto EdgeOpen = [S](int32 X, int32 Y, int32 Z, uint32 AxisSalt, float Threshold) -> bool + { + uint32 H = VoxelHash::Cell(X, Y, S ^ AxisSalt); + H ^= VoxelHash::Mix((uint32)(Z * 73856093) ^ AxisSalt); + return VoxelHash::ToFloat01(VoxelHash::Mix(H)) < Threshold; + }; + + const float R = FMath::Max(Params.CorridorRadius, 0.5f); + float MazeSDF = FLT_MAX; + + // Nodes in {-1,0} per axis cover every edge that can reach this voxel's cell. + for (int32 dz = -1; dz <= 0; dz++) + for (int32 dy = -1; dy <= 0; dy++) + for (int32 dx = -1; dx <= 0; dx++) + { + const int32 nx = CX + dx, ny = CY + dy, nz = CZ + dz; + const FVector A = NodeCenter(nx, ny, nz); + + if (EdgeOpen(nx, ny, nz, 0xA1u, Params.BranchProbability)) + MazeSDF = FMath::Min(MazeSDF, VoxelSDF::Capsule(Pos, A, NodeCenter(nx + 1, ny, nz), R)); + if (EdgeOpen(nx, ny, nz, 0xB2u, Params.BranchProbability)) + MazeSDF = FMath::Min(MazeSDF, VoxelSDF::Capsule(Pos, A, NodeCenter(nx, ny + 1, nz), R)); + if (EdgeOpen(nx, ny, nz, 0xC3u, Params.Verticality)) + MazeSDF = FMath::Min(MazeSDF, VoxelSDF::Capsule(Pos, A, NodeCenter(nx, ny, nz + 1), R)); + } + + // Wall roughness: perturb the corridor surface. + if (Params.SurfaceRoughness > 0.0f && MazeSDF < R + Params.SurfaceRoughness + 2.0f) + { + MazeSDF += FractalNoise3D(FVector(WorldX * 0.12f, WorldY * 0.12f, WorldZ * 0.12f), 3) + * VOXEL_NOISE_SCALE * Params.SurfaceRoughness; + } + + // Carve air where inside a corridor. + const float Blend = 2.0f; + if (MazeSDF < Blend) + { + float Carve = FMath::Clamp((Blend - MazeSDF) / (Blend * 2.0f), 0.0f, 1.0f); + Carve = SmoothStep01(Carve); + Density -= Carve * Params.BaseDensity * 2.0f; + } + + ApplyOriginSpine(Density, WorldX, WorldY, WorldZ, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius); + + ApplyBoundarySeal(Density, WorldZ, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Params.BoundarySealThickness, Params.BaseDensity); + + if (StrateManager) + { + const float ModSDF = StrateManager->EvaluateModifierSDF(WorldX, WorldY, WorldZ); + ApplyPassageCarving(Density, ModSDF, Params.BaseDensity, Params.BoundarySealThickness); + } + + return -Density; +} + +//============================================================================= +// SURFACE-WORLD GENERATOR (ECaveGeneratorType::SurfaceWorld) +//============================================================================= +// A heightfield terrain (fBM continents + ridged mountains + fine detail) under a +// high solid "sky cap" ceiling, with a flattened beach band around the water line. +// Open air fills the gap between ground and ceiling; water is a render-side overlay. + +float UVoxelGenerator::GetSurfaceDensity(float WorldX, float WorldY, float WorldZ, + const FSurfaceGenerationParams& Params) const +{ + const float H = Params.StrateTopWorldZ - Params.StrateBottomWorldZ; + if (H <= 0.0f) return 1.0f; + + const float SeedF = (float)Seed; + const float BottomZ = Params.StrateBottomWorldZ; + + // --- Heightfield (a function of XY only — Z is a fixed seed slice) --- + const float GroundBase = BottomZ + H * Params.BaseGroundRelative; + + float Cont = FractalNoise3D(FVector( + WorldX * Params.ContinentFrequency + SeedF * 3.1f, + WorldY * Params.ContinentFrequency + SeedF * 5.7f, + SeedF * 0.7f), 4); // [-1,1] + + float Detail = FractalNoise3D(FVector( + WorldX * Params.DetailFrequency + 11.0f, + WorldY * Params.DetailFrequency + 22.0f, + SeedF * 1.3f), 3); // [-1,1] + + float Mountain = 0.0f; + if (Params.MountainStrength > 0.0f) + { + float Ridge = RidgedNoise3D(FVector( + WorldX * Params.MountainFrequency + 99.0f, + WorldY * Params.MountainFrequency + 77.0f, + SeedF * 0.9f), 4); // [-1,1] + Ridge = Ridge * 0.5f + 0.5f; // [0,1] peaks + Mountain = Ridge * Params.MountainStrength; + } + + float Terrain = GroundBase + + Cont * Params.ElevationRange * 0.5f + + Mountain * Params.ElevationRange + + Detail * Params.SurfaceRoughness; + + // Beach: flatten terrain toward the water line within BeachWidth. + const float WaterZ = BottomZ + H * Params.WaterLevelRelative; + if (Params.WaterLevelRelative > 0.0f && Params.BeachWidth > 0.0f) + { + const float DAbs = FMath::Abs(Terrain - WaterZ); + if (DAbs < Params.BeachWidth) + { + float T = SmoothStep01(DAbs / Params.BeachWidth); + Terrain = FMath::Lerp(WaterZ, Terrain, T); + } + } + + // Solid below the terrain surface (positive = solid). + float Density = Terrain - WorldZ; + + // Sky cap: solid ceiling near the top of the strate, bumpy downward. + const float CeilZ = BottomZ + H * Params.CeilingRelative; + float CeilNoise = 0.0f; + if (Params.CeilingRoughness > 0.0f) + { + CeilNoise = FMath::Abs(FractalNoise3D(FVector( + WorldX * 0.04f + 5.0f, WorldY * 0.04f + 6.0f, SeedF * 2.1f), 3)) + * VOXEL_NOISE_SCALE * Params.CeilingRoughness; + } + const float CeilSurface = CeilZ - CeilNoise; + Density = FMath::Max(Density, WorldZ - CeilSurface); // add solid above the ceiling + + ApplyOriginSpine(Density, WorldX, WorldY, WorldZ, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius); + + ApplyBoundarySeal(Density, WorldZ, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Params.BoundarySealThickness, Params.BaseDensity); + + if (StrateManager) + { + const float ModSDF = StrateManager->EvaluateModifierSDF(WorldX, WorldY, WorldZ); + ApplyPassageCarving(Density, ModSDF, Params.BaseDensity, Params.BoundarySealThickness); + } + + return -Density; +} + +//============================================================================= +// VERTICAL-SHAFT GENERATOR (ECaveGeneratorType::VerticalShafts) +//============================================================================= +// Solid rock carved by hash-placed full-height vertical shafts (cylinders) with +// occasional horizontal connector tunnels between neighbouring shafts and partial +// ledges inside them. Emphasises climbing and falling. + +float UVoxelGenerator::GetVerticalShaftDensity(float WorldX, float WorldY, float WorldZ, + const FVerticalShaftParams& Params) const +{ + const float StrateHeight = Params.StrateTopWorldZ - Params.StrateBottomWorldZ; + if (StrateHeight <= 0.0f) return 1.0f; + + const float Spacing = FMath::Max(Params.ShaftSpacing, 1.0f); + const FVector Pos(WorldX, WorldY, WorldZ); + const uint32 S = (uint32)Seed ^ 0x53686674u; // 'Shft' + + float Density = Params.BaseDensity; // start solid + + const int32 CX = FMath::FloorToInt(WorldX / Spacing); + const int32 CY = FMath::FloorToInt(WorldY / Spacing); + + // Collect shafts in the 3x3 neighbourhood (XY). + struct FLocalShaft { float X, Y, R; }; + TArray> Shafts; + + for (int32 dy = -1; dy <= 1; dy++) + for (int32 dx = -1; dx <= 1; dx++) + { + const int32 nx = CX + dx, ny = CY + dy; + const uint32 Hh = VoxelHash::Cell(nx, ny, S); + if (VoxelHash::ToFloat01(Hh) > Params.ShaftDensity) continue; + + const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x12345678u)); + const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x9ABCDEF0u)); + FLocalShaft Sh; + Sh.X = (nx + 0.15f + JX * 0.7f) * Spacing; + Sh.Y = (ny + 0.15f + JY * 0.7f) * Spacing; + Sh.R = FMath::Lerp(Params.ShaftMinRadius, Params.ShaftMaxRadius, + VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0xBEEFu))); + Shafts.Add(Sh); + } + + float CaveSDF = FLT_MAX; + + // Vertical shafts as infinite cylinders (boundary seal handles the ends). + for (const FLocalShaft& Sh : Shafts) + { + const float DX = WorldX - Sh.X; + const float DY = WorldY - Sh.Y; + CaveSDF = FMath::Min(CaveSDF, FMath::Sqrt(DX * DX + DY * DY) - Sh.R); + } + + // Horizontal connectors between nearby shaft pairs (hash-gated). + if (Params.CrossConnectChance > 0.0f && Shafts.Num() >= 2) + { + const float BottomZ = Params.StrateBottomWorldZ + Params.BoundarySealThickness; + const float TopZ = Params.StrateTopWorldZ - Params.BoundarySealThickness; + for (int32 i = 0; i < Shafts.Num(); i++) + for (int32 j = i + 1; j < Shafts.Num(); j++) + { + const FLocalShaft& A = Shafts[i]; + const FLocalShaft& B = Shafts[j]; + const float DSq = FMath::Square(A.X - B.X) + FMath::Square(A.Y - B.Y); + if (DSq > FMath::Square(Spacing * 1.6f)) continue; // only neighbours + + // Symmetric pair hash from quantised endpoints. + const uint32 PH = VoxelHash::Pair( + FMath::RoundToInt(A.X), FMath::RoundToInt(A.Y), + FMath::RoundToInt(B.X), FMath::RoundToInt(B.Y), S ^ 0xC04Eu); + if (VoxelHash::ToFloat01(PH) >= Params.CrossConnectChance) continue; + + const float Zc = FMath::Lerp(BottomZ, TopZ, VoxelHash::ToFloat01(VoxelHash::Mix(PH))); + CaveSDF = FMath::Min(CaveSDF, VoxelSDF::Capsule(Pos, + FVector(A.X, A.Y, Zc), FVector(B.X, B.Y, Zc), Params.ConnectorRadius)); + } + } + + // Wall roughness. + if (Params.SurfaceRoughness > 0.0f && CaveSDF < Params.SurfaceRoughness + 4.0f) + { + CaveSDF += FractalNoise3D(FVector(WorldX * 0.1f, WorldY * 0.1f, WorldZ * 0.1f), 3) + * VOXEL_NOISE_SCALE * Params.SurfaceRoughness; + } + + // Carve air inside shafts/connectors. + const float Blend = 2.0f; + if (CaveSDF < Blend) + { + float Carve = FMath::Clamp((Blend - CaveSDF) / (Blend * 2.0f), 0.0f, 1.0f); + Carve = SmoothStep01(Carve); + Density -= Carve * Params.BaseDensity * 2.0f; + } + + // Partial ledges inside shafts: thin shelves on one side at LedgeSpacing intervals, + // leaving the opposite side open so the shaft stays traversable. + if (Params.LedgeSpacing > 0.0f && Params.LedgeDepth > 0.0f && CaveSDF < 0.0f && Shafts.Num() > 0) + { + const float Phase = FMath::Frac((WorldZ - Params.StrateBottomWorldZ) / Params.LedgeSpacing); + const float BandT = FMath::Min(Phase, 1.0f - Phase) * Params.LedgeSpacing; // dist to nearest band + if (BandT < Params.LedgeDepth) + { + // Nearest shaft center → only shelf the +X/+Y half so a climb path remains. + const FLocalShaft* Near = nullptr; float BestSq = FLT_MAX; + for (const FLocalShaft& Sh : Shafts) + { + const float D2 = FMath::Square(WorldX - Sh.X) + FMath::Square(WorldY - Sh.Y); + if (D2 < BestSq) { BestSq = D2; Near = &Sh; } + } + if (Near && (WorldX - Near->X) + (WorldY - Near->Y) > 0.0f) + { + float Shelf = 1.0f - SmoothStep01(BandT / Params.LedgeDepth); + Density = FMath::Max(Density, Shelf * Params.BaseDensity); + } + } + } + + ApplyOriginSpine(Density, WorldX, WorldY, WorldZ, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius); + + ApplyBoundarySeal(Density, WorldZ, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Params.BoundarySealThickness, Params.BaseDensity); + + if (StrateManager) + { + const float ModSDF = StrateManager->EvaluateModifierSDF(WorldX, WorldY, WorldZ); + ApplyPassageCarving(Density, ModSDF, Params.BaseDensity, Params.BoundarySealThickness); + } + + return -Density; +} + +//============================================================================= +// FLOATING-ISLAND GENERATOR (ECaveGeneratorType::FloatingIslands) +//============================================================================= +// A large open void with hash-placed island blobs (flattened-top ellipsoids, rough +// undersides) floating at jittered heights. Top/bottom sealed so the void encloses. + +float UVoxelGenerator::GetFloatingIslandDensity(float WorldX, float WorldY, float WorldZ, + const FFloatingIslandParams& Params) const +{ + const float H = Params.StrateTopWorldZ - Params.StrateBottomWorldZ; + if (H <= 0.0f) return 1.0f; + + const float Spacing = FMath::Max(Params.IslandSpacing, 1.0f); + const uint32 S = (uint32)Seed ^ 0x49736C64u; // 'Isld' + const float BlendK = FMath::Max(Params.SDFBlendRadius, 0.01f); + + float Density = -Params.BaseDensity; // start as open air (void) + + const int32 CX = FMath::FloorToInt(WorldX / Spacing); + const int32 CY = FMath::FloorToInt(WorldY / Spacing); + + const float MidZ = (Params.StrateTopWorldZ + Params.StrateBottomWorldZ) * 0.5f; + + float IslandSDF = FLT_MAX; + + // IRREGULAR OUTLINE: domain-warp the horizontal query so island edges are lobed and + // organic instead of perfect circles. Computed once per voxel and shared by all nearby + // islands (each samples a different part of the field → distinct silhouettes). + const float WarpAmp = (Params.IslandMinRadius + Params.IslandMaxRadius) * 0.5f * 0.35f; + const float WX = WorldX + FractalNoise3D(FVector(WorldX * 0.04f + (float)S * 0.0007f, WorldY * 0.04f, WorldZ * 0.012f), 3) + * VOXEL_NOISE_SCALE * WarpAmp; + const float WY = WorldY + FractalNoise3D(FVector(WorldX * 0.04f + 31.0f, WorldY * 0.04f + 7.0f, WorldZ * 0.012f), 3) + * VOXEL_NOISE_SCALE * WarpAmp; + + for (int32 dy = -1; dy <= 1; dy++) + for (int32 dx = -1; dx <= 1; dx++) + { + const int32 nx = CX + dx, ny = CY + dy; + const uint32 Hh = VoxelHash::Cell(nx, ny, S); + if (VoxelHash::ToFloat01(Hh) > Params.IslandDensity) continue; + + const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x12345678u)); + const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x9ABCDEF0u)); + const float IslX = (nx + 0.15f + JX * 0.7f) * Spacing; + const float IslY = (ny + 0.15f + JY * 0.7f) * Spacing; + + const float Rxy = FMath::Lerp(Params.IslandMinRadius, Params.IslandMaxRadius, + VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x5A5Au))); + + // ASYMMETRIC ISLAND PROFILE: a fairly flat land slab on TOP, and an underside that + // tapers DOWN to a rough point (the hanging "roots"). ThicknessRatio scales how deep + // the underside hangs. This is what reads as a floating island vs. a sphere. + const float TopHalf = Rxy * 0.20f; // land slab above centre + const float UnderDepth = Rxy * FMath::Max(Params.ThicknessRatio, 0.25f); // tapering underside + + const float SpreadZ = FMath::Max(H * 0.5f - FMath::Max(TopHalf, UnderDepth) - Params.BoundarySealThickness, 0.0f) + * Params.VerticalJitter; + const float Cz = MidZ + VoxelHash::ToFloatSigned(VoxelHash::Mix(Hh ^ 0xB17Du)) * SpreadZ; + const float TopZ = Cz + TopHalf; + const float BotZ = Cz - UnderDepth; + + // Horizontal distance in the WARPED (lobed) frame so the outline isn't a circle. + const float Dxw = WX - IslX, Dyw = WY - IslY; + const float DistXY = FMath::Sqrt(Dxw * Dxw + Dyw * Dyw); + + // Radius envelope by height: full width across the top, narrowing to a point at the + // bottom tip (SmoothStep taper). Per-island hash gives slightly different taper sharpness. + const float TaperEnd = FMath::Lerp(0.45f, 0.7f, VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x7A1Eu))); + const float Hgt = FMath::Clamp((WorldZ - BotZ) / FMath::Max(TopZ - BotZ, 1.0f), 0.0f, 1.0f); + const float Taper = SmoothStep01(FMath::Clamp(Hgt / TaperEnd, 0.0f, 1.0f)); + const float Env = Rxy * Taper; + + // Top surface: flat by default; dome the edges down when TopFlatten < 1. + float TopSurf = TopZ; + if (Params.TopFlatten < 1.0f) + { + const float Edge = FMath::Clamp(DistXY / FMath::Max(Rxy, 1.0f), 0.0f, 1.0f); + TopSurf = TopZ - (1.0f - Params.TopFlatten) * TopHalf * 2.0f * Edge * Edge; + } + + // Pseudo-SDF: outside if beyond the radial envelope OR above the top surface. + const float Sdf = FMath::Max(DistXY - Env, WorldZ - TopSurf); + + IslandSDF = VoxelSDF::SmoothMin(IslandSDF, Sdf, BlendK); + } + + // Craggy shells. + if (Params.SurfaceRoughness > 0.0f && IslandSDF < Params.SurfaceRoughness + BlendK + 2.0f) + { + IslandSDF += FractalNoise3D(FVector(WorldX * 0.08f, WorldY * 0.08f, WorldZ * 0.08f), 4) + * VOXEL_NOISE_SCALE * Params.SurfaceRoughness; + } + + // Fill solid inside islands. + const float Blend = BlendK; + if (IslandSDF < Blend) + { + float Fill = FMath::Clamp((Blend - IslandSDF) / (Blend * 2.0f), 0.0f, 1.0f); + Fill = SmoothStep01(Fill); + Density += Fill * Params.BaseDensity * 2.0f; + } + + ApplyOriginSpine(Density, WorldX, WorldY, WorldZ, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius); + + ApplyBoundarySeal(Density, WorldZ, + Params.StrateTopWorldZ, Params.StrateBottomWorldZ, + Params.BoundarySealThickness, Params.BaseDensity); + + if (StrateManager) + { + const float ModSDF = StrateManager->EvaluateModifierSDF(WorldX, WorldY, WorldZ); + ApplyPassageCarving(Density, ModSDF, Params.BaseDensity, Params.BoundarySealThickness); + } + + return -Density; +} diff --git a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp new file mode 100644 index 0000000..07d8797 --- /dev/null +++ b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp @@ -0,0 +1,252 @@ +// VoxelMarchingCubesMesher.cpp +// Implémentation du marching cubes density-only. + +#include "VoxelMarchingCubesMesher.h" +#include "MarchingCubesTables.h" + +//============================================================================= +// DENSITY SAMPLING +//============================================================================= + +float UVoxelMarchingCubesMesher::GetDensity(const FVoxelChunk& Chunk, int32 X, int32 Y, int32 Z) const +{ + // On n'utilise plus de stockage de blocs — densité demandée directement + // au générateur, qui produit la valeur pour TOUTE coordonnée monde. + // Si le générateur manque, le chunk est considéré tout-air (IsoLevel par défaut = 0). + if (!Generator) return 0.0f; + + const float WorldX = Chunk.ChunkCoord.X * CHUNK_SIZE + X; + const float WorldY = Chunk.ChunkCoord.Y * CHUNK_SIZE + Y; + const float WorldZ = Chunk.ChunkCoord.Z * CHUNK_SIZE + Z; + return Generator->GetDensityAt(WorldX, WorldY, WorldZ); +} + +//============================================================================= +// EDGE INTERPOLATION +//============================================================================= + +FVector UVoxelMarchingCubesMesher::InterpolateEdge( + const FVector& P1, const FVector& P2, + float D1, float D2) const +{ + // Densités quasi-égales → on prend le milieu (évite division par ~0). + if (FMath::Abs(D2 - D1) < KINDA_SMALL_NUMBER) + { + return (P1 + P2) * 0.5f; + } + + // t = 0 → surface en P1; t = 1 → surface en P2. + float T = (IsoLevel - D1) / (D2 - D1); + T = FMath::Clamp(T, 0.0f, 1.0f); + return P1 + T * (P2 - P1); +} + +//============================================================================= +// NORMAL (gradient central de densité) +//============================================================================= + +FVector UVoxelMarchingCubesMesher::ComputeGradientNormal(float WorldX, float WorldY, float WorldZ) const +{ + // Convention: densité négative = solide, positive = air. + // Le gradient pointe solide→air = vers l'extérieur de la surface. + // Pas de négation à faire. + const float Dx = Generator->GetDensityAt(WorldX + GradientOffset, WorldY, WorldZ) + - Generator->GetDensityAt(WorldX - GradientOffset, WorldY, WorldZ); + const float Dy = Generator->GetDensityAt(WorldX, WorldY + GradientOffset, WorldZ) + - Generator->GetDensityAt(WorldX, WorldY - GradientOffset, WorldZ); + const float Dz = Generator->GetDensityAt(WorldX, WorldY, WorldZ + GradientOffset) + - Generator->GetDensityAt(WorldX, WorldY, WorldZ - GradientOffset); + + FVector Normal(Dx, Dy, Dz); + Normal.Normalize(); + + // Fallback si le gradient est dégénéré (zone plate). + if (Normal.IsNearlyZero()) + { + Normal = FVector(0.0f, 0.0f, 1.0f); + } + return Normal; +} + +//============================================================================= +// MAIN ALGORITHM +//============================================================================= + +FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(const FVoxelChunk& Chunk, int32 Step) +{ + FVoxelMeshData MeshData; + + // Step valide = puissance de 2 dans [1, 4] + Step = FMath::Clamp(Step, 1, 4); + + const FVector ChunkWorldPos = Chunk.GetWorldPosition(); + + //========================================================================= + // VERTEX DEDUPLICATION MAP + //========================================================================= + // Clé = position quantifiée au 0.01 unité (FIntVector). + // Valeur = index dans MeshData.Vertices. + // Les vertices partagés permettent des normales lisses et réduisent le count ~3x. + TMap VertexMap; + + auto GetOrCreateVertex = [&](const FVector& WorldPos) -> int32 + { + const FIntVector Key( + FMath::RoundToInt(WorldPos.X * 100.0f), + FMath::RoundToInt(WorldPos.Y * 100.0f), + FMath::RoundToInt(WorldPos.Z * 100.0f) + ); + + if (int32* Existing = VertexMap.Find(Key)) + { + return *Existing; + } + + const int32 NewIndex = MeshData.Vertices.Num(); + VertexMap.Add(Key, NewIndex); + + MeshData.Vertices.Add(WorldPos); + + // Normale par gradient de densité (shading lisse). + if (Generator) + { + const float VoxelX = WorldPos.X / VOXEL_SIZE; + const float VoxelY = WorldPos.Y / VOXEL_SIZE; + const float VoxelZ = WorldPos.Z / VOXEL_SIZE; + MeshData.Normals.Add(ComputeGradientNormal(VoxelX, VoxelY, VoxelZ)); + } + else + { + MeshData.Normals.Add(FVector(0.0f, 0.0f, 1.0f)); + } + + // UVs planaires — le triplanar mapping se fait dans le matériau. + MeshData.UVs.Add(FVector2D(WorldPos.X / VOXEL_SIZE, WorldPos.Y / VOXEL_SIZE)); + + return NewIndex; + }; + + //========================================================================= + // CORNER + EDGE TABLES + //========================================================================= + // 4-------5 + // /| /| + // / | / | + // 7-------6 | + // | 0----|--1 + // | / | / + // |/ |/ + // 3-------2 + static const FIntVector CornerOffsets[8] = { + {0, 0, 0}, {1, 0, 0}, {1, 1, 0}, {0, 1, 0}, + {0, 0, 1}, {1, 0, 1}, {1, 1, 1}, {0, 1, 1}, + }; + + static const int32 EdgeCorners[12][2] = { + {0, 1}, {1, 2}, {2, 3}, {3, 0}, // Bas (0-3) + {4, 5}, {5, 6}, {6, 7}, {7, 4}, // Haut (4-7) + {0, 4}, {1, 5}, {2, 6}, {3, 7}, // Verticales (8-11) + }; + + //========================================================================= + // PRÉ-CALCUL DE LA GRILLE DE DENSITÉ + //========================================================================= + // Les cellules adjacentes partagent leurs coins : en échantillonnant par + // cellule (8 coins) on appelle GetDensityAt ~8× de trop pour chaque point. + // On échantillonne donc chaque point de grille UNE SEULE FOIS dans un tableau + // plat, puis le balayage des cellules y lit ses coins. GetDensityAt est une + // fonction pure de la coordonnée monde, donc le maillage est identique au bit + // près — c'est juste ~7× moins d'appels au LOD0 (33³ au lieu de 32³×8). + // + // CellsPerAxis = nombre de cellules par axe ; GridDim = points de grille (+1). + // Le point de grille (gx,gy,gz) correspond au voxel monde (gx,gy,gz)*Step. + // Ordre de remplissage z→y→x : garde le cache SDF (search-box) bien chaud. + const int32 CellsPerAxis = CHUNK_SIZE / Step; + const int32 GridDim = CellsPerAxis + 1; + + TArray DensityGrid; + DensityGrid.SetNumUninitialized(GridDim * GridDim * GridDim); + for (int32 gz = 0; gz < GridDim; gz++) + { + for (int32 gy = 0; gy < GridDim; gy++) + { + for (int32 gx = 0; gx < GridDim; gx++) + { + DensityGrid[(gz * GridDim + gy) * GridDim + gx] = + GetDensity(Chunk, gx * Step, gy * Step, gz * Step); + } + } + } + + //========================================================================= + // ITÉRATION SUR LES CELLULES + //========================================================================= + // On itère sur les CELLULES (indices de grille), pas sur les voxels monde. + // Coin i de la cellule = point de grille (cx+ox, cy+oy, cz+oz) ; coord voxel + // monde = ce point × Step. LOD0 Step=1 → full res ; LOD1 Step=2 → ~4× moins. + for (int32 cz = 0; cz < CellsPerAxis; cz++) + { + for (int32 cy = 0; cy < CellsPerAxis; cy++) + { + for (int32 cx = 0; cx < CellsPerAxis; cx++) + { + // Densités + positions aux 8 coins (lues dans la grille pré-calculée) + float Densities[8]; + FVector Positions[8]; + + for (int32 i = 0; i < 8; i++) + { + const int32 GX = cx + CornerOffsets[i].X; + const int32 GY = cy + CornerOffsets[i].Y; + const int32 GZ = cz + CornerOffsets[i].Z; + + Densities[i] = DensityGrid[(GZ * GridDim + GY) * GridDim + GX]; + Positions[i] = ChunkWorldPos + + FVector(GX * Step, GY * Step, GZ * Step) * VOXEL_SIZE; + } + + // Index de cas MC (8 bits, un par coin) + int32 CaseIndex = 0; + for (int32 i = 0; i < 8; i++) + { + if (Densities[i] >= IsoLevel) + { + CaseIndex |= (1 << i); + } + } + + if (MCEdgeTable[CaseIndex] == 0) continue; // Pas de surface ici + + // Interpolation des positions sur les arêtes traversées + FVector EdgeVertices[12]; + for (int32 i = 0; i < 12; i++) + { + if (MCEdgeTable[CaseIndex] & (1 << i)) + { + const int32 A = EdgeCorners[i][0]; + const int32 B = EdgeCorners[i][1]; + EdgeVertices[i] = InterpolateEdge( + Positions[A], Positions[B], + Densities[A], Densities[B] + ); + } + } + + // Génère les triangles avec vertices dédupliqués. + // Ordre 0, 2, 1 (pas 0, 1, 2) pour le winding attendu par RealtimeMesh. + for (int32 i = 0; MCTriTable[CaseIndex][i] != -1; i += 3) + { + const int32 Idx0 = GetOrCreateVertex(EdgeVertices[MCTriTable[CaseIndex][i]]); + const int32 Idx1 = GetOrCreateVertex(EdgeVertices[MCTriTable[CaseIndex][i + 1]]); + const int32 Idx2 = GetOrCreateVertex(EdgeVertices[MCTriTable[CaseIndex][i + 2]]); + + MeshData.Triangles.Add(Idx0); + MeshData.Triangles.Add(Idx2); + MeshData.Triangles.Add(Idx1); + } + } + } + } + + return MeshData; +} diff --git a/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Source/VoxelForge/Private/VoxelStrateManager.cpp new file mode 100644 index 0000000..d165071 --- /dev/null +++ b/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -0,0 +1,852 @@ +// VoxelStrateManager.cpp +// Runtime strate layout generation and queries. + +#include "VoxelStrateManager.h" +#include "VoxelSettings.h" +#include "VoxelTypes.h" // For CHUNK_SIZE, VOXEL_SIZE, WorldToChunkCoord +#include "VoxelCaveMorphology.h" // For VoxelSDF and VoxelHash +#include "VoxelTerrainOpDefinition.h" // For UVoxelTerrainOpDefinition::ApplyTo + +// Fractal Brownian Motion (layered Perlin) along a 1D parameter, ~[-1,1]. +// Independent octaves at increasing frequency / decreasing amplitude give an organic, +// non-repeating wander — the key to a worm that SQUIRMS instead of zig-zagging (1D) or +// orbiting (single-octave 2-channel = a spiral). Each axis samples this with its own seed. +static float PassageFBM(float X, float Seed) +{ + float Total = 0.0f, Amp = 1.0f, Freq = 1.0f, MaxV = 0.0f; + for (int32 O = 0; O < 4; ++O) + { + Total += FMath::PerlinNoise3D(FVector(X * Freq + Seed, Seed * 1.7f + O * 13.0f, O * 5.0f)) * Amp; + MaxV += Amp; + Amp *= 0.5f; + Freq *= 2.0f; + } + return (MaxV > 0.0f) ? (Total / MaxV) : 0.0f; +} + +void UVoxelStrateManager::Initialize(UVoxelSettings* Settings, int32 WorldSeed) +{ + if (!Settings) + { + UE_LOG(LogTemp, Error, TEXT("[StrateManager] No settings provided!")); + return; + } + + StrateLayout.Empty(); + + const int32 TotalStrates = Settings->TotalStrates; + + //========================================================================= + // STEP 1: Build shuffled pool (seed-based randomization) + //========================================================================= + // Copy the pool and shuffle it deterministically using the world seed. + // Fixed strates are excluded from the shuffle — they always use their + // assigned definition regardless of seed. + + TArray ShuffledPool; + for (const TSoftObjectPtr& SoftPtr : Settings->StratePool) + { + // Load the asset (synchronous for now — could be async later) + UVoxelStrateDefinition* Def = SoftPtr.LoadSynchronous(); + if (Def) + { + ShuffledPool.Add(Def); + } + } + + // Seed-based shuffle using Fisher-Yates + // FRandomStream gives us deterministic random numbers from a seed + FRandomStream Rng(WorldSeed); + for (int32 i = ShuffledPool.Num() - 1; i > 0; i--) + { + int32 j = Rng.RandRange(0, i); + ShuffledPool.Swap(i, j); + } + + //========================================================================= + // STEP 2: Assign definitions to each strate slot + //========================================================================= + // Walk through strate indices 0..TotalStrates-1. + // Fixed strates use their pinned definition. + // Random strates cycle through the shuffled pool. + + int32 PoolCursor = 0; // Current position in the shuffled pool + + // Pre-load fixed strate definitions + TMap LoadedFixed; + for (auto& Pair : Settings->FixedStrates) + { + UVoxelStrateDefinition* Def = Pair.Value.LoadSynchronous(); + if (Def) + { + LoadedFixed.Add(Pair.Key, Def); + } + } + + // Current Z position (in chunks). Starts at 0 and goes downward (negative). + int32 CurrentTopZ = 0; + + for (int32 i = 0; i < TotalStrates; i++) + { + FStrateSlot Slot; + Slot.StrateIndex = i; + + // Pick definition: fixed or from pool + UVoxelStrateDefinition** FixedDef = LoadedFixed.Find(i); + if (FixedDef && *FixedDef) + { + Slot.Definition = *FixedDef; + } + else if (ShuffledPool.Num() > 0) + { + // Cycle through the pool (wraps around if more strates than pool entries) + Slot.Definition = ShuffledPool[PoolCursor % ShuffledPool.Num()]; + PoolCursor++; + } + else + { + UE_LOG(LogTemp, Warning, TEXT("[StrateManager] No strate definitions available for slot %d!"), i); + continue; + } + + // Compute Z range from definition's height + Slot.HeightInChunks = Slot.Definition->StrateHeightInChunks; + Slot.TopChunkZ = CurrentTopZ; + Slot.BottomChunkZ = CurrentTopZ - (Slot.HeightInChunks - 1); + + // Move the cursor down for the next strate, leaving a solid-bedrock gap of + // InterStrateGapChunks chunks between this strate and the next. + CurrentTopZ = Slot.BottomChunkZ - 1 - FMath::Max(0, Settings->InterStrateGapChunks); + + StrateLayout.Add(Slot); + + UE_LOG(LogTemp, Log, TEXT("[StrateManager] Strate %d: '%s' | Z chunks [%d to %d] | %d chunks tall"), + i, + *Slot.Definition->StrateName.ToString(), + Slot.TopChunkZ, + Slot.BottomChunkZ, + Slot.HeightInChunks); + } + + CachedSeed = WorldSeed; + bOpenSurfaceEntry = Settings->bOpenSurfaceEntry; + OriginSpineRadius = Settings->OriginSpineRadius; + InterStrateGapChunks = FMath::Max(0, Settings->InterStrateGapChunks); + // Passage shape/count is per-strate now (UVoxelStrateDefinition::PassageConfig). + + //========================================================================= + // STEP 3: Load terrain operation assets + //========================================================================= + // Each strate definition references terrain ops as soft pointers. + // We load them synchronously here so they're available during generation. + // Without this, BuildParamsFromDefinition's Entry.Operation.Get() would + // return null if the assets haven't been loaded yet. + for (const FStrateSlot& Slot : StrateLayout) + { + if (!Slot.Definition) continue; + + for (const FStrateTerrainOpEntry& Entry : Slot.Definition->TerrainOperations) + { + if (!Entry.Operation.IsNull()) + { + Entry.Operation.LoadSynchronous(); + } + } + } + + UE_LOG(LogTemp, Log, TEXT("[StrateManager] Initialized %d strates (seed=%d)"), + StrateLayout.Num(), WorldSeed); + + // Generate passages between consecutive strates + GeneratePassages(); +} + +//============================================================================= +// PASSAGE GENERATION +//============================================================================= + +void UVoxelStrateManager::GeneratePassages() +{ + Passages.Empty(); + + if (StrateLayout.Num() < 1) return; + + // Deterministic RNG from world seed + FRandomStream Rng(CachedSeed ^ 0x50A55A6E); // XOR with "PASSAGE" hash + + //========================================================================= + // INTER-STRATE PASSAGES: tunnels connecting consecutive strates. + // Each passage is randomly assigned one of 5 types, which determines + // its shape, radius, and control point layout. + //========================================================================= + for (int32 i = 0; i < StrateLayout.Num() - 1; i++) + { + const FStrateSlot& Upper = StrateLayout[i]; + const FStrateSlot& Lower = StrateLayout[i + 1]; + + // This (upper) strate's PassageConfig controls the descent tunnels to the layer + // below. The (0,0) spine descent is separate (player-dug); these are the shortcuts. + const UVoxelStrateDefinition* UpperDef = Upper.Definition; + if (!UpperDef) continue; + const FStratePassageConfig& Cfg = UpperDef->PassageConfig; + + // Upper strate floor and lower strate ceiling (differ when there's a bedrock gap). + const float UpperBottomZ = (float)(Upper.BottomChunkZ) * CHUNK_SIZE; + const float LowerTopZ = (float)(Lower.TopChunkZ + 1) * CHUNK_SIZE; + const float UpperMax = (float)Upper.HeightInChunks * CHUNK_SIZE * 0.9f; + const float LowerMax = (float)Lower.HeightInChunks * CHUNK_SIZE * 0.9f; + + const float DistLo = FMath::Min(Cfg.DistanceMin, Cfg.DistanceMax); + const float DistHi = FMath::Max(Cfg.DistanceMin, Cfg.DistanceMax); + + const int32 Conns = FMath::Max(0, Cfg.Connections); + for (int32 c = 0; c < Conns; c++) + { + FVoxelPassage Passage; + Passage.UpperStrateIndex = i; + Passage.LowerStrateIndex = i + 1; + + // PLACEMENT: random angle, distance from the (0,0) spine within config range. + const float Angle = Rng.FRandRange(0.0f, 2.0f * PI); + const float Distance = Rng.FRandRange(DistLo, DistHi); + const float PX = FMath::Cos(Angle) * Distance; + const float PY = FMath::Sin(Angle) * Distance; + + // LENGTH: reach into each strate, capped to the interior. + const float UpperReach = FMath::Min(Rng.FRandRange(Cfg.ReachMin, Cfg.ReachMax), UpperMax); + const float LowerReach = FMath::Min(Rng.FRandRange(Cfg.ReachMin, Cfg.ReachMax), LowerMax); + const float TopZ = UpperBottomZ + UpperReach; + const float BottomZ = LowerTopZ - LowerReach; + + const int32 Segments = FMath::Clamp(Cfg.Segments, 1, 48); + Passage.ControlPoints.Reset(); + Passage.ControlRadii.Reset(); + Passage.ControlPoints.Reserve(Segments + 1); + Passage.ControlRadii.Reserve(Segments + 1); + + // WIDTH profile: mouth radius at the ends, mid radius in the centre (taper/bulge). + auto RadiusAt = [&](float t) { return FMath::Lerp(Cfg.MouthRadius, Cfg.MidRadius, FMath::Sin(t * PI)); }; + + // Per-passage shape seeds. + const float WormFreq = Rng.FRandRange(0.8f, 1.8f); // (vertical wobble only) + const float NSeedX = Rng.FRandRange(0.0f, 500.0f); + const float NSeedY = Rng.FRandRange(0.0f, 500.0f); + const float NSeedZ = Rng.FRandRange(0.0f, 500.0f); + const float PhaseA = Rng.FRandRange(0.0f, 2.0f * PI); + // Base fBM frequency for the worm's wander (octaves add finer detail on top). + const float BendFreq = Rng.FRandRange(1.5f, 2.5f); + + for (int32 s = 0; s <= Segments; s++) + { + const float T = (float)s / (float)Segments; + float Z = FMath::Lerp(TopZ, BottomZ, T); + const float Env = FMath::Sin(T * PI); // 0 at both ends → mouths stay anchored + float OX = 0.0f, OY = 0.0f; + + switch (Cfg.Style) + { + case EVoxelPassageStyle::Straight: + break; // pure vertical + + case EVoxelPassageStyle::Spiral: + { + const float Ang = PhaseA + T * Cfg.SpiralTurns * 2.0f * PI; + OX = FMath::Cos(Ang) * Cfg.SpiralRadius * Env; + OY = FMath::Sin(Ang) * Cfg.SpiralRadius * Env; + break; + } + + case EVoxelPassageStyle::Cascading: + { + // Switchback staircase: each tread offsets in a new deterministic direction. + const int32 Steps = FMath::Clamp(Cfg.CascadeSteps, 1, 16); + const int32 Idx = FMath::Min((int32)(T * Steps), Steps - 1); + const float SA = PhaseA + (float)Idx * 2.39996f; // golden-angle spread + OX = FMath::Cos(SA) * Cfg.CascadeLedge * Env; + OY = FMath::Sin(SA) * Cfg.CascadeLedge * Env; + break; + } + + case EVoxelPassageStyle::Worm: + default: + { + // SQUIRM: displace the descent independently on X and Y with multi-octave + // fBM (different seeds → uncorrelated). Independent fBM per axis is a true + // 2D organic wander — it curls and meanders "here and there" rather than + // oscillating along one line (zig-zag) or orbiting the axis (spiral). + // Flat-top envelope keeps full motion along the length but anchors the mouths. + const float WormEnv = FMath::Clamp(FMath::Sin(T * PI) * 3.0f, 0.0f, 1.0f); + OX = PassageFBM(T * BendFreq, NSeedX) * VOXEL_NOISE_SCALE * Cfg.Wander * WormEnv; + OY = PassageFBM(T * BendFreq, NSeedY + 53.0f) * VOXEL_NOISE_SCALE * Cfg.Wander * WormEnv; + break; + } + } + + // Vertical wobble (all styles): dips/rises along the descent, anchored at ends. + if (Cfg.VerticalWobble > 0.0f) + { + const float NZ = FMath::PerlinNoise3D(FVector(T * WormFreq * 1.3f + NSeedZ, NSeedZ * 0.5f, 27.0f)); + Z += NZ * VOXEL_NOISE_SCALE * Cfg.VerticalWobble * Env; + } + + Passage.ControlPoints.Add(FVector(PX + OX, PY + OY, Z)); + Passage.ControlRadii.Add(RadiusAt(T)); + } + + Passage.bHasMidPoint = false; + Passage.UpperPoint = Passage.ControlPoints[0]; + Passage.LowerPoint = Passage.ControlPoints.Last(); + Passage.Radius = FMath::Max(Cfg.MouthRadius, Cfg.MidRadius); // fallback / bounds + + // Bounding sphere over all control points (+ widest radius + blend) for culling. + { + FVector Center = FVector::ZeroVector; + for (const FVector& CP : Passage.ControlPoints) Center += CP; + Center /= (float)Passage.ControlPoints.Num(); + float MaxDistSq = 0.0f; + for (const FVector& CP : Passage.ControlPoints) + MaxDistSq = FMath::Max(MaxDistSq, (float)FVector::DistSquared(Center, CP)); + const float R = FMath::Sqrt(MaxDistSq) + Passage.Radius + 4.0f; + Passage.BoundCenter = Center; + Passage.BoundRadiusSq = R * R; + } + + Passages.Add(Passage); + } + } + + //========================================================================= + // SURFACE ENTRY SHAFT — the one auto-opened (0,0) connection. + // A straight vertical shaft at (0,0) piercing the TOP seal of the topmost + // strate, so the world begins with "a hole opened to the surface". All other + // (0,0) descents between strates remain player-dug. + //========================================================================= + if (bOpenSurfaceEntry && StrateLayout.Num() > 0) + { + const FStrateSlot& Top = StrateLayout[0]; + const float TopZ = (float)(Top.TopChunkZ + 1) * CHUNK_SIZE; + + FVoxelPassage Entry; + Entry.UpperStrateIndex = 0; + Entry.LowerStrateIndex = 0; + Entry.PassageType = EVoxelPassageType::VerticalShaft; + Entry.Radius = FMath::Max(OriginSpineRadius * 0.7f, 4.0f); + // From a little above the strate top (open air outside all strates) down + // past the seal into the interior, so the seal at (0,0) is breached. + Entry.UpperPoint = FVector(0.0f, 0.0f, TopZ + CHUNK_SIZE); + Entry.LowerPoint = FVector(0.0f, 0.0f, TopZ - CHUNK_SIZE); + Entry.bHasMidPoint = false; + { + const FVector C = (Entry.UpperPoint + Entry.LowerPoint) * 0.5f; + const float R = (float)FVector::Dist(C, Entry.UpperPoint) + Entry.Radius + 4.0f; + Entry.BoundCenter = C; + Entry.BoundRadiusSq = R * R; + } + Passages.Add(Entry); + + UE_LOG(LogTemp, Log, TEXT("[StrateManager] Surface entry shaft at (0,0) topZ=%.0f R=%.1f"), + TopZ, Entry.Radius); + } +} + +//============================================================================= +// MODIFIER SDF (inter-strate passages) +//============================================================================= + +float UVoxelStrateManager::EvaluateModifierSDF(float WorldX, float WorldY, float WorldZ) const +{ + float MinSDF = FLT_MAX; + const float BlendK = 3.0f; // Smooth blend for passage junctions + + //========================================================================= + // PASSAGES — tapered capsule chains between strates (per-strate PassageConfig). + // Each passage is a control-point chain with per-point radii; the (0,0) surface + // entry is a simple straight tube. A bounding-sphere reject skips far passages. + //========================================================================= + const FVector Pos(WorldX, WorldY, WorldZ); + for (const FVoxelPassage& P : Passages) + { + // BOUNDING-SPHERE REJECT: skip passages this voxel can't possibly be inside. + // EvaluateModifierSDF runs PER VOXEL and used to evaluate every passage's full + // capsule chain unconditionally — the dominant lag source once passages became + // 12-segment worms. Now far passages cost a single squared-distance compare. + if (FVector::DistSquared(Pos, P.BoundCenter) > P.BoundRadiusSq) continue; + + if (P.ControlPoints.Num() >= 2) + { + // Tapered capsule chain along the control points. ControlRadii (if present) + // gives the per-point width so the tunnel can flare at the mouths and pinch + // in the middle; otherwise the uniform Radius is used. + const bool bTaper = (P.ControlRadii.Num() == P.ControlPoints.Num()); + float PassageSDF = FLT_MAX; + for (int32 j = 0; j < P.ControlPoints.Num() - 1; j++) + { + const float rA = bTaper ? P.ControlRadii[j] : P.Radius; + const float rB = bTaper ? P.ControlRadii[j + 1] : P.Radius; + const float SegSDF = VoxelSDF::TaperedCapsule( + Pos, P.ControlPoints[j], P.ControlPoints[j + 1], rA, rB); + PassageSDF = VoxelSDF::SmoothMin(PassageSDF, SegSDF, BlendK); + } + MinSDF = VoxelSDF::SmoothMin(MinSDF, PassageSDF, BlendK); + } + else + { + // Fallback: straight uniform tube Upper→Lower (e.g. the (0,0) surface entry). + const float PassageSDF = VoxelSDF::Capsule(Pos, P.UpperPoint, P.LowerPoint, P.Radius); + MinSDF = VoxelSDF::SmoothMin(MinSDF, PassageSDF, BlendK); + } + } + + return MinSDF; +} + +//============================================================================= +// QUERIES +//============================================================================= + +int32 UVoxelStrateManager::FindSlotIndexForChunkZ(int32 ChunkZ) const +{ + // Linear search through strate layout. + // With ~10-20 strates this is fine. If we ever have hundreds, + // switch to binary search (layout is sorted by Z). + for (int32 i = 0; i < StrateLayout.Num(); i++) + { + const FStrateSlot& Slot = StrateLayout[i]; + if (ChunkZ <= Slot.TopChunkZ && ChunkZ >= Slot.BottomChunkZ) + { + return i; + } + } + return -1; +} + +UVoxelStrateDefinition* UVoxelStrateManager::GetStrateAt(float WorldZ) const +{ + // Convert world Z to chunk Z coordinate + int32 ChunkZ = FMath::FloorToInt((WorldZ / VOXEL_SIZE) / CHUNK_SIZE); + int32 SlotIdx = FindSlotIndexForChunkZ(ChunkZ); + if (SlotIdx >= 0) + { + return StrateLayout[SlotIdx].Definition; + } + return nullptr; +} + +int32 UVoxelStrateManager::GetStrateIndex(float WorldZ) const +{ + int32 ChunkZ = FMath::FloorToInt((WorldZ / VOXEL_SIZE) / CHUNK_SIZE); + int32 SlotIdx = FindSlotIndexForChunkZ(ChunkZ); + if (SlotIdx >= 0) + { + return StrateLayout[SlotIdx].StrateIndex; + } + return -1; +} + +UVoxelStrateDefinition* UVoxelStrateManager::GetStrateForChunk(const FIntVector& ChunkCoord) const +{ + int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx >= 0) + { + return StrateLayout[SlotIdx].Definition; + } + return nullptr; +} + +ECaveGeneratorType UVoxelStrateManager::GetGeneratorTypeForChunk(const FIntVector& ChunkCoord) const +{ + // Look up which slot this chunk falls into. + // If outside all strates (above or below), default to TunnelNetwork — + // the fallback density path will produce solid rock anyway. + int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) + { + return ECaveGeneratorType::TunnelNetwork; + } + + return StrateLayout[SlotIdx].Definition->GeneratorType; +} + +bool UVoxelStrateManager::IsGapChunk(const FIntVector& ChunkCoord) const +{ + if (StrateLayout.Num() == 0) return false; + + // Above the top strate or below the bottom strate = open air, NOT a gap. + const int32 StackTop = StrateLayout[0].TopChunkZ; + const int32 StackBottom = StrateLayout.Last().BottomChunkZ; + if (ChunkCoord.Z > StackTop || ChunkCoord.Z < StackBottom) return false; + + // Inside the stack's Z span but not in any strate slot → it's a bedrock gap. + return FindSlotIndexForChunkZ(ChunkCoord.Z) < 0; +} + +FSlabGenerationParams UVoxelStrateManager::GetSlabParamsForChunk(const FIntVector& ChunkCoord) const +{ + // Fallback: empty params with BaseDensity < 0 → all-air outside strate range. + int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) + { + FSlabGenerationParams Empty; + Empty.BaseDensity = -1.0f; + return Empty; + } + + const FStrateSlot& Slot = StrateLayout[SlotIdx]; + + // Copy the designer-authored slab params from the strate definition. + FSlabGenerationParams Result = Slot.Definition->SlabParams; + + // Fill in the runtime Z bounds (voxel coordinates, same convention as + // FStrateGenerationParams::StrateTopWorldZ / StrateBottomWorldZ). + // TopChunkZ+1 because the top chunk's CEILING is at (TopChunkZ+1)*CHUNK_SIZE. + Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE; + Result.StrateBottomWorldZ = (float)(Slot.BottomChunkZ) * CHUNK_SIZE; + + return Result; +} + +//============================================================================= +// PER-ARCHETYPE PARAM GETTERS +//============================================================================= +// Each mirrors GetSlabParamsForChunk: copy designer params, fill runtime Z bounds. +// No cross-boundary blending — archetypes meet at Hard boundaries. A macro keeps +// the boilerplate (slot lookup + fallback + Z bounds) in one place. + +#define VF_ARCHETYPE_PARAMS_GETTER(FnName, StructType, DefMember) \ +StructType UVoxelStrateManager::FnName(const FIntVector& ChunkCoord) const \ +{ \ + int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); \ + if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) \ + { \ + StructType Empty; \ + Empty.BaseDensity = -1.0f; \ + return Empty; \ + } \ + const FStrateSlot& Slot = StrateLayout[SlotIdx]; \ + StructType Result = Slot.Definition->DefMember; \ + Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE; \ + Result.StrateBottomWorldZ = (float)(Slot.BottomChunkZ) * CHUNK_SIZE; \ + return Result; \ +} + +VF_ARCHETYPE_PARAMS_GETTER(GetMazeParamsForChunk, FMazeGenerationParams, MazeParams) +VF_ARCHETYPE_PARAMS_GETTER(GetSurfaceParamsForChunk, FSurfaceGenerationParams, SurfaceParams) +VF_ARCHETYPE_PARAMS_GETTER(GetVerticalShaftParamsForChunk, FVerticalShaftParams, VerticalShaftParams) +VF_ARCHETYPE_PARAMS_GETTER(GetFloatingIslandParamsForChunk, FFloatingIslandParams, FloatingIslandParams) + +#undef VF_ARCHETYPE_PARAMS_GETTER + +bool UVoxelStrateManager::GetStrateUnrealZRange(float WorldZ, float& OutTopZ, float& OutBottomZ) const +{ + const int32 ChunkZ = FMath::FloorToInt((WorldZ / VOXEL_SIZE) / CHUNK_SIZE); + const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkZ); + if (SlotIdx < 0) return false; + + const FStrateSlot& Slot = StrateLayout[SlotIdx]; + // Voxel-space Z bounds → Unreal units. Ceiling = top chunk's upper edge. + OutTopZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE * VOXEL_SIZE; + OutBottomZ = (float)(Slot.BottomChunkZ) * CHUNK_SIZE * VOXEL_SIZE; + return true; +} + +FStrateDisturbanceParams UVoxelStrateManager::GetDisturbanceParamsForChunk(const FIntVector& ChunkCoord) const +{ + int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) + { + return FStrateDisturbanceParams(); // all features disabled + } + const FStrateSlot& Slot = StrateLayout[SlotIdx]; + FStrateDisturbanceParams Result = Slot.Definition->Disturbances; + Result.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE; + Result.StrateBottomWorldZ = (float)(Slot.BottomChunkZ) * CHUNK_SIZE; + return Result; +} + +float UVoxelStrateManager::GetWaterLevelWorldZForChunk(const FIntVector& ChunkCoord) const +{ + int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) return -FLT_MAX; + + const FStrateSlot& Slot = StrateLayout[SlotIdx]; + const UVoxelStrateDefinition* Def = Slot.Definition; + if (!Def->bHasWater) return -FLT_MAX; + + // Pull the relative level from whichever archetype owns water. + float Rel = 0.0f; + switch (Def->GeneratorType) + { + case ECaveGeneratorType::SurfaceWorld: Rel = Def->SurfaceParams.WaterLevelRelative; break; + case ECaveGeneratorType::Underwater: Rel = Def->GenerationParams.WaterLevelRelative; break; + default: Rel = Def->GenerationParams.WaterLevelRelative; break; + } + if (Rel <= 0.0f) return -FLT_MAX; + + const float BottomZ = (float)(Slot.BottomChunkZ) * CHUNK_SIZE; + const float TopZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE; + return FMath::Lerp(BottomZ, TopZ, FMath::Clamp(Rel, 0.0f, 1.0f)); +} + +FStrateGenerationParams UVoxelStrateManager::GetGenerationParams(const FIntVector& ChunkCoord) const +{ + int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + + // If outside all strates, return negative density → guaranteed air. + // BaseDensity must be < 0 because IsoLevel is 0.0 and density >= IsoLevel = solid. + if (SlotIdx < 0) + { + FStrateGenerationParams Empty; + Empty.BaseDensity = -1.0f; // Negative → air after negation + Empty.WormStrength = 0.0f; + Empty.RoomDensity = 0.0f; // No rooms outside strates + return Empty; + } + + const FStrateSlot& Slot = StrateLayout[SlotIdx]; + FStrateGenerationParams BaseParams = BuildParamsFromDefinition(Slot.Definition); + + //========================================================================= + // SET STRATE BOUNDARY Z VALUES + //========================================================================= + // The density function needs to know the strate's Z range (in voxel coords) + // to seal the top and bottom with solid rock. This prevents caves from + // carving through strate boundaries. + // + // TopChunkZ=0, CHUNK_SIZE=32: top of the strate = chunk 0's top edge = voxel Z=32 + // BottomChunkZ=-3: bottom of the strate = chunk -3's bottom edge = voxel Z=-3*32 = -96 + BaseParams.StrateTopWorldZ = (float)(Slot.TopChunkZ + 1) * CHUNK_SIZE; + BaseParams.StrateBottomWorldZ = (float)(Slot.BottomChunkZ) * CHUNK_SIZE; + + //========================================================================= + // BOUNDARY BLENDING (transition-type aware) + //========================================================================= + // If this chunk is near a strate boundary, apply the appropriate transition + // based on the UPPER strate's TransitionType setting. + // + // Three transition styles: + // + // GRADIENT (default): + // Classic linear lerp of all params across BlendChunks. Smooth, + // invisible boundary. Cave shape morphs gradually from one strate + // to the next over several chunks. + // + // HARD: + // No blending at all — params switch instantly at the boundary. + // The abrupt change in density, room size, roughness, etc. creates + // a natural cliff, ledge, or visible material discontinuity. + // BlendChunks is ignored (effectively 0). + // + // INTERLEAVED: + // 3D Perlin noise warps the effective boundary Z position per XY column. + // Some columns transition early (fingers of the lower strate reach UP), + // others late (fingers of the upper strate reach DOWN). The Z frequency + // is intentionally low so the fingers are horizontal — wide, flat + // intrusions rather than vertical spikes. + // + // WHICH STRATE'S TRANSITION TYPE IS USED: + // At the bottom boundary of strate N (between N and N+1), we use + // strate N's (the upper strate's) TransitionType. This is consistent: + // each strate definition controls what happens at its lower edge. + // At the top boundary of strate N (between N-1 and N), we use + // strate N-1's TransitionType (the strate above controls its lower edge). + + //--------------------------------------------------------------------- + // CHECK BOTTOM BOUNDARY (transitioning to strate below) + //--------------------------------------------------------------------- + // DistFromBottom = how many chunks above the bottom edge of this strate. + // When 0, we're right at the boundary. When == BlendChunks, we're at + // the outer edge of the transition zone. + int32 DistFromBottom = ChunkCoord.Z - Slot.BottomChunkZ; + + if (SlotIdx + 1 < StrateLayout.Num()) + { + // The upper strate (this one) controls the transition type at its lower edge + const EVoxelStrateTransition TransType = Slot.Definition->TransitionType; + + // Per-definition blend distance (overrides the manager's default BlendChunks) + const int32 EffectiveBlend = Slot.Definition->TransitionBlendChunks; + + // Prepare the neighbor's params (only used for Gradient and Interleaved) + const FStrateSlot& BelowSlot = StrateLayout[SlotIdx + 1]; + + switch (TransType) + { + case EVoxelStrateTransition::Hard: + { + // HARD TRANSITION: No blending. The current strate's params apply + // all the way to the boundary with zero transition zone. + // The abrupt param change (different densities, room sizes, etc.) + // creates a natural cliff or ledge — no special density boost needed. + // We simply skip blending and fall through to the "return BaseParams" below. + break; + } + + case EVoxelStrateTransition::Gradient: + { + // GRADIENT TRANSITION: Classic smooth lerp across the blend zone. + // Alpha goes from 0 (at the outer edge of the zone) to 1 (right at boundary). + if (DistFromBottom < EffectiveBlend) + { + FStrateGenerationParams BelowParams = BuildParamsFromDefinition(BelowSlot.Definition); + BelowParams.StrateTopWorldZ = (float)(BelowSlot.TopChunkZ + 1) * CHUNK_SIZE; + BelowParams.StrateBottomWorldZ = (float)(BelowSlot.BottomChunkZ) * CHUNK_SIZE; + + // Linear alpha: 0 at EffectiveBlend chunks away, 1 at the boundary + float Alpha = 1.0f - ((float)DistFromBottom / (float)EffectiveBlend); + Alpha = FMath::Clamp(Alpha, 0.0f, 1.0f); + + return FStrateGenerationParams::Lerp(BaseParams, BelowParams, Alpha); + } + break; + } + + case EVoxelStrateTransition::Interleaved: + { + // INTERLEAVED TRANSITION: 3D noise warps the effective boundary Z. + // + // Instead of a flat boundary plane, the boundary becomes a wavy 3D surface. + // For each XY position, a Perlin noise sample offsets the boundary Z by + // up to ±2 chunks. Where the noise pushes the boundary UP, the lower strate's + // params appear earlier (its "fingers" reach into the upper strate). Where + // the noise pushes DOWN, the upper strate's params persist longer. + // + // The Z frequency is intentionally 3x lower than XY frequency so the fingers + // are horizontal slabs rather than vertical spikes — this matches how real + // geological intrusions look (wide, flat, layered). + // + // WarpAmplitude of 2.0 means the boundary can shift ±2 chunks from its + // true position. Combined with EffectiveBlend for the transition width, + // we need to check a wider zone: EffectiveBlend + WarpAmplitude. + const float WarpAmplitude = 2.0f; // Max boundary offset in chunks + const int32 CheckRange = EffectiveBlend + FMath::CeilToInt(WarpAmplitude); + + if (DistFromBottom < CheckRange) + { + FStrateGenerationParams BelowParams = BuildParamsFromDefinition(BelowSlot.Definition); + BelowParams.StrateTopWorldZ = (float)(BelowSlot.TopChunkZ + 1) * CHUNK_SIZE; + BelowParams.StrateBottomWorldZ = (float)(BelowSlot.BottomChunkZ) * CHUNK_SIZE; + + // Sample 3D Perlin noise to warp the boundary position. + // XY frequency 0.15 gives medium-scale variation (~6-7 chunks per cycle). + // Z frequency 0.05 gives slow vertical change — horizontal finger shapes. + // CachedSeed offsets ensure each world has unique finger patterns. + float WarpNoise = FMath::PerlinNoise3D(FVector( + ChunkCoord.X * 0.15f + CachedSeed * 0.01f, + ChunkCoord.Y * 0.15f + CachedSeed * 0.017f, + ChunkCoord.Z * 0.05f // Lower Z frequency for horizontal "fingers" + )) * VOXEL_NOISE_SCALE; + + // Offset the distance from boundary by the noise * amplitude. + // Positive noise → boundary pushed up → lower strate appears earlier. + // Negative noise → boundary pushed down → upper strate persists longer. + float WarpedDist = (float)DistFromBottom + WarpNoise * WarpAmplitude; + + // Compute alpha from the warped distance (same formula as Gradient, + // but using the noise-displaced distance instead of the true distance) + float Alpha = 1.0f - FMath::Clamp(WarpedDist / (float)EffectiveBlend, 0.0f, 1.0f); + + // Only blend if alpha > 0 (we're inside the warped transition zone) + if (Alpha > 0.0f) + { + return FStrateGenerationParams::Lerp(BaseParams, BelowParams, Alpha); + } + } + break; + } + } + } + + //--------------------------------------------------------------------- + // CHECK TOP BOUNDARY (transitioning to strate above) + //--------------------------------------------------------------------- + // Mirror logic: the ABOVE strate's TransitionType controls its lower edge, + // which is this strate's upper edge. So we read from StrateLayout[SlotIdx-1]. + int32 DistFromTop = Slot.TopChunkZ - ChunkCoord.Z; + + if (SlotIdx > 0) + { + // The strate ABOVE controls the transition at its lower edge (= our upper edge) + const FStrateSlot& AboveSlot = StrateLayout[SlotIdx - 1]; + const EVoxelStrateTransition TransType = AboveSlot.Definition->TransitionType; + const int32 EffectiveBlend = AboveSlot.Definition->TransitionBlendChunks; + + switch (TransType) + { + case EVoxelStrateTransition::Hard: + { + // No blending — fall through to return BaseParams + break; + } + + case EVoxelStrateTransition::Gradient: + { + if (DistFromTop < EffectiveBlend) + { + FStrateGenerationParams AboveParams = BuildParamsFromDefinition(AboveSlot.Definition); + AboveParams.StrateTopWorldZ = (float)(AboveSlot.TopChunkZ + 1) * CHUNK_SIZE; + AboveParams.StrateBottomWorldZ = (float)(AboveSlot.BottomChunkZ) * CHUNK_SIZE; + + float Alpha = 1.0f - ((float)DistFromTop / (float)EffectiveBlend); + Alpha = FMath::Clamp(Alpha, 0.0f, 1.0f); + + return FStrateGenerationParams::Lerp(BaseParams, AboveParams, Alpha); + } + break; + } + + case EVoxelStrateTransition::Interleaved: + { + const float WarpAmplitude = 2.0f; + const int32 CheckRange = EffectiveBlend + FMath::CeilToInt(WarpAmplitude); + + if (DistFromTop < CheckRange) + { + FStrateGenerationParams AboveParams = BuildParamsFromDefinition(AboveSlot.Definition); + AboveParams.StrateTopWorldZ = (float)(AboveSlot.TopChunkZ + 1) * CHUNK_SIZE; + AboveParams.StrateBottomWorldZ = (float)(AboveSlot.BottomChunkZ) * CHUNK_SIZE; + + // Same noise function but with a different seed offset to avoid + // symmetry between top and bottom boundaries of adjacent strates + float WarpNoise = FMath::PerlinNoise3D(FVector( + ChunkCoord.X * 0.15f + CachedSeed * 0.013f, + ChunkCoord.Y * 0.15f + CachedSeed * 0.023f, + ChunkCoord.Z * 0.05f + )) * VOXEL_NOISE_SCALE; + + float WarpedDist = (float)DistFromTop + WarpNoise * WarpAmplitude; + float Alpha = 1.0f - FMath::Clamp(WarpedDist / (float)EffectiveBlend, 0.0f, 1.0f); + + if (Alpha > 0.0f) + { + return FStrateGenerationParams::Lerp(BaseParams, AboveParams, Alpha); + } + } + break; + } + } + } + + // Not near any boundary (or Hard transition) — use this strate's params directly + return BaseParams; +} + +//============================================================================= +// BUILD PARAMS FROM DEFINITION +//============================================================================= +// Returns the definition's base GenerationParams (cave shape, SDF, roughness, etc.). +// +// NOTE: Terrain op fields (TerraceStepHeight, ColumnDensity, etc.) are no longer +// merged here. They default to 0 (disabled) in the base params, and are applied +// per-room during BuildChunkCache() via FCachedRoom::RoomOp — each room hash-rolls +// one op from the strate's probability pool (FStrateTerrainOpEntry::Probability). +// +// Assets are still pre-loaded in Initialize() so BuildChunkCache can resolve +// soft pointers (Entry.Operation.Get()) without a disk read during generation. + +FStrateGenerationParams UVoxelStrateManager::BuildParamsFromDefinition(const UVoxelStrateDefinition* Definition) +{ + if (!Definition) return FStrateGenerationParams(); + + // Base params only — terrain op fields stay 0 until per-room assignment. + return Definition->GenerationParams; +} diff --git a/Source/VoxelForge/Private/VoxelTerrainOpDefinition.cpp b/Source/VoxelForge/Private/VoxelTerrainOpDefinition.cpp new file mode 100644 index 0000000..7537d98 --- /dev/null +++ b/Source/VoxelForge/Private/VoxelTerrainOpDefinition.cpp @@ -0,0 +1,87 @@ +// VoxelTerrainOpDefinition.cpp +// Applies a terrain operation's parameters to the generation params struct. + +#include "VoxelTerrainOpDefinition.h" + +void UVoxelTerrainOpDefinition::ApplyTo(FStrateGenerationParams& OutParams, float Weight) const +{ + // Weight scales the "primary" field of each op type — the one that controls + // whether the op is active and how strong it is. Other fields (radii, spacing, + // ratios) are copied directly since they define shape, not intensity. + + switch (Type) + { + case EVoxelTerrainOpType::Terrace: + // TerraceStepHeight is the activation field (0 = disabled) + OutParams.TerraceStepHeight = TerraceStepHeight * Weight; + OutParams.TerraceHardness = TerraceHardness; + OutParams.TerraceNoiseDisplacement = TerraceNoiseDisplacement; + break; + + case EVoxelTerrainOpType::LayerLines: + // LayerLineSpacing is the activation field (0 = disabled) + OutParams.LayerLineSpacing = LayerLineSpacing * Weight; + OutParams.LayerLineDepth = LayerLineDepth; + break; + + case EVoxelTerrainOpType::Ribbing: + // RibbingSpacing is the activation field (0 = disabled) + OutParams.RibbingSpacing = RibbingSpacing * Weight; + OutParams.RibbingDepth = RibbingDepth; + break; + + case EVoxelTerrainOpType::Cliff: + OutParams.CliffStrength = CliffStrength * Weight; + break; + + case EVoxelTerrainOpType::Scallop: + OutParams.ScallopStrength = ScallopStrength * Weight; + OutParams.ScallopFrequency = ScallopFrequency; + break; + + case EVoxelTerrainOpType::Overhang: + OutParams.OverhangStrength = OverhangStrength * Weight; + OutParams.OverhangDepth = OverhangDepth; + OutParams.OverhangFrequency = OverhangFrequency; + break; + + case EVoxelTerrainOpType::Arch: + OutParams.ArchDensity = ArchDensity * Weight; + OutParams.ArchMinRadius = ArchMinRadius; + OutParams.ArchMaxRadius = ArchMaxRadius; + break; + + case EVoxelTerrainOpType::Column: + OutParams.ColumnDensity = ColumnDensity * Weight; + OutParams.ColumnMinRadius = ColumnMinRadius; + OutParams.ColumnMaxRadius = ColumnMaxRadius; + break; + + case EVoxelTerrainOpType::Pit: + OutParams.PitDensity = PitDensity * Weight; + OutParams.PitMinRadius = PitMinRadius; + OutParams.PitMaxRadius = PitMaxRadius; + OutParams.PitDepth = PitDepth; + break; + + case EVoxelTerrainOpType::Chimney: + OutParams.ChimneyDensity = ChimneyDensity * Weight; + OutParams.ChimneyMinRadius = ChimneyMinRadius; + OutParams.ChimneyMaxRadius = ChimneyMaxRadius; + OutParams.ChimneyHeight = ChimneyHeight; + break; + + case EVoxelTerrainOpType::Dome: + OutParams.DomeDensity = DomeDensity * Weight; + OutParams.DomeMinRadius = DomeMinRadius; + OutParams.DomeMaxRadius = DomeMaxRadius; + OutParams.DomeHeightRatio = DomeHeightRatio; + break; + + case EVoxelTerrainOpType::Pinch: + OutParams.PinchDensity = PinchDensity * Weight; + OutParams.PinchStrength = PinchStrength; + OutParams.PinchLength = PinchLength; + break; + } +} diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp new file mode 100644 index 0000000..1b559e2 --- /dev/null +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -0,0 +1,1074 @@ +// VoxelWorld.cpp +// Implementation of the voxel world manager + +#include "VoxelWorld.h" +#include "VoxelDiffLayer.h" +#include "RealtimeMeshComponent.h" +#include "RealtimeMeshSimple.h" +#include "VoxelMarchingCubesMesher.h" +#include "VoxelStrateDefinition.h" +#include "VoxelTerrainOpDefinition.h" +#include "VoxelContentManager.h" +#include "VoxelAtmosphereManager.h" +#include "DrawDebugHelpers.h" + +AVoxelWorld::AVoxelWorld() +{ + PrimaryActorTick.bCanEverTick = true; +} + +//============================================================================= +// LIVE EDIT — regenerate all chunks when params change in the Details panel +//============================================================================= + +void AVoxelWorld::RegenerateAllChunks() +{ + // Bump the generation epoch so in-flight async tasks become stale. + // ProcessPendingChunks will discard any result with an old epoch. + GenerationEpoch++; + + // Collect all loaded chunk coords + TArray AllCoords; + ChunkMeshes.GetKeys(AllCoords); + + // Unload every chunk (destroys mesh components + data) + for (const FIntVector& Coord : AllCoords) + { + UnloadChunk(Coord); + } + + // Clear pending set — stale tasks will be discarded by epoch check + PendingChunkCoord.Empty(); + + // Reset streaming state so the next Tick rebuilds the desired set and reloads. + LastUpdateCenter = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); + bAllChunksLoaded = false; + DesiredSorted.Reset(); + DesiredSet.Reset(); + + // Tick will reload all chunks on the next frame with fresh params. + UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] RegenerateAllChunks (epoch %u): unloaded %d chunks"), GenerationEpoch, AllCoords.Num()); +} + +void AVoxelWorld::RebuildStrates() +{ + if (StrateManager && Settings) + { + // Re-applies layout + inter-strate gap + passage/spine settings from VoxelSettings. + StrateManager->Initialize(Settings, Settings->Seed); + } + if (AtmosphereManager) AtmosphereManager->Reset(); + if (ContentManager) ContentManager->ClearAll(); + + // Reload all chunks against the rebuilt strate data. + RegenerateAllChunks(); + + UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] RebuildStrates: strate layout + passages rebuilt from settings.")); +} + +#if WITH_EDITOR +void AVoxelWorld::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) +{ + Super::PostEditChangeProperty(PropertyChangedEvent); + + // During PIE with live edit on, regenerate when the actor's own properties change + // (e.g., Settings reference, bLiveEditStrates toggle, etc.) + // Data asset edits (strate definitions) are handled separately by OnObjectModifiedInEditor. + if (bLiveEditStrates && GetWorld() && GetWorld()->IsPlayInEditor()) + { + RegenerateAllChunks(); + } +} + +void AVoxelWorld::OnObjectModifiedInEditor(UObject* ModifiedObject) +{ + // Only react during PIE with live edit enabled + if (!bLiveEditStrates || !GetWorld() || !GetWorld()->IsPlayInEditor()) return; + + // Only care about strate definition and terrain op definition edits. + // (This delegate fires for EVERY UObject modification in the editor.) + bool bIsRelevant = false; + FString AssetName; + + // Case 1: A strate definition was modified + if (UVoxelStrateDefinition* ModifiedStrate = Cast(ModifiedObject)) + { + if (!Settings) return; + AssetName = ModifiedStrate->GetName(); + + // Check the strate pool + for (const TSoftObjectPtr& PoolEntry : Settings->StratePool) + { + if (PoolEntry.Get() == ModifiedStrate) { bIsRelevant = true; break; } + } + // Check fixed strates + if (!bIsRelevant) + { + for (const auto& FixedEntry : Settings->FixedStrates) + { + if (FixedEntry.Value.Get() == ModifiedStrate) { bIsRelevant = true; break; } + } + } + } + // Case 2: A terrain op definition was modified — check if any strate references it + else if (UVoxelTerrainOpDefinition* ModifiedOp = Cast(ModifiedObject)) + { + if (!Settings || !StrateManager) return; + AssetName = ModifiedOp->GetName(); + + // Check every strate definition's terrain op list + for (const TSoftObjectPtr& PoolEntry : Settings->StratePool) + { + UVoxelStrateDefinition* Def = PoolEntry.Get(); + if (!Def) continue; + for (const FStrateTerrainOpEntry& Entry : Def->TerrainOperations) + { + if (Entry.Operation.Get() == ModifiedOp) { bIsRelevant = true; break; } + } + if (bIsRelevant) break; + } + if (!bIsRelevant) + { + for (const auto& FixedEntry : Settings->FixedStrates) + { + UVoxelStrateDefinition* Def = FixedEntry.Value.Get(); + if (!Def) continue; + for (const FStrateTerrainOpEntry& Entry : Def->TerrainOperations) + { + if (Entry.Operation.Get() == ModifiedOp) { bIsRelevant = true; break; } + } + if (bIsRelevant) break; + } + } + } + + if (!bIsRelevant) return; + + UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] Live edit: '%s' modified, regenerating..."), + *AssetName); + + // Re-initialize the strate manager so it picks up the changed definition values, + // then regenerate all chunks with the updated params. + if (StrateManager) + { + StrateManager->Initialize(Settings, Settings->Seed); + } + if (Generator) + { + Generator->InitializeSettings(Settings); + } + + RegenerateAllChunks(); +} +#endif + +void AVoxelWorld::EndPlay(const EEndPlayReason::Type EndPlayReason) +{ + // Signal all async tasks to bail out ASAP + bShuttingDown.store(true, std::memory_order_release); + + // Wait for all running tasks to finish before destroying UObjects. + // Tasks check bShuttingDown and exit early, so this should be fast. + // Timeout after 3 seconds to avoid hanging the editor. + const double Deadline = FPlatformTime::Seconds() + 3.0; + while (ActiveTaskCount.load(std::memory_order_relaxed) > 0) + { + if (FPlatformTime::Seconds() > Deadline) + { + UE_LOG(LogTemp, Warning, TEXT("[VoxelWorld] EndPlay: %d tasks still running after 3s timeout"), + ActiveTaskCount.load(std::memory_order_relaxed)); + break; + } + FPlatformProcess::Yield(); // Give CPU to other threads + } + + // Drain any queued results + FChunkResult Discard; + while (ProcessQueue.Dequeue(Discard)) {} + PendingChunkCoord.Empty(); + + // Destroy any spawned atmosphere layer actors. + if (AtmosphereManager) + { + AtmosphereManager->Reset(); + } + + // Unbind the data asset monitoring delegate +#if WITH_EDITOR + if (OnObjectModifiedHandle.IsValid()) + { + FCoreUObjectDelegates::OnObjectModified.Remove(OnObjectModifiedHandle); + OnObjectModifiedHandle.Reset(); + } +#endif + + Super::EndPlay(EndPlayReason); +} + +void AVoxelWorld::BeginPlay() +{ + Super::BeginPlay(); + bShuttingDown.store(false, std::memory_order_relaxed); + + if (!Settings) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] No Settings assigned — world won't generate.")); + return; + } + + // Générateur + mesher (UObjects légers) + Generator = NewObject(this); + Mesher = NewObject(this); + + Generator->InitializeSettings(Settings); + Mesher->SetGenerator(Generator); + + // Système de strates — piloté par le pool et les fixed entries dans Settings. + if (Settings->StratePool.Num() > 0) + { + StrateManager = NewObject(this); + StrateManager->Initialize(Settings, Settings->Seed); + Generator->SetStrateManager(StrateManager); + UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] Strate system initialized with %d strates"), + StrateManager->GetNumStrates()); + } + + // Diff layer — stocke les modifications du joueur par dessus la densité + // procédurale. Créé systématiquement (coût nul tant qu'il n'y a pas d'édit). + DiffLayer = NewObject(this); + DiffLayer->SetBudget(Settings->MaxModifications, Settings->MaxBrushRadius, Settings->MaxTotalVolume); + Generator->SetDiffLayer(DiffLayer); + + // Content manager — scatters decorations/actors + water per chunk as they stream in. + ContentManager = NewObject(this); + ContentManager->Initialize(this, StrateManager, Settings->Seed); + + // Atmosphere manager — per-strate fog + ambient + persistent ceiling/floor layers. + if (bManageAtmosphere && StrateManager) + { + AtmosphereManager = NewObject(this); + AtmosphereManager->Initialize(this, StrateManager); + } + +#if WITH_EDITOR + // Listen for data asset edits during PIE so live edit can detect + // strate definition changes (PostEditChangeProperty only fires for + // properties on this actor itself, not on referenced data assets). + OnObjectModifiedHandle = FCoreUObjectDelegates::OnObjectModified.AddUObject( + this, &AVoxelWorld::OnObjectModifiedInEditor); +#endif +} + +void AVoxelWorld::Tick(float DeltaTime) +{ + Super::Tick(DeltaTime); + FVector PlayerLastPos = GetPlayerPosition(); + + if ((PlayerLastPos != FVector::ZeroVector)) { + UpdateChunksAroundPosition(PlayerLastPos); + if (AtmosphereManager) + { + AtmosphereManager->UpdateForPlayer(PlayerLastPos); + } + } + ProcessPendingChunks(); + +#if ENABLE_DRAW_DEBUG + // Inter-strate passage overlay (cyan path, green=upper / red=lower endpoints). + // Points are in voxel coords → world units (×VOXEL_SIZE) → actor space. + if (bDebugDrawPassages && StrateManager) + { + const FTransform Xf = GetActorTransform(); + auto ToWorld = [&](const FVector& VoxelPt) { return Xf.TransformPosition(VoxelPt * VOXEL_SIZE); }; + auto DrawSeg = [&](const FVector& A, const FVector& B) + { + DrawDebugLine(GetWorld(), ToWorld(A), ToWorld(B), FColor::Cyan, false, -1.0f, 0, 30.0f); + }; + + for (const FVoxelPassage& P : StrateManager->GetPassages()) + { + if (P.ControlPoints.Num() >= 2) + { + for (int32 j = 0; j < P.ControlPoints.Num() - 1; ++j) + DrawSeg(P.ControlPoints[j], P.ControlPoints[j + 1]); + } + else if (P.bHasMidPoint) + { + DrawSeg(P.UpperPoint, P.MidPoint); + DrawSeg(P.MidPoint, P.LowerPoint); + } + else + { + DrawSeg(P.UpperPoint, P.LowerPoint); + } + + DrawDebugSphere(GetWorld(), ToWorld(P.UpperPoint), P.Radius * VOXEL_SIZE, 12, FColor::Green, false, -1.0f, 0, 4.0f); + DrawDebugSphere(GetWorld(), ToWorld(P.LowerPoint), P.Radius * VOXEL_SIZE, 12, FColor::Red, false, -1.0f, 0, 4.0f); + } + } +#endif +} + +FVector AVoxelWorld::GetPlayerPosition() const +{ + // This one is tricky with Unreal's API, so I'll give you more help: + APlayerController* PC = GetWorld()->GetFirstPlayerController(); + if (PC && PC->GetPawn()) + { + return PC->GetPawn()->GetActorLocation(); + } + return FVector::ZeroVector; +} + +int32 AVoxelWorld::GetLODForChunk(const FIntVector& ChunkCoord, const FIntVector& CenterChunk) const +{ + // Chebyshev distance (max of absolute differences on each axis) + // This gives a cubic LOD zone instead of spherical — simpler and + // matches how chunks are loaded (cubic view distance). + FIntVector Delta = ChunkCoord - CenterChunk; + int32 Distance = FMath::Max3( + FMath::Abs(Delta.X), + FMath::Abs(Delta.Y), + FMath::Abs(Delta.Z) + ); + + if (Distance <= Settings->LOD0Distance) + { + return 0; // Full resolution + } + else if (Distance <= Settings->LOD1Distance) + { + return 1; // Half resolution + } + else + { + return 2; // Quarter resolution + } +} + +int32 AVoxelWorld::LODToStep(int32 LODLevel) +{ + // LOD0 → 1, LOD1 → 2, LOD2 → 4 + // Using bit shift: 1 << LODLevel + return 1 << FMath::Clamp(LODLevel, 0, 2); +} + +bool AVoxelWorld::IsChunkInRange(const FIntVector& ChunkCoord, const FIntVector& CenterChunk) const +{ + const int32 ViewXY = Settings->ViewDistanceXY; + const int32 ViewUp = Settings->ViewDistanceUp; + const int32 ViewDown = Settings->ViewDistanceDown; + FIntVector Range = ChunkCoord - CenterChunk; + + if ((FMath::Abs(Range.X) <= ViewXY) and (FMath::Abs(Range.Y) <= ViewXY)) { + if (Range.Z > 0) + { + if (FMath::Abs(Range.Z) <= ViewUp) + { + return true; + } + } + else + { + if (FMath::Abs(Range.Z) <= ViewDown) + { + return true; + } + } + } + return false; +} + +void AVoxelWorld::ProcessPendingChunks() +{ + // This runs on the game thread, called from Tick. + // + // STEPS: + // 1. Try to dequeue a result from ProcessQueue + // TQueue has a Dequeue(OutItem) method that returns true if it got something + // + // 2. If we got a result: + // a. Store the chunk data in our Chunks map + // b. Apply the mesh so it becomes visible + // c. Remove the coord from PendingChunkCoord (it's no longer "in progress") + // + // 3. You can process multiple results per frame with a while loop, + // or limit to a few per frame to avoid stutters (e.g., max 4 per tick) + // + // TOOLS: + // - ProcessQueue.Dequeue(Result) — returns bool, fills Result if true + // - Chunks.Add(Key, Value) + // - ApplyMeshToChunk(ChunkCoord, MeshData) + // - PendingChunkCoord.Remove(ChunkCoord) + // Drain the process queue up to the per-frame budget. + // This prevents stutters from applying too many meshes in one frame. + // Budget limits how many VISIBLE mesh applies we do per frame (GPU upload cost). + // Empty meshes and stale results are free to drain — don't count them. + const int32 MaxApplies = Settings ? Settings->MaxMeshAppliesPerFrame : 4; + int32 MeshesApplied = 0; + FChunkResult DequeuedChunk; + while (ProcessQueue.Dequeue(DequeuedChunk)) + { + PendingChunkCoord.Remove(DequeuedChunk.ChunkCoord); + + // Discard results from a previous generation epoch (stale). + if (DequeuedChunk.Epoch != GenerationEpoch) + { + continue; + } + + // Always register the chunk as loaded (even if empty — so we don't re-generate it). + Chunks.Add(DequeuedChunk.ChunkCoord, DequeuedChunk.Chunk); + ChunkLODs.Add(DequeuedChunk.ChunkCoord, DequeuedChunk.LODLevel); + + // Empty mesh = all air chunk — nothing to render, but still "loaded". + if (DequeuedChunk.MeshData.IsEmpty()) + { + continue; + } + + // Apply mesh (GPU upload) — this is the expensive part we budget. + ApplyMeshToChunk(DequeuedChunk.ChunkCoord, DequeuedChunk.MeshData); + MeshesApplied++; + + if (MeshesApplied >= MaxApplies) + { + break; + } + } + +} + + +void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition) +{ + // + // TOOLS: + // - WorldToChunkCoord(Position) - convert world pos to chunk coord + // - Chunks.Contains(Coord) - check if chunk exists + // - TArray to build a list + // - Chunks is a TMap you can iterate with: for (auto& Pair : Chunks) + // where Pair.Key is the coordinate and Pair.Value is the chunk + const int32 ViewXY = Settings->ViewDistanceXY; + const int32 ViewUp = Settings->ViewDistanceUp; + const int32 ViewDown = Settings->ViewDistanceDown; + const int32 MaxTasks = Settings ? Settings->MaxConcurrentTasks : 16; + + const FIntVector CenterChunk = WorldToChunkCoord(CenterPosition); + CurrentCenterChunk = CenterChunk; + + //========================================================================= + // ONLY rebuild the desired set when the player crosses a chunk boundary. + // Standing still (or moving within a chunk) costs nothing here. + //========================================================================= + if (CenterChunk != LastUpdateCenter) + { + LastUpdateCenter = CenterChunk; + bAllChunksLoaded = false; + + DesiredSorted.Reset(); + DesiredSet.Reset(); + + for (int32 OffsetX = -ViewXY; OffsetX <= ViewXY; OffsetX++) + for (int32 OffsetY = -ViewXY; OffsetY <= ViewXY; OffsetY++) + for (int32 OffsetZ = -ViewDown; OffsetZ <= ViewUp; OffsetZ++) + { + const FIntVector C = CenterChunk + FIntVector(OffsetX, OffsetY, OffsetZ); + if (IsChunkInRange(C, CenterChunk)) + { + DesiredSorted.Add(C); + DesiredSet.Add(C); + } + } + + // Nearest-first so the closest chunks stream in before distant ones. + DesiredSorted.Sort([&CenterChunk](const FIntVector& A, const FIntVector& B) + { + const FIntVector DA = A - CenterChunk; + const FIntVector DB = B - CenterChunk; + return (DA.X * DA.X + DA.Y * DA.Y + DA.Z * DA.Z) + < (DB.X * DB.X + DB.Y * DB.Y + DB.Z * DB.Z); + }); + + // Cull pass — O(loaded) thanks to the TSet (was O(loaded × desired)). + TArray ChunksToRemove; + for (const auto& Pair : Chunks) + { + if (!DesiredSet.Contains(Pair.Key)) + { + ChunksToRemove.Add(Pair.Key); + } + } + for (const FIntVector& C : ChunksToRemove) + { + UnloadChunk(C); + } + + // LOD reconciliation for chunks that stayed in view is handled by the persistent + // budgeted loop below — NOT as a one-shot here. Doing it once on the boundary-cross + // frame stranded any chunk skipped by a full task budget at its stale LOD; the + // persistent loop retries on later frames until every chunk sits at its target LOD. + } + + //========================================================================= + // Submit pending work (budgeted). Runs each frame until everything desired is + // streamed in AT ITS TARGET LOD, then goes idle until the player moves again. + // Handles BOTH missing chunks (load) and loaded-but-wrong-LOD chunks (hot-swap + // re-mesh) in one persistent, budget-retried pass — so nothing is stranded. + //========================================================================= + if (!bAllChunksLoaded) + { + int32 Submitted = 0; + for (const FIntVector& C : DesiredSorted) + { + // Budget full → stop submitting this frame. Pending > 0 keeps us out of the + // idle state below, so we resume next frame (nearest-first, so closest first). + if (PendingChunkCoord.Num() >= MaxTasks) break; + if (PendingChunkCoord.Contains(C)) continue; // already in flight + + bool bNeedsWork; + if (Chunks.Contains(C)) + { + // Loaded — re-mesh only if its LOD no longer matches the current center. + // Hot-swap (LoadChunk, never unload-first): the old mesh stays visible + // until the new one lands, so no hole/pop during the swap. + const int32 DesiredLOD = GetLODForChunk(C, CurrentCenterChunk); + const int32* CurrentLOD = ChunkLODs.Find(C); + bNeedsWork = (CurrentLOD && *CurrentLOD != DesiredLOD); + } + else + { + bNeedsWork = true; // not loaded yet + } + if (!bNeedsWork) continue; + + LoadChunk(C); + ++Submitted; + } + + // Idle only after a FULL scan submitted nothing and nothing is in flight — i.e. + // every desired chunk is loaded at its target LOD. (We only break early when the + // budget is full, which leaves Pending > 0, so this can't fire prematurely.) + if (Submitted == 0 && PendingChunkCoord.Num() == 0) + { + bAllChunksLoaded = true; + } + } +} + +void AVoxelWorld::LoadChunk(const FIntVector& ChunkCoord) +{ + if (PendingChunkCoord.Contains(ChunkCoord)) return; + + const int32 MaxTasks = Settings ? Settings->MaxConcurrentTasks : 16; + if (PendingChunkCoord.Num() >= MaxTasks) + { + return; // Budget plein — on attend qu'une tâche finisse + } + PendingChunkCoord.Add(ChunkCoord); + + const int32 LODLevel = GetLODForChunk(ChunkCoord, CurrentCenterChunk); + const int32 Step = LODToStep(LODLevel); + + // Epoch capturé pour détecter les résultats périmés (après un RegenerateAll) + const uint32 TaskEpoch = GenerationEpoch; + + ActiveTaskCount.fetch_add(1, std::memory_order_relaxed); + + UE::Tasks::Launch(TEXT("ChunkGen"), [this, ChunkCoord, Step, LODLevel, TaskEpoch]() + { + // RAII: décrément du compteur quel que soit le chemin de sortie + struct FTaskGuard + { + std::atomic& Counter; + ~FTaskGuard() { Counter.fetch_sub(1, std::memory_order_relaxed); } + } Guard{ActiveTaskCount}; + + if (bShuttingDown.load(std::memory_order_relaxed)) return; + + // Le mesher lit la densité directement depuis Generator — pas de + // "generate chunk" préliminaire: le chunk est juste un wrapper de coord. + const FVoxelChunk Chunk(ChunkCoord); + + FChunkResult Result; + Result.ChunkCoord = ChunkCoord; + Result.Chunk = Chunk; + Result.LODLevel = LODLevel; + Result.Epoch = TaskEpoch; + Result.MeshData = Mesher->GenerateMesh(Chunk, Step); + + if (!bShuttingDown.load(std::memory_order_relaxed)) + { + ProcessQueue.Enqueue(Result); + } + }); +} + +void AVoxelWorld::UnloadChunk(const FIntVector& ChunkCoord) +{ + // Destroy any spawned content (decorations + water) before the mesh goes. + if (ContentManager) + { + ContentManager->ClearChunk(ChunkCoord); + } + + if (ChunkMeshes.Contains(ChunkCoord)) { + ChunkMeshes[ChunkCoord]->DestroyComponent(); + ChunkMeshes.Remove(ChunkCoord); + Chunks.Remove(ChunkCoord); + ChunkLODs.Remove(ChunkCoord); + } +} + +void AVoxelWorld::ApplyMeshToChunk(const FIntVector& ChunkCoord, const FVoxelMeshData& MeshData) +{ + //========================================================================== + // STEP 1: EARLY EXIT IF NO MESH DATA + //========================================================================== + // If the chunk is all air (empty), there's nothing to render. + // MeshData.IsEmpty() returns true if Vertices array has 0 elements. + if (MeshData.IsEmpty()) + { + return; + } + + //========================================================================== + // STEP 2: GET OR CREATE THE MESH COMPONENT + //========================================================================== + // Each chunk needs a URealtimeMeshComponent to be visible in the world. + // We store these in the ChunkMeshes map, keyed by chunk coordinate. + URealtimeMeshComponent* MeshComp = nullptr; + + // Check if we already have a mesh component for this chunk + if (ChunkMeshes.Contains(ChunkCoord)) + { + // Reuse existing component (chunk is being updated, not created) + MeshComp = ChunkMeshes[ChunkCoord]; + } + else + { + // Create a brand new mesh component for this chunk + // NewObject(Outer) creates a new UObject of type T + // 'this' (AVoxelWorld) is the "outer" - it owns this component + MeshComp = NewObject(this); + + // RegisterComponent() tells Unreal "this component is ready to use" + // Without this, the component won't tick, render, or do anything + MeshComp->RegisterComponent(); + + // Attach to our actor so it moves with us and inherits our transform + MeshComp->AttachToComponent(GetRootComponent(), FAttachmentTransformRules::KeepRelativeTransform); + + // Store in our map so we can find it later + ChunkMeshes.Add(ChunkCoord, MeshComp); + } + + // Safety check + if (!MeshComp) + { + return; + } + + //========================================================================== + // STEP 3: INITIALIZE THE REALTIMEMESH ASSET + //========================================================================== + // Component = "the renderer", MeshAsset = "the data" + // InitializeRealtimeMesh creates or returns the asset + URealtimeMeshSimple* RTMesh = MeshComp->InitializeRealtimeMesh(); + if (!RTMesh) + { + return; + } + + //========================================================================== + // STEP 4: CREATE KEYS FOR THE MESH STRUCTURE + //========================================================================== + // RealtimeMesh hierarchy: Mesh -> LODs -> SectionGroups -> Sections + // + // LOD = Level of Detail (LOD0 = highest detail, closest to camera) + // SectionGroup = A group of mesh sections (one per chunk) + // Section = Actual triangle data with a material + + const FRealtimeMeshLODKey LOD0(0); // Highest detail + + // GroupKey = identifies our section group + const FRealtimeMeshSectionGroupKey GroupKey = + FRealtimeMeshSectionGroupKey::Create(LOD0, FName("ChunkMesh")); + + // SectionKey = identifies the mesh section within group + const FRealtimeMeshSectionKey SectionKey = + FRealtimeMeshSectionKey::CreateForPolyGroup(GroupKey, 0); + + //========================================================================== + // STEP 5: REMOVE OLD GEOMETRY + //========================================================================== + // Clear previous data before adding new (important for chunk updates) + RTMesh->RemoveSectionGroup(GroupKey); + + //========================================================================== + // STEP 6: CREATE THE MESH BUILDER + //========================================================================== + // StreamSet = container for vertex data, indices, normals, UVs, etc. + // Builder = helper to easily add vertices and triangles + // + // Template: + RealtimeMesh::FRealtimeMeshStreamSet Streams; + RealtimeMesh::TRealtimeMeshBuilderLocal Builder(Streams); + + // Enable data streams we'll use + Builder.EnableTangents(); // Pour le normal mapping + Builder.EnableTexCoords(); // Pour les UVs + Builder.EnablePolyGroups(); // Pour l'assignation du matériau + + //========================================================================== + // STEP 7: ADD ALL VERTICES + //========================================================================== + + const int32 NumVertices = MeshData.Vertices.Num(); + Builder.ReserveAdditionalVertices(NumVertices); // Pre-allocate for speed + + for (int32 i = 0; i < NumVertices; i++) + { + const FVector3f Position = (FVector3f)MeshData.Vertices[i]; + auto Vertex = Builder.AddVertex(Position); + + if (MeshData.Normals.IsValidIndex(i)) + { + const FVector3f Normal = (FVector3f)MeshData.Normals[i]; + const FVector3f Tangent = FVector3f(1, 0, 0); + Vertex.SetNormalAndTangent(Normal, Tangent); + } + + if (MeshData.UVs.IsValidIndex(i)) + { + Vertex.SetTexCoord(0, (FVector2f)MeshData.UVs[i]); + } + } + + //========================================================================== + // STEP 8: ADD ALL TRIANGLES + //========================================================================== + // Each triangle = 3 vertex indices + // Our quads = 2 triangles = 6 indices each + const int32 NumIndices = MeshData.Triangles.Num(); + Builder.ReserveAdditionalTriangles(NumIndices / 3); + + for (int32 i = 0; i < NumIndices; i += 3) + { + Builder.AddTriangle( + (uint32)MeshData.Triangles[i], + (uint32)MeshData.Triangles[i + 1], + (uint32)MeshData.Triangles[i + 2], + 0 // PolyGroup 0 = material slot 0 + ); + } + + //========================================================================== + // STEP 9: UPLOAD TO GPU + //========================================================================== + // MoveTemp = transfer ownership efficiently (no copy) + RTMesh->CreateSectionGroup(GroupKey, MoveTemp(Streams)); + + //========================================================================== + // STEP 10: CONFIGURE SECTION + //========================================================================== + FRealtimeMeshSectionConfig SectionConfig(0); // Material slot 0 + SectionConfig.bIsVisible = true; + + // Pick the material: use the strate's override if available, else the global default. + UMaterialInterface* ChunkMaterial = Settings->VoxelMaterial; + if (StrateManager) + { + if (UVoxelStrateDefinition* StrateDef = StrateManager->GetStrateForChunk(ChunkCoord)) + { + if (StrateDef->OverrideMaterial) + { + ChunkMaterial = StrateDef->OverrideMaterial; + } + } + } + + RTMesh->SetupMaterialSlot(0, "Main", ChunkMaterial); + RTMesh->UpdateSectionConfig(SectionKey, SectionConfig, true); + + //========================================================================== + // STEP 11: POPULATE CONTENT (decorations + water) + //========================================================================== + // Runs on the game thread (ProcessPendingChunks calls us here), so spawning + // actors is safe. PopulateChunk clears any prior content for this coord first, + // so re-meshing after a carve refreshes the scatter cleanly. + if (ContentManager) + { + ContentManager->PopulateChunk(ChunkCoord, MeshData, ChunkLODs.FindRef(ChunkCoord)); + } +} + +//============================================================================= +// STRATE QUERIES +//============================================================================= + +int32 AVoxelWorld::GetStrateAtPosition(FVector WorldPosition) const +{ + if (!StrateManager) return -1; + + // GetStrateIndex expects Unreal world units — it converts internally. + return StrateManager->GetStrateIndex(WorldPosition.Z); +} + +//============================================================================= +// TERRAIN MODIFICATION — player carving & filling +//============================================================================= + +void AVoxelWorld::CarveAtPosition(FVector Position, float Radius, float Strength) +{ + if (!DiffLayer) return; + + // Convert world position (Unreal units) to voxel space. + // VOXEL_SIZE = 25 in VoxelForge, so divide by it. + const FVector VoxelPos = Position / VOXEL_SIZE; + + // Carve = negative strength (subtracts density → creates air) + FVoxelModification Mod; + Mod.Center = VoxelPos; + Mod.Radius = Radius; + Mod.Strength = -FMath::Abs(Strength); // Force negative for carving + + TArray AffectedChunks = DiffLayer->ApplyModification(Mod); + RemeshDirtyChunks(AffectedChunks); +} + +void AVoxelWorld::FillAtPosition(FVector Position, float Radius, float Strength) +{ + if (!DiffLayer) return; + + // Convert world position to voxel space + const FVector VoxelPos = Position / VOXEL_SIZE; + + // Fill = positive strength (adds density → creates solid) + FVoxelModification Mod; + Mod.Center = VoxelPos; + Mod.Radius = Radius; + Mod.Strength = FMath::Abs(Strength); // Force positive for filling + + TArray AffectedChunks = DiffLayer->ApplyModification(Mod); + RemeshDirtyChunks(AffectedChunks); +} + +void AVoxelWorld::ApplyModification(const FVoxelModification& Modification) +{ + if (!DiffLayer) return; + TArray AffectedChunks = DiffLayer->ApplyModification(Modification); + RemeshDirtyChunks(AffectedChunks); +} + +void AVoxelWorld::CarveBox(FVector Position, FVector ExtentVoxels, float Strength) +{ + FVoxelModification Mod; + Mod.Shape = EVoxelBrushShape::Box; + Mod.Center = Position / VOXEL_SIZE; + Mod.BoxExtent = ExtentVoxels; + Mod.Radius = ExtentVoxels.GetMax(); // budget proxy + Mod.Strength = -FMath::Abs(Strength); // carve + ApplyModification(Mod); +} + +void AVoxelWorld::FillBox(FVector Position, FVector ExtentVoxels, float Strength) +{ + FVoxelModification Mod; + Mod.Shape = EVoxelBrushShape::Box; + Mod.Center = Position / VOXEL_SIZE; + Mod.BoxExtent = ExtentVoxels; + Mod.Radius = ExtentVoxels.GetMax(); + Mod.Strength = FMath::Abs(Strength); // fill + ApplyModification(Mod); +} + +void AVoxelWorld::CarveCapsule(FVector WorldA, FVector WorldB, float RadiusVoxels, float Strength) +{ + FVoxelModification Mod; + Mod.Shape = EVoxelBrushShape::Capsule; + Mod.Center = WorldA / VOXEL_SIZE; + Mod.CapsuleEnd = WorldB / VOXEL_SIZE; + Mod.Radius = RadiusVoxels; + Mod.Strength = -FMath::Abs(Strength); + ApplyModification(Mod); +} + +void AVoxelWorld::FillCapsule(FVector WorldA, FVector WorldB, float RadiusVoxels, float Strength) +{ + FVoxelModification Mod; + Mod.Shape = EVoxelBrushShape::Capsule; + Mod.Center = WorldA / VOXEL_SIZE; + Mod.CapsuleEnd = WorldB / VOXEL_SIZE; + Mod.Radius = RadiusVoxels; + Mod.Strength = FMath::Abs(Strength); + ApplyModification(Mod); +} + +void AVoxelWorld::EditorCarveSphere() +{ + if (!DiffLayer) + { + UE_LOG(LogTemp, Warning, TEXT("[VoxelWorld] EditorCarveSphere: no DiffLayer (start PIE first).")); + return; + } + CarveAtPosition(EditorBrushCenter, EditorBrushRadius, EditorBrushStrength); +} + +void AVoxelWorld::EditorFillSphere() +{ + if (!DiffLayer) + { + UE_LOG(LogTemp, Warning, TEXT("[VoxelWorld] EditorFillSphere: no DiffLayer (start PIE first).")); + return; + } + FillAtPosition(EditorBrushCenter, EditorBrushRadius, EditorBrushStrength); +} + +void AVoxelWorld::ClearAllModifications() +{ + if (!DiffLayer) return; + + DiffLayer->Clear(); + + // Regenerate all loaded chunks to restore procedural terrain + RegenerateAllChunks(); +} + +//============================================================================= +// SEED / SEASON MANAGEMENT +//============================================================================= + +void AVoxelWorld::ChangeSeed(int32 NewSeed) +{ + if (!Settings) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] ChangeSeed failed — no Settings assigned")); + return; + } + + const int32 OldSeed = Settings->Seed; + const int32 OldSeason = Settings->CurrentSeason; + + // 1. Update seed in Settings (the authoritative source) + Settings->Seed = NewSeed; + + // 2. Increment season counter + Settings->CurrentSeason++; + + // 3. Push new seed to Generator + if (Generator) + { + Generator->InitializeSettings(Settings); + } + + // 4. Rebuild strate layout with the new seed. + // Strate assignments and passages all change. + if (StrateManager) + { + StrateManager->Initialize(Settings, NewSeed); + } + + // 5. Clear all player modifications — carvings from the old world are meaningless + if (DiffLayer) + { + DiffLayer->Clear(); + } + + // 5b. Update content placement seed so the new world scatters differently. + if (ContentManager) + { + ContentManager->SetSeed(NewSeed); + ContentManager->ClearAll(); + } + + // 5c. Reset atmosphere — strate layout changed, re-apply on next Tick. + if (AtmosphereManager) + { + AtmosphereManager->Reset(); + } + + // 6. Unload all existing chunks and let Tick reload them with new generation + RegenerateAllChunks(); + + UE_LOG(LogTemp, Log, + TEXT("[VoxelWorld] Seed changed: %d -> %d (Season %d -> %d). All chunks regenerated, mods cleared."), + OldSeed, NewSeed, OldSeason, Settings->CurrentSeason); +} + +int32 AVoxelWorld::GetCurrentSeed() const +{ + return Settings ? Settings->Seed : 0; +} + +int32 AVoxelWorld::GetCurrentSeason() const +{ + return Settings ? Settings->CurrentSeason : 0; +} + +//============================================================================= +// REMESH DIRTY CHUNKS — re-queue affected chunks after terrain modification +//============================================================================= + +void AVoxelWorld::RemeshDirtyChunks(const TArray& DirtyCoords) +{ + // For each affected chunk that's currently loaded, re-queue it for + // async generation + meshing. The old mesh stays visible until the + // new result arrives in ProcessPendingChunks, so no visual pop. + // + // Chunks that aren't loaded are ignored — when they eventually load + // through normal streaming, they'll include the diff layer automatically. + + for (const FIntVector& Coord : DirtyCoords) + { + // Only remesh chunks that are actually loaded + if (!Chunks.Contains(Coord)) continue; + + // Skip if already queued for generation (avoid double-submit) + if (PendingChunkCoord.Contains(Coord)) continue; + + // Check concurrent task budget + const int32 MaxTasks = Settings ? Settings->MaxConcurrentTasks : 16; + if (PendingChunkCoord.Num() >= MaxTasks) break; + + PendingChunkCoord.Add(Coord); + + // Use existing LOD for this chunk (it hasn't moved, just modified) + const int32* CurrentLOD = ChunkLODs.Find(Coord); + const int32 LODLevel = CurrentLOD ? *CurrentLOD : 0; + const int32 Step = LODToStep(LODLevel); + const uint32 TaskEpoch = GenerationEpoch; + + ActiveTaskCount.fetch_add(1, std::memory_order_relaxed); + + UE::Tasks::Launch(TEXT("ChunkRemesh"), [this, Coord, Step, LODLevel, TaskEpoch]() + { + struct FTaskGuard + { + std::atomic& Counter; + ~FTaskGuard() { Counter.fetch_sub(1, std::memory_order_relaxed); } + } Guard{ActiveTaskCount}; + + if (bShuttingDown.load(std::memory_order_relaxed)) return; + + // Re-mesh: la densité inclut automatiquement le DiffLayer + // (carves du joueur) puisque le générateur le consulte dans GetDensityAt. + const FVoxelChunk Chunk(Coord); + + FChunkResult Result; + Result.ChunkCoord = Coord; + Result.Chunk = Chunk; + Result.LODLevel = LODLevel; + Result.Epoch = TaskEpoch; + Result.MeshData = Mesher->GenerateMesh(Chunk, Step); + + if (!bShuttingDown.load(std::memory_order_relaxed)) + { + ProcessQueue.Enqueue(Result); + } + }); + } + + UE_LOG(LogTemp, Verbose, TEXT("[VoxelWorld] RemeshDirtyChunks: %d coords, %d queued"), + DirtyCoords.Num(), PendingChunkCoord.Num()); +} diff --git a/Source/VoxelForge/Public/MarchingCubesTables.h b/Source/VoxelForge/Public/MarchingCubesTables.h new file mode 100644 index 0000000..2dc2cc6 --- /dev/null +++ b/Source/VoxelForge/Public/MarchingCubesTables.h @@ -0,0 +1,344 @@ +// MarchingCubesTables.h +// Standard lookup tables for the Marching Cubes algorithm +// +// These tables are reference data — everyone uses the same ones. +// Originally from Paul Bourke's Marching Cubes implementation. +// +// CUBE CORNER LAYOUT: +// ------------------- +// 4-------5 +// /| /| +// / | / | +// 7-------6 | +// | 0----|--1 +// | / | / +// |/ |/ +// 3-------2 +// +// Corner indices and their positions relative to (X, Y, Z): +// 0: (0, 0, 0) 4: (0, 0, 1) +// 1: (1, 0, 0) 5: (1, 0, 1) +// 2: (1, 1, 0) 6: (1, 1, 1) +// 3: (0, 1, 0) 7: (0, 1, 1) +// +// EDGE INDICES: +// ------------- +// Edge 0: corner 0 - corner 1 (bottom front) +// Edge 1: corner 1 - corner 2 (bottom right) +// Edge 2: corner 2 - corner 3 (bottom back) +// Edge 3: corner 3 - corner 0 (bottom left) +// Edge 4: corner 4 - corner 5 (top front) +// Edge 5: corner 5 - corner 6 (top right) +// Edge 6: corner 6 - corner 7 (top back) +// Edge 7: corner 7 - corner 4 (top left) +// Edge 8: corner 0 - corner 4 (vertical left-front) +// Edge 9: corner 1 - corner 5 (vertical right-front) +// Edge 10: corner 2 - corner 6 (vertical right-back) +// Edge 11: corner 3 - corner 7 (vertical left-back) + +#pragma once + +#include "CoreMinimal.h" + +// EdgeTable[caseIndex] = 12-bit bitmask +// Each bit tells you if that edge is intersected by the surface. +// Example: EdgeTable[1] = 0x109 = 0000 0001 0000 1001 +// → edges 0, 3, and 8 are intersected +static const int32 MCEdgeTable[256] = { + 0x000, 0x109, 0x203, 0x30a, 0x406, 0x50f, 0x605, 0x70c, + 0x80c, 0x905, 0xa0f, 0xb06, 0xc0a, 0xd03, 0xe09, 0xf00, + 0x190, 0x099, 0x393, 0x29a, 0x596, 0x49f, 0x795, 0x69c, + 0x99c, 0x895, 0xb9f, 0xa96, 0xd9a, 0xc93, 0xf99, 0xe90, + 0x230, 0x339, 0x033, 0x13a, 0x636, 0x73f, 0x435, 0x53c, + 0xa3c, 0xb35, 0x83f, 0x936, 0xe3a, 0xf33, 0xc39, 0xd30, + 0x3a0, 0x2a9, 0x1a3, 0x0aa, 0x7a6, 0x6af, 0x5a5, 0x4ac, + 0xbac, 0xaa5, 0x9af, 0x8a6, 0xfaa, 0xea3, 0xda9, 0xca0, + 0x460, 0x569, 0x663, 0x76a, 0x066, 0x16f, 0x265, 0x36c, + 0xc6c, 0xd65, 0xe6f, 0xf66, 0x86a, 0x963, 0xa69, 0xb60, + 0x5f0, 0x4f9, 0x7f3, 0x6fa, 0x1f6, 0x0ff, 0x3f5, 0x2fc, + 0xdfc, 0xcf5, 0xfff, 0xef6, 0x9fa, 0x8f3, 0xbf9, 0xaf0, + 0x650, 0x759, 0x453, 0x55a, 0x256, 0x35f, 0x055, 0x15c, + 0xe5c, 0xf55, 0xc5f, 0xd56, 0xa5a, 0xb53, 0x859, 0x950, + 0x7c0, 0x6c9, 0x5c3, 0x4ca, 0x3c6, 0x2cf, 0x1c5, 0x0cc, + 0xfcc, 0xec5, 0xdcf, 0xcc6, 0xbca, 0xac3, 0x9c9, 0x8c0, + 0x8c0, 0x9c9, 0xac3, 0xbca, 0xcc6, 0xdcf, 0xec5, 0xfcc, + 0x0cc, 0x1c5, 0x2cf, 0x3c6, 0x4ca, 0x5c3, 0x6c9, 0x7c0, + 0x950, 0x859, 0xb53, 0xa5a, 0xd56, 0xc5f, 0xf55, 0xe5c, + 0x15c, 0x055, 0x35f, 0x256, 0x55a, 0x453, 0x759, 0x650, + 0xaf0, 0xbf9, 0x8f3, 0x9fa, 0xef6, 0xfff, 0xcf5, 0xdfc, + 0x2fc, 0x3f5, 0x0ff, 0x1f6, 0x6fa, 0x7f3, 0x4f9, 0x5f0, + 0xb60, 0xa69, 0x963, 0x86a, 0xf66, 0xe6f, 0xd65, 0xc6c, + 0x36c, 0x265, 0x16f, 0x066, 0x76a, 0x663, 0x569, 0x460, + 0xca0, 0xda9, 0xea3, 0xfaa, 0x8a6, 0x9af, 0xaa5, 0xbac, + 0x4ac, 0x5a5, 0x6af, 0x7a6, 0x0aa, 0x1a3, 0x2a9, 0x3a0, + 0xd30, 0xc39, 0xf33, 0xe3a, 0x936, 0x83f, 0xb35, 0xa3c, + 0x53c, 0x435, 0x73f, 0x636, 0x13a, 0x033, 0x339, 0x230, + 0xe90, 0xf99, 0xc93, 0xd9a, 0xa96, 0xb9f, 0x895, 0x99c, + 0x69c, 0x795, 0x49f, 0x596, 0x29a, 0x393, 0x099, 0x190, + 0xf00, 0xe09, 0xd03, 0xc0a, 0xb06, 0xa0f, 0x905, 0x80c, + 0x70c, 0x605, 0x50f, 0x406, 0x30a, 0x203, 0x109, 0x000 +}; + +// TriTable[caseIndex][up to 16] +// Lists edge indices that form triangles, in groups of 3. +// -1 terminates the list. +// Example: TriTable[1] = {0, 8, 3, -1, ...} +// → one triangle using edges 0, 8, and 3 +static const int32 MCTriTable[256][16] = { + {-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 8, 3, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 1, 9, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 8, 3, 9, 8, 1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 2, 10, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 8, 3, 1, 2, 10, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 9, 2, 10, 0, 2, 9, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 2, 8, 3, 2, 10, 8, 10, 9, 8, -1, -1, -1, -1, -1, -1, -1}, + { 3, 11, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 11, 2, 8, 11, 0, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 9, 0, 2, 3, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 11, 2, 1, 9, 11, 9, 8, 11, -1, -1, -1, -1, -1, -1, -1}, + { 3, 10, 1, 11, 10, 3, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 10, 1, 0, 8, 10, 8, 11, 10, -1, -1, -1, -1, -1, -1, -1}, + { 3, 9, 0, 3, 11, 9, 11, 10, 9, -1, -1, -1, -1, -1, -1, -1}, + { 9, 8, 10, 10, 8, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 4, 7, 8, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 4, 3, 0, 7, 3, 4, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 1, 9, 8, 4, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 4, 1, 9, 4, 7, 1, 7, 3, 1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 2, 10, 8, 4, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 3, 4, 7, 3, 0, 4, 1, 2, 10, -1, -1, -1, -1, -1, -1, -1}, + { 9, 2, 10, 9, 0, 2, 8, 4, 7, -1, -1, -1, -1, -1, -1, -1}, + { 2, 10, 9, 2, 9, 7, 2, 7, 3, 7, 9, 4, -1, -1, -1, -1}, + { 8, 4, 7, 3, 11, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {11, 4, 7, 11, 2, 4, 2, 0, 4, -1, -1, -1, -1, -1, -1, -1}, + { 9, 0, 1, 8, 4, 7, 2, 3, 11, -1, -1, -1, -1, -1, -1, -1}, + { 4, 7, 11, 9, 4, 11, 9, 11, 2, 9, 2, 1, -1, -1, -1, -1}, + { 3, 10, 1, 3, 11, 10, 7, 8, 4, -1, -1, -1, -1, -1, -1, -1}, + { 1, 11, 10, 1, 4, 11, 1, 0, 4, 7, 11, 4, -1, -1, -1, -1}, + { 4, 7, 8, 9, 0, 11, 9, 11, 10, 11, 0, 3, -1, -1, -1, -1}, + { 4, 7, 11, 4, 11, 9, 9, 11, 10, -1, -1, -1, -1, -1, -1, -1}, + { 9, 5, 4, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 9, 5, 4, 0, 8, 3, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 5, 4, 1, 5, 0, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 8, 5, 4, 8, 3, 5, 3, 1, 5, -1, -1, -1, -1, -1, -1, -1}, + { 1, 2, 10, 9, 5, 4, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 3, 0, 8, 1, 2, 10, 4, 9, 5, -1, -1, -1, -1, -1, -1, -1}, + { 5, 2, 10, 5, 4, 2, 4, 0, 2, -1, -1, -1, -1, -1, -1, -1}, + { 2, 10, 5, 3, 2, 5, 3, 5, 4, 3, 4, 8, -1, -1, -1, -1}, + { 9, 5, 4, 2, 3, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 11, 2, 0, 8, 11, 4, 9, 5, -1, -1, -1, -1, -1, -1, -1}, + { 0, 5, 4, 0, 1, 5, 2, 3, 11, -1, -1, -1, -1, -1, -1, -1}, + { 2, 1, 5, 2, 5, 8, 2, 8, 11, 4, 8, 5, -1, -1, -1, -1}, + {10, 3, 11, 10, 1, 3, 9, 5, 4, -1, -1, -1, -1, -1, -1, -1}, + { 4, 9, 5, 0, 8, 1, 8, 10, 1, 8, 11, 10, -1, -1, -1, -1}, + { 5, 4, 0, 5, 0, 11, 5, 11, 10, 11, 0, 3, -1, -1, -1, -1}, + { 5, 4, 8, 5, 8, 10, 10, 8, 11, -1, -1, -1, -1, -1, -1, -1}, + { 9, 7, 8, 5, 7, 9, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 9, 3, 0, 9, 5, 3, 5, 7, 3, -1, -1, -1, -1, -1, -1, -1}, + { 0, 7, 8, 0, 1, 7, 1, 5, 7, -1, -1, -1, -1, -1, -1, -1}, + { 1, 5, 3, 3, 5, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 9, 7, 8, 9, 5, 7, 10, 1, 2, -1, -1, -1, -1, -1, -1, -1}, + {10, 1, 2, 9, 5, 0, 5, 3, 0, 5, 7, 3, -1, -1, -1, -1}, + { 8, 0, 2, 8, 2, 5, 8, 5, 7, 10, 5, 2, -1, -1, -1, -1}, + { 2, 10, 5, 2, 5, 3, 3, 5, 7, -1, -1, -1, -1, -1, -1, -1}, + { 7, 9, 5, 7, 8, 9, 3, 11, 2, -1, -1, -1, -1, -1, -1, -1}, + { 9, 5, 7, 9, 7, 2, 9, 2, 0, 2, 7, 11, -1, -1, -1, -1}, + { 2, 3, 11, 0, 1, 8, 1, 7, 8, 1, 5, 7, -1, -1, -1, -1}, + {11, 2, 1, 11, 1, 7, 7, 1, 5, -1, -1, -1, -1, -1, -1, -1}, + { 9, 5, 8, 8, 5, 7, 10, 1, 3, 10, 3, 11, -1, -1, -1, -1}, + { 5, 7, 0, 5, 0, 9, 7, 11, 0, 1, 0, 10, 11, 10, 0, -1}, + {11, 10, 0, 11, 0, 3, 10, 5, 0, 8, 0, 7, 5, 7, 0, -1}, + {11, 10, 5, 7, 11, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {10, 6, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 8, 3, 5, 10, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 9, 0, 1, 5, 10, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 8, 3, 1, 9, 8, 5, 10, 6, -1, -1, -1, -1, -1, -1, -1}, + { 1, 6, 5, 2, 6, 1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 6, 5, 1, 2, 6, 3, 0, 8, -1, -1, -1, -1, -1, -1, -1}, + { 9, 6, 5, 9, 0, 6, 0, 2, 6, -1, -1, -1, -1, -1, -1, -1}, + { 5, 9, 8, 5, 8, 2, 5, 2, 6, 3, 2, 8, -1, -1, -1, -1}, + { 2, 3, 11, 10, 6, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {11, 0, 8, 11, 2, 0, 10, 6, 5, -1, -1, -1, -1, -1, -1, -1}, + { 0, 1, 9, 2, 3, 11, 5, 10, 6, -1, -1, -1, -1, -1, -1, -1}, + { 5, 10, 6, 1, 9, 2, 9, 11, 2, 9, 8, 11, -1, -1, -1, -1}, + { 6, 3, 11, 6, 5, 3, 5, 1, 3, -1, -1, -1, -1, -1, -1, -1}, + { 0, 8, 11, 0, 11, 5, 0, 5, 1, 5, 11, 6, -1, -1, -1, -1}, + { 3, 11, 6, 0, 3, 6, 0, 6, 5, 0, 5, 9, -1, -1, -1, -1}, + { 6, 5, 9, 6, 9, 11, 11, 9, 8, -1, -1, -1, -1, -1, -1, -1}, + { 5, 10, 6, 4, 7, 8, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 4, 3, 0, 4, 7, 3, 6, 5, 10, -1, -1, -1, -1, -1, -1, -1}, + { 1, 9, 0, 5, 10, 6, 8, 4, 7, -1, -1, -1, -1, -1, -1, -1}, + {10, 6, 5, 1, 9, 7, 1, 7, 3, 7, 9, 4, -1, -1, -1, -1}, + { 6, 1, 2, 6, 5, 1, 4, 7, 8, -1, -1, -1, -1, -1, -1, -1}, + { 1, 2, 5, 5, 2, 6, 3, 0, 4, 3, 4, 7, -1, -1, -1, -1}, + { 8, 4, 7, 9, 0, 5, 0, 6, 5, 0, 2, 6, -1, -1, -1, -1}, + { 7, 3, 9, 7, 9, 4, 3, 2, 9, 5, 9, 6, 2, 6, 9, -1}, + { 3, 11, 2, 7, 8, 4, 10, 6, 5, -1, -1, -1, -1, -1, -1, -1}, + { 5, 10, 6, 4, 7, 2, 4, 2, 0, 2, 7, 11, -1, -1, -1, -1}, + { 0, 1, 9, 4, 7, 8, 2, 3, 11, 5, 10, 6, -1, -1, -1, -1}, + { 9, 2, 1, 9, 11, 2, 9, 4, 11, 7, 11, 4, 5, 10, 6, -1}, + { 8, 4, 7, 3, 11, 5, 3, 5, 1, 5, 11, 6, -1, -1, -1, -1}, + { 5, 1, 11, 5, 11, 6, 1, 0, 11, 7, 11, 4, 0, 4, 11, -1}, + { 0, 5, 9, 0, 6, 5, 0, 3, 6, 11, 6, 3, 8, 4, 7, -1}, + { 6, 5, 9, 6, 9, 11, 4, 7, 9, 7, 11, 9, -1, -1, -1, -1}, + {10, 4, 9, 6, 4, 10, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 4, 10, 6, 4, 9, 10, 0, 8, 3, -1, -1, -1, -1, -1, -1, -1}, + {10, 0, 1, 10, 6, 0, 6, 4, 0, -1, -1, -1, -1, -1, -1, -1}, + { 8, 3, 1, 8, 1, 6, 8, 6, 4, 6, 1, 10, -1, -1, -1, -1}, + { 1, 4, 9, 1, 2, 4, 2, 6, 4, -1, -1, -1, -1, -1, -1, -1}, + { 3, 0, 8, 1, 2, 9, 2, 4, 9, 2, 6, 4, -1, -1, -1, -1}, + { 0, 2, 4, 4, 2, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 8, 3, 2, 8, 2, 4, 4, 2, 6, -1, -1, -1, -1, -1, -1, -1}, + {10, 4, 9, 10, 6, 4, 11, 2, 3, -1, -1, -1, -1, -1, -1, -1}, + { 0, 8, 2, 2, 8, 11, 4, 9, 10, 4, 10, 6, -1, -1, -1, -1}, + { 3, 11, 2, 0, 1, 6, 0, 6, 4, 6, 1, 10, -1, -1, -1, -1}, + { 6, 4, 1, 6, 1, 10, 4, 8, 1, 2, 1, 11, 8, 11, 1, -1}, + { 9, 6, 4, 9, 3, 6, 9, 1, 3, 11, 6, 3, -1, -1, -1, -1}, + { 8, 11, 1, 8, 1, 0, 11, 6, 1, 9, 1, 4, 6, 4, 1, -1}, + { 3, 11, 6, 3, 6, 0, 0, 6, 4, -1, -1, -1, -1, -1, -1, -1}, + { 6, 4, 8, 11, 6, 8, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 7, 10, 6, 7, 8, 10, 8, 9, 10, -1, -1, -1, -1, -1, -1, -1}, + { 0, 7, 3, 0, 10, 7, 0, 9, 10, 6, 7, 10, -1, -1, -1, -1}, + {10, 6, 7, 1, 10, 7, 1, 7, 8, 1, 8, 0, -1, -1, -1, -1}, + {10, 6, 7, 10, 7, 1, 1, 7, 3, -1, -1, -1, -1, -1, -1, -1}, + { 1, 2, 6, 1, 6, 8, 1, 8, 9, 8, 6, 7, -1, -1, -1, -1}, + { 2, 6, 9, 2, 9, 1, 6, 7, 9, 0, 9, 3, 7, 3, 9, -1}, + { 7, 8, 0, 7, 0, 6, 6, 0, 2, -1, -1, -1, -1, -1, -1, -1}, + { 7, 3, 2, 6, 7, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 2, 3, 11, 10, 6, 8, 10, 8, 9, 8, 6, 7, -1, -1, -1, -1}, + { 2, 0, 7, 2, 7, 11, 0, 9, 7, 6, 7, 10, 9, 10, 7, -1}, + { 1, 8, 0, 1, 7, 8, 1, 10, 7, 6, 7, 10, 2, 3, 11, -1}, + {11, 2, 1, 11, 1, 7, 10, 6, 1, 6, 7, 1, -1, -1, -1, -1}, + { 8, 9, 6, 8, 6, 7, 9, 1, 6, 11, 6, 3, 1, 3, 6, -1}, + { 0, 9, 1, 11, 6, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 7, 8, 0, 7, 0, 6, 3, 11, 0, 11, 6, 0, -1, -1, -1, -1}, + { 7, 11, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 7, 6, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 3, 0, 8, 11, 7, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 1, 9, 11, 7, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 8, 1, 9, 8, 3, 1, 11, 7, 6, -1, -1, -1, -1, -1, -1, -1}, + {10, 1, 2, 6, 11, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 2, 10, 3, 0, 8, 6, 11, 7, -1, -1, -1, -1, -1, -1, -1}, + { 2, 9, 0, 2, 10, 9, 6, 11, 7, -1, -1, -1, -1, -1, -1, -1}, + { 6, 11, 7, 2, 10, 3, 10, 8, 3, 10, 9, 8, -1, -1, -1, -1}, + { 7, 2, 3, 6, 2, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 7, 0, 8, 7, 6, 0, 6, 2, 0, -1, -1, -1, -1, -1, -1, -1}, + { 2, 7, 6, 2, 3, 7, 0, 1, 9, -1, -1, -1, -1, -1, -1, -1}, + { 1, 6, 2, 1, 8, 6, 1, 9, 8, 8, 7, 6, -1, -1, -1, -1}, + {10, 7, 6, 10, 1, 7, 1, 3, 7, -1, -1, -1, -1, -1, -1, -1}, + {10, 7, 6, 1, 7, 10, 1, 8, 7, 1, 0, 8, -1, -1, -1, -1}, + { 0, 3, 7, 0, 7, 10, 0, 10, 9, 6, 10, 7, -1, -1, -1, -1}, + { 7, 6, 10, 7, 10, 8, 8, 10, 9, -1, -1, -1, -1, -1, -1, -1}, + { 6, 8, 4, 11, 8, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 3, 6, 11, 3, 0, 6, 0, 4, 6, -1, -1, -1, -1, -1, -1, -1}, + { 8, 6, 11, 8, 4, 6, 9, 0, 1, -1, -1, -1, -1, -1, -1, -1}, + { 9, 4, 6, 9, 6, 3, 9, 3, 1, 11, 3, 6, -1, -1, -1, -1}, + { 6, 8, 4, 6, 11, 8, 2, 10, 1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 2, 10, 3, 0, 11, 0, 6, 11, 0, 4, 6, -1, -1, -1, -1}, + { 4, 11, 8, 4, 6, 11, 0, 2, 9, 2, 10, 9, -1, -1, -1, -1}, + {10, 9, 3, 10, 3, 2, 9, 4, 3, 11, 3, 6, 4, 6, 3, -1}, + { 8, 2, 3, 8, 4, 2, 4, 6, 2, -1, -1, -1, -1, -1, -1, -1}, + { 0, 4, 2, 4, 6, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 9, 0, 2, 3, 4, 2, 4, 6, 4, 3, 8, -1, -1, -1, -1}, + { 1, 9, 4, 1, 4, 2, 2, 4, 6, -1, -1, -1, -1, -1, -1, -1}, + { 8, 1, 3, 8, 6, 1, 8, 4, 6, 6, 10, 1, -1, -1, -1, -1}, + {10, 1, 0, 10, 0, 6, 6, 0, 4, -1, -1, -1, -1, -1, -1, -1}, + { 4, 6, 3, 4, 3, 8, 6, 10, 3, 0, 3, 9, 10, 9, 3, -1}, + {10, 9, 4, 6, 10, 4, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 4, 9, 5, 7, 6, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 8, 3, 4, 9, 5, 11, 7, 6, -1, -1, -1, -1, -1, -1, -1}, + { 5, 0, 1, 5, 4, 0, 7, 6, 11, -1, -1, -1, -1, -1, -1, -1}, + {11, 7, 6, 8, 3, 4, 3, 5, 4, 3, 1, 5, -1, -1, -1, -1}, + { 9, 5, 4, 10, 1, 2, 7, 6, 11, -1, -1, -1, -1, -1, -1, -1}, + { 6, 11, 7, 1, 2, 10, 0, 8, 3, 4, 9, 5, -1, -1, -1, -1}, + { 7, 6, 11, 5, 4, 10, 4, 2, 10, 4, 0, 2, -1, -1, -1, -1}, + { 3, 4, 8, 3, 5, 4, 3, 2, 5, 10, 5, 2, 11, 7, 6, -1}, + { 7, 2, 3, 7, 6, 2, 5, 4, 9, -1, -1, -1, -1, -1, -1, -1}, + { 9, 5, 4, 0, 8, 6, 0, 6, 2, 6, 8, 7, -1, -1, -1, -1}, + { 3, 6, 2, 3, 7, 6, 1, 5, 0, 5, 4, 0, -1, -1, -1, -1}, + { 6, 2, 8, 6, 8, 7, 2, 1, 8, 4, 8, 5, 1, 5, 8, -1}, + { 9, 5, 4, 10, 1, 6, 1, 7, 6, 1, 3, 7, -1, -1, -1, -1}, + { 1, 6, 10, 1, 7, 6, 1, 0, 7, 8, 7, 0, 9, 5, 4, -1}, + { 4, 0, 10, 4, 10, 5, 0, 3, 10, 6, 10, 7, 3, 7, 10, -1}, + { 7, 6, 10, 7, 10, 8, 5, 4, 10, 4, 8, 10, -1, -1, -1, -1}, + { 6, 9, 5, 6, 11, 9, 11, 8, 9, -1, -1, -1, -1, -1, -1, -1}, + { 3, 6, 11, 0, 6, 3, 0, 5, 6, 0, 9, 5, -1, -1, -1, -1}, + { 0, 11, 8, 0, 5, 11, 0, 1, 5, 5, 6, 11, -1, -1, -1, -1}, + { 6, 11, 3, 6, 3, 5, 5, 3, 1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 2, 10, 9, 5, 11, 9, 11, 8, 11, 5, 6, -1, -1, -1, -1}, + { 0, 11, 3, 0, 6, 11, 0, 9, 6, 5, 6, 9, 1, 2, 10, -1}, + {11, 8, 5, 11, 5, 6, 8, 0, 5, 10, 5, 2, 0, 2, 5, -1}, + { 6, 11, 3, 6, 3, 5, 2, 10, 3, 10, 5, 3, -1, -1, -1, -1}, + { 5, 8, 9, 5, 2, 8, 5, 6, 2, 3, 8, 2, -1, -1, -1, -1}, + { 9, 5, 6, 9, 6, 0, 0, 6, 2, -1, -1, -1, -1, -1, -1, -1}, + { 1, 5, 8, 1, 8, 0, 5, 6, 8, 3, 8, 2, 6, 2, 8, -1}, + { 1, 5, 6, 2, 1, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 3, 6, 1, 6, 10, 3, 8, 6, 5, 6, 9, 8, 9, 6, -1}, + {10, 1, 0, 10, 0, 6, 9, 5, 0, 5, 6, 0, -1, -1, -1, -1}, + { 0, 3, 8, 5, 6, 10, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {10, 5, 6, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {11, 5, 10, 7, 5, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {11, 5, 10, 11, 7, 5, 8, 3, 0, -1, -1, -1, -1, -1, -1, -1}, + { 5, 11, 7, 5, 10, 11, 1, 9, 0, -1, -1, -1, -1, -1, -1, -1}, + {10, 7, 5, 10, 11, 7, 9, 8, 1, 8, 3, 1, -1, -1, -1, -1}, + {11, 1, 2, 11, 7, 1, 7, 5, 1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 8, 3, 1, 2, 7, 1, 7, 5, 7, 2, 11, -1, -1, -1, -1}, + { 9, 7, 5, 9, 2, 7, 9, 0, 2, 2, 11, 7, -1, -1, -1, -1}, + { 7, 5, 2, 7, 2, 11, 5, 9, 2, 3, 2, 8, 9, 8, 2, -1}, + { 2, 5, 10, 2, 3, 5, 3, 7, 5, -1, -1, -1, -1, -1, -1, -1}, + { 8, 2, 0, 8, 5, 2, 8, 7, 5, 10, 2, 5, -1, -1, -1, -1}, + { 9, 0, 1, 5, 10, 3, 5, 3, 7, 3, 10, 2, -1, -1, -1, -1}, + { 9, 8, 2, 9, 2, 1, 8, 7, 2, 10, 2, 5, 7, 5, 2, -1}, + { 1, 3, 5, 3, 7, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 8, 7, 0, 7, 1, 1, 7, 5, -1, -1, -1, -1, -1, -1, -1}, + { 9, 0, 3, 9, 3, 5, 5, 3, 7, -1, -1, -1, -1, -1, -1, -1}, + { 9, 8, 7, 5, 9, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 5, 8, 4, 5, 10, 8, 10, 11, 8, -1, -1, -1, -1, -1, -1, -1}, + { 5, 0, 4, 5, 11, 0, 5, 10, 11, 11, 3, 0, -1, -1, -1, -1}, + { 0, 1, 9, 8, 4, 10, 8, 10, 11, 10, 4, 5, -1, -1, -1, -1}, + {10, 11, 4, 10, 4, 5, 11, 3, 4, 9, 4, 1, 3, 1, 4, -1}, + { 2, 5, 1, 2, 8, 5, 2, 11, 8, 4, 5, 8, -1, -1, -1, -1}, + { 0, 4, 11, 0, 11, 3, 4, 5, 11, 2, 11, 1, 5, 1, 11, -1}, + { 0, 2, 5, 0, 5, 9, 2, 11, 5, 4, 5, 8, 11, 8, 5, -1}, + { 9, 4, 5, 2, 11, 3, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 2, 5, 10, 3, 5, 2, 3, 4, 5, 3, 8, 4, -1, -1, -1, -1}, + { 5, 10, 2, 5, 2, 4, 4, 2, 0, -1, -1, -1, -1, -1, -1, -1}, + { 3, 10, 2, 3, 5, 10, 3, 8, 5, 4, 5, 8, 0, 1, 9, -1}, + { 5, 10, 2, 5, 2, 4, 1, 9, 2, 9, 4, 2, -1, -1, -1, -1}, + { 8, 4, 5, 8, 5, 3, 3, 5, 1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 4, 5, 1, 0, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 8, 4, 5, 8, 5, 3, 9, 0, 5, 0, 3, 5, -1, -1, -1, -1}, + { 9, 4, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 4, 11, 7, 4, 9, 11, 9, 10, 11, -1, -1, -1, -1, -1, -1, -1}, + { 0, 8, 3, 4, 9, 7, 9, 11, 7, 9, 10, 11, -1, -1, -1, -1}, + { 1, 10, 11, 1, 11, 4, 1, 4, 0, 7, 4, 11, -1, -1, -1, -1}, + { 3, 1, 4, 3, 4, 8, 1, 10, 4, 7, 4, 11, 10, 11, 4, -1}, + { 4, 11, 7, 9, 11, 4, 9, 2, 11, 9, 1, 2, -1, -1, -1, -1}, + { 9, 7, 4, 9, 11, 7, 9, 1, 11, 2, 11, 1, 0, 8, 3, -1}, + {11, 7, 4, 11, 4, 2, 2, 4, 0, -1, -1, -1, -1, -1, -1, -1}, + {11, 7, 4, 11, 4, 2, 8, 3, 4, 3, 2, 4, -1, -1, -1, -1}, + { 2, 9, 10, 2, 7, 9, 2, 3, 7, 7, 4, 9, -1, -1, -1, -1}, + { 9, 10, 7, 9, 7, 4, 10, 2, 7, 8, 7, 0, 2, 0, 7, -1}, + { 3, 7, 10, 3, 10, 2, 7, 4, 10, 1, 10, 0, 4, 0, 10, -1}, + { 1, 10, 2, 8, 7, 4, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 4, 9, 1, 4, 1, 7, 7, 1, 3, -1, -1, -1, -1, -1, -1, -1}, + { 4, 9, 1, 4, 1, 7, 0, 8, 1, 8, 7, 1, -1, -1, -1, -1}, + { 4, 0, 3, 7, 4, 3, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 4, 8, 7, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 9, 10, 8, 10, 11, 8, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 3, 0, 9, 3, 9, 11, 11, 9, 10, -1, -1, -1, -1, -1, -1, -1}, + { 0, 1, 10, 0, 10, 8, 8, 10, 11, -1, -1, -1, -1, -1, -1, -1}, + { 3, 1, 10, 11, 3, 10, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 2, 11, 1, 11, 9, 9, 11, 8, -1, -1, -1, -1, -1, -1, -1}, + { 3, 0, 9, 3, 9, 11, 1, 2, 9, 2, 11, 9, -1, -1, -1, -1}, + { 0, 2, 11, 8, 0, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 3, 2, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 2, 3, 8, 2, 8, 10, 10, 8, 9, -1, -1, -1, -1, -1, -1, -1}, + { 9, 10, 2, 0, 9, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 2, 3, 8, 2, 8, 10, 0, 1, 8, 1, 10, 8, -1, -1, -1, -1}, + { 1, 10, 2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 1, 3, 8, 9, 1, 8, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 9, 1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + { 0, 3, 8, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}, + {-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1} +}; diff --git a/Source/VoxelForge/Public/VoxelAtmosphereManager.h b/Source/VoxelForge/Public/VoxelAtmosphereManager.h new file mode 100644 index 0000000..17c7968 --- /dev/null +++ b/Source/VoxelForge/Public/VoxelAtmosphereManager.h @@ -0,0 +1,62 @@ +// VoxelAtmosphereManager.h +// Whole-strate ambiance: a managed height fog + skylight driven by the strate the +// PLAYER is in, plus persistent ceiling/floor "layer" actors (e.g. seas of clouds) +// that follow the player in XY and hug the strate boundaries. +// +// Unlike the per-chunk content spawner, this is PLAYER-scoped: it updates only when +// the player changes strate, and the layer actors are single persistent instances — +// no spawn/unload churn with chunk streaming. + +#pragma once + +#include "CoreMinimal.h" +#include "VoxelAtmosphereManager.generated.h" + +class UVoxelStrateManager; +class UVoxelStrateDefinition; +class UExponentialHeightFogComponent; +class USkyLightComponent; + +UCLASS() +class VOXELFORGE_API UVoxelAtmosphereManager : public UObject +{ + GENERATED_BODY() + +public: + /** Create the managed fog + skylight components on the owner actor. */ + void Initialize(AActor* InOwner, UVoxelStrateManager* InStrateManager); + + /** Call each frame with the player's world position. Cheap: only reacts on strate change. */ + void UpdateForPlayer(const FVector& PlayerWorldPos); + + /** Tear down spawned layer actors + reset (season reset / shutdown). */ + void Reset(); + +private: + void ApplyStrate(const UVoxelStrateDefinition* Def); + + TWeakObjectPtr Owner; + + UPROPERTY() + UVoxelStrateManager* StrateManager = nullptr; + + UPROPERTY() + UExponentialHeightFogComponent* Fog = nullptr; + + UPROPERTY() + USkyLightComponent* Sky = nullptr; + + // Fully-authored atmosphere BP instance (replaces managed fog/sky when present). + UPROPERTY() + AActor* AtmosphereActorInstance = nullptr; + + // Persistent per-strate layer actor instances (single each). + UPROPERTY() + AActor* CeilingActor = nullptr; + + UPROPERTY() + AActor* FloorActor = nullptr; + + // Which strate's atmosphere is currently applied (INT32_MIN = none yet). + int32 CurrentStrateIndex = INT32_MIN; +}; diff --git a/Source/VoxelForge/Public/VoxelCaveMorphology.h b/Source/VoxelForge/Public/VoxelCaveMorphology.h new file mode 100644 index 0000000..6e5539b --- /dev/null +++ b/Source/VoxelForge/Public/VoxelCaveMorphology.h @@ -0,0 +1,395 @@ +// VoxelCaveMorphology.h +// Hash-based cave room/tunnel generation and SDF evaluation. +// +// HOW IT WORKS: +// ============= +// The world is divided into a coarse 2D grid (cell size = RoomSpacing). +// Each cell deterministically either contains a room or doesn't, based on +// a hash of (CellX, CellY, Seed, StrateIndex). No pre-computation needed — +// room positions are derived on-the-fly from the hash, so this works for +// infinite worlds without storing anything. +// +// ROOMS: +// - Origin room: guaranteed large room at (0,0) per strate — the hub +// - Hash rooms: ellipsoids, rounded boxes, or elongated capsules +// - All blended with smooth-min for organic junctions +// +// TUNNELS: +// - Connect pairs of rooms based on distance and probability +// - Tapered (variable min/max radius at each endpoint) +// - Curved (midpoint displaced perpendicular to tunnel direction) +// - Horizontal bias (vertical connections penalized) +// - Endpoint Z offset (tunnels enter rooms at different heights) +// - Origin room forces connections to all nearby rooms +// +// PIPELINE POSITION: +// Step 3 (cave warp) bends the SDF query coordinates before we evaluate. +// This step (Step 4) builds the SDF skeleton of rooms + tunnels. +// Surface roughness (Step 4b), terrain ops, and worm tunnels layer on top. + +#pragma once + +#include "CoreMinimal.h" + +// Forward declarations +struct FStrateGenerationParams; +struct FStrateTerrainOpEntry; +class UVoxelTerrainOpDefinition; + +//============================================================================= +// SDF PRIMITIVES +//============================================================================= +// Signed Distance Field functions. +// Return value: negative = inside the shape, positive = outside. +// The "distance" is how far from the surface — 0.0 = exactly on the surface. + +namespace VoxelSDF +{ + // Distance from point P to the surface of a sphere at Center with Radius. + // Negative when P is inside the sphere. + FORCEINLINE float Sphere(const FVector& P, const FVector& Center, float Radius) + { + return FVector::Dist(P, Center) - Radius; + } + + // Distance from P to an ellipsoid (squished sphere). + // Radii = (RadiusX, RadiusY, RadiusZ) — different size per axis. + // We scale the point into a unit sphere, compute distance, then scale back. + // This isn't an exact SDF (distances are approximate near the surface), + // but it's good enough for marching cubes density evaluation. + FORCEINLINE float Ellipsoid(const FVector& P, const FVector& Center, const FVector& Radii) + { + // Transform to unit sphere space + FVector Scaled = (P - Center) / Radii; + float ScaledDist = Scaled.Size(); + // Approximate: scale the distance back by the average radius + float AvgRadius = (Radii.X + Radii.Y + Radii.Z) / 3.0f; + return (ScaledDist - 1.0f) * AvgRadius; + } + + // Distance from P to a capsule (line segment with thickness). + // A and B are the endpoints, Radius is the tube thickness. + // This is an EXACT SDF — used for tunnels. + FORCEINLINE float Capsule(const FVector& P, const FVector& A, const FVector& B, float Radius) + { + FVector AB = B - A; + FVector AP = P - A; + // Project P onto line AB, clamped to [0,1] (stays within segment) + float T = FMath::Clamp(FVector::DotProduct(AP, AB) / FMath::Max(FVector::DotProduct(AB, AB), KINDA_SMALL_NUMBER), 0.0f, 1.0f); + // Closest point on the segment + FVector Closest = A + AB * T; + return FVector::Dist(P, Closest) - Radius; + } + + // Distance from P to a rounded box (box with rounded edges). + // Center = box center, HalfExtent = half-size per axis, Rounding = edge radius. + // This creates angular chambers (rectangular rooms with smooth corners). + // Without rounding (Rounding=0), it's a sharp box. + // With rounding, edges and corners are smoothed — essential for MC quality. + FORCEINLINE float RoundedBox(const FVector& P, const FVector& Center, const FVector& HalfExtent, float Rounding) + { + // Vector from center to P, take absolute value (box symmetry) + FVector D = FVector( + FMath::Abs(P.X - Center.X), + FMath::Abs(P.Y - Center.Y), + FMath::Abs(P.Z - Center.Z) + ) - HalfExtent; + + // Distance outside the box (positive when outside) + float Outside = FVector( + FMath::Max(D.X, 0.0f), + FMath::Max(D.Y, 0.0f), + FMath::Max(D.Z, 0.0f) + ).Size(); + + // Distance inside the box (negative when inside) + float Inside = FMath::Min(FMath::Max(D.X, FMath::Max(D.Y, D.Z)), 0.0f); + + return Outside + Inside - Rounding; + } + + // Distance from P to a tapered capsule (cone-like tube with spherical caps). + // A and B are endpoints, RadiusA and RadiusB are the tube radius at each end. + // When RadiusA != RadiusB, the tube narrows/widens along its length. + // This creates natural-looking corridors that aren't perfectly uniform. + FORCEINLINE float TaperedCapsule(const FVector& P, const FVector& A, const FVector& B, float RadiusA, float RadiusB) + { + FVector AB = B - A; + FVector AP = P - A; + float LenSq = FVector::DotProduct(AB, AB); + // T = how far along AB the closest point is (0 = at A, 1 = at B) + float T = (LenSq > KINDA_SMALL_NUMBER) + ? FMath::Clamp(FVector::DotProduct(AP, AB) / LenSq, 0.0f, 1.0f) + : 0.0f; + FVector Closest = A + AB * T; + // Radius interpolates from RadiusA at A to RadiusB at B + float R = FMath::Lerp(RadiusA, RadiusB, T); + return FVector::Dist(P, Closest) - R; + } + + // Polynomial smooth minimum — blends two SDF shapes together. + // K controls the blend radius: higher K = rounder, smoother junctions. + // With K=0, this is just FMath::Min(A, B) (hard intersection). + // With K=4, room/tunnel junctions look organic and natural. + FORCEINLINE float SmoothMin(float A, float B, float K) + { + if (K <= 0.0f) return FMath::Min(A, B); + float H = FMath::Max(K - FMath::Abs(A - B), 0.0f) / K; + return FMath::Min(A, B) - H * H * H * K * (1.0f / 6.0f); + } + + // Polynomial smooth maximum — dual of SmoothMin, enforces the larger value softly. + // Used for soft SDF intersections (e.g., floor cut planes) so the boundary + // rounds off near tunnel/pit openings instead of creating a hard lip. + // With K=0, this is just FMath::Max(A, B). + FORCEINLINE float SmoothMax(float A, float B, float K) + { + return -SmoothMin(-A, -B, K); + } +} + +//============================================================================= +// HASH FUNCTIONS +//============================================================================= +// Deterministic integer hashing for room placement. +// Given (CellX, CellY, Seed), always returns the same result. +// No randomness — fully reproducible across sessions and clients. + +namespace VoxelHash +{ + // Mix a single uint32 — scrambles bits to reduce patterns + FORCEINLINE uint32 Mix(uint32 X) + { + X ^= X >> 16; + X *= 0x45d9f3bu; + X ^= X >> 16; + X *= 0x45d9f3bu; + X ^= X >> 16; + return X; + } + + // Hash a 2D cell coordinate with a seed → deterministic uint32 + FORCEINLINE uint32 Cell(int32 CellX, int32 CellY, uint32 Seed) + { + uint32 H = Seed; + H ^= Mix((uint32)(CellX + 0x7FFFFFFF)); + H ^= Mix((uint32)(CellY + 0x7FFFFFFF) * 2654435761u); + return Mix(H); + } + + // Hash a pair of cells (for tunnel connectivity decisions). + // Order-independent: Cell(A,B) == Cell(B,A) so each tunnel is evaluated once. + FORCEINLINE uint32 Pair(int32 AX, int32 AY, int32 BX, int32 BY, uint32 Seed) + { + // Sort the pair so (A,B) and (B,A) give the same hash + int32 MinX = FMath::Min(AX, BX), MinY = FMath::Min(AY, BY); + int32 MaxX = FMath::Max(AX, BX), MaxY = FMath::Max(AY, BY); + uint32 H = Seed ^ 0xDEADBEEF; + H ^= Mix((uint32)(MinX + 0x7FFFFFFF)); + H ^= Mix((uint32)(MinY + 0x7FFFFFFF) * 2654435761u); + H ^= Mix((uint32)(MaxX + 0x7FFFFFFF) * 374761393u); + H ^= Mix((uint32)(MaxY + 0x7FFFFFFF) * 668265263u); + return Mix(H); + } + + // Convert a hash to a float in [0, 1) — uniform distribution + FORCEINLINE float ToFloat01(uint32 Hash) + { + return (float)(Hash & 0xFFFFFF) / (float)0x1000000; + } + + // Convert a hash to a float in [-1, 1) — for centering + FORCEINLINE float ToFloatSigned(uint32 Hash) + { + return ToFloat01(Hash) * 2.0f - 1.0f; + } +} + +//============================================================================= +// PER-CHUNK SDF CACHE +//============================================================================= +// The room list and tunnel connections are IDENTICAL for all voxels in a chunk. +// Without caching, EvaluateSDF rebuilds these 32,768 times per 32³ chunk: +// - Hash every grid cell in the search area +// - Determine room existence, position, size +// - Run O(N²) nearest-neighbor backbone +// - Decide O(N²) tunnel connections with hash lookups +// - Compute tunnel radii, Z offsets, midpoint warping +// +// With caching: build once, evaluate 32K times with just SDF math. +// This is the single biggest CPU performance win for cave generation. + +// A room in the cache — everything needed for per-voxel SDF evaluation. +// Internal details (CellX, CellY) used during cache building are NOT stored here. +struct FCachedRoom +{ + FVector Center; // World position of room center + float RadiusXY; // Horizontal radius (used for SDF + shape selection) + float RadiusZ; // Vertical radius (usually squished: RadiusXY * HeightRatio) + uint32 Hash; // Cell hash — used for deterministic shape selection per voxel + bool bIsOrigin; // True = origin room at (0,0), always uses ellipsoid shape + float CullRadiusSq; // Squared distance beyond which this room can't affect a voxel + + // Flat floor cut: base world Z of the soft floor plane. + // Applied via SmoothMax so tunnels/pits pass through without a hard lip seam. + // Set to -FLT_MAX when the hash-rolled floor cut = 1.0 (full bubble, no cut). + float FloorCutZ; + + // Floor relief: noise amplitude (voxels) and frequency for floor undulation. + // 0 strength = perfectly flat floor (just the cut plane, no variation). + // Stored per-room so EvaluateSDFCached can apply it without a params lookup. + float FloorReliefStrength; + float FloorReliefFrequency; + uint32 FloorSeed; // Deterministic seed offset for this room's floor noise + + // Per-room terrain operation, hash-rolled during BuildChunkCache from the + // strate's probability pool (FStrateTerrainOpEntry::Probability). + // null = this room has no terrain op (the "no op" slot in the probability pool). + // Raw pointer is safe — assets stay loaded for the entire generation session. + const UVoxelTerrainOpDefinition* RoomOp = nullptr; + + // Intensity scale for this room's op (from FStrateTerrainOpEntry::Weight). + // 1.0 = use op as configured, 0.5 = half intensity, 2.0 = double. + float RoomOpWeight = 1.0f; +}; + +// A pre-computed tunnel segment — all connection decisions and hash-derived +// properties (radius, Z offset, midpoint warp) are resolved during cache build. +struct FCachedTunnel +{ + FVector EndpointA; // First room's connection point (with Z offset) + FVector EndpointB; // Second room's connection point (with Z offset) + float RadiusA; // Tube radius at endpoint A + float RadiusB; // Tube radius at endpoint B + // Warped midpoint — only used if bHasMidpoint is true. + // Creates a two-segment curved path instead of a straight tube. + FVector Midpoint; + float RadiusMid; // Radius at the midpoint (average of A and B) + bool bHasMidpoint; // True when TunnelWarpStrength > 0 and tunnel is long enough + // Bounding sphere for quick per-voxel rejection + FVector BoundCenter; // Center of the bounding sphere + float BoundRadiusSq; // Squared radius — if voxel is further, skip this tunnel +}; + +// A pre-baked pit shaft — position and dimensions resolved during BuildChunkCache. +// +// Pits are included in the main SDF evaluation (SmoothMin alongside rooms and +// tunnels) rather than as a separate density subtraction. This lets SmoothMin +// handle the pit-to-room junction organically — same mechanism as tunnel junctions. +// No more hard seam at PitTopZ. BlendK drives the junction roundness and comes +// from the strate's SDFBlendRadius, making it tweakable in the data asset. +struct FCachedPit +{ + float CenterX, CenterY; // XY center in world coords (unwarped) + float TopZ; // World Z where the pit starts (anchored inside room air) + float Radius; // Shaft cylinder radius + float Depth; // Max depth the pit reaches below TopZ + float FlareDist; // Over how many voxels the opening flares (Radius * 2) + float FlareExtra; // Extra radius at the lip (Radius * 1) + float BaseDensity; // Carving strength — matches the strate's BaseDensity + float BlendK; // SmoothMin blend radius — set from Params.SDFBlendRadius + float BoundXYRadiusSq; // XY rejection: skip if (dx^2+dy^2) > this +}; + +// A pre-baked chimney shaft — inverse of a pit (carves upward from room ceiling). +struct FCachedChimney +{ + float CenterX, CenterY; + float BottomZ; // World Z where the chimney starts (anchored inside room air) + float Radius; + float Height; // How far the chimney reaches above BottomZ + float FlareDist; + float FlareExtra; + float BaseDensity; + float BlendK; // SmoothMin blend radius — set from Params.SDFBlendRadius + float BoundXYRadiusSq; +}; + +// A pre-baked column — vertical solid cylinder inside a room. +// Pre-baking fixes the NearestRoomIdx ownership issue for columns too: +// a column's XY position is fixed, but which room "owns" the voxel can change +// with depth, which would cause columns to appear/disappear mid-height. +struct FCachedColumn +{ + float CenterX, CenterY; + float Radius; + float BaseDensity; + float BoundXYRadiusSq; +}; + +// The complete SDF cache for a chunk region. +// Built once per chunk by BuildChunkCache(), then passed to EvaluateSDFCached() +// for every voxel in the chunk. Typically contains 5-15 rooms and 10-30 tunnels. +struct FChunkSDFCache +{ + TArray Rooms; + TArray Tunnels; + TArray Pits; + TArray Chimneys; + TArray Columns; +}; + +//============================================================================= +// CAVE MORPHOLOGY EVALUATOR +//============================================================================= +// Two-phase evaluation: BuildChunkCache (once per chunk) + EvaluateSDFCached (per voxel). +// The original EvaluateSDF is kept as a convenience wrapper for backward compatibility. + +namespace VoxelCaveMorphology +{ + // PHASE 1: Build the SDF cache for a rectangular region. + // Collects all rooms, computes nearest-neighbor backbone, decides tunnel + // connections, and pre-computes all tunnel geometry (radii, offsets, midpoints). + // Also hash-rolls a terrain op per room from the optional probability pool. + // + // Call once per chunk before the voxel loop. The region bounds should cover + // the chunk's XY extent PLUS CaveWarpStrength (warped queries can shift) + // PLUS a small margin for gradient normal sampling (~2 voxels). + // MaxInfluence (room/tunnel reach) is computed internally from Params. + // + // @param OutCache — filled with rooms and tunnels for this region + // @param SearchMinX/Y, SearchMaxX/Y — XY world bounds to search (expanded by caller) + // @param Params — strate generation parameters + // @param Seed — world seed + // @param StrateIndex — which strate (offsets hash for variety) + // @param TerrainOps — optional probability pool; null = no per-room ops + void BuildChunkCache( + FChunkSDFCache& OutCache, + float SearchMinX, float SearchMinY, + float SearchMaxX, float SearchMaxY, + const FStrateGenerationParams& Params, + uint32 Seed, int32 StrateIndex, + const TArray* TerrainOps = nullptr + ); + + // PHASE 2: Evaluate the SDF at a single world position using cached data. + // Loops through cached rooms and tunnels, computes SDF primitives, applies + // SmoothMin. Includes per-room/tunnel distance culling to skip primitives + // that are too far to contribute. + // + // @param WorldX, WorldY, WorldZ — position in voxel coordinates (may be warped) + // @param Cache — pre-built cache from BuildChunkCache + // @param SDFBlendRadius — SmoothMin blend radius (from Params.SDFBlendRadius) + // @param RoomShapeVariety — shape variety factor (from Params.RoomShapeVariety) + // @param OutNearestRoomIdx — optional out: index of the room with minimum SDF + // contribution. -1 if no room passed the cull test. + // Used by the terrain ops system to look up the + // per-room terrain op assigned to this voxel's room. + // @return negative = inside cave, positive = solid rock + float EvaluateSDFCached( + float WorldX, float WorldY, float WorldZ, + const FChunkSDFCache& Cache, + float SDFBlendRadius, + float RoomShapeVariety, + int32* OutNearestRoomIdx = nullptr + ); + + // CONVENIENCE WRAPPER: builds a temporary cache and evaluates in one call. + // Use this for one-off queries (debug visualization, single-point sampling). + // For chunk generation, use BuildChunkCache + EvaluateSDFCached instead. + float EvaluateSDF( + float WorldX, float WorldY, float WorldZ, + const FStrateGenerationParams& Params, + uint32 Seed, int32 StrateIndex + ); +} diff --git a/Source/VoxelForge/Public/VoxelChunk.h b/Source/VoxelForge/Public/VoxelChunk.h new file mode 100644 index 0000000..ea5334f --- /dev/null +++ b/Source/VoxelForge/Public/VoxelChunk.h @@ -0,0 +1,34 @@ +// VoxelChunk.h +// Identifiant léger de chunk. +// +// Rôle: dans un monde density-only (pas de blocs), le chunk n'a plus rien +// à stocker — la densité est évaluée à la volée par le générateur à partir +// des coordonnées monde. On garde un struct fin pour: +// - Servir de clé/valeur dans les collections de AVoxelWorld (Chunks, FChunkResult) +// - Fournir l'helper GetWorldPosition() au mesher +// - Laisser une place si on veut cacher des infos par chunk plus tard +// (index de strate, LOD courant, etc.) + +#pragma once + +#include "CoreMinimal.h" +#include "VoxelTypes.h" +#include "VoxelChunk.generated.h" + +USTRUCT(BlueprintType) +struct FVoxelChunk +{ + GENERATED_BODY() + + // Coordonnée de chunk dans la grille mondiale (peut être négative). + FIntVector ChunkCoord = FIntVector::ZeroValue; + + FVoxelChunk() = default; + explicit FVoxelChunk(const FIntVector& InCoord) : ChunkCoord(InCoord) {} + + // Coin (0,0,0) du chunk en espace monde (cm). + FVector GetWorldPosition() const + { + return ChunkToWorldPos(ChunkCoord); + } +}; diff --git a/Source/VoxelForge/Public/VoxelContentManager.h b/Source/VoxelForge/Public/VoxelContentManager.h new file mode 100644 index 0000000..c6c3c8c --- /dev/null +++ b/Source/VoxelForge/Public/VoxelContentManager.h @@ -0,0 +1,70 @@ +// VoxelContentManager.h +// Per-chunk content population: deterministic decoration/actor scatter from the +// strate content pools, plus self-contained aesthetic water surfaces. +// +// LIFECYCLE (driven by AVoxelWorld on the GAME THREAD): +// - PopulateChunk(coord, meshdata) after a chunk's mesh is applied +// - ClearChunk(coord) when a chunk unloads / is re-meshed +// - ClearAll() on regenerate / season reset +// +// DETERMINISM: every placement decision is a pure hash of (chunk, surface index, +// entry index, seed), so the same world re-populates identically. Spawning itself +// must run on the game thread (UWorld::SpawnActor), which it does — ApplyMeshToChunk +// is game-thread. + +#pragma once + +#include "CoreMinimal.h" +#include "VoxelTypes.h" +#include "VoxelContentManager.generated.h" + +class UVoxelStrateManager; +class UVoxelStrateDefinition; +class UStaticMesh; +class UStaticMeshComponent; + +UCLASS() +class VOXELFORGE_API UVoxelContentManager : public UObject +{ + GENERATED_BODY() + +public: + /** Wire up services. Owner is the AVoxelWorld actor that owns spawned content. */ + void Initialize(AActor* InOwner, UVoxelStrateManager* InStrateManager, int32 InSeed); + + /** Update the seed used for placement hashing (season reset). */ + void SetSeed(int32 InSeed) { Seed = InSeed; } + + /** Populate decorations + water for a chunk. Clears any previous content first. + * Decorations are only scattered at LOD 0 (near chunks); water is placed at any LOD. */ + void PopulateChunk(const FIntVector& ChunkCoord, const FVoxelMeshData& MeshData, int32 LODLevel = 0); + + /** Destroy all spawned content (actors + water plane) for one chunk. */ + void ClearChunk(const FIntVector& ChunkCoord); + + /** Destroy all spawned content for every chunk. */ + void ClearAll(); + +private: + void SpawnDecorations(const FIntVector& ChunkCoord, const FVoxelMeshData& MeshData, + const UVoxelStrateDefinition* Def, TArray>& Out); + void SpawnWater(const FIntVector& ChunkCoord, const UVoxelStrateDefinition* Def); + + TWeakObjectPtr Owner; + + UPROPERTY() + UVoxelStrateManager* StrateManager = nullptr; + + int32 Seed = 0; + + // Engine unit plane (/Engine/BasicShapes/Plane) reused for every water surface. + UPROPERTY() + UStaticMesh* PlaneMesh = nullptr; + + // Spawned decoration/ambient actors per chunk (weak — they live in the level). + TMap>> SpawnedActors; + + // Water surface component per chunk. + UPROPERTY() + TMap WaterPlanes; +}; diff --git a/Source/VoxelForge/Public/VoxelDiffLayer.h b/Source/VoxelForge/Public/VoxelDiffLayer.h new file mode 100644 index 0000000..e01cc46 --- /dev/null +++ b/Source/VoxelForge/Public/VoxelDiffLayer.h @@ -0,0 +1,260 @@ +// VoxelDiffLayer.h +// Runtime terrain modification system (player carving & filling). +// +// HOW IT WORKS: +// ============= +// The procedural density function generates cave terrain deterministically. +// When the player carves or fills terrain, we DON'T modify the procedural +// output — instead, we store modifications as a "diff layer" on top. +// +// During density evaluation: +// Final density = Procedural density + Diff offset +// +// This keeps procedural generation deterministic while supporting freeform editing. +// Modifications are stored spatially (per-chunk) for O(1) lookup. +// +// MODIFICATION LIFECYCLE: +// 1. Player uses a tool at position P with radius R and strength S +// 2. ApplyModification() stores the mod in all overlapping chunks +// 3. Returns the list of affected chunk coords → world re-meshes those chunks +// 4. During re-meshing, GetDensityOffset() returns the combined diff at each voxel +// +// PERSISTENCE: +// Diffs are stored in memory. The game layer is responsible for saving/loading +// them to disk (server-side) and clearing them on season reset. + +#pragma once + +#include "CoreMinimal.h" +#include "VoxelTypes.h" +#include "VoxelDiffLayer.generated.h" + +/** + * EVoxelBrushShape — geometry of a modification brush. + * Sphere : Center + Radius. Classic round brush. + * Box : Center + BoxExtent (half-extents) + Falloff band. + * Capsule : segment Center→CapsuleEnd + Radius (tube) + Falloff band. Good for tunnels. + */ +UENUM(BlueprintType) +enum class EVoxelBrushShape : uint8 +{ + Sphere UMETA(DisplayName = "Sphere"), + Box UMETA(DisplayName = "Box"), + Capsule UMETA(DisplayName = "Capsule") +}; + +/** + * FVoxelModification — A single edit to the density field. + * + * Represents one brush stroke at a world position. Multiple overlapping + * modifications combine additively. + * + * Strength convention (INTERNAL density, before MC negate): + * Negative → subtract density → create air → CARVE (remove rock) + * Positive → add density → create solid → FILL (add rock) + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FVoxelModification +{ + GENERATED_BODY() + + // World-space center of the brush (endpoint A for Capsule). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification") + FVector Center = FVector::ZeroVector; + + // Sphere radius / capsule tube radius, in voxels. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification", meta = (ClampMin = "0.5")) + float Radius = 3.0f; + + // Density change: negative = carve (remove rock), positive = fill (add rock). + // -10 → strong carve (punches through BaseDensity=8) · +10 → strong fill + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification") + float Strength = -10.0f; + + // Brush geometry. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification") + EVoxelBrushShape Shape = EVoxelBrushShape::Sphere; + + // Box half-extents in voxels (Box shape only). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification", + meta = (EditCondition = "Shape == EVoxelBrushShape::Box")) + FVector BoxExtent = FVector(5.0f, 5.0f, 5.0f); + + // Capsule second endpoint in world voxel coords (Capsule shape only). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification", + meta = (EditCondition = "Shape == EVoxelBrushShape::Capsule")) + FVector CapsuleEnd = FVector::ZeroVector; + + // Soft falloff band (voxels) around the Box/Capsule surface for smooth edges. + // Sphere ignores this — its whole Radius is the falloff. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Modification", meta = (ClampMin = "0.1")) + float Falloff = 2.0f; + + // Conservative world-space AABB the brush can affect (used to find overlapping chunks). + void GetWorldBounds(FVector& OutMin, FVector& OutMax) const + { + switch (Shape) + { + case EVoxelBrushShape::Box: + { + const FVector E = BoxExtent + FVector(Falloff); + OutMin = Center - E; + OutMax = Center + E; + break; + } + case EVoxelBrushShape::Capsule: + { + const float Pad = Radius + Falloff; + OutMin = FVector(FMath::Min(Center.X, CapsuleEnd.X), FMath::Min(Center.Y, CapsuleEnd.Y), FMath::Min(Center.Z, CapsuleEnd.Z)) - FVector(Pad); + OutMax = FVector(FMath::Max(Center.X, CapsuleEnd.X), FMath::Max(Center.Y, CapsuleEnd.Y), FMath::Max(Center.Z, CapsuleEnd.Z)) + FVector(Pad); + break; + } + case EVoxelBrushShape::Sphere: + default: + OutMin = Center - FVector(Radius); + OutMax = Center + FVector(Radius); + break; + } + } +}; + +/** + * UVoxelDiffLayer — Stores all player modifications as a spatial diff. + * + * Owned by AVoxelWorld, passed to the generator for density evaluation. + * Modifications are grouped by chunk coord for fast spatial lookup — + * when evaluating density for a chunk, we only check modifications + * stored in that chunk (no global scan). + * + * When a modification sphere overlaps multiple chunks, it's stored in ALL + * of them so each chunk's lookup is self-contained. + */ +UCLASS() +class VOXELFORGE_API UVoxelDiffLayer : public UObject +{ + GENERATED_BODY() + +public: + //========================================================================= + // BUDGET CONFIGURATION + //========================================================================= + + /** + * Set the budget limits for player modifications. + * Called by AVoxelWorld during BeginPlay from VoxelSettings values. + * A value of 0 means unlimited (no cap). + * + * @param InMaxMods - Max number of operations (0 = unlimited) + * @param InMaxRadius - Max brush radius per operation + * @param InMaxVolume - Max cumulative brush volume in cubic voxels (0 = unlimited) + */ + void SetBudget(int32 InMaxMods, float InMaxRadius, float InMaxVolume); + + /** + * Check if a modification would be allowed under the current budget. + * Does NOT consume budget — use this for UI feedback (greyed-out tool, etc.) + * + * @param Radius - The brush radius the player wants to use + * @return true if the modification is allowed + */ + bool CanModify(float Radius) const; + + /** + * Get remaining modification count (how many more operations the player can do). + * Returns -1 if unlimited. + */ + UFUNCTION(BlueprintPure, Category = "Voxel Diff Layer") + int32 GetRemainingModifications() const; + + /** + * Get remaining volume budget (cubic voxels of modification still allowed). + * Returns -1.0 if unlimited. + */ + UFUNCTION(BlueprintPure, Category = "Voxel Diff Layer") + float GetRemainingVolume() const; + + //========================================================================= + // APPLY MODIFICATIONS + //========================================================================= + + /** + * Apply a carve or fill operation. + * + * Stores the modification in every chunk the brush sphere overlaps. + * Returns the list of affected chunk coordinates so the world knows + * which chunks need re-meshing. + * + * Budget enforcement: if the modification would exceed any configured + * limit (count, radius, or volume), it is REJECTED and an empty array + * is returned. Check CanModify() first for UI feedback. + * + * @param Mod - The modification to apply + * @return Array of chunk coordinates that need re-meshing (empty if rejected) + */ + TArray ApplyModification(const FVoxelModification& Mod); + + //========================================================================= + // DENSITY EVALUATION + //========================================================================= + + /** + * Get the combined density offset from all modifications at a world position. + * + * Called per-voxel by the generator during density evaluation. + * Only checks modifications stored for the given chunk coordinate, + * so it's fast when most chunks have no modifications. + * + * Each modification contributes with a smoothstep falloff from center to edge. + * Multiple overlapping modifications combine additively. + * + * @param ChunkCoord - The chunk being evaluated (for spatial lookup) + * @param WorldX, WorldY, WorldZ - The voxel position + * @return Total density offset (negative = carve, positive = fill) + */ + float GetDensityOffset(const FIntVector& ChunkCoord, + float WorldX, float WorldY, float WorldZ) const; + + /** + * Check if a chunk has any modifications (fast reject for hot path). + * + * @param ChunkCoord - The chunk to check + * @return true if there are modifications affecting this chunk + */ + bool HasModifications(const FIntVector& ChunkCoord) const; + + //========================================================================= + // MANAGEMENT + //========================================================================= + + /** Clear ALL modifications (e.g., on season reset). */ + void Clear(); + + /** Get total number of stored modification entries (for debug/stats). */ + int32 GetTotalModificationCount() const; + + /** Get number of chunks that have modifications. */ + int32 GetModifiedChunkCount() const; + +private: + //========================================================================= + // STORAGE + //========================================================================= + + // Modifications grouped by chunk coordinate. + // Each chunk's list contains ALL modifications that overlap it + // (including mods centered in neighboring chunks whose radius reaches here). + TMap> ChunkMods; + + //========================================================================= + // BUDGET TRACKING + //========================================================================= + + // Configured limits (0 = unlimited) + int32 BudgetMaxMods = 0; + float BudgetMaxRadius = 50.0f; + float BudgetMaxVolume = 0.0f; + + // Running totals — reset when Clear() is called + int32 ModificationCount = 0; // Total operations applied + float AccumulatedVolume = 0.0f; // Sum of all brush sphere volumes (4/3 * PI * R^3) +}; diff --git a/Source/VoxelForge/Public/VoxelForgeModule.h b/Source/VoxelForge/Public/VoxelForgeModule.h new file mode 100644 index 0000000..d035eb1 --- /dev/null +++ b/Source/VoxelForge/Public/VoxelForgeModule.h @@ -0,0 +1,25 @@ +// VoxelForgeModule.h +// Module interface - this is boilerplate that every Unreal plugin needs + +#pragma once + +#include "CoreMinimal.h" +#include "Modules/ModuleManager.h" + +/** + * FVoxelForgeModule + * + * This is the module class for VoxelForge. Every Unreal plugin needs one. + * It handles startup and shutdown of the plugin. + * + * For our purposes, this is just boilerplate - the real code is elsewhere. + */ +class FVoxelForgeModule : public IModuleInterface +{ +public: + // Called when the plugin loads (game/editor starts) + virtual void StartupModule() override; + + // Called when the plugin unloads (game/editor closes) + virtual void ShutdownModule() override; +}; diff --git a/Source/VoxelForge/Public/VoxelGenerator.h b/Source/VoxelForge/Public/VoxelGenerator.h new file mode 100644 index 0000000..cc8c2ac --- /dev/null +++ b/Source/VoxelForge/Public/VoxelGenerator.h @@ -0,0 +1,123 @@ +// VoxelGenerator.h +// Fournit le champ de densité du monde (positif = solide, négatif = air). +// +// Pipeline density-only: plus de grille de blocs, plus de surface heightfield. +// Le mesher appelle GetDensityAt() par voxel pour reconstruire la géométrie. +// Toute la logique de caves vit dans GetDensityWithParams / GetSlabDensity, +// pilotée par les params de strate récupérés auprès du StrateManager. + +#pragma once + +#include "CoreMinimal.h" +#include "VoxelTypes.h" +#include "VoxelStrateTypes.h" +#include "VoxelGenerator.generated.h" + +// Forward decls (évite les includes transitifs) +class UVoxelSettings; +class UVoxelStrateManager; +class UVoxelDiffLayer; + +/** + * UVoxelGenerator + * + * Objet UObject léger — ne stocke pas de données lourdes. + * Tient juste un pointeur sur les settings (pour le seed) et les services + * dont il a besoin (StrateManager pour les params par chunk, DiffLayer pour + * les carves du joueur). + */ +UCLASS(BlueprintType) +class VOXELFORGE_API UVoxelGenerator : public UObject +{ + GENERATED_BODY() + +public: + //========================================================================= + // SEED (source unique: Settings->Seed) + //========================================================================= + // On copie juste le seed au démarrage pour éviter un déréférencement + // par voxel. Tout le reste vient des params de strate. + int32 Seed = 0; + + // Radius (voxels) of the guaranteed open vertical landing column carved at world + // XY (0,0) in every strate — the (0,0) descent spine. Copied from VoxelSettings. + // 0 disables the spine carve. + float OriginSpineRadius = 14.0f; + + //========================================================================= + // SERVICES (injectés par AVoxelWorld::BeginPlay) + //========================================================================= + + // Manager des strates — fournit les params de cave par chunk. + // Peut être nullptr: dans ce cas on utilise des params par défaut. + UPROPERTY() + const UVoxelStrateManager* StrateManager = nullptr; + + // Diff layer des modifications joueur (carve/fill). + // Peut être nullptr: dans ce cas pas de modifs appliquées. + UPROPERTY() + const UVoxelDiffLayer* DiffLayer = nullptr; + + void SetStrateManager(const UVoxelStrateManager* InManager) { StrateManager = InManager; } + void SetDiffLayer(const UVoxelDiffLayer* InDiffLayer) { DiffLayer = InDiffLayer; } + + // Copie le seed depuis les settings. Appelé au démarrage et lors d'un ChangeSeed. + void InitializeSettings(const UVoxelSettings* Settings); + + //========================================================================= + // API DE DENSITÉ + //========================================================================= + + /** + * Densité combinée (strates + diff du joueur). + * + * Convention de sortie (MC): négatif = solide, positif = air. + * C'est ce que le marching cubes attend comme champ scalaire. + * + * @param WorldX, WorldY, WorldZ - coordonnées monde en voxels (pas cm) + */ + float GetDensityAt(float WorldX, float WorldY, float WorldZ) const; + + /** + * Densité pour une strate TunnelNetwork (rooms + tunnels + worm noise). + * Utilisée en interne par GetDensityAt quand la strate est de ce type. + * Exposée pour permettre des tests isolés avec des params custom. + */ + float GetDensityWithParams(float WorldX, float WorldY, float WorldZ, + const FStrateGenerationParams& Params) const; + + /** + * Densité pour une strate Slab (FlatPlain / CrystalChamber). + * Vide horizontal entre un sol bruité et un plafond bruité. + */ + float GetSlabDensity(float WorldX, float WorldY, float WorldZ, + const FSlabGenerationParams& Params) const; + + /** + * Densité pour une strate Maze — couloirs étroits sur un treillis 3D déterministe. + * Pas de cache: évalué par voxel sur les quelques cellules voisines. + */ + float GetMazeDensity(float WorldX, float WorldY, float WorldZ, + const FMazeGenerationParams& Params) const; + + /** + * Densité pour une strate SurfaceWorld — terrain à ciel ouvert (collines, + * montagnes, plages) sous un plafond solide, avec nappe d'eau optionnelle. + */ + float GetSurfaceDensity(float WorldX, float WorldY, float WorldZ, + const FSurfaceGenerationParams& Params) const; + + /** + * Densité pour une strate VerticalShafts — puits verticaux pleine hauteur, + * vires horizontales, et connecteurs horizontaux occasionnels. + */ + float GetVerticalShaftDensity(float WorldX, float WorldY, float WorldZ, + const FVerticalShaftParams& Params) const; + + /** + * Densité pour une strate FloatingIslands — masses de terre suspendues dans + * un grand vide ouvert (îles flottantes), sommets aplatis, dessous rugueux. + */ + float GetFloatingIslandDensity(float WorldX, float WorldY, float WorldZ, + const FFloatingIslandParams& Params) const; +}; diff --git a/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h b/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h new file mode 100644 index 0000000..adf8d8d --- /dev/null +++ b/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h @@ -0,0 +1,71 @@ +// VoxelMarchingCubesMesher.h +// Mesh lisse à partir du champ de densité du générateur. +// +// Principe: +// 1. Chaque cellule du chunk (cube 2x2x2 voxels) lit la densité aux 8 coins. +// 2. Un index 8 bits indique quels coins sont au-dessus du IsoLevel. +// 3. Les tables MC (EdgeTable + TriTable) donnent les triangles à générer. +// 4. On interpole la position du vertex le long de chaque arête traversée. +// +// LOD: Step > 1 échantillonne moins souvent → moins de triangles à distance. + +#pragma once + +#include "CoreMinimal.h" +#include "VoxelTypes.h" // Pour FVoxelMeshData, CHUNK_SIZE, VOXEL_SIZE, etc. +#include "VoxelChunk.h" +#include "VoxelGenerator.h" +#include "VoxelMarchingCubesMesher.generated.h" + +UCLASS(BlueprintType) +class VOXELFORGE_API UVoxelMarchingCubesMesher : public UObject +{ + GENERATED_BODY() + +public: + /** + * Génère le mesh d'un chunk. + * + * @param Chunk - Le chunk à mesher (on n'utilise que ChunkCoord pour + * calculer les positions monde; la densité vient du générateur). + * @param Step - Pas d'échantillonnage (1=LOD0, 2=LOD1, 4=LOD2). + */ + FVoxelMeshData GenerateMesh(const FVoxelChunk& Chunk, int32 Step = 1); + + //========================================================================= + // SERVICES (injectés par AVoxelWorld) + //========================================================================= + + // Le générateur fournit la densité par voxel. + UPROPERTY() + const UVoxelGenerator* Generator = nullptr; + + void SetGenerator(const UVoxelGenerator* InGenerator) { Generator = InGenerator; } + + //========================================================================= + // SETTINGS + //========================================================================= + + // Seuil de l'isosurface dans le champ de densité. + // Convention MC: densité < IsoLevel = solide, >= = air. + float IsoLevel = 0.0f; + + // Distance d'échantillonnage (en voxels) pour calculer la normale par + // différence centrée du gradient. Plus petit = plus détaillé mais bruité. + float GradientOffset = 1.0f; + +protected: + //========================================================================= + // DENSITY + NORMAL SAMPLING + //========================================================================= + + // Lit la densité à une position locale (via le générateur en coords monde). + float GetDensity(const FVoxelChunk& Chunk, int32 X, int32 Y, int32 Z) const; + + // Normale lissée: gradient central du champ de densité (pointe solide→air). + FVector ComputeGradientNormal(float WorldX, float WorldY, float WorldZ) const; + + // Interpolation linéaire le long d'une arête: trouve où la surface + // traverse entre P1 (densité D1) et P2 (densité D2). + FVector InterpolateEdge(const FVector& P1, const FVector& P2, float D1, float D2) const; +}; diff --git a/Source/VoxelForge/Public/VoxelSettings.h b/Source/VoxelForge/Public/VoxelSettings.h new file mode 100644 index 0000000..65ad4ff --- /dev/null +++ b/Source/VoxelForge/Public/VoxelSettings.h @@ -0,0 +1,137 @@ +// VoxelSettings.h +// Settings du plugin VoxelForge. +// +// Data asset unique (assigné sur AVoxelWorld) qui regroupe tous les tuning +// du monde: seed, streaming, LOD, pool de strates, budgets de carving. + +#pragma once + +#include "CoreMinimal.h" +#include "Engine/DataAsset.h" +#include "VoxelStrateDefinition.h" +#include "VoxelSettings.generated.h" + +UCLASS(BlueprintType) +class UVoxelSettings : public UPrimaryDataAsset +{ + GENERATED_BODY() + +public: + + //========================================================================= + // STREAMING (distance de vue) + //========================================================================= + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming") + int32 ViewDistanceXY = 16; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming") + int32 ViewDistanceUp = 5; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming") + int32 ViewDistanceDown = 5; + + // Nombre max de tâches de génération/mesh en parallèle. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming") + int32 MaxConcurrentTasks = 16; + + // Nombre max de meshes appliqués par frame (évite les stutters d'upload GPU). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming") + int32 MaxMeshAppliesPerFrame = 4; + + //========================================================================= + // LOD + //========================================================================= + + // Distance en chunks pour LOD0 (pleine résolution, step=1). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|LOD") + int32 LOD0Distance = 4; + + // Distance en chunks pour LOD1 (demi-résolution, step=2). + // Au-delà → LOD2 (quart-résolution, step=4). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|LOD") + int32 LOD1Distance = 8; + + //========================================================================= + // RENDERING + //========================================================================= + + // Matériau global par défaut (peut être override par strate). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Rendering") + UMaterialInterface* VoxelMaterial; + + //========================================================================= + // STRATES + //========================================================================= + + // Seed du monde. Pilote toute la génération procédurale. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates") + int32 Seed = 0; + + // Numéro de saison (incrementé par ChangeSeed). Purement informatif côté gameplay. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates") + int32 CurrentSeason = 1; + + // Pool de définitions de strates disponibles pour l'assignation aléatoire. + // Chaque définition a sa propre hauteur — les strates ne sont PAS uniformes. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates") + TArray> StratePool; + + // Strates fixées: {index → définition}. Ex: {5, CrystalCaverns} = + // la strate 5 est toujours CrystalCaverns. Les autres sont tirées du pool. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates") + TMap> FixedStrates; + + // Nombre total de strates à générer vers le bas. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates", meta = (ClampMin = "1")) + int32 TotalStrates = 10; + + // Vertical SEPARATION between consecutive strates, in chunks of solid bedrock. + // 0 = strates touch (just the seals between them). Higher = a thick rock layer + // between each biome that the player must dig through at (0,0), and that the + // auto-carved passages tunnel across. + // 0 → adjacent layers (default) · 1-2 → a real bedrock band · 4+ → deep separation + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Strates", meta = (ClampMin = "0", ClampMax = "16")) + int32 InterStrateGapChunks = 0; + + //========================================================================= + // SPINE & INTER-STRATE CONNECTIONS (the (0,0) descent axis) + //========================================================================= + + // Radius (voxels) of the guaranteed open vertical "landing" column carved at + // world XY (0,0) in EVERY strate, regardless of archetype. This is the prepared + // space the player digs THROUGH the seal into when descending. 0 = disabled. + // 10-14 → cozy shaft (default) · 20+ → wide landing chamber + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Spine", meta = (ClampMin = "0.0")) + float OriginSpineRadius = 14.0f; + + // Auto-open the very top seal at (0,0) so the world starts with a hole to the + // surface (the entry shaft). Lower boundaries are NOT auto-opened — the player + // digs those. Disable for a fully sealed world the player must breach from above. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Spine") + bool bOpenSurfaceEntry = true; + + // NOTE: inter-strate tunnel count + shape (style, width, length, placement, worming, + // spiral, cascade) are now PER-STRATE — see UVoxelStrateDefinition::PassageConfig. + // Each strate controls its own descent tunnels to the layer below it. + + //========================================================================= + // CARVING (modifications du joueur) + //========================================================================= + + // Nombre max d'opérations de modification par saison. 0 = illimité. + // 500 = édition légère, 2000 = mining modéré, 0 = créatif. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Carving", + meta = (ClampMin = "0")) + int32 MaxModifications = 0; + + // Rayon max d'un brush (en voxels). Empêche les édits géants. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Carving", + meta = (ClampMin = "1.0")) + float MaxBrushRadius = 15.0f; + + // Volume total maximum (somme des volumes de brush). 0 = illimité. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Carving", + meta = (ClampMin = "0.0")) + float MaxTotalVolume = 0.0f; +}; diff --git a/Source/VoxelForge/Public/VoxelStrateDefinition.h b/Source/VoxelForge/Public/VoxelStrateDefinition.h new file mode 100644 index 0000000..4b97f0e --- /dev/null +++ b/Source/VoxelForge/Public/VoxelStrateDefinition.h @@ -0,0 +1,335 @@ +// VoxelStrateDefinition.h +// Data asset defining everything about a single strate (layer) type. +// +// HOW TO USE: +// ----------- +// 1. Right-click in Content Browser → Miscellaneous → Data Asset +// 2. Pick "VoxelStrateDefinition" as the class +// 3. Fill in the fields — cave shape, visuals, content lists, audio, gameplay tags +// 4. Reference it from VoxelSettings (StratePool or FixedStrates) +// +// Each asset is a strate TYPE (e.g., "CrystalCaverns"). +// Multiple strate slots can use the same definition. + +#pragma once + +#include "CoreMinimal.h" +#include "Engine/DataAsset.h" +#include "GameplayTagContainer.h" +#include "VoxelStrateTypes.h" +#include "VoxelStrateDefinition.generated.h" + +/** + * UVoxelStrateDefinition — The content bag for a strate type. + * + * This is the central piece of the strate system. Every field here + * defines what makes a strate unique: how its caves form, what it + * looks like, what lives in it, how it sounds, and what gameplay + * rules apply. + * + * EXAMPLE STRATES: + * - "Crystal Caverns": big open caves, blue fog, crystal decorations + * - "Tight Tunnels": narrow caves, dark, spider creatures + * - "Flooded Galleries": flat floors, water, diving required + */ +UCLASS(BlueprintType) +class VOXELFORGE_API UVoxelStrateDefinition : public UPrimaryDataAsset +{ + GENERATED_BODY() + +public: + //========================================================================= + // IDENTIFICATION + //========================================================================= + + // Human-readable name for this strate type (shown in editor and debug) + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Strate") + FText StrateName; + + // Short description for editor tooltips + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Strate", meta = (MultiLine = true)) + FText StrateDescription; + + //========================================================================= + // DIMENSIONS + //========================================================================= + + // How many chunks tall this strate is. + // Each strate can have a different height! + // Big open caverns = 6-8 chunks, tight tunnels = 3, vertical shaft = 12+ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Dimensions", meta = (ClampMin = "1", ClampMax = "32")) + int32 StrateHeightInChunks = 4; + + //========================================================================= + // BOUNDARY TRANSITION + //========================================================================= + // Controls how this strate blends with the strate BELOW it. + // Each boundary between two strates uses the UPPER strate's transition type. + // + // Gradient: Smooth param lerp — no visible boundary (default). + // Hard: Abrupt switch — creates a cliff/ledge at the boundary. + // Interleaved: 3D noise warps the boundary — fingers of one strate + // reach into the other for an organic, interlocking look. + // + // NOTE: The BOTTOM-MOST strate's transition type is unused (nothing below it). + + // Which transition style to use at this strate's lower boundary + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Boundary", + meta = (ToolTip = "How this strate transitions to the strate below it. Gradient = smooth blend, Hard = sharp cliff, Interleaved = noisy interlocking fingers.")) + EVoxelStrateTransition TransitionType = EVoxelStrateTransition::Gradient; + + // How many chunks the transition zone spans (used by Gradient and Interleaved). + // For Hard transitions this is ignored (effectively 0). + // Higher = wider, more gradual transition zone. + // 1 → very narrow transition (almost hard) + // 2 → moderate (good default) + // 4 → wide, gentle blend across many chunks + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Boundary", + meta = (ClampMin = "1", ClampMax = "8", EditCondition = "TransitionType != EVoxelStrateTransition::Hard")) + int32 TransitionBlendChunks = 2; + + //========================================================================= + // GENERATOR TYPE + //========================================================================= + // Controls which density strategy this strate uses. + // + // TunnelNetwork → classic rooms + tunnels + worm noise (FStrateGenerationParams below) + // FlatPlain → horizontal void between floor and ceiling (FSlabGenerationParams below) + // CrystalChamber → same as FlatPlain but ceiling has heavy downward formations + // + // Changing this shows/hides the relevant parameter sections below. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation") + ECaveGeneratorType GeneratorType = ECaveGeneratorType::TunnelNetwork; + + //========================================================================= + // TUNNEL NETWORK PARAMS (shown only for TunnelNetwork generator type) + //========================================================================= + // These params control how the density field is shaped for chunks in this strate. + // The generator reads these values to produce unique cave geometry per layer. + // Only relevant when GeneratorType == TunnelNetwork. + + // Used by TunnelNetwork (rooms+tunnels) AND Underwater (same rock, flooded). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation", + meta = (EditCondition = "GeneratorType == ECaveGeneratorType::TunnelNetwork || GeneratorType == ECaveGeneratorType::Underwater")) + FStrateGenerationParams GenerationParams; + + //========================================================================= + // SLAB PARAMS (shown only for FlatPlain and CrystalChamber generator types) + //========================================================================= + // Controls the floor height, ceiling height, roughness, and column density + // for open-void strates. Only relevant when GeneratorType is FlatPlain or + // CrystalChamber. Other generator types ignore these entirely. + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation", + meta = (EditCondition = "GeneratorType == ECaveGeneratorType::FlatPlain || GeneratorType == ECaveGeneratorType::CrystalChamber")) + FSlabGenerationParams SlabParams; + + //========================================================================= + // ARCHETYPE PARAMS (each shown only for its generator type) + //========================================================================= + // Tight branching corridors on a 3D lattice. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation", + meta = (EditCondition = "GeneratorType == ECaveGeneratorType::Maze")) + FMazeGenerationParams MazeParams; + + // Open-sky terrain: hills/mountains/plains/beaches under a high ceiling, with water. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation", + meta = (EditCondition = "GeneratorType == ECaveGeneratorType::SurfaceWorld")) + FSurfaceGenerationParams SurfaceParams; + + // Mostly-vertical shafts with ledges and sparse horizontal links. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation", + meta = (EditCondition = "GeneratorType == ECaveGeneratorType::VerticalShafts")) + FVerticalShaftParams VerticalShaftParams; + + // Suspended land masses floating in a large open void. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation", + meta = (EditCondition = "GeneratorType == ECaveGeneratorType::FloatingIslands")) + FFloatingIslandParams FloatingIslandParams; + + //========================================================================= + // DISTURBANCES (the "wow" layer — applies on top of ANY archetype) + //========================================================================= + // Chasms, natural bridges and rock ridges layered onto whatever the archetype + // produces, for surprise and variety. All features default to disabled. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Disturbances") + FStrateDisturbanceParams Disturbances; + + //========================================================================= + // INTER-STRATE PASSAGES (how THIS strate connects DOWN to the next) + //========================================================================= + // Auto-carved descent tunnels from this strate to the one below it: count, style + // (straight / worm / spiral / cascade), tapered width, length, placement. The (0,0) + // spine descent is separate (player-dug); these are the extra shortcuts. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Passages") + FStratePassageConfig PassageConfig; + + //========================================================================= + // WATER RENDERING (self-contained, aesthetic — no swim/flow simulation) + //========================================================================= + // The water table HEIGHT comes from the active generator params + // (FSurfaceGenerationParams::WaterLevelRelative or FStrateGenerationParams:: + // WaterLevelRelative). These fields control whether/how it is rendered. + + // Master switch for the water surface in this strate. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Water") + bool bHasWater = false; + + // Translucent material applied to the generated water surface planes. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Water", + meta = (EditCondition = "bHasWater")) + UMaterialInterface* WaterMaterial = nullptr; + + // Water tint/colour, available to the material as a parameter if it reads it. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Water", + meta = (EditCondition = "bHasWater")) + FLinearColor WaterColor = FLinearColor(0.02f, 0.08f, 0.12f, 0.8f); + + //========================================================================= + // TERRAIN OPERATIONS + //========================================================================= + // Weighted list of terrain operation data assets to apply in this strate. + // + // HOW TO USE: + // 1. Create terrain op assets (Content → Data Asset → VoxelTerrainOpDefinition) + // 2. Add entries here — pick an asset and set its Weight + // 3. Weight scales the op's intensity: 1.0 = as configured, 0.5 = half, 2.0 = double + // + // EXAMPLE: + // "Crystal Caverns" strate might reference: + // - DA_Op_TallColumns (Weight 1.0) → floor-to-ceiling pillars + // - DA_Op_WideDomes (Weight 0.8) → cathedral ceilings + // - DA_Op_LightScallop (Weight 0.5) → subtle erosion texture + // + // The same op asset can be shared across multiple strate definitions. + // At generation time, these are merged into FStrateGenerationParams + // before being passed to the density pipeline. + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Terrain Operations") + TArray TerrainOperations; + + //========================================================================= + // VISUALS + //========================================================================= + + // Material override for this strate (null = use the default VoxelMaterial) + // Allows each strate to have its own rock/ground look + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Visuals") + UMaterialInterface* OverrideMaterial = nullptr; + + // Fog color for this strate (used by a future fog system or post-process) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Visuals") + FLinearColor FogColor = FLinearColor(0.05f, 0.05f, 0.1f, 1.0f); + + // Fog density (0 = no fog, 1 = very thick) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Visuals", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float FogDensity = 0.3f; + + // Ambient light tint (color of the "base" underground light) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Visuals") + FLinearColor AmbientLightColor = FLinearColor(0.1f, 0.1f, 0.15f, 1.0f); + + // Ambient light brightness + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Visuals", meta = (ClampMin = "0.0")) + float AmbientLightIntensity = 0.5f; + + // Render the managed height fog volumetrically (softer, light-shaft-friendly, costlier). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Visuals") + bool bVolumetricFog = false; + + //========================================================================= + // ATMOSPHERE LAYERS (persistent ceiling / floor actors — e.g. seas of clouds) + //========================================================================= + // These spawn ONCE when the player enters this strate (NOT per chunk, so they + // don't churn with streaming) and follow the player in XY while hugging the + // strate's ceiling / floor. Perfect for the "sky island" look: open space + // between two cloud seas. Author the look as a Blueprint (a large plane with a + // cloud/fog material, or a Niagara system). Leave null to disable. + // This is also how you get "fog emitting from ceiling and floor". + + // FULL atmosphere override: a Blueprint you author with your own ExponentialHeightFog, + // SkyLight, PostProcessVolume, SkyAtmosphere, etc. — tuned exactly how you want. + // When set, this REPLACES the simple Fog/Ambient knobs above for this strate (the + // plugin's managed fog + skylight are disabled so there's no double-up). The plugin + // spawns one instance when you enter the strate and keeps it anchored to the player. + // Leave null to use the simple knobs instead. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Atmosphere") + TSubclassOf AtmosphereActor; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Atmosphere") + TSubclassOf CeilingLayerActor; + + // Vertical offset (Unreal units) from the strate ceiling. Negative = below the ceiling. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Atmosphere", + meta = (EditCondition = "CeilingLayerActor != nullptr")) + float CeilingLayerZOffset = -200.0f; + + // Rotation applied to the ceiling layer actor each update. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Atmosphere", + meta = (EditCondition = "CeilingLayerActor != nullptr")) + FRotator CeilingLayerRotation = FRotator::ZeroRotator; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Atmosphere") + TSubclassOf FloorLayerActor; + + // Vertical offset (Unreal units) from the strate floor. Positive = above the floor. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Atmosphere", + meta = (EditCondition = "FloorLayerActor != nullptr")) + float FloorLayerZOffset = 200.0f; + + // Rotation applied to the floor layer actor each update. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Atmosphere", + meta = (EditCondition = "FloorLayerActor != nullptr")) + FRotator FloorLayerRotation = FRotator::ZeroRotator; + + //========================================================================= + // CONTENT LISTS + //========================================================================= + // These arrays define what gets spawned in this strate. + // Future systems (decoration placer, creature spawner) consume these lists. + + // Decorations: actors placed ON cave surfaces (stalactites, mushrooms, crystals) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Content") + TArray Decorations; + + // Ambient actors: things floating in cave space (fog volumes, particles, lights) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Content") + TArray AmbientActors; + + // Creatures: enemies/NPCs that spawn in this strate + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Content") + TArray Creatures; + + //========================================================================= + // AUDIO + //========================================================================= + + // Ambient sound loop for this strate (dripping water, wind, etc.) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Audio") + USoundBase* AmbientSound = nullptr; + + // Background music for this strate + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Audio") + USoundBase* Music = nullptr; + + // Music volume multiplier + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Audio", meta = (ClampMin = "0.0", ClampMax = "2.0")) + float MusicVolume = 1.0f; + + //========================================================================= + // GAMEPLAY TAGS + //========================================================================= + // Extensible tag system for gameplay rules. + // Game code checks these tags to apply effects (damage, movement restrictions, etc.) + // + // Examples: + // "Strate.Hazard.Flooded" → water everywhere, need diving gear + // "Strate.Hazard.Toxic" → air damages without mask + // "Strate.Lighting.Dark" → almost no ambient light + // "Strate.Rule.NeedEquipment.Rope" → climbing areas + // + // The plugin doesn't act on these — it just stores them. + // Your game code reads them and applies the appropriate effects. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Gameplay") + FGameplayTagContainer GameplayTags; +}; diff --git a/Source/VoxelForge/Public/VoxelStrateManager.h b/Source/VoxelForge/Public/VoxelStrateManager.h new file mode 100644 index 0000000..80c4ae0 --- /dev/null +++ b/Source/VoxelForge/Public/VoxelStrateManager.h @@ -0,0 +1,324 @@ +// VoxelStrateManager.h +// Runtime system that maps world Z coordinates to strate definitions. +// +// HOW IT WORKS: +// ------------- +// 1. At world startup, Initialize() builds the strate layout: +// - Fixed strates go into their assigned slots +// - Random strates are shuffled from a pool using the world seed +// - Each strate's height (in chunks) is accumulated to compute Z ranges +// +// 2. During gameplay, GetStrateAt(WorldZ) returns which strate definition +// applies at a given depth. GetGenerationParams() returns blended params +// (with smooth transitions at strate boundaries). +// +// 3. The generator, world manager, and future systems (decorations, creatures, +// audio) all query this manager to know "what goes here." + +#pragma once + +#include "CoreMinimal.h" +#include "VoxelStrateTypes.h" +#include "VoxelStrateDefinition.h" +#include "VoxelStrateManager.generated.h" + +class UVoxelSettings; + +/** + * FVoxelPassage — A navigable connection between two strates. + * + * Generated deterministically from the world seed during Initialize(). + * Each passage is a path from a point in strate A down through the + * boundary seal to a point in strate B. It's carved as a series of + * capsule SDFs in the density function. + * + * Passages are the PRIMARY progression path — players find these + * through exploration. The elevator is a FAST TRAVEL shortcut unlocked later. + */ +USTRUCT() +struct FVoxelPassage +{ + GENERATED_BODY() + + // Which strates this passage connects (indices into StrateLayout) + int32 UpperStrateIndex = 0; // The strate above + int32 LowerStrateIndex = 0; // The strate below + + // World-space positions of the passage endpoints + FVector UpperPoint = FVector::ZeroVector; // Entry in upper strate + FVector LowerPoint = FVector::ZeroVector; // Exit in lower strate + + // An optional midpoint for non-straight passages (sloped, curved). + // Used by SlopedTunnel and CrackCrevice types. Ignored when ControlPoints is populated. + FVector MidPoint = FVector::ZeroVector; + + // Passage dimensions — how wide the carved tunnel is (in voxels). + // Varies by type: VerticalShaft ~7-8, SpiralDescent ~4, CrackCrevice ~2-3, others ~5. + float Radius = 5.0f; + + // Whether this passage uses a midpoint (curved/sloped) or is straight. + // Only relevant when ControlPoints is empty — if ControlPoints has entries, + // the passage is evaluated as a capsule chain along those points instead. + bool bHasMidPoint = false; + + // The shape/style of this passage. Determines how control points are generated + // and how the passage feels to navigate (shaft, spiral, ledges, crack, etc.). + EVoxelPassageType PassageType = EVoxelPassageType::SlopedTunnel; + + // Multi-segment control points for complex passage shapes. + // Used by SpiralDescent (helix points) and CascadingDrops (ledge+drop points). + // When non-empty, the SDF evaluator walks this array as a capsule chain + // instead of using Upper→Mid→Lower logic. + // Empty for simple types (SlopedTunnel, VerticalShaft, CrackCrevice). + TArray ControlPoints; + + // Per-control-point tube radius (parallel to ControlPoints) for tapered tunnels — + // wider chambers at the mouths, a squeeze in the middle, etc. If shorter than + // ControlPoints, the uniform Radius is used as a fallback. + TArray ControlRadii; + + // Bounding sphere enclosing the whole passage (+ radius + blend), in voxel coords. + // Computed once in GeneratePassages; lets EvaluateModifierSDF reject far voxels with + // a single squared-distance test instead of walking every segment per voxel. + FVector BoundCenter = FVector::ZeroVector; + float BoundRadiusSq = 0.0f; +}; + +/** + * FStrateSlot — Runtime info for one strate in the layout. + * + * Created during Initialize() and stored in the StrateLayout array. + * Each slot knows its definition, its Z-range in chunk coordinates, + * and its index in the sequence. + */ +USTRUCT() +struct FStrateSlot +{ + GENERATED_BODY() + + // The strate definition asset (content bag) + UPROPERTY() + UVoxelStrateDefinition* Definition = nullptr; + + // Strate index (0 = topmost, increases downward) + int32 StrateIndex = 0; + + // Z chunk range (inclusive). TopZ > BottomZ since Z decreases downward. + // Example: TopZ = 0, BottomZ = -3 means this strate spans chunks Z=0 to Z=-3 + int32 TopChunkZ = 0; + int32 BottomChunkZ = 0; + + // Height in chunks (from the definition) + int32 HeightInChunks = 4; +}; + +/** + * UVoxelStrateManager — Maps depth to strate definitions at runtime. + */ +UCLASS(BlueprintType) +class VOXELFORGE_API UVoxelStrateManager : public UObject +{ + GENERATED_BODY() + +public: + //========================================================================= + // INITIALIZATION + //========================================================================= + + /** + * Build the strate layout from settings and seed. + * + * STEPS: + * 1. For each strate index (0 to TotalStrates-1): + * - If it's in FixedStrates → use that definition + * - Else → pick from the shuffled pool (seeded random) + * 2. Stack strates top-to-bottom, accumulating heights + * 3. Store the layout in StrateLayout array + * + * @param Settings - VoxelSettings with pool/fixed strate config + * @param WorldSeed - Seed for randomizing non-fixed strates + */ + void Initialize(UVoxelSettings* Settings, int32 WorldSeed); + + //========================================================================= + // QUERIES + //========================================================================= + + /** + * Get the strate definition at a world Z coordinate. + * + * @param WorldZ - Z position in world space (Unreal units) + * @return The strate definition, or nullptr if above/below all strates + */ + UFUNCTION(BlueprintCallable, Category = "Strate") + UVoxelStrateDefinition* GetStrateAt(float WorldZ) const; + + /** + * Get the strate index at a world Z coordinate. + * + * @param WorldZ - Z position in world space + * @return Strate index (0 = topmost), or -1 if outside strate range + */ + UFUNCTION(BlueprintCallable, Category = "Strate") + int32 GetStrateIndex(float WorldZ) const; + + /** + * Get the strate definition for a specific chunk coordinate. + * + * @param ChunkCoord - The chunk position + * @return The strate definition, or nullptr if outside strate range + */ + UVoxelStrateDefinition* GetStrateForChunk(const FIntVector& ChunkCoord) const; + + /** + * Get generation params for a chunk, with boundary blending. + * + * BLENDING CONCEPT: + * When a chunk is near a strate boundary (within BlendChunks of the edge), + * the params are lerped between the two adjacent strates. This prevents + * hard visual seams where one strate ends and another begins. + * + * @param ChunkCoord - The chunk position + * @return Blended generation params for this chunk + */ + FStrateGenerationParams GetGenerationParams(const FIntVector& ChunkCoord) const; + + /** + * Get the generator type for a specific chunk. + * + * Returns TunnelNetwork if the chunk is outside all strates or if the + * strate definition is null. The generator uses this to pick which + * density function to call (GetDensityWithParams vs GetSlabDensity). + * + * @param ChunkCoord - The chunk position + * @return The ECaveGeneratorType for the strate containing this chunk + */ + ECaveGeneratorType GetGeneratorTypeForChunk(const FIntVector& ChunkCoord) const; + + /** + * True if this chunk is in the solid-bedrock GAP between two strates (inside the + * overall stack's Z range but not in any strate slot). Chunks above the top strate + * or below the bottom strate are NOT gaps (they're open air). Driven by + * VoxelSettings::InterStrateGapChunks. + */ + bool IsGapChunk(const FIntVector& ChunkCoord) const; + + /** + * Get slab generation params for a chunk, with runtime Z bounds filled in. + * + * Used when GetGeneratorTypeForChunk returns FlatPlain or CrystalChamber. + * Does NOT blend between adjacent strates — slab strates use Hard transition + * at their boundaries (blending between fundamentally different generator types + * is not meaningful). + * + * @param ChunkCoord - The chunk position + * @return FSlabGenerationParams with StrateTopWorldZ / StrateBottomWorldZ set + */ + FSlabGenerationParams GetSlabParamsForChunk(const FIntVector& ChunkCoord) const; + + /** + * Per-archetype param getters. Each copies the designer params from the strate + * definition and fills in the runtime Z bounds (voxel coords). Like the slab + * getter, these do NOT blend across boundaries — fundamentally different + * archetypes meet at Hard boundaries. + */ + FMazeGenerationParams GetMazeParamsForChunk(const FIntVector& ChunkCoord) const; + FSurfaceGenerationParams GetSurfaceParamsForChunk(const FIntVector& ChunkCoord) const; + FVerticalShaftParams GetVerticalShaftParamsForChunk(const FIntVector& ChunkCoord) const; + FFloatingIslandParams GetFloatingIslandParamsForChunk(const FIntVector& ChunkCoord) const; + + /** + * World-space Z (voxel coords) of this strate's water surface, or -FLT_MAX if the + * strate has no water. Derived from the active archetype's WaterLevelRelative and + * the strate's Z range. Used by the water render system. + */ + float GetWaterLevelWorldZForChunk(const FIntVector& ChunkCoord) const; + + /** + * Unreal-unit (cm) world Z range of the strate containing WorldZ (Unreal units). + * OutTopZ = ceiling, OutBottomZ = floor. Returns false if WorldZ is outside all strates. + * Used by the atmosphere system to glue ceiling/floor layers to the strate. + */ + bool GetStrateUnrealZRange(float WorldZ, float& OutTopZ, float& OutBottomZ) const; + + /** + * Disturbance params for a chunk (chasms/bridges/ridges), with runtime Z bounds + * filled in. Returns an all-disabled default when outside the strate range. + */ + FStrateDisturbanceParams GetDisturbanceParamsForChunk(const FIntVector& ChunkCoord) const; + + /** + * Get the full strate layout (for debug display or UI). + */ + const TArray& GetLayout() const { return StrateLayout; } + + /** + * Get the total number of strates in the layout. + */ + int32 GetNumStrates() const { return StrateLayout.Num(); } + + //========================================================================= + // PASSAGES & ELEVATOR + //========================================================================= + + /** + * Evaluate the SDF for all passages and the elevator shaft at a world position. + * Called by the density function to carve these modifiers into the terrain. + * + * Returns: negative = inside a passage/shaft, positive = solid rock. + * FLT_MAX = no modifier nearby. + */ + float EvaluateModifierSDF(float WorldX, float WorldY, float WorldZ) const; + + /** Get all generated passages (for debug display). */ + const TArray& GetPassages() const { return Passages; } + +protected: + //========================================================================= + // INTERNAL DATA + //========================================================================= + + // The stacked strate layout (index 0 = topmost strate) + UPROPERTY() + TArray StrateLayout; + + // Passages connecting consecutive strates + TArray Passages; + + // How many chunks at strate boundaries are blended (transition zone) + int32 BlendChunks = 2; + + // World seed (stored for passage generation) + int32 CachedSeed = 0; + + // Whether to auto-open the (0,0) entry shaft through the top of strate 0. + // Copied from VoxelSettings::bOpenSurfaceEntry during Initialize(). + bool bOpenSurfaceEntry = true; + + // Radius (voxels) of the surface entry shaft at (0,0). Copied from + // VoxelSettings::OriginSpineRadius so the entry matches the spine landing. + float OriginSpineRadius = 14.0f; + + // Solid-bedrock gap between consecutive strates, in chunks. Copied from + // VoxelSettings::InterStrateGapChunks during Initialize(). + int32 InterStrateGapChunks = 0; + + // Per-passage tunnel shape now lives on each UVoxelStrateDefinition::PassageConfig + // (the upper strate of each boundary controls its own descent tunnels). + + //========================================================================= + // HELPERS + //========================================================================= + + // Find which slot a chunk Z coordinate falls into. + // Returns INDEX into StrateLayout, or -1 if not found. + int32 FindSlotIndexForChunkZ(int32 ChunkZ) const; + + // Build FStrateGenerationParams from a strate definition: + // copies base GenerationParams, then applies all referenced terrain op assets. + // This is the single point where terrain op data assets are merged into params. + static FStrateGenerationParams BuildParamsFromDefinition(const UVoxelStrateDefinition* Definition); + + // Generate passages between consecutive strates. Called from Initialize(). + void GeneratePassages(); +}; diff --git a/Source/VoxelForge/Public/VoxelStrateTypes.h b/Source/VoxelForge/Public/VoxelStrateTypes.h new file mode 100644 index 0000000..d493af0 --- /dev/null +++ b/Source/VoxelForge/Public/VoxelStrateTypes.h @@ -0,0 +1,1697 @@ +// VoxelStrateTypes.h +// Shared structs and enums for the strate (layer) system. +// +// WHAT ARE STRATES? +// ----------------- +// The Depths world is divided into vertical layers called "strates". +// Each strate is radically different — unique cave shapes, decorations, +// creatures, materials, and even gameplay rules (flooded, toxic, etc.). +// +// This file defines the data structures that carry strate information +// between systems (generator, world manager, future decoration/creature spawners). + +#pragma once + +#include "CoreMinimal.h" +#include "GameplayTagContainer.h" +#include "VoxelStrateTypes.generated.h" + +//============================================================================= +// ENUMS +//============================================================================= + +/** + * EVoxelPassageType — The shape/style of an inter-strate passage. + * + * Each passage connecting two adjacent strates is randomly assigned one of + * these types during GeneratePassages(). The type determines the geometry + * of the carved tunnel (control point layout, radius, overall feel). + * + * Types: + * SlopedTunnel — Angled bore with a horizontal midpoint offset (default/legacy). + * VerticalShaft — Straight or near-straight drop between strates. + * SpiralDescent — Helical corkscrew path winding downward. + * CascadingDrops — Series of short vertical pits connected by horizontal ledges. + * CrackCrevice — Narrow fracture passage, tight squeeze through rock. + */ +UENUM(BlueprintType) +enum class EVoxelPassageType : uint8 +{ + // Angled bore connecting two layers with a horizontal midpoint offset. + // Two capsule segments: Upper→Mid, Mid→Lower. Comfortable radius (~5 voxels). + // This is the original passage type — feels like a natural sloped tunnel. + SlopedTunnel UMETA(DisplayName = "Sloped Tunnel (angled bore)"), + + // Straight vertical drop between strates. Same XY for upper and lower points. + // Wider radius (~7-8 voxels) to feel like a natural shaft or sinkhole. + // Players drop or climb directly between strates. + VerticalShaft UMETA(DisplayName = "Vertical Shaft (straight drop)"), + + // Helical corkscrew path with 6-10 control points spiraling downward. + // Narrower radius (~4 voxels). The spiral gives a sense of descending + // through layers of rock, and the tight turns create interesting navigation. + SpiralDescent UMETA(DisplayName = "Spiral Descent (corkscrew)"), + + // Alternating horizontal ledges and short vertical drops. + // 3-5 drop segments, each ~8-12 voxels deep with ~10-voxel horizontal runs. + // Medium radius (~5 voxels). Feels like a natural cascading waterfall path. + CascadingDrops UMETA(DisplayName = "Cascading Drops (ledge series)"), + + // Narrow fracture passage — like squeezing through a crack in the rock. + // Similar shape to SlopedTunnel but much narrower radius (~2-3 voxels) + // and more pronounced horizontal offset at the midpoint. + CrackCrevice UMETA(DisplayName = "Crack/Crevice (narrow fracture)") +}; + +/** + * ESurfaceType — Where on a cave surface a decoration can be placed. + * + * Used by the decoration system to filter placement locations based + * on the surface normal direction at each point. + * + * Floor: Normal pointing up (dot with Z > 0.7) + * Wall: Normal roughly horizontal + * Ceiling: Normal pointing down (dot with Z < -0.7) + * Any: No restriction — place on any surface + */ +UENUM(BlueprintType) +enum class ESurfaceType : uint8 +{ + Floor UMETA(DisplayName = "Floor (normal up)"), + Wall UMETA(DisplayName = "Wall (normal horizontal)"), + Ceiling UMETA(DisplayName = "Ceiling (normal down)"), + Any UMETA(DisplayName = "Any surface") +}; + +//============================================================================= +// NOISE TYPE +//============================================================================= + +/** + * EVoxelNoiseType — Which noise function to use for cave surface roughness. + * + * Different noise types give radically different visual character to cave walls: + * - fBM: smooth, organic, blobby surfaces (default, works everywhere) + * - Ridged: sharp ridge-like features, great for craggy cliffs and tunnels + * - Mixed: blend of both — ridged structure with fBM softness + */ +UENUM(BlueprintType) +enum class EVoxelNoiseType : uint8 +{ + // Standard fractional Brownian motion (layered Perlin). + // Smooth, organic look. Blobby hills and valleys on cave walls. + FBM UMETA(DisplayName = "fBM (smooth, organic)"), + + // Ridged multifractal — folds the noise at zero-crossings, + // creating sharp ridge-like features. Looks like craggy rock, + // natural corridors, and erosion patterns. + Ridged UMETA(DisplayName = "Ridged (sharp, craggy)"), + + // 50/50 blend of fBM and Ridged — ridged structure softened + // by fBM. Good default for "interesting but not too alien." + Mixed UMETA(DisplayName = "Mixed (fBM + Ridged)"), + + // Worley/cellular noise — distance-to-nearest-feature-point. + // Creates rounded, cell-like patterns: bubble walls, grotto + // pockets, honeycomb textures. Great for alien or crystalline strates. + Cellular UMETA(DisplayName = "Cellular (grotto, bubbles)") +}; + +//============================================================================= +// STRATE TRANSITION TYPE +//============================================================================= + +/** + * ECaveGeneratorType — Which density strategy this strate uses. + * + * Each strate is its own "tiny world" and can use a fundamentally different + * density function. The generator branches on this value before building + * the density field for a chunk. + * + * TunnelNetwork: The default. Room-and-corridor SDF graph + worm tunnels. + * Classic cave networks with interconnected passages and chambers. + * Uses FStrateGenerationParams (all the room/tunnel/worm settings). + * + * FlatPlain: Underground open plains. A horizontal void between a flat(ish) + * floor and a gently roughened ceiling. Scattered columns. + * Looks like a massive underground prairie — flat ground, low + * ceiling, occasional stone pillars. Uses FSlabGenerationParams. + * + * CrystalChamber: Like FlatPlain but with a heavily roughened ceiling that creates + * crystal-like formations hanging downward. Combine with crystal + * decoration actors on the ceiling for the "fake sky" effect. + * Uses FSlabGenerationParams (same as FlatPlain, different defaults). + */ +UENUM(BlueprintType) +enum class ECaveGeneratorType : uint8 +{ + // Classic cave network: room SDF + tunnels + worm noise. Uses FStrateGenerationParams. + TunnelNetwork UMETA(DisplayName = "Tunnel Network (rooms + corridors)"), + + // Horizontal slab void: flat floor + gentle ceiling + scattered columns. Uses FSlabGenerationParams. + FlatPlain UMETA(DisplayName = "Flat Plain (underground prairie)"), + + // Horizontal slab void: flat floor + heavily roughened ceiling (crystal formations). Uses FSlabGenerationParams. + CrystalChamber UMETA(DisplayName = "Crystal Chamber (formations hanging from ceiling)"), + + // Tight branching corridors on a deterministic 3D lattice. Uses FMazeGenerationParams. + Maze UMETA(DisplayName = "Maze (tight branching tunnels)"), + + // Open-sky terrain: hills/mountains/plains/beaches under a high solid ceiling, + // lit by placed skylight actors, with an optional water table. Uses FSurfaceGenerationParams. + SurfaceWorld UMETA(DisplayName = "Surface World (skylight terrain: hills, rivers, beaches)"), + + // Mostly-vertical cave system: tall shafts with ledges + sparse horizontal links. Uses FVerticalShaftParams. + VerticalShafts UMETA(DisplayName = "Vertical Shafts (mostly-vertical cave system)"), + + // Suspended land masses in a large open void — "floating islands". Uses FFloatingIslandParams. + FloatingIslands UMETA(DisplayName = "Floating Islands (suspended land in open void)"), + + // Flooded cavern network: TunnelNetwork rock with a high water table. Uses FStrateGenerationParams + water. + Underwater UMETA(DisplayName = "Underwater (flooded cavern system)"), +}; + +/** + * EVoxelStrateTransition — How two adjacent strates blend at their shared boundary. + * + * Each strate boundary can use a different transition style. The transition type + * is defined on the UPPER strate's definition and controls the boundary between + * itself and the strate below it. + * + * Gradient: Smooth linear interpolation of generation params across BlendChunks. + * This is the classic approach — no seam visible, but also no dramatic + * geological feature at the boundary. Best for similar-looking strates. + * + * Hard: No blending at all. The strate's params apply right up to the boundary, + * then instantly switch to the neighbor's params. The abrupt change in + * density and cave shape naturally creates a cliff, ledge, or material + * discontinuity at the boundary. Best for strates that should feel like + * distinct geological layers with a visible dividing line. + * + * Interleaved: The boundary position is warped by a 3D noise field, so "fingers" + * of one strate reach into the other. Some XY columns transition early, + * others late, creating an irregular, interlocking boundary. The warp + * amplitude is ~2 chunks, and Z frequency is lower than XY to produce + * horizontal finger-like intrusions. Best for strates that should feel + * like they grew into each other over geological time. + */ +UENUM(BlueprintType) +enum class EVoxelStrateTransition : uint8 +{ + // Smooth linear interpolation of all generation params across the blend zone. + // No visible boundary — params change gradually over BlendChunks distance. + Gradient UMETA(DisplayName = "Gradient (smooth blend)"), + + // No blending — params switch instantly at the boundary. + // Creates natural cliffs, ledges, or material discontinuities. + Hard UMETA(DisplayName = "Hard (sharp boundary with cliff)"), + + // 3D noise warps the boundary position per XY column. + // Creates finger-like intrusions of one strate into the other. + Interleaved UMETA(DisplayName = "Interleaved (domain-warped fingers)") +}; + +//============================================================================= +// GENERATION PARAMS +//============================================================================= + +/** + * FStrateGenerationParams — Cave generation parameters for one strate. + * + * Extracted from a UVoxelStrateDefinition at runtime. + * At strate boundaries, two sets of params are blended (Lerp) so there's + * no hard visual seam between strates. + * + * The generator reads these instead of its own member variables when the + * strate system is active. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FStrateGenerationParams +{ + GENERATED_BODY() + + // ----- Rock density ----- + + // How solid the rock is BEFORE any carving. + // The entire strate volume starts at this density (all solid rock). + // Worm tunnels and cavern noise then subtract from it to create air. + // + // Higher = more rock survives carving = fewer, thinner caves. + // The carving systems must overcome this value to create air (density < 0). + // 5-6 → moderate rock, lots of tunnels get through + // 8-10 → thick rock, only the strongest carving creates caves (good default) + // 12+ → very dense, barely any caves + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rock") + float BaseDensity = 8.0f; + + // Vertical stretch factor for ALL noise (worms + caverns). + // Scales the Z coordinate before noise sampling: + // >1 = tall galleries, vertical shafts + // <1 = flat chambers, horizontal tunnels + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rock") + float VerticalScale = 1.0f; + + // ===== WORM TUNNELS (primary cave structure) ===== + // + // Worm tunnels are the main way caves form. The technique: + // Sample TWO 3D noise fields at the same position. + // Take abs() of each → both form "sheets" near their zero-crossings. + // Where both sheets intersect → a thin, winding 1D curve → the tunnel. + // + // Worm = abs(Noise1) + abs(Noise2) + // Where Worm < WormThreshold → air (tunnel) + // + // This naturally creates connected, winding tunnel networks. + // The tunnels branch, merge, and snake through the rock organically. + + // How tightly the tunnels wind. Lower = long gentle curves, higher = tight turns. + // This controls the SCALE of tunnels — how far apart they are and how long + // each straight section is before curving. + // 0.008 → very long winding tunnels spanning many chunks + // 0.015 → natural cave tunnels (good default) + // 0.03 → tighter turns, shorter sections + // 0.06+ → chaotic maze (hard to navigate) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Worm Tunnels") + float WormFrequency = 0.015f; + + // Bias tunnels toward horizontal orientation. + // Multiplies the Z component of worm noise frequency, making the noise + // change faster vertically. The result: tunnel paths prefer to stay + // at similar Z levels, giving natural floors and ceilings. + // 1.0 → no bias (tunnels go in all directions, including vertical shafts) + // 3.0 → mostly horizontal tunnels with gentle slopes (good default) + // 5.0 → very flat tunnels, almost no vertical movement + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Worm Tunnels", meta = (ClampMin = "1.0", ClampMax = "10.0")) + float WormHorizontalBias = 3.0f; + + // Tunnel width. Lower = thinner tunnels, higher = wider passages. + // This is the threshold below which the worm noise creates air. + // KEEP THIS SMALL — it controls what fraction of volume becomes tunnel. + // 0.03 → very narrow crawlspaces + // 0.06 → walking-width tunnels (good default) + // 0.10 → wide corridors + // 0.15+ → WARNING: starts creating too much air + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Worm Tunnels", meta = (ClampMin = "0.01", ClampMax = "0.5")) + float WormThreshold = 0.06f; + + // How aggressively worm tunnels carve through rock. + // At the tunnel center (WormValue ≈ 0), the full WormStrength is applied. + // Must exceed BaseDensity to create air. With BaseDensity=8, you need + // WormStrength > 8 for tunnels to fully punch through. + // 9-10 → tunnels just barely pierce through BaseDensity=8 + // 12 → clean tunnels with smooth walls + // 15+ → very wide tunnel profile + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Worm Tunnels") + float WormStrength = 10.0f; + + // ===== CAVE MORPHOLOGY (room-and-corridor) ===== + // + // Cave shape is defined by SDF (Signed Distance Field) primitives: + // - ROOMS: ellipsoid voids placed on a hash grid + // - TUNNELS: capsule corridors connecting nearby rooms + // Combined with smooth union for organic, rounded junctions. + // + // This replaces the old 2D cave slab system. Instead of a flat + // heightfield cave, you get actual rooms connected by corridors — + // explorable spatial structure with clear "this is a room" / "this + // is a corridor" readability. + + // Distance between room grid cells (in voxels). + // This controls room SPACING, not size. Larger = rooms further apart. + // 40 → dense room network, rooms nearly touching + // 80 → moderate density, corridors between rooms (good default) + // 120 → sparse rooms, long corridors + // 200+→ isolated chambers with long tunnel treks + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms") + float RoomSpacing = 80.0f; + + // Probability of a room existing in each grid cell (0-1). + // Not every cell gets a room — this controls how many are filled. + // 0.2 → sparse — only 20% of cells have rooms (many empty areas) + // 0.35→ moderate (good default) + // 0.5 → dense — half of all cells have rooms + // 0.7+→ very crowded, almost solid cave network + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float RoomDensity = 0.35f; + + // Smallest possible room radius (in voxels). + // Small rooms feel like alcoves or nooks. + // 5-8 → small alcoves + // 10-15→ natural small chambers (good default) + // 20+ → even "small" rooms are spacious + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms") + float MinRoomRadius = 10.0f; + + // Largest possible room radius (in voxels). + // Large rooms are cathedral chambers. + // 15-20→ moderate rooms + // 25-35→ large caverns (good default) + // 50+ → massive cathedral spaces + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms") + float MaxRoomRadius = 30.0f; + + // How vertically squished rooms are. + // 1.0 = perfect sphere (tall as wide). + // Lower = flatter, more horizontal chambers. + // 0.2-0.3 → very flat caverns (strate-like, wide and low) + // 0.4-0.5 → natural cave chambers (good default) + // 0.7-1.0 → tall, cathedral-like rooms + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms", meta = (ClampMin = "0.1", ClampMax = "1.0")) + float RoomHeightRatio = 0.4f; + + // Per-room floor flatness range. Each room hash-rolls a value in [Min, Max]. + // FloorCutZ = RoomCenter.Z - RoomRadiusZ * roll + // + // The floor is a soft intersection (SmoothMax) so tunnels/pits pass through + // without a hard seam — the floor rounds off naturally near openings. + // + // 1.0 / 1.0 → all rooms are full ellipsoid bubbles (no flat floor) + // 0.7 / 1.0 → mix: some rooms flat, some bubbles + // 0.5 / 0.8 → all rooms have a flat floor, varying how low it sits + // 0.0 → floor at room center — only the top dome is carved + // + // Combine with RoomHeightRatio = 1.0 and Min~0.7 to get tall dome rooms + // with navigable flat floors. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms", + meta = (ClampMin = "0.0", ClampMax = "1.0")) + float RoomFloorCutMin = 1.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms", + meta = (ClampMin = "0.0", ClampMax = "1.0")) + float RoomFloorCutMax = 1.0f; + + // Large-scale floor undulation — how many voxels the floor plane rises and falls. + // This is NOT surface roughness (which adds rock texture). This shifts the entire + // floor height across the room, creating genuine terrain: hills, shallow basins, + // gradual ramps. A large room with FloorReliefStrength=5 has floor variation of ±5 + // voxels — navigable, but with noticeable topography. + // + // Only meaningful when RoomFloorCutMin < 1.0 (flat floor mode active). + // + // 0 → perfectly flat floor (default — use surface roughness for texture) + // 2-4 → subtle floor variation, barely perceptible gradient + // 5-10 → clear hills and basins — interesting to walk across + // 15+ → dramatic terrain — slopes and drops within the room + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms", + meta = (ClampMin = "0.0", EditCondition = "RoomFloorCutMin < 1.0")) + float FloorReliefStrength = 0.0f; + + // How wide the floor hills are. Lower = broader, gentler undulations. + // 0.005 → very broad (one hill spans the whole room) + // 0.015 → moderate rolling terrain (good default) + // 0.04 → tighter, more frequent hills + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms", + meta = (ClampMin = "0.001", EditCondition = "RoomFloorCutMin < 1.0")) + float FloorReliefFrequency = 0.015f; + + // How much variety in room shapes (0 = all ellipsoids, 1 = full variety). + // At 0: every room is a smooth ellipsoid (uniform, organic caves). + // At 0.5: some rooms become angular (rounded boxes) or elongated (capsules). + // At 1.0: maximum variety — ellipsoids, angular chambers, and elongated halls. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float RoomShapeVariety = 0.5f; + + // ===== ORIGIN ROOM (guaranteed hub at world center) ===== + // + // A guaranteed large room at (0, 0) in the center of each strate. + // This is where the cave network originates — the designer can later + // place the elevator, rest area, or other key features here. + // All nearby hash rooms will have tunnels forced to connect to it, + // making it the natural hub of the strate's cave network. + // 0 → disabled (no guaranteed origin room) + // 15 → moderate hub room + // 20 → large central chamber (good default) + // 30+ → massive starting cavern + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms") + float OriginRoomRadius = 20.0f; + + // Maximum number of backbone tunnels that can force-connect to the origin room. + // Without a limit, every room in the search area that picks origin as its nearest + // neighbor gets a guaranteed tunnel — with large spacing this can create 8-10 + // tunnels all radiating from origin, overwhelming it visually. + // 0 → no limit (legacy behavior — every room can backbone to origin) + // 3-4 → natural hub with a few major exits (good default) + // 6+ → busy hub + // Rooms beyond the cap can still connect via TunnelDensity random chance, + // so connectivity isn't broken — only the "guaranteed" aspect is limited. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Rooms", + meta = (ClampMin = "0", ClampMax = "12")) + int32 OriginRoomMaxConnections = 4; + + // ===== TUNNELS (connecting corridors between rooms) ===== + + // Smallest tunnel radius (in voxels). Creates tight squeeze corridors. + // 2-3 → claustrophobic crawlspaces + // 3-4 → tight but passable (good default) + // 5+ → even "narrow" tunnels are comfortable + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels") + float TunnelMinRadius = 3.0f; + + // Largest tunnel radius (in voxels). Creates wide passages. + // 5-6 → comfortable walking tunnels + // 7-8 → wide passages (good default) + // 10+ → practically small rooms + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels") + float TunnelMaxRadius = 7.0f; + + // Probability that two nearby rooms are connected by a tunnel (0-1). + // 0.2 → sparse connections, mostly dead-end rooms + // 0.4 → moderate connectivity (good default) + // 0.8+→ dense network, nearly everything connected + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float TunnelDensity = 0.4f; + + // Maximum tunnel length (in voxels). Rooms further apart won't connect. + // 100 → only close neighbors + // 200 → moderate reach (good default) + // 400+→ long-range connections possible + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels") + float MaxTunnelLength = 200.0f; + + // How much tunnel paths curve (in voxels of sideways displacement). + // Without this, tunnels are straight lines between rooms. + // This adds a hash-derived midpoint offset to each tunnel, creating + // unique curves — some bend left, some right, some are nearly straight. + // Capped at 25% of tunnel length to prevent kinky short tunnels. + // 0 → perfectly straight tunnels (artificial look) + // 8-12→ gentle natural curves + // 15 → clearly winding passages (good default) + // 25+ → very curvy, meandering corridors + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels") + float TunnelWarpStrength = 15.0f; + + // Preference for horizontal connections over vertical (0-1). + // Real caves are mostly horizontal — vertical connections are rarer. + // This penalizes Z-separation when deciding which rooms to connect. + // 0.0 → no preference (connects freely in all directions) + // 0.5 → moderate horizontal preference (good default) + // 1.0 → strongly horizontal — vertical connections very unlikely + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float TunnelHorizontalBias = 0.5f; + + // How much tunnel endpoints shift up/down within rooms (0-1). + // Fraction of the room's vertical radius. Each tunnel endpoint gets + // a hash-derived Z offset, so tunnels enter rooms at different heights. + // Combined with terrain ops (cliffs, terraces), this creates multi-level rooms + // where one tunnel exits at the upper ledge and another at the lower floor. + // 0.0 → all tunnels connect at room center height + // 0.5 → endpoints range from -50% to +50% of room height (good default) + // 1.0 → full range — tunnels can enter near floor or ceiling + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float TunnelEndpointZOffset = 0.5f; + + // ===== SDF BLEND (junction smoothness) ===== + + // Smooth union blend radius. Controls how rounded the junctions + // are where rooms meet tunnels (or rooms meet rooms). + // 1-2 → sharp, angular junctions + // 3-5 → natural rounded junctions (good default) + // 8+ → very blobby, organic blending + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Blend") + float SDFBlendRadius = 4.0f; + + // ===== CAVE WARP (large-scale skeleton distortion) ===== + // + // Warps the world coordinates BEFORE the SDF is evaluated. + // This is THE key technique that turns graph-like caves into organic shapes: + // rooms become irregular blobs, tunnels become winding passages. + // + // Without this, the SDF skeleton is a clean graph of geometric primitives. + // With this, the entire field bends and flows — like real geological forces + // pushed the rock around over millions of years. + // + // Two octaves are applied internally: + // - Large-scale (CaveWarpFrequency): bends whole rooms/tunnels gently + // - Medium-scale (3x frequency, 0.3x strength): adds irregularity to walls + + // How far the cave shapes shift (in voxels). + // This controls the overall organic distortion of the cave skeleton. + // 0 → disabled — pristine geometric rooms and straight tunnels + // 4-6 → subtle natural variation (rooms slightly irregular) + // 8-12→ clearly organic caves, tunnels visibly curve (good default) + // 16+ → heavily distorted, surreal cave shapes + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Cave Warp") + float CaveWarpStrength = 8.0f; + + // Frequency of the warp noise. Lower = broader, smoother bends. + // This controls the SCALE of the distortion. + // 0.008 → very broad sweeping curves (whole strate-scale) + // 0.015 → natural cave bending — one curve every ~2 chunks (good default) + // 0.03 → tighter curves, more chaotic layout + // 0.06+ → extreme — small-scale jittering (usually too noisy) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Cave Warp") + float CaveWarpFrequency = 0.015f; + + // ===== SURFACE ROUGHNESS (rocky, craggy walls) ===== + // + // 3D noise applied near cave surfaces to break up the smooth SDF shapes. + // Creates overhangs, ledges, rocky protrusions, bumps, and cracks. + // Without this, rooms are perfect ellipsoids. With this, they're rock. + + // How strong the roughness is (in voxels of displacement). + // 0 → perfectly smooth SDF shapes + // 2-3 → subtle rocky texture + // 4-6 → natural rocky cave surfaces (good default) + // 8+ → very rough, jagged rock + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Roughness") + float SurfaceRoughness = 5.0f; + + // Frequency of the roughness noise. Higher = finer details. + // 0.05 → large rocky features (boulders, ledges) + // 0.1 → natural rock grain (good default) + // 0.15+ → fine cracks and bumps + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Roughness") + float RoughnessFrequency = 0.1f; + + // Which noise function to use for surface roughness. + // Different types give caves very different visual character: + // fBM → smooth organic (lava tubes, rounded galleries) + // Ridged → sharp craggy (erosion cracks, natural corridors) + // Mixed → blend of both (good general default) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Roughness") + EVoxelNoiseType RoughnessNoiseType = EVoxelNoiseType::FBM; + + // ===== DOMAIN WARPING ===== + // + // Distorts the noise input coordinates using a secondary noise field. + // This breaks up repetitive patterns and makes surfaces look more + // organic — like they were shaped by flowing water or geological forces. + // Applied to ALL roughness noise (regardless of noise type). + // + // 0.0 → disabled (default) — noise is sampled at true world positions + // 3-5 → subtle organic distortion (good starting point) + // 8-12 → noticeable warping, very organic cave walls + // 15+ → extreme distortion, alien/surreal look + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Domain Warp") + float DomainWarpStrength = 0.0f; + + // Frequency of the warp noise field. Higher = tighter, more chaotic warps. + // 0.01 → very broad, sweeping distortion + // 0.03 → natural geological warping (good default) + // 0.06+ → tight, detailed warping + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Domain Warp") + float DomainWarpFrequency = 0.03f; + + // ===== FLOOR BIAS ===== + // + // Adds density in the lower portion of rooms to counteract surface roughness + // creating bumpy, uneven floors. Acts like sediment settling on the cave floor — + // the noise still carves relief, but the floor stays generally walkable. + // + // Only applied below room center Z (fades to 0 at center, strongest at floor). + // Does NOT affect the open space above room center or walls/ceiling. + // + // 0 → disabled — full roughness on all surfaces (default) + // 2-3 → subtle flattening, floor still has character + // 4-6 → noticeably flatter floor while keeping wall/ceiling rough + // 8+ → near-flat floor (good for navigable caves) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Roughness", + meta = (ClampMin = "0.0")) + float FloorBias = 0.0f; + + // ===== TERRAIN OPERATIONS ===== + // + // These modify the density field AFTER morphology + roughness to create + // specific geological features. They run on every voxel near cave surfaces. + // Set the "strength" or "spacing" param to 0 to disable any operation. + // + // Pipeline order: Morphology → Roughness → TerrainOps → Worm Tunnels + // + // NOTE: These fields are INTERNAL TRANSPORT — populated by + // UVoxelTerrainOpDefinition::ApplyTo() and read by the generator. + // They have no UPROPERTY so they don't clutter the strate definition editor. + // Designers configure terrain ops via UVoxelTerrainOpDefinition data assets + // referenced in the strate definition's TerrainOperations array. + + // --- TERRACING --- + // + // Creates step-like horizontal ledges on cave walls. The cave surface + // is broken into a staircase: flat shelf floors connected by short cliffs. + // Great for platforming — players can jump between ledges. + // + // How it works: a smooth staircase function of world Z is used to + // offset the density field near cave surfaces. Where the staircase + // is above the real Z → extra solid (shelf floor). Where below → air gap. + // + // 0 → disabled (default) + // 6-8 → natural stone steps (comfortable jump height in Unreal units) + // 10-12 → tall ledges (need climbing) + // 16+ → dramatic cliff-like terraces + float TerraceStepHeight = 0.0f; + + // How sharp the terrace step edges are. + // 0.0 → rounded, 0.5 → moderate, 0.9 → sharp angular ledges + float TerraceHardness = 0.5f; + + // Noise displacement on terrace edge Z positions. + // 0.0 → perfectly horizontal, 0.5 → natural, 1.0 → very wavy + float TerraceNoiseDisplacement = 0.5f; + + // --- LAYER LINES --- + // + // Horizontal grooves carved into cave walls — visible geological strata. + // Creates thin recessed lines at regular Z intervals, making walls + // look like layered sedimentary rock. Purely geometric — the + // material/shader could also add visual layers, but this is structural. + // + // 0 → disabled (default) + // 4-6 → fine layering (detailed geological look) + // 8-12 → broad visible layers + // 16+ → dramatic thick bands + float LayerLineSpacing = 0.0f; + + // Depth of each groove (how much density is subtracted). + // 0.1 → barely visible, 0.3 → subtle, 0.6 → deep, 1.0+ → dramatic + float LayerLineDepth = 0.3f; + + // --- OVERHANGS --- + // + // Horizontal shelf-like protrusions from cave walls. + // Uses 3D noise with very low Z frequency, so features extend + // horizontally for long distances before varying vertically. + // Creates natural-looking rocky overhangs, shelves, and ledges + // that are independent of the terracing system. + // + // Strength: 0 = disabled, higher = more prominent overhang features. + // 0.0 → disabled (default) + // 0.3 → subtle horizontal features + // 0.5 → moderate overhangs (good default when enabled) + // 0.8 → very prominent horizontal protrusions + float OverhangStrength = 0.0f; + + // How far overhangs protrude from walls (in voxels of density added). + // 2-3 → small, 5-8 → natural, 10+ → dramatic + float OverhangDepth = 5.0f; + + // Frequency of the overhang noise. Z freq is auto 5x lower for horizontal features. + // 0.03 → large rare, 0.06 → moderate, 0.1+ → many small + float OverhangFrequency = 0.06f; + + // --- RIBBING --- + // + // Parallel ridge patterns on cave walls and ceilings — like lava tubes. + // Creates evenly-spaced bumps/ribs that run horizontally along the + // cave surface. The ridges ADD density (solid), creating protruding + // bands of rock. This is the complement to layer lines (which subtract). + // + // Ribbing direction is along Z (horizontal ribs on walls), which gives + // the classic lava tube look. Combined with domain warping, the ribs + // become wavy and organic instead of perfectly straight. + // + // 0 → disabled (default) + // 3-5 → fine ribbing (subtle lava tube feel) + // 6-10→ prominent ribs (dramatic geological feature) + // 12+ → thick bands of rock (very stylized) + float RibbingSpacing = 0.0f; + + // How far ribs protrude from the wall (in voxels of density added). + // 0.2 → barely visible, 0.4 → subtle, 0.8 → prominent, 1.5+ → dramatic + float RibbingDepth = 0.4f; + + // --- CLIFF SHARPENING --- + // + // Amplifies vertical gradients in the density field to create sheer + // rock faces. Where the cave surface is already somewhat vertical + // (density changes quickly along Z), this operation steepens it further. + // + // How it works: near cave surfaces, we measure the vertical density + // gradient. Where it's steep (cave wall is already vertical), we ADD + // density below and SUBTRACT above — steepening the transition from + // solid to air. This turns gentle slopes into dramatic cliff faces. + // + // Strength: 0 = disabled, higher = steeper cliffs. + // 0.0 → disabled (default) + // 0.3 → subtle steepening (walls feel more vertical) + // 0.6 → strong cliff faces (good for dramatic strates) + // 1.0 → extreme — nearly all slopes become vertical walls + float CliffStrength = 0.0f; + + // --- SCALLOP --- + // + // Water-erosion-like concave patterns on cave walls. + // Creates bowl-shaped indentations arranged in a semi-regular pattern, + // like the scallops you see in real water-carved limestone caves. + // + // Uses 3D cellular (Worley) noise near cave surfaces to create + // concave pockets. The distance-to-nearest-feature-point creates + // natural bowl shapes when subtracted from density. + // + // 0.0 → disabled (default) + // 0.3 → subtle erosion texture + // 0.6 → visible scalloped walls (good for wet/limestone strates) + // 1.0 → deep scallops, dramatic water-carved look + float ScallopStrength = 0.0f; + + // Size of each scallop bowl. Lower = bigger, higher = smaller. + // 0.05 → large, 0.1 → medium, 0.2 → small frequent + float ScallopFrequency = 0.1f; + + // --- ARCH / BRIDGE --- + // + // Natural rock bridges spanning gaps in cave chambers. + // Hash-placed horizontal cylinders of ADDED density that create + // walkable bridges across open cave spaces. Arches only appear + // inside caves (where SDF < 0), spanning from one wall to another. + // + // Each arch is a horizontal capsule (solid rock tube) that crosses + // through the cave void. The hash determines position, direction, + // and size. Arches are relatively rare, dramatic features. + // + // 0.0 → disabled (default) + // 0.02 → very rare arches (impressive when found) + // 0.06 → moderate (good for cathedral strates) + // 0.1 → frequent bridges + float ArchDensity = 0.0f; + float ArchMinRadius = 3.0f; + float ArchMaxRadius = 6.0f; + + // ===== COLUMNS / PILLARS ===== + // + // Vertical solid cylinders connecting floor to ceiling in cave spaces. + // These are natural rock pillars — where a stalactite and stalagmite + // would have met, or where cave erosion left a supporting column. + // + // Columns are placed using hash-based placement (like rooms) and only + // appear where the cave morphology has already carved an opening. + // They ADD density (solid rock) inside what would otherwise be air. + + // Probability that a hash cell contains a column (0-1). + // 0 = disabled. Higher = more pillars scattered through caves. + // 0.0 → disabled (default) + // 0.05 → rare pillars, impressive when found + // 0.15 → moderate (good for cathedral strates) + // 0.3 → dense forest of pillars + float ColumnDensity = 0.0f; + float ColumnMinRadius = 2.0f; + float ColumnMaxRadius = 5.0f; + + // ===== PITS / VERTICAL SHAFTS ===== + // + // Cylindrical downward voids carved into the cave floor. + // These are natural vertical drops — sinkholes, collapsed ceilings, + // narrow shafts. They create dramatic vertical gameplay: + // the player can fall in, climb down, or discover connections + // to lower areas within the same strate. + // + // Pits are hash-placed and only carve where the cave morphology + // already created solid rock with air above (floor areas). + + // Probability that a hash cell contains a pit (0-1). + // 0 = disabled. Pits are rare, impactful features. + // 0.0 → disabled (default) + // 0.03 → rare sinkholes (dangerous, surprising) + // 0.08 → moderate (good for deep strates with vertical gameplay) + // 0.15 → frequent pits (chaotic, maze-like) + float PitDensity = 0.0f; + float PitMinRadius = 4.0f; + float PitMaxRadius = 10.0f; + float PitDepth = 25.0f; + + // ----- Chimney / Shaft (upward voids — inverse of pits) ----- + + // Chimneys: narrow vertical tubes piercing upward from cave ceilings. + float ChimneyDensity = 0.0f; + float ChimneyMinRadius = 2.0f; + float ChimneyMaxRadius = 5.0f; + float ChimneyHeight = 20.0f; + + // ----- Dome (hemispherical chamber ceilings) ----- + + // Domes: hemispherical chamber ceilings for cathedral-like feel. + float DomeDensity = 0.0f; + float DomeMinRadius = 8.0f; + float DomeMaxRadius = 15.0f; + float DomeHeightRatio = 0.8f; + + // ----- Pinch / Bottleneck (passage narrowing) ----- + + // Pinch: passage narrowing for bottlenecks/chokepoints. + float PinchDensity = 0.0f; + float PinchStrength = 5.0f; + float PinchLength = 12.0f; + + // ----- Strate boundary sealing ----- + + // Thickness of the solid shell at the top and bottom of each strate (in voxels). + // Prevents caves from carving through strate boundaries, guaranteeing + // a solid ceiling at the top and solid floor at the bottom. + // The player must find actual passages to move between strates. + // 2-3 → thin seal (some small holes possible near edges) + // 4-5 → solid boundary (good default) + // 8+ → very thick, no chance of breakthrough + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Boundary") + float BoundarySealThickness = 4.0f; + + // ----- Water table (used by the Underwater archetype + water render system) ----- + + // Height of the strate water table as a fraction of the strate's height (0-1). + // 0 = no water. For the Underwater archetype set this high (e.g. 0.9) so almost + // the whole cavern floods. The density field is unaffected — this only tells the + // water render system where the surface sits. Rock below it reads as submerged. + // 0.0 → dry caves (default) + // 0.5 → half-flooded galleries + // 0.9 → nearly fully submerged (Underwater archetype) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Water", + meta = (ClampMin = "0.0", ClampMax = "1.0")) + float WaterLevelRelative = 0.0f; + + // Runtime values — set by StrateManager, NOT editable in the definition. + // These define the strate's Z range in voxel coordinates so the density + // function can seal the top and bottom boundaries. + float StrateTopWorldZ = 0.0f; + float StrateBottomWorldZ = 0.0f; + + /** + * Linearly interpolate between two param sets. + * Used at strate boundaries to blend smoothly between adjacent strates. + * + * @param A - Params for the strate above + * @param B - Params for the strate below + * @param Alpha - 0.0 = fully A, 1.0 = fully B + * @return Blended params + */ + static FStrateGenerationParams Lerp( + const FStrateGenerationParams& A, + const FStrateGenerationParams& B, + float Alpha) + { + FStrateGenerationParams Result; + // Rock + Result.BaseDensity = FMath::Lerp(A.BaseDensity, B.BaseDensity, Alpha); + Result.VerticalScale = FMath::Lerp(A.VerticalScale, B.VerticalScale, Alpha); + // Worm tunnels + Result.WormFrequency = FMath::Lerp(A.WormFrequency, B.WormFrequency, Alpha); + Result.WormHorizontalBias = FMath::Lerp(A.WormHorizontalBias, B.WormHorizontalBias, Alpha); + Result.WormThreshold = FMath::Lerp(A.WormThreshold, B.WormThreshold, Alpha); + Result.WormStrength = FMath::Lerp(A.WormStrength, B.WormStrength, Alpha); + // 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.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; + } +}; + +//============================================================================= +// TERRAIN OPERATION REFERENCE +//============================================================================= + +// Forward declaration — the actual data asset lives in VoxelTerrainOpDefinition.h. +// We only need a soft pointer here, so no #include needed. +class UVoxelTerrainOpDefinition; + +/** + * FStrateTerrainOpEntry — A reference to a terrain operation with a weight. + * + * Strate definitions hold an array of these. Each entry says "apply this + * terrain operation at this intensity." The weight scales the op's primary + * activation field (density, strength, or spacing). + * + * USAGE IN EDITOR: + * In the strate definition's Details panel, expand TerrainOperations and add + * entries. Pick a DA_Op_* asset and set its weight: + * - Weight 1.0 = use the op as configured in the asset + * - Weight 0.5 = half intensity (fewer/smaller features) + * - Weight 1.5 = more intense than the asset's default + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FStrateTerrainOpEntry +{ + GENERATED_BODY() + + // Reference to a terrain operation data asset (e.g., DA_Op_DeepPit). + // Soft pointer allows async loading and avoids hard dependencies. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Terrain Op") + TSoftObjectPtr Operation; + + // Intensity multiplier for this operation in this strate. + // 1.0 = use as configured in the data asset + // 0.5 = half intensity (fewer/smaller features) + // 2.0 = double intensity (use carefully — can overwhelm the cave) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Terrain Op", + meta = (ClampMin = "0.0", ClampMax = "3.0")) + float Weight = 1.0f; + + // Probability that any given room rolls this op from the strate's pool. + // Ops are selected by weighted draw per-room during cache build: + // - All probs sum to < 1.0 → some rooms get no terrain op (empty space in the pool) + // - All probs sum to >= 1.0 → every room gets an op (pool is fully claimed) + // - Multi-op strates: each entry competes for each room's random roll + // + // Examples (single op): + // 0.0 = never selected — op is disabled + // 0.5 = ~50% of rooms get this op, ~50% get nothing + // 1.0 = every room gets this op + // + // Examples (two ops, Prob=0.4 each → sum 0.8): + // ~40% rooms get op A, ~40% get op B, ~20% get nothing + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Terrain Op", + meta = (ClampMin = "0.0", ClampMax = "1.0")) + float Probability = 0.5f; +}; + +//============================================================================= +// SLAB GENERATION PARAMS (FlatPlain / CrystalChamber generator types) +//============================================================================= + +/** + * FSlabGenerationParams — Density parameters for slab-based strates. + * + * Used by the FlatPlain and CrystalChamber generator types instead of the + * room-and-corridor FStrateGenerationParams. The entire strate becomes a + * horizontal void between a noisy floor surface and a noisy ceiling surface. + * + * COORDINATE CONVENTION: + * - Floor and ceiling positions are expressed as a fraction of the strate's + * total height (0.0 = strate bottom, 1.0 = strate top). + * - Roughness values are in voxels of surface displacement. + * - StrateTopWorldZ / StrateBottomWorldZ are runtime values (voxel coords), + * set by StrateManager — do NOT edit these manually. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FSlabGenerationParams +{ + GENERATED_BODY() + + // ===== VOID SHAPE ===== + + // Where the floor surface sits, relative to this strate's height. + // 0.0 = at the very bottom of the strate, 1.0 = at the very top. + // Keep this well below CeilingRelativeHeight or you get no open space. + // 0.1-0.2 → floor is near the bottom (lots of headroom) + // 0.25 → floor at 25% height (good default) + // 0.4+ → very low ceiling; claustrophobic plains + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Slab|Shape", + meta = (ClampMin = "0.0", ClampMax = "0.95")) + float FloorRelativeHeight = 0.25f; + + // Where the ceiling surface sits, relative to this strate's height. + // Must be above FloorRelativeHeight. The difference determines how tall + // the open void is. + // 0.6 → moderate ceiling height (tight plains) + // 0.8 → tall open space (good default for FlatPlain) + // 0.9+ → nearly the full strate height is open (cavernous plains) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Slab|Shape", + meta = (ClampMin = "0.05", ClampMax = "1.0")) + float CeilingRelativeHeight = 0.80f; + + // ===== FLOOR ROUGHNESS ===== + // Perlin-based displacement of the floor surface up/down. + // Creates gently rolling ground — hills, shallow valleys, bumps. + // The noise has a very low Z frequency so features extend horizontally. + + // How many voxels the floor surface can shift up or down. + // 0 → perfectly flat floor (artificial, but dramatic) + // 3-5 → subtle rolling ground (good for FlatPlain) + // 8+ → significant hills, deep valleys + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Slab|Floor") + float FloorRoughness = 4.0f; + + // Spatial frequency of the floor noise. Lower = broader hills. + // 0.02 → very wide rolling hills (strate-scale) + // 0.04 → moderate hills (good default) + // 0.08 → frequent small bumps + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Slab|Floor") + float FloorRoughnessFrequency = 0.04f; + + // ===== CEILING ROUGHNESS ===== + // abs(noise) is used so ceiling features only protrude DOWNWARD. + // This creates stalactite/crystal-like formations hanging from above. + // For FlatPlain: keep this low for a relatively smooth ceiling. + // For CrystalChamber: crank this up to create dense formation forests. + + // How far ceiling formations hang down into the void (in voxels). + // 0 → flat ceiling (no formations) + // 4-6 → subtle bumps (FlatPlain default) + // 10-15 → significant formations (CrystalChamber default) + // 20+ → dramatic columns reaching toward the floor + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Slab|Ceiling") + float CeilingRoughness = 6.0f; + + // Spatial frequency of the ceiling formation noise. Lower = fewer, larger formations. + // 0.02 → rare massive formations (icebergs) + // 0.04 → moderate (good default for FlatPlain) + // 0.06 → dense cluster of crystals (good for CrystalChamber) + // 0.1+ → very frequent small stalactites + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Slab|Ceiling") + float CeilingRoughnessFrequency = 0.04f; + + // ===== COLUMNS / PILLARS ===== + // Vertical rock cylinders scattered through the open void. + // Unlike TunnelNetwork columns (room-relative), these are placed on a + // world-space hash grid — they can appear anywhere in the void. + // + // Columns are ALWAYS full-height (floor-to-ceiling). Their radius can + // vary but they always span the full void from floor to ceiling. + + // Probability that a hash grid cell contains a column (0 = none, 1 = maximum density). + // 0.0 → disabled (no columns) + // 0.05 → sparse, dramatic lone pillars + // 0.12 → moderate scattering (good default) + // 0.25 → dense forest of pillars + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Slab|Columns", + meta = (ClampMin = "0.0", ClampMax = "1.0")) + float ColumnDensity = 0.08f; + + // Smallest column radius (in voxels). + // 1-2 → slender needle-like pillars + // 3-4 → narrow columns (good default) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Slab|Columns") + float ColumnMinRadius = 2.0f; + + // Largest column radius (in voxels). + // 6-8 → natural stone pillars (good default) + // 12-15 → massive trunks + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Slab|Columns") + float ColumnMaxRadius = 7.0f; + + // Hash grid cell size (higher = columns further apart). + // 30 → dense grid, many candidate slots (most empty due to ColumnDensity) + // 60 → moderate spacing (good default) + // 100+ → only occasional columns + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Slab|Columns") + float ColumnSpacing = 60.0f; + + // ===== BOUNDARY & DENSITY ===== + + // Solid rock shell at the top and bottom of the strate. + // Prevents the void from touching the strate boundary — guarantees a + // solid ceiling and floor at the geological layer edges. + // 3-4 → thin seal (good default for slab strates) + // 6+ → thick solid boundary + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Slab|Boundary") + float BoundarySealThickness = 4.0f; + + // Base solidity strength — used to scale the boundary seal force. + // Match this to the floor/ceiling noise scale so the seal overpowers roughness. + // 6-8 → good default (matches typical roughness values) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Slab|Boundary") + float BaseDensity = 8.0f; + + // ===== RUNTIME (set by StrateManager — do not edit) ===== + + // Top Z boundary of this strate in voxel coordinates. Set by StrateManager. + float StrateTopWorldZ = 0.0f; + + // Bottom Z boundary of this strate in voxel coordinates. Set by StrateManager. + float StrateBottomWorldZ = 0.0f; +}; + +//============================================================================= +// MAZE GENERATION PARAMS (ECaveGeneratorType::Maze) +//============================================================================= +/** + * FMazeGenerationParams — tight, branching corridors on a deterministic 3D lattice. + * + * Each lattice node sits at a cell center. An edge to a +X / +Y / +Z neighbour exists + * when a symmetric pair-hash passes BranchProbability (Z edges additionally gated by + * Verticality). Corridors are thin capsules carved through solid rock. Because edges + * are decided by a pure symmetric hash of the two cells, the maze is identical in every + * chunk with no caching needed — evaluated per-voxel over the few nearby cells. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FMazeGenerationParams +{ + GENERATED_BODY() + + // Lattice cell size in voxels. Smaller = tighter, more claustrophobic maze. + // 25-35 → very tight warren · 40 → classic maze (default) · 60+ → roomy + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Maze", meta = (ClampMin = "8.0")) + float CellSize = 40.0f; + + // Corridor tube radius in voxels. Keep well below CellSize/2 to leave walls. + // 3 → crawlspace · 4 → walkable (default) · 6 → wide halls + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Maze", meta = (ClampMin = "1.0")) + float CorridorRadius = 4.0f; + + // Probability that a horizontal edge between adjacent cells is open (0-1). + // Lower = more dead ends and a more maze-like feel; higher = more open/connected. + // 0.45 → sparse, lots of dead ends · 0.7 → connected maze (default) · 0.9 → very open + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Maze", meta = (ClampMin = "0.05", ClampMax = "1.0")) + float BranchProbability = 0.7f; + + // Probability that a vertical (Z) edge between stacked cells is open (0-1). + // 0 = single-level maze; higher = multi-storey with vertical shafts between levels. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Maze", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float Verticality = 0.3f; + + // Small-scale wall roughness (voxels). 0 = perfectly smooth tubes. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Maze", meta = (ClampMin = "0.0")) + float SurfaceRoughness = 2.0f; + + // Solid shell thickness at strate top/bottom (voxels). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Maze", meta = (ClampMin = "0.0")) + float BoundarySealThickness = 4.0f; + + // Rock solidity before carving (higher = more solid). Matches roughness scale. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Maze") + float BaseDensity = 8.0f; + + // Runtime Z bounds (voxel coords) — set by StrateManager, do not edit. + float StrateTopWorldZ = 0.0f; + float StrateBottomWorldZ = 0.0f; +}; + +//============================================================================= +// SURFACE-WORLD GENERATION PARAMS (ECaveGeneratorType::SurfaceWorld) +//============================================================================= +/** + * FSurfaceGenerationParams — open-sky terrain inside a strate. + * + * A heightfield (fBM continents + ridged mountains + fine detail) defines the ground; + * everything below it is solid, everything above is open air up to a high solid ceiling + * (the "sky cap"). An optional water table floods valleys to make rivers/lakes/beaches. + * Skylight is simulated by up-facing light actors placed via the content pool. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FSurfaceGenerationParams +{ + GENERATED_BODY() + + // Mean ground height as a fraction of the strate height (0 = bottom, 1 = top). + // The terrain heightfield varies around this baseline by ElevationRange. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Shape", meta = (ClampMin = "0.05", ClampMax = "0.9")) + float BaseGroundRelative = 0.30f; + + // Total vertical amplitude of the terrain heightfield in voxels (peak-to-trough-ish). + // 20 → gentle plains · 60 → hills + valleys (default) · 120+ → tall mountains + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Shape", meta = (ClampMin = "0.0")) + float ElevationRange = 60.0f; + + // Low-frequency "continent" noise — broad landmasses and basins. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Shape") + float ContinentFrequency = 0.006f; + + // How strongly sharp ridged mountains mix into the heightfield (0-1). + // 0 → rolling hills only · 0.5 → hills + peaks (default) · 1 → dramatic ranges + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Shape", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float MountainStrength = 0.5f; + + // Frequency of the ridged mountain noise. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Shape") + float MountainFrequency = 0.012f; + + // Fine detail frequency — small bumps and rocks on the surface. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Shape") + float DetailFrequency = 0.04f; + + // Small-scale surface roughness in voxels (rocks, bumps). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Shape", meta = (ClampMin = "0.0")) + float SurfaceRoughness = 3.0f; + + // ----- Water ----- + + // Water table height as a fraction of strate height (0 = no water). Valleys below + // this flood into lakes/rivers; the band just above is flattened into beaches. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Water", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float WaterLevelRelative = 0.27f; + + // Width (voxels) of the flattened beach/shore band around the water line. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Water", meta = (ClampMin = "0.0")) + float BeachWidth = 8.0f; + + // ----- Sky cap & boundary ----- + + // Height of the solid ceiling ("sky cap") as a fraction of strate height (0-1). + // The open-air gap is between the terrain and this cap. Keep high for big skies. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.3", ClampMax = "1.0")) + float CeilingRelative = 0.95f; + + // Downward bumpiness of the sky-cap ceiling (voxels). 0 = flat ceiling. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0")) + float CeilingRoughness = 6.0f; + + // Solid shell thickness at strate top/bottom (voxels). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Boundary", meta = (ClampMin = "0.0")) + float BoundarySealThickness = 4.0f; + + // Rock solidity before carving. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Boundary") + float BaseDensity = 8.0f; + + // Runtime Z bounds (voxel coords) — set by StrateManager, do not edit. + float StrateTopWorldZ = 0.0f; + float StrateBottomWorldZ = 0.0f; +}; + +//============================================================================= +// VERTICAL-SHAFT GENERATION PARAMS (ECaveGeneratorType::VerticalShafts) +//============================================================================= +/** + * FVerticalShaftParams — a mostly-vertical cave system. + * + * Hash-placed full-height vertical shafts (tapered capsules spanning the strate) carved + * into solid rock, with periodic horizontal ledges inside them and occasional thin + * horizontal connectors linking neighbouring shafts. Emphasises climbing/falling. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FVerticalShaftParams +{ + GENERATED_BODY() + + // Hash-grid cell size for shaft XY placement (voxels). Larger = shafts further apart. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Shafts", meta = (ClampMin = "10.0")) + float ShaftSpacing = 55.0f; + + // Probability a grid cell contains a shaft (0-1). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Shafts", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float ShaftDensity = 0.6f; + + // Shaft radius range (voxels). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Shafts", meta = (ClampMin = "1.0")) + float ShaftMinRadius = 5.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Shafts", meta = (ClampMin = "1.0")) + float ShaftMaxRadius = 11.0f; + + // Chance an adjacent pair of shafts is joined by a horizontal connector tunnel (0-1). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Shafts", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float CrossConnectChance = 0.35f; + + // Radius of horizontal connector tunnels (voxels). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Shafts", meta = (ClampMin = "1.0")) + float ConnectorRadius = 4.0f; + + // Vertical spacing of ledges inside shafts (voxels). 0 = no ledges (sheer drops). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Shafts", meta = (ClampMin = "0.0")) + float LedgeSpacing = 24.0f; + + // How far ledges intrude into the shaft (voxels of solid added). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Shafts", meta = (ClampMin = "0.0")) + float LedgeDepth = 3.0f; + + // Small-scale wall roughness (voxels). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Shafts", meta = (ClampMin = "0.0")) + float SurfaceRoughness = 3.0f; + + // Solid shell thickness at strate top/bottom (voxels). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Shafts", meta = (ClampMin = "0.0")) + float BoundarySealThickness = 4.0f; + + // Rock solidity before carving. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Shafts") + float BaseDensity = 8.0f; + + // Runtime Z bounds (voxel coords) — set by StrateManager, do not edit. + float StrateTopWorldZ = 0.0f; + float StrateBottomWorldZ = 0.0f; +}; + +//============================================================================= +// FLOATING-ISLAND GENERATION PARAMS (ECaveGeneratorType::FloatingIslands) +//============================================================================= +/** + * FFloatingIslandParams — suspended land masses in a large open void. + * + * The strate interior is open air; hash-placed island blobs (flattened-top ellipsoids, + * roughened underside) float at jittered heights. From below they read as land hanging + * "in the sky". Top/bottom are sealed solid so the void is enclosed. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FFloatingIslandParams +{ + GENERATED_BODY() + + // Hash-grid cell size for island placement (voxels). Larger = islands further apart. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Islands", meta = (ClampMin = "20.0")) + float IslandSpacing = 95.0f; + + // Probability a grid cell contains an island (0-1). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Islands", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float IslandDensity = 0.5f; + + // Island horizontal radius range (voxels). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Islands", meta = (ClampMin = "2.0")) + float IslandMinRadius = 18.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Islands", meta = (ClampMin = "2.0")) + float IslandMaxRadius = 42.0f; + + // Island vertical thickness as a fraction of its horizontal radius. + // 0.4 → thin plates · 0.7 → chunky (default) · 1.0 → near-spherical + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Islands", meta = (ClampMin = "0.1", ClampMax = "1.5")) + float ThicknessRatio = 0.7f; + + // How much islands scatter vertically within the void (0 = all mid-height, 1 = full spread). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Islands", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float VerticalJitter = 0.6f; + + // Flatten island tops into walkable land (0 = round dome, 1 = flat plateau). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Islands", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float TopFlatten = 0.6f; + + // Surface roughness on island shells (voxels) — craggy undersides, bumpy tops. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Islands", meta = (ClampMin = "0.0")) + float SurfaceRoughness = 4.0f; + + // SmoothMin blend radius for merging overlapping islands (voxels). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Islands", meta = (ClampMin = "0.0")) + float SDFBlendRadius = 5.0f; + + // Solid shell thickness at strate top/bottom (voxels). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Islands", meta = (ClampMin = "0.0")) + float BoundarySealThickness = 4.0f; + + // Rock solidity inside islands. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Islands") + float BaseDensity = 8.0f; + + // Runtime Z bounds (voxel coords) — set by StrateManager, do not edit. + float StrateTopWorldZ = 0.0f; + float StrateBottomWorldZ = 0.0f; +}; + +//============================================================================= +// DISTURBANCE PARAMS (the "wow" layer — applies to ANY archetype) +//============================================================================= +/** + * FStrateDisturbanceParams — composable surprises layered on top of whatever + * archetype a strate uses, as a density post-process. Hash-placed and deterministic, + * they never breach the strate seals (they only act in the interior). Set a feature's + * density to 0 to disable it. Designed to make exploration unpredictable: a chasm + * splitting an otherwise tidy maze, a natural bridge over a chamber, blades of rock + * rising from the floor. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FStrateDisturbanceParams +{ + GENERATED_BODY() + + // --- CHASMS: large vertical rifts that carve open air through the interior --- + // Probability a chasm cell is active (0 = disabled). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Disturbance|Chasms", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float ChasmDensity = 0.0f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Disturbance|Chasms", meta = (ClampMin = "20.0")) + float ChasmSpacing = 170.0f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Disturbance|Chasms", meta = (ClampMin = "1.0")) + float ChasmRadius = 14.0f; + + // --- BRIDGES: horizontal solid spans across open space --- + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Disturbance|Bridges", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float BridgeDensity = 0.0f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Disturbance|Bridges", meta = (ClampMin = "20.0")) + float BridgeSpacing = 120.0f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Disturbance|Bridges", meta = (ClampMin = "1.0")) + float BridgeRadius = 5.0f; + + // --- RIDGES: thin solid blades rising from the floor --- + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Disturbance|Ridges", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float RidgeDensity = 0.0f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Disturbance|Ridges", meta = (ClampMin = "20.0")) + float RidgeSpacing = 110.0f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Disturbance|Ridges", meta = (ClampMin = "0.0")) + float RidgeHeight = 30.0f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Disturbance|Ridges", meta = (ClampMin = "1.0")) + float RidgeThickness = 6.0f; + + // Rock solidity used to scale carve/fill strength. Match the strate's BaseDensity. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Disturbance") + float BaseDensity = 8.0f; + + // Seal thickness — disturbances stay clear of the seal bands. Match the strate. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Disturbance", meta = (ClampMin = "0.0")) + float BoundarySealThickness = 4.0f; + + // Runtime Z bounds (voxel coords) — set by StrateManager, do not edit. + float StrateTopWorldZ = 0.0f; + float StrateBottomWorldZ = 0.0f; +}; + +//============================================================================= +// INTER-STRATE PASSAGE CONFIG (per-strate tunnel control) +//============================================================================= +/** + * EVoxelPassageStyle — the shape of an auto-carved inter-strate tunnel. + */ +UENUM(BlueprintType) +enum class EVoxelPassageStyle : uint8 +{ + Straight UMETA(DisplayName = "Straight (vertical shaft)"), + Worm UMETA(DisplayName = "Worm (organic meander around the axis)"), + Spiral UMETA(DisplayName = "Spiral (corkscrew descent)"), + Cascading UMETA(DisplayName = "Cascading (ledge + drop staircase)") +}; + +/** + * FStratePassageConfig — how THIS strate connects DOWN to the strate below it. + * + * Lives on each UVoxelStrateDefinition: the upper strate of every boundary controls its + * own descent tunnels, so different layers connect differently. The (0,0) spine descent + * is separate (player-dug); these are the auto-carved shortcuts placed away from it. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FStratePassageConfig +{ + GENERATED_BODY() + + // How many auto-carved tunnels descend from this strate to the one below (0 = none). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage", meta = (ClampMin = "0", ClampMax = "12")) + int32 Connections = 1; + + // Tunnel shape. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage") + EVoxelPassageStyle Style = EVoxelPassageStyle::Worm; + + // ----- WIDTH (tapers along the length) ----- + // Radius at the two mouths (entry/exit) and at the middle. Equal = uniform tube; + // Mouth > Mid = chambers at the ends with a squeeze between; Mid > Mouth = a bulge. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Width", meta = (ClampMin = "1.0")) + float MouthRadius = 6.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Width", meta = (ClampMin = "1.0")) + float MidRadius = 4.0f; + + // ----- LENGTH ----- + // How far the tunnel reaches INTO each strate (voxels). Auto-capped to the interior. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Length", meta = (ClampMin = "8.0")) + float ReachMin = 40.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Length", meta = (ClampMin = "8.0")) + float ReachMax = 90.0f; + + // ----- PLACEMENT ----- + // Horizontal distance range from the (0,0) spine (voxels) where tunnels may appear. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Placement", meta = (ClampMin = "0.0")) + float DistanceMin = 60.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Placement", meta = (ClampMin = "0.0")) + float DistanceMax = 200.0f; + + // ----- SHAPE detail ----- + // Worm: max sideways excursion from the axis (voxels). 0 = straight even in Worm style. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Shape", meta = (ClampMin = "0.0")) + float Wander = 15.0f; + + // Path resolution (control points). Higher = smoother curves; lower = more faceted / + // cheaper. The worm makes several bends, so keep this reasonably high for smoothness. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Shape", meta = (ClampMin = "1", ClampMax = "48")) + int32 Segments = 20; + + // Vertical wobble (voxels) — dips/rises along the descent for a less uniform fall. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Shape", meta = (ClampMin = "0.0")) + float VerticalWobble = 0.0f; + + // Spiral style: helix radius (voxels) and number of full turns over the descent. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Spiral", meta = (ClampMin = "1.0")) + float SpiralRadius = 16.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Spiral", meta = (ClampMin = "0.25")) + float SpiralTurns = 2.0f; + + // Cascading style: number of ledge+drop steps, and how far each ledge runs (voxels). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Cascade", meta = (ClampMin = "1", ClampMax = "16")) + int32 CascadeSteps = 4; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Passage|Cascade", meta = (ClampMin = "0.0")) + float CascadeLedge = 14.0f; +}; + +//============================================================================= +// CONTENT ENTRY STRUCTS +//============================================================================= + +/** + * FStrateDecoration — One decoration type that can spawn on surfaces. + * + * Decorations are actors placed ON the cave surface (stalactites on ceilings, + * mushrooms on floors, crystals on walls, etc.). + * The decoration placer (future system) reads these entries from the active + * strate definition and spawns actors accordingly. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FStrateDecoration +{ + GENERATED_BODY() + + // The actor class to spawn (e.g., BP_Stalactite, BP_CrystalCluster) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration") + TSubclassOf ActorClass; + + // Which surface type this decoration can be placed on + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration") + ESurfaceType SurfacePlacement = ESurfaceType::Any; + + // Chance per valid surface point to spawn this decoration (0-1) + // 0.01 = rare, 0.1 = common, 0.5 = very dense + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float SpawnDensity = 0.05f; + + // Random scale range for variety + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration") + float MinScale = 0.8f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration") + float MaxScale = 1.2f; + + // ----- Placement rules ----- + + // Rotate the actor so its up-axis follows the surface normal (stalactites point + // down on ceilings, plants stand up on floors). If false, keeps world-up. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement") + bool bAlignToSurface = true; + + // Apply a deterministic random yaw so instances don't all face the same way. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement") + bool bRandomYaw = true; + + // Offset along the surface normal (world units). Positive = lift off the surface, + // negative = sink into it. Useful to embed roots or float crystals slightly. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement") + float SurfaceOffset = 0.0f; + + // Hard cap on how many of THIS decoration spawn per chunk (perf safety). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement", meta = (ClampMin = "1")) + int32 MaxPerChunk = 40; + + // Only place where this is below the strate water line (true) or above it (false). + // Ignored unless RequireWaterRelative is set. Lets you put seaweed underwater and + // grass above water in the same strate. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement") + bool bRequireWaterRelative = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement", + meta = (EditCondition = "bRequireWaterRelative")) + bool bPlaceBelowWater = false; +}; + +/** + * FStrateAmbientActor — An actor that spawns in open cave space. + * + * Unlike decorations (placed on surfaces), ambient actors float in the void: + * fog volumes, particle emitters, light sources, floating crystals, etc. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FStrateAmbientActor +{ + GENERATED_BODY() + + // The actor class to spawn + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Ambient") + TSubclassOf ActorClass; + + // Probability of this actor spawning per chunk (0-1) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Ambient", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float SpawnChancePerChunk = 0.1f; +}; + +/** + * FStrateCreature — A creature/enemy type that can spawn in this strate. + * + * Creatures are managed by a future spawning system that respects + * per-strate population limits and weighted random selection. + */ +USTRUCT(BlueprintType) +struct VOXELFORGE_API FStrateCreature +{ + GENERATED_BODY() + + // The creature actor class + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Creature") + TSubclassOf ActorClass; + + // Maximum number of this creature type alive in the entire strate at once + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Creature") + int32 MaxPerStrate = 5; + + // Relative spawn weight (higher = more likely to be chosen when spawning) + // If two creatures have weights 2.0 and 1.0, the first spawns 2x as often + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Creature") + float SpawnWeight = 1.0f; +}; diff --git a/Source/VoxelForge/Public/VoxelTerrainOpDefinition.h b/Source/VoxelForge/Public/VoxelTerrainOpDefinition.h new file mode 100644 index 0000000..bf041a7 --- /dev/null +++ b/Source/VoxelForge/Public/VoxelTerrainOpDefinition.h @@ -0,0 +1,350 @@ +// VoxelTerrainOpDefinition.h +// Individual terrain operation data asset — one per operation variant. +// +// DESIGN: +// ======== +// Each terrain operation (pit, arch, terrace, etc.) is its own asset. +// The strate definition holds a weighted array of references to these. +// This allows: +// - Reuse: "DA_Op_DeepPit" can be referenced by multiple strates +// - Composition: mix different ops per strate by drag-and-drop +// - Tuning: tweak one asset, all strates using it update +// +// HOW PARAMS FLOW: +// At generation time, the strate manager builds FStrateGenerationParams by: +// 1. Starting from the strate definition's base params (terrain op fields = 0) +// 2. For each terrain op reference: calling ApplyTo() which copies the op's +// fields into the params, scaled by the entry's Weight. +// 3. The generator reads the final params as usual — no code change needed. +// +// EDITOR UX: +// The Type enum controls which param group is visible via EditCondition. +// When you select "Pit", only pit-related fields appear in the Details panel. + +#pragma once + +#include "CoreMinimal.h" +#include "Engine/DataAsset.h" +#include "VoxelStrateTypes.h" +#include "VoxelTerrainOpDefinition.generated.h" + +/** + * EVoxelTerrainOpType — Which terrain operation this asset configures. + * Each type maps to a specific density modification in the generation pipeline. + */ +UENUM(BlueprintType) +enum class EVoxelTerrainOpType : uint8 +{ + // Surface treatments (modify cave wall character) + Terrace UMETA(DisplayName = "Terrace / Ledges"), + LayerLines UMETA(DisplayName = "Layer Lines"), + Ribbing UMETA(DisplayName = "Ribbing"), + Cliff UMETA(DisplayName = "Cliff Sharpening"), + Scallop UMETA(DisplayName = "Scallop / Erosion"), + Overhang UMETA(DisplayName = "Overhang / Shelf"), + + // Structural features (place geometry in caves) + Arch UMETA(DisplayName = "Arch / Bridge"), + Column UMETA(DisplayName = "Column / Pillar"), + Pit UMETA(DisplayName = "Pit / Shaft (downward)"), + Chimney UMETA(DisplayName = "Chimney / Shaft (upward)"), + Dome UMETA(DisplayName = "Dome (cathedral ceiling)"), + Pinch UMETA(DisplayName = "Pinch / Bottleneck"), +}; + +/** + * UVoxelTerrainOpDefinition — A single terrain operation, configured as a data asset. + * + * Create these in your Content folder (e.g., Content/TerrainOps/DA_Op_DeepPit). + * Then reference them from your strate definitions with a weight. + * + * USAGE EXAMPLE: + * 1. Create: Right-click Content → Miscellaneous → Data Asset → VoxelTerrainOpDefinition + * 2. Set Type to "Pit", configure depth/radius/taper + * 3. In your strate definition, add this asset to TerrainOperations with Weight=1.0 + */ +UCLASS(BlueprintType) +class VOXELFORGE_API UVoxelTerrainOpDefinition : public UPrimaryDataAsset +{ + GENERATED_BODY() + +public: + + //========================================================================= + // OPERATION TYPE + //========================================================================= + + // Which terrain operation this asset configures. + // Changing this shows/hides the relevant parameter group below. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Terrain Operation") + EVoxelTerrainOpType Type = EVoxelTerrainOpType::Terrace; + + //========================================================================= + // TERRACE PARAMS (visible when Type == Terrace) + //========================================================================= + + // Vertical spacing between terrace steps (in voxels). 0 = disabled. + // 3-5 → tight steps (staircase feel) + // 8-12 → wide ledges (platforming scale) + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Terrace", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Terrace")) + float TerraceStepHeight = 5.0f; + + // Edge sharpness: 0 = rounded steps, 1 = razor sharp edges. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Terrace", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Terrace", + ClampMin = "0.0", ClampMax = "1.0")) + float TerraceHardness = 0.5f; + + // Noise displacement on step edges (organic variation). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Terrace", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Terrace", + ClampMin = "0.0", ClampMax = "2.0")) + float TerraceNoiseDisplacement = 0.5f; + + //========================================================================= + // LAYER LINES PARAMS (visible when Type == LayerLines) + //========================================================================= + + // Spacing between horizontal geological lines (in voxels). 0 = disabled. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Layer Lines", + meta = (EditCondition = "Type == EVoxelTerrainOpType::LayerLines")) + float LayerLineSpacing = 4.0f; + + // Depth of the grooves (how much density is removed). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Layer Lines", + meta = (EditCondition = "Type == EVoxelTerrainOpType::LayerLines", + ClampMin = "0.0", ClampMax = "2.0")) + float LayerLineDepth = 0.3f; + + //========================================================================= + // RIBBING PARAMS (visible when Type == Ribbing) + //========================================================================= + + // Spacing between parallel ribs along Z (in voxels). 0 = disabled. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Ribbing", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Ribbing")) + float RibbingSpacing = 3.0f; + + // Depth of the ridges. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Ribbing", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Ribbing", + ClampMin = "0.0", ClampMax = "3.0")) + float RibbingDepth = 0.4f; + + //========================================================================= + // CLIFF PARAMS (visible when Type == Cliff) + //========================================================================= + + // How much to amplify vertical gradients near cave surfaces. + // 0 = no effect, 1 = maximum sheer cliff amplification. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cliff", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Cliff", + ClampMin = "0.0", ClampMax = "1.0")) + float CliffStrength = 0.5f; + + //========================================================================= + // SCALLOP PARAMS (visible when Type == Scallop) + //========================================================================= + + // Strength of the erosion bowl patterns (cellular noise subtraction). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Scallop", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Scallop", + ClampMin = "0.0", ClampMax = "2.0")) + float ScallopStrength = 0.5f; + + // Frequency of the cellular noise (smaller = larger bowls). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Scallop", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Scallop")) + float ScallopFrequency = 0.1f; + + //========================================================================= + // OVERHANG PARAMS (visible when Type == Overhang) + //========================================================================= + + // Probability/strength of overhang generation (0 = none, 1 = max). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Overhang", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Overhang", + ClampMin = "0.0", ClampMax = "1.0")) + float OverhangStrength = 0.5f; + + // How far the overhang protrudes from the wall (in voxels). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Overhang", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Overhang")) + float OverhangDepth = 5.0f; + + // Frequency of the noise that creates overhangs (lower = broader shelves). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Overhang", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Overhang")) + float OverhangFrequency = 0.06f; + + //========================================================================= + // ARCH PARAMS (visible when Type == Arch) + //========================================================================= + + // Probability per grid cell (0 = none, 0.3 = max recommended). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Arch", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Arch", + ClampMin = "0.0", ClampMax = "0.3")) + float ArchDensity = 0.1f; + + // Minimum arch thickness (radius of the capsule SDF). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Arch", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Arch", + ClampMin = "1.0")) + float ArchMinRadius = 3.0f; + + // Maximum arch thickness. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Arch", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Arch", + ClampMin = "1.0")) + float ArchMaxRadius = 6.0f; + + + //========================================================================= + // COLUMN PARAMS (visible when Type == Column) + //========================================================================= + + // Probability per grid cell (0 = none, 1 = every cell). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Column", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Column", + ClampMin = "0.0", ClampMax = "1.0")) + float ColumnDensity = 0.15f; + + // Minimum column radius. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Column", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Column", + ClampMin = "0.5")) + float ColumnMinRadius = 2.0f; + + // Maximum column radius. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Column", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Column", + ClampMin = "1.0")) + float ColumnMaxRadius = 5.0f; + + + //========================================================================= + // PIT PARAMS (visible when Type == Pit) + //========================================================================= + + // Probability per grid cell. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Pit", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Pit", + ClampMin = "0.0", ClampMax = "0.5")) + float PitDensity = 0.1f; + + // Minimum pit radius at the top. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Pit", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Pit", + ClampMin = "1.0")) + float PitMinRadius = 4.0f; + + // Maximum pit radius at the top. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Pit", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Pit", + ClampMin = "2.0")) + float PitMaxRadius = 10.0f; + + // How deep the pit extends downward (in voxels). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Pit", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Pit", + ClampMin = "5.0")) + float PitDepth = 25.0f; + + + //========================================================================= + // CHIMNEY PARAMS (visible when Type == Chimney) + //========================================================================= + + // Probability per grid cell. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Chimney", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Chimney", + ClampMin = "0.0", ClampMax = "1.0")) + float ChimneyDensity = 0.1f; + + // Minimum chimney radius. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Chimney", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Chimney", + ClampMin = "0.5")) + float ChimneyMinRadius = 2.0f; + + // Maximum chimney radius. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Chimney", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Chimney", + ClampMin = "1.0")) + float ChimneyMaxRadius = 5.0f; + + // How high the chimney extends upward (in voxels). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Chimney", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Chimney", + ClampMin = "5.0")) + float ChimneyHeight = 20.0f; + + + //========================================================================= + // DOME PARAMS (visible when Type == Dome) + //========================================================================= + + // Probability per grid cell. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dome", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Dome", + ClampMin = "0.0", ClampMax = "1.0")) + float DomeDensity = 0.15f; + + // Minimum dome radius. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dome", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Dome", + ClampMin = "3.0")) + float DomeMinRadius = 8.0f; + + // Maximum dome radius. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dome", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Dome", + ClampMin = "4.0")) + float DomeMaxRadius = 15.0f; + + // Height-to-radius ratio (0.5 = flat, 1.0 = hemisphere, 1.5 = tall/gothic). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Dome", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Dome", + ClampMin = "0.3", ClampMax = "2.0")) + float DomeHeightRatio = 0.8f; + + + //========================================================================= + // PINCH PARAMS (visible when Type == Pinch) + //========================================================================= + + // Probability per grid cell. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Pinch", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Pinch", + ClampMin = "0.0", ClampMax = "1.0")) + float PinchDensity = 0.15f; + + // How much the passage narrows (voxels of added density from each side). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Pinch", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Pinch", + ClampMin = "1.0")) + float PinchStrength = 5.0f; + + // Length of the narrowed section along the passage. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Pinch", + meta = (EditCondition = "Type == EVoxelTerrainOpType::Pinch", + ClampMin = "3.0")) + float PinchLength = 12.0f; + + + //========================================================================= + // APPLY TO GENERATION PARAMS + //========================================================================= + + /** + * Copy this operation's parameters into a FStrateGenerationParams struct. + * Only the fields matching this operation's Type are written. + * Weight scales the primary "strength" or "density" field for intensity control. + * + * @param OutParams - The generation params to write into + * @param Weight - Multiplier for the op's intensity (1.0 = full strength) + */ + void ApplyTo(FStrateGenerationParams& OutParams, float Weight = 1.0f) const; +}; diff --git a/Source/VoxelForge/Public/VoxelTypes.h b/Source/VoxelForge/Public/VoxelTypes.h new file mode 100644 index 0000000..ecca60e --- /dev/null +++ b/Source/VoxelForge/Public/VoxelTypes.h @@ -0,0 +1,173 @@ +// VoxelTypes.h +// Core type definitions, coordinate conversions, and mesh data. +// +// Ce header est le socle du plugin — tout le monde l'inclut. +// Pas de dépendance sur des UClasses ici (on reste léger). + +#pragma once + +#include "CoreMinimal.h" + +//============================================================================= +// CHUNK CONSTANTS +//============================================================================= +// +// Taille de chunk: 32^3 = 32 768 voxels. +// Pourquoi 32 ? Puissance de 2 → astuces bit à bit + bon alignement GPU. +// VOXEL_SIZE = 25 cm/voxel (Unreal travaille en centimètres). + +constexpr int32 CHUNK_SIZE = 32; +constexpr int32 CHUNK_SIZE_SQUARED = CHUNK_SIZE * CHUNK_SIZE; // 1024 +constexpr int32 CHUNK_VOLUME = CHUNK_SIZE * CHUNK_SIZE * CHUNK_SIZE; // 32768 + +constexpr float VOXEL_SIZE = 25.0f; + +//============================================================================= +// FACE DIRECTIONS +//============================================================================= +// +// Les 6 faces d'un cube. Gardé ici car le mesher peut encore en avoir besoin +// (ex. normales orientées), et les strates pourraient s'en servir pour des +// décorations "sur le sol" vs "au plafond". + +enum class EVoxelFace : uint8 +{ + PositiveX, // East (+X) + NegativeX, // West (-X) + PositiveY, // North (+Y) + NegativeY, // South (-Y) + PositiveZ, // Up (+Z) + NegativeZ, // Down (-Z) +}; + +inline FIntVector GetFaceDirection(EVoxelFace Face) +{ + switch (Face) + { + case EVoxelFace::PositiveX: return FIntVector( 1, 0, 0); + case EVoxelFace::NegativeX: return FIntVector(-1, 0, 0); + case EVoxelFace::PositiveY: return FIntVector( 0, 1, 0); + case EVoxelFace::NegativeY: return FIntVector( 0, -1, 0); + case EVoxelFace::PositiveZ: return FIntVector( 0, 0, 1); + case EVoxelFace::NegativeZ: return FIntVector( 0, 0, -1); + default: return FIntVector( 0, 0, 0); + } +} + +inline FVector GetFaceNormal(EVoxelFace Face) +{ + const FIntVector Dir = GetFaceDirection(Face); + return FVector(Dir.X, Dir.Y, Dir.Z); +} + +//============================================================================= +// COORDINATE CONVERSION +//============================================================================= +// +// Trois espaces: +// WORLD (FVector, cm) → position Unreal +// CHUNK (FIntVector) → quel chunk (32 voxels) +// LOCAL (FIntVector 0-31) → position dans le chunk +// +// Relation: WorldPos = (ChunkCoord * CHUNK_SIZE + LocalPos) * VOXEL_SIZE + +inline FIntVector WorldToChunkCoord(const FVector& WorldPos) +{ + // FloorToInt (pas division int) pour gérer proprement les coords négatives: + // -5 / 32 = 0 (faux — on veut -1) + // floor(-5 / 32) = floor(-0.156) = -1 (correct) + return FIntVector( + FMath::FloorToInt((WorldPos.X / VOXEL_SIZE) / CHUNK_SIZE), + FMath::FloorToInt((WorldPos.Y / VOXEL_SIZE) / CHUNK_SIZE), + FMath::FloorToInt((WorldPos.Z / VOXEL_SIZE) / CHUNK_SIZE) + ); +} + +inline FIntVector WorldToLocalCoord(const FVector& WorldPos) +{ + // ((x % n) + n) % n → modulo positif même pour x négatif. + // (-5 % 32) = -5 en C++, mais on veut 27. + return FIntVector( + ((FMath::FloorToInt(WorldPos.X / VOXEL_SIZE) % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE, + ((FMath::FloorToInt(WorldPos.Y / VOXEL_SIZE) % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE, + ((FMath::FloorToInt(WorldPos.Z / VOXEL_SIZE) % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE + ); +} + +inline FVector ChunkToWorldPos(const FIntVector& ChunkCoord) +{ + return FVector( + ChunkCoord.X * CHUNK_SIZE * VOXEL_SIZE, + ChunkCoord.Y * CHUNK_SIZE * VOXEL_SIZE, + ChunkCoord.Z * CHUNK_SIZE * VOXEL_SIZE + ); +} + +// Index 3D → 1D pour un tableau plat (voir doc CLAUDE.md: "x + y*SizeX + z*SizeX*SizeY"). +inline int32 LocalToIndex(int32 X, int32 Y, int32 Z) +{ + return X + (Y * CHUNK_SIZE) + (Z * CHUNK_SIZE_SQUARED); +} + +inline FIntVector IndexToLocal(int32 Index) +{ + return FIntVector( + Index % CHUNK_SIZE, + (Index / CHUNK_SIZE) % CHUNK_SIZE, + Index / CHUNK_SIZE_SQUARED + ); +} + +inline bool IsValidLocalCoord(int32 X, int32 Y, int32 Z) +{ + return X >= 0 && X < CHUNK_SIZE + && Y >= 0 && Y < CHUNK_SIZE + && Z >= 0 && Z < CHUNK_SIZE; +} + +inline bool IsValidLocalCoord(const FIntVector& Coord) +{ + return IsValidLocalCoord(Coord.X, Coord.Y, Coord.Z); +} + +//============================================================================= +// MATH HELPERS (partagés entre générateur, morphologie, manager) +//============================================================================= + +// Smoothstep classique: 3x² - 2x³. Maps 0→0, 1→1, avec pente nulle aux bornes. +// Utilisé partout pour adoucir les transitions de densité (blend SDF, seal boundary). +// Entrée: x doit être dans [0, 1] (clamp avant si besoin). +inline float SmoothStep01(float x) +{ + return x * x * (3.0f - 2.0f * x); +} + +// UE's PerlinNoise3D renvoie ~[-0.8, 0.8] — ce facteur remet à ~[-1, 1] +// pour correspondre aux attentes des formules de densité. +constexpr float VOXEL_NOISE_SCALE = 1.25f; + +//============================================================================= +// MESH DATA +//============================================================================= +// +// Sortie du mesher: géométrie prête pour l'upload GPU. +// Struct plain C++ (pas de USTRUCT) — uniquement utilisé entre tâches C++. +// Si lighting est ajouté plus tard, ré-ajouter un champ Colors dédié. + +struct FVoxelMeshData +{ + TArray Vertices; // Positions monde + TArray Triangles; // Indices, 3 par triangle + TArray UVs; // Coords de texture (une par vertex) + TArray Normals; // Normale lissée (gradient de densité) + + void Clear() + { + Vertices.Empty(); + Triangles.Empty(); + UVs.Empty(); + Normals.Empty(); + } + + bool IsEmpty() const { return Vertices.Num() == 0; } +}; diff --git a/Source/VoxelForge/Public/VoxelWorld.h b/Source/VoxelForge/Public/VoxelWorld.h new file mode 100644 index 0000000..09fab3a --- /dev/null +++ b/Source/VoxelForge/Public/VoxelWorld.h @@ -0,0 +1,426 @@ +// VoxelWorld.h +// The main world manager - orchestrates chunk loading, generation, meshing, and rendering + +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Actor.h" +#include +#include "VoxelTypes.h" +#include "VoxelChunk.h" +#include "VoxelGenerator.h" +#include "VoxelMarchingCubesMesher.h" +#include "VoxelSettings.h" +#include "VoxelStrateManager.h" +#include "VoxelDiffLayer.h" +#include "VoxelWorld.generated.h" + +// Forward declaration +class URealtimeMeshComponent; +class URealtimeMeshSimple; +class UVoxelDiffLayer; +class UVoxelContentManager; +class UVoxelAtmosphereManager; + +/** + * AVoxelWorld - The main voxel terrain actor + * + * RESPONSIBILITIES: + * - Track which chunks should be loaded based on player position + * - Create/destroy chunks as player moves + * - Coordinate generation and meshing + * - Manage mesh components for rendering + * + * LIFECYCLE: + * - BeginPlay: Initialize generator, mesher + * - Tick: Update chunks around player position + */ +struct FChunkResult +{ + FIntVector ChunkCoord; + FVoxelChunk Chunk; + FVoxelMeshData MeshData; + int32 LODLevel = 0; // 0=full, 1=half, 2=quarter resolution + uint32 Epoch = 0; // Generation epoch — discard if stale +}; + +UCLASS() +class VOXELFORGE_API AVoxelWorld : public AActor +{ + GENERATED_BODY() + +public: + AVoxelWorld(); + + //========================================================================= + // SETTINGS + //========================================================================= + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel World") + UVoxelSettings* Settings; + + //========================================================================= + // COMPONENTS & REFERENCES + //========================================================================= + + /** The terrain generator */ + UPROPERTY() + UVoxelGenerator* Generator; + + /** The mesh builder */ + UPROPERTY() + UVoxelMarchingCubesMesher* Mesher; + + /** The strate manager — maps depth to strate definitions */ + UPROPERTY() + UVoxelStrateManager* StrateManager; + + /** Player terrain modifications (carving & filling). + * Stores density diffs on top of procedural terrain. + * Created in BeginPlay, passed to Generator for density evaluation. */ + UPROPERTY() + UVoxelDiffLayer* DiffLayer; + + /** Spawns per-chunk decorations/actors from the strate content pools and the + * aesthetic water surfaces. Created in BeginPlay. */ + UPROPERTY() + UVoxelContentManager* ContentManager; + + /** Drives per-strate fog + ambient + persistent ceiling/floor layer actors + * (e.g. seas of clouds) based on the strate the player is in. Created in BeginPlay + * when bManageAtmosphere is true. */ + UPROPERTY() + UVoxelAtmosphereManager* AtmosphereManager; + + /** When true, VoxelForge spawns & drives its own height fog + skylight + ceiling/floor + * layer actors from each strate's settings. Turn OFF if you manage fog/lighting + * yourself in the level (avoids a duplicate ExponentialHeightFog). */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel World") + bool bManageAtmosphere = true; + + //========================================================================= + // CHUNK STORAGE + //========================================================================= + + /** All currently loaded chunks, keyed by chunk coordinate */ + TMap Chunks; + + /** Mesh components for each chunk, keyed by chunk coordinate */ + UPROPERTY() + TMap ChunkMeshes; + + + + //========================================================================= + // TERRAIN MODIFICATION (player carving & filling) + //========================================================================= + + /** + * Carve terrain at a world position (remove rock, create air). + * Applies a spherical brush that subtracts density. + * + * @param Position - World-space center of the carve brush + * @param Radius - Brush radius in voxels (default 3) + * @param Strength - How aggressively to carve (default 10, higher = deeper) + */ + UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification") + void CarveAtPosition(FVector Position, float Radius = 3.0f, float Strength = 10.0f); + + /** + * Fill terrain at a world position (add rock, seal holes). + * Applies a spherical brush that adds density. + * + * @param Position - World-space center of the fill brush + * @param Radius - Brush radius in voxels (default 3) + * @param Strength - How aggressively to fill (default 10, higher = more solid) + */ + UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification") + void FillAtPosition(FVector Position, float Radius = 3.0f, float Strength = 10.0f); + + /** + * Box brush carve/fill. Position is world-space (Unreal units); ExtentVoxels is the + * box half-size in voxels. Strength magnitude controls aggressiveness (sign forced). + */ + UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification") + void CarveBox(FVector Position, FVector ExtentVoxels, float Strength = 12.0f); + + UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification") + void FillBox(FVector Position, FVector ExtentVoxels, float Strength = 12.0f); + + /** + * Capsule brush carve/fill between two world-space points (Unreal units), with a + * tube radius in voxels. Great for boring tunnels or laying solid pillars/walls. + */ + UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification") + void CarveCapsule(FVector WorldA, FVector WorldB, float RadiusVoxels = 3.0f, float Strength = 12.0f); + + UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification") + void FillCapsule(FVector WorldA, FVector WorldB, float RadiusVoxels = 3.0f, float Strength = 12.0f); + + /** + * Apply a fully-specified modification (any shape). Centers/endpoints are expected + * in VOXEL coordinates here (this is the low-level entry the helpers build on). + * Returns nothing; re-meshes affected chunks. + */ + UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification") + void ApplyModification(const FVoxelModification& Modification); + + /** + * Clear all player modifications (e.g., on season reset). + * Regenerates all currently loaded chunks to restore procedural terrain. + */ + UFUNCTION(BlueprintCallable, Category = "Voxel World|Modification") + void ClearAllModifications(); + + //========================================================================= + // SEED / SEASON MANAGEMENT + //========================================================================= + + /** + * Change the world seed and regenerate everything. + * + * This is the "season reset" operation: + * 1. Updates the seed in Settings, Generator, and StrateManager + * 2. Clears ALL player modifications (carvings are meaningless in a new world) + * 3. Resets the elevator depth (player must re-discover strates) + * 4. Increments the season counter + * 5. Unloads all chunks and lets Tick reload them with the new seed + * + * The game layer is responsible for calling this at season boundaries, + * saving/loading the season counter, and any pre-reset cleanup (inventory, etc.) + * + * @param NewSeed - The new world seed + */ + UFUNCTION(BlueprintCallable, Category = "Voxel World|Season") + void ChangeSeed(int32 NewSeed); + + /** + * Get the current world seed. + */ + UFUNCTION(BlueprintPure, Category = "Voxel World|Season") + int32 GetCurrentSeed() const; + + /** + * Get the current season number (incremented each time ChangeSeed is called). + */ + UFUNCTION(BlueprintPure, Category = "Voxel World|Season") + int32 GetCurrentSeason() const; + + //========================================================================= + // STRATE QUERIES (gameplay integration) + //========================================================================= + + /** + * Get which strate index a world position is in. + * + * @param WorldPosition - Position in world space (Unreal units) + * @return Strate index (0 = topmost), or -1 if outside all strates + */ + UFUNCTION(BlueprintPure, Category = "Voxel World|Strate") + int32 GetStrateAtPosition(FVector WorldPosition) const; + + //========================================================================= + // LIVE EDIT (debug tuning in PIE) + //========================================================================= + + /** When true, editing your Strate Definition data assets during PIE + * will automatically regenerate all chunks so you see the result live. + * Also adds a "Regenerate" button in Details for manual refresh. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Live Edit") + bool bLiveEditStrates = false; + + /** Click to force-regenerate all chunks right now (useful during PIE). */ + UFUNCTION(CallInEditor, BlueprintCallable, Category = "Live Edit") + void RegenerateAllChunks(); + + /** Re-read ALL of VoxelSettings (strate layout, inter-strate gap, passages, spine) + * and rebuild from scratch, then regenerate every chunk. Use this after changing + * passage / gap / spine settings so they apply live without restarting PIE — + * RegenerateAllChunks alone keeps the existing layout & passages. */ + UFUNCTION(CallInEditor, BlueprintCallable, Category = "Live Edit") + void RebuildStrates(); + + //========================================================================= + // EDITOR BRUSH (manual carve/fill from the Details panel, works in PIE) + //========================================================================= + // The diff layer only exists while playing, so these buttons act during PIE. + + /** World-space center (Unreal units) for the editor carve/fill buttons below. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Editor Brush") + FVector EditorBrushCenter = FVector::ZeroVector; + + /** Brush radius in voxels for the editor buttons. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Editor Brush", meta = (ClampMin = "0.5")) + float EditorBrushRadius = 6.0f; + + /** Brush strength for the editor buttons (sign forced by the button). */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Editor Brush", meta = (ClampMin = "0.1")) + float EditorBrushStrength = 12.0f; + + /** Carve a sphere at EditorBrushCenter using the brush settings above. */ + UFUNCTION(CallInEditor, BlueprintCallable, Category = "Editor Brush") + void EditorCarveSphere(); + + /** Fill a sphere at EditorBrushCenter using the brush settings above. */ + UFUNCTION(CallInEditor, BlueprintCallable, Category = "Editor Brush") + void EditorFillSphere(); + + /** Generation counter — incremented each time we regenerate. + * Async tasks carry this value; stale results are discarded. */ + uint32 GenerationEpoch = 0; + + /** Draw the inter-strate passages each frame (lines along the path + endpoint + * spheres) so you can see where they spawned and verify they carve. PIE only. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel World|Debug") + bool bDebugDrawPassages = false; + +#if WITH_EDITOR + virtual void PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) override; + + /** Called by FCoreUObjectDelegates::OnObjectModified when ANY UObject is edited. + * We filter for UVoxelStrateDefinition changes and regenerate if live edit is on. */ + void OnObjectModifiedInEditor(UObject* ModifiedObject); + + /** Handle to unbind the delegate when EndPlay is called */ + FDelegateHandle OnObjectModifiedHandle; +#endif + + //========================================================================= + // ACTOR LIFECYCLE + //========================================================================= + + virtual void BeginPlay() override; + virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override; + virtual void Tick(float DeltaTime) override; + + //========================================================================= + // CHUNK MANAGEMENT - YOU IMPLEMENT THESE + //========================================================================= + + /** + * Update which chunks are loaded based on a world position. + * + * CONCEPT: + * - Figure out which chunk the position is in (the "center") + * - Determine which chunks SHOULD exist (within view distance) + * - Load any chunks that should exist but don't + * - Unload any chunks that exist but shouldn't + * + * @param CenterPosition - Usually the player's position + */ + void UpdateChunksAroundPosition(const FVector& CenterPosition); + + /** + * Load a single chunk at the given coordinate. + * + * CONCEPT: + * - Create chunk data + * - Generate terrain + * - Build mesh + * - Create visual component + * + * @param ChunkCoord - Which chunk to load + */ + void LoadChunk(const FIntVector& ChunkCoord); + + /** + * Unload a single chunk. + * + * CONCEPT: + * - Remove and destroy the mesh component + * - Remove chunk data from storage + * + * @param ChunkCoord - Which chunk to unload + */ + void UnloadChunk(const FIntVector& ChunkCoord); + + /** + * Apply mesh data to a RealtimeMesh component. + * + * CONCEPT: + * - Get or create the mesh component for this chunk + * - Set the mesh data (vertices, triangles, etc.) + * + * @param ChunkCoord - Which chunk this mesh belongs to + * @param MeshData - The generated mesh data + */ + void ApplyMeshToChunk(const FIntVector& ChunkCoord, const FVoxelMeshData& MeshData); + + //========================================================================= + // HELPERS + //========================================================================= + + /** Get the current player position (or zero if no player) */ + FVector GetPlayerPosition() const; + + /** Check if a chunk coordinate is within view distance of a center chunk */ + bool IsChunkInRange(const FIntVector& ChunkCoord, const FIntVector& CenterChunk) const; + + /** + * Determine LOD level for a chunk based on its distance from the center. + * + * LOD CONCEPT: + * Chunks close to the player get full resolution (LOD0, Step=1). + * Chunks further away get coarser resolution (LOD1=Step 2, LOD2=Step 4). + * This dramatically reduces triangle count for distant terrain without + * visible quality loss (they're far away!). + * + * @param ChunkCoord - The chunk to evaluate + * @param CenterChunk - The player's current chunk + * @return LOD level: 0 (full), 1 (half), 2 (quarter) + */ + int32 GetLODForChunk(const FIntVector& ChunkCoord, const FIntVector& CenterChunk) const; + + /** + * Convert LOD level to marching cubes step size. + * LOD0 → Step 1 (every voxel) + * LOD1 → Step 2 (every 2nd voxel) + * LOD2 → Step 4 (every 4th voxel) + */ + static int32 LODToStep(int32 LODLevel); + + //========================================================================= + // ASYNC + //========================================================================= + // MPSC: up to MaxConcurrentTasks ChunkGen worker threads Enqueue concurrently, + // the game thread (ProcessPendingChunks) is the only consumer. The default Spsc + // mode is NOT safe for multiple producers — concurrent Enqueues race on the tail + // link and silently drop results, which leaks PendingChunkCoord slots until the + // budget is exhausted and streaming stalls permanently. Mpsc guards the producer side. + TQueue ProcessQueue; + TSet PendingChunkCoord; + + // Set to true during EndPlay — async tasks check this before accessing UObjects + std::atomic bShuttingDown{false}; + + // Number of async tasks currently running — EndPlay waits for this to reach 0 + std::atomic ActiveTaskCount{0}; + + // Last known player chunk coord — used by LoadChunk to compute LOD + FIntVector CurrentCenterChunk = FIntVector::ZeroValue; + + // Track current LOD per loaded chunk (for LOD transitions) + TMap ChunkLODs; + + // --- Streaming work-avoidance (perf) --- + // The desired chunk set only changes when the player crosses a chunk boundary. + // We cache it and only rebuild/cull/sort on a real move, and go idle once every + // desired chunk is streamed in — so a stationary player costs ~nothing per frame. + FIntVector LastUpdateCenter = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); + bool bAllChunksLoaded = false; + TArray DesiredSorted; // desired coords, nearest-first + TSet DesiredSet; // O(1) membership for the cull pass + + void ProcessPendingChunks(); + + /** + * Re-queue loaded chunks for async re-generation + re-meshing. + * Used after terrain modifications: the old mesh stays visible until + * the new async result comes back, so there's no visual pop. + * + * Chunks that aren't currently loaded are ignored (they'll generate + * fresh with the diff layer when loaded normally). + * + * @param DirtyCoords - Chunk coordinates that need re-meshing + */ + void RemeshDirtyChunks(const TArray& DirtyCoords); +}; diff --git a/Source/VoxelForge/VoxelForge.Build.cs b/Source/VoxelForge/VoxelForge.Build.cs new file mode 100644 index 0000000..2b18007 --- /dev/null +++ b/Source/VoxelForge/VoxelForge.Build.cs @@ -0,0 +1,36 @@ +// VoxelForge.Build.cs +// This file tells Unreal how to compile our plugin + +using UnrealBuildTool; + +public class VoxelForge : ModuleRules +{ + public VoxelForge(ReadOnlyTargetRules Target) : base(Target) + { + // PCH = Precompiled Headers - speeds up compilation + // UseExplicitOrSharedPCHs is the modern recommended setting + PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs; + + // Modules we depend on: + // - Core: Basic types (TArray, FString, etc.) + // - CoreUObject: UObject system (UCLASS, UPROPERTY, etc.) + // - Engine: Game engine features (AActor, UWorld, etc.) + PublicDependencyModuleNames.AddRange(new string[] + { + "Core", + "CoreUObject", + "Engine", + "GameplayTags", // For FGameplayTagContainer on strate definitions + }); + + // RealtimeMeshComponent - the rendering library we'll use + // This is a third-party plugin you should have installed + PublicDependencyModuleNames.Add("RealtimeMeshComponent"); + + // Private dependencies - only used in our .cpp files, not exposed in headers + PrivateDependencyModuleNames.AddRange(new string[] + { + // None for now - we'll add more as needed + }); + } +} diff --git a/VoxelForge.uplugin b/VoxelForge.uplugin new file mode 100644 index 0000000..a35f345 --- /dev/null +++ b/VoxelForge.uplugin @@ -0,0 +1,24 @@ +{ + "FileVersion": 3, + "Version": 1, + "VersionName": "1.0", + "FriendlyName": "VoxelForge", + "Description": "A voxel terrain generation plugin - built from scratch to learn", + "Category": "Procedural Generation", + "CreatedBy": "You", + "CreatedByURL": "", + "DocsURL": "", + "MarketplaceURL": "", + "SupportURL": "", + "CanContainContent": true, + "IsBetaVersion": true, + "IsExperimentalVersion": false, + "Installed": false, + "Modules": [ + { + "Name": "VoxelForge", + "Type": "Runtime", + "LoadingPhase": "Default" + } + ] +}