OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_mesh_bake_pixels.gd

215 lines · gd ref:
extends Node
## MESH-TIME BAKE PIXEL GATE (hyper-games #90): the mountain rendered with the SLIM
## material + per-vertex kernel against the FULL per-pixel material, same poses.
##
##   <fork editor> --path . --rendering-driver vulkan --resolution 1920x1080 \
##       res://scenes/test/gate_mesh_bake_pixels.tscn -- --graph <graph+mesh_bake.json> --out-dir <dir>
##
## The graph is the bridged mountain material as JSON WITH its mesh_bake
## (blender-bridge >= 0.168.0, emit_mesh_bake). It is built here through
## BlenderBridge._deserialize_material_graph, so this gate needs no republished
## scene; the full material it builds must equal the published one (checked in
## blender-bridge: identical code modulo group-function id suffixes).
##
## Per pose: FULL, then SLIM, then FULL again. FULL vs FULL is the noise floor
## (sky, LOD timing); FULL vs SLIM is the answer. A visible window is required
## (a hidden one renders nothing: every capture would be identical and the gate
## would pass on nothing -- so it checks the captures are not blank).
## Writes <pose>_{full,slim,full2}.png and <pose>_diff.png (|slim - full| x 16).

const MAP := "res://scenes/maps/map_mountain.tscn"
const PROBE := "res://scenes/test/terrain_probe.gd"
const LOAD_CAP_S := 240.0
const POSE_CAP_S := 40.0
const SETTLE_FRAMES := 30

var _map: Node = null
var _cam: Camera3D = null
var _graph := ""
var _out := ""
var _report := {}


func _ready() -> void:
	var args := OS.get_cmdline_user_args()
	for i in args.size():
		if args[i] == "--graph" and i + 1 < args.size():
			_graph = args[i + 1]
		elif args[i] == "--out-dir" and i + 1 < args.size():
			_out = args[i + 1]
	_run.call_deferred()


func _since(t0: int) -> float:
	return (Time.get_ticks_msec() - t0) / 1000.0


func _terrain() -> Node:
	return _map.get_node_or_null(^"MountainTerrain")


func _settle(cap_s: float, site = null) -> bool:
	var t0 := Time.get_ticks_msec()
	var calm := 0
	while calm < SETTLE_FRAMES and _since(t0) < cap_s:
		await get_tree().process_frame
		var tr := _terrain()
		var ok: bool = tr != null and bool(tr.call("is_lod_settled")) and int(tr.call("get_chunk_count")) > 0
		if site != null and ok:
			var ci: Dictionary = tr.call("debug_chunk_info", site)
			ok = int(ci.get("lod", 99)) <= 0
		calm = calm + 1 if ok else 0
	return calm >= SETTLE_FRAMES


func _capture(name: String) -> Image:
	for i in 3:
		await RenderingServer.frame_post_draw
	var img := get_viewport().get_texture().get_image()
	img.convert(Image.FORMAT_RGB8)
	img.save_png(_out.path_join(name + ".png"))
	return img


## {mean, p99, max, frac>2, frac>8} of the per-pixel max channel |a - b| in 0..255,
## and a heat map of it x16.
func _diff(a: Image, b: Image, heat_name: String) -> Dictionary:
	var w := a.get_width()
	var h := a.get_height()
	var da := a.get_data()
	var db := b.get_data()
	var hist := PackedInt64Array()
	hist.resize(256)
	var sum := 0
	var heat := PackedByteArray()
	heat.resize(w * h * 3)
	for i in w * h:
		var d := maxi(maxi(absi(da[i * 3] - db[i * 3]), absi(da[i * 3 + 1] - db[i * 3 + 1])),
				absi(da[i * 3 + 2] - db[i * 3 + 2]))
		hist[d] += 1
		sum += d
		var v := mini(255, d * 16)
		heat[i * 3] = v
		heat[i * 3 + 1] = v
		heat[i * 3 + 2] = v
	if heat_name != "":
		Image.create_from_data(w, h, false, Image.FORMAT_RGB8, heat).save_png(_out.path_join(heat_name + ".png"))
	var n := w * h
	var acc := 0
	var p99 := 0
	var mx := 0
	var over2 := 0
	var over8 := 0
	for d in 256:
		if hist[d] == 0:
			continue
		mx = d
		if acc < n * 0.99:
			p99 = d
		acc += hist[d]
		if d > 2:
			over2 += hist[d]
		if d > 8:
			over8 += hist[d]
	return {"mean": float(sum) / n, "p99": p99, "max": mx,
			"frac_gt2": float(over2) / n, "frac_gt8": float(over8) / n}


func _blank(img: Image) -> bool:
	var d := img.get_data()
	var first := d[0]
	for i in range(0, d.size(), 997):
		if d[i] != first:
			return false
	return true


