snoise tie rule: x over y, y over z, x over z (a total order) in C# and both HLSL copies

The cyclic non-strict step(x0.yzx, x0.xyz) made the four corners a non-simplex
where x0's components are equal: the origin and every (t, t, t) read a different
function (snoise(0) was -0.4358730, jumps up to 0.77). One strict compare, z
against x, gives simplexnoise1234's order. Off the tie set old and new return
the same bits.

NoiseGradTests.SnoiseIsContinuousOnTheSimplexDiagonal fails against the old
rule (0.054 within 1 ulp of (-53.6, -53.6, -53.6)). The origin golden is 0 and a
(1.25, 1.25, 1.25) diagonal row joins it. SNOISE_LIPSCHITZ and the GLSL follow
in the next commits, so the ratio pin and CommittedGlslEqualsLowering are red here.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-04 03:07:02 +02:00
commit e89fd68778
5 changed files with 74 additions and 11 deletions

View file

@ -80,13 +80,14 @@ float3 cultmath_snoise_permute(float3 value) { return cultmath_snoise_mod289(((v
float4 cultmath_snoise_permute(float4 value) { return cultmath_snoise_mod289(((value * 34.0) + 10.0) * value); }
// Ashima Arts / Ian McEwan 3D simplex noise (MIT), same float32 evaluation
// order as the C# math.snoise(float3) mirror.
// order as the C# math.snoise(float3) mirror. Ties resolve in simplexnoise1234's
// total order; see math.snoise.
float cultmath_snoise(float3 value)
{
float2 c = float2(1.0 / 6.0, 1.0 / 3.0);
float3 i = floor(value + dot(value, c.yyy));
float3 x0 = value - i + dot(i, c.xxx);
float3 g = step(x0.yzx, x0.xyz);
float3 g = float3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x));
float3 l = 1.0 - g;
float3 i1 = min(g.xyz, l.zxy);
float3 i2 = max(g.xyz, l.zxy);
@ -172,7 +173,7 @@ float4 cultmath_snoise_grad(float3 value)
float2 c = float2(1.0 / 6.0, 1.0 / 3.0);
float3 i = floor(value + dot(value, c.yyy));
float3 x0 = value - i + dot(i, c.xxx);
float3 g = step(x0.yzx, x0.xyz);
float3 g = float3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x));
float3 l = 1.0 - g;
float3 i1 = min(g.xyz, l.zxy);
float3 i2 = max(g.xyz, l.zxy);

View file

@ -599,7 +599,10 @@ public static partial class math
// in shaders/CultMath.hlsl with the same float32 evaluation order. Follows stegu/webgl-noise
// src/noise3D.glsl at 22434e04d7 (kernel radius^2 0.5, scale 105, permute (34x+10)x); see
// THIRD-PARTY-NOTICES.md. Continuous across simplex-cell boundaries
// (NoiseGradTests.SnoiseAndItsGradientAreContinuousAcrossSimplexCellBoundaries).
// (NoiseGradTests.SnoiseAndItsGradientAreContinuousAcrossSimplexCellBoundaries). Ties in x0 resolve
// in Gustavson's simplexnoise1234 order (x over y, y over z, x over z), a total order, so the four
// corners are always a simplex; upstream's step is cyclic and degenerates where x0's components are
// equal (NoiseGradTests.SnoiseIsContinuousOnTheSimplexDiagonal).
// SNOISE_LIPSCHITZ (math.Interval.cs) is measured from this kernel: a change here must re-run
// NoiseBoundTests.MeasureLipschitz and re-pin it.
public static float snoise(float3 value)
@ -608,7 +611,7 @@ public static partial class math
const float cy = 1.0f / 3.0f;
var i = floor(value + dot(value, new float3(cy, cy, cy)));
var x0 = value - i + dot(i, new float3(cx, cx, cx));
var g = step(new float3(x0.y, x0.z, x0.x), x0);
var g = new float3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0f - step(x0.z, x0.x));
var l = 1.0f - g;
var lzxy = new float3(l.z, l.x, l.y);
var i1 = min(g, lzxy);
@ -714,7 +717,7 @@ public static partial class math
const float cy = 1.0f / 3.0f;
var i = floor(value + dot(value, new float3(cy, cy, cy)));
var x0 = value - i + dot(i, new float3(cx, cx, cx));
var g = step(new float3(x0.y, x0.z, x0.x), x0);
var g = new float3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0f - step(x0.z, x0.x));
var l = 1.0f - g;
var lzxy = new float3(l.z, l.x, l.y);
var i1 = min(g, lzxy);

View file

@ -444,7 +444,8 @@ public sealed class HlslSemanticsTests
}
[Theory]
[InlineData(0.0f, 0.0f, 0.0f, -0.435872972f)]
[InlineData(0.0f, 0.0f, 0.0f, 0.0f)]
[InlineData(1.25f, 1.25f, 1.25f, -0.245501652f)]
[InlineData(0.25f, -0.5f, 1.75f, 0.0492948629f)]
[InlineData(12.25f, -4.5f, 3.125f, 0.443114728f)]
[InlineData(-7.3f, 2.9f, 101.4f, -0.10221792f)]

View file

@ -133,7 +133,7 @@ public sealed class NoiseGradTests
{
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 = math.step(new float3(x0.y, x0.z, x0.x), x0);
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));
@ -190,6 +190,63 @@ public sealed class NoiseGradTests
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 by up to 0.77 on the line (t, t, t), lattice diagonals included.
[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<float3>();
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]

View file

@ -80,13 +80,14 @@ float3 cultmath_snoise_permute(float3 value) { return cultmath_snoise_mod289(((v
float4 cultmath_snoise_permute(float4 value) { return cultmath_snoise_mod289(((value * 34.0) + 10.0) * value); }
// Ashima Arts / Ian McEwan 3D simplex noise (MIT), same float32 evaluation
// order as the C# math.snoise(float3) mirror.
// order as the C# math.snoise(float3) mirror. Ties resolve in simplexnoise1234's
// total order; see math.snoise.
float cultmath_snoise(float3 value)
{
float2 c = float2(1.0 / 6.0, 1.0 / 3.0);
float3 i = floor(value + dot(value, c.yyy));
float3 x0 = value - i + dot(i, c.xxx);
float3 g = step(x0.yzx, x0.xyz);
float3 g = float3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x));
float3 l = 1.0 - g;
float3 i1 = min(g.xyz, l.zxy);
float3 i2 = max(g.xyz, l.zxy);
@ -172,7 +173,7 @@ float4 cultmath_snoise_grad(float3 value)
float2 c = float2(1.0 / 6.0, 1.0 / 3.0);
float3 i = floor(value + dot(value, c.yyy));
float3 x0 = value - i + dot(i, c.xxx);
float3 g = step(x0.yzx, x0.xyz);
float3 g = float3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x));
float3 l = 1.0 - g;
float3 i1 = min(g.xyz, l.zxy);
float3 i2 = max(g.xyz, l.zxy);