OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / pt_bench.gd

817 lines · gd ref:
extends Node
## Deterministic path-traced frame-time benchmark for the DC-terrain streaming scene.
##
## Inert unless `--pt-bench` appears in the user args (everything after a bare `--`),
## so the scene keeps working as a manual flycam when the flag is absent.
##
## When enabled it takes the camera off the flycam, parks it at the start of a fixed
## path until BOTH the terrain streamer and the frame time have gone quiet, then flies
## the path on a wall-clock parameterisation and reports p50/p95/p99 frame times.
##
## Usage — drive it through the runner, which also validates the binary:
##   bash scripts/pt-bench.sh --label=baseline
## or by hand against the godot-rtx build (RT work does not go through the shipped
## launcher — see the RTX exception in the canonical-launcher rule):
##   <godot-rtx> --path . res://scenes/test/dc_terrain_streaming_pt_noplayer.tscn \
##     -- --pt-bench --label=baseline --spp=1 --bounces=1 --dlss=off
##
## Why the settle gate exists: hit-group shaders compile asynchronously (nvidia-rtx-godot
## 3c50f6017b) and terrain chunks stream in on worker threads, so the first seconds
## measure the build-up, not the steady state. If the gate times out the run still
## reports, stamped UNSETTLED — a check that cannot express its failure is not a check.
##
## Flags (all optional):
##   --label=NAME       tag for the output filenames and the report header
##   --spp=N            path-tracing samples per pixel (default: whatever the scene sets)
##   --bounces=N        path-tracing max bounces (default: whatever the scene sets)
##   --resolution=WxH   resize the window before measuring (the WM may refuse; the
##                      report always prints the render target it actually got)
##   --vis=N            path-tracing debug vis mode (default 0 = full PT)
##   --dlss=MODE        off | sr_perf | sr_quality | rr_perf | rr_quality | rr_dlaa
##   --raster           measure the rasterizer instead (path tracing off)
##   --unshaded         raster: draw unshaded albedo (Viewport.DEBUG_DRAW_UNSHADED).
##                      The raster counterpart of PT's --vis=8, so a raster and a
##                      path-traced shot of the same viewpoint can be compared for
##                      texture LOD without lighting or Monte Carlo grain in the way.
##   --lod-heatmap      dc_level only: render the selected texture LOD as an emitted
##                      colour ramp instead of the texture. Ground truth is the same
##                      capture under --raster, whose derivatives are real.
##   --static           hold the camera at waypoint 0 instead of flying the path
##   --freeze-lod       freeze terrain LOD after settling (isolates render from streaming)
##   --measure=SEC      measurement window, default 30
##   --warmup-max=SEC   settle-gate timeout, default 60
##   --shots=N          screenshots spread along the path, default 3 (0 disables)
##   --radius=N         orbit radius, default 140
##   --height=N         orbit height, default 70 (raised further if inside the terrain)
##   --pitch=DEG        fixed look-down angle instead of aiming at the ground
##   --out=DIR          report directory, default res://benchmarks/pt

# --- path ------------------------------------------------------------------
# A closed orbit around the origin: fixed, closed (so start and end framing match),
# and it moves far enough in world space to force octree re-centres, which a
# stationary camera never exercises.
const ORBIT_SEGMENTS := 4          # quadrants, reported separately
const CLEARANCE := 12.0            # keep the camera this far outside the SDF surface
const CLEARANCE_STEPS := 8
const LOOK_AHEAD := 90.0           # aim this far along the direction of travel
const SURFACE_MARCH_STEP := 6.0    # SDF march resolution when locating the ground
const SURFACE_MARCH_MAX := 80      # steps before giving up on finding ground

# --- settle gate -----------------------------------------------------------
const MIN_WARMUP := 2.0            # never trust a settle before this
const QUIET_FRAMES := 30           # consecutive frames both conditions must hold
const MIN_ACTIVE_CHUNKS := 10      # terrain has actually produced geometry
const STABILITY_WINDOW := 30       # frames per half of the median-vs-median test
const STABILITY_TOLERANCE := 0.05  # 5% median drift between halves
const SPIKE_FACTOR := 3.0          # no frame in the recent half above 3x its median

const RTX_INIT_MAX_FRAMES := 300   # give RTXTest this long to resolve the Environment

# A window the WM never gave real geometry to (an i3 tab that is not the visible one, a
# minimised window) leaves Godot rendering into a clamped 2x2 target. The run still
# produces frame times — ~7ms of nothing — so this is a hard abort, not a warning.
const MIN_RENDER_PX := 256

# A tiling WM trims a border off a requested window size (1920x1080 -> 1914x1053, 97% of
# the pixels). That is not a refused resize; 1274x693 instead of 1920x1080 is.
const RESOLUTION_TOLERANCE := 0.10

