OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / mountain_bench.gd

410 lines · gd ref:
extends Node
## MOUNTAIN BENCHMARK — frame times on map_mountain under a FIXED workload.
##
##   scripts/mountain-bench.sh [--out /path/result.json] [--inject-ms 25]
##
## THE WORKLOAD IS FIXED; the implementation is what changes. Never shorten a phase,
## drop edits, lower the resolution or change a setting to win (optimization-loop §3).
##
## Phases (wall-clock, same every run):
##   load     map instantiated -> terrain SETTLED (is_lod_settled() for 30 consecutive
##            frames). Reported as a time, not as frames. ⛔ NOT get_pending_count()==0:
##            that counts PENDING leaves only, and a split waiting on its children's
##            pre-meshes is neither -- "pending 0" held while the camera's own chunk sat
##            at LOD 7 for 40 s (measured 2026-09-29, DC_LODLOG).
##   idle     20 s at the first spawn point, looking at the island centre.
##   orbit    20 s, one revolution at r=2500 m, y=1200 m, looking at (0, 900, 0).
##   csg      16 edits through RMB's own path (map_core._carve_terrain): alternating
##            hard-sphere subtract r=8 / add r=12 at fixed surface points near the
##            spawn, one every 0.5 s, camera aimed at each from 25 m. Before each carve
##            the camera jumps and WAITS (unrecorded) until the chunk under the aim is
##            LOD <= 0 with collision and the aim ray hits; that wait is reported as
##            "refine" -- time-to-detail after a jump.
##   recover  10 s holding the last aim, while meshing/collision catch up.
##
## Per frame: frame ms (wall clock between frames), CPU and GPU render ms
## (RenderingServer's own timestamps -- never wall clock around an async submit),
## process ms, draw calls, primitives, dc-terrain chunks/active/pending/edits.
## Per phase: p50/p95/p99/max frame ms, % over 16.7 ms, frames over 33.3 ms (hitches),
## the 5 worst frames with their time. 60 fps target = p99 < 16.7 AND 0 hitches.
##
## RULER SELF-TEST (optimization-loop §0): --inject-ms N stalls the main thread N ms
## on the first frame after idle t = 5, 6 and 7 s. A valid ruler reports exactly
## those three as the idle phase's worst frames. Run it once per ruler change.
##
## ⛔ NOT HEADLESS (the mountain field is GPU-only; see mountain_edit_gate.gd).
## ⚠ Not in the scene: game.gd's Sky3D + clouds (map_core's /root/Game/Sky3DExtended).
##   This measures terrain, material, streaming and CSG; the in-game run adds sky cost.

const MAP := "res://scenes/maps/map_mountain.tscn"
const MAP_ID := "map_mountain"
const PROBE := "res://scenes/test/terrain_probe.gd"
const SETTLE_FRAMES := 30
const LOAD_CAP_S := 240.0
const IDLE_S := 20.0
const ORBIT_S := 20.0
const CSG_EDITS := 16
const CSG_INTERVAL_S := 0.5
const RECOVER_S := 10.0
const REFINE_CAP_S := 30.0      # per carve: camera jump -> LOD settled AND aim ray hits
const RENDER_WARMUP_FRAMES := 5 # the window's first frames draw nothing; not a hidden window
const HITCH_MS := 1000.0 / 30.0
const BUDGET_MS := 1000.0 / 60.0

var _map: Node
var _cam: Camera3D
var _mgr: Node
var _vp_rid: RID
var _phase := "setup"
var _phase_t0 := 0
var _last_us := 0
var _frames: Array = []         # [phase, t_s, frame_ms, cpu_ms, gpu_ms, process_ms, draws, prims, chunks, active, pending, edits, worker_queue, premesh_pending, settled]
var _inject_ms := 0
var _injected := {}
var _out := ""
var _load_s := -1.0
var _plain := false
var _no_mesh_bake := false
var _shader_path := ""
var _refine := {}
## --aim-control: record AIM_CONTROL_S at the FIRST carve site's pose, settled, BEFORE any
## carve, as phase "aim". csg/recover view a surface from 25 m while orbit views the
## island from 2.5 km, so csg-vs-orbit mixes "carving costs frames" with "a close-up
## costs frames". aim is csg's pose without the carving. Off by default: it adds a phase
## ahead of csg, and existing runs must stay comparable (fixed workload).
var _aim_control := false
const AIM_CONTROL_S := 5.0


