OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_lod_refine_order.gd

142 lines · gd ref:
extends SceneTree
## GATE — the terrain refines from the PLAYER'S point of view, not one world-space
## octree quadrant at a time (user 2026-10-06: "only half the mountain coalescing to its
## highest lod ... as if half the mountain is waiting for the other half").
##
## map_mountain from its first spawn point, camera looking at the massif. Every 0.5 s
## the leaf LOD is sampled on a 3D grid over the mountain (debug_chunk_info); once the
## tree settles each sample's FINAL LOD is known, and every earlier sample's EXCESS
## (lod - final) says how much coarser than wanted it still was, per quadrant (sign of
## x, sign of z). The spawn stands ON the z = 0 plane, so z- and z+ at the same x are
## MIRROR IMAGES of each other from the player's point of view and must fall TOGETHER:
## the gap between mirrored quadrants must stay under MAX_SPREAD levels. (The x+ side,
## the camera's, legitimately needs more levels than the far x- side — comparing ALL
## quadrants would fail a correct, view-ordered refinement.)
##
## Measured before the fix (2026-10-06): quadrants finished one after another at
## t = 2, 4, 6, 8 s; mirrored gap 6.24 levels, and the camera's OWN quadrant was last.
## Pre-mesh dispatch was ordered by distance to the waiting node's CENTRE, so a huge
## coarse cell (centre far away even with the camera inside it) lost to every finer,
## nearer request in the half that started first.
##
## ⚠ WINDOWED. Unset CI or the window is hidden and presents at ~1 Hz:
##   env -u CI HYPERTPS_DAEMON_TCP=127.0.0.1:1 DISPLAY=:0 "$HYPER_TPS_GODOT" --path . \
##       --script res://scenes/test/gate_lod_refine_order.gd

const MAP := "res://scenes/maps/map_mountain.tscn"
const HALF := 3000.0
const STEP := 300.0
const YS := [100.0, 400.0, 800.0, 1200.0, 1800.0]
const MAX_SPREAD := 1.5
const MAX_SECS := 60.0
const MIN_FPS := 15
const ProbeWindow := preload("res://scenes/test/probe_window.gd")

var _ws := ""
var _t: Node
var _pts: PackedVector3Array = []
var _hist: Array = []
var _last := 0
var _start := 0
var _fps_min := 1 << 30
var _fps_min_t := 0.0
var _last_frames := 0


func _initialize() -> void:
	_ws = ProbeWindow.free_workspace()
	var m: Node = (load(MAP) as PackedScene).instantiate()
	root.add_child(m)
	var spawn := Vector3.ZERO
	for n in m.get_children():
		if n.is_in_group("spawn_points"):
			spawn = (n as Node3D).position
			break
	var cam := Camera3D.new()
	cam.far = 20000.0
	root.add_child(cam)
	cam.look_at_from_position(spawn + Vector3(0, 1.7, 0), Vector3(0, spawn.y + 300.0, 0))
	cam.current = true
	var x := -HALF
	while x <= HALF + 0.1:
		var z := -HALF
		while z <= HALF + 0.1:
			for y: float in YS:
				_pts.append(Vector3(x, y, z))
			z += STEP
		x += STEP
	_start = Time.get_ticks_msec()


func _process(_d: float) -> bool:
	if not ProbeWindow.drawing(root):
		ProbeWindow.move_to(_ws, Vector2i(1600, 900))
	if _t == null:
		var f := root.find_children("*", "DCTerrain", true, false)
		if f.is_empty():
			return false
		_t = f[0]
	var now := Time.get_ticks_msec()
	if now - _last < 500:
		return false
	# MAIN-LOOP rate, not drawn fps: LOD streaming runs on the main loop. A window
	# that briefly cannot draw (moved / minimized) reads 1 drawn fps while the loop
	# runs at full speed; a window hidden behind a fullscreen one throttles the LOOP
	# itself to ~1 Hz (NVIDIA present), and that is what makes a run prove nothing.
	var frames := Engine.get_process_frames()
	# The first sample has no previous one: _last is 0 and the map's _ready may have
	# blocked for seconds before the first frame (map_mountain: ~8 s) — not a stall
	# DURING streaming, so it is not counted.
	var fps := int((frames - _last_frames) * 1000.0 / maxi(now - _last, 1)) if _last > 0 else 1 << 30
	_last_frames = frames
	_last = now
	if now - _start > 3000:
		if fps < _fps_min:
			_fps_min = fps
			_fps_min_t = (now - _start) / 1000.0
	var row := PackedInt32Array()
	row.resize(_pts.size())
	for i in _pts.size():
		row[i] = int((_t.debug_chunk_info(_pts[i]) as Dictionary).get("lod", 99))
	_hist.append(row)
	var el := (now - _start) / 1000.0
	if el < 5.0 or (not _t.is_lod_settled() and el < MAX_SECS):
		return false
	_report(el)
	return true


func _report(el: float) -> void:
	var fin: PackedInt32Array = _hist[_hist.size() - 1]
	var worst := 0.0
	var worst_t := 0.0
	var worst_line := ""
	for s in _hist.size():
		var row: PackedInt32Array = _hist[s]
		var sum := [0, 0, 0, 0]
		var cnt := [0, 0, 0, 0]
		for i in _pts.size():
			var q := (2 if _pts[i].x >= 0 else 0) + (1 if _pts[i].z >= 0 else 0)
			sum[q] += row[i] - fin[i]
			cnt[q] += 1
		var ex: Array = []
		for q in 4:
			ex.append(float(sum[q]) / maxi(cnt[q], 1))
		var spread: float = maxf(absf(ex[0] - ex[1]), absf(ex[2] - ex[3]))   # x-: z-/z+, x+: z-/z+
		if spread > worst:
			worst = spread
			worst_t = s * 0.5
			worst_line = "x-z- %.2f  x-z+ %.2f  x+z- %.2f  x+z+ %.2f" % ex
	var settled: bool = _t.is_lod_settled()
	print("  settled=%s after %.1f s, %d chunks, min fps %d at t=%.1f s" % [settled, el, _t.get_chunk_count(), _fps_min, _fps_min_t])
	print("  worst mirrored-quadrant gap %.2f levels at t=%.1f s (mean excess: %s)" % [worst, worst_t, worst_line])
	var ok := settled and worst <= MAX_SPREAD and _fps_min >= MIN_FPS
	if not settled:
		print("FAIL  never settled in %.0f s — final LODs unknown, the spread proves nothing" % MAX_SECS)
	if _fps_min < MIN_FPS:
		print("FAIL  min fps %d < %d — window hidden? proves nothing" % [_fps_min, MIN_FPS])
	if worst > MAX_SPREAD:
		print("FAIL  one half refined %.2f levels ahead of its mirror image (max %.1f)" % [worst, MAX_SPREAD])
	print("PASS" if ok else "FAIL")
	quit(0 if ok else 1)