1use std::collections::VecDeque;
25use std::time::{Duration, Instant};
26
27#[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#[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#[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#[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#[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#[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, error_frame_ms: 33.3, warn_vram_mb: 7_000.0,warn_particles: 800_000_000,
221 target_fps: 60.0,
222 }
223 }
224}
225
226#[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 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 pub fn begin_frame(&mut self) {
274 self.frame_start = Some(Instant::now());
275 self.frame_count += 1;
276 }
277
278 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 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 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 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, 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 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 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#[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#[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); 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}