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                window,
161                key,
162                modifiers,
163                is_pressed,
164                ..
165            } => {
166                let (shortcut_key, shortcut_modifiers) = &self.toggle_shortcut;
167                let key_matches = match (key, shortcut_key) {
168                    (Key::Character(a), Key::Character(b)) => a.eq_ignore_ascii_case(b),
169                    (a, b) => a == b,
170                };
171                if *is_pressed && *modifiers == *shortcut_modifiers && key_matches {
172                    self.enabled = !self.enabled;
173                    handle.send_event_loop_event(NativeEvent::Window(NativeWindowEvent {
174                        window_id: window.id(),
175                        action: NativeWindowEventAction::User(UserEvent::RequestRedraw),
176                    }));
177                }
178            }
179            PluginEvent::WindowCreated {
180                window,
181                graphics_driver,
182                gpu_name,
183                ..
184            }
185            | PluginEvent::GraphicsDriverChanged {
186                window,
187                graphics_driver,
188                gpu_name,
189            } => {
190                let metrics = self.get_metrics(window.id());
191                metrics.graphics_driver = graphics_driver;
192                metrics.gpu_name = gpu_name.map(str::to_string);
193            }
194            PluginEvent::AfterRedraw { window, .. } => {
195                let metrics = self.get_metrics(window.id());
196                let now = Instant::now();
197
198                metrics.record_frame_time();
199
200                metrics
201                    .frames
202                    .retain(|frame| now.duration_since(*frame).as_millis() < 1000);
203
204                metrics.frames.push(now);
205
206                // Accumulated across the frame, so they need a reset
207                metrics.tasks_poll_time = Duration::ZERO;
208                metrics.events_time = Duration::ZERO;
209                metrics.finished_layout = None;
210            }
211            PluginEvent::BeforePresenting { window, .. } => {
212                self.get_metrics(window.id()).started_presenting = Some(Instant::now())
213            }
214            PluginEvent::AfterPresenting { window, .. } => {
215                let metrics = self.get_metrics(window.id());
216                metrics.finished_presenting = Some(metrics.started_presenting.unwrap().elapsed())
217            }
218            PluginEvent::StartedMeasuringLayout { window, .. } => {
219                let metrics = self.get_metrics(window.id());
220                metrics.started_redraw.get_or_insert(Instant::now());
221                metrics.started_layout = Some(Instant::now());
222            }
223            PluginEvent::FinishedMeasuringLayout { window, .. } => {
224                let metrics = self.get_metrics(window.id());
225                metrics.finished_layout = Some(metrics.started_layout.unwrap().elapsed())
226            }
227            PluginEvent::StartedUpdatingTree { window, .. } => {
228                self.get_metrics(window.id()).started_tree_updates = Some(Instant::now())
229            }
230            PluginEvent::FinishedUpdatingTree { window, .. } => {
231                let metrics = self.get_metrics(window.id());
232                metrics.finished_tree_updates =
233                    Some(metrics.started_tree_updates.unwrap().elapsed())
234            }
235            PluginEvent::StartedPollingTasks { window, .. } => {
236                self.get_metrics(window.id()).started_tasks_poll = Some(Instant::now())
237            }
238            PluginEvent::FinishedPollingTasks { window, .. } => {
239                let metrics = self.get_metrics(window.id());
240                if let Some(started) = metrics.started_tasks_poll.take() {
241                    metrics.tasks_poll_time += started.elapsed();
242                }
243                if self.enabled {
244                    handle.send_event_loop_event(NativeEvent::Window(NativeWindowEvent {
245                        window_id: window.id(),
246                        action: NativeWindowEventAction::User(UserEvent::RequestRedraw),
247                    }));
248                }
249            }
250            PluginEvent::StartedMeasuringEvents { window, .. } => {
251                self.get_metrics(window.id()).started_events = Some(Instant::now())
252            }
253            PluginEvent::FinishedMeasuringEvents { window, .. } => {
254                let metrics = self.get_metrics(window.id());
255                if let Some(started) = metrics.started_events.take() {
256                    metrics.events_time += started.elapsed();
257                }
258                if self.enabled {
259                    handle.send_event_loop_event(NativeEvent::Window(NativeWindowEvent {
260                        window_id: window.id(),
261                        action: NativeWindowEventAction::User(UserEvent::RequestRedraw),
262                    }));
263                }
264            }
265            PluginEvent::BeforeAccessibility { window, .. } => {
266                self.get_metrics(window.id()).started_accessibility_updates = Some(Instant::now())
267            }
268            PluginEvent::AfterAccessibility { window, .. } => {
269                let metrics = self.get_metrics(window.id());
270                metrics.finished_accessibility_updates =
271                    Some(metrics.started_accessibility_updates.unwrap().elapsed())
272            }
273            PluginEvent::BeforeRender { window, .. } => {
274                let metrics = self.get_metrics(window.id());
275                metrics.started_redraw.get_or_insert(Instant::now());
276                metrics.started_render = Some(Instant::now());
277            }
278            PluginEvent::AfterRender {
279                window,
280                canvas,
281                font_collection,
282                tree,
283                animation_clock,
284            } => {
285                if !self.enabled {
286                    return;
287                }
288                let metrics = self.get_metrics(window.id());
289                let scale_factor = window.scale_factor() as f32;
290                let started_render = metrics.started_render.take().unwrap();
291
292                canvas.save();
293                canvas.scale((scale_factor, scale_factor));
294
295                let finished_render = started_render.elapsed();
296                let finished_presenting = metrics.finished_presenting.unwrap_or_default();
297                let finished_layout = metrics.finished_layout.unwrap_or_default();
298                let finished_tree_updates = metrics.finished_tree_updates.unwrap_or_default();
299                let tasks_poll_time = metrics.tasks_poll_time;
300                let events_time = metrics.events_time;
301                let finished_accessibility_updates =
302                    metrics.finished_accessibility_updates.unwrap_or_default();
303                let overlay_time = metrics.overlay_time;
304                let overlay_started = Instant::now();
305
306                let mut fps_paragraph_builder =
307                    ParagraphBuilder::new(&ParagraphStyle::default(), *font_collection);
308                add_text(
309                    &mut fps_paragraph_builder,
310                    format!("{} FPS", metrics.frames.len()),
311                    24.0,
312                );
313                let mut fps_paragraph = fps_paragraph_builder.build();
314                fps_paragraph.layout(235.0);
315
316                let rows = [
317                    (
318                        "Rendering",
319                        format!("{:.3}ms", finished_render.as_secs_f64() * 1000.0),
320                    ),
321                    (
322                        "Presenting",
323                        format!("{:.3}ms", finished_presenting.as_secs_f64() * 1000.0),
324                    ),
325                    (
326                        "Layout",
327                        format!("{:.3}ms", finished_layout.as_secs_f64() * 1000.0),
328                    ),
329                    (
330                        "Tree Updates",
331                        format!("{:.3}ms", finished_tree_updates.as_secs_f64() * 1000.0),
332                    ),
333                    (
334                        "a11y Updates",
335                        format!(
336                            "{:.3}ms",
337                            finished_accessibility_updates.as_secs_f64() * 1000.0
338                        ),
339                    ),
340                    (
341                        "Tasks",
342                        format!("{:.3}ms", tasks_poll_time.as_secs_f64() * 1000.0),
343                    ),
344                    (
345                        "Events",
346                        format!("{:.3}ms", events_time.as_secs_f64() * 1000.0),
347                    ),
348                    (
349                        "Overlay",
350                        format!("{:.3}ms", overlay_time.as_secs_f64() * 1000.0),
351                    ),
352                    (
353                        "Frame",
354                        format!(
355                            "{:.3}ms",
356                            (finished_render
357                                + finished_presenting
358                                + finished_layout
359                                + finished_tree_updates
360                                + tasks_poll_time
361                                + events_time
362                                + finished_accessibility_updates
363                                + overlay_time)
364                                .as_secs_f64()
365                                * 1000.0
366                        ),
367                    ),
368                    ("Tree Nodes", tree.size().to_string()),
369                    ("Layout Nodes", tree.layout.size().to_string()),
370                    ("Scale Factor", format!("{}x", window.scale_factor())),
371                    (
372                        "Animation clock speed",
373                        format!("{}x", animation_clock.speed()),
374                    ),
375                    ("Renderer", metrics.graphics_driver.to_string()),
376                    ("Freya", env!("CARGO_PKG_VERSION").to_string()),
377                    (
378                        "Build",
379                        (if cfg!(debug_assertions) {
380                            "Debug"
381                        } else {
382                            "Release"
383                        })
384                        .to_string(),
385                    ),
386                ];
387
388                let mut keys_paragraph_builder =
389                    ParagraphBuilder::new(&ParagraphStyle::default(), *font_collection);
390                let mut values_style = ParagraphStyle::default();
391                values_style.set_text_align(TextAlign::Right);
392                let mut values_paragraph_builder =
393                    ParagraphBuilder::new(&values_style, *font_collection);
394                for (key, value) in &rows {
395                    add_text(&mut keys_paragraph_builder, format!("{key}\n"), 14.0);
396                    add_text(&mut values_paragraph_builder, format!("{value}\n"), 14.0);
397                }
398                let mut keys_paragraph = keys_paragraph_builder.build();
399                keys_paragraph.layout(235.0);
400                let mut values_paragraph = values_paragraph_builder.build();
401                values_paragraph.layout(235.0);
402
403                let gpu_paragraph = metrics.gpu_name.as_ref().map(|gpu_name| {
404                    let mut builder =
405                        ParagraphBuilder::new(&ParagraphStyle::default(), *font_collection);
406                    add_text(&mut builder, format!("GPU: {gpu_name}"), 14.0);
407                    let mut paragraph = builder.build();
408                    paragraph.layout(235.0);
409                    paragraph
410                });
411
412                let graph_left = 40.0;
413                let graph_top = fps_paragraph.height() + 10.0;
414                let graph_bottom = graph_top + 60.0;
415
416                let rows_top = graph_bottom + 22.0;
417                let gpu_top = rows_top + keys_paragraph.height() + 4.0;
418                let content_bottom = gpu_paragraph
419                    .as_ref()
420                    .map(|paragraph| gpu_top + paragraph.height())
421                    .unwrap_or(rows_top + keys_paragraph.height());
422
423                let mut paint = Paint::default();
424                paint.set_anti_alias(true);
425                paint.set_style(PaintStyle::Fill);
426                paint.set_color(Color::from_argb(235, 24, 24, 24));
427                canvas.draw_rect(Rect::new(5., 5., 245.0, content_bottom + 10.0), &paint);
428
429                fps_paragraph.paint(canvas, (5.0, 0.0));
430                keys_paragraph.paint(canvas, (5.0, rows_top));
431                values_paragraph.paint(canvas, (5.0, rows_top));
432                if let Some(paragraph) = &gpu_paragraph {
433                    paragraph.paint(canvas, (5.0, gpu_top));
434                }
435
436                let scale_max = metrics.graph_scale_max();
437
438                let mut axis_paint = Paint::default();
439                axis_paint.set_anti_alias(true);
440                axis_paint.set_style(PaintStyle::Stroke);
441                axis_paint.set_stroke_width(1.0);
442                axis_paint.set_color(Color::from_rgb(130, 130, 130));
443
444                canvas.draw_line(
445                    (graph_left, graph_top),
446                    (graph_left, graph_bottom),
447                    &axis_paint,
448                );
449                canvas.draw_line(
450                    (graph_left, graph_bottom),
451                    (graph_left + 195.0, graph_bottom),
452                    &axis_paint,
453                );
454
455                let decimals = if scale_max < 10.0 { 1 } else { 0 };
456                for (value, y) in [
457                    (scale_max, graph_top),
458                    (scale_max / 2.0, graph_top + 30.0),
459                    (0.0, graph_bottom),
460                ] {
461                    canvas.draw_line((graph_left - 3.0, y), (graph_left, y), &axis_paint);
462                    draw_axis_label(
463                        canvas,
464                        font_collection,
465                        &format!("{value:.decimals$}ms"),
466                        7.0,
467                        y - 6.0,
468                    );
469                }
470
471                draw_axis_label(
472                    canvas,
473                    font_collection,
474                    &format!("last {} frames", metrics.frame_times.len()),
475                    graph_left + 62.5,
476                    graph_bottom + 4.0,
477                );
478
479                let mut line_paint = Paint::default();
480                line_paint.set_anti_alias(true);
481                line_paint.set_style(PaintStyle::Stroke);
482                line_paint.set_stroke_width(1.5);
483                line_paint.set_color(Color::from_rgb(255, 204, 92));
484
485                let step = 195.0 / (FRAME_TIME_SAMPLES - 1) as f32;
486                let point = |index: usize, frame_time: f32| {
487                    let x = graph_left + index as f32 * step;
488                    let y = graph_bottom - (frame_time / scale_max).min(1.0) * 60.0;
489                    (x, y)
490                };
491                for (index, window) in metrics.frame_times.windows(2).enumerate() {
492                    canvas.draw_line(
493                        point(index, window[0]),
494                        point(index + 1, window[1]),
495                        &line_paint,
496                    );
497                }
498
499                metrics.overlay_time = overlay_started.elapsed();
500
501                canvas.restore();
502            }
503            _ => {}
504        }
505    }
506}
507
508/// Rounds up to a human-friendly axis ceiling (1/2/5 times a power of ten).
509fn nice_scale_max(value: f32) -> f32 {
510    if value <= 0.0 {
511        return 1.0;
512    }
513    let magnitude = 10f32.powf(value.log10().floor());
514    let fraction = value / magnitude;
515    let nice_fraction = if fraction <= 1.0 {
516        1.0
517    } else if fraction <= 2.0 {
518        2.0
519    } else if fraction <= 5.0 {
520        5.0
521    } else {
522        10.0
523    };
524    nice_fraction * magnitude
525}
526
527fn draw_axis_label(canvas: &Canvas, font_collection: &FontCollection, text: &str, x: f32, y: f32) {
528    let mut paragraph_builder = ParagraphBuilder::new(&ParagraphStyle::default(), font_collection);
529    let mut text_style = TextStyle::default();
530    text_style.set_color(Color::from_rgb(170, 170, 170));
531    text_style.set_font_size(10.0);
532    paragraph_builder.push_style(&text_style);
533    paragraph_builder.add_text(text);
534    let mut paragraph = paragraph_builder.build();
535    paragraph.layout(90.0);
536    paragraph.paint(canvas, (x, y));
537}
538
539fn add_text(paragraph_builder: &mut ParagraphBuilder, text: String, font_size: f32) {
540    let mut text_style = TextStyle::default();
541    text_style.set_color(Color::from_rgb(255, 204, 92));
542    let font_style = FontStyle::new(Weight::BOLD, Width::NORMAL, Slant::Upright);
543    text_style.set_font_style(font_style);
544    text_style.set_font_size(font_size);
545    paragraph_builder.push_style(&text_style);
546    paragraph_builder.add_text(text);
547}