OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_undersea_gpu_parity.gd

164 lines · gd ref:
extends SceneTree
## GATE — the undersea field on the GPU equals the graph sampled on the CPU (§4 P2).
##
## gate_undersea_geiss_graph samples the graph on the CPU (evaluate_socket). What the
## game meshes is the EMITTED GLSL, run by DCTerrainGeneratorProcNodesBounded inside
## dc-terrain's density_fill. This compares the two at the same world points:
##   dc-terrain  DCTerrain.debug_gpu_density()  -> evaluate_density -> proc_density
##   CPU         UnderseaGeissGraph with a constant Vector, evaluate_socket, built with
##               the SCENE's knobs
## The GPU runs the bridged scene's graph (blender/undersea_geiss.tscn), and since
## 2026-10-07 so does the CPU side: THE SCENE IS CANON. The builder was recalibrated on
## 2026-10-06 and the scene was not re-seeded; the user chose to keep the scene's look
## (hyper-games #108). So the CPU reference is a duplicate(true) of the scene's own graph
## with each Position node rewired to a constant VectorNode (Position reads geometry,
## which a point sample does not have), evaluated at the socket feeding Group Output.
## Whether the scene still matches the builder is printed, not gated. (ProcNodeGraph.
## evaluate_density_at is not a substitute: it returns 0 for a Group Output Float.)
## Three blocks straddling the ground plane (y ~ 0, ~±40 m of relief since the 2026-10-06
## rebuild; the old blocks at -24 / -140 / -320 m were all solid). It also settles the
## SIGN: the CPU graph is positive below the surface, and dc-terrain treats positive as
## solid, so agreement means the GPU field is solid where it should be.
##
## Negative control: the CPU sampled half a voxel sideways must FAIL, or the comparison is
## too loose to mean anything.
##
## ⚠ NEEDS A RENDERING DEVICE — run WINDOWED (DISPLAY=:0, no --headless). A hidden
## window still has one (dc-terrain brings up its own compute thread).
##
## Run: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 DISPLAY=:0 "$HYPER_TPS_GODOT" --path . --script res://scenes/test/gate_undersea_gpu_parity.gd

const VOXEL := 1.0
const CELLS := 8
const ORIGINS := [Vector3(13.0, -4.0, 7.0), Vector3(-41.0, -6.0, 66.0), Vector3(250.0, -2.0, -180.0)]
const MAX_ABS := 0.05        # m — float precision, not structure
const MAX_STEP_FLIPS := 0.01 # fraction of points allowed to straddle a floor boundary

var ok := true
var checks := 0
var _terrain: Node
var _frames := 0
var _g: ProcNodeGraph
## The CPU sample graph: a copy of the scene's, Position nodes -> these VectorNodes.
var _s: ProcNodeGraph
var _pos_nodes: Array[int] = []
var _out_node := -1
var _out_socket := -1


func _p(c: bool, m: String, got: Variant = "") -> void:
	checks += 1
	print(("ok   " if c else "FAIL: ") + m + ("" if str(got) == "" else "  (" + str(got) + ")"))
	if not c:
		ok = false


func _initialize() -> void:
	_g = UnderseaGeissGraph.load_graph()
	_p(_g != null, "the scene holds the undersea graph")
	var knobs := UnderseaGeissGraph.knobs_for(_g)
	var a: String = _g.compile_to_glsl_function("proc_density", false, PackedStringArray()) if _g != null else ""
	var b: String = UnderseaGeissGraph.make(null, knobs).compile_to_glsl_function("proc_density", false, PackedStringArray())
	print("    info: the scene's structure %s the builder's" % ("MATCHES" if a == b else "differs from (scene is canon)"))
	_build_sample_graph()
	_p(not _pos_nodes.is_empty() and _out_node >= 0,
		"the CPU sample graph is the scene's own (%d Position node(s) rewired, output node %d socket %d)"
			% [_pos_nodes.size(), _out_node, _out_socket])
	var gen: Object = ClassDB.instantiate("DCTerrainGeneratorProcNodesBounded")
	gen.set("world_scale", 1.0)
	gen.set("graph", _g)
	_p(bool(gen.call("has_compiled_shader")), "the bounded generator compiled the graph",
		String(gen.call("get_last_compile_error")))
	_terrain = ClassDB.instantiate("DCTerrain")
	_terrain.set("use_gpu", true)
	_terrain.set("voxel_size", VOXEL)
	_terrain.set("generator", gen)
	root.add_child(_terrain)