const DLSS_MODES := {
	"off": 0, "sr_perf": 1, "sr_quality": 2,
	"rr_perf": 3, "rr_quality": 4, "rr_dlaa": 5,
}

enum Phase { SETTLE, MEASURE, DONE }

@export var terrain_path: NodePath = ^"../DCTerrain"
@export var level_path: NodePath = ^"../MapDCLevel/DCLevel"
@export var camera_path: NodePath = ^"../Camera3D"
@export var rtx_path: NodePath = ^"../RTXTest"
@export var stats_label_path: NodePath = ^"../CanvasLayer/StatsLabel"

var enabled: bool = false
var phase: int = Phase.SETTLE

# config (from user args)
var cfg_label: String = ""
var cfg_spp: int = -1              # -1 = whatever the scene authored
var cfg_bounces: int = -1
var authored_spp: int = 1
var authored_bounces: int = 1
var cfg_resolution: Vector2i = Vector2i.ZERO
var cfg_vis: int = 0
var cfg_dlss: String = "off"
var cfg_raster: bool = false
var cfg_unshaded: bool = false
var cfg_lod_heatmap: bool = false
var cfg_lod_mode: int = 0
var cfg_no_pbr: bool = false
var cfg_static: bool = false
var cfg_freeze_lod: bool = false
var cfg_measure: float = 30.0
var cfg_warmup_max: float = 60.0
var cfg_shots: int = 3
var cfg_out: String = "res://benchmarks/pt"
var cfg_radius: float = 140.0
var cfg_height: float = 70.0
var cfg_pitch: float = NAN         # explicit look-down degrees; NaN = aim at the ground

var terrain: Node3D = null
var level: Node3D = null          # DCLevel (Quake brush map), when there is no DCTerrain
var level_tris: int = -1
var level_stable_frames: int = 0
var cam: Camera3D = null
var rtx: Node = null
var stats_label: Label = null

# settle state
var settle_elapsed: float = 0.0
var quiet_run: int = 0
var settled: bool = false
var settle_seconds: float = 0.0
var recent_ms: Array[float] = []

# measure state
var measure_elapsed: float = 0.0
var frame_ms: Array[float] = []
var seg_ms: Array = []             # Array[Array[float]] — one bucket per orbit segment
var pending_samples: Array[int] = []
var active_min: int = 0x7FFFFFFF
var active_max: int = 0
var lifted_frames: int = 0
var min_clearance: float = INF
var lifted_this_frame: bool = false
var prim_samples: Array[int] = []
var obj_samples: Array[int] = []
var shots_taken: int = 0
var shot_paths: Array[String] = []
var skip_frames: int = 0
var run_basename: String = ""
var skip_first: bool = true

var _last_usec: int = 0


func _ready() -> void:
	var args := OS.get_cmdline_user_args()
	enabled = "--pt-bench" in args
	if not enabled:
		set_process(false)
		return

	_parse_args(args)

	terrain = get_node_or_null(terrain_path) as Node3D
	cam = get_node_or_null(camera_path) as Camera3D
	rtx = get_node_or_null(rtx_path)
	stats_label = get_node_or_null(stats_label_path) as Label
	level = get_node_or_null(level_path) as Node3D
	# A scene that builds its camera in code cannot be reached by a fixed NodePath:
	# Camera3D.new() + add_child() without force_readable_name auto-names the node
	# "@Camera3D@23", so map_dc_terrain.gd:128-133's fly camera has no stable path.
	# Fall back to whichever camera is CURRENT, which is the one whose view is being
	# measured — the property that actually matters. Driving a non-current camera is
	# the silent-wrong-number failure this whole harness exists to prevent.
	if cam == null:
		cam = get_viewport().get_camera_3d()
		if cam != null:
			print("[PT-BENCH] no camera at %s; bound the current camera instead: %s" % [
				camera_path, cam.get_path()])
	if cam == null:
		push_error("[PT-BENCH] no Camera3D at %s and no current camera — aborting" % camera_path)
		get_tree().quit(1)
		return
	# Bound a camera by path that is NOT the one rendering: every frame time would be
	# real, and every one of them would describe a view nobody sees.
	if not cam.current:
		push_error("[PT-BENCH] camera %s is not current — it is not the camera being" % cam.get_path())
		push_error("[PT-BENCH] rendered, so these frame times would describe another view. Aborting.")
		get_tree().quit(1)
		return
	if terrain == null and level == null:
		push_warning("[PT-BENCH] neither DCTerrain (%s) nor DCLevel (%s) found; the settle gate falls back to frame-time stability alone" % [terrain_path, level_path])

	# Read the PT config the scene actually authors, BEFORE RTXTest._init_rt_params()
	# overwrites it with 1spp/1bounce two frames from now. Benchmarking the engine's
	# cheapest settings while the scene specifies something heavier is how you end up
	# comparing against a number nobody plays at.
	var authored := _find_environment()
	if authored != null:
		authored_spp = int(authored.get("pathtracing_samples_per_pixel"))
		authored_bounces = int(authored.get("pathtracing_max_bounces"))

	if cfg_resolution != Vector2i.ZERO:
		DisplayServer.window_set_size(cfg_resolution)

	# Take the camera off the flycam: it reads live input and would make the path
	# non-deterministic. It also captures the mouse in _ready; give that back.
	cam.set_process(false)
	cam.set_process_unhandled_input(false)
	Input.mouse_mode = Input.MOUSE_MODE_VISIBLE

	if cfg_unshaded:
		get_viewport().debug_draw = Viewport.DEBUG_DRAW_UNSHADED

	# Uncapped, unthrottled — a vsync'd frame time measures the monitor, not the renderer.
	DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)
	Engine.max_fps = 0

	for i in ORBIT_SEGMENTS:
		seg_ms.append([] as Array[float])

	run_basename = "pt-bench_%s%s" % [
		Time.get_datetime_string_from_system().replace(":", "-"),
		("_" + cfg_label) if cfg_label != "" else ""]

	_place_camera(0.0)

	# RTXTest resolves the WorldEnvironment over the first frames and stamps its own
	# defaults on it; apply ours after, or they get overwritten. Hold off _process
	# until then so the settle window never sees frames from the wrong config.
	set_process(false)
	await _await_rtx_ready()
	_apply_config()
	set_process(true)

	print("[PT-BENCH] %s" % _config_line())
	print("[PT-BENCH] settling (max %.0fs): waiting for terrain quiescence + stable frame time" % cfg_warmup_max)
	_last_usec = Time.get_ticks_usec()


