Skip to main content

motion_core/authoring/
sampling.rs

1//! Integer-tick centered shutter sampling and linear accumulation.
2//!
3//! ```
4//! use motion_core::prelude::*;
5//! let cache = SceneCache::default();
6//! let context = SceneContext::new(
7//!     Time::from_frame(1, fps!(30)),
8//!     fps!(30),
9//!     Size::new(8.0, 4.0),
10//!     &cache,
11//! );
12//! let node = context.motion_blur(2.milliseconds(), 2, |sample| {
13//!     Node::from(Rect::new(sample.seconds() * 4.0, 0.0, 4.0, 4.0).fill(Color::WHITE))
14//! });
15//! node.compile(
16//!     context.size(),
17//!     PixelSize::new(8, 4),
18//!     ValidationLimits::default(),
19//! )?;
20//! # Ok::<(), motion_core::Error>(())
21//! ```
22
23use crate::{Duration, FrameRate, Node, SceneContext, TimeOffset, ValidationError};
24
25/// Global shutter configuration; with exact centered tick offsets.
26#[derive(Clone, Copy, Debug, PartialEq)]
27pub struct MotionBlur {
28    /// Exposure angle in 0..=360 degrees; only final integer ticks are rounded.
29    pub shutter_angle: f64,
30    /// Equally weighted midpoint captures, in 1..=64.
31    pub samples: u32,
32}
33
34impl Default for MotionBlur {
35    fn default() -> Self {
36        Self {
37            shutter_angle: 180.0,
38            samples: 8,
39        }
40    }
41}
42
43impl MotionBlur {
44    /// Validates without evaluating scenes or initializing rendering resources.
45    pub fn validate(self) -> Result<(), ValidationError> {
46        if !self.shutter_angle.is_finite()
47            || !(0.0..=360.0).contains(&self.shutter_angle)
48            || !(1..=64).contains(&self.samples)
49        {
50            Err(invalid(
51                "configuration",
52                "Require finite shutter angle in 0..=360 and 1..=64 samples",
53            ))
54        } else {
55            Ok(())
56        }
57    }
58
59    /// Derives exposure from the exact rational rate, rounded once to nearest tick.
60    pub fn shutter(self, rate: FrameRate) -> Result<Duration, ValidationError> {
61        self.validate()?;
62        let bits = self.shutter_angle.to_bits();
63        let exponent = ((bits >> 52) & 0x7ff) as u32;
64        let mantissa = (bits & ((1u64 << 52) - 1)) | if exponent == 0 { 0 } else { 1u64 << 52 };
65        let numerator = i128::from(crate::TICKS_PER_SECOND)
66            * i128::from(rate.denominator())
67            * i128::from(mantissa);
68        let shift = if exponent == 0 { 1074 } else { 1075 - exponent };
69        let Some(denominator) = 1i128
70            .checked_shl(shift)
71            .and_then(|scale| scale.checked_mul(i128::from(rate.numerator()) * 360))
72            .filter(|value| *value > 0)
73        else {
74            return Ok(Duration::ZERO);
75        };
76        let ticks = (numerator + denominator / 2) / denominator;
77        Ok(Duration::from_ticks(
78            i64::try_from(ticks).map_err(|_| invalid("shutter", "Exposure exceeds signed ticks"))?
79                as u64,
80        ))
81    }
82}
83
84fn invalid(field: &str, message: &str) -> ValidationError {
85    ValidationError::invalid("motion_blur", field, message)
86}
87
88/// Normalized temporal exposure profile.
89#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
90pub enum ShutterCurve {
91    /// Uniform rectangular exposure.
92    #[default]
93    Box,
94    /// Symmetric triangular exposure.
95    Triangle,
96    /// Smooth raised-cosine exposure.
97    Cosine,
98}
99
100/// Explicit deterministic jitter, exposure weights and adaptive screen-space sampling.
101#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
102pub struct ShutterSampling {
103    /// None uses centered integer midpoint offsets; Some enables stratified jitter.
104    pub jitter_seed: Option<u64>,
105    /// Estimate endpoint geometry displacement and select 8..=64 samples.
106    pub adaptive: bool,
107    /// Temporal exposure profile, normalized over the selected samples.
108    pub curve: ShutterCurve,
109}
110
111impl ShutterSampling {
112    /// Captures a full scene or an explicitly selected subtree. Nested shutter captures
113    /// are rejected. Endpoint estimates cannot detect motion returning to its starting pose.
114    pub fn capture(
115        self,
116        blur: MotionBlur,
117        context: &SceneContext<'_>,
118        output: crate::PixelSize,
119        mut build: impl FnMut(&SceneContext<'_>) -> Node,
120    ) -> Result<Node, ValidationError> {
121        let shutter = blur.shutter(context.frame_rate())?;
122        output.mapping(context.size())?;
123        if context.render_quality() == crate::RenderQuality::Draft {
124            return Ok(build(context));
125        }
126        let count = if self.adaptive && shutter > Duration::ZERO {
127            let half = TimeOffset::from_ticks((shutter.ticks() / 2) as i64);
128            let left = context.resampled(-half)?;
129            let right = context.resampled(half)?;
130            let drawing = crate::DrawContext::default().with_assets(context.assets());
131            let first = build(&left).compile_with_context(
132                context.size(),
133                output,
134                crate::ValidationLimits::default(),
135                &drawing,
136            )?;
137            let last = build(&right).compile_with_context(
138                context.size(),
139                output,
140                crate::ValidationLimits::default(),
141                &drawing,
142            )?;
143            displacement_samples(first.display_list(), last.display_list())
144        } else {
145            blur.samples
146        };
147        let mut offsets = sample_offsets(shutter, count)?;
148        if let Some(seed) = self.jitter_seed {
149            let frame = context.time().ticks() as u64;
150            for (index, offset) in offsets.iter_mut().enumerate() {
151                let random = shutter_random(seed ^ frame.rotate_left(17) ^ index as u64);
152                let numerator = (index as f64 + random) / f64::from(count) - 0.5;
153                *offset =
154                    TimeOffset::from_ticks((numerator * shutter.ticks() as f64).round() as i64);
155            }
156        }
157        let weights = (0..count)
158            .map(|index| {
159                let phase = (f64::from(index) + 0.5) / f64::from(count);
160                match self.curve {
161                    ShutterCurve::Box => 1.0,
162                    ShutterCurve::Triangle => 1.0 - (phase * 2.0 - 1.0).abs(),
163                    ShutterCurve::Cosine => 0.5 - 0.5 * libm::cos(std::f64::consts::TAU * phase),
164                }
165            })
166            .collect::<Vec<_>>();
167        let total: f64 = weights.iter().sum();
168        let mut nodes = Vec::with_capacity(count as usize);
169        for (offset, weight) in offsets.into_iter().zip(weights) {
170            let node = build(&context.resampled(offset)?);
171            if node.contains_shutter(0) {
172                return Err(invalid(
173                    "scope",
174                    "Do not combine full-scene and subtree shutter captures",
175                ));
176            }
177            nodes.push((node, weight / total));
178        }
179        Ok(Node::weighted_sum(nodes)?.shutter_sampled())
180    }
181}
182
183pub(crate) fn shutter_random(mut value: u64) -> f64 {
184    value = (value ^ (value >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
185    value = (value ^ (value >> 27)).wrapping_mul(0x94d049bb133111eb);
186    value ^= value >> 31;
187    (value >> 11) as f64 / (1u64 << 53) as f64
188}
189
190fn displacement_samples(first: &crate::DisplayList, last: &crate::DisplayList) -> u32 {
191    use crate::DrawCommand;
192    if first.commands.len() != last.commands.len() {
193        return 64;
194    }
195    let mut maximum = 0.0f64;
196    for (first, last) in first.commands.iter().zip(&last.commands) {
197        let points = |command: &DrawCommand| -> Option<Vec<crate::Point>> {
198            match command {
199                DrawCommand::FillRect {
200                    bounds, transform, ..
201                } => Some(
202                    [
203                        crate::Point::new(bounds.left, bounds.top),
204                        crate::Point::new(bounds.right, bounds.top),
205                        crate::Point::new(bounds.right, bounds.bottom),
206                        crate::Point::new(bounds.left, bounds.bottom),
207                    ]
208                    .map(|point| transform.apply(point))
209                    .to_vec(),
210                ),
211                DrawCommand::Fill {
212                    path, transform, ..
213                } => Some(
214                    path.verbs()
215                        .iter()
216                        .flat_map(|verb| verb.points().into_iter().flatten())
217                        .map(|point| transform.apply(point))
218                        .collect(),
219                ),
220                _ => None,
221            }
222        };
223        match (points(first), points(last)) {
224            (Some(first), Some(last)) if first.len() == last.len() => {
225                for (first, last) in first.into_iter().zip(last) {
226                    maximum = maximum.max(libm::hypot(first.x - last.x, first.y - last.y));
227                }
228            }
229            (None, None) if first == last => {}
230            _ => return 64,
231        }
232    }
233    (maximum.ceil() as u32).clamp(8, 64)
234}
235
236/// Centered integer midpoint offsets, nearest-tick ties away from zero.
237pub fn sample_offsets(shutter: Duration, samples: u32) -> Result<Vec<TimeOffset>, ValidationError> {
238    if !(1..=64).contains(&samples) {
239        return Err(invalid("samples", "Require 1..=64 samples"));
240    }
241    let denominator = i128::from(samples) * 2;
242    Ok((0..samples)
243        .map(|index| {
244            let numerator =
245                (i128::from(index) * 2 + 1 - i128::from(samples)) * i128::from(shutter.ticks());
246            TimeOffset::from_ticks(
247                (((numerator.abs() + denominator / 2) / denominator) * numerator.signum()) as i64,
248            )
249        })
250        .collect())
251}
252
253pub(crate) fn capture(
254    context: &SceneContext<'_>,
255    shutter: Duration,
256    samples: u32,
257    mut build: impl FnMut(&SceneContext<'_>) -> Node,
258) -> Result<Node, ValidationError> {
259    let offsets = sample_offsets(shutter, samples)?;
260    if context.render_quality() == crate::RenderQuality::Draft {
261        return Ok(build(context));
262    }
263    let contexts = offsets
264        .into_iter()
265        .map(|offset| context.resampled(offset))
266        .collect::<Result<Vec<_>, _>>()?;
267    let nodes = contexts.iter().map(&mut build).collect::<Vec<_>>();
268    Ok(Node::accumulate(nodes)?.shutter_sampled())
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::{Shape, Time};
275
276    #[test]
277    fn jittered_capture_replays_and_rejects_nested_scopes() {
278        use crate::NodeModifiers;
279        let cache = crate::SceneCache::default();
280        let context = SceneContext::new(
281            Duration::from_frame(5, crate::fps!(24)).after_origin(),
282            crate::fps!(24),
283            crate::Size::new(8.0, 8.0),
284            &cache,
285        );
286        let policy = ShutterSampling {
287            jitter_seed: Some(17),
288            adaptive: true,
289            curve: ShutterCurve::Triangle,
290        };
291        let build = |sample: &SceneContext<'_>| {
292            Node::from(crate::Rect::new(0.0, 0.0, 2.0, 2.0).fill(crate::Color::WHITE))
293                .translate(sample.seconds(), 0.0)
294        };
295        let first = policy
296            .capture(
297                MotionBlur::default(),
298                &context,
299                crate::PixelSize::new(8, 8),
300                build,
301            )
302            .unwrap();
303        let second = policy
304            .capture(
305                MotionBlur::default(),
306                &context,
307                crate::PixelSize::new(8, 8),
308                build,
309            )
310            .unwrap();
311        assert_eq!(
312            first
313                .compile(
314                    context.size(),
315                    crate::PixelSize::new(8, 8),
316                    crate::ValidationLimits::default()
317                )
318                .unwrap(),
319            second
320                .compile(
321                    context.size(),
322                    crate::PixelSize::new(8, 8),
323                    crate::ValidationLimits::default()
324                )
325                .unwrap()
326        );
327        assert!(
328            policy
329                .capture(
330                    MotionBlur::default(),
331                    &context,
332                    crate::PixelSize::new(8, 8),
333                    |_| Node::group([first.clone()])
334                )
335                .is_err()
336        );
337        assert!(
338            ShutterSampling::default()
339                .capture(
340                    MotionBlur::default(),
341                    &context,
342                    crate::PixelSize::new(8, 8),
343                    |_| Node::group([first.clone()])
344                )
345                .is_err(),
346            "The default policy rejects nested captures too"
347        );
348    }
349
350    #[test]
351    fn draft_subtree_shutter_samples_once_but_still_validates() {
352        let cache = crate::SceneCache::default();
353        let context = SceneContext::new(
354            Time::from_ticks(0),
355            crate::fps!(30),
356            crate::Size::new(8.0, 4.0),
357            &cache,
358        );
359        for (quality, expected) in [
360            (crate::RenderQuality::Draft, 1),
361            (crate::RenderQuality::Final, 4),
362        ] {
363            let mut calls = 0;
364            capture(
365                &context.clone().with_quality(quality),
366                Duration::from_ticks(100),
367                4,
368                |_| {
369                    calls += 1;
370                    Node::empty()
371                },
372            )
373            .unwrap();
374            assert_eq!(calls, expected);
375        }
376        assert!(
377            capture(
378                &context.clone().with_quality(crate::RenderQuality::Draft),
379                Duration::from_ticks(100),
380                0,
381                |_| panic!("invalid shutter must not evaluate")
382            )
383            .is_err()
384        );
385    }
386}