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 past what a pass takes was reduced to it:
23    /// [`crate::RenderQualityControls::MAX_FILTER_SAMPLES`], or less on a
24    /// Tier-B (WebGL2, GL ES) device.
25    FilterSamplesReduced {
26        /// Original caller request.
27        requested: u32,
28        /// The samples a pass takes.
29        applied: u32,
30    },
31}
32
33/// Caller-owned resource envelope; zero means the corresponding resource is unavailable.
34#[derive(Copy, Clone, Debug, Eq, PartialEq)]
35#[non_exhaustive]
36pub struct ResourceBudget {
37    /// Soft GPU texture bytes.
38    pub texture_bytes: u64,
39    /// Maximum concurrent decoder sessions.
40    pub decoder_sessions: u32,
41    /// Decoder sessions already active in the host.
42    pub active_decoder_sessions: u32,
43}
44
45impl ResourceBudget {
46    /// Creates a caller-owned resource envelope.
47    #[must_use]
48    pub const fn new(
49        texture_bytes: u64,
50        decoder_sessions: u32,
51        active_decoder_sessions: u32,
52    ) -> Self {
53        Self {
54            texture_bytes,
55            decoder_sessions,
56            active_decoder_sessions,
57        }
58    }
59}
60
61/// Effective controls and every observable fallback decision.
62#[derive(Clone, Debug, Eq, PartialEq)]
63pub struct DegradationDecision {
64    /// Applied texture budget.
65    pub texture_bytes: u64,
66    /// Applied maximum filter sample count.
67    pub max_filter_samples: u32,
68    /// Whether another decoder may be opened.
69    pub decoder_available: bool,
70    /// Explicit fallback diagnostics.
71    pub diagnostics: Vec<CapabilityDiagnostic>,
72}
73
74/// The most filter samples a pass takes on a device of `tier`:
75/// [`crate::RenderQualityControls::MAX_FILTER_SAMPLES`], and 16 on Tier B
76/// (WebGL2, GL ES), whose fill rate the larger budgets outrun.
77#[must_use]
78pub const fn filter_sample_limit(tier: CapabilityTier) -> u32 {
79    match tier {
80        CapabilityTier::TierB => 16,
81        _ => crate::RenderQualityControls::MAX_FILTER_SAMPLES,
82    }
83}
84
85/// Resolves deterministic degradation without product- or device-name assumptions.
86#[must_use]
87pub fn resolve_degradation(
88    capabilities: &Capabilities,
89    requested_texture_bytes: u64,
90    requested_filter_samples: u32,
91    budget: ResourceBudget,
92) -> DegradationDecision {
93    let texture_bytes = requested_texture_bytes.min(budget.texture_bytes);
94    let max_filter_samples = requested_filter_samples
95        .min(filter_sample_limit(capabilities.tier))
96        .max(1);
97    let decoder_available = budget.active_decoder_sessions < budget.decoder_sessions;
98    let mut diagnostics = Vec::new();
99    if capabilities.intermediate_format == IntermediateFormat::Rgba8Unorm {
100        diagnostics.push(CapabilityDiagnostic::Rgba8Intermediate);
101    }
102    if texture_bytes < requested_texture_bytes {
103        diagnostics.push(CapabilityDiagnostic::TextureBudgetReduced {
104            requested: requested_texture_bytes,
105            applied: texture_bytes,
106        });
107    }
108    if max_filter_samples < requested_filter_samples {
109        diagnostics.push(CapabilityDiagnostic::FilterSamplesReduced {
110            requested: requested_filter_samples,
111            applied: max_filter_samples,
112        });
113    }
114    if !decoder_available {
115        diagnostics.push(CapabilityDiagnostic::DecoderSessionLimit {
116            limit: budget.decoder_sessions,
117        });
118    }
119    DegradationDecision {
120        texture_bytes,
121        max_filter_samples,
122        decoder_available,
123        diagnostics,
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn tier_b_and_caller_budgets_degrade_observably() {
133        let capabilities = Capabilities::new(
134            CapabilityTier::TierB,
135            IntermediateFormat::Rgba8Unorm,
136            "test",
137            4096,
138            false,
139            "Gl",
140            "software",
141            "fixture",
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        let budget = ResourceBudget::new(1_000, 1, 0);
177        let decision = resolve_degradation(&capabilities, 1_000, 256, budget);
178        assert_eq!(decision.max_filter_samples, 64);
179        assert_eq!(
180            decision.diagnostics,
181            [CapabilityDiagnostic::FilterSamplesReduced {
182                requested: 256,
183                applied: 64
184            }]
185        );
186        assert_eq!(
187            resolve_degradation(&capabilities, 1_000, 64, budget).diagnostics,
188            [] as [CapabilityDiagnostic; 0]
189        );
190    }
191}