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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-12 05:28:03 +02:00
commit 51c5da11c4
7 changed files with 147 additions and 28 deletions

View file

@ -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

View file

@ -20,13 +20,13 @@ public static partial class math
private const float CYCLES_FLT_EPSILON = 1.1920929e-07f;
/// <summary>The scene-linear red luminance coefficient Cycles' RGB to BW node reads from Blender 5.2.2's default OCIO config.</summary>
public const float CYCLES_RGB_TO_Y_R = 0.2126729f;
public const float CYCLES_RGB_TO_Y_R = 0.212639093f;
/// <summary>The scene-linear green luminance coefficient Cycles' RGB to BW node reads from Blender 5.2.2's default OCIO config.</summary>
public const float CYCLES_RGB_TO_Y_G = 0.7151522f;
public const float CYCLES_RGB_TO_Y_G = 0.715169132f;
/// <summary>The scene-linear blue luminance coefficient Cycles' RGB to BW node reads from Blender 5.2.2's default OCIO config.</summary>
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;
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>Math node Floored Modulo (safe_floored_modulo): a - floor(a / b) b, or 0 when b is 0.</summary>

View file

@ -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 <see cref="Tolerances"/> 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).
/// </summary>
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)
{

View file

@ -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 -- <fixture directory> [<family> ...]
```
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
<https://download.blender.org/release/Blender5.2/> (`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`).

View file

@ -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,

View file

@ -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

View file

@ -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)