OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / bench_path.gd

493 lines · gd ref:
extends Node3D
## Frame-time benchmark along a FIXED camera path, on a guaranteed-visible window
## (hyper-games #108 perf). Same shell as _depth_shot: root named "Game", the shared
## Sky3DExtended borrowed from game.tscn, the map instanced under it.
##
## The camera IS the terrain's viewer: DCTerrain streams LOD and builds collision
## around viewport->get_camera_3d() (dc_terrain.cpp:655, :1379), so flying a camera
## exercises the same terrain work a player does. Player physics / IK / animation
## are NOT in this frame.
##
## vsync is OFF so a frame's time is its work, not the 60 Hz wait
## (Performance.TIME_PROCESS includes that wait).
##
##   BENCH_MAP=map_dc_erosion
##   BENCH_PATH='x,y,z;x,y,z;...'   waypoints, flown in order at BENCH_SPEED m/s
##   BENCH_SPEED=40  BENCH_SETTLE=25 (s held at the first waypoint before timing)
##   BENCH_PITCH=-10 (deg; the camera faces the horizontal direction of travel)
##   BENCH_OUT=/path/prefix  -> prefix.csv (one row per timed frame)
##   BENCH_SIZE=1920x1080  (window size measured at; native on tabertron)
##   BENCH_SHADER_PARAMS='{"density_normal_blend":0.0}'  (terrain material A/B)
##   BENCH_SUBVIEWPORT=full|area  (#124 B0) render the 3D through a SubViewport the way
##       Godot's editor does (SubViewportContainer, stretch, input off, the root's World3D
##       inherited): `full` = the whole window, `area` = Blender's centre area (75% x 94%).
##       The bench camera STAYS in the root (DCTerrain streams around the root's camera,
##       and 23 game lookups read it); a follower camera in the SubViewport copies it each
##       frame; the root renders no 3D (disable_3d). The root's scaling / AA settings are
##       copied over (MSAA / SSAA / TAA / debanding are root-only at startup).
##   BENCH_SHOT=/path.png  the rendered 3D image at timing start (clouds, DLSS: by eye)
##   "$GODOT" --path . --resolution 1920x1080 res://scenes/test/bench_path.tscn

const ProbeWindow := preload("res://scenes/test/probe_window.gd")

var _cam: Camera3D
var _terrain: Node = null
var _path: Array[Vector3] = []
var _speed := 40.0
var _settle := 25.0
var _pitch := -10.0
var _out := "/tmp/bench_path"
var _ws := ""
## Window size the frame is measured at. The target is native 1920x1080 (all three
## of tabertron's displays); per-pixel costs scale with it.
var _size := Vector2i(1920, 1080)
var _fullscreen := false
var _t := 0.0
var _seg := 0
var _seg_t := 0.0
var _yaw := 0.0
var _timing := false
var _last_us := 0
var _rows: Array[PackedFloat64Array] = []
var _hidden := 0


func _v3(s: String) -> Vector3:
	var p := s.split(",")
	return Vector3(float(p[0]), float(p[1]), float(p[2]))


func _env(k: String, d: String) -> String:
	var v := OS.get_environment(k)
	return v if v != "" else d


