Skip to main content

alpha2_engine/
quality.rs

1use alpha2_model::ProjectSettings;
2use alpha2_render::RenderSize;
3
4use crate::EngineError;
5
6// The project model and the renderer bound a canvas independently. They must stay the same limit
7// so a canvas the model accepts renders at full scale.
8const _: () = assert!(
9    alpha2_model::MAX_CANVAS_DIMENSION == RenderSize::MAX_DIMENSION
10        && alpha2_model::MAX_CANVAS_PIXELS == RenderSize::MAX_PIXELS
11);
12
13/// Runtime quality controls shared by real-time playback and export.
14#[derive(Copy, Clone, Debug, PartialEq)]
15#[non_exhaustive]
16pub struct QualityProfile {
17    /// Output scale relative to project dimensions.
18    pub resolution_scale: f32,
19    /// Maximum sampling work available to a filter pass.
20    pub max_filter_samples: u32,
21    /// Temporal samples used by motion blur implementations.
22    pub motion_blur_subframes: u32,
23    /// Soft budget available to reusable GPU textures.
24    pub texture_budget_bytes: u64,
25}
26
27impl Default for QualityProfile {
28    fn default() -> Self {
29        Self::export()
30    }
31}
32
33impl QualityProfile {
34    /// Largest supported multiplier for project dimensions.
35    pub const MAX_RESOLUTION_SCALE: f32 = 8.0;
36    /// Largest per-pass filter sampling budget: the most any pass takes
37    /// ([`alpha2_render::RenderQualityControls::MAX_FILTER_SAMPLES`]). A
38    /// Tier-B renderer applies less, and reports it.
39    pub const MAX_FILTER_SAMPLES: u32 = alpha2_render::RenderQualityControls::MAX_FILTER_SAMPLES;
40    /// Largest temporal sample count allocated for one render plan.
41    pub const MAX_MOTION_BLUR_SUBFRAMES: u32 = 256;
42    /// Smallest useful reusable-texture pool budget.
43    pub const MIN_TEXTURE_BUDGET_BYTES: u64 = 1024 * 1024;
44    /// Largest accepted reusable-texture pool budget.
45    pub const MAX_TEXTURE_BUDGET_BYTES: u64 = 64 * 1024 * 1024 * 1024;
46
47    /// Full-resolution deterministic export defaults.
48    #[must_use]
49    pub const fn export() -> Self {
50        Self {
51            resolution_scale: 1.0,
52            max_filter_samples: 64,
53            motion_blur_subframes: 1,
54            texture_budget_bytes: 512 * 1024 * 1024,
55        }
56    }
57
58    /// Responsive half-resolution playback defaults.
59    #[must_use]
60    pub const fn preview() -> Self {
61        Self {
62            resolution_scale: 0.5,
63            max_filter_samples: 32,
64            motion_blur_subframes: 1,
65            texture_budget_bytes: 256 * 1024 * 1024,
66        }
67    }
68
69    /// Checks that all controls are finite and within runtime-safe bounds.
70    ///
71    /// # Errors
72    /// Returns [`EngineError::InvalidQualityProfile`] for out-of-range values.
73    pub fn validate(self) -> Result<(), EngineError> {
74        if self.resolution_scale.is_finite()
75            && self.resolution_scale > 0.0
76            && self.resolution_scale <= Self::MAX_RESOLUTION_SCALE
77            && (1..=Self::MAX_FILTER_SAMPLES).contains(&self.max_filter_samples)
78            && (1..=Self::MAX_MOTION_BLUR_SUBFRAMES).contains(&self.motion_blur_subframes)
79            && (Self::MIN_TEXTURE_BUDGET_BYTES..=Self::MAX_TEXTURE_BUDGET_BYTES)
80                .contains(&self.texture_budget_bytes)
81        {
82            Ok(())
83        } else {
84            Err(EngineError::InvalidQualityProfile)
85        }
86    }
87
88    /// Resolves scaled project dimensions, rounding to the nearest pixel.
89    ///
90    /// # Errors
91    /// Returns [`EngineError`] for an invalid profile or unrepresentable size.
92    pub fn render_size(self, settings: &ProjectSettings) -> Result<RenderSize, EngineError> {
93        self.validate()?;
94        let width = scaled_dimension(settings.width, self.resolution_scale)?;
95        let height = scaled_dimension(settings.height, self.resolution_scale)?;
96        RenderSize::new(width, height).map_err(EngineError::from)
97    }
98
99    /// The size of the 4:2:0 frames an export at this profile renders: the
100    /// [render size](Self::render_size), which must be even in both
101    /// dimensions. Every export entry point refuses an odd one with this
102    /// before it renders, so the error names the nearest even size instead
103    /// of the readback failing deep inside the GPU work.
104    ///
105    /// # Errors
106    /// Returns [`EngineError::OddExportDimensions`], or what
107    /// [`Self::render_size`] returns.
108    pub fn export_size(self, settings: &ProjectSettings) -> Result<RenderSize, EngineError> {
109        let size = self.render_size(settings)?;
110        if size.width % 2 != 0 || size.height % 2 != 0 {
111            let even = |value: u32| match value {
112                1 => 2,
113                odd if odd % 2 != 0 => odd - 1,
114                even => even,
115            };
116            return Err(EngineError::OddExportDimensions {
117                width: size.width,
118                height: size.height,
119                suggested: (even(size.width), even(size.height)),
120            });
121        }
122        Ok(size)
123    }
124
125    /// Captures project, adapter, driver, backend, and quality facts for a benchmark.
126    ///
127    /// # Errors
128    /// Returns [`EngineError`] when the profile cannot resolve output dimensions.
129    pub fn benchmark_metadata(
130        self,
131        project: impl Into<String>,
132        settings: &ProjectSettings,
133        renderer: &alpha2_render::Renderer,
134    ) -> Result<alpha2_render::BenchmarkMetadata, EngineError> {
135        let size = self.render_size(settings)?;
136        Ok(alpha2_render::BenchmarkMetadata {
137            project: project.into(),
138            alpha2_version: env!("CARGO_PKG_VERSION"),
139            adapter: renderer.diagnostics().adapter,
140            dimensions: (size.width, size.height),
141            resolution_scale: self.resolution_scale,
142            max_filter_samples: self.max_filter_samples,
143            motion_blur_subframes: self.motion_blur_subframes,
144            texture_budget_bytes: self.texture_budget_bytes,
145        })
146    }
147}
148
149fn scaled_dimension(value: u32, scale: f32) -> Result<u32, EngineError> {
150    let scaled = f64::from(value) * f64::from(scale);
151    if scaled.is_finite() && scaled <= f64::from(u32::MAX) {
152        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
153        Ok((scaled.round() as u32).max(1))
154    } else {
155        Err(EngineError::OutOfRange)
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use alpha2_time::FrameRate;
162
163    use super::*;
164
165    #[test]
166    fn quality_scales_dimensions_and_rejects_zero() {
167        let settings = ProjectSettings::new(
168            1920,
169            1080,
170            FrameRate::new(30, 1).expect("valid rate"),
171            48_000,
172        )
173        .expect("valid settings");
174        assert_eq!(
175            QualityProfile::preview()
176                .render_size(&settings)
177                .expect("valid size"),
178            RenderSize::new(960, 540).expect("valid size")
179        );
180        assert_eq!(
181            QualityProfile {
182                resolution_scale: 0.0,
183                ..QualityProfile::default()
184            }
185            .validate(),
186            Err(EngineError::InvalidQualityProfile)
187        );
188        assert_eq!(
189            QualityProfile {
190                motion_blur_subframes: u32::MAX,
191                ..QualityProfile::default()
192            }
193            .validate(),
194            Err(EngineError::InvalidQualityProfile)
195        );
196        assert_eq!(
197            QualityProfile {
198                texture_budget_bytes: 1,
199                ..QualityProfile::default()
200            }
201            .validate(),
202            Err(EngineError::InvalidQualityProfile)
203        );
204    }
205}