func _parse_args(args: PackedStringArray) -> void:
	for a in args:
		if a == "--raster": cfg_raster = true
		elif a == "--unshaded": cfg_unshaded = true
		elif a == "--lod-heatmap": cfg_lod_heatmap = true
		elif a.begins_with("--lod-mode="):
			cfg_lod_mode = int(a.substr(11))
			cfg_lod_heatmap = true
		elif a == "--no-pbr": cfg_no_pbr = true
		elif a == "--static": cfg_static = true
		elif a == "--freeze-lod": cfg_freeze_lod = true
		elif a.begins_with("--label="): cfg_label = a.substr(8)
		elif a.begins_with("--spp="): cfg_spp = int(a.substr(6))
		elif a.begins_with("--bounces="): cfg_bounces = int(a.substr(10))
		elif a.begins_with("--vis="): cfg_vis = int(a.substr(6))
		elif a.begins_with("--dlss="): cfg_dlss = a.substr(7).to_lower()
		elif a.begins_with("--measure="): cfg_measure = float(a.substr(10))
		elif a.begins_with("--warmup-max="): cfg_warmup_max = float(a.substr(13))
		elif a.begins_with("--shots="): cfg_shots = int(a.substr(8))
		elif a.begins_with("--radius="): cfg_radius = float(a.substr(9))
		elif a.begins_with("--height="): cfg_height = float(a.substr(9))
		elif a.begins_with("--pitch="): cfg_pitch = float(a.substr(8))
		elif a.begins_with("--resolution="):
			var wh := a.substr(13).split("x")
			if wh.size() == 2:
				cfg_resolution = Vector2i(int(wh[0]), int(wh[1]))
			else:
				push_error("[PT-BENCH] --resolution wants WxH, e.g. 1920x1080")
		elif a.begins_with("--out="): cfg_out = a.substr(6)
	if not DLSS_MODES.has(cfg_dlss):
		push_error("[PT-BENCH] unknown --dlss=%s, falling back to off" % cfg_dlss)
		cfg_dlss = "off"


func _await_rtx_ready() -> void:
	if rtx == null:
		return
	for _i in RTX_INIT_MAX_FRAMES:
		if rtx.get("env") != null:
			return
		await get_tree().process_frame
	push_warning("[PT-BENCH] RTXTest never resolved a WorldEnvironment — PT config may be the scene default, not the requested one")


func _apply_config() -> void:
	if rtx != null and rtx.get("env") != null:
		# Reuse RTXTest's setters: they handle the legacy raytracing_* API too.
		rtx.call("_set_rt_enabled", not cfg_raster)
		if not cfg_raster:
			rtx.call("_set_spp", _eff_spp())
			rtx.call("_set_bounces", _eff_bounces())
			rtx.call("_set_debug_mode", cfg_vis)
		rtx.call("_set_dlss_mode", DLSS_MODES[cfg_dlss])
		return
	# Fallback: no RTXTest in the scene, drive the Environment directly.
	var env := _find_environment()
	if env == null:
		push_error("[PT-BENCH] no WorldEnvironment — cannot configure path tracing")
		return
	env.set("pathtracing_enabled", not cfg_raster)
	if not cfg_raster:
		env.set("pathtracing_samples_per_pixel", _eff_spp())
		env.set("pathtracing_max_bounces", _eff_bounces())
		env.set("pathtracing_debug_mode", cfg_vis)


