Skip to main content

alpha2_engine/
quality.rs

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