extends SceneTree # Profile scene runner — manually drives the frame loop. # In --script --headless mode the automatic main loop doesn't # iterate, so we drive it ourselves via a manual loop. const DEFAULT_SCENE := "res://assets/scenes/modular/kit_demo.tscn" const DEFAULT_CAPTURE_FRAMES := 600 const WARMUP_FRAMES := 60 var capture_frames := DEFAULT_CAPTURE_FRAMES var scene_path := "" var frame_count := 0 var recording: Array[Dictionary] = [] var profiling_complete := false func _init(): # Parse CLI args var args = OS.get_cmdline_args() for i in range(args.size()): if args[i] == "--scene" and i + 1 < args.size(): scene_path = args[i + 1] if args[i] == "--capture-frames" and i + 1 < args.size(): capture_frames = args[i + 1].to_int() if scene_path.is_empty(): scene_path = DEFAULT_SCENE print("=== Profile Scene Tool ===") print("Engine: ", Engine.get_version_info().string) print("Scene: ", scene_path) print("Capture frames: ", capture_frames) print("Warmup frames: ", WARMUP_FRAMES) print("") print("Frame,Delta(ms),GPU(ms),Physics(ms),DrawCalls,Tris,Vertices,VRAM(MB),RAM(MB),Objects") # Load target scene var scene = ResourceLoader.load(scene_path) if not scene: printerr("ERROR: Failed to load scene: ", scene_path) quit(1) return var instance = scene.instantiate() root.add_child(instance) # Manual frame loop: Godot doesn't auto-iterate the main loop # in --script --headless mode. We iterate it ourselves. profiling_complete = false _manual_loop() func _manual_loop() -> void: var start_time := Time.get_ticks_usec() var frame_delta := 0.016 # Target 60fps step while not profiling_complete: var frame_start := Time.get_ticks_usec() # Call idle (process) on the scene tree — this triggers # _process() on all nodes including the instanced scene #idle(frame_delta) # Manually iterate — this processes input, idle, and physics iteration(frame_delta) _on_frame(frame_delta) # Cap frame rate to avoid 100% CPU in headless mode var elapsed := (Time.get_ticks_usec() - frame_start) / 1000.0 var target_ms := 16.0 # ~60fps if elapsed < target_ms: OS.delay_msec(int(target_ms - elapsed)) # Use elapsed wall time as delta for next frame frame_delta = (Time.get_ticks_usec() - start_time) / 1000000.0 start_time = Time.get_ticks_usec() _finish_report() func _on_frame(delta: float) -> void: frame_count += 1 # Skip warmup frames if frame_count <= WARMUP_FRAMES: return if frame_count > WARMUP_FRAMES + capture_frames: profiling_complete = true return var p := Performance var frame_delta := delta * 1000.0 var gpu_time := float(RenderingServer.get_frame_setup_time_cpu()) * 1000.0 var physics_time := p.get_monitor(p.TIME_PHYSICS_PROCESS) * 1000.0 var draw_calls := int(p.get_monitor(p.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)) var tris := int(p.get_monitor(p.RENDER_TOTAL_PRIMITIVES_IN_FRAME)) var verts := int(p.get_monitor(p.RENDER_TOTAL_VERTICES_IN_FRAME)) var vram_mb := int(p.get_monitor(p.RENDER_VIDEO_MEM_USED)) / (1024 * 1024) var ram_mb := int(OS.get_static_memory_usage()) / (1024 * 1024) var objects := int(p.get_monitor(p.OBJECT_NODE_COUNT)) recording.append({ frame = frame_count - WARMUP_FRAMES, delta_ms = snapped(frame_delta, 0.01), gpu_ms = snapped(gpu_time, 0.01), physics_ms = snapped(physics_time, 0.01), draw_calls = draw_calls, tris = tris, verts = verts, vram_mb = vram_mb, ram_mb = ram_mb, objects = objects }) print("%d,%.2f,%.2f,%.2f,%d,%d,%d,%d,%d,%d" % [ frame_count - WARMUP_FRAMES, frame_delta, gpu_time, physics_time, draw_calls, tris, verts, vram_mb, ram_mb, objects ]) func _finish_report() -> void: print("") print("=== Profile Summary ===") if recording.is_empty(): print("No frames recorded!") quit(1) return var deltas: Array[float] = [] for r in recording: deltas.append(r.delta_ms) deltas.sort() var total := float(recording.size()) var n := deltas.size() var p50: float = deltas[n * 50 / 100] var p95: float = deltas[n * 95 / 100] var p99: float = deltas[n * 99 / 100] var sum: float = 0.0 for d in deltas: sum += d var avg: float = sum / total var max_dc := 0 var max_tris := 0 var max_vram := 0 for r in recording: if r.draw_calls > max_dc: max_dc = r.draw_calls if r.tris > max_tris: max_tris = r.tris if r.vram_mb > max_vram: max_vram = r.vram_mb print("Frames captured: ", recording.size()) print("Avg frame time: %.2f ms" % avg) print("P50 frame time: %.2f ms" % p50) print("P95 frame time: %.2f ms" % p95) print("P99 frame time: %.2f ms" % p99) print("Max draw calls: %d" % max_dc) print("Max triangles: %d" % max_tris) print("Peak VRAM: %d MB" % max_vram) var pass_fail := "PASS" if p95 > 16.6: pass_fail = "FAIL (P95 > 16.6 ms)" elif p99 > 50.0: pass_fail = "FAIL (P99 spike > 50 ms)" print("Result: ", pass_fail) print("") print("=== Profile complete ===") quit(0 if pass_fail == "PASS" else 1)