Compare commits
73 Commits
main
...
e55f12a9de
| Author | SHA1 | Date | |
|---|---|---|---|
| e55f12a9de | |||
| b533294f5d | |||
| ce409e7bb1 | |||
| 46b507a261 | |||
| 6a8390f11f | |||
| 239c037f35 | |||
| 353d504169 | |||
| 64d0e11821 | |||
| 10dbe64b06 | |||
| e479fcdf7d | |||
| 9591088d34 | |||
| 8a303cc085 | |||
| b063d437c1 | |||
| 6ec60099d6 | |||
| ab1a996cc5 | |||
| 6e29cbfe4c | |||
| 7a87cdda14 | |||
| 473bccb43f | |||
| b888b86729 | |||
| 68e43238bd | |||
| 5bf61c1815 | |||
| ef5bda3d8a | |||
| 96e75abe57 | |||
| f5d5a03ad1 | |||
| d0a9ce3018 | |||
| 3acb3fbc6b | |||
| f3faa3b5c2 | |||
| c277931a08 | |||
| 1a3f6b6a72 | |||
| 6ad9f37a65 | |||
| 353c023dfd | |||
| cd4cf216f5 | |||
| f7ed9407bf | |||
| 4dc55b1af3 | |||
| 7cd2bed237 | |||
| 2b2afacd9e | |||
| 921a9fb666 | |||
| 25df4fceff | |||
| 6a60732c98 | |||
| f1fd1e0b05 | |||
| 51d8db842b | |||
| 85993199fb | |||
| 644339def5 | |||
| 974e795b66 | |||
| f7cccb044b | |||
| 256a262140 | |||
| 34f06dfea7 | |||
| b6ccb1c9f1 | |||
| 7189d51b7b | |||
| 34f8ca7953 | |||
| 210602586e | |||
| d5c71d6b68 | |||
| a49d440bf6 | |||
| cca9e83182 | |||
| 0379d59c1c | |||
| af5f2103b3 | |||
| 23605d5350 | |||
| 826a8c99dc | |||
| 62e3d5a933 | |||
| 8a33bcb42a | |||
| 4c53d3bbed | |||
| 3e4ee198fe | |||
| beb66e06d4 | |||
| f8194dbf92 | |||
| 6267af86a7 | |||
| 831ee2fbf7 | |||
| c188ee8262 | |||
| a820f140e1 | |||
| b4d13e09ad | |||
| 73f6b26f4d | |||
| d41d34ecd3 | |||
| 6eec796403 | |||
| 3128852d4e |
+3
-1
@@ -10,4 +10,6 @@
|
||||
!/Source/**
|
||||
|
||||
!VoxelForge.uplugin
|
||||
!CODEMAP.md
|
||||
|
||||
# Keep every design / doc markdown at any depth (AUDIT P1 — these were untracked)
|
||||
!*.md
|
||||
+762
@@ -0,0 +1,762 @@
|
||||
# VoxelForge — Architecture & Design Deep-Dive
|
||||
|
||||
> The 2026 redesign in detail: archetypes, the (0,0) spine, disturbances, content/atmosphere,
|
||||
> biomes, and the **performance invariants** (the `§8.10` "don't regress" list). Read this
|
||||
> before touching generation / strates / passages.
|
||||
> Navigation, file index & conventions live in [CODEMAP.md](CODEMAP.md). Section numbers (`§8.x`)
|
||||
> are preserved so existing cross-references keep resolving.
|
||||
|
||||
## 8. Archetypes, spine, disturbances, content & carving (2026 redesign)
|
||||
|
||||
A large A-to-Z expansion. The world is a stack of strates the player descends through;
|
||||
each strate can be a fundamentally different *archetype*, connected at (0,0).
|
||||
|
||||
### 8.1 Archetypes (`ECaveGeneratorType`, VoxelStrateTypes.h)
|
||||
Each archetype has its own param `USTRUCT` (on `UVoxelStrateDefinition`, EditCondition-gated
|
||||
by `GeneratorType`) and its own density function in `VoxelGenerator.cpp`, dispatched by the
|
||||
`switch` in `GetDensityAt`.
|
||||
|
||||
| Archetype | Params struct | Density fn | Idea |
|
||||
|-----------|---------------|------------|------|
|
||||
| TunnelNetwork | `FStrateGenerationParams` | `GetDensityWithParams` | rooms+tunnels (original) |
|
||||
| FlatPlain / CrystalChamber | `FSlabGenerationParams` | `GetSlabDensity` | floor/ceiling void (original) |
|
||||
| Maze | `FMazeGenerationParams` | `GetMazeDensity` | tight corridors on a 3D lattice (per-voxel, no cache; edge = lower node + axis hash) |
|
||||
| SurfaceWorld | `FSurfaceGenerationParams` | `GetSurfaceDensity` | heightfield terrain: domain-warped continents+ridged mtns+detail, a low-freq **relief map** (`M`) that scales mountains/elevation for plains↔highland variety, **F20 heightfield terrain ops** (`Surface|Ops` — all default off ⇒ byte-identical): **Cliff** (slope-gated steepening — where the analytic structural slope > `CliffSlopeThreshold`, push the height away from the local mean by `CliffSharpness` ⇒ gentle slopes become sheer walls/canyon faces that hug real steep ground, gentle areas untouched; 4 structural resamples only when enabled), **Terrace** (relief-gated plateau quantize + `TerraceHardness` soft-round↔crisp-mesa), **LayerLines** (sedimentary sine shelves, slope-expressed) — pure per-column height remaps in the single oracle `ComputeSurfaceTerrainZ` (`SampleSurfaceStructuralZ` = pre-op raw height, re-sampled at an XY offset for Cliff's slope), biome-selected + border-blended for free via each biome's `SurfaceParams` + the height output-lerp; plus **phase-2 Overhang** (the first VOLUMETRIC op — real jutting shelves like a cliff lip): in `SurfaceDensityFromColumn`, for AIR voxels in the window `(TerrainZ, TerrainZ+OverhangHeight]` above a steep slope, the heightfield is re-sampled UPHILL (toward the cliff) by a reach that GROWS with height and unioned in. Low in the window the shift is ~0 (borrows nearby low rock ⇒ stays air over the void); high up it reaches the far cliff (solid) ⇒ a shelf attached to the cliff, tapering out over the void with air underneath. Per-column `OverhangAmp`(=strength·slope-gate) + unit uphill dir `(DirX,DirY)` are resolved once in `ComputeSurfaceColumn` — the gradient sampled at the REACH scale (`OverhangReach`) so a point out over the void can "see" the cliff to know which way is uphill — and cached on `FSurfaceColumn`. It's genuine 3D (per-voxel structural re-eval), so it's gated hard to steep overhang columns; the union only ADDS rock (never removes), capped at `TerrainZ+OverhangHeight`, so `ClassifyTile` forces Mixed only in `(TerrainZ, TerrainZ+OverhangMargin]` (upward-only; margin = max `OverhangHeight`) — a shelf never holes a trivially-skipped tile, and only the thin cliff-edge band of air tiles is woken (NOT far-field like phase-3 spikes/holes). Overhang undersides classify as ground rock by the F17 rule (down-facing but below `TerrainZ`). Known v1 limit: applies at all LODs (Step-agnostic) — may alias far; gate to fine tiles later. Beaches at water line, high sky-cap ceiling. The cap is shapeable terrain in its own right (`ComputeSurfaceCeiling`, `Surface|Sky` params: `CeilingUndulation` broad inverted hills/valleys, `CeilingRidgeStrength` hanging ridgelines, `CeilingRoughness`+freq fine bumps, `CeilingWarp*`) — defaults (strengths 0, freq 0.04) = old flat-ish cap. (`Surface|Macro` params = the cheap precursor to biomes; `ReliefStrength=0` ⇒ old uniform terrain.) **Sky-cap vs ground is a PER-TRIANGLE surface class (F17), not a per-tile verdict**: the mesher classifies each unique vertex on the **worker** — only down-facing verts (`N.Z<-0.1`) pay a memoized `GetSurfaceHeightAt` column query; nearer `CeilSurf` ⇒ sky-cap, nearer `TerrainZ` ⇒ terrain overhang stays ground (a future SurfaceWorld cave roof — down-facing but below `TerrainZ` — also lands ground by the same rule; non-surface strates always ground). Triangles take the majority class of their 3 verts and are packed as **two contiguous index runs** (ground then cap, `FVoxelMeshData::NumCeilingTriangles`; skirts inherit their source triangle's run) → RMC **polygroups 0/1** → one **section per non-empty group** (`ApplyMeshToTile`, material slot = group index): slot 0 = strate `OverrideMaterial`/default (min-corner chunk), slot 1 = `CeilingMaterial` resolved at the tile's **TOP chunk** (mid as gap fallback, then min; fallback: ground material) — a coarse tile is 2^level chunks tall, so its min corner can sit in a lower strate/gap while the cap belongs to the strate above (this was the "far cap = ground material" residue), so the shadowless overhead rock is tinted separately instead of reading flat/bright. Shadow is **per section** via `FRealtimeMeshSectionConfig::bCastsShadow` (NOT the component `SetCastShadow` — RMC's proxy ignores the component flag; this is also why level≥2 far tiles only stopped casting once the section flag was wired): ground casts at level≤1, the cap section never casts, so the rock ceiling never shadows the terrain below it. History: v1 was a game-thread centre height-oracle (misclassified coarse far tiles → terrain material on the cap underside); v2 a whole-tile worker normal VOTE — which painted **mixed coarse tiles** (one far tile spanning terrain AND cap) entirely with the winner's material, and put sky material under terrain overhangs. The per-triangle class fixes both and is the identity channel caves/F8 will reuse. |
|
||||
| VerticalShafts | `FVerticalShaftParams` | `GetVerticalShaftDensity` | full-height shafts + horizontal connectors + partial ledges |
|
||||
| FloatingIslands | `FFloatingIslandParams` | `GetFloatingIslandDensity` | asymmetric islands: flat land top + underside tapering to a point, lobed (domain-warped) outline, in an open void |
|
||||
| Underwater | `FStrateGenerationParams` + water | (reuses `GetDensityWithParams`) | tunnel rock + high water table |
|
||||
|
||||
All density fns share the convention: internal **positive=solid**, apply origin spine →
|
||||
boundary seal → inter-strate passages, then `return -Density` (MC: negative=solid).
|
||||
StrateManager provides params per chunk via `GetMaze/Surface/VerticalShaft/FloatingIslandParamsForChunk`
|
||||
(macro `VF_ARCHETYPE_PARAMS_GETTER`) — no cross-boundary blend (Hard transitions between archetypes).
|
||||
On top of the archetype, an optional **biome** layer (§8.14) modulates terrain & content WITHIN a
|
||||
strate via a window-invariant XY field — currently wired into SurfaceWorld.
|
||||
|
||||
⚠️ **The `switch` above is no longer the only density path.** 6 of the 8 archetypes (all but
|
||||
TunnelNetwork and Underwater) also exist as **operator stacks**, selected per strate by
|
||||
`bUseOperatorStack` and evaluated instead of the `switch`; each is bit-identical to the function in
|
||||
its row. The design lives in `OPSTACK-PLAN.md` / `OPSTACK-DECOMPOSITION.md`, the symbol index in
|
||||
`CODEMAP §3.2d` — not repeated here. What matters for *this* document: the archetype table describes
|
||||
what the world IS, and both paths compute it.
|
||||
|
||||
### 8.2 (0,0) spine & hybrid connections
|
||||
- `ApplyOriginSpine` (VoxelGenerator.cpp, static helper) carves a guaranteed open vertical
|
||||
column at XY (0,0) in every strate's **interior** (seals untouched). Radius =
|
||||
`UVoxelGenerator::OriginSpineRadius` ← `VoxelSettings::OriginSpineRadius`. Called before
|
||||
every `ApplyBoundarySeal`.
|
||||
- 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) ✅ BUILT & WORKING (ticked 2026-07-27). Cave structural biomes
|
||||
deferred (determinism, see above). Per-voxel biome warp (+2 Perlin) & content `GetDominantBiomeAt`
|
||||
are future T1.a column-cache candidates.
|
||||
|
||||
### 8.15 Biome material identity — vertex-colour palette (F6, Stage 1)
|
||||
A biome re-skins the terrain SURFACE (not just content/atmosphere) through a single master material,
|
||||
with NO extra draw calls / material slots and NO per-tile material swap (which would seam at tile
|
||||
borders). The biome's `MaterialPaletteIndex` (0-255) is **baked into the mesh vertex colour** and a
|
||||
master triplanar material switches/blends its layers on it. Works for ANY archetype (it rides the
|
||||
generic biome field), not just SurfaceWorld. Empty `Biomes[]` ⇒ all-zero colour ⇒ bit-identical look.
|
||||
|
||||
- **Vertex-colour layout** (`FVoxelMeshData::Colors`, packed in `UVoxelMarchingCubesMesher::GenerateMesh`
|
||||
`GetOrCreateVertex`): **R** = dominant biome `MaterialPaletteIndex`; **G** = slope (`1-|N.z|`: 0 flat
|
||||
floor/ceiling, 1 vertical wall — for rock-on-cliffs); **B** = biome border blend weight (0 deep in a
|
||||
cell → ~0.5 at the border); **A** = NEIGHBOUR biome `MaterialPaletteIndex`. The master material does
|
||||
`lerp(layer[R], layer[A], B)` for a seamless cross-fade along the biome field's own border (B peaks at
|
||||
~0.5 = 50/50 at the border; the identities swap across it, so 50/50 both sides ⇒ no discontinuity —
|
||||
do NOT rescale B to reach 1.0 or the swap becomes a hard seam).
|
||||
Height/snow-line is derived in-material from `WorldPosition.Z` (no channel needed). Skirt verts inherit
|
||||
their source vertex's colour (`AddSkirtVert` takes the colour) so the `Colors` array stays parallel.
|
||||
- **Data path.** `UVoxelBiomeDefinition::MaterialPaletteIndex` → `FBiomeResolved::MaterialPaletteIndex`
|
||||
(set in `StrateManager::GetBiomeContextForChunk`) → `UVoxelGenerator::GetBiomeMaterialAt(x,y,z →
|
||||
dominant/neighbour palette + weight)`. That method mirrors `GetDensityAt`'s biome caching: a
|
||||
thread_local per-chunk `FBiomeContext` + box-validated `FChunkBiomeCache`, so the noise-heavy classify
|
||||
is reused across a tile's vertices. Resolved per UNIQUE vertex (after dedup), not per triangle corner.
|
||||
Window-invariant (`ResolveBiomeSampleAt`, bit-identical to `SampleBiomeAt`).
|
||||
- **Apply.** `AVoxelWorld::ApplyMeshToTile` calls `Builder.EnableColors()` + `Vertex.SetColor(...)`.
|
||||
The terrain material slot is still strate `OverrideMaterial` / `Settings->VoxelMaterial` — author THAT
|
||||
as the master palette material. No biome terrain-material asset field (palette index is the contract).
|
||||
- **Perf.** Free where a strate has no biomes (`GetBiomeMaterialAt` early-outs to palette 0). Otherwise
|
||||
one biome resolve per unique vertex, bounded by the per-chunk biome cache (don't feed it a chunk key —
|
||||
keep the box validity, §8.10). Coarse far tiles have few vertices.
|
||||
- **Status:** C++ ✅ BUILT & WORKING (ticked 2026-07-27). The master material graph is still
|
||||
editor-side work and is deliberately NOT ticked — that half is Jahni's, not the code's.
|
||||
|
||||
## 9. Multiplayer model (listen-server first, dedicated-friendly)
|
||||
|
||||
> **Status: DESIGN ONLY — nothing is networked in-tree yet** (no `Replicated`/`HasAuthority`/RPCs; a
|
||||
> single `GetPlayerPosition()` center; a local diff layer). This section locks in the invariants so the
|
||||
> streaming / AI / carve systems are built network-aware from the start instead of retrofitted. Target
|
||||
> **now = listen server** (the host is a player AND the authority); **dedicated server = future / out of
|
||||
> scope**, but the abstractions below (anchor *policy* + *role*) already cover it so it's additive later.
|
||||
|
||||
### 9.1 The core invariant — determinism means you NEVER replicate geometry
|
||||
The world is a pure function of **(seed, strate layout)** (§8.4). So terrain is reconstructed identically
|
||||
on every peer from a tiny amount of shared state — it is **never streamed as geometry over the wire**:
|
||||
- Replicate the **effective seed + strate layout** ONCE (at join). Every client's `UVoxelGenerator` +
|
||||
`UVoxelStrateManager` then generate byte-identical terrain locally. (Today the seed lives on the data
|
||||
asset / `UVoxelSettings::Seed`; MP must propagate the *host's* effective seed to joiners so their
|
||||
generators match — a mismatch = divergent worlds. The strate layout is deterministic from seed, so it
|
||||
syncs implicitly once the seed does.)
|
||||
- The **diff layer is the ONLY non-deterministic terrain state** (§3.9, [[voxelforge-difflayer-threading]])
|
||||
→ it is the only thing that must sync. Since carving is a minor feature, this traffic is small.
|
||||
|
||||
### 9.2 Authority — server-authoritative diff, deterministic local re-mesh
|
||||
- A carve/fill is a **request**: client → `Server_RequestModification(FVoxelModification)` → the authority
|
||||
(the host) validates it (`DiffLayer` budget / anti-cheat, §3.9 `CanModify`) → applies to the
|
||||
**authoritative diff layer** → **multicasts the small `FVoxelModification`** (center/radius/strength ~a
|
||||
few floats) → every peer applies it to its LOCAL diff layer and re-meshes locally via the existing
|
||||
`ApplyModification` path (§3.5). Geometry never crosses the wire; only the edit event does.
|
||||
- On the **listen server** the host is also a player, so a host carve applies directly (still through
|
||||
validation) then multicasts. Remote clients only ever send requests.
|
||||
- **Season / seed change** (`ChangeSeed`, `RegenerateAllChunks`) bumps a **local** `GenerationEpoch` today
|
||||
— in MP this must become a **server-driven multicast event** (everyone bumps epoch + regenerates from the
|
||||
new seed). Epoch stays a per-peer local counter; the *trigger* is networked, the counter is not.
|
||||
|
||||
### 9.3 Multi-anchor streaming — the backbone, not just an AI feature
|
||||
**IMPLEMENTED 2026-07-07 (the streaming/collision half; the collision-only render-skip §9.4 is still
|
||||
pending).** `AVoxelWorld::RegisterStreamingAnchor(Actor, Policy, RadiusChunks)` / `UnregisterStreamingAnchor`
|
||||
(BlueprintCallable) add an actor to `StreamingAnchors`. `UpdateChunksAroundPosition` prunes dead anchors +
|
||||
detects chunk crossings (rebuilds the desired set when any anchor crosses a level-0 boundary — same cadence
|
||||
as player movement, coalesced into one rebuild). `AddAnchorDesiredTiles` (inside `BuildDesiredTiles`, after
|
||||
the player clipmap + sheet ring) folds each anchor's Chebyshev box of **level-0** tiles into the SAME
|
||||
`DesiredStamped`/`DesiredSorted` set (deduped vs the clipmap by stamp) → the existing delta cull releases an
|
||||
anchor's tiles automatically when it moves away / unregisters. Zero cost when no anchors (empty loop). The box
|
||||
defaults to a THIN shape (its chunk + 1 horizontal ring + 1 chunk below for ground safety, nothing above —
|
||||
`XYRadiusChunks`/`ZBelowChunks`/`ZAboveChunks`, per-register). `CollisionOnly` anchor tiles are hidden (§9.4).
|
||||
Anchor tiles sort by distance-to-*player*, so one far from every player streams last (fine for now; a
|
||||
per-anchor priority is a later tweak).
|
||||
|
||||
The general model MP requires — **N centers**: the authority streams around **every connected player + every
|
||||
AI**, because that's how a remote pawn gets server-side collision / movement authority. The registry of
|
||||
**anchors**:
|
||||
- **Anchor = { actor, policy }**, `policy ∈ { CollisionOnly, FullVisual }`, plus a **role** on the world
|
||||
(client / listen-host / [future] dedicated).
|
||||
- **Listen-host role:** `FullVisual` anchor on its OWN camera (it renders for itself) + **`CollisionOnly`**
|
||||
anchors around every REMOTE player + AI (it needs their collision for authority, not their pixels).
|
||||
- **Remote-client role:** `FullVisual` anchor on its own camera + collision around its own pawn (local
|
||||
prediction). It does not stream other players' far tiles.
|
||||
- **[Future] dedicated role:** ALL anchors `CollisionOnly` — no visual mesh anywhere server-side. The
|
||||
listen-host's `CollisionOnly` path IS this path, so dedicated is just "no local FullVisual anchor."
|
||||
- Keep today's single-player fast path exactly when the registry has one FullVisual anchor and no others.
|
||||
|
||||
### 9.4 Collision-only tiles — render-skip (IMPLEMENTED 2026-07-07)
|
||||
A level-0 tile that ONLY a `CollisionOnly` anchor wants (the player clipmap did not stamp that exact key this
|
||||
crossing) goes into `CollisionOnlyTiles`; `ApplyMeshToTile` cooks its collision but `SetVisibility(false)` —
|
||||
**no draw, no VSM, no shadow** — killing the cost of terrain around AI / remote players far from the local
|
||||
camera. If the clipmap (or a `FullVisual` anchor) also wants the tile, it renders normally. Visibility flips
|
||||
on ALREADY-LOADED tiles (player walks toward/away from a cluster) are handled by `ReconcileAnchorTileVisibility`
|
||||
diffing `CollisionOnlyTiles` vs its previous set each crossing (bounded by the small anchor set, no O(loaded)
|
||||
scan). Collision is independent of visibility in UE, so a hidden tile still collides.
|
||||
- **Still on the frame:** the geometry streams are built on the worker (`BuildTileStreamSet`) even for hidden
|
||||
tiles — off the frame, but it's CPU+memory. A deeper "cook collision without building render streams" path
|
||||
(true `CollisionOnly`, and the future dedicated-server terrain) is a later optimization.
|
||||
- `SetCanEverAffectNavigation(false)` stays — nav is function-based (§9.6), not Recast, so collision tiles
|
||||
never feed a navmesh.
|
||||
|
||||
### 9.5 Late join
|
||||
A joiner receives the seed/layout (regenerates everything locally) + a **compacted diff snapshot** replayed
|
||||
into its diff layer. Nothing else needs transfer — the rest of the world is a function. The diff layer is
|
||||
already chunk-keyed and lock-guarded ([[voxelforge-difflayer-threading]]); a serialize/replay path is the
|
||||
main new piece.
|
||||
|
||||
### 9.6 AI is authority-side + function-based nav (see the AI-nav plan)
|
||||
AI runs on the authority (host now, dedicated later). Nav is **function-based** — a coarse A* + funnel +
|
||||
spline route over `GetVoxelSurfaceHeightAt`/`GetDensityAt` (+ the diff layer so AI sees carves), followed by
|
||||
a steering component for smooth (non-robotic), cheap locomotion. Crucially it queries the world FUNCTION, so
|
||||
it needs **zero loaded geometry** — ideal for the authority side and mandatory for a future headless
|
||||
dedicated server (which has no meshes). Recast is rejected: it would need cooked collision everywhere AI
|
||||
roams, server-side, re-cooking on every dig. Build order: multi-anchor collision streaming (§9.3) FIRST
|
||||
(now MP-foundational), then the function nav.
|
||||
|
||||
### 9.7 What's NOT built (greenfield checklist)
|
||||
Seed/layout replication at join · `Server_RequestModification` RPC + multicast of applied mods · ~~anchor
|
||||
registry~~ (DONE 2026-07-07, §9.3) + ~~`CollisionOnly` render-skip~~ (DONE 2026-07-07, §9.4 — hide-based; the
|
||||
deeper no-stream-build path still open) + role awareness · networked season/seed (epoch multicast) ·
|
||||
diff-layer serialize/replay snapshot for late join · server-side AI + function nav. All additive on top of
|
||||
today's deterministic single-player core.
|
||||
+1005
File diff suppressed because it is too large
Load Diff
@@ -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).
|
||||
+126
-2
@@ -90,8 +90,104 @@ Paths relative to `Source/VoxelForge/`. `Public/` = headers, `Private/` = impl.
|
||||
| `LocalToIndex` / `IndexToLocal` / `IsValidLocalCoord` | 107-131 | Flat-array 3D↔1D indexing. |
|
||||
| `SmoothStep01` | 140 | 3x²-2x³ — used everywhere for blends. |
|
||||
| `VOXEL_NOISE_SCALE` (1.25f) | 147 | Rescales UE PerlinNoise3D to ~[-1,1]. |
|
||||
| `EVoxelTileClass` enum (`Mixed`/`AllSolid`/`AllAir`) | — | T1.d verdict. **MOVED here from `VoxelGenerator.h` 2026-07-27** so `VoxelDensityOp.h` can share it without a UCLASS dependency. A false `AllSolid`/`AllAir` is a HOLE; a false `Mixed` only costs CPU. |
|
||||
| `FVoxelMeshData` struct | 157-173 | Mesher output (Vertices/Triangles/UVs/Normals/**Colors**). Plain C++, not USTRUCT. `Colors` = F6 material masks (R=dominant biome palette, G=slope, B=border blend weight, A=neighbour biome palette). §8.15. |
|
||||
|
||||
### 3.2b Density operator stack contract — `Public/VoxelDensityOp.h` (plain C++, no UHT)
|
||||
**Phase 1 skeleton, added 2026-07-27. Nothing is wired in yet** — `GetDensityAt`'s archetype
|
||||
`switch` is untouched and no operator exists. See [OPSTACK-PLAN.md](OPSTACK-PLAN.md) for the plan and
|
||||
[OPSTACK-DECOMPOSITION.md](OPSTACK-DECOMPOSITION.md) for the per-archetype breakdown.
|
||||
|
||||
| Symbol | Notes |
|
||||
|--------|-------|
|
||||
| `EVoxelOpRole` | The four roles: `FieldSource` (what the field IS) · `Combiner` (how fields merge) · `DetailModifier` (today's `UVoxelTerrainOpDefinition`) · `StructuralPost` (spine→seal→passage→diff, appended automatically, never author-omittable). |
|
||||
| `EVoxelOpCombine` | `Replace`/`Union`(min)/`Subtract`(max)/`SmoothUnion`/`SmoothSubtract`/`Add`/`Mask`. Sign reminder: negative = solid, so "add solid" is `min`. |
|
||||
| `EVoxelOpEffect` | `Identity`/`CarveOnly`/`FillOnly`/`Both`. Conservative: `Both` is always safe, the wrong one is a hole. |
|
||||
| `FVoxelOpContext` | Chunk-constant inputs. **Carries `LayoutVersion` by construction** so a new op cannot forget it (AUDIT C2). |
|
||||
| `IVoxelDensityOp` | `PrepareChunk` / `Eval` / `EffectOverBox` / `ClassifyBox` / `IsXYPure`. |
|
||||
| `IVoxelDensityOp::ClassifyBox` | ⚠️ **not source-only.** Forcing ops (the boundary seal inside its band) overwrite the input entirely, which pure direction cannot express. |
|
||||
| `FVoxelOpSample` | The state threaded through the stack: **two** channels, `Density` (INTERNAL convention, **positive = SOLID**, negated to MC once by the caller) and `Sdf` (standard SDF, negative = inside). ⚠️ `min()` therefore means opposite things on the two channels. |
|
||||
| `FVoxelBoxHypotheses` + `VF_ForceHypotheses` / `VF_FoldEffect` / `VF_FoldOp` | The fold that turns a stack into an `EVoxelTileClass`. Reproduces today's hand-written `ClassifyTile` line for line — the mapping is written out in the header. |
|
||||
|
||||
### 3.2c Structural primitives — `Public/VoxelDensityPrimitives.h`
|
||||
`VF_ApplyOriginSpine` · `VF_ApplyBoundarySeal` · `VF_ApplyPassageCarving` — the three world
|
||||
invariants every archetype appends, **moved here 2026-07-27** so the generator and the operator
|
||||
stack share ONE copy. `VoxelGenerator.cpp` keeps same-named `static FORCEINLINE` forwarders so its
|
||||
~20 call sites are unchanged; bodies are byte-identical. Also `VoxelDensityReach::SpineBlend` /
|
||||
`PassageBlend`, the blend radii `ClassifyTile` currently hand-duplicates.
|
||||
**Convention: INTERNAL (positive = solid).**
|
||||
|
||||
### 3.2d Operator stack — `Public/VoxelDensityOpStack.h` + `Private/VoxelDensityOpStack.cpp`
|
||||
⚠️ **Feeds the game, behind a per-strate opt-in** (Phase 1 step 3). `GetDensityAt` builds the stack
|
||||
in its per-chunk refetch block and evaluates it *instead of* the `switch` only when
|
||||
`UVoxelStrateManager::UsesOperatorStackForChunk` says so — strate ticked `bUseOperatorStack` **and**
|
||||
archetype in the ported list, which is now **all 8 of 8**: Maze, FlatPlain, CrystalChamber,
|
||||
SurfaceWorld, VerticalShafts, FloatingIslands, TunnelNetwork, Underwater. A strate that has not
|
||||
ticked the box still takes the `switch`, unchanged — the flag is the only thing that switches paths.
|
||||
**`ClassifyTile` IS wired now**, for CAVE archetypes only and behind the same per-strate opt-in:
|
||||
where it used to `return Mixed` without a call, it builds the strate's stack through the *same*
|
||||
factory `GetDensityAt` uses (`VF_BuildOpStackForChunk`) and folds `ClassifyBox`. SurfaceWorld and
|
||||
bedrock gaps keep their hand-written proofs — the exact-lattice column test is better than any box
|
||||
bound. Guards, all failing to `Mixed`: one cave slot per tile, no mixed cave/surface/gap tile, the
|
||||
opt-in true on *every* chunk the box touches, the params **bit-identical** across every chunk coord
|
||||
the box touches (blended transition bands make one stack unable to represent the tile — `AUDIT §C2`),
|
||||
a 27-chunk-coord cap, and the disturbances folded in by hand since they are applied after the stack.
|
||||
Brute-forced end to end by `VoxelForge.OpStack.ClassifyTileSoundness`.
|
||||
⛔ Never run both paths in one world. **Comparing them IS legitimate now** — the ~1 ULP residue of
|
||||
AUDIT §C10 is gone since `FPSemantics = Precise`, and all eight equivalence tests compare bit for
|
||||
bit. They are port-correctness oracles, not fidelity checks: the acceptance bar is §2.6.1 (same seed
|
||||
⇒ same world on every peer), which does not require resembling the pre-refactor world.
|
||||
|
||||
| Symbol | Role | Notes |
|
||||
|--------|------|-------|
|
||||
| `FVoxelOpStack` | — | Ordered `TUniquePtr` list. `PrepareChunk` / `EvalInternal` / `EvalMC` / `ClassifyBox` (the fold, with an early-out when both hypotheses die). |
|
||||
| `FVoxelOpStack::AppendStructuralPost` | 4 | Appends spine → seal → passage **in that fixed order**. An author cannot omit or reorder them. The diff layer is NOT here yet — it still lives in `GetDensityAt` after the MC negate, with disturbances. |
|
||||
| `VoxelDensityOps::MakeConstantRockSource` | 1 | `Density = BaseDensity`. `ClassifyBox` → **AllSolid**, exact and free. Shared by TunnelNetwork, Maze, VerticalShafts and bedrock gaps. Class is `FConstantFieldSource` (one class, two factories). |
|
||||
| `VoxelDensityOps::MakeConstantVoidSource` | 1 | The **same class, negated**: `Density = -BaseDensity`, and `ClassifyBox` → **AllAir** — the first source in the plugin that can prove it. FloatingIslands' root; that verdict is what makes a mostly-empty island strate skippable. |
|
||||
| `VoxelDensityOps::MakeLatticeCorridorSource` | 1 | Maze corridors, SDF channel. Edge identity = `hash(lower node, axis)` ⇒ adjacent chunks cannot disagree (AUDIT §6.4's preferred pattern). Its `EffectOverBox` answers for the source+carve **pair** (Phase 1 simplification) so it must be told the downstream `ExtraReach`. |
|
||||
| `VoxelDensityOps::MakeSdfRoughnessMod` | 3 | Wall roughness in **SDF** space (Maze/Shafts/Islands variant). TunnelNetwork's density-space roughness is a **different op** — see OPSTACK-DECOMPOSITION §1. |
|
||||
| `VoxelDensityOps::MakeSdfCarve` | 2 | SDF → density carve. The same six lines currently copied in three archetypes. Class is `FSdfConvertOp(Sign = -1)`. |
|
||||
| `VoxelDensityOps::MakeSdfFill` | 2 | The same op with `Sign = +1` — FloatingIslands' `Density += Fill·Base·2`. ±1 multiplication is exact in IEEE-754, so the carve path is bit-for-bit unchanged by the generalisation. |
|
||||
| `VoxelDensityOps::MakeSlabVoidSource` | 1 | Floor surface + ceiling surface → void field. **XY-pure** since §3.1, which is what gives it an **exact `ClassifyBox` with no sampling**: FBM's `[-1,1]` contract bounds both surfaces into known Z bands. Serves FlatPlain **and** CrystalChamber. |
|
||||
| `VoxelDensityOps::MakeGridColumnMod` | 3 | Infinite-height cylinders on a world grid, 3×3 cell memo. Adds solid only ⇒ `FillOnly` when a column reaches the box, `Identity` otherwise — and that `Identity` is what lets the source's `AllAir` verdict survive. |
|
||||
| `VoxelDensityOps::BuildSlabStack` | — | 5 ops, **no branch on archetype**: FlatPlain and CrystalChamber differ only in defaults, exactly as `GetSlabDensity` already had it. 8 archetypes → 7. |
|
||||
| `FSurfaceColumnSource` (internal) | 1 | The bridge between the two spaces: consumes the ground + sky-cap **height** stacks and produces density. `IsXYPure()` **false** — the heights are XY-pure, a distance to them never is. Owns the per-column memo, keyed by `PrepareChunk` on `(StrateBottomWorldZ, LayoutVersion, Seed)` so it is **shared down the whole vertical strate stack**, exactly like `GSurfColCache`. |
|
||||
| `VoxelDensityOps::BuildSurfaceStack` | — | SurfaceWorld, complete: column + overhang + 3 structural, plus biome blending when `PerBiomeParams` is non-empty. Takes ownership of an `IVoxelBiomeField`. |
|
||||
| `VoxelDensityOps::BuildVerticalShaftStack` | — | 8 ops, and **three are Maze's reused unchanged** (`ConstantRock`, `SdfRoughness`, `SdfCarve`) with different tuning (freq 0.1 vs 0.12, window `rough+4` vs `R+rough+2`). The measured proof of `OPSTACK-PLAN §2.5`'s reuse claim. |
|
||||
| `FRoomGraphSource` (internal) | 1 | TunnelNetwork's SDF spine. **CALLS `BuildChunkCache`/`EvaluateSDFCached` — does not transcribe them**: that is where §8.4's two-region discipline lives and a copy would fork it. Owns the cave warp (scope = this op alone; pits/chimneys read *unwarped* coords, which is why no FRAME op was needed). Its cache key adds a **params CRC + LayoutVersion** that the original lacks — see the suspected staleness note in AUDIT §C2. `EffectOverBox` → `Both` for now (a real answer means building the cache for the queried box; only pays once `ClassifyTile` consumes `ClassifyBox`). |
|
||||
| `FWormFieldSource` (internal) | 1 | Fielded 3D-noise threshold carve, masked by distance to the room network (reads `InOut.Sdf` *after* pits/chimneys). `EffectOverBox` → **`CarveOnly` everywhere** — no spatial bound, so it kills `AllSolid` on every tile of every strate with worms on. `MaxCarveAmplitude()` holds the bound from DECOMPOSITION §0.2 that would recover it, waiting for a fold that carries numbers. |
|
||||
| `VoxelDensityOps::BuildTunnelNetworkStack` | — | **COMPLETE, 19 ops** — the biggest port in the plugin (~1080 lines), done in three stages: SDF spine (A) → the twelve detail modifiers of 4b–4h (B) → the per-room op override (C). Serves **TunnelNetwork and Underwater** from one builder. Operator order is the original's, line for line, and it is load-bearing (`FFloorBiasMod` exists to undo what `FCaveRoughnessMod` did to floors). |
|
||||
| `FCaveRoughnessMod` (internal) | 3 | STEP 4b, **density space** — a different op from `MakeSdfRoughnessMod`: two octave sets, optional domain warp, four noise types, an anti-fill clamp inside definite air, quadratic fade. ⚠️ **Reads STRATE params, not the per-room copy** — the original's shadow is declared *after* step 4b. Eleven of twelve modifiers read the room copy; this one does not. |
|
||||
| `FCaveTerraceMod` (internal) | 3 | STEP 4c. The only modifier that **re-queries the SDF** (Z±1, through `FRoomGraphSource::ProbeSdfUnwarped`) for its horizontality gate — which is why the room source's cache is exposed at all. ⚠️ Those probes use unwarped X/Y and raw Z although the field was evaluated warped: transcribed as-is, see OPSTACK-PROGRESS. |
|
||||
| `FLayerLineMod` / `FRibbingMod` (internal) | 3 | The same sine along Z: cubed and subtracted (grooves) vs quarter-phase-shifted, squared and added (ribs). `CarveOnly` / `FillOnly` — two of the few detail modifiers that keep a usable direction for the fold. |
|
||||
| `FCaveOverhangMod` / `FCaveCliffMod` / `FScallopMod` (internal) | 3 | STEP 4c. ⚠️ The cliff's own comment promises a sampled vertical gradient; the **code** uses a Z-stretched Perlin as a proxy and samples nothing. Ported as written — fixing it would change the world. |
|
||||
| `FCaveArchMod` / `FDomeMod` / `FPinchMod` / `FFloorBiasMod` (internal) | 3 | Room-relative: they read `FRoomGraphSource::GetNearestRoomIdx()` and the cached room. Their gate is `SDF < SDFBlendRadius`, **not** the shared `·3` one — they live in the cave's void, not its wall. |
|
||||
| `FRoomColumnMod` (internal) | 3 | STEP 4d, and **not** `MakeGridColumnMod`: it walks `SDFCache.Columns`, pre-baked per room. ⚠️ It has **no strate parameter at all** — neither the bake nor the loop reads `FStrateGenerationParams::ColumnDensity`. Columns exist only through a `Column` terrain-op asset in the strate's pool, and the only way to prove they fired is to look at `SDFCache.Columns.Num()`. |
|
||||
| `FRoomGraphSource::LocalParams()` | — | **The per-room op override** (DECOMPOSITION §2's "no clean home"). Strate params + the nearest room's `UVoxelTerrainOpDefinition::ApplyTo`, memoised once per voxel and read by eleven modifiers. One op owns the shared state, the rest read it — the same pattern as `FOverhangShelfMod` ← `FSurfaceColumnSource`. ⚠️ `EffectOverBox` still answers from STRATE params, so a box verdict can be **too optimistic** on a strate with a terrain-op pool; harmless until `ClassifyTile` consumes `ClassifyBox`, and it must be fixed before that. |
|
||||
| `FIslandBlobSource` (internal) | 1 | Hash-placed tapered flat-top blobs, `SmoothMin`'d, in a **domain-warped XY frame** (the warp stays inside the op — see the deviation note vs DECOMPOSITION §7). SDF channel only. `EffectOverBox` → `FillOnly` when a blob reaches the box, `Identity` otherwise; its pad must cover warp·**√2** (two independent noise axes), roughness, fill blend and the `SmoothMin` dip. **No lower Z bound exists** — a hairline thread of matter hangs below each island down its axis, so only the TOP may reject. |
|
||||
| `VoxelDensityOps::BuildFloatingIslandStack` | — | 7 ops, and **the stack runs backwards**: void source + fill instead of rock source + carve, using the *same* classes with the opposite sign. Only the blob source is new. Reuse by **inversion** — a stronger result than reuse by identity, since it says the abstract axis (the density sign) is the right one. |
|
||||
| `VoxelDensityOps::BuildMazeStack` | — | The 7-op Maze stack. If this ever becomes one op, the refactor failed its own test (§2.5). Callers must skip it on a **degenerate strate** (top−bottom ≤ 0): `GetMazeDensity` early-outs to air there and the stack has no such early-out by design — `GetDensityAt` falls back to the `switch`. |
|
||||
|
||||
### 3.2e Height-space operators — `Public/VoxelHeightOp.h` + `Private/VoxelHeightOpStack.cpp`
|
||||
⚠️ **Feeds nothing yet** — built and exercised only by `VoxelForge.OpStack.SurfaceHeightEquivalence`.
|
||||
**A SECOND op family, and it exists for a reason worth knowing:** SurfaceWorld's terrain ops (cliff /
|
||||
terrace / layer lines / beach) read and write an **altitude**, not a density. They have no input Z
|
||||
(they produce one), are XY-pure (once per column, not per voxel), and touch neither density nor SDF —
|
||||
so they do not fit `IVoxelDensityOp` at all. Forcing them in would need a per-voxel channel for what
|
||||
is a **column** property, or one opaque op (`OPSTACK-PLAN §2.5`'s failure mode). Same lesson as
|
||||
`§0.1` one step further: some things are not another channel, they are another **space**.
|
||||
|
||||
| Symbol | Notes |
|
||||
|--------|-------|
|
||||
| `FVoxelHeightSample` | Two channels: `Height` (voxel Z) + `Relief` (the original's `M`). Relief is produced by the structural source and consumed by the terrace gate — threading it beats resampling it. |
|
||||
| `IVoxelHeightOp` | `Eval(X, Y, FVoxelHeightSample&)`. No `IsXYPure` (XY-purity is structural here — there is no Z to wrongly put in), no `PrepareChunk` (already per-column). `MaxDisplacement()` is the conservative vertical bound for a future heightfield `ClassifyBox`. |
|
||||
| `FVoxelHeightStack` | Move-only, like `FVoxelOpStack`. `EvalHeight` / `EvalSample` / `MaxTotalDisplacement`. |
|
||||
| `VoxelHeightOps::MakeStructuralHeightSource` | Continents + mountains + detail under a warp frame. Hands back a **non-owning pointer** so the cliff mod can resample it. |
|
||||
| `VoxelHeightOps::MakeCliffHeightMod` | Slope-gated steepening; 4 resamples of the **structural** field (never the modified height — that would feed back). |
|
||||
| `VoxelHeightOps::MakeTerraceHeightMod` | Relief-gated plateaus. The `* Relief` is the original's `* M`. |
|
||||
| `VoxelHeightOps::MakeLayerLineHeightMod` / `MakeBeachHeightMod` | Sine bands; flatten toward the water line. Both have exact `MaxDisplacement`. |
|
||||
| `VoxelHeightOps::BuildSurfaceHeightStack` | 5 ops in `ComputeSurfaceTerrainZ`'s order — structural → cliff → terrace → layer lines → beach. **Order is not negotiable.** |
|
||||
|
||||
### 3.3 Chunk identity
|
||||
`VoxelChunk.h` (the old `FVoxelChunk` coord wrapper) was DELETED — dead since the tile
|
||||
redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h).
|
||||
@@ -170,7 +266,8 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h).
|
||||
| **`GetSlabDensity`** | 1306 | FlatPlain/CrystalChamber pipeline. See §4.2. |
|
||||
| `SampleSurfaceStructuralZ` | — | **F20:** the RAW SurfaceWorld heightfield (continents+mountains+detail), BEFORE any terrain op; returns terrain Z + relief M. Cliff re-samples it at an XY offset for a cheap analytic slope. |
|
||||
| `ComputeSurfaceTerrainZ` / `GetSurfaceDensity` | — | SurfaceWorld heightfield → terrain Z, then density; biome **output-blend** lerps dominant/neighbour heights (`ParamsD`/`ParamsN`/weight). **F20 surface ops** (`FSurfaceGenerationParams`, biome-selected + slope/relief-conditioned, all default off): Cliff (slope-gated STEEPENING — push height from local mean where steep ⇒ sheer walls; 4 structural resamples only when on), Terrace (relief-gated + `TerraceHardness`), LayerLines (sedimentary shelves) — pure per-column height REMAPS applied here so the single height oracle stays consistent (MC/sheets/ClassifyTile/deco/BP bridge). **Phase 2 OVERHANG** (volumetric — real jutting shelves): in `SurfaceDensityFromColumn`, for AIR voxels in a window `(TerrainZ, TerrainZ+OverhangHeight]` above a steep slope, the heightfield is re-sampled UPHILL (toward the cliff) by a reach that GROWS with height (tiny low ⇒ air over the void, full high ⇒ borrows the far cliff rock) and unioned in ⇒ a shelf attached to the cliff, tapering out over the void with air beneath (the sketch). Per-column `OverhangAmp`(=strength·slope-gate) + unit uphill `(DirX,DirY)` resolved once in `ComputeSurfaceColumn` (gradient sampled at the REACH scale so a spot over the void can see the cliff), cached on `FSurfaceColumn`. Genuine 3D (per-voxel structural re-eval, gated to steep overhang columns). Off ⇒ byte-identical. §8.14. |
|
||||
| `ClassifyTile` | — | **T1.d trivial-tile reject** (worker, called by `LoadTile` before `GenerateMesh`): proves a tile AllSolid/AllAir on the mesher's exact lattice (gap chunks + SurfaceWorld columns via the SHARED `GSurfColCache`; seal bands; guards: diff mods, passages, spine, disturbances, **F20 overhang** — a column point in `(TerrainZ, TerrainZ+OverhangMargin]` (margin = max `OverhangHeight`) is unprovable ⇒ Mixed, UPWARD only since the shelf union only ADDS rock above ground, so an overhang shelf never holes a trivially-skipped tile) → skip gen. Mixed = generate normally. §8.10. |
|
||||
| `VF_BuildOpStackForChunk` (file-static) | — | **The archetype → stack mapping, written down once.** `GetDensityAt` and `ClassifyTile` both call it; params are passed in, never fetched here. A second copy would be the worst bug available in this file — a tile skipped on the verdict of a stack that is not the one producing its density is a hole. Returns false (⇒ caller falls back to the `switch`) for an unported archetype, missing params, or a **degenerate strate**, since five archetype functions early-out to air there and the stack deliberately has no such early-out. `Refs.Surface == nullptr` makes it refuse SurfaceWorld, which is how `ClassifyTile` keeps its own exact-lattice proof. |
|
||||
| `ClassifyTile` | — | **T1.d trivial-tile reject** (worker, called by `LoadTile` before `GenerateMesh`): proves a tile AllSolid/AllAir on the mesher's exact lattice (gap chunks + SurfaceWorld columns via the SHARED `GSurfColCache`; seal bands; **cave archetypes via `FVoxelOpStack::ClassifyBox` when the strate opted in** — see §3.2d for the six guards, all failing to `Mixed`; guards: diff mods, passages, spine, disturbances, **F20 overhang** — a column point in `(TerrainZ, TerrainZ+OverhangMargin]` (margin = max `OverhangHeight`) is unprovable ⇒ Mixed, UPWARD only since the shelf union only ADDS rock above ground, so an overhang shelf never holes a trivially-skipped tile) → skip gen. Mixed = generate normally. §8.10. |
|
||||
| `SampleRelief` / `SampleMoisture` | — | Climate fields (pure XY, [0,1]). Relief = shared source of truth for the relief map M. §8.14. |
|
||||
| `SampleBiomeAt` | — | Warped-Voronoi + climate biome query (dominant + neighbour + weight). Reference used by the preview bake + `GetDominantBiomeAt`. §8.14. |
|
||||
| `ResolveBiomeSampleAt` / `RebuildBiomeGrid` | — | Hot-path biome resolve (FBiomeSample) via a box-validated per-chunk cell-grid cache. Bit-identical to `SampleBiomeAt`. §8.14, §8.10. |
|
||||
@@ -230,7 +327,9 @@ Header is rich with inline docs. Two namespaces + a per-chunk cache system.
|
||||
`TransitionType`(79)/`TransitionBlendChunks`(89), `GeneratorType`(102),
|
||||
`GenerationParams`(113), `SlabParams`(124), `Biomes[]`+`BiomeMapParams` (the biome list +
|
||||
field tuning — empty ⇒ unchanged world, §8.14), `TerrainOperations`(147), visuals/fog/light,
|
||||
content lists, audio, `GameplayTags`(223). EditConditions show/hide param groups by generator type.
|
||||
content lists, audio, `GameplayTags`(223), `bUseOperatorStack` (the OPSTACK A/B opt-in — only bites
|
||||
if the archetype is in `UsesOperatorStackForChunk`'s ported list). EditConditions show/hide param
|
||||
groups by generator type.
|
||||
|
||||
**`Public/VoxelStrateManager.h` + `.cpp`** — `UVoxelStrateManager : UObject` (h:108).
|
||||
Maps depth→strate at runtime; owns passages.
|
||||
@@ -247,6 +346,7 @@ Maps depth→strate at runtime; owns passages.
|
||||
| `GetLayoutVersion` | h:161 (inline) | Layout/passage generation counter (= `PassagesVersion`, bumped by every `Initialize`). Hot-path callers key `thread_local` memos on it (strate-index memo in `GetDensityWithParams`, passage shortlist) so editor rebuilds never serve stale data. |
|
||||
| `GetStrateForChunk` | 466 | Chunk → definition. |
|
||||
| `GetGeneratorTypeForChunk` | 476 | Chunk → generator type. |
|
||||
| `UsesOperatorStackForChunk` | 559 | Chunk → should `GetDensityAt` take the operator stack? `bUseOperatorStack` on the definition **AND** archetype in the ported list — **now all 8 of 8** (Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands, TunnelNetwork, Underwater). **That list is written down here and nowhere else.** With every archetype ported the flag is now the *only* thing that decides the path, so ticking the box is no longer a no-op anywhere — it is a real switch onto the operator stack for that strate. |
|
||||
| `GetSlabParamsForChunk` | 490 | Slab params with runtime Z bounds (no blend — slabs use Hard). |
|
||||
| `GetBiomeContextForChunk` | — | Flatten the strate's `Biomes[]` + `BiomeMapParams` into a POD `FBiomeContext` for the biome field. Empty ⇒ biomes disabled. §8.14. |
|
||||
| `GetGenerationParams` | 515 | **Blended** TunnelNetwork params (handles Gradient/Hard/Interleaved transitions). |
|
||||
@@ -263,6 +363,10 @@ border warp+blend / climate field freqs), `EBiomePreviewChannel` (preview-bake s
|
||||
`FVoxelBiomeQuery` (BlueprintType result of `GetBiomeAtWorldLocation` — dominant/neighbour asset,
|
||||
climate, blend weight, deco count), and plain runtime PODs `FBiomeResolved` / `FBiomeContext` /
|
||||
`FBiomeSample` / `FChunkBiomeCache` (the box-validated per-chunk grid cache). See §8.14.
|
||||
`FChunkBiomeCache::Invalidate()` (added 2026-07-27, AUDIT C2) — force a rebuild when the strate
|
||||
layout version moves. The validity BOX says nothing about the `FBiomeContext` the cells were
|
||||
classified against, so after a `RebuildStrates` the grid is stale even though the box still covers
|
||||
the query. Called by all four callers on a `GetLayoutVersion()` change.
|
||||
|
||||
**`Public/VoxelBiomeDefinition.h` + `.cpp`** (NEW) — `UVoxelBiomeDefinition : UPrimaryDataAsset`.
|
||||
One asset = one biome: identity + `DebugColor`, climate placement box (`ReliefMin/Max`,
|
||||
@@ -313,6 +417,26 @@ Bourke). Cube corner/edge layout documented at top (lines 7-37). Rarely needs ed
|
||||
|
||||
---
|
||||
|
||||
### 3.12 Automation tests — `Private/Tests/` (added 2026-07-27, `#if WITH_DEV_AUTOMATION_TESTS`)
|
||||
The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor's
|
||||
**Session Frontend → Automation**, filter `VoxelForge`.
|
||||
|
||||
| File | Test name | What it proves |
|
||||
|------|-----------|----------------|
|
||||
| `VoxelForgeTestFixture.h` | — | `FTestWorld`: a headless world (transient strate definitions → `UVoxelSettings` → a real `UVoxelStrateManager::Initialize`) so tests hit `GetDensityAt`, where the thread_local caches live. One strate per archetype, **pinned via `FixedStrates`** so slot index → archetype is stable across seeds (`SlotSurfaceWorld` etc.). |
|
||||
| `VoxelForgeDensityPurityTest.cpp` | `VoxelForge.Determinism.DensityPurity` | 10k points re-sampled in shuffled order, same thread **and** on N workers, asserting BIT equality. `ValidateDeterminism` is game-thread only and cannot see worker-cache divergence. Includes a flat-field canary (AUDIT C1) and a diff-layer pass. |
|
||||
| ″ | `VoxelForge.Determinism.LiveEditInvalidation` | AUDIT C2 regression: triple the heightfield params, `Initialize` again, require the density to MOVE. The edit does not move the strate, so only `LayoutVersion` changes. |
|
||||
| `VoxelForgeClassifyTileTest.cpp` | `VoxelForge.Determinism.ClassifyTileSoundness` | Scans for a non-`Mixed` verdict, then brute-forces the exact mesher lattice (`g ∈ [-1, Cells+1]`). **A false verdict is an invisible, collisionless hole** — T1.d v1 was reverted for exactly this. Errors out rather than passing if it found nothing to check. |
|
||||
| `VoxelForgeDiffLayerTest.cpp` | `VoxelForge.Determinism.DiffLayerContention` | N readers running the worker call mix while the game thread writes and `Clear()`s. Survival + monotonic `ModsVersion`. |
|
||||
| `VoxelForgeClassifyTileTest.cpp` | `VoxelForge.OpStack.BoxVerdictFold` | Pure-logic walk of the fold in `VoxelDensityOp.h`, case by case — including the seal-forces-AllSolid case that justifies `ClassifyBox` existing. Also the only `.cpp` that includes the op header, so the build actually sees it. |
|
||||
| `VoxelForgeHeightStackTest.cpp` | `VoxelForge.OpStack.SurfaceHeightEquivalence` | The height-space stack vs `ComputeSurfaceTerrainZ`, in **altitudes**. Runs twice: defaults, then **all F20 terrain ops ON** — the load-bearing pass, since the ops are off by default and the defaults pass exercises only the structural source. Also brute-forces `MaxDisplacement` (a false bound would be a hole). Bar is bit-identity; a height delta is a visibly different world, not rounding. |
|
||||
| `VoxelForgeCrossPlatformTest.cpp` | `VoxelForge.Determinism.CrossPlatformDigest` | SHAPE digest (sign of density = the world) + FIELD digest (bit-for-bit) over a fixed integer grid, plus `NearIso` bounding how many samples could flip sign. Reports rather than asserts until pinned. Run on Windows and Linux and compare. |
|
||||
| `VoxelForgeOpStackSlabTest.cpp` | `VoxelForge.OpStack.SlabEquivalence` | **Phase 2's first port.** The same 5-op slab stack vs `GetSlabDensity` over 20k points, run twice — FlatPlain **and** CrystalChamber — which is what demonstrates the two archetypes really are one op. Plus window-invariance and box-verdict brute force. Compares against the reference **as it is now** (post Z-term removal), so green = pure refactor and any visual delta is attributable to §3.1 alone. |
|
||||
| `VoxelForgeOpStackTunnelTest.cpp` | `VoxelForge.OpStack.TunnelNetworkSpineEquivalence` | **Stage A of the last port.** Zeroes the 13 detail-op amplitudes so the *original* takes the path stage A ported — that is what makes an incomplete stack verifiable now. Samples in **clusters** (24 chunks × 250 points), because the SDF cache rebuilds when a query leaves its box and uniform sampling would rebuild per point on both paths. Check 3 (two param sets, A/B interleaved) compares each stack **to itself alone, never to the original** — the original would fail it, see AUDIT §C2. Asserts **zero** box verdicts, which is the honest stage-A result. |
|
||||
| `VoxelForgeOpStackShaftTest.cpp` | `VoxelForge.OpStack.VerticalShaftEquivalence` | The port that tests **reuse**, not fidelity: three of the five ops are Maze's, unchanged. Forces connectors + ledges on, because both are off or negligible at defaults and a resting param is an untested operator. Known-pessimistic: proves **0 of 60** tiles (its `EffectOverBox` rejects on a `Spacing*1.6` halo instead of real connector capsules — lost CPU, never a hole). |
|
||||
| `VoxelForgeOpStackIslandTest.cpp` | `VoxelForge.OpStack.FloatingIslandEquivalence` | The port that runs the stack **backwards** — void + fill vs rock + carve, same classes with the opposite sign. Counts interior-solid and open-void samples separately (on this archetype an aggregate "N solid" is dominated by the seal bands and says nothing about the islands). Counts `AllSolid` and `AllAir` verdicts **separately** too: `AllAir` is the one no cave archetype could ever prove, and it is the entire perf argument here. |
|
||||
| `VoxelForgeOpStackMazeTest.cpp` | `VoxelForge.OpStack.MazeEquivalence` | **Phase 1's load-bearing test.** The 7-op Maze stack vs `GetMazeDensity` over 20k points (aiming for bit-identity; a side-of-iso disagreement is the hard fail), plus purity across workers and brute force on every box verdict the stack emits. Reports how many tiles the stack can prove uniform — today's `ClassifyTile` proves **zero** for any cave archetype. |
|
||||
|
||||
## 4. The density pipeline (most-edited hot path)
|
||||
|
||||
### 4.1 `GetDensityWithParams` (TunnelNetwork) — VoxelGenerator.cpp:277
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
# VoxelForge — the decomposition map
|
||||
|
||||
> **What this is:** every one of the 8 archetypes, read line by line, broken into the four roles of
|
||||
> `OPSTACK-PLAN.md §2.5`, with each existing param traced to the op that will own it. Written
|
||||
> 2026-07-27 so no later port has to re-derive it.
|
||||
>
|
||||
> **Read `OPSTACK-PLAN.md §2.5` first.** The whole point is that an archetype becomes
|
||||
> `source + combiners + modifiers`, not one opaque op. If a port here collapses into a single
|
||||
> `FMazeOp`, the refactor has failed its own test.
|
||||
>
|
||||
> **Status:** analysis only. No op exists. `Public/VoxelDensityOp.h` (the contract) is written; the
|
||||
> `switch` in `GetDensityAt` is untouched.
|
||||
|
||||
---
|
||||
|
||||
## 0. What fell out — read this before the per-archetype sections
|
||||
|
||||
Three findings changed how I'd sequence the work. They are the reason this document is worth its
|
||||
length.
|
||||
|
||||
### 0.1 ⚠️ The contract needs an SDF channel, not just a density channel
|
||||
|
||||
`IVoxelDensityOp::Eval` returns *density*. But look at what TunnelNetwork actually does:
|
||||
|
||||
```
|
||||
CaveSDF = EvaluateSDFCached(rooms + tunnels) // SDF space
|
||||
CaveSDF = SmoothMin(CaveSDF, PitSDF, Pit.BlendK) // SDF space
|
||||
CaveSDF = SmoothMin(CaveSDF, ChimneySDF, Chim.BlendK) // SDF space
|
||||
→ ONE carve at the end: Density -= CarveFactor · BaseDensity · 2
|
||||
```
|
||||
|
||||
Maze, VerticalShafts and FloatingIslands do the same shape: build an SDF from several primitives,
|
||||
perturb the SDF with roughness noise, then convert once.
|
||||
|
||||
**If each primitive becomes a density op with its own carve, the `SmoothMin` junctions are lost** —
|
||||
a pit would meet its room at a hard seam instead of the organic blend the code deliberately builds
|
||||
(the comment at the pit block says exactly this: *"SmoothMin at the pit-to-room junction creates the
|
||||
same organic transition as tunnel-to-room (no hard seam at PitTopZ)"*). Roughness is worse: three of
|
||||
the four archetypes add noise to the **SDF**, which displaces the surface; adding the same noise to
|
||||
**density** scales with the local gradient and is a different effect.
|
||||
|
||||
So ops need two channels: `float Density` and `float Sdf`, with an explicit `SdfToDensity` op that
|
||||
converts. Sketch:
|
||||
|
||||
```cpp
|
||||
struct FVoxelOpSample { float Density; float Sdf; }; // Sdf = FLT_MAX ⇒ "no surface nearby"
|
||||
virtual void Eval(float X, float Y, float Z, FVoxelOpSample& InOut) const = 0;
|
||||
```
|
||||
|
||||
**This is the one part of `OPSTACK-PLAN §3` I think is wrong as written, and it matters far beyond
|
||||
fidelity:** SDF-space `SmoothMin` between two *different* sources is precisely how "a room graph
|
||||
carved into a mountain" produces an organic junction rather than one field punching a hole in the
|
||||
other. The single-channel contract can only ever overwrite. **Recommend adopting the two-channel
|
||||
`Eval` before porting Maze** — it is cheaper now than after four ports.
|
||||
|
||||
*(Left as a recommendation, not a change: the header as committed is single-channel, and this is
|
||||
Jahni's call.)*
|
||||
|
||||
### 0.2 ⚠️ Worm tunnels are why TunnelNetwork can never skip a tile — and the fix is a number
|
||||
|
||||
`AUDIT §6.2` frames "placed vs fielded" as a question about *future* 3D caves. It is already a live
|
||||
cost. Worms are a pure 3D-noise threshold carve with no bounds:
|
||||
|
||||
```
|
||||
if (WormStrength > 0 && WormThreshold > 0) { ... Density -= t · WormStrength · NetworkMask; }
|
||||
```
|
||||
|
||||
Unbounded in space ⇒ `EffectOverBox` = `CarveOnly` **everywhere** ⇒ `AllSolid` is dead for every
|
||||
tile of every strate with worms enabled. Direction alone cannot recover it.
|
||||
|
||||
**But the amplitude is bounded and trivially known:** `t ∈ [0,1]`, `NetworkMask ∈ [0,1]`, so the worm
|
||||
op can move density toward air by at most `WormStrength`. If the stack so far is provably solid by
|
||||
more than the sum of every remaining op's max carve, the box is still `AllSolid`.
|
||||
|
||||
So the first numeric interval bound worth writing is not the SDF Lipschitz bound the plan reaches
|
||||
for — it is **a scalar amplitude cap on the fielded-noise carves**. Roughly ten lines, and it is
|
||||
what unlocks deep-rock skipping for the plugin's most-used archetype. `OPSTACK-PLAN §4` defers all
|
||||
numeric bounds to Phase 3; on this evidence one of them belongs in Phase 2.
|
||||
|
||||
### 0.3 Disturbances already have bounds that `ClassifyTile` throws away
|
||||
|
||||
Today: `if (D.ChasmDensity > 0) bCanSolid = false;` — strate-wide, for the whole tile.
|
||||
|
||||
But chasms/bridges/ridges are hash-placed on an XY lattice of known spacing and radius, and the
|
||||
per-voxel code already builds the 3×3 candidate list. A box that no candidate reaches is `Identity`.
|
||||
This is a **pure win available to SurfaceWorld today**, independent of everything else in this plan:
|
||||
any surface strate that enables chasms currently loses `AllSolid` on every tile in it.
|
||||
|
||||
---
|
||||
|
||||
## 1. The shared primitive library
|
||||
|
||||
The 8 archetypes are ~6 functions in costumes (`OPSTACK-PLAN §1b`). Decomposed, here is what
|
||||
actually repeats. **Reuse count is the payoff metric** — anything used once is suspicious.
|
||||
|
||||
### Role 1 — FIELD SOURCES
|
||||
|
||||
| Op | What it lays down | Used by | `IsXYPure` | `ClassifyBox` / `EffectOverBox` |
|
||||
|---|---|---|---|---|
|
||||
| `FConstantRockSource` | `Density = BaseDensity` (solid) | TunnelNetwork, Maze, VerticalShafts, **bedrock gaps** | ✅ trivially | `ClassifyBox` → **AllSolid**, always. Free, exact. |
|
||||
| `FConstantVoidSource` | `Density = −BaseDensity` (air) | FloatingIslands | ✅ | `ClassifyBox` → **AllAir**, always. |
|
||||
| `FSlabVoidSource` | floor surface + ceiling surface, `Density = −min(z−floor, ceil−z)` | FlatPlain, CrystalChamber | ❌ *see §3.1* | `ClassifyBox` by sampling floor/ceiling over the box's XY corners — **exact, cheap, and a new skip win** |
|
||||
| `FHeightfieldSource` | `max(TerrainZ − z, z − CeilSurf)` | SurfaceWorld | ✅ (the column) | `ClassifyBox` by sampling columns on the mesher's exact lattice — **this is today's `TestColumn`, moved verbatim** |
|
||||
| `FRoomGraphSource` | rooms + tunnels (+ pits + chimneys) SDF | TunnelNetwork | ❌ | `Identity` when no room/tunnel bound reaches the box — **`FCachedRoom`/`FCachedTunnel` already carry the bounds** |
|
||||
| `FLatticeCorridorSource` | 3D lattice capsule corridors | Maze | ❌ | `Identity` when no open edge's capsule bound reaches the box |
|
||||
| `FShaftFieldSource` | vertical cylinders + horizontal connectors | VerticalShafts | ❌ (cylinders are XY-pure; connectors are not) | `Identity` when no shaft cell reaches the box in XY |
|
||||
| `FIslandBlobSource` | tapered flat-topped blobs | FloatingIslands | ❌ | `Identity` when no island bound reaches the box |
|
||||
| `FWormFieldSource` | 3D-noise threshold carve | TunnelNetwork | ❌ | **`CarveOnly` always** — see §0.2. The one unbounded source. |
|
||||
|
||||
Note how much `Identity` is available and unused: **six of nine sources can prove themselves absent
|
||||
from most of the volume**, and today none of them do.
|
||||
|
||||
### Role 2 — COMBINERS
|
||||
|
||||
`Replace` · `Union`(min) · `Subtract`(max) · `SmoothUnion`/`SmoothSubtract` (reuse
|
||||
`VoxelSDF::SmoothMin/Max`) · `Add` · `Mask`. Plus, from §0.1, the conversion op:
|
||||
|
||||
| Op | Meaning |
|
||||
|---|---|
|
||||
| `FSdfCarve(blend)` | `SDF < blend` ⇒ `Density -= smoothstep(...)·BaseDensity·2`. **The exact same six lines appear in TunnelNetwork, Maze and VerticalShafts.** |
|
||||
| `FSdfFill(blend)` | the `+=` mirror. FloatingIslands. |
|
||||
|
||||
That one op deduplicates three copies of the carve formula and one of the fill.
|
||||
|
||||
### Role 3 — DETAIL MODIFIERS
|
||||
|
||||
Everything here is gated on being near a surface, and everything here already exists.
|
||||
|
||||
| Op | Space | Used by | Notes |
|
||||
|---|---|---|---|
|
||||
| `FSurfaceRoughnessMod` | **SDF** *or* **density** — two variants, see below | all 4 SDF archetypes | the single biggest reuse in the plugin |
|
||||
| `FTerraceMod` (cave) | density | TunnelNetwork | SDF-gradient orientation gate (2 extra SDF evals) |
|
||||
| `FLayerLineMod` | density | TunnelNetwork | sine along Z, cubed |
|
||||
| `FRibbingMod` | density | TunnelNetwork | sine along Z, squared, `+=` |
|
||||
| `FCaveOverhangMod` | density | TunnelNetwork | low-Z-frequency fBm, positive lobe only |
|
||||
| `FCaveCliffMod` | density | TunnelNetwork | noise-modulated vertical gradient |
|
||||
| `FScallopMod` | density | TunnelNetwork | cellular noise |
|
||||
| `FArchMod` | density | TunnelNetwork | room-relative |
|
||||
| `FRoomColumnMod` | density | TunnelNetwork | room-relative, pre-baked in `BuildChunkCache` |
|
||||
| `FDomeMod` | density | TunnelNetwork | room-relative |
|
||||
| `FPinchMod` | density | TunnelNetwork | room-relative |
|
||||
| `FFloorBiasMod` | density | TunnelNetwork | only inside cave air |
|
||||
| `FPitMod` / `FChimneyMod` | **SDF** | TunnelNetwork | `SmoothMin`'d into the cave SDF — §0.1's motivating case |
|
||||
| `FGridColumnMod` | density | FlatPlain, CrystalChamber | world-grid cylinders — **different op** from `FRoomColumnMod` |
|
||||
| `FShaftLedgeMod` | density | VerticalShafts | banded shelves, half-sided |
|
||||
| `FChasmMod` / `FBridgeMod` / `FRidgeMod` | density (MC) | **all archetypes** (disturbances) | see §0.3 |
|
||||
|
||||
**The two roughness variants, because this is the trap:**
|
||||
|
||||
- **SDF variant** (Maze, VerticalShafts, FloatingIslands): `Sdf += fBm(x·k, y·k, z·k)·SCALE·Rough`.
|
||||
Raw, no fade, no clamp, fixed frequency baked into the call site (`0.12`, `0.1`, `0.08`).
|
||||
- **Density variant** (TunnelNetwork): two octave sets (main + fine×3), optional domain warp,
|
||||
four noise types, a `min(…, 0)` clamp so roughness can never re-fill definite air, and a
|
||||
quadratic fade by distance from surface.
|
||||
|
||||
They are *not* the same op with different params. Port them as one op with a `Space` enum and let
|
||||
the SDF variant's fixed frequencies become real params — that alone is a small authoring win, and
|
||||
`OPSTACK-PLAN §2.6` explicitly permits the re-tune.
|
||||
|
||||
### Role 4 — STRUCTURAL POST (fixed order, appended by the compiler, never author-omittable)
|
||||
|
||||
| # | Op | Today | Effect over box |
|
||||
|---|---|---|---|
|
||||
| 1 | `FOriginSpineOp` | `ApplyOriginSpine` | `CarveOnly`; `Identity` when the XY circle (R + 3) misses the box, or Z is outside the interior |
|
||||
| 2 | `FBoundarySealOp` | `ApplyBoundarySeal` | **`ClassifyBox` → AllSolid inside its band** (forcing — see `VoxelDensityOp.h`); `Identity` when the box misses both bands |
|
||||
| 3 | `FPassageCarveOp` | `ApplyPassageCarving` | `CarveOnly`; `Identity` via `AnyPassageNearBox` — **already written** |
|
||||
| 4 | `FDiffLayerOp` | the diff block in `GetDensityAt` | `Both` when mods intersect; `Identity` via `HasAnyModInChunkRange` — **already written** |
|
||||
|
||||
The order is load-bearing and is the order the code already uses: spine carves the interior only,
|
||||
the seal then re-solidifies its bands (the spine deliberately never touches them), passages punch
|
||||
through everything including the seal, and the player wins last.
|
||||
|
||||
> ### ⛔ RETIRED 2026-07-28 — frames were never a fifth thing. Porting all three candidates killed it.
|
||||
>
|
||||
> The section below argues for a `FRAME` op family from three examples. All three are now ported,
|
||||
> and none of them turned out to need one:
|
||||
>
|
||||
> - **`CaveWarp` wraps exactly ONE operator.** Pits and chimneys explicitly read *unwarped* coords
|
||||
> while writing the same SDF channel — the thing this document called "the single fiddliest thing
|
||||
> in the whole decomposition". Inside one operator the difficulty evaporates: the warp is a local
|
||||
> variable, not an inherited context. A transform whose scope is one op is not a frame.
|
||||
> - **`VerticalScale` is `Z / Scale`** — a pure function of a scalar and a param, recomputed in one
|
||||
> line by each op that needs it. A frame would add a channel to avoid a division.
|
||||
> - **The island warp (§7)** was kept local for the same reason, before the other two were even read.
|
||||
>
|
||||
> **Zero frames out of three candidates.** It was not missing infrastructure; it was one idea seen
|
||||
> three times from a distance. Kept below as the reasoning that was superseded, not as a plan.
|
||||
|
||||
### A fifth thing the plan doesn't name: FRAME OPS
|
||||
|
||||
Two archetypes transform the *query coordinates* rather than the field:
|
||||
|
||||
- `VerticalScale` — `EffectiveZ = WorldZ / VerticalScale` (TunnelNetwork), stretches everything below it.
|
||||
- `CaveWarpStrength/Frequency` — domain-warps the coords the SDF is evaluated at, *but not* the
|
||||
coords roughness/terrain-ops/columns use. The comment is emphatic about why: *"terracing stays
|
||||
horizontal, columns stay vertical"*.
|
||||
|
||||
So a frame op has **scope** — it applies to some ops below it and not others. Modelling that as
|
||||
"push frame / pop frame" markers in the tape is straightforward; modelling it as a per-op flag is
|
||||
not, because the same op can appear inside and outside a frame. **Decide this before the tape
|
||||
format is fixed.** It also has a `EffectOverBox` consequence: any op under a warp frame must inflate
|
||||
its box by the warp amplitude before answering — the existing code already does exactly this
|
||||
(`Expansion = CaveWarpStrength + 2`).
|
||||
|
||||
---
|
||||
|
||||
## 2. TunnelNetwork *(and Underwater — identical rock, a water flag)*
|
||||
|
||||
`GetDensityWithParams`, ~1080 lines, the biggest single function in the plugin. **Port LAST** — it
|
||||
owns `BuildChunkCache`'s two-region window-invariance discipline (§8.4), the most delicate code here.
|
||||
|
||||
```
|
||||
FRAME VerticalScale (Z pre-divide, wraps everything below)
|
||||
├─ FConstantRockSource Replace BaseDensity
|
||||
├─ FRAME CaveWarp (SDF queries only — NOT the ops below the frame)
|
||||
│ ├─ FRoomGraphSource → Sdf rooms + tunnels, cached per chunk
|
||||
│ ├─ FPitMod → Sdf SmoothMin, unwarped coords ⚠️
|
||||
│ └─ FChimneyMod → Sdf SmoothMin, unwarped coords ⚠️
|
||||
├─ FSdfCarve(SDFBlendRadius) Subtract
|
||||
├─ [gate: bNearCaveSurface = Sdf < SDFBlendRadius·3]
|
||||
│ ├─ FSurfaceRoughnessMod Add density-space variant
|
||||
│ ├─ ── per-room op override ── ⚠️ see below
|
||||
│ ├─ FTerraceMod Add
|
||||
│ ├─ FLayerLineMod Subtract
|
||||
│ ├─ FRibbingMod Add
|
||||
│ ├─ FCaveOverhangMod Add
|
||||
│ ├─ FCaveCliffMod Add
|
||||
│ ├─ FScallopMod Subtract
|
||||
│ ├─ FArchMod Add
|
||||
│ ├─ FRoomColumnMod Add
|
||||
│ ├─ FDomeMod Subtract
|
||||
│ ├─ FPinchMod Add
|
||||
│ └─ FFloorBiasMod Add
|
||||
├─ FWormFieldSource Subtract ⚠️ ungated, unbounded — §0.2
|
||||
└─ [structural post ×4]
|
||||
```
|
||||
|
||||
⚠️ **Pits and chimneys are evaluated at UNWARPED coordinates while rooms are evaluated at warped
|
||||
ones**, and then `SmoothMin`'d together. The comment justifies it (pit anchors come from unwarped
|
||||
room centres). Under a frame model this means pits/chimneys must sit *outside* the warp frame while
|
||||
still writing the same SDF channel. That is expressible, but it is the single fiddliest thing in the
|
||||
whole decomposition — **budget for it and do not discover it during the port.**
|
||||
|
||||
⚠️ **The per-room terrain-op override has no clean home.** Today: `NearestRoomIdx` picks a room,
|
||||
that room's hash-rolled `UVoxelTerrainOpDefinition` is applied onto a *copy of the whole param
|
||||
struct*, and the copy shadows `Params` for the rest of the function. In an op stack there is no
|
||||
"whole param struct" to overwrite. Two options:
|
||||
|
||||
- **(a) Scope by room.** Each modifier gains an optional "only inside room N's influence" predicate,
|
||||
and the room-graph source publishes the nearest-room index per voxel as stack state. Faithful,
|
||||
and it generalises to "this op only inside this region", which is the `Mask` combiner already in
|
||||
the plan.
|
||||
- **(b) Drop per-room ops**, make modifiers strate-wide, and recover variety with `Mask` on a
|
||||
hash field. Much simpler, visibly different world.
|
||||
|
||||
**(a) is right** — per-room variety is a real feature and (b) would flatten it — but it is the piece
|
||||
that could blow the Phase-1 timebox if attempted early. It is also the *only* consumer of
|
||||
`NearestRoomIdx`, so it can be deferred: port TunnelNetwork's geometry first with strate-wide ops,
|
||||
add room scoping after.
|
||||
|
||||
**Tile-skipping prize:** currently zero. After the port, with §0.2's amplitude bound and the room
|
||||
bounds already in `FCachedRoom`, deep bedrock below a tunnel network becomes provably `AllSolid`.
|
||||
This is the largest single perf item in the whole plan.
|
||||
|
||||
---
|
||||
|
||||
## 3. FlatPlain and CrystalChamber
|
||||
|
||||
**These are one op with two default sets** — `OPSTACK-PLAN §4` calls this the first real win, and
|
||||
reading the code confirms it: `GetSlabDensity` is called for both types with no branch on which.
|
||||
CrystalChamber is FlatPlain with a bigger `CeilingRoughness`.
|
||||
|
||||
```
|
||||
FSlabVoidSource Replace floor surface + ceiling surface → void field
|
||||
FGridColumnMod Add world-grid jittered cylinders, infinite height
|
||||
[structural post ×4]
|
||||
```
|
||||
|
||||
That is the entire archetype. Two of the eight collapse into one, and the ceiling's `abs(noise)`
|
||||
(formations hang down only, never punch up) is a two-line flag on the source.
|
||||
|
||||
> **Observed in-editor 2026-07-27, and it settles the question:** Jahni reports FlatPlain and
|
||||
> CrystalChamber render **identical** in the live world. They should — they share
|
||||
> `FSlabGenerationParams`, and nothing in the content sets them apart. **The enum promised a
|
||||
> difference the data never delivered**, in the shipped world as well as in the test fixture.
|
||||
> So the merge does not lose a distinction; it *reveals* that there was none. Making CrystalChamber
|
||||
> look like a crystal chamber is a **params** job — raise `CeilingRoughness` (6 → ~20, what
|
||||
> `SlabEquivalence`'s tuned pass uses) and drop `CeilingRelativeHeight` a little. That is authoring,
|
||||
> which is exactly the outcome the whole refactor is aiming at.
|
||||
|
||||
### 3.1 ✅ RESOLVED 2026-07-27 — Jahni: the Z term can go. Removed.
|
||||
|
||||
**Decision:** the Z term was not intentional character. It is gone from `GetSlabDensity` (both
|
||||
surfaces), `FSlabVoidSource` is XY-pure, and FlatPlain + CrystalChamber are ported and wired.
|
||||
|
||||
**What that bought, and what it cost:**
|
||||
- `IsXYPure() == true` ⇒ the T1.a column-cache treatment becomes available generically.
|
||||
- An **exact `ClassifyBox` with no sampling**: `VoxelNoise::FBM`'s contract is `[-1,1]`, so both
|
||||
surfaces live in Z bands with known bounds — a tile entirely below the floor band is provably
|
||||
solid, a tile strictly between the bands is provably air. These two archetypes proved **zero**
|
||||
tiles before. `VoxelForge.OpStack.SlabEquivalence` reports the count.
|
||||
- **Cost: the world re-tunes once.** Dropping the term samples a different slice of the noise
|
||||
field, so floor and ceiling shapes change (they do not degrade). Covered by §2.6's explicit
|
||||
permission to re-tune.
|
||||
|
||||
The original finding, kept because it explains why the answer mattered:
|
||||
|
||||
`FSlabVoidSource` is **not XY-pure, and probably should be.** Both surfaces sample noise with a
|
||||
small Z term:
|
||||
|
||||
```cpp
|
||||
FloorNoise: FractalNoise3D(x·FF, y·FF, WorldZ·FF·0.05f) // ← Z
|
||||
CeilNoise: FractalNoise3D(x·CF, y·CF, WorldZ·CF·0.08f) // ← Z
|
||||
```
|
||||
|
||||
A "floor surface height" that depends on the altitude you sample it from is geometrically odd — the
|
||||
floor is at a different height depending on which voxel asks. In practice the coefficient is tiny so
|
||||
it reads as a subtle vertical smear rather than a bug, and it is deterministic, so nothing is broken.
|
||||
But it blocks the T1.a column-cache treatment and it makes an exact `ClassifyBox` more awkward
|
||||
(the surface must be sampled per Z rather than per column).
|
||||
|
||||
**Question for Jahni: was the `·0.05f` Z term intentional character, or a leftover from copying the
|
||||
3D-noise call signature?** If it can go, `FSlabVoidSource` becomes XY-pure, gets the column cache
|
||||
for free, and gets an exact box classification — which means FlatPlain and CrystalChamber start
|
||||
skipping trivial tiles, which they never have. That is a large win for a one-character change, so
|
||||
it is worth asking rather than assuming either way.
|
||||
|
||||
**Answered: it can go.** See the resolution above.
|
||||
|
||||
---
|
||||
|
||||
## 4. Maze — **the Phase 1 port**
|
||||
|
||||
The plan picks Maze, and the code justifies the pick completely. ~100 lines, and it decomposes
|
||||
without any of the awkwardness elsewhere.
|
||||
|
||||
```
|
||||
FConstantRockSource Replace BaseDensity
|
||||
FLatticeCorridorSource → Sdf capsules over open lattice edges
|
||||
FSurfaceRoughnessMod → Sdf SDF-space variant, frequency 0.12 (hardcoded today)
|
||||
FSdfCarve(blend = 2.0) Subtract
|
||||
[structural post ×4]
|
||||
```
|
||||
|
||||
**Why it is the right first port, beyond size:** edge identity is `hash(lower node, axis)`, so two
|
||||
adjacent chunks *cannot* disagree. No `BuildChunkCache`, no COLLECT/STORE region, no window-invariance
|
||||
risk at all (`AUDIT §6.4` names this the pattern to prefer). If the source/modifier split does not
|
||||
fall out here, it will not fall out anywhere, and that is exactly what the stop-trigger is for.
|
||||
|
||||
**`EffectOverBox`:** the open-edge set reachable from a box is the same `{-1,0}³` node sweep the
|
||||
per-voxel code already does, at cell granularity. Capsule bound = `CorridorRadius + roughness
|
||||
amplitude + blend`. `Identity` when no open edge's capsule reaches the box — which for a sparse
|
||||
`BranchProbability` is most of the volume. **Maze currently skips zero tiles; this is its first.**
|
||||
|
||||
**The proof `OPSTACK-PLAN §4` asks for:** once `FLatticeCorridorSource` exists, drop it into a
|
||||
`SurfaceWorld` strate under the terrain and confirm you get a maze inside a mountain with no C++.
|
||||
Note this requires §0.1's SDF channel to look *good* (smooth junctions where corridors meet rock);
|
||||
it works but reads harsh without it.
|
||||
|
||||
---
|
||||
|
||||
## 5. SurfaceWorld — biggest payoff, most care
|
||||
|
||||
Three XY-pure functions plus a cheap per-voxel combine. The column cache (T1.a) and the exact-lattice
|
||||
`ClassifyTile` bound must both survive the port — they are the two most valuable pieces of
|
||||
engineering in the file.
|
||||
|
||||
```
|
||||
FHeightfieldSource Replace ← the whole column pipeline, XY-pure
|
||||
├─ FStructuralHeightField continents + mountains + detail, under a warp frame
|
||||
├─ FCliffHeightMod slope-gated steepening (4 structural resamples)
|
||||
├─ FTerraceHeightMod relief-gated plateaus
|
||||
├─ FLayerLineHeightMod sine bands
|
||||
└─ FBeachHeightMod flatten toward the water line
|
||||
FSkyCapSource Subtract ceiling: warp + signed swell + downward-only hang
|
||||
FOverhangShelfMod Union ⚠️ per-voxel, NOT XY-pure — the one 3D op here
|
||||
[structural post ×4]
|
||||
```
|
||||
|
||||
> ### ⚠️ RESOLVED 2026-07-27 — the height ops needed a SECOND OP FAMILY, not a sub-list
|
||||
>
|
||||
> This section says the height ops *"operate on Z values in the column, not on density"* and then
|
||||
> lists them as children of `FHeightfieldSource`. Writing them made the consequence unavoidable:
|
||||
> **they do not fit `IVoxelDensityOp` at all.** Its signature is `Eval(x, y, z, FVoxelOpSample&)` —
|
||||
> per voxel, density + SDF. A height op has **no input Z** (it produces one), is XY-pure (once per
|
||||
> column), and writes neither channel.
|
||||
>
|
||||
> The two ways to force it were both bad: a per-voxel third channel for what is a **column**
|
||||
> property, or collapsing all five into one opaque op — `OPSTACK-PLAN §2.5`'s explicit failure mode.
|
||||
>
|
||||
> **So height space got its own contract: `VoxelHeightOp.h`** (`FVoxelHeightSample` with
|
||||
> `Height` + `Relief`, `IVoxelHeightOp`, `FVoxelHeightStack`). Same lesson as `§0.1`, one step
|
||||
> further: §0.1 found that density needed a second *channel*; this found that terrain needs a second
|
||||
> *space*. Verified by `VoxelForge.OpStack.SurfaceHeightEquivalence` before anything was built on
|
||||
> top of it — deliberately, so a wrong answer would have cost one test rather than a whole port.
|
||||
>
|
||||
> **Bonus the type system gives for free:** a height stack cannot contain Z-dependent data, because
|
||||
> there is no Z in the signature to put there. `AUDIT §6.3` warns that Z-dependent data smuggled into
|
||||
> `FSurfaceColumn` silently corrupts every chunk in the vertical stack and that `ValidateDeterminism`
|
||||
> would not catch it. Here the *type* forbids it rather than a convention.
|
||||
|
||||
**Critical distinction the port must preserve:** the height ops (`FCliffHeightMod` and friends)
|
||||
operate on **Z values in the column**, not on density. They are XY-pure and belong in
|
||||
`PrepareChunk`/the column cache. `FOverhangShelfMod` operates per voxel and re-samples the
|
||||
structural heightfield at a shifted XY. Mixing those two up puts Z-dependent data in `FSurfaceColumn`,
|
||||
which `AUDIT §6.3` warns silently corrupts every chunk in the vertical stack — **and
|
||||
`ValidateDeterminism`, which samples along an X boundary, would not catch it.**
|
||||
|
||||
This is the archetype where `IsXYPure()` earns its place in the contract: it turns an implicit
|
||||
convention that has to be remembered into a declaration the compiler routes on.
|
||||
|
||||
**Biome blending:** the heightfield is evaluated for the dominant biome and its nearest neighbour and
|
||||
the two *heights* are lerped. In stack terms that is the `Mask` combiner with a biome-weight field —
|
||||
which is exactly the mechanism `OPSTACK-PLAN §4 Phase 3` wants for unifying strates and biomes. So
|
||||
SurfaceWorld's existing biome blend is the prototype for the whole Phase 3 idea, and porting it is
|
||||
how that gets validated.
|
||||
|
||||
---
|
||||
|
||||
## 6. VerticalShafts
|
||||
|
||||
```
|
||||
FConstantRockSource Replace
|
||||
FShaftFieldSource → Sdf infinite cylinders (XY-only) + hash-gated connectors
|
||||
FSurfaceRoughnessMod → Sdf SDF variant, frequency 0.1
|
||||
FSdfCarve(blend = 2.0) Subtract
|
||||
FShaftLedgeMod Union banded shelves, +X/+Y half only so a climb path remains
|
||||
[structural post ×4]
|
||||
```
|
||||
|
||||
Nearly identical in shape to Maze — same `source → roughness → carve` spine, different primitive.
|
||||
That similarity is the evidence the abstraction is real: two archetypes that look unrelated in the
|
||||
`switch` are the same three ops with a different source.
|
||||
|
||||
**Split worth making:** the shafts are XY-pure infinite cylinders; the connectors are not. Two ops
|
||||
(`FShaftColumnSource` XY-pure + `FShaftConnectorSource`) let the cylinder half get the column-cache
|
||||
treatment and answer `ClassifyBox` exactly in XY. Keeping them as one op forfeits that.
|
||||
|
||||
---
|
||||
|
||||
## 7. FloatingIslands
|
||||
|
||||
The only archetype whose source is **air**, which is what makes it a good composition test.
|
||||
|
||||
```
|
||||
FConstantVoidSource Replace −BaseDensity (open void)
|
||||
FRAME IslandWarp XY domain warp (lobed outlines, amplitude ~0.35·meanR)
|
||||
└─ FIslandBlobSource → Sdf tapered flat-top blobs, SmoothMin'd together
|
||||
FSurfaceRoughnessMod → Sdf SDF variant, frequency 0.08, 4 octaves
|
||||
FSdfFill(SDFBlendRadius) Union
|
||||
[structural post ×4]
|
||||
```
|
||||
|
||||
Note the warp here is applied to the **query** (`WX`,`WY` computed once per voxel and shared by all
|
||||
nearby islands) exactly like TunnelNetwork's cave warp — same frame concept, third instance. Three
|
||||
uses is enough to make frames a first-class part of the model rather than a special case.
|
||||
|
||||
**`FIslandBlobSource` is the cleanest `Identity` opportunity in the plugin:** islands are hash-placed
|
||||
with a known XY radius and explicit `TopZ`/`BotZ`. A box that no island's AABB reaches is provably
|
||||
untouched — and since a floating-island strate is *mostly* empty void, that is most tiles. Combined
|
||||
with `FConstantVoidSource`'s `ClassifyBox → AllAir`, a FloatingIslands strate could go from skipping
|
||||
zero tiles to skipping the large majority of them.
|
||||
|
||||
#### ✅ PORTED 2026-07-28 — three deviations from the sketch above, all deliberate
|
||||
|
||||
1. **`FConstantVoidSource` and `FSdfFill` are not new classes.** Each is the class it mirrors, with
|
||||
the opposite **sign**: `FConstantFieldSource(±Base)` and `FSdfConvertOp(Sign = ±1)`, two factories
|
||||
each. The table above listed them as separate ops; writing them separately would have duplicated
|
||||
the classifier and the six-line formula for nothing. Multiplying by ±1 is exact in IEEE-754, so
|
||||
the three already-green ports are bit-for-bit untouched by the generalisation.
|
||||
**This is the port's actual result:** reuse **by inversion** rather than by identity — evidence
|
||||
that the abstract axis (the sign of the internal density) is the right one, not just that two
|
||||
archetypes happened to look alike.
|
||||
2. **The warp stays inside the source; no `FRAME` op was built.** Frames are worth building at the
|
||||
second real user, and two of the three (`TunnelNetwork`'s cave warp, its tunnel warp) are not
|
||||
ported yet. Designing the abstraction against a single example is what this refactor has avoided
|
||||
throughout — cf. `IVoxelBiomeField`, which was born from a concrete second need. Revisit with
|
||||
TunnelNetwork.
|
||||
3. **`AUDIT §C1`'s last surviving site was in this archetype** and was fixed in both paths in the
|
||||
same pass (the warp's `(float)S * 0.0007f`; the 2026-07-27 sweep matched `SeedF * K` and missed
|
||||
the `(float)S` spelling).
|
||||
|
||||
**The `Identity` bound is one-sided, and that matters:** `Sdf ≥ WorldZ − TopSurf` bounds an island
|
||||
from **above** only. Below `BotZ` the SDF degenerates to ≈ `DistXY`, so a hairline thread of matter
|
||||
hangs down each island's axis to the strate floor. Rejecting a box because it sits below an island
|
||||
would be a hole. Only the top rejects.
|
||||
|
||||
---
|
||||
|
||||
## 8. Underwater
|
||||
|
||||
`GetDensityAt` routes `Underwater` to `GetDensityWithParams` with a comment: *"Underwater shares
|
||||
tunnel rock (water table is a render-side overlay)."* There is **no density difference at all**.
|
||||
|
||||
So `Underwater` is not an archetype, it is TunnelNetwork plus `WaterLevelRelative` consumed by the
|
||||
water render system. When `ECaveGeneratorType` finally disappears, this one vanishes for free — it
|
||||
never needed to exist as a generator type.
|
||||
|
||||
---
|
||||
|
||||
## 9. Q2 — the param audit: every field, and who claims it
|
||||
|
||||
`FStrateGenerationParams` via the `VF_STRATE_PARAM_FIELDS` X-macro. **Every field is claimed except
|
||||
where flagged.**
|
||||
|
||||
| Field(s) | Destination op |
|
||||
|---|---|
|
||||
| `BaseDensity` | **stack-global** — read by every source and all four structural-post ops. Not any single op's param. |
|
||||
| `VerticalScale` | `FRAME VerticalScale` |
|
||||
| `WormFrequency`, `WormHorizontalBias`, `WormThreshold`, `WormStrength`, `WormNetworkRange` | `FWormFieldSource` |
|
||||
| `RoomSpacing`, `RoomDensity`, `MinRoomRadius`, `MaxRoomRadius`, `RoomHeightRatio`, `RoomShapeVariety`, `RoomFloorCutMin`, `RoomFloorCutMax`, `FloorReliefStrength`, `FloorReliefFrequency` | `FRoomGraphSource` |
|
||||
| `OriginRoomRadius`, `OriginRoomMaxConnections` | `FRoomGraphSource` — ⚠️ but see §10.3, they couple to the spine |
|
||||
| `TunnelMinRadius`, `TunnelMaxRadius`, `TunnelDensity`, `MaxTunnelLength`, `TunnelWarpStrength`, `TunnelHorizontalBias`, `bTunnelsFlowTowardOrigin`, `TunnelEndpointZOffset` | `FRoomGraphSource` |
|
||||
| `SDFBlendRadius` | `FSdfCarve` / `FSdfFill` — **shared**, also the `bNearCaveSurface` gate width |
|
||||
| `CaveWarpStrength`, `CaveWarpFrequency` | `FRAME CaveWarp` |
|
||||
| `SurfaceRoughness`, `RoughnessFrequency`, `RoughnessNoiseType`, `DomainWarpStrength`, `DomainWarpFrequency` | `FSurfaceRoughnessMod` (density variant) |
|
||||
| `BoundarySealThickness` | `FBoundarySealOp` — and read by the spine, disturbances, shaft connectors, island spread |
|
||||
| `StrateTopWorldZ`, `StrateBottomWorldZ` | **`FVoxelOpContext`, not op params.** Runtime-injected, never author-set. |
|
||||
| `FloorBias` | `FFloorBiasMod` |
|
||||
| `TerraceStepHeight`, `TerraceHardness`, `TerraceNoiseDisplacement` | `FTerraceMod` |
|
||||
| `LayerLineSpacing`, `LayerLineDepth` | `FLayerLineMod` |
|
||||
| `OverhangStrength`, `OverhangDepth`, `OverhangFrequency` | `FCaveOverhangMod` ⚠️ name collision, §9.1 |
|
||||
| `RibbingSpacing`, `RibbingDepth` | `FRibbingMod` |
|
||||
| `CliffStrength` | `FCaveCliffMod` ⚠️ name collision, §9.1 |
|
||||
| `ScallopStrength`, `ScallopFrequency` | `FScallopMod` |
|
||||
| `ArchDensity`, `ArchMinRadius`, `ArchMaxRadius` | `FArchMod` |
|
||||
| `ColumnDensity`, `ColumnMinRadius`, `ColumnMaxRadius` | `FRoomColumnMod` ⚠️ name collision, §9.1 |
|
||||
| `PitDensity`, `PitMinRadius`, `PitMaxRadius`, `PitDepth` | `FPitMod` |
|
||||
| `ChimneyDensity`, `ChimneyMinRadius`, `ChimneyMaxRadius`, `ChimneyHeight` | `FChimneyMod` |
|
||||
| `DomeDensity`, `DomeMinRadius`, `DomeMaxRadius`, `DomeHeightRatio` | `FDomeMod` |
|
||||
| `PinchDensity`, `PinchStrength`, `PinchLength` | `FPinchMod` |
|
||||
| **`WaterLevelRelative`** | ⚠️ **claimed by no density op.** See §9.2. |
|
||||
|
||||
### 9.1 Three names mean different things in different structs
|
||||
|
||||
`FStrateGenerationParams` and `FSurfaceGenerationParams` both define `CliffStrength`,
|
||||
`OverhangStrength`/`OverhangFrequency`, `TerraceHardness` and `ColumnDensity`-family fields — and
|
||||
they are **genuinely different operations**:
|
||||
|
||||
| Name | in `FStrateGenerationParams` (cave) | in `FSurfaceGenerationParams` (surface) |
|
||||
|---|---|---|
|
||||
| `CliffStrength` | noise-modulated vertical density gradient near a cave wall | slope-gated steepening of a **height value** |
|
||||
| `OverhangStrength` | fBm lobe adding rock into a cave | F20 warped-terrain union making a real 3D shelf |
|
||||
| `TerraceHardness` | staircase edge width on a cave wall | plateau/riser ratio of a **height** quantiser |
|
||||
| `ColumnDensity` | room-anchored columns | *(slab struct)* world-grid cylinders |
|
||||
|
||||
Today the type system keeps them apart. Once ops are data assets in one list, **nothing does** —
|
||||
`DA_Op_Cliff` would be ambiguous. Name them for what they operate on from day one:
|
||||
`FCaveWallCliffMod` vs `FHeightSlopeCliffMod`, `FCaveShelfMod` vs `FTerrainOverhangMod`. Cheap now,
|
||||
a rename with authored assets in the field later.
|
||||
|
||||
### 9.2 The one unclaimed field: `WaterLevelRelative`
|
||||
|
||||
It lives in `FStrateGenerationParams` (and is `Lerp`'d at every strate boundary along with the
|
||||
density params), but **nothing in the density path reads it.** Its consumers are
|
||||
`GetWaterLevelWorldZForChunk` (the water render system) and `ComputeSurfaceTerrainZ`'s beach
|
||||
flattening — which reads the *surface* struct's copy, not this one.
|
||||
|
||||
Not dead, but misfiled: it is a *content/render* property riding in a *density* struct, and it is
|
||||
being interpolated across strate boundaries where a water plane should probably be a hard property
|
||||
of one strate. **Report, don't delete** — but when ops become assets it should move to the strate
|
||||
itself rather than to any op.
|
||||
|
||||
### 9.3 Fields that are context, not params
|
||||
|
||||
`StrateTopWorldZ` / `StrateBottomWorldZ` are runtime-injected by
|
||||
`UVoxelStrateManager::Get*ParamsForChunk`, never author-set, and every archetype's first line is a
|
||||
degenerate check on them. They belong in `FVoxelOpContext` (where the header already puts them) and
|
||||
should be *removed* from the per-op param structs so an author cannot see or set them. That deletes
|
||||
a whole class of "I set the Z bounds and nothing happened".
|
||||
|
||||
---
|
||||
|
||||
## 10. Ordering rules the compiler must enforce
|
||||
|
||||
### 10.1 Structural post is appended, always, in order
|
||||
`FOriginSpineOp` → `FBoundarySealOp` → `FPassageCarveOp` → `FDiffLayerOp`. Verified identical in all
|
||||
six density functions. An author cannot omit, reorder or insert between them.
|
||||
|
||||
### 10.2 Disturbances run after the archetype, before the diff layer
|
||||
`ApplyDisturbances` is called in `GetDensityAt` *after* the archetype function returns (so after that
|
||||
function's own spine/seal/passages) and *before* the diff layer. So the true global order is:
|
||||
|
||||
```
|
||||
[archetype stack] → spine → seal → passages → disturbances → diff layer
|
||||
```
|
||||
|
||||
Disturbances self-limit to the seal interior (`if (Z <= InnerBot || Z >= InnerTop) return;`), which
|
||||
is why running them after the seal is safe. **Preserve this or bridges will punch through seals.**
|
||||
|
||||
### 10.3 The spine and the origin room are two systems aimed at the same place
|
||||
`ApplyOriginSpine` carves an unconditional column at XY (0,0) in every strate; `OriginRoomRadius`
|
||||
makes the room graph put a big room there too; `bOpenSurfaceEntry` opens a shaft from above. Three
|
||||
mechanisms, one location, and `AUDIT C8` already flags an unchecked invariant between
|
||||
`OriginRoomRadius` and the COLLECT margin. When these become ops, the coupling becomes visible and
|
||||
should be either unified or documented — right now it works by everyone independently agreeing that
|
||||
(0,0) is special.
|
||||
|
||||
### 10.4 Recommended port order (unchanged from the plan, now with reasons from the code)
|
||||
|
||||
1. **Maze** — cleanest split, no connectivity decision, cheapest mistake. §4.
|
||||
2. **FlatPlain + CrystalChamber** — two archetypes → one op. Ask §3.1 first.
|
||||
3. **FloatingIslands** — the biggest `Identity` win, and the first air-source stack.
|
||||
4. **VerticalShafts** — same spine as Maze, validates the source-swap claim.
|
||||
5. **SurfaceWorld** — biggest payoff; the column cache and exact-lattice bound must survive.
|
||||
6. **TunnelNetwork** — last. §8.4, the warp/pit coordinate split, and the per-room op override.
|
||||
7. **Underwater** — falls out of 6 for free.
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions for Jahni
|
||||
|
||||
Ranked by how much they change the work.
|
||||
|
||||
1. **Two-channel `Eval` (density + SDF)?** §0.1. Changes the contract. Cheapest to decide now.
|
||||
My recommendation: yes — without it, cross-source `SmoothMin` is impossible and "a maze inside a
|
||||
mountain" reads as a hole punched in rock rather than a cave that belongs there.
|
||||
2. **Is `FSlabVoidSource`'s Z-term intentional?** §3.1. One character; unlocks XY-purity, the column
|
||||
cache and exact tile classification for two archetypes.
|
||||
3. **Per-room terrain ops: scope-by-room (faithful) or strate-wide + `Mask` (simpler, flatter)?**
|
||||
§2. Recommend scope-by-room, but *after* the geometry port, not during it.
|
||||
4. **Amplitude bound on the worm carve in Phase 2 rather than Phase 3?** §0.2. It is the difference
|
||||
between TunnelNetwork skipping tiles and never skipping tiles.
|
||||
5. **Rename the colliding cave/surface op names before any asset is authored?** §9.1.
|
||||
|
||||
---
|
||||
|
||||
*Written 2026-07-27 by Opus 5, unattended, as build-free queue item Q1. Companion to
|
||||
`OPSTACK-PLAN.md` (the plan) and `OPSTACK-PROGRESS.md` (what is actually built).*
|
||||
@@ -0,0 +1,208 @@
|
||||
# Handoff — VoxelForge operator stack, 2026-07-28 (Phase 2 complete and green)
|
||||
|
||||
> Paste the block below into a fresh session. Everything it refers to is on disk and in git.
|
||||
>
|
||||
> **State:** Phase 2 is **DONE — 8 of 8 archetypes ported, built, and green** (14 tests, two
|
||||
> consecutive green builds). `ClassifyTile` consumes `ClassifyBox`. **Everything in git is compiled
|
||||
> and tested.** One question to confirm in the first run, then one clear next task.
|
||||
|
||||
---
|
||||
|
||||
You're picking up the VoxelForge UE5 voxel plugin on branch `experimental` (already checked out —
|
||||
do not create another). I'm Jahni. The design and the history are written down so you don't
|
||||
re-derive them.
|
||||
|
||||
## Read first, in this order
|
||||
|
||||
1. **`CLAUDE.md`** — project rules. **Rule #1 is absolute: never build, compile, or run the editor.**
|
||||
I build everything myself. When code is done, stop, say "ready to build", list the likely
|
||||
compile-error spots, and wait.
|
||||
2. **`OPSTACK-PROGRESS.md` — THE LAST ENTRY FIRST.** Append-only log; the resume point. The last
|
||||
entry is the green build with every measured number in it.
|
||||
3. **`OPSTACK-PLAN.md`** — the plan. **§2.6.1 is the acceptance bar** and supersedes §2.6.
|
||||
4. **`OPSTACK-DECOMPOSITION.md`** — per-archetype breakdown. **§0.2** (the amplitude bound) is the
|
||||
live one; §2 TunnelNetwork and §8 Underwater are now history, not instructions.
|
||||
5. **`AUDIT-2026-07.md`** — **§C2 has a CONFIRMED sub-item as of 2026-07-28, read it**; §C10 is
|
||||
SOLVED, don't reopen; §C9's library half is the top open theoretical risk with 0 measured
|
||||
exposure.
|
||||
6. **`CODEMAP.md`** — navigation. Trust symbol names over line numbers.
|
||||
|
||||
## Where things stand — the transition is COMPLETE and VERIFIED
|
||||
|
||||
All 8 archetypes have an operator-stack twin, per-strate opt-in, each equivalence-tested **bit for
|
||||
bit** against its original density function. The `switch` and the stack are now two complete,
|
||||
interchangeable implementations.
|
||||
|
||||
| Archetype | State |
|
||||
|---|---|
|
||||
| `Maze` | ✅ ported, bit-identical, wired |
|
||||
| `FlatPlain` + `CrystalChamber` | ✅ **one op for both**, bit-identical, wired |
|
||||
| `SurfaceWorld` | ✅ ported incl. biome blending, bit-identical, wired |
|
||||
| `VerticalShafts` | ✅ ported, bit-identical, wired |
|
||||
| `FloatingIslands` | ✅ ported, bit-identical, wired — the stack that runs **backwards** |
|
||||
| `TunnelNetwork` | ✅ **19 ops**, bit-identical incl. all 12 detail modifiers + per-room override |
|
||||
| `Underwater` | ✅ same builder, second `case` — confirm its coverage number once, see below |
|
||||
|
||||
Everything sits behind `UVoxelStrateDefinition::bUseOperatorStack`; the ported list lives **only** in
|
||||
`UVoxelStrateManager::UsesOperatorStackForChunk` (now all 8). **No strate asset has the box ticked**
|
||||
— that is my call and I haven't made it. But the flag is no longer a no-op anywhere: ticking it now
|
||||
really switches that strate onto the stack, for density *and* for tile classification.
|
||||
|
||||
`ClassifyTile` **consumes `ClassifyBox`** for cave archetypes (SurfaceWorld and bedrock gaps keep
|
||||
their hand-written exact-lattice proofs). `GetDensityAt` and `ClassifyTile` build the stack through
|
||||
the **same** factory, `VF_BuildOpStackForChunk` — a second copy would be a hole, not a bug.
|
||||
|
||||
## The one number to confirm, and it takes one run
|
||||
|
||||
The first green build reported this, and it is the one result worth understanding before trusting
|
||||
anything about `Underwater`:
|
||||
|
||||
```
|
||||
Underwater (stage C2): bit-identical across 2000 samples — 0 of them in open cave (0.0%)
|
||||
```
|
||||
|
||||
**A green bit-identity over 2000 samples of solid rock is not evidence** — it is exactly what two
|
||||
agreeing voids look like. Same failure as stage A's 1.1 % run, in a different slot, caught by a
|
||||
counter written for it.
|
||||
|
||||
A real bug surfaced while diagnosing it: the sampled chunk-Z range used `Z / CHUNK_SIZE`, and C++
|
||||
integer division **truncates toward zero**. TunnelNetwork is at the top of the layout in positive Z
|
||||
where truncation == floor, so it could not show there; Underwater is at the **bottom, in negative
|
||||
Z**, where it shifts the upper chunk bound a notch high and the `Clamp` piles samples into the top
|
||||
seal band. Fixed (`FloorDivChunk`), sampling widened 8 → 24 clusters, **and not trusted**: check 5b
|
||||
gives each of the three possible causes (the bake / the sampled Z range / the XY spread) its own
|
||||
number and prints how to read them.
|
||||
|
||||
**That fix is built — the second build was green too. What I did not see is the number.** So:
|
||||
|
||||
> **First action: run the `VoxelForge` filter and read the `Underwater diagnosis` line, plus the
|
||||
> cave-coverage percentage on the line above it.**
|
||||
>
|
||||
> - **Non-zero cave coverage** ⇒ the truncation *was* the cause, `Underwater` is genuinely covered,
|
||||
> and this whole section is closed. Say so in `OPSTACK-PROGRESS.md` and move on to the next
|
||||
> section — do not go looking for a bug that no longer exists.
|
||||
> - **Still 0.0 %** ⇒ the diagnosis line names which of the three causes it is, and the fix follows
|
||||
> from that rather than from a guess.
|
||||
>
|
||||
> Either way it is one run, and the answer is printed. Do not infer it from the fact that the suite
|
||||
> is green: a bit-identity over solid rock is green for the wrong reason, which is the entire point
|
||||
> of that counter existing.
|
||||
|
||||
## Then the one task everything is waiting on
|
||||
|
||||
**Make `FRoomGraphSource::EffectOverBox` answer spatially.**
|
||||
|
||||
TunnelNetwork proves **0 of 40** tiles today, and the test asserts that. The chain dies at the room
|
||||
source, which returns `Both` with unknown amplitude before anything downstream is reached. Its room
|
||||
and tunnel bounds (`FCachedRoom::CullRadiusSq`, `FCachedTunnel::BoundRadiusSq`) are **already in the
|
||||
SDF cache**; what it costs is building that cache for the *queried box*, on the querying thread.
|
||||
|
||||
That cost is now clearly worth paying, and every other piece is already built to receive it:
|
||||
|
||||
- `ClassifyTile` consumes `ClassifyBox` in production, so a proved tile skips `GenerateMesh` —
|
||||
30 000+ density evaluations saved against one `BuildChunkCache`;
|
||||
- the fold carries **numbers** (`MaxCarveOverBox` / `MaxFillOverBox` / `ForcedMarginOverBox`), so a
|
||||
bounded worm no longer kills `AllSolid` on rock that is solid by more than it can carve;
|
||||
- the twelve detail modifiers already **inherit** the room source's verdict via `VF_NoCaveOverBox` —
|
||||
the day the source says `Identity` for a box, all twelve follow, in one place rather than thirteen.
|
||||
|
||||
**Keep the brute-force check.** `VoxelForge.OpStack.ClassifyTileSoundness` verifies verdicts against
|
||||
`GetDensityAt` on a world where every strate opted in. A false verdict is an invisible hole: no
|
||||
geometry, **no collision**, until a player falls through it.
|
||||
|
||||
## ⚠️ Debts that must be paid BEFORE that lands, not after
|
||||
|
||||
Both were introduced knowingly and are written at the exact site a reader would land on.
|
||||
|
||||
1. **Box bounds read STRATE params, but a per-room op can raise them.** `EffectOverBox` and the new
|
||||
amplitude bounds are computed from strate params, because a box spans many rooms. But `ApplyTo`
|
||||
writes the op's value **even where the strate's was 0**, so a room op can enable a modifier the
|
||||
strate had switched off, or give it a bigger amplitude. A box verdict on a strate with a
|
||||
terrain-op pool can therefore be **too optimistic** — the dangerous direction. Harmless while the
|
||||
room source answers `Both` (nothing is provable anyway); **not harmless the moment it doesn't.**
|
||||
Noted at `FLayerLineMod::EffectOverBox` and `FRoomGraphSource::LocalParams()`.
|
||||
2. **`AUDIT §C2` is confirmed and unfixed on the `switch` path.** `GetGenerationParams` blends params
|
||||
*within* a strate (`Alpha` depends on chunk Z for `Gradient`, and on chunk XY too for
|
||||
`Interleaved`), and `Gradient` + `TransitionBlendChunks = 2` are the **defaults**. The original's
|
||||
SDF cache key has neither params nor chunk Z, so a worker evaluates the second chunk it builds
|
||||
against the first chunk's rooms — and *which* chunk came first depends on worker order, so two
|
||||
peers can diverge from the same seed. The op stack does **not** inherit it (params CRC in the
|
||||
key), and `ClassifyTile`'s new path guards against it explicitly (params must be bit-identical
|
||||
across every chunk coord the box touches). The fix on the `switch` path is a params CRC in its
|
||||
key — a live-generation change that wants a build in front of it.
|
||||
|
||||
## After that, in order
|
||||
|
||||
1. **PERF — unparked.** The op path is measurably slower. One cause found and fixed (the column memo
|
||||
discarded itself every chunk). Remaining suspects in order: the hashed column lookup vs
|
||||
`GSurfColCache`'s direct-indexed box, then per-voxel virtual dispatch. Also measured and stated:
|
||||
the gate is now tested twelve times per voxel instead of once (stage B5's deliberate trade).
|
||||
**Measure before optimising** — that is the §C10 lesson.
|
||||
2. **`VerticalShafts` proves 0 of 60 tiles.** Pessimistic, not wrong: `EffectOverBox` returns
|
||||
`CarveOnly` whenever any shaft is within a `Spacing*1.6` halo instead of testing real connector
|
||||
capsules. Lost CPU, never a hole.
|
||||
3. **`AUDIT §C9` library half** — `sinf`/`cosf` are not IEEE-754 specified, so MSVC's CRT and glibc's
|
||||
libm can differ. Currently **0 samples within 1e-6 of the isosurface**, i.e. no measured risk. Run
|
||||
`CrossPlatformDigest` on Linux, compare the SHAPE digest, pin it. The real fix if ever needed is a
|
||||
deterministic in-house sin/cos.
|
||||
4. **Phase 3 — ops as data assets.** A design conversation, not a transcription. Don't start it
|
||||
unprompted. What makes it possible is already in place: ops depend on capabilities
|
||||
(`IVoxelBiomeField`), never on `UVoxelGenerator`.
|
||||
|
||||
## Hard rules that prevent real bugs
|
||||
|
||||
- **Density sign:** negative = solid at the mesher. Inside the op stack the convention is INTERNAL
|
||||
(**positive = solid**), negated once by the caller. The SDF channel uses standard SDF convention.
|
||||
- **Never run both density paths in one world.** **Comparing them is legitimate** — §C10 is closed
|
||||
since `FPSemantics = Precise`, and all eight equivalence tests compare bit for bit. They are
|
||||
**port-correctness oracles**, not fidelity checks: §2.6.1 requires *same seed ⇒ same world on every
|
||||
peer*, not resemblance to the pre-refactor world.
|
||||
- **Every cache key includes `LayoutVersion` AND the params.** See §C2 and the overhang regression of
|
||||
2026-07-27, where omitting the params silently deleted the overhang and only 1 sample in 20 000
|
||||
crossed the isosurface.
|
||||
- `ProcessQueue` stays `EQueueMode::Mpsc`; `Epoch` carries through every async path; don't "optimize"
|
||||
the `ARCHITECTURE §8.10` invariants.
|
||||
- Commit per coherent unit with a real message. **Never push.** `main` is the known-good fallback.
|
||||
- Update `CODEMAP §3`, `ARCHITECTURE §8`, tick `OPSTACK-PLAN`, append to `OPSTACK-PROGRESS.md`.
|
||||
- **When inserting a class into `VoxelDensityOpStack.cpp` / `VoxelHeightOpStack.cpp`, put it ABOVE
|
||||
the labelled end of the anonymous namespace.** Anchoring on the FACTORIES banner puts it outside,
|
||||
and the brace added with it closes nothing. Made that mistake twice; both files say so at the
|
||||
exact line.
|
||||
|
||||
## Method lessons this refactor actually paid for
|
||||
|
||||
Ordered by how much they cost.
|
||||
|
||||
- **Instrument before hypothesising.** §C10 cost six builds and five refuted hypotheses, then was
|
||||
solved for free by a build setting changed for an unrelated reason. Park a question whose
|
||||
consequences are measured and benign.
|
||||
- **Verify the premise before reasoning from it.** Five times now a confident chain rested on an
|
||||
unchecked assumption and the check reversed it: C1's *documented* fix was wrong; "C9's risk is gone
|
||||
after FPSemantics" was wrong; "C1 is closed, 0 sites left behind" was wrong (the sweep matched a
|
||||
*spelling*); "PitDensity enables pits" was wrong; "there are 13 detail modifiers" was wrong (twelve,
|
||||
and only eleven read the per-room copy). **A grep over a spelling is evidence about the spelling.**
|
||||
- **Read the code, not the comment.** The cliff modifier's comment promises a sampled Z±1 gradient;
|
||||
the code samples nothing and uses a Z-stretched Perlin it *calls* `VertGrad`. Ported as written —
|
||||
and written down, so nobody "fixes" it from the comment.
|
||||
- **A perf change can be a correctness change.** The column-memo optimisation silently deleted the
|
||||
overhang; the tests caught it the same day. Invisible to inspection, and it produced plausible
|
||||
terrain.
|
||||
- **Coverage is a number, not a boolean.** Four related traps, each of which produced a green run
|
||||
that proved almost nothing:
|
||||
- *A test that prints nothing on success is indistinguishable from one that never ran.*
|
||||
- *A guard that only trips at zero notices absence, it does not measure coverage.* Use fractions.
|
||||
- *A success message that **asserts** coverage instead of reporting it reads as evidence while
|
||||
measuring nothing.*
|
||||
- *A check can be vacuous as well as a counter.* "Nothing leaked" is worthless unless something
|
||||
happened — so the gate check also reports how many samples move when the modifiers are zeroed.
|
||||
- **Enabling a feature is not evidence it fired — ask the structure, not the output.** Setting
|
||||
`PitDensity` did nothing (wrong struct). Diffing two stacks with/without the op pool would have
|
||||
*lied* (the pool is not in the SDF cache key, so both share the `thread_local` cache). What worked:
|
||||
call `BuildChunkCache` and look at `Pits.Num()`. **Prefer the check that can fail for exactly one
|
||||
reason** — and when a zero has three possible causes, give each one its own number.
|
||||
- **An oracle that shares the defect under test proves nothing.** The stale-cache check compares each
|
||||
stack against *itself evaluated alone*, never against the original — which keys its SDF cache
|
||||
without the params and would fail it.
|
||||
- **One definition, not two kept in sync.** `VF_BuildOpStackForChunk` exists because a tile skipped on
|
||||
the verdict of a stack that is not the one producing its density is a hole. A "keep these in sync"
|
||||
comment would not have been enough.
|
||||
+548
@@ -0,0 +1,548 @@
|
||||
# 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 (2026-07-28):** **Phases 0.5, 1 and 2 CODE-COMPLETE — 8 of 8 archetypes ported**, each
|
||||
> bit-identical to its original in an equivalence test, all wired behind `bUseOperatorStack`:
|
||||
> **Maze · FlatPlain · CrystalChamber · SurfaceWorld (biomes included) · VerticalShafts ·
|
||||
> FloatingIslands · TunnelNetwork · Underwater.** The archetype `switch` now has a complete
|
||||
> operator-stack twin, opt-in per strate.
|
||||
>
|
||||
> ✅ **BUILT AND GREEN, 2026-07-28 — 14 tests.** TunnelNetwork A+B bit-identical over 6000 samples
|
||||
> with all twelve group-coverage probes non-zero, all four noise branches covered, 0 gate leaks, and
|
||||
> C1 proved by 10 Terrace-op rooms containing 1119 samples. One open warning: the `Underwater` check
|
||||
> landed 0 samples in open cave, so its bit-identity proves little — diagnosed, not guessed, in
|
||||
> `OPSTACK-PROGRESS.md`'s last entry.
|
||||
>
|
||||
> **Not done, and it is the next real prize:** `ClassifyTile` still uses hand-written guards and does
|
||||
> not consume `ClassifyBox`. That is where measured tile-skipping becomes frames.
|
||||
>
|
||||
> Two things came out of Phase 2 that were not in the original design: **height space**
|
||||
> (`VoxelHeightOp.h`, a second operator family — some things are not another channel but another
|
||||
> *space*) and **`IVoxelBiomeField`** (ops depend on a capability, never on the generator, which is
|
||||
> what lets them become assets in Phase 3). Both are described in `OPSTACK-DECOMPOSITION §5`.
|
||||
>
|
||||
> **Known open:** generation is measurably slower on the op path (one fix landed — the column memo
|
||||
> was discarding itself every chunk; virtual dispatch and the hashed lookup remain). Deferred by
|
||||
> Jahni until the transition is complete. Live state and the next action live in
|
||||
> [OPSTACK-PROGRESS.md](OPSTACK-PROGRESS.md) — read its last entry first. The per-archetype
|
||||
> breakdown is in [OPSTACK-DECOMPOSITION.md](OPSTACK-DECOMPOSITION.md).
|
||||
>
|
||||
> **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.
|
||||
|
||||
> **✅ CONFIRMED THE HARD WAY, 2026-07-27.** The Maze port reproduces `GetMazeDensity`'s **SDF bit for
|
||||
> bit**, and its final density to within 1-2 ULP on ~2% of samples, with **zero isosurface
|
||||
> crossings** — geometrically identical, not one triangle moved. The exact origin of that last
|
||||
> rounding was chased through five measured-and-refuted hypotheses and then **parked by decision**;
|
||||
> the full evidence is in `AUDIT-2026-07.md §C10`. **Read C10 before ever reopening it.**
|
||||
>
|
||||
> **The operational bar for every remaining archetype port, encoded in
|
||||
> `VoxelForge.OpStack.MazeEquivalence`:** hard-fail on any isosurface crossing (that moves geometry);
|
||||
> tolerate ULP-scale deltas (the accepted floor); warn on anything larger (that is real port drift).
|
||||
>
|
||||
> **And the rule that came out of it:** never run the archetype `switch` and the operator stack in the
|
||||
> same world, and never compare their outputs for equality — a half-migrated strate would seam. Not a
|
||||
> client-desync risk (the field is proven bit-pure within a binary); the cross-platform concern is
|
||||
> `§C9`.
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
### 2.6.1 ⚠️ RELAXED FURTHER, 2026-07-27 — resemblance to the old world is NOT a requirement at all
|
||||
|
||||
**Jahni, verbatim:** *"I do not need your work to be identical or near identical to what I had
|
||||
before, only having it 99.99% at worst reproducible if two people share the same seed, since
|
||||
everyone rebuilds it on multiplayer."*
|
||||
|
||||
**This replaces the "recognisably the same place" bar above.** The requirement is not fidelity to the
|
||||
past — it is **agreement between peers in the present**. Restated as the only two properties that
|
||||
now matter:
|
||||
|
||||
| Property | Required? | Enforced by |
|
||||
|---|---|---|
|
||||
| **Same seed ⇒ same world, on every peer** | **YES — this is the whole bar** | `DensityPurity` within a binary; **`§C9`** across binaries/platforms |
|
||||
| Resemblance to the pre-refactor world | **NO** | nothing; freely re-tunable |
|
||||
| Bit-identity with the archetype `switch` | **NO** | nothing; never compare them |
|
||||
|
||||
**What this changes, concretely:**
|
||||
|
||||
1. **`§C10` is closed, not parked.** It measures old-path vs new-path agreement, and the two paths
|
||||
will never both exist in a shipped world. The residue cannot affect anything Jahni requires.
|
||||
2. **The equivalence tests keep their value, but for a different reason.** They are no longer
|
||||
*fidelity* checks; they are **port-correctness** checks — a transcription slip is still a real
|
||||
bug, and comparing against the old function is the cheapest way to catch one. Read them that way.
|
||||
The hard-fail (isosurface crossing) stays; the ULP grading is now diagnostic only.
|
||||
3. **`§C9` is promoted from a footnote to THE risk.** "Two people share a seed" is exactly the
|
||||
guarantee `/fp:fast` weakens across toolchains, and a Linux dedicated server generating collision
|
||||
geometry against Windows clients is the concrete case.
|
||||
4. **Changes that re-roll the world's noise are no longer expensive.** `§C1` in particular was
|
||||
deferred *only* because it forces a re-tune. That objection is gone.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
> **✅ WRITTEN 2026-07-27 — ⏳ NOT YET COMPILED OR RUN.** Four tests in `Private/Tests/` (the three
|
||||
> below, plus a live-edit regression test for `AUDIT C2`). The gate below is NOT met until Jahni
|
||||
> builds and runs them. See `OPSTACK-PROGRESS.md`.
|
||||
|
||||
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. ✅ **DONE 2026-07-27 (uncompiled):** `IVoxelDensityOp` + `EVoxelOpEffect` + `FVoxelOpContext` +
|
||||
the four role tags + the box-verdict fold, in `Public/VoxelDensityOp.h`. **One addition beyond
|
||||
this spec:** `ClassifyBox` is not source-only — forcing ops (the boundary seal inside its band)
|
||||
overwrite the input, which pure direction cannot express. Rationale in the header.
|
||||
**One open question the decomposition raised:** `Eval` probably needs an SDF channel as well as
|
||||
a density channel — see `OPSTACK-DECOMPOSITION.md §0.1`. Decide before porting Maze.
|
||||
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; **done**, `BuildSlabStack`, 8 archetypes → 7) → ✅ `SurfaceWorld`
|
||||
(**done**, incl. biomes — needed a whole second op family, `VoxelHeightOp.h`; biggest payoff, biggest
|
||||
care: the T1.a column cache and the exact-lattice `ClassifyTile` bound both survived) →
|
||||
✅ `VerticalShafts` (**done**, 3 ops reused from Maze unchanged) →
|
||||
✅ `FloatingIslands` (**done**, `BuildFloatingIslandStack` — the stack that runs **backwards**: void
|
||||
source + fill instead of rock source + carve, the *same* classes with the opposite sign; only the
|
||||
blob source is new) → ✅ `TunnelNetwork` + `Underwater` (**done**, one builder for both —
|
||||
`BuildTunnelNetworkStack`, 19 ops).
|
||||
|
||||
**8 of 8 ported.** The last two were really one: `Underwater` *is* TunnelNetwork plus
|
||||
`WaterLevelRelative` (§8, re-verified before relying on it), so the switch lost its last two cases in
|
||||
a single port.
|
||||
|
||||
TunnelNetwork was ~1080 lines and was taken in **three stages, each verifiable on its own** rather
|
||||
than as ~600 unverified lines on top of ~200 (the `AUDIT §P3` pattern):
|
||||
* **A** — SDF spine: vertical scale, base rock, cave warp, room graph (+ pits + chimneys), carve,
|
||||
worms, structural post. Verifiable *while incomplete* because every detail modifier is
|
||||
amplitude-gated and defaults to zero, so zeroing them sends the ORIGINAL down exactly stage A's path.
|
||||
* **B** — the twelve detail modifiers of `STEP 4b–4h`, one group per commit, each with a coverage
|
||||
probe that proves the group actually moved something (`B1` roughness, `B2` terrace/lines/ribs,
|
||||
`B3` overhang/cliff/scallop/arch, `B4` columns/domes/pinch/floor-bias, `B5` the gate itself).
|
||||
* **C** — the per-room op override (`§2`'s option (a), and it needed no scoping predicate: one op
|
||||
owns the state, eleven read it), `Underwater`, and the flag flip.
|
||||
|
||||
⚠️ **`FRoomGraphSource` CALLS `BuildChunkCache`/`EvaluateSDFCached`; it does not transcribe them.**
|
||||
That is where §8.4's two-region window-invariance discipline lives, and a copy would fork it — with
|
||||
the fork "validated" by a test that compares it to the original.
|
||||
|
||||
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. ✅ **DONE 2026-07-27 (uncompiled)** — `GetLayoutVersion()` added to `CP_Chunk` / `OC_Chunk` /
|
||||
`BM_Chunk`, **plus two the audit missed**: `TC_BiomeCache` in `ClassifyTile`, and the
|
||||
`GSurfColCache` box key (whose `StrateKey` is `round(StrateBottomWorldZ)`, so a live edit that
|
||||
changes terrain params without moving the strate served stale columns — the most visible form of
|
||||
the bug). `FChunkBiomeCache::Invalidate()` added, since a validity BOX says nothing about the
|
||||
`FBiomeContext` its cells were classified against. (`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. ✅ **DONE 2026-07-27** — `!*.md` in `.gitignore`; all nine design docs are tracked (commit `3128852`).
|
||||
6. ✅ **DONE** — the work lives on branch `experimental`; `main` is the known-good fallback.
|
||||
|
||||
---
|
||||
|
||||
## 9. Resume here
|
||||
|
||||
**Next action (2026-07-27): BUILD.** Phase 0.5's four tests and the Phase 1 skeleton header are
|
||||
committed and unverified. Nothing else should be written until they compile and the tests are green
|
||||
— writing unverified code on top of unverified code is the exact pattern `AUDIT §P3` documents.
|
||||
|
||||
After the build, in order: (1) fix whatever the tests report, (2) answer the five questions in
|
||||
`OPSTACK-DECOMPOSITION.md §11` — especially the SDF channel, which changes the contract and is
|
||||
cheapest to decide before any port, (3) port `Maze` per `OPSTACK-DECOMPOSITION.md §4`.
|
||||
|
||||
`AUDIT C1` (unbounded `SeedF`) is deliberately still open — see the progress log for why it was held
|
||||
back rather than forgotten.
|
||||
|
||||
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`.*
|
||||
+2469
File diff suppressed because it is too large
Load Diff
@@ -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)** — ✅ BUILT & WORKING (ticked 2026-07-27). 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"). ✅ BUILT & WORKING together with pass 2 (ticked 2026-07-27):
|
||||
> • **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`).*
|
||||
@@ -0,0 +1,595 @@
|
||||
// VoxelForgeClassifyTileTest.cpp
|
||||
// Phase 0.5 test #2 — LA SOLIDITÉ DE ClassifyTile / ClassifyTile soundness.
|
||||
//
|
||||
// ⚠️ LE TEST LE PLUS IMPORTANT DU PLUGIN / THE HIGHEST-CONSEQUENCE TEST IN THE PLUGIN.
|
||||
//
|
||||
// ClassifyTile (T1.d) répond "cette tuile est entièrement solide / entièrement air" AVANT tout
|
||||
// échantillonnage, et sur un verdict non-Mixed le monde SAUTE GenerateMesh entièrement. Le contrat
|
||||
// est asymétrique, et le commentaire de la fonction le dit déjà :
|
||||
//
|
||||
// un faux Mixed ne coûte que du CPU ;
|
||||
// un faux AllSolid / AllAir est un TROU — pas de géométrie, PAS DE COLLISION, invisible
|
||||
// jusqu'à ce qu'un joueur tombe au travers.
|
||||
//
|
||||
// ClassifyTile answers "this tile is entirely solid / entirely air" BEFORE any sampling, and on a
|
||||
// non-Mixed verdict the world SKIPS GenerateMesh completely. The contract is asymmetric:
|
||||
// a false Mixed only costs CPU; a false AllSolid/AllAir is a HOLE — no geometry, NO COLLISION,
|
||||
// invisible until a player falls through it.
|
||||
//
|
||||
// Cette fonction a DÉJÀ produit cette panne : la v1 de T1.d a été revertée le 2026-06-26 pour une
|
||||
// borne de plafond pas assez conservative. Jusqu'ici elle n'est validée que par raisonnement.
|
||||
// Ce test la valide par la force brute : pour chaque tuile jugée non-Mixed, on échantillonne le
|
||||
// treillis EXACT que le mesher aurait échantillonné (marge ±1 incluse) et on vérifie que chaque
|
||||
// point est bien du côté annoncé.
|
||||
//
|
||||
// This function has ALREADY produced that failure: T1.d v1 was reverted on 2026-06-26 over a
|
||||
// non-conservative ceiling bound. Until now it was validated by reasoning only. This test
|
||||
// validates it by brute force: for every tile judged non-Mixed, sample the EXACT lattice the
|
||||
// mesher would have sampled (±1 margin included) and assert every point is on the claimed side.
|
||||
//
|
||||
// CONVENTION (VoxelMarchingCubesMesher.cpp:309, IsoLevel == 0):
|
||||
// D >= 0 ⇒ côté AIR / air side
|
||||
// D < 0 ⇒ côté SOLIDE / solid side
|
||||
// Le classifieur utilise exactement ces inégalités (cf. TestColumn), donc le test aussi.
|
||||
|
||||
#if WITH_DEV_AUTOMATION_TESTS
|
||||
|
||||
#include "Misc/AutomationTest.h"
|
||||
|
||||
#include "VoxelForgeTestFixture.h"
|
||||
// Inclus ici DÉLIBÉRÉMENT : VoxelDensityOp.h n'est encore inclus par aucun .cpp, donc le
|
||||
// compilateur ne le verrait jamais. Le fold qu'il définit prétend reproduire ClassifyTile — ce
|
||||
// fichier est l'endroit naturel pour que cette prétention soit à la fois COMPILÉE et TESTÉE.
|
||||
// Deliberately included here: VoxelDensityOp.h is not yet included by any .cpp, so the compiler
|
||||
// would never see it. Its fold claims to reproduce ClassifyTile, so this is the natural place for
|
||||
// that claim to be both compiled and tested.
|
||||
#include "VoxelDensityOp.h"
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||
FVoxelForgeClassifyTileTest,
|
||||
"VoxelForge.Determinism.ClassifyTileSoundness",
|
||||
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||
|
||||
namespace
|
||||
{
|
||||
/** Tuiles balayées à la recherche d'un verdict non-Mixed (ClassifyTile est bon marché). */
|
||||
constexpr int32 NumTilesScanned = 600;
|
||||
|
||||
/** Tuiles réellement brute-forcées (chacune ~(Cells+3)³ appels à GetDensityAt — cher). */
|
||||
constexpr int32 MaxTilesVerified = 24;
|
||||
|
||||
struct FTileSpec
|
||||
{
|
||||
FIntVector Origin = FIntVector::ZeroValue;
|
||||
int32 Step = 1;
|
||||
int32 Cells = 16;
|
||||
};
|
||||
}
|
||||
|
||||
bool FVoxelForgeClassifyTileTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
using namespace VoxelForgeTest;
|
||||
|
||||
FTestWorld World;
|
||||
World.Build();
|
||||
if (!World.IsValid())
|
||||
{
|
||||
AddError(World.WhyInvalid());
|
||||
return false;
|
||||
}
|
||||
|
||||
const UVoxelGenerator* Gen = World.Generator.Get();
|
||||
|
||||
// Quelques carves : la garde diff-layer de ClassifyTile doit elle aussi être couverte, et
|
||||
// c'est la garde la plus facile à casser en ajoutant une feature (elle est globale, pas
|
||||
// par-archétype). / A few carves: ClassifyTile's diff-layer guard needs covering too, and it
|
||||
// is the guard most easily broken by a new feature since it is global rather than per-archetype.
|
||||
{
|
||||
FVoxelModification Mod;
|
||||
Mod.Shape = EVoxelBrushShape::Sphere;
|
||||
Mod.Radius = 10.0f;
|
||||
Mod.Strength = -12.0f;
|
||||
for (int32 k = 0; k < 4; ++k)
|
||||
{
|
||||
Mod.Center = FVector((float)(k * CHUNK_SIZE * 2), 0.0f,
|
||||
World.MidVoxelZ() + (float)(k * CHUNK_SIZE));
|
||||
World.DiffLayer->ApplyModification(Mod);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Balayage : trouver des tuiles où le classifieur ose un verdict. ──
|
||||
// Les origines suivent la géométrie réelle du clipmap : une tuile couvre Step*Cells voxels et
|
||||
// est alignée sur son propre pas. / Tile origins follow the real clipmap geometry: a tile
|
||||
// covers Step*Cells voxels and is aligned to its own extent.
|
||||
FRandomStream Rng(20260727);
|
||||
TArray<FTileSpec> ToVerify;
|
||||
int32 NumMixed = 0, NumAllSolid = 0, NumAllAir = 0;
|
||||
|
||||
const int32 TopVoxelZ = World.TopChunkZ * CHUNK_SIZE;
|
||||
const int32 BottomVoxelZ = World.BottomChunkZ * CHUNK_SIZE;
|
||||
|
||||
// La moitié des tuiles vise la strate SurfaceWorld : c'est le SEUL archétype dont ClassifyTile
|
||||
// sait prouver quoi que ce soit aujourd'hui (avec les gaps de bedrock), donc un tirage uniforme
|
||||
// sur tout le layout gaspillerait le budget en tuiles Mixed garanties.
|
||||
// Half the tiles target the SurfaceWorld strate: it is the ONLY archetype ClassifyTile can prove
|
||||
// anything about today (alongside bedrock gaps), so a uniform draw over the whole layout would
|
||||
// spend the budget on guaranteed-Mixed tiles.
|
||||
int32 SurfTopZ = 0, SurfBotZ = 0;
|
||||
const bool bHaveSurface = World.GetSlotVoxelZRange(FTestWorld::SlotSurfaceWorld, SurfTopZ, SurfBotZ);
|
||||
|
||||
for (int32 t = 0; t < NumTilesScanned; ++t)
|
||||
{
|
||||
FTileSpec Spec;
|
||||
// Step 1/2/4 comme le clipmap ; Cells petit pour que la vérification brute reste tenable.
|
||||
Spec.Step = 1 << Rng.RandRange(0, 2);
|
||||
Spec.Cells = (t % 8 == 0) ? CHUNK_SIZE : 16;
|
||||
const int32 Extent = Spec.Step * Spec.Cells;
|
||||
|
||||
const bool bAimSurface = bHaveSurface && (t % 2 == 0);
|
||||
const int32 LoZ = bAimSurface ? SurfBotZ : BottomVoxelZ;
|
||||
const int32 HiZ = bAimSurface ? SurfTopZ : TopVoxelZ;
|
||||
// Division entière PLANCHER : en C++ la troncature va vers zéro, ce qui décalerait la
|
||||
// borne basse (négative) d'un extent vers le haut. / Integer FLOOR division: C++ truncates
|
||||
// toward zero, which would shift the negative low bound up by one extent.
|
||||
auto FloorDiv = [](int32 A, int32 B) { const int32 Q = A / B, R = A % B; return (R != 0 && (R < 0) != (B < 0)) ? Q - 1 : Q; };
|
||||
const int32 LoTile = FloorDiv(LoZ, Extent);
|
||||
const int32 HiTile = FMath::Max(LoTile, FloorDiv(HiZ, Extent));
|
||||
|
||||
Spec.Origin = FIntVector(
|
||||
Rng.RandRange(-4, 4) * Extent,
|
||||
Rng.RandRange(-4, 4) * Extent,
|
||||
Rng.RandRange(LoTile, HiTile) * Extent);
|
||||
|
||||
const EVoxelTileClass Verdict = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
|
||||
switch (Verdict)
|
||||
{
|
||||
case EVoxelTileClass::Mixed: ++NumMixed; break;
|
||||
case EVoxelTileClass::AllSolid: ++NumAllSolid; if (ToVerify.Num() < MaxTilesVerified) ToVerify.Add(Spec); break;
|
||||
case EVoxelTileClass::AllAir: ++NumAllAir; if (ToVerify.Num() < MaxTilesVerified) ToVerify.Add(Spec); break;
|
||||
}
|
||||
}
|
||||
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("ClassifyTile verdicts over %d scanned tiles: Mixed %d, AllSolid %d, AllAir %d ")
|
||||
TEXT("(brute-forcing %d of them). NOTE: cave archetypes always return Mixed today — see ")
|
||||
TEXT("VoxelGenerator.cpp \"archétype cave [...] pas prouvable en v1\". A low non-Mixed count ")
|
||||
TEXT("is expected and is exactly the tile-skipping prize OPSTACK-PLAN wants EffectOverBox ")
|
||||
TEXT("to unlock."),
|
||||
NumTilesScanned, NumMixed, NumAllSolid, NumAllAir, ToVerify.Num()));
|
||||
|
||||
if (ToVerify.Num() == 0)
|
||||
{
|
||||
AddError(TEXT("VACUOUS: not one scanned tile produced an AllSolid/AllAir verdict, so this ")
|
||||
TEXT("test verified nothing. Either the fixture's layout has no SurfaceWorld/gap ")
|
||||
TEXT("chunks in the sampled Z range, or T1.d has stopped emitting verdicts entirely ")
|
||||
TEXT("(which would be a large silent perf regression). Widen the Z range before ")
|
||||
TEXT("trusting a green run."));
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Vérification par force brute, sur le treillis EXACT du mesher. ──
|
||||
// Les bornes reproduisent ClassifyTile / GenerateMesh : g ∈ [-1, Cells+1] par axe.
|
||||
int32 NumHoles = 0;
|
||||
for (const FTileSpec& Spec : ToVerify)
|
||||
{
|
||||
const EVoxelTileClass Verdict = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
|
||||
if (Verdict == EVoxelTileClass::Mixed) { continue; } // verdict instable ⇒ rien à prouver
|
||||
|
||||
const int32 CPA = FMath::Clamp(Spec.Cells, 2, CHUNK_SIZE);
|
||||
const int32 GridDim = CPA + 1;
|
||||
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
|
||||
|
||||
bool bTileBad = false;
|
||||
for (int32 gz = -1; gz <= GridDim && !bTileBad; ++gz)
|
||||
for (int32 gy = -1; gy <= GridDim && !bTileBad; ++gy)
|
||||
for (int32 gx = -1; gx <= GridDim && !bTileBad; ++gx)
|
||||
{
|
||||
const float X = (float)(Spec.Origin.X + gx * Spec.Step);
|
||||
const float Y = (float)(Spec.Origin.Y + gy * Spec.Step);
|
||||
const float Z = (float)(Spec.Origin.Z + gz * Spec.Step);
|
||||
const float D = Gen->GetDensityAt(X, Y, Z);
|
||||
|
||||
// AllSolid ⇒ tout le treillis doit être D < 0
|
||||
// AllAir ⇒ tout le treillis doit être D >= 0
|
||||
const bool bAgrees = bClaimsSolid ? (D < 0.0f) : (D >= 0.0f);
|
||||
if (!bAgrees)
|
||||
{
|
||||
bTileBad = true;
|
||||
++NumHoles;
|
||||
AddError(FString::Printf(
|
||||
TEXT("HOLE: ClassifyTile said %s for tile origin (%d,%d,%d) Step=%d Cells=%d, ")
|
||||
TEXT("but GetDensityAt(%.0f, %.0f, %.0f) = %.6g is on the %s side. This tile ")
|
||||
TEXT("would be skipped by the mesher: no triangles and NO COLLISION where there ")
|
||||
TEXT("should be a surface. Find which guard in ClassifyTile failed to fire for ")
|
||||
TEXT("the feature at that point."),
|
||||
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
|
||||
Spec.Origin.X, Spec.Origin.Y, Spec.Origin.Z, Spec.Step, Spec.Cells,
|
||||
X, Y, Z, D, (D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TestEqual(TEXT("no tile was classified uniform while containing a surface (a false verdict is a hole)"),
|
||||
NumHoles, 0);
|
||||
|
||||
// ── Stabilité du verdict : ClassifyTile partage GSurfColCache avec GetDensityAt, donc le
|
||||
// brute-force ci-dessus a réchauffé les caches. Re-classifier doit rendre le MÊME verdict.
|
||||
// Verdict stability: ClassifyTile shares GSurfColCache with GetDensityAt, so the brute force
|
||||
// above warmed the caches. Re-classifying must yield the SAME verdict.
|
||||
for (const FTileSpec& Spec : ToVerify)
|
||||
{
|
||||
const EVoxelTileClass A = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
|
||||
const EVoxelTileClass B = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
|
||||
if (A != B)
|
||||
{
|
||||
AddError(FString::Printf(
|
||||
TEXT("UNSTABLE VERDICT at tile (%d,%d,%d) Step=%d: two consecutive ClassifyTile ")
|
||||
TEXT("calls disagreed (%d vs %d). The classifier is reading state that GetDensityAt ")
|
||||
TEXT("mutates — the shared column cache is the prime suspect."),
|
||||
Spec.Origin.X, Spec.Origin.Y, Spec.Origin.Z, Spec.Step, (int32)A, (int32)B));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// LE CHEMIN PILE D'OPÉRATEURS DE ClassifyTile — MÊME FORCE BRUTE, MONDE OPT-IN
|
||||
//=============================================================================
|
||||
// `ClassifyTile` rendait `Mixed` sans appel pour tout archétype de CAVE. Il consulte désormais
|
||||
// `FVoxelOpStack::ClassifyBox` quand la strate a coché `bUseOperatorStack` — donc **un tout nouveau
|
||||
// chemin peut faire sauter le maillage d'une tuile**, et son erreur est un TROU : pas de triangles,
|
||||
// pas de collision, invisible jusqu'à ce qu'un joueur tombe au travers.
|
||||
//
|
||||
// Ce test est le même oracle par force brute que `ClassifyTileSoundness`, sur un monde dont TOUTES
|
||||
// les strates ont coché la case. Il ne vérifie pas le pliage (c'est `BoxVerdictFold`) ni les
|
||||
// opérateurs (ce sont les huit tests d'équivalence) : il vérifie le **câblage** — que la pile
|
||||
// interrogée par le classifieur est bien celle qui produit la densité, params, drapeau et
|
||||
// disturbances compris.
|
||||
//
|
||||
// ⚠️ LE COMPTEUR À LIRE EN PREMIER est le nombre de tuiles réellement brute-forcées. Un run vert
|
||||
// avec zéro verdict non-Mixed ne prouverait RIEN — exactement le piège que ce fichier documente
|
||||
// depuis sa première version, et la raison pour laquelle l'absence de verdict est une ERREUR ici.
|
||||
//
|
||||
// Same brute-force oracle as ClassifyTileSoundness, on a world where every strate has opted in.
|
||||
// It checks the WIRING, not the fold and not the operators.
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||
FVoxelForgeOpStackClassifyTileTest,
|
||||
"VoxelForge.OpStack.ClassifyTileSoundness",
|
||||
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||
|
||||
bool FVoxelForgeOpStackClassifyTileTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
using namespace VoxelForgeTest;
|
||||
|
||||
// ⚠️ `bUseOperatorStack = true` sur toutes les strates : c'est LE point du test. La fixture
|
||||
// donne à ce monde une `LayoutVersion` unique dans le processus, sans quoi les caches par chunk
|
||||
// de `GetDensityAt` — dont `CP_UseOpStack` — pourraient encore porter ceux d'un autre test.
|
||||
FTestWorld World;
|
||||
World.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true);
|
||||
if (!World.IsValid())
|
||||
{
|
||||
AddError(World.WhyInvalid());
|
||||
return false;
|
||||
}
|
||||
|
||||
const UVoxelGenerator* Gen = World.Generator.Get();
|
||||
|
||||
FRandomStream Rng(20260728);
|
||||
TArray<FTileSpec> ToVerify;
|
||||
int32 NumMixed = 0, NumAllSolid = 0, NumAllAir = 0;
|
||||
|
||||
const int32 TopVoxelZ = World.TopChunkZ * CHUNK_SIZE;
|
||||
const int32 BottomVoxelZ = World.BottomChunkZ * CHUNK_SIZE;
|
||||
|
||||
// Tirage uniforme sur tout le layout, PAS biaisé vers SurfaceWorld comme l'autre test : ici ce
|
||||
// sont précisément les strates de cave qui intéressent, puisque ce sont elles qui passent par le
|
||||
// nouveau chemin. SurfaceWorld continue d'être prouvé par le code écrit à la main.
|
||||
for (int32 t = 0; t < NumTilesScanned; ++t)
|
||||
{
|
||||
FTileSpec Spec;
|
||||
Spec.Step = 1 << Rng.RandRange(0, 2);
|
||||
Spec.Cells = (t % 8 == 0) ? CHUNK_SIZE : 16;
|
||||
const int32 Extent = Spec.Step * Spec.Cells;
|
||||
|
||||
auto FloorDiv = [](int32 A, int32 B) { const int32 Q = A / B, R = A % B; return (R != 0 && (R < 0) != (B < 0)) ? Q - 1 : Q; };
|
||||
const int32 LoTile = FloorDiv(BottomVoxelZ, Extent);
|
||||
const int32 HiTile = FMath::Max(LoTile, FloorDiv(TopVoxelZ, Extent));
|
||||
|
||||
Spec.Origin = FIntVector(
|
||||
Rng.RandRange(-4, 4) * Extent,
|
||||
Rng.RandRange(-4, 4) * Extent,
|
||||
Rng.RandRange(LoTile, HiTile) * Extent);
|
||||
|
||||
const EVoxelTileClass Verdict = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
|
||||
switch (Verdict)
|
||||
{
|
||||
case EVoxelTileClass::Mixed: ++NumMixed; break;
|
||||
case EVoxelTileClass::AllSolid: ++NumAllSolid; if (ToVerify.Num() < MaxTilesVerified) ToVerify.Add(Spec); break;
|
||||
case EVoxelTileClass::AllAir: ++NumAllAir; if (ToVerify.Num() < MaxTilesVerified) ToVerify.Add(Spec); break;
|
||||
}
|
||||
}
|
||||
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("ClassifyTile ON THE OPERATOR-STACK PATH, %d scanned tiles: Mixed %d, AllSolid %d, ")
|
||||
TEXT("AllAir %d (brute-forcing %d). Compare with VoxelForge.Determinism.ClassifyTileSoundness, ")
|
||||
TEXT("which runs the SAME scan on a world that has NOT opted in: every verdict beyond what ")
|
||||
TEXT("that test reports is a tile the mesher now skips and did not before. That difference ")
|
||||
TEXT("IS the T1.d prize OPSTACK-PLAN has been aiming at -- and every one of those tiles is a ")
|
||||
TEXT("hole if the wiring is wrong, which is what the brute force below is for."),
|
||||
NumTilesScanned, NumMixed, NumAllSolid, NumAllAir, ToVerify.Num()));
|
||||
|
||||
if (ToVerify.Num() == 0)
|
||||
{
|
||||
AddError(TEXT("VACUOUS: not one tile got a non-Mixed verdict on the operator-stack path, so ")
|
||||
TEXT("this test verified NOTHING about the new wiring. Either no strate actually ")
|
||||
TEXT("opted in (check FTestWorld::Build's bUseOperatorStack), or every guard in the ")
|
||||
TEXT("cave branch of ClassifyTile bailed to Mixed -- the params-identical check and ")
|
||||
TEXT("the 27-chunk-coord cap are the likeliest. Do NOT read a green run as proof."));
|
||||
return false;
|
||||
}
|
||||
|
||||
int32 NumHoles = 0;
|
||||
for (const FTileSpec& Spec : ToVerify)
|
||||
{
|
||||
const EVoxelTileClass Verdict = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
|
||||
if (Verdict == EVoxelTileClass::Mixed) { continue; }
|
||||
|
||||
const int32 CPA = FMath::Clamp(Spec.Cells, 2, CHUNK_SIZE);
|
||||
const int32 GridDim = CPA + 1;
|
||||
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
|
||||
|
||||
bool bTileBad = false;
|
||||
for (int32 gz = -1; gz <= GridDim && !bTileBad; ++gz)
|
||||
for (int32 gy = -1; gy <= GridDim && !bTileBad; ++gy)
|
||||
for (int32 gx = -1; gx <= GridDim && !bTileBad; ++gx)
|
||||
{
|
||||
const float X = (float)(Spec.Origin.X + gx * Spec.Step);
|
||||
const float Y = (float)(Spec.Origin.Y + gy * Spec.Step);
|
||||
const float Z = (float)(Spec.Origin.Z + gz * Spec.Step);
|
||||
const float D = Gen->GetDensityAt(X, Y, Z);
|
||||
|
||||
const bool bAgrees = bClaimsSolid ? (D < 0.0f) : (D >= 0.0f);
|
||||
if (!bAgrees)
|
||||
{
|
||||
bTileBad = true;
|
||||
++NumHoles;
|
||||
AddError(FString::Printf(
|
||||
TEXT("HOLE ON THE OPERATOR-STACK PATH: ClassifyTile said %s for tile (%d,%d,%d) ")
|
||||
TEXT("Step=%d Cells=%d, but GetDensityAt(%.0f, %.0f, %.0f) = %.6g is on the %s ")
|
||||
TEXT("side. Check, in order: (1) does GetDensityAt for this chunk actually take ")
|
||||
TEXT("the stack (CP_UseOpStack), or did the classifier judge a field the mesher ")
|
||||
TEXT("will not produce; (2) the params-identical check -- a blended transition ")
|
||||
TEXT("band means one stack cannot represent the whole tile (AUDIT C2); (3) the ")
|
||||
TEXT("disturbance fold, since disturbances are applied AFTER the stack and are ")
|
||||
TEXT("not part of it; (4) an operator's EffectOverBox claiming Identity where it ")
|
||||
TEXT("can act -- the per-room op override can ENABLE a modifier the strate had ")
|
||||
TEXT("switched off, which makes a box bound too optimistic."),
|
||||
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
|
||||
Spec.Origin.X, Spec.Origin.Y, Spec.Origin.Z, Spec.Step, Spec.Cells,
|
||||
X, Y, Z, D, (D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TestEqual(TEXT("no tile was classified uniform on the operator-stack path while containing a ")
|
||||
TEXT("surface (a false verdict is a hole)"), NumHoles, 0);
|
||||
|
||||
// Même contrôle de stabilité que sur l'autre chemin : la pile est reconstruite à chaque appel,
|
||||
// et `FRoomGraphSource` partage un cache `thread_local` avec le chemin densité — deux appels
|
||||
// successifs doivent malgré tout rendre le même verdict.
|
||||
for (const FTileSpec& Spec : ToVerify)
|
||||
{
|
||||
const EVoxelTileClass A = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
|
||||
const EVoxelTileClass B = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
|
||||
if (A != B)
|
||||
{
|
||||
AddError(FString::Printf(
|
||||
TEXT("UNSTABLE VERDICT on the operator-stack path at tile (%d,%d,%d) Step=%d: %d vs ")
|
||||
TEXT("%d. The classifier builds a fresh stack per call, so a difference means an ")
|
||||
TEXT("operator is reading thread_local state the density path mutates."),
|
||||
Spec.Origin.X, Spec.Origin.Y, Spec.Origin.Z, Spec.Step, (int32)A, (int32)B));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// LE FOLD DE LA PILE D'OPÉRATEURS / the op-stack fold
|
||||
//=============================================================================
|
||||
// `VoxelDensityOp.h` affirme que son fold reproduit le ClassifyTile écrit à la main. C'est de la
|
||||
// logique pure — pas de monde, pas de bruit, pas de thread — donc elle peut être vérifiée
|
||||
// exhaustivement, et elle doit l'être : c'est elle qui décidera un jour si une tuile est maillée.
|
||||
//
|
||||
// `VoxelDensityOp.h` claims its fold reproduces the hand-written ClassifyTile. That is pure logic —
|
||||
// no world, no noise, no threads — so it can be checked exhaustively, and it should be: this is what
|
||||
// will one day decide whether a tile gets meshed at all.
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||
FVoxelForgeOpFoldTest,
|
||||
"VoxelForge.OpStack.BoxVerdictFold",
|
||||
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||
|
||||
bool FVoxelForgeOpFoldTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
// Un état neuf ne prouve rien ⇒ Mixed (les deux hypothèses vivantes = égalité = prudence).
|
||||
{
|
||||
FVoxelBoxHypotheses H;
|
||||
TestEqual(TEXT("a fresh state proves nothing"), (int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
|
||||
}
|
||||
|
||||
// Une source qui affirme un côté tue l'autre hypothèse.
|
||||
{
|
||||
FVoxelBoxHypotheses H;
|
||||
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid);
|
||||
TestEqual(TEXT("a solid source yields AllSolid"), (int32)H.Resolve(), (int32)EVoxelTileClass::AllSolid);
|
||||
VF_FoldEffect(H, EVoxelOpEffect::Identity);
|
||||
TestEqual(TEXT("Identity changes nothing"), (int32)H.Resolve(), (int32)EVoxelTileClass::AllSolid);
|
||||
}
|
||||
|
||||
// ≡ « AnyPassageNearBox ⇒ bCanSolid = false » : un carve tue AllSolid.
|
||||
{
|
||||
FVoxelBoxHypotheses H;
|
||||
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid);
|
||||
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly);
|
||||
TestEqual(TEXT("a passage over solid rock forces Mixed"), (int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
|
||||
}
|
||||
|
||||
// ≡ « bande de seal ⇒ bCanAir = false » : un fill tue AllAir.
|
||||
{
|
||||
FVoxelBoxHypotheses H;
|
||||
VF_ForceHypotheses(H, EVoxelTileClass::AllAir);
|
||||
VF_FoldEffect(H, EVoxelOpEffect::FillOnly);
|
||||
TestEqual(TEXT("a fill over open air forces Mixed"), (int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
|
||||
}
|
||||
|
||||
// LE CAS QUI JUSTIFIE ClassifyBox : au-dessus du terrain mais DANS la bande de seal supérieure,
|
||||
// la source dit « tout air » et le seal FORCE « tout solide ». Aujourd'hui ClassifyTile rend
|
||||
// AllSolid ici. Un simple FillOnly rendrait Mixed et perdrait la tuile.
|
||||
// THE CASE THAT JUSTIFIES ClassifyBox — a pure FillOnly would lose this tile.
|
||||
{
|
||||
FVoxelBoxHypotheses H;
|
||||
VF_ForceHypotheses(H, EVoxelTileClass::AllAir); // source: above the terrain
|
||||
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid); // seal: forcing, inside its band
|
||||
TestEqual(TEXT("a forcing seal recovers AllSolid over an air source"),
|
||||
(int32)H.Resolve(), (int32)EVoxelTileClass::AllSolid);
|
||||
|
||||
// …et un passage qui traverse cette même boîte la reprend, exactement comme aujourd'hui.
|
||||
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly);
|
||||
TestEqual(TEXT("a passage still takes the sealed verdict back"),
|
||||
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
|
||||
}
|
||||
|
||||
// Le diff layer : Both tue tout, ce qui est le comportement voulu (une édition joueur peut
|
||||
// creuser OU remplir n'importe où).
|
||||
{
|
||||
FVoxelBoxHypotheses H;
|
||||
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid);
|
||||
VF_FoldEffect(H, EVoxelOpEffect::Both);
|
||||
TestTrue(TEXT("Both kills every hypothesis"), H.IsDead());
|
||||
TestEqual(TEXT("a player edit in range forces Mixed"), (int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
|
||||
}
|
||||
|
||||
// Une source qui ne sait rien (Mixed) ne peut jamais être ressuscitée par un opérateur
|
||||
// directionnel — seul un opérateur FORÇANT le peut. C'est la propriété de sûreté.
|
||||
{
|
||||
for (const EVoxelOpEffect E : { EVoxelOpEffect::Identity, EVoxelOpEffect::CarveOnly,
|
||||
EVoxelOpEffect::FillOnly, EVoxelOpEffect::Both })
|
||||
{
|
||||
FVoxelBoxHypotheses H;
|
||||
VF_ForceHypotheses(H, EVoxelTileClass::Mixed);
|
||||
VF_FoldEffect(H, E);
|
||||
TestEqual(TEXT("a directional op can never resurrect an unprovable box"),
|
||||
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
|
||||
}
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// LE PLIAGE NUMÉRIQUE — `OPSTACK-DECOMPOSITION §0.2`
|
||||
//=========================================================================
|
||||
// La direction seule ne récupère jamais un carve FIELDÉ : il peut creuser partout, donc il rend
|
||||
// `CarveOnly` partout, et c'est VRAI. Ce que la direction ignore, c'est qu'il ne peut creuser que
|
||||
// de `WormStrength` au plus. Le pliage porte donc deux nombres : une MARGE posée par l'opérateur
|
||||
// forçant, et une AMPLITUDE retirée par chaque carve.
|
||||
//
|
||||
// ⚠️ LE PREMIER CONTRÔLE CI-DESSOUS EST LE PLUS IMPORTANT : il vérifie que la rétro-compatibilité
|
||||
// est réelle. Les cinq blocs au-dessus n'ont pas changé d'une ligne et doivent rester verts —
|
||||
// ils appellent les mêmes fonctions sans marge ni amplitude, donc avec les défauts
|
||||
// (`Margin = 0`, `MaxCarve = FLT_MAX`), qui reproduisent le comportement purement directionnel.
|
||||
|
||||
// 1. Les défauts REPRODUISENT l'ancien pliage — c'est ce qui rend le changement sûr.
|
||||
{
|
||||
FVoxelBoxHypotheses H;
|
||||
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid); // marge par défaut = 0
|
||||
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly); // amplitude par défaut = FLT_MAX
|
||||
TestEqual(TEXT("with default margin and amplitude, a carve still kills AllSolid"),
|
||||
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
|
||||
}
|
||||
|
||||
// 2. Une amplitude INCONNUE tue même une grosse marge. « Je ne sais pas » n'est pas « zéro ».
|
||||
{
|
||||
FVoxelBoxHypotheses H;
|
||||
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 1000.0f);
|
||||
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly); // FLT_MAX
|
||||
TestEqual(TEXT("an unbounded carve kills AllSolid however solid the rock is"),
|
||||
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
|
||||
}
|
||||
|
||||
// 3. LE CAS QUI JUSTIFIE TOUT : roc à 1.0, ver à 0.6 ⇒ il reste 0.4 de marge, la boîte est
|
||||
// prouvablement pleine. C'est exactement `FConstantFieldSource` + `FWormFieldSource`.
|
||||
{
|
||||
FVoxelBoxHypotheses H;
|
||||
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 1.0f); // BaseDensity
|
||||
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly, 0.6f, 0.0f); // WormStrength
|
||||
TestEqual(TEXT("rock solid by more than the worm can carve stays provably AllSolid"),
|
||||
(int32)H.Resolve(), (int32)EVoxelTileClass::AllSolid);
|
||||
|
||||
// …et les carves S'ACCUMULENT : un second à 0.5 fait passer la marge sous zéro.
|
||||
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly, 0.5f, 0.0f);
|
||||
TestEqual(TEXT("carve amplitudes accumulate until the margin runs out"),
|
||||
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
|
||||
}
|
||||
|
||||
// 4. ÉGALITÉ ⇒ ON PERD LA TUILE, délibérément. Une marge de 1.0 contre un carve de 1.0 peut
|
||||
// atteindre exactement zéro, et zéro est du côté AIR pour le mesher. Le test `> 0` est
|
||||
// STRICT, et il doit le rester : se tromper ici ferait un trou, pas une tuile en trop.
|
||||
{
|
||||
FVoxelBoxHypotheses H;
|
||||
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 1.0f);
|
||||
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly, 1.0f, 0.0f);
|
||||
TestEqual(TEXT("a carve exactly equal to the margin loses the tile (strict >, on purpose)"),
|
||||
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
|
||||
}
|
||||
|
||||
// 5. Le miroir côté AIR : une strate d'îles flottantes, surtout vide, avec un remplissage borné.
|
||||
{
|
||||
FVoxelBoxHypotheses H;
|
||||
VF_ForceHypotheses(H, EVoxelTileClass::AllAir, 1.0f);
|
||||
VF_FoldEffect(H, EVoxelOpEffect::FillOnly, 0.0f, 0.35f);
|
||||
TestEqual(TEXT("air deeper than the fill can reach stays provably AllAir"),
|
||||
(int32)H.Resolve(), (int32)EVoxelTileClass::AllAir);
|
||||
}
|
||||
|
||||
// 6. `Both` BORNÉ : la rugosité de paroi peut aller dans les deux sens, mais pas loin. Sur du
|
||||
// roc forcé, l'hypothèse AIR est déjà morte (le forçage l'a tuée) ; ce qui compte est que
|
||||
// l'hypothèse SOLIDE survive à un `Both` d'amplitude connue — impossible avant ce changement,
|
||||
// où `Both` tuait tout inconditionnellement.
|
||||
{
|
||||
FVoxelBoxHypotheses H;
|
||||
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 1.0f);
|
||||
VF_FoldEffect(H, EVoxelOpEffect::Both, 0.3f, 0.3f);
|
||||
TestEqual(TEXT("a bounded Both no longer kills a margin it cannot cross"),
|
||||
(int32)H.Resolve(), (int32)EVoxelTileClass::AllSolid);
|
||||
}
|
||||
|
||||
// 7. LA PROPRIÉTÉ DE SÛRETÉ TIENT TOUJOURS : rien de borné ne ressuscite quoi que ce soit.
|
||||
// Le pliage numérique ne fait que retarder la mort d'une hypothèse, jamais l'annuler.
|
||||
{
|
||||
for (const EVoxelOpEffect E : { EVoxelOpEffect::Identity, EVoxelOpEffect::CarveOnly,
|
||||
EVoxelOpEffect::FillOnly, EVoxelOpEffect::Both })
|
||||
{
|
||||
FVoxelBoxHypotheses H;
|
||||
VF_ForceHypotheses(H, EVoxelTileClass::Mixed, 1000.0f); // marge ignorée sur Mixed
|
||||
VF_FoldEffect(H, E, 0.0f, 0.0f); // amplitudes NULLES
|
||||
TestEqual(TEXT("a zero-amplitude op cannot resurrect an unprovable box either"),
|
||||
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Une marge NULLE avec une amplitude NULLE : le carve ne retire rien, mais `0 > 0` est faux,
|
||||
// donc l'hypothèse meurt quand même. C'est voulu — une marge inconnue reste inconnue, et un
|
||||
// opérateur qui ne fait rien devrait rendre `Identity`, pas `CarveOnly` d'amplitude 0.
|
||||
{
|
||||
FVoxelBoxHypotheses H;
|
||||
VF_ForceHypotheses(H, EVoxelTileClass::AllSolid, 0.0f);
|
||||
VF_FoldEffect(H, EVoxelOpEffect::CarveOnly, 0.0f, 0.0f);
|
||||
TestEqual(TEXT("zero margin dies even to a zero carve -- unknown is not zero"),
|
||||
(int32)H.Resolve(), (int32)EVoxelTileClass::Mixed);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||
@@ -0,0 +1,289 @@
|
||||
// VoxelForgeCrossPlatformTest.cpp
|
||||
// LA QUESTION MULTIJOUEUR, RENDUE MESURABLE / THE MULTIPLAYER QUESTION, MADE MEASURABLE
|
||||
//
|
||||
// Jahni, 2026-07-27 : *« je voudrais que le jeu soit jouable sur les deux plateformes, Linux et
|
||||
// Windows, donc un hôte Windows avec un client Linux pourrait arriver, et l'inverse. »*
|
||||
// Et la barre d'acceptation : *« 99.99% au pire reproductible si deux personnes partagent la même
|
||||
// seed, puisque tout le monde le reconstruit en multijoueur. »*
|
||||
//
|
||||
// ⚠️ LE PROBLÈME (AUDIT §C9) : le MÊME `FPSemanticsMode.Default` d'UBT ne veut pas dire la même
|
||||
// chose selon la toolchain — `VCToolChain` (Windows/MSVC) le résout en **`/fp:fast`**,
|
||||
// `ClangToolChain` (Linux/Mac/Windows-Clang) le résout en **précis + `-ffp-contract=off`**. Deux
|
||||
// builds de la MÊME source sont donc compilés sous des règles flottantes OPPOSÉES. Un hôte Windows
|
||||
// et un client Linux ne sont pas seulement *autorisés* à diverger : ils sont compilés pour.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// CE TEST NE CORRIGE RIEN — IL MESURE, et c'est ce qui manque
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// Aucune quantité de raisonnement ne dit à quel point les deux plateformes divergent : il faut le
|
||||
// LIRE. Ce test produit deux empreintes du même monde, à la même seed, et les affiche. On le lance
|
||||
// sur Windows, on le lance sur Linux, on compare les deux lignes.
|
||||
//
|
||||
// • **EMPREINTE DE FORME** — le SIGNE de la densité seulement (solide / air). C'est la SEULE
|
||||
// chose que le mesher lit (`D >= IsoLevel ⇒ air`). Si cette empreinte est identique, les deux
|
||||
// plateformes ont **le même monde** : mêmes cavités, mêmes murs, même navigabilité, même
|
||||
// collision au voxel près. C'est littéralement le critère « 99.99% reproductible » de Jahni.
|
||||
//
|
||||
// • **EMPREINTE DE CHAMP** — tous les bits de tous les floats. Identique ⇒ reproductibilité
|
||||
// BIT à BIT. Différente alors que la forme est identique ⇒ la divergence est un frémissement
|
||||
// sous-voxel de la position des sommets, sans conséquence de jeu.
|
||||
//
|
||||
// C'est le bon découpage parce qu'il sépare les deux échecs possibles, qui n'ont pas du tout la
|
||||
// même gravité :
|
||||
//
|
||||
// forme == && champ == ⇒ parfait, rien à faire.
|
||||
// forme == && champ != ⇒ ACCEPTABLE. Les sommets bougent de ~1e-5 voxel. Personne ne le voit,
|
||||
// rien ne s'y accroche — SAUF si un jour on compare des hashs de
|
||||
// géométrie entre pairs. À ne pas faire, donc.
|
||||
// forme != ⇒ **INACCEPTABLE**. Un voxel solide chez l'un est de l'air chez
|
||||
// l'autre : un joueur traverse un mur que l'autre voit plein.
|
||||
//
|
||||
// ⚠️ POURQUOI LA FORME A DE BONNES CHANCES DE TENIR MÊME AUJOURD'HUI — et pourquoi il faut quand
|
||||
// même la mesurer : toutes les décisions STRUCTURELLES du plugin (quelle arête de treillis est
|
||||
// ouverte, quelle cellule porte une colonne, où sont les salles et les passages) passent par
|
||||
// `VoxelHash::*`, c.-à-d. de l'ARITHMÉTIQUE ENTIÈRE, identique sur toute plateforme. Le flottant
|
||||
// ne décide que la POSITION de la surface. Un signe ne bascule donc que si un échantillon tombe à
|
||||
// ~1e-5 de l'isosurface — d'où le troisième chiffre affiché, `NearIso`, qui BORNE le risque au
|
||||
// lieu de le supposer.
|
||||
//
|
||||
// The structural decisions all go through integer hashing, so only the surface POSITION is
|
||||
// float-decided. A sign flips only where a sample sits within ~1e-5 of the isosurface, which is why
|
||||
// NearIso is reported: it bounds the risk instead of assuming it.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// COMMENT S'EN SERVIR / HOW TO USE THIS
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// 1. Lancer sur Windows, noter les deux empreintes.
|
||||
// 2. Lancer sur Linux, comparer.
|
||||
// 3. Une fois `FPSemantics = Precise` posé sur le module (AUDIT §C9, bloqué par la dette IWYU)
|
||||
// et les deux plateformes d'accord : **épingler** les valeurs dans `PinnedShapeDigest` /
|
||||
// `PinnedFieldDigest` ci-dessous. Le test devient alors un garde-fou permanent — toute
|
||||
// régression de déterminisme échoue bruyamment, sur la plateforme qui a dérivé.
|
||||
//
|
||||
// Tant que les constantes valent 0, le test ne peut pas échouer sur les empreintes : il RAPPORTE.
|
||||
// C'est délibéré — épingler une valeur avant que les plateformes soient d'accord ne ferait que
|
||||
// graver la divergence dans le test.
|
||||
|
||||
#if WITH_DEV_AUTOMATION_TESTS
|
||||
|
||||
#include "Misc/AutomationTest.h"
|
||||
|
||||
#include "VoxelForgeTestFixture.h"
|
||||
#include "VoxelGenerator.h"
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||
FVoxelForgeCrossPlatformTest,
|
||||
"VoxelForge.Determinism.CrossPlatformDigest",
|
||||
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||
|
||||
namespace
|
||||
{
|
||||
//=========================================================================
|
||||
// ÉPINGLES / PINS — 0 = « pas encore d'accord de référence », le test rapporte sans juger.
|
||||
//=========================================================================
|
||||
// À remplir UNIQUEMENT quand Windows et Linux rendent la même valeur. Voir l'en-tête.
|
||||
constexpr uint64 PinnedShapeDigest = 0;
|
||||
constexpr uint64 PinnedFieldDigest = 0;
|
||||
|
||||
// FNV-1a 64 bits, octet par octet, **entier pur**. Volontairement écrit à la main plutôt que
|
||||
// pris dans le moteur : une empreinte de déterminisme ne doit dépendre d'aucune implémentation
|
||||
// qui pourrait, elle, varier. Ici il n'y a que des `^` et des `*` sur uint64.
|
||||
// Hand-rolled on purpose: a determinism digest must not depend on an implementation that could
|
||||
// itself vary. Nothing here but XOR and multiply on uint64.
|
||||
constexpr uint64 FnvOffsetBasis = 0xcbf29ce484222325ull;
|
||||
constexpr uint64 FnvPrime = 0x00000100000001b3ull;
|
||||
|
||||
FORCEINLINE void FnvAccumByte(uint64& H, uint8 B)
|
||||
{
|
||||
H ^= (uint64)B;
|
||||
H *= FnvPrime;
|
||||
}
|
||||
|
||||
FORCEINLINE void FnvAccumU32(uint64& H, uint32 V)
|
||||
{
|
||||
FnvAccumByte(H, (uint8)( V & 0xFFu));
|
||||
FnvAccumByte(H, (uint8)((V >> 8) & 0xFFu));
|
||||
FnvAccumByte(H, (uint8)((V >> 16) & 0xFFu));
|
||||
FnvAccumByte(H, (uint8)((V >> 24) & 0xFFu));
|
||||
}
|
||||
|
||||
/** Bits d'un float, avec les NaN NORMALISÉS : un NaN a plusieurs représentations et rien ne
|
||||
* garantit que deux plateformes produisent la même. On les compte à part. */
|
||||
FORCEINLINE uint32 FloatBitsNormalised(float V, bool& bOutWasNaN)
|
||||
{
|
||||
bOutWasNaN = FMath::IsNaN(V);
|
||||
if (bOutWasNaN) { return 0x7FC00000u; }
|
||||
return *reinterpret_cast<const uint32*>(&V);
|
||||
}
|
||||
}
|
||||
|
||||
bool FVoxelForgeCrossPlatformTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
using namespace VoxelForgeTest;
|
||||
|
||||
FTestWorld World;
|
||||
World.Build();
|
||||
if (!World.IsValid())
|
||||
{
|
||||
AddError(World.WhyInvalid());
|
||||
return false;
|
||||
}
|
||||
|
||||
const UVoxelGenerator* Gen = World.Generator.Get();
|
||||
|
||||
//=========================================================================
|
||||
// LA GRILLE — entièrement déterministe, SANS RNG
|
||||
//=========================================================================
|
||||
// Pas de `FRandomStream` ici, contrairement aux autres tests : l'ensemble des points doit être
|
||||
// identique sur les deux plateformes SANS dépendre d'une seule ligne de code partagé. Une
|
||||
// boucle entière sur des bornes entières ne peut pas diverger.
|
||||
// No RNG: the point set must be identical across platforms without depending on any shared
|
||||
// code at all. An integer loop over integer bounds cannot diverge.
|
||||
const int32 TopVoxelZ = World.TopChunkZ * CHUNK_SIZE + CHUNK_SIZE - 1;
|
||||
const int32 BottomVoxelZ = World.BottomChunkZ * CHUNK_SIZE;
|
||||
|
||||
constexpr int32 XYStep = 8;
|
||||
constexpr int32 ZStep = 8;
|
||||
const int32 XYExtent = 3 * CHUNK_SIZE; // couvre la spine (0,0), les passages et le rocher
|
||||
|
||||
uint64 ShapeDigest = FnvOffsetBasis;
|
||||
uint64 FieldDigest = FnvOffsetBasis;
|
||||
|
||||
int32 NumSamples = 0, NumSolid = 0, NumNaN = 0;
|
||||
int32 NumNearWide = 0, NumNearMid = 0, NumNearTight = 0;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// `NearIso` — À QUELLE DISTANCE DE ZÉRO UN ÉCHANTILLON PEUT-IL CHANGER DE SIGNE ?
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// ⚠️ CORRIGÉ 2026-07-27, ET LA CORRECTION EST LE POINT INTÉRESSANT.
|
||||
//
|
||||
// Version d'origine : une seule bande à 1e-4, justifiée par « une différence de MODÈLE
|
||||
// FLOTTANT ». Depuis `FPSemantics = Precise` (§C9), il n'y a plus de différence de modèle
|
||||
// flottant : MSVC et Clang compilent tous deux en IEEE-754 sans contraction. J'ai d'abord cru
|
||||
// que ça rendait cette mesure caduque. **C'est faux, et il a fallu vérifier plutôt que
|
||||
// supposer.**
|
||||
//
|
||||
// Il reste `FMath::Sin` / `FMath::Cos`, présents partout dans le chemin de densité (lignes de
|
||||
// strates, nervures, placement des salles, rotations). **`sinf`/`cosf` ne sont PAS spécifiés
|
||||
// par IEEE-754** : le CRT de MSVC et la libm de la glibc ont parfaitement le droit de rendre
|
||||
// des résultats différents (typiquement ≤ 1 ULP, mais différents). `FPSemantics` a donc fermé
|
||||
// la moitié COMPILATEUR de §C9 et laissé ouverte la moitié BIBLIOTHÈQUE.
|
||||
//
|
||||
// FPSemantics = Precise removed the float-MODEL difference, but sinf/cosf are not IEEE-754
|
||||
// specified, so MSVC's CRT and glibc's libm may still differ. The compiler half of C9 is closed;
|
||||
// the library half is not.
|
||||
//
|
||||
// D'où trois bandes au lieu d'une : une bande unique à 1e-4 est **100× trop large** pour un
|
||||
// écart de libm (~1e-6 en absolu sur des densités de magnitude ~10), donc elle sur-estime
|
||||
// grossièrement le risque et crie au loup. Mesurer trois échelles donne un vrai profil, et
|
||||
// seule la plus serrée — celle qui correspond réellement à un écart de libm — déclenche
|
||||
// l'alerte.
|
||||
// Three bands, not one: 1e-4 over-estimates a libm-scale delta by ~100x. Only the tight band,
|
||||
// which actually matches a libm difference, raises a warning.
|
||||
constexpr float NearIsoWide = 1.0e-4f; // profil : large, informatif
|
||||
constexpr float NearIsoMid = 1.0e-5f; // profil
|
||||
constexpr float NearIsoTight = 1.0e-6f; // ≈ l'échelle d'un écart libm ⇒ LE chiffre du risque
|
||||
|
||||
for (int32 Z = BottomVoxelZ; Z <= TopVoxelZ; Z += ZStep)
|
||||
{
|
||||
for (int32 Y = -XYExtent; Y <= XYExtent; Y += XYStep)
|
||||
{
|
||||
for (int32 X = -XYExtent; X <= XYExtent; X += XYStep)
|
||||
{
|
||||
const float D = Gen->GetDensityAt((float)X, (float)Y, (float)Z);
|
||||
|
||||
bool bWasNaN = false;
|
||||
const uint32 Bits = FloatBitsNormalised(D, bWasNaN);
|
||||
if (bWasNaN) { ++NumNaN; }
|
||||
|
||||
// FORME : un seul bit par échantillon — le côté de l'isosurface, ce que lit le
|
||||
// mesher. C'est l'empreinte qui doit tenir entre plateformes.
|
||||
const bool bAir = (D >= 0.0f);
|
||||
if (!bAir) { ++NumSolid; }
|
||||
FnvAccumByte(ShapeDigest, bAir ? 1u : 0u);
|
||||
|
||||
// CHAMP : tous les bits.
|
||||
FnvAccumU32(FieldDigest, Bits);
|
||||
|
||||
if (!bWasNaN)
|
||||
{
|
||||
const float A = FMath::Abs(D);
|
||||
if (A < NearIsoWide) { ++NumNearWide; }
|
||||
if (A < NearIsoMid) { ++NumNearMid; }
|
||||
if (A < NearIsoTight) { ++NumNearTight; }
|
||||
}
|
||||
++NumSamples;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// LE RAPPORT — c'est le produit de ce test
|
||||
//=========================================================================
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("CROSS-PLATFORM DIGEST (seed %d, %d samples, step %d/%d)\n")
|
||||
TEXT(" SHAPE digest : 0x%016llX <- must match across Windows/Linux. This is the world.\n")
|
||||
TEXT(" FIELD digest : 0x%016llX <- bit-for-bit. May differ; see NearIso below.\n")
|
||||
TEXT(" solid %d / air %d / NaN %d"),
|
||||
World.Settings->Seed, NumSamples, XYStep, ZStep,
|
||||
ShapeDigest, FieldDigest, NumSolid, NumSamples - NumSolid, NumNaN));
|
||||
|
||||
// Le PROFIL de proximité à l'isosurface, plutôt qu'un seul seuil binaire.
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("NearIso profile over %d samples: %d within 1e-4, %d within 1e-5, %d within 1e-6. ")
|
||||
TEXT("Only the LAST number is the cross-platform risk: FPSemantics = Precise removed the ")
|
||||
TEXT("float-MODEL difference, so what remains is that sinf/cosf are not IEEE-754 specified ")
|
||||
TEXT("and MSVC's CRT may differ from glibc's libm by ~1 ULP. On densities of magnitude ~10 ")
|
||||
TEXT("that is ~1e-6 absolute, which is why the wide band over-states the risk ~100x."),
|
||||
NumSamples, NumNearWide, NumNearMid, NumNearTight));
|
||||
|
||||
if (NumNearTight > 0)
|
||||
{
|
||||
AddWarning(FString::Printf(
|
||||
TEXT("%d of %d samples sit within 1e-6 of the isosurface -- tight enough that a libm ")
|
||||
TEXT("difference between MSVC and glibc could flip their SIGN, i.e. one voxel solid for ")
|
||||
TEXT("a Windows host and air for a Linux client. FMath::Sin/Cos are used throughout the ")
|
||||
TEXT("density path (layer lines, ribs, room placement, rotations), so this is the ")
|
||||
TEXT("REMAINING half of AUDIT C9 -- the compiler half is fixed, the library half is not. ")
|
||||
TEXT("If this must be zero, the fix is a deterministic in-house sin/cos in the density ")
|
||||
TEXT("path (one more world re-tune), not another build flag."),
|
||||
NumNearTight, NumSamples));
|
||||
}
|
||||
else
|
||||
{
|
||||
AddInfo(TEXT("No sample sits within 1e-6 of the isosurface, so no sampled voxel is close ")
|
||||
TEXT("enough for a libm difference to flip its side. Evidence, not proof: it covers ")
|
||||
TEXT("this grid, not every voxel of a world."));
|
||||
}
|
||||
|
||||
TestEqual(TEXT("no sample produced NaN"), NumNaN, 0);
|
||||
|
||||
// Un monde entièrement solide ou entièrement vide rendrait les empreintes vraies mais vides de
|
||||
// sens. Garde-fou minimal contre un test qui se félicite de ne rien mesurer.
|
||||
TestTrue(TEXT("the sampled world contains both solid and air (the digest is meaningful)"),
|
||||
NumSolid > 0 && NumSolid < NumSamples);
|
||||
|
||||
//=========================================================================
|
||||
// LES ÉPINGLES — inertes tant que personne ne les a posées
|
||||
//=========================================================================
|
||||
if (PinnedShapeDigest != 0)
|
||||
{
|
||||
TestEqual(TEXT("SHAPE digest matches the pinned cross-platform reference"),
|
||||
ShapeDigest, PinnedShapeDigest);
|
||||
}
|
||||
else
|
||||
{
|
||||
AddInfo(TEXT("SHAPE digest is not pinned yet. Pin it only once Windows and Linux agree -- ")
|
||||
TEXT("pinning first would just carve the divergence into the test."));
|
||||
}
|
||||
|
||||
if (PinnedFieldDigest != 0)
|
||||
{
|
||||
TestEqual(TEXT("FIELD digest matches the pinned cross-platform reference"),
|
||||
FieldDigest, PinnedFieldDigest);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||
@@ -0,0 +1,387 @@
|
||||
// VoxelForgeDensityPurityTest.cpp
|
||||
// Phase 0.5 test #1 — LA PURETÉ DE LA DENSITÉ / density purity.
|
||||
//
|
||||
// L'INVARIANT / THE INVARIANT (ARCHITECTURE §8.4, "window invariance"):
|
||||
// GetDensityAt(x,y,z) est une fonction PURE de (coords monde, seed, layout). Le même point
|
||||
// interrogé depuis une autre tuile, un autre ordre de requêtes ou un autre thread doit rendre
|
||||
// le float BIT-IDENTIQUE. Pas "proche" — identique : un écart d'1 ULP entre deux fenêtres de
|
||||
// chunk est une COUTURE visible, et en multijoueur une divergence de monde.
|
||||
//
|
||||
// GetDensityAt is a PURE function of (world coords, seed, layout). The same point queried from
|
||||
// a different tile, in a different order, or on a different thread must return the BIT-IDENTICAL
|
||||
// float. Not "close" — identical: a 1-ULP disagreement between two chunk windows is a visible
|
||||
// seam, and in multiplayer a world divergence.
|
||||
//
|
||||
// POURQUOI CE TEST EXISTE / WHY THIS TEST EXISTS:
|
||||
// ~30 caches thread_local à clé manuelle vivent sous GetDensityAt (CP_*, GSurfColCache, les
|
||||
// slots de diff, le cache SDF). Chacun est correct exactement tant que sa CLÉ contient toutes
|
||||
// les entrées dont dépend la valeur cachée. Une entrée oubliée ne casse rien tout de suite :
|
||||
// elle produit une mauvaise valeur seulement quand le cache est chaud pour une AUTRE entrée —
|
||||
// c'est-à-dire de façon intermittente, dépendante de l'ordre, et invisible en jeu jusqu'à ce
|
||||
// qu'un joueur trouve la couture. C'est exactement ainsi que AUDIT C2 s'est caché.
|
||||
//
|
||||
// ~30 hand-keyed thread_local caches live under GetDensityAt. Each is correct exactly as long as
|
||||
// its KEY contains every input the cached value depends on. A forgotten input breaks nothing
|
||||
// immediately: it yields a wrong value only when the cache is warm for a DIFFERENT input — i.e.
|
||||
// intermittently, order-dependently, invisible in play until a player finds the seam. That is
|
||||
// precisely how AUDIT C2 stayed hidden.
|
||||
//
|
||||
// AVoxelWorld::ValidateDeterminism existe déjà mais tourne sur le GAME THREAD uniquement : il ne
|
||||
// peut structurellement pas voir une divergence de cache worker. Ce test tourne multi-thread.
|
||||
// AVoxelWorld::ValidateDeterminism already exists but runs on the GAME THREAD only: it
|
||||
// structurally cannot see a worker-cache divergence. This test runs multi-threaded.
|
||||
|
||||
#if WITH_DEV_AUTOMATION_TESTS
|
||||
|
||||
#include "Misc/AutomationTest.h"
|
||||
#include "Async/ParallelFor.h"
|
||||
#include "HAL/PlatformMisc.h"
|
||||
|
||||
#include "VoxelForgeTestFixture.h"
|
||||
|
||||
#include <atomic>
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||
FVoxelForgeDensityPurityTest,
|
||||
"VoxelForge.Determinism.DensityPurity",
|
||||
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||
|
||||
namespace
|
||||
{
|
||||
// Assez de points pour traverser plusieurs chunks/strates et faire tourner tous les caches,
|
||||
// assez peu pour rester sous la seconde. / Enough points to cross many chunks and strates and
|
||||
// churn every cache, few enough to stay under a second.
|
||||
constexpr int32 NumSamples = 10000;
|
||||
|
||||
struct FMismatch
|
||||
{
|
||||
std::atomic<int32> Count{ 0 };
|
||||
std::atomic<int32> FirstIndex{ -1 };
|
||||
|
||||
void Record(int32 Index)
|
||||
{
|
||||
Count.fetch_add(1, std::memory_order_relaxed);
|
||||
int32 Expected = -1;
|
||||
FirstIndex.compare_exchange_strong(Expected, Index, std::memory_order_relaxed);
|
||||
}
|
||||
};
|
||||
|
||||
/** Report the first divergent point with both floats and their raw bits — a mismatch that is
|
||||
* invisible in decimal (a 1-ULP cache seam) is the exact case this test is for. */
|
||||
FString DescribeMismatch(const FVector& P, float Ref, float Got)
|
||||
{
|
||||
return FString::Printf(
|
||||
TEXT("at (%.0f, %.0f, %.0f): reference %.9g [0x%08X] vs re-sample %.9g [0x%08X]"),
|
||||
P.X, P.Y, P.Z,
|
||||
Ref, *reinterpret_cast<const uint32*>(&Ref),
|
||||
Got, *reinterpret_cast<const uint32*>(&Got));
|
||||
}
|
||||
}
|
||||
|
||||
bool FVoxelForgeDensityPurityTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
using namespace VoxelForgeTest;
|
||||
|
||||
FTestWorld World;
|
||||
World.Build();
|
||||
if (!World.IsValid())
|
||||
{
|
||||
AddError(World.WhyInvalid());
|
||||
return false;
|
||||
}
|
||||
|
||||
const UVoxelGenerator* Gen = World.Generator.Get();
|
||||
|
||||
TArray<FVector> Points;
|
||||
BuildSamplePoints(World, NumSamples, /*Seed*/ 20260727, Points);
|
||||
|
||||
// ── Référence : ordre linéaire, thread de jeu, caches chauds naturellement. ──
|
||||
TArray<float> Ref;
|
||||
Ref.SetNumUninitialized(NumSamples);
|
||||
for (int32 i = 0; i < NumSamples; ++i)
|
||||
{
|
||||
Ref[i] = Gen->GetDensityAt((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
|
||||
}
|
||||
|
||||
// Un monde entièrement NaN/constant passerait tout ce qui suit trivialement. Vérifier qu'on
|
||||
// mesure bien un vrai champ. / An all-NaN or constant world would pass everything below
|
||||
// trivially. Check we are measuring a real field. (This is also the canary for AUDIT C1: a
|
||||
// large seed collapses the noise terms and the field goes constant.)
|
||||
{
|
||||
int32 NumFinite = 0, NumDistinct = 0;
|
||||
TSet<uint32> Seen;
|
||||
for (const float V : Ref)
|
||||
{
|
||||
if (FMath::IsFinite(V)) { ++NumFinite; }
|
||||
Seen.Add(*reinterpret_cast<const uint32*>(&V));
|
||||
}
|
||||
NumDistinct = Seen.Num();
|
||||
TestEqual(TEXT("every density sample is finite (no NaN/Inf leaking out of the generator)"),
|
||||
NumFinite, NumSamples);
|
||||
if (NumDistinct < NumSamples / 100)
|
||||
{
|
||||
AddError(FString::Printf(
|
||||
TEXT("The density field is suspiciously flat: only %d distinct values across %d ")
|
||||
TEXT("samples. Either the fixture built an empty world, or the noise field has ")
|
||||
TEXT("collapsed (see AUDIT-2026-07.md C1 — unbounded SeedF). The purity checks ")
|
||||
TEXT("below would pass trivially on a constant field, so they prove nothing here."),
|
||||
NumDistinct, NumSamples));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1. INDÉPENDANCE À L'ORDRE, même thread. ──
|
||||
// Un cache dont la clé est incomplète rend une valeur différente selon ce qui l'a précédé.
|
||||
// An incompletely-keyed cache returns a different value depending on what preceded it.
|
||||
{
|
||||
TArray<int32> Order;
|
||||
BuildShuffledOrder(NumSamples, /*Seed*/ 991, Order);
|
||||
|
||||
int32 Mismatches = 0;
|
||||
FString First;
|
||||
for (const int32 i : Order)
|
||||
{
|
||||
const float Got = Gen->GetDensityAt((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
|
||||
if (!BitEqual(Got, Ref[i]))
|
||||
{
|
||||
if (Mismatches == 0) { First = DescribeMismatch(Points[i], Ref[i], Got); }
|
||||
++Mismatches;
|
||||
}
|
||||
}
|
||||
if (Mismatches > 0)
|
||||
{
|
||||
AddError(FString::Printf(
|
||||
TEXT("ORDER DEPENDENCE: %d of %d points changed value when queried in a different ")
|
||||
TEXT("order on the SAME thread. A per-chunk cache is missing an input from its key. ")
|
||||
TEXT("First: %s"), Mismatches, NumSamples, *First));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. INDÉPENDANCE AU THREAD. ──
|
||||
// C'est la moitié que ValidateDeterminism (game-thread) ne peut pas voir. Chaque worker
|
||||
// parcourt SON propre ordre mélangé, donc ses thread_local se réchauffent différemment.
|
||||
// This is the half game-thread ValidateDeterminism cannot see. Each worker walks its OWN
|
||||
// shuffled order, so its thread_locals warm up differently.
|
||||
{
|
||||
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
|
||||
FMismatch Bad;
|
||||
|
||||
ParallelFor(NumBlocks, [&](int32 Block)
|
||||
{
|
||||
TArray<int32> Order;
|
||||
BuildShuffledOrder(NumSamples, /*Seed*/ 4000 + Block, Order);
|
||||
const UVoxelGenerator* LocalGen = World.Generator.Get();
|
||||
for (const int32 i : Order)
|
||||
{
|
||||
// Chaque bloc parcourt TOUS les points (pas seulement une tranche) : c'est le
|
||||
// parcours complet dans un ordre différent qui réchauffe les caches thread_local
|
||||
// différemment, et c'est exactement ce qu'on cherche à faire diverger.
|
||||
// Every block walks ALL the points, not a slice: it is the full walk in a
|
||||
// different order that warms the thread_local caches differently, which is
|
||||
// precisely what we are trying to make diverge.
|
||||
const float V = LocalGen->GetDensityAt(
|
||||
(float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
|
||||
if (!BitEqual(V, Ref[i])) { Bad.Record(i); }
|
||||
}
|
||||
});
|
||||
|
||||
const int32 Count = Bad.Count.load();
|
||||
if (Count > 0)
|
||||
{
|
||||
const int32 Idx = Bad.FirstIndex.load();
|
||||
AddError(FString::Printf(
|
||||
TEXT("WORKER DIVERGENCE: %d sample evaluations on worker threads disagreed with the ")
|
||||
TEXT("game-thread reference. This is the failure mode AVoxelWorld::ValidateDeterminism ")
|
||||
TEXT("cannot detect, and it means a thread_local cache under GetDensityAt is serving a ")
|
||||
TEXT("value it should not. First: %s"),
|
||||
Count, *DescribeMismatch(Points[Idx], Ref[Idx],
|
||||
Gen->GetDensityAt((float)Points[Idx].X, (float)Points[Idx].Y,
|
||||
(float)Points[Idx].Z))));
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. PURETÉ AVEC LA COUCHE DE DIFF ACTIVE. ──
|
||||
// Les DiffSlots sont un cache direct-mapped à 64 entrées, indexé par les bits bas du chunk.
|
||||
// Une collision servirait la liste de mods d'un AUTRE chunk : un carve fantôme à distance.
|
||||
// DiffSlots is a 64-entry direct-mapped cache indexed by the chunk coord's low bits. A
|
||||
// collision would serve another chunk's mod list: a ghost carve at a distance.
|
||||
{
|
||||
FVoxelModification Mod;
|
||||
Mod.Shape = EVoxelBrushShape::Sphere;
|
||||
Mod.Radius = 12.0f;
|
||||
Mod.Strength = -10.0f;
|
||||
for (int32 k = 0; k < 8; ++k)
|
||||
{
|
||||
Mod.Center = FVector((float)(k * CHUNK_SIZE), (float)(-k * CHUNK_SIZE), World.MidVoxelZ());
|
||||
World.DiffLayer->ApplyModification(Mod);
|
||||
}
|
||||
TestTrue(TEXT("the diff layer registered the test carves"), World.DiffLayer->HasAnyMods());
|
||||
|
||||
TArray<float> DiffRef;
|
||||
DiffRef.SetNumUninitialized(NumSamples);
|
||||
for (int32 i = 0; i < NumSamples; ++i)
|
||||
{
|
||||
DiffRef[i] = Gen->GetDensityAt((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
|
||||
}
|
||||
|
||||
FMismatch Bad;
|
||||
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
|
||||
ParallelFor(NumBlocks, [&](int32 Block)
|
||||
{
|
||||
TArray<int32> Order;
|
||||
BuildShuffledOrder(NumSamples, /*Seed*/ 7000 + Block, Order);
|
||||
const UVoxelGenerator* LocalGen = World.Generator.Get();
|
||||
for (const int32 i : Order)
|
||||
{
|
||||
const float V = LocalGen->GetDensityAt(
|
||||
(float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
|
||||
if (!BitEqual(V, DiffRef[i])) { Bad.Record(i); }
|
||||
}
|
||||
});
|
||||
|
||||
const int32 Count = Bad.Count.load();
|
||||
if (Count > 0)
|
||||
{
|
||||
const int32 Idx = Bad.FirstIndex.load();
|
||||
AddError(FString::Printf(
|
||||
TEXT("DIFF-LAYER IMPURITY: %d evaluations diverged with player edits present. ")
|
||||
TEXT("Suspect the direct-mapped DiffSlots cache in GetDensityAt (chunk low-bit ")
|
||||
TEXT("index + ModsVersion). First mismatch index %d at (%.0f, %.0f, %.0f)."),
|
||||
Count, Idx, Points[Idx].X, Points[Idx].Y, Points[Idx].Z));
|
||||
}
|
||||
|
||||
// Et le carve doit vraiment avoir changé quelque chose, sinon le sous-test ci-dessus
|
||||
// n'a rien testé. / And the carve must actually have changed something, else the sub-test
|
||||
// above tested nothing.
|
||||
int32 NumChanged = 0;
|
||||
for (int32 i = 0; i < NumSamples; ++i)
|
||||
{
|
||||
if (!BitEqual(DiffRef[i], Ref[i])) { ++NumChanged; }
|
||||
}
|
||||
if (NumChanged == 0)
|
||||
{
|
||||
AddError(TEXT("No sample changed after applying 8 carves — the diff layer branch of ")
|
||||
TEXT("GetDensityAt was never exercised, so the purity check above is vacuous. ")
|
||||
TEXT("Move the carve centres so they overlap the sample cloud."));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// AUDIT C2 — L'INVALIDATION APRÈS ÉDITION À CHAUD / live-edit invalidation
|
||||
//=============================================================================
|
||||
// La pureté ci-dessus teste « le même monde répond toujours pareil ». Ce test-ci teste l'inverse,
|
||||
// et c'est le bug réellement observé : après un RebuildStrates / une édition d'asset dans
|
||||
// l'éditeur, un worker dont le cache par-chunk est encore chaud pour ce chunk DOIT re-résoudre ses
|
||||
// params. Sinon il génère avec les ANCIENS — symptôme : « j'ai retouché la strate, régénéré, et une
|
||||
// zone a gardé l'ancienne forme ».
|
||||
//
|
||||
// The purity test above checks "the same world always answers the same". This checks the opposite,
|
||||
// and it is the bug actually observed: after a layout rebuild, a warm per-chunk cache MUST refetch.
|
||||
//
|
||||
// Le test échantillonne D0, mute un param de terrain qui NE déplace PAS la strate (donc StrateKey
|
||||
// et toutes les autres clés existantes restent identiques), ré-initialise, et exige que la densité
|
||||
// AIT CHANGÉ au même point sur le MÊME thread. Sans le correctif de version de layout, elle ne
|
||||
// change pas et ce test échoue.
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||
FVoxelForgeLiveEditInvalidationTest,
|
||||
"VoxelForge.Determinism.LiveEditInvalidation",
|
||||
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||
|
||||
bool FVoxelForgeLiveEditInvalidationTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
using namespace VoxelForgeTest;
|
||||
|
||||
FTestWorld World;
|
||||
World.Build();
|
||||
if (!World.IsValid())
|
||||
{
|
||||
AddError(World.WhyInvalid());
|
||||
return false;
|
||||
}
|
||||
|
||||
int32 SurfTopZ = 0, SurfBotZ = 0;
|
||||
if (!World.GetSlotVoxelZRange(FTestWorld::SlotSurfaceWorld, SurfTopZ, SurfBotZ))
|
||||
{
|
||||
AddError(TEXT("The fixture layout has no SurfaceWorld slot, so there is no heightfield to ")
|
||||
TEXT("live-edit. Check FTestWorld::Build's Archetypes[] against the slot constants."));
|
||||
return false;
|
||||
}
|
||||
|
||||
const UVoxelGenerator* Gen = World.Generator.Get();
|
||||
|
||||
// Colonne bien à l'écart de (0,0) : la spine et l'entrée de surface y forcent de l'air et
|
||||
// masqueraient un changement de heightfield.
|
||||
TArray<FVector> Probes;
|
||||
for (int32 i = 0; i < 64; ++i)
|
||||
{
|
||||
Probes.Add(FVector(200.0f + i * 7.0f, -140.0f + i * 5.0f,
|
||||
(float)((SurfTopZ + SurfBotZ) / 2)));
|
||||
}
|
||||
|
||||
TArray<float> Before;
|
||||
Before.Reserve(Probes.Num());
|
||||
for (const FVector& P : Probes)
|
||||
{
|
||||
Before.Add(Gen->GetDensityAt((float)P.X, (float)P.Y, (float)P.Z));
|
||||
}
|
||||
|
||||
// ── L'édition. Choisie pour NE PAS bouger la strate : StrateBottomWorldZ est inchangé, donc
|
||||
// StrateKey, le seed et les coords de chunk sont tous identiques à avant. La SEULE chose
|
||||
// qui bouge est la version de layout. / The edit is chosen NOT to move the strate: the only
|
||||
// thing that changes is the layout version.
|
||||
UVoxelStrateDefinition* SurfDef = World.Definitions[FTestWorld::SlotSurfaceWorld].Get();
|
||||
SurfDef->SurfaceParams.ElevationRange *= 2.5f;
|
||||
SurfDef->SurfaceParams.MountainStrength = FMath::Min(1.0f, SurfDef->SurfaceParams.MountainStrength + 0.4f);
|
||||
SurfDef->SurfaceParams.ContinentFrequency *= 1.7f;
|
||||
World.Reinitialize();
|
||||
|
||||
int32 NumChanged = 0;
|
||||
for (int32 i = 0; i < Probes.Num(); ++i)
|
||||
{
|
||||
const float After = Gen->GetDensityAt((float)Probes[i].X, (float)Probes[i].Y, (float)Probes[i].Z);
|
||||
if (!BitEqual(After, Before[i])) { ++NumChanged; }
|
||||
}
|
||||
|
||||
if (NumChanged == 0)
|
||||
{
|
||||
AddError(FString::Printf(
|
||||
TEXT("STALE PARAMS (AUDIT C2): the SurfaceWorld heightfield params were tripled and the ")
|
||||
TEXT("layout rebuilt, yet all %d probe densities are bit-identical. A per-chunk cache is ")
|
||||
TEXT("still keyed on ChunkCoord alone and skipped its refetch. Suspects, in order: ")
|
||||
TEXT("CP_Chunk/CP_Version in GetDensityAt, the GSurfColCache box key, and the ")
|
||||
TEXT("FChunkBiomeCache validity box (which says nothing about the FBiomeContext its ")
|
||||
TEXT("cells were classified against)."), Probes.Num()));
|
||||
}
|
||||
else
|
||||
{
|
||||
AddInfo(FString::Printf(TEXT("%d of %d probes moved after the live edit — caches refetched."),
|
||||
NumChanged, Probes.Num()));
|
||||
}
|
||||
|
||||
// Et le monde édité doit rester pur : une invalidation qui laisse un cache à moitié chaud
|
||||
// produirait des valeurs dépendantes de l'ordre. / And the edited world must stay pure.
|
||||
{
|
||||
TArray<int32> Order;
|
||||
BuildShuffledOrder(Probes.Num(), 555, Order);
|
||||
TArray<float> After;
|
||||
After.SetNumZeroed(Probes.Num());
|
||||
for (const int32 i : Order)
|
||||
{
|
||||
After[i] = Gen->GetDensityAt((float)Probes[i].X, (float)Probes[i].Y, (float)Probes[i].Z);
|
||||
}
|
||||
int32 Impure = 0;
|
||||
for (const int32 i : Order)
|
||||
{
|
||||
const float Again = Gen->GetDensityAt((float)Probes[i].X, (float)Probes[i].Y, (float)Probes[i].Z);
|
||||
if (!BitEqual(Again, After[i])) { ++Impure; }
|
||||
}
|
||||
TestEqual(TEXT("the world is still order-independent after a live edit"), Impure, 0);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||
@@ -0,0 +1,196 @@
|
||||
// VoxelForgeDiffLayerTest.cpp
|
||||
// Phase 0.5 test #3 — LA COUCHE DE DIFF SOUS CONTENTION / DiffLayer under contention.
|
||||
//
|
||||
// LE RISQUE / THE RISK:
|
||||
// UVoxelDiffLayer::ChunkMods est une TMap LUE par les threads de meshing (via GetDensityAt →
|
||||
// GetChunkModsSnapshot) et ÉCRITE par le thread de jeu (ApplyModification / Clear). TMap n'est
|
||||
// pas thread-safe : un rehash pendant une lecture est une violation d'accès. Tout est censé
|
||||
// passer par ModsLock (FRWLock) — et une AV carve-vs-stream a déjà été corrigée exactement là.
|
||||
//
|
||||
// UVoxelDiffLayer::ChunkMods is a TMap READ by mesher workers (through GetDensityAt →
|
||||
// GetChunkModsSnapshot) and WRITTEN by the game thread (ApplyModification / Clear). TMap is not
|
||||
// thread-safe: a rehash during a read is an access violation. Everything is meant to go through
|
||||
// ModsLock (FRWLock) — and a carve-vs-stream AV was already fixed in exactly this spot.
|
||||
//
|
||||
// CE QUE CE TEST PROUVE / WHAT THIS TEST PROVES:
|
||||
// 1. Aucun crash quand N lecteurs martèlent la couche pendant que le thread de jeu écrit.
|
||||
// 2. ModsVersion ne RECULE jamais du point de vue d'un lecteur (c'est la clé sur laquelle les
|
||||
// caches de snapshot invalident ; une version non monotone rendrait un cache définitivement
|
||||
// périmé).
|
||||
// 3. L'état final est exact : chaque carve appliqué est retrouvable.
|
||||
// Le point (1) est le vrai but, et il ne peut être prouvé que statistiquement — un test vert
|
||||
// veut dire "pas reproduit ici", pas "impossible". C'est quand même infiniment mieux que rien.
|
||||
//
|
||||
// Point (1) is the real target, and it can only ever be shown statistically — a green run means
|
||||
// "not reproduced here", not "impossible". Still infinitely better than nothing.
|
||||
|
||||
#if WITH_DEV_AUTOMATION_TESTS
|
||||
|
||||
#include "Misc/AutomationTest.h"
|
||||
#include "Async/Async.h"
|
||||
#include "HAL/PlatformMisc.h"
|
||||
#include "UObject/StrongObjectPtr.h"
|
||||
#include "UObject/Package.h"
|
||||
|
||||
#include "VoxelTypes.h"
|
||||
#include "VoxelDiffLayer.h"
|
||||
|
||||
#include <atomic>
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||
FVoxelForgeDiffLayerContentionTest,
|
||||
"VoxelForge.Determinism.DiffLayerContention",
|
||||
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr int32 NumWrites = 400;
|
||||
constexpr int32 NumChunksX = 8;
|
||||
|
||||
FVoxelModification MakeCarve(int32 Index)
|
||||
{
|
||||
FVoxelModification Mod;
|
||||
Mod.Shape = EVoxelBrushShape::Sphere;
|
||||
Mod.Radius = 6.0f;
|
||||
Mod.Strength = -9.0f;
|
||||
Mod.Center = FVector(
|
||||
(float)((Index % NumChunksX) * CHUNK_SIZE + 4),
|
||||
(float)(((Index / NumChunksX) % NumChunksX) * CHUNK_SIZE + 4),
|
||||
(float)(-((Index / (NumChunksX * NumChunksX)) % 4) * CHUNK_SIZE));
|
||||
return Mod;
|
||||
}
|
||||
}
|
||||
|
||||
bool FVoxelForgeDiffLayerContentionTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
TStrongObjectPtr<UVoxelDiffLayer> Diff(
|
||||
NewObject<UVoxelDiffLayer>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||
Diff->SetBudget(/*MaxMods*/ 0, /*MaxRadius*/ 50.0f, /*MaxVolume*/ 0.0f); // 0 = illimité
|
||||
|
||||
const int32 NumReaders = FMath::Max(3, FMath::Min(8, FPlatformMisc::NumberOfCores() - 1));
|
||||
|
||||
std::atomic<bool> bStop{ false };
|
||||
std::atomic<int32> VersionRegressions{ 0 };
|
||||
std::atomic<int64> ReadOps{ 0 };
|
||||
|
||||
// ── Les lecteurs : exactement le mix d'appels que fait le chemin densité d'un worker. ──
|
||||
// The readers: exactly the call mix a worker's density path makes.
|
||||
TArray<TFuture<void>> Readers;
|
||||
Readers.Reserve(NumReaders);
|
||||
for (int32 R = 0; R < NumReaders; ++R)
|
||||
{
|
||||
Readers.Add(Async(EAsyncExecution::Thread, [&, R]()
|
||||
{
|
||||
uint32 LastVersion = 0;
|
||||
int64 LocalOps = 0;
|
||||
FRandomStream Rng(9000 + R);
|
||||
while (!bStop.load(std::memory_order_relaxed))
|
||||
{
|
||||
const uint32 V = Diff->GetModsVersion();
|
||||
if (V < LastVersion)
|
||||
{
|
||||
VersionRegressions.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
LastVersion = V;
|
||||
|
||||
const FIntVector Chunk(Rng.RandRange(0, NumChunksX - 1),
|
||||
Rng.RandRange(0, NumChunksX - 1),
|
||||
Rng.RandRange(-3, 0));
|
||||
|
||||
// Le fast-reject sans verrou, puis le vrai chemin sous verrou.
|
||||
if (Diff->HasAnyMods())
|
||||
{
|
||||
Diff->HasAnyModInChunkRange(Chunk - FIntVector(1, 1, 1), Chunk + FIntVector(1, 1, 1));
|
||||
Diff->HasModifications(Chunk);
|
||||
|
||||
TArray<FVoxelModification> Snapshot;
|
||||
Diff->GetChunkModsSnapshot(Chunk, Snapshot);
|
||||
|
||||
// Toucher réellement les données copiées : un snapshot qui aliaserait la TMap
|
||||
// (au lieu de la copier) exploserait ici et pas au moment de la copie.
|
||||
// Actually touch the copied data: a snapshot that aliased the TMap instead of
|
||||
// copying it would blow up here rather than at copy time.
|
||||
const float X = (float)(Chunk.X * CHUNK_SIZE + 3);
|
||||
const float Y = (float)(Chunk.Y * CHUNK_SIZE + 3);
|
||||
const float Z = (float)(Chunk.Z * CHUNK_SIZE + 3);
|
||||
const float Sink = UVoxelDiffLayer::EvaluateMods(Snapshot, X, Y, Z)
|
||||
+ Diff->GetDensityOffset(Chunk, X, Y, Z);
|
||||
// Consommer Sink dans une branche que le compilateur ne peut pas prouver morte,
|
||||
// sinon tout le bloc de lecture est éliminé et le test ne teste rien.
|
||||
// Consume Sink in a branch the compiler cannot prove dead, otherwise the whole
|
||||
// read block is optimised away and the test tests nothing.
|
||||
if (Sink == 1.2345678e30f) { ++LocalOps; }
|
||||
}
|
||||
++LocalOps;
|
||||
}
|
||||
ReadOps.fetch_add(LocalOps, std::memory_order_relaxed);
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Phase 1 : écritures pures. L'état final doit être exact. ──
|
||||
// ⚠️ `GetTotalModificationCount()` ne compte PAS les opérations : il somme les entrées
|
||||
// STOCKÉES, et un coup de pinceau est rangé dans CHAQUE chunk que son AABB recouvre. 400
|
||||
// sphères de rayon 6 posées à cheval sur des coins de chunk donnent 3200 entrées, pas 400.
|
||||
// (Le compteur d'opérations est le membre privé `ModificationCount`, non exposé.)
|
||||
// C'est d'ailleurs la métrique qui compte pour AUDIT C6 : ce sont les ENTRÉES stockées qui
|
||||
// grossissent sans borne, pas le nombre de coups de pioche. Le nom du getter induit en erreur.
|
||||
//
|
||||
// GetTotalModificationCount() does NOT count operations: it sums STORED entries, and a stroke
|
||||
// is filed under EVERY chunk its AABB overlaps. It is also the metric that matters for AUDIT C6
|
||||
// — stored entries are what grow without bound. The getter's name misleads.
|
||||
int32 ExpectedEntries = 0;
|
||||
for (int32 i = 0; i < NumWrites; ++i)
|
||||
{
|
||||
const TArray<FIntVector> Touched = Diff->ApplyModification(MakeCarve(i));
|
||||
if (Touched.Num() == 0)
|
||||
{
|
||||
AddError(FString::Printf(
|
||||
TEXT("ApplyModification #%d was rejected. The budget should be unlimited here — ")
|
||||
TEXT("if this fires, SetBudget(0, ...) no longer means 'no cap'."), i));
|
||||
break;
|
||||
}
|
||||
ExpectedEntries += Touched.Num();
|
||||
}
|
||||
|
||||
// Vérifie que le fan-out RÉELLEMENT stocké correspond à ce qu'ApplyModification a rapporté —
|
||||
// un désaccord voudrait dire que la liste de chunks rendue à l'appelant (celle qui décide quoi
|
||||
// re-mailler) ne décrit pas ce qui a été écrit. C'est un bien meilleur test que « == 400 ».
|
||||
TestEqual(TEXT("stored diff entries match the chunk fan-out ApplyModification reported"),
|
||||
Diff->GetTotalModificationCount(), ExpectedEntries);
|
||||
TestTrue(TEXT("the lock-free bHasAnyMods fast-path agrees with the map"), Diff->HasAnyMods());
|
||||
TestTrue(TEXT("at least one chunk holds mods"), Diff->GetModifiedChunkCount() > 0);
|
||||
|
||||
// ── Phase 2 : le chemin réellement dangereux — Clear() pendant que les lecteurs tiennent des
|
||||
// itérateurs potentiels. On n'affirme plus de compte ici, seulement la survie + la monotonie.
|
||||
// Phase 2: the genuinely dangerous path — Clear() while readers may hold iterators. No count
|
||||
// assertions here, only survival + monotonicity.
|
||||
for (int32 Round = 0; Round < 6; ++Round)
|
||||
{
|
||||
for (int32 i = 0; i < 60; ++i) { Diff->ApplyModification(MakeCarve(i + Round * 60)); }
|
||||
Diff->Clear();
|
||||
}
|
||||
|
||||
bStop.store(true, std::memory_order_relaxed);
|
||||
for (TFuture<void>& F : Readers) { F.Wait(); }
|
||||
|
||||
AddInfo(FString::Printf(TEXT("%d reader threads completed %lld read rounds against %d writes + 6 clears."),
|
||||
NumReaders, (long long)ReadOps.load(), NumWrites + 360));
|
||||
|
||||
TestEqual(TEXT("ModsVersion never went backwards from a reader's point of view"),
|
||||
VersionRegressions.load(), 0);
|
||||
|
||||
if (ReadOps.load() < (int64)NumReaders)
|
||||
{
|
||||
AddError(TEXT("The reader threads barely ran, so no contention was actually exercised. ")
|
||||
TEXT("The writes finished before the threads started — increase NumWrites or add ")
|
||||
TEXT("a barrier before the writer loop."));
|
||||
}
|
||||
|
||||
// Après Clear(), l'état doit être franchement vide (pas « presque »).
|
||||
TestFalse(TEXT("Clear() left no mods behind"), Diff->HasAnyMods());
|
||||
TestEqual(TEXT("Clear() reset the modified-chunk count"), Diff->GetModifiedChunkCount(), 0);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||
@@ -0,0 +1,610 @@
|
||||
// VoxelForgeHeightStackTest.cpp
|
||||
// LA QUESTION D'ARCHITECTURE DE LA PHASE 2, POSÉE AVANT D'ÉCRIRE CE QUI EN DÉPEND.
|
||||
// PHASE 2'S ARCHITECTURAL QUESTION, ASKED BEFORE WRITING WHAT DEPENDS ON THE ANSWER.
|
||||
//
|
||||
// SurfaceWorld a forcé une décision que ni Maze ni Slab n'avaient forcée : ses opérateurs de
|
||||
// terrain (cliff / terrace / layer lines / plage) n'opèrent PAS sur la densité. Ils lisent et
|
||||
// écrivent **une altitude**. Ils ne rentrent donc pas dans `IVoxelDensityOp`, et les y forcer
|
||||
// voudrait dire soit un canal par-voxel pour une propriété de COLONNE, soit un seul opérateur
|
||||
// opaque — ce que `OPSTACK-PLAN §2.5` appelle exactement l'échec du refactor.
|
||||
//
|
||||
// D'où une seconde famille, `VoxelHeightOp.h`. **Ce test est ce qui dit si elle était une bonne
|
||||
// idée** — la même méthode que la Phase 1 a appliquée à la densité : décomposer, puis MESURER
|
||||
// contre l'original, avant de construire par-dessus.
|
||||
//
|
||||
// ⚠️ CE QUE CE TEST COUVRE, ET SURTOUT CE QU'IL NE COUVRE PAS
|
||||
// ✅ la pile de HAUTEUR du sol, contre `ComputeSurfaceTerrainZ` (2 passes : défauts, puis tous
|
||||
// les ops F20 allumés — c'est la seconde qui porte le test) ;
|
||||
// ✅ la pile de HAUTEUR de la voûte + le pont vers l'espace densité (`FSurfaceColumnSource`),
|
||||
// contre `GetSurfaceDensity` ;
|
||||
// ❌ **l'OVERHANG** — `GetSurfaceDensity` passe `OverhangAmp = 0`, donc il n'en calcule aucun.
|
||||
// Sa seule référence est le chemin CACHÉ (`ComputeSurfaceColumn`), qui résout le gate et la
|
||||
// direction amont par colonne ;
|
||||
// ❌ **le MÉLANGE DE BIOMES** — ici `ParamsD == ParamsN`, poids 0. C'est le combiner `Mask`, et
|
||||
// `§5` en fait le prototype de la Phase 3 : ça mérite son étape.
|
||||
//
|
||||
// Les deux manques sont l'étape 2b. **Ne pas brancher SurfaceWorld dans un monde à biomes ou à
|
||||
// overhang avant**, parce que rien ici ne dirait que c'est faux.
|
||||
//
|
||||
// COVERED: the ground height stack vs ComputeSurfaceTerrainZ, and the ceiling stack + the bridge
|
||||
// into density space vs GetSurfaceDensity. NOT COVERED: the overhang (GetSurfaceDensity passes
|
||||
// OverhangAmp = 0, so only the cached path computes it) and biome blending (weight 0 here). Both
|
||||
// are step 2b — do not wire SurfaceWorld into a world with biomes or overhangs before then.
|
||||
//
|
||||
// LA BARRE : **bit à bit.** Depuis `FPSemantics = Precise` (AUDIT §C9/§C10), Maze et Slab sont
|
||||
// bit-identiques à leur original ; il n'y a plus de « plancher ULP » à tolérer. Un écart ici est
|
||||
// donc une vraie trouvaille — un offset de bruit faux, un ordre d'op inversé, un gate oublié.
|
||||
// Ces fonctions sont des ALTITUDES en voxels, pas des densités : un écart d'un demi-voxel est un
|
||||
// terrain visiblement différent, pas du bruit d'arrondi.
|
||||
|
||||
#if WITH_DEV_AUTOMATION_TESTS
|
||||
|
||||
#include "Misc/AutomationTest.h"
|
||||
#include "Async/ParallelFor.h"
|
||||
#include "HAL/PlatformMisc.h"
|
||||
|
||||
#include "VoxelForgeTestFixture.h"
|
||||
#include "VoxelHeightOp.h"
|
||||
#include "VoxelDensityOpStack.h"
|
||||
|
||||
#include <atomic>
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||
FVoxelForgeHeightStackTest,
|
||||
"VoxelForge.OpStack.SurfaceHeightEquivalence",
|
||||
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr int32 NumHeightSamples = 20000;
|
||||
|
||||
/** Les params du terrain ne sont intéressants que si les ops sont ALLUMÉS. Ceux de la fixture
|
||||
* sont les défauts, et `§5` note que les ops F20 sont « all off by default ». Un test qui ne
|
||||
* ferait tourner que les défauts vérifierait la source structurelle et RIEN des quatre
|
||||
* modificateurs — c'est-à-dire l'essentiel de ce qui est nouveau ici. */
|
||||
void EnableAllTerrainOps(FSurfaceGenerationParams& P)
|
||||
{
|
||||
P.CliffStrength = 0.6f;
|
||||
P.CliffSampleDist = 2.0f;
|
||||
P.CliffSlopeThreshold = 0.15f;
|
||||
P.CliffSharpness = 1.4f;
|
||||
|
||||
P.TerraceStrength = 0.7f;
|
||||
P.TerraceHeight = 9.0f;
|
||||
P.TerraceHardness = 0.8f;
|
||||
|
||||
P.LayerLineDepth = 1.3f;
|
||||
P.LayerLineSpacing = 7.0f;
|
||||
|
||||
// ⚠️ `WaterLevelRelative` DOIT être > 0, sinon `FBeachHeightMod` sort immédiatement et le
|
||||
// cinquième op n'est jamais exercé — un test vert qui n'a rien testé. Le défaut de la
|
||||
// struct est 0.0f, donc l'oublier est le piège naturel ici.
|
||||
// The beach op early-outs unless WaterLevelRelative > 0, so without this the fifth op is
|
||||
// never exercised at all — a green test that measured nothing.
|
||||
P.WaterLevelRelative = 0.30f;
|
||||
P.BeachWidth = 6.0f;
|
||||
}
|
||||
}
|
||||
|
||||
bool FVoxelForgeHeightStackTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
using namespace VoxelForgeTest;
|
||||
|
||||
FTestWorld World;
|
||||
World.Build();
|
||||
if (!World.IsValid())
|
||||
{
|
||||
AddError(World.WhyInvalid());
|
||||
return false;
|
||||
}
|
||||
|
||||
const UVoxelGenerator* Gen = World.Generator.Get();
|
||||
|
||||
int32 TopVoxelZ = 0, BottomVoxelZ = 0;
|
||||
if (!World.GetSlotVoxelZRange(FTestWorld::SlotSurfaceWorld, TopVoxelZ, BottomVoxelZ))
|
||||
{
|
||||
AddError(TEXT("The fixture layout has no SurfaceWorld slot. Check FTestWorld::Build's ")
|
||||
TEXT("Archetypes[] against FTestWorld::SlotSurfaceWorld."));
|
||||
return false;
|
||||
}
|
||||
|
||||
const int32 MidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE;
|
||||
|
||||
// Les points d'échantillonnage : XY seulement, la hauteur ne dépend pas de Z (c'est le point).
|
||||
TArray<FVector2D> Points;
|
||||
Points.Reserve(NumHeightSamples);
|
||||
{
|
||||
FRandomStream Rng(90210);
|
||||
for (int32 i = 0; i < NumHeightSamples; ++i)
|
||||
{
|
||||
Points.Add(FVector2D(
|
||||
(float)Rng.RandRange(-6 * CHUNK_SIZE, 6 * CHUNK_SIZE),
|
||||
(float)Rng.RandRange(-6 * CHUNK_SIZE, 6 * CHUNK_SIZE)));
|
||||
}
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// LA BATTERIE, PARAMÉTRÉE PAR JEU DE PARAMS
|
||||
//=========================================================================
|
||||
auto RunForParams = [&](const FSurfaceGenerationParams& P, const TCHAR* Label, int32 SeedSalt)
|
||||
{
|
||||
FVoxelHeightStack Stack;
|
||||
VoxelHeightOps::BuildSurfaceHeightStack(Stack, P, World.Settings->Seed);
|
||||
|
||||
// Une DÉCOMPOSITION, pas une enveloppe : source + 4 modificateurs.
|
||||
TestEqual(*FString::Printf(TEXT("%s: the height stack is decomposed into 5 ops"), Label),
|
||||
Stack.Num(), 5);
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// 1. ÉQUIVALENCE — contre ComputeSurfaceTerrainZ, en ALTITUDE
|
||||
//---------------------------------------------------------------------
|
||||
int32 NumDiff = 0, WorstIdx = -1;
|
||||
float WorstDelta = 0.0f, WorstOld = 0.0f;
|
||||
|
||||
for (int32 i = 0; i < NumHeightSamples; ++i)
|
||||
{
|
||||
const float X = (float)Points[i].X, Y = (float)Points[i].Y;
|
||||
|
||||
const float Old = Gen->ComputeSurfaceTerrainZ(X, Y, P);
|
||||
const float New = Stack.EvalHeight(X, Y);
|
||||
|
||||
if (!BitEqual(Old, New))
|
||||
{
|
||||
++NumDiff;
|
||||
const float Delta = FMath::Abs(Old - New);
|
||||
if (Delta > WorstDelta) { WorstDelta = Delta; WorstIdx = i; WorstOld = Old; }
|
||||
}
|
||||
}
|
||||
|
||||
if (NumDiff == 0)
|
||||
{
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("%s: bit-identical across %d samples. The height-space decomposition ")
|
||||
TEXT("reproduces ComputeSurfaceTerrainZ exactly."), Label, NumHeightSamples));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Pas de gradation ULP ici, à dessein : ce sont des ALTITUDES. Depuis /fp:precise la
|
||||
// barre est l'égalité binaire, et un écart de hauteur se voit dans le monde.
|
||||
AddError(FString::Printf(
|
||||
TEXT("%s: %d of %d samples differ from ComputeSurfaceTerrainZ (largest |delta| ")
|
||||
TEXT("%.9g voxels at (%.0f, %.0f), where the reference height is %.4f). These are ")
|
||||
TEXT("ALTITUDES, not densities -- this is a real port error, not rounding. Check, ")
|
||||
TEXT("in order: the op ORDER (structural -> cliff -> terrace -> layer lines -> ")
|
||||
TEXT("beach), the terrace's `* Relief` gate (that is the original's `* M`), the ")
|
||||
TEXT("cliff resampling the STRUCTURAL field rather than the modified height, and ")
|
||||
TEXT("the noise offsets (3.1/5.7/0.7, 11/22/1.3, 99/77/0.9, 7.3/2.1/0.5)."),
|
||||
Label, NumDiff, NumHeightSamples, WorstDelta,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
|
||||
WorstOld));
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// 2. INVARIANCE DE FENÊTRE
|
||||
//---------------------------------------------------------------------
|
||||
// Une pile de hauteur alimente le cache de colonne T1.a, qui est PARTAGÉ sur toute la pile
|
||||
// verticale de chunks. Une impureté ici ne fait pas une couture locale : elle se propage à
|
||||
// tous les Z d'un coup (AUDIT §6.3).
|
||||
{
|
||||
std::atomic<int32> Impure{ 0 };
|
||||
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
|
||||
|
||||
TArray<float> Ref;
|
||||
Ref.SetNumUninitialized(NumHeightSamples);
|
||||
for (int32 i = 0; i < NumHeightSamples; ++i)
|
||||
{
|
||||
Ref[i] = Stack.EvalHeight((float)Points[i].X, (float)Points[i].Y);
|
||||
}
|
||||
|
||||
ParallelFor(NumBlocks, [&](int32 Block)
|
||||
{
|
||||
TArray<int32> LocalOrder;
|
||||
BuildShuffledOrder(NumHeightSamples, 1200 + Block + SeedSalt, LocalOrder);
|
||||
for (const int32 i : LocalOrder)
|
||||
{
|
||||
const float V = Stack.EvalHeight((float)Points[i].X, (float)Points[i].Y);
|
||||
if (!BitEqual(V, Ref[i])) { Impure.fetch_add(1, std::memory_order_relaxed); }
|
||||
}
|
||||
});
|
||||
|
||||
TestEqual(*FString::Printf(
|
||||
TEXT("%s: the height stack is window-invariant across order and threads"), Label),
|
||||
Impure.load(), 0);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// 3. LES MAJORANTS DE DÉPLACEMENT SONT-ILS HONNÊTES ?
|
||||
//---------------------------------------------------------------------
|
||||
// `MaxDisplacement` servira à borner une colonne pour un `ClassifyBox` de heightfield, la
|
||||
// même mécanique qui fait prouver 36-40 tuiles sur 60 à la dalle. Un majorant FAUX serait
|
||||
// un TROU, donc on le teste par force brute AVANT de construire quoi que ce soit dessus.
|
||||
//
|
||||
// On mesure le déplacement des trois mods bornables en comparant la pile complète à une
|
||||
// pile tronquée (source + cliff seuls) : la différence est exactement ce que terrace +
|
||||
// layer lines + plage ont déplacé.
|
||||
{
|
||||
FVoxelHeightStack Base;
|
||||
const IVoxelHeightOp* Structural = nullptr;
|
||||
Base.Add(VoxelHeightOps::MakeStructuralHeightSource(P, World.Settings->Seed, &Structural));
|
||||
Base.Add(VoxelHeightOps::MakeCliffHeightMod(P, Structural));
|
||||
|
||||
FVoxelHeightStack Bounded;
|
||||
const IVoxelHeightOp* Structural2 = nullptr;
|
||||
Bounded.Add(VoxelHeightOps::MakeStructuralHeightSource(P, World.Settings->Seed, &Structural2));
|
||||
Bounded.Add(VoxelHeightOps::MakeCliffHeightMod(P, Structural2));
|
||||
Bounded.Add(VoxelHeightOps::MakeTerraceHeightMod(P));
|
||||
Bounded.Add(VoxelHeightOps::MakeLayerLineHeightMod(P));
|
||||
Bounded.Add(VoxelHeightOps::MakeBeachHeightMod(P));
|
||||
|
||||
const float Claimed = FMath::Max(P.TerraceStrength > 0.0f ? P.TerraceHeight : 0.0f, 0.0f)
|
||||
+ FMath::Max(P.LayerLineSpacing > 0.0f ? P.LayerLineDepth : 0.0f, 0.0f)
|
||||
+ FMath::Max(P.WaterLevelRelative > 0.0f ? P.BeachWidth : 0.0f, 0.0f);
|
||||
|
||||
float WorstObserved = 0.0f;
|
||||
int32 NumOverBound = 0;
|
||||
for (int32 i = 0; i < NumHeightSamples; ++i)
|
||||
{
|
||||
const float X = (float)Points[i].X, Y = (float)Points[i].Y;
|
||||
const float Moved = FMath::Abs(Bounded.EvalHeight(X, Y) - Base.EvalHeight(X, Y));
|
||||
WorstObserved = FMath::Max(WorstObserved, Moved);
|
||||
if (Moved > Claimed) { ++NumOverBound; }
|
||||
}
|
||||
|
||||
TestEqual(*FString::Printf(
|
||||
TEXT("%s: no sample exceeds the claimed MaxDisplacement (a false bound is a hole)"),
|
||||
Label),
|
||||
NumOverBound, 0);
|
||||
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("%s: MaxDisplacement claims %.3f voxels, worst observed %.3f (%.0f%% of the ")
|
||||
TEXT("claim). A loose bound only costs CPU later; a tight-but-wrong one would be a hole."),
|
||||
Label, Claimed, WorstObserved,
|
||||
Claimed > 0.0f ? 100.0f * WorstObserved / Claimed : 0.0f));
|
||||
}
|
||||
};
|
||||
|
||||
//=========================================================================
|
||||
// DEUX PASSES — et la seconde est celle qui compte
|
||||
//=========================================================================
|
||||
const UVoxelStrateDefinition* SurfaceDef =
|
||||
World.StrateManager->GetStrateForChunk(FIntVector(0, 0, MidChunkZ));
|
||||
if (!SurfaceDef)
|
||||
{
|
||||
AddError(TEXT("No strate definition resolved for the SurfaceWorld slot's mid chunk."));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Les bornes Z de runtime sont posées à la main : `GetSlabParamsForChunk` a un équivalent pour
|
||||
// la dalle, mais le chemin surface passe par `ResolveSurfaceChunkParams`, qui est privé et
|
||||
// mêle la résolution de biome. La pile de hauteur ne dépend que des params + du seed, donc
|
||||
// fournir les params directement est à la fois suffisant et plus lisible en cas d'échec.
|
||||
FSurfaceGenerationParams Defaults = SurfaceDef->SurfaceParams;
|
||||
Defaults.StrateTopWorldZ = (float)TopVoxelZ;
|
||||
Defaults.StrateBottomWorldZ = (float)BottomVoxelZ;
|
||||
|
||||
RunForParams(Defaults, TEXT("SurfaceWorld(defaults)"), 0);
|
||||
|
||||
// ⚠️ LA PASSE LOAD-BEARING. Les ops de terrain F20 sont éteints par défaut, donc la passe
|
||||
// ci-dessus n'exerce que la source structurelle et laisse les QUATRE modificateurs — c'est-à-
|
||||
// dire tout ce qui est nouveau dans cette décomposition — non testés. Celle-ci les allume.
|
||||
FSurfaceGenerationParams AllOps = Defaults;
|
||||
EnableAllTerrainOps(AllOps);
|
||||
RunForParams(AllOps, TEXT("SurfaceWorld(all terrain ops on)"), 64);
|
||||
|
||||
//=========================================================================
|
||||
// ÉTAPE 2a — LE PONT VERS L'ESPACE DENSITÉ
|
||||
//=========================================================================
|
||||
// `FSurfaceColumnSource` consomme les DEUX piles de hauteur (sol + voûte) et rend une densité.
|
||||
// La référence est `GetSurfaceDensity`, qui est exactement la variante **sans overhang**
|
||||
// (il passe `OverhangAmp = 0`) et **sans biomes** (ParamsD == ParamsN, poids 0) — donc la
|
||||
// comparaison est nette plutôt qu'approximative.
|
||||
//
|
||||
// ⚠️ Ce que ce bloc NE teste PAS, et qu'il ne faut pas croire testé : l'overhang et le mélange
|
||||
// de biomes. Tous deux arrivent à l'étape 2b, avec le chemin CACHÉ pour référence — c'est le
|
||||
// seul qui les calcule.
|
||||
{
|
||||
// ⚠️ `OverhangStrength = 0` EXPLICITEMENT : `GetSurfaceDensity` passe `OverhangAmp = 0`,
|
||||
// donc il n'en calcule aucun. Comparer une pile qui en produit à une référence qui n'en
|
||||
// produit pas ferait échouer le test pour la seule raison que la référence est incomplète.
|
||||
// L'overhang a sa propre passe juste en dessous, avec le bon oracle.
|
||||
FSurfaceGenerationParams P = AllOps;
|
||||
P.OverhangStrength = 0.0f;
|
||||
|
||||
FVoxelOpStack Stack;
|
||||
VoxelDensityOps::BuildSurfaceStack(Stack, P, World.Settings->Seed,
|
||||
Gen->OriginSpineRadius, World.StrateManager.Get());
|
||||
|
||||
// 1 source + 1 overhang + 3 structurels. L'op overhang est présent mais inerte ici
|
||||
// (amp 0 ⇒ sortie immédiate) — la décomposition ne change pas selon les params.
|
||||
TestEqual(TEXT("the surface density stack is source + overhang + 3 structural"),
|
||||
Stack.Num(), 5);
|
||||
|
||||
FVoxelOpContext Ctx;
|
||||
Ctx.Seed = (uint32)World.Settings->Seed;
|
||||
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
|
||||
Ctx.StrateTopWorldZ = P.StrateTopWorldZ;
|
||||
Ctx.StrateBottomWorldZ = P.StrateBottomWorldZ;
|
||||
Stack.PrepareChunk(Ctx);
|
||||
|
||||
int32 NumDiff = 0, NumSideDisagree = 0, WorstIdx = -1;
|
||||
float WorstDelta = 0.0f;
|
||||
|
||||
FRandomStream Rng(5150);
|
||||
for (int32 i = 0; i < NumHeightSamples; ++i)
|
||||
{
|
||||
const float X = (float)Rng.RandRange(-4 * CHUNK_SIZE, 4 * CHUNK_SIZE);
|
||||
const float Y = (float)Rng.RandRange(-4 * CHUNK_SIZE, 4 * CHUNK_SIZE);
|
||||
const float Z = (float)Rng.RandRange(BottomVoxelZ, TopVoxelZ);
|
||||
|
||||
// ParamsD == ParamsN, poids 0 ⇒ une seule évaluation, pas de biomes.
|
||||
const float Old = Gen->GetSurfaceDensity(X, Y, Z, P, P, 0.0f);
|
||||
const float New = Stack.EvalMC(X, Y, Z);
|
||||
|
||||
if (!BitEqual(Old, New))
|
||||
{
|
||||
++NumDiff;
|
||||
const float Delta = FMath::Abs(Old - New);
|
||||
if (Delta > WorstDelta) { WorstDelta = Delta; WorstIdx = i; }
|
||||
}
|
||||
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSideDisagree; }
|
||||
}
|
||||
|
||||
if (NumDiff == 0)
|
||||
{
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("SurfaceWorld density stack: bit-identical to GetSurfaceDensity across %d ")
|
||||
TEXT("samples. The height stacks feed the density space correctly."),
|
||||
NumHeightSamples));
|
||||
}
|
||||
else
|
||||
{
|
||||
AddError(FString::Printf(
|
||||
TEXT("SurfaceWorld density stack: %d of %d samples differ (largest |delta| %.9g); ")
|
||||
TEXT("%d cross the isosurface. Since /fp:precise the bar is bit-identity, so this ")
|
||||
TEXT("is a real port error. Check, in order: the combine (Density = max(TerrainZ - Z, ")
|
||||
TEXT("Z - CeilSurf)), the sky-cap transcription (warp offsets 0.71/2.3/3.3 and ")
|
||||
TEXT("6.1/0.19/4.7, the abs() on roughness, the ridge *0.5+0.5), and the order of ")
|
||||
TEXT("the structural post ops."),
|
||||
NumDiff, NumHeightSamples, WorstDelta, NumSideDisagree));
|
||||
}
|
||||
|
||||
TestEqual(TEXT("surface: no sample lands on the opposite side of the isosurface"),
|
||||
NumSideDisagree, 0);
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// ÉTAPE 2b — L'OVERHANG, contre le SEUL oracle qui le calcule
|
||||
//=========================================================================
|
||||
// `GetSurfaceDensity` passe `OverhangAmp = 0`. La seule référence est donc le chemin caché :
|
||||
// `ComputeSurfaceColumn` (qui résout le gate et la direction amont par colonne) suivi de
|
||||
// `SurfaceDensityFromColumn` (qui applique l'union par voxel). Les deux viennent d'être
|
||||
// exposées pour ça.
|
||||
//
|
||||
// C'est aussi la passe qui vérifie le MÉMO DE COLONNE de `FSurfaceColumnSource` : l'op overhang
|
||||
// lit la colonne produite par la source, et s'ils divergeaient d'un XY, l'union se ferait au
|
||||
// mauvais endroit. Un mémo mal clé se verrait ici.
|
||||
{
|
||||
FSurfaceGenerationParams P = AllOps;
|
||||
P.OverhangStrength = 0.8f;
|
||||
P.OverhangSlopeThreshold = 0.12f;
|
||||
P.OverhangHeight = 14.0f;
|
||||
P.OverhangReach = 10.0f;
|
||||
P.OverhangFrequency = 0.05f;
|
||||
P.OverhangZScale = 0.6f;
|
||||
|
||||
FVoxelOpStack Stack;
|
||||
VoxelDensityOps::BuildSurfaceStack(Stack, P, World.Settings->Seed,
|
||||
Gen->OriginSpineRadius, World.StrateManager.Get());
|
||||
|
||||
FVoxelOpContext Ctx;
|
||||
Ctx.Seed = (uint32)World.Settings->Seed;
|
||||
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
|
||||
Ctx.StrateTopWorldZ = P.StrateTopWorldZ;
|
||||
Ctx.StrateBottomWorldZ = P.StrateBottomWorldZ;
|
||||
Stack.PrepareChunk(Ctx);
|
||||
|
||||
// Pas de biomes : contexte vide ⇒ ComputeSurfaceColumn retombe sur BaseSurface pour les
|
||||
// deux côtés, poids 0. C'est exactement ce que la pile fait aujourd'hui.
|
||||
FBiomeContext EmptyCtx;
|
||||
TArray<FSurfaceGenerationParams> NoBiomeParams;
|
||||
FChunkBiomeCache BiomeCache;
|
||||
|
||||
int32 NumDiff = 0, NumSideDisagree = 0, NumInWindow = 0;
|
||||
float WorstDelta = 0.0f;
|
||||
|
||||
FRandomStream Rng(1337);
|
||||
for (int32 i = 0; i < NumHeightSamples; ++i)
|
||||
{
|
||||
const float X = (float)Rng.RandRange(-4 * CHUNK_SIZE, 4 * CHUNK_SIZE);
|
||||
const float Y = (float)Rng.RandRange(-4 * CHUNK_SIZE, 4 * CHUNK_SIZE);
|
||||
|
||||
float TerrainZ = 0.0f, CeilSurf = 0.0f, Amp = 0.0f, DirX = 0.0f, DirY = 0.0f;
|
||||
Gen->ComputeSurfaceColumn(X, Y, MidChunkZ, P, EmptyCtx, NoBiomeParams, BiomeCache,
|
||||
TerrainZ, CeilSurf, Amp, DirX, DirY);
|
||||
|
||||
// Échantillonner DANS la fenêtre d'overhang la moitié du temps : un tirage uniforme sur
|
||||
// toute la strate la raterait presque toujours, et le test serait vert sans avoir
|
||||
// exercé l'op une seule fois — le même piège que `WaterLevelRelative` plus haut.
|
||||
float Z;
|
||||
if ((i & 1) && Amp > 0.0f)
|
||||
{
|
||||
Z = TerrainZ + P.OverhangHeight * ((float)(i % 97) / 97.0f);
|
||||
++NumInWindow;
|
||||
}
|
||||
else
|
||||
{
|
||||
Z = (float)Rng.RandRange(BottomVoxelZ, TopVoxelZ);
|
||||
}
|
||||
|
||||
const float Old = Gen->SurfaceDensityFromColumn(X, Y, Z, TerrainZ, CeilSurf,
|
||||
Amp, DirX, DirY, P);
|
||||
const float New = Stack.EvalMC(X, Y, Z);
|
||||
|
||||
if (!BitEqual(Old, New))
|
||||
{
|
||||
++NumDiff;
|
||||
WorstDelta = FMath::Max(WorstDelta, FMath::Abs(Old - New));
|
||||
}
|
||||
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSideDisagree; }
|
||||
}
|
||||
|
||||
if (NumDiff == 0)
|
||||
{
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("Overhang: bit-identical to SurfaceDensityFromColumn across %d samples, %d of ")
|
||||
TEXT("them deliberately inside the overhang window. The per-column memo hands the ")
|
||||
TEXT("source's column to the overhang op correctly."),
|
||||
NumHeightSamples, NumInWindow));
|
||||
}
|
||||
else
|
||||
{
|
||||
AddError(FString::Printf(
|
||||
TEXT("Overhang: %d of %d samples differ (largest |delta| %.9g); %d cross the ")
|
||||
TEXT("isosurface; %d samples were inside the window. Check, in order: the column ")
|
||||
TEXT("memo key (does the overhang op see the SAME column as the source?), the ")
|
||||
TEXT("window gate (Z > TerrainZ && Z <= TerrainZ + OverhangHeight), Frac and the ")
|
||||
TEXT("ShiftV > 0.5 threshold, the shelf noise offsets (17.3/23.9/5.1 with the ")
|
||||
TEXT("OverhangZScale Z term), and that the shift resamples the STRUCTURAL height."),
|
||||
NumDiff, NumHeightSamples, WorstDelta, NumSideDisagree, NumInWindow));
|
||||
}
|
||||
|
||||
TestEqual(TEXT("overhang: no sample lands on the opposite side of the isosurface"),
|
||||
NumSideDisagree, 0);
|
||||
|
||||
if (NumInWindow == 0)
|
||||
{
|
||||
AddWarning(TEXT("No sample landed inside the overhang window, so the op was never ")
|
||||
TEXT("actually exercised. Raise OverhangStrength or lower ")
|
||||
TEXT("OverhangSlopeThreshold until this is well above zero."));
|
||||
}
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// LE COMBINER `Mask` — mélange de biomes (§5 : le prototype de la Phase 3)
|
||||
//=========================================================================
|
||||
// Testé contre un champ de biomes SYNTHÉTIQUE plutôt que contre le résolveur Voronoï réel, et
|
||||
// c'est le bon choix ici : le vrai résolveur est déjà couvert par ses propres tests, alors
|
||||
// qu'un champ synthétique permet de balayer le poids de 0 à 1 de façon CONTINUE et de vérifier
|
||||
// l'identité `blend(w) == lerp(A, B, w)` sur toute la plage — y compris les deux bouts, où une
|
||||
// erreur d'inversion (`1-w` au lieu de `w`) se cache le mieux.
|
||||
//
|
||||
// Tested against a SYNTHETIC field rather than the real Voronoi resolver: the resolver has its
|
||||
// own tests, while a synthetic field lets the weight be swept continuously from 0 to 1, which is
|
||||
// where an inverted lerp hides.
|
||||
{
|
||||
// Deux jeux de params franchement différents : si le mélange était un no-op, ou prenait le
|
||||
// mauvais côté, l'écart serait énorme plutôt que subtil.
|
||||
FSurfaceGenerationParams A = Defaults;
|
||||
FSurfaceGenerationParams B = Defaults;
|
||||
A.ElevationRange = 40.0f; A.MountainStrength = 0.2f;
|
||||
B.ElevationRange = 12.0f; B.MountainStrength = 0.9f;
|
||||
B.BaseGroundRelative = FMath::Clamp(A.BaseGroundRelative + 0.15f, 0.0f, 1.0f);
|
||||
|
||||
TArray<FSurfaceGenerationParams> PerBiome;
|
||||
PerBiome.Add(A);
|
||||
PerBiome.Add(B);
|
||||
|
||||
/** Champ synthétique : biome 0 dominant, biome 1 voisin, poids imposé par le test. */
|
||||
class FFixedWeightField final : public IVoxelBiomeField
|
||||
{
|
||||
public:
|
||||
float W = 0.0f;
|
||||
FVoxelBiomeWeights SampleAt(float, float) const override
|
||||
{
|
||||
FVoxelBiomeWeights Out;
|
||||
Out.Dominant = 0; Out.Neighbor = 1; Out.NeighborWeight = W;
|
||||
return Out;
|
||||
}
|
||||
};
|
||||
FFixedWeightField FieldA;
|
||||
|
||||
// Les deux piles de référence, non mélangées.
|
||||
FVoxelHeightStack StackA, StackB;
|
||||
VoxelHeightOps::BuildSurfaceHeightStack(StackA, A, World.Settings->Seed);
|
||||
VoxelHeightOps::BuildSurfaceHeightStack(StackB, B, World.Settings->Seed);
|
||||
|
||||
FVoxelHeightStack Blended;
|
||||
Blended.Add(VoxelHeightOps::MakeBiomeBlendHeightSource(PerBiome, World.Settings->Seed, &FieldA));
|
||||
|
||||
const float Weights[] = { 0.0f, 0.25f, 0.5f, 0.75f, 1.0f };
|
||||
int32 NumWrong = 0;
|
||||
float WorstDelta = 0.0f;
|
||||
|
||||
for (const float W : Weights)
|
||||
{
|
||||
FieldA.W = W;
|
||||
for (int32 i = 0; i < 400; ++i)
|
||||
{
|
||||
const float X = (float)((i % 20) * 11);
|
||||
const float Y = (float)((i / 20) * 13);
|
||||
|
||||
const float HA = StackA.EvalHeight(X, Y);
|
||||
const float HB = StackB.EvalHeight(X, Y);
|
||||
// ⚠️ L'attendu doit reproduire la MÊME expression que l'op, `FMath::Lerp` compris :
|
||||
// écrire `HA + (HB - HA) * W` à la place testerait l'algèbre, pas le code.
|
||||
const float Expect = (W > 0.0f) ? FMath::Lerp(HA, HB, W) : HA;
|
||||
const float Got = Blended.EvalHeight(X, Y);
|
||||
|
||||
if (!BitEqual(Expect, Got))
|
||||
{
|
||||
++NumWrong;
|
||||
WorstDelta = FMath::Max(WorstDelta, FMath::Abs(Expect - Got));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TestEqual(TEXT("biome blend: heights lerp between the two biomes' full stacks, bit-exactly"),
|
||||
NumWrong, 0);
|
||||
|
||||
// ⚠️ Rapporter le SUCCÈS, pas seulement l'échec. Un `TestEqual` qui passe n'écrit rien, et
|
||||
// une vérification silencieuse est indiscernable d'une vérification qui n'a jamais tourné —
|
||||
// exactement le piège signalé pour `WaterLevelRelative` et la fenêtre d'overhang, dans
|
||||
// lequel ce bloc-ci était tombé au premier jet. Le compte rend l'exécution visible.
|
||||
// Report success, not just failure: a silent pass is indistinguishable from a check that
|
||||
// never ran.
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("Biome blend: %d (weight, point) pairs across weights 0/0.25/0.5/0.75/1.0 all match ")
|
||||
TEXT("Lerp of the two biomes' full height stacks bit-exactly. Weight 0 returns the ")
|
||||
TEXT("dominant untouched and weight 1 the neighbour, so the lerp is not inverted."),
|
||||
(int32)UE_ARRAY_COUNT(Weights) * 400));
|
||||
|
||||
if (NumWrong > 0)
|
||||
{
|
||||
AddError(FString::Printf(
|
||||
TEXT("Biome blend wrong on %d of 2000 (weight, point) pairs, worst |delta| %.6g. ")
|
||||
TEXT("Check: is the lerp toward the NEIGHBOUR (weight 0 must give the dominant ")
|
||||
TEXT("untouched, weight 1 the neighbour), and does each biome's stack compute its ")
|
||||
TEXT("OWN relief for its OWN terrace gate rather than sharing the dominant's?"),
|
||||
NumWrong, WorstDelta));
|
||||
}
|
||||
|
||||
// Le plafond SÉLECTIONNE au lieu de mélanger — comportement d'origine, reproduit tel quel.
|
||||
{
|
||||
FieldA.W = 1.0f; // le voisin l'emporterait si le plafond mélangeait
|
||||
FVoxelHeightStack CeilSel;
|
||||
CeilSel.Add(VoxelHeightOps::MakeBiomeSelectCeilingSource(PerBiome, World.Settings->Seed, &FieldA));
|
||||
|
||||
FVoxelHeightStack CeilDominant;
|
||||
VoxelHeightOps::BuildSurfaceCeilingStack(CeilDominant, A, World.Settings->Seed);
|
||||
|
||||
int32 NumCeilWrong = 0;
|
||||
for (int32 i = 0; i < 200; ++i)
|
||||
{
|
||||
const float X = (float)((i % 20) * 11), Y = (float)((i / 20) * 13);
|
||||
if (!BitEqual(CeilSel.EvalHeight(X, Y), CeilDominant.EvalHeight(X, Y))) { ++NumCeilWrong; }
|
||||
}
|
||||
TestEqual(TEXT("biome ceiling SELECTS the dominant (never blends), even at weight 1"),
|
||||
NumCeilWrong, 0);
|
||||
|
||||
AddInfo(TEXT("Biome ceiling: 200 points at neighbour-weight 1.0 still return the ")
|
||||
TEXT("DOMINANT biome's sky cap, i.e. it selects rather than blends -- the "
|
||||
"original's behaviour, and the case a \"blend everything\" refactor would "
|
||||
"silently break."));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||
@@ -0,0 +1,142 @@
|
||||
// VoxelForgeLargeSeedTest.cpp
|
||||
// AUDIT §C1 — le monde doit rester un monde quand la seed est grande.
|
||||
// AUDIT C1 — the world must still be a world at a large seed.
|
||||
//
|
||||
// LE BUG / THE BUG
|
||||
// Les sites de bruit s'écrivaient `WorldX * Freq + (float)Seed * 97.7f`. Un float a 24 bits de
|
||||
// mantisse, donc à magnitude `V` l'ULP vaut `V · 2⁻²³` :
|
||||
//
|
||||
// Seed = 1 000 → terme 9.8e4 → ULP 0.012 → correct
|
||||
// Seed = 100 000 → terme 9.8e6 → ULP 1.2 → le bruit se cale sur un treillis
|
||||
// Seed = 10 000 000 → terme 9.8e8 → ULP 117 → la coordonnée du voxel (~0.02/voxel) est
|
||||
// ENTIÈREMENT absorbée ⇒ champ CONSTANT
|
||||
//
|
||||
// `ChangeSeed(int32)` est `BlueprintCallable` : un `FMath::Rand()` (jusqu'à 2³¹) suffit à produire
|
||||
// un monde plat. Ça n'a jamais été vu parce que les seeds de test restaient petites — et la fixture
|
||||
// des autres tests garde délibérément une petite seed, ce qui veut dire qu'**aucun autre test de ce
|
||||
// dossier ne peut voir ce bug**.
|
||||
//
|
||||
// ⚠️ POURQUOI LES TESTS D'ÉQUIVALENCE NE L'AURAIENT JAMAIS ATTRAPÉ
|
||||
// Ils comparent la pile d'opérateurs au `switch` d'archétype. Les deux lisent la MÊME expression
|
||||
// fautive, donc les deux s'effondrent EXACTEMENT DE LA MÊME FAÇON à grande seed : bit-identiques,
|
||||
// verts, et tous les deux plats. Un oracle qui partage le bug de l'implémentation ne le voit pas.
|
||||
// **Ce test-ci ne compare rien à rien : il vérifie une PROPRIÉTÉ** — le terrain doit varier.
|
||||
//
|
||||
// The equivalence tests compare the op stack to the archetype switch. Both read the same faulty
|
||||
// expression, so at a large seed both collapse identically: bit-identical, green, and both flat. An
|
||||
// oracle that shares the implementation's bug cannot see it. This test asserts a PROPERTY instead.
|
||||
//
|
||||
// LE CORRECTIF, ET POURQUOI L'ÉVIDENT ÉTAIT FAUX
|
||||
// Borner `SeedF` en gardant le `· 97.7` laisse le terme atteindre 1.6e6 (ULP 0.19 = 9.5× le pas par
|
||||
// voxel) : moins spectaculaire, toujours cassé, ticket refermé. C'est le MULTIPLICATEUR qu'il faut
|
||||
// supprimer. `VoxelHash::SeedOffset(Seed, SiteKey)` rend un décalage déjà dans les unités finales,
|
||||
// borné à [0, 16383], salé par site — donc deux seeds doivent collisionner sur les ~50 sites à la
|
||||
// fois pour donner le même monde, au lieu d'un seul bucket partagé.
|
||||
|
||||
#if WITH_DEV_AUTOMATION_TESTS
|
||||
|
||||
#include "Misc/AutomationTest.h"
|
||||
|
||||
#include "VoxelForgeTestFixture.h"
|
||||
#include "VoxelGenerator.h"
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||
FVoxelForgeLargeSeedTest,
|
||||
"VoxelForge.Determinism.LargeSeedSurvives",
|
||||
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||
|
||||
namespace
|
||||
{
|
||||
/** Les seeds à éprouver. La première est le régime « ça marchait par chance », les suivantes
|
||||
* sont là où le champ s'effondrait. La dernière est ce qu'un `FMath::Rand()` produit. */
|
||||
const int32 SeedsUnderTest[] = { 1337, 100000, 10000000, 2000000000 };
|
||||
|
||||
/** Combien de hauteurs distinctes faut-il pour dire « ce n'est pas plat » ? Un champ effondré
|
||||
* rend UNE valeur (ou deux ou trois par effet de bord d'arrondi). Un terrain sain en rend des
|
||||
* centaines sur 400 échantillons. Le seuil est bas exprès : on teste « le bruit existe-t-il
|
||||
* encore », pas « est-il joli ». */
|
||||
constexpr int32 MinDistinctHeights = 50;
|
||||
}
|
||||
|
||||
bool FVoxelForgeLargeSeedTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
using namespace VoxelForgeTest;
|
||||
|
||||
bool bAnyCollapse = false;
|
||||
|
||||
for (const int32 Seed : SeedsUnderTest)
|
||||
{
|
||||
FTestWorld World;
|
||||
World.Build(Seed);
|
||||
if (!World.IsValid())
|
||||
{
|
||||
AddError(FString::Printf(TEXT("Seed %d: %s"), Seed, *World.WhyInvalid()));
|
||||
continue;
|
||||
}
|
||||
|
||||
int32 TopVoxelZ = 0, BottomVoxelZ = 0;
|
||||
if (!World.GetSlotVoxelZRange(FTestWorld::SlotSurfaceWorld, TopVoxelZ, BottomVoxelZ))
|
||||
{
|
||||
AddError(TEXT("The fixture layout has no SurfaceWorld slot."));
|
||||
return false;
|
||||
}
|
||||
|
||||
const UVoxelStrateDefinition* Def =
|
||||
World.StrateManager->GetStrateForChunk(
|
||||
FIntVector(0, 0, ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE));
|
||||
if (!Def) { AddError(TEXT("No SurfaceWorld definition.")); return false; }
|
||||
|
||||
FSurfaceGenerationParams P = Def->SurfaceParams;
|
||||
P.StrateTopWorldZ = (float)TopVoxelZ;
|
||||
P.StrateBottomWorldZ = (float)BottomVoxelZ;
|
||||
|
||||
// Échantillonner le HEIGHTFIELD plutôt que la densité : c'est là que le bruit vit, et une
|
||||
// hauteur est directement lisible ("le terrain est-il plat ?") là où une densité demande
|
||||
// d'être interprétée.
|
||||
TSet<uint32> DistinctBits;
|
||||
float MinH = FLT_MAX, MaxH = -FLT_MAX;
|
||||
|
||||
const UVoxelGenerator* Gen = World.Generator.Get();
|
||||
for (int32 iy = 0; iy < 20; ++iy)
|
||||
for (int32 ix = 0; ix < 20; ++ix)
|
||||
{
|
||||
// Pas de 7 voxels : assez large pour traverser plusieurs cellules de bruit, assez
|
||||
// petit pour rester dans une région cohérente.
|
||||
const float X = (float)(ix * 7);
|
||||
const float Y = (float)(iy * 7);
|
||||
const float H = Gen->ComputeSurfaceTerrainZ(X, Y, P);
|
||||
|
||||
DistinctBits.Add(*reinterpret_cast<const uint32*>(&H));
|
||||
MinH = FMath::Min(MinH, H);
|
||||
MaxH = FMath::Max(MaxH, H);
|
||||
}
|
||||
|
||||
const int32 NumDistinct = DistinctBits.Num();
|
||||
const float Range = MaxH - MinH;
|
||||
|
||||
if (NumDistinct < MinDistinctHeights)
|
||||
{
|
||||
bAnyCollapse = true;
|
||||
AddError(FString::Printf(
|
||||
TEXT("SEED %d COLLAPSED THE NOISE FIELD: only %d distinct heights across 400 ")
|
||||
TEXT("samples (range %.4f voxels). This is AUDIT C1 — a seed offset large enough ")
|
||||
TEXT("that the float ULP swallows the voxel coordinate, so the noise input is ")
|
||||
TEXT("constant across many voxels and the terrain goes flat. Check that every noise ")
|
||||
TEXT("site uses VoxelHash::SeedOffset(SeedU, K) and that no `SeedF * K` pattern has ")
|
||||
TEXT("come back."),
|
||||
Seed, NumDistinct, Range));
|
||||
}
|
||||
else
|
||||
{
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("Seed %d: %d distinct heights across 400 samples, range %.2f voxels. Field alive."),
|
||||
Seed, NumDistinct, Range));
|
||||
}
|
||||
}
|
||||
|
||||
TestFalse(TEXT("no seed collapses the noise field (AUDIT C1)"), bAnyCollapse);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||
@@ -0,0 +1,311 @@
|
||||
// VoxelForgeOpStackIslandTest.cpp
|
||||
// FloatingIslands — le portage qui fait tourner la pile À L'ENVERS.
|
||||
// FloatingIslands — the port that runs the stack BACKWARDS.
|
||||
//
|
||||
// CE QUE CELUI-CI PROUVE EN PLUS DES AUTRES
|
||||
// `VerticalShaftEquivalence` a mesuré la réutilisation À L'IDENTIQUE : trois opérateurs de Maze
|
||||
// repris sans une ligne de changement. Celui-ci mesure quelque chose de plus fort, et de plus
|
||||
// risqué pour l'abstraction : **la réutilisation PAR INVERSION**.
|
||||
//
|
||||
// Les quatre archétypes déjà portés partent tous de ROC et CREUSENT. FloatingIslands part du VIDE
|
||||
// et REMPLIT. Si l'axe abstrait choisi (le SIGNE de la densité, convention interne positif = solide)
|
||||
// est le bon, alors les deux extrémités de la pile doivent être les MÊMES opérateurs au signe près :
|
||||
//
|
||||
// FConstantFieldSource(+Base) ←→ FConstantFieldSource(-Base)
|
||||
// FSdfConvertOp(Sign = -1) ←→ FSdfConvertOp(Sign = +1)
|
||||
//
|
||||
// Et c'est le cas : le seul opérateur neuf de ce portage est le blob d'île. Un archétype qui se
|
||||
// réutilise en s'INVERSANT est une preuve plus forte qu'un archétype qui se réutilise à l'identique
|
||||
// — le premier dit que l'abstraction a trouvé le bon axe, le second seulement que deux archétypes
|
||||
// se ressemblaient.
|
||||
//
|
||||
// ET LE VERDICT DE BOÎTE : c'est ici que `ClassifyBox` peut rendre **AllAir** pour la première fois
|
||||
// de tout le plugin. Une strate d'îles flottantes est, par construction, surtout vide ; aucun
|
||||
// archétype de grotte n'a jamais su prouver « tout air » (`OPSTACK-DECOMPOSITION §7`). Le test
|
||||
// compte les deux verdicts SÉPARÉMENT, parce qu'un total agrégé masquerait exactement ce gain-là.
|
||||
//
|
||||
// LA BARRE : bit à bit, comme les autres depuis `FPSemantics = Precise` (AUDIT §C9/§C10).
|
||||
|
||||
#if WITH_DEV_AUTOMATION_TESTS
|
||||
|
||||
#include "Misc/AutomationTest.h"
|
||||
#include "Async/ParallelFor.h"
|
||||
#include "HAL/PlatformMisc.h"
|
||||
|
||||
#include "VoxelForgeTestFixture.h"
|
||||
#include "VoxelDensityOpStack.h"
|
||||
|
||||
#include <atomic>
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||
FVoxelForgeOpStackIslandTest,
|
||||
"VoxelForge.OpStack.FloatingIslandEquivalence",
|
||||
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr int32 NumIslandSamples = 20000;
|
||||
|
||||
/**
|
||||
* Les défauts génèrent bien des îles, mais un test qui les prend tels quels laisse la question
|
||||
* « les échantillons sont-ils VRAIMENT tombés dedans ? » à la chance du seed. On force donc une
|
||||
* densité d'îles haute, et surtout un `TopFlatten < 1` — la branche du dôme de bord est le seul
|
||||
* endroit où `TopHalf` et `Edge²` interviennent, et elle est silencieusement morte à 1.0.
|
||||
* (Même piège que `WaterLevelRelative` et la fenêtre d'overhang : un paramètre au repos est un
|
||||
* opérateur non testé.)
|
||||
*/
|
||||
void EnableIslandFeatures(FFloatingIslandParams& P)
|
||||
{
|
||||
P.IslandDensity = 0.75f; // des îles dans presque chaque cellule du 3×3
|
||||
P.TopFlatten = 0.55f; // < 1 ⇒ la branche du dôme de bord s'exécute
|
||||
P.SurfaceRoughness = 4.0f; // la rugosité SDF partagée avec Maze et VerticalShafts
|
||||
P.VerticalJitter = 0.6f; // des îles à des hauteurs différentes
|
||||
P.ThicknessRatio = 0.7f;
|
||||
}
|
||||
}
|
||||
|
||||
bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
using namespace VoxelForgeTest;
|
||||
|
||||
FTestWorld World;
|
||||
World.Build();
|
||||
if (!World.IsValid())
|
||||
{
|
||||
AddError(World.WhyInvalid());
|
||||
return false;
|
||||
}
|
||||
|
||||
const UVoxelGenerator* Gen = World.Generator.Get();
|
||||
|
||||
int32 TopVoxelZ = 0, BottomVoxelZ = 0;
|
||||
if (!World.GetSlotVoxelZRange(FTestWorld::SlotFloatingIsland, TopVoxelZ, BottomVoxelZ))
|
||||
{
|
||||
AddError(TEXT("The fixture layout has no FloatingIslands slot. Check FTestWorld::Build's ")
|
||||
TEXT("Archetypes[] against FTestWorld::SlotFloatingIsland."));
|
||||
return false;
|
||||
}
|
||||
|
||||
const int32 MidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE;
|
||||
FFloatingIslandParams P = World.StrateManager->GetFloatingIslandParamsForChunk(
|
||||
FIntVector(0, 0, MidChunkZ));
|
||||
|
||||
if (P.StrateTopWorldZ - P.StrateBottomWorldZ <= 0.0f)
|
||||
{
|
||||
AddError(TEXT("The FloatingIslands strate has degenerate Z bounds, which sends ")
|
||||
TEXT("GetFloatingIslandDensity down its early-out. The op stack has none by design."));
|
||||
return false;
|
||||
}
|
||||
|
||||
EnableIslandFeatures(P);
|
||||
|
||||
FVoxelOpStack Stack;
|
||||
VoxelDensityOps::BuildFloatingIslandStack(Stack, P, World.Settings->Seed,
|
||||
Gen->OriginSpineRadius, World.StrateManager.Get());
|
||||
|
||||
// void + blobs + roughness + fill + 3 structurels.
|
||||
TestEqual(TEXT("the island stack is decomposed into 7 ops"), Stack.Num(), 7);
|
||||
|
||||
FVoxelOpContext Ctx;
|
||||
Ctx.Seed = (uint32)World.Settings->Seed;
|
||||
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
|
||||
Ctx.StrateTopWorldZ = P.StrateTopWorldZ;
|
||||
Ctx.StrateBottomWorldZ = P.StrateBottomWorldZ;
|
||||
Stack.PrepareChunk(Ctx);
|
||||
|
||||
TArray<FVector> Points;
|
||||
Points.Reserve(NumIslandSamples);
|
||||
{
|
||||
FRandomStream Rng(60186);
|
||||
for (int32 i = 0; i < NumIslandSamples; ++i)
|
||||
{
|
||||
Points.Add(FVector(
|
||||
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
|
||||
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
|
||||
(float)Rng.RandRange(BottomVoxelZ, TopVoxelZ)));
|
||||
}
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// 1. ÉQUIVALENCE
|
||||
//=========================================================================
|
||||
// On compte SÉPARÉMENT le solide d'intérieur et le solide de seal : sur cet archétype la
|
||||
// quasi-totalité du volume est de l'air, donc un « N solides » agrégé serait dominé par les
|
||||
// deux bandes de seal et ne dirait RIEN sur les îles elles-mêmes.
|
||||
const float InnerBot = P.StrateBottomWorldZ + P.BoundarySealThickness;
|
||||
const float InnerTop = P.StrateTopWorldZ - P.BoundarySealThickness;
|
||||
|
||||
int32 NumDiff = 0, NumSideDisagree = 0, WorstIdx = -1;
|
||||
int32 NumInsideIsland = 0, NumOpenVoid = 0;
|
||||
float WorstDelta = 0.0f;
|
||||
|
||||
for (int32 i = 0; i < NumIslandSamples; ++i)
|
||||
{
|
||||
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
|
||||
|
||||
const float Old = Gen->GetFloatingIslandDensity(X, Y, Z, P);
|
||||
const float New = Stack.EvalMC(X, Y, Z);
|
||||
|
||||
const bool bInterior = (Z > InnerBot && Z < InnerTop);
|
||||
if (bInterior && Old < 0.0f) { ++NumInsideIsland; } // solide loin des seals ⇒ une île
|
||||
if (bInterior && Old >= 0.0f) { ++NumOpenVoid; }
|
||||
|
||||
if (!BitEqual(Old, New))
|
||||
{
|
||||
++NumDiff;
|
||||
const float D = FMath::Abs(Old - New);
|
||||
if (D > WorstDelta) { WorstDelta = D; WorstIdx = i; }
|
||||
}
|
||||
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSideDisagree; }
|
||||
}
|
||||
|
||||
if (NumDiff == 0)
|
||||
{
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("FloatingIslands: bit-identical across %d samples (%d inside island rock away from ")
|
||||
TEXT("the seal bands, %d in open void, so the void source, the blobs, the roughness and ")
|
||||
TEXT("the fill were all exercised). The stack runs BACKWARDS -- void source + fill ")
|
||||
TEXT("instead of rock source + carve -- using the SAME operators with the opposite ")
|
||||
TEXT("sign. Only the blob source is new (OPSTACK-PLAN 2.5)."),
|
||||
NumIslandSamples, NumInsideIsland, NumOpenVoid));
|
||||
}
|
||||
else
|
||||
{
|
||||
AddError(FString::Printf(
|
||||
TEXT("FloatingIslands: %d of %d samples differ (largest |delta| %.9g at (%.0f, %.0f, ")
|
||||
TEXT("%.0f)); %d cross the isosurface. Since /fp:precise the bar is bit-identity, so ")
|
||||
TEXT("this is a real port error. Check, in order: the C1 warp fix (BOTH paths must now ")
|
||||
TEXT("use VoxelHash::SeedOffset(S, 0.0007f) -- if only one was changed, EVERY warped ")
|
||||
TEXT("sample differs), then the SdfConvert SIGN (+1 fills, -1 carves), then the 'Isld' ")
|
||||
TEXT("salt (0x49736C64), the roughness frequency (0.08 / 4 octaves here, NOT Maze's ")
|
||||
TEXT("0.12 / 3), the per-island TaperEnd and TopFlatten dome branch, and the ")
|
||||
TEXT("SmoothMin blend K = max(SDFBlendRadius, 0.01)."),
|
||||
NumDiff, NumIslandSamples, WorstDelta,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f,
|
||||
NumSideDisagree));
|
||||
}
|
||||
|
||||
TestEqual(TEXT("no sample lands on the opposite side of the isosurface"), NumSideDisagree, 0);
|
||||
|
||||
if (NumInsideIsland == 0)
|
||||
{
|
||||
AddWarning(TEXT("No sample landed inside island rock away from the seal bands, so the blob ")
|
||||
TEXT("source and the fill were never meaningfully exercised -- the equivalence ")
|
||||
TEXT("above then only proves that two empty voids agree. Raise IslandDensity or ")
|
||||
TEXT("IslandMaxRadius."));
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// 2. INVARIANCE DE FENÊTRE
|
||||
//=========================================================================
|
||||
// La source garde un cache 3×3 `thread_local` dont la clé est le jeu de params — et cette clé
|
||||
// inclut délibérément `BoundarySealThickness`, que l'original omet alors que `SpreadZ` le lit
|
||||
// (voir la note dans FIslandBlobSource::GetCells).
|
||||
{
|
||||
std::atomic<int32> Impure{ 0 };
|
||||
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
|
||||
|
||||
TArray<float> Ref;
|
||||
Ref.SetNumUninitialized(NumIslandSamples);
|
||||
for (int32 i = 0; i < NumIslandSamples; ++i)
|
||||
{
|
||||
Ref[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
|
||||
}
|
||||
|
||||
ParallelFor(NumBlocks, [&](int32 Block)
|
||||
{
|
||||
TArray<int32> LocalOrder;
|
||||
BuildShuffledOrder(NumIslandSamples, 3300 + Block, LocalOrder);
|
||||
for (const int32 i : LocalOrder)
|
||||
{
|
||||
const float V = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
|
||||
if (!BitEqual(V, Ref[i])) { Impure.fetch_add(1, std::memory_order_relaxed); }
|
||||
}
|
||||
});
|
||||
|
||||
TestEqual(TEXT("the island stack is window-invariant across order and threads"),
|
||||
Impure.load(), 0);
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// 3. LE VERDICT DE BOÎTE — et la première preuve « AllAir » du plugin
|
||||
//=========================================================================
|
||||
{
|
||||
int32 NumProvedSolid = 0, NumProvedAir = 0, NumMixed = 0, NumUnsound = 0;
|
||||
FRandomStream Rng(24680);
|
||||
|
||||
for (int32 t = 0; t < 60; ++t)
|
||||
{
|
||||
const int32 Step = 1, Cells = 8;
|
||||
const int32 Extent = Step * Cells;
|
||||
const FIntVector Origin(
|
||||
Rng.RandRange(-6, 6) * Extent,
|
||||
Rng.RandRange(-6, 6) * Extent,
|
||||
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
|
||||
|
||||
const int32 GridDim = Cells + 1;
|
||||
const FBox Box(
|
||||
FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step),
|
||||
FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step));
|
||||
|
||||
const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Ctx);
|
||||
if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; }
|
||||
|
||||
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
|
||||
if (bClaimsSolid) { ++NumProvedSolid; } else { ++NumProvedAir; }
|
||||
|
||||
for (int32 gz = -1; gz <= GridDim; ++gz)
|
||||
for (int32 gy = -1; gy <= GridDim; ++gy)
|
||||
for (int32 gx = -1; gx <= GridDim; ++gx)
|
||||
{
|
||||
const float X = (float)(Origin.X + gx * Step);
|
||||
const float Y = (float)(Origin.Y + gy * Step);
|
||||
const float Z = (float)(Origin.Z + gz * Step);
|
||||
const float D = Stack.EvalMC(X, Y, Z);
|
||||
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
|
||||
{
|
||||
if (NumUnsound == 0)
|
||||
{
|
||||
AddError(FString::Printf(
|
||||
TEXT("HOLE: the island stack claimed %s for the box at (%d,%d,%d) but ")
|
||||
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. Suspects, in ")
|
||||
TEXT("order: the blob source's Pad (does it cover the WARP amplitude ")
|
||||
TEXT("AND the roughness AND the fill blend AND the SmoothMin dip?), ")
|
||||
TEXT("then the Z bound -- note there is NO lower bound, a thin thread ")
|
||||
TEXT("of matter hangs below each island down the axis, so only the ")
|
||||
TEXT("TOP may be used to reject."),
|
||||
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
|
||||
Origin.X, Origin.Y, Origin.Z, X, Y, Z, D,
|
||||
(D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
|
||||
}
|
||||
++NumUnsound;
|
||||
gz = gy = gx = GridDim + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TestEqual(TEXT("every box verdict the island stack emits survives brute force"), NumUnsound, 0);
|
||||
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("Box verdicts over 60 FloatingIslands tiles: %d proved AllSolid, %d proved AllAir, ")
|
||||
TEXT("%d Mixed. Today's ClassifyTile proves ZERO of these. The AllAir count is the new ")
|
||||
TEXT("thing: no cave archetype has ever been able to prove 'all air', and a floating-")
|
||||
TEXT("island strate is mostly exactly that (OPSTACK-DECOMPOSITION 7)."),
|
||||
NumProvedSolid, NumProvedAir, NumMixed));
|
||||
|
||||
if (NumProvedAir == 0)
|
||||
{
|
||||
AddWarning(TEXT("Zero tiles proved AllAir. The stack is still SOUND, but the whole perf ")
|
||||
TEXT("argument for this archetype rests on that verdict, so it is worth ")
|
||||
TEXT("knowing it did not fire. Most likely the blob source's Pad is so wide ")
|
||||
TEXT("that every box finds an island within reach -- the same pessimism ")
|
||||
TEXT("VerticalShafts has (0 of 60), for the same reason."));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||
@@ -0,0 +1,338 @@
|
||||
// VoxelForgeOpStackMazeTest.cpp
|
||||
// PHASE 1, LE TEST QUI COMPTE — la pile d'opérateurs Maze contre GetMazeDensity.
|
||||
// PHASE 1'S LOAD-BEARING TEST — the Maze operator stack against GetMazeDensity.
|
||||
//
|
||||
// CE QUE LA PHASE 1 DEVAIT PROUVER / WHAT PHASE 1 HAD TO PROVE
|
||||
// Le déclencheur d'arrêt de `OPSTACK-PLAN §4` : **« est-ce que la séparation source / modifier tombe
|
||||
// naturellement du code existant ? »** Réponse mesurée : oui. Maze se décompose en sept opérateurs
|
||||
// sans contorsion, le SDF est reproduit BIT POUR BIT, et aucun échantillon ne change de côté de
|
||||
// l'isosurface.
|
||||
//
|
||||
// ═════════════════════════════════════════════════════════════════════════════════════════
|
||||
// ✅ MISE À JOUR 2026-07-27 : LE PLANCHER ULP N'EXISTE PLUS. C'ÉTAIT `/fp:fast`.
|
||||
// ═════════════════════════════════════════════════════════════════════════════════════════
|
||||
// `FPSemantics = Precise` sur le module (AUDIT §C9, posé pour le cross-play Linux/Windows) fait
|
||||
// passer ce test à **BIT-IDENTIQUE**. La section ci-dessous décrit un état RÉVOLU ; elle est gardée
|
||||
// parce qu'elle explique pourquoi les cinq expériences d'isolation avaient toutes échoué (sous
|
||||
// `/fp:fast` le compilateur transforme selon le CONTEXTE — il n'y avait aucune variable à isoler)
|
||||
// et parce qu'elle dit quoi regarder si la bit-identité régresse un jour.
|
||||
//
|
||||
// **Conséquence pratique : ce test est maintenant un instrument BEAUCOUP plus fin.** Le moindre
|
||||
// écart est désormais une vraie trouvaille, pas du bruit à noter. La machinerie de gradation ULP
|
||||
// est conservée exprès — c'est elle qui signalerait une régression du modèle flottant.
|
||||
//
|
||||
// UPDATE: the ULP floor is GONE — FPSemantics = Precise makes this test bit-identical. The section
|
||||
// below describes a past state, kept because it explains why five isolation experiments all failed
|
||||
// (under /fp:fast the compiler transforms by CONTEXT — there was no variable to isolate) and what to
|
||||
// look at if bit-identity ever regresses.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// ⚠️ LE PLANCHER ULP (HISTORIQUE) — lire ceci avant de « corriger » un écart résiduel
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// La pile reproduit `GetMazeDensity` à ~1-2 ULP près sur ~2 % des échantillons (ceux qui tombent
|
||||
// dans la coquille de blend du SDF, où `Blend - Sdf` annule catastrophiquement et amplifie le
|
||||
// dernier arrondi). **Zéro échantillon ne traverse l'isosurface**, donc pas un triangle ne bouge.
|
||||
//
|
||||
// L'origine exacte de ce dernier arrondi n'a PAS été identifiée, après six cycles de build et cinq
|
||||
// hypothèses toutes réfutées par la mesure (aller-retour FVector · fenêtre de rugosité · `/fp:fast`
|
||||
// entre unités de compilation · contexte d'inlining · constante de compilation vs donnée
|
||||
// d'exécution). Ce qui EST établi par la mesure :
|
||||
//
|
||||
// • le SDF est bit-identique sur 126/126 des écarts — le treillis, les hashs, l'ensemble d'arêtes
|
||||
// et `VoxelSDF::Capsule` sont donc exacts ;
|
||||
// • l'écart naît entièrement dans la conversion SDF→densité, au dernier arrondi ;
|
||||
// • il est DÉTERMINISTE (mêmes échantillons, même delta, même coordonnée à chaque run) ;
|
||||
// • il ne dépend ni de l'unité de compilation, ni de l'inlining, ni du modèle flottant.
|
||||
//
|
||||
// **Décision (Jahni, 2026-07-27) : on l'accepte et on avance.** Aucune décision du projet ne dépend
|
||||
// de la réponse, et la chasse coûtait plus que l'information. Consigné comme point ouvert dans
|
||||
// `AUDIT-2026-07.md §C10`.
|
||||
//
|
||||
// ⚠️ LA RÈGLE QUI EN DÉCOULE, ELLE, EST IMPORTANTE :
|
||||
// **ne jamais faire tourner les deux chemins (switch d'archétype et pile d'opérateurs) dans le même
|
||||
// monde, et ne jamais comparer leurs sorties pour égalité.** Ce n'est PAS un risque de désync entre
|
||||
// clients — dans un même binaire le champ est prouvé pur (`VoxelForge.Determinism.DensityPurity`,
|
||||
// bit-identique entre threads et ordres de requête) et tous les pairs exécutent le même chemin. Mais
|
||||
// une strate à moitié migrée produirait une couture. Le vrai sujet multijoueur est ailleurs :
|
||||
// `AUDIT §C9` (le défaut FP d'UBT diffère selon la toolchain).
|
||||
//
|
||||
// Never run both paths in one world and never compare their outputs for equality. This is NOT a
|
||||
// client-desync risk — within one binary the field is proven pure and every peer runs the same path —
|
||||
// but a half-migrated strate would produce a seam.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// LA BARRE D'ACCEPTATION, ENCODÉE CI-DESSOUS / THE ACCEPTANCE BAR, ENCODED BELOW
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// • ÉCHEC DUR : un seul échantillon qui change de côté de l'isosurface (la géométrie bouge).
|
||||
// • INFO : des écarts à l'échelle de l'ULP (le plancher, attendu).
|
||||
// • WARN : un écart plus grand — ÇA, c'est une vraie dérive de portage, et il faut chercher.
|
||||
// Un test qui avertit à chaque portage serait ignoré par le portage qui compte.
|
||||
|
||||
#if WITH_DEV_AUTOMATION_TESTS
|
||||
|
||||
#include "Misc/AutomationTest.h"
|
||||
#include "Async/ParallelFor.h"
|
||||
#include "HAL/PlatformMisc.h"
|
||||
|
||||
#include "VoxelForgeTestFixture.h"
|
||||
#include "VoxelDensityOpStack.h"
|
||||
|
||||
#include <atomic>
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||
FVoxelForgeOpStackMazeTest,
|
||||
"VoxelForge.OpStack.MazeEquivalence",
|
||||
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr int32 NumMazeSamples = 20000;
|
||||
|
||||
/** Les params Maze de la strate Maze de la fixture, bornes Z de runtime comprises. */
|
||||
bool ResolveMazeParams(const VoxelForgeTest::FTestWorld& World, FMazeGenerationParams& Out,
|
||||
int32& OutTopVoxelZ, int32& OutBottomVoxelZ)
|
||||
{
|
||||
using namespace VoxelForgeTest;
|
||||
if (!World.GetSlotVoxelZRange(FTestWorld::SlotMaze, OutTopVoxelZ, OutBottomVoxelZ)) { return false; }
|
||||
const int32 MidChunkZ = ((OutTopVoxelZ + OutBottomVoxelZ) / 2) / CHUNK_SIZE;
|
||||
Out = World.StrateManager->GetMazeParamsForChunk(FIntVector(0, 0, MidChunkZ));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
using namespace VoxelForgeTest;
|
||||
|
||||
FTestWorld World;
|
||||
World.Build();
|
||||
if (!World.IsValid())
|
||||
{
|
||||
AddError(World.WhyInvalid());
|
||||
return false;
|
||||
}
|
||||
|
||||
FMazeGenerationParams MazeParams;
|
||||
int32 TopVoxelZ = 0, BottomVoxelZ = 0;
|
||||
if (!ResolveMazeParams(World, MazeParams, TopVoxelZ, BottomVoxelZ))
|
||||
{
|
||||
AddError(TEXT("The fixture layout has no Maze slot. Check FTestWorld::Build's Archetypes[] ")
|
||||
TEXT("against FTestWorld::SlotMaze."));
|
||||
return false;
|
||||
}
|
||||
|
||||
// GetMazeDensity court-circuite sur une strate dégénérée (`return 1.0f`). Cette garde appartient
|
||||
// à la fonction d'archétype, pas à un opérateur ; la pile suppose une strate valide.
|
||||
if (MazeParams.StrateTopWorldZ - MazeParams.StrateBottomWorldZ <= 0.0f)
|
||||
{
|
||||
AddError(FString::Printf(
|
||||
TEXT("The Maze strate has degenerate Z bounds (top %.1f, bottom %.1f), which sends ")
|
||||
TEXT("GetMazeDensity down its early-out. The op stack has no such early-out by design."),
|
||||
MazeParams.StrateTopWorldZ, MazeParams.StrateBottomWorldZ));
|
||||
return false;
|
||||
}
|
||||
|
||||
const UVoxelGenerator* Gen = World.Generator.Get();
|
||||
|
||||
FVoxelOpStack Stack;
|
||||
VoxelDensityOps::BuildMazeStack(Stack, MazeParams, World.Settings->Seed,
|
||||
Gen->OriginSpineRadius, World.StrateManager.Get());
|
||||
|
||||
// La décomposition doit être une DÉCOMPOSITION. Un `FMazeOp` monolithique passerait tous les
|
||||
// tests numériques ci-dessous et aurait pourtant raté l'objet entier du refactor (§2.5).
|
||||
TestEqual(TEXT("the Maze stack is decomposed, not wrapped (rock + corridors + roughness + carve + 3 structural)"),
|
||||
Stack.Num(), 7);
|
||||
|
||||
FVoxelOpContext Ctx;
|
||||
Ctx.Seed = (uint32)World.Settings->Seed;
|
||||
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
|
||||
Ctx.StrateTopWorldZ = MazeParams.StrateTopWorldZ;
|
||||
Ctx.StrateBottomWorldZ = MazeParams.StrateBottomWorldZ;
|
||||
Stack.PrepareChunk(Ctx);
|
||||
|
||||
TArray<FVector> Points;
|
||||
Points.Reserve(NumMazeSamples);
|
||||
{
|
||||
FRandomStream Rng(31337);
|
||||
for (int32 i = 0; i < NumMazeSamples; ++i)
|
||||
{
|
||||
Points.Add(FVector(
|
||||
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
|
||||
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
|
||||
(float)Rng.RandRange(BottomVoxelZ, TopVoxelZ)));
|
||||
}
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// ÉQUIVALENCE — géométrie d'abord, bits ensuite.
|
||||
//=========================================================================
|
||||
int32 NumDiff = 0, WorstIdx = -1, NumBeyondUlpNoise = 0, NumSolidDisagreements = 0;
|
||||
float WorstDelta = 0.0f;
|
||||
for (int32 i = 0; i < NumMazeSamples; ++i)
|
||||
{
|
||||
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
|
||||
|
||||
const float Old = Gen->GetMazeDensity(X, Y, Z, MazeParams); // MC : négatif = solide
|
||||
const float New = Stack.EvalMC(X, Y, Z);
|
||||
|
||||
if (!BitEqual(Old, New))
|
||||
{
|
||||
++NumDiff;
|
||||
const float Delta = FMath::Abs(Old - New);
|
||||
if (Delta > WorstDelta) { WorstDelta = Delta; WorstIdx = i; }
|
||||
|
||||
// `Blend - Sdf` annule catastrophiquement au bord de la coquille de blend, donc un
|
||||
// écart d'ULP sur le SDF ressort amplifié sur la densité : marge généreuse, mais bornée.
|
||||
const float UlpNoise = 16.0f * FMath::Max(FMath::Abs(Old), 1.0f) * FLT_EPSILON;
|
||||
if (Delta > UlpNoise) { ++NumBeyondUlpNoise; }
|
||||
}
|
||||
// Le mesher ne lit que le SIGNE (D >= IsoLevel ⇒ air). Un désaccord de CÔTÉ bouge la géométrie.
|
||||
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSolidDisagreements; }
|
||||
}
|
||||
|
||||
if (NumDiff == 0)
|
||||
{
|
||||
AddInfo(FString::Printf(TEXT("Bit-identical across %d samples."), NumMazeSamples));
|
||||
}
|
||||
else if (NumBeyondUlpNoise == 0)
|
||||
{
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("%d of %d samples differ, ALL at ULP scale (largest |delta| %.9g at (%.0f, %.0f, ")
|
||||
TEXT("%.0f)), and 0 cross the isosurface -- not one triangle would move. This is the ")
|
||||
TEXT("accepted floor; see the header comment and AUDIT-2026-07.md C10. The SDF itself is ")
|
||||
TEXT("reproduced BIT FOR BIT, so the lattice, the hashes and VoxelSDF::Capsule are exact; ")
|
||||
TEXT("only the final SDF->density rounding differs. Do not go hunting this again without ")
|
||||
TEXT("reading C10 first -- five hypotheses have already been measured and refuted."),
|
||||
NumDiff, NumMazeSamples, WorstDelta,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f));
|
||||
}
|
||||
else
|
||||
{
|
||||
AddWarning(FString::Printf(
|
||||
TEXT("%d of %d samples differ and %d are TOO LARGE to be the accepted ULP floor (largest ")
|
||||
TEXT("|delta| %.9g at (%.0f, %.0f, %.0f)); %d cross the isosurface. THIS one is real port ")
|
||||
TEXT("drift, not the known floor. Check, in order: the roughness apply-window ")
|
||||
TEXT("(R + SurfaceRoughness + 2), the carve blend (2.0), the noise frequency (0.12) and ")
|
||||
TEXT("octave count (3), and the order of the structural post ops."),
|
||||
NumDiff, NumMazeSamples, NumBeyondUlpNoise, WorstDelta,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f,
|
||||
NumSolidDisagreements));
|
||||
}
|
||||
|
||||
// Le SEUL échec dur : un désaccord de côté d'iso EST une différence de géométrie.
|
||||
TestEqual(TEXT("no sample lands on the opposite side of the isosurface from the original"),
|
||||
NumSolidDisagreements, 0);
|
||||
|
||||
//=========================================================================
|
||||
// INVARIANCE DE FENÊTRE — la pile doit tenir les mêmes règles que le générateur.
|
||||
//=========================================================================
|
||||
// Le cache par cellule de la source de couloirs est `thread_local` : c'est exactement le genre
|
||||
// d'endroit où une clé incomplète produit une couture (cf. AUDIT C2).
|
||||
{
|
||||
std::atomic<int32> Impure{ 0 };
|
||||
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
|
||||
|
||||
TArray<float> Ref;
|
||||
Ref.SetNumUninitialized(NumMazeSamples);
|
||||
for (int32 i = 0; i < NumMazeSamples; ++i)
|
||||
{
|
||||
Ref[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
|
||||
}
|
||||
|
||||
ParallelFor(NumBlocks, [&](int32 Block)
|
||||
{
|
||||
TArray<int32> LocalOrder;
|
||||
BuildShuffledOrder(NumMazeSamples, 500 + Block, LocalOrder);
|
||||
for (const int32 i : LocalOrder)
|
||||
{
|
||||
const float V = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
|
||||
if (!BitEqual(V, Ref[i])) { Impure.fetch_add(1, std::memory_order_relaxed); }
|
||||
}
|
||||
});
|
||||
|
||||
TestEqual(TEXT("the op stack is window-invariant across query order and worker threads"),
|
||||
Impure.load(), 0);
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// LE VERDICT DE BOÎTE — le vrai prix perf : Maze n'a JAMAIS su sauter une tuile.
|
||||
//=========================================================================
|
||||
// ClassifyTile renvoie Mixed pour tout archétype de grotte, donc TunnelNetwork, Maze,
|
||||
// VerticalShafts, FloatingIslands, FlatPlain, CrystalChamber et Underwater ne captent RIEN du
|
||||
// gain T1.d. Tout nombre > 0 ici est du saut de tuile que Maze n'a jamais eu.
|
||||
{
|
||||
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
|
||||
FRandomStream Rng(24680);
|
||||
|
||||
for (int32 t = 0; t < 60; ++t)
|
||||
{
|
||||
const int32 Step = 1, Cells = 8; // petites tuiles : force brute tenable
|
||||
const int32 Extent = Step * Cells;
|
||||
const FIntVector Origin(
|
||||
Rng.RandRange(-6, 6) * Extent,
|
||||
Rng.RandRange(-6, 6) * Extent,
|
||||
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
|
||||
|
||||
const int32 GridDim = Cells + 1; // le MÊME treillis que le mesher, marge ±1 comprise
|
||||
const FBox Box(
|
||||
FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step),
|
||||
FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step));
|
||||
|
||||
const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Ctx);
|
||||
if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; }
|
||||
++NumProved;
|
||||
|
||||
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
|
||||
for (int32 gz = -1; gz <= GridDim; ++gz)
|
||||
for (int32 gy = -1; gy <= GridDim; ++gy)
|
||||
for (int32 gx = -1; gx <= GridDim; ++gx)
|
||||
{
|
||||
const float X = (float)(Origin.X + gx * Step);
|
||||
const float Y = (float)(Origin.Y + gy * Step);
|
||||
const float Z = (float)(Origin.Z + gz * Step);
|
||||
const float D = Stack.EvalMC(X, Y, Z);
|
||||
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
|
||||
{
|
||||
if (NumUnsound == 0)
|
||||
{
|
||||
AddError(FString::Printf(
|
||||
TEXT("HOLE: the op stack claimed %s for the box at (%d,%d,%d) but ")
|
||||
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. One of the ops' ")
|
||||
TEXT("EffectOverBox/ClassifyBox is not conservative. Suspects, in order: ")
|
||||
TEXT("the lattice source's ExtraReach (does it cover the roughness ")
|
||||
TEXT("amplitude AND the carve blend?), then the seal's forcing verdict."),
|
||||
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
|
||||
Origin.X, Origin.Y, Origin.Z, X, Y, Z, D,
|
||||
(D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
|
||||
}
|
||||
++NumUnsound;
|
||||
gz = gy = gx = GridDim + 1; // ce verdict est déjà mort, tuile suivante
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TestEqual(TEXT("every box verdict the stack emits survives brute force (a false verdict is a hole)"),
|
||||
NumUnsound, 0);
|
||||
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("Box verdicts over 60 Maze tiles: %d proved uniform, %d Mixed. Today's ClassifyTile ")
|
||||
TEXT("proves ZERO of these -- every cave archetype falls through to \"pas prouvable en ")
|
||||
TEXT("v1\". Any number above zero here is tile-skipping Maze has never had."),
|
||||
NumProved, NumMixed));
|
||||
|
||||
if (NumProved == 0)
|
||||
{
|
||||
AddWarning(TEXT("The stack proved no tile uniform, so it is not yet better than today's ")
|
||||
TEXT("classifier for Maze. Not a correctness problem, but the perf case for ")
|
||||
TEXT("the port rests on this number."));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||
@@ -0,0 +1,271 @@
|
||||
// VoxelForgeOpStackShaftTest.cpp
|
||||
// VerticalShafts — le portage qui teste la RÉUTILISATION, pas seulement la fidélité.
|
||||
// VerticalShafts — the port that tests REUSE, not just fidelity.
|
||||
//
|
||||
// CE QUE CELUI-CI PROUVE EN PLUS DES AUTRES
|
||||
// Les portages précédents demandaient « la décomposition reproduit-elle l'original ? ». Celui-ci
|
||||
// demande **« les opérateurs se RÉUTILISENT-ils vraiment entre archétypes ? »**, qui est la thèse
|
||||
// de `OPSTACK-PLAN §2.5` et la seule raison de faire ce refactor plutôt que de nettoyer le `switch`.
|
||||
//
|
||||
// Trois des cinq opérateurs de VerticalShafts sont ceux de Maze, **repris sans une ligne de
|
||||
// changement** : `ConstantRock`, `SdfRoughness`, `SdfCarve`. Dans le `switch`, `GetMazeDensity` et
|
||||
// `GetVerticalShaftDensity` sont deux fonctions de ~100 lignes qui n'ont rien en commun à l'œil.
|
||||
// En opérateurs, ce sont les mêmes trois ops avec une source différente et d'autres réglages
|
||||
// (fréquence 0.1 au lieu de 0.12, fenêtre `rough + 4` au lieu de `R + rough + 2`).
|
||||
//
|
||||
// **Si ce test passe en bit-à-bit, la réutilisation n'est plus une intention : c'est une mesure.**
|
||||
//
|
||||
// LA BARRE : bit à bit, comme les autres depuis `FPSemantics = Precise` (AUDIT §C9/§C10). Un écart
|
||||
// est une vraie trouvaille, pas du bruit d'arrondi.
|
||||
|
||||
#if WITH_DEV_AUTOMATION_TESTS
|
||||
|
||||
#include "Misc/AutomationTest.h"
|
||||
#include "Async/ParallelFor.h"
|
||||
#include "HAL/PlatformMisc.h"
|
||||
|
||||
#include "VoxelForgeTestFixture.h"
|
||||
#include "VoxelDensityOpStack.h"
|
||||
|
||||
#include <atomic>
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||
FVoxelForgeOpStackShaftTest,
|
||||
"VoxelForge.OpStack.VerticalShaftEquivalence",
|
||||
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr int32 NumShaftSamples = 20000;
|
||||
|
||||
/** Les ledges et les connecteurs sont éteints ou discrets par défaut. Un test sur les seuls
|
||||
* défauts vérifierait les cylindres et laisserait les DEUX opérateurs intéressants au repos —
|
||||
* le même piège que `WaterLevelRelative` et la fenêtre d'overhang. */
|
||||
void EnableShaftFeatures(FVerticalShaftParams& P)
|
||||
{
|
||||
P.CrossConnectChance = 0.65f; // des connecteurs, donc des capsules dans le SDF
|
||||
P.ConnectorRadius = 3.5f;
|
||||
P.LedgeSpacing = 11.0f; // des étagères, donc l'op forçant s'exécute
|
||||
P.LedgeDepth = 2.5f;
|
||||
P.SurfaceRoughness = 3.0f; // la rugosité SDF partagée avec Maze
|
||||
}
|
||||
}
|
||||
|
||||
bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
using namespace VoxelForgeTest;
|
||||
|
||||
FTestWorld World;
|
||||
World.Build();
|
||||
if (!World.IsValid())
|
||||
{
|
||||
AddError(World.WhyInvalid());
|
||||
return false;
|
||||
}
|
||||
|
||||
const UVoxelGenerator* Gen = World.Generator.Get();
|
||||
|
||||
int32 TopVoxelZ = 0, BottomVoxelZ = 0;
|
||||
if (!World.GetSlotVoxelZRange(FTestWorld::SlotVerticalShafts, TopVoxelZ, BottomVoxelZ))
|
||||
{
|
||||
AddError(TEXT("The fixture layout has no VerticalShafts slot. Check FTestWorld::Build's ")
|
||||
TEXT("Archetypes[] against FTestWorld::SlotVerticalShafts."));
|
||||
return false;
|
||||
}
|
||||
|
||||
const int32 MidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE;
|
||||
FVerticalShaftParams P = World.StrateManager->GetVerticalShaftParamsForChunk(
|
||||
FIntVector(0, 0, MidChunkZ));
|
||||
|
||||
if (P.StrateTopWorldZ - P.StrateBottomWorldZ <= 0.0f)
|
||||
{
|
||||
AddError(TEXT("The VerticalShafts strate has degenerate Z bounds, which sends ")
|
||||
TEXT("GetVerticalShaftDensity down its early-out. The op stack has none by design."));
|
||||
return false;
|
||||
}
|
||||
|
||||
EnableShaftFeatures(P);
|
||||
|
||||
FVoxelOpStack Stack;
|
||||
VoxelDensityOps::BuildVerticalShaftStack(Stack, P, World.Settings->Seed,
|
||||
Gen->OriginSpineRadius, World.StrateManager.Get());
|
||||
|
||||
// rock + shafts + roughness + carve + ledges + 3 structurels.
|
||||
TestEqual(TEXT("the shaft stack is decomposed into 8 ops"), Stack.Num(), 8);
|
||||
|
||||
FVoxelOpContext Ctx;
|
||||
Ctx.Seed = (uint32)World.Settings->Seed;
|
||||
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
|
||||
Ctx.StrateTopWorldZ = P.StrateTopWorldZ;
|
||||
Ctx.StrateBottomWorldZ = P.StrateBottomWorldZ;
|
||||
Stack.PrepareChunk(Ctx);
|
||||
|
||||
TArray<FVector> Points;
|
||||
Points.Reserve(NumShaftSamples);
|
||||
{
|
||||
FRandomStream Rng(80486);
|
||||
for (int32 i = 0; i < NumShaftSamples; ++i)
|
||||
{
|
||||
Points.Add(FVector(
|
||||
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
|
||||
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
|
||||
(float)Rng.RandRange(BottomVoxelZ, TopVoxelZ)));
|
||||
}
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// 1. ÉQUIVALENCE
|
||||
//=========================================================================
|
||||
int32 NumDiff = 0, NumSideDisagree = 0, WorstIdx = -1, NumInsideShaft = 0;
|
||||
float WorstDelta = 0.0f;
|
||||
|
||||
for (int32 i = 0; i < NumShaftSamples; ++i)
|
||||
{
|
||||
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
|
||||
|
||||
const float Old = Gen->GetVerticalShaftDensity(X, Y, Z, P);
|
||||
const float New = Stack.EvalMC(X, Y, Z);
|
||||
|
||||
if (Old >= 0.0f) { ++NumInsideShaft; } // air ⇒ dans un puits/connecteur/étagère
|
||||
|
||||
if (!BitEqual(Old, New))
|
||||
{
|
||||
++NumDiff;
|
||||
const float D = FMath::Abs(Old - New);
|
||||
if (D > WorstDelta) { WorstDelta = D; WorstIdx = i; }
|
||||
}
|
||||
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSideDisagree; }
|
||||
}
|
||||
|
||||
if (NumDiff == 0)
|
||||
{
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("VerticalShafts: bit-identical across %d samples (%d of them inside a shaft, so ")
|
||||
TEXT("the cylinders, connectors, roughness, carve and ledges were all exercised). ")
|
||||
TEXT("THREE of the five ops here are Maze's, reused unchanged -- operator reuse across ")
|
||||
TEXT("archetypes is now measured rather than intended (OPSTACK-PLAN 2.5)."),
|
||||
NumShaftSamples, NumInsideShaft));
|
||||
}
|
||||
else
|
||||
{
|
||||
AddError(FString::Printf(
|
||||
TEXT("VerticalShafts: %d of %d samples differ (largest |delta| %.9g at (%.0f, %.0f, ")
|
||||
TEXT("%.0f)); %d cross the isosurface. Since /fp:precise the bar is bit-identity, so ")
|
||||
TEXT("this is a real port error. Check, in order: the roughness FREQUENCY (0.1 here, ")
|
||||
TEXT("NOT Maze's 0.12) and window (rough + 4, not R + rough + 2), the 'Shft' salt ")
|
||||
TEXT("(0x53686674), the connector pair hash and its Z lerp between sealed bounds, and ")
|
||||
TEXT("the ledge gate reading the POST-roughness Sdf rather than re-deriving it."),
|
||||
NumDiff, NumShaftSamples, WorstDelta,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f,
|
||||
NumSideDisagree));
|
||||
}
|
||||
|
||||
TestEqual(TEXT("no sample lands on the opposite side of the isosurface"), NumSideDisagree, 0);
|
||||
|
||||
if (NumInsideShaft == 0)
|
||||
{
|
||||
AddWarning(TEXT("No sample landed inside a shaft, so the source, carve and ledge ops were ")
|
||||
TEXT("never meaningfully exercised. Raise ShaftDensity or ShaftMaxRadius."));
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// 2. INVARIANCE DE FENÊTRE
|
||||
//=========================================================================
|
||||
// La source garde un cache 3×3 `thread_local` dont la clé est le jeu de params : c'est
|
||||
// exactement le genre d'endroit où une clé incomplète produit une couture (AUDIT §C2).
|
||||
{
|
||||
std::atomic<int32> Impure{ 0 };
|
||||
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
|
||||
|
||||
TArray<float> Ref;
|
||||
Ref.SetNumUninitialized(NumShaftSamples);
|
||||
for (int32 i = 0; i < NumShaftSamples; ++i)
|
||||
{
|
||||
Ref[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
|
||||
}
|
||||
|
||||
ParallelFor(NumBlocks, [&](int32 Block)
|
||||
{
|
||||
TArray<int32> LocalOrder;
|
||||
BuildShuffledOrder(NumShaftSamples, 2200 + Block, LocalOrder);
|
||||
for (const int32 i : LocalOrder)
|
||||
{
|
||||
const float V = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
|
||||
if (!BitEqual(V, Ref[i])) { Impure.fetch_add(1, std::memory_order_relaxed); }
|
||||
}
|
||||
});
|
||||
|
||||
TestEqual(TEXT("the shaft stack is window-invariant across order and threads"),
|
||||
Impure.load(), 0);
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// 3. LE VERDICT DE BOÎTE
|
||||
//=========================================================================
|
||||
{
|
||||
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
|
||||
FRandomStream Rng(13579);
|
||||
|
||||
for (int32 t = 0; t < 60; ++t)
|
||||
{
|
||||
const int32 Step = 1, Cells = 8;
|
||||
const int32 Extent = Step * Cells;
|
||||
const FIntVector Origin(
|
||||
Rng.RandRange(-6, 6) * Extent,
|
||||
Rng.RandRange(-6, 6) * Extent,
|
||||
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
|
||||
|
||||
const int32 GridDim = Cells + 1;
|
||||
const FBox Box(
|
||||
FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step),
|
||||
FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step));
|
||||
|
||||
const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Ctx);
|
||||
if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; }
|
||||
++NumProved;
|
||||
|
||||
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
|
||||
for (int32 gz = -1; gz <= GridDim; ++gz)
|
||||
for (int32 gy = -1; gy <= GridDim; ++gy)
|
||||
for (int32 gx = -1; gx <= GridDim; ++gx)
|
||||
{
|
||||
const float X = (float)(Origin.X + gx * Step);
|
||||
const float Y = (float)(Origin.Y + gy * Step);
|
||||
const float Z = (float)(Origin.Z + gz * Step);
|
||||
const float D = Stack.EvalMC(X, Y, Z);
|
||||
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
|
||||
{
|
||||
if (NumUnsound == 0)
|
||||
{
|
||||
AddError(FString::Printf(
|
||||
TEXT("HOLE: the shaft stack claimed %s for the box at (%d,%d,%d) but ")
|
||||
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. Suspects, in ")
|
||||
TEXT("order: the shaft source's ExtraReach (does it cover the roughness ")
|
||||
TEXT("amplitude AND the carve blend?), then the connector sweep (a ")
|
||||
TEXT("connector can reach Spacing*1.6 beyond its cell), then the ledge ")
|
||||
TEXT("op's FillOnly."),
|
||||
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
|
||||
Origin.X, Origin.Y, Origin.Z, X, Y, Z, D,
|
||||
(D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
|
||||
}
|
||||
++NumUnsound;
|
||||
gz = gy = gx = GridDim + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TestEqual(TEXT("every box verdict the shaft stack emits survives brute force"), NumUnsound, 0);
|
||||
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("Box verdicts over 60 VerticalShafts tiles: %d proved uniform, %d Mixed. Today's ")
|
||||
TEXT("ClassifyTile proves ZERO of these -- every cave archetype falls through to \"pas ")
|
||||
TEXT("prouvable en v1\"."),
|
||||
NumProved, NumMixed));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||
@@ -0,0 +1,418 @@
|
||||
// VoxelForgeOpStackSlabTest.cpp
|
||||
// PHASE 2, PREMIER PORTAGE — la pile Slab contre GetSlabDensity, sur LES DEUX archétypes.
|
||||
// PHASE 2'S FIRST PORT — the Slab operator stack against GetSlabDensity, on BOTH archetypes.
|
||||
//
|
||||
// CE QUE CE TEST DOIT PROUVER / WHAT THIS TEST HAS TO PROVE
|
||||
// Trois choses, et la troisième est la raison d'être du portage :
|
||||
//
|
||||
// 1. ÉQUIVALENCE — la pile reproduit `GetSlabDensity`. ✅ **BIT-IDENTIQUE depuis 2026-07-27**,
|
||||
// quand `FPSemantics = Precise` (AUDIT §C9/§C10) a supprimé le résidu d'ULP : il venait de
|
||||
// `/fp:fast`. Un changement de côté d'isosurface reste l'ÉCHEC DUR ; la gradation ULP est
|
||||
// gardée comme détecteur de régression du modèle flottant, pas comme tolérance attendue.
|
||||
// 2. UN OPÉRATEUR, DEUX ARCHÉTYPES — la MÊME pile est vérifiée contre FlatPlain ET
|
||||
// CrystalChamber. `GetSlabDensity` ne les distingue par aucun branchement ; si la pile a
|
||||
// besoin d'en faire un, la fusion est fausse et ce test le dit.
|
||||
// ⚠️ La fixture ne règle que `GeneratorType`, donc les deux slots portent des params PAR
|
||||
// DÉFAUT : à eux seuls ils exécutent la même configuration à deux profondeurs. C'est la
|
||||
// TROISIÈME passe (`CrystalChamber(tuned)`, `CeilingRoughness` 6 → 20) qui fait réellement
|
||||
// varier ce qui distingue les deux archétypes — et qui sert en même temps de pire cas aux
|
||||
// bornes d'amplitude de `ClassifyBox`. Voir le bloc en bas de fichier.
|
||||
// 3. LE VERDICT DE BOÎTE — et c'est ici que §3.1 se paie. `ClassifyTile` prouve ZÉRO tuile pour
|
||||
// FlatPlain et CrystalChamber aujourd'hui. Depuis que les deux surfaces sont XY-PURES, leurs
|
||||
// bornes en Z sont connues exactement (contrat [-1,1] de FBM), donc toute tuile entièrement
|
||||
// sous le sol ou entre les deux bandes se prouve SANS échantillonner.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// ⚠️ CE TEST NE PEUT PAS DÉTECTER LE RETRAIT DU TERME EN Z — et c'est voulu
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// `GetSlabDensity` a perdu son terme en Z en même temps que ce portage était écrit
|
||||
// (OPSTACK-DECOMPOSITION §3.1, tranché par Jahni). La pile est comparée à la fonction TELLE
|
||||
// QU'ELLE EST MAINTENANT, donc ce test dit « le portage est fidèle » et ne dit RIEN sur le
|
||||
// changement de génération — c'est exactement la séparation voulue :
|
||||
//
|
||||
// • ce test vert ⇒ la pile == la fonction de référence. Le portage est un refactor pur.
|
||||
// • le monde a changé ⇒ imputable au retrait du terme en Z, ET À RIEN D'AUTRE.
|
||||
//
|
||||
// Sans cette séparation, un écart visuel serait inattribuable entre « j'ai changé le design » et
|
||||
// « j'ai raté le portage ». C'est le test qui fait l'attribution, pas l'ordre des builds.
|
||||
//
|
||||
// This test compares the stack against the reference function AS IT IS NOW, so green here means the
|
||||
// port is a pure refactor and ANY visual delta is attributable to the Z-term removal alone.
|
||||
//
|
||||
// ⚠️ Et la règle de §C10 tient toujours : ne jamais faire tourner les deux chemins dans le même
|
||||
// monde, ne jamais comparer leurs sorties pour égalité ailleurs qu'ici.
|
||||
|
||||
#if WITH_DEV_AUTOMATION_TESTS
|
||||
|
||||
#include "Misc/AutomationTest.h"
|
||||
#include "Async/ParallelFor.h"
|
||||
#include "HAL/PlatformMisc.h"
|
||||
|
||||
#include "VoxelForgeTestFixture.h"
|
||||
#include "VoxelDensityOpStack.h"
|
||||
|
||||
#include <atomic>
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||
FVoxelForgeOpStackSlabTest,
|
||||
"VoxelForge.OpStack.SlabEquivalence",
|
||||
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr int32 NumSlabSamples = 20000;
|
||||
constexpr int32 NumSlabTiles = 60;
|
||||
}
|
||||
|
||||
bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
using namespace VoxelForgeTest;
|
||||
|
||||
FTestWorld World;
|
||||
World.Build();
|
||||
if (!World.IsValid())
|
||||
{
|
||||
AddError(World.WhyInvalid());
|
||||
return false;
|
||||
}
|
||||
|
||||
const UVoxelGenerator* Gen = World.Generator.Get();
|
||||
|
||||
//=========================================================================
|
||||
// LA BATTERIE, PARAMÉTRÉE PAR ARCHÉTYPE
|
||||
//=========================================================================
|
||||
// Exécutée à l'identique sur FlatPlain et CrystalChamber. Si les deux passent avec la MÊME
|
||||
// pile et la MÊME fabrique, la fusion des deux archétypes est démontrée plutôt qu'affirmée.
|
||||
auto RunBattery = [&](const FSlabGenerationParams& SlabParams,
|
||||
int32 TopVoxelZ, int32 BottomVoxelZ,
|
||||
int32 SlotIndex, const TCHAR* SlotName)
|
||||
{
|
||||
// `GetSlabDensity` court-circuite sur une strate dégénérée (`return 1.0f`). Cette garde
|
||||
// appartient à la fonction d'archétype, pas à un opérateur ; la pile suppose une strate
|
||||
// valide, et `GetDensityAt` retombe sur le `switch` dans ce cas.
|
||||
if (SlabParams.StrateTopWorldZ - SlabParams.StrateBottomWorldZ <= 0.0f)
|
||||
{
|
||||
AddError(FString::Printf(
|
||||
TEXT("%s has degenerate Z bounds (top %.1f, bottom %.1f), which sends GetSlabDensity ")
|
||||
TEXT("down its early-out. The op stack has no such early-out by design."),
|
||||
SlotName, SlabParams.StrateTopWorldZ, SlabParams.StrateBottomWorldZ));
|
||||
return;
|
||||
}
|
||||
|
||||
FVoxelOpStack Stack;
|
||||
VoxelDensityOps::BuildSlabStack(Stack, SlabParams, World.Settings->Seed,
|
||||
Gen->OriginSpineRadius, World.StrateManager.Get());
|
||||
|
||||
// La décomposition doit rester une DÉCOMPOSITION : vide + colonnes + 3 structurels.
|
||||
TestEqual(*FString::Printf(TEXT("%s decomposes into void + columns + 3 structural"), SlotName),
|
||||
Stack.Num(), 5);
|
||||
|
||||
FVoxelOpContext Ctx;
|
||||
Ctx.Seed = (uint32)World.Settings->Seed;
|
||||
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
|
||||
Ctx.StrateTopWorldZ = SlabParams.StrateTopWorldZ;
|
||||
Ctx.StrateBottomWorldZ = SlabParams.StrateBottomWorldZ;
|
||||
Stack.PrepareChunk(Ctx);
|
||||
|
||||
TArray<FVector> Points;
|
||||
Points.Reserve(NumSlabSamples);
|
||||
{
|
||||
FRandomStream Rng(31337 + SlotIndex);
|
||||
for (int32 i = 0; i < NumSlabSamples; ++i)
|
||||
{
|
||||
Points.Add(FVector(
|
||||
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
|
||||
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
|
||||
(float)Rng.RandRange(BottomVoxelZ, TopVoxelZ)));
|
||||
}
|
||||
}
|
||||
|
||||
//=====================================================================
|
||||
// 1. ÉQUIVALENCE — géométrie d'abord, bits ensuite.
|
||||
//=====================================================================
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// LE BON MÈTRE — corrigé 2026-07-27 après que la passe `tuned` a crié au loup
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Première version : `16 · max(|Old|, 1) · FLT_EPSILON`, c.-à-d. l'ULP mesuré sur la
|
||||
// DENSITÉ DE SORTIE. C'est le mauvais mètre, et il se trompe exactement là où le test
|
||||
// regarde le plus : la densité vaut `min(Z - Sol, Plafond - Z)`, donc PRÈS DE L'ISOSURFACE
|
||||
// la sortie tend vers 0 pendant que les intermédiaires (surfaces, Z monde, amplitudes de
|
||||
// bruit) valent des CENTAINES. Un arrondi né à l'échelle 400 était jugé contre un mètre
|
||||
// à l'échelle 1 — 400× trop serré.
|
||||
//
|
||||
// Mesuré : la passe `tuned` (rugosités ×2.25 et ×3.33) a vu ses écarts croître ×4.5, et
|
||||
// son pire écart valait **0.345 ULP de |Z|**. Sous-ULP à l'échelle où l'erreur naît.
|
||||
// L'erreur est donc proportionnelle à l'AMPLITUDE, ce qui est la signature d'un arrondi
|
||||
// ordinaire, pas d'une transcription fausse.
|
||||
//
|
||||
// Le mètre correct est la magnitude des quantités D'OÙ VIENT l'erreur. Le test reste
|
||||
// discriminant : une vraie dérive de portage (offset de bruit faux, `abs()` manquant,
|
||||
// clamp oublié) déplace la surface de plusieurs VOXELS — 4 ordres de grandeur au-dessus
|
||||
// de ce seuil, pas 4 fois.
|
||||
//
|
||||
// The first yardstick measured ULPs on the OUTPUT density, which tends to 0 near the
|
||||
// isosurface while the intermediates are in the hundreds. Rounding born at scale ~400 was
|
||||
// judged against a yardstick of scale 1. Real port drift moves the surface by voxels —
|
||||
// four orders of magnitude above this bound, so the test stays discriminating.
|
||||
const float SurfaceScale = FMath::Max(FMath::Abs(SlabParams.StrateTopWorldZ),
|
||||
FMath::Abs(SlabParams.StrateBottomWorldZ));
|
||||
|
||||
int32 NumDiff = 0, WorstIdx = -1, NumBeyondUlpNoise = 0, NumSolidDisagreements = 0;
|
||||
float WorstDelta = 0.0f, WorstOld = 0.0f, WorstUlpsOfScale = 0.0f;
|
||||
// Le pire cas PARMI LES DÉPASSEMENTS — c'est lui qui dit si un WARN est du bruit ou une dérive.
|
||||
float WorstOutlierDelta = 0.0f, WorstOutlierOld = 0.0f, WorstOutlierUlps = 0.0f;
|
||||
|
||||
for (int32 i = 0; i < NumSlabSamples; ++i)
|
||||
{
|
||||
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
|
||||
|
||||
const float Old = Gen->GetSlabDensity(X, Y, Z, SlabParams); // MC : négatif = solide
|
||||
const float New = Stack.EvalMC(X, Y, Z);
|
||||
|
||||
if (!BitEqual(Old, New))
|
||||
{
|
||||
++NumDiff;
|
||||
const float Delta = FMath::Abs(Old - New);
|
||||
|
||||
// L'échelle à laquelle CET échantillon calcule : la sortie, sa propre altitude, et
|
||||
// les bornes de la strate. C'est le plus grand des trois qui porte l'arrondi.
|
||||
const float Scale = FMath::Max3(FMath::Abs(Old), FMath::Abs(Z),
|
||||
FMath::Max(SurfaceScale, 1.0f));
|
||||
const float Ulps = Delta / (Scale * FLT_EPSILON);
|
||||
|
||||
if (Delta > WorstDelta)
|
||||
{
|
||||
WorstDelta = Delta; WorstIdx = i; WorstOld = Old; WorstUlpsOfScale = Ulps;
|
||||
}
|
||||
|
||||
if (Delta > 16.0f * Scale * FLT_EPSILON)
|
||||
{
|
||||
++NumBeyondUlpNoise;
|
||||
if (Delta > WorstOutlierDelta)
|
||||
{
|
||||
WorstOutlierDelta = Delta; WorstOutlierOld = Old; WorstOutlierUlps = Ulps;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Le mesher ne lit que le SIGNE. Un désaccord de CÔTÉ bouge la géométrie.
|
||||
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSolidDisagreements; }
|
||||
}
|
||||
|
||||
if (NumDiff == 0)
|
||||
{
|
||||
AddInfo(FString::Printf(TEXT("%s: bit-identical across %d samples."),
|
||||
SlotName, NumSlabSamples));
|
||||
}
|
||||
else if (NumBeyondUlpNoise == 0)
|
||||
{
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("%s: %d of %d samples differ, ALL at ULP scale (largest |delta| %.9g = %.3f ULP ")
|
||||
TEXT("of the working scale, where density = %.6g, at (%.0f, %.0f, %.0f)), and 0 cross ")
|
||||
TEXT("the isosurface. Same accepted floor as Maze -- see AUDIT-2026-07.md C10."),
|
||||
SlotName, NumDiff, NumSlabSamples, WorstDelta, WorstUlpsOfScale, WorstOld,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Le message porte maintenant LE DISCRIMINANT, pas seulement l'alarme : la densité au
|
||||
// point fautif et l'écart exprimé en ULP de l'échelle de travail. Un dépassement à
|
||||
// quelques ULP avec une densité proche de 0 est un artefact de mètre ; un dépassement
|
||||
// à des milliers d'ULP est une vraie dérive. La différence se lit, elle ne se devine pas.
|
||||
AddWarning(FString::Printf(
|
||||
TEXT("%s: %d of %d samples differ and %d exceed the ULP bound. Worst OUTLIER: ")
|
||||
TEXT("|delta| %.9g = %.1f ULP of the working scale, where density = %.6g. ")
|
||||
TEXT("(Worst overall: |delta| %.9g at (%.0f, %.0f, %.0f).) %d cross the isosurface. ")
|
||||
TEXT("READ THE ULP FIGURE BEFORE INVESTIGATING: a few ULP with a near-zero density is ")
|
||||
TEXT("cancellation near the isosurface, not drift. Thousands of ULP IS drift -- check, ")
|
||||
TEXT("in order: the floor/ceiling noise offsets (7.3/11.1 and 17.3+1000/19.7+2000/3000), ")
|
||||
TEXT("the abs() on the ceiling noise, the ceiling clamp (FloorSurface + 2), the column ")
|
||||
TEXT("blend (2.0) and the 0.15/0.7 jitter."),
|
||||
SlotName, NumDiff, NumSlabSamples, NumBeyondUlpNoise,
|
||||
WorstOutlierDelta, WorstOutlierUlps, WorstOutlierOld,
|
||||
WorstDelta,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f,
|
||||
NumSolidDisagreements));
|
||||
}
|
||||
|
||||
TestEqual(*FString::Printf(
|
||||
TEXT("%s: no sample lands on the opposite side of the isosurface"), SlotName),
|
||||
NumSolidDisagreements, 0);
|
||||
|
||||
//=====================================================================
|
||||
// 2. INVARIANCE DE FENÊTRE
|
||||
//=====================================================================
|
||||
// Le cache 3×3 des colonnes est `thread_local` et sa clé n'est PAS le chunk mais le jeu de
|
||||
// params + le seed. Si cette clé est incomplète, la couture apparaît ici.
|
||||
{
|
||||
std::atomic<int32> Impure{ 0 };
|
||||
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
|
||||
|
||||
TArray<float> Ref;
|
||||
Ref.SetNumUninitialized(NumSlabSamples);
|
||||
for (int32 i = 0; i < NumSlabSamples; ++i)
|
||||
{
|
||||
Ref[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
|
||||
}
|
||||
|
||||
ParallelFor(NumBlocks, [&](int32 Block)
|
||||
{
|
||||
TArray<int32> LocalOrder;
|
||||
BuildShuffledOrder(NumSlabSamples, 700 + Block + SlotIndex * 32, LocalOrder);
|
||||
for (const int32 i : LocalOrder)
|
||||
{
|
||||
const float V = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
|
||||
if (!BitEqual(V, Ref[i])) { Impure.fetch_add(1, std::memory_order_relaxed); }
|
||||
}
|
||||
});
|
||||
|
||||
TestEqual(*FString::Printf(
|
||||
TEXT("%s: the op stack is window-invariant across order and threads"), SlotName),
|
||||
Impure.load(), 0);
|
||||
}
|
||||
|
||||
//=====================================================================
|
||||
// 3. LE VERDICT DE BOÎTE — ce que §3.1 a acheté
|
||||
//=====================================================================
|
||||
{
|
||||
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
|
||||
FRandomStream Rng(24680 + SlotIndex);
|
||||
|
||||
for (int32 t = 0; t < NumSlabTiles; ++t)
|
||||
{
|
||||
const int32 Step = 1, Cells = 8;
|
||||
const int32 Extent = Step * Cells;
|
||||
const FIntVector Origin(
|
||||
Rng.RandRange(-6, 6) * Extent,
|
||||
Rng.RandRange(-6, 6) * Extent,
|
||||
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
|
||||
|
||||
const int32 GridDim = Cells + 1; // le MÊME treillis que le mesher, marge ±1 comprise
|
||||
const FBox Box(
|
||||
FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step),
|
||||
FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step));
|
||||
|
||||
const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Ctx);
|
||||
if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; }
|
||||
++NumProved;
|
||||
|
||||
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
|
||||
for (int32 gz = -1; gz <= GridDim; ++gz)
|
||||
for (int32 gy = -1; gy <= GridDim; ++gy)
|
||||
for (int32 gx = -1; gx <= GridDim; ++gx)
|
||||
{
|
||||
const float X = (float)(Origin.X + gx * Step);
|
||||
const float Y = (float)(Origin.Y + gy * Step);
|
||||
const float Z = (float)(Origin.Z + gz * Step);
|
||||
const float D = Stack.EvalMC(X, Y, Z);
|
||||
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
|
||||
{
|
||||
if (NumUnsound == 0)
|
||||
{
|
||||
AddError(FString::Printf(
|
||||
TEXT("HOLE: %s claimed %s for the box at (%d,%d,%d) but ")
|
||||
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. One of the ")
|
||||
TEXT("ops is not conservative. Suspects, in order: the slab source's ")
|
||||
TEXT("noise amplitude bounds (does FBM really honour [-1,1]?), the ")
|
||||
TEXT("ceiling clamp raising CeilSurface above CeilZ, then the column ")
|
||||
TEXT("mod's reach (MaxRadius + blend)."),
|
||||
SlotName, bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
|
||||
Origin.X, Origin.Y, Origin.Z, X, Y, Z, D,
|
||||
(D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
|
||||
}
|
||||
++NumUnsound;
|
||||
gz = gy = gx = GridDim + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TestEqual(*FString::Printf(
|
||||
TEXT("%s: every box verdict survives brute force (a false verdict is a hole)"),
|
||||
SlotName),
|
||||
NumUnsound, 0);
|
||||
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("%s box verdicts over %d tiles: %d proved uniform, %d Mixed. Today's ")
|
||||
TEXT("ClassifyTile proves ZERO of these. This number is the whole point of making ")
|
||||
TEXT("the slab surfaces XY-pure (OPSTACK-DECOMPOSITION 3.1)."),
|
||||
SlotName, NumSlabTiles, NumProved, NumMixed));
|
||||
|
||||
if (NumProved == 0)
|
||||
{
|
||||
AddWarning(FString::Printf(
|
||||
TEXT("%s proved no tile uniform. Not a correctness problem, but the entire perf ")
|
||||
TEXT("case for dropping the Z term rests on this number being well above zero -- ")
|
||||
TEXT("a slab is mostly solid rock below the floor. Check that the sampled tile Z ")
|
||||
TEXT("range actually reaches below FloorZ - FloorAmp."), SlotName));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
//=========================================================================
|
||||
// LES TROIS PASSES
|
||||
//=========================================================================
|
||||
auto ResolveSlot = [&](int32 SlotIndex, const TCHAR* SlotName,
|
||||
FSlabGenerationParams& OutParams, int32& OutTop, int32& OutBottom) -> bool
|
||||
{
|
||||
if (!World.GetSlotVoxelZRange(SlotIndex, OutTop, OutBottom))
|
||||
{
|
||||
AddError(FString::Printf(
|
||||
TEXT("The fixture layout has no %s slot. Check FTestWorld::Build's Archetypes[] ")
|
||||
TEXT("against FTestWorld::Slot%s."), SlotName, SlotName));
|
||||
return false;
|
||||
}
|
||||
const int32 MidChunkZ = ((OutTop + OutBottom) / 2) / CHUNK_SIZE;
|
||||
OutParams = World.StrateManager->GetSlabParamsForChunk(FIntVector(0, 0, MidChunkZ));
|
||||
return true;
|
||||
};
|
||||
|
||||
FSlabGenerationParams FlatParams, CrystalParams;
|
||||
int32 FlatTop = 0, FlatBottom = 0, CrystalTop = 0, CrystalBottom = 0;
|
||||
|
||||
if (ResolveSlot(FTestWorld::SlotFlatPlain, TEXT("FlatPlain"), FlatParams, FlatTop, FlatBottom))
|
||||
{
|
||||
RunBattery(FlatParams, FlatTop, FlatBottom, FTestWorld::SlotFlatPlain, TEXT("FlatPlain"));
|
||||
}
|
||||
|
||||
if (ResolveSlot(FTestWorld::SlotCrystalChamber, TEXT("CrystalChamber"),
|
||||
CrystalParams, CrystalTop, CrystalBottom))
|
||||
{
|
||||
RunBattery(CrystalParams, CrystalTop, CrystalBottom,
|
||||
FTestWorld::SlotCrystalChamber, TEXT("CrystalChamber"));
|
||||
|
||||
//=====================================================================
|
||||
// LA PASSE QUI FAIT VRAIMENT LA DÉMONSTRATION
|
||||
//=====================================================================
|
||||
// ⚠️ La fixture ne règle QUE `GeneratorType` : FlatPlain et CrystalChamber y reçoivent des
|
||||
// `FSlabGenerationParams` PAR DÉFAUT, donc identiques. Les deux passes ci-dessus exécutent
|
||||
// en réalité la même configuration à deux profondeurs — ce qui est un test utile, mais qui
|
||||
// ne démontre PAS « un opérateur, deux jeux de défauts » : `CeilingRoughness`, la seule
|
||||
// chose qui distingue réellement CrystalChamber, n'y varie jamais.
|
||||
//
|
||||
// Cette passe-ci fait varier ce qui compte, et elle est aussi le PIRE CAS pour les bornes
|
||||
// d'amplitude de `ClassifyBox` : un `CeilingRoughness` élevé élargit la bande du plafond et
|
||||
// rend le clamp `Max(CeilZ - bruit, FloorSurface + 2)` beaucoup plus susceptible de mordre.
|
||||
// Si un verdict de boîte est faux quelque part, c'est ici qu'il apparaît.
|
||||
//
|
||||
// The fixture only sets GeneratorType, so both slots get DEFAULT slab params — the two
|
||||
// passes above are the same configuration at two depths. This pass varies what actually
|
||||
// distinguishes CrystalChamber, and is simultaneously the worst case for the ClassifyBox
|
||||
// amplitude bounds: a large CeilingRoughness widens the ceiling band and makes the
|
||||
// FloorSurface + 2 clamp far more likely to bind.
|
||||
FSlabGenerationParams Tuned = CrystalParams;
|
||||
Tuned.CeilingRoughness = 20.0f; // vs 6.0 par défaut — de vraies stalactites
|
||||
Tuned.CeilingRoughnessFrequency = 0.09f;
|
||||
Tuned.FloorRoughness = 9.0f;
|
||||
Tuned.ColumnDensity = 0.25f; // beaucoup plus de colonnes ⇒ FillOnly plus souvent
|
||||
Tuned.ColumnMaxRadius = 11.0f;
|
||||
|
||||
RunBattery(Tuned, CrystalTop, CrystalBottom, 64, TEXT("CrystalChamber(tuned)"));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,275 @@
|
||||
// VoxelForgeTestFixture.h
|
||||
// Fixture partagée par les tests d'automatisation VoxelForge (Phase 0.5 de OPSTACK-PLAN.md).
|
||||
// Shared fixture for the VoxelForge automation tests (OPSTACK-PLAN.md, Phase 0.5).
|
||||
//
|
||||
// WHY THIS EXISTS
|
||||
// ---------------
|
||||
// The interesting invariants (density purity across worker threads, ClassifyTile soundness)
|
||||
// only fire on the REAL path — UVoxelGenerator::GetDensityAt — because that is where the
|
||||
// thread_local per-chunk caches live (CP_*, GSurfColCache, the diff slots, the SDF cache).
|
||||
// Calling GetSurfaceDensity / GetMazeDensity directly bypasses every one of them and would
|
||||
// test almost nothing. GetDensityAt in turn needs a live UVoxelStrateManager, whose only
|
||||
// entry point is Initialize(UVoxelSettings*, int32) reading TSoftObjectPtr pools.
|
||||
//
|
||||
// So the fixture builds a whole synthetic world in memory: transient strate definitions →
|
||||
// a transient UVoxelSettings pointing at them → a real UVoxelStrateManager::Initialize.
|
||||
//
|
||||
// ⚠️ KNOWN RISK, stated rather than hidden: the settings hold TSoftObjectPtr, and we point
|
||||
// them at TRANSIENT objects (/Engine/Transient.<name>). LoadSynchronous() resolves those via
|
||||
// FindObject, which works for in-memory objects — but it is the one part of this fixture that
|
||||
// has never been compiled or run. IsValid() below checks the layout actually materialised, and
|
||||
// every test hard-FAILS with a clear message when it didn't. A silent skip would be worse than
|
||||
// a failure: it would look like a pass.
|
||||
//
|
||||
// Everything is held by TStrongObjectPtr so the GC cannot eat the world mid-test.
|
||||
|
||||
#pragma once
|
||||
|
||||
#if WITH_DEV_AUTOMATION_TESTS
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "UObject/StrongObjectPtr.h"
|
||||
#include "UObject/Package.h"
|
||||
|
||||
#include "VoxelTypes.h"
|
||||
#include "VoxelSettings.h"
|
||||
#include "VoxelStrateTypes.h"
|
||||
#include "VoxelStrateDefinition.h"
|
||||
#include "VoxelStrateManager.h"
|
||||
#include "VoxelDiffLayer.h"
|
||||
#include "VoxelGenerator.h"
|
||||
|
||||
namespace VoxelForgeTest
|
||||
{
|
||||
/**
|
||||
* FTestWorld — a complete, headless VoxelForge world: settings + strate layout +
|
||||
* generator + diff layer. No AActor, no UWorld, no PIE.
|
||||
*
|
||||
* The default layout stacks one strate of EVERY archetype (in ECaveGeneratorType order),
|
||||
* so a single fixture exercises all eight density functions and their per-chunk caches,
|
||||
* plus the gap-bedrock path when InterStrateGapChunks > 0.
|
||||
*/
|
||||
struct FTestWorld
|
||||
{
|
||||
TStrongObjectPtr<UVoxelSettings> Settings;
|
||||
TStrongObjectPtr<UVoxelStrateManager> StrateManager;
|
||||
TStrongObjectPtr<UVoxelDiffLayer> DiffLayer;
|
||||
TStrongObjectPtr<UVoxelGenerator> Generator;
|
||||
TArray<TStrongObjectPtr<UVoxelStrateDefinition>> Definitions;
|
||||
|
||||
/** World Z (voxel coords) span actually covered by the layout — handy for picking samples. */
|
||||
int32 TopChunkZ = 0;
|
||||
int32 BottomChunkZ = 0;
|
||||
|
||||
/**
|
||||
* Build the world.
|
||||
*
|
||||
* The default seed stays SMALL, but the reason has changed. It USED to be a workaround:
|
||||
* AUDIT §C1 (unbounded `SeedF`) meant a large seed collapsed the noise fields to constants,
|
||||
* which would have made a purity test pass trivially for the wrong reason.
|
||||
*
|
||||
* **§C1 is fixed** (`VoxelHash::SeedOffset` — bounded and site-salted). The small default
|
||||
* now just keeps failure messages comparable across tests. A large seed is no longer
|
||||
* dangerous — and `VoxelForge.Determinism.LargeSeedSurvives` deliberately passes big ones
|
||||
* (up to 2e9) to prove it stays that way.
|
||||
*/
|
||||
void Build(int32 InSeed = 1337, int32 InGapChunks = 2, bool bUseOperatorStack = false)
|
||||
{
|
||||
Settings = TStrongObjectPtr<UVoxelSettings>(
|
||||
NewObject<UVoxelSettings>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||
Settings->Seed = InSeed;
|
||||
Settings->InterStrateGapChunks = InGapChunks;
|
||||
|
||||
// Une strate par archétype. PINNED via FixedStrates, pas via le pool : Initialize()
|
||||
// mélange le pool avec le seed, ce qui rendrait la correspondance archétype → Z
|
||||
// dépendante du seed et un message d'échec impossible à relire.
|
||||
// One strate per archetype, PINNED through FixedStrates rather than the pool:
|
||||
// Initialize() shuffles the pool by seed, which would make the archetype → Z mapping
|
||||
// seed-dependent and a failure message unreadable. Slot i == Archetypes[i].
|
||||
static const ECaveGeneratorType Archetypes[] = {
|
||||
ECaveGeneratorType::TunnelNetwork,
|
||||
ECaveGeneratorType::FlatPlain,
|
||||
ECaveGeneratorType::CrystalChamber,
|
||||
ECaveGeneratorType::Maze,
|
||||
ECaveGeneratorType::SurfaceWorld,
|
||||
ECaveGeneratorType::VerticalShafts,
|
||||
ECaveGeneratorType::FloatingIslands,
|
||||
ECaveGeneratorType::Underwater,
|
||||
};
|
||||
|
||||
const int32 NumArchetypes = (int32)UE_ARRAY_COUNT(Archetypes);
|
||||
for (int32 i = 0; i < NumArchetypes; ++i)
|
||||
{
|
||||
UVoxelStrateDefinition* Def = NewObject<UVoxelStrateDefinition>(
|
||||
GetTransientPackage(), NAME_None, RF_Transient);
|
||||
Def->GeneratorType = Archetypes[i];
|
||||
Def->StrateHeightInChunks = 4;
|
||||
// L'OPT-IN de la pile d'opérateurs. Faux par défaut : les treize tests existants
|
||||
// doivent continuer à exercer le `switch`, qui reste le comportement de référence.
|
||||
// Seul le test de solidité de ClassifyTie côté pile le passe à vrai.
|
||||
Def->bUseOperatorStack = bUseOperatorStack;
|
||||
// Hard transitions: param blending across a boundary would make "which archetype
|
||||
// owns this chunk" ambiguous, and these tests want an unambiguous mapping.
|
||||
Def->TransitionType = EVoxelStrateTransition::Hard;
|
||||
Definitions.Add(TStrongObjectPtr<UVoxelStrateDefinition>(Def));
|
||||
|
||||
const TSoftObjectPtr<UVoxelStrateDefinition> SoftDef(Def);
|
||||
Settings->FixedStrates.Add(i, SoftDef);
|
||||
Settings->StratePool.Add(SoftDef); // fallback if a fixed entry fails to resolve
|
||||
}
|
||||
Settings->TotalStrates = NumArchetypes;
|
||||
|
||||
StrateManager = TStrongObjectPtr<UVoxelStrateManager>(
|
||||
NewObject<UVoxelStrateManager>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||
|
||||
//=================================================================
|
||||
// ⚠️ CHAQUE MONDE DE TEST OBTIENT UNE `LayoutVersion` UNIQUE DANS LE PROCESSUS
|
||||
//=================================================================
|
||||
// Ce n'est pas de la cosmétique, c'est une CONTAMINATION CROISÉE réelle entre tests, et
|
||||
// elle n'était jusqu'ici masquée que par un accident.
|
||||
//
|
||||
// `PassagesVersion` est PAR INSTANCE et part de 0, donc deux `FTestWorld` successifs
|
||||
// rendaient tous les deux **1**. Or les caches par chunk de `GetDensityAt` sont clés sur
|
||||
// `(ChunkCoord, LayoutVersion)` : deux mondes différents, même version, même chunk ⇒ le
|
||||
// second se voit servir les params — ET le drapeau `CP_UseOpStack` — du premier.
|
||||
// Personne ne l'a vu parce que `bUseOperatorStack` valait false partout : les deux
|
||||
// mondes étaient d'accord par défaut. Le premier monde qui coche la case fait tomber
|
||||
// cette coïncidence, dans les DEUX sens (il contamine, et il est contaminé).
|
||||
//
|
||||
// Un compteur de processus donne à chaque monde une version distincte, donc tout cache
|
||||
// survivant d'un test à l'autre est forcément invalidé. `Initialize` est déterministe
|
||||
// (le pool est mélangé par le seed, les fixed strates sont épinglées), donc le rappeler
|
||||
// ne change pas le layout — seulement le compteur.
|
||||
//
|
||||
// Each test world gets a process-unique LayoutVersion. Two worlds both reporting 1 made
|
||||
// GetDensityAt's per-chunk caches serve the previous world's params — and its
|
||||
// CP_UseOpStack flag — for the same chunk coord. Invisible while every world agreed that
|
||||
// the flag was false.
|
||||
static int32 GWorldSerial = 0;
|
||||
const int32 Bumps = ++GWorldSerial;
|
||||
for (int32 b = 0; b < Bumps; ++b)
|
||||
{
|
||||
StrateManager->Initialize(Settings.Get(), Settings->Seed);
|
||||
}
|
||||
|
||||
DiffLayer = TStrongObjectPtr<UVoxelDiffLayer>(
|
||||
NewObject<UVoxelDiffLayer>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||
|
||||
Generator = TStrongObjectPtr<UVoxelGenerator>(
|
||||
NewObject<UVoxelGenerator>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||
Generator->InitializeSettings(Settings.Get());
|
||||
Generator->SetStrateManager(StrateManager.Get());
|
||||
Generator->SetDiffLayer(DiffLayer.Get());
|
||||
|
||||
CacheZBounds();
|
||||
}
|
||||
|
||||
/** Re-run Initialize (bumps LayoutVersion) — the live-edit path AUDIT C2 is about. */
|
||||
void Reinitialize()
|
||||
{
|
||||
StrateManager->Initialize(Settings.Get(), Settings->Seed);
|
||||
CacheZBounds();
|
||||
}
|
||||
|
||||
/** False when the soft-pointer resolve failed and no strate layout exists. */
|
||||
bool IsValid() const
|
||||
{
|
||||
return StrateManager.IsValid() && StrateManager->GetNumStrates() > 0;
|
||||
}
|
||||
|
||||
FString WhyInvalid() const
|
||||
{
|
||||
return TEXT("FTestWorld could not build a strate layout. Most likely the ")
|
||||
TEXT("TSoftObjectPtr -> transient UVoxelStrateDefinition resolve failed inside ")
|
||||
TEXT("UVoxelStrateManager::Initialize (LoadSynchronous on /Engine/Transient.*). ")
|
||||
TEXT("See the header comment in VoxelForgeTestFixture.h. This is a FIXTURE ")
|
||||
TEXT("failure, not a generator failure — do not read it as a density bug.");
|
||||
}
|
||||
|
||||
/** Voxel-Z of the middle of the layout — a point guaranteed inside a real strate. */
|
||||
float MidVoxelZ() const
|
||||
{
|
||||
return (float)((TopChunkZ + BottomChunkZ) / 2 * CHUNK_SIZE + CHUNK_SIZE / 2);
|
||||
}
|
||||
|
||||
/** Layout slot index of each archetype — the Archetypes[] order in Build(), pinned via
|
||||
* FixedStrates so it is stable across seeds. SurfaceWorld matters most: it is the only
|
||||
* archetype ClassifyTile can currently prove anything about (besides bedrock gaps). */
|
||||
static constexpr int32 SlotTunnelNetwork = 0;
|
||||
static constexpr int32 SlotFlatPlain = 1;
|
||||
static constexpr int32 SlotCrystalChamber = 2;
|
||||
static constexpr int32 SlotMaze = 3;
|
||||
static constexpr int32 SlotSurfaceWorld = 4;
|
||||
static constexpr int32 SlotVerticalShafts = 5;
|
||||
static constexpr int32 SlotFloatingIsland = 6;
|
||||
static constexpr int32 SlotUnderwater = 7;
|
||||
|
||||
/** Voxel-Z span of one layout slot. False if the layout is shorter than expected. */
|
||||
bool GetSlotVoxelZRange(int32 SlotIndex, int32& OutTopVoxelZ, int32& OutBottomVoxelZ) const
|
||||
{
|
||||
const TArray<FStrateSlot>& Layout = StrateManager->GetLayout();
|
||||
if (!Layout.IsValidIndex(SlotIndex)) { return false; }
|
||||
OutTopVoxelZ = Layout[SlotIndex].TopChunkZ * CHUNK_SIZE + CHUNK_SIZE - 1;
|
||||
OutBottomVoxelZ = Layout[SlotIndex].BottomChunkZ * CHUNK_SIZE;
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
void CacheZBounds()
|
||||
{
|
||||
TopChunkZ = 0;
|
||||
BottomChunkZ = 0;
|
||||
for (const FStrateSlot& Slot : StrateManager->GetLayout())
|
||||
{
|
||||
TopChunkZ = FMath::Max(TopChunkZ, Slot.TopChunkZ);
|
||||
BottomChunkZ = FMath::Min(BottomChunkZ, Slot.BottomChunkZ);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A spread of world sample points that deliberately crosses chunk boundaries, strate
|
||||
* boundaries and bedrock gaps — the exact conditions under which a per-chunk cache with a
|
||||
* missing key input produces a wrong answer. Integer XY on purpose: that is the branch
|
||||
* GetDensityAt's T1.a column cache actually takes (fractional XY bypasses the cache).
|
||||
*/
|
||||
inline void BuildSamplePoints(const FTestWorld& World, int32 Count, int32 Seed,
|
||||
TArray<FVector>& OutPoints)
|
||||
{
|
||||
OutPoints.Reset(Count);
|
||||
FRandomStream Rng(Seed);
|
||||
const int32 TopVoxelZ = World.TopChunkZ * CHUNK_SIZE + CHUNK_SIZE - 1;
|
||||
const int32 BottomVoxelZ = World.BottomChunkZ * CHUNK_SIZE;
|
||||
for (int32 i = 0; i < Count; ++i)
|
||||
{
|
||||
// XY range spans several chunks either side of the origin so the (0,0) spine, the
|
||||
// passages and plain interior rock all appear in the sample set.
|
||||
const int32 X = Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE);
|
||||
const int32 Y = Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE);
|
||||
const int32 Z = Rng.RandRange(BottomVoxelZ, TopVoxelZ);
|
||||
OutPoints.Add(FVector((float)X, (float)Y, (float)Z));
|
||||
}
|
||||
}
|
||||
|
||||
/** Deterministic shuffle of an index array — the "different query order" half of purity. */
|
||||
inline void BuildShuffledOrder(int32 Count, int32 Seed, TArray<int32>& OutOrder)
|
||||
{
|
||||
OutOrder.Reset(Count);
|
||||
for (int32 i = 0; i < Count; ++i) { OutOrder.Add(i); }
|
||||
FRandomStream Rng(Seed);
|
||||
for (int32 i = Count - 1; i > 0; --i)
|
||||
{
|
||||
OutOrder.Swap(i, Rng.RandRange(0, i));
|
||||
}
|
||||
}
|
||||
|
||||
/** Bit-exact float compare — NOT FMath::IsNearlyEqual. Window invariance is a bit property
|
||||
* (ARCHITECTURE §8.4): a 1-ULP difference between two chunk windows is a visible seam. */
|
||||
inline bool BitEqual(float A, float B)
|
||||
{
|
||||
return FMath::IsNaN(A) == FMath::IsNaN(B)
|
||||
&& (FMath::IsNaN(A) || *reinterpret_cast<const uint32*>(&A) == *reinterpret_cast<const uint32*>(&B));
|
||||
}
|
||||
}
|
||||
|
||||
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,497 @@
|
||||
// VoxelHeightOpStack.cpp
|
||||
// Les cinq opérateurs d'espace-hauteur de SurfaceWorld.
|
||||
// The five height-space operators of SurfaceWorld.
|
||||
//
|
||||
// FIDÉLITÉ / FIDELITY
|
||||
// Chaque corps est une transcription LITTÉRALE du bloc correspondant de
|
||||
// `SampleSurfaceStructuralZ` / `ComputeSurfaceTerrainZ` — mêmes offsets, mêmes octaves, même ordre
|
||||
// d'opérations flottantes. Depuis que `FPSemantics = Precise` est posé (AUDIT §C9), l'égalité
|
||||
// BIT À BIT est atteignable et atteinte pour Maze et Slab : c'est donc la barre ici aussi, et
|
||||
// `VoxelForge.OpStack.SurfaceHeightEquivalence` la vérifie.
|
||||
//
|
||||
// ⚠️ LE DÉTOUR PAR `FVector` EST DÉLIBÉRÉ, comme ailleurs dans ce refactor : `FractalNoise3D` prend
|
||||
// un `FVector` (donc des DOUBLES en UE5) et re-descend en float. Passer directement des floats
|
||||
// saute un arrondi. Reproduire le détour, c'est reproduire l'arrondi.
|
||||
// The FVector round-trip is deliberate: FVector is double in UE5, so the original rounds through a
|
||||
// double. Going straight through floats skips a rounding step.
|
||||
|
||||
#include "VoxelHeightOp.h"
|
||||
|
||||
#include "VoxelCaveMorphology.h" // VoxelHash::SeedOffset — AUDIT §C1 (bounded, site-salted)
|
||||
#include "VoxelNoise.h" // VoxelNoise::FBM / Ridged / Perlin3D
|
||||
#include "VoxelTypes.h" // SmoothStep01, VOXEL_NOISE_SCALE
|
||||
|
||||
namespace
|
||||
{
|
||||
//=========================================================================
|
||||
// HELPERS — les mêmes enveloppes que VoxelGenerator.cpp, transcrites
|
||||
//=========================================================================
|
||||
// `FractalNoise3D` et `RidgedNoise3D` sont `static` dans VoxelGenerator.cpp, donc invisibles
|
||||
// ici. Elles sont recopiées à l'identique plutôt qu'exportées : les exporter changerait leur
|
||||
// contexte d'inlining, et sous /fp:precise comme sous /fp:fast la règle est la même — on ne
|
||||
// touche à rien de ce qui entoure une expression flottante qu'on veut reproduire.
|
||||
FORCEINLINE float HFractalNoise3D(const FVector& Position, int32 Octaves = 4,
|
||||
float Lacunarity = 2.0f, float Persistence = 0.5f)
|
||||
{
|
||||
return VoxelNoise::FBM((float)Position.X, (float)Position.Y, (float)Position.Z,
|
||||
Octaves, Lacunarity, Persistence);
|
||||
}
|
||||
|
||||
FORCEINLINE float HRidgedNoise3D(const FVector& Position, int32 Octaves = 4,
|
||||
float Lacunarity = 2.0f, float Persistence = 0.5f)
|
||||
{
|
||||
return VoxelNoise::Ridged((float)Position.X, (float)Position.Y, (float)Position.Z,
|
||||
Octaves, Lacunarity, Persistence);
|
||||
}
|
||||
|
||||
/** Transcription de `UVoxelGenerator::SampleRelief`. Champ [0,1] partagé avec la carte de
|
||||
* biomes, pour que la géographie et le terrain qu'elle module restent d'accord. */
|
||||
FORCEINLINE float HSampleRelief(float WorldX, float WorldY, uint32 SeedU,
|
||||
float Frequency, float Contrast)
|
||||
{
|
||||
float R = HFractalNoise3D(FVector(
|
||||
WorldX * Frequency + VoxelHash::SeedOffset(SeedU, 7.3f),
|
||||
WorldY * Frequency + VoxelHash::SeedOffset(SeedU, 2.1f),
|
||||
VoxelHash::SeedOffset(SeedU, 0.5f)), 2) * 0.5f + 0.5f; // [0,1]
|
||||
R = FMath::Clamp((R - 0.5f) * Contrast + 0.5f, 0.0f, 1.0f);
|
||||
return SmoothStep01(R);
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// SOURCE — CHAMP STRUCTUREL / STRUCTURAL HEIGHT FIELD
|
||||
//=========================================================================
|
||||
// Continents + montagnes + détail, sous une frame de domain-warp. Produit les DEUX canaux.
|
||||
class FStructuralHeightSource final : public IVoxelHeightOp
|
||||
{
|
||||
public:
|
||||
FStructuralHeightSource(const FSurfaceGenerationParams& InP, int32 InSeed)
|
||||
: P(InP), SeedU((uint32)InSeed) {}
|
||||
|
||||
void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const override
|
||||
{
|
||||
float M = 1.0f;
|
||||
InOut.Height = SampleZ(WorldX, WorldY, M); // Replace : racine de pile
|
||||
InOut.Relief = M;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le champ nu, exposé parce que `FCliffHeightMod` doit le RÉ-ÉCHANTILLONNER en différences
|
||||
* centrées. C'est une dépendance réelle du code d'origine (`ComputeSurfaceTerrainZ` appelle
|
||||
* `SampleSurfaceStructuralZ` quatre fois de plus), pas un raccourci : la pente doit venir du
|
||||
* champ STRUCTUREL, sans rétroaction des ops, sinon le cliff se nourrirait de lui-même.
|
||||
*/
|
||||
float SampleZ(float WorldX, float WorldY, float& OutM) const
|
||||
{
|
||||
const float H = P.StrateTopWorldZ - P.StrateBottomWorldZ;
|
||||
const float BottomZ = P.StrateBottomWorldZ;
|
||||
|
||||
const float GroundBase = BottomZ + H * P.BaseGroundRelative;
|
||||
|
||||
// Domain-warp des coords STRUCTURELLES (continents + montagnes). Le bruit de détail
|
||||
// reste sur le vrai XY pour que les bosses fines restent nettes et décorrélées.
|
||||
float QX = WorldX, QY = WorldY;
|
||||
if (P.HeightWarpStrength > 0.0f)
|
||||
{
|
||||
const float WF = P.HeightWarpFrequency;
|
||||
const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.31f), WorldY * WF + 4.2f, VoxelHash::SeedOffset(SeedU, 1.7f)));
|
||||
const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 8.6f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.53f), VoxelHash::SeedOffset(SeedU, 2.9f)));
|
||||
QX += wx * VOXEL_NOISE_SCALE * P.HeightWarpStrength;
|
||||
QY += wy * VOXEL_NOISE_SCALE * P.HeightWarpStrength;
|
||||
}
|
||||
|
||||
const float Relief = HSampleRelief(WorldX, WorldY, SeedU, P.ReliefFrequency, P.ReliefContrast);
|
||||
const float M = FMath::Lerp(1.0f, Relief, P.ReliefStrength);
|
||||
|
||||
float Cont = HFractalNoise3D(FVector(
|
||||
QX * P.ContinentFrequency + VoxelHash::SeedOffset(SeedU, 3.1f),
|
||||
QY * P.ContinentFrequency + VoxelHash::SeedOffset(SeedU, 5.7f),
|
||||
VoxelHash::SeedOffset(SeedU, 0.7f)), 4); // [-1,1]
|
||||
|
||||
float Detail = HFractalNoise3D(FVector(
|
||||
WorldX * P.DetailFrequency + 11.0f,
|
||||
WorldY * P.DetailFrequency + 22.0f,
|
||||
VoxelHash::SeedOffset(SeedU, 1.3f)), 3); // [-1,1]
|
||||
|
||||
float Mountain = 0.0f;
|
||||
if (P.MountainStrength > 0.0f)
|
||||
{
|
||||
float Ridge = HRidgedNoise3D(FVector(
|
||||
QX * P.MountainFrequency + 99.0f,
|
||||
QY * P.MountainFrequency + 77.0f,
|
||||
VoxelHash::SeedOffset(SeedU, 0.9f)), 4); // [-1,1]
|
||||
Ridge = Ridge * 0.5f + 0.5f; // [0,1] sommets
|
||||
Mountain = Ridge * P.MountainStrength * M; // les montagnes ne montent qu'en haut relief
|
||||
}
|
||||
|
||||
// Les plaines gardent une fraction du gonflement continental ; les hautes terres tout.
|
||||
const float ContScale = FMath::Lerp(0.45f, 1.0f, M);
|
||||
|
||||
float Terrain = GroundBase
|
||||
+ Cont * P.ElevationRange * 0.5f * ContScale
|
||||
+ Mountain * P.ElevationRange
|
||||
+ Detail * P.SurfaceRoughness;
|
||||
|
||||
OutM = M;
|
||||
return Terrain;
|
||||
}
|
||||
|
||||
// Une SOURCE pose l'altitude, elle ne la déplace pas : la notion de « déplacement max » ne
|
||||
// s'applique pas. La borne d'une colonne se calcule à partir de la source elle-même
|
||||
// (GroundBase ± ElevationRange ± SurfaceRoughness), pas ici — d'où FLT_MAX, honnête.
|
||||
float MaxDisplacement() const override { return FLT_MAX; }
|
||||
|
||||
private:
|
||||
FSurfaceGenerationParams P;
|
||||
uint32 SeedU;
|
||||
};
|
||||
|
||||
//=========================================================================
|
||||
// MOD — FALAISE / CLIFF (raidissement conditionné par la pente)
|
||||
//=========================================================================
|
||||
class FCliffHeightMod final : public IVoxelHeightOp
|
||||
{
|
||||
public:
|
||||
FCliffHeightMod(const FSurfaceGenerationParams& InP, const FStructuralHeightSource* InSrc)
|
||||
: P(InP), Src(InSrc) {}
|
||||
|
||||
void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const override
|
||||
{
|
||||
if (P.CliffStrength <= 0.0f || Src == nullptr) { return; }
|
||||
|
||||
const float D = FMath::Max(P.CliffSampleDist, 0.5f);
|
||||
float Ms; // relief scratch — on ne veut que les hauteurs
|
||||
const float Zxp = Src->SampleZ(WorldX + D, WorldY, Ms);
|
||||
const float Zxm = Src->SampleZ(WorldX - D, WorldY, Ms);
|
||||
const float Zyp = Src->SampleZ(WorldX, WorldY + D, Ms);
|
||||
const float Zym = Src->SampleZ(WorldX, WorldY - D, Ms);
|
||||
const float dZdX = (Zxp - Zxm) / (2.0f * D);
|
||||
const float dZdY = (Zyp - Zym) / (2.0f * D);
|
||||
const float Slope = FMath::Sqrt(dZdX * dZdX + dZdY * dZdY);
|
||||
|
||||
const float Thr = FMath::Max(P.CliffSlopeThreshold, 0.05f);
|
||||
const float SlopeGate = FMath::Clamp((Slope - Thr) / Thr, 0.0f, 1.0f);
|
||||
if (SlopeGate > 0.0f)
|
||||
{
|
||||
const float Ref = 0.25f * (Zxp + Zxm + Zyp + Zym);
|
||||
const float Gain = P.CliffStrength * SlopeGate * P.CliffSharpness;
|
||||
InOut.Height += (InOut.Height - Ref) * Gain;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
FSurfaceGenerationParams P;
|
||||
const FStructuralHeightSource* Src;
|
||||
};
|
||||
|
||||
//=========================================================================
|
||||
// MOD — TERRASSES / TERRACE (gaté par le relief : le canal Relief sert ICI)
|
||||
//=========================================================================
|
||||
class FTerraceHeightMod final : public IVoxelHeightOp
|
||||
{
|
||||
public:
|
||||
explicit FTerraceHeightMod(const FSurfaceGenerationParams& InP) : P(InP) {}
|
||||
|
||||
void Eval(float, float, FVoxelHeightSample& InOut) const override
|
||||
{
|
||||
if (P.TerraceStrength <= 0.0f || P.TerraceHeight <= 0.0f) { return; }
|
||||
|
||||
const float StepH = P.TerraceHeight;
|
||||
const float T = InOut.Height / StepH;
|
||||
const float K = FMath::FloorToFloat(T);
|
||||
const float Frac = T - K;
|
||||
const float W = FMath::Lerp(0.5f, 0.03f, FMath::Clamp(P.TerraceHardness, 0.0f, 1.0f));
|
||||
const float Fs = SmoothStep01(FMath::Clamp((Frac - (0.5f - W)) / (2.0f * W), 0.0f, 1.0f));
|
||||
const float Stepped = (K + Fs) * StepH;
|
||||
// `* InOut.Relief` : c'est le `* M` de l'original — la raison d'être du second canal.
|
||||
InOut.Height = FMath::Lerp(InOut.Height, Stepped, P.TerraceStrength * InOut.Relief);
|
||||
}
|
||||
|
||||
// Le terrace interpole VERS une hauteur quantifiée : l'écart ne dépasse jamais un palier.
|
||||
float MaxDisplacement() const override
|
||||
{
|
||||
return (P.TerraceStrength > 0.0f) ? FMath::Max(P.TerraceHeight, 0.0f) : 0.0f;
|
||||
}
|
||||
|
||||
private:
|
||||
FSurfaceGenerationParams P;
|
||||
};
|
||||
|
||||
//=========================================================================
|
||||
// MOD — LIGNES DE STRATES / LAYER LINES
|
||||
//=========================================================================
|
||||
class FLayerLineHeightMod final : public IVoxelHeightOp
|
||||
{
|
||||
public:
|
||||
explicit FLayerLineHeightMod(const FSurfaceGenerationParams& InP) : P(InP) {}
|
||||
|
||||
void Eval(float, float, FVoxelHeightSample& InOut) const override
|
||||
{
|
||||
if (P.LayerLineDepth <= 0.0f || P.LayerLineSpacing <= 0.0f) { return; }
|
||||
|
||||
const float Phase = InOut.Height * (2.0f * PI / P.LayerLineSpacing);
|
||||
InOut.Height -= FMath::Sin(Phase) * P.LayerLineDepth;
|
||||
}
|
||||
|
||||
// `sin` ∈ [-1,1] ⇒ borne exacte.
|
||||
float MaxDisplacement() const override
|
||||
{
|
||||
return (P.LayerLineSpacing > 0.0f) ? FMath::Max(P.LayerLineDepth, 0.0f) : 0.0f;
|
||||
}
|
||||
|
||||
private:
|
||||
FSurfaceGenerationParams P;
|
||||
};
|
||||
|
||||
//=========================================================================
|
||||
// MOD — PLAGE / BEACH (aplatissement vers la ligne d'eau)
|
||||
//=========================================================================
|
||||
class FBeachHeightMod final : public IVoxelHeightOp
|
||||
{
|
||||
public:
|
||||
explicit FBeachHeightMod(const FSurfaceGenerationParams& InP) : P(InP) {}
|
||||
|
||||
void Eval(float, float, FVoxelHeightSample& InOut) const override
|
||||
{
|
||||
// Le niveau d'eau est GLOBAL à la strate (forcé depuis la strate) pour que le plan
|
||||
// d'eau reste continu — d'où le calcul depuis les bornes de strate, pas depuis un param
|
||||
// par biome.
|
||||
const float H = P.StrateTopWorldZ - P.StrateBottomWorldZ;
|
||||
const float WaterZ = P.StrateBottomWorldZ + H * P.WaterLevelRelative;
|
||||
if (P.WaterLevelRelative <= 0.0f || P.BeachWidth <= 0.0f) { return; }
|
||||
|
||||
const float DAbs = FMath::Abs(InOut.Height - WaterZ);
|
||||
if (DAbs < P.BeachWidth)
|
||||
{
|
||||
float T = SmoothStep01(DAbs / P.BeachWidth);
|
||||
InOut.Height = FMath::Lerp(WaterZ, InOut.Height, T);
|
||||
}
|
||||
}
|
||||
|
||||
// N'agit que dans `BeachWidth` de l'eau, et ne fait qu'y RAPPROCHER.
|
||||
float MaxDisplacement() const override
|
||||
{
|
||||
return (P.WaterLevelRelative > 0.0f) ? FMath::Max(P.BeachWidth, 0.0f) : 0.0f;
|
||||
}
|
||||
|
||||
private:
|
||||
FSurfaceGenerationParams P;
|
||||
};
|
||||
|
||||
//=========================================================================
|
||||
// SOURCE — LE CIEL / SKY CAP (c'est une ALTITUDE, donc c'est un op de hauteur)
|
||||
//=========================================================================
|
||||
// `ComputeSurfaceCeiling` rend un Z, exactement comme le terrain. `OPSTACK-DECOMPOSITION §5` le
|
||||
// range en `FSkyCapSource` côté DENSITÉ (« Subtract »), mais c'est le même glissement que pour
|
||||
// les ops de terrain : ce que la fonction produit est une hauteur, et la soustraction n'arrive
|
||||
// qu'après, dans le combine. Le mettre ici lui donne gratuitement l'invariance de fenêtre
|
||||
// testée, la pureté XY garantie par le type, et le cache de colonne.
|
||||
// The sky cap returns a Z, so it belongs in height space; the subtraction happens later, in the
|
||||
// density-side combine.
|
||||
class FSkyCapHeightSource final : public IVoxelHeightOp
|
||||
{
|
||||
public:
|
||||
FSkyCapHeightSource(const FSurfaceGenerationParams& InP, int32 InSeed)
|
||||
: P(InP), SeedU((uint32)InSeed) {}
|
||||
|
||||
void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const override
|
||||
{
|
||||
const float H = P.StrateTopWorldZ - P.StrateBottomWorldZ;
|
||||
float CeilZ = P.StrateBottomWorldZ + H * P.CeilingRelative;
|
||||
|
||||
// Domain-warp des coords larges/ridge (miroir du HeightWarp du sol). Les bosses fines
|
||||
// restent sur le vrai XY pour rester nettes et décorrélées. 0 ⇒ pas de warp.
|
||||
float QX = WorldX, QY = WorldY;
|
||||
if (P.CeilingWarpStrength > 0.0f)
|
||||
{
|
||||
const float WF = P.CeilingWarpFrequency;
|
||||
const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.71f), WorldY * WF + 2.3f, VoxelHash::SeedOffset(SeedU, 3.3f)));
|
||||
const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 6.1f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.19f), VoxelHash::SeedOffset(SeedU, 4.7f)));
|
||||
QX += wx * VOXEL_NOISE_SCALE * P.CeilingWarpStrength;
|
||||
QY += wy * VOXEL_NOISE_SCALE * P.CeilingWarpStrength;
|
||||
}
|
||||
|
||||
// Gonflement large SIGNÉ : monte/descend toute la voûte.
|
||||
if (P.CeilingUndulation > 0.0f)
|
||||
{
|
||||
const float Swell = HFractalNoise3D(FVector(
|
||||
QX * P.CeilingUndulationFrequency + VoxelHash::SeedOffset(SeedU, 1.9f),
|
||||
QY * P.CeilingUndulationFrequency + 13.0f,
|
||||
VoxelHash::SeedOffset(SeedU, 0.5f)), 3); // [-1,1]
|
||||
CeilZ += Swell * VOXEL_NOISE_SCALE * P.CeilingUndulation;
|
||||
}
|
||||
|
||||
// Pendage vers le BAS uniquement : tout est >= 0, donc rien ne perce vers le haut dans
|
||||
// le seal. Bosses fines + lames ridgées s'additionnent.
|
||||
float Hang = 0.0f;
|
||||
if (P.CeilingRoughness > 0.0f)
|
||||
{
|
||||
Hang += FMath::Abs(HFractalNoise3D(FVector(
|
||||
WorldX * P.CeilingRoughnessFrequency + 5.0f,
|
||||
WorldY * P.CeilingRoughnessFrequency + 6.0f,
|
||||
VoxelHash::SeedOffset(SeedU, 2.1f)), 3)) * VOXEL_NOISE_SCALE * P.CeilingRoughness;
|
||||
}
|
||||
if (P.CeilingRidgeStrength > 0.0f)
|
||||
{
|
||||
float Ridge = HRidgedNoise3D(FVector(
|
||||
QX * P.CeilingRidgeFrequency + 31.0f,
|
||||
QY * P.CeilingRidgeFrequency + 47.0f,
|
||||
VoxelHash::SeedOffset(SeedU, 1.1f)), 4); // [-1,1]
|
||||
Ridge = Ridge * 0.5f + 0.5f; // [0,1] lignes de crête pendantes
|
||||
Hang += Ridge * P.CeilingRidgeStrength;
|
||||
}
|
||||
|
||||
InOut.Height = CeilZ - Hang; // Replace : racine de sa propre pile
|
||||
// Relief laissé intact : le ciel n'en produit pas et personne ne le lui demande.
|
||||
}
|
||||
|
||||
float MaxDisplacement() const override { return FLT_MAX; } // source, pas modificateur
|
||||
|
||||
private:
|
||||
FSurfaceGenerationParams P;
|
||||
uint32 SeedU;
|
||||
};
|
||||
|
||||
//=========================================================================
|
||||
// COMBINER `Mask` — MÉLANGE DE BIOMES / BIOME BLEND
|
||||
//=========================================================================
|
||||
// Une pile complète par biome ; le champ dit lequel domine ; on interpole les HAUTEURS.
|
||||
//
|
||||
// ⚠️ Chaque pile calcule SON PROPRE relief `M` en interne et l'utilise pour son propre gate de
|
||||
// terrace — exactement comme l'original, où `ComputeSurfaceTerrainZ(X, Y, *PD)` et
|
||||
// `(…, *PN)` sont deux appels complets et indépendants dont seules les SORTIES sont mêlées.
|
||||
// Le canal `Relief` qui ressort ici est celui du DOMINANT : il est informatif, personne en aval
|
||||
// ne s'en sert pour re-gater quoi que ce soit.
|
||||
//
|
||||
// Each biome stack computes its own relief internally and gates its own terrace with it, exactly
|
||||
// as the original makes two independent full calls and blends only the OUTPUTS.
|
||||
class FBiomeBlendHeightSource final : public IVoxelHeightOp
|
||||
{
|
||||
public:
|
||||
FBiomeBlendHeightSource(const TArray<FSurfaceGenerationParams>& PerBiome, int32 Seed,
|
||||
const IVoxelBiomeField* InField, bool bCeilingOnly)
|
||||
: Field(InField)
|
||||
{
|
||||
Stacks.Reserve(PerBiome.Num());
|
||||
for (const FSurfaceGenerationParams& BP : PerBiome)
|
||||
{
|
||||
FVoxelHeightStack S;
|
||||
if (bCeilingOnly) { VoxelHeightOps::BuildSurfaceCeilingStack(S, BP, Seed); }
|
||||
else { VoxelHeightOps::BuildSurfaceHeightStack(S, BP, Seed); }
|
||||
Stacks.Add(MoveTemp(S));
|
||||
}
|
||||
bBlend = !bCeilingOnly; // le plafond SÉLECTIONNE, il ne mélange pas
|
||||
}
|
||||
|
||||
void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const override
|
||||
{
|
||||
if (Stacks.Num() == 0) { return; }
|
||||
|
||||
FVoxelBiomeWeights W;
|
||||
if (Field) { W = Field->SampleAt(WorldX, WorldY); }
|
||||
|
||||
const int32 D = Stacks.IsValidIndex(W.Dominant) ? W.Dominant : 0;
|
||||
InOut = Stacks[D].EvalSample(WorldX, WorldY);
|
||||
|
||||
// Le plafond ne se mélange pas (voir la fabrique) ; le sol si, et seulement dans la
|
||||
// bande de frontière où le poids est non nul.
|
||||
if (bBlend && W.NeighborWeight > 0.0f && Stacks.IsValidIndex(W.Neighbor))
|
||||
{
|
||||
const float HN = Stacks[W.Neighbor].EvalHeight(WorldX, WorldY);
|
||||
InOut.Height = FMath::Lerp(InOut.Height, HN, W.NeighborWeight);
|
||||
}
|
||||
}
|
||||
|
||||
float MaxDisplacement() const override { return FLT_MAX; } // source composite
|
||||
|
||||
private:
|
||||
TArray<FVoxelHeightStack> Stacks;
|
||||
const IVoxelBiomeField* Field;
|
||||
bool bBlend = true;
|
||||
};
|
||||
|
||||
} // ⚠️ FIN DU NAMESPACE ANONYME — TOUT NOUVEL OPÉRATEUR SE MET AU-DESSUS DE CETTE LIGNE.
|
||||
// En dessous commence `namespace VoxelHeightOps` (les fabriques). Y insérer une classe la sort
|
||||
// de la liaison interne, et l'accolade qu'on ajoute avec elle ne ferme rien → C2059. Erreur
|
||||
// commise DEUX fois (7cd2bed, puis à nouveau ici) en s'ancrant sur la bannière « FABRIQUES »,
|
||||
// qui est de l'autre côté de cette accolade.
|
||||
// END OF THE ANONYMOUS NAMESPACE — new operators go ABOVE this line. Anchoring on the FACTORIES
|
||||
// banner below puts them outside it, and the brace added with them closes nothing.
|
||||
|
||||
//=============================================================================
|
||||
// FABRIQUES / FACTORIES
|
||||
//=============================================================================
|
||||
|
||||
namespace VoxelHeightOps
|
||||
{
|
||||
TUniquePtr<IVoxelHeightOp> MakeBiomeBlendHeightSource(
|
||||
const TArray<FSurfaceGenerationParams>& PerBiomeParams, int32 Seed,
|
||||
const IVoxelBiomeField* Field)
|
||||
{
|
||||
return MakeUnique<FBiomeBlendHeightSource>(PerBiomeParams, Seed, Field, /*bCeilingOnly*/false);
|
||||
}
|
||||
|
||||
TUniquePtr<IVoxelHeightOp> MakeBiomeSelectCeilingSource(
|
||||
const TArray<FSurfaceGenerationParams>& PerBiomeParams, int32 Seed,
|
||||
const IVoxelBiomeField* Field)
|
||||
{
|
||||
return MakeUnique<FBiomeBlendHeightSource>(PerBiomeParams, Seed, Field, /*bCeilingOnly*/true);
|
||||
}
|
||||
|
||||
TUniquePtr<IVoxelHeightOp> MakeSkyCapHeightSource(const FSurfaceGenerationParams& P, int32 Seed)
|
||||
{
|
||||
return MakeUnique<FSkyCapHeightSource>(P, Seed);
|
||||
}
|
||||
|
||||
void BuildSurfaceCeilingStack(FVoxelHeightStack& OutStack, const FSurfaceGenerationParams& P, int32 Seed)
|
||||
{
|
||||
// Un seul op aujourd'hui — et c'est une information, pas un manque : le plafond n'a pas
|
||||
// d'équivalent des quatre modificateurs du sol. Le jour où on veut des terrasses au
|
||||
// plafond, on ajoute la ligne ; c'est exactement le genre de composition que le refactor
|
||||
// existe pour rendre possible.
|
||||
OutStack.Add(MakeSkyCapHeightSource(P, Seed));
|
||||
}
|
||||
TUniquePtr<IVoxelHeightOp> MakeStructuralHeightSource(const FSurfaceGenerationParams& P, int32 Seed,
|
||||
const IVoxelHeightOp** OutSource)
|
||||
{
|
||||
TUniquePtr<FStructuralHeightSource> Src = MakeUnique<FStructuralHeightSource>(P, Seed);
|
||||
if (OutSource) { *OutSource = Src.Get(); }
|
||||
return Src;
|
||||
}
|
||||
|
||||
TUniquePtr<IVoxelHeightOp> MakeCliffHeightMod(const FSurfaceGenerationParams& P,
|
||||
const IVoxelHeightOp* StructuralSource)
|
||||
{
|
||||
// `static_cast` plutôt que `Cast<>` : ce ne sont pas des UObject, et le contrat de la
|
||||
// fabrique est qu'on lui rend exactement le pointeur sorti de MakeStructuralHeightSource.
|
||||
return MakeUnique<FCliffHeightMod>(
|
||||
P, static_cast<const FStructuralHeightSource*>(StructuralSource));
|
||||
}
|
||||
|
||||
TUniquePtr<IVoxelHeightOp> MakeTerraceHeightMod(const FSurfaceGenerationParams& P)
|
||||
{
|
||||
return MakeUnique<FTerraceHeightMod>(P);
|
||||
}
|
||||
|
||||
TUniquePtr<IVoxelHeightOp> MakeLayerLineHeightMod(const FSurfaceGenerationParams& P)
|
||||
{
|
||||
return MakeUnique<FLayerLineHeightMod>(P);
|
||||
}
|
||||
|
||||
TUniquePtr<IVoxelHeightOp> MakeBeachHeightMod(const FSurfaceGenerationParams& P)
|
||||
{
|
||||
return MakeUnique<FBeachHeightMod>(P);
|
||||
}
|
||||
|
||||
void BuildSurfaceHeightStack(FVoxelHeightStack& OutStack, const FSurfaceGenerationParams& P, int32 Seed)
|
||||
{
|
||||
// L'ORDRE EST CELUI DE `ComputeSurfaceTerrainZ`, et il porte du sens :
|
||||
// le cliff raidit le champ brut, le terrace quantifie le résultat raidi, les lignes de
|
||||
// strates se posent dessus, et la plage écrase tout près de l'eau.
|
||||
const IVoxelHeightOp* Structural = nullptr;
|
||||
OutStack.Add(MakeStructuralHeightSource(P, Seed, &Structural));
|
||||
OutStack.Add(MakeCliffHeightMod(P, Structural));
|
||||
OutStack.Add(MakeTerraceHeightMod(P));
|
||||
OutStack.Add(MakeLayerLineHeightMod(P));
|
||||
OutStack.Add(MakeBeachHeightMod(P));
|
||||
}
|
||||
}
|
||||
@@ -556,6 +556,60 @@ ECaveGeneratorType UVoxelStrateManager::GetGeneratorTypeForChunk(const FIntVecto
|
||||
return StrateLayout[SlotIdx].Definition->GeneratorType;
|
||||
}
|
||||
|
||||
bool UVoxelStrateManager::UsesOperatorStackForChunk(const FIntVector& ChunkCoord) const
|
||||
{
|
||||
const int32 SlotIdx = FindSlotIndexForChunkZ(ChunkCoord.Z);
|
||||
if (SlotIdx < 0 || !StrateLayout[SlotIdx].Definition) { return false; }
|
||||
|
||||
const UVoxelStrateDefinition* Def = StrateLayout[SlotIdx].Definition;
|
||||
if (!Def->bUseOperatorStack) { return false; }
|
||||
|
||||
// LA LISTE DES ARCHÉTYPES PORTÉS — le seul endroit où elle est écrite. Un archétype non porté
|
||||
// ignore le drapeau et retombe sur le `switch`, pour qu'on puisse cocher la case sur n'importe
|
||||
// quelle strate sans rien casser en attendant son portage.
|
||||
// THE PORTED-ARCHETYPE LIST, written down exactly once. An unported archetype ignores the flag
|
||||
// and falls back to the switch, so the box can be ticked anywhere without breaking anything.
|
||||
switch (Def->GeneratorType)
|
||||
{
|
||||
case ECaveGeneratorType::Maze: return true; // Phase 1
|
||||
case ECaveGeneratorType::FlatPlain: // Phase 2 — les deux partagent
|
||||
case ECaveGeneratorType::CrystalChamber: return true; // UNE seule pile (BuildSlabStack)
|
||||
|
||||
case ECaveGeneratorType::SurfaceWorld:
|
||||
// ✅ La garde « pas de biomes » est TOMBÉE (étape 2c) : le combiner `Mask` existe, donc une
|
||||
// strate à biomes mélange bien ses hauteurs comme le chemin d'origine. Les trois archétypes
|
||||
// du dessus plus celui-ci font 5 des 8 portés.
|
||||
// The no-biome guard is GONE: the Mask combiner exists, so a biome strate blends its heights
|
||||
// exactly as the original path does.
|
||||
return true;
|
||||
|
||||
case ECaveGeneratorType::VerticalShafts: return true; // Phase 2 — 3 ops repris de Maze tels quels
|
||||
|
||||
case ECaveGeneratorType::FloatingIslands:
|
||||
// Phase 2 — la pile qui tourne à l'ENVERS : source de VIDE + fill, au lieu de source de ROC
|
||||
// + carve, avec les MÊMES opérateurs au signe près.
|
||||
return true;
|
||||
|
||||
case ECaveGeneratorType::Underwater:
|
||||
// ⚠️ AUCUNE PILE À ELLE : `Underwater` EST `TunnelNetwork` plus un drapeau d'eau consommé
|
||||
// côté rendu. `GetDensityAt` les met dans le même `case`, et `WaterLevelRelative` n'est lu
|
||||
// que par `GetWaterLevel*` de ce manager — jamais par la densité (vérifié, pas supposé).
|
||||
case ECaveGeneratorType::TunnelNetwork:
|
||||
// Phase 2, LE DERNIER, et le plus gros : ~1080 lignes portées en trois étapes (squelette
|
||||
// SDF → douze modificateurs de détail → override d'op par salle), 19 opérateurs, dont
|
||||
// `FRoomGraphSource` qui **APPELLE** `BuildChunkCache`/`EvaluateSDFCached` au lieu de les
|
||||
// transcrire — c'est là que vit la discipline d'invariance de fenêtre d'ARCHITECTURE §8.4,
|
||||
// et en forker une copie aurait été le pire résultat possible de ce refactor.
|
||||
//
|
||||
// **8 SUR 8.** Le `switch` d'archétypes a désormais un jumeau en pile d'opérateurs, opt-in
|
||||
// par strate, chacun vérifié par un test d'équivalence bit à bit contre sa fonction
|
||||
// d'origine. Ce qui n'est PAS fait : `ClassifyTile` n'utilise toujours pas `ClassifyBox`.
|
||||
return true;
|
||||
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool UVoxelStrateManager::IsGapChunk(const FIntVector& ChunkCoord) const
|
||||
{
|
||||
if (StrateLayout.Num() == 0) return false;
|
||||
|
||||
@@ -11,6 +11,14 @@
|
||||
#include "VoxelTerrainOpDefinition.h"
|
||||
#include "VoxelContentManager.h"
|
||||
#include "VoxelDensityVolume.h"
|
||||
// IWYU (FPSemantics = Precise ⇒ plus de PCH partagé) : GetPlayerPosition déréférence le pawn, donc
|
||||
// APawn doit être COMPLET — `Casts.h` n'en donne qu'une déclaration avant. APlayerController était
|
||||
// complet par transitivité seulement : on l'inclut explicitement, c'est exactement la fragilité
|
||||
// qu'on est en train de retirer.
|
||||
// GetPlayerPosition dereferences the pawn, so APawn must be COMPLETE — Casts.h only forward-declares
|
||||
// it. APlayerController was complete transitively only; include it explicitly.
|
||||
#include "GameFramework/Pawn.h"
|
||||
#include "GameFramework/PlayerController.h"
|
||||
#include "Materials/MaterialInstanceDynamic.h"
|
||||
#include "Materials/MaterialParameterCollection.h"
|
||||
#include "Kismet/KismetMaterialLibrary.h"
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "CoreMinimal.h"
|
||||
#include "VoxelAtmosphereManager.generated.h"
|
||||
|
||||
class AActor; // IWYU : pointeur / TWeakObjectPtr seulement / pointer-only
|
||||
class UVoxelStrateManager;
|
||||
class UVoxelStrateDefinition;
|
||||
class UVoxelBiomeDefinition;
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
#include "VoxelStrateTypes.h" // FStrateDecoration / FStrateAmbientActor
|
||||
#include "VoxelBiomeDefinition.generated.h"
|
||||
|
||||
// IWYU : utilisé en pointeur seulement ⇒ déclaration avant. Fournie gratuitement par le PCH
|
||||
// partagé jusqu'ici ; `FPSemantics = Precise` nous en sort. / Pointer-only use, so a forward
|
||||
// declaration is enough. The shared PCH used to provide this for free.
|
||||
class UMaterialInterface;
|
||||
|
||||
/**
|
||||
* UVoxelBiomeDefinition — one biome's identity, placement, terrain modulation and content.
|
||||
*/
|
||||
|
||||
@@ -243,4 +243,16 @@ struct FChunkBiomeCache
|
||||
&& X >= ValidMinX && X <= ValidMaxX
|
||||
&& Y >= ValidMinY && Y <= ValidMaxY;
|
||||
}
|
||||
|
||||
/** AUDIT C2 — force a rebuild on the next query. The validity box says nothing about the
|
||||
* FBiomeContext the cells were classified AGAINST, so when the strate layout is rebuilt
|
||||
* (RebuildStrates / an editor live edit) the grid is stale even though the box still
|
||||
* covers the query. Callers that key on GetLayoutVersion() call this on a version change.
|
||||
* Le box de validité ne dit rien du contexte de biome ayant servi à classer les cellules :
|
||||
* après un rebuild de layout, la grille est périmée alors que la boîte couvre encore. */
|
||||
void Invalidate()
|
||||
{
|
||||
ValidMinX = 1.0f; ValidMaxX = -1.0f; // min > max ⇒ Contains() is false everywhere
|
||||
ChunkZ = MIN_int32;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -168,6 +168,43 @@ namespace VoxelHash
|
||||
return X;
|
||||
}
|
||||
|
||||
/**
|
||||
* AUDIT §C1 — décalage de bruit BORNÉ et salé par site. Remplace le motif `SeedF * K`.
|
||||
*
|
||||
* LE BUG QUE ÇA CORRIGE : les sites de bruit s'écrivaient
|
||||
* `WorldX * Freq + (float)Seed * 97.7f`. Le float a 24 bits de mantisse, donc à magnitude `V`
|
||||
* l'ULP vaut `V · 2⁻²³`. Avec `Seed = 10⁷` le terme atteint 10⁹, où l'ULP vaut **117** — la
|
||||
* coordonnée du voxel (qui avance de ~0.02 par voxel) est **entièrement absorbée** et le champ
|
||||
* de bruit devient CONSTANT. Terrain plat. `ChangeSeed` est `BlueprintCallable`, donc un
|
||||
* `FMath::Rand()` suffit à déclencher ça. Ça ne marchait que parce que les seeds restaient petits.
|
||||
*
|
||||
* ⚠️ LE CORRECTIF ÉVIDENT EST FAUX. Borner `SeedF` à 16383 en gardant le `· 97.7` laisse le
|
||||
* terme atteindre 1.6e6, où l'ULP vaut 0.19 — **9.5× le pas par voxel**. Ça rend le bug moins
|
||||
* spectaculaire tout en le laissant vivant, et referme le ticket. C'est le multiplicateur qu'il
|
||||
* faut supprimer, pas le seed qu'il faut réduire.
|
||||
*
|
||||
* CE QUE FAIT CETTE FONCTION : le multiplicateur ne SERT plus à décorréler par amplification —
|
||||
* il IDENTIFIE le site, et c'est le hash qui décorrèle. La sortie est déjà dans les unités
|
||||
* finales, bornée à [0, 16383] : l'ULP y vaut 0.002, soit 10 % d'un pas de voxel.
|
||||
*
|
||||
* ET C'EST PLUS SÛR QU'UN SEEDF BORNÉ PARTAGÉ : avec un offset unique par monde, deux seeds qui
|
||||
* collident donneraient un bruit identique PARTOUT. Salé par site, il faudrait qu'ils
|
||||
* collident sur les ~50 sites à la fois — c'est-à-dire jamais.
|
||||
*
|
||||
* The multiplier no longer decorrelates by amplifying — it IDENTIFIES the site, and the hash
|
||||
* decorrelates. Output is already in final units and bounded, so the ULP is 10% of a voxel step.
|
||||
*
|
||||
* @param SiteKey la constante littérale d'origine (`7.3f`, `97.7f`, …). Gardée VISIBLE au site
|
||||
* d'appel pour que la correspondance avec le code d'avant reste vérifiable à l'œil.
|
||||
*/
|
||||
FORCEINLINE float SeedOffset(uint32 Seed, float SiteKey)
|
||||
{
|
||||
// ×100 puis arrondi : les constantes ont au plus 2 décimales, donc `0.31f` → 31 et
|
||||
// `3.1f` → 310 restent distincts. Le site est une identité entière, pas un flottant.
|
||||
const uint32 Site = (uint32)(SiteKey * 100.0f + 0.5f);
|
||||
return (float)(Mix(Seed ^ (Site * 2654435761u)) & 0x3FFFu); // [0, 16383]
|
||||
}
|
||||
|
||||
// Hash a 2D cell coordinate with a seed → deterministic uint32
|
||||
FORCEINLINE uint32 Cell(int32 CellX, int32 CellY, uint32 Seed)
|
||||
{
|
||||
@@ -205,6 +242,88 @@ namespace VoxelHash
|
||||
}
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// BRUIT CELLULAIRE / CELLULAR (WORLEY) NOISE — 3D
|
||||
//=============================================================================
|
||||
// ⚠️ POURQUOI CE CORPS VIT ICI ET NON DANS VoxelNoise.h.
|
||||
// Il a besoin de `VoxelHash::Mix` / `ToFloat01`, qui vivent dans CE fichier. Faire dépendre
|
||||
// VoxelNoise.h (le socle bas niveau, inclus partout) du header de morphologie de grotte serait une
|
||||
// inversion de dépendance ; dupliquer les 50 lignes serait un FORK d'une fonction pure — exactement
|
||||
// le motif qui a produit `AUDIT §C1` (un correctif appliqué à une copie sur deux). Il monte donc au
|
||||
// point le plus bas qui voit déjà le hash, et le générateur comme la pile d'opérateurs l'appellent.
|
||||
//
|
||||
// Ce corps était `static float CellularNoise3D(const FVector&)` dans VoxelGenerator.cpp, invisible
|
||||
// à la pile d'opérateurs. Déplacement LITTÉRAL : mêmes opérations, même ordre, même passage par
|
||||
// `FVector` (donc par des doubles) — l'égalité binaire du portage TunnelNetwork en dépend.
|
||||
// `UVoxelGenerator`'s copy is now a one-line forwarder; the body moved verbatim.
|
||||
//
|
||||
// Algorithme : distance au point-feature le plus proche dans une grille hachée.
|
||||
// 1. cellule entière du point 2. voisinage 3×3×3 3. rendre (F2 − F1), normalisé ~[-1, 1]
|
||||
// F2−F1 donne des frontières de cellules lisses avec des arêtes entre elles.
|
||||
namespace VoxelNoise
|
||||
{
|
||||
FORCEINLINE float Cellular3D(const FVector& Position)
|
||||
{
|
||||
// Integer cell coordinates
|
||||
int32 CellX = FMath::FloorToInt(Position.X);
|
||||
int32 CellY = FMath::FloorToInt(Position.Y);
|
||||
int32 CellZ = FMath::FloorToInt(Position.Z);
|
||||
|
||||
// Fractional position within cell
|
||||
float FracX = Position.X - CellX;
|
||||
float FracY = Position.Y - CellY;
|
||||
float FracZ = Position.Z - CellZ;
|
||||
|
||||
float F1 = FLT_MAX; // Distance to nearest feature point
|
||||
float F2 = FLT_MAX; // Distance to 2nd nearest
|
||||
|
||||
// Search 3x3x3 neighborhood
|
||||
for (int32 DZ = -1; DZ <= 1; DZ++)
|
||||
{
|
||||
for (int32 DY = -1; DY <= 1; DY++)
|
||||
{
|
||||
for (int32 DX = -1; DX <= 1; DX++)
|
||||
{
|
||||
int32 NX = CellX + DX;
|
||||
int32 NY = CellY + DY;
|
||||
int32 NZ = CellZ + DZ;
|
||||
|
||||
// Hash the neighbor cell to get a feature point position [0,1)
|
||||
// Using three different hash mixes for X, Y, Z offsets
|
||||
uint32 H = VoxelHash::Mix(
|
||||
(uint32)(NX + 0x7FFFFFFF)
|
||||
^ VoxelHash::Mix((uint32)(NY + 0x7FFFFFFF) * 2654435761u)
|
||||
^ VoxelHash::Mix((uint32)(NZ + 0x7FFFFFFF) * 374761393u)
|
||||
);
|
||||
|
||||
float FPX = (float)DX + VoxelHash::ToFloat01(H) - FracX;
|
||||
float FPY = (float)DY + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x12345678u)) - FracY;
|
||||
float FPZ = (float)DZ + VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x9ABCDEF0u)) - FracZ;
|
||||
|
||||
float DistSq = FPX * FPX + FPY * FPY + FPZ * FPZ;
|
||||
|
||||
// Track closest two distances
|
||||
if (DistSq < F1)
|
||||
{
|
||||
F2 = F1;
|
||||
F1 = DistSq;
|
||||
}
|
||||
else if (DistSq < F2)
|
||||
{
|
||||
F2 = DistSq;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// F2 - F1: smooth cell boundaries with ridges between cells
|
||||
// Sqrt for actual distance, then normalize to ~[-1, 1]
|
||||
float Result = FMath::Sqrt(F2) - FMath::Sqrt(F1);
|
||||
// Result is in [0, ~1.0]. Map to [-1, 1] for compatibility with other noise types.
|
||||
return Result * 2.0f - 1.0f;
|
||||
}
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// PER-CHUNK SDF CACHE
|
||||
//=============================================================================
|
||||
|
||||
@@ -45,9 +45,11 @@
|
||||
#include "VoxelTypes.h"
|
||||
#include "VoxelStrateTypes.h" // FStrateDecoration (resolved per dominant biome)
|
||||
#include "VoxelBiomeTypes.h" // FBiomeContext (per-column biome resolve on the worker)
|
||||
#include "Templates/SubclassOf.h" // IWYU : TSubclassOf<AActor> (FRegionActorBucket & co)
|
||||
#include <atomic>
|
||||
#include "VoxelContentManager.generated.h"
|
||||
|
||||
class AActor; // IWYU : pointeur / TWeakObjectPtr / TSubclassOf seulement
|
||||
class UVoxelStrateManager;
|
||||
class UVoxelStrateDefinition;
|
||||
class UVoxelGenerator;
|
||||
|
||||
@@ -0,0 +1,564 @@
|
||||
// VoxelDensityOp.h
|
||||
// LE CONTRAT de la pile d'opérateurs de densité / THE density operator stack CONTRACT.
|
||||
// Phase 1 de OPSTACK-PLAN.md. HEADER SEUL — rien n'est encore branché dans GetDensityAt.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// POURQUOI / WHY
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// UVoxelGenerator::GetDensityAt est aujourd'hui un `switch` sur 8 ECaveGeneratorType, chacun
|
||||
// possédant sa fonction de densité et son struct de params. Conséquences : une nouvelle idée de
|
||||
// monde coûte ~6 sites d'édition, et surtout LES IDÉES NE PEUVENT PAS SE COMBINER — un archétype
|
||||
// possède le voxel entier. On ne peut pas écrire « une strate de surface dont les montagnes
|
||||
// contiennent un réseau de salles, avec des îles flottantes dans le vide au-dessus », à aucun prix.
|
||||
//
|
||||
// GetDensityAt is today a `switch` over 8 ECaveGeneratorType, each owning a bespoke density
|
||||
// function and param struct. A new world idea costs ~6 edit sites, and — the real problem —
|
||||
// IDEAS CANNOT COMBINE: one archetype owns the whole voxel.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// ⚠️ CE N'EST PAS L'ANCIEN SYSTÈME DE « ROOM OPERATIONS » / THIS IS NOT THE OLD ROOM-OPS SYSTEM
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// UVoxelTerrainOpDefinition (Terrace, Ribbing, Cliff, Scallop, Overhang, Arch, Column, Pit…) ne
|
||||
// sait que PERTURBER une densité près d'une surface qui existe déjà. Il ne décide jamais ce que le
|
||||
// champ EST — cette décision vit dans le `switch`. Une pile d'opérateurs qui se contenterait de
|
||||
// cela aurait reconstruit le switch avec des étapes en plus.
|
||||
//
|
||||
// D'où QUATRE RÔLES, dont l'ancien système n'occupait que le troisième :
|
||||
//
|
||||
// 1. FIELD SOURCE — fabrique un champ À PARTIR DE RIEN. C'est ce qui fait qu'une grotte est
|
||||
// une grotte et qu'un monde ouvert est un monde ouvert. Chaque archétype
|
||||
// d'aujourd'hui est fondamentalement l'une de ces sources.
|
||||
// 2. COMBINER — comment deux champs fusionnent (min/max/smooth/mask). C'EST le rôle qui
|
||||
// achète la composition ; sans lui il n'y a pas de refactor.
|
||||
// 3. DETAIL MODIFIER — l'ancien système, rétrogradé à un rôle sur quatre. Inchangé.
|
||||
// 4. STRUCTURAL POST — spine (0,0) → seal → passages → diff layer. Des INVARIANTS de monde,
|
||||
// pas des choix créatifs : toujours ajoutés, dans cet ordre, jamais
|
||||
// omissibles par l'auteur.
|
||||
//
|
||||
// The old system only ever had role 3. Roles 1 and 2 are the new thing, and role 4 is what keeps
|
||||
// the descent structure intact no matter what an author assembles.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// LA CLÉ DE VOÛTE : EffectOverBox — direction, pas intervalle / THE KEYSTONE: direction, not intervals
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// La version « complète » d'une borne rendrait un intervalle numérique. NE PAS COMMENCER LÀ.
|
||||
// Presque tout opérateur existant est UNIDIRECTIONNEL : il ne fait que creuser, ou que remplir.
|
||||
// Cela suffit à reproduire GÉNÉRIQUEMENT chaque garde écrite à la main dans ClassifyTile :
|
||||
//
|
||||
// « passages ⇒ bCanSolid = false » EST CarveOnly
|
||||
// « ponts/arêtes ⇒ bCanAir = false » EST FillOnly
|
||||
// « aucun passage près de cette boîte » EST Identity
|
||||
//
|
||||
// Donc la Phase 1 n'a besoin d'AUCUNE borne numérique et obtient déjà toute la propriété de
|
||||
// sûreté. Les intervalles sont un resserrement ultérieur pour le coût de génération, pas un
|
||||
// prérequis de correction. C'est ce qui rend le premier pas petit.
|
||||
//
|
||||
// Phase 1 needs NO numeric bounds and already gets the whole safety property. Intervals are a
|
||||
// later tightening for gen cost, not a correctness prerequisite.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// LE GROS LOT PERF : les strates de grotte ne sautent AUCUNE tuile aujourd'hui
|
||||
// THE PERF PRIZE: cave strates skip ZERO tiles today
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// ClassifyTile ne sait prouver que les gaps de bedrock et SurfaceWorld ; tout le reste tombe sur
|
||||
// `return EVoxelTileClass::Mixed; // archétype cave […] pas prouvable en v1`. TunnelNetwork, Maze,
|
||||
// VerticalShafts, FloatingIslands, FlatPlain, CrystalChamber et Underwater ne captent donc RIEN du
|
||||
// gain T1.d (84 % des générations vides, −44 % de CPU worker). Écrire un prouveur sur mesure par
|
||||
// archétype a toujours été trop cher — EffectOverBox EST le mécanisme générique qui le rend gratuit :
|
||||
// une source à graphe de salles qui rend Identity quand aucune borne de salle ni de tunnel n'atteint
|
||||
// la boîte rend le bedrock profond sautable pour la première fois.
|
||||
//
|
||||
// **Traiter cela comme un livrable explicite de chaque portage, pas comme un effet de bord.**
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// ÉTAT / STATUS
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// Phase 1. Le premier archétype (Maze) EST porté, dans VoxelDensityOpStack.{h,cpp} — mais
|
||||
// **GetDensityAt et ClassifyTile ne sont PAS touchés** : le `switch` reste le seul chemin qui
|
||||
// alimente le jeu. La pile est validée par un test qui la compare à GetMazeDensity point par point.
|
||||
// Le branchement dans GetDensityAt attend un build vert.
|
||||
//
|
||||
// Phase 1. Maze IS ported (VoxelDensityOpStack.{h,cpp}) but **GetDensityAt and ClassifyTile are NOT
|
||||
// touched** — the switch is still the only path feeding the game. The stack is validated by a test
|
||||
// that compares it to GetMazeDensity point by point. Wiring it in waits for a green build.
|
||||
//
|
||||
// NOTE sur les UENUM : ces types sont volontairement du C++ nu (pas d'UHT, pas de .generated.h).
|
||||
// Ils deviendront UENUM/USTRUCT en Phase 3, quand les opérateurs deviendront des data assets et
|
||||
// auront besoin d'être édités dans l'éditeur. Les promouvoir plus tôt n'achèterait rien et
|
||||
// ajouterait une étape UHT à chaque itération.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "VoxelTypes.h" // CHUNK_SIZE, EVoxelTileClass
|
||||
|
||||
struct FBiomeContext;
|
||||
|
||||
//=============================================================================
|
||||
// LES QUATRE RÔLES / THE FOUR ROLES
|
||||
//=============================================================================
|
||||
// Le rôle n'est pas décoratif : le compilateur de pile s'en sert pour ORDONNER. Les
|
||||
// StructuralPost sont toujours ajoutés en dernier, dans l'ordre fixe spine → seal → passage →
|
||||
// diff, quoi que l'auteur ait assemblé. Un auteur ne peut pas les omettre : la descente doit
|
||||
// rester possible, les seals doivent tenir, les passages doivent percer, les éditions du joueur
|
||||
// gagnent toujours.
|
||||
enum class EVoxelOpRole : uint8
|
||||
{
|
||||
// Rôle 1 — fabrique un champ à partir de rien. Une pile en a au moins un (sa racine).
|
||||
FieldSource,
|
||||
|
||||
// Rôle 2 — fusionne le champ précédent avec le suivant. Voir EVoxelOpCombine.
|
||||
Combiner,
|
||||
|
||||
// Rôle 3 — perturbe un champ existant près de sa surface (l'ancien UVoxelTerrainOpDefinition).
|
||||
DetailModifier,
|
||||
|
||||
// Rôle 4 — invariants de monde. Ajoutés automatiquement, ordre fixe, non omissibles.
|
||||
StructuralPost,
|
||||
};
|
||||
|
||||
//=============================================================================
|
||||
// COMPOSITION / COMBINERS
|
||||
//=============================================================================
|
||||
// Vocabulaire délibérément petit, et il réutilise ce qui existe déjà
|
||||
// (VoxelSDF::SmoothMin / SmoothMax).
|
||||
//
|
||||
// ⚠️⚠️ RAPPEL DE SIGNE — LA source n°1 de confusion du plugin, et il y a DEUX conventions en jeu.
|
||||
// Lire ceci en entier avant d'écrire un opérateur.
|
||||
//
|
||||
// • CANAL DENSITÉ, à l'intérieur de la pile : convention INTERNE, **POSITIF = SOLIDE**.
|
||||
// C'est celle dans laquelle CHAQUE fonction d'archétype est écrite aujourd'hui. La négation
|
||||
// vers la convention marching-cubes (négatif = solide) se fait UNE FOIS, tout à la fin, par
|
||||
// l'appelant. Donc ici : « ajouter du solide » = MAX, « creuser de l'air » = MIN.
|
||||
//
|
||||
// • CANAL SDF : convention SDF standard, **NÉGATIF = À L'INTÉRIEUR de la primitive**.
|
||||
// Réunir deux formes = MIN (c'est `SmoothMin`, ce que fait déjà le code pour salle+puits).
|
||||
// Le sens de « min » est donc l'INVERSE d'un canal à l'autre. Ce n'est pas une incohérence :
|
||||
// un SDF décrit une FORME, une densité décrit de la MATIÈRE.
|
||||
//
|
||||
// DENSITY channel inside the stack: INTERNAL convention, **POSITIVE = SOLID** (what every
|
||||
// archetype body already uses; the MC negate happens once, at the end, in the caller). So
|
||||
// "add solid" is max(), "carve air" is min().
|
||||
// SDF channel: standard SDF, **NEGATIVE = INSIDE the primitive**; unioning shapes is min().
|
||||
// The meaning of min() is therefore opposite between the two channels — an SDF describes a
|
||||
// SHAPE, a density describes MATTER.
|
||||
enum class EVoxelOpCombine : uint8
|
||||
{
|
||||
Replace, // ignore l'entrée — racine de pile (heightfield, densité de base)
|
||||
Union, // max() sur la DENSITÉ — ajoute du solide : ponts, îles, colonnes
|
||||
Subtract, // min() sur la DENSITÉ — creuse de l'air : salles, tunnels, passages, spine
|
||||
SmoothUnion, // VoxelSDF::SmoothMin(k) — jonctions organiques
|
||||
SmoothSubtract, // VoxelSDF::SmoothMax(k)
|
||||
Add, // accumulation scalaire — termes de bruit / rugosité
|
||||
Mask, // met à l'échelle l'opérateur SUIVANT par un champ [0,1]
|
||||
// (poids de biome, porte de pente, relief, profondeur).
|
||||
// C'est Mask qui achète le plus d'expressivité : « cet opérateur, mais
|
||||
// seulement dans les régions à fort relief » devient de la composition
|
||||
// au lieu d'une garde codée en dur dans chaque opérateur.
|
||||
};
|
||||
|
||||
//=============================================================================
|
||||
// EFFET SUR UNE BOÎTE / EFFECT OVER A BOX
|
||||
//=============================================================================
|
||||
// CONSERVATIF PAR CONSTRUCTION. Rendre `Both` est TOUJOURS SÛR (ça ne coûte que du CPU) ;
|
||||
// rendre le mauvais est un TROU — pas de géométrie, pas de collision, invisible jusqu'à ce
|
||||
// qu'un joueur tombe au travers. En cas de doute : `Both`.
|
||||
//
|
||||
// CONSERVATIVE BY CONSTRUCTION. Returning `Both` is ALWAYS SAFE (it only costs CPU); returning
|
||||
// the wrong one is a HOLE. When unsure: `Both`.
|
||||
enum class EVoxelOpEffect : uint8
|
||||
{
|
||||
// Prouvablement aucun effet sur cette boîte. C'est le early-out qui rend la pile rapide,
|
||||
// et c'est ce qui rendra le bedrock profond sautable pour les strates de grotte.
|
||||
Identity,
|
||||
|
||||
// Ne peut que pousser la densité vers l'AIR ⇒ tue l'hypothèse « tout solide ».
|
||||
CarveOnly,
|
||||
|
||||
// Ne peut que pousser la densité vers le SOLIDE ⇒ tue l'hypothèse « tout air ».
|
||||
FillOnly,
|
||||
|
||||
// Non contraint ⇒ tue les deux hypothèses.
|
||||
Both,
|
||||
};
|
||||
|
||||
//=============================================================================
|
||||
// CONTEXTE DE CHUNK / CHUNK CONTEXT
|
||||
//=============================================================================
|
||||
// Miroir de ce que le bloc thread_local CP_* résout aujourd'hui dans GetDensityAt.
|
||||
//
|
||||
// ⚠️ LayoutVersion est ici PAR CONSTRUCTION, pas par politesse. AUDIT C2 : trois caches
|
||||
// existants (CP_Chunk, OC_Chunk, BM_Chunk) sont clés sur ChunkCoord SEUL, donc après un
|
||||
// RebuildStrates ou une édition à chaud un worker dont le cache est encore chaud pour ce chunk
|
||||
// saute le refetch et génère avec les ANCIENS params. En faisant porter LayoutVersion par le
|
||||
// contexte, un nouvel opérateur ne PEUT PAS oublier de l'inclure dans sa clé.
|
||||
//
|
||||
// LayoutVersion is here BY CONSTRUCTION, not by politeness — see AUDIT C2. Carrying it in the
|
||||
// context means a new op CANNOT forget to put it in its cache key.
|
||||
struct FVoxelOpContext
|
||||
{
|
||||
FIntVector ChunkCoord = FIntVector::ZeroValue;
|
||||
|
||||
// Pas d'échantillonnage LOD (1/2/4…). Un opérateur a le droit de se simplifier quand Step
|
||||
// est grand — c'est le contrat T2.b : le bruit volumétrique par voxel perd des octaves au
|
||||
// loin, le bruit de champ XY délibérément non (il alimente des caches box-validés partagés).
|
||||
int32 Step = 1;
|
||||
|
||||
uint32 Seed = 0;
|
||||
|
||||
// Compteur de génération du layout (UVoxelStrateManager::GetLayoutVersion()).
|
||||
// DOIT faire partie de toute clé de cache. Voir AUDIT C2.
|
||||
uint32 LayoutVersion = 0;
|
||||
|
||||
// Bornes Z de la strate en coords VOXEL (pas cm).
|
||||
float StrateTopWorldZ = 0.0f;
|
||||
float StrateBottomWorldZ = 0.0f;
|
||||
|
||||
// null = cette strate n'a pas de champ de biome.
|
||||
const FBiomeContext* Biome = nullptr;
|
||||
};
|
||||
|
||||
//=============================================================================
|
||||
// L'ÉTAT QUI TRAVERSE LA PILE / THE STATE THE STACK THREADS THROUGH
|
||||
//=============================================================================
|
||||
// DEUX canaux, pas un. Ce n'est pas de la généralité gratuite — c'est ce que le code fait déjà :
|
||||
//
|
||||
// CaveSDF = EvaluateSDFCached(salles + tunnels) ← espace SDF
|
||||
// CaveSDF = SmoothMin(CaveSDF, PitSDF, BlendK) ← espace SDF
|
||||
// CaveSDF = SmoothMin(CaveSDF, ChimneySDF, BlendK) ← espace SDF
|
||||
// → UN SEUL carve à la fin : Density -= CarveFactor · BaseDensity · 2
|
||||
//
|
||||
// Maze, VerticalShafts et FloatingIslands ont la même forme, et TROIS d'entre eux appliquent la
|
||||
// rugosité au **SDF** (`MazeSDF += bruit·Rough`), pas à la densité. Sur la densité, le même bruit
|
||||
// est mis à l'échelle par le gradient local : effet visiblement différent.
|
||||
//
|
||||
// Avec un seul canal, un opérateur ne peut qu'ÉCRASER le précédent — les jonctions SmoothMin
|
||||
// (salle↔puits, et demain « un graphe de salles creusé DANS une montagne ») sont impossibles.
|
||||
// Un `SmoothMin` entre deux SOURCES différentes est précisément ce qui fait qu'une idée composée
|
||||
// a l'air d'appartenir au lieu au lieu d'y avoir été percée. Coût : un float.
|
||||
//
|
||||
// Two channels, not one — because that is what the code already does, and because SmoothMin between
|
||||
// two different SOURCES is precisely what makes a composed idea look like it belongs there rather
|
||||
// than like a hole punched in something else. Cost: one float.
|
||||
struct FVoxelOpSample
|
||||
{
|
||||
// Convention INTERNE : POSITIF = SOLIDE. Négation vers MC une seule fois, par l'appelant.
|
||||
// INTERNAL convention: POSITIVE = SOLID. Negated to MC once, by the caller.
|
||||
float Density = 0.0f;
|
||||
|
||||
// Convention SDF standard : NÉGATIF = à l'intérieur de la primitive.
|
||||
// FLT_MAX = « aucune surface à proximité » (l'état initial, et le early-out des sources
|
||||
// placées quand aucune primitive n'atteint ce voxel).
|
||||
// FLT_MAX = "no surface nearby" — the initial state, and the early-out placed sources use.
|
||||
float Sdf = FLT_MAX;
|
||||
};
|
||||
|
||||
//=============================================================================
|
||||
// L'INTERFACE / THE INTERFACE
|
||||
//=============================================================================
|
||||
// Trois méthodes, et elles FORMALISENT CE QUE LE CODE FAIT DÉJÀ À LA MAIN : chaque archétype
|
||||
// hisse déjà son travail constant-par-chunk dans un cache thread_local (= PrepareChunk), évalue
|
||||
// à bas coût par voxel (= Eval), et possède déjà dans ClassifyTile une déclaration écrite à la
|
||||
// main de ce qu'il peut faire à une tuile (= EffectOverBox). Ce n'est pas une nouvelle
|
||||
// discipline, c'est la discipline existante, nommée.
|
||||
class IVoxelDensityOp
|
||||
{
|
||||
public:
|
||||
virtual ~IVoxelDensityOp() = default;
|
||||
|
||||
virtual EVoxelOpRole GetRole() const = 0;
|
||||
|
||||
/**
|
||||
* Hisser ici TOUT le travail constant sur le chunk : listes de salles, grilles de biome,
|
||||
* caches de colonnes, cuissons de treillis. Appelé une fois par chunk et par worker.
|
||||
* C'est ici que déménagent les caches thread_local d'aujourd'hui.
|
||||
*
|
||||
* THREADING : appelé sur des workers. L'opérateur ne doit écrire QUE son propre état
|
||||
* par-chunk ; le Generator / le Mesher / le StrateManager restent en LECTURE SEULE
|
||||
* (invariant ARCHITECTURE §8.10). Toute clé de cache DOIT inclure Ctx.LayoutVersion.
|
||||
*/
|
||||
virtual void PrepareChunk(const FVoxelOpContext& Ctx) = 0;
|
||||
|
||||
/**
|
||||
* Par voxel. `InOut` est l'état que la pile a produit jusqu'ici (voir FVoxelOpSample).
|
||||
* Coordonnées en VOXELS, pas en cm.
|
||||
*
|
||||
* INVARIANCE DE FENÊTRE (ARCHITECTURE §8.4) : fonction PURE de (coords monde, seed, layout).
|
||||
* Le même point évalué depuis une autre tuile, un autre ordre, un autre thread doit rendre le
|
||||
* float BIT-IDENTIQUE. Pas « proche » : 1 ULP d'écart entre deux fenêtres est une couture
|
||||
* visible, et en multijoueur une divergence de monde. Le test
|
||||
* VoxelForge.Determinism.DensityPurity vérifie cela.
|
||||
*/
|
||||
virtual void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const = 0;
|
||||
|
||||
/**
|
||||
* CONSERVATIF. Phase 1 : direction seule. Phase 3 : surcharge avec intervalle numérique.
|
||||
* Rendre Both est toujours sûr ; rendre le mauvais est un trou.
|
||||
*
|
||||
* Le contrat est « conservatif », pas « forme close » : un opérateur A LE DROIT
|
||||
* D'ÉCHANTILLONNER pour répondre. C'est exactement ce que fait ClassifyTile aujourd'hui pour
|
||||
* SurfaceWorld — il évalue ComputeSurfaceColumn sur le treillis EXACT du mesher, mêmes
|
||||
* fonctions, mêmes floats, donc verdict exact plutôt qu'estimé. Cela DOIT survivre au portage.
|
||||
*
|
||||
* The contract is "conservative", not "closed-form": an op MAY sample to answer.
|
||||
*
|
||||
* ⚠️ SIMPLIFICATION DE PHASE 1, à connaître : une source qui n'écrit QUE le canal SDF ne touche
|
||||
* pas la densité par elle-même — c'est l'opérateur de conversion (`FSdfCarve`/`FSdfFill`) qui le
|
||||
* fait. Répondre honnêtement demanderait de propager un INTERVALLE de SDF à travers la requête
|
||||
* de boîte, exactement comme `Eval` propage une valeur de SDF. En attendant, **la source répond
|
||||
* pour la paire** (elle rend `CarveOnly`/`FillOnly` quand une primitive atteint la boîte,
|
||||
* `Identity` sinon) et la conversion rend `Identity`. Conservatif et correct ; à remplacer par
|
||||
* une requête de boîte à deux canaux quand les intervalles numériques arriveront (Phase 3).
|
||||
*
|
||||
* PHASE 1 SIMPLIFICATION: an SDF-only source answers for itself AND its conversion op; the
|
||||
* conversion returns Identity. Answering honestly needs an SDF INTERVAL threaded through the box
|
||||
* query, mirroring how Eval threads an SDF value. Conservative and correct meanwhile.
|
||||
*/
|
||||
virtual EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const = 0;
|
||||
|
||||
/**
|
||||
* ⚠️ LE PLIAGE QUI PORTE DES NOMBRES — `OPSTACK-DECOMPOSITION §0.2`, et le plus gros poste de
|
||||
* perf du plan.
|
||||
*
|
||||
* La direction seule ne suffit pas pour les opérateurs FIELDÉS. Un carve à seuil de bruit (les
|
||||
* vers de TunnelNetwork, la rugosité de paroi) n'a AUCUNE borne spatiale : il rend `CarveOnly`
|
||||
* sur CHAQUE boîte de CHAQUE strate qui l'active, donc il tue l'hypothèse `AllSolid` partout et
|
||||
* l'archétype ne saute pas une tuile. Aucun raffinement de `EffectOverBox` ne peut le récupérer,
|
||||
* parce que la réponse « oui, je peux creuser ici » est VRAIE.
|
||||
*
|
||||
* **Mais son AMPLITUDE est bornée, et souvent triviale** : pour un ver, `t ∈ [0,1]` et
|
||||
* `Mask ∈ [0,1]`, donc il ne peut déplacer la densité vers l'air que de `WormStrength` au plus.
|
||||
* Si le roc est solide d'une marge SUPÉRIEURE à la somme de tous les carves restants, la boîte
|
||||
* est prouvablement pleine — quel que soit le bruit.
|
||||
*
|
||||
* D'où deux nombres, en unités de DENSITÉ (convention interne, positif = solide) :
|
||||
* • `MaxCarveOverBox` — de combien AU PLUS cet opérateur peut baisser la densité sur la boîte,
|
||||
* • `MaxFillOverBox` — de combien AU PLUS il peut la monter.
|
||||
*
|
||||
* **`FLT_MAX` = « je ne sais pas », et c'est le DÉFAUT.** Un opérateur qui ne redéfinit rien se
|
||||
* comporte donc EXACTEMENT comme avant ce changement : le pliage retire `FLT_MAX` à la marge,
|
||||
* elle passe sous zéro, l'hypothèse meurt. Les treize tests d'équivalence et
|
||||
* `VoxelForge.OpStack.BoxVerdictFold` ne bougent pas d'un verdict.
|
||||
*
|
||||
* ⚠️ SENS DE L'ERREUR : SUR-estimer une amplitude coûte du CPU (une tuile maillée pour rien) ;
|
||||
* SOUS-estimer produit un TROU. Comme partout ailleurs dans ce fichier, en cas de doute rendre
|
||||
* `FLT_MAX`. Ce n'est pas une borne « raisonnable », c'est une borne PROUVÉE ou rien.
|
||||
*
|
||||
* The fold carries NUMBERS, not just directions. A fielded noise carve has no spatial bound but
|
||||
* its AMPLITUDE is bounded, so "the rock is solid by more than the sum of every remaining carve"
|
||||
* becomes provable. FLT_MAX means "unknown" and is the default, so every existing op is
|
||||
* unchanged. Over-estimating costs CPU; under-estimating is a hole.
|
||||
*/
|
||||
virtual float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
|
||||
{
|
||||
return FLT_MAX;
|
||||
}
|
||||
|
||||
virtual float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
|
||||
{
|
||||
return FLT_MAX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pour un opérateur FORÇANT (celui dont `ClassifyBox` rend autre chose que `Mixed`) : de combien
|
||||
* la densité est-elle garantie du bon côté de zéro, PARTOUT dans la boîte ?
|
||||
*
|
||||
* C'est l'autre moitié du pliage numérique. `MaxCarveOverBox` dit ce qu'on peut RETIRER ; ceci
|
||||
* dit ce qu'il y avait à retirer. Sans les deux, la soustraction n'a pas de premier terme.
|
||||
*
|
||||
* Exemple, et c'est LE cas qui compte : `FConstantFieldSource` pose `Density = BaseDensity`
|
||||
* partout. Sa marge est donc exactement `BaseDensity`. Un ver à `WormStrength = 0.6` sur un roc
|
||||
* à `BaseDensity = 1.0` laisse 0.4 de marge ⇒ la boîte reste prouvablement pleine.
|
||||
*
|
||||
* **0 = « je ne sais pas », et c'est le DÉFAUT** : la marge tombe à zéro, le premier carve la
|
||||
* fait passer sous zéro, l'hypothèse meurt — le comportement d'avant, à l'identique.
|
||||
*/
|
||||
virtual float ForcedMarginOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
|
||||
{
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
/**
|
||||
* OPÉRATEURS FORÇANTS. Certains opérateurs ne « déplacent » pas la densité d'entrée : ils
|
||||
* l'ÉCRASENT. La question « dans quelle direction peux-tu bouger ce champ ? » n'a alors pas de
|
||||
* sens ; la bonne question est « sais-tu prouver que toute cette boîte est d'un seul côté,
|
||||
* QUELLE QUE SOIT l'entrée ? ».
|
||||
*
|
||||
* Deux familles répondent autre chose que Mixed :
|
||||
* • les SOURCES (rôle 1) — elles posent le champ, donc elles le savent par construction ;
|
||||
* • les op STRUCTURELS forçants — typiquement ApplyBoundarySeal, qui à l'intérieur de sa
|
||||
* bande fait `Max(Density, SealFactor·BaseDensity)` avec SealFactor > 0 : le résultat est
|
||||
* solide garanti quoi qu'il y ait eu avant. C'est exactement ce que ClassifyTile encode
|
||||
* aujourd'hui avec « bande de seal ⇒ bCanAir = false » — et un simple FillOnly ne suffirait
|
||||
* PAS à le reproduire (voir VF_FoldOp plus bas).
|
||||
*
|
||||
* Par défaut Mixed = « je ne sais pas », toujours sûr. Une source à primitives placées (graphe
|
||||
* de salles, îles, puits) répond en testant ses bornes ; une source heightfield répond en
|
||||
* échantillonnant ses colonnes sur le treillis exact, exactement comme aujourd'hui.
|
||||
*
|
||||
* FORCING OPS. Some ops do not *move* the input density, they *overwrite* it. Default Mixed =
|
||||
* "I don't know", always safe. The boundary seal is the non-source example, and it is the
|
||||
* reason this method exists at all rather than being folded into EffectOverBox.
|
||||
*/
|
||||
virtual EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
|
||||
{
|
||||
return EVoxelTileClass::Mixed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Déclare si Eval dépend de Z. Les opérateurs purs en XY reçoivent le traitement du cache de
|
||||
* colonnes T1.a de façon GÉNÉRIQUE, au lieu que SurfaceWorld en ait un sur mesure.
|
||||
*
|
||||
* ⚠️ Le cache de colonnes est clé sur (boîte XY, StrateKey, Seed) SANS ChunkZ et il est
|
||||
* partagé sur TOUTE la pile verticale de chunks. Mettre une donnée dépendante de Z dans un
|
||||
* opérateur qui se déclare XY-pur corrompt silencieusement chaque chunk de la colonne, et
|
||||
* ValidateDeterminism — qui échantillonne le long d'une frontière en X — ne le verrait pas.
|
||||
*/
|
||||
virtual bool IsXYPure() const { return false; }
|
||||
};
|
||||
|
||||
//=============================================================================
|
||||
// LE PLIAGE : comment la pile devient un verdict de tuile
|
||||
// THE FOLD: how a stack becomes a tile verdict
|
||||
//=============================================================================
|
||||
// C'est l'algorithme générique qui remplace le ClassifyTile écrit à la main, et il REPRODUIT
|
||||
// EXACTEMENT le comportement d'aujourd'hui — vérifié ligne à ligne contre VoxelGenerator.cpp :
|
||||
//
|
||||
// source gap bedrock → ClassifyBox = AllSolid
|
||||
// source SurfaceWorld → ClassifyBox échantillonne les colonnes sur le treillis exact
|
||||
// ApplyPassageCarving → CarveOnly (tue AllSolid) ≡ « AnyPassageNearBox ⇒ bCanSolid=false »
|
||||
// ApplyOriginSpine → CarveOnly (tue AllSolid) ≡ le test cercle/boîte XY
|
||||
// ApplyBoundarySeal → ClassifyBox = AllSolid DANS sa bande (opérateur forçant),
|
||||
// FillOnly ailleurs ≡ « bande de seal ⇒ bCanAir=false »
|
||||
// disturbances chasms → CarveOnly ≡ « ChasmDensity > 0 ⇒ bCanSolid=false »
|
||||
// disturbances ponts/arêtes → FillOnly ≡ « Bridge/RidgeDensity > 0 ⇒ bCanAir=false »
|
||||
// diff layer → Both si des mods touchent la boîte, sinon Identity
|
||||
// ≡ « HasAnyModInChunkRange ⇒ Mixed »
|
||||
//
|
||||
// et le verdict final « exactement une hypothèse survit, sinon Mixed » est littéralement le
|
||||
// `if (bCanSolid == bCanAir) return Mixed;` de la fin de ClassifyTile.
|
||||
//
|
||||
// This fold reproduces today's hand-written ClassifyTile exactly — verified line by line against
|
||||
// VoxelGenerator.cpp. That correspondence is the evidence that the abstraction fits this codebase
|
||||
// rather than being imposed on it.
|
||||
|
||||
/** Les deux hypothèses que ClassifyTile poursuit, sous forme d'état pliable. */
|
||||
struct FVoxelBoxHypotheses
|
||||
{
|
||||
bool bCanBeAllSolid = true;
|
||||
bool bCanBeAllAir = true;
|
||||
|
||||
/**
|
||||
* ⚠️ LES DEUX NOMBRES DU PLIAGE (`OPSTACK-DECOMPOSITION §0.2`).
|
||||
* `SolidMargin` = de combien la densité est encore garantie AU-DESSUS de zéro partout dans la
|
||||
* boîte, SOUS l'hypothèse « tout solide ». Un opérateur forçant la pose ; chaque carve en retire
|
||||
* son amplitude maximale ; quand elle n'est plus strictement positive, l'hypothèse meurt.
|
||||
* `AirMargin` est son miroir.
|
||||
*
|
||||
* **Elles valent 0 sur un état neuf, et c'est ce qui rend le changement rétro-compatible :**
|
||||
* sans opérateur forçant qui déclare une marge, le premier carve fait `0 − FLT_MAX < 0` et tue
|
||||
* l'hypothèse — le comportement exact d'avant le pliage numérique.
|
||||
*/
|
||||
float SolidMargin = 0.0f;
|
||||
float AirMargin = 0.0f;
|
||||
|
||||
bool IsDead() const { return !bCanBeAllSolid && !bCanBeAllAir; }
|
||||
|
||||
/** Verdict final : exactement une hypothèse doit survivre. Égalité = prudence ⇒ Mixed. */
|
||||
EVoxelTileClass Resolve() const
|
||||
{
|
||||
if (bCanBeAllSolid == bCanBeAllAir) { return EVoxelTileClass::Mixed; }
|
||||
return bCanBeAllSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir;
|
||||
}
|
||||
};
|
||||
|
||||
/** Poser l'état depuis un verdict FORÇANT (source, ou seal dans sa bande) : l'opérateur écrase
|
||||
* l'entrée, donc il écrase aussi tout ce que la pile avait conclu avant lui. Un verdict « tout
|
||||
* air » affirme du même coup « pas tout solide », et réciproquement. */
|
||||
/** @param Margin de combien la densité est garantie du bon côté de zéro dans toute la boîte.
|
||||
* 0 (le défaut) = « je ne sais pas » ⇒ comportement d'avant le pliage numérique. */
|
||||
FORCEINLINE void VF_ForceHypotheses(FVoxelBoxHypotheses& H, EVoxelTileClass ForcedVerdict,
|
||||
float Margin = 0.0f)
|
||||
{
|
||||
switch (ForcedVerdict)
|
||||
{
|
||||
case EVoxelTileClass::AllSolid: H.bCanBeAllSolid = true; H.bCanBeAllAir = false;
|
||||
H.SolidMargin = Margin; H.AirMargin = 0.0f; break;
|
||||
case EVoxelTileClass::AllAir: H.bCanBeAllSolid = false; H.bCanBeAllAir = true;
|
||||
H.SolidMargin = 0.0f; H.AirMargin = Margin; break;
|
||||
case EVoxelTileClass::Mixed:
|
||||
default: H.bCanBeAllSolid = false; H.bCanBeAllAir = false;
|
||||
H.SolidMargin = 0.0f; H.AirMargin = 0.0f; break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plier l'effet d'un opérateur dans l'état. **Monotone : ne fait que tuer**, jamais ressusciter —
|
||||
* c'est la propriété de sûreté, et le pliage numérique ne l'affaiblit pas : une marge ne peut que
|
||||
* DESCENDRE, jamais remonter, en dehors d'un opérateur forçant.
|
||||
*
|
||||
* ⚠️ `MaxCarve` / `MaxFill` valent `FLT_MAX` par défaut = « amplitude inconnue ». La soustraction
|
||||
* fait alors passer la marge très en dessous de zéro et l'hypothèse meurt, exactement comme la
|
||||
* version purement directionnelle de ce pliage. Aucun opérateur existant ne change de verdict.
|
||||
* (Arithmétique volontairement laissée en float sans garde : `0 − FLT_MAX` vaut `−FLT_MAX`,
|
||||
* `−FLT_MAX − FLT_MAX` sature à `−inf`, et `−inf > 0` est faux. Pas de NaN possible, les deux
|
||||
* termes étant de même signe.)
|
||||
*/
|
||||
FORCEINLINE void VF_FoldEffect(FVoxelBoxHypotheses& H, EVoxelOpEffect Effect,
|
||||
float MaxCarve = FLT_MAX, float MaxFill = FLT_MAX)
|
||||
{
|
||||
switch (Effect)
|
||||
{
|
||||
case EVoxelOpEffect::Identity:
|
||||
break;
|
||||
|
||||
case EVoxelOpEffect::CarveOnly:
|
||||
H.SolidMargin -= MaxCarve;
|
||||
if (!(H.SolidMargin > 0.0f)) { H.bCanBeAllSolid = false; }
|
||||
break;
|
||||
|
||||
case EVoxelOpEffect::FillOnly:
|
||||
H.AirMargin -= MaxFill;
|
||||
if (!(H.AirMargin > 0.0f)) { H.bCanBeAllAir = false; }
|
||||
break;
|
||||
|
||||
case EVoxelOpEffect::Both:
|
||||
default:
|
||||
H.SolidMargin -= MaxCarve;
|
||||
if (!(H.SolidMargin > 0.0f)) { H.bCanBeAllSolid = false; }
|
||||
H.AirMargin -= MaxFill;
|
||||
if (!(H.AirMargin > 0.0f)) { H.bCanBeAllAir = false; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plier UN opérateur. L'ORDRE COMPTE ICI, et c'est délibéré : un opérateur forçant écrase ce que
|
||||
* la pile avait conclu AVANT lui, tandis que les opérateurs qui suivent continuent de s'appliquer.
|
||||
*
|
||||
* Exemple à garder en tête, parce qu'il est le piège : sur une boîte entièrement dans la bande de
|
||||
* seal, la source dit peut-être « tout air » (on est au-dessus du terrain), puis le seal FORCE
|
||||
* « tout solide » — verdict AllSolid, comme aujourd'hui. Si le seal ne savait dire que FillOnly,
|
||||
* on obtiendrait « les deux hypothèses mortes ⇒ Mixed » : pas un trou, mais la perte pure et
|
||||
* simple d'une des tuiles triviales que T1.d sait sauter. C'est pour cela que ClassifyBox existe.
|
||||
*
|
||||
* Réciproquement, un passage qui traverse cette même boîte rend CarveOnly APRÈS le seal et retue
|
||||
* l'hypothèse solide ⇒ Mixed. Identique au code actuel, où la garde passage et la garde seal se
|
||||
* neutralisent en `bCanSolid == bCanAir`.
|
||||
*
|
||||
* ORDER MATTERS HERE, deliberately: a forcing op overwrites what the stack concluded before it,
|
||||
* while ops after it still apply. This is what lets the seal recover an AllSolid verdict that a
|
||||
* pure FillOnly would have thrown away, while still letting a passage take it back.
|
||||
*/
|
||||
FORCEINLINE void VF_FoldOp(FVoxelBoxHypotheses& H, const IVoxelDensityOp& Op,
|
||||
const FBox& VoxelBox, const FVoxelOpContext& Ctx)
|
||||
{
|
||||
const EVoxelTileClass Forced = Op.ClassifyBox(VoxelBox, Ctx);
|
||||
if (Forced != EVoxelTileClass::Mixed)
|
||||
{
|
||||
VF_ForceHypotheses(H, Forced, Op.ForcedMarginOverBox(VoxelBox, Ctx));
|
||||
return;
|
||||
}
|
||||
VF_FoldEffect(H, Op.EffectOverBox(VoxelBox, Ctx),
|
||||
Op.MaxCarveOverBox(VoxelBox, Ctx), Op.MaxFillOverBox(VoxelBox, Ctx));
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
// VoxelDensityOpStack.h
|
||||
// La PILE : un conteneur ordonné d'opérateurs, plus les fabriques d'opérateurs concrets.
|
||||
// The STACK: an ordered container of operators, plus the concrete-operator factories.
|
||||
//
|
||||
// ⚠️ CECI ALIMENTE LE JEU, MAIS SEULEMENT SUR OPT-IN (depuis OPSTACK-PLAN §4, Phase 1, point 3).
|
||||
// `UVoxelGenerator::GetDensityAt` construit la pile par chunk et l'évalue à la place du `switch`
|
||||
// UNIQUEMENT quand `UVoxelStrateManager::UsesOperatorStackForChunk` rend true — c.-à-d. quand la
|
||||
// strate a coché `bUseOperatorStack` ET que son archétype figure dans la liste des portés :
|
||||
// **Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands (6 sur 8)**.
|
||||
// Toute autre strate passe encore par le `switch`, inchangé.
|
||||
// `ClassifyTile` n'est PAS branché : il utilise toujours ses gardes écrites à la main, pas
|
||||
// `ClassifyBox`. C'est la Phase 2.
|
||||
//
|
||||
// THIS FEEDS THE GAME, BUT ONLY BEHIND AN OPT-IN. GetDensityAt builds the stack per chunk and
|
||||
// evaluates it instead of the switch only when UsesOperatorStackForChunk returns true (strate ticked
|
||||
// bUseOperatorStack AND its archetype ported — 6 of 8). ClassifyTile is NOT wired: it still uses its
|
||||
// hand-written guards rather than ClassifyBox. That is Phase 2.
|
||||
//
|
||||
// ⛔ NE JAMAIS faire tourner les deux chemins dans le même monde.
|
||||
// ⚠️ EN REVANCHE, LES COMPARER EST DEVENU LÉGITIME — cette ligne disait l'inverse et elle est
|
||||
// périmée. `AUDIT §C10` (le résidu ~1 ULP) est CLOS depuis `FPSemantics = Precise` : les cinq tests
|
||||
// d'équivalence comparent bit à bit et sont verts. Ils ne sont plus des contrôles de FIDÉLITÉ (la
|
||||
// barre `§2.6.1` n'exige aucune ressemblance avec l'ancien monde) mais des oracles de
|
||||
// CORRECTION DE PORTAGE — une faute de transcription reste un vrai bug, et l'ancienne fonction est
|
||||
// le moyen le moins cher de l'attraper.
|
||||
//
|
||||
// POURQUOI CETTE FORME / WHY THIS SHAPE
|
||||
// La question à laquelle la Phase 1 doit répondre n'est pas « est-ce que ça marche ? » mais
|
||||
// **« est-ce que la séparation source / modifier tombe naturellement du code existant ? »**
|
||||
// (OPSTACK-PLAN §4, le déclencheur d'arrêt). En portant Maze hors du chemin chaud et en le
|
||||
// comparant à l'original, cette question reçoit une réponse MESURÉE plutôt qu'une opinion.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "VoxelDensityOp.h"
|
||||
#include "VoxelStrateTypes.h" // FMazeGenerationParams
|
||||
#include "VoxelHeightOp.h" // IVoxelBiomeField — BuildSurfaceStack takes ownership of one
|
||||
|
||||
class UVoxelStrateManager;
|
||||
|
||||
/**
|
||||
* FVoxelOpStack — une liste ordonnée d'opérateurs + le pliage de verdict de boîte.
|
||||
*
|
||||
* PROPRIÉTÉ (rôle 4) : les opérateurs STRUCTURELS sont ajoutés par `AppendStructuralPost` et
|
||||
* l'ordre spine → seal → passage est garanti par cette fonction, pas par l'auteur. Un auteur ne
|
||||
* peut pas les omettre ni les réordonner — ce sont des invariants de monde (la descente doit rester
|
||||
* possible, les seals doivent tenir, les passages doivent percer).
|
||||
*
|
||||
* PROPRIÉTÉ (threading) : la pile est LUE par les workers. Les opérateurs concrets qui ont besoin
|
||||
* d'un cache par cellule/chunk le tiennent en `thread_local` à l'intérieur de leur `Eval`, comme le
|
||||
* fait déjà chaque fonction d'archétype. En Phase 3, quand les opérateurs deviendront des assets
|
||||
* partagés, il faudra un objet d'état PAR WORKER — noté ici pour que ça ne surprenne personne.
|
||||
*
|
||||
* THREADING: the stack is READ by workers. Concrete ops that need a per-cell/per-chunk cache keep it
|
||||
* thread_local inside Eval, exactly as every archetype function already does. Phase 3 (ops as shared
|
||||
* assets) will need a per-worker state object — flagged here so it is not a surprise.
|
||||
*/
|
||||
class FVoxelOpStack
|
||||
{
|
||||
public:
|
||||
// DÉPLAÇABLE, PAS COPIABLE — et c'est la bonne sémantique, pas un contournement de compilateur :
|
||||
// une pile POSSÈDE ses opérateurs de façon unique. La copier voudrait dire cloner des opérateurs
|
||||
// polymorphes, ce qui n'a pas de sens ici (une pile n'existe qu'une fois par strate).
|
||||
//
|
||||
// ⚠️ NOTE COMPILATEUR : ne PAS remettre `VOXELFORGE_API` sur la classe. Sous MSVC, dllexport sur
|
||||
// une classe force l'instanciation de TOUS ses membres implicites, y compris l'opérateur
|
||||
// d'affectation par copie — impossible à générer pour un `TArray<TUniquePtr<...>>`, d'où
|
||||
// l'erreur C2280 « fonction supprimée ». L'export va sur la seule méthode hors-ligne.
|
||||
//
|
||||
// MOVE-ONLY, and that is the correct semantics rather than a compiler workaround: a stack
|
||||
// uniquely OWNS its operators. Do NOT put VOXELFORGE_API back on the class — under MSVC,
|
||||
// dllexport forces instantiation of every implicit member including copy-assignment, which
|
||||
// cannot be generated for a TArray<TUniquePtr<...>> (error C2280). Export the out-of-line
|
||||
// method instead.
|
||||
FVoxelOpStack() = default;
|
||||
FVoxelOpStack(FVoxelOpStack&&) = default;
|
||||
FVoxelOpStack& operator=(FVoxelOpStack&&) = default;
|
||||
FVoxelOpStack(const FVoxelOpStack&) = delete;
|
||||
FVoxelOpStack& operator=(const FVoxelOpStack&) = delete;
|
||||
|
||||
void Add(TUniquePtr<IVoxelDensityOp> Op) { Ops.Add(MoveTemp(Op)); }
|
||||
|
||||
int32 Num() const { return Ops.Num(); }
|
||||
|
||||
/** Hoist chunk-constant work for every op. Une fois par chunk et par worker.
|
||||
* Non-const : ça MUTE l'état par-chunk des opérateurs, et le prétendre const serait un
|
||||
* mensonge utile qui finirait par masquer une course. */
|
||||
void PrepareChunk(const FVoxelOpContext& Ctx)
|
||||
{
|
||||
for (const TUniquePtr<IVoxelDensityOp>& Op : Ops) { Op->PrepareChunk(Ctx); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Évalue la pile complète en un point. Rend la densité en convention INTERNE
|
||||
* (positif = solide) — l'appelant négate UNE FOIS pour le marching cubes.
|
||||
*
|
||||
* Returns INTERNAL-convention density (positive = solid). The caller negates once for MC.
|
||||
*/
|
||||
float EvalInternal(float WorldX, float WorldY, float WorldZ) const
|
||||
{
|
||||
return EvalSample(WorldX, WorldY, WorldZ).Density;
|
||||
}
|
||||
|
||||
/** L'état COMPLET (densité + SDF) après toute la pile. Diagnostic : quand une comparaison
|
||||
* avec l'ancien chemin diverge, c'est le canal SDF qui dit si l'écart naît avant ou après
|
||||
* la conversion. / The full state after the stack — the SDF channel is what says whether a
|
||||
* divergence is born before or after the carve. */
|
||||
FVoxelOpSample EvalSample(float WorldX, float WorldY, float WorldZ) const
|
||||
{
|
||||
FVoxelOpSample S;
|
||||
for (const TUniquePtr<IVoxelDensityOp>& Op : Ops) { Op->Eval(WorldX, WorldY, WorldZ, S); }
|
||||
return S;
|
||||
}
|
||||
|
||||
/** Le même, négaté pour le mesher (négatif = solide). */
|
||||
float EvalMC(float WorldX, float WorldY, float WorldZ) const
|
||||
{
|
||||
return -EvalInternal(WorldX, WorldY, WorldZ);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le pliage générique qui remplacera les gardes écrites à la main dans ClassifyTile.
|
||||
* Voir `VF_FoldOp` (VoxelDensityOp.h) pour la sémantique — en particulier pourquoi un
|
||||
* opérateur FORÇANT (le seal dans sa bande) écrase ce que la pile avait conclu avant lui.
|
||||
*/
|
||||
EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
|
||||
{
|
||||
FVoxelBoxHypotheses H;
|
||||
for (const TUniquePtr<IVoxelDensityOp>& Op : Ops)
|
||||
{
|
||||
VF_FoldOp(H, *Op, VoxelBox, Ctx);
|
||||
if (H.IsDead()) { return EVoxelTileClass::Mixed; } // early-out : plus rien à prouver
|
||||
}
|
||||
return H.Resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* RÔLE 4 — ajoute les invariants de monde, dans l'ordre fixe, à la fin de la pile.
|
||||
* spine (0,0) → seal de frontière → carve de passage.
|
||||
*
|
||||
* ⚠️ La couche de diff (édits joueur) n'est PAS ici : elle vit dans `GetDensityAt`, APRÈS la
|
||||
* négation MC, avec les disturbances. Elle rejoindra la pile quand les disturbances seront
|
||||
* portées et que la question de convention MC-vs-interne sera tranchée pour de bon
|
||||
* (OPSTACK-DECOMPOSITION §10.2). Tant que la pile n'alimente pas le jeu, c'est sans effet.
|
||||
*
|
||||
* The diff layer is NOT here: it lives in GetDensityAt, AFTER the MC negate, with disturbances.
|
||||
* It joins the stack when disturbances are ported. Harmless while the stack feeds nothing.
|
||||
*
|
||||
* @param StrateManager peut être nullptr → pas de carve de passage (comme le fallback actuel).
|
||||
*/
|
||||
VOXELFORGE_API void AppendStructuralPost(float StrateTopWorldZ, float StrateBottomWorldZ,
|
||||
float SealThickness, float BaseDensity, float SpineRadius,
|
||||
const UVoxelStrateManager* StrateManager);
|
||||
|
||||
private:
|
||||
TArray<TUniquePtr<IVoxelDensityOp>> Ops;
|
||||
};
|
||||
|
||||
//=============================================================================
|
||||
// FABRIQUES / FACTORIES
|
||||
//=============================================================================
|
||||
|
||||
namespace VoxelDensityOps
|
||||
{
|
||||
/** Rôle 1 — `Density = BaseDensity` partout. `ClassifyBox` → AllSolid, exact et gratuit.
|
||||
* Racine de TunnelNetwork, Maze, VerticalShafts et des gaps de bedrock. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeConstantRockSource(float BaseDensity);
|
||||
|
||||
/** Rôle 1 — le MÊME opérateur au signe près : `Density = -BaseDensity`, un grand vide ouvert.
|
||||
* `ClassifyBox` → **AllAir**, ce qu'aucune source n'avait encore su rendre — c'est ce qui rend
|
||||
* une strate d'îles flottantes (surtout vide) sautable là où aucune île n'arrive. Racine de
|
||||
* FloatingIslands. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeConstantVoidSource(float BaseDensity);
|
||||
|
||||
/** Rôle 1 — les couloirs de Maze : capsules sur les arêtes ouvertes d'un treillis 3D.
|
||||
* Écrit le canal SDF uniquement. Identité d'arête = hash(nœud inférieur, axe), donc deux
|
||||
* chunks adjacents NE PEUVENT PAS être en désaccord : pas de cache de chunk, pas de région
|
||||
* COLLECT, zéro risque de couture (AUDIT §6.4 — le motif à préférer). */
|
||||
/* `ExtraReach` = tout ce qui peut eLARGIR la portée du couloir en aval (amplitude de rugosité
|
||||
* rayon de blend du carve). La source répond pour la paire source+conversion dans
|
||||
* `EffectOverBox` (voir la note « SIMPLIFICATION DE PHASE 1 » dans VoxelDensityOp.h), donc elle
|
||||
* doit connaître cette marge, sinon sa réponse `Identity` serait un MENSONGE — c'est-à-dire un
|
||||
* trou. / The source answers for the source+conversion pair, so it must know the downstream
|
||||
* margin: an Identity that is wrong is a hole. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeLatticeCorridorSource(const FMazeGenerationParams& P,
|
||||
int32 Seed, float ExtraReach);
|
||||
|
||||
/** Rôle 3 — rugosité de paroi appliquée au canal SDF (variante Maze/Shafts/Islands).
|
||||
* `Frequency` est codée en dur au site d'appel aujourd'hui (0.12 pour Maze) ; l'exposer est
|
||||
* un gain d'authoring gratuit, et §2.6 autorise explicitement le re-tune. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSdfRoughnessMod(float Strength, float Frequency,
|
||||
int32 BaseOctaves, float ApplyWithin);
|
||||
|
||||
/** Rôle 2 — conversion SDF → densité : creuse de l'air là où le SDF est à l'intérieur.
|
||||
* Les six mêmes lignes apparaissent aujourd'hui dans TunnelNetwork, Maze et VerticalShafts.
|
||||
* @param MinDivisor plancher du diviseur `Blend·2`. **TunnelNetwork passe 1.0** (son original
|
||||
* écrit `FMath::Max(SDFBlendRadius·2, 1)`) ; Maze/Shafts laissent 0, où
|
||||
* `Max(x,0) == x` exactement. Les deux formules divergent si `Blend·2 < 1`,
|
||||
* donc ce paramètre est une vraie différence, pas une précaution. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity,
|
||||
float MinDivisor = 0.0f);
|
||||
|
||||
/** Rôle 2 — la même conversion, signe opposé : REMPLIT du solide là où le SDF est à l'intérieur.
|
||||
* C'est ce que fait FloatingIslands (`Density += Fill·Base·2`), et la multiplication par ±1
|
||||
* étant exacte en IEEE-754, le chemin carve reste bit pour bit ce qu'il était. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSdfFill(float Blend, float BaseDensity);
|
||||
|
||||
/** Rôle 1 — la dalle : surface de sol + surface de plafond → champ de vide. **XY-PUR** depuis
|
||||
* OPSTACK-DECOMPOSITION §3.1 (le terme en Z des deux bruits est parti), ce qui lui donne un
|
||||
* `ClassifyBox` EXACT sans échantillonnage : les deux surfaces vivent dans des bandes en Z
|
||||
* bornées par le contrat [-1,1] de FBM. Sert FlatPlain **et** CrystalChamber. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed);
|
||||
|
||||
/** Rôle 3 — cylindres de hauteur infinie sur une grille monde. N'ajoute que du solide ⇒
|
||||
* `FillOnly` quand une colonne atteint la boîte, `Identity` (le cas courant) sinon. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeGridColumnMod(const FSlabGenerationParams& P, int32 Seed);
|
||||
|
||||
/** Rôle 1 — le pont entre les deux espaces : consomme les piles de HAUTEUR (sol + voûte,
|
||||
* `VoxelHeightOp.h`) et en fait une densité. `IsXYPure()` est **false** — les hauteurs sont
|
||||
* pures en XY, la densité est une distance à celles-ci et ne peut pas l'être. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSurfaceColumnSource(const FSurfaceGenerationParams& P,
|
||||
int32 Seed);
|
||||
|
||||
/**
|
||||
* SurfaceWorld, COMPLET : colonne (sol + voûte) → densité, overhang 3D, post structurel, et le
|
||||
* mélange de biomes quand `PerBiomeParams` est non vide.
|
||||
*
|
||||
* @param PerBiomeParams vide ⇒ pas de biomes, chemin d'origine strictement inchangé. Non vide
|
||||
* ⇒ une pile de hauteur COMPLÈTE par biome, sol mélangé / voûte
|
||||
* sélectionnée, amplitude d'overhang interpolée (§5, combiner `Mask`).
|
||||
* @param BiomeField **transféré** à la pile, qui le possède. Doit répondre pour les mêmes
|
||||
* indices que `PerBiomeParams`. `nullptr` avec des params non vides ⇒
|
||||
* biome 0 partout (dégradation sûre, pas un crash).
|
||||
*/
|
||||
VOXELFORGE_API void BuildSurfaceStack(FVoxelOpStack& OutStack, const FSurfaceGenerationParams& P,
|
||||
int32 Seed, float SpineRadius,
|
||||
const UVoxelStrateManager* StrateManager,
|
||||
const TArray<FSurfaceGenerationParams>& PerBiomeParams =
|
||||
TArray<FSurfaceGenerationParams>(),
|
||||
TUniquePtr<IVoxelBiomeField> BiomeField = nullptr);
|
||||
|
||||
/**
|
||||
* FlatPlain ET CrystalChamber — la même pile, **sans branchement sur le type** :
|
||||
* SlabVoidSource → GridColumnMod → [structural post ×3]
|
||||
*
|
||||
* C'est le premier vrai gain du refactor (OPSTACK-PLAN §4) : deux des huit archétypes
|
||||
* disparaissent dans un opérateur, et leur différence redevient ce qu'elle était déjà dans
|
||||
* `GetSlabDensity` — un jeu de valeurs par défaut, pas du code.
|
||||
*/
|
||||
VOXELFORGE_API void BuildSlabStack(FVoxelOpStack& OutStack, const FSlabGenerationParams& P,
|
||||
int32 Seed, float SpineRadius,
|
||||
const UVoxelStrateManager* StrateManager);
|
||||
|
||||
/**
|
||||
* VerticalShafts — 8 ops, et **TROIS viennent de Maze sans une ligne de changement** :
|
||||
* ConstantRock → ShaftField → SdfRoughness → SdfCarve → ShaftLedge → [structural post ×3]
|
||||
*
|
||||
* C'est la démonstration que `§2.5` promettait : dans le `switch`, Maze et VerticalShafts sont
|
||||
* deux fonctions de ~100 lignes sans rien de commun à l'œil ; en opérateurs, ce sont les mêmes
|
||||
* trois ops avec une source différente et d'autres réglages (fréquence 0.1 au lieu de 0.12,
|
||||
* fenêtre `rough + 4` au lieu de `R + rough + 2`).
|
||||
*/
|
||||
VOXELFORGE_API void BuildVerticalShaftStack(FVoxelOpStack& OutStack, const FVerticalShaftParams& P,
|
||||
int32 Seed, float SpineRadius,
|
||||
const UVoxelStrateManager* StrateManager);
|
||||
|
||||
/**
|
||||
* TunnelNetwork — **COMPLET, 19 ops** :
|
||||
* ConstantRock → RoomGraph(warp + pits + cheminées) → SdfCarve → CaveRoughness(4b)
|
||||
* → Terrace → LayerLines → Ribbing → Overhang → Cliff → Scallop → Arch → RoomColumn(4d)
|
||||
* → Dome(4g) → Pinch(4h) → FloorBias → Worms → [structural ×3]
|
||||
*
|
||||
* L'override d'op PAR SALLE (étape C1) n'ajoute aucun opérateur : `FRoomGraphSource` publie
|
||||
* `LocalParams()` — les params de la strate avec l'op de la salle la plus proche appliqué — et
|
||||
* ONZE des douze modificateurs y lisent leurs champs. La rugosité (4b) lit les params de la
|
||||
* STRATE, parce que dans l'original elle précède la déclaration du shadow.
|
||||
*
|
||||
* ⚠️ `FRoomGraphSource` **APPELLE** `BuildChunkCache`/`EvaluateSDFCached`, il ne les transcrit
|
||||
* pas : c'est là que vit la discipline d'invariance de fenêtre à deux régions (`ARCHITECTURE
|
||||
* §8.4`), et en faire une copie serait le pire résultat possible pour un refactor dont le but est
|
||||
* d'avoir UNE définition de chaque idée.
|
||||
*/
|
||||
VOXELFORGE_API void BuildTunnelNetworkStack(FVoxelOpStack& OutStack,
|
||||
const FStrateGenerationParams& P,
|
||||
int32 Seed, float SpineRadius,
|
||||
const UVoxelStrateManager* StrateManager);
|
||||
|
||||
/**
|
||||
* FloatingIslands — 7 ops, et **la pile tourne à l'ENVERS** :
|
||||
* ConstantVoid → IslandBlob → SdfRoughness → SdfFill → [structural post ×3]
|
||||
*
|
||||
* Les quatre archétypes portés jusqu'ici partent de ROC et CREUSENT ; celui-ci part du VIDE et
|
||||
* REMPLIT. Aucune des deux extrémités n'a demandé d'opérateur neuf — `FConstantFieldSource` et
|
||||
* `FSdfConvertOp` sont les mêmes classes au signe près, et `FSdfRoughnessMod` est repris sans
|
||||
* une ligne de changement (4ᵉ archétype). Seul le blob d'île est nouveau.
|
||||
*
|
||||
* The stack that runs backwards: void source + fill instead of rock source + carve, using the
|
||||
* SAME operators with the opposite sign.
|
||||
*/
|
||||
VOXELFORGE_API void BuildFloatingIslandStack(FVoxelOpStack& OutStack, const FFloatingIslandParams& P,
|
||||
int32 Seed, float SpineRadius,
|
||||
const UVoxelStrateManager* StrateManager);
|
||||
|
||||
/**
|
||||
* La pile Maze complète, décomposée — PAS un `FMazeOp` monolithique :
|
||||
* ConstantRockSource → LatticeCorridorSource → SdfRoughnessMod → SdfCarve → [structural post]
|
||||
*
|
||||
* C'est le test de la Phase 1 : si Maze ne se décompose pas ainsi, l'abstraction est mauvaise
|
||||
* pour ce domaine (OPSTACK-PLAN §4, déclencheur d'arrêt).
|
||||
*/
|
||||
VOXELFORGE_API void BuildMazeStack(FVoxelOpStack& OutStack, const FMazeGenerationParams& P,
|
||||
int32 Seed, float SpineRadius,
|
||||
const UVoxelStrateManager* StrateManager);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// VoxelDensityPrimitives.h
|
||||
// Les trois post-traitements STRUCTURELS partagés par chaque archétype.
|
||||
// The three STRUCTURAL post-processes every archetype shares.
|
||||
//
|
||||
// POURQUOI CE FICHIER EXISTE / WHY THIS FILE EXISTS
|
||||
// Ces trois fonctions étaient `static` dans VoxelGenerator.cpp et appelées à l'identique par les six
|
||||
// fonctions de densité. La pile d'opérateurs a besoin des MÊMES, donc elles montent ici : UNE copie,
|
||||
// partagée par le générateur et par les opérateurs. Dupliquer serait garantir qu'elles divergent —
|
||||
// et ce sont des INVARIANTS de monde (la descente doit rester possible, les seals doivent tenir, les
|
||||
// passages doivent percer), pas des choix créatifs.
|
||||
//
|
||||
// They were `static` in VoxelGenerator.cpp and called identically by all six density functions. The
|
||||
// operator stack needs the same ones, so they move here: ONE copy, shared. Duplicating would
|
||||
// guarantee divergence, and these are world INVARIANTS, not creative choices.
|
||||
//
|
||||
// ⚠️ CONVENTION DE SIGNE — la source n°1 de confusion du plugin.
|
||||
// Ces trois fonctions travaillent en convention INTERNE : **positif = SOLIDE, négatif = AIR**.
|
||||
// C'est la convention dans laquelle chaque fonction d'archétype est écrite ; la négation vers la
|
||||
// convention marching-cubes (négatif = solide) se fait UNE FOIS, sur le `return`.
|
||||
// SIGN CONVENTION: these work in INTERNAL convention — **positive = SOLID**. The negate to MC
|
||||
// convention happens ONCE, at the caller's return.
|
||||
//
|
||||
// Aucun changement de comportement en les déplaçant : corps identiques, FORCEINLINE au lieu de
|
||||
// static, mêmes appelants. / No behavioural change: identical bodies, FORCEINLINE instead of static.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "VoxelTypes.h" // SmoothStep01
|
||||
|
||||
//=============================================================================
|
||||
// SEAL DE FRONTIÈRE / BOUNDARY SEAL
|
||||
//=============================================================================
|
||||
// Seal solide aux bords haut et bas de la strate. Fade smoothstep sur `Thickness` voxels depuis
|
||||
// chaque bord. N'AJOUTE que de la densité (FMath::Max), jamais n'en enlève → le joueur ne peut
|
||||
// jamais percer le seal "par accident", seulement via les passages.
|
||||
//
|
||||
// ⚠️ C'est un opérateur FORÇANT, pas seulement un FillOnly : à l'intérieur de la bande, avec
|
||||
// SealFactor > 0 et BaseDensity > 0, le résultat est solide GARANTI quelle qu'ait été l'entrée.
|
||||
// C'est exactement ce qu'encode `IVoxelDensityOp::ClassifyBox` (voir VoxelDensityOp.h), et la
|
||||
// raison pour laquelle cette méthode existe.
|
||||
FORCEINLINE void VF_ApplyBoundarySeal(float& Density, float WorldZ,
|
||||
float StrateTopZ, float StrateBottomZ,
|
||||
float Thickness, float BaseDensity)
|
||||
{
|
||||
if (Thickness <= 0.0f) return;
|
||||
|
||||
const float DistTop = StrateTopZ - WorldZ; // + si on est sous le plafond
|
||||
const float DistBot = WorldZ - StrateBottomZ; // + si on est au-dessus du sol
|
||||
|
||||
if (DistTop >= 0.0f && DistTop < Thickness)
|
||||
{
|
||||
float SealFactor = 1.0f - (DistTop / Thickness);
|
||||
SealFactor = SmoothStep01(SealFactor);
|
||||
Density = FMath::Max(Density, SealFactor * BaseDensity);
|
||||
}
|
||||
if (DistBot >= 0.0f && DistBot < Thickness)
|
||||
{
|
||||
float SealFactor = 1.0f - (DistBot / Thickness);
|
||||
SealFactor = SmoothStep01(SealFactor);
|
||||
Density = FMath::Max(Density, SealFactor * BaseDensity);
|
||||
}
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// CARVE DE PASSAGE / PASSAGE CARVING
|
||||
//=============================================================================
|
||||
// Creuse un passage inter-strates. Évalué APRÈS le seal pour que les passages puissent percer à
|
||||
// travers le bouchon solide. Le rayon de blend hard-codé à 4.0f correspond à l'ancienne valeur —
|
||||
// à exposer via UVoxelSettings si on veut pouvoir le tweaker.
|
||||
FORCEINLINE void VF_ApplyPassageCarving(float& Density, float ModSDF,
|
||||
float BaseDensity, float SealThickness)
|
||||
{
|
||||
constexpr float PASSAGE_BLEND_RADIUS = 4.0f;
|
||||
if (ModSDF >= PASSAGE_BLEND_RADIUS) return;
|
||||
|
||||
float CarveFactor = FMath::Clamp(
|
||||
(PASSAGE_BLEND_RADIUS - ModSDF) / (PASSAGE_BLEND_RADIUS * 2.0f),
|
||||
0.0f, 1.0f);
|
||||
CarveFactor = SmoothStep01(CarveFactor);
|
||||
|
||||
// FORCE the density toward guaranteed AIR so the passage punches through ANYTHING in
|
||||
// its path (seals, columns, surface roughness, terrain ops). A plain subtraction can
|
||||
// be out-paced by stacked density additions, leaving solid plugs mid-tunnel — which is
|
||||
// why the shaft "didn't go all the way through". Lerp toward a strongly negative target
|
||||
// and take the min so we only ever make it MORE air (never refill an existing cave).
|
||||
const float AirTarget = -(BaseDensity * 2.0f + SealThickness + 4.0f);
|
||||
Density = FMath::Min(Density, FMath::Lerp(Density, AirTarget, CarveFactor));
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// SPINE DE DESCENTE (0,0) / (0,0) DESCENT SPINE
|
||||
//=============================================================================
|
||||
// Creuse une colonne verticale garantie ouverte au XY monde (0,0) dans l'INTÉRIEUR de la strate
|
||||
// (entre les seals haut et bas). Les seals sont laissés intacts pour que le joueur doive encore
|
||||
// creuser à travers pour descendre — ceci ne fait qu'un espace d'atterrissage propre, indépendant
|
||||
// de l'archétype, aligné à travers toutes les strates.
|
||||
FORCEINLINE void VF_ApplyOriginSpine(float& Density, float WorldX, float WorldY, float WorldZ,
|
||||
float StrateTopZ, float StrateBottomZ, float SealThickness, float BaseDensity, float Radius)
|
||||
{
|
||||
if (Radius <= 0.0f) return;
|
||||
|
||||
// Stay within the interior — never touch the seal bands.
|
||||
const float InnerTop = StrateTopZ - SealThickness;
|
||||
const float InnerBot = StrateBottomZ + SealThickness;
|
||||
if (WorldZ <= InnerBot || WorldZ >= InnerTop) return;
|
||||
|
||||
const float DistXY = FMath::Sqrt(WorldX * WorldX + WorldY * WorldY);
|
||||
const float SDF = DistXY - Radius; // < 0 inside the column
|
||||
const float Blend = 3.0f;
|
||||
if (SDF < Blend)
|
||||
{
|
||||
float Carve = FMath::Clamp((Blend - SDF) / (Blend * 2.0f), 0.0f, 1.0f);
|
||||
Carve = SmoothStep01(Carve);
|
||||
Density -= Carve * (BaseDensity * 2.0f + SealThickness);
|
||||
}
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// PORTÉES / REACHES — les rayons dont ClassifyTile et EffectOverBox ont besoin
|
||||
//=============================================================================
|
||||
// Les constantes de blend ci-dessus (3.0 pour la spine, 4.0 pour les passages) sont dupliquées à la
|
||||
// main dans ClassifyTile aujourd'hui. Les nommer ici pour qu'un futur test de bornes ne puisse pas
|
||||
// les désynchroniser. / The blend constants above are hand-duplicated inside ClassifyTile today.
|
||||
// Naming them here so a future bounds test cannot let the two drift apart.
|
||||
namespace VoxelDensityReach
|
||||
{
|
||||
constexpr float SpineBlend = 3.0f;
|
||||
constexpr float PassageBlend = 4.0f;
|
||||
}
|
||||
@@ -37,9 +37,17 @@
|
||||
#include "CoreMinimal.h"
|
||||
#include "Containers/Queue.h"
|
||||
#include "VoxelTypes.h"
|
||||
// ⚠️ IWYU, ET CELUI-CI EST PIÉGEUX : `ENABLE_DRAW_DEBUG` est utilisé en `#if` plus bas. Un macro
|
||||
// NON DÉFINI vaut 0 dans un `#if` — donc sans cet include le bloc de debug disparaît EN SILENCE au
|
||||
// lieu de provoquer une erreur de compilation. Il venait du PCH partagé ; `FPSemantics = Precise`
|
||||
// (AUDIT §C9) nous en prive. Défini par DrawDebugHelpers.h (vérifié dans UE 5.7).
|
||||
// An UNDEFINED macro evaluates to 0 in an #if, so without this include the debug block vanishes
|
||||
// SILENTLY instead of failing the build. Defined by DrawDebugHelpers.h (verified in UE 5.7).
|
||||
#include "DrawDebugHelpers.h"
|
||||
#include <atomic>
|
||||
#include "VoxelDensityVolume.generated.h"
|
||||
|
||||
class AActor; // IWYU : pointeur / TWeakObjectPtr seulement / pointer-only
|
||||
class UVoxelGenerator;
|
||||
class UVoxelSettings;
|
||||
class UVolumeTexture;
|
||||
|
||||
@@ -50,23 +50,9 @@ namespace VoxelGenLOD
|
||||
FORCEINLINE int32 Eff(int32 Octaves) { return FMath::Max(1, Octaves - OctaveBias); }
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// TRIVIAL-TILE CLASSIFICATION (T1.d)
|
||||
//=============================================================================
|
||||
// Verdict de ClassifyTile pour une tuile AVANT le pré-échantillonnage 33³+ :
|
||||
// AllSolid / AllAir garantissent que CHAQUE point du treillis du mesher (marge
|
||||
// ±1 incluse) est du même côté de l'iso ⇒ maillage vide, GenerateMesh est
|
||||
// sautée. Mixed = "je ne peux pas le prouver" ⇒ génération normale. Un faux
|
||||
// Mixed coûte juste du CPU ; un faux AllSolid/AllAir ferait un TROU — les
|
||||
// verdicts ne sont donc émis que sur des bornes exactes (colonnes surface
|
||||
// échantillonnées au MÊME treillis que le mesher) + gardes conservatives sur
|
||||
// tout ce qui peut creuser/remplir (spine, passages, disturbances, diff layer).
|
||||
enum class EVoxelTileClass : uint8
|
||||
{
|
||||
Mixed, // peut contenir une surface → mesher normalement
|
||||
AllSolid, // chaque échantillon prouvé solide → maillage vide
|
||||
AllAir, // chaque échantillon prouvé air → maillage vide
|
||||
};
|
||||
// NOTE: EVoxelTileClass (le verdict T1.d) a déménagé dans VoxelTypes.h — inclus ci-dessus —
|
||||
// pour que VoxelDensityOp.h puisse le partager sans dépendre d'un header UCLASS.
|
||||
// EVoxelTileClass (the T1.d verdict) moved to VoxelTypes.h, included above.
|
||||
|
||||
/**
|
||||
* UVoxelGenerator
|
||||
@@ -191,6 +177,37 @@ public:
|
||||
*/
|
||||
float SampleRelief(float WorldX, float WorldY, float Frequency, float Contrast) const;
|
||||
|
||||
/**
|
||||
* La chaîne de hauteur complète de SurfaceWorld : structural → cliff → terrace → layer lines →
|
||||
* plage. Rend une ALTITUDE monde en voxels, pas une densité.
|
||||
*
|
||||
* PUBLIQUE pour la même raison que `GetSlabDensity` / `GetMazeDensity` : permettre un test
|
||||
* isolé. C'est la référence de `VoxelForge.OpStack.SurfaceHeightEquivalence`, qui compare la
|
||||
* pile d'opérateurs de hauteur (`VoxelHeightOp.h`) à cette fonction point par point.
|
||||
* Public so the height-op stack can be measured against it — same reason as GetSlabDensity.
|
||||
*/
|
||||
float ComputeSurfaceTerrainZ(float WorldX, float WorldY, const FSurfaceGenerationParams& Params) const;
|
||||
|
||||
/**
|
||||
* La colonne de surface : terrain Z, plafond, et le gate d'OVERHANG résolu par colonne
|
||||
* (amplitude + direction amont). PUBLIQUES toutes deux pour la même raison que ci-dessus :
|
||||
* c'est le seul chemin qui calcule l'overhang — `GetSurfaceDensity` passe `OverhangAmp = 0` —
|
||||
* donc c'est la seule référence possible pour `FOverhangShelfMod`.
|
||||
* Public because this is the ONLY path that computes the overhang (GetSurfaceDensity passes 0),
|
||||
* so it is the only possible reference for the ported op.
|
||||
*/
|
||||
void ComputeSurfaceColumn(float WorldX, float WorldY, int32 ChunkZ,
|
||||
const FSurfaceGenerationParams& BaseSurface, const FBiomeContext& BiomeCtx,
|
||||
const TArray<FSurfaceGenerationParams>& BiomeParams, FChunkBiomeCache& BiomeCache,
|
||||
float& OutTerrainZ, float& OutCeilSurf,
|
||||
float& OutOverhangAmp, float& OutDirX, float& OutDirY) const;
|
||||
|
||||
/** Le combine par voxel : colonne → densité, overhang compris, puis le post structurel. */
|
||||
float SurfaceDensityFromColumn(float WorldX, float WorldY, float WorldZ,
|
||||
float TerrainZ, float CeilSurf,
|
||||
float OverhangAmp, float DirX, float DirY,
|
||||
const FSurfaceGenerationParams& S) const;
|
||||
|
||||
/**
|
||||
* Moisture field at a world XY → [0,1]. The second climate axis for biome placement.
|
||||
*/
|
||||
@@ -281,9 +298,9 @@ private:
|
||||
/** Pick the biome (index into Ctx.Biomes) for a Voronoi site, by its climate. */
|
||||
int32 ClassifyBiomeAtSite(float SiteX, float SiteY, const FBiomeContext& Ctx, uint32 SiteHash) const;
|
||||
|
||||
/** The SurfaceWorld heightfield: world XY → terrain surface Z (voxel coords). Pure
|
||||
* per-XY; the part that's evaluated per biome and blended in GetSurfaceDensity. */
|
||||
float ComputeSurfaceTerrainZ(float WorldX, float WorldY, const FSurfaceGenerationParams& Params) const;
|
||||
// ComputeSurfaceTerrainZ a été DÉPLACÉE en `public` (voir plus haut) pour que
|
||||
// VoxelForge.OpStack.SurfaceHeightEquivalence puisse s'y comparer. Une seule déclaration.
|
||||
// Moved to public above so the height-stack test can compare against it. One declaration only.
|
||||
|
||||
/** F20 — the RAW structural heightfield (continents + mountains + detail), BEFORE any
|
||||
* terrain op (cliff/terrace/layer-lines/beach). Ops in ComputeSurfaceTerrainZ build on
|
||||
@@ -302,22 +319,10 @@ private:
|
||||
FSurfaceGenerationParams& OutSurface, FBiomeContext& OutBiomeCtx,
|
||||
TArray<FSurfaceGenerationParams>& OutBiomeParams) const;
|
||||
|
||||
/** Biome-blended terrain Z + sky-cap ceiling Z for one column (the XY-only surface field). Shared
|
||||
* by the density column cache (T1.a) and the oracle. F20 phase 2 overhang, resolved per column:
|
||||
* `OutOverhangAmp` = strength·slope-gate (0 = off), `(OutDirX,OutDirY)` = unit UPHILL gradient dir. */
|
||||
void ComputeSurfaceColumn(float WorldX, float WorldY, int32 ChunkZ,
|
||||
const FSurfaceGenerationParams& BaseSurface, const FBiomeContext& BiomeCtx,
|
||||
const TArray<FSurfaceGenerationParams>& BiomeParams, FChunkBiomeCache& BiomeCache,
|
||||
float& OutTerrainZ, float& OutCeilSurf,
|
||||
float& OutOverhangAmp, float& OutDirX, float& OutDirY) const;
|
||||
|
||||
/** Final SurfaceWorld density from a column's precomputed terrain Z + ceiling: the cheap per-voxel
|
||||
* Z-combine + F20 overhang shelf (warped-terrain union, uphill dir) + origin spine + seal + passages.
|
||||
* The XY-only work (terrain/ceiling/overhang amp+dir) is done once per column and cached (T1.a). */
|
||||
float SurfaceDensityFromColumn(float WorldX, float WorldY, float WorldZ,
|
||||
float TerrainZ, float CeilSurf,
|
||||
float OverhangAmp, float DirX, float DirY,
|
||||
const FSurfaceGenerationParams& Structural) const;
|
||||
// ComputeSurfaceColumn et SurfaceDensityFromColumn ont été DÉPLACÉES en `public` (voir plus
|
||||
// haut) : c'est le seul chemin qui calcule l'overhang, donc la seule référence possible pour
|
||||
// VoxelForge.OpStack.SurfaceHeightEquivalence. Une seule déclaration chacune.
|
||||
// Moved to public above — the only path that computes the overhang, hence the only oracle.
|
||||
|
||||
/** (Re)build the per-chunk biome cell grid covering chunk (X,Y) footprint + margin. */
|
||||
void RebuildBiomeGrid(int32 ChunkX, int32 ChunkY, int32 ChunkZ,
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
// VoxelHeightOp.h
|
||||
// L'ESPACE DES HAUTEURS — une seconde famille d'opérateurs, et pourquoi elle DOIT exister.
|
||||
// HEIGHT SPACE — a second operator family, and why it has to exist.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// LE CONSTAT QUI FORCE CE FICHIER
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// `OPSTACK-DECOMPOSITION §5` décompose SurfaceWorld ainsi :
|
||||
//
|
||||
// FHeightfieldSource ← toute la chaîne de colonne, XY-pure
|
||||
// ├─ FStructuralHeightField
|
||||
// ├─ FCliffHeightMod
|
||||
// ├─ FTerraceHeightMod
|
||||
// ├─ FLayerLineHeightMod
|
||||
// └─ FBeachHeightMod
|
||||
//
|
||||
// et note, sans en tirer la conséquence : *« les ops de hauteur opèrent sur des valeurs Z dans la
|
||||
// colonne, pas sur la densité »*. En lisant `ComputeSurfaceTerrainZ`, c'est littéralement vrai :
|
||||
// c'est une suite de blocs qui lisent et écrivent **un seul float `Terrain`**, une altitude.
|
||||
//
|
||||
// **Ils ne rentrent donc PAS dans `IVoxelDensityOp`.** Sa signature est
|
||||
// `Eval(x, y, z, FVoxelOpSample&)` — par voxel, deux canaux densité/SDF. Un op de hauteur n'a pas
|
||||
// de Z d'entrée (il en PRODUIT un), ne veut pas être appelé par voxel (il est XY-pur, une fois par
|
||||
// colonne), et n'écrit ni densité ni SDF. Les forcer dans le contrat densité demanderait soit un
|
||||
// troisième canal par voxel — alors que la hauteur est une propriété de COLONNE, pas de voxel —,
|
||||
// soit de replier les cinq en un seul op opaque, ce que `§2.5` appelle précisément l'échec du
|
||||
// refactor.
|
||||
//
|
||||
// **Donc : une seconde famille, dans son propre espace.** C'est la même leçon que `§0.1` (il fallait
|
||||
// un canal SDF en plus de la densité), un cran plus loin : certaines choses ne sont pas un canal de
|
||||
// plus, elles sont un ESPACE de plus.
|
||||
//
|
||||
// The height ops read and write a single float ALTITUDE. They have no input Z (they produce one),
|
||||
// are XY-pure (once per column, not per voxel), and write neither density nor SDF. Forcing them into
|
||||
// IVoxelDensityOp would need either a per-voxel third channel for what is a COLUMN property, or
|
||||
// collapsing all five into one opaque op — which §2.5 calls the failure mode. Hence a second family.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// CE QUE ÇA ACHÈTE / WHAT IT BUYS
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// • **Le cache de colonne T1.a tombe naturellement.** Une pile de hauteur est XY-pure PAR
|
||||
// CONSTRUCTION — il n'y a pas de Z à mettre dedans par erreur. `AUDIT §6.3` avertit qu'une donnée
|
||||
// dépendante de Z glissée dans `FSurfaceColumn` corrompt silencieusement toute la pile verticale
|
||||
// de chunks, et que `ValidateDeterminism` ne le verrait pas. Ici c'est le TYPE qui l'interdit.
|
||||
// • **La composition d'idées de terrain devient de l'authoring**, comme pour la densité.
|
||||
// • Les mêmes ops resserviront à VerticalShafts (ledges) et FloatingIslands.
|
||||
//
|
||||
// ⚠️ CE FICHIER NE TOUCHE PAS AU JEU. Il est bâti et exercé par
|
||||
// `VoxelForge.OpStack.SurfaceHeightEquivalence`, qui le compare à `ComputeSurfaceTerrainZ` point par
|
||||
// point. Le branchement dans le chemin densité est l'étape SUIVANTE (§5 : `FHeightfieldSource`,
|
||||
// `FSkyCapSource`, `FOverhangShelfMod`), délibérément séparée pour que la question d'architecture
|
||||
// — *« l'espace des hauteurs se décompose-t-il vraiment ? »* — reçoive une réponse MESURÉE avant
|
||||
// qu'on écrive l'adaptateur qui en dépend.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Templates/UniquePtr.h"
|
||||
#include "VoxelStrateTypes.h" // FSurfaceGenerationParams
|
||||
|
||||
/**
|
||||
* L'état qui traverse une pile de hauteur. DEUX canaux, exactement comme `FVoxelOpSample` — et
|
||||
* pour la même raison : le code le fait déjà.
|
||||
*
|
||||
* `Relief` (le `M` de `SampleSurfaceStructuralZ`) est PRODUIT par la source structurelle et CONSOMMÉ
|
||||
* par le gate du terrace (`TerraceStrength * M`). Sans ce second canal, le terrace devrait
|
||||
* ré-échantillonner le champ de relief — plus lent, et surtout une occasion de diverger de la valeur
|
||||
* que la source a réellement utilisée.
|
||||
*
|
||||
* Two channels, for the same reason as FVoxelOpSample: Relief (the `M` of the structural field) is
|
||||
* produced by the source and consumed by the terrace gate. Threading it beats resampling it.
|
||||
*/
|
||||
struct FVoxelHeightSample
|
||||
{
|
||||
/** Altitude monde en VOXELS (pas cm). */
|
||||
float Height = 0.0f;
|
||||
|
||||
/** « Montagnosité » [0,1]. 1 = uniforme (ReliefStrength = 0). */
|
||||
float Relief = 1.0f;
|
||||
};
|
||||
|
||||
/**
|
||||
* Le résultat d'une requête de champ de biome en un XY : qui domine, qui est le voisin, et à quel
|
||||
* poids on va vers lui dans la bande de frontière.
|
||||
*/
|
||||
struct FVoxelBiomeWeights
|
||||
{
|
||||
int32 Dominant = 0;
|
||||
int32 Neighbor = -1;
|
||||
float NeighborWeight = 0.0f; // 0 ⇒ pas de mélange, le dominant seul
|
||||
};
|
||||
|
||||
/**
|
||||
* LE CHAMP DE BIOMES, VU COMME UNE INTERFACE — et c'est délibérément une interface, pas un pointeur
|
||||
* vers `UVoxelGenerator`.
|
||||
*
|
||||
* Le résolveur de biome réel est une Voronoï warpée avec un cache par chunk, qui vit sur le
|
||||
* générateur. Un opérateur ne doit PAS en dépendre : la Phase 3 veut que les opérateurs deviennent
|
||||
* des DONNÉES (des assets), et un op qui tient un `UVoxelGenerator*` ne peut pas le devenir. En
|
||||
* passant par cette interface, l'adaptateur qui connaît le générateur reste du côté du générateur,
|
||||
* et l'opérateur ne connaît qu'« un truc qui répond (dominant, voisin, poids) en XY ».
|
||||
*
|
||||
* Deliberately an interface rather than a UVoxelGenerator*: Phase 3 wants ops to become data, and an
|
||||
* op holding a generator pointer never can. The adapter that knows the generator stays on the
|
||||
* generator's side; the op only knows "something that answers (dominant, neighbour, weight) at XY".
|
||||
*/
|
||||
class IVoxelBiomeField
|
||||
{
|
||||
public:
|
||||
virtual ~IVoxelBiomeField() = default;
|
||||
|
||||
/** PURE en XY, et bit-identique quel que soit le thread ou l'ordre — même contrat que le reste
|
||||
* de l'espace-hauteur, puisque le résultat alimente le cache de colonne T1.a. */
|
||||
virtual FVoxelBiomeWeights SampleAt(float WorldX, float WorldY) const = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Un opérateur d'espace-hauteur. Trois différences avec `IVoxelDensityOp`, toutes voulues :
|
||||
* • pas de Z d'entrée — la pile en PRODUIT un ;
|
||||
* • XY-pur par construction, donc pas de `IsXYPure()` à déclarer ni à oublier ;
|
||||
* • pas de `PrepareChunk` — ces ops sont déjà appelés une fois par colonne, ce qui EST la
|
||||
* granularité que `PrepareChunk` sert à obtenir côté densité.
|
||||
*/
|
||||
class IVoxelHeightOp
|
||||
{
|
||||
public:
|
||||
virtual ~IVoxelHeightOp() = default;
|
||||
|
||||
/**
|
||||
* INVARIANCE DE FENÊTRE (ARCHITECTURE §8.4) : fonction PURE de (X, Y, seed, params). Le même XY
|
||||
* évalué depuis une autre tuile, un autre ordre, un autre thread doit rendre le float
|
||||
* BIT-IDENTIQUE — le cache de colonne T1.a est partagé sur toute la pile verticale de chunks,
|
||||
* donc une impureté ici se propage à tous les Z d'un coup.
|
||||
*/
|
||||
virtual void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const = 0;
|
||||
|
||||
/**
|
||||
* Majorant CONSERVATIF du déplacement vertical que cet op peut ajouter, en voxels.
|
||||
* Sert à borner la colonne pour un futur `ClassifyBox` exact du heightfield — la même logique
|
||||
* que les bandes de `FSlabVoidSource`, qui prouvent 36-40 tuiles sur 60.
|
||||
* Rendre trop grand coûte du CPU ; rendre trop petit serait un TROU. `FLT_MAX` = « je ne sais
|
||||
* pas », toujours sûr, et c'est le défaut.
|
||||
*/
|
||||
virtual float MaxDisplacement() const { return FLT_MAX; }
|
||||
};
|
||||
|
||||
/**
|
||||
* Pile de hauteur : source → modificateurs, dans l'ordre. Déplaçable, pas copiable, exactement
|
||||
* comme `FVoxelOpStack` et pour la même raison (elle POSSÈDE ses opérateurs).
|
||||
*/
|
||||
class FVoxelHeightStack
|
||||
{
|
||||
public:
|
||||
FVoxelHeightStack() = default;
|
||||
FVoxelHeightStack(FVoxelHeightStack&&) = default;
|
||||
FVoxelHeightStack& operator=(FVoxelHeightStack&&) = default;
|
||||
FVoxelHeightStack(const FVoxelHeightStack&) = delete;
|
||||
FVoxelHeightStack& operator=(const FVoxelHeightStack&) = delete;
|
||||
|
||||
void Add(TUniquePtr<IVoxelHeightOp> Op) { Ops.Add(MoveTemp(Op)); }
|
||||
int32 Num() const { return Ops.Num(); }
|
||||
|
||||
/** L'altitude après toute la pile. */
|
||||
float EvalHeight(float WorldX, float WorldY) const
|
||||
{
|
||||
return EvalSample(WorldX, WorldY).Height;
|
||||
}
|
||||
|
||||
/** L'état complet (altitude + relief). */
|
||||
FVoxelHeightSample EvalSample(float WorldX, float WorldY) const
|
||||
{
|
||||
FVoxelHeightSample S;
|
||||
for (const TUniquePtr<IVoxelHeightOp>& Op : Ops) { Op->Eval(WorldX, WorldY, S); }
|
||||
return S;
|
||||
}
|
||||
|
||||
/** Somme des majorants. `FLT_MAX` dès qu'un seul op ne sait pas répondre. */
|
||||
float MaxTotalDisplacement() const
|
||||
{
|
||||
float Total = 0.0f;
|
||||
for (const TUniquePtr<IVoxelHeightOp>& Op : Ops)
|
||||
{
|
||||
const float D = Op->MaxDisplacement();
|
||||
if (D >= FLT_MAX) { return FLT_MAX; }
|
||||
Total += D;
|
||||
}
|
||||
return Total;
|
||||
}
|
||||
|
||||
private:
|
||||
TArray<TUniquePtr<IVoxelHeightOp>> Ops;
|
||||
};
|
||||
|
||||
//=============================================================================
|
||||
// FABRIQUES / FACTORIES
|
||||
//=============================================================================
|
||||
|
||||
namespace VoxelHeightOps
|
||||
{
|
||||
/**
|
||||
* La source structurelle : continents + montagnes + détail, sous une frame de warp.
|
||||
* Produit `Height` ET `Relief`. Transcription littérale de `SampleSurfaceStructuralZ`.
|
||||
*
|
||||
* ⚠️ Rend un pointeur NON-POSSÉDANT via `OutSource` : `FCliffHeightMod` doit pouvoir
|
||||
* RÉ-ÉCHANTILLONNER ce champ (4 fois, en différences centrées) et doit le faire sur la MÊME
|
||||
* fonction, pas sur une copie qui pourrait dériver. La pile garde la propriété ; la source vit
|
||||
* donc aussi longtemps que le modificateur qui la référence, parce que le constructeur de pile
|
||||
* les ajoute ensemble et que la pile ne réordonne jamais.
|
||||
*/
|
||||
VOXELFORGE_API TUniquePtr<IVoxelHeightOp> MakeStructuralHeightSource(
|
||||
const FSurfaceGenerationParams& P, int32 Seed, const IVoxelHeightOp** OutSource);
|
||||
|
||||
/** Raidissement conditionné par la pente. Le seul op qui coûte des échantillons en plus
|
||||
* (4 resamples structurels), et seulement quand il est activé. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelHeightOp> MakeCliffHeightMod(
|
||||
const FSurfaceGenerationParams& P, const IVoxelHeightOp* StructuralSource);
|
||||
|
||||
/** Plateaux quantifiés, gatés par le relief (`TerraceStrength * M`) — d'où le canal Relief. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelHeightOp> MakeTerraceHeightMod(const FSurfaceGenerationParams& P);
|
||||
|
||||
/** Bandes sédimentaires : `Height -= sin(Height · 2π / Spacing) · Depth`. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelHeightOp> MakeLayerLineHeightMod(const FSurfaceGenerationParams& P);
|
||||
|
||||
/** Aplatissement vers la ligne d'eau dans `BeachWidth`. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelHeightOp> MakeBeachHeightMod(const FSurfaceGenerationParams& P);
|
||||
|
||||
/**
|
||||
* La pile de hauteur complète de SurfaceWorld, dans l'ordre de `ComputeSurfaceTerrainZ` :
|
||||
* structural → cliff → terrace → layer lines → beach
|
||||
*
|
||||
* L'ordre n'est PAS négociable : le terrace quantifie une hauteur que le cliff a déjà raidie,
|
||||
* les layer lines se posent sur le résultat, et la plage écrase tout près de l'eau. C'est
|
||||
* l'ordre du code d'origine, et le test échouerait bruyamment sur toute permutation.
|
||||
*/
|
||||
VOXELFORGE_API void BuildSurfaceHeightStack(FVoxelHeightStack& OutStack,
|
||||
const FSurfaceGenerationParams& P, int32 Seed);
|
||||
|
||||
/** La voûte : warp + gonflement signé + pendage vers le bas uniquement. C'est une ALTITUDE,
|
||||
* donc un op de hauteur — la soustraction n'arrive qu'au combine côté densité. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelHeightOp> MakeSkyCapHeightSource(const FSurfaceGenerationParams& P,
|
||||
int32 Seed);
|
||||
|
||||
/** La pile de plafond de SurfaceWorld. Un seul op aujourd'hui, et c'est une information : le
|
||||
* plafond n'a pas d'équivalent des quatre modificateurs du sol. */
|
||||
VOXELFORGE_API void BuildSurfaceCeilingStack(FVoxelHeightStack& OutStack,
|
||||
const FSurfaceGenerationParams& P, int32 Seed);
|
||||
|
||||
/**
|
||||
* LE COMBINER `Mask` — mélange de biomes, et `OPSTACK-DECOMPOSITION §5` en fait le prototype
|
||||
* de la Phase 3 entière : « unifier strates et biomes » EST ce mécanisme, généralisé.
|
||||
*
|
||||
* Une pile de hauteur COMPLÈTE par biome, plus un champ qui dit lequel domine en (X,Y). Ce sont
|
||||
* les **HAUTEURS** qui sont interpolées, pas les params — c'est ce que fait déjà le code
|
||||
* d'origine, et c'est ce qui rend les frontières continues quelle que soit la différence de
|
||||
* params entre deux biomes (interpoler des params ferait passer un terrace de « fort » à
|
||||
* « faible » à travers des états intermédiaires qui n'ont de sens pour personne).
|
||||
*
|
||||
* Each biome gets a COMPLETE height stack; the HEIGHTS are lerped, not the params — which is
|
||||
* what keeps borders continuous across any param difference.
|
||||
*
|
||||
* @param Field non possédé, doit survivre à la pile. `nullptr` ⇒ biome 0 partout.
|
||||
*/
|
||||
VOXELFORGE_API TUniquePtr<IVoxelHeightOp> MakeBiomeBlendHeightSource(
|
||||
const TArray<FSurfaceGenerationParams>& PerBiomeParams, int32 Seed,
|
||||
const IVoxelBiomeField* Field);
|
||||
|
||||
/** Idem pour le plafond — mais le plafond N'EST PAS mélangé : le code d'origine prend celui du
|
||||
* biome DOMINANT seul. Reproduit tel quel, pas « amélioré » : une voûte interpolée changerait
|
||||
* la silhouette du monde et ce portage n'est pas l'endroit pour décider ça. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelHeightOp> MakeBiomeSelectCeilingSource(
|
||||
const TArray<FSurfaceGenerationParams>& PerBiomeParams, int32 Seed,
|
||||
const IVoxelBiomeField* Field);
|
||||
}
|
||||
@@ -11,6 +11,9 @@
|
||||
#include "VoxelStrateDefinition.h"
|
||||
#include "VoxelSettings.generated.h"
|
||||
|
||||
// IWYU : pointeur seulement (VoxelMaterial). / Pointer-only use.
|
||||
class UMaterialInterface;
|
||||
|
||||
UCLASS(BlueprintType)
|
||||
class UVoxelSettings : public UPrimaryDataAsset
|
||||
{
|
||||
|
||||
@@ -18,9 +18,16 @@
|
||||
#include "GameplayTagContainer.h"
|
||||
#include "VoxelStrateTypes.h"
|
||||
#include "VoxelBiomeTypes.h"
|
||||
#include "Templates/SubclassOf.h" // IWYU : TSubclassOf<AActor> (Atmosphere/Ceiling/FloorLayerActor)
|
||||
#include "VoxelStrateDefinition.generated.h"
|
||||
|
||||
class UVoxelBiomeDefinition;
|
||||
// IWYU : tous en pointeur ou en paramètre de TSubclassOf ⇒ déclarations avant suffisantes.
|
||||
// Le PCH partagé les fournissait ; `FPSemantics = Precise` (AUDIT §C9) nous en prive.
|
||||
// All pointer-only or TSubclassOf parameters, so forward declarations suffice.
|
||||
class UMaterialInterface;
|
||||
class USoundBase;
|
||||
class AActor;
|
||||
|
||||
/**
|
||||
* UVoxelStrateDefinition — The content bag for a strate type.
|
||||
@@ -106,6 +113,29 @@ public:
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation")
|
||||
ECaveGeneratorType GeneratorType = ECaveGeneratorType::TunnelNetwork;
|
||||
|
||||
/**
|
||||
* OPERATOR STACK (experimental) — generate this strate through the composable density operator
|
||||
* stack instead of the hardcoded archetype switch. Same world, different machinery.
|
||||
*
|
||||
* This is the A/B switch for `OPSTACK-PLAN §2.6`'s acceptance bar: flip it, regenerate, and
|
||||
* judge on a screenshot that it is recognisably the same place. Both systems coexist
|
||||
* indefinitely — the switch is not going away until every archetype is ported.
|
||||
*
|
||||
* ⚠️ ONLY `Maze` IS PORTED SO FAR. On any other GeneratorType this flag is ignored and the
|
||||
* switch runs as before, so setting it is harmless but does nothing yet.
|
||||
*
|
||||
* ⚠️ Do NOT flip this on a strate mid-session and expect the old and new geometry to agree to
|
||||
* the bit — they differ by ~1-2 ULP with ZERO isosurface crossings, so the shape is identical
|
||||
* but the floats are not (`AUDIT-2026-07.md §C10`). Regenerate the world after changing it
|
||||
* rather than letting old and new tiles sit side by side.
|
||||
*
|
||||
* Pile d'opérateurs (expérimental) : génère cette strate via la pile composable au lieu du
|
||||
* `switch` d'archétype. Seul `Maze` est porté ; ailleurs le drapeau est ignoré.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Strate|Generation",
|
||||
meta = (DisplayName = "Use Operator Stack (experimental)"))
|
||||
bool bUseOperatorStack = false;
|
||||
|
||||
//=========================================================================
|
||||
// TUNNEL NETWORK PARAMS (shown only for TunnelNetwork generator type)
|
||||
//=========================================================================
|
||||
|
||||
@@ -201,6 +201,16 @@ public:
|
||||
*/
|
||||
ECaveGeneratorType GetGeneratorTypeForChunk(const FIntVector& ChunkCoord) const;
|
||||
|
||||
/**
|
||||
* True when this chunk's strate opts into the density OPERATOR STACK instead of the hardcoded
|
||||
* archetype switch (`UVoxelStrateDefinition::bUseOperatorStack`).
|
||||
*
|
||||
* Returns false for archetypes that have no port yet, so the flag can be set on any strate
|
||||
* without changing its output until that archetype lands. Only `Maze` is ported today — this
|
||||
* predicate is where that list grows, and it is deliberately the ONLY place it is written down.
|
||||
*/
|
||||
bool UsesOperatorStackForChunk(const FIntVector& ChunkCoord) const;
|
||||
|
||||
/**
|
||||
* True if this chunk is in the solid-bedrock GAP between two strates (inside the
|
||||
* overall stack's Z range but not in any strate slot). Chunks above the top strate
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "GameplayTagContainer.h"
|
||||
#include "Templates/SubclassOf.h" // IWYU : TSubclassOf<AActor> (FPlacementProfile & co)
|
||||
#include "VoxelStrateTypes.generated.h"
|
||||
|
||||
class UVoxelBiomeDefinition; // FPlacementProfile::RequiredBiome (optional per-entry biome filter)
|
||||
class AActor; // IWYU : paramètre de TSubclassOf seulement / TSubclassOf param only
|
||||
|
||||
//=============================================================================
|
||||
// ENUMS
|
||||
|
||||
@@ -28,6 +28,29 @@ constexpr int32 CHUNK_VOLUME = CHUNK_SIZE * CHUNK_SIZE * CHUNK_SIZE; // 32
|
||||
|
||||
constexpr float VOXEL_SIZE = 25.0f;
|
||||
|
||||
//=============================================================================
|
||||
// TRIVIAL-TILE CLASSIFICATION (T1.d)
|
||||
//=============================================================================
|
||||
// Verdict de ClassifyTile pour une tuile AVANT le pré-échantillonnage 33³+ :
|
||||
// AllSolid / AllAir garantissent que CHAQUE point du treillis du mesher (marge
|
||||
// ±1 incluse) est du même côté de l'iso ⇒ maillage vide, GenerateMesh est
|
||||
// sautée. Mixed = "je ne peux pas le prouver" ⇒ génération normale. Un faux
|
||||
// Mixed coûte juste du CPU ; un faux AllSolid/AllAir ferait un TROU — les
|
||||
// verdicts ne sont donc émis que sur des bornes exactes (colonnes surface
|
||||
// échantillonnées au MÊME treillis que le mesher) + gardes conservatives sur
|
||||
// tout ce qui peut creuser/remplir (spine, passages, disturbances, diff layer).
|
||||
//
|
||||
// Vit ici plutôt que dans VoxelGenerator.h pour que VoxelDensityOp.h (le contrat
|
||||
// de la pile d'opérateurs) puisse s'en servir sans tirer un header UCLASS.
|
||||
// Lives here rather than in VoxelGenerator.h so VoxelDensityOp.h (the operator-stack
|
||||
// contract) can use it without pulling in a UCLASS header.
|
||||
enum class EVoxelTileClass : uint8
|
||||
{
|
||||
Mixed, // peut contenir une surface → mesher normalement
|
||||
AllSolid, // chaque échantillon prouvé solide → maillage vide
|
||||
AllAir, // chaque échantillon prouvé air → maillage vide
|
||||
};
|
||||
|
||||
//=============================================================================
|
||||
// DENSITY → R8 QUANTIZATION (density clipmap / mini-sun shadows)
|
||||
//=============================================================================
|
||||
|
||||
@@ -11,6 +11,55 @@ public class VoxelForge : ModuleRules
|
||||
// UseExplicitOrSharedPCHs is the modern recommended setting
|
||||
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
|
||||
|
||||
// ============================================================================
|
||||
// FLOAT MODEL — pinned to Precise so Windows and Linux compute the SAME WORLD.
|
||||
// ============================================================================
|
||||
// Jahni, 2026-07-27: the game must be playable on both Linux and Windows, either side
|
||||
// hosting. The MP design replicates the SEED and has every peer regenerate the terrain, so
|
||||
// a Windows host and a Linux client must agree on the density field.
|
||||
//
|
||||
// They did not, by construction. UBT resolves FPSemanticsMode.Default differently per
|
||||
// toolchain (verified in UE 5.7 source, not assumed):
|
||||
// VCToolChain.cs:1264 Default/Imprecise -> "/fp:fast" (Windows/MSVC)
|
||||
// ClangToolChain.cs:712 Default/Precise -> "-ffp-contract=off" (Linux/Mac/Clang)
|
||||
// So the same source was compiled under OPPOSITE float rules depending on who built it.
|
||||
//
|
||||
// Precise resolves to "/fp:precise" on MSVC and "-ffp-contract=off" on Clang — both
|
||||
// IEEE-754 compliant with no FMA contraction, so the two toolchains agree BY CONSTRUCTION
|
||||
// rather than by luck. That is the fix for AUDIT-2026-07.md C9.
|
||||
//
|
||||
// COST: /fp:precise forbids the reassociation and contraction /fp:fast allowed, on a
|
||||
// noise-heavy hot path. Expect a measurable perf regression and check it against
|
||||
// ARCHITECTURE 8.10 — determinism across platforms is worth paying for, but the price
|
||||
// should be known, not assumed.
|
||||
//
|
||||
// VERIFY: run VoxelForge.Determinism.CrossPlatformDigest on both platforms and compare the
|
||||
// SHAPE digest (sign of density = the world) and the FIELD digest (bit-for-bit). Pin the
|
||||
// values in that test once they agree, and it guards this forever after.
|
||||
FPSemantics = FPSemanticsMode.Precise;
|
||||
|
||||
// ============================================================================
|
||||
// ⚠️ HISTORY — why this took a second attempt (kept: it explains the includes below)
|
||||
// ============================================================================
|
||||
// Setting FPSemantics (or any property that alters this module's compile environment)
|
||||
// makes VoxelForge ineligible for the ENGINE'S SHARED PCH — UBT can only share a
|
||||
// precompiled header between modules whose compile environments match. The first attempt
|
||||
// (2026-07-27) was therefore reverted: it failed with ~30 "undefined type" errors that
|
||||
// were not FP-related at all — UMaterialInterface, USoundBase, TSubclassOf<AActor>,
|
||||
// ENABLE_DRAW_DEBUG — i.e. includes this plugin had always taken from the shared PCH for
|
||||
// free. That is a latent IWYU debt, not an FP problem.
|
||||
//
|
||||
// ⚠️ SO IF YOU SEE "undefined type" ERRORS HERE, THEY ARE IWYU, NOT FLOAT SETTINGS.
|
||||
// The fix is to add the missing include or forward declaration to the header that needs
|
||||
// it — never to revert FPSemantics, which is now load-bearing for cross-platform play.
|
||||
// Headers fixed on 2026-07-27: VoxelBiomeDefinition, VoxelSettings, VoxelStrateDefinition,
|
||||
// VoxelStrateTypes, VoxelContentManager, VoxelAtmosphereManager, VoxelDensityVolume.
|
||||
// Plus VoxelWorld.cpp (GameFramework/Pawn.h) — the .cpp files needed auditing too, not just
|
||||
// the public headers. That was the whole residual tail: one site, found in one build.
|
||||
// Expect a residual tail: the shared PCH hid these for years and only a build enumerates
|
||||
// them all. VoxelDensityVolume's was the nasty one — ENABLE_DRAW_DEBUG is used in an #if,
|
||||
// and an undefined macro there is silently 0 rather than an error.
|
||||
|
||||
// Modules we depend on:
|
||||
// - Core: Basic types (TArray, FString, etc.)
|
||||
// - CoreUObject: UObject system (UCLASS, UPROPERTY, etc.)
|
||||
|
||||
+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). ✅ BUILT & WORKING 2026-07-06**
|
||||
(marker ticked 2026-07-27). 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 — ✅ BUILT & WORKING** (built 2026-07-08; marker ticked 2026-07-27 — confirmed working by Jahni 2026-07-26, see `AUDIT-2026-07.md §0`). 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 — ✅ BUILT & WORKING** (built 2026-07-08; marker ticked 2026-07-27 — confirmed working by Jahni 2026-07-26, see `AUDIT-2026-07.md §0`). 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