func _ready() -> void:
	# ';' or '|' between waypoints ('|' survives tools whose env syntax is "A=1; B=2;").
	for w in _env("BENCH_PATH", "0,450,10000;0,450,0").replace("|", ";").split(";"):
		_path.append(_v3(w))
	_speed = float(_env("BENCH_SPEED", "40"))
	_settle = float(_env("BENCH_SETTLE", "25"))
	_pitch = float(_env("BENCH_PITCH", "-10"))
	_out = _env("BENCH_OUT", _out)
	var sz := _env("BENCH_SIZE", "1920x1080").split("x")
	_size = Vector2i(int(sz[0]), int(sz[1]))
	DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)

	var game: Node = (load("res://scenes/game/game.tscn") as PackedScene).instantiate()
	var sky: Node = game.get_node("Sky3DExtended")
	game.remove_child(sky)
	game.free()
	add_child(sky)
	var tod: Node = sky.get_node_or_null("TimeOfDay")
	if tod != null:
		tod.current_time = 11.0
		if tod.has_method("_update_celestial_coords"):
			tod._update_celestial_coords()

	_cam = Camera3D.new()
	_cam.far = 60000.0
	_cam.near = 0.5
	add_child(_cam)
	_cam.current = true
	_yaw = _travel_yaw(0)
	_place(_path[0])

	var map: Node = (load("res://scenes/maps/%s.tscn" % _env("BENCH_MAP", "map_mountain")) as PackedScene).instantiate()
	add_child(map)
	var found := map.find_children("*", "DCTerrain", true, false)
	_terrain = found[0] if not found.is_empty() else null
	# BENCH_FIND_COAST=x,z: walk +x in 1 km steps (200 km max) with the map's own
	# _surface_y and print where the seabed first rises above WATER_Y -- a view with a
	# coastline in it, so a fetch A/B is not trivially "all open water".
	if _env("BENCH_FIND_COAST", "") != "" and map.has_method("_surface_y"):
		var s := _env("BENCH_FIND_COAST", "0,0").split(",")
		var x0 := float(s[0])
		var z0 := float(s[1])
		var wy: float = float(map.get("WATER_Y"))
		for dir: Vector2 in [Vector2(1, 0), Vector2(-1, 0), Vector2(0, 1), Vector2(0, -1)]:
			var hit := false
			for k in 200:
				var x := x0 + 1000.0 * k * dir.x
				var z := z0 + 1000.0 * k * dir.y
				var h: float = map.call("_surface_y", _terrain, x, z)
				if h > wy:
					print("[bench-path] coast: surface %.0f m > water %.0f at x=%.0f z=%.0f (%d km toward %s)" % [h, wy, x, z, k, dir])
					hit = true
					break
			if not hit:
				print("[bench-path] coast: none within 200 km toward %s" % dir)
	# A/B for the submerged far-plane clamp (map_dc_erosion._on_submerged_changed):
	# cut every listener so the baseline renders exactly as before it existed.
	if _env("BENCH_NO_SUBMERGED_CULL", "") == "1":
		# Not find_children(type): its type test is is_class(), blind to class_name.
		for o in map.find_children("*", "", true, false):
			if o is Ocean:
				for c in o.get_signal_connection_list(&"submerged_changed"):
					o.disconnect(&"submerged_changed", c["callable"])
				print("[bench-path] submerged cull DISCONNECTED (baseline)")
	RenderingServer.viewport_set_measure_render_time(get_viewport().get_viewport_rid(), true)
	if _env("BENCH_SUBVIEWPORT", "") != "":
		_sub_setup(_env("BENCH_SUBVIEWPORT", "full"))
	# 3D render scale (the game's render_scale row). A cost that does NOT fall with it
	# is geometry, not pixels.
	if _env("BENCH_SCALE", "") != "":
		get_viewport().scaling_3d_scale = float(_env("BENCH_SCALE", "1.0"))
	# LOD distance factor, set AFTER the terrain's _ready: DCTerrain::_ready overwrites
	# lod_factor with 2.0 (dc_terrain.cpp:346), so it is applied one frame later.
	if _env("BENCH_LOD_FACTOR", "") != "" and _terrain != null:
		_terrain.set_deferred("lod_factor", float(_env("BENCH_LOD_FACTOR", "2.0")))
	print("[bench-path] map %s, %d waypoints, %.0f m/s, settle %.0f s, terrain %s"
			% [_env("BENCH_MAP", "map_mountain"), _path.size(), _speed, _settle, _terrain != null])


var _sub: SubViewport = null
var _sub_cam: Camera3D = null


