Jahni chose: make the (0,0) column join the shafts it passes, so VerticalShafts behaves like the other seven archetypes. Built, and it works as far as it goes -- the spine is now the LARGEST air component in 13 of 16 seeds (share up to 0.81, was 0.096 and never largest). ⚠️ IT DOES NOT MEET THE ACCEPTANCE CRITERION. arrival->departure is still 1/16. Reported as a failure rather than dressed up, because the criterion was the measured law, not "the code was written". The test now prints ACCEPTANCE arrival->departure: FAIL (1/16 seeds pass) so it cannot be quietly forgotten. Why it is insufficient, and it is structural: the spine only joins its own 3x3 cell neighbourhood, while passage mouths land 60-200 voxels from (0,0). The departure network is unreachable from the spine in all 16 seeds. Joining the hub to its neighbours does not help if the rest of the graph never reaches the hub. Part A diagnosis (seed 1337, margin 0, step 2, radius 192): baseline 30 components, largest .220 roughness 0 17 components, largest .226 connector radius 8 29 components, largest .229 H1 is real but partial: roughness fragments the space (13 of the 30 components are noise pockets), yet widening connectors past the proven roughness bound barely moves it. So roughness is NOT what severs the network -- the largest component sits at ~0.22 in every variant, which is the signature of a graph below its percolation threshold. The lever is topology, not radius. H2 (the hardcoded 1.6 pair cutoff) went untested. Implementation notes: the spine is added ONLY as a connector endpoint, not as a carving shaft -- VF_ApplyOriginSpine remains sole owner -- and is excluded from ledge placement. Spine connectors use radius 6.625, above the proven roughness supremum of 5.625 (sup|FBM| = 1.5, not 1.0). SpineRadius is threaded through BuildVerticalShaftStack, not hardcoded. Op-stack mirror bit-identical over 20,000 samples, 0 box-verdict violations. LayoutOrderIndependence green. Build clean, 19 succeeded / 0 warnings / 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bp7FxoQcSoiuhxjuLr6aBM
801 lines
77 KiB
Markdown
801 lines
77 KiB
Markdown
# VoxelForge — Architecture & Design Deep-Dive
|
||
|
||
> The 2026 redesign in detail: archetypes, the (0,0) spine, disturbances, content/atmosphere,
|
||
> biomes, and the **performance invariants** (the `§8.10` "don't regress" list). Read this
|
||
> before touching generation / strates / passages.
|
||
> Navigation, file index & conventions live in [CODEMAP.md](CODEMAP.md). Section numbers (`§8.x`)
|
||
> are preserved so existing cross-references keep resolving.
|
||
|
||
## 8. Archetypes, spine, disturbances, content & carving (2026 redesign)
|
||
|
||
A large A-to-Z expansion. The world is a stack of strates the player descends through;
|
||
each strate can be a fundamentally different *archetype*, connected at (0,0).
|
||
|
||
### 8.1 Archetypes (`ECaveGeneratorType`, VoxelStrateTypes.h)
|
||
Each archetype has its own param `USTRUCT` (on `UVoxelStrateDefinition`, EditCondition-gated
|
||
by `GeneratorType`) and its own density function in `VoxelGenerator.cpp`, dispatched by the
|
||
`switch` in `GetDensityAt`.
|
||
|
||
| Archetype | Params struct | Density fn | Idea |
|
||
|-----------|---------------|------------|------|
|
||
| TunnelNetwork | `FStrateGenerationParams` | `GetDensityWithParams` | rooms+tunnels (original) |
|
||
| FlatPlain / CrystalChamber | `FSlabGenerationParams` | `GetSlabDensity` | floor/ceiling void (original) |
|
||
| Maze | `FMazeGenerationParams` | `GetMazeDensity` | tight corridors on a 3D lattice (per-voxel, no cache; edge = lower node + axis hash) |
|
||
| SurfaceWorld | `FSurfaceGenerationParams` | `GetSurfaceDensity` | heightfield terrain: domain-warped continents+ridged mtns+detail, a low-freq **relief map** (`M`) that scales mountains/elevation for plains↔highland variety, **F20 heightfield terrain ops** (`Surface|Ops` — all default off ⇒ byte-identical): **Cliff** (slope-gated steepening — where the analytic structural slope > `CliffSlopeThreshold`, push the height away from the local mean by `CliffSharpness` ⇒ gentle slopes become sheer walls/canyon faces that hug real steep ground, gentle areas untouched; 4 structural resamples only when enabled), **Terrace** (relief-gated plateau quantize + `TerraceHardness` soft-round↔crisp-mesa), **LayerLines** (sedimentary sine shelves, slope-expressed) — pure per-column height remaps in the single oracle `ComputeSurfaceTerrainZ` (`SampleSurfaceStructuralZ` = pre-op raw height, re-sampled at an XY offset for Cliff's slope), biome-selected + border-blended for free via each biome's `SurfaceParams` + the height output-lerp; plus **phase-2 Overhang** (the first VOLUMETRIC op — real jutting shelves like a cliff lip): in `SurfaceDensityFromColumn`, for AIR voxels in the window `(TerrainZ, TerrainZ+OverhangHeight]` above a steep slope, the heightfield is re-sampled UPHILL (toward the cliff) by a reach that GROWS with height and unioned in. Low in the window the shift is ~0 (borrows nearby low rock ⇒ stays air over the void); high up it reaches the far cliff (solid) ⇒ a shelf attached to the cliff, tapering out over the void with air underneath. Per-column `OverhangAmp`(=strength·slope-gate) + unit uphill dir `(DirX,DirY)` are resolved once in `ComputeSurfaceColumn` — the gradient sampled at the REACH scale (`OverhangReach`) so a point out over the void can "see" the cliff to know which way is uphill — and cached on `FSurfaceColumn`. It's genuine 3D (per-voxel structural re-eval), so it's gated hard to steep overhang columns; the union only ADDS rock (never removes), capped at `TerrainZ+OverhangHeight`, so `ClassifyTile` forces Mixed only in `(TerrainZ, TerrainZ+OverhangMargin]` (upward-only; margin = max `OverhangHeight`) — a shelf never holes a trivially-skipped tile, and only the thin cliff-edge band of air tiles is woken (NOT far-field like phase-3 spikes/holes). Overhang undersides classify as ground rock by the F17 rule (down-facing but below `TerrainZ`). Known v1 limit: applies at all LODs (Step-agnostic) — may alias far; gate to fine tiles later. 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 vs ground is a PER-TRIANGLE surface class (F17), not a per-tile verdict**: the mesher classifies each unique vertex on the **worker** — only down-facing verts (`N.Z<-0.1`) pay a memoized `GetSurfaceHeightAt` column query; nearer `CeilSurf` ⇒ sky-cap, nearer `TerrainZ` ⇒ terrain overhang stays ground (a future SurfaceWorld cave roof — down-facing but below `TerrainZ` — also lands ground by the same rule; non-surface strates always ground). Triangles take the majority class of their 3 verts and are packed as **two contiguous index runs** (ground then cap, `FVoxelMeshData::NumCeilingTriangles`; skirts inherit their source triangle's run) → RMC **polygroups 0/1** → one **section per non-empty group** (`ApplyMeshToTile`, material slot = group index): slot 0 = strate `OverrideMaterial`/default (min-corner chunk), slot 1 = `CeilingMaterial` resolved at the tile's **TOP chunk** (mid as gap fallback, then min; fallback: ground material) — a coarse tile is 2^level chunks tall, so its min corner can sit in a lower strate/gap while the cap belongs to the strate above (this was the "far cap = ground material" residue), so the shadowless overhead rock is tinted separately instead of reading flat/bright. Shadow is **per section** via `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): ground casts at level≤1, the cap section never casts, so the rock ceiling never shadows the terrain below it. History: v1 was a game-thread centre height-oracle (misclassified coarse far tiles → terrain material on the cap underside); v2 a whole-tile worker normal VOTE — which painted **mixed coarse tiles** (one far tile spanning terrain AND cap) entirely with the winner's material, and put sky material under terrain overhangs. The per-triangle class fixes both and is the identity channel caves/F8 will reuse. |
|
||
| VerticalShafts | `FVerticalShaftParams` | `GetVerticalShaftDensity` | full-height shafts + horizontal connectors + partial ledges |
|
||
| FloatingIslands | `FFloatingIslandParams` | `GetFloatingIslandDensity` | asymmetric islands: flat land top + underside tapering to a point, lobed (domain-warped) outline, in an open void |
|
||
| Underwater | `FStrateGenerationParams` + water | (reuses `GetDensityWithParams`) | tunnel rock + high water table |
|
||
|
||
All density fns share the convention: internal **positive=solid**, apply origin spine →
|
||
boundary seal → inter-strate passages, then `return -Density` (MC: negative=solid).
|
||
StrateManager provides params per chunk via `GetMaze/Surface/VerticalShaft/FloatingIslandParamsForChunk`
|
||
(macro `VF_ARCHETYPE_PARAMS_GETTER`) — no cross-boundary blend (Hard transitions between archetypes).
|
||
On top of the archetype, an optional **biome** layer (§8.14) modulates terrain & content WITHIN a
|
||
strate via a window-invariant XY field — currently wired into SurfaceWorld.
|
||
|
||
⚠️ **The `switch` above is no longer the only density path.** 6 of the 8 archetypes (all but
|
||
TunnelNetwork and Underwater) also exist as **operator stacks**, selected per strate by
|
||
`bUseOperatorStack` and evaluated instead of the `switch`; each is bit-identical to the function in
|
||
its row. The design lives in `OPSTACK-PLAN.md` / `OPSTACK-DECOMPOSITION.md`, the symbol index in
|
||
`CODEMAP §3.2d` — not repeated here. What matters for *this* document: the archetype table describes
|
||
what the world IS, and both paths compute it.
|
||
|
||
### 8.2 (0,0) spine & hybrid connections
|
||
- `ApplyOriginSpine` (VoxelGenerator.cpp, static helper) carves a guaranteed open vertical
|
||
column at XY (0,0) in every strate's **interior** (seals untouched). Radius =
|
||
`UVoxelGenerator::OriginSpineRadius` ← `VoxelSettings::OriginSpineRadius`. Called before
|
||
every `ApplyBoundarySeal`.
|
||
- `VerticalShafts` treats that structural column as a **connector endpoint**, not as a second
|
||
density primitive: while rebuilding its memoised 3×3 shaft neighbourhood, an origin-adjacent
|
||
query adds `(0,0,OriginSpineRadius)` to the local shaft set for pair generation only. The
|
||
existing `VoxelHash::Pair` gate and `Spacing*1.6` neighbour cutoff remain in force, so the link
|
||
is a pure function of the shaft cell and seed and is independent of evaluation order. Spine
|
||
links use a radius above the proven `sup|FBM|=1.5` roughness envelope (`max(ConnectorRadius,
|
||
SurfaceRoughness*VOXEL_NOISE_SCALE*1.5 + 1)`); the ordinary connector radius and ordinary shaft
|
||
roughness are unchanged. `BuildVerticalShaftStack` receives the same runtime spine radius and
|
||
mirrors this source rule; `VF_ApplyOriginSpine` remains the sole owner of the column itself.
|
||
- Descent is **player-dug** through the thin seals at (0,0). The single auto-opened
|
||
connection is the **surface entry shaft** at (0,0) through the top of strate 0
|
||
(`GeneratePassages`, `bOpenSurfaceEntry`).
|
||
- **Hybrid extras:** auto-carved *shortcut* passages per boundary, placed away from (0,0).
|
||
Now fully **per-strate** — see §8.8 (the upper strate's `PassageConfig` drives count/style/shape).
|
||
|
||
This connector is local topology, not a promise that independently placed shortcut mouths are
|
||
globally connected. The acceptance measurement for `VerticalShafts` remains the actual
|
||
arrival→departure flood-fill at the passage endpoints.
|
||
|
||
### 8.3 Disturbance layer (the "wow" post-process)
|
||
`FStrateDisturbanceParams` (on the definition, all archetypes). `ApplyDisturbances`
|
||
(VoxelGenerator.cpp static, **MC convention**) runs in `GetDensityAt` after dispatch:
|
||
chasms (carve air), bridges (solid spans), ridges (solid blades). Stays inside seal bands.
|
||
Provided per chunk by `StrateManager::GetDisturbanceParamsForChunk`.
|
||
|
||
### 8.4 Cross-chunk determinism (the seam-prevention invariant)
|
||
`BuildChunkCache` (VoxelCaveMorphology.cpp) uses **two regions**: a wide *COLLECT* region
|
||
(`2*MaxTunnelLength + MaxInfluence`) over which connectivity is decided (NN filtered to
|
||
`<= MaxTunnelLength`, origin cap = deterministic top-N by hash), and a tight *STORE* region
|
||
(`+MaxInfluence`) kept for per-voxel eval. This makes the room/tunnel graph window-invariant.
|
||
**If you add a connectivity rule with longer edges, the COLLECT region must still cover the
|
||
max edge reach, and decisions must not depend on the stored window.**
|
||
|
||
### 8.5 Content scatter & water — `VoxelContentManager.h/.cpp` (NEW)
|
||
`UVoxelContentManager` (owned by `AVoxelWorld`, game-thread). TWO INDEPENDENT subsystems:
|
||
|
||
**(A) DECORATIONS — distance-based WORLD GRID (the no-pop system, 2026-06-17).** Decorations are placed
|
||
on a fixed world XY cell grid (**1 cell = 1 chunk footprint**, `DECO_CELL_VOXELS = CHUNK_SIZE`) and streamed
|
||
by DISTANCE from the player, **fully decoupled from clipmap tiles / LOD**. **THE MARCH RUNS ASYNC ON WORKER
|
||
THREADS** (mirrors mesh gen — `GetDensityAt` is thread-safe; the synchronous-on-game-thread first cut was a
|
||
perf disaster + starved streaming → seams, so it was moved off-thread). Driven by `AVoxelWorld::Tick →
|
||
UpdateDecorations(playerWorldPos)`, three phases: **(1)** recompute the desired cell set (`RebuildDesiredCells`)
|
||
only when the player crosses a cell boundary OR changes strate — clears out-of-range loaded cells, queues
|
||
cells that are NOT loaded and NOT in flight (`PendingLaunch`, nearest-first). A loaded region 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. **TWO STREAMING GRIDS (`FStrateDecoration::StreamTier`, 2026-06-26):** the stream RADIUS is a
|
||
property of the GRID, never of an entry — mixing radii inside one grid would force an in-place re-stream when
|
||
the player crosses an entry's radius (the original tier system's flicker bug). So there are exactly two
|
||
self-contained grids (`FDecoGrid`, each with its own region map + builds + queues + HISMs): **NearGrid**
|
||
(`DecorationNearRadiusChunks`, FINE `DecorationSpacingVoxels` column grid — dense groundcover near the player)
|
||
and **FarGrid** (`DecorationRadiusChunks`, COARSE `DecorationFarSpacingVoxels` column grid — trees/landmarks/RARE
|
||
props visible everywhere; the coarse grid is what makes a rare prop cheap, since the worker march cost scales
|
||
with column count). An entry picks a grid via `StreamTier` (default Far); the palette is partitioned by tier so
|
||
each grid marches only its own subset. A given world XY is covered by a Far region always, plus a Near region
|
||
when close (separate HISMs → crossing the near boundary never touches the far region). **(2)** `LaunchDecoTasks`
|
||
(per grid, sharing ONE `MaxConcurrentDecorationTasks` budget — each throttles against the other's in-flight count):
|
||
snapshot the update's decoration palette (built once 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`). **Placement-constraint gates (all in `PlaceAtCrossing`, deterministic, zero-cost at defaults):**
|
||
`MinSlopeAngle` (lower companion to Max — band a prop onto a tilt range, e.g. 30..70 = slopes only),
|
||
`bWallExcludeOverhangs` (wall-only-upright: drop normals with N.Z < 0 so downward overhangs don't take wall props).
|
||
**Shared vocabulary (2026-07-06):** these gates + spawn/transform/render fields now live on `FPlacementProfile`
|
||
(embedded as `Profile` on `FStrateDecoration`, `FStrateLandmark`, and the coming `FStrateSetPiece`), so all three
|
||
scatter primitives are authored identically. Rotation unified to `RotationOffset` (fixed) + `RandomRotation`
|
||
(per-axis hash-random) — decoration's ctor defaults `RandomRotation.Yaw = 360` (full random heading, replacing the
|
||
old `bRandomYaw`/`MinYaw`/`MaxYaw`; banded yaw = offset + a smaller random range). Distribution is unchanged; the
|
||
exact per-instance yaw values reshuffle once (different hash mix).
|
||
**F7 AWARE PLACEMENT (2026-07-06):** `FPlacementProfile::Conditions` = a list of `FTerrainCondition`
|
||
DERIVED PREDICATES (relief / moisture / biome-border weight, each an inclusive [Min,Max] band, optional
|
||
invert), AND-ed and evaluated by `Generator::EvaluateTerrainConditions` at the candidate XY — "conditions,
|
||
not annotations": nothing is stored, the phenomenon is queried from the analytic fields (SampleRelief /
|
||
SampleMoisture / SampleBiomeAt) on demand, so it's deterministic + worker-safe (the caller hands over the
|
||
strate's already-resolved `FBiomeContext`, so no re-resolve). Opt-in per entry (empty list = zero cost);
|
||
wired into both the deco worker (`BuildCellSpawns`) and landmark placement (`SpawnLandmarkInstance`). This
|
||
is the shared core of the coming quest `FindFeature` locator (same predicate, run as an outward search) and
|
||
the anchor gate for `FStrateSetPiece`. The BP bridge `AVoxelWorld::GetVoxelSurfaceHeightAt` exposes the
|
||
trace-free deterministic ground/ceiling height so authored ruin/set-piece Blueprints self-arrange on the
|
||
real surface before it meshes. Next types (water-edge band, relief-peak local-max, slope) are additive.
|
||
**F7 COMPANIONS (relational decoration, `FDecoCompanion`):** each `FStrateDecoration` may list `Companions`
|
||
(satellites: rocks/mushrooms around a tree). When `PlaceAtCrossing` emits a parent spawn, it immediately rolls
|
||
each companion (probability → count in `[CountMin,CountMax]` → disk offset `RadiusMin/MaxVox`), all hashed off
|
||
the PARENT's hash `H` → a pure function of the parent, so NO "did a tree spawn here?" search. Each satellite offsets from the parent in the XY plane (voxel space) and then, by default (`bSnapToSurface`),
|
||
**re-snaps to the real surface at its OWN XY** via `FindLandmarkColumn` (cheap `GetSurfaceHeightAt` oracle on
|
||
SurfaceWorld, a short ray-march in caves; worker-safe — reads only `Gen`) — this kills floaters on uneven
|
||
ground; a satellite that finds no surface at its spot is skipped. Turn `bSnapToSurface` off to inherit the
|
||
parent's exact height+normal (cheapest, flat ground only). Satellites can also be **gated by their own
|
||
`Profile.Conditions`** evaluated at the satellite XY (e.g. a unique mushroom only at a biome border). They carry
|
||
their own `Profile` transform/render. `FDecoSpawn` gained `CompanionIdx` (−1 = the entry; else index into
|
||
`Companions`) so `MergeCellResult`/`ApplyRegion` resolve the satellite's mesh/actor + render tuning from
|
||
`Companions[ci].Profile` (the region mesh bucket now stores an `FPlacementProfile`, not a whole entry). **Two levels:** a companion may carry `SubCompanions` (`FDecoSubCompanion` — a distinct type, since UHT can't
|
||
reflect a self-recursive `FDecoCompanion`) that spawn ON each level-1 satellite (moss on a rock); level-2
|
||
**inherits the L1 satellite's snapped point** (no re-snap — the cost lever that keeps nesting cheap) and can
|
||
still gate on its own `Conditions`. `FDecoSpawn::SubIdx` routes L2 back to `Companions[ci].SubCompanions[sj]`.
|
||
A hard **per-parent budget** (`GMaxCompanionsPerParent`, 256) caps the total L1+L2 count so a misconfiguration
|
||
can't blow up regardless of authored counts. Bounded (count × parents, ≤ budget), deterministic, streams inside
|
||
the existing two-grid deco system untouched. Depth beyond 2 (and per-entry references for arbitrary nesting) is
|
||
the remaining flagged follow-up.
|
||
|
||
**F7 SET-PIECES — FOLDED INTO LANDMARKS (2026-07-06).** Set-pieces (ruins/shrines/monuments) and landmarks
|
||
(mini-suns) had no real reason to be separate once both shared the spawn core and gained `Conditions`, so
|
||
`FStrateSetPiece`/`UpdateSetPieces` were MERGED into `FStrateLandmark`/`UpdateLandmarks` — one primitive, one
|
||
`UVoxelStrateDefinition::Landmarks` list. Each entry has an `AnchorMode`: **HashLattice** (scatter on a coarse
|
||
lattice, `SpacingChunks`) or **PassageMouth** (enumerate `StrateManager->GetPassages()`, keep endpoints whose
|
||
Upper/LowerStrateIndex == the current strate — `bAtDescentMouths` = the hole going down, `bAtArrivalMouths` =
|
||
where you land; endpoints are global VOXEL coords). Feature-conditioning is just `Profile.Conditions` on either
|
||
mode. **Exclusion (self-awareness, optional):** `ExclusionRadiusChunks` (0 = OFF, the default — mini-suns don't
|
||
exclude) + `Priority`; a candidate is suppressed if a HIGHER-RANKED one's disk covers it (rank = Priority, then
|
||
hash — deterministic). `UpdateLandmarks` now gathers all candidates → resolves exclusion (SKIPPED entirely when
|
||
no entry opts in, so pure scatter keeps its old O(n) cost; HashLattice keeps the original hash salt 0x1A2D5u so
|
||
mini-sun positions are unchanged) → spawns survivors via `SpawnFromProfile` (+ the orb wrapper).
|
||
**Exclusion is now POP-FREE:** each entry is gathered in `StreamRadius + MaxExcl` (MaxExcl = the strate's largest
|
||
`ExclusionRadiusChunks`); the extra "ring" candidates carry `bSpawnable = false` and only SUPPRESS (never spawn),
|
||
so every conflictor of an in-range candidate is always present regardless of player position → a candidate's fate
|
||
is a pure function of (seed, layout), no edge-of-radius flicker.
|
||
**Decoration footprint (`bSuppressDecorationsUnder` + `SuppressRadiusChunks`):** a landmark mesh doesn't change
|
||
density, so the deco placer can't see it — instead, on spawn it calls `RemoveDecorationsInSphere` to clear grass
|
||
in its footprint, stores the footprint on `FLandmarkInstance`, and `ApplyRegion` re-clears under any loaded
|
||
suppressing landmark when a deco region streams in fresh (so temple floors stay clear as you leave/return).
|
||
**PLAYER DIG → GRASS REMOVAL:** `AVoxelWorld::ApplyModification` (the single funnel for every carve/fill brush)
|
||
calls `RemoveDecorationsInSphere(Center·VOXEL_SIZE, Radius·VOXEL_SIZE)` after the diff+remesh — instant,
|
||
flicker-free (only the affected HISM instances go, via `GetInstancesOverlappingSphere`+`RemoveInstances`; nothing
|
||
is cleared+rebuilt), so grass never floats over a dug hole. This only patches the LIVE instances; the placer's
|
||
existing density check (`D(VX,VY,hC) <= 0.5f`) already keeps any future natural rebuild correct. (Editing a
|
||
Static-mobility HISM re-caches its proxy — fine for player-paced digging.) Not handled by immediate removal: new
|
||
grass on a freshly-exposed ledge / regrowth after fill-back — both self-correct on the next natural re-stream. **Freeze note:** a huge set-piece mesh hitches on register (game-thread proxy/distance-field build —
|
||
NOT async-fixable; the spawn is game-thread by engine rule; the asset is a hard ref so already resident) →
|
||
mitigate ASSET-side (Nanite on the mesh, bake distance fields), optionally budget spawns across frames.
|
||
`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.
|
||
**Per-entry tier (`StreamTier`, default Far)** picks NearGrid or FarGrid; radius + column spacing are PER-GRID
|
||
settings, never per-entry (a per-entry radius would re-introduce the in-place re-stream flicker — see the two-grid
|
||
rationale above). This REPLACES the vestigial `MaxLODLevel`; the dead `DecorationActorRadiusChunks` setting is
|
||
repurposed as `DecorationNearRadiusChunks`. `CullDistance` still bounds GPU draw on top (orthogonal to which grid
|
||
streams the entry). **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 — FAR reach in cells), `DecorationNearRadiusChunks` (3 — NEAR
|
||
reach), `DecorationSpacingVoxels` (4 → 8×8 cols/cell — NEAR/fine grid), `DecorationFarSpacingVoxels` (4 by default
|
||
→ raise to 8–16 for a cheap coarse FAR grid; the rare-prop lever), `DecorationRegionSizeCells` (4 — RxR cells per
|
||
region/HISM), `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²).
|
||
|
||
**(A2) LANDMARKS — rare large objects on a COARSE HASH LATTICE (`UpdateLandmarks`, 2026-06-26).** The right
|
||
primitive for sparse, far-visible objects like the underground "mini-suns" — where the per-chunk decoration
|
||
grid fails: at a 2048-chunk radius that grid enumerates ~13M cells per cell-crossing on the game thread and
|
||
FREEZES. Landmarks instead live on a per-entry hash lattice (cell = `FStrateLandmark::SpacingChunks` chunks),
|
||
so a radius-R disk holds only ~(R/Spacing)² candidates (≈16 at R=2048, Spacing=512). Listed strate-wide on
|
||
`UVoxelStrateDefinition::Landmarks`. Each Tick, for each entry, walk the small lattice box around the player
|
||
within `StreamRadiusChunks`: `DecoHash(cell,entry,seed)` rolls existence (`SpawnProbability`), a jittered XY
|
||
(`JitterFraction` — effective min spacing ≈ Spacing·(1−Jitter)), then a SINGLE-column surface find
|
||
(`FindLandmarkColumn`: SurfaceWorld height oracle, else one density ray-march) snaps to the chosen
|
||
`SurfacePlacement` (default Ceiling = sky-cap). Gates mirror decorations (biome via `GetDominantBiomeAt`,
|
||
slope band, water-relative). Foliage-style transform tweaks: `LocationOffset` (world XYZ), `RotationOffset` +
|
||
per-axis `RandomRotation`, `Min/MaxScale`, `bAlignToSurface`. Spawned as a real actor (`ActorClass`, for a
|
||
sun's light) or one Static `UStaticMeshComponent` (`InstancedMesh`, `CullDistance`=0 → never cull). All
|
||
SYNCHRONOUS on the game thread (so few candidates it never hitches); a cell's surface-find runs only the
|
||
first frame it enters range, then is cached in `LandmarkInstances` (keyed `FIntVector(cellX,cellY,entry)`,
|
||
null entry = "evaluated, nothing placed" so it isn't retried). Deterministic (hash → no pop, infinite reach).
|
||
Strate-bounded (wiped on strate change, like decorations). This is the real home for "rare prop at all
|
||
distances" — the job the decoration FarGrid could approximate at moderate range but not at extreme radius.
|
||
|
||
**(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):** decorations resolve the dominant biome **PER COLUMN** on the worker
|
||
(`ResolveBiomeSampleAt` via the strate's `FBiomeContext`, box-cached → one rebuild per chunk footprint). The
|
||
update builds ONE flat decoration palette (every biome's list concatenated; `CurrentEntryBiome[i]` tags entry
|
||
`i` with its context-biome index, -1 = strate fallback), and `PlaceAtCrossing` rolls only the entries the
|
||
column's biome owns. This replaced the old per-CELL `GetDominantBiomeAt` (one biome for a whole 8 m cell →
|
||
axis-aligned border snapping); borders now follow the warped-Voronoi field at column resolution, no straight
|
||
lines. Water still uses the chunk-centre `GetDominantBiomeAt` for its material. `Initialize` now also takes
|
||
`UVoxelSettings*` (for the grid tunables). `ContentMaxLevel` is now legacy/dead for decorations.
|
||
|
||
### 8.6 Atmosphere — `VoxelAtmosphereManager.h/.cpp` (NEW)
|
||
`UVoxelAtmosphereManager` (owned by `AVoxelWorld`, gated by `bManageAtmosphere`).
|
||
`UpdateForPlayer(pos)` each Tick, reacts only on strate change. Drives a managed
|
||
`UExponentialHeightFogComponent` + movable `USkyLightComponent` from the player's strate
|
||
(`FogColor/FogDensity/bVolumetricFog/AmbientLightColor/AmbientLightIntensity`), and spawns
|
||
PERSISTENT ceiling/floor "layer" actors (`Def->CeilingLayerActor`/`FloorLayerActor` + ZOffsets
|
||
+ rotations) that follow the player in XY — the sky-island sea-of-clouds / two-sided fog.
|
||
`Def->AtmosphereActor` (a full BP with your own fog/sky/postprocess) OVERRIDES the managed
|
||
fog+sky for that strate. `Reset()` on ChangeSeed/EndPlay. (Skylight ambient underground is
|
||
weak — captures a dark scene; fog is the strong visual.)
|
||
**Per-biome atmosphere (§8.14):** `UpdateForPlayer` also resolves the player's dominant biome and,
|
||
when the biome has `bOverrideAtmosphere`, its fog/sky beats the strate's (reacts on biome change, not
|
||
just strate change). `ApplyFogSky(Def, Biome)` is the shared path; layer actors + the full `AtmosphereActor`
|
||
BP stay strate-level. Needs the generator injected (`Initialize(..., Generator)`).
|
||
|
||
### 8.7 Inter-strate bedrock gap
|
||
`VoxelSettings::InterStrateGapChunks` (N) inserts N chunks of SOLID bedrock between consecutive
|
||
strates (`StrateManager::Initialize` leaves the gap in the layout). `IsGapChunk` detects it;
|
||
`GetDensityAt` renders gap chunks as solid + passages only (no caves/spine/seal) so the player
|
||
digs (0,0) through the gap to descend. `GetStrateUnrealZRange` gives a strate's cm Z range.
|
||
|
||
### 8.8 Inter-strate passages — PER-STRATE (`FStratePassageConfig` on the definition)
|
||
Each strate's `PassageConfig` (VoxelStrateTypes.h) controls its descent tunnels to the layer
|
||
below: `Connections`, `Style` (`EVoxelPassageStyle`: Straight/Worm/Spiral/Cascading),
|
||
`MouthRadius`/`MidRadius` (tapered width → `FVoxelPassage::ControlRadii` + `VoxelSDF::TaperedCapsule`),
|
||
`ReachMin/Max` (depth into each strate), `DistanceMin/Max` (from the (0,0) spine), `Wander`,
|
||
`Segments`, `VerticalWobble`, Spiral/Cascade params. Built in `StrateManager::GeneratePassages`
|
||
as control-point chains. **Worm = independent fBM per horizontal axis** (`PassageFBM` static)
|
||
with a flat-top envelope → organic squirm (NOT a 1D zigzag, NOT a same-freq 2-channel spiral).
|
||
`EvaluateModifierSDF` (per voxel) first builds a `thread_local` **per-chunk shortlist** of passages
|
||
whose bounds reach this chunk (rebuilt on chunk change / `PassagesVersion` bump) — chunks with no
|
||
passage near return `FLT_MAX` immediately — then **bounding-sphere-culls** each shortlisted passage
|
||
(`FVoxelPassage::BoundCenter/BoundRadiusSq`). Both are perf-critical (§8.10). The (0,0) surface entry
|
||
is a simple straight tube. Placement, reach, and shape draws for inter-strate passages are keyed by
|
||
`(seed, upper-strate index, connection index, per-value salt)` through `VoxelHash`, so one passage's
|
||
connection count cannot shift any later passage. Global passage settings were removed from
|
||
`VoxelSettings`.
|
||
|
||
After a passage's XY is selected, `GeneratePassages` asks the **destination** strate for a full lower-mouth
|
||
landing point through the pure free function `VF_SuggestLandingPoint` (`VoxelCaveMorphology`).
|
||
TunnelNetwork and Underwater keep the nearest hash-room vertical placement at the requested XY, with
|
||
`MakeStrateSeed(world-seed, strate-index)` matching the room graph's existing identity; FlatPlain and
|
||
CrystalChamber recompute their slab void band and reject column-overlap points. Maze snaps to the
|
||
nearest roughness-safe horizontal lattice corridor within one cell, VerticalShafts to a roughness-safe
|
||
shaft within one shaft spacing, and FloatingIslands to a validated blob top within one island spacing.
|
||
Those three return false when no footing exists inside that explicit lateral budget. GeneratePassages
|
||
interpolates the snapped XY through the control-point chain and recomputes its conservative bound;
|
||
SurfaceWorld remains deliberately unanswerable because production terrain can be selected through
|
||
manager-owned biome/per-column context.
|
||
Declined answers (unsupported or no-footing) keep the historical random reach and emit a warning. This query is source-level
|
||
only — it does not construct an operator stack, touch a cache, or call back into
|
||
`UVoxelStrateManager` while `Initialize` is building the layout. The resulting lower endpoint is
|
||
clamped to the destination's seal-free interior.
|
||
|
||
### 8.9 Carving — brush shapes + editor controls
|
||
`FVoxelModification` has `EVoxelBrushShape {Sphere,Box,Capsule}` + `BoxExtent`/`CapsuleEnd`/
|
||
`Falloff` + `GetWorldBounds`. `UVoxelDiffLayer::GetDensityOffset` switches per shape; chunk
|
||
overlap uses the shape AABB. `AVoxelWorld`: `CarveBox/FillBox/CarveCapsule/FillCapsule/
|
||
ApplyModification` (BlueprintCallable) + `EditorCarveSphere/EditorFillSphere` (CallInEditor)
|
||
driven by `EditorBrush*` props.
|
||
|
||
### 8.10 Performance invariants (DON'T regress)
|
||
- **Streaming** (`UpdateChunksAroundPosition`): rebuild/cull the desired set ONLY when the
|
||
player crosses a chunk boundary (`LastUpdateCenter`); use the `DesiredSet` TSet for the cull;
|
||
idle via `bAllChunksLoaded`. Stationary player ≈ free. (Old per-frame O(loaded×desired) scan = 22ms.)
|
||
- **LOD** changes HOT-SWAP (`LoadChunk` only, never unload-first) → no holes. LOD
|
||
reconciliation lives in the PERSISTENT per-frame submit loop (same loop as new-chunk loads),
|
||
NOT as a one-shot on the boundary-cross frame — a one-shot drops every chunk past the task
|
||
budget and strands it at a stale LOD. Idle (`bAllChunksLoaded`) only when a full scan finds
|
||
no loads AND no LOD mismatches outstanding.
|
||
- **SDF cache** (`GetDensityWithParams`): search-BOX validity, not chunk-key — gradient ±1
|
||
sampling must not thrash the (expensive) rebuild.
|
||
- **VerticalShafts field cache**: shaft rolls and pair decisions, including the structural-spine
|
||
connector endpoint, are rebuilt only when the thread-local centre cell or a geometry-affecting
|
||
parameter changes. They must not be re-hashed in the per-voxel loop; the operator-stack source
|
||
follows the same cache contract.
|
||
- **Per-chunk param cache** in `GetDensityAt`: GenType + param struct + disturbance cached
|
||
thread-locally by `(DensityCacheOwnerId, ChunkCoord, LayoutVersion)`; the process-unique owner ID
|
||
prevents cross-world reuse while adding only one `uint64` compare per voxel. Don't remove the owner
|
||
or layout key, and don't move the fetch/blend back to per-voxel.
|
||
- **Biome cache** (`ResolveBiomeSampleAt`/`FChunkBiomeCache`, §8.14): validity is a world-XY BOX +
|
||
ChunkZ + Seed, NOT a chunk key — same reason as the SDF cache. The cell classification is
|
||
noise-heavy; a chunk-key would thrash it on gradient-normal / +X/+Y boundary samples. Keep
|
||
the box halo (≥ CHUNK_SIZE) + cell margin (warp + CellSize) so the 3x3 lookup never misses.
|
||
- **Passage cull** (§8.8) + **morphology two-region** (§8.4): both are per-voxel-cost critical.
|
||
- **Per-chunk passage shortlist** (`EvaluateModifierSDF`): runs per voxel and is called from every
|
||
archetype's `ApplyPassageCarving`. Keeps a `thread_local` shortlist (passage INDICES) of passages
|
||
whose bounds reach the current chunk, rebuilt only on chunk change or `PassagesVersion` bump
|
||
(incremented in `GeneratePassages`). Most chunks have NO passage near → instant `FLT_MAX` return
|
||
instead of walking the whole `Passages` array per voxel. Conservative superset (chunk bounding
|
||
sphere vs passage bound) ⇒ bit-identical carve. Store indices + version, never pointers (the array
|
||
is rebuilt on `RebuildStrates`).
|
||
- **Gen tasks run at `UE::Tasks::ETaskPriority::BackgroundNormal`** (`LoadTile`): worker gen yields
|
||
to foreground game/render tasks. Without it, raising `MaxConcurrentTasks` past the spare-core count
|
||
saturates the scheduler and starves the frame (the "concurrency > ~12 = stutter" symptom). Keep gen
|
||
at background priority so the frame keeps its cores.
|
||
- **Mesher density grid + margin ring** (`GenerateMesh`): sample each grid point ONCE into a flat
|
||
`(CHUNK_SIZE/Step + 1 + 2)³` array (the `+2` is a 1-point MARGIN ring, indices −1..GridDim, for
|
||
T1.b normals). The cell loop reads 8 corners from it; per-cell sampling would call `GetDensityAt`
|
||
~8× too often. Geometry is bit-identical (edge positions unchanged). Don't refactor back to
|
||
per-corner `GetDensity` and don't drop the margin ring (normals + seamless borders need it).
|
||
The cell loop is **two-pass**: pass 1 reads the 8 corner densities + builds the MC case index and
|
||
`continue`s on no-surface cells (≈70% of cells); pass 2 computes the 8 positions + grid-gradients
|
||
ONLY for surface cells. Don't hoist position/gradient back above the case-index test. The
|
||
`DensityGrid` and vertex-dedup `TMap` are `thread_local` and reused per worker (Reset / keep
|
||
capacity) — don't make them per-call locals (re-allocates ~170 KB + a hash map every tile).
|
||
**CAPTURE-DURING-MESHING (sanctioned reuse, doesn't regress the above):** `GenerateMesh`'s optional
|
||
`OutCaptureGrid` copies the already-filled `DensityGrid` interior (`CHUNK_SIZE³`, quantized via
|
||
`VF_QuantizeDensity`) out for the density clipmap (mini-sun shadows) — a PURE READ added after the
|
||
grid loop. It does NOT touch the grid shape, the two-pass loop, the margin ring, or the thread_local
|
||
reuse. Only level-0 full-res tiles request it (`Step==1<<Level`, 1:1 with a clipmap level). The
|
||
clipmap (`UVoxelDensityVolume`) reuses these bytes instead of re-evaluating `GetDensityAt` on its own
|
||
fills — see `IngestTileCapture` / `BlitCaptureToWindow` (cache keyed by tile coord) and the
|
||
chunk-aligned level-0 recenter. The volume fill that captures DON'T cover runs on **one dedicated
|
||
thread** (`FVoxelDensityFillRunnable`, off the UE::Tasks pool — the old shared-pool `BackgroundLow`
|
||
fill starved behind mesh-gen, ~10 s to resolve shadows; the dedicated thread fills full-speed without
|
||
stealing a mesh-gen core) and is the backstop for cache misses (vertical strate gaps, cold start,
|
||
carves). Captures are bit-identical to a fill of the same cells (same `GetDensityAt`, same
|
||
`VF_QuantizeDensity`).
|
||
- **Normals from the density grid (T1.b)** (`GenerateMesh`): corner gradients = central differences
|
||
on the (margin) grid; edge normals interpolate the two corner gradients by the SAME `t` as the
|
||
position → seamless across chunk borders (both sides use identical pure samples). NO per-vertex
|
||
`GetDensityAt` (was ~6/vertex, often as costly as the whole grid). `ComputeGradientNormal` is now
|
||
unused. Only NORMALS changed vs the old path; geometry is identical.
|
||
- **Surface column cache (T1.a)** (`FSurfaceColumnCache` = LRU of `FSurfaceColumnBox`, `GetDensityAt`
|
||
SurfaceWorld branch): the heightfield + sky-cap + biome blend are a PURE function of (XY, seed,
|
||
strate) — **ZERO Z dependence** (climate/Voronoi are pure-XY; surface params are per-strate constant
|
||
under Hard transitions) — yet sampled ~33× per column (once per Z grid-point). Cached per integer XY
|
||
(box-valid, like the SDF cache) and reused down the column. **Keyed by (XY box, StrateKey, Seed), NOT
|
||
ChunkZ** (`StrateKey = round(StrateBottomWorldZ)`, taken from the params so it can't disagree with
|
||
them) and held as a small **LRU of 6 boxes** so the WHOLE vertical view-distance stack — and XY
|
||
neighbours the scheduler interleaves — share one another's heavy column noise instead of each
|
||
recomputing it ~once per vertical chunk (this was the dominant `GenerateMesh` cost: the same 2D
|
||
heightfield recomputed per altitude). It also makes pure-air / pure-solid chunks cheap (they hit the
|
||
shared box). Box `Halo = CHUNK_SIZE + 8` each side so the T1.b margin ring stays inside (no thrash).
|
||
**Used ONLY for integer-XY queries**; fractional queries compute directly → bit-identical. Don't
|
||
re-introduce a ChunkZ key, don't feed it fractional coords. (`GetSurfaceHeightAt`'s own `OC_*` oracle
|
||
cache is separate and still per-chunk — lower volume, not worth the LRU.)
|
||
- **Collision only at LOD0 (T1.c)** (`ApplyMeshToChunk`): `UpdateSectionConfig(..., LOD==0)`.
|
||
LOD1/2 chunks are unreachable (the §8.10 reconciliation hot-swaps to LOD0 before the player
|
||
arrives), so cooking their Chaos collision is waste. Don't force collision on for all LODs.
|
||
- **CHUNKED-LOD CLIPMAP — the streaming model** (`FVoxelTileKey` in VoxelTypes.h; `UpdateChunksAroundPosition`
|
||
/ `BuildDesiredTiles` / `IsTileInClipRange` / `LoadTile` / `UnloadTile` / `ApplyMeshToTile`; mesher
|
||
`GenerateMesh(OriginVoxels, Step)`). Replaces the fixed-32³-chunk + LOD-step-on-fixed-extent model
|
||
AND supersedes the old region-batching / strate-Z-clamp / wide-ceiling (all removed). A **level-L tile**
|
||
spans `CHUNK_SIZE<<L` voxels meshed at `step 1<<L` → constant 32³-cell mesh, ONE component, ONE draw,
|
||
covering 8^L× the volume. Streaming loads **concentric shells** (level 0 near, each coarser level a 2×
|
||
larger shell beyond; inner hole of level L = the region the finer level covers). **Total tile count
|
||
stays ~flat regardless of view distance** — that's why see-far (ceiling, horizon) is cheap AND why
|
||
per-tile components are fine for the game thread (no batching: ~1-2k tiles, not 40k). **Load-before-
|
||
unload cull** (no holes, STRICT): out-of-range tiles cull now; in-range LOD-transition tiles cull only
|
||
once EVERY desired tile overlapping their footprint is loaded — tested as "no UNLOADED desired tile
|
||
overlaps T" (`ReplacementsReady` + `FootprintsOverlap` vs the `DesiredPending` list, built once per
|
||
crossing = desired-minus-loaded, usually tiny). Scanning all of `DesiredSorted` per candidate was an
|
||
O(loaded×desired) game-thread spike when fast movement turned many tiles non-desired at once. A coarse
|
||
tile is replaced by several finer tiles, so the old center-owner
|
||
check (`ReplacementLoaded`) dropped it as soon as the ONE tile over its centre loaded → the not-yet-
|
||
ready edges flashed a hole; the full-coverage check keeps the old tile at its current resolution until
|
||
the better mesh is wholly in, then swaps. In-flight (pending) tiles are NEVER cancelled on a rebuild —
|
||
they finish, apply, and are culled later if no longer desired. Collision level-0 only; water level-0
|
||
only; shadows off for level≥2. **Decorations are NO LONGER tied to tiles** — they stream on a fixed world
|
||
grid by distance (§8.5), so they don't pop on LOD swaps. Settings: `VoxelSettings::ClipRadius` (full-res near radius, tiles/level),
|
||
`MaxClipLevel` (far reach). **NEAR-FIELD GEN COST levers** (`LoadTile`): levels `< FullResClipLevels`
|
||
mesh at full `CHUNK_SIZE` cells (≈35³ `GetDensityAt` incl. margin ring), coarser levels at
|
||
`CoarseTileCells` (Step = Extent/Cells) for far-cheaper gen. A level-1 tile at `FullResClipLevels=2`
|
||
costs the SAME gen as a level-0 tile (same cell count, 8× extent) — set `FullResClipLevels=1` to drop
|
||
level 1 to `CoarseTileCells` (~6× cheaper) when the near field is gen-bound (slightly harder L0→L1
|
||
seam, hidden by skirts). `ClipRadius` bounds the full-res level-0 tile COUNT independently of reach.
|
||
**STRATE CONTENT CUT** (`StrateContentCutMinLevel`, default 0 = all levels — tested: the level 0/1
|
||
straddler tiles were the visible mixers, a higher floor read as "no improvement"): a level-L tile is 2^L chunks TALL and
|
||
can straddle a strate boundary — at coarse Steps the thin seal/gap solid between two strates' airs
|
||
falls between lattice points (holes into the neighbour strate at far LOD) and one tile mixes both
|
||
strates' materials. From that level up, `GenerateMesh(..., BandZMin/MaxVox)` only meshes cells inside
|
||
the PLAYER's strate chunk-Z band (exact bounds, no margin — the view clamp handles selection; in the
|
||
inter-strate gap: no band). The band travels on `FChunkResult::BandChunkLo/Hi` so `ApplyMeshToTile`
|
||
clamps its strate/material lookups into the meshed content. On band change (strate transition) the
|
||
affected loaded coarse tiles re-queue via `BandRemeshQueue` (budgeted, re-gen in place, no pop).
|
||
Meshed cells stay bit-identical (§8.4 — same pure world samples; the cut only selects cells).
|
||
**Too-coarse skip (ultra levels)**: the cut is CELL-granular, so once one cell (Step voxels tall)
|
||
is taller than the whole band (level ≥7 at CoarseTileCells=16 with typical strate heights), a
|
||
band-overlapping cell still samples both strates' airs — same holes/mixing as uncut. `LoadTile`
|
||
detects `Step > band height` and emits an EMPTY tile (it could only render garbage); an empty
|
||
re-gen result also releases the tile's old component in `ProcessPendingChunks` (otherwise the
|
||
previous strate's geometry would linger after a band change).
|
||
**Horizon past that point = RENDER DISTANCE** (`RenderDistanceChunks`, default 0 = off): set
|
||
`MaxClipLevel` to the coarsest level that still renders strates correctly (one cell must fit
|
||
inside a strate band), then the OUTERMOST shell keeps generating tiles outward until it covers
|
||
the requested distance (`VF_OuterShell`, shared by `BuildDesiredTiles` and `IsTileInClipRange`
|
||
so the cull sees the same horizon; the ring's dz sweep is pre-clamped to the vertical strate
|
||
band). As MC tiles the ring cost grows with (distance/2^MaxClipLevel)² — which is why the ring
|
||
defaults to **F18 SHEETS** (`bFarSheetRing`): in an open strate the far field is exactly two
|
||
heightfields (TerrainZ + CeilSurf, per-column oracle), so the ring streams tiles at level
|
||
`MaxClipLevel + FarSheetSpanLevels` (one sheet = 2^span MC footprints per axis → 4-16× fewer
|
||
components) meshed by `GenerateSheetMesh` as two displaced grids — ground polygroup 0 / cap
|
||
polygroup 1, tags true by construction, same materials/UVs/F6 masks, ~3-6× cheaper gen per
|
||
area than band-cut MC. **XY hole**: a partially-covered sheet renders its whole footprint, so
|
||
the MC-covered box around the player (level-MaxClipLevel box, shrunk 1 tile for a seam-overlap
|
||
ring) is CUT out of the sheet at cell granularity (`SheetHole*Vox`, passed to
|
||
`GenerateSheetMesh`); when the player crosses a MaxClipLevel tile the hole moves and the
|
||
overlapping sheets re-queue via `BandRemeshQueue` — the shrink means the newly-cut area's MC
|
||
tiles were already desired one crossing earlier (loaded before uncovered). Sheet tiles take
|
||
the strate from the BAND (mid chunk): band unarmed
|
||
(inter-strate gap) ⇒ empty ring until landing; non-open strates ⇒ empty sheets (their far
|
||
ring was enclosed rock anyway); carved features (passages/spine/chasms/diff) don't show at
|
||
sheet distance — the MC shells keep them near. (A "step cap" variant — raising CoarseTileCells
|
||
per level so far levels keep a fine Step — was tried and rejected 2026-07-06.)
|
||
Trade-offs accepted: other strates simply don't render at far LOD (they're sealed/enclosed —
|
||
invisible except through passage mouths, which read as dark holes); passage tubes crossing the gap
|
||
are cut at coarse levels only (near levels mesh full).
|
||
`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.
|
||
- **Delta cull (stamped desired set)** (`DesiredStamped` + `DesiredStamp` + `TransitionHold`,
|
||
`BuildDesiredTiles`/`UpdateChunksAroundPosition`): the desired set is a `TMap<key, stamp>`;
|
||
each crossing bumps the stamp, upserts the new set, and ONE map sweep yields the **leavers**
|
||
(stale stamp — removed + returned). The cull then considers ONLY leavers + `TransitionHold`
|
||
(tiles kept by load-before-unload from earlier crossings) instead of re-scanning EVERY loaded
|
||
tile per crossing — that scan was the measured ~1.6 ms/crossing `CullTiles` spike (2026-07-05
|
||
trace, plus `BuildDesiredTiles` 0.66 ms on the same frames). In-flight tiles that finish while
|
||
no longer desired are caught at apply time (`ProcessPendingChunks` adds them to the hold);
|
||
`UnloadTile` drops hold entries; the settled cull (everything loaded) keeps its full scan as
|
||
the safety net. `DesiredPending` for the overlap test is now built LAZILY (only when an
|
||
in-range transition candidate exists). **The hold is re-evaluated on a ROTATING BUDGET**
|
||
(`TransitionHoldQueue` + cursor, ~256 tiles/crossing): re-scanning the whole hold each
|
||
crossing degenerates back to the O(loaded) scan whenever streaming never settles (measured
|
||
2.47 ms/crossing in the first packaged capture) — keeping a tile a few crossings longer is
|
||
always hole-safe, and every held tile passes under the cursor within a few crossings. The
|
||
SET is authoritative membership; the queue may hold stale keys (lazily dropped on scan).
|
||
Same hole-free semantics, O(delta + budget) per crossing.
|
||
- **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).
|
||
- **Trivial-empty tile reject (T1.d) — v2 SHIPPED (2026-07-05); v1 was reverted 2026-06-26.**
|
||
`UVoxelGenerator::ClassifyTile(Origin, Step, Cells)` runs on the gen worker BEFORE the density
|
||
pre-sample (`LoadTile` task, scope `VoxelForge_ClassifyTile`); `AllSolid`/`AllAir` ⇒ GenerateMesh is
|
||
skipped and the tile stays `bEmpty`. Motivation: the 2026-07-05 Insights trace showed **84 % of
|
||
GenerateMesh calls produced empty tiles** (83 925 gens vs 13 227 meshes — ~500 s of 617 s worker CPU
|
||
wasted) and gen throughput had become the felt gameplay limit ("standing waiting for generation").
|
||
**Why v1 failed & how v2 avoids it:** v1 used a GLOBAL analytic ceiling bound — not conservative when
|
||
the cap hangs low (`CeilingRoughness`/`RidgeStrength`) → holes in the roof. v2 makes NO amplitude
|
||
guesses: it evaluates `ComputeSurfaceColumn` (the SAME function as the density path, via the SHARED
|
||
`GSurfColCache`) **on the exact lattice the mesher would sample** (margin ring included) — same
|
||
functions + same inputs ⇒ the same floats ⇒ the verdict is exact at the lattice, not an estimate.
|
||
Per-z rules: gap chunk = solid; surface seal band (ApplyBoundarySeal inequalities, `BaseDensity>0`)
|
||
= solid; surface interior z: air ⇔ `TerrainZ ≤ z ≤ CeilSurf` (MC `D ≥ 0`); ANY other archetype /
|
||
out-of-layout chunkZ ⇒ `Mixed` (cave interiors are not provable in v1 of this classifier — a future
|
||
extension could use "SDF cache empty + worm network mask" for deep TunnelNetwork rock).
|
||
**Conservative guards** (anything that can carve/fill): player mods (`HasAnyModInChunkRange`) ⇒
|
||
Mixed; passages (`AnyPassageNearBox`, bounding spheres + carve blend pad) and the (0,0) spine
|
||
(circle/box XY) kill AllSolid; disturbance chasms kill AllSolid, bridges/ridges kill AllAir.
|
||
A false `Mixed` only costs CPU; the code must NEVER emit a false AllSolid/AllAir (that's a hole).
|
||
Capture tiles (`bWantCapture`, density-volume shadow window) always generate — the volume wants the
|
||
grid even for uniform cells. A sparse ~5×5 column pre-pass exits Mixed fast on surface-crossing
|
||
tiles; a Mixed verdict leaves its columns warm in `GSurfColCache` for the GenerateMesh that follows.
|
||
- **Worker-built StreamSet (T1.f)** (`BuildTileStreamSet`, `LoadTile` task → `FChunkResult::Streams`):
|
||
the RMC vertex/index buffers (`FRealtimeMeshStreamSet`) are built ON THE GEN WORKER, not on the game
|
||
thread. The per-vertex builder loop was the dominant game-thread streaming cost (measured: game
|
||
thread >6 ms while moving, GPU/Draw idle — purely game-bound). `BuildTileStreamSet` touches ONLY the
|
||
POD `FVoxelMeshData` arrays (no UObject, no generator) so it's worker-safe; `ApplyMeshToTile` now only
|
||
resolves material/ceiling (O(1)), gets/creates the component, and hands the finished streams to
|
||
`CreateSectionGroup(MoveTemp(...))` (which already uploads async via its `TFuture`). `FChunkResult`
|
||
carries the streams as a `TSharedPtr` (forward-declared in the header) so it stays movable through the
|
||
MPSC queue; the worker `Enqueue(MoveTemp(Result))` (no payload copy). Don't move the builder loop back
|
||
onto the game thread. Geometry is byte-identical — only WHERE it's built changed. Empty/all-air tiles
|
||
carry no streams (`bEmpty`) → no component. NOTE: RMC collision is already async-cooked
|
||
(`bUseAsyncCook=true`), so level-0 collision (T1.c) is NOT a game-thread spike. Remaining per-apply
|
||
game cost is `NewObject`+`RegisterComponent` for new tiles → component pooling (T2.c) is the next lever
|
||
IF a trace still shows `ApplyMeshToChunk` cost.
|
||
- **Insights scopes** `VoxelForge_GenerateMesh` / `VoxelForge_BuildStreams` (worker) /
|
||
`VoxelForge_ApplyMeshToChunk` (game-thread apply, Perf 0) bracket the worker gen + stream build +
|
||
game-thread upload — capture a trace to see if we're density-, build-, or upload-bound.
|
||
- **Float SIMD noise core (T2.a)** (`Public/VoxelNoise.h`): the density hot path uses
|
||
`VoxelNoise::Perlin3D` (single-sample, float, table-free hash-gradient) and `VoxelNoise::FBM` /
|
||
`Ridged` (octaves evaluated **4-wide via SSE** `Perlin3D_x4`) — NOT `FMath::PerlinNoise3D`
|
||
(double-precision, the old ~6.6 ms/chunk noise cost). `FractalNoise3D` / `RidgedNoise3D` in
|
||
`VoxelGenerator.cpp` are now thin wrappers over it; every call site is unchanged. It's a
|
||
DIFFERENT noise field than FMath's ⇒ a ONE-TIME world re-tune (fBm/Ridged contracts/[-1,1] are
|
||
identical). Pure function of (x,y,z) ⇒ every box-validity cache stays valid. Scalar `Perlin3D`
|
||
and SSE `Perlin3D_x4` are op-for-op identical (bit-identical on x86) — the SIMD path is a free
|
||
speedup; `#define VF_NOISE_USE_SIMD 0` falls back to scalar with no re-tune if a toolchain
|
||
rejects the SSE4.1 intrinsics. StrateManager's passage/transition Perlin calls were left on
|
||
`FMath` (layout-time, not per-voxel). Don't reintroduce `FMath::PerlinNoise3D` on the density path.
|
||
- **LOD-aware octave drop (T2.b, opt-in)** (`VoxelGenLOD` in `VoxelGenerator.h`, guard in
|
||
`GenerateMesh`): coarse tiles (Step>1) drop `Settings->LODOctaveDrop × log2(Step)` octaves from
|
||
the generator's PER-VOXEL volumetric noise via a `thread_local` bias — sub-cell octaves can't
|
||
shape a coarse isosurface. Default **0 = off = byte-identical**; LOD0 is never biased. The bias
|
||
is `TGuardValue`-scoped to the tile, so deco snapping / density-volume fill / game-thread
|
||
queries always see 0. Deliberately NOT applied to XY-field noise (heightfield, ceiling, relief,
|
||
moisture): those feed box-validated caches that outlive a tile task on the same thread, and
|
||
climate/biome must stay LOD-independent. Keep any new per-voxel fractal call site on
|
||
`VoxelGenLOD::Eff(N)` and any new cached-field call site OFF it.
|
||
- **Tile component pool (T2.c)** (`TileComponentPool` + `Acquire/ReleaseTileComponent`,
|
||
`VoxelWorld`): unloading parks the tile's RMC component (geometry+collision stripped via
|
||
`RemoveSectionGroup`, hidden, still registered) instead of `DestroyComponent`; applies pop from
|
||
the pool instead of `NewObject`+`RegisterComponent`. Also: `ApplyMeshToTile` now reuses the
|
||
component's existing `URealtimeMesh` (`GetRealtimeMeshAs`) — `InitializeRealtimeMesh` allocates
|
||
a NEW mesh object every call, so calling it per apply (the old code) orphaned one UObject per
|
||
re-mesh to the GC. A parked component MUST have its section group removed (hidden ≠ collision
|
||
off) — don't "optimize" that away. Pool is bounded (`MaxPooledTileComponents`); overflow is
|
||
destroyed for real.
|
||
- **`ProcessQueue` MUST be `EQueueMode::Mpsc`** (`VoxelWorld.h`): up to `MaxConcurrentTasks`
|
||
`ChunkGen` worker threads `Enqueue` concurrently; the game thread is the sole consumer.
|
||
The default `Spsc` is single-producer — concurrent enqueues race the tail link and silently
|
||
DROP results, leaking `PendingChunkCoord` slots until the budget is exhausted and streaming
|
||
stalls for good (intermittent; worst during the completion bursts right after the player moves).
|
||
|
||
### 8.11 Live tuning & debug (`AVoxelWorld`, CallInEditor / PIE)
|
||
- `RebuildStrates` — re-reads ALL of `VoxelSettings` and rebuilds layout/gap/passages/spine +
|
||
regenerates. Use after changing those (plain `RegenerateAllChunks` keeps the old layout/passages).
|
||
- `ValidateDeterminism` (F2) — one-click §8.4 regression test: samples chunk-boundary points under
|
||
two different thread_local cache alignments (left-chunk warm vs right-chunk warm) + a repeat
|
||
pass; every delta must be EXACTLY 0. Run it after any hot-path refactor that claims
|
||
bit-identity (~1 s, game thread, PIE).
|
||
- `bDebugDrawPassages` — draws every passage (cyan path, green=upper / red=lower endpoints).
|
||
- `EditorCarveSphere`/`EditorFillSphere` + `EditorBrush*` props — manual carve/fill in PIE.
|
||
|
||
### 8.12 Authoring a strate (data asset)
|
||
1. Create `UVoxelStrateDefinition`, pick `GeneratorType` → its param group appears; tune it.
|
||
2. `PassageConfig` → how THIS strate connects DOWN (count / style / tapered width / length / placement).
|
||
3. `Disturbances` for chasms/bridges/ridges; `bHasWater`+`WaterMaterial`(+`WaterLevelRelative`) for water.
|
||
4. Atmosphere: `FogColor/Density`, `AmbientLight*`, `bVolumetricFog`, or a full `AtmosphereActor` BP;
|
||
`CeilingLayerActor`/`FloorLayerActor` (+offsets/rotations) for cloud seas.
|
||
5. `Decorations`/`AmbientActors` (placement rules) for content + lights.
|
||
6. (Optional) `Biomes[]` + `BiomeMapParams` to vary terrain/content within the strate (§8.14).
|
||
Author `UVoxelBiomeDefinition` assets (climate box + modulation + content), then tune layout
|
||
with `AVoxelWorld::BakeBiomePreview`. Turn `ReliefStrength` down when biomes drive elevation.
|
||
7. Reference from `VoxelSettings` (`StratePool`/`FixedStrates`). Global knobs there:
|
||
`OriginSpineRadius`, `bOpenSurfaceEntry`, `InterStrateGapChunks`, view distances, LOD, carving budget.
|
||
|
||
### 8.13 New files this redesign
|
||
`Public/Private/VoxelContentManager.h/.cpp` (§8.5) · `Public/Private/VoxelAtmosphereManager.h/.cpp` (§8.6) ·
|
||
`Public/VoxelBiomeTypes.h` + `Public/VoxelBiomeDefinition.h`/`Private/VoxelBiomeDefinition.cpp` (§8.14).
|
||
Everything else extended existing files: `VoxelStrateTypes.h` (archetype params, disturbance,
|
||
`FStratePassageConfig`, enums), `VoxelStrateDefinition.h`, `VoxelGenerator.h/.cpp` (archetype
|
||
density fns + spine/disturbance/param-cache), `VoxelStrateManager.h/.cpp` (per-archetype getters,
|
||
passages, gap, atmosphere Z helper), `VoxelWorld.h/.cpp` (managers, streaming perf, brush API,
|
||
editor buttons), `VoxelDiffLayer.h/.cpp` (brush shapes), `VoxelSettings.h`, `VoxelCaveMorphology.cpp`
|
||
(two-region determinism). Status: compiles & runs in-editor.
|
||
|
||
### 8.14 Biome system (Stage 1 — climate-driven, full-param overrides)
|
||
Biomes vary terrain **and** content WITHIN a strate. A biome is a **"mini-strate-variant"**: it
|
||
can carry a FULL archetype param override (its own `FSurfaceGenerationParams`, …) plus a content
|
||
profile, placed by a deterministic, window-invariant world-XY field. Empty `Biomes[]` ⇒ bit-identical
|
||
to the pre-biome world. (Replaces the earlier `FBiomeModulation` scalar bag — full params let a biome
|
||
change *anything*, e.g. frequencies, which scalar multipliers couldn't.)
|
||
|
||
- **Assets/data.** `UVoxelBiomeDefinition` (one per biome): `DebugColor`, climate box (relief,
|
||
moisture), `bOverrideTerrain` + `GeneratorType` + the matching archetype param struct (Surface
|
||
wired), content profile (decorations/atmosphere/water). + `UVoxelStrateDefinition::Biomes[]` &
|
||
`BiomeMapParams`. Types in `VoxelBiomeTypes.h` (§3.8).
|
||
- **The field (pure XY, window-invariant — §8.4).** `SampleBiomeAt` (VoxelGenerator.cpp): warped
|
||
**Voronoi** over a jittered grid → dominant cell + nearest neighbour (F1/F2) + border blend weight.
|
||
Each cell's biome is chosen by `ClassifyBiomeAtSite` from the site's **climate** = `SampleRelief`
|
||
(the relief map M, shared with SurfaceWorld terrain) + `SampleMoisture`, matched against each
|
||
biome's (relief, moisture) box → coherent geography. **Climate must vary much slower than
|
||
`CellSize`** (~4-6 cells/feature) or it's salt-and-pepper.
|
||
- **Per-chunk resolution (perf — §8.10).** `ResolveBiomeSampleAt`/`RebuildBiomeGrid` build a
|
||
`FChunkBiomeCache`: the expensive cell classification is done ONCE into a small grid; per voxel only
|
||
a warp + 3x3 lookup, returning `FBiomeSample` (dominant + neighbour + weight). **Cache validity is a
|
||
world-XY BOX + ChunkZ + Seed (NOT a chunk key)** — gradient-normal + boundary samples stay inside
|
||
the box and don't thrash the noise-heavy rebuild (same as the SDF cache). Bit-identical to
|
||
`SampleBiomeAt`, so the baked preview matches the terrain. `GetBiomeContextForChunk` supplies the
|
||
flattened POD context per chunk (thread-local `CP_BiomeCtx`).
|
||
- **Consumption — SURFACE (output-blend).** Per chunk, `CP_SurfaceBiomeParams[]` holds each biome's
|
||
resolved surface params (its override when `bOverrideTerrain` + GeneratorType matches, else the
|
||
strate's) with **structural fields forced from the strate** (Z bounds, seal, base density, water
|
||
level). Per voxel: `ResolveBiomeSampleAt` → dominant `PD` (+ neighbour `PN`); `GetSurfaceDensity`
|
||
computes `ComputeSurfaceTerrainZ` for `PD` and, in the border band, for `PN`, and **lerps the
|
||
resulting HEIGHTS**. Blending heights (not params) is seamless across *any* difference (frequencies
|
||
included) — what per-param blend never could. `PD==PN`, weight 0 ⇒ bit-identical, no biomes.
|
||
- **Consumption — CAVES: structural overrides are NOT applied (determinism).** Rooms/tunnels are
|
||
decided over a wide COLLECT region spanning chunks (§8.4); making room params vary by region would
|
||
need the biome sampled per *room site* inside `BuildChunkCache`, or it breaks window-invariance
|
||
(a room near a border resolves differently per querying chunk → seams/holes). So SDF archetypes
|
||
(Tunnel/Maze/Shaft/Islands) keep strate-level structure; biomes affect them via **content +
|
||
atmosphere only** (below). Per-room-site biome params = a future deep task.
|
||
- **Consumption (content/atmosphere).** ContentManager DECORATIONS resolve the biome **per column** on the
|
||
worker (`ResolveBiomeSampleAt`, box-cached) → organic borders (§8.5); a column rolls only its biome's
|
||
decorations (else the strate's). `GetDominantBiomeAt(x,y,chunkZ)` (game-thread, uncached) → biome ASSET is
|
||
still used for the cheaper single-point picks: ContentManager water material + AtmosphereManager player
|
||
dominant biome fog/sky (`bOverrideAtmosphere`). Works for ANY archetype.
|
||
Water LEVEL stays strate-global (continuous plane); biomes retint material only.
|
||
- **Preview tool.** `AVoxelWorld::BakeBiomePreview()` (CallInEditor) bakes biome / relief / moisture
|
||
to `Saved/BiomePreview.png` via a transient generator (no PIE). Needs the `ImageWrapper` module.
|
||
- **Status:** A (field+asset+preview), B (terrain), C (content/atmosphere) verified in-editor.
|
||
Full-param redesign (surface output-blend) ✅ BUILT & WORKING (ticked 2026-07-27). 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++ ✅ BUILT & WORKING (ticked 2026-07-27). The master material graph is still
|
||
editor-side work and is deliberately NOT ticked — that half is Jahni's, not the code's.
|
||
|
||
## 9. Multiplayer model (listen-server first, dedicated-friendly)
|
||
|
||
> **Status: DESIGN ONLY — nothing is networked in-tree yet** (no `Replicated`/`HasAuthority`/RPCs; a
|
||
> single `GetPlayerPosition()` center; a local diff layer). This section locks in the invariants so the
|
||
> streaming / AI / carve systems are built network-aware from the start instead of retrofitted. Target
|
||
> **now = listen server** (the host is a player AND the authority); **dedicated server = future / out of
|
||
> scope**, but the abstractions below (anchor *policy* + *role*) already cover it so it's additive later.
|
||
|
||
### 9.1 The core invariant — determinism means you NEVER replicate geometry
|
||
The world is a pure function of **(seed, strate layout)** (§8.4). So terrain is reconstructed identically
|
||
on every peer from a tiny amount of shared state — it is **never streamed as geometry over the wire**:
|
||
- Replicate the **effective seed + strate layout** ONCE (at join). Every client's `UVoxelGenerator` +
|
||
`UVoxelStrateManager` then generate byte-identical terrain locally. (Today the seed lives on the data
|
||
asset / `UVoxelSettings::Seed`; MP must propagate the *host's* effective seed to joiners so their
|
||
generators match — a mismatch = divergent worlds. The strate layout is deterministic from seed, so it
|
||
syncs implicitly once the seed does.)
|
||
- The **diff layer is the ONLY non-deterministic terrain state** (§3.9, [[voxelforge-difflayer-threading]])
|
||
→ it is the only thing that must sync. Since carving is a minor feature, this traffic is small.
|
||
|
||
### 9.2 Authority — server-authoritative diff, deterministic local re-mesh
|
||
- A carve/fill is a **request**: client → `Server_RequestModification(FVoxelModification)` → the authority
|
||
(the host) validates it (`DiffLayer` budget / anti-cheat, §3.9 `CanModify`) → applies to the
|
||
**authoritative diff layer** → **multicasts the small `FVoxelModification`** (center/radius/strength ~a
|
||
few floats) → every peer applies it to its LOCAL diff layer and re-meshes locally via the existing
|
||
`ApplyModification` path (§3.5). Geometry never crosses the wire; only the edit event does.
|
||
- On the **listen server** the host is also a player, so a host carve applies directly (still through
|
||
validation) then multicasts. Remote clients only ever send requests.
|
||
- **Season / seed change** (`ChangeSeed`, `RegenerateAllChunks`) bumps a **local** `GenerationEpoch` today
|
||
— in MP this must become a **server-driven multicast event** (everyone bumps epoch + regenerates from the
|
||
new seed). Epoch stays a per-peer local counter; the *trigger* is networked, the counter is not.
|
||
|
||
### 9.3 Multi-anchor streaming — the backbone, not just an AI feature
|
||
**IMPLEMENTED 2026-07-07 (the streaming/collision half; the collision-only render-skip §9.4 is still
|
||
pending).** `AVoxelWorld::RegisterStreamingAnchor(Actor, Policy, RadiusChunks)` / `UnregisterStreamingAnchor`
|
||
(BlueprintCallable) add an actor to `StreamingAnchors`. `UpdateChunksAroundPosition` prunes dead anchors +
|
||
detects chunk crossings (rebuilds the desired set when any anchor crosses a level-0 boundary — same cadence
|
||
as player movement, coalesced into one rebuild). `AddAnchorDesiredTiles` (inside `BuildDesiredTiles`, after
|
||
the player clipmap + sheet ring) folds each anchor's Chebyshev box of **level-0** tiles into the SAME
|
||
`DesiredStamped`/`DesiredSorted` set (deduped vs the clipmap by stamp) → the existing delta cull releases an
|
||
anchor's tiles automatically when it moves away / unregisters. Zero cost when no anchors (empty loop). The box
|
||
defaults to a THIN shape (its chunk + 1 horizontal ring + 1 chunk below for ground safety, nothing above —
|
||
`XYRadiusChunks`/`ZBelowChunks`/`ZAboveChunks`, per-register). `CollisionOnly` anchor tiles are hidden (§9.4).
|
||
Anchor tiles sort by distance-to-*player*, so one far from every player streams last (fine for now; a
|
||
per-anchor priority is a later tweak).
|
||
|
||
The general model MP requires — **N centers**: the authority streams around **every connected player + every
|
||
AI**, because that's how a remote pawn gets server-side collision / movement authority. The registry of
|
||
**anchors**:
|
||
- **Anchor = { actor, policy }**, `policy ∈ { CollisionOnly, FullVisual }`, plus a **role** on the world
|
||
(client / listen-host / [future] dedicated).
|
||
- **Listen-host role:** `FullVisual` anchor on its OWN camera (it renders for itself) + **`CollisionOnly`**
|
||
anchors around every REMOTE player + AI (it needs their collision for authority, not their pixels).
|
||
- **Remote-client role:** `FullVisual` anchor on its own camera + collision around its own pawn (local
|
||
prediction). It does not stream other players' far tiles.
|
||
- **[Future] dedicated role:** ALL anchors `CollisionOnly` — no visual mesh anywhere server-side. The
|
||
listen-host's `CollisionOnly` path IS this path, so dedicated is just "no local FullVisual anchor."
|
||
- Keep today's single-player fast path exactly when the registry has one FullVisual anchor and no others.
|
||
|
||
### 9.4 Collision-only tiles — render-skip (IMPLEMENTED 2026-07-07)
|
||
A level-0 tile that ONLY a `CollisionOnly` anchor wants (the player clipmap did not stamp that exact key this
|
||
crossing) goes into `CollisionOnlyTiles`; `ApplyMeshToTile` cooks its collision but `SetVisibility(false)` —
|
||
**no draw, no VSM, no shadow** — killing the cost of terrain around AI / remote players far from the local
|
||
camera. If the clipmap (or a `FullVisual` anchor) also wants the tile, it renders normally. Visibility flips
|
||
on ALREADY-LOADED tiles (player walks toward/away from a cluster) are handled by `ReconcileAnchorTileVisibility`
|
||
diffing `CollisionOnlyTiles` vs its previous set each crossing (bounded by the small anchor set, no O(loaded)
|
||
scan). Collision is independent of visibility in UE, so a hidden tile still collides.
|
||
- **Still on the frame:** the geometry streams are built on the worker (`BuildTileStreamSet`) even for hidden
|
||
tiles — off the frame, but it's CPU+memory. A deeper "cook collision without building render streams" path
|
||
(true `CollisionOnly`, and the future dedicated-server terrain) is a later optimization.
|
||
- `SetCanEverAffectNavigation(false)` stays — nav is function-based (§9.6), not Recast, so collision tiles
|
||
never feed a navmesh.
|
||
|
||
### 9.5 Late join
|
||
A joiner receives the seed/layout (regenerates everything locally) + a **compacted diff snapshot** replayed
|
||
into its diff layer. Nothing else needs transfer — the rest of the world is a function. The diff layer is
|
||
already chunk-keyed and lock-guarded ([[voxelforge-difflayer-threading]]); a serialize/replay path is the
|
||
main new piece.
|
||
|
||
### 9.6 AI is authority-side + function-based nav (see the AI-nav plan)
|
||
AI runs on the authority (host now, dedicated later). Nav is **function-based** — a coarse A* + funnel +
|
||
spline route over `GetVoxelSurfaceHeightAt`/`GetDensityAt` (+ the diff layer so AI sees carves), followed by
|
||
a steering component for smooth (non-robotic), cheap locomotion. Crucially it queries the world FUNCTION, so
|
||
it needs **zero loaded geometry** — ideal for the authority side and mandatory for a future headless
|
||
dedicated server (which has no meshes). Recast is rejected: it would need cooked collision everywhere AI
|
||
roams, server-side, re-cooking on every dig. Build order: multi-anchor collision streaming (§9.3) FIRST
|
||
(now MP-foundational), then the function nav.
|
||
|
||
### 9.7 What's NOT built (greenfield checklist)
|
||
Seed/layout replication at join · `Server_RequestModification` RPC + multicast of applied mods · ~~anchor
|
||
registry~~ (DONE 2026-07-07, §9.3) + ~~`CollisionOnly` render-skip~~ (DONE 2026-07-07, §9.4 — hide-based; the
|
||
deeper no-stream-build path still open) + role awareness · networked season/seed (epoch multicast) ·
|
||
diff-layer serialize/replay snapshot for late join · server-side AI + function nav. All additive on top of
|
||
today's deterministic single-player core.
|