extends Node ## Frame and simulation cost in a real match scene at representative maximum occupation: ## six armies at the supply cap and a round-13 wave on all three lanes (synthetic fixture, ## gold granted to the fixture only). Two camera setups are measured one after the other: ## a lane view (Mid, Blau side, grotto in the background) and the whole-map overview, ## where every lane, tower, base and the grotto are drawn at once. VSync off; the ## figures apply to this machine only. ## G2 adds a base view (own Ancient and base towers) and a grotto view, plus a census of ## lights, shadow casters and particles per phase. ## Run: godot --path . --resolution 1920x1080 res://tools/luneraria/lune_benchmark.tscn -- --out=res://docs/luneraria/logs/g1/perf.json const Game = preload("res://scripts/luneraria/lune_game.gd") const PHASE_SECONDS: float = 6.0 var out: String = "" ## Start of the current frame's main-thread work (set by a node that processes first). var frame_start: int = 0 class FrameClock: extends Node var owner_bench: Node func _process(_delta: float) -> void: owner_bench.frame_start = Time.get_ticks_usec() func _ready() -> void: for arg: String in OS.get_cmdline_user_args(): if arg.begins_with("--out="): out = arg.substr(6) var clock := FrameClock.new() clock.owner_bench = self clock.process_priority = -1000 add_child(clock) call_deferred("run") func mean(values: Array) -> float: var total: float = 0.0 for value: Variant in values: total += float(value) return total / maxf(1.0, values.size()) ## Lights, shadow casters and particles that are active in the scene right now (G2 addition). func census(root: Node) -> Dictionary: var result := {"lights": 0, "shadow_lights": 0, "shadow_caster_nodes": 0, "shadow_caster_instances": 0, "particle_systems": 0, "particles_max": 0} for node: Node in root.find_children("*", "", true, false): if node is Light3D and (node as Light3D).is_visible_in_tree(): result.lights += 1 if (node as Light3D).shadow_enabled: result.shadow_lights += 1 elif node is GPUParticles3D and (node as GPUParticles3D).is_visible_in_tree() and (node as GPUParticles3D).emitting: result.particle_systems += 1 result.particles_max += (node as GPUParticles3D).amount elif node is CPUParticles3D and (node as CPUParticles3D).is_visible_in_tree() and (node as CPUParticles3D).emitting: result.particle_systems += 1 result.particles_max += (node as CPUParticles3D).amount elif node is GeometryInstance3D and (node as GeometryInstance3D).is_visible_in_tree() and (node as GeometryInstance3D).cast_shadow != GeometryInstance3D.SHADOW_CASTING_SETTING_OFF: result.shadow_caster_nodes += 1 var instances: int = 1 if node is MultiMeshInstance3D and (node as MultiMeshInstance3D).multimesh != null: var multimesh: MultiMesh = (node as MultiMeshInstance3D).multimesh instances = multimesh.instance_count if multimesh.visible_instance_count < 0 else multimesh.visible_instance_count result.shadow_caster_instances += instances return result ## Resident memory of the whole process (Linux /proc, macOS ps), -1 elsewhere. static func resident_mb() -> float: var output: Array = [] if OS.get_name() == "macOS": if OS.execute("ps", ["-o", "rss=", "-p", str(OS.get_process_id())], output) != 0 or output.is_empty(): return -1.0 return float(str(output[0]).strip_edges()) / 1024.0 if OS.get_name() != "Linux" or OS.execute("cat", ["/proc/%d/status" % OS.get_process_id()], output) != 0 or output.is_empty(): return -1.0 for line: String in str(output[0]).split("\n"): if line.begins_with("VmRSS:"): return float(line.split(":")[1].strip_edges().split(" ")[0]) / 1024.0 return -1.0 func median(values: Array[float]) -> float: if values.is_empty(): return -1.0 var sorted: Array[float] = values.duplicate() sorted.sort() return sorted[sorted.size() / 2] ## GPU load on macOS: Godot 4.7.2 gets no GPU timestamps from Metal (always 0), so a thread ## samples the accelerator statistics (ioreg: device/renderer utilisation, GPU memory in use) ## while a phase runs. GPU time per frame ~ utilisation x frame time (system-wide figure). class GpuSampler: var thread := Thread.new() var running: bool = false var samples: Array = [] var mutex := Mutex.new() func start() -> void: if OS.get_name() != "macOS": return running = true thread.start(_loop) func _loop() -> void: while running: var output: Array = [] if OS.execute("ioreg", ["-r", "-d", "1", "-w", "0", "-c", "IOAccelerator"], output) == 0 and not output.is_empty(): var text: String = str(output[0]) var sample: Dictionary = {} for key: String in ["Device Utilization %", "Renderer Utilization %", "Tiler Utilization %", "In use system memory"]: var at: int = text.find('"%s"=' % key) if at >= 0: var rest: String = text.substr(at + key.length() + 3, 24) sample[key] = float(rest.get_slice(",", 0).get_slice("}", 0)) mutex.lock() samples.append(sample) mutex.unlock() OS.delay_msec(400) func stop() -> Dictionary: if not running: return {} running = false thread.wait_to_finish() var result: Dictionary = {} for key: String in ["Device Utilization %", "Renderer Utilization %", "Tiler Utilization %"]: var total: float = 0.0 for sample: Dictionary in samples: total += float(sample.get(key, 0.0)) result[key] = total / maxf(1.0, samples.size()) var memory: float = 0.0 for sample: Dictionary in samples: memory = maxf(memory, float(sample.get("In use system memory", 0.0))) result["gpu_memory_in_use_mb"] = memory / 1048576.0 result["samples"] = samples.size() return result func measure(game: Node3D, state: LuneMatch) -> Dictionary: for i: int in 30: await get_tree().process_frame var sampler := GpuSampler.new() sampler.start() var process_ms: Array[float] = [] var setup_ms: Array[float] = [] var strategic_ms: Array[float] = [] var frames: Array[float] = [] var cpu: Array[float] = [] var gpu: Array[float] = [] var draws: Array[float] = [] var primitives: Array[float] = [] var objects: Array[float] = [] var mobile: Array[int] = [] var scene: Dictionary = {} var last: int = Time.get_ticks_usec() var end: int = Time.get_ticks_msec() + int(PHASE_SECONDS * 1000.0) while Time.get_ticks_msec() < end and state.phase == LuneMatch.Phase.COMBAT: await RenderingServer.frame_post_draw var now: int = Time.get_ticks_usec() frames.append((now - last) / 1000.0) last = now # Main-thread time from the first _process of the frame until the frame was drawn. cpu.append((now - frame_start) / 1000.0) gpu.append(RenderingServer.viewport_get_measured_render_time_gpu(get_viewport().get_viewport_rid())) draws.append(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)) primitives.append(Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME)) objects.append(Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME)) mobile.append(state.combat.mobile_count()) process_ms.append(Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0) setup_ms.append(RenderingServer.get_frame_setup_time_cpu()) if game.world.get("strategic_usec") != null: strategic_ms.append(float(game.world.strategic_usec) / 1000.0) if frames.size() % 60 == 30: var sample: Dictionary = census(game) for key: String in sample: scene[key] = maxi(int(scene.get(key, 0)), int(sample[key])) var gpu_load: Dictionary = sampler.stop() var frame_mean: float = mean(frames) frames.sort() var result := {"frames": frames.size(), "frame_ms_mean": frame_mean, "frame_ms_p95": frames[int(frames.size() * .95)] if not frames.is_empty() else -1.0, "cpu_frame_ms_mean": mean(cpu), "gpu_ms_mean": mean(gpu), "draw_calls_mean": mean(draws), "primitives_mean": mean(primitives), "objects_mean": mean(objects), "mobile_units_mean": mean(mobile), "camera_zoom": game.camera.zoom, "scene_max": scene, "process_ms_median": median(process_ms), "render_setup_ms_median": median(setup_ms)} if not strategic_ms.is_empty(): result["strategic_view_ms_median"] = median(strategic_ms) if not gpu_load.is_empty(): result["gpu_load"] = gpu_load result["gpu_ms_estimate"] = frame_mean * float(gpu_load["Device Utilization %"]) / 100.0 return result func run() -> void: Soundscape.enabled = false DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED) var t0: int = Time.get_ticks_usec() var game: Node3D = Game.new() add_child(game) game.start({"seed": 5, "human_slot": 0}) var build_ms: float = (Time.get_ticks_usec() - t0) / 1000.0 var state: LuneMatch = game.match_state state.round_number = 13 for slot: int in range(6): state.players[slot].draft.gold = 100000 state.players[slot].draft.roster.clear() LuneBot.plan(state, slot, "ausgewogen") game._on_ready() RenderingServer.viewport_set_measure_render_time(get_viewport().get_viewport_rid(), true) game.speed_index = 1 game.camera.look_at_point(Vector3(-10, 0, 10), 34.0, true) var lane_view: Dictionary = await measure(game, state) game.camera.overview(true) var overview: Dictionary = await measure(game, state) # G2: the strategic buildings up close (own base with Ancient and base towers, the grotto). var core: Vector2 = state.map.core_position(0) game.camera.look_at_point(Vector3(core.x, 0, core.y), 26.0, true) var base_view: Dictionary = await measure(game, state) var grotto: Vector2 = state.map.grotto_center() game.camera.look_at_point(Vector3(grotto.x, 0, grotto.y), 22.0, true) var grotto_view: Dictionary = await measure(game, state) # Pure simulation cost of one fixed step at this occupation, measured separately. var probe := LuneMatch.new() probe.setup({"seed": 5, "human_slot": -1}) probe.round_number = 13 for slot: int in range(6): probe.players[slot].draft.gold = 100000 LuneBot.plan(probe, slot, "ausgewogen") probe.begin_combat() var start_units: int = probe.combat.mobile_count() var s1: int = Time.get_ticks_usec() for i: int in 200: probe.step_combat() var step_ms: float = (Time.get_ticks_usec() - s1) / 1000.0 / 200.0 var report := {"window": get_window().size, "renderer": RenderingServer.get_current_rendering_method(), "gpu": RenderingServer.get_video_adapter_name(), "cpu": OS.get_processor_name(), "engine": Engine.get_version_info().string, "scene_build_ms": build_ms, "mobile_units_at_start": start_units, "buildings": 18, "sim_step_ms": step_ms, "sim_speed": "2x (40 steps/s)", "lane_view": lane_view, "overview": overview, "base_view": base_view, "grotto_view": grotto_view, "video_mem_mb": Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED) / 1048576.0, "texture_mem_mb": Performance.get_monitor(Performance.RENDER_TEXTURE_MEM_USED) / 1048576.0, "static_mem_mb": Performance.get_monitor(Performance.MEMORY_STATIC) / 1048576.0, "buffer_mem_mb": Performance.get_monitor(Performance.RENDER_BUFFER_MEM_USED) / 1048576.0, "process_rss_mb": resident_mb()} var path: String = out if not out.is_empty() else "res://docs/luneraria/logs/perf-%dx%d.json" % [get_window().size.x, get_window().size.y] DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(path.get_base_dir())) var file := FileAccess.open(path, FileAccess.WRITE) file.store_string(JSON.stringify(report, "\t")) file.close() print("LUNE_BENCH ", JSON.stringify(report)) get_tree().quit()