OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / bridged_density_splice_gate.gd

276 lines · gd ref:
extends SceneTree
## Does BridgedDensityMaterial turn the bridged mountain's VisualShader into a
## .gdshader that carries the generator's density field AND COMPILES?
##
##   godot --headless --path . --script res://scenes/test/bridged_density_splice_gate.gd
##
## ⚠ THE TRANSFORMS LIVE IN scenes/maps/bridged_density_material.gd, NOT HERE.
## This gate owns no copy of them. A test that carries its own transcription of
## the thing it tests can only ever agree with itself -- the most expensive
## recurring bug in this repo, and one that certified a wrong euler order twice.
##
## ⛔ IT DOES NOT GO THROUGH map_flat. Headless, map_flat frees the mountain
## ("MountainTerrain freed (gamestate-sync only)") so the node carries no
## generator and no material. Both texts are built here from the SAME bridged
## scene map_flat reads, which makes this a statement about the ASSET rather
## than about one map's streaming setup.
##
## ⚠ WHAT "COMPILES" MEANS HERE. Headless runs the dummy rasterizer, so this
## exercises Godot's shader-language FRONT END -- which is what produced every
## error found while building this: "Global non-constant variables are not
## supported", "Expected a ',' or ';'", "No matching function found". It does
## NOT hand GLSL to the GPU driver. For that, run the map.

## preload, not the class_name global: a global class is only registered after
## a project --import, and this gate runs standalone via --script.
const BDM := preload("res://scenes/maps/bridged_density_material.gd")

const MOUNTAIN_SCENE := "res://blender/mountain_voronoi2.tscn"
const MOUNTAIN_GRAPH_NODE := ^"Mountain/ProcGeoNodes"
const MOUNTAIN_MESH_NODE := ^"Mountain"
const WORLD_SCALE := 100.0

var OUT := "/tmp/bridged_spliced.gdshader"

var _fail := 0
var _checks := 0
## Kept for _composition(), which cannot run until the tree is up.
var _built_material: ShaderMaterial = null


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


## ⛔ THE COMPOSITION CHECK RUNS HERE, NOT IN _initialize, AND THAT IS THIS
## GATE'S OWN BUG FIXED. During _initialize the tree is not up: root.add_child()
## leaves the node with is_inside_tree() == false, so a position set on it does
## not take and DCTerrain::terrain_origin() correctly answers ZERO. The check
## then read an identity matrix and blamed the product. Measured -- the gate
## printed `inside_tree=false global_position=(0,0,0)` while the push had
## plainly run (on=1.0). dc-terrain's own gate_terrain_transform.gd records
## exactly this trap about itself; I rebuilt it one repo over.
func _process(_delta: float) -> bool:
	_composition()
	print("\n%d/%d checks passed" % [_checks - _fail, _checks])
	quit(1 if _fail > 0 else 0)
	return true


func _ck(name: String, ok: bool, detail: String = "") -> void:
	_checks += 1
	if not ok:
		_fail += 1
	print("  %s  %s%s" % ["ok  " if ok else "FAIL", name,
			("" if detail == "" else "   [" + detail + "]")])


