Skip to main content

ranim/cmd/preview/
profiler.rs

1//! In-app profiler panel for the preview app (CPU + GPU + uploads).
2//!
3//! The main view is a **progress chart**: the X axis is scene progress
4//! (0..total_sec, one bucket per logic frame) and every rendered frame
5//! writes its sample into the bucket at the current timeline position —
6//! playing, seeking or dragging the chart progressively fills (and on
7//! revisit, refreshes) the samples, so performance variation across the
8//! animation becomes visible. The chart can be clicked/dragged to seek.
9//!
10//! CPU spans ([`crate::render::cpu_probe`]) and upload stats
11//! ([`crate::render::upload_probe`]) are available in the native preview;
12//! GPU pass scopes are enabled at runtime on devices with timestamp query
13//! support. Upload strategy modes can be switched live.
14
15use eframe::egui;
16
17use super::RanimPreviewApp;
18use crate::render::RenderAppExt;
19use crate::render::upload_probe::UploadMode;
20
21/// One sampled rendering of the scene at a specific timeline position.
22#[derive(Clone, Default)]
23pub(crate) struct ProgressSample {
24    pub gpu_total_us: f64,
25    /// Per-pass GPU times in μs (render order).
26    pub gpu_passes: Vec<(String, f64)>,
27    /// CPU span total in ms (leaf-level spans, no double counting).
28    pub cpu_total_ms: f64,
29    /// CPU spans in ms (leaf-level, e.g. eval / prepare_vitems / render_graph).
30    pub cpu_spans: Vec<(String, f64)>,
31    pub render_ms: f64,
32    pub eval_ms: f64,
33    pub upload_total_kib: f64,
34    pub upload_written_kib: f64,
35}
36
37/// Metric drawn in the progress chart. Stackable metrics (`GpuPasses`,
38/// `CpuSpans`) render as a stacked color chart when the panel's
39/// `profiler_stacked` toggle is on, and as a plain total otherwise.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub(crate) enum ProfilerMetric {
42    #[default]
43    GpuPasses,
44    CpuSpans,
45    UploadWrittenKiB,
46    UploadTotalKiB,
47    RenderMs,
48    EvalMs,
49}
50
51impl ProfilerMetric {
52    fn label(self) -> &'static str {
53        match self {
54            ProfilerMetric::GpuPasses => "GPU passes",
55            ProfilerMetric::CpuSpans => "CPU spans",
56            ProfilerMetric::UploadWrittenKiB => "Upload written (KiB)",
57            ProfilerMetric::UploadTotalKiB => "Upload total (KiB)",
58            ProfilerMetric::RenderMs => "Render (ms)",
59            ProfilerMetric::EvalMs => "Eval (ms)",
60        }
61    }
62
63    /// Whether the stacked view applies to this metric.
64    fn stackable(self) -> bool {
65        matches!(self, ProfilerMetric::GpuPasses | ProfilerMetric::CpuSpans)
66    }
67
68    fn value(self, s: &ProgressSample) -> f64 {
69        match self {
70            ProfilerMetric::GpuPasses => s.gpu_total_us,
71            ProfilerMetric::CpuSpans => s.cpu_total_ms,
72            ProfilerMetric::UploadWrittenKiB => s.upload_written_kib,
73            ProfilerMetric::UploadTotalKiB => s.upload_total_kib,
74            ProfilerMetric::RenderMs => s.render_ms,
75            ProfilerMetric::EvalMs => s.eval_ms,
76        }
77    }
78
79    fn unit(self) -> &'static str {
80        match self {
81            ProfilerMetric::GpuPasses => "μs",
82            ProfilerMetric::CpuSpans => "ms",
83            ProfilerMetric::UploadWrittenKiB | ProfilerMetric::UploadTotalKiB => "KiB",
84            ProfilerMetric::RenderMs | ProfilerMetric::EvalMs => "ms",
85        }
86    }
87
88    /// The stacked span list this metric draws, if any.
89    fn stacked_spans(self, s: &ProgressSample) -> Option<&[(String, f64)]> {
90        match self {
91            ProfilerMetric::GpuPasses => (!s.gpu_passes.is_empty()).then_some(&s.gpu_passes),
92            ProfilerMetric::CpuSpans => (!s.cpu_spans.is_empty()).then_some(&s.cpu_spans),
93            _ => None,
94        }
95    }
96}
97
98/// Record the just-rendered frame into the progress-sample bucket at the
99/// current timeline position. Called from `render_animation`.
100pub(crate) fn record_sample(app: &mut RanimPreviewApp) {
101    let total_sec = app.timeline_state.total_sec;
102    if total_sec <= 0.0 {
103        return;
104    }
105    let bucket_count = (total_sec * super::DEFAULT_LOGIC_FPS).ceil() as usize;
106    if app.progress_total_sec != total_sec || app.progress_samples.len() != bucket_count {
107        app.progress_samples = vec![None; bucket_count];
108        app.progress_total_sec = total_sec;
109    }
110    let idx = ((app.timeline_state.current_sec / total_sec) * bucket_count as f64) as usize;
111    let Some(slot) = app.progress_samples.get_mut(idx.min(bucket_count - 1)) else {
112        return;
113    };
114    *slot = Some(ProgressSample {
115        gpu_total_us: app.gpu_pass_times.iter().map(|&(_, t)| t).sum(),
116        gpu_passes: app.gpu_pass_times.clone(),
117        cpu_total_ms: app.cpu_spans.iter().map(|&(_, t)| t).sum(),
118        cpu_spans: app.cpu_spans.clone(),
119        render_ms: app
120            .last_render_time
121            .map(|d| d.as_secs_f64() * 1e3)
122            .unwrap_or(0.0),
123        eval_ms: app
124            .last_eval_time
125            .map(|d| d.as_secs_f64() * 1e3)
126            .unwrap_or(0.0),
127        upload_total_kib: app
128            .upload_stats
129            .values()
130            .map(|s| s.bytes as f64 / 1024.0)
131            .sum(),
132        upload_written_kib: app
133            .upload_stats
134            .values()
135            .map(|s| s.written_bytes as f64 / 1024.0)
136            .sum(),
137    });
138}
139
140/// GPU timer features requested from eframe's device so wgpu-profiler
141/// scopes produce results (intersected with adapter support at the call
142/// site, so device creation can never fail because of them).
143pub(crate) fn gpu_timer_features() -> wgpu::Features {
144    wgpu::Features::TIMESTAMP_QUERY
145        | wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS
146        | wgpu::Features::TIMESTAMP_QUERY_INSIDE_PASSES
147}
148
149/// Flatten a wgpu-profiler scope tree into `(label, μs)` pairs.
150pub(crate) fn flatten_scopes(
151    scopes: &[wgpu_profiler::GpuTimerQueryResult],
152    out: &mut Vec<(String, f64)>,
153) {
154    for scope in scopes {
155        if let Some(time) = &scope.time {
156            out.push((scope.label.clone(), (time.end - time.start) * 1e6));
157        }
158        flatten_scopes(&scope.nested_queries, out);
159    }
160}
161
162pub(crate) fn ui_profiler_window(app: &mut RanimPreviewApp, ctx: &egui::Context) {
163    let mut open = app.profiler_open;
164    egui::Window::new("GPU Profiler")
165        .open(&mut open)
166        .default_size([460.0, 560.0])
167        .show(ctx, |ui| {
168            ui_summary(app, ui);
169            ui_progress_chart(app, ui);
170            ui_cpu_spans(app, ui);
171            ui_gpu_passes(app, ui);
172            ui_uploads(app, ui);
173        });
174    app.profiler_open = open;
175}
176
177fn ui_summary(app: &mut RanimPreviewApp, ui: &mut egui::Ui) {
178    ui.horizontal(|ui| {
179        if let Some(d) = app.last_render_time {
180            ui.label(format!("Render: {:.2} ms", d.as_secs_f64() * 1e3));
181        }
182        if let Some(d) = app.last_eval_time {
183            ui.label(format!("Eval: {:.2} ms", d.as_secs_f64() * 1e3));
184        }
185        if !app.gpu_pass_times.is_empty() {
186            let total: f64 = app.gpu_pass_times.iter().map(|&(_, t)| t).sum();
187            ui.strong(format!("GPU total: {total:.0} μs"));
188        }
189        if !app.cpu_spans.is_empty() {
190            let total: f64 = app.cpu_spans.iter().map(|&(_, t)| t).sum();
191            ui.strong(format!("CPU total: {total:.2} ms"));
192        }
193    });
194}
195
196/// X axis = scene progress; click/drag to seek, hover for the sample values.
197fn ui_progress_chart(app: &mut RanimPreviewApp, ui: &mut egui::Ui) {
198    ui.add_space(4.0);
199    ui.horizontal(|ui| {
200        ui.label("metric:");
201        let metric = app.profiler_metric;
202        egui::ComboBox::from_id_salt("profiler_metric")
203            .selected_text(metric.label())
204            .show_ui(ui, |ui| {
205                for m in [
206                    ProfilerMetric::GpuPasses,
207                    ProfilerMetric::CpuSpans,
208                    ProfilerMetric::UploadWrittenKiB,
209                    ProfilerMetric::UploadTotalKiB,
210                    ProfilerMetric::RenderMs,
211                    ProfilerMetric::EvalMs,
212                ] {
213                    ui.selectable_value(&mut app.profiler_metric, m, m.label());
214                }
215            });
216        ui.add_enabled(
217            metric.stackable(),
218            egui::Checkbox::new(&mut app.profiler_stacked, "stacked"),
219        );
220        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
221            ui.weak("click / drag to seek");
222        });
223    });
224
225    let height = 96.0;
226    let (rect, response) = ui.allocate_exact_size(
227        egui::vec2(ui.available_width(), height),
228        egui::Sense::click_and_drag(),
229    );
230    let painter = ui.painter_at(rect);
231    painter.rect_filled(rect, 2.0, ui.visuals().faint_bg_color);
232
233    let total_sec = app.timeline_state.total_sec.max(1e-9);
234    let samples = &app.progress_samples;
235    let n = samples.len();
236    if n == 0 {
237        painter.text(
238            rect.center(),
239            egui::Align2::CENTER_CENTER,
240            "no samples yet — play or scrub the timeline",
241            egui::FontId::proportional(12.0),
242            ui.visuals().weak_text_color(),
243        );
244        return;
245    }
246
247    let metric = app.profiler_metric;
248    let y_max = samples
249        .iter()
250        .flatten()
251        .map(|s| metric.value(s))
252        .fold(0.0f64, f64::max)
253        .max(1e-9);
254
255    // Pass/span order and colors come from the latest sample (stable set).
256    let stacked = metric.stackable() && app.profiler_stacked;
257    let stacked_labels: Vec<String> = match metric {
258        ProfilerMetric::GpuPasses => app.gpu_pass_times.iter().map(|(l, _)| l.clone()).collect(),
259        ProfilerMetric::CpuSpans => app.cpu_spans.iter().map(|(l, _)| l.clone()).collect(),
260        _ => Vec::new(),
261    };
262
263    for (i, slot) in samples.iter().enumerate() {
264        let Some(s) = slot else { continue };
265        let x0 = egui::lerp(rect.left()..=rect.right(), i as f32 / n as f32);
266        let x1 = egui::lerp(rect.left()..=rect.right(), (i + 1) as f32 / n as f32);
267        let w = (x1 - x0).max(1.0);
268        if stacked && let Some(spans) = metric.stacked_spans(s) {
269            let mut y_base = rect.bottom();
270            for (label, v) in spans {
271                let h = ((v / y_max) * rect.height() as f64) as f32;
272                let h = h.min(rect.height());
273                let seg = egui::Rect::from_min_max(
274                    egui::pos2(x0, (y_base - h).max(rect.top())),
275                    egui::pos2(x0 + w, y_base),
276                );
277                painter.rect_filled(seg, 0.0, pass_color(&stacked_labels, label));
278                y_base = (y_base - h).max(rect.top());
279            }
280        } else {
281            let h = ((metric.value(s) / y_max) * rect.height() as f64) as f32;
282            let h = h.clamp(0.0, rect.height());
283            painter.rect_filled(
284                egui::Rect::from_min_max(
285                    egui::pos2(x0, rect.bottom() - h),
286                    egui::pos2(x0 + w, rect.bottom()),
287                ),
288                0.0,
289                ui.visuals().selection.bg_fill,
290            );
291        }
292    }
293
294    // Playhead at the current timeline position.
295    let ph_x = egui::lerp(
296        rect.left()..=rect.right(),
297        (app.timeline_state.current_sec / total_sec) as f32,
298    );
299    painter.line_segment(
300        [
301            egui::pos2(ph_x, rect.top()),
302            egui::pos2(ph_x, rect.bottom()),
303        ],
304        egui::Stroke::new(1.5, ui.visuals().strong_text_color()),
305    );
306
307    // Axis annotations.
308    painter.text(
309        rect.left_top() + egui::vec2(4.0, 2.0),
310        egui::Align2::LEFT_TOP,
311        format!("max {:.0} {}", y_max, metric.unit()),
312        egui::FontId::proportional(10.0),
313        ui.visuals().weak_text_color(),
314    );
315    painter.text(
316        rect.left_bottom() + egui::vec2(4.0, -2.0),
317        egui::Align2::LEFT_BOTTOM,
318        "0s",
319        egui::FontId::proportional(10.0),
320        ui.visuals().weak_text_color(),
321    );
322    painter.text(
323        rect.right_bottom() + egui::vec2(-4.0, -2.0),
324        egui::Align2::RIGHT_BOTTOM,
325        format!("{total_sec:.1}s"),
326        egui::FontId::proportional(10.0),
327        ui.visuals().weak_text_color(),
328    );
329
330    // Click / drag to seek (same semantics as the timeline slider).
331    if (response.clicked() || response.dragged())
332        && let Some(pos) = response.interact_pointer_pos()
333        && let Some(sec) = x_to_sec(rect, pos.x, total_sec)
334    {
335        app.timeline_state.current_sec = sec.clamp(0.0, app.timeline_state.total_sec);
336        app.need_eval = true;
337    }
338
339    // Hover tooltip with the sample under the pointer.
340    if let Some(pos) = response.hover_pos()
341        && let Some(sec) = x_to_sec(rect, pos.x, total_sec)
342        && let Some(Some(s)) = samples.get((((sec / total_sec) * n as f64) as usize).min(n - 1))
343    {
344        let mut text = format!(
345            "t = {:.3}s\nGPU total: {:.0} μs | CPU total: {:.2} ms\nrender {:.2} ms | eval {:.2} ms\nupload: {:.1} KiB written / {:.1} KiB total",
346            sec,
347            s.gpu_total_us,
348            s.cpu_total_ms,
349            s.render_ms,
350            s.eval_ms,
351            s.upload_written_kib,
352            s.upload_total_kib
353        );
354        for (label, us) in s.gpu_passes.iter().take(4) {
355            text.push_str(&format!("\n  {label}: {us:.0} μs"));
356        }
357        for (label, ms) in s.cpu_spans.iter().take(4) {
358            text.push_str(&format!("\n  {label}: {ms:.2} ms"));
359        }
360        response.on_hover_text(text);
361    }
362}
363
364fn x_to_sec(rect: egui::Rect, x: f32, total_sec: f64) -> Option<f64> {
365    if x < rect.left() {
366        return None;
367    }
368    let x = x.min(rect.right());
369    let t = (x - rect.left()) / rect.width();
370    Some((t as f64).clamp(0.0, 1.0) * total_sec)
371}
372
373/// Fixed palette for per-pass stacked bars, keyed by pass-label order.
374const PASS_PALETTE: [egui::Color32; 8] = [
375    egui::Color32::from_rgb(0x4C, 0x78, 0xE8), // blue
376    egui::Color32::from_rgb(0xF2, 0x8E, 0x2B), // orange
377    egui::Color32::from_rgb(0x54, 0xA2, 0x4F), // green
378    egui::Color32::from_rgb(0xE6, 0x4F, 0x59), // red
379    egui::Color32::from_rgb(0xB0, 0x79, 0xD1), // purple
380    egui::Color32::from_rgb(0xDD, 0xCA, 0x3A), // yellow
381    egui::Color32::from_rgb(0x76, 0xB7, 0xB2), // teal
382    egui::Color32::from_rgb(0xFF, 0x9D, 0xA7), // pink
383];
384
385fn pass_color(pass_labels: &[String], label: &str) -> egui::Color32 {
386    let idx = pass_labels
387        .iter()
388        .position(|l| l == label)
389        .unwrap_or(usize::MAX);
390    PASS_PALETTE[idx % PASS_PALETTE.len()]
391}
392
393/// Legend for the stacked pass/span colors.
394fn ui_stacked_legend(spans: &[(String, f64)], ui: &mut egui::Ui) {
395    if spans.is_empty() {
396        return;
397    }
398    let labels: Vec<String> = spans.iter().map(|(l, _)| l.clone()).collect();
399    ui.horizontal_wrapped(|ui| {
400        for (label, _) in spans {
401            let color = pass_color(&labels, label);
402            let (rect, _) = ui.allocate_exact_size(egui::vec2(8.0, 8.0), egui::Sense::hover());
403            ui.painter().rect_filled(rect, 1.0, color);
404            ui.label(egui::RichText::new(label).small());
405        }
406    });
407}
408
409fn ui_cpu_spans(app: &mut RanimPreviewApp, ui: &mut egui::Ui) {
410    ui.add_space(4.0);
411    if app.profiler_metric == ProfilerMetric::CpuSpans && app.profiler_stacked {
412        ui_stacked_legend(&app.cpu_spans, ui);
413    }
414    ui.heading("CPU spans");
415    if app.cpu_spans.is_empty() {
416        ui.label(egui::RichText::new("no spans recorded yet — play the animation").weak());
417        return;
418    }
419    let total: f64 = app.cpu_spans.iter().map(|&(_, t)| t).sum();
420    egui::Grid::new("cpu_span_grid")
421        .num_columns(3)
422        .striped(true)
423        .show(ui, |ui| {
424            ui.strong("span");
425            ui.strong("time");
426            ui.strong("share");
427            ui.end_row();
428            for (label, ms) in &app.cpu_spans {
429                ui.label(label);
430                ui.label(format!("{ms:.2} ms"));
431                let share = if total > 0.0 { *ms / total } else { 0.0 };
432                share_bar(ui, share as f32);
433                ui.end_row();
434            }
435            ui.strong("total");
436            ui.strong(format!("{total:.2} ms"));
437            ui.label("");
438            ui.end_row();
439        });
440}
441
442fn ui_gpu_passes(app: &mut RanimPreviewApp, ui: &mut egui::Ui) {
443    ui.add_space(4.0);
444    if app.profiler_metric == ProfilerMetric::GpuPasses && app.profiler_stacked {
445        ui_stacked_legend(&app.gpu_pass_times, ui);
446    }
447    ui.horizontal(|ui| {
448        ui.heading("GPU passes");
449        let supported = app
450            .render_app()
451            .as_ref()
452            .is_some_and(|r| r.gpu_timers_supported());
453        if supported {
454            let mut enabled = app
455                .render_app()
456                .as_ref()
457                .is_some_and(|r| r.gpu_timers_enabled());
458            if ui.checkbox(&mut enabled, "GPU timers").changed()
459                && let Some(r) = app.render_app_mut()
460            {
461                r.set_gpu_timers_enabled(enabled);
462            }
463        }
464    });
465    if !app
466        .render_app()
467        .as_ref()
468        .is_some_and(|r| r.gpu_timers_supported())
469    {
470        ui.label(egui::RichText::new("GPU timers unsupported on this device").weak());
471        return;
472    }
473    if app.gpu_pass_times.is_empty() {
474        ui.label(
475            egui::RichText::new(
476                "GPU timers are off or no frame was rendered yet — enable them \
477                 above and play the animation (note: while enabled, each frame \
478                 pays a device poll)",
479            )
480            .weak(),
481        );
482        return;
483    }
484    let total: f64 = app.gpu_pass_times.iter().map(|&(_, t)| t).sum();
485    egui::Grid::new("gpu_pass_grid")
486        .num_columns(3)
487        .striped(true)
488        .show(ui, |ui| {
489            ui.strong("pass");
490            ui.strong("time");
491            ui.strong("share");
492            ui.end_row();
493            for (label, us) in &app.gpu_pass_times {
494                ui.label(label);
495                ui.label(format!("{us:.1} μs"));
496                let share = if total > 0.0 { *us / total } else { 0.0 };
497                share_bar(ui, share as f32);
498                ui.end_row();
499            }
500            ui.strong("total");
501            ui.strong(format!("{total:.1} μs"));
502            ui.label("");
503            ui.end_row();
504        });
505}
506
507fn ui_uploads(app: &mut RanimPreviewApp, ui: &mut egui::Ui) {
508    ui.add_space(8.0);
509    ui.horizontal(|ui| {
510        ui.heading("Buffer uploads");
511        let mut track = crate::render::upload_probe::mode().enabled();
512        if ui.checkbox(&mut track, "Track uploads").changed() {
513            crate::render::upload_probe::set_mode(if track {
514                UploadMode::Count
515            } else {
516                UploadMode::Off
517            });
518        }
519    });
520
521    if app.upload_stats.is_empty() {
522        if crate::render::upload_probe::mode().enabled() {
523            ui.label(
524                egui::RichText::new("waiting for a rendered frame (play or scrub the timeline)")
525                    .weak(),
526            );
527        }
528        return;
529    }
530
531    let (mut calls, mut bytes, mut written, mut cpu_ns) = (0u64, 0u64, 0u64, 0u128);
532    egui::Grid::new("upload_stats_grid")
533        .num_columns(5)
534        .striped(true)
535        .show(ui, |ui| {
536            ui.strong("buffer");
537            ui.strong("calls");
538            ui.strong("KiB");
539            ui.strong("written");
540            ui.strong("cpu μs");
541            ui.end_row();
542            for (label, s) in &app.upload_stats {
543                calls += s.calls;
544                bytes += s.bytes;
545                written += s.written_bytes;
546                cpu_ns += s.cpu_time.as_nanos();
547                ui.label(*label);
548                ui.label(s.calls.to_string());
549                ui.label(format!("{:.1}", s.bytes as f64 / 1024.0));
550                let pct = if s.bytes > 0 {
551                    100.0 * s.written_bytes as f64 / s.bytes as f64
552                } else {
553                    0.0
554                };
555                ui.label(format!("{pct:.0}%"));
556                ui.label(format!("{:.1}", s.cpu_time.as_secs_f64() * 1e6));
557                ui.end_row();
558            }
559            ui.strong("TOTAL");
560            ui.strong(calls.to_string());
561            ui.strong(format!("{:.1}", bytes as f64 / 1024.0));
562            let pct = if bytes > 0 {
563                100.0 * written as f64 / bytes as f64
564            } else {
565                0.0
566            };
567            ui.strong(format!("{pct:.0}%"));
568            ui.strong(format!("{:.1}", cpu_ns as f64 / 1e3));
569            ui.end_row();
570        });
571    ui.label(
572        egui::RichText::new(
573            "per rendered frame; `written` counts bytes actually handed to \
574             write_buffer (skipped/range uploads excluded)",
575        )
576        .small()
577        .weak(),
578    );
579}
580
581fn share_bar(ui: &mut egui::Ui, frac: f32) {
582    let (rect, _) = ui.allocate_exact_size(
583        egui::vec2(96.0, ui.spacing().interact_size.y),
584        egui::Sense::hover(),
585    );
586    let frac = frac.clamp(0.0, 1.0);
587    ui.painter()
588        .rect_filled(rect, 2.0, ui.visuals().faint_bg_color);
589    ui.painter().rect_filled(
590        egui::Rect::from_min_size(rect.min, egui::vec2(rect.width() * frac, rect.height())),
591        2.0,
592        ui.visuals().selection.bg_fill,
593    );
594    ui.painter().text(
595        rect.center(),
596        egui::Align2::CENTER_CENTER,
597        format!("{:.0}%", frac * 100.0),
598        egui::FontId::proportional(10.0),
599        ui.visuals().text_color(),
600    );
601}