func _eff_spp() -> int:
	return cfg_spp if cfg_spp > 0 else authored_spp


func _eff_bounces() -> int:
	return cfg_bounces if cfg_bounces > 0 else authored_bounces


## Flip dc_level.gdshader's debug_lod uniform. The level's material is built in
## GDScript by map_dc_level.gd and applied as material_override on its LevelMesh, so
## it has to be reached through the node rather than a preloaded resource.
func _enable_lod_heatmap() -> void:
	var found := 0
	for n in get_tree().get_root().find_children("*", "MeshInstance3D", true, false):
		var mi := n as MeshInstance3D
		var mat := mi.material_override as ShaderMaterial
		if mat != null and mat.shader != null:
			mat.set_shader_parameter("debug_lod", true)
			mat.set_shader_parameter("debug_lod_mode", cfg_lod_mode)
			found += 1
	if found == 0:
		push_error("[PT-BENCH] --lod-heatmap found no ShaderMaterial material_override; nothing to instrument")
		get_tree().quit(1)
	else:
		print("[PT-BENCH] LOD heatmap enabled on %d material(s), mode %d" % [found, cfg_lod_mode])


## Force use_pbr off, so a capture with PBR can be differenced against one without it.
## Without this the only evidence that the normal/gloss/glow atlases DO anything is that
## the render "looks three-dimensional" — which the diffuse texture alone can also
## produce. Same material-poke route as _enable_lod_heatmap: the level's material is
## built in GDScript and applied as material_override, so it is reached through the node.
func _disable_pbr() -> void:
	var found := 0
	for n in get_tree().get_root().find_children("*", "MeshInstance3D", true, false):
		var mi := n as MeshInstance3D
		var mat := mi.material_override as ShaderMaterial
		if mat != null and mat.shader != null:
			mat.set_shader_parameter("use_pbr", false)
			found += 1
	print("[PT-BENCH] PBR DISABLED on %d material(s) — A/B reference capture" % found)


func _find_environment() -> Environment:
	for n in get_tree().get_root().find_children("*", "WorldEnvironment", true, false):
		var we := n as WorldEnvironment
		if we != null and we.environment != null:
			return we.environment
	return null


func _process(_delta: float) -> void:
	if not enabled or phase == Phase.DONE:
		return

	var now := Time.get_ticks_usec()
	var ms := float(now - _last_usec) / 1000.0
	_last_usec = now

	match phase:
		Phase.SETTLE: _process_settle(ms)
		Phase.MEASURE: _process_measure(ms)


# --- settle ----------------------------------------------------------------

func _process_settle(ms: float) -> void:
	settle_elapsed += ms / 1000.0
	if settle_elapsed >= MIN_WARMUP and not _check_render_size():
		return

	recent_ms.append(ms)
	if recent_ms.size() > STABILITY_WINDOW * 2:
		recent_ms.remove_at(0)

	var terrain_quiet := _terrain_quiet()
	var time_quiet := _frametime_quiet()
	if terrain_quiet and time_quiet and settle_elapsed >= MIN_WARMUP:
		quiet_run += 1
	else:
		quiet_run = 0

	_set_status("SETTLE %5.1fs  terrain:%s  frametime:%s  quiet %d/%d" % [
		settle_elapsed, "ok" if terrain_quiet else "..",
		"ok" if time_quiet else "..", quiet_run, QUIET_FRAMES])

	if quiet_run >= QUIET_FRAMES:
		settled = true
	elif settle_elapsed >= cfg_warmup_max:
		settled = false
		push_warning("[PT-BENCH] settle gate TIMED OUT after %.1fs (terrain_quiet=%s frametime_quiet=%s) — results are UNSETTLED" % [
			settle_elapsed, terrain_quiet, time_quiet])
	else:
		return

	settle_seconds = settle_elapsed
	# Only now is the level guaranteed to exist: map_dc_level.gd imports the .MAP via
	# call_deferred, so at _ready() time there is no LevelMesh and no material_override
	# to instrument yet.
	if cfg_lod_heatmap:
		_enable_lod_heatmap()
	if cfg_no_pbr:
		_disable_pbr()
	# Re-assert the requested size: the WM (or pt-bench.sh's own i3 nudge) may have
	# resized the window out from under the startup request.
	if cfg_resolution != Vector2i.ZERO and DisplayServer.window_get_size() != cfg_resolution:
		DisplayServer.window_set_size(cfg_resolution)
	print("[PT-BENCH] settled=%s after %.1fs (chunks active=%d pending=%d collision_q=%d) — measuring %.0fs" % [
		settled, settle_seconds, _terrain_int("get_active_chunk_count"),
		_terrain_int("get_pending_count"), _terrain_int("get_collision_queue_size"), cfg_measure])
	if cfg_freeze_lod and terrain.has_method("set_lod_frozen"):
		terrain.set_lod_frozen(true)
		print("[PT-BENCH] terrain LOD frozen — measuring render cost without streaming churn")
	phase = Phase.MEASURE
	skip_first = true


