feat(classify): fold cave boundary categories safely

This commit is contained in:
2026-08-17 01:35:58 +02:00
parent 2303442d8f
commit 4d33321bfa
5 changed files with 167 additions and 43 deletions
+64 -35
View File
@@ -2725,8 +2725,8 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
if (CX * CX + CY * CY <= Reach * Reach) bCanSolid = false;
}
// ── Catégorisation par Z de treillis. v1 : gap bedrock = solide ; SurfaceWorld = test
// colonne ; tout le reste (intérieurs de caves, hors layout) = Mixed immédiat. ──
// ── Catégorisation par Z du treillis : gap bedrock = solide ; hors layout = air constant ;
// SurfaceWorld = test colonne ; un slot cave opt-in = verdict de pile sur sa sous-boîte. ──
struct FSurfSlot
{
int32 BotChunkZ = INT32_MAX; // identité du slot (borne basse de la strate, en chunks)
@@ -2753,13 +2753,19 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
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`).
// Ces valeurs suivent l'UNIQUE slot de cave que la tuile touche. La pile classera seulement la
// sous-boîte Z de ses échantillons ; les catégories gap/surface/hors-layout plient séparément
// leurs hypothèses dans `bCanSolid` / `bCanAir`.
// These values track the ONE cave slot touched by the tile. The stack classifies only its
// sampled Z sub-box; gap/surface/out-of-layout fold their hypotheses separately.
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 CaveMinZ = MAX_int32;
int32 CaveMaxZ = MIN_int32;
bool bAnyCave = false;
bool bAnyGap = false;
bool bAnySurface = false;
bool bAnyOutOfLayout = false;
int32 MemoChunkZ = INT32_MAX;
int32 MemoCat = -1; // 0 = gap, 1 = surface, 2 = cave (pile), 3 = hors layout
@@ -2783,7 +2789,7 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
if (StrateManager->IsGapChunk(CC))
{
MemoCat = 0;
bAnyNonCave = true;
bAnyGap = true;
}
//=================================================================
// ⛔ HORS LAYOUT = AIR CONSTANT. C'ÉTAIT LE BLOCAGE DE T1.d.
@@ -2809,7 +2815,7 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
else if (!VF_ChunkZHasSlot(ChunkZ))
{
MemoCat = 3;
bAnyNonCave = true; // n'entre JAMAIS dans la branche de cave
bAnyOutOfLayout = true;
// Les disturbances sont appliquées APRÈS la densité d'archétype et peuvent AJOUTER
// de la roche (ponts, arêtes). Même prudence que les branches gap et cave : si
@@ -2861,7 +2867,7 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
if (D.BridgeDensity > 0.0f || D.RidgeDensity > 0.0f) bCanAir = false;
}
MemoCat = 1;
bAnyNonCave = true;
bAnySurface = true;
}
else
{
@@ -2909,12 +2915,12 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
int32 CaveTopCZ = 0, CaveBotCZ = 0;
if (!StrateManager->GetStrateChunkZBounds(ChunkZ, CaveTopCZ, CaveBotCZ))
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailMixedContent);
INC_DWORD_STAT(STAT_VoxelForgeCaveMixOutOfLayout);
return EVoxelTileClass::Mixed; // hors layout
}
if (CaveBotChunkZ != INT32_MAX && CaveBotChunkZ != CaveBotCZ)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailMixedContent);
INC_DWORD_STAT(STAT_VoxelForgeCaveBailTwoCaveSlots);
return EVoxelTileClass::Mixed;
}
CaveBotChunkZ = CaveBotCZ;
@@ -2926,7 +2932,10 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
if (MemoCat == 2)
{
// Rien par z : la pile répond pour la boîte entière, après la boucle.
// La pile répond après la boucle, sur la sous-boîte Z contenant exactement les
// échantillons cave (XY reste la boîte complète du treillis).
CaveMinZ = FMath::Min(CaveMinZ, Zi);
CaveMaxZ = FMath::Max(CaveMaxZ, Zi);
}
else if (MemoCat == 0)
{
@@ -2970,23 +2979,27 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
// 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)
// Diagnostic de PRÉSENCE avant les gardes : le signal reste visible même si le pliage rend
// finalement AllSolid/AllAir. Ces compteurs ne sont pas exclusifs entre eux sur une tuile
// très haute ; chacun répond exactement à « cette catégorie était-elle aussi présente ? ».
// Presence diagnostics run before the guards, so a successful fold cannot hide the mix.
// They are not mutually exclusive for a very tall tile; each answers one exact question.
if (bAnyOutOfLayout || bAnyGap || bAnySurface)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailMixedContent);
return EVoxelTileClass::Mixed;
if (bAnyOutOfLayout) { INC_DWORD_STAT(STAT_VoxelForgeCaveMixOutOfLayout); }
if (bAnyGap) { INC_DWORD_STAT(STAT_VoxelForgeCaveMixGap); }
if (bAnySurface) { INC_DWORD_STAT(STAT_VoxelForgeCaveMixSurfaceWorld); }
}
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
// ⚠️ LA GARDE QUI COMPTE : LES PARAMS DOIVENT ÊTRE LES MÊMES SUR TOUTE LA SOUS-BOÎTE CAVE
//---------------------------------------------------------------------
// `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
// chunks d'une même sous-boîte 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.
@@ -2995,7 +3008,12 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
// `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);
// IMPORTANT : les gardes restent complètes, mais seulement sur les chunks où le mesher
// échantillonne réellement CETTE strate cave. Inclure gap/surface/hors-layout ici ferait
// échouer la garde d'archétype avant de pouvoir plier leurs hypothèses indépendantes.
// The guards stay exhaustive over the cave samples. Non-cave chunks are intentionally not
// represented by this stack; their hypotheses were folded separately in the Z pass.
const int32 CZ0 = FloorDivC(CaveMinZ, CHUNK_SIZE), CZ1 = FloorDivC(CaveMaxZ, 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é.
@@ -3098,7 +3116,7 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
}
default:
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
return EVoxelTileClass::Mixed; // SurfaceWorld ne peut pas arriver ici (bAnyNonCave)
return EVoxelTileClass::Mixed; // SurfaceWorld ne peut pas être le type du slot cave
}
bFirst = false;
@@ -3134,9 +3152,9 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
}
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);
const FBox CaveBox(FVector((float)MinX, (float)MinY, (float)CaveMinZ),
FVector((float)MaxX, (float)MaxY, (float)CaveMaxZ));
const EVoxelTileClass StackVerdict = TileStack.ClassifyBox(CaveBox, OpCtx);
if (StackVerdict == EVoxelTileClass::Mixed)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailStackVerdict);
@@ -3146,6 +3164,17 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
if (StackVerdict == EVoxelTileClass::AllSolid) { bCanAir = false; }
else { bCanSolid = false; }
// Le verdict cave se plie avec gap=solide, hors-layout=air, seals surface=solide. Si les
// deux hypothèses sont mortes ici, les catégories se contredisent : ce n'est PAS un échec
// de borne de la pile ni une disturbance.
// Fold the cave verdict with gap=solid, out-of-layout=air, and solid surface seals. If both
// hypotheses die here, the categories conflict; this is not a stack-bound/disturbance bail.
if (!bCanSolid && !bCanAir)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailFoldConflict);
return EVoxelTileClass::Mixed;
}
//---------------------------------------------------------------------
// ⚠️ 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
@@ -3156,20 +3185,11 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
if (D.ChasmDensity > 0.0f) { bCanSolid = false; }
if (D.BridgeDensity > 0.0f || D.RidgeDensity > 0.0f) { bCanAir = false; }
if (bCanSolid == bCanAir)
if (!bCanSolid && !bCanAir)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailDisturbance);
return EVoxelTileClass::Mixed;
}
if (bCanSolid)
{
INC_DWORD_STAT(STAT_VoxelForgeTilesOpStackSolid);
}
else
{
INC_DWORD_STAT(STAT_VoxelForgeTilesOpStackAir);
}
return bCanSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir;
}
// ── Balayage des colonnes XY sur le treillis exact du mesher (marge incluse). Une colonne
@@ -3228,6 +3248,15 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
// Ici exactement UNE hypothèse doit survivre (chaque point testé en tue une ; les tuiles
// sans point intérieur ont tué AllAir via gap/seal). Égalité = prudence → Mixed.
if (bCanSolid == bCanAir) return EVoxelTileClass::Mixed;
if (bAnyCave)
{
// Compte seulement les verdicts FINAUX qui sautent réellement une tuile. Une pile peut avoir
// prouvé sa sous-boîte cave puis perdre l'hypothèse sur une colonne SurfaceWorld adjacente.
// Count only final verdicts that actually skip a tile; a later surface column may still
// invalidate the hypothesis proved for the cave sub-box.
if (bCanSolid) { INC_DWORD_STAT(STAT_VoxelForgeTilesOpStackSolid); }
else { INC_DWORD_STAT(STAT_VoxelForgeTilesOpStackAir); }
}
return bCanSolid ? EVoxelTileClass::AllSolid : EVoxelTileClass::AllAir;
}
+5 -1
View File
@@ -14,9 +14,13 @@ DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStackSoleSlot);
DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStackBoundaryTile);
DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStackNoLayout);
DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStackRecheck);
DEFINE_STAT(STAT_VoxelForgeCaveBailMixedContent);
DEFINE_STAT(STAT_VoxelForgeCaveMixOutOfLayout);
DEFINE_STAT(STAT_VoxelForgeCaveMixGap);
DEFINE_STAT(STAT_VoxelForgeCaveMixSurfaceWorld);
DEFINE_STAT(STAT_VoxelForgeCaveBailTwoCaveSlots);
DEFINE_STAT(STAT_VoxelForgeCaveBailParams);
DEFINE_STAT(STAT_VoxelForgeCaveBailStackVerdict);
DEFINE_STAT(STAT_VoxelForgeCaveBailFoldConflict);
DEFINE_STAT(STAT_VoxelForgeCaveBailDisturbance);
DEFINE_STAT(STAT_VoxelForgeCaveBailNoStack);
DEFINE_STAT(STAT_VoxelForgeColumnMemoHit);
+6 -6
View File
@@ -313,12 +313,12 @@ public:
* (OriginVoxels, Step, CellsPerAxis) = les MÊMES arguments que GenerateMesh ; le verdict
* porte sur le treillis exact que le mesher échantillonnerait (marge ±1 incluse).
*
* v1 : ne prouve que les chunks GAP (bedrock) et les strates SurfaceWorld — colonnes
* terrain/plafond évaluées par le MÊME ComputeSurfaceColumn que le chemin densité (donc
* bit-identiques), bandes de seal solides, gardes spine/passages/disturbances/diff.
* Tout autre archétype (intérieur de caves) ⇒ Mixed. Worker-safe (lecture seule +
* caches thread_local partagés avec GetDensityAt — un verdict Mixed laisse les colonnes
* chaudes pour la génération qui suit).
* GAP (bedrock), hors-layout (air constant) et SurfaceWorld plient leurs hypothèses par Z ;
* SurfaceWorld évalue ses colonnes terrain/plafond avec le MÊME ComputeSurfaceColumn que le
* chemin densité. Un unique slot cave opt-in peut ajouter le verdict conservatif de sa pile,
* sous gardes d'archétype et de params bit-identiques. Spine/passages/disturbances/diff restent
* des gardes conservatrices. Worker-safe (lecture seule + caches thread_local partagés avec
* GetDensityAt — un verdict Mixed laisse les colonnes chaudes pour la génération qui suit).
*/
EVoxelTileClass ClassifyTile(const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis) const;
+5 -1
View File
@@ -18,9 +18,13 @@ DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Not Op Stack Sole Slot"), STAT
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Not Op Stack Boundary Tile"), STAT_VoxelForgeCaveBailNotOpStackBoundaryTile, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Not Op Stack No Layout"), STAT_VoxelForgeCaveBailNotOpStackNoLayout, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Not Op Stack Recheck"), STAT_VoxelForgeCaveBailNotOpStackRecheck, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Mixed Content"), STAT_VoxelForgeCaveBailMixedContent, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Mix Out Of Layout"), STAT_VoxelForgeCaveMixOutOfLayout, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Mix Gap"), STAT_VoxelForgeCaveMixGap, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Mix Surface World"), STAT_VoxelForgeCaveMixSurfaceWorld, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Two Cave Slots"), STAT_VoxelForgeCaveBailTwoCaveSlots, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Params"), STAT_VoxelForgeCaveBailParams, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Stack Verdict"), STAT_VoxelForgeCaveBailStackVerdict, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Fold Conflict"), STAT_VoxelForgeCaveBailFoldConflict, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Disturbance"), STAT_VoxelForgeCaveBailDisturbance, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail No Stack"), STAT_VoxelForgeCaveBailNoStack, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Column Memo Hits"), STAT_VoxelForgeColumnMemoHit, STATGROUP_VoxelForge, VOXELFORGE_API);