Skip to main content

proof_engine/editor/
perf_overlay.rs

1//! Performance Overlay — real-time frame time, particle count, GPU
2//! utilisation, and per-kit cost breakdown for the editor viewport.
3//!
4//! # Metrics collected
5//!
6//! - **Frame time** (ms): full round-trip from CPU submit to present.
7//! - **CPU time** (ms): scene update + command recording only.
8//! - **GPU time** (ms): measured via timer queries when available.
9//! - **FPS**: rolling average over the last N frames.
10//! - **Particle count**: live body + ambient + hair particles this frame.
11//! - **Draw calls**: number of instanced draw calls issued.
12//! - **Kit cost** (µs): per-kit CPU evaluation time measured with
13//!   `Instant::now()` spans.
14//! - **Memory**: approximate VRAM and system RAM usage.
15//! - **Post-FX passes active**: count of enabled post-processing stages.
16//!
17//! # Display
18//!
19//! `PerfOverlay::render_text` produces a multi-line ASCII string suitable for
20//! rendering as glyphs in the editor viewport corner.  The display is
21//! configurable: compact (one-liner), normal (6 lines), or verbose (full
22//! breakdown with kit costs and a mini frame-time graph).
23
24use std::collections::VecDeque;
25use std::time::{Duration, Instant};
26
27// ─────────────────────────────────────────────────────────────────────────────
28// TimeSample
29// ─────────────────────────────────────────────────────────────────────────────
30
31/// A single frame's timing data.
32#[derive(Debug, Clone, Default)]
33pub struct TimeSample {
34    pub frame_ms:   f32,
35    pub cpu_ms:     f32,
36    pub gpu_ms:     f32,
37    pub particle_count: u64,
38    pub draw_calls: u32,
39    pub vram_mb:    f32,
40    pub ram_mb:     f32,
41}
42
43// ─────────────────────────────────────────────────────────────────────────────
44// KitTimings
45// ─────────────────────────────────────────────────────────────────────────────
46
47/// Per-kit CPU cost in microseconds for the last frame.
48#[derive(Debug, Clone, Default)]
49pub struct KitTimings {
50    pub bone_kit:     f32,
51    pub model_kit:    f32,
52    pub material_kit: f32,
53    pub lighting_kit: f32,
54    pub clothing_kit: f32,
55    pub hair_kit:     f32,
56    pub physics_kit:  f32,
57    pub render_kit:   f32,
58    pub sdf_eval:     f32,
59    pub post_fx:      f32,
60    pub scene_update: f32,
61    pub command_rec:  f32,
62}
63
64impl KitTimings {
65    pub fn total_us(&self) -> f32 {
66        self.bone_kit + self.model_kit + self.material_kit + self.lighting_kit +
67        self.clothing_kit + self.hair_kit + self.physics_kit + self.render_kit +
68        self.sdf_eval + self.post_fx + self.scene_update + self.command_rec
69    }
70
71    pub fn pairs(&self) -> Vec<(&'static str, f32)> {
72        vec![
73            ("BoneKit",     self.bone_kit),
74            ("ModelKit",    self.model_kit),
75            ("MaterialKit", self.material_kit),
76            ("LightingKit", self.lighting_kit),
77            ("ClothingKit", self.clothing_kit),
78            ("HairKit",     self.hair_kit),
79            ("PhysicsKit",  self.physics_kit),
80            ("RenderKit",   self.render_kit),
81            ("SDF Eval",    self.sdf_eval),
82            ("Post-FX",     self.post_fx),
83            ("Scene Upd",   self.scene_update),
84            ("Cmd Rec",     self.command_rec),
85        ]
86    }
87}
88
89// ─────────────────────────────────────────────────────────────────────────────
90// RingBuffer — fixed-size circular queue
91// ─────────────────────────────────────────────────────────────────────────────
92
93#[derive(Debug, Clone)]
94pub struct RingBuffer<T> {
95    data:     VecDeque<T>,
96    capacity: usize,
97}
98
99impl<T: Clone + Default> RingBuffer<T> {
100    pub fn new(capacity: usize) -> Self {
101        Self { data: VecDeque::with_capacity(capacity), capacity }
102    }
103
104    pub fn push(&mut self, v: T) {
105        if self.data.len() == self.capacity { self.data.pop_front(); }
106        self.data.push_back(v);
107    }
108
109    pub fn len(&self)   -> usize { self.data.len() }
110    pub fn is_empty(&self) -> bool { self.data.is_empty() }
111    pub fn iter(&self)  -> impl Iterator<Item = &T> { self.data.iter() }
112    pub fn last(&self)  -> Option<&T> { self.data.back() }
113
114    pub fn as_slice(&self) -> Vec<&T> { self.data.iter().collect() }
115}
116
117impl RingBuffer<f32> {
118    pub fn mean(&self) -> f32 {
119        if self.data.is_empty() { return 0.0; }
120        self.data.iter().sum::<f32>() / self.data.len() as f32
121    }
122    pub fn max(&self) -> f32 {
123        self.data.iter().cloned().fold(f32::NEG_INFINITY, f32::max)
124    }
125    pub fn min(&self) -> f32 {
126        self.data.iter().cloned().fold(f32::INFINITY, f32::min)
127    }
128    pub fn p95(&self) -> f32 {
129        if self.data.is_empty() { return 0.0; }
130        let mut sorted: Vec<f32> = self.data.iter().cloned().collect();
131        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
132        let idx = (sorted.len() as f32 * 0.95) as usize;
133        sorted[idx.min(sorted.len() - 1)]
134    }
135}
136
137// ─────────────────────────────────────────────────────────────────────────────
138// PerfOverlayMode
139// ─────────────────────────────────────────────────────────────────────────────
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
142pub enum PerfOverlayMode {
143    Off,
144    Compact,
145    #[default]
146    Normal,
147    Verbose,
148    Graph,
149}
150
151impl PerfOverlayMode {
152    pub fn label(self) -> &'static str {
153        match self {
154            Self::Off     => "Off",
155            Self::Compact => "Compact",
156            Self::Normal  => "Normal",
157            Self::Verbose => "Verbose",
158            Self::Graph   => "Graph",
159        }
160    }
161
162    pub fn cycle(self) -> Self {
163        match self {
164            Self::Off     => Self::Compact,
165            Self::Compact => Self::Normal,
166            Self::Normal  => Self::Verbose,
167            Self::Verbose => Self::Graph,
168            Self::Graph   => Self::Off,
169        }
170    }
171}
172
173// ─────────────────────────────────────────────────────────────────────────────
174// Bottleneck — identifies the current performance bottleneck
175// ─────────────────────────────────────────────────────────────────────────────
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum Bottleneck {
179    None,
180    CpuBound,
181    GpuBound,
182    MemoryBound,
183    ParticleCount,
184    PostFx,
185}
186
187impl Bottleneck {
188    pub fn label(self) -> &'static str {
189        match self {
190            Self::None          => "OK",
191            Self::CpuBound      => "CPU-bound",
192            Self::GpuBound      => "GPU-bound",
193            Self::MemoryBound   => "Mem-bound",
194            Self::ParticleCount => "Particle count",
195            Self::PostFx        => "Post-FX",
196        }
197    }
198}
199
200// ─────────────────────────────────────────────────────────────────────────────
201// PerfThresholds
202// ─────────────────────────────────────────────────────────────────────────────
203
204/// User-configurable warning thresholds for performance metrics.
205#[derive(Debug, Clone)]
206pub struct PerfThresholds {
207    pub warn_frame_ms:   f32,
208    pub error_frame_ms:  f32,
209    pub warn_vram_mb:    f32,
210    pub warn_particles:  u64,
211    pub target_fps:      f32,
212}
213
214impl Default for PerfThresholds {
215    fn default() -> Self {
216        Self {
217            warn_frame_ms:  16.7,   // 60 fps
218            error_frame_ms: 33.3,   // 30 fps
219            warn_vram_mb:   7_000.0,// 7 GB
220            warn_particles: 800_000_000,
221            target_fps:     60.0,
222        }
223    }
224}
225
226// ─────────────────────────────────────────────────────────────────────────────
227// PerfOverlay
228// ─────────────────────────────────────────────────────────────────────────────
229
230/// Full performance overlay state.
231#[derive(Debug)]
232pub struct PerfOverlay {
233    pub mode:         PerfOverlayMode,
234    pub thresholds:   PerfThresholds,
235    frame_times:      RingBuffer<f32>,
236    cpu_times:        RingBuffer<f32>,
237    gpu_times:        RingBuffer<f32>,
238    particle_counts:  RingBuffer<f32>,
239    pub current:      TimeSample,
240    pub kit_timings:  KitTimings,
241    pub post_fx_passes: u32,
242    pub post_fx_names:  Vec<String>,
243    frame_count:      u64,
244    frame_start:      Option<Instant>,
245    /// Accumulated per-kit timing spans for the current frame.
246    kit_spans:        HashMap<&'static str, Instant>,
247}
248
249use std::collections::HashMap;
250
251impl PerfOverlay {
252    pub fn new() -> Self {
253        Self {
254            mode:          PerfOverlayMode::Normal,
255            thresholds:    PerfThresholds::default(),
256            frame_times:   RingBuffer::new(128),
257            cpu_times:     RingBuffer::new(128),
258            gpu_times:     RingBuffer::new(128),
259            particle_counts: RingBuffer::new(128),
260            current:       TimeSample::default(),
261            kit_timings:   KitTimings::default(),
262            post_fx_passes:0,
263            post_fx_names: Vec::new(),
264            frame_count:   0,
265            frame_start:   None,
266            kit_spans:     HashMap::new(),
267        }
268    }
269
270    // ── Frame lifecycle ───────────────────────────────────────────────────
271
272    /// Call at the very start of each frame.
273    pub fn begin_frame(&mut self) {
274        self.frame_start = Some(Instant::now());
275        self.frame_count += 1;
276    }
277
278    /// Call at the end of each frame with measured values.
279    pub fn end_frame(&mut self, gpu_ms: f32, particles: u64, draw_calls: u32, vram_mb: f32, ram_mb: f32) {
280        let cpu_ms = self.frame_start
281            .take()
282            .map(|s| s.elapsed().as_secs_f32() * 1000.0)
283            .unwrap_or(0.0);
284        let frame_ms = cpu_ms.max(gpu_ms);
285        self.frame_times.push(frame_ms);
286        self.cpu_times.push(cpu_ms);
287        self.gpu_times.push(gpu_ms);
288        self.particle_counts.push(particles as f32);
289        self.current = TimeSample {
290            frame_ms, cpu_ms, gpu_ms,
291            particle_count: particles,
292            draw_calls,
293            vram_mb, ram_mb,
294        };
295    }
296
297    // ── Kit timing spans ──────────────────────────────────────────────────
298
299    pub fn begin_kit(&mut self, kit: &'static str) {
300        self.kit_spans.insert(kit, Instant::now());
301    }
302
303    pub fn end_kit(&mut self, kit: &'static str) {
304        if let Some(start) = self.kit_spans.remove(kit) {
305            let us = start.elapsed().as_secs_f32() * 1_000_000.0;
306            match kit {
307                "BoneKit"     => self.kit_timings.bone_kit     = us,
308                "ModelKit"    => self.kit_timings.model_kit    = us,
309                "MaterialKit" => self.kit_timings.material_kit = us,
310                "LightingKit" => self.kit_timings.lighting_kit = us,
311                "ClothingKit" => self.kit_timings.clothing_kit = us,
312                "HairKit"     => self.kit_timings.hair_kit     = us,
313                "PhysicsKit"  => self.kit_timings.physics_kit  = us,
314                "RenderKit"   => self.kit_timings.render_kit   = us,
315                "SDF Eval"    => self.kit_timings.sdf_eval     = us,
316                "Post-FX"     => self.kit_timings.post_fx      = us,
317                "Scene Upd"   => self.kit_timings.scene_update = us,
318                "Cmd Rec"     => self.kit_timings.command_rec  = us,
319                _ => {}
320            }
321        }
322    }
323
324    // ── Statistics ────────────────────────────────────────────────────────
325
326    pub fn avg_fps(&self) -> f32 {
327        let avg_ms = self.frame_times.mean();
328        if avg_ms > 0.0 { 1000.0 / avg_ms } else { 0.0 }
329    }
330
331    pub fn p95_frame_ms(&self) -> f32 { self.frame_times.p95() }
332    pub fn max_frame_ms(&self) -> f32 { self.frame_times.max() }
333
334    pub fn bottleneck(&self) -> Bottleneck {
335        let t = &self.current;
336        if t.vram_mb > self.thresholds.warn_vram_mb { return Bottleneck::MemoryBound; }
337        if t.particle_count > self.thresholds.warn_particles { return Bottleneck::ParticleCount; }
338        if self.kit_timings.post_fx > self.kit_timings.total_us() * 0.4 { return Bottleneck::PostFx; }
339        if t.cpu_ms > t.gpu_ms * 1.5 { return Bottleneck::CpuBound; }
340        if t.gpu_ms > t.cpu_ms * 1.5 { return Bottleneck::GpuBound; }
341        Bottleneck::None
342    }
343
344    pub fn frame_status(&self) -> FrameStatus {
345        let ms = self.current.frame_ms;
346        if ms >= self.thresholds.error_frame_ms { FrameStatus::Error }
347        else if ms >= self.thresholds.warn_frame_ms { FrameStatus::Warn }
348        else { FrameStatus::Ok }
349    }
350
351    // ── Text rendering ────────────────────────────────────────────────────
352
353    pub fn render_text(&self) -> String {
354        match self.mode {
355            PerfOverlayMode::Off     => String::new(),
356            PerfOverlayMode::Compact => self.render_compact(),
357            PerfOverlayMode::Normal  => self.render_normal(),
358            PerfOverlayMode::Verbose => self.render_verbose(),
359            PerfOverlayMode::Graph   => self.render_graph(),
360        }
361    }
362
363    pub fn render_compact(&self) -> String {
364        let fps = self.avg_fps();
365        let ms  = self.current.frame_ms;
366        let p   = self.current.particle_count;
367        format!("{:.1} FPS  {:.2}ms  {} M", fps, ms, p / 1_000_000)
368    }
369
370    pub fn render_normal(&self) -> String {
371        let fps   = self.avg_fps();
372        let t     = &self.current;
373        let bn    = self.bottleneck();
374        let status= self.frame_status();
375        let particles_m = t.particle_count as f64 / 1_000_000.0;
376        format!(
377            "FPS: {:.1}  frame={:.2}ms  cpu={:.2}ms  gpu={:.2}ms\n\
378             particles={:.1}M  draws={}\n\
379             vram={:.0}MB  ram={:.0}MB\n\
380             post-fx={} passes  n_copies={}\n\
381             bottleneck: {}  [{}]",
382            fps, t.frame_ms, t.cpu_ms, t.gpu_ms,
383            particles_m, t.draw_calls,
384            t.vram_mb, t.ram_mb,
385            self.post_fx_passes, 1, // n_copies placeholder
386            bn.label(), status.label(),
387        )
388    }
389
390    pub fn render_verbose(&self) -> String {
391        let mut out = self.render_normal();
392        out.push_str("\n── Kit Costs (µs) ──\n");
393        let total = self.kit_timings.total_us().max(1.0);
394        for (name, us) in self.kit_timings.pairs() {
395            let bar_len = ((us / total) * 20.0) as usize;
396            let bar: String = "█".repeat(bar_len);
397            out.push_str(&format!("  {:<14} {:>8.1}µs {}\n", name, us, bar));
398        }
399        out.push_str(&format!("  TOTAL          {:>8.1}µs\n", total));
400        if !self.post_fx_names.is_empty() {
401            out.push_str("── Post-FX Active ──\n");
402            for name in &self.post_fx_names {
403                out.push_str(&format!("  ✓ {}\n", name));
404            }
405        }
406        out.push_str(&format!("p95 frame: {:.2}ms  max: {:.2}ms\n",
407            self.p95_frame_ms(), self.max_frame_ms()));
408        out
409    }
410
411    pub fn render_graph(&self) -> String {
412        // ASCII sparkline of frame times
413        let data = self.frame_times.as_slice();
414        if data.is_empty() { return "no data".into(); }
415        let max = data.iter().cloned().map(|v| *v).fold(0.0f32, f32::max).max(1.0);
416        let bars = "▁▂▃▄▅▆▇█";
417        let bar_chars: Vec<char> = bars.chars().collect();
418        let graph: String = data.iter().map(|&&v| {
419            let idx = ((v / max) * (bar_chars.len() - 1) as f32) as usize;
420            bar_chars[idx.min(bar_chars.len() - 1)]
421        }).collect();
422        format!("Frame time (0–{:.1}ms)\n{}\n{}", max, graph, self.render_compact())
423    }
424
425    // ── Helpers ───────────────────────────────────────────────────────────
426
427    pub fn status_line(&self) -> String {
428        format!(
429            "Perf [{:?}] {:.1}fps  {:.2}ms  {:.0}M particles  {}",
430            self.mode, self.avg_fps(), self.current.frame_ms,
431            self.current.particle_count as f64 / 1e6,
432            self.bottleneck().label(),
433        )
434    }
435}
436
437// ─────────────────────────────────────────────────────────────────────────────
438// FrameStatus
439// ─────────────────────────────────────────────────────────────────────────────
440
441#[derive(Debug, Clone, Copy, PartialEq, Eq)]
442pub enum FrameStatus { Ok, Warn, Error }
443impl FrameStatus {
444    pub fn label(self) -> &'static str {
445        match self { Self::Ok=>"OK", Self::Warn=>"WARN", Self::Error=>"SLOW" }
446    }
447}
448
449impl Default for PerfOverlay { fn default() -> Self { Self::new() } }
450
451// ─────────────────────────────────────────────────────────────────────────────
452// Tests
453// ─────────────────────────────────────────────────────────────────────────────
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    fn make_overlay_with_frames(n: usize, frame_ms: f32) -> PerfOverlay {
460        let mut o = PerfOverlay::new();
461        for _ in 0..n {
462            o.begin_frame();
463            o.end_frame(frame_ms * 0.6, 50_000_000, 1, 2_000.0, 4_000.0);
464        }
465        o
466    }
467
468    #[test]
469    fn avg_fps_60() {
470        let o = make_overlay_with_frames(60, 16.7);
471        assert!((o.avg_fps() - 60.0).abs() < 5.0);
472    }
473
474    #[test]
475    fn bottleneck_gpu() {
476        let mut o = PerfOverlay::new();
477        o.begin_frame();
478        o.end_frame(30.0, 1, 1, 100.0, 100.0); // gpu >> cpu → gpu-bound
479        o.current.cpu_ms = 5.0;
480        o.current.gpu_ms = 30.0;
481        assert_eq!(o.bottleneck(), Bottleneck::GpuBound);
482    }
483
484    #[test]
485    fn render_compact_nonempty() {
486        let o = make_overlay_with_frames(10, 16.0);
487        let text = o.render_compact();
488        assert!(!text.is_empty());
489        assert!(text.contains("FPS"));
490    }
491
492    #[test]
493    fn ring_buffer_capacity() {
494        let mut rb: RingBuffer<f32> = RingBuffer::new(4);
495        for i in 0..8 { rb.push(i as f32); }
496        assert_eq!(rb.len(), 4);
497        assert_eq!(*rb.last().unwrap(), 7.0);
498    }
499
500    #[test]
501    fn p95_frame_time() {
502        let mut rb: RingBuffer<f32> = RingBuffer::new(100);
503        for i in 0..100 { rb.push(i as f32); }
504        let p95 = rb.p95();
505        assert!(p95 >= 94.0 && p95 <= 96.0);
506    }
507
508    #[test]
509    fn kit_timings_total() {
510        let k = KitTimings {
511            bone_kit: 10.0, model_kit: 20.0, material_kit: 5.0, lighting_kit: 8.0,
512            clothing_kit: 3.0, hair_kit: 15.0, physics_kit: 6.0, render_kit: 12.0,
513            sdf_eval: 40.0, post_fx: 25.0, scene_update: 7.0, command_rec: 4.0,
514        };
515        let total = k.total_us();
516        assert!((total - 155.0).abs() < 1e-3);
517    }
518}