diff --git a/packages/cultmath/tests/CultMath.Tests/CyclesOracleTests.cs b/packages/cultmath/tests/CultMath.Tests/CyclesOracleTests.cs index 28074a69..2cafbcf5 100644 --- a/packages/cultmath/tests/CultMath.Tests/CyclesOracleTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/CyclesOracleTests.cs @@ -5,11 +5,14 @@ using Xunit; namespace CultMath.Tests; /// -/// 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 ; 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 +/// ; 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. /// 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); + }); + + /// One implementation of the Cycles functions: CultMath's C# or the HLSL text compiled as C#. + 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 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 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] diff --git a/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs b/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs index 7226658a..0d2b310c 100644 --- a/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs @@ -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 Errors) CompileShaderMirror() + internal static (Assembly? Assembly, IReadOnlyList Errors) CompileShaderMirror() { var hlsl = ReadShaderSource(FindCultMathRoot()); var tree = CSharpSyntaxTree.ParseText(TransformHlslToCSharp(hlsl));