diff --git a/OPSTACK-PROGRESS.md b/OPSTACK-PROGRESS.md index e2a4f5a..42349e5 100644 --- a/OPSTACK-PROGRESS.md +++ b/OPSTACK-PROGRESS.md @@ -1429,3 +1429,53 @@ the `Mask` combiner (biome blending, §5's Phase 3 prototype) and integrating `G stack path reuses the existing box cache rather than only its own table. `§C1` still open. --- + +## 2026-07-27 — AUDIT §C1 FIXED. 85 noise sites, mechanically, in both paths at once. + +SurfaceWorld's wiring came back fine, so I took `§C1` next — **not a detour any more**: the op stack +had already inherited the bug three times, and every remaining port (`VerticalShafts`, +`FloatingIslands`, `TunnelNetwork`) would copy it again. Fixing it now means those get written +correctly instead of needing a follow-up pass. + +**The fix, and why the documented one was wrong.** The audit proposed bounding `SeedF` to 16383 +while keeping the `· 97.7f` multiplier — which still reaches 1.6e6, where the ULP is 0.19, **9.5× the +per-voxel step**. Less spectacular, still broken, ticket closed. The multiplier is the problem. + +`VoxelHash::SeedOffset(Seed, SiteKey)` inverts the roles: **the multiplier no longer decorrelates by +amplifying — it IDENTIFIES the site, and the hash decorrelates.** Output is already in final units, +bounded to [0, 16383], so the ULP is 0.002 = 10 % of a voxel step. Site-salted, so two seeds must +collide at **all ~50 sites** to give the same world, rather than sharing one global bucket. + +**Why the 85-site edit was safe to do without compiling:** the transformation is a pure regex — +`SeedF * K.Kf` → `VoxelHash::SeedOffset(SeedU, K.Kf)` — and **the literal stays visible at the call +site**, so every line can still be eye-checked against the original. Applied to all three files in +one pass, so the archetype `switch` and the ported ops changed *identically*; if they had not, the +three equivalence tests would say so loudly. 62 + 7 + 16 sites, 0 left behind, plus 2 bare `+ SeedF` +worm sites handled by hand (site key `1.0f`). + +### ⚠️ The new test exists because the equivalence tests are structurally blind here + +`VoxelForge.Determinism.LargeSeedSurvives` — seeds 1337, 1e5, 1e7, 2e9; asserts the heightfield still +produces ≥ 50 distinct heights over 400 samples. + +**The equivalence tests could never have caught C1.** They compare the op stack against the archetype +switch, and both read the *same* faulty expression — so at a large seed both collapse **identically**: +bit-identical, green, and both perfectly flat. An oracle that shares the implementation's bug cannot +see the bug. This test compares nothing to nothing; it asserts a **property** — the terrain must vary. +That distinction is worth keeping in mind for the ports still to come. + +The fixture's own comment said the small seed was a *workaround* for C1; corrected, since the reason +is now just message comparability. + +**UNVERIFIED:** not compiled. Likely spots: a missed `SeedU` declaration in one of the 9 functions +(the awk sweep found none, but it is a heuristic); `VoxelCaveMorphology.h` newly included in +`VoxelHeightOpStack.cpp`; and the `uint32`/`float` swap on two op members. + +**⚠️ EXPECT EVERY WORLD TO LOOK DIFFERENT.** This re-rolls every noise offset in the plugin. That is +the intended consequence and it is covered by §2.6.1 — nothing depends on the old shapes any more. +The three equivalence tests should stay green (both paths changed together); the visual is new. + +**Next single action:** build, run the full `VoxelForge` filter — six tests now. Then back to the +op stack: the `Mask` combiner (biome blending) to finish SurfaceWorld, or `VerticalShafts` next. + +--- diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeLargeSeedTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeLargeSeedTest.cpp new file mode 100644 index 0000000..1e95dd3 --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeLargeSeedTest.cpp @@ -0,0 +1,142 @@ +// VoxelForgeLargeSeedTest.cpp +// AUDIT §C1 — le monde doit rester un monde quand la seed est grande. +// AUDIT C1 — the world must still be a world at a large seed. +// +// LE BUG / THE BUG +// Les sites de bruit s'écrivaient `WorldX * Freq + (float)Seed * 97.7f`. Un float a 24 bits de +// mantisse, donc à magnitude `V` l'ULP vaut `V · 2⁻²³` : +// +// Seed = 1 000 → terme 9.8e4 → ULP 0.012 → correct +// Seed = 100 000 → terme 9.8e6 → ULP 1.2 → le bruit se cale sur un treillis +// Seed = 10 000 000 → terme 9.8e8 → ULP 117 → la coordonnée du voxel (~0.02/voxel) est +// ENTIÈREMENT absorbée ⇒ champ CONSTANT +// +// `ChangeSeed(int32)` est `BlueprintCallable` : un `FMath::Rand()` (jusqu'à 2³¹) suffit à produire +// un monde plat. Ça n'a jamais été vu parce que les seeds de test restaient petites — et la fixture +// des autres tests garde délibérément une petite seed, ce qui veut dire qu'**aucun autre test de ce +// dossier ne peut voir ce bug**. +// +// ⚠️ POURQUOI LES TESTS D'ÉQUIVALENCE NE L'AURAIENT JAMAIS ATTRAPÉ +// Ils comparent la pile d'opérateurs au `switch` d'archétype. Les deux lisent la MÊME expression +// fautive, donc les deux s'effondrent EXACTEMENT DE LA MÊME FAÇON à grande seed : bit-identiques, +// verts, et tous les deux plats. Un oracle qui partage le bug de l'implémentation ne le voit pas. +// **Ce test-ci ne compare rien à rien : il vérifie une PROPRIÉTÉ** — le terrain doit varier. +// +// The equivalence tests compare the op stack to the archetype switch. Both read the same faulty +// expression, so at a large seed both collapse identically: bit-identical, green, and both flat. An +// oracle that shares the implementation's bug cannot see it. This test asserts a PROPERTY instead. +// +// LE CORRECTIF, ET POURQUOI L'ÉVIDENT ÉTAIT FAUX +// Borner `SeedF` en gardant le `· 97.7` laisse le terme atteindre 1.6e6 (ULP 0.19 = 9.5× le pas par +// voxel) : moins spectaculaire, toujours cassé, ticket refermé. C'est le MULTIPLICATEUR qu'il faut +// supprimer. `VoxelHash::SeedOffset(Seed, SiteKey)` rend un décalage déjà dans les unités finales, +// borné à [0, 16383], salé par site — donc deux seeds doivent collisionner sur les ~50 sites à la +// fois pour donner le même monde, au lieu d'un seul bucket partagé. + +#if WITH_DEV_AUTOMATION_TESTS + +#include "Misc/AutomationTest.h" + +#include "VoxelForgeTestFixture.h" +#include "VoxelGenerator.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeLargeSeedTest, + "VoxelForge.Determinism.LargeSeedSurvives", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +namespace +{ + /** Les seeds à éprouver. La première est le régime « ça marchait par chance », les suivantes + * sont là où le champ s'effondrait. La dernière est ce qu'un `FMath::Rand()` produit. */ + const int32 SeedsUnderTest[] = { 1337, 100000, 10000000, 2000000000 }; + + /** Combien de hauteurs distinctes faut-il pour dire « ce n'est pas plat » ? Un champ effondré + * rend UNE valeur (ou deux ou trois par effet de bord d'arrondi). Un terrain sain en rend des + * centaines sur 400 échantillons. Le seuil est bas exprès : on teste « le bruit existe-t-il + * encore », pas « est-il joli ». */ + constexpr int32 MinDistinctHeights = 50; +} + +bool FVoxelForgeLargeSeedTest::RunTest(const FString& Parameters) +{ + using namespace VoxelForgeTest; + + bool bAnyCollapse = false; + + for (const int32 Seed : SeedsUnderTest) + { + FTestWorld World; + World.Build(Seed); + if (!World.IsValid()) + { + AddError(FString::Printf(TEXT("Seed %d: %s"), Seed, *World.WhyInvalid())); + continue; + } + + int32 TopVoxelZ = 0, BottomVoxelZ = 0; + if (!World.GetSlotVoxelZRange(FTestWorld::SlotSurfaceWorld, TopVoxelZ, BottomVoxelZ)) + { + AddError(TEXT("The fixture layout has no SurfaceWorld slot.")); + return false; + } + + const UVoxelStrateDefinition* Def = + World.StrateManager->GetStrateForChunk( + FIntVector(0, 0, ((TopVoxelZ + BottomVoxelZ) / 2) / CHUNK_SIZE)); + if (!Def) { AddError(TEXT("No SurfaceWorld definition.")); return false; } + + FSurfaceGenerationParams P = Def->SurfaceParams; + P.StrateTopWorldZ = (float)TopVoxelZ; + P.StrateBottomWorldZ = (float)BottomVoxelZ; + + // Échantillonner le HEIGHTFIELD plutôt que la densité : c'est là que le bruit vit, et une + // hauteur est directement lisible ("le terrain est-il plat ?") là où une densité demande + // d'être interprétée. + TSet DistinctBits; + float MinH = FLT_MAX, MaxH = -FLT_MAX; + + const UVoxelGenerator* Gen = World.Generator.Get(); + for (int32 iy = 0; iy < 20; ++iy) + for (int32 ix = 0; ix < 20; ++ix) + { + // Pas de 7 voxels : assez large pour traverser plusieurs cellules de bruit, assez + // petit pour rester dans une région cohérente. + const float X = (float)(ix * 7); + const float Y = (float)(iy * 7); + const float H = Gen->ComputeSurfaceTerrainZ(X, Y, P); + + DistinctBits.Add(*reinterpret_cast(&H)); + MinH = FMath::Min(MinH, H); + MaxH = FMath::Max(MaxH, H); + } + + const int32 NumDistinct = DistinctBits.Num(); + const float Range = MaxH - MinH; + + if (NumDistinct < MinDistinctHeights) + { + bAnyCollapse = true; + AddError(FString::Printf( + TEXT("SEED %d COLLAPSED THE NOISE FIELD: only %d distinct heights across 400 ") + TEXT("samples (range %.4f voxels). This is AUDIT C1 — a seed offset large enough ") + TEXT("that the float ULP swallows the voxel coordinate, so the noise input is ") + TEXT("constant across many voxels and the terrain goes flat. Check that every noise ") + TEXT("site uses VoxelHash::SeedOffset(SeedU, K) and that no `SeedF * K` pattern has ") + TEXT("come back."), + Seed, NumDistinct, Range)); + } + else + { + AddInfo(FString::Printf( + TEXT("Seed %d: %d distinct heights across 400 samples, range %.2f voxels. Field alive."), + Seed, NumDistinct, Range)); + } + } + + TestFalse(TEXT("no seed collapses the noise field (AUDIT C1)"), bAnyCollapse); + + return true; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h b/Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h index 163af06..518b1bd 100644 --- a/Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h +++ b/Source/VoxelForge/Private/Tests/VoxelForgeTestFixture.h @@ -62,9 +62,16 @@ namespace VoxelForgeTest int32 BottomChunkZ = 0; /** - * Build the world. Seed stays SMALL on purpose: AUDIT C1 (unbounded SeedF) is a real - * open bug and a large seed would collapse the noise fields to constants, which would - * make a purity test pass trivially for the wrong reason. + * Build the world. + * + * The default seed stays SMALL, but the reason has changed. It USED to be a workaround: + * AUDIT §C1 (unbounded `SeedF`) meant a large seed collapsed the noise fields to constants, + * which would have made a purity test pass trivially for the wrong reason. + * + * **§C1 is fixed** (`VoxelHash::SeedOffset` — bounded and site-salted). The small default + * now just keeps failure messages comparable across tests. A large seed is no longer + * dangerous — and `VoxelForge.Determinism.LargeSeedSurvives` deliberately passes big ones + * (up to 2e9) to prove it stays that way. */ void Build(int32 InSeed = 1337, int32 InGapChunks = 2) { diff --git a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 22627db..8c92c0e 100644 --- a/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -254,7 +254,7 @@ namespace { public: FSlabVoidSource(const FSlabGenerationParams& P, int32 Seed) - : SeedF((float)Seed) + : SeedU((uint32)Seed) , FloorRoughness(P.FloorRoughness) , FloorFrequency(P.FloorRoughnessFrequency) , CeilRoughness(P.CeilingRoughness) @@ -364,8 +364,8 @@ namespace { if (FloorRoughness <= 0.0f) { return FloorZ; } const float FF = FloorFrequency; - const FVector NoisePos(WorldX * FF + SeedF * 7.3f, - WorldY * FF + SeedF * 11.1f, + const FVector NoisePos(WorldX * FF + VoxelHash::SeedOffset(SeedU, 7.3f), + WorldY * FF + VoxelHash::SeedOffset(SeedU, 11.1f), 0.0f); const float N = VoxelNoise::FBM((float)NoisePos.X, (float)NoisePos.Y, (float)NoisePos.Z, VoxelGenLOD::Eff(3), 2.0f, 0.5f) @@ -379,8 +379,8 @@ namespace if (CeilRoughness > 0.0f) { const float CF = CeilFrequency; - const FVector NoisePos(WorldX * CF + SeedF * 17.3f + 1000.0f, - WorldY * CF + SeedF * 19.7f + 2000.0f, + const FVector NoisePos(WorldX * CF + VoxelHash::SeedOffset(SeedU, 17.3f) + 1000.0f, + WorldY * CF + VoxelHash::SeedOffset(SeedU, 19.7f) + 2000.0f, 3000.0f); const float Raw = VoxelNoise::FBM((float)NoisePos.X, (float)NoisePos.Y, (float)NoisePos.Z, VoxelGenLOD::Eff(3), 2.0f, 0.5f) @@ -391,7 +391,7 @@ namespace return FMath::Max(CeilZ - CeilNoise, FloorSurface + 2.0f); } - float SeedF; + uint32 SeedU; float FloorZ = 0.0f, CeilZ = 0.0f; float FloorRoughness, FloorFrequency; float CeilRoughness, CeilFrequency; @@ -573,7 +573,7 @@ namespace public: FOverhangShelfMod(const FSurfaceGenerationParams& InP, int32 Seed, const FSurfaceColumnSource* InColumn) - : P(InP), SeedF((float)Seed), Column(InColumn) {} + : P(InP), SeedU((uint32)Seed), Column(InColumn) {} EVoxelOpRole GetRole() const override { return EVoxelOpRole::DetailModifier; } void PrepareChunk(const FVoxelOpContext&) override {} @@ -594,9 +594,9 @@ namespace // Bruit de forme d'étagère [0,1] ; le terme en Z fait onduler la portée avec la hauteur // (déchiqueté, pas une lèvre lisse). const float Ns = HFractal3D(FVector( - WorldX * f + SeedF * 17.3f, - WorldY * f + SeedF * 23.9f, - WorldZ * f * P.OverhangZScale + SeedF * 5.1f), 3) * 0.5f + 0.5f; // [0,1] + WorldX * f + VoxelHash::SeedOffset(SeedU, 17.3f), + WorldY * f + VoxelHash::SeedOffset(SeedU, 23.9f), + WorldZ * f * P.OverhangZScale + VoxelHash::SeedOffset(SeedU, 5.1f)), 3) * 0.5f + 0.5f; // [0,1] // LA CLÉ : la portée amont CROÎT avec la hauteur dans la fenêtre (Frac : 0 au sol → 1 // au plafond de la fenêtre). En bas le décalage est minuscule ⇒ on emprunte de la roche @@ -626,7 +626,7 @@ namespace private: FSurfaceGenerationParams P; - float SeedF; + uint32 SeedU; const FSurfaceColumnSource* Column; // NON possédant : la pile possède la source }; diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 09ddacd..d4ab32a 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -760,7 +760,7 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo // At the end, we negate for the MC table (negative = solid there). //========================================================================= - const float SeedF = (float)Seed; + const uint32 SeedU = (uint32)Seed; //========================================================================= // STEP 1: VERTICAL SCALE @@ -810,17 +810,17 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo // so the X/Y/Z warp channels don't correlate with each other. // Single octave to keep per-voxel cost low (3 Perlin calls total). WarpedX += VoxelNoise::Perlin3D(FVector( - WorldX * WF + SeedF * 0.37f, + WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.37f), WorldY * WF + 1.3f, EffectiveZ * WF + 5.7f)) * VOXEL_NOISE_SCALE * WS; WarpedY += VoxelNoise::Perlin3D(FVector( WorldX * WF + 7.1f, - WorldY * WF + SeedF * 0.59f, + WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.59f), EffectiveZ * WF + 2.3f)) * VOXEL_NOISE_SCALE * WS; WarpedZ += VoxelNoise::Perlin3D(FVector( WorldX * WF + 11.3f, WorldY * WF + 9.7f, - EffectiveZ * WF + SeedF * 0.41f)) * VOXEL_NOISE_SCALE * WS; + EffectiveZ * WF + VoxelHash::SeedOffset(SeedU, 0.41f))) * VOXEL_NOISE_SCALE * WS; } //========================================================================= @@ -1069,14 +1069,14 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo // Base noise input positions (with seed offsets for uniqueness) FVector MainPos( - WorldX * RF + SeedF * 11.3f, - WorldY * RF + SeedF * 13.7f, - EffectiveZ * RF + SeedF * 17.1f + WorldX * RF + VoxelHash::SeedOffset(SeedU, 11.3f), + WorldY * RF + VoxelHash::SeedOffset(SeedU, 13.7f), + EffectiveZ * RF + VoxelHash::SeedOffset(SeedU, 17.1f) ); FVector FinePos( - WorldX * RF * 3.0f + SeedF * 19.1f + 2000.0f, - WorldY * RF * 3.0f + SeedF * 23.7f + 2500.0f, - EffectiveZ * RF * 3.0f + SeedF * 29.3f + 3000.0f + WorldX * RF * 3.0f + VoxelHash::SeedOffset(SeedU, 19.1f) + 2000.0f, + WorldY * RF * 3.0f + VoxelHash::SeedOffset(SeedU, 23.7f) + 2500.0f, + EffectiveZ * RF * 3.0f + VoxelHash::SeedOffset(SeedU, 29.3f) + 3000.0f ); // DOMAIN WARPING: distort noise coordinates with a secondary field. @@ -1090,21 +1090,21 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo // Sample three independent noise fields for X, Y, Z warp float WarpX = VoxelNoise::Perlin3D(FVector( - WorldX * WF + SeedF * 5.2f, - WorldY * WF + SeedF * 1.3f, - EffectiveZ * WF + SeedF * 9.7f + WorldX * WF + VoxelHash::SeedOffset(SeedU, 5.2f), + WorldY * WF + VoxelHash::SeedOffset(SeedU, 1.3f), + EffectiveZ * WF + VoxelHash::SeedOffset(SeedU, 9.7f) )) * VOXEL_NOISE_SCALE * WS; float WarpY = VoxelNoise::Perlin3D(FVector( - WorldX * WF + 100.0f + SeedF * 7.7f, - WorldY * WF + 200.0f + SeedF * 3.1f, + WorldX * WF + 100.0f + VoxelHash::SeedOffset(SeedU, 7.7f), + WorldY * WF + 200.0f + VoxelHash::SeedOffset(SeedU, 3.1f), EffectiveZ * WF + 300.0f )) * VOXEL_NOISE_SCALE * WS; float WarpZ = VoxelNoise::Perlin3D(FVector( WorldX * WF + 400.0f, - WorldY * WF + 500.0f + SeedF * 11.9f, - EffectiveZ * WF + 600.0f + SeedF * 13.3f + WorldY * WF + 500.0f + VoxelHash::SeedOffset(SeedU, 11.9f), + EffectiveZ * WF + 600.0f + VoxelHash::SeedOffset(SeedU, 13.3f) )) * VOXEL_NOISE_SCALE * WS; // Apply warp to both noise positions @@ -1261,9 +1261,9 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo if (Params.TerraceNoiseDisplacement > 0.0f) { float DispNoise = FractalNoise3D(FVector( - WorldX * 0.04f + SeedF * 31.1f, - WorldY * 0.04f + SeedF * 37.3f, - WorldZ * 0.02f + SeedF * 41.7f + WorldX * 0.04f + VoxelHash::SeedOffset(SeedU, 31.1f), + WorldY * 0.04f + VoxelHash::SeedOffset(SeedU, 37.3f), + WorldZ * 0.02f + VoxelHash::SeedOffset(SeedU, 41.7f) ), VoxelGenLOD::Eff(2)) * VOXEL_NOISE_SCALE; NoisedZ += DispNoise * Params.TerraceNoiseDisplacement * StepH; } @@ -1387,9 +1387,9 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo { // Low Z frequency (0.15x of XY) → features extend horizontally float OverhangNoise = FractalNoise3D(FVector( - WorldX * Params.OverhangFrequency + SeedF * 53.1f, - WorldY * Params.OverhangFrequency + SeedF * 59.3f, - EffectiveZ * Params.OverhangFrequency * 0.15f + SeedF * 61.7f + WorldX * Params.OverhangFrequency + VoxelHash::SeedOffset(SeedU, 53.1f), + WorldY * Params.OverhangFrequency + VoxelHash::SeedOffset(SeedU, 59.3f), + EffectiveZ * Params.OverhangFrequency * 0.15f + VoxelHash::SeedOffset(SeedU, 61.7f) ), VoxelGenLOD::Eff(2)) * VOXEL_NOISE_SCALE; // Only where noise is positive → protrusions (not recesses) @@ -1427,9 +1427,9 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo // side we're on: negative = inside cave, positive = solid rock. // We use a noise-modulated vertical gradient to detect steep faces. float VertGrad = VoxelNoise::Perlin3D(FVector( - WorldX * 0.05f + SeedF * 71.3f, - WorldY * 0.05f + SeedF * 73.7f, - EffectiveZ * 0.15f + SeedF * 79.1f // 3x faster in Z → detects vertical features + WorldX * 0.05f + VoxelHash::SeedOffset(SeedU, 71.3f), + WorldY * 0.05f + VoxelHash::SeedOffset(SeedU, 73.7f), + EffectiveZ * 0.15f + VoxelHash::SeedOffset(SeedU, 79.1f) // 3x faster in Z → detects vertical features )) * VOXEL_NOISE_SCALE; // VertGrad near ±1 means terrain is changing fast vertically. @@ -1468,9 +1468,9 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo // Cellular noise: returns ~[-1, 1] where positive = cell interior (bowl) float SF = Params.ScallopFrequency; float ScallopNoise = CellularNoise3D(FVector( - WorldX * SF + SeedF * 83.1f, - WorldY * SF + SeedF * 89.3f, - EffectiveZ * SF + SeedF * 97.7f + WorldX * SF + VoxelHash::SeedOffset(SeedU, 83.1f), + WorldY * SF + VoxelHash::SeedOffset(SeedU, 89.3f), + EffectiveZ * SF + VoxelHash::SeedOffset(SeedU, 97.7f) )); // Only carve where noise is positive (cell interiors = bowl centers) @@ -1763,9 +1763,9 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo float WormZFreq = Params.WormFrequency * Params.WormHorizontalBias; float N1 = FMath::Abs(VoxelNoise::Perlin3D(FVector( - WorldX * Params.WormFrequency + SeedF, - WorldY * Params.WormFrequency + SeedF * 1.7f, - EffectiveZ * WormZFreq + SeedF * 2.3f + WorldX * Params.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.0f), + WorldY * Params.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.7f), + EffectiveZ * WormZFreq + VoxelHash::SeedOffset(SeedU, 2.3f) )) * VOXEL_NOISE_SCALE); // N2 >= 0, so if N1 alone already clears the threshold the sum can't carve — @@ -1773,9 +1773,9 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo if (N1 < Params.WormThreshold) { float N2 = FMath::Abs(VoxelNoise::Perlin3D(FVector( - WorldX * Params.WormFrequency + SeedF + 137.0f, - WorldY * Params.WormFrequency + SeedF * 1.7f + 259.0f, - EffectiveZ * WormZFreq + SeedF * 2.3f + 431.0f + WorldX * Params.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.0f) + 137.0f, + WorldY * Params.WormFrequency + VoxelHash::SeedOffset(SeedU, 1.7f) + 259.0f, + EffectiveZ * WormZFreq + VoxelHash::SeedOffset(SeedU, 2.3f) + 431.0f )) * VOXEL_NOISE_SCALE); float WormValue = N1 + N2; @@ -1834,7 +1834,7 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ, // Degenerate strate (zero or inverted bounds) — return solid. if (StrateHeight <= 0.0f) return 1.0f; - const float SeedF = (float)Seed; + const uint32 SeedU = (uint32)Seed; //========================================================================= // STEP 1: FLOOR SURFACE @@ -1862,8 +1862,8 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ, { float FF = Params.FloorRoughnessFrequency; FloorNoise = FractalNoise3D(FVector( - WorldX * FF + SeedF * 7.3f, - WorldY * FF + SeedF * 11.1f, + WorldX * FF + VoxelHash::SeedOffset(SeedU, 7.3f), + WorldY * FF + VoxelHash::SeedOffset(SeedU, 11.1f), 0.0f // XY-pur : plus aucune dépendance en Z / no Z dependence ), VoxelGenLOD::Eff(3)) * VOXEL_NOISE_SCALE * Params.FloorRoughness; } @@ -1893,8 +1893,8 @@ float UVoxelGenerator::GetSlabDensity(float WorldX, float WorldY, float WorldZ, { float CF = Params.CeilingRoughnessFrequency; float RawNoise = FractalNoise3D(FVector( - WorldX * CF + SeedF * 17.3f + 1000.0f, - WorldY * CF + SeedF * 19.7f + 2000.0f, + WorldX * CF + VoxelHash::SeedOffset(SeedU, 17.3f) + 1000.0f, + WorldY * CF + VoxelHash::SeedOffset(SeedU, 19.7f) + 2000.0f, 3000.0f // XY-pur : décalage de décorrélation seul / offset only ), VoxelGenLOD::Eff(3)) * VOXEL_NOISE_SCALE; @@ -2154,7 +2154,7 @@ float UVoxelGenerator::SampleSurfaceStructuralZ(float WorldX, float WorldY, const FSurfaceGenerationParams& Params, float& OutM) const { const float H = Params.StrateTopWorldZ - Params.StrateBottomWorldZ; - const float SeedF = (float)Seed; + const uint32 SeedU = (uint32)Seed; const float BottomZ = Params.StrateBottomWorldZ; // --- Heightfield (a function of XY only — Z is a fixed seed slice) --- @@ -2167,8 +2167,8 @@ float UVoxelGenerator::SampleSurfaceStructuralZ(float WorldX, float WorldY, if (Params.HeightWarpStrength > 0.0f) { const float WF = Params.HeightWarpFrequency; - const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + SeedF * 0.31f, WorldY * WF + 4.2f, SeedF * 1.7f)); - const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 8.6f, WorldY * WF + SeedF * 0.53f, SeedF * 2.9f)); + const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.31f), WorldY * WF + 4.2f, VoxelHash::SeedOffset(SeedU, 1.7f))); + const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 8.6f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.53f), VoxelHash::SeedOffset(SeedU, 2.9f))); QX += wx * VOXEL_NOISE_SCALE * Params.HeightWarpStrength; QY += wy * VOXEL_NOISE_SCALE * Params.HeightWarpStrength; } @@ -2179,14 +2179,14 @@ float UVoxelGenerator::SampleSurfaceStructuralZ(float WorldX, float WorldY, const float M = FMath::Lerp(1.0f, Relief, Params.ReliefStrength); float Cont = FractalNoise3D(FVector( - QX * Params.ContinentFrequency + SeedF * 3.1f, - QY * Params.ContinentFrequency + SeedF * 5.7f, - SeedF * 0.7f), 4); // [-1,1] + QX * Params.ContinentFrequency + VoxelHash::SeedOffset(SeedU, 3.1f), + QY * Params.ContinentFrequency + VoxelHash::SeedOffset(SeedU, 5.7f), + VoxelHash::SeedOffset(SeedU, 0.7f)), 4); // [-1,1] float Detail = FractalNoise3D(FVector( WorldX * Params.DetailFrequency + 11.0f, WorldY * Params.DetailFrequency + 22.0f, - SeedF * 1.3f), 3); // [-1,1] + VoxelHash::SeedOffset(SeedU, 1.3f)), 3); // [-1,1] float Mountain = 0.0f; if (Params.MountainStrength > 0.0f) @@ -2194,7 +2194,7 @@ float UVoxelGenerator::SampleSurfaceStructuralZ(float WorldX, float WorldY, float Ridge = RidgedNoise3D(FVector( QX * Params.MountainFrequency + 99.0f, QY * Params.MountainFrequency + 77.0f, - SeedF * 0.9f), 4); // [-1,1] + VoxelHash::SeedOffset(SeedU, 0.9f)), 4); // [-1,1] Ridge = Ridge * 0.5f + 0.5f; // [0,1] peaks Mountain = Ridge * Params.MountainStrength * M; // mountains rise only in high-relief regions } @@ -2296,7 +2296,7 @@ float UVoxelGenerator::ComputeSurfaceCeiling(float WorldX, float WorldY, const FSurfaceGenerationParams& Params) const { const float H = Params.StrateTopWorldZ - Params.StrateBottomWorldZ; - const float SeedF = (float)Seed; + const uint32 SeedU = (uint32)Seed; float CeilZ = Params.StrateBottomWorldZ + H * Params.CeilingRelative; // Domain-warp the broad/ridge query coords so ceiling ridgelines and valleys wind @@ -2306,8 +2306,8 @@ float UVoxelGenerator::ComputeSurfaceCeiling(float WorldX, float WorldY, if (Params.CeilingWarpStrength > 0.0f) { const float WF = Params.CeilingWarpFrequency; - const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + SeedF * 0.71f, WorldY * WF + 2.3f, SeedF * 3.3f)); - const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 6.1f, WorldY * WF + SeedF * 0.19f, SeedF * 4.7f)); + const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.71f), WorldY * WF + 2.3f, VoxelHash::SeedOffset(SeedU, 3.3f))); + const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 6.1f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.19f), VoxelHash::SeedOffset(SeedU, 4.7f))); QX += wx * VOXEL_NOISE_SCALE * Params.CeilingWarpStrength; QY += wy * VOXEL_NOISE_SCALE * Params.CeilingWarpStrength; } @@ -2316,9 +2316,9 @@ float UVoxelGenerator::ComputeSurfaceCeiling(float WorldX, float WorldY, if (Params.CeilingUndulation > 0.0f) { const float Swell = FractalNoise3D(FVector( - QX * Params.CeilingUndulationFrequency + SeedF * 1.9f, + QX * Params.CeilingUndulationFrequency + VoxelHash::SeedOffset(SeedU, 1.9f), QY * Params.CeilingUndulationFrequency + 13.0f, - SeedF * 0.5f), 3); // [-1,1] + VoxelHash::SeedOffset(SeedU, 0.5f)), 3); // [-1,1] CeilZ += Swell * VOXEL_NOISE_SCALE * Params.CeilingUndulation; } @@ -2330,14 +2330,14 @@ float UVoxelGenerator::ComputeSurfaceCeiling(float WorldX, float WorldY, Hang += FMath::Abs(FractalNoise3D(FVector( WorldX * Params.CeilingRoughnessFrequency + 5.0f, WorldY * Params.CeilingRoughnessFrequency + 6.0f, - SeedF * 2.1f), 3)) * VOXEL_NOISE_SCALE * Params.CeilingRoughness; + VoxelHash::SeedOffset(SeedU, 2.1f)), 3)) * VOXEL_NOISE_SCALE * Params.CeilingRoughness; } if (Params.CeilingRidgeStrength > 0.0f) { float Ridge = RidgedNoise3D(FVector( QX * Params.CeilingRidgeFrequency + 31.0f, QY * Params.CeilingRidgeFrequency + 47.0f, - SeedF * 1.1f), 4); // [-1,1] + VoxelHash::SeedOffset(SeedU, 1.1f)), 4); // [-1,1] Ridge = Ridge * 0.5f + 0.5f; // [0,1] hanging ridgelines Hang += Ridge * Params.CeilingRidgeStrength; } @@ -2363,13 +2363,13 @@ float UVoxelGenerator::SurfaceDensityFromColumn(float WorldX, float WorldY, floa if (OverhangAmp > 0.0f && S.OverhangHeight > 0.0f && WorldZ > TerrainZ && WorldZ <= TerrainZ + S.OverhangHeight) { - const float SeedF = (float)Seed; + const uint32 SeedU = (uint32)Seed; const float f = S.OverhangFrequency; // Shelf-shape noise [0,1]; the Z term makes the reach fold/curl with height (ragged, not a lip). const float Ns = FractalNoise3D(FVector( - WorldX * f + SeedF * 17.3f, - WorldY * f + SeedF * 23.9f, - WorldZ * f * S.OverhangZScale + SeedF * 5.1f), 3) * 0.5f + 0.5f; // [0,1] + WorldX * f + VoxelHash::SeedOffset(SeedU, 17.3f), + WorldY * f + VoxelHash::SeedOffset(SeedU, 23.9f), + WorldZ * f * S.OverhangZScale + VoxelHash::SeedOffset(SeedU, 5.1f)), 3) * 0.5f + 0.5f; // [0,1] // KEY: the uphill reach GROWS with height in the window (Frac: 0 at ground → 1 at the cap). Low // down the shift is tiny ⇒ borrows nearby low rock ⇒ stays AIR over the void; high up the shift // reaches the far cliff ⇒ solid ⇒ the lip sits on top with air UNDERNEATH = a real overhang. @@ -2789,24 +2789,24 @@ float UVoxelGenerator::GetSurfaceDensity(float WorldX, float WorldY, float World float UVoxelGenerator::SampleRelief(float WorldX, float WorldY, float Frequency, float Contrast) const { - const float SeedF = (float)Seed; + const uint32 SeedU = (uint32)Seed; // Same offsets/octaves as the original SurfaceWorld relief so existing worlds are // unchanged (this is the function that code path now calls). float R = FractalNoise3D(FVector( - WorldX * Frequency + SeedF * 7.3f, - WorldY * Frequency + SeedF * 2.1f, - SeedF * 0.5f), 2) * 0.5f + 0.5f; // [0,1] + WorldX * Frequency + VoxelHash::SeedOffset(SeedU, 7.3f), + WorldY * Frequency + VoxelHash::SeedOffset(SeedU, 2.1f), + VoxelHash::SeedOffset(SeedU, 0.5f)), 2) * 0.5f + 0.5f; // [0,1] R = FMath::Clamp((R - 0.5f) * Contrast + 0.5f, 0.0f, 1.0f); return SmoothStep01(R); } float UVoxelGenerator::SampleMoisture(float WorldX, float WorldY, float Frequency) const { - const float SeedF = (float)Seed; + const uint32 SeedU = (uint32)Seed; const float N = FractalNoise3D(FVector( - WorldX * Frequency + SeedF * 4.7f, - WorldY * Frequency + SeedF * 8.9f, - SeedF * 1.3f), 2) * 0.5f + 0.5f; // [0,1] + WorldX * Frequency + VoxelHash::SeedOffset(SeedU, 4.7f), + WorldY * Frequency + VoxelHash::SeedOffset(SeedU, 8.9f), + VoxelHash::SeedOffset(SeedU, 1.3f)), 2) * 0.5f + 0.5f; // [0,1] return FMath::Clamp(N, 0.0f, 1.0f); } @@ -2885,10 +2885,10 @@ FBiomeSample UVoxelGenerator::SampleBiomeAt(float WorldX, float WorldY, const FB float QX = WorldX, QY = WorldY; if (MP.WarpStrength > 0.0f) { - const float SeedF = (float)Seed; + const uint32 SeedU = (uint32)Seed; const float WF = MP.WarpFrequency; - const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + SeedF * 0.27f, WorldY * WF + 3.1f, SeedF * 1.1f)); - const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 7.7f, WorldY * WF + SeedF * 0.61f, SeedF * 2.3f)); + const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.27f), WorldY * WF + 3.1f, VoxelHash::SeedOffset(SeedU, 1.1f))); + const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 7.7f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.61f), VoxelHash::SeedOffset(SeedU, 2.3f))); QX += wx * VOXEL_NOISE_SCALE * MP.WarpStrength; QY += wy * VOXEL_NOISE_SCALE * MP.WarpStrength; } @@ -3017,10 +3017,10 @@ FBiomeSample UVoxelGenerator::ResolveBiomeSampleAt(float WorldX, float WorldY, i float QX = WorldX, QY = WorldY; if (MP.WarpStrength > 0.0f) { - const float SeedF = (float)Seed; + const uint32 SeedU = (uint32)Seed; const float WF = MP.WarpFrequency; - const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + SeedF * 0.27f, WorldY * WF + 3.1f, SeedF * 1.1f)); - const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 7.7f, WorldY * WF + SeedF * 0.61f, SeedF * 2.3f)); + const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.27f), WorldY * WF + 3.1f, VoxelHash::SeedOffset(SeedU, 1.1f))); + const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 7.7f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.61f), VoxelHash::SeedOffset(SeedU, 2.3f))); QX += wx * VOXEL_NOISE_SCALE * MP.WarpStrength; QY += wy * VOXEL_NOISE_SCALE * MP.WarpStrength; } diff --git a/Source/VoxelForge/Private/VoxelHeightOpStack.cpp b/Source/VoxelForge/Private/VoxelHeightOpStack.cpp index 720276c..4dae318 100644 --- a/Source/VoxelForge/Private/VoxelHeightOpStack.cpp +++ b/Source/VoxelForge/Private/VoxelHeightOpStack.cpp @@ -17,6 +17,7 @@ #include "VoxelHeightOp.h" +#include "VoxelCaveMorphology.h" // VoxelHash::SeedOffset — AUDIT §C1 (bounded, site-salted) #include "VoxelNoise.h" // VoxelNoise::FBM / Ridged / Perlin3D #include "VoxelTypes.h" // SmoothStep01, VOXEL_NOISE_SCALE @@ -45,13 +46,13 @@ namespace /** Transcription de `UVoxelGenerator::SampleRelief`. Champ [0,1] partagé avec la carte de * biomes, pour que la géographie et le terrain qu'elle module restent d'accord. */ - FORCEINLINE float HSampleRelief(float WorldX, float WorldY, float SeedF, + FORCEINLINE float HSampleRelief(float WorldX, float WorldY, uint32 SeedU, float Frequency, float Contrast) { float R = HFractalNoise3D(FVector( - WorldX * Frequency + SeedF * 7.3f, - WorldY * Frequency + SeedF * 2.1f, - SeedF * 0.5f), 2) * 0.5f + 0.5f; // [0,1] + WorldX * Frequency + VoxelHash::SeedOffset(SeedU, 7.3f), + WorldY * Frequency + VoxelHash::SeedOffset(SeedU, 2.1f), + VoxelHash::SeedOffset(SeedU, 0.5f)), 2) * 0.5f + 0.5f; // [0,1] R = FMath::Clamp((R - 0.5f) * Contrast + 0.5f, 0.0f, 1.0f); return SmoothStep01(R); } @@ -64,7 +65,7 @@ namespace { public: FStructuralHeightSource(const FSurfaceGenerationParams& InP, int32 InSeed) - : P(InP), SeedF((float)InSeed) {} + : P(InP), SeedU((uint32)InSeed) {} void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const override { @@ -92,24 +93,24 @@ namespace if (P.HeightWarpStrength > 0.0f) { const float WF = P.HeightWarpFrequency; - const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + SeedF * 0.31f, WorldY * WF + 4.2f, SeedF * 1.7f)); - const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 8.6f, WorldY * WF + SeedF * 0.53f, SeedF * 2.9f)); + const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.31f), WorldY * WF + 4.2f, VoxelHash::SeedOffset(SeedU, 1.7f))); + const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 8.6f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.53f), VoxelHash::SeedOffset(SeedU, 2.9f))); QX += wx * VOXEL_NOISE_SCALE * P.HeightWarpStrength; QY += wy * VOXEL_NOISE_SCALE * P.HeightWarpStrength; } - const float Relief = HSampleRelief(WorldX, WorldY, SeedF, P.ReliefFrequency, P.ReliefContrast); + const float Relief = HSampleRelief(WorldX, WorldY, SeedU, P.ReliefFrequency, P.ReliefContrast); const float M = FMath::Lerp(1.0f, Relief, P.ReliefStrength); float Cont = HFractalNoise3D(FVector( - QX * P.ContinentFrequency + SeedF * 3.1f, - QY * P.ContinentFrequency + SeedF * 5.7f, - SeedF * 0.7f), 4); // [-1,1] + QX * P.ContinentFrequency + VoxelHash::SeedOffset(SeedU, 3.1f), + QY * P.ContinentFrequency + VoxelHash::SeedOffset(SeedU, 5.7f), + VoxelHash::SeedOffset(SeedU, 0.7f)), 4); // [-1,1] float Detail = HFractalNoise3D(FVector( WorldX * P.DetailFrequency + 11.0f, WorldY * P.DetailFrequency + 22.0f, - SeedF * 1.3f), 3); // [-1,1] + VoxelHash::SeedOffset(SeedU, 1.3f)), 3); // [-1,1] float Mountain = 0.0f; if (P.MountainStrength > 0.0f) @@ -117,7 +118,7 @@ namespace float Ridge = HRidgedNoise3D(FVector( QX * P.MountainFrequency + 99.0f, QY * P.MountainFrequency + 77.0f, - SeedF * 0.9f), 4); // [-1,1] + VoxelHash::SeedOffset(SeedU, 0.9f)), 4); // [-1,1] Ridge = Ridge * 0.5f + 0.5f; // [0,1] sommets Mountain = Ridge * P.MountainStrength * M; // les montagnes ne montent qu'en haut relief } @@ -141,7 +142,7 @@ namespace private: FSurfaceGenerationParams P; - float SeedF; + uint32 SeedU; }; //========================================================================= @@ -290,7 +291,7 @@ namespace { public: FSkyCapHeightSource(const FSurfaceGenerationParams& InP, int32 InSeed) - : P(InP), SeedF((float)InSeed) {} + : P(InP), SeedU((uint32)InSeed) {} void Eval(float WorldX, float WorldY, FVoxelHeightSample& InOut) const override { @@ -303,8 +304,8 @@ namespace if (P.CeilingWarpStrength > 0.0f) { const float WF = P.CeilingWarpFrequency; - const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + SeedF * 0.71f, WorldY * WF + 2.3f, SeedF * 3.3f)); - const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 6.1f, WorldY * WF + SeedF * 0.19f, SeedF * 4.7f)); + const float wx = VoxelNoise::Perlin3D(FVector(WorldX * WF + VoxelHash::SeedOffset(SeedU, 0.71f), WorldY * WF + 2.3f, VoxelHash::SeedOffset(SeedU, 3.3f))); + const float wy = VoxelNoise::Perlin3D(FVector(WorldX * WF + 6.1f, WorldY * WF + VoxelHash::SeedOffset(SeedU, 0.19f), VoxelHash::SeedOffset(SeedU, 4.7f))); QX += wx * VOXEL_NOISE_SCALE * P.CeilingWarpStrength; QY += wy * VOXEL_NOISE_SCALE * P.CeilingWarpStrength; } @@ -313,9 +314,9 @@ namespace if (P.CeilingUndulation > 0.0f) { const float Swell = HFractalNoise3D(FVector( - QX * P.CeilingUndulationFrequency + SeedF * 1.9f, + QX * P.CeilingUndulationFrequency + VoxelHash::SeedOffset(SeedU, 1.9f), QY * P.CeilingUndulationFrequency + 13.0f, - SeedF * 0.5f), 3); // [-1,1] + VoxelHash::SeedOffset(SeedU, 0.5f)), 3); // [-1,1] CeilZ += Swell * VOXEL_NOISE_SCALE * P.CeilingUndulation; } @@ -327,14 +328,14 @@ namespace Hang += FMath::Abs(HFractalNoise3D(FVector( WorldX * P.CeilingRoughnessFrequency + 5.0f, WorldY * P.CeilingRoughnessFrequency + 6.0f, - SeedF * 2.1f), 3)) * VOXEL_NOISE_SCALE * P.CeilingRoughness; + VoxelHash::SeedOffset(SeedU, 2.1f)), 3)) * VOXEL_NOISE_SCALE * P.CeilingRoughness; } if (P.CeilingRidgeStrength > 0.0f) { float Ridge = HRidgedNoise3D(FVector( QX * P.CeilingRidgeFrequency + 31.0f, QY * P.CeilingRidgeFrequency + 47.0f, - SeedF * 1.1f), 4); // [-1,1] + VoxelHash::SeedOffset(SeedU, 1.1f)), 4); // [-1,1] Ridge = Ridge * 0.5f + 0.5f; // [0,1] lignes de crête pendantes Hang += Ridge * P.CeilingRidgeStrength; } @@ -347,7 +348,7 @@ namespace private: FSurfaceGenerationParams P; - float SeedF; + uint32 SeedU; }; } diff --git a/Source/VoxelForge/Public/VoxelCaveMorphology.h b/Source/VoxelForge/Public/VoxelCaveMorphology.h index a1f9b86..c3435ca 100644 --- a/Source/VoxelForge/Public/VoxelCaveMorphology.h +++ b/Source/VoxelForge/Public/VoxelCaveMorphology.h @@ -168,6 +168,43 @@ namespace VoxelHash return X; } + /** + * AUDIT §C1 — décalage de bruit BORNÉ et salé par site. Remplace le motif `SeedF * K`. + * + * LE BUG QUE ÇA CORRIGE : les sites de bruit s'écrivaient + * `WorldX * Freq + (float)Seed * 97.7f`. Le float a 24 bits de mantisse, donc à magnitude `V` + * l'ULP vaut `V · 2⁻²³`. Avec `Seed = 10⁷` le terme atteint 10⁹, où l'ULP vaut **117** — la + * coordonnée du voxel (qui avance de ~0.02 par voxel) est **entièrement absorbée** et le champ + * de bruit devient CONSTANT. Terrain plat. `ChangeSeed` est `BlueprintCallable`, donc un + * `FMath::Rand()` suffit à déclencher ça. Ça ne marchait que parce que les seeds restaient petits. + * + * ⚠️ LE CORRECTIF ÉVIDENT EST FAUX. Borner `SeedF` à 16383 en gardant le `· 97.7` laisse le + * terme atteindre 1.6e6, où l'ULP vaut 0.19 — **9.5× le pas par voxel**. Ça rend le bug moins + * spectaculaire tout en le laissant vivant, et referme le ticket. C'est le multiplicateur qu'il + * faut supprimer, pas le seed qu'il faut réduire. + * + * CE QUE FAIT CETTE FONCTION : le multiplicateur ne SERT plus à décorréler par amplification — + * il IDENTIFIE le site, et c'est le hash qui décorrèle. La sortie est déjà dans les unités + * finales, bornée à [0, 16383] : l'ULP y vaut 0.002, soit 10 % d'un pas de voxel. + * + * ET C'EST PLUS SÛR QU'UN SEEDF BORNÉ PARTAGÉ : avec un offset unique par monde, deux seeds qui + * collident donneraient un bruit identique PARTOUT. Salé par site, il faudrait qu'ils + * collident sur les ~50 sites à la fois — c'est-à-dire jamais. + * + * The multiplier no longer decorrelates by amplifying — it IDENTIFIES the site, and the hash + * decorrelates. Output is already in final units and bounded, so the ULP is 10% of a voxel step. + * + * @param SiteKey la constante littérale d'origine (`7.3f`, `97.7f`, …). Gardée VISIBLE au site + * d'appel pour que la correspondance avec le code d'avant reste vérifiable à l'œil. + */ + FORCEINLINE float SeedOffset(uint32 Seed, float SiteKey) + { + // ×100 puis arrondi : les constantes ont au plus 2 décimales, donc `0.31f` → 31 et + // `3.1f` → 310 restent distincts. Le site est une identité entière, pas un flottant. + const uint32 Site = (uint32)(SiteKey * 100.0f + 0.5f); + return (float)(Mix(Seed ^ (Site * 2654435761u)) & 0x3FFFu); // [0, 16383] + } + // Hash a 2D cell coordinate with a seed → deterministic uint32 FORCEINLINE uint32 Cell(int32 CellX, int32 CellY, uint32 Seed) {