func _ready() -> void:
	var args := OS.get_cmdline_user_args()
	var i := 0
	while i < args.size():
		if args[i] == "--out" and i + 1 < args.size():
			_out = args[i + 1]; i += 1
		elif args[i] == "--shader" and i + 1 < args.size():
			_shader_path = args[i + 1]; i += 1       # A/B: swap the terrain material's CODE, keep its parameters
		elif args[i] == "--plain-material":
			_plain = true                             # A/B: map_mountain's own mountain_plain_material switch
		elif args[i] == "--no-mesh-bake":
			_no_mesh_bake = true                      # A/B: map_mountain's mountain_mesh_bake switch (#90)
		elif args[i] == "--inject-ms" and i + 1 < args.size():
			_inject_ms = int(args[i + 1]); i += 1
		elif args[i] == "--aim-control":
			_aim_control = true
		i += 1
	_run.call_deferred()


func _terrain() -> Node:
	return _map.get_node_or_null(^"MountainTerrain") if _map != null else null


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


func _set_phase(p: String) -> void:
	_phase = p
	_phase_t0 = Time.get_ticks_msec()
	print("[BENCH] phase %s" % p)


func _process(_d: float) -> void:
	var now := Time.get_ticks_usec()
	if _last_us == 0:
		_last_us = now
		return
	var frame_ms := (now - _last_us) / 1000.0
	_last_us = now
	if _phase in ["setup", "done"]:
		return
	var t_s := _since(_phase_t0)
	var tr := _terrain()
	_frames.append([_phase, t_s, frame_ms,
			RenderingServer.viewport_get_measured_render_time_cpu(_vp_rid),
			RenderingServer.viewport_get_measured_render_time_gpu(_vp_rid),
			Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0,
			Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME),
			Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME),
			int(tr.call("get_chunk_count")) if tr else -1,
			int(tr.call("get_active_chunk_count")) if tr else -1,
			int(tr.call("get_pending_count")) if tr else -1,
			int(tr.call("get_edit_count")) if tr else -1,
			int(tr.call("get_worker_queue_depth")) if tr else -1,
			int(tr.call("get_premesh_pending_count")) if tr else -1,
			bool(tr.call("is_lod_settled")) if tr else false])
	# Ruler self-test: a known stall on known frames.
	if _inject_ms > 0 and _phase == "idle":
		for mark in [5, 6, 7]:
			if t_s >= mark and not _injected.has(mark):
				_injected[mark] = true
				OS.delay_msec(_inject_ms)


func _wait(seconds: float) -> void:
	var t0 := Time.get_ticks_msec()
	while _since(t0) < seconds:
		await get_tree().process_frame


