Fix Decoration Placement
This commit is contained in:
+13
-421
@@ -134,14 +134,15 @@ per-chunk info later.
|
|||||||
| `UnloadChunk` | 493 | Destroys mesh component + map entries. |
|
| `UnloadChunk` | 493 | Destroys mesh component + map entries. |
|
||||||
| `ApplyMeshToChunk` | — | Upload geometry. **LOD0 → own component (`ChunkMeshes`, collision); LOD1/2 → batched into one component per region (`ChunkRegions`), each chunk a SectionGroup, no collision.** Handles LOD promote/demote between the two. §8.10. |
|
| `ApplyMeshToChunk` | — | Upload geometry. **LOD0 → own component (`ChunkMeshes`, collision); LOD1/2 → batched into one component per region (`ChunkRegions`), each chunk a SectionGroup, no collision.** Handles LOD promote/demote between the two. §8.10. |
|
||||||
| `ChunkToRegion` / `ChunkSectionGroupName` / `RemoveChunkFromRegion` / `DestroyIndividualChunkComponent` | — | Plumbing for the batched far-chunk scheme. |
|
| `ChunkToRegion` / `ChunkSectionGroupName` / `RemoveChunkFromRegion` / `DestroyIndividualChunkComponent` | — | Plumbing for the batched far-chunk scheme. |
|
||||||
| `GetStrateAtPosition` | 679 | Gameplay query → strate index. |
|
| `GetStrateAtPosition` | 965 | Gameplay query → strate index. |
|
||||||
|
| `GetBiomeAtWorldLocation` | — | **BlueprintCallable** biome probe at a world point (undoes actor xf → voxel → `Generator::QueryBiomeAt`). Returns `FVoxelBiomeQuery` for BP debug ("what biome / how many decos under the cursor?"). |
|
||||||
| `CarveAtPosition` / `FillAtPosition` | 691 / 709 | Build `FVoxelModification` → DiffLayer → RemeshDirtyChunks. |
|
| `CarveAtPosition` / `FillAtPosition` | 691 / 709 | Build `FVoxelModification` → DiffLayer → RemeshDirtyChunks. |
|
||||||
| `ClearAllModifications` | 726 | Clears diff layer, regenerates. |
|
| `ClearAllModifications` | 726 | Clears diff layer, regenerates. |
|
||||||
| `ChangeSeed` | 740 | **Season reset**: new seed everywhere, clear diffs, bump season, reload. |
|
| `ChangeSeed` | 740 | **Season reset**: new seed everywhere, clear diffs, bump season, reload. |
|
||||||
| `GetCurrentSeed` / `GetCurrentSeason` | 784 / 789 | Accessors. |
|
| `GetCurrentSeed` / `GetCurrentSeason` | 784 / 789 | Accessors. |
|
||||||
| `RemeshDirtyChunks` | 798 | Re-queue loaded chunks for async re-mesh (no visual pop). |
|
| `RemeshDirtyChunks` | 798 | Re-queue loaded chunks for async re-mesh (no visual pop). |
|
||||||
|
|
||||||
> **Game-thread profiling (Perf):** `AVoxelWorld::Tick` and its sub-steps are wrapped in `TRACE_CPUPROFILER_EVENT_SCOPE` — `VoxelForge_Tick / UpdateChunks / BuildDesiredTiles / CullTiles / SubmitTiles / ProcessPending / ProcessUnload / UpdateDecorations / UpdateWater`. Capture a `Count/Incl/Excl` Insights timer export and read the `Excl` column to see which step owns the per-frame cost (the actor tick shows as `BP_VoxelWorld_C` if subclassed in BP). `VoxelForge_GenerateMesh` is worker-side (off the frame).
|
> **Game-thread profiling (Perf):** `AVoxelWorld::Tick` and its sub-steps are wrapped in `TRACE_CPUPROFILER_EVENT_SCOPE` — `VoxelForge_Tick / UpdateChunks / BuildDesiredTiles / CullTiles / SubmitTiles / ProcessPending / ProcessUnload / UpdateDecorations / UpdateWater`. Capture a `Count/Incl/Excl` Insights timer export and read the `Excl` column to see which step owns the per-frame cost (the actor tick shows as `BP_VoxelWorld_C` if subclassed in BP). `VoxelForge_GenerateMesh` + `VoxelForge_BuildStreams` are worker-side (off the frame): the RMC `FRealtimeMeshStreamSet` is now built on the gen worker (`BuildTileStreamSet`) and carried on `FChunkResult::Streams` (TSharedPtr), so `ApplyMeshToTile` is game-thread-cheap — just material/ceiling resolve + `CreateSectionGroup(MoveTemp)`. See ARCHITECTURE §8.10 "Worker-built StreamSet (T1.f)".
|
||||||
|
|
||||||
### 3.6 Density generator — `Public/VoxelGenerator.h` + `Private/VoxelGenerator.cpp`
|
### 3.6 Density generator — `Public/VoxelGenerator.h` + `Private/VoxelGenerator.cpp`
|
||||||
`UVoxelGenerator : UObject` — lightweight; holds `Seed`, and injected services
|
`UVoxelGenerator : UObject` — lightweight; holds `Seed`, and injected services
|
||||||
@@ -163,6 +164,7 @@ per-chunk info later.
|
|||||||
| `SampleBiomeAt` | — | Warped-Voronoi + climate biome query (dominant + neighbour + weight). Reference used by the preview bake + `GetDominantBiomeAt`. §8.14. |
|
| `SampleBiomeAt` | — | Warped-Voronoi + climate biome query (dominant + neighbour + weight). Reference used by the preview bake + `GetDominantBiomeAt`. §8.14. |
|
||||||
| `ResolveBiomeSampleAt` / `RebuildBiomeGrid` | — | Hot-path biome resolve (FBiomeSample) via a box-validated per-chunk cell-grid cache. Bit-identical to `SampleBiomeAt`. §8.14, §8.10. |
|
| `ResolveBiomeSampleAt` / `RebuildBiomeGrid` | — | Hot-path biome resolve (FBiomeSample) via a box-validated per-chunk cell-grid cache. Bit-identical to `SampleBiomeAt`. §8.14, §8.10. |
|
||||||
| `GetDominantBiomeAt` | — | Game-thread query → dominant biome ASSET (content/atmosphere). §8.14. |
|
| `GetDominantBiomeAt` | — | Game-thread query → dominant biome ASSET (content/atmosphere). §8.14. |
|
||||||
|
| `QueryBiomeAt` | — | Rich game-thread biome probe → `FVoxelBiomeQuery` (dominant/neighbour asset, relief/moisture, blend weight, dominant deco count). Diagnostic behind `AVoxelWorld::GetBiomeAtWorldLocation`. §8.14. |
|
||||||
|
|
||||||
### 3.7 Cave morphology (SDF rooms/tunnels) — `Public/VoxelCaveMorphology.h` + `.cpp`
|
### 3.7 Cave morphology (SDF rooms/tunnels) — `Public/VoxelCaveMorphology.h` + `.cpp`
|
||||||
Header is rich with inline docs. Two namespaces + a per-chunk cache system.
|
Header is rich with inline docs. Two namespaces + a per-chunk cache system.
|
||||||
@@ -229,9 +231,10 @@ param groups gated by EditCondition. `ApplyTo(OutParams, Weight)` (.cpp:6) copie
|
|||||||
the active type's fields into `FStrateGenerationParams`, scaled by Weight.
|
the active type's fields into `FStrateGenerationParams`, scaled by Weight.
|
||||||
|
|
||||||
**`Public/VoxelBiomeTypes.h`** (NEW) — biome vocabulary. `FBiomeMapParams` (Voronoi cell size /
|
**`Public/VoxelBiomeTypes.h`** (NEW) — biome vocabulary. `FBiomeMapParams` (Voronoi cell size /
|
||||||
border warp+blend / climate field freqs), `EBiomePreviewChannel` (preview-bake selector), and plain
|
border warp+blend / climate field freqs), `EBiomePreviewChannel` (preview-bake selector),
|
||||||
runtime PODs `FBiomeResolved` / `FBiomeContext` / `FBiomeSample` / `FChunkBiomeCache` (the
|
`FVoxelBiomeQuery` (BlueprintType result of `GetBiomeAtWorldLocation` — dominant/neighbour asset,
|
||||||
box-validated per-chunk grid cache). See §8.14.
|
climate, blend weight, deco count), 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`.
|
**`Public/VoxelBiomeDefinition.h` + `.cpp`** (NEW) — `UVoxelBiomeDefinition : UPrimaryDataAsset`.
|
||||||
One asset = one biome: identity + `DebugColor`, climate placement box (`ReliefMin/Max`,
|
One asset = one biome: identity + `DebugColor`, climate placement box (`ReliefMin/Max`,
|
||||||
@@ -366,420 +369,9 @@ Stage order (negative=solid throughout). Each stage's anchor:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Archetypes, spine, disturbances, content & carving (2026 redesign)
|
## 8. Architecture & design deep-dive → [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||||
|
|
||||||
A large A-to-Z expansion. The world is a stack of strates the player descends through;
|
Moved out of the codemap to keep this file a fast navigation index. The archetypes, (0,0)
|
||||||
each strate can be a fundamentally different *archetype*, connected at (0,0).
|
spine, disturbances, content/atmosphere, biomes, and the **performance invariants**
|
||||||
|
(`§8.10` — read before optimizing the hot path) now live in **[ARCHITECTURE.md](ARCHITECTURE.md)**.
|
||||||
### 8.1 Archetypes (`ECaveGeneratorType`, VoxelStrateTypes.h)
|
All `§8.x` cross-references throughout this file point there.
|
||||||
Each archetype has its own param `USTRUCT` (on `UVoxelStrateDefinition`, EditCondition-gated
|
|
||||||
by `GeneratorType`) and its own density function in `VoxelGenerator.cpp`, dispatched by the
|
|
||||||
`switch` in `GetDensityAt`.
|
|
||||||
|
|
||||||
| Archetype | Params struct | Density fn | Idea |
|
|
||||||
|-----------|---------------|------------|------|
|
|
||||||
| TunnelNetwork | `FStrateGenerationParams` | `GetDensityWithParams` | rooms+tunnels (original) |
|
|
||||||
| FlatPlain / CrystalChamber | `FSlabGenerationParams` | `GetSlabDensity` | floor/ceiling void (original) |
|
|
||||||
| Maze | `FMazeGenerationParams` | `GetMazeDensity` | tight corridors on a 3D lattice (per-voxel, no cache; edge = lower node + axis hash) |
|
|
||||||
| SurfaceWorld | `FSurfaceGenerationParams` | `GetSurfaceDensity` | heightfield terrain: 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. The cap is shapeable terrain in its own right (`ComputeSurfaceCeiling`, `Surface|Sky` params: `CeilingUndulation` broad inverted hills/valleys, `CeilingRidgeStrength` hanging ridgelines, `CeilingRoughness`+freq fine bumps, `CeilingWarp*`) — defaults (strengths 0, freq 0.04) = old flat-ish cap. (`Surface|Macro` params = the cheap precursor to biomes; `ReliefStrength=0` ⇒ old uniform terrain.) **Sky-cap tiles don't cast shadows**: `ApplyMeshToTile` classifies a near tile as ceiling (centre above the terrain↔cap midpoint via the `GetSurfaceHeightAt` oracle) and drives the **RMC per-section** `FRealtimeMeshSectionConfig::bCastsShadow` (NOT the component `SetCastShadow` — RMC's proxy ignores the component flag; this is also why level≥2 far tiles only stopped casting once the section flag was wired). So the rock ceiling never shadows the terrain below it (cap and ground are one mesh but live in different clipmap tiles). The same `bIsCeiling` classification (now computed at every LOD, not just near tiles) also selects the strate's `CeilingMaterial` when set, so the shadowless overhead rock can be tinted/darkened separately from the ground instead of reading flat/bright. |
|
|
||||||
| VerticalShafts | `FVerticalShaftParams` | `GetVerticalShaftDensity` | full-height shafts + horizontal connectors + partial ledges |
|
|
||||||
| FloatingIslands | `FFloatingIslandParams` | `GetFloatingIslandDensity` | asymmetric islands: flat land top + underside tapering to a point, lobed (domain-warped) outline, in an open void |
|
|
||||||
| Underwater | `FStrateGenerationParams` + water | (reuses `GetDensityWithParams`) | tunnel rock + high water table |
|
|
||||||
|
|
||||||
All density fns share the convention: internal **positive=solid**, apply origin spine →
|
|
||||||
boundary seal → inter-strate passages, then `return -Density` (MC: negative=solid).
|
|
||||||
StrateManager provides params per chunk via `GetMaze/Surface/VerticalShaft/FloatingIslandParamsForChunk`
|
|
||||||
(macro `VF_ARCHETYPE_PARAMS_GETTER`) — no cross-boundary blend (Hard transitions between archetypes).
|
|
||||||
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
|
|
||||||
column at XY (0,0) in every strate's **interior** (seals untouched). Radius =
|
|
||||||
`UVoxelGenerator::OriginSpineRadius` ← `VoxelSettings::OriginSpineRadius`. Called before
|
|
||||||
every `ApplyBoundarySeal`.
|
|
||||||
- Descent is **player-dug** through the thin seals at (0,0). The single auto-opened
|
|
||||||
connection is the **surface entry shaft** at (0,0) through the top of strate 0
|
|
||||||
(`GeneratePassages`, `bOpenSurfaceEntry`).
|
|
||||||
- **Hybrid extras:** auto-carved *shortcut* passages per boundary, placed away from (0,0).
|
|
||||||
Now fully **per-strate** — see §8.8 (the upper strate's `PassageConfig` drives count/style/shape).
|
|
||||||
|
|
||||||
### 8.3 Disturbance layer (the "wow" post-process)
|
|
||||||
`FStrateDisturbanceParams` (on the definition, all archetypes). `ApplyDisturbances`
|
|
||||||
(VoxelGenerator.cpp static, **MC convention**) runs in `GetDensityAt` after dispatch:
|
|
||||||
chasms (carve air), bridges (solid spans), ridges (solid blades). Stays inside seal bands.
|
|
||||||
Provided per chunk by `StrateManager::GetDisturbanceParamsForChunk`.
|
|
||||||
|
|
||||||
### 8.4 Cross-chunk determinism (the seam-prevention invariant)
|
|
||||||
`BuildChunkCache` (VoxelCaveMorphology.cpp) uses **two regions**: a wide *COLLECT* region
|
|
||||||
(`2*MaxTunnelLength + MaxInfluence`) over which connectivity is decided (NN filtered to
|
|
||||||
`<= MaxTunnelLength`, origin cap = deterministic top-N by hash), and a tight *STORE* region
|
|
||||||
(`+MaxInfluence`) kept for per-voxel eval. This makes the room/tunnel graph window-invariant.
|
|
||||||
**If you add a connectivity rule with longer edges, the COLLECT region must still cover the
|
|
||||||
max edge reach, and decisions must not depend on the stored window.**
|
|
||||||
|
|
||||||
### 8.5 Content scatter & water — `VoxelContentManager.h/.cpp` (NEW)
|
|
||||||
`UVoxelContentManager` (owned by `AVoxelWorld`, game-thread). TWO INDEPENDENT subsystems:
|
|
||||||
|
|
||||||
**(A) DECORATIONS — distance-based WORLD GRID (the no-pop system, 2026-06-17).** Decorations are placed
|
|
||||||
on a fixed world XY cell grid (**1 cell = 1 chunk footprint**, `DECO_CELL_VOXELS = CHUNK_SIZE`) and streamed
|
|
||||||
by DISTANCE from the player, **fully decoupled from clipmap tiles / LOD**. **THE MARCH RUNS ASYNC ON WORKER
|
|
||||||
THREADS** (mirrors mesh gen — `GetDensityAt` is thread-safe; the synchronous-on-game-thread first cut was a
|
|
||||||
perf disaster + starved streaming → seams, so it was moved off-thread). Driven by `AVoxelWorld::Tick →
|
|
||||||
UpdateDecorations(playerWorldPos)`, three phases: **(1)** recompute the desired cell set (`RebuildDesiredCells`)
|
|
||||||
only when the player crosses a cell boundary OR changes strate — clears out-of-range loaded cells, queues
|
|
||||||
cells that are NOT loaded and NOT in flight (`PendingLaunch`, nearest-first). **SINGLE radius, NO near/far tiers:**
|
|
||||||
a loaded cell is NEVER re-streamed in place while it stays in range (only cleared when it leaves), so
|
|
||||||
decorations don't FLICKER as the player moves / as terrain LOD shells shift (re-streaming on tier crossings was
|
|
||||||
the flicker cause — tiers removed). **(2)** `LaunchDecoTasks`:
|
|
||||||
resolve the cell's decoration list on the GAME thread (biome — see below) then fire an async `UE::Tasks` march
|
|
||||||
(`BuildCellSpawns`, `BackgroundNormal`, capped at `MaxConcurrentDecorationTasks` in flight via `InFlightCells`).
|
|
||||||
**(3)** `ProcessDecoResults`: drain finished tasks' results (`Mpsc` queue → `ReadyResults`), epoch-guarded
|
|
||||||
(`DecoEpoch`, bumped on clear/strate-change so stale in-flight results are discarded) + range-checked, and
|
|
||||||
**apply (spawn) budgeted** (`MaxDecorationCellsPerFrame` — the only game-thread cost, SpawnActor/AddInstance).
|
|
||||||
`BuildCellSpawns` (worker) finds each column's surface point(s) and rolls the entries there (shared
|
|
||||||
`PlaceAtCrossing`). Candidate columns are **snapped to INTEGER voxel XY** (integer jitter) so the generator's
|
|
||||||
surface-column cache (T1.a, §8.10) applies — FRACTIONAL XY bypasses it and recomputes the noise-heavy
|
|
||||||
heightfield+biome on every sample. **TWO column strategies by archetype:**
|
|
||||||
**(a) SurfaceWorld → HEIGHT ORACLE (`Ctx.bSurfaceWorld`), NO marching.** `Generator::GetSurfaceHeightAt(x,y,
|
|
||||||
chunkZ → TerrainZ, CeilSurf)` returns the heightfield surface + sky-cap ceiling in O(1) (it shares the density
|
|
||||||
path's `ResolveSurfaceChunkParams`/`ComputeSurfaceColumn` via its own thread_local per-chunk cache, so it's
|
|
||||||
bit-identical to the rendered ground). Per column: query centre + 4 neighbours (gradient → floor/ceiling
|
|
||||||
normals), place a Floor crossing at `TerrainZ` and a Ceiling crossing at `CeilSurf` (if open space below). A
|
|
||||||
single `GetDensityAt` at the surface verifies the column isn't CARVED (passage/spine/diff make it air → skip;
|
|
||||||
the oracle is the raw heightfield and doesn't know carving). ~5 height evals + 1-2 density samples/column vs
|
|
||||||
hundreds marched. **(b) other archetypes (caves/shafts/islands) → ray-march** the strate Z-band
|
|
||||||
(`GetStrateUnrealZRange`, voxel coords) via `GetDensityAt` at a COARSE step (`DecorationMarchStepVoxels`), each
|
|
||||||
air↔solid sign change **bisection-refined** (4 iters → accuracy independent of step). Either way **a prop sits at
|
|
||||||
the SAME world position at every LOD → no pop**. (march) The top cap/seal + the open air are always marched first; the scan only stops after
|
|
||||||
`DecorationColumnDepthVoxels` of CONTIGUOUS solid once it has ENTERED the open space (trims dead bedrock below
|
|
||||||
the ground without ever stopping short of it — a "below the first crossing" cap was wrong: on a surface world
|
|
||||||
the first crossing is the high CEILING, so it stopped mid-air before reaching the ground = no floor props).
|
|
||||||
Outward normal = normalized density gradient (solid→air, matches the mesher), classified
|
|
||||||
Floor/Wall/Ceiling by `normal.Z`. Each crossing rolls every `FStrateDecoration` independently: surface-type,
|
|
||||||
density gate (`DecoHash(cell,column,crossing,entry,seed)`), water-relative, align/yaw/scale, per-cell
|
|
||||||
`MaxPerChunk` + global actor cap → a `FDecoSpawn{EntryIdx, bInstanced, Xf}`. The game thread spawns from the
|
|
||||||
result's `Entries` snapshot. `DecorationMaxCrossingsPerColumn` caps cave columns (surface worlds have 1).
|
|
||||||
**Shutdown:** `NotifyShutdown()` (called from `AVoxelWorld::EndPlay`) flags + spin-waits on the in-flight
|
|
||||||
task count before UObject teardown (tasks read the Generator); `BeginDestroy` is the backstop. **Determinism:**
|
|
||||||
pure hash of (cell, column, crossing, entry, seed) + the density surface snap. **Decorations exist ONLY in
|
|
||||||
the player's current strate** (march is strate-bounded) → a strate change wipes + rebuilds them, and there
|
|
||||||
is **no cross-strate light bleed to cull** (the old `SetActiveStrate` light-culling pass is SUBSUMED — gone).
|
|
||||||
**Render paths:** `ActorClass` → real actors (lights/logic, pricey game-thread spawn); `InstancedMesh` → HISM
|
|
||||||
(no tick/actor/collision, emissive glows far), per-cell-per-entry. **Per-entry HISM tuning for dense groundcover**
|
|
||||||
(`FStrateDecoration`, only the InstancedMesh path): `CullDistance` (cm; 0 = no cull — the lever that makes dense
|
|
||||||
grass affordable: placed thickly, drawn only near → GPU cost bounded by area-within-cull, NOT the stream radius),
|
|
||||||
`bCastShadow` (default true; turn OFF for grass — dense instanced shadows are the dominant foliage cost),
|
|
||||||
`MaxSlopeAngle` (deg from flat = acos(|N.Z|); 90 = no filter, ~35 keeps grass off cliffs — applied in the worker's
|
|
||||||
`PlaceAtCrossing`). `ApplyDecoResult` buckets spawns per entry and builds each HISM with ONE batched `AddInstances`
|
|
||||||
(single cluster-tree build, set cull/shadow BEFORE `RegisterComponent`) — the game-thread hitch-killer for dense cells.
|
|
||||||
Note: NO per-entry placement radius (it would fight the no-re-stream cell model — cells stream once at the outer ring
|
|
||||||
and persist, so a smaller radius would never populate already-loaded far cells as the player approaches; CullDistance
|
|
||||||
covers the render cost instead). **All entries stream within ONE radius**
|
|
||||||
(`DecorationRadiusChunks`) — the old near/far tier split was removed (it caused flicker). `MaxLODLevel` and
|
|
||||||
`DecorationActorRadiusChunks` are now LEGACY/unused. **No LOD area-density compensation** (placement is per real
|
|
||||||
surface point, density-stable with distance). **SpawnDensity semantics CHANGED** vs the old vertex scatter: it
|
|
||||||
rolls per column surface-point (not per mesh vertex) → expect a one-time density re-tune. **Settings
|
|
||||||
(`Voxel|Content`):** `DecorationRadiusChunks` (6 — reach in cells), `DecorationSpacingVoxels` (4 → 8×8 cols/cell),
|
|
||||||
`DecorationMarchStepVoxels` (2 — coarse, bisection-refined; cave march only), `DecorationMaxCrossingsPerColumn`
|
|
||||||
(4 — cave march only), `DecorationColumnDepthVoxels` (160 — bedrock march cap; cave march only),
|
|
||||||
`MaxDecorationCellsPerFrame` (2 — apply/spawn budget), `MaxConcurrentDecorationTasks` (4 — in-flight task cap;
|
|
||||||
0 disables decorations). **COST:** surface worlds now use the O(1) oracle (cheap); caves ray-march. The work is
|
|
||||||
OFF the frame (worker threads) — game thread only pays the budgeted spawn. If streaming slows, lower
|
|
||||||
`MaxConcurrentDecorationTasks` / raise `DecorationMarchStepVoxels` / shrink radii/spacing. Default
|
|
||||||
`DecorationRadiusChunks=6` ≈ props ~48 m out — raise for far flora (cost ~r²).
|
|
||||||
|
|
||||||
**(B) WATER — tile-driven, level-0 only (continuous plane, never pops).** `PopulateTileWater(tile)` in
|
|
||||||
`ApplyMeshToTile` (level-0 tiles), `ClearTileWater(tile)` in `UnloadTile`. One scaled engine plane
|
|
||||||
(`/Engine/BasicShapes/Plane`) per water-surface chunk (per-chunk-Z plane logic assumes a single chunk's
|
|
||||||
vertical span — hence level-0 only), keyed `TMap<FIntVector, UStaticMeshComponent*>` (reflected UPROPERTY).
|
|
||||||
Water Z: `bHasWater` + `WaterLevelRelative` → `StrateManager::GetWaterLevelWorldZForChunk`. Biome
|
|
||||||
`WaterMaterial` overrides `UVoxelStrateDefinition::WaterMaterial` (level stays strate-global).
|
|
||||||
|
|
||||||
`ClearAll`/`SetSeed` on `ChangeSeed`/regenerate clears both subsystems (decorations re-stream on the next
|
|
||||||
Tick via the INT_MIN sentinels). **Per-biome content (§8.14):** the cell-centre (decorations) / chunk-centre
|
|
||||||
(water) dominant biome (`Generator::GetDominantBiomeAt`, game-thread, uncached — once per cell, NOT per
|
|
||||||
column) supplies the decoration list (replaces the strate's) + water material. `Initialize` now also takes
|
|
||||||
`UVoxelSettings*` (for the grid tunables). `ContentMaxLevel` is now legacy/dead for decorations.
|
|
||||||
|
|
||||||
### 8.6 Atmosphere — `VoxelAtmosphereManager.h/.cpp` (NEW)
|
|
||||||
`UVoxelAtmosphereManager` (owned by `AVoxelWorld`, gated by `bManageAtmosphere`).
|
|
||||||
`UpdateForPlayer(pos)` each Tick, reacts only on strate change. Drives a managed
|
|
||||||
`UExponentialHeightFogComponent` + movable `USkyLightComponent` from the player's strate
|
|
||||||
(`FogColor/FogDensity/bVolumetricFog/AmbientLightColor/AmbientLightIntensity`), and spawns
|
|
||||||
PERSISTENT ceiling/floor "layer" actors (`Def->CeilingLayerActor`/`FloorLayerActor` + ZOffsets
|
|
||||||
+ rotations) that follow the player in XY — the sky-island sea-of-clouds / two-sided fog.
|
|
||||||
`Def->AtmosphereActor` (a full BP with your own fog/sky/postprocess) OVERRIDES the managed
|
|
||||||
fog+sky for that strate. `Reset()` on ChangeSeed/EndPlay. (Skylight ambient underground is
|
|
||||||
weak — captures a dark scene; fog is the strong visual.)
|
|
||||||
**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
|
|
||||||
strates (`StrateManager::Initialize` leaves the gap in the layout). `IsGapChunk` detects it;
|
|
||||||
`GetDensityAt` renders gap chunks as solid + passages only (no caves/spine/seal) so the player
|
|
||||||
digs (0,0) through the gap to descend. `GetStrateUnrealZRange` gives a strate's cm Z range.
|
|
||||||
|
|
||||||
### 8.8 Inter-strate passages — PER-STRATE (`FStratePassageConfig` on the definition)
|
|
||||||
Each strate's `PassageConfig` (VoxelStrateTypes.h) controls its descent tunnels to the layer
|
|
||||||
below: `Connections`, `Style` (`EVoxelPassageStyle`: Straight/Worm/Spiral/Cascading),
|
|
||||||
`MouthRadius`/`MidRadius` (tapered width → `FVoxelPassage::ControlRadii` + `VoxelSDF::TaperedCapsule`),
|
|
||||||
`ReachMin/Max` (depth into each strate), `DistanceMin/Max` (from the (0,0) spine), `Wander`,
|
|
||||||
`Segments`, `VerticalWobble`, Spiral/Cascade params. Built in `StrateManager::GeneratePassages`
|
|
||||||
as control-point chains. **Worm = independent fBM per horizontal axis** (`PassageFBM` static)
|
|
||||||
with a flat-top envelope → organic squirm (NOT a 1D zigzag, NOT a same-freq 2-channel spiral).
|
|
||||||
`EvaluateModifierSDF` (per voxel) first builds a `thread_local` **per-chunk shortlist** of passages
|
|
||||||
whose bounds reach this chunk (rebuilt on chunk change / `PassagesVersion` bump) — chunks with no
|
|
||||||
passage near return `FLT_MAX` immediately — then **bounding-sphere-culls** each shortlisted passage
|
|
||||||
(`FVoxelPassage::BoundCenter/BoundRadiusSq`). Both are perf-critical (§8.10). The (0,0) surface entry
|
|
||||||
is a simple straight tube. Global passage settings were removed from `VoxelSettings`.
|
|
||||||
|
|
||||||
### 8.9 Carving — brush shapes + editor controls
|
|
||||||
`FVoxelModification` has `EVoxelBrushShape {Sphere,Box,Capsule}` + `BoxExtent`/`CapsuleEnd`/
|
|
||||||
`Falloff` + `GetWorldBounds`. `UVoxelDiffLayer::GetDensityOffset` switches per shape; chunk
|
|
||||||
overlap uses the shape AABB. `AVoxelWorld`: `CarveBox/FillBox/CarveCapsule/FillCapsule/
|
|
||||||
ApplyModification` (BlueprintCallable) + `EditorCarveSphere/EditorFillSphere` (CallInEditor)
|
|
||||||
driven by `EditorBrush*` props.
|
|
||||||
|
|
||||||
### 8.10 Performance invariants (DON'T regress)
|
|
||||||
- **Streaming** (`UpdateChunksAroundPosition`): rebuild/cull the desired set ONLY when the
|
|
||||||
player crosses a chunk boundary (`LastUpdateCenter`); use the `DesiredSet` TSet for the cull;
|
|
||||||
idle via `bAllChunksLoaded`. Stationary player ≈ free. (Old per-frame O(loaded×desired) scan = 22ms.)
|
|
||||||
- **LOD** changes HOT-SWAP (`LoadChunk` only, never unload-first) → no holes. LOD
|
|
||||||
reconciliation lives in the PERSISTENT per-frame submit loop (same loop as new-chunk loads),
|
|
||||||
NOT as a one-shot on the boundary-cross frame — a one-shot drops every chunk past the task
|
|
||||||
budget and strands it at a stale LOD. Idle (`bAllChunksLoaded`) only when a full scan finds
|
|
||||||
no loads AND no LOD mismatches outstanding.
|
|
||||||
- **SDF cache** (`GetDensityWithParams`): search-BOX validity, not chunk-key — gradient ±1
|
|
||||||
sampling must not thrash the (expensive) rebuild.
|
|
||||||
- **Per-chunk param cache** in `GetDensityAt`: GenType + param struct + disturbance cached
|
|
||||||
thread-locally per chunk; don't move the fetch/blend back to per-voxel.
|
|
||||||
- **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.
|
|
||||||
- **Per-chunk passage shortlist** (`EvaluateModifierSDF`): runs per voxel and is called from every
|
|
||||||
archetype's `ApplyPassageCarving`. Keeps a `thread_local` shortlist (passage INDICES) of passages
|
|
||||||
whose bounds reach the current chunk, rebuilt only on chunk change or `PassagesVersion` bump
|
|
||||||
(incremented in `GeneratePassages`). Most chunks have NO passage near → instant `FLT_MAX` return
|
|
||||||
instead of walking the whole `Passages` array per voxel. Conservative superset (chunk bounding
|
|
||||||
sphere vs passage bound) ⇒ bit-identical carve. Store indices + version, never pointers (the array
|
|
||||||
is rebuilt on `RebuildStrates`).
|
|
||||||
- **Gen tasks run at `UE::Tasks::ETaskPriority::BackgroundNormal`** (`LoadTile`): worker gen yields
|
|
||||||
to foreground game/render tasks. Without it, raising `MaxConcurrentTasks` past the spare-core count
|
|
||||||
saturates the scheduler and starves the frame (the "concurrency > ~12 = stutter" symptom). Keep gen
|
|
||||||
at background priority so the frame keeps its cores.
|
|
||||||
- **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).
|
|
||||||
The cell loop is **two-pass**: pass 1 reads the 8 corner densities + builds the MC case index and
|
|
||||||
`continue`s on no-surface cells (≈70% of cells); pass 2 computes the 8 positions + grid-gradients
|
|
||||||
ONLY for surface cells. Don't hoist position/gradient back above the case-index test. The
|
|
||||||
`DensityGrid` and vertex-dedup `TMap` are `thread_local` and reused per worker (Reset / keep
|
|
||||||
capacity) — don't make them per-call locals (re-allocates ~170 KB + a hash map every tile).
|
|
||||||
- **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` = LRU of `FSurfaceColumnBox`, `GetDensityAt`
|
|
||||||
SurfaceWorld branch): the heightfield + sky-cap + biome blend are a PURE function of (XY, seed,
|
|
||||||
strate) — **ZERO Z dependence** (climate/Voronoi are pure-XY; surface params are per-strate constant
|
|
||||||
under Hard transitions) — yet sampled ~33× per column (once per Z grid-point). Cached per integer XY
|
|
||||||
(box-valid, like the SDF cache) and reused down the column. **Keyed by (XY box, StrateKey, Seed), NOT
|
|
||||||
ChunkZ** (`StrateKey = round(StrateBottomWorldZ)`, taken from the params so it can't disagree with
|
|
||||||
them) and held as a small **LRU of 6 boxes** so the WHOLE vertical view-distance stack — and XY
|
|
||||||
neighbours the scheduler interleaves — share one another's heavy column noise instead of each
|
|
||||||
recomputing it ~once per vertical chunk (this was the dominant `GenerateMesh` cost: the same 2D
|
|
||||||
heightfield recomputed per altitude). It also makes pure-air / pure-solid chunks cheap (they hit the
|
|
||||||
shared box). 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
|
|
||||||
re-introduce a ChunkZ key, don't feed it fractional coords. (`GetSurfaceHeightAt`'s own `OC_*` oracle
|
|
||||||
cache is separate and still per-chunk — lower volume, not worth the LRU.)
|
|
||||||
- **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.
|
|
||||||
- **CHUNKED-LOD CLIPMAP — the streaming model** (`FVoxelTileKey` in VoxelTypes.h; `UpdateChunksAroundPosition`
|
|
||||||
/ `BuildDesiredTiles` / `IsTileInClipRange` / `LoadTile` / `UnloadTile` / `ApplyMeshToTile`; mesher
|
|
||||||
`GenerateMesh(OriginVoxels, Step)`). Replaces the fixed-32³-chunk + LOD-step-on-fixed-extent model
|
|
||||||
AND supersedes the old region-batching / strate-Z-clamp / wide-ceiling (all removed). A **level-L tile**
|
|
||||||
spans `CHUNK_SIZE<<L` voxels meshed at `step 1<<L` → constant 32³-cell mesh, ONE component, ONE draw,
|
|
||||||
covering 8^L× the volume. Streaming loads **concentric shells** (level 0 near, each coarser level a 2×
|
|
||||||
larger shell beyond; inner hole of level L = the region the finer level covers). **Total tile count
|
|
||||||
stays ~flat regardless of view distance** — that's why see-far (ceiling, horizon) is cheap AND why
|
|
||||||
per-tile components are fine for the game thread (no batching: ~1-2k tiles, not 40k). **Load-before-
|
|
||||||
unload cull** (no holes, STRICT): out-of-range tiles cull now; in-range LOD-transition tiles cull only
|
|
||||||
once EVERY desired tile overlapping their footprint is loaded — tested as "no UNLOADED desired tile
|
|
||||||
overlaps T" (`ReplacementsReady` + `FootprintsOverlap` vs the `DesiredPending` list, built once per
|
|
||||||
crossing = desired-minus-loaded, usually tiny). Scanning all of `DesiredSorted` per candidate was an
|
|
||||||
O(loaded×desired) game-thread spike when fast movement turned many tiles non-desired at once. A coarse
|
|
||||||
tile is replaced by several finer tiles, so the old center-owner
|
|
||||||
check (`ReplacementLoaded`) dropped it as soon as the ONE tile over its centre loaded → the not-yet-
|
|
||||||
ready edges flashed a hole; the full-coverage check keeps the old tile at its current resolution until
|
|
||||||
the better mesh is wholly in, then swaps. In-flight (pending) tiles are NEVER cancelled on a rebuild —
|
|
||||||
they finish, apply, and are culled later if no longer desired. Collision level-0 only; water level-0
|
|
||||||
only; shadows off for level≥2. **Decorations are NO LONGER tied to tiles** — they stream on a fixed world
|
|
||||||
grid by distance (§8.5), so they don't pop on LOD swaps. Settings: `VoxelSettings::ClipRadius` (full-res near radius, tiles/level),
|
|
||||||
`MaxClipLevel` (far reach). **NEAR-FIELD GEN COST levers** (`LoadTile`): levels `< FullResClipLevels`
|
|
||||||
mesh at full `CHUNK_SIZE` cells (≈35³ `GetDensityAt` incl. margin ring), coarser levels at
|
|
||||||
`CoarseTileCells` (Step = Extent/Cells) for far-cheaper gen. A level-1 tile at `FullResClipLevels=2`
|
|
||||||
costs the SAME gen as a level-0 tile (same cell count, 8× extent) — set `FullResClipLevels=1` to drop
|
|
||||||
level 1 to `CoarseTileCells` (~6× cheaper) when the near field is gen-bound (slightly harder L0→L1
|
|
||||||
seam, hidden by skirts). `ClipRadius` bounds the full-res level-0 tile COUNT independently of reach.
|
|
||||||
`GetLODForChunk` / `LODToStep` / `IsChunkInRange` / `GetStrateChunkZBounds` and the
|
|
||||||
ViewDistance/LOD/strate-Z/ceiling settings are now DEAD/unused (left in place).
|
|
||||||
- **SKIRTS — LOD-seam crack filler** (`GenerateMesh`, after the cell loop; `VoxelSettings::bGenerateSkirts`
|
|
||||||
+ `SkirtCells`, wired onto the mesher at setup). Neighbouring shells mesh at different resolutions so
|
|
||||||
their iso-surfaces don't meet along the shared face → a thin see-through crack. After meshing, every
|
|
||||||
triangle edge whose BOTH endpoints lie on one of the tile's 6 outer boundary planes (exact float compare
|
|
||||||
— MC keeps the face-axis coordinate fixed) is a surface-contour edge on that face; a skirt quad hangs
|
|
||||||
from it INTO the solid along the inverted vertex normals by `SkirtCells × Step × VOXEL_SIZE` (~one cell,
|
|
||||||
≥ the gap to a one-level-coarser neighbour). Emitted DOUBLE-SIDED (both windings) so it shows regardless
|
|
||||||
of camera side / material two-sidedness; buried elsewhere → invisible. Adds verts/tris ONLY on boundary
|
|
||||||
contour edges (small). Tune `SkirtCells` up if cracks persist, down if skirts peek out on convex edges.
|
|
||||||
- **Budgeted teardown** (`PendingUnload` + `ProcessUnloadQueue`, called from `Tick` after the apply
|
|
||||||
drain; `VoxelSettings::MaxUnloadsPerFrame`): the cull APPROVES removals (strict load-before-unload) but
|
|
||||||
doesn't destroy in place — it queues them. `ProcessUnloadQueue` runs at most `MaxUnloadsPerFrame`
|
|
||||||
`UnloadTile`s/frame (scaled up to 4× with backlog, capped so a huge backlog can't re-spike). WHY: a
|
|
||||||
fast traversal culls a whole shell's worth of tiles in ONE frame, and each `UnloadTile` does
|
|
||||||
`DestroyComponent` + (level 0) `ContentManager::ClearChunk` → `Destroy()` of every decoration actor —
|
|
||||||
an unbudgeted burst = a game-thread spike ("stuff torn down behind you" at speed). Mesh APPLIES were
|
|
||||||
already budgeted; this matches it for DESTROYS. Re-desired tiles are cancelled out of the queue (still
|
|
||||||
loaded → no reload). `PendingUnload` is cleared in `RegenerateAllChunks`/`EndPlay` (tiles already gone).
|
|
||||||
- **Collision only at LEVEL 0** (`ApplyMeshToTile`): `UpdateSectionConfig(..., Tile.Level==0)`. Far tiles
|
|
||||||
are unreachable; cooking their Chaos collision is waste. (Was T1.c, now per-tile-level.)
|
|
||||||
- **No shadows on far tiles (draw cut)** (`ApplyMeshToTile`): `SetCastShadow(Tile.Level <= 1)`. Each
|
|
||||||
shadow-casting tile emits a second shadow-pass draw; the far coarse tiles don't need it. NOTE: fps is
|
|
||||||
RENDER-side (draws ≈ visible tile count × passes); generation cost (workers) and tile *resolution*
|
|
||||||
(cuts triangles, not draws/components) don't move the game thread — tile COUNT does (hence the clipmap).
|
|
||||||
- **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
|
|
||||||
DROP results, leaking `PendingChunkCoord` slots until the budget is exhausted and streaming
|
|
||||||
stalls for good (intermittent; worst during the completion bursts right after the player moves).
|
|
||||||
|
|
||||||
### 8.11 Live tuning & debug (`AVoxelWorld`, CallInEditor / PIE)
|
|
||||||
- `RebuildStrates` — re-reads ALL of `VoxelSettings` and rebuilds layout/gap/passages/spine +
|
|
||||||
regenerates. Use after changing those (plain `RegenerateAllChunks` keeps the old layout/passages).
|
|
||||||
- `bDebugDrawPassages` — draws every passage (cyan path, green=upper / red=lower endpoints).
|
|
||||||
- `EditorCarveSphere`/`EditorFillSphere` + `EditorBrush*` props — manual carve/fill in PIE.
|
|
||||||
|
|
||||||
### 8.12 Authoring a strate (data asset)
|
|
||||||
1. Create `UVoxelStrateDefinition`, pick `GeneratorType` → its param group appears; tune it.
|
|
||||||
2. `PassageConfig` → how THIS strate connects DOWN (count / style / tapered width / length / placement).
|
|
||||||
3. `Disturbances` for chasms/bridges/ridges; `bHasWater`+`WaterMaterial`(+`WaterLevelRelative`) for water.
|
|
||||||
4. Atmosphere: `FogColor/Density`, `AmbientLight*`, `bVolumetricFog`, or a full `AtmosphereActor` BP;
|
|
||||||
`CeilingLayerActor`/`FloorLayerActor` (+offsets/rotations) for cloud seas.
|
|
||||||
5. `Decorations`/`AmbientActors` (placement rules) for content + lights.
|
|
||||||
6. (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/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.
|
|
||||||
|
|
||||||
### 8.15 Biome material identity — vertex-colour palette (F6, Stage 1)
|
|
||||||
A biome re-skins the terrain SURFACE (not just content/atmosphere) through a single master material,
|
|
||||||
with NO extra draw calls / material slots and NO per-tile material swap (which would seam at tile
|
|
||||||
borders). The biome's `MaterialPaletteIndex` (0-255) is **baked into the mesh vertex colour** and a
|
|
||||||
master triplanar material switches/blends its layers on it. Works for ANY archetype (it rides the
|
|
||||||
generic biome field), not just SurfaceWorld. Empty `Biomes[]` ⇒ all-zero colour ⇒ bit-identical look.
|
|
||||||
|
|
||||||
- **Vertex-colour layout** (`FVoxelMeshData::Colors`, packed in `UVoxelMarchingCubesMesher::GenerateMesh`
|
|
||||||
`GetOrCreateVertex`): **R** = dominant biome `MaterialPaletteIndex`; **G** = slope (`1-|N.z|`: 0 flat
|
|
||||||
floor/ceiling, 1 vertical wall — for rock-on-cliffs); **B** = biome border blend weight (0 deep in a
|
|
||||||
cell → ~0.5 at the border); **A** = NEIGHBOUR biome `MaterialPaletteIndex`. The master material does
|
|
||||||
`lerp(layer[R], layer[A], B)` for a seamless cross-fade along the biome field's own border (B peaks at
|
|
||||||
~0.5 = 50/50 at the border; the identities swap across it, so 50/50 both sides ⇒ no discontinuity —
|
|
||||||
do NOT rescale B to reach 1.0 or the swap becomes a hard seam).
|
|
||||||
Height/snow-line is derived in-material from `WorldPosition.Z` (no channel needed). Skirt verts inherit
|
|
||||||
their source vertex's colour (`AddSkirtVert` takes the colour) so the `Colors` array stays parallel.
|
|
||||||
- **Data path.** `UVoxelBiomeDefinition::MaterialPaletteIndex` → `FBiomeResolved::MaterialPaletteIndex`
|
|
||||||
(set in `StrateManager::GetBiomeContextForChunk`) → `UVoxelGenerator::GetBiomeMaterialAt(x,y,z →
|
|
||||||
dominant/neighbour palette + weight)`. That method mirrors `GetDensityAt`'s biome caching: a
|
|
||||||
thread_local per-chunk `FBiomeContext` + box-validated `FChunkBiomeCache`, so the noise-heavy classify
|
|
||||||
is reused across a tile's vertices. Resolved per UNIQUE vertex (after dedup), not per triangle corner.
|
|
||||||
Window-invariant (`ResolveBiomeSampleAt`, bit-identical to `SampleBiomeAt`).
|
|
||||||
- **Apply.** `AVoxelWorld::ApplyMeshToTile` calls `Builder.EnableColors()` + `Vertex.SetColor(...)`.
|
|
||||||
The terrain material slot is still strate `OverrideMaterial` / `Settings->VoxelMaterial` — author THAT
|
|
||||||
as the master palette material. No biome terrain-material asset field (palette index is the contract).
|
|
||||||
- **Perf.** Free where a strate has no biomes (`GetBiomeMaterialAt` early-outs to palette 0). Otherwise
|
|
||||||
one biome resolve per unique vertex, bounded by the per-chunk biome cache (don't feed it a chunk key —
|
|
||||||
keep the box validity, §8.10). Coarse far tiles have few vertices.
|
|
||||||
- **Status:** C++ code-complete, pending in-editor build + the master material graph (editor-side work).
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// VoxelBiomeDefinition.cpp
|
||||||
|
// All data lives in the header; this TU exists so UHT generates the asset's body and
|
||||||
|
// the module has an object file for it.
|
||||||
|
|
||||||
|
#include "VoxelBiomeDefinition.h"
|
||||||
@@ -87,19 +87,30 @@ int32 UVoxelContentManager::RegionSize() const
|
|||||||
return FMath::Max(1, Settings ? Settings->DecorationRegionSizeCells : 4);
|
return FMath::Max(1, Settings ? Settings->DecorationRegionSizeCells : 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRUE floor division (rounds toward -infinity). DO NOT use FMath::DivideAndRoundDown for cell↔region
|
||||||
|
// mapping: it is `A / B`, which TRUNCATES toward zero for negatives (a UE naming footgun). Cells are
|
||||||
|
// assigned to a region by the exact inverse `region*R + offset`, so the mapping back MUST floor — with
|
||||||
|
// truncation, a negative-coord cell is queued under one region but routed back to another → its march
|
||||||
|
// result is dropped → blank chunk. Truncation == floor for A>=0 and for R==1, which is exactly why only
|
||||||
|
// NEGATIVE coordinates at R>=2 were affected. R (region size) is always > 0 here.
|
||||||
|
static FORCEINLINE int32 FloorDivPos(int32 A, int32 R)
|
||||||
|
{
|
||||||
|
return (A >= 0) ? (A / R) : -(((-A) + R - 1) / R);
|
||||||
|
}
|
||||||
|
|
||||||
static FORCEINLINE FIntPoint CellToRegion(const FIntPoint& Cell, int32 R)
|
static FORCEINLINE FIntPoint CellToRegion(const FIntPoint& Cell, int32 R)
|
||||||
{
|
{
|
||||||
return FIntPoint(FMath::DivideAndRoundDown(Cell.X, R), FMath::DivideAndRoundDown(Cell.Y, R));
|
return FIntPoint(FloorDivPos(Cell.X, R), FloorDivPos(Cell.Y, R));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Region is desired iff its cell footprint intersects the radius-FarR cell box around the player.
|
// Region is desired iff its cell footprint intersects the radius-FarR cell box around the player.
|
||||||
static FORCEINLINE bool IsRegionDesired(const FIntPoint& Region, const FIntPoint& PlayerCell,
|
static FORCEINLINE bool IsRegionDesired(const FIntPoint& Region, const FIntPoint& PlayerCell,
|
||||||
int32 FarR, int32 R)
|
int32 FarR, int32 R)
|
||||||
{
|
{
|
||||||
const int32 MinX = FMath::DivideAndRoundDown(PlayerCell.X - FarR, R);
|
const int32 MinX = FloorDivPos(PlayerCell.X - FarR, R);
|
||||||
const int32 MaxX = FMath::DivideAndRoundDown(PlayerCell.X + FarR, R);
|
const int32 MaxX = FloorDivPos(PlayerCell.X + FarR, R);
|
||||||
const int32 MinY = FMath::DivideAndRoundDown(PlayerCell.Y - FarR, R);
|
const int32 MinY = FloorDivPos(PlayerCell.Y - FarR, R);
|
||||||
const int32 MaxY = FMath::DivideAndRoundDown(PlayerCell.Y + FarR, R);
|
const int32 MaxY = FloorDivPos(PlayerCell.Y + FarR, R);
|
||||||
return Region.X >= MinX && Region.X <= MaxX && Region.Y >= MinY && Region.Y <= MaxY;
|
return Region.X >= MinX && Region.X <= MaxX && Region.Y >= MinY && Region.Y <= MaxY;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +172,44 @@ void UVoxelContentManager::UpdateDecorations(const FVector& PlayerWorldPos)
|
|||||||
CurrentCtx.bHasWater = (Wv != -FLT_MAX);
|
CurrentCtx.bHasWater = (Wv != -FLT_MAX);
|
||||||
CurrentCtx.WaterLocalZ = CurrentCtx.bHasWater ? Wv * VOXEL_SIZE : -FLT_MAX;
|
CurrentCtx.WaterLocalZ = CurrentCtx.bHasWater ? Wv * VOXEL_SIZE : -FLT_MAX;
|
||||||
}
|
}
|
||||||
|
// Strate biome field (XY-global → resolved once; the worker picks the dominant biome per COLUMN).
|
||||||
|
CurrentCtx.BiomeCtx = StrateManager->GetBiomeContextForChunk(RepChunk);
|
||||||
|
|
||||||
|
// Build the decoration palette ONCE for this update. With biomes, concatenate every biome's deco list
|
||||||
|
// and tag each entry with its context-biome index; the worker resolves a column's biome and rolls only
|
||||||
|
// the entries it owns → borders follow the warped-Voronoi field, not the 8 m cell grid (Task 1, §8.5).
|
||||||
|
// Without biomes, fall back to the strate's single list tagged -1 (always matches → legacy behaviour).
|
||||||
|
CurrentEntries.Reset();
|
||||||
|
CurrentEntryBiome.Reset();
|
||||||
|
if (CurrentCtx.Def)
|
||||||
|
{
|
||||||
|
if (CurrentCtx.BiomeCtx.IsValid())
|
||||||
|
{
|
||||||
|
for (int32 ci = 0; ci < CurrentCtx.BiomeCtx.Biomes.Num(); ++ci)
|
||||||
|
{
|
||||||
|
const int32 StrateBiomeIdx = CurrentCtx.BiomeCtx.Biomes[ci].Index;
|
||||||
|
const UVoxelBiomeDefinition* Bio =
|
||||||
|
CurrentCtx.Def->Biomes.IsValidIndex(StrateBiomeIdx) ? CurrentCtx.Def->Biomes[StrateBiomeIdx] : nullptr;
|
||||||
|
// A biome with no decorations inherits the strate's list (still tagged with THIS biome's
|
||||||
|
// index so it only fires inside that biome's columns — no cross-biome bleed).
|
||||||
|
const TArray<FStrateDecoration>& Src =
|
||||||
|
(Bio && Bio->Decorations.Num() > 0) ? Bio->Decorations : CurrentCtx.Def->Decorations;
|
||||||
|
for (const FStrateDecoration& D : Src)
|
||||||
|
{
|
||||||
|
CurrentEntries.Add(D);
|
||||||
|
CurrentEntryBiome.Add(ci);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
for (const FStrateDecoration& D : CurrentCtx.Def->Decorations)
|
||||||
|
{
|
||||||
|
CurrentEntries.Add(D);
|
||||||
|
CurrentEntryBiome.Add(-1); // no biome field → matches the column's ColBiome (-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Strate change → wipe + force a full rebuild (bumps epoch so old in-flight tasks are discarded).
|
// Strate change → wipe + force a full rebuild (bumps epoch so old in-flight tasks are discarded).
|
||||||
if (StrateIndex != LastStrateIndex)
|
if (StrateIndex != LastStrateIndex)
|
||||||
@@ -191,10 +240,10 @@ void UVoxelContentManager::RebuildDesiredCells(const FIntPoint& PlayerCell)
|
|||||||
const int32 R = RegionSize();
|
const int32 R = RegionSize();
|
||||||
|
|
||||||
// Desired regions = every region whose footprint touches the radius-FarR cell box around the player.
|
// Desired regions = every region whose footprint touches the radius-FarR cell box around the player.
|
||||||
const FIntPoint RMin(FMath::DivideAndRoundDown(PlayerCell.X - FarR, R),
|
const FIntPoint RMin(FloorDivPos(PlayerCell.X - FarR, R),
|
||||||
FMath::DivideAndRoundDown(PlayerCell.Y - FarR, R));
|
FloorDivPos(PlayerCell.Y - FarR, R));
|
||||||
const FIntPoint RMax(FMath::DivideAndRoundDown(PlayerCell.X + FarR, R),
|
const FIntPoint RMax(FloorDivPos(PlayerCell.X + FarR, R),
|
||||||
FMath::DivideAndRoundDown(PlayerCell.Y + FarR, R));
|
FloorDivPos(PlayerCell.Y + FarR, R));
|
||||||
|
|
||||||
TSet<FIntPoint> DesiredRegions;
|
TSet<FIntPoint> DesiredRegions;
|
||||||
DesiredRegions.Reserve((RMax.X - RMin.X + 1) * (RMax.Y - RMin.Y + 1));
|
DesiredRegions.Reserve((RMax.X - RMin.X + 1) * (RMax.Y - RMin.Y + 1));
|
||||||
@@ -280,20 +329,17 @@ void UVoxelContentManager::LaunchDecoTasks(const FIntPoint& PlayerCell)
|
|||||||
if (!Build) continue;
|
if (!Build) continue;
|
||||||
const uint32 BuildId = Build->BuildId;
|
const uint32 BuildId = Build->BuildId;
|
||||||
|
|
||||||
// Resolve the decoration list on the GAME THREAD (GetDominantBiomeAt is game-thread).
|
// The decoration palette (all biomes' lists, flattened + tagged) is built ONCE per update in
|
||||||
const float CX = ((float)Cell.X + 0.5f) * CHUNK_SIZE;
|
// UpdateDecorations; the per-COLUMN biome pick happens on the worker. Snapshot the flat list +
|
||||||
const float CY = ((float)Cell.Y + 0.5f) * CHUNK_SIZE;
|
// tags for this cell's task (the biome context rides in Ctx).
|
||||||
const UVoxelBiomeDefinition* Biome = Generator->GetDominantBiomeAt(CX, CY, CurrentCtx.RepChunkZ);
|
if (CurrentEntries.Num() == 0)
|
||||||
const TArray<FStrateDecoration>& SrcDecos =
|
|
||||||
(Biome && Biome->Decorations.Num() > 0) ? Biome->Decorations : CurrentCtx.Def->Decorations;
|
|
||||||
|
|
||||||
if (SrcDecos.Num() == 0)
|
|
||||||
{
|
{
|
||||||
MarkCellDone(Region, BuildId); // empty cell still counts toward the region's completion
|
MarkCellDone(Region, Cell, BuildId); // empty cell still counts toward the region's completion
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
TArray<FStrateDecoration> EntriesCopy = SrcDecos; // snapshot for the worker + the spawner
|
TArray<FStrateDecoration> EntriesCopy = CurrentEntries; // snapshot for the worker + the spawner
|
||||||
|
TArray<int32> EntryBiomeCopy = CurrentEntryBiome; // parallel: ctx-biome owner per entry
|
||||||
const FDecoContext Ctx = CurrentCtx; // PODs only used on the worker
|
const FDecoContext Ctx = CurrentCtx; // PODs only used on the worker
|
||||||
const uint32 LocalSeed = (uint32)Seed;
|
const uint32 LocalSeed = (uint32)Seed;
|
||||||
UVoxelGenerator* Gen = Generator;
|
UVoxelGenerator* Gen = Generator;
|
||||||
@@ -303,7 +349,7 @@ void UVoxelContentManager::LaunchDecoTasks(const FIntPoint& PlayerCell)
|
|||||||
|
|
||||||
UE::Tasks::Launch(TEXT("DecoMarch"),
|
UE::Tasks::Launch(TEXT("DecoMarch"),
|
||||||
[this, Gen, OwnerXf, Cell, Ctx, LocalSeed, Spacing, Step, MaxCross, ColDepth, BuildId,
|
[this, Gen, OwnerXf, Cell, Ctx, LocalSeed, Spacing, Step, MaxCross, ColDepth, BuildId,
|
||||||
Entries = MoveTemp(EntriesCopy)]() mutable
|
Entries = MoveTemp(EntriesCopy), EntryBiome = MoveTemp(EntryBiomeCopy)]() mutable
|
||||||
{
|
{
|
||||||
struct FGuard { ~FGuard() { GActiveDecoTasks.fetch_sub(1, std::memory_order_relaxed); } } Guard;
|
struct FGuard { ~FGuard() { GActiveDecoTasks.fetch_sub(1, std::memory_order_relaxed); } } Guard;
|
||||||
|
|
||||||
@@ -313,7 +359,7 @@ void UVoxelContentManager::LaunchDecoTasks(const FIntPoint& PlayerCell)
|
|||||||
Result.Cell = Cell;
|
Result.Cell = Cell;
|
||||||
Result.BuildId = BuildId;
|
Result.BuildId = BuildId;
|
||||||
Result.Entries = MoveTemp(Entries);
|
Result.Entries = MoveTemp(Entries);
|
||||||
BuildCellSpawns(Gen, OwnerXf, Cell, Ctx, Result.Entries, LocalSeed,
|
BuildCellSpawns(Gen, OwnerXf, Cell, Ctx, Result.Entries, EntryBiome, LocalSeed,
|
||||||
Spacing, Step, MaxCross, ColDepth, Result.Spawns);
|
Spacing, Step, MaxCross, ColDepth, Result.Spawns);
|
||||||
|
|
||||||
if (!bShuttingDown.load(std::memory_order_relaxed))
|
if (!bShuttingDown.load(std::memory_order_relaxed))
|
||||||
@@ -327,7 +373,8 @@ void UVoxelContentManager::LaunchDecoTasks(const FIntPoint& PlayerCell)
|
|||||||
// ---- WORKER THREAD: find each column's surface points → spawn commands. ----
|
// ---- WORKER THREAD: find each column's surface points → spawn commands. ----
|
||||||
void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTransform& OwnerXf,
|
void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTransform& OwnerXf,
|
||||||
const FIntPoint& Cell, const FDecoContext& Ctx,
|
const FIntPoint& Cell, const FDecoContext& Ctx,
|
||||||
const TArray<FStrateDecoration>& Entries, uint32 InSeed,
|
const TArray<FStrateDecoration>& Entries,
|
||||||
|
const TArray<int32>& EntryBiome, uint32 InSeed,
|
||||||
int32 Spacing, float Step, int32 MaxCrossings, float ColumnDepth,
|
int32 Spacing, float Step, int32 MaxCrossings, float ColumnDepth,
|
||||||
TArray<FDecoSpawn>& OutSpawns)
|
TArray<FDecoSpawn>& OutSpawns)
|
||||||
{
|
{
|
||||||
@@ -340,12 +387,20 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
|
|||||||
TArray<int32> EntryCount; EntryCount.Init(0, Entries.Num());
|
TArray<int32> EntryCount; EntryCount.Init(0, Entries.Num());
|
||||||
int32 TotalActors = 0;
|
int32 TotalActors = 0;
|
||||||
|
|
||||||
|
// Per-COLUMN biome cache: ResolveBiomeSampleAt's noise-heavy cell classification is box-validated
|
||||||
|
// (one rebuild per chunk footprint), so resolving the dominant biome at every column in this cell is
|
||||||
|
// cheap. The cache is local to this worker task (determinism-safe — pure function of XY/seed/Ctx).
|
||||||
|
FChunkBiomeCache BiomeCache;
|
||||||
|
const bool bHasBiomes = Ctx.BiomeCtx.IsValid();
|
||||||
|
|
||||||
auto D = [&](float VX, float VY, float VZ) { return Gen->GetDensityAt(VX, VY, VZ); };
|
auto D = [&](float VX, float VY, float VZ) { return Gen->GetDensityAt(VX, VY, VZ); };
|
||||||
|
|
||||||
// Shared: roll every decoration entry at one surface point (voxel XY, voxel Z, outward world normal)
|
// Shared: roll every decoration entry at one surface point (voxel XY, voxel Z, outward world normal)
|
||||||
// and append the passing ones to OutSpawns. CrossingIdx salts the hash so stacked surfaces differ.
|
// and append the passing ones to OutSpawns. CrossingIdx salts the hash so stacked surfaces differ.
|
||||||
|
// ColBiome = the column's dominant context-biome index (-1 when biomes are off); an entry is rolled
|
||||||
|
// only if it belongs to that biome (EntryBiome[EntryIdx] == ColBiome) → organic, per-column borders.
|
||||||
auto PlaceAtCrossing = [&](float VX, float VY, int32 gx, int32 gy, float ZC,
|
auto PlaceAtCrossing = [&](float VX, float VY, int32 gx, int32 gy, float ZC,
|
||||||
const FVector& NormalWorld, int32 CrossingIdx)
|
const FVector& NormalWorld, int32 CrossingIdx, int32 ColBiome)
|
||||||
{
|
{
|
||||||
const bool bFloor = NormalWorld.Z > 0.5f;
|
const bool bFloor = NormalWorld.Z > 0.5f;
|
||||||
const bool bCeiling = NormalWorld.Z < -0.5f;
|
const bool bCeiling = NormalWorld.Z < -0.5f;
|
||||||
@@ -357,6 +412,10 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
|
|||||||
|
|
||||||
for (int32 EntryIdx = 0; EntryIdx < Entries.Num(); ++EntryIdx)
|
for (int32 EntryIdx = 0; EntryIdx < Entries.Num(); ++EntryIdx)
|
||||||
{
|
{
|
||||||
|
// Per-column biome gate: only this column's dominant biome owns its entries. -1-tagged
|
||||||
|
// entries (no biome field) match the -1 ColBiome, so the legacy single-list path is intact.
|
||||||
|
if (EntryBiome[EntryIdx] != ColBiome) continue;
|
||||||
|
|
||||||
const FStrateDecoration& Deco = Entries[EntryIdx];
|
const FStrateDecoration& Deco = Entries[EntryIdx];
|
||||||
const bool bInstanced = (Deco.InstancedMesh != nullptr);
|
const bool bInstanced = (Deco.InstancedMesh != nullptr);
|
||||||
if (!bInstanced && !Deco.ActorClass) continue;
|
if (!bInstanced && !Deco.ActorClass) continue;
|
||||||
@@ -374,6 +433,11 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
|
|||||||
}
|
}
|
||||||
if (!bMatches) continue;
|
if (!bMatches) continue;
|
||||||
|
|
||||||
|
// Strict-wall overhang gate: a "wall" point also covers surfaces that lean slightly downward
|
||||||
|
// (N.Z in [-0.5, 0)). For props flagged wall-only-upright, drop those so overhangs don't take
|
||||||
|
// wall decals. Applies whenever the point IS a wall (independent of Floor/Wall/Any setting).
|
||||||
|
if (bWall && Deco.bWallExcludeOverhangs && NormalWorld.Z < 0.0f) continue;
|
||||||
|
|
||||||
// Surface-tilt gate: tilt = acos(|N.Z|) (0 = flat, 90 = vertical). Skip surfaces steeper than
|
// Surface-tilt gate: tilt = acos(|N.Z|) (0 = flat, 90 = vertical). Skip surfaces steeper than
|
||||||
// MaxSlopeAngle. cos is monotone-decreasing, so |N.Z| < cos(MaxSlope) ⇔ tilt > MaxSlope.
|
// MaxSlopeAngle. cos is monotone-decreasing, so |N.Z| < cos(MaxSlope) ⇔ tilt > MaxSlope.
|
||||||
// Guarded so the default (90°, cos = 0) costs no trig and never rejects anything.
|
// Guarded so the default (90°, cos = 0) costs no trig and never rejects anything.
|
||||||
@@ -383,6 +447,14 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lower-bound tilt gate (companion to the above): skip surfaces FLATTER than MinSlopeAngle.
|
||||||
|
// tilt < MinSlope ⇔ |N.Z| > cos(MinSlope). Guarded so the default (0°, cos = 1) never rejects.
|
||||||
|
if (Deco.MinSlopeAngle > 0.01f &&
|
||||||
|
FMath::Abs(NormalWorld.Z) > FMath::Cos(FMath::DegreesToRadians(Deco.MinSlopeAngle)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const uint32 H = DecoHash(Cell.X, Cell.Y, gx, gy, CrossingIdx, EntryIdx, InSeed, 0xDEC0u);
|
const uint32 H = DecoHash(Cell.X, Cell.Y, gx, gy, CrossingIdx, EntryIdx, InSeed, 0xDEC0u);
|
||||||
if (VoxelHash::ToFloat01(H) > Deco.SpawnDensity) continue;
|
if (VoxelHash::ToFloat01(H) > Deco.SpawnDensity) continue;
|
||||||
|
|
||||||
@@ -397,7 +469,10 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
|
|||||||
: FQuat::Identity;
|
: FQuat::Identity;
|
||||||
if (Deco.bRandomYaw)
|
if (Deco.bRandomYaw)
|
||||||
{
|
{
|
||||||
const float Yaw = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x59415721u)) * 2.0f * PI;
|
// Roll within [MinYaw, MaxYaw]; the default 0..360 reproduces the legacy full-turn roll
|
||||||
|
// bit-for-bit (Lerp(0,360,t)° == t·2π rad), so existing assets are unchanged.
|
||||||
|
const float YawT = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x59415721u));
|
||||||
|
const float Yaw = FMath::DegreesToRadians(FMath::Lerp(Deco.MinYaw, Deco.MaxYaw, YawT));
|
||||||
const FVector Axis = Deco.bAlignToSurface ? NormalWorld : FVector::UpVector;
|
const FVector Axis = Deco.bAlignToSurface ? NormalWorld : FVector::UpVector;
|
||||||
BaseQ = FQuat(Axis, Yaw) * BaseQ;
|
BaseQ = FQuat(Axis, Yaw) * BaseQ;
|
||||||
}
|
}
|
||||||
@@ -425,6 +500,25 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
|
|||||||
const float VX = (float)(CellOriginVX + gx * Spacing + JX);
|
const float VX = (float)(CellOriginVX + gx * Spacing + JX);
|
||||||
const float VY = (float)(CellOriginVY + gy * Spacing + JY);
|
const float VY = (float)(CellOriginVY + gy * Spacing + JY);
|
||||||
|
|
||||||
|
// Resolve the column's biome ONCE (constant over the column's whole Z range). -1 when the strate
|
||||||
|
// has no biome field → matches the -1-tagged legacy entries.
|
||||||
|
int32 ColBiome = -1;
|
||||||
|
if (bHasBiomes)
|
||||||
|
{
|
||||||
|
const FBiomeSample BS = Gen->ResolveBiomeSampleAt(VX, VY, Ctx.RepChunkZ, Ctx.BiomeCtx, BiomeCache);
|
||||||
|
ColBiome = BS.DominantIndex; // index into Ctx.BiomeCtx.Biomes == the entry tag
|
||||||
|
|
||||||
|
// SOFTEN THE BORDER: a hard dominant pick still switches deco sets on a crisp line. In the
|
||||||
|
// blend band (NeighborWeight rises 0 → ~0.5 toward the shared border) flip a hash-decided
|
||||||
|
// fraction of columns to the NEIGHBOUR biome, so the two deco sets DITHER across the seam
|
||||||
|
// instead of snapping. Deterministic (pure hash of cell/column/seed) → no flicker/perf cost.
|
||||||
|
if (BS.NeighborWeight > 0.0f && BS.NeighborIndex >= 0)
|
||||||
|
{
|
||||||
|
const uint32 HBlend = DecoHash(Cell.X, Cell.Y, gx, gy, -7, 0, InSeed, 0xB1E2u);
|
||||||
|
if (VoxelHash::ToFloat01(HBlend) < BS.NeighborWeight) { ColBiome = BS.NeighborIndex; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Ctx.bSurfaceWorld)
|
if (Ctx.bSurfaceWorld)
|
||||||
{
|
{
|
||||||
// HEIGHTFIELD ORACLE — O(1)/column instead of marching the whole band. Query the surface
|
// HEIGHTFIELD ORACLE — O(1)/column instead of marching the whole band. Query the surface
|
||||||
@@ -445,7 +539,7 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
|
|||||||
const float dHdy = (hYp - hYm) * 0.5f;
|
const float dHdy = (hYp - hYm) * 0.5f;
|
||||||
FVector N = OwnerXf.TransformVectorNoScale(FVector(-dHdx, -dHdy, 1.0f)).GetSafeNormal();
|
FVector N = OwnerXf.TransformVectorNoScale(FVector(-dHdx, -dHdy, 1.0f)).GetSafeNormal();
|
||||||
if (N.IsNearlyZero()) N = FVector::UpVector;
|
if (N.IsNearlyZero()) N = FVector::UpVector;
|
||||||
PlaceAtCrossing(VX, VY, gx, gy, hC, N, 0);
|
PlaceAtCrossing(VX, VY, gx, gy, hC, N, 0, ColBiome);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sky-cap ceiling underside: outward normal = (dC/dx, dC/dy, -1). Only if open space below.
|
// Sky-cap ceiling underside: outward normal = (dC/dx, dC/dy, -1). Only if open space below.
|
||||||
@@ -455,7 +549,7 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
|
|||||||
const float dCdy = (cYp - cYm) * 0.5f;
|
const float dCdy = (cYp - cYm) * 0.5f;
|
||||||
FVector N = OwnerXf.TransformVectorNoScale(FVector(dCdx, dCdy, -1.0f)).GetSafeNormal();
|
FVector N = OwnerXf.TransformVectorNoScale(FVector(dCdx, dCdy, -1.0f)).GetSafeNormal();
|
||||||
if (N.IsNearlyZero()) N = FVector::DownVector;
|
if (N.IsNearlyZero()) N = FVector::DownVector;
|
||||||
PlaceAtCrossing(VX, VY, gx, gy, cC, N, 1);
|
PlaceAtCrossing(VX, VY, gx, gy, cC, N, 1, ColBiome);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -491,7 +585,7 @@ void UVoxelContentManager::BuildCellSpawns(const UVoxelGenerator* Gen, const FTr
|
|||||||
FVector NormalWorld = OwnerXf.TransformVectorNoScale(LocalGrad).GetSafeNormal();
|
FVector NormalWorld = OwnerXf.TransformVectorNoScale(LocalGrad).GetSafeNormal();
|
||||||
if (NormalWorld.IsNearlyZero()) NormalWorld = FVector::UpVector;
|
if (NormalWorld.IsNearlyZero()) NormalWorld = FVector::UpVector;
|
||||||
|
|
||||||
PlaceAtCrossing(VX, VY, gx, gy, ZC, NormalWorld, Crossings);
|
PlaceAtCrossing(VX, VY, gx, gy, ZC, NormalWorld, Crossings, ColBiome);
|
||||||
++Crossings;
|
++Crossings;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,7 +644,16 @@ void UVoxelContentManager::MergeCellResult(const FDecoCellResult& Result)
|
|||||||
{
|
{
|
||||||
const FIntPoint Region = CellToRegion(Result.Cell, RegionSize());
|
const FIntPoint Region = CellToRegion(Result.Cell, RegionSize());
|
||||||
FDecoRegionBuild* Build = RegionBuilds.Find(Region);
|
FDecoRegionBuild* Build = RegionBuilds.Find(Region);
|
||||||
if (!Build || Build->BuildId != Result.BuildId) return;
|
if (!Build || Build->BuildId != Result.BuildId)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Already counted this cell (a duplicate task for the same cell+build landed first) → drop this
|
||||||
|
// result whole, or we'd append its spawns twice (double decorations at the same spots).
|
||||||
|
if (Build->AccountedCells.Contains(Result.Cell))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const FDecoSpawn& S : Result.Spawns)
|
for (const FDecoSpawn& S : Result.Spawns)
|
||||||
{
|
{
|
||||||
@@ -574,14 +677,21 @@ void UVoxelContentManager::MergeCellResult(const FDecoCellResult& Result)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MarkCellDone(Region, Result.BuildId);
|
MarkCellDone(Region, Result.Cell, Result.BuildId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Account one cell against its region's remaining-cell count; queue the region for apply once all are in.
|
// Account one cell against its region — IDEMPOTENT per cell, so a duplicate task for the same cell can't
|
||||||
void UVoxelContentManager::MarkCellDone(const FIntPoint& Region, uint32 BuildId)
|
// double-decrement and apply the region early (which left a permanently-empty chunk until a regen). Queues
|
||||||
|
// the region for apply once every distinct cell has reported.
|
||||||
|
void UVoxelContentManager::MarkCellDone(const FIntPoint& Region, const FIntPoint& Cell, uint32 BuildId)
|
||||||
{
|
{
|
||||||
FDecoRegionBuild* Build = RegionBuilds.Find(Region);
|
FDecoRegionBuild* Build = RegionBuilds.Find(Region);
|
||||||
if (!Build || Build->BuildId != BuildId) return;
|
if (!Build || Build->BuildId != BuildId) return;
|
||||||
|
|
||||||
|
bool bAlreadyAccounted = false;
|
||||||
|
Build->AccountedCells.Add(Cell, &bAlreadyAccounted);
|
||||||
|
if (bAlreadyAccounted) return; // this cell already counted → ignore the duplicate
|
||||||
|
|
||||||
if (--Build->CellsRemaining <= 0)
|
if (--Build->CellsRemaining <= 0)
|
||||||
{
|
{
|
||||||
CompletedRegions.Add(Region); // ready for budgeted apply in ProcessDecoResults
|
CompletedRegions.Add(Region); // ready for budgeted apply in ProcessDecoResults
|
||||||
@@ -782,3 +892,76 @@ void UVoxelContentManager::ClearAll()
|
|||||||
LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN);
|
LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN);
|
||||||
LastStrateIndex = INT32_MIN;
|
LastStrateIndex = INT32_MIN;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// DIAGNOSTIC — decoration streaming state under a point
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
void UVoxelContentManager::QueryDecoDebugAt(const FVector& LocalPos, bool& bApplied, int32& InstanceCount,
|
||||||
|
bool& bBuilding, int32& CellsAccounted, int32& CellsTotal,
|
||||||
|
int32& LiveMarchSpawns, int32& InstancesInCell) const
|
||||||
|
{
|
||||||
|
bApplied = false; InstanceCount = 0; bBuilding = false; CellsAccounted = 0;
|
||||||
|
LiveMarchSpawns = -1; InstancesInCell = 0;
|
||||||
|
|
||||||
|
const int32 R = RegionSize();
|
||||||
|
CellsTotal = R * R;
|
||||||
|
|
||||||
|
// World-LOCAL XY → deco cell → region (same math the scatter uses).
|
||||||
|
const float CellWorld = (float)DECO_CELL_VOXELS * VOXEL_SIZE;
|
||||||
|
const FIntPoint Cell(FMath::FloorToInt(LocalPos.X / CellWorld),
|
||||||
|
FMath::FloorToInt(LocalPos.Y / CellWorld));
|
||||||
|
const FIntPoint Region = CellToRegion(Cell, R);
|
||||||
|
|
||||||
|
// Actor-LOCAL XY footprint of the probed cell (instances are stored component-local; the HISM sits at
|
||||||
|
// the actor transform with identity relative, so component-local == actor-local cell space).
|
||||||
|
const float CellMinX = (float)Cell.X * CellWorld, CellMaxX = CellMinX + CellWorld;
|
||||||
|
const float CellMinY = (float)Cell.Y * CellWorld, CellMaxY = CellMinY + CellWorld;
|
||||||
|
|
||||||
|
if (const FDecoRegionContent* Content = DecoRegions.Find(Region))
|
||||||
|
{
|
||||||
|
bApplied = true;
|
||||||
|
for (const TWeakObjectPtr<UHierarchicalInstancedStaticMeshComponent>& C : Content->Instances)
|
||||||
|
{
|
||||||
|
const UHierarchicalInstancedStaticMeshComponent* Comp = C.Get();
|
||||||
|
if (!Comp) continue;
|
||||||
|
const int32 N = Comp->GetInstanceCount();
|
||||||
|
InstanceCount += N;
|
||||||
|
// Count the ones actually inside the probed cell → tells a blank cell apart from a blank region.
|
||||||
|
for (int32 i = 0; i < N; ++i)
|
||||||
|
{
|
||||||
|
FTransform Xf;
|
||||||
|
if (!Comp->GetInstanceTransform(i, Xf, /*bWorldSpace=*/false)) continue;
|
||||||
|
const FVector P = Xf.GetLocation();
|
||||||
|
if (P.X >= CellMinX && P.X < CellMaxX && P.Y >= CellMinY && P.Y < CellMaxY)
|
||||||
|
{
|
||||||
|
++InstancesInCell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (const FDecoRegionBuild* Build = RegionBuilds.Find(Region))
|
||||||
|
{
|
||||||
|
bBuilding = true;
|
||||||
|
CellsAccounted = Build->AccountedCells.Num();
|
||||||
|
}
|
||||||
|
|
||||||
|
// PER-CELL decisive probe: re-run the march for THIS cell synchronously with the current strate
|
||||||
|
// context (set each UpdateDecorations). Same inputs the worker uses → byte-identical result, so it
|
||||||
|
// reports exactly what the scatter decides for this cell right now. Only valid when the probed point
|
||||||
|
// shares the player's current strate (CurrentCtx/CurrentEntries reflect that); else leave -1.
|
||||||
|
AActor* OwnerActor = Owner.Get();
|
||||||
|
if (Generator && Settings && OwnerActor && CurrentCtx.Def && CurrentEntries.Num() > 0)
|
||||||
|
{
|
||||||
|
const int32 Spacing = FMath::Clamp(Settings->DecorationSpacingVoxels, 1, CHUNK_SIZE);
|
||||||
|
const float Step = (float)FMath::Max(1, Settings->DecorationMarchStepVoxels);
|
||||||
|
const int32 MaxCross = FMath::Max(1, Settings->DecorationMaxCrossingsPerColumn);
|
||||||
|
const float ColDepth = (float)FMath::Max(8, Settings->DecorationColumnDepthVoxels);
|
||||||
|
|
||||||
|
TArray<FDecoSpawn> Spawns;
|
||||||
|
BuildCellSpawns(Generator, OwnerActor->GetActorTransform(), Cell, CurrentCtx,
|
||||||
|
CurrentEntries, CurrentEntryBiome, (uint32)Seed,
|
||||||
|
Spacing, Step, MaxCross, ColDepth, Spawns);
|
||||||
|
LiveMarchSpawns = Spawns.Num();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2433,6 +2433,54 @@ const UVoxelBiomeDefinition* UVoxelGenerator::GetDominantBiomeAt(float WorldX, f
|
|||||||
return (Def && Def->Biomes.IsValidIndex(StrateBiomeIdx)) ? Def->Biomes[StrateBiomeIdx] : nullptr;
|
return (Def && Def->Biomes.IsValidIndex(StrateBiomeIdx)) ? Def->Biomes[StrateBiomeIdx] : nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void UVoxelGenerator::QueryBiomeAt(float WorldX, float WorldY, int32 ChunkZ, FVoxelBiomeQuery& Out) const
|
||||||
|
{
|
||||||
|
Out = FVoxelBiomeQuery();
|
||||||
|
if (!StrateManager) return;
|
||||||
|
|
||||||
|
const FIntVector Coord(FMath::FloorToInt(WorldX / CHUNK_SIZE),
|
||||||
|
FMath::FloorToInt(WorldY / CHUNK_SIZE), ChunkZ);
|
||||||
|
const FBiomeContext Ctx = StrateManager->GetBiomeContextForChunk(Coord);
|
||||||
|
|
||||||
|
// Climate fields are always meaningful (use the strate's map freqs, or defaults when no biomes).
|
||||||
|
const FBiomeMapParams MP = Ctx.IsValid() ? Ctx.Map : FBiomeMapParams();
|
||||||
|
Out.Relief = SampleRelief(WorldX, WorldY, MP.ReliefFrequency, MP.ReliefContrast);
|
||||||
|
Out.Moisture = SampleMoisture(WorldX, WorldY, MP.MoistureFrequency);
|
||||||
|
|
||||||
|
if (!Ctx.IsValid()) return; // bHasBiomes stays false → BP knows this strate has no biome field
|
||||||
|
Out.bHasBiomes = true;
|
||||||
|
|
||||||
|
const FBiomeSample S = SampleBiomeAt(WorldX, WorldY, Ctx);
|
||||||
|
Out.NeighborWeight = S.NeighborWeight;
|
||||||
|
Out.DominantContextIndex = S.DominantIndex;
|
||||||
|
|
||||||
|
const UVoxelStrateDefinition* Def = StrateManager->GetStrateForChunk(Coord);
|
||||||
|
|
||||||
|
if (Ctx.Biomes.IsValidIndex(S.DominantIndex))
|
||||||
|
{
|
||||||
|
const int32 DomStrateIdx = Ctx.Biomes[S.DominantIndex].Index;
|
||||||
|
if (Def && Def->Biomes.IsValidIndex(DomStrateIdx))
|
||||||
|
{
|
||||||
|
UVoxelBiomeDefinition* Bio = Def->Biomes[DomStrateIdx];
|
||||||
|
Out.DominantBiome = Bio;
|
||||||
|
if (Bio)
|
||||||
|
{
|
||||||
|
Out.DebugColor = Bio->DebugColor;
|
||||||
|
Out.DominantName = Bio->BiomeName.IsEmpty() ? FText::FromName(Bio->GetFName()) : Bio->BiomeName;
|
||||||
|
// Effective list = the biome's decorations, or the strate's when the biome has none
|
||||||
|
// (this is exactly what the per-column scatter falls back to). 0 ⇒ empty band.
|
||||||
|
Out.DominantDecorationCount =
|
||||||
|
(Bio->Decorations.Num() > 0) ? Bio->Decorations.Num() : Def->Decorations.Num();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Ctx.Biomes.IsValidIndex(S.NeighborIndex))
|
||||||
|
{
|
||||||
|
const int32 NbStrateIdx = Ctx.Biomes[S.NeighborIndex].Index;
|
||||||
|
if (Def && Def->Biomes.IsValidIndex(NbStrateIdx)) { Out.NeighborBiome = Def->Biomes[NbStrateIdx]; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void UVoxelGenerator::GetBiomeMaterialAt(float WorldX, float WorldY, float WorldZ,
|
void UVoxelGenerator::GetBiomeMaterialAt(float WorldX, float WorldY, float WorldZ,
|
||||||
int32& OutDominantPalette, int32& OutNeighborPalette, float& OutBlendWeight) const
|
int32& OutDominantPalette, int32& OutNeighborPalette, float& OutBlendWeight) const
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,6 +24,51 @@ AVoxelWorld::AVoxelWorld()
|
|||||||
PrimaryActorTick.bCanEverTick = true;
|
PrimaryActorTick.bCanEverTick = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// T1.f — build the RMC geometry buffers OFF the game thread.
|
||||||
|
//=============================================================================
|
||||||
|
// FRealtimeMeshStreamSet is plain CPU data; the per-vertex/per-triangle builder loop used to run
|
||||||
|
// in ApplyMeshToTile ON THE GAME THREAD, where it was the dominant streaming cost (game thread
|
||||||
|
// >6 ms while moving, GPU/draw idle). It touches ONLY the POD MeshData arrays — no UObject, no
|
||||||
|
// generator — so it's safe on the gen worker. The game thread then just uploads the finished
|
||||||
|
// streams (CreateSectionGroup). Byte-identical geometry; the only thing that moved is WHERE it runs.
|
||||||
|
static void BuildTileStreamSet(RealtimeMesh::FRealtimeMeshStreamSet& Streams, const FVoxelMeshData& MeshData)
|
||||||
|
{
|
||||||
|
RealtimeMesh::TRealtimeMeshBuilderLocal<uint32, FPackedNormal, FVector2DHalf, 1> Builder(Streams);
|
||||||
|
Builder.EnableTangents();
|
||||||
|
Builder.EnableTexCoords();
|
||||||
|
Builder.EnableColors(); // masques matériau F6 (palette biome / pente / fondu) — voir le mesher
|
||||||
|
Builder.EnablePolyGroups();
|
||||||
|
|
||||||
|
const int32 NumVertices = MeshData.Vertices.Num();
|
||||||
|
Builder.ReserveAdditionalVertices(NumVertices);
|
||||||
|
for (int32 i = 0; i < NumVertices; i++)
|
||||||
|
{
|
||||||
|
auto Vertex = Builder.AddVertex((FVector3f)MeshData.Vertices[i]);
|
||||||
|
if (MeshData.Normals.IsValidIndex(i))
|
||||||
|
{
|
||||||
|
Vertex.SetNormalAndTangent((FVector3f)MeshData.Normals[i], FVector3f(1, 0, 0));
|
||||||
|
}
|
||||||
|
if (MeshData.UVs.IsValidIndex(i))
|
||||||
|
{
|
||||||
|
Vertex.SetTexCoord(0, (FVector2f)MeshData.UVs[i]);
|
||||||
|
}
|
||||||
|
if (MeshData.Colors.IsValidIndex(i))
|
||||||
|
{
|
||||||
|
Vertex.SetColor(MeshData.Colors[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const int32 NumIndices = MeshData.Triangles.Num();
|
||||||
|
Builder.ReserveAdditionalTriangles(NumIndices / 3);
|
||||||
|
for (int32 i = 0; i < NumIndices; i += 3)
|
||||||
|
{
|
||||||
|
Builder.AddTriangle((uint32)MeshData.Triangles[i],
|
||||||
|
(uint32)MeshData.Triangles[i + 1],
|
||||||
|
(uint32)MeshData.Triangles[i + 2], 0 /*poly group*/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// LIVE EDIT — regenerate all chunks when params change in the Details panel
|
// LIVE EDIT — regenerate all chunks when params change in the Details panel
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
@@ -465,13 +510,14 @@ void AVoxelWorld::ProcessPendingChunks()
|
|||||||
LoadedTiles.Add(DequeuedChunk.Tile);
|
LoadedTiles.Add(DequeuedChunk.Tile);
|
||||||
|
|
||||||
// Empty mesh = all-air tile — nothing to render, but still "loaded".
|
// Empty mesh = all-air tile — nothing to render, but still "loaded".
|
||||||
if (DequeuedChunk.MeshData.IsEmpty())
|
if (DequeuedChunk.bEmpty || !DequeuedChunk.Streams)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply mesh (GPU upload) — this is the expensive part we budget.
|
// Apply mesh (GPU upload) — this is the budgeted part. The vertex/index buffers were
|
||||||
ApplyMeshToTile(DequeuedChunk.Tile, DequeuedChunk.MeshData);
|
// already built on the worker (T1.f); the game thread only uploads them here.
|
||||||
|
ApplyMeshToTile(DequeuedChunk.Tile, MoveTemp(*DequeuedChunk.Streams), DequeuedChunk.bIsCeiling);
|
||||||
MeshesApplied++;
|
MeshesApplied++;
|
||||||
|
|
||||||
if (MeshesApplied >= MaxApplies)
|
if (MeshesApplied >= MaxApplies)
|
||||||
@@ -781,14 +827,43 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile)
|
|||||||
FChunkResult Result;
|
FChunkResult Result;
|
||||||
Result.Tile = Tile;
|
Result.Tile = Tile;
|
||||||
Result.Epoch = TaskEpoch;
|
Result.Epoch = TaskEpoch;
|
||||||
|
|
||||||
|
FVoxelMeshData MeshData;
|
||||||
{
|
{
|
||||||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_GenerateMesh);
|
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_GenerateMesh);
|
||||||
Result.MeshData = Mesher->GenerateMesh(OriginVoxels, Step, Cells);
|
MeshData = Mesher->GenerateMesh(OriginVoxels, Step, Cells);
|
||||||
|
}
|
||||||
|
|
||||||
|
// T1.f — build the RMC geometry buffers HERE (worker), not on the game thread. Empty/all-air
|
||||||
|
// tiles carry no streams (Result.bEmpty stays true) → no component on apply.
|
||||||
|
if (!MeshData.IsEmpty())
|
||||||
|
{
|
||||||
|
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_BuildStreams);
|
||||||
|
Result.Streams = MakeShared<RealtimeMesh::FRealtimeMeshStreamSet>();
|
||||||
|
BuildTileStreamSet(*Result.Streams, MeshData);
|
||||||
|
Result.bEmpty = false;
|
||||||
|
|
||||||
|
// Classify ceiling from the ACTUAL mesh normals (smoothed density gradient, solid→air):
|
||||||
|
// a ceiling surface faces DOWN (N.Z < 0), ground faces UP. This is the rendered geometry,
|
||||||
|
// so it can't disagree with the view — unlike the old game-thread height-oracle sample,
|
||||||
|
// which misclassified coarse far tiles (terrain material on the sky-cap underside). Vote
|
||||||
|
// down-vs-up over the verts; near-vertical wall normals (|N.Z| small) abstain. The game
|
||||||
|
// thread gates this to SurfaceWorld strates before it actually swaps material / shadow.
|
||||||
|
// STOPGAP (fable-idea F17): orientation only works while NO CAVES EXIST — down == sky-cap.
|
||||||
|
// A future cave roof is also down-facing; distinguishing it needs a generator-stamped surface
|
||||||
|
// class (CeilSurf vs carve-below-TerrainZ) carried as a polygroup → material slot. See F17.
|
||||||
|
int32 DownVerts = 0, UpVerts = 0;
|
||||||
|
for (const FVector& N : MeshData.Normals)
|
||||||
|
{
|
||||||
|
if (N.Z < -0.1f) { ++DownVerts; }
|
||||||
|
else if (N.Z > 0.1f) { ++UpVerts; }
|
||||||
|
}
|
||||||
|
Result.bIsCeiling = (DownVerts > UpVerts);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!bShuttingDown.load(std::memory_order_relaxed))
|
if (!bShuttingDown.load(std::memory_order_relaxed))
|
||||||
{
|
{
|
||||||
ProcessQueue.Enqueue(Result);
|
ProcessQueue.Enqueue(MoveTemp(Result)); // move: don't copy the geometry payload
|
||||||
}
|
}
|
||||||
}, UE::Tasks::ETaskPriority::BackgroundNormal);
|
}, UE::Tasks::ETaskPriority::BackgroundNormal);
|
||||||
}
|
}
|
||||||
@@ -806,29 +881,24 @@ void AVoxelWorld::UnloadTile(const FVoxelTileKey& Tile)
|
|||||||
PendingTiles.Remove(Tile);
|
PendingTiles.Remove(Tile);
|
||||||
}
|
}
|
||||||
|
|
||||||
void AVoxelWorld::ApplyMeshToTile(const FVoxelTileKey& Tile, const FVoxelMeshData& MeshData)
|
void AVoxelWorld::ApplyMeshToTile(const FVoxelTileKey& Tile, RealtimeMesh::FRealtimeMeshStreamSet&& Streams, bool bGeomCeiling)
|
||||||
{
|
{
|
||||||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ApplyMeshToChunk);
|
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ApplyMeshToChunk);
|
||||||
|
|
||||||
if (MeshData.IsEmpty())
|
// Streams are pre-built on the worker (T1.f) and guaranteed non-empty by the caller
|
||||||
{
|
// (ProcessPendingChunks skips empty tiles). This path is now game-thread-CHEAP: O(1) classify +
|
||||||
// Became empty (e.g. fully carved) — drop any prior component for this tile.
|
// material + component get/create + the upload. No per-vertex work here anymore.
|
||||||
if (URealtimeMeshComponent** C = TileComponents.Find(Tile))
|
|
||||||
{
|
|
||||||
if (*C) { (*C)->DestroyComponent(); }
|
|
||||||
TileComponents.Remove(Tile);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bool bLevel0 = (Tile.Level == 0);
|
const bool bLevel0 = (Tile.Level == 0);
|
||||||
|
|
||||||
// SKY-CAP CEILING classification (computed once, used for BOTH shadow + material). A tile is a
|
// SKY-CAP CEILING classification (used for BOTH shadow + material). WHAT it is — down-facing geometry
|
||||||
// ceiling tile if its centre sits in the upper half of the open span between the ground surface and
|
// — is decided on the worker by voting the tile's ACTUAL mesh normals (bGeomCeiling); that's the
|
||||||
// the cap (the tile grid keeps ceiling tiles separate from ground tiles — they're far apart in Z).
|
// rendered surface, so it can't disagree with the view the way the old centre height-oracle sample did
|
||||||
// The oracle is O(1) and returns false for non-surface strates, so this is cheap at every level.
|
// (it misclassified coarse far tiles → terrain material on the sky-cap underside). WHETHER a tile may
|
||||||
|
// be a sky-cap ceiling at all is still gated to SurfaceWorld strates: one O(1) oracle probe at the tile
|
||||||
|
// centre (the oracle returns false for non-surface strates), so cave ceilings keep their prior material
|
||||||
|
// + shadow. The probed heights themselves are unused now — only the success/fail gate matters.
|
||||||
bool bIsCeiling = false;
|
bool bIsCeiling = false;
|
||||||
if (Generator)
|
if (Generator && bGeomCeiling)
|
||||||
{
|
{
|
||||||
const int32 VoxelsPerTile = CHUNK_SIZE << Tile.Level;
|
const int32 VoxelsPerTile = CHUNK_SIZE << Tile.Level;
|
||||||
const FIntVector MinVoxel = Tile.Coord * VoxelsPerTile;
|
const FIntVector MinVoxel = Tile.Coord * VoxelsPerTile;
|
||||||
@@ -838,11 +908,7 @@ void AVoxelWorld::ApplyMeshToTile(const FVoxelTileKey& Tile, const FVoxelMeshDat
|
|||||||
const int32 CenterChunkZ = FMath::FloorToInt(CenterZ / (float)CHUNK_SIZE);
|
const int32 CenterChunkZ = FMath::FloorToInt(CenterZ / (float)CHUNK_SIZE);
|
||||||
|
|
||||||
float TerrainZ = 0.0f, CeilSurf = 0.0f;
|
float TerrainZ = 0.0f, CeilSurf = 0.0f;
|
||||||
if (Generator->GetSurfaceHeightAt(CenterX, CenterY, CenterChunkZ, TerrainZ, CeilSurf))
|
bIsCeiling = Generator->GetSurfaceHeightAt(CenterX, CenterY, CenterChunkZ, TerrainZ, CeilSurf);
|
||||||
{
|
|
||||||
const float Mid = (TerrainZ + CeilSurf) * 0.5f;
|
|
||||||
bIsCeiling = (CenterZ > Mid);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Material: strate override (by the tile's min-corner chunk coord) else the global default. Ceiling
|
// Material: strate override (by the tile's min-corner chunk coord) else the global default. Ceiling
|
||||||
@@ -859,43 +925,8 @@ void AVoxelWorld::ApplyMeshToTile(const FVoxelTileKey& Tile, const FVoxelMeshDat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the geometry stream set. Vertices are world-space; the component sits at the actor origin.
|
// The geometry stream set was built on the worker (BuildTileStreamSet, T1.f); we just upload it.
|
||||||
RealtimeMesh::FRealtimeMeshStreamSet Streams;
|
// Vertices are world-space; the component sits at the actor origin.
|
||||||
{
|
|
||||||
RealtimeMesh::TRealtimeMeshBuilderLocal<uint32, FPackedNormal, FVector2DHalf, 1> Builder(Streams);
|
|
||||||
Builder.EnableTangents();
|
|
||||||
Builder.EnableTexCoords();
|
|
||||||
Builder.EnableColors(); // masques matériau F6 (palette biome / pente / fondu) — voir le mesher
|
|
||||||
Builder.EnablePolyGroups();
|
|
||||||
|
|
||||||
const int32 NumVertices = MeshData.Vertices.Num();
|
|
||||||
Builder.ReserveAdditionalVertices(NumVertices);
|
|
||||||
for (int32 i = 0; i < NumVertices; i++)
|
|
||||||
{
|
|
||||||
auto Vertex = Builder.AddVertex((FVector3f)MeshData.Vertices[i]);
|
|
||||||
if (MeshData.Normals.IsValidIndex(i))
|
|
||||||
{
|
|
||||||
Vertex.SetNormalAndTangent((FVector3f)MeshData.Normals[i], FVector3f(1, 0, 0));
|
|
||||||
}
|
|
||||||
if (MeshData.UVs.IsValidIndex(i))
|
|
||||||
{
|
|
||||||
Vertex.SetTexCoord(0, (FVector2f)MeshData.UVs[i]);
|
|
||||||
}
|
|
||||||
if (MeshData.Colors.IsValidIndex(i))
|
|
||||||
{
|
|
||||||
Vertex.SetColor(MeshData.Colors[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const int32 NumIndices = MeshData.Triangles.Num();
|
|
||||||
Builder.ReserveAdditionalTriangles(NumIndices / 3);
|
|
||||||
for (int32 i = 0; i < NumIndices; i += 3)
|
|
||||||
{
|
|
||||||
Builder.AddTriangle((uint32)MeshData.Triangles[i],
|
|
||||||
(uint32)MeshData.Triangles[i + 1],
|
|
||||||
(uint32)MeshData.Triangles[i + 2], 0 /*poly group*/);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// One component per tile — the clipmap keeps the total tile count low (~1-2k), so this is
|
// One component per tile — the clipmap keeps the total tile count low (~1-2k), so this is
|
||||||
// cheap on the game thread (no batching needed). Collision + content are level-0 only.
|
// cheap on the game thread (no batching needed). Collision + content are level-0 only.
|
||||||
@@ -955,6 +986,31 @@ int32 AVoxelWorld::GetStrateAtPosition(FVector WorldPosition) const
|
|||||||
return StrateManager->GetStrateIndex(WorldPosition.Z);
|
return StrateManager->GetStrateIndex(WorldPosition.Z);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FVoxelBiomeQuery AVoxelWorld::GetBiomeAtWorldLocation(FVector WorldLocation) const
|
||||||
|
{
|
||||||
|
FVoxelBiomeQuery Out;
|
||||||
|
if (!Generator) return Out;
|
||||||
|
|
||||||
|
// Bring the world point into actor-LOCAL voxel space — the SAME transform the decoration scatter
|
||||||
|
// applies (UpdateDecorations), so the probe agrees with where props actually land.
|
||||||
|
const FVector Local = GetActorTransform().InverseTransformPosition(WorldLocation);
|
||||||
|
const float VX = Local.X / VOXEL_SIZE;
|
||||||
|
const float VY = Local.Y / VOXEL_SIZE;
|
||||||
|
const int32 ChunkZ = FMath::FloorToInt((Local.Z / VOXEL_SIZE) / (float)CHUNK_SIZE);
|
||||||
|
|
||||||
|
Generator->QueryBiomeAt(VX, VY, ChunkZ, Out);
|
||||||
|
|
||||||
|
// Decoration streaming state of the region under this point — discriminates a render drop / empty
|
||||||
|
// march / stuck build / never-requested region for a visibly-bare patch (see FVoxelBiomeQuery).
|
||||||
|
if (ContentManager)
|
||||||
|
{
|
||||||
|
ContentManager->QueryDecoDebugAt(Local, Out.bDecoRegionApplied, Out.DecoAppliedInstances,
|
||||||
|
Out.bDecoRegionBuilding, Out.DecoCellsAccounted, Out.DecoCellsTotal,
|
||||||
|
Out.DecoLiveMarchSpawns, Out.DecoInstancesInCell);
|
||||||
|
}
|
||||||
|
return Out;
|
||||||
|
}
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// TERRAIN MODIFICATION — player carving & filling
|
// TERRAIN MODIFICATION — player carving & filling
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// VoxelBiomeDefinition.h
|
||||||
|
// Data asset defining one biome: where it lives (climate), how it reshapes the base
|
||||||
|
// terrain (modulation), and what content/atmosphere it brings.
|
||||||
|
//
|
||||||
|
// HOW TO USE:
|
||||||
|
// -----------
|
||||||
|
// 1. Content Browser → Miscellaneous → Data Asset → "VoxelBiomeDefinition".
|
||||||
|
// 2. Set its climate box (ReliefMin/Max, MoistureMin/Max), its terrain override, and its
|
||||||
|
// Decorations / atmosphere / water.
|
||||||
|
// 3. Add it to a UVoxelStrateDefinition's Biomes[] list.
|
||||||
|
//
|
||||||
|
// A biome is generator-agnostic: the same asset works inside any archetype strate
|
||||||
|
// (surface biomes today, cave biomes later). The strate's base archetype params are
|
||||||
|
// the baseline; this asset modulates on top.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
|
#include "Engine/DataAsset.h"
|
||||||
|
#include "GameplayTagContainer.h"
|
||||||
|
#include "VoxelBiomeTypes.h"
|
||||||
|
#include "VoxelStrateTypes.h" // FStrateDecoration / FStrateAmbientActor
|
||||||
|
#include "VoxelBiomeDefinition.generated.h"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UVoxelBiomeDefinition — one biome's identity, placement, terrain modulation and content.
|
||||||
|
*/
|
||||||
|
UCLASS(BlueprintType)
|
||||||
|
class VOXELFORGE_API UVoxelBiomeDefinition : public UPrimaryDataAsset
|
||||||
|
{
|
||||||
|
GENERATED_BODY()
|
||||||
|
|
||||||
|
public:
|
||||||
|
//=========================================================================
|
||||||
|
// IDENTITY
|
||||||
|
//=========================================================================
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Biome")
|
||||||
|
FText BiomeName;
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Biome", meta = (MultiLine = true))
|
||||||
|
FText BiomeDescription;
|
||||||
|
|
||||||
|
// Colour used by the 2D biome-preview bake to identify this biome's regions.
|
||||||
|
// Pick something distinct per biome so the baked map is readable.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome")
|
||||||
|
FLinearColor DebugColor = FLinearColor(0.5f, 0.5f, 0.5f, 1.0f);
|
||||||
|
|
||||||
|
//=========================================================================
|
||||||
|
// CLIMATE PLACEMENT
|
||||||
|
//=========================================================================
|
||||||
|
// A biome wins the Voronoi cells whose site climate (relief, moisture) falls inside
|
||||||
|
// — or, failing any match, nearest to — this box. Both axes are [0,1].
|
||||||
|
// Mountains / snow → high ReliefMin. Lush forest → high Moisture.
|
||||||
|
// Desert → low Moisture. Plains → mid relief, any moisture.
|
||||||
|
// Boxes may overlap; ties are broken deterministically per cell.
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Climate", meta = (ClampMin = "0.0", ClampMax = "1.0"))
|
||||||
|
float ReliefMin = 0.0f;
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Climate", meta = (ClampMin = "0.0", ClampMax = "1.0"))
|
||||||
|
float ReliefMax = 1.0f;
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Climate", meta = (ClampMin = "0.0", ClampMax = "1.0"))
|
||||||
|
float MoistureMin = 0.0f;
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Climate", meta = (ClampMin = "0.0", ClampMax = "1.0"))
|
||||||
|
float MoistureMax = 1.0f;
|
||||||
|
|
||||||
|
//=========================================================================
|
||||||
|
// TERRAIN OVERRIDE — a biome is a "mini-strate-variant"
|
||||||
|
//=========================================================================
|
||||||
|
// When enabled, this biome supplies its OWN archetype params for its regions,
|
||||||
|
// replacing the strate's. Heightfield archetypes (SurfaceWorld) blend the output
|
||||||
|
// across biome borders, so even frequency/shape differences stay seamless.
|
||||||
|
// Global/structural fields (strate Z bounds, boundary seal, base density, water
|
||||||
|
// level) are always forced from the strate — a biome can't break seals/connectivity.
|
||||||
|
//
|
||||||
|
// GeneratorType must match the strate this biome is used in; if it doesn't, the
|
||||||
|
// override is ignored and the strate's params are used. (Currently wired: SurfaceWorld.
|
||||||
|
// Other archetypes' param overrides arrive as each is hooked up — see CODEMAP §8.14.)
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Terrain")
|
||||||
|
bool bOverrideTerrain = false;
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Terrain",
|
||||||
|
meta = (EditCondition = "bOverrideTerrain"))
|
||||||
|
ECaveGeneratorType GeneratorType = ECaveGeneratorType::SurfaceWorld;
|
||||||
|
|
||||||
|
// Open-sky terrain override (used when bOverrideTerrain && GeneratorType == SurfaceWorld).
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Terrain",
|
||||||
|
meta = (EditCondition = "bOverrideTerrain && GeneratorType == ECaveGeneratorType::SurfaceWorld"))
|
||||||
|
FSurfaceGenerationParams SurfaceParams;
|
||||||
|
|
||||||
|
//=========================================================================
|
||||||
|
// CONTENT PROFILE (consumed by the content / atmosphere managers)
|
||||||
|
//=========================================================================
|
||||||
|
|
||||||
|
// Decorations placed in this biome's regions. When set, these REPLACE the strate's
|
||||||
|
// decorations for chunks whose dominant biome is this one (falls back to the strate
|
||||||
|
// list when empty).
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Content")
|
||||||
|
TArray<FStrateDecoration> Decorations;
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Content")
|
||||||
|
TArray<FStrateAmbientActor> AmbientActors;
|
||||||
|
|
||||||
|
//=========================================================================
|
||||||
|
// ATMOSPHERE OVERRIDE (optional — beats the strate's atmosphere for this biome)
|
||||||
|
//=========================================================================
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Atmosphere")
|
||||||
|
bool bOverrideAtmosphere = false;
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Atmosphere", meta = (EditCondition = "bOverrideAtmosphere"))
|
||||||
|
FLinearColor FogColor = FLinearColor(0.05f, 0.05f, 0.1f, 1.0f);
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Atmosphere", meta = (EditCondition = "bOverrideAtmosphere", ClampMin = "0.0", ClampMax = "1.0"))
|
||||||
|
float FogDensity = 0.3f;
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Atmosphere", meta = (EditCondition = "bOverrideAtmosphere"))
|
||||||
|
FLinearColor AmbientLightColor = FLinearColor(0.1f, 0.1f, 0.15f, 1.0f);
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Atmosphere", meta = (EditCondition = "bOverrideAtmosphere", ClampMin = "0.0"))
|
||||||
|
float AmbientLightIntensity = 0.5f;
|
||||||
|
|
||||||
|
//=========================================================================
|
||||||
|
// WATER
|
||||||
|
//=========================================================================
|
||||||
|
|
||||||
|
// Water material override for this biome (null = use the strate's WaterMaterial).
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Water")
|
||||||
|
UMaterialInterface* WaterMaterial = nullptr;
|
||||||
|
|
||||||
|
//=========================================================================
|
||||||
|
// MATERIAL — F6 vertex-colour triplanar palette
|
||||||
|
//=========================================================================
|
||||||
|
// Which palette layer this biome's terrain uses in the master triplanar material.
|
||||||
|
// It is baked into the mesh vertex colour (R channel) at mesh time, so ONE material
|
||||||
|
// re-skins the terrain per biome and cross-fades across biome borders (the neighbour
|
||||||
|
// biome's index + a blend weight ride in the A/B channels). The terrain material is
|
||||||
|
// still the strate's OverrideMaterial / Settings->VoxelMaterial — author it as the
|
||||||
|
// master palette material and switch its layers on this index. 0 = default layer.
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Material", meta = (ClampMin = "0", ClampMax = "255"))
|
||||||
|
int32 MaterialPaletteIndex = 0;
|
||||||
|
|
||||||
|
//=========================================================================
|
||||||
|
// GAMEPLAY TAGS
|
||||||
|
//=========================================================================
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Gameplay")
|
||||||
|
FGameplayTagContainer GameplayTags;
|
||||||
|
};
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
// VoxelBiomeTypes.h
|
||||||
|
// Shared vocabulary for the biome system (Stage 1).
|
||||||
|
//
|
||||||
|
// A biome is a "mini-strate-variant": it can carry a FULL archetype param override
|
||||||
|
// (e.g. its own FSurfaceGenerationParams) plus a content profile, placed across the
|
||||||
|
// world by a deterministic, window-invariant XY field (warped Voronoi + climate).
|
||||||
|
//
|
||||||
|
// Resolution discipline (protects CODEMAP §8.4 + §8.10):
|
||||||
|
// - The biome assigned at any XY is a PURE function of world coords + seed + the
|
||||||
|
// strate's biome context. No dependence on the chunk window.
|
||||||
|
// - Heightfield archetypes (Surface) resolve dominant + neighbour biome per voxel and
|
||||||
|
// blend the OUTPUT height — so ANY param difference (even frequencies) stays seamless,
|
||||||
|
// which per-param blending could never do. The expensive climate classification is
|
||||||
|
// cached once per chunk; per voxel is just a warp + 3x3 lookup.
|
||||||
|
// - Structural/global fields (Z bounds, seal, base density, water level) are always
|
||||||
|
// forced from the STRATE so seals / spine / passages / water plane stay intact.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
|
#include "VoxelBiomeTypes.generated.h"
|
||||||
|
|
||||||
|
class UVoxelBiomeDefinition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FBiomeMapParams — controls the world-XY biome field: how big biome regions are,
|
||||||
|
* how organic their borders look, and the two climate fields that ASSIGN a biome to
|
||||||
|
* each Voronoi cell. Lives on UVoxelStrateDefinition (one map per strate).
|
||||||
|
*/
|
||||||
|
USTRUCT(BlueprintType)
|
||||||
|
struct VOXELFORGE_API FBiomeMapParams
|
||||||
|
{
|
||||||
|
GENERATED_BODY()
|
||||||
|
|
||||||
|
// Average biome cell size in voxels. Larger = bigger, sweeping biome regions.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Map", meta = (ClampMin = "32.0"))
|
||||||
|
float CellSize = 800.0f;
|
||||||
|
|
||||||
|
// Domain-warp the cell lookup by up to this many voxels → organic, non-hexagonal
|
||||||
|
// borders. 0 = raw Voronoi cells (straight-ish borders).
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Map", meta = (ClampMin = "0.0"))
|
||||||
|
float WarpStrength = 250.0f;
|
||||||
|
|
||||||
|
// Frequency of the border-warp noise. Lower = broader, sweeping border bends.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Map")
|
||||||
|
float WarpFrequency = 0.0018f;
|
||||||
|
|
||||||
|
// Width (voxels) of the smooth blend band between neighbouring biomes. The density
|
||||||
|
// scalars cross-fade across this band so there is no hard wall at a biome border.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Map", meta = (ClampMin = "0.0"))
|
||||||
|
float BorderBlend = 120.0f;
|
||||||
|
|
||||||
|
// ----- Climate fields (assign each cell its biome) -----
|
||||||
|
//
|
||||||
|
// IMPORTANT: for COHERENT geography (mountain ranges, desert regions — not confetti)
|
||||||
|
// the climate must vary much more SLOWLY than CellSize: aim for ~4-6 cells per climate
|
||||||
|
// feature, i.e. ClimateFrequency ≈ 1 / (5 * CellSize). If the climate wavelength is
|
||||||
|
// near or below CellSize, neighbouring cells sample unrelated climate → salt-and-pepper.
|
||||||
|
|
||||||
|
// Relief ("elevation") field, [0,1]. Mountain / snow biomes live where this is high.
|
||||||
|
// Default tuned for CellSize≈800 (wavelength ~3300 ≈ 4 cells). Lower → bigger regions.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Climate")
|
||||||
|
float ReliefFrequency = 0.0003f;
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Climate", meta = (ClampMin = "0.25", ClampMax = "4.0"))
|
||||||
|
float ReliefContrast = 2.0f;
|
||||||
|
|
||||||
|
// Moisture field, [0,1] — the second climate axis (wet lowlands vs arid). Same
|
||||||
|
// coherence rule as relief: keep the wavelength several cells wide.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Biome|Climate")
|
||||||
|
float MoistureFrequency = 0.0004f;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel selector for the 2D biome-preview bake (AVoxelWorld::BakeBiomePreview).
|
||||||
|
*/
|
||||||
|
UENUM(BlueprintType)
|
||||||
|
enum class EBiomePreviewChannel : uint8
|
||||||
|
{
|
||||||
|
Biome UMETA(DisplayName = "Biome (debug colours)"),
|
||||||
|
Relief UMETA(DisplayName = "Relief / elevation field"),
|
||||||
|
Moisture UMETA(DisplayName = "Moisture field")
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FVoxelBiomeQuery — a Blueprint-readable snapshot of the biome field at one world point.
|
||||||
|
* Returned by AVoxelWorld::GetBiomeAtWorldLocation so BP can read "which biome is under the
|
||||||
|
* cursor" — the SAME resolution the decoration scatter uses per column. DecorationCount is the
|
||||||
|
* effective number of decorations the dominant biome would place (its own list, or the strate's
|
||||||
|
* when empty): 0 here explains a "band of nothing" — that biome simply has no decorations.
|
||||||
|
*/
|
||||||
|
USTRUCT(BlueprintType)
|
||||||
|
struct VOXELFORGE_API FVoxelBiomeQuery
|
||||||
|
{
|
||||||
|
GENERATED_BODY()
|
||||||
|
|
||||||
|
// False when the point's strate has no biome field (then only the climate fields are filled).
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category = "Biome") bool bHasBiomes = false;
|
||||||
|
|
||||||
|
// Dominant biome asset at this point (what a decoration column here primarily uses). May be null.
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category = "Biome") UVoxelBiomeDefinition* DominantBiome = nullptr;
|
||||||
|
|
||||||
|
// Nearest neighbouring biome (the one a border blend fades toward). May be null / same as dominant.
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category = "Biome") UVoxelBiomeDefinition* NeighborBiome = nullptr;
|
||||||
|
|
||||||
|
// Dominant biome display name (falls back to the asset name when BiomeName is unset).
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category = "Biome") FText DominantName;
|
||||||
|
|
||||||
|
// Dominant biome's preview/debug colour (handy to tint a BP debug draw to match the bake).
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category = "Biome") FLinearColor DebugColor = FLinearColor::Black;
|
||||||
|
|
||||||
|
// Border blend weight toward the neighbour: 0 deep inside a cell → ~0.5 at the shared border.
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category = "Biome") float NeighborWeight = 0.0f;
|
||||||
|
|
||||||
|
// Climate fields at this XY, both [0,1] (always filled, even when bHasBiomes is false).
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category = "Biome") float Relief = 0.0f;
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category = "Biome") float Moisture = 0.0f;
|
||||||
|
|
||||||
|
// Effective decoration count for the dominant biome (its list, else the strate's). 0 ⇒ empty band.
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category = "Biome") int32 DominantDecorationCount = 0;
|
||||||
|
|
||||||
|
// Dominant biome's index in the strate's biome context (matches the decoration column tag). -1 = none.
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category = "Biome") int32 DominantContextIndex = -1;
|
||||||
|
|
||||||
|
//--- DECORATION STREAMING STATE of the region covering this point (debug "why is this chunk blank?") ---
|
||||||
|
// Discriminates the three failure modes for a visibly-bare patch:
|
||||||
|
// • bDecoRegionApplied && DecoAppliedInstances > 0 → instances EXIST + are uploaded; if still not
|
||||||
|
// visible it is a RENDER drop (proxy/culling), not data/streaming.
|
||||||
|
// • bDecoRegionApplied && DecoAppliedInstances == 0 → the region applied but the per-column MARCH
|
||||||
|
// produced no spawns here (config/determinism), not a render or streaming bug.
|
||||||
|
// • bDecoRegionBuilding (not applied) → region is STILL marching; if it never finishes
|
||||||
|
// (DecoCellsAccounted stuck < DecoCellsTotal) it is a streaming/accounting leak.
|
||||||
|
// • neither flag set → region was never requested (desired-set / range).
|
||||||
|
|
||||||
|
// True if the region covering this XY is in DecoRegions (applied; HISMs built).
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category = "Biome|Deco") bool bDecoRegionApplied = false;
|
||||||
|
// Total HISM instances actually uploaded across the applied region's components.
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category = "Biome|Deco") int32 DecoAppliedInstances = 0;
|
||||||
|
// True if the region is in RegionBuilds (cells still being marched / merged, not yet applied).
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category = "Biome|Deco") bool bDecoRegionBuilding = false;
|
||||||
|
// Cells accounted vs the region's total (R*R). Building but accounted < total = in progress; if it
|
||||||
|
// never reaches total the region is stuck (the permanent-blank-until-regen signature).
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category = "Biome|Deco") int32 DecoCellsAccounted = 0;
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category = "Biome|Deco") int32 DecoCellsTotal = 0;
|
||||||
|
|
||||||
|
// PER-CELL decisive probe: re-runs the decoration march for the EXACT cell (chunk footprint) under
|
||||||
|
// this point, synchronously, with the current strate context — i.e. how many spawns the deterministic
|
||||||
|
// scatter produces here RIGHT NOW. Region state above is R*R-cell coarse and can't see a single blank
|
||||||
|
// cell; this can. On a visibly-BLANK cell: >0 ⇒ the march works, the spawns were lost in merge/apply or
|
||||||
|
// not drawn (streaming/render bug); 0 ⇒ the march genuinely makes nothing here (data/config, or — if a
|
||||||
|
// regen brings grass back at the same spot — a non-determinism bug). -1 ⇒ couldn't run (no strate
|
||||||
|
// context / point not in the player's current strate).
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category = "Biome|Deco") int32 DecoLiveMarchSpawns = -1;
|
||||||
|
|
||||||
|
// FINAL discriminator, paired with DecoLiveMarchSpawns. Counts the applied region's HISM instances
|
||||||
|
// that actually fall inside THIS cell's footprint (not the whole region). On a blank cell where the
|
||||||
|
// live march says N>0:
|
||||||
|
// • DecoInstancesInCell ≈ N → the instances ARE uploaded here but not visible → RENDER drop.
|
||||||
|
// • DecoInstancesInCell ≈ 0 → the march's spawns never reached the HISM → MERGE/apply loss.
|
||||||
|
UPROPERTY(BlueprintReadOnly, Category = "Biome|Deco") int32 DecoInstancesInCell = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// RUNTIME STRUCTS (plain C++ — not USTRUCT; live in the thread-local hot path)
|
||||||
|
//=============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One biome flattened from its asset for fast, asset-free evaluation. The biome field
|
||||||
|
* and the per-chunk cache only ever see these PODs — never a UObject — so they stay
|
||||||
|
* cheap to copy and safe to touch from worker threads.
|
||||||
|
*/
|
||||||
|
struct FBiomeResolved
|
||||||
|
{
|
||||||
|
// Index back into the owning strate's Biomes[] array (for param / content lookup).
|
||||||
|
int32 Index = 0;
|
||||||
|
|
||||||
|
// Climate placement box in (relief, moisture) space, both [0,1].
|
||||||
|
float ReliefMin = 0.0f, ReliefMax = 1.0f;
|
||||||
|
float MoistureMin = 0.0f, MoistureMax = 1.0f;
|
||||||
|
|
||||||
|
// Colour used by the 2D preview bake.
|
||||||
|
FColor DebugColor = FColor::White;
|
||||||
|
|
||||||
|
// Palette layer this biome's terrain uses in the master triplanar material (F6).
|
||||||
|
// Baked into the mesh vertex colour so one material can re-skin per biome. 0 = default.
|
||||||
|
int32 MaterialPaletteIndex = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the biome field needs for one strate. Built once by StrateManager
|
||||||
|
* (GetBiomeContextForChunk) from the strate definition. Empty Biomes ⇒ biomes
|
||||||
|
* disabled for this strate (legacy behaviour, bit-identical output).
|
||||||
|
*/
|
||||||
|
struct FBiomeContext
|
||||||
|
{
|
||||||
|
TArray<FBiomeResolved> Biomes;
|
||||||
|
FBiomeMapParams Map;
|
||||||
|
|
||||||
|
bool IsValid() const { return Biomes.Num() > 0; }
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a single biome query at a world XY: the dominant biome cell, the nearest
|
||||||
|
* neighbour cell, and a blend weight toward that neighbour (0 deep inside a cell,
|
||||||
|
* → 0.5 at the shared border). Indices are positions into FBiomeContext::Biomes.
|
||||||
|
*/
|
||||||
|
struct FBiomeSample
|
||||||
|
{
|
||||||
|
int32 DominantIndex = -1;
|
||||||
|
int32 NeighborIndex = -1;
|
||||||
|
float NeighborWeight = 0.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-chunk biome cache for the density hot path. The EXPENSIVE part of a biome query
|
||||||
|
* (classifying each Voronoi cell by its climate — several noise samples per cell) is
|
||||||
|
* done ONCE here, into a small grid covering the chunk footprint + margin; per voxel
|
||||||
|
* the resolver then only warps + does a cheap 3x3 lookup + blend.
|
||||||
|
*
|
||||||
|
* VALIDITY IS A WORLD-XY BOX (+ ChunkZ + Seed), NOT a chunk key (CODEMAP §8.10). The
|
||||||
|
* grid covers a halo beyond the chunk, so gradient-normal samples and the +X/+Y chunk
|
||||||
|
* boundary corners stay inside the valid box and DO NOT thrash the noise-heavy rebuild.
|
||||||
|
* The box logic is identical in spirit to the SDF cache in GetDensityWithParams.
|
||||||
|
*/
|
||||||
|
struct FChunkBiomeCache
|
||||||
|
{
|
||||||
|
// World-XY box (voxel coords) over which the cached grid answers correctly.
|
||||||
|
float ValidMinX = 1.0f, ValidMaxX = -1.0f; // start invalid (min > max)
|
||||||
|
float ValidMinY = 0.0f, ValidMaxY = 0.0f;
|
||||||
|
int32 ChunkZ = MIN_int32; // which strate slice this was built for
|
||||||
|
int32 Seed = MIN_int32;
|
||||||
|
bool bActive = false; // does this strate have biomes?
|
||||||
|
|
||||||
|
FBiomeContext Ctx; // resolved biomes + map (for blending)
|
||||||
|
|
||||||
|
// Cell grid (row-major, CellsX × CellsY), each entry = biome index into Ctx.Biomes.
|
||||||
|
int32 BaseCellX = 0, BaseCellY = 0, CellsX = 0, CellsY = 0;
|
||||||
|
TArray<int32> CellBiome;
|
||||||
|
|
||||||
|
bool Contains(float X, float Y, int32 InChunkZ, int32 InSeed) const
|
||||||
|
{
|
||||||
|
return InSeed == Seed && InChunkZ == ChunkZ
|
||||||
|
&& X >= ValidMinX && X <= ValidMaxX
|
||||||
|
&& Y >= ValidMinY && Y <= ValidMaxY;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
#include "Containers/Queue.h"
|
#include "Containers/Queue.h"
|
||||||
#include "VoxelTypes.h"
|
#include "VoxelTypes.h"
|
||||||
#include "VoxelStrateTypes.h" // FStrateDecoration (resolved per dominant biome)
|
#include "VoxelStrateTypes.h" // FStrateDecoration (resolved per dominant biome)
|
||||||
|
#include "VoxelBiomeTypes.h" // FBiomeContext (per-column biome resolve on the worker)
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include "VoxelContentManager.generated.h"
|
#include "VoxelContentManager.generated.h"
|
||||||
|
|
||||||
@@ -81,6 +82,14 @@ public:
|
|||||||
* epoch so any in-flight march tasks' results are discarded. */
|
* epoch so any in-flight march tasks' results are discarded. */
|
||||||
void ClearAll();
|
void ClearAll();
|
||||||
|
|
||||||
|
/** DIAGNOSTIC: report the decoration streaming state of the region covering an actor-LOCAL XY, so a
|
||||||
|
* line-trace probe can tell apart a render drop / an empty march / a stuck build / a never-requested
|
||||||
|
* region for a visibly-bare patch. LocalPos is in actor-local cm (the caller undoes the actor xf).
|
||||||
|
* Game-thread only (reads DecoRegions / RegionBuilds). */
|
||||||
|
void QueryDecoDebugAt(const FVector& LocalPos, bool& bApplied, int32& InstanceCount,
|
||||||
|
bool& bBuilding, int32& CellsAccounted, int32& CellsTotal,
|
||||||
|
int32& LiveMarchSpawns, int32& InstancesInCell) const;
|
||||||
|
|
||||||
virtual void BeginDestroy() override; // flag shutdown so in-flight march tasks don't touch us
|
virtual void BeginDestroy() override; // flag shutdown so in-flight march tasks don't touch us
|
||||||
|
|
||||||
/** Flag shutdown + block until in-flight march tasks drain. Call from AVoxelWorld::EndPlay BEFORE
|
/** Flag shutdown + block until in-flight march tasks drain. Call from AVoxelWorld::EndPlay BEFORE
|
||||||
@@ -135,6 +144,12 @@ private:
|
|||||||
TArray<FRegionActorSpawn> ActorSpawns;
|
TArray<FRegionActorSpawn> ActorSpawns;
|
||||||
int32 CellsRemaining = 0; // cells still to account for before this region can apply
|
int32 CellsRemaining = 0; // cells still to account for before this region can apply
|
||||||
uint32 BuildId = 0; // unique, monotonic — stale in-flight cell results fail to match
|
uint32 BuildId = 0; // unique, monotonic — stale in-flight cell results fail to match
|
||||||
|
// Cells already counted toward completion. A cell can spawn TWO worker tasks (a stale cell from a
|
||||||
|
// discarded build re-enters range, gets re-enqueued, and launches again once its first task frees
|
||||||
|
// the InFlightCells slot). Accounting per-cell here (not a blind --CellsRemaining) makes completion
|
||||||
|
// IDEMPOTENT so the second task can't double-decrement and apply the region before every cell has
|
||||||
|
// actually reported — which left a permanent empty chunk until a regen re-marched it.
|
||||||
|
TSet<FIntPoint> AccountedCells;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Constant per-update strate context (a strate is a horizontal slab → same for every cell). Carries
|
// Constant per-update strate context (a strate is a horizontal slab → same for every cell). Carries
|
||||||
@@ -148,21 +163,29 @@ private:
|
|||||||
float WaterLocalZ = -FLT_MAX; // water surface, actor-local cm (-FLT_MAX = no water)
|
float WaterLocalZ = -FLT_MAX; // water surface, actor-local cm (-FLT_MAX = no water)
|
||||||
bool bHasWater = false;
|
bool bHasWater = false;
|
||||||
bool bSurfaceWorld = false; // heightfield archetype → use the GetSurfaceHeightAt oracle
|
bool bSurfaceWorld = false; // heightfield archetype → use the GetSurfaceHeightAt oracle
|
||||||
|
|
||||||
|
// Strate biome field (PODs only → worker-safe). Empty ⇒ biomes disabled for this strate. The
|
||||||
|
// worker resolves the dominant biome PER COLUMN (ResolveBiomeSampleAt) so decoration borders
|
||||||
|
// follow the warped-Voronoi field instead of snapping to the chunk-footprint cell grid (§8.5).
|
||||||
|
FBiomeContext BiomeCtx;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** WORKER-THREAD surface find → fills OutSpawns for one cell. SurfaceWorld uses the height oracle
|
/** WORKER-THREAD surface find → fills OutSpawns for one cell. SurfaceWorld uses the height oracle
|
||||||
* (cheap, O(1)/column); other archetypes ray-march the density column. No UObject access except
|
* (cheap, O(1)/column); other archetypes ray-march the density column. No UObject access except
|
||||||
* Generator (thread-safe). Determinism-critical. */
|
* Generator (thread-safe). Determinism-critical. Resolves the dominant biome PER COLUMN
|
||||||
|
* (ResolveBiomeSampleAt via Ctx.BiomeCtx) and rolls only the entries that biome owns — EntryBiome[i]
|
||||||
|
* is the context-biome index for Entries[i] (-1 = strate fallback, always matches). */
|
||||||
static void BuildCellSpawns(const UVoxelGenerator* Gen, const FTransform& OwnerXf,
|
static void BuildCellSpawns(const UVoxelGenerator* Gen, const FTransform& OwnerXf,
|
||||||
const FIntPoint& Cell, const FDecoContext& Ctx,
|
const FIntPoint& Cell, const FDecoContext& Ctx,
|
||||||
const TArray<FStrateDecoration>& Entries, uint32 InSeed,
|
const TArray<FStrateDecoration>& Entries,
|
||||||
|
const TArray<int32>& EntryBiome, uint32 InSeed,
|
||||||
int32 Spacing, float Step, int32 MaxCrossings, float ColumnDepth,
|
int32 Spacing, float Step, int32 MaxCrossings, float ColumnDepth,
|
||||||
TArray<FDecoSpawn>& OutSpawns);
|
TArray<FDecoSpawn>& OutSpawns);
|
||||||
|
|
||||||
void LaunchDecoTasks(const FIntPoint& PlayerCell);
|
void LaunchDecoTasks(const FIntPoint& PlayerCell);
|
||||||
void ProcessDecoResults(const FIntPoint& PlayerCell, int32 FarR);
|
void ProcessDecoResults(const FIntPoint& PlayerCell, int32 FarR);
|
||||||
void MergeCellResult(const FDecoCellResult& Result); // fold one cell's spawns into its region build
|
void MergeCellResult(const FDecoCellResult& Result); // fold one cell's spawns into its region build
|
||||||
void MarkCellDone(const FIntPoint& Region, uint32 BuildId); // decrement region's remaining-cell count
|
void MarkCellDone(const FIntPoint& Region, const FIntPoint& Cell, uint32 BuildId); // idempotent per-cell accounting
|
||||||
void ApplyRegion(const FIntPoint& Region, FDecoRegionBuild& Build);
|
void ApplyRegion(const FIntPoint& Region, FDecoRegionBuild& Build);
|
||||||
void RebuildDesiredCells(const FIntPoint& PlayerCell);
|
void RebuildDesiredCells(const FIntPoint& PlayerCell);
|
||||||
void ClearDecorationRegion(const FIntPoint& Region);
|
void ClearDecorationRegion(const FIntPoint& Region);
|
||||||
@@ -221,6 +244,14 @@ private:
|
|||||||
// copies the PODs into each task).
|
// copies the PODs into each task).
|
||||||
FDecoContext CurrentCtx;
|
FDecoContext CurrentCtx;
|
||||||
|
|
||||||
|
// Decoration palette for the current update, built ONCE (a strate's biome field is XY-global, so the
|
||||||
|
// flat list is the same for every cell — only the per-COLUMN biome pick varies). CurrentEntries is the
|
||||||
|
// concatenation of every biome's decoration list (or the strate's when a biome has none / biomes are
|
||||||
|
// disabled); CurrentEntryBiome[i] is the context-biome index that owns entry i (-1 = strate fallback,
|
||||||
|
// always matches). The worker resolves a column's dominant biome and rolls only the entries it owns.
|
||||||
|
TArray<FStrateDecoration> CurrentEntries;
|
||||||
|
TArray<int32> CurrentEntryBiome;
|
||||||
|
|
||||||
// Single strate-global ocean plane, repositioned to follow the player (see UpdateWater).
|
// Single strate-global ocean plane, repositioned to follow the player (see UpdateWater).
|
||||||
UPROPERTY()
|
UPROPERTY()
|
||||||
UStaticMeshComponent* WaterPlane = nullptr;
|
UStaticMeshComponent* WaterPlane = nullptr;
|
||||||
|
|||||||
@@ -173,6 +173,14 @@ public:
|
|||||||
*/
|
*/
|
||||||
const UVoxelBiomeDefinition* GetDominantBiomeAt(float WorldX, float WorldY, int32 ChunkZ) const;
|
const UVoxelBiomeDefinition* GetDominantBiomeAt(float WorldX, float WorldY, int32 ChunkZ) const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rich biome probe at a world XY for a strate slice (ChunkZ): dominant + neighbour biome assets,
|
||||||
|
* climate fields, border blend weight, and the dominant biome's EFFECTIVE decoration count. Mirrors
|
||||||
|
* exactly what the decoration scatter resolves per column, so it is a faithful "what's under here?"
|
||||||
|
* diagnostic (DominantDecorationCount == 0 explains an empty biome region). Game-thread, uncached.
|
||||||
|
*/
|
||||||
|
void QueryBiomeAt(float WorldX, float WorldY, int32 ChunkZ, FVoxelBiomeQuery& Out) const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-vertex material data for the master triplanar palette material (F6). Resolves the
|
* Per-vertex material data for the master triplanar palette material (F6). Resolves the
|
||||||
* biome field at a world XY/Z and returns the dominant + neighbour MaterialPaletteIndex
|
* biome field at a world XY/Z and returns the dominant + neighbour MaterialPaletteIndex
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
// VoxelNoise.h
|
||||||
|
// Float, SIMD-batched gradient-noise core (T2.a). Replaces UE's double-precision
|
||||||
|
// FMath::PerlinNoise3D on the density hot path (~6.6 ms/chunk was all noise math).
|
||||||
|
//
|
||||||
|
// WHY THIS EXISTS
|
||||||
|
// FMath::PerlinNoise3D is double-precision with a permutation-table lookup. The
|
||||||
|
// density field calls it >1M times per surface chunk (fBm octaves + domain warps).
|
||||||
|
// This core is:
|
||||||
|
// - FLOAT (no double math),
|
||||||
|
// - table-free hash-gradient Perlin (pure arithmetic → vectorizes cleanly),
|
||||||
|
// - SIMD-BATCHED across fBm octaves: one FractalNoise/Ridged call evaluates up to
|
||||||
|
// 4 octaves' Perlin samples in one 4-wide SSE pass.
|
||||||
|
//
|
||||||
|
// It is a DIFFERENT noise field than FMath's, so worlds re-tune ONCE (accepted).
|
||||||
|
//
|
||||||
|
// DETERMINISM / CACHES
|
||||||
|
// Perlin3D is a pure function of (x,y,z) exactly like the old call — every box-validity
|
||||||
|
// cache (SDF §8.10, biome, surface column) stays valid. No invariant changes.
|
||||||
|
//
|
||||||
|
// SCALAR vs SIMD
|
||||||
|
// Perlin3D (scalar) and Perlin3D_x4 (SSE) use the IDENTICAL formula, op-for-op, so on
|
||||||
|
// x86 (SSE math == scalar-float math, same IEEE rounding) they produce bit-identical
|
||||||
|
// results. The SSE path is therefore a pure speedup with no second re-tune. If it ever
|
||||||
|
// fails to build on a given toolchain, force the scalar fallback with one line:
|
||||||
|
// #define VF_NOISE_USE_SIMD 0 // (before including this header, or here)
|
||||||
|
// The scalar path alone is still a large win over the old double-precision core.
|
||||||
|
//
|
||||||
|
// REQUIRES: SSE4.1 for the SIMD path (UE5 x64 baseline is SSE4.2 → always available).
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CoreMinimal.h"
|
||||||
|
|
||||||
|
#if !defined(VF_NOISE_USE_SIMD)
|
||||||
|
#if PLATFORM_ENABLE_VECTORINTRINSICS && PLATFORM_CPU_X86_FAMILY
|
||||||
|
#define VF_NOISE_USE_SIMD 1
|
||||||
|
#else
|
||||||
|
#define VF_NOISE_USE_SIMD 0
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if VF_NOISE_USE_SIMD
|
||||||
|
#include <immintrin.h> // SSE4.1: _mm_floor_ps / _mm_mullo_epi32 / _mm_blendv_ps
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace VoxelNoise
|
||||||
|
{
|
||||||
|
namespace Detail
|
||||||
|
{
|
||||||
|
// Table-free integer hash of a lattice corner → gradient selector. Pure mul/xor/shift
|
||||||
|
// so it vectorizes 1:1 (see the SSE Hash lambda below — must stay in lock-step).
|
||||||
|
FORCEINLINE uint32 HashCorner(int32 ix, int32 iy, int32 iz)
|
||||||
|
{
|
||||||
|
uint32 h = (uint32)ix * 0x9E3779B1u
|
||||||
|
^ (uint32)iy * 0x85EBCA77u
|
||||||
|
^ (uint32)iz * 0xC2B2AE3Du;
|
||||||
|
h ^= h >> 15; h *= 0x2C1B3C6Du;
|
||||||
|
h ^= h >> 12; h *= 0x297A2D39u;
|
||||||
|
h ^= h >> 15;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quintic fade 6t^5-15t^4+10t^3, factored as t^3 * (t*(6t-15)+10) so the SSE twin
|
||||||
|
// can mirror the exact grouping.
|
||||||
|
FORCEINLINE float Fade(float t)
|
||||||
|
{
|
||||||
|
const float inner = t * (t * 6.0f - 15.0f) + 10.0f;
|
||||||
|
const float t3 = t * t * t;
|
||||||
|
return t3 * inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ken Perlin's 12-gradient dot (hash&15 picks the gradient). Branchy form here;
|
||||||
|
// the SSE twin reproduces it with selects.
|
||||||
|
FORCEINLINE float GradDot(uint32 hash, float x, float y, float z)
|
||||||
|
{
|
||||||
|
const uint32 h = hash & 15u;
|
||||||
|
const float u = (h & 8u) == 0u ? x : y;
|
||||||
|
float v;
|
||||||
|
if ((h & 12u) == 0u) v = y; // h < 4
|
||||||
|
else if ((h & 13u) == 12u) v = x; // h == 12 or 14
|
||||||
|
else v = z;
|
||||||
|
const float ru = (h & 1u) == 0u ? u : -u;
|
||||||
|
const float rv = (h & 2u) == 0u ? v : -v;
|
||||||
|
return ru + rv;
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE float Lerp(float a, float b, float t) { return a + t * (b - a); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single 3D Perlin sample, ~[-1,1] (typically [-0.7,0.7], same character as the old core
|
||||||
|
// so VOXEL_NOISE_SCALE still applies). Used for all single-sample domain warps.
|
||||||
|
FORCEINLINE float Perlin3D(float x, float y, float z)
|
||||||
|
{
|
||||||
|
using namespace Detail;
|
||||||
|
const float xf = FMath::FloorToFloat(x);
|
||||||
|
const float yf = FMath::FloorToFloat(y);
|
||||||
|
const float zf = FMath::FloorToFloat(z);
|
||||||
|
const int32 X = (int32)xf, Y = (int32)yf, Z = (int32)zf;
|
||||||
|
const float fx = x - xf, fy = y - yf, fz = z - zf;
|
||||||
|
const float su = Fade(fx), sv = Fade(fy), sw = Fade(fz);
|
||||||
|
|
||||||
|
const uint32 h000 = HashCorner(X, Y, Z );
|
||||||
|
const uint32 h100 = HashCorner(X+1, Y, Z );
|
||||||
|
const uint32 h010 = HashCorner(X, Y+1, Z );
|
||||||
|
const uint32 h110 = HashCorner(X+1, Y+1, Z );
|
||||||
|
const uint32 h001 = HashCorner(X, Y, Z+1);
|
||||||
|
const uint32 h101 = HashCorner(X+1, Y, Z+1);
|
||||||
|
const uint32 h011 = HashCorner(X, Y+1, Z+1);
|
||||||
|
const uint32 h111 = HashCorner(X+1, Y+1, Z+1);
|
||||||
|
|
||||||
|
const float fx1 = fx - 1.0f, fy1 = fy - 1.0f, fz1 = fz - 1.0f;
|
||||||
|
const float n000 = GradDot(h000, fx, fy, fz );
|
||||||
|
const float n100 = GradDot(h100, fx1, fy, fz );
|
||||||
|
const float n010 = GradDot(h010, fx, fy1, fz );
|
||||||
|
const float n110 = GradDot(h110, fx1, fy1, fz );
|
||||||
|
const float n001 = GradDot(h001, fx, fy, fz1);
|
||||||
|
const float n101 = GradDot(h101, fx1, fy, fz1);
|
||||||
|
const float n011 = GradDot(h011, fx, fy1, fz1);
|
||||||
|
const float n111 = GradDot(h111, fx1, fy1, fz1);
|
||||||
|
|
||||||
|
const float x00 = Lerp(n000, n100, su);
|
||||||
|
const float x10 = Lerp(n010, n110, su);
|
||||||
|
const float x01 = Lerp(n001, n101, su);
|
||||||
|
const float x11 = Lerp(n011, n111, su);
|
||||||
|
const float y0 = Lerp(x00, x10, sv);
|
||||||
|
const float y1 = Lerp(x01, x11, sv);
|
||||||
|
return Lerp(y0, y1, sw);
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE float Perlin3D(const FVector& P)
|
||||||
|
{
|
||||||
|
return Perlin3D((float)P.X, (float)P.Y, (float)P.Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// 4-WIDE BATCH — the SIMD multiplier. Computes 4 independent Perlin samples.
|
||||||
|
// Inputs are 4-element arrays; unused lanes must be zero-filled by the caller
|
||||||
|
// (FBM/Ridged below do). Results written to Out[0..3].
|
||||||
|
//=============================================================================
|
||||||
|
#if VF_NOISE_USE_SIMD
|
||||||
|
FORCEINLINE void Perlin3D_x4(const float* Xs, const float* Ys, const float* Zs, float* Out)
|
||||||
|
{
|
||||||
|
const __m128 x = _mm_loadu_ps(Xs);
|
||||||
|
const __m128 y = _mm_loadu_ps(Ys);
|
||||||
|
const __m128 z = _mm_loadu_ps(Zs);
|
||||||
|
|
||||||
|
const __m128 xf = _mm_floor_ps(x);
|
||||||
|
const __m128 yf = _mm_floor_ps(y);
|
||||||
|
const __m128 zf = _mm_floor_ps(z);
|
||||||
|
|
||||||
|
const __m128i X = _mm_cvttps_epi32(xf);
|
||||||
|
const __m128i Y = _mm_cvttps_epi32(yf);
|
||||||
|
const __m128i Z = _mm_cvttps_epi32(zf);
|
||||||
|
|
||||||
|
const __m128 fx = _mm_sub_ps(x, xf);
|
||||||
|
const __m128 fy = _mm_sub_ps(y, yf);
|
||||||
|
const __m128 fz = _mm_sub_ps(z, zf);
|
||||||
|
|
||||||
|
const __m128 c6 = _mm_set1_ps(6.0f);
|
||||||
|
const __m128 c15 = _mm_set1_ps(15.0f);
|
||||||
|
const __m128 c10 = _mm_set1_ps(10.0f);
|
||||||
|
auto Fade = [&](const __m128 t) -> __m128
|
||||||
|
{
|
||||||
|
const __m128 inner = _mm_add_ps(_mm_mul_ps(t, _mm_sub_ps(_mm_mul_ps(t, c6), c15)), c10);
|
||||||
|
const __m128 t3 = _mm_mul_ps(_mm_mul_ps(t, t), t);
|
||||||
|
return _mm_mul_ps(t3, inner);
|
||||||
|
};
|
||||||
|
const __m128 su = Fade(fx);
|
||||||
|
const __m128 sv = Fade(fy);
|
||||||
|
const __m128 sw = Fade(fz);
|
||||||
|
|
||||||
|
const __m128i one = _mm_set1_epi32(1);
|
||||||
|
const __m128i X1 = _mm_add_epi32(X, one);
|
||||||
|
const __m128i Y1 = _mm_add_epi32(Y, one);
|
||||||
|
const __m128i Z1 = _mm_add_epi32(Z, one);
|
||||||
|
|
||||||
|
const __m128i k1 = _mm_set1_epi32((int32)0x9E3779B1u);
|
||||||
|
const __m128i k2 = _mm_set1_epi32((int32)0x85EBCA77u);
|
||||||
|
const __m128i k3 = _mm_set1_epi32((int32)0xC2B2AE3Du);
|
||||||
|
const __m128i m1 = _mm_set1_epi32((int32)0x2C1B3C6Du);
|
||||||
|
const __m128i m2 = _mm_set1_epi32((int32)0x297A2D39u);
|
||||||
|
auto Hash = [&](const __m128i ix, const __m128i iy, const __m128i iz) -> __m128i
|
||||||
|
{
|
||||||
|
__m128i h = _mm_xor_si128(_mm_xor_si128(_mm_mullo_epi32(ix, k1),
|
||||||
|
_mm_mullo_epi32(iy, k2)),
|
||||||
|
_mm_mullo_epi32(iz, k3));
|
||||||
|
h = _mm_xor_si128(h, _mm_srli_epi32(h, 15)); h = _mm_mullo_epi32(h, m1);
|
||||||
|
h = _mm_xor_si128(h, _mm_srli_epi32(h, 12)); h = _mm_mullo_epi32(h, m2);
|
||||||
|
h = _mm_xor_si128(h, _mm_srli_epi32(h, 15));
|
||||||
|
return h;
|
||||||
|
};
|
||||||
|
|
||||||
|
const __m128 fx1 = _mm_sub_ps(fx, _mm_set1_ps(1.0f));
|
||||||
|
const __m128 fy1 = _mm_sub_ps(fy, _mm_set1_ps(1.0f));
|
||||||
|
const __m128 fz1 = _mm_sub_ps(fz, _mm_set1_ps(1.0f));
|
||||||
|
|
||||||
|
const __m128i i8 = _mm_set1_epi32(8);
|
||||||
|
const __m128i i12 = _mm_set1_epi32(12);
|
||||||
|
const __m128i i13 = _mm_set1_epi32(13);
|
||||||
|
const __m128i i15 = _mm_set1_epi32(15);
|
||||||
|
const __m128i i1 = _mm_set1_epi32(1);
|
||||||
|
const __m128i i2 = _mm_set1_epi32(2);
|
||||||
|
const __m128i izero = _mm_setzero_si128();
|
||||||
|
const __m128 sgn = _mm_set1_ps(-0.0f);
|
||||||
|
auto Grad = [&](const __m128i hash, const __m128 gx, const __m128 gy, const __m128 gz) -> __m128
|
||||||
|
{
|
||||||
|
const __m128i h = _mm_and_si128(hash, i15);
|
||||||
|
// u = (h&8)==0 ? gx : gy
|
||||||
|
const __m128 mU = _mm_castsi128_ps(_mm_cmpeq_epi32(_mm_and_si128(h, i8), izero));
|
||||||
|
const __m128 u = _mm_blendv_ps(gy, gx, mU);
|
||||||
|
// v = (h&12)==0 ? gy : ((h&13)==12 ? gx : gz)
|
||||||
|
const __m128 mLt4 = _mm_castsi128_ps(_mm_cmpeq_epi32(_mm_and_si128(h, i12), izero));
|
||||||
|
const __m128 m1214 = _mm_castsi128_ps(_mm_cmpeq_epi32(_mm_and_si128(h, i13), i12));
|
||||||
|
const __m128 vTmp = _mm_blendv_ps(gz, gx, m1214);
|
||||||
|
const __m128 v = _mm_blendv_ps(vTmp, gy, mLt4);
|
||||||
|
// signs: (h&1)? -u:u + (h&2)? -v:v
|
||||||
|
const __m128 mNegU = _mm_castsi128_ps(_mm_cmpeq_epi32(_mm_and_si128(h, i1), i1));
|
||||||
|
const __m128 mNegV = _mm_castsi128_ps(_mm_cmpeq_epi32(_mm_and_si128(h, i2), i2));
|
||||||
|
const __m128 ru = _mm_blendv_ps(u, _mm_xor_ps(u, sgn), mNegU);
|
||||||
|
const __m128 rv = _mm_blendv_ps(v, _mm_xor_ps(v, sgn), mNegV);
|
||||||
|
return _mm_add_ps(ru, rv);
|
||||||
|
};
|
||||||
|
|
||||||
|
const __m128 n000 = Grad(Hash(X, Y, Z ), fx, fy, fz );
|
||||||
|
const __m128 n100 = Grad(Hash(X1, Y, Z ), fx1, fy, fz );
|
||||||
|
const __m128 n010 = Grad(Hash(X, Y1, Z ), fx, fy1, fz );
|
||||||
|
const __m128 n110 = Grad(Hash(X1, Y1, Z ), fx1, fy1, fz );
|
||||||
|
const __m128 n001 = Grad(Hash(X, Y, Z1), fx, fy, fz1);
|
||||||
|
const __m128 n101 = Grad(Hash(X1, Y, Z1), fx1, fy, fz1);
|
||||||
|
const __m128 n011 = Grad(Hash(X, Y1, Z1), fx, fy1, fz1);
|
||||||
|
const __m128 n111 = Grad(Hash(X1, Y1, Z1), fx1, fy1, fz1);
|
||||||
|
|
||||||
|
auto Lerp = [&](const __m128 a, const __m128 b, const __m128 t) -> __m128
|
||||||
|
{
|
||||||
|
return _mm_add_ps(a, _mm_mul_ps(t, _mm_sub_ps(b, a)));
|
||||||
|
};
|
||||||
|
const __m128 x00 = Lerp(n000, n100, su);
|
||||||
|
const __m128 x10 = Lerp(n010, n110, su);
|
||||||
|
const __m128 x01 = Lerp(n001, n101, su);
|
||||||
|
const __m128 x11 = Lerp(n011, n111, su);
|
||||||
|
const __m128 y0 = Lerp(x00, x10, sv);
|
||||||
|
const __m128 y1 = Lerp(x01, x11, sv);
|
||||||
|
_mm_storeu_ps(Out, Lerp(y0, y1, sw));
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
FORCEINLINE void Perlin3D_x4(const float* Xs, const float* Ys, const float* Zs, float* Out)
|
||||||
|
{
|
||||||
|
for (int32 i = 0; i < 4; ++i) Out[i] = Perlin3D(Xs[i], Ys[i], Zs[i]);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// fBm / Ridged — octaves evaluated 4 at a time through Perlin3D_x4.
|
||||||
|
// Accumulation stays scalar in octave order, so the result is independent of
|
||||||
|
// whether the SIMD or scalar Perlin3D_x4 is used (bit-identical either way).
|
||||||
|
//=============================================================================
|
||||||
|
FORCEINLINE float FBM(float x, float y, float z,
|
||||||
|
int32 Octaves = 4, float Lacunarity = 2.0f, float Persistence = 0.5f)
|
||||||
|
{
|
||||||
|
float Total = 0.0f, MaxValue = 0.0f, Freq = 1.0f, Amp = 1.0f;
|
||||||
|
for (int32 o = 0; o < Octaves; )
|
||||||
|
{
|
||||||
|
const int32 N = FMath::Min(4, Octaves - o);
|
||||||
|
float Xs[4] = {}, Ys[4] = {}, Zs[4] = {}, Out[4];
|
||||||
|
float f = Freq;
|
||||||
|
for (int32 i = 0; i < N; ++i) { Xs[i] = x * f; Ys[i] = y * f; Zs[i] = z * f; f *= Lacunarity; }
|
||||||
|
Perlin3D_x4(Xs, Ys, Zs, Out);
|
||||||
|
for (int32 i = 0; i < N; ++i) { Total += Out[i] * Amp; MaxValue += Amp; Amp *= Persistence; }
|
||||||
|
Freq = f;
|
||||||
|
o += N;
|
||||||
|
}
|
||||||
|
return Total / MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
FORCEINLINE float Ridged(float x, float y, float z,
|
||||||
|
int32 Octaves = 4, float Lacunarity = 2.0f, float Persistence = 0.5f)
|
||||||
|
{
|
||||||
|
// Matches the original RidgedNoise3D fold exactly (NS scale, square, weight feedback).
|
||||||
|
static constexpr float NS = 1.25f;
|
||||||
|
float Total = 0.0f, MaxValue = 0.0f, Freq = 1.0f, Amp = 1.0f, Weight = 1.0f;
|
||||||
|
for (int32 o = 0; o < Octaves; )
|
||||||
|
{
|
||||||
|
const int32 N = FMath::Min(4, Octaves - o);
|
||||||
|
float Xs[4] = {}, Ys[4] = {}, Zs[4] = {}, Out[4];
|
||||||
|
float f = Freq;
|
||||||
|
for (int32 i = 0; i < N; ++i) { Xs[i] = x * f; Ys[i] = y * f; Zs[i] = z * f; f *= Lacunarity; }
|
||||||
|
Perlin3D_x4(Xs, Ys, Zs, Out);
|
||||||
|
for (int32 i = 0; i < N; ++i)
|
||||||
|
{
|
||||||
|
float Nn = Out[i] * NS;
|
||||||
|
Nn = 1.0f - FMath::Abs(Nn); // fold → ridge at zero-crossings
|
||||||
|
Nn = Nn * Nn; // sharpen
|
||||||
|
Nn *= Weight; // detail follows previous ridge
|
||||||
|
Weight = FMath::Clamp(Nn * 2.0f, 0.0f, 1.0f);
|
||||||
|
Total += Nn * Amp; MaxValue += Amp; Amp *= Persistence;
|
||||||
|
}
|
||||||
|
Freq = f;
|
||||||
|
o += N;
|
||||||
|
}
|
||||||
|
return (Total / MaxValue) * 2.0f - 1.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace VoxelNoise
|
||||||
@@ -1730,6 +1730,14 @@ struct VOXELFORGE_API FStrateDecoration
|
|||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0.0", ClampMax = "90.0"))
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0.0", ClampMax = "90.0"))
|
||||||
float MaxSlopeAngle = 90.0f;
|
float MaxSlopeAngle = 90.0f;
|
||||||
|
|
||||||
|
// Minimum surface tilt (degrees from flat) — the LOWER companion to MaxSlopeAngle. Rejects surfaces
|
||||||
|
// FLATTER than this, so a prop can be kept OFF flat ground and restricted to slopes / walls. Same
|
||||||
|
// metric as MaxSlopeAngle: acos(|normal.Z|), 0 = flat, 90 = vertical. Pair the two to band a prop
|
||||||
|
// onto a tilt range (e.g. 30..70 = slopes only, never flats or sheer walls).
|
||||||
|
// 0 → no filter (default) · 45 → slopes & walls only · 70 → near-vertical only
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0.0", ClampMax = "90.0"))
|
||||||
|
float MinSlopeAngle = 0.0f;
|
||||||
|
|
||||||
// Chance per valid surface point to spawn this decoration (0-1)
|
// Chance per valid surface point to spawn this decoration (0-1)
|
||||||
// 0.01 = rare, 0.1 = common, 0.5 = very dense
|
// 0.01 = rare, 0.1 = common, 0.5 = very dense
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0.0", ClampMax = "1.0"))
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0.0", ClampMax = "1.0"))
|
||||||
@@ -1753,6 +1761,25 @@ struct VOXELFORGE_API FStrateDecoration
|
|||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement")
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement")
|
||||||
bool bRandomYaw = true;
|
bool bRandomYaw = true;
|
||||||
|
|
||||||
|
// When bRandomYaw is set, constrain the random yaw to [MinYaw, MaxYaw] degrees instead of a full
|
||||||
|
// turn. Lets a prop face roughly one way with a little variation (wind-bent grass: 80..100). The
|
||||||
|
// default 0..360 is a full unrestricted turn — byte-identical to the legacy behaviour. Ignored when
|
||||||
|
// bRandomYaw is false.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement",
|
||||||
|
meta = (EditCondition = "bRandomYaw", ClampMin = "0.0", ClampMax = "360.0"))
|
||||||
|
float MinYaw = 0.0f;
|
||||||
|
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement",
|
||||||
|
meta = (EditCondition = "bRandomYaw", ClampMin = "0.0", ClampMax = "360.0"))
|
||||||
|
float MaxYaw = 360.0f;
|
||||||
|
|
||||||
|
// Wall props only: exclude downward-facing OVERHANGS. A "wall" is any surface between floor and
|
||||||
|
// ceiling (|normal.Z| <= 0.5), which still includes surfaces that lean slightly DOWNWARD (overhang
|
||||||
|
// ceilings). For props that must sit on upright walls (vines, wall torches) set this so only normals
|
||||||
|
// with Z >= 0 (vertical or up-leaning) qualify. Ignored unless the point resolves as a wall.
|
||||||
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement")
|
||||||
|
bool bWallExcludeOverhangs = false;
|
||||||
|
|
||||||
// Offset along the surface normal (world units). Positive = lift off the surface,
|
// Offset along the surface normal (world units). Positive = lift off the surface,
|
||||||
// negative = sink into it. Useful to embed roots or float crystals slightly.
|
// negative = sink into it. Useful to embed roots or float crystals slightly.
|
||||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement")
|
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement")
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class UVoxelDiffLayer;
|
|||||||
class UVoxelContentManager;
|
class UVoxelContentManager;
|
||||||
class UVoxelAtmosphereManager;
|
class UVoxelAtmosphereManager;
|
||||||
class UMaterialInterface;
|
class UMaterialInterface;
|
||||||
|
namespace RealtimeMesh { struct FRealtimeMeshStreamSet; } // T1.f — worker-built geometry buffers
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AVoxelWorld - The main voxel terrain actor
|
* AVoxelWorld - The main voxel terrain actor
|
||||||
@@ -39,8 +40,19 @@ class UMaterialInterface;
|
|||||||
struct FChunkResult
|
struct FChunkResult
|
||||||
{
|
{
|
||||||
FVoxelTileKey Tile; // which clipmap tile this mesh is for (carries coord + level)
|
FVoxelTileKey Tile; // which clipmap tile this mesh is for (carries coord + level)
|
||||||
FVoxelMeshData MeshData;
|
// T1.f: the RMC geometry buffers are BUILT ON THE WORKER (BuildTileStreamSet in the gen task)
|
||||||
|
// so the game thread only uploads them — the per-vertex builder loop was the dominant
|
||||||
|
// game-thread cost while moving (the apply drain). TSharedPtr (not a by-value StreamSet) so
|
||||||
|
// FChunkResult stays movable through the MPSC queue with the type only FORWARD-DECLARED here.
|
||||||
|
// Null ⇒ empty/all-air tile (no component).
|
||||||
|
TSharedPtr<RealtimeMesh::FRealtimeMeshStreamSet> Streams;
|
||||||
uint32 Epoch = 0; // Generation epoch — discard if stale
|
uint32 Epoch = 0; // Generation epoch — discard if stale
|
||||||
|
bool bEmpty = true; // true ⇒ all-air tile (Streams null); still marked loaded so we don't re-submit
|
||||||
|
// Ceiling classification from the ACTUAL mesh normals (down-facing geometry = sky-cap ceiling),
|
||||||
|
// computed on the worker where Normals are free. Authoritative — can't disagree with the rendered
|
||||||
|
// view the way a game-thread height-oracle sample did (it misclassified coarse far tiles). The
|
||||||
|
// game thread still gates this to SurfaceWorld strates before applying CeilingMaterial / no-shadow.
|
||||||
|
bool bIsCeiling = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
UCLASS()
|
UCLASS()
|
||||||
@@ -227,6 +239,16 @@ public:
|
|||||||
UFUNCTION(BlueprintPure, Category = "Voxel World|Strate")
|
UFUNCTION(BlueprintPure, Category = "Voxel World|Strate")
|
||||||
int32 GetStrateAtPosition(FVector WorldPosition) const;
|
int32 GetStrateAtPosition(FVector WorldPosition) const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe the biome field at a world location (e.g. a mouse line-trace hit). Returns the dominant +
|
||||||
|
* neighbour biome, the climate fields, the border blend weight, and the dominant biome's decoration
|
||||||
|
* count — the SAME resolution the decoration scatter uses per column. Use it to debug placement:
|
||||||
|
* a returned DominantDecorationCount of 0 means that biome has no decorations (an empty region),
|
||||||
|
* NOT a bug. WorldLocation is full world space (the actor transform is undone internally).
|
||||||
|
*/
|
||||||
|
UFUNCTION(BlueprintCallable, Category = "Voxel World|Biome")
|
||||||
|
FVoxelBiomeQuery GetBiomeAtWorldLocation(FVector WorldLocation) const;
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
// LIVE EDIT (debug tuning in PIE)
|
// LIVE EDIT (debug tuning in PIE)
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
@@ -374,16 +396,18 @@ public:
|
|||||||
void UnloadTile(const FVoxelTileKey& Tile);
|
void UnloadTile(const FVoxelTileKey& Tile);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply mesh data to a RealtimeMesh component.
|
* Upload a tile's geometry to its RealtimeMesh component (game thread).
|
||||||
*
|
*
|
||||||
* CONCEPT:
|
* The vertex/index buffers (Streams) are already BUILT on the worker (T1.f — see
|
||||||
* - Get or create the mesh component for this chunk
|
* BuildTileStreamSet / FChunkResult), so this only does the game-thread-only work:
|
||||||
* - Set the mesh data (vertices, triangles, etc.)
|
* ceiling/material resolution, get-or-create the component, CreateSectionGroup(MoveTemp),
|
||||||
|
* and section config (collision/shadow). Never called for empty tiles.
|
||||||
*
|
*
|
||||||
* @param ChunkCoord - Which chunk this mesh belongs to
|
* @param Tile - Which clipmap tile this mesh belongs to
|
||||||
* @param MeshData - The generated mesh data
|
* @param Streams - Pre-built RMC geometry buffers (consumed/moved)
|
||||||
|
* @param bGeomCeiling - Worker's geometry-normal ceiling vote (gated to SurfaceWorld here)
|
||||||
*/
|
*/
|
||||||
void ApplyMeshToTile(const FVoxelTileKey& Tile, const FVoxelMeshData& MeshData);
|
void ApplyMeshToTile(const FVoxelTileKey& Tile, RealtimeMesh::FRealtimeMeshStreamSet&& Streams, bool bGeomCeiling);
|
||||||
|
|
||||||
/** Build the clipmap desired-tile set (concentric shells) around the player tile. */
|
/** Build the clipmap desired-tile set (concentric shells) around the player tile. */
|
||||||
void BuildDesiredTiles(const FIntVector& CenterChunkCoord);
|
void BuildDesiredTiles(const FIntVector& CenterChunkCoord);
|
||||||
|
|||||||
Reference in New Issue
Block a user