perf(opstack): direct-indexed column box (measured 14.7% miss); + T1.d bail attribution

BUILD RESULTS FIRST: 14/14 tests green, 0 violations. VerticalShafts box
verdicts went 0 -> 30 of 60 (zero was the number for the project's whole life).
TunnelNetwork production held at 11 proved / 14641 voxels, byte-identical --
that line was the isolated diagnostic for cab8e8f, so its stillness proves
Jahni's room/tunnel Min <= Max are correctly ordered. All eight equivalence
tests bit-identical, which TESTS the "bounds only, cannot reach density" claim
made on 7dbdf51 / eaa44bf / cab8e8f rather than asserting it.

PART A -- the column memo thrash is confirmed quantitatively. Measured 14.74%
miss rate against a prediction of 14.0% if thrashing and 1.4% if not; and 8,300
recomputes per tile against a healthy 1,225 (6.8x) from an independent
statistic. Replaced the 4096-slot direct-mapped hashed table with the
direct-indexed box scheme GSurfColCache has always used: no hash, no
collisions, every column computed exactly once. ParamsFingerprint is retained
in the box key -- its absence was the shipped bug that silently deleted the
overhang, and GSurfColCache's own omission of it was deliberately not copied.

PART B -- T1.d never fires in game: the two op-stack counters never appeared,
and since every displayed row has Min 1.00 rather than 0.00, rows only render
when a counter fires. The cave branch has 13 return-Mixed paths; guessing which
is the mistake this project keeps paying for. Six attribution counters now name
the bail category outright. Only edits there are brace-expansions so a counter
fits before each existing return -- every condition and returned value is
byte-identical.

Open falsifiable hypothesis: Tiles Meshed averages 2.13/frame, plausibly the
reason LOD rings update slowly. If misses drop ~10x and LODs speed up, the memo
was the ceiling; if not, they are separate problems, cleanly separated.

Not built -- Jahni builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 18:28:46 +02:00
parent 49a9959aed
commit 3ad2720d0a
7 changed files with 350 additions and 45 deletions
@@ -661,43 +661,79 @@ namespace
}
/** La colonne complète, exactement les cinq sorties de `ComputeSurfaceColumn`.
* Mémoïsée par (instance, X, Y) : la pile évalue tous les Z d'une colonne au même XY, donc
* le taux de succès est ~1 et l'overhang lit la MÊME colonne que la source, par
* Le mémo est une boîte à index direct, comme `FSurfaceColumnBox` : la pile évalue tous
* les Z d'une colonne au même XY, donc l'overhang lit la MÊME colonne que la source, par
* construction plutôt que par convention. */
struct FColumn { float TerrainZ, CeilSurf, OverhangAmp, DirX, DirY; };
const FColumn& GetColumn(float WorldX, float WorldY) const
{
// ⚠️ POURQUOI UNE TABLE ET PAS UNE SEULE ENTRÉE. Un mémo à une entrée n'est correct que
// si l'appelant descend une colonne Z avant de changer de XY. Le mesher n'en promet
// RIEN — s'il itère X en premier dans une tranche Z, chaque voxel raterait et on
// relancerait toute la pile de hauteur par voxel, cliff compris (4 resamples
// structurels). Ce n'est pas « un peu plus lent », c'est un ordre de grandeur sur
// l'archétype le plus cher du plugin.
//
// Table à correspondance directe, clé COMPLÈTE comparée sur touche : une collision ne
// peut que coûter un recalcul, jamais rendre une mauvaise colonne.
//
// TAILLE : un chunk fait CHUNK_SIZE² colonnes (1024 à 32³). Les 256 entrées du premier
// jet ne tenaient donc même pas UN chunk — la table se piétinait elle-même à
// l'intérieur d'une seule tuile. 4096 entrées couvrent quatre chunks de front, pour
// ~150 Ko par worker : du même ordre qu'une boîte de `GSurfColCache` (~59 Ko × 6).
//
// A chunk is CHUNK_SIZE² columns (1024), so the first draft's 256 entries could not
// even hold one chunk and thrashed inside a single tile. 4096 covers four chunks.
struct FSlot { uint64 Key; float X, Y; FColumn C; };
thread_local FSlot Slots[4096] = {};
// Même schéma éprouvé que `GSurfColCache` : index direct dans une boîte XY, puis un
// drapeau `Computed` par cellule. Une tuile MC pleine résolution demande
// (CHUNK_SIZE + 3)² = 35×35 = 1225 colonnes (anneau de marge inclus) ; cette boîte
// de Dim×Dim, recentrée sur le premier échantillon, les garde toutes sans collision.
// Same proven scheme as `GSurfColCache`: direct XY indexing plus one `Computed` flag per
// cell. A full-resolution MC tile needs 35×35 = 1225 columns including its margin ring;
// the box is sized so one tile fits without eviction.
struct FColumnBox
{
enum : int32 { Halo = CHUNK_SIZE + 8, Dim = 2 * Halo + 1 };
int32 BaseX = 0, BaseY = 0;
uint64 Key = 0; // strate + layout + seed + ParamsFingerprint
bool bValid = false;
FColumn Cols[Dim * Dim];
bool Computed[Dim * Dim];
};
thread_local FColumnBox Box = {};
thread_local FColumn DirectColumn = {};
const uint32 HX = *reinterpret_cast<const uint32*>(&WorldX);
const uint32 HY = *reinterpret_cast<const uint32*>(&WorldY);
const uint32 Idx = ((HX * 0x9E3779B9u) ^ (HY * 0x85EBCA6Bu)) >> 20; // [0,4095]
// The production mesher and the exact-lattice classifier use integer XY. Fractional
// XY is still valid for the public density/equivalence probes: compute it directly so
// no integer cell can ever be returned for a different full (WorldX, WorldY) pair.
const bool bIntegerXY = WorldX == FMath::FloorToFloat(WorldX)
&& WorldY == FMath::FloorToFloat(WorldY);
FColumn* MemoColumn = &DirectColumn;
int32 CI = 0;
bool bNeedsCompute = true;
FSlot& S = Slots[Idx];
if (S.Key != ColumnKey || S.X != WorldX || S.Y != WorldY)
if (bIntegerXY)
{
const int32 IX = (int32)WorldX;
const int32 IY = (int32)WorldY;
// Bounds are checked exactly before deriving CI; the index itself is the XY key.
// Les bornes sont vérifiées exactement avant CI : l'index EST la clé XY.
if (!Box.bValid || Box.Key != ColumnKey
|| IX < Box.BaseX || IX >= Box.BaseX + FColumnBox::Dim
|| IY < Box.BaseY || IY >= Box.BaseY + FColumnBox::Dim)
{
Box.BaseX = IX - FColumnBox::Halo;
Box.BaseY = IY - FColumnBox::Halo;
Box.Key = ColumnKey;
Box.bValid = true;
FMemory::Memzero(Box.Computed, sizeof(Box.Computed));
}
CI = (IY - Box.BaseY) * FColumnBox::Dim + (IX - Box.BaseX);
MemoColumn = &Box.Cols[CI];
if (Box.Computed[CI])
{
INC_DWORD_STAT(STAT_VoxelForgeColumnMemoHit);
bNeedsCompute = false;
}
else
{
INC_DWORD_STAT(STAT_VoxelForgeColumnMemoMiss);
}
}
else
{
INC_DWORD_STAT(STAT_VoxelForgeColumnMemoMiss);
S.Key = ColumnKey; S.X = WorldX; S.Y = WorldY;
FColumn& C = S.C;
}
if (bNeedsCompute)
{
FColumn& C = *MemoColumn;
C.TerrainZ = TerrainStack.EvalHeight(WorldX, WorldY);
C.CeilSurf = CeilingStack.EvalHeight(WorldX, WorldY);
@@ -754,12 +790,10 @@ namespace
// plat — mais l'amplitude y vaut 0 de toute façon.
if (Slope > KINDA_SMALL_NUMBER) { C.DirX = GX / Slope; C.DirY = GY / Slope; }
}
if (bIntegerXY) { Box.Computed[CI] = true; }
}
else
{
INC_DWORD_STAT(STAT_VoxelForgeColumnMemoHit);
}
return S.C;
return *MemoColumn;
}
/** Le champ structurel nu — l'overhang s'en sert pour emprunter la roche amont.
+60 -11
View File
@@ -2807,17 +2807,23 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
// 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; }
if (!StrateManager->UsesOperatorStackForChunk(CC))
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailNotOpStack);
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))
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailMixedContent);
return EVoxelTileClass::Mixed; // hors layout
}
if (CaveBotChunkZ != INT32_MAX && CaveBotChunkZ != CaveBotCZ)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailMixedContent);
return EVoxelTileClass::Mixed;
}
CaveBotChunkZ = CaveBotCZ;
@@ -2867,7 +2873,11 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
{
// 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; }
if (bAnyNonCave)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailMixedContent);
return EVoxelTileClass::Mixed;
}
const FIntVector RepCC(0, 0, CaveRepChunkZ);
const ECaveGeneratorType CaveType = StrateManager->GetGeneratorTypeForChunk(RepCC);
@@ -2891,7 +2901,11 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
// 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; }
if (NumChunkCoords > 27)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
return EVoxelTileClass::Mixed;
}
FSlabGenerationParams TileSlab;
FMazeGenerationParams TileMaze;
@@ -2907,12 +2921,17 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
const FIntVector CC(cx, cy, cz);
if (StrateManager->GetGeneratorTypeForChunk(CC) != CaveType)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
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; }
if (!StrateManager->UsesOperatorStackForChunk(CC))
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailNotOpStack);
return EVoxelTileClass::Mixed;
}
switch (CaveType)
{
@@ -2921,28 +2940,44 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
{
const FSlabGenerationParams Q = StrateManager->GetSlabParamsForChunk(CC);
if (bFirst) { TileSlab = Q; }
else if (FMemory::Memcmp(&Q, &TileSlab, sizeof(Q)) != 0) { return EVoxelTileClass::Mixed; }
else if (FMemory::Memcmp(&Q, &TileSlab, sizeof(Q)) != 0)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
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; }
else if (FMemory::Memcmp(&Q, &TileMaze, sizeof(Q)) != 0)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
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; }
else if (FMemory::Memcmp(&Q, &TileVert, sizeof(Q)) != 0)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
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; }
else if (FMemory::Memcmp(&Q, &TileFloat, sizeof(Q)) != 0)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
return EVoxelTileClass::Mixed;
}
break;
}
case ECaveGeneratorType::Underwater:
@@ -2950,10 +2985,15 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
{
const FStrateGenerationParams Q = StrateManager->GetGenerationParams(CC);
if (bFirst) { TileTunnel = Q; }
else if (FMemory::Memcmp(&Q, &TileTunnel, sizeof(Q)) != 0) { return EVoxelTileClass::Mixed; }
else if (FMemory::Memcmp(&Q, &TileTunnel, sizeof(Q)) != 0)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
return EVoxelTileClass::Mixed;
}
break;
}
default:
INC_DWORD_STAT(STAT_VoxelForgeCaveBailParams);
return EVoxelTileClass::Mixed; // SurfaceWorld ne peut pas arriver ici (bAnyNonCave)
}
@@ -2985,6 +3025,7 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
{
// 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.
INC_DWORD_STAT(STAT_VoxelForgeCaveBailNoStack);
return EVoxelTileClass::Mixed;
}
TileStack.PrepareChunk(OpCtx);
@@ -2992,7 +3033,11 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
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::Mixed)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailStackVerdict);
return EVoxelTileClass::Mixed;
}
if (StackVerdict == EVoxelTileClass::AllSolid) { bCanAir = false; }
else { bCanSolid = false; }
@@ -3007,7 +3052,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) { return EVoxelTileClass::Mixed; }
if (bCanSolid == bCanAir)
{
INC_DWORD_STAT(STAT_VoxelForgeCaveBailDisturbance);
return EVoxelTileClass::Mixed;
}
if (bCanSolid)
{
INC_DWORD_STAT(STAT_VoxelForgeTilesOpStackSolid);
+6
View File
@@ -10,5 +10,11 @@ DEFINE_STAT(STAT_VoxelForgeTilesSkippedAllAir);
DEFINE_STAT(STAT_VoxelForgeTilesMeshed);
DEFINE_STAT(STAT_VoxelForgeTilesOpStackSolid);
DEFINE_STAT(STAT_VoxelForgeTilesOpStackAir);
DEFINE_STAT(STAT_VoxelForgeCaveBailNotOpStack);
DEFINE_STAT(STAT_VoxelForgeCaveBailMixedContent);
DEFINE_STAT(STAT_VoxelForgeCaveBailParams);
DEFINE_STAT(STAT_VoxelForgeCaveBailStackVerdict);
DEFINE_STAT(STAT_VoxelForgeCaveBailDisturbance);
DEFINE_STAT(STAT_VoxelForgeCaveBailNoStack);
DEFINE_STAT(STAT_VoxelForgeColumnMemoHit);
DEFINE_STAT(STAT_VoxelForgeColumnMemoMiss);
+6
View File
@@ -14,5 +14,11 @@ DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Skipped All Air"), STAT_VoxelForge
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Meshed"), STAT_VoxelForgeTilesMeshed, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Operator Stack Solid"), STAT_VoxelForgeTilesOpStackSolid, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Tiles Operator Stack Air"), STAT_VoxelForgeTilesOpStackAir, STATGROUP_VoxelForge, VOXELFORGE_API);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Cave Bail Not Op Stack"), STAT_VoxelForgeCaveBailNotOpStack, 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 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 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);
DECLARE_DWORD_COUNTER_STAT_EXTERN(TEXT("Column Memo Misses"), STAT_VoxelForgeColumnMemoMiss, STATGROUP_VoxelForge, VOXELFORGE_API);