feat(opstack): EffectOverBox correct in ISOLATION -- the blocker is closed

COMPOSER-NOTES §6.1, titled THE BLOCKER, gated everything after it. A
source writing only Sdf did not touch density, so it answered the box
query ON BEHALF OF the converter that followed it -- fine when a human
wrote and verified the pair, unsafe the moment the composer assembles a
combination nobody checked. And the failure is the silent one: a tile
wrongly proved uniform means no geometry, no collision, no error, and a
player falls through the floor.

FVoxelBoxHypotheses now carries FVoxelBoxSdfInterval. SDF writers publish
their own [Min, Max]; replacement writers replace it, accumulating writers
MinWith it; converters and detail ops consume the prior interval through a
state-aware EffectOverBox. Each op answers only for itself and the fold
composes them. Unknown, invalid, non-finite or unimplemented bounds force
Mixed -- when in doubt the answer is Mixed, because a missed skip costs
milliseconds and a false uniform costs a player.

Bounds are SUPREMA of what Eval can produce, never the param named "max":
FBM 1.5 (not 1.0), max(Min,Max) radius envelopes, warp sqrt(2), SmoothMin
slack. [FLT_MAX, FLT_MAX] is the exact no-surface identity.

⭐ THE PROOF IS THE NOVEL STACK. Built a combination no shipping builder
produces:
    ConstantVoid -> LatticeCorridorSource -> SdfRoughness -> SdfFill
brute-forced 332,750 voxels across 256 boxes: 250 proved AllAir,
0 VIOLATIONS. If isolation is real an unverified stack must still be
sound, and it is. That test -- not the refactor -- is the deliverable.

Every archetype re-proved by brute force, 0 violations everywhere:
  Maze 112/256 · CrystalChamber 38/60 · Underwater 33/40 · FlatPlain 32/60
  VerticalShafts 28/60 · FloatingIslands 15/60 · TunnelNetwork prod 9/40
  TunnelNetwork dense 0/40 (correctly -- it really is all mixed)

COST, reported not hidden: isolation gives up some skipping. TunnelNetwork
production 12->9, VerticalShafts 32->28. Answering for yourself alone is
strictly weaker than answering for a verified pair. The classifier stays
materially useful and the trade buys safe composition, which is the point.

Eval is untouched; equivalence tests bit-identical; no per-voxel box query.
SurfaceWorld keeps its separate exact height/lattice classifier.

Also: ⛔ WITHDREW the "corrective ops must be fused" claim from §3.2.
FFloorBiasMod does NOT undo FCaveRoughnessMod -- it adds an independent
NormZ^2 x FloorBias term to the ACCUMULATED density and never reads
TotalRough. Not a correction, no fuse, no "corrects" dependency. The claim
came from reading a name and a comment; one grep would have shown the ten
Density-writing ops in between before the paragraph was written.

Build clean, 22 succeeded / 0 failed, verified in VoxelM.log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJiTcTSDNkKBtnNdiBtdaU
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-09-04 06:20:40 +02:00
1 parent 7b982ad5ae
commit 8e346a3054
11 files changed
+1193 -890

No files matched your search