func _run() -> void:
	var ps: PackedScene = load(MOUNTAIN_SCENE)
	if ps == null:
		_ck("bridged scene loads", false, MOUNTAIN_SCENE)
		return
	var inst: Node = ps.instantiate()
	var mi: Node = inst.get_node_or_null(MOUNTAIN_MESH_NODE)
	var holder: Node = inst.get_node_or_null(MOUNTAIN_GRAPH_NODE)
	var mat: Material = mi.get("material_override") if mi != null else null
	var graph: Resource = holder.get("graph") if holder != null else null
	inst.free()

	_ck("bridged scene has a graph at %s" % MOUNTAIN_GRAPH_NODE, graph != null)
	_ck("bridged material is a ShaderMaterial", mat is ShaderMaterial, str(mat))
	_ck("its shader is a VisualShader",
			mat is ShaderMaterial and (mat as ShaderMaterial).shader is VisualShader)

	var gen = null
	if graph != null and ClassDB.class_exists("DCTerrainGeneratorProcNodesBounded"):
		gen = ClassDB.instantiate("DCTerrainGeneratorProcNodesBounded")
		gen.set_world_scale(WORLD_SCALE)
		gen.set_heightfield(true)
		gen.set_graph(graph)
	_ck("generator compiled the graph", gen != null and gen.has_compiled_shader(),
			"" if gen == null else str(gen.get_last_compile_error()))

	# The RAW emitted density GLSL, before any of this file's transforms. This
	# is what the MESHER compiles, so it is the text to measure the emitter's
	# cost against -- measuring the spliced material instead mixes in the macro
	# pre-expansion and gives a number that describes neither.
	if gen != null:
		var rf := FileAccess.open("/tmp/density_raw.glsl", FileAccess.WRITE)
		rf.store_string(gen.get_compiled_glsl())
		rf.flush()
		rf.close()
		print("[GATE] wrote /tmp/density_raw.glsl (%d bytes)"
				% gen.get_compiled_glsl().length())

	# ---- the conversion under test -------------------------------------
	# ⚠ TIMED, because headless cannot tell a fast conversion from a fast
	# conversion followed by a shader compile that never returns. Run this gate
	# WITHOUT --headless (DISPLAY=:0 --rendering-driver vulkan) and the same
	# numbers describe the real driver compile.
	print("[GATE] compile-begin — the ONE compile that describes what renders")
	var t0 := Time.get_ticks_msec()
	var built := BDM.build(mat as ShaderMaterial, gen)
	var t_build := Time.get_ticks_msec() - t0
	print("[GATE] compile-end  build() took %d ms" % t_build)
	print("[GATE] stage-timings %s" % str(built.get("timings", {})))

	if built["material"] is ShaderMaterial:
		_built_material = built["material"]
	_ck("build() returned a ShaderMaterial", built["material"] is ShaderMaterial,
			", ".join(built["log"]))
	_ck("macros were pre-expanded", int(built["macros"]) > 0,
			"%d invocations" % int(built["macros"]))
	_ck("mutable globals were constified", int(built["consts"]) > 0,
			"%d" % int(built["consts"]))
	_ck("the .gdshader text is non-trivial", String(built["code"]).length() > 100000,
			"%d bytes" % String(built["code"]).length())
	_ck("it calls evaluate_density from fragment()",
			String(built["code"]).contains("evaluate_density(wp.x + e"))

	var names: Array[String] = []
	if built["material"] != null:
		for u in (built["material"] as ShaderMaterial).shader.get_shader_uniform_list():
			names.append(str(u["name"]))
	_ck("detail-normal uniforms exist on the result",
			names.has("dn_blend") and names.has("dn_eps"),
			"%d uniforms: %s" % [names.size(), ", ".join(names.slice(0, 8))])
	_ck("the bridge's own uniforms survived", names.has("bb_coord_scale"))

	# ---- the object-space hook -----------------------------------------
	# ⛔ OBJECT SPACE DOES NOT EXIST ON A CHUNKED TERRAIN. Every DCTerrain chunk
	# is its own RenderingServer instance with CHUNK-LOCAL vertices, so
	# inverse(MODEL_MATRIX) in the bridge's Generated expression returns a
	# position within that chunk (0..64 m, resetting at every boundary) rather
	# than one spanning the mountain. The material renders as a flat wash and
	# NOTHING ERRORS -- hyper-games #83 §6b. blender-bridge v0.161.0 declares
	# bb_world_to_object / bb_world_to_object_on for a consumer to supply the
	# transform, and DCTerrain pushes it (dc-terrain
	# demo/gate_custom_material_object_space.gd, 8/8).
	#
	# ⚠ THIS CHECKS THE PUBLISHED ASSET. The uniform is emitted by the addon's
	# material_serializer.py, and the expression text is BAKED INTO the .tscn at
	# publish time -- so changing the emitter does not reach this mountain until
	# it is republished from Blender. A failure here means exactly that, and
	# says so rather than looking like a code defect.
	_ck("the published asset carries the object-space hook",
			names.has("bb_world_to_object") and names.has("bb_world_to_object_on"),
			"republish mountain_voronoi2 from Blender 5.2 (addon v0.161.0+) -- "
			+ "the expression text lives in the .tscn, not in the addon")
	_ck("the detail normal samples the density in the TERRAIN frame",
			bool(built.get("object_space_hook", false)),
			"evaluate_density is terrain-local; map_flat puts the mountain 4 km "
			+ "out, so a world position asks about open air beside it")

	# ---- ...and the transform itself, WITHOUT waiting for a republish ---
	# The two checks above are statements about an ASSET. These are statements
	# about THIS FILE, so they hold today: given a source that declares the
	# uniform, the emitted body must convert; given one that does not, it must
	# fall back to the world position rather than naming an identifier that
	# would not compile.
	const FAKE_DENSITY := "float evaluate_density(float x, float y, float z) { return 0.0; }"
	var with_hook := BDM.splice("shader_type spatial;\n"
			+ "uniform mat4 bb_world_to_object = mat4(1.0);\n"
			+ "uniform float bb_world_to_object_on = 0.0;\n"
			+ "void fragment() {\n}\n", FAKE_DENSITY)
	var no_hook := BDM.splice("shader_type spatial;\nvoid fragment() {\n}\n", FAKE_DENSITY)
	_ck("a source WITH the uniform converts world -> terrain",
			with_hook.contains("bb_world_to_object * vec4(_gwp, 1.0)"))
	_ck("a source WITHOUT it falls back to the world position",
			no_hook.contains("vec3 wp = _gwp;")
					and not no_hook.contains("bb_world_to_object"),
			"naming an undeclared uniform would not compile")
	var framed := Shader.new()
	framed.code = with_hook
	_ck("the converted body compiles", framed.get_shader_uniform_list().size() > 0,
			"the ternary on mat4 is valid Godot shader language")

	# ---- MUTATION: the gate must be able to FAIL -----------------------
	# Without this, "17/17" only proves the script ran. Skipping the const
	# transform must break the compile -- if it does not, the transform is not
	# load-bearing and this gate is measuring nothing.
	var vs: VisualShader = (mat as ShaderMaterial).shader
	if vs.has_method("_update_shader"):
		vs.call("_update_shader")
	var no_const := BDM.splice(vs.code,
			BDM.expand_function_macros(
					gen.get_compiled_glsl())["code"])
	print("[GATE] mutation-begin (errors here are the POINT)")
	var mut := Shader.new()
	mut.code = no_const
	var mut_ok := mut.get_shader_uniform_list().size() > 0
	print("[GATE] mutation-end")
	_ck("MUTATION: dropping constify_globals breaks the compile", not mut_ok,
			"it compiled anyway — the transform is not load-bearing")

	# ---- #include, with a negative control -----------------------------
	# Proves Shader::set_code runs the preprocessor on runtime code with no
	# resource path (scene/resources/shader.cpp:107). The FIRST version of this
	# check asserted "gained uniforms", and terrain_density.gdshaderinc declares
	# ZERO uniforms -- it reported the same 0 either way. A broken ruler.
	var inc := Shader.new()
	inc.code = "shader_type spatial;\n" \
			+ "#include \"res://shaders/terrain_density.gdshaderinc\"\n" \
			+ "void fragment() { ALBEDO = vec3(clamp01(0.5)); }\n"
	_ck("#include resolves in a runtime Shader with no resource path",
			inc.get_shader_uniform_list() != null, "calls clamp01() from the inc")
	print("[GATE] negative-control-begin (the error here is the POINT)")
	var neg := Shader.new()
	neg.code = "shader_type spatial;\n" \
			+ "void fragment() { ALBEDO = vec3(clamp01(0.5)); }\n"
	var _u = neg.get_shader_uniform_list()
	print("[GATE] negative-control-end")

	if String(built["code"]) != "":
		var f := FileAccess.open(OUT, FileAccess.WRITE)
		f.store_string(String(built["code"]))
		f.flush()
		f.close()
		print("\nwrote %s (%d bytes)" % [OUT, String(built["code"]).length()])


