feat: TunnelNetwork stage A — the SDF spine, wrapping BuildChunkCache
The last archetype is ~1080 lines with 13 detail modifiers, a two-region cache and a per-room op override. Porting it whole before anything can be verified is ~600 unverified lines on top of ~200 — the pattern this refactor has dodged six times. So: three stages. Stage A = vertical scale, base rock, cave warp, room graph (+ pits and chimneys), carve, worms, structural post. 6 ops. It is verifiable NOW because every detail modifier is amplitude-gated and FStrateGenerationParams already defaults them all to zero — zeroing SurfaceRoughness sends the ORIGINAL down exactly the path stage A ported. TunnelNetwork stays OFF in UsesOperatorStackForChunk until stage C. The decision that matters: FRoomGraphSource CALLS BuildChunkCache and EvaluateSDFCached rather than transcribing them. That is where §8.4's two-region window-invariance discipline lives; a transcription would fork it, and the fork would be "validated" by a test comparing it to the original. Only the ~60 lines of glue are transcribed. FRAME ops are retired. All three candidates are now ported and none needed one: CaveWarp's scope is exactly one operator (pits/chimneys read unwarped coords), VerticalScale is a one-line pure function, and the island warp was already local. Not missing infrastructure — one idea seen three times from a distance. Also: check 3 was going to compare two interleaved param sets against the original, which would have FAILED — the original's SDF cache key has no params, so it serves B the rooms it built for A. Comparing there measures its bug, not the port. Rewritten against each stack evaluated alone. The same reasoning suggests a live production staleness across Gradient transitions; filed in AUDIT §C2 as SUSPECTED with the check that would confirm it, since it rests on a premise I have not verified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -182,6 +182,30 @@ Seed) **are** seed-guarded, and the strate-index memo **is** version-guarded (`S
|
|||||||
`ChangeSeed` mostly survives; **live-edit is where this bites**. Symptom: "I tweaked the strate asset,
|
`ChangeSeed` mostly survives; **live-edit is where this bites**. Symptom: "I tweaked the strate asset,
|
||||||
regenerated, and one patch kept the old shape."
|
regenerated, and one patch kept the old shape."
|
||||||
|
|
||||||
|
#### ⚠️ SUSPECTED, NOT PROVEN, 2026-07-28 — the SDF cache may serve stale rooms *within* a strate
|
||||||
|
|
||||||
|
Found while porting TunnelNetwork, and stated as a suspicion on purpose: I have reasoned it, not
|
||||||
|
measured it.
|
||||||
|
|
||||||
|
`GetDensityWithParams`' SDF cache key is `(XY search box, StrateIndex, Seed)`. It contains **no
|
||||||
|
params and no chunk Z**. Meanwhile `GetGenerationParams` *blends* params between neighbouring strates
|
||||||
|
across a Gradient transition — so two chunks at different Z **inside the same strate** can carry
|
||||||
|
different `RoomSpacing`/`RoomDensity`/… while sharing an XY box, a strate index and a seed.
|
||||||
|
|
||||||
|
If that is right, a worker descending a transition band gets **no rebuild** and evaluates the lower
|
||||||
|
chunk against the upper chunk's room layout. Same family as `§C2` above and as the overhang
|
||||||
|
regression of 2026-07-27; the difference is that this one needs no live edit to trigger.
|
||||||
|
|
||||||
|
**What would confirm it:** call `GetGenerationParams` for two adjacent chunk Zs inside a
|
||||||
|
Gradient-transitioned strate and compare the room-placement fields. If they differ, the cache is
|
||||||
|
being reused across a real param change. **Do that before acting** — the whole thing rests on
|
||||||
|
"Gradient blending actually varies within a strate", which I have not verified.
|
||||||
|
|
||||||
|
The operator-stack port does **not** inherit this: `FRoomGraphSource` folds a `FCrc::MemCrc32`
|
||||||
|
fingerprint of the params (plus `LayoutVersion`) into its key, so differing params force a rebuild.
|
||||||
|
`VoxelForge.OpStack.TunnelNetworkSpineEquivalence` check 3 exercises exactly that — and deliberately
|
||||||
|
does *not* compare against the original there, because the original would fail it.
|
||||||
|
|
||||||
**Fix:** the getter already exists and is already used elsewhere —
|
**Fix:** the getter already exists and is already used elsewhere —
|
||||||
```cpp
|
```cpp
|
||||||
const uint32 LV = StrateManager->GetLayoutVersion();
|
const uint32 LV = StrateManager->GetLayoutVersion();
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ inherent (AUDIT §C10). The acceptance bar is visual (OPSTACK-PLAN §2.6).
|
|||||||
| `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. |
|
||||||
|
| `FRoomGraphSource` (internal) | 1 | TunnelNetwork's SDF spine. **CALLS `BuildChunkCache`/`EvaluateSDFCached` — does not transcribe them**: that is where §8.4's two-region discipline lives and a copy would fork it. Owns the cave warp (scope = this op alone; pits/chimneys read *unwarped* coords, which is why no FRAME op was needed). Its cache key adds a **params CRC + LayoutVersion** that the original lacks — see the suspected staleness note in AUDIT §C2. `EffectOverBox` → `Both` for now (a real answer means building the cache for the queried box; only pays once `ClassifyTile` consumes `ClassifyBox`). |
|
||||||
|
| `FWormFieldSource` (internal) | 1 | Fielded 3D-noise threshold carve, masked by distance to the room network (reads `InOut.Sdf` *after* pits/chimneys). `EffectOverBox` → **`CarveOnly` everywhere** — no spatial bound, so it kills `AllSolid` on every tile of every strate with worms on. `MaxCarveAmplitude()` holds the bound from DECOMPOSITION §0.2 that would recover it, waiting for a fold that carries numbers. |
|
||||||
|
| `VoxelDensityOps::BuildTunnelNetworkStack` | — | **STAGE A of three — incomplete on purpose.** 6 ops: rock → room graph → carve → worms → structural ×3. The 13 detail modifiers (stage B) and the per-room op override (stage C) are missing, which is why TunnelNetwork is still **off** in `UsesOperatorStackForChunk`. Testable now because every detail modifier is amplitude-gated and defaults to 0. |
|
||||||
| `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. |
|
| `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::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`. |
|
| `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`. |
|
||||||
@@ -410,6 +413,7 @@ 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. |
|
||||||
|
| `VoxelForgeOpStackTunnelTest.cpp` | `VoxelForge.OpStack.TunnelNetworkSpineEquivalence` | **Stage A of the last port.** Zeroes the 13 detail-op amplitudes so the *original* takes the path stage A ported — that is what makes an incomplete stack verifiable now. Samples in **clusters** (24 chunks × 250 points), because the SDF cache rebuilds when a query leaves its box and uniform sampling would rebuild per point on both paths. Check 3 (two param sets, A/B interleaved) compares each stack **to itself alone, never to the original** — the original would fail it, see AUDIT §C2. Asserts **zero** box verdicts, which is the honest stage-A result. |
|
||||||
| `VoxelForgeOpStackShaftTest.cpp` | `VoxelForge.OpStack.VerticalShaftEquivalence` | The port that tests **reuse**, not fidelity: three of the five ops are Maze's, unchanged. Forces connectors + ledges on, because both are off or negligible at defaults and a resting param is an untested operator. Known-pessimistic: proves **0 of 60** tiles (its `EffectOverBox` rejects on a `Spacing*1.6` halo instead of real connector capsules — lost CPU, never a hole). |
|
| `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. |
|
| `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. |
|
||||||
|
|||||||
@@ -170,6 +170,22 @@ The order is load-bearing and is the order the code already uses: spine carves t
|
|||||||
the seal then re-solidifies its bands (the spine deliberately never touches them), passages punch
|
the seal then re-solidifies its bands (the spine deliberately never touches them), passages punch
|
||||||
through everything including the seal, and the player wins last.
|
through everything including the seal, and the player wins last.
|
||||||
|
|
||||||
|
> ### ⛔ RETIRED 2026-07-28 — frames were never a fifth thing. Porting all three candidates killed it.
|
||||||
|
>
|
||||||
|
> The section below argues for a `FRAME` op family from three examples. All three are now ported,
|
||||||
|
> and none of them turned out to need one:
|
||||||
|
>
|
||||||
|
> - **`CaveWarp` wraps exactly ONE operator.** Pits and chimneys explicitly read *unwarped* coords
|
||||||
|
> while writing the same SDF channel — the thing this document called "the single fiddliest thing
|
||||||
|
> in the whole decomposition". Inside one operator the difficulty evaporates: the warp is a local
|
||||||
|
> variable, not an inherited context. A transform whose scope is one op is not a frame.
|
||||||
|
> - **`VerticalScale` is `Z / Scale`** — a pure function of a scalar and a param, recomputed in one
|
||||||
|
> line by each op that needs it. A frame would add a channel to avoid a division.
|
||||||
|
> - **The island warp (§7)** was kept local for the same reason, before the other two were even read.
|
||||||
|
>
|
||||||
|
> **Zero frames out of three candidates.** It was not missing infrastructure; it was one idea seen
|
||||||
|
> three times from a distance. Kept below as the reasoning that was superseded, not as a plan.
|
||||||
|
|
||||||
### A fifth thing the plan doesn't name: FRAME OPS
|
### A fifth thing the plan doesn't name: FRAME OPS
|
||||||
|
|
||||||
Two archetypes transform the *query coordinates* rather than the field:
|
Two archetypes transform the *query coordinates* rather than the field:
|
||||||
|
|||||||
@@ -1903,3 +1903,92 @@ Then `Underwater` + `TunnelNetwork` (§8 / §2, **last**, with §8.4's window-in
|
|||||||
Perf still parked by Jahni until the transition is complete.
|
Perf still parked by Jahni until the transition is complete.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-28 — 12 tests green. TunnelNetwork STAGE A (of three) written.
|
||||||
|
|
||||||
|
Jahni: *"everything's green."* FloatingIslands is bit-identical and wired; 6 of 8 ported.
|
||||||
|
**Worth asking for explicitly next run:** the three coverage numbers that test prints — samples
|
||||||
|
inside island rock, the AllAir verdict count, and the diff count. Green with zero samples inside
|
||||||
|
island rock would mean the equivalence proved that two empty voids agree.
|
||||||
|
|
||||||
|
### The last archetype, staged deliberately
|
||||||
|
|
||||||
|
`GetDensityWithParams` is ~1080 lines, 13 detail modifiers, a two-region cache and a per-room op
|
||||||
|
override. Porting all of it before anything can be verified would be ~600 unverified lines on top of
|
||||||
|
~200 unverified ones — the `AUDIT §P3` pattern this refactor has dodged six times.
|
||||||
|
|
||||||
|
**The way in:** every detail modifier is amplitude-gated, and `FStrateGenerationParams` already
|
||||||
|
leaves all of them at **zero** by default (`BuildParamsFromDefinition` stopped merging them globally
|
||||||
|
— they arrive as per-room ops). One exception, `SurfaceRoughness = 5`. So zeroing those amplitudes
|
||||||
|
sends the ORIGINAL down exactly the path stage A ported, and stage A is verifiable **today**, bit for
|
||||||
|
bit, against the real function. Same discipline as the height-stack test's "defaults, then all ops
|
||||||
|
ON", taken in the other direction.
|
||||||
|
|
||||||
|
- **Stage A (this commit):** vertical scale · base rock · cave warp · room graph (+ pits + chimneys)
|
||||||
|
· carve · worms · structural post. 6 ops.
|
||||||
|
- **Stage B:** the 13 detail modifiers, gated on `Sdf < SDFBlendRadius·3`.
|
||||||
|
- **Stage C:** the per-room op override (`§2`'s option (a)), the `Underwater` water flag, and only
|
||||||
|
then does `UsesOperatorStackForChunk` return true for either.
|
||||||
|
|
||||||
|
### ⚠️ The one decision that matters: the op CALLS `BuildChunkCache`, it does not transcribe it
|
||||||
|
|
||||||
|
Every other port is a literal transcription. This one must not be. `BuildChunkCache` carries the
|
||||||
|
two-region window-invariance discipline (`ARCHITECTURE §8.4`) and is the most delicate code in the
|
||||||
|
plugin; transcribing it would **fork** it — two copies of one invariant, drifting, with the copy
|
||||||
|
"validated" by a test that compares it to the original. What *is* transcribed is the ~60 lines of
|
||||||
|
glue around it (strate-index memo, search-box key, warp, pit/chimney loops).
|
||||||
|
|
||||||
|
### `FRAME` ops are retired, and porting is what retired them
|
||||||
|
|
||||||
|
`§2` described two nested frames (`VerticalScale`, `CaveWarp`) and `§7` a third (the island warp).
|
||||||
|
Porting all three dissolved all three:
|
||||||
|
|
||||||
|
- **`CaveWarp` wraps exactly ONE operator.** Pits and chimneys explicitly read *unwarped* coords.
|
||||||
|
A transform whose scope is one op is a local variable, not a frame.
|
||||||
|
- **`VerticalScale` is `Z / Scale`** — a pure function of a scalar and a param, one line wherever it
|
||||||
|
is needed. A frame would add a channel to avoid a division.
|
||||||
|
- **The island warp** was already kept local for the same reason.
|
||||||
|
|
||||||
|
**Zero frames out of three candidates.** It was never missing infrastructure; it was the same thing
|
||||||
|
seen three times from a distance. Recorded in the builder rather than left as a permanent TODO.
|
||||||
|
|
||||||
|
Also found: TunnelNetwork's carve divides by `Max(SDFBlendRadius·2, 1)` where Maze/Shafts/Islands
|
||||||
|
divide by `Blend·2`. The formulas diverge once `Blend·2 < 1`, so `FSdfConvertOp` gained an explicit
|
||||||
|
`MinDivisor` instead of letting the two look interchangeable. `Max(x, 0) == x` for positive Blend, so
|
||||||
|
the green ports are untouched.
|
||||||
|
|
||||||
|
### ⚠️ A test that compares against the original would have measured the ORIGINAL'S bug
|
||||||
|
|
||||||
|
Check 3 (two stacks, different params, evaluated A/B/A/B at the same point) was written comparing
|
||||||
|
both against `GetDensityWithParams`. **It would have failed** — and not because of the port. The
|
||||||
|
original's SDF cache key is `(XY box, strate, seed)` with **no params**, so under interleaving it
|
||||||
|
serves B the rooms it built for A. Comparing to it there measures its staleness, not my operator.
|
||||||
|
Rewritten to compare each stack against **itself evaluated alone**: an oracle that does not share the
|
||||||
|
defect under test.
|
||||||
|
|
||||||
|
That in turn raises a **suspicion, filed as a suspicion**: `GetGenerationParams` blends params across
|
||||||
|
Gradient transitions, so two chunk Zs inside one strate can hold different params with the same XY
|
||||||
|
box, strate index and seed ⇒ no rebuild ⇒ the lower chunk gets the upper chunk's rooms. In
|
||||||
|
production, with no live edit needed. Recorded in `AUDIT §C2` **with the check that would confirm
|
||||||
|
it**, because the whole chain rests on "Gradient blending actually varies within a strate", which I
|
||||||
|
have not verified. The port does not inherit it — the params fingerprint forces the rebuild.
|
||||||
|
|
||||||
|
### What stage A deliberately does NOT prove
|
||||||
|
|
||||||
|
Box verdicts: **zero proved, and the test asserts zero.** `FRoomGraphSource` answers `Both` (its
|
||||||
|
bounds are in the SDF cache, which it would have to build for the queried box — worth doing only
|
||||||
|
once `ClassifyTile` actually consumes `ClassifyBox`), and `FWormFieldSource` answers `CarveOnly`
|
||||||
|
*everywhere*, because a fielded-noise carve has no spatial bound. That is `§0.2`'s point, and the
|
||||||
|
amplitude cap that fixes it (`t ∈ [0,1]`, `Mask ∈ [0,1]` ⇒ at most `WormStrength` toward air) now has
|
||||||
|
a home in `FWormFieldSource::MaxCarveAmplitude()`, waiting for a fold that carries numbers.
|
||||||
|
|
||||||
|
**UNVERIFIED:** not compiled. Likely spots: `FStrateTerrainOpEntry` / `UVoxelStrateDefinition` newly
|
||||||
|
reachable from `VoxelDensityOpStack.cpp` (added the include); `MakeSdfCarve`'s new defaulted third
|
||||||
|
parameter (declared in the header, so the three existing call sites still compile); `FCrc` needing
|
||||||
|
`Misc/Crc.h` (it comes via `CoreMinimal.h`, and `FSurfaceColumnSource` already uses it in this file);
|
||||||
|
and the test's `GetGenerationParams` signature.
|
||||||
|
|
||||||
|
**Next single action:** build, run the filter — **13 tests**, the new one is
|
||||||
|
`VoxelForge.OpStack.TunnelNetworkSpineEquivalence`. Then stage B (the 13 modifiers).
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
// VoxelForgeOpStackTunnelTest.cpp
|
||||||
|
// TunnelNetwork — ÉTAPE A : le squelette SDF, sans les modificateurs de détail.
|
||||||
|
// TunnelNetwork — STAGE A: the SDF spine, without the detail modifiers.
|
||||||
|
//
|
||||||
|
// POURQUOI UN TEST D'UNE PILE INCOMPLÈTE
|
||||||
|
// `GetDensityWithParams` fait ~1080 lignes et treize modificateurs de détail. Tout porter avant de
|
||||||
|
// pouvoir rien vérifier, ce serait écrire ~600 lignes non compilées par-dessus ~200 non vérifiées —
|
||||||
|
// exactement le motif que `AUDIT §P3` documente et que ce refactor a évité six fois de suite.
|
||||||
|
//
|
||||||
|
// La sortie : **tous les modificateurs de détail sont pilotés par une amplitude**, et
|
||||||
|
// `FStrateGenerationParams` les laisse déjà TOUS à zéro par défaut (`BuildParamsFromDefinition` ne
|
||||||
|
// les fusionne plus globalement — ils viennent d'ops par salle). Une seule exception,
|
||||||
|
// `SurfaceRoughness = 5`. Les mettre à zéro fait passer l'ORIGINAL par exactement le chemin que
|
||||||
|
// l'étape A a porté, donc l'étape A est vérifiable AUJOURD'HUI, bit à bit, contre la vraie fonction.
|
||||||
|
// Même discipline que la passe « défauts puis tous les ops ON » du test de pile de hauteur, prise
|
||||||
|
// dans l'autre sens.
|
||||||
|
//
|
||||||
|
// CE QUE CE TEST NE PROUVE PAS (et le dit) : rien sur les 13 modificateurs, rien sur l'override d'op
|
||||||
|
// par salle, et rien sur le saut de tuile — `FRoomGraphSource::EffectOverBox` rend `Both`, donc
|
||||||
|
// aucun verdict n'est prouvable à ce stade. Ces trois manques sont l'étape B et l'étape C.
|
||||||
|
//
|
||||||
|
// ⚠️ ÉCHANTILLONNAGE PAR GRAPPES, PAS UNIFORME. Le cache SDF se reconstruit quand la requête sort de
|
||||||
|
// sa boîte de recherche ; 20 000 points uniformément aléatoires feraient ~20 000 `BuildChunkCache`
|
||||||
|
// par chemin, et un test qui dure trois minutes est un test qu'on finit par ne plus lancer. On tire
|
||||||
|
// donc N chunks et M points DANS chacun — ce qui est aussi plus représentatif du vrai motif d'accès
|
||||||
|
// (un mesher parcourt une tuile, il ne saute pas au hasard).
|
||||||
|
|
||||||
|
#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(
|
||||||
|
FVoxelForgeOpStackTunnelTest,
|
||||||
|
"VoxelForge.OpStack.TunnelNetworkSpineEquivalence",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr int32 NumTunnelChunks = 24;
|
||||||
|
constexpr int32 PointsPerChunk = 250;
|
||||||
|
constexpr int32 NumTunnelSamples = NumTunnelChunks * PointsPerChunk;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à zéro tout ce que l'étape A n'a pas encore porté, pour que l'original prenne le même
|
||||||
|
* chemin. Tout sauf `SurfaceRoughness` est DÉJÀ à zéro par défaut ; on l'écrit quand même, parce
|
||||||
|
* qu'un test qui dépend d'un défaut se casse le jour où le défaut change, et silencieusement.
|
||||||
|
*/
|
||||||
|
void DisableStageBModifiers(FStrateGenerationParams& P)
|
||||||
|
{
|
||||||
|
P.SurfaceRoughness = 0.0f; // le seul non nul par défaut (5.0)
|
||||||
|
P.DomainWarpStrength = 0.0f;
|
||||||
|
P.TerraceStepHeight = 0.0f;
|
||||||
|
P.TerraceNoiseDisplacement = 0.0f;
|
||||||
|
P.LayerLineSpacing = 0.0f;
|
||||||
|
P.RibbingSpacing = 0.0f;
|
||||||
|
P.OverhangStrength = 0.0f;
|
||||||
|
P.CliffStrength = 0.0f;
|
||||||
|
P.ScallopStrength = 0.0f;
|
||||||
|
P.ArchDensity = 0.0f;
|
||||||
|
P.ColumnDensity = 0.0f; // ⚠️ celui-ci se cuit dans SDFCache.Columns, pas un `if`
|
||||||
|
P.DomeDensity = 0.0f;
|
||||||
|
P.PinchDensity = 0.0f;
|
||||||
|
P.FloorBias = 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pits et cheminées sont à 0 par défaut — or ce sont précisément les deux boucles que `§2`
|
||||||
|
* annonçait comme « le plus retors de toute la décomposition » (coordonnées NON warpées
|
||||||
|
* mélangées au SDF warpé). Les laisser au repos testerait tout sauf le morceau difficile. */
|
||||||
|
void EnableTunnelFeatures(FStrateGenerationParams& P)
|
||||||
|
{
|
||||||
|
P.PitDensity = 0.55f;
|
||||||
|
P.ChimneyDensity = 0.55f;
|
||||||
|
P.VerticalScale = 1.35f; // ≠ 1 ⇒ le Z « effectif » diverge du Z monde partout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FVoxelForgeOpStackTunnelTest::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::SlotTunnelNetwork, TopVoxelZ, BottomVoxelZ))
|
||||||
|
{
|
||||||
|
AddError(TEXT("The fixture layout has no TunnelNetwork slot. Check FTestWorld::Build's ")
|
||||||
|
TEXT("Archetypes[] against FTestWorld::SlotTunnelNetwork."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int32 MidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE;
|
||||||
|
FStrateGenerationParams P = World.StrateManager->GetGenerationParams(FIntVector(0, 0, MidChunkZ));
|
||||||
|
|
||||||
|
if (P.StrateTopWorldZ - P.StrateBottomWorldZ <= 0.0f)
|
||||||
|
{
|
||||||
|
AddError(TEXT("The TunnelNetwork strate has degenerate Z bounds."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
DisableStageBModifiers(P);
|
||||||
|
EnableTunnelFeatures(P);
|
||||||
|
|
||||||
|
FVoxelOpStack Stack;
|
||||||
|
VoxelDensityOps::BuildTunnelNetworkStack(Stack, P, World.Settings->Seed,
|
||||||
|
Gen->OriginSpineRadius, World.StrateManager.Get());
|
||||||
|
|
||||||
|
// rock + roomgraph + carve + worms + 3 structurels. Les 13 modificateurs de détail viendront
|
||||||
|
// s'insérer entre le carve et les vers — ce nombre DOIT bouger à l'étape B.
|
||||||
|
TestEqual(TEXT("the stage-A tunnel stack is decomposed into 6 ops"), Stack.Num(), 6);
|
||||||
|
|
||||||
|
FVoxelOpContext Ctx;
|
||||||
|
Ctx.Seed = (uint32)World.Settings->Seed;
|
||||||
|
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
|
||||||
|
Ctx.StrateTopWorldZ = P.StrateTopWorldZ;
|
||||||
|
Ctx.StrateBottomWorldZ = P.StrateBottomWorldZ;
|
||||||
|
Stack.PrepareChunk(Ctx);
|
||||||
|
|
||||||
|
// Grappes : N chunks, M points dans chacun. Voir l'en-tête — un tirage uniforme ferait
|
||||||
|
// reconstruire le cache SDF à presque chaque point, sur les DEUX chemins.
|
||||||
|
TArray<FVector> Points;
|
||||||
|
Points.Reserve(NumTunnelSamples);
|
||||||
|
{
|
||||||
|
FRandomStream Rng(1080601);
|
||||||
|
const int32 ChunkZ0 = BottomVoxelZ / CHUNK_SIZE;
|
||||||
|
const int32 ChunkZ1 = FMath::Max(ChunkZ0, (TopVoxelZ / CHUNK_SIZE) - 1);
|
||||||
|
for (int32 c = 0; c < NumTunnelChunks; ++c)
|
||||||
|
{
|
||||||
|
const int32 CX = Rng.RandRange(-3, 3);
|
||||||
|
const int32 CY = Rng.RandRange(-3, 3);
|
||||||
|
const int32 CZ = Rng.RandRange(ChunkZ0, ChunkZ1);
|
||||||
|
for (int32 i = 0; i < PointsPerChunk; ++i)
|
||||||
|
{
|
||||||
|
Points.Add(FVector(
|
||||||
|
(float)(CX * CHUNK_SIZE + Rng.RandRange(0, CHUNK_SIZE - 1)),
|
||||||
|
(float)(CY * CHUNK_SIZE + Rng.RandRange(0, CHUNK_SIZE - 1)),
|
||||||
|
(float)FMath::Clamp(CZ * CHUNK_SIZE + Rng.RandRange(0, CHUNK_SIZE - 1),
|
||||||
|
BottomVoxelZ, TopVoxelZ)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//=========================================================================
|
||||||
|
// 1. ÉQUIVALENCE
|
||||||
|
//=========================================================================
|
||||||
|
const float InnerBot = P.StrateBottomWorldZ + P.BoundarySealThickness;
|
||||||
|
const float InnerTop = P.StrateTopWorldZ - P.BoundarySealThickness;
|
||||||
|
|
||||||
|
int32 NumDiff = 0, NumSideDisagree = 0, WorstIdx = -1;
|
||||||
|
int32 NumInCave = 0, NumInRock = 0;
|
||||||
|
float WorstDelta = 0.0f;
|
||||||
|
|
||||||
|
for (int32 i = 0; i < NumTunnelSamples; ++i)
|
||||||
|
{
|
||||||
|
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
|
||||||
|
|
||||||
|
const float Old = Gen->GetDensityWithParams(X, Y, Z, P);
|
||||||
|
const float New = Stack.EvalMC(X, Y, Z);
|
||||||
|
|
||||||
|
const bool bInterior = (Z > InnerBot && Z < InnerTop);
|
||||||
|
if (bInterior && Old >= 0.0f) { ++NumInCave; } // air loin des seals ⇒ salle/tunnel/ver
|
||||||
|
if (bInterior && Old < 0.0f) { ++NumInRock; }
|
||||||
|
|
||||||
|
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("TunnelNetwork STAGE A: bit-identical across %d samples in %d chunks (%d in open ")
|
||||||
|
TEXT("cave, %d in rock, away from the seal bands). Exercised: vertical scale (1.35, so ")
|
||||||
|
TEXT("effective Z differs from world Z everywhere), cave warp, the room/tunnel SDF via ")
|
||||||
|
TEXT("the SHARED BuildChunkCache, pits and chimneys at UNWARPED coords, the carve with ")
|
||||||
|
TEXT("its floored divisor, and the worm carve with its network mask. NOT covered: the ")
|
||||||
|
TEXT("13 detail modifiers, the per-room op override, and any tile verdict."),
|
||||||
|
NumTunnelSamples, NumTunnelChunks, NumInCave, NumInRock));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("TunnelNetwork STAGE A: %d of %d samples differ (largest |delta| %.9g at ")
|
||||||
|
TEXT("(%.0f, %.0f, %.0f)); %d cross the isosurface. Check, in order: the carve's ")
|
||||||
|
TEXT("MinDivisor (TunnelNetwork floors Blend*2 at 1.0 and the other archetypes do NOT ")
|
||||||
|
TEXT("-- getting this wrong only shows up when SDFBlendRadius*2 < 1), then EffectiveZ ")
|
||||||
|
TEXT("(VerticalScale must divide BEFORE the warp and the worms, and must NOT touch pit ")
|
||||||
|
TEXT("or chimney Z), then the pit/chimney loops reading UNWARPED coords while the room ")
|
||||||
|
TEXT("SDF reads warped ones, then the SDF cache key (it now includes a params ")
|
||||||
|
TEXT("fingerprint the original lacks -- that can cost a rebuild, never a wrong room), ")
|
||||||
|
TEXT("then the worm early-out on N1 >= threshold."),
|
||||||
|
NumDiff, NumTunnelSamples, 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 (NumInCave == 0)
|
||||||
|
{
|
||||||
|
AddWarning(TEXT("No sample landed in open cave away from the seal bands: the room graph, ")
|
||||||
|
TEXT("the carve, the pits and the worms were never meaningfully exercised, so ")
|
||||||
|
TEXT("the equivalence above mostly compares solid rock to solid rock. Raise ")
|
||||||
|
TEXT("RoomDensity or lower RoomSpacing."));
|
||||||
|
}
|
||||||
|
|
||||||
|
//=========================================================================
|
||||||
|
// 2. INVARIANCE DE FENÊTRE — le test qui compte le plus sur cet archétype
|
||||||
|
//=========================================================================
|
||||||
|
// `BuildChunkCache` porte la discipline à deux régions de `ARCHITECTURE §8.4` : c'est LE endroit
|
||||||
|
// du plugin où un cache mal clé produit une couture visible entre deux tuiles. La pile ajoute sa
|
||||||
|
// propre clé par-dessus (boîte + strate + seed + empreinte de params + version de layout), donc
|
||||||
|
// c'est cette clé-là que ce bloc met à l'épreuve : mêmes points, ordre mélangé, N threads.
|
||||||
|
{
|
||||||
|
std::atomic<int32> Impure{ 0 };
|
||||||
|
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
|
||||||
|
|
||||||
|
TArray<float> Ref;
|
||||||
|
Ref.SetNumUninitialized(NumTunnelSamples);
|
||||||
|
for (int32 i = 0; i < NumTunnelSamples; ++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(NumTunnelSamples, 4400 + 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 tunnel stack is window-invariant across order and threads"),
|
||||||
|
Impure.load(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
//=========================================================================
|
||||||
|
// 3. LE CACHE NE PEUT PAS SERVIR LES PARAMS DU VOISIN
|
||||||
|
//=========================================================================
|
||||||
|
// La régression d'overhang du 2026-07-27 : deux piles dans la MÊME strate, au MÊME seed, ne
|
||||||
|
// différant QUE par des params, partageaient un cache `thread_local` dont la clé ignorait les
|
||||||
|
// params — et la seconde lisait les salles de la première. La pile clé donc aussi sur une
|
||||||
|
// empreinte CRC des params. Ce bloc le vérifie en ALTERNANT A, B, A, B au même point, le motif
|
||||||
|
// qui fait mentir une clé incomplète.
|
||||||
|
//
|
||||||
|
// ⚠️⚠️ ON NE COMPARE **PAS** À L'ORIGINAL ICI, ET C'EST LE POINT LE PLUS IMPORTANT DE CE TEST.
|
||||||
|
// `GetDensityWithParams` clé son cache sur (boîte XY, strate, seed) — **sans les params**. En
|
||||||
|
// alternance il rendrait donc, pour B, les salles de A : l'original ÉCHOUERAIT ce contrôle. Le
|
||||||
|
// comparer à lui ici ne mesurerait pas mon opérateur, ça mesurerait son bug. On compare donc
|
||||||
|
// chaque pile à ELLE-MÊME évaluée seule — un oracle qui ne partage pas le défaut testé.
|
||||||
|
//
|
||||||
|
// ⚠️ ET CE N'EST PEUT-ÊTRE PAS QU'UN ARTEFACT DE TEST — à vérifier, pas à croire. En production
|
||||||
|
// `GetGenerationParams` MÉLANGE les params entre strates voisines (transitions Gradient), donc
|
||||||
|
// deux chunks de Z différents dans la même strate peuvent avoir des params différents, avec la
|
||||||
|
// même boîte XY, le même index de strate et le même seed ⇒ aucune reconstruction. Si c'est
|
||||||
|
// exact, un worker qui descend une bande de transition sert les salles du chunk précédent.
|
||||||
|
// Noté dans `AUDIT §C2` comme SUSPECTÉ, avec le test qui le confirmerait — pas comme prouvé.
|
||||||
|
//
|
||||||
|
// We compare each stack to ITSELF evaluated alone, not to the original: the original keys its
|
||||||
|
// SDF cache without the params and would fail this check, so comparing against it would measure
|
||||||
|
// its bug rather than this operator.
|
||||||
|
{
|
||||||
|
FStrateGenerationParams P2 = P;
|
||||||
|
P2.RoomSpacing = P.RoomSpacing * 0.6f; // une autre disposition de salles
|
||||||
|
P2.RoomDensity = FMath::Min(P.RoomDensity * 1.7f, 1.0f);
|
||||||
|
|
||||||
|
FVoxelOpStack Stack2;
|
||||||
|
VoxelDensityOps::BuildTunnelNetworkStack(Stack2, P2, World.Settings->Seed,
|
||||||
|
Gen->OriginSpineRadius, World.StrateManager.Get());
|
||||||
|
Stack2.PrepareChunk(Ctx);
|
||||||
|
|
||||||
|
// Chaque pile compte 2 reconstructions de cache par point en alternance (elles partagent le
|
||||||
|
// `thread_local`), donc on reste modeste sur le nombre de sondes : `BuildChunkCache` est la
|
||||||
|
// fonction la plus chère du plugin.
|
||||||
|
const int32 Probe = FMath::Min(400, NumTunnelSamples);
|
||||||
|
|
||||||
|
TArray<float> SoloA, SoloB;
|
||||||
|
SoloA.SetNumUninitialized(Probe);
|
||||||
|
SoloB.SetNumUninitialized(Probe);
|
||||||
|
for (int32 i = 0; i < Probe; ++i)
|
||||||
|
{
|
||||||
|
SoloA[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
|
||||||
|
}
|
||||||
|
for (int32 i = 0; i < Probe; ++i)
|
||||||
|
{
|
||||||
|
SoloB[i] = Stack2.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
int32 NumWrong = 0, NumActuallyDifferent = 0;
|
||||||
|
for (int32 i = 0; i < Probe; ++i)
|
||||||
|
{
|
||||||
|
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
|
||||||
|
const float GotA = Stack.EvalMC(X, Y, Z);
|
||||||
|
const float GotB = Stack2.EvalMC(X, Y, Z);
|
||||||
|
|
||||||
|
if (!BitEqual(GotA, SoloA[i]) || !BitEqual(GotB, SoloB[i])) { ++NumWrong; }
|
||||||
|
if (!BitEqual(SoloA[i], SoloB[i])) { ++NumActuallyDifferent; }
|
||||||
|
}
|
||||||
|
|
||||||
|
TestEqual(TEXT("two tunnel stacks with different params never serve each other's rooms"),
|
||||||
|
NumWrong, 0);
|
||||||
|
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Params-fingerprint check: %d of %d probe points genuinely differ between the two ")
|
||||||
|
TEXT("param sets, and %d were served wrong under A/B interleaving. A zero in the FIRST ")
|
||||||
|
TEXT("number would mean the check proved nothing -- the two param sets must actually ")
|
||||||
|
TEXT("produce different rock for a stale cache to be detectable."),
|
||||||
|
NumActuallyDifferent, Probe, NumWrong));
|
||||||
|
|
||||||
|
if (NumActuallyDifferent == 0)
|
||||||
|
{
|
||||||
|
AddWarning(TEXT("The two param sets produced identical density at every probe point, so ")
|
||||||
|
TEXT("this check cannot distinguish a correct cache from a stale one. Make ")
|
||||||
|
TEXT("P2 differ more."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//=========================================================================
|
||||||
|
// 4. LE VERDICT DE BOÎTE — attendu NUL, et c'est le point
|
||||||
|
//=========================================================================
|
||||||
|
{
|
||||||
|
int32 NumProved = 0, NumMixed = 0;
|
||||||
|
FRandomStream Rng(97531);
|
||||||
|
for (int32 t = 0; t < 40; ++t)
|
||||||
|
{
|
||||||
|
const int32 Step = 1, Cells = 8;
|
||||||
|
const int32 Extent = Step * Cells;
|
||||||
|
const FIntVector Origin(
|
||||||
|
Rng.RandRange(-4, 4) * Extent,
|
||||||
|
Rng.RandRange(-4, 4) * 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));
|
||||||
|
|
||||||
|
if (Stack.ClassifyBox(Box, Ctx) == EVoxelTileClass::Mixed) { ++NumMixed; }
|
||||||
|
else { ++NumProved; }
|
||||||
|
}
|
||||||
|
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("Box verdicts over 40 TunnelNetwork tiles: %d proved, %d Mixed. %d proved is the ")
|
||||||
|
TEXT("EXPECTED result at stage A and not a defect: the room source answers Both (its ")
|
||||||
|
TEXT("bounds live in the SDF cache, which it would have to build for the queried box), ")
|
||||||
|
TEXT("and the worm source answers CarveOnly EVERYWHERE because a fielded noise carve ")
|
||||||
|
TEXT("has no spatial bound at all. Recovering these needs the numeric amplitude cap in ")
|
||||||
|
TEXT("OPSTACK-DECOMPOSITION 0.2 -- the largest single perf item in the whole plan, and ")
|
||||||
|
TEXT("the reason this archetype currently skips zero tiles."),
|
||||||
|
NumProved, NumMixed, NumProved));
|
||||||
|
|
||||||
|
TestEqual(TEXT("stage A emits no unsound verdict (it emits none at all)"), NumProved, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
#include "VoxelGenerator.h" // VoxelGenLOD::Eff
|
#include "VoxelGenerator.h" // VoxelGenLOD::Eff
|
||||||
#include "VoxelHeightOp.h" // FVoxelHeightStack — SurfaceWorld's two height stacks
|
#include "VoxelHeightOp.h" // FVoxelHeightStack — SurfaceWorld's two height stacks
|
||||||
#include "VoxelNoise.h" // VoxelNoise::FBM
|
#include "VoxelNoise.h" // VoxelNoise::FBM
|
||||||
|
#include "VoxelStrateDefinition.h" // TerrainOperations — le pool que BuildChunkCache tire par salle
|
||||||
#include "VoxelStrateManager.h" // EvaluateModifierSDF / AnyPassageNearBox
|
#include "VoxelStrateManager.h" // EvaluateModifierSDF / AnyPassageNearBox
|
||||||
#include "VoxelTypes.h" // SmoothStep01, VOXEL_NOISE_SCALE
|
#include "VoxelTypes.h" // SmoothStep01, VOXEL_NOISE_SCALE
|
||||||
|
|
||||||
@@ -1036,8 +1037,8 @@ namespace
|
|||||||
class FSdfConvertOp final : public IVoxelDensityOp
|
class FSdfConvertOp final : public IVoxelDensityOp
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
FSdfConvertOp(float InBlend, float InBaseDensity, float InSign)
|
FSdfConvertOp(float InBlend, float InBaseDensity, float InSign, float InMinDivisor)
|
||||||
: Blend(InBlend), BaseDensity(InBaseDensity), Sign(InSign) {}
|
: Blend(InBlend), BaseDensity(InBaseDensity), Sign(InSign), MinDivisor(InMinDivisor) {}
|
||||||
|
|
||||||
EVoxelOpRole GetRole() const override { return EVoxelOpRole::Combiner; }
|
EVoxelOpRole GetRole() const override { return EVoxelOpRole::Combiner; }
|
||||||
void PrepareChunk(const FVoxelOpContext&) override {}
|
void PrepareChunk(const FVoxelOpContext&) override {}
|
||||||
@@ -1045,7 +1046,14 @@ 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 T = FMath::Clamp((Blend - InOut.Sdf) / (Blend * 2.0f), 0.0f, 1.0f);
|
// ⚠️ `MinDivisor` n'est PAS une précaution ajoutée : TunnelNetwork écrit
|
||||||
|
// `/ FMath::Max(SDFBlendRadius * 2, 1.0f)` là où Maze/Shafts/Islands écrivent `/ (Blend*2)`.
|
||||||
|
// Les deux formules DIVERGENT dès que `Blend·2 < 1`, donc les confondre serait une faute
|
||||||
|
// de portage silencieuse. Avec `MinDivisor = 0` et un Blend positif, `Max(x, 0) == x`
|
||||||
|
// exactement — les trois portages déjà verts ne bougent pas d'un bit.
|
||||||
|
// Not a safety tweak: TunnelNetwork genuinely floors this divisor at 1 and the others
|
||||||
|
// do not. Max(x, 0) is exactly x for positive Blend, so existing ports are untouched.
|
||||||
|
float T = FMath::Clamp((Blend - InOut.Sdf) / FMath::Max(Blend * 2.0f, MinDivisor), 0.0f, 1.0f);
|
||||||
T = SmoothStep01(T);
|
T = SmoothStep01(T);
|
||||||
InOut.Density += Sign * T * BaseDensity * 2.0f; // interne : monter = vers le solide
|
InOut.Density += Sign * T * BaseDensity * 2.0f; // interne : monter = vers le solide
|
||||||
}
|
}
|
||||||
@@ -1056,7 +1064,7 @@ namespace
|
|||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
float Blend, BaseDensity, Sign;
|
float Blend, BaseDensity, Sign, MinDivisor;
|
||||||
};
|
};
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
@@ -1691,6 +1699,370 @@ namespace
|
|||||||
float ExtraReach;
|
float ExtraReach;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
//=========================================================================
|
||||||
|
// RÔLE 1 — SOURCE : GRAPHE DE SALLES / ROOM GRAPH (TunnelNetwork)
|
||||||
|
//=========================================================================
|
||||||
|
// ⚠️⚠️ CET OPÉRATEUR N'A PAS RÉÉCRIT `BuildChunkCache` / `EvaluateSDFCached` : IL LES APPELLE.
|
||||||
|
//
|
||||||
|
// C'est LA décision de ce portage, et elle mérite d'être dite explicitement parce que la
|
||||||
|
// tentation inverse est forte : les six autres portages sont des transcriptions littérales.
|
||||||
|
// Celui-ci ne peut pas l'être. `BuildChunkCache` porte la discipline d'invariance de fenêtre à
|
||||||
|
// deux régions (ARCHITECTURE §8.4) — la région COLLECT (plus large, décide QUELLES primitives
|
||||||
|
// existent) et la région STORE (ce qu'on garde) — et c'est le code le plus délicat du plugin.
|
||||||
|
// Le transcrire, ce serait le FORKER : deux copies d'un invariant qui dérivent, dont l'une n'est
|
||||||
|
// testée que par un test d'équivalence qui compare... la copie à l'original.
|
||||||
|
//
|
||||||
|
// Ce qui EST transcrit ici, c'est la glu autour : la mémo d'index de strate, la clé de cache par
|
||||||
|
// BOÎTE DE RECHERCHE (pas par chunk — voir plus bas), le warp, et les boucles pits/cheminées.
|
||||||
|
// ~60 lignes déjà relues, contre ~400 lignes d'algorithme qu'on ne touche pas.
|
||||||
|
//
|
||||||
|
// This op CALLS the morphology cache rather than transcribing it: BuildChunkCache carries the
|
||||||
|
// two-region window-invariance discipline (§8.4) and forking it would be the worst possible
|
||||||
|
// outcome of a refactor whose whole point is to have ONE definition of each idea.
|
||||||
|
class FRoomGraphSource final : public IVoxelDensityOp
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
FRoomGraphSource(const FStrateGenerationParams& InP, int32 InSeed,
|
||||||
|
const UVoxelStrateManager* InManager)
|
||||||
|
: P(InP), Seed(InSeed), SeedU((uint32)InSeed), Manager(InManager)
|
||||||
|
, ParamsFingerprint(FCrc::MemCrc32(&InP, sizeof(InP)))
|
||||||
|
{}
|
||||||
|
|
||||||
|
EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; }
|
||||||
|
|
||||||
|
void PrepareChunk(const FVoxelOpContext& Ctx) override
|
||||||
|
{
|
||||||
|
// La seule chose vraiment constante par chunk ET dépendante du contexte. Le reste
|
||||||
|
// (index de strate, pool d'ops) est résolu paresseusement dans `Eval` comme l'original,
|
||||||
|
// parce que le cache SDF se ré-clé sur une BOÎTE, pas sur un chunk.
|
||||||
|
LayoutVersion = Ctx.LayoutVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Le Z « effectif » : `VerticalScale` étire le monde AVANT le bruit. Pure fonction de Z et
|
||||||
|
* d'un param — c'est pourquoi ce portage n'a PAS eu besoin d'un opérateur « frame »
|
||||||
|
* (voir la note de conception dans BuildTunnelNetworkStack). */
|
||||||
|
FORCEINLINE float EffZ(float WorldZ) const
|
||||||
|
{
|
||||||
|
return (P.VerticalScale != 1.0f && P.VerticalScale > 0.0f) ? (WorldZ / P.VerticalScale)
|
||||||
|
: WorldZ;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override
|
||||||
|
{
|
||||||
|
if (!(P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f)) { return; } // Sdf reste FLT_MAX
|
||||||
|
|
||||||
|
const float EffectiveZ = EffZ(WorldZ);
|
||||||
|
|
||||||
|
//---------------------------------------------------------------
|
||||||
|
// WARP DE CAVE — coordonnées de REQUÊTE uniquement
|
||||||
|
//---------------------------------------------------------------
|
||||||
|
// ⚠️ Le warp ne s'applique QU'À la requête du graphe de salles. Les pits et les cheminées
|
||||||
|
// plus bas lisent les coordonnées RÉELLES, et c'est délibéré dans l'original : leurs
|
||||||
|
// ancres viennent de centres de salles NON warpés. C'est aussi pourquoi le warp n'est pas
|
||||||
|
// un « frame » : sa portée est exactement UN opérateur, donc elle appartient à cet
|
||||||
|
// opérateur.
|
||||||
|
float WarpedX = WorldX, WarpedY = WorldY, WarpedZ = EffectiveZ;
|
||||||
|
if (P.CaveWarpStrength > 0.0f)
|
||||||
|
{
|
||||||
|
const float WF = P.CaveWarpFrequency;
|
||||||
|
const float WS = P.CaveWarpStrength;
|
||||||
|
WarpedX += VoxelNoise::Perlin3D(FVector(
|
||||||
|
WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.37f),
|
||||||
|
WorldY * WF + 1.3f,
|
||||||
|
EffectiveZ * WF + 5.7f)) * VOXEL_NOISE_SCALE * WS;
|
||||||
|
WarpedY += VoxelNoise::Perlin3D(FVector(
|
||||||
|
WorldX * WF + 7.1f,
|
||||||
|
WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.59f),
|
||||||
|
EffectiveZ * WF + 2.3f)) * VOXEL_NOISE_SCALE * WS;
|
||||||
|
WarpedZ += VoxelNoise::Perlin3D(FVector(
|
||||||
|
WorldX * WF + 11.3f,
|
||||||
|
WorldY * WF + 9.7f,
|
||||||
|
EffectiveZ * WF + VoxelHash::SeedOffset(SeedU, 0.41f))) * VOXEL_NOISE_SCALE * WS;
|
||||||
|
}
|
||||||
|
|
||||||
|
//---------------------------------------------------------------
|
||||||
|
// LE CACHE PAR BOÎTE DE RECHERCHE
|
||||||
|
//---------------------------------------------------------------
|
||||||
|
// ⚠️ CLÉ PAR BOÎTE, PAS PAR CHUNK, et c'est un INVARIANT DE PERF (§8.10) : les
|
||||||
|
// échantillons de gradient interrogent `WorldX ± 1` et le warp déplace encore, donc une
|
||||||
|
// clé « égalité de chunk » se retournait à chaque cellule de bord et reconstruisait le
|
||||||
|
// cache (coûteux) en boucle. Comme le cache couvre la boîte + MaxInfluence, toute requête
|
||||||
|
// DANS la boîte est correcte. Ne pas « simplifier » en clé de chunk.
|
||||||
|
thread_local FChunkSDFCache SDFCache;
|
||||||
|
thread_local float CachedSMinX = 1.0f, CachedSMaxX = -1.0f; // invalide au départ
|
||||||
|
thread_local float CachedSMinY = 0.0f, CachedSMaxY = 0.0f;
|
||||||
|
thread_local int32 CachedStrate = INT32_MIN;
|
||||||
|
thread_local uint32 CachedSeed = 0;
|
||||||
|
// ⚠️ AJOUTÉ PAR RAPPORT À L'ORIGINAL — la leçon du 2026-07-27 (régression d'overhang).
|
||||||
|
// L'original ne clé QUE sur (boîte, strate, seed) : deux jeux de params différents dans
|
||||||
|
// la MÊME strate au MÊME seed se servent mutuellement leur cache. En production
|
||||||
|
// `RebuildStrates` masque le trou en bougeant la strate ; en test, deux piles construites
|
||||||
|
// côte à côte le déclenchent immédiatement. Empreinte CRC des params + LayoutVersion.
|
||||||
|
// `FStrateGenerationParams` est du POD pur (aucun TArray/FString/pointeur), donc une CRC
|
||||||
|
// mémoire ne peut pas donner un FAUX POSITIF ; au pire un padding donne un faux MANQUE,
|
||||||
|
// c'est-à-dire un recalcul. On se trompe du côté du CPU, jamais du côté d'une salle fausse.
|
||||||
|
thread_local uint32 CachedFingerprint = 0xFFFFFFFFu;
|
||||||
|
thread_local uint32 CachedLayout = 0xFFFFFFFFu;
|
||||||
|
|
||||||
|
// Index de strate — mémo (chunk-Z, version de layout), transcrit tel quel. La requête
|
||||||
|
// vise le CENTRE de la bande, donc le résultat est une fonction pure de la clé.
|
||||||
|
int32 StrateIdx = 0;
|
||||||
|
if (Manager)
|
||||||
|
{
|
||||||
|
thread_local int32 SI_ChunkZ = INT32_MAX;
|
||||||
|
thread_local uint32 SI_Version = 0xFFFFFFFFu;
|
||||||
|
thread_local int32 SI_Index = 0;
|
||||||
|
const int32 QZ = FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE);
|
||||||
|
const uint32 LV = Manager->GetLayoutVersion();
|
||||||
|
if (QZ != SI_ChunkZ || LV != SI_Version)
|
||||||
|
{
|
||||||
|
SI_ChunkZ = QZ;
|
||||||
|
SI_Version = LV;
|
||||||
|
SI_Index = Manager->GetStrateIndex(((float)QZ + 0.5f) * CHUNK_SIZE * VOXEL_SIZE);
|
||||||
|
}
|
||||||
|
StrateIdx = SI_Index;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool bNeedRebuild =
|
||||||
|
StrateIdx != CachedStrate || SeedU != CachedSeed ||
|
||||||
|
ParamsFingerprint != CachedFingerprint || LayoutVersion != CachedLayout ||
|
||||||
|
WarpedX < CachedSMinX || WarpedX > CachedSMaxX ||
|
||||||
|
WarpedY < CachedSMinY || WarpedY > CachedSMaxY;
|
||||||
|
|
||||||
|
if (bNeedRebuild)
|
||||||
|
{
|
||||||
|
const int32 CacheChunkX = FMath::FloorToInt(WorldX / (float)CHUNK_SIZE);
|
||||||
|
const int32 CacheChunkY = FMath::FloorToInt(WorldY / (float)CHUNK_SIZE);
|
||||||
|
const float ChunkMinX = CacheChunkX * (float)CHUNK_SIZE;
|
||||||
|
const float ChunkMinY = CacheChunkY * (float)CHUNK_SIZE;
|
||||||
|
const float ChunkMaxX = ChunkMinX + (float)CHUNK_SIZE;
|
||||||
|
const float ChunkMaxY = ChunkMinY + (float)CHUNK_SIZE;
|
||||||
|
const float Expansion = P.CaveWarpStrength + 2.0f;
|
||||||
|
|
||||||
|
const float SMinX = ChunkMinX - Expansion;
|
||||||
|
const float SMinY = ChunkMinY - Expansion;
|
||||||
|
const float SMaxX = ChunkMaxX + Expansion;
|
||||||
|
const float SMaxY = ChunkMaxY + Expansion;
|
||||||
|
|
||||||
|
const TArray<FStrateTerrainOpEntry>* TerrainOps = nullptr;
|
||||||
|
if (Manager)
|
||||||
|
{
|
||||||
|
const int32 ChunkZ = FMath::FloorToInt(WorldZ / (float)CHUNK_SIZE);
|
||||||
|
UVoxelStrateDefinition* Def = Manager->GetStrateForChunk(
|
||||||
|
FIntVector(CacheChunkX, CacheChunkY, ChunkZ));
|
||||||
|
if (Def) { TerrainOps = &Def->TerrainOperations; }
|
||||||
|
}
|
||||||
|
|
||||||
|
VoxelCaveMorphology::BuildChunkCache(
|
||||||
|
SDFCache, SMinX, SMinY, SMaxX, SMaxY, P, SeedU, StrateIdx, TerrainOps);
|
||||||
|
|
||||||
|
CachedSMinX = SMinX; CachedSMaxX = SMaxX;
|
||||||
|
CachedSMinY = SMinY; CachedSMaxY = SMaxY;
|
||||||
|
CachedStrate = StrateIdx;
|
||||||
|
CachedSeed = SeedU;
|
||||||
|
CachedFingerprint = ParamsFingerprint;
|
||||||
|
CachedLayout = LayoutVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
int32 NearestRoom = -1;
|
||||||
|
float CaveSDF = VoxelCaveMorphology::EvaluateSDFCached(
|
||||||
|
WarpedX, WarpedY, WarpedZ, SDFCache, P.SDFBlendRadius, &NearestRoom);
|
||||||
|
|
||||||
|
//---------------------------------------------------------------
|
||||||
|
// PITS & CHEMINÉES — coordonnées RÉELLES, SmoothMin dans le même canal SDF
|
||||||
|
//---------------------------------------------------------------
|
||||||
|
// C'est le point que `OPSTACK-DECOMPOSITION §2` annonçait comme « le plus retors de toute
|
||||||
|
// la décomposition » : deux primitives qui écrivent le MÊME canal que le graphe de salles
|
||||||
|
// mais à des coordonnées NON warpées. Sous un modèle de frames il aurait fallu les sortir
|
||||||
|
// du frame tout en gardant le canal — exprimable, mais tordu. Dans un opérateur unique la
|
||||||
|
// difficulté disparaît : le warp est une variable locale, pas un contexte hérité.
|
||||||
|
for (const FCachedPit& Pit : SDFCache.Pits)
|
||||||
|
{
|
||||||
|
const float DZ = WorldZ - Pit.TopZ;
|
||||||
|
if (DZ >= Pit.BlendK) { continue; }
|
||||||
|
if (-DZ > Pit.Depth + Pit.BlendK) { continue; }
|
||||||
|
|
||||||
|
const float DX = WorldX - Pit.CenterX;
|
||||||
|
const float DY = WorldY - Pit.CenterY;
|
||||||
|
const float XYDistSq = DX * DX + DY * DY;
|
||||||
|
if (XYDistSq > Pit.BoundXYRadiusSq) { continue; }
|
||||||
|
|
||||||
|
float PitSDF;
|
||||||
|
if (DZ <= 0.0f)
|
||||||
|
{
|
||||||
|
const float DepthBelow = -DZ;
|
||||||
|
float FlareFactor = FMath::Clamp(1.0f - DepthBelow / Pit.FlareDist, 0.0f, 1.0f);
|
||||||
|
FlareFactor = FlareFactor * FlareFactor;
|
||||||
|
const float EffRadius = Pit.Radius + Pit.FlareExtra * FlareFactor;
|
||||||
|
PitSDF = FMath::Sqrt(XYDistSq) - EffRadius;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
PitSDF = FMath::Sqrt(XYDistSq) - (Pit.Radius + Pit.FlareExtra);
|
||||||
|
}
|
||||||
|
|
||||||
|
CaveSDF = VoxelSDF::SmoothMin(CaveSDF, PitSDF, Pit.BlendK);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const FCachedChimney& Chim : SDFCache.Chimneys)
|
||||||
|
{
|
||||||
|
const float DZ = WorldZ - Chim.BottomZ;
|
||||||
|
if (-DZ >= Chim.BlendK) { continue; }
|
||||||
|
if (DZ > Chim.Height + Chim.BlendK) { continue; }
|
||||||
|
|
||||||
|
const float DX = WorldX - Chim.CenterX;
|
||||||
|
const float DY = WorldY - Chim.CenterY;
|
||||||
|
const float XYDistSq = DX * DX + DY * DY;
|
||||||
|
if (XYDistSq > Chim.BoundXYRadiusSq) { continue; }
|
||||||
|
|
||||||
|
float ChmSDF;
|
||||||
|
if (DZ >= 0.0f)
|
||||||
|
{
|
||||||
|
float FlareFactor = FMath::Clamp(1.0f - DZ / Chim.FlareDist, 0.0f, 1.0f);
|
||||||
|
FlareFactor = FlareFactor * FlareFactor;
|
||||||
|
const float EffRadius = Chim.Radius + Chim.FlareExtra * FlareFactor;
|
||||||
|
ChmSDF = FMath::Sqrt(XYDistSq) - EffRadius;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ChmSDF = FMath::Sqrt(XYDistSq) - (Chim.Radius + Chim.FlareExtra);
|
||||||
|
}
|
||||||
|
|
||||||
|
CaveSDF = VoxelSDF::SmoothMin(CaveSDF, ChmSDF, Chim.BlendK);
|
||||||
|
}
|
||||||
|
|
||||||
|
InOut.Sdf = CaveSDF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ `Both` POUR L'INSTANT, ET C'EST UNE DETTE ASSUMÉE, PAS UN OUBLI.
|
||||||
|
*
|
||||||
|
* Les bornes existent pourtant : `FCachedRoom` / `FCachedTunnel` portent déjà leurs
|
||||||
|
* `Bound*` (c'est ce dont `§2` dit qu'il rend le bedrock profond prouvable, « le plus gros
|
||||||
|
* poste de perf de tout le plan »). Ce qui manque, c'est que répondre honnêtement demande de
|
||||||
|
* consulter le cache — donc de le CONSTRUIRE pour la boîte interrogée, sur le thread qui
|
||||||
|
* interroge, ce qui n'est raisonnable qu'une fois `ClassifyBox` réellement branché dans
|
||||||
|
* `ClassifyTile` (il ne l'est toujours pas). Rendre `Both` coûte du CPU et ne peut pas faire
|
||||||
|
* de trou ; rendre le mauvais en ferait un.
|
||||||
|
*
|
||||||
|
* Conservative placeholder: the room/tunnel bounds needed for a real answer are already in
|
||||||
|
* the cache, but answering means building that cache for the queried box, which only pays
|
||||||
|
* once ClassifyTile actually consumes ClassifyBox. Both is always safe.
|
||||||
|
*/
|
||||||
|
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
|
||||||
|
{
|
||||||
|
return (P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f) ? EVoxelOpEffect::Both
|
||||||
|
: EVoxelOpEffect::Identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
FStrateGenerationParams P;
|
||||||
|
int32 Seed;
|
||||||
|
uint32 SeedU;
|
||||||
|
const UVoxelStrateManager* Manager; // NON possédant
|
||||||
|
uint32 ParamsFingerprint;
|
||||||
|
uint32 LayoutVersion = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
//=========================================================================
|
||||||
|
// RÔLE 1 — SOURCE : VERS / WORM TUNNELS (TunnelNetwork)
|
||||||
|
//=========================================================================
|
||||||
|
// Un carve par SEUIL sur du bruit 3D, masqué par la distance au réseau de salles. Il écrit la
|
||||||
|
// DENSITÉ directement (pas le canal SDF) : c'est une source « fieldée », pas une primitive
|
||||||
|
// placée — la distinction que `AUDIT §6.2` pose et que `OPSTACK-DECOMPOSITION §0.2` chiffre.
|
||||||
|
//
|
||||||
|
// ⚠️ IL LIT `InOut.Sdf` : le masque de réseau est une fonction de `CaveSDF` APRÈS pits et
|
||||||
|
// cheminées. C'est encore le canal SDF utilisé comme ce pour quoi il existe — transporter une
|
||||||
|
// information géométrique entre deux opérateurs au lieu de la recalculer.
|
||||||
|
class FWormFieldSource final : public IVoxelDensityOp
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
FWormFieldSource(const FStrateGenerationParams& InP, int32 Seed)
|
||||||
|
: P(InP), SeedU((uint32)Seed) {}
|
||||||
|
|
||||||
|
EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; }
|
||||||
|
void PrepareChunk(const FVoxelOpContext&) override {}
|
||||||
|
|
||||||
|
void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override
|
||||||
|
{
|
||||||
|
if (!(P.WormStrength > 0.0f && P.WormThreshold > 0.0f)) { return; }
|
||||||
|
|
||||||
|
const float EffectiveZ = (P.VerticalScale != 1.0f && P.VerticalScale > 0.0f)
|
||||||
|
? (WorldZ / P.VerticalScale) : WorldZ;
|
||||||
|
const float CaveSDF = InOut.Sdf;
|
||||||
|
|
||||||
|
float NetworkMask = 1.0f;
|
||||||
|
if (P.WormNetworkRange > 0.0f)
|
||||||
|
{
|
||||||
|
if (CaveSDF >= P.WormNetworkRange) // vrai aussi quand il n'y a pas de réseau (FLT_MAX)
|
||||||
|
{
|
||||||
|
NetworkMask = 0.0f;
|
||||||
|
}
|
||||||
|
else if (CaveSDF > 0.0f)
|
||||||
|
{
|
||||||
|
NetworkMask = 1.0f - SmoothStep01(CaveSDF / P.WormNetworkRange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (NetworkMask <= 0.0f) { return; }
|
||||||
|
|
||||||
|
const float WormZFreq = P.WormFrequency * P.WormHorizontalBias;
|
||||||
|
|
||||||
|
const float N1 = FMath::Abs(VoxelNoise::Perlin3D(FVector(
|
||||||
|
WorldX * P.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.0f),
|
||||||
|
WorldY * P.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.7f),
|
||||||
|
EffectiveZ * WormZFreq + VoxelHash::SeedOffset(SeedU, 2.3f)
|
||||||
|
)) * VOXEL_NOISE_SCALE);
|
||||||
|
|
||||||
|
// N2 ≥ 0, donc si N1 dépasse déjà le seuil la somme ne peut plus creuser — on saute le
|
||||||
|
// second Perlin (le cas courant ; sortie bit-identique). Transcrit tel quel.
|
||||||
|
if (N1 >= P.WormThreshold) { return; }
|
||||||
|
|
||||||
|
const float N2 = FMath::Abs(VoxelNoise::Perlin3D(FVector(
|
||||||
|
WorldX * P.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.0f) + 137.0f,
|
||||||
|
WorldY * P.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.7f) + 259.0f,
|
||||||
|
EffectiveZ * WormZFreq + VoxelHash::SeedOffset(SeedU, 2.3f) + 431.0f
|
||||||
|
)) * VOXEL_NOISE_SCALE);
|
||||||
|
|
||||||
|
const float WormValue = N1 + N2;
|
||||||
|
if (WormValue < P.WormThreshold)
|
||||||
|
{
|
||||||
|
const float t = 1.0f - (WormValue / P.WormThreshold);
|
||||||
|
InOut.Density -= t * P.WormStrength * NetworkMask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ `CarveOnly` PARTOUT quand les vers sont actifs — et c'est exactement le problème que
|
||||||
|
* `OPSTACK-DECOMPOSITION §0.2` isole : un carve fieldé n'a AUCUNE borne spatiale, donc il tue
|
||||||
|
* l'hypothèse `AllSolid` sur CHAQUE tuile de CHAQUE strate à vers. La direction seule ne peut
|
||||||
|
* pas le récupérer.
|
||||||
|
*
|
||||||
|
* **Mais l'amplitude, elle, est bornée et triviale** : `t ∈ [0,1]`, `NetworkMask ∈ [0,1]`,
|
||||||
|
* donc ce ver ne peut déplacer la densité vers l'air que de `WormStrength` au plus. Dès que
|
||||||
|
* le pliage saura porter un INTERVALLE numérique et pas seulement une direction, « le rocher
|
||||||
|
* est solide de plus que la somme des carves restants » redevient prouvable — et c'est le
|
||||||
|
* plus gros poste de perf du plan. Noté ici, au point exact où la borne manque.
|
||||||
|
*/
|
||||||
|
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
|
||||||
|
{
|
||||||
|
return (P.WormStrength > 0.0f && P.WormThreshold > 0.0f) ? EVoxelOpEffect::CarveOnly
|
||||||
|
: EVoxelOpEffect::Identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** L'amplitude max de carve, en unités de densité. Pas encore consommée par le pliage —
|
||||||
|
* posée ici pour que la borne de `§0.2` ait déjà un domicile quand les intervalles
|
||||||
|
* arriveront. / The bound §0.2 needs, given a home before it has a consumer. */
|
||||||
|
float MaxCarveAmplitude() const
|
||||||
|
{
|
||||||
|
return (P.WormStrength > 0.0f && P.WormThreshold > 0.0f) ? P.WormStrength : 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
FStrateGenerationParams P;
|
||||||
|
uint32 SeedU;
|
||||||
|
};
|
||||||
|
|
||||||
} // ⚠️ 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
|
||||||
@@ -1742,14 +2114,14 @@ namespace VoxelDensityOps
|
|||||||
return MakeUnique<FSdfRoughnessMod>(Strength, Frequency, BaseOctaves, ApplyWithin);
|
return MakeUnique<FSdfRoughnessMod>(Strength, Frequency, BaseOctaves, ApplyWithin);
|
||||||
}
|
}
|
||||||
|
|
||||||
TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity)
|
TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity, float MinDivisor)
|
||||||
{
|
{
|
||||||
return MakeUnique<FSdfConvertOp>(Blend, BaseDensity, -1.0f);
|
return MakeUnique<FSdfConvertOp>(Blend, BaseDensity, -1.0f, MinDivisor);
|
||||||
}
|
}
|
||||||
|
|
||||||
TUniquePtr<IVoxelDensityOp> MakeSdfFill(float Blend, float BaseDensity)
|
TUniquePtr<IVoxelDensityOp> MakeSdfFill(float Blend, float BaseDensity)
|
||||||
{
|
{
|
||||||
return MakeUnique<FSdfConvertOp>(Blend, BaseDensity, +1.0f);
|
return MakeUnique<FSdfConvertOp>(Blend, BaseDensity, +1.0f, 0.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
TUniquePtr<IVoxelDensityOp> MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed)
|
TUniquePtr<IVoxelDensityOp> MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed)
|
||||||
@@ -1837,6 +2209,52 @@ namespace VoxelDensityOps
|
|||||||
P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager);
|
P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void BuildTunnelNetworkStack(FVoxelOpStack& OutStack, const FStrateGenerationParams& P,
|
||||||
|
int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager)
|
||||||
|
{
|
||||||
|
// ⚠️ ÉTAPE A SUR TROIS — LA PILE EST INCOMPLÈTE, ET DÉLIBÉRÉMENT.
|
||||||
|
// Sont portés : l'échelle verticale, le roc de base, le warp, le graphe de salles (+ pits
|
||||||
|
// + cheminées), le carve, les vers, le post structurel. **NE SONT PAS ENCORE PORTÉS** les
|
||||||
|
// treize modificateurs de détail de l'étape 4b-4h (rugosité, terrasses, lignes de strates,
|
||||||
|
// nervures, surplombs, falaise, festons, arches, colonnes, dômes, pincement, biais de sol),
|
||||||
|
// ni l'override d'op PAR SALLE.
|
||||||
|
//
|
||||||
|
// C'est pour cela que `UsesOperatorStackForChunk` rend encore **false** pour TunnelNetwork :
|
||||||
|
// brancher une pile incomplète sur le monde en retirerait tout le détail. Le test compare
|
||||||
|
// avec ces amplitudes MISES À ZÉRO, donc l'étape A est entièrement vérifiable dès
|
||||||
|
// maintenant au lieu d'attendre ~600 lignes de plus — c'est la même discipline que la passe
|
||||||
|
// « défauts puis tous les ops ON » du test de la pile de hauteur.
|
||||||
|
//
|
||||||
|
// STAGE A OF THREE, deliberately incomplete: the 13 detail modifiers and the per-room op
|
||||||
|
// override are not ported yet, which is why the archetype is still off in
|
||||||
|
// UsesOperatorStackForChunk. The test zeroes those amplitudes so stage A is verifiable now.
|
||||||
|
//
|
||||||
|
//---------------------------------------------------------------------
|
||||||
|
// ⚠️ CE PORTAGE RETIRE L'IDÉE DE « FRAME OPS » (OPSTACK-DECOMPOSITION §1)
|
||||||
|
//---------------------------------------------------------------------
|
||||||
|
// `§2` décrivait deux frames imbriqués : `VerticalScale` et `CaveWarp`. En les portant pour
|
||||||
|
// de vrai, les deux se sont dissous :
|
||||||
|
// • `CaveWarp` a une portée d'EXACTEMENT UN opérateur (le graphe de salles — pits et
|
||||||
|
// cheminées lisent explicitement les coordonnées non warpées). Une transformation qui
|
||||||
|
// n'enveloppe qu'un opérateur n'est pas un frame, c'est une variable locale.
|
||||||
|
// • `VerticalScale` est `Z / Scale` : une fonction PURE d'un scalaire et d'un param, que
|
||||||
|
// chaque opérateur qui en a besoin recalcule en une ligne. Un frame ne ferait
|
||||||
|
// qu'ajouter un canal pour éviter une division.
|
||||||
|
// Il restait le warp d'îles (§7), déjà gardé local pour la même raison. **Zéro frame sur
|
||||||
|
// trois candidats** : ce n'était pas une infrastructure manquante, c'était trois fois la
|
||||||
|
// même chose vue de loin. Noté ici plutôt que laissé en TODO permanent.
|
||||||
|
constexpr float CarveMinDivisor = 1.0f; // TunnelNetwork plancher son diviseur, cf. FSdfConvertOp
|
||||||
|
|
||||||
|
OutStack.Add(MakeConstantRockSource(P.BaseDensity));
|
||||||
|
OutStack.Add(MakeUnique<FRoomGraphSource>(P, Seed, StrateManager));
|
||||||
|
OutStack.Add(MakeSdfCarve(P.SDFBlendRadius, P.BaseDensity, CarveMinDivisor));
|
||||||
|
// [ÉTAPE B ira ici : les 13 modificateurs de détail, gated sur `Sdf < SDFBlendRadius·3`]
|
||||||
|
OutStack.Add(MakeUnique<FWormFieldSource>(P, Seed));
|
||||||
|
|
||||||
|
OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ,
|
||||||
|
P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager);
|
||||||
|
}
|
||||||
|
|
||||||
void BuildFloatingIslandStack(FVoxelOpStack& OutStack, const FFloatingIslandParams& P,
|
void BuildFloatingIslandStack(FVoxelOpStack& OutStack, const FFloatingIslandParams& P,
|
||||||
int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager)
|
int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -193,8 +193,13 @@ namespace VoxelDensityOps
|
|||||||
int32 BaseOctaves, float ApplyWithin);
|
int32 BaseOctaves, float ApplyWithin);
|
||||||
|
|
||||||
/** Rôle 2 — conversion SDF → densité : creuse de l'air là où le SDF est à l'intérieur.
|
/** Rôle 2 — conversion SDF → densité : creuse de l'air là où le SDF est à l'intérieur.
|
||||||
* Les six mêmes lignes apparaissent aujourd'hui dans TunnelNetwork, Maze et VerticalShafts. */
|
* Les six mêmes lignes apparaissent aujourd'hui dans TunnelNetwork, Maze et VerticalShafts.
|
||||||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity);
|
* @param MinDivisor plancher du diviseur `Blend·2`. **TunnelNetwork passe 1.0** (son original
|
||||||
|
* écrit `FMath::Max(SDFBlendRadius·2, 1)`) ; Maze/Shafts laissent 0, où
|
||||||
|
* `Max(x,0) == x` exactement. Les deux formules divergent si `Blend·2 < 1`,
|
||||||
|
* donc ce paramètre est une vraie différence, pas une précaution. */
|
||||||
|
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity,
|
||||||
|
float MinDivisor = 0.0f);
|
||||||
|
|
||||||
/** Rôle 2 — la même conversion, signe opposé : REMPLIT du solide là où le SDF est à l'intérieur.
|
/** 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
|
* C'est ce que fait FloatingIslands (`Density += Fill·Base·2`), et la multiplication par ±1
|
||||||
@@ -260,6 +265,24 @@ namespace VoxelDensityOps
|
|||||||
int32 Seed, float SpineRadius,
|
int32 Seed, float SpineRadius,
|
||||||
const UVoxelStrateManager* StrateManager);
|
const UVoxelStrateManager* StrateManager);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TunnelNetwork — **ÉTAPE A SUR TROIS, PILE INCOMPLÈTE** :
|
||||||
|
* ConstantRock → RoomGraph(warp + pits + cheminées) → SdfCarve → Worms → [structural ×3]
|
||||||
|
*
|
||||||
|
* ⛔ NE PAS brancher cet archétype dans `UsesOperatorStackForChunk` avant l'étape C : les 13
|
||||||
|
* modificateurs de détail (4b–4h) et l'override d'op par salle ne sont pas portés, donc le monde
|
||||||
|
* y perdrait tout son détail. Le test compare avec ces amplitudes à zéro.
|
||||||
|
*
|
||||||
|
* ⚠️ `FRoomGraphSource` **APPELLE** `BuildChunkCache`/`EvaluateSDFCached`, il ne les transcrit
|
||||||
|
* pas : c'est là que vit la discipline d'invariance de fenêtre à deux régions (`ARCHITECTURE
|
||||||
|
* §8.4`), et en faire une copie serait le pire résultat possible pour un refactor dont le but est
|
||||||
|
* d'avoir UNE définition de chaque idée.
|
||||||
|
*/
|
||||||
|
VOXELFORGE_API void BuildTunnelNetworkStack(FVoxelOpStack& OutStack,
|
||||||
|
const FStrateGenerationParams& P,
|
||||||
|
int32 Seed, float SpineRadius,
|
||||||
|
const UVoxelStrateManager* StrateManager);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FloatingIslands — 7 ops, et **la pile tourne à l'ENVERS** :
|
* FloatingIslands — 7 ops, et **la pile tourne à l'ENVERS** :
|
||||||
* ConstantVoid → IslandBlob → SdfRoughness → SdfFill → [structural post ×3]
|
* ConstantVoid → IslandBlob → SdfRoughness → SdfFill → [structural post ×3]
|
||||||
|
|||||||
Reference in New Issue
Block a user