Source code for simvx.editor.panels.profiler_panel

"""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)]