## Returns false (and kills the run) if the window never got real geometry. Measuring a
## 2x2 render target is the same class of mistake as an A/B where neither run loaded a
## map: it produces numbers that mean nothing (hyper-games #11 §4.4).
func _check_render_size() -> bool:
	var size := get_viewport().get_texture().get_size()
	if size.x >= MIN_RENDER_PX and size.y >= MIN_RENDER_PX:
		return true
	push_error("[PT-BENCH] ABORT — render target is %s (window %s), below the %d px floor." % [
		size, DisplayServer.window_get_size(), MIN_RENDER_PX])
	push_error("[PT-BENCH] The window has no usable geometry, so this run would measure an")
	push_error("[PT-BENCH] empty framebuffer. Under a tiling WM the window is probably a")
	push_error("[PT-BENCH] background tab: make it visible (i3: float it and give it a size)")
	push_error("[PT-BENCH] and re-run. scripts/pt-bench.sh does this automatically.")
	phase = Phase.DONE
	get_tree().quit(3)
	return false


## Has the scene finished building itself? Streaming terrain and a Quake brush map
## become ready in completely different ways, and a scene with neither (a plain mesh
## level) is ready as soon as it is loaded -- so the frame-time stability half of the
## gate carries it.
func _terrain_quiet() -> bool:
	if terrain != null:
		return _terrain_int("get_pending_count") == 0 \
			and _terrain_int("get_collision_queue_size") == 0 \
			and _terrain_int("get_active_chunk_count") >= MIN_ACTIVE_CHUNKS
	if level != null:
		# The .MAP is imported deferred and asynchronously, so wait for the triangle
		# count to become non-zero AND stop changing.
		var tris := 0
		if level.has_method("get_triangle_count"):
			tris = int(level.call("get_triangle_count"))
		if tris <= 0:
			level_stable_frames = 0
			level_tris = tris
			return false
		if tris == level_tris:
			level_stable_frames += 1
		else:
			level_stable_frames = 0
			level_tris = tris
		return level_stable_frames >= QUIET_FRAMES
	return true


## Median of the newest half vs the older half: a frame time still trending (shaders
## compiling, chunks uploading) moves the median; a settled one does not. The spike
## test catches a window that is stable on average but still hitching.
func _frametime_quiet() -> bool:
	if recent_ms.size() < STABILITY_WINDOW * 2:
		return false
	var older := recent_ms.slice(0, STABILITY_WINDOW)
	var newer := recent_ms.slice(STABILITY_WINDOW)
	var m_old := _median(older)
	var m_new := _median(newer)
	if m_old <= 0.0 or m_new <= 0.0:
		return false
	if absf(m_new - m_old) / m_old > STABILITY_TOLERANCE:
		return false
	for v in newer:
		if v > m_new * SPIKE_FACTOR:
			return false
	return true


# --- measure ---------------------------------------------------------------

func _process_measure(ms: float) -> void:
	# The transition frame carries the cost of the phase switch (and the LOD freeze).
	if skip_first:
		skip_first = false
		return

	measure_elapsed += ms / 1000.0
	var t := clampf(measure_elapsed / cfg_measure, 0.0, 0.999999)

	# A screenshot forces a GPU->CPU readback; the stall lands on the frames after it.
	if skip_frames > 0:
		skip_frames -= 1
		_place_camera(t)
		return
	if cfg_shots > 0 and shots_taken < cfg_shots and t >= (float(shots_taken) + 0.5) / cfg_shots:
		_take_shot(t)
		skip_frames = 2
		_place_camera(t)
		return

	frame_ms.append(ms)
	prim_samples.append(int(RenderingServer.get_rendering_info(
		RenderingServer.RENDERING_INFO_TOTAL_PRIMITIVES_IN_FRAME)))
	obj_samples.append(int(RenderingServer.get_rendering_info(
		RenderingServer.RENDERING_INFO_TOTAL_OBJECTS_IN_FRAME)))
	var seg := clampi(int(t * ORBIT_SEGMENTS), 0, ORBIT_SEGMENTS - 1)
	seg_ms[seg].append(ms)

	var pending := _terrain_int("get_pending_count")
	var active := _terrain_int("get_active_chunk_count")
	pending_samples.append(pending)
	active_min = mini(active_min, active)
	active_max = maxi(active_max, active)

	_place_camera(t)
	if lifted_this_frame:
		lifted_frames += 1
	_set_status("MEASURE %5.1f/%.0fs  %d frames  pending %d" % [
		measure_elapsed, cfg_measure, frame_ms.size(), pending])

	if measure_elapsed >= cfg_measure:
		phase = Phase.DONE
		_report()


