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)