feat(opstack): FRoomGraphSource::EffectOverBox answers spatially -- and AUDIT C2 is fixed
The chain no longer dies at the room source. The criterion is the per-voxel cull
lifted from point to box, which can fail for exactly one reason: Eval starts at
MinSDF = FLT_MAX and only lowers it through a primitive that survives its own
cull, so if no cached primitive survives its cull anywhere in the box, Sdf stays
FLT_MAX across the whole box.
FSdfConvertOp already returned Identity ("la source a repondu pour la paire") and
the twelve detail modifiers already inherited through VF_NoCaveOverBox. One
function learned to answer and fourteen operators became provable -- what the C1
wiring was built for.
Three deliberate choices, all erring toward CPU rather than toward a hole:
- |Perlin3D| <= 2, derived from GradDot + the convex hull of a trilinear lerp,
instead of the header's observed "~[-1,1]". A verdict resting on an observation
is the hole this file spends its life avoiding.
- the op pool is passed to BuildChunkCache, not nullptr: the bake reads OpParams
to place pits and chimneys, so nullptr would under-bound the cache and could
return Identity over a real pit.
- the search box is wider than Eval's, giving a superset of primitives.
The verdict is memoised per box (all twelve modifiers ask the same question), and
the cache is a SECOND per-worker cache so classification cannot disturb a live
generation's hot cache.
AUDIT C2, confirmed 2026-07-28, is fixed on the switch path in the same breath:
GetDensityWithParams now takes required ParamsFingerprint + LayoutVersion. The
alternative this audit section used to recommend -- add chunk Z to the key -- is
insufficient (Interleaved makes Alpha depend on chunk XY too) and destructive
(chunk XY is deliberately absent so gradient probes don't thrash the box, ARCH
8.10). The CRC is taken once per chunk where the params memo already lives, so
the per-voxel cost is two integer compares. The three test call sites pass it
too, so the oracle stops sharing the defect it tests.
Check 4 of the tunnel test no longer asserts "0 proved" -- that assertion would
now forbid the gain. It brute-forces every proved tile voxel by voxel instead and
reports the count, because a false verdict leaves no geometry and no collision
behind it.
The second debt (per-room ops can raise a modifier's amplitude above the strate
params a box bound reads) turned out to be DORMANT, not live: where the source
proves Identity the modifiers' gate never opens, and where it answers Both it
supplies no MaxCarveOverBox so nothing is provable anyway. It goes live the day
the source gains one. Written at the site.
Unbuilt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -70,6 +70,23 @@ namespace
|
||||
constexpr int32 PointsPerChunk = 250;
|
||||
constexpr int32 NumTunnelSamples = NumTunnelChunks * PointsPerChunk;
|
||||
|
||||
/**
|
||||
* L'empreinte de params que `GetDensityWithParams` exige depuis le correctif d'`AUDIT §C2`.
|
||||
*
|
||||
* ⚠️ CE N'EST PAS DU REMPLISSAGE D'ARGUMENT. Avant ce correctif, l'original clé son cache SDF
|
||||
* sans les params, et le contrôle 3 plus bas explique en détail pourquoi il fallait alors
|
||||
* comparer chaque pile à ELLE-MÊME plutôt qu'à l'original : l'oracle partageait le défaut
|
||||
* testé. En passant la même empreinte que la production, l'oracle ne le partage plus.
|
||||
*
|
||||
* `LayoutVersion = 0` partout dans ce test : le monde de test ne rebâtit jamais son layout en
|
||||
* cours de route, donc la version est constante — ce qui compte ici, c'est que l'empreinte
|
||||
* DIFFÈRE entre deux jeux de params, et c'est exactement ce que la CRC donne.
|
||||
*/
|
||||
FORCEINLINE uint32 VF_FP(const FStrateGenerationParams& InP)
|
||||
{
|
||||
return FCrc::MemCrc32(&InP, sizeof(InP));
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// ⚠️ `DisableStageBModifiers` A DISPARU, ET SA DISPARITION EST LE RÉSULTAT DE L'ÉTAPE B
|
||||
//=========================================================================
|
||||
@@ -503,7 +520,7 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
||||
{
|
||||
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
|
||||
|
||||
const float Old = Gen->GetDensityWithParams(X, Y, Z, P);
|
||||
const float Old = Gen->GetDensityWithParams(X, Y, Z, P, VF_FP(P), 0);
|
||||
const float New = Stack.EvalMC(X, Y, Z);
|
||||
FullVals[i] = New;
|
||||
|
||||
@@ -650,7 +667,8 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
||||
for (int32 i = 0; i < RoughSweepPoints; ++i)
|
||||
{
|
||||
const float X = (float)Points[i].X, Y = (float)Points[i].Y, Z = (float)Points[i].Z;
|
||||
if (!BitEqual(Gen->GetDensityWithParams(X, Y, Z, PV), VStack.EvalMC(X, Y, Z)))
|
||||
if (!BitEqual(Gen->GetDensityWithParams(X, Y, Z, PV, VF_FP(PV), 0),
|
||||
VStack.EvalMC(X, Y, Z)))
|
||||
{
|
||||
++VDiff;
|
||||
}
|
||||
@@ -819,22 +837,28 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
||||
// empreinte CRC des params. Ce bloc le vérifie en ALTERNANT A, B, A, B au même point, le motif
|
||||
// qui fait mentir une clé incomplète.
|
||||
//
|
||||
// ⚠️⚠️ ON NE COMPARE **PAS** À L'ORIGINAL ICI, ET C'EST LE POINT LE PLUS IMPORTANT DE CE TEST.
|
||||
// `GetDensityWithParams` clé son cache sur (boîte XY, strate, seed) — **sans les params**. En
|
||||
// alternance il rendrait donc, pour B, les salles de A : l'original ÉCHOUERAIT ce contrôle. Le
|
||||
// comparer à lui ici ne mesurerait pas mon opérateur, ça mesurerait son bug. On compare donc
|
||||
// chaque pile à ELLE-MÊME évaluée seule — un oracle qui ne partage pas le défaut testé.
|
||||
// ⚠️⚠️ HISTORIQUE, ET LE DÉNOUEMENT EST DANS LE PARAGRAPHE SUIVANT — À LIRE EN ENTIER.
|
||||
// Ce bloc a été écrit quand `GetDensityWithParams` clé son cache sur (boîte XY, strate, seed),
|
||||
// **sans les params** : en alternance il rendait, pour B, les salles de A, donc l'original
|
||||
// ÉCHOUAIT ce contrôle. Le comparer à lui ici n'aurait pas mesuré l'opérateur, ça aurait mesuré
|
||||
// son bug — d'où le choix de comparer chaque pile à ELLE-MÊME évaluée seule.
|
||||
//
|
||||
// ⚠️ ET CE N'EST PEUT-ÊTRE PAS QU'UN ARTEFACT DE TEST — à vérifier, pas à croire. En production
|
||||
// `GetGenerationParams` MÉLANGE les params entre strates voisines (transitions Gradient), donc
|
||||
// deux chunks de Z différents dans la même strate peuvent avoir des params différents, avec la
|
||||
// même boîte XY, le même index de strate et le même seed ⇒ aucune reconstruction. Si c'est
|
||||
// exact, un worker qui descend une bande de transition sert les salles du chunk précédent.
|
||||
// Noté dans `AUDIT §C2` comme SUSPECTÉ, avec le test qui le confirmerait — pas comme prouvé.
|
||||
// ✅ **CE N'ÉTAIT PAS QU'UN ARTEFACT DE TEST, ET C'EST MAINTENANT CORRIGÉ** (2026-07-28). Le
|
||||
// soupçon écrit ici s'est confirmé : `GetGenerationParams` blende les params À L'INTÉRIEUR
|
||||
// d'une strate (`Alpha` = f(chunk Z) en `Gradient`, le défaut), donc deux chunks de Z différents
|
||||
// partageaient boîte XY, index de strate et seed ⇒ aucune reconstruction ⇒ le deuxième chunk
|
||||
// évalué contre les salles du premier. Et comme l'ordre des workers décide lequel est « le
|
||||
// premier », **deux pairs divergeaient depuis la même seed**, ce que §2.6.1 interdit.
|
||||
// `GetDensityWithParams` prend désormais une empreinte de params et une `LayoutVersion`
|
||||
// OBLIGATOIRES (calculées une fois par chunk côté production, `VF_FP` ici).
|
||||
//
|
||||
// We compare each stack to ITSELF evaluated alone, not to the original: the original keys its
|
||||
// SDF cache without the params and would fail this check, so comparing against it would measure
|
||||
// its bug rather than this operator.
|
||||
// ⚠️ ON GARDE POURTANT L'ORACLE « CHAQUE PILE CONTRE ELLE-MÊME », et ce n'est pas de la
|
||||
// paresse : il teste la clé de la PILE, qui est une clé distincte de celle de l'original. Les
|
||||
// faire dépendre l'une de l'autre remettrait exactement le couplage qu'on vient de défaire.
|
||||
//
|
||||
// The suspicion recorded here was CONFIRMED and is now fixed: the params fingerprint and layout
|
||||
// version are required arguments. The self-comparison oracle stays, because it tests the STACK's
|
||||
// key, which is a different key from the original's.
|
||||
{
|
||||
FStrateGenerationParams P2 = P;
|
||||
P2.RoomSpacing = P.RoomSpacing * 0.6f; // une autre disposition de salles
|
||||
@@ -1059,10 +1083,30 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
// 4. LE VERDICT DE BOÎTE — attendu NUL, et c'est le point
|
||||
// 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
|
||||
// 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.
|
||||
//
|
||||
// Ce qui le remplace n'est PAS « on enlève l'assertion » : c'est l'assertion qui compte
|
||||
// vraiment, la SOUNDNESS. Un verdict faux ne se voit pas — pas de géométrie, **pas de
|
||||
// collision** — jusqu'à ce qu'un joueur traverse le sol. Donc chaque tuile déclarée prouvée est
|
||||
// ré-évaluée voxel par voxel, et le test échoue si UN seul échantillon contredit le verdict.
|
||||
// Le nombre de tuiles prouvées, lui, est REPORTÉ, pas asserté : c'est une mesure, pas un
|
||||
// contrat (la leçon « coverage is a number, not a boolean »).
|
||||
//
|
||||
// Was: assert zero proved. That was honest while the source answered Both unconditionally; it
|
||||
// would now forbid the very gain this change makes. What replaces it is the assertion that
|
||||
// actually matters — every proved tile is brute-forced voxel by voxel, because a false verdict
|
||||
// means no geometry and NO COLLISION until a player falls through it.
|
||||
{
|
||||
int32 NumProved = 0, NumMixed = 0;
|
||||
int32 NumProved = 0, NumMixed = 0, NumSolid = 0, NumAir = 0;
|
||||
int32 NumBruteSamples = 0, NumViolations = 0;
|
||||
float WorstViolation = 0.0f;
|
||||
|
||||
FRandomStream Rng(97531);
|
||||
for (int32 t = 0; t < 40; ++t)
|
||||
{
|
||||
@@ -1077,21 +1121,56 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
||||
FVector(Origin.X - Step, Origin.Y - Step, Origin.Z - Step),
|
||||
FVector(Origin.X + GridDim * Step, Origin.Y + GridDim * Step, Origin.Z + GridDim * Step));
|
||||
|
||||
if (Stack.ClassifyBox(Box, Ctx) == EVoxelTileClass::Mixed) { ++NumMixed; }
|
||||
else { ++NumProved; }
|
||||
const EVoxelTileClass Verdict = Stack.ClassifyBox(Box, Ctx);
|
||||
if (Verdict == EVoxelTileClass::Mixed) { ++NumMixed; continue; }
|
||||
|
||||
++NumProved;
|
||||
const bool bClaimSolid = (Verdict == EVoxelTileClass::AllSolid);
|
||||
if (bClaimSolid) { ++NumSolid; } else { ++NumAir; }
|
||||
|
||||
// BRUTE FORCE — la boîte entière, pas un échantillonnage. `EvalMC` rend la convention
|
||||
// du mesher (négatif = solide), donc « tout solide » veut dire qu'aucun échantillon
|
||||
// n'est du côté air. On teste le SIGNE, c'est-à-dire l'existence d'une traversée
|
||||
// d'isosurface : c'est exactement la propriété sur laquelle le mesher est sauté.
|
||||
for (float Z = (float)Box.Min.Z; Z <= (float)Box.Max.Z; Z += 1.0f)
|
||||
for (float Y = (float)Box.Min.Y; Y <= (float)Box.Max.Y; Y += 1.0f)
|
||||
for (float X = (float)Box.Min.X; X <= (float)Box.Max.X; X += 1.0f)
|
||||
{
|
||||
const float D = Stack.EvalMC(X, Y, Z);
|
||||
++NumBruteSamples;
|
||||
const bool bViolates = bClaimSolid ? (D > 0.0f) : (D < 0.0f);
|
||||
if (bViolates)
|
||||
{
|
||||
++NumViolations;
|
||||
WorstViolation = FMath::Max(WorstViolation, FMath::Abs(D));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AddInfo(FString::Printf(
|
||||
TEXT("Box verdicts over 40 TunnelNetwork tiles: %d proved, %d Mixed. %d proved is the ")
|
||||
TEXT("EXPECTED result at stage A and not a defect: the room source answers Both (its ")
|
||||
TEXT("bounds live in the SDF cache, which it would have to build for the queried box), ")
|
||||
TEXT("and the worm source answers CarveOnly EVERYWHERE because a fielded noise carve ")
|
||||
TEXT("has no spatial bound at all. Recovering these needs the numeric amplitude cap in ")
|
||||
TEXT("OPSTACK-DECOMPOSITION 0.2 -- the largest single perf item in the whole plan, and ")
|
||||
TEXT("the reason this archetype currently skips zero tiles."),
|
||||
NumProved, NumMixed, NumProved));
|
||||
TEXT("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("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 ")
|
||||
TEXT("contract -- what is asserted below is that none of them is WRONG, because a false ")
|
||||
TEXT("verdict leaves no geometry and no collision behind it."),
|
||||
NumProved, NumSolid, NumAir, NumMixed, NumBruteSamples, NumViolations));
|
||||
|
||||
TestEqual(TEXT("stage A emits no unsound verdict (it emits none at all)"), NumProved, 0);
|
||||
if (NumProved == 0)
|
||||
{
|
||||
AddWarning(TEXT("No TunnelNetwork tile was proved. That is not a failure, but it means ")
|
||||
TEXT("this check verified nothing: the brute force below has no verdict to ")
|
||||
TEXT("contradict. Either the sampled tiles all genuinely straddle cave, or ")
|
||||
TEXT("the spatial EffectOverBox is not reaching its Identity branch -- the ")
|
||||
TEXT("bake-coverage line of check 5b is the one that tells those apart."));
|
||||
}
|
||||
|
||||
TestEqual(FString::Printf(
|
||||
TEXT("every proved TunnelNetwork tile survives brute force (worst |density| ")
|
||||
TEXT("on the wrong side: %.9g)"), WorstViolation),
|
||||
NumViolations, 0);
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
@@ -1172,7 +1251,7 @@ bool FVoxelForgeOpStackTunnelTest::RunTest(const FString& Parameters)
|
||||
for (int32 i = 0; i < UWSamples; ++i)
|
||||
{
|
||||
const float X = (float)UWPoints[i].X, Y = (float)UWPoints[i].Y, Z = (float)UWPoints[i].Z;
|
||||
const float Old = Gen->GetDensityWithParams(X, Y, Z, UP);
|
||||
const float Old = Gen->GetDensityWithParams(X, Y, Z, UP, VF_FP(UP), 0);
|
||||
const float New = UWStack.EvalMC(X, Y, Z);
|
||||
if (Z > UWInnerBot && Z < UWInnerTop && Old >= 0.0f) { ++UWInCave; }
|
||||
if (!BitEqual(Old, New))
|
||||
|
||||
@@ -2124,25 +2124,273 @@ namespace
|
||||
InOut.Sdf = CaveSDF;
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ `Both` POUR L'INSTANT, ET C'EST UNE DETTE ASSUMÉE, PAS UN OUBLI.
|
||||
*
|
||||
* Les bornes existent pourtant : `FCachedRoom` / `FCachedTunnel` portent déjà leurs
|
||||
* `Bound*` (c'est ce dont `§2` dit qu'il rend le bedrock profond prouvable, « le plus gros
|
||||
* poste de perf de tout le plan »). Ce qui manque, c'est que répondre honnêtement demande de
|
||||
* consulter le cache — donc de le CONSTRUIRE pour la boîte interrogée, sur le thread qui
|
||||
* interroge, ce qui n'est raisonnable qu'une fois `ClassifyBox` réellement branché dans
|
||||
* `ClassifyTile` (il ne l'est toujours pas). Rendre `Both` coûte du CPU et ne peut pas faire
|
||||
* de trou ; rendre le mauvais en ferait un.
|
||||
*
|
||||
* Conservative placeholder: the room/tunnel bounds needed for a real answer are already in
|
||||
* the cache, but answering means building that cache for the queried box, which only pays
|
||||
* once ClassifyTile actually consumes ClassifyBox. Both is always safe.
|
||||
*/
|
||||
EVoxelOpEffect EffectOverBox(const FBox&, const FVoxelOpContext&) const override
|
||||
//---------------------------------------------------------------------
|
||||
// L'ÉTAT PAR BOÎTE — SÉPARÉ DE `FState`, ET DÉLIBÉRÉMENT
|
||||
//---------------------------------------------------------------------
|
||||
// `EffectOverBox` construit un cache pour la boîte INTERROGÉE, qui n'est pas la boîte de
|
||||
// recherche que `Eval` construit pour le voxel courant. Les faire partager `FState::Cache`
|
||||
// serait *correct* — la discipline d'invariance de fenêtre de §8.4 garantit qu'un cache bâti
|
||||
// sur une boîte PLUS LARGE donne le même SDF par voxel — mais ça rendrait `ClassifyTile`
|
||||
// capable de perturber le cache chaud d'une génération en cours, et un jour quelqu'un
|
||||
// paierait cette élégance très cher. Un deuxième cache par worker coûte une allocation
|
||||
// amortie ; on la paie.
|
||||
//
|
||||
// Le VERDICT est mémoïsé, et ce n'est pas du confort : `VF_NoCaveOverBox` fait poser la
|
||||
// question par les DOUZE modificateurs de détail pour la même boîte. Sans mémo, une tuile
|
||||
// coûterait treize `BuildChunkCache` au lieu d'un.
|
||||
//
|
||||
// Second per-worker cache, on purpose: sharing FState::Cache would be sound but would let
|
||||
// tile classification disturb a live generation's hot cache. The verdict is memoised because
|
||||
// all twelve detail modifiers ask the same question about the same box.
|
||||
struct FBoxState
|
||||
{
|
||||
return (P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f) ? EVoxelOpEffect::Both
|
||||
: EVoxelOpEffect::Identity;
|
||||
FChunkSDFCache Cache;
|
||||
FBox KeyBox = FBox(ForceInit);
|
||||
int32 KeyStrate = INT32_MIN;
|
||||
uint32 KeySeed = 0;
|
||||
uint32 KeyFingerprint = 0xFFFFFFFFu;
|
||||
uint32 KeyLayout = 0xFFFFFFFFu;
|
||||
bool bValid = false;
|
||||
EVoxelOpEffect Verdict = EVoxelOpEffect::Both;
|
||||
};
|
||||
|
||||
static FBoxState& BoxState()
|
||||
{
|
||||
thread_local FBoxState S;
|
||||
return S;
|
||||
}
|
||||
|
||||
/**
|
||||
* BORNE **PROUVABLE** DE `|Perlin3D|`, ET ELLE N'EST PAS 1.0.
|
||||
*
|
||||
* L'en-tête de `VoxelNoise::Perlin3D` annonce « ~[-1,1] (typiquement [-0.7,0.7]) ». Le `~`
|
||||
* est un aveu : c'est une observation, pas un théorème, et un verdict de boîte fondé sur une
|
||||
* observation est exactement le genre de trou que ce fichier passe son temps à éviter.
|
||||
*
|
||||
* Ce qui EST démontrable, en lisant `GradDot` : il rend `ru + rv` où `ru` et `rv` sont des
|
||||
* composantes de l'offset fractionnaire, donc chacune dans `[-1, 1]` ⇒ `|GradDot| ≤ 2`. La
|
||||
* valeur finale est une interpolation trilinéaire de huit `GradDot`, et une interpolation
|
||||
* convexe ne sort jamais de l'enveloppe de ses entrées ⇒ `|Perlin3D| ≤ 2`. (La vraie borne
|
||||
* de Perlin 3D est `√3/2 ≈ 0.87` ; on ne s'appuie pas dessus, elle dépend du jeu de
|
||||
* gradients.) Se tromper ici coûte une boîte de recherche un peu plus large, jamais un
|
||||
* verdict faux : plus large ⇒ SUR-ensemble de primitives ⇒ `Identity` plus rare.
|
||||
*
|
||||
* Provable bound rather than the header's observed one: GradDot returns ru+rv with both in
|
||||
* [-1,1], and a trilinear lerp stays inside the hull of its inputs. Erring high costs CPU.
|
||||
*/
|
||||
static constexpr float PerlinAbsBound = 2.0f;
|
||||
|
||||
/**
|
||||
* ✅ LA RÉPONSE SPATIALE. La dette annoncée ici pendant tout le portage est payée.
|
||||
*
|
||||
* Ce que ça débloque, en un mot : `FSdfConvertOp` renvoie déjà `Identity` (« la source a
|
||||
* répondu pour la paire ») et les douze modificateurs de détail héritent de ce verdict par
|
||||
* `VF_NoCaveOverBox`. Le jour où cette fonction rend `Identity` pour une boîte, **quatorze
|
||||
* opérateurs deviennent l'identité d'un coup** et la tuile est prouvable — c'est pour ça que
|
||||
* le câblage a été posé à UN endroit et pas treize.
|
||||
*
|
||||
* LE CRITÈRE, ET POURQUOI IL NE PEUT ÊTRE FAUX QUE DANS UN SENS.
|
||||
* `Eval` part de `MinSDF = FLT_MAX` et ne l'abaisse que via une primitive qui SURVIT à son
|
||||
* cull par voxel (sphère 3D pour les salles et les tunnels, bornes Z + cercle XY pour les
|
||||
* pits et les cheminées). Donc : si AUCUNE primitive du cache ne peut survivre à son cull en
|
||||
* un point quelconque de la boîte, `Sdf` reste `FLT_MAX` sur TOUTE la boîte, la source est
|
||||
* l'identité, et tout ce qui en dépend l'est aussi. On teste exactement ça — la même
|
||||
* inégalité que le cull par voxel, élevée du point à la boîte. Une seule raison d'échouer.
|
||||
*
|
||||
* LES TROIS CHOSES QUI RENDENT LE TEST CONSERVATIF DU BON CÔTÉ :
|
||||
* 1. le warp déplace la coordonnée de REQUÊTE, donc la boîte est dilatée de sa borne
|
||||
* prouvable avant d'être confrontée aux salles et aux tunnels ;
|
||||
* 2. les pits et les cheminées sont interrogés en coordonnées RÉELLES (voir `Eval`), donc
|
||||
* ils sont confrontés à la boîte NON dilatée — la dilater serait juste plus prudent, ne
|
||||
* pas la dilater pour eux serait faux ;
|
||||
* 3. la boîte de recherche du cache est PLUS LARGE que celle de `Eval`, ce qui donne un
|
||||
* SUR-ensemble de primitives : si rien n'atteint la boîte ici, rien ne l'atteint là-bas.
|
||||
*
|
||||
* The criterion is the per-voxel cull lifted from point to box: if no cached primitive can
|
||||
* survive its own cull anywhere in the box, Sdf stays FLT_MAX across the whole box and the
|
||||
* source — with the converter and all twelve modifiers behind it — is the identity.
|
||||
*/
|
||||
EVoxelOpEffect EffectOverBox(const FBox& VoxelBox, const FVoxelOpContext& Ctx) const override
|
||||
{
|
||||
if (!(P.RoomDensity > 0.0f && P.RoomSpacing > 0.0f)) { return EVoxelOpEffect::Identity; }
|
||||
|
||||
//-----------------------------------------------------------------
|
||||
// 1. LA BOÎTE DOIT TENIR DANS UNE SEULE STRATE, PARAMS COMPRIS
|
||||
//-----------------------------------------------------------------
|
||||
// ⚠️ C'est la moitié « boîte » de la garde d'AUDIT §C2. `Eval` résout l'index de strate
|
||||
// et le pool d'ops PAR CHUNK ; une boîte qui traverse une frontière verrait donc deux
|
||||
// graphes de salles différents, et un cache unique n'en représenterait aucun. On ne
|
||||
// devine pas lequel : on rend `Both`. Ça arrive au plus sur les tuiles de bord.
|
||||
const int32 CZ0 = FMath::FloorToInt((float)VoxelBox.Min.Z / (float)CHUNK_SIZE);
|
||||
const int32 CZ1 = FMath::FloorToInt((float)VoxelBox.Max.Z / (float)CHUNK_SIZE);
|
||||
const int32 CX0 = FMath::FloorToInt((float)VoxelBox.Min.X / (float)CHUNK_SIZE);
|
||||
const int32 CX1 = FMath::FloorToInt((float)VoxelBox.Max.X / (float)CHUNK_SIZE);
|
||||
const int32 CY0 = FMath::FloorToInt((float)VoxelBox.Min.Y / (float)CHUNK_SIZE);
|
||||
const int32 CY1 = FMath::FloorToInt((float)VoxelBox.Max.Y / (float)CHUNK_SIZE);
|
||||
|
||||
// Une boîte qui couvre des dizaines de chunks n'est de toute façon jamais prouvable ;
|
||||
// la borne évite qu'un appelant futur transforme ce test en boucle coûteuse.
|
||||
if ((int64)(CX1 - CX0 + 1) * (CY1 - CY0 + 1) * (CZ1 - CZ0 + 1) > 64)
|
||||
{
|
||||
return EVoxelOpEffect::Both;
|
||||
}
|
||||
|
||||
int32 StrateIdx = 0;
|
||||
const TArray<FStrateTerrainOpEntry>* TerrainOps = nullptr;
|
||||
if (Manager)
|
||||
{
|
||||
StrateIdx = Manager->GetStrateIndex(((float)CZ0 + 0.5f) * CHUNK_SIZE * VOXEL_SIZE);
|
||||
for (int32 CZ = CZ0 + 1; CZ <= CZ1; ++CZ)
|
||||
{
|
||||
if (Manager->GetStrateIndex(((float)CZ + 0.5f) * CHUNK_SIZE * VOXEL_SIZE) != StrateIdx)
|
||||
{
|
||||
return EVoxelOpEffect::Both;
|
||||
}
|
||||
}
|
||||
|
||||
// ⚠️ LE POOL D'OPS FAIT PARTIE DE LA GÉOMÉTRIE, contrairement à ce qu'on croit en
|
||||
// lisant `FCachedRoom` : `BuildChunkCache` s'en sert pour cuire les PITS et les
|
||||
// CHEMINÉES (`OpParams` y lit `PitDensity`, `PitMinRadius`…). Passer `nullptr`
|
||||
// « puisque la forme des salles n'en dépend pas » sous-bornerait le cache et
|
||||
// pourrait rendre `Identity` au-dessus d'un pit réel. Un trou, exactement.
|
||||
UVoxelStrateDefinition* Def0 = Manager->GetStrateForChunk(FIntVector(CX0, CY0, CZ0));
|
||||
for (int32 CZ = CZ0; CZ <= CZ1; ++CZ)
|
||||
for (int32 CY = CY0; CY <= CY1; ++CY)
|
||||
for (int32 CX = CX0; CX <= CX1; ++CX)
|
||||
{
|
||||
if (Manager->GetStrateForChunk(FIntVector(CX, CY, CZ)) != Def0)
|
||||
{
|
||||
return EVoxelOpEffect::Both;
|
||||
}
|
||||
}
|
||||
if (Def0) { TerrainOps = &Def0->TerrainOperations; }
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------
|
||||
// 2. LE MÉMO — clé complète (§C2 : jamais de clé sans params ni LayoutVersion)
|
||||
//-----------------------------------------------------------------
|
||||
// `Ctx.LayoutVersion` plutôt que le membre rempli par `PrepareChunk` : rien ne garantit
|
||||
// qu'un appelant de `ClassifyBox` ait ouvert un chunk, et une version périmée dans une
|
||||
// clé de cache est précisément la régression du 2026-07-27.
|
||||
const uint32 LV = Ctx.LayoutVersion;
|
||||
|
||||
FBoxState& B = BoxState();
|
||||
if (B.bValid && B.KeyBox == VoxelBox && B.KeyStrate == StrateIdx
|
||||
&& B.KeySeed == SeedU && B.KeyFingerprint == ParamsFingerprint && B.KeyLayout == LV)
|
||||
{
|
||||
return B.Verdict;
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------
|
||||
// 3. LE CACHE POUR LA BOÎTE INTERROGÉE
|
||||
//-----------------------------------------------------------------
|
||||
const float Warp = (P.CaveWarpStrength > 0.0f)
|
||||
? P.CaveWarpStrength * VOXEL_NOISE_SCALE * PerlinAbsBound
|
||||
: 0.0f;
|
||||
|
||||
// `+ 2` : la même marge de gradient que la boîte de recherche de `Eval`.
|
||||
VoxelCaveMorphology::BuildChunkCache(
|
||||
B.Cache,
|
||||
(float)VoxelBox.Min.X - Warp - 2.0f, (float)VoxelBox.Min.Y - Warp - 2.0f,
|
||||
(float)VoxelBox.Max.X + Warp + 2.0f, (float)VoxelBox.Max.Y + Warp + 2.0f,
|
||||
P, SeedU, StrateIdx, TerrainOps);
|
||||
|
||||
//-----------------------------------------------------------------
|
||||
// 4. LE CULL PAR VOXEL, ÉLEVÉ DU POINT À LA BOÎTE
|
||||
//-----------------------------------------------------------------
|
||||
// Espace de REQUÊTE des salles et des tunnels : XY dilaté du warp, Z passé par `EffZ`
|
||||
// (monotone croissante tant que `VerticalScale > 0`, donc min et max se conservent)
|
||||
// puis dilaté du warp lui aussi — `Eval` warpe bien les trois axes.
|
||||
const FVector QMin((float)VoxelBox.Min.X - Warp,
|
||||
(float)VoxelBox.Min.Y - Warp,
|
||||
EffZ((float)VoxelBox.Min.Z) - Warp);
|
||||
const FVector QMax((float)VoxelBox.Max.X + Warp,
|
||||
(float)VoxelBox.Max.Y + Warp,
|
||||
EffZ((float)VoxelBox.Max.Z) + Warp);
|
||||
|
||||
auto SphereHitsBox = [](const FVector& C, float RSq, const FVector& Mn, const FVector& Mx)
|
||||
{
|
||||
const float dx = FMath::Max3((float)(Mn.X - C.X), 0.0f, (float)(C.X - Mx.X));
|
||||
const float dy = FMath::Max3((float)(Mn.Y - C.Y), 0.0f, (float)(C.Y - Mx.Y));
|
||||
const float dz = FMath::Max3((float)(Mn.Z - C.Z), 0.0f, (float)(C.Z - Mx.Z));
|
||||
return (dx * dx + dy * dy + dz * dz) <= RSq;
|
||||
};
|
||||
|
||||
// Pits, cheminées et colonnes : coordonnées RÉELLES, donc boîte NON dilatée.
|
||||
const float RMinX = (float)VoxelBox.Min.X, RMaxX = (float)VoxelBox.Max.X;
|
||||
const float RMinY = (float)VoxelBox.Min.Y, RMaxY = (float)VoxelBox.Max.Y;
|
||||
const float RMinZ = (float)VoxelBox.Min.Z, RMaxZ = (float)VoxelBox.Max.Z;
|
||||
|
||||
auto CircleHitsBoxXY = [&](float CX, float CY, float RSq)
|
||||
{
|
||||
const float dx = FMath::Max3(RMinX - CX, 0.0f, CX - RMaxX);
|
||||
const float dy = FMath::Max3(RMinY - CY, 0.0f, CY - RMaxY);
|
||||
return (dx * dx + dy * dy) <= RSq;
|
||||
};
|
||||
|
||||
bool bReached = false;
|
||||
|
||||
for (const FCachedRoom& R : B.Cache.Rooms)
|
||||
{
|
||||
if (SphereHitsBox(R.Center, R.CullRadiusSq, QMin, QMax)) { bReached = true; break; }
|
||||
}
|
||||
if (!bReached)
|
||||
{
|
||||
for (const FCachedTunnel& T : B.Cache.Tunnels)
|
||||
{
|
||||
if (SphereHitsBox(T.BoundCenter, T.BoundRadiusSq, QMin, QMax)) { bReached = true; break; }
|
||||
}
|
||||
}
|
||||
if (!bReached)
|
||||
{
|
||||
// Miroir exact des deux `continue` de `Eval` : actif si `Z < TopZ + BlendK` ET
|
||||
// `Z >= TopZ - Depth - BlendK`.
|
||||
for (const FCachedPit& Pit : B.Cache.Pits)
|
||||
{
|
||||
if (!(RMinZ < Pit.TopZ + Pit.BlendK)) { continue; }
|
||||
if (!(RMaxZ >= Pit.TopZ - Pit.Depth - Pit.BlendK)) { continue; }
|
||||
if (CircleHitsBoxXY(Pit.CenterX, Pit.CenterY, Pit.BoundXYRadiusSq))
|
||||
{
|
||||
bReached = true; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!bReached)
|
||||
{
|
||||
// Miroir exact : actif si `Z > BottomZ - BlendK` ET `Z <= BottomZ + Height + BlendK`.
|
||||
for (const FCachedChimney& Ch : B.Cache.Chimneys)
|
||||
{
|
||||
if (!(RMaxZ > Ch.BottomZ - Ch.BlendK)) { continue; }
|
||||
if (!(RMinZ <= Ch.BottomZ + Ch.Height + Ch.BlendK)) { continue; }
|
||||
if (CircleHitsBoxXY(Ch.CenterX, Ch.CenterY, Ch.BoundXYRadiusSq))
|
||||
{
|
||||
bReached = true; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!bReached)
|
||||
{
|
||||
// Les colonnes ne sont pas lues par CETTE source (c'est `FRoomColumnMod`, STEP 4d,
|
||||
// qui parcourt `GetCache()`), mais elles héritent de ce verdict. Elles n'ont aucune
|
||||
// borne en Z dans le cache : on les traite donc comme des cylindres infinis, ce qui
|
||||
// est le test le plus prudent qu'on puisse écrire à partir de ce qui est stocké.
|
||||
for (const FCachedColumn& Col : B.Cache.Columns)
|
||||
{
|
||||
if (CircleHitsBoxXY(Col.CenterX, Col.CenterY, Col.BoundXYRadiusSq))
|
||||
{
|
||||
bReached = true; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
B.Verdict = bReached ? EVoxelOpEffect::Both : EVoxelOpEffect::Identity;
|
||||
B.KeyBox = VoxelBox;
|
||||
B.KeyStrate = StrateIdx;
|
||||
B.KeySeed = SeedU;
|
||||
B.KeyFingerprint = ParamsFingerprint;
|
||||
B.KeyLayout = LV;
|
||||
B.bValid = true;
|
||||
return B.Verdict;
|
||||
}
|
||||
|
||||
private:
|
||||
|
||||
@@ -582,6 +582,10 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
||||
thread_local FIntVector CP_Chunk(INT32_MAX, INT32_MAX, INT32_MAX);
|
||||
thread_local ECaveGeneratorType CP_GenType = ECaveGeneratorType::TunnelNetwork;
|
||||
thread_local FStrateGenerationParams CP_Tunnel;
|
||||
// AUDIT §C2 — empreinte de `CP_Tunnel`, rafraîchie avec lui. Elle voyage jusqu'à la clé du
|
||||
// cache SDF de `GetDensityWithParams` pour qu'un chunk ne puisse plus être évalué contre
|
||||
// les salles d'un chunk voisin aux params blendés différemment.
|
||||
thread_local uint32 CP_TunnelFP = 0xFFFFFFFFu;
|
||||
thread_local FSlabGenerationParams CP_Slab;
|
||||
thread_local FMazeGenerationParams CP_Maze;
|
||||
thread_local FSurfaceGenerationParams CP_Surface;
|
||||
@@ -639,7 +643,14 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
||||
case ECaveGeneratorType::FloatingIslands:
|
||||
CP_Float = StrateManager->GetFloatingIslandParamsForChunk(ChunkCoord); break;
|
||||
default: // TunnelNetwork / Underwater
|
||||
CP_Tunnel = StrateManager->GetGenerationParams(ChunkCoord); break;
|
||||
CP_Tunnel = StrateManager->GetGenerationParams(ChunkCoord);
|
||||
// AUDIT §C2 — l'empreinte est calculée ICI, une fois par chunk, au seul endroit où
|
||||
// les params changent. `FStrateGenerationParams` est du POD pur (aucun TArray /
|
||||
// FString / pointeur), donc une CRC mémoire ne peut pas donner de FAUX POSITIF ; au
|
||||
// pire un octet de padding donne un faux MANQUE, c'est-à-dire une reconstruction de
|
||||
// cache. On se trompe du côté du CPU, jamais du côté d'une salle fausse.
|
||||
CP_TunnelFP = FCrc::MemCrc32(&CP_Tunnel, sizeof(CP_Tunnel));
|
||||
break;
|
||||
}
|
||||
CP_Dist = StrateManager->GetDisturbanceParamsForChunk(ChunkCoord);
|
||||
|
||||
@@ -754,7 +765,8 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
||||
case ECaveGeneratorType::TunnelNetwork:
|
||||
default:
|
||||
// Underwater shares tunnel rock (water table is a render-side overlay).
|
||||
Result = GetDensityWithParams(WorldX, WorldY, WorldZ, CP_Tunnel); break;
|
||||
Result = GetDensityWithParams(WorldX, WorldY, WorldZ, CP_Tunnel,
|
||||
CP_TunnelFP, LayoutVersion); break;
|
||||
}
|
||||
|
||||
// Disturbance layer (the "wow" post-process) — cached params, MC convention.
|
||||
@@ -764,8 +776,13 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
||||
{
|
||||
// ── FALLBACK (no strate manager) ──
|
||||
// Use default TunnelNetwork params — produces generic caves.
|
||||
FStrateGenerationParams FallbackParams;
|
||||
Result = GetDensityWithParams(WorldX, WorldY, WorldZ, FallbackParams);
|
||||
// `static` : ces params sont constants (construction par défaut), donc leur empreinte l'est
|
||||
// aussi. La calculer une fois évite un CRC par voxel sur un chemin qui n'en a aucun besoin.
|
||||
// `LayoutVersion = 0` : sans `StrateManager` il n'y a pas de layout, donc rien qui puisse
|
||||
// périmer — et l'empreinte constante suffit à distinguer ce cache de tous les autres.
|
||||
static const FStrateGenerationParams FallbackParams;
|
||||
static const uint32 FallbackFP = FCrc::MemCrc32(&FallbackParams, sizeof(FallbackParams));
|
||||
Result = GetDensityWithParams(WorldX, WorldY, WorldZ, FallbackParams, FallbackFP, 0);
|
||||
}
|
||||
|
||||
//=========================================================================
|
||||
@@ -813,7 +830,8 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co
|
||||
}
|
||||
|
||||
float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float WorldZ,
|
||||
const FStrateGenerationParams& Params) const
|
||||
const FStrateGenerationParams& Params,
|
||||
uint32 ParamsFingerprint, uint32 LayoutVersion) const
|
||||
{
|
||||
//=========================================================================
|
||||
// STRATE DENSITY FUNCTION (Morphology Pipeline)
|
||||
@@ -930,6 +948,20 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo
|
||||
thread_local float CachedSMinY = 0.0f, CachedSMaxY = 0.0f;
|
||||
thread_local int32 CachedStrate = INT32_MIN;
|
||||
thread_local uint32 CachedSeed = 0;
|
||||
// ⚠️ AUDIT §C2 (corrigé le 2026-07-28). Les deux lignes qui manquaient à cette clé.
|
||||
// La clé ci-dessus décrit la GÉOMÉTRIE de la fenêtre (boîte, strate, seed) et rien de ce qui
|
||||
// détermine les PARAMS avec lesquels les salles ont été cuites. Comme `GetGenerationParams`
|
||||
// blende à l'intérieur d'une strate (`Alpha` = f(chunk Z), et f(chunk XY) aussi en
|
||||
// `Interleaved`), deux chunks voisins produisent la MÊME clé avec des params DIFFÉRENTS, et le
|
||||
// deuxième se sert des salles du premier. Non déterministe entre pairs, parce que l'ordre des
|
||||
// workers décide lequel est « le premier » — exactement ce que §2.6.1 interdit.
|
||||
//
|
||||
// Pourquoi ça ne casse PAS l'invariant de perf de §8.10 : la clé reste une BOÎTE, donc les
|
||||
// sondes de gradient à `WorldX ± 1` ne font toujours pas tourner le cache. Ce qui le fait
|
||||
// tourner en plus, c'est un changement RÉEL de params — une fois par chunk dans une bande de
|
||||
// transition, ce qui est le nombre de reconstructions que ce cache aurait toujours dû faire.
|
||||
thread_local uint32 CachedFingerprint = 0xFFFFFFFFu;
|
||||
thread_local uint32 CachedLayout = 0xFFFFFFFFu;
|
||||
|
||||
// Index of the room with the smallest (most-inside) SDF for this voxel.
|
||||
// Written by EvaluateSDFCached, read by the terrain ops block to pick the
|
||||
@@ -968,6 +1000,7 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo
|
||||
// cached search box, or the strate/seed changed.
|
||||
const bool bNeedRebuild =
|
||||
StrateIdx != CachedStrate || (uint32)Seed != CachedSeed ||
|
||||
ParamsFingerprint != CachedFingerprint || LayoutVersion != CachedLayout ||
|
||||
WarpedX < CachedSMinX || WarpedX > CachedSMaxX ||
|
||||
WarpedY < CachedSMinY || WarpedY > CachedSMaxY;
|
||||
|
||||
@@ -1011,6 +1044,8 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo
|
||||
CachedSMinY = SMinY; CachedSMaxY = SMaxY;
|
||||
CachedStrate = StrateIdx;
|
||||
CachedSeed = (uint32)Seed;
|
||||
CachedFingerprint = ParamsFingerprint;
|
||||
CachedLayout = LayoutVersion;
|
||||
}
|
||||
|
||||
// Evaluate SDF using cached rooms and tunnels (WARPED coordinates).
|
||||
|
||||
@@ -118,9 +118,35 @@ public:
|
||||
* Densité pour une strate TunnelNetwork (rooms + tunnels + worm noise).
|
||||
* Utilisée en interne par GetDensityAt quand la strate est de ce type.
|
||||
* Exposée pour permettre des tests isolés avec des params custom.
|
||||
*
|
||||
* ⚠️ `ParamsFingerprint` ET `LayoutVersion` SONT OBLIGATOIRES, ET C'EST LE CORRECTIF
|
||||
* D'`AUDIT §C2` (2026-07-28). Le cache SDF interne est clé sur (boîte XY, strate, seed) et
|
||||
* PAS sur les params. Or `GetGenerationParams` BLENDE les params à l'intérieur d'une même
|
||||
* strate — `Alpha` dépend du chunk Z en mode `Gradient` (le DÉFAUT, avec
|
||||
* `TransitionBlendChunks = 2`) et du chunk XY en plus en mode `Interleaved`. Deux chunks de la
|
||||
* même strate, même seed, donc même clé, mais des params DIFFÉRENTS : le worker évalue le
|
||||
* deuxième chunk qu'il construit contre les salles du premier. Et comme *quel* chunk vient en
|
||||
* premier dépend de l'ordre des workers, **deux pairs divergent depuis la même seed** — ce que
|
||||
* `OPSTACK-PLAN §2.6.1` interdit explicitement.
|
||||
*
|
||||
* Pourquoi une empreinte PASSÉE plutôt qu'un `MemCrc32` calculé ici : ce serait ~300 octets de
|
||||
* CRC PAR VOXEL sur le chemin le plus chaud du plugin. L'appelant la calcule UNE fois par
|
||||
* chunk, là où le mémo de params vit déjà (`CP_*`), donc le coût par voxel est exactement deux
|
||||
* comparaisons d'entiers. Pas de valeur par défaut : un appelant qui oublie doit ne pas
|
||||
* compiler, pas hériter silencieusement du trou (la discipline de `FVoxelOpContext`).
|
||||
*
|
||||
* ⚠️ POUR LES TESTS : passez `FCrc::MemCrc32(&Params, sizeof(Params))`. Un oracle qui partage
|
||||
* le défaut qu'il teste ne prouve rien — c'est précisément ce que la note de
|
||||
* `VoxelForgeOpStackTunnelTest.cpp` (contrôle 3) décrivait comme le trou de l'original.
|
||||
*
|
||||
* The SDF cache key had neither the params nor anything that determines them, while the params
|
||||
* are blended per chunk INSIDE a strate — so a worker could evaluate one chunk against another
|
||||
* chunk's rooms, and which came first depends on worker order. Passing a once-per-chunk
|
||||
* fingerprint keeps the fix off the per-voxel path. No default: forgetting it must not compile.
|
||||
*/
|
||||
float GetDensityWithParams(float WorldX, float WorldY, float WorldZ,
|
||||
const FStrateGenerationParams& Params) const;
|
||||
const FStrateGenerationParams& Params,
|
||||
uint32 ParamsFingerprint, uint32 LayoutVersion) const;
|
||||
|
||||
/**
|
||||
* Densité pour une strate Slab (FlatPlain / CrystalChamber).
|
||||
|
||||
Reference in New Issue
Block a user