## #124 B0: the 3D view as Godot's editor builds it (node_3d_editor_viewport.cpp:6822-6844).
func _sub_setup(mode: String) -> void:
	var layer := CanvasLayer.new()
	add_child(layer)
	var c := SubViewportContainer.new()
	c.stretch = true
	layer.add_child(c)
	c.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	if mode == "area":
		c.anchor_right = 0.75
		c.anchor_bottom = 0.94
		c.offset_right = 0.0
		c.offset_bottom = 0.0
	_sub = SubViewport.new()
	_sub.gui_disable_input = true
	c.add_child(_sub)
	_sub_cam = Camera3D.new()
	_sub_cam.far = _cam.far
	_sub_cam.near = _cam.near
	_sub.add_child(_sub_cam)
	_sub_cam.current = true
	_sub_cam.global_transform = _cam.global_transform      # placed before the settle, as _cam is
	_sub_cam.fov = _cam.fov
	var root := get_viewport()
	for p: String in ["scaling_3d_mode", "scaling_3d_scale", "fsr_sharpness", "msaa_3d", "screen_space_aa",
			"use_taa", "use_debanding", "mesh_lod_threshold", "use_hdr_2d"]:
		_sub.set(p, root.get(p))
	root.disable_3d = true
	RenderingServer.viewport_set_measure_render_time(_sub.get_viewport_rid(), true)
	print("[bench-path] SUBVIEWPORT %s: scaling_3d_mode %d scale %.2f msaa %d taa %s; root disable_3d; camera %s (root) -> follower"
		% [mode, _sub.scaling_3d_mode, _sub.scaling_3d_scale, _sub.msaa_3d, _sub.use_taa, root.get_camera_3d()])


## BENCH_SUBVIEWPORT=toggle (#124 B0): the GPU here throttles thermally (87-88 C, clocks
## falling run to run), so separate runs cannot compare a ~10 ms difference. One run
## alternates root rendering and SubViewport rendering every TOGGLE_S; each window's
## neighbours share its temperature, clock and view. The first TOGGLE_SKIP_S after a
## switch is not counted (the frame in flight belongs to the other state).
const TOGGLE_S := 5.0
const TOGGLE_SKIP_S := 0.5
var _tog_sub := false
var _tog_t := 0.0
## Plain Arrays: a PackedFloat64Array is a VALUE type, so appending to one read out of a
## Dictionary appends to a copy (the first toggle run recorded nothing).
var _tog: Dictionary = {"root": [], "sub": []}


func _toggle_apply() -> void:
	get_viewport().disable_3d = _tog_sub
	_sub.render_target_update_mode = SubViewport.UPDATE_ALWAYS if _tog_sub else SubViewport.UPDATE_DISABLED
	(_sub.get_parent() as Control).visible = _tog_sub


func _toggle_step(gpu: float) -> void:
	if _tog_t == 0.0:
		_toggle_apply()
	_tog_t += get_process_delta_time()
	if _tog_t >= TOGGLE_SKIP_S:
		(_tog["sub" if _tog_sub else "root"] as Array).append(gpu)
	if _tog_t >= TOGGLE_S:
		_tog_sub = not _tog_sub
		_tog_t = 0.0001
		_toggle_apply()


func _toggle_report() -> void:
	for k: String in ["root", "sub"]:
		var v := PackedFloat64Array(_tog[k] as Array)
		var sum := 0.0
		for x in v:
			sum += x
		print("[bench-path] TOGGLE %-4s gpu ms  n %5d  mean %6.2f  p50 %6.2f  p95 %6.2f"
			% [k, v.size(), sum / maxf(v.size(), 1), _pct(v, 0.5), _pct(v, 0.95)])


## Horizontal heading of segment i; a vertical segment keeps the previous heading.
func _travel_yaw(i: int) -> float:
	if i + 1 >= _path.size():
		return _yaw
	var d := _path[i + 1] - _path[i]
	if Vector2(d.x, d.z).length() < 0.1:
		return _yaw
	return atan2(-d.x, -d.z)