## Visual proof of what was measured. A frame-time number with no picture behind it is
## how you end up reporting 700fps for a camera buried in a hillside.
func _take_shot(t: float) -> void:
	var dir := _out_dir()
	DirAccess.make_dir_recursive_absolute(dir)
	var path := "%s/%s_shot%d.png" % [dir, run_basename, shots_taken]
	var img := get_viewport().get_texture().get_image()
	if img != null and img.save_png(path) == OK:
		shot_paths.append(path)
		print("[PT-BENCH] shot %d at t=%.2f (cam %s): %s" % [shots_taken, t, cam.global_position, path])
	else:
		push_warning("[PT-BENCH] could not save screenshot to %s" % path)
	shots_taken += 1


func _out_dir() -> String:
	return ProjectSettings.globalize_path(cfg_out) if cfg_out.begins_with("res://") else cfg_out


# --- camera path -----------------------------------------------------------

## Position at path parameter t in [0,1). Wall-clock parameterised, so the camera is
## in the same place at the same second of the run no matter what the frame rate is —
## two runs are comparable even when one of them is half the speed.
func _place_camera(t: float) -> void:
	var angle := 0.0 if cfg_static else t * TAU
	var pos := Vector3(cos(angle) * cfg_radius, cfg_height, sin(angle) * cfg_radius)
	pos = _lift_above_surface(pos)
	var tangent := Vector3(-sin(angle), 0.0, cos(angle))
	if cfg_static:
		tangent = Vector3(-1.0, 0.0, 0.0)

	# Aim at the ground ahead rather than at a fixed angle. A fixed pitch over floating
	# islands spends most of the frame on sky and void, which path-traces for almost
	# nothing — the first run read 712fps on a screen that was two-thirds empty.
	var ahead := pos + tangent * LOOK_AHEAD
	var target := ahead
	if is_nan(cfg_pitch):
		var ground_y := _surface_y(ahead.x, ahead.z, pos.y)
		target = Vector3(ahead.x, ground_y, ahead.z) if is_finite(ground_y) \
			else ahead + Vector3.DOWN * (LOOK_AHEAD * 0.4)
	else:
		target = ahead + Vector3.DOWN * (LOOK_AHEAD * tan(deg_to_rad(cfg_pitch)))

	cam.global_position = pos
	cam.look_at(target, Vector3.UP)


## March down the SDF to find the ground under (x, z). Returns INF when there is nothing
## below — over a floating island's edge that is the honest answer, and the caller falls
## back to a fixed downward angle.
func _surface_y(x: float, z: float, from_y: float) -> float:
	if terrain == null or not terrain.has_method("get_distance"):
		return INF
	var y := from_y
	for _i in SURFACE_MARCH_MAX:
		y -= SURFACE_MARCH_STEP
		if terrain.get_distance(Vector3(x, y, z)) < 0.0:
			return y + SURFACE_MARCH_STEP * 0.5
	return INF


## The orbit is a fixed geometric path but the terrain is procedural, so a waypoint can
## land inside rock — which would measure the inside of a chunk, not the scene. Push up
## along Y until the SDF says we are clear. Deterministic (same terrain, same path) and
## counted, so the report says when it happened.
func _lift_above_surface(pos: Vector3) -> Vector3:
	lifted_this_frame = false
	if terrain == null or not terrain.has_method("get_distance"):
		return pos
	for _i in CLEARANCE_STEPS:
		var d: float = terrain.get_distance(pos)
		if d >= CLEARANCE:
			min_clearance = minf(min_clearance, d)
			return pos
		pos.y += maxf(CLEARANCE - d, 1.0)
		lifted_this_frame = true
	# Ran out of steps: the camera may still be inside the surface. Record the truth.
	min_clearance = minf(min_clearance, terrain.get_distance(pos))
	return pos


# --- reporting -------------------------------------------------------------

func _report() -> void:
	if frame_ms.is_empty():
		push_error("[PT-BENCH] measured zero frames — nothing to report")
		get_tree().quit(1)
		return

	var lines := _build_report()
	for l in lines:
		print(l)

	var base := run_basename
	var dir := _out_dir()
	DirAccess.make_dir_recursive_absolute(dir)

	var txt := FileAccess.open("%s/%s.txt" % [dir, base], FileAccess.WRITE)
	if txt != null:
		for l in lines:
			txt.store_line(l)
		txt.close()
		print("[PT-BENCH] report: %s/%s.txt" % [dir, base])
	else:
		push_error("[PT-BENCH] could not write report to %s" % dir)

	var csv := FileAccess.open("%s/%s.csv" % [dir, base], FileAccess.WRITE)
	if csv != null:
		csv.store_line("scope,frames,avg_ms,p50_ms,p95_ms,p99_ms,min_ms,max_ms,fps_at_p50")
		csv.store_line(_csv_row("overall", frame_ms))
		for i in ORBIT_SEGMENTS:
			csv.store_line(_csv_row("segment%d" % i, seg_ms[i]))
		csv.close()
		print("[PT-BENCH] csv:    %s/%s.csv" % [dir, base])

	get_tree().quit(0 if settled else 2)