## Copy the scene's graph; rewire every Position output to a fresh VectorNode; find
## the (node, socket) feeding the Group Output's first input.
func _build_sample_graph() -> void:
	_s = _g.duplicate(true) as ProcNodeGraph
	var conns: PackedInt32Array = _s.get_connections_flat()
	var go := -1
	for i in _s.get_node_count():
		var n: ProcNode = _s.get_node(i)
		if n == null:
			continue
		if String(n.get_node_type()) == "GroupOutput":
			go = i
		elif n is PositionNode:
			var v := _s.add_node(VectorNode.new())
			_pos_nodes.append(v)
			for c in range(0, conns.size(), 4):
				if conns[c] == i:
					_s.disconnect_sockets(i, conns[c + 1], conns[c + 2], conns[c + 3])
					_s.connect_sockets(v, 0, conns[c + 2], conns[c + 3])
	for c in range(0, conns.size(), 4):
		if conns[c + 2] == go and conns[c + 3] == 0:
			_out_node = conns[c]
			_out_socket = conns[c + 1]


func _cpu(p: Vector3) -> float:
	for v in _pos_nodes:
		_s.get_node(v).set("value", p)
	return float(_s.evaluate_socket(_out_node, _out_socket))


## {max, flips, mean} of |gpu - cpu(p + shift)| over one block.
func _compare(gpu: PackedFloat32Array, org: Vector3, shift: Vector3) -> Dictionary:
	var s := CELLS + 1
	var worst := 0.0
	var flips := 0
	var sum := 0.0
	var n := 0
	for z in s:
		for y in s:
			for x in s:
				var d := absf(gpu[z * s * s + y * s + x] - _cpu(org + Vector3(x, y, z) * VOXEL + shift))
				if d > 1.0:
					flips += 1       # a floor step decided differently — counted, not maxed
				else:
					worst = maxf(worst, d)
				sum += minf(d, 1.0)
				n += 1
	return {"max": worst, "flips": float(flips) / float(n), "mean": sum / float(n)}


func _process(_dt: float) -> bool:
	_frames += 1
	if _frames < 90:
		return false
	var all_solid := 0
	var all_air := 0
	for org: Vector3 in ORIGINS:
		var gpu: PackedFloat32Array = _terrain.call("debug_gpu_density", org, VOXEL, CELLS)
		if gpu.size() != (CELLS + 1) * (CELLS + 1) * (CELLS + 1):
			_p(false, "debug_gpu_density returned %d values at %s (GPU declined)" % [gpu.size(), str(org)])
			continue
		var r := _compare(gpu, org, Vector3.ZERO)
		var s1 := CELLS + 1
		for probe: Vector3i in [Vector3i(0, 0, 0), Vector3i(4, 4, 4), Vector3i(8, 2, 5)]:
			var gi := probe.z * s1 * s1 + probe.y * s1 + probe.x
			print("    sample %s: gpu %.4f cpu %.4f" % [str(org + Vector3(probe) * VOXEL), gpu[gi], _cpu(org + Vector3(probe) * VOXEL)])
		_p(float(r["max"]) <= MAX_ABS and float(r["flips"]) <= MAX_STEP_FLIPS,
			"GPU == CPU at %s" % str(org), "max %.4f m, mean %.4f, step flips %.2f%%"
				% [float(r["max"]), float(r["mean"]), 100.0 * float(r["flips"])])
		for v in gpu:
			if v > 0.0: all_solid += 1
			else: all_air += 1
	_p(all_solid > 0 and all_air > 0, "the blocks hold both solid and air (%d / %d)" % [all_solid, all_air])
	# ---- negative control ----
	var ctl_org: Vector3 = ORIGINS[1]
	var ctl: PackedFloat32Array = _terrain.call("debug_gpu_density", ctl_org, VOXEL, CELLS)
	if ctl.size() == (CELLS + 1) * (CELLS + 1) * (CELLS + 1):
		var c := _compare(ctl, ctl_org, Vector3(VOXEL * 0.5, 0, 0))   # half a voxel SIDEWAYS: a subtle offset
		_p(float(c["max"]) > MAX_ABS or float(c["flips"]) > MAX_STEP_FLIPS,
			"negative control: half a voxel sideways FAILS the comparison",
			"max %.4f mean %.4f flips %.1f%%" % [float(c["max"]), float(c["mean"]), 100.0 * float(c["flips"])])
	print("\n%d checks — %s" % [checks, "PASS" if ok else "FAIL"])
	quit(0 if ok else 1)
	return true