func _place(p: Vector3) -> void:
	_cam.global_position = p
	_cam.rotation = Vector3(deg_to_rad(_pitch), _yaw, 0.0)
	if _sub_cam != null:
		_sub_cam.global_transform = _cam.global_transform
		_sub_cam.fov = _cam.fov


func _process(dt: float) -> void:
	_t += dt
	if _ws == "":
		_ws = ProbeWindow.free_workspace()
	if not ProbeWindow.drawing(get_tree().root):
		ProbeWindow.move_to(_ws, _size)
	elif not _fullscreen and _ws != "":
		# Floating at 1920x1080 still loses the i3 border and bar (measured 1914x1024).
		# Fullscreen on that output gives the native 1920x1080 the target is set at.
		_fullscreen = true
		OS.execute("i3-msg", ['[id="%d"] fullscreen enable'
				% DisplayServer.window_get_native_handle(DisplayServer.WINDOW_HANDLE)], [])

	if not _timing:
		if _t < _settle:
			return
		_timing = true
		# BISECTING A FIXED COST (the 6 ms depth pre-pass that ignored pixels, draws and
		# triangles): take the terrain out of the frame, or keep its geometry and give it
		# a trivial material.
		# NOT terrain.visible = false: DCTerrain draws through its own RenderingServer
		# instances and never handles NOTIFICATION_VISIBILITY_CHANGED, so that hides
		# nothing. Culling every layer at the camera draws no geometry at all; a cost
		# that survives it is per-pass overhead, not anything drawn.
		# BENCH_FILTER="lod_min,lod_max,seams,internal" -> DCTerrain.debug_filter_visible
		# (show only leaves in a LOD range; seams on/off; hide parents still holding an
		# instance unless internal=1). One-shot: use on a SETTLED tree (BENCH_LOD_FACTOR=1).
		if _env("BENCH_FILTER", "") != "" and _terrain != null:
			var f := _env("BENCH_FILTER", "").split(",")
			var r: Dictionary = _terrain.call("debug_filter_visible", int(f[0]), int(f[1]),
					f[2] == "1", f[3] == "1")
			print("[bench-path] filter lod %s..%s seams=%s internal=%s -> %s" % [f[0], f[1], f[2], f[3], str(r)])
		# BENCH_HIDE="relative/node/path,..." under the map: hide ordinary scene nodes.
		if _env("BENCH_HIDE", "") != "":
			var mapn: Node = _terrain.get_parent() if _terrain != null else null
			for p in _env("BENCH_HIDE", "").split(","):
				var hn: Node = mapn.get_node_or_null(p) if mapn != null else null
				if hn is Node3D:
					(hn as Node3D).visible = false
				print("[bench-path] hide %s -> %s" % [p, "hidden" if hn is Node3D else "NOT FOUND"])
		# #124 B0 bisect: the world's compositor (Nubis clouds, cloud_bank.gd) off.
		if _env("BENCH_NO_COMPOSITOR", "") == "1":
			for we in get_tree().root.find_children("*", "WorldEnvironment", true, false):
				(we as WorldEnvironment).compositor = null
			print("[bench-path] every WorldEnvironment.compositor -> null")
		if _env("BENCH_CULL_ALL", "") == "1":
			_cam.cull_mask = 0
			print("[bench-path] camera cull_mask 0 (no geometry)")
		if _env("BENCH_FLAT_TERRAIN_SHADER", "") == "1" and _terrain != null:
			var flat := Shader.new()
			flat.code = "shader_type spatial;\nvoid fragment() { ALBEDO = vec3(0.5); }\n"
			var fm := ShaderMaterial.new()
			fm.shader = flat
			_terrain.set("custom_material", fm)
			print("[bench-path] terrain material -> FLAT shader")
		# A/B knobs on the OCEAN's material (sheet only; the horizon plate is flat).
		var op := OS.get_environment("BENCH_OCEAN_PARAMS")
		if op != "" and _terrain != null:
			for o in _terrain.get_parent().find_children("*", "", true, false):
				if o is Ocean and (o as GeometryInstance3D).material_override is ShaderMaterial:
					var od: Dictionary = JSON.parse_string(op)
					for k in od:
						((o as GeometryInstance3D).material_override as ShaderMaterial).set_shader_parameter(String(k), od[k])
					print("[bench-path] ocean shader params %s" % op)
		# A/B knobs on the terrain material, applied once the map has set its own.
		var sp := OS.get_environment("BENCH_SHADER_PARAMS")
		if sp != "" and _terrain != null:
			var mat: Variant = _terrain.get("custom_material")
			if mat is ShaderMaterial:
				var d: Dictionary = JSON.parse_string(sp)
				for k in d:
					(mat as ShaderMaterial).set_shader_parameter(String(k), d[k])
				print("[bench-path] terrain shader params %s" % sp)
			else:
				print("[bench-path] ⚠ BENCH_SHADER_PARAMS set but the terrain has no ShaderMaterial")
		_last_us = Time.get_ticks_usec()
		print("[bench-path] timing starts at t=%.1f s, viewport %s, 3D %s" % [_t, get_viewport().get_visible_rect().size,
			_sub.size if _sub != null else get_viewport().get_visible_rect().size])
		if _env("BENCH_SHOT", "") != "":
			var vp: Viewport = _sub if _sub != null else get_viewport()
			vp.get_texture().get_image().save_png(_env("BENCH_SHOT", ""))
			print("[bench-path] shot -> %s" % _env("BENCH_SHOT", ""))
		return

	var now := Time.get_ticks_usec()
	var frame_ms := (now - _last_us) / 1000.0
	_last_us = now
	if not ProbeWindow.drawing(get_tree().root):
		_hidden += 1
	var gpu := RenderingServer.viewport_get_measured_render_time_gpu(get_viewport().get_viewport_rid())
	if _sub != null:                       # the 3D is the SubViewport's; the root draws 2D only
		gpu += RenderingServer.viewport_get_measured_render_time_gpu(_sub.get_viewport_rid())
	if _env("BENCH_SUBVIEWPORT", "") == "toggle":
		_toggle_step(gpu)
	var s: Dictionary = _terrain.get_frame_stats() if _terrain != null else {}
	_rows.append(PackedFloat64Array([
		_t, _cam.global_position.y, frame_ms, gpu,
		float(s.get("total_ms", 0.0)), float(s.get("traverse_ms", 0.0)),
		float(s.get("upload_ms", 0.0)), float(s.get("collision_ms", 0.0)),
		float(s.get("dispatched", 0)), float(s.get("uploaded", 0))]))

	# Advance along the path by distance, not by frame: a slow frame moves further.
	var step := _speed * dt
	while step > 0.0 and _seg + 1 < _path.size():
		var a := _path[_seg]
		var b := _path[_seg + 1]
		var left := a.distance_to(b) - _seg_t
		if step < left:
			_seg_t += step
			step = 0.0
		else:
			step -= left
			_seg += 1
			_seg_t = 0.0
			_yaw = _travel_yaw(_seg)
	if _seg + 1 >= _path.size():
		_finish()
		return
	_place(_path[_seg].lerp(_path[_seg + 1], _seg_t / _path[_seg].distance_to(_path[_seg + 1])))


