OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / graph_wire_gate.gd

152 lines · gd ref:
extends SceneTree
## DOES A DRAWN WIRE CARRY A VALUE?
##
##   godot --headless --path . --script res://scenes/test/graph_wire_gate.gd
##
## Until proc-nodes' GPU compiler was given the graph, it did not. It walked
## `nodes[]` in array order and pasted each node's text into one function, so
## `connect_sockets(noise, 0, erosion, 7)` -- the wire both terrain maps draw --
## meant nothing: HeightfieldNode declared a variable called `gn_height` and
## ErosionNoiseNode read one, and the composition was a coincidence of spelling.
## docs/spec-graph-composition.md is the design; this is its acceptance.
##
## THE CONTROL IS AN ADD OF ZERO. Inserting Math(Add, B=0) between the base
## layer and the erosion filter must change the surface by nothing at all, and
## the erosion node must now read the ADD's output rather than the base's. One
## case, and it proves both halves: that the wire is followed, and that
## following it did not perturb the field.
##
## AND A NEGATIVE CONTROL, because a gate that cannot fail is not a gate: the
## same graph with B = 1000 must come out DIFFERENT. Without it, a compiler that
## ignored the Add entirely would pass the first check perfectly.

var _fails: Array[String] = []
var _out_dir := "/tmp"


func _initialize() -> void:
	var args := OS.get_cmdline_user_args()
	for i in range(args.size() - 1):
		if args[i] == "--out-dir":
			_out_dir = args[i + 1]

	var plain := _compile(_build(false, 0.0), "plain")
	var add0  := _compile(_build(true,  0.0), "add_zero")
	var add1k := _compile(_build(true,  1000.0), "add_1000")

	# 1. all three must emit something at all
	for pair in [["plain", plain], ["add_zero", add0], ["add_1000", add1k]]:
		if String(pair[1]).is_empty():
			_fails.append("%s: emitted NO GLSL" % pair[0])

	# 2. the wire is followed: with no Add, erosion reads node 0's outputs
	if not plain.contains("vec3(n0_o0, n0_o1.x, n0_o1.z)"):
		_fails.append("plain: the erosion node does not read node 0's sockets — " +
				"the wire from the base layer was not followed")

	# 3. with the Add in the middle, erosion reads the ADD's output (node 2),
	#    and the Add reads the base. Both halves of one wire.
	if not add0.contains("n2_o0 = n0_o0 +"):
		_fails.append("add_zero: the Math node does not read node 0's Height output")
	if not add0.contains("vec3(n2_o0, n0_o1.x, n0_o1.z)"):
		_fails.append("add_zero: the erosion node still reads the base directly — " +
				"the Math node was spliced in but nothing was rewired through it")

	# 4. THE FIELD IS UNCHANGED. Adding zero is an identity, so every line of
	#    the two shaders must match except the Math node's own block.
	var d0 := _diff_lines(plain, add0)
	for line in d0:
		var t := String(line).strip_edges()
		if t.is_empty():
			continue
		if t.contains("Math") or t.contains("n2_o0") or t == "{" or t == "}":
			continue
		if t.contains("n2_o0, n0_o1.x") or t.contains("n0_o0, n0_o1.x"):
			continue      # the one call site that must change, checked above
		_fails.append("add_zero: adding zero changed a line it should not have: %s" % t)

	# 5. NEGATIVE CONTROL — the same insertion with a real value must differ.
	if add0 == add1k:
		_fails.append("NEGATIVE CONTROL FAILED: Add(0) and Add(1000) emit identical " +
				"GLSL, so this gate cannot tell whether the Add is evaluated at all")

	# The texts, for glslc to compile outside this process.
	for pair in [["plain", plain], ["add_zero", add0], ["add_1000", add1k]]:
		var f := FileAccess.open("%s/wire_%s.glsl" % [_out_dir, pair[0]], FileAccess.WRITE)
		if f != null:
			# A COMPILABLE UNIT, not just the function. Without #version glslc
			# refuses before it reaches our text at all, and reports an internal
			# error about built-ins -- which reads like a shader fault and is not
			# one. The main() is there so the function is actually referenced.
			# THE AMBIENT HOST INPUTS, both of them. dc-terrain supplies
			# voxel_scale as a uniform ahead of the injected body and
			# pn_max_octaves as a wrapper local; a standalone unit has neither,
			# and ErosionNoiseNode reads voxel_scale to compute its own Nyquist
			# limit. Without this the file does not compile and the failure is
			# about the harness, not about the graph.
			f.store_string("#version 450\n" +
					"layout(local_size_x = 1) in;\n" +
					"int pn_max_octaves = 99;\n" +
					"float voxel_scale = 0.0;\n" + String(pair[1]) +
					"\nvoid main() { proc_density(vec3(1.0, 2.0, 3.0)); }\n")
			f.close()

	if _fails.is_empty():
		print("PASS — the wire carries the value, and adding zero changed nothing.")
		print("       wrote %s/wire_{plain,add_zero,add_1000}.glsl for glslc" % _out_dir)
		quit(0)
	else:
		print("FAIL — %d problem(s):" % _fails.size())
		for m in _fails:
			print("  " + m)
		quit(1)


## node 0 = GradientNoise, node 1 = Erosion, node 2 = Math(Add) when present.
## Node 2 is added LAST on purpose: array order would put it after the erosion
## node, so if it comes out in the right place it is the topological sort that
## put it there, not the order it was added.
func _build(with_add: bool, addend: float) -> ProcNodeGraph:
	var g := ProcNodeGraph.new()
	var noise := g.add_node(GradientNoiseNode.new())
	g.get_node(noise).set_input_default(1, 3.0)
	g.get_node(noise).set_input_default(2, 3)
	g.get_node(noise).set_input_default(3, 0.1)

	var erosion := g.add_node(ErosionNoiseNode.new())
	g.get_node(erosion).set_input_default(1, 5.0)
	g.get_node(erosion).set_input_default(2, 0.15)
	g.get_node(erosion).set_input_default(4, 0.7)
	g.get_node(erosion).set_input_default(5, 7)
	g.get_node(erosion).set_input_default(6, 0.5)

	g.connect_sockets(noise, 1, erosion, 8)          # Slope, direct in both cases

	if with_add:
		var add := g.add_node(MathNode.new())
		g.get_node(add).set("operation", 0)          # MathOp::Add
		g.get_node(add).set_input_default(1, addend)
		g.connect_sockets(noise, 0, add, 0)          # Height -> A
		g.connect_sockets(add, 0, erosion, 7)        # Result -> external height
	else:
		g.connect_sockets(noise, 0, erosion, 7)
	return g


func _compile(g: ProcNodeGraph, _label: String) -> String:
	return g.compile_to_glsl_function("proc_density", true)


## Lines present in b but not in a.
func _diff_lines(a: String, b: String) -> Array:
	var seen := {}
	for l in a.split("\n"):
		seen[l] = int(seen.get(l, 0)) + 1
	var only := []
	for l in b.split("\n"):
		if int(seen.get(l, 0)) > 0:
			seen[l] = int(seen[l]) - 1
		else:
			only.append(l)
	return only