chore: track the design docs in git (.gitignore !*.md)

AUDIT P1: every markdown design doc except CODEMAP.md was untracked, so
ARCHITECTURE / AUDIT / OPSTACK-PLAN / fable-idea / REVIEW_FINDINGS lived
only on disk. Replaces the single !CODEMAP.md exception with !*.md.

Also makes OPSTACK-PROGRESS.md commits actually record something, which
the unattended crash-safety discipline depends on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 01:39:21 +02:00
parent 69fa73e07e
commit 3128852d4e
9 changed files with 2589 additions and 1 deletions
+3 -1
View File
@@ -10,4 +10,6 @@
!/Source/**
!VoxelForge.uplugin
!CODEMAP.md
# Keep every design / doc markdown at any depth (AUDIT P1 — these were untracked)
!*.md
+754
View File
@@ -0,0 +1,754 @@
# 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.
### 8.2 (0,0) spine & hybrid connections
- `ApplyOriginSpine` (VoxelGenerator.cpp, static helper) carves a guaranteed open vertical
column at XY (0,0) in every strate's **interior** (seals untouched). Radius =
`UVoxelGenerator::OriginSpineRadius``VoxelSettings::OriginSpineRadius`. Called before
every `ApplyBoundarySeal`.
- Descent is **player-dug** through the thin seals at (0,0). The single auto-opened
connection is the **surface entry shaft** at (0,0) through the top of strate 0
(`GeneratePassages`, `bOpenSurfaceEntry`).
- **Hybrid extras:** auto-carved *shortcut* passages per boundary, placed away from (0,0).
Now fully **per-strate** — see §8.8 (the upper strate's `PassageConfig` drives count/style/shape).
### 8.3 Disturbance layer (the "wow" post-process)
`FStrateDisturbanceParams` (on the definition, all archetypes). `ApplyDisturbances`
(VoxelGenerator.cpp static, **MC convention**) runs in `GetDensityAt` after dispatch:
chasms (carve air), bridges (solid spans), ridges (solid blades). Stays inside seal bands.
Provided per chunk by `StrateManager::GetDisturbanceParamsForChunk`.
### 8.4 Cross-chunk determinism (the seam-prevention invariant)
`BuildChunkCache` (VoxelCaveMorphology.cpp) uses **two regions**: a wide *COLLECT* region
(`2*MaxTunnelLength + MaxInfluence`) over which connectivity is decided (NN filtered to
`<= MaxTunnelLength`, origin cap = deterministic top-N by hash), and a tight *STORE* region
(`+MaxInfluence`) kept for per-voxel eval. This makes the room/tunnel graph window-invariant.
**If you add a connectivity rule with longer edges, the COLLECT region must still cover the
max edge reach, and decisions must not depend on the stored window.**
### 8.5 Content scatter & water — `VoxelContentManager.h/.cpp` (NEW)
`UVoxelContentManager` (owned by `AVoxelWorld`, game-thread). TWO INDEPENDENT subsystems:
**(A) DECORATIONS — distance-based WORLD GRID (the no-pop system, 2026-06-17).** Decorations are placed
on a fixed world XY cell grid (**1 cell = 1 chunk footprint**, `DECO_CELL_VOXELS = CHUNK_SIZE`) and streamed
by DISTANCE from the player, **fully decoupled from clipmap tiles / LOD**. **THE MARCH RUNS ASYNC ON WORKER
THREADS** (mirrors mesh gen — `GetDensityAt` is thread-safe; the synchronous-on-game-thread first cut was a
perf disaster + starved streaming → seams, so it was moved off-thread). Driven by `AVoxelWorld::Tick →
UpdateDecorations(playerWorldPos)`, three phases: **(1)** recompute the desired cell set (`RebuildDesiredCells`)
only when the player crosses a cell boundary OR changes strate — clears out-of-range loaded cells, queues
cells that are NOT loaded and NOT in flight (`PendingLaunch`, nearest-first). 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 816 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·(1Jitter)), 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. Global passage settings were removed from `VoxelSettings`.
### 8.9 Carving — brush shapes + editor controls
`FVoxelModification` has `EVoxelBrushShape {Sphere,Box,Capsule}` + `BoxExtent`/`CapsuleEnd`/
`Falloff` + `GetWorldBounds`. `UVoxelDiffLayer::GetDensityOffset` switches per shape; chunk
overlap uses the shape AABB. `AVoxelWorld`: `CarveBox/FillBox/CarveCapsule/FillCapsule/
ApplyModification` (BlueprintCallable) + `EditorCarveSphere/EditorFillSphere` (CallInEditor)
driven by `EditorBrush*` props.
### 8.10 Performance invariants (DON'T regress)
- **Streaming** (`UpdateChunksAroundPosition`): rebuild/cull the desired set ONLY when the
player crosses a chunk boundary (`LastUpdateCenter`); use the `DesiredSet` TSet for the cull;
idle via `bAllChunksLoaded`. Stationary player ≈ free. (Old per-frame O(loaded×desired) scan = 22ms.)
- **LOD** changes HOT-SWAP (`LoadChunk` only, never unload-first) → no holes. LOD
reconciliation lives in the PERSISTENT per-frame submit loop (same loop as new-chunk loads),
NOT as a one-shot on the boundary-cross frame — a one-shot drops every chunk past the task
budget and strands it at a stale LOD. Idle (`bAllChunksLoaded`) only when a full scan finds
no loads AND no LOD mismatches outstanding.
- **SDF cache** (`GetDensityWithParams`): search-BOX validity, not chunk-key — gradient ±1
sampling must not thrash the (expensive) rebuild.
- **Per-chunk param cache** in `GetDensityAt`: GenType + param struct + disturbance cached
thread-locally per chunk; don't move the fetch/blend back to per-voxel.
- **Biome cache** (`ResolveBiomeSampleAt`/`FChunkBiomeCache`, §8.14): validity is a world-XY BOX +
ChunkZ + Seed, NOT a chunk key — same reason as the SDF cache. The cell classification is
noise-heavy; a chunk-key would thrash it on gradient-normal / +X/+Y boundary samples. Keep
the box halo (≥ CHUNK_SIZE) + cell margin (warp + CellSize) so the 3x3 lookup never misses.
- **Passage cull** (§8.8) + **morphology two-region** (§8.4): both are per-voxel-cost critical.
- **Per-chunk passage shortlist** (`EvaluateModifierSDF`): runs per voxel and is called from every
archetype's `ApplyPassageCarving`. Keeps a `thread_local` shortlist (passage INDICES) of passages
whose bounds reach the current chunk, rebuilt only on chunk change or `PassagesVersion` bump
(incremented in `GeneratePassages`). Most chunks have NO passage near → instant `FLT_MAX` return
instead of walking the whole `Passages` array per voxel. Conservative superset (chunk bounding
sphere vs passage bound) ⇒ bit-identical carve. Store indices + version, never pointers (the array
is rebuilt on `RebuildStrates`).
- **Gen tasks run at `UE::Tasks::ETaskPriority::BackgroundNormal`** (`LoadTile`): worker gen yields
to foreground game/render tasks. Without it, raising `MaxConcurrentTasks` past the spare-core count
saturates the scheduler and starves the frame (the "concurrency > ~12 = stutter" symptom). Keep gen
at background priority so the frame keeps its cores.
- **Mesher density grid + margin ring** (`GenerateMesh`): sample each grid point ONCE into a flat
`(CHUNK_SIZE/Step + 1 + 2)³` array (the `+2` is a 1-point MARGIN ring, indices 1..GridDim, for
T1.b normals). The cell loop reads 8 corners from it; per-cell sampling would call `GetDensityAt`
~8× too often. Geometry is bit-identical (edge positions unchanged). Don't refactor back to
per-corner `GetDensity` and don't drop the margin ring (normals + seamless borders need it).
The cell loop is **two-pass**: pass 1 reads the 8 corner densities + builds the MC case index and
`continue`s on no-surface cells (≈70% of cells); pass 2 computes the 8 positions + grid-gradients
ONLY for surface cells. Don't hoist position/gradient back above the case-index test. The
`DensityGrid` and vertex-dedup `TMap` are `thread_local` and reused per worker (Reset / keep
capacity) — don't make them per-call locals (re-allocates ~170 KB + a hash map every tile).
**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) 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).
## 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.
+677
View File
@@ -0,0 +1,677 @@
# VoxelForge — Independent Audit, 2026-07-26
*Scope: every file under `Source/VoxelForge/` (~16,400 lines, 30 files) + all five design docs, read in
full. Axes requested: **correctness & threading**, **perf & algorithm choice**, **architecture &
maintainability**. Bar: **fitness for purpose for VoxelM** — not marketplace-grade generality.*
> ⚠️ This file is currently **untracked** — the strict-whitelist `.gitignore` only keeps `Source/`,
> `CODEMAP.md` and the `.uplugin`. See P1.
---
## 0. Verdict
**This is good work.** Well above the median for a solo UE plugin: the perf reasoning is real and
measured (Insights traces cited with numbers), the invariants are named and defended, the caches are
box-validated rather than key-validated for the right reasons, and almost every expensive path is
budgeted. The clipmap + trivial-tile-reject + worker-built streams architecture is the correct shape
for this game. Nothing in here reads as cargo-cult.
**And it is not in a state where you can answer "does it work?"** Three separate things have quietly
gone wrong at the process layer, and they compound:
1. **~3 weeks of work sits in one commit called `tmp`** (3,903 insertions / 1,029 deletions across 20
files). No bisect, no per-feature revert, no "when did seed X stop matching".
2. **The design record isn't in version control.** `ARCHITECTURE.md`, `fable-idea.md`,
`REVIEW_FINDINGS.md`, `CLAUDE.md` are all excluded by `.gitignore`. Only `CODEMAP.md` survives.
3. ~~A stack of "CODE-COMPLETE, PENDING BUILD"...~~ **CORRECTED 2026-07-26 (Jahni):** everything has
been built and verified working. The *documents* are stale, not the code — `fable-idea.md` and
`ARCHITECTURE.md` still carry "PENDING BUILD" markers dated 07-04/-06/-08 that were resolved and
never ticked. That's a doc-hygiene item, not a project-state risk. The uncommitted tree is
deliberate: the current 3D work is experimental (see §7).
For a codebase whose central promise is **determinism**, having no per-change history is the sharpest
internal contradiction in the project. That's the headline.
The code findings below are secondary to that, but there are real ones — including one latent bug
that will silently flatten your terrain the day you pass a large seed.
---
## 1. Correctness & threading
### C1 — Large seeds destroy the noise field ⚠️ **highest-severity latent bug**
`VoxelGenerator.cpp` uses `const float SeedF = (float)Seed;` and then adds `SeedF * k` (k up to
**97.7**) directly into Perlin coordinates — ~40 call sites, e.g.:
```cpp
// VoxelGenerator.cpp:1390 (Scallop)
CellularNoise3D(FVector(WorldX * SF + SeedF * 83.1f, ..., EffectiveZ * SF + SeedF * 97.7f));
```
Float has a 24-bit mantissa. At magnitude `V` the ULP is `V · 2⁻²³`:
| `Seed` | `SeedF · 97.7` | ULP there | Effect on a coordinate stepping by ~0.02/voxel |
|---|---|---|---|
| 1,000 | 9.8e4 | 0.012 | fine |
| 100,000 | 9.8e6 | 1.2 | noise lattice-snaps; detail gone |
| 10,000,000 | 9.8e8 | **117** | the coordinate term is *completely* absorbed → that noise field becomes **constant** |
`ChangeSeed(int32)` is `BlueprintCallable`. The moment game code calls it with anything like
`FMath::Rand()` (up to 2³¹), whole noise terms collapse to a constant and terrain goes flat or
degenerate. It works today only because your seeds are small.
`fable-idea.md` **flagged this exact issue** in T2.a ("seed offsets like `SeedF * 7.3f` can reach 1e8+
where float precision is ~64 units — hash the seed into a bounded offset range (e.g. [0, 16k])") and it
was never done.
**Fix (one line, one world re-tune):**
```cpp
const float SeedF = (float)(VoxelHash::Mix((uint32)Seed) & 0x3FFF); // bounded [0, 16383]
```
Applied consistently at all `SeedF` definitions (`:682`, `:1756`, `:2066`, `:2208`, `:2275`, `:2687`).
**Verify:** set `Seed = 1234567` in the data asset and look at the terrain. If it's flat, this is live.
---
### C2 — Per-chunk parameter caches have no layout key (stale after live-edit) ⚠️ **real, reproducible**
`VoxelGenerator.cpp:503-524`:
```cpp
thread_local FIntVector CP_Chunk(INT32_MAX, INT32_MAX, INT32_MAX);
thread_local ECaveGeneratorType CP_GenType;
thread_local FStrateGenerationParams CP_Tunnel; // + CP_Slab, CP_Maze, CP_Surface, CP_Vert,
// CP_Float, CP_Dist, CP_BiomeCtx, ...
if (ChunkCoord != CP_Chunk) { /* refetch everything */ }
```
The key is **`ChunkCoord` alone** — no seed, no layout version. After `RebuildStrates()` or an
`OnObjectModifiedInEditor` live edit, `StrateManager::Initialize` rebuilds the layout and bumps
`PassagesVersion`, but a pooled worker thread whose `CP_Chunk` still equals the chunk it's now asked to
regenerate **skips the refetch and generates with the old params**. Since `RegenerateAllChunks` reloads
the *same* tile coords, often on the *same* workers, this is likely rather than exotic.
Same pattern, same gap:
- `OC_Chunk` — the `GetSurfaceHeightAt` oracle cache (`:2420`)
- `BM_Chunk``GetBiomeMaterialAt` (`:3045`)
- `FChunkBiomeCache::Contains` — keyed on `(box, ChunkZ, Seed)` but **not** the biome context, so
editing a strate's `Biomes[]` leaves stale cells
Note the asymmetry: the SDF cache (`CachedSeed`) and the surface-column cache (`GSurfColCache`, keyed on
Seed) **are** seed-guarded, and the strate-index memo **is** version-guarded (`SI_Version`). So
`ChangeSeed` mostly survives; **live-edit is where this bites**. Symptom: "I tweaked the strate asset,
regenerated, and one patch kept the old shape."
**Fix:** the getter already exists and is already used elsewhere —
```cpp
const uint32 LV = StrateManager->GetLayoutVersion();
if (ChunkCoord != CP_Chunk || LV != CP_Version) { CP_Version = LV; /* refetch */ }
```
Three sites. Cheap, and it closes a whole class of "why didn't my edit apply".
---
### C3 — `FMath::PerlinNoise2D` survived the SIMD migration, on the per-voxel hot path
`VoxelCaveMorphology.cpp:799-800`, inside `EvaluateSDFCached`'s **per-room, per-voxel** loop:
```cpp
float N = FMath::PerlinNoise2D(FVector2D(Pos.X * RF + SF, Pos.Y * RF + SF * 1.7f)) * 0.65f
+ FMath::PerlinNoise2D(FVector2D(Pos.X * RF * 2.3f + SF * 3.1f, ...)) * 0.35f;
```
Two **double-precision, permutation-table** UE Perlin calls per voxel per in-range room. The entire T2.a
pass existed to remove exactly this from the density path, and `ARCHITECTURE §8.10` states outright:
*"Don't reintroduce `FMath::PerlinNoise3D` on the density path."* The 2D sibling slipped through the net.
Gated by `FloorReliefStrength > 0` (default 0), so it's **dormant today** — but the moment you author a
room floor with relief (the tooltip recommends 5-10 for "clear hills and basins — interesting to walk
across"), every TunnelNetwork voxel near a room pays up to ~6 double-precision Perlin evals. That is
plausibly larger than everything T2.a saved.
**Fix:** `VoxelNoise::Perlin3D(x, y, 0.0f)` (or add a `VoxelNoise::Perlin2D`). One-time visual re-tune of
the floor relief; nothing else moves.
---
### C4 — Streaming stops if the pawn is at exactly the origin, or before a pawn exists
`VoxelWorld.cpp:453`:
```cpp
FVector PlayerLastPos = GetPlayerPosition(); // returns ZeroVector when no PC/pawn
if ((PlayerLastPos != FVector::ZeroVector)) { UpdateChunksAroundPosition(...); ... }
```
Two problems with using a value as a validity sentinel:
- **(0,0,0) is a real, designed location** — it's the (0,0) descent spine, the landing column in every
strate. A pawn resting exactly there freezes chunk streaming, atmosphere, decorations and the density
volume for that frame.
- **No pawn ⇒ permanent silent stall.** Early `BeginPlay`, a cinematic without a possessed pawn, or a
future headless/dedicated path (§9 explicitly plans for this) gets a world that never generates and
logs nothing.
**Fix:** `bool bHasPlayer` out-param on `GetPlayerPosition`, or return `TOptional<FVector>`.
---
### C5 — Shutdown spin-waits time out and then destroy objects workers are still reading
`AVoxelWorld::EndPlay` (`:319-330`) waits 3 s on `ActiveTaskCount`, logs a warning, then **falls through
to teardown** while a worker may still be inside `GenerateTileResult` holding `Generator`/`Mesher`/
`StrateManager`. `UVoxelContentManager::NotifyShutdown` does the same (3 s, then `break`), and its task
lambda captures raw `this` (a UObject) — so after the timeout, `bShuttingDown.load()` and
`DecoResults.Enqueue()` touch freed memory.
`UVoxelDensityVolume` gets this right (`FillThread->Kill(true)` — unbounded, blocks until `Run()`
returns). The other two should either be unbounded too, or hold a shared-ownership handle the workers
keep alive.
Low probability. High confusion cost when it does fire — it presents as a random PIE-exit crash.
---
### C6 — The diff layer is an unbounded append-only list of brush strokes
`UVoxelDiffLayer` stores each `FVoxelModification` in **every chunk its AABB overlaps**, and
`EvaluateMods` iterates **all** of a chunk's mods per voxel:
```cpp
for (const FVoxelModification& Mod : Mods) { /* sphere/box/capsule falloff */ }
```
- `MaxModifications` defaults to **0 = unlimited**.
- No compaction, no rasterization to a sparse voxel grid, no spatial subdivision within a chunk.
- No serialization (`fable-idea` F4 save/load is unbuilt), so the entire player footprint dies with the
session.
Cost of re-meshing a chunk grows **linearly in the number of strokes ever made in it**, forever. For a
game whose stated core verb is digging, this is the most significant architectural gap in the plugin.
It's invisible today because nobody has dug 500 times in one chunk.
The threading is correct (`FRWLock` + the version-stamped snapshot cache is a genuinely nice design —
~27 lock ops per tile instead of ~86k). It's the *data structure* that won't scale, not the concurrency.
**When it matters:** the moment mining becomes a real loop. The natural answer is a per-chunk sparse
`uint8`/`int8` delta grid that strokes rasterize into (O(1) per voxel, bounded memory, trivially
serializable, and it's the same representation MP §9.5's "compacted diff snapshot" wants).
---
### C7 — The density volume runs by default for a feature that's switched off
`bEnableDensityVolume = true` (`VoxelSettings.h:268`). What that buys, per frame while streaming:
- a dedicated OS thread (`FVoxelDensityFillRunnable`),
- a `CHUNK_SIZE³` quantize + 32 KB queue payload per capture-eligible level-0 tile,
- a `TMap<FIntVector, TArray<uint8>>` capture cache,
- and the big one — `UploadDirtyTextures()` copies **the whole `Res³` array** and issues a full
`RHIUpdateTexture3D` per dirty level. `BlitCaptureToWindow` sets `bGPUDirty = true` on *every* tile
ingest, so level 0 is dirty essentially every frame a tile lands.
At the default `Res = 128` that's **2 MB CPU copy + 2 MB GPU upload per level per frame** during
streaming; at 192 it's ~7 MB. The code itself flags it: *"Sub-box upload (with toroidal wrap-splitting)
is a later optimisation."*
**CONFIRMED 2026-07-26 (Jahni): the lighting system is deprecated for now.** So this is not
speculative — the volume, the fill thread, the capture path and the per-frame texture upload are all
running to feed a consumer that no longer exists.
**Action:** set `bEnableDensityVolume = false`. Straight cost deletion, no behavioural change. If the
volume is revived later, the sub-box upload (with toroidal wrap-splitting) must land first — a full
`Res³` re-upload per frame was never meant to be the steady state.
Also worth deleting while it's cold rather than leaving it half-live: `UpdateTerrainMaterialParams`
still recomputes and pushes `TVP0..TVP9` onto every terrain MID each Tick, and `ApplyMeshToTile` still
routes every material through `GetOrCreateTerrainMID` when the volume is enabled. With `bEnable...` off,
both self-disable — but the code paths stay as a trap for whoever reads them next.
---
### C8 — Documented invariant that isn't checked: `OriginRoomRadius` vs the COLLECT margin
`BuildChunkCache` guarantees window-invariance by collecting rooms within
`CollectMargin = 2·MaxTunnelLength + MaxInfluence`. The origin room is special-cased: it's also added when
`RoomReachesSearchBox(...)` succeeds even if `(0,0)` is *outside* the collect box. In that case
`NearestNeighbor[OriginIdx]` and the `OriginDowngraded` ranking are computed over a **different candidate
set** than a chunk near `(0,0)` computes → different tunnel decisions → a discontinuity ring at roughly
`CollectMargin` from origin.
**Cannot fire at defaults** (`OriginRoomRadius = 20`, `MaxTunnelLength = 200` ⇒ margin ≈ 434). It becomes
reachable only with a very large origin room *and* short tunnels. Flagging it because the tooltip invites
"30+ → massive starting cavern" and there's nothing stopping someone typing 500.
**Fix:** an `ensureMsgf(OriginRoomRadius < CollectMargin)` in `BuildChunkCache`, plus a line in
`ARCHITECTURE §8.4`.
---
### Threading — what's *right*, for the record
Worth stating plainly, because it's the part that's easy to get wrong and this doesn't:
- `ProcessQueue` is correctly `Mpsc`, with the failure mode documented at the declaration.
- The `FTaskGuard` RAII decrement covers every early-exit path in `LoadTile`.
- Epoch carrying is consistent across all four async paths (tile gen, deco march, volume fill, band re-gen).
- `DiffLayer`'s `FRWLock` + lock-free `bHasAnyMods` fast-reject + per-(chunk,version) snapshot is a genuinely
good piece of engineering, and the comment records the AV it fixed.
- `BuildCellSpawns` is correctly `static` — no implicit `this` on the worker.
- `VoxelGenLOD::OctaveBias` is `TGuardValue`-scoped per tile, so deco snapping and volume fills can't
inherit a stale bias.
---
## 2. Performance & algorithm choice
### Is Marching Cubes still the right call in 2026? — **Yes, for this game.**
- **Transvoxel** ([transvoxel.org](https://transvoxel.org/)) remains the canonical answer to LOD seams,
and `fable-idea` already schedules it correctly ("whenever LOD cracks become the loudest remaining
flaw"). Your skirts are a legitimate substitute: they cost extra verts and some overdraw on boundary
contour edges, but they're ~80 lines instead of a transition-cell rewrite, and they work for *any*
level delta rather than only ±1.
- **Surface Nets / Dual Contouring** would cut triangle count meaningfully and are faster per chunk, but
naive Surface Nets can't hold a sharp 90° feature and DC brings QEF solving + self-intersection
handling. You'd trade a rewrite for a benefit you aren't currently bottlenecked on. Correct to skip.
- **GPU meshing** — `fable-idea` rejects it for readback latency, CPU-side collision, and cross-GPU float
determinism. All three are still true, and the last one is fatal for a game whose MP model is
"replicate the seed, regenerate identically on every peer". Right call, right reasons.
**The verdict is: your mesher isn't the problem.** T1.af and T2.ae genuinely took per-tile generation
into diminishing returns. The remaining cost lives elsewhere (C3, C7) and in the scaling cliffs below.
### Scaling cliffs nobody has hit yet
**`BuildChunkCache` is O(N²) in rooms, and N grows quadratically with the tunnel/spacing ratio.**
`PickNeighbor` is O(N) per room and the connection loop is O(N²), over the COLLECT box which spans
`2·MaxTunnelLength + MaxInfluence`. So room count `N ≈ ((2L + I)/S)²` and the graph build is
**O((L/S)⁴)**:
| `MaxTunnelLength` | `RoomSpacing` | ≈ rooms in COLLECT | pair iterations |
|---|---|---|---|
| 200 (default) | 80 (default) | ~40 | ~800 |
| 300 | 60 | ~140 | ~10,000 |
| 400 | 40 | ~500 | ~125,000 |
Per cache rebuild, per worker. Amortised over 32,768 voxels it's survivable at defaults; at row 3 it
isn't. Worth a comment on `MaxTunnelLength` and a soft clamp.
**`AnyPassageNearBox`** walks every passage per tile with no spatial index. Fine at 10-20 passages; grows
linearly with `TotalStrates × Connections`.
**`FindSlotIndexForChunkZ`** is a linear scan called extremely often — per lattice-Z in `ClassifyTile`,
per archetype param getter, on every `GetDensityAt` chunk change. The comment acknowledges it ("with
~10-20 strates this is fine"). It is. But it's the classic death-by-a-thousand-cuts profile entry.
**`GetDominantBiomeAt`** is fully uncached: it builds an `FBiomeContext` (heap-allocating a `TArray`) and
runs the warped Voronoi + up to 2 `ClassifyBiomeAtSite` (4 fBM evals). `AtmosphereManager::UpdateForPlayer`
calls it **every Tick**, unconditionally. Small, but it's a per-frame game-thread allocation for a value
that changes maybe once a minute.
### The external development you should know about: **RealtimeMeshComponent 6.0**
You're on **RMC 5.3.2**. [RMC 6.0.0 shipped June 2026](https://triaxis.games/realtime-mesh/) with:
- **Runtime Nanite** — "real cluster groups, full LOD DAG, per-page streaming. No cooked bake, no
`UStaticMesh` round-trip."
- **Lumen card generation** + **distance field support** (Pro tier, $49 one-time).
- UE 5.25.7 support (you're on 5.7).
This matters more to VoxelForge than to most plugins, because it potentially subsumes **three separate
hand-built systems**:
| Your system | What RMC 6.0 offers instead |
|---|---|
| Clipmap levels + `CoarseTileCells` + `LODOctaveDrop` + skirts | Nanite's own LOD DAG, no seams by construction |
| F18 sheet ring (`GenerateSheetMesh`, XY hole, band re-queue) | far-field falls out of Nanite streaming |
| Density volume + raymarched mini-sun shadows (currently gated off) | runtime Lumen cards + distance fields |
I am **not** saying "rewrite on Nanite" — runtime Nanite on procedurally-remeshed geometry is new, and
digging (constant re-meshing) is the adversarial case for cluster building. But this is the single
biggest change in the landscape since your architecture was designed, and it argues strongly for a
**timeboxed spike before building any more LOD machinery**. Measure: build time per tile, re-mesh cost
on carve, and whether skirts/sheets become unnecessary.
(For reference on the alternative path: [Voxel Plugin 2.0](https://docs.voxelplugin.com/) now targets
5.6/5.7 and its [VoxelCore](https://github.com/VoxelPlugin/VoxelCore) module is open-source — worth
knowing exists, not worth switching to. Your strate/archetype model is the whole game; theirs isn't.)
---
## 3. Architecture & maintainability
### The structural risk: ~30 hand-rolled `thread_local` caches
The density field is a *pure function by convention*, and that convention is enforced by roughly thirty
hand-written memo caches, each with a hand-written invalidation key:
`GSurfColCache` · `CP_Chunk`+9 param slots · `SDFCache`+`CachedSMinX/Y`+`CachedStrate`+`CachedSeed` ·
`SI_ChunkZ`/`SI_Version` · `DiffSlots[64]` · `CH_*` chasms · `BR_*` bridges · `RG_*` ridges · `SC_*` slab
columns · `MZ_*` maze edges · `VS_*` shafts · `FI_*` islands · `OC_*` oracle · `BM_*` biome material ·
`TC_BiomeCache` · `Slots[2]` · `SL_*` passage shortlist · the mesher's `VertexMap`/`DensityGrid`/
`SurfColMemo`/`VertexClasses`/`GroundTris`/`CapTris`/`SheetCols`/`GroundIdx`/`CapIdx`.
Every one is individually justified and individually documented. Collectively they mean **the
correctness of the entire world rests on ~30 key lists each enumerating every input**, audited by hand.
Two are provably incomplete (C2). This isn't a bug count — it's an *unbounded bug surface that grows
with every feature*, and the failure mode is the worst kind: a seam or hole that appears only under a
particular thread/edit interleaving.
The existing mitigation, `ValidateDeterminism`, is the right instinct but a narrow test: one axis (X
boundary), near the player, one alignment pair, **on the game thread**. It would not have caught C2
(worker-thread caches) or C8 (origin ring).
**What would actually help**, in rough order of value-per-hour:
1. A `VF_CACHE_KEY(...)` macro that bundles `(coord, seed, layoutVersion)` so a new cache can't forget
the standard fields.
2. A **debug-only "no-cache" mode** (`#if VF_VALIDATE_PURITY`) where every memo is bypassed, plus an
automation test that samples N random points in both modes and asserts bit-equality. That converts
*all* "bit-identical, I promise" claims — of which the docs make dozens — into a machine check.
3. Extend `ValidateDeterminism` to run its samples across **multiple worker threads** in shuffled order.
### No tests, no automation
Zero test files. UE ships an automation framework you're not using. Three tests would cover most of the
risk surface:
- **Density purity**: sample 10k points, shuffle the query order, re-sample, assert bit-equality.
- **`ClassifyTile` soundness**: for random tiles, if the verdict is `AllSolid`/`AllAir`, brute-force the
lattice and assert every sample agrees. A false verdict is a *hole* — this is the highest-consequence
function in the plugin and it's validated only by reasoning.
- **`DiffLayer` under contention**: N reader threads + a writer, assert no crash and monotonic version.
### Function size (the open item that stays open)
`REVIEW_FINDINGS.md` has listed these splits as open through three passes:
| Function | Lines | Note |
|---|---|---|
| `GetDensityWithParams` | ~1,080 | listed as "~600-1000 L" — it has grown since |
| `BuildChunkCache` | ~640 | |
| `UpdateChunksAroundPosition` | ~300 | + 5 nested lambdas, 3 of them capturing by reference |
| `BuildCellSpawns` | ~350 | contains a **175-line nested lambda** (`PlaceAtCrossing`) |
| `ClassifyTile` | ~200 | |
An item that survives three review passes isn't going to get done voluntarily. Either accept it
explicitly (write "we are keeping these monolithic, here's why" in `REVIEW_FINDINGS`) or bind it to the
next feature that touches each one. Leaving it as a permanent open checkbox just makes the checklist
lie.
### Smaller structural notes
- **`FStrateGenerationParams` is a 74-field god-struct**, and blend correctness depends on remembering
to add every new field to `VF_STRATE_PARAM_FIELDS`. The X-macro was the right fix; make drift a
*compile* error by putting `static_assert(sizeof(FStrateGenerationParams) == N, "add the new field to
VF_STRATE_PARAM_FIELDS")` right next to it.
- **Chunk-quantised param blending.** `GetGenerationParams` computes the Gradient/Interleaved blend
`Alpha` from `ChunkCoord`, so within a transition band the density field has a **step at every chunk
boundary**. MC samples corners from both sides, so it won't tear, but small ledges at chunk borders in
Gradient zones are expected behaviour, not a mystery, if you ever see them.
- **Dead knobs still shipping**: `ViewDistanceXY` (only `Up`/`Down` are read), `CeilingViewMultiplier` and
`CeilingBandChunks` (the wide-ceiling system was removed by the clipmap), `DensityVolumeMaxTasks`
(deprecated in its own tooltip). Each is a lever a designer will pull and get nothing.
- **`EVoxelPassageType` vs `EVoxelPassageStyle`** — two overlapping passage-shape enums, both live.
Already open in `REVIEW_FINDINGS`; still open.
- **Leftover tutorial scaffolding** in `VoxelWorld.h/.cpp`: `// CHUNK MANAGEMENT - YOU IMPLEMENT THESE`,
`// This one is tricky with Unreal's API, so I'll give you more help:`, and a 20-line `STEPS: 1. Try to
dequeue...` block inside the now-finished `ProcessPendingChunks`. Cosmetic, but it's the first thing a
reader hits.
- **`.uplugin` metadata**: `"CreatedBy": "You"`, `"Description": "...built from scratch to learn"`.
### The documentation, honestly
The prose is genuinely excellent — better than most commercial plugins. `ARCHITECTURE §8.10` in
particular is a real asset: it records *why* each optimisation exists and what regressing it costs.
Two problems:
1. **The numbers have drifted.** `CODEMAP.md` header says *"~8,300 lines of C++ across 25 files"*;
reality is **~16,400 across 30**. §3.5's line anchors are badly stale (`LoadChunk :445` → actually
~1336; `ApplyModification :691` → ~1816). Since `CLAUDE.md` already instructs "trust symbol names over
line numbers", I'd **delete the line column entirely** — it's a maintenance tax that buys nothing and
actively misleads.
2. **The comment density is optimised for one kind of reader.** Blocks like the F17 row in
`ARCHITECTURE §8.1` (a single table cell running ~40 lines, narrating v1 → v2 → v3 with rationale and
quoted feedback) are superb context for an AI collaborator picking the file up cold, and a genuine
liability for a human trying to find the code. You've made a real trade here; it's worth making it
knowingly. A `HISTORY.md` for the "we tried X, it failed because Y" narrative would let the source
comments shrink to *what the code does now*.
---
## 4. Process — what I'd fix first
### P1 — Track the design docs. Today. ⚠️
```
.gitignore: * → !/Source/ !VoxelForge.uplugin !CODEMAP.md
```
`git ls-files` confirms **`ARCHITECTURE.md`, `fable-idea.md`, `REVIEW_FINDINGS.md`, `CLAUDE.md` and this
audit are all untracked.** `fable-idea.md` even says so in its own header — *"the strict-whitelist
`.gitignore` will ignore this file. Add `!fable-idea.md` if you want it tracked"* — and it never was.
These documents *are* the project's memory. Losing them loses more than losing the code, because the code
can be re-read and the reasoning can't. Two lines:
```
!*.md
```
### P2 — Stop batching three weeks into `tmp`
| Commit | Date | Content |
|---|---|---|
| `69fa73e` **tmp** | 2026-07-26 | **+3,903 / 1,029 across 20 files** — F17, F18, F19 anchors, F20 ph.1+2, companions, landmark merge, strate cut, sheet ring |
| `cb61c8b` potential perf regression | 2026-07-04 | |
| `6875614` Fix Decoration Placement | 2026-06-26 | |
| `e6cd852` Another pass | 2026-06-23 | |
| `db558d9` j | 2026-06-16 | |
Seven commits in seven weeks, and the message quality (`j`, `tmp`, `Another pass`) means even the
existing history isn't navigable. You don't need discipline theatre — one commit per *feature*, with the
feature's name in the subject, would be enough to bisect "which change broke seed 42".
This matters disproportionately *here* because determinism is the product. When a world stops matching,
"which commit changed the field" is the only question, and right now it's unanswerable.
### P3 — Reconcile the "pending build" backlog before adding anything
`fable-idea.md` and `ARCHITECTURE.md` currently carry, unreconciled:
- F20 **phase 1** — "CODE-COMPLETE, PENDING BUILD (2026-07-08)"
- F20 **phase 2** — "CODE-COMPLETE, PENDING BUILD (2026-07-08)"
- §8.14 biome full-param redesign — "code-complete, pending build"
- §8.15 F6 vertex palette — "C++ code-complete, pending in-editor build + the master material graph"
- `REVIEW_FINDINGS` perf pass 2 + batch 3 — "CODE-COMPLETE, pending build"
That's three weeks of unverified code stacked on unverified code, on a system where the failure modes
(seams, holes, stale caches) are *visual* and only appear in-editor. **The honest statement of project
state is: unknown for everything after 2026-07-04.**
Suggested order before any new feature:
1. Build. Fix compile errors.
2. Run `ValidateDeterminism` at several locations (including far from origin).
3. `bEnableDensityVolume = false` → trace → compare (C7).
4. Set `Seed = 1234567` → look (C1).
5. Live-edit a strate asset, regenerate, look for patches that kept old params (C2).
6. Commit, per feature, with real messages.
---
## 5. Priority list
*Re-ordered 2026-07-26 around the live 3D work.*
| # | Item | Cost | Why |
|---|---|---|---|
| 1 | **§6.1** — `ClassifyTile` guard for 3D caves | ~30 lines | **blocks the current feature**; a false `AllSolid` is an invisible, collision-less cave |
| 2 | **P1** — track the `.md`s (`!*.md`) | 2 min | irreplaceable if lost; the docs *are* the design record |
| 3 | **C7**`bEnableDensityVolume = false` | 1 min | confirmed dead weight — lighting is deprecated |
| 4 | **§6.6** — branch the experimental 3D work | 1 min | restores a known-good world to diff against |
| 5 | **C1** — bound `SeedF` | 1 line + re-tune | silent terrain collapse on large seeds |
| 6 | **C2** — add `GetLayoutVersion()` to 3 cache keys | ~10 lines | closes "my edit didn't apply" |
| 7 | **§6.2** — keep 3D caves *placed*, not *fielded* | a design call | decides whether T1.d keeps its 44 % |
| 8 | **C4** — explicit no-player flag | ~5 lines | origin-standing / no-pawn stall |
| 9 | **C5** — unbounded joins on shutdown | ~20 lines | random PIE-exit crashes |
| 10 | Automation tests (purity, `ClassifyTile`, `DiffLayer`) | ~a day | `ClassifyTile` is now the highest-consequence function in the plugin |
| 11 | **P2** — per-feature commits | ongoing | determinism without history is a contradiction |
| 12 | **C3**`PerlinNoise2D` off the SDF hot path | ~5 lines | dormant, but violates a stated invariant |
| 13 | **RMC 6.0 spike** (runtime Nanite / Lumen cards) | timeboxed | may subsume clipmap LOD + sheets + density volume |
| 14 | **C6** — diff-layer delta grid | a design pass | the scaling wall for the game's core verb |
| 15 | Tick the stale "PENDING BUILD" markers in the docs | 20 min | they currently misreport project state |
| 16 | Drop line numbers from CODEMAP §3; fix the header count | 30 min | the map misstates its own scale (8.3k → 16.4k) |
| 17 | Delete dead settings + tutorial scaffolding | 30 min | knobs that do nothing mislead |
---
## 6. Going 3D — what the current architecture will fight you on
*Added 2026-07-26 after Jahni confirmed the live direction: 3D density features — overhangs, caves
inside mountains, volumetric generation inside `SurfaceWorld`. This is the section that matters most
right now; everything above is maintenance by comparison.*
### 6.1 `ClassifyTile` will delete your caves. This is the one to get right first. ⚠️
T1.d proves a tile `AllSolid`/`AllAir` and **skips `GenerateMesh` entirely**. For `SurfaceWorld` the
proof is (`VoxelGenerator.cpp`, `TestColumn`):
```cpp
if (Z >= TerrainZ && Z <= CeilSurf) { bCanSolid = false; } // air side
else { bCanAir = false; } // below terrain, or in the cap
```
So **any point below `TerrainZ` is treated as provably solid.** A tile entirely inside a mountain
returns `AllSolid`, emits no mesh, and never calls the density function. Carve a cave in there and:
- the cave's walls are **never meshed** — you get an unrendered void,
- there is **no collision** (level-0 collision is per-section, and there are no sections),
- and it is invisible until you enter it from a passage, at which point you fall through nothing.
The code comment states the rule exactly: *"A false `Mixed` only costs CPU; the code must NEVER emit a
false `AllSolid`/`AllAir` (that's a hole)."* This is the same failure that got T1.d **v1 reverted on
2026-06-26** — a global analytic ceiling bound that wasn't conservative.
The existing guards are the template — each kills one hypothesis, cheaply, per tile:
| Guard | Kills | Mechanism |
|---|---|---|
| `AnyPassageNearBox` | `AllSolid` | sphere-vs-AABB over the passage list |
| origin spine | `AllSolid` | circle-vs-box in XY |
| chasm disturbances | `AllSolid` | `ChasmDensity > 0` on the strate |
| bridges / ridges | `AllAir` | density > 0 on the strate |
| `HasAnyModInChunkRange` | both | one key walk of the diff map |
| F20 overhang | both, in a band | `(TerrainZ, TerrainZ + OverhangMargin]``Mixed`, upward only |
**Every new 3D feature needs one of these before it ships.** For hash-placed cave systems that means a
per-tile shortlist exactly like `EvaluateModifierSDF`'s passage shortlist: collect the feature bounds
that reach this tile once, and if the list is non-empty, `bCanSolid = false`. `fable-idea`'s own F20
phase-3 GOTCHA already says this for spikes/holes — it applies verbatim to caves.
> **Test that catches it:** generate a mountain with caves, then stand outside and fly the camera through
> the mountain volume. Any cave that renders from inside a passage but shows nothing when approached
> through rock is a false `AllSolid`.
### 6.2 The T1.d economics invert as caves get more volumetric
The 2026-07-05 trace justified T1.d with: **84 % of `GenerateMesh` calls produced empty tiles**
(83,925 gens vs 13,227 meshes, ~500 s of 617 s worker CPU wasted), and shipping it cut worker CPU 44 %.
The bulk of those `AllSolid` tiles **is deep rock below `TerrainZ`** — precisely the volume "caves inside
mountains" occupies. Every tile a cave touches must now generate. So there's a direct, quantifiable
tension:
- **Bounded caves** (hash-placed systems with bounding volumes, like rooms/tunnels already are) wake only
the tiles they actually intersect. The shortlist proves the rest of the mountain still solid. T1.d keeps
most of its win.
- **A global 3D noise field** (`if (noise3D(x,y,z) > t) carve`) is unprovable *everywhere* below terrain →
every deep tile becomes `Mixed` → you hand back a large slice of that 44 %, plus you now mesh and cook
collision for tiles that are 99 % solid rock.
**Recommendation: make 3D caves placed, not fielded.** Same discipline as `FCachedRoom`/`FCachedTunnel`
they exist at hash-determined locations with known bounds. That keeps the classifier effective, makes the
shortlist guard trivial to write, and gives you authored control over cave density per biome. It's also
the only version that stays cheap at the render-distance ring.
### 6.3 Do not put Z-dependent data in `FSurfaceColumn`
`FSurfaceColumnBox` is keyed `(XY box, StrateKey, Seed)` with **no `ChunkZ`**, held as a 6-box LRU, and
deliberately **shared down the entire vertical chunk stack** — that's the whole T1.a win (the heightfield
was being recomputed once per altitude). `ARCHITECTURE §8.10` states it: *"ZERO Z dependence... Don't
re-introduce a ChunkZ key."*
So the rule for every new 3D feature is:
- **XY-pure data** (a per-column gate, amplitude, direction, a cave-system ID) → cache on `FSurfaceColumn`.
- **Z-dependent evaluation** → do it per-voxel in `SurfaceDensityFromColumn`, reading the cached column.
Your F20 phase-2 overhang is the correct template and worth copying deliberately: `OverhangAmp` (strength ×
slope-gate) and the unit uphill `(DirX, DirY)` are resolved **once per column** in `ComputeSurfaceColumn`;
the actual warped-terrain union is per-voxel. Anything that violates this silently corrupts every chunk in
the vertical stack, and `ValidateDeterminism` — which samples along an **X** boundary — would not catch it.
### 6.4 Window invariance: pick the cheap pattern where you can
Two existing precedents, and the difference is worth being deliberate about:
- **`Maze`** — edge identity is a pure hash of `(lower node, axis)`. Two adjacent chunks *cannot* disagree.
No cache, no COLLECT region, no seam risk, zero invariant maintenance.
- **`BuildChunkCache`** — because tunnel existence depends on a *connectivity decision over a neighbourhood*,
it needs the two-region COLLECT/STORE split (§8.4), and that invariant is delicate enough to have its own
documentation section.
If your cave systems need connectivity (a cave network that must be traversable), you inherit the second
pattern and its COLLECT-margin discipline. If each cave is independently placed, use the first — a pure
`hash(cell, seed)` placement with per-voxel SDF evaluation and no cross-chunk decision at all.
**Pick the Maze pattern unless connectivity is a gameplay requirement.** The complexity delta is large and
it's all seam-risk.
### 6.5 Smaller things that will bite
- **Route new per-voxel volumetric noise through `VoxelGenLOD::Eff(N)`.** That's the T2.b contract: per-voxel
3D noise gets the LOD octave drop, XY-field noise deliberately does not. New 3D code that calls
`FractalNoise3D(..., 3)` directly silently opts out of far-tile savings.
- **LOD-gate the expensive 3D ops.** Your own note on F20 phase 2 flags it: *"KNOWN v1 LIMIT: applies at ALL
LODs (may alias far) — gate to fine tiles later."* With one op that's a wart; with several it's a far-field
cost multiplier. A `Step > N` early-out per volumetric op is a few lines and compounds.
- **F17 already handles cave roofs correctly** — the surface class is semantic, not geometric: down-facing but
*below* `TerrainZ` ⇒ ground/rock, not sky-cap. This was designed for exactly your current case and needs
nothing. Good call at the time.
- **The F18 sheet ring won't show caves** — sheets are two heightfields, so carved features don't exist there.
Already an accepted trade-off; just don't expect a cave mouth to be visible at render-distance.
- **`SurfaceDensityFromColumn` is now the hot path**, not the SDF morphology. The overhang block already
re-runs `SampleSurfaceStructuralZ` per lip voxel. Each new volumetric op that re-samples structural height
per voxel stacks on that. Budget it consciously — the per-column cache saved you ~33×, and per-voxel
structural re-evals spend it back.
### 6.6 Since it's experimental and uncommitted
You said the tree is uncommitted *because* the 3D work is experimental. That's a reasonable instinct pointed
at the wrong tool — experimental work is exactly what branches are for:
```
git switch -c 3d-density # experiment freely, commit noisily
git switch main # a known-good world is always one command away
```
The specific thing you lose right now: when a seed stops matching or a hole appears mid-experiment, there
is no known-good state to diff against, and the 3D features are the *highest*-risk changes in the project
for exactly that failure mode (§7.1). A branch costs nothing and gives you the bisect back.
---
## 7. What I'd tell someone joining this project
The generation core is strong and the perf work is honest and measured — the person who wrote this knows
what they're doing and has the traces to prove it. The weakness is **everything around verification**:
no history you can bisect, no tests, design docs outside version control, and a habit of stacking
features on top of unbuilt features. Nothing in the code list above is hard to fix; the process items
are the ones that decide whether the next three weeks are as productive as the last three.
*Sources consulted for the external comparison: [transvoxel.org](https://transvoxel.org/) ·
[triaxis.games/realtime-mesh](https://triaxis.games/realtime-mesh/) ·
[TriAxis-Games/RealtimeMeshComponent](https://github.com/TriAxis-Games/RealtimeMeshComponent) ·
[docs.voxelplugin.com](https://docs.voxelplugin.com/) ·
[VoxelPlugin/VoxelCore](https://github.com/VoxelPlugin/VoxelCore) ·
[UE 5.7 release notes](https://www.unrealengine.com/news/unreal-engine-5-7-is-now-available)*
+43
View File
@@ -0,0 +1,43 @@
# CLAUDE.md — VoxelForge
UE5 **density-field voxel terrain** plugin (strates / Marching Cubes / async streaming).
Don't re-read the whole plugin — use the map.
## ⛔ #1 rule: NEVER build or compile
Jahni runs every build himself (he has the editor open; running it via Claude just burns cost).
When code changes are done, **stop and say "ready to build"** + list likely compile-error spots,
then wait for his results / pasted errors. Same for in-editor checks — ask for a screenshot, don't
try to run anything.
## Read first (in order, load only what the task needs)
- **[CODEMAP.md](CODEMAP.md) — ALWAYS.** Navigation: what it is, data-flow (§2), symbol→`file:line`
index (§3), "I want to change X → go here" (§5), conventions & gotchas (§6). Trust **symbol names
over line numbers** (lines drift).
- **[ARCHITECTURE.md](ARCHITECTURE.md) — when touching generation / strates / passages / biomes.**
The deep design (archetypes, (0,0) spine, disturbances, content/atmosphere, biomes) **and the
`§8.10` performance invariants ("don't regress").**
- **[fable-idea.md](fable-idea.md) — before planning PERF or FEATURE work.** Ranked perf+feature
roadmap with verified hot spots; check it so you don't re-propose done/known work. (A perf pass
already shipped: game thread solved ~3.94 ms, CullTiles spiral fixed, region-granular foliage.)
- **[REVIEW_FINDINGS.md](REVIEW_FINDINGS.md)** — open quality/cleanup items (cross-check vs fable-idea
before acting; some may already be addressed).
## Hard rules (these prevent real bugs — verify against code, don't assume)
- **Density sign: negative = solid, positive = air** (MC convention at the mesher).
`FVoxelModification::Strength` negative = carve. *The #1 source of confusion.*
- **Density functions take VOXEL coords, not cm.** Conversions live in `VoxelTypes.h`.
- **Determinism:** all randomness = hash of (coord, seed, strateIndex). No RNG state — same seed ⇒
same world. Player edits (diff layer) are the only non-deterministic overlay.
- **Async safety:** worker tasks only READ Generator/Mesher, must check `bShuttingDown`, and return via
`ProcessQueue` (must stay `EQueueMode::Mpsc`). `EndPlay` blocks on `ActiveTaskCount → 0`.
- **Carry the `Epoch`** through any new async path (stale results are dropped on mismatch).
- **Don't "optimize" the perf invariants** in ARCHITECTURE `§8.10` — the `thread_local` box-valid
caches, two-pass MC loop, SSE noise, and clipmap streaming are intentional.
- **Never edit** `Binaries/`, `Intermediate/`, `*.generated.h`. Comments are French + English — match
the surrounding file.
## Discipline (keep the map alive)
- Add / rename / move a symbol → update its **CODEMAP §3** row (symbol-first; line is a hint).
- Change generation/strate design → update **ARCHITECTURE §8**.
- Resolve a checklist item → tick it in **REVIEW_FINDINGS.md**.
- Don't duplicate content across these files — each has one job (map / design / findings / rules).
+434
View File
@@ -0,0 +1,434 @@
# VoxelForge — Density Operator Stack: the plan
> **What this is:** the agreed direction for turning VoxelForge from an *archetype dispatcher* into a
> *composable density pipeline*, so new world ideas become authoring instead of C++. Written
> 2026-07-26 as a handoff for a future context — read this instead of re-deriving it.
>
> **Status:** DESIGN AGREED, NOT STARTED. Nothing in the codebase implements this yet.
>
> **Read first:** `CODEMAP.md` (navigation) · `ARCHITECTURE.md §8.10` (the perf invariants this must not
> break) · `AUDIT-2026-07.md §6` (the 3D hazards this is designed to kill permanently).
>
> **Jahni's goal, verbatim (2026-07-26):** *"a world generator, of any kind, of any possibility, almost
> — any combination of ideas you could guess, could be happening."*
---
## 0. The one-paragraph version
`UVoxelGenerator::GetDensityAt` is a `switch` over 8 hardcoded `ECaveGeneratorType` values, each owning
a bespoke density function and param struct. That means (a) a new world idea costs ~6 edit sites, and
(b) **ideas cannot combine** — one archetype owns the whole voxel. The fix is to make density a **stack
of small operators**, each of which can `PrepareChunk` / `Eval` / declare **what it can do to a box**.
That last part is the keystone: it makes `ClassifyTile` generic and correct *forever*, instead of a
hand-written guard per feature (the thing that already caused one revert and is the top hazard for the
current 3D work). Staged so the game never stops working, and so the first useful step is small.
**Non-goal, stated deliberately:** this must *serve* the descent-through-strates structure, not dissolve
it. "The world is a vertical stack you dig down through, each layer its own place" is the idea of this
project — the seals, the (0,0) spine and the passages only mean something because of it. The op stack
should make each strate more surprising, not turn the world into undifferentiated composable soup.
---
## 1. Why this specific shape fits this specific codebase
Not a generic "use composition" argument — three concrete reasons:
**(a) It formalises what the code already does by hand.** Every archetype already: hoists chunk-constant
work into a `thread_local` cache (`PrepareChunk`), evaluates cheaply per voxel (`Eval`), and — in
`ClassifyTile` — has a hand-written statement of what it can do to a tile (`Bounds`). The operator model
isn't a new discipline; it's the existing discipline, named.
**(b) The 8 archetypes are ~6 functions wearing costumes.** `FlatPlain` and `CrystalChamber` are literally
the same function with different defaults; `Underwater` is `TunnelNetwork` + a water flag. Decomposed,
they're roughly **15 orthogonal primitives** (floor surface, ceiling surface, hash cylinders, room SDF
graph, tunnel capsules, worm noise, lattice corridors, heightfield stack, island blobs, boundary seal,
spine carve, passage carve, disturbances, surface ops, diff layer). 15 primitives that combine covers
vastly more than 8 that don't.
**(c) It kills the recurring hazard permanently.** T1.d guards are currently written per feature
(`AnyPassageNearBox`, the spine circle test, chasm/bridge flags, `HasAnyModInChunkRange`, phase-2's
`OverhangMargin`). Every new 3D feature needs one, forgetting one is a hole, and one was already
forgotten badly enough to revert T1.d v1 on 2026-06-26. Under this model a new op **cannot ship without
answering the question**, and a test catches it if the answer is wrong.
---
## 2. The keystone insight — start with DIRECTION, not intervals
The full version of `Bounds` returns a numeric interval. **Don't start there.** Almost every existing
operator is *one-directional*: it only ever carves, or only ever fills.
```cpp
enum class EVoxelOpEffect : uint8
{
CarveOnly, // can only move density toward AIR → kills the AllSolid hypothesis
FillOnly, // can only move density toward SOLID → kills the AllAir hypothesis
Both, // unconstrained
Identity // provably no effect on this box (the early-out that makes it fast)
};
virtual EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const;
```
**This alone reproduces every hand-written guard in `ClassifyTile` today, generically.** Look at the
current code: "passages ⇒ `bCanSolid = false`" *is* `CarveOnly`. "bridges/ridges ⇒ `bCanAir = false`"
*is* `FillOnly`. "no passage near this box ⇒ skip" *is* `Identity`.
So **Phase 1 ships with no numeric bounds at all** and already gets the whole safety property. Numeric
intervals are a later tightening for gen-cost, not a correctness prerequisite. This is what turns a scary
refactor into a small first step — and it's the thing to remember if this plan ever feels too big.
### Every existing primitive already has an obvious answer
Filled in here so a future context doesn't have to re-derive it:
| Primitive | Effect | Identity test (cheap) | Numeric bound (later) |
|---|---|---|---|
| `ApplyBoundarySeal` | **FillOnly** (`FMath::Max`) | box misses both seal bands | `+[0, BaseDensity]` |
| `ApplyOriginSpine` | **CarveOnly** | circle-vs-box XY, + Z outside interior | `[0, Base*2 + Seal]` |
| `ApplyPassageCarving` | **CarveOnly** | `AnyPassageNearBox`**already written** | `[0, …]` via `AirTarget` |
| Disturbance chasms | **CarveOnly** | `ChasmDensity == 0`, or lattice miss | `+[0, Solid]` toward air |
| Disturbance bridges/ridges | **FillOnly** | density == 0, or lattice miss | `[0, Solid]` |
| F20 overhang | **FillOnly**, banded | outside `(TerrainZ, TerrainZ+Height]`**already written** | union ⇒ `max` only |
| Room / tunnel / column SDF | Both | bounding sphere vs box — **already written** | **Lipschitz-1**: `SDF ∈ [SDF(c) r, SDF(c) + r]`, `r` = box half-diagonal |
| fBm / Ridged term `k·N(...)` | Both | `k == 0` | `±\|k\|``FBM` returns `[-1,1]` by construction (`Total/MaxValue`) |
| Heightfield (`TerrainZ Z`) | Both | — | `[minT Zmax, maxT Zmin]`; **or sample the lattice exactly** (see below) |
| Diff layer | Both | `HasAnyModInChunkRange`**already written** | `±max\|Strength\|` over mods in range |
Two things to notice:
- **Five of these already exist as code.** The work is mostly *moving* guards, not inventing them.
- **`Bounds` is allowed to be exact-by-sampling, not just analytic.** Today's `ClassifyTile` gets the
tightest possible SurfaceWorld verdict by evaluating `ComputeSurfaceColumn` **on the mesher's exact
lattice** — same functions, same floats, so the verdict is exact rather than estimated. That must
survive. The contract is "conservative", not "closed-form": an op may sample to answer.
### Prior art (30 minutes, worth it before Phase 1)
- [Keeter, *Massively Parallel Rendering of Complex Closed-Form Implicit Surfaces*](https://dl.acm.org/doi/10.1145/3386569.3392429) + [fidget](https://github.com/mkeeter/fidget) — interval arithmetic per expression node to prune empty regions. The industrial version of this idea.
- [Barbier et al., *Lipschitz Pruning: Hierarchical Simplification of Primitive-Based SDFs* (CGF 2025)](https://onlinelibrary.wiley.com/doi/10.1111/cgf.70057) — **the better fit for us**: bounds each primitive's range of influence and treats primitives as **black boxes**, where full interval arithmetic needs interval semantics defined for every node. Our ops are SDFs (Lipschitz by construction) and bounded-amplitude fBm. Black-box bounds; don't build a node-level IA engine.
---
## 2.5 ⚠️ The op TAXONOMY — and why this is NOT the old room-ops system
**Jahni's objection, 2026-07-27, and it is the correct one:** *"ops structure — which, let's all be honest,
was what I had before, 'room operations' which would modify stuff, so I sure hope your idea is not that.
There's a world of difference between a grotto strate and an open world strata."*
He is right, and a fresh context **must** internalise this or it will build something useless.
`UVoxelTerrainOpDefinition` today can only **perturb density near a surface that already exists**, inside
a fixed archetype (`Terrace`, `LayerLines`, `Ribbing`, `Cliff`, `Scallop`, `Overhang`, `Arch`, `Column`,
`Pit`, `Chimney`, `Dome`, `Pinch` — all applied near cave walls where `bNearCaveSurface`). It cannot turn a
grotto into an open world, because it never decides **what the field IS**. That decision lives in the
`switch` in `GetDensityAt`, which is exactly the thing we are removing.
So the op stack has **four ROLES**, and the old system only had role 3:
### Role 1 — FIELD SOURCES (the new thing; this is the "world of difference")
Produce a density field *from nothing*. **This is what makes a grotto a grotto and an open world an open
world.** Each of today's archetypes is fundamentally one of these:
| Source | Today's archetype |
|---|---|
| Heightfield ground + sky-cap ceiling | `SurfaceWorld` |
| Room-graph SDF (hash rooms + tunnel capsules) | `TunnelNetwork` |
| Floor/ceiling slab void | `FlatPlain`, `CrystalChamber` |
| 3D lattice corridors | `Maze` |
| Full-height shafts + connectors | `VerticalShafts` |
| Suspended island blobs in open void | `FloatingIslands` |
A source is a first-class op with the same three-method contract. **A "strate archetype" therefore stops
being an enum and becomes `source + combiners + modifiers`.** Two strates differ in their SOURCE first,
their modifiers second.
### Role 2 — COMBINERS (how sources merge; this is what makes ideas compose)
`Replace` · `Union`(min) · `Subtract`(max) · `SmoothUnion`/`SmoothSubtract` (reuse `VoxelSDF::SmoothMin/Max`)
· `Mask` (scale the next op by a field: biome weight, slope gate, relief, depth).
This is the role that buys the ambition. *Floating islands **inside** a grotto. A maze **beneath** an open
world's ground. A room-graph carved **into** a mountain.* None of those are expressible today at any price.
### Role 3 — DETAIL MODIFIERS (the old system, demoted to one role of four)
Roughness, terrace, layer lines, ribbing, scallop, cliff, overhang, domes, pinch. **All of today's
`UVoxelTerrainOpDefinition` types land here, essentially unchanged.** They keep working; they stop being
the whole story.
### Role 4 — STRUCTURAL POST (fixed order, non-negotiable, runs last)
`ApplyOriginSpine``ApplyBoundarySeal``ApplyPassageCarving` → diff layer.
These are **world invariants**, not creative choices: descent must stay possible, seals must hold, passages
must punch through anything, player edits win. They are ops for uniformity but the stack compiler must
always append them, in this order, regardless of authoring. **An author must not be able to omit them.**
### Plus: SCOPING
Any op may be gated by a region predicate — Z band, XY region, biome index, slope range, depth. Scoping is
what lets one strate hold several sources without them fighting, and it's the mechanism that unifies
"strate" and "biome" into one concept in Phase 3.
> **The test for whether this refactor was worth doing:** can you author *"an open-world surface strate
> whose mountains contain a room-graph cave system, with floating islands in the upper void"* **without
> writing C++?** If no, it collapsed back into the old system and something went wrong.
---
## 2.6 The acceptance bar — "very close", NOT byte-identical
**Jahni, 2026-07-27:** *"I want not a 1/1 replica of the current strates with the current system, but a
possible very close result to what I have right now, else it won't really matter much."*
Two different properties get confused here. Keep them apart:
| Property | Required? | Meaning |
|---|---|---|
| **`ValidateDeterminism` = 0** | **YES, ALWAYS** | the same world point sampled twice, from different cache windows/threads, returns the identical float. This is window invariance (§8.4) — self-consistency. Non-negotiable, it's what prevents seams and MP divergence. |
| **Byte-identical to the OLD system's output** | **NO** | matching the pre-refactor world float-for-float. |
**This is a deliberate relaxation of what an earlier draft of this plan demanded, and it matters
strategically:** if bit-identity were required, the cheap path would be to wrap each old density function
as one monolithic op — 8 opaque ops that don't compose, i.e. **the switch with extra steps and zero
gain.** Releasing that constraint is what permits *real* decomposition into the primitives in §2.5.
**The bar instead:** for each ported archetype, an authored op stack must reproduce the *character* of the
old one — same scale, same navigability, same feel, recognisably the same kind of place. Judged by Jahni
on a screenshot at a fixed seed, not by a diff. Expect and accept a one-time re-tune, exactly as the T2.a
SIMD noise switch required.
---
## 3. The contract
```cpp
// Chunk-constant inputs. Mirrors what the thread_local CP_* block resolves today.
struct FVoxelOpContext
{
FIntVector ChunkCoord;
int32 Step; // LOD step — ops may cheapen themselves (see §7)
uint32 Seed;
uint32 LayoutVersion; // ⚠️ see AUDIT C2 — every cache key MUST include this
float StrateTopWorldZ, StrateBottomWorldZ;
const FBiomeContext* Biome; // null = strate has no biome field
};
class IVoxelDensityOp
{
public:
// Hoist all chunk-constant work here (room lists, biome grids, column caches, lattice bakes).
// Called once per chunk per worker. This is where today's thread_local caches move to.
virtual void PrepareChunk(const FVoxelOpContext& Ctx) = 0;
// Per-voxel. InDensity = what the stack produced so far, MC convention (negative = solid).
virtual float Eval(float X, float Y, float Z, float InDensity) const = 0;
// CONSERVATIVE. Phase 1: direction only. Phase 3: add a numeric interval overload.
// Returning Both is always SAFE (costs CPU); returning the wrong one is a HOLE.
virtual EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const = 0;
// Declares whether Eval depends on Z. XY-pure ops get the T1.a column-cache treatment
// generically instead of SurfaceWorld having a bespoke one.
virtual bool IsXYPure() const { return false; }
};
```
**Composition semantics** — keep the vocabulary small, and reuse what exists (`VoxelSDF::SmoothMin` /
`SmoothMax`):
| Mode | Meaning |
|---|---|
| `Replace` | ignore `InDensity` (stack roots: heightfield, base density) |
| `Union` (min) | add solid — bridges, islands, columns |
| `Subtract` (max) | carve air — rooms, tunnels, passages, spine |
| `SmoothUnion/Subtract` | the same with `SmoothMin/Max(k)` — organic junctions |
| `Add` | scalar accumulate — noise/roughness terms |
| `Mask` | scale the *next* op by a field (biome weight, slope gate, relief) |
`Mask` is what buys most of the expressiveness: "this op, but only in high-relief regions / only on
steep slopes / only in this biome" becomes composition rather than a bespoke gate inside each op.
---
## 4. Order of work
### Phase 0 — THIS WEEK. Ship the 3D caves. Refactor nothing.
The current feature (caves inside mountains, volumetric generation) ships as-is with a **hand-written
`ClassifyTile` guard** — see `AUDIT-2026-07.md §6.1` for why it's mandatory (without it the caves are
never meshed: no geometry, no collision, invisible until you fall through them).
**One constraint only:** write the guard as a standalone function in the shape of the future contract —
```cpp
// Not a member of anything yet. Just the right shape.
EVoxelOpEffect CaveSystemEffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx);
```
Same work, zero architectural commitment, and it establishes the pattern.
**Also decide here (see `AUDIT §6.2`): placed, not fielded.** Hash-located cave systems with bounds
(like `FCachedRoom`/`FCachedTunnel`) let the guard prove most of the mountain still solid and keep
T1.d's 44% worker-CPU win. A global 3D noise field makes every deep tile `Mixed` and hands that back.
> ✅ **Gate:** caves render and collide when approached through solid rock, and worker CPU hasn't
> visibly regressed.
---
### Phase 0.5 — The safety net. ~1 day. Do this before Phase 1, not after.
Three automation tests (`Source/VoxelForge/Private/Tests/`, `IMPLEMENT_SIMPLE_AUTOMATION_TEST`).
There are currently **zero tests**, and nothing machine-checks the dozens of "bit-identical" claims in
the docs.
1. **Density purity** — sample 10k points, shuffle query order, re-sample, assert bit-equality. Catches
every cache-key bug including `AUDIT C2`. Run it across **multiple worker threads**, because
`ValidateDeterminism` runs on the game thread and would miss worker-cache divergence.
2. **`ClassifyTile` soundness** — for random tiles, if the verdict is `AllSolid`/`AllAir`, brute-force
the lattice and assert every sample agrees. **This is the highest-consequence function in the plugin
and it is currently validated only by reasoning.**
3. **`DiffLayer` under contention** — N readers + a writer; assert no crash, monotonic version.
> ✅ **Gate:** all three green on the current code. If #1 or #2 fails, you've found a live bug — fix it
> before building on top.
---
### Phase 1 — The pivot. Port ONE archetype. Timebox it.
**Pick `Maze`.** ~100 lines, no cross-chunk connectivity decision, trivial bound (corridor SDF is
Lipschitz-1 off a lattice), and it's the least-used archetype so a mistake is cheap.
1. Add `IVoxelDensityOp` + `EVoxelOpEffect` + `FVoxelOpContext` + the four role tags (new file:
`Public/VoxelDensityOp.h`).
2. **DECOMPOSE, don't wrap** (§2.5, §2.6). Maze becomes a stack, not one op:
`FLatticeCorridorSource` (role 1 — the capsule field off the 3D lattice) → `Subtract`
`FSurfaceRoughnessMod` (role 3 — the existing `SurfaceRoughness` perturbation) → then the four
structural-post ops appended automatically. **If it comes out as a single `FMazeOp`, the refactor
has failed its own test** — that's the switch with extra steps.
3. `GetDensityAt` gains **one** branch: strate has an op stack ⇒ run it; else fall through to today's
switch. **Both systems coexist**, indefinitely if needed.
4. `ClassifyTile` gains a generic path (`EffectOverBox` folded over the stack) used only by ported strates.
> ✅ **Gate:** `ValidateDeterminism` = 0 delta (§2.6 — always), Phase 0.5 tests green, and the Maze world
> is **recognisably the same place** at a fixed seed — same corridor scale, same connectivity, same feel.
> Judged on a screenshot, not a diff. A one-time re-tune of the params is expected and fine.
>
> ✅ **The real proof, and the one that answers Jahni's objection:** once `FLatticeCorridorSource` exists,
> **drop it into a `SurfaceWorld` strate underneath the terrain** and confirm you get a maze inside a
> mountain with no C++ written. If that works, the architecture is doing the thing it was built for.
>
> 🛑 **Stop-and-reconsider trigger:** if Phase 1 exceeds ~2 days, or the source/modifier split doesn't fall
> out naturally from the existing code, the abstraction is wrong for this domain. **Say so plainly, revert,
> and report** — do NOT push through and port a second archetype to prove a point.
---
### Phase 2 — Port opportunistically. No big bang.
Port each archetype **the next time a feature makes you open it anyway**. The switch shrinks on its own.
Suggested order when there's a free choice — cheapest and least risky first:
`Maze` (P1) → `FlatPlain`/`CrystalChamber` (one op, two default sets — the first real win: two archetypes
collapse into one) → `SurfaceWorld` (biggest payoff, biggest care: the T1.a column cache and the exact-
lattice `ClassifyTile` bound must both survive) → `VerticalShafts``FloatingIslands``TunnelNetwork`
(**last** — it owns `BuildChunkCache`'s two-region window-invariance discipline, §8.4, the most delicate
code in the plugin).
Along the way, `FStrateGenerationParams`' 74 fields decompose into per-op structs, which retires the
`VF_STRATE_PARAM_FIELDS` X-macro drift problem for free.
---
### Phase 3 — The payoff. Ops become data.
1. `UVoxelDensityOpDefinition : UPrimaryDataAsset` — one asset per op, mirroring today's
`UVoxelTerrainOpDefinition` (which is *already* the right authoring shape: an asset + weight +
probability in an ordered list).
2. **A strate becomes "a Z range + an ordered op list"** instead of "an enum + a param bag".
3. **A biome becomes "an XY region predicate + an op list" — the same mechanism.** Today these are two
unrelated systems (`ECaveGeneratorType` dispatch vs the biome field with its `bOverrideTerrain`
special case that only works for `SurfaceWorld`). Unifying them is where the expressiveness comes
from: any op, scoped by any region, in any combination.
4. Numeric interval bounds where profiling shows `Both` is costing real gen time.
5. **Only if needed:** compile the per-chunk stack into a flat opcode tape and interpret with a switch,
killing per-voxel virtual dispatch (~43k samples/tile × N ops). Standard technique. **Don't do this
pre-emptively** — measure first; the noise is likely still dominant.
---
## 5. Invariants this must not break
Non-negotiable. Each has a documented reason and, mostly, a scar.
- **Window invariance (§8.4).** Every op stays a pure function of world coords + seed. Any op with a
*connectivity decision over a neighbourhood* inherits `BuildChunkCache`'s two-region COLLECT/STORE
discipline. **Prefer the `Maze` pattern** (pure hash of `(lower node, axis)` — adjacent chunks cannot
disagree, no cache, no COLLECT region) unless connectivity is a gameplay requirement.
- **T1.a column cache Z-independence.** `FSurfaceColumn` data is keyed `(XY box, StrateKey, Seed)` with
**no ChunkZ** and shared down the whole vertical stack. XY-pure data goes in the column cache;
Z-dependent evaluation happens per voxel. `IsXYPure()` exists to make this explicit instead of implicit.
- **Cache keys include `LayoutVersion`** (`AUDIT C2` — today's `CP_Chunk`/`OC_Chunk`/`BM_Chunk` don't,
and serve stale params after a live edit). `FVoxelOpContext` carries it so a new op can't forget.
- **`ProcessQueue` stays `EQueueMode::Mpsc`**; ops are read-only on workers; `Epoch` carries through
every async path.
- **The two-pass MC loop, margin ring, and `thread_local` grid reuse** (§8.10) are untouched by all of
this — the op stack lives *below* `GetDensityAt`, the mesher never knows.
---
## 6. Risks, and how each one is detected
| Risk | Detection |
|---|---|
| A wrong `EffectOverBox`**a hole** | Phase 0.5 test #2 (brute-force vs verdict) — the load-bearing one |
| A cache key missing an input ⇒ **seams** | Phase 0.5 test #1 (shuffled order, multi-threaded) |
| Per-voxel dispatch cost | Insights `VoxelForge_GenerateMesh` before/after each port; tape compile if it bites |
| Abstraction is wrong for this domain | The Phase 1 stop-trigger — one archetype, timeboxed, revert cheaply |
| Scope creep into a node-graph editor | See §7 |
---
## 7. Explicitly NOT doing
- **No node-graph editor.** An ordered `TArray` of op assets is 90% of the value. A graph UI is a
separate project, years later, and chasing it is how generative systems die.
- **No GPU density.** Same reasons as `fable-idea` Part I: readback latency, CPU collision, and
cross-GPU float determinism is fatal for "replicate the seed, regenerate identically on every peer"
(`ARCHITECTURE §9.1`).
- **No node-level interval arithmetic engine.** Black-box Lipschitz/amplitude bounds per op (§2).
- **No big-bang port.** If more than one archetype is mid-port at any time, stop.
- **No dissolving the strate structure.** See §0.
---
## 8. Independent fixes — do these regardless, they get worse with time
From `AUDIT-2026-07.md §5`. None depend on this plan; all become harder inside it.
1. **Bound `SeedF`** (`AUDIT C1`) — `const float SeedF = (float)(VoxelHash::Mix((uint32)Seed) & 0x3FFF);`
at all 6 definition sites. Large seeds currently collapse noise terms to constants. One world re-tune.
**Do it before tuning 3D caves against a seed you might later randomise.**
2. **Add `GetLayoutVersion()` to `CP_Chunk` / `OC_Chunk` / `BM_Chunk`** (`AUDIT C2`).
3. **`GetPlayerPosition` no-player flag** (`AUDIT C4`) — `(0,0,0)` is the designed spine landing and
currently stalls all streaming.
4. **Unbounded joins on shutdown** (`AUDIT C5`).
5. **Track the `.md` files** (`AUDIT P1`) — `!*.md` in `.gitignore`. This file is currently untracked.
6. **Branch the experimental 3D work** (`AUDIT §6.6`) — `git switch -c 3d-density`.
---
## 9. Resume here
**Next action:** Phase 0 — ship the 3D caves with `CaveSystemEffectOverBox` as a standalone function.
Nothing in this plan is started.
When picking this up cold: read §0, §2 (the direction-only insight — that's what makes step 1 small),
and §4. The rest is reference. If the plan feels too big, re-read §2: **Phase 1 needs no numeric bounds
at all**, and the first real win is two archetypes collapsing into one.
---
*Changelog — 2026-07-26: written by Opus 5 after a full-tree audit, at Jahni's request, as a durable
handoff so a future context doesn't re-derive it. Companion to `AUDIT-2026-07.md`.*
+36
View File
@@ -0,0 +1,36 @@
# OPSTACK — progress log
> **APPEND ONLY. Never rewrite or reorder entries.** Write the entry for a piece of work *before*
> starting it, so an abrupt session end still leaves an accurate marker.
>
> **Entry format:** date · what · believed-true · **UNVERIFIED** (everything not yet built by Jahni) ·
> next single action.
>
> This file is how a fresh context resumes. Read the last entry first, then `OPSTACK-PLAN.md §9`.
---
## 2026-07-27 — branch created, design locked, nothing built
**What:** Branch `experimental` created from `69fa73e tmp` and checked out. `main` untouched and is the
known-good fallback world. Design finalised across `OPSTACK-PLAN.md` (incl. §2.5 op taxonomy and §2.6
acceptance bar, both added after Jahni's objection that a naive "ops" reading would just be his old
room-operations system). Kickoff prompt written to `OPSTACK-PROMPT.md`.
**Believed true:** the plugin builds and runs correctly as of this commit — everything through F20
phases 1+2 is built and working (confirmed by Jahni 2026-07-26; the "PENDING BUILD" markers still in
`fable-idea.md` / `ARCHITECTURE.md` are stale and are queue item Q3). `bEnableDensityVolume` is already
set to false. Lighting is deprecated for now. Live direction before this refactor was 3D density
generation (overhangs, caves inside mountains).
**UNVERIFIED:** nothing yet — no code has been written for this refactor.
**Known open bugs, not yet fixed, documented in `AUDIT-2026-07.md §1`:** C1 large-seed noise collapse ·
C2 three cache keys missing `LayoutVersion` · C3 `FMath::PerlinNoise2D` on the SDF hot path (dormant) ·
C4 `GetPlayerPosition` zero-vector sentinel · C5 unbounded-join-on-shutdown · C6 diff-layer scaling.
**Next single action:** Phase 0.5 — the three automation tests (density purity across worker threads,
`ClassifyTile` vs brute force, `DiffLayer` under contention). Then the Phase 1 skeleton header only.
Then STOP for a build. See `OPSTACK-PROMPT.md``WHAT TO DO`.
---
+258
View File
@@ -0,0 +1,258 @@
# Kickoff prompt — density operator stack refactor
*Paste the block below into a fresh context. Everything above the line is for Jahni, not the new session.*
**Branch:** already created and checked out — `experimental` (from `69fa73e tmp`). `main` is untouched.
**Why this exists:** the refactor is too big for one context. This prompt makes any fresh session able to
start, or resume at a phase boundary, without re-deriving the design.
**Working rhythm it enforces:** one large batch of code → stop → *"ready to build"* + likely compile-error
spots → Jahni builds → he pastes errors/screenshots → fix → next batch. That is deliberate; see §Autonomy.
---
```
You're picking up an agreed refactor of the VoxelForge UE5 voxel plugin, on branch `experimental`
(already checked out — do not create another). I'm Jahni. This was designed with a previous context
and written down so you don't have to re-derive it.
GOAL
Replace the hardcoded archetype `switch` in `UVoxelGenerator::GetDensityAt` with a composable
density OPERATOR STACK, so new world ideas become data-authoring instead of C++. Long-term ambition:
"a world generator of any kind, of any possibility — any combination of ideas could be happening."
READ FIRST, IN THIS ORDER (do not skip, do not skim §2.5)
1. CLAUDE.md — project rules. Rule #1 is absolute.
2. OPSTACK-PLAN.md — THE PLAN. §0 the summary, §2 why step 1 is small, **§2.5 the op taxonomy**,
§2.6 the acceptance bar, §4 the phases, §5 the invariants, §7 non-goals.
3. AUDIT-2026-07.md — §6 (the 3D hazards) and §5 (priority list). §1 has real open bugs.
4. CODEMAP.md — navigation. Trust symbol names over line numbers; the lines are stale.
5. ARCHITECTURE.md §8.10 — the perf invariants. Read before touching any hot path.
THE THREE WAYS YOU WILL FAIL — internalise these before writing code
(1) BUILDING. Never run a build, compile, or the editor. Jahni builds everything himself; he has the
editor open and running it yourself just burns cost and tells you nothing. When a batch of code is
done: STOP, say "ready to build", and list the likely compile-error spots. Then WAIT. Same for
in-editor verification — ask for a screenshot, don't try to produce one.
(2) WRAPPING INSTEAD OF DECOMPOSING. Read OPSTACK-PLAN §2.5. Jahni already had a "room operations"
system — ops that perturb density near an existing surface. His words: "there's a world of
difference between a grotto strate and an open world strata." If you turn each old density
function into one monolithic op, you have rebuilt the switch with extra steps and wasted the
effort. Archetypes must DECOMPOSE into: FIELD SOURCES (role 1 — what makes a grotto a grotto),
COMBINERS (role 2 — how sources merge; this is what makes ideas compose), DETAIL MODIFIERS
(role 3 — his old ops, now one role of four), STRUCTURAL POST (role 4 — spine/seal/passage/diff,
always appended in that order, never author-omittable).
The test: can you author "open-world surface strate whose mountains contain a room-graph cave
system, with floating islands in the upper void" with NO C++? If no, you built the wrong thing.
(3) BREAKING AN INVARIANT. OPSTACK-PLAN §5. In particular: window invariance (§8.4 — every op is a
pure function of world coords + seed); the T1.a column cache is keyed (XY box, StrateKey, Seed)
with NO ChunkZ and is shared down the whole vertical stack, so XY-pure data only; every cache key
must include LayoutVersion (see AUDIT C2 — three existing caches get this wrong today);
ProcessQueue stays EQueueMode::Mpsc; Epoch carries through every async path.
ACCEPTANCE BAR — read OPSTACK-PLAN §2.6 carefully, two properties get confused
- `ValidateDeterminism` = 0 delta: REQUIRED ALWAYS. Same point, different cache window/thread,
identical float. This is self-consistency, not reproduction.
- Byte-identical to the OLD system's output: NOT required. Jahni: "not a 1/1 replica, but a possible
very close result to what I have right now, else it won't really matter much." Recognisably the same
kind of place at a fixed seed, judged on a screenshot. A one-time param re-tune is expected and fine.
This relaxation is WHY you are allowed to decompose properly instead of wrapping.
WHAT TO DO — first batch (Jahni has standing permission for multiple changes per build)
A. Phase 0.5 from the plan — the three automation tests. There are currently ZERO tests in this
plugin and nothing machine-checks the many "bit-identical" claims in the docs.
1. Density purity: sample ~10k points, shuffle query order, re-sample, assert bit-equality. Run it
across MULTIPLE worker threads (the existing `ValidateDeterminism` button is game-thread only and
would miss worker-cache divergence — that's how AUDIT C2 hid).
2. ClassifyTile soundness: for random tiles, if the verdict is AllSolid/AllAir, brute-force the
lattice and assert every sample agrees. This is the highest-consequence function in the plugin
and is currently validated only by reasoning; a false verdict is an invisible, collisionless hole.
3. DiffLayer under contention: N readers + a writer, assert no crash and monotonic version.
B. Phase 1 skeleton — `Public/VoxelDensityOp.h`: `IVoxelDensityOp` (PrepareChunk / Eval /
EffectOverBox / IsXYPure), `EVoxelOpEffect { CarveOnly, FillOnly, Both, Identity }`, the four role
tags, `FVoxelOpContext` (carrying LayoutVersion), and the combiner enum. Header + docs only, no
ports yet. Start with DIRECTION-only effects — no numeric intervals (OPSTACK-PLAN §2 explains why
this alone reproduces every hand-written ClassifyTile guard, and why it makes step 1 small).
C. Then STOP and hand off for a build. Do not start porting Maze in the same batch.
If (A) fails on the current code you have found a live bug — report it, fix it, don't build on top.
FOUR THINGS AGREED WITH JAHNI 2026-07-27 THAT THE PLAN UNDER-STATES — hold these as goals
(i) THE BIGGEST PERF PRIZE IS TILE-SKIPPING FOR CAVE STRATES, and it is currently zero. Read
`ClassifyTile`: any chunk that is neither a bedrock gap nor SurfaceWorld hits
`return EVoxelTileClass::Mixed; // archétype cave […] pas prouvable en v1`. So TunnelNetwork,
Maze, VerticalShafts, FloatingIslands, FlatPlain, CrystalChamber and Underwater capture NONE of
T1.d's win (which was 84% of gens empty, 44% worker CPU). `fable-idea` wanted this from the start
("for cave strates, 'no room/tunnel/passage/spine/seal/diff-layer bounds intersect' — all bounding
data already exists") and it never happened because a bespoke prover per archetype was too much.
`EffectOverBox` IS the generic mechanism. A room-graph source returning `Identity` when no room or
tunnel bound reaches the box makes deep bedrock skippable for the first time. Treat this as an
explicit deliverable of each port, not a side effect.
(ii) COMPILE THE STACK TO A FLAT TAPE, and don't wait for Phase 3 if the profile says otherwise.
Naive per-voxel virtual dispatch is ~6 ops × ~43k samples/tile ≈ 257k indirect calls ≈ ~1 ms/tile
of pure overhead — material against current gen cost. In `PrepareChunk`, compile the stack into a
flat `(opcode, params)` array and run a switch over a small dense opcode set in the inner loop: no
vtables, params cache-hot, predictable branches. Offsetting win, worth stating: ops that are
disabled or out-of-scope are ABSENT from the tape, so the ~15 per-voxel `if (Params.X > 0)` gates
inside `GetDensityWithParams` today become zero cost instead of one always-false branch each.
Take an Insights capture after Phase 1; if dispatch shows up, do the tape then.
(iii) THE PERF STORY IS "≈ NEUTRAL PLUS ONE REAL WIN", NOT "FASTER". Do not oversell it in docs or
reports. The reason to do this refactor is composition. Honest expectation: neutral per-voxel after
the tape, meaningful gain on tiles-never-generated. If a measurement contradicts that, say so.
(iv) SHIP PRESET STACKS so authoring doesn't regress. A simple world today is "pick an enum, fill one
struct"; after, it's "assemble 3-5 assets in the right order", which is more clicks and a new class
of mistake (ordering is now semantic). Provide `DA_Stack_ClassicGrotto`, `DA_Stack_OpenWorld` etc.
as starting points a strate can diverge from. `ECaveGeneratorType` is expected to disappear
eventually — but only after every archetype is ported; it stays as the fallback path until then.
HOW TO REPORT AT EVERY STOP
- What changed, file by file.
- "Ready to build" + the specific spots likely to error (signatures, UHT, includes, template/lambda
capture) so Jahni knows where to look.
- What he should LOOK AT in-editor afterwards, concretely, and what a pass vs fail looks like.
- What the next batch will be.
- Anything you became unsure about. Ask rather than assume — for UE API behaviour especially, ask him
for the docs instead of guessing (DivideAndRoundDown truncating rather than flooring already cost
build cycles once).
DISCIPLINE
- Update CODEMAP §3 rows for any new/renamed symbol; ARCHITECTURE §8 for design changes; tick phases
in OPSTACK-PLAN.md as they land. Comments are French + English — match the surrounding file.
- Never edit Binaries/, Intermediate/, *.generated.h.
- Density sign: NEGATIVE = solid, POSITIVE = air at the mesher. #1 source of confusion.
- Commit per feature with a real message (this branch exists so you can commit freely; `main` is the
known-good fallback). Do not push.
- If the plan turns out to be wrong for this domain, SAY SO and stop. There is an explicit
stop-trigger on Phase 1: if it exceeds ~2 days or the source/modifier split doesn't fall out
naturally from the existing code, revert and report rather than pushing through.
UNATTENDED OPERATION — Jahni may start you and go to sleep. Read this before your first tool call.
YOU CANNOT CHECK YOUR REMAINING BUDGET. No tool reports usage or quota. So do not try, and do not
claim to. Instead assume the harder thing: **this session can end at any moment, without warning,
mid-edit, and nobody will be watching.** Everything below follows from that.
CRASH-SAFE DISCIPLINE (non-negotiable when unattended)
1. `git commit` after every coherent unit — a file, a test, a header. Small and often. You are on
branch `experimental`; `main` is the known-good fallback, so committing costs nothing and a
half-finished commit is infinitely better than an uncommitted half-edit. Never push.
2. Maintain `OPSTACK-PROGRESS.md` at the plugin root. APPEND (never rewrite) a dated entry per
milestone: what you did, what you believe is true, what is UNVERIFIED (i.e. everything not yet
built), and the single next action. Write the entry BEFORE starting the work it describes, so an
abrupt death still leaves an accurate marker. This file is how the next context resumes.
3. Never leave a file mid-transformation across a stopping point. If you're partway through changing
a signature and its call sites, finish all call sites or revert the change. A tree that doesn't
compile *for a reason you documented* is fine; one that doesn't compile for an unknown reason is
the thing that wastes Jahni's morning.
4. Don't batch a risky change with a safe one in the same commit. If a build fails he needs to know
which half did it.
WHEN YOU REACH THE BUILD GATE — this will happen quickly, and it is not a failure
The first batch (A + B) is a few hours at most, and then you physically cannot verify anything. At
that point: STOP writing plugin code. Do NOT invent more C++ to fill the night — writing unverified
code on top of unverified code is the exact failure `AUDIT-2026-07.md §P3` documents, and doing it
unattended would be the worst version of it.
Instead work the BUILD-FREE QUEUE, in this order. All of it is genuinely useful and none of it can
break anything:
Q1. ★ THE DECOMPOSITION MAP — the highest-value unattended task by far. Create
`OPSTACK-DECOMPOSITION.md`: for EACH of the 8 archetypes, read its density function carefully
and write out its proposed op breakdown — which FIELD SOURCE, which COMBINERS, which DETAIL
MODIFIERS, and exactly which existing params migrate to which op asset (field by field, so
nothing is silently dropped). Note per op: `IsXYPure()`, its `EffectOverBox` strategy, and any
shared primitive two archetypes could reuse. This is hours of careful reading, has zero build
risk, and it de-risks and speeds up every later port. Do this before anything else in the queue.
Q2. Audit the `FStrateGenerationParams` 74 fields against Q1 and list any that no op claims — those
are either dead or a decomposition gap. Report, don't delete.
Q3. Tick the stale "PENDING BUILD" markers in `fable-idea.md` / `ARCHITECTURE.md` (confirmed
resolved 2026-07-26 — everything is built and working; see `AUDIT-2026-07.md §0`).
Q4. Fix `.gitignore` to `!*.md` — the design docs are currently untracked (`AUDIT P1`).
Q5. STOP. Write the final `OPSTACK-PROGRESS.md` entry and a clear "good morning" summary: what's
ready to build, exact likely error spots, what to look at in the editor, what a pass looks like.
Then idle. Do not start Phase 2. Do not port a second archetype. Do not refactor anything not
in the plan.
There is no prize for burning the whole night. A small, committed, well-documented, build-ready
increment plus a complete decomposition map is a genuinely good night's work.
```
---
## Autonomy — what a fresh context can and cannot do alone
**Asked:** *"possibly even, you tell me if it's possible, for you to wait for the token to replenish? to
continue it all automatically and on your own."* — and then: *"can you tell it to check for token remaining
before continuing actions? I plan to let it run as I sleep."*
**On checking the budget: no. There is no tool that reports remaining usage or quota**, so an instruction to
"check tokens first" would be unfollowable — a safety net that looks real and isn't. The prompt therefore
instructs the opposite and stronger discipline: **assume the session can die at any moment, unattended**,
and make every stopping point crash-safe (commit-per-unit, an append-only progress log written *before*
the work it describes, never a half-applied edit). That achieves the actual goal — not waking up to a
broken tree — without depending on information the session can't get.
**On resuming automatically across a limit reset: the mechanism exists, but don't use it for this, and
tokens are not the real limit.**
- A self-pacing loop is available (`/loop` with no interval, which schedules its own wakeups). Whether it
resumes cleanly across a usage-limit reset is not something to promise — treat it as unverified.
- **The actual blocker is the build gate, not the budget.** Every meaningful step of a C++ refactor ends
at a compile, and rule #1 is that Jahni compiles. A loop left running would therefore do exactly one
thing: **write more unverified code on top of unverified code** — which is the precise failure pattern
`AUDIT-2026-07.md §P3` was written about (three weeks of stacked "PENDING BUILD"). Automating it would
deepen the problem it diagnosed.
- **What genuinely scales instead: batch size.** A single context can produce a large, coherent,
self-contained batch before stopping. Standing permission for multiple changes per build already exists.
So the throughput lever is "fewer, bigger handoffs", not "unattended looping".
- **What IS worth looping:** work with no build gate — documentation passes, analysis, or the separate
web-based world-rating harness. Not this.
**So the honest shape of "handling it on its own":** a fresh context can own the *design decisions, the
code, the doc updates and the sequencing* end to end, across many sessions, resuming from
`OPSTACK-PLAN.md §9`. It cannot own *verification*. That stays with Jahni, and given that the failure
modes here are invisible holes and silent seams, that's the correct place for it.
### What an overnight run realistically produces
Setting expectations honestly, because the first batch is deliberately small:
| | |
|---|---|
| ~13 h | Phase 0.5 tests + Phase 1 skeleton header. **Then it hits the build gate and cannot verify anything.** |
| remaining night | The BUILD-FREE QUEUE — dominated by **Q1, the decomposition map**: all 8 archetypes read carefully and broken into source / combiners / modifiers, with every param field traced to its destination op. Genuinely hours of work, zero build risk, and it makes every later port faster. |
| morning | A committed build-ready increment, `OPSTACK-PROGRESS.md`, `OPSTACK-DECOMPOSITION.md`, and a "good morning" summary with exact likely error spots. |
**What it must NOT do overnight:** port archetypes, start Phase 2, or write more plugin C++ once the gate
is reached. The prompt says this explicitly, twice, because "fill the night with code" is the tempting
wrong answer and it reproduces the stacked-unverified-work pattern the audit was written about.
---
## Feasibility, honestly
| Phase | Effort | Risk |
|---|---|---|
| 0.5 — three tests | 1 session + 1-2 build rounds | Low. May surface a real bug (that's a win). |
| 1 — skeleton + Maze decomposed | 1-2 sessions + 2-3 build rounds | **Medium — this is the go/no-go.** |
| 2 — port remaining archetypes | 1 session each, opportunistic | Medium; `TunnelNetwork` last, it owns §8.4. |
| 3 — ops as assets, strate = op list | several sessions | Medium-high; big authoring-surface change. |
**The single genuine unknown is Phase 1**, and it's cheap to find out — one archetype, timeboxed, with an
explicit abort. If `Maze` doesn't decompose cleanly into a source + a modifier, the abstraction is wrong
for this domain and two days bought that knowledge.
**The largest hidden cost is not code, it's re-tuning.** Every ported archetype needs its params re-dialled
to look right again (§2.6 accepts this). That's Jahni's time in the editor, not a context's time writing
C++, and it is likely to dominate the schedule.
+117
View File
@@ -0,0 +1,117 @@
# VoxelForge — Review Findings (2026-06-23)
Local-LLM cross-file QUALITY review (Qwen3.6-27B @128K, codemap-guardrailed) + Claude
verification. Scope: performance, redundancy, dead code, over-complexity (not correctness bugs).
**Verdict: codebase is healthy — no perf regressions.** Items below are improvements, by appetite.
> **Reconciled against `fable-idea.md` + the perf-pass history (2026-06-23).** The big density/
> streaming perf wins are already SHIPPED and are *not* listed here (T1.a surface cache, T1.b
> grid normals, T1.c LOD0 collision, CullTiles spiral fix, region foliage, passage shortlist).
> Everything below is **net-new** (redundancy / dead code / complexity — a different axis), except
> the two notes flagged inline. Before acting on a PERF item, glance at `fable-idea.md` Part I.
Legend: ✅ verified against code · ◻️ checklist box.
> **2026-07-04 full-codebase pass (Fable 5)** — applied everything marked `[x]` below, plus NEW items
> found reviewing the un-built two-grid deco + density-volume work:
> • **Deco launch stall fix** — a pending cell already in flight from a previous build was dropped,
> permanently stalling its region build (blank region until strate change). Now deferred + retried.
> • **Capture gating** — level-0 tiles outside the shadow window no longer pay the mesher capture
> (quantize + 32 KB queue payload) nor pollute `CaptureCache` (`IsTileCaptureUseful`, both ends).
> • **Per-tick material pushes change-detected** — `UpdateTerrainMaterialParams` (10 vectors + 3
> textures × every terrain MID) and the orb MPC writes now no-op on idle frames; static `FName`s.
> • **Landmark cave march bounded** — `FindLandmarkColumn` now honours `ColDepth` (same bedrock
> early-stop as the deco march); it ran the full strate band synchronously on the game thread.
> • **`UploadDirtyTextures` now calls `EnsureTextures`** — GPU upload toggled ON at runtime works.
> Deliberately NOT done: the big behavior-preserving splits below (un-built tree; compile risk).
> **2026-07-04 perf pass 2 (per-voxel hot path, Fable 5)** — CODE-COMPLETE, pending build. All
> bit-identical (same hashes/math, hoisted per chunk/cell):
> • **DiffLayer snapshot API** (`HasAnyMods`/`GetModsVersion`/`GetChunkModsSnapshot`/static
> `EvaluateMods` + `ModsVersion` atomic) — `GetDensityAt` snapshots a chunk's mods once per
> (chunk, version) via a `thread_local` 64-slot cache: ~27 lock ops per tile task instead of
> ~86k once any carve exists.
> • **Room shape pre-bake** — `FCachedRoom::ShapeType/ShapeA/ShapeB/ShapeR` baked in
> `BuildChunkCache`; **`EvaluateSDFCached` signature changed** (`RoomShapeVariety` removed,
> 8 call sites updated).
> • **Strate-index memo** in `GetDensityWithParams`, keyed (chunkZ, `GetLayoutVersion()`) —
> new inline getter on `UVoxelStrateManager`.
> • **Per-cell lattice bakes** (`thread_local`, keyed cell+seed+params): slab columns, maze open
> edges, vertical shafts + cross-connectors, floating-island constants, disturbance
> chasms/bridges/ridges.
> • **Worm N2 short-circuit** — skip the 2nd Perlin when N1 ≥ WormThreshold (lossless).
> **Regression fixed same day:** pass 1's skip-if-identical cache on the orb MPC writes broke the
> mini-sun lighting (MPC world instances reset behind the writer) — REVERTED, `LastOrbMPC`
> removed; never de-duplicate writes to externally resettable state. The MID-side change
> detection stays (MIDs own their values).
> **2026-07-04 batch 3 (Fable 5)** — Jahni green-lit multiple changes per build + visual deltas
> ("nothing is set in stone"). PENDING BUILD together with pass 2:
> • **Terracing gradient Z-only** (6→2 SDF samples — see the ticked item above).
> • **Lerp X-macro** + **BakeRoomFeature dedupe** (both ticked above, bit-identical).
> • **T2.b LOD octave drop** — opt-in `UVoxelSettings::LODOctaveDrop` (default 0 = byte-identical);
> `VoxelGenLOD::OctaveBias` thread_local set per tile in `GenerateMesh`, per-voxel fractal sites
> wrapped in `VoxelGenLOD::Eff(N)`, XY-field noise deliberately excluded. ARCHITECTURE §8.10.
> • **T2.c tile component pool** — `TileComponentPool` + `Acquire/ReleaseTileComponent`; unload
> parks (RemoveSectionGroup strips geometry+collision, hidden, stays registered), apply pops.
> Bonus: `ApplyMeshToTile` reuses the existing `URealtimeMesh` (`GetRealtimeMeshAs`) — the old
> unconditional `InitializeRealtimeMesh` allocated + orphaned one mesh UObject PER APPLY.
> • **T2.d worker clamp** — `GetMaxConcurrentTasks()` caps the asset budget to logical cores 2
> (the BackgroundNormal priority half had already shipped). **Tier 2 is now COMPLETE** —
> T2.a was already done (float SSE `VoxelNoise` core, §8.10).
> • **F2 determinism validator** — `AVoxelWorld::ValidateDeterminism` CallInEditor button:
> boundary points sampled under two cache-window alignments + a repeat pass; any non-zero
> delta = §8.4 regression. The tool that VERIFIES all the "bit-identical" claims above.
## Performance
- [x] ~~**`VoxelGenerator.cpp` (terrain-op gradient)** — compute once per voxel and reuse.~~
**RE-VERIFIED 2026-07-04: misdiagnosed.** The 6 `EvaluateSDFCached` calls in the terracing block are
six DISTINCT sample positions (±1 on each axis) for one central-difference gradient — computed once
per voxel already, gated to terrace-enabled rooms near surfaces. Nothing is redundantly re-evaluated.
*Optional lossy halving:* **APPLIED 2026-07-04 (Jahni approved visual deltas).** SDFs are
≈unit-gradient, so `Horizontality = clamp(|SDF(Z+1)SDF(Z1)|/2)` — the 4 X/Y samples are gone
(6→2 SDF evals per terrace-voxel); terraces fade slightly differently on SmoothMin'd slopes.
- [x] **`VoxelContentManager.cpp` (BuildCellSpawns)** — slope-gate cosines now precomputed per entry
(`CosMaxSlope`/`CosMinSlope` arrays), bit-identical gating. *(2026-07-04)*
- [x] **`VoxelContentManager.cpp` (LaunchDecoTasks)** — head-index drain + one `RemoveAt(0, Head)`
compaction (was O(N) per pop). Bonus fix: a pending cell still in flight from a previous build is now
DEFERRED, not dropped (dropping stalled its region build forever → permanently blank region). *(2026-07-04)*
- [x] **`VoxelStrateManager.cpp`** — linear `BoundRadius` stored on `FVoxelPassage`; shortlist no longer
Sqrt's per passage. *(2026-07-04)*
## Redundancy (factor out)
- [x] **`VoxelCaveMorphology.cpp`** — Pit/Chimney/Column baking loops → shared `BakeRoomFeature`
hash-placement skeleton (gate/XY/radius chain) + per-type Emit lambda. Bit-identical (same salts,
same hash order). *(2026-07-04, batch 3 — Jahni green-lit batching without intermediate builds)*
- [x] **`VoxelStrateTypes.h`** — `FStrateGenerationParams::Lerp` now expands the
`VF_STRATE_PARAM_FIELDS` X-macro (all 74 fields verified present — no drift had happened yet).
New struct fields MUST be added to that list. Bit-identical. *(2026-07-04, batch 3)*
- [x] **`VoxelWorld.cpp` brush methods** — Carve/Fill sphere now funnel through `ApplyModification`
like Box/Capsule already did (one diff-layer + remesh dispatch). *(2026-07-04)*
- [x] **`VoxelContentManager.cpp`** — duplicated drain/reset loops → `DrainDecoResults()` +
`ResetGridBuildState(FDecoGrid&)`. *(2026-07-04)*
- [ ] **`EVoxelPassageType` vs `EVoxelPassageStyle`** — two overlapping passage-shape enums, both in
active use (11 refs). Consider consolidating to one. *(judgment call, not dead)*
## Dead code
- [x] **`UVoxelMarchingCubesMesher::GetDensity()`** — removed, along with `InterpolateEdge`,
`ComputeGradientNormal` and `GradientOffset` (all dead since T1.b). *(2026-07-04)*
- [x] **Vestigial `MidPoint`**`MidPoint`/`bHasMidPoint` fields + the unreachable debug-draw branch
removed. *(2026-07-04)*
- [x] **Codemap-known dead/legacy**`GetLODForChunk`, `LODToStep`, `IsChunkInRange`, `LOD0Distance`,
`LOD1Distance`, `ContentMaxLevel`, `VoxelChunk.h` (whole file — `FVoxelTileKey` is the identity now)
all removed 2026-07-04. `MaxLODLevel` / `DecorationActorRadiusChunks` were already replaced by the
two-grid deco redesign (`StreamTier` / `DecorationNearRadiusChunks`).
**CORRECTION: `GetStrateChunkZBounds` is NOT dead**`BuildDesiredTiles` uses it for the
strate-aware vertical clamp; struck from the dead list.
## Over-complexity (behavior-preserving splits, optional)
- [ ] `GetDensityWithParams` (~600-1000 L) → `ApplyCaveMorphology`/`ApplySurfaceRoughness`/`ApplyTerrainOps`/`ApplyPostProcess`
- [ ] `BuildChunkCache` (~450 L) → `CollectRooms`/`BuildNeighborGraph`/`ResolveTunnels`/`BakeRoomFeatures`
- [ ] `GenerateMesh` (~250 L) → `PrecalcDensityGrid`/`MarchCells`/`GenerateSkirts`
- [ ] `GetGenerationParams` (~180 L) → extract `ApplyBoundaryTransition(...)`
- [ ] `GeneratePassages` (~150 L) → `ComputePlacement`/`BuildControlChain`/`ComputeBounds`
- [ ] `BuildCellSpawns` (~150 L) → `FindSurfaceCrossings`/`PlaceDecorationsAtCrossings`
---
*Full method notes + raw per-pass output: `E:\LocalLLM\reviews\QUALITY_VoxelForge.md` (+ `QUALITY_pass1/2/3`).*
+267
View File
@@ -0,0 +1,267 @@
# fable-idea.md — performance & feature ideas
*Fable 5, max-effort pass — 2026-06-09. Grounded in a read of the actual code (mesher, apply path, task launch, content manager), not generic advice. Companion to CODEMAP.md §8 — nothing here is implemented; it's a menu.*
*Note: the strict-whitelist `.gitignore` will ignore this file. Add `!fable-idea.md` if you want it tracked.*
---
## Part I — Performance
### 0. Measure before anything (half a session, directs everything else)
Add `TRACE_CPUPROFILER_EVENT_SCOPE` around the four stages of a chunk (density grid sample / MC loop / normals / RMC stream build+upload) plus a `stat VoxelForge` group: chunks pending, applies this frame, avg gen ms, verts/chunk. One Unreal Insights capture then tells us if we're density-bound (my bet) or upload-bound, and every item below gets a before/after number. Cheap insurance against optimizing the wrong thing.
### The verified cost model (what I found reading the code)
A LOD0 chunk today costs roughly:
| Stage | Cost | Source |
|---|---|---|
| Density grid | 33³ = 35,937 `GetDensityAt` | `GenerateMesh` pre-sample (already optimal shape) |
| **Vertex normals** | **+6 `GetDensityAt` per unique vertex** (~1236k more for 26k verts) | `ComputeGradientNormal` — central differences, *per vertex* |
| Heightfield redundancy | SurfaceWorld's XY-only terrain stack (~16 Perlin evals: warp 2 + relief 2 + continents 4 + detail 4 + mountains 4) recomputed **for all 33 Z samples of a column** | `GetSurfaceDensity` is pure per-call |
| Noise core | Scalar, **double-precision** `FMath::PerlinNoise3D(FVector)`, 4-octave loops | `FractalNoise3D`/`RidgedNoise3D` |
| Collision | **Cooked for every chunk at every LOD** | `UpdateSectionConfig(SectionKey, Config, /*bShouldCreateCollision=*/true)` |
| Apply | Unbounded `while (ProcessQueue.Dequeue(...))` drain — all finished chunks upload in one frame; `Enqueue(Result)` copies the whole MeshData; RMC StreamSet built on the **game thread** | `VoxelWorld.cpp:410`, `:602`, `ApplyMeshToChunk` |
| Components | `NewObject`+`Register` per load, `DestroyComponent` per unload (no pooling) | `LoadChunk`/`UnloadChunk` |
So: normals can cost as much as the entire density grid; a SurfaceWorld chunk does ~33× redundant heightfield work; and every distant LOD2 chunk pays Chaos tri-mesh cooking the player can never touch.
### Tier 1 — high win / low risk / small-medium effort (do these first)
> **STATUS 2026-07-05: Tier 1 COMPLETE.** T1.a ✅ (surface-column cache, now file-scope `GSurfColCache`);
> T1.b ✅ (grid-based normals — the old per-vertex `ComputeGradientNormal` trio is gone from the mesher);
> T1.c ✅ (`bShouldCreateCollision` = level 0 only, VoxelWorld.cpp ApplyMeshToTile); T1.d ✅ (v2
> `ClassifyTile`, trace-verified 44 % worker CPU); T1.e ✅ (`MaxMeshAppliesPerFrame`, default 4);
> T1.f ✅ (`BuildTileStreamSet` runs in the gen task; apply only uploads).
> **Per-tile generation cost is now in diminishing-returns territory — remaining perf lives in
> Tier 3, Transvoxel, and the streaming/crossing work (see voxelforge-lod-transition-cost memory).**
**T1.a — Per-column XY cache for heightfield work.** Split SurfaceWorld density into `f_XY` (everything up to `Terrain`, + WaterZ) and a trivial Z-combine. In the chunk task, compute a thread-local `(GridDim+margin)²` column grid once, then `GetDensityAt` reads it. Heightfield evals drop ~33× (36k → ~1.2k). Same pattern later for any archetype with an XY-only sub-field (biome map will be one). Keep it keyed like the existing per-chunk param cache; purity in world coords is preserved so it stays bit-identical and window-invariant.
**T1.b — Normals from the density grid, not 6 fresh samples per vertex.** Sample the grid with a 1-point margin ring — `(GridDim+2)³` = 42.9k at LOD0, +19% — then compute vertex normals by central differences *on the grid* (trilinear-interpolate the 8 cell-corner gradients at the vertex position). Net: ~4872k density calls → ~43k, normals become batchable, and chunk-border shading stays continuous because the margin uses the same pure world-coord samples a neighbor would. Trade-off: gradient resolution becomes `Step` instead of `GradientOffset` — slightly softer normals, smoother (good) at distance. Verify visually at LOD0.
**T1.c — Collision only where it matters.** `bShouldCreateCollision = (LOD == 0)`. Distant chunks are unreachable by definition (if the player got there, they'd be LOD0 — and the LOD reconciliation loop §8.10 guarantees a hot-swap on approach). Kills Chaos cooking + collision memory for the large majority of loaded chunks. Also check RMC's async-collision setting is on. **Likely the best win-per-line-changed in the whole document.**
**T1.d — Chunk classification: skip trivially solid/air chunks before sampling.** ✅ DONE 2026-07-05
(v2 — `UVoxelGenerator::ClassifyTile`, see ARCHITECTURE §8.10; a 2026-06-26 v1 with a global ceiling
bound was reverted for roof holes. Trigger: trace showed 84 % of GenerateMesh calls produced empty
tiles.) In a tall multi-strate world most chunks in the desired set are full bedrock or full sky. Conservative per-chunk test before the 33³ sample: for heightfield strates, min/max terrain over the footprint (free from T1.a's column grid ± noise amplitude bound) vs the chunk's Z range; for cave strates, "no room/tunnel/passage/spine/seal/diff-layer bounds intersect" (all bounding data already exists). Fully-solid/air ⇒ empty MeshData, no component, done. Cuts whole chunks, not percentages — compounds with everything else.
**T1.e — Bound the apply side.** The submit loop is budgeted; the drain loop isn't. Cap mesh applies per frame (~24, generous for carves), keep draining *results* into a pending-apply list sorted by player distance. Also `ProcessQueue.Enqueue(MoveTemp(Result))` — currently the whole vertex/index payload is copied. Smooths the burst hitch (the "upload spikes" already observed).
**T1.f — Build the RMC StreamSet inside the worker task.** `FRealtimeMeshStreamSet` is plain data — the per-vertex Builder loop in `ApplyMeshToChunk` can run in the chunk task; the game thread then only does `CreateSectionGroup(MoveTemp(Streams))` + config. Removes a few ms of game-thread work per applied chunk; pairs with T1.e.
### Tier 2 — multiplicative, more effort
> **STATUS 2026-07-04:** T2.a ✅ DONE (float SSE `VoxelNoise.h` core — see ARCHITECTURE §8.10);
> T2.b ✅ DONE (opt-in `LODOctaveDrop`, default 0); T2.c ✅ DONE (`TileComponentPool`);
> T2.d ✅ DONE (BackgroundNormal priority had already shipped; core-clamp via
> `GetMaxConcurrentTasks`); T2.e ✅ DONE (per-chunk passage shortlist). **Tier 2 complete.**
**T2.a — Float + SIMD noise core (the big multiplier).** Everything funnels into scalar double-precision `FMath::PerlinNoise3D`. Two routes: an **ISPC kernel** (UBT compiles `.ispc` natively — zero third-party deps; used by Chaos/Niagara) or **FastNoise2** (MIT, runtime SIMD dispatch). Evaluate fractal/ridged noise over the whole flat grid / column grid in one batch call per field. Realistic 410× on the noise-bound part, on top of T1.
⚠️ Two correctness notes: (1) world changes for existing seeds — do this *before* content lock-in, bump a generator-version constant; (2) seed offsets like `SeedF * 7.3f` can reach 1e8+ where float precision is ~64 units — hash the seed into a bounded offset range (e.g. [0, 16k]) in the float core or noise quantizes.
**T2.b — LOD-aware octave count.** At Step=4, octaves with wavelength < the cell size are pure aliasing cost. `EffectiveOctaves = Octaves - LODBias(Step)` per field (keep the *low* octaves identical so the coarse shape matches). 3050% off distant chunks; iso-surface shifts by sub-cell amounts that Transvoxel/skirts have to stitch anyway. Cheap, do together with T2.a.
**T2.c — Component pooling + no components for empty chunks.** Recycle `URealtimeMeshComponent`s through a free list on unload instead of `DestroyComponent`/`NewObject`/`Register` churn (T1.d already stops creating them for empty chunks). Reduces GC pressure and register/unregister hitches during fast travel.
**T2.d — Task priority + worker count.** `UE::Tasks::Launch(..., LowLevelTasks::ETaskPriority::BackgroundNormal)` so a 16-task gen burst can't starve game/render workers; consider `MaxConcurrentTasks = Clamp(NumberOfCores - 2, 2, 16)` instead of a flat 16 on smaller CPUs.
**T2.e — Per-chunk passage gather.** `EvaluateModifierSDF` sphere-tests *every* passage per voxel. Gather the passages whose bounds intersect the current chunk once into the thread-local chunk cache; the per-voxel loop walks that short (usually empty) list. Matters as passage counts grow with deeper worlds.
### Tier 3 — when carving becomes the moment-to-moment verb
**T3.a — LRU base-density grid cache for carve re-mesh.** Keep the LOD0 density grid for the ~3264 most recently carved/near-player chunks (~144 KB each ⇒ < 10 MB). A carve then re-meshes as *cached grid + diff + MC* — no noise at all. Dig feedback becomes effectively instant, which is exactly where the game's feel lives.
**T3.b — Streaming feel:** frustum-weighted priority bonus in the submit sort (load what the player looks at first), and the pre-load/unload hysteresis ring already discussed (kill leading-edge pop and boundary churn).
### Explicitly NOT now (and why)
- **GPU density/meshing** — 100× throughput on paper, but a rewrite: readback latency, CPU collision still needed, float determinism across GPUs. North star only if T1+T2 ever hit a wall; they won't for this scope.
- **Octree/adaptive within-chunk structures** — T1.d gets the win at chunk granularity for ~5% of the complexity.
- **CHUNK_SIZE change, Nanite, greedy meshing** — lever already documented (§8.10) / no runtime procedural Nanite / MC isn't blocky.
**Expected compound for a SurfaceWorld chunk: T1.a × T1.b × T2.a ≈ order-of-magnitude on generation; T1.c/d/e attack frame-time and chunk count independently.**
---
## Part II — Features
### A. Tooling first — force multipliers for everything after
**F1 — 2D world-preview editor tool.** ★ my top pick. An editor utility that samples `f_XY` (terrain height, relief M, water mask — later the biome map) over an N×N window into a `UTexture2D`, with the Surface|Macro knobs live. Tuning ReliefStrength/biome layout becomes seconds instead of regen-and-fly. Directly addresses the standing "hard to dial blind" pain (Worm passages, today's Stage 0 knobs). Extend with a top-view passage-path overlay (the data exists in `bDebugDrawPassages`).
**F2 — Determinism validator button.** ✅ DONE 2026-07-04 (`AVoxelWorld::ValidateDeterminism`,
Live Edit category). CallInEditor: sample a band of densities from two different chunk-window alignments, diff, report max delta. Turns the scariest invariant (§8.4 — window invariance) into a one-click regression test *before* biome code starts landing.
**F3 — `stat VoxelForge` + Insights scopes.** Same as Perf §0 — listed here because it's also the tool that tells us when a feature regressed something.
### B. The "this becomes a game" features
**F4 — Save/load.** The diff layer is the player's entire footprint and it currently dies with the session. Serialize: seed + settings hash + generator version + per-chunk modification lists (they're compact structs already). Versioning matters: stamp saves with a gen-version so a noise change (T2.a!) can refuse/migrate old saves instead of silently shifting terrain under bases.
**F5 — Biome system. ✅ DONE (warped-Voronoi + climate XY field, per-chunk resolve —
`ResolveBiomeSampleAt` / `FBiomeContext`, VoxelGenerator.cpp §biomes; deco borders follow the
Voronoi field).** Original sketch (kept for the cave-biome extension): Deterministic XY biome map = warped Voronoi/cellular cells (seeded, window-invariant by construction, same family as the relief map — and relief M should be an *input*: mountain biomes live where M is high). Resolution rules to protect §8.10: resolve **per chunk** (dominant biome + ≤2 neighbors + blend weights, stored in the thread-local chunk cache); per **voxel** blend only a handful of scalars (height offset, roughness, terrace, water tint index). Per biome: a *content profile* — decoration set, atmosphere/audio override, material palette index, water level offset. Archetype transitions stay Hard; biomes vary *within* SurfaceWorld first, cave-biomes (crystal/fungal/ice) reuse the identical pattern later.
**F6 — Material identity: vertex-data masks + triplanar palette material.** Geometry variety without *surface* variety still reads samey. At mesh time, pack per-vertex: slope (from the T1.b normal), relative height, biome/material index (from F5) into vertex color channels. One master material: triplanar rock/grass/sand/snow layers selected & blended by those masks + a macro-variation texture. This is the single biggest *visual* multiplier available and it's mostly material-graph work.
**F7 — POI / set-piece system.** Noise terrain everywhere = beautiful nowhere. Deterministic destinations: chunk-hash-placed stamps (composed SDF carves/fills + a decoration prefab + optional ambient actor), e.g. buried shrines, crystal gardens at passage mouths, ruins on mesas. Placement uses the same two-region COLLECT discipline as rooms (§8.4). Destinations are what turn wandering into stories ("found a shrine at 400 m").
**F8 — Ore veins / diggable resources.** The game's verb is digging; give digging a reward loop. A secondary material-id field (cheap 3D noise threshold, per-strate/biome tables with depth curves) evaluated **only at mesh vertices** (≈ free) → vertex color → material shows veins; on carve, query the field at the brush center → grant resource. No per-voxel density cost, fully deterministic.
**F9 — Audio/ambience manager.** The exact architectural twin of the atmosphere manager (player strate/biome → assets): ambient loop crossfade, cave reverb submix, dig impacts by surface type, a stinger + title card on first strate entry. Sound is half of cave atmosphere and this is days, not weeks.
**F17 — Generator surface-class tag (ceiling/ground/cave material the *right* way). ✅ DONE 2026-07-05** (per-vertex semantic class in the mesher — down-facing verts query a memoized `GetSurfaceHeightAt`, nearer CeilSurf ⇒ sky-cap — per-tri majority → two contiguous polygroup runs → RMC section per group, slot 1 = `CeilingMaterial`, per-section shadow. Trigger: the whole-tile normal vote painted mixed coarse tiles with one material. Cave-roof discrimination hook is in place: down-facing below TerrainZ ⇒ ground/rock. Remaining polish idea: fully sideways cap-fold tris (all 3 verts |N.Z|≤0.1) default to ground.) Original design note: ★ do this when caves land. Today `ApplyMeshToTile` picks one material per tile from a ceiling test — first a height-oracle sample (midpoint), now a worker-side **normal vote** over the tile's mesh normals (down-facing ⇒ `bIsCeiling``CeilingMaterial` + no shadow; gated to SurfaceWorld by one `GetSurfaceHeightAt` probe). That's a **stopgap that only works because down-facing == sky-cap *while no caves exist*.** The moment a mountain-biome cave uses the same density/mesh system, its roof is also down-facing and would wrongly get the sky-cap material — orientation can't tell a cave ceiling from a surface ceiling. **The discriminator is semantic, not geometric, and only the generator knows it:** a sky-cap surface is the `ComputeSurfaceCeiling` (`CeilSurf`) boundary; a cave ceiling is a 3D-noise **carve** below `TerrainZ`. Cheap test the generator already has the inputs for — at a down-facing surface vertex, compare world Z to the column's `TerrainZ`/`CeilSurf` (both from the surface-column cache the mesher already holds): near `CeilSurf` ⇒ sky-cap, below `TerrainZ` ⇒ cave. **Plan:** stamp a discrete *surface class* (ground / sky-cap / cave-ceiling / cave-wall…) per vertex/triangle **at mesh time** in the mesher → carry it as the **polygroup** (already enabled, `Builder.EnablePolyGroups()`, every tri currently group 0) → `ApplyMeshToTile` maps polygroup → material slot (slot 0 terrain, slot 1 sky-cap, slot 2 cave-rock, biome-specific via the F5 palette mask) and sets per-section shadow. Discrete "which material" → polygroup/slot; continuous masks (biome blend, slope — F6) stay in `Colors`. This **subsumes** the current ground/ceiling split (it falls out as a special case), fixes the coarse mixed-tile horizon artifact exactly (per-triangle, not per-tile dominant-wins), and is the only version that survives caves. Pairs naturally with F5/F6/F8 (all want generator-stamped per-vertex material identity). Cost: a per-tri classify in the mesher (cheap, has the cache) + multi-slot setup in the apply path (RMC supports it; confirm the v5 per-section `UpdateSectionConfig` / slot-per-polygroup calls + empty-polygroup = no draw). Until then: the normal vote is fine — it's commented as "no caves yet → down == cap."
**F18 — Far-field per-surface SHEETS (the render-distance ring, cheap). ✅ code-complete 2026-07-06
(pending build).** With `RenderDistanceChunks` the
outermost ring can reach many km — as MC tiles that's 600-1000 primitives paying per-frame visibility/VSM
forever, and each far tile runs full 3D marching cubes just to rediscover two heightfields. In an open
strate the far field IS two heightfields the generator already computes per column (`GetSurfaceHeightAt`:
TerrainZ + CeilSurf). So: the extended ring streams SHEET tiles instead (level `MaxClipLevel +
FarSheetSpanLevels`, so one sheet covers 4-16 MC-tile footprints) and the mesher builds each as two
regular displaced grids — ground sheet (polygroup 0) + sky-cap sheet (polygroup 1), tags true **by
construction** (no vote, no classify probes), same materials/UVs/biome color masks as MC, normals from
the height gradient, perimeter skirts per bucket. Gen ~3-6× cheaper per area than band-cut MC; primitives
~10-16× fewer. Caveats accepted: SurfaceWorld only (non-open strates produce empty sheets — their far
ring was invisible rock anyway; FloatingIslands has no far ring beyond MaxClipLevel), carved features
(passages/spine/chasms) don't show at sheet distance, sheets need the strate band armed (in the
inter-strate gap the far ring blanks until you land). Streaming: sheet ring in `BuildDesiredTiles`
(covered-check vs the MaxClipLevel box), `IsTileInClipRange` shares the same outer shell,
`LoadTile` routes `Level > MaxClipLevel` to `GenerateSheetMesh`. **Build-1 fix (XY hole):** a
partially-covered sheet rendered its *whole* footprint, overlaying the near LOD0-1 terrain with its
coarse sampling. So the MC-covered box around the player (level-MaxClipLevel box, shrunk 1 tile for a
seam-overlap ring) is cut from sheets at cell granularity (`AVoxelWorld::SheetHole*Vox``GenerateSheetMesh`
hole args; hole moves on a MaxClipLevel-tile crossing → overlapping sheets re-queue via `BandRemeshQueue`;
hole-edge cells emit no skirt).
**F19 — AI navigation & agents (function-based). PARKED (no NPCs yet); FOUNDATION BUILT 2026-07-07.**
Mobs need two things the world didn't give them: (1) to *exist* away from the local player, (2) to *route*.
- **(1) DONE — multi-anchor collision streaming + render-skip** (ARCHITECTURE §9.3/§9.4, built 2026-07-07):
`AVoxelWorld::RegisterStreamingAnchor(actor, CollisionOnly, thin box)` keeps a small box of level-0
collision tiles loaded around any actor; `CollisionOnly` tiles cook collision but are hidden (no draw/VSM)
unless the player clipmap also wants them. So an NPC/remote-player has ground to stand on / be hit on,
cheaply, anywhere. Same system serves MP (stream around every player) — [[voxelforge-multiplayer]] §9.
- **(2) TO BUILD — routing, function-based (NOT Recast).** The world is a cheap deterministic function, so
nav = a query, not a baked navmesh: coarse A* / flow-field over a grid sampling `GetSurfaceHeightAt`
(slope + water gated, + the diff layer so AI sees carves) → **funnel/string-pull****Catmull-Rom spline**
→ a **steering follow-component** for smooth, non-robotic, cheap locomotion (path once, re-path on a timer;
budget requests like the streaming loop). Needs **zero loaded geometry**, deterministic (same seed = same
path), and **digging costs it nothing** (no per-carve re-cook). Bridgeable to `AIController`/`CharacterMovement`.
- **Analytic surface-follow (the free tier):** a pure *surface walker* needs no collision AND no navmesh —
pin it to `GetVoxelSurfaceHeightAt` each tick. Reserve real collision (the anchor) for physics / caves /
carved terrain / being hit by player traces. Both coexist, decided per NPC type.
- **Recast rejected:** would need cooked collision everywhere AI roams, re-cooking on every dig — and it's
unusable on a future headless dedicated server (no meshes), where function nav is not just cheaper but
mandatory. AI is server-authoritative (§9.6). ★ Build when NPCs actually land.
**F20 — Biome-selected surface terrain ops (terrace / cliff / layer-lines / overhang / spike / hole). SPEC 2026-07-07.**
Terrain ops are cave-only today (per-room in `GetDensityWithParams`; `GetSurfaceDensity` applies NONE — that's
the "ops don't work on the surface" report). This brings them to the SURFACE, as a **biome** property,
**conditioned on local terrain** so they read geological instead of random. (Slots in ahead of the later
cave-system redo, which will add biome support cave-side reusing this same op→biome model.)
*Data:* add `TArray<FStrateTerrainOpEntry> SurfaceOps` to `UVoxelBiomeDefinition`, each entry gated by a
condition (Min/MaxSlopeAngle like `FStrateDecoration` already has, + optional relief/height band via the F7
`FTerrainCondition` set — add a `Slope` type). Resolved through the biome field (dominant biome per column —
already cached). Empty ⇒ early-out ⇒ **zero cost**, so the feature is free in every biome that doesn't use it.
*The cheapness architecture (the whole point — think per-COLUMN first, per-voxel only when forced):*
- **Two op classes.** HEIGHTFIELD ops modify the cached column → **~free**: **Terrace** (quantize TerrainZ into
steps), **Cliff** (sharpen the height transition where slope is high), **LayerLines/Ribbing** (a `sin(Z)`
groove in the near-surface band — no noise). VOLUMETRIC ops need genuine 3D near the surface → real but
bounded: **Overhang**, **Spike**, **Hole**.
- **Slope/relief conditioning is free AND is what makes them look right.** Slope = the surface height gradient
(2 extra cached-column samples, or reuse the mesh normal / F6 slope channel). Overhang strength ∝ slope ⇒
overhangs grow out of EXISTING cliffs, never poke out of flat ground (the "would it look weird" answer: no,
it's cliff-conditioned, not random). Terrace on moderate slopes; spikes where relief M is high (mountains).
Conditions cost ~0 (fields already computed) and double as the "not random" guarantee.
- **Volumetric work is banded + tile-skipped.** Overhang = low-freq 3D displacement only in a ±few-voxel band
around TerrainZ, only where slope-gated + the biome has it. Non-straddling tiles never pay (T1.d ClassifyTile).
Coarse/far tiles stay pure heightfield (LOD-cull the 3D ops — they're near-field detail; the sheet ring ignores
them entirely).
- **Spikes/holes = hash-placed SDF via a per-tile shortlist** (cone/capsule UP for spikes, shaft DOWN for holes;
placed on a hash lattice like landmarks, collected once per tile like rooms/passages; per-voxel tests a 0-3
shortlist). Biome+condition-gated so empty biomes collect nothing.
- **GOTCHA — the dominant cost, spikes/holes only:** a spike rises INTO otherwise-all-air tiles and a hole carves
INTO otherwise-all-solid tiles → they DEFEAT T1.d's trivial-skip for every tile they pass through (those must
now mesh). So ClassifyTile needs a spike/hole shortlist guard (cheap AABB, like `AnyPassageNearBox`), and a tall
spike "wakes up" the whole vertical stack of air tiles it crosses = more meshed tiles + collision. Overhang/
terrace do NOT do this (they stay in the already-meshed surface band). ⇒ keep spikes SHORT + SPARSE; they're the
priciest of the set in tiles-meshed + triangle/collision terms, not just density evals.
*Cost:* heightfield ops ≈ free. Overhang ≈ 1.5-2× noise on slope-gated surface tiles, ~0 elsewhere. Spike/hole ≈
that PLUS the woken tiles (the real cost — budget by count/height). All biome-gated ⇒ world-average cost ≈ 0;
you pay only near the player, in the biomes that opt in.
*Determinism/borders:* op strength blends by biome weight (like the surface height output-blend), conditioned ops
fade with slope ⇒ seamless at biome borders; placement hashes are pure `(seed, coord)` ⇒ window-invariant (§8.4).
*Build phasing:* **(1)** heightfield ops (terrace/cliff/layerlines) — cheap, high payoff, low risk, ships the
"ops finally work on the surface" win; **(2)** overhang (3D band, slope-conditioned); **(3)** spike/hole (placed +
shortlist + ClassifyTile guard) — most cost, do last.
**PHASE 1 — CODE-COMPLETE, PENDING BUILD (2026-07-08).** Heightfield ops shipped: **Cliff** (slope-gated STEEPENING —
push height from the local mean where steep ⇒ sheer walls; the slope-conditioned one, hugs steep terrain;
v1 band-snap was too subtle, reformulated to steepening after Jahni's "doesn't change much"), **Terrace** (relief-gated plateau quantize, now with
`TerraceHardness` soft-round↔crisp-mesa), **LayerLines** (sedimentary sine shelves, slope-expressed). *Design
deviation from the spec above, deliberate:* instead of a `SurfaceOps` array of `FStrateTerrainOpEntry` +
`FTerrainCondition` gating on `UVoxelBiomeDefinition`, phase-1 ops are **direct fields on
`FSurfaceGenerationParams`** (the `Surface|Ops` category). Rationale: that struct is ALREADY the per-biome
surface-shape carrier (`B->SurfaceParams` when `bOverrideTerrain`) and is already biome-resolved +
border-blended by `ResolveSurfaceChunkParams`/`ComputeSurfaceColumn` (the height output-lerp) — so biome
selection AND seamless border blending come for FREE with zero new resolution path, and the conditioning
(relief `M`, analytic slope) is intrinsic to each op. Cost: a biome must set `bOverrideTerrain` to carry its own
ops (fine — biome-differentiated terrain already implies that), and a strate can also carry ops with no biomes at
all (more flexible than biome-only). All fields default OFF ⇒ current world byte-identical. Applied in the single
height oracle `ComputeSurfaceTerrainZ` (new `SampleSurfaceStructuralZ` helper = pre-op raw height, re-sampled at
an XY offset for Cliff's slope) so MC/sheets/ClassifyTile/deco/BP-bridge all agree, no T1.d interference. Revisit
the array+condition model for **phase 2 (overhangs)** where per-entry slope-gating earns its keep.
**PHASE 2 — CODE-COMPLETE, PENDING BUILD (2026-07-08).** Overhang (first VOLUMETRIC op) as
`FSurfaceGenerationParams` fields (`OverhangStrength/Reach/Height/Frequency/ZScale/SlopeThreshold`, default
off). **Design NOTE — v1 additive-noise-band was WRONG (Jahni: "does nothing" + sketch of a real cliff lip):
band-additive noise can only bump the surface where it already is, never make rock jut OUT over a void.**
Rewritten to a **warped-terrain UNION**: for air voxels in `(TerrainZ, TerrainZ+OverhangHeight]` above a
steep slope, re-sample the heightfield UPHILL by a reach that GROWS with height (tiny low ⇒ air over the
void; full high ⇒ borrows the far cliff rock) and `max()` it in ⇒ a shelf attached to the cliff, tapering
out over the void with air beneath (matches the sketch). Per-column `OverhangAmp`(=strength·slope-gate) +
unit uphill `(DirX,DirY)` resolved in `ComputeSurfaceColumn` — gradient sampled at the REACH scale so a
point over the void can SEE the cliff — cached on `FSurfaceColumn`. **ClassifyTile guard:**
`FSurfSlot::OverhangMargin`=max `OverhangHeight`; a column Z in `(TerrainZ, TerrainZ+margin]` ⇒ Mixed
(UPWARD only — the union only adds rock). Thin cliff-edge band woken, NOT far tiles. Genuine 3D per-voxel
structural re-eval (gated hard to steep overhang columns → localized to cliff edges; flagged as a real but
bounded cost). KNOWN v1 LIMITS (flagged): applies at ALL LODs (may alias far — gate to fine later); shelf
sits at ~`OverhangHeight` above the ground below it, not necessarily at the cliff TOP (raise Height for
taller); terrain-overriding biomes use strate params for the warped sample (minor seam). NEXT = **phase 3
spike/hole** (hash-placed SDF + per-tile shortlist + the real T1.d wake-guard — keep SHORT+SPARSE).
### C. Experience polish (cheap, high feel-per-effort)
- **F10 — Swimmable water:** physics volume + underwater post-process tied to the existing water-chunk regions (the plane is visual-only today). Buoyancy later.
- **F11 — Depth & place HUD:** depth meter, strate name title cards (the atmosphere manager already detects strate change), simple explored-chunks map.
- **F12 — Day/night + weather on strate 0 only** — surface gets a sky lifecycle; underground untouched (free scoping).
- **F13 — Carve UX:** runtime brush ghost preview, tool tiers (radius/speed), material-aware dig speed (bedrock slow), rockfall-dust juice on carve.
- **F14 — The (0,0) spine as gameplay:** buildable lift/teleport anchors per strate — descent is the game, but re-ascent shouldn't be the chore. BP prototype on the existing carve/actor APIs.
- **F15 — Ambient life:** Niagara bats/fireflies/fish schools per biome profile (no AI, pure atmosphere). Real mobs = **F19** (nav strategy now decided: function-based).
- **F16 — Decorations as HISM: ✅ DONE** (region-granular HISMs, two-grid Near/Far streaming, Static mobility). Original note: scatter currently `SpawnActor`s every prop — actors tick, register, and pile up fast. Pure props should be `UHierarchicalInstancedStaticMeshComponent` instances (per mesh type, per chunk); keep actors only for lit/interactable things. This is also a perf item wearing a feature hat.
### D. Deliberately deferred
Multiplayer — **NO LONGER just "deferred": it's the confirmed direction (listen-server first), design + first foundation IN.** Full model in ARCHITECTURE §9 ([[voxelforge-multiplayer]]): determinism = replicate seed+layout+diff events, never geometry; server-authoritative diff; multi-anchor streaming + §9.4 render-skip BUILT 2026-07-07. Remaining netcode (§9.7): seed replication at join, `Server_RequestModification` RPC, late-join diff snapshot. Diff records stay compact/replicatable-shaped (they already are). — GPU generation, mod/scripting API, Nanite: all real, none load-bearing for the current vision.
---
## Suggested order (if it were mine to pick)
1. **Perf 0 + T1.c + T1.a + T1.b** — one focused session: measurement, the one-line collision win, the two big density cuts.
2. **T1.e + T1.f + F16** — smooth the game thread (apply budget, worker-side streams, HISM props).
3. **F1 preview tool + F2 validator** — before biome work starts, build the instruments.
4. **F5 biomes + F6 materials** — the look of the game. (F9 audio rides along cheaply.)
5. **F4 save/load** — the moment it feels like a game, players will want to keep one.
6. **F7 POIs + F8 ores** — destinations and rewards.
7. **T2.a SIMD noise** — after content direction settles (it changes seeds), before world-size ambitions grow.
8. **Transvoxel** (already chosen) whenever LOD cracks become the loudest remaining flaw.