diff --git a/AUDIT-2026-07.md b/AUDIT-2026-07.md index d95a0a8..9670d39 100644 --- a/AUDIT-2026-07.md +++ b/AUDIT-2026-07.md @@ -391,14 +391,33 @@ risk is real. That is one build, and it settles it. ### C10 — The op-stack ULP residue: ~~PARKED~~ **CLOSED PERMANENTLY**, 2026-07-27 -> **⛔ CLOSED, and now provably irrelevant — not merely parked.** Jahni, 2026-07-27: *"I do not need -> your work to be identical or near identical to what I had before, only having it 99.99% at worst -> reproducible if two people share the same seed."* This entry measures **old path vs new path** -> agreement, and the two paths will never both exist in a shipped world. **No requirement anywhere -> in the project depends on this number.** Do not spend another minute on it. +> # ✅ SOLVED, 2026-07-27 — and the cause was `/fp:fast`, exactly as hypothesis 3 said. > -> What survives is the *method* lesson at the bottom of this entry, and the redirect: the -> reproducibility that IS required is **`§C9`** — across binaries and platforms, not across refactors. +> **Setting `FPSemantics = Precise` (see `§C9`) made both `MazeEquivalence` and `SlabEquivalence` +> report BIT-IDENTICAL.** The residue is gone — not tolerated, *gone*. +> +> **So hypothesis 3 had the right mechanism and every isolation experiment built on it was doomed.** +> Under `/fp:fast` the compiler may reassociate and contract *based on surrounding context*, with +> **no single isolable axis** — which is precisely why five carefully-designed one-variable tests +> (FVector round-trip, transcription, cross-TU, inlining, const-vs-runtime) all came back negative +> while the difference stayed. There was no variable to find. Removing the *permission* removed the +> difference. +> +> ### The lesson, and it is not the one I expected +> +> **The answer arrived for free, from work done for an unrelated reason.** Nobody solved C10; C9 got +> fixed because Jahni wants Linux/Windows cross-play, and C10 fell out of it. Six more builds spent +> hunting would have found nothing, because the thing that resolved it was a *build setting nobody +> was looking at* while I was busy bisecting source code. +> +> **The decision to stop was therefore right on its own terms and right in hindsight** — the +> information was not obtainable by continuing along that path at any price. Park a question whose +> every consequence is measured and benign; it may well be answered later by something else. +> +> **What this changes going forward:** ports can now aim for and *achieve* bit-identity, so the +> equivalence tests are much sharper instruments than they were — any diff at all is now a real +> finding rather than noise to be graded. The ULP-grading machinery in both tests is kept, because it +> is what would tell us if this ever regresses. **Status: accepted and closed by decision (Jahni), not by explanation.** Do not reopen this without reading the whole entry — five hypotheses have already been measured and refuted, and re-deriving diff --git a/CODEMAP.md b/CODEMAP.md index 753034d..eb48812 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -140,6 +140,26 @@ inherent (AUDIT §C10). The acceptance bar is visual (OPSTACK-PLAN §2.6). | `VoxelDensityOps::BuildSlabStack` | — | 5 ops, **no branch on archetype**: FlatPlain and CrystalChamber differ only in defaults, exactly as `GetSlabDensity` already had it. 8 archetypes → 7. | | `VoxelDensityOps::BuildMazeStack` | — | The 7-op Maze stack. If this ever becomes one op, the refactor failed its own test (§2.5). Callers must skip it on a **degenerate strate** (top−bottom ≤ 0): `GetMazeDensity` early-outs to air there and the stack has no such early-out by design — `GetDensityAt` falls back to the `switch`. | +### 3.2e Height-space operators — `Public/VoxelHeightOp.h` + `Private/VoxelHeightOpStack.cpp` +⚠️ **Feeds nothing yet** — built and exercised only by `VoxelForge.OpStack.SurfaceHeightEquivalence`. +**A SECOND op family, and it exists for a reason worth knowing:** SurfaceWorld's terrain ops (cliff / +terrace / layer lines / beach) read and write an **altitude**, not a density. They have no input Z +(they produce one), are XY-pure (once per column, not per voxel), and touch neither density nor SDF — +so they do not fit `IVoxelDensityOp` at all. Forcing them in would need a per-voxel channel for what +is a **column** property, or one opaque op (`OPSTACK-PLAN §2.5`'s failure mode). Same lesson as +`§0.1` one step further: some things are not another channel, they are another **space**. + +| Symbol | Notes | +|--------|-------| +| `FVoxelHeightSample` | Two channels: `Height` (voxel Z) + `Relief` (the original's `M`). Relief is produced by the structural source and consumed by the terrace gate — threading it beats resampling it. | +| `IVoxelHeightOp` | `Eval(X, Y, FVoxelHeightSample&)`. No `IsXYPure` (XY-purity is structural here — there is no Z to wrongly put in), no `PrepareChunk` (already per-column). `MaxDisplacement()` is the conservative vertical bound for a future heightfield `ClassifyBox`. | +| `FVoxelHeightStack` | Move-only, like `FVoxelOpStack`. `EvalHeight` / `EvalSample` / `MaxTotalDisplacement`. | +| `VoxelHeightOps::MakeStructuralHeightSource` | Continents + mountains + detail under a warp frame. Hands back a **non-owning pointer** so the cliff mod can resample it. | +| `VoxelHeightOps::MakeCliffHeightMod` | Slope-gated steepening; 4 resamples of the **structural** field (never the modified height — that would feed back). | +| `VoxelHeightOps::MakeTerraceHeightMod` | Relief-gated plateaus. The `* Relief` is the original's `* M`. | +| `VoxelHeightOps::MakeLayerLineHeightMod` / `MakeBeachHeightMod` | Sine bands; flatten toward the water line. Both have exact `MaxDisplacement`. | +| `VoxelHeightOps::BuildSurfaceHeightStack` | 5 ops in `ComputeSurfaceTerrainZ`'s order — structural → cliff → terrace → layer lines → beach. **Order is not negotiable.** | + ### 3.3 Chunk identity `VoxelChunk.h` (the old `FVoxelChunk` coord wrapper) was DELETED — dead since the tile redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h). @@ -380,6 +400,8 @@ The plugin's first tests (`OPSTACK-PLAN.md` Phase 0.5). Run them from the editor | `VoxelForgeClassifyTileTest.cpp` | `VoxelForge.Determinism.ClassifyTileSoundness` | Scans for a non-`Mixed` verdict, then brute-forces the exact mesher lattice (`g ∈ [-1, Cells+1]`). **A false verdict is an invisible, collisionless hole** — T1.d v1 was reverted for exactly this. Errors out rather than passing if it found nothing to check. | | `VoxelForgeDiffLayerTest.cpp` | `VoxelForge.Determinism.DiffLayerContention` | N readers running the worker call mix while the game thread writes and `Clear()`s. Survival + monotonic `ModsVersion`. | | `VoxelForgeClassifyTileTest.cpp` | `VoxelForge.OpStack.BoxVerdictFold` | Pure-logic walk of the fold in `VoxelDensityOp.h`, case by case — including the seal-forces-AllSolid case that justifies `ClassifyBox` existing. Also the only `.cpp` that includes the op header, so the build actually sees it. | +| `VoxelForgeHeightStackTest.cpp` | `VoxelForge.OpStack.SurfaceHeightEquivalence` | The height-space stack vs `ComputeSurfaceTerrainZ`, in **altitudes**. Runs twice: defaults, then **all F20 terrain ops ON** — the load-bearing pass, since the ops are off by default and the defaults pass exercises only the structural source. Also brute-forces `MaxDisplacement` (a false bound would be a hole). Bar is bit-identity; a height delta is a visibly different world, not rounding. | +| `VoxelForgeCrossPlatformTest.cpp` | `VoxelForge.Determinism.CrossPlatformDigest` | SHAPE digest (sign of density = the world) + FIELD digest (bit-for-bit) over a fixed integer grid, plus `NearIso` bounding how many samples could flip sign. Reports rather than asserts until pinned. Run on Windows and Linux and compare. | | `VoxelForgeOpStackSlabTest.cpp` | `VoxelForge.OpStack.SlabEquivalence` | **Phase 2's first port.** The same 5-op slab stack vs `GetSlabDensity` over 20k points, run twice — FlatPlain **and** CrystalChamber — which is what demonstrates the two archetypes really are one op. Plus window-invariance and box-verdict brute force. Compares against the reference **as it is now** (post Z-term removal), so green = pure refactor and any visual delta is attributable to §3.1 alone. | | `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. | diff --git a/OPSTACK-DECOMPOSITION.md b/OPSTACK-DECOMPOSITION.md index 9e00454..9afbf35 100644 --- a/OPSTACK-DECOMPOSITION.md +++ b/OPSTACK-DECOMPOSITION.md @@ -361,6 +361,28 @@ FOverhangShelfMod Union ⚠️ per-voxel, NOT XY-pure — the [structural post ×4] ``` +> ### ⚠️ RESOLVED 2026-07-27 — the height ops needed a SECOND OP FAMILY, not a sub-list +> +> This section says the height ops *"operate on Z values in the column, not on density"* and then +> lists them as children of `FHeightfieldSource`. Writing them made the consequence unavoidable: +> **they do not fit `IVoxelDensityOp` at all.** Its signature is `Eval(x, y, z, FVoxelOpSample&)` — +> per voxel, density + SDF. A height op has **no input Z** (it produces one), is XY-pure (once per +> column), and writes neither channel. +> +> The two ways to force it were both bad: a per-voxel third channel for what is a **column** +> property, or collapsing all five into one opaque op — `OPSTACK-PLAN §2.5`'s explicit failure mode. +> +> **So height space got its own contract: `VoxelHeightOp.h`** (`FVoxelHeightSample` with +> `Height` + `Relief`, `IVoxelHeightOp`, `FVoxelHeightStack`). Same lesson as `§0.1`, one step +> further: §0.1 found that density needed a second *channel*; this found that terrain needs a second +> *space*. Verified by `VoxelForge.OpStack.SurfaceHeightEquivalence` before anything was built on +> top of it — deliberately, so a wrong answer would have cost one test rather than a whole port. +> +> **Bonus the type system gives for free:** a height stack cannot contain Z-dependent data, because +> there is no Z in the signature to put there. `AUDIT §6.3` warns that Z-dependent data smuggled into +> `FSurfaceColumn` silently corrupts every chunk in the vertical stack and that `ValidateDeterminism` +> would not catch it. Here the *type* forbids it rather than a convention. + **Critical distinction the port must preserve:** the height ops (`FCliffHeightMod` and friends) operate on **Z values in the column**, not on density. They are XY-pure and belong in `PrepareChunk`/the column cache. `FOverhangShelfMod` operates per voxel and re-samples the diff --git a/OPSTACK-PROGRESS.md b/OPSTACK-PROGRESS.md index 196e147..55db6c8 100644 --- a/OPSTACK-PROGRESS.md +++ b/OPSTACK-PROGRESS.md @@ -1207,3 +1207,71 @@ headers. Everything else in the module compiled, so this is the whole tail: one path, checked against `ARCHITECTURE §8.10`. Then back to the opstack: `SurfaceWorld` (§5). --- + +## 2026-07-27 — ✅ C10 SOLVED (it WAS /fp:fast). SurfaceWorld opened: height space needed its own family. + +**Maze and Slab are now BIT-IDENTICAL to their originals.** `FPSemantics = Precise`, set for +cross-platform play, dissolved the ULP residue. + +**So hypothesis 3 had the right mechanism all along, and every experiment built on it was doomed by +construction.** Under `/fp:fast` the compiler reassociates and contracts *by surrounding context*, +with **no single isolable axis** — which is exactly why five carefully-designed one-variable tests +all came back negative while the difference stayed. There was no variable to find. Removing the +*permission* removed the difference. + +**The lesson is not the one I expected.** Nobody solved C10; C9 got fixed for an unrelated +requirement and C10 fell out of it. Six more builds of bisecting source would have found nothing, +because the answer was a **build setting nobody was looking at**. Parking a question whose every +consequence is measured and benign was right on its own terms *and* right in hindsight — the +information was not obtainable along the path I was on, at any price. Recorded in `AUDIT §C10`. + +**Immediate consequence:** the equivalence tests are now much sharper instruments. Any diff at all is +a real finding rather than noise to grade. The ULP machinery stays as a float-model regression alarm. + +### SurfaceWorld, step 1 of 2 — and it forced an architectural decision + +`§5` says the height ops *"operate on Z values in the column, not on density"* and then lists them as +children of `FHeightfieldSource`. Writing them made the consequence unavoidable: **they do not fit +`IVoxelDensityOp` at all.** No input Z (they produce one), XY-pure per column rather than per voxel, +and they write neither density nor SDF. The two ways to force them in were a per-voxel third channel +for what is a **column** property, or one opaque op — `§2.5`'s named failure mode. + +**So height space got its own contract:** `VoxelHeightOp.h` — `FVoxelHeightSample` (`Height` + +`Relief`), `IVoxelHeightOp`, `FVoxelHeightStack`, and five ops in `VoxelHeightOpStack.cpp`. +`Relief` is the original's `M`: produced by the structural source, consumed by the terrace gate. +Same shape of lesson as `§0.1` — that one found density needed a second *channel*; this found +terrain needs a second *space*. + +**A property the type system now gives for free:** a height stack **cannot** hold Z-dependent data, +because there is no Z in the signature to put there. `AUDIT §6.3` warns that Z-dependent data +smuggled into `FSurfaceColumn` silently corrupts every chunk in the vertical stack and that +`ValidateDeterminism` would not catch it. That hazard is now a type error instead of a convention. + +**Deliberately staged.** This step touches **nothing** on the density path — no `FHeightfieldSource` +adapter, no `FSkyCapSource`, no `FOverhangShelfMod`, no column cache, no wiring. If height space had +not decomposed cleanly, that would show up here for the price of one test rather than after building +the adapter, the cache integration and the dispatch on top of it. Same method Phase 1 used on +density, applied to the question Phase 2 actually raised. + +**The test runs twice, and the second pass is the one that matters:** the F20 terrain ops are **off +by default**, so a defaults-only run would exercise the structural source and leave all four +modifiers — i.e. everything new — untested. The second pass turns them all on. It also brute-forces +`MaxDisplacement` against observed movement, because a false bound would later be a hole. +`WaterLevelRelative` must be set for that pass or `FBeachHeightMod` early-outs and the fifth op is +never touched — a green test that measured nothing. + +**Also:** `ComputeSurfaceTerrainZ` moved from private to public on `UVoxelGenerator` (same +justification as `GetSlabDensity` / `GetMazeDensity` — exposed for isolated tests). Its old private +declaration was removed; two declarations of one member would not compile. + +**UNVERIFIED:** none of this is compiled. Likely error spots: the new `.cpp`/`.h` pair being picked +up; `FVoxelHeightStack` as a move-only local; the non-owning `const IVoxelHeightOp**` out-param in +`MakeStructuralHeightSource` and its `static_cast` back down in `MakeCliffHeightMod`; `FVector2D` +members being double in UE5 (cast at every use); and the `ComputeSurfaceTerrainZ` access move. + +**Next single action:** build + run `SurfaceHeightEquivalence`. If green, step 2 — `FHeightfieldSource` +(the adapter that turns a column into density, preserving the T1.a cache), `FSkyCapSource`, +`FOverhangShelfMod` (the one genuinely 3D op here), then biome blending and the wiring. `§C1` +(bounded seed offsets) still open. + +--- diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeHeightStackTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeHeightStackTest.cpp new file mode 100644 index 0000000..39f432c --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeHeightStackTest.cpp @@ -0,0 +1,283 @@ +// VoxelForgeHeightStackTest.cpp +// LA QUESTION D'ARCHITECTURE DE LA PHASE 2, POSÉE AVANT D'ÉCRIRE CE QUI EN DÉPEND. +// PHASE 2'S ARCHITECTURAL QUESTION, ASKED BEFORE WRITING WHAT DEPENDS ON THE ANSWER. +// +// SurfaceWorld a forcé une décision que ni Maze ni Slab n'avaient forcée : ses opérateurs de +// terrain (cliff / terrace / layer lines / plage) n'opèrent PAS sur la densité. Ils lisent et +// écrivent **une altitude**. Ils ne rentrent donc pas dans `IVoxelDensityOp`, et les y forcer +// voudrait dire soit un canal par-voxel pour une propriété de COLONNE, soit un seul opérateur +// opaque — ce que `OPSTACK-PLAN §2.5` appelle exactement l'échec du refactor. +// +// D'où une seconde famille, `VoxelHeightOp.h`. **Ce test est ce qui dit si elle était une bonne +// idée** — la même méthode que la Phase 1 a appliquée à la densité : décomposer, puis MESURER +// contre l'original, avant de construire par-dessus. +// +// ⚠️ Ce test ne touche PAS au chemin densité. `FHeightfieldSource` / `FSkyCapSource` / +// `FOverhangShelfMod` (OPSTACK-DECOMPOSITION §5) sont l'étape SUIVANTE, délibérément séparée : si +// l'espace-hauteur ne se décomposait pas proprement, on l'apprendrait ici, pour le prix d'un test, +// et pas après avoir écrit l'adaptateur, le cache de colonne et le branchement. +// +// LA BARRE : **bit à bit.** Depuis `FPSemantics = Precise` (AUDIT §C9/§C10), Maze et Slab sont +// bit-identiques à leur original ; il n'y a plus de « plancher ULP » à tolérer. Un écart ici est +// donc une vraie trouvaille — un offset de bruit faux, un ordre d'op inversé, un gate oublié. +// Ces fonctions sont des ALTITUDES en voxels, pas des densités : un écart d'un demi-voxel est un +// terrain visiblement différent, pas du bruit d'arrondi. + +#if WITH_DEV_AUTOMATION_TESTS + +#include "Misc/AutomationTest.h" +#include "Async/ParallelFor.h" +#include "HAL/PlatformMisc.h" + +#include "VoxelForgeTestFixture.h" +#include "VoxelHeightOp.h" + +#include + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeHeightStackTest, + "VoxelForge.OpStack.SurfaceHeightEquivalence", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +namespace +{ + constexpr int32 NumHeightSamples = 20000; + + /** Les params du terrain ne sont intéressants que si les ops sont ALLUMÉS. Ceux de la fixture + * sont les défauts, et `§5` note que les ops F20 sont « all off by default ». Un test qui ne + * ferait tourner que les défauts vérifierait la source structurelle et RIEN des quatre + * modificateurs — c'est-à-dire l'essentiel de ce qui est nouveau ici. */ + void EnableAllTerrainOps(FSurfaceGenerationParams& P) + { + P.CliffStrength = 0.6f; + P.CliffSampleDist = 2.0f; + P.CliffSlopeThreshold = 0.15f; + P.CliffSharpness = 1.4f; + + P.TerraceStrength = 0.7f; + P.TerraceHeight = 9.0f; + P.TerraceHardness = 0.8f; + + P.LayerLineDepth = 1.3f; + P.LayerLineSpacing = 7.0f; + + // ⚠️ `WaterLevelRelative` DOIT être > 0, sinon `FBeachHeightMod` sort immédiatement et le + // cinquième op n'est jamais exercé — un test vert qui n'a rien testé. Le défaut de la + // struct est 0.0f, donc l'oublier est le piège naturel ici. + // The beach op early-outs unless WaterLevelRelative > 0, so without this the fifth op is + // never exercised at all — a green test that measured nothing. + P.WaterLevelRelative = 0.30f; + P.BeachWidth = 6.0f; + } +} + +bool FVoxelForgeHeightStackTest::RunTest(const FString& Parameters) +{ + using namespace VoxelForgeTest; + + FTestWorld World; + World.Build(); + if (!World.IsValid()) + { + AddError(World.WhyInvalid()); + return false; + } + + const UVoxelGenerator* Gen = World.Generator.Get(); + + int32 TopVoxelZ = 0, BottomVoxelZ = 0; + if (!World.GetSlotVoxelZRange(FTestWorld::SlotSurfaceWorld, TopVoxelZ, BottomVoxelZ)) + { + AddError(TEXT("The fixture layout has no SurfaceWorld slot. Check FTestWorld::Build's ") + TEXT("Archetypes[] against FTestWorld::SlotSurfaceWorld.")); + return false; + } + + const int32 MidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE; + + // Les points d'échantillonnage : XY seulement, la hauteur ne dépend pas de Z (c'est le point). + TArray Points; + Points.Reserve(NumHeightSamples); + { + FRandomStream Rng(90210); + for (int32 i = 0; i < NumHeightSamples; ++i) + { + Points.Add(FVector2D( + (float)Rng.RandRange(-6 * CHUNK_SIZE, 6 * CHUNK_SIZE), + (float)Rng.RandRange(-6 * CHUNK_SIZE, 6 * CHUNK_SIZE))); + } + } + + //========================================================================= + // LA BATTERIE, PARAMÉTRÉE PAR JEU DE PARAMS + //========================================================================= + auto RunForParams = [&](const FSurfaceGenerationParams& P, const TCHAR* Label, int32 SeedSalt) + { + FVoxelHeightStack Stack; + VoxelHeightOps::BuildSurfaceHeightStack(Stack, P, World.Settings->Seed); + + // Une DÉCOMPOSITION, pas une enveloppe : source + 4 modificateurs. + TestEqual(*FString::Printf(TEXT("%s: the height stack is decomposed into 5 ops"), Label), + Stack.Num(), 5); + + //--------------------------------------------------------------------- + // 1. ÉQUIVALENCE — contre ComputeSurfaceTerrainZ, en ALTITUDE + //--------------------------------------------------------------------- + int32 NumDiff = 0, WorstIdx = -1; + float WorstDelta = 0.0f, WorstOld = 0.0f; + + for (int32 i = 0; i < NumHeightSamples; ++i) + { + const float X = (float)Points[i].X, Y = (float)Points[i].Y; + + const float Old = Gen->ComputeSurfaceTerrainZ(X, Y, P); + const float New = Stack.EvalHeight(X, Y); + + if (!BitEqual(Old, New)) + { + ++NumDiff; + const float Delta = FMath::Abs(Old - New); + if (Delta > WorstDelta) { WorstDelta = Delta; WorstIdx = i; WorstOld = Old; } + } + } + + if (NumDiff == 0) + { + AddInfo(FString::Printf( + TEXT("%s: bit-identical across %d samples. The height-space decomposition ") + TEXT("reproduces ComputeSurfaceTerrainZ exactly."), Label, NumHeightSamples)); + } + else + { + // Pas de gradation ULP ici, à dessein : ce sont des ALTITUDES. Depuis /fp:precise la + // barre est l'égalité binaire, et un écart de hauteur se voit dans le monde. + AddError(FString::Printf( + TEXT("%s: %d of %d samples differ from ComputeSurfaceTerrainZ (largest |delta| ") + TEXT("%.9g voxels at (%.0f, %.0f), where the reference height is %.4f). These are ") + TEXT("ALTITUDES, not densities -- this is a real port error, not rounding. Check, ") + TEXT("in order: the op ORDER (structural -> cliff -> terrace -> layer lines -> ") + TEXT("beach), the terrace's `* Relief` gate (that is the original's `* M`), the ") + TEXT("cliff resampling the STRUCTURAL field rather than the modified height, and ") + TEXT("the noise offsets (3.1/5.7/0.7, 11/22/1.3, 99/77/0.9, 7.3/2.1/0.5)."), + Label, NumDiff, NumHeightSamples, WorstDelta, + WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f, + WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f, + WorstOld)); + } + + //--------------------------------------------------------------------- + // 2. INVARIANCE DE FENÊTRE + //--------------------------------------------------------------------- + // Une pile de hauteur alimente le cache de colonne T1.a, qui est PARTAGÉ sur toute la pile + // verticale de chunks. Une impureté ici ne fait pas une couture locale : elle se propage à + // tous les Z d'un coup (AUDIT §6.3). + { + std::atomic Impure{ 0 }; + const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores())); + + TArray Ref; + Ref.SetNumUninitialized(NumHeightSamples); + for (int32 i = 0; i < NumHeightSamples; ++i) + { + Ref[i] = Stack.EvalHeight((float)Points[i].X, (float)Points[i].Y); + } + + ParallelFor(NumBlocks, [&](int32 Block) + { + TArray LocalOrder; + BuildShuffledOrder(NumHeightSamples, 1200 + Block + SeedSalt, LocalOrder); + for (const int32 i : LocalOrder) + { + const float V = Stack.EvalHeight((float)Points[i].X, (float)Points[i].Y); + if (!BitEqual(V, Ref[i])) { Impure.fetch_add(1, std::memory_order_relaxed); } + } + }); + + TestEqual(*FString::Printf( + TEXT("%s: the height stack is window-invariant across order and threads"), Label), + Impure.load(), 0); + } + + //--------------------------------------------------------------------- + // 3. LES MAJORANTS DE DÉPLACEMENT SONT-ILS HONNÊTES ? + //--------------------------------------------------------------------- + // `MaxDisplacement` servira à borner une colonne pour un `ClassifyBox` de heightfield, la + // même mécanique qui fait prouver 36-40 tuiles sur 60 à la dalle. Un majorant FAUX serait + // un TROU, donc on le teste par force brute AVANT de construire quoi que ce soit dessus. + // + // On mesure le déplacement des trois mods bornables en comparant la pile complète à une + // pile tronquée (source + cliff seuls) : la différence est exactement ce que terrace + + // layer lines + plage ont déplacé. + { + FVoxelHeightStack Base; + const IVoxelHeightOp* Structural = nullptr; + Base.Add(VoxelHeightOps::MakeStructuralHeightSource(P, World.Settings->Seed, &Structural)); + Base.Add(VoxelHeightOps::MakeCliffHeightMod(P, Structural)); + + FVoxelHeightStack Bounded; + const IVoxelHeightOp* Structural2 = nullptr; + Bounded.Add(VoxelHeightOps::MakeStructuralHeightSource(P, World.Settings->Seed, &Structural2)); + Bounded.Add(VoxelHeightOps::MakeCliffHeightMod(P, Structural2)); + Bounded.Add(VoxelHeightOps::MakeTerraceHeightMod(P)); + Bounded.Add(VoxelHeightOps::MakeLayerLineHeightMod(P)); + Bounded.Add(VoxelHeightOps::MakeBeachHeightMod(P)); + + const float Claimed = FMath::Max(P.TerraceStrength > 0.0f ? P.TerraceHeight : 0.0f, 0.0f) + + FMath::Max(P.LayerLineSpacing > 0.0f ? P.LayerLineDepth : 0.0f, 0.0f) + + FMath::Max(P.WaterLevelRelative > 0.0f ? P.BeachWidth : 0.0f, 0.0f); + + float WorstObserved = 0.0f; + int32 NumOverBound = 0; + for (int32 i = 0; i < NumHeightSamples; ++i) + { + const float X = (float)Points[i].X, Y = (float)Points[i].Y; + const float Moved = FMath::Abs(Bounded.EvalHeight(X, Y) - Base.EvalHeight(X, Y)); + WorstObserved = FMath::Max(WorstObserved, Moved); + if (Moved > Claimed) { ++NumOverBound; } + } + + TestEqual(*FString::Printf( + TEXT("%s: no sample exceeds the claimed MaxDisplacement (a false bound is a hole)"), + Label), + NumOverBound, 0); + + AddInfo(FString::Printf( + TEXT("%s: MaxDisplacement claims %.3f voxels, worst observed %.3f (%.0f%% of the ") + TEXT("claim). A loose bound only costs CPU later; a tight-but-wrong one would be a hole."), + Label, Claimed, WorstObserved, + Claimed > 0.0f ? 100.0f * WorstObserved / Claimed : 0.0f)); + } + }; + + //========================================================================= + // DEUX PASSES — et la seconde est celle qui compte + //========================================================================= + const UVoxelStrateDefinition* SurfaceDef = + World.StrateManager->GetStrateForChunk(FIntVector(0, 0, MidChunkZ)); + if (!SurfaceDef) + { + AddError(TEXT("No strate definition resolved for the SurfaceWorld slot's mid chunk.")); + return false; + } + + // Les bornes Z de runtime sont posées à la main : `GetSlabParamsForChunk` a un équivalent pour + // la dalle, mais le chemin surface passe par `ResolveSurfaceChunkParams`, qui est privé et + // mêle la résolution de biome. La pile de hauteur ne dépend que des params + du seed, donc + // fournir les params directement est à la fois suffisant et plus lisible en cas d'échec. + FSurfaceGenerationParams Defaults = SurfaceDef->SurfaceParams; + Defaults.StrateTopWorldZ = (float)TopVoxelZ; + Defaults.StrateBottomWorldZ = (float)BottomVoxelZ; + + RunForParams(Defaults, TEXT("SurfaceWorld(defaults)"), 0); + + // ⚠️ LA PASSE LOAD-BEARING. Les ops de terrain F20 sont éteints par défaut, donc la passe + // ci-dessus n'exerce que la source structurelle et laisse les QUATRE modificateurs — c'est-à- + // dire tout ce qui est nouveau dans cette décomposition — non testés. Celle-ci les allume. + FSurfaceGenerationParams AllOps = Defaults; + EnableAllTerrainOps(AllOps); + RunForParams(AllOps, TEXT("SurfaceWorld(all terrain ops on)"), 64); + + return true; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp index 4041ced..7cee486 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackMazeTest.cpp @@ -8,8 +8,26 @@ // sans contorsion, le SDF est reproduit BIT POUR BIT, et aucun échantillon ne change de côté de // l'isosurface. // +// ═════════════════════════════════════════════════════════════════════════════════════════ +// ✅ MISE À JOUR 2026-07-27 : LE PLANCHER ULP N'EXISTE PLUS. C'ÉTAIT `/fp:fast`. +// ═════════════════════════════════════════════════════════════════════════════════════════ +// `FPSemantics = Precise` sur le module (AUDIT §C9, posé pour le cross-play Linux/Windows) fait +// passer ce test à **BIT-IDENTIQUE**. La section ci-dessous décrit un état RÉVOLU ; elle est gardée +// parce qu'elle explique pourquoi les cinq expériences d'isolation avaient toutes échoué (sous +// `/fp:fast` le compilateur transforme selon le CONTEXTE — il n'y avait aucune variable à isoler) +// et parce qu'elle dit quoi regarder si la bit-identité régresse un jour. +// +// **Conséquence pratique : ce test est maintenant un instrument BEAUCOUP plus fin.** Le moindre +// écart est désormais une vraie trouvaille, pas du bruit à noter. La machinerie de gradation ULP +// est conservée exprès — c'est elle qui signalerait une régression du modèle flottant. +// +// UPDATE: the ULP floor is GONE — FPSemantics = Precise makes this test bit-identical. The section +// below describes a past state, kept because it explains why five isolation experiments all failed +// (under /fp:fast the compiler transforms by CONTEXT — there was no variable to isolate) and what to +// look at if bit-identity ever regresses. +// // ───────────────────────────────────────────────────────────────────────────────────────── -// ⚠️ LE PLANCHER ULP — lire ceci avant de « corriger » un écart résiduel +// ⚠️ LE PLANCHER ULP (HISTORIQUE) — lire ceci avant de « corriger » un écart résiduel // ───────────────────────────────────────────────────────────────────────────────────────── // La pile reproduit `GetMazeDensity` à ~1-2 ULP près sur ~2 % des échantillons (ceux qui tombent // dans la coquille de blend du SDF, où `Blend - Sdf` annule catastrophiquement et amplifie le diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackSlabTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackSlabTest.cpp index 20ce719..5358ece 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeOpStackSlabTest.cpp +++ b/Source/VoxelForge/Private/Tests/VoxelForgeOpStackSlabTest.cpp @@ -5,8 +5,10 @@ // CE QUE CE TEST DOIT PROUVER / WHAT THIS TEST HAS TO PROVE // Trois choses, et la troisième est la raison d'être du portage : // -// 1. ÉQUIVALENCE — la pile reproduit `GetSlabDensity`. Même barre que Maze : un changement de -// côté d'isosurface est un ÉCHEC DUR, un écart d'ULP est le plancher accepté. +// 1. ÉQUIVALENCE — la pile reproduit `GetSlabDensity`. ✅ **BIT-IDENTIQUE depuis 2026-07-27**, +// quand `FPSemantics = Precise` (AUDIT §C9/§C10) a supprimé le résidu d'ULP : il venait de +// `/fp:fast`. Un changement de côté d'isosurface reste l'ÉCHEC DUR ; la gradation ULP est +// gardée comme détecteur de régression du modèle flottant, pas comme tolérance attendue. // 2. UN OPÉRATEUR, DEUX ARCHÉTYPES — la MÊME pile est vérifiée contre FlatPlain ET // CrystalChamber. `GetSlabDensity` ne les distingue par aucun branchement ; si la pile a // besoin d'en faire un, la fusion est fausse et ce test le dit. diff --git a/Source/VoxelForge/Private/VoxelHeightOpStack.cpp b/Source/VoxelForge/Private/VoxelHeightOpStack.cpp new file mode 100644 index 0000000..8ec0339 --- /dev/null +++ b/Source/VoxelForge/Private/VoxelHeightOpStack.cpp @@ -0,0 +1,330 @@ +// VoxelHeightOpStack.cpp +// Les cinq opérateurs d'espace-hauteur de SurfaceWorld. +// The five height-space operators of SurfaceWorld. +// +// FIDÉLITÉ / FIDELITY +// Chaque corps est une transcription LITTÉRALE du bloc correspondant de +// `SampleSurfaceStructuralZ` / `ComputeSurfaceTerrainZ` — mêmes offsets, mêmes octaves, même ordre +// d'opérations flottantes. Depuis que `FPSemantics = Precise` est posé (AUDIT §C9), l'égalité +// BIT À BIT est atteignable et atteinte pour Maze et Slab : c'est donc la barre ici aussi, et +// `VoxelForge.OpStack.SurfaceHeightEquivalence` la vérifie. +// +// ⚠️ LE DÉTOUR PAR `FVector` EST DÉLIBÉRÉ, comme ailleurs dans ce refactor : `FractalNoise3D` prend +// un `FVector` (donc des DOUBLES en UE5) et re-descend en float. Passer directement des floats +// saute un arrondi. Reproduire le détour, c'est reproduire l'arrondi. +// The FVector round-trip is deliberate: FVector is double in UE5, so the original rounds through a +// double. Going straight through floats skips a rounding step. + +#include "VoxelHeightOp.h" + +#include "VoxelNoise.h" // VoxelNoise::FBM / Ridged / Perlin3D +#include "VoxelTypes.h" // SmoothStep01, VOXEL_NOISE_SCALE + +namespace +{ + //========================================================================= + // HELPERS — les mêmes enveloppes que VoxelGenerator.cpp, transcrites + //========================================================================= + // `FractalNoise3D` et `RidgedNoise3D` sont `static` dans VoxelGenerator.cpp, donc invisibles + // ici. Elles sont recopiées à l'identique plutôt qu'exportées : les exporter changerait leur + // contexte d'inlining, et sous /fp:precise comme sous /fp:fast la règle est la même — on ne + // touche à rien de ce qui entoure une expression flottante qu'on veut reproduire. + FORCEINLINE float HFractalNoise3D(const FVector& Position, int32 Octaves = 4, + float Lacunarity = 2.0f, float Persistence = 0.5f) + { + return VoxelNoise::FBM((float)Position.X, (float)Position.Y, (float)Position.Z, + Octaves, Lacunarity, Persistence); + } + + FORCEINLINE float HRidgedNoise3D(const FVector& Position, int32 Octaves = 4, + float Lacunarity = 2.0f, float Persistence = 0.5f) + { + return VoxelNoise::Ridged((float)Position.X, (float)Position.Y, (float)Position.Z, + Octaves, Lacunarity, Persistence); + } + + /** Transcription de `UVoxelGenerator::SampleRelief`. Champ [0,1] partagé avec la carte de + * biomes, pour que la géographie et le terrain qu'elle module restent d'accord. */ + FORCEINLINE float HSampleRelief(float WorldX, float WorldY, float SeedF, + float Frequency, float Contrast) + { + float R = HFractalNoise3D(FVector( + WorldX * Frequency + SeedF * 7.3f, + WorldY * Frequency + SeedF * 2.1f, + SeedF * 0.5f), 2) * 0.5f + 0.5f; // [0,1] + R = FMath::Clamp((R - 0.5f) * Contrast + 0.5f, 0.0f, 1.0f); + return SmoothStep01(R); + } + + //========================================================================= + // SOURCE — CHAMP STRUCTUREL / STRUCTURAL HEIGHT FIELD + //========================================================================= + // Continents + montagnes + détail, sous une frame de domain-warp. Produit les DEUX canaux. + class FStructuralHeightSource final : public IVoxelHeightOp + { + public: + FStructuralHeightSource(const FSurfaceGenerationParams& InP, int32 InSeed) + : P(InP), SeedF((float)InSeed) {} + + void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const override + { + float M = 1.0f; + InOut.Height = SampleZ(WorldX, WorldY, M); // Replace : racine de pile + InOut.Relief = M; + } + + /** + * Le champ nu, exposé parce que `FCliffHeightMod` doit le RÉ-ÉCHANTILLONNER en différences + * centrées. C'est une dépendance réelle du code d'origine (`ComputeSurfaceTerrainZ` appelle + * `SampleSurfaceStructuralZ` quatre fois de plus), pas un raccourci : la pente doit venir du + * champ STRUCTUREL, sans rétroaction des ops, sinon le cliff se nourrirait de lui-même. + */ + float SampleZ(float WorldX, float WorldY, float& OutM) const + { + const float H = P.StrateTopWorldZ - P.StrateBottomWorldZ; + const float BottomZ = P.StrateBottomWorldZ; + + const float GroundBase = BottomZ + H * P.BaseGroundRelative; + + // Domain-warp des coords STRUCTURELLES (continents + montagnes). Le bruit de détail + // reste sur le vrai XY pour que les bosses fines restent nettes et décorrélées. + float QX = WorldX, QY = WorldY; + if (P.HeightWarpStrength > 0.0f) + { + const float WF = P.HeightWarpFrequency; + const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + SeedF * 0.31f, WorldY * WF + 4.2f, SeedF * 1.7f)); + const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 8.6f, WorldY * WF + SeedF * 0.53f, SeedF * 2.9f)); + QX += wx * VOXEL_NOISE_SCALE * P.HeightWarpStrength; + QY += wy * VOXEL_NOISE_SCALE * P.HeightWarpStrength; + } + + const float Relief = HSampleRelief(WorldX, WorldY, SeedF, P.ReliefFrequency, P.ReliefContrast); + const float M = FMath::Lerp(1.0f, Relief, P.ReliefStrength); + + float Cont = HFractalNoise3D(FVector( + QX * P.ContinentFrequency + SeedF * 3.1f, + QY * P.ContinentFrequency + SeedF * 5.7f, + SeedF * 0.7f), 4); // [-1,1] + + float Detail = HFractalNoise3D(FVector( + WorldX * P.DetailFrequency + 11.0f, + WorldY * P.DetailFrequency + 22.0f, + SeedF * 1.3f), 3); // [-1,1] + + float Mountain = 0.0f; + if (P.MountainStrength > 0.0f) + { + float Ridge = HRidgedNoise3D(FVector( + QX * P.MountainFrequency + 99.0f, + QY * P.MountainFrequency + 77.0f, + SeedF * 0.9f), 4); // [-1,1] + Ridge = Ridge * 0.5f + 0.5f; // [0,1] sommets + Mountain = Ridge * P.MountainStrength * M; // les montagnes ne montent qu'en haut relief + } + + // Les plaines gardent une fraction du gonflement continental ; les hautes terres tout. + const float ContScale = FMath::Lerp(0.45f, 1.0f, M); + + float Terrain = GroundBase + + Cont * P.ElevationRange * 0.5f * ContScale + + Mountain * P.ElevationRange + + Detail * P.SurfaceRoughness; + + OutM = M; + return Terrain; + } + + // Une SOURCE pose l'altitude, elle ne la déplace pas : la notion de « déplacement max » ne + // s'applique pas. La borne d'une colonne se calcule à partir de la source elle-même + // (GroundBase ± ElevationRange ± SurfaceRoughness), pas ici — d'où FLT_MAX, honnête. + float MaxDisplacement() const override { return FLT_MAX; } + + private: + FSurfaceGenerationParams P; + float SeedF; + }; + + //========================================================================= + // MOD — FALAISE / CLIFF (raidissement conditionné par la pente) + //========================================================================= + class FCliffHeightMod final : public IVoxelHeightOp + { + public: + FCliffHeightMod(const FSurfaceGenerationParams& InP, const FStructuralHeightSource* InSrc) + : P(InP), Src(InSrc) {} + + void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const override + { + if (P.CliffStrength <= 0.0f || Src == nullptr) { return; } + + const float D = FMath::Max(P.CliffSampleDist, 0.5f); + float Ms; // relief scratch — on ne veut que les hauteurs + const float Zxp = Src->SampleZ(WorldX + D, WorldY, Ms); + const float Zxm = Src->SampleZ(WorldX - D, WorldY, Ms); + const float Zyp = Src->SampleZ(WorldX, WorldY + D, Ms); + const float Zym = Src->SampleZ(WorldX, WorldY - D, Ms); + const float dZdX = (Zxp - Zxm) / (2.0f * D); + const float dZdY = (Zyp - Zym) / (2.0f * D); + const float Slope = FMath::Sqrt(dZdX * dZdX + dZdY * dZdY); + + const float Thr = FMath::Max(P.CliffSlopeThreshold, 0.05f); + const float SlopeGate = FMath::Clamp((Slope - Thr) / Thr, 0.0f, 1.0f); + if (SlopeGate > 0.0f) + { + const float Ref = 0.25f * (Zxp + Zxm + Zyp + Zym); + const float Gain = P.CliffStrength * SlopeGate * P.CliffSharpness; + InOut.Height += (InOut.Height - Ref) * Gain; + } + } + + private: + FSurfaceGenerationParams P; + const FStructuralHeightSource* Src; + }; + + //========================================================================= + // MOD — TERRASSES / TERRACE (gaté par le relief : le canal Relief sert ICI) + //========================================================================= + class FTerraceHeightMod final : public IVoxelHeightOp + { + public: + explicit FTerraceHeightMod(const FSurfaceGenerationParams& InP) : P(InP) {} + + void Eval(float, float, FVoxelHeightSample& InOut) const override + { + if (P.TerraceStrength <= 0.0f || P.TerraceHeight <= 0.0f) { return; } + + const float StepH = P.TerraceHeight; + const float T = InOut.Height / StepH; + const float K = FMath::FloorToFloat(T); + const float Frac = T - K; + const float W = FMath::Lerp(0.5f, 0.03f, FMath::Clamp(P.TerraceHardness, 0.0f, 1.0f)); + const float Fs = SmoothStep01(FMath::Clamp((Frac - (0.5f - W)) / (2.0f * W), 0.0f, 1.0f)); + const float Stepped = (K + Fs) * StepH; + // `* InOut.Relief` : c'est le `* M` de l'original — la raison d'être du second canal. + InOut.Height = FMath::Lerp(InOut.Height, Stepped, P.TerraceStrength * InOut.Relief); + } + + // Le terrace interpole VERS une hauteur quantifiée : l'écart ne dépasse jamais un palier. + float MaxDisplacement() const override + { + return (P.TerraceStrength > 0.0f) ? FMath::Max(P.TerraceHeight, 0.0f) : 0.0f; + } + + private: + FSurfaceGenerationParams P; + }; + + //========================================================================= + // MOD — LIGNES DE STRATES / LAYER LINES + //========================================================================= + class FLayerLineHeightMod final : public IVoxelHeightOp + { + public: + explicit FLayerLineHeightMod(const FSurfaceGenerationParams& InP) : P(InP) {} + + void Eval(float, float, FVoxelHeightSample& InOut) const override + { + if (P.LayerLineDepth <= 0.0f || P.LayerLineSpacing <= 0.0f) { return; } + + const float Phase = InOut.Height * (2.0f * PI / P.LayerLineSpacing); + InOut.Height -= FMath::Sin(Phase) * P.LayerLineDepth; + } + + // `sin` ∈ [-1,1] ⇒ borne exacte. + float MaxDisplacement() const override + { + return (P.LayerLineSpacing > 0.0f) ? FMath::Max(P.LayerLineDepth, 0.0f) : 0.0f; + } + + private: + FSurfaceGenerationParams P; + }; + + //========================================================================= + // MOD — PLAGE / BEACH (aplatissement vers la ligne d'eau) + //========================================================================= + class FBeachHeightMod final : public IVoxelHeightOp + { + public: + explicit FBeachHeightMod(const FSurfaceGenerationParams& InP) : P(InP) {} + + void Eval(float, float, FVoxelHeightSample& InOut) const override + { + // Le niveau d'eau est GLOBAL à la strate (forcé depuis la strate) pour que le plan + // d'eau reste continu — d'où le calcul depuis les bornes de strate, pas depuis un param + // par biome. + const float H = P.StrateTopWorldZ - P.StrateBottomWorldZ; + const float WaterZ = P.StrateBottomWorldZ + H * P.WaterLevelRelative; + if (P.WaterLevelRelative <= 0.0f || P.BeachWidth <= 0.0f) { return; } + + const float DAbs = FMath::Abs(InOut.Height - WaterZ); + if (DAbs < P.BeachWidth) + { + float T = SmoothStep01(DAbs / P.BeachWidth); + InOut.Height = FMath::Lerp(WaterZ, InOut.Height, T); + } + } + + // N'agit que dans `BeachWidth` de l'eau, et ne fait qu'y RAPPROCHER. + float MaxDisplacement() const override + { + return (P.WaterLevelRelative > 0.0f) ? FMath::Max(P.BeachWidth, 0.0f) : 0.0f; + } + + private: + FSurfaceGenerationParams P; + }; +} + +//============================================================================= +// FABRIQUES / FACTORIES +//============================================================================= + +namespace VoxelHeightOps +{ + TUniquePtr MakeStructuralHeightSource(const FSurfaceGenerationParams& P, int32 Seed, + const IVoxelHeightOp** OutSource) + { + TUniquePtr Src = MakeUnique(P, Seed); + if (OutSource) { *OutSource = Src.Get(); } + return Src; + } + + TUniquePtr MakeCliffHeightMod(const FSurfaceGenerationParams& P, + const IVoxelHeightOp* StructuralSource) + { + // `static_cast` plutôt que `Cast<>` : ce ne sont pas des UObject, et le contrat de la + // fabrique est qu'on lui rend exactement le pointeur sorti de MakeStructuralHeightSource. + return MakeUnique( + P, static_cast(StructuralSource)); + } + + TUniquePtr MakeTerraceHeightMod(const FSurfaceGenerationParams& P) + { + return MakeUnique(P); + } + + TUniquePtr MakeLayerLineHeightMod(const FSurfaceGenerationParams& P) + { + return MakeUnique(P); + } + + TUniquePtr MakeBeachHeightMod(const FSurfaceGenerationParams& P) + { + return MakeUnique(P); + } + + void BuildSurfaceHeightStack(FVoxelHeightStack& OutStack, const FSurfaceGenerationParams& P, int32 Seed) + { + // L'ORDRE EST CELUI DE `ComputeSurfaceTerrainZ`, et il porte du sens : + // le cliff raidit le champ brut, le terrace quantifie le résultat raidi, les lignes de + // strates se posent dessus, et la plage écrase tout près de l'eau. + const IVoxelHeightOp* Structural = nullptr; + OutStack.Add(MakeStructuralHeightSource(P, Seed, &Structural)); + OutStack.Add(MakeCliffHeightMod(P, Structural)); + OutStack.Add(MakeTerraceHeightMod(P)); + OutStack.Add(MakeLayerLineHeightMod(P)); + OutStack.Add(MakeBeachHeightMod(P)); + } +} diff --git a/Source/VoxelForge/Public/VoxelGenerator.h b/Source/VoxelForge/Public/VoxelGenerator.h index 5b60ebd..fbeceef 100644 --- a/Source/VoxelForge/Public/VoxelGenerator.h +++ b/Source/VoxelForge/Public/VoxelGenerator.h @@ -177,6 +177,17 @@ public: */ float SampleRelief(float WorldX, float WorldY, float Frequency, float Contrast) const; + /** + * La chaîne de hauteur complète de SurfaceWorld : structural → cliff → terrace → layer lines → + * plage. Rend une ALTITUDE monde en voxels, pas une densité. + * + * PUBLIQUE pour la même raison que `GetSlabDensity` / `GetMazeDensity` : permettre un test + * isolé. C'est la référence de `VoxelForge.OpStack.SurfaceHeightEquivalence`, qui compare la + * pile d'opérateurs de hauteur (`VoxelHeightOp.h`) à cette fonction point par point. + * Public so the height-op stack can be measured against it — same reason as GetSlabDensity. + */ + float ComputeSurfaceTerrainZ(float WorldX, float WorldY, const FSurfaceGenerationParams& Params) const; + /** * Moisture field at a world XY → [0,1]. The second climate axis for biome placement. */ @@ -267,9 +278,9 @@ private: /** Pick the biome (index into Ctx.Biomes) for a Voronoi site, by its climate. */ int32 ClassifyBiomeAtSite(float SiteX, float SiteY, const FBiomeContext& Ctx, uint32 SiteHash) const; - /** The SurfaceWorld heightfield: world XY → terrain surface Z (voxel coords). Pure - * per-XY; the part that's evaluated per biome and blended in GetSurfaceDensity. */ - float ComputeSurfaceTerrainZ(float WorldX, float WorldY, const FSurfaceGenerationParams& Params) const; + // ComputeSurfaceTerrainZ a été DÉPLACÉE en `public` (voir plus haut) pour que + // VoxelForge.OpStack.SurfaceHeightEquivalence puisse s'y comparer. Une seule déclaration. + // Moved to public above so the height-stack test can compare against it. One declaration only. /** F20 — the RAW structural heightfield (continents + mountains + detail), BEFORE any * terrain op (cliff/terrace/layer-lines/beach). Ops in ComputeSurfaceTerrainZ build on diff --git a/Source/VoxelForge/Public/VoxelHeightOp.h b/Source/VoxelForge/Public/VoxelHeightOp.h new file mode 100644 index 0000000..28f39ff --- /dev/null +++ b/Source/VoxelForge/Public/VoxelHeightOp.h @@ -0,0 +1,202 @@ +// VoxelHeightOp.h +// L'ESPACE DES HAUTEURS — une seconde famille d'opérateurs, et pourquoi elle DOIT exister. +// HEIGHT SPACE — a second operator family, and why it has to exist. +// +// ───────────────────────────────────────────────────────────────────────────────────────── +// LE CONSTAT QUI FORCE CE FICHIER +// ───────────────────────────────────────────────────────────────────────────────────────── +// `OPSTACK-DECOMPOSITION §5` décompose SurfaceWorld ainsi : +// +// FHeightfieldSource ← toute la chaîne de colonne, XY-pure +// ├─ FStructuralHeightField +// ├─ FCliffHeightMod +// ├─ FTerraceHeightMod +// ├─ FLayerLineHeightMod +// └─ FBeachHeightMod +// +// et note, sans en tirer la conséquence : *« les ops de hauteur opèrent sur des valeurs Z dans la +// colonne, pas sur la densité »*. En lisant `ComputeSurfaceTerrainZ`, c'est littéralement vrai : +// c'est une suite de blocs qui lisent et écrivent **un seul float `Terrain`**, une altitude. +// +// **Ils ne rentrent donc PAS dans `IVoxelDensityOp`.** Sa signature est +// `Eval(x, y, z, FVoxelOpSample&)` — par voxel, deux canaux densité/SDF. Un op de hauteur n'a pas +// de Z d'entrée (il en PRODUIT un), ne veut pas être appelé par voxel (il est XY-pur, une fois par +// colonne), et n'écrit ni densité ni SDF. Les forcer dans le contrat densité demanderait soit un +// troisième canal par voxel — alors que la hauteur est une propriété de COLONNE, pas de voxel —, +// soit de replier les cinq en un seul op opaque, ce que `§2.5` appelle précisément l'échec du +// refactor. +// +// **Donc : une seconde famille, dans son propre espace.** C'est la même leçon que `§0.1` (il fallait +// un canal SDF en plus de la densité), un cran plus loin : certaines choses ne sont pas un canal de +// plus, elles sont un ESPACE de plus. +// +// The height ops read and write a single float ALTITUDE. They have no input Z (they produce one), +// are XY-pure (once per column, not per voxel), and write neither density nor SDF. Forcing them into +// IVoxelDensityOp would need either a per-voxel third channel for what is a COLUMN property, or +// collapsing all five into one opaque op — which §2.5 calls the failure mode. Hence a second family. +// +// ───────────────────────────────────────────────────────────────────────────────────────── +// CE QUE ÇA ACHÈTE / WHAT IT BUYS +// ───────────────────────────────────────────────────────────────────────────────────────── +// • **Le cache de colonne T1.a tombe naturellement.** Une pile de hauteur est XY-pure PAR +// CONSTRUCTION — il n'y a pas de Z à mettre dedans par erreur. `AUDIT §6.3` avertit qu'une donnée +// dépendante de Z glissée dans `FSurfaceColumn` corrompt silencieusement toute la pile verticale +// de chunks, et que `ValidateDeterminism` ne le verrait pas. Ici c'est le TYPE qui l'interdit. +// • **La composition d'idées de terrain devient de l'authoring**, comme pour la densité. +// • Les mêmes ops resserviront à VerticalShafts (ledges) et FloatingIslands. +// +// ⚠️ CE FICHIER NE TOUCHE PAS AU JEU. Il est bâti et exercé par +// `VoxelForge.OpStack.SurfaceHeightEquivalence`, qui le compare à `ComputeSurfaceTerrainZ` point par +// point. Le branchement dans le chemin densité est l'étape SUIVANTE (§5 : `FHeightfieldSource`, +// `FSkyCapSource`, `FOverhangShelfMod`), délibérément séparée pour que la question d'architecture +// — *« l'espace des hauteurs se décompose-t-il vraiment ? »* — reçoive une réponse MESURÉE avant +// qu'on écrive l'adaptateur qui en dépend. + +#pragma once + +#include "CoreMinimal.h" +#include "Templates/UniquePtr.h" +#include "VoxelStrateTypes.h" // FSurfaceGenerationParams + +/** + * L'état qui traverse une pile de hauteur. DEUX canaux, exactement comme `FVoxelOpSample` — et + * pour la même raison : le code le fait déjà. + * + * `Relief` (le `M` de `SampleSurfaceStructuralZ`) est PRODUIT par la source structurelle et CONSOMMÉ + * par le gate du terrace (`TerraceStrength * M`). Sans ce second canal, le terrace devrait + * ré-échantillonner le champ de relief — plus lent, et surtout une occasion de diverger de la valeur + * que la source a réellement utilisée. + * + * Two channels, for the same reason as FVoxelOpSample: Relief (the `M` of the structural field) is + * produced by the source and consumed by the terrace gate. Threading it beats resampling it. + */ +struct FVoxelHeightSample +{ + /** Altitude monde en VOXELS (pas cm). */ + float Height = 0.0f; + + /** « Montagnosité » [0,1]. 1 = uniforme (ReliefStrength = 0). */ + float Relief = 1.0f; +}; + +/** + * Un opérateur d'espace-hauteur. Trois différences avec `IVoxelDensityOp`, toutes voulues : + * • pas de Z d'entrée — la pile en PRODUIT un ; + * • XY-pur par construction, donc pas de `IsXYPure()` à déclarer ni à oublier ; + * • pas de `PrepareChunk` — ces ops sont déjà appelés une fois par colonne, ce qui EST la + * granularité que `PrepareChunk` sert à obtenir côté densité. + */ +class IVoxelHeightOp +{ +public: + virtual ~IVoxelHeightOp() = default; + + /** + * INVARIANCE DE FENÊTRE (ARCHITECTURE §8.4) : fonction PURE de (X, Y, seed, params). Le même XY + * évalué depuis une autre tuile, un autre ordre, un autre thread doit rendre le float + * BIT-IDENTIQUE — le cache de colonne T1.a est partagé sur toute la pile verticale de chunks, + * donc une impureté ici se propage à tous les Z d'un coup. + */ + virtual void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const = 0; + + /** + * Majorant CONSERVATIF du déplacement vertical que cet op peut ajouter, en voxels. + * Sert à borner la colonne pour un futur `ClassifyBox` exact du heightfield — la même logique + * que les bandes de `FSlabVoidSource`, qui prouvent 36-40 tuiles sur 60. + * Rendre trop grand coûte du CPU ; rendre trop petit serait un TROU. `FLT_MAX` = « je ne sais + * pas », toujours sûr, et c'est le défaut. + */ + virtual float MaxDisplacement() const { return FLT_MAX; } +}; + +/** + * Pile de hauteur : source → modificateurs, dans l'ordre. Déplaçable, pas copiable, exactement + * comme `FVoxelOpStack` et pour la même raison (elle POSSÈDE ses opérateurs). + */ +class FVoxelHeightStack +{ +public: + FVoxelHeightStack() = default; + FVoxelHeightStack(FVoxelHeightStack&&) = default; + FVoxelHeightStack& operator=(FVoxelHeightStack&&) = default; + FVoxelHeightStack(const FVoxelHeightStack&) = delete; + FVoxelHeightStack& operator=(const FVoxelHeightStack&) = delete; + + void Add(TUniquePtr Op) { Ops.Add(MoveTemp(Op)); } + int32 Num() const { return Ops.Num(); } + + /** L'altitude après toute la pile. */ + float EvalHeight(float WorldX, float WorldY) const + { + return EvalSample(WorldX, WorldY).Height; + } + + /** L'état complet (altitude + relief). */ + FVoxelHeightSample EvalSample(float WorldX, float WorldY) const + { + FVoxelHeightSample S; + for (const TUniquePtr& Op : Ops) { Op->Eval(WorldX, WorldY, S); } + return S; + } + + /** Somme des majorants. `FLT_MAX` dès qu'un seul op ne sait pas répondre. */ + float MaxTotalDisplacement() const + { + float Total = 0.0f; + for (const TUniquePtr& Op : Ops) + { + const float D = Op->MaxDisplacement(); + if (D >= FLT_MAX) { return FLT_MAX; } + Total += D; + } + return Total; + } + +private: + TArray> Ops; +}; + +//============================================================================= +// FABRIQUES / FACTORIES +//============================================================================= + +namespace VoxelHeightOps +{ + /** + * La source structurelle : continents + montagnes + détail, sous une frame de warp. + * Produit `Height` ET `Relief`. Transcription littérale de `SampleSurfaceStructuralZ`. + * + * ⚠️ Rend un pointeur NON-POSSÉDANT via `OutSource` : `FCliffHeightMod` doit pouvoir + * RÉ-ÉCHANTILLONNER ce champ (4 fois, en différences centrées) et doit le faire sur la MÊME + * fonction, pas sur une copie qui pourrait dériver. La pile garde la propriété ; la source vit + * donc aussi longtemps que le modificateur qui la référence, parce que le constructeur de pile + * les ajoute ensemble et que la pile ne réordonne jamais. + */ + VOXELFORGE_API TUniquePtr MakeStructuralHeightSource( + const FSurfaceGenerationParams& P, int32 Seed, const IVoxelHeightOp** OutSource); + + /** Raidissement conditionné par la pente. Le seul op qui coûte des échantillons en plus + * (4 resamples structurels), et seulement quand il est activé. */ + VOXELFORGE_API TUniquePtr MakeCliffHeightMod( + const FSurfaceGenerationParams& P, const IVoxelHeightOp* StructuralSource); + + /** Plateaux quantifiés, gatés par le relief (`TerraceStrength * M`) — d'où le canal Relief. */ + VOXELFORGE_API TUniquePtr MakeTerraceHeightMod(const FSurfaceGenerationParams& P); + + /** Bandes sédimentaires : `Height -= sin(Height · 2π / Spacing) · Depth`. */ + VOXELFORGE_API TUniquePtr MakeLayerLineHeightMod(const FSurfaceGenerationParams& P); + + /** Aplatissement vers la ligne d'eau dans `BeachWidth`. */ + VOXELFORGE_API TUniquePtr MakeBeachHeightMod(const FSurfaceGenerationParams& P); + + /** + * La pile de hauteur complète de SurfaceWorld, dans l'ordre de `ComputeSurfaceTerrainZ` : + * structural → cliff → terrace → layer lines → beach + * + * L'ordre n'est PAS négociable : le terrace quantifie une hauteur que le cliff a déjà raidie, + * les layer lines se posent sur le résultat, et la plage écrase tout près de l'eau. C'est + * l'ordre du code d'origine, et le test échouerait bruyamment sur toute permutation. + */ + VOXELFORGE_API void BuildSurfaceHeightStack(FVoxelHeightStack& OutStack, + const FSurfaceGenerationParams& P, int32 Seed); +}