CultLib/packages/cultmath/tools/cycles-oracle/oracle.py
Meta Vens 77a60d2910 CultMath Cycles: an infinite exponent is even in cycles_power; the oracle's infinite family and the pins for infinite operands
cycles_power read b - 2 floor(0.5 b) != 0 as odd, which is NaN for b = +-inf, so a negative base got a negative result where Cycles' powf(a, +-inf) is never negative. Odd now means the remainder is exactly 1 (C#, HLSL, Unity copy). oracle.py gains the infinite family (operands made in-kernel as +-exp(100), results read through comparisons with 0) for power, modulo and Map Range Smooth Step; the infinite.json fixture and the regenerated GLSL follow in the next commit. This commit builds; InfiniteOperandsFollowBlender and CommittedGlslEqualsLowering fail until it lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 04:42:39 +02:00

699 lines
31 KiB
Python

# 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. They witness the
# reference of ruling cultmath-tapes:ruling:cycles-kernel-reference-ruled (one IEEE single rounding per operation,
# the SSE4.2 kernel, CYCLES_CPU_NO_AVX2=1); other kernels are other builds and are never written here.
import hashlib
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 by_identifier(sockets, identifier):
"""A node's socket by identifier: several data types share one socket name."""
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):
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):
# One stream per name: a function, a blend mode's second colours (name + "2"), a ramp. Sharing a seed shares the draws.
return np.random.default_rng(int.from_bytes(hashlib.sha256(name.encode()).digest()[:8], "little"))
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)]),
("cycles_arcsine", "ARCSINE", 1, [SPECIAL], [(-1.5, 1.5)]),
("cycles_arccosine", "ARCCOSINE", 1, [SPECIAL], [(-1.5, 1.5)]),
("cycles_arctan2", "ARCTAN2", 2, [SPECIAL, SPECIAL], [(-20, 20)] * 2),
]
# 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),
# A base of -1 keeps a finite result for any exponent, so the film shows the exponent past the int range.
(-1.0, 3.0e9), (-1.0, -3.0e9), (-1.0, 2147483648.0), (-1.0, 16777216.0), (-1.0, 16777215.0)],
"cycles_compare": [(0.0, 1.0e-7, 0.0), (1.0, 1.0000001, 0.0), (1.0, 1.0000002, 0.0), (0.0, 1.0e-7, 1.0e-8), (5.0, 5.0, 0.0), (0.0, 2.0e-7, 1.0e-7)],
# Burn with a divisor of exactly 0 (tm + t col2 = 0) and a colour above 1: the result is 1 for the channel Cycles reads
# as 0, and an infinity the film would hide for the others.
"cycles_mix_burn": [(2.0, 1.5, 0.5, 2.5, 0.5, 0.5, 0.5), (0.5, 1.5, 2.0, 3.0, -1.0, -1.0, -1.0)],
# safe_asinf / safe_acosf clamp to [-1, 1]; compatible_atan2 guards only the origin (y = a, x = b).
"cycles_arcsine": [(2.0,), (-2.0,), (1.0,), (-1.0,), (1.0000001,), (-1.0000001,), (1.5,)],
"cycles_arccosine": [(2.0,), (-2.0,), (1.0,), (-1.0,), (1.0000001,), (-1.0000001,), (1.5,)],
"cycles_arctan2": [(0.0, 0.0), (0.0, -0.0), (-0.0, -0.0), (-0.0, -1.0), (0.0, -1.0), (1.0, 0.0), (-1.0, 0.0), (1.0e-7, -1.0e-7)],
"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)
if kind == "SMOOTHSTEP":
# An overflowing range: from_max - from_min is infinite, and Cycles' smoothstep decides x >= edge1 before dividing.
for r, row in ((24, (3.0e38, -3.0e38, 3.0e38, 0.0, 1.0, 4.0)), (25, (3.0e38, 3.0e38, -3.0e38, 0.0, 1.0, 4.0))):
for k in range(6):
columns[k][CASES - 1 - r] = row[k]
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 = by_identifier(node.inputs, identifier)
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 = by_identifier(node.inputs, identifier)
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], by_identifier(node.inputs, identifier))
return [by_identifier(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], by_identifier(node.inputs, "Factor_Float"))
tree.links.new(triple_socket(tree, imgs, 1), by_identifier(node.inputs, "A_Vector"))
tree.links.new(triple_socket(tree, imgs, 4), by_identifier(node.inputs, "B_Vector"))
return by_identifier(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 = with_extra(function, mix_columns(function, blend), 7)
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], by_identifier(node.inputs, "Factor_Float"))
tree.links.new(triple_socket(tree, imgs, 1), by_identifier(node.inputs, "A_Color"))
tree.links.new(triple_socket(tree, imgs, 4), by_identifier(node.inputs, "B_Color"))
return by_identifier(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))}]
# A Blender ramp is sampled at 256 intervals into a table of 257 entries (colorramp_to_array: size + 1 entries
# at i / size), and the kernel reads that table with table_size 257.
RAMP_SIZE = 257
# A constant ramp whose 32 stops sit half an entry past an entry boundary: its table is the stops' values, so
# a lookup's result names the entry it read.
CONSTANT_STOPS = [((8 * j + 3.5) / (RAMP_SIZE - 1), (j + 1) / 32.0) for j in range(32)]
def ramp_cases(rng, boundaries):
"""CASES values of t: the floats around each boundary t = i / 255, the specials, then uniform draws."""
values = []
for i in boundaries:
t = np.float32(i / (RAMP_SIZE - 1))
down = [t]
up = [t]
for _ in range(7):
down.append(np.nextafter(down[-1], np.float32(-1.0)))
up.append(np.nextafter(up[-1], np.float32(2.0)))
values += sorted(set(down + up))
values += [-0.5, -1.0e-7, 0.0, 1.0e-7, 1.0, 1.0000001, 1.5]
while len(values) < CASES:
values.append(float(rng.uniform(-0.25, 1.25)))
return f32(values[:CASES])
def ramp_entry(label, interpolation, stops, boundaries):
rng = rng_for("ramp-" + label)
column = ramp_cases(rng, boundaries)
images = pack_operands([column])
def build(tree, imgs):
node = tree.nodes.new("ShaderNodeValToRGB")
ramp = node.color_ramp
ramp.interpolation = interpolation
ramp.elements[0].position = stops[0][0]
ramp.elements[1].position = stops[-1][0]
for position, _ in stops[1:-1]:
ramp.elements.new(position)
for element, (_, red) in zip(ramp.elements, stops):
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 " + interpolation, "table_entries": RAMP_SIZE,
"stops": [[bits(position), bits(red)] for position, red in stops],
"cases": encode_cases([column], bake("ramp-" + label, images, build, 1))}
def family_ramp():
boundaries = [8 * j + 4 for j in range(32)]
return [
ramp_entry("constant", "CONSTANT", CONSTANT_STOPS, boundaries),
ramp_entry("linear", "LINEAR", [(0.0, 0.0), (1.0, 1.0)], list(range(1, RAMP_SIZE, 17))),
]
# ---- infinite operands ----
INF = float("inf")
# Rows of (function, node, operands) whose operands may be an infinity. The film writes 0 for an infinite operand and
# for an infinite or NaN result, so neither can travel through an image: the operand is made in-kernel (+-1 times
# exp(100), which overflows to +-inf) and the result is read back through three comparisons with 0.
INFINITE = [
("cycles_power", "POWER", [
(-1.0, INF), (-1.0, -INF), (-2.0, INF), (-2.0, -INF), (-0.5, INF), (-0.5, -INF), (-0.0, INF), (-0.0, -INF),
(0.5, INF), (0.5, -INF), (2.0, INF), (2.0, -INF), (1.0, INF), (0.0, INF), (0.0, -INF), (INF, 3.0), (-INF, 3.0),
(-INF, 2.0), (INF, -1.0), (-INF, -1.0), (-INF, INF), (INF, INF)]),
("cycles_modulo", "MODULO", [
(INF, 1.0), (-INF, 1.0), (INF, 3.0), (-INF, 3.0), (INF, 0.1), (-INF, 0.1), (INF, -2.0), (5.0, INF), (-5.0, INF),
(5.0, -INF), (INF, INF), (-INF, INF), (0.0, INF), (INF, 0.0)]),
]
# Map Range, Smooth Step: (value, from_min, from_max, to_min, to_max).
INFINITE_SMOOTHSTEP = [
(-INF, -INF, 1.0, 2.0, 3.0), (-INF, 1.0, -INF, 2.0, 3.0), (-INF, 1.0, 5.0, 2.0, 3.0), (-INF, -5.0, 1.0, 2.0, 3.0),
(INF, -5.0, 1.0, 2.0, 3.0), (INF, 1.0, -5.0, 2.0, 3.0), (INF, INF, 1.0, 2.0, 3.0), (INF, 1.0, INF, 2.0, 3.0),
(-INF, INF, 1.0, 2.0, 3.0), (-INF, 1.0, INF, 2.0, 3.0), (1.0, -INF, INF, 2.0, 3.0),
]
def result_token(value, greater, less, equal):
"""A result as the fixture writes it: float32 bits, or inf / -inf / nan where the film showed 0 and the comparisons say why."""
if bits(value) != "00000000" and bits(value) != "80000000":
return bits(value)
if equal:
return bits(value)
return "inf" if greater else ("-inf" if less else "nan")
def infinite_entry(function, node_label, build_node, rows):
count = len(rows[0])
value_columns = []
flag_columns = []
for k in range(count):
value_columns.append(f32([(0.0 if r >= len(rows) else (float(np.sign(rows[r][k])) if np.isinf(rows[r][k]) else rows[r][k])) for r in range(CASES)]))
flag_columns.append(f32([(0.0 if r >= len(rows) else (1.0 if np.isinf(rows[r][k]) else 0.0)) for r in range(CASES)]))
images = pack_operands(value_columns + flag_columns)
def build(tree, imgs):
sockets = operand_sockets(tree, imgs, 2 * count)
operands = []
for k in range(count):
hundred = tree.nodes.new("ShaderNodeMath")
hundred.operation = "MULTIPLY"
tree.links.new(sockets[count + k], hundred.inputs[0])
hundred.inputs[1].default_value = 100.0
overflow = tree.nodes.new("ShaderNodeMath")
overflow.operation = "EXPONENT"
tree.links.new(hundred.outputs[0], overflow.inputs[0])
made = tree.nodes.new("ShaderNodeMath")
made.operation = "MULTIPLY"
tree.links.new(sockets[k], made.inputs[0])
tree.links.new(overflow.outputs[0], made.inputs[1])
operands.append(made.outputs[0])
result = build_node(tree, operands)
probes = []
for operation in ("GREATER_THAN", "LESS_THAN", "COMPARE"):
probe = tree.nodes.new("ShaderNodeMath")
probe.operation = operation
tree.links.new(result, probe.inputs[0])
probe.inputs[1].default_value = 0.0
if operation == "COMPARE":
probe.inputs[2].default_value = 0.0
probes.append(probe.outputs[0])
return [result] + probes
value, greater, less, equal = bake(function + ".infinite", images, build, 4)
cases = []
for r, row in enumerate(rows):
operands = " ".join(("inf" if x == INF else "-inf" if x == -INF else bits(x)) for x in row)
cases.append("%s -> %s" % (operands, result_token(value[r], greater[r] > 0.5, less[r] > 0.5, equal[r] > 0.5)))
return {"function": function, "node": node_label, "cases": cases}
def family_infinite():
def math_node(operation):
def build(tree, operands):
node = tree.nodes.new("ShaderNodeMath")
node.operation = operation
for k, socket in enumerate(operands):
tree.links.new(socket, node.inputs[k])
return node.outputs[0]
return build
def smoothstep(tree, operands):
node = tree.nodes.new("ShaderNodeMapRange")
node.data_type = "FLOAT"
node.interpolation_type = "SMOOTHSTEP"
node.clamp = False
for identifier, socket in zip(["Value", "From Min", "From Max", "To Min", "To Max"], operands):
tree.links.new(socket, by_identifier(node.inputs, identifier))
return node.outputs["Result"]
functions = [infinite_entry(function, "ShaderNodeMath " + operation, math_node(operation), rows) for function, operation, rows in INFINITE]
functions.append(infinite_entry("cycles_map_range_smoothstep", "ShaderNodeMapRange SMOOTHSTEP", smoothstep, INFINITE_SMOOTHSTEP))
return functions
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,
"infinite": family_infinite,
}
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 kernel_check():
"""The fixtures witness the ruled reference: Cycles' formulas with one IEEE single rounding per operation.
Ruling cultmath-tapes:ruling:cycles-kernel-reference-ruled. 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. Other kernels are other builds, measured under the
tolerance classes and never written here: 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, which is not the reference of ruling cycles-kernel-reference-ruled; 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,
"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()