OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes / demo / gpu_batch_gate.gd

166 lines · gd ref:
## Does the GPU batch actually run on the GPU, and does it agree with the CPU?
##
## Both halves matter and the second is the one that is easy to fake. The batch
## falls back to the CPU per node, silently, for four separate reasons -- so a
## bake-vs-bake comparison passes by comparing the CPU against itself and says
## nothing at all. ProcGeometryNode.get_last_exec_report() is what makes the
## difference visible: node type -> "gpu" | "cpu...".
##
## The graph is the shape the real ones have: a primitive, a per-vertex FIELD,
## and a geometry node consuming it.
##
##     MeshGrid -> SetPosition(Offset <- VectorMath(Position * 0.25))
##
## Nothing here is noise, so CPU and GPU must agree to floating-point slack
## rather than to a tolerance anybody had to argue about.
extends SceneTree

const EPS := 1e-4

var _fail := 0

func _ck(n: String, ok: bool, d: String) -> void:
	print("  %-46s %s  %s" % [n, "PASS" if ok else "FAIL", d])
	if not ok:
		_fail += 1


func _build() -> ProcNodeGraph:
	var g := ProcNodeGraph.new()
	var grid := g.add_node(MeshGridNode.new())
	g.get_node(grid).set_input_default(0, 4.0)   # size x
	g.get_node(grid).set_input_default(1, 4.0)   # size y
	g.get_node(grid).set_input_default(2, 16)    # verts x
	g.get_node(grid).set_input_default(3, 16)    # verts y

	var pos := g.add_node(PositionNode.new())
	var vm := g.add_node(VectorMathNode.new())
	g.get_node(vm).set_input_default(1, Vector3(0.25, 0.25, 0.25))
	g.connect_sockets(pos, 0, vm, 0)

	var sp := g.add_node(SetPositionNode.new())
	g.connect_sockets(grid, 0, sp, 0)
	g.connect_sockets(vm, 0, sp, 3)   # Offset
	return g


## ⛔ THE SHAPE THE FIRST GRAPH CANNOT TEST.
##
## _build()'s chain is fully fused, so every producer is inside the batch and
## there is nothing outside for a node to misread. That is the right assertion
## on the wrong shape, and it passed while the bridged mountain came out flat.
##
## A Switch has no supports_gpu() override, so _is_gpu_value_node rejects it and
## it can never join a batch -- which is exactly where the mountain's height sat.
## Its consumer, Combine XYZ, IS fusable, so the batch ends up reading a producer
## that is not in the shader. GlslEmitContext::in() falls back to the socket's
## LITERAL there, which compiles and computes something else: offset 0 instead
## of 5, no error anywhere.
##
##     MeshGrid -> SetPosition(Offset <- CombineXYZ(Z <- Switch(true, 0.0, 5.0)))
func _build_outside_producer() -> ProcNodeGraph:
	var g := ProcNodeGraph.new()
	var grid := g.add_node(MeshGridNode.new())
	g.get_node(grid).set_input_default(0, 4.0)
	g.get_node(grid).set_input_default(1, 4.0)
	g.get_node(grid).set_input_default(2, 8)
	g.get_node(grid).set_input_default(3, 8)

	var sw := g.add_node(SwitchNode.new())
	g.get_node(sw).set("input_type", 0)          # Float, as Blender's was
	g.get_node(sw).set_input_default(0, true)    # take the True branch
	g.get_node(sw).set_input_default(1, 0.0)     # False
	g.get_node(sw).set_input_default(2, 5.0)     # True -- the displacement

	var comb := g.add_node(CombineXYZNode.new())
	g.connect_sockets(sw, 0, comb, 2)            # Z

	var sp := g.add_node(SetPositionNode.new())
	g.connect_sockets(grid, 0, sp, 0)
	g.connect_sockets(comb, 0, sp, 3)            # Offset
	return g


