using System; using CultMath; using Xunit; namespace CultMath.Tests; public sealed class NoiseGradTests { // ---- Invariant-8 property harness (shared by snoise_grad, fbm_grad, ridged_grad) ---- // // Each test compares a function's analytic gradient with central differences of its own .w at // random points in [-3, 3]^3. eps trades curvature error (eps too large) against float32 // rounding noise (eps too small). snoise is continuous across simplex-cell boundaries (the // continuity test below), so no point needs excluding for a seam; ridged_grad excludes a band // around its own deliberate crease. Each tolerance is at least 5x the largest error the harness // itself produces at its seed. private const float PropertyEps = 0.0001f; private const float SnoiseGradTolerance = 0.03f; private const float FbmGradTolerance = 0.05f; private const float RidgedGradTolerance = 0.05f; [Fact] public void SnoiseGradGradientMatchesCentralDifferencesOfItsOwnValue() { var random = new System.Random(0xA11CE); var tested = 0; var attempts = 0; while (tested < 200 && attempts < 20000) { attempts++; var p = RandomPoint(random); var g = math.snoise_grad(p); float F(float3 q) => math.snoise_grad(q).w; var fine = CentralDifference(F, p, PropertyEps); tested++; AssertGradientWithinTolerance(fine, g, SnoiseGradTolerance); } Assert.True(tested >= 150, $"only {tested} of {attempts} points were usable"); } [Fact] public void FbmGradGradientMatchesCentralDifferencesOfItsOwnValue() { const int octaves = 4; const float lacunarity = 2.0f; const float gain = 0.5f; var random = new System.Random(0xA11CF); var tested = 0; var attempts = 0; while (tested < 200 && attempts < 40000) { attempts++; var p = RandomPoint(random); var g = math.fbm_grad(p, octaves, lacunarity, gain); float F(float3 q) => math.fbm_grad(q, octaves, lacunarity, gain).w; var fine = CentralDifference(F, p, PropertyEps); tested++; AssertGradientWithinTolerance(fine, g, FbmGradTolerance); } Assert.True(tested >= 150, $"only {tested} of {attempts} points were usable"); } [Fact] public void RidgedGradGradientMatchesCentralDifferencesExcludingAnyOctavesZeroBand() { // ridged_grad's own crease (any octave's n_i = 0) is a real, deliberate discontinuity // (design.md, "the operator's dune term"), not a numerical artifact: exclude a band around it // for every octave, the same way cellular excludes its F1 = F2 seam. const float zeroBand = 0.1f; const int octaves = 3; const float lacunarity = 2.0f; const float gain = 0.5f; var random = new System.Random(0xA11D0); var tested = 0; var attempts = 0; while (tested < 200 && attempts < 80000) { attempts++; var p = RandomPoint(random); if (AnyOctaveNearZero(p, octaves, lacunarity, zeroBand)) continue; var g = math.ridged_grad(p, octaves, lacunarity, gain); float F(float3 q) => math.ridged_grad(q, octaves, lacunarity, gain).w; var fine = CentralDifference(F, p, PropertyEps); tested++; AssertGradientWithinTolerance(fine, g, RidgedGradTolerance); } Assert.True(tested >= 150, $"only {tested} of {attempts} points were outside the zero band"); } private static bool AnyOctaveNearZero(float3 p, int octaves, float lacunarity, float band) { var frequency = 1.0f; for (var i = 0; i < octaves; i++) { if (MathF.Abs(math.snoise_grad(p * frequency).w) < band) return true; frequency *= lacunarity; } return false; } private static float3 RandomPoint(System.Random random) => new( random.NextSingle() * 6.0f - 3.0f, random.NextSingle() * 6.0f - 3.0f, random.NextSingle() * 6.0f - 3.0f); private static float3 CentralDifference(Func f, float3 p, float eps) => new( (f(p + new float3(eps, 0.0f, 0.0f)) - f(p - new float3(eps, 0.0f, 0.0f))) / (2.0f * eps), (f(p + new float3(0.0f, eps, 0.0f)) - f(p - new float3(0.0f, eps, 0.0f))) / (2.0f * eps), (f(p + new float3(0.0f, 0.0f, eps)) - f(p - new float3(0.0f, 0.0f, eps))) / (2.0f * eps)); private static void AssertGradientWithinTolerance(float3 numeric, float4 analytic, float tolerance) { AssertWithinTolerance(numeric.x, analytic.x, tolerance); AssertWithinTolerance(numeric.y, analytic.y, tolerance); AssertWithinTolerance(numeric.z, analytic.z, tolerance); } private static void AssertWithinTolerance(float expected, float actual, float tolerance) => Assert.True(MathF.Abs(expected - actual) <= tolerance, $"expected {expected}, actual {actual}, tolerance {tolerance}"); // ---- snoise is continuous across simplex-cell boundaries ---- // The simplex corner set (i, i1, i2) summed by snoise(float3), derived the same way snoise does. private static (float3 i, float3 i1, float3 i2) SimplexCell(float3 p) { var i = math.floor(p + math.dot(p, new float3(1.0f / 3.0f, 1.0f / 3.0f, 1.0f / 3.0f))); var x0 = p - i + math.dot(i, new float3(1.0f / 6.0f, 1.0f / 6.0f, 1.0f / 6.0f)); var g = new float3(math.step(x0.y, x0.x), math.step(x0.z, x0.y), 1.0f - math.step(x0.z, x0.x)); var l = 1.0f - g; var lzxy = new float3(l.z, l.x, l.y); return (i, math.min(g, lzxy), math.max(g, lzxy)); } [Fact] public void SnoiseAndItsGradientAreContinuousAcrossSimplexCellBoundaries() { // March random lines in steps of 1e-3 until the corner set changes, bisect the crossing to // float precision, and compare the two sides. With a kernel radius^2 above 0.5 a lattice // vertex outside the four summed corners can lie inside the kernel, which leaves a value // jump at the boundary (stegu/webgl-noise fixed it in 21d9fe23d7); at 0.5 the jump is float // noise. A value jump below 1e-5 and a gradient jump below 1e-4 is float noise on both sides. const int lines = 400; const int maxSteps = 20000; const float dt = 1.0e-3f; var random = new System.Random(0x5EA4); var crossings = 0; var maxValueJump = 0.0f; var maxGradientJump = 0.0f; for (var line = 0; line < lines; line++) { var origin = RandomPoint(random); var direction = math.normalize(new float3( random.NextSingle() - 0.5f, random.NextSingle() - 0.5f, random.NextSingle() - 0.5f)); var start = SimplexCell(origin); var t = 0.0f; var found = false; for (var step = 0; step < maxSteps && !found; step++) { t += dt; found = !SimplexCell(origin + direction * t).Equals(start); } Assert.True(found, $"line {line} never left its simplex cell in {maxSteps} steps"); double lo = t - dt; double hi = t; for (var bisect = 0; bisect < 60; bisect++) { var mid = 0.5 * (lo + hi); if (SimplexCell(origin + direction * (float)mid).Equals(start)) lo = mid; else hi = mid; } var a = math.snoise_grad(origin + direction * (float)lo); var b = math.snoise_grad(origin + direction * (float)hi); crossings++; maxValueJump = MathF.Max(maxValueJump, MathF.Abs(a.w - b.w)); maxGradientJump = MathF.Max(maxGradientJump, MathF.Max(MathF.Abs(a.x - b.x), MathF.Max(MathF.Abs(a.y - b.y), MathF.Abs(a.z - b.z)))); } Assert.Equal(lines, crossings); Assert.True(maxValueJump < 1.0e-5f, $"value jump {maxValueJump} across a simplex boundary"); Assert.True(maxGradientJump < 1.0e-4f, $"gradient jump {maxGradientJump} across a simplex boundary"); } // The simplex diagonals are where x0's three components compare equal. The tie rule must be a total // order there (math.snoise): with a cyclic or all-strict compare the four corners stop being a // simplex and the value jumps wherever x0 has three equal components (inputs whose differences y - x and // z - x are whole numbers, the line (t, t, t) among them): about 0.77 near the origin, up to 1.56 at // magnitudes of 64 and above. SnoiseTieScopeTests pins the set against the 0.3.0 values. [Fact] public void SnoiseIsContinuousOnTheSimplexDiagonal() { const int vertices = 160; const int perVertex = 8; var random = new System.Random(0x71E5); var points = new System.Collections.Generic.List(); for (var k = 0; k < 400; k++) { var t = (float)(random.NextDouble() * 128.0 - 64.0); points.Add(new float3(t, t, t)); } points.Add(new float3(0.0f, 0.0f, 0.0f)); for (var k = 0; k < vertices; k++) { var lx = random.Next(-64, 65); var ly = random.Next(-64, 65); var lz = random.Next(-64, 65); var shift = (lx + ly + lz) / 6.0f; var v = new float3(lx - shift, ly - shift, lz - shift); for (var j = 0; j < perVertex; j++) { var s = j == 0 ? 0.0f : random.NextSingle(); points.Add(new float3(v.x + s, v.y + s, v.z + s)); } } foreach (var p in points) { var value = math.snoise(p); for (var axis = 0; axis < 3; axis++) { for (var direction = -1; direction <= 1; direction += 2) { var q = p; for (var ulp = 1; ulp <= 2; ulp++) { var component = axis == 0 ? q.x : axis == 1 ? q.y : q.z; component = direction < 0 ? MathF.BitDecrement(component) : MathF.BitIncrement(component); q = axis == 0 ? new float3(component, q.y, q.z) : axis == 1 ? new float3(q.x, component, q.z) : new float3(q.x, q.y, component); var jump = MathF.Abs(math.snoise(q) - value); Assert.True(jump <= 1.0e-4f, $"snoise jumps {jump} within {ulp} ulp of {p}"); } } } var grad = math.snoise_grad(p); Assert.Equal(BitConverter.SingleToInt32Bits(value), BitConverter.SingleToInt32Bits(grad.w)); var slope = math.length(new float3(grad.x, grad.y, grad.z)); Assert.True(slope <= math.SNOISE_LIPSCHITZ, $"|snoise_grad| {slope} exceeds SNOISE_LIPSCHITZ at {p}"); } } // ---- snoise_grad.w vs snoise ---- [Fact] public void SnoiseGradValueIsBitEqualToSnoise() { // Both compute 105 * dot(m4, px) via the exact same operation order (math.cs comment on // snoise_grad), so this asserts bit equality over 5,000 random points in [-100, 100]^3. var random = new System.Random(0x50F2); for (var i = 0; i < 5000; i++) { var p = new float3( random.NextSingle() * 200.0f - 100.0f, random.NextSingle() * 200.0f - 100.0f, random.NextSingle() * 200.0f - 100.0f); var expected = math.snoise(p); var actual = math.snoise_grad(p).w; Assert.Equal(BitConverter.SingleToInt32Bits(expected), BitConverter.SingleToInt32Bits(actual)); } } // ---- fbm_grad / ridged_grad: per-octave gain and lacunarity ---- [Fact] public void FbmGradWithOneOctaveEqualsSnoiseGrad() { var random = new System.Random(0xF6B10); for (var i = 0; i < 64; i++) { var p = RandomPoint(random); var lacunarity = 1.0f + random.NextSingle() * 3.0f; var gain = 0.1f + random.NextSingle() * 0.8f; var expected = math.snoise_grad(p); var actual = math.fbm_grad(p, 1, lacunarity, gain); Assert.Equal(expected.x, actual.x); Assert.Equal(expected.y, actual.y); Assert.Equal(expected.z, actual.z); Assert.Equal(expected.w, actual.w); } } [Fact] public void FbmGradCompoundsGainAndLacunarityPerOctaveNotOnce() { // Falsifies two mutant shapes at once: applying gain/lacunarity once for the whole sum // instead of compounding per octave, and swapping which argument drives amplitude versus // frequency. lacunarity != gain and both != 1 so a swap changes the result, and 3 octaves // gives amplitude/frequency three distinct compounded values (1, gain, gain^2 and // 1, lacunarity, lacunarity^2) so "applied once" mutants cannot coincidentally match. // The expected value is built by the same incremental multiply chain fbm_grad itself uses // (frequency *= lacunarity, amplitude *= gain, one step at a time, not lacunarity*lacunarity // or MathF.Pow), so this asserts bit-exact equality rather than needing a tolerance. const float lacunarity = 2.5f; const float gain = 0.6f; var random = new System.Random(0xF6B11); for (var i = 0; i < 32; i++) { var p = RandomPoint(random); var frequency = 1.0f; var amplitude = 1.0f; var n0 = math.snoise_grad(p * frequency); var expectedValue = amplitude * n0.w; var expectedGradient = amplitude * frequency * new float3(n0.x, n0.y, n0.z); frequency *= lacunarity; amplitude *= gain; var n1 = math.snoise_grad(p * frequency); expectedValue += amplitude * n1.w; expectedGradient += amplitude * frequency * new float3(n1.x, n1.y, n1.z); frequency *= lacunarity; amplitude *= gain; var n2 = math.snoise_grad(p * frequency); expectedValue += amplitude * n2.w; expectedGradient += amplitude * frequency * new float3(n2.x, n2.y, n2.z); var actual = math.fbm_grad(p, 3, lacunarity, gain); Assert.Equal(expectedValue, actual.w); Assert.Equal(expectedGradient.x, actual.x); Assert.Equal(expectedGradient.y, actual.y); Assert.Equal(expectedGradient.z, actual.z); } } [Fact] public void RidgedGradCompoundsGainAndLacunarityPerOctaveNotOnce() { // Same construction as FbmGradCompoundsGainAndLacunarityPerOctaveNotOnce, folded: value is // Sum a_i*(1-|n_i|), gradient is -Sum a_i*f_i*sign(n_i)*grad(n_i) (math.cs comment on // ridged_grad). p is offset away from the origin and re-tried on a near-zero octave so no // octave's n_i lands in the fold's own zero band, which would make the closed form's sign() // ambiguous between runs. const float lacunarity = 2.5f; const float gain = 0.6f; var random = new System.Random(0xF6B12); var tested = 0; var attempts = 0; while (tested < 32 && attempts < 4000) { attempts++; var p = RandomPoint(random); if (AnyOctaveNearZero(p, 3, lacunarity, 0.1f)) continue; tested++; var frequency = 1.0f; var amplitude = 1.0f; var n0 = math.snoise_grad(p * frequency); var s0 = (float)MathF.Sign(n0.w); var expectedValue = amplitude * (1.0f - MathF.Abs(n0.w)); var expectedGradient = -amplitude * frequency * s0 * new float3(n0.x, n0.y, n0.z); frequency *= lacunarity; amplitude *= gain; var n1 = math.snoise_grad(p * frequency); var s1 = (float)MathF.Sign(n1.w); expectedValue += amplitude * (1.0f - MathF.Abs(n1.w)); expectedGradient += -amplitude * frequency * s1 * new float3(n1.x, n1.y, n1.z); frequency *= lacunarity; amplitude *= gain; var n2 = math.snoise_grad(p * frequency); var s2 = (float)MathF.Sign(n2.w); expectedValue += amplitude * (1.0f - MathF.Abs(n2.w)); expectedGradient += -amplitude * frequency * s2 * new float3(n2.x, n2.y, n2.z); var actual = math.ridged_grad(p, 3, lacunarity, gain); Assert.Equal(expectedValue, actual.w); Assert.Equal(expectedGradient.x, actual.x); Assert.Equal(expectedGradient.y, actual.y); Assert.Equal(expectedGradient.z, actual.z); } Assert.True(tested == 32, $"only {tested} of {attempts} points were outside the zero band"); } // ---- ridged_grad's deliberate crease at n = 0 ---- [Fact] public void RidgedGradOneSidedGradientsAcrossAZeroCrossingDifferInSign() { // 1 octave: ridged_grad(p) = 1 - |snoise(p)|, gradient = -sign(snoise(p)) * grad(snoise)(p). // Bisect along a fixed direction from a random start point to find a tight straddle of a // snoise zero crossing, then check the two ridged_grad gradients just either side of it point // in near-opposite directions (their dot product is negative): the crease is real, not a // numerical accident (design.md, "the operator's dune term"). var random = new System.Random(0xC2E55); for (var attempt = 0; attempt < 200; attempt++) { var origin = RandomPoint(random); var direction = new float3(random.NextSingle() - 0.5f, random.NextSingle() - 0.5f, random.NextSingle() - 0.5f); if (math.length(direction) < 1.0e-3f) continue; float NoiseAt(float t) => math.snoise(origin + direction * t); var lo = -3.0f; var hi = 3.0f; if (Math.Sign(NoiseAt(lo)) == Math.Sign(NoiseAt(hi))) continue; for (var step = 0; step < 40; step++) { var mid = (lo + hi) * 0.5f; if (Math.Sign(NoiseAt(lo)) == Math.Sign(NoiseAt(mid))) lo = mid; else hi = mid; } // lo and hi now straddle the crossing within about 3 * 2^-40 of parameter t; step off by // a small margin either side so each sample is cleanly on one side, not exactly on it. const float margin = 1.0e-4f; var minusSide = origin + direction * (lo - margin); var plusSide = origin + direction * (hi + margin); if (MathF.Sign(NoiseAt(lo - margin)) == MathF.Sign(NoiseAt(hi + margin))) continue; // margin stepped back across the crossing; try another direction. var gradMinus = math.ridged_grad(minusSide, 1, 2.0f, 0.5f); var gradPlus = math.ridged_grad(plusSide, 1, 2.0f, 0.5f); var dot = gradMinus.x * gradPlus.x + gradMinus.y * gradPlus.y + gradMinus.z * gradPlus.z; Assert.True(dot < 0.0f, $"one-sided gradients did not differ in sign: minus=({gradMinus.x},{gradMinus.y},{gradMinus.z}) plus=({gradPlus.x},{gradPlus.y},{gradPlus.z}) dot={dot}"); return; } Assert.Fail("did not find a snoise zero crossing to straddle"); } }