@@ -233,7 +233,7 @@ bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters)
// 3. LE VERDICT DE BOÎTE — et la première preuve « AllAir » du plugin
//=========================================================================
{
int32 NumProvedSolid = 0, NumProvedAir = 0, NumMixed = 0, NumUnsound = 0;
int32 NumProvedSolid = 0, NumProvedAir = 0, NumMixed = 0, NumUnsound = 0, NumBruteSamples = 0;
FRandomStream Rng(24680);
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
@@ -268,6 +268,7 @@ bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters)
const float Y = (float)(Origin.Y + gy * Step);
const float Z = (float)(Origin.Z + gz * Step);
const float D = Stack.EvalMC(X, Y, Z);
++NumBruteSamples;
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
{
if (NumUnsound == 0)
@@ -275,7 +276,7 @@ bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters)
AddError(FString::Printf(
TEXT("HOLE: the island stack claimed %s for the box at (%d,%d,%d) but ")
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. Suspects, in ")
TEXT("order: the blob source's Pad (does it cover the WARP amplitude ")
TEXT("order: the blob source's propagated SDF interval (does it cover the WARP amplitude ")
TEXT("AND the roughness AND the fill blend AND the SmoothMin dip?), ")
TEXT("then the Z bound -- note there is NO lower bound, a thin thread ")
TEXT("of matter hangs below each island down the axis, so only the ")
@@ -295,11 +296,11 @@ bool FVoxelForgeOpStackIslandTest::RunTest(const FString& Parameters)
AddInfo(FString::Printf(
TEXT("Box verdicts over 60 FloatingIslands tiles (XY sampled from +/- %d voxels = %.1f x ")
TEXT("IslandSpacing %.0f): %d proved AllSolid, %d proved AllAir, ")
TEXT("%d Mixed. Today's ClassifyTile proves ZERO of these. The AllAir count is the new ")
TEXT("%d Mixed, %d voxels checked, %d violations. Today's ClassifyTile proves ZERO of these. The AllAir count is the new ")
TEXT("thing: no cave archetype has ever been able to prove 'all air', and a floating-")
TEXT("island strate is mostly exactly that (OPSTACK-DECOMPOSITION 7)."),
SpanVoxels, (float)SpanVoxels / FMath::Max(P.IslandSpacing, 1.0f), P.IslandSpacing,
NumProvedSolid, NumProvedAir, NumMixed));
NumProvedSolid, NumProvedAir, NumMixed, NumBruteSamples, NumUnsound));
if (NumProvedAir == 0)
{
@@ -87,6 +87,7 @@ IMPLEMENT_SIMPLE_AUTOMATION_TEST(
namespace
{
constexpr int32 NumMazeSamples = 20000;
constexpr int32 NumMazeBoxTests = 256;
/** Les params Maze de la strate Maze de la fixture, bornes Z de runtime comprises. */
bool ResolveMazeParams(const VoxelForgeTest::FTestWorld& World, FMazeGenerationParams& Out,
@@ -265,14 +266,14 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
// VerticalShafts, FloatingIslands, FlatPlain, CrystalChamber et Underwater ne captent RIEN du
// gain T1.d. Tout nombre > 0 ici est du saut de tuile que Maze n'a jamais eu.
{
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0, NumBruteSamples = 0;
FRandomStream Rng(24680);
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
const int32 SpanCells = 40;
const int32 SpanVoxels = SpanCells * 8; // Extent = Step * Cells = 1 * 8
for (int32 t = 0; t < 60; ++t)
for (int32 t = 0; t < NumMazeBoxTests; ++t)
{
const int32 Step = 1, Cells = 8; // petites tuiles : force brute tenable
const int32 Extent = Step * Cells;
@@ -299,6 +300,7 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
const float Y = (float)(Origin.Y + gy * Step);
const float Z = (float)(Origin.Z + gz * Step);
const float D = Stack.EvalMC(X, Y, Z);
++NumBruteSamples;
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
{
if (NumUnsound == 0)
@@ -307,8 +309,8 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
TEXT("HOLE: the op stack claimed %s for the box at (%d,%d,%d) but ")
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. One of the ops' ")
TEXT("EffectOverBox/ClassifyBox is not conservative. Suspects, in order: ")
TEXT("the lattice source's ExtraReach (does it cover the roughness ")
TEXT("amplitude AND the carve blend?), then the seal's forcing verdict."),
TEXT("the source interval, the SDF roughness interval, the converter's ")
TEXT("threshold fold, then the seal's forcing verdict."),
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
Origin.X, Origin.Y, Origin.Z, X, Y, Z, D,
(D >= 0.0f) ? TEXT("AIR") : TEXT("SOLID")));
@@ -323,12 +325,12 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
NumUnsound, 0);
AddInfo(FString::Printf(
TEXT("Box verdicts over 60 Maze tiles (XY sampled from +/- %d voxels = %.1f x ")
TEXT("CellSize %.0f): %d proved uniform, %d Mixed. Today's ClassifyTile ")
TEXT("Box verdicts over %d Maze tiles (XY sampled from +/- %d voxels = %.1f x ")
TEXT("CellSize %.0f): %d proved uniform, %d Mixed, %d voxels checked, %d violations. Today's ClassifyTile ")
TEXT("proves ZERO of these -- every cave archetype falls through to \"pas prouvable en ")
TEXT("v1\". Any number above zero here is tile-skipping Maze has never had."),
SpanVoxels, (float)SpanVoxels / FMath::Max(MazeParams.CellSize, 1.0f), MazeParams.CellSize,
NumProved, NumMixed));
NumMazeBoxTests, SpanVoxels, (float)SpanVoxels / FMath::Max(MazeParams.CellSize, 1.0f), MazeParams.CellSize,
NumProved, NumMixed, NumBruteSamples, NumUnsound));
if (NumProved == 0)
{
@@ -338,6 +340,86 @@ bool FVoxelForgeOpStackMazeTest::RunTest(const FString& Parameters)
}
}
//==========================================================================
// ISOLATION — a source with a converter it was never authored with
//==========================================================================
// Maze's hand-written stack uses ConstantRock -> Lattice -> SdfRoughness -> SdfCarve.
// This stack deliberately uses a VOID root and SdfFill instead. The only way it may
// claim AllAir is for the propagated lattice/roughness interval to prove that the fill
// converter is inactive over the whole box. A source that still answered for its old
// carve neighbour would not be a valid implementation of this test.
{
FMazeGenerationParams NovelParams = MazeParams;
NovelParams.BranchProbability = 0.12f;
NovelParams.Verticality = 0.08f;
NovelParams.CorridorRadius = 1.0f;
FVoxelOpStack NovelStack;
NovelStack.Add(VoxelDensityOps::MakeConstantVoidSource(NovelParams.BaseDensity));
NovelStack.Add(VoxelDensityOps::MakeLatticeCorridorSource(NovelParams, World.Settings->Seed));
NovelStack.Add(VoxelDensityOps::MakeSdfRoughnessMod(
0.35f, 0.12f, 3, NovelParams.CorridorRadius + 2.0f));
NovelStack.Add(VoxelDensityOps::MakeSdfFill(1.25f, NovelParams.BaseDensity));
NovelStack.PrepareChunk(Ctx);
FString ChannelError;
TestTrue(TEXT("the unverified source/converter stack has a valid channel order"),
NovelStack.ValidateChannelOrder(&ChannelError));
if (!ChannelError.IsEmpty()) { AddError(ChannelError); }
constexpr int32 NumNovelBoxTests = 256;
const int32 NovelSpanCells = 160;
const int32 NovelSpanVoxels = NovelSpanCells * 8;
int32 NumNovelSolid = 0, NumNovelAir = 0, NumNovelMixed = 0;
int32 NumNovelVoxels = 0, NumNovelViolations = 0;
FRandomStream NovelRng(86420);
for (int32 t = 0; t < NumNovelBoxTests; ++t)
{
const int32 Step = 1, Cells = 8;
const int32 Extent = Step * Cells;
const FIntVector Origin(
NovelRng.RandRange(-NovelSpanCells, NovelSpanCells) * Extent,
NovelRng.RandRange(-NovelSpanCells, NovelSpanCells) * Extent,
FMath::Clamp(NovelRng.RandRange(BottomVoxelZ / Extent, TopVoxelZ / Extent), -4096, 4096) * Extent);
const int32 GridDim = Cells + 1;
const FBox Box(
FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step),
FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step));
const EVoxelTileClass Verdict = NovelStack.ClassifyBox(Box, Ctx);
if (Verdict == EVoxelTileClass::Mixed)
{
++NumNovelMixed;
continue;
}
const bool bClaimsSolid = (Verdict == EVoxelTileClass::AllSolid);
if (bClaimsSolid) { ++NumNovelSolid; } else { ++NumNovelAir; }
// No sampled subset: every lattice point in every proved box is checked.
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 = NovelStack.EvalMC(X, Y, Z);
++NumNovelVoxels;
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f)) { ++NumNovelViolations; }
}
}
TestEqual(TEXT("the unverified source/converter stack has zero box-verdict violations"),
NumNovelViolations, 0);
AddInfo(FString::Printf(
TEXT("Novel lattice+roughness+FILL stack: %d boxes proved (%d AllSolid, %d AllAir), " )
TEXT("%d Mixed; %d voxels checked, %d violations; XY sampled from +/- %d voxels."),
NumNovelSolid + NumNovelAir, NumNovelSolid, NumNovelAir, NumNovelMixed,
NumNovelVoxels, NumNovelViolations, NovelSpanVoxels));
}
return true;
}
@@ -252,7 +252,7 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
// 3. LE VERDICT DE BOÎTE
//=========================================================================
{
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0, NumBruteSamples = 0;
FRandomStream Rng(13579);
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
@@ -292,6 +292,7 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
const float Y = (float)(Origin.Y + gy * Step);
const float Z = (float)(Origin.Z + gz * Step);
const float D = Stack.EvalMC(X, Y, Z);
++NumBruteSamples;
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
{
if (NumUnsound == 0)
@@ -299,8 +300,8 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
AddError(FString::Printf(
TEXT("HOLE: the shaft stack claimed %s for the box at (%d,%d,%d) but ")
TEXT("EvalMC(%.0f, %.0f, %.0f) = %.6g is on the %s side. Suspects, in ")
TEXT("order: the shaft source's ExtraReach (does it cover the roughness ")
TEXT("amplitude AND the carve blend?), then the connector sweep (a ")
TEXT("order: the shaft source's SDF interval (does it cover all shaft ")
TEXT("and connector geometry?), then the connector sweep (a ")
TEXT("connector can reach Spacing*1.6 beyond its cell), then the ledge ")
TEXT("op's FillOnly."),
bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
@@ -317,22 +318,22 @@ bool FVoxelForgeOpStackShaftTest::RunTest(const FString& Parameters)
AddInfo(FString::Printf(
TEXT("Box verdicts over 60 VerticalShafts tiles (XY sampled from +/-%d voxels = %.1f x ")
TEXT("ShaftSpacing %.0f): %d proved uniform, %d Mixed, brute-forced with %d violations. ")
TEXT("ShaftSpacing %.0f): %d proved uniform, %d Mixed, %d voxels checked, %d violations. ")
TEXT("This was 0 proved for as long as the connector branch bailed on mere shaft ")
TEXT("EXISTENCE within Spacing*1.6 -- true almost everywhere at ShaftDensity 0.6, so it ")
TEXT("was conservative AND sterile. It now tests the real connector capsules. Read the ")
TEXT("proved count as a measurement; what is ASSERTED is that none of them is wrong, ")
TEXT("because a false verdict here leaves no geometry and no collision."),
SpanVoxels, (float)SpanVoxels / FMath::Max(P.ShaftSpacing, 1.0f), P.ShaftSpacing,
NumProved, NumMixed, NumUnsound));
NumProved, NumMixed, NumBruteSamples, NumUnsound));
if (NumProved == 0)
{
AddWarning(TEXT("No VerticalShafts tile was proved, so the brute force above verified ")
TEXT("nothing. Before hypothesising: the shaft CIRCLE test and the connector ")
TEXT("CAPSULE test are the only two things that can return CarveOnly here, ")
TEXT("and ExtraReach inflates both -- check its value against ShaftMaxRadius ")
TEXT("before touching either test."));
TEXT("and the interval must cover both the shaft and connector capsules before ")
TEXT("either test is relaxed."));
}
}
@@ -278,7 +278,7 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
// 3. LE VERDICT DE BOÎTE — ce que §3.1 a acheté
//=====================================================================
{
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0;
int32 NumProved = 0, NumMixed = 0, NumUnsound = 0, NumBruteSamples = 0;
FRandomStream Rng(24680 + SlotIndex);
// Hors de la boucle : la ligne de rapport en a besoin. Une étendue d'échantillonnage qu'on
// ne peut pas citer dans le rapport est une étendue que personne ne surveille.
@@ -312,6 +312,7 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
const float Y = (float)(Origin.Y + gy * Step);
const float Z = (float)(Origin.Z + gz * Step);
const float D = Stack.EvalMC(X, Y, Z);
++NumBruteSamples;
if (bClaimsSolid ? (D >= 0.0f) : (D < 0.0f))
{
if (NumUnsound == 0)
@@ -320,7 +321,7 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
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("noise amplitude bounds (the proved FBM supremum is 1.5, not 1.0), the ")
TEXT("ceiling clamp raising CeilSurface above CeilZ, then the column ")
TEXT("mod's reach (MaxRadius + blend)."),
SlotName, bClaimsSolid ? TEXT("AllSolid") : TEXT("AllAir"),
@@ -340,12 +341,12 @@ bool FVoxelForgeOpStackSlabTest::RunTest(const FString& Parameters)
AddInfo(FString::Printf(
TEXT("%s box verdicts over %d tiles (XY sampled from +/- %d voxels = %.1f x ")
TEXT("ColumnSpacing %.0f): %d proved uniform, %d Mixed. Today's ")
TEXT("ColumnSpacing %.0f): %d proved uniform, %d Mixed, %d voxels checked, %d violations. 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, SpanVoxels,
(float)SpanVoxels / FMath::Max(SlabParams.ColumnSpacing, 1.0f), SlabParams.ColumnSpacing,
NumProved, NumMixed));
NumProved, NumMixed, NumBruteSamples, NumUnsound));
if (NumProved == 0)
{
@@ -1086,8 +1086,8 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
// 4. LE VERDICT DE BOÎTE — plus attendu nul, et CHAQUE VERDICT EST BRUTE-FORCÉ
//=========================================================================
// ⚠️ CE BLOC A CHANGÉ DE NATURE LE 2026-07-28, ET IL FAUT SAVOIR POURQUOI.
// Il ASSERTAIT `NumProved == 0`. C'était juste tant que `FRoomGraphSource::EffectOverBox`
// rendait `Both` inconditionnellement : « zéro » était alors une description honnête de l'état
// Il ASSERTAIT `NumProved == 0`. C'était juste tant que la source de salles répondait
// `Both` inconditionnellement : « zéro » était alors une description honnête de l'état
// du portage. Depuis que la source répond SPATIALEMENT, asserter zéro reviendrait à interdire
// le gain qu'on vient de construire — et pire, ça transformerait le test en gardien du bug.
//
@@ -1239,7 +1239,7 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
AddInfo(FString::Printf(
TEXT("[%s] Box verdicts over 40 TunnelNetwork tiles: %d proved (%d AllSolid, %d AllAir), ")
TEXT("%d Mixed -- brute-forced over %d voxels, %d violations. This number was 0 proved / ")
TEXT("40 Mixed until FRoomGraphSource::EffectOverBox learned to answer spatially, and it ")
TEXT("40 Mixed until the SDF source interval and state-aware fold learned to answer spatially, and it ")
TEXT("is the single largest perf item of the whole plan (OPSTACK-DECOMPOSITION 0.2): a ")
TEXT("proved tile skips GenerateMesh entirely, so it trades one BuildChunkCache against ")
TEXT("30000+ density evaluations. Read the PROVED count as a measurement, never as a ")
@@ -1408,6 +1408,11 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
UWCtx.StrateBottomWorldZ = UP.StrateBottomWorldZ;
UWStack.PrepareChunk(UWCtx);
// Underwater shares the tunnel builder, but it is a separate production archetype. Keep
// its box proof in the report so a future change cannot silently make only this slot
// unsound or sterile.
RunTileScan(UWStack, UP, UWCtx, TEXT("Underwater"), /*bZeroProvedIsExpected*/ false);
TestEqual(TEXT("the Underwater stack is the tunnel stack: same 20 ops"), UWStack.Num(), 20);
// 8 → 24 chunks, comme le balayage principal. Le premier run vert a rapporté **0 de 2000
File diff suppressed because it is too large. Load diff
+142 -25
View File
@@ -39,22 +39,35 @@
// the descent structure intact no matter what an author assembles.
//
// ─────────────────────────────────────────────────────────────────────────────────────────
// LA CLÉ DE VOÛTE : EffectOverBox — direction, pas intervalle / THE KEYSTONE: direction, not intervals
// LA CLÉ DE VOÛTE : EffectOverBox + intervalle SDF / THE KEYSTONE: EffectOverBox + SDF interval
// ─────────────────────────────────────────────────────────────────────────────────────────
// La version « complète » d'une borne rendrait un intervalle numérique. NE PAS COMMENCER LÀ.
// Presque tout opérateur existant est UNIDIRECTIONNEL : il ne fait que creuser, ou que remplir.
// Cela suffit à reproduire GÉNÉRIQUEMENT chaque garde écrite à la main dans ClassifyTile :
// Un verdict de boîte doit être une preuve. Rendre `Both` coûte du CPU ; rendre `Identity` ou une
// direction fausse supprime de la géométrie et peut laisser un joueur tomber au travers du monde.
// En cas de doute, `Mixed`.
//
// Un opérateur qui écrit seulement le canal SDF ne peut pas honnêtement répondre pour le convertisseur
// qui viendra peut-être après lui : il doit propager un intervalle SDF, et le convertisseur doit plier
// cet intervalle avec sa propre formule. Chaque opérateur répond ainsi pour lui-même, et la composition
// reste sûre même pour une combinaison que personne n'a écrite à la main auparavant.
//
// A box verdict is a proof. `Both` costs CPU; a false `Identity` or direction removes geometry and can
// let a player fall through the world. When in doubt, return `Mixed`.
// An SDF-only writer reports an SDF interval; a later converter folds that interval through its own
// response. Each operator answers for itself, so an unverified composition remains sound.
//
// La première version de cette interface n'avait qu'une direction numérique. Presque tout opérateur
// existant est UNIDIRECTIONNEL : il ne fait que creuser, ou que remplir. Cela suffit à reproduire
// GÉNÉRIQUEMENT chaque garde écrite à la main dans ClassifyTile :
//
// « passages ⇒ bCanSolid = false » EST CarveOnly
// « ponts/arêtes ⇒ bCanAir = false » EST FillOnly
// « aucun passage près de cette boîte » EST Identity
//
// Donc la Phase 1 n'a besoin d'AUCUNE borne numérique et obtient déjà toute la propriété de
// sûreté. Les intervalles sont un resserrement ultérieur pour le coût de génération, pas un
// prérequis de correction. C'est ce qui rend le premier pas petit.
// La direction reste le repli conservateur par défaut pour les opérateurs qui ne connaissent pas
// d'intervalle ; elle ne permet jamais à un écrivain SDF d'usurper la réponse d'un voisin.
//
// Phase 1 needs NO numeric bounds and already gets the whole safety property. Intervals are a
// later tightening for gen cost, not a correctness prerequisite.
// Direction remains the conservative fallback for operators without an interval implementation;
// it never lets an SDF writer impersonate a downstream neighbour.
//
// ─────────────────────────────────────────────────────────────────────────────────────────
// LE GROS LOT PERF : les strates de grotte ne sautent AUCUNE tuile aujourd'hui
@@ -283,6 +296,64 @@ struct FVoxelOpSample
float Sdf = FLT_MAX;
};
/**
* Intervalle conservateur du canal SDF sur une boîte.
*
* `Min` et `Max` sont les bornes SUPRÊMES de toutes les valeurs que l'opérateur peut produire dans
* la boîte. `[FLT_MAX, FLT_MAX]` est l'identité exacte : aucune surface connue. Un intervalle inconnu
* est volontairement large ; il force le fold à rester `Mixed` plutôt que de risquer un faux tile
* uniforme. Les setters refusent toute borne non finie ou inversée : un opérateur qui ne peut pas
* prouver son intervalle perd le skip, jamais la géométrie.
*
* Conservative SDF interval over a box. `Min`/`Max` bound every value the operator can produce.
* `[FLT_MAX, FLT_MAX]` is the exact "no surface" identity. Unknown is deliberately wide: it costs
* a skip, never a hole. Invalid bounds become unknown instead of being guessed.
*/
struct FVoxelBoxSdfInterval
{
bool bKnown = true;
float Min = FLT_MAX;
float Max = FLT_MAX;
bool IsKnown() const { return bKnown; }
void SetUnknown()
{
bKnown = false;
Min = -FLT_MAX;
Max = FLT_MAX;
}
void Set(float InMin, float InMax)
{
if (!FMath::IsFinite(InMin) || !FMath::IsFinite(InMax) || InMin > InMax)
{
SetUnknown();
return;
}
bKnown = true;
Min = InMin;
Max = InMax;
}
/** Output of `min(Input, Other)` when both intervals are known. */
void MinWith(const FVoxelBoxSdfInterval& Other)
{
if (!bKnown || !Other.bKnown)
{
SetUnknown();
return;
}
Min = FMath::Min(Min, Other.Min);
Max = FMath::Min(Max, Other.Max);
}
};
// Forward declaration: the interval-aware EffectOverBox overloads consume the hypotheses that
// have already been folded, while the hypotheses themselves are declared immediately below the
// interface.
struct FVoxelBoxHypotheses;
//=============================================================================
// L'INTERFACE / THE INTERFACE
//=============================================================================
@@ -344,8 +415,9 @@ public:
virtual void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const = 0;
/**
* CONSERVATIF. Phase 1 : direction seule. Phase 3 : surcharge avec intervalle numérique.
* Rendre Both est toujours sûr ; rendre le mauvais est un trou.
* CONSERVATIF. The two-argument method is the intrinsic fallback. The state-aware overload
* below is what the stack calls: it sees the SDF interval already produced by earlier ops.
* Returning Both is always safe; returning the wrong direction is a hole.
*
* Le contrat est « conservatif », pas « forme close » : un opérateur A LE DROIT
* D'ÉCHANTILLONNER pour répondre. C'est exactement ce que fait ClassifyTile aujourd'hui pour
@@ -354,20 +426,20 @@ public:
*
* The contract is "conservative", not "closed-form": an op MAY sample to answer.
*
* ⚠️ SIMPLIFICATION DE PHASE 1, à connaître : une source qui n'écrit QUE le canal SDF ne touche
* pas la densité par elle-même — c'est l'opérateur de conversion (`FSdfCarve`/`FSdfFill`) qui le
* fait. Répondre honnêtement demanderait de propager un INTERVALLE de SDF à travers la requête
* de boîte, exactement comme `Eval` propage une valeur de SDF. En attendant, **la source répond
* pour la paire** (elle rend `CarveOnly`/`FillOnly` quand une primitive atteint la boîte,
* `Identity` sinon) et la conversion rend `Identity`. Conservatif et correct ; à remplacer par
* une requête de boîte à deux canaux quand les intervalles numériques arriveront (Phase 3).
*
* PHASE 1 SIMPLIFICATION: an SDF-only source answers for itself AND its conversion op; the
* conversion returns Identity. Answering honestly needs an SDF INTERVAL threaded through the box
* query, mirroring how Eval threads an SDF value. Conservative and correct meanwhile.
* An SDF-only source MUST NOT answer for a converter that happens to follow it. It reports its
* own interval through `PropagateSdfOverBox`; the converter's state-aware overload then folds
* the interval through its own formula. This is the isolation rule that makes free composition
* safe.
*/
virtual EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const = 0;
/** State-aware effect. The default preserves old custom operators' conservative direction. */
virtual EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx,
const FVoxelBoxHypotheses&) const
{
return EffectOverBox(VoxelBox, Ctx);
}
/**
* ⚠️ LE PLIAGE QUI PORTE DES NOMBRES — `OPSTACK-DECOMPOSITION §0.2`, et le plus gros poste de
* perf du plan.
@@ -406,11 +478,38 @@ public:
return FLT_MAX;
}
/** State-aware amplitude. The default is the old, conservative amplitude. */
virtual float MaxCarveOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx,
const FVoxelBoxHypotheses&) const
{
return MaxCarveOverBox(VoxelBox, Ctx);
}
virtual float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const
{
return FLT_MAX;
}
/** State-aware amplitude. The default is the old, conservative amplitude. */
virtual float MaxFillOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx,
const FVoxelBoxHypotheses&) const
{
return MaxFillOverBox(VoxelBox, Ctx);
}
/**
* Propagate this operator's own SDF result through a box query. Only call this for an operator
* whose cached channel declaration writes `Sdf`. The default is unknown by construction: a new
* SDF writer cannot accidentally make a false uniform verdict merely because it forgot this
* method. Implementations that assign SDF replace the interval; min/accumulating writers use
* `FVoxelBoxSdfInterval::MinWith`.
*/
virtual void PropagateSdfOverBox(FVoxelBoxSdfInterval& InOut, const FBox& VoxelBox,
const FVoxelOpContext&) const
{
InOut.SetUnknown();
}
/**
* Pour un opérateur FORÇANT (celui dont `ClassifyBox` rend autre chose que `Mixed`) : de combien
* la densité est-elle garantie du bon côté de zéro, PARTOUT dans la boîte ?
@@ -537,6 +636,9 @@ struct FVoxelBoxHypotheses
float SolidMargin = 0.0f;
float AirMargin = 0.0f;
/** SDF interval after all SDF writers folded so far. It starts at the exact no-surface identity. */
FVoxelBoxSdfInterval Sdf;
bool IsDead() const { return !bCanBeAllSolid && !bCanBeAllAir; }
/** Verdict final : exactement une hypothèse doit survivre. Égalité = prudence ⇒ Mixed. */
@@ -626,14 +728,29 @@ FORCEINLINE void VF_FoldEffect(FVoxelBoxHypotheses& H, EVoxelOpEffect Effect,
* pure FillOnly would have thrown away, while still letting a passage take it back.
*/
FORCEINLINE void VF_FoldOp(FVoxelBoxHypotheses& H, const IVoxelDensityOp& Op,
const FBox& VoxelBox, const FVoxelOpContext& Ctx)
const FBox& VoxelBox, const FVoxelOpContext& Ctx,
bool bWritesSdf)
{
// Soundness bias: an unknown SDF interval is allowed to kill a skip, never to manufacture a
// uniform tile. The interval is propagated only after this op's own effect has been folded, so
// the next op sees exactly the SDF version that Eval would expose at this point in the stack.
const EVoxelTileClass Forced = Op.ClassifyBox(VoxelBox, Ctx);
if (Forced != EVoxelTileClass::Mixed)
{
VF_ForceHypotheses(H, Forced, Op.ForcedMarginOverBox(VoxelBox, Ctx));
if (bWritesSdf) { Op.PropagateSdfOverBox(H.Sdf, VoxelBox, Ctx); }
return;
}
VF_FoldEffect(H, Op.EffectOverBox(VoxelBox, Ctx),
Op.MaxCarveOverBox(VoxelBox, Ctx), Op.MaxFillOverBox(VoxelBox, Ctx));
VF_FoldEffect(H, Op.EffectOverBox(VoxelBox, Ctx, H),
Op.MaxCarveOverBox(VoxelBox, Ctx, H),
Op.MaxFillOverBox(VoxelBox, Ctx, H));
if (bWritesSdf) { Op.PropagateSdfOverBox(H.Sdf, VoxelBox, Ctx); }
}
/** Backward-compatible helper for unit tests and callers with no cached declaration metadata. */
FORCEINLINE void VF_FoldOp(FVoxelBoxHypotheses& H, const IVoxelDensityOp& Op,
const FBox& VoxelBox, const FVoxelOpContext& Ctx)
{
VF_FoldOp(H, Op, VoxelBox, Ctx,
(Op.ChannelWrites() & VoxelOpChannels::Sdf) != 0);
}
+13 -12
View File
@@ -154,7 +154,8 @@ public:
FVoxelBoxHypotheses H;
for (const FOpEntry& Entry : Ops)
{
VF_FoldOp(H, *Entry.Op, VoxelBox, Ctx);
VF_FoldOp(H, *Entry.Op, VoxelBox, Ctx,
(Entry.Writes & VoxelOpChannels::Sdf) != 0);
// Do not early-out on a dead hypothesis: a later forcing structural post may
// deliberately overwrite it (the XY edge seal is appended after passage carving).
}
@@ -189,7 +190,8 @@ public:
const bool bSolidBefore = H.bCanBeAllSolid;
const bool bAirBefore = H.bCanBeAllAir;
VF_FoldOp(H, *Ops[i].Op, VoxelBox, Ctx);
VF_FoldOp(H, *Ops[i].Op, VoxelBox, Ctx,
(Ops[i].Writes & VoxelOpChannels::Sdf) != 0);
if (bSolidBefore && !H.bCanBeAllSolid && OutSolidKiller == INDEX_NONE) { OutSolidKiller = i; }
if (bAirBefore && !H.bCanBeAllAir && OutAirKiller == INDEX_NONE) { OutAirKiller = i; }
@@ -270,14 +272,11 @@ namespace VoxelDensityOps
* Écrit le canal SDF uniquement. Identité d'arête = hash(nœud inférieur, axe), donc deux
* chunks adjacents NE PEUVENT PAS être en désaccord : pas de cache de chunk, pas de région
* COLLECT, zéro risque de couture (AUDIT §6.4 — le motif à préférer). */
/* `ExtraReach` = tout ce qui peut eLARGIR la portée du couloir en aval (amplitude de rugosité
* rayon de blend du carve). La source répond pour la paire source+conversion dans
* `EffectOverBox` (voir la note « SIMPLIFICATION DE PHASE 1 » dans VoxelDensityOp.h), donc elle
* doit connaître cette marge, sinon sa réponse `Identity` serait un MENSONGE — c'est-à-dire un
* trou. / The source answers for the source+conversion pair, so it must know the downstream
* margin: an Identity that is wrong is a hole. */
/** The source answers only for its own SDF field. Its box query publishes an SDF interval;
* each later converter or modifier consumes that interval through the generic fold. It does
* not know, and must not answer for, any downstream operator. */
VOXELFORGE_API TUniquePtr<IVoxelDensityOp> MakeLatticeCorridorSource(const FMazeGenerationParams& P,
int32 Seed, float ExtraReach);
int32 Seed);
/** Rôle 3 — rugosité de paroi appliquée au canal SDF (variante Maze/Shafts/Islands).
* `Frequency` est codée en dur au site d'appel aujourd'hui (0.12 pour Maze) ; l'exposer est
@@ -302,7 +301,8 @@ namespace VoxelDensityOps
/** 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. */
* bornées par le supremum prouvé de FBM (1.5, not the nominal parameter label). 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 ⇒
@@ -380,8 +380,9 @@ namespace VoxelDensityOps
const UVoxelStrateManager* StrateManager);
/**
* DIAGNOSTIC — la ventilation par CLASSE DE PRIMITIVE du dernier `FRoomGraphSource::EffectOverBox`
* évalué sur ce thread. **Tests uniquement. N'entre dans aucune décision de génération.**
* DIAGNOSTIC — la ventilation par CLASSE DE PRIMITIVE de la dernière propagation d'intervalle
* de `FRoomGraphSource` évaluée sur ce thread. **Tests uniquement. N'entre dans aucune décision
* de génération.**
*
* ⚠️ POURQUOI ÇA EXISTE PLUTÔT QUE D'ÊTRE REFAIT DANS LE TEST. Le test a déjà tout ce qu'il faut
* pour rejouer le critère — il appelle `BuildChunkCache` ailleurs. Le rejouer serait une