diff --git a/packages/cultmath/tests/CultMath.Tests/CyclesOracleTests.cs b/packages/cultmath/tests/CultMath.Tests/CyclesOracleTests.cs new file mode 100644 index 00000000..a2cbfdb9 --- /dev/null +++ b/packages/cultmath/tests/CultMath.Tests/CyclesOracleTests.cs @@ -0,0 +1,174 @@ +using System.Reflection; +using System.Text.Json.Nodes; +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 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. +/// +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 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(); + 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(); + 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)); + } +} diff --git a/packages/cultmath/tools/cycles-oracle/oracle.py b/packages/cultmath/tools/cycles-oracle/oracle.py new file mode 100644 index 00000000..5a1f9381 --- /dev/null +++ b/packages/cultmath/tools/cycles-oracle/oracle.py @@ -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 -- [ ...] +# +# 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/.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()