func _run() -> void:
	print("\n=== mountain benchmark ===")
	if DisplayServer.get_name() == "headless":
		print("RESULT: SKIP (needs a display: the mountain's field is GPU-only)")
		get_tree().quit(2)
		return
	DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)
	Engine.max_fps = 0
	_vp_rid = get_viewport().get_viewport_rid()
	RenderingServer.viewport_set_measure_render_time(_vp_rid, true)

	GameState.enter_map(MAP_ID)
	_mgr = TerrainEditManager.new()
	_mgr.name = "TerrainEditManager"
	add_child(_mgr)
	_cam = Camera3D.new()
	_cam.far = 20000.0
	add_child(_cam)
	_cam.current = true

	# ---- load ----------------------------------------------------------------
	_set_phase("load")
	var t_load := Time.get_ticks_msec()
	_map = (load(MAP) as PackedScene).instantiate()
	if _plain:
		_map.set("mountain_plain_material", true)     # read in _ready, so set before add_child
	# FORCED either way (#91): the player's Terrain Material setting must not
	# decide what a bench run measures. Default Vertex, as before the setting.
	_map.set("mountain_terrain_material", 1 if _no_mesh_bake else 2)
	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)
	_cam.look_at(Vector3(0, 900, 0))
	var calm := 0
	while calm < SETTLE_FRAMES and _since(t_load) < LOAD_CAP_S:
		await get_tree().process_frame
		var tr := _terrain()
		calm = calm + 1 if (tr != null and bool(tr.call("is_lod_settled"))
				and int(tr.call("get_chunk_count")) > 0) else 0
	_load_s = _since(t_load)
	if _shader_path != "":
		var cur: ShaderMaterial = _terrain().get("custom_material") as ShaderMaterial
		if cur == null:
			print("[BENCH] --shader: the terrain has no ShaderMaterial to swap"); get_tree().quit(1); return
		var swapped: ShaderMaterial = cur.duplicate()
		var sh := Shader.new()
		sh.code = FileAccess.get_file_as_string(_shader_path)
		swapped.shader = sh
		_terrain().set("custom_material", swapped)
		print("[BENCH] shader swapped to %s (%d chars)" % [_shader_path, sh.code.length()])
		_phase = "setup"
		await _wait(3.0)                          # first draw compiles it: unrecorded
	print("[BENCH] load: %.1f s (%s)" % [_load_s, "settled" if calm >= SETTLE_FRAMES else "CAP HIT"])

	# ---- idle ----------------------------------------------------------------
	_set_phase("idle")
	await _wait(IDLE_S)

	# ---- orbit ---------------------------------------------------------------
	_set_phase("orbit")
	var t_orb := Time.get_ticks_msec()
	while _since(t_orb) < ORBIT_S:
		var a := TAU * _since(t_orb) / ORBIT_S
		_cam.global_position = Vector3(cos(a) * 2500.0, 1200.0, sin(a) * 2500.0)
		_cam.look_at(Vector3(0, 900, 0))
		await get_tree().process_frame

	# ---- csg -----------------------------------------------------------------
	# Fixed surface points near the spawn, found on the GPU field (the gate's method).
	# ⛔ NOT RECORDED: the 16 GPU surface searches block one frame for ~3.3 s (measured,
	# first self-test run), and that is benchmark setup, not a CSG cost. _process skips
	# the "setup" phase; the settle wait lets that long frame land inside it.
	_phase = "setup"
	var tr := _terrain()
	var Probe = load(PROBE)
	var sites: Array[Vector3] = []
	for k in CSG_EDITS:
		var col := Vector3(spawn.x + 40.0 + 12.0 * (k % 4), 1500.0, spawn.z + 12.0 * (k / 4))
		var s = _map.call("_find_surface_gpu", Probe, tr, col, 64.0)
		if s != null:
			var fine = _map.call("_find_surface_gpu", Probe, tr, Vector3(col.x, float(s), col.z), 1.0)
			sites.append(Vector3(col.x, float(fine if fine != null else s), col.z))
	print("[BENCH] csg: %d/%d surface sites found" % [sites.size(), CSG_EDITS])
	_cam.global_position = sites[0] + Vector3(0, 25, 25) if not sites.is_empty() else _cam.global_position
	await _wait(1.0)                             # the search's long frame is measured HERE, unrecorded
	_set_phase("csg")
	_map.set("_csg_type", 2)
	var edits0 := int(tr.call("get_edit_count"))
	var aim_waits: Array[float] = []
	var refine_queue_max := 0
	for k in sites.size():
		var p: Vector3 = sites[k]
		# ⛔ THE WORKLOAD MUST BE FIXED: _carve_terrain raycasts the COLLISION from the
		# camera, and right after a jump the chunk under the new aim may still be a
		# 1-2 km LOD 5-7 chunk with no collision -- 4/16 to 12/16 carves applied on
		# identical runs. Wait until THAT chunk is fine and solid and the ray hits, as
		# a player would have to. The wait is setup (unrecorded) and is reported on its own as
		# "refine": it is the time-to-detail after a jump, which is a cost in itself.
		_phase = "setup"
		_cam.global_position = p + Vector3(0, 25, 25)
		_cam.look_at(p)
		var t_aim := Time.get_ticks_msec()
		var hit := false
		while _since(t_aim) < REFINE_CAP_S:
			await get_tree().process_frame
			refine_queue_max = maxi(refine_queue_max, int(tr.call("get_worker_queue_depth")))
			# Detail UNDER THE AIM, not the whole tree: after the orbit ~2200 pre-meshes
			# for the far island are still queued, and is_lod_settled() waits for all of
			# them -- that is backlog drain (the load metric), not what the player sees.
			var ci: Dictionary = tr.call("debug_chunk_info", p)
			if int(ci.get("lod", 99)) > 0 or not bool(ci.get("has_collision", false)):
				continue
			var q := PhysicsRayQueryParameters3D.create(_cam.global_position,
					_cam.global_position - _cam.global_basis.z * 200.0)
			if not get_viewport().get_world_3d().direct_space_state.intersect_ray(q).is_empty():
				hit = true
				break
		aim_waits.append(_since(t_aim))
		if _aim_control and k == 0:
			# Same pose csg is about to carve from, settled, nothing carved yet.
			_set_phase("aim")
			await _wait(AIM_CONTROL_S)
		_phase = "csg"
		if not hit:
			# WHY the ray missed, not just that it did: is the chunk under the site meshed,
			# at a collision LOD, with a body -- and is the queue still draining?
			print("[BENCH] aim MISS %d site=%s chunk=%s cam_chunk=%s col_faces(10m)=%d col_queue=%d pending=%d"
					% [k, str(p), str(tr.call("debug_chunk_info", p)),
					str(tr.call("debug_chunk_info", _cam.global_position)),
					(tr.call("debug_collision_faces", p, 10.0) as PackedVector3Array).size() / 3,
					int(tr.call("get_collision_queue_size")), int(tr.call("get_pending_count"))])
			print("[BENCH]   settled=%s worker_queue=%d premesh_pending=%d" % [str(tr.call("is_lod_settled")),
					int(tr.call("get_worker_queue_depth")), int(tr.call("get_premesh_pending_count"))])
		_map.set("_csg_radius", 8.0 if k % 2 == 0 else 12.0)
		_map.call("_carve_terrain", k % 2 == 1)
		await _wait(CSG_INTERVAL_S)
	aim_waits.sort()
	_refine = {"median_s": aim_waits[aim_waits.size() / 2] if not aim_waits.is_empty() else -1.0,
			"max_s": aim_waits[-1] if not aim_waits.is_empty() else -1.0, "worker_queue_max": refine_queue_max}
	print("[BENCH] refine (jump -> aim chunk LOD<=0 + collision + ray hit): median %.2f s, max %.2f s, worker queue max %d"
			% [_refine.median_s, _refine.max_s, refine_queue_max])
	var edits_done := int(tr.call("get_edit_count")) - edits0

	# ---- recover -------------------------------------------------------------
	_set_phase("recover")
	await _wait(RECOVER_S)
	_phase = "done"
	_report(sites.size(), edits_done)


