Skip to main content

freya_performance_plugin/
lib.rs

1use std::{
2    collections::HashMap,
3    time::{
4        Duration,
5        Instant,
6    },
7};
8
9use freya_core::prelude::{
10    ModifiersExt,
11    UserEvent,
12};
13use freya_engine::prelude::{
14    Canvas,
15    Color,
16    FontCollection,
17    FontStyle,
18    Paint,
19    PaintStyle,
20    ParagraphBuilder,
21    ParagraphStyle,
22    Rect,
23    Slant,
24    TextAlign,
25    TextStyle,
26    Weight,
27    Width,
28};
29use freya_winit::{
30    plugins::{
31        FreyaPlugin,
32        Key,
33        Modifiers,
34        PluginEvent,
35        PluginHandle,
36    },
37    reexports::winit::window::WindowId,
38    renderer::{
39        NativeEvent,
40        NativeWindowEvent,
41        NativeWindowEventAction,
42    },
43};
44
45const FRAME_TIME_SAMPLES: usize = 100;
46
47/// Performance overlay plugin that displays FPS, timing metrics, and other
48/// diagnostics on top of the rendered frame. Hidden by default, toggle with
49/// Ctrl+Shift+P (Cmd+Shift+P on macOS).
50pub struct PerformanceOverlayPlugin {
51    enabled: bool,
52    toggle_shortcut: (Key, Modifiers),
53    metrics: HashMap<WindowId, WindowMetrics>,
54}
55
56impl Default for PerformanceOverlayPlugin {
57    fn default() -> Self {
58        Self {
59            enabled: false,
60            toggle_shortcut: (
61                Key::Character("p".into()),
62                Modifiers::ctrl_or_meta() | Modifiers::SHIFT,
63            ),
64            metrics: HashMap::new(),
65        }
66    }
67}
68
69#[derive(Default)]
70struct WindowMetrics {
71    graphics_driver: &'static str,
72    gpu_name: Option<String>,
73
74    frames: Vec<Instant>,
75
76    started_redraw: Option<Instant>,
77    frame_times: Vec<f32>,
78    graph_scale_max: f32,
79    graph_scale_checked_at: Option<Instant>,
80
81    started_render: Option<Instant>,
82
83    started_layout: Option<Instant>,
84    finished_layout: Option<Duration>,
85
86    started_tree_updates: Option<Instant>,
87    finished_tree_updates: Option<Duration>,
88
89    started_tasks_poll: Option<Instant>,
90    tasks_poll_time: Duration,
91
92    started_events: Option<Instant>,
93    events_time: Duration,
94
95    started_accessibility_updates: Option<Instant>,
96    finished_accessibility_updates: Option<Duration>,
97
98    started_presenting: Option<Instant>,
99    finished_presenting: Option<Duration>,
100
101    overlay_time: Duration,
102}
103
104impl WindowMetrics {
105    fn record_frame_time(&mut self) {
106        let Some(started_redraw) = self.started_redraw.take() else {
107            return;
108        };
109        let frame_time = started_redraw.elapsed().as_secs_f32() * 1000.0;
110        self.frame_times.push(frame_time);
111        if self.frame_times.len() > FRAME_TIME_SAMPLES {
112            self.frame_times.remove(0);
113        }
114    }
115
116    fn graph_scale_max(&mut self) -> f32 {
117        let max_frame_time = self.frame_times.iter().copied().fold(0.0, f32::max);
118        let required = nice_scale_max(max_frame_time);
119
120        let due_for_recheck = match self.graph_scale_checked_at {
121            Some(checked_at) => checked_at.elapsed() >= Duration::from_secs(1),
122            None => true,
123        };
124
125        if max_frame_time > self.graph_scale_max || due_for_recheck {
126            self.graph_scale_max = required;
127            self.graph_scale_checked_at = Some(Instant::now());
128        }
129
130        self.graph_scale_max
131    }
132}
133
134impl PerformanceOverlayPlugin {
135    /// Set the keyboard shortcut that toggles the overlay visibility.
136    pub fn with_toggle_shortcut(mut self, key: Key, modifiers: Modifiers) -> Self {
137        self.toggle_shortcut = (key, modifiers);
138        self
139    }
140
141    /// Set whether the overlay is visible by default.
142    pub fn with_visible(mut self, visible: bool) -> Self {
143        self.enabled = visible;
144        self
145    }
146
147    fn get_metrics(&mut self, id: WindowId) -> &mut WindowMetrics {
148        self.metrics.entry(id).or_default()
149    }
150}
151
152impl FreyaPlugin for PerformanceOverlayPlugin {
153    fn plugin_id(&self) -> &'static str {
154        "freya-performance-overlay"
155    }
156
157    fn on_event(&mut self, event: &mut PluginEvent, handle: PluginHandle) {
158        match event {
159            PluginEvent::KeyboardInput {
160                key,
161                modifiers,
162                is_pressed,
163                ..
164            } => {
165                let (shortcut_key, shortcut_modifiers) = &self.toggle_shortcut;
166                let key_matches = match (key, shortcut_key) {
167                    (Key::Character(a), Key::Character(b)) => a.eq_ignore_ascii_case(b),
168                    (a, b) => a == b,
169                };
170                if *is_pressed && *modifiers == *shortcut_modifiers && key_matches {
171                    self.enabled = !self.enabled;
172                    for window_id in self.metrics.keys() {
173                        handle.send_event_loop_event(NativeEvent::Window(NativeWindowEvent {
174                            window_id: *window_id,
175                            action: NativeWindowEventAction::User(UserEvent::RequestRedraw),
176                        }));
177                    }
178                }
179            }
180            PluginEvent::WindowCreated {
181                window,
182                graphics_driver,
183                gpu_name,
184                ..
185            }
186            | PluginEvent::GraphicsDriverChanged {
187                window,
188                graphics_driver,
189                gpu_name,
190            } => {
191                let metrics = self.get_metrics(window.id());
192                metrics.graphics_driver = graphics_driver;
193                metrics.gpu_name = gpu_name.map(str::to_string);
194            }
195            PluginEvent::WindowClosed { window, .. } => {
196                self.metrics.remove(&window.id());
197            }
198            PluginEvent::AfterRedraw { window, .. } => {
199                let metrics = self.get_metrics(window.id());
200                let now = Instant::now();
201
202                metrics.record_frame_time();
203
204                metrics
205                    .frames
206                    .retain(|frame| now.duration_since(*frame).as_millis() < 1000);
207
208                metrics.frames.push(now);
209
210                // Accumulated across the frame, so they need a reset
211                metrics.tasks_poll_time = Duration::ZERO;
212                metrics.events_time = Duration::ZERO;
213                metrics.finished_layout = None;
214            }
215            PluginEvent::BeforePresenting { window, .. } => {
216                self.get_metrics(window.id()).started_presenting = Some(Instant::now())
217            }
218            PluginEvent::AfterPresenting { window, .. } => {
219                let metrics = self.get_metrics(window.id());
220                metrics.finished_presenting = Some(metrics.started_presenting.unwrap().elapsed())
221            }
222            PluginEvent::StartedMeasuringLayout { window, .. } => {
223                let metrics = self.get_metrics(window.id());
224                metrics.started_redraw.get_or_insert(Instant::now());
225                metrics.started_layout = Some(Instant::now());
226            }
227            PluginEvent::FinishedMeasuringLayout { window, .. } => {
228                let metrics = self.get_metrics(window.id());
229                metrics.finished_layout = Some(metrics.started_layout.unwrap().elapsed())
230            }
231            PluginEvent::StartedUpdatingTree { window, .. } => {
232                self.get_metrics(window.id()).started_tree_updates = Some(Instant::now())
233            }
234            PluginEvent::FinishedUpdatingTree { window, .. } => {
235                let metrics = self.get_metrics(window.id());
236                metrics.finished_tree_updates =
237                    Some(metrics.started_tree_updates.unwrap().elapsed())
238            }
239            PluginEvent::StartedPollingTasks { window, .. } => {
240                self.get_metrics(window.id()).started_tasks_poll = Some(Instant::now())
241            }
242            PluginEvent::FinishedPollingTasks { window, .. } => {
243                let metrics = self.get_metrics(window.id());
244                if let Some(started) = metrics.started_tasks_poll.take() {
245                    metrics.tasks_poll_time += started.elapsed();
246                }
247                if self.enabled {
248                    handle.send_event_loop_event(NativeEvent::Window(NativeWindowEvent {
249                        window_id: window.id(),
250                        action: NativeWindowEventAction::User(UserEvent::RequestRedraw),
251                    }));
252                }
253            }
254            PluginEvent::StartedMeasuringEvents { window, .. } => {
255                self.get_metrics(window.id()).started_events = Some(Instant::now())
256            }
257            PluginEvent::FinishedMeasuringEvents { window, .. } => {
258                let metrics = self.get_metrics(window.id());
259                if let Some(started) = metrics.started_events.take() {
260                    metrics.events_time += started.elapsed();
261                }
262                if self.enabled {
263                    handle.send_event_loop_event(NativeEvent::Window(NativeWindowEvent {
264                        window_id: window.id(),
265                        action: NativeWindowEventAction::User(UserEvent::RequestRedraw),
266                    }));
267                }
268            }
269            PluginEvent::BeforeAccessibility { window, .. } => {
270                self.get_metrics(window.id()).started_accessibility_updates = Some(Instant::now())
271            }
272            PluginEvent::AfterAccessibility { window, .. } => {
273                let metrics = self.get_metrics(window.id());
274                metrics.finished_accessibility_updates =
275                    Some(metrics.started_accessibility_updates.unwrap().elapsed())
276            }
277            PluginEvent::BeforeRender { window, .. } => {
278                let metrics = self.get_metrics(window.id());
279                metrics.started_redraw.get_or_insert(Instant::now());
280                metrics.started_render = Some(Instant::now());
281            }
282            PluginEvent::AfterRender {
283                window,
284                canvas,
285                font_collection,
286                tree,
287                animation_clock,
288            } => {
289                if !self.enabled {
290                    return;
291                }
292                let metrics = self.get_metrics(window.id());
293                let scale_factor = window.scale_factor() as f32;
294                let started_render = metrics.started_render.take().unwrap();
295
296                canvas.save();
297                canvas.scale((scale_factor, scale_factor));
298
299                let finished_render = started_render.elapsed();
300                let finished_presenting = metrics.finished_presenting.unwrap_or_default();
301                let finished_layout = metrics.finished_layout.unwrap_or_default();
302                let finished_tree_updates = metrics.finished_tree_updates.unwrap_or_default();
303                let tasks_poll_time = metrics.tasks_poll_time;
304                let events_time = metrics.events_time;
305                let finished_accessibility_updates =
306                    metrics.finished_accessibility_updates.unwrap_or_default();
307                let overlay_time = metrics.overlay_time;
308                let overlay_started = Instant::now();
309
310                let mut fps_paragraph_builder =
311                    ParagraphBuilder::new(&ParagraphStyle::default(), *font_collection);
312                add_text(
313                    &mut fps_paragraph_builder,
314                    format!("{} FPS", metrics.frames.len()),
315                    24.0,
316                );
317                let mut fps_paragraph = fps_paragraph_builder.build();
318                fps_paragraph.layout(235.0);
319
320                let rows = [
321                    (
322                        "Rendering",
323                        format!("{:.3}ms", finished_render.as_secs_f64() * 1000.0),
324                    ),
325                    (
326                        "Presenting",
327                        format!("{:.3}ms", finished_presenting.as_secs_f64() * 1000.0),
328                    ),
329                    (
330                        "Layout",
331                        format!("{:.3}ms", finished_layout.as_secs_f64() * 1000.0),
332                    ),
333                    (
334                        "Tree Updates",
335                        format!("{:.3}ms", finished_tree_updates.as_secs_f64() * 1000.0),
336                    ),
337                    (
338                        "a11y Updates",
339                        format!(
340                            "{:.3}ms",
341                            finished_accessibility_updates.as_secs_f64() * 1000.0
342                        ),
343                    ),
344                    (
345                        "Tasks",
346                        format!("{:.3}ms", tasks_poll_time.as_secs_f64() * 1000.0),
347                    ),
348                    (
349                        "Events",
350                        format!("{:.3}ms", events_time.as_secs_f64() * 1000.0),
351                    ),
352                    (
353                        "Overlay",
354                        format!("{:.3}ms", overlay_time.as_secs_f64() * 1000.0),
355                    ),
356                    (
357                        "Frame",
358                        format!(
359                            "{:.3}ms",
360                            (finished_render
361                                + finished_presenting
362                                + finished_layout
363                                + finished_tree_updates
364                                + tasks_poll_time
365                                + events_time
366                                + finished_accessibility_updates
367                                + overlay_time)
368                                .as_secs_f64()
369                                * 1000.0
370                        ),
371                    ),
372                    ("Tree Nodes", tree.size().to_string()),
373                    ("Layout Nodes", tree.layout.size().to_string()),
374                    ("Scale Factor", format!("{}x", window.scale_factor())),
375                    (
376                        "Animation clock speed",
377                        format!("{}x", animation_clock.speed()),
378                    ),
379                    ("Renderer", metrics.graphics_driver.to_string()),
380                    ("Freya", env!("CARGO_PKG_VERSION").to_string()),
381                    (
382                        "Build",
383                        (if cfg!(debug_assertions) {
384                            "Debug"
385                        } else {
386                            "Release"
387                        })
388                        .to_string(),
389                    ),
390                ];
391
392                let mut keys_paragraph_builder =
393                    ParagraphBuilder::new(&ParagraphStyle::default(), *font_collection);
394                let mut values_style = ParagraphStyle::default();
395                values_style.set_text_align(TextAlign::Right);
396                let mut values_paragraph_builder =
397                    ParagraphBuilder::new(&values_style, *font_collection);
398                for (key, value) in &rows {
399                    add_text(&mut keys_paragraph_builder, format!("{key}\n"), 14.0);
400                    add_text(&mut values_paragraph_builder, format!("{value}\n"), 14.0);
401                }
402                let mut keys_paragraph = keys_paragraph_builder.build();
403                keys_paragraph.layout(235.0);
404                let mut values_paragraph = values_paragraph_builder.build();
405                values_paragraph.layout(235.0);
406
407                let gpu_paragraph = metrics.gpu_name.as_ref().map(|gpu_name| {
408                    let mut builder =
409                        ParagraphBuilder::new(&ParagraphStyle::default(), *font_collection);
410                    add_text(&mut builder, format!("GPU: {gpu_name}"), 14.0);
411                    let mut paragraph = builder.build();
412                    paragraph.layout(235.0);
413                    paragraph
414                });
415
416                let graph_left = 40.0;
417                let graph_top = fps_paragraph.height() + 10.0;
418                let graph_bottom = graph_top + 60.0;
419
420                let rows_top = graph_bottom + 22.0;
421                let gpu_top = rows_top + keys_paragraph.height() + 4.0;
422                let content_bottom = gpu_paragraph
423                    .as_ref()
424                    .map(|paragraph| gpu_top + paragraph.height())
425                    .unwrap_or(rows_top + keys_paragraph.height());
426
427                let mut paint = Paint::default();
428                paint.set_anti_alias(true);
429                paint.set_style(PaintStyle::Fill);
430                paint.set_color(Color::from_argb(235, 24, 24, 24));
431                canvas.draw_rect(Rect::new(5., 5., 245.0, content_bottom + 10.0), &paint);
432
433                fps_paragraph.paint(canvas, (5.0, 0.0));
434                keys_paragraph.paint(canvas, (5.0, rows_top));
435                values_paragraph.paint(canvas, (5.0, rows_top));
436                if let Some(paragraph) = &gpu_paragraph {
437                    paragraph.paint(canvas, (5.0, gpu_top));
438                }
439
440                let scale_max = metrics.graph_scale_max();
441
442                let mut axis_paint = Paint::default();
443                axis_paint.set_anti_alias(true);
444                axis_paint.set_style(PaintStyle::Stroke);
445                axis_paint.set_stroke_width(1.0);
446                axis_paint.set_color(Color::from_rgb(130, 130, 130));
447
448                canvas.draw_line(
449                    (graph_left, graph_top),
450                    (graph_left, graph_bottom),
451                    &axis_paint,
452                );
453                canvas.draw_line(
454                    (graph_left, graph_bottom),
455                    (graph_left + 195.0, graph_bottom),
456                    &axis_paint,
457                );
458
459                let decimals = if scale_max < 10.0 { 1 } else { 0 };
460                for (value, y) in [
461                    (scale_max, graph_top),
462                    (scale_max / 2.0, graph_top + 30.0),
463                    (0.0, graph_bottom),
464                ] {
465                    canvas.draw_line((graph_left - 3.0, y), (graph_left, y), &axis_paint);
466                    draw_axis_label(
467                        canvas,
468                        font_collection,
469                        &format!("{value:.decimals$}ms"),
470                        7.0,
471                        y - 6.0,
472                    );
473                }
474
475                draw_axis_label(
476                    canvas,
477                    font_collection,
478                    &format!("last {} frames", metrics.frame_times.len()),
479                    graph_left + 62.5,
480                    graph_bottom + 4.0,
481                );
482
483                let mut line_paint = Paint::default();
484                line_paint.set_anti_alias(true);
485                line_paint.set_style(PaintStyle::Stroke);
486                line_paint.set_stroke_width(1.5);
487                line_paint.set_color(Color::from_rgb(255, 204, 92));
488
489                let step = 195.0 / (FRAME_TIME_SAMPLES - 1) as f32;
490                let point = |index: usize, frame_time: f32| {
491                    let x = graph_left + index as f32 * step;
492                    let y = graph_bottom - (frame_time / scale_max).min(1.0) * 60.0;
493                    (x, y)
494                };
495                for (index, window) in metrics.frame_times.windows(2).enumerate() {
496                    canvas.draw_line(
497                        point(index, window[0]),
498                        point(index + 1, window[1]),
499                        &line_paint,
500                    );
501                }
502
503                metrics.overlay_time = overlay_started.elapsed();
504
505                canvas.restore();
506            }
507            _ => {}
508        }
509    }
510}
511
512/// Rounds up to a human-friendly axis ceiling (1/2/5 times a power of ten).
513fn nice_scale_max(value: f32) -> f32 {
514    if value <= 0.0 {
515        return 1.0;
516    }
517    let magnitude = 10f32.powf(value.log10().floor());
518    let fraction = value / magnitude;
519    let nice_fraction = if fraction <= 1.0 {
520        1.0
521    } else if fraction <= 2.0 {
522        2.0
523    } else if fraction <= 5.0 {
524        5.0
525    } else {
526        10.0
527    };
528    nice_fraction * magnitude
529}
530
531fn draw_axis_label(canvas: &Canvas, font_collection: &FontCollection, text: &str, x: f32, y: f32) {
532    let mut paragraph_builder = ParagraphBuilder::new(&ParagraphStyle::default(), font_collection);
533    let mut text_style = TextStyle::default();
534    text_style.set_color(Color::from_rgb(170, 170, 170));
535    text_style.set_font_size(10.0);
536    paragraph_builder.push_style(&text_style);
537    paragraph_builder.add_text(text);
538    let mut paragraph = paragraph_builder.build();
539    paragraph.layout(90.0);
540    paragraph.paint(canvas, (x, y));
541}
542
543fn add_text(paragraph_builder: &mut ParagraphBuilder, text: String, font_size: f32) {
544    let mut text_style = TextStyle::default();
545    text_style.set_color(Color::from_rgb(255, 204, 92));
546    let font_style = FontStyle::new(Weight::BOLD, Width::NORMAL, Slant::Upright);
547    text_style.set_font_style(font_style);
548    text_style.set_font_size(font_size);
549    paragraph_builder.push_style(&text_style);
550    paragraph_builder.add_text(text);
551}