OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes / demo / glsl_op_gate_dump.gd

470 lines · gd ref:
## 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()