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, FrameRate::from(30)),
8//!     FrameRate::from(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, Time, 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(Time::ZERO);
75        };
76        let ticks = (numerator + denominator / 2) / denominator;
77        Ok(Time::from_ticks(i64::try_from(ticks).map_err(|_| {
78            invalid("shutter", "Exposure exceeds signed ticks")
79        })?))
80    }
81}
82
83fn invalid(field: &str, message: &str) -> ValidationError {
84    ValidationError::invalid("motion_blur", field, message)
85}
86
87/// Normalized temporal exposure profile.
88#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
89pub enum ShutterCurve {
90    /// Uniform rectangular exposure.
91    #[default]
92    Box,
93    /// Symmetric triangular exposure.
94    Triangle,
95    /// Smooth raised-cosine exposure.
96    Cosine,
97}
98
99/// Explicit deterministic jitter, exposure weights and adaptive screen-space sampling.
100#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
101pub struct ShutterSampling {
102    /// None uses centered integer midpoint offsets; Some enables stratified jitter.
103    pub jitter_seed: Option<u64>,
104    /// Estimate endpoint geometry displacement and select 8..=64 samples.
105    pub adaptive: bool,
106    /// Temporal exposure profile, normalized over the selected samples.
107    pub curve: ShutterCurve,
108}
109
110impl ShutterSampling {
111    /// Captures a full scene or an explicitly selected subtree. Nested shutter captures
112    /// are rejected. Endpoint estimates cannot detect motion returning to its starting pose.
113    pub fn capture(
114        self,
115        blur: MotionBlur,
116        context: &SceneContext<'_>,
117        output: crate::PixelSize,
118        mut build: impl FnMut(&SceneContext<'_>) -> Node,
119    ) -> Result<Node, ValidationError> {
120        let shutter = blur.shutter(context.frame_rate())?;
121        output.mapping(context.size())?;
122        if context.render_quality() == crate::RenderQuality::Draft {
123            return Ok(build(context));
124        }
125        let count = if self.adaptive && shutter > Time::ZERO {
126            let left = context.resampled(Time::from_ticks(-shutter.ticks() / 2))?;
127            let right = context.resampled(Time::from_ticks(shutter.ticks() / 2))?;
128            let drawing = crate::DrawContext::default().with_assets(context.assets());
129            let first = build(&left).compile_with_context(
130                context.size(),
131                output,
132                crate::ValidationLimits::default(),
133                &drawing,
134            )?;
135            let last = build(&right).compile_with_context(
136                context.size(),
137                output,
138                crate::ValidationLimits::default(),
139                &drawing,
140            )?;
141            displacement_samples(first.display_list(), last.display_list())
142        } else {
143            blur.samples
144        };
145        let mut offsets = sample_offsets(shutter, count)?;
146        if let Some(seed) = self.jitter_seed {
147            let frame = context.time().ticks() as u64;
148            for (index, offset) in offsets.iter_mut().enumerate() {
149                let random = shutter_random(seed ^ frame.rotate_left(17) ^ index as u64);
150                let numerator = (index as f64 + random) / f64::from(count) - 0.5;
151                *offset = Time::from_ticks((numerator * shutter.ticks() as f64).round() as i64);
152            }
153        }
154        let weights = (0..count)
155            .map(|index| {
156                let phase = (f64::from(index) + 0.5) / f64::from(count);
157                match self.curve {
158                    ShutterCurve::Box => 1.0,
159                    ShutterCurve::Triangle => 1.0 - (phase * 2.0 - 1.0).abs(),
160                    ShutterCurve::Cosine => 0.5 - 0.5 * libm::cos(std::f64::consts::TAU * phase),
161                }
162            })
163            .collect::<Vec<_>>();
164        let total: f64 = weights.iter().sum();
165        let mut nodes = Vec::with_capacity(count as usize);
166        for (offset, weight) in offsets.into_iter().zip(weights) {
167            let node = build(&context.resampled(offset)?);
168            if node.contains_shutter(0) {
169                return Err(invalid(
170                    "scope",
171                    "Do not combine full-scene and subtree shutter captures",
172                ));
173            }
174            nodes.push((node, weight / total));
175        }
176        Ok(Node::weighted_sum(nodes)?.shutter_sampled())
177    }
178}
179
180pub(crate) fn shutter_random(mut value: u64) -> f64 {
181    value = (value ^ (value >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
182    value = (value ^ (value >> 27)).wrapping_mul(0x94d049bb133111eb);
183    value ^= value >> 31;
184    (value >> 11) as f64 / (1u64 << 53) as f64
185}
186
187fn displacement_samples(first: &crate::DisplayList, last: &crate::DisplayList) -> u32 {
188    use crate::DrawCommand;
189    if first.commands.len() != last.commands.len() {
190        return 64;
191    }
192    let mut maximum = 0.0f64;
193    for (first, last) in first.commands.iter().zip(&last.commands) {
194        let points = |command: &DrawCommand| -> Option<Vec<crate::Point>> {
195            match command {
196                DrawCommand::FillRect {
197                    bounds, transform, ..
198                } => Some(
199                    [
200                        crate::Point::new(bounds.left, bounds.top),
201                        crate::Point::new(bounds.right, bounds.top),
202                        crate::Point::new(bounds.right, bounds.bottom),
203                        crate::Point::new(bounds.left, bounds.bottom),
204                    ]
205                    .map(|point| transform.apply(point))
206                    .to_vec(),
207                ),
208                DrawCommand::Fill {
209                    path, transform, ..
210                } => Some(
211                    path.verbs()
212                        .iter()
213                        .flat_map(|verb| verb.points().into_iter().flatten())
214                        .map(|point| transform.apply(point))
215                        .collect(),
216                ),
217                _ => None,
218            }
219        };
220        match (points(first), points(last)) {
221            (Some(first), Some(last)) if first.len() == last.len() => {
222                for (first, last) in first.into_iter().zip(last) {
223                    maximum = maximum.max(libm::hypot(first.x - last.x, first.y - last.y));
224                }
225            }
226            (None, None) if first == last => {}
227            _ => return 64,
228        }
229    }
230    (maximum.ceil() as u32).clamp(8, 64)
231}
232
233/// Centered integer midpoint offsets, nearest-tick ties away from zero.
234pub fn sample_offsets(shutter: Duration, samples: u32) -> Result<Vec<Duration>, ValidationError> {
235    if shutter < Time::ZERO || !(1..=64).contains(&samples) {
236        return Err(invalid(
237            "samples",
238            "Require nonnegative shutter and 1..=64 samples",
239        ));
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            Time::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
275    #[test]
276    fn jittered_capture_replays_and_rejects_nested_scopes() {
277        use crate::NodeModifiers;
278        let cache = crate::SceneCache::default();
279        let context = SceneContext::new(
280            Time::from_frame(5, 24.into()),
281            24.into(),
282            crate::Size::new(8.0, 8.0),
283            &cache,
284        );
285        let policy = ShutterSampling {
286            jitter_seed: Some(17),
287            adaptive: true,
288            curve: ShutterCurve::Triangle,
289        };
290        let build = |sample: &SceneContext<'_>| {
291            Node::from(crate::Rect::new(0.0, 0.0, 2.0, 2.0).fill(crate::Color::WHITE))
292                .translate(sample.seconds(), 0.0)
293        };
294        let first = policy
295            .capture(
296                MotionBlur::default(),
297                &context,
298                crate::PixelSize::new(8, 8),
299                build,
300            )
301            .unwrap();
302        let second = policy
303            .capture(
304                MotionBlur::default(),
305                &context,
306                crate::PixelSize::new(8, 8),
307                build,
308            )
309            .unwrap();
310        assert_eq!(
311            first
312                .compile(
313                    context.size(),
314                    crate::PixelSize::new(8, 8),
315                    crate::ValidationLimits::default()
316                )
317                .unwrap(),
318            second
319                .compile(
320                    context.size(),
321                    crate::PixelSize::new(8, 8),
322                    crate::ValidationLimits::default()
323                )
324                .unwrap()
325        );
326        assert!(
327            policy
328                .capture(
329                    MotionBlur::default(),
330                    &context,
331                    crate::PixelSize::new(8, 8),
332                    |_| Node::group([first.clone()])
333                )
334                .is_err()
335        );
336        assert!(
337            ShutterSampling::default()
338                .capture(
339                    MotionBlur::default(),
340                    &context,
341                    crate::PixelSize::new(8, 8),
342                    |_| Node::group([first.clone()])
343                )
344                .is_err(),
345            "The default policy rejects nested captures too"
346        );
347    }
348
349    #[test]
350    fn draft_subtree_shutter_samples_once_but_still_validates() {
351        let cache = crate::SceneCache::default();
352        let context = SceneContext::new(
353            Time::from_ticks(0),
354            30.into(),
355            crate::Size::new(8.0, 4.0),
356            &cache,
357        );
358        for (quality, expected) in [
359            (crate::RenderQuality::Draft, 1),
360            (crate::RenderQuality::Final, 4),
361        ] {
362            let mut calls = 0;
363            capture(&context.quality(quality), Time::from_ticks(100), 4, |_| {
364                calls += 1;
365                Node::empty()
366            })
367            .unwrap();
368            assert_eq!(calls, expected);
369        }
370        assert!(
371            capture(
372                &context.quality(crate::RenderQuality::Draft),
373                Time::from_ticks(100),
374                0,
375                |_| panic!("invalid shutter must not evaluate")
376            )
377            .is_err()
378        );
379    }
380}