Skip to main content

proof_engine/wgpu_backend/
quality.rs

1//! Adaptive quality management: auto-detect hardware capability, dynamically
2//! adjust rendering quality to maintain a target frame rate, and benchmarking.
3
4use super::backend::BackendCapabilities;
5use super::renderer::MultiBackendRenderer;
6
7// ---------------------------------------------------------------------------
8// Quality level
9// ---------------------------------------------------------------------------
10
11/// Discrete quality tier.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
13pub enum QualityLevel {
14    Potato = 0,
15    Low    = 1,
16    Medium = 2,
17    High   = 3,
18    Ultra  = 4,
19}
20
21impl QualityLevel {
22    /// All levels from lowest to highest.
23    pub const ALL: [QualityLevel; 5] = [
24        Self::Potato, Self::Low, Self::Medium, Self::High, Self::Ultra,
25    ];
26
27    /// Try to go one level up.
28    pub fn upgrade(self) -> Option<QualityLevel> {
29        match self {
30            Self::Potato => Some(Self::Low),
31            Self::Low    => Some(Self::Medium),
32            Self::Medium => Some(Self::High),
33            Self::High   => Some(Self::Ultra),
34            Self::Ultra  => None,
35        }
36    }
37
38    /// Try to go one level down.
39    pub fn downgrade(self) -> Option<QualityLevel> {
40        match self {
41            Self::Potato => None,
42            Self::Low    => Some(Self::Potato),
43            Self::Medium => Some(Self::Low),
44            Self::High   => Some(Self::Medium),
45            Self::Ultra  => Some(Self::High),
46        }
47    }
48
49    /// Index 0..4 for array lookups.
50    pub fn index(self) -> usize { self as usize }
51}
52
53impl std::fmt::Display for QualityLevel {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        match self {
56            Self::Potato => write!(f, "Potato"),
57            Self::Low    => write!(f, "Low"),
58            Self::Medium => write!(f, "Medium"),
59            Self::High   => write!(f, "High"),
60            Self::Ultra  => write!(f, "Ultra"),
61        }
62    }
63}
64
65// ---------------------------------------------------------------------------
66// Quality profile
67// ---------------------------------------------------------------------------
68
69/// Concrete rendering settings for a quality level.
70#[derive(Debug, Clone)]
71pub struct QualityProfile {
72    pub particle_count: u32,
73    pub bloom_passes: u32,
74    pub shadow_resolution: u32,
75    pub postfx_enabled: bool,
76    pub compute_enabled: bool,
77    pub msaa: u32,
78}
79
80impl QualityProfile {
81    /// Build a profile for the given quality level.
82    pub fn for_level(level: QualityLevel) -> Self {
83        match level {
84            QualityLevel::Potato => Self {
85                particle_count: 1_000,
86                bloom_passes: 0,
87                shadow_resolution: 256,
88                postfx_enabled: false,
89                compute_enabled: false,
90                msaa: 1,
91            },
92            QualityLevel::Low => Self {
93                particle_count: 5_000,
94                bloom_passes: 1,
95                shadow_resolution: 512,
96                postfx_enabled: false,
97                compute_enabled: false,
98                msaa: 1,
99            },
100            QualityLevel::Medium => Self {
101                particle_count: 20_000,
102                bloom_passes: 2,
103                shadow_resolution: 1024,
104                postfx_enabled: true,
105                compute_enabled: true,
106                msaa: 2,
107            },
108            QualityLevel::High => Self {
109                particle_count: 100_000,
110                bloom_passes: 3,
111                shadow_resolution: 2048,
112                postfx_enabled: true,
113                compute_enabled: true,
114                msaa: 4,
115            },
116            QualityLevel::Ultra => Self {
117                particle_count: 500_000,
118                bloom_passes: 4,
119                shadow_resolution: 4096,
120                postfx_enabled: true,
121                compute_enabled: true,
122                msaa: 8,
123            },
124        }
125    }
126
127    /// Estimated VRAM usage in bytes for this profile (rough heuristic).
128    pub fn estimated_vram(&self) -> u64 {
129        let shadow = (self.shadow_resolution as u64) * (self.shadow_resolution as u64) * 4;
130        let particles = (self.particle_count as u64) * 64; // ~64 bytes per particle
131        let msaa_factor = self.msaa as u64;
132        shadow * msaa_factor + particles
133    }
134}
135
136// ---------------------------------------------------------------------------
137// Auto-detect
138// ---------------------------------------------------------------------------
139
140/// Choose a quality level based on hardware capabilities.
141pub fn auto_detect_quality(capabilities: &BackendCapabilities) -> QualityLevel {
142    let mut score = 0u32;
143
144    // Texture size
145    if capabilities.max_texture_size >= 16384 { score += 3; }
146    else if capabilities.max_texture_size >= 8192 { score += 2; }
147    else if capabilities.max_texture_size >= 4096 { score += 1; }
148
149    // Compute
150    if capabilities.compute_shaders { score += 2; }
151
152    // SSBO size
153    if capabilities.max_ssbo_size >= 1024 * 1024 * 1024 { score += 2; }
154    else if capabilities.max_ssbo_size >= 256 * 1024 * 1024 { score += 1; }
155
156    // Indirect draw
157    if capabilities.indirect_draw { score += 1; }
158    if capabilities.multi_draw_indirect { score += 1; }
159
160    // Workgroup size
161    if capabilities.max_workgroup_size[0] >= 1024 { score += 1; }
162
163    match score {
164        0..=2  => QualityLevel::Potato,
165        3..=4  => QualityLevel::Low,
166        5..=7  => QualityLevel::Medium,
167        8..=9  => QualityLevel::High,
168        _      => QualityLevel::Ultra,
169    }
170}
171
172// ---------------------------------------------------------------------------
173// QualityManager
174// ---------------------------------------------------------------------------
175
176/// Dynamically adjusts quality level to maintain a target frame rate.
177pub struct QualityManager {
178    pub current: QualityLevel,
179    pub target_fps: f32,
180    fps_history: Vec<f32>,
181    max_history: usize,
182    upgrade_cooldown: f32,
183    downgrade_cooldown: f32,
184    time_since_change: f32,
185    /// Minimum time (seconds) between quality changes.
186    pub cooldown_seconds: f32,
187    /// FPS must be above target * this factor before considering upgrade.
188    pub upgrade_headroom: f32,
189    /// FPS must be below target * this factor before considering downgrade.
190    pub downgrade_threshold: f32,
191}
192
193impl QualityManager {
194    pub fn new(initial: QualityLevel, target_fps: f32) -> Self {
195        Self {
196            current: initial,
197            target_fps,
198            fps_history: Vec::with_capacity(60),
199            max_history: 60,
200            upgrade_cooldown: 0.0,
201            downgrade_cooldown: 0.0,
202            time_since_change: 0.0,
203            cooldown_seconds: 3.0,
204            upgrade_headroom: 1.15,   // need 15% headroom above target
205            downgrade_threshold: 0.85, // drop below 85% of target
206        }
207    }
208
209    /// Call each frame with current FPS and delta time.
210    pub fn tick(&mut self, current_fps: f32, dt: f32) {
211        if self.fps_history.len() >= self.max_history {
212            self.fps_history.remove(0);
213        }
214        self.fps_history.push(current_fps);
215        self.time_since_change += dt;
216
217        if self.time_since_change < self.cooldown_seconds {
218            return;
219        }
220
221        let avg = self.average_fps();
222
223        if self.should_downgrade_at(avg) {
224            if let Some(lower) = self.current.downgrade() {
225                self.current = lower;
226                self.time_since_change = 0.0;
227                self.fps_history.clear();
228            }
229        } else if self.should_upgrade_at(avg) {
230            if let Some(higher) = self.current.upgrade() {
231                self.current = higher;
232                self.time_since_change = 0.0;
233                self.fps_history.clear();
234            }
235        }
236    }
237
238    /// Average FPS over the history window.
239    pub fn average_fps(&self) -> f32 {
240        if self.fps_history.is_empty() { return 0.0; }
241        let sum: f32 = self.fps_history.iter().sum();
242        sum / self.fps_history.len() as f32
243    }
244
245    /// Whether the manager would upgrade at this moment.
246    pub fn should_upgrade(&self) -> bool {
247        self.should_upgrade_at(self.average_fps())
248    }
249
250    /// Whether the manager would downgrade at this moment.
251    pub fn should_downgrade(&self) -> bool {
252        self.should_downgrade_at(self.average_fps())
253    }
254
255    fn should_upgrade_at(&self, avg_fps: f32) -> bool {
256        avg_fps > self.target_fps * self.upgrade_headroom
257            && self.current.upgrade().is_some()
258    }
259
260    fn should_downgrade_at(&self, avg_fps: f32) -> bool {
261        avg_fps < self.target_fps * self.downgrade_threshold
262            && self.current.downgrade().is_some()
263    }
264
265    /// Current quality profile.
266    pub fn profile(&self) -> QualityProfile {
267        QualityProfile::for_level(self.current)
268    }
269
270    /// Force a specific quality level.
271    pub fn set_quality(&mut self, level: QualityLevel) {
272        self.current = level;
273        self.time_since_change = 0.0;
274        self.fps_history.clear();
275    }
276}
277
278// ---------------------------------------------------------------------------
279// BenchmarkResult
280// ---------------------------------------------------------------------------
281
282/// Result of a GPU benchmark run.
283#[derive(Debug, Clone)]
284pub struct BenchmarkResult {
285    pub fps: f32,
286    pub gpu_ms: f32,
287    pub cpu_ms: f32,
288    pub vram_used: u64,
289}
290
291impl BenchmarkResult {
292    pub fn score(&self) -> f32 {
293        // Simple composite: FPS weighted by inverse GPU time.
294        if self.gpu_ms > 0.0 {
295            self.fps * (16.67 / self.gpu_ms)
296        } else {
297            self.fps
298        }
299    }
300}
301
302/// Run a simple benchmark on the given renderer for `duration_secs`.
303/// Because we have no real GPU, this measures CPU-side overhead of the
304/// software backend.
305pub fn run_benchmark(renderer: &mut MultiBackendRenderer, duration_secs: f32) -> BenchmarkResult {
306    use std::time::Instant;
307    use super::backend::{BufferUsage, PipelineLayout, ShaderStage};
308    use super::renderer::{DrawCall, RenderPass};
309
310    let start = Instant::now();
311    let mut frames = 0u32;
312    let target_duration = std::time::Duration::from_secs_f32(duration_secs);
313
314    // Create minimal resources for the benchmark.
315    let vbuf = renderer.create_vertex_buffer(&[0u8; 48]);
316    let vs = renderer.backend.create_shader("v", ShaderStage::Vertex);
317    let fs = renderer.backend.create_shader("f", ShaderStage::Fragment);
318    let pipe = renderer.backend.create_pipeline(vs, fs, &PipelineLayout::default());
319    let pass = RenderPass::new();
320    let call = DrawCall::new(pipe, vbuf, 3);
321
322    while start.elapsed() < target_duration {
323        renderer.begin_frame();
324        renderer.draw(&pass, &[call.clone()]);
325        renderer.end_frame();
326        frames += 1;
327    }
328
329    let elapsed = start.elapsed().as_secs_f32();
330    let fps = frames as f32 / elapsed;
331    let frame_ms = elapsed * 1000.0 / frames.max(1) as f32;
332
333    renderer.destroy_buffer(vbuf);
334
335    BenchmarkResult {
336        fps,
337        gpu_ms: frame_ms, // In software, GPU = CPU
338        cpu_ms: frame_ms,
339        vram_used: 0,
340    }
341}
342
343// ---------------------------------------------------------------------------
344// Tests
345// ---------------------------------------------------------------------------
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use crate::wgpu_backend::backend::{BackendCapabilities, GpuBackend};
351
352    #[test]
353    fn quality_level_ordering() {
354        assert!(QualityLevel::Potato < QualityLevel::Low);
355        assert!(QualityLevel::Low < QualityLevel::Medium);
356        assert!(QualityLevel::Medium < QualityLevel::High);
357        assert!(QualityLevel::High < QualityLevel::Ultra);
358    }
359
360    #[test]
361    fn quality_level_upgrade_downgrade() {
362        assert_eq!(QualityLevel::Potato.upgrade(), Some(QualityLevel::Low));
363        assert_eq!(QualityLevel::Ultra.upgrade(), None);
364        assert_eq!(QualityLevel::Ultra.downgrade(), Some(QualityLevel::High));
365        assert_eq!(QualityLevel::Potato.downgrade(), None);
366    }
367
368    #[test]
369    fn quality_level_display() {
370        assert_eq!(format!("{}", QualityLevel::Medium), "Medium");
371        assert_eq!(format!("{}", QualityLevel::Potato), "Potato");
372    }
373
374    #[test]
375    fn quality_level_index() {
376        assert_eq!(QualityLevel::Potato.index(), 0);
377        assert_eq!(QualityLevel::Ultra.index(), 4);
378    }
379
380    #[test]
381    fn quality_profile_for_each_level() {
382        for level in QualityLevel::ALL {
383            let profile = QualityProfile::for_level(level);
384            assert!(profile.particle_count > 0);
385            assert!(profile.msaa >= 1);
386            assert!(profile.shadow_resolution >= 256);
387        }
388    }
389
390    #[test]
391    fn quality_profiles_scale_up() {
392        let potato = QualityProfile::for_level(QualityLevel::Potato);
393        let ultra = QualityProfile::for_level(QualityLevel::Ultra);
394        assert!(ultra.particle_count > potato.particle_count);
395        assert!(ultra.shadow_resolution > potato.shadow_resolution);
396        assert!(ultra.msaa > potato.msaa);
397    }
398
399    #[test]
400    fn estimated_vram_increases_with_quality() {
401        let low = QualityProfile::for_level(QualityLevel::Low);
402        let high = QualityProfile::for_level(QualityLevel::High);
403        assert!(high.estimated_vram() > low.estimated_vram());
404    }
405
406    #[test]
407    fn auto_detect_vulkan() {
408        let caps = BackendCapabilities::for_backend(GpuBackend::Vulkan);
409        let level = auto_detect_quality(&caps);
410        assert!(level >= QualityLevel::High);
411    }
412
413    #[test]
414    fn auto_detect_software() {
415        let caps = BackendCapabilities::for_backend(GpuBackend::Software);
416        let level = auto_detect_quality(&caps);
417        // Software has limited caps
418        assert!(level <= QualityLevel::Medium);
419    }
420
421    #[test]
422    fn auto_detect_minimal_caps() {
423        let caps = BackendCapabilities {
424            compute_shaders: false,
425            max_texture_size: 1024,
426            max_ssbo_size: 0,
427            max_workgroup_size: [64, 64, 1],
428            indirect_draw: false,
429            multi_draw_indirect: false,
430        };
431        assert_eq!(auto_detect_quality(&caps), QualityLevel::Potato);
432    }
433
434    #[test]
435    fn quality_manager_downgrade_on_low_fps() {
436        let mut mgr = QualityManager::new(QualityLevel::High, 60.0);
437        mgr.cooldown_seconds = 0.0; // disable cooldown for test
438
439        // Simulate many frames at 30 FPS (below 85% of 60 = 51)
440        for _ in 0..10 {
441            mgr.tick(30.0, 0.016);
442        }
443        // Should have downgraded
444        assert!(mgr.current < QualityLevel::High);
445    }
446
447    #[test]
448    fn quality_manager_upgrade_on_high_fps() {
449        let mut mgr = QualityManager::new(QualityLevel::Low, 60.0);
450        mgr.cooldown_seconds = 0.0;
451
452        // Simulate high FPS (above 115% of 60 = 69)
453        for _ in 0..10 {
454            mgr.tick(120.0, 0.008);
455        }
456        assert!(mgr.current > QualityLevel::Low);
457    }
458
459    #[test]
460    fn quality_manager_stays_stable() {
461        let mut mgr = QualityManager::new(QualityLevel::Medium, 60.0);
462        mgr.cooldown_seconds = 0.0;
463
464        // FPS right at target: should not change
465        for _ in 0..20 {
466            mgr.tick(60.0, 0.016);
467        }
468        assert_eq!(mgr.current, QualityLevel::Medium);
469    }
470
471    #[test]
472    fn quality_manager_cooldown() {
473        let mut mgr = QualityManager::new(QualityLevel::High, 60.0);
474        mgr.cooldown_seconds = 5.0;
475
476        // Even at low FPS, won't change until cooldown expires
477        mgr.tick(10.0, 1.0);
478        assert_eq!(mgr.current, QualityLevel::High);
479    }
480
481    #[test]
482    fn quality_manager_should_upgrade_downgrade() {
483        let mut mgr = QualityManager::new(QualityLevel::Medium, 60.0);
484        mgr.cooldown_seconds = 0.0;
485        for _ in 0..5 { mgr.tick(120.0, 0.008); }
486        assert!(mgr.should_upgrade());
487        assert!(!mgr.should_downgrade());
488    }
489
490    #[test]
491    fn quality_manager_average_fps() {
492        let mut mgr = QualityManager::new(QualityLevel::Medium, 60.0);
493        mgr.tick(50.0, 0.016);
494        mgr.tick(70.0, 0.016);
495        let avg = mgr.average_fps();
496        assert!((avg - 60.0).abs() < 0.01);
497    }
498
499    #[test]
500    fn quality_manager_set_quality() {
501        let mut mgr = QualityManager::new(QualityLevel::Low, 60.0);
502        mgr.set_quality(QualityLevel::Ultra);
503        assert_eq!(mgr.current, QualityLevel::Ultra);
504    }
505
506    #[test]
507    fn quality_manager_profile() {
508        let mgr = QualityManager::new(QualityLevel::High, 60.0);
509        let profile = mgr.profile();
510        assert_eq!(profile.msaa, 4);
511    }
512
513    #[test]
514    fn benchmark_result_score() {
515        let result = BenchmarkResult {
516            fps: 60.0,
517            gpu_ms: 16.67,
518            cpu_ms: 16.67,
519            vram_used: 0,
520        };
521        let score = result.score();
522        assert!((score - 60.0).abs() < 0.1);
523    }
524
525    #[test]
526    fn run_benchmark_returns_result() {
527        let mut renderer = MultiBackendRenderer::software();
528        let result = run_benchmark(&mut renderer, 0.05);
529        assert!(result.fps > 0.0);
530        assert!(result.gpu_ms > 0.0);
531    }
532
533    #[test]
534    fn quality_manager_cannot_go_below_potato() {
535        let mut mgr = QualityManager::new(QualityLevel::Potato, 60.0);
536        mgr.cooldown_seconds = 0.0;
537        for _ in 0..20 {
538            mgr.tick(5.0, 0.2);
539        }
540        assert_eq!(mgr.current, QualityLevel::Potato);
541    }
542
543    #[test]
544    fn quality_manager_cannot_go_above_ultra() {
545        let mut mgr = QualityManager::new(QualityLevel::Ultra, 60.0);
546        mgr.cooldown_seconds = 0.0;
547        for _ in 0..20 {
548            mgr.tick(300.0, 0.003);
549        }
550        assert_eq!(mgr.current, QualityLevel::Ultra);
551    }
552}