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    /// This profile with `scale` as its multiplier for project dimensions;
70    /// [`Self::validate`] checks it.
71    #[must_use]
72    pub const fn with_resolution_scale(mut self, scale: f32) -> Self {
73        self.resolution_scale = scale;
74        self
75    }
76
77    /// Checks that all controls are finite and within runtime-safe bounds.
78    ///
79    /// # Errors
80    /// Returns [`EngineError::InvalidQualityProfile`] for out-of-range values.
81    pub fn validate(self) -> Result<(), EngineError> {
82        if self.resolution_scale.is_finite()
83            && self.resolution_scale > 0.0
84            && self.resolution_scale <= Self::MAX_RESOLUTION_SCALE
85            && (1..=Self::MAX_FILTER_SAMPLES).contains(&self.max_filter_samples)
86            && (1..=Self::MAX_MOTION_BLUR_SUBFRAMES).contains(&self.motion_blur_subframes)
87            && (Self::MIN_TEXTURE_BUDGET_BYTES..=Self::MAX_TEXTURE_BUDGET_BYTES)
88                .contains(&self.texture_budget_bytes)
89        {
90            Ok(())
91        } else {
92            Err(EngineError::InvalidQualityProfile)
93        }
94    }
95
96    /// Resolves scaled project dimensions, rounding to the nearest pixel.
97    ///
98    /// # Errors
99    /// Returns [`EngineError`] for an invalid profile or unrepresentable size.
100    pub fn render_size(self, settings: &ProjectSettings) -> Result<RenderSize, EngineError> {
101        self.validate()?;
102        let width = scaled_dimension(settings.width, self.resolution_scale)?;
103        let height = scaled_dimension(settings.height, self.resolution_scale)?;
104        RenderSize::new(width, height).map_err(EngineError::from)
105    }
106
107    /// The size of the 4:2:0 frames an export at this profile renders: the
108    /// [render size](Self::render_size), which must be even in both
109    /// dimensions. Every export entry point refuses an odd one with this
110    /// before it renders, so the error names the nearest even size instead
111    /// of the readback failing deep inside the GPU work.
112    ///
113    /// # Errors
114    /// Returns [`EngineError::OddExportDimensions`], or what
115    /// [`Self::render_size`] returns.
116    pub fn export_size(self, settings: &ProjectSettings) -> Result<RenderSize, EngineError> {
117        let size = self.render_size(settings)?;
118        if size.width % 2 != 0 || size.height % 2 != 0 {
119            let even = |value: u32| match value {
120                1 => 2,
121                odd if odd % 2 != 0 => odd - 1,
122                even => even,
123            };
124            return Err(EngineError::OddExportDimensions {
125                width: size.width,
126                height: size.height,
127                suggested: (even(size.width), even(size.height)),
128            });
129        }
130        Ok(size)
131    }
132
133    /// Captures project, adapter, driver, backend, and quality facts for a benchmark.
134    ///
135    /// # Errors
136    /// Returns [`EngineError`] when the profile cannot resolve output dimensions.
137    pub fn benchmark_metadata(
138        self,
139        project: impl Into<String>,
140        settings: &ProjectSettings,
141        renderer: &alpha2_render::Renderer,
142    ) -> Result<alpha2_render::BenchmarkMetadata, EngineError> {
143        let size = self.render_size(settings)?;
144        Ok(alpha2_render::BenchmarkMetadata {
145            project: project.into(),
146            alpha2_version: env!("CARGO_PKG_VERSION"),
147            adapter: renderer.diagnostics().adapter,
148            dimensions: (size.width, size.height),
149            resolution_scale: self.resolution_scale,
150            max_filter_samples: self.max_filter_samples,
151            motion_blur_subframes: self.motion_blur_subframes,
152            texture_budget_bytes: self.texture_budget_bytes,
153        })
154    }
155}
156
157fn scaled_dimension(value: u32, scale: f32) -> Result<u32, EngineError> {
158    let scaled = f64::from(value) * f64::from(scale);
159    if scaled.is_finite() && scaled <= f64::from(u32::MAX) {
160        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
161        Ok((scaled.round() as u32).max(1))
162    } else {
163        Err(EngineError::OutOfRange)
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use alpha2_time::FrameRate;
170
171    use super::*;
172
173    #[test]
174    fn quality_scales_dimensions_and_rejects_zero() {
175        let settings = ProjectSettings::new(
176            1920,
177            1080,
178            FrameRate::new(30, 1).expect("valid rate"),
179            48_000,
180        )
181        .expect("valid settings");
182        assert_eq!(
183            QualityProfile::preview()
184                .render_size(&settings)
185                .expect("valid size"),
186            RenderSize::new(960, 540).expect("valid size")
187        );
188        assert_eq!(
189            QualityProfile {
190                resolution_scale: 0.0,
191                ..QualityProfile::default()
192            }
193            .validate(),
194            Err(EngineError::InvalidQualityProfile)
195        );
196        assert_eq!(
197            QualityProfile {
198                motion_blur_subframes: u32::MAX,
199                ..QualityProfile::default()
200            }
201            .validate(),
202            Err(EngineError::InvalidQualityProfile)
203        );
204        assert_eq!(
205            QualityProfile {
206                texture_budget_bytes: 1,
207                ..QualityProfile::default()
208            }
209            .validate(),
210            Err(EngineError::InvalidQualityProfile)
211        );
212    }
213
214    #[test]
215    fn a_preview_scale_sizes_the_render_and_is_validated() {
216        let settings = |width, height| {
217            ProjectSettings::new(
218                width,
219                height,
220                FrameRate::new(30, 1).expect("valid rate"),
221                48_000,
222            )
223            .expect("valid settings")
224        };
225        let size = |scale: f32, width, height| {
226            QualityProfile::preview()
227                .with_resolution_scale(scale)
228                .render_size(&settings(width, height))
229                .map(|size| (size.width, size.height))
230        };
231        assert_eq!(size(0.5, 1280, 720), Ok((640, 360)));
232        assert_eq!(size(1.0, 1280, 720), Ok((1280, 720)));
233        // An odd document rounds to the nearest pixel.
234        assert_eq!(size(0.5, 1281, 721), Ok((641, 361)));
235        assert_eq!(size(0.25, 1282, 722), Ok((321, 181)));
236        for scale in [0.0, -1.0, 8.5, f32::NAN, f32::INFINITY] {
237            assert_eq!(
238                QualityProfile::preview()
239                    .with_resolution_scale(scale)
240                    .validate(),
241                Err(EngineError::InvalidQualityProfile),
242                "{scale}"
243            );
244        }
245        assert_eq!(
246            QualityProfile::preview()
247                .with_resolution_scale(QualityProfile::MAX_RESOLUTION_SCALE)
248                .validate(),
249            Ok(())
250        );
251    }
252}