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.
354 lines
12 KiB
GDScript
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
|