Skip to main content

motion_content/
atmosphere.rs

1//! Deterministic advected fog, sparse dust and HDR light-leak image generators.
2//!
3//! Explicitly generated assets use the ordinary CPU/GPU image path; [`Atmosphere::paint`]
4//! instead captures independent procedural evaluation without baking. Time and global
5//! pixel coordinates are explicit; these are 2D overlays, not volumetric scattering.
6//!
7//! ```
8//! use motion_content::{Image, atmosphere::Atmosphere};
9//! use motion_core::{BlendMode, Node, NodeModifiers, PixelSize, RasterLimits, TimeLiteral};
10//! let asset = Atmosphere::light_leak(42).render(
11//!     PixelSize::new(32, 24), 1.seconds().after_origin(), RasterLimits::sandbox(),
12//! )?;
13//! let overlay = Node::from(Image::from_asset(std::sync::Arc::new(asset)))
14//!     .blend_mode(BlendMode::Plus);
15//! # let _ = overlay;
16//! # Ok::<(), motion_core::Error>(())
17//! ```
18
19use motion_core::{
20    Color, PixelSize, RasterLimits, Time, ValidationError,
21    budget::Budget,
22    noise::{self, Fractal},
23};
24
25use crate::ImageAsset;
26
27pub use motion_core::{AtmosphereKind, AtmosphereParameters};
28
29/// Editable bounded recipe; sampling retains no playback or random state.
30///
31/// The parameters are shared with the core paint, and the recipe dereferences to them so
32/// fields such as `opacity` remain directly editable. This type adds presets, baking and
33/// blend-mode selection.
34#[derive(Clone, Copy, Debug, PartialEq)]
35pub struct Atmosphere {
36    parameters: AtmosphereParameters,
37}
38
39impl std::ops::Deref for Atmosphere {
40    type Target = AtmosphereParameters;
41
42    fn deref(&self) -> &Self::Target {
43        &self.parameters
44    }
45}
46
47impl std::ops::DerefMut for Atmosphere {
48    fn deref_mut(&mut self) -> &mut Self::Target {
49        &mut self.parameters
50    }
51}
52
53impl Atmosphere {
54    /// Wraps explicit parameters; they are validated when the recipe is painted or rendered.
55    pub const fn new(parameters: AtmosphereParameters) -> Self {
56        Self { parameters }
57    }
58
59    /// A pale, low-contrast fog layer, denser near the bottom edge.
60    pub fn fog(seed: u32) -> Self {
61        Self {
62            parameters: AtmosphereParameters {
63                kind: AtmosphereKind::Fog {
64                    gradient: [0.15, 1.0],
65                },
66                seed,
67                color: Color::rgb(220, 233, 230),
68                opacity: 0.45,
69                intensity: 1.0,
70                scale: 96.0,
71                drift: [6.0, -2.0],
72                fractal: Fractal::default(),
73            },
74        }
75    }
76
77    /// Sparse warm HDR dust, suitable for Plus or Screen composition.
78    pub fn dust(seed: u32) -> Self {
79        Self {
80            parameters: AtmosphereParameters {
81                kind: AtmosphereKind::Dust {
82                    radius: 2.0,
83                    coverage: 0.3,
84                },
85                seed,
86                color: Color::rgb(255, 238, 209),
87                opacity: 0.65,
88                intensity: 4.0,
89                scale: 24.0,
90                drift: [5.0, -12.0],
91                fractal: Fractal::default(),
92            },
93        }
94    }
95
96    /// Warm HDR light entering from the left edge.
97    pub fn light_leak(seed: u32) -> Self {
98        Self {
99            parameters: AtmosphereParameters {
100                kind: AtmosphereKind::LightLeak {
101                    center: [0.0, 0.5],
102                    extent: [0.3, 0.65],
103                },
104                seed,
105                color: Color::rgb(255, 100, 32),
106                opacity: 0.8,
107                intensity: 3.0,
108                scale: 128.0,
109                drift: [2.0, 1.0],
110                fractal: Fractal::default(),
111            },
112        }
113    }
114
115    /// Validates the full recipe before noise evaluation or allocation.
116    pub fn validate(&self) -> Result<(), ValidationError> {
117        self.parameters.validate()
118    }
119
120    /// Captures the same recipe as an independently evaluated CPU/GPU paint.
121    ///
122    /// Canvas pixels determine wavelength, drift and envelope coordinates; painted
123    /// shape bounds map to that canvas. This does not bake or allocate an image.
124    pub fn paint(
125        &self,
126        canvas: PixelSize,
127        time: Time,
128    ) -> Result<motion_core::AtmospherePaint, ValidationError> {
129        motion_core::AtmospherePaint::new(self.parameters, canvas, time)
130    }
131
132    /// Blend mode that composes this recipe without darkening: Screen for fog, Plus for HDR light.
133    pub fn blend_mode(&self) -> motion_core::BlendMode {
134        match self.kind {
135            AtmosphereKind::Fog { .. } => motion_core::BlendMode::Screen,
136            AtmosphereKind::Dust { .. } | AtmosphereKind::LightLeak { .. } => {
137                motion_core::BlendMode::Plus
138            }
139        }
140    }
141
142    /// Renders the recipe at an explicit time into a bilinear-sampled node using
143    /// [`Self::blend_mode`]. The node has the pixel size as its design size, so it
144    /// fills a canvas of the same dimensions.
145    pub fn overlay(
146        &self,
147        size: PixelSize,
148        time: Time,
149        limits: RasterLimits,
150    ) -> motion_core::Result<motion_core::Node> {
151        use motion_core::NodeModifiers;
152        let asset = std::sync::Arc::new(self.render(size, time, limits)?);
153        Ok(motion_core::Node::from(
154            crate::Image::from_asset(asset).sampling(motion_core::Sampling::Bilinear),
155        )
156        .blend_mode(self.blend_mode()))
157    }
158
159    /// Generates a full image at explicit time after pixel, byte and work admission.
160    pub fn render(
161        &self,
162        size: PixelSize,
163        time: Time,
164        limits: RasterLimits,
165    ) -> motion_core::Result<ImageAsset> {
166        self.render_region(size, [0, 0], size, time, limits)
167    }
168
169    /// Generates a base-level region using full-canvas envelope and global pixels.
170    /// Adjacent regions match full-image base texels exactly; mipmaps are regional.
171    pub fn render_region(
172        &self,
173        canvas: PixelSize,
174        origin: [u32; 2],
175        size: PixelSize,
176        time: Time,
177        limits: RasterLimits,
178    ) -> motion_core::Result<ImageAsset> {
179        self.validate()?;
180        let valid_size = |size: PixelSize| {
181            size.width > 0 && size.height > 0 && size.width <= 16384 && size.height <= 16384
182        };
183        if !valid_size(canvas)
184            || !valid_size(size)
185            || u64::from(origin[0]) + u64::from(size.width) > u64::from(canvas.width)
186            || u64::from(origin[1]) + u64::from(size.height) > u64::from(canvas.height)
187        {
188            return Err(ValidationError::invalid(
189                "atmosphere",
190                "region",
191                "Require a positive bounded region inside a 1..=16384 canvas",
192            )
193            .into());
194        }
195        let pixels = u64::from(size.width) * u64::from(size.height);
196        if pixels > 16_777_216 {
197            return Err(ValidationError::limit(
198                "atmosphere",
199                "max_pixels",
200                "Image source exceeds 16777216 pixels",
201            )
202            .into());
203        }
204        let phase = self.drift.map(|velocity| -velocity * time.as_seconds());
205        for horizontal in [origin[0], origin[0] + size.width - 1] {
206            for vertical in [origin[1], origin[1] + size.height - 1] {
207                let point = [
208                    (f64::from(horizontal) + 0.5 + phase[0]) / self.scale,
209                    (f64::from(vertical) + 0.5 + phase[1]) / self.scale,
210                ];
211                noise::fbm(|_| 0.0, point, self.fractal)?;
212            }
213        }
214        let mut mip_pixels = pixels;
215        let mut mip_size = size;
216        while mip_size.width > 1 || mip_size.height > 1 {
217            mip_size = PixelSize::new(mip_size.width.div_ceil(2), mip_size.height.div_ceil(2));
218            mip_pixels += u64::from(mip_size.width) * u64::from(mip_size.height);
219        }
220        let mut budget = Budget::new(size, limits)?;
221        budget.bytes(pixels * 32 + mip_pixels * 16, "atmosphere")?;
222        budget.work(
223            pixels * (u64::from(self.fractal.octaves) * 192 + 256) + mip_pixels * 128,
224            "atmosphere",
225        )?;
226        let parameters = self.parameters;
227        let mut values = Vec::new();
228        values.try_reserve_exact(pixels as usize).map_err(|_| {
229            ValidationError::limit(
230                "atmosphere",
231                "max_bytes",
232                "Could not reserve generated pixels",
233            )
234        })?;
235        for row in 0..size.height {
236            for column in 0..size.width {
237                let pixel = [
238                    f64::from(origin[0] + column) + 0.5,
239                    f64::from(origin[1] + row) + 0.5,
240                ];
241                values.push(parameters.sample(pixel, canvas, phase)?);
242            }
243        }
244        ImageAsset::from_rgba_float(size.width, size.height, values)
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use motion_core::Shape;
252    use motion_core::{BlendMode, Node, NodeModifiers, Rect, Size, TimeLiteral};
253    use motion_render::{BackendKind, Composition};
254
255    #[test]
256    fn procedural_paint_preserves_recipe_time_and_unquantized_bake_reference() -> motion_core::Result
257    {
258        let size = PixelSize::new(32, 24);
259        let time = 0.75.seconds().after_origin();
260        for recipe in [
261            Atmosphere::fog(42),
262            Atmosphere::dust(42),
263            Atmosphere::light_leak(42),
264        ] {
265            let paint = recipe.paint(size, time)?;
266            assert_eq!(paint.parameters(), &recipe.parameters);
267            assert_eq!(paint.time(), time);
268            assert_eq!(paint.canvas(), size);
269            let image = recipe.render(size, time, RasterLimits::default())?;
270            for row in 0..size.height {
271                for column in 0..size.width {
272                    let analytic = paint.sample(motion_core::Point::new(
273                        (f64::from(column) + 0.5) / f64::from(size.width),
274                        (f64::from(row) + 0.5) / f64::from(size.height),
275                    ))?;
276                    let baked = image.levels()[0].pixel(column, row).unwrap();
277                    for (analytic, baked) in analytic.into_iter().zip(baked) {
278                        assert!((analytic - baked).abs() <= 2e-3);
279                    }
280                }
281            }
282        }
283        Ok(())
284    }
285
286    fn compose(
287        recipe: Atmosphere,
288        mode: BlendMode,
289        renderer: BackendKind,
290        time: Time,
291    ) -> motion_core::Result<motion_render::FrameBuffer> {
292        let size = PixelSize::new(64, 48);
293        let image = std::sync::Arc::new(recipe.render(size, time, RasterLimits::default())?);
294        Composition::new(
295            64,
296            48,
297            motion_render::FrameClock::new(motion_core::fps!(30), 150),
298        )
299        .with_options(
300            motion_render::RenderOptions::default()
301                .backend(motion_render::BackendPreference::from_kind(renderer)),
302        )?
303        .render_frame(0, Size::new(64.0, 48.0), |_| {
304            Node::group([
305                Rect::new(0.0, 0.0, 64.0, 48.0)
306                    .fill(Color::rgb(18, 38, 45))
307                    .into(),
308                Node::from(
309                    crate::Image::from_asset(image.clone())
310                        .sampling(motion_core::Sampling::Bilinear),
311                )
312                .blend_mode(mode),
313            ])
314        })
315    }
316
317    #[test]
318    fn overlay_helper_matches_manual_composition_and_picks_blend_modes() -> motion_core::Result {
319        assert_eq!(Atmosphere::fog(1).blend_mode(), BlendMode::Screen);
320        assert_eq!(Atmosphere::dust(1).blend_mode(), BlendMode::Plus);
321        assert_eq!(Atmosphere::light_leak(1).blend_mode(), BlendMode::Plus);
322        let size = PixelSize::new(64, 48);
323        for recipe in [Atmosphere::fog(7), Atmosphere::light_leak(7)] {
324            let overlay =
325                recipe.overlay(size, 1.seconds().after_origin(), RasterLimits::default())?;
326            let frame = Composition::new(
327                64,
328                48,
329                motion_render::FrameClock::new(motion_core::fps!(30), 150),
330            )
331            .with_options(
332                motion_render::RenderOptions::default()
333                    .backend(motion_render::BackendPreference::Cpu),
334            )?
335            .render_frame(0, Size::new(64.0, 48.0), |_| {
336                Node::group([
337                    Rect::new(0.0, 0.0, 64.0, 48.0)
338                        .fill(Color::rgb(18, 38, 45))
339                        .into(),
340                    overlay.clone(),
341                ])
342            })?;
343            let manual = compose(
344                recipe,
345                recipe.blend_mode(),
346                BackendKind::Cpu,
347                1.seconds().after_origin(),
348            )?;
349            assert_eq!(frame.packed_rgb(), manual.packed_rgb());
350        }
351        let mut invalid = Atmosphere::fog(1);
352        invalid.opacity = 2.0;
353        assert!(
354            invalid
355                .overlay(size, 0.seconds().after_origin(), RasterLimits::default())
356                .is_err()
357        );
358        Ok(())
359    }
360
361    #[test]
362    fn generated_overlays_compose_on_cpu() -> motion_core::Result {
363        for recipe in [
364            Atmosphere::fog(42),
365            Atmosphere::dust(42),
366            Atmosphere::light_leak(42),
367        ] {
368            for mode in [BlendMode::Plus, BlendMode::Screen] {
369                let frame = compose(recipe, mode, BackendKind::Cpu, 0.seconds().after_origin())?;
370                assert_eq!(
371                    frame.packed_rgb(),
372                    compose(recipe, mode, BackendKind::Cpu, 0.seconds().after_origin())?
373                        .packed_rgb()
374                );
375                assert_ne!(
376                    frame.packed_rgb(),
377                    compose(recipe, mode, BackendKind::Cpu, 1.seconds().after_origin())?
378                        .packed_rgb()
379                );
380                assert!(
381                    frame
382                        .packed_rgb()
383                        .windows(2)
384                        .any(|pixels| pixels[0] != pixels[1])
385                );
386            }
387        }
388        Ok(())
389    }
390
391    #[test]
392    #[ignore = "requires native GPU; atmosphere image/composite parity, no timing"]
393    fn generated_overlays_cpu_gpu_parity() -> motion_core::Result {
394        for recipe in [
395            Atmosphere::fog(42),
396            Atmosphere::dust(42),
397            Atmosphere::light_leak(42),
398        ] {
399            for mode in [BlendMode::Plus, BlendMode::Screen] {
400                let cpu = compose(recipe, mode, BackendKind::Cpu, 1.seconds().after_origin())?;
401                let gpu = compose(recipe, mode, BackendKind::Gpu, 1.seconds().after_origin())?;
402                assert_eq!(
403                    gpu.packed_rgb(),
404                    compose(recipe, mode, BackendKind::Gpu, 1.seconds().after_origin())?
405                        .packed_rgb()
406                );
407                for (cpu, gpu) in cpu.packed_rgb().iter().zip(gpu.packed_rgb()) {
408                    for (cpu, gpu) in cpu.to_le_bytes().into_iter().zip(gpu.to_le_bytes()) {
409                        assert!(
410                            cpu.abs_diff(gpu) <= 1,
411                            "{:?} {mode:?}: {cpu} != {gpu}",
412                            recipe.kind
413                        );
414                    }
415                }
416            }
417        }
418        Ok(())
419    }
420
421    #[test]
422    fn presets_replay_animate_and_regions_match_full_base_pixels() -> motion_core::Result {
423        let size = PixelSize::new(65, 49);
424        for mut recipe in [
425            Atmosphere::fog(42),
426            Atmosphere::dust(42),
427            Atmosphere::light_leak(42),
428        ] {
429            if matches!(recipe.kind, AtmosphereKind::Dust { .. }) {
430                recipe.kind = AtmosphereKind::Dust {
431                    radius: 3.0,
432                    coverage: 1.0,
433                };
434                recipe.scale = 12.0;
435            }
436            let time = 0.seconds();
437            let full = recipe.render(size, time.after_origin(), RasterLimits::default())?;
438            assert_eq!(
439                full,
440                recipe.render(size, time.after_origin(), RasterLimits::default())?
441            );
442            assert_ne!(
443                full,
444                recipe.render(size, 1.seconds().after_origin(), RasterLimits::default())?
445            );
446            let region = recipe.render_region(
447                size,
448                [17, 11],
449                PixelSize::new(31, 23),
450                time.after_origin(),
451                RasterLimits::default(),
452            )?;
453            let mut energy = 0.0;
454            for row in 0..size.height {
455                for column in 0..size.width {
456                    let pixel = full.levels()[0].pixel(column, row).unwrap();
457                    assert!(pixel.into_iter().all(f32::is_finite));
458                    assert!((0.0..=1.0).contains(&pixel[3]));
459                    energy += f64::from(pixel[0]);
460                }
461            }
462            assert!(energy > 0.0, "{:?}", recipe.kind);
463            for row in 0..23 {
464                for column in 0..31 {
465                    assert_eq!(
466                        region.levels()[0].pixel(column, row),
467                        full.levels()[0].pixel(column + 17, row + 11)
468                    );
469                }
470            }
471            recipe.seed += 1;
472            assert_ne!(
473                full,
474                recipe.render(size, time.after_origin(), RasterLimits::default())?
475            );
476        }
477        Ok(())
478    }
479
480    #[test]
481    fn admission_rejects_bad_parameters_regions_and_budgets() {
482        let size = PixelSize::new(8, 8);
483        let time = 0.seconds();
484        let base = Atmosphere::fog(42);
485        for limits in [
486            RasterLimits {
487                max_pixels: 63,
488                ..RasterLimits::default()
489            },
490            RasterLimits {
491                max_bytes: 1,
492                ..RasterLimits::default()
493            },
494            RasterLimits {
495                max_work: 1,
496                ..RasterLimits::default()
497            },
498        ] {
499            assert!(base.render(size, time.after_origin(), limits).is_err());
500        }
501        assert!(
502            base.render(
503                PixelSize::new(0, 1),
504                time.after_origin(),
505                RasterLimits::default()
506            )
507            .is_err()
508        );
509        assert!(
510            base.render_region(
511                size,
512                [8, 0],
513                size,
514                time.after_origin(),
515                RasterLimits::default()
516            )
517            .is_err()
518        );
519        let edited = |edit: &dyn Fn(&mut Atmosphere)| {
520            let mut recipe = base;
521            edit(&mut recipe);
522            recipe
523        };
524        for recipe in [
525            edited(&|recipe| recipe.scale = 0.0),
526            edited(&|recipe| recipe.intensity = f64::NAN),
527            edited(&|recipe| recipe.drift = [f64::MAX, 0.0]),
528            edited(&|recipe| {
529                recipe.fractal = Fractal {
530                    octaves: 9,
531                    ..Fractal::default()
532                }
533            }),
534            edited(&|recipe| {
535                recipe.kind = AtmosphereKind::Dust {
536                    radius: 100.0,
537                    coverage: 0.5,
538                }
539            }),
540        ] {
541            assert!(
542                recipe
543                    .render(size, 1.seconds().after_origin(), RasterLimits::default())
544                    .is_err()
545            );
546        }
547    }
548
549    #[test]
550    fn zero_opacity_and_hdr_intensity_obey_image_contract() -> motion_core::Result {
551        let size = PixelSize::new(16, 16);
552        for mut recipe in [
553            Atmosphere::fog(1),
554            Atmosphere::dust(1),
555            Atmosphere::light_leak(1),
556        ] {
557            recipe.opacity = 0.0;
558            let image = recipe.render(size, 0.seconds().after_origin(), RasterLimits::default())?;
559            for level in image.levels() {
560                for row in 0..level.height() {
561                    for column in 0..level.width() {
562                        assert_eq!(level.pixel(column, row), Some([0.0; 4]));
563                    }
564                }
565            }
566        }
567        let mut leak = Atmosphere::light_leak(42);
568        leak.intensity = 16.0;
569        let image = leak.render(size, 0.seconds().after_origin(), RasterLimits::default())?;
570        assert!(image.levels()[0].pixel(0, 8).unwrap()[0] > 1.0);
571        Ok(())
572    }
573}