CultMath: the Cycles oracle tool (oracle.py) and CyclesOracleTests

oracle.py bakes each Cycles node under Blender 5.2.2 LTS (build d13f752e3b9c, refusing any other)
into float images; CyclesOracleTests holds math.cycles_* to the fixtures it writes. No fixtures
exist at this commit, so the tests fail here; builds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-12 04:04:39 +02:00
commit 99884cc34c
2 changed files with 706 additions and 0 deletions

View file

@ -0,0 +1,174 @@
using System.Reflection;
using System.Text.Json.Nodes;
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 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.
/// </summary>
public sealed class CyclesOracleTests
{
// The ulp distance each function is allowed from Cycles, with the measured worst case that earned it.
// Anything absent is 0 ulp.
private static readonly Dictionary<string, (int Ulp, string Measured)> Tolerances = new();
private static string FixturePath(string family) =>
Path.Combine(HlslSourceCompatibilityTests.FindCultMathRoot(), "tests", "CultMath.Tests", "fixtures", "cycles-oracle", family + ".json");
private static JsonObject Load(string family) => JsonNode.Parse(File.ReadAllText(FixturePath(family)))!.AsObject();
private static float Float(string token) => BitConverter.Int32BitsToSingle((int)Convert.ToUInt32(token, 16));
// A float32 bit pattern as an integer whose order and differences are the floats' order and ulps.
private static int Ordered(float value)
{
var bits = BitConverter.SingleToInt32Bits(value);
return bits < 0 ? int.MinValue - bits : bits;
}
private static long Distance(float want, float have) =>
want == have || (float.IsNaN(want) && float.IsNaN(have)) ? 0 : float.IsNaN(want) || float.IsNaN(have) ? long.MaxValue : Math.Abs((long)Ordered(want) - Ordered(have));
// The method takes floats and float3s only; each float3 is three consecutive tokens.
private static object[] Arguments(MethodInfo method, string[] tokens)
{
var values = new List<object>();
var next = 0;
foreach (var parameter in method.GetParameters())
{
if (parameter.ParameterType == typeof(float))
values.Add(Float(tokens[next++]));
else if (parameter.ParameterType == typeof(float3))
{
values.Add(new float3(Float(tokens[next]), Float(tokens[next + 1]), Float(tokens[next + 2])));
next += 3;
}
else
throw new InvalidOperationException($"{method.Name}: parameter type {parameter.ParameterType.Name} is not a fixture operand");
}
Assert.Equal(tokens.Length, next);
return values.ToArray();
}
private static float[] Flatten(object result) => result switch
{
float f => new[] { f },
float3 v => new[] { v.x, v.y, v.z },
_ => throw new InvalidOperationException(result.GetType().Name),
};
private static MethodInfo Method(string name) =>
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)
{
var fixture = Load(family);
Assert.Equal("5.2.2 LTS", (string?)fixture["blender"]);
Assert.Equal("d13f752e3b9c", (string?)fixture["build_hash"]);
var functions = fixture["functions"]!.AsArray();
Assert.NotEmpty(functions);
var failures = new List<string>();
foreach (var entry in functions)
{
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 worst = 0L;
string? worstCase = null;
var cases = entry["cases"]!.AsArray();
Assert.True(cases.Count >= 1000, $"{name}: only {cases.Count} fixture cases");
foreach (var line in cases)
{
var sides = ((string)line!).Split(" -> ");
var operands = sides[0].Split(' ');
var have = Flatten(method.Invoke(null, 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) };
}
var want = sides[1].Split(' ').Select(Float).ToArray();
Assert.Equal(want.Length, have.Length);
for (var k = 0; k < want.Length; k++)
{
var distance = Distance(want[k], have[k]);
if (distance > worst)
{
worst = distance;
worstCase = $"{line} (C# {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}");
}
Assert.True(failures.Count == 0, string.Join(Environment.NewLine, failures));
}
[Fact]
public void RampIndexAndWeightReproduceBlendersLookup()
{
var fixture = Load("ramp");
var entry = fixture["functions"]!.AsArray().Single()!;
var size = (int)entry["table_entries"]!;
var cases = entry["cases"]!.AsArray().Select(line => ((string)line!).Split(" -> ")).ToArray();
// The first `size` cases are the table: each t reads its own entry (weight 0), so the result is the entry.
var table = new float[size];
for (var k = 0; k < size; k++)
{
var t = Float(cases[k][0]);
var lookup = math.cycles_ramp_index(t, size);
Assert.Equal((float)k, lookup.x);
Assert.Equal(0.0f, lookup.y);
table[k] = Float(cases[k][1]);
}
var interpolated = 0;
for (var c = size; c < cases.Length; c++)
{
var t = Float(cases[c][0]);
var lookup = math.cycles_ramp_index(t, size);
var index = (int)lookup.x;
Assert.InRange(index, 0, size - 1);
var have = lookup.y > 0.0f ? (1.0f - lookup.y) * table[index] + lookup.y * table[index + 1] : table[index];
interpolated += lookup.y > 0.0f ? 1 : 0;
var want = Float(cases[c][1]);
Assert.True(Distance(want, have) == 0, $"ramp t {cases[c][0]}: index {index}, weight {lookup.y}, got {have}, Blender {want}");
}
Assert.True(interpolated > 100, "the fixture holds too few interpolated lookups");
}
[Fact]
public void RgbToBwCoefficientsAreTheOnesBlenderReads()
{
var entry = Load("rgb-to-bw")["functions"]!.AsArray().Single()!["cases"]!.AsArray().Select(line => ((string)line!).Split(" -> ")).ToArray();
float Primary(int channel) => Float(entry.First(c =>
{
var operands = c[0].Split(' ').Select(Float).ToArray();
return Enumerable.Range(0, 3).All(k => operands[k] == (k == channel ? 1.0f : 0.0f));
})[1]);
Assert.Equal(math.CYCLES_RGB_TO_Y_R, Primary(0));
Assert.Equal(math.CYCLES_RGB_TO_Y_G, Primary(1));
Assert.Equal(math.CYCLES_RGB_TO_Y_B, Primary(2));
}
}

