"""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
from simvx.core.descriptors import Property
# Colour thresholds (frame time in milliseconds)
_BUDGET_60FPS_MS = 1000.0 / 60.0 # ~16.67ms
_BUDGET_30FPS_MS = 1000.0 / 30.0 # ~33.33ms
_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."""
size_x = Property(640.0, range=(0, 10000), hint="Control width", on_change="_on_size_changed")
size_y = Property(360.0, range=(0, 10000), hint="Control height", on_change="_on_size_changed")
def __init__(self, editor_state=None, **kwargs):
super().__init__(**kwargs)
self.state = editor_state
self.bg_colour = _BG
self._profiler = FrameProfiler()
self._recording = True
self._max_samples = 240 # width of the frame graph in bars
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
# 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._profiler.reset()
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _toggle_recording(self):
self.recording = not self._recording
@staticmethod
def _bar_colour(dt_ms: float) -> tuple[float, float, float, float]:
"""Return a colour for a frame time in milliseconds.
* Green: inside the 60 FPS budget.
* Yellow: 30–60 FPS.
* Red: below 30 FPS.
"""
if dt_ms <= _BUDGET_60FPS_MS:
return _COLOUR_GOOD
if dt_ms <= _BUDGET_30FPS_MS:
return _COLOUR_WARN
return _COLOUR_BAD
# ------------------------------------------------------------------
# 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 on_update(self, dt: float):
# Handled externally; no per-frame work on the widget itself.
pass
[docs]
def on_draw(self, renderer):
self._layout_children()
x, y, w, h = self._local_rect()
scale = 11.0 / 16.0
renderer.rect((x, y), (w, h), fill=self.bg_colour)
# --- 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.rect((x, y), (w, _HEADER_H), fill=_HEADER_BG)
renderer.line((x, y + _HEADER_H), (x + w, y + _HEADER_H), stroke=_SECTION_BORDER)
title = f"Profiler ({self._source_label})"
renderer.text(title, (x + 10, y + 7), fill=_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
# 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} frame: {frame_ms:5.1f}ms avg: {avg_ms:5.1f}ms max: {max_ms:5.1f}ms"
renderer.text(stats_text, (x + 110, y + 7), fill=fps_colour, scale=scale)
# Right-side buttons
self._clear_btn._draw_self(renderer)
self._pause_btn._draw_self(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.rect((x, y), (w, h), fill=_SECTION_BG)
renderer.rect((x, y), (w, h), stroke=_SECTION_BORDER)
label = "Frame time"
renderer.text(label, (x + 6, y + 4), fill=_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:
msg = "No data" if not self._recording else "Waiting for frames..."
renderer.text(msg, (inner_x + 8, inner_y + inner_h / 2 - 6), fill=_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.rect((bx, top - stacked - seg_h), (max(bar_w - 0.5, 0.5), seg_h), fill=colour)
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)
renderer.rect((bx, top - total_h), (max(bar_w - 0.5, 0.5), uncovered), fill=colour)
# 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.line((inner_x, ty), (inner_x + inner_w, ty), stroke=colour)
label_y = ty - 12 if (ty - inner_y) >= 14 else ty + 2
renderer.text(f"{label} ({budget_ms:.1f}ms)", (inner_x + 4, label_y), fill=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.rect((x, y), (w, h), fill=_SECTION_BG)
renderer.rect((x, y), (w, h), stroke=_SECTION_BORDER)
renderer.text("Phases (60-frame avg)", (x + 6, y + 4), fill=_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.text(label, (x + 8, row_y + row_h / 2 - 6), fill=_TEXT, scale=scale * 0.85)
bar_x = x + 78.0
bar_w = max(col_w - 78.0 - ms_label_w, 20.0)
renderer.rect((bar_x, row_y + 2), (bar_w, row_h - 4), fill=(0.18, 0.18, 0.20, 1.0))
fill = min(ms / max_val, 1.0) * bar_w
if fill > 0:
renderer.rect((bar_x, row_y + 2), (fill, row_h - 4), fill=colour)
renderer.text(f"{ms:5.2f}ms", (bar_x + bar_w + 6, row_y + row_h / 2 - 6), fill=_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.text(f"{label}:", (rx, ry), fill=_TEXT_DIM, scale=scale * 0.85)
renderer.text(str(value), (rx + 100.0, ry), fill=_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.rect((x, y), (w, h), fill=_SECTION_BG)
renderer.rect((x, y), (w, h), stroke=_SECTION_BORDER)
renderer.text(self._GPU_HEADER, (x + 6, y + 4), fill=_COLOUR_GPU_HEADER, scale=scale * 0.9)
latest = self._profiler.gpu_phase_latest()
if not latest:
renderer.text(self._GPU_PLACEHOLDER, (x + 10, y + h / 2 - 6), fill=_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.text(label, (x + 10.0, row_y + row_h / 2 - 6), fill=_TEXT, scale=scale * 0.82)
renderer.rect((spark_x, row_y + 2), (spark_w, row_h - 4), fill=(0.15, 0.15, 0.17, 1.0))
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.text(
f"{ms:5.2f}ms", (spark_x + spark_w + 6, row_y + row_h / 2 - 6), fill=_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.rect((x + i * bar_w, y + h - bar_h), (max(bar_w - 0.4, 0.6), bar_h), fill=_COLOUR_GPU_SPARK)
# ------------------------------------------------------------------
# Hotspots
# ------------------------------------------------------------------
def _draw_hotspots(self, renderer, x, y, w, h, scale):
renderer.rect((x, y), (w, h), fill=_SECTION_BG)
renderer.rect((x, y), (w, h), stroke=_SECTION_BORDER)
renderer.text("Top nodes (smoothed per-frame time)", (x + 6, y + 4), fill=_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.text(msg, (x + 10, y + h / 2 - 6), fill=_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.text(display_path, (x + 10.0, row_y + row_h / 2 - 6), fill=_TEXT, scale=scale * 0.82)
bar_w = max(bar_col_w, 20.0)
renderer.rect((bar_col_x, row_y + 2), (bar_w, row_h - 4), fill=(0.15, 0.15, 0.17, 1.0))
fill = min(ms / max_ms, 1.0) * bar_w
renderer.rect((bar_col_x, row_y + 2), (fill, row_h - 4), fill=colour)
renderer.text(
f"{ms:5.2f}ms", (bar_col_x + bar_w + 6, row_y + row_h / 2 - 6), fill=_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)]