From db558d9e14f536ef060ca3305c4b8e22a5bc95a9 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Tue, 16 Jun 2026 03:39:22 +0200 Subject: [PATCH] j --- CODEMAP.md | 171 ++++- .../Private/VoxelAtmosphereManager.cpp | 111 +++- .../Private/VoxelCaveMorphology.cpp | 152 +++-- .../Private/VoxelContentManager.cpp | 186 +++++- Source/VoxelForge/Private/VoxelGenerator.cpp | 628 +++++++++++++++--- .../Private/VoxelMarchingCubesMesher.cpp | 82 ++- .../VoxelForge/Private/VoxelStrateManager.cpp | 28 + Source/VoxelForge/Private/VoxelWorld.cpp | 162 ++++- .../Public/VoxelAtmosphereManager.h | 21 +- .../VoxelForge/Public/VoxelContentManager.h | 53 +- Source/VoxelForge/Public/VoxelGenerator.h | 76 ++- Source/VoxelForge/Public/VoxelSettings.h | 12 +- .../VoxelForge/Public/VoxelStrateDefinition.h | 21 + Source/VoxelForge/Public/VoxelStrateManager.h | 8 + Source/VoxelForge/Public/VoxelStrateTypes.h | 78 ++- Source/VoxelForge/Public/VoxelTypes.h | 12 +- Source/VoxelForge/Public/VoxelWorld.h | 31 + Source/VoxelForge/VoxelForge.Build.cs | 2 +- 18 files changed, 1570 insertions(+), 264 deletions(-) diff --git a/CODEMAP.md b/CODEMAP.md index fe519b8..3d9261a 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -4,7 +4,7 @@ > 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. +> Plugin root: `Source/VoxelForge/` · ~8,300 lines of C++ across 25 files. > Comments in the code are mixed **French + English**. UE module = `VoxelForge` (Runtime). --- @@ -83,7 +83,7 @@ Paths relative to `Source/VoxelForge/`. `Public/` = headers, `Private/` = impl. ### 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. | +| `CHUNK_SIZE` (32), `CHUNK_SIZE_SQUARED`, `CHUNK_VOLUME` | 19-21 | Chunk dimensions. (64³ tried for fewer draws → reverted: streaming too bursty. fps is fixed render-side instead.) | | `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). | @@ -101,8 +101,8 @@ per-chunk info later. `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) | +| Streaming | `ViewDistanceXY=16`, `ViewDistanceUp/Down=5`, `MaxConcurrentTasks=16`, `MaxMeshAppliesPerFrame=4` (defaults — actual values live on the data asset) | +| LOD | `LOD0Distance=4`, `LOD1Distance=8` | | 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. | @@ -155,6 +155,11 @@ per-chunk info later. | **`GetDensityAt`** | 218 | **Entry point.** Picks strate + generator type, dispatches, adds diff offset. | | **`GetDensityWithParams`** | 277 | TunnelNetwork pipeline (~1000 lines). See §4. | | **`GetSlabDensity`** | 1306 | FlatPlain/CrystalChamber pipeline. See §4.2. | +| `ComputeSurfaceTerrainZ` / `GetSurfaceDensity` | — | SurfaceWorld heightfield → terrain Z, then density; biome **output-blend** lerps dominant/neighbour heights (`ParamsD`/`ParamsN`/weight). §8.14. | +| `SampleRelief` / `SampleMoisture` | — | Climate fields (pure XY, [0,1]). Relief = shared source of truth for the relief map M. §8.14. | +| `SampleBiomeAt` | — | Warped-Voronoi + climate biome query (dominant + neighbour + weight). Reference used by the preview bake + `GetDominantBiomeAt`. §8.14. | +| `ResolveBiomeSampleAt` / `RebuildBiomeGrid` | — | Hot-path biome resolve (FBiomeSample) via a box-validated per-chunk cell-grid cache. Bit-identical to `SampleBiomeAt`. §8.14, §8.10. | +| `GetDominantBiomeAt` | — | Game-thread query → dominant biome ASSET (content/atmosphere). §8.14. | ### 3.7 Cave morphology (SDF rooms/tunnels) — `Public/VoxelCaveMorphology.h` + `.cpp` Header is rich with inline docs. Two namespaces + a per-chunk cache system. @@ -168,7 +173,7 @@ Header is rich with inline docs. Two namespaces + a per-chunk cache system. - `namespace VoxelCaveMorphology`: | Function | .cpp line | Role | |----------|-----------|------| - | `BuildChunkCache` | 47 | **Phase 1** (once/chunk): collect rooms, nearest-neighbor backbone, decide tunnels, pre-bake pits/chimneys/columns, hash-roll per-room terrain op. | + | `BuildChunkCache` | 47 | **Phase 1** (once/chunk): collect rooms, guaranteed backbone (`bTunnelsFlowTowardOrigin`: tree rooted at the (0,0) hub — every room reachable, links flow inward; false = legacy NN forest), slope-aware link metric (`TunnelHorizontalBias` now applies to backbone too), decide tunnels, **cull zero-connection rooms** (no sealed bubbles), store rooms by their OWN reach (fixes origin-room clipping at `MaxInfluence`), pre-bake pits/chimneys/columns, 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. | @@ -192,7 +197,8 @@ Header is rich with inline docs. Two namespaces + a per-chunk cache system. **`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, +`GenerationParams`(113), `SlabParams`(124), `Biomes[]`+`BiomeMapParams` (the biome list + +field tuning — empty ⇒ unchanged world, §8.14), `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). @@ -209,6 +215,7 @@ Maps depth→strate at runtime; owns passages. | `GetStrateForChunk` | 466 | Chunk → definition. | | `GetGeneratorTypeForChunk` | 476 | Chunk → generator type. | | `GetSlabParamsForChunk` | 490 | Slab params with runtime Z bounds (no blend — slabs use Hard). | +| `GetBiomeContextForChunk` | — | Flatten the strate's `Biomes[]` + `BiomeMapParams` into a POD `FBiomeContext` for the biome field. Empty ⇒ biomes disabled. §8.14. | | `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. | @@ -218,6 +225,17 @@ Ribbing, Cliff, Scallop, Overhang, Arch, Column, Pit, Chimney, Dome, Pinch. Per- param groups gated by EditCondition. `ApplyTo(OutParams, Weight)` (.cpp:6) copies only the active type's fields into `FStrateGenerationParams`, scaled by Weight. +**`Public/VoxelBiomeTypes.h`** (NEW) — biome vocabulary. `FBiomeMapParams` (Voronoi cell size / +border warp+blend / climate field freqs), `EBiomePreviewChannel` (preview-bake selector), and plain +runtime PODs `FBiomeResolved` / `FBiomeContext` / `FBiomeSample` / `FChunkBiomeCache` (the +box-validated per-chunk grid cache). See §8.14. + +**`Public/VoxelBiomeDefinition.h` + `.cpp`** (NEW) — `UVoxelBiomeDefinition : UPrimaryDataAsset`. +One asset = one biome: identity + `DebugColor`, climate placement box (`ReliefMin/Max`, +`MoistureMin/Max`), terrain override (`bOverrideTerrain` + `GeneratorType` + archetype params), content profile (`Decorations`/`AmbientActors`, +atmosphere override, `WaterMaterial`, reserved `MaterialPaletteIndex`), `GameplayTags`. Referenced from +`UVoxelStrateDefinition::Biomes[]`. Generator-agnostic (surface biomes now, cave biomes later). §8.14. + ### 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`. @@ -279,7 +297,7 @@ Stage order (negative=solid throughout). Each stage's anchor: | 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. | +| 5 — Worm tunnels | 1241 | abs(noise1)+abs(noise2), masked by distance-to-network (`WormNetworkRange`: braids hugging rooms/tunnels, no far-field speckle; 0 = legacy unmasked). | | 6 — Boundary seal | 1274 | Solid top/bottom shells (`ApplyBoundarySeal`). | | 7 — Inter-strate passages | 1281 | Carve passages/elevator (`ApplyPassageCarving`). | @@ -312,6 +330,9 @@ Stage order (negative=solid throughout). Each stage's anchor: | 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). | +| Biome placement / layout | `BiomeMapParams` on the strate (cell size, warp, climate freqs) + each biome's climate box. Bake `AVoxelWorld::BakeBiomePreview` to tune. §8.14. | +| What a biome does to terrain | A full archetype param override on the biome (`bOverrideTerrain` + `SurfaceParams`); surface output-blends dominant/neighbour heights in `GetSurfaceDensity`. Caves = content/atmosphere only (determinism, §8.14). | +| Add a biome / biome content | New `UVoxelBiomeDefinition` asset → add to the strate's `Biomes[]`. §8.14 / §8.12. | | Season reset | `AVoxelWorld::ChangeSeed` :740. | --- @@ -357,7 +378,7 @@ by `GeneratorType`) and its own density function in `VoxelGenerator.cpp`, dispat | 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 | +| SurfaceWorld | `FSurfaceGenerationParams` | `GetSurfaceDensity` | heightfield terrain: domain-warped continents+ridged mtns+detail, a low-freq **relief map** (`M`) that scales mountains/elevation for plains↔highland variety, opt-in plateau **terracing**, beaches at water line, high sky-cap ceiling. (`Surface|Macro` params = the cheap precursor to biomes; `ReliefStrength=0` ⇒ old uniform terrain.) | | 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 | @@ -366,6 +387,8 @@ All density fns share the convention: internal **positive=solid**, apply origin 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). +On top of the archetype, an optional **biome** layer (§8.14) modulates terrain & content WITHIN a +strate via a window-invariant XY field — currently wired into SurfaceWorld. ### 8.2 (0,0) spine & hybrid connections - `ApplyOriginSpine` (VoxelGenerator.cpp, static helper) carves a guaranteed open vertical @@ -395,12 +418,24 @@ 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`. +`ClearAll`/`SetSeed` in `ChangeSeed`, `SetActiveStrate(GetStrateAtPosition(player))` each Tick. +Deterministic decoration scatter on mesh vertices (surface-type / water-relative / +align-to-normal / random-yaw / scale / `MaxPerChunk`, from `FStrateDecoration`). +**Two render paths per entry:** `ActorClass` (real actors — lights/logic/interaction, +keep `MaxLODLevel=0`) vs `InstancedMesh` (batched per-chunk **HISM**, no tick/actor cost, +no collision; safe at `MaxLODLevel` 1-2 so visual props don't pop out with LOD0 — emissive +materials still glow at distance). Placement samples the LOD's vertices ⇒ instances +re-scatter slightly on LOD swap (masked by terrain pop). **Strate light culling:** +light components on decoration actors are visible only while the player is in the SAME +strate (`SetActiveStrate`, no-op until strate change) — analytical occlusion: seals+gap are +light-tight, and shadowless lights otherwise BLEED through rock (shadowed ones render black +at full cost). 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`. +**Per-biome content (§8.14):** when the chunk's dominant biome (`Generator::GetDominantBiomeAt`) +supplies decorations they REPLACE the strate's; biome `WaterMaterial` overrides the plane's material +(level stays strate-global). NOTE: `GetChunkStrateIndex` (light culling) feeds `GetStrateIndex` which +expects Unreal **cm** — it now passes `chunkZ*CHUNK_SIZE*VOXEL_SIZE` (was voxel-Z, a latent strate-match bug). ### 8.6 Atmosphere — `VoxelAtmosphereManager.h/.cpp` (NEW) `UVoxelAtmosphereManager` (owned by `AVoxelWorld`, gated by `bManageAtmosphere`). @@ -412,6 +447,10 @@ PERSISTENT ceiling/floor "layer" actors (`Def->CeilingLayerActor`/`FloorLayerAct `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.) +**Per-biome atmosphere (§8.14):** `UpdateForPlayer` also resolves the player's dominant biome and, +when the biome has `bOverrideAtmosphere`, its fog/sky beats the strate's (reacts on biome change, not +just strate change). `ApplyFogSky(Def, Biome)` is the shared path; layer actors + the full `AtmosphereActor` +BP stay strate-level. Needs the generator injected (`Initialize(..., Generator)`). ### 8.7 Inter-strate bedrock gap `VoxelSettings::InterStrateGapChunks` (N) inserts N chunks of SOLID bedrock between consecutive @@ -451,12 +490,50 @@ driven by `EditorBrush*` props. 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. +- **Biome cache** (`ResolveBiomeSampleAt`/`FChunkBiomeCache`, §8.14): validity is a world-XY BOX + + ChunkZ + Seed, NOT a chunk key — same reason as the SDF cache. The cell classification is + noise-heavy; a chunk-key would thrash it on gradient-normal / +X/+Y boundary samples. Keep + the box halo (≥ CHUNK_SIZE) + cell margin (warp + CellSize) so the 3x3 lookup never misses. - **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. +- **Mesher density grid + margin ring** (`GenerateMesh`): sample each grid point ONCE into a flat + `(CHUNK_SIZE/Step + 1 + 2)³` array (the `+2` is a 1-point MARGIN ring, indices −1..GridDim, for + T1.b normals). The cell loop reads 8 corners from it; per-cell sampling would call `GetDensityAt` + ~8× too often. Geometry is bit-identical (edge positions unchanged). Don't refactor back to + per-corner `GetDensity` and don't drop the margin ring (normals + seamless borders need it). +- **Normals from the density grid (T1.b)** (`GenerateMesh`): corner gradients = central differences + on the (margin) grid; edge normals interpolate the two corner gradients by the SAME `t` as the + position → seamless across chunk borders (both sides use identical pure samples). NO per-vertex + `GetDensityAt` (was ~6/vertex, often as costly as the whole grid). `ComputeGradientNormal` is now + unused. Only NORMALS changed vs the old path; geometry is identical. +- **Surface column cache (T1.a)** (`FSurfaceColumnCache`, `GetDensityAt` SurfaceWorld branch): + the heightfield + sky-cap + biome blend are XY-only but sampled ~33× per column (once per Z + grid-point). Cached per integer XY (box-valid, like the SDF cache) and reused down the column. + Box `Halo = CHUNK_SIZE + 8` each side so the T1.b margin ring stays inside (no thrash). **Used + ONLY for integer-XY queries**; fractional queries compute directly → bit-identical. Don't key it + by chunk and don't feed it fractional coords. +- **Collision only at LOD0 (T1.c)** (`ApplyMeshToChunk`): `UpdateSectionConfig(..., LOD==0)`. + LOD1/2 chunks are unreachable (the §8.10 reconciliation hot-swaps to LOD0 before the player + arrives), so cooking their Chaos collision is waste. Don't force collision on for all LODs. +- **No shadows on LOD2 (draw-call cut)** (`ApplyMeshToChunk`): `MeshComp->SetCastShadow(LOD <= 1)`. + Every shadow-casting chunk emits a SECOND draw in the shadow-depth pass; the farthest tier + (LOD2, blocky) doesn't need it. Re-applied every apply (LOD hot-swaps reuse the component). + Pulling `LOD0Distance`/`LOD1Distance` inward pushes more chunks into the shadowless LOD2 band + → fewer draws (free fps knob). Widen to `<= 0` (drop LOD1 shadows too) if still draw-bound. + NOTE: fps is a RENDER-side problem (draw calls ≈ visible chunk count × passes) — generation + cost (worker threads) and LOD *resolution* (cuts triangles, not draws) don't move it. +- **Insights scopes** `VoxelForge_GenerateMesh` / `VoxelForge_ApplyMeshToChunk` (Perf 0) bracket + the worker gen + game-thread apply — capture a trace to see if we're density- or upload-bound. +- **Float SIMD noise core (T2.a)** (`Public/VoxelNoise.h`): the density hot path uses + `VoxelNoise::Perlin3D` (single-sample, float, table-free hash-gradient) and `VoxelNoise::FBM` / + `Ridged` (octaves evaluated **4-wide via SSE** `Perlin3D_x4`) — NOT `FMath::PerlinNoise3D` + (double-precision, the old ~6.6 ms/chunk noise cost). `FractalNoise3D` / `RidgedNoise3D` in + `VoxelGenerator.cpp` are now thin wrappers over it; every call site is unchanged. It's a + DIFFERENT noise field than FMath's ⇒ a ONE-TIME world re-tune (fBm/Ridged contracts/[-1,1] are + identical). Pure function of (x,y,z) ⇒ every box-validity cache stays valid. Scalar `Perlin3D` + and SSE `Perlin3D_x4` are op-for-op identical (bit-identical on x86) — the SIMD path is a free + speedup; `#define VF_NOISE_USE_SIMD 0` falls back to scalar with no re-tune if a toolchain + rejects the SSE4.1 intrinsics. StrateManager's passage/transition Perlin calls were left on + `FMath` (layout-time, not per-voxel). Don't reintroduce `FMath::PerlinNoise3D` on the density path. - **`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 @@ -476,14 +553,66 @@ driven by `EditorBrush*` props. 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: +6. (Optional) `Biomes[]` + `BiomeMapParams` to vary terrain/content within the strate (§8.14). + Author `UVoxelBiomeDefinition` assets (climate box + modulation + content), then tune layout + with `AVoxelWorld::BakeBiomePreview`. Turn `ReliefStrength` down when biomes drive elevation. +7. 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). +`Public/Private/VoxelContentManager.h/.cpp` (§8.5) · `Public/Private/VoxelAtmosphereManager.h/.cpp` (§8.6) · +`Public/VoxelBiomeTypes.h` + `Public/VoxelBiomeDefinition.h`/`Private/VoxelBiomeDefinition.cpp` (§8.14). 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. + +### 8.14 Biome system (Stage 1 — climate-driven, full-param overrides) +Biomes vary terrain **and** content WITHIN a strate. A biome is a **"mini-strate-variant"**: it +can carry a FULL archetype param override (its own `FSurfaceGenerationParams`, …) plus a content +profile, placed by a deterministic, window-invariant world-XY field. Empty `Biomes[]` ⇒ bit-identical +to the pre-biome world. (Replaces the earlier `FBiomeModulation` scalar bag — full params let a biome +change *anything*, e.g. frequencies, which scalar multipliers couldn't.) + +- **Assets/data.** `UVoxelBiomeDefinition` (one per biome): `DebugColor`, climate box (relief, + moisture), `bOverrideTerrain` + `GeneratorType` + the matching archetype param struct (Surface + wired), content profile (decorations/atmosphere/water). + `UVoxelStrateDefinition::Biomes[]` & + `BiomeMapParams`. Types in `VoxelBiomeTypes.h` (§3.8). +- **The field (pure XY, window-invariant — §8.4).** `SampleBiomeAt` (VoxelGenerator.cpp): warped + **Voronoi** over a jittered grid → dominant cell + nearest neighbour (F1/F2) + border blend weight. + Each cell's biome is chosen by `ClassifyBiomeAtSite` from the site's **climate** = `SampleRelief` + (the relief map M, shared with SurfaceWorld terrain) + `SampleMoisture`, matched against each + biome's (relief, moisture) box → coherent geography. **Climate must vary much slower than + `CellSize`** (~4-6 cells/feature) or it's salt-and-pepper. +- **Per-chunk resolution (perf — §8.10).** `ResolveBiomeSampleAt`/`RebuildBiomeGrid` build a + `FChunkBiomeCache`: the expensive cell classification is done ONCE into a small grid; per voxel only + a warp + 3x3 lookup, returning `FBiomeSample` (dominant + neighbour + weight). **Cache validity is a + world-XY BOX + ChunkZ + Seed (NOT a chunk key)** — gradient-normal + boundary samples stay inside + the box and don't thrash the noise-heavy rebuild (same as the SDF cache). Bit-identical to + `SampleBiomeAt`, so the baked preview matches the terrain. `GetBiomeContextForChunk` supplies the + flattened POD context per chunk (thread-local `CP_BiomeCtx`). +- **Consumption — SURFACE (output-blend).** Per chunk, `CP_SurfaceBiomeParams[]` holds each biome's + resolved surface params (its override when `bOverrideTerrain` + GeneratorType matches, else the + strate's) with **structural fields forced from the strate** (Z bounds, seal, base density, water + level). Per voxel: `ResolveBiomeSampleAt` → dominant `PD` (+ neighbour `PN`); `GetSurfaceDensity` + computes `ComputeSurfaceTerrainZ` for `PD` and, in the border band, for `PN`, and **lerps the + resulting HEIGHTS**. Blending heights (not params) is seamless across *any* difference (frequencies + included) — what per-param blend never could. `PD==PN`, weight 0 ⇒ bit-identical, no biomes. +- **Consumption — CAVES: structural overrides are NOT applied (determinism).** Rooms/tunnels are + decided over a wide COLLECT region spanning chunks (§8.4); making room params vary by region would + need the biome sampled per *room site* inside `BuildChunkCache`, or it breaks window-invariance + (a room near a border resolves differently per querying chunk → seams/holes). So SDF archetypes + (Tunnel/Maze/Shaft/Islands) keep strate-level structure; biomes affect them via **content + + atmosphere only** (below). Per-room-site biome params = a future deep task. +- **Consumption (content/atmosphere).** `GetDominantBiomeAt(x,y,chunkZ)` (game-thread, uncached) → + biome ASSET. ContentManager: dominant biome's decorations (else strate's) + water material override. + AtmosphereManager: player's dominant biome fog/sky (`bOverrideAtmosphere`). Works for ANY archetype. + Water LEVEL stays strate-global (continuous plane); biomes retint material only. +- **Preview tool.** `AVoxelWorld::BakeBiomePreview()` (CallInEditor) bakes biome / relief / moisture + to `Saved/BiomePreview.png` via a transient generator (no PIE). Needs the `ImageWrapper` module. +- **Status:** A (field+asset+preview), B (terrain), C (content/atmosphere) verified in-editor. + Full-param redesign (surface output-blend) code-complete, pending build. Cave structural biomes + deferred (determinism, see above). Per-voxel biome warp (+2 Perlin) & content `GetDominantBiomeAt` + are future T1.a column-cache candidates. diff --git a/Source/VoxelForge/Private/VoxelAtmosphereManager.cpp b/Source/VoxelForge/Private/VoxelAtmosphereManager.cpp index a7e3515..0b628b4 100644 --- a/Source/VoxelForge/Private/VoxelAtmosphereManager.cpp +++ b/Source/VoxelForge/Private/VoxelAtmosphereManager.cpp @@ -3,15 +3,19 @@ #include "VoxelAtmosphereManager.h" #include "VoxelStrateManager.h" #include "VoxelStrateDefinition.h" +#include "VoxelBiomeDefinition.h" +#include "VoxelGenerator.h" +#include "VoxelTypes.h" // CHUNK_SIZE / VOXEL_SIZE #include "Components/ExponentialHeightFogComponent.h" #include "Components/SkyLightComponent.h" #include "GameFramework/Actor.h" #include "Engine/World.h" -void UVoxelAtmosphereManager::Initialize(AActor* InOwner, UVoxelStrateManager* InStrateManager) +void UVoxelAtmosphereManager::Initialize(AActor* InOwner, UVoxelStrateManager* InStrateManager, UVoxelGenerator* InGenerator) { Owner = InOwner; StrateManager = InStrateManager; + Generator = InGenerator; if (!InOwner) return; USceneComponent* Root = InOwner->GetRootComponent(); @@ -41,11 +45,28 @@ void UVoxelAtmosphereManager::UpdateForPlayer(const FVector& PlayerWorldPos) 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). + // Player's dominant biome (XY field) — atmosphere can vary by biome within a strate. + // Convention matches GetStrateAt: world cm = voxel * VOXEL_SIZE (actor at origin/identity). + const UVoxelBiomeDefinition* Biome = nullptr; + if (Generator && Def) + { + const int32 ChunkZ = FMath::FloorToInt((PlayerWorldPos.Z / VOXEL_SIZE) / CHUNK_SIZE); + Biome = Generator->GetDominantBiomeAt(PlayerWorldPos.X / VOXEL_SIZE, + PlayerWorldPos.Y / VOXEL_SIZE, ChunkZ); + } + + // React on strate change (full apply) OR biome change within a strate (fog/sky only — + // layer actors + atmosphere BP are strate-level). Cheap no-op otherwise. if (Idx != CurrentStrateIndex) { CurrentStrateIndex = Idx; - ApplyStrate(Def); + CurrentBiome = Biome; + ApplyStrate(Def, Biome); + } + else if (Biome != CurrentBiome.Get()) + { + CurrentBiome = Biome; + ApplyFogSky(Def, Biome); } // Anchor the full-atmosphere BP to the player (so any localized volumes/effects @@ -78,7 +99,54 @@ void UVoxelAtmosphereManager::UpdateForPlayer(const FVector& PlayerWorldPos) } } -void UVoxelAtmosphereManager::ApplyStrate(const UVoxelStrateDefinition* Def) +void UVoxelAtmosphereManager::ApplyFogSky(const UVoxelStrateDefinition* Def, const UVoxelBiomeDefinition* Biome) +{ + // A strate's full atmosphere BP owns the entire look — managed fog/sky stay off. + const bool bUseOverride = (Def && Def->AtmosphereActor); + + // Biome retint beats the strate's fog/sky when the biome opts in. + const bool bBiome = (Biome && Biome->bOverrideAtmosphere); + const FLinearColor FogCol = bBiome ? Biome->FogColor : (Def ? Def->FogColor : FLinearColor::Black); + const float FogDen = bBiome ? Biome->FogDensity : (Def ? Def->FogDensity : 0.0f); + const FLinearColor AmbCol = bBiome ? Biome->AmbientLightColor : (Def ? Def->AmbientLightColor : FLinearColor::Black); + const float AmbInt = bBiome ? Biome->AmbientLightIntensity : (Def ? Def->AmbientLightIntensity : 0.0f); + const bool bVol = Def ? Def->bVolumetricFog : false; + + // ── Managed fog (only when NOT overridden by a full atmosphere BP) ── + if (Fog) + { + if (!bUseOverride && FogDen > 0.0f) + { + Fog->SetVisibility(true); + // FogDensity is authored 0..1; EHF density is tiny — scale into a sane range. + Fog->SetFogDensity(FogDen * 0.05f); + Fog->SetFogInscatteringColor(FogCol); + Fog->SetVolumetricFog(bVol); + } + else + { + Fog->SetVisibility(false); + } + } + + // ── Managed ambient skylight (only when NOT overridden) ── + if (Sky) + { + if (!bUseOverride && Def) + { + Sky->SetIntensity(AmbInt); + Sky->SetLightColor(AmbCol); + Sky->SetLowerHemisphereColor(AmbCol); + Sky->RecaptureSky(); + } + else + { + Sky->SetIntensity(0.0f); + } + } +} + +void UVoxelAtmosphereManager::ApplyStrate(const UVoxelStrateDefinition* Def, const UVoxelBiomeDefinition* Biome) { AActor* O = Owner.Get(); UWorld* W = O ? O->GetWorld() : nullptr; @@ -98,38 +166,8 @@ void UVoxelAtmosphereManager::ApplyStrate(const UVoxelStrateDefinition* Def) 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); - } - } + // ── Managed fog + ambient skylight (biome-aware) ── + ApplyFogSky(Def, Biome); // ── Ceiling / floor layer actors — destroy old, spawn new for this strate ── // (Independent of the override — you can have cloud seas with either fog path.) @@ -155,5 +193,6 @@ void UVoxelAtmosphereManager::Reset() if (CeilingActor) { CeilingActor->Destroy(); CeilingActor = nullptr; } if (FloorActor) { FloorActor->Destroy(); FloorActor = nullptr; } CurrentStrateIndex = INT32_MIN; + CurrentBiome = nullptr; if (Fog) Fog->SetVisibility(false); } diff --git a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp index b1ca73f..b2fe2ba 100644 --- a/Source/VoxelForge/Private/VoxelCaveMorphology.cpp +++ b/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -94,14 +94,10 @@ void VoxelCaveMorphology::BuildChunkCache( const float MaxTunnelLen = FMath::Max(Params.MaxTunnelLength, 0.0f); //========================================================================= - // STORE region — what we keep for the per-voxel loop. + // STORE decision — 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; + // A room/tunnel is stored iff its OWN influence sphere can overlap the search box + // (RoomReachesSearchBox / tunnel bounding spheres below). Per-voxel culling refines. //========================================================================= // COLLECT region — what we hash into existence for the connectivity decision. @@ -142,13 +138,19 @@ void VoxelCaveMorphology::BuildChunkCache( 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 + // "This room can reach the search box" test, using the room's OWN reach — the same + // extent formula as its per-voxel cull sphere (1.5x radius for capsule-stretched + // variants + blend margin). The old test compared the center against a box inflated + // by the SHARED MaxInfluence, which silently assumed every room's reach <= MaxInfluence. + // That's FALSE for the origin room (OriginRoomRadius >> MaxRoomRadius) — chunks inside + // the big room but > MaxInfluence from (0,0) didn't store it, so its carve clipped at an + // arbitrary chunk-aligned radius — and slightly false even for hash rooms (1.5x stretch). + auto RoomReachesSearchBox = [&](const FVector& C, float RadiusXY, float RadiusZ) -> bool { - return C.X >= StoreMinX && C.X <= StoreMaxX - && C.Y >= StoreMinY && C.Y <= StoreMaxY; + const float Reach = FMath::Max(RadiusXY * 1.5f, RadiusZ) + BlendK * 3.0f; + 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) <= Reach * Reach; }; // Sphere-vs-search-box test in XY (treated as infinite in Z; per-voxel culling @@ -170,8 +172,14 @@ void VoxelCaveMorphology::BuildChunkCache( int32 OriginIdx = -1; if (Params.OriginRoomRadius > 0.0f) { - if (0.0f >= CollectMinX && 0.0f <= CollectMaxX && - 0.0f >= CollectMinY && 0.0f <= CollectMaxY) + // Collected when (0,0) is in the COLLECT region (connectivity) OR when the room's + // own body can reach this chunk (store) — its radius may exceed the collect margin. + const bool bOriginInCollect = + 0.0f >= CollectMinX && 0.0f <= CollectMaxX && + 0.0f >= CollectMinY && 0.0f <= CollectMaxY; + const float OriginRZ = Params.OriginRoomRadius * Params.RoomHeightRatio; + if (bOriginInCollect || + RoomReachesSearchBox(FVector(0.0f, 0.0f, StrateCenterZ), Params.OriginRoomRadius, OriginRZ)) { FBuildRoom OriginRoom; OriginRoom.CellX = INT32_MAX; // Sentinel — never matches a real grid cell @@ -181,7 +189,7 @@ void VoxelCaveMorphology::BuildChunkCache( OriginRoom.RadiusXY = Params.OriginRoomRadius; OriginRoom.RadiusZ = Params.OriginRoomRadius * Params.RoomHeightRatio; OriginRoom.bIsOrigin = true; - OriginRoom.bStore = CenterInStoreBox(OriginRoom.Center); + OriginRoom.bStore = RoomReachesSearchBox(OriginRoom.Center, OriginRoom.RadiusXY, OriginRoom.RadiusZ); OriginIdx = BuildRooms.Add(OriginRoom); } } @@ -216,7 +224,7 @@ void VoxelCaveMorphology::BuildChunkCache( Room.RadiusXY = FMath::Lerp(Params.MinRoomRadius, Params.MaxRoomRadius, SizeFactor); Room.RadiusZ = Room.RadiusXY * Params.RoomHeightRatio; Room.bIsOrigin = false; - Room.bStore = CenterInStoreBox(Room.Center); + Room.bStore = RoomReachesSearchBox(Room.Center, Room.RadiusXY, Room.RadiusZ); BuildRooms.Add(Room); } @@ -226,35 +234,70 @@ void VoxelCaveMorphology::BuildChunkCache( if (NumRooms == 0) return; //========================================================================= - // Window-invariant nearest-neighbor backbone + // Window-invariant guaranteed 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. + // Each room gets ONE guaranteed link, chosen among candidates within MaxTunnelLength + // (that reach filter is what keeps the decision identical across chunk windows). + // + // bTunnelsFlowTowardOrigin = true (default): the link target is the best candidate + // among rooms STRICTLY CLOSER to (0,0) in XY. Every chain of links then descends in + // origin-distance and terminates at the origin room → the network is a TREE ROOTED AT + // THE SPINE HUB: every room is reachable, tunnels flow inward like tributaries. + // (Frontier rooms with no closer candidate in reach fall back to plain NN — a far + // cluster stays internally chained even when it can't bridge to the origin side.) + // + // bTunnelsFlowTowardOrigin = false (legacy): plain nearest-neighbor pairing. NOTE: + // despite what this comment used to claim, an NN-graph is a FOREST of small clusters, + // not a connected tree — isolated cave pockets are expected in this mode. + // + // Selection metric (not the reach filter) penalizes vertical separation via + // TunnelHorizontalBias, so the GUARANTEED links also prefer walkable slopes — + // previously only the random TunnelDensity extras were biased, which is why + // backbone tunnels could come out absurdly steep. TArray> NearestNeighbor; NearestNeighbor.SetNumUninitialized(NumRooms); const float MaxTunnelLenSq = MaxTunnelLen * MaxTunnelLen; + const bool bFlowToOrigin = Params.bTunnelsFlowTowardOrigin; + + auto LinkMetric = [&](int32 I, int32 J) -> float + { + const float D = FVector::Dist(BuildRooms[I].Center, BuildRooms[J].Center); + const float VertSep = FMath::Abs(BuildRooms[I].Center.Z - BuildRooms[J].Center.Z); + return D + VertSep * Params.TunnelHorizontalBias * 5.0f; + }; + // Squared XY distance to the (0,0) spine — the "inward" ordering. Purely positional, + // so it is window-invariant by construction. + auto OriginKeySq = [&](int32 I) -> float + { + const FVector& C = BuildRooms[I].Center; + return C.X * C.X + C.Y * C.Y; + }; + + // ExcludeJ: used by the origin-cap redirect below (re-pick ignoring the origin room). + auto PickNeighbor = [&](int32 I, int32 ExcludeJ) -> int32 + { + const float MyKeySq = OriginKeySq(I); + float BestInward = FLT_MAX; int32 BestInwardJ = -1; + float BestAny = FLT_MAX; int32 BestAnyJ = -1; + for (int32 J = 0; J < NumRooms; J++) + { + if (J == I || J == ExcludeJ) continue; + const float DSq = FVector::DistSquared(BuildRooms[I].Center, BuildRooms[J].Center); + if (DSq > MaxTunnelLenSq) continue; // out of reach — never a tunnel + const float M = LinkMetric(I, J); + if (M < BestAny) { BestAny = M; BestAnyJ = J; } + if (bFlowToOrigin && OriginKeySq(J) < MyKeySq && M < BestInward) + { + BestInward = M; BestInwardJ = J; + } + } + return (bFlowToOrigin && BestInwardJ != -1) ? BestInwardJ : BestAnyJ; + }; 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; + NearestNeighbor[I] = PickNeighbor(I, /*ExcludeJ=*/INDEX_NONE); } //========================================================================= @@ -293,6 +336,19 @@ void VoxelCaveMorphology::BuildChunkCache( { OriginDowngraded.Add(OriginLinks[R].Value); } + + // REDIRECT, don't strand: a downgraded room whose guaranteed link pointed at the + // origin re-picks its best target EXCLUDING origin. It keeps a guaranteed link + // (chains to the hub through another room instead of directly), which matters + // doubly now that rooms with zero connections are culled below. Deterministic: + // same candidate set, same metric, one exclusion. + for (int32 DowngradedI : OriginDowngraded) + { + if (NearestNeighbor[DowngradedI] == OriginIdx) + { + NearestNeighbor[DowngradedI] = PickNeighbor(DowngradedI, /*ExcludeJ=*/OriginIdx); + } + } } //========================================================================= @@ -304,6 +360,15 @@ void VoxelCaveMorphology::BuildChunkCache( // 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. + // Tracks whether each room ends up with at least one tunnel — DECIDED connections, + // independent of whether the tunnel itself is stored for this chunk (a room near the + // window edge may have all its tunnels outside the box; it's still "connected"). + // Stored rooms with zero connections are culled at emission: they'd be sealed air + // pockets no tunnel ever reaches. Window-invariant: a stored room's full candidate + // set (and each candidate's own candidates) lies inside the COLLECT region. + TArray> RoomConnected; + RoomConnected.Init(false, NumRooms); + for (int32 I = 0; I < NumRooms; I++) { for (int32 J = I + 1; J < NumRooms; J++) @@ -347,6 +412,10 @@ void VoxelCaveMorphology::BuildChunkCache( if (ConnectChance >= Params.TunnelDensity) continue; } + // Connection DECIDED (backbone or density roll) — both rooms are reachable. + RoomConnected[I] = true; + RoomConnected[J] = true; + // --- TUNNEL HASH (for deriving all tunnel properties) --- const uint32 TunnelHash = VoxelHash::Pair( RoomA.CellX, RoomA.CellY, @@ -451,10 +520,15 @@ void VoxelCaveMorphology::BuildChunkCache( OutCache.Rooms.Reserve(NumRooms); - for (const FBuildRoom& BR : BuildRooms) + for (int32 RoomIdx = 0; RoomIdx < NumRooms; RoomIdx++) { + const FBuildRoom& BR = BuildRooms[RoomIdx]; if (!BR.bStore) continue; // Far room — collected for connectivity only + // Sealed-bubble cull: a room no tunnel ever reaches would be an isolated air + // pocket — don't carve it at all. The origin room is always kept (spine hub). + if (!RoomConnected[RoomIdx] && !BR.bIsOrigin) continue; + FCachedRoom CR; CR.Center = BR.Center; CR.RadiusXY = BR.RadiusXY; diff --git a/Source/VoxelForge/Private/VoxelContentManager.cpp b/Source/VoxelForge/Private/VoxelContentManager.cpp index a3f9093..2c7e603 100644 --- a/Source/VoxelForge/Private/VoxelContentManager.cpp +++ b/Source/VoxelForge/Private/VoxelContentManager.cpp @@ -4,20 +4,27 @@ #include "VoxelContentManager.h" #include "VoxelStrateManager.h" #include "VoxelStrateDefinition.h" +#include "VoxelBiomeDefinition.h" +#include "VoxelGenerator.h" #include "VoxelCaveMorphology.h" // VoxelHash #include "Components/StaticMeshComponent.h" +#include "Components/HierarchicalInstancedStaticMeshComponent.h" +#include "Components/LightComponent.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). +// Global safety cap on spawned decoration ACTORS per chunk (across all entries). +// HISM instances are exempt — they're batched render data, capped per entry by MaxPerChunk. static constexpr int32 GMaxDecorationsPerChunk = 400; -void UVoxelContentManager::Initialize(AActor* InOwner, UVoxelStrateManager* InStrateManager, int32 InSeed) +void UVoxelContentManager::Initialize(AActor* InOwner, UVoxelStrateManager* InStrateManager, + UVoxelGenerator* InGenerator, int32 InSeed) { Owner = InOwner; StrateManager = InStrateManager; + Generator = InGenerator; Seed = InSeed; // Engine unit plane — reused (scaled) for every water surface. @@ -46,25 +53,43 @@ void UVoxelContentManager::PopulateChunk(const FIntVector& ChunkCoord, const FVo 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) + // Dominant biome for this chunk (center XY) selects the content + water overrides. + // Per-chunk granularity: a chunk straddling a biome border takes its centre's biome + // for the whole decoration SET (fine — the set is a choice, not a per-vertex blend). + const UVoxelBiomeDefinition* Biome = nullptr; + if (Generator) + { + const float CenterVoxelX = ((float)ChunkCoord.X + 0.5f) * CHUNK_SIZE; + const float CenterVoxelY = ((float)ChunkCoord.Y + 0.5f) * CHUNK_SIZE; + Biome = Generator->GetDominantBiomeAt(CenterVoxelX, CenterVoxelY, ChunkCoord.Z); + } + + // Decorations come from the biome when it provides any, else the strate's list. + const TArray& Decos = + (Biome && Biome->Decorations.Num() > 0) ? Biome->Decorations : Def->Decorations; + + // Per-entry LOD gate inside SpawnDecorations (FStrateDecoration::MaxLODLevel): + // actor entries usually stay LOD0-only; instanced entries may persist to LOD1-2. + // Water is one cheap plane, place it at any LOD. { TArray> Spawned; - SpawnDecorations(ChunkCoord, MeshData, Def, Spawned); + SpawnDecorations(ChunkCoord, MeshData, Decos, LODLevel, Spawned); if (Spawned.Num() > 0) { SpawnedActors.Add(ChunkCoord, MoveTemp(Spawned)); } } - SpawnWater(ChunkCoord, Def); + // Water plane: biome material override (level stays strate-global, see SpawnWater). + UMaterialInterface* WaterMat = (Biome && Biome->WaterMaterial) ? Biome->WaterMaterial : Def->WaterMaterial; + SpawnWater(ChunkCoord, Def, WaterMat); } void UVoxelContentManager::SpawnDecorations(const FIntVector& ChunkCoord, const FVoxelMeshData& MeshData, - const UVoxelStrateDefinition* Def, TArray>& Out) + const TArray& Decorations, int32 LODLevel, + TArray>& Out) { - if (Def->Decorations.Num() == 0) return; + if (Decorations.Num() == 0) return; AActor* OwnerActor = Owner.Get(); if (!OwnerActor) return; @@ -81,19 +106,30 @@ void UVoxelContentManager::SpawnDecorations(const FIntVector& ChunkCoord, const const bool bHasWater = (WaterVoxelZ != -FLT_MAX); const float WaterWorldZ = bHasWater ? WaterVoxelZ * VOXEL_SIZE : -FLT_MAX; + // Strate light culling: lights only live in the player's strate. INT32_MIN = player + // strate not known yet (first frames) — leave lights on, the first SetActiveStrate + // sweep corrects them. + const int32 ChunkStrate = GetChunkStrateIndex(ChunkCoord); + const bool bLightsOn = (ActiveStrateIndex == INT32_MIN) || (ChunkStrate == ActiveStrateIndex); + int32 TotalSpawned = 0; - for (int32 DecoIdx = 0; DecoIdx < Def->Decorations.Num(); ++DecoIdx) + for (int32 DecoIdx = 0; DecoIdx < Decorations.Num(); ++DecoIdx) { - const FStrateDecoration& Deco = Def->Decorations[DecoIdx]; - if (!Deco.ActorClass) continue; + const FStrateDecoration& Deco = Decorations[DecoIdx]; + const bool bInstanced = (Deco.InstancedMesh != nullptr); + if (!bInstanced && !Deco.ActorClass) continue; if (Deco.SpawnDensity <= 0.0f) continue; + if (LODLevel > Deco.MaxLODLevel) continue; // entry not wanted at this LOD + + // Lazily created on the first placement of this entry (avoids empty components). + UHierarchicalInstancedStaticMeshComponent* HISM = nullptr; int32 SpawnedThisEntry = 0; for (int32 i = 0; i < NumVerts; ++i) { - if (TotalSpawned >= GMaxDecorationsPerChunk) return; + if (!bInstanced && TotalSpawned >= GMaxDecorationsPerChunk) break; // actor cap if (SpawnedThisEntry >= Deco.MaxPerChunk) break; const FVector NormalWorld = MeshData.Normals.IsValidIndex(i) @@ -144,26 +180,101 @@ void UVoxelContentManager::SpawnDecorations(const FIntVector& ChunkCoord, const 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; + const FTransform SpawnXf(BaseQ, SpawnPos, FVector(Scale)); - AActor* NewActor = World->SpawnActor( - Deco.ActorClass, - FTransform(BaseQ, SpawnPos, FVector(Scale)), - SpawnParams); - - if (NewActor) + if (bInstanced) { - Out.Add(NewActor); + // HISM path — batched instances, no actor. Pure visual: no collision, + // engine handles frustum/occlusion culling per cluster. + if (!HISM) + { + HISM = NewObject(OwnerActor); + HISM->SetStaticMesh(Deco.InstancedMesh); + HISM->SetMobility(EComponentMobility::Movable); + HISM->SetCollisionEnabled(ECollisionEnabled::NoCollision); + HISM->RegisterComponent(); + HISM->AttachToComponent(OwnerActor->GetRootComponent(), + FAttachmentTransformRules::KeepRelativeTransform); + ChunkInstances.FindOrAdd(ChunkCoord).Add(HISM); + } + HISM->AddInstance(SpawnXf, /*bWorldSpace=*/true); ++SpawnedThisEntry; - ++TotalSpawned; + } + else + { + FActorSpawnParameters SpawnParams; + SpawnParams.Owner = OwnerActor; + SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + + AActor* NewActor = World->SpawnActor(Deco.ActorClass, SpawnXf, SpawnParams); + + if (NewActor) + { + // Strate light culling — wrong strate ⇒ lights start hidden. + if (!bLightsOn) + { + SetActorLightsEnabled(NewActor, false); + } + Out.Add(NewActor); + ++SpawnedThisEntry; + ++TotalSpawned; + } } } } } -void UVoxelContentManager::SpawnWater(const FIntVector& ChunkCoord, const UVoxelStrateDefinition* Def) +//============================================================================= +// STRATE LIGHT CULLING +//============================================================================= +// Seals + the bedrock gap make strates light-tight by construction, but a shadowless +// point light shines straight through rock (blocking IS shadowing), and a shadowed one +// pays full shadow cost to render black. Lights can therefore only legitimately matter +// in the player's own strate — toggle them analytically on strate change instead of +// asking the GPU to discover it per-pixel. + +int32 UVoxelContentManager::GetChunkStrateIndex(const FIntVector& ChunkCoord) const +{ + if (!StrateManager) return INT32_MIN; + // GetStrateIndex expects Unreal world units (cm) — it converts cm→chunkZ internally. + // (Previously this passed voxel-Z, so the strate match for light culling was wrong.) + const float CenterWorldZ = ((float)ChunkCoord.Z + 0.5f) * CHUNK_SIZE * VOXEL_SIZE; + return StrateManager->GetStrateIndex(CenterWorldZ); +} + +void UVoxelContentManager::SetActorLightsEnabled(AActor* Actor, bool bEnabled) +{ + TInlineComponentArray Lights(Actor); + for (ULightComponent* Light : Lights) + { + if (Light) + { + Light->SetVisibility(bEnabled); + } + } +} + +void UVoxelContentManager::SetActiveStrate(int32 PlayerStrateIndex) +{ + if (PlayerStrateIndex == ActiveStrateIndex) return; // every-Tick fast path + ActiveStrateIndex = PlayerStrateIndex; + + // Strate change (rare): sweep all populated chunks and toggle their actors' lights. + for (const auto& Pair : SpawnedActors) + { + const bool bEnable = (GetChunkStrateIndex(Pair.Key) == ActiveStrateIndex); + for (const TWeakObjectPtr& A : Pair.Value) + { + if (AActor* Act = A.Get()) + { + SetActorLightsEnabled(Act, bEnable); + } + } + } +} + +void UVoxelContentManager::SpawnWater(const FIntVector& ChunkCoord, const UVoxelStrateDefinition* Def, + UMaterialInterface* WaterMaterial) { if (!Def->bHasWater || !PlaneMesh) return; @@ -196,9 +307,9 @@ void UVoxelContentManager::SpawnWater(const FIntVector& ChunkCoord, const UVoxel const float ChunkWorld = (float)CHUNK_SIZE * VOXEL_SIZE; Plane->SetWorldScale3D(FVector(ChunkWorld / 100.0f, ChunkWorld / 100.0f, 1.0f)); - if (Def->WaterMaterial) + if (WaterMaterial) { - Plane->SetMaterial(0, Def->WaterMaterial); + Plane->SetMaterial(0, WaterMaterial); } WaterPlanes.Add(ChunkCoord, Plane); @@ -218,6 +329,18 @@ void UVoxelContentManager::ClearChunk(const FIntVector& ChunkCoord) SpawnedActors.Remove(ChunkCoord); } + if (TArray>* Comps = ChunkInstances.Find(ChunkCoord)) + { + for (const TWeakObjectPtr& C : *Comps) + { + if (UHierarchicalInstancedStaticMeshComponent* Comp = C.Get()) + { + Comp->DestroyComponent(); + } + } + ChunkInstances.Remove(ChunkCoord); + } + if (UStaticMeshComponent** Plane = WaterPlanes.Find(ChunkCoord)) { if (*Plane) @@ -236,7 +359,13 @@ void UVoxelContentManager::ClearAll() { ClearChunk(C); } - // Any water planes without decorations. + // Chunks that only have instances or water (no actors). + TArray InstanceCoords; + ChunkInstances.GetKeys(InstanceCoords); + for (const FIntVector& C : InstanceCoords) + { + ClearChunk(C); + } TArray WaterCoords; WaterPlanes.GetKeys(WaterCoords); for (const FIntVector& C : WaterCoords) @@ -244,5 +373,6 @@ void UVoxelContentManager::ClearAll() ClearChunk(C); } SpawnedActors.Empty(); + ChunkInstances.Empty(); WaterPlanes.Empty(); } diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 972c61c..6ff9d82 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -11,6 +11,34 @@ #include "VoxelTerrainOpDefinition.h" #include "VoxelCaveMorphology.h" #include "VoxelDiffLayer.h" +#include "VoxelBiomeDefinition.h" +#include "VoxelNoise.h" // T2.a: float, SIMD-batched gradient-noise core + +//============================================================================= +// SURFACE COLUMN CACHE (T1.a) — kill the per-Z heightfield redundancy +//============================================================================= +// The SurfaceWorld heightfield + sky-cap + biome blend are functions of XY ONLY, but +// the mesher samples ~33 Z grid-points per column, each re-running that XY work. We cache +// the column (terrain Z + ceiling Z) once per integer XY and reuse it down the column. +// Used ONLY for integer-XY queries (the density grid); fractional queries (gradient +// normals at interpolated vertices) fall through and compute directly → bit-identical. +// Validity is a world-XY BOX (chunk footprint) + ChunkZ + Seed, same discipline as the +// SDF/biome caches (§8.10) so the +X/+Y boundary plane doesn't thrash it. +struct FSurfaceColumn { float TerrainZ = 0.0f; float CeilSurf = 0.0f; }; + +struct FSurfaceColumnCache +{ + // Box covers a chunk footprint + a margin on every side (≥ the LOD margin ring the + // mesher samples for grid-based normals, ±Step ≤ 4, plus slack). Symmetric around the + // first (rebuild-triggering) sample so the whole chunk's column queries stay in one box. + static constexpr int32 Halo = CHUNK_SIZE + 8; + static constexpr int32 Dim = 2 * Halo + 1; + int32 BaseX = 0, BaseY = 0; // box origin (voxel coords) + int32 ChunkZ = MIN_int32, Seed = MIN_int32; + bool bValid = false; + FSurfaceColumn Cols[Dim * Dim]; + bool Computed[Dim * Dim]; +}; //============================================================================= // FRACTAL NOISE (fBm — fractional Brownian motion) @@ -22,23 +50,15 @@ // Lacunarity = x freq par octave (2 = double à chaque fois) // Persistence = x amp par octave (0.5 = moitié) +// NOTE (T2.a): the fBm/Ridged bodies moved to VoxelNoise.h, where octaves are evaluated +// 4-wide via SSE (Perlin3D_x4). These thin wrappers keep every call site unchanged. They +// sample a DIFFERENT (float hash-gradient) noise field than the old FMath::PerlinNoise3D, +// so worlds re-tune once — but the fBm/Ridged math/contracts ([-1,1]) are identical. 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; + return VoxelNoise::FBM((float)Position.X, (float)Position.Y, (float)Position.Z, + Octaves, Lacunarity, Persistence); } //============================================================================= @@ -55,32 +75,8 @@ static float FractalNoise3D(const FVector& Position, int32 Octaves = 4, 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; + return VoxelNoise::Ridged((float)Position.X, (float)Position.Y, (float)Position.Z, + Octaves, Lacunarity, Persistence); } //============================================================================= @@ -401,6 +397,13 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co thread_local FVerticalShaftParams CP_Vert; thread_local FFloatingIslandParams CP_Float; thread_local FStrateDisturbanceParams CP_Dist; + // Biomes (SurfaceWorld for now): CP_BiomeCtx is the cheap per-chunk flatten; + // CP_BiomeCache is the box-validated grid; CP_SurfaceBiomeParams holds each biome's + // resolved surface params (override or strate fallback) parallel to CP_BiomeCtx.Biomes. + thread_local FBiomeContext CP_BiomeCtx; + thread_local FChunkBiomeCache CP_BiomeCache; + thread_local TArray CP_SurfaceBiomeParams; + thread_local FSurfaceColumnCache CP_SurfCol; // T1.a per-column surface cache if (ChunkCoord != CP_Chunk) { @@ -414,7 +417,37 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co case ECaveGeneratorType::Maze: CP_Maze = StrateManager->GetMazeParamsForChunk(ChunkCoord); break; case ECaveGeneratorType::SurfaceWorld: - CP_Surface = StrateManager->GetSurfaceParamsForChunk(ChunkCoord); break; + { + CP_Surface = StrateManager->GetSurfaceParamsForChunk(ChunkCoord); + CP_BiomeCtx = StrateManager->GetBiomeContextForChunk(ChunkCoord); + + // Per-biome surface params: each biome's override (when it overrides + // SurfaceWorld) else the strate's, with STRUCTURAL fields forced from the + // strate (Z bounds, seal, base, water level) so seals/spine/water stay intact. + CP_SurfaceBiomeParams.Reset(); + if (CP_BiomeCtx.IsValid()) + { + const UVoxelStrateDefinition* Def = StrateManager->GetStrateForChunk(ChunkCoord); + CP_SurfaceBiomeParams.Reserve(CP_BiomeCtx.Biomes.Num()); + for (const FBiomeResolved& BR : CP_BiomeCtx.Biomes) + { + FSurfaceGenerationParams P = CP_Surface; // strate base (+ structural) + const UVoxelBiomeDefinition* B = + (Def && Def->Biomes.IsValidIndex(BR.Index)) ? Def->Biomes[BR.Index] : nullptr; + if (B && B->bOverrideTerrain && B->GeneratorType == ECaveGeneratorType::SurfaceWorld) + { + P = B->SurfaceParams; // biome shape + P.StrateTopWorldZ = CP_Surface.StrateTopWorldZ; + P.StrateBottomWorldZ = CP_Surface.StrateBottomWorldZ; + P.BoundarySealThickness = CP_Surface.BoundarySealThickness; + P.BaseDensity = CP_Surface.BaseDensity; + P.WaterLevelRelative = CP_Surface.WaterLevelRelative; // shared water plane + } + CP_SurfaceBiomeParams.Add(P); + } + } + break; + } case ECaveGeneratorType::VerticalShafts: CP_Vert = StrateManager->GetVerticalShaftParamsForChunk(ChunkCoord); break; case ECaveGeneratorType::FloatingIslands: @@ -433,7 +466,69 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co case ECaveGeneratorType::Maze: Result = GetMazeDensity(WorldX, WorldY, WorldZ, CP_Maze); break; case ECaveGeneratorType::SurfaceWorld: - Result = GetSurfaceDensity(WorldX, WorldY, WorldZ, CP_Surface); break; + { + // The XY-only surface field (biome-blended terrain Z + sky-cap ceiling) for one + // column. Resolves the biome sample here so it's cached per column too. + auto ComputeColumn = [&](float X, float Y) -> FSurfaceColumn + { + const FSurfaceGenerationParams* PD = &CP_Surface; + const FSurfaceGenerationParams* PN = &CP_Surface; + float W = 0.0f; + if (CP_BiomeCtx.IsValid() && CP_SurfaceBiomeParams.Num() > 0) + { + const FBiomeSample Smp = ResolveBiomeSampleAt(X, Y, ChunkCoord.Z, CP_BiomeCtx, CP_BiomeCache); + const int32 Di = CP_SurfaceBiomeParams.IsValidIndex(Smp.DominantIndex) ? Smp.DominantIndex : 0; + PD = &CP_SurfaceBiomeParams[Di]; + if (Smp.NeighborWeight > 0.0f && CP_SurfaceBiomeParams.IsValidIndex(Smp.NeighborIndex)) + { + PN = &CP_SurfaceBiomeParams[Smp.NeighborIndex]; + W = Smp.NeighborWeight; + } + } + FSurfaceColumn Col; + Col.TerrainZ = ComputeSurfaceTerrainZ(X, Y, *PD); + if (W > 0.0f) Col.TerrainZ = FMath::Lerp(Col.TerrainZ, ComputeSurfaceTerrainZ(X, Y, *PN), W); + Col.CeilSurf = ComputeSurfaceCeiling(X, Y, *PD); + return Col; + }; + + FSurfaceColumn Col; + // Integer XY (the density grid) → reuse the column down its whole Z extent. + // Fractional XY (gradient-normal samples) → compute directly (no cache key). + if (WorldX == FMath::FloorToFloat(WorldX) && WorldY == FMath::FloorToFloat(WorldY)) + { + const int32 IX = (int32)WorldX, IY = (int32)WorldY; + const bool bInBox = CP_SurfCol.bValid + && CP_SurfCol.ChunkZ == ChunkCoord.Z && CP_SurfCol.Seed == Seed + && IX >= CP_SurfCol.BaseX && IX < CP_SurfCol.BaseX + FSurfaceColumnCache::Dim + && IY >= CP_SurfCol.BaseY && IY < CP_SurfCol.BaseY + FSurfaceColumnCache::Dim; + if (!bInBox) + { + // Centre the box on this (first / boundary-crossing) sample so the rest of + // the chunk's column queries — including the ±Step margin ring — fall inside. + CP_SurfCol.BaseX = IX - FSurfaceColumnCache::Halo; + CP_SurfCol.BaseY = IY - FSurfaceColumnCache::Halo; + CP_SurfCol.ChunkZ = ChunkCoord.Z; + CP_SurfCol.Seed = Seed; + CP_SurfCol.bValid = true; + FMemory::Memzero(CP_SurfCol.Computed, sizeof(CP_SurfCol.Computed)); + } + const int32 CI = (IY - CP_SurfCol.BaseY) * FSurfaceColumnCache::Dim + (IX - CP_SurfCol.BaseX); + if (!CP_SurfCol.Computed[CI]) + { + CP_SurfCol.Cols[CI] = ComputeColumn(WorldX, WorldY); + CP_SurfCol.Computed[CI] = true; + } + Col = CP_SurfCol.Cols[CI]; + } + else + { + Col = ComputeColumn(WorldX, WorldY); + } + + Result = SurfaceDensityFromColumn(WorldX, WorldY, WorldZ, Col.TerrainZ, Col.CeilSurf, CP_Surface); + break; + } case ECaveGeneratorType::VerticalShafts: Result = GetVerticalShaftDensity(WorldX, WorldY, WorldZ, CP_Vert); break; case ECaveGeneratorType::FloatingIslands: @@ -549,15 +644,15 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo // 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( + WarpedX += VoxelNoise::Perlin3D(FVector( WorldX * WF + SeedF * 0.37f, WorldY * WF + 1.3f, EffectiveZ * WF + 5.7f)) * VOXEL_NOISE_SCALE * WS; - WarpedY += FMath::PerlinNoise3D(FVector( + WarpedY += VoxelNoise::Perlin3D(FVector( WorldX * WF + 7.1f, WorldY * WF + SeedF * 0.59f, EffectiveZ * WF + 2.3f)) * VOXEL_NOISE_SCALE * WS; - WarpedZ += FMath::PerlinNoise3D(FVector( + WarpedZ += VoxelNoise::Perlin3D(FVector( WorldX * WF + 11.3f, WorldY * WF + 9.7f, EffectiveZ * WF + SeedF * 0.41f)) * VOXEL_NOISE_SCALE * WS; @@ -814,19 +909,19 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo float WS = Params.DomainWarpStrength; // Sample three independent noise fields for X, Y, Z warp - float WarpX = FMath::PerlinNoise3D(FVector( + float WarpX = VoxelNoise::Perlin3D(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( + float WarpY = VoxelNoise::Perlin3D(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( + float WarpZ = VoxelNoise::Perlin3D(FVector( WorldX * WF + 400.0f, WorldY * WF + 500.0f + SeedF * 11.9f, EffectiveZ * WF + 600.0f + SeedF * 13.3f @@ -1149,7 +1244,7 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo // 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( + float VertGrad = VoxelNoise::Perlin3D(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 @@ -1458,28 +1553,52 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo // produce natural winding paths that complement the room-and-corridor structure. // // HORIZONTAL BIAS: Z frequency is scaled up so tunnels prefer horizontal paths. + // + // NETWORK MASK: |N1|+|N2| < threshold carves tubes near the intersection of two noise + // zero-surfaces — but wherever the fields merely GRAZE the threshold it leaves pinhole + // pockets, and none of it is connected to anything (the far-field "confetti"). + // WormNetworkRange masks the carve by distance to the room/tunnel network (CaveSDF is + // already computed by Step 4 — free): full strength at the network, smooth fade to zero + // at Range. Worms become braids/shortcuts hugging the cave system; no isolated speckle. + // Range = 0 → unmasked legacy behaviour. Bonus: fully-masked voxels skip both Perlins. 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 NetworkMask = 1.0f; + if (Params.WormNetworkRange > 0.0f) { - float t = 1.0f - (WormValue / Params.WormThreshold); - Density -= t * Params.WormStrength; + if (CaveSDF >= Params.WormNetworkRange) // also true when no network (FLT_MAX) + { + NetworkMask = 0.0f; + } + else if (CaveSDF > 0.0f) + { + NetworkMask = 1.0f - SmoothStep01(CaveSDF / Params.WormNetworkRange); + } + } + + if (NetworkMask > 0.0f) + { + float WormZFreq = Params.WormFrequency * Params.WormHorizontalBias; + + float N1 = FMath::Abs(VoxelNoise::Perlin3D(FVector( + WorldX * Params.WormFrequency + SeedF, + WorldY * Params.WormFrequency + SeedF * 1.7f, + EffectiveZ * WormZFreq + SeedF * 2.3f + )) * VOXEL_NOISE_SCALE); + + float N2 = FMath::Abs(VoxelNoise::Perlin3D(FVector( + WorldX * Params.WormFrequency + SeedF + 137.0f, + WorldY * Params.WormFrequency + SeedF * 1.7f + 259.0f, + EffectiveZ * WormZFreq + SeedF * 2.3f + 431.0f + )) * VOXEL_NOISE_SCALE); + + float WormValue = N1 + N2; + + if (WormValue < Params.WormThreshold) + { + float t = 1.0f - (WormValue / Params.WormThreshold); + Density -= t * Params.WormStrength * NetworkMask; + } } } @@ -1792,21 +1911,37 @@ float UVoxelGenerator::GetMazeDensity(float WorldX, float WorldY, float WorldZ, // high solid "sky cap" ceiling, with a flattened beach band around the water line. // Open air fills the gap between ground and ceiling; water is a render-side overlay. -float UVoxelGenerator::GetSurfaceDensity(float WorldX, float WorldY, float WorldZ, - const FSurfaceGenerationParams& Params) const +float UVoxelGenerator::ComputeSurfaceTerrainZ(float WorldX, float WorldY, + 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; + // Domain-warp the STRUCTURAL query coords (continents + mountains) so coastlines and + // ridgelines wind organically instead of looking like axis-aligned noise blobs. Detail + // noise stays on the real XY so fine bumps remain crisp and uncorrelated with the warp. + float QX = WorldX, QY = WorldY; + if (Params.HeightWarpStrength > 0.0f) + { + const float WF = Params.HeightWarpFrequency; + const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + SeedF * 0.31f, WorldY * WF + 4.2f, SeedF * 1.7f)); + const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 8.6f, WorldY * WF + SeedF * 0.53f, SeedF * 2.9f)); + QX += wx * VOXEL_NOISE_SCALE * Params.HeightWarpStrength; + QY += wy * VOXEL_NOISE_SCALE * Params.HeightWarpStrength; + } + + // Macro relief map [0,1]: where this XY sits on the plains <-> mountains spectrum. + // M is the "mountainous-ness"; ReliefStrength=0 → M=1 everywhere (uniform terrain). + const float Relief = SampleRelief(WorldX, WorldY, Params.ReliefFrequency, Params.ReliefContrast); + const float M = FMath::Lerp(1.0f, Relief, Params.ReliefStrength); + float Cont = FractalNoise3D(FVector( - WorldX * Params.ContinentFrequency + SeedF * 3.1f, - WorldY * Params.ContinentFrequency + SeedF * 5.7f, + QX * Params.ContinentFrequency + SeedF * 3.1f, + QY * Params.ContinentFrequency + SeedF * 5.7f, SeedF * 0.7f), 4); // [-1,1] float Detail = FractalNoise3D(FVector( @@ -1818,19 +1953,31 @@ float UVoxelGenerator::GetSurfaceDensity(float WorldX, float WorldY, float World if (Params.MountainStrength > 0.0f) { float Ridge = RidgedNoise3D(FVector( - WorldX * Params.MountainFrequency + 99.0f, - WorldY * Params.MountainFrequency + 77.0f, + QX * Params.MountainFrequency + 99.0f, + QY * Params.MountainFrequency + 77.0f, SeedF * 0.9f), 4); // [-1,1] Ridge = Ridge * 0.5f + 0.5f; // [0,1] peaks - Mountain = Ridge * Params.MountainStrength; + Mountain = Ridge * Params.MountainStrength * M; // mountains rise only in high-relief regions } + // Plains keep a fraction of the continental swell; highlands get the full range. + const float ContScale = FMath::Lerp(0.45f, 1.0f, M); + float Terrain = GroundBase - + Cont * Params.ElevationRange * 0.5f + + Cont * Params.ElevationRange * 0.5f * ContScale + Mountain * Params.ElevationRange + Detail * Params.SurfaceRoughness; - // Beach: flatten terrain toward the water line within BeachWidth. + // Plateau/mesa terracing — quantize height into steps, but only in high-relief areas and + // only as strongly as TerraceStrength asks. Layered cliffs/mesas up top, smooth lowlands. + if (Params.TerraceStrength > 0.0f && Params.TerraceHeight > 0.0f) + { + const float Stepped = FMath::RoundToFloat(Terrain / Params.TerraceHeight) * Params.TerraceHeight; + Terrain = FMath::Lerp(Terrain, Stepped, Params.TerraceStrength * M); + } + + // Beach: flatten terrain toward the water line within BeachWidth. (Water level is + // strate-global — forced from the strate — so the water plane stays continuous.) const float WaterZ = BottomZ + H * Params.WaterLevelRelative; if (Params.WaterLevelRelative > 0.0f && Params.BeachWidth > 0.0f) { @@ -1842,11 +1989,15 @@ float UVoxelGenerator::GetSurfaceDensity(float WorldX, float WorldY, float World } } - // Solid below the terrain surface (positive = solid). - float Density = Terrain - WorldZ; + return Terrain; +} - // Sky cap: solid ceiling near the top of the strate, bumpy downward. - const float CeilZ = BottomZ + H * Params.CeilingRelative; +float UVoxelGenerator::ComputeSurfaceCeiling(float WorldX, float WorldY, + const FSurfaceGenerationParams& Params) const +{ + const float H = Params.StrateTopWorldZ - Params.StrateBottomWorldZ; + const float SeedF = (float)Seed; + const float CeilZ = Params.StrateBottomWorldZ + H * Params.CeilingRelative; float CeilNoise = 0.0f; if (Params.CeilingRoughness > 0.0f) { @@ -1854,26 +2005,323 @@ float UVoxelGenerator::GetSurfaceDensity(float WorldX, float WorldY, float World 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 + return CeilZ - CeilNoise; +} +float UVoxelGenerator::SurfaceDensityFromColumn(float WorldX, float WorldY, float WorldZ, + float TerrainZ, float CeilSurf, + const FSurfaceGenerationParams& S) const +{ + // Solid below the terrain surface; solid above the sky-cap ceiling. + float Density = TerrainZ - WorldZ; + Density = FMath::Max(Density, WorldZ - CeilSurf); + + // Structural fields (Z bounds, seal, base) are forced equal across biomes → S is safe. ApplyOriginSpine(Density, WorldX, WorldY, WorldZ, - Params.StrateTopWorldZ, Params.StrateBottomWorldZ, - Params.BoundarySealThickness, Params.BaseDensity, OriginSpineRadius); + S.StrateTopWorldZ, S.StrateBottomWorldZ, + S.BoundarySealThickness, S.BaseDensity, OriginSpineRadius); ApplyBoundarySeal(Density, WorldZ, - Params.StrateTopWorldZ, Params.StrateBottomWorldZ, - Params.BoundarySealThickness, Params.BaseDensity); + S.StrateTopWorldZ, S.StrateBottomWorldZ, + S.BoundarySealThickness, S.BaseDensity); if (StrateManager) { const float ModSDF = StrateManager->EvaluateModifierSDF(WorldX, WorldY, WorldZ); - ApplyPassageCarving(Density, ModSDF, Params.BaseDensity, Params.BoundarySealThickness); + ApplyPassageCarving(Density, ModSDF, S.BaseDensity, S.BoundarySealThickness); } return -Density; } +float UVoxelGenerator::GetSurfaceDensity(float WorldX, float WorldY, float WorldZ, + const FSurfaceGenerationParams& ParamsD, + const FSurfaceGenerationParams& ParamsN, + float NeighborWeight) const +{ + if (ParamsD.StrateTopWorldZ - ParamsD.StrateBottomWorldZ <= 0.0f) return 1.0f; + + // Heightfield: dominant biome, OUTPUT-BLENDED toward the nearest neighbour in the + // border band. Blending heights (not params) keeps borders seamless across any param + // difference. ParamsD == ParamsN, weight 0 ⇒ single eval (bit-identical, no biomes). + float TerrainZ = ComputeSurfaceTerrainZ(WorldX, WorldY, ParamsD); + if (NeighborWeight > 0.0f) + { + TerrainZ = FMath::Lerp(TerrainZ, ComputeSurfaceTerrainZ(WorldX, WorldY, ParamsN), NeighborWeight); + } + const float CeilSurf = ComputeSurfaceCeiling(WorldX, WorldY, ParamsD); + + return SurfaceDensityFromColumn(WorldX, WorldY, WorldZ, TerrainZ, CeilSurf, ParamsD); +} + +//============================================================================= +// CLIMATE & BIOME FIELDS +//============================================================================= +// Pure functions of world XY (+ seed). The relief field is shared with SurfaceWorld +// terrain so the biome map and the terrain it modulates stay in agreement. The biome +// field is a warped Voronoi whose cells are assigned a biome by climate — coherent +// geography (mountains cluster in high relief), window-invariant by construction. + +float UVoxelGenerator::SampleRelief(float WorldX, float WorldY, float Frequency, float Contrast) const +{ + const float SeedF = (float)Seed; + // Same offsets/octaves as the original SurfaceWorld relief so existing worlds are + // unchanged (this is the function that code path now calls). + float R = FractalNoise3D(FVector( + WorldX * Frequency + SeedF * 7.3f, + WorldY * Frequency + SeedF * 2.1f, + SeedF * 0.5f), 2) * 0.5f + 0.5f; // [0,1] + R = FMath::Clamp((R - 0.5f) * Contrast + 0.5f, 0.0f, 1.0f); + return SmoothStep01(R); +} + +float UVoxelGenerator::SampleMoisture(float WorldX, float WorldY, float Frequency) const +{ + const float SeedF = (float)Seed; + const float N = FractalNoise3D(FVector( + WorldX * Frequency + SeedF * 4.7f, + WorldY * Frequency + SeedF * 8.9f, + SeedF * 1.3f), 2) * 0.5f + 0.5f; // [0,1] + return FMath::Clamp(N, 0.0f, 1.0f); +} + +int32 UVoxelGenerator::ClassifyBiomeAtSite(float SiteX, float SiteY, + const FBiomeContext& Ctx, uint32 SiteHash) const +{ + const float R = SampleRelief(SiteX, SiteY, Ctx.Map.ReliefFrequency, Ctx.Map.ReliefContrast); + const float Mo = SampleMoisture(SiteX, SiteY, Ctx.Map.MoistureFrequency); + + int32 Best = 0; + float BestScore = FLT_MAX; + for (int32 i = 0; i < Ctx.Biomes.Num(); ++i) + { + const FBiomeResolved& B = Ctx.Biomes[i]; + // Distance in climate space to the biome's box (0 when inside it). + const float dr = (R < B.ReliefMin) ? (B.ReliefMin - R) + : (R > B.ReliefMax) ? (R - B.ReliefMax) : 0.0f; + const float dm = (Mo < B.MoistureMin) ? (B.MoistureMin - Mo) + : (Mo > B.MoistureMax) ? (Mo - B.MoistureMax) : 0.0f; + float Score = dr * dr + dm * dm; + // Tiny deterministic jitter so overlapping boxes don't all collapse to biome 0. + Score += VoxelHash::ToFloat01(VoxelHash::Mix(SiteHash ^ (0x9e3779b9u * (uint32)(i + 1)))) * 1.0e-4f; + if (Score < BestScore) { BestScore = Score; Best = i; } + } + return Best; +} + +FBiomeSample UVoxelGenerator::SampleBiomeAt(float WorldX, float WorldY, const FBiomeContext& Ctx) const +{ + FBiomeSample Out; + if (Ctx.Biomes.Num() == 0) return Out; // biomes disabled + if (Ctx.Biomes.Num() == 1) { Out.DominantIndex = 0; return Out; } + + const FBiomeMapParams& MP = Ctx.Map; + const float Cell = FMath::Max(MP.CellSize, 1.0f); + const uint32 S = (uint32)Seed ^ 0x42494f4du; // 'BIOM' + + // Domain-warp the query so cell borders wind organically (not a hex grid). + float QX = WorldX, QY = WorldY; + if (MP.WarpStrength > 0.0f) + { + const float SeedF = (float)Seed; + const float WF = MP.WarpFrequency; + const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + SeedF * 0.27f, WorldY * WF + 3.1f, SeedF * 1.1f)); + const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 7.7f, WorldY * WF + SeedF * 0.61f, SeedF * 2.3f)); + QX += wx * VOXEL_NOISE_SCALE * MP.WarpStrength; + QY += wy * VOXEL_NOISE_SCALE * MP.WarpStrength; + } + + const int32 CX = FMath::FloorToInt(QX / Cell); + const int32 CY = FMath::FloorToInt(QY / Cell); + + // Worley F1/F2 over the 3x3 neighbourhood of the (warped) cell. With jitter confined + // to [0,1) of a cell, the nearest site is always within this neighbourhood. + float BestD2 = FLT_MAX, SecondD2 = FLT_MAX; + int32 BestBiome = 0, SecondBiome = 0; + 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, S); + const float jx = VoxelHash::ToFloat01(h); + const float jy = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0x68bc21ebu)); + const float sx = (nx + jx) * Cell; + const float sy = (ny + jy) * Cell; + const float ddx = sx - QX, ddy = sy - QY; + const float d2 = ddx * ddx + ddy * ddy; + + if (d2 < BestD2) + { + SecondD2 = BestD2; SecondBiome = BestBiome; + BestD2 = d2; BestBiome = ClassifyBiomeAtSite(sx, sy, Ctx, h); + } + else if (d2 < SecondD2) + { + SecondD2 = d2; SecondBiome = ClassifyBiomeAtSite(sx, sy, Ctx, h); + } + } + + Out.DominantIndex = BestBiome; + Out.NeighborIndex = SecondBiome; + + // Blend weight: 0.5 at the shared border (d1≈d2), fading to 0 a BorderBlend-wide + // band inside the dominant cell. Only blend when the neighbour is a DIFFERENT biome. + if (MP.BorderBlend > 0.0f && SecondD2 < FLT_MAX && BestBiome != SecondBiome) + { + const float d1 = FMath::Sqrt(BestD2); + const float d2 = FMath::Sqrt(SecondD2); + const float t = FMath::Clamp((d2 - d1) / FMath::Max(MP.BorderBlend, 1.0f), 0.0f, 1.0f); + Out.NeighborWeight = 0.5f * (1.0f - SmoothStep01(t)); + } + return Out; +} + +void UVoxelGenerator::RebuildBiomeGrid(int32 ChunkX, int32 ChunkY, int32 ChunkZ, + const FBiomeContext& Ctx, FChunkBiomeCache& Cache) const +{ + Cache.ChunkZ = ChunkZ; + Cache.Seed = Seed; + Cache.bActive = Ctx.IsValid(); + Cache.Ctx = Ctx; + Cache.CellBiome.Reset(); + + if (!Cache.bActive) + { + // Mark the whole chunk footprint valid so non-biome chunks don't rebuild per voxel. + Cache.ValidMinX = (float)ChunkX * CHUNK_SIZE - (float)CHUNK_SIZE; + Cache.ValidMaxX = (float)(ChunkX + 1) * CHUNK_SIZE + (float)CHUNK_SIZE; + Cache.ValidMinY = (float)ChunkY * CHUNK_SIZE - (float)CHUNK_SIZE; + Cache.ValidMaxY = (float)(ChunkY + 1) * CHUNK_SIZE + (float)CHUNK_SIZE; + return; + } + + const FBiomeMapParams& MP = Ctx.Map; + const float Cell = FMath::Max(MP.CellSize, 1.0f); + const uint32 S = (uint32)Seed ^ 0x42494f4du; // 'BIOM' (must match SampleBiomeAt) + + // Validity halo: one chunk beyond the footprint, so the +X/+Y boundary corners and + // ±1 gradient-normal samples stay inside the valid box (no rebuild thrash, §8.10). + const float Halo = (float)CHUNK_SIZE; + Cache.ValidMinX = (float)ChunkX * CHUNK_SIZE - Halo; + Cache.ValidMaxX = (float)(ChunkX + 1) * CHUNK_SIZE + Halo; + Cache.ValidMinY = (float)ChunkY * CHUNK_SIZE - Halo; + Cache.ValidMaxY = (float)(ChunkY + 1) * CHUNK_SIZE + Halo; + + // Cell-grid coverage: the valid box expanded by the max warp displacement + one cell, + // so the 3x3 search around any in-box query's warped point is fully present. + const float CellMargin = MP.WarpStrength * VOXEL_NOISE_SCALE + Cell + 1.0f; + Cache.BaseCellX = FMath::FloorToInt((Cache.ValidMinX - CellMargin) / Cell); + Cache.BaseCellY = FMath::FloorToInt((Cache.ValidMinY - CellMargin) / Cell); + const int32 MaxCellX = FMath::FloorToInt((Cache.ValidMaxX + CellMargin) / Cell); + const int32 MaxCellY = FMath::FloorToInt((Cache.ValidMaxY + CellMargin) / Cell); + Cache.CellsX = MaxCellX - Cache.BaseCellX + 1; + Cache.CellsY = MaxCellY - Cache.BaseCellY + 1; + Cache.CellBiome.SetNumUninitialized(Cache.CellsX * Cache.CellsY); + + for (int32 cy = 0; cy < Cache.CellsY; ++cy) + for (int32 cx = 0; cx < Cache.CellsX; ++cx) + { + const int32 nx = Cache.BaseCellX + cx; + const int32 ny = Cache.BaseCellY + cy; + const uint32 h = VoxelHash::Cell(nx, ny, S); + const float jx = VoxelHash::ToFloat01(h); + const float jy = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0x68bc21ebu)); + const float sx = (nx + jx) * Cell; + const float sy = (ny + jy) * Cell; + Cache.CellBiome[cy * Cache.CellsX + cx] = ClassifyBiomeAtSite(sx, sy, Ctx, h); + } +} + +FBiomeSample UVoxelGenerator::ResolveBiomeSampleAt(float WorldX, float WorldY, int32 ChunkZ, + const FBiomeContext& Ctx, FChunkBiomeCache& Cache) const +{ + FBiomeSample Out; + + // Box-validated rebuild (perf-only; result is the pure function of XY either way). + if (!Cache.Contains(WorldX, WorldY, ChunkZ, Seed)) + { + RebuildBiomeGrid(FMath::FloorToInt(WorldX / CHUNK_SIZE), + FMath::FloorToInt(WorldY / CHUNK_SIZE), ChunkZ, Ctx, Cache); + } + + if (!Cache.bActive) return Out; + if (Cache.Ctx.Biomes.Num() == 1) { Out.DominantIndex = 0; return Out; } + + const FBiomeMapParams& MP = Cache.Ctx.Map; + const float Cell = FMath::Max(MP.CellSize, 1.0f); + const uint32 S = (uint32)Seed ^ 0x42494f4du; + + // Same warp + 3x3 Worley as SampleBiomeAt → identical assignment (matches the preview). + float QX = WorldX, QY = WorldY; + if (MP.WarpStrength > 0.0f) + { + const float SeedF = (float)Seed; + const float WF = MP.WarpFrequency; + const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + SeedF * 0.27f, WorldY * WF + 3.1f, SeedF * 1.1f)); + const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 7.7f, WorldY * WF + SeedF * 0.61f, SeedF * 2.3f)); + QX += wx * VOXEL_NOISE_SCALE * MP.WarpStrength; + QY += wy * VOXEL_NOISE_SCALE * MP.WarpStrength; + } + + const int32 CX = FMath::FloorToInt(QX / Cell); + const int32 CY = FMath::FloorToInt(QY / Cell); + + float BestD2 = FLT_MAX, SecondD2 = FLT_MAX; + int32 BestBiome = 0, SecondBiome = 0; + 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, S); + const float jx = VoxelHash::ToFloat01(h); + const float jy = VoxelHash::ToFloat01(VoxelHash::Mix(h ^ 0x68bc21ebu)); + const float sx = (nx + jx) * Cell; + const float sy = (ny + jy) * Cell; + const float ddx = sx - QX, ddy = sy - QY; + const float d2 = ddx * ddx + ddy * ddy; + + // Cached biome index (fallback to classify on the rare margin miss → still correct). + const int32 gx = nx - Cache.BaseCellX; + const int32 gy = ny - Cache.BaseCellY; + const int32 BiomeIdx = (gx >= 0 && gx < Cache.CellsX && gy >= 0 && gy < Cache.CellsY) + ? Cache.CellBiome[gy * Cache.CellsX + gx] + : ClassifyBiomeAtSite(sx, sy, Cache.Ctx, h); + + if (d2 < BestD2) { SecondD2 = BestD2; SecondBiome = BestBiome; BestD2 = d2; BestBiome = BiomeIdx; } + else if (d2 < SecondD2) { SecondD2 = d2; SecondBiome = BiomeIdx; } + } + + Out.DominantIndex = BestBiome; + Out.NeighborIndex = SecondBiome; + if (MP.BorderBlend > 0.0f && SecondD2 < FLT_MAX && BestBiome != SecondBiome) + { + const float d1 = FMath::Sqrt(BestD2); + const float d2 = FMath::Sqrt(SecondD2); + const float t = FMath::Clamp((d2 - d1) / FMath::Max(MP.BorderBlend, 1.0f), 0.0f, 1.0f); + Out.NeighborWeight = 0.5f * (1.0f - SmoothStep01(t)); + } + return Out; +} + +const UVoxelBiomeDefinition* UVoxelGenerator::GetDominantBiomeAt(float WorldX, float WorldY, int32 ChunkZ) const +{ + if (!StrateManager) return nullptr; + + const FIntVector Coord(FMath::FloorToInt(WorldX / CHUNK_SIZE), + FMath::FloorToInt(WorldY / CHUNK_SIZE), ChunkZ); + + const FBiomeContext Ctx = StrateManager->GetBiomeContextForChunk(Coord); + if (!Ctx.IsValid()) return nullptr; + + const FBiomeSample S = SampleBiomeAt(WorldX, WorldY, Ctx); + if (!Ctx.Biomes.IsValidIndex(S.DominantIndex)) return nullptr; + + // Map the context position back to the strate's Biomes[] asset. + const int32 StrateBiomeIdx = Ctx.Biomes[S.DominantIndex].Index; + const UVoxelStrateDefinition* Def = StrateManager->GetStrateForChunk(Coord); + return (Def && Def->Biomes.IsValidIndex(StrateBiomeIdx)) ? Def->Biomes[StrateBiomeIdx] : nullptr; +} + //============================================================================= // VERTICAL-SHAFT GENERATOR (ECaveGeneratorType::VerticalShafts) //============================================================================= diff --git a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp index 07d8797..27d64f9 100644 --- a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp +++ b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp @@ -89,7 +89,10 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(const FVoxelChunk& Chunk, // Les vertices partagés permettent des normales lisses et réduisent le count ~3x. TMap VertexMap; - auto GetOrCreateVertex = [&](const FVector& WorldPos) -> int32 + // Normale fournie par l'appelant (gradient lu dans la grille de densité, T1.b) — + // plus d'échantillonnage de densité par vertex. RawNormal pointe solide→air ; on la + // normalise ici (fallback up si dégénérée). + auto GetOrCreateVertex = [&](const FVector& WorldPos, const FVector& RawNormal) -> int32 { const FIntVector Key( FMath::RoundToInt(WorldPos.X * 100.0f), @@ -107,18 +110,12 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(const FVoxelChunk& Chunk, MeshData.Vertices.Add(WorldPos); - // Normale par gradient de densité (shading lisse). - if (Generator) + FVector Normal = RawNormal; + if (!Normal.Normalize()) { - 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)); + Normal = FVector(0.0f, 0.0f, 1.0f); // dégénéré (zone plate) } + MeshData.Normals.Add(Normal); // UVs planaires — le triplanar mapping se fait dans le matériau. MeshData.UVs.Add(FVector2D(WorldPos.X / VOXEL_SIZE, WorldPos.Y / VOXEL_SIZE)); @@ -161,23 +158,44 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(const FVoxelChunk& Chunk, // 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. + // On échantillonne avec un anneau de marge de 1 point de chaque côté (indices -1..GridDim) + // pour pouvoir calculer les normales par GRADIENT DE GRILLE (T1.b) — différences centrales + // sur la grille au lieu de 6 appels densité frais par vertex. La marge utilise les mêmes + // échantillons monde purs qu'un chunk voisin, donc les normales restent continues aux bords + // de chunk (pas de couture de shading). La géométrie est inchangée au bit près (mêmes + // positions d'arête) ; seules les normales changent. const int32 CellsPerAxis = CHUNK_SIZE / Step; const int32 GridDim = CellsPerAxis + 1; + const int32 MDim = GridDim + 2; // +1 marge de chaque côté TArray DensityGrid; - DensityGrid.SetNumUninitialized(GridDim * GridDim * GridDim); - for (int32 gz = 0; gz < GridDim; gz++) + DensityGrid.SetNumUninitialized(MDim * MDim * MDim); + for (int32 gz = -1; gz <= GridDim; gz++) { - for (int32 gy = 0; gy < GridDim; gy++) + for (int32 gy = -1; gy <= GridDim; gy++) { - for (int32 gx = 0; gx < GridDim; gx++) + for (int32 gx = -1; gx <= GridDim; gx++) { - DensityGrid[(gz * GridDim + gy) * GridDim + gx] = + DensityGrid[((gz + 1) * MDim + (gy + 1)) * MDim + (gx + 1)] = GetDensity(Chunk, gx * Step, gy * Step, gz * Step); } } } + // Lecture grille (avec offset de marge) + gradient central depuis la grille. + auto SampleG = [&](int32 gx, int32 gy, int32 gz) -> float + { + return DensityGrid[((gz + 1) * MDim + (gy + 1)) * MDim + (gx + 1)]; + }; + auto GradAt = [&](int32 gx, int32 gy, int32 gz) -> FVector + { + // Densité négative=solide, positive=air → le gradient pointe vers l'air (sortant). + return FVector( + SampleG(gx + 1, gy, gz) - SampleG(gx - 1, gy, gz), + SampleG(gx, gy + 1, gz) - SampleG(gx, gy - 1, gz), + SampleG(gx, gy, gz + 1) - SampleG(gx, gy, gz - 1)); + }; + //========================================================================= // ITÉRATION SUR LES CELLULES //========================================================================= @@ -190,9 +208,10 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(const FVoxelChunk& Chunk, { for (int32 cx = 0; cx < CellsPerAxis; cx++) { - // Densités + positions aux 8 coins (lues dans la grille pré-calculée) + // Densités + positions + gradients aux 8 coins (lus dans la grille). float Densities[8]; FVector Positions[8]; + FVector Gradients[8]; for (int32 i = 0; i < 8; i++) { @@ -200,9 +219,10 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(const FVoxelChunk& Chunk, 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 + Densities[i] = SampleG(GX, GY, GZ); + Positions[i] = ChunkWorldPos + FVector(GX * Step, GY * Step, GZ * Step) * VOXEL_SIZE; + Gradients[i] = GradAt(GX, GY, GZ); } // Index de cas MC (8 bits, un par coin) @@ -217,18 +237,23 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(const FVoxelChunk& Chunk, if (MCEdgeTable[CaseIndex] == 0) continue; // Pas de surface ici - // Interpolation des positions sur les arêtes traversées + // Interpolation des positions + normales sur les arêtes traversées. Le t est + // calculé exactement comme InterpolateEdge → positions bit-identiques (topologie + // inchangée) ; la normale interpole les gradients de coin par le même t. FVector EdgeVertices[12]; + FVector EdgeNormals[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] - ); + const float D1 = Densities[A], D2 = Densities[B]; + const float T = (FMath::Abs(D2 - D1) < KINDA_SMALL_NUMBER) + ? 0.5f + : FMath::Clamp((IsoLevel - D1) / (D2 - D1), 0.0f, 1.0f); + EdgeVertices[i] = Positions[A] + T * (Positions[B] - Positions[A]); + EdgeNormals[i] = Gradients[A] + T * (Gradients[B] - Gradients[A]); } } @@ -236,9 +261,12 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(const FVoxelChunk& Chunk, // 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]]); + const int32 E0 = MCTriTable[CaseIndex][i]; + const int32 E1 = MCTriTable[CaseIndex][i + 1]; + const int32 E2 = MCTriTable[CaseIndex][i + 2]; + const int32 Idx0 = GetOrCreateVertex(EdgeVertices[E0], EdgeNormals[E0]); + const int32 Idx1 = GetOrCreateVertex(EdgeVertices[E1], EdgeNormals[E1]); + const int32 Idx2 = GetOrCreateVertex(EdgeVertices[E2], EdgeNormals[E2]); MeshData.Triangles.Add(Idx0); MeshData.Triangles.Add(Idx2); diff --git a/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Source/VoxelForge/Private/VoxelStrateManager.cpp index d165071..689e97e 100644 --- a/Source/VoxelForge/Private/VoxelStrateManager.cpp +++ b/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -6,6 +6,7 @@ #include "VoxelTypes.h" // For CHUNK_SIZE, VOXEL_SIZE, WorldToChunkCoord #include "VoxelCaveMorphology.h" // For VoxelSDF and VoxelHash #include "VoxelTerrainOpDefinition.h" // For UVoxelTerrainOpDefinition::ApplyTo +#include "VoxelBiomeDefinition.h" // For UVoxelBiomeDefinition (biome context flatten) // Fractal Brownian Motion (layered Perlin) along a 1D parameter, ~[-1,1]. // Independent octaves at increasing frequency / decreasing amplitude give an organic, @@ -536,6 +537,33 @@ VF_ARCHETYPE_PARAMS_GETTER(GetFloatingIslandParamsForChunk, FFloatingIslandParam #undef VF_ARCHETYPE_PARAMS_GETTER +FBiomeContext UVoxelStrateManager::GetBiomeContextForChunk(const FIntVector& ChunkCoord) const +{ + FBiomeContext Out; + + const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z); + if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) return Out; + + const UVoxelStrateDefinition* Def = StrateLayout[SlotIdx].Definition; + if (Def->Biomes.Num() == 0) return Out; // biomes disabled for this strate + + Out.Map = Def->BiomeMapParams; + Out.Biomes.Reserve(Def->Biomes.Num()); + for (int32 i = 0; i < Def->Biomes.Num(); ++i) + { + const UVoxelBiomeDefinition* B = Def->Biomes[i]; + if (!B) continue; // skip null entries (keep original index for content lookup) + + FBiomeResolved R; + R.Index = i; + R.ReliefMin = B->ReliefMin; R.ReliefMax = B->ReliefMax; + R.MoistureMin = B->MoistureMin; R.MoistureMax = B->MoistureMax; + R.DebugColor = B->DebugColor.ToFColor(true); + Out.Biomes.Add(R); + } + return Out; +} + bool UVoxelStrateManager::GetStrateUnrealZRange(float WorldZ, float& OutTopZ, float& OutBottomZ) const { const int32 ChunkZ = FMath::FloorToInt((WorldZ / VOXEL_SIZE) / CHUNK_SIZE); diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index 1b559e2..44b210e 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -7,10 +7,17 @@ #include "RealtimeMeshSimple.h" #include "VoxelMarchingCubesMesher.h" #include "VoxelStrateDefinition.h" +#include "VoxelBiomeDefinition.h" #include "VoxelTerrainOpDefinition.h" #include "VoxelContentManager.h" #include "VoxelAtmosphereManager.h" #include "DrawDebugHelpers.h" +#include "IImageWrapper.h" +#include "IImageWrapperModule.h" +#include "Modules/ModuleManager.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" +#include "ProfilingDebugging/CpuProfilerTrace.h" // Unreal Insights scopes (Perf 0) AVoxelWorld::AVoxelWorld() { @@ -241,13 +248,13 @@ void AVoxelWorld::BeginPlay() // Content manager — scatters decorations/actors + water per chunk as they stream in. ContentManager = NewObject(this); - ContentManager->Initialize(this, StrateManager, Settings->Seed); + ContentManager->Initialize(this, StrateManager, Generator, Settings->Seed); // Atmosphere manager — per-strate fog + ambient + persistent ceiling/floor layers. if (bManageAtmosphere && StrateManager) { AtmosphereManager = NewObject(this); - AtmosphereManager->Initialize(this, StrateManager); + AtmosphereManager->Initialize(this, StrateManager, Generator); } #if WITH_EDITOR @@ -270,6 +277,11 @@ void AVoxelWorld::Tick(float DeltaTime) { AtmosphereManager->UpdateForPlayer(PlayerLastPos); } + if (ContentManager) + { + // Strate light culling — no-op unless the player changed strate. + ContentManager->SetActiveStrate(GetStrateAtPosition(PlayerLastPos)); + } } ProcessPendingChunks(); @@ -595,7 +607,10 @@ void AVoxelWorld::LoadChunk(const FIntVector& ChunkCoord) Result.Chunk = Chunk; Result.LODLevel = LODLevel; Result.Epoch = TaskEpoch; - Result.MeshData = Mesher->GenerateMesh(Chunk, Step); + { + TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_GenerateMesh); + Result.MeshData = Mesher->GenerateMesh(Chunk, Step); + } if (!bShuttingDown.load(std::memory_order_relaxed)) { @@ -622,6 +637,8 @@ void AVoxelWorld::UnloadChunk(const FIntVector& ChunkCoord) void AVoxelWorld::ApplyMeshToChunk(const FIntVector& ChunkCoord, const FVoxelMeshData& MeshData) { + TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ApplyMeshToChunk); + //========================================================================== // STEP 1: EARLY EXIT IF NO MESH DATA //========================================================================== @@ -652,6 +669,17 @@ void AVoxelWorld::ApplyMeshToChunk(const FIntVector& ChunkCoord, const FVoxelMes // 'this' (AVoxelWorld) is the "outer" - it owns this component MeshComp = NewObject(this); + // 40k+ chunk components hammer the GAME THREAD, not the GPU. Kill per-component + // bookkeeping the engine would otherwise do every frame for each of them: + // - overlap events: chunks never use overlap callbacks (gameplay uses raycasts + // against the LOD0 collision), and UpdateOverlaps over tens of thousands of + // components is a classic game-thread sink. + // - navigation: terrain isn't navmesh-driven here. + // (The real fix for component COUNT is the chunk-LOD clipmap; this trims the + // per-component cost meanwhile and survives that refactor.) + MeshComp->SetGenerateOverlapEvents(false); + MeshComp->SetCanEverAffectNavigation(false); + // RegisterComponent() tells Unreal "this component is ready to use" // Without this, the component won't tick, render, or do anything MeshComp->RegisterComponent(); @@ -789,7 +817,21 @@ void AVoxelWorld::ApplyMeshToChunk(const FIntVector& ChunkCoord, const FVoxelMes } RTMesh->SetupMaterialSlot(0, "Main", ChunkMaterial); - RTMesh->UpdateSectionConfig(SectionKey, SectionConfig, true); + + // Collision only at LOD0. Distant chunks are unreachable by definition (the LOD + // reconciliation loop §8.10 hot-swaps a chunk to LOD0 before the player can touch it), + // so cooking Chaos tri-mesh collision for LOD1/2 is pure waste — kills the cook cost + + // collision memory for the large majority of loaded chunks. (T1.c) + const int32 ChunkLOD = ChunkLODs.FindRef(ChunkCoord); + const bool bWantCollision = (ChunkLOD == 0); + RTMesh->UpdateSectionConfig(SectionKey, SectionConfig, bWantCollision); + + // Shadow casting: the FARTHEST tier (LOD2) does NOT cast shadows. Each shadow-casting + // chunk adds a SECOND draw call in the shadow-depth pass (~doubles the draw cost of the + // terrain), and crisp shadows on distant blocky LOD2 geometry aren't worth it. LOD0/1 + // keep shadows. Conservative first cut — widen to `<= 0` (LOD1 too) if still draw-bound. + // Re-applied every time because LOD hot-swaps reuse the same component. + MeshComp->SetCastShadow(ChunkLOD <= 1); //========================================================================== // STEP 11: POPULATE CONTENT (decorations + water) @@ -925,6 +967,118 @@ void AVoxelWorld::EditorFillSphere() FillAtPosition(EditorBrushCenter, EditorBrushRadius, EditorBrushStrength); } +//============================================================================= +// BIOME MAP PREVIEW — bake the XY biome field to Saved/BiomePreview.png +//============================================================================= + +void AVoxelWorld::BakeBiomePreview() +{ + if (!BiomePreviewStrate) + { + UE_LOG(LogTemp, Warning, TEXT("[VoxelWorld] BakeBiomePreview: assign BiomePreviewStrate first.")); + return; + } + if (BiomePreviewChannel == EBiomePreviewChannel::Biome && BiomePreviewStrate->Biomes.Num() == 0) + { + UE_LOG(LogTemp, Warning, TEXT("[VoxelWorld] BakeBiomePreview: '%s' has no Biomes — bake Relief/Moisture instead, or add biomes."), + *BiomePreviewStrate->GetName()); + return; + } + + // Transient generator so this works in the editor without PIE. + UVoxelGenerator* Gen = NewObject(this); + Gen->Seed = Settings ? Settings->Seed : 0; + + // Flatten the strate's biomes into a context (mirrors StrateManager::GetBiomeContextForChunk). + FBiomeContext Ctx; + Ctx.Map = BiomePreviewStrate->BiomeMapParams; + for (int32 i = 0; i < BiomePreviewStrate->Biomes.Num(); ++i) + { + const UVoxelBiomeDefinition* B = BiomePreviewStrate->Biomes[i]; + if (!B) continue; + FBiomeResolved R; + R.Index = i; + R.ReliefMin = B->ReliefMin; R.ReliefMax = B->ReliefMax; + R.MoistureMin = B->MoistureMin; R.MoistureMax = B->MoistureMax; + R.DebugColor = B->DebugColor.ToFColor(true); + Ctx.Biomes.Add(R); + } + + const int32 Res = FMath::Clamp(BiomePreviewResolution, 64, 2048); + const float Size = FMath::Max(BiomePreviewWorldSize, 1.0f); + const float Step = Size / (float)Res; + const float OriginX = BiomePreviewCenter.X - Size * 0.5f; + const float OriginY = BiomePreviewCenter.Y - Size * 0.5f; + + TArray Pixels; + Pixels.SetNumUninitialized(Res * Res); + + for (int32 py = 0; py < Res; ++py) + for (int32 px = 0; px < Res; ++px) + { + const float wx = OriginX + (px + 0.5f) * Step; + const float wy = OriginY + (py + 0.5f) * Step; + + FColor C = FColor::Black; + switch (BiomePreviewChannel) + { + case EBiomePreviewChannel::Relief: + { + const float r = Gen->SampleRelief(wx, wy, Ctx.Map.ReliefFrequency, Ctx.Map.ReliefContrast); + const uint8 v = (uint8)FMath::Clamp(r * 255.0f, 0.0f, 255.0f); + C = FColor(v, v, v); + break; + } + case EBiomePreviewChannel::Moisture: + { + const float m = Gen->SampleMoisture(wx, wy, Ctx.Map.MoistureFrequency); + const uint8 v = (uint8)FMath::Clamp(m * 255.0f, 0.0f, 255.0f); + C = FColor(0, v, (uint8)(255 - v)); // dry=blue → wet=green + break; + } + default: // Biome + { + const FBiomeSample S = Gen->SampleBiomeAt(wx, wy, Ctx); + if (Ctx.Biomes.IsValidIndex(S.DominantIndex)) + { + C = Ctx.Biomes[S.DominantIndex].DebugColor; + if (S.NeighborWeight > 0.0f && Ctx.Biomes.IsValidIndex(S.NeighborIndex)) + { + const FLinearColor A(C); + const FLinearColor Bn(Ctx.Biomes[S.NeighborIndex].DebugColor); + C = FLinearColor::LerpUsingHSV(A, Bn, S.NeighborWeight).ToFColor(true); + } + } + break; + } + } + C.A = 255; + Pixels[py * Res + px] = C; + } + + // Encode PNG and write to Saved/. + IImageWrapperModule& Module = FModuleManager::LoadModuleChecked(FName("ImageWrapper")); + const TSharedPtr Wrapper = Module.CreateImageWrapper(EImageFormat::PNG); + if (!Wrapper.IsValid() || + !Wrapper->SetRaw(Pixels.GetData(), (int64)Pixels.Num() * sizeof(FColor), Res, Res, ERGBFormat::BGRA, 8)) + { + UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] BakeBiomePreview: failed to encode image.")); + return; + } + + const TArray64& Png = Wrapper->GetCompressed(100); + const FString Path = FPaths::ProjectSavedDir() / TEXT("BiomePreview.png"); + if (FFileHelper::SaveArrayToFile(Png, *Path)) + { + UE_LOG(LogTemp, Display, TEXT("[VoxelWorld] Biome preview (%dx%d, %s) saved to %s"), + Res, Res, *UEnum::GetValueAsString(BiomePreviewChannel), *FPaths::ConvertRelativePathToFull(Path)); + } + else + { + UE_LOG(LogTemp, Error, TEXT("[VoxelWorld] BakeBiomePreview: failed to write %s"), *Path); + } +} + void AVoxelWorld::ClearAllModifications() { if (!DiffLayer) return; diff --git a/Source/VoxelForge/Public/VoxelAtmosphereManager.h b/Source/VoxelForge/Public/VoxelAtmosphereManager.h index 17c7968..c862cc9 100644 --- a/Source/VoxelForge/Public/VoxelAtmosphereManager.h +++ b/Source/VoxelForge/Public/VoxelAtmosphereManager.h @@ -14,6 +14,8 @@ class UVoxelStrateManager; class UVoxelStrateDefinition; +class UVoxelBiomeDefinition; +class UVoxelGenerator; class UExponentialHeightFogComponent; class USkyLightComponent; @@ -23,23 +25,31 @@ 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); + /** Create the managed fog + skylight components on the owner actor. Generator supplies + * the dominant-biome query so atmosphere can vary by biome within a strate. */ + void Initialize(AActor* InOwner, UVoxelStrateManager* InStrateManager, UVoxelGenerator* InGenerator); - /** Call each frame with the player's world position. Cheap: only reacts on strate change. */ + /** Call each frame with the player's world position. Cheap: only reacts on strate OR + * dominant-biome change. */ void UpdateForPlayer(const FVector& PlayerWorldPos); /** Tear down spawned layer actors + reset (season reset / shutdown). */ void Reset(); private: - void ApplyStrate(const UVoxelStrateDefinition* Def); + // Full strate apply: layer actors + atmosphere BP + fog/sky. Biome retints fog/sky. + void ApplyStrate(const UVoxelStrateDefinition* Def, const UVoxelBiomeDefinition* Biome); + // Just the managed fog + skylight (biome override beats strate when set). + void ApplyFogSky(const UVoxelStrateDefinition* Def, const UVoxelBiomeDefinition* Biome); TWeakObjectPtr Owner; UPROPERTY() UVoxelStrateManager* StrateManager = nullptr; + UPROPERTY() + UVoxelGenerator* Generator = nullptr; + UPROPERTY() UExponentialHeightFogComponent* Fog = nullptr; @@ -59,4 +69,7 @@ private: // Which strate's atmosphere is currently applied (INT32_MIN = none yet). int32 CurrentStrateIndex = INT32_MIN; + + // Dominant biome currently driving fog/sky (identity token for change detection). + TWeakObjectPtr CurrentBiome; }; diff --git a/Source/VoxelForge/Public/VoxelContentManager.h b/Source/VoxelForge/Public/VoxelContentManager.h index c6c3c8c..05cd1db 100644 --- a/Source/VoxelForge/Public/VoxelContentManager.h +++ b/Source/VoxelForge/Public/VoxelContentManager.h @@ -6,22 +6,39 @@ // - PopulateChunk(coord, meshdata) after a chunk's mesh is applied // - ClearChunk(coord) when a chunk unloads / is re-meshed // - ClearAll() on regenerate / season reset +// - SetActiveStrate(playerStrate) each Tick (no-op unless the strate changed) // // 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. +// +// RENDERING PATHS per decoration entry (FStrateDecoration): +// - ActorClass → real actors (lights, logic, interaction). Keep MaxLODLevel=0. +// - InstancedMesh → HISM instances batched per chunk: no tick, no per-actor cost, +// engine-culled. Safe to allow at LOD 1-2 so visual props don't +// pop out with LOD0 (emissive materials still glow at distance). +// +// STRATE LIGHT CULLING: a light in another strate can never legitimately reach the +// player (seals + bedrock gap are solid), but a shadowless light BLEEDS through rock +// and a shadowed one pays full cost to render black. So light components on spawned +// decoration actors are only visible while the player is in the SAME strate — the +// analytical form of occlusion culling, computed once per strate change. #pragma once #include "CoreMinimal.h" #include "VoxelTypes.h" +#include "VoxelStrateTypes.h" // FStrateDecoration (resolved per dominant biome) #include "VoxelContentManager.generated.h" class UVoxelStrateManager; class UVoxelStrateDefinition; +class UVoxelGenerator; class UStaticMesh; class UStaticMeshComponent; +class UHierarchicalInstancedStaticMeshComponent; +class UMaterialInterface; UCLASS() class VOXELFORGE_API UVoxelContentManager : public UObject @@ -29,34 +46,55 @@ 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); + /** Wire up services. Owner is the AVoxelWorld actor that owns spawned content. + * Generator supplies the dominant-biome query for per-biome content selection. */ + void Initialize(AActor* InOwner, UVoxelStrateManager* InStrateManager, + UVoxelGenerator* InGenerator, 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. */ + * Each decoration entry spawns only while LOD <= its MaxLODLevel; water at any LOD. */ void PopulateChunk(const FIntVector& ChunkCoord, const FVoxelMeshData& MeshData, int32 LODLevel = 0); - /** Destroy all spawned content (actors + water plane) for one chunk. */ + /** Destroy all spawned content (actors + instances + water plane) for one chunk. */ void ClearChunk(const FIntVector& ChunkCoord); /** Destroy all spawned content for every chunk. */ void ClearAll(); + /** Player strate changed → toggle decoration lights (strict: lights only live in the + * player's strate). Cheap no-op while the strate stays the same — call every Tick. */ + void SetActiveStrate(int32 PlayerStrateIndex); + private: void SpawnDecorations(const FIntVector& ChunkCoord, const FVoxelMeshData& MeshData, - const UVoxelStrateDefinition* Def, TArray>& Out); - void SpawnWater(const FIntVector& ChunkCoord, const UVoxelStrateDefinition* Def); + const TArray& Decorations, int32 LODLevel, + TArray>& Out); + void SpawnWater(const FIntVector& ChunkCoord, const UVoxelStrateDefinition* Def, + UMaterialInterface* WaterMaterial); + + /** Strate index a chunk belongs to (center-Z lookup). */ + int32 GetChunkStrateIndex(const FIntVector& ChunkCoord) const; + + /** Show/hide every light component on a decoration actor. */ + static void SetActorLightsEnabled(AActor* Actor, bool bEnabled); TWeakObjectPtr Owner; UPROPERTY() UVoxelStrateManager* StrateManager = nullptr; + UPROPERTY() + UVoxelGenerator* Generator = nullptr; + int32 Seed = 0; + // Player's current strate (INT32_MIN until first SetActiveStrate). Lights on spawned + // actors are enabled iff their chunk's strate matches this. + int32 ActiveStrateIndex = INT32_MIN; + // Engine unit plane (/Engine/BasicShapes/Plane) reused for every water surface. UPROPERTY() UStaticMesh* PlaneMesh = nullptr; @@ -64,6 +102,9 @@ private: // Spawned decoration/ambient actors per chunk (weak — they live in the level). TMap>> SpawnedActors; + // HISM components per chunk (weak — registered components are owned by the actor). + TMap>> ChunkInstances; + // Water surface component per chunk. UPROPERTY() TMap WaterPlanes; diff --git a/Source/VoxelForge/Public/VoxelGenerator.h b/Source/VoxelForge/Public/VoxelGenerator.h index cc8c2ac..bffdf27 100644 --- a/Source/VoxelForge/Public/VoxelGenerator.h +++ b/Source/VoxelForge/Public/VoxelGenerator.h @@ -11,12 +11,14 @@ #include "CoreMinimal.h" #include "VoxelTypes.h" #include "VoxelStrateTypes.h" +#include "VoxelBiomeTypes.h" #include "VoxelGenerator.generated.h" // Forward decls (évite les includes transitifs) class UVoxelSettings; class UVoxelStrateManager; class UVoxelDiffLayer; +class UVoxelBiomeDefinition; /** * UVoxelGenerator @@ -103,9 +105,18 @@ public: /** * Densité pour une strate SurfaceWorld — terrain à ciel ouvert (collines, * montagnes, plages) sous un plafond solide, avec nappe d'eau optionnelle. + * + * Biome output-blend: the heightfield is evaluated with ParamsD (the voxel's dominant + * biome) and, when NeighborWeight > 0, also with ParamsN (its nearest neighbour); the + * two surface HEIGHTS are lerped. This stays seamless across ANY param difference + * (frequencies included). With no biomes, pass ParamsD == ParamsN and weight 0 → + * bit-identical to the single-param terrain. Structural fields (Z bounds, seal, base, + * water level) must be equal in both (forced from the strate). */ float GetSurfaceDensity(float WorldX, float WorldY, float WorldZ, - const FSurfaceGenerationParams& Params) const; + const FSurfaceGenerationParams& ParamsD, + const FSurfaceGenerationParams& ParamsN, + float NeighborWeight) const; /** * Densité pour une strate VerticalShafts — puits verticaux pleine hauteur, @@ -120,4 +131,67 @@ public: */ float GetFloatingIslandDensity(float WorldX, float WorldY, float WorldZ, const FFloatingIslandParams& Params) const; + + //========================================================================= + // CLIMATE & BIOME FIELDS (pure XY, deterministic, window-invariant) + //========================================================================= + + /** + * Relief ("elevation") field at a world XY → [0,1], contrast-shaped & smoothed. + * The single source of truth for the relief map M: SurfaceWorld terrain and the + * biome climate map both call this, so they agree when given the same freq/contrast. + */ + float SampleRelief(float WorldX, float WorldY, float Frequency, float Contrast) const; + + /** + * Moisture field at a world XY → [0,1]. The second climate axis for biome placement. + */ + float SampleMoisture(float WorldX, float WorldY, float Frequency) const; + + /** + * Resolve the biome at a world XY: dominant cell + nearest neighbour + blend weight. + * Warped Voronoi over a jittered grid; each cell's biome is chosen by its site's + * (relief, moisture) against the context's climate boxes. PURE function of (XY, seed, + * Ctx) — no chunk-window dependence (CODEMAP §8.4). Returns an empty sample when the + * context has no biomes. Used by the 2D preview bake and (next) the density path. + */ + FBiomeSample SampleBiomeAt(float WorldX, float WorldY, const FBiomeContext& Ctx) const; + + /** + * Hot-path biome resolve: the dominant + neighbour biome and blend weight at a world + * XY for the given strate slice. Uses (and lazily rebuilds) Cache — a box-validated + * per-chunk grid — so the noise-heavy cell classification happens once per chunk, not + * per voxel. Bit-identical to SampleBiomeAt, so the baked preview matches the terrain. + */ + FBiomeSample ResolveBiomeSampleAt(float WorldX, float WorldY, int32 ChunkZ, + const FBiomeContext& Ctx, FChunkBiomeCache& Cache) const; + + /** + * Dominant biome ASSET at a world XY for the given strate slice (chunk Z), or nullptr + * when the strate has no biomes / the point is outside the strate range. Game-thread + * helper for content + atmosphere selection — uses the same field as the density path. + */ + const UVoxelBiomeDefinition* GetDominantBiomeAt(float WorldX, float WorldY, int32 ChunkZ) const; + +private: + /** Pick the biome (index into Ctx.Biomes) for a Voronoi site, by its climate. */ + int32 ClassifyBiomeAtSite(float SiteX, float SiteY, const FBiomeContext& Ctx, uint32 SiteHash) const; + + /** The SurfaceWorld heightfield: world XY → terrain surface Z (voxel coords). Pure + * per-XY; the part that's evaluated per biome and blended in GetSurfaceDensity. */ + float ComputeSurfaceTerrainZ(float WorldX, float WorldY, const FSurfaceGenerationParams& Params) const; + + /** The SurfaceWorld sky-cap ceiling surface Z at a world XY (also pure per-XY). */ + float ComputeSurfaceCeiling(float WorldX, float WorldY, const FSurfaceGenerationParams& Params) const; + + /** Final SurfaceWorld density from a column's precomputed terrain Z + ceiling: the + * cheap per-voxel Z-combine + origin spine + boundary seal + passage carving. The + * XY-only work (terrain/ceiling) is done once per column and cached (T1.a). */ + float SurfaceDensityFromColumn(float WorldX, float WorldY, float WorldZ, + float TerrainZ, float CeilSurf, + const FSurfaceGenerationParams& Structural) const; + + /** (Re)build the per-chunk biome cell grid covering chunk (X,Y) footprint + margin. */ + void RebuildBiomeGrid(int32 ChunkX, int32 ChunkY, int32 ChunkZ, + const FBiomeContext& Ctx, FChunkBiomeCache& Cache) const; }; diff --git a/Source/VoxelForge/Public/VoxelSettings.h b/Source/VoxelForge/Public/VoxelSettings.h index 65ad4ff..cd72af2 100644 --- a/Source/VoxelForge/Public/VoxelSettings.h +++ b/Source/VoxelForge/Public/VoxelSettings.h @@ -22,6 +22,7 @@ public: // STREAMING (distance de vue) //========================================================================= + // En CHUNKS (CHUNK_SIZE=32). Couverture linéaire = ViewDistanceXY × 32 × 25 cm. UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming") int32 ViewDistanceXY = 16; @@ -35,7 +36,10 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming") int32 MaxConcurrentTasks = 16; - // Nombre max de meshes appliqués par frame (évite les stutters d'upload GPU). + // Nombre max de meshes appliqués (upload GPU) par frame. Seuls les vrais applies + // comptent (chunks vides/périmés se vident gratuitement). À 32³ chaque apply est léger + // (~0.1 ms) — tunable en live sur l'asset : montez (8-16) si le remplissage traîne, + // baissez si l'apparition des chunks fait des à-coups. UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming") int32 MaxMeshAppliesPerFrame = 4; @@ -47,8 +51,10 @@ public: 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). + // Distance en chunks pour LOD1 (demi-résolution, step=2). Au-delà → LOD2 (quart-rés, + // step=4). LOD2 = le plus lointain ; ces chunks ne projettent PLUS d'ombre (cf. + // ApplyMeshToChunk) → rapprocher LOD0/LOD1 pousse plus de chunks dans la bande + // LOD2 sans-ombre = moins de draws (levier fps gratuit, à doser visuellement). UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|LOD") int32 LOD1Distance = 8; diff --git a/Source/VoxelForge/Public/VoxelStrateDefinition.h b/Source/VoxelForge/Public/VoxelStrateDefinition.h index 4b97f0e..dff46f8 100644 --- a/Source/VoxelForge/Public/VoxelStrateDefinition.h +++ b/Source/VoxelForge/Public/VoxelStrateDefinition.h @@ -17,8 +17,11 @@ #include "Engine/DataAsset.h" #include "GameplayTagContainer.h" #include "VoxelStrateTypes.h" +#include "VoxelBiomeTypes.h" #include "VoxelStrateDefinition.generated.h" +class UVoxelBiomeDefinition; + /** * UVoxelStrateDefinition — The content bag for a strate type. * @@ -147,6 +150,24 @@ public: meta = (EditCondition = "GeneratorType == ECaveGeneratorType::FloatingIslands")) FFloatingIslandParams FloatingIslandParams; + //========================================================================= + // BIOMES (vary terrain & content WITHIN this strate — any archetype) + //========================================================================= + // Optional. When empty, this strate generates exactly as before (no biome field, + // bit-identical output). When populated, a deterministic world-XY biome field + // (warped Voronoi + climate, see FBiomeMapParams) assigns regions; each biome can + // supply its OWN archetype params (a mini-strate-variant, output-blended for surface) + // plus its own decorations / atmosphere / water. Surface terrain wired today; + // content/atmosphere work for any archetype. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Biomes") + TArray Biomes; + + // World-XY biome field tuning (cell size, border warp/blend, climate fields). + // Only relevant when Biomes is non-empty. Bake AVoxelWorld::BakeBiomePreview to + // see the resulting map before regenerating the world. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Biomes") + FBiomeMapParams BiomeMapParams; + //========================================================================= // DISTURBANCES (the "wow" layer — applies on top of ANY archetype) //========================================================================= diff --git a/Source/VoxelForge/Public/VoxelStrateManager.h b/Source/VoxelForge/Public/VoxelStrateManager.h index 80c4ae0..0428a47 100644 --- a/Source/VoxelForge/Public/VoxelStrateManager.h +++ b/Source/VoxelForge/Public/VoxelStrateManager.h @@ -227,6 +227,14 @@ public: FVerticalShaftParams GetVerticalShaftParamsForChunk(const FIntVector& ChunkCoord) const; FFloatingIslandParams GetFloatingIslandParamsForChunk(const FIntVector& ChunkCoord) const; + /** + * Flatten the strate's Biomes[] + BiomeMapParams into a POD FBiomeContext for the + * biome field. Returns an empty (invalid) context when the strate has no biomes — + * callers treat that as "biomes disabled" and fall back to base params. The result + * is window-invariant (depends only on the strate at this Z, not the chunk window). + */ + FBiomeContext GetBiomeContextForChunk(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 diff --git a/Source/VoxelForge/Public/VoxelStrateTypes.h b/Source/VoxelForge/Public/VoxelStrateTypes.h index d493af0..dcc6e9b 100644 --- a/Source/VoxelForge/Public/VoxelStrateTypes.h +++ b/Source/VoxelForge/Public/VoxelStrateTypes.h @@ -304,6 +304,16 @@ struct VOXELFORGE_API FStrateGenerationParams UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Worm Tunnels") float WormStrength = 10.0f; + // Worms only carve within this distance (voxels) of the room/tunnel network, fading + // smoothly to zero at the edge. Keeps worms as organic braids and shortcuts that HUG + // the cave system instead of spraying disconnected noise pockets through the whole + // strate (the far-field "confetti"). 0 = unlimited (legacy unmasked behaviour). + // 16 → tight braiding right along rooms/tunnels + // 24 → braids + short noodle shortcuts (good default) + // 48+ → loose, wandering side-passages + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Worm Tunnels", meta = (ClampMin = "0.0")) + float WormNetworkRange = 24.0f; + // ===== CAVE MORPHOLOGY (room-and-corridor) ===== // // Cave shape is defined by SDF (Signed Distance Field) primitives: @@ -489,6 +499,16 @@ struct VOXELFORGE_API FStrateGenerationParams UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels", meta = (ClampMin = "0.0", ClampMax = "1.0")) float TunnelHorizontalBias = 0.5f; + // Topology of the guaranteed tunnel network. + // true → each room links to its best candidate among rooms CLOSER to (0,0): the whole + // network becomes a tree rooted at the origin room — every cave is reachable + // from the spine hub, tunnels flow inward like tributaries (intentional descent + // structure). TunnelDensity still adds loops on top. + // false → legacy nearest-neighbor pairing: organic scattered clusters, but connectivity + // between clusters is NOT guaranteed (isolated pockets are common). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cave Morphology|Tunnels") + bool bTunnelsFlowTowardOrigin = true; + // 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. @@ -884,6 +904,7 @@ struct VOXELFORGE_API FStrateGenerationParams Result.WormHorizontalBias = FMath::Lerp(A.WormHorizontalBias, B.WormHorizontalBias, Alpha); Result.WormThreshold = FMath::Lerp(A.WormThreshold, B.WormThreshold, Alpha); Result.WormStrength = FMath::Lerp(A.WormStrength, B.WormStrength, Alpha); + Result.WormNetworkRange = FMath::Lerp(A.WormNetworkRange, B.WormNetworkRange, Alpha); // Cave morphology Result.RoomSpacing = FMath::Lerp(A.RoomSpacing, B.RoomSpacing, Alpha); Result.RoomDensity = FMath::Lerp(A.RoomDensity, B.RoomDensity, Alpha); @@ -903,6 +924,7 @@ struct VOXELFORGE_API FStrateGenerationParams Result.MaxTunnelLength = FMath::Lerp(A.MaxTunnelLength, B.MaxTunnelLength, Alpha); Result.TunnelWarpStrength = FMath::Lerp(A.TunnelWarpStrength, B.TunnelWarpStrength, Alpha); Result.TunnelHorizontalBias = FMath::Lerp(A.TunnelHorizontalBias, B.TunnelHorizontalBias, Alpha); + Result.bTunnelsFlowTowardOrigin = (Alpha < 0.5f) ? A.bTunnelsFlowTowardOrigin : B.bTunnelsFlowTowardOrigin; Result.TunnelEndpointZOffset = FMath::Lerp(A.TunnelEndpointZOffset, B.TunnelEndpointZOffset, Alpha); Result.SDFBlendRadius = FMath::Lerp(A.SDFBlendRadius, B.SDFBlendRadius, Alpha); Result.WaterLevelRelative = FMath::Lerp(A.WaterLevelRelative, B.WaterLevelRelative, Alpha); @@ -976,6 +998,7 @@ struct VOXELFORGE_API FStrateGenerationParams // Forward declaration — the actual data asset lives in VoxelTerrainOpDefinition.h. // We only need a soft pointer here, so no #include needed. class UVoxelTerrainOpDefinition; +class UStaticMesh; /** * FStrateTerrainOpEntry — A reference to a terrain operation with a weight. @@ -1276,6 +1299,43 @@ struct VOXELFORGE_API FSurfaceGenerationParams UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Shape", meta = (ClampMin = "0.0")) float SurfaceRoughness = 3.0f; + // ----- Macro relief & landforms ----- + + // Domain-warp the heightfield query by this many voxels of XY displacement before + // sampling continents/mountains. Bends straight coastlines and ridgelines into winding, + // organic landforms. 0 = no warp (axis-aligned blobby noise, the old look). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Macro", meta = (ClampMin = "0.0")) + float HeightWarpStrength = 35.0f; + + // Frequency of the domain-warp noise. Lower = broader, sweeping bends. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Macro") + float HeightWarpFrequency = 0.008f; + + // Macro "relief" map frequency: a very-low-frequency field that makes some regions flat + // plains and others mountainous highlands — distinct terrain depending on where you + // stand. (The cheap, continuous precursor to a full biome system.) Lower = larger regions. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Macro") + float ReliefFrequency = 0.0015f; + + // How strongly the relief map modulates terrain (0-1). 0 = uniform everywhere (old + // behaviour); 1 = full plains <-> mountains variation across the world. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Macro", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float ReliefStrength = 0.7f; + + // Contrast of the relief map. >1 sharpens the plains/highland boundary (more distinct + // regions); ~1 keeps it a gradual blend. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Macro", meta = (ClampMin = "0.25", ClampMax = "4.0")) + float ReliefContrast = 1.6f; + + // Plateau/mesa terracing strength (0-1), applied in high-relief regions only. 0 = off + // (smooth slopes). Crank up for stepped mesas and layered cliffs in the mountainous areas. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Macro", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float TerraceStrength = 0.0f; + + // Height of each terrace step in voxels (when TerraceStrength > 0). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Macro", meta = (ClampMin = "1.0")) + float TerraceHeight = 12.0f; + // ----- Water ----- // Water table height as a fraction of strate height (0 = no water). Valleys below @@ -1600,10 +1660,26 @@ struct VOXELFORGE_API FStrateDecoration { GENERATED_BODY() - // The actor class to spawn (e.g., BP_Stalactite, BP_CrystalCluster) + // The actor class to spawn (e.g., BP_Stalactite, BP_CrystalCluster). + // Real actors: lights, logic, interaction. They cost game-thread time per instance — + // keep MaxLODLevel at 0 for these, and prefer InstancedMesh for pure visual props. UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration") TSubclassOf ActorClass; + // INSTANCED path: if set, this entry renders as batched HISM instances instead of + // spawning ActorClass (which is then ignored). No tick, no per-actor overhead, + // engine-culled — orders of magnitude cheaper. Use for everything that doesn't need + // logic/lights/interaction; an emissive material still glows at distance without a light. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration") + UStaticMesh* InstancedMesh = nullptr; + + // Spawn while the chunk's LOD <= this (0 = LOD0 only, the old behaviour). + // Lets instanced visual props persist on LOD1-2 chunks instead of popping out with + // LOD0. NOTE: placement samples the LOD's mesh vertices, so instances re-scatter + // slightly on LOD transitions (masked by the terrain's own LOD pop). + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0", ClampMax = "2")) + int32 MaxLODLevel = 0; + // Which surface type this decoration can be placed on UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration") ESurfaceType SurfacePlacement = ESurfaceType::Any; diff --git a/Source/VoxelForge/Public/VoxelTypes.h b/Source/VoxelForge/Public/VoxelTypes.h index ecca60e..cd60b94 100644 --- a/Source/VoxelForge/Public/VoxelTypes.h +++ b/Source/VoxelForge/Public/VoxelTypes.h @@ -14,7 +14,13 @@ // // 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). +// VOXEL_SIZE = 25 cm/voxel (Unreal travaille en centimètres). 1 chunk = 8 m. +// +// NOTE: 64³ a été essayé ("B", 2026-06-16) pour couper les draw calls (8× moins de +// chunks) mais le streaming devenait trop saccadé (briques 8× plus lourdes → applies +// + spawns de contenu en gros à-coups sur le game thread). Reverté à 32³ : le fps se +// règle côté RENDU (ombres off sur LOD lointain, voir ApplyMeshToChunk), pas via la +// taille de chunk. La taille de chunk reste le levier streaming-vs-draws si besoin. constexpr int32 CHUNK_SIZE = 32; constexpr int32 CHUNK_SIZE_SQUARED = CHUNK_SIZE * CHUNK_SIZE; // 1024 @@ -142,8 +148,8 @@ 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é. +// Le coeur de bruit (VoxelNoise::Perlin3D, T2.a) renvoie ~[-0.8, 0.8] comme l'ancien +// FMath::PerlinNoise3D — ce facteur remet à ~[-1, 1] pour les formules de densité. constexpr float VOXEL_NOISE_SCALE = 1.25f; //============================================================================= diff --git a/Source/VoxelForge/Public/VoxelWorld.h b/Source/VoxelForge/Public/VoxelWorld.h index 09fab3a..ab12da1 100644 --- a/Source/VoxelForge/Public/VoxelWorld.h +++ b/Source/VoxelForge/Public/VoxelWorld.h @@ -273,6 +273,37 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel World|Debug") bool bDebugDrawPassages = false; + //========================================================================= + // BIOME MAP PREVIEW (bake the XY biome field to a PNG — works without PIE) + //========================================================================= + // Tune biome layout (cell size, warp, climate boxes) without flying around: set + // the strate + window, click Bake, open Saved/BiomePreview.png. Uses a transient + // generator seeded from VoxelSettings, so it works in the editor with no PIE. + + /** The strate whose Biomes[] + BiomeMapParams to preview. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome Preview") + UVoxelStrateDefinition* BiomePreviewStrate = nullptr; + + /** Width/height of the sampled window in VOXELS (centred on BiomePreviewCenter). */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome Preview", meta = (ClampMin = "1.0")) + float BiomePreviewWorldSize = 16000.0f; + + /** Centre of the preview window in voxel coords (X,Y). */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome Preview") + FVector2D BiomePreviewCenter = FVector2D::ZeroVector; + + /** Output image resolution (pixels per side). */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome Preview", meta = (ClampMin = "64", ClampMax = "2048")) + int32 BiomePreviewResolution = 512; + + /** Which field to visualise: biome debug colours, relief, or moisture. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome Preview") + EBiomePreviewChannel BiomePreviewChannel = EBiomePreviewChannel::Biome; + + /** Bake the selected channel to Saved/BiomePreview.png. */ + UFUNCTION(CallInEditor, BlueprintCallable, Category = "Biome Preview") + void BakeBiomePreview(); + #if WITH_EDITOR virtual void PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) override; diff --git a/Source/VoxelForge/VoxelForge.Build.cs b/Source/VoxelForge/VoxelForge.Build.cs index 2b18007..e989300 100644 --- a/Source/VoxelForge/VoxelForge.Build.cs +++ b/Source/VoxelForge/VoxelForge.Build.cs @@ -30,7 +30,7 @@ public class VoxelForge : ModuleRules // 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 + "ImageWrapper", // PNG encode for the biome-map preview bake (BakeBiomePreview) }); } }