CultMath: CyclesOracleTests also holds the HLSL mirror (the shader text compiled as C#) to Blender's results at the fixture operands

A mutant of CultMath.Cycles.hlsl that changes a Cycles result the fixtures reach now fails a test on its own;
before, only the random-input mirror comparison reached the HLSL. Builds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-12 08:46:41 +02:00
commit 6196dcde17
2 changed files with 66 additions and 37 deletions

View file

@ -5,11 +5,14 @@ using Xunit;
namespace CultMath.Tests;
/// <summary>
/// math.cycles_* against the results of Blender 5.2.2's own Cycles on the CPU. The fixtures under
/// fixtures/cycles-oracle are written by tools/cycles-oracle/oracle.py (never by hand): each case is the
/// operands and the node's baked results as float32 bit patterns. Every function is compared at every case
/// within the tolerance named in <see cref="Tolerances"/>; a function not named there is held to 0 ulp. Signed zeros are not distinguished (-0 equals 0), and NaN equals NaN. The fixtures come from Cycles'
/// SSE4.2 CPU kernel, which does not fuse multiply-adds as C# does not (oracle.py checks this before it bakes).
/// math.cycles_* and the shader mirror's cultmath_cycles_* (shaders/CultMath.Cycles.hlsl, compiled as C#) against
/// the results of Blender 5.2.2's own Cycles on the CPU. The fixtures under fixtures/cycles-oracle are written by
/// tools/cycles-oracle/oracle.py (never by hand): each case is the operands and the node's baked results as
/// float32 bit patterns. Every function is compared at every case within the tolerance named in
/// <see cref="Tolerances"/>; a function not named there is held to 0 ulp. Signed zeros are not distinguished (-0
/// equals 0), and NaN equals NaN. The fixtures come from Cycles' SSE4.2 CPU kernel, which does not fuse
/// multiply-adds as C# does not (oracle.py checks this before it bakes). Cycles' film writes 0 for an infinite or
/// NaN result, so a case whose result here is not finite and whose fixture result is 0 holds nothing to compare.
/// </summary>
public sealed class CyclesOracleTests
{
@ -23,6 +26,35 @@ public sealed class CyclesOracleTests
["cycles_logarithm"] = (1, "0 ulp on Linux"),
};
private const string CSharp = "C#";
private const string Mirror = "HLSL mirror";
private static readonly Lazy<(object Shader, Type Type)> ShaderMirror = new(() =>
{
var (assembly, errors) = HlslSourceCompatibilityTests.CompileShaderMirror();
Assert.True(assembly is not null, string.Join(Environment.NewLine, errors));
var type = assembly!.GetType("CultMathHlsl.HlslShader")!;
return (Activator.CreateInstance(type)!, type);
});
/// <summary>One implementation of the Cycles functions: CultMath's C# or the HLSL text compiled as C#.</summary>
private static object Call(string target, string function, params object[] arguments)
{
if (target == CSharp)
return Method(function).Invoke(null, arguments)!;
var (shader, type) = ShaderMirror.Value;
var method = type.GetMethod("cultmath_" + function, BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("no mirror function cultmath_" + function);
return method.Invoke(shader, arguments)!;
}
public static IEnumerable<object[]> Families() =>
from family in new[] { "math", "map-range", "clamp", "mix", "rgb-to-bw" }
from target in new[] { CSharp, Mirror }
select new object[] { family, target };
public static IEnumerable<object[]> Targets() => new[] { CSharp, Mirror }.Select(target => new object[] { target });
private static string FixturePath(string family) =>
Path.Combine(HlslSourceCompatibilityTests.FindCultMathRoot(), "tests", "CultMath.Tests", "fixtures", "cycles-oracle", family + ".json");
@ -66,6 +98,7 @@ public sealed class CyclesOracleTests
private static float[] Flatten(object result) => result switch
{
float f => new[] { f },
float2 v => new[] { v.x, v.y },
float3 v => new[] { v.x, v.y, v.z },
_ => throw new InvalidOperationException(result.GetType().Name),
};
@ -74,12 +107,8 @@ public sealed class CyclesOracleTests
typeof(math).GetMethod(name, BindingFlags.Public | BindingFlags.Static) ?? throw new InvalidOperationException("no CultMath function " + name);
[Theory]
[InlineData("math")]
[InlineData("map-range")]
[InlineData("clamp")]
[InlineData("mix")]
[InlineData("rgb-to-bw")]
public void FamilyMatchesBlender(string family)
[MemberData(nameof(Families))]
public void FamilyMatchesBlender(string family, string target)
{
var fixture = Load(family);
Assert.Equal("5.2.2 LTS", (string?)fixture["blender"]);
@ -92,7 +121,7 @@ public sealed class CyclesOracleTests
var name = (string)entry!["function"]!;
var method = Method(name);
var clamped = (string?)entry["then"] is not null;
var allowed = Tolerances.TryGetValue(name + (clamped ? "+clamp" : string.Empty), out var tolerance) ? tolerance.Ulp : 0;
var allowed = Tolerances.TryGetValue(name, out var tolerance) ? tolerance.Ulp : 0;
var worst = 0L;
string? worstCase = null;
var cases = entry["cases"]!.AsArray();
@ -101,43 +130,43 @@ public sealed class CyclesOracleTests
{
var sides = ((string)line!).Split(" -> ");
var operands = sides[0].Split(' ');
var have = Flatten(method.Invoke(null, Arguments(method, operands))!);
var have = Flatten(Call(target, name, Arguments(method, operands)));
if (clamped)
{
// A Map Range node's Clamp option is a Clamp node of type Range on its result.
var toMin = Float(operands[3]);
var toMax = Float(operands[4]);
have = new[] { math.cycles_clamp_range(have[0], toMin, toMax) };
have = Flatten(Call(target, "cycles_clamp_range", have[0], Float(operands[3]), Float(operands[4])));
}
var want = sides[1].Split(' ').Select(Float).ToArray();
Assert.Equal(want.Length, have.Length);
for (var k = 0; k < want.Length; k++)
{
// Cycles' film writes 0 for an infinite or NaN result; the operands are finite, so such a case holds
// nothing to compare.
if (!float.IsFinite(have[k]) && want[k] == 0.0f)
continue;
var distance = Distance(want[k], have[k]);
if (distance > worst)
{
worst = distance;
worstCase = $"{line} (C# {BitConverter.SingleToInt32Bits(have[k]):x8})";
worstCase = $"{line} ({target} {BitConverter.SingleToInt32Bits(have[k]):x8})";
}
}
}
if (worst > allowed)
failures.Add($"{name}{(clamped ? " with Clamp" : string.Empty)}: {worst} ulp from Blender, allowed {allowed}; worst case {worstCase}");
failures.Add($"{target} {name}{(clamped ? " with Clamp" : string.Empty)}: {worst} ulp from Blender, allowed {allowed}; worst case {worstCase}");
}
Assert.True(failures.Count == 0, string.Join(Environment.NewLine, failures));
}
[Fact]
public void RampIndexNamesTheEntryBlenderReads()
private static JsonNode RampEntry(string interpolation) =>
Load("ramp")["functions"]!.AsArray().Single(e => e!["node"]!.ToString() == "ShaderNodeValToRGB " + interpolation)!;
[Theory]
[MemberData(nameof(Targets))]
public void RampIndexNamesTheEntryBlenderReads(string target)
{
var entry = Load("ramp")["functions"]!.AsArray().Single(e => (string)e!["node"]!.ToString() == "ShaderNodeValToRGB CONSTANT")!;
var entry = RampEntry("CONSTANT");
var size = (int)entry["table_entries"]!;
var stops = entry["stops"]!.AsArray().Select(stop => (Position: Float((string)stop![0]!), Value: Float((string)stop[1]!))).ToArray();
@ -153,9 +182,8 @@ public sealed class CyclesOracleTests
Assert.True(table.Distinct().Count() > 30, "the constant ramp's table should name 32 entries apart");
foreach (var line in cases)
{
var t = Float(line[0]);
var lookup = math.cycles_ramp_index(t, size);
var index = (int)lookup.x;
var lookup = Flatten(Call(target, "cycles_ramp_index", Float(line[0]), size));
var index = (int)lookup[0];
Assert.InRange(index, 0, size - 1);
Assert.True(Distance(Float(line[1]), table[index]) == 0, $"ramp t {line[0]}: index {index} reads {table[index]}, Blender {Float(line[1])}");
}
@ -164,27 +192,28 @@ public sealed class CyclesOracleTests
Assert.True(cases.Select(line => line[1]).Distinct().Count() >= 32, "the cases never cross the stops");
}
[Fact]
public void RampWeightBlendsTheEntryWithTheNextOne()
[Theory]
[MemberData(nameof(Targets))]
public void RampWeightBlendsTheEntryWithTheNextOne(string target)
{
// A linear ramp from 0 to 1: entry i holds i / 255 to within the float rounding of Blender's table, and a
// lookup at (index, weight) reads (index + weight) / 255. A weight that is off by a fraction of a
// A linear ramp from 0 to 1: entry i holds i / (size - 1) to within the float rounding of Blender's table, and
// a lookup at (index, weight) reads (index + weight) / (size - 1). A weight that is off by a fraction of a
// percent, or an index that is off by one, moves the result by more than the bound.
var entry = Load("ramp")["functions"]!.AsArray().Single(e => (string)e!["node"]!.ToString() == "ShaderNodeValToRGB LINEAR")!;
var entry = RampEntry("LINEAR");
var size = (int)entry["table_entries"]!;
var worst = 0.0;
var fractional = 0;
foreach (var line in entry["cases"]!.AsArray().Select(line => ((string)line!).Split(" -> ")))
{
var lookup = math.cycles_ramp_index(Float(line[0]), size);
Assert.InRange(lookup.y, 0.0f, 1.0f);
fractional += lookup.y > 0.0f ? 1 : 0;
var expected = (lookup.x + lookup.y) / (size - 1);
var lookup = Flatten(Call(target, "cycles_ramp_index", Float(line[0]), size));
Assert.InRange(lookup[1], 0.0f, 1.0f);
fractional += lookup[1] > 0.0f ? 1 : 0;
var expected = (lookup[0] + lookup[1]) / (size - 1);
worst = Math.Max(worst, Math.Abs(expected - Float(line[1])));
}
Assert.True(fractional > 500, "the cases hold too few interpolated lookups");
Assert.True(worst <= Math.ScaleB(1.0, -22), $"a lookup is {worst} from (index + weight) / 255, more than 2^-22");
Assert.True(worst <= Math.ScaleB(1.0, -22), $"a lookup is {worst} from (index + weight) / (size - 1), more than 2^-22");
}
[Fact]

View file

@ -433,7 +433,7 @@ public sealed class HlslSourceCompatibilityTests
return "using CultMath;\nusing static CultMath.math;\nnamespace CultMathHlsl;\npublic class HlslShader\n{\n" + source + "\n}\n";
}
private static (Assembly? Assembly, IReadOnlyList<string> Errors) CompileShaderMirror()
internal static (Assembly? Assembly, IReadOnlyList<string> Errors) CompileShaderMirror()
{
var hlsl = ReadShaderSource(FindCultMathRoot());
var tree = CSharpSyntaxTree.ParseText(TransformHlslToCSharp(hlsl));