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)