1use eframe::egui;
16
17use super::RanimPreviewApp;
18use crate::render::upload_probe::UploadMode;
19
20#[derive(Clone, Default)]
22pub(crate) struct ProgressSample {
23 pub gpu_total_us: f64,
24 pub gpu_passes: Vec<(String, f64)>,
26 pub cpu_total_ms: f64,
28 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#[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 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 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
97pub(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
139pub(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
148pub(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
195fn 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 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 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 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 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 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
372const PASS_PALETTE: [egui::Color32; 8] = [
374 egui::Color32::from_rgb(0x4C, 0x78, 0xE8), egui::Color32::from_rgb(0xF2, 0x8E, 0x2B), egui::Color32::from_rgb(0x54, 0xA2, 0x4F), egui::Color32::from_rgb(0xE6, 0x4F, 0x59), egui::Color32::from_rgb(0xB0, 0x79, 0xD1), egui::Color32::from_rgb(0xDD, 0xCA, 0x3A), egui::Color32::from_rgb(0x76, 0xB7, 0xB2), egui::Color32::from_rgb(0xFF, 0x9D, 0xA7), ];
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
392fn 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}