func _build_report() -> Array[String]:
	var sorted := frame_ms.duplicate()
	sorted.sort()
	var p50 := _pct(sorted, 0.50)
	var p95 := _pct(sorted, 0.95)
	var p99 := _pct(sorted, 0.99)
	var avg := _mean(frame_ms)

	var out: Array[String] = []
	out.append("=".repeat(78))
	out.append("[PT-BENCH] %s%s" % [
		Time.get_datetime_string_from_system(),
		("  label=" + cfg_label) if cfg_label != "" else ""])
	out.append("=".repeat(78))
	if not settled:
		out.append("!! UNSETTLED — the settle gate timed out after %.1fs. Frame times below" % settle_seconds)
		out.append("!! include streaming and/or async shader compilation. DO NOT COMPARE THEM.")
	out.append("")
	out.append("-- run ----------------------------------------------------------------")
	out.append("  scene          : %s" % get_tree().current_scene.scene_file_path)
	out.append("  config         : %s" % _config_line())
	var aim := "aimed at the ground ahead" if is_nan(cfg_pitch) else "pitch %.0f deg" % cfg_pitch
	var path_desc := "STATIC at waypoint 0, %s" % aim if cfg_static else \
		"orbit r=%.0f h=%.0f, one revolution over %.0fs, %s" % [
			cfg_radius, cfg_height, cfg_measure, aim]
	out.append("  camera path    : %s" % path_desc)
	out.append("  settled        : %s after %.1fs" % [settled, settle_seconds])
	out.append("  camera lifted  : %d/%d frames pushed up out of the SDF surface" % [
		lifted_frames, frame_ms.size()])
	out.append("  min clearance  : %.1f  %s" % [min_clearance,
		"(negative = the camera was INSIDE terrain, numbers are not scene-representative)"
		if min_clearance < 0.0 else ""])
	out.append("")
	out.append("-- build (record this: a config difference reads as a perf difference) --")
	out.append("  executable     : %s" % OS.get_executable_path())
	out.append("  engine         : %s" % Engine.get_version_info().get("string", "?"))
	out.append("  gpu            : %s" % RenderingServer.get_video_adapter_name())
	out.append("  driver         : %s" % RenderingServer.get_video_adapter_api_version())
	out.append("  rendering      : %s" % ProjectSettings.get_setting("rendering/renderer/rendering_method", "forward_plus"))
	var rd := RenderingServer.get_rendering_device()
	if rd != null:
		out.append("  vk rt pipeline : %s" % rd.has_feature(RenderingDevice.SUPPORTS_RAYTRACING_PIPELINE))
		out.append("  vk ray query   : %s" % rd.has_feature(RenderingDevice.SUPPORTS_RAY_QUERY))
	var vp := get_viewport()
	# Compare pixel COUNT with a tolerance, not exact dimensions: a tiling WM shaves a
	# few pixels of border off the request, which is not a refused resize. Only a real
	# gap (the 1274x693-instead-of-1920x1080 case) is worth invalidating a run over.
	if cfg_resolution != Vector2i.ZERO:
		var got := get_viewport().get_texture().get_size()
		var want_px := float(cfg_resolution.x) * float(cfg_resolution.y)
		var got_px := got.x * got.y
		if want_px > 0.0 and absf(got_px - want_px) / want_px > RESOLUTION_TOLERANCE:
			out.append("  !! RESOLUTION MISMATCH — asked for %s, rendered at %s (%.0f%% of the" % [
				cfg_resolution, got, 100.0 * got_px / want_px])
			out.append("  !! requested pixels). The window manager refused the resize; do NOT")
			out.append("  !! compare this run against one at a different size — frame time")
			out.append("  !! scales with pixel count.")
	out.append("  window size    : %s%s" % [DisplayServer.window_get_size(),
		"  (requested %s)" % cfg_resolution if cfg_resolution != Vector2i.ZERO else ""])
	out.append("  viewport rect  : %s" % vp.get_visible_rect().size)
	out.append("  render target  : %s" % vp.get_texture().get_size())
	out.append("  scaling        : scaling_3d_mode=%d scale=%.2f" % [
		vp.scaling_3d_mode, vp.scaling_3d_scale])
	out.append("  NOTE: scaling_3d_mode reads back the DLSS mode even in a build compiled")
	out.append("        WITHOUT DLSS (renderer_viewport.cpp only ERR_PRINTs). The log, not")
	out.append("        this field, is the authority on whether DLSS actually ran.")
	out.append("")
	out.append("-- frame time ---------------------------------------------------------")
	out.append("  frames         : %d over %.1fs" % [frame_ms.size(), measure_elapsed])
	out.append("  p50            : %8.2f ms   (%.1f fps)" % [p50, 1000.0 / maxf(p50, 0.001)])
	out.append("  p95            : %8.2f ms   (%.1f fps)" % [p95, 1000.0 / maxf(p95, 0.001)])
	out.append("  p99            : %8.2f ms   (%.1f fps)" % [p99, 1000.0 / maxf(p99, 0.001)])
	out.append("  avg            : %8.2f ms   (%.1f fps)" % [avg, 1000.0 / maxf(avg, 0.001)])
	out.append("  min / max      : %8.2f / %.2f ms" % [sorted[0], sorted[sorted.size() - 1]])
	out.append("")
	out.append("  %-10s %8s %8s %8s %8s %8s" % ["segment", "frames", "p50 ms", "p95 ms", "p99 ms", "fps@p50"])
	for i in ORBIT_SEGMENTS:
		var s: Array = seg_ms[i]
		if s.is_empty():
			continue
		var ss := s.duplicate()
		ss.sort()
		var sp50 := _pct(ss, 0.50)
		out.append("  %-10s %8d %8.2f %8.2f %8.2f %8.1f" % [
			"seg%d" % i, s.size(), sp50, _pct(ss, 0.95), _pct(ss, 0.99), 1000.0 / maxf(sp50, 0.001)])
	out.append("")
	out.append("-- streaming during measurement ---------------------------------------")
	var ps := pending_samples.duplicate()
	ps.sort()
	out.append("  premesh pending: p50 %d, max %d  (non-zero = streaming was still working)" % [
		int(_pct(ps, 0.50)), ps[ps.size() - 1]])
	out.append("  active chunks  : %d..%d" % [active_min, active_max])
	out.append("  lod frozen     : %s" % cfg_freeze_lod)
	out.append("")
	out.append("-- what was actually on screen ----------------------------------------")
	var prims := prim_samples.duplicate()
	prims.sort()
	var objs := obj_samples.duplicate()
	objs.sort()
	out.append("  primitives     : p50 %d  (near zero = the camera was looking at nothing)" % int(_pct(prims, 0.50)))
	out.append("  objects        : p50 %d" % int(_pct(objs, 0.50)))
	if shot_paths.is_empty():
		out.append("  screenshots    : none (--shots=0) — nothing corroborates these numbers")
	else:
		for p in shot_paths:
			out.append("  screenshot     : %s" % p)
	out.append("=".repeat(78))
	return out


