OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / density_field_gate.gd

172 lines · gd ref:
extends SceneTree
## THE FIELD, FROM BOTH SIDES, AS NUMBERS.
##
##   "$HYPER_TPS_GODOT" --headless --path . --script res://scenes/test/density_field_gate.gd -- \
##       --map res://scenes/maps/map_dc_diffusion.tscn --out /tmp/field.txt
##
## WHY THIS EXISTS. The proc-nodes decomposition (hyper-games #53 section 9.1)
## moves the mountain, the detail and the erosion knobs out of a hand-assembled
## wrapper and into graph nodes. Its acceptance is "both maps' emitted GLSL
## unchanged in BEHAVIOUR" -- and a diff of 60 KB of shader text cannot say that,
## because the whole point of the change is that the text is different. So the
## check has to be the field itself.
##
## TWO INDEPENDENT PATHS, because they are two independent implementations and
## the failure that matters most is them disagreeing:
##   CPU  DCTerrain.sample_density -> ProcNodesBridge::evaluate  (collision)
##   GPU  DCTerrain.debug_gpu_density -> the compiled shader     (what you see)
## A player falls through ground that is visibly there exactly when these part.
##
## SELF-TEST FIRST, and it can fail. #50 shipped an instrument that returned an
## identical number for every input and #53 shipped one whose threshold filtered
## out the entire problem. So before any row is written this asserts that the
## CPU field VARIES across the grid, that the GPU field VARIES, and that the two
## are not accidentally the same array. A run whose header says SELFTEST FAIL is
## not evidence of anything.

var MAP := "res://scenes/maps/map_dc_diffusion.tscn"
var OUT := "/tmp/density_field.txt"
## Probe centre. The default is inside map_dc_diffusion's baked block
## (world x 38370..69150, z 23010..53790); outside it the sampler CLAMPS and
## every row would report the same clamped edge, which reads as a pass.
var OX := 52800.0
var OZ := 37440.0
var STEP := 240.0
var N := 8
## Surface search band. Wide enough for both maps' floor/ceiling.
var Y_LO := -11000.0
var Y_HI := 10000.0

var _map: Node = null
## The GPU thread is created LAZILY, inside the meshing loop, the first time a
## chunk actually needs a GPU fill (dc_terrain.cpp:1600). One frame after
## add_child there is no thread yet and debug_gpu_density returns an empty
## array -- which reads exactly like "the GPU declined". So wait for it, and say
## how long it took rather than quietly reporting half a gate.
var _frames := 0
const GPU_WAIT_FRAMES := 600


func _initialize() -> void:
	var args := OS.get_cmdline_user_args()
	for i in range(args.size() - 1):
		match args[i]:
			"--map": MAP = args[i + 1]
			"--out": OUT = args[i + 1]
			"--ox": OX = float(args[i + 1])
			"--oz": OZ = float(args[i + 1])
			"--step": STEP = float(args[i + 1])
			"--n": N = int(args[i + 1])
	var packed := load(MAP) as PackedScene
	if packed == null:
		print("FAIL — %s did not load" % MAP)
		quit(2)
		return
	_map = packed.instantiate()
	root.add_child(_map)


## Bisect for the density zero crossing. Density is positive below the surface
## and negative above (the SDF convention every wrapper in this stack ends on),
## so this is a plain sign search and does not care which path produced it.
func _surface(terrain: Node, x: float, z: float) -> float:
	var lo := Y_LO
	var hi := Y_HI
	if terrain.call("sample_density", Vector3(x, lo, z)) <= 0.0:
		return NAN                      # no ground anywhere in the band
	if terrain.call("sample_density", Vector3(x, hi, z)) > 0.0:
		return NAN                      # solid to the ceiling
	for _i in range(48):
		var mid := (lo + hi) * 0.5
		if terrain.call("sample_density", Vector3(x, mid, z)) > 0.0:
			lo = mid
		else:
			hi = mid
	return (lo + hi) * 0.5


