From 51c5da11c45f5e7f6618d0f697c904d8d288eed5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 05:28:03 +0200 Subject: [PATCH] CultMath: cycles_modulo is exact (power-of-two subtraction, no quotient form); RGB to BW coefficients are Blender's; oracle.py pins the SSE4.2 CPU kernel The oracle's first fixtures showed Cycles' AVX2 kernel fuses multiply-adds and its film zeroes non-finite results; the tool now exits 4 off the SSE4.2 kernel (CYCLES_CPU_NO_AVX2=1) and the test skips cases Cycles cannot report. The Unity copy of CultMath.hlsl carries the new include. Builds. Fixtures and CultMath.Cycles.glsl are still to come. Co-Authored-By: Claude Sonnet 5.5 --- .../cultmath/shaders/CultMath.Cycles.hlsl | 26 +++++++--- packages/cultmath/src/CultMath/math.Cycles.cs | 27 +++++++--- .../tests/CultMath.Tests/CyclesOracleTests.cs | 7 ++- .../cultmath/tools/cycles-oracle/README.md | 52 +++++++++++++++++++ .../cultmath/tools/cycles-oracle/oracle.py | 36 +++++++++++-- .../Shaders/CultMath.Cycles.hlsl | 26 +++++++--- .../Shaders/CultMath.hlsl | 1 + 7 files changed, 147 insertions(+), 28 deletions(-) create mode 100644 packages/cultmath/tools/cycles-oracle/README.md diff --git a/packages/cultmath/shaders/CultMath.Cycles.hlsl b/packages/cultmath/shaders/CultMath.Cycles.hlsl index 5102b6dc..6c4b73b8 100644 --- a/packages/cultmath/shaders/CultMath.Cycles.hlsl +++ b/packages/cultmath/shaders/CultMath.Cycles.hlsl @@ -19,9 +19,9 @@ static const float CULTMATH_CYCLES_FLT_EPSILON = 1.1920929e-07; // The scene-linear luminance coefficients Cycles reads from Blender 5.2.2's default OCIO config // (kernel/film linear_rgb_to_gray). The oracle fixture pins them with a case per primary. -static const float CULTMATH_CYCLES_RGB_TO_Y_R = 0.2126729; -static const float CULTMATH_CYCLES_RGB_TO_Y_G = 0.7151522; -static const float CULTMATH_CYCLES_RGB_TO_Y_B = 0.0721750; +static const float CULTMATH_CYCLES_RGB_TO_Y_R = 0.212639093; +static const float CULTMATH_CYCLES_RGB_TO_Y_G = 0.715169132; +static const float CULTMATH_CYCLES_RGB_TO_Y_B = 0.0721927434; // ---- Math node (kernel/svm/math_util.h svm_math; util/math_base.h) ---- @@ -63,13 +63,23 @@ float cultmath_cycles_sqrt(float a) { return sqrt(max(a, 0.0)); } // inversesqrtf float cultmath_cycles_inverse_sqrt(float a) { return a > 0.0 ? 1.0 / sqrt(a) : 0.0; } -// safe_modulo is fmodf, the remainder with the dividend's sign. GLSL has no fmod and the HLSL one is this -// quotient form on a GPU, so this is the form every CultMath target shares; it is not fmodf bit for bit, -// and CyclesOracleTests states how far it is. +// safe_modulo is fmodf, the remainder with the dividend's sign, exactly. GLSL has no fmod, and a - b trunc(a / b) +// is wrong where the quotient rounds up to an integer or the product rounds (3 mod 0.3 is 0.29999995, not 0). +// So this subtracts the largest power-of-two multiple of the divisor not above the remainder until the +// remainder is below the divisor: every step is an exact subtraction, as in fmod. The loops are bounded by the +// binades a float spans. float cultmath_cycles_modulo(float a, float b) { - float quotient = a / b; - return b != 0.0 ? a - b * (quotient >= 0.0 ? floor(quotient) : ceil(quotient)) : 0.0; + float remainder = abs(a); + float divisor = abs(b); + for (int steps = 0; steps < 300 && remainder >= divisor && divisor > 0.0; steps++) + { + float multiple = divisor; + for (int doublings = 0; doublings < 300 && multiple * 2.0 <= remainder; doublings++) + multiple = multiple * 2.0; + remainder = remainder - multiple; + } + return b != 0.0 ? (a < 0.0 ? -remainder : remainder) : 0.0; } // safe_floored_modulo diff --git a/packages/cultmath/src/CultMath/math.Cycles.cs b/packages/cultmath/src/CultMath/math.Cycles.cs index 01bbe7dd..c8a52749 100644 --- a/packages/cultmath/src/CultMath/math.Cycles.cs +++ b/packages/cultmath/src/CultMath/math.Cycles.cs @@ -20,13 +20,13 @@ public static partial class math private const float CYCLES_FLT_EPSILON = 1.1920929e-07f; /// The scene-linear red luminance coefficient Cycles' RGB to BW node reads from Blender 5.2.2's default OCIO config. - public const float CYCLES_RGB_TO_Y_R = 0.2126729f; + public const float CYCLES_RGB_TO_Y_R = 0.212639093f; /// The scene-linear green luminance coefficient Cycles' RGB to BW node reads from Blender 5.2.2's default OCIO config. - public const float CYCLES_RGB_TO_Y_G = 0.7151522f; + public const float CYCLES_RGB_TO_Y_G = 0.715169132f; /// The scene-linear blue luminance coefficient Cycles' RGB to BW node reads from Blender 5.2.2's default OCIO config. - public const float CYCLES_RGB_TO_Y_B = 0.0721750f; + public const float CYCLES_RGB_TO_Y_B = 0.0721927434f; // ---- Math node (kernel/svm/math_util.h svm_math; util/math_base.h) ---- @@ -72,14 +72,25 @@ public static partial class math public static float cycles_inverse_sqrt(float a) => a > 0.0f ? 1.0f / sqrt(a) : 0.0f; /// - /// Math node Truncated Modulo (safe_modulo), the remainder with the dividend's sign, or 0 when b is 0. - /// Cycles computes fmodf. GLSL has no fmod and HLSL's is this quotient form on a GPU, so this is the form - /// every CultMath target shares: it is not fmodf bit for bit, and CyclesOracleTests states how far it is. + /// Math node Truncated Modulo (safe_modulo), the remainder with the dividend's sign, exactly as fmodf computes + /// it, or 0 when b is 0. GLSL has no fmod, and a - b trunc(a / b) is wrong where the quotient rounds up to an + /// integer or the product rounds (3 mod 0.3 is 0.29999995, not 0). So this subtracts the largest power-of-two + /// multiple of the divisor not above the remainder until the remainder is below the divisor: every step is an + /// exact subtraction, as in fmod. The loops are bounded by the binades a float spans. /// public static float cycles_modulo(float a, float b) { - var quotient = a / b; - return b != 0.0f ? a - b * (quotient >= 0.0f ? floor(quotient) : ceil(quotient)) : 0.0f; + var remainder = abs(a); + var divisor = abs(b); + for (var steps = 0; steps < 300 && remainder >= divisor && divisor > 0.0f; steps++) + { + var multiple = divisor; + for (var doublings = 0; doublings < 300 && multiple * 2.0f <= remainder; doublings++) + multiple = multiple * 2.0f; + remainder = remainder - multiple; + } + + return b != 0.0f ? (a < 0.0f ? -remainder : remainder) : 0.0f; } /// Math node Floored Modulo (safe_floored_modulo): a - floor(a / b) b, or 0 when b is 0. diff --git a/packages/cultmath/tests/CultMath.Tests/CyclesOracleTests.cs b/packages/cultmath/tests/CultMath.Tests/CyclesOracleTests.cs index a2cbfdb9..12e76c79 100644 --- a/packages/cultmath/tests/CultMath.Tests/CyclesOracleTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/CyclesOracleTests.cs @@ -9,7 +9,8 @@ namespace CultMath.Tests; /// 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 its family states below; a function not named in is held to -/// 0 ulp. Signed zeros are not distinguished (-0 equals 0), and NaN equals NaN. +/// 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). /// public sealed class CyclesOracleTests { @@ -107,6 +108,10 @@ public sealed class CyclesOracleTests 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) { diff --git a/packages/cultmath/tools/cycles-oracle/README.md b/packages/cultmath/tools/cycles-oracle/README.md new file mode 100644 index 00000000..777d58b9 --- /dev/null +++ b/packages/cultmath/tools/cycles-oracle/README.md @@ -0,0 +1,52 @@ +# Cycles oracle + +`oracle.py` produces the fixtures that `CyclesOracleTests` holds `math.cycles_*` to: for each Cycles node +CultMath ports, the node's results as Blender's own Cycles computes them. + +## Run + +``` +CYCLES_CPU_NO_AVX2=1 blender --background --factory-startup --python oracle.py -- [ ...] +``` + +The fixture directory is `tests/CultMath.Tests/fixtures/cycles-oracle`. Families are `math`, `map-range`, +`clamp`, `mix`, `rgb-to-bw` and `ramp`; with none named, all are written. The tool is a Blender Python +script and runs only under Blender, never under a plain Python. + +## The pin + +It refuses to run under any Blender but **5.2.2 LTS, build hash `d13f752e3b9c`**, exits nonzero and names +the version and build it found beside the ones it needs. Blender 5.2.2 is + (`blender-5.2.2-linux-x64.tar.xz`, or the Windows build +from Steam). The fixtures record the version, the hash and the platform they were written on. + +## The CPU kernel + +Cycles' AVX2 CPU kernel is compiled with fused multiply-add, so an expression such as `a - floor(a / b) b` is +one fused operation there, and CultMath's C# (which fuses nothing) computes the unfused one. The fixtures pin +the SSE4.2 kernel, which has no FMA, by `CYCLES_CPU_NO_AVX2=1`; the tool bakes `10000 mod 0.1` before anything +else and exits 4 if the result is the fused one, so a run on the wrong kernel writes nothing. + +## Method + +Operands go into float, Non-Color input images (channel packed, read with Closest interpolation), three +operands per image. The node under test reads them. One Emit bake per output channel (Cycles on the CPU, 1 +sample, margin 0, no denoising) writes the result to a float image; each pixel is one case. The tool first +checks that a bake returns an operand image bit for bit, and stops if it does not. The fixtures hold the +operands and the results as float32 bit patterns, so a test reads exactly what Cycles computed. + +A Map Range node's Clamp option is baked as the node with Clamp on, which Cycles expands into a Clamp node +of type Range on the result. The ramp family bakes a Color Ramp whose table entries are each read at a `t` +whose product with 255 is the entry's index, so the first 256 cases are the ramp's own table. + +## Fixtures + +Written by this tool and never edited by hand. Rerunning it under the same Blender build on the same +platform rewrites them byte for byte; another platform's libm can differ in the transcendental families +(`power`, `logarithm`), which is why `CyclesOracleTests` states a tolerance for them. + +## Licence + +`oracle.py` is GPL-3.0-or-later, as a script that runs inside Blender (`bpy`). It ships in no package: the +`GameCult.Math` NuGet package and the Unity package do not contain `tools/`. The fixtures are data it +produced. The Cycles functions they test are Apache-2.0 (`math.Cycles.cs`, `CultMath.Cycles.hlsl`). diff --git a/packages/cultmath/tools/cycles-oracle/oracle.py b/packages/cultmath/tools/cycles-oracle/oracle.py index 66a21bf5..a442d695 100644 --- a/packages/cultmath/tools/cycles-oracle/oracle.py +++ b/packages/cultmath/tools/cycles-oracle/oracle.py @@ -45,7 +45,10 @@ PLANE = bpy.context.active_object def by_identifier(sockets, identifier): """A node's socket by identifier: several data types share one socket name.""" - return next(socket for socket in sockets if socket.identifier == identifier) + for socket in sockets: + if socket.identifier == identifier: + return socket + raise KeyError("no socket %s among %s" % (identifier, [s.identifier for s in sockets])) def f32(values): @@ -308,7 +311,7 @@ def family_map_range(): node.interpolation_type = kind node.clamp = False sockets = operand_sockets(tree, imgs, 6) - for k, identifier in enumerate(["Value", "From_Min", "From_Max", "To_Min", "To_Max", "Steps"]): + for k, identifier in enumerate(["Value", "From_Min_FLOAT", "From_Max_FLOAT", "To_Min_FLOAT", "To_Max_FLOAT", "Steps_FLOAT"]): socket = by_identifier(node.inputs, identifier) tree.links.new(sockets[k], socket) return [node.outputs["Result"]] @@ -321,7 +324,7 @@ def family_map_range(): node.interpolation_type = kind node.clamp = True sockets = operand_sockets(tree, imgs, 6) - for k, identifier in enumerate(["Value", "From_Min", "From_Max", "To_Min", "To_Max", "Steps"]): + for k, identifier in enumerate(["Value", "From_Min_FLOAT", "From_Max_FLOAT", "To_Min_FLOAT", "To_Max_FLOAT", "Steps_FLOAT"]): socket = by_identifier(node.inputs, identifier) tree.links.new(sockets[k], socket) return [node.outputs["Result"]] @@ -511,12 +514,39 @@ def identity_check(): sys.exit(3) +def kernel_check(): + """The CPU kernel must not fuse multiply-adds. + + Cycles' AVX2 CPU kernel is compiled with FMA, so a - floor(a / b) b is one fused operation there and + 10000 mod 0.1 is -1.5e-4; the SSE4.2 kernel has none, and gives the unfused result 0 that CultMath's C# + computes. The fixtures pin the SSE4.2 kernel: run with CYCLES_CPU_NO_AVX2=1. + """ + a = f32([10000.0] * CASES) + b = f32([0.1] * CASES) + unfused = a - np.floor(a / b) * b + images = pack_operands([a, b]) + + def build(tree, imgs): + node = tree.nodes.new("ShaderNodeMath") + node.operation = "FLOORED_MODULO" + sockets = operand_sockets(tree, imgs, 2) + tree.links.new(sockets[0], node.inputs[0]) + tree.links.new(sockets[1], node.inputs[1]) + return [node.outputs[0]] + + got = bake("kernel", images, build, 1)[0] + if not np.array_equal(got.view(np.uint32), unfused.astype(np.float32).view(np.uint32)): + sys.stderr.write("cycles-oracle: this Cycles CPU kernel fuses multiply-adds; run with CYCLES_CPU_NO_AVX2=1\n") + sys.exit(4) + + def main(): argv = sys.argv[sys.argv.index("--") + 1:] out = argv[0] wanted = argv[1:] or list(FAMILIES) os.makedirs(out, exist_ok=True) identity_check() + kernel_check() header = { "oracle": "tools/cycles-oracle/oracle.py (Blender %s, build %s, Cycles CPU, 1 sample, Emit bake, float Non-Color images)" % (PINNED_VERSION, PINNED_HASH), "blender": PINNED_VERSION, diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.Cycles.hlsl b/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.Cycles.hlsl index 5102b6dc..6c4b73b8 100644 --- a/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.Cycles.hlsl +++ b/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.Cycles.hlsl @@ -19,9 +19,9 @@ static const float CULTMATH_CYCLES_FLT_EPSILON = 1.1920929e-07; // The scene-linear luminance coefficients Cycles reads from Blender 5.2.2's default OCIO config // (kernel/film linear_rgb_to_gray). The oracle fixture pins them with a case per primary. -static const float CULTMATH_CYCLES_RGB_TO_Y_R = 0.2126729; -static const float CULTMATH_CYCLES_RGB_TO_Y_G = 0.7151522; -static const float CULTMATH_CYCLES_RGB_TO_Y_B = 0.0721750; +static const float CULTMATH_CYCLES_RGB_TO_Y_R = 0.212639093; +static const float CULTMATH_CYCLES_RGB_TO_Y_G = 0.715169132; +static const float CULTMATH_CYCLES_RGB_TO_Y_B = 0.0721927434; // ---- Math node (kernel/svm/math_util.h svm_math; util/math_base.h) ---- @@ -63,13 +63,23 @@ float cultmath_cycles_sqrt(float a) { return sqrt(max(a, 0.0)); } // inversesqrtf float cultmath_cycles_inverse_sqrt(float a) { return a > 0.0 ? 1.0 / sqrt(a) : 0.0; } -// safe_modulo is fmodf, the remainder with the dividend's sign. GLSL has no fmod and the HLSL one is this -// quotient form on a GPU, so this is the form every CultMath target shares; it is not fmodf bit for bit, -// and CyclesOracleTests states how far it is. +// safe_modulo is fmodf, the remainder with the dividend's sign, exactly. GLSL has no fmod, and a - b trunc(a / b) +// is wrong where the quotient rounds up to an integer or the product rounds (3 mod 0.3 is 0.29999995, not 0). +// So this subtracts the largest power-of-two multiple of the divisor not above the remainder until the +// remainder is below the divisor: every step is an exact subtraction, as in fmod. The loops are bounded by the +// binades a float spans. float cultmath_cycles_modulo(float a, float b) { - float quotient = a / b; - return b != 0.0 ? a - b * (quotient >= 0.0 ? floor(quotient) : ceil(quotient)) : 0.0; + float remainder = abs(a); + float divisor = abs(b); + for (int steps = 0; steps < 300 && remainder >= divisor && divisor > 0.0; steps++) + { + float multiple = divisor; + for (int doublings = 0; doublings < 300 && multiple * 2.0 <= remainder; doublings++) + multiple = multiple * 2.0; + remainder = remainder - multiple; + } + return b != 0.0 ? (a < 0.0 ? -remainder : remainder) : 0.0; } // safe_floored_modulo diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl b/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl index 6856a000..c29fd603 100644 --- a/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl +++ b/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl @@ -509,6 +509,7 @@ CultCellular cultmath_cellular(float3 p) #include "CultMath.Phacelle.hlsl" #include "CultMath.Interval.hlsl" +#include "CultMath.Cycles.hlsl" #include "CultMath.Affine.hlsl" float cultmath_value_noise(float2 position)