fix: AUDIT C1 — bounded, site-salted seed offsets across all 85 noise sites

The op stack had already inherited this three times and every remaining port would
copy it again, so fixing it now is cheaper than after.

The audit's documented fix was wrong: bounding SeedF while keeping the * 97.7f
multiplier still reaches 1.6e6, where the ULP is 0.19 — 9.5x the per-voxel step.
Less spectacular, still broken, ticket closed.

VoxelHash::SeedOffset(Seed, SiteKey) inverts the roles: the multiplier no longer
decorrelates by amplifying, it IDENTIFIES the site, and the hash decorrelates.
Output is in final units, bounded to [0, 16383], so the ULP is 10% of a voxel step.
Site-salted, so two seeds must collide at all ~50 sites rather than sharing one
global bucket.

Safe to apply without compiling because the transformation is a pure regex and the
literal stays visible at the call site, so each line remains eye-checkable against
the original. Applied to all three files in one pass so the archetype switch and the
ported ops changed identically — had they not, the three equivalence tests would say
so. 62 + 7 + 16 sites, none left, plus two bare `+ SeedF` worm sites by hand.

New test VoxelForge.Determinism.LargeSeedSurvives (seeds up to 2e9) because the
equivalence tests are structurally blind to this: they compare the stack against the
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 bug
cannot see it. This test asserts a property instead of a comparison.

EXPECT EVERY WORLD TO LOOK DIFFERENT: this re-rolls every noise offset in the plugin.
Intended, and covered by OPSTACK-PLAN 2.6.1.

UNVERIFIED: not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 16:33:36 +02:00
parent f7ed9407bf
commit cd4cf216f5
7 changed files with 346 additions and 109 deletions
@@ -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<uint32> 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<const uint32*>(&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
@@ -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)
{
@@ -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
};
+73 -73
View File
@@ -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;
}
@@ -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;
};
}
@@ -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)
{