func _run() -> void:
	if _graph == "" or _out == "":
		print("[GATE-MB] usage: -- --graph <json> --out-dir <dir>"); get_tree().quit(2); return
	DirAccess.make_dir_recursive_absolute(_out)
	var g = JSON.parse_string(FileAccess.get_file_as_string(_graph))
	if g == null or not (g as Dictionary).has("mesh_bake"):
		print("[GATE-MB] %s: no JSON with mesh_bake" % _graph); get_tree().quit(2); return
	var bridge = ClassDB.instantiate("BlenderBridge")
	var full_mb: ShaderMaterial = bridge.call("_deserialize_material_graph", g)
	if full_mb == null or not full_mb.has_meta("bb_mesh_bake"):
		print("[GATE-MB] the bridge built no material / no bb_mesh_bake meta"); get_tree().quit(1); return
	# RULER (GATE_MB_RULER=1): the kernel writes constants instead of the noises. If
	# the slim material really reads CUSTOM2, the masks jump and full-vs-slim must
	# blow far past the floor; if it silently fell back (w = 0) it would not move.
	if OS.get_environment("GATE_MB_RULER") == "1":
		var mb: Dictionary = full_mb.get_meta("bb_mesh_bake")
		var k: String = mb["kernel"]
		var i := k.find("void dc_material_kernel(")
		mb["kernel"] = k.substr(0, i) + ("void dc_material_kernel(vec3 p, vec3 n, float spacing, out vec4 c0, out vec4 c1, out vec4 c2, out vec4 c3)"
				+ " { c0 = vec4(2.0, 1.0, 0.0, 0.0); c1 = vec4(0.0); c2 = vec4(0.0); c3 = vec4(0.0); }\n")
		full_mb.set_meta("bb_mesh_bake", mb)
		print("[GATE-MB] RULER: constant kernel")

	_cam = Camera3D.new()
	_cam.far = 20000.0
	add_child(_cam)
	_cam.current = true
	_map = (load(MAP) as PackedScene).instantiate()
	_map.set("mountain_terrain_material", 1)   # forced Full: the setting must not decide
	add_child(_map)
	var spawn := Vector3(0, 250, 0)
	var sp := get_tree().get_nodes_in_group("spawn_points")
	if not sp.is_empty():
		spawn = (sp[0] as Node3D).global_position
	_cam.global_position = spawn + Vector3(0, 2, 0)
	var settled: bool = await _settle(LOAD_CAP_S)
	print("[GATE-MB] load %s" % ("settled" if settled else "CAP HIT"))
	var tr := _terrain()
	# The same full material the map would load, but carrying the meta. Same
	# runtime parameter the map sets.
	full_mb.set_shader_parameter("bb_coord_scale", 1.0 / float(_map.get("mountain_world_scale")))
	tr.set("custom_material", full_mb)

	var col := Vector3(spawn.x + 40.0, 1500.0, spawn.z)
	var s = _map.call("_find_surface_gpu", load(PROBE), tr, col, 64.0)
	var site := Vector3(col.x, float(s) if s != null else spawn.y, col.z)
	var poses := [
		["close", site + Vector3(0, 25, 25), site, site],
		["far", Vector3(2500.0, 1200.0, 0.0), Vector3(0, 900, 0), null],
	]
	var fails := 0
	for pose in poses:
		var pname: String = pose[0]
		_cam.global_position = pose[1]
		_cam.look_at(pose[2])
		# FULL
		tr.call("set_material_kernel", "")
		tr.set("custom_material", full_mb)
		await _settle(POSE_CAP_S, pose[3])
		var a: Image = await _capture(pname + "_full")
		# SLIM + kernel, through the map's own wiring
		_map.set("mountain_mesh_bake", true)     # loaded OFF so the FULL capture comes first
		var slim: Material = _map.call("_apply_mesh_bake", full_mb)
		var on := slim != full_mb
		await _settle(POSE_CAP_S, pose[3])
		var b: Image = await _capture(pname + "_slim")
		# FULL again: the floor
		tr.call("set_material_kernel", "")
		tr.set("custom_material", full_mb)
		await _settle(POSE_CAP_S, pose[3])
		var a2: Image = await _capture(pname + "_full2")
		var floor_d := _diff(a, a2, "")
		var d := _diff(a, b, pname + "_diff")
		var d2 := _diff(a2, b, "")
		var blank := _blank(a) or _blank(b)
		_report[pname] = {"mesh_bake_on": on, "full_vs_full": floor_d, "full_vs_slim": d,
				"full2_vs_slim": d2, "blank": blank}
		print("[GATE-MB] %s: mesh bake %s | floor mean %.3f p99 %d max %d | slim mean %.3f p99 %d max %d >2: %.4f >8: %.4f | blank %s"
				% [pname, "ON" if on else "OFF", floor_d.mean, floor_d.p99, floor_d.max,
				   d.mean, d.p99, d.max, d.frac_gt2, d.frac_gt8, str(blank)])
		if not on or blank:
			fails += 1
	var f := FileAccess.open(_out.path_join("report.json"), FileAccess.WRITE)
	f.store_string(JSON.stringify(_report, "  "))
	f.close()
	print("[GATE-MB] report %s -- %s" % [_out.path_join("report.json"),
			"RAN" if fails == 0 else "INVALID (%d)" % fails])
	get_tree().quit(0 if fails == 0 else 1)