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