SPIRV-Cross renames members matching GLSL builtins, so the source names change. Resolver and JSON writer accessors follow the members; the wire (positional MessagePack, JSON keys) is untouched in this commit. The committed GLSL is stale until its regeneration commit, so CommittedGlslEqualsLowering fails here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
340 lines
16 KiB
C#
340 lines
16 KiB
C#
using System;
|
|
using CultMath;
|
|
using Xunit;
|
|
|
|
namespace CultMath.Tests;
|
|
|
|
public sealed class PhacelleTests
|
|
{
|
|
// Every tolerance below is at least 5x the largest error the same test measures at its seed.
|
|
private const float GradientEps = 1.0e-3f;
|
|
private const float GradientTolerance = 5.0e-3f;
|
|
private const double ValueTolerance = 1.6e-5;
|
|
private const double ThresholdBand = 0.02;
|
|
|
|
private static float3 RandomPoint(System.Random random, float extent = 3.0f) => new(
|
|
(random.NextSingle() * 2.0f - 1.0f) * extent, (random.NextSingle() * 2.0f - 1.0f) * extent, (random.NextSingle() * 2.0f - 1.0f) * extent);
|
|
|
|
// A wave vector of length 2*pi times a stripe frequency in [0.5, 1.5], in a random direction.
|
|
private static float3 RandomSide(System.Random random)
|
|
{
|
|
float3 direction;
|
|
do
|
|
{
|
|
direction = new float3(random.NextSingle() - 0.5f, random.NextSingle() - 0.5f, random.NextSingle() - 0.5f);
|
|
}
|
|
while (math.length(direction) < 0.1f);
|
|
|
|
return math.normalize(direction) * (math.TAU * (0.5f + random.NextSingle()));
|
|
}
|
|
|
|
// ---- An independent double-precision evaluation: all 64 cells, no prune, no gradient. ----
|
|
//
|
|
// It shares only the hash (pcg3d over the integer cell coordinate, top 24 bits) and the constants
|
|
// the function is specified by: weight max(0, exp(-2 d^2) - 0.01111), jitter in [-0.5, 0.5).
|
|
|
|
private readonly record struct Reference(double Cos, double Sin, double RawLength, double SupportEdgeGap);
|
|
|
|
private static Reference Evaluate(float3 p, float3 side, float offset, float normalization)
|
|
{
|
|
double fx = Math.Floor(p.x), fy = Math.Floor(p.y), fz = Math.Floor(p.z);
|
|
double lx = p.x - fx, ly = p.y - fy, lz = p.z - fz;
|
|
double sumW = 0.0, sumCos = 0.0, sumSin = 0.0, edgeGap = double.MaxValue;
|
|
var supportRadius = Math.Sqrt(Math.Log(1.0 / 0.01111) / 2.0);
|
|
for (var dz = -1; dz <= 2; dz++)
|
|
for (var dy = -1; dy <= 2; dy++)
|
|
for (var dx = -1; dx <= 2; dx++)
|
|
{
|
|
var hash = math.pcg3d(new int3((int)fx + dx, (int)fy + dy, (int)fz + dz));
|
|
double vx = lx - dx - (math.cellular_unit(hash.x) - 0.5);
|
|
double vy = ly - dy - (math.cellular_unit(hash.y) - 0.5);
|
|
double vz = lz - dz - (math.cellular_unit(hash.z) - 0.5);
|
|
var d2 = vx * vx + vy * vy + vz * vz;
|
|
edgeGap = Math.Min(edgeGap, Math.Abs(Math.Sqrt(d2) - supportRadius));
|
|
var w = Math.Max(0.0, Math.Exp(-2.0 * d2) - 0.01111);
|
|
var phase = vx * side.x + vy * side.y + vz * side.z + (double)offset * math.TAU;
|
|
sumW += w;
|
|
sumCos += w * Math.Cos(phase);
|
|
sumSin += w * Math.Sin(phase);
|
|
}
|
|
|
|
double rawCos = sumCos / sumW, rawSin = sumSin / sumW;
|
|
var raw = Math.Sqrt(rawCos * rawCos + rawSin * rawSin);
|
|
var length = Math.Max(1.0 - normalization, raw);
|
|
return new Reference(rawCos / length, rawSin / length, raw, edgeGap);
|
|
}
|
|
|
|
private static float3 CentralDifference(Func<float3, float> 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 float MaxComponentError(float3 numeric, float4 analytic) => MathF.Max(
|
|
MathF.Abs(numeric.x - analytic.x), MathF.Max(MathF.Abs(numeric.y - analytic.y), MathF.Abs(numeric.z - analytic.z)));
|
|
|
|
// ---- Value: the function is Johansen's, on 3D cells ----
|
|
|
|
[Theory]
|
|
[InlineData(0.0f)]
|
|
[InlineData(0.5f)]
|
|
[InlineData(0.9f)]
|
|
[InlineData(1.0f)]
|
|
public void ValueMatchesTheIndependentReferenceAndNormalizesAboveTheThreshold(float normalization)
|
|
{
|
|
var random = new System.Random(0x7ACE1 + (int)(normalization * 10.0f));
|
|
var maxError = 0.0;
|
|
var above = 0;
|
|
var below = 0;
|
|
for (var i = 0; i < 3000; i++)
|
|
{
|
|
var p = RandomPoint(random, 40.0f);
|
|
var side = RandomSide(random);
|
|
var offset = random.NextSingle();
|
|
var actual = math.phacelle(p, side, offset, normalization);
|
|
var expected = Evaluate(p, side, offset, normalization);
|
|
|
|
maxError = Math.Max(maxError, Math.Max(Math.Abs(actual.cosine.w - expected.Cos), Math.Abs(actual.sine.w - expected.Sin)));
|
|
if (expected.RawLength >= 1.0 - normalization + ThresholdBand)
|
|
{
|
|
// The output magnitude is 1 wherever the raw magnitude reaches 1 - normalization.
|
|
above++;
|
|
var magnitude = Math.Sqrt((double)actual.cosine.w * actual.cosine.w + (double)actual.sine.w * actual.sine.w);
|
|
Assert.True(Math.Abs(magnitude - 1.0) < ValueTolerance, $"magnitude {magnitude} above the threshold at {p}");
|
|
}
|
|
else if (expected.RawLength <= 1.0 - normalization - ThresholdBand)
|
|
{
|
|
below++;
|
|
}
|
|
}
|
|
|
|
Assert.True(maxError < ValueTolerance, $"max value error against the reference {maxError}");
|
|
if (normalization > 0.0f)
|
|
Assert.True(above >= 500, $"only {above} points above the threshold");
|
|
if (normalization is > 0.0f and < 1.0f)
|
|
Assert.True(below >= 50, $"only {below} points below the threshold");
|
|
}
|
|
|
|
[Fact]
|
|
public void ValueIsPeriodicInOffsetAndAQuarterCycleRotatesCosIntoSin()
|
|
{
|
|
// offset is in cycles: a quarter turn maps cos to -sin and sin to cos, and a whole turn is the
|
|
// identity. The weights and the normalization do not depend on the phase, so this is exact
|
|
// up to float rounding.
|
|
var random = new System.Random(0x7ACE2);
|
|
for (var i = 0; i < 200; i++)
|
|
{
|
|
var p = RandomPoint(random);
|
|
var side = RandomSide(random);
|
|
var offset = random.NextSingle();
|
|
var basePhasor = math.phacelle(p, side, offset, 0.5f);
|
|
var quarter = math.phacelle(p, side, offset + 0.25f, 0.5f);
|
|
var whole = math.phacelle(p, side, offset + 1.0f, 0.5f);
|
|
|
|
Assert.True(MathF.Abs(quarter.cosine.w + basePhasor.sine.w) < 2.0e-5f);
|
|
Assert.True(MathF.Abs(quarter.sine.w - basePhasor.cosine.w) < 2.0e-5f);
|
|
Assert.True(MathF.Abs(whole.cosine.w - basePhasor.cosine.w) < 2.0e-5f);
|
|
Assert.True(MathF.Abs(whole.sine.w - basePhasor.sine.w) < 2.0e-5f);
|
|
}
|
|
}
|
|
|
|
// ---- Invariant 8: the gradient is exact ----
|
|
|
|
[Fact]
|
|
public void GradientOfCosAndSinMatchesCentralDifferencesOnBothSidesOfTheNormalizationThreshold()
|
|
{
|
|
// Above the threshold the output is I / |I|; below it, I / (1 - normalization). The gradient
|
|
// is discontinuous where |I| crosses the threshold, and at the edge of each cell's weight support
|
|
// (distance 1.49998, where max(0, ...) starts: a jump of 4 d 0.01111 in grad w), so a band
|
|
// around each is excluded (by the
|
|
// reference's raw length, since the output alone cannot say how close it is).
|
|
const float normalization = 0.5f;
|
|
var random = new System.Random(0x7ACE3);
|
|
var above = 0;
|
|
var below = 0;
|
|
var maxError = 0.0f;
|
|
for (var attempts = 0; attempts < 20000 && (above < 150 || below < 150); attempts++)
|
|
{
|
|
var p = RandomPoint(random);
|
|
var side = RandomSide(random);
|
|
var offset = random.NextSingle();
|
|
var reference = Evaluate(p, side, offset, normalization);
|
|
var distance = reference.RawLength - (1.0 - normalization);
|
|
if (Math.Abs(distance) < ThresholdBand || reference.SupportEdgeGap < 3.0 * GradientEps)
|
|
continue;
|
|
if (distance > 0.0 ? above >= 150 : below >= 150)
|
|
continue;
|
|
if (distance > 0.0) above++; else below++;
|
|
|
|
var analytic = math.phacelle(p, side, offset, normalization);
|
|
var numericCos = CentralDifference(q => math.phacelle(q, side, offset, normalization).cosine.w, p, GradientEps);
|
|
var numericSin = CentralDifference(q => math.phacelle(q, side, offset, normalization).sine.w, p, GradientEps);
|
|
maxError = MathF.Max(maxError, MathF.Max(MaxComponentError(numericCos, analytic.cosine), MaxComponentError(numericSin, analytic.sine)));
|
|
}
|
|
|
|
Assert.True(above >= 150 && below >= 150, $"{above} points above and {below} below the threshold");
|
|
Assert.True(maxError < GradientTolerance, $"max gradient error against central differences {maxError}");
|
|
}
|
|
|
|
// ---- Stripes run along the flow ----
|
|
|
|
[Fact]
|
|
public void OnTheUnitSphereStripesRunAlongTheFlow()
|
|
{
|
|
// side = cross(p_hat, flow) * 2*pi is tangent to the sphere and perpendicular to the flow, so the
|
|
// cosine should change across the stripes (along side) and hardly at all along them (along
|
|
// flow). Asura's probe measured 0.212 for this ratio, against 0.202 for the planar upstream.
|
|
const float radiusInCells = 10.0f;
|
|
var random = new System.Random(0x7ACE4);
|
|
var reference = math.normalize(new float3(0.3f, 0.5f, 0.8f));
|
|
var alongFlow = 0.0;
|
|
var acrossFlow = 0.0;
|
|
var used = 0;
|
|
while (used < 2000)
|
|
{
|
|
var hat = math.normalize(new float3(random.NextSingle() - 0.5f, random.NextSingle() - 0.5f, random.NextSingle() - 0.5f));
|
|
var tangent = reference - hat * math.dot(hat, reference);
|
|
if (math.length(tangent) < 0.2f)
|
|
continue;
|
|
|
|
var flow = math.normalize(tangent);
|
|
var sideDirection = math.cross(hat, flow);
|
|
var phasor = math.phacelle(hat * radiusInCells, sideDirection * math.TAU, 0.25f, 0.5f);
|
|
var gradient = new float3(phasor.cosine.x, phasor.cosine.y, phasor.cosine.z);
|
|
alongFlow += Math.Abs(math.dot(gradient, flow));
|
|
acrossFlow += Math.Abs(math.dot(gradient, sideDirection));
|
|
used++;
|
|
}
|
|
|
|
var ratio = alongFlow / acrossFlow;
|
|
Assert.True(ratio <= 0.25, $"mean|d cos/d flow| / mean|d cos/d side| = {ratio}");
|
|
}
|
|
|
|
// ---- Continuity ----
|
|
|
|
[Fact]
|
|
public void ValueStepDifferenceShrinksTenfoldPerTenfoldStepIncludingAcrossCellPlanes()
|
|
{
|
|
// Pairs straddle an integer plane on each axis (where the 4x4x4 window moves by one cell and a
|
|
// weight that did not vanish at the window edge would jump) and also start at random points.
|
|
// Lipschitz continuity means the largest difference over a step scales with the step.
|
|
var random = new System.Random(0x7ACE5);
|
|
var steps = new[] { 1.0e-3f, 1.0e-4f, 1.0e-5f };
|
|
var maxDifference = new float[steps.Length];
|
|
for (var i = 0; i < 3000; i++)
|
|
{
|
|
var p = RandomPoint(random);
|
|
var side = RandomSide(random);
|
|
var offset = random.NextSingle();
|
|
var axis = i % 3;
|
|
if (i % 2 == 0)
|
|
p = SetAxis(p, axis, MathF.Round(GetAxis(p, axis)));
|
|
for (var k = 0; k < steps.Length; k++)
|
|
{
|
|
var low = math.phacelle(SetAxis(p, axis, GetAxis(p, axis) - steps[k] * 0.5f), side, offset, 0.5f);
|
|
var high = math.phacelle(SetAxis(p, axis, GetAxis(p, axis) + steps[k] * 0.5f), side, offset, 0.5f);
|
|
maxDifference[k] = MathF.Max(maxDifference[k], MathF.Max(MathF.Abs(low.cosine.w - high.cosine.w), MathF.Abs(low.sine.w - high.sine.w)));
|
|
}
|
|
}
|
|
|
|
Assert.True(maxDifference[1] * 8.0f <= maxDifference[0], $"1e-3: {maxDifference[0]}, 1e-4: {maxDifference[1]}");
|
|
Assert.True(maxDifference[2] * 8.0f <= maxDifference[1], $"1e-4: {maxDifference[1]}, 1e-5: {maxDifference[2]}");
|
|
}
|
|
|
|
private static float GetAxis(float3 v, int axis) => axis == 0 ? v.x : axis == 1 ? v.y : v.z;
|
|
|
|
private static float3 SetAxis(float3 v, int axis, float value) =>
|
|
axis == 0 ? new float3(value, v.y, v.z) : axis == 1 ? new float3(v.x, value, v.z) : new float3(v.x, v.y, value);
|
|
|
|
// ---- Exact pruning ----
|
|
|
|
[Fact]
|
|
public void PrunedOutputIsBitEqualToTheUnprunedSum()
|
|
{
|
|
var random = new System.Random(0x7ACE6);
|
|
for (var i = 0; i < 20000; i++)
|
|
{
|
|
var p = RandomPoint(random, 50.0f);
|
|
var side = RandomSide(random);
|
|
var offset = random.NextSingle();
|
|
var normalization = random.NextSingle();
|
|
var pruned = math.phacelle(p, side, offset, normalization);
|
|
var unpruned = math.phacelle(p, side, offset, normalization, false);
|
|
|
|
AssertBitEqual(unpruned.cosine, pruned.cosine);
|
|
AssertBitEqual(unpruned.sine, pruned.sine);
|
|
}
|
|
}
|
|
|
|
// Points next to a cell whose jitter sits at the far corner of its cube: the cell's box bound is
|
|
// just under the 2.25 cut (2.2451 to 2.25) while its real distance is still under the support
|
|
// radius (d^2 < 2.24995), so its weight is small but nonzero and a prune that cuts even slightly
|
|
// low (2.245, 2.24, 2.2) drops a contribution and changes output bits. Random points meet such
|
|
// a cell about once in 10^4 evaluations, which is why the random sweep above let those cuts
|
|
// through. Each was found once by an offline search (cells K in [-12, 12]^3 whose jitter on one
|
|
// axis lies within 0.0008 of the cube face; p is the feature moved 1.4996 out along that axis,
|
|
// so |p - feature| = 1.4996 and the bound is about 1.4988). The witness check below re-derives
|
|
// that property from the hash, so a change to pcg3d fails loudly instead of degenerating.
|
|
// Shared with the HLSL mirror test.
|
|
internal static readonly float3[] PruneCutPoints =
|
|
{
|
|
new(1.77850008f, -2.16020727f, -0.9991166f),
|
|
new(1.09346282f, -2.37882328f, -3.00082445f),
|
|
new(-6.39144945f, -3.99953747f, -0.610023975f),
|
|
new(-0.99940908f, 4.16302729f, 4.07277536f),
|
|
new(3.99912024f, 7.21890545f, 1.09053993f),
|
|
new(5.43959713f, 1.99937367f, -6.14929914f),
|
|
};
|
|
|
|
internal static readonly float3 PruneCutSide = new(2.1f, -3.7f, 1.3f);
|
|
internal const float PruneCutOffset = 0.3f;
|
|
internal const float PruneCutNormalization = 0.5f;
|
|
|
|
// True when some cell of the 4x4x4 window has a box bound in [2.2451, 2.25) and a positive weight.
|
|
internal static bool HasCellJustInsideTheCut(float3 p)
|
|
{
|
|
double fx = Math.Floor(p.x), fy = Math.Floor(p.y), fz = Math.Floor(p.z);
|
|
double lx = p.x - fx, ly = p.y - fy, lz = p.z - fz;
|
|
for (var dz = -1; dz <= 2; dz++)
|
|
for (var dy = -1; dy <= 2; dy++)
|
|
for (var dx = -1; dx <= 2; dx++)
|
|
{
|
|
var gx = Math.Max(0.0, Math.Abs(lx - dx) - 0.5);
|
|
var gy = Math.Max(0.0, Math.Abs(ly - dy) - 0.5);
|
|
var gz = Math.Max(0.0, Math.Abs(lz - dz) - 0.5);
|
|
var bound = gx * gx + gy * gy + gz * gz;
|
|
if (bound < 2.2451 || bound >= 2.25)
|
|
continue;
|
|
|
|
var hash = math.pcg3d(new int3((int)fx + dx, (int)fy + dy, (int)fz + dz));
|
|
double vx = lx - dx - (math.cellular_unit(hash.x) - 0.5);
|
|
double vy = ly - dy - (math.cellular_unit(hash.y) - 0.5);
|
|
double vz = lz - dz - (math.cellular_unit(hash.z) - 0.5);
|
|
if (Math.Exp(-2.0 * (vx * vx + vy * vy + vz * vz)) > 0.01111)
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
[Fact]
|
|
public void PrunedOutputIsBitEqualToTheUnprunedSumNextToCellsJustInsideTheCut()
|
|
{
|
|
foreach (var p in PruneCutPoints)
|
|
{
|
|
Assert.True(HasCellJustInsideTheCut(p), $"no cell just inside the cut at {p}: the fixture no longer exercises the prune");
|
|
var pruned = math.phacelle(p, PruneCutSide, PruneCutOffset, PruneCutNormalization);
|
|
var unpruned = math.phacelle(p, PruneCutSide, PruneCutOffset, PruneCutNormalization, false);
|
|
|
|
AssertBitEqual(unpruned.cosine, pruned.cosine);
|
|
AssertBitEqual(unpruned.sine, pruned.sine);
|
|
}
|
|
}
|
|
|
|
private static void AssertBitEqual(float4 expected, float4 actual)
|
|
{
|
|
Assert.Equal(BitConverter.SingleToInt32Bits(expected.x), BitConverter.SingleToInt32Bits(actual.x));
|
|
Assert.Equal(BitConverter.SingleToInt32Bits(expected.y), BitConverter.SingleToInt32Bits(actual.y));
|
|
Assert.Equal(BitConverter.SingleToInt32Bits(expected.z), BitConverter.SingleToInt32Bits(actual.z));
|
|
Assert.Equal(BitConverter.SingleToInt32Bits(expected.w), BitConverter.SingleToInt32Bits(actual.w));
|
|
}
|
|
}
|