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>
242 lines
11 KiB
GDScript
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()
|