feat(opstack): ClassifyTile consumes ClassifyBox for cave archetypes -- the T1.d prize, behind the same opt-in
⚠️ THIS IS THE ONE COMMIT OF THIS RUN WHOSE FAILURE MODE IS A HOLE, NOT A REGRESSION. A non-Mixed verdict makes the world skip GenerateMesh entirely: no triangles, no collision, invisible until a player falls through. Read VoxelForge.OpStack.ClassifyTileSoundness before trusting it. WHAT CHANGED ClassifyTile used to `return Mixed` without a call for every cave archetype ("archétype cave [...] pas prouvable en v1"). It now builds that strate's stack and folds ClassifyBox. SurfaceWorld and bedrock gaps keep their hand-written proofs untouched -- the exact-lattice column test is strictly better than any box bound, so the stack has nothing to add there. ONE DEFINITION OF THE STACK MAPPING, and that is the load-bearing part GetDensityAt's ~90-line build switch is extracted into VF_BuildOpStackForChunk and both callers now use it. A second copy would be the worst bug available here: a tile skipped on the verdict of a stack that is not the one producing its density is precisely a hole. A "keep these in sync" comment would not have been enough; there had to be only one. Params are passed in by pointer, never fetched inside, because both callers already have them. SIX GUARDS, ALL FAILING TO MIXED, none giving the benefit of the doubt 1. The strate must actually opt in -- on EVERY chunk coord the box touches, not just the one that triggered the attempt. Otherwise the classifier judges a field the mesher will not produce. 2. One cave slot per tile. Two slots means two param sets, and a stack answers only for its strate. 3. No mixed cave/surface/gap tile: the cave stack's box would cover Z belonging to another strate. 4. The params must be BIT-IDENTICAL across every chunk coord the box touches. This is the guard that matters, and it exists because of the finding committed earlier today: GetGenerationParams blends params inside a strate (Alpha depends on chunk Z for Gradient, and on chunk XY as well for Interleaved), so one stack genuinely cannot represent a tile that straddles a transition band. Memcmp on POD: differing padding can only produce a false MISMATCH, i.e. one Mixed too many. 5. A 27-chunk-coord cap, so a very wide tile does not pay for the check. We give up the gain, never the safety. 6. The disturbances are folded by hand (chasm ⇒ CarveOnly, bridge/ridge ⇒ FillOnly), because DECOMPOSITION 10.2 leaves them OUTSIDE the stack -- GetDensityAt applies them after the negate. A verdict that ignored them would be wrong exactly where they act. Same inequalities the SurfaceWorld branch already uses. The diff layer needs no new guard: ClassifyTile already returns Mixed for any tile with player mods in range, before any of this. TEST: VoxelForge.OpStack.ClassifyTileSoundness -- the same brute-force oracle as the existing ClassifyTileSoundness, on a world where every strate opted in. It does not check the fold (that is BoxVerdictFold) or the operators (those are the eight equivalence tests); it checks the WIRING. The number to read first is the count of tiles actually brute-forced: zero non-Mixed verdicts would mean the test proved nothing, so that case is an ERROR rather than a quiet pass. Its failure message lists the four suspects in the order worth checking. FIXTURE: FTestWorld::Build gains a bUseOperatorStack parameter (default false, so the thirteen existing tests still exercise the switch), and every test world now gets a PROCESS-UNIQUE LayoutVersion. That second change fixes a real cross-test hazard that was only ever hidden by an accident: PassagesVersion is per-instance and starts at 0, so two FTestWorlds both reported 1, and GetDensityAt's per-chunk caches are keyed on (ChunkCoord, LayoutVersion) -- one world could be served the previous world's params AND its CP_UseOpStack flag. Invisible while every world agreed the flag was false. The first world that ticks it removes that coincidence, in both directions. WHAT THIS BUYS TODAY: Maze, FlatPlain/CrystalChamber, VerticalShafts and FloatingIslands can now prove tiles in production, which is where the measured skipping (Maze 23/60, slabs 36-40/60) turns into frames. TunnelNetwork and Underwater still prove nothing: their chain dies at FRoomGraphSource, which answers Both with unknown amplitude. Making it answer spatially means building the SDF cache for the queried box -- now worth doing, since a skipped tile saves 30k+ density evaluations, and the amplitude fold plus the modifiers' Identity inheritance are already in place to receive it. That is the next piece. And nothing here changes Jahni's current world: no strate asset has bUseOperatorStack ticked, so every guard above is unreachable in his project until he ticks one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+11
-3
@@ -124,8 +124,15 @@ in its per-chunk refetch block and evaluates it *instead of* the `switch` only w
|
|||||||
archetype in the ported list, which is now **all 8 of 8**: Maze, FlatPlain, CrystalChamber,
|
archetype in the ported list, which is now **all 8 of 8**: Maze, FlatPlain, CrystalChamber,
|
||||||
SurfaceWorld, VerticalShafts, FloatingIslands, TunnelNetwork, Underwater. A strate that has not
|
SurfaceWorld, VerticalShafts, FloatingIslands, TunnelNetwork, Underwater. A strate that has not
|
||||||
ticked the box still takes the `switch`, unchanged — the flag is the only thing that switches paths.
|
ticked the box still takes the `switch`, unchanged — the flag is the only thing that switches paths.
|
||||||
**`ClassifyTile` is NOT wired** — still hand-written guards, not `ClassifyBox`. That is the next
|
**`ClassifyTile` IS wired now**, for CAVE archetypes only and behind the same per-strate opt-in:
|
||||||
piece, and it is where the measured tile-skipping turns into frames.
|
where it used to `return Mixed` without a call, it builds the strate's stack through the *same*
|
||||||
|
factory `GetDensityAt` uses (`VF_BuildOpStackForChunk`) and folds `ClassifyBox`. SurfaceWorld and
|
||||||
|
bedrock gaps keep their hand-written proofs — the exact-lattice column test is better than any box
|
||||||
|
bound. Guards, all failing to `Mixed`: one cave slot per tile, no mixed cave/surface/gap tile, the
|
||||||
|
opt-in true on *every* chunk the box touches, the params **bit-identical** across every chunk coord
|
||||||
|
the box touches (blended transition bands make one stack unable to represent the tile — `AUDIT §C2`),
|
||||||
|
a 27-chunk-coord cap, and the disturbances folded in by hand since they are applied after the stack.
|
||||||
|
Brute-forced end to end by `VoxelForge.OpStack.ClassifyTileSoundness`.
|
||||||
⛔ Never run both paths in one world. **Comparing them IS legitimate now** — the ~1 ULP residue of
|
⛔ Never run both paths in one world. **Comparing them IS legitimate now** — the ~1 ULP residue of
|
||||||
AUDIT §C10 is gone since `FPSemantics = Precise`, and all eight equivalence tests compare bit for
|
AUDIT §C10 is gone since `FPSemantics = Precise`, and all eight equivalence tests compare bit for
|
||||||
bit. They are port-correctness oracles, not fidelity checks: the acceptance bar is §2.6.1 (same seed
|
bit. They are port-correctness oracles, not fidelity checks: the acceptance bar is §2.6.1 (same seed
|
||||||
@@ -259,7 +266,8 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h).
|
|||||||
| **`GetSlabDensity`** | 1306 | FlatPlain/CrystalChamber pipeline. See §4.2. |
|
| **`GetSlabDensity`** | 1306 | FlatPlain/CrystalChamber pipeline. See §4.2. |
|
||||||
| `SampleSurfaceStructuralZ` | — | **F20:** the RAW SurfaceWorld heightfield (continents+mountains+detail), BEFORE any terrain op; returns terrain Z + relief M. Cliff re-samples it at an XY offset for a cheap analytic slope. |
|
| `SampleSurfaceStructuralZ` | — | **F20:** the RAW SurfaceWorld heightfield (continents+mountains+detail), BEFORE any terrain op; returns terrain Z + relief M. Cliff re-samples it at an XY offset for a cheap analytic slope. |
|
||||||
| `ComputeSurfaceTerrainZ` / `GetSurfaceDensity` | — | SurfaceWorld heightfield → terrain Z, then density; biome **output-blend** lerps dominant/neighbour heights (`ParamsD`/`ParamsN`/weight). **F20 surface ops** (`FSurfaceGenerationParams`, biome-selected + slope/relief-conditioned, all default off): Cliff (slope-gated STEEPENING — push height from local mean where steep ⇒ sheer walls; 4 structural resamples only when on), Terrace (relief-gated + `TerraceHardness`), LayerLines (sedimentary shelves) — pure per-column height REMAPS applied here so the single height oracle stays consistent (MC/sheets/ClassifyTile/deco/BP bridge). **Phase 2 OVERHANG** (volumetric — real jutting shelves): in `SurfaceDensityFromColumn`, for AIR voxels in a window `(TerrainZ, TerrainZ+OverhangHeight]` above a steep slope, the heightfield is re-sampled UPHILL (toward the cliff) by a reach that GROWS with height (tiny low ⇒ air over the void, full high ⇒ borrows the far cliff rock) and unioned in ⇒ a shelf attached to the cliff, tapering out over the void with air beneath (the sketch). Per-column `OverhangAmp`(=strength·slope-gate) + unit uphill `(DirX,DirY)` resolved once in `ComputeSurfaceColumn` (gradient sampled at the REACH scale so a spot over the void can see the cliff), cached on `FSurfaceColumn`. Genuine 3D (per-voxel structural re-eval, gated to steep overhang columns). Off ⇒ byte-identical. §8.14. |
|
| `ComputeSurfaceTerrainZ` / `GetSurfaceDensity` | — | SurfaceWorld heightfield → terrain Z, then density; biome **output-blend** lerps dominant/neighbour heights (`ParamsD`/`ParamsN`/weight). **F20 surface ops** (`FSurfaceGenerationParams`, biome-selected + slope/relief-conditioned, all default off): Cliff (slope-gated STEEPENING — push height from local mean where steep ⇒ sheer walls; 4 structural resamples only when on), Terrace (relief-gated + `TerraceHardness`), LayerLines (sedimentary shelves) — pure per-column height REMAPS applied here so the single height oracle stays consistent (MC/sheets/ClassifyTile/deco/BP bridge). **Phase 2 OVERHANG** (volumetric — real jutting shelves): in `SurfaceDensityFromColumn`, for AIR voxels in a window `(TerrainZ, TerrainZ+OverhangHeight]` above a steep slope, the heightfield is re-sampled UPHILL (toward the cliff) by a reach that GROWS with height (tiny low ⇒ air over the void, full high ⇒ borrows the far cliff rock) and unioned in ⇒ a shelf attached to the cliff, tapering out over the void with air beneath (the sketch). Per-column `OverhangAmp`(=strength·slope-gate) + unit uphill `(DirX,DirY)` resolved once in `ComputeSurfaceColumn` (gradient sampled at the REACH scale so a spot over the void can see the cliff), cached on `FSurfaceColumn`. Genuine 3D (per-voxel structural re-eval, gated to steep overhang columns). Off ⇒ byte-identical. §8.14. |
|
||||||
| `ClassifyTile` | — | **T1.d trivial-tile reject** (worker, called by `LoadTile` before `GenerateMesh`): proves a tile AllSolid/AllAir on the mesher's exact lattice (gap chunks + SurfaceWorld columns via the SHARED `GSurfColCache`; seal bands; guards: diff mods, passages, spine, disturbances, **F20 overhang** — a column point in `(TerrainZ, TerrainZ+OverhangMargin]` (margin = max `OverhangHeight`) is unprovable ⇒ Mixed, UPWARD only since the shelf union only ADDS rock above ground, so an overhang shelf never holes a trivially-skipped tile) → skip gen. Mixed = generate normally. §8.10. |
|
| `VF_BuildOpStackForChunk` (file-static) | — | **The archetype → stack mapping, written down once.** `GetDensityAt` and `ClassifyTile` both call it; params are passed in, never fetched here. A second copy would be the worst bug available in this file — a tile skipped on the verdict of a stack that is not the one producing its density is a hole. Returns false (⇒ caller falls back to the `switch`) for an unported archetype, missing params, or a **degenerate strate**, since five archetype functions early-out to air there and the stack deliberately has no such early-out. `Refs.Surface == nullptr` makes it refuse SurfaceWorld, which is how `ClassifyTile` keeps its own exact-lattice proof. |
|
||||||
|
| `ClassifyTile` | — | **T1.d trivial-tile reject** (worker, called by `LoadTile` before `GenerateMesh`): proves a tile AllSolid/AllAir on the mesher's exact lattice (gap chunks + SurfaceWorld columns via the SHARED `GSurfColCache`; seal bands; **cave archetypes via `FVoxelOpStack::ClassifyBox` when the strate opted in** — see §3.2d for the six guards, all failing to `Mixed`; guards: diff mods, passages, spine, disturbances, **F20 overhang** — a column point in `(TerrainZ, TerrainZ+OverhangMargin]` (margin = max `OverhangHeight`) is unprovable ⇒ Mixed, UPWARD only since the shelf union only ADDS rock above ground, so an overhang shelf never holes a trivially-skipped tile) → skip gen. Mixed = generate normally. §8.10. |
|
||||||
| `SampleRelief` / `SampleMoisture` | — | Climate fields (pure XY, [0,1]). Relief = shared source of truth for the relief map M. §8.14. |
|
| `SampleRelief` / `SampleMoisture` | — | Climate fields (pure XY, [0,1]). Relief = shared source of truth for the relief map M. §8.14. |
|
||||||
| `SampleBiomeAt` | — | Warped-Voronoi + climate biome query (dominant + neighbour + weight). Reference used by the preview bake + `GetDominantBiomeAt`. §8.14. |
|
| `SampleBiomeAt` | — | Warped-Voronoi + climate biome query (dominant + neighbour + weight). Reference used by the preview bake + `GetDominantBiomeAt`. §8.14. |
|
||||||
| `ResolveBiomeSampleAt` / `RebuildBiomeGrid` | — | Hot-path biome resolve (FBiomeSample) via a box-validated per-chunk cell-grid cache. Bit-identical to `SampleBiomeAt`. §8.14, §8.10. |
|
| `ResolveBiomeSampleAt` / `RebuildBiomeGrid` | — | Hot-path biome resolve (FBiomeSample) via a box-validated per-chunk cell-grid cache. Bit-identical to `SampleBiomeAt`. §8.14, §8.10. |
|
||||||
|
|||||||
@@ -233,6 +233,169 @@ bool FVoxelForgeClassifyTileTest::RunTest(const FString& Parameters)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// LE CHEMIN PILE D'OPÉRATEURS DE ClassifyTile — MÊME FORCE BRUTE, MONDE OPT-IN
|
||||||
|
//=============================================================================
|
||||||
|
// `ClassifyTile` rendait `Mixed` sans appel pour tout archétype de CAVE. Il consulte désormais
|
||||||
|
// `FVoxelOpStack::ClassifyBox` quand la strate a coché `bUseOperatorStack` — donc **un tout nouveau
|
||||||
|
// chemin peut faire sauter le maillage d'une tuile**, et son erreur est un TROU : pas de triangles,
|
||||||
|
// pas de collision, invisible jusqu'à ce qu'un joueur tombe au travers.
|
||||||
|
//
|
||||||
|
// Ce test est le même oracle par force brute que `ClassifyTileSoundness`, sur un monde dont TOUTES
|
||||||
|
// les strates ont coché la case. Il ne vérifie pas le pliage (c'est `BoxVerdictFold`) ni les
|
||||||
|
// opérateurs (ce sont les huit tests d'équivalence) : il vérifie le **câblage** — que la pile
|
||||||
|
// interrogée par le classifieur est bien celle qui produit la densité, params, drapeau et
|
||||||
|
// disturbances compris.
|
||||||
|
//
|
||||||
|
// ⚠️ LE COMPTEUR À LIRE EN PREMIER est le nombre de tuiles réellement brute-forcées. Un run vert
|
||||||
|
// avec zéro verdict non-Mixed ne prouverait RIEN — exactement le piège que ce fichier documente
|
||||||
|
// depuis sa première version, et la raison pour laquelle l'absence de verdict est une ERREUR ici.
|
||||||
|
//
|
||||||
|
// Same brute-force oracle as ClassifyTileSoundness, on a world where every strate has opted in.
|
||||||
|
// It checks the WIRING, not the fold and not the operators.
|
||||||
|
|
||||||
|
IMPLEMENT_SIMPLE_AUTOMATION_TEST(
|
||||||
|
FVoxelForgeOpStackClassifyTileTest,
|
||||||
|
"VoxelForge.OpStack.ClassifyTileSoundness",
|
||||||
|
EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter)
|
||||||
|
|
||||||
|
bool FVoxelForgeOpStackClassifyTileTest::RunTest(const FString& Parameters)
|
||||||
|
{
|
||||||
|
using namespace VoxelForgeTest;
|
||||||
|
|
||||||
|
// ⚠️ `bUseOperatorStack = true` sur toutes les strates : c'est LE point du test. La fixture
|
||||||
|
// donne à ce monde une `LayoutVersion` unique dans le processus, sans quoi les caches par chunk
|
||||||
|
// de `GetDensityAt` — dont `CP_UseOpStack` — pourraient encore porter ceux d'un autre test.
|
||||||
|
FTestWorld World;
|
||||||
|
World.Build(/*Seed*/1337, /*GapChunks*/2, /*bUseOperatorStack*/true);
|
||||||
|
if (!World.IsValid())
|
||||||
|
{
|
||||||
|
AddError(World.WhyInvalid());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UVoxelGenerator* Gen = World.Generator.Get();
|
||||||
|
|
||||||
|
FRandomStream Rng(20260728);
|
||||||
|
TArray<FTileSpec> ToVerify;
|
||||||
|
int32 NumMixed = 0, NumAllSolid = 0, NumAllAir = 0;
|
||||||
|
|
||||||
|
const int32 TopVoxelZ = World.TopChunkZ * CHUNK_SIZE;
|
||||||
|
const int32 BottomVoxelZ = World.BottomChunkZ * CHUNK_SIZE;
|
||||||
|
|
||||||
|
// Tirage uniforme sur tout le layout, PAS biaisé vers SurfaceWorld comme l'autre test : ici ce
|
||||||
|
// sont précisément les strates de cave qui intéressent, puisque ce sont elles qui passent par le
|
||||||
|
// nouveau chemin. SurfaceWorld continue d'être prouvé par le code écrit à la main.
|
||||||
|
for (int32 t = 0; t < NumTilesScanned; ++t)
|
||||||
|
{
|
||||||
|
FTileSpec Spec;
|
||||||
|
Spec.Step = 1 << Rng.RandRange(0, 2);
|
||||||
|
Spec.Cells = (t % 8 == 0) ? CHUNK_SIZE : 16;
|
||||||
|
const int32 Extent = Spec.Step * Spec.Cells;
|
||||||
|
|
||||||
|
auto FloorDiv = [](int32 A, int32 B) { const int32 Q = A / B, R = A % B; return (R != 0 && (R < 0) != (B < 0)) ? Q - 1 : Q; };
|
||||||
|
const int32 LoTile = FloorDiv(BottomVoxelZ, Extent);
|
||||||
|
const int32 HiTile = FMath::Max(LoTile, FloorDiv(TopVoxelZ, Extent));
|
||||||
|
|
||||||
|
Spec.Origin = FIntVector(
|
||||||
|
Rng.RandRange(-4, 4) * Extent,
|
||||||
|
Rng.RandRange(-4, 4) * Extent,
|
||||||
|
Rng.RandRange(LoTile, HiTile) * Extent);
|
||||||
|
|
||||||
|
const EVoxelTileClass Verdict = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
|
||||||
|
switch (Verdict)
|
||||||
|
{
|
||||||
|
case EVoxelTileClass::Mixed: ++NumMixed; break;
|
||||||
|
case EVoxelTileClass::AllSolid: ++NumAllSolid; if (ToVerify.Num() < MaxTilesVerified) ToVerify.Add(Spec); break;
|
||||||
|
case EVoxelTileClass::AllAir: ++NumAllAir; if (ToVerify.Num() < MaxTilesVerified) ToVerify.Add(Spec); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AddInfo(FString::Printf(
|
||||||
|
TEXT("ClassifyTile ON THE OPERATOR-STACK PATH, %d scanned tiles: Mixed %d, AllSolid %d, ")
|
||||||
|
TEXT("AllAir %d (brute-forcing %d). Compare with VoxelForge.Determinism.ClassifyTileSoundness, ")
|
||||||
|
TEXT("which runs the SAME scan on a world that has NOT opted in: every verdict beyond what ")
|
||||||
|
TEXT("that test reports is a tile the mesher now skips and did not before. That difference ")
|
||||||
|
TEXT("IS the T1.d prize OPSTACK-PLAN has been aiming at -- and every one of those tiles is a ")
|
||||||
|
TEXT("hole if the wiring is wrong, which is what the brute force below is for."),
|
||||||
|
NumTilesScanned, NumMixed, NumAllSolid, NumAllAir, ToVerify.Num()));
|
||||||
|
|
||||||
|
if (ToVerify.Num() == 0)
|
||||||
|
{
|
||||||
|
AddError(TEXT("VACUOUS: not one tile got a non-Mixed verdict on the operator-stack path, so ")
|
||||||
|
TEXT("this test verified NOTHING about the new wiring. Either no strate actually ")
|
||||||
|
TEXT("opted in (check FTestWorld::Build's bUseOperatorStack), or every guard in the ")
|
||||||
|
TEXT("cave branch of ClassifyTile bailed to Mixed -- the params-identical check and ")
|
||||||
|
TEXT("the 27-chunk-coord cap are the likeliest. Do NOT read a green run as proof."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int32 NumHoles = 0;
|
||||||
|
for (const FTileSpec& Spec : ToVerify)
|
||||||
|
{
|
||||||
|
const EVoxelTileClass Verdict = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
|
||||||
|
if (Verdict == EVoxelTileClass::Mixed) { continue; }
|
||||||
|
|
||||||
|
const int32 CPA = FMath::Clamp(Spec.Cells, 2, CHUNK_SIZE);
|
||||||
|
const int32 GridDim = CPA + 1;
|
||||||
|
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
|
||||||
|
|
||||||
|
bool bTileBad = false;
|
||||||
|
for (int32 gz = -1; gz <= GridDim && !bTileBad; ++gz)
|
||||||
|
for (int32 gy = -1; gy <= GridDim && !bTileBad; ++gy)
|
||||||
|
for (int32 gx = -1; gx <= GridDim && !bTileBad; ++gx)
|
||||||
|
{
|
||||||
|
const float X = (float)(Spec.Origin.X + gx * Spec.Step);
|
||||||
|
const float Y = (float)(Spec.Origin.Y + gy * Spec.Step);
|
||||||
|
const float Z = (float)(Spec.Origin.Z + gz * Spec.Step);
|
||||||
|
const float D = Gen->GetDensityAt(X, Y, Z);
|
||||||
|
|
||||||
|
const bool bAgrees = bClaimsSolid ? (D < 0.0f) : (D >= 0.0f);
|
||||||
|
if (!bAgrees)
|
||||||
|
{
|
||||||
|
bTileBad = true;
|
||||||
|
++NumHoles;
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("HOLE ON THE OPERATOR-STACK PATH: ClassifyTile said %s for tile (%d,%d,%d) ")
|
||||||
|
TEXT("Step=%d Cells=%d, but GetDensityAt(%.0f, %.0f, %.0f) = %.6g is on the %s ")
|
||||||
|
TEXT("side. Check, in order: (1) does GetDensityAt for this chunk actually take ")
|
||||||
|
TEXT("the stack (CP_UseOpStack), or did the classifier judge a field the mesher ")
|
||||||
|
TEXT("will not produce; (2) the params-identical check -- a blended transition ")
|
||||||
|
TEXT("band means one stack cannot represent the whole tile (AUDIT C2); (3) the ")
|
||||||
|
TEXT("disturbance fold, since disturbances are applied AFTER the stack and are ")
|
||||||
|
TEXT("not part of it; (4) an operator's EffectOverBox claiming Identity where it ")
|
||||||
|
TEXT("can act -- the per-room op override can ENABLE a modifier the strate had ")
|
||||||
|
TEXT("switched off, which makes a box bound too optimistic."),
|
||||||
|
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
|
||||||
|
Spec.Origin.X, Spec.Origin.Y, Spec.Origin.Z, Spec.Step, Spec.Cells,
|
||||||
|
X, Y, Z, D, (D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TestEqual(TEXT("no tile was classified uniform on the operator-stack path while containing a ")
|
||||||
|
TEXT("surface (a false verdict is a hole)"), NumHoles, 0);
|
||||||
|
|
||||||
|
// Même contrôle de stabilité que sur l'autre chemin : la pile est reconstruite à chaque appel,
|
||||||
|
// et `FRoomGraphSource` partage un cache `thread_local` avec le chemin densité — deux appels
|
||||||
|
// successifs doivent malgré tout rendre le même verdict.
|
||||||
|
for (const FTileSpec& Spec : ToVerify)
|
||||||
|
{
|
||||||
|
const EVoxelTileClass A = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
|
||||||
|
const EVoxelTileClass B = Gen->ClassifyTile(Spec.Origin, Spec.Step, Spec.Cells);
|
||||||
|
if (A != B)
|
||||||
|
{
|
||||||
|
AddError(FString::Printf(
|
||||||
|
TEXT("UNSTABLE VERDICT on the operator-stack path at tile (%d,%d,%d) Step=%d: %d vs ")
|
||||||
|
TEXT("%d. The classifier builds a fresh stack per call, so a difference means an ")
|
||||||
|
TEXT("operator is reading thread_local state the density path mutates."),
|
||||||
|
Spec.Origin.X, Spec.Origin.Y, Spec.Origin.Z, Spec.Step, (int32)A, (int32)B));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
// LE FOLD DE LA PILE D'OPÉRATEURS / the op-stack fold
|
// LE FOLD DE LA PILE D'OPÉRATEURS / the op-stack fold
|
||||||
//=============================================================================
|
//=============================================================================
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ namespace VoxelForgeTest
|
|||||||
* dangerous — and `VoxelForge.Determinism.LargeSeedSurvives` deliberately passes big ones
|
* dangerous — and `VoxelForge.Determinism.LargeSeedSurvives` deliberately passes big ones
|
||||||
* (up to 2e9) to prove it stays that way.
|
* (up to 2e9) to prove it stays that way.
|
||||||
*/
|
*/
|
||||||
void Build(int32 InSeed = 1337, int32 InGapChunks = 2)
|
void Build(int32 InSeed = 1337, int32 InGapChunks = 2, bool bUseOperatorStack = false)
|
||||||
{
|
{
|
||||||
Settings = TStrongObjectPtr<UVoxelSettings>(
|
Settings = TStrongObjectPtr<UVoxelSettings>(
|
||||||
NewObject<UVoxelSettings>(GetTransientPackage(), NAME_None, RF_Transient));
|
NewObject<UVoxelSettings>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
@@ -104,6 +104,10 @@ namespace VoxelForgeTest
|
|||||||
GetTransientPackage(), NAME_None, RF_Transient);
|
GetTransientPackage(), NAME_None, RF_Transient);
|
||||||
Def->GeneratorType = Archetypes[i];
|
Def->GeneratorType = Archetypes[i];
|
||||||
Def->StrateHeightInChunks = 4;
|
Def->StrateHeightInChunks = 4;
|
||||||
|
// L'OPT-IN de la pile d'opérateurs. Faux par défaut : les treize tests existants
|
||||||
|
// doivent continuer à exercer le `switch`, qui reste le comportement de référence.
|
||||||
|
// Seul le test de solidité de ClassifyTie côté pile le passe à vrai.
|
||||||
|
Def->bUseOperatorStack = bUseOperatorStack;
|
||||||
// Hard transitions: param blending across a boundary would make "which archetype
|
// Hard transitions: param blending across a boundary would make "which archetype
|
||||||
// owns this chunk" ambiguous, and these tests want an unambiguous mapping.
|
// owns this chunk" ambiguous, and these tests want an unambiguous mapping.
|
||||||
Def->TransitionType = EVoxelStrateTransition::Hard;
|
Def->TransitionType = EVoxelStrateTransition::Hard;
|
||||||
@@ -117,7 +121,36 @@ namespace VoxelForgeTest
|
|||||||
|
|
||||||
StrateManager = TStrongObjectPtr<UVoxelStrateManager>(
|
StrateManager = TStrongObjectPtr<UVoxelStrateManager>(
|
||||||
NewObject<UVoxelStrateManager>(GetTransientPackage(), NAME_None, RF_Transient));
|
NewObject<UVoxelStrateManager>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
|
||||||
|
//=================================================================
|
||||||
|
// ⚠️ CHAQUE MONDE DE TEST OBTIENT UNE `LayoutVersion` UNIQUE DANS LE PROCESSUS
|
||||||
|
//=================================================================
|
||||||
|
// Ce n'est pas de la cosmétique, c'est une CONTAMINATION CROISÉE réelle entre tests, et
|
||||||
|
// elle n'était jusqu'ici masquée que par un accident.
|
||||||
|
//
|
||||||
|
// `PassagesVersion` est PAR INSTANCE et part de 0, donc deux `FTestWorld` successifs
|
||||||
|
// rendaient tous les deux **1**. Or les caches par chunk de `GetDensityAt` sont clés sur
|
||||||
|
// `(ChunkCoord, LayoutVersion)` : deux mondes différents, même version, même chunk ⇒ le
|
||||||
|
// second se voit servir les params — ET le drapeau `CP_UseOpStack` — du premier.
|
||||||
|
// Personne ne l'a vu parce que `bUseOperatorStack` valait false partout : les deux
|
||||||
|
// mondes étaient d'accord par défaut. Le premier monde qui coche la case fait tomber
|
||||||
|
// cette coïncidence, dans les DEUX sens (il contamine, et il est contaminé).
|
||||||
|
//
|
||||||
|
// Un compteur de processus donne à chaque monde une version distincte, donc tout cache
|
||||||
|
// survivant d'un test à l'autre est forcément invalidé. `Initialize` est déterministe
|
||||||
|
// (le pool est mélangé par le seed, les fixed strates sont épinglées), donc le rappeler
|
||||||
|
// ne change pas le layout — seulement le compteur.
|
||||||
|
//
|
||||||
|
// Each test world gets a process-unique LayoutVersion. Two worlds both reporting 1 made
|
||||||
|
// GetDensityAt's per-chunk caches serve the previous world's params — and its
|
||||||
|
// CP_UseOpStack flag — for the same chunk coord. Invisible while every world agreed that
|
||||||
|
// the flag was false.
|
||||||
|
static int32 GWorldSerial = 0;
|
||||||
|
const int32 Bumps = ++GWorldSerial;
|
||||||
|
for (int32 b = 0; b < Bumps; ++b)
|
||||||
|
{
|
||||||
StrateManager->Initialize(Settings.Get(), Settings->Seed);
|
StrateManager->Initialize(Settings.Get(), Settings->Seed);
|
||||||
|
}
|
||||||
|
|
||||||
DiffLayer = TStrongObjectPtr<UVoxelDiffLayer>(
|
DiffLayer = TStrongObjectPtr<UVoxelDiffLayer>(
|
||||||
NewObject<UVoxelDiffLayer>(GetTransientPackage(), NAME_None, RF_Transient));
|
NewObject<UVoxelDiffLayer>(GetTransientPackage(), NAME_None, RF_Transient));
|
||||||
|
|||||||
@@ -423,6 +423,124 @@ static void ApplyDisturbances(float& MC, float X, float Y, float Z,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//=============================================================================
|
||||||
|
// LE MAPPING « ARCHÉTYPE → PILE D'OPÉRATEURS » — UNE SEULE DÉFINITION
|
||||||
|
//=============================================================================
|
||||||
|
// ⚠️ EXTRAIT DE `GetDensityAt` PARCE QUE `ClassifyTile` EN A BESOIN AUSSI, ET QU'UNE DEUXIÈME COPIE
|
||||||
|
// SERAIT LA PIRE FORME DE BUG DISPONIBLE ICI.
|
||||||
|
//
|
||||||
|
// `ClassifyTile` décide si une tuile est maillée DU TOUT. Si son verdict venait d'une pile
|
||||||
|
// construite autrement que celle qui produit la densité — ne serait-ce qu'un paramètre de
|
||||||
|
// construction différent — la tuile serait sautée sur la foi d'un champ qui n'est pas celui que le
|
||||||
|
// mesher aurait vu. C'est-à-dire un TROU : pas de géométrie, pas de collision, invisible.
|
||||||
|
// Un commentaire « garder les deux en phase » n'aurait pas suffi ; il fallait qu'il n'y en ait
|
||||||
|
// qu'une.
|
||||||
|
//
|
||||||
|
// Les params ne sont PAS cherchés ici : les deux appelants les ont déjà (le chemin densité les tient
|
||||||
|
// dans ses `CP_*`, le classifieur les cherche pour son slot). On ne passe que des pointeurs.
|
||||||
|
//
|
||||||
|
// ONE definition of the archetype → stack mapping, because ClassifyTile decides whether a tile is
|
||||||
|
// meshed at all: a verdict from a differently-built stack would be a hole. Params are passed in,
|
||||||
|
// never fetched here — both callers already have them.
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
struct FVoxelStackParamRefs
|
||||||
|
{
|
||||||
|
const FSlabGenerationParams* Slab = nullptr;
|
||||||
|
const FMazeGenerationParams* Maze = nullptr;
|
||||||
|
const FVerticalShaftParams* Vert = nullptr;
|
||||||
|
const FFloatingIslandParams* Float = nullptr;
|
||||||
|
const FStrateGenerationParams* Tunnel = nullptr;
|
||||||
|
|
||||||
|
// SurfaceWorld uniquement. `Surface == nullptr` ⇒ la fabrique REFUSE cet archétype, ce qui
|
||||||
|
// est exactement ce que veut `ClassifyTile` : il prouve SurfaceWorld lui-même, sur le
|
||||||
|
// treillis exact du mesher, et n'a aucune raison de passer par la pile pour ça.
|
||||||
|
const FSurfaceGenerationParams* Surface = nullptr;
|
||||||
|
const TArray<FSurfaceGenerationParams>* SurfaceBiomeParams = nullptr;
|
||||||
|
TUniquePtr<IVoxelBiomeField> BiomeField;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit la pile de cet archétype dans `OutStack` et remplit les bornes Z de `OutCtx`.
|
||||||
|
*
|
||||||
|
* @return false quand la pile NE DOIT PAS être utilisée — archétype non porté, params absents,
|
||||||
|
* ou **strate dégénérée**. Ce dernier cas n'est pas de la prudence : cinq fonctions
|
||||||
|
* d'archétype court-circuitent sur `return 1.0f` (= air) quand la hauteur est nulle,
|
||||||
|
* et la pile n'a pas cet early-out, par conception. L'appelant retombe sur le `switch`,
|
||||||
|
* qui EST le comportement de référence. (`GetDensityWithParams`, lui, n'a aucun
|
||||||
|
* early-out de ce genre : TunnelNetwork/Underwater n'ont donc pas cette garde.)
|
||||||
|
*/
|
||||||
|
bool VF_BuildOpStackForChunk(ECaveGeneratorType Type, FVoxelStackParamRefs& Refs,
|
||||||
|
int32 Seed, float SpineRadius, const UVoxelStrateManager* SM,
|
||||||
|
FVoxelOpStack& OutStack, FVoxelOpContext& OutCtx)
|
||||||
|
{
|
||||||
|
switch (Type)
|
||||||
|
{
|
||||||
|
case ECaveGeneratorType::Maze:
|
||||||
|
if (!Refs.Maze) { return false; }
|
||||||
|
if (Refs.Maze->StrateTopWorldZ - Refs.Maze->StrateBottomWorldZ <= 0.0f) { return false; }
|
||||||
|
OutCtx.StrateTopWorldZ = Refs.Maze->StrateTopWorldZ;
|
||||||
|
OutCtx.StrateBottomWorldZ = Refs.Maze->StrateBottomWorldZ;
|
||||||
|
VoxelDensityOps::BuildMazeStack(OutStack, *Refs.Maze, Seed, SpineRadius, SM);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case ECaveGeneratorType::FlatPlain:
|
||||||
|
case ECaveGeneratorType::CrystalChamber:
|
||||||
|
// UN SEUL cas pour les deux, comme le `switch` de production : `GetSlabDensity` ne les
|
||||||
|
// distingue pas non plus. Voir BuildSlabStack.
|
||||||
|
if (!Refs.Slab) { return false; }
|
||||||
|
if (Refs.Slab->StrateTopWorldZ - Refs.Slab->StrateBottomWorldZ <= 0.0f) { return false; }
|
||||||
|
OutCtx.StrateTopWorldZ = Refs.Slab->StrateTopWorldZ;
|
||||||
|
OutCtx.StrateBottomWorldZ = Refs.Slab->StrateBottomWorldZ;
|
||||||
|
VoxelDensityOps::BuildSlabStack(OutStack, *Refs.Slab, Seed, SpineRadius, SM);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case ECaveGeneratorType::SurfaceWorld:
|
||||||
|
if (!Refs.Surface) { return false; }
|
||||||
|
if (Refs.Surface->StrateTopWorldZ - Refs.Surface->StrateBottomWorldZ <= 0.0f) { return false; }
|
||||||
|
OutCtx.StrateTopWorldZ = Refs.Surface->StrateTopWorldZ;
|
||||||
|
OutCtx.StrateBottomWorldZ = Refs.Surface->StrateBottomWorldZ;
|
||||||
|
VoxelDensityOps::BuildSurfaceStack(
|
||||||
|
OutStack, *Refs.Surface, Seed, SpineRadius, SM,
|
||||||
|
Refs.SurfaceBiomeParams ? *Refs.SurfaceBiomeParams : TArray<FSurfaceGenerationParams>(),
|
||||||
|
MoveTemp(Refs.BiomeField));
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case ECaveGeneratorType::VerticalShafts:
|
||||||
|
if (!Refs.Vert) { return false; }
|
||||||
|
if (Refs.Vert->StrateTopWorldZ - Refs.Vert->StrateBottomWorldZ <= 0.0f) { return false; }
|
||||||
|
OutCtx.StrateTopWorldZ = Refs.Vert->StrateTopWorldZ;
|
||||||
|
OutCtx.StrateBottomWorldZ = Refs.Vert->StrateBottomWorldZ;
|
||||||
|
VoxelDensityOps::BuildVerticalShaftStack(OutStack, *Refs.Vert, Seed, SpineRadius, SM);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case ECaveGeneratorType::FloatingIslands:
|
||||||
|
if (!Refs.Float) { return false; }
|
||||||
|
if (Refs.Float->StrateTopWorldZ - Refs.Float->StrateBottomWorldZ <= 0.0f) { return false; }
|
||||||
|
OutCtx.StrateTopWorldZ = Refs.Float->StrateTopWorldZ;
|
||||||
|
OutCtx.StrateBottomWorldZ = Refs.Float->StrateBottomWorldZ;
|
||||||
|
VoxelDensityOps::BuildFloatingIslandStack(OutStack, *Refs.Float, Seed, SpineRadius, SM);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case ECaveGeneratorType::Underwater:
|
||||||
|
case ECaveGeneratorType::TunnelNetwork:
|
||||||
|
// Underwater EST TunnelNetwork plus un drapeau d'eau consommé côté rendu. Pas de garde
|
||||||
|
// de strate dégénérée : `GetDensityWithParams` n'a pas d'early-out à reproduire.
|
||||||
|
if (!Refs.Tunnel) { return false; }
|
||||||
|
OutCtx.StrateTopWorldZ = Refs.Tunnel->StrateTopWorldZ;
|
||||||
|
OutCtx.StrateBottomWorldZ = Refs.Tunnel->StrateBottomWorldZ;
|
||||||
|
VoxelDensityOps::BuildTunnelNetworkStack(OutStack, *Refs.Tunnel, Seed, SpineRadius, SM);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// `UsesOperatorStackForChunk` ne rend true que pour les archétypes portés (les 8), donc
|
||||||
|
// on ne devrait jamais arriver ici. Si ça arrive : retomber sur le `switch` plutôt que
|
||||||
|
// générer du vide — un monde faux est pire qu'un monde non porté.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void UVoxelGenerator::InitializeSettings(const UVoxelSettings* Settings)
|
void UVoxelGenerator::InitializeSettings(const UVoxelSettings* Settings)
|
||||||
{
|
{
|
||||||
// Seul le seed est copié ici. Tout le reste (params de cave, transitions,
|
// Seul le seed est copié ici. Tout le reste (params de cave, transitions,
|
||||||
@@ -537,124 +655,35 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
|||||||
OpCtx.Seed = (uint32)Seed;
|
OpCtx.Seed = (uint32)Seed;
|
||||||
OpCtx.LayoutVersion = LayoutVersion;
|
OpCtx.LayoutVersion = LayoutVersion;
|
||||||
|
|
||||||
switch (CP_GenType)
|
// ⚠️ LE MAPPING VIT DANS `VF_BuildOpStackForChunk` (haut de ce fichier) ET NULLE
|
||||||
{
|
// PART AILLEURS — `ClassifyTile` appelle la MÊME fabrique. Un verdict de tuile issu
|
||||||
case ECaveGeneratorType::Maze:
|
// d'une pile construite autrement serait un trou. Ici on ne fait que fournir les
|
||||||
// GARDE DE STRATE DÉGÉNÉRÉE : GetMazeDensity court-circuite sur `return 1.0f`
|
// params déjà cherchés juste au-dessus.
|
||||||
// (= air) quand la hauteur est nulle ou négative ; cette garde appartient à la
|
FVoxelStackParamRefs Refs;
|
||||||
// fonction d'archétype et la pile n'en a pas, par conception. Sans ce test, une
|
Refs.Slab = &CP_Slab;
|
||||||
// strate dégénérée donnerait de l'air sur un chemin et de la géométrie sur
|
Refs.Maze = &CP_Maze;
|
||||||
// l'autre. On retombe sur le `switch`, qui EST le comportement de référence.
|
Refs.Vert = &CP_Vert;
|
||||||
// Degenerate-strate guard: the archetype early-outs to air, the stack has no
|
Refs.Float = &CP_Float;
|
||||||
// such early-out by design. Fall back to the switch, which is the reference.
|
Refs.Tunnel = &CP_Tunnel;
|
||||||
if (CP_Maze.StrateTopWorldZ - CP_Maze.StrateBottomWorldZ <= 0.0f)
|
|
||||||
{
|
|
||||||
CP_UseOpStack = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
OpCtx.StrateTopWorldZ = CP_Maze.StrateTopWorldZ;
|
|
||||||
OpCtx.StrateBottomWorldZ = CP_Maze.StrateBottomWorldZ;
|
|
||||||
VoxelDensityOps::BuildMazeStack(CP_OpStack, CP_Maze, Seed,
|
|
||||||
OriginSpineRadius, StrateManager);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case ECaveGeneratorType::FlatPlain:
|
// SurfaceWorld : le champ de biomes est fabriqué ICI, du côté qui connaît le
|
||||||
case ECaveGeneratorType::CrystalChamber:
|
// générateur, et TRANSFÉRÉ à la pile. L'opérateur ne voit qu'une `IVoxelBiomeField`,
|
||||||
// UN SEUL cas pour les deux, comme le `switch` de production juste en dessous :
|
// ce qui lui permet de devenir un asset en Phase 3 sans traîner le générateur.
|
||||||
// `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;
|
|
||||||
|
|
||||||
case ECaveGeneratorType::SurfaceWorld:
|
|
||||||
{
|
|
||||||
if (CP_Surface.StrateTopWorldZ - CP_Surface.StrateBottomWorldZ <= 0.0f)
|
|
||||||
{
|
|
||||||
CP_UseOpStack = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
OpCtx.StrateTopWorldZ = CP_Surface.StrateTopWorldZ;
|
|
||||||
OpCtx.StrateBottomWorldZ = CP_Surface.StrateBottomWorldZ;
|
|
||||||
|
|
||||||
// Le champ de biomes est fabriqué ICI, du côté qui connaît le générateur, et
|
|
||||||
// TRANSFÉRÉ à la pile. L'opérateur ne voit qu'une `IVoxelBiomeField` : c'est ce
|
|
||||||
// qui lui permet de devenir un asset en Phase 3 sans traîner le générateur.
|
|
||||||
// Built here, on the side that knows the generator, and handed to the stack.
|
|
||||||
TUniquePtr<IVoxelBiomeField> Field;
|
|
||||||
TArray<FSurfaceGenerationParams> PerBiome;
|
TArray<FSurfaceGenerationParams> PerBiome;
|
||||||
|
if (CP_GenType == ECaveGeneratorType::SurfaceWorld)
|
||||||
|
{
|
||||||
|
Refs.Surface = &CP_Surface;
|
||||||
if (CP_BiomeCtx.IsValid() && CP_SurfaceBiomeParams.Num() > 0)
|
if (CP_BiomeCtx.IsValid() && CP_SurfaceBiomeParams.Num() > 0)
|
||||||
{
|
{
|
||||||
PerBiome = CP_SurfaceBiomeParams;
|
PerBiome = CP_SurfaceBiomeParams;
|
||||||
Field = MakeUnique<FGeneratorBiomeField>(
|
Refs.SurfaceBiomeParams = &PerBiome;
|
||||||
|
Refs.BiomeField = MakeUnique<FGeneratorBiomeField>(
|
||||||
this, &CP_BiomeCtx, &CP_BiomeCache, ChunkCoord.Z);
|
this, &CP_BiomeCtx, &CP_BiomeCache, ChunkCoord.Z);
|
||||||
}
|
}
|
||||||
|
|
||||||
VoxelDensityOps::BuildSurfaceStack(CP_OpStack, CP_Surface, Seed,
|
|
||||||
OriginSpineRadius, StrateManager,
|
|
||||||
PerBiome, MoveTemp(Field));
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case ECaveGeneratorType::VerticalShafts:
|
CP_UseOpStack = VF_BuildOpStackForChunk(CP_GenType, Refs, Seed, OriginSpineRadius,
|
||||||
if (CP_Vert.StrateTopWorldZ - CP_Vert.StrateBottomWorldZ <= 0.0f)
|
StrateManager, CP_OpStack, OpCtx);
|
||||||
{
|
|
||||||
CP_UseOpStack = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
OpCtx.StrateTopWorldZ = CP_Vert.StrateTopWorldZ;
|
|
||||||
OpCtx.StrateBottomWorldZ = CP_Vert.StrateBottomWorldZ;
|
|
||||||
VoxelDensityOps::BuildVerticalShaftStack(CP_OpStack, CP_Vert, Seed,
|
|
||||||
OriginSpineRadius, StrateManager);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case ECaveGeneratorType::FloatingIslands:
|
|
||||||
// Même garde de strate dégénérée : GetFloatingIslandDensity court-circuite sur
|
|
||||||
// `return 1.0f` (= air) quand la hauteur est nulle ou négative.
|
|
||||||
if (CP_Float.StrateTopWorldZ - CP_Float.StrateBottomWorldZ <= 0.0f)
|
|
||||||
{
|
|
||||||
CP_UseOpStack = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
OpCtx.StrateTopWorldZ = CP_Float.StrateTopWorldZ;
|
|
||||||
OpCtx.StrateBottomWorldZ = CP_Float.StrateBottomWorldZ;
|
|
||||||
VoxelDensityOps::BuildFloatingIslandStack(CP_OpStack, CP_Float, Seed,
|
|
||||||
OriginSpineRadius, StrateManager);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case ECaveGeneratorType::Underwater:
|
|
||||||
case ECaveGeneratorType::TunnelNetwork:
|
|
||||||
// ⚠️ UN SEUL CAS POUR LES DEUX, exactement comme le `switch` de production
|
|
||||||
// vingt lignes plus bas : `GetDensityAt` route déjà `Underwater` vers
|
|
||||||
// `GetDensityWithParams`, et `WaterLevelRelative` n'est lu que par
|
|
||||||
// `UVoxelStrateManager` (côté rendu / requête), JAMAIS par la densité — vérifié
|
|
||||||
// par recherche, pas supposé. Underwater EST TunnelNetwork plus un drapeau d'eau.
|
|
||||||
//
|
|
||||||
// ⚠️ PAS DE GARDE DE STRATE DÉGÉNÉRÉE ICI, et c'est une différence RÉELLE avec
|
|
||||||
// les cinq cas au-dessus : eux la portent parce que leur fonction d'archétype
|
|
||||||
// court-circuite sur `return 1.0f` quand la hauteur est nulle. Lu ligne à ligne :
|
|
||||||
// `GetDensityWithParams` n'a AUCUN early-out de ce genre. Ajouter la garde ici
|
|
||||||
// ferait diverger la pile du `switch` sur les strates dégénérées, dans le sens
|
|
||||||
// exact que la garde était censée empêcher ailleurs.
|
|
||||||
OpCtx.StrateTopWorldZ = CP_Tunnel.StrateTopWorldZ;
|
|
||||||
OpCtx.StrateBottomWorldZ = CP_Tunnel.StrateBottomWorldZ;
|
|
||||||
VoxelDensityOps::BuildTunnelNetworkStack(CP_OpStack, CP_Tunnel, 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`
|
|
||||||
// plutôt que générer du vide — un monde faux est pire qu'un monde non porté.
|
|
||||||
CP_UseOpStack = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Le test appelle PrepareChunk, pas la production : c'est exactement la divergence
|
// Le test appelle PrepareChunk, pas la production : c'est exactement la divergence
|
||||||
// qui rend un opérateur vert en test et faux en jeu. Les sept `PrepareChunk`
|
// qui rend un opérateur vert en test et faux en jeu. Les sept `PrepareChunk`
|
||||||
@@ -2666,8 +2695,17 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
|
|||||||
}
|
}
|
||||||
int32 NumSlots = 0;
|
int32 NumSlots = 0;
|
||||||
|
|
||||||
|
// ── T1.d GÉNÉRIQUE : la pile d'opérateurs classe les archétypes de CAVE ──
|
||||||
|
// Ces trois-là suivent le slot de cave que la tuile touche. Le verdict de la pile porte sur la
|
||||||
|
// BOÎTE ENTIÈRE, pas sur un z, donc il ne peut être calculé qu'après la boucle — et il n'est
|
||||||
|
// valable que si la tuile ne touche QUE ce slot-là (voir la garde `bAnyNonCave`).
|
||||||
|
int32 CaveBotChunkZ = INT32_MAX; // identité du slot de cave (borne basse, en chunks)
|
||||||
|
int32 CaveRepChunkZ = 0;
|
||||||
|
bool bAnyCave = false;
|
||||||
|
bool bAnyNonCave = false; // gap ou SurfaceWorld dans la même tuile ⇒ on abandonne
|
||||||
|
|
||||||
int32 MemoChunkZ = INT32_MAX;
|
int32 MemoChunkZ = INT32_MAX;
|
||||||
int32 MemoCat = -1; // 0 = gap, 1 = surface
|
int32 MemoCat = -1; // 0 = gap, 1 = surface, 2 = cave (pile d'opérateurs)
|
||||||
int32 MemoSlotIdx = -1;
|
int32 MemoSlotIdx = -1;
|
||||||
for (int32 g = -1; g <= GridDim; ++g)
|
for (int32 g = -1; g <= GridDim; ++g)
|
||||||
{
|
{
|
||||||
@@ -2680,6 +2718,7 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
|
|||||||
if (StrateManager->IsGapChunk(CC))
|
if (StrateManager->IsGapChunk(CC))
|
||||||
{
|
{
|
||||||
MemoCat = 0;
|
MemoCat = 0;
|
||||||
|
bAnyNonCave = true;
|
||||||
}
|
}
|
||||||
else if (StrateManager->GetGeneratorTypeForChunk(CC) == ECaveGeneratorType::SurfaceWorld)
|
else if (StrateManager->GetGeneratorTypeForChunk(CC) == ECaveGeneratorType::SurfaceWorld)
|
||||||
{
|
{
|
||||||
@@ -2721,14 +2760,42 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
|
|||||||
if (D.BridgeDensity > 0.0f || D.RidgeDensity > 0.0f) bCanAir = false;
|
if (D.BridgeDensity > 0.0f || D.RidgeDensity > 0.0f) bCanAir = false;
|
||||||
}
|
}
|
||||||
MemoCat = 1;
|
MemoCat = 1;
|
||||||
|
bAnyNonCave = true;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
return EVoxelTileClass::Mixed; // archétype cave / hors layout : pas prouvable en v1
|
// ── ARCHÉTYPE DE CAVE ── Jusqu'ici : `return Mixed`, sans appel. Désormais on tente
|
||||||
|
// le pliage générique de la pile — mais SEULEMENT sous des conditions vérifiables,
|
||||||
|
// parce qu'un faux verdict ici est un trou (pas de géométrie, pas de collision).
|
||||||
|
//
|
||||||
|
// Condition 1 : la strate doit RÉELLEMENT être générée par la pile. Sinon on
|
||||||
|
// classerait un champ que le mesher ne produira pas. C'est le même drapeau, lu au
|
||||||
|
// même endroit, que `GetDensityAt`.
|
||||||
|
if (!StrateManager->UsesOperatorStackForChunk(CC)) { return EVoxelTileClass::Mixed; }
|
||||||
|
|
||||||
|
// Condition 2 : un seul slot de cave par tuile. Deux slots = deux jeux de params =
|
||||||
|
// deux piles, et une pile ne sait répondre que pour SA strate.
|
||||||
|
int32 CaveTopCZ = 0, CaveBotCZ = 0;
|
||||||
|
if (!StrateManager->GetStrateChunkZBounds(ChunkZ, CaveTopCZ, CaveBotCZ))
|
||||||
|
{
|
||||||
|
return EVoxelTileClass::Mixed; // hors layout
|
||||||
|
}
|
||||||
|
if (CaveBotChunkZ != INT32_MAX && CaveBotChunkZ != CaveBotCZ)
|
||||||
|
{
|
||||||
|
return EVoxelTileClass::Mixed;
|
||||||
|
}
|
||||||
|
CaveBotChunkZ = CaveBotCZ;
|
||||||
|
CaveRepChunkZ = ChunkZ;
|
||||||
|
bAnyCave = true;
|
||||||
|
MemoCat = 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (MemoCat == 0)
|
if (MemoCat == 2)
|
||||||
|
{
|
||||||
|
// Rien par z : la pile répond pour la boîte entière, après la boucle.
|
||||||
|
}
|
||||||
|
else if (MemoCat == 0)
|
||||||
{
|
{
|
||||||
bCanAir = false; // bedrock du gap = solide (le carve des passages est déjà gardé)
|
bCanAir = false; // bedrock du gap = solide (le carve des passages est déjà gardé)
|
||||||
}
|
}
|
||||||
@@ -2749,6 +2816,165 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
|
|||||||
if (!bCanSolid && !bCanAir) return EVoxelTileClass::Mixed;
|
if (!bCanSolid && !bCanAir) return EVoxelTileClass::Mixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//=========================================================================
|
||||||
|
// ── LE PLIAGE DE LA PILE D'OPÉRATEURS, POUR LES ARCHÉTYPES DE CAVE ──
|
||||||
|
//=========================================================================
|
||||||
|
// ⚠️ ERREUR ICI = TROU, PAS RÉGRESSION. Un verdict non-Mixed fait SAUTER `GenerateMesh` : pas
|
||||||
|
// de triangles, pas de collision, invisible jusqu'à ce qu'un joueur tombe au travers. Toutes les
|
||||||
|
// gardes ci-dessous ÉCHOUENT EN MIXED ; aucune ne donne le bénéfice du doute.
|
||||||
|
//
|
||||||
|
// Le verdict lui-même ne peut pas être meilleur que la pile : `FVoxelOpStack::ClassifyBox` plie
|
||||||
|
// chaque opérateur avec `VF_FoldOp` et rend `Mixed` dès que les deux hypothèses meurent. Ce que
|
||||||
|
// ce bloc ajoute, c'est la vérification que la pile interrogée est bien CELLE QUI PRODUIRA LA
|
||||||
|
// DENSITÉ de cette tuile — même fabrique, mêmes params, même drapeau.
|
||||||
|
if (bAnyCave)
|
||||||
|
{
|
||||||
|
// Une tuile mi-cave mi-surface (ou mi-gap) n'est pas classable ainsi : la pile de cave ne
|
||||||
|
// répond que pour SA strate, et sa boîte couvrirait des z appartenant à une autre.
|
||||||
|
if (bAnyNonCave) { return EVoxelTileClass::Mixed; }
|
||||||
|
|
||||||
|
const FIntVector RepCC(0, 0, CaveRepChunkZ);
|
||||||
|
const ECaveGeneratorType CaveType = StrateManager->GetGeneratorTypeForChunk(RepCC);
|
||||||
|
|
||||||
|
//---------------------------------------------------------------------
|
||||||
|
// ⚠️ LA GARDE QUI COMPTE : LES PARAMS DOIVENT ÊTRE LES MÊMES SUR TOUTE LA TUILE
|
||||||
|
//---------------------------------------------------------------------
|
||||||
|
// `GetGenerationParams` et ses homologues BLENDENT les params dans les bandes de transition :
|
||||||
|
// `Alpha` dépend du chunk Z pour `Gradient`, et du chunk XY EN PLUS pour `Interleaved`. Deux
|
||||||
|
// chunks d'une même tuile peuvent donc porter des params différents — c'est le constat de
|
||||||
|
// `AUDIT §C2`, confirmé par lecture le 2026-07-28 — et UNE pile ne peut pas représenter DEUX
|
||||||
|
// champs. On construit donc les params pour CHAQUE coordonnée de chunk que la boîte touche et
|
||||||
|
// on exige qu'ils soient identiques bit à bit.
|
||||||
|
//
|
||||||
|
// `Memcmp` sur un POD : un padding différent ne peut produire qu'un FAUX ÉCART, donc un
|
||||||
|
// `Mixed` de trop. On se trompe du côté du CPU, jamais du côté du trou.
|
||||||
|
const int32 CX0 = FloorDivC(MinX, CHUNK_SIZE), CX1 = FloorDivC(MaxX, CHUNK_SIZE);
|
||||||
|
const int32 CY0 = FloorDivC(MinY, CHUNK_SIZE), CY1 = FloorDivC(MaxY, CHUNK_SIZE);
|
||||||
|
const int32 CZ0 = FloorDivC(MinZ, CHUNK_SIZE), CZ1 = FloorDivC(MaxZ, CHUNK_SIZE);
|
||||||
|
|
||||||
|
// Une tuile très étalée (Step élevé) toucherait trop de chunks pour que cette vérification
|
||||||
|
// reste bon marché. Au-delà, `Mixed` — on renonce au gain, jamais à la sûreté.
|
||||||
|
const int64 NumChunkCoords = (int64)(CX1 - CX0 + 1) * (int64)(CY1 - CY0 + 1) * (int64)(CZ1 - CZ0 + 1);
|
||||||
|
if (NumChunkCoords > 27) { return EVoxelTileClass::Mixed; }
|
||||||
|
|
||||||
|
FSlabGenerationParams TileSlab;
|
||||||
|
FMazeGenerationParams TileMaze;
|
||||||
|
FVerticalShaftParams TileVert;
|
||||||
|
FFloatingIslandParams TileFloat;
|
||||||
|
FStrateGenerationParams TileTunnel;
|
||||||
|
bool bFirst = true;
|
||||||
|
|
||||||
|
for (int32 cz = CZ0; cz <= CZ1; ++cz)
|
||||||
|
for (int32 cy = CY0; cy <= CY1; ++cy)
|
||||||
|
for (int32 cx = CX0; cx <= CX1; ++cx)
|
||||||
|
{
|
||||||
|
const FIntVector CC(cx, cy, cz);
|
||||||
|
if (StrateManager->GetGeneratorTypeForChunk(CC) != CaveType)
|
||||||
|
{
|
||||||
|
return EVoxelTileClass::Mixed; // la boîte déborde sur un autre archétype
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le drapeau doit tenir sur TOUS les chunks de la boîte, pas seulement sur celui qui a
|
||||||
|
// déclenché la tentative : un seul chunk hors pile invaliderait le verdict.
|
||||||
|
if (!StrateManager->UsesOperatorStackForChunk(CC)) { return EVoxelTileClass::Mixed; }
|
||||||
|
|
||||||
|
switch (CaveType)
|
||||||
|
{
|
||||||
|
case ECaveGeneratorType::FlatPlain:
|
||||||
|
case ECaveGeneratorType::CrystalChamber:
|
||||||
|
{
|
||||||
|
const FSlabGenerationParams Q = StrateManager->GetSlabParamsForChunk(CC);
|
||||||
|
if (bFirst) { TileSlab = Q; }
|
||||||
|
else if (FMemory::Memcmp(&Q, &TileSlab, sizeof(Q)) != 0) { return EVoxelTileClass::Mixed; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ECaveGeneratorType::Maze:
|
||||||
|
{
|
||||||
|
const FMazeGenerationParams Q = StrateManager->GetMazeParamsForChunk(CC);
|
||||||
|
if (bFirst) { TileMaze = Q; }
|
||||||
|
else if (FMemory::Memcmp(&Q, &TileMaze, sizeof(Q)) != 0) { return EVoxelTileClass::Mixed; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ECaveGeneratorType::VerticalShafts:
|
||||||
|
{
|
||||||
|
const FVerticalShaftParams Q = StrateManager->GetVerticalShaftParamsForChunk(CC);
|
||||||
|
if (bFirst) { TileVert = Q; }
|
||||||
|
else if (FMemory::Memcmp(&Q, &TileVert, sizeof(Q)) != 0) { return EVoxelTileClass::Mixed; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ECaveGeneratorType::FloatingIslands:
|
||||||
|
{
|
||||||
|
const FFloatingIslandParams Q = StrateManager->GetFloatingIslandParamsForChunk(CC);
|
||||||
|
if (bFirst) { TileFloat = Q; }
|
||||||
|
else if (FMemory::Memcmp(&Q, &TileFloat, sizeof(Q)) != 0) { return EVoxelTileClass::Mixed; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ECaveGeneratorType::Underwater:
|
||||||
|
case ECaveGeneratorType::TunnelNetwork:
|
||||||
|
{
|
||||||
|
const FStrateGenerationParams Q = StrateManager->GetGenerationParams(CC);
|
||||||
|
if (bFirst) { TileTunnel = Q; }
|
||||||
|
else if (FMemory::Memcmp(&Q, &TileTunnel, sizeof(Q)) != 0) { return EVoxelTileClass::Mixed; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return EVoxelTileClass::Mixed; // SurfaceWorld ne peut pas arriver ici (bAnyNonCave)
|
||||||
|
}
|
||||||
|
|
||||||
|
bFirst = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
//---------------------------------------------------------------------
|
||||||
|
// La pile — construite par la MÊME fabrique que `GetDensityAt`.
|
||||||
|
//---------------------------------------------------------------------
|
||||||
|
FVoxelStackParamRefs Refs;
|
||||||
|
Refs.Slab = &TileSlab;
|
||||||
|
Refs.Maze = &TileMaze;
|
||||||
|
Refs.Vert = &TileVert;
|
||||||
|
Refs.Float = &TileFloat;
|
||||||
|
Refs.Tunnel = &TileTunnel;
|
||||||
|
// `Refs.Surface` reste nul — la fabrique refuse alors SurfaceWorld, et c'est voulu : cette
|
||||||
|
// fonction le prouve elle-même sur le treillis EXACT du mesher, ce qu'aucune borne de boîte
|
||||||
|
// ne fera mieux.
|
||||||
|
|
||||||
|
FVoxelOpContext OpCtx;
|
||||||
|
OpCtx.ChunkCoord = RepCC;
|
||||||
|
OpCtx.Seed = (uint32)Seed;
|
||||||
|
OpCtx.LayoutVersion = TC_LayoutVersion;
|
||||||
|
OpCtx.Step = Step;
|
||||||
|
|
||||||
|
FVoxelOpStack TileStack;
|
||||||
|
if (!VF_BuildOpStackForChunk(CaveType, Refs, Seed, OriginSpineRadius,
|
||||||
|
StrateManager, TileStack, OpCtx))
|
||||||
|
{
|
||||||
|
// Strate dégénérée ou archétype non porté : `GetDensityAt` retomberait sur le `switch`,
|
||||||
|
// donc la pile ne décrit pas ce que le mesher verra. Aucun verdict.
|
||||||
|
return EVoxelTileClass::Mixed;
|
||||||
|
}
|
||||||
|
TileStack.PrepareChunk(OpCtx);
|
||||||
|
|
||||||
|
const FBox TileBox(FVector((float)MinX, (float)MinY, (float)MinZ),
|
||||||
|
FVector((float)MaxX, (float)MaxY, (float)MaxZ));
|
||||||
|
const EVoxelTileClass StackVerdict = TileStack.ClassifyBox(TileBox, OpCtx);
|
||||||
|
if (StackVerdict == EVoxelTileClass::Mixed) { return EVoxelTileClass::Mixed; }
|
||||||
|
|
||||||
|
if (StackVerdict == EVoxelTileClass::AllSolid) { bCanAir = false; }
|
||||||
|
else { bCanSolid = false; }
|
||||||
|
|
||||||
|
//---------------------------------------------------------------------
|
||||||
|
// ⚠️ LES DISTURBANCES NE SONT PAS DANS LA PILE (`OPSTACK-DECOMPOSITION §10.2`) :
|
||||||
|
// `GetDensityAt` les applique APRÈS, sur la densité déjà négatée. Un verdict qui les
|
||||||
|
// ignorerait serait faux exactement là où elles agissent. Mêmes inégalités que la branche
|
||||||
|
// SurfaceWorld plus haut, pour la même raison.
|
||||||
|
//---------------------------------------------------------------------
|
||||||
|
const FStrateDisturbanceParams D = StrateManager->GetDisturbanceParamsForChunk(RepCC);
|
||||||
|
if (D.ChasmDensity > 0.0f) { bCanSolid = false; }
|
||||||
|
if (D.BridgeDensity > 0.0f || D.RidgeDensity > 0.0f) { bCanAir = false; }
|
||||||
|
|
||||||
|
if (bCanSolid == bCanAir) { return EVoxelTileClass::Mixed; }
|
||||||
|
return bCanSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Balayage des colonnes XY sur le treillis exact du mesher (marge incluse). Une colonne
|
// ── Balayage des colonnes XY sur le treillis exact du mesher (marge incluse). Une colonne
|
||||||
// tranche chaque z intérieur : air côté MC ⇔ TerrainZ ≤ z ≤ CeilSurf (D = −interne ≥ 0,
|
// tranche chaque z intérieur : air côté MC ⇔ TerrainZ ≤ z ≤ CeilSurf (D = −interne ≥ 0,
|
||||||
// cf. SurfaceDensityFromColumn ; spine/passages ne font QUE de l'air → gardés plus haut).
|
// cf. SurfaceDensityFromColumn ; spine/passages ne font QUE de l'air → gardés plus haut).
|
||||||
|
|||||||
Reference in New Issue
Block a user