Test smin_grad and cellular's invariants behaviourally
CellularAndSminGradTests pins the rules the cut map calls out, each against a mutant it must kill: - smin's value never exceeds the ordinary minimum, and matches min with that input's own gradient once |a.w - b.w| exceeds k on either side. - smin_grad's gradient matches central differences of its own .w at random seeded points (the formula is exact, not an approximation, so no exclusion band is needed away from the |a.w-b.w|=k seam itself). - A direct case falsifies blending the gradient with a constant 0.5 or with (1 - h) instead of h. - cellular's F1 never exceeds F2, matched against an independent brute-force Worley search that shares only the public pcg3d hash, not cellular's control flow. - The true nearest feature wins even when an independent search finds it in the (1, 1, 1) neighbour offset, the last one cellular's own loop order visits, ruling out a "first found" or early-exit mutant. - The gradient at a feature point is finite zero, not NaN. - Both nearest and edge gradients match central differences away from a band around the F1 = F2 set, where the nearest and second-nearest identity swap discontinuously. - id is constant across small moves that stay in the same feature's region and differs from a distant cell's id. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
c13e7f7871
commit
3eed434b31
1 changed files with 314 additions and 0 deletions
|
|
@ -0,0 +1,314 @@
|
|||
using System;
|
||||
using CultMath;
|
||||
using Xunit;
|
||||
|
||||
namespace CultMath.Tests;
|
||||
|
||||
public sealed class CellularAndSminGradTests
|
||||
{
|
||||
// ---- smin_grad ----
|
||||
|
||||
[Fact]
|
||||
public void SminValueNeverExceedsTheOrdinaryMinimum()
|
||||
{
|
||||
var random = new 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 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 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).
|
||||
var diff = F(p) - G(p);
|
||||
if (Math.Abs(Math.Abs(diff) - k) < 0.05f * k)
|
||||
continue;
|
||||
|
||||
tested++;
|
||||
var analytic = math.smin_grad(new float4(cf, F(p)), new float4(cg, G(p)), k);
|
||||
const float eps = 0.001f;
|
||||
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));
|
||||
|
||||
Assert.Equal(numeric.x, analytic.x, precision: 2);
|
||||
Assert.Equal(numeric.y, analytic.y, precision: 2);
|
||||
Assert.Equal(numeric.z, analytic.z, precision: 2);
|
||||
}
|
||||
}
|
||||
|
||||
[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 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}");
|
||||
var (f1, _) = FindF1F2(p);
|
||||
Assert.Equal(f1, result.nearest.w, precision: 3);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NearestFeatureWinsEvenWhenItIsInTheLastVisitedNeighbourCell()
|
||||
{
|
||||
// math.cellular's own search order visits neighbour offsets with dz outermost and dx
|
||||
// innermost, so (dx, dy, dz) = (1, 1, 1) relative to floor(p) is the very last candidate it
|
||||
// looks at. A "first found within some radius" mutant, or one that stops early, would miss
|
||||
// it. Rather than hand-picking a hash value, search random points for one whose true nearest
|
||||
// feature (by independent brute force) happens to live in exactly that last-visited cell, at
|
||||
// a comfortable margin from the F1 = F2 seam, then check math.cellular agrees.
|
||||
var random = new 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 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) = FindF1F2(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 skip the exact feature point itself (F1 = 0), covered separately.
|
||||
if (f2 - f1 < 0.05f || f1 < 0.01f)
|
||||
continue;
|
||||
|
||||
tested++;
|
||||
var result = math.cellular(p);
|
||||
const float eps = 0.0005f;
|
||||
float F1At(float3 q) => FindF1F2(q).f1;
|
||||
float F2At(float3 q) => FindF1F2(q).f2;
|
||||
|
||||
AssertGradientMatchesCentralDifference(F1At, p, eps, result.nearest);
|
||||
var edgeValueFn = new Func<float3, float>(q => { var (a, b) = FindF1F2(q); return b - a; });
|
||||
AssertGradientMatchesCentralDifference(edgeValueFn, p, eps, result.edge);
|
||||
}
|
||||
|
||||
Assert.True(tested >= 100, $"only {tested} of {attempts} random points landed away from the F1=F2 band");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IdIsConstantAcrossAFeaturesWholeRegionAndVariesBetweenCells()
|
||||
{
|
||||
var random = new 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);
|
||||
}
|
||||
|
||||
// ---- 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. ----
|
||||
|
||||
private static float3 FeaturePoint(float3 cell)
|
||||
{
|
||||
var hash = math.pcg3d(cell);
|
||||
var jitter = new float3(Unit(hash.x), Unit(hash.y), Unit(hash.z));
|
||||
return cell + jitter;
|
||||
}
|
||||
|
||||
private static float Unit(int bits) => (uint)bits * (1.0f / 4294967296.0f);
|
||||
|
||||
private static (float f1, float f2) FindF1F2(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;
|
||||
for (var dz = -1; dz <= 1; dz++)
|
||||
for (var dy = -1; dy <= 1; dy++)
|
||||
for (var dx = -1; dx <= 1; dx++)
|
||||
{
|
||||
var d = math.distance(p, FeaturePoint(cell + new float3(dx, dy, dz)));
|
||||
if (d < f1) { f2 = f1; f1 = d; }
|
||||
else if (d < f2) { f2 = d; }
|
||||
}
|
||||
|
||||
return (f1, f2);
|
||||
}
|
||||
|
||||
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 = -1; dz <= 1; dz++)
|
||||
for (var dy = -1; dy <= 1; dy++)
|
||||
for (var dx = -1; dx <= 1; 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)
|
||||
{
|
||||
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));
|
||||
|
||||
Assert.Equal(numeric.x, valueAndGradient.x, precision: 2);
|
||||
Assert.Equal(numeric.y, valueAndGradient.y, precision: 2);
|
||||
Assert.Equal(numeric.z, valueAndGradient.z, precision: 2);
|
||||
}
|
||||
|
||||
private static float3 RandomUnit(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(Random random) => random.NextSingle() * 20.0f - 10.0f;
|
||||
}
|
||||
Loading…
Reference in a new issue