## Every terrain chunk mesh (DCTerrain.debug_chunk_meshes: leaves with a mesh, NOT the
## seam meshes): triangles and vertices by LOD and by distance band, the largest
## chunks, and the vertex format -- to see whether a few chunks or the layout carry
## the fixed depth pre-pass cost.
func _chunk_report() -> void:
	var cam_pos := _cam.global_position
	var by_lod := {}
	var by_band := {}
	var rows: Array = []
	var fmt := -1
	for c: Dictionary in _terrain.call("debug_chunk_meshes"):
		var m: Mesh = c["mesh"]
		if m == null or m.get_surface_count() == 0:
			continue
		var tris := 0
		var verts := 0
		for s in m.get_surface_count():
			var arr := m.surface_get_arrays(s)
			var v: PackedVector3Array = arr[Mesh.ARRAY_VERTEX]
			verts += v.size()
			var ix: Variant = arr[Mesh.ARRAY_INDEX]
			tris += ((ix as PackedInt32Array).size() if ix != null and (ix as PackedInt32Array).size() > 0 else v.size()) / 3
		if fmt < 0 and m is ArrayMesh:
			fmt = (m as ArrayMesh).surface_get_format(0)
		var origin: Vector3 = c["origin"]
		var world := (_terrain as Node3D).to_global(origin)
		var d := world.distance_to(cam_pos)
		var lod := int(c["lod"])
		var band := "0-100m" if d < 100.0 else ("100-300m" if d < 300.0 else ("300-1200m" if d < 1200.0 else ">1200m"))
		for pair in [[by_lod, lod], [by_band, band]]:
			var acc: Dictionary = pair[0]
			if not acc.has(pair[1]):
				acc[pair[1]] = [0, 0, 0]
			acc[pair[1]][0] += 1
			acc[pair[1]][1] += tris
			acc[pair[1]][2] += verts
		rows.append([tris, verts, lod, d])
	var keys := by_lod.keys()
	keys.sort()
	for k in keys:
		print("[chunks] lod %2d: %4d chunks %9d tris %9d verts" % [k, by_lod[k][0], by_lod[k][1], by_lod[k][2]])
	for k in ["0-100m", "100-300m", "300-1200m", ">1200m"]:
		if by_band.has(k):
			print("[chunks] %-9s: %4d chunks %9d tris %9d verts" % [k, by_band[k][0], by_band[k][1], by_band[k][2]])
	rows.sort_custom(func(a, b): return a[0] > b[0])
	for r in rows.slice(0, 8):
		print("[chunks] top: %8d tris %8d verts lod %d at %.0f m" % [r[0], r[1], r[2], r[3]])
	print("[chunks] vertex format flags 0x%x (CUSTOM0-3 present: %s)" % [fmt,
			str([fmt & Mesh.ARRAY_FORMAT_CUSTOM0 != 0, fmt & Mesh.ARRAY_FORMAT_CUSTOM1 != 0,
				fmt & Mesh.ARRAY_FORMAT_CUSTOM2 != 0, fmt & Mesh.ARRAY_FORMAT_CUSTOM3 != 0])])


