## Stage 1 of the emitted-GLSL gate: for every operation of a node, the CPU's
## answer beside the GLSL the engine emits for it.
##
## The CPU side is the code that SHIPS -- ProcNodeGraph.evaluate_socket, which
## runs bmath::math_op, the same function the Blender parity suites gate at
## 71/71 and 29/29. The GLSL side is compile_to_glsl_function's own output. So
## neither half is a transcription written for this test, and stage 2 compiles
## the emitted text on a real device rather than reasoning about it.
##
## Usage: godot --headless --path demo --script res://glsl_op_gate_dump.gd -- out.json
extends SceneTree
# a, b, c triples chosen to reach the corners the safe_* helpers exist for:
# division by zero, log of a non-positive, sqrt of a negative, a negative base
# with a fractional exponent, and the wrap/pingpong edge where b == c.
const INPUTS := [
[3.0, 4.0, 2.0],
[-2.5, 0.0, 1.0],
[0.5, -1.0, 0.25],
[7.0, 7.0, 7.0],
[-0.75, 2.0, 0.0],
[1e-7, 1e7, 3.0],
]
func _float_output_graph(node: ProcNode, n_inputs: int, inputs: Array) -> Dictionary:
var g := ProcNodeGraph.new()
var idx := g.add_node(node)
for i in range(n_inputs):
var v := g.add_node(ValueNode.new())
g.get_node(v).set("value", inputs[i])
g.connect_sockets(v, 0, idx, i)
var go := GroupOutputNode.new()
go.set_interface([{"name": "Density", "type": 0}])
var go_idx := g.add_node(go)
g.connect_sockets(idx, 0, go_idx, 0)
return {"graph": g, "node": idx}
func _init() -> void:
var a := OS.get_cmdline_user_args()
var cases := []
var ops: int = 41
for op in range(ops):
for inputs in INPUTS:
var node := MathNode.new()
node.set("operation", op)
var built := _float_output_graph(node, 3, inputs)
var g: ProcNodeGraph = built["graph"]
var cpu = g.evaluate_socket(built["node"], 0)
var glsl: String = g.compile_to_glsl_function("proc_density", false,
PackedStringArray())
cases.append({
"node": "Math", "op": op, "inputs": inputs,
"cpu": float(cpu), "glsl": glsl,
})
# --- Vector Math -------------------------------------------------------
# Its vector output is compared COMPONENTWISE, through a Separate XYZ, and
# its three scalar operations (dot, distance, length) through the node's
# second output. Both halves have to be checked: an operation that writes
# the wrong one is exactly the kind of thing a vector-only comparison misses.
var vec_inputs := [
[Vector3(1.0, 2.0, 3.0), Vector3(4.0, 5.0, 6.0), Vector3(0.5, 0.5, 0.5), 2.0],
[Vector3(-2.0, -7.0, 5.0), Vector3(0.0, 3.0, 0.0), Vector3(1.0, 1.0, 1.0), -1.5],
[Vector3(0.0, 0.0, 0.0), Vector3(0.0, 0.0, 0.0), Vector3(2.0, 2.0, 2.0), 0.0],
]
for op in range(30):
for vi in vec_inputs:
for comp in range(4):
var g := ProcNodeGraph.new()
var vm := VectorMathNode.new()
vm.set("operation", op)
var idx := g.add_node(vm)
for k in range(3):
var vn := g.add_node(VectorNode.new())
g.get_node(vn).set("value", vi[k])
g.connect_sockets(vn, 0, idx, k)
var sc := g.add_node(ValueNode.new())
g.get_node(sc).set("value", vi[3])
g.connect_sockets(sc, 0, idx, 3)
var src_node := idx
var src_sock := 1 # comp 3: the Value output
if comp < 3:
var sep := g.add_node(SeparateXYZNode.new())
g.connect_sockets(idx, 0, sep, 0)
src_node = sep
src_sock = comp
var go2 := GroupOutputNode.new()
go2.set_interface([{"name": "Density", "type": 0}])
var go2_idx := g.add_node(go2)
g.connect_sockets(src_node, src_sock, go2_idx, 0)
var cpu2 = g.evaluate_socket(src_node, src_sock)
cases.append({
"node": "VectorMath.%d" % comp, "op": op,
"inputs": [vi[0].x, vi[1].x, vi[3]],
"cpu": float(cpu2),
"glsl": g.compile_to_glsl_function("proc_density", false,
PackedStringArray()),
})
# --- Map Range ---------------------------------------------------------
# Four interpolations x clamp on/off, and the corners that make the two
# non-obvious behaviours visible: an INVERTED output range (which the clamp
# handles by swapping bounds) and from_min == from_max (which safe_divide
# turns into the start of the range rather than infinity).
var mr_inputs := [
[0.35, 0.0, 1.0, 10.0, 20.0, 4.0],
[1.50, 0.0, 1.0, 10.0, 20.0, 4.0],
[-0.25, 0.0, 1.0, 1.0, 0.0, 4.0],
[0.50, 2.0, 2.0, 0.0, 5.0, 4.0],
[0.70, 0.0, 1.0, 0.0, 1.0, 0.0],
]
for interp in range(4):
for do_clamp in [false, true]:
for mi in mr_inputs:
var g3 := ProcNodeGraph.new()
var mr := MapRangeNode.new()
mr.set("interpolation_type", interp)
mr.set("clamp_result", do_clamp)
var mr_idx := g3.add_node(mr)
for k in range(6):
var vn2 := g3.add_node(ValueNode.new())
g3.get_node(vn2).set("value", mi[k])
g3.connect_sockets(vn2, 0, mr_idx, k)
var go3 := GroupOutputNode.new()
go3.set_interface([{"name": "Density", "type": 0}])
var go3_idx := g3.add_node(go3)
g3.connect_sockets(mr_idx, 0, go3_idx, 0)
cases.append({
"node": "MapRange", "op": interp * 2 + (1 if do_clamp else 0),
"inputs": [mi[0], mi[1], mi[3]],
"cpu": float(g3.evaluate_socket(mr_idx, 0)),
"glsl": g3.compile_to_glsl_function("proc_density", false,
PackedStringArray()),
})
# --- Mix, float and vector ---------------------------------------------
# clamp_factor on and off against a factor OUTSIDE [0,1], which is the only
# thing that tells the two apart.
for dtype in [0, 1]:
for clampf in [false, true]:
for fac in [0.25, -0.5, 1.75]:
var g4 := ProcNodeGraph.new()
var mx := MixNode.new()
mx.set("data_type", dtype)
mx.set("clamp_factor", clampf)
var mx_idx := g4.add_node(mx)
var fv := g4.add_node(ValueNode.new())
g4.get_node(fv).set("value", fac)
g4.connect_sockets(fv, 0, mx_idx, 0)
var a_sock: int = 4 if dtype == 1 else 2
if dtype == 1:
var av := g4.add_node(VectorNode.new())
g4.get_node(av).set("value", Vector3(1.0, -2.0, 3.0))
g4.connect_sockets(av, 0, mx_idx, a_sock)
var bv := g4.add_node(VectorNode.new())
g4.get_node(bv).set("value", Vector3(-4.0, 5.0, 0.5))
g4.connect_sockets(bv, 0, mx_idx, a_sock + 1)
else:
var av2 := g4.add_node(ValueNode.new())
g4.get_node(av2).set("value", 1.0)
g4.connect_sockets(av2, 0, mx_idx, a_sock)
var bv2 := g4.add_node(ValueNode.new())
g4.get_node(bv2).set("value", -4.0)
g4.connect_sockets(bv2, 0, mx_idx, a_sock + 1)
var src2 := mx_idx
var sock2: int = 0
if dtype == 1:
var sep2 := g4.add_node(SeparateXYZNode.new())
g4.connect_sockets(mx_idx, 1, sep2, 0)
src2 = sep2
sock2 = 1 # the y component, which differs from x and z
var go4 := GroupOutputNode.new()
go4.set_interface([{"name": "Density", "type": 0}])
var go4_idx := g4.add_node(go4)
g4.connect_sockets(src2, sock2, go4_idx, 0)
cases.append({
"node": "Mix.d%d" % dtype, "op": (1 if clampf else 0),
"inputs": [fac, 0.0, 0.0],
"cpu": float(g4.evaluate_socket(src2, sock2)),
"glsl": g4.compile_to_glsl_function("proc_density", false,
PackedStringArray()),
})
# --- White Noise ------------------------------------------------------
# The first texture node back on the GPU, and the one with nothing above
# the hash: Blender's White Noise IS hash_float_to_float, so this compares
# the dual-compiled blender_hash.inc through the node that exposes it most
# directly. All four noise_dimensions, because each keys a DIFFERENT hash
# arity -- 1D takes W alone, 2D drops z, 4D appends W -- and a node that
# emitted the 3D form for all of them would still look perfectly plausible.
#
# The Value output only. The Colour is hash_float_to_float3 of the same
# key, and that family is gated exactly, all 1056 cases of it, by
# tests/test_hash_parity.cpp -- which is a better instrument for it than a
# float socket.
var wn_inputs := [
[Vector3(0.0, 0.0, 0.0), 0.0],
[Vector3(1.0, 2.0, 3.0), 0.5],
[Vector3(-7.25, 0.5, 1e7), -1.5],
[Vector3(1e-7, -0.001, 65536.0), 3.14159265],
[Vector3(0.5, 0.5, 0.5), 0.0],
]
for dims in [1, 2, 3, 4]:
for wi in wn_inputs:
var g5 := ProcNodeGraph.new()
var wn := WhiteNoiseNode.new()
wn.set("noise_dimensions", dims)
var wn_idx := g5.add_node(wn)
var vn := VectorNode.new()
vn.set("value", wi[0])
g5.connect_sockets(g5.add_node(vn), 0, wn_idx, 0)
var wv := ValueNode.new()
wv.set("value", wi[1])
g5.connect_sockets(g5.add_node(wv), 0, wn_idx, 1)
var go5 := GroupOutputNode.new()
go5.set_interface([{"name": "Density", "type": 0}])
var go5_idx := g5.add_node(go5)
g5.connect_sockets(wn_idx, 0, go5_idx, 0)
cases.append({
"node": "WhiteNoise.d%d" % dims, "op": dims,
"inputs": [wi[0].x, wi[0].y, wi[1]],
"cpu": float(g5.evaluate_socket(wn_idx, 0)),
"glsl": g5.compile_to_glsl_function("proc_density", false,
PackedStringArray()),
})
# --- Random Value -----------------------------------------------------
# All FOUR data types, because they are four different functions wearing
# one node: the socket LIST changes (boolean drops Min and Max for a
# single Probability, so ID and Seed move from 2/3 to 1/2) and the hash
# ARGUMENT ORDER changes (float and vector hash (seed, id); int and
# boolean hash (id, seed)). Getting either wrong still produces
# well-distributed random numbers, which is exactly why it needs a gate
# and not a reading.
var rv_ids := [[0, 0], [1, 0], [0, 1], [7, 13], [-3, 99]]
for rv_type in [10, 11, 48, 50]:
for ids in rv_ids:
var comps := 3 if rv_type == 48 else 1
for comp in range(comps):
var g6 := ProcNodeGraph.new()
var rv := RandomValueNode.new()
rv.set("data_type", rv_type)
var rv_idx := g6.add_node(rv)
var is_bool: bool = rv_type == 50
# Min/Max, or Probability alone.
if is_bool:
var pn := ValueNode.new()
pn.set("value", 0.5)
g6.connect_sockets(g6.add_node(pn), 0, rv_idx, 0)
elif rv_type == 48:
var mn := VectorNode.new()
mn.set("value", Vector3(-1.0, 0.0, 2.5))
g6.connect_sockets(g6.add_node(mn), 0, rv_idx, 0)
var mx := VectorNode.new()
mx.set("value", Vector3(1.0, 10.0, 3.5))
g6.connect_sockets(g6.add_node(mx), 0, rv_idx, 1)
elif rv_type == 11:
var mn2 := IntegerNode.new()
mn2.set("value", -5)
g6.connect_sockets(g6.add_node(mn2), 0, rv_idx, 0)
var mx2 := IntegerNode.new()
mx2.set("value", 42)
g6.connect_sockets(g6.add_node(mx2), 0, rv_idx, 1)
else:
var mn3 := ValueNode.new()
mn3.set("value", -2.5)
g6.connect_sockets(g6.add_node(mn3), 0, rv_idx, 0)
var mx3 := ValueNode.new()
mx3.set("value", 7.5)
g6.connect_sockets(g6.add_node(mx3), 0, rv_idx, 1)
# ID and Seed, wherever this data type keeps them.
var id_sock := 1 if is_bool else 2
var idn := IntegerNode.new()
idn.set("value", ids[0])
g6.connect_sockets(g6.add_node(idn), 0, rv_idx, id_sock)
var sdn := IntegerNode.new()
sdn.set("value", ids[1])
g6.connect_sockets(g6.add_node(sdn), 0, rv_idx, id_sock + 1)
var rsrc := rv_idx
var rsock := 0
if rv_type == 48:
var sep3 := SeparateXYZNode.new()
var sep3_idx := g6.add_node(sep3)
g6.connect_sockets(rv_idx, 0, sep3_idx, 0)
rsrc = sep3_idx
rsock = comp
var go6 := GroupOutputNode.new()
go6.set_interface([{"name": "Density", "type": 0}])
var go6_idx := g6.add_node(go6)
g6.connect_sockets(rsrc, rsock, go6_idx, 0)
cases.append({
"node": "RandomValue.t%d" % rv_type, "op": comp,
"inputs": [float(ids[0]), float(ids[1]), 0.0],
"cpu": float(g6.evaluate_socket(rsrc, rsock)),
"glsl": g6.compile_to_glsl_function("proc_density", false,
PackedStringArray()),
})
# --- Noise Texture ----------------------------------------------------
# DELIBERATELY FEW. This gate emits one function per case and a perlin case
# carries an octave loop, which is how its sibling came to OOM a 31 GB
# machine. The ARITHMETIC is gated exhaustively next door by
# tests/test_shared_noise_parity.cpp (2496 cases, data-driven); what is
# checked HERE is the thing only this gate can see -- that the emitter
# wires the sockets and picks the dimension the node is set to.
#
# All four dimensions, because each builds a different coordinate from
# Vector and W and calls a different per-dimension function, and each of
# those has its own scale constant.
for dims in [1, 2, 3, 4]:
for ntype in [1, 3]:
var g7 := ProcNodeGraph.new()
var pn := PerlinNoiseNode.new()
pn.set("noise_dimensions", dims)
pn.set("noise_type", ntype)
pn.set("normalize", true)
var pn_idx := g7.add_node(pn)
var vn2 := VectorNode.new()
vn2.set("value", Vector3(1.5, -2.25, 0.75))
g7.connect_sockets(g7.add_node(vn2), 0, pn_idx, 0)
var wv2 := ValueNode.new()
wv2.set("value", 0.5)
g7.connect_sockets(g7.add_node(wv2), 0, pn_idx, 1)
var sc := ValueNode.new()
sc.set("value", 3.0)
g7.connect_sockets(g7.add_node(sc), 0, pn_idx, 2)
var dt := ValueNode.new()
dt.set("value", 2.5) # fractional: takes the extra-octave branch
g7.connect_sockets(g7.add_node(dt), 0, pn_idx, 3)
var ds := ValueNode.new()
ds.set("value", 0.4) # non-zero distortion
g7.connect_sockets(g7.add_node(ds), 0, pn_idx, 8)
var go7 := GroupOutputNode.new()
go7.set_interface([{"name": "Density", "type": 0}])
var go7_idx := g7.add_node(go7)
g7.connect_sockets(pn_idx, 0, go7_idx, 0)
cases.append({
"node": "NoiseTexture.d%d.t%d" % [dims, ntype], "op": dims,
"inputs": [3.0, 2.5, 0.4],
"cpu": float(g7.evaluate_socket(pn_idx, 0)),
"glsl": g7.compile_to_glsl_function("proc_density", false,
PackedStringArray()),
})
# --- Voronoi Texture --------------------------------------------------
# Few, for the same reason as Noise Texture: this gate inlines a function
# per case and a Voronoi case carries a 3x3x3 (or 5x5x5) cell search on top
# of an octave loop. The arithmetic is gated next door across every
# dimension x feature x metric (2496 cases, data-driven). What is checked
# HERE is the emitter: that it builds the parameter block, picks the
# per-dimension function, and routes five outputs.
#
# ⚠ Including DISTANCE_TO_EDGE and N_SPHERE_RADIUS, because those are the
# two features where the CPU leaves max_distance at the struct default --
# and a GLSL struct has no defaults, so the emitter has to set it.
for vcase in [[3, 0], [1, 0], [2, 0], [4, 0], [3, 3], [3, 4]]:
var g8 := ProcNodeGraph.new()
var vt := VoronoiTextureNode.new()
vt.set("voronoi_dimensions", vcase[0])
vt.set("feature", vcase[1])
vt.set("metric", 0)
vt.set("normalize", true)
var vt_idx := g8.add_node(vt)
var vvec := VectorNode.new()
vvec.set("value", Vector3(1.25, -2.5, 0.75))
g8.connect_sockets(g8.add_node(vvec), 0, vt_idx, 0)
var vw := ValueNode.new()
vw.set("value", 0.5)
g8.connect_sockets(g8.add_node(vw), 0, vt_idx, 1)
var vsc := ValueNode.new()
vsc.set("value", 2.0)
g8.connect_sockets(g8.add_node(vsc), 0, vt_idx, 2)
var vdet := ValueNode.new()
vdet.set("value", 1.5) # fractional, so the extra octave runs
g8.connect_sockets(g8.add_node(vdet), 0, vt_idx, 3)
var vrand := ValueNode.new()
vrand.set("value", 0.85)
g8.connect_sockets(g8.add_node(vrand), 0, vt_idx, 8)
# Output 0 is Distance for f1/edge; for N_SPHERE_RADIUS the interesting
# one is output 4, and reading 0 there would compare two zeroes.
var osock := 4 if vcase[1] == 4 else 0
var go8 := GroupOutputNode.new()
go8.set_interface([{"name": "Density", "type": 0}])
var go8_idx := g8.add_node(go8)
g8.connect_sockets(vt_idx, osock, go8_idx, 0)
cases.append({
"node": "Voronoi.d%d.f%d" % [vcase[0], vcase[1]], "op": vcase[1],
"inputs": [float(vcase[0]), 2.0, 1.5],
"cpu": float(g8.evaluate_socket(vt_idx, osock)),
"glsl": g8.compile_to_glsl_function("proc_density", false,
PackedStringArray()),
})
# --- Wave Texture -----------------------------------------------------
# Cheap enough to cover properly here: the only heavy part is the
# distortion, which is one perlin fBM and only when Distortion != 0.
#
# All three PROFILES, because SAW and TRI take fract() of nn/2pi and the
# CPU does that division in DOUBLE (Math_PI is a double) where the shader
# does it in float. A last-bit difference in the quotient becomes a
# whole-unit difference in fract() for any nn landing on a period
# boundary, so this is measured rather than assumed. Both wave types and
# every band/ring direction, since each is a different arm.
for wt in [0, 1]:
for wdir in [0, 1, 2, 3]:
for prof in [0, 1, 2]:
for dist_on in [0, 1]:
var g9 := ProcNodeGraph.new()
var wv := WaveTextureNode.new()
wv.set("wave_type", wt)
if wt == 0:
wv.set("bands_direction", wdir)
else:
wv.set("rings_direction", wdir)
wv.set("wave_profile", prof)
var wv_idx := g9.add_node(wv)
var wvec := VectorNode.new()
wvec.set("value", Vector3(0.75, -1.25, 2.5))
g9.connect_sockets(g9.add_node(wvec), 0, wv_idx, 0)
var wsc := ValueNode.new()
wsc.set("value", 3.0)
g9.connect_sockets(g9.add_node(wsc), 0, wv_idx, 1)
var wds := ValueNode.new()
wds.set("value", 1.5 if dist_on == 1 else 0.0)
g9.connect_sockets(g9.add_node(wds), 0, wv_idx, 2)
var wdt := ValueNode.new()
wdt.set("value", 2.5)
g9.connect_sockets(g9.add_node(wdt), 0, wv_idx, 3)
var wph := ValueNode.new()
wph.set("value", 0.35)
g9.connect_sockets(g9.add_node(wph), 0, wv_idx, 6)
var go9 := GroupOutputNode.new()
go9.set_interface([{"name": "Density", "type": 0}])
var go9_idx := g9.add_node(go9)
# output 1 is Fac; output 0 is Colour (Blender's order)
g9.connect_sockets(wv_idx, 1, go9_idx, 0)
cases.append({
"node": "Wave.t%d.d%d.p%d" % [wt, wdir, prof], "op": prof,
"inputs": [float(wt), float(wdir), float(dist_on)],
"cpu": float(g9.evaluate_socket(wv_idx, 1)),
"glsl": g9.compile_to_glsl_function("proc_density", false,
PackedStringArray()),
})
# A DIRECTORY, not JSON: stage 2 is a standalone C++ program with no JSON
# parser, and adding one to read our own test data would be more code than
# the gate. One manifest line per case, one .glsl file per case.
var dir: String = a[0] if a.size() > 0 else "/tmp/proc_op_gate"
DirAccess.make_dir_recursive_absolute(dir)
var manifest := ""
for i in range(cases.size()):
var c: Dictionary = cases[i]
# %g is not a GDScript format specifier and the substitution fails
# SILENTLY, writing the format string itself into the manifest.
manifest += "%d %s %d %s %s %s %s\n" % [i, c["node"], c["op"],
String.num(c["inputs"][0], 9), String.num(c["inputs"][1], 9),
String.num(c["inputs"][2], 9), String.num(c["cpu"], 9)]
FileAccess.open("%s/case_%04d.glsl" % [dir, i], FileAccess.WRITE).store_string(c["glsl"])
FileAccess.open(dir + "/manifest.txt", FileAccess.WRITE).store_string(manifest)
print("OP_GATE_DUMP: %d cases -> %s" % [cases.size(), dir])
quit()