Skip to main content

alpha2_render/
robustness.rs

1//! Capability-driven, observable degradation policy.
2
3use alpha2_gpu::{Capabilities, CapabilityTier, IntermediateFormat};
4
5/// One deterministic fallback selected from adapter facts and caller budgets.
6#[derive(Clone, Debug, Eq, PartialEq)]
7pub enum CapabilityDiagnostic {
8    /// Half-float filtering is unavailable; the renderer uses normalized RGBA8.
9    Rgba8Intermediate,
10    /// Caller memory pressure reduced the idle texture budget.
11    TextureBudgetReduced {
12        /// Original caller request.
13        requested: u64,
14        /// Budget accepted by the policy.
15        applied: u64,
16    },
17    /// Caller decoder-session limit prevents opening another decoder.
18    DecoderSessionLimit {
19        /// Caller-configured maximum session count.
20        limit: u32,
21    },
22    /// A filter sample budget was reduced to what passes take
23    /// ([`crate::RenderQualityControls::MAX_FILTER_SAMPLES`], or less on Tier-B devices).
24    FilterSamplesReduced {
25        /// Original caller request.
26        requested: u32,
27        /// The samples a pass takes.
28        applied: u32,
29    },
30}
31
32/// Caller-owned resource envelope; zero means the corresponding resource is unavailable.
33#[derive(Copy, Clone, Debug, Eq, PartialEq)]
34#[non_exhaustive]
35pub struct ResourceBudget {
36    /// Soft GPU texture bytes.
37    pub texture_bytes: u64,
38    /// Maximum concurrent decoder sessions.
39    pub decoder_sessions: u32,
40    /// Decoder sessions already active in the host.
41    pub active_decoder_sessions: u32,
42}
43
44impl ResourceBudget {
45    /// Creates a caller-owned resource envelope.
46    #[must_use]
47    pub const fn new(
48        texture_bytes: u64,
49        decoder_sessions: u32,
50        active_decoder_sessions: u32,
51    ) -> Self {
52        Self {
53            texture_bytes,
54            decoder_sessions,
55            active_decoder_sessions,
56        }
57    }
58}
59
60/// Effective controls and every observable fallback decision.
61#[derive(Clone, Debug, Eq, PartialEq)]
62pub struct DegradationDecision {
63    /// Applied texture budget.
64    pub texture_bytes: u64,
65    /// Applied maximum filter sample count.
66    pub max_filter_samples: u32,
67    /// Whether another decoder may be opened.
68    pub decoder_available: bool,
69    /// Explicit fallback diagnostics.
70    pub diagnostics: Vec<CapabilityDiagnostic>,
71}
72
73/// Most filter samples a pass takes on `tier`:
74/// [`crate::RenderQualityControls::MAX_FILTER_SAMPLES`], or 16 on Tier B (WebGL2, GL ES), whose
75/// fill rate cannot keep up with more.
76#[must_use]
77pub const fn filter_sample_limit(tier: CapabilityTier) -> u32 {
78    match tier {
79        CapabilityTier::TierB => 16,
80        _ => crate::RenderQualityControls::MAX_FILTER_SAMPLES,
81    }
82}
83
84/// Resolves deterministic degradation without product- or device-name assumptions.
85#[must_use]
86pub fn resolve_degradation(
87    capabilities: &Capabilities,
88    requested_texture_bytes: u64,
89    requested_filter_samples: u32,
90    budget: ResourceBudget,
91) -> DegradationDecision {
92    let texture_bytes = requested_texture_bytes.min(budget.texture_bytes);
93    let max_filter_samples = requested_filter_samples
94        .min(filter_sample_limit(capabilities.tier))
95        .max(1);
96    let decoder_available = budget.active_decoder_sessions < budget.decoder_sessions;
97    let mut diagnostics = Vec::new();
98    if capabilities.intermediate_format == IntermediateFormat::Rgba8Unorm {
99        diagnostics.push(CapabilityDiagnostic::Rgba8Intermediate);
100    }
101    if texture_bytes < requested_texture_bytes {
102        diagnostics.push(CapabilityDiagnostic::TextureBudgetReduced {
103            requested: requested_texture_bytes,
104            applied: texture_bytes,
105        });
106    }
107    if max_filter_samples < requested_filter_samples {
108        diagnostics.push(CapabilityDiagnostic::FilterSamplesReduced {
109            requested: requested_filter_samples,
110            applied: max_filter_samples,
111        });
112    }
113    if !decoder_available {
114        diagnostics.push(CapabilityDiagnostic::DecoderSessionLimit {
115            limit: budget.decoder_sessions,
116        });
117    }
118    DegradationDecision {
119        texture_bytes,
120        max_filter_samples,
121        decoder_available,
122        diagnostics,
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn tier_b_and_caller_budgets_degrade_observably() {
132        let capabilities = Capabilities::new(
133            CapabilityTier::TierB,
134            IntermediateFormat::Rgba8Unorm,
135            "test",
136            4096,
137            false,
138            "Gl",
139            "software",
140            "fixture",
141            "",
142        );
143        let decision = resolve_degradation(
144            &capabilities,
145            1_000,
146            64,
147            ResourceBudget {
148                texture_bytes: 500,
149                decoder_sessions: 2,
150                active_decoder_sessions: 2,
151            },
152        );
153        assert_eq!(decision.texture_bytes, 500);
154        assert_eq!(decision.max_filter_samples, 16);
155        assert!(!decision.decoder_available);
156        assert_eq!(decision.diagnostics.len(), 4);
157    }
158
159    #[test]
160    fn the_filter_sample_limit_is_the_renderers_on_every_tier() {
161        assert_eq!(filter_sample_limit(CapabilityTier::TierB), 16);
162        assert_eq!(
163            filter_sample_limit(CapabilityTier::TierA),
164            crate::RenderQualityControls::MAX_FILTER_SAMPLES
165        );
166        let capabilities = Capabilities::new(
167            CapabilityTier::TierA,
168            IntermediateFormat::Rgba16Float,
169            "test",
170            4096,
171            false,
172            "Vulkan",
173            "driver",
174            "fixture",
175            "",
176        );
177        let budget = ResourceBudget::new(1_000, 1, 0);
178        let decision = resolve_degradation(&capabilities, 1_000, 256, budget);
179        assert_eq!(decision.max_filter_samples, 64);
180        assert_eq!(
181            decision.diagnostics,
182            [CapabilityDiagnostic::FilterSamplesReduced {
183                requested: 256,
184                applied: 64
185            }]
186        );
187        assert_eq!(
188            resolve_degradation(&capabilities, 1_000, 64, budget).diagnostics,
189            [] as [CapabilityDiagnostic; 0]
190        );
191    }
192}