func _pct(v: PackedFloat64Array, p: float) -> float:
	if v.is_empty():
		return 0.0
	var s := v.duplicate()
	s.sort()
	return s[clampi(int(round(p * (s.size() - 1))), 0, s.size() - 1)]


func _col(c: int) -> PackedFloat64Array:
	var r := PackedFloat64Array()
	for row in _rows:
		r.append(row[c])
	return r


func _finish() -> void:
	if _env("BENCH_SUBVIEWPORT", "") == "toggle":
		_toggle_report()
	var f := FileAccess.open(_out + ".csv", FileAccess.WRITE)
	f.store_line("t,cam_y,frame_ms,gpu_ms,terrain_total_ms,traverse_ms,upload_ms,collision_ms,dispatched,uploaded")
	for row in _rows:
		var cells := PackedStringArray()
		for x in row:
			cells.append("%.3f" % x)
		f.store_line(",".join(cells))
	f.close()
	var fr := _col(2)
	var slow := 0
	for x in fr:
		if x > 33.4:
			slow += 1
	var total := 0.0
	for x in fr:
		total += x
	print("[bench-path] %d frames over %.1f s, mean %.1f fps%s" % [fr.size(), total / 1000.0,
			1000.0 * fr.size() / maxf(total, 1.0),
			"  ⚠ NOT DRAWING for %d frames — invalid" % _hidden if _hidden > 0 else ""])
	for c in [[2, "frame"], [3, "gpu"], [4, "terrain total"], [5, "traverse"], [6, "upload"], [7, "collision"]]:
		var v := _col(c[0])
		print("[bench-path] %-14s ms  p50 %7.2f  p95 %7.2f  p99 %7.2f  max %8.2f"
				% [c[1], _pct(v, 0.5), _pct(v, 0.95), _pct(v, 0.99), _pct(v, 1.0)])
	print("[bench-path] frames > 33.4 ms: %d (%.0f%%)  -> %s.csv" % [slow, 100.0 * slow / maxf(fr.size(), 1), _out])
	# What the last frame DREW, per Godot: separates "too many triangles" from
	# "expensive draws" without guessing (depth pre-pass stayed 6 ms at a quarter of the
	# pixels AND a quarter of the chunks, 2026-10-07).
	var vp_rid := get_viewport().get_viewport_rid()
	for t in [[RenderingServer.VIEWPORT_RENDER_INFO_TYPE_VISIBLE, "visible"],
			[RenderingServer.VIEWPORT_RENDER_INFO_TYPE_SHADOW, "shadow"]]:
		print("[bench-path] render info %-7s: %d objects, %d primitives, %d draw calls" % [t[1],
				RenderingServer.viewport_get_render_info(vp_rid, t[0], RenderingServer.VIEWPORT_RENDER_INFO_OBJECTS_IN_FRAME),
				RenderingServer.viewport_get_render_info(vp_rid, t[0], RenderingServer.VIEWPORT_RENDER_INFO_PRIMITIVES_IN_FRAME),
				RenderingServer.viewport_get_render_info(vp_rid, t[0], RenderingServer.VIEWPORT_RENDER_INFO_DRAW_CALLS_IN_FRAME)])
	if _env("BENCH_CHUNK_REPORT", "") == "1" and _terrain != null:
		_chunk_report()
	# The biggest scene MESHES (DCTerrain chunks are RenderingServer instances, not
	# nodes, so anything large here is NOT terrain).
	var meshes: Array = []
	for n in get_tree().root.find_children("*", "MeshInstance3D", true, false):
		var mi := n as MeshInstance3D
		if mi.mesh == null:
			continue
		var v := 0
		for s in mi.mesh.get_surface_count():
			var arr := mi.mesh.surface_get_arrays(s)
			v += (arr[Mesh.ARRAY_VERTEX] as PackedVector3Array).size() if arr.size() > 0 and arr[Mesh.ARRAY_VERTEX] != null else 0
		meshes.append([v, mi.is_visible_in_tree(), String(mi.get_path())])
	meshes.sort_custom(func(a, b): return a[0] > b[0])
	for m in meshes.slice(0, 6):
		print("[bench-path] mesh %8d verts  visible=%s  %s" % [m[0], str(m[1]), m[2]])
	# The last frame, for A/B-ing what a change does to the IMAGE. The chunk count and
	# settled flag say whether two shots saw the same geometry.
	get_viewport().get_texture().get_image().save_png(_out + ".png")
	if _terrain != null:
		# Not get("lod_factor"): the getter returns the inspector value (3.0) while
		# _ready forces the EFFECTIVE factor to 2.0 (dc_terrain.cpp:346).
		print("[bench-path] terrain chunks %d, settled %s, lod_factor %s, scale %.2f  -> %s.png" % [
				int(_terrain.call("get_chunk_count")), str(_terrain.call("is_lod_settled")),
				_env("BENCH_LOD_FACTOR", "2.0 (engine default)"), get_viewport().scaling_3d_scale, _out])
	get_tree().quit(0)