feat: port FloatingIslands — the stack that runs backwards

6 of 8 archetypes ported. This one starts from VOID and FILLS where the
other four start from ROCK and CARVE, which is what it was worth doing:
neither end of the pile needed a new operator, only the opposite sign.

  FConstantRockSource -> FConstantFieldSource(+/-Base)   AllSolid <-> AllAir
  FSdfCarveOp         -> FSdfConvertOp(Sign = +/-1)      carve    <-> fill
  FSdfRoughnessMod                                       4th archetype, unchanged

Only the island blob source is new. Multiplying by +/-1 is exact in
IEEE-754, so the three already-green ports are bit-for-bit untouched.

ClassifyBox can return AllAir for the first time in the plugin, and an
island strate is by construction mostly empty — the test counts AllSolid
and AllAir separately so an aggregate cannot hide whether that fired.

Two bounds that would have been holes if assumed rather than derived:
the island bound is one-sided (a hairline thread of matter hangs below
each island down its axis, so only the TOP may reject), and the domain
warp displaces X and Y independently, so the pad needs WarpAmp*sqrt(2).

Also: AUDIT C1 was NOT closed. The 2026-07-27 sweep matched `SeedF * K`
and this archetype's warp spells it `(float)S * K`, so one site survived
— at seed 2e9 the warp flattens and every island snaps back to a perfect
circle. Fixed in both paths in one pass so the equivalence test stays a
valid oracle. Expect island silhouettes to change at large seeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 17:50:36 +02:00
parent f5d5a03ad1
commit 96e75abe57
11 changed files with 901 additions and 35 deletions
+7
View File
@@ -33,6 +33,13 @@ StrateManager provides params per chunk via `GetMaze/Surface/VerticalShaft/Float
On top of the archetype, an optional **biome** layer (§8.14) modulates terrain & content WITHIN a 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. 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 ### 8.2 (0,0) spine & hybrid connections
- `ApplyOriginSpine` (VoxelGenerator.cpp, static helper) carves a guaranteed open vertical - `ApplyOriginSpine` (VoxelGenerator.cpp, static helper) carves a guaranteed open vertical
column at XY (0,0) in every strate's **interior** (seals untouched). Radius = column at XY (0,0) in every strate's **interior** (seals untouched). Radius =
+36
View File
@@ -117,6 +117,42 @@ again.**
--- ---
#### ⚠️ REOPENED AND RE-CLOSED 2026-07-28 — the sweep missed one site, and the search pattern is why
`§C1` was reported fixed on 2026-07-27 (85 sites, *"0 left behind"*), and
`VoxelForge.Determinism.LargeSeedSurvives` went green on seeds up to 2e9. **One site had survived**,
found by reading `GetFloatingIslandDensity` line by line to port it:
```cpp
// VoxelGenerator.cpp — the floating-island domain warp
const float WX = WorldX + FractalNoise3D(FVector(WorldX * 0.04f + (float)S * 0.0007f, ...));
```
**Why the sweep missed it:** the sweep matched the `SeedF * K` spelling. This site spells the same
thing `(float)S * K`, where `S` is the archetype's salted seed. A textual sweep finds a *spelling*,
not a *bug* — and the green property test could not compensate, because `LargeSeedSurvives` asserts
that the **heightfield** still varies, and this site perturbs an **island outline**. Neither the
comparison oracle nor the property oracle covered it; a human read did.
**Impact, before the fix:** at `Seed = 2e9` the term reaches ~1.4e6, where the float ULP is 0.125
against a per-voxel step of 0.04 — the warp flattens and every island silhouette snaps back to a
perfect circle. Cosmetic rather than catastrophic (the C1 failure mode for a *heightfield* is a flat
world), which is exactly why nothing screamed.
**Fixed** in both paths in one pass (`VoxelHash::SeedOffset(S, 0.0007f)` in `GetFloatingIslandDensity`
and in `FIslandBlobSource`), so `FloatingIslandEquivalence` stays a valid oracle.
**One sharp edge recorded:** `SeedOffset` quantises the site key by `×100 + 0.5`, so `0.0007f` maps to
site **0**. That is unique today — every other key in the plugin is ≥ 0.19 — but the next sub-`0.005`
key will collide silently. Two sites sharing an offset is a correlation, not a collapse; still, it is
a footgun in a helper whose whole job is decorrelation.
**Lesson, and it is the session's third instance of the same one:** *verify the premise before
reasoning from it.* "C1 is closed" was load-bearing for two days and was 1 site short. `grep` over a
spelling is evidence about the spelling.
---
### C2 — Per-chunk parameter caches have no layout key (stale after live-edit) ⚠️ **real, reproducible** ### C2 — Per-chunk parameter caches have no layout key (stale after live-edit) ⚠️ **real, reproducible**
`VoxelGenerator.cpp:503-524`: `VoxelGenerator.cpp:503-524`:
+11 -5
View File
@@ -121,8 +121,8 @@ stack share ONE copy. `VoxelGenerator.cpp` keeps same-named `static FORCEINLINE`
⚠️ **Feeds the game, behind a per-strate opt-in** (Phase 1 step 3). `GetDensityAt` builds the stack ⚠️ **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 in its per-chunk refetch block and evaluates it *instead of* the `switch` only when
`UVoxelStrateManager::UsesOperatorStackForChunk` says so — strate ticked `bUseOperatorStack` **and** `UVoxelStrateManager::UsesOperatorStackForChunk` says so — strate ticked `bUseOperatorStack` **and**
archetype in the ported list (**Maze, FlatPlain, CrystalChamber**). Everything else still takes the archetype in the ported list (**Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts,
`switch`, unchanged. FloatingIslands** — 6 of 8). Everything else still takes the `switch`, unchanged.
**`ClassifyTile` is NOT wired** — still hand-written guards, not `ClassifyBox`. That is Phase 2. **`ClassifyTile` is NOT wired** — still hand-written guards, not `ClassifyBox`. That is Phase 2.
⛔ Never run both paths in one world, and never compare them for equality: the ~1 ULP residue is ⛔ Never run both paths in one world, and never compare them for equality: the ~1 ULP residue is
inherent (AUDIT §C10). The acceptance bar is visual (OPSTACK-PLAN §2.6). inherent (AUDIT §C10). The acceptance bar is visual (OPSTACK-PLAN §2.6).
@@ -131,16 +131,20 @@ inherent (AUDIT §C10). The acceptance bar is visual (OPSTACK-PLAN §2.6).
|--------|------|-------| |--------|------|-------|
| `FVoxelOpStack` | — | Ordered `TUniquePtr` list. `PrepareChunk` / `EvalInternal` / `EvalMC` / `ClassifyBox` (the fold, with an early-out when both hypotheses die). | | `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. | | `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. | | `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::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::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. | | `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::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::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. | | `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`. | | `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::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. | | `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. |
| `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** (topbottom ≤ 0): `GetMazeDensity` early-outs to air there and the stack has no such early-out by design — `GetDensityAt` falls back to the `switch`. | | `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** (topbottom ≤ 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` ### 3.2e Height-space operators — `Public/VoxelHeightOp.h` + `Private/VoxelHeightOpStack.cpp`
@@ -320,7 +324,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. | | `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. | | `GetStrateForChunk` | 466 | Chunk → definition. |
| `GetGeneratorTypeForChunk` | 476 | Chunk → generator type. | | `GetGeneratorTypeForChunk` | 476 | Chunk → generator type. |
| `UsesOperatorStackForChunk` | 559 | Chunk → should `GetDensityAt` take the operator stack? `bUseOperatorStack` on the definition **AND** archetype in the ported list (Maze, FlatPlain, CrystalChamber). **That list is written down here and nowhere else** — an unported archetype ignores the flag, so ticking the box anywhere is harmless. Add a row here when you port one. | | `UsesOperatorStackForChunk` | 559 | Chunk → should `GetDensityAt` take the operator stack? `bUseOperatorStack` on the definition **AND** archetype in the ported list (Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts, FloatingIslands — 6 of 8; missing: TunnelNetwork, Underwater). **That list is written down here and nowhere else** — an unported archetype ignores the flag, so ticking the box anywhere is harmless. Add a row here when you port one. |
| `GetSlabParamsForChunk` | 490 | Slab params with runtime Z bounds (no blend — slabs use Hard). | | `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. | | `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). | | `GetGenerationParams` | 515 | **Blended** TunnelNetwork params (handles Gradient/Hard/Interleaved transitions). |
@@ -406,6 +410,8 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor
| `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. | | `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. | | `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. | | `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. |
| `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. | | `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. The density pipeline (most-edited hot path)
+24
View File
@@ -445,6 +445,30 @@ untouched — and since a floating-island strate is *mostly* empty void, that is
with `FConstantVoidSource`'s `ClassifyBox → AllAir`, a FloatingIslands strate could go from skipping with `FConstantVoidSource`'s `ClassifyBox → AllAir`, a FloatingIslands strate could go from skipping
zero tiles to skipping the large majority of them. 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 ## 8. Underwater
+10 -2
View File
@@ -400,11 +400,19 @@ Port each archetype **the next time a feature makes you open it anyway**. The sw
Suggested order when there's a free choice — cheapest and least risky first: 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 `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`) → ✅ `VerticalShafts` (**done**, 3 ops reused from Maze unchanged) (biggest payoff, biggest care: the T1.a column cache and the exact- archetypes collapse into one; **done**, `BuildSlabStack`, 8 archetypes → 7) → ✅ `SurfaceWorld`
lattice `ClassifyTile` bound must both survive) → `VerticalShafts``FloatingIslands``TunnelNetwork` (**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) → `Underwater` (TunnelNetwork + a flag) → `TunnelNetwork`
(**last** — it owns `BuildChunkCache`'s two-region window-invariance discipline, §8.4, the most delicate (**last** — it owns `BuildChunkCache`'s two-region window-invariance discipline, §8.4, the most delicate
code in the plugin). code in the plugin).
**6 of 8 ported.** The two that remain are really one: `Underwater` *is* TunnelNetwork plus
`WaterLevelRelative` (§8), so the switch loses its last two cases in a single port.
Along the way, `FStrateGenerationParams`' 74 fields decompose into per-op structs, which retires the Along the way, `FStrateGenerationParams`' 74 fields decompose into per-op structs, which retires the
`VF_STRATE_PARAM_FIELDS` X-macro drift problem for free. `VF_STRATE_PARAM_FIELDS` X-macro drift problem for free.
+106
View File
@@ -1797,3 +1797,109 @@ things Phase 2 invented (height space, `IVoxelBiomeField`), and the method lesso
in build cycles. in build cycles.
--- ---
## 2026-07-28 — FloatingIslands ported. 6 of 8. The stack runs BACKWARDS, and §C1 was NOT closed.
**The portage that tests the AXIS, not the fidelity.** `VerticalShafts` measured reuse *by identity*
— three of Maze's ops, not a line changed. This one measures something stronger and riskier for the
abstraction: **reuse by INVERSION**.
The four archetypes ported so far all start from ROC and CARVE. FloatingIslands starts from the VOID
and FILLS. If the abstract axis chosen back in §0.1 — the *sign* of the internal density — is the
right one, then both ends of the pile must be the same operators negated:
```
FConstantFieldSource(+Base) ←→ FConstantFieldSource(-Base) ClassifyBox: AllSolid ←→ AllAir
FSdfConvertOp(Sign = -1) ←→ FSdfConvertOp(Sign = +1) carve ←→ fill
FSdfRoughnessMod ←→ FSdfRoughnessMod 4ᵉ archétype, inchangé
```
And it holds: **the only new operator in this port is the island blob.** 7 ops total.
Two classes were merged rather than duplicated (`FConstantRockSource``FConstantFieldSource`,
`FSdfCarveOp``FSdfConvertOp`), each with two factories so the *authoring* vocabulary keeps saying
"rock"/"void" and "carve"/"fill". Multiplying by ±1 is exact in IEEE-754, so the three green ports
are bit-for-bit untouched — that claim is load-bearing and the next run tests it.
### `ClassifyBox` can say **AllAir** for the first time
No cave archetype has ever proved "all air"; `FConstantVoidSource` can, trivially and exactly, and a
floating-island strate is *by construction* mostly empty. That is `OPSTACK-DECOMPOSITION §7`'s claim,
and the test counts AllSolid and AllAir **separately** — an aggregate "N proved" would have hidden
precisely the number that matters. If AllAir comes back 0, the stack is still sound and the perf
argument simply did not fire (the VerticalShafts situation); the test says so out loud rather than
looking green.
### ⚠️ The bound is ONE-SIDED, and assuming otherwise would have been a hole
`Sdf ≥ WorldZ TopSurf` bounds an island from above. **There is no bound from below:** under an
island the SDF degenerates to ≈ `DistXY`, so a hairline thread of matter hangs down the axis to the
strate floor. Rejecting a box for sitting below an island would be a hole in the original's own
geometry. Only the top rejects.
Second trap, caught by doing the arithmetic rather than eyeballing it: the domain warp displaces X
and Y with **two independent** noise samples, so the point moves along the diagonal — the pad needs
`WarpAmp·√2`, not `WarpAmp`. A 1× pad is wrong by 41 % exactly where both noises saturate together:
rare, plausible-looking, and effectively unreachable by random testing.
### ⚠️⚠️ `AUDIT §C1` was reported closed on 2026-07-27. It was one site short.
Found by reading `GetFloatingIslandDensity` line by line to port it:
```cpp
const float WX = WorldX + FractalNoise3D(FVector(WorldX * 0.04f + (float)S * 0.0007f, ...));
```
**The sweep matched `SeedF * K`; this site spells it `(float)S * K`.** A textual sweep finds a
spelling, not a bug. And the property test could not compensate — `LargeSeedSurvives` asserts the
*heightfield* still varies, while this site perturbs an *island outline*: at seed 2e9 the term hits
~1.4e6, ULP 0.125 against a 0.04 voxel step, so the warp flattens and every island snaps back to a
perfect circle. Cosmetic, not catastrophic, which is exactly why nothing screamed for two days.
Fixed in **both** paths in one pass so `FloatingIslandEquivalence` stays a valid oracle. Recorded in
`AUDIT §C1` with the sharp edge that came with it: `SeedOffset` quantises its site key by ×100, so
`0.0007f` lands on site **0** — unique today (every other key is ≥ 0.19), silently collidable
tomorrow.
**This is the third time this session that a confident premise failed a check.** C1's *documented*
fix was wrong; "C9 is gone after FPSemantics" was wrong; now "C1 is closed, 0 left behind" was wrong.
The pattern is stable enough to plan around: **a claim about the code is evidence about whatever was
actually examined, and nothing else.**
### Deviation from `§7`, stated
`§7` sketched a `FRAME IslandWarp` wrapping the source. The warp stays **inside** the op. Frames are
worth building at the *second* real user, and two of the three (TunnelNetwork's cave warp, its tunnel
warp) are not ported. Designing an abstraction against one example is what this refactor has avoided
throughout — `IVoxelBiomeField` exists because a second, concrete need appeared. Revisit at
TunnelNetwork.
Also carried over from the shaft port: the 3×3 memo key includes `BoundarySealThickness`, **which the
original omits** although `SpreadZ` reads it. Same family as `§C2` and as the overhang regression of
2026-07-27. Adding a field to a cache key can only cost a recompute; leaving one out costs a wrong
world, invisibly.
**Ported: Maze · FlatPlain · CrystalChamber · SurfaceWorld (biomes incl.) · VerticalShafts ·
FloatingIslands — 6 of 8.** The two remaining are really one: `Underwater` *is* TunnelNetwork plus
`WaterLevelRelative` (§8), so the `switch` loses both cases in a single port.
**UNVERIFIED:** nothing here is compiled. Likely spots, in order: the two class renames
(`FConstantRockSource` / `FSdfCarveOp` no longer exist — every reference should go through a factory,
but a missed one is a clean C2065); `FFloatingIslandParams` reaching `VoxelDensityOpStack.cpp`
(it comes via `VoxelStrateTypes.h`, already included, so this should be free); the nested `FCells`
declared before its returning functions (the `FShaftFieldSource` C4430 trap, avoided deliberately);
and `MakeUnique<FIslandBlobSource>` being called from the factory namespace, which is fine only
because the class sits above the end-of-anonymous-namespace line.
**Next single action:** build, run the `VoxelForge` filter — **12 tests** now, the new one is
`VoxelForge.OpStack.FloatingIslandEquivalence`. Watch three numbers in its output: samples inside
island rock (0 ⇒ the equivalence proved only that two voids agree), the AllAir verdict count (0 ⇒ the
perf argument did not fire), and of course the diff count.
**⚠️ EXPECT ISLAND SILHOUETTES TO CHANGE** wherever the seed is large — that is the C1 fix, it is
intended, and §2.6.1 covers it.
Then `Underwater` + `TunnelNetwork` (§8 / §2, **last**, with §8.4's window-invariance discipline).
Perf still parked by Jahni until the transition is complete.
---
@@ -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 `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
+327 -20
View File
@@ -42,14 +42,24 @@ namespace
} }
//========================================================================= //=========================================================================
// RÔLE 1 — SOURCE : ROC CONSTANT / CONSTANT ROCK // RÔLE 1 — SOURCE : CHAMP CONSTANT / CONSTANT FIELD (roc ET vide)
//========================================================================= //=========================================================================
// `float Density = Params.BaseDensity; // start solid` — la première ligne de TunnelNetwork, // `float Density = Params.BaseDensity; // start solid` — la première ligne de TunnelNetwork,
// de Maze ET de VerticalShafts. Trois archétypes, une ligne, désormais un opérateur. // de Maze ET de VerticalShafts. Et `float Density = -Params.BaseDensity; // open air (void)` —
class FConstantRockSource final : public IVoxelDensityOp // la première ligne de FloatingIslands. **C'est le MÊME opérateur au signe près**, et le signe
// n'est pas un détail : il décide du verdict de boîte de départ (AllSolid contre AllAir), donc
// de ce que la strate saura sauter.
//
// Quatre archétypes, une ligne, un opérateur. Deux fabriques (`MakeConstantRockSource` /
// `MakeConstantVoidSource`) parce que « roc » et « vide » sont ce que l'auteur veut DIRE ; la
// classe, elle, n'a aucune raison d'exister en deux exemplaires.
//
// One operator, two factories: rock and void are the same constant field with opposite signs,
// and the sign is what decides the starting box verdict (AllSolid vs AllAir).
class FConstantFieldSource final : public IVoxelDensityOp
{ {
public: public:
explicit FConstantRockSource(float InBaseDensity) : BaseDensity(InBaseDensity) {} explicit FConstantFieldSource(float InValue) : Value(InValue) {}
EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; } EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; }
void PrepareChunk(const FVoxelOpContext&) override {} void PrepareChunk(const FVoxelOpContext&) override {}
@@ -57,23 +67,28 @@ namespace
void Eval(float, float, float, FVoxelOpSample& InOut) const override void Eval(float, float, float, FVoxelOpSample& InOut) const override
{ {
InOut.Density = BaseDensity; // Replace : racine de pile, ignore l'entrée InOut.Density = Value; // Replace : racine de pile, ignore l'entrée
} }
// Exact et gratuit : une constante positive est solide partout. C'est ce qui donne aux // Exact et gratuit, dans les DEUX sens (convention interne : positif = solide).
// strates de grotte une hypothèse AllSolid de départ — elles n'en ont jamais eu. // Positif ⇒ AllSolid : c'est ce qui donne aux strates de grotte une hypothèse de départ
// qu'elles n'ont jamais eue. Négatif ⇒ AllAir : c'est ce qui rend une strate d'îles
// flottantes — un grand vide surtout vide — sautable là où aucune île n'arrive.
EVoxelTileClass ClassifyBox(const FBox&, const FVoxelOpContext&) const override EVoxelTileClass ClassifyBox(const FBox&, const FVoxelOpContext&) const override
{ {
return (BaseDensity > 0.0f) ? EVoxelTileClass::AllSolid : EVoxelTileClass::Mixed; if (Value > 0.0f) { return EVoxelTileClass::AllSolid; }
if (Value < 0.0f) { return EVoxelTileClass::AllAir; }
return EVoxelTileClass::Mixed; // exactement 0 : le mesher le compte du côté AIR,
// mais un champ nul n'est pas une hypothèse utile.
} }
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
{ {
return EVoxelOpEffect::Both; // jamais atteint : ClassifyBox répond avant return EVoxelOpEffect::Both; // jamais atteint sauf Value == 0 : ClassifyBox répond avant
} }
private: private:
float BaseDensity; float Value;
}; };
//========================================================================= //=========================================================================
@@ -1003,13 +1018,26 @@ namespace
}; };
//========================================================================= //=========================================================================
// RÔLE 2 — COMBINER : SDF → DENSITÉ (CARVE) // RÔLE 2 — COMBINER : SDF → DENSITÉ (CARVE et FILL)
//========================================================================= //=========================================================================
// Les six mêmes lignes dans TunnelNetwork, Maze et VerticalShafts. Une fois ici, plus jamais. // Les six mêmes lignes dans TunnelNetwork, Maze, VerticalShafts — et FloatingIslands, où le
class FSdfCarveOp final : public IVoxelDensityOp // SEUL changement est `Density += Fill·Base·2` au lieu de `Density -= Carve·Base·2`.
//
// Un archétype qui CREUSE dans du roc et un archétype qui REMPLIT du vide sont donc le même
// opérateur au signe près, exactement comme la source constante au-dessus. C'est la symétrie
// que le `switch` ne pouvait pas montrer : les deux blocs y sont à 900 lignes l'un de l'autre.
//
// ⚠️ `Sign` vaut ±1.0f et rien d'autre. La multiplication par ±1 est EXACTE en IEEE-754, donc
// `D += (-1·F)·B·2` rend bit pour bit ce que `D -= F·B·2` rendait — l'égalité binaire des trois
// portages déjà verts en dépend.
//
// Same operator, opposite sign. Multiplying by ±1 is exact in IEEE-754, so the carve path is
// bit-for-bit what it was before this generalisation — the three green ports depend on that.
class FSdfConvertOp final : public IVoxelDensityOp
{ {
public: public:
FSdfCarveOp(float InBlend, float InBaseDensity) : Blend(InBlend), BaseDensity(InBaseDensity) {} FSdfConvertOp(float InBlend, float InBaseDensity, float InSign)
: Blend(InBlend), BaseDensity(InBaseDensity), Sign(InSign) {}
EVoxelOpRole GetRole() const override { return EVoxelOpRole::Combiner; } EVoxelOpRole GetRole() const override { return EVoxelOpRole::Combiner; }
void PrepareChunk(const FVoxelOpContext&) override {} void PrepareChunk(const FVoxelOpContext&) override {}
@@ -1017,9 +1045,9 @@ namespace
void Eval(float, float, float, FVoxelOpSample& InOut) const override void Eval(float, float, float, FVoxelOpSample& InOut) const override
{ {
if (InOut.Sdf >= Blend) { return; } if (InOut.Sdf >= Blend) { return; }
float Carve = FMath::Clamp((Blend - InOut.Sdf) / (Blend * 2.0f), 0.0f, 1.0f); float T = FMath::Clamp((Blend - InOut.Sdf) / (Blend * 2.0f), 0.0f, 1.0f);
Carve = SmoothStep01(Carve); T = SmoothStep01(T);
InOut.Density -= Carve * BaseDensity * 2.0f; // interne : baisser = vers l'air InOut.Density += Sign * T * BaseDensity * 2.0f; // interne : monter = vers le solide
} }
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
@@ -1028,7 +1056,7 @@ namespace
} }
private: private:
float Blend, BaseDensity; float Blend, BaseDensity, Sign;
}; };
//========================================================================= //=========================================================================
@@ -1429,6 +1457,240 @@ namespace
const FShaftFieldSource* Field; // NON possédant : la pile possède la source const FShaftFieldSource* Field; // NON possédant : la pile possède la source
}; };
//=========================================================================
// RÔLE 1 — SOURCE : ÎLES FLOTTANTES / FLOATING ISLAND BLOBS
//=========================================================================
// Le SEUL archétype dont la source est de l'AIR : `FConstantFieldSource(-BaseDensity)` pose un
// grand vide, et cet opérateur y suspend des blobs. C'est ce qui en fait le bon test de
// composition — tous les autres portages partent de roc et creusent.
//
// FORME D'UNE ÎLE : une dalle assez plate au-dessus du centre (`TopHalf = 0.20·Rxy`) et un
// dessous qui s'effile vers une pointe (`ThicknessRatio·Rxy`). C'est l'asymétrie qui se lit
// comme une île flottante plutôt que comme une sphère.
//
// ⚠️ ÉCART ASSUMÉ AVEC `OPSTACK-DECOMPOSITION §7`, qui décrivait un `FRAME IslandWarp` enveloppant
// la source. Le warp reste À L'INTÉRIEUR de l'opérateur, et c'est délibéré : `§7` compte trois
// usages de frames (îles, caves de TunnelNetwork, tunnels), mais **deux d'entre eux ne sont pas
// encore portés**. Inventer l'infrastructure de frame pour son unique utilisateur actuel, c'est
// la concevoir contre un seul exemple — précisément ce que ce refactor a évité jusqu'ici en
// n'abstrayant qu'à la deuxième occurrence (cf. `IVoxelBiomeField`, né d'un besoin réel).
// À reprendre quand TunnelNetwork arrivera avec le deuxième usage réel.
//
// The warp stays INSIDE the op against §7's FRAME suggestion: two of the three frame users are
// not ported yet, and designing the abstraction against a single example is what this refactor
// has deliberately avoided. Revisit when TunnelNetwork brings the second real use.
class FIslandBlobSource final : public IVoxelDensityOp
{
public:
FIslandBlobSource(const FFloatingIslandParams& InP, int32 Seed, float InExtraReach)
: P(InP), Salt((uint32)Seed ^ 0x49736C64u) // 'Isld' — identique à GetFloatingIslandDensity
, ExtraReach(InExtraReach) {}
EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; }
void PrepareChunk(const FVoxelOpContext&) override {}
struct FIsland { float X, Y, Rxy, TopHalf, TopZ, BotZ, TaperEnd; };
// ⚠️ DÉCLARÉE ICI, avant toute fonction qui la renvoie — même piège que `FShaftFieldSource`
// (C4430 : les corps de méthodes sont différés, les types de retour non).
struct FCells { TArray<FIsland, TInlineAllocator<9>> Islands; };
void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override
{
const float BlendK = FMath::Max(P.SDFBlendRadius, 0.01f);
const FCells& C = GetCells(WorldX, WorldY);
// CONTOUR IRRÉGULIER : on déforme la requête HORIZONTALE pour que les bords des îles
// soient lobés au lieu d'être des cercles parfaits. Calculé une fois par voxel et
// partagé par toutes les îles proches — chacune échantillonne une autre partie du champ,
// d'où des silhouettes distinctes.
const float WarpAmp = (P.IslandMinRadius + P.IslandMaxRadius) * 0.5f * 0.35f;
const float WX = WorldX + HFractal3D(FVector(WorldX * 0.04f + VoxelHash::SeedOffset(Salt, 0.0007f),
WorldY * 0.04f, WorldZ * 0.012f), VoxelGenLOD::Eff(3))
* VOXEL_NOISE_SCALE * WarpAmp;
const float WY = WorldY + HFractal3D(FVector(WorldX * 0.04f + 31.0f, WorldY * 0.04f + 7.0f,
WorldZ * 0.012f), VoxelGenLOD::Eff(3))
* VOXEL_NOISE_SCALE * WarpAmp;
float IslandSDF = FLT_MAX;
for (const FIsland& Isl : C.Islands)
{
// Distance horizontale dans le repère DÉFORMÉ, donc le contour n'est pas un cercle.
const float Dxw = WX - Isl.X, Dyw = WY - Isl.Y;
const float DistXY = FMath::Sqrt(Dxw * Dxw + Dyw * Dyw);
// Enveloppe de rayon par la hauteur : pleine largeur en haut, resserrée jusqu'à une
// pointe en bas (taper SmoothStep).
const float Hgt = FMath::Clamp((WorldZ - Isl.BotZ) / FMath::Max(Isl.TopZ - Isl.BotZ, 1.0f),
0.0f, 1.0f);
const float Taper = SmoothStep01(FMath::Clamp(Hgt / Isl.TaperEnd, 0.0f, 1.0f));
const float Env = Isl.Rxy * Taper;
// Surface du dessus : plate par défaut ; les bords retombent en dôme si TopFlatten < 1.
float TopSurf = Isl.TopZ;
if (P.TopFlatten < 1.0f)
{
const float Edge = FMath::Clamp(DistXY / FMath::Max(Isl.Rxy, 1.0f), 0.0f, 1.0f);
TopSurf = Isl.TopZ - (1.0f - P.TopFlatten) * Isl.TopHalf * 2.0f * Edge * Edge;
}
// Pseudo-SDF : dehors si au-delà de l'enveloppe radiale OU au-dessus du dessus.
const float Sdf = FMath::Max(DistXY - Env, WorldZ - TopSurf);
IslandSDF = VoxelSDF::SmoothMin(IslandSDF, Sdf, BlendK);
}
InOut.Sdf = IslandSDF;
}
/**
* `FillOnly` si une île peut atteindre la boîte, `Identity` sinon et sur une strate d'îles
* `Identity` est le cas COURANT, ce qui est tout l'intérêt : combiné à l'`AllAir` de la
* source constante, c'est la première fois qu'un archétype de grotte peut prouver « tout air »
* (`OPSTACK-DECOMPOSITION §7`).
*
* BORNE, et pourquoi elle est sûre dans les deux directions :
* en XY, `Sdf DistXY Rxy` (l'enveloppe ne dépasse jamais `Rxy`), et le warp déplace
* le POINT de `WarpAmp · VOXEL_NOISE_SCALE · 2` au plus (FBM [1,1] sur DEUX axes
* indépendants voir la note 2 dans le corps) ;
* en Z, `Sdf WorldZ TopSurf WorldZ TopZ`, donc au-dessus du sommet + marge il
* n'y a plus rien à faire. **En dessous, il n'y a PAS de borne** : sous une île, le SDF
* vaut `DistXY` à toute profondeur, donc un mince fil de matière descend le long de
* l'axe. C'est le comportement de l'original ; le confondre avec « rien en dessous »
* serait un TROU, et c'est pourquoi seule la borne HAUTE est testée.
* `ExtraReach` couvre l'aval (rugosité, blend du fill, creux du SmoothMin K/6).
*/
EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext&) const override
{
// ⚠️ √2, PAS 1×. Le warp déplace X et Y par DEUX échantillons de bruit INDÉPENDANTS,
// chacun borné par `WarpAmp · VOXEL_NOISE_SCALE`. Le déplacement du POINT est donc la
// diagonale, `WarpMax·√2`, et non `WarpMax`. Une marge à 1× serait fausse de 41 % dans
// le pire cas — c'est-à-dire un trou dans le coin exact où les deux bruits saturent
// ensemble. Rare, et c'est précisément ce qui rendrait le bug injoignable en test.
// TWO independent noise samples ⇒ the point displacement is the diagonal, not one axis.
constexpr float Sqrt2 = 1.4142136f;
const float WarpMax = (P.IslandMinRadius + P.IslandMaxRadius) * 0.5f * 0.35f
* VOXEL_NOISE_SCALE * Sqrt2;
const float Pad = ExtraReach + FMath::Abs(WarpMax);
const float MaxR = FMath::Max(P.IslandMinRadius, P.IslandMaxRadius);
const float Spacing = FMath::Max(P.IslandSpacing, 1.0f);
const FBox Padded = VoxelBox.ExpandBy(MaxR + Pad);
const int32 CX0 = FMath::FloorToInt((float)Padded.Min.X / Spacing);
const int32 CX1 = FMath::FloorToInt((float)Padded.Max.X / Spacing);
const int32 CY0 = FMath::FloorToInt((float)Padded.Min.Y / Spacing);
const int32 CY1 = FMath::FloorToInt((float)Padded.Max.Y / Spacing);
for (int32 cy = CY0; cy <= CY1; ++cy)
for (int32 cx = CX0; cx <= CX1; ++cx)
{
FIsland Isl;
if (!RollIsland(cx, cy, Isl)) { continue; }
// Entièrement au-dessus du sommet de l'île (+ marge) ⇒ hors d'atteinte.
if ((float)VoxelBox.Min.Z > Isl.TopZ + Pad) { continue; }
const float R = Isl.Rxy + Pad;
const float QX = FMath::Max(0.0f, FMath::Max((float)VoxelBox.Min.X - Isl.X,
Isl.X - (float)VoxelBox.Max.X));
const float QY = FMath::Max(0.0f, FMath::Max((float)VoxelBox.Min.Y - Isl.Y,
Isl.Y - (float)VoxelBox.Max.Y));
if (QX * QX + QY * QY < R * R) { return EVoxelOpEffect::FillOnly; }
}
return EVoxelOpEffect::Identity;
}
private:
/** Tirage d'une cellule. PURE en (cellule, seed, params) ⇒ `Eval` et `EffectOverBox` ne
* peuvent pas voir des îles différentes. Transcription littérale du bloc de cuisson de
* `GetFloatingIslandDensity`. */
bool RollIsland(int32 nx, int32 ny, FIsland& Out) const
{
const float H = P.StrateTopWorldZ - P.StrateBottomWorldZ;
const float Spacing = FMath::Max(P.IslandSpacing, 1.0f);
const float MidZ = (P.StrateTopWorldZ + P.StrateBottomWorldZ) * 0.5f;
const uint32 Hh = VoxelHash::Cell(nx, ny, Salt);
if (VoxelHash::ToFloat01(Hh) > P.IslandDensity) { return false; }
const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x12345678u));
const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x9ABCDEF0u));
Out.X = (nx + 0.15f + JX * 0.7f) * Spacing;
Out.Y = (ny + 0.15f + JY * 0.7f) * Spacing;
Out.Rxy = FMath::Lerp(P.IslandMinRadius, P.IslandMaxRadius,
VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x5A5Au)));
// PROFIL ASYMÉTRIQUE : dalle de terre au-dessus, dessous qui s'effile en pointe.
Out.TopHalf = Out.Rxy * 0.20f;
const float UnderDepth = Out.Rxy * FMath::Max(P.ThicknessRatio, 0.25f);
const float SpreadZ = FMath::Max(H * 0.5f - FMath::Max(Out.TopHalf, UnderDepth)
- P.BoundarySealThickness, 0.0f) * P.VerticalJitter;
const float Cz = MidZ + VoxelHash::ToFloatSigned(VoxelHash::Mix(Hh ^ 0xB17Du)) * SpreadZ;
Out.TopZ = Cz + Out.TopHalf;
Out.BotZ = Cz - UnderDepth;
// Netteté du taper par île (point d'arrivée du SmoothStep) → silhouettes variées.
Out.TaperEnd = FMath::Lerp(0.45f, 0.7f, VoxelHash::ToFloat01(VoxelHash::Mix(Hh ^ 0x7A1Eu)));
return true;
}
/**
* Le voisinage 3×3, mémoïsé par worker la même cuisson `thread_local` que l'original.
*
* LA CLÉ INCLUT `BoundarySealThickness`, QUE L'ORIGINAL OMET. `SpreadZ` s'en sert
* (`H·0.5 max(TopHalf, UnderDepth) Seal`), donc dans `GetFloatingIslandDensity` une
* édition à chaud qui ne change QUE l'épaisseur de seal sert des îles périmées. Même famille
* que `AUDIT §C2` et que la régression d'overhang du 2026-07-27 : une clé de cache
* incomplète ne se voit pas, elle produit du terrain plausible. Ajouter le champ ne coûte
* qu'un recalcul, jamais une valeur différente donc l'égalité binaire tient.
*
* The key includes BoundarySealThickness, which the original omits although SpreadZ reads it.
* Adding it can only cost a recompute, never change a value bit-equality is unaffected.
*/
const FCells& GetCells(float WorldX, float WorldY) const
{
const float Spacing = FMath::Max(P.IslandSpacing, 1.0f);
const int32 CX = FMath::FloorToInt(WorldX / Spacing);
const int32 CY = FMath::FloorToInt(WorldY / Spacing);
thread_local FCells Cache;
thread_local int32 FI_CX = INT32_MAX, FI_CY = INT32_MAX;
thread_local uint32 FI_Salt = 0xFFFFFFFFu;
thread_local float FI_Spacing = -1.0f, FI_Dens = -1.0f, FI_MinR = -1.0f, FI_MaxR = -1.0f,
FI_Thick = -1.0f, FI_VJit = -1.0f, FI_Seal = -1.0f,
FI_BotZ = FLT_MAX, FI_TopZ = FLT_MAX;
if (CX != FI_CX || CY != FI_CY || Salt != FI_Salt || Spacing != FI_Spacing ||
P.IslandDensity != FI_Dens || P.IslandMinRadius != FI_MinR ||
P.IslandMaxRadius != FI_MaxR || P.ThicknessRatio != FI_Thick ||
P.VerticalJitter != FI_VJit || P.BoundarySealThickness != FI_Seal ||
P.StrateBottomWorldZ != FI_BotZ || P.StrateTopWorldZ != FI_TopZ)
{
FI_CX = CX; FI_CY = CY; FI_Salt = Salt; FI_Spacing = Spacing;
FI_Dens = P.IslandDensity; FI_MinR = P.IslandMinRadius; FI_MaxR = P.IslandMaxRadius;
FI_Thick = P.ThicknessRatio; FI_VJit = P.VerticalJitter;
FI_Seal = P.BoundarySealThickness;
FI_BotZ = P.StrateBottomWorldZ; FI_TopZ = P.StrateTopWorldZ;
Cache.Islands.Reset();
for (int32 dy = -1; dy <= 1; dy++)
for (int32 dx = -1; dx <= 1; dx++)
{
FIsland Isl;
if (RollIsland(CX + dx, CY + dy, Isl)) { Cache.Islands.Add(Isl); }
}
}
return Cache;
}
FFloatingIslandParams P;
uint32 Salt;
float ExtraReach;
};
} // ⚠️ FIN DU NAMESPACE ANONYME — TOUT NOUVEL OPÉRATEUR SE MET AU-DESSUS DE CETTE LIGNE. } // ⚠️ FIN DU NAMESPACE ANONYME — TOUT NOUVEL OPÉRATEUR SE MET AU-DESSUS DE CETTE LIGNE.
// Même piège que dans VoxelHeightOpStack.cpp : s'ancrer sur une bannière située plus bas // Même piège que dans VoxelHeightOpStack.cpp : s'ancrer sur une bannière située plus bas
// (« FVoxelOpStack », « FABRIQUES ») insère la classe HORS du namespace anonyme, et l'accolade // (« FVoxelOpStack », « FABRIQUES ») insère la classe HORS du namespace anonyme, et l'accolade
@@ -1459,7 +1721,14 @@ namespace VoxelDensityOps
{ {
TUniquePtr<IVoxelDensityOp> MakeConstantRockSource(float BaseDensity) TUniquePtr<IVoxelDensityOp> MakeConstantRockSource(float BaseDensity)
{ {
return MakeUnique<FConstantRockSource>(BaseDensity); return MakeUnique<FConstantFieldSource>(BaseDensity);
}
TUniquePtr<IVoxelDensityOp> MakeConstantVoidSource(float BaseDensity)
{
// `float Density = -Params.BaseDensity; // start as open air (void)` — la négation unaire
// est exacte, donc c'est littéralement la première ligne de GetFloatingIslandDensity.
return MakeUnique<FConstantFieldSource>(-BaseDensity);
} }
TUniquePtr<IVoxelDensityOp> MakeLatticeCorridorSource(const FMazeGenerationParams& P, int32 Seed, float ExtraReach) TUniquePtr<IVoxelDensityOp> MakeLatticeCorridorSource(const FMazeGenerationParams& P, int32 Seed, float ExtraReach)
@@ -1475,7 +1744,12 @@ namespace VoxelDensityOps
TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity) TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity)
{ {
return MakeUnique<FSdfCarveOp>(Blend, BaseDensity); return MakeUnique<FSdfConvertOp>(Blend, BaseDensity, -1.0f);
}
TUniquePtr<IVoxelDensityOp> MakeSdfFill(float Blend, float BaseDensity)
{
return MakeUnique<FSdfConvertOp>(Blend, BaseDensity, +1.0f);
} }
TUniquePtr<IVoxelDensityOp> MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed) TUniquePtr<IVoxelDensityOp> MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed)
@@ -1563,6 +1837,39 @@ namespace VoxelDensityOps
P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager); P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager);
} }
void BuildFloatingIslandStack(FVoxelOpStack& OutStack, const FFloatingIslandParams& P,
int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager)
{
// ⚠️ LA PILE QUI S'INVERSE, et c'est la mesure que ce portage-ci ajoute : les quatre autres
// archétypes partent de ROC et CREUSENT ; celui-ci part du VIDE et REMPLIT. Aucune des deux
// extrémités n'a demandé un opérateur neuf — la source constante et la conversion SDF→densité
// sont les MÊMES classes, au signe près (`FConstantFieldSource`, `FSdfConvertOp`). Un
// opérateur qui se réutilise en s'inversant est une preuve plus forte qu'un opérateur qui se
// réutilise à l'identique : ça veut dire que l'axe abstrait (le signe de la densité) est le
// bon, pas seulement que deux archétypes se ressemblaient.
//
// The stack that runs BACKWARDS: four archetypes start from rock and carve, this one starts
// from void and fills — and neither end needed a new operator, only the opposite sign.
const float BlendK = FMath::Max(P.SDFBlendRadius, 0.01f);
// Portée que la source doit déclarer pour la paire source+fill : la rugosité peut abaisser
// le SDF de `Rough·VOXEL_NOISE_SCALE` (FBM ∈ [-1,1]), le SmoothMin de `K/6` de plus, et le
// fill s'applique dès `Sdf < BlendK`. Sur-estimer coûte du CPU ; sous-estimer serait un trou.
const float ExtraReach = FMath::Abs(P.SurfaceRoughness) * VOXEL_NOISE_SCALE
+ BlendK * 2.0f + 1.0f;
OutStack.Add(MakeConstantVoidSource(P.BaseDensity));
OutStack.Add(MakeUnique<FIslandBlobSource>(P, Seed, ExtraReach));
// Fréquence 0.08 et 4 octaves — les constantes de `GetFloatingIslandDensity`. Quatrième
// archétype à réutiliser cet opérateur (Maze 0.12/3, VerticalShafts 0.1/3).
OutStack.Add(MakeSdfRoughnessMod(P.SurfaceRoughness, 0.08f, 4,
P.SurfaceRoughness + BlendK + 2.0f));
OutStack.Add(MakeSdfFill(BlendK, P.BaseDensity));
OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ,
P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager);
}
void BuildMazeStack(FVoxelOpStack& OutStack, const FMazeGenerationParams& P, void BuildMazeStack(FVoxelOpStack& OutStack, const FMazeGenerationParams& P,
int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager) int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager)
{ {
+24 -1
View File
@@ -664,6 +664,20 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
VoxelDensityOps::BuildVerticalShaftStack(CP_OpStack, CP_Vert, Seed, VoxelDensityOps::BuildVerticalShaftStack(CP_OpStack, CP_Vert, Seed,
OriginSpineRadius, StrateManager); OriginSpineRadius, StrateManager);
break; break;
case ECaveGeneratorType::FloatingIslands:
// Même garde de strate dégénérée : GetFloatingIslandDensity court-circuite sur
// `return 1.0f` (= air) quand la hauteur est nulle ou négative.
if (CP_Float.StrateTopWorldZ - CP_Float.StrateBottomWorldZ <= 0.0f)
{
CP_UseOpStack = false;
break;
}
OpCtx.StrateTopWorldZ = CP_Float.StrateTopWorldZ;
OpCtx.StrateBottomWorldZ = CP_Float.StrateBottomWorldZ;
VoxelDensityOps::BuildFloatingIslandStack(CP_OpStack, CP_Float, Seed,
OriginSpineRadius, StrateManager);
break;
default: default:
// UsesOperatorStackForChunk ne rend true que pour les archétypes portés, donc // UsesOperatorStackForChunk ne rend true que pour les archétypes portés, donc
// on ne devrait jamais arriver ici. Si ça arrive, retomber sur le `switch` // on ne devrait jamais arriver ici. Si ça arrive, retomber sur le `switch`
@@ -3428,7 +3442,16 @@ float UVoxelGenerator::GetFloatingIslandDensity(float WorldX, float WorldY, floa
// organic instead of perfect circles. Computed once per voxel and shared by all nearby // organic instead of perfect circles. Computed once per voxel and shared by all nearby
// islands (each samples a different part of the field → distinct silhouettes). // islands (each samples a different part of the field → distinct silhouettes).
const float WarpAmp = (Params.IslandMinRadius + Params.IslandMaxRadius) * 0.5f * 0.35f; const float WarpAmp = (Params.IslandMinRadius + Params.IslandMaxRadius) * 0.5f * 0.35f;
const float WX = WorldX + FractalNoise3D(FVector(WorldX * 0.04f + (float)S * 0.0007f, WorldY * 0.04f, WorldZ * 0.012f), VoxelGenLOD::Eff(3)) // ⚠️ AUDIT §C1 — DERNIER SITE DU PLUGIN, trouvé en portant cet archétype (2026-07-28). Le
// balayage du 2026-07-27 cherchait le motif `SeedF * K` et celui-ci s'écrit `(float)S * K`, donc
// il a survécu : à Seed = 2e9 le terme atteint ~1.4e6, où l'ULP du float vaut 0.125 contre un pas
// de 0.04 par voxel — le warp s'aplatit et les îles redeviennent des cercles parfaits. Corrigé
// dans les DEUX chemins (ici et FIslandBlobSource) en une passe, pour que le test d'équivalence
// reste un oracle valable.
// NOTE : `SeedOffset` quantifie la clé de site par ×100, donc 0.0007 → site 0. Unique aujourd'hui
// (toutes les autres clés du plugin sont ≥ 0.19) ; la prochaine clé sous 0.005 devra en choisir
// une autre plutôt que de collisionner en silence.
const float WX = WorldX + FractalNoise3D(FVector(WorldX * 0.04f + VoxelHash::SeedOffset(S, 0.0007f), WorldY * 0.04f, WorldZ * 0.012f), VoxelGenLOD::Eff(3))
* VOXEL_NOISE_SCALE * WarpAmp; * VOXEL_NOISE_SCALE * WarpAmp;
const float WY = WorldY + FractalNoise3D(FVector(WorldX * 0.04f + 31.0f, WorldY * 0.04f + 7.0f, WorldZ * 0.012f), VoxelGenLOD::Eff(3)) const float WY = WorldY + FractalNoise3D(FVector(WorldX * 0.04f + 31.0f, WorldY * 0.04f + 7.0f, WorldZ * 0.012f), VoxelGenLOD::Eff(3))
* VOXEL_NOISE_SCALE * WarpAmp; * VOXEL_NOISE_SCALE * WarpAmp;
@@ -585,6 +585,11 @@ bool UVoxelStrateManager::UsesOperatorStackForChunk(const FIntVector& ChunkCoord
case ECaveGeneratorType::VerticalShafts: return true; // Phase 2 — 3 ops repris de Maze tels quels 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. 6 des 8 portés.
return true;
default: return false; default: return false;
} }
} }
+40 -7
View File
@@ -5,18 +5,24 @@
// ⚠️ CECI ALIMENTE LE JEU, MAIS SEULEMENT SUR OPT-IN (depuis OPSTACK-PLAN §4, Phase 1, point 3). // ⚠️ 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` // `UVoxelGenerator::GetDensityAt` construit la pile par chunk et l'évalue à la place du `switch`
// UNIQUEMENT quand `UVoxelStrateManager::UsesOperatorStackForChunk` rend true — c.-à-d. quand la // 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 // strate a coché `bUseOperatorStack` ET que son archétype figure dans la liste des portés :
// seul aujourd'hui). Toute autre strate passe encore par le `switch`, inchangé. // **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 // `ClassifyTile` n'est PAS branché : il utilise toujours ses gardes écrites à la main, pas
// `ClassifyBox`. C'est la Phase 2. // `ClassifyBox`. C'est la Phase 2.
// //
// THIS FEEDS THE GAME, BUT ONLY BEHIND AN OPT-IN. GetDensityAt builds the stack per chunk and // 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 // evaluates it instead of the switch only when UsesOperatorStackForChunk returns true (strate ticked
// ticked bUseOperatorStack AND its archetype is ported — Maze only, today). ClassifyTile is NOT // bUseOperatorStack AND its archetype ported — 6 of 8). ClassifyTile is NOT wired: it still uses its
// wired: it still uses its hand-written guards rather than ClassifyBox. That is Phase 2. // hand-written guards rather than ClassifyBox. That is Phase 2.
// //
// ⛔ NE JAMAIS faire tourner les deux chemins dans le même monde, ni les comparer pour l'égalité : // ⛔ NE JAMAIS faire tourner les deux chemins dans le même monde.
// le résidu de ~1 ULP est INHÉRENT et documenté (AUDIT-2026-07 §C10). La barre est visuelle (§2.6). // ⚠️ 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 // POURQUOI CETTE FORME / WHY THIS SHAPE
// La question à laquelle la Phase 1 doit répondre n'est pas « est-ce que ça marche ? » mais // La question à laquelle la Phase 1 doit répondre n'est pas « est-ce que ça marche ? » mais
@@ -161,6 +167,12 @@ namespace VoxelDensityOps
* Racine de TunnelNetwork, Maze, VerticalShafts et des gaps de bedrock. */ * Racine de TunnelNetwork, Maze, VerticalShafts et des gaps de bedrock. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeConstantRockSource(float BaseDensity); 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 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. /** 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 * É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 * chunks adjacents NE PEUVENT PAS être en désaccord : pas de cache de chunk, pas de région
@@ -184,6 +196,11 @@ namespace VoxelDensityOps
* Les six mêmes lignes apparaissent aujourd'hui dans TunnelNetwork, Maze et VerticalShafts. */ * Les six mêmes lignes apparaissent aujourd'hui dans TunnelNetwork, Maze et VerticalShafts. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity); VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity);
/** 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 /** 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 * 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 * `ClassifyBox` EXACT sans échantillonnage : les deux surfaces vivent dans des bandes en Z
@@ -243,6 +260,22 @@ namespace VoxelDensityOps
int32 Seed, float SpineRadius, int32 Seed, float SpineRadius,
const UVoxelStrateManager* StrateManager); 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 : * La pile Maze complète, décomposée PAS un `FMazeOp` monolithique :
* ConstantRockSource LatticeCorridorSource SdfRoughnessMod SdfCarve [structural post] * ConstantRockSource LatticeCorridorSource SdfRoughnessMod SdfCarve [structural post]