1use eframe::egui;
16
17use super::RanimPreviewApp;
18use crate::render::RenderAppExt;
19use crate::render::upload_probe::UploadMode;
20
21#[derive(Clone, Default)]
23pub(crate) struct ProgressSample {
24 pub gpu_total_us: f64,
25 pub gpu_passes: Vec<(String, f64)>,
27 pub cpu_total_ms: f64,
29 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#[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 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 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
98pub(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
140pub(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
149pub(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
196fn 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 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 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 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 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 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
373const PASS_PALETTE: [egui::Color32; 8] = [
375 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), ];
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
393fn 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}