func _process(_dt: float) -> bool:
	var terrain: Node = _map.get_node_or_null("DCTerrain")
	if terrain == null:
		print("FAIL — no DCTerrain in %s" % MAP); quit(1); return true
	if terrain.get("generator") == null:
		print("FAIL — DCTerrain has no generator; it is drawing built-in noise")
		quit(1); return true

	# ---- wait for the GPU thread ----------------------------------------
	_frames += 1
	var probe: PackedFloat32Array = PackedFloat32Array()
	if terrain.has_method("debug_gpu_density"):
		probe = terrain.call("debug_gpu_density", Vector3(OX, 0.0, OZ), 1.0, 8)
	if probe.is_empty() and _frames < GPU_WAIT_FRAMES:
		return false

	# ---- CPU field -------------------------------------------------------
	var cpu: Array[float] = []
	for jz in range(N):
		for ix in range(N):
			cpu.append(_surface(terrain, OX + ix * STEP, OZ + jz * STEP))

	# ---- GPU field -------------------------------------------------------
	# One block at the probe centre, at a 1 m voxel so voxel_scale is LOD 0 and
	# no Nyquist term is engaged -- the same field the finest chunks mesh.
	var gpu: PackedFloat32Array = probe

	# ---- SELF-TEST, before anything is written ---------------------------
	var fails: Array[String] = []
	var finite := 0
	var cmin := INF
	var cmax := -INF
	for v in cpu:
		if is_finite(v):
			finite += 1
			cmin = minf(cmin, v)
			cmax = maxf(cmax, v)
	if finite < cpu.size() / 2:
		fails.append("CPU: %d/%d columns found no surface in [%.0f, %.0f]" % [
				finite, cpu.size(), Y_LO, Y_HI])
	if finite > 1 and absf(cmax - cmin) < 0.001:
		fails.append("CPU: every column reports the same height (%.6f) — a broken ruler" % cmin)
	if gpu.is_empty():
		fails.append("GPU: debug_gpu_density returned nothing after %d frames — the GPU declined, so half this gate is blind" % _frames)
	else:
		var gmin := INF
		var gmax := -INF
		for v in gpu:
			gmin = minf(gmin, float(v))
			gmax = maxf(gmax, float(v))
		if absf(gmax - gmin) < 1e-9:
			fails.append("GPU: every cell reports %.9f — a broken ruler" % gmin)

	var f := FileAccess.open(OUT, FileAccess.WRITE)
	if fails.is_empty():
		f.store_line("# SELFTEST PASS  (cpu varies %.3f..%.3f m, gpu %d cells)" % [cmin, cmax, gpu.size()])
	else:
		f.store_line("# SELFTEST FAIL — this file is NOT evidence")
		for s in fails:
			f.store_line("#   " + s)
	f.store_line("# map=%s  ox=%.1f oz=%.1f step=%.1f n=%d  gpu_ready_after=%d frames" % [MAP, OX, OZ, STEP, N, _frames])
	f.store_line("# CPU surface Y by bisection of DCTerrain.sample_density")
	for jz in range(N):
		for ix in range(N):
			var v := cpu[jz * N + ix]
			f.store_line("cpu %8.1f %8.1f  %s" % [
					OX + ix * STEP, OZ + jz * STEP,
					("nan" if not is_finite(v) else "%.6f" % v)])
	f.store_line("# GPU density, debug_gpu_density(origin=(%.1f,0,%.1f), voxel=1, cells=8)" % [OX, OZ])
	for i in range(gpu.size()):
		f.store_line("gpu %4d  %.9f" % [i, gpu[i]])
	f.flush()
	f.close()

	if fails.is_empty():
		print("wrote %s — SELFTEST PASS (cpu %.3f..%.3f m over %d columns, %d gpu cells)" % [
				OUT, cmin, cmax, finite, gpu.size()])
		quit(0)
	else:
		print("wrote %s — SELFTEST FAIL:" % OUT)
		for s in fails:
			print("  " + s)
		quit(4)
	return true