func _composition() -> void:
	# ---- COMPOSITION: the real terrain, the real asset -----------------
	# Two gates passing separately is not the same claim as the two FITTING.
	# dc-terrain writes the parameter names in C++; the addon declares them in
	# Python; nothing makes a typo on either side show up in that repo's own
	# gate, which would agree with itself perfectly. So: a real DCTerrain, placed
	# where map_flat places the mountain, handed the REPUBLISHED material -- and
	# the transform has to arrive, under the names the shader actually declares.
	if _built_material != null and ClassDB.class_exists("DCTerrain") and _built_material is ShaderMaterial:
		var t: Node = ClassDB.instantiate("DCTerrain")
		t.set("use_gpu", false)
		root.add_child(t)
		# map_flat: tile (1, 0) * extent, and extent is world_scale * 40.
		var tile_at := Vector3(WORLD_SCALE * 40.0, 0.0, 0.0)
		(t as Node3D).position = tile_at
		t.set("custom_material", _built_material)
		var arrived = (_built_material).get_shader_parameter(
				"bb_world_to_object")
		# ⚠ NOT "is it a Projection". The republished .tscn SAVES
		# bb_world_to_object = identity as a shader parameter and build() copies
		# the source material's parameters, so the type check passes on the
		# COPIED DEFAULT whether dc-terrain pushed or not -- measured. The flag
		# is the discriminator: only dc-terrain sets it to 1.0.
		var on = (_built_material).get_shader_parameter(
				"bb_world_to_object_on")
		_ck("DCTerrain reaches the asset's own uniform names",
				arrived is Projection and float(on) == 1.0,
				"type=%s on=%s inside_tree=%s global_position=%v"
						% [type_string(typeof(arrived)), str(on),
						str((t as Node3D).is_inside_tree()),
						(t as Node3D).global_position])
		if arrived is Projection:
			# The point it is all for: a world position ON the mountain must come
			# back as a position WITHIN the authored box, not 4 km out.
			var probe := Vector3(tile_at.x + 137.0, 42.0, -88.0)
			var o: Vector4 = (arrived as Projection) * Vector4(probe.x, probe.y, probe.z, 1.0)
			_ck("a world point on the mountain lands inside the asset",
					absf(o.x - 137.0) < 1e-3 and absf(o.z + 88.0) < 1e-3,
					"world %v -> object (%.1f, %.1f, %.1f), and NOT %.0f m out"
							% [probe, o.x, o.y, o.z, tile_at.x])
		t.queue_free()