func _bake_graph(g: ProcNodeGraph, use_gpu: bool) -> PackedVector3Array:
	var host := ProcGeometryNode.new()
	host.use_gpu = use_gpu
	host.set_graph(g)
	get_root().add_child(host)
	var m: ArrayMesh = host.bake_mesh()
	if m == null or m.get_surface_count() == 0:
		return PackedVector3Array()
	return m.surface_get_arrays(0)[Mesh.ARRAY_VERTEX]


func _bake(use_gpu: bool) -> PackedVector3Array:
	var host := ProcGeometryNode.new()
	host.use_gpu = use_gpu
	host.set_graph(_build())
	get_root().add_child(host)
	var m: ArrayMesh = host.bake_mesh()
	if m == null or m.get_surface_count() == 0:
		return PackedVector3Array()
	return m.surface_get_arrays(0)[Mesh.ARRAY_VERTEX]


func _init() -> void:
	var cpu := _bake(false)
	_ck("CPU bake produced a mesh", cpu.size() > 0, "%d verts" % cpu.size())

	var gpu := _bake(true)
	var report: Dictionary = ProcGeometryNode.get_last_exec_report()
	_ck("GPU bake produced a mesh", gpu.size() > 0, "%d verts" % gpu.size())
	print("  execution report: %s" % report)

	# ⛔ THE ASSERTION THAT STOPS THIS BEING A NO-OP. Without it the two bakes
	# agree because they were the same bake.
	_ck("SetPosition ran on the GPU", report.get("SetPosition", "absent") == "gpu",
			"reported '%s'" % report.get("SetPosition", "absent"))
	# VectorMath is deliberately NOT asserted: Position is a lazy field producer,
	# so its consumer is deferred and materialised inside SetPosition rather than
	# executed in its own right. What matters is that the node consuming the
	# field did not bail out for want of a channel to read it through.
	var bailed := []
	for k in report.keys():
		if String(report[k]).begins_with("cpu (a connected input"):
			bailed.append(k)
	_ck("no node bailed for an unreadable input", bailed.is_empty(),
			"bailed: %s" % [bailed])

	if cpu.size() == gpu.size() and cpu.size() > 0:
		var worst := 0.0
		for i in range(cpu.size()):
			worst = max(worst, (cpu[i] - gpu[i]).length())
		_ck("CPU and GPU agree", worst < EPS, "worst |dp| = " + String.num(worst, 9))
	else:
		_ck("CPU and GPU agree", false,
				"vertex counts differ: %d vs %d" % [cpu.size(), gpu.size()])

	# ── A PRODUCER OUTSIDE THE BATCH ────────────────────────────────────────
	var og := _build_outside_producer()
	var ocpu := _bake_graph(og, false)
	var ogpu := _bake_graph(og, true)
	var orep: Dictionary = ProcGeometryNode.get_last_exec_report()
	print("  outside-producer report: %s" % orep)

	var cpu_lift := 0.0
	for p in ocpu:
		cpu_lift = max(cpu_lift, absf(p.z))
	_ck("the outside-producer graph displaces at all", cpu_lift > 4.9,
			"CPU max |z| = " + String.num(cpu_lift, 4) + ", Switch True is 5.0")

	if ocpu.size() == ogpu.size() and ocpu.size() > 0:
		var oworst := 0.0
		for i in range(ocpu.size()):
			oworst = max(oworst, (ocpu[i] - ogpu[i]).length())
		# ⛔ THE ASSERTION THAT WAS MISSING. Without the batch refusal the GPU
		# reads Combine XYZ's Z as its literal 0.0 and this is 5.0.
		_ck("CPU and GPU agree when a producer is outside the batch",
				oworst < EPS, "worst |dp| = " + String.num(oworst, 9))
	else:
		_ck("CPU and GPU agree when a producer is outside the batch", false,
				"vertex counts differ: %d vs %d" % [ocpu.size(), ogpu.size()])

	print("\n%d/%d passed" % [8 - _fail, 8])
	quit(1 if _fail > 0 else 0)