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:
+3
-1
@@ -10,4 +10,6 @@
|
|||||||
!/Source/**
|
!/Source/**
|
||||||
|
|
||||||
!VoxelForge.uplugin
|
!VoxelForge.uplugin
|
||||||
!CODEMAP.md
|
|
||||||
|
# Keep every design / doc markdown at any depth (AUDIT P1 — these were untracked)
|
||||||
|
!*.md
|
||||||
+754
@@ -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 8–16 for a cheap coarse FAR grid; the rare-prop lever), `DecorationRegionSizeCells` (4 — RxR cells per
|
||||||
|
region/HISM), `DecorationMarchStepVoxels` (2 — coarse, bisection-refined; cave march only), `DecorationMaxCrossingsPerColumn`
|
||||||
|
(4 — cave march only), `DecorationColumnDepthVoxels` (160 — bedrock march cap; cave march only),
|
||||||
|
`MaxDecorationCellsPerFrame` (2 — apply/spawn budget), `MaxConcurrentDecorationTasks` (4 — in-flight task cap;
|
||||||
|
0 disables decorations). **COST:** surface worlds now use the O(1) oracle (cheap); caves ray-march. The work is
|
||||||
|
OFF the frame (worker threads) — game thread only pays the budgeted spawn. If streaming slows, lower
|
||||||
|
`MaxConcurrentDecorationTasks` / raise `DecorationMarchStepVoxels` / shrink radii/spacing. Default
|
||||||
|
`DecorationRadiusChunks=6` ≈ props ~48 m out — raise for far flora (cost ~r²).
|
||||||
|
|
||||||
|
**(A2) LANDMARKS — rare large objects on a COARSE HASH LATTICE (`UpdateLandmarks`, 2026-06-26).** The right
|
||||||
|
primitive for sparse, far-visible objects like the underground "mini-suns" — where the per-chunk decoration
|
||||||
|
grid fails: at a 2048-chunk radius that grid enumerates ~13M cells per cell-crossing on the game thread and
|
||||||
|
FREEZES. Landmarks instead live on a per-entry hash lattice (cell = `FStrateLandmark::SpacingChunks` chunks),
|
||||||
|
so a radius-R disk holds only ~(R/Spacing)² candidates (≈16 at R=2048, Spacing=512). Listed strate-wide on
|
||||||
|
`UVoxelStrateDefinition::Landmarks`. Each Tick, for each entry, walk the small lattice box around the player
|
||||||
|
within `StreamRadiusChunks`: `DecoHash(cell,entry,seed)` rolls existence (`SpawnProbability`), a jittered XY
|
||||||
|
(`JitterFraction` — effective min spacing ≈ Spacing·(1−Jitter)), then a SINGLE-column surface find
|
||||||
|
(`FindLandmarkColumn`: SurfaceWorld height oracle, else one density ray-march) snaps to the chosen
|
||||||
|
`SurfacePlacement` (default Ceiling = sky-cap). Gates mirror decorations (biome via `GetDominantBiomeAt`,
|
||||||
|
slope band, water-relative). Foliage-style transform tweaks: `LocationOffset` (world XYZ), `RotationOffset` +
|
||||||
|
per-axis `RandomRotation`, `Min/MaxScale`, `bAlignToSurface`. Spawned as a real actor (`ActorClass`, for a
|
||||||
|
sun's light) or one Static `UStaticMeshComponent` (`InstancedMesh`, `CullDistance`=0 → never cull). All
|
||||||
|
SYNCHRONOUS on the game thread (so few candidates it never hitches); a cell's surface-find runs only the
|
||||||
|
first frame it enters range, then is cached in `LandmarkInstances` (keyed `FIntVector(cellX,cellY,entry)`,
|
||||||
|
null entry = "evaluated, nothing placed" so it isn't retried). Deterministic (hash → no pop, infinite reach).
|
||||||
|
Strate-bounded (wiped on strate change, like decorations). This is the real home for "rare prop at all
|
||||||
|
distances" — the job the decoration FarGrid could approximate at moderate range but not at extreme radius.
|
||||||
|
|
||||||
|
**(B) WATER — tile-driven, level-0 only (continuous plane, never pops).** `PopulateTileWater(tile)` in
|
||||||
|
`ApplyMeshToTile` (level-0 tiles), `ClearTileWater(tile)` in `UnloadTile`. One scaled engine plane
|
||||||
|
(`/Engine/BasicShapes/Plane`) per water-surface chunk (per-chunk-Z plane logic assumes a single chunk's
|
||||||
|
vertical span — hence level-0 only), keyed `TMap<FIntVector, UStaticMeshComponent*>` (reflected UPROPERTY).
|
||||||
|
Water Z: `bHasWater` + `WaterLevelRelative` → `StrateManager::GetWaterLevelWorldZForChunk`. Biome
|
||||||
|
`WaterMaterial` overrides `UVoxelStrateDefinition::WaterMaterial` (level stays strate-global).
|
||||||
|
|
||||||
|
`ClearAll`/`SetSeed` on `ChangeSeed`/regenerate clears both subsystems (decorations re-stream on the next
|
||||||
|
Tick via the INT_MIN sentinels). **Per-biome content (§8.14):** decorations resolve the dominant biome **PER COLUMN** on the worker
|
||||||
|
(`ResolveBiomeSampleAt` via the strate's `FBiomeContext`, box-cached → one rebuild per chunk footprint). The
|
||||||
|
update builds ONE flat decoration palette (every biome's list concatenated; `CurrentEntryBiome[i]` tags entry
|
||||||
|
`i` with its context-biome index, -1 = strate fallback), and `PlaceAtCrossing` rolls only the entries the
|
||||||
|
column's biome owns. This replaced the old per-CELL `GetDominantBiomeAt` (one biome for a whole 8 m cell →
|
||||||
|
axis-aligned border snapping); borders now follow the warped-Voronoi field at column resolution, no straight
|
||||||
|
lines. Water still uses the chunk-centre `GetDominantBiomeAt` for its material. `Initialize` now also takes
|
||||||
|
`UVoxelSettings*` (for the grid tunables). `ContentMaxLevel` is now legacy/dead for decorations.
|
||||||
|
|
||||||
|
### 8.6 Atmosphere — `VoxelAtmosphereManager.h/.cpp` (NEW)
|
||||||
|
`UVoxelAtmosphereManager` (owned by `AVoxelWorld`, gated by `bManageAtmosphere`).
|
||||||
|
`UpdateForPlayer(pos)` each Tick, reacts only on strate change. Drives a managed
|
||||||
|
`UExponentialHeightFogComponent` + movable `USkyLightComponent` from the player's strate
|
||||||
|
(`FogColor/FogDensity/bVolumetricFog/AmbientLightColor/AmbientLightIntensity`), and spawns
|
||||||
|
PERSISTENT ceiling/floor "layer" actors (`Def->CeilingLayerActor`/`FloorLayerActor` + ZOffsets
|
||||||
|
+ rotations) that follow the player in XY — the sky-island sea-of-clouds / two-sided fog.
|
||||||
|
`Def->AtmosphereActor` (a full BP with your own fog/sky/postprocess) OVERRIDES the managed
|
||||||
|
fog+sky for that strate. `Reset()` on ChangeSeed/EndPlay. (Skylight ambient underground is
|
||||||
|
weak — captures a dark scene; fog is the strong visual.)
|
||||||
|
**Per-biome atmosphere (§8.14):** `UpdateForPlayer` also resolves the player's dominant biome and,
|
||||||
|
when the biome has `bOverrideAtmosphere`, its fog/sky beats the strate's (reacts on biome change, not
|
||||||
|
just strate change). `ApplyFogSky(Def, Biome)` is the shared path; layer actors + the full `AtmosphereActor`
|
||||||
|
BP stay strate-level. Needs the generator injected (`Initialize(..., Generator)`).
|
||||||
|
|
||||||
|
### 8.7 Inter-strate bedrock gap
|
||||||
|
`VoxelSettings::InterStrateGapChunks` (N) inserts N chunks of SOLID bedrock between consecutive
|
||||||
|
strates (`StrateManager::Initialize` leaves the gap in the layout). `IsGapChunk` detects it;
|
||||||
|
`GetDensityAt` renders gap chunks as solid + passages only (no caves/spine/seal) so the player
|
||||||
|
digs (0,0) through the gap to descend. `GetStrateUnrealZRange` gives a strate's cm Z range.
|
||||||
|
|
||||||
|
### 8.8 Inter-strate passages — PER-STRATE (`FStratePassageConfig` on the definition)
|
||||||
|
Each strate's `PassageConfig` (VoxelStrateTypes.h) controls its descent tunnels to the layer
|
||||||
|
below: `Connections`, `Style` (`EVoxelPassageStyle`: Straight/Worm/Spiral/Cascading),
|
||||||
|
`MouthRadius`/`MidRadius` (tapered width → `FVoxelPassage::ControlRadii` + `VoxelSDF::TaperedCapsule`),
|
||||||
|
`ReachMin/Max` (depth into each strate), `DistanceMin/Max` (from the (0,0) spine), `Wander`,
|
||||||
|
`Segments`, `VerticalWobble`, Spiral/Cascade params. Built in `StrateManager::GeneratePassages`
|
||||||
|
as control-point chains. **Worm = independent fBM per horizontal axis** (`PassageFBM` static)
|
||||||
|
with a flat-top envelope → organic squirm (NOT a 1D zigzag, NOT a same-freq 2-channel spiral).
|
||||||
|
`EvaluateModifierSDF` (per voxel) first builds a `thread_local` **per-chunk shortlist** of passages
|
||||||
|
whose bounds reach this chunk (rebuilt on chunk change / `PassagesVersion` bump) — chunks with no
|
||||||
|
passage near return `FLT_MAX` immediately — then **bounding-sphere-culls** each shortlisted passage
|
||||||
|
(`FVoxelPassage::BoundCenter/BoundRadiusSq`). Both are perf-critical (§8.10). The (0,0) surface entry
|
||||||
|
is a simple straight tube. 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.
|
||||||
@@ -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.a–f and T2.a–e 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.2–5.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)*
|
||||||
@@ -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
@@ -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`.*
|
||||||
@@ -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`.
|
||||||
|
|
||||||
|
---
|
||||||
@@ -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:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| ~1–3 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.
|
||||||
@@ -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(Z−1)|/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
@@ -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** (~12–36k more for 2–6k 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: ~48–72k 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 (~2–4, 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 4–10× 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). 30–50% 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 ~32–64 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.
|
||||||
Reference in New Issue
Block a user