Another pass

This commit is contained in:
2026-06-23 08:30:13 +02:00
parent db558d9e14
commit e6cd852129
17 changed files with 2340 additions and 878 deletions
+209 -42
View File
@@ -90,7 +90,7 @@ Paths relative to `Source/VoxelForge/`. `Public/` = headers, `Private/` = impl.
| `LocalToIndex` / `IndexToLocal` / `IsValidLocalCoord` | 107-131 | Flat-array 3D↔1D indexing. |
| `SmoothStep01` | 140 | 3x²-2x³ — used everywhere for blends. |
| `VOXEL_NOISE_SCALE` (1.25f) | 147 | Rescales UE PerlinNoise3D to ~[-1,1]. |
| `FVoxelMeshData` struct | 157-173 | Mesher output (Vertices/Triangles/UVs/Normals). Plain C++, not USTRUCT. |
| `FVoxelMeshData` struct | 157-173 | Mesher output (Vertices/Triangles/UVs/Normals/**Colors**). Plain C++, not USTRUCT. `Colors` = F6 material masks (R=dominant biome palette, G=slope, B=border blend weight, A=neighbour biome palette). §8.15. |
### 3.3 Chunk identity — `Public/VoxelChunk.h`
`FVoxelChunk` (USTRUCT, line 19): just a `ChunkCoord` + `GetWorldPosition()`. In a
@@ -132,7 +132,8 @@ per-chunk info later.
| `UpdateChunksAroundPosition` | 362 | Builds desired set, sorts by distance, loads/unloads, handles LOD changes. |
| `LoadChunk` | 445 | Budget check → `UE::Tasks::Launch` background gen+mesh; RAII task guard. |
| `UnloadChunk` | 493 | Destroys mesh component + map entries. |
| `ApplyMeshToChunk` | 503 | Get/create RealtimeMeshComponent, upload geometry, assign material. |
| `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. |
| `GetStrateAtPosition` | 679 | Gameplay query → strate index. |
| `CarveAtPosition` / `FillAtPosition` | 691 / 709 | Build `FVoxelModification` → DiffLayer → RemeshDirtyChunks. |
| `ClearAllModifications` | 726 | Clears diff layer, regenerates. |
@@ -140,6 +141,8 @@ per-chunk info later.
| `GetCurrentSeed` / `GetCurrentSeason` | 784 / 789 | Accessors. |
| `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).
### 3.6 Density generator — `Public/VoxelGenerator.h` + `Private/VoxelGenerator.cpp`
`UVoxelGenerator : UObject` — lightweight; holds `Seed`, and injected services
`StrateManager` + `DiffLayer` (both nullable). This is **where terrain shape lives.**
@@ -209,7 +212,7 @@ Maps depth→strate at runtime; owns passages.
|--------|-----------|------|
| `Initialize` | 10 | Builds the stacked layout from settings+seed (fixed slots + shuffled pool), then `GeneratePassages`. |
| `GeneratePassages` | 146 | Deterministic passages between consecutive strates (per-type control points). |
| `EvaluateModifierSDF` | 371 | SDF of all passages + elevator shaft at a point (for carving). |
| `EvaluateModifierSDF` | 357 | SDF of passages at a point (for carving). Per-chunk `thread_local` shortlist (`PassagesVersion`-stamped) → far chunks return `FLT_MAX` without walking `Passages`. §8.10. |
| `FindSlotIndexForChunkZ` | 427 | Z → layout index. |
| `GetStrateAt` / `GetStrateIndex` | 443 / 455 | World-Z queries. |
| `GetStrateForChunk` | 466 | Chunk → definition. |
@@ -233,7 +236,7 @@ box-validated per-chunk grid cache). See §8.14.
**`Public/VoxelBiomeDefinition.h` + `.cpp`** (NEW) — `UVoxelBiomeDefinition : UPrimaryDataAsset`.
One asset = one biome: identity + `DebugColor`, climate placement box (`ReliefMin/Max`,
`MoistureMin/Max`), terrain override (`bOverrideTerrain` + `GeneratorType` + archetype params), content profile (`Decorations`/`AmbientActors`,
atmosphere override, `WaterMaterial`, reserved `MaterialPaletteIndex`), `GameplayTags`. Referenced from
atmosphere override, `WaterMaterial`, `MaterialPaletteIndex` (F6 — baked to vertex colour, §8.15)), `GameplayTags`. Referenced from
`UVoxelStrateDefinition::Biomes[]`. Generator-agnostic (surface biomes now, cave biomes later). §8.14.
### 3.9 Player edits — `Public/VoxelDiffLayer.h` + `.cpp`
@@ -266,7 +269,7 @@ Bourke). Cube corner/edge layout documented at top (lines 7-37). Rarely needs ed
### 3.11 Per-chunk content & per-strate atmosphere (2026 redesign — see §8)
| File | Role |
|------|------|
| `Public/Private/VoxelContentManager.h/.cpp` | `UVoxelContentManager` — deterministic decoration scatter (LOD0) + aesthetic water planes, per chunk. Owned by `AVoxelWorld`. §8.5. |
| `Public/Private/VoxelContentManager.h/.cpp` | `UVoxelContentManager` — distance-based world-grid decoration scatter (no LOD pop, surface-snapped via `GetDensityAt`) + level-0 water planes. Owned by `AVoxelWorld`. §8.5. |
| `Public/Private/VoxelAtmosphereManager.h/.cpp` | `UVoxelAtmosphereManager` — per-strate fog/skylight + persistent ceiling/floor layer actors + full `AtmosphereActor` override. Owned by `AVoxelWorld`. §8.6. |
> The big 2026 redesign (8 archetypes, (0,0) spine, inter-strate gap, per-strate passages,
@@ -378,7 +381,7 @@ by `GeneratorType`) and its own density function in `VoxelGenerator.cpp`, dispat
| TunnelNetwork | `FStrateGenerationParams` | `GetDensityWithParams` | rooms+tunnels (original) |
| FlatPlain / CrystalChamber | `FSlabGenerationParams` | `GetSlabDensity` | floor/ceiling void (original) |
| Maze | `FMazeGenerationParams` | `GetMazeDensity` | tight corridors on a 3D lattice (per-voxel, no cache; edge = lower node + axis hash) |
| SurfaceWorld | `FSurfaceGenerationParams` | `GetSurfaceDensity` | heightfield terrain: domain-warped continents+ridged mtns+detail, a low-freq **relief map** (`M`) that scales mountains/elevation for plains↔highland variety, opt-in plateau **terracing**, beaches at water line, high sky-cap ceiling. (`Surface|Macro` params = the cheap precursor to biomes; `ReliefStrength=0` ⇒ old uniform terrain.) |
| 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 |
@@ -416,26 +419,88 @@ Provided per chunk by `StrateManager::GetDisturbanceParamsForChunk`.
max edge reach, and decisions must not depend on the stored window.**
### 8.5 Content scatter & water — `VoxelContentManager.h/.cpp` (NEW)
`UVoxelContentManager` (owned by `AVoxelWorld`, game-thread). Chunk lifecycle:
`PopulateChunk(coord, mesh, LOD)` in `ApplyMeshToChunk`, `ClearChunk` in `UnloadChunk`,
`ClearAll`/`SetSeed` in `ChangeSeed`, `SetActiveStrate(GetStrateAtPosition(player))` each Tick.
Deterministic decoration scatter on mesh vertices (surface-type / water-relative /
align-to-normal / random-yaw / scale / `MaxPerChunk`, from `FStrateDecoration`).
**Two render paths per entry:** `ActorClass` (real actors — lights/logic/interaction,
keep `MaxLODLevel=0`) vs `InstancedMesh` (batched per-chunk **HISM**, no tick/actor cost,
no collision; safe at `MaxLODLevel` 1-2 so visual props don't pop out with LOD0 — emissive
materials still glow at distance). Placement samples the LOD's vertices ⇒ instances
re-scatter slightly on LOD swap (masked by terrain pop). **Strate light culling:**
light components on decoration actors are visible only while the player is in the SAME
strate (`SetActiveStrate`, no-op until strate change) — analytical occlusion: seals+gap are
light-tight, and shadowless lights otherwise BLEED through rock (shadowed ones render black
at full cost). Aesthetic water = one scaled engine plane (`/Engine/BasicShapes/Plane`) per
water-surface chunk (any LOD), material `UVoxelStrateDefinition::WaterMaterial`. Water Z:
`bHasWater` + `WaterLevelRelative` (Surface/tunnel params) → `StrateManager::GetWaterLevelWorldZForChunk`.
**Per-biome content (§8.14):** when the chunk's dominant biome (`Generator::GetDominantBiomeAt`)
supplies decorations they REPLACE the strate's; biome `WaterMaterial` overrides the plane's material
(level stays strate-global). NOTE: `GetChunkStrateIndex` (light culling) feeds `GetStrateIndex` which
expects Unreal **cm** — it now passes `chunkZ*CHUNK_SIZE*VOXEL_SIZE` (was voxel-Z, a latent strate-match bug).
`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`).
@@ -466,9 +531,11 @@ below: `Connections`, `Style` (`EVoxelPassageStyle`: Straight/Worm/Spiral/Cascad
`Segments`, `VerticalWobble`, Spiral/Cascade params. Built in `StrateManager::GeneratePassages`
as control-point chains. **Worm = independent fBM per horizontal axis** (`PassageFBM` static)
with a flat-top envelope → organic squirm (NOT a 1D zigzag, NOT a same-freq 2-channel spiral).
`EvaluateModifierSDF` (per voxel) **bounding-sphere-culls** each passage
(`FVoxelPassage::BoundCenter/BoundRadiusSq`) — perf-critical. The (0,0) surface entry is a
simple straight tube. Global passage settings were removed from `VoxelSettings`.
`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`/
@@ -495,32 +562,102 @@ driven by `EditorBrush*` props.
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`, `GetDensityAt` SurfaceWorld branch):
the heightfield + sky-cap + biome blend are XY-only but sampled ~33× per column (once per Z
grid-point). Cached per integer XY (box-valid, like the SDF cache) and reused down the column.
Box `Halo = CHUNK_SIZE + 8` each side so the T1.b margin ring stays inside (no thrash). **Used
ONLY for integer-XY queries**; fractional queries compute directly → bit-identical. Don't key it
by chunk and don't feed it fractional coords.
- **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.
- **No shadows on LOD2 (draw-call cut)** (`ApplyMeshToChunk`): `MeshComp->SetCastShadow(LOD <= 1)`.
Every shadow-casting chunk emits a SECOND draw in the shadow-depth pass; the farthest tier
(LOD2, blocky) doesn't need it. Re-applied every apply (LOD hot-swaps reuse the component).
Pulling `LOD0Distance`/`LOD1Distance` inward pushes more chunks into the shadowless LOD2 band
→ fewer draws (free fps knob). Widen to `<= 0` (drop LOD1 shadows too) if still draw-bound.
NOTE: fps is a RENDER-side problem (draw calls ≈ visible chunk count × passes) — generation
cost (worker threads) and LOD *resolution* (cuts triangles, not draws) don't move it.
- **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
@@ -616,3 +753,33 @@ change *anything*, e.g. frequencies, which scalar multipliers couldn't.)
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).
File diff suppressed because it is too large Load Diff
@@ -95,6 +95,10 @@ TArray<FIntVector> UVoxelDiffLayer::ApplyModification(const FVoxelModification&
const int32 MinCZ = FMath::FloorToInt(BoundsMin.Z / CHUNK_SIZE);
const int32 MaxCZ = FMath::FloorToInt(BoundsMax.Z / CHUNK_SIZE);
{
// Write lock: blocks worker-thread readers (HasModifications / GetDensityOffset) while the map
// is mutated/rehashed. AffectedChunks is local; populate it in the same pass.
FWriteScopeLock Lock(ModsLock);
for (int32 CZ = MinCZ; CZ <= MaxCZ; CZ++)
{
for (int32 CY = MinCY; CY <= MaxCY; CY++)
@@ -111,6 +115,9 @@ TArray<FIntVector> UVoxelDiffLayer::ApplyModification(const FVoxelModification&
}
}
}
// Publish: subsequent readers must now take the lock instead of fast-rejecting.
bHasAnyMods.store(true, std::memory_order_release);
}
UE_LOG(LogTemp, Log,
TEXT("[DiffLayer] Modification #%d at (%.0f, %.0f, %.0f) R=%.1f S=%.1f -> %d chunks (budget: %d/%d mods, %.0f/%.0f vol)"),
@@ -131,6 +138,13 @@ TArray<FIntVector> UVoxelDiffLayer::ApplyModification(const FVoxelModification&
float UVoxelDiffLayer::GetDensityOffset(const FIntVector& ChunkCoord,
float WorldX, float WorldY, float WorldZ) const
{
// Lock-free fast reject: no carves anywhere -> nothing to offset.
if (!bHasAnyMods.load(std::memory_order_acquire)) return 0.0f;
// Hold the read lock for the whole body: Mods points INTO the map and is dereferenced through the
// falloff loop below, so the map must not be rehashed by a concurrent writer meanwhile.
FReadScopeLock Lock(ModsLock);
// Fast path: if this chunk has no modifications, return 0
const TArray<FVoxelModification>* Mods = ChunkMods.Find(ChunkCoord);
if (!Mods || Mods->Num() == 0) return 0.0f;
@@ -194,6 +208,10 @@ float UVoxelDiffLayer::GetDensityOffset(const FIntVector& ChunkCoord,
bool UVoxelDiffLayer::HasModifications(const FIntVector& ChunkCoord) const
{
// Lock-free fast reject: no carves anywhere -> the map is empty, skip lock + lookup entirely.
if (!bHasAnyMods.load(std::memory_order_acquire)) return false;
FReadScopeLock Lock(ModsLock); // worker threads read concurrently; serialised vs. writers
const TArray<FVoxelModification>* Mods = ChunkMods.Find(ChunkCoord);
return Mods && Mods->Num() > 0;
}
@@ -205,7 +223,13 @@ bool UVoxelDiffLayer::HasModifications(const FIntVector& ChunkCoord) const
void UVoxelDiffLayer::Clear()
{
int32 Count = GetTotalModificationCount();
{
// Write lock: workers may be mid-read. Flip the fast-path flag false BEFORE emptying so any
// reader that loads it afterwards skips the map without locking.
FWriteScopeLock Lock(ModsLock);
bHasAnyMods.store(false, std::memory_order_release);
ChunkMods.Empty();
}
// Reset budget counters — player gets a fresh budget after clear/season reset
ModificationCount = 0;
@@ -216,6 +240,7 @@ void UVoxelDiffLayer::Clear()
int32 UVoxelDiffLayer::GetTotalModificationCount() const
{
FReadScopeLock Lock(ModsLock);
int32 Total = 0;
for (const auto& Pair : ChunkMods)
{
@@ -226,5 +251,6 @@ int32 UVoxelDiffLayer::GetTotalModificationCount() const
int32 UVoxelDiffLayer::GetModifiedChunkCount() const
{
FReadScopeLock Lock(ModsLock);
return ChunkMods.Num();
}
+239 -91
View File
@@ -22,24 +22,68 @@
// the column (terrain Z + ceiling Z) once per integer XY and reuse it down the column.
// Used ONLY for integer-XY queries (the density grid); fractional queries (gradient
// normals at interpolated vertices) fall through and compute directly → bit-identical.
// Validity is a world-XY BOX (chunk footprint) + ChunkZ + Seed, same discipline as the
// SDF/biome caches (§8.10) so the +X/+Y boundary plane doesn't thrash it.
// The surface heightfield (TerrainZ + sky-cap CeilSurf) is a PURE function of (worldX, worldY,
// seed, strate) — ZERO Z dependence: the climate/Voronoi fields are pure-XY (see ResolveBiomeSampleAt:
// "result is the pure function of XY either way") and surface params are constant within a strate (Hard
// transitions). So a column computed for one chunk is valid for EVERY chunk stacked above/below it in
// the same strate. This cache is therefore keyed by (XY box, StrateKey, Seed) — NOT ChunkZ — and held as
// a small LRU of boxes so the whole vertical view-distance stack (and XY neighbours interleaved by the
// task scheduler) reuse one another's heavy column noise instead of each recomputing it ~once per
// vertical chunk in view. Validity stays a world-XY BOX (chunk footprint + margin) so the ±1 gradient /
// +X/+Y boundary samples don't thrash it (same discipline as the SDF/biome caches, §8.10).
struct FSurfaceColumn { float TerrainZ = 0.0f; float CeilSurf = 0.0f; };
struct FSurfaceColumnCache
struct FSurfaceColumnBox
{
// Box covers a chunk footprint + a margin on every side (≥ the LOD margin ring the
// mesher samples for grid-based normals, ±Step ≤ 4, plus slack). Symmetric around the
// first (rebuild-triggering) sample so the whole chunk's column queries stay in one box.
// Covers a chunk footprint + a margin on every side (≥ the LOD margin ring the mesher samples for
// grid-based normals). Symmetric around the first (allocating) sample so a whole chunk's column
// queries — including the ±Step margin ring — stay inside one box.
static constexpr int32 Halo = CHUNK_SIZE + 8;
static constexpr int32 Dim = 2 * Halo + 1;
int32 BaseX = 0, BaseY = 0; // box origin (voxel coords)
int32 ChunkZ = MIN_int32, Seed = MIN_int32;
int32 StrateKey = MIN_int32, Seed = MIN_int32; // key: same strate ⇒ identical heightfield params
uint32 LastUse = 0; // LRU stamp
bool bValid = false;
FSurfaceColumn Cols[Dim * Dim];
bool Computed[Dim * Dim];
};
struct FSurfaceColumnCache
{
// A handful of boxes keeps the player's column + a few XY neighbours warm across interleaved tasks,
// so vertical reuse survives whatever order the worker pulls chunks in. ~59 KB/box.
static constexpr int32 NumBoxes = 6;
FSurfaceColumnBox Boxes[NumBoxes];
uint32 Clock = 0;
// Return the box covering (IX,IY) for this (StrateKey,Seed); allocate by evicting the LRU box on miss.
FSurfaceColumnBox& Acquire(int32 IX, int32 IY, int32 InStrateKey, int32 InSeed)
{
++Clock;
for (FSurfaceColumnBox& B : Boxes)
{
if (B.bValid && B.StrateKey == InStrateKey && B.Seed == InSeed
&& IX >= B.BaseX && IX < B.BaseX + FSurfaceColumnBox::Dim
&& IY >= B.BaseY && IY < B.BaseY + FSurfaceColumnBox::Dim)
{
B.LastUse = Clock;
return B;
}
}
// Miss → reuse the least-recently-used box, recentred on this sample.
FSurfaceColumnBox* Victim = &Boxes[0];
for (FSurfaceColumnBox& B : Boxes) { if (B.LastUse < Victim->LastUse) Victim = &B; }
Victim->BaseX = IX - FSurfaceColumnBox::Halo;
Victim->BaseY = IY - FSurfaceColumnBox::Halo;
Victim->StrateKey = InStrateKey;
Victim->Seed = InSeed;
Victim->bValid = true;
Victim->LastUse = Clock;
FMemory::Memzero(Victim->Computed, sizeof(Victim->Computed));
return *Victim;
}
};
//=============================================================================
// FRACTAL NOISE (fBm — fractional Brownian motion)
//=============================================================================
@@ -403,7 +447,11 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
thread_local FBiomeContext CP_BiomeCtx;
thread_local FChunkBiomeCache CP_BiomeCache;
thread_local TArray<FSurfaceGenerationParams> CP_SurfaceBiomeParams;
thread_local FSurfaceColumnCache CP_SurfCol; // T1.a per-column surface cache
thread_local FSurfaceColumnCache CP_SurfCol; // T1.a per-column surface cache (XY-keyed LRU)
// Discriminates the surface cache by strate: same strate ⇒ identical heightfield params ⇒ columns
// are shareable across the whole vertical chunk stack. Taken from the params themselves
// (StrateBottomWorldZ is unique per stacked strate) so the key can never disagree with CP_Surface.
thread_local int32 CP_StrateKey = MIN_int32;
if (ChunkCoord != CP_Chunk)
{
@@ -417,37 +465,9 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
case ECaveGeneratorType::Maze:
CP_Maze = StrateManager->GetMazeParamsForChunk(ChunkCoord); break;
case ECaveGeneratorType::SurfaceWorld:
{
CP_Surface = StrateManager->GetSurfaceParamsForChunk(ChunkCoord);
CP_BiomeCtx = StrateManager->GetBiomeContextForChunk(ChunkCoord);
// Per-biome surface params: each biome's override (when it overrides
// SurfaceWorld) else the strate's, with STRUCTURAL fields forced from the
// strate (Z bounds, seal, base, water level) so seals/spine/water stay intact.
CP_SurfaceBiomeParams.Reset();
if (CP_BiomeCtx.IsValid())
{
const UVoxelStrateDefinition* Def = StrateManager->GetStrateForChunk(ChunkCoord);
CP_SurfaceBiomeParams.Reserve(CP_BiomeCtx.Biomes.Num());
for (const FBiomeResolved& BR : CP_BiomeCtx.Biomes)
{
FSurfaceGenerationParams P = CP_Surface; // strate base (+ structural)
const UVoxelBiomeDefinition* B =
(Def && Def->Biomes.IsValidIndex(BR.Index)) ? Def->Biomes[BR.Index] : nullptr;
if (B && B->bOverrideTerrain && B->GeneratorType == ECaveGeneratorType::SurfaceWorld)
{
P = B->SurfaceParams; // biome shape
P.StrateTopWorldZ = CP_Surface.StrateTopWorldZ;
P.StrateBottomWorldZ = CP_Surface.StrateBottomWorldZ;
P.BoundarySealThickness = CP_Surface.BoundarySealThickness;
P.BaseDensity = CP_Surface.BaseDensity;
P.WaterLevelRelative = CP_Surface.WaterLevelRelative; // shared water plane
}
CP_SurfaceBiomeParams.Add(P);
}
}
ResolveSurfaceChunkParams(ChunkCoord, CP_Surface, CP_BiomeCtx, CP_SurfaceBiomeParams);
CP_StrateKey = FMath::RoundToInt(CP_Surface.StrateBottomWorldZ);
break;
}
case ECaveGeneratorType::VerticalShafts:
CP_Vert = StrateManager->GetVerticalShaftParamsForChunk(ChunkCoord); break;
case ECaveGeneratorType::FloatingIslands:
@@ -467,63 +487,29 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
Result = GetMazeDensity(WorldX, WorldY, WorldZ, CP_Maze); break;
case ECaveGeneratorType::SurfaceWorld:
{
// The XY-only surface field (biome-blended terrain Z + sky-cap ceiling) for one
// column. Resolves the biome sample here so it's cached per column too.
auto ComputeColumn = [&](float X, float Y) -> FSurfaceColumn
{
const FSurfaceGenerationParams* PD = &CP_Surface;
const FSurfaceGenerationParams* PN = &CP_Surface;
float W = 0.0f;
if (CP_BiomeCtx.IsValid() && CP_SurfaceBiomeParams.Num() > 0)
{
const FBiomeSample Smp = ResolveBiomeSampleAt(X, Y, ChunkCoord.Z, CP_BiomeCtx, CP_BiomeCache);
const int32 Di = CP_SurfaceBiomeParams.IsValidIndex(Smp.DominantIndex) ? Smp.DominantIndex : 0;
PD = &CP_SurfaceBiomeParams[Di];
if (Smp.NeighborWeight > 0.0f && CP_SurfaceBiomeParams.IsValidIndex(Smp.NeighborIndex))
{
PN = &CP_SurfaceBiomeParams[Smp.NeighborIndex];
W = Smp.NeighborWeight;
}
}
FSurfaceColumn Col;
Col.TerrainZ = ComputeSurfaceTerrainZ(X, Y, *PD);
if (W > 0.0f) Col.TerrainZ = FMath::Lerp(Col.TerrainZ, ComputeSurfaceTerrainZ(X, Y, *PN), W);
Col.CeilSurf = ComputeSurfaceCeiling(X, Y, *PD);
return Col;
};
FSurfaceColumn Col;
// Integer XY (the density grid) → reuse the column down its whole Z extent.
// Integer XY (the density grid) → reuse the column down its whole Z extent (T1.a).
// Fractional XY (gradient-normal samples) → compute directly (no cache key).
if (WorldX == FMath::FloorToFloat(WorldX) && WorldY == FMath::FloorToFloat(WorldY))
{
const int32 IX = (int32)WorldX, IY = (int32)WorldY;
const bool bInBox = CP_SurfCol.bValid
&& CP_SurfCol.ChunkZ == ChunkCoord.Z && CP_SurfCol.Seed == Seed
&& IX >= CP_SurfCol.BaseX && IX < CP_SurfCol.BaseX + FSurfaceColumnCache::Dim
&& IY >= CP_SurfCol.BaseY && IY < CP_SurfCol.BaseY + FSurfaceColumnCache::Dim;
if (!bInBox)
// XY-keyed LRU box (shared down the whole vertical strate stack). Acquire centres a box on
// the first sample so the rest of the chunk's queries — incl. the ±Step margin ring — hit.
FSurfaceColumnBox& Box = CP_SurfCol.Acquire(IX, IY, CP_StrateKey, Seed);
const int32 CI = (IY - Box.BaseY) * FSurfaceColumnBox::Dim + (IX - Box.BaseX);
if (!Box.Computed[CI])
{
// Centre the box on this (first / boundary-crossing) sample so the rest of
// the chunk's column queries — including the ±Step margin ring — fall inside.
CP_SurfCol.BaseX = IX - FSurfaceColumnCache::Halo;
CP_SurfCol.BaseY = IY - FSurfaceColumnCache::Halo;
CP_SurfCol.ChunkZ = ChunkCoord.Z;
CP_SurfCol.Seed = Seed;
CP_SurfCol.bValid = true;
FMemory::Memzero(CP_SurfCol.Computed, sizeof(CP_SurfCol.Computed));
ComputeSurfaceColumn(WorldX, WorldY, ChunkCoord.Z, CP_Surface, CP_BiomeCtx,
CP_SurfaceBiomeParams, CP_BiomeCache,
Box.Cols[CI].TerrainZ, Box.Cols[CI].CeilSurf);
Box.Computed[CI] = true;
}
const int32 CI = (IY - CP_SurfCol.BaseY) * FSurfaceColumnCache::Dim + (IX - CP_SurfCol.BaseX);
if (!CP_SurfCol.Computed[CI])
{
CP_SurfCol.Cols[CI] = ComputeColumn(WorldX, WorldY);
CP_SurfCol.Computed[CI] = true;
}
Col = CP_SurfCol.Cols[CI];
Col = Box.Cols[CI];
}
else
{
Col = ComputeColumn(WorldX, WorldY);
ComputeSurfaceColumn(WorldX, WorldY, ChunkCoord.Z, CP_Surface, CP_BiomeCtx,
CP_SurfaceBiomeParams, CP_BiomeCache, Col.TerrainZ, Col.CeilSurf);
}
Result = SurfaceDensityFromColumn(WorldX, WorldY, WorldZ, Col.TerrainZ, Col.CeilSurf, CP_Surface);
@@ -1997,15 +1983,52 @@ float UVoxelGenerator::ComputeSurfaceCeiling(float WorldX, float WorldY,
{
const float H = Params.StrateTopWorldZ - Params.StrateBottomWorldZ;
const float SeedF = (float)Seed;
const float CeilZ = Params.StrateBottomWorldZ + H * Params.CeilingRelative;
float CeilNoise = 0.0f;
float CeilZ = Params.StrateBottomWorldZ + H * Params.CeilingRelative;
// Domain-warp the broad/ridge query coords so ceiling ridgelines and valleys wind
// organically (mirrors the ground heightfield's HeightWarp). Fine bumps below stay on the
// true XY so detail remains crisp and uncorrelated. 0 ⇒ no warp.
float QX = WorldX, QY = WorldY;
if (Params.CeilingWarpStrength > 0.0f)
{
const float WF = Params.CeilingWarpFrequency;
const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + SeedF * 0.71f, WorldY * WF + 2.3f, SeedF * 3.3f));
const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 6.1f, WorldY * WF + SeedF * 0.19f, SeedF * 4.7f));
QX += wx * VOXEL_NOISE_SCALE * Params.CeilingWarpStrength;
QY += wy * VOXEL_NOISE_SCALE * Params.CeilingWarpStrength;
}
// Broad SIGNED swell: raises/lowers the whole cap → big inverted hills and valleys.
if (Params.CeilingUndulation > 0.0f)
{
const float Swell = FractalNoise3D(FVector(
QX * Params.CeilingUndulationFrequency + SeedF * 1.9f,
QY * Params.CeilingUndulationFrequency + 13.0f,
SeedF * 0.5f), 3); // [-1,1]
CeilZ += Swell * VOXEL_NOISE_SCALE * Params.CeilingUndulation;
}
// Downward hang: everything here is >= 0 so features only protrude into the void (never
// punch up into the seal). Fine bumps + sharp ridged blades sum together.
float Hang = 0.0f;
if (Params.CeilingRoughness > 0.0f)
{
CeilNoise = FMath::Abs(FractalNoise3D(FVector(
WorldX * 0.04f + 5.0f, WorldY * 0.04f + 6.0f, SeedF * 2.1f), 3))
* VOXEL_NOISE_SCALE * Params.CeilingRoughness;
Hang += FMath::Abs(FractalNoise3D(FVector(
WorldX * Params.CeilingRoughnessFrequency + 5.0f,
WorldY * Params.CeilingRoughnessFrequency + 6.0f,
SeedF * 2.1f), 3)) * VOXEL_NOISE_SCALE * Params.CeilingRoughness;
}
return CeilZ - CeilNoise;
if (Params.CeilingRidgeStrength > 0.0f)
{
float Ridge = RidgedNoise3D(FVector(
QX * Params.CeilingRidgeFrequency + 31.0f,
QY * Params.CeilingRidgeFrequency + 47.0f,
SeedF * 1.1f), 4); // [-1,1]
Ridge = Ridge * 0.5f + 0.5f; // [0,1] hanging ridgelines
Hang += Ridge * Params.CeilingRidgeStrength;
}
return CeilZ - Hang;
}
float UVoxelGenerator::SurfaceDensityFromColumn(float WorldX, float WorldY, float WorldZ,
@@ -2034,6 +2057,94 @@ float UVoxelGenerator::SurfaceDensityFromColumn(float WorldX, float WorldY, floa
return -Density;
}
void UVoxelGenerator::ResolveSurfaceChunkParams(const FIntVector& ChunkCoord,
FSurfaceGenerationParams& OutSurface, FBiomeContext& OutBiomeCtx,
TArray<FSurfaceGenerationParams>& OutBiomeParams) const
{
OutSurface = StrateManager->GetSurfaceParamsForChunk(ChunkCoord);
OutBiomeCtx = StrateManager->GetBiomeContextForChunk(ChunkCoord);
// Per-biome surface params: each biome's override (when it overrides SurfaceWorld) else the
// strate's, with STRUCTURAL fields forced from the strate (Z bounds, seal, base, water level)
// so seals/spine/water stay intact.
OutBiomeParams.Reset();
if (OutBiomeCtx.IsValid())
{
const UVoxelStrateDefinition* Def = StrateManager->GetStrateForChunk(ChunkCoord);
OutBiomeParams.Reserve(OutBiomeCtx.Biomes.Num());
for (const FBiomeResolved& BR : OutBiomeCtx.Biomes)
{
FSurfaceGenerationParams P = OutSurface; // strate base (+ structural)
const UVoxelBiomeDefinition* B =
(Def && Def->Biomes.IsValidIndex(BR.Index)) ? Def->Biomes[BR.Index] : nullptr;
if (B && B->bOverrideTerrain && B->GeneratorType == ECaveGeneratorType::SurfaceWorld)
{
P = B->SurfaceParams; // biome shape
P.StrateTopWorldZ = OutSurface.StrateTopWorldZ;
P.StrateBottomWorldZ = OutSurface.StrateBottomWorldZ;
P.BoundarySealThickness = OutSurface.BoundarySealThickness;
P.BaseDensity = OutSurface.BaseDensity;
P.WaterLevelRelative = OutSurface.WaterLevelRelative; // shared water plane
}
OutBiomeParams.Add(P);
}
}
}
void UVoxelGenerator::ComputeSurfaceColumn(float WorldX, float WorldY, int32 ChunkZ,
const FSurfaceGenerationParams& BaseSurface, const FBiomeContext& BiomeCtx,
const TArray<FSurfaceGenerationParams>& BiomeParams, FChunkBiomeCache& BiomeCache,
float& OutTerrainZ, float& OutCeilSurf) const
{
const FSurfaceGenerationParams* PD = &BaseSurface;
const FSurfaceGenerationParams* PN = &BaseSurface;
float W = 0.0f;
if (BiomeCtx.IsValid() && BiomeParams.Num() > 0)
{
const FBiomeSample Smp = ResolveBiomeSampleAt(WorldX, WorldY, ChunkZ, BiomeCtx, BiomeCache);
const int32 Di = BiomeParams.IsValidIndex(Smp.DominantIndex) ? Smp.DominantIndex : 0;
PD = &BiomeParams[Di];
if (Smp.NeighborWeight > 0.0f && BiomeParams.IsValidIndex(Smp.NeighborIndex))
{
PN = &BiomeParams[Smp.NeighborIndex];
W = Smp.NeighborWeight;
}
}
OutTerrainZ = ComputeSurfaceTerrainZ(WorldX, WorldY, *PD);
if (W > 0.0f) OutTerrainZ = FMath::Lerp(OutTerrainZ, ComputeSurfaceTerrainZ(WorldX, WorldY, *PN), W);
OutCeilSurf = ComputeSurfaceCeiling(WorldX, WorldY, *PD);
}
bool UVoxelGenerator::GetSurfaceHeightAt(float WorldX, float WorldY, int32 ChunkZ,
float& OutTerrainZ, float& OutCeilSurf) const
{
if (!StrateManager) return false;
const FIntVector ChunkCoord(
FMath::FloorToInt(WorldX / (float)CHUNK_SIZE),
FMath::FloorToInt(WorldY / (float)CHUNK_SIZE),
ChunkZ);
if (StrateManager->GetGeneratorTypeForChunk(ChunkCoord) != ECaveGeneratorType::SurfaceWorld)
{
return false;
}
// Own per-chunk cache (independent of GetDensityAt's CP_*) so interleaved gen/deco tasks on the
// same worker don't thrash each other. All columns of one deco cell share one chunk ⇒ resolve once.
thread_local FIntVector OC_Chunk(INT32_MAX, INT32_MAX, INT32_MAX);
thread_local FSurfaceGenerationParams OC_Surface;
thread_local FBiomeContext OC_BiomeCtx;
thread_local TArray<FSurfaceGenerationParams> OC_BiomeParams;
thread_local FChunkBiomeCache OC_BiomeCache;
if (ChunkCoord != OC_Chunk)
{
OC_Chunk = ChunkCoord;
ResolveSurfaceChunkParams(ChunkCoord, OC_Surface, OC_BiomeCtx, OC_BiomeParams);
}
ComputeSurfaceColumn(WorldX, WorldY, ChunkZ, OC_Surface, OC_BiomeCtx, OC_BiomeParams, OC_BiomeCache,
OutTerrainZ, OutCeilSurf);
return true;
}
float UVoxelGenerator::GetSurfaceDensity(float WorldX, float WorldY, float WorldZ,
const FSurfaceGenerationParams& ParamsD,
const FSurfaceGenerationParams& ParamsN,
@@ -2322,6 +2433,43 @@ const UVoxelBiomeDefinition* UVoxelGenerator::GetDominantBiomeAt(float WorldX, f
return (Def && Def->Biomes.IsValidIndex(StrateBiomeIdx)) ? Def->Biomes[StrateBiomeIdx] : nullptr;
}
void UVoxelGenerator::GetBiomeMaterialAt(float WorldX, float WorldY, float WorldZ,
int32& OutDominantPalette, int32& OutNeighborPalette, float& OutBlendWeight) const
{
OutDominantPalette = 0;
OutNeighborPalette = 0;
OutBlendWeight = 0.0f;
if (!StrateManager) return;
const FIntVector ChunkCoord(
FMath::FloorToInt(WorldX / CHUNK_SIZE),
FMath::FloorToInt(WorldY / CHUNK_SIZE),
FMath::FloorToInt(WorldZ / CHUNK_SIZE));
// Per-chunk biome context cache, mirroring GetDensityAt: mesher vertices cluster by chunk,
// so the (cheap) flatten + (noise-heavy, box-validated) grid stay warm across a tile.
thread_local FIntVector BM_Chunk(INT32_MAX, INT32_MAX, INT32_MAX);
thread_local FBiomeContext BM_Ctx;
thread_local FChunkBiomeCache BM_Cache;
if (ChunkCoord != BM_Chunk)
{
BM_Chunk = ChunkCoord;
BM_Ctx = StrateManager->GetBiomeContextForChunk(ChunkCoord);
}
if (!BM_Ctx.IsValid()) return; // strate has no biomes → default palette
const FBiomeSample S = ResolveBiomeSampleAt(WorldX, WorldY, ChunkCoord.Z, BM_Ctx, BM_Cache);
if (BM_Ctx.Biomes.IsValidIndex(S.DominantIndex))
{
OutDominantPalette = BM_Ctx.Biomes[S.DominantIndex].MaterialPaletteIndex;
// Neighbour defaults to the dominant so an interior vertex blends to itself (no seam).
OutNeighborPalette = BM_Ctx.Biomes.IsValidIndex(S.NeighborIndex)
? BM_Ctx.Biomes[S.NeighborIndex].MaterialPaletteIndex
: OutDominantPalette;
OutBlendWeight = S.NeighborWeight; // 0 inside a cell → ~0.5 at the border
}
}
//=============================================================================
// VERTICAL-SHAFT GENERATOR (ECaveGeneratorType::VerticalShafts)
//=============================================================================
@@ -72,14 +72,17 @@ FVector UVoxelMarchingCubesMesher::ComputeGradientNormal(float WorldX, float Wor
// MAIN ALGORITHM
//=============================================================================
FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(const FVoxelChunk& Chunk, int32 Step)
FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, int32 Step, int32 InCellsPerAxis)
{
FVoxelMeshData MeshData;
if (!Generator) return MeshData;
// Step valide = puissance de 2 dans [1, 4]
Step = FMath::Clamp(Step, 1, 4);
// Cell size in voxels. No upper clamp: coarse clipmap levels use bigger steps (the EXTENT
// grows). Coarse tiles also use FEWER cells (InCellsPerAxis) for cheaper gen.
Step = FMath::Max(1, Step);
const FVector ChunkWorldPos = Chunk.GetWorldPosition();
// World-cm origin of the tile's min corner (positions are built relative to this).
const FVector ChunkWorldPos = FVector(OriginVoxels) * VOXEL_SIZE;
//=========================================================================
// VERTEX DEDUPLICATION MAP
@@ -87,7 +90,10 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(const FVoxelChunk& Chunk,
// Clé = position quantifiée au 0.01 unité (FIntVector).
// Valeur = index dans MeshData.Vertices.
// Les vertices partagés permettent des normales lisses et réduisent le count ~3x.
TMap<FIntVector, int32> VertexMap;
// Réutilisé d'une tuile à l'autre (thread_local) : Reset garde les buckets alloués →
// plus de (ré)allocation de hash-map par tuile (chaque worker a son propre exemplaire).
static thread_local TMap<FIntVector, int32> VertexMap;
VertexMap.Reset();
// Normale fournie par l'appelant (gradient lu dans la grille de densité, T1.b) —
// plus d'échantillonnage de densité par vertex. RawNormal pointe solide→air ; on la
@@ -120,6 +126,24 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(const FVoxelChunk& Chunk,
// UVs planaires — le triplanar mapping se fait dans le matériau.
MeshData.UVs.Add(FVector2D(WorldPos.X / VOXEL_SIZE, WorldPos.Y / VOXEL_SIZE));
//---------------------------------------------------------------------
// VERTEX COLOUR — masques pour le matériau triplanar maître (F6).
// R = index de palette du biome DOMINANT (0-255) — re-skin par biome.
// G = pente (0 = sol/plafond plat, 1 = paroi verticale) — roche sur falaises.
// B = poids de fondu de bordure (0 au cœur d'un biome → ~0.5 à la frontière).
// A = index de palette du biome VOISIN — le matériau lerp(R,A) par B → bords sans couture.
// La hauteur/snow-line se déduit de WorldPosition.Z dans le matériau (pas besoin de canal).
// Coût: une résolution biome par vertex UNIQUE (déduplication) ; nul si la strate n'a pas
// de biomes (GetBiomeMaterialAt sort en O(1) → palette 0 partout).
int32 PalD = 0, PalN = 0; float BlendW = 0.0f;
Generator->GetBiomeMaterialAt(WorldPos.X / VOXEL_SIZE, WorldPos.Y / VOXEL_SIZE,
WorldPos.Z / VOXEL_SIZE, PalD, PalN, BlendW);
const uint8 R = (uint8)FMath::Clamp(PalD, 0, 255);
const uint8 A = (uint8)FMath::Clamp(PalN, 0, 255);
const uint8 G = (uint8)FMath::Clamp(FMath::RoundToInt((1.0f - FMath::Abs((float)Normal.Z)) * 255.0f), 0, 255);
const uint8 Bc = (uint8)FMath::Clamp(FMath::RoundToInt(BlendW * 255.0f), 0, 255);
MeshData.Colors.Add(FColor(R, G, Bc, A));
return NewIndex;
};
@@ -164,11 +188,13 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(const FVoxelChunk& Chunk,
// échantillons monde purs qu'un chunk voisin, donc les normales restent continues aux bords
// de chunk (pas de couture de shading). La géométrie est inchangée au bit près (mêmes
// positions d'arête) ; seules les normales changent.
const int32 CellsPerAxis = CHUNK_SIZE / Step;
const int32 CellsPerAxis = FMath::Clamp(InCellsPerAxis, 2, CHUNK_SIZE); // coarse tiles use fewer
const int32 GridDim = CellsPerAxis + 1;
const int32 MDim = GridDim + 2; // +1 marge de chaque côté
TArray<float> DensityGrid;
// Réutilise le tampon entre tuiles (thread_local) : SetNumUninitialized garde la
// capacité, donc plus de malloc/free de ~170 Ko (35³ floats) par tuile.
static thread_local TArray<float> DensityGrid;
DensityGrid.SetNumUninitialized(MDim * MDim * MDim);
for (int32 gz = -1; gz <= GridDim; gz++)
{
@@ -176,8 +202,12 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(const FVoxelChunk& Chunk,
{
for (int32 gx = -1; gx <= GridDim; gx++)
{
// World voxel = tile origin + grid offset scaled by the cell size (Step).
DensityGrid[((gz + 1) * MDim + (gy + 1)) * MDim + (gx + 1)] =
GetDensity(Chunk, gx * Step, gy * Step, gz * Step);
Generator->GetDensityAt(
OriginVoxels.X + gx * Step,
OriginVoxels.Y + gy * Step,
OriginVoxels.Z + gz * Step);
}
}
}
@@ -208,35 +238,37 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(const FVoxelChunk& Chunk,
{
for (int32 cx = 0; cx < CellsPerAxis; cx++)
{
// Densités + positions + gradients aux 8 coins (lus dans la grille).
// PASSE 1 : densités aux 8 coins + index de cas MC SEULEMENT.
// ~70% des cellules d'un chunk sont tout-roc ou tout-air (aucune surface) ;
// on les rejette ICI, AVANT de payer les 8 positions + 8 gradients (48 lectures
// grille + maths vectorielles). Sortie bit-identique : positions et gradients ne
// servent qu'aux cellules réellement traversées par l'isosurface.
float Densities[8];
int32 CaseIndex = 0;
for (int32 i = 0; i < 8; i++)
{
const float D = SampleG(cx + CornerOffsets[i].X,
cy + CornerOffsets[i].Y,
cz + CornerOffsets[i].Z);
Densities[i] = D;
if (D >= IsoLevel) CaseIndex |= (1 << i);
}
if (MCEdgeTable[CaseIndex] == 0) continue; // Pas de surface ici → skip
// PASSE 2 : positions + gradients aux 8 coins (uniquement si surface présente).
FVector Positions[8];
FVector Gradients[8];
for (int32 i = 0; i < 8; i++)
{
const int32 GX = cx + CornerOffsets[i].X;
const int32 GY = cy + CornerOffsets[i].Y;
const int32 GZ = cz + CornerOffsets[i].Z;
Densities[i] = SampleG(GX, GY, GZ);
Positions[i] = ChunkWorldPos
+ FVector(GX * Step, GY * Step, GZ * Step) * VOXEL_SIZE;
Gradients[i] = GradAt(GX, GY, GZ);
}
// Index de cas MC (8 bits, un par coin)
int32 CaseIndex = 0;
for (int32 i = 0; i < 8; i++)
{
if (Densities[i] >= IsoLevel)
{
CaseIndex |= (1 << i);
}
}
if (MCEdgeTable[CaseIndex] == 0) continue; // Pas de surface ici
// Interpolation des positions + normales sur les arêtes traversées. Le t est
// calculé exactement comme InterpolateEdge → positions bit-identiques (topologie
// inchangée) ; la normale interpole les gradients de coin par le même t.
@@ -276,5 +308,70 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(const FVoxelChunk& Chunk,
}
}
//=========================================================================
// SKIRTS — boucheurs de fissures aux coutures de LOD (clipmap)
//=========================================================================
// Deux tuiles de niveaux voisins maillent à des résolutions différentes : leurs iso-surfaces
// ne se rejoignent pas parfaitement le long de la face partagée → une fine fissure traversante.
// On la scelle en suspendant une courte « jupe » (mur) sous chaque arête de surface posée sur
// l'une des 6 faces externes de la tuile, extrudée VERS LE SOLIDE le long de la normale inversée
// sur ~une taille de cellule. Là où la surface du voisin est décalée, les jupes des deux tuiles
// se recouvrent dans la roche et ferment le trou ; ailleurs la jupe est enterrée et invisible.
// Émis en DOUBLE FACE (deux orientations) pour s'afficher quel que soit le côté caméra / le
// matériau. Une arête de surface posée sur une face de frontière a sa coordonnée d'axe EXACTE
// (l'interpolation MC garde fixe l'axe de la face) → comparaison flottante exacte fiable.
if (bGenerateSkirts && MeshData.Triangles.Num() > 0)
{
const float ExtentCm = (float)(CellsPerAxis * Step) * VOXEL_SIZE;
const float MinX = ChunkWorldPos.X, MinY = ChunkWorldPos.Y, MinZ = ChunkWorldPos.Z;
const float MaxX = MinX + ExtentCm, MaxY = MinY + ExtentCm, MaxZ = MinZ + ExtentCm;
const float SkirtDepth = FMath::Max(1.0f, SkirtCells) * (float)Step * VOXEL_SIZE;
auto OnBoundaryPlane = [&](const FVector& A, const FVector& B) -> bool
{
return (A.X == MinX && B.X == MinX) || (A.X == MaxX && B.X == MaxX)
|| (A.Y == MinY && B.Y == MinY) || (A.Y == MaxY && B.Y == MaxY)
|| (A.Z == MinZ && B.Z == MinZ) || (A.Z == MaxZ && B.Z == MaxZ);
};
auto AddSkirtVert = [&](const FVector& Pos, const FVector& Nrm, const FColor& Col) -> int32
{
const int32 Idx = MeshData.Vertices.Num();
MeshData.Vertices.Add(Pos);
MeshData.Normals.Add(Nrm);
MeshData.UVs.Add(FVector2D(Pos.X / VOXEL_SIZE, Pos.Y / VOXEL_SIZE));
MeshData.Colors.Add(Col); // hérite la couleur du vertex source (parallèle aux autres tableaux)
return Idx;
};
// On ajoute en itérant : on fige le nombre de triangles de surface et on n'ajoute qu'au-delà.
const int32 BaseTriNum = MeshData.Triangles.Num();
for (int32 t = 0; t + 2 < BaseTriNum; t += 3)
{
const int32 Tri[3] = { MeshData.Triangles[t], MeshData.Triangles[t + 1], MeshData.Triangles[t + 2] };
for (int32 e = 0; e < 3; ++e)
{
const int32 iA = Tri[e], iB = Tri[(e + 1) % 3];
// COPIES par valeur — AddSkirtVert réalloue Vertices/Normals (invaliderait des refs).
const FVector PA = MeshData.Vertices[iA];
const FVector PB = MeshData.Vertices[iB];
if (!OnBoundaryPlane(PA, PB)) continue;
const FVector NA = MeshData.Normals[iA];
const FVector NB = MeshData.Normals[iB];
const FColor CA = MeshData.Colors[iA];
const FColor CB = MeshData.Colors[iB];
const int32 iA2 = AddSkirtVert(PA - NA * SkirtDepth, NA, CA);
const int32 iB2 = AddSkirtVert(PB - NB * SkirtDepth, NB, CB);
// Quad (iA, iB, iB2, iA2) → 2 triangles, émis dans LES DEUX orientations.
MeshData.Triangles.Add(iA); MeshData.Triangles.Add(iB); MeshData.Triangles.Add(iB2);
MeshData.Triangles.Add(iA); MeshData.Triangles.Add(iB2); MeshData.Triangles.Add(iA2);
MeshData.Triangles.Add(iA); MeshData.Triangles.Add(iB2); MeshData.Triangles.Add(iB);
MeshData.Triangles.Add(iA); MeshData.Triangles.Add(iA2); MeshData.Triangles.Add(iB2);
}
}
}
return MeshData;
}
@@ -348,6 +348,9 @@ void UVoxelStrateManager::GeneratePassages()
UE_LOG(LogTemp, Log, TEXT("[StrateManager] Surface entry shaft at (0,0) topZ=%.0f R=%.1f"),
TopZ, Entry.Radius);
}
// Invalidate any thread_local per-chunk passage shortlists (see EvaluateModifierSDF).
++PassagesVersion;
}
//=============================================================================
@@ -356,6 +359,53 @@ void UVoxelStrateManager::GeneratePassages()
float UVoxelStrateManager::EvaluateModifierSDF(float WorldX, float WorldY, float WorldZ) const
{
//=========================================================================
// PER-CHUNK PASSAGE SHORTLIST
//=========================================================================
// This runs PER VOXEL (35³ per tile). The vast majority of chunks are nowhere near a
// descent passage, yet every voxel still walked the WHOLE Passages array just to reject
// each one on a squared-distance test (Passages.Num() × 35³ rejects per tile, all wasted).
// Cache, per chunk, the shortlist of passages whose bounds actually reach this chunk —
// usually EMPTY → instant FLT_MAX return (no carve). Indices (not pointers) + a version
// stamp keep it safe across a GeneratePassages rebuild. Output is bit-identical: the
// shortlist is a conservative superset (chunk bounding sphere vs each passage bound).
thread_local FIntVector SL_Chunk(INT32_MAX, INT32_MAX, INT32_MAX);
thread_local uint32 SL_Version = 0xFFFFFFFFu;
thread_local TArray<int32> SL_Nearby;
const FIntVector ChunkCoord(
FMath::FloorToInt(WorldX / (float)CHUNK_SIZE),
FMath::FloorToInt(WorldY / (float)CHUNK_SIZE),
FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE));
if (ChunkCoord != SL_Chunk || SL_Version != PassagesVersion)
{
SL_Chunk = ChunkCoord;
SL_Version = PassagesVersion;
SL_Nearby.Reset();
// Chunk bounding sphere (centre + half-diagonal), padded by the blend radius. A passage
// is kept iff its bounding sphere overlaps the chunk's — i.e. some voxel here could be
// inside its per-voxel reject radius. √3/2 · CHUNK_SIZE ≈ 0.866 · size.
const FVector CCenter(
(ChunkCoord.X + 0.5f) * (float)CHUNK_SIZE,
(ChunkCoord.Y + 0.5f) * (float)CHUNK_SIZE,
(ChunkCoord.Z + 0.5f) * (float)CHUNK_SIZE);
const float ChunkR = (float)CHUNK_SIZE * 0.8660254f + 3.0f; // +BlendK
for (int32 i = 0; i < Passages.Num(); ++i)
{
const FVoxelPassage& P = Passages[i];
const float Reach = FMath::Sqrt(P.BoundRadiusSq) + ChunkR;
if (FVector::DistSquared(CCenter, P.BoundCenter) <= Reach * Reach)
{
SL_Nearby.Add(i);
}
}
}
if (SL_Nearby.Num() == 0) return FLT_MAX; // no passage near this chunk → no carve
float MinSDF = FLT_MAX;
const float BlendK = 3.0f; // Smooth blend for passage junctions
@@ -365,8 +415,9 @@ float UVoxelStrateManager::EvaluateModifierSDF(float WorldX, float WorldY, float
// entry is a simple straight tube. A bounding-sphere reject skips far passages.
//=========================================================================
const FVector Pos(WorldX, WorldY, WorldZ);
for (const FVoxelPassage& P : Passages)
for (int32 PIdx : SL_Nearby)
{
const FVoxelPassage& P = Passages[PIdx];
// BOUNDING-SPHERE REJECT: skip passages this voxel can't possibly be inside.
// EvaluateModifierSDF runs PER VOXEL and used to evaluate every passage's full
// capsule chain unconditionally — the dominant lag source once passages became
@@ -454,6 +505,22 @@ UVoxelStrateDefinition* UVoxelStrateManager::GetStrateForChunk(const FIntVector&
return nullptr;
}
bool UVoxelStrateManager::GetStrateChunkZBounds(int32 ChunkZ, int32& OutTopChunkZ, int32& OutBottomChunkZ) const
{
// Strate-aware vertical streaming. Returns the chunk-Z span of the strate containing
// ChunkZ; false if ChunkZ is in the inter-strate gap (or outside the layout) — there the
// caller leaves the vertical view UNCLAMPED, since the gap is a brief see-both-sides
// descent transition. TopChunkZ > BottomChunkZ (Z decreases downward).
const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkZ);
if (SlotIdx < 0)
{
return false;
}
OutTopChunkZ = StrateLayout[SlotIdx].TopChunkZ;
OutBottomChunkZ = StrateLayout[SlotIdx].BottomChunkZ;
return true;
}
ECaveGeneratorType UVoxelStrateManager::GetGeneratorTypeForChunk(const FIntVector& ChunkCoord) const
{
// Look up which slot this chunk falls into.
@@ -559,6 +626,7 @@ FBiomeContext UVoxelStrateManager::GetBiomeContextForChunk(const FIntVector& Chu
R.ReliefMin = B->ReliefMin; R.ReliefMax = B->ReliefMax;
R.MoistureMin = B->MoistureMin; R.MoistureMax = B->MoistureMax;
R.DebugColor = B->DebugColor.ToFColor(true);
R.MaterialPaletteIndex = B->MaterialPaletteIndex;
Out.Biomes.Add(R);
}
return Out;
+407 -344
View File
@@ -34,18 +34,21 @@ void AVoxelWorld::RegenerateAllChunks()
// ProcessPendingChunks will discard any result with an old epoch.
GenerationEpoch++;
// Collect all loaded chunk coords
TArray<FIntVector> AllCoords;
ChunkMeshes.GetKeys(AllCoords);
// Tear down every tile component, then clear all tile state. Components are GC-safe via
// actor ownership; destroying them here is immediate.
const int32 Count = LoadedTiles.Num();
for (auto& Pair : TileComponents) { if (Pair.Value) Pair.Value->DestroyComponent(); }
TileComponents.Empty();
LoadedTiles.Empty();
// Unload every chunk (destroys mesh components + data)
for (const FIntVector& Coord : AllCoords)
{
UnloadChunk(Coord);
}
// Decorations/water are keyed per level-0 chunk — clear them all.
if (ContentManager) { ContentManager->ClearAll(); }
// Clear pending set — stale tasks will be discarded by epoch check
PendingChunkCoord.Empty();
// Clear pending set — stale tasks will be discarded by the epoch check.
PendingTiles.Empty();
// Tiles are already destroyed above — drop any deferred-teardown keys so the drain doesn't
// try to UnloadTile coords that no longer exist.
PendingUnload.Empty();
// Reset streaming state so the next Tick rebuilds the desired set and reloads.
LastUpdateCenter = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
@@ -53,8 +56,8 @@ void AVoxelWorld::RegenerateAllChunks()
DesiredSorted.Reset();
DesiredSet.Reset();
// Tick will reload all chunks on the next frame with fresh params.
UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] RegenerateAllChunks (epoch %u): unloaded %d chunks"), GenerationEpoch, AllCoords.Num());
// Tick will reload all tiles on the next frame with fresh params.
UE_LOG(LogTemp, Log, TEXT("[VoxelWorld] RegenerateAllChunks (epoch %u): cleared %d tiles"), GenerationEpoch, Count);
}
void AVoxelWorld::RebuildStrates()
@@ -192,7 +195,14 @@ void AVoxelWorld::EndPlay(const EEndPlayReason::Type EndPlayReason)
// Drain any queued results
FChunkResult Discard;
while (ProcessQueue.Dequeue(Discard)) {}
PendingChunkCoord.Empty();
PendingTiles.Empty();
PendingUnload.Empty();
// Stop + drain the decoration march tasks (they read the Generator) before UObject teardown.
if (ContentManager)
{
ContentManager->NotifyShutdown();
}
// Destroy any spawned atmosphere layer actors.
if (AtmosphereManager)
@@ -223,12 +233,28 @@ void AVoxelWorld::BeginPlay()
return;
}
// Tiles never move once generated, so make the actor root STATIC. RMC already requests the
// cached static DRAW path (section group DrawType defaults to Static), but a Movable parent
// forces every child back to Movable — which also defeats Virtual Shadow Map caching (Movable
// geometry re-renders its shadow every frame; that's the cost that made us turn VSM off). With
// a Static root + Static tile components, draws cache AND VSM can cache the terrain's shadows,
// so VSM can be turned back on cheaply. Content decoration HISMs are likewise Static (placed once,
// never move). Movable children (atmosphere fog/sky, water planes — these follow the player) under a
// Static root are allowed. NOTE: a Static actor can't be moved in-editor —
// VoxelWorld is expected to sit at the origin.
if (USceneComponent* Root = GetRootComponent())
{
Root->SetMobility(EComponentMobility::Static);
}
// Générateur + mesher (UObjects légers)
Generator = NewObject<UVoxelGenerator>(this);
Mesher = NewObject<UVoxelMarchingCubesMesher>(this);
Generator->InitializeSettings(Settings);
Mesher->SetGenerator(Generator);
Mesher->bGenerateSkirts = Settings->bGenerateSkirts;
Mesher->SkirtCells = Settings->SkirtCells;
// Système de strates — piloté par le pool et les fixed entries dans Settings.
if (Settings->StratePool.Num() > 0)
@@ -246,9 +272,9 @@ void AVoxelWorld::BeginPlay()
DiffLayer->SetBudget(Settings->MaxModifications, Settings->MaxBrushRadius, Settings->MaxTotalVolume);
Generator->SetDiffLayer(DiffLayer);
// Content manager — scatters decorations/actors + water per chunk as they stream in.
// Content manager — distance-based decoration grid (no LOD pop) + level-0 water planes.
ContentManager = NewObject<UVoxelContentManager>(this);
ContentManager->Initialize(this, StrateManager, Generator, Settings->Seed);
ContentManager->Initialize(this, StrateManager, Generator, Settings, Settings->Seed);
// Atmosphere manager — per-strate fog + ambient + persistent ceiling/floor layers.
if (bManageAtmosphere && StrateManager)
@@ -269,6 +295,7 @@ void AVoxelWorld::BeginPlay()
void AVoxelWorld::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_Tick); // game-thread streaming orchestration breakdown
FVector PlayerLastPos = GetPlayerPosition();
if ((PlayerLastPos != FVector::ZeroVector)) {
@@ -279,11 +306,15 @@ void AVoxelWorld::Tick(float DeltaTime)
}
if (ContentManager)
{
// Strate light culling — no-op unless the player changed strate.
ContentManager->SetActiveStrate(GetStrateAtPosition(PlayerLastPos));
// Distance-based decoration streaming (no LOD pop). Cheap no-op unless the player crosses
// a decoration cell boundary or changes strate; otherwise just drains the spawn budget.
{ TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateDecorations); ContentManager->UpdateDecorations(PlayerLastPos); }
// One strate-global ocean plane following the player (water at every LOD, to the horizon).
{ TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateWater); ContentManager->UpdateWater(PlayerLastPos); }
}
}
ProcessPendingChunks();
ProcessUnloadQueue();
#if ENABLE_DRAW_DEBUG
// Inter-strate passage overlay (cyan path, green=upper / red=lower endpoints).
@@ -393,6 +424,7 @@ bool AVoxelWorld::IsChunkInRange(const FIntVector& ChunkCoord, const FIntVector&
void AVoxelWorld::ProcessPendingChunks()
{
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ProcessPending);
// This runs on the game thread, called from Tick.
//
// STEPS:
@@ -421,7 +453,7 @@ void AVoxelWorld::ProcessPendingChunks()
FChunkResult DequeuedChunk;
while (ProcessQueue.Dequeue(DequeuedChunk))
{
PendingChunkCoord.Remove(DequeuedChunk.ChunkCoord);
PendingTiles.Remove(DequeuedChunk.Tile);
// Discard results from a previous generation epoch (stale).
if (DequeuedChunk.Epoch != GenerationEpoch)
@@ -429,18 +461,17 @@ void AVoxelWorld::ProcessPendingChunks()
continue;
}
// Always register the chunk as loaded (even if empty — so we don't re-generate it).
Chunks.Add(DequeuedChunk.ChunkCoord, DequeuedChunk.Chunk);
ChunkLODs.Add(DequeuedChunk.ChunkCoord, DequeuedChunk.LODLevel);
// Mark the tile loaded (even if empty — so we don't re-submit it).
LoadedTiles.Add(DequeuedChunk.Tile);
// Empty mesh = all air chunk — nothing to render, but still "loaded".
// Empty mesh = all-air tile — nothing to render, but still "loaded".
if (DequeuedChunk.MeshData.IsEmpty())
{
continue;
}
// Apply mesh (GPU upload) — this is the expensive part we budget.
ApplyMeshToChunk(DequeuedChunk.ChunkCoord, DequeuedChunk.MeshData);
ApplyMeshToTile(DequeuedChunk.Tile, DequeuedChunk.MeshData);
MeshesApplied++;
if (MeshesApplied >= MaxApplies)
@@ -451,145 +482,294 @@ void AVoxelWorld::ProcessPendingChunks()
}
void AVoxelWorld::ProcessUnloadQueue()
{
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ProcessUnload);
// Budgeted teardown: destroy at most a few tiles' components + content actors per frame, so a
// fast traversal's whole-shell cull (dozens of UnloadTile in one frame) doesn't spike the game
// thread. The budget auto-scales with the backlog (PendingUnload/4) so it never falls far behind.
if (PendingUnload.Num() == 0) return;
// Drain the floor budget, scaling up with the backlog so we never fall far behind, but capped
// at 4× the floor so a huge backlog (extreme speed) can't itself become a one-frame spike — the
// excess just lingers a few more frames (it's all behind the player, out of view).
const int32 Floor = Settings ? FMath::Max(1, Settings->MaxUnloadsPerFrame) : 6;
int32 DestroyBudget = FMath::Clamp(PendingUnload.Num() / 4, Floor, Floor * 4);
TArray<FVoxelTileKey> Dequeued; // removed from the queue this frame (destroyed OR cancelled)
for (const FVoxelTileKey& T : PendingUnload)
{
if (DesiredSet.Contains(T))
{
// Re-desired before its turn (player reversed) — keep it; it's still loaded, just drop
// it from the queue. Doesn't count against the destroy budget.
Dequeued.Add(T);
continue;
}
UnloadTile(T);
Dequeued.Add(T);
if (--DestroyBudget <= 0) break;
}
for (const FVoxelTileKey& T : Dequeued) PendingUnload.Remove(T);
}
// Integer floor-division (correct for negatives), scalar + vector.
static FORCEINLINE int32 VF_FloorDiv(int32 V, int32 D)
{
return V >= 0 ? (V / D) : -(((-V) + D - 1) / D);
}
static FORCEINLINE FIntVector VF_FloorDiv(const FIntVector& V, int32 D)
{
return FIntVector(VF_FloorDiv(V.X, D), VF_FloorDiv(V.Y, D), VF_FloorDiv(V.Z, D));
}
void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center)
{
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_BuildDesiredTiles);
DesiredSorted.Reset();
DesiredSet.Reset();
const int32 R = Settings ? FMath::Max(1, Settings->ClipRadius) : 3;
const int32 MaxLevel = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4;
// Strate-aware VERTICAL band (in level-0 chunk-Z). Without this the clipmap generates the
// occluded volume above/below (sealed strates are light-tight, §8.7) AND the full underground
// depth — a huge column of invisible solid rock = the gen lag. So clamp the vertical reach to
// the player's strate ± margin (open-ceiling strates extend UP to the sky-cap). The clipmap
// stays full-reach HORIZONTALLY (the horizon) but limited vertically. ZLo/ZHi span ⇒ no clamp.
int32 ZLo = MIN_int32, ZHi = MAX_int32;
if (Settings && Settings->bClampViewToStrate && StrateManager)
{
const int32 Margin = Settings->StrateViewMarginChunks;
ZLo = Center.Z - Settings->ViewDistanceDown;
ZHi = Center.Z + Settings->ViewDistanceUp;
int32 StrTopZ = 0, StrBotZ = 0;
if (StrateManager->GetStrateChunkZBounds(Center.Z, StrTopZ, StrBotZ))
{
const ECaveGeneratorType GenType = StrateManager->GetGeneratorTypeForChunk(Center);
const bool bOpen = (GenType == ECaveGeneratorType::SurfaceWorld
|| GenType == ECaveGeneratorType::FloatingIslands);
ZLo = FMath::Max(ZLo, StrBotZ - Margin);
ZHi = bOpen ? (StrTopZ + Margin) : FMath::Min(ZHi, StrTopZ + Margin);
}
// else: in the bedrock gap → keep the player window (see both sides while descending).
}
// Concentric shells: level 0 near the player, each coarser level a 2× larger shell beyond.
// A level-L tile is dropped if it's fully covered by the finer (L-1) level's box — that's
// the inner hole, so the shells tile space without big gaps.
for (int32 L = 0; L <= MaxLevel; ++L)
{
const int32 Pow = 1 << L; // level-L tile = 2^L chunks
const FIntVector CL = VF_FloorDiv(Center, Pow); // player's level-L tile coord
const FIntVector CF = (L > 0) ? VF_FloorDiv(Center, Pow >> 1) : FIntVector::ZeroValue;
for (int32 dz = -R; dz <= R; ++dz)
for (int32 dy = -R; dy <= R; ++dy)
for (int32 dx = -R; dx <= R; ++dx)
{
const FIntVector T = CL + FIntVector(dx, dy, dz);
// Strate-aware vertical clamp: drop tiles whose level-0 chunk-Z footprint doesn't
// overlap [ZLo, ZHi] (the occluded strate above/below / deep underground).
const int32 TZLo = T.Z << L;
const int32 TZHi = ((T.Z + 1) << L) - 1;
if (TZHi < ZLo || TZLo > ZHi) continue;
if (L > 0)
{
// Covered by the finer level iff the level-(L-1) tiles 2T..2T+1 (per axis)
// all lie inside the finer box [CF-R, CF+R].
const bool bCovered =
(2 * T.X >= CF.X - R) && (2 * T.X + 1 <= CF.X + R) &&
(2 * T.Y >= CF.Y - R) && (2 * T.Y + 1 <= CF.Y + R) &&
(2 * T.Z >= CF.Z - R) && (2 * T.Z + 1 <= CF.Z + R);
if (bCovered) continue;
}
const FVoxelTileKey Key(T, L);
DesiredSorted.Add(Key);
DesiredSet.Add(Key);
}
}
// Nearest-first (by tile-centre distance to the player), so the closest tiles stream first.
const FVector PlayerVoxel = (FVector(Center) + FVector(0.5f, 0.5f, 0.5f)) * (float)CHUNK_SIZE;
DesiredSorted.Sort([&PlayerVoxel](const FVoxelTileKey& A, const FVoxelTileKey& B)
{
const FVector CA = A.CenterCm() / VOXEL_SIZE;
const FVector CB = B.CenterCm() / VOXEL_SIZE;
return FVector::DistSquared(CA, PlayerVoxel) < FVector::DistSquared(CB, PlayerVoxel);
});
}
bool AVoxelWorld::IsTileInClipRange(const FVoxelTileKey& Tile, const FIntVector& Center) const
{
// In range = the tile's centre falls within the OUTERMOST shell (level MaxLevel ± R). A
// loaded-but-not-desired tile in range is mid-LOD-transition (wait for its replacement);
// one out of range has left the view entirely (cull immediately).
const int32 R = Settings ? FMath::Max(1, Settings->ClipRadius) : 3;
const int32 MaxLevel = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4;
const int32 PowMax = 1 << MaxLevel;
const FIntVector CMax = VF_FloorDiv(Center, PowMax);
const FVector CV = Tile.CenterCm() / VOXEL_SIZE; // tile centre in voxels
const int32 SizeMax = CHUNK_SIZE * PowMax;
const FIntVector TMax(
VF_FloorDiv(FMath::FloorToInt(CV.X), SizeMax),
VF_FloorDiv(FMath::FloorToInt(CV.Y), SizeMax),
VF_FloorDiv(FMath::FloorToInt(CV.Z), SizeMax));
return FMath::Abs(TMax.X - CMax.X) <= R
&& FMath::Abs(TMax.Y - CMax.Y) <= R
&& FMath::Abs(TMax.Z - CMax.Z) <= R;
}
void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition)
{
//
// TOOLS:
// - WorldToChunkCoord(Position) - convert world pos to chunk coord
// - Chunks.Contains(Coord) - check if chunk exists
// - TArray<FIntVector> to build a list
// - Chunks is a TMap you can iterate with: for (auto& Pair : Chunks)
// where Pair.Key is the coordinate and Pair.Value is the chunk
const int32 ViewXY = Settings->ViewDistanceXY;
const int32 ViewUp = Settings->ViewDistanceUp;
const int32 ViewDown = Settings->ViewDistanceDown;
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateChunks);
const int32 MaxTasks = Settings ? Settings->MaxConcurrentTasks : 16;
const FIntVector CenterChunk = WorldToChunkCoord(CenterPosition);
const FIntVector CenterChunk = WorldToChunkCoord(CenterPosition); // player's level-0 tile
CurrentCenterChunk = CenterChunk;
//=========================================================================
// ONLY rebuild the desired set when the player crosses a chunk boundary.
// Standing still (or moving within a chunk) costs nothing here.
// Rebuild the desired tile set only when the player crosses a level-0 tile boundary.
//=========================================================================
if (CenterChunk != LastUpdateCenter)
{
LastUpdateCenter = CenterChunk;
bAllChunksLoaded = false;
DesiredSorted.Reset();
DesiredSet.Reset();
BuildDesiredTiles(CenterChunk);
for (int32 OffsetX = -ViewXY; OffsetX <= ViewXY; OffsetX++)
for (int32 OffsetY = -ViewXY; OffsetY <= ViewXY; OffsetY++)
for (int32 OffsetZ = -ViewDown; OffsetZ <= ViewUp; OffsetZ++)
// Cull loaded tiles that are no longer desired — STRICT LOAD-BEFORE-UNLOAD so crossing a
// shell boundary NEVER leaves a hole and we never drop a tile before its replacement exists:
// - out of clip range → left the view entirely, no replacement coming → cull now.
// - in range (mid-LOD-transition) → cull ONLY once EVERY desired tile that overlaps its
// footprint is loaded. A coarse tile is replaced by several finer tiles; the old
// center-owner check culled it as soon as the ONE tile over its centre was ready, so the
// not-yet-ready edges flashed a hole. Checking the whole covering set fixes that: the old
// tile stays at its current resolution until the better mesh is fully in, then drops.
auto FootprintsOverlap = [](const FVoxelTileKey& A, const FVoxelTileKey& B) -> bool
{
const FIntVector C = CenterChunk + FIntVector(OffsetX, OffsetY, OffsetZ);
if (IsChunkInRange(C, CenterChunk))
const int32 ea = A.ExtentVoxels(), eb = B.ExtentVoxels();
const FIntVector aMin = A.OriginVoxels(), bMin = B.OriginVoxels();
return aMin.X < bMin.X + eb && bMin.X < aMin.X + ea
&& aMin.Y < bMin.Y + eb && bMin.Y < aMin.Y + ea
&& aMin.Z < bMin.Z + eb && bMin.Z < aMin.Z + ea;
};
// Only a desired tile that ISN'T loaded yet can block a cull (an old tile must stay until its
// replacement is in). That set is small — just the few newly-needed tiles this crossing — so
// build it ONCE and test each candidate against it (was: scan ALL of DesiredSorted per tile,
// an O(loaded×desired) game-thread spike when fast movement turns many tiles non-desired).
// "Every covering desired tile loaded" ⟺ "no unloaded desired tile overlaps T" — equivalent.
TArray<FVoxelTileKey> DesiredPending;
for (const FVoxelTileKey& D : DesiredSorted)
{
DesiredSorted.Add(C);
DesiredSet.Add(C);
if (!LoadedTiles.Contains(D)) DesiredPending.Add(D);
}
auto ReplacementsReady = [&](const FVoxelTileKey& T) -> bool
{
for (const FVoxelTileKey& D : DesiredPending)
{
if (FootprintsOverlap(T, D)) return false; // a covering tile isn't ready → keep T
}
return true;
};
// Nearest-first so the closest chunks stream in before distant ones.
DesiredSorted.Sort([&CenterChunk](const FIntVector& A, const FIntVector& B)
{
const FIntVector DA = A - CenterChunk;
const FIntVector DB = B - CenterChunk;
return (DA.X * DA.X + DA.Y * DA.Y + DA.Z * DA.Z)
< (DB.X * DB.X + DB.Y * DB.Y + DB.Z * DB.Z);
});
// ReplacementsReady is O(DesiredPending); calling it for every loaded tile is O(loaded × pending),
// which goes QUADRATIC exactly when streaming falls behind (sprinting → DesiredPending balloons) —
// the measured 53 ms/cross CullTiles spike and a death spiral (behind → stall → further behind).
// KEEPING a transition tile longer is always hole-safe (the conservative direction), so when pending
// is large we skip the overlap test and just keep in-range transition tiles; the settled cull (once
// bAllChunksLoaded) + ProcessUnloadQueue reclaim them when streaming catches up. Out-of-range tiles
// still cull unconditionally (bounds memory). This caps the cull at O(loaded) and breaks the spiral.
const bool bDoOverlapCull = DesiredPending.Num() <= 48;
// Cull pass — O(loaded) thanks to the TSet (was O(loaded × desired)).
TArray<FIntVector> ChunksToRemove;
for (const auto& Pair : Chunks)
TArray<FVoxelTileKey> ToRemove;
auto Consider = [&](const FVoxelTileKey& T)
{
if (!DesiredSet.Contains(Pair.Key))
if (DesiredSet.Contains(T)) return;
// T comes from TileComponents keys then LoadedTiles-not-in-TileComponents → never twice.
if (!IsTileInClipRange(T, CenterChunk)) { ToRemove.Add(T); return; } // left the view → cull now
if (bDoOverlapCull && ReplacementsReady(T)) ToRemove.Add(T); // transition → cull when safe
};
{
ChunksToRemove.Add(Pair.Key);
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_CullTiles);
for (const auto& Pair : TileComponents) Consider(Pair.Key);
for (const FVoxelTileKey& T : LoadedTiles) { if (!TileComponents.Contains(T)) Consider(T); }
// Defer the actual teardown — ProcessUnloadQueue spreads it across frames so a fast
// traversal's whole-shell cull doesn't destroy dozens of components + actors in one frame.
for (const FVoxelTileKey& T : ToRemove) PendingUnload.Add(T);
}
}
for (const FIntVector& C : ChunksToRemove)
{
UnloadChunk(C);
}
// LOD reconciliation for chunks that stayed in view is handled by the persistent
// budgeted loop below — NOT as a one-shot here. Doing it once on the boundary-cross
// frame stranded any chunk skipped by a full task budget at its stale LOD; the
// persistent loop retries on later frames until every chunk sits at its target LOD.
}
//=========================================================================
// Submit pending work (budgeted). Runs each frame until everything desired is
// streamed in AT ITS TARGET LOD, then goes idle until the player moves again.
// Handles BOTH missing chunks (load) and loaded-but-wrong-LOD chunks (hot-swap
// re-mesh) in one persistent, budget-retried pass — so nothing is stranded.
// Submit pending work (budgeted, nearest-first). Once everything desired is loaded,
// do the "settled" cull of the deferred LOD-transition tiles, then go idle.
//=========================================================================
if (!bAllChunksLoaded)
{
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_SubmitTiles);
int32 Submitted = 0;
for (const FIntVector& C : DesiredSorted)
for (const FVoxelTileKey& T : DesiredSorted)
{
// Budget full → stop submitting this frame. Pending > 0 keeps us out of the
// idle state below, so we resume next frame (nearest-first, so closest first).
if (PendingChunkCoord.Num() >= MaxTasks) break;
if (PendingChunkCoord.Contains(C)) continue; // already in flight
bool bNeedsWork;
if (Chunks.Contains(C))
{
// Loaded — re-mesh only if its LOD no longer matches the current center.
// Hot-swap (LoadChunk, never unload-first): the old mesh stays visible
// until the new one lands, so no hole/pop during the swap.
const int32 DesiredLOD = GetLODForChunk(C, CurrentCenterChunk);
const int32* CurrentLOD = ChunkLODs.Find(C);
bNeedsWork = (CurrentLOD && *CurrentLOD != DesiredLOD);
}
else
{
bNeedsWork = true; // not loaded yet
}
if (!bNeedsWork) continue;
LoadChunk(C);
if (PendingTiles.Num() >= MaxTasks) break;
if (PendingTiles.Contains(T)) continue; // in flight
if (LoadedTiles.Contains(T)) continue; // already loaded (level is in the key — no LOD remesh)
LoadTile(T);
++Submitted;
}
// Idle only after a FULL scan submitted nothing and nothing is in flight — i.e.
// every desired chunk is loaded at its target LOD. (We only break early when the
// budget is full, which leaves Pending > 0, so this can't fire prematurely.)
if (Submitted == 0 && PendingChunkCoord.Num() == 0)
if (Submitted == 0 && PendingTiles.Num() == 0)
{
// Everything desired is loaded → load-before-unload is satisfied: drop the
// deferred (in-range, not-desired) transition tiles now. No holes.
for (const auto& Pair : TileComponents)
if (!DesiredSet.Contains(Pair.Key)) PendingUnload.Add(Pair.Key);
for (const FVoxelTileKey& T : LoadedTiles)
if (!DesiredSet.Contains(T) && !TileComponents.Contains(T)) PendingUnload.Add(T);
// Teardown is drained by ProcessUnloadQueue (budgeted) — single spike-free path.
bAllChunksLoaded = true;
}
}
}
void AVoxelWorld::LoadChunk(const FIntVector& ChunkCoord)
void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile)
{
if (PendingChunkCoord.Contains(ChunkCoord)) return;
if (PendingTiles.Contains(Tile)) return;
const int32 MaxTasks = Settings ? Settings->MaxConcurrentTasks : 16;
if (PendingChunkCoord.Num() >= MaxTasks)
if (PendingTiles.Num() >= MaxTasks)
{
return; // Budget plein — on attend qu'une tâche finisse
return; // Budget full — wait for a task to finish.
}
PendingChunkCoord.Add(ChunkCoord);
PendingTiles.Add(Tile);
const int32 LODLevel = GetLODForChunk(ChunkCoord, CurrentCenterChunk);
const int32 Step = LODToStep(LODLevel);
const FIntVector OriginVoxels = Tile.OriginVoxels(); // min corner, voxel coords
// Epoch capturé pour détecter les résultats périmés (après un RegenerateAll)
// Coarse levels mesh with FEWER cells → much cheaper gen (blockier far field, which is far
// away + skirts hide the seams). Near levels (< FullResClipLevels) stay full CHUNK_SIZE cells.
// Extent stays CHUNK_SIZE<<Level (so the shell tiling is unchanged) — only the cell count and
// step change: Step = Extent / Cells.
const int32 FullRes = Settings ? FMath::Max(1, Settings->FullResClipLevels) : 2;
const int32 Cells = (Tile.Level < FullRes)
? CHUNK_SIZE
: (Settings ? FMath::Clamp(Settings->CoarseTileCells, 4, CHUNK_SIZE) : 16);
const int32 Extent = CHUNK_SIZE << Tile.Level;
const int32 Step = FMath::Max(1, Extent / Cells);
const uint32 TaskEpoch = GenerationEpoch;
ActiveTaskCount.fetch_add(1, std::memory_order_relaxed);
UE::Tasks::Launch(TEXT("ChunkGen"), [this, ChunkCoord, Step, LODLevel, TaskEpoch]()
// BackgroundNormal priority: gen runs on background workers that YIELD to foreground
// (game/render-thread) tasks. Without this, raising MaxConcurrentTasks past the spare
// core count saturates the scheduler and starves the frame (the "over 12 = lag" symptom).
// At background priority the frame keeps its cores; gen just fills in around it.
UE::Tasks::Launch(TEXT("ChunkGen"), [this, Tile, OriginVoxels, Step, Cells, TaskEpoch]()
{
// RAII: décrément du compteur quel que soit le chemin de sortie
// RAII: decrement the counter on every exit path.
struct FTaskGuard
{
std::atomic<int32>& Counter;
@@ -598,251 +778,169 @@ void AVoxelWorld::LoadChunk(const FIntVector& ChunkCoord)
if (bShuttingDown.load(std::memory_order_relaxed)) return;
// Le mesher lit la densité directement depuis Generator — pas de
// "generate chunk" préliminaire: le chunk est juste un wrapper de coord.
const FVoxelChunk Chunk(ChunkCoord);
FChunkResult Result;
Result.ChunkCoord = ChunkCoord;
Result.Chunk = Chunk;
Result.LODLevel = LODLevel;
Result.Tile = Tile;
Result.Epoch = TaskEpoch;
{
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_GenerateMesh);
Result.MeshData = Mesher->GenerateMesh(Chunk, Step);
Result.MeshData = Mesher->GenerateMesh(OriginVoxels, Step, Cells);
}
if (!bShuttingDown.load(std::memory_order_relaxed))
{
ProcessQueue.Enqueue(Result);
}
});
}, UE::Tasks::ETaskPriority::BackgroundNormal);
}
void AVoxelWorld::UnloadChunk(const FIntVector& ChunkCoord)
void AVoxelWorld::UnloadTile(const FVoxelTileKey& Tile)
{
// Destroy any spawned content (decorations + water) before the mesh goes.
if (ContentManager)
// Water + decorations are no longer tile-bound (water is one player-following ocean plane via
// UpdateWater; decorations stream by distance via UpdateDecorations) — nothing to clear per tile.
if (URealtimeMeshComponent** Comp = TileComponents.Find(Tile))
{
ContentManager->ClearChunk(ChunkCoord);
}
if (ChunkMeshes.Contains(ChunkCoord)) {
ChunkMeshes[ChunkCoord]->DestroyComponent();
ChunkMeshes.Remove(ChunkCoord);
Chunks.Remove(ChunkCoord);
ChunkLODs.Remove(ChunkCoord);
if (*Comp) { (*Comp)->DestroyComponent(); }
TileComponents.Remove(Tile);
}
LoadedTiles.Remove(Tile);
PendingTiles.Remove(Tile);
}
void AVoxelWorld::ApplyMeshToChunk(const FIntVector& ChunkCoord, const FVoxelMeshData& MeshData)
void AVoxelWorld::ApplyMeshToTile(const FVoxelTileKey& Tile, const FVoxelMeshData& MeshData)
{
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ApplyMeshToChunk);
//==========================================================================
// STEP 1: EARLY EXIT IF NO MESH DATA
//==========================================================================
// If the chunk is all air (empty), there's nothing to render.
// MeshData.IsEmpty() returns true if Vertices array has 0 elements.
if (MeshData.IsEmpty())
{
// Became empty (e.g. fully carved) — drop any prior component for this tile.
if (URealtimeMeshComponent** C = TileComponents.Find(Tile))
{
if (*C) { (*C)->DestroyComponent(); }
TileComponents.Remove(Tile);
}
return;
}
//==========================================================================
// STEP 2: GET OR CREATE THE MESH COMPONENT
//==========================================================================
// Each chunk needs a URealtimeMeshComponent to be visible in the world.
// We store these in the ChunkMeshes map, keyed by chunk coordinate.
URealtimeMeshComponent* MeshComp = nullptr;
const bool bLevel0 = (Tile.Level == 0);
// Check if we already have a mesh component for this chunk
if (ChunkMeshes.Contains(ChunkCoord))
// SKY-CAP CEILING classification (computed once, used for BOTH shadow + material). A tile is a
// ceiling tile if its centre sits in the upper half of the open span between the ground surface and
// the cap (the tile grid keeps ceiling tiles separate from ground tiles — they're far apart in Z).
// The oracle is O(1) and returns false for non-surface strates, so this is cheap at every level.
bool bIsCeiling = false;
if (Generator)
{
// Reuse existing component (chunk is being updated, not created)
MeshComp = ChunkMeshes[ChunkCoord];
const int32 VoxelsPerTile = CHUNK_SIZE << Tile.Level;
const FIntVector MinVoxel = Tile.Coord * VoxelsPerTile;
const float CenterX = (float)MinVoxel.X + VoxelsPerTile * 0.5f;
const float CenterY = (float)MinVoxel.Y + VoxelsPerTile * 0.5f;
const float CenterZ = (float)MinVoxel.Z + VoxelsPerTile * 0.5f;
const int32 CenterChunkZ = FMath::FloorToInt(CenterZ / (float)CHUNK_SIZE);
float TerrainZ = 0.0f, CeilSurf = 0.0f;
if (Generator->GetSurfaceHeightAt(CenterX, CenterY, CenterChunkZ, TerrainZ, CeilSurf))
{
const float Mid = (TerrainZ + CeilSurf) * 0.5f;
bIsCeiling = (CenterZ > Mid);
}
else
{
// Create a brand new mesh component for this chunk
// NewObject<T>(Outer) creates a new UObject of type T
// 'this' (AVoxelWorld) is the "outer" - it owns this component
MeshComp = NewObject<URealtimeMeshComponent>(this);
// 40k+ chunk components hammer the GAME THREAD, not the GPU. Kill per-component
// bookkeeping the engine would otherwise do every frame for each of them:
// - overlap events: chunks never use overlap callbacks (gameplay uses raycasts
// against the LOD0 collision), and UpdateOverlaps over tens of thousands of
// components is a classic game-thread sink.
// - navigation: terrain isn't navmesh-driven here.
// (The real fix for component COUNT is the chunk-LOD clipmap; this trims the
// per-component cost meanwhile and survives that refactor.)
MeshComp->SetGenerateOverlapEvents(false);
MeshComp->SetCanEverAffectNavigation(false);
// RegisterComponent() tells Unreal "this component is ready to use"
// Without this, the component won't tick, render, or do anything
MeshComp->RegisterComponent();
// Attach to our actor so it moves with us and inherits our transform
MeshComp->AttachToComponent(GetRootComponent(), FAttachmentTransformRules::KeepRelativeTransform);
// Store in our map so we can find it later
ChunkMeshes.Add(ChunkCoord, MeshComp);
}
// Safety check
if (!MeshComp)
// Material: strate override (by the tile's min-corner chunk coord) else the global default. Ceiling
// tiles take the strate's CeilingMaterial when set (the rocky "night sky" overhead reads flat/bright
// otherwise, since it casts no shadow).
UMaterialInterface* ChunkMaterial = Settings ? Settings->VoxelMaterial : nullptr;
if (StrateManager)
{
return;
const FIntVector ChunkCoord = Tile.Coord * (1 << Tile.Level); // level-0-equivalent min corner
if (UVoxelStrateDefinition* StrateDef = StrateManager->GetStrateForChunk(ChunkCoord))
{
if (StrateDef->OverrideMaterial) { ChunkMaterial = StrateDef->OverrideMaterial; }
if (bIsCeiling && StrateDef->CeilingMaterial) { ChunkMaterial = StrateDef->CeilingMaterial; }
}
}
//==========================================================================
// STEP 3: INITIALIZE THE REALTIMEMESH ASSET
//==========================================================================
// Component = "the renderer", MeshAsset = "the data"
// InitializeRealtimeMesh creates or returns the asset
URealtimeMeshSimple* RTMesh = MeshComp->InitializeRealtimeMesh<URealtimeMeshSimple>();
if (!RTMesh)
{
return;
}
//==========================================================================
// STEP 4: CREATE KEYS FOR THE MESH STRUCTURE
//==========================================================================
// RealtimeMesh hierarchy: Mesh -> LODs -> SectionGroups -> Sections
//
// LOD = Level of Detail (LOD0 = highest detail, closest to camera)
// SectionGroup = A group of mesh sections (one per chunk)
// Section = Actual triangle data with a material
const FRealtimeMeshLODKey LOD0(0); // Highest detail
// GroupKey = identifies our section group
const FRealtimeMeshSectionGroupKey GroupKey =
FRealtimeMeshSectionGroupKey::Create(LOD0, FName("ChunkMesh"));
// SectionKey = identifies the mesh section within group
const FRealtimeMeshSectionKey SectionKey =
FRealtimeMeshSectionKey::CreateForPolyGroup(GroupKey, 0);
//==========================================================================
// STEP 5: REMOVE OLD GEOMETRY
//==========================================================================
// Clear previous data before adding new (important for chunk updates)
RTMesh->RemoveSectionGroup(GroupKey);
//==========================================================================
// STEP 6: CREATE THE MESH BUILDER
//==========================================================================
// StreamSet = container for vertex data, indices, normals, UVs, etc.
// Builder = helper to easily add vertices and triangles
//
// Template: <IndexType, NormalType, UVType, NumUVChannels>
// Build the geometry stream set. Vertices are world-space; the component sits at the actor origin.
RealtimeMesh::FRealtimeMeshStreamSet Streams;
{
RealtimeMesh::TRealtimeMeshBuilderLocal<uint32, FPackedNormal, FVector2DHalf, 1> Builder(Streams);
// Enable data streams we'll use
Builder.EnableTangents(); // Pour le normal mapping
Builder.EnableTexCoords(); // Pour les UVs
Builder.EnablePolyGroups(); // Pour l'assignation du matériau
//==========================================================================
// STEP 7: ADD ALL VERTICES
//==========================================================================
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); // Pre-allocate for speed
Builder.ReserveAdditionalVertices(NumVertices);
for (int32 i = 0; i < NumVertices; i++)
{
const FVector3f Position = (FVector3f)MeshData.Vertices[i];
auto Vertex = Builder.AddVertex(Position);
auto Vertex = Builder.AddVertex((FVector3f)MeshData.Vertices[i]);
if (MeshData.Normals.IsValidIndex(i))
{
const FVector3f Normal = (FVector3f)MeshData.Normals[i];
const FVector3f Tangent = FVector3f(1, 0, 0);
Vertex.SetNormalAndTangent(Normal, Tangent);
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]);
}
}
//==========================================================================
// STEP 8: ADD ALL TRIANGLES
//==========================================================================
// Each triangle = 3 vertex indices
// Our quads = 2 triangles = 6 indices each
const int32 NumIndices = MeshData.Triangles.Num();
Builder.ReserveAdditionalTriangles(NumIndices / 3);
for (int32 i = 0; i < NumIndices; i += 3)
{
Builder.AddTriangle(
(uint32)MeshData.Triangles[i],
Builder.AddTriangle((uint32)MeshData.Triangles[i],
(uint32)MeshData.Triangles[i + 1],
(uint32)MeshData.Triangles[i + 2],
0 // PolyGroup 0 = material slot 0
);
(uint32)MeshData.Triangles[i + 2], 0 /*poly group*/);
}
}
//==========================================================================
// STEP 9: UPLOAD TO GPU
//==========================================================================
// MoveTemp = transfer ownership efficiently (no copy)
// 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.
URealtimeMeshComponent* MeshComp = TileComponents.FindRef(Tile);
if (!MeshComp)
{
MeshComp = NewObject<URealtimeMeshComponent>(this);
// Generated once, never moves → Static so RMC's cached static draw path + VSM shadow
// caching apply (see the root SetMobility note in BeginPlay). Must be set before register.
// Re-mesh on carve recreates the section-group proxy (RMC's Static path already does this),
// which is fine for an infrequent action.
MeshComp->SetMobility(EComponentMobility::Static);
MeshComp->SetGenerateOverlapEvents(false); // chunks use raycasts, not overlaps
MeshComp->SetCanEverAffectNavigation(false);
MeshComp->RegisterComponent();
MeshComp->AttachToComponent(GetRootComponent(), FAttachmentTransformRules::KeepRelativeTransform);
TileComponents.Add(Tile, MeshComp);
}
URealtimeMeshSimple* RTMesh = MeshComp->InitializeRealtimeMesh<URealtimeMeshSimple>();
if (!RTMesh) { return; }
// Shadow casting: far (level >= 2) tiles never cast; the SurfaceWorld SKY-CAP CEILING never casts
// either — otherwise the high rock ceiling shadows the entire terrain below it (one mesh, so we
// can't split it). bIsCeiling was classified above via the O(1) oracle.
const bool bCastShadow = (Tile.Level <= 1) && !bIsCeiling;
MeshComp->SetCastShadow(bCastShadow);
const FRealtimeMeshSectionGroupKey GroupKey =
FRealtimeMeshSectionGroupKey::Create(FRealtimeMeshLODKey(0), FName("Tile"));
RTMesh->RemoveSectionGroup(GroupKey); // clear old geometry on re-mesh
RTMesh->SetupMaterialSlot(0, "Main", ChunkMaterial);
RTMesh->CreateSectionGroup(GroupKey, MoveTemp(Streams));
//==========================================================================
// STEP 10: CONFIGURE SECTION
//==========================================================================
FRealtimeMeshSectionConfig SectionConfig(0); // Material slot 0
SectionConfig.bIsVisible = true;
FRealtimeMeshSectionConfig SectionConfig(0);
// RMC casts shadows PER SECTION (FRealtimeMeshSectionConfig::bCastsShadow, default true) — the
// component-level UPrimitiveComponent::CastShadow is NOT honored by the RMC proxy. So the real
// shadow lever is here: drive the section flag from the same decision (far tiles + sky-cap ceiling
// → no cast). SetCastShadow above is kept only to keep the component flag consistent.
SectionConfig.bCastsShadow = bCastShadow;
RTMesh->UpdateSectionConfig(
FRealtimeMeshSectionKey::CreateForPolyGroup(GroupKey, 0),
SectionConfig, /*bShouldCreateCollision*/ bLevel0); // collision at level 0 only (T1.c)
// Pick the material: use the strate's override if available, else the global default.
UMaterialInterface* ChunkMaterial = Settings->VoxelMaterial;
if (StrateManager)
{
if (UVoxelStrateDefinition* StrateDef = StrateManager->GetStrateForChunk(ChunkCoord))
{
if (StrateDef->OverrideMaterial)
{
ChunkMaterial = StrateDef->OverrideMaterial;
}
}
}
RTMesh->SetupMaterialSlot(0, "Main", ChunkMaterial);
// Collision only at LOD0. Distant chunks are unreachable by definition (the LOD
// reconciliation loop §8.10 hot-swaps a chunk to LOD0 before the player can touch it),
// so cooking Chaos tri-mesh collision for LOD1/2 is pure waste — kills the cook cost +
// collision memory for the large majority of loaded chunks. (T1.c)
const int32 ChunkLOD = ChunkLODs.FindRef(ChunkCoord);
const bool bWantCollision = (ChunkLOD == 0);
RTMesh->UpdateSectionConfig(SectionKey, SectionConfig, bWantCollision);
// Shadow casting: the FARTHEST tier (LOD2) does NOT cast shadows. Each shadow-casting
// chunk adds a SECOND draw call in the shadow-depth pass (~doubles the draw cost of the
// terrain), and crisp shadows on distant blocky LOD2 geometry aren't worth it. LOD0/1
// keep shadows. Conservative first cut — widen to `<= 0` (LOD1 too) if still draw-bound.
// Re-applied every time because LOD hot-swaps reuse the same component.
MeshComp->SetCastShadow(ChunkLOD <= 1);
//==========================================================================
// STEP 11: POPULATE CONTENT (decorations + water)
//==========================================================================
// Runs on the game thread (ProcessPendingChunks calls us here), so spawning
// actors is safe. PopulateChunk clears any prior content for this coord first,
// so re-meshing after a carve refreshes the scatter cleanly.
if (ContentManager)
{
ContentManager->PopulateChunk(ChunkCoord, MeshData, ChunkLODs.FindRef(ChunkCoord));
}
// Water is no longer spawned per tile — it's a single player-following ocean plane (UpdateWater,
// driven from Tick), so it renders at every LOD and to the horizon with no per-tile gaps.
}
//=============================================================================
@@ -1173,56 +1271,21 @@ void AVoxelWorld::RemeshDirtyChunks(const TArray<FIntVector>& DirtyCoords)
// Chunks that aren't loaded are ignored — when they eventually load
// through normal streaming, they'll include the diff layer automatically.
// Edits only affect LEVEL-0 tiles (collision + visible detail are full-res near the player;
// coarse far tiles sample too sparsely to show small carves, and pick up the diff naturally
// when they next stream). Re-queue the loaded level-0 tile for each dirty coord — LoadTile
// re-runs gen (density includes the DiffLayer via GetDensityAt) and ProcessPendingChunks
// updates the existing component in place (old mesh stays visible until then, no pop).
const int32 MaxTasks = Settings ? Settings->MaxConcurrentTasks : 16;
for (const FIntVector& Coord : DirtyCoords)
{
// Only remesh chunks that are actually loaded
if (!Chunks.Contains(Coord)) continue;
// Skip if already queued for generation (avoid double-submit)
if (PendingChunkCoord.Contains(Coord)) continue;
// Check concurrent task budget
const int32 MaxTasks = Settings ? Settings->MaxConcurrentTasks : 16;
if (PendingChunkCoord.Num() >= MaxTasks) break;
PendingChunkCoord.Add(Coord);
// Use existing LOD for this chunk (it hasn't moved, just modified)
const int32* CurrentLOD = ChunkLODs.Find(Coord);
const int32 LODLevel = CurrentLOD ? *CurrentLOD : 0;
const int32 Step = LODToStep(LODLevel);
const uint32 TaskEpoch = GenerationEpoch;
ActiveTaskCount.fetch_add(1, std::memory_order_relaxed);
UE::Tasks::Launch(TEXT("ChunkRemesh"), [this, Coord, Step, LODLevel, TaskEpoch]()
{
struct FTaskGuard
{
std::atomic<int32>& Counter;
~FTaskGuard() { Counter.fetch_sub(1, std::memory_order_relaxed); }
} Guard{ActiveTaskCount};
if (bShuttingDown.load(std::memory_order_relaxed)) return;
// Re-mesh: la densité inclut automatiquement le DiffLayer
// (carves du joueur) puisque le générateur le consulte dans GetDensityAt.
const FVoxelChunk Chunk(Coord);
FChunkResult Result;
Result.ChunkCoord = Coord;
Result.Chunk = Chunk;
Result.LODLevel = LODLevel;
Result.Epoch = TaskEpoch;
Result.MeshData = Mesher->GenerateMesh(Chunk, Step);
if (!bShuttingDown.load(std::memory_order_relaxed))
{
ProcessQueue.Enqueue(Result);
}
});
const FVoxelTileKey Tile(Coord, 0);
if (!LoadedTiles.Contains(Tile)) continue; // only re-mesh loaded full-res tiles
if (PendingTiles.Contains(Tile)) continue; // already queued
if (PendingTiles.Num() >= MaxTasks) break; // task budget
LoadTile(Tile);
}
UE_LOG(LogTemp, Verbose, TEXT("[VoxelWorld] RemeshDirtyChunks: %d coords, %d queued"),
DirtyCoords.Num(), PendingChunkCoord.Num());
UE_LOG(LogTemp, Verbose, TEXT("[VoxelWorld] RemeshDirtyChunks: %d coords, %d pending"),
DirtyCoords.Num(), PendingTiles.Num());
}
+172 -51
View File
@@ -1,40 +1,48 @@
// VoxelContentManager.h
// Per-chunk content population: deterministic decoration/actor scatter from the
// strate content pools, plus self-contained aesthetic water surfaces.
// Decoration scatter (distance-based world grid, ASYNC surface march) + aesthetic water surfaces.
//
// LIFECYCLE (driven by AVoxelWorld on the GAME THREAD):
// - PopulateChunk(coord, meshdata) after a chunk's mesh is applied
// - ClearChunk(coord) when a chunk unloads / is re-meshed
// - ClearAll() on regenerate / season reset
// - SetActiveStrate(playerStrate) each Tick (no-op unless the strate changed)
// TWO INDEPENDENT SUBSYSTEMS:
//
// DETERMINISM: every placement decision is a pure hash of (chunk, surface index,
// entry index, seed), so the same world re-populates identically. Spawning itself
// must run on the game thread (UWorld::SpawnActor), which it does — ApplyMeshToChunk
// is game-thread.
// 1) DECORATIONS — distance-based WORLD GRID (the no-pop system).
// Decorations are placed on a fixed world XY cell grid (1 cell = 1 chunk footprint) and streamed
// by DISTANCE from the player, completely decoupled from clipmap tiles / LOD. Each candidate column
// is ray-marched vertically through the player's strate Z-band via UVoxelGenerator::GetDensityAt and
// snapped to the real surface crossings. Every placement is a pure hash of (cell, column, crossing,
// entry, seed) + the density surface snap → a prop sits at the SAME world position no matter which
// LOD tile meshes the ground under it: NO pop/teleport on LOD swaps.
//
// RENDERING PATHS per decoration entry (FStrateDecoration):
// - ActorClass → real actors (lights, logic, interaction). Keep MaxLODLevel=0.
// - InstancedMesh → HISM instances batched per chunk: no tick, no per-actor cost,
// engine-culled. Safe to allow at LOD 1-2 so visual props don't
// pop out with LOD0 (emissive materials still glow at distance).
// THREADING (critical): the column ray-march is EXPENSIVE (many GetDensityAt per column). It runs on
// a WORKER thread (UE::Tasks, like mesh gen — GetDensityAt is thread-safe), producing a list of spawn
// commands. Only the SpawnActor/AddInstance (which MUST be game-thread) happens on the game thread,
// budgeted. The game thread never does decoration density math. Flow (UpdateDecorations each Tick):
// - recompute desired cell set on player cell-boundary / strate change
// - LaunchDecoTasks: fire async march tasks (capped by MaxConcurrentDecorationTasks)
// - ProcessDecoResults: drain finished tasks' results, apply (spawn) budgeted, epoch-guarded
// 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.
//
// STRATE LIGHT CULLING: a light in another strate can never legitimately reach the
// player (seals + bedrock gap are solid), but a shadowless light BLEEDS through rock
// and a shadowed one pays full cost to render black. So light components on spawned
// decoration actors are only visible while the player is in the SAME strate — the
// analytical form of occlusion culling, computed once per strate change.
// 2) WATER — ONE strate-global ocean plane that follows the player (UpdateWater). Terrain pokes
// through it, so it reads as water at every LOD / to the horizon with no per-tile gaps. One draw.
//
// RENDERING PATHS / DISTANCE TIERS per entry (FStrateDecoration): non-instanced ActorClass entries with
// MaxLODLevel==0 are near-only (DecorationActorRadiusChunks — pricey actors stay close); InstancedMesh
// (HISM) entries + MaxLODLevel>=1 actor entries are any-distance (DecorationRadiusChunks).
//
// DETERMINISM: same seed + world ⇒ identical placement. Spawning runs on the game thread.
#pragma once
#include "CoreMinimal.h"
#include "Containers/Queue.h"
#include "VoxelTypes.h"
#include "VoxelStrateTypes.h" // FStrateDecoration (resolved per dominant biome)
#include <atomic>
#include "VoxelContentManager.generated.h"
class UVoxelStrateManager;
class UVoxelStrateDefinition;
class UVoxelGenerator;
class UVoxelSettings;
class UStaticMesh;
class UStaticMeshComponent;
class UHierarchicalInstancedStaticMeshComponent;
@@ -47,39 +55,121 @@ class VOXELFORGE_API UVoxelContentManager : public UObject
public:
/** Wire up services. Owner is the AVoxelWorld actor that owns spawned content.
* Generator supplies the dominant-biome query for per-biome content selection. */
* Generator supplies GetDensityAt (surface snapping) + GetDominantBiomeAt (per-biome content).
* Settings supplies the decoration grid tunables (radii / spacing / march / budget). */
void Initialize(AActor* InOwner, UVoxelStrateManager* InStrateManager,
UVoxelGenerator* InGenerator, int32 InSeed);
UVoxelGenerator* InGenerator, UVoxelSettings* InSettings, int32 InSeed);
/** Update the seed used for placement hashing (season reset). */
void SetSeed(int32 InSeed) { Seed = InSeed; }
/** Populate decorations + water for a chunk. Clears any previous content first.
* Each decoration entry spawns only while LOD <= its MaxLODLevel; water at any LOD. */
void PopulateChunk(const FIntVector& ChunkCoord, const FVoxelMeshData& MeshData, int32 LODLevel = 0);
//--- WATER (single strate-global ocean plane, follows the player) ---------
/** Maintain ONE large water plane at the player strate's water level, centred on the player
* (snapped to a coarse grid). Terrain pokes through it, so it reads as water at EVERY LOD and
* out to the horizon with no per-tile gaps (the old per-tile planes only appeared on tiles that
* had terrain geometry in the water band → gaps over deep water + level-0 only). One draw, cheap.
* Call every Tick. Uses the strate's WaterMaterial (per-biome override not applied to the global
* plane). */
void UpdateWater(const FVector& PlayerWorldPos);
/** Destroy all spawned content (actors + instances + water plane) for one chunk. */
void ClearChunk(const FIntVector& ChunkCoord);
//--- DECORATIONS (distance-based world grid, async march) ----------------
/** Stream decoration cells around the player: recompute the desired set on cell/strate change,
* launch async march tasks (capped), and apply finished results budgeted. Call every Tick. */
void UpdateDecorations(const FVector& PlayerWorldPos);
/** Destroy all spawned content for every chunk. */
/** Destroy all spawned content (decorations + water). Regenerate / season reset. Bumps the deco
* epoch so any in-flight march tasks' results are discarded. */
void ClearAll();
/** Player strate changed → toggle decoration lights (strict: lights only live in the
* player's strate). Cheap no-op while the strate stays the same — call every Tick. */
void SetActiveStrate(int32 PlayerStrateIndex);
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
* UObject teardown (worker tasks read the Generator). */
void NotifyShutdown();
//--- async-task plumbing (public so the worker lambda can reach them) -----
/** One placement decided off-thread; spawned on the game thread from FDecoCellResult::Entries. */
struct FDecoSpawn
{
int32 EntryIdx = 0;
bool bInstanced = false;
FTransform Xf = FTransform::Identity;
};
/** A finished cell march: a snapshot of the resolved decoration list + the spawn commands. */
struct FDecoCellResult
{
FIntPoint Cell = FIntPoint::ZeroValue;
uint32 BuildId = 0; // identity of the region build this cell belongs to
TArray<FStrateDecoration> Entries; // snapshot the game thread spawns from (by EntryIdx)
TArray<FDecoSpawn> Spawns;
};
private:
void SpawnDecorations(const FIntVector& ChunkCoord, const FVoxelMeshData& MeshData,
const TArray<FStrateDecoration>& Decorations, int32 LODLevel,
TArray<TWeakObjectPtr<AActor>>& Out);
void SpawnWater(const FIntVector& ChunkCoord, const UVoxelStrateDefinition* Def,
UMaterialInterface* WaterMaterial);
// Per-REGION spawned content (weak — actors live in the level, components owned by the owner actor).
// A region groups RxR cells (DecorationRegionSizeCells); ALL placements in the region share ONE HISM
// per mesh, so the render thread walks ~R^2 fewer components. Regions load/unload as a unit, so
// clearing is a plain DestroyComponent — no per-instance RemoveInstances index remapping.
struct FDecoRegionContent
{
TArray<TWeakObjectPtr<AActor>> Actors;
TArray<TWeakObjectPtr<UHierarchicalInstancedStaticMeshComponent>> Instances;
};
/** Strate index a chunk belongs to (center-Z lookup). */
int32 GetChunkStrateIndex(const FIntVector& ChunkCoord) const;
// Instanced transforms for one mesh, accumulated across all of a region's cells → one batched HISM.
struct FRegionMeshBucket
{
FStrateDecoration Deco; // representative entry (mesh + HISM render tuning: cull/shadow/scale)
TArray<FTransform> Xforms;
};
// A non-instanced actor placement, spawned when the region is applied.
struct FRegionActorSpawn
{
TSubclassOf<AActor> ActorClass;
FTransform Xf = FTransform::Identity;
};
// A region being assembled: cell march results merged in as they land; APPLIED (HISMs built, actors
// spawned) only once every cell has reported, so the whole region is one batched build per mesh.
struct FDecoRegionBuild
{
TMap<TWeakObjectPtr<UStaticMesh>, FRegionMeshBucket> MeshBuckets;
TArray<FRegionActorSpawn> ActorSpawns;
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
};
// Constant per-update strate context (a strate is a horizontal slab → same for every cell). Carries
// only PODs/Z-bounds so it is safe to copy into a worker task (no UObject deref on the worker).
struct FDecoContext
{
const UVoxelStrateDefinition* Def = nullptr; // game-thread only (biome/decoration resolve)
int32 RepChunkZ = 0; // representative chunk-Z for biome lookups
float TopVoxelZ = 0.0f; // strate band, voxel coords (march range)
float BottomVoxelZ = 0.0f;
float WaterLocalZ = -FLT_MAX; // water surface, actor-local cm (-FLT_MAX = no water)
bool bHasWater = false;
bool bSurfaceWorld = false; // heightfield archetype → use the GetSurfaceHeightAt 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
* Generator (thread-safe). Determinism-critical. */
static void BuildCellSpawns(const UVoxelGenerator* Gen, const FTransform& OwnerXf,
const FIntPoint& Cell, const FDecoContext& Ctx,
const TArray<FStrateDecoration>& Entries, uint32 InSeed,
int32 Spacing, float Step, int32 MaxCrossings, float ColumnDepth,
TArray<FDecoSpawn>& OutSpawns);
void LaunchDecoTasks(const FIntPoint& PlayerCell);
void ProcessDecoResults(const FIntPoint& PlayerCell, int32 FarR);
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 ApplyRegion(const FIntPoint& Region, FDecoRegionBuild& Build);
void RebuildDesiredCells(const FIntPoint& PlayerCell);
void ClearDecorationRegion(const FIntPoint& Region);
void ClearAllDecorations();
// Region size in cells, clamped (>=1). Cell↔region math lives in file-static helpers in the .cpp.
int32 RegionSize() const;
/** Show/hide every light component on a decoration actor. */
static void SetActorLightsEnabled(AActor* Actor, bool bEnabled);
TWeakObjectPtr<AActor> Owner;
@@ -89,23 +179,54 @@ private:
UPROPERTY()
UVoxelGenerator* Generator = nullptr;
int32 Seed = 0;
UPROPERTY()
UVoxelSettings* Settings = nullptr;
// Player's current strate (INT32_MIN until first SetActiveStrate). Lights on spawned
// actors are enabled iff their chunk's strate matches this.
int32 ActiveStrateIndex = INT32_MIN;
int32 Seed = 0;
// Engine unit plane (/Engine/BasicShapes/Plane) reused for every water surface.
UPROPERTY()
UStaticMesh* PlaneMesh = nullptr;
// Spawned decoration/ambient actors per chunk (weak — they live in the level).
TMap<FIntVector, TArray<TWeakObjectPtr<AActor>>> SpawnedActors;
// Loaded decoration regions (FIntPoint = region XY). One HISM per mesh per region. Not a UPROPERTY
// (weak ptrs inside; the owner actor keeps the components alive).
TMap<FIntPoint, FDecoRegionContent> DecoRegions;
// HISM components per chunk (weak — registered components are owned by the actor).
TMap<FIntVector, TArray<TWeakObjectPtr<UHierarchicalInstancedStaticMeshComponent>>> ChunkInstances;
// Regions currently being marched cell-by-cell; merged here until every cell reports, then applied.
TMap<FIntPoint, FDecoRegionBuild> RegionBuilds;
// Water surface component per chunk.
// Cells that are desired but need a march task launched (nearest-first).
TArray<FIntPoint> PendingLaunch;
// Cells with a march task in flight (awaiting a result).
TSet<FIntPoint> InFlightCells;
// Worker tasks enqueue here (Mpsc: many workers, one game-thread consumer).
TQueue<FDecoCellResult, EQueueMode::Mpsc> DecoResults;
// Regions whose last cell just landed, awaiting budgeted game-thread apply (HISM build + actor spawn).
TArray<FIntPoint> CompletedRegions;
// Monotonic id stamped on each region build + the cell tasks it launches. A cell result merges only
// if its BuildId still matches the live build for that region → a region that was cleared and later
// re-marched (same coords, new BuildId) never absorbs a stale in-flight cell from its prior life.
uint32 NextBuildId = 1;
// Set in BeginDestroy; worker tasks check it before touching us.
std::atomic<bool> bShuttingDown{false};
// Streaming state. INT_MIN sentinels force a full rebuild on the first update / after ClearAll.
FIntPoint LastDecoCell = FIntPoint(INT32_MIN, INT32_MIN);
int32 LastStrateIndex = INT32_MIN;
// Shared strate context for the current update (recomputed each UpdateDecorations; the launch step
// copies the PODs into each task).
FDecoContext CurrentCtx;
// Single strate-global ocean plane, repositioned to follow the player (see UpdateWater).
UPROPERTY()
TMap<FIntVector, UStaticMeshComponent*> WaterPlanes;
UStaticMeshComponent* WaterPlane = nullptr;
// Cached state so UpdateWater is a cheap no-op when neither the water level nor the snapped
// player cell changed since last frame.
float LastWaterZ = -FLT_MAX; // strate water level (voxel Z)
FIntPoint LastWaterCell = FIntPoint(INT32_MIN, INT32_MIN); // snapped XY grid cell
};
+12
View File
@@ -26,7 +26,10 @@
#pragma once
#include "CoreMinimal.h"
#include "HAL/CriticalSection.h" // FRWLock
#include "Misc/ScopeRWLock.h" // FReadScopeLock / FWriteScopeLock
#include "VoxelTypes.h"
#include <atomic>
#include "VoxelDiffLayer.generated.h"
/**
@@ -243,7 +246,16 @@ private:
// Modifications grouped by chunk coordinate.
// Each chunk's list contains ALL modifications that overlap it
// (including mods centered in neighboring chunks whose radius reaches here).
//
// THREADING: written on the game thread (ApplyModification / Clear) but read by MANY mesher
// WORKER threads (GetDensityAt -> HasModifications / GetDensityOffset). TMap is not thread-safe —
// a read landing during a write's rehash reads freed hash memory (EXCEPTION_ACCESS_VIOLATION in
// TSet::FindId). All access to ChunkMods MUST hold ModsLock (read lock for queries, write lock for
// mutation). bHasAnyMods is a lock-free fast reject: while it's false (the common streaming case,
// no carves) readers skip the map AND the lock entirely, so unmodified worlds pay nothing.
TMap<FIntVector, TArray<FVoxelModification>> ChunkMods;
mutable FRWLock ModsLock;
std::atomic<bool> bHasAnyMods{ false };
//=========================================================================
// BUDGET TRACKING
+38
View File
@@ -173,6 +173,30 @@ public:
*/
const UVoxelBiomeDefinition* GetDominantBiomeAt(float WorldX, float WorldY, int32 ChunkZ) const;
/**
* 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
* and the border blend weight (0 deep in a cell → 0.5 at the border). The mesher packs
* these into vertex colour so one material re-skins terrain per biome and cross-fades
* across biome borders. No biomes ⇒ Dominant=Neighbour=0, Weight=0 (default palette).
* Thread-safe: own per-chunk thread_local context + box cache (clustered tile queries
* stay warm). Window-invariant (ResolveBiomeSampleAt is bit-identical to SampleBiomeAt).
*/
void GetBiomeMaterialAt(float WorldX, float WorldY, float WorldZ,
int32& OutDominantPalette, int32& OutNeighborPalette,
float& OutBlendWeight) const;
/**
* SurfaceWorld HEIGHT ORACLE: the terrain surface Z + sky-cap ceiling Z (voxel coords) at a world
* XY for the given strate slice (ChunkZ), WITHOUT ray-marching the density column. Returns false
* (outs untouched) when the chunk is NOT a SurfaceWorld heightfield — callers fall back to marching.
* Shares the density path's surface helpers, so a decoration snapped to OutTerrainZ sits exactly on
* the rendered ground. Does NOT include passage/spine/seal carving — verify with one GetDensityAt at
* the result if a column might be carved. Thread-safe (own per-chunk thread_local cache).
*/
bool GetSurfaceHeightAt(float WorldX, float WorldY, int32 ChunkZ,
float& OutTerrainZ, float& OutCeilSurf) const;
private:
/** Pick the biome (index into Ctx.Biomes) for a Voronoi site, by its climate. */
int32 ClassifyBiomeAtSite(float SiteX, float SiteY, const FBiomeContext& Ctx, uint32 SiteHash) const;
@@ -184,6 +208,20 @@ private:
/** The SurfaceWorld sky-cap ceiling surface Z at a world XY (also pure per-XY). */
float ComputeSurfaceCeiling(float WorldX, float WorldY, const FSurfaceGenerationParams& Params) const;
/** Resolve a chunk's SurfaceWorld params (strate base + per-biome overrides, structural fields
* forced from the strate). Shared by GetDensityAt's per-chunk cache and the GetSurfaceHeightAt
* oracle so both produce the SAME surface. */
void ResolveSurfaceChunkParams(const FIntVector& ChunkCoord,
FSurfaceGenerationParams& OutSurface, FBiomeContext& OutBiomeCtx,
TArray<FSurfaceGenerationParams>& OutBiomeParams) const;
/** Biome-blended terrain Z + sky-cap ceiling Z for one column (the XY-only surface field). Shared
* by the density column cache (T1.a) and the oracle. */
void ComputeSurfaceColumn(float WorldX, float WorldY, int32 ChunkZ,
const FSurfaceGenerationParams& BaseSurface, const FBiomeContext& BiomeCtx,
const TArray<FSurfaceGenerationParams>& BiomeParams, FChunkBiomeCache& BiomeCache,
float& OutTerrainZ, float& OutCeilSurf) const;
/** Final SurfaceWorld density from a column's precomputed terrain Z + ceiling: the
* cheap per-voxel Z-combine + origin spine + boundary seal + passage carving. The
* XY-only work (terrain/ceiling) is done once per column and cached (T1.a). */
@@ -24,13 +24,15 @@ class VOXELFORGE_API UVoxelMarchingCubesMesher : public UObject
public:
/**
* Génère le mesh d'un chunk.
* Génère le mesh d'une TUILE de clipmap (voir FVoxelTileKey).
*
* @param Chunk - Le chunk à mesher (on n'utilise que ChunkCoord pour
* calculer les positions monde; la densité vient du générateur).
* @param Step - Pas d'échantillonnage (1=LOD0, 2=LOD1, 4=LOD2).
* @param OriginVoxels - Coin min de la tuile en coords VOXEL (= Tile.OriginVoxels()).
* @param Step - Taille de cellule en voxels. La tuile couvre CellsPerAxis*Step voxels.
* @param CellsPerAxis - Nombre de cellules par axe. Les tuiles GROSSIÈRES en utilisent MOINS
* (gen moins chère, maillage plus grossier au loin) tout en couvrant la
* même étendue (extent = CellsPerAxis*Step). Niveau 0 = CHUNK_SIZE.
*/
FVoxelMeshData GenerateMesh(const FVoxelChunk& Chunk, int32 Step = 1);
FVoxelMeshData GenerateMesh(FIntVector OriginVoxels, int32 Step = 1, int32 CellsPerAxis = CHUNK_SIZE);
//=========================================================================
// SERVICES (injectés par AVoxelWorld)
@@ -54,6 +56,15 @@ public:
// différence centrée du gradient. Plus petit = plus détaillé mais bruité.
float GradientOffset = 1.0f;
// SKIRTS — bouchent les fissures aux frontières de tuiles entre niveaux de clipmap voisins
// (résolutions différentes → les iso-surfaces ne se rejoignent pas exactement). Une jupe
// (mur court) est extrudée vers le solide depuis chaque arête de surface posée sur une des 6
// faces externes de la tuile. Voir GenerateMesh.
bool bGenerateSkirts = true;
// Profondeur de la jupe, en CELLULES de la tuile (× Step × VOXEL_SIZE). ~2 cellules couvrent
// l'écart vers un voisin un niveau plus grossier (cellule 2×). Monter si des fissures persistent.
float SkirtCells = 2.0f;
protected:
//=========================================================================
// DENSITY + NORMAL SAMPLING
+153
View File
@@ -43,6 +43,43 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming")
int32 MaxMeshAppliesPerFrame = 4;
// Max tile teardowns (component destroy + content-actor Destroy) per frame. A fast traversal
// culls a whole shell of tiles at once; doing every destroy in one frame is a game-thread spike
// ("stuff torn down behind you"). This spreads it. The drain auto-scales up if the backlog grows
// (PendingUnload/4) so it never falls far behind — raise the floor if teardown lags at high speed.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming")
int32 MaxUnloadsPerFrame = 6;
// Strate-aware VERTICAL streaming: don't load chunks into a strate you can't see.
// Seals + the inter-strate bedrock gap are light-tight (§8.7), so the strate above/below
// the one you're in is fully occluded — loading it just wastes scene primitives (the
// game-thread cost). When ON, the vertical view is clamped to the player's strate ±
// StrateViewMarginChunks. In the bedrock GAP itself the view is NOT clamped (you can see
// both sides through the descent passage there), so the next strate streams in ahead.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming")
bool bClampViewToStrate = true;
// Chunks of vertical look-ahead PAST the strate seal (each side) when bClampViewToStrate
// is on: keeps the descent tunnel / immediate transition loaded and acts as the trailing
// buffer of the strate you're leaving (it sheds naturally as you descend out of view).
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "0"))
int32 StrateViewMarginChunks = 3;
// Open-world SKY reach: the sky-cap ceiling of an open strate (SurfaceWorld / FloatingIslands)
// is FAR, so the ceiling BAND is streamed across a wider horizontal radius = ViewDistanceXY ×
// this, so the sky reaches toward the horizon instead of being a patch over the player's head.
// Only the ceiling band (top CeilingBandChunks of the strate) gets the wide radius — the empty
// air below it stays at base XY. Cost grows ~ multiplier² (more far chunks/draws; gen is cheap
// and batching keeps the game-thread cost low). 1 = off. Live-tunable on the asset — push to 4
// if the draw budget allows; a proper to-the-horizon view (terrain too) is the chunk-LOD clipmap.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "1"))
int32 CeilingViewMultiplier = 2;
// How many chunks down from the strate top get the wide CeilingViewMultiplier radius (covers
// the sky-cap slab). Bigger = no gaps if the ceiling surface dips, but more far chunks.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "1"))
int32 CeilingBandChunks = 4;
//=========================================================================
// LOD
//=========================================================================
@@ -58,6 +95,122 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|LOD")
int32 LOD1Distance = 8;
//=========================================================================
// CLIPMAP (chunked-LOD streaming — supersedes the ViewDistance/LOD box above)
//=========================================================================
// Streaming loads concentric shells of tiles: level 0 = full-res chunks near the player,
// each coarser level doubles tile size (and reach). Total tile/draw/gen count stays ~flat
// regardless of how far you see — so this is the "see to the horizon cheaply" system.
// Total reach ≈ ClipRadius × CHUNK_SIZE × 2^MaxClipLevel voxels.
// Near full-res reach ≈ ClipRadius × CHUNK_SIZE voxels.
// Half-extent (in tiles, per axis) of EACH level's shell. Bigger = more tiles per level
// (more detail / overlap) but more components. 3 → a 7×7×7 box per level (minus the inner
// hole filled by the finer level).
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "1"))
int32 ClipRadius = 3;
// Coarsest LOD level. 0 = full-res only (no clipmap). Each level up doubles tile size &
// reach at ~constant cost — raise this to see much farther for cheap. (Level L step = 2^L.)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0", ClampMax = "8"))
int32 MaxClipLevel = 4;
// How many near levels mesh at FULL resolution (CHUNK_SIZE cells). Levels at or above this
// use the cheaper CoarseTileCells → MUCH faster gen for the far field (it's far, so the
// extra blockiness is hidden, and skirts cover the seams). 2 = levels 0,1 crisp; set to 1
// for the fastest gen (slightly harder LOD0→1 transition).
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "1"))
int32 FullResClipLevels = 2;
// Cells per axis for COARSE tiles (levels >= FullResClipLevels). Lower = cheaper gen, blockier
// far terrain. 16 = ~⅛ the gen samples of a full 32-cell tile; 8 = ~1/64. Tile EXTENT is
// unchanged (the shells still tile) — only the mesh resolution within a far tile drops.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "4", ClampMax = "32"))
int32 CoarseTileCells = 16;
// SKIRTS — seal the thin cracks where neighbouring clipmap shells (different resolutions) meet.
// A short wall is extruded into the solid from each surface edge on the tile's outer faces.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap")
bool bGenerateSkirts = true;
// Skirt depth in tile CELLS (× the tile's step). ~2 covers the gap to a one-level-coarser
// neighbour (2× cell). Raise if cracks still show; lower if skirts peek out on convex edges.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0.5", ClampMax = "8.0"))
float SkirtCells = 2.0f;
// LEGACY / WATER ONLY. Decorations no longer ride clipmap tiles (see Voxel|Content below —
// they stream on a fixed world grid by distance, so they don't pop on LOD swaps). This now only
// bounds the tile level at which the level-0 WATER plane is considered (water is level-0 anyway,
// so its practical effect is nil). Left in place; safe to ignore.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Clipmap", meta = (ClampMin = "0", ClampMax = "8"))
int32 ContentMaxLevel = 2;
//=========================================================================
// CONTENT — distance-based decoration grid (no LOD pop)
//=========================================================================
// Decorations are placed on a fixed WORLD XY cell grid (1 cell = 1 chunk footprint) and streamed
// by DISTANCE from the player, independent of which clipmap tile (LOD) currently meshes the ground
// under them. Each candidate column is ray-marched through the player's strate via the generator's
// density field and snapped to the real surface — so a given prop keeps the SAME world position at
// every LOD (no teleport/pop on tile swaps). Decorations exist only in the player's current strate.
// Far stream radius in cells (= chunks) for "any-distance" entries (instanced/HISM visual props,
// and actor entries with MaxLODLevel >= 1). Bigger = props visible farther + more spawn/march cost.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1"))
int32 DecorationRadiusChunks = 6;
// Decoration cells are grouped into REGIONS of RxR cells, and ALL placements in a region share ONE
// HISM per mesh (instead of one HISM per cell per mesh). Regions load/unload as a unit, so clearing
// stays a plain DestroyComponent — no per-instance index remapping. This is the render-thread lever:
// component count (which InitViews walks every frame) drops by ~R^2. R=4 → ~16 regions in a radius-6
// disk vs 169 cells (~10x fewer components). Bigger R = fewer components but coarser pop-in + heavier
// one-shot cluster-tree build per region (apply is budgeted, so the build is amortised). MUST be >= 1.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1", ClampMax = "16"))
int32 DecorationRegionSizeCells = 4;
// LEGACY / UNUSED. The near/far tier system was removed (it re-streamed cells at the tier boundary
// as the player moved → decoration flicker). All entries now stream within DecorationRadiusChunks and
// a loaded cell is never re-streamed in place. Kept only to avoid breaking the asset; safe to ignore.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1"))
int32 DecorationActorRadiusChunks = 3;
// Spacing (in voxels) of candidate columns within a cell. MUST divide CHUNK_SIZE (32): 4 → 8×8=64
// columns/cell. Smaller = denser placement potential + more march cost. SpawnDensity then rolls per
// column-crossing (NOTE: this changes the meaning of SpawnDensity vs the old per-vertex scatter —
// expect to re-tune decoration densities once).
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1", ClampMax = "32"))
int32 DecorationSpacingVoxels = 4;
// COARSE vertical march step (in voxels) when searching a column for surface crossings. The crossing
// Z is then bisection-refined, so accuracy is independent of this — raise it (4-8) to cut the scan
// cost (the column is ray-marched on a WORKER thread, but a smaller step still means more samples).
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1", ClampMax = "8"))
int32 DecorationMarchStepVoxels = 2;
// Max surface crossings placed per column (caps cave columns that pierce many floors/ceilings;
// surface worlds have 1). Bounds worst-case march/spawn cost.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1", ClampMax = "16"))
int32 DecorationMaxCrossingsPerColumn = 4;
// Stop marching a column after this many voxels of CONTIGUOUS SOLID once it has already entered the
// open space — i.e. cap how far into bedrock BELOW the floor (or below a cavern) we keep scanning.
// The top cap/seal and the air above the ground are always marched first (this only trims the dead
// rock underneath), so surface worlds still get their ground and caves still find layered floors.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "8"))
int32 DecorationColumnDepthVoxels = 160;
// Budget: how many completed decoration REGIONS to APPLY (game-thread HISM build + SpawnActor) per
// frame. A region is applied once ALL its cells have finished marching, in one batched AddInstances
// per mesh; this throttles that burst. The expensive ray-march runs async on workers. (Named "Cells"
// for asset back-compat — the apply unit is now a region of DecorationRegionSizeCells^2 cells.)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "1"))
int32 MaxDecorationCellsPerFrame = 2;
// Max decoration ray-march tasks in flight at once. Keeps decoration marching from crowding the
// mesh-gen workers (which are the streaming bottleneck). 0 disables decorations entirely.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Content", meta = (ClampMin = "0"))
int32 MaxConcurrentDecorationTasks = 4;
//=========================================================================
// RENDERING
//=========================================================================
@@ -60,7 +60,9 @@ public:
// How many chunks tall this strate is.
// Each strate can have a different height!
// Big open caverns = 6-8 chunks, tight tunnels = 3, vertical shaft = 12+
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Dimensions", meta = (ClampMin = "1", ClampMax = "32"))
// Surface/sky worlds want a high sky-cap ceiling, so they go much taller.
// 1 chunk = 32 voxels x 25cm = 8 m, so 256 chunks ~= 2 km of vertical strate.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Dimensions", meta = (ClampMin = "1", ClampMax = "256"))
int32 StrateHeightInChunks = 4;
//=========================================================================
@@ -238,6 +240,14 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Visuals")
UMaterialInterface* OverrideMaterial = nullptr;
// Material for the SKY-CAP CEILING tiles of a surface-like strate (the rocky "night sky" overhead).
// Null = ceiling uses the same material as the ground (OverrideMaterial / default VoxelMaterial).
// The ceiling casts no shadow, so under it the ground material reads as flat/bright — give the
// ceiling its own darker/tinted material (or a tinted instance of the terrain material) to sell the
// "lit from within, dark rock overhead" look. Applies only to tiles classified as ceiling.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Visuals")
UMaterialInterface* CeilingMaterial = nullptr;
// Fog color for this strate (used by a future fog system or post-process)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Visuals")
FLinearColor FogColor = FLinearColor(0.05f, 0.05f, 0.1f, 1.0f);
@@ -170,6 +170,14 @@ public:
*/
UVoxelStrateDefinition* GetStrateForChunk(const FIntVector& ChunkCoord) const;
/**
* Strate-aware vertical streaming: fills the chunk-Z span of the strate containing
* ChunkZ. Returns false if ChunkZ is in the inter-strate bedrock gap (or outside the
* layout) — the caller then leaves the vertical view unclamped (the gap is a brief
* see-both-sides descent transition). TopChunkZ > BottomChunkZ (Z decreases downward).
*/
bool GetStrateChunkZBounds(int32 ChunkZ, int32& OutTopChunkZ, int32& OutBottomChunkZ) const;
/**
* Get generation params for a chunk, with boundary blending.
*
@@ -293,6 +301,11 @@ protected:
// Passages connecting consecutive strates
TArray<FVoxelPassage> Passages;
// Bumped every time Passages is rebuilt (GeneratePassages). EvaluateModifierSDF keeps a
// thread_local per-chunk shortlist of nearby passages and uses this to invalidate it when
// the passage set changes — so stale indices are never read after a rebuild.
uint32 PassagesVersion = 0;
// How many chunks at strate boundaries are blended (transition zone)
int32 BlendChunks = 2;
+69 -5
View File
@@ -1358,6 +1358,46 @@ struct VOXELFORGE_API FSurfaceGenerationParams
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0"))
float CeilingRoughness = 6.0f;
// Frequency of the fine downward bumpiness above. Higher = smaller, denser bumps.
// (Was hard-coded to 0.04 — exposed so the cap detail scale is tunable.)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0"))
float CeilingRoughnessFrequency = 0.04f;
// Broad ceiling undulation (voxels): a low-frequency SIGNED swell that raises and lowers
// the WHOLE sky-cap, giving big inverted "hills and valleys" overhead — the ceiling reads
// like terrain instead of a flat lid. Independent of the fine bumps. 0 = level cap height.
// 0 → flat cap (old look)
// 30 → gentle rolling ceiling
// 80+ → dramatic overhead valleys and rises
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0"))
float CeilingUndulation = 0.0f;
// Frequency of the broad undulation. Lower = larger, sweeping ceiling valleys.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0"))
float CeilingUndulationFrequency = 0.004f;
// Ridged hanging formations (voxels): sharp downward ridges/blades carved into the cap —
// the "valley-like ridges" / inverted-mountain-range look. Adds to the downward hang.
// 0 → none
// 20 → clear ridgelines across the ceiling
// 50+ → dramatic hanging ranges
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0"))
float CeilingRidgeStrength = 0.0f;
// Frequency of the ridged ceiling formations. Lower = broader ridges.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0"))
float CeilingRidgeFrequency = 0.02f;
// Domain-warp the ceiling ridge/undulation query by this many voxels so ridgelines wind
// organically instead of looking like axis-aligned noise. 0 = no warp (fine bumps are
// unaffected, like the ground heightfield's detail layer).
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0"))
float CeilingWarpStrength = 0.0f;
// Frequency of the ceiling warp noise. Lower = broader bends.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Sky", meta = (ClampMin = "0.0"))
float CeilingWarpFrequency = 0.01f;
// Solid shell thickness at strate top/bottom (voxels).
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Surface|Boundary", meta = (ClampMin = "0.0"))
float BoundarySealThickness = 4.0f;
@@ -1673,17 +1713,23 @@ struct VOXELFORGE_API FStrateDecoration
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration")
UStaticMesh* InstancedMesh = nullptr;
// Spawn while the chunk's LOD <= this (0 = LOD0 only, the old behaviour).
// Lets instanced visual props persist on LOD1-2 chunks instead of popping out with
// LOD0. NOTE: placement samples the LOD's mesh vertices, so instances re-scatter
// slightly on LOD transitions (masked by the terrain's own LOD pop).
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0", ClampMax = "2"))
// LEGACY / UNUSED by the world-grid decoration system (§8.5). It once meant a clipmap tile level,
// then a near/far distance tier — both removed. All decorations now stream within a single radius
// (VoxelSettings::DecorationRadiusChunks) and never re-stream in place. Kept to avoid breaking assets.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0", ClampMax = "8"))
int32 MaxLODLevel = 0;
// Which surface type this decoration can be placed on
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration")
ESurfaceType SurfacePlacement = ESurfaceType::Any;
// Maximum surface tilt (degrees from flat) this decoration tolerates. The surface tilt is
// acos(|normal.Z|): 0 = perfectly flat floor/ceiling, 90 = vertical wall. Grass on gentle
// ground → ~30-40; rock/lichen that clings to slopes → 90 (no filter, the default).
// 90 → place anywhere (default, no filter) · 35 → grass that avoids cliffs · 15 → flats only
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0.0", ClampMax = "90.0"))
float MaxSlopeAngle = 90.0f;
// Chance per valid surface point to spawn this decoration (0-1)
// 0.01 = rare, 0.1 = common, 0.5 = very dense
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration", meta = (ClampMin = "0.0", ClampMax = "1.0"))
@@ -1725,6 +1771,24 @@ struct VOXELFORGE_API FStrateDecoration
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Placement",
meta = (EditCondition = "bRequireWaterRelative"))
bool bPlaceBelowWater = false;
// ----- Performance (HISM render tuning — only affects the InstancedMesh path) -----
// Distance (world units / cm) past which instances stop rendering. This is THE lever that makes
// DENSE groundcover affordable: grass can be placed thickly but only drawn near the player, so the
// GPU cost is bounded by area-within-cull, not by the whole streaming radius. 0 = never cull (the
// default — correct for trees / large props you want visible to the horizon).
// 0 → no cull (props, trees)
// 2000 → ~20 m, typical grass / small ground clutter
// 4000 → ~40 m, taller plants you want visible a bit further
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Performance", meta = (ClampMin = "0.0"))
float CullDistance = 0.0f;
// Whether these instances cast dynamic shadows. Dense instanced shadows are the single biggest cost
// of heavy foliage — turn this OFF for grass / small clutter (an unlit-from-below tuft loses almost
// nothing visually). Leave ON for trees and anything large enough that its shadow reads as grounding.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Decoration|Performance")
bool bCastShadow = true;
};
/**
+45
View File
@@ -152,6 +152,48 @@ inline float SmoothStep01(float x)
// FMath::PerlinNoise3D — ce facteur remet à ~[-1, 1] pour les formules de densité.
constexpr float VOXEL_NOISE_SCALE = 1.25f;
//=============================================================================
// CLIPMAP TILE KEY
//=============================================================================
//
// A "tile" generalises a chunk for the chunked-LOD clipmap. A level-L tile spans
// (CHUNK_SIZE << Level) voxels per axis and is meshed at step (1 << Level), so it always
// produces a constant CHUNK_SIZE³-cell mesh (one component, one draw) but covers 8^Level ×
// the volume. Level 0 = a full-resolution chunk; each level up doubles linear size.
// Streaming loads concentric shells: level 0 near the player, coarser levels farther out,
// so chunk/draw count stays ~flat regardless of view distance.
struct FVoxelTileKey
{
FIntVector Coord = FIntVector::ZeroValue; // in units of (CHUNK_SIZE << Level) voxels
int32 Level = 0; // 0 = full res
FVoxelTileKey() = default;
FVoxelTileKey(const FIntVector& InCoord, int32 InLevel) : Coord(InCoord), Level(InLevel) {}
// Cell size (sampling step) in voxels, and tile extent in voxels per axis.
int32 StepVoxels() const { return 1 << Level; }
int32 ExtentVoxels() const { return CHUNK_SIZE << Level; }
// Min-corner of the tile in VOXEL coords (what the mesher takes).
FIntVector OriginVoxels() const { return Coord * (CHUNK_SIZE << Level); }
// Tile centre in world cm (for distance sorting / LOD selection).
FVector CenterCm() const
{
const double Ext = (double)(CHUNK_SIZE << Level) * (double)VOXEL_SIZE;
return FVector((Coord.X + 0.5) * Ext, (Coord.Y + 0.5) * Ext, (Coord.Z + 0.5) * Ext);
}
bool operator==(const FVoxelTileKey& O) const { return Level == O.Level && Coord == O.Coord; }
bool operator!=(const FVoxelTileKey& O) const { return !(*this == O); }
};
FORCEINLINE uint32 GetTypeHash(const FVoxelTileKey& K)
{
return HashCombine(GetTypeHash(K.Coord), ::GetTypeHash(K.Level));
}
//=============================================================================
// MESH DATA
//=============================================================================
@@ -166,6 +208,8 @@ struct FVoxelMeshData
TArray<int32> Triangles; // Indices, 3 par triangle
TArray<FVector2D> UVs; // Coords de texture (une par vertex)
TArray<FVector> Normals; // Normale lissée (gradient de densité)
TArray<FColor> Colors; // Masques matériau F6 (R=palette biome dominant, G=pente,
// B=poids de fondu de bordure, A=palette biome voisin)
void Clear()
{
@@ -173,6 +217,7 @@ struct FVoxelMeshData
Triangles.Empty();
UVs.Empty();
Normals.Empty();
Colors.Empty();
}
bool IsEmpty() const { return Vertices.Num() == 0; }
+41 -21
View File
@@ -21,6 +21,7 @@ class URealtimeMeshSimple;
class UVoxelDiffLayer;
class UVoxelContentManager;
class UVoxelAtmosphereManager;
class UMaterialInterface;
/**
* AVoxelWorld - The main voxel terrain actor
@@ -37,10 +38,8 @@ class UVoxelAtmosphereManager;
*/
struct FChunkResult
{
FIntVector ChunkCoord;
FVoxelChunk Chunk;
FVoxelTileKey Tile; // which clipmap tile this mesh is for (carries coord + level)
FVoxelMeshData MeshData;
int32 LODLevel = 0; // 0=full, 1=half, 2=quarter resolution
uint32 Epoch = 0; // Generation epoch — discard if stale
};
@@ -101,12 +100,22 @@ public:
// CHUNK STORAGE
//=========================================================================
/** All currently loaded chunks, keyed by chunk coordinate */
TMap<FIntVector, FVoxelChunk> Chunks;
//=========================================================================
// CLIPMAP TILE STORAGE (chunked-LOD)
//=========================================================================
// 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, coarser far), so total tile count stays low (~1-2k) regardless
// of view distance. That low count is WHY each tile can have its own component without a
// game-thread problem (this supersedes the earlier region-batching). Collision + content
// are level-0 only.
/** Mesh components for each chunk, keyed by chunk coordinate */
UPROPERTY()
TMap<FIntVector, URealtimeMeshComponent*> ChunkMeshes;
/** Tiles fully loaded — INCLUDING empty/all-air tiles, so we never re-submit them. */
TSet<FVoxelTileKey> LoadedTiles;
/** Render component per NON-empty loaded tile (GC-safe via actor ownership; not UPROPERTY
* because FVoxelTileKey isn't a USTRUCT key). */
TMap<FVoxelTileKey, URealtimeMeshComponent*> TileComponents;
@@ -351,7 +360,7 @@ public:
*
* @param ChunkCoord - Which chunk to load
*/
void LoadChunk(const FIntVector& ChunkCoord);
void LoadTile(const FVoxelTileKey& Tile);
/**
* Unload a single chunk.
@@ -362,7 +371,7 @@ public:
*
* @param ChunkCoord - Which chunk to unload
*/
void UnloadChunk(const FIntVector& ChunkCoord);
void UnloadTile(const FVoxelTileKey& Tile);
/**
* Apply mesh data to a RealtimeMesh component.
@@ -374,7 +383,15 @@ public:
* @param ChunkCoord - Which chunk this mesh belongs to
* @param MeshData - The generated mesh data
*/
void ApplyMeshToChunk(const FIntVector& ChunkCoord, const FVoxelMeshData& MeshData);
void ApplyMeshToTile(const FVoxelTileKey& Tile, const FVoxelMeshData& MeshData);
/** Build the clipmap desired-tile set (concentric shells) around the player tile. */
void BuildDesiredTiles(const FIntVector& CenterChunkCoord);
/** True if a tile's world footprint is still within the outermost clip shell (so a
* not-desired loaded tile there is mid-LOD-transition and must wait for its replacement,
* vs. one that has left the view entirely and can be culled immediately). */
bool IsTileInClipRange(const FVoxelTileKey& Tile, const FIntVector& CenterChunkCoord) const;
//=========================================================================
// HELPERS
@@ -418,7 +435,7 @@ public:
// link and silently drop results, which leaks PendingChunkCoord slots until the
// budget is exhausted and streaming stalls permanently. Mpsc guards the producer side.
TQueue<FChunkResult, EQueueMode::Mpsc> ProcessQueue;
TSet<FIntVector> PendingChunkCoord;
TSet<FVoxelTileKey> PendingTiles; // tiles with a gen task in flight
// Set to true during EndPlay — async tasks check this before accessing UObjects
std::atomic<bool> bShuttingDown{false};
@@ -426,22 +443,25 @@ public:
// Number of async tasks currently running — EndPlay waits for this to reach 0
std::atomic<int32> ActiveTaskCount{0};
// Last known player chunk coord — used by LoadChunk to compute LOD
// Player's level-0 tile coord (= chunk coord). The desired set is rebuilt when this changes.
FIntVector CurrentCenterChunk = FIntVector::ZeroValue;
// Track current LOD per loaded chunk (for LOD transitions)
TMap<FIntVector, int32> ChunkLODs;
// --- Streaming work-avoidance (perf) ---
// The desired chunk set only changes when the player crosses a chunk boundary.
// We cache it and only rebuild/cull/sort on a real move, and go idle once every
// desired chunk is streamed in — so a stationary player costs ~nothing per frame.
// The desired tile set only changes when the player crosses a level-0 tile boundary.
// We cache it and only rebuild/cull/sort on a real move, and go idle once every desired
// tile is streamed in — so a stationary player costs ~nothing per frame.
FIntVector LastUpdateCenter = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
bool bAllChunksLoaded = false;
TArray<FIntVector> DesiredSorted; // desired coords, nearest-first
TSet<FIntVector> DesiredSet; // O(1) membership for the cull pass
TArray<FVoxelTileKey> DesiredSorted; // desired tiles, nearest-first
TSet<FVoxelTileKey> DesiredSet; // O(1) membership for the cull pass
// Tiles approved for removal but whose teardown (component destroy + content actor Destroy())
// is spread across frames. Unbudgeted, a fast traversal culls a whole shell's worth of tiles in
// ONE frame → a game-thread teardown spike. Drained by ProcessUnloadQueue (catch-up scaled).
TSet<FVoxelTileKey> PendingUnload;
void ProcessPendingChunks();
void ProcessUnloadQueue(); // budgeted teardown drain (see PendingUnload)
/**
* Re-queue loaded chunks for async re-generation + re-meshing.