saumwacht-godot/tools/luneraria/lune_benchmark.gd
Martin Stickelbroeck 9f62e30377 G2: Ancients, Türme, Bossgrotte und Superboss; macOS- und Windows-ARM64-Pakete
Mondherz-Ancients (Saumkern-Kristall als Herz, Sichelklingen, Ringe, Krone,
Teamwappen, Energieadern) mit den Stufen stabil/instabil/kritisch, kurzem
Zusammenbruch und bleibender Ruine; der Sieg wartet nie auf die Grafik.
Turmfamilie aus einem Blender-Generator: Grenzwacht (Tower 1), Bastion
(Tower 2), Kernwacht (Basisturm in der Formensprache des Ancients), mit
Schadensstufen, Einsturz, Ruine und sichtbarem Angriff (Zielring, Aufladen,
Bolzen, Einschlag) - der Schaden bleibt vollständig in der Simulation.
Bossgrotte Mondschlund mit Tor, Treppe in die Tiefe, Altar, Säulen, Felsen
und fünf Zuständen; der Generator hält Abstand zur Top-Lane, die Höhle
braucht eine kleine Geländesenke (nur Darstellung). Superboss Schlundkrone
mit 24 Knochen und 11 Clips (inkl. Brüllen und Sieg), Adern in Teamfarbe,
dasselbe Modell für Grotte und Lane. Gemeinsame LN-Materialfamilie (Stein,
Metall, Kristall, Rune, Stoff, Mondwasser, Kreaturenhaut), Occlusion-Fade
ohne dithernde Schatten. Keine Regeländerung.

Geprüft: verify_g2 245/245, verify_g1 443/443, Regeln 91/91 und 73/73,
Stress 17/17, Balance 40 Partien identisch mit G1, verify_m1 38/38,
verify_m2 14/14, Netz 43/43 und Loopback 12/12, Legacy 209 und 463.
21 Ansichten vorher/nachher (1920x1080), Videos Turmkampf und Bossbegegnung,
Leistung G1/G2 auf demselben Mac (+0,5 ms in vollen Kampfansichten).
Pakete 0.17.0-g2 für macOS (Universal, ad-hoc signiert), Windows x64/ARM64
und Linux; macOS nativ und unter Rosetta, beide Windows-Pakete in Windows 11
ARM (Parallels, OpenGL-Fallback) mit --lune-smoke bestanden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:12:49 +02:00

242 lines
11 KiB
GDScript

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()