View file

@ -0,0 +1,532 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# The Cycles oracle for CultMath's Cycles node semantics (math.Cycles.cs, shaders/CultMath.Cycles.hlsl).
# A Blender Python script, so it is GPL-3.0-or-later; it is a test tool and ships in no package.
#
# blender --background --factory-startup --python oracle.py -- <fixture directory> [<family> ...]
#
# It refuses to run under any Blender but 5.2.2 LTS, build hash d13f752e3b9c. For each family it bakes
# the node under test with Cycles on the CPU and writes tests/CultMath.Tests/fixtures/cycles-oracle/<family>.json.
# Method: operands go into float, Non-Color input images read with Closest interpolation; one Emit bake per
# output channel (1 sample, margin 0, no denoising) writes the node's result to a float image whose pixels are
# the results, bit for bit. The fixtures are this tool's output and are never edited by hand.
import json
import os
import sys
import bpy
import numpy as np
PINNED_VERSION = "5.2.2 LTS"
PINNED_HASH = "d13f752e3b9c"
SIDE = 32
CASES = SIDE * SIDE
def refuse_other_blender():
found = (bpy.app.version_string, bpy.app.build_hash.decode() if isinstance(bpy.app.build_hash, bytes) else bpy.app.build_hash)
if found != (PINNED_VERSION, PINNED_HASH):
sys.stderr.write(
"cycles-oracle: needs Blender %s build %s, found %s build %s\n" % (PINNED_VERSION, PINNED_HASH, found[0], found[1]))
sys.exit(2)
refuse_other_blender()
scene = bpy.context.scene
scene.render.engine = "CYCLES"
scene.cycles.device = "CPU"
scene.cycles.samples = 1
scene.cycles.use_denoising = False
bpy.ops.mesh.primitive_plane_add(size=2)
PLANE = bpy.context.active_object
def f32(values):
return np.asarray(values, dtype=np.float32)
def bits(value):
return "%08x" % int(np.asarray(value, dtype=np.float32).view(np.uint32))
def rng_for(name):
return np.random.default_rng(int.from_bytes(name.encode(), "little") % (2**32))
def new_image(name, rgba=None):
image = bpy.data.images.new(name, SIDE, SIDE, alpha=True, float_buffer=True)
image.colorspace_settings.name = "Non-Color"
image.alpha_mode = "CHANNEL_PACKED"
if rgba is not None:
image.pixels.foreach_set(rgba.reshape(-1))
image.pack()
return image
def pack_operands(columns):
"""Operand columns (CASES float32 each) to input images of three operands per image."""
images = []
for first in range(0, len(columns), 3):
rgba = np.zeros((SIDE * SIDE, 4), dtype=np.float32)
rgba[:, 3] = 1.0
for k, column in enumerate(columns[first:first + 3]):
rgba[:, k] = column
images.append(new_image("operands%d" % first, rgba.reshape(SIDE, SIDE, 4)))
return images
def operand_sockets(tree, images, count):
"""One float socket per operand, read from the input images with Closest interpolation."""
sockets = []
for image in images:
texture = tree.nodes.new("ShaderNodeTexImage")
texture.image = image
texture.interpolation = "Closest"
separate = tree.nodes.new("ShaderNodeSeparateColor")
tree.links.new(texture.outputs["Color"], separate.inputs[0])
sockets.append([separate.outputs[0], separate.outputs[1], separate.outputs[2]])
flat = [socket for triple in sockets for socket in triple]
return flat[:count]
def triple_socket(tree, images, first):
"""The colour made of operands first, first + 1, first + 2."""
combine = tree.nodes.new("ShaderNodeCombineColor")
sockets = operand_sockets(tree, images, first + 3)
for k in range(3):
tree.links.new(sockets[first + k], combine.inputs[k])
return combine.outputs[0]
def bake(label, images, build, channels):
"""Bakes build(tree, images) once per output channel; returns channels float32 arrays of CASES results."""
results = []
for channel in range(channels):
material = bpy.data.materials.new("%s.%d" % (label, channel))
material.use_nodes = True
tree = material.node_tree
tree.nodes.clear()
output = tree.nodes.new("ShaderNodeOutputMaterial")
emit = tree.nodes.new("ShaderNodeEmission")
tree.links.new(emit.outputs[0], output.inputs[0])
sockets = build(tree, images)
socket = sockets[channel]
if socket.type == "RGBA" or socket.type == "VECTOR":
separate = tree.nodes.new("ShaderNodeSeparateColor")
tree.links.new(socket, separate.inputs[0])
# Cycles stores a colour result channel by channel; the channel under test is red.
tree.links.new(separate.outputs[0], emit.inputs["Color"])
else:
tree.links.new(socket, emit.inputs["Color"])
target = new_image("%s.%d.out" % (label, channel))
target_node = tree.nodes.new("ShaderNodeTexImage")
target_node.image = target
tree.nodes.active = target_node
PLANE.data.materials.clear()
PLANE.data.materials.append(material)
bpy.context.view_layer.objects.active = PLANE
PLANE.select_set(True)
bpy.ops.object.bake(type="EMIT", margin=0)
pixels = np.empty(SIDE * SIDE * 4, dtype=np.float32)
target.pixels.foreach_get(pixels)
results.append(pixels.reshape(SIDE * SIDE, 4)[:, 0].copy())
return results
def vector_bake(label, images, build):
"""For a node whose result is a colour: one bake per RGB channel via Separate Color."""
results = []
for channel in range(3):
def pick(tree, imgs, channel=channel):
color = build(tree, imgs)
separate = tree.nodes.new("ShaderNodeSeparateColor")
tree.links.new(color, separate.inputs[0])
return [separate.outputs[channel]]
results.append(bake("%s.c%d" % (label, channel), images, pick, 1)[0])
return results
# ---- operand sets ----
SPECIAL = [0.0, 1.0, -1.0, 0.5, -0.5, 2.0, -2.0, 3.0, -2.75, 0.1, -0.1, 7.5, -7.5, 1.0e-7, -1.0e-7, 100.0, -100.0, 1.0e4, 0.3, 0.7]
POSITIVE = [0.0, 1.0, 0.5, 2.0, 3.0, 0.1, 10.0, 7.5, 1.0e-3, 100.0, 0.3, 0.7, 2.718281828]
FACTOR = [0.0, 0.5, 1.0, 1.5, -0.5, 0.25, 0.75, 2.0]
COLOR_SPECIAL = [0.0, 0.25, 0.5, 0.75, 1.0, 1.25, -0.25, 0.1, 0.9, 0.3333333, 0.6666667]
def sample_columns(name, count, specials, lows_highs, pair_specials=2):
"""CASES rows of `count` operands: the specials' product for the first operands, then uniform draws."""
rng = rng_for(name)
rows = []
import itertools
lists = [specials[k] if k < len(specials) else None for k in range(count)]
fixed = [lst for lst in lists if lst is not None]
if fixed:
for combo in itertools.product(*fixed):
row = list(combo) + [None] * (count - len(combo))
rows.append(row)
rows = rows[:CASES]
while len(rows) < CASES:
rows.append([None] * count)
columns = []
for k in range(count):
low, high = lows_highs[k]
column = np.empty(CASES, dtype=np.float32)
for r, row in enumerate(rows):
column[r] = row[k] if row[k] is not None else rng.uniform(low, high)
columns.append(column)
return columns
def random_color_rows(name, extra=0):
"""CASES colours: primaries, secondaries, greys, the specials' products, then random with tied channels."""
rng = rng_for(name)
rows = []
for r in COLOR_SPECIAL[:5]:
for g in COLOR_SPECIAL[:5]:
for b in COLOR_SPECIAL[:5]:
rows.append((r, g, b))
for v in COLOR_SPECIAL:
rows.append((v, v, v))
while len(rows) < CASES:
c = list(rng.uniform(-0.25, 1.25, 3))
tie = rng.integers(0, 6)
if tie == 0:
c[1] = c[0]
elif tie == 1:
c[2] = c[1]
elif tie == 2:
c[2] = c[0]
rows.append(tuple(c))
return rows[:CASES]
# ---- families ----
MATH = [
# (function, operation, operand count, operand specials, ranges)
("cycles_divide", "DIVIDE", 2, [SPECIAL, SPECIAL], [(-20, 20)] * 2),
("cycles_power", "POWER", 2, [SPECIAL, SPECIAL], [(-4, 4), (-4, 4)]),
("cycles_logarithm", "LOGARITHM", 2, [SPECIAL, SPECIAL], [(0.01, 20), (0.01, 20)]),
("cycles_sqrt", "SQRT", 1, [SPECIAL], [(-5, 20)]),
("cycles_inverse_sqrt", "INVERSE_SQRT", 1, [SPECIAL], [(-5, 20)]),
("cycles_modulo", "MODULO", 2, [SPECIAL, SPECIAL], [(-20, 20)] * 2),
("cycles_floored_modulo", "FLOORED_MODULO", 2, [SPECIAL, SPECIAL], [(-20, 20)] * 2),
("cycles_wrap", "WRAP", 3, [SPECIAL[:10], SPECIAL[:10], SPECIAL[:10]], [(-20, 20), (-10, 10), (-10, 10)]),
("cycles_snap", "SNAP", 2, [SPECIAL, SPECIAL], [(-20, 20)] * 2),
("cycles_pingpong", "PINGPONG", 2, [SPECIAL, SPECIAL], [(-20, 20)] * 2),
("cycles_smooth_min", "SMOOTH_MIN", 3, [SPECIAL[:10], SPECIAL[:10], SPECIAL[:10]], [(-20, 20), (-20, 20), (-3, 3)]),
("cycles_smooth_max", "SMOOTH_MAX", 3, [SPECIAL[:10], SPECIAL[:10], SPECIAL[:10]], [(-20, 20), (-20, 20), (-3, 3)]),
("cycles_compare", "COMPARE", 3, [SPECIAL[:10], SPECIAL[:10], [0.0, 0.5, 1.0, 1.0e-7]], [(-20, 20), (-20, 20), (0, 2)]),
("cycles_round", "ROUND", 1, [SPECIAL], [(-20, 20)]),
("cycles_trunc", "TRUNC", 1, [SPECIAL], [(-20, 20)]),
("cycles_fract", "FRACT", 1, [SPECIAL], [(-20, 20)]),
("cycles_sign", "SIGN", 1, [SPECIAL], [(-20, 20)]),
]
# Operands that must be present as exact pairs whatever the random draws: the quirks the port names.
EXTRA_ROWS = {
"cycles_power": [(-2.0, 1.0e10), (-2.0, -1.0e10), (-2.0, 3.0), (-2.0, 2.0), (-2.0, -3.0), (-2.0, 2147483648.0), (-2.0, -2147483648.0),
(-8.0, 0.3333333), (2.0, 0.0), (0.0, 0.0), (0.0, 2.0), (-0.0, -3.0), (-1.5, 17.0), (-1.5, 16777217.0)],
"cycles_modulo": [(0.3, 0.1), (-0.3, 0.1), (5.5, -2.0), (-5.5, -2.0), (1.0, 0.0), (1.0e7, 3.0), (7.0, 7.0), (-7.0, 7.0)],
}
MAP_RANGE = [
("cycles_map_range_linear", "LINEAR"),
("cycles_map_range_stepped", "STEPPED"),
("cycles_map_range_smoothstep", "SMOOTHSTEP"),
("cycles_map_range_smootherstep", "SMOOTHERSTEP"),
]
MIX_MODES = [
("cycles_mix_blend", "MIX"), ("cycles_mix_add", "ADD"), ("cycles_mix_mul", "MULTIPLY"), ("cycles_mix_screen", "SCREEN"),
("cycles_mix_overlay", "OVERLAY"), ("cycles_mix_sub", "SUBTRACT"), ("cycles_mix_div", "DIVIDE"), ("cycles_mix_diff", "DIFFERENCE"),
("cycles_mix_exclusion", "EXCLUSION"), ("cycles_mix_dark", "DARKEN"), ("cycles_mix_light", "LIGHTEN"), ("cycles_mix_dodge", "DODGE"),
("cycles_mix_burn", "BURN"), ("cycles_mix_hue", "HUE"), ("cycles_mix_sat", "SATURATION"), ("cycles_mix_val", "VALUE"),
("cycles_mix_color", "COLOR"), ("cycles_mix_soft", "SOFT_LIGHT"), ("cycles_mix_linear", "LINEAR_LIGHT"),
]
def with_extra(function, columns, count):
extra = EXTRA_ROWS.get(function, [])
for r, row in enumerate(extra):
for k in range(count):
columns[k][CASES - 1 - r] = row[k] if k < len(row) else 0.0
return columns
def encode_cases(columns, results):
cases = []
for r in range(CASES):
cases.append("%s -> %s" % (" ".join(bits(c[r]) for c in columns), " ".join(bits(x[r]) for x in results)))
return cases
def family_math():
functions = []
for function, operation, count, specials, ranges in MATH:
columns = with_extra(function, sample_columns(function, count, specials, ranges), count)
images = pack_operands(columns)
def build(tree, imgs, operation=operation, count=count):
node = tree.nodes.new("ShaderNodeMath")
node.operation = operation
sockets = operand_sockets(tree, imgs, count)
for k in range(count):
tree.links.new(sockets[k], node.inputs[k])
return [node.outputs[0]]
functions.append({"function": function, "node": "ShaderNodeMath " + operation, "cases": encode_cases(columns, bake(function, images, build, 1))})
return functions
def family_map_range():
functions = []
for function, kind in MAP_RANGE:
count = 6
specials = [SPECIAL[:10], SPECIAL[:5], SPECIAL[:5]]
columns = sample_columns(function, count, specials, [(-20, 20), (-10, 10), (-10, 10), (-20, 20), (-20, 20), (-1, 8)])
# Rows with From Min equal to From Max, and with From Min above From Max, whatever the draws.
for r in range(8):
columns[2][CASES - 1 - r] = columns[1][CASES - 1 - r]
for r in range(8, 16):
columns[1][CASES - 1 - r], columns[2][CASES - 1 - r] = max(columns[1][CASES - 1 - r], columns[2][CASES - 1 - r]), min(columns[1][CASES - 1 - r], columns[2][CASES - 1 - r])
for r in range(16, 24):
columns[5][CASES - 1 - r] = float(r - 18)
images = pack_operands(columns)
def build(tree, imgs, kind=kind):
node = tree.nodes.new("ShaderNodeMapRange")
node.data_type = "FLOAT"
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"]):
socket = next(i for i in node.inputs if i.identifier == identifier and i.enabled)
tree.links.new(sockets[k], socket)
return [node.outputs["Result"]]
functions.append({"function": function, "node": "ShaderNodeMapRange " + kind, "cases": encode_cases(columns, bake(function, images, build, 1))})
# The Clamp option, on the same operands: a Clamp node of type Range on the result.
def build_clamped(tree, imgs, kind=kind):
node = tree.nodes.new("ShaderNodeMapRange")
node.data_type = "FLOAT"
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"]):
socket = next(i for i in node.inputs if i.identifier == identifier and i.enabled)
tree.links.new(sockets[k], socket)
return [node.outputs["Result"]]
functions.append({"function": function, "node": "ShaderNodeMapRange " + kind + " with Clamp",
"then": "cycles_clamp_range(result, to_min, to_max)",
"cases": encode_cases(columns, bake(function + ".clamped", images, build_clamped, 1))})
return functions
def family_clamp():
functions = []
for function, clamp_type in (("cycles_clamp_minmax", "MINMAX"), ("cycles_clamp_range", "RANGE")):
columns = sample_columns(function, 3, [SPECIAL[:10], SPECIAL[:10], SPECIAL[:10]], [(-20, 20)] * 3)
images = pack_operands(columns)
def build(tree, imgs, clamp_type=clamp_type):
node = tree.nodes.new("ShaderNodeClamp")
node.clamp_type = clamp_type
sockets = operand_sockets(tree, imgs, 3)
for k in range(3):
tree.links.new(sockets[k], node.inputs[k])
return [node.outputs[0]]
functions.append({"function": function, "node": "ShaderNodeClamp " + clamp_type, "cases": encode_cases(columns, bake(function, images, build, 1))})
return functions
def mix_columns(name, blend):
rng = rng_for(name)
colors = random_color_rows(name)
colors2 = random_color_rows(name + "2")
rng.shuffle(colors2)
factors = [FACTOR[r % len(FACTOR)] if r < 8 * 64 else float(rng.uniform(-0.5, 1.5)) for r in range(CASES)]
columns = [f32(factors)]
columns += [f32([c[k] for c in colors]) for k in range(3)]
columns += [f32([c[k] for c in colors2]) for k in range(3)]
return columns
def family_mix():
functions = []
rng = rng_for("mix-float")
# Mix, Float: a and b in the usual ranges, the factor as in the blend modes.
columns = [f32([FACTOR[r % len(FACTOR)] if r < 512 else rng.uniform(-0.5, 1.5) for r in range(CASES)]),
f32(rng.uniform(-20, 20, CASES)), f32(rng.uniform(-20, 20, CASES))]
images = pack_operands(columns)
def build_float(tree, imgs):
node = tree.nodes.new("ShaderNodeMix")
node.data_type = "FLOAT"
node.clamp_factor = False
node.clamp_result = False
sockets = operand_sockets(tree, imgs, 3)
for k, identifier in enumerate(["Factor_Float", "A_Float", "B_Float"]):
tree.links.new(sockets[k], next(i for i in node.inputs if i.identifier == identifier))
return [node.outputs["Result_Float"]]
functions.append({"function": "cycles_mix_float", "node": "ShaderNodeMix FLOAT", "cases": encode_cases(columns, bake("mix_float", images, build_float, 1))})
columns = mix_columns("mix-vector", "VECTOR")
images = pack_operands(columns)
def build_vector(tree, imgs):
node = tree.nodes.new("ShaderNodeMix")
node.data_type = "VECTOR"
node.factor_mode = "UNIFORM"
node.clamp_factor = False
sockets = operand_sockets(tree, imgs, 1)
tree.links.new(sockets[0], next(i for i in node.inputs if i.identifier == "Factor_Float"))
tree.links.new(triple_socket(tree, imgs, 1), next(i for i in node.inputs if i.identifier == "A_Vector"))
tree.links.new(triple_socket(tree, imgs, 4), next(i for i in node.inputs if i.identifier == "B_Vector"))
return node.outputs["Result_Vector"]
functions.append({"function": "cycles_mix_vector", "node": "ShaderNodeMix VECTOR UNIFORM", "cases": encode_cases(columns, vector_bake("mix_vector", images, build_vector))})
for function, blend in MIX_MODES:
columns = mix_columns(function, blend)
images = pack_operands(columns)
def build(tree, imgs, blend=blend):
node = tree.nodes.new("ShaderNodeMix")
node.data_type = "RGBA"
node.blend_type = blend
node.clamp_factor = False
node.clamp_result = False
sockets = operand_sockets(tree, imgs, 1)
tree.links.new(sockets[0], next(i for i in node.inputs if i.identifier == "Factor_Float"))
tree.links.new(triple_socket(tree, imgs, 1), next(i for i in node.inputs if i.identifier == "A_Color"))
tree.links.new(triple_socket(tree, imgs, 4), next(i for i in node.inputs if i.identifier == "B_Color"))
return node.outputs["Result_Color"]
functions.append({"function": function, "node": "ShaderNodeMix RGBA " + blend, "cases": encode_cases(columns, vector_bake(function, images, build))})
return functions
def family_rgb_to_bw():
rows = random_color_rows("rgb_to_bw")
columns = [f32([c[k] for c in rows]) for k in range(3)]
# One case per primary pins the scene-linear coefficients.
for k in range(3):
for j in range(3):
columns[j][CASES - 1 - k] = 1.0 if j == k else 0.0
images = pack_operands(columns)
def build(tree, imgs):
node = tree.nodes.new("ShaderNodeRGBToBW")
tree.links.new(triple_socket(tree, imgs, 0), node.inputs["Color"])
return [node.outputs["Val"]]
return [{"function": "cycles_rgb_to_bw", "node": "ShaderNodeRGBToBW", "cases": encode_cases(columns, bake("rgb_to_bw", images, build, 1))}]
RAMP_SIZE = 256
RAMP_STOPS = [(0.0, 0.0), (0.3, 1.0), (1.0, 0.25)]
def family_ramp():
rng = rng_for("ramp")
# The table: one case per entry, at a t whose float32 product with 255 is the entry's index, so the
# lookup returns the entry itself.
table_t = []
for k in range(RAMP_SIZE):
base = np.float32(k) / np.float32(RAMP_SIZE - 1)
found = None
candidate = base
for _ in range(8):
if np.float32(candidate * np.float32(RAMP_SIZE - 1)) == np.float32(k):
found = candidate
break
candidate = np.nextafter(candidate, np.float32(2.0))
if found is None:
candidate = base
for _ in range(8):
candidate = np.nextafter(candidate, np.float32(-1.0))
if np.float32(candidate * np.float32(RAMP_SIZE - 1)) == np.float32(k):
found = candidate
break
if found is None:
raise RuntimeError("no float32 t reads ramp entry %d exactly" % k)
table_t.append(found)
specials = [-0.5, -0.25, -1.0e-7, 0.0, 1.0e-7, 0.001, 0.002, 0.5, 0.25, 0.999, 1.0, 1.0000001, 1.25, 1.5, 0.3, 0.29999998, 0.30000004]
values = list(table_t) + specials
while len(values) < CASES:
values.append(float(rng.uniform(-0.25, 1.25)))
column = f32(values[:CASES])
images = pack_operands([column])
def build(tree, imgs):
node = tree.nodes.new("ShaderNodeValToRGB")
node.color_ramp.interpolation = "LINEAR"
elements = node.color_ramp.elements
while len(elements) < len(RAMP_STOPS):
elements.new(0.5)
for element, (position, red) in zip(elements, RAMP_STOPS):
element.position = position
element.color = (red, 0.0, 0.0, 1.0)
tree.links.new(operand_sockets(tree, imgs, 1)[0], node.inputs["Fac"])
return [node.outputs["Color"]]
return [{"function": "cycles_ramp_index", "node": "ShaderNodeValToRGB LINEAR size %d stops %s" % (RAMP_SIZE, json.dumps(RAMP_STOPS)),
"table_entries": RAMP_SIZE, "cases": encode_cases([column], bake("ramp", images, build, 1))}]
FAMILIES = {
"math": family_math,
"map-range": family_map_range,
"clamp": family_clamp,
"mix": family_mix,
"rgb-to-bw": family_rgb_to_bw,
"ramp": family_ramp,
}
def identity_check():
"""The bake must return an operand image bit for bit, or no fixture means anything."""
rng = np.random.default_rng(1)
column = f32(rng.uniform(-200, 200, CASES))
images = pack_operands([column])
def build(tree, imgs):
return [operand_sockets(tree, imgs, 1)[0]]
got = bake("identity", images, build, 1)[0]
if not np.array_equal(got.view(np.uint32), column.view(np.uint32)):
sys.stderr.write("cycles-oracle: the bake does not return its operands bit for bit\n")
sys.exit(3)
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()
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,
"build_hash": PINNED_HASH,
"platform": bpy.app.build_platform.decode() if isinstance(bpy.app.build_platform, bytes) else str(bpy.app.build_platform),
"encoding": "each case is 'operands -> results', every scalar a float32 bit pattern in hex, colours flattened as r g b",
}
for family in wanted:
document = dict(header)
document["family"] = family
document["functions"] = FAMILIES[family]()
text = json.dumps(document, indent=1) + "\n"
with open(os.path.join(out, family + ".json"), "w", newline="\n") as handle:
handle.write(text)
print("cycles-oracle: wrote", family, len(document["functions"]), "functions")
main()