1use 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#[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 pub const fn new(parameters: AtmosphereParameters) -> Self {
56 Self { parameters }
57 }
58
59 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 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 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 pub fn validate(&self) -> Result<(), ValidationError> {
117 self.parameters.validate()
118 }
119
120 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 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 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 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 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}