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:
parent
559800f494
commit
99884cc34c
2 changed files with 706 additions and 0 deletions
174
packages/cultmath/tests/CultMath.Tests/CyclesOracleTests.cs
Normal file
174
packages/cultmath/tests/CultMath.Tests/CyclesOracleTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
532
packages/cultmath/tools/cycles-oracle/oracle.py
Normal file
532
packages/cultmath/tools/cycles-oracle/oracle.py
Normal 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()
|
||||
Loading…
Reference in a new issue