This commit is contained in:
2026-06-16 03:39:22 +02:00
parent f030eec08a
commit db558d9e14
18 changed files with 1570 additions and 264 deletions
+150 -21
View File
@@ -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.
@@ -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<AActor>(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);
}
+113 -39
View File
@@ -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<int32, TInlineAllocator<64>> 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<bool, TInlineAllocator<64>> 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;
+151 -21
View File
@@ -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<FStrateDecoration>& 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<TWeakObjectPtr<AActor>> 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<TWeakObjectPtr<AActor>>& Out)
const TArray<FStrateDecoration>& Decorations, int32 LODLevel,
TArray<TWeakObjectPtr<AActor>>& 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);
const FTransform SpawnXf(BaseQ, SpawnPos, FVector(Scale));
if (bInstanced)
{
// HISM path — batched instances, no actor. Pure visual: no collision,
// engine handles frustum/occlusion culling per cluster.
if (!HISM)
{
HISM = NewObject<UHierarchicalInstancedStaticMeshComponent>(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;
}
else
{
FActorSpawnParameters SpawnParams;
SpawnParams.Owner = OwnerActor;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
AActor* NewActor = World->SpawnActor<AActor>(
Deco.ActorClass,
FTransform(BaseQ, SpawnPos, FVector(Scale)),
SpawnParams);
AActor* NewActor = World->SpawnActor<AActor>(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<ULightComponent*> 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<AActor>& 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<TWeakObjectPtr<UHierarchicalInstancedStaticMeshComponent>>* Comps = ChunkInstances.Find(ChunkCoord))
{
for (const TWeakObjectPtr<UHierarchicalInstancedStaticMeshComponent>& 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<FIntVector> InstanceCoords;
ChunkInstances.GetKeys(InstanceCoords);
for (const FIntVector& C : InstanceCoords)
{
ClearChunk(C);
}
TArray<FIntVector> WaterCoords;
WaterPlanes.GetKeys(WaterCoords);
for (const FIntVector& C : WaterCoords)
@@ -244,5 +373,6 @@ void UVoxelContentManager::ClearAll()
ClearChunk(C);
}
SpawnedActors.Empty();
ChunkInstances.Empty();
WaterPlanes.Empty();
}
+521 -73
View File
@@ -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<FSurfaceGenerationParams> 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,17 +1553,40 @@ 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 NetworkMask = 1.0f;
if (Params.WormNetworkRange > 0.0f)
{
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(FMath::PerlinNoise3D(FVector(
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(FMath::PerlinNoise3D(FVector(
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
@@ -1479,7 +1597,8 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo
if (WormValue < Params.WormThreshold)
{
float t = 1.0f - (WormValue / Params.WormThreshold);
Density -= t * Params.WormStrength;
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,
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)
//=============================================================================
@@ -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<FIntVector, int32> 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<float> 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];
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);
@@ -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);
+157 -3
View File
@@ -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<UVoxelContentManager>(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<UVoxelAtmosphereManager>(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;
{
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<URealtimeMeshComponent>(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<UVoxelGenerator>(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<FColor> 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<IImageWrapperModule>(FName("ImageWrapper"));
const TSharedPtr<IImageWrapper> 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<uint8>& 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;
@@ -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<AActor> 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<const UVoxelBiomeDefinition> CurrentBiome;
};
+47 -6
View File
@@ -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<TWeakObjectPtr<AActor>>& Out);
void SpawnWater(const FIntVector& ChunkCoord, const UVoxelStrateDefinition* Def);
const TArray<FStrateDecoration>& Decorations, int32 LODLevel,
TArray<TWeakObjectPtr<AActor>>& 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<AActor> 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<FIntVector, TArray<TWeakObjectPtr<AActor>>> SpawnedActors;
// HISM components per chunk (weak — registered components are owned by the actor).
TMap<FIntVector, TArray<TWeakObjectPtr<UHierarchicalInstancedStaticMeshComponent>>> ChunkInstances;
// Water surface component per chunk.
UPROPERTY()
TMap<FIntVector, UStaticMeshComponent*> WaterPlanes;
+75 -1
View File
@@ -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;
};
+9 -3
View File
@@ -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;
@@ -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<UVoxelBiomeDefinition*> 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)
//=========================================================================
@@ -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
+77 -1
View File
@@ -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<AActor> 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;
+9 -3
View File
@@ -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;
//=============================================================================
+31
View File
@@ -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;
+1 -1
View File
@@ -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)
});
}
}