func _pct(sorted: Array, q: float) -> float:
	if sorted.is_empty():
		return NAN
	return float(sorted[mini(sorted.size() - 1, int(q * (sorted.size() - 1) + 0.5))])


func _report(csg_sites: int, csg_edits: int) -> void:
	var phases := {}
	for f: Array in _frames:
		if not phases.has(f[0]):
			phases[f[0]] = []
		phases[f[0]].append(f)
	var result := {
		"config": {
			"gpu": RenderingServer.get_video_adapter_name(),
			"driver": RenderingServer.get_video_adapter_api_version(),
			"engine": Engine.get_version_info().get("string", ""),
			"build": "debug" if OS.is_debug_build() else "release",
			"editor_binary": OS.has_feature("editor"),
			"resolution": str(get_viewport().get_visible_rect().size),
			"vsync": DisplayServer.window_get_vsync_mode(),
			"inject_ms": _inject_ms,
			"plain_material": _plain,
			"mesh_bake": not _no_mesh_bake,
			"shader_override": _shader_path,
		},
		"load_s": _load_s, "refine": _refine, "csg_sites": csg_sites, "csg_edits_applied": csg_edits, "phases": {},
	}
	print("\n[BENCH] %s | %s | %s | %s" % [result.config.gpu, result.config.engine, result.config.build,
			result.config.resolution])
	print("[BENCH] load %.1f s   csg edits applied %d/%d" % [_load_s, csg_edits, csg_sites])
	result["workload_complete"] = csg_edits == CSG_EDITS and csg_sites == CSG_EDITS
	if not result.workload_complete:
		print("[BENCH] ⛔ WORKLOAD INCOMPLETE (%d/%d edits) -- this run is NOT comparable; discard it." % [csg_edits, CSG_EDITS])
	print("[BENCH] %-8s %6s %7s %7s %7s %8s %6s %7s %7s %7s %6s" % ["phase", "frames", "p50", "p95", "p99",
			"max", ">16.7", "hitch", "gpu95", "cpu95", "proc95"])
	for ph in ["load", "idle", "orbit", "aim", "csg", "recover"]:
		if not phases.has(ph):
			continue
		var rows: Array = phases[ph]
		var ft := []; var gpu := []; var cpu := []; var proc := []
		var over := 0; var hitch := 0
		for r: Array in rows:
			ft.append(r[2]); cpu.append(r[3]); gpu.append(r[4]); proc.append(r[5])
			if r[2] > BUDGET_MS: over += 1
			if r[2] > HITCH_MS: hitch += 1
		# ⛔ A FRAME THAT DREW NOTHING IS NOT A FAST FRAME. With the window minimised,
		# on another workspace or covered, Godot skips rendering: measured GPU 0.00 ms and
		# ~1 ms frames (2026-09-29 A/B, discarded). Flag the phase instead of reporting it.
		var no_gpu := 0
		for i in gpu.size():
			if ph == "load" and i < RENDER_WARMUP_FRAMES:
				continue
			if float(gpu[i]) <= 0.0:
				no_gpu += 1
		if no_gpu > rows.size() * 0.05:
			result["render_skipped"] = true
			print("[BENCH] ⛔ %s: %d/%d frames rendered NOTHING (window hidden?) -- this run is NOT valid"
					% [ph, no_gpu, rows.size()])
		var sft := ft.duplicate(); sft.sort()
		var sg := gpu.duplicate(); sg.sort()
		var sc := cpu.duplicate(); sc.sort()
		var sp := proc.duplicate(); sp.sort()
		var worst := rows.duplicate()
		worst.sort_custom(func(x, y): return x[2] > y[2])
		var w5 := []
		for r: Array in worst.slice(0, 5):
			w5.append({"t": snappedf(r[1], 0.01), "ms": snappedf(r[2], 0.01), "proc": snappedf(r[5], 0.01),
					"pending": r[10]})
		var wq_max := 0
		for r: Array in rows:
			wq_max = maxi(wq_max, int(r[12]))
		var ps := {"frames": rows.size(), "worker_queue_max": wq_max, "p50": _pct(sft, 0.5), "p95": _pct(sft, 0.95), "p99": _pct(sft, 0.99),
				"max": sft[-1], "pct_over_16_7": 100.0 * over / rows.size(), "hitches_over_33": hitch,
				"gpu_p50": _pct(sg, 0.5), "gpu_p95": _pct(sg, 0.95), "cpu_p95": _pct(sc, 0.95),
				"process_p95": _pct(sp, 0.95), "process_max": sp[-1], "worst": w5}
		# What the GPU was asked to draw: rows [6] draw calls, [7] primitives. Recorded
		# every frame all along; reported so "more geometry after carving" is measured.
		var sdc := []; var spr := []
		for r: Array in rows:
			sdc.append(float(r[6])); spr.append(float(r[7]))
		sdc.sort(); spr.sort()
		ps["draw_calls_p50"] = _pct(sdc, 0.5)
		ps["primitives_p50"] = _pct(spr, 0.5)
		result.phases[ph] = ps
		print("[BENCH] %-8s %6d %7.2f %7.2f %7.2f %8.2f %5.1f%% %7d %7.2f %7.2f %7.2f" % [ph, rows.size(),
				ps.p50, ps.p95, ps.p99, ps.max, ps.pct_over_16_7, hitch, ps.gpu_p95, ps.cpu_p95, ps.process_p95])
		print("[BENCH]          gpu p50 %.2f   draw calls p50 %d   primitives p50 %d" % [ps.gpu_p50,
				int(ps.draw_calls_p50), int(ps.primitives_p50)])
		print("[BENCH]          worst: %s   worker queue max %d" % [str(w5), wq_max])
	if _inject_ms > 0:
		var idle_worst: Array = result.phases.get("idle", {}).get("worst", [])
		var hit := 0
		for w: Dictionary in idle_worst.slice(0, 3):
			for mark in [5, 6, 7]:
				if absf(float(w.t) - mark) < 0.1:
					hit += 1
		result["ruler_selftest"] = {"inject_ms": _inject_ms, "found_of_3": hit}
		print("[BENCH] RULER SELF-TEST: %d/3 injected stalls are the idle phase's 3 worst frames -> %s"
				% [hit, "VALID" if hit == 3 else "RULER BROKEN"])
	if _out != "":
		var f := FileAccess.open(_out, FileAccess.WRITE)
		f.store_string(JSON.stringify(result, "  "))
		f.close()
		print("[BENCH] wrote %s" % _out)
	get_tree().quit(0)