Files
Battle-for-Tismo/scripts/classes/performance_monitor.gd
T
adrien c49899a2fd Initial commit
Server-authoritative Godot 4.6 strategy game.

Includes the headless WebSocket game server, the browser client, and
Nix packaging (server and web-export derivations plus a NixOS module)
for deployment.
2026-09-15 14:48:34 +02:00

354 lines
12 KiB
GDScript

class_name PerformanceMonitor
extends RefCounted
## Client-side frame-rate watchdog. The HUD feeds it one sample per frame; it
## keeps a rolling window of frame times plus a ring buffer of recent gameplay
## actions (input, orders, snapshots, map rebuilds). When the frame rate takes a
## dive it prints one self-contained report describing the dip and everything
## that happened just before it, so a player testing under real conditions can
## paste the log back for diagnosis.
##
## Like Profiler, all state is static, so any script can record an action
## without holding a reference:
##
## PerformanceMonitor.record_action("order", {"type": "move"})
##
## Only the HUD should call tick()/sample_frame(), and only once per frame.
## Master switch. When false, sampling and recording are both no-ops.
static var enabled: bool = true
## Label stamped on every report, so client and server logs are distinguishable.
static var tag: String = "client"
## Below this many FPS is always a dive, whatever the recent baseline was.
static var fps_floor: float = 30.0
## A dive is also declared when FPS falls below this fraction of the baseline.
static var drop_ratio: float = 0.65
## Recent frames averaged into the "current" FPS (roughly half a second at 60).
static var short_frames: int = 30
## Recent frames averaged into the baseline FPS (roughly five seconds at 60).
static var long_frames: int = 300
## Consecutive dived frames needed before a report is printed, so a one-frame
## hiccup does not spam the log.
static var confirm_frames: int = 10
## Minimum real seconds between two dive reports.
static var cooldown_seconds: float = 5.0
## How far back (real seconds) a report looks for actions to print.
static var context_seconds: float = 15.0
## Ring-buffer capacity for recorded actions.
static var max_actions: int = 300
## Newest actions printed per report.
static var report_actions: int = 80
## Frames slower than this many milliseconds are logged as their own action.
static var hitch_ms: float = 50.0
## Set by the HUD; when valid, used to stamp reports with in-game time.
static var game_time_provider: Callable = Callable()
## When false, report() stays silent (tests turn it off).
static var log_reports: bool = true
static var _frame_seconds: Array[float] = []
static var _frame_ids: Array[int] = []
static var _actions: Array[Dictionary] = []
static var _below_count: int = 0
static var _last_report_msec: int = -1000000
static var _last_tick_usec: int = 0
static var _current_fps: float = 0.0
static var _baseline_fps: float = 0.0
static var _worst_frame_ms: float = 0.0
static var _peak_frame_ms: float = 0.0
static var _dive_count: int = 0
## Samples one frame using the wall clock. Call once per frame, from the HUD.
## Uses Time directly rather than the engine's delta (which honours time_scale)
## so the measured rate is real frames per real second.
static func tick() -> void:
var now := Time.get_ticks_usec()
if _last_tick_usec == 0:
_last_tick_usec = now
return
var frame_seconds := float(now - _last_tick_usec) / 1000000.0
_last_tick_usec = now
if enabled:
sample_frame(frame_seconds)
## Records a frame of a known duration. tick() calls this; tests call it
## directly so the detection algorithm can be exercised deterministically.
static func sample_frame(frame_seconds: float) -> void:
if not enabled:
return
frame_seconds = maxf(frame_seconds, 0.0001)
_frame_seconds.append(frame_seconds)
_frame_ids.append(Engine.get_frames_drawn())
if _frame_seconds.size() > long_frames:
_frame_seconds.pop_front()
_frame_ids.pop_front()
var milliseconds := frame_seconds * 1000.0
_peak_frame_ms = maxf(_peak_frame_ms, milliseconds)
if milliseconds >= hitch_ms:
_record_action_internal("frame.hitch", {"ms": snappedf(milliseconds, 0.1)})
_current_fps = _fps_over(short_frames)
_baseline_fps = _fps_over(long_frames)
_worst_frame_ms = _max_frame_over(short_frames) * 1000.0
if is_diving():
_below_count += 1
else:
_below_count = 0
if _below_count >= confirm_frames and _cooldown_elapsed():
_dive_count += 1
_below_count = 0
_last_report_msec = Time.get_ticks_msec()
report("PERFORMANCE DIVE #%d" % _dive_count)
## True while the current rate is below the floor or the baseline's safe ratio.
static func is_diving() -> bool:
if not enabled or _current_fps <= 0.0:
return false
if _current_fps < fps_floor:
return true
return _baseline_fps > 0.0 and _current_fps < _baseline_fps * drop_ratio
## Stores an action with the wall-clock time and frame it happened on. Cheap
## enough to call on every order, snapshot and map rebuild.
static func record_action(label: String, detail: Dictionary = {}) -> void:
if not enabled:
return
_record_action_internal(label, detail)
static func _record_action_internal(label: String, detail: Dictionary) -> void:
_actions.append({
"label": label,
"detail": detail,
"msec": Time.get_ticks_msec(),
"frame": Engine.get_frames_drawn(),
})
while _actions.size() > max_actions:
_actions.pop_front()
## Actions recorded within the last `window_seconds` (or context_seconds when
## negative), oldest first.
static func get_recent_actions(window_seconds: float = -1.0) -> Array[Dictionary]:
var window := window_seconds if window_seconds >= 0.0 else context_seconds
var cutoff := Time.get_ticks_msec() - int(window * 1000.0)
var recent: Array[Dictionary] = []
for action in _actions:
if int(action["msec"]) >= cutoff:
recent.append(action)
return recent
static func current_fps() -> float:
return _current_fps
static func baseline_fps() -> float:
return _baseline_fps
static func worst_frame_ms() -> float:
return _worst_frame_ms
static func peak_frame_ms() -> float:
return _peak_frame_ms
static func dive_count() -> int:
return _dive_count
static func action_count() -> int:
return _actions.size()
## Prints a complete, self-contained snapshot: frame-rate metrics, engine and
## renderer counters, memory, the worst recent frames and the recent actions.
static func report(title: String = "Performance report") -> void:
if not log_reports:
return
var divider := "================================================================"
var now_msec := Time.get_ticks_msec()
print(divider)
print(" %s [%s]" % [title, tag])
print(divider)
print(" wall : %s game: %s" % [
Time.get_datetime_string_from_system(), _game_time_string(),
])
print(" fps : %.1f baseline %.1f floor %.0f drop ratio %.2f" % [
_current_fps, _baseline_fps, fps_floor, drop_ratio,
])
print(" frame : %.2f ms avg (last %d) worst %.2f ms peak %.2f ms" % [
_average_frame_seconds(short_frames) * 1000.0,
mini(short_frames, _frame_seconds.size()), _worst_frame_ms, _peak_frame_ms,
])
var version: Dictionary = Engine.get_version_info()
print(" engine : Godot %s frames %d fps %.0f physics %d Hz max_fps %d time_scale %.2f" % [
str(version.get("string", "?")), Engine.get_frames_drawn(),
Engine.get_frames_per_second(), Engine.physics_ticks_per_second,
Engine.max_fps, Engine.time_scale,
])
print(" render : %s adapter %s draw calls %d objects %d prims %d" % [
_rendering_method(), RenderingServer.get_video_adapter_name(),
_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME),
_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME),
_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME),
])
print(" memory : static %.1f MB peak %.1f MB objects %d nodes %d resources %d orphans %d" % [
OS.get_static_memory_usage() / 1048576.0,
OS.get_static_memory_peak_usage() / 1048576.0,
_monitor(Performance.OBJECT_COUNT),
_monitor(Performance.OBJECT_NODE_COUNT),
_monitor(Performance.OBJECT_RESOURCE_COUNT),
_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT),
])
print(" process : process %.2f ms physics %.2f ms navigation %.2f ms" % [
Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0,
Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0,
Performance.get_monitor(Performance.TIME_NAVIGATION_PROCESS) * 1000.0,
])
_print_worst_frames()
_print_recent_actions(now_msec)
print(divider)
static func _print_worst_frames() -> void:
print(" -- worst frames (last %d) --" % mini(short_frames, _frame_seconds.size()))
for row in _worst_recent_frames(8):
print(" %8.2f ms age %5.2f s frame #%d" % [
row["ms"], row["age"], row["frame"],
])
static func _print_recent_actions(now_msec: int) -> void:
var recent := get_recent_actions()
var first := maxi(0, recent.size() - report_actions)
print(" -- recent actions (last %.1f s, %d of %d) --" % [
context_seconds, recent.size() - first, _actions.size(),
])
if recent.is_empty():
print(" (none)")
return
for i in range(first, recent.size()):
var action: Dictionary = recent[i]
var age := float(now_msec - int(action["msec"])) / 1000.0
print(" -%6.2fs %-20s %s" % [
age, action["label"], _format_detail(action["detail"]),
])
## Frames from the short window, newest first, sorted by duration. `age` is the
## time before the report at which the frame ran.
static func _worst_recent_frames(count: int) -> Array[Dictionary]:
var window := mini(short_frames, _frame_seconds.size())
var rows: Array[Dictionary] = []
var elapsed := 0.0
for offset in window:
var i := _frame_seconds.size() - 1 - offset
rows.append({
"ms": _frame_seconds[i] * 1000.0,
"age": elapsed,
"frame": _frame_ids[i],
})
elapsed += _frame_seconds[i]
rows.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a["ms"] > b["ms"])
if rows.size() > count:
rows.resize(count)
return rows
static func _format_detail(detail: Dictionary) -> String:
if detail.is_empty():
return ""
var parts := PackedStringArray()
for key in detail.keys():
parts.append("%s=%s" % [key, detail[key]])
return " ".join(parts)
static func _average_frame_seconds(count: int) -> float:
var n := mini(maxi(count, 0), _frame_seconds.size())
if n <= 0:
return 0.0
var total := 0.0
for i in range(_frame_seconds.size() - n, _frame_seconds.size()):
total += _frame_seconds[i]
return total / float(n)
static func _fps_over(count: int) -> float:
var average := _average_frame_seconds(count)
return 1.0 / average if average > 0.0 else 0.0
static func _max_frame_over(count: int) -> float:
var n := mini(maxi(count, 0), _frame_seconds.size())
if n <= 0:
return 0.0
var worst := 0.0
for i in range(_frame_seconds.size() - n, _frame_seconds.size()):
worst = maxf(worst, _frame_seconds[i])
return worst
static func _cooldown_elapsed() -> bool:
return Time.get_ticks_msec() - _last_report_msec >= int(cooldown_seconds * 1000.0)
static func _monitor(id: int) -> int:
return int(Performance.get_monitor(id))
static func _rendering_method() -> String:
return str(ProjectSettings.get_setting("rendering/renderer/rendering_method", "?"))
static func _game_time_string() -> String:
if game_time_provider.is_valid():
return str(game_time_provider.call())
return "?"
## Clears all runtime state (samples, actions, counters) but keeps the config.
static func reset() -> void:
_frame_seconds.clear()
_frame_ids.clear()
_actions.clear()
_below_count = 0
_last_report_msec = -1000000
_last_tick_usec = 0
_current_fps = 0.0
_baseline_fps = 0.0
_worst_frame_ms = 0.0
_peak_frame_ms = 0.0
_dive_count = 0
## Restores every tuning value to its default. Tests call this so one test's
## tweaks cannot leak into the next.
static func reset_config() -> void:
enabled = true
tag = "client"
fps_floor = 30.0
drop_ratio = 0.65
short_frames = 30
long_frames = 300
confirm_frames = 10
cooldown_seconds = 5.0
context_seconds = 15.0
max_actions = 300
report_actions = 80
hitch_ms = 50.0
game_time_provider = Callable()
log_reports = true