func _config_line() -> String:
	if cfg_raster:
		return "RASTER (path tracing off)%s%s, dlss=%s" % [", unshaded" if cfg_unshaded else "", ", lod-heatmap" if cfg_lod_heatmap else "", cfg_dlss]
	return "PT spp=%d%s bounces=%d%s vis=%d dlss=%s%s" % [
		_eff_spp(), "" if cfg_spp > 0 else " (scene)",
		_eff_bounces(), "" if cfg_bounces > 0 else " (scene)",
		cfg_vis, cfg_dlss, ", lod-frozen" if cfg_freeze_lod else ""]


func _csv_row(scope: String, samples: Array) -> String:
	if samples.is_empty():
		return "%s,0,0,0,0,0,0,0,0" % scope
	var s := samples.duplicate()
	s.sort()
	var p50 := _pct(s, 0.50)
	return "%s,%d,%.3f,%.3f,%.3f,%.3f,%.3f,%.3f,%.2f" % [
		scope, s.size(), _mean(samples), p50, _pct(s, 0.95), _pct(s, 0.99),
		s[0], s[s.size() - 1], 1000.0 / maxf(p50, 0.001)]


func _set_status(text: String) -> void:
	if stats_label != null:
		stats_label.text = "[PT-BENCH]\n%s" % text


static func _pct(sorted_samples: Array, q: float) -> float:
	if sorted_samples.is_empty():
		return 0.0
	var idx := int(ceil(q * sorted_samples.size())) - 1
	return float(sorted_samples[clampi(idx, 0, sorted_samples.size() - 1)])


static func _mean(samples: Array) -> float:
	if samples.is_empty():
		return 0.0
	var sum := 0.0
	for v in samples:
		sum += float(v)
	return sum / samples.size()


static func _median(samples: Array) -> float:
	var s := samples.duplicate()
	s.sort()
	return _pct(s, 0.50)


func _terrain_int(method: String) -> int:
	if terrain != null and terrain.has_method(method):
		return int(terrain.call(method))
	return 0