CellularAndSminGradTests.cs changes for cut 2a-i's pass-2 findings: - FeaturePoint and the two other independent pcg3d/pcg4d call sites now hash int3(cell)/int4(int3(cellId), 0) to match production's integer-hashing fix (previous commit); the oracle would otherwise diverge from cellular() and every comparison test would fail. - New JitterAndIdPassAChiSquareUniformityTest: jitter.x/y/z and id each pass a chi-square uniformity test (16 bins, 15 df, critical value 37.7 at alpha=0.001) over 200,000 cells. This is the behavioural pin for the hashing fix; reverting to pcg3d(cell)'s float-bit hash fails it. - Two false comments corrected to the measured facts: the NearestFeatureWins test's claim that |component|=2 offsets are "provably never the true nearest" is false (their lower bound is 1, not sqrt(3); Soul measured 3 F1 wins in 2,000,000 samples), and SearchReachesEveryAxis...'s claim of "lower bound is exactly 2" and "zero hits in 3,000,000" is also false (lower bound is 1; Soul measured about 1 hit in 28,000). - PrunedSearchIsBitIdentical... renamed to ...BelowTwoToTheTwentyFifth and its comment states that domain explicitly (design.md, previous commit). - GradientTolerance split into SminGradientTolerance (2.5e-4f) and CellularGradientTolerance (1.3e-3f): the two functions have different curvature (smin_grad is exactly quadratic in a band; cellular's F1/F2 are real distance fields), so a shared 3e-4 tolerance carried almost no headroom over either's actual measured noise (re-measured here at 5.3e-4 for smin's old eps, 2.0e-4/2.6e-4 for cellular), not the "~6e-5" the old comment claimed. smin_grad's eps moves from 0.001f to 0.01f (no truncation cost there; dilutes float noise 8x) with a matching widened seam-exclusion band so p +/- eps cannot step across the seam the check at p already avoided. cellular's eps stays at 0.0005f (a small sweep found it already near the truncation/noise sweet spot for a real distance field). Both new tolerances keep >5x headroom over their own measured worst case and both still die under a 1.01x-scale mutant on the returned gradient — smin down to 1.0005x, cellular (M9/M16-style, either gradient) down to 1.002x. - GradientMatchesCentralDifferencesAwayFromTheF1EqualsF2Seam also excludes a band around F2 = F3 now, not just F1 = F2: the second-nearest feature's identity swaps there too, a genuine kink the old test could silently hit (an unfiltered large sample found a 0.78 edge error this way, swamping the ~2e-4 the filtered test actually sees). - AxisAlignedRadiusTwoOffsets and FindPointRealizingOffset are extracted from SearchReachesEveryAxisAlignedRadiusTwoSlice as internal fixtures so HlslSourceCompatibilityTests (next commit) can reuse the same construction instead of a second copy. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
686 lines
34 KiB
C#
686 lines
34 KiB
C#
using System;
|
|
using CultMath;
|
|
using Xunit;
|
|
|
|
namespace CultMath.Tests;
|
|
|
|
public sealed class CellularAndSminGradTests
|
|
{
|
|
// Central-difference tolerances for smin_grad and cellular (F5), sized separately: the two
|
|
// functions have different curvature, so the same eps does not carry the same truncation
|
|
// error, and an earlier shared 3e-4 tolerance was fragile against both (Soul, cut 2a-i: measured
|
|
// max error smin 2.74e-4, cellular nearest 1.25e-4, cellular edge 1.88e-4 on a small sample —
|
|
// and re-measured here at 5.3e-4, 2.0e-4, 2.6e-4 on a larger one — against that 3e-4 tolerance,
|
|
// sometimes under 1.1x headroom, not the "~6e-5 noise" an earlier version of this comment
|
|
// claimed). Each tolerance below is set from its own measured worst case with real headroom.
|
|
|
|
// smin_grad's value is exactly quadratic in position within a smoothing band (math.cs comment on
|
|
// smin_grad: h is affine in b.w - a.w, which is itself affine in position, so value(q) has zero
|
|
// third derivative there): central differences have no truncation error from curvature, only
|
|
// float32 rounding noise, so a larger eps directly dilutes that noise with no accuracy cost.
|
|
// Measured max error at eps=0.01f over 10,000 samples: 4.37e-5 (up from eps=0.001f's 5.3e-4,
|
|
// confirming the noise is eps-limited, not curvature-limited); this tolerance keeps about 5.7x
|
|
// headroom over that. A uniform-scale mutant on the returned gradient (a 1.01x M9/M16-style
|
|
// mutant applied here) still dies down to a 1.0005x scale (0.05%); it survives at 1.0002x.
|
|
private const float SminGradientTolerance = 2.5e-4f;
|
|
|
|
// cellular's F1/F2 are Euclidean distance fields: real curvature away from a feature point means
|
|
// central differences DO carry O(eps^2) truncation error, so (unlike smin_grad) a larger eps
|
|
// makes things worse, not better; a sweep at eps in {0.0002f, 0.0003f, 0.0005f, 0.0007f, 0.001f}
|
|
// over 8,000 samples found the committed eps=0.0005f already close to the sweet spot. Measured
|
|
// max error at that eps: nearest 2.04e-4, edge 2.57e-4; this tolerance keeps just over 5x
|
|
// headroom over the larger of the two. M9 (grad1 * 1.01) and M16 (grad2 * 1.01) both still die
|
|
// down to a 1.002x scale (0.2%); both survive at 1.001x.
|
|
private const float CellularGradientTolerance = 1.3e-3f;
|
|
|
|
// ---- smin_grad ----
|
|
|
|
[Fact]
|
|
public void SminValueNeverExceedsTheOrdinaryMinimum()
|
|
{
|
|
var random = new System.Random(0xACE1);
|
|
for (var i = 0; i < 256; i++)
|
|
{
|
|
var a = new float4(RandomUnit(random), RandomScalar(random));
|
|
var b = new float4(RandomUnit(random), RandomScalar(random));
|
|
var k = 0.05f + random.NextSingle() * 5.0f;
|
|
|
|
var result = math.smin_grad(a, b, k);
|
|
|
|
Assert.True(result.w <= math.min(a.w, b.w) + 1.0e-4f, $"a.w={a.w} b.w={b.w} k={k} smin={result.w}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void SminMatchesTheHardMinimumAndItsGradientOutsideTheBand()
|
|
{
|
|
var random = new System.Random(0xACE2);
|
|
for (var i = 0; i < 64; i++)
|
|
{
|
|
var gradA = RandomUnit(random);
|
|
var gradB = RandomUnit(random);
|
|
var k = 0.1f + random.NextSingle() * 2.0f;
|
|
|
|
// Push b comfortably above a, past the smoothing band, so a is the ordinary minimum.
|
|
var aw = random.NextSingle() * 10.0f - 5.0f;
|
|
var bw = aw + k * (1.5f + random.NextSingle());
|
|
var a = new float4(gradA, aw);
|
|
var b = new float4(gradB, bw);
|
|
|
|
var lower = math.smin_grad(a, b, k);
|
|
Assert.Equal(aw, lower.w, precision: 4);
|
|
Assert.Equal(gradA.x, lower.x, precision: 4);
|
|
Assert.Equal(gradA.y, lower.y, precision: 4);
|
|
Assert.Equal(gradA.z, lower.z, precision: 4);
|
|
|
|
// Argument order must not matter: b below a is symmetric.
|
|
var higher = math.smin_grad(b, a, k);
|
|
Assert.Equal(aw, higher.w, precision: 4);
|
|
Assert.Equal(gradA.x, higher.x, precision: 4);
|
|
Assert.Equal(gradA.y, higher.y, precision: 4);
|
|
Assert.Equal(gradA.z, higher.z, precision: 4);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void SminGradientMatchesCentralDifferencesOfItsOwnValue()
|
|
{
|
|
var random = new System.Random(0xACE3);
|
|
var tested = 0;
|
|
while (tested < 200)
|
|
{
|
|
var cf = RandomUnit(random);
|
|
var cg = RandomUnit(random);
|
|
var offF = random.NextSingle() * 4.0f - 2.0f;
|
|
var offG = random.NextSingle() * 4.0f - 2.0f;
|
|
var k = 0.2f + random.NextSingle() * 2.0f;
|
|
var p = new float3(random.NextSingle() * 4.0f - 2.0f, random.NextSingle() * 4.0f - 2.0f, random.NextSingle() * 4.0f - 2.0f);
|
|
|
|
float F(float3 q) => math.dot(q, cf) + offF;
|
|
float G(float3 q) => math.dot(q, cg) + offG;
|
|
float Smin(float3 q) => math.smin_grad(new float4(cf, F(q)), new float4(cg, G(q)), k).w;
|
|
|
|
// Stay clear of the |a.w - b.w| = k seam: central differences straddling a kink would
|
|
// disagree with either side's analytic gradient (the same reason cellular excludes a
|
|
// band around its own F1 = F2 set). The band has an absolute floor, not just a
|
|
// fraction of k: eps grew to 0.01f below (SminGradientTolerance's comment), and a
|
|
// fraction-of-k-only band could be narrower than the diff can shift by eps*|cf-cg|
|
|
// (up to about 0.035 here) when k is small, letting p +/- eps step across the seam even
|
|
// though p itself was clear of it.
|
|
var diff = F(p) - G(p);
|
|
if (Math.Abs(Math.Abs(diff) - k) < Math.Max(0.05f * k, 0.05f))
|
|
continue;
|
|
|
|
tested++;
|
|
var analytic = math.smin_grad(new float4(cf, F(p)), new float4(cg, G(p)), k);
|
|
|
|
// eps=0.01f, not 0.001f: smin is exactly quadratic within a band (no truncation error
|
|
// from curvature; see SminGradientTolerance's comment), so the larger eps only dilutes
|
|
// float32 rounding noise, at no accuracy cost, while still comfortably inside the band
|
|
// exclusion above.
|
|
const float eps = 0.01f;
|
|
var numeric = new float3(
|
|
(Smin(p + new float3(eps, 0.0f, 0.0f)) - Smin(p - new float3(eps, 0.0f, 0.0f))) / (2.0f * eps),
|
|
(Smin(p + new float3(0.0f, eps, 0.0f)) - Smin(p - new float3(0.0f, eps, 0.0f))) / (2.0f * eps),
|
|
(Smin(p + new float3(0.0f, 0.0f, eps)) - Smin(p - new float3(0.0f, 0.0f, eps))) / (2.0f * eps));
|
|
|
|
AssertWithinTolerance(numeric.x, analytic.x, SminGradientTolerance);
|
|
AssertWithinTolerance(numeric.y, analytic.y, SminGradientTolerance);
|
|
AssertWithinTolerance(numeric.z, analytic.z, SminGradientTolerance);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void SminGradientIsNotAConstantBlendOrTheSwappedFactor()
|
|
{
|
|
// Falsifies two named mutants directly: blending the gradient with a constant 0.5 instead of
|
|
// h, or with (1 - h) instead of h. Here a is comfortably the minimum (h saturates to 1 in the
|
|
// correct formula), so the correct gradient is exactly a's, unmixed. A constant-0.5 blend or a
|
|
// swapped (1 - h) blend both pull in b's gradient, which differs from a's here.
|
|
var a = new float4(1.0f, 0.0f, 0.0f, -3.0f);
|
|
var b = new float4(0.0f, 1.0f, 0.0f, 5.0f);
|
|
|
|
var result = math.smin_grad(a, b, 0.25f);
|
|
|
|
Assert.Equal(1.0f, result.x, precision: 5);
|
|
Assert.Equal(0.0f, result.y, precision: 5);
|
|
Assert.Equal(0.0f, result.z, precision: 5);
|
|
Assert.Equal(-3.0f, result.w, precision: 5);
|
|
}
|
|
|
|
// ---- cellular ----
|
|
|
|
[Fact]
|
|
public void F1NeverExceedsF2()
|
|
{
|
|
var random = new System.Random(0xCE11);
|
|
for (var i = 0; i < 256; i++)
|
|
{
|
|
var p = new float3(random.NextSingle() * 20.0f - 10.0f, random.NextSingle() * 20.0f - 10.0f, random.NextSingle() * 20.0f - 10.0f);
|
|
var result = math.cellular(p);
|
|
Assert.True(result.edge.w >= -1.0e-4f, $"F2 - F1 = {result.edge.w} at {p.x},{p.y},{p.z}");
|
|
|
|
// Both returned .w values, checked against the independent 7x7x7 brute-force oracle (F1,
|
|
// F6), not a value the test derived some other way.
|
|
var (f1, f2) = FindF1F2(p);
|
|
Assert.Equal(f1, result.nearest.w, precision: 3);
|
|
Assert.Equal(f2 - f1, result.edge.w, precision: 3);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void NearestFeatureWinsEvenWhenItIsInTheLastVisitedNeighbourCell()
|
|
{
|
|
// math.cellular's own search order visits neighbour offsets with dz outermost and dx
|
|
// innermost over a 5x5x5 block (F6), and it also prunes cells whose position-only lower
|
|
// bound already exceeds the running F2. Offsets with any |component| = 2 are not provably
|
|
// unreachable as F1: their minimum possible distance is 1 (e.g. (2, 0, 0), whose lower bound
|
|
// per the exactness proof in math.cs is max(0, 2-1) = 1), well inside the query's own cell's
|
|
// sqrt(3) ceiling, so a |component| = 2 offset winning F1 is rare rather than impossible
|
|
// (Soul, cut 2a-i: measured 3 F1 wins in 2,000,000 samples). It is still rare enough that the
|
|
// 3x3x3 corners remain the practically-reachable "last" cases for this test. (dx, dy, dz)
|
|
// = (1, 1, 1) is the last of those the fixed iteration order visits, and if pruning or search
|
|
// order were broken, this is the offset most likely to be skipped. Rather than hand-picking a
|
|
// hash value, search random points for one whose true nearest feature (by independent brute
|
|
// force over the same 5x5x5 block) happens to live in exactly that cell, at a comfortable
|
|
// margin from the F1 = F2 seam, then check math.cellular agrees.
|
|
var random = new System.Random(0xCE14);
|
|
var lastOffset = new float3(1.0f, 1.0f, 1.0f);
|
|
for (var attempt = 0; attempt < 5000; attempt++)
|
|
{
|
|
var p = new float3(random.NextSingle() * 8.0f - 4.0f, random.NextSingle() * 8.0f - 4.0f, random.NextSingle() * 8.0f - 4.0f);
|
|
var (offset, f1, f2) = FindNearestOffset(p);
|
|
if (!offset.Equals(lastOffset) || f1 < 0.05f || f2 - f1 < 0.05f)
|
|
continue;
|
|
|
|
var cell = new float3(MathF.Floor(p.x), MathF.Floor(p.y), MathF.Floor(p.z));
|
|
var feature = FeaturePoint(cell + lastOffset);
|
|
var expectedGradient = (p - feature) / f1;
|
|
|
|
var result = math.cellular(p);
|
|
Assert.Equal(f1, result.nearest.w, precision: 4);
|
|
Assert.Equal(expectedGradient.x, result.nearest.x, precision: 4);
|
|
Assert.Equal(expectedGradient.y, result.nearest.y, precision: 4);
|
|
Assert.Equal(expectedGradient.z, result.nearest.z, precision: 4);
|
|
return;
|
|
}
|
|
|
|
Assert.Fail("did not find a random point whose nearest feature lives in the last-visited neighbour cell");
|
|
}
|
|
|
|
[Fact]
|
|
public void NearestGradientIsFiniteZeroAtAFeaturePointInsteadOfNaN()
|
|
{
|
|
var feature = FeaturePoint(new float3(4.0f, -2.0f, 9.0f));
|
|
var result = math.cellular(feature);
|
|
|
|
Assert.Equal(0.0f, result.nearest.w, precision: 4);
|
|
Assert.True(float.IsFinite(result.nearest.x) && float.IsFinite(result.nearest.y) && float.IsFinite(result.nearest.z));
|
|
Assert.Equal(0.0f, result.nearest.x, precision: 5);
|
|
Assert.Equal(0.0f, result.nearest.y, precision: 5);
|
|
Assert.Equal(0.0f, result.nearest.z, precision: 5);
|
|
}
|
|
|
|
[Fact]
|
|
public void GradientMatchesCentralDifferencesAwayFromTheF1EqualsF2Seam()
|
|
{
|
|
var random = new System.Random(0xCE12);
|
|
var tested = 0;
|
|
var attempts = 0;
|
|
while (tested < 120 && attempts < 20000)
|
|
{
|
|
attempts++;
|
|
var p = new float3(random.NextSingle() * 6.0f - 3.0f, random.NextSingle() * 6.0f - 3.0f, random.NextSingle() * 6.0f - 3.0f);
|
|
var (f1, f2, f3) = FindF1F2F3(p);
|
|
|
|
// Exclude a band around the F1 = F2 set: the identity of the nearest and second-nearest
|
|
// feature swap there, a genuine kink in F1 and F2 alike, not something central differences
|
|
// can approximate. Also exclude a band around F2 = F3: edge (F2 - F1) kinks there too,
|
|
// since the second-nearest feature's identity swaps (Soul, cut 2a-i: an unfiltered sample
|
|
// hit a 0.78 edge error this way). Also skip the exact feature point itself (F1 = 0),
|
|
// covered separately.
|
|
if (f2 - f1 < 0.05f || f1 < 0.01f || f3 - f2 < 0.05f)
|
|
continue;
|
|
|
|
tested++;
|
|
var result = math.cellular(p);
|
|
|
|
// eps=0.0005f: unlike smin_grad, cellular's F1/F2 are Euclidean distance fields with real
|
|
// curvature, so central differences carry O(eps^2) truncation error and a larger eps is
|
|
// worse, not better (see CellularGradientTolerance's comment for the measured sweep).
|
|
const float eps = 0.0005f;
|
|
|
|
// Differentiate cellular's own returned .w values (F1): calling cellular again at p +/-
|
|
// eps and reading result.nearest.w / result.edge.w, not a duplicate FindF1F2 search. A
|
|
// mutant that corrupts only the returned gradient, leaving the returned value alone,
|
|
// would survive a check built on an independent value oracle; it cannot survive this one.
|
|
float NearestValueAt(float3 q) => math.cellular(q).nearest.w;
|
|
float EdgeValueAt(float3 q) => math.cellular(q).edge.w;
|
|
|
|
AssertGradientMatchesCentralDifference(NearestValueAt, p, eps, result.nearest, CellularGradientTolerance);
|
|
AssertGradientMatchesCentralDifference(EdgeValueAt, p, eps, result.edge, CellularGradientTolerance);
|
|
}
|
|
|
|
Assert.True(tested >= 100, $"only {tested} of {attempts} random points landed away from the F1=F2 band");
|
|
}
|
|
|
|
[Fact]
|
|
public void IdIsConstantAcrossAFeaturesWholeRegionAndVariesBetweenCells()
|
|
{
|
|
var random = new System.Random(0xCE13);
|
|
var baseCell = new float3(2.0f, 2.0f, 2.0f);
|
|
var feature = FeaturePoint(baseCell);
|
|
var idAtFeature = math.cellular(feature).id;
|
|
|
|
for (var i = 0; i < 32; i++)
|
|
{
|
|
// Small offsets that stay inside the same Voronoi region (well short of any neighbour).
|
|
var jitter = new float3(random.NextSingle() * 0.2f - 0.1f, random.NextSingle() * 0.2f - 0.1f, random.NextSingle() * 0.2f - 0.1f);
|
|
var nearby = feature + jitter;
|
|
if (!FindNearestCell(nearby).Equals(baseCell))
|
|
continue;
|
|
|
|
Assert.Equal(idAtFeature, math.cellular(nearby).id, precision: 6);
|
|
}
|
|
|
|
var otherCellId = math.cellular(FeaturePoint(baseCell + new float3(5.0f, 0.0f, 0.0f))).id;
|
|
Assert.NotEqual(idAtFeature, otherCellId);
|
|
}
|
|
|
|
[Fact]
|
|
public void IdIsNotCorrelatedWithAnyJitterComponent()
|
|
{
|
|
// F4: id must come from hash bits the jitter never uses. A mutant that takes id from
|
|
// hash.x (or hash.y) makes id literally equal to that jitter component, which this catches
|
|
// as a correlation near 1.0, far past the 0.05 threshold. n = 20000 keeps sampling noise
|
|
// (measured under 0.01 at this n) well clear of that threshold.
|
|
const int n = 20000;
|
|
var random = new System.Random(0xCE15);
|
|
var ids = new double[n];
|
|
var jitterX = new double[n];
|
|
var jitterY = new double[n];
|
|
var jitterZ = new double[n];
|
|
|
|
for (var i = 0; i < n; i++)
|
|
{
|
|
var cell = new float3(random.Next(-2000, 2000), random.Next(-2000, 2000), random.Next(-2000, 2000));
|
|
var hash = math.pcg3d(math.int3(cell));
|
|
var jitter = new float3(Unit(hash.x), Unit(hash.y), Unit(hash.z));
|
|
|
|
ids[i] = math.cellular(cell + jitter).id;
|
|
jitterX[i] = jitter.x;
|
|
jitterY[i] = jitter.y;
|
|
jitterZ[i] = jitter.z;
|
|
}
|
|
|
|
Assert.True(Math.Abs(Correlation(ids, jitterX)) < 0.05, "id correlates with jitter.x");
|
|
Assert.True(Math.Abs(Correlation(ids, jitterY)) < 0.05, "id correlates with jitter.y");
|
|
Assert.True(Math.Abs(Correlation(ids, jitterZ)) < 0.05, "id correlates with jitter.z");
|
|
}
|
|
|
|
[Fact]
|
|
public void JitterAndIdPassAChiSquareUniformityTest()
|
|
{
|
|
// F4/jitter uniformity: cellular hashes the winning cell's INTEGER coordinate (design.md,
|
|
// "Integer hashing"), never that coordinate's float32 bit pattern. Hashing the bit pattern of
|
|
// an integer-valued float shares more structure between adjacent cells (same exponent, a
|
|
// mantissa one increment apart) than the integers themselves do going into pcg3d's own
|
|
// mixing, and that structure survived into the jitter: Soul measured jitter.x's marginal
|
|
// chi-square statistic at 203.8 over 1,000,000 cells this way (15 degrees of freedom;
|
|
// critical value 37.7 at alpha = 0.001), soundly rejecting uniformity. 16 equal-width bins
|
|
// over [0, 1) give those same 15 degrees of freedom for jitter.x/y/z and id alike.
|
|
const int n = 200_000;
|
|
const int bins = 16;
|
|
const double criticalValue = 37.7; // chi-square, 15 df, alpha = 0.001
|
|
|
|
var jitterXCounts = new int[bins];
|
|
var jitterYCounts = new int[bins];
|
|
var jitterZCounts = new int[bins];
|
|
var idCounts = new int[bins];
|
|
var random = new System.Random(0xCE19);
|
|
|
|
for (var i = 0; i < n; i++)
|
|
{
|
|
var cell = new float3(random.Next(-100000, 100000), random.Next(-100000, 100000), random.Next(-100000, 100000));
|
|
var hash = math.pcg3d(math.int3(cell));
|
|
jitterXCounts[Bin(Unit(hash.x), bins)]++;
|
|
jitterYCounts[Bin(Unit(hash.y), bins)]++;
|
|
jitterZCounts[Bin(Unit(hash.z), bins)]++;
|
|
|
|
var idHash = math.pcg4d(new int4(math.int3(cell), 0));
|
|
idCounts[Bin(Unit(idHash.w), bins)]++;
|
|
}
|
|
|
|
var expected = (double)n / bins;
|
|
AssertUniform(jitterXCounts, expected, criticalValue, "jitter.x");
|
|
AssertUniform(jitterYCounts, expected, criticalValue, "jitter.y");
|
|
AssertUniform(jitterZCounts, expected, criticalValue, "jitter.z");
|
|
AssertUniform(idCounts, expected, criticalValue, "id");
|
|
}
|
|
|
|
private static int Bin(float value, int binCount) => Math.Clamp((int)(value * binCount), 0, binCount - 1);
|
|
|
|
private static void AssertUniform(int[] counts, double expected, double criticalValue, string name)
|
|
{
|
|
var chiSquare = 0.0;
|
|
foreach (var count in counts)
|
|
{
|
|
var delta = count - expected;
|
|
chiSquare += delta * delta / expected;
|
|
}
|
|
|
|
Assert.True(chiSquare < criticalValue, $"{name} chi-square {chiSquare:G6} exceeds critical value {criticalValue} ({counts.Length - 1} df)");
|
|
}
|
|
|
|
[Fact]
|
|
public void NoNaNAtIntegerPointsNear2To24()
|
|
{
|
|
// F3: past |p| of about 2^23, distinct cells' jittered features can round to the same
|
|
// float32 value, making F2 = 0 reachable. Soul measured 4096 of 4096 integer points at 2^24
|
|
// producing NaN before the guard; this pins the fix over the same sample.
|
|
const float scale = 16777216.0f; // 2^24
|
|
for (var ix = 0; ix < 16; ix++)
|
|
for (var iy = 0; iy < 16; iy++)
|
|
for (var iz = 0; iz < 16; iz++)
|
|
{
|
|
var p = new float3(scale + ix, scale + iy, scale + iz);
|
|
var result = math.cellular(p);
|
|
|
|
Assert.True(
|
|
float.IsFinite(result.nearest.x) && float.IsFinite(result.nearest.y) &&
|
|
float.IsFinite(result.nearest.z) && float.IsFinite(result.nearest.w),
|
|
$"nearest not finite at {p.x},{p.y},{p.z}: {result.nearest.x},{result.nearest.y},{result.nearest.z},{result.nearest.w}");
|
|
Assert.True(
|
|
float.IsFinite(result.edge.x) && float.IsFinite(result.edge.y) &&
|
|
float.IsFinite(result.edge.z) && float.IsFinite(result.edge.w),
|
|
$"edge not finite at {p.x},{p.y},{p.z}: {result.edge.x},{result.edge.y},{result.edge.z},{result.edge.w}");
|
|
Assert.True(float.IsFinite(result.id), $"id not finite at {p.x},{p.y},{p.z}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void CellularUnitMapsAllOnesBitsBelowOne()
|
|
{
|
|
// F7: cellular_unit(0xFFFFFFFF) must land in [0, 1), not round up to exactly 1.0f.
|
|
var mapped = math.cellular_unit(-1); // -1's bit pattern is 0xFFFFFFFF.
|
|
Assert.True(mapped >= 0.0f && mapped < 1.0f, $"cellular_unit(0xFFFFFFFF) = {mapped}");
|
|
}
|
|
|
|
[Fact]
|
|
public void ProductionSearchMatchesTheBruteForceOracleExactly()
|
|
{
|
|
// F6, part 2: the production 5x5x5 search must equal the independent 7x7x7 oracle exactly
|
|
// (to the oracle's own float32 precision), over a large seeded sweep in the domain Soul
|
|
// measured ([-50, 50]^3, where 3x3x3 missed 9 of 400,000 points).
|
|
var random = new System.Random(0xCE16);
|
|
for (var i = 0; i < 20000; i++)
|
|
{
|
|
var p = new float3(random.NextSingle() * 100.0f - 50.0f, random.NextSingle() * 100.0f - 50.0f, random.NextSingle() * 100.0f - 50.0f);
|
|
var result = math.cellular(p);
|
|
var (f1, f2) = FindF1F2(p);
|
|
|
|
Assert.Equal(f1, result.nearest.w, precision: 4);
|
|
Assert.Equal(f2 - f1, result.edge.w, precision: 4);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void SearchReachesEveryAxisAlignedRadiusTwoSlice()
|
|
{
|
|
// F6, part 2 (Soul finding): a single-slice loop-bound mutant on one axis (e.g. dz < 2
|
|
// instead of dz <= 2, silently dropping the +2 layer on that axis alone) still passed the
|
|
// random 20,000-point sweeps above. That is not those sweeps being weak; it is the
|
|
// exactness proof's own margin (F1, F2 always < sqrt(3), a radius-2 cell's lower bound is
|
|
// max(0, 2-1) = 1, not "exactly 2" as an earlier version of this comment claimed) making a
|
|
// genuine win from any single |offset component| = 2 cell rare over uniformly random points
|
|
// (Soul, cut 2a-i: measured about 1 hit in 28,000 samples, not the "zero in 3,000,000" an
|
|
// earlier version of this comment claimed).
|
|
// Hunting for one by choosing candidate query POINTS is the wrong end of the search; this
|
|
// instead scans candidate integer CELLS and, for each, builds the query point most likely to
|
|
// realize that cell's neighbour as the winner: the closest point of the query cell's own unit
|
|
// cube to the target neighbour's actual jittered feature. That construction finds a real
|
|
// example for every one of the six axis-aligned radius-2 offsets within a couple thousand
|
|
// candidate cells, deterministically (pcg3d has no randomness to get lucky or unlucky with).
|
|
foreach (var offset in AxisAlignedRadiusTwoOffsets)
|
|
{
|
|
var p = FindPointRealizingOffset(offset);
|
|
var (f1, f2) = FindF1F2(p); // independent 7x7x7 oracle
|
|
var result = math.cellular(p);
|
|
Assert.Equal(f1, result.nearest.w, precision: 3);
|
|
Assert.Equal(f2 - f1, result.edge.w, precision: 3);
|
|
}
|
|
}
|
|
|
|
// The six axis-aligned radius-2 offsets (F6, part 2): shared by
|
|
// SearchReachesEveryAxisAlignedRadiusTwoSlice and, via FindPointRealizingOffset, by
|
|
// HlslSourceCompatibilityTests' mirror-comparison inputs, so both pin the same construction.
|
|
internal static readonly float3[] AxisAlignedRadiusTwoOffsets =
|
|
{
|
|
new(2.0f, 0.0f, 0.0f), new(-2.0f, 0.0f, 0.0f),
|
|
new(0.0f, 2.0f, 0.0f), new(0.0f, -2.0f, 0.0f),
|
|
new(0.0f, 0.0f, 2.0f), new(0.0f, 0.0f, -2.0f),
|
|
};
|
|
|
|
// Hunting for a query point where a radius-2 offset wins F1 or F2 by choosing candidate query
|
|
// POINTS is the wrong end of the search (Soul, cut 2a-i: about 1 hit in 28,000 uniformly random
|
|
// points); this instead scans candidate integer CELLS and, for each, builds the query point most
|
|
// likely to realize that cell's neighbour as the winner: the closest point of the query cell's
|
|
// own unit cube to the target neighbour's actual jittered feature. That construction finds a
|
|
// real example for every one of the six axis-aligned radius-2 offsets within a couple thousand
|
|
// candidate cells, deterministically (pcg3d has no randomness to get lucky or unlucky with).
|
|
internal static float3 FindPointRealizingOffset(float3 offset)
|
|
{
|
|
for (var qz = -80; qz <= 80; qz++)
|
|
for (var qy = -80; qy <= 80; qy++)
|
|
for (var qx = -80; qx <= 80; qx++)
|
|
{
|
|
var q = new float3(qx, qy, qz);
|
|
var targetFeature = FeaturePoint(q + offset);
|
|
var p = new float3(
|
|
Math.Clamp(targetFeature.x, q.x, q.x + 0.999f),
|
|
Math.Clamp(targetFeature.y, q.y, q.y + 0.999f),
|
|
Math.Clamp(targetFeature.z, q.z, q.z + 0.999f));
|
|
|
|
var (f1, f2) = FindF1F2(p); // independent 7x7x7 oracle
|
|
var d = math.distance(p, targetFeature);
|
|
if (MathF.Abs(d - f1) > 1.0e-4f && MathF.Abs(d - f2) > 1.0e-4f)
|
|
continue; // this cell didn't realize the target offset as F1 or F2; try another.
|
|
|
|
return p;
|
|
}
|
|
|
|
throw new InvalidOperationException($"could not construct a query point where offset {offset.x},{offset.y},{offset.z} ever wins F1 or F2");
|
|
}
|
|
|
|
[Fact]
|
|
public void PrunedSearchIsBitIdenticalToTheUnprunedFiveCubedSearchBelowTwoToTheTwentyFifth()
|
|
{
|
|
// F6, part 2: the lower-bound prune inside math.cellular's loop must never change the
|
|
// result. Compares against an independent, unpruned 5x5x5 reference that visits every cell
|
|
// in the same fixed order, over a large seeded sweep in [-50, 50]^3.
|
|
//
|
|
// This bit-identity has a domain: it holds for |p| < 2^25 (design.md, "Precision domain").
|
|
// Above that, cell + offset starts to round in float32, so the pruned loop's recomputed
|
|
// neighbour and the unpruned loop's neighbour at the same nominal (dx, dy, dz) can land on
|
|
// different actual cells, and the two searches diverge (Soul, cut 2a-i: 180 of 1,100,000
|
|
// points differ at |p| ~= 3.4e7). [-50, 50]^3 is comfortably inside the proven domain.
|
|
var random = new System.Random(0xCE18);
|
|
for (var i = 0; i < 20000; i++)
|
|
{
|
|
var p = new float3(random.NextSingle() * 100.0f - 50.0f, random.NextSingle() * 100.0f - 50.0f, random.NextSingle() * 100.0f - 50.0f);
|
|
var pruned = math.cellular(p);
|
|
var unpruned = UnprunedCellular5x5x5(p);
|
|
|
|
Assert.Equal(unpruned.nearest.x, pruned.nearest.x);
|
|
Assert.Equal(unpruned.nearest.y, pruned.nearest.y);
|
|
Assert.Equal(unpruned.nearest.z, pruned.nearest.z);
|
|
Assert.Equal(unpruned.nearest.w, pruned.nearest.w);
|
|
Assert.Equal(unpruned.edge.x, pruned.edge.x);
|
|
Assert.Equal(unpruned.edge.y, pruned.edge.y);
|
|
Assert.Equal(unpruned.edge.z, pruned.edge.z);
|
|
Assert.Equal(unpruned.edge.w, pruned.edge.w);
|
|
Assert.Equal(unpruned.id, pruned.id);
|
|
}
|
|
}
|
|
|
|
// Independent of math.cellular's own pruning: visits every cell of the 5x5x5 block, in the same
|
|
// fixed order, unconditionally.
|
|
private static CultCellular UnprunedCellular5x5x5(float3 p)
|
|
{
|
|
var cell = new float3(MathF.Floor(p.x), MathF.Floor(p.y), MathF.Floor(p.z));
|
|
var f1 = 1.0e30f;
|
|
var f2 = 1.0e30f;
|
|
var c1 = float3.zero;
|
|
var c2 = float3.zero;
|
|
var cellId = float3.zero;
|
|
|
|
for (var dz = -2; dz <= 2; dz++)
|
|
for (var dy = -2; dy <= 2; dy++)
|
|
for (var dx = -2; dx <= 2; dx++)
|
|
{
|
|
var neighbor = cell + new float3(dx, dy, dz);
|
|
var feature = FeaturePoint(neighbor);
|
|
var d = math.distance(p, feature);
|
|
if (d < f1) { f2 = f1; c2 = c1; f1 = d; c1 = feature; cellId = neighbor; }
|
|
else if (d < f2) { f2 = d; c2 = feature; }
|
|
}
|
|
|
|
var grad1 = f1 > 0.0f ? (p - c1) / f1 : float3.zero;
|
|
var grad2 = f2 > 0.0f ? (p - c2) / f2 : float3.zero;
|
|
var idHash = math.pcg4d(new int4(math.int3(cellId), 0));
|
|
return new CultCellular(new float4(grad1, f1), new float4(grad2 - grad1, f2 - f1), Unit(idHash.w));
|
|
}
|
|
|
|
private static double Correlation(double[] a, double[] b)
|
|
{
|
|
var n = a.Length;
|
|
var meanA = 0.0;
|
|
var meanB = 0.0;
|
|
for (var i = 0; i < n; i++) { meanA += a[i]; meanB += b[i]; }
|
|
meanA /= n;
|
|
meanB /= n;
|
|
|
|
var cov = 0.0;
|
|
var varA = 0.0;
|
|
var varB = 0.0;
|
|
for (var i = 0; i < n; i++)
|
|
{
|
|
var da = a[i] - meanA;
|
|
var db = b[i] - meanB;
|
|
cov += da * db;
|
|
varA += da * da;
|
|
varB += db * db;
|
|
}
|
|
|
|
return cov / Math.Sqrt(varA * varB);
|
|
}
|
|
|
|
// ---- shared fixtures: an independent brute-force Worley search over the same public pcg3d
|
|
// hash cellular itself is documented to use (design.md, "Integer hashing"), so these tests do
|
|
// not anchor to cellular's own control flow or iteration order. FindF1F2 searches 7x7x7 (radius
|
|
// 3), strictly wider than production's own proven-sufficient 5x5x5 (F6), so it is a true
|
|
// independent oracle rather than a second copy of the algorithm under test. ----
|
|
|
|
internal static float3 FeaturePoint(float3 cell)
|
|
{
|
|
var hash = math.pcg3d(math.int3(cell));
|
|
var jitter = new float3(Unit(hash.x), Unit(hash.y), Unit(hash.z));
|
|
return cell + jitter;
|
|
}
|
|
|
|
// Mirrors math.cellular_unit's top-24-bit [0, 1) mapping (F7) exactly, so this fixture never
|
|
// drifts from production's own jitter/id mapping.
|
|
private static float Unit(int bits) => ((uint)bits >> 8) * (1.0f / 16777216.0f);
|
|
|
|
private static (float f1, float f2) FindF1F2(float3 p)
|
|
{
|
|
var (f1, f2, _) = FindF1F2F3(p);
|
|
return (f1, f2);
|
|
}
|
|
|
|
// Same independent 7x7x7 oracle as FindF1F2, plus the third-nearest distance. Central-difference
|
|
// tests need this: excluding only the F1=F2 gap is not enough, because a small F2=F3 gap is also
|
|
// a kink (the identity of the second-nearest feature swaps there), and hitting one produces a
|
|
// large, curvature-driven finite-difference error unrelated to eps or float noise (Soul, cut
|
|
// 2a-i: an unfiltered large sample hit an edge error of 0.78 this way, versus ~2e-4 once F2=F3
|
|
// is also excluded).
|
|
private static (float f1, float f2, float f3) FindF1F2F3(float3 p)
|
|
{
|
|
var cell = new float3(MathF.Floor(p.x), MathF.Floor(p.y), MathF.Floor(p.z));
|
|
var f1 = float.PositiveInfinity;
|
|
var f2 = float.PositiveInfinity;
|
|
var f3 = float.PositiveInfinity;
|
|
for (var dz = -3; dz <= 3; dz++)
|
|
for (var dy = -3; dy <= 3; dy++)
|
|
for (var dx = -3; dx <= 3; dx++)
|
|
{
|
|
var d = math.distance(p, FeaturePoint(cell + new float3(dx, dy, dz)));
|
|
if (d < f1) { f3 = f2; f2 = f1; f1 = d; }
|
|
else if (d < f2) { f3 = f2; f2 = d; }
|
|
else if (d < f3) { f3 = d; }
|
|
}
|
|
|
|
return (f1, f2, f3);
|
|
}
|
|
|
|
private static (float3 offset, float f1, float f2) FindNearestOffset(float3 p)
|
|
{
|
|
var cell = new float3(MathF.Floor(p.x), MathF.Floor(p.y), MathF.Floor(p.z));
|
|
var f1 = float.PositiveInfinity;
|
|
var f2 = float.PositiveInfinity;
|
|
var bestOffset = float3.zero;
|
|
for (var dz = -2; dz <= 2; dz++)
|
|
for (var dy = -2; dy <= 2; dy++)
|
|
for (var dx = -2; dx <= 2; dx++)
|
|
{
|
|
var offset = new float3(dx, dy, dz);
|
|
var d = math.distance(p, FeaturePoint(cell + offset));
|
|
if (d < f1) { f2 = f1; f1 = d; bestOffset = offset; }
|
|
else if (d < f2) { f2 = d; }
|
|
}
|
|
|
|
return (bestOffset, f1, f2);
|
|
}
|
|
|
|
private static float3 FindNearestCell(float3 p)
|
|
{
|
|
var cell = new float3(MathF.Floor(p.x), MathF.Floor(p.y), MathF.Floor(p.z));
|
|
var best = float.PositiveInfinity;
|
|
var bestCell = cell;
|
|
for (var dz = -1; dz <= 1; dz++)
|
|
for (var dy = -1; dy <= 1; dy++)
|
|
for (var dx = -1; dx <= 1; dx++)
|
|
{
|
|
var neighbour = cell + new float3(dx, dy, dz);
|
|
var d = math.distance(p, FeaturePoint(neighbour));
|
|
if (d < best) { best = d; bestCell = neighbour; }
|
|
}
|
|
|
|
return bestCell;
|
|
}
|
|
|
|
private static void AssertGradientMatchesCentralDifference(Func<float3, float> scalarField, float3 p, float eps, float4 valueAndGradient, float tolerance)
|
|
{
|
|
var numeric = new float3(
|
|
(scalarField(p + new float3(eps, 0.0f, 0.0f)) - scalarField(p - new float3(eps, 0.0f, 0.0f))) / (2.0f * eps),
|
|
(scalarField(p + new float3(0.0f, eps, 0.0f)) - scalarField(p - new float3(0.0f, eps, 0.0f))) / (2.0f * eps),
|
|
(scalarField(p + new float3(0.0f, 0.0f, eps)) - scalarField(p - new float3(0.0f, 0.0f, eps))) / (2.0f * eps));
|
|
|
|
AssertWithinTolerance(numeric.x, valueAndGradient.x, tolerance);
|
|
AssertWithinTolerance(numeric.y, valueAndGradient.y, tolerance);
|
|
AssertWithinTolerance(numeric.z, valueAndGradient.z, tolerance);
|
|
}
|
|
|
|
// A fixed decimal-place Assert.Equal snaps two numbers that agree to within a few times 1e-5 to
|
|
// different rounded digits whenever their true difference straddles a rounding boundary (seen:
|
|
// 0.824987829 vs 0.825006127, agreeing to 2e-5, rounding to 0.82 and 0.83). An absolute-tolerance
|
|
// check states the actual claim (central differences agree with the analytic gradient within the
|
|
// finite-difference step's own truncation error) without that artifact.
|
|
private static void AssertWithinTolerance(float expected, float actual, float tolerance) =>
|
|
Assert.True(MathF.Abs(expected - actual) <= tolerance, $"expected {expected}, actual {actual}, tolerance {tolerance}");
|
|
|
|
private static float3 RandomUnit(System.Random random) =>
|
|
new(random.NextSingle() * 2.0f - 1.0f, random.NextSingle() * 2.0f - 1.0f, random.NextSingle() * 2.0f - 1.0f);
|
|
|
|
private static float RandomScalar(System.Random random) => random.NextSingle() * 20.0f - 10.0f;
|
|
}
|