diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 52e2c16..af13296 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -306,7 +306,9 @@ driven by `EditorBrush*` props. - **SDF cache** (`GetDensityWithParams`): search-BOX validity, not chunk-key — gradient ±1 sampling must not thrash the (expensive) rebuild. - **Per-chunk param cache** in `GetDensityAt`: GenType + param struct + disturbance cached - thread-locally per chunk; don't move the fetch/blend back to per-voxel. + thread-locally by `(DensityCacheOwnerId, ChunkCoord, LayoutVersion)`; the process-unique owner ID + prevents cross-world reuse while adding only one `uint64` compare per voxel. Don't remove the owner + or layout key, and don't move the fetch/blend back to per-voxel. - **Biome cache** (`ResolveBiomeSampleAt`/`FChunkBiomeCache`, §8.14): validity is a world-XY BOX + ChunkZ + Seed, NOT a chunk key — same reason as the SDF cache. The cell classification is noise-heavy; a chunk-key would thrash it on gradient-normal / +X/+Y boundary samples. Keep diff --git a/CODEMAP.md b/CODEMAP.md index d0e04bb..f39cbef 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -251,18 +251,20 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h). > **Game-thread profiling (Perf):** `AVoxelWorld::Tick` and its sub-steps are wrapped in `TRACE_CPUPROFILER_EVENT_SCOPE` — `VoxelForge_Tick / UpdateChunks / BuildDesiredTiles / CullTiles / SubmitTiles / ProcessPending / ProcessUnload / UpdateDecorations / UpdateWater`. Capture a `Count/Incl/Excl` Insights timer export and read the `Excl` column to see which step owns the per-frame cost (the actor tick shows as `BP_VoxelWorld_C` if subclassed in BP). `VoxelForge_ClassifyTile` (T1.d) / `VoxelForge_GenerateMesh` + `VoxelForge_BuildStreams` are worker-side (off the frame): the RMC `FRealtimeMeshStreamSet` is now built on the gen worker (`BuildTileStreamSet`) and carried on `FChunkResult::Streams` (TSharedPtr), so `ApplyMeshToTile` is game-thread-cheap — just material/ceiling resolve + `CreateSectionGroup(MoveTemp)`. See ARCHITECTURE §8.10 "Worker-built StreamSet (T1.f)". ### 3.6 Density generator — `Public/VoxelGenerator.h` + `Private/VoxelGenerator.cpp` -`UVoxelGenerator : UObject` — lightweight; holds `Seed`, and injected services -`StrateManager` + `DiffLayer` (both nullable). This is **where terrain shape lives.** +`UVoxelGenerator : UObject` — lightweight; holds `Seed`, a process-unique +`DensityCacheOwnerId`, and injected services `StrateManager` + `DiffLayer` (both nullable). +This is **where terrain shape lives.** | Symbol | .cpp line | Role | |--------|-----------|------| +| `UVoxelGenerator` / `DensityCacheOwnerId` | — | Constructor allocates a process-unique integer identity (relaxed atomic, once per object). `GetDensityAt` includes it in the `CP_*` thread-local key, preventing a worker from serving another generator/world's params, biome context, `CP_UseOpStack`, or stack when `(ChunkCoord, LayoutVersion)` happens to match. Hot-path cost: one `uint64` compare per voxel. Scope is deliberately only the proved `CP_*` path. | | `FractalNoise3D` (static) | 25 | fBM (layered Perlin). | | `RidgedNoise3D` (static) | 55 | Ridged multifractal — craggy. | | `CellularNoise3D` (static) | 101 | Worley/cellular — grotto/scallop. | | `ApplyBoundarySeal` (static) | 170 | Solidifies strate top/bottom shells. | | `ApplyPassageCarving` (static) | 197 | Punches passages/elevator through the seal. | | `InitializeSettings` | 211 | Copies seed from settings. | -| **`GetDensityAt`** | 218 | **Entry point.** Picks strate + generator type, dispatches, adds diff offset. | +| **`GetDensityAt`** | 218 | **Entry point.** Picks strate + generator type, dispatches, adds diff offset. Its `CP_*` per-chunk state is keyed by `(DensityCacheOwnerId, ChunkCoord, LayoutVersion)`; every key component is an integer compare and a different generator/world cannot inherit the previous owner's cached params or op stack. | | **`GetDensityWithParams`** | 277 | TunnelNetwork pipeline (~1000 lines). See §4. ⚠️ Takes **required** `ParamsFingerprint` + `LayoutVersion` since the AUDIT §C2 fix (2026-07-28) — they go into the SDF cache key so a chunk can no longer be evaluated against a neighbour's rooms. Callers compute the CRC **once per chunk** (`CP_TunnelFP`), never per voxel. | | **`GetSlabDensity`** | 1306 | FlatPlain/CrystalChamber pipeline. See §4.2. | | `SampleSurfaceStructuralZ` | — | **F20:** the RAW SurfaceWorld heightfield (continents+mountains+detail), BEFORE any terrain op; returns terrain Z + relief M. Cliff re-samples it at an XY offset for a cheap analytic slope. | diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h b/Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h index 79b803d..19f65f2 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h +++ b/Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h @@ -129,22 +129,20 @@ namespace VoxelForgeTest // elle n'était jusqu'ici masquée que par un accident. // // `PassagesVersion` est PAR INSTANCE et part de 0, donc deux `FTestWorld` successifs - // rendaient tous les deux **1**. Or les caches par chunk de `GetDensityAt` sont clés sur - // `(ChunkCoord, LayoutVersion)` : deux mondes différents, même version, même chunk ⇒ le - // second se voit servir les params — ET le drapeau `CP_UseOpStack` — du premier. - // Personne ne l'a vu parce que `bUseOperatorStack` valait false partout : les deux - // mondes étaient d'accord par défaut. Le premier monde qui coche la case fait tomber - // cette coïncidence, dans les DEUX sens (il contamine, et il est contaminé). + // rendaient tous les deux **1**. Historiquement, les caches `CP_*` de `GetDensityAt` + // n'avaient que `(ChunkCoord, LayoutVersion)` et le second monde pouvait hériter les + // params — ET `CP_UseOpStack` — du premier. `DensityCacheOwnerId` ferme maintenant CE + // chemin prouvé. Les bumps restent ici comme isolation conservatrice des autres caches + // TLS que cette correction n'a volontairement pas audités ni modifiés. // // Un compteur de processus donne à chaque monde une version distincte, donc tout cache // survivant d'un test à l'autre est forcément invalidé. `Initialize` est déterministe // (le pool est mélangé par le seed, les fixed strates sont épinglées), donc le rappeler // ne change pas le layout — seulement le compteur. // - // Each test world gets a process-unique LayoutVersion. Two worlds both reporting 1 made - // GetDensityAt's per-chunk caches serve the previous world's params — and its - // CP_UseOpStack flag — for the same chunk coord. Invisible while every world agreed that - // the flag was false. + // Each test world still gets a process-unique LayoutVersion. DensityCacheOwnerId now + // prevents the proved CP_* cross-world reuse directly; the version bumps remain as + // conservative isolation for other TLS caches not audited or changed by that fix. static int32 GWorldSerial = 0; const int32 Bumps = ++GWorldSerial; for (int32 b = 0; b < Bumps; ++b) diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 39c97f8..ccea273 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -18,6 +18,8 @@ #include "VoxelHeightOp.h" // IVoxelBiomeField — the adapter below implements it #include "VoxelStats.h" +#include + //============================================================================= // L'ADAPTATEUR DE CHAMP DE BIOMES / THE BIOME FIELD ADAPTER //============================================================================= @@ -445,6 +447,13 @@ static void ApplyDisturbances(float& MC, float X, float Y, float Z, // never fetched here — both callers already have them. namespace { + // Une identité monotone évite qu'un worker réutilise les CP_* d'un monde détruit même si + // l'allocateur UObject recycle plus tard la même adresse. Relaxed suffit : on ne publie aucune + // donnée, on alloue seulement une valeur distincte par instance. + // A monotonic identity prevents stale CP_* reuse even if UObject allocation later recycles an + // address. Relaxed ordering is sufficient: this allocates uniqueness, it publishes no data. + std::atomic GNextDensityCacheOwnerId { 0 }; + struct FVoxelStackParamRefs { const FSlabGenerationParams* Slab = nullptr; @@ -542,6 +551,11 @@ namespace } } +UVoxelGenerator::UVoxelGenerator() + : DensityCacheOwnerId(GNextDensityCacheOwnerId.fetch_add(1, std::memory_order_relaxed) + 1) +{ +} + void UVoxelGenerator::InitializeSettings(const UVoxelSettings* Settings) { // Seul le seed est copié ici. Tout le reste (params de cave, transitions, @@ -579,7 +593,9 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // The generator type, the (boundary-blended) param struct, and the disturbance // params are identical for the whole chunk, yet resolving them re-runs a strate // lookup + copies large structs (and a ~60-field Lerp for blended cave chunks). - // Cache them thread-locally, keyed by chunk coord — refetch only on chunk change. + // Cache them thread-locally, keyed by owner + chunk coord + layout version — refetch only + // when one of those integer identities changes. + thread_local uint64 CP_OwnerId = 0; thread_local FIntVector CP_Chunk(INT32_MAX, INT32_MAX, INT32_MAX); thread_local ECaveGeneratorType CP_GenType = ECaveGeneratorType::TunnelNetwork; thread_local FStrateGenerationParams CP_Tunnel; @@ -613,18 +629,23 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // "I tweaked the strate asset, regenerated, and one patch kept the old shape." thread_local uint32 CP_Version = 0xFFFFFFFFu; // OPSTACK Phase 1 — la pile d'opérateurs, construite dans le MÊME bloc de refetch que les - // params (donc même clé chunk+version, aucune logique d'invalidation en plus). Vide tant que + // params (donc même clé owner+chunk+version, aucune logique d'invalidation en plus). Vide tant que // la strate n'a pas coché `bUseOperatorStack` ET que son archétype n'est pas porté. thread_local FVoxelOpStack CP_OpStack; thread_local bool CP_UseOpStack = false; const uint32 LayoutVersion = StrateManager->GetLayoutVersion(); - if (ChunkCoord != CP_Chunk || LayoutVersion != CP_Version) + const bool bOwnerChanged = DensityCacheOwnerId != CP_OwnerId; + if (bOwnerChanged || ChunkCoord != CP_Chunk || LayoutVersion != CP_Version) { // La grille de biome est validée par une BOÎTE XY, qui ne dit rien du FBiomeContext // ayant servi à classer ses cellules : sur un changement de version elle est périmée - // même si la boîte couvre encore la requête. - if (LayoutVersion != CP_Version) { CP_BiomeCache.Invalidate(); } + // même si la boîte couvre encore la requête. Même invalidation quand le propriétaire + // change : deux mondes peuvent partager version et coordonnées, jamais leur contexte. + // The biome grid's XY box says nothing about its context. Owner changes invalidate it + // too: two worlds may share version and coordinates, never cached params/context. + if (bOwnerChanged || LayoutVersion != CP_Version) { CP_BiomeCache.Invalidate(); } + CP_OwnerId = DensityCacheOwnerId; CP_Version = LayoutVersion; CP_Chunk = ChunkCoord; CP_GenType = StrateManager->GetGeneratorTypeForChunk(ChunkCoord); diff --git a/Source/VoxelForge/Public/VoxelGenerator.h b/Source/VoxelForge/Public/VoxelGenerator.h index 47f515e..248ac54 100644 --- a/Source/VoxelForge/Public/VoxelGenerator.h +++ b/Source/VoxelForge/Public/VoxelGenerator.h @@ -68,6 +68,8 @@ class VOXELFORGE_API UVoxelGenerator : public UObject GENERATED_BODY() public: + UVoxelGenerator(); + //========================================================================= // SEED (source unique: Settings->Seed) //========================================================================= @@ -321,6 +323,10 @@ public: EVoxelTileClass ClassifyTile(const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis) const; private: + /** Identité process-unique du propriétaire des caches `CP_*` thread_local. + * Process-unique owner identity for the `CP_*` thread-local cache key. */ + uint64 DensityCacheOwnerId = 0; + /** Pick the biome (index into Ctx.Biomes) for a Voronoi site, by its climate. */ int32 ClassifyBiomeAtSite(float SiteX, float SiteY, const FBiomeContext& Ctx, uint32 SiteHash) const;