"""Profiler Panel -- live performance inspector for editor and play mode.
Displays four sections stacked vertically:
1. Header: title, source label (Editor/Game), FPS/min/avg/max stats,
Pause/Clear/Reset buttons.
2. Frame-time graph: scrolling per-frame bar chart with target lines at
16.67ms (60 FPS) and 33.33ms (30 FPS). Bars are colour-coded by budget
and stacked with phase contributions (physics, process, draw) so users
can see which phase is eating the budget.
3. Phase breakdown: horizontal bars showing the average time per phase
over the last second alongside scene counts (nodes, controls, meshes,
draw calls) and memory RSS.
4. Hotspot list: top-N nodes by smoothed per-frame process+physics time.
Surfaces the exact node path that is taking the most time.
The panel is driven from a shared :class:`FrameProfiler`. Producers (the
editor shell and play mode) call ``record_frame``/``profiler.begin`` etc.
to feed data in; the panel renders whatever the profiler currently holds.
"""
from simvx.core import Button, Control, Vec2
from simvx.core.debug.profiler import FrameProfiler
# Colour thresholds (frame time in milliseconds)
_BUDGET_60FPS_MS = 1000.0 / 60.0 # ~16.67ms
_BUDGET_30FPS_MS = 1000.0 / 30.0 # ~33.33ms
# Legacy aliases (seconds) preserved for downstream importers / tests.
_THRESHOLD_60FPS = _BUDGET_60FPS_MS / 1000.0
_THRESHOLD_30FPS = _BUDGET_30FPS_MS / 1000.0
_COLOUR_GOOD = (0.30, 0.85, 0.30, 1.0)
_COLOUR_WARN = (0.95, 0.80, 0.20, 1.0)
_COLOUR_BAD = (0.90, 0.25, 0.25, 1.0)
_COLOUR_TARGET_60 = (0.40, 0.70, 1.00, 0.55)
_COLOUR_TARGET_30 = (0.95, 0.55, 0.20, 0.45)
_COLOUR_PHASE_PROCESS = (0.35, 0.75, 0.95, 0.85)
_COLOUR_PHASE_PHYSICS = (0.85, 0.50, 0.90, 0.85)
_COLOUR_PHASE_DRAW = (0.55, 0.95, 0.55, 0.85)
_COLOUR_PHASE_OTHER = (0.75, 0.75, 0.75, 0.70)
# GPU section uses a warmer palette to distinguish from CPU phases.
_COLOUR_GPU_BAR = (0.95, 0.65, 0.30, 0.90)
_COLOUR_GPU_SPARK = (0.95, 0.75, 0.45, 0.95)
_COLOUR_GPU_HEADER = (0.95, 0.75, 0.50, 1.0)
_BG = (0.12, 0.12, 0.13, 1.0)
_HEADER_BG = (0.16, 0.16, 0.18, 1.0)
_SECTION_BG = (0.09, 0.09, 0.10, 1.0)
_SECTION_BORDER = (0.22, 0.22, 0.24, 1.0)
_TEXT = (0.88, 0.88, 0.90, 1.0)
_TEXT_DIM = (0.55, 0.55, 0.58, 1.0)
_TEXT_LABEL = (0.70, 0.70, 0.72, 1.0)
_HEADER_H = 28.0
_SECTION_PAD = 6.0
_HOTSPOT_ROWS = 6
[docs]
class ProfilerPanel(Control):
"""Live profiler display. Call :meth:`record_frame` every frame."""
def __init__(self, editor_state=None, **kwargs):
super().__init__(**kwargs)
self.state = editor_state
self.bg_colour = _BG
self.size = Vec2(640, 360)
self._profiler = FrameProfiler()
self._recording = True
self._max_samples = 240 # width of the frame graph in bars
# Legacy convenience: simple list of recent total frame seconds,
# used by callers that don't have access to a FrameProfiler.
self._frame_times: list[float] = []
self._source_label: str = "Editor"
self._clear_btn = Button(text="Clear", on_press=self.clear)
self._clear_btn.size = Vec2(56, 22)
self._pause_btn = Button(text="Pause", on_press=self._toggle_recording)
self._pause_btn.size = Vec2(62, 22)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
[docs]
@property
def profiler(self) -> FrameProfiler:
"""The underlying :class:`FrameProfiler`. External producers time
phases and sample nodes through this handle."""
return self._profiler
@property
def recording(self) -> bool:
return self._recording
[docs]
@recording.setter
def recording(self, value: bool):
self._recording = value
self._profiler.enabled = value
self._pause_btn.text = "Resume" if not value else "Pause"
@property
def source_label(self) -> str:
"""Human-readable label for the current data source (``Editor`` /
``Game``). Displayed in the header."""
return self._source_label
[docs]
@source_label.setter
def source_label(self, value: str):
self._source_label = value
[docs]
def record_frame(self, dt: float, scene_tree=None) -> None:
"""Record a simple total-frame sample.
``dt`` is in seconds (editor/process loop units). If no phase
timings have been pushed via :attr:`profiler` directly, the panel
still gets a usable frame-time graph. When ``scene_tree`` is
provided, node/control counts are refreshed.
"""
if not self._recording:
return
self._frame_times.append(dt)
if len(self._frame_times) > self._max_samples:
self._frame_times.pop(0)
# Feed the FrameProfiler so phase_avg, hotspots, etc. stay in sync
# even when the caller only provides a total dt.
prof = self._profiler
# If no phases were timed by the producer, install "total".
if "total" not in prof._current and "total" not in prof._starts:
prof._current["total"] = dt * 1000.0
if scene_tree is not None:
prof.count_nodes(scene_tree)
prof.end_frame()
[docs]
def clear(self) -> None:
"""Discard all recorded samples and hotspot averages."""
self._frame_times.clear()
self._profiler.reset()
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _toggle_recording(self):
self.recording = not self._recording
@staticmethod
def _bar_colour(dt: float) -> tuple[float, float, float, float]:
"""Return a colour for a frame time (seconds).
* Green — inside the 60 FPS budget.
* Yellow — 30–60 FPS.
* Red — below 30 FPS.
"""
if dt <= _THRESHOLD_60FPS:
return _COLOUR_GOOD
if dt <= _THRESHOLD_30FPS:
return _COLOUR_WARN
return _COLOUR_BAD
def _stats(self) -> tuple[float, float, float, float]:
"""Return ``(avg, min_dt, max_dt, current_fps)`` from the seconds-based frame-time buffer."""
if not self._frame_times:
return (0.0, 0.0, 0.0, 0.0)
avg = sum(self._frame_times) / len(self._frame_times)
min_dt = min(self._frame_times)
max_dt = max(self._frame_times)
last = self._frame_times[-1]
fps = 1.0 / last if last > 0 else 0.0
return (avg, min_dt, max_dt, fps)
# ------------------------------------------------------------------
# Layout
# ------------------------------------------------------------------
def _layout_children(self):
x, y, w, _h = self.get_global_rect()
pad = 4.0
btn_y = y + (_HEADER_H - self._clear_btn.size.y) / 2.0
clear_w = self._clear_btn.size.x
pause_w = self._pause_btn.size.x
self._clear_btn.position = Vec2(x + w - clear_w - pad, btn_y)
self._pause_btn.position = Vec2(x + w - clear_w - pause_w - pad * 2, btn_y)
# Make sure the main panel consumes input; children handle their own.
self.size = Vec2(max(self.size.x, 320.0), max(self.size.y, 200.0))
# ------------------------------------------------------------------
# Draw
# ------------------------------------------------------------------
[docs]
def process(self, dt: float):
# Handled externally; no per-frame work on the widget itself.
pass
[docs]
def draw(self, renderer):
self._layout_children()
x, y, w, h = self.get_global_rect()
scale = 11.0 / 14.0
renderer.draw_rect((x, y), (w, h), colour=self.bg_colour, filled=True)
# --- Header --------------------------------------------------
self._draw_header(renderer, x, y, w, scale)
# --- Body sections ------------------------------------------
body_y = y + _HEADER_H + _SECTION_PAD
body_h = h - _HEADER_H - _SECTION_PAD * 2
if body_h <= 0:
return
graph_h = max(min(body_h * 0.34, 140.0), 60.0)
phase_h = max(min(body_h * 0.22, 110.0), 70.0)
gpu_h = max(min(body_h * 0.22, 130.0), 56.0)
hotspot_h = body_h - graph_h - phase_h - gpu_h - _SECTION_PAD * 3
hotspot_h = max(hotspot_h, 40.0)
bx = x + _SECTION_PAD
bw = w - _SECTION_PAD * 2
self._draw_frame_graph(renderer, bx, body_y, bw, graph_h, scale)
phase_y = body_y + graph_h + _SECTION_PAD
self._draw_phase_breakdown(renderer, bx, phase_y, bw, phase_h, scale)
gpu_y = phase_y + phase_h + _SECTION_PAD
self._draw_gpu_section(renderer, bx, gpu_y, bw, gpu_h, scale)
hot_y = gpu_y + gpu_h + _SECTION_PAD
self._draw_hotspots(renderer, bx, hot_y, bw, hotspot_h, scale)
# ------------------------------------------------------------------
# Header
# ------------------------------------------------------------------
def _draw_header(self, renderer, x, y, w, scale):
renderer.draw_rect((x, y), (w, _HEADER_H), colour=_HEADER_BG, filled=True)
renderer.draw_line((x, y + _HEADER_H), (x + w, y + _HEADER_H), colour=_SECTION_BORDER)
title = f"Profiler ({self._source_label})"
renderer.draw_text(title, (x + 10, y + 7), colour=_TEXT_LABEL, scale=scale)
prof = self._profiler
fps = prof.fps
frame_ms = prof.frame_time_ms
if prof.sample_count > 0:
avg_ms = prof.phase_avg_ms("total")
max_ms = prof.phase_max_ms("total")
else:
avg_ms = max_ms = 0.0
if not avg_ms and self._frame_times:
avg_s, _min_s, max_s, fps_s = self._stats()
avg_ms = avg_s * 1000.0
max_ms = max_s * 1000.0
if fps == 0.0:
fps = fps_s
frame_ms = self._frame_times[-1] * 1000.0
# Colour FPS by budget
if fps >= 55.0:
fps_colour = _COLOUR_GOOD
elif fps >= 29.0:
fps_colour = _COLOUR_WARN
elif fps > 0.0:
fps_colour = _COLOUR_BAD
else:
fps_colour = _TEXT_DIM
stats_text = (
f"FPS: {fps:5.1f} "
f"frame: {frame_ms:5.1f}ms "
f"avg: {avg_ms:5.1f}ms "
f"max: {max_ms:5.1f}ms"
)
renderer.draw_text(stats_text, (x + 110, y + 7), colour=fps_colour, scale=scale)
# Right-side buttons
self._clear_btn.draw(renderer)
self._pause_btn.draw(renderer)
# ------------------------------------------------------------------
# Frame-time graph
# ------------------------------------------------------------------
_PHASE_LAYERS = (
("physics", _COLOUR_PHASE_PHYSICS),
("process", _COLOUR_PHASE_PROCESS),
("draw", _COLOUR_PHASE_DRAW),
)
def _draw_frame_graph(self, renderer, x, y, w, h, scale):
renderer.draw_rect((x, y), (w, h), colour=_SECTION_BG, filled=True)
renderer.draw_rect((x, y), (w, h), colour=_SECTION_BORDER)
label = "Frame time"
renderer.draw_text(label, (x + 6, y + 4), colour=_TEXT_LABEL, scale=scale * 0.9)
inner_x = x + 4.0
inner_y = y + 18.0
inner_w = w - 8.0
inner_h = h - 22.0
if inner_w <= 4 or inner_h <= 4:
return
totals = self._profiler.frame_times(self._max_samples)
if not totals and self._frame_times:
# Legacy fallback: use seconds-based buffer.
totals = [t * 1000.0 for t in self._frame_times[-self._max_samples:]]
if not totals:
msg = "No data" if not self._recording else "Waiting for frames..."
renderer.draw_text(
msg,
(inner_x + 8, inner_y + inner_h / 2 - 6),
colour=_TEXT_DIM,
scale=scale,
)
return
max_ms = max(max(totals), _BUDGET_30FPS_MS * 1.05)
n = len(totals)
bar_w = max(inner_w / self._max_samples, 1.0)
start_x = inner_x + inner_w - n * bar_w
# Per-phase history aligned to the tail of ``totals``.
phase_history = {
name: self._profiler.phase_history(name, n)
for name, _ in self._PHASE_LAYERS
}
for i, dt_ms in enumerate(totals):
bx = start_x + i * bar_w
total_h = min((dt_ms / max_ms) * inner_h, inner_h)
top = inner_y + inner_h
# Stacked phase bars (bottom-up).
stacked = 0.0
for name, colour in self._PHASE_LAYERS:
series = phase_history[name]
val = series[i] if i < len(series) else 0.0
if val <= 0:
continue
seg_h = min((val / max_ms) * inner_h, inner_h - stacked)
if seg_h <= 0:
continue
renderer.draw_rect(
(bx, top - stacked - seg_h), (max(bar_w - 0.5, 0.5), seg_h), colour=colour, filled=True
)
stacked += seg_h
# Draw the budget-colour outline on top if phases didn't cover
# the full total (uncategorised time or no per-phase data).
uncovered = total_h - stacked
if uncovered > 0.5:
colour = self._bar_colour(dt_ms / 1000.0)
renderer.draw_rect(
(bx, top - total_h), (max(bar_w - 0.5, 0.5), uncovered), colour=colour, filled=True
)
# Budget reference lines. Labels sit above the line when there's
# room, otherwise below -- avoids colliding with the section header.
for budget_ms, colour, label in (
(_BUDGET_60FPS_MS, _COLOUR_TARGET_60, "60 FPS"),
(_BUDGET_30FPS_MS, _COLOUR_TARGET_30, "30 FPS"),
):
if budget_ms > max_ms:
continue
ty = inner_y + inner_h - (budget_ms / max_ms) * inner_h
renderer.draw_line((inner_x, ty), (inner_x + inner_w, ty), colour=colour)
label_y = ty - 12 if (ty - inner_y) >= 14 else ty + 2
renderer.draw_text(
f"{label} ({budget_ms:.1f}ms)",
(inner_x + 4, label_y),
colour=colour,
scale=scale * 0.75,
)
# ------------------------------------------------------------------
# Phase breakdown + counts
# ------------------------------------------------------------------
_BREAKDOWN_PHASES = ("process", "physics", "draw", "ui", "total")
def _draw_phase_breakdown(self, renderer, x, y, w, h, scale):
renderer.draw_rect((x, y), (w, h), colour=_SECTION_BG, filled=True)
renderer.draw_rect((x, y), (w, h), colour=_SECTION_BORDER)
renderer.draw_text("Phases (60-frame avg)", (x + 6, y + 4), colour=_TEXT_LABEL, scale=scale * 0.9)
prof = self._profiler
phases = [(p, prof.phase_avg_ms(p)) for p in self._BREAKDOWN_PHASES]
max_val = max((ms for _, ms in phases), default=0.0)
max_val = max(max_val, _BUDGET_60FPS_MS)
# Left column: phase bars. Right column: counts / memory.
# Leave ~60px gap between the ms label and the counts column so
# narrow windows don't collide the two.
col_w = w * 0.55
row_y = y + 20.0
row_h = max((h - 28.0) / max(len(phases), 1), 14.0)
ms_label_w = 60.0
for phase, ms in phases:
colour = _phase_colour(phase)
label = f"{phase:>8s}"
renderer.draw_text(label, (x + 8, row_y + row_h / 2 - 6), colour=_TEXT, scale=scale * 0.85)
bar_x = x + 78.0
bar_w = max(col_w - 78.0 - ms_label_w, 20.0)
renderer.draw_rect(
(bar_x, row_y + 2), (bar_w, row_h - 4), colour=(0.18, 0.18, 0.20, 1.0), filled=True
)
fill = min(ms / max_val, 1.0) * bar_w
if fill > 0:
renderer.draw_rect((bar_x, row_y + 2), (fill, row_h - 4), colour=colour, filled=True)
renderer.draw_text(
f"{ms:5.2f}ms",
(bar_x + bar_w + 6, row_y + row_h / 2 - 6),
colour=_TEXT,
scale=scale * 0.85,
)
row_y += row_h
# Right column — scene counts + memory.
rx = x + col_w + 12.0
ry = y + 22.0
line_h = 14.0
counts = [
("Nodes", prof.node_count),
("Controls", prof.control_count),
("Meshes", prof.mesh_count),
("Draw calls", prof.draw_call_count),
("Memory", f"{prof.memory_mb():.1f} MB" if prof.memory_mb() else "n/a"),
("Samples", prof.sample_count),
]
for label, value in counts:
renderer.draw_text(f"{label}:", (rx, ry), colour=_TEXT_DIM, scale=scale * 0.85)
renderer.draw_text(str(value), (rx + 100.0, ry), colour=_TEXT, scale=scale * 0.85)
ry += line_h
# ------------------------------------------------------------------
# GPU phases (per-pass timestamp queries from the renderer)
# ------------------------------------------------------------------
_GPU_HEADER = "GPU passes (latest ms + ~2s sparkline)"
_GPU_PLACEHOLDER = "GPU profiling unavailable"
_GPU_MAX_ROWS = 6
def _draw_gpu_section(self, renderer, x, y, w, h, scale):
renderer.draw_rect((x, y), (w, h), colour=_SECTION_BG, filled=True)
renderer.draw_rect((x, y), (w, h), colour=_SECTION_BORDER)
renderer.draw_text(
self._GPU_HEADER, (x + 6, y + 4), colour=_COLOUR_GPU_HEADER, scale=scale * 0.9
)
latest = self._profiler.gpu_phase_latest()
if not latest:
renderer.draw_text(
self._GPU_PLACEHOLDER,
(x + 10, y + h / 2 - 6),
colour=_TEXT_DIM,
scale=scale * 0.85,
)
return
# Sort heaviest pass first, drop trailing rows we can't fit.
rows = sorted(latest.items(), key=lambda kv: kv[1], reverse=True)[: self._GPU_MAX_ROWS]
max_ms = max((ms for _, ms in rows), default=0.0)
max_ms = max(max_ms, 0.05)
row_y = y + 22.0
row_h = max((h - 28.0) / max(len(rows), 1), 12.0)
# Three columns: label | sparkline | latest ms
label_col_w = max(min(w * 0.25, 110.0), 70.0)
ms_col_w = 64.0
spark_x = x + 10.0 + label_col_w + 8.0
spark_w = w - (spark_x - x) - ms_col_w - 10.0
spark_w = max(spark_w, 30.0)
for label, ms in rows:
renderer.draw_text(
label, (x + 10.0, row_y + row_h / 2 - 6), colour=_TEXT, scale=scale * 0.82
)
renderer.draw_rect(
(spark_x, row_y + 2),
(spark_w, row_h - 4),
colour=(0.15, 0.15, 0.17, 1.0),
filled=True,
)
history = self._profiler.gpu_phase_history_for(label)
self._draw_sparkline(
renderer, spark_x, row_y + 2, spark_w, row_h - 4, history, max_ms
)
renderer.draw_text(
f"{ms:5.2f}ms",
(spark_x + spark_w + 6, row_y + row_h / 2 - 6),
colour=_TEXT,
scale=scale * 0.82,
)
row_y += row_h
@staticmethod
def _draw_sparkline(renderer, x, y, w, h, samples, max_value):
"""Render a min-area sparkline as thin filled bars across the row."""
n = len(samples)
if n == 0 or w <= 1.0 or h <= 1.0:
return
scale_y = h / max(max_value, 1e-6)
bar_w = max(w / n, 1.0)
for i, sample in enumerate(samples):
if sample <= 0.0:
continue
bar_h = min(sample * scale_y, h)
renderer.draw_rect(
(x + i * bar_w, y + h - bar_h),
(max(bar_w - 0.4, 0.6), bar_h),
colour=_COLOUR_GPU_SPARK,
filled=True,
)
# ------------------------------------------------------------------
# Hotspots
# ------------------------------------------------------------------
def _draw_hotspots(self, renderer, x, y, w, h, scale):
renderer.draw_rect((x, y), (w, h), colour=_SECTION_BG, filled=True)
renderer.draw_rect((x, y), (w, h), colour=_SECTION_BORDER)
renderer.draw_text(
"Top nodes (smoothed per-frame time)", (x + 6, y + 4), colour=_TEXT_LABEL, scale=scale * 0.9
)
hotspots = self._profiler.hotspots(_HOTSPOT_ROWS)
if not hotspots:
msg = (
"No per-node samples. Start play mode to profile game scripts."
if not self._profiler._node_ema
else "No hotspots"
)
renderer.draw_text(
msg, (x + 10, y + h / 2 - 6), colour=_TEXT_DIM, scale=scale * 0.85
)
return
max_ms = hotspots[0][1]
max_ms = max(max_ms, 0.05)
row_y = y + 22.0
row_h = max((h - 26.0) / _HOTSPOT_ROWS, 12.0)
path_col_w = w * 0.55
bar_col_x = x + 10.0 + path_col_w + 10.0
bar_col_w = w - (bar_col_x - x) - 70.0
for i, (path, ms) in enumerate(hotspots):
display_path = path if len(path) < 60 else "..." + path[-57:]
colour = _hotspot_colour(i)
renderer.draw_text(
display_path, (x + 10.0, row_y + row_h / 2 - 6), colour=_TEXT, scale=scale * 0.82
)
bar_w = max(bar_col_w, 20.0)
renderer.draw_rect(
(bar_col_x, row_y + 2), (bar_w, row_h - 4), colour=(0.15, 0.15, 0.17, 1.0), filled=True
)
fill = min(ms / max_ms, 1.0) * bar_w
renderer.draw_rect(
(bar_col_x, row_y + 2), (fill, row_h - 4), colour=colour, filled=True
)
renderer.draw_text(
f"{ms:5.2f}ms",
(bar_col_x + bar_w + 6, row_y + row_h / 2 - 6),
colour=_TEXT,
scale=scale * 0.82,
)
row_y += row_h
# ----------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------
def _phase_colour(phase: str) -> tuple[float, float, float, float]:
if phase == "process":
return _COLOUR_PHASE_PROCESS
if phase == "physics":
return _COLOUR_PHASE_PHYSICS
if phase == "draw":
return _COLOUR_PHASE_DRAW
if phase == "total":
return (0.3, 0.6, 1.0, 0.85)
return _COLOUR_PHASE_OTHER
def _hotspot_colour(i: int) -> tuple[float, float, float, float]:
palette = (
(0.95, 0.55, 0.30, 0.9),
(0.90, 0.75, 0.25, 0.9),
(0.55, 0.85, 0.45, 0.9),
(0.35, 0.75, 0.95, 0.9),
(0.65, 0.55, 0.95, 0.9),
(0.85, 0.45, 0.75, 0.9),
)
return palette[i % len(palette)]