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:
@@ -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
|
||||
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 =
|
||||
|
||||
@@ -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**
|
||||
|
||||
`VoxelGenerator.cpp:503-524`:
|
||||
|
||||
+11
-5
@@ -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
|
||||
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 (**Maze, FlatPlain, CrystalChamber**). Everything else still takes the
|
||||
`switch`, unchanged.
|
||||
archetype in the ported list (**Maze, FlatPlain, CrystalChamber, SurfaceWorld, VerticalShafts,
|
||||
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.
|
||||
⛔ 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).
|
||||
@@ -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::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::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::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. |
|
||||
| `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`
|
||||
@@ -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. |
|
||||
| `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 (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). |
|
||||
| `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). |
|
||||
@@ -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. |
|
||||
| `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. |
|
||||
| `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)
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
+10
-2
@@ -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:
|
||||
|
||||
✅ `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-
|
||||
lattice `ClassifyTile` bound must both survive) → `VerticalShafts` → `FloatingIslands` → `TunnelNetwork`
|
||||
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) → `Underwater` (TunnelNetwork + a flag) → `TunnelNetwork`
|
||||
(**last** — it owns `BuildChunkCache`'s two-region window-invariance discipline, §8.4, the most delicate
|
||||
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
|
||||
`VF_STRATE_PARAM_FIELDS` X-macro drift problem for free.
|
||||
|
||||
|
||||
@@ -1797,3 +1797,109 @@ things Phase 2 invented (height space, `IVoxelBiomeField`), and the method lesso
|
||||
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 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
|
||||
@@ -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,
|
||||
// de Maze ET de VerticalShafts. Trois archétypes, une ligne, désormais un opérateur.
|
||||
class FConstantRockSource final : public IVoxelDensityOp
|
||||
// de Maze ET de VerticalShafts. Et `float Density = -Params.BaseDensity; // open air (void)` —
|
||||
// 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:
|
||||
explicit FConstantRockSource(float InBaseDensity) : BaseDensity(InBaseDensity) {}
|
||||
explicit FConstantFieldSource(float InValue) : Value(InValue) {}
|
||||
|
||||
EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; }
|
||||
void PrepareChunk(const FVoxelOpContext&) override {}
|
||||
@@ -57,23 +67,28 @@ namespace
|
||||
|
||||
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
|
||||
// strates de grotte une hypothèse AllSolid de départ — elles n'en ont jamais eu.
|
||||
// Exact et gratuit, dans les DEUX sens (convention interne : positif = solide).
|
||||
// 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
|
||||
{
|
||||
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
|
||||
{
|
||||
return EVoxelOpEffect::Both; // jamais atteint : ClassifyBox répond avant
|
||||
return EVoxelOpEffect::Both; // jamais atteint sauf Value == 0 : ClassifyBox répond avant
|
||||
}
|
||||
|
||||
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.
|
||||
class FSdfCarveOp final : public IVoxelDensityOp
|
||||
// Les six mêmes lignes dans TunnelNetwork, Maze, VerticalShafts — et FloatingIslands, où le
|
||||
// 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:
|
||||
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; }
|
||||
void PrepareChunk(const FVoxelOpContext&) override {}
|
||||
@@ -1017,9 +1045,9 @@ namespace
|
||||
void Eval(float, float, float, FVoxelOpSample& InOut) const override
|
||||
{
|
||||
if (InOut.Sdf >= Blend) { return; }
|
||||
float Carve = FMath::Clamp((Blend - InOut.Sdf) / (Blend * 2.0f), 0.0f, 1.0f);
|
||||
Carve = SmoothStep01(Carve);
|
||||
InOut.Density -= Carve * BaseDensity * 2.0f; // interne : baisser = vers l'air
|
||||
float T = FMath::Clamp((Blend - InOut.Sdf) / (Blend * 2.0f), 0.0f, 1.0f);
|
||||
T = SmoothStep01(T);
|
||||
InOut.Density += Sign * T * BaseDensity * 2.0f; // interne : monter = vers le solide
|
||||
}
|
||||
|
||||
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
|
||||
@@ -1028,7 +1056,7 @@ namespace
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
//=========================================================================
|
||||
// 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.
|
||||
// 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
|
||||
@@ -1459,7 +1721,14 @@ namespace VoxelDensityOps
|
||||
{
|
||||
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)
|
||||
@@ -1475,7 +1744,12 @@ namespace VoxelDensityOps
|
||||
|
||||
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)
|
||||
@@ -1563,6 +1837,39 @@ namespace VoxelDensityOps
|
||||
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,
|
||||
int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager)
|
||||
{
|
||||
|
||||
@@ -664,6 +664,20 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
||||
VoxelDensityOps::BuildVerticalShaftStack(CP_OpStack, CP_Vert, Seed,
|
||||
OriginSpineRadius, StrateManager);
|
||||
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:
|
||||
// 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`
|
||||
@@ -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
|
||||
// islands (each samples a different part of the field → distinct silhouettes).
|
||||
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;
|
||||
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;
|
||||
|
||||
@@ -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::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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,24 @@
|
||||
// ⚠️ 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
|
||||
// seul aujourd'hui). Toute autre strate passe encore par le `switch`, inchangé.
|
||||
// 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 is ported — Maze only, today). ClassifyTile is NOT
|
||||
// wired: it still uses its hand-written guards rather than ClassifyBox. That is Phase 2.
|
||||
// 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, ni les comparer pour l'égalité :
|
||||
// le résidu de ~1 ULP est INHÉRENT et documenté (AUDIT-2026-07 §C10). La barre est visuelle (§2.6).
|
||||
// ⛔ 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
|
||||
@@ -161,6 +167,12 @@ namespace VoxelDensityOps
|
||||
* 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
|
||||
@@ -184,6 +196,11 @@ namespace VoxelDensityOps
|
||||
* Les six mêmes lignes apparaissent aujourd'hui dans TunnelNetwork, Maze et VerticalShafts. */
|
||||
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
|
||||
* 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
|
||||
@@ -243,6 +260,22 @@ namespace VoxelDensityOps
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user