feat: Phase 2 first port — FlatPlain + CrystalChamber collapse into one op
Jahni closed OPSTACK-DECOMPOSITION 3.1: the slab noise Z term was not intentional character. Phase 1 also closed — the visual A/B on Maze passed. Two changes, deliberately together, kept attributable by the test: 1. Design: GetSlabDensity's floor and ceiling noise lose their Z terms. A floor height no longer depends on the altitude you sample it from. The ceiling keeps its + 3000.0f, which is a decorrelation offset, not a Z term. The world re-tunes once — a different slice of the noise field, not a worse one. 2. Refactor: the now-XY-pure function ports to FSlabVoidSource + FGridColumnMod plus the three structural ops. BuildSlabStack has NO branch on archetype because GetSlabDensity never had one — CrystalChamber is FlatPlain with a bigger CeilingRoughness. 8 archetypes -> 7. SlabEquivalence compares against the reference AS IT IS NOW and runs the whole battery on both slots, so green means the port is a pure refactor and any visual delta is attributable to the Z-term removal alone. The attribution comes from the test, not from splitting it across two builds. The payoff 3.1 was actually about: FSlabVoidSource::ClassifyBox is exact and needs no sampling. FBM is contractually [-1,1], so both surfaces live in Z bands with known bounds — a tile below the floor band is provably solid, a tile between the bands provably air. ClassifyTile proves zero tiles for these archetypes today. FGridColumnMod answers Identity when no column reaches the box, which is what lets the source's AllAir verdict survive the fold. UNVERIFIED: not compiled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+7
-2
@@ -121,7 +121,8 @@ stack share ONE copy. `VoxelGenerator.cpp` keeps same-named `static FORCEINLINE`
|
||||
⚠️ **Feeds the game, behind a per-strate opt-in** (Phase 1 step 3). `GetDensityAt` builds the stack
|
||||
in its per-chunk refetch block and evaluates it *instead of* the `switch` only when
|
||||
`UVoxelStrateManager::UsesOperatorStackForChunk` says so — strate ticked `bUseOperatorStack` **and**
|
||||
archetype in the ported list (**Maze only**). Everything else still takes the `switch`, unchanged.
|
||||
archetype in the ported list (**Maze, FlatPlain, CrystalChamber**). Everything else still takes the
|
||||
`switch`, unchanged.
|
||||
**`ClassifyTile` is NOT wired** — still hand-written guards, not `ClassifyBox`. That is Phase 2.
|
||||
⛔ Never run both paths in one world, and never compare them for equality: the ~1 ULP residue is
|
||||
inherent (AUDIT §C10). The acceptance bar is visual (OPSTACK-PLAN §2.6).
|
||||
@@ -134,6 +135,9 @@ inherent (AUDIT §C10). The acceptance bar is visual (OPSTACK-PLAN §2.6).
|
||||
| `VoxelDensityOps::MakeLatticeCorridorSource` | 1 | Maze corridors, SDF channel. Edge identity = `hash(lower node, axis)` ⇒ adjacent chunks cannot disagree (AUDIT §6.4's preferred pattern). Its `EffectOverBox` answers for the source+carve **pair** (Phase 1 simplification) so it must be told the downstream `ExtraReach`. |
|
||||
| `VoxelDensityOps::MakeSdfRoughnessMod` | 3 | Wall roughness in **SDF** space (Maze/Shafts/Islands variant). TunnelNetwork's density-space roughness is a **different op** — see OPSTACK-DECOMPOSITION §1. |
|
||||
| `VoxelDensityOps::MakeSdfCarve` | 2 | SDF → density carve. The same six lines currently copied in three archetypes. |
|
||||
| `VoxelDensityOps::MakeSlabVoidSource` | 1 | Floor surface + ceiling surface → void field. **XY-pure** since §3.1, which is what gives it an **exact `ClassifyBox` with no sampling**: FBM's `[-1,1]` contract bounds both surfaces into known Z bands. Serves FlatPlain **and** CrystalChamber. |
|
||||
| `VoxelDensityOps::MakeGridColumnMod` | 3 | Infinite-height cylinders on a world grid, 3×3 cell memo. Adds solid only ⇒ `FillOnly` when a column reaches the box, `Identity` otherwise — and that `Identity` is what lets the source's `AllAir` verdict survive. |
|
||||
| `VoxelDensityOps::BuildSlabStack` | — | 5 ops, **no branch on archetype**: FlatPlain and CrystalChamber differ only in defaults, exactly as `GetSlabDensity` already had it. 8 archetypes → 7. |
|
||||
| `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.3 Chunk identity
|
||||
@@ -293,7 +297,7 @@ Maps depth→strate at runtime; owns passages.
|
||||
| `GetLayoutVersion` | h:161 (inline) | Layout/passage generation counter (= `PassagesVersion`, bumped by every `Initialize`). Hot-path callers key `thread_local` memos on it (strate-index memo in `GetDensityWithParams`, passage shortlist) so editor rebuilds never serve stale data. |
|
||||
| `GetStrateForChunk` | 466 | Chunk → definition. |
|
||||
| `GetGeneratorTypeForChunk` | 476 | Chunk → generator type. |
|
||||
| `UsesOperatorStackForChunk` | 559 | Chunk → should `GetDensityAt` take the operator stack? `bUseOperatorStack` on the definition **AND** archetype in the ported list. **That list is written down here and nowhere else** — an unported archetype ignores the flag, so ticking the box anywhere is harmless. Add a row here when you port one. |
|
||||
| `UsesOperatorStackForChunk` | 559 | Chunk → should `GetDensityAt` take the operator stack? `bUseOperatorStack` on the definition **AND** archetype in the ported list (Maze, FlatPlain, CrystalChamber). **That list is written down here and nowhere else** — an unported archetype ignores the flag, so ticking the box anywhere is harmless. Add a row here when you port one. |
|
||||
| `GetSlabParamsForChunk` | 490 | Slab params with runtime Z bounds (no blend — slabs use Hard). |
|
||||
| `GetBiomeContextForChunk` | — | Flatten the strate's `Biomes[]` + `BiomeMapParams` into a POD `FBiomeContext` for the biome field. Empty ⇒ biomes disabled. §8.14. |
|
||||
| `GetGenerationParams` | 515 | **Blended** TunnelNetwork params (handles Gradient/Hard/Interleaved transitions). |
|
||||
@@ -376,6 +380,7 @@ 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. |
|
||||
| `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. |
|
||||
|
||||
## 4. The density pipeline (most-edited hot path)
|
||||
|
||||
@@ -263,7 +263,22 @@ FGridColumnMod Add world-grid jittered cylinders, infinit
|
||||
That is the entire archetype. Two of the eight collapse into one, and the ceiling's `abs(noise)`
|
||||
(formations hang down only, never punch up) is a two-line flag on the source.
|
||||
|
||||
### 3.1 A finding to raise with Jahni before porting
|
||||
### 3.1 ✅ RESOLVED 2026-07-27 — Jahni: the Z term can go. Removed.
|
||||
|
||||
**Decision:** the Z term was not intentional character. It is gone from `GetSlabDensity` (both
|
||||
surfaces), `FSlabVoidSource` is XY-pure, and FlatPlain + CrystalChamber are ported and wired.
|
||||
|
||||
**What that bought, and what it cost:**
|
||||
- `IsXYPure() == true` ⇒ the T1.a column-cache treatment becomes available generically.
|
||||
- An **exact `ClassifyBox` with no sampling**: `VoxelNoise::FBM`'s contract is `[-1,1]`, so both
|
||||
surfaces live in Z bands with known bounds — a tile entirely below the floor band is provably
|
||||
solid, a tile strictly between the bands is provably air. These two archetypes proved **zero**
|
||||
tiles before. `VoxelForge.OpStack.SlabEquivalence` reports the count.
|
||||
- **Cost: the world re-tunes once.** Dropping the term samples a different slice of the noise
|
||||
field, so floor and ceiling shapes change (they do not degrade). Covered by §2.6's explicit
|
||||
permission to re-tune.
|
||||
|
||||
The original finding, kept because it explains why the answer mattered:
|
||||
|
||||
`FSlabVoidSource` is **not XY-pure, and probably should be.** Both surfaces sample noise with a
|
||||
small Z term:
|
||||
@@ -285,6 +300,8 @@ for free, and gets an exact box classification — which means FlatPlain and Cry
|
||||
skipping trivial tiles, which they never have. That is a large win for a one-character change, so
|
||||
it is worth asking rather than assuming either way.
|
||||
|
||||
**Answered: it can go.** See the resolution above.
|
||||
|
||||
---
|
||||
|
||||
## 4. Maze — **the Phase 1 port**
|
||||
|
||||
+7
-5
@@ -4,9 +4,11 @@
|
||||
> *composable density pipeline*, so new world ideas become authoring instead of C++. Written
|
||||
> 2026-07-26 as a handoff for a future context — read this instead of re-deriving it.
|
||||
>
|
||||
> **Status (2026-07-27):** Phase 0.5 (tests) and the Phase 1 skeleton header are WRITTEN AND
|
||||
> COMMITTED but **NOT YET COMPILED**. No operator exists; `GetDensityAt`'s archetype `switch` is
|
||||
> untouched. Live state and the next action live in
|
||||
> **Status (2026-07-27):** **Phase 0.5 and Phase 1 are DONE and verified** — five green tests, Maze
|
||||
> decomposed into seven ops, wired into `GetDensityAt` behind `bUseOperatorStack`, and the visual
|
||||
> A/B passed (Jahni: *"pretty similar, if not entirely similar"*). **Phase 2 is in progress:**
|
||||
> FlatPlain + CrystalChamber are ported into ONE op (`BuildSlabStack`), their §3.1 Z term is gone,
|
||||
> and both are wired — **written, not yet compiled.** Live state and the next action live in
|
||||
> [OPSTACK-PROGRESS.md](OPSTACK-PROGRESS.md) — read its last entry first. The per-archetype
|
||||
> breakdown is in [OPSTACK-DECOMPOSITION.md](OPSTACK-DECOMPOSITION.md).
|
||||
>
|
||||
@@ -356,8 +358,8 @@ Lipschitz-1 off a lattice), and it's the least-used archetype so a mistake is ch
|
||||
Port each archetype **the next time a feature makes you open it anyway**. The switch shrinks on its own.
|
||||
Suggested order when there's a free choice — cheapest and least risky first:
|
||||
|
||||
`Maze` (P1) → `FlatPlain`/`CrystalChamber` (one op, two default sets — the first real win: two archetypes
|
||||
collapse into one) → `SurfaceWorld` (biggest payoff, biggest care: the T1.a column cache and the exact-
|
||||
✅ `Maze` (P1) → ✅ `FlatPlain`/`CrystalChamber` (one op, two default sets — the first real win: two
|
||||
archetypes collapse into one; **done 2026-07-27**, `BuildSlabStack`, 8 archetypes → 7) → `SurfaceWorld` (biggest payoff, biggest care: the T1.a column cache and the exact-
|
||||
lattice `ClassifyTile` bound must both survive) → `VerticalShafts` → `FloatingIslands` → `TunnelNetwork`
|
||||
(**last** — it owns `BuildChunkCache`'s two-region window-invariance discipline, §8.4, the most delicate
|
||||
code in the plugin).
|
||||
|
||||
@@ -876,3 +876,69 @@ field rename would show up here.
|
||||
recognisably the same maze, judged on a screenshot). Then Phase 2, starting with the §3.1 question.
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-27 — PHASE 1 CLOSED (visual A/B passed). Phase 2 opened: the slab collapse.
|
||||
|
||||
**Phase 1's acceptance bar is met.** Jahni built step 3, ticked `bUseOperatorStack` on the Maze
|
||||
strate and compared: *"it's hard to see with our current maze (which is simple in architecture) but
|
||||
seems like it's pretty similar, if not entirely similar."* That is §2.6's bar — recognisably the
|
||||
same maze — and it is worth being precise about how much weight it carries.
|
||||
|
||||
**The screenshot is the weakest evidence Phase 1 has, and that is fine, because it was never
|
||||
carrying the argument.** A simple maze is a poor visual discriminator: "hard to tell apart" is
|
||||
*exactly* what the measurements already predicted, since **0 of 20 000 samples cross the
|
||||
isosurface** — no triangle can move. The A/B's job was to catch the class of error the numbers
|
||||
cannot see (wrong params reaching the stack, wrong strate, wrong wiring), and it did that. The
|
||||
geometric claim rests on the numbers, and always did.
|
||||
|
||||
### Phase 2, first port: FlatPlain + CrystalChamber → ONE op
|
||||
|
||||
**§3.1 answered by Jahni: the Z term can go.** So it is gone, and this is the change that makes the
|
||||
rest worth doing.
|
||||
|
||||
**Two separate changes landed together, deliberately, and the test is what keeps them attributable:**
|
||||
|
||||
1. **The design change** — `GetSlabDensity`'s floor and ceiling noise lost their Z terms
|
||||
(`WorldZ * FF * 0.05f` → `0.0f`; `WorldZ * CF * 0.08f + 3000.0f` → `3000.0f`, keeping the
|
||||
decorrelation offset). The world **re-tunes once**: a different slice of the noise field means a
|
||||
different floor/ceiling shape. Not a degradation — a different draw.
|
||||
2. **The refactor** — the now-XY-pure function ported to `FSlabVoidSource` + `FGridColumnMod`,
|
||||
plus the three structural ops. Five ops.
|
||||
|
||||
`SlabEquivalence` compares the stack against `GetSlabDensity` **as it is now**, so: green ⇒ the port
|
||||
is a pure refactor ⇒ **any visual delta is attributable to the Z-term removal and nothing else.**
|
||||
That is why both could go in one build without losing the ability to say which one caused what —
|
||||
the attribution comes from the test, not from the build order.
|
||||
|
||||
**Why this port matters more than its size:** `BuildSlabStack` has **no branch on archetype**,
|
||||
because `GetSlabDensity` never had one either — CrystalChamber IS FlatPlain with a bigger
|
||||
`CeilingRoughness`. The test runs the identical battery on both slots, so "two archetypes are one
|
||||
op" is demonstrated rather than asserted. **8 archetypes → 7.**
|
||||
|
||||
**And the perf claim, which is what §3.1 was really about:** `FSlabVoidSource::ClassifyBox` is
|
||||
**exact and needs no sampling**. `VoxelNoise::FBM` is contractually `[-1,1]`, so both surfaces live
|
||||
in Z bands with known bounds — a tile below `FloorZ - FloorAmp` is provably solid, a tile strictly
|
||||
between the bands is provably air. A slab strate is mostly solid rock below its floor, so this
|
||||
should prove a large fraction of tiles. `ClassifyTile` proves **zero** today. The test prints the
|
||||
count per archetype; that number is the whole return on the Z term.
|
||||
|
||||
`FGridColumnMod` returning `Identity` when no column reaches the box is what lets the source's
|
||||
`AllAir` survive the fold — otherwise columns would kill every air verdict in the strate.
|
||||
|
||||
**UNVERIFIED: none of this is compiled.** Likely error spots, in order:
|
||||
- `VoxelForgeOpStackSlabTest.cpp` is new — check it is picked up by the module's build.
|
||||
- The lambda `RunForSlot` captures `World`/`Gen` by reference and calls `AddError`/`TestEqual` on
|
||||
the test instance; `TestEqual`'s name argument is built with `*FString::Printf(...)`.
|
||||
- `FGridColumnMod::GetCells` returns a reference to a `thread_local` — intentional (same pattern as
|
||||
`FLatticeCorridorSource::GetCellEdges`), but it is `const` while mutating the thread_local.
|
||||
- `static constexpr float ColBlend` used inside `FMath::Max`/comparisons — may need a definition
|
||||
under older MSVC ODR rules if it is ever odr-used.
|
||||
- `FSlabGenerationParams` must be complete in `VoxelDensityOpStack.h` (it comes via
|
||||
`VoxelStrateTypes.h`, already included).
|
||||
|
||||
**Next single action:** build, run `VoxelForge.OpStack.SlabEquivalence`, and **read the two
|
||||
"proved uniform" numbers** — they are the measured payoff of §3.1. Then tick `bUseOperatorStack` on
|
||||
a FlatPlain or CrystalChamber strate for the visual A/B. Expect the floor/ceiling shape to have
|
||||
changed from the Z-term removal; the question is whether it still reads as the same *kind* of place.
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
// VoxelForgeOpStackSlabTest.cpp
|
||||
// PHASE 2, PREMIER PORTAGE — la pile Slab contre GetSlabDensity, sur LES DEUX archétypes.
|
||||
// PHASE 2'S FIRST PORT — the Slab operator stack against GetSlabDensity, on BOTH archetypes.
|
||||
//
|
||||
// CE QUE CE TEST DOIT PROUVER / WHAT THIS TEST HAS TO PROVE
|
||||
// Trois choses, et la troisième est la raison d'être du portage :
|
||||
//
|
||||
// 1. ÉQUIVALENCE — la pile reproduit `GetSlabDensity`. Même barre que Maze : un changement de
|
||||
// côté d'isosurface est un ÉCHEC DUR, un écart d'ULP est le plancher accepté.
|
||||
// 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.
|
||||
// 3. LE VERDICT DE BOÎTE — et c'est ici que §3.1 se paie. `ClassifyTile` prouve ZÉRO tuile pour
|
||||
// FlatPlain et CrystalChamber aujourd'hui. Depuis que les deux surfaces sont XY-PURES, leurs
|
||||
// bornes en Z sont connues exactement (contrat [-1,1] de FBM), donc toute tuile entièrement
|
||||
// sous le sol ou entre les deux bandes se prouve SANS échantillonner.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// ⚠️ CE TEST NE PEUT PAS DÉTECTER LE RETRAIT DU TERME EN Z — et c'est voulu
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
// `GetSlabDensity` a perdu son terme en Z en même temps que ce portage était écrit
|
||||
// (OPSTACK-DECOMPOSITION §3.1, tranché par Jahni). La pile est comparée à la fonction TELLE
|
||||
// QU'ELLE EST MAINTENANT, donc ce test dit « le portage est fidèle » et ne dit RIEN sur le
|
||||
// changement de génération — c'est exactement la séparation voulue :
|
||||
//
|
||||
// • ce test vert ⇒ la pile == la fonction de référence. Le portage est un refactor pur.
|
||||
// • le monde a changé ⇒ imputable au retrait du terme en Z, ET À RIEN D'AUTRE.
|
||||
//
|
||||
// Sans cette séparation, un écart visuel serait inattribuable entre « j'ai changé le design » et
|
||||
// « j'ai raté le portage ». C'est le test qui fait l'attribution, pas l'ordre des builds.
|
||||
//
|
||||
// This test compares the stack against the reference function AS IT IS NOW, so green here means the
|
||||
// port is a pure refactor and ANY visual delta is attributable to the Z-term removal alone.
|
||||
//
|
||||
// ⚠️ Et la règle de §C10 tient toujours : ne jamais faire tourner les deux chemins dans le même
|
||||
// monde, ne jamais comparer leurs sorties pour égalité ailleurs qu'ici.
|
||||
|
||||
#if WITH_DEV_AUTOMATION_TESTS
|
||||
|
||||
#include "Misc/AutomationTest.h"
|
||||
#include "Async/ParallelFor.h"
|
||||
#include "HAL/PlatformMisc.h"
|
||||
|
||||
#include "VoxelForgeTestFixture.h"
|
||||
#include "VoxelDensityOpStack.h"
|
||||
|
||||
#include <atomic>
|
||||
|
||||
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||
FVoxelForgeOpStackSlabTest,
|
||||
"VoxelForge.OpStack.SlabEquivalence",
|
||||
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr int32 NumSlabSamples = 20000;
|
||||
constexpr int32 NumSlabTiles = 60;
|
||||
}
|
||||
|
||||
bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
using namespace VoxelForgeTest;
|
||||
|
||||
FTestWorld World;
|
||||
World.Build();
|
||||
if (!World.IsValid())
|
||||
{
|
||||
AddError(World.WhyInvalid());
|
||||
return false;
|
||||
}
|
||||
|
||||
const UVoxelGenerator* Gen = World.Generator.Get();
|
||||
|
||||
//=========================================================================
|
||||
// LA BATTERIE, PARAMÉTRÉE PAR ARCHÉTYPE
|
||||
//=========================================================================
|
||||
// Exécutée à l'identique sur FlatPlain et CrystalChamber. Si les deux passent avec la MÊME
|
||||
// pile et la MÊME fabrique, la fusion des deux archétypes est démontrée plutôt qu'affirmée.
|
||||
auto RunForSlot = [&](int32 SlotIndex, const TCHAR* SlotName)
|
||||
{
|
||||
int32 TopVoxelZ = 0, BottomVoxelZ = 0;
|
||||
if (!World.GetSlotVoxelZRange(SlotIndex, TopVoxelZ, BottomVoxelZ))
|
||||
{
|
||||
AddError(FString::Printf(
|
||||
TEXT("The fixture layout has no %s slot. Check FTestWorld::Build's Archetypes[] ")
|
||||
TEXT("against FTestWorld::Slot%s."), SlotName, SlotName));
|
||||
return;
|
||||
}
|
||||
|
||||
const int32 MidChunkZ = ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE;
|
||||
const FSlabGenerationParams SlabParams =
|
||||
World.StrateManager->GetSlabParamsForChunk(FIntVector(0, 0, MidChunkZ));
|
||||
|
||||
// `GetSlabDensity` court-circuite sur une strate dégénérée (`return 1.0f`). Cette garde
|
||||
// appartient à la fonction d'archétype, pas à un opérateur ; la pile suppose une strate
|
||||
// valide, et `GetDensityAt` retombe sur le `switch` dans ce cas.
|
||||
if (SlabParams.StrateTopWorldZ - SlabParams.StrateBottomWorldZ <= 0.0f)
|
||||
{
|
||||
AddError(FString::Printf(
|
||||
TEXT("%s has degenerate Z bounds (top %.1f, bottom %.1f), which sends GetSlabDensity ")
|
||||
TEXT("down its early-out. The op stack has no such early-out by design."),
|
||||
SlotName, SlabParams.StrateTopWorldZ, SlabParams.StrateBottomWorldZ));
|
||||
return;
|
||||
}
|
||||
|
||||
FVoxelOpStack Stack;
|
||||
VoxelDensityOps::BuildSlabStack(Stack, SlabParams, World.Settings->Seed,
|
||||
Gen->OriginSpineRadius, World.StrateManager.Get());
|
||||
|
||||
// La décomposition doit rester une DÉCOMPOSITION : vide + colonnes + 3 structurels.
|
||||
TestEqual(*FString::Printf(TEXT("%s decomposes into void + columns + 3 structural"), SlotName),
|
||||
Stack.Num(), 5);
|
||||
|
||||
FVoxelOpContext Ctx;
|
||||
Ctx.Seed = (uint32)World.Settings->Seed;
|
||||
Ctx.LayoutVersion = World.StrateManager->GetLayoutVersion();
|
||||
Ctx.StrateTopWorldZ = SlabParams.StrateTopWorldZ;
|
||||
Ctx.StrateBottomWorldZ = SlabParams.StrateBottomWorldZ;
|
||||
Stack.PrepareChunk(Ctx);
|
||||
|
||||
TArray<FVector> Points;
|
||||
Points.Reserve(NumSlabSamples);
|
||||
{
|
||||
FRandomStream Rng(31337 + SlotIndex);
|
||||
for (int32 i = 0; i < NumSlabSamples; ++i)
|
||||
{
|
||||
Points.Add(FVector(
|
||||
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
|
||||
(float)Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE),
|
||||
(float)Rng.RandRange(BottomVoxelZ, TopVoxelZ)));
|
||||
}
|
||||
}
|
||||
|
||||
//=====================================================================
|
||||
// 1. ÉQUIVALENCE — géométrie d'abord, bits ensuite.
|
||||
//=====================================================================
|
||||
int32 NumDiff = 0, WorstIdx = -1, NumBeyondUlpNoise = 0, NumSolidDisagreements = 0;
|
||||
float WorstDelta = 0.0f;
|
||||
for (int32 i = 0; i < NumSlabSamples; ++i)
|
||||
{
|
||||
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
|
||||
|
||||
const float Old = Gen->GetSlabDensity(X, Y, Z, SlabParams); // MC : négatif = solide
|
||||
const float New = Stack.EvalMC(X, Y, Z);
|
||||
|
||||
if (!BitEqual(Old, New))
|
||||
{
|
||||
++NumDiff;
|
||||
const float Delta = FMath::Abs(Old - New);
|
||||
if (Delta > WorstDelta) { WorstDelta = Delta; WorstIdx = i; }
|
||||
|
||||
// Même forme que le carve de Maze : `(ColBlend - ColumnSDF)` annule au bord de la
|
||||
// coquille de blend des colonnes, donc un ULP amont ressort amplifié. Marge
|
||||
// généreuse mais BORNÉE — au-delà, c'est une vraie dérive de portage.
|
||||
const float UlpNoise = 16.0f * FMath::Max(FMath::Abs(Old), 1.0f) * FLT_EPSILON;
|
||||
if (Delta > UlpNoise) { ++NumBeyondUlpNoise; }
|
||||
}
|
||||
// Le mesher ne lit que le SIGNE. Un désaccord de CÔTÉ bouge la géométrie.
|
||||
if ((Old >= 0.0f) != (New >= 0.0f)) { ++NumSolidDisagreements; }
|
||||
}
|
||||
|
||||
if (NumDiff == 0)
|
||||
{
|
||||
AddInfo(FString::Printf(TEXT("%s: bit-identical across %d samples."),
|
||||
SlotName, NumSlabSamples));
|
||||
}
|
||||
else if (NumBeyondUlpNoise == 0)
|
||||
{
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("%s: %d of %d samples differ, ALL at ULP scale (largest |delta| %.9g at ")
|
||||
TEXT("(%.0f, %.0f, %.0f)), and 0 cross the isosurface. Same accepted floor as Maze ")
|
||||
TEXT("-- see AUDIT-2026-07.md C10 before hunting it."),
|
||||
SlotName, NumDiff, NumSlabSamples, WorstDelta,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f));
|
||||
}
|
||||
else
|
||||
{
|
||||
AddWarning(FString::Printf(
|
||||
TEXT("%s: %d of %d samples differ and %d are TOO LARGE to be the accepted ULP floor ")
|
||||
TEXT("(largest |delta| %.9g at (%.0f, %.0f, %.0f)); %d cross the isosurface. THIS is ")
|
||||
TEXT("real port drift. Check, in order: the floor/ceiling noise offsets (7.3/11.1 and ")
|
||||
TEXT("17.3+1000/19.7+2000/3000), the abs() on the ceiling noise, the ceiling clamp ")
|
||||
TEXT("(FloorSurface + 2), the column blend (2.0) and the 0.15/0.7 jitter."),
|
||||
SlotName, NumDiff, NumSlabSamples, NumBeyondUlpNoise, WorstDelta,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].X : 0.0f,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].Y : 0.0f,
|
||||
WorstIdx >= 0 ? Points[WorstIdx].Z : 0.0f,
|
||||
NumSolidDisagreements));
|
||||
}
|
||||
|
||||
TestEqual(*FString::Printf(
|
||||
TEXT("%s: no sample lands on the opposite side of the isosurface"), SlotName),
|
||||
NumSolidDisagreements, 0);
|
||||
|
||||
//=====================================================================
|
||||
// 2. INVARIANCE DE FENÊTRE
|
||||
//=====================================================================
|
||||
// Le cache 3×3 des colonnes est `thread_local` et sa clé n'est PAS le chunk mais le jeu de
|
||||
// params + le seed. Si cette clé est incomplète, la couture apparaît ici.
|
||||
{
|
||||
std::atomic<int32> Impure{ 0 };
|
||||
const int32 NumBlocks = FMath::Max(4, FMath::Min(16, FPlatformMisc::NumberOfCores()));
|
||||
|
||||
TArray<float> Ref;
|
||||
Ref.SetNumUninitialized(NumSlabSamples);
|
||||
for (int32 i = 0; i < NumSlabSamples; ++i)
|
||||
{
|
||||
Ref[i] = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
|
||||
}
|
||||
|
||||
ParallelFor(NumBlocks, [&](int32 Block)
|
||||
{
|
||||
TArray<int32> LocalOrder;
|
||||
BuildShuffledOrder(NumSlabSamples, 700 + Block + SlotIndex * 32, LocalOrder);
|
||||
for (const int32 i : LocalOrder)
|
||||
{
|
||||
const float V = Stack.EvalMC((float)Points[i].X, (float)Points[i].Y, (float)Points[i].Z);
|
||||
if (!BitEqual(V, Ref[i])) { Impure.fetch_add(1, std::memory_order_relaxed); }
|
||||
}
|
||||
});
|
||||
|
||||
TestEqual(*FString::Printf(
|
||||
TEXT("%s: the op stack is window-invariant across order and threads"), SlotName),
|
||||
Impure.load(), 0);
|
||||
}
|
||||
|
||||
//=====================================================================
|
||||
// 3. LE VERDICT DE BOÎTE — ce que §3.1 a acheté
|
||||
//=====================================================================
|
||||
{
|
||||
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
|
||||
FRandomStream Rng(24680 + SlotIndex);
|
||||
|
||||
for (int32 t = 0; t < NumSlabTiles; ++t)
|
||||
{
|
||||
const int32 Step = 1, Cells = 8;
|
||||
const int32 Extent = Step * Cells;
|
||||
const FIntVector Origin(
|
||||
Rng.RandRange(-6, 6) * Extent,
|
||||
Rng.RandRange(-6, 6) * Extent,
|
||||
FMath::Clamp(Rng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
|
||||
|
||||
const int32 GridDim = Cells + 1; // le MÊME treillis que le mesher, marge ±1 comprise
|
||||
const FBox Box(
|
||||
FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step),
|
||||
FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step));
|
||||
|
||||
const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Ctx);
|
||||
if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; }
|
||||
++NumProved;
|
||||
|
||||
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
|
||||
for (int32 gz = -1; gz <= GridDim; ++gz)
|
||||
for (int32 gy = -1; gy <= GridDim; ++gy)
|
||||
for (int32 gx = -1; gx <= GridDim; ++gx)
|
||||
{
|
||||
const float X = (float)(Origin.X + gx * Step);
|
||||
const float Y = (float)(Origin.Y + gy * Step);
|
||||
const float Z = (float)(Origin.Z + gz * Step);
|
||||
const float D = Stack.EvalMC(X, Y, Z);
|
||||
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
|
||||
{
|
||||
if (NumUnsound == 0)
|
||||
{
|
||||
AddError(FString::Printf(
|
||||
TEXT("HOLE: %s claimed %s for the box at (%d,%d,%d) but ")
|
||||
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. One of the ")
|
||||
TEXT("ops is not conservative. Suspects, in order: the slab source's ")
|
||||
TEXT("noise amplitude bounds (does FBM really honour [-1,1]?), the ")
|
||||
TEXT("ceiling clamp raising CeilSurface above CeilZ, then the column ")
|
||||
TEXT("mod's reach (MaxRadius + blend)."),
|
||||
SlotName, bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
|
||||
Origin.X, Origin.Y, Origin.Z, X, Y, Z, D,
|
||||
(D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
|
||||
}
|
||||
++NumUnsound;
|
||||
gz = gy = gx = GridDim + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TestEqual(*FString::Printf(
|
||||
TEXT("%s: every box verdict survives brute force (a false verdict is a hole)"),
|
||||
SlotName),
|
||||
NumUnsound, 0);
|
||||
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("%s box verdicts over %d tiles: %d proved uniform, %d Mixed. Today's ")
|
||||
TEXT("ClassifyTile proves ZERO of these. This number is the whole point of making ")
|
||||
TEXT("the slab surfaces XY-pure (OPSTACK-DECOMPOSITION 3.1)."),
|
||||
SlotName, NumSlabTiles, NumProved, NumMixed));
|
||||
|
||||
if (NumProved == 0)
|
||||
{
|
||||
AddWarning(FString::Printf(
|
||||
TEXT("%s proved no tile uniform. Not a correctness problem, but the entire perf ")
|
||||
TEXT("case for dropping the Z term rests on this number being well above zero -- ")
|
||||
TEXT("a slab is mostly solid rock below the floor. Check that the sampled tile Z ")
|
||||
TEXT("range actually reaches below FloorZ - FloorAmp."), SlotName));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
RunForSlot(FTestWorld::SlotFlatPlain, TEXT("FlatPlain"));
|
||||
RunForSlot(FTestWorld::SlotCrystalChamber, TEXT("CrystalChamber"));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // WITH_DEV_AUTOMATION_TESTS
|
||||
@@ -221,6 +221,152 @@ namespace
|
||||
float ExtraReach;
|
||||
};
|
||||
|
||||
//=========================================================================
|
||||
// RÔLE 1 — SOURCE : DALLE / SLAB VOID (FlatPlain ET CrystalChamber)
|
||||
//=========================================================================
|
||||
// Transcription littérale des ÉTAPES 1-3 de `GetSlabDensity` : surface de sol, surface de
|
||||
// plafond, puis `Density = -min(distAuSol, distAuPlafond)`.
|
||||
//
|
||||
// DEUX archétypes, UN opérateur. `GetSlabDensity` est appelé pour FlatPlain et
|
||||
// CrystalChamber sans le moindre branchement sur le type — CrystalChamber n'est rien d'autre
|
||||
// que FlatPlain avec un `CeilingRoughness` plus grand. C'est le premier vrai gain du refactor
|
||||
// (OPSTACK-PLAN §4) : deux des huit archétypes disparaissent dans un seul opérateur, et la
|
||||
// différence entre eux redevient ce qu'elle a toujours été — un jeu de valeurs par défaut.
|
||||
//
|
||||
// Two archetypes, ONE op: GetSlabDensity is called for both with no branch on the type.
|
||||
// CrystalChamber IS FlatPlain with a bigger CeilingRoughness.
|
||||
//
|
||||
// XY-PUR depuis §3.1 (le terme en Z des deux bruits est parti). C'est ce qui rend
|
||||
// `ClassifyBox` exact plutôt qu'estimé — voir plus bas.
|
||||
class FSlabVoidSource final : public IVoxelDensityOp
|
||||
{
|
||||
public:
|
||||
FSlabVoidSource(const FSlabGenerationParams& P, int32 Seed)
|
||||
: SeedF((float)Seed)
|
||||
, FloorRoughness(P.FloorRoughness)
|
||||
, FloorFrequency(P.FloorRoughnessFrequency)
|
||||
, CeilRoughness(P.CeilingRoughness)
|
||||
, CeilFrequency(P.CeilingRoughnessFrequency)
|
||||
{
|
||||
const float StrateHeight = P.StrateTopWorldZ - P.StrateBottomWorldZ;
|
||||
FloorZ = P.StrateBottomWorldZ + StrateHeight * P.FloorRelativeHeight;
|
||||
CeilZ = P.StrateBottomWorldZ + StrateHeight * P.CeilingRelativeHeight;
|
||||
|
||||
// Amplitudes MAXIMALES des deux bruits. Le contrat de `VoxelNoise::FBM` est [-1,1]
|
||||
// (noté à sa définition), donc ces bornes sont des garanties, pas des estimations —
|
||||
// c'est exactement ce qui autorise un verdict de boîte SÛR.
|
||||
// FBM's contract is [-1,1], so these bounds are guarantees, not estimates.
|
||||
FloorAmp = VOXEL_NOISE_SCALE * FMath::Max(FloorRoughness, 0.0f);
|
||||
CeilAmp = VOXEL_NOISE_SCALE * FMath::Max(CeilRoughness, 0.0f);
|
||||
}
|
||||
|
||||
EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; }
|
||||
void PrepareChunk(const FVoxelOpContext&) override {}
|
||||
|
||||
// ⚠️ LE point de §3.1. Faux avant le retrait du terme en Z ; le déclarer alors aurait
|
||||
// corrompu silencieusement toute la pile verticale de chunks (voir l'avertissement sur
|
||||
// `IsXYPure` dans VoxelDensityOp.h).
|
||||
bool IsXYPure() const override { return true; }
|
||||
|
||||
void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override
|
||||
{
|
||||
const float FloorSurface = SurfaceFloor(WorldX, WorldY);
|
||||
const float CeilSurface = SurfaceCeil(WorldX, WorldY, FloorSurface);
|
||||
|
||||
const float DistAboveFloor = WorldZ - FloorSurface;
|
||||
const float DistBelowCeil = CeilSurface - WorldZ;
|
||||
const float VoidField = FMath::Min(DistAboveFloor, DistBelowCeil);
|
||||
|
||||
InOut.Density = -VoidField; // Replace : interne, positif = solide
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// LE VERDICT QUE FLATPLAIN N'A JAMAIS EU
|
||||
//---------------------------------------------------------------------
|
||||
// `ClassifyTile` ne prouve AUCUNE tuile pour les archétypes de grotte aujourd'hui. Ici la
|
||||
// preuve est immédiate et n'exige aucun échantillonnage : les deux surfaces vivent dans des
|
||||
// BANDES en Z dont on connaît les bornes exactes, donc une boîte entièrement sous la bande
|
||||
// du sol est solide, et une boîte entièrement entre les deux bandes est de l'air.
|
||||
//
|
||||
// ⚠️ Conservatif dans le bon sens : rendre `Mixed` ne coûte que du CPU, rendre le mauvais
|
||||
// verdict est un TROU. Toutes les comparaisons ci-dessous sont donc strictes et prennent le
|
||||
// pire cas des deux bruits.
|
||||
EVoxelTileClass ClassifyBox(const FBox& VoxelBox, const FVoxelOpContext&) const override
|
||||
{
|
||||
const float ZMin = (float)VoxelBox.Min.Z;
|
||||
const float ZMax = (float)VoxelBox.Max.Z;
|
||||
|
||||
// Bornes de la surface de sol : FloorZ ± FloorAmp.
|
||||
const float FloorLo = FloorZ - FloorAmp;
|
||||
const float FloorHi = FloorZ + FloorAmp;
|
||||
|
||||
// Bornes du plafond. `CeilNoise = |bruit| · rugosité` ∈ [0, CeilAmp] ⇒ la surface ne
|
||||
// peut que DESCENDRE depuis CeilZ… sauf que le clamp `Max(…, FloorSurface + 2)` peut la
|
||||
// remonter. Le majorant honnête est donc le max des deux possibilités.
|
||||
const float CeilLo = CeilZ - CeilAmp;
|
||||
const float CeilHi = FMath::Max(CeilZ, FloorHi + 2.0f);
|
||||
|
||||
// Sous le sol le plus bas possible ⇒ distAuSol < 0 partout ⇒ densité > 0 ⇒ SOLIDE.
|
||||
if (ZMax < FloorLo) { return EVoxelTileClass::AllSolid; }
|
||||
|
||||
// Au-dessus du plafond le plus haut possible ⇒ distAuPlafond < 0 ⇒ SOLIDE.
|
||||
if (ZMin > CeilHi) { return EVoxelTileClass::AllSolid; }
|
||||
|
||||
// Strictement entre les deux bandes ⇒ les deux distances sont > 0 ⇒ densité < 0 ⇒ AIR.
|
||||
// (Les colonnes peuvent re-remplir cet air : c'est FGridColumnMod qui le déclare, en
|
||||
// rendant FillOnly quand une colonne atteint la boîte. Le pliage s'en charge.)
|
||||
if (ZMin > FloorHi && ZMax < CeilLo) { return EVoxelTileClass::AllAir; }
|
||||
|
||||
return EVoxelTileClass::Mixed;
|
||||
}
|
||||
|
||||
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
|
||||
{
|
||||
return EVoxelOpEffect::Both; // jamais atteint : ClassifyBox répond avant
|
||||
}
|
||||
|
||||
private:
|
||||
// Les deux surfaces, transcrites au caractère près depuis GetSlabDensity — y compris le
|
||||
// détour par FVector, qui est le même piège d'arrondi que dans FSdfRoughnessMod
|
||||
// (float → double → float sous /fp:fast). Ne pas « simplifier ».
|
||||
float SurfaceFloor(float WorldX, float WorldY) const
|
||||
{
|
||||
if (FloorRoughness <= 0.0f) { return FloorZ; }
|
||||
const float FF = FloorFrequency;
|
||||
const FVector NoisePos(WorldX * FF + SeedF * 7.3f,
|
||||
WorldY * FF + SeedF * 11.1f,
|
||||
0.0f);
|
||||
const float N = VoxelNoise::FBM((float)NoisePos.X, (float)NoisePos.Y, (float)NoisePos.Z,
|
||||
VoxelGenLOD::Eff(3), 2.0f, 0.5f)
|
||||
* VOXEL_NOISE_SCALE * FloorRoughness;
|
||||
return FloorZ + N;
|
||||
}
|
||||
|
||||
float SurfaceCeil(float WorldX, float WorldY, float FloorSurface) const
|
||||
{
|
||||
float CeilNoise = 0.0f;
|
||||
if (CeilRoughness > 0.0f)
|
||||
{
|
||||
const float CF = CeilFrequency;
|
||||
const FVector NoisePos(WorldX * CF + SeedF * 17.3f + 1000.0f,
|
||||
WorldY * CF + SeedF * 19.7f + 2000.0f,
|
||||
3000.0f);
|
||||
const float Raw = VoxelNoise::FBM((float)NoisePos.X, (float)NoisePos.Y, (float)NoisePos.Z,
|
||||
VoxelGenLOD::Eff(3), 2.0f, 0.5f)
|
||||
* VOXEL_NOISE_SCALE;
|
||||
// abs() ⇒ les formations ne pendent QUE vers le bas.
|
||||
CeilNoise = FMath::Abs(Raw) * CeilRoughness;
|
||||
}
|
||||
return FMath::Max(CeilZ - CeilNoise, FloorSurface + 2.0f);
|
||||
}
|
||||
|
||||
float SeedF;
|
||||
float FloorZ = 0.0f, CeilZ = 0.0f;
|
||||
float FloorRoughness, FloorFrequency;
|
||||
float CeilRoughness, CeilFrequency;
|
||||
float FloorAmp = 0.0f, CeilAmp = 0.0f;
|
||||
};
|
||||
|
||||
//=========================================================================
|
||||
// RÔLE 3 — MODIFIER : RUGOSITÉ DE PAROI, ESPACE SDF
|
||||
//=========================================================================
|
||||
@@ -275,6 +421,148 @@ namespace
|
||||
float ApplyWithin;
|
||||
};
|
||||
|
||||
//=========================================================================
|
||||
// RÔLE 3 — MODIFIER : COLONNES SUR GRILLE MONDE / WORLD-GRID COLUMNS
|
||||
//=========================================================================
|
||||
// ÉTAPE 4 de `GetSlabDensity`. Des cylindres de hauteur infinie posés sur une grille de
|
||||
// `ColumnSpacing`, un tirage d'existence et un jitter par cellule. Le champ de vide décide déjà
|
||||
// où est le solide, donc la colonne n'a qu'à AJOUTER de la densité le long de son XY — elle
|
||||
// n'est visible que là où le vide avait creusé autour d'elle.
|
||||
//
|
||||
// Le cache 3×3 par cellule est repris tel quel (il était déjà `thread_local` dans l'original,
|
||||
// et c'est exactement ce que la note de threading de VoxelDensityOp.h autorise). Sa clé
|
||||
// contient tous les paramètres qui influent sur le résultat + le seed, donc un changement de
|
||||
// layout qui change un param invalide bien ; un changement qui n'en touche aucun produirait
|
||||
// des colonnes identiques (cf. AUDIT C2 — la clé est complète, pas seulement le coord).
|
||||
class FGridColumnMod final : public IVoxelDensityOp
|
||||
{
|
||||
public:
|
||||
explicit FGridColumnMod(const FSlabGenerationParams& P, int32 InSeed)
|
||||
: Seed((uint32)InSeed)
|
||||
, Spacing(P.ColumnSpacing)
|
||||
, ColDensity(P.ColumnDensity)
|
||||
, MinRadius(P.ColumnMinRadius)
|
||||
, MaxRadius(P.ColumnMaxRadius)
|
||||
, BaseDensity(P.BaseDensity)
|
||||
{}
|
||||
|
||||
EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; }
|
||||
void PrepareChunk(const FVoxelOpContext&) override {}
|
||||
bool IsXYPure() const override { return true; } // cylindres de hauteur infinie
|
||||
|
||||
void Eval(float WorldX, float WorldY, float, FVoxelOpSample& InOut) const override
|
||||
{
|
||||
if (ColDensity <= 0.0f || Spacing <= 0.0f) { return; }
|
||||
|
||||
const int32 ColCX = FMath::FloorToInt(WorldX / Spacing);
|
||||
const int32 ColCY = FMath::FloorToInt(WorldY / Spacing);
|
||||
|
||||
const TArray<FSlabColumn, TInlineAllocator<9>>& Cols = GetCells(ColCX, ColCY);
|
||||
|
||||
float ColumnSDF = FLT_MAX;
|
||||
for (const FSlabColumn& Col : Cols)
|
||||
{
|
||||
const float DX2D = WorldX - Col.X;
|
||||
const float DY2D = WorldY - Col.Y;
|
||||
ColumnSDF = FMath::Min(ColumnSDF, FMath::Sqrt(DX2D * DX2D + DY2D * DY2D) - Col.R);
|
||||
}
|
||||
|
||||
if (ColumnSDF < ColBlend && ColumnSDF < FLT_MAX)
|
||||
{
|
||||
float Fill = FMath::Clamp((ColBlend - ColumnSDF) / (ColBlend * 2.0f), 0.0f, 1.0f);
|
||||
Fill = SmoothStep01(Fill);
|
||||
InOut.Density += Fill * BaseDensity * 1.5f;
|
||||
}
|
||||
}
|
||||
|
||||
// N'AJOUTE que du solide ⇒ tue AllAir, jamais AllSolid. `Identity` dès qu'aucune colonne
|
||||
// n'atteint la boîte — ce qui, pour un `ColumnDensity` de 0.08, est l'écrasante majorité du
|
||||
// volume. C'est cet `Identity` qui laisse survivre le verdict AllAir de la source.
|
||||
EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext&) const override
|
||||
{
|
||||
if (ColDensity <= 0.0f || Spacing <= 0.0f) { return EVoxelOpEffect::Identity; }
|
||||
|
||||
// Marge : le centre d'une colonne vit dans sa cellule, son influence porte au plus
|
||||
// MaxRadius + ColBlend. Sur-estimer coûte du CPU ; sous-estimer serait un trou.
|
||||
const float Reach = FMath::Max(MaxRadius, 0.0f) + ColBlend;
|
||||
|
||||
const int32 CX0 = FMath::FloorToInt(((float)VoxelBox.Min.X - Reach) / Spacing);
|
||||
const int32 CX1 = FMath::FloorToInt(((float)VoxelBox.Max.X + Reach) / Spacing);
|
||||
const int32 CY0 = FMath::FloorToInt(((float)VoxelBox.Min.Y - Reach) / Spacing);
|
||||
const int32 CY1 = FMath::FloorToInt(((float)VoxelBox.Max.Y + Reach) / Spacing);
|
||||
|
||||
for (int32 CY = CY0; CY <= CY1; ++CY)
|
||||
{
|
||||
for (int32 CX = CX0; CX <= CX1; ++CX)
|
||||
{
|
||||
FSlabColumn Col;
|
||||
if (!RollColumn(CX, CY, Col)) { continue; }
|
||||
|
||||
// Cercle (rayon + blend) contre le rectangle XY de la boîte.
|
||||
const float R = Col.R + ColBlend;
|
||||
const float QX = FMath::Max(0.0f, FMath::Max((float)VoxelBox.Min.X - Col.X,
|
||||
Col.X - (float)VoxelBox.Max.X));
|
||||
const float QY = FMath::Max(0.0f, FMath::Max((float)VoxelBox.Min.Y - Col.Y,
|
||||
Col.Y - (float)VoxelBox.Max.Y));
|
||||
if (QX * QX + QY * QY < R * R) { return EVoxelOpEffect::FillOnly; }
|
||||
}
|
||||
}
|
||||
return EVoxelOpEffect::Identity;
|
||||
}
|
||||
|
||||
private:
|
||||
struct FSlabColumn { float X, Y, R; };
|
||||
|
||||
static constexpr float ColBlend = 2.0f; // identique à GetSlabDensity
|
||||
|
||||
/** Le tirage d'une cellule : existence, jitter, rayon. Fonction PURE de (cellule, seed,
|
||||
* params) — donc `Eval` et `EffectOverBox` voient forcément la même colonne. */
|
||||
bool RollColumn(int32 CX, int32 CY, FSlabColumn& Out) const
|
||||
{
|
||||
const uint32 H = VoxelHash::Cell(CX, CY, Seed ^ 0xC01C01u);
|
||||
if (VoxelHash::ToFloat01(H) > ColDensity) { return false; }
|
||||
|
||||
const float JX = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x12345678u));
|
||||
const float JY = VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0x9ABCDEF0u));
|
||||
|
||||
Out.X = (CX + 0.15f + JX * 0.7f) * Spacing;
|
||||
Out.Y = (CY + 0.15f + JY * 0.7f) * Spacing;
|
||||
Out.R = FMath::Lerp(MinRadius, MaxRadius,
|
||||
VoxelHash::ToFloat01(VoxelHash::Mix(H ^ 0xBEEFu)));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Le voisinage 3×3 de la cellule centrale, mémoïsé par worker. */
|
||||
const TArray<FSlabColumn, TInlineAllocator<9>>& GetCells(int32 ColCX, int32 ColCY) const
|
||||
{
|
||||
thread_local TArray<FSlabColumn, TInlineAllocator<9>> SC_Cols;
|
||||
thread_local int32 SC_CX = INT32_MAX, SC_CY = INT32_MAX;
|
||||
thread_local uint32 SC_Seed = 0xFFFFFFFFu;
|
||||
thread_local float SC_Spacing = -1.0f, SC_Dens = -1.0f, SC_MinR = -1.0f, SC_MaxR = -1.0f;
|
||||
|
||||
if (ColCX != SC_CX || ColCY != SC_CY || Seed != SC_Seed || Spacing != SC_Spacing ||
|
||||
ColDensity != SC_Dens || MinRadius != SC_MinR || MaxRadius != SC_MaxR)
|
||||
{
|
||||
SC_CX = ColCX; SC_CY = ColCY; SC_Seed = Seed; SC_Spacing = Spacing;
|
||||
SC_Dens = ColDensity; SC_MinR = MinRadius; SC_MaxR = MaxRadius;
|
||||
SC_Cols.Reset();
|
||||
|
||||
for (int32 DY = -1; DY <= 1; DY++)
|
||||
{
|
||||
for (int32 DX = -1; DX <= 1; DX++)
|
||||
{
|
||||
FSlabColumn Col;
|
||||
if (RollColumn(ColCX + DX, ColCY + DY, Col)) { SC_Cols.Add(Col); }
|
||||
}
|
||||
}
|
||||
}
|
||||
return SC_Cols;
|
||||
}
|
||||
|
||||
uint32 Seed;
|
||||
float Spacing, ColDensity, MinRadius, MaxRadius, BaseDensity;
|
||||
};
|
||||
|
||||
//=========================================================================
|
||||
// RÔLE 2 — COMBINER : SDF → DENSITÉ (CARVE)
|
||||
//=========================================================================
|
||||
@@ -488,6 +776,30 @@ namespace VoxelDensityOps
|
||||
return MakeUnique<FSdfCarveOp>(Blend, BaseDensity);
|
||||
}
|
||||
|
||||
TUniquePtr<IVoxelDensityOp> MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed)
|
||||
{
|
||||
return MakeUnique<FSlabVoidSource>(P, Seed);
|
||||
}
|
||||
|
||||
TUniquePtr<IVoxelDensityOp> MakeGridColumnMod(const FSlabGenerationParams& P, int32 Seed)
|
||||
{
|
||||
return MakeUnique<FGridColumnMod>(P, Seed);
|
||||
}
|
||||
|
||||
void BuildSlabStack(FVoxelOpStack& OutStack, const FSlabGenerationParams& P,
|
||||
int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager)
|
||||
{
|
||||
// DEUX archétypes entrent ici, aucun branchement ne les distingue — parce que
|
||||
// `GetSlabDensity` n'en fait aucun non plus. FlatPlain et CrystalChamber ne diffèrent que
|
||||
// par leurs valeurs par défaut, et c'est maintenant visible dans le code plutôt que dans
|
||||
// un commentaire. 8 archétypes → 7.
|
||||
OutStack.Add(MakeSlabVoidSource(P, Seed));
|
||||
OutStack.Add(MakeGridColumnMod(P, Seed));
|
||||
|
||||
OutStack.AppendStructuralPost(P.StrateTopWorldZ, P.StrateBottomWorldZ,
|
||||
P.BoundarySealThickness, P.BaseDensity, SpineRadius, StrateManager);
|
||||
}
|
||||
|
||||
void BuildMazeStack(FVoxelOpStack& OutStack, const FMazeGenerationParams& P,
|
||||
int32 Seed, float SpineRadius, const UVoxelStrateManager* StrateManager)
|
||||
{
|
||||
|
||||
@@ -562,6 +562,22 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
||||
VoxelDensityOps::BuildMazeStack(CP_OpStack, CP_Maze, Seed,
|
||||
OriginSpineRadius, StrateManager);
|
||||
break;
|
||||
|
||||
case ECaveGeneratorType::FlatPlain:
|
||||
case ECaveGeneratorType::CrystalChamber:
|
||||
// UN SEUL cas pour les deux, comme le `switch` de production juste en dessous :
|
||||
// `GetSlabDensity` ne les distingue pas non plus. Voir BuildSlabStack.
|
||||
// Même garde de strate dégénérée : GetSlabDensity court-circuite sur `1.0f`.
|
||||
if (CP_Slab.StrateTopWorldZ - CP_Slab.StrateBottomWorldZ <= 0.0f)
|
||||
{
|
||||
CP_UseOpStack = false;
|
||||
break;
|
||||
}
|
||||
OpCtx.StrateTopWorldZ = CP_Slab.StrateTopWorldZ;
|
||||
OpCtx.StrateBottomWorldZ = CP_Slab.StrateBottomWorldZ;
|
||||
VoxelDensityOps::BuildSlabStack(CP_OpStack, CP_Slab, Seed,
|
||||
OriginSpineRadius, StrateManager);
|
||||
break;
|
||||
default:
|
||||
// UsesOperatorStackForChunk ne rend true que pour les archétypes portés, donc
|
||||
// on ne devrait jamais arriver ici. Si ça arrive, retomber sur le `switch`
|
||||
@@ -1807,8 +1823,17 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ,
|
||||
// Signed noise allows both hills (noise > 0 → floor rises) and
|
||||
// valleys (noise < 0 → floor dips) for natural rolling ground.
|
||||
//
|
||||
// Z frequency is set very low (5% of XY) so the floor features are
|
||||
// broad and horizontal — like natural geological ground, not bumpy walls.
|
||||
// ⚠️ XY-PUR / XY-PURE (OPSTACK-DECOMPOSITION §3.1, tranché par Jahni 2026-07-27).
|
||||
// La 3e coordonnée était `WorldZ * FF * 0.05f` : une hauteur de sol qui dépendait de
|
||||
// l'altitude d'où on la demandait. Le coefficient était minuscule, donc ça se lisait comme un
|
||||
// léger étirement vertical plutôt que comme un bug — mais ça bloquait le cache de colonnes T1.a
|
||||
// et rendait toute classification de boîte inexacte. Constante ⇒ la surface est une vraie
|
||||
// fonction de (X,Y). Le monde se re-tune une fois : on échantillonne une autre tranche du champ
|
||||
// de bruit, donc la forme du sol change (elle ne se dégrade pas).
|
||||
//
|
||||
// The 3rd coord was WorldZ * FF * 0.05f — a floor height that depended on the altitude you
|
||||
// asked from. Now a constant, so the surface is a genuine function of (X,Y): the T1.a column
|
||||
// cache and an exact box verdict both become available. Worlds re-tune once.
|
||||
|
||||
const float FloorZ = Params.StrateBottomWorldZ + StrateHeight * Params.FloorRelativeHeight;
|
||||
|
||||
@@ -1819,7 +1844,7 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ,
|
||||
FloorNoise = FractalNoise3D(FVector(
|
||||
WorldX * FF + SeedF * 7.3f,
|
||||
WorldY * FF + SeedF * 11.1f,
|
||||
WorldZ * FF * 0.05f // Very low Z freq → horizontal ground features
|
||||
0.0f // XY-pur : plus aucune dépendance en Z / no Z dependence
|
||||
), VoxelGenLOD::Eff(3)) * VOXEL_NOISE_SCALE * Params.FloorRoughness;
|
||||
}
|
||||
|
||||
@@ -1836,9 +1861,10 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ,
|
||||
// This asymmetry (only downward protrusions, never upward pockets) creates
|
||||
// the crystal-forest / stalactite silhouette from below.
|
||||
//
|
||||
// Z frequency is also low so formations have horizontal extent — each
|
||||
// "crystal" or "stalactite" is wide and sweeps across the ceiling, not
|
||||
// a sharp spike (use high frequency for spike-like features if desired).
|
||||
// XY-PUR, même raison que le sol ci-dessus (§3.1). Le `+ 3000.0f` RESTE : ce n'est pas un
|
||||
// terme en Z, c'est le décalage qui décorrèle le champ du plafond de celui du sol.
|
||||
// XY-pure for the same reason as the floor. The + 3000.0f STAYS — it is not a Z term, it is
|
||||
// the offset that decorrelates the ceiling's noise field from the floor's.
|
||||
|
||||
const float CeilZ = Params.StrateBottomWorldZ + StrateHeight * Params.CeilingRelativeHeight;
|
||||
|
||||
@@ -1849,7 +1875,7 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ,
|
||||
float RawNoise = FractalNoise3D(FVector(
|
||||
WorldX * CF + SeedF * 17.3f + 1000.0f,
|
||||
WorldY * CF + SeedF * 19.7f + 2000.0f,
|
||||
WorldZ * CF * 0.08f + 3000.0f // Low Z freq → formations extend horizontally
|
||||
3000.0f // XY-pur : décalage de décorrélation seul / offset only
|
||||
), VoxelGenLOD::Eff(3)) * VOXEL_NOISE_SCALE;
|
||||
|
||||
// abs() → formations ONLY hang down, never push ceiling up into solid rock.
|
||||
|
||||
@@ -571,8 +571,10 @@ bool UVoxelStrateManager::UsesOperatorStackForChunk(const FIntVector& ChunkCoord
|
||||
// and falls back to the switch, so the box can be ticked anywhere without breaking anything.
|
||||
switch (Def->GeneratorType)
|
||||
{
|
||||
case ECaveGeneratorType::Maze: return true;
|
||||
default: return false;
|
||||
case ECaveGeneratorType::Maze: return true; // Phase 1
|
||||
case ECaveGeneratorType::FlatPlain: // Phase 2 — les deux partagent
|
||||
case ECaveGeneratorType::CrystalChamber: return true; // UNE seule pile (BuildSlabStack)
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -183,6 +183,28 @@ namespace VoxelDensityOps
|
||||
* Les six mêmes lignes apparaissent aujourd'hui dans TunnelNetwork, Maze et VerticalShafts. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSdfCarve(float Blend, float BaseDensity);
|
||||
|
||||
/** Rôle 1 — la dalle : surface de sol + surface de plafond → champ de vide. **XY-PUR** depuis
|
||||
* OPSTACK-DECOMPOSITION §3.1 (le terme en Z des deux bruits est parti), ce qui lui donne un
|
||||
* `ClassifyBox` EXACT sans échantillonnage : les deux surfaces vivent dans des bandes en Z
|
||||
* bornées par le contrat [-1,1] de FBM. Sert FlatPlain **et** CrystalChamber. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeSlabVoidSource(const FSlabGenerationParams& P, int32 Seed);
|
||||
|
||||
/** Rôle 3 — cylindres de hauteur infinie sur une grille monde. N'ajoute que du solide ⇒
|
||||
* `FillOnly` quand une colonne atteint la boîte, `Identity` (le cas courant) sinon. */
|
||||
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeGridColumnMod(const FSlabGenerationParams& P, int32 Seed);
|
||||
|
||||
/**
|
||||
* FlatPlain ET CrystalChamber — la même pile, **sans branchement sur le type** :
|
||||
* SlabVoidSource → GridColumnMod → [structural post ×3]
|
||||
*
|
||||
* C'est le premier vrai gain du refactor (OPSTACK-PLAN §4) : deux des huit archétypes
|
||||
* disparaissent dans un opérateur, et leur différence redevient ce qu'elle était déjà dans
|
||||
* `GetSlabDensity` — un jeu de valeurs par défaut, pas du code.
|
||||
*/
|
||||
VOXELFORGE_API void BuildSlabStack(FVoxelOpStack& OutStack, const FSlabGenerationParams& P,
|
||||
int32 Seed, float SpineRadius,
|
||||
const UVoxelStrateManager* StrateManager);
|
||||
|
||||
/**
|
||||
* La pile Maze complète, décomposée — PAS un `FMazeOp` monolithique :
|
||||
* ConstantRockSource → LatticeCorridorSource → SdfRoughnessMod → SdfCarve → [structural post]
|
||||
|
||||
Reference in New Issue
Block a user