Skip to main content

motion_content/particles/
presets.rs

1//! Deterministic particle recipes returning ordinary, further configurable simulations.
2//!
3//! ```
4//! use motion_content::particles::presets::{Preset, PresetOptions};
5//! use motion_core::{Rect, TimeLiteral};
6//! let sparks = Preset::Sparks.simulation(PresetOptions::new(7, 2.seconds(), Rect::new(0.0, 0.0, 320.0, 180.0)))?.build()?;
7//! assert!(!sparks.sample(100.milliseconds().after_origin())?.is_empty());
8//! # Ok::<(), motion_core::Error>(())
9//! ```
10
11use super::*;
12use motion_core::{Point, Rect, Vector2};
13
14pub use crate::Preset;
15
16/// Adjustable inputs shared by the recipe gallery and Studio.
17#[derive(Clone)]
18pub struct PresetOptions {
19    /// Explicit random seed.
20    pub seed: u64,
21    /// Visible clip duration.
22    pub duration: Duration,
23    /// Local design bounds.
24    pub bounds: Rect,
25    /// Density multiplier in (0,16].
26    pub density: f64,
27    /// Motion multiplier in (0,16].
28    pub speed: f64,
29    /// Finite HDR intensity multiplier in 0..=64.
30    pub intensity: f32,
31    /// Shared colored source required by the logo recipes.
32    pub logo: Option<Arc<crate::VectorTree>>,
33}
34
35impl PresetOptions {
36    /// Creates a recipe input with unit density, motion and intensity.
37    pub fn new(seed: u64, duration: Duration, bounds: Rect) -> Self {
38        Self {
39            seed,
40            duration,
41            bounds,
42            density: 1.0,
43            speed: 1.0,
44            intensity: 1.0,
45            logo: None,
46        }
47    }
48}
49
50impl Preset {
51    /// All recipes in a stable presentation order.
52    pub const ALL: [Self; 13] = [
53        Self::Sparks,
54        Self::Embers,
55        Self::Dust,
56        Self::Motes,
57        Self::Snow,
58        Self::Rain,
59        Self::Smoke,
60        Self::Fire,
61        Self::Confetti,
62        Self::Fireworks,
63        Self::Fireflies,
64        Self::LogoDissolve,
65        Self::LogoAssemble,
66    ];
67
68    /// Stable display name used by preview registries.
69    pub fn name(self) -> &'static str {
70        match self {
71            Self::Sparks => "Sparks",
72            Self::Embers => "Embers",
73            Self::Dust => "Dust",
74            Self::Motes => "Motes",
75            Self::Snow => "Snow",
76            Self::Rain => "Rain",
77            Self::Smoke => "Smoke",
78            Self::Fire => "Fire",
79            Self::Confetti => "Confetti",
80            Self::Fireworks => "Fireworks",
81            Self::Fireflies => "Fireflies",
82            Self::LogoDissolve => "Logo Dissolve",
83            Self::LogoAssemble => "Logo Assemble",
84        }
85    }
86
87    /// Starts a lazy simulation; callers can append operators, replace style or override limits.
88    pub fn simulation(
89        self,
90        options: PresetOptions,
91    ) -> Result<ParticleSimulationBuilder, ValidationError> {
92        motion_core::drawing::validate_rect(options.bounds, "particles.preset", "bounds")?;
93        if options.duration <= Duration::ZERO
94            || options.duration.as_seconds() > 3600.0
95            || options.bounds.width() <= 0.0
96            || options.bounds.height() <= 0.0
97            || !options.density.is_finite()
98            || !(0.0..=16.0).contains(&options.density)
99            || options.density == 0.0
100            || !options.speed.is_finite()
101            || !(0.0..=16.0).contains(&options.speed)
102            || options.speed == 0.0
103            || !options.intensity.is_finite()
104            || !(0.0..=64.0).contains(&options.intensity)
105        {
106            return Err(invalid(
107                "preset",
108                "Invalid recipe bounds, duration, density, speed or intensity",
109            ));
110        }
111        let bounds = options.bounds;
112        let center = Point::new(
113            (bounds.left + bounds.right) * 0.5,
114            (bounds.top + bounds.bottom) * 0.5,
115        );
116        let bottom = Rect::new(
117            bounds.left,
118            bounds.bottom - 2.0,
119            bounds.right,
120            bounds.bottom,
121        );
122        let top = Rect::new(bounds.left, bounds.top, bounds.right, bounds.top + 2.0);
123        let speed = options.speed;
124        let count = |amount: f64| (amount * options.density).ceil() as usize;
125        let trail = ParticleStyle::Trails {
126            length: Duration::from_seconds(0.12),
127            width: 2.0,
128        }
129        .trails(TrailOptions {
130            segments: 16,
131            taper: Some(ParticleCurve::linear(0.0, 1.0)),
132            fade: Some(ParticleCurve::linear(0.0, 1.0)),
133            smooth: true,
134            ..Default::default()
135        })
136        .blend(ParticleBlend::Plus);
137        let mut simulation = ParticleSimulation::new(options.seed).duration(options.duration);
138        let curl_seed = options.seed as u32 ^ (options.seed >> 32) as u32;
139        simulation = match self {
140            Self::Sparks => simulation
141                .emitter(
142                    BurstEmitter::new(Time::ZERO, count(140.0), center)
143                        .lifetime(Duration::from_seconds(1.8))
144                        .speed(100.0 * speed),
145                )
146                .modifier(Gravity::new(Vector2::new(0.0, 95.0 * speed)))
147                .modifier(Drag::new(0.6))
148                .modifier(Temperature::new(ParticleCurve::linear(6500.0, 1200.0)))
149                .style(trail),
150            Self::Embers => simulation
151                .emitter(
152                    RectEmitter::new(bottom)
153                        .rate(90.0 * options.density)
154                        .speed(12.0)
155                        .velocity(Vector2::new(0.0, -24.0 * speed))
156                        .lifetime(Duration::from_seconds(3.0)),
157                )
158                .modifier(CurlNoise {
159                    frequency: 0.03,
160                    strength: 20.0 * speed,
161                    seed: curl_seed,
162                    phase_speed: 1.0,
163                })
164                .modifier(Temperature::new(ParticleCurve::linear(4200.0, 1100.0)))
165                .prewarm(Duration::from_seconds(1.0))
166                .style(ParticleStyle::GlowDots { radius: 1.8 }),
167            Self::Dust | Self::Motes => simulation
168                .emitter(
169                    RectEmitter::new(bounds)
170                        .rate(35.0 * options.density)
171                        .speed(3.0 * speed)
172                        .lifetime(Duration::from_seconds(5.0))
173                        .depth_range(-2.0, 2.0)
174                        .color([0.45, 0.65, 0.75, 0.75]),
175                )
176                .modifier(Drift {
177                    seed: options.seed,
178                    strength: 3.0 * speed,
179                })
180                .prewarm(Duration::from_seconds(1.5))
181                .style(
182                    ParticleStyle::Bokeh {
183                        radius: if self == Self::Dust { 1.0 } else { 2.0 },
184                        aperture: 0.8,
185                        focus: 0.0,
186                        shape: BokehShape::Polygon(6),
187                        ring: 0.3,
188                        hardness: 0.6,
189                        highlight: 1.0,
190                    }
191                    .parallax([4.0, 1.0])
192                    .blend(ParticleBlend::Screen),
193                ),
194            Self::Snow | Self::Rain => {
195                for depth in [-1.0, 0.0, 1.0] {
196                    simulation = simulation.emitter(
197                        RectEmitter::new(top)
198                            .rate(50.0 * options.density)
199                            .speed(0.0)
200                            .velocity(Vector2::new(
201                                5.0 * speed,
202                                if self == Self::Snow {
203                                    32.0 * speed
204                                } else {
205                                    160.0 * speed
206                                },
207                            ))
208                            .lifetime(Duration::from_seconds(4.0))
209                            .depth_range(depth, depth)
210                            .color([0.65, 0.85, 1.0, 0.8]),
211                    );
212                }
213                simulation = simulation.modifier(Wind::new(Vector2::new(2.0, 0.0)));
214                if self == Self::Rain {
215                    simulation = simulation
216                        .modifier(SegmentCollider {
217                            start: Point::new(bounds.left, bounds.bottom),
218                            end: Point::new(bounds.right, bounds.bottom),
219                            response: CollisionResponse {
220                                restitution: 0.3,
221                                friction: 0.2,
222                                kill_on_hit: true,
223                            },
224                        })
225                        .sub_emitter(SubEmitter {
226                            on: ParticleEventKind::Collision,
227                            emitter: Arc::new(StageBurst {
228                                parent: 0.0,
229                                stage: 1.0,
230                                burst: BurstEmitter::new(Time::ZERO, 4, Point::ORIGIN)
231                                    .lifetime(Duration::from_seconds(0.2))
232                                    .speed(20.0),
233                            }),
234                            inherit: Inherit::Color,
235                            probability: 1.0,
236                        });
237                    simulation.style(
238                        ParticleStyle::Shape {
239                            radius: 1.2,
240                            shape: ParticleShape::Capsule { aspect: 1.0 },
241                        }
242                        .stretch_by_speed(0.03, 8.0)
243                        .parallax([3.0, 0.0]),
244                    )
245                } else {
246                    simulation
247                        .prewarm(Duration::from_seconds(1.0))
248                        .style(ParticleStyle::Dots { radius: 1.8 }.parallax([8.0, 0.0]))
249                }
250            }
251            Self::Smoke | Self::Fire => {
252                let sprite = if self == Self::Smoke {
253                    ProceduralSprite::Smoke {
254                        scale: 3.0,
255                        evolution: 0.0,
256                        fractal: motion_core::noise::Fractal::default(),
257                    }
258                } else {
259                    ProceduralSprite::FireCore
260                }
261                .generate(48, options.seed)?;
262                let smoke = self == Self::Smoke;
263                simulation = simulation
264                    .emitter(
265                        RectEmitter::new(bottom)
266                            .rate(if smoke { 24.0 } else { 70.0 } * options.density)
267                            .speed(8.0)
268                            .velocity(Vector2::new(0.0, -20.0 * speed))
269                            .lifetime(Duration::from_seconds(if smoke { 3.0 } else { 1.0 })),
270                    )
271                    .modifier(CurlNoise {
272                        frequency: 0.025,
273                        strength: 10.0,
274                        seed: curl_seed,
275                        phase_speed: 1.0,
276                    })
277                    .modifier(Wind::new(Vector2::new(0.0, -12.0 * speed)))
278                    .modifier(SizeOverLifetime {
279                        start: if smoke { 0.4 } else { 1.0 },
280                        end: if smoke { 2.0 } else { 0.1 },
281                    })
282                    .modifier(ColorOverLifetime::new(Arc::from(if smoke {
283                        [[0.18, 0.2, 0.22, 0.25], [0.0; 4]]
284                    } else {
285                        [[1.0; 4], [0.0; 4]]
286                    })));
287                if !smoke {
288                    simulation = simulation
289                        .modifier(Temperature::new(ParticleCurve::linear(6500.0, 1100.0)));
290                }
291                simulation.style(
292                    ParticleStyle::Sprites {
293                        image: motion_core::Assets::default()
294                            .preload(
295                                motion_core::Source::Bytes {
296                                    key: "particle-preset-sprite".into(),
297                                    data: Arc::from([]),
298                                },
299                                sprite,
300                            )
301                            .map_err(|_| {
302                                invalid(
303                                    "sprite",
304                                    "Procedural preset material exceeds resource allowance",
305                                )
306                            })?,
307                        size: if smoke { 30.0 } else { 14.0 },
308                        align_to_velocity: false,
309                    }
310                    .blend(if smoke {
311                        ParticleBlend::SourceOver
312                    } else {
313                        ParticleBlend::Plus
314                    }),
315                )
316            }
317            Self::Confetti => simulation
318                .emitter(
319                    RectEmitter::new(top)
320                        .rate(60.0 * options.density)
321                        .speed(25.0 * speed)
322                        .lifetime(Duration::from_seconds(4.0))
323                        .palette(Arc::from([
324                            [0.8, 0.1, 0.2, 1.0],
325                            [0.05, 0.7, 0.3, 1.0],
326                            [0.1, 0.3, 0.9, 1.0],
327                            [0.9, 0.7, 0.05, 1.0],
328                        ])),
329                )
330                .modifier(Gravity::new(Vector2::new(0.0, 50.0 * speed)))
331                .modifier(Drag::new(0.3))
332                .modifier(FlipPhase(options.seed))
333                .style(
334                    ParticleStyle::Shape {
335                        radius: 2.5,
336                        shape: ParticleShape::Quad { aspect: 1.5 },
337                    }
338                    .flip(),
339                ),
340            Self::Fireworks => simulation
341                .emitter(
342                    BurstEmitter::new(Time::ZERO, 1, Point::new(center.x, bounds.bottom - 5.0))
343                        .lifetime(Duration::from_seconds(0.8))
344                        .speed(0.0)
345                        .velocity(Vector2::new(0.0, -160.0 * speed)),
346                )
347                .modifier(Gravity::new(Vector2::new(0.0, 70.0 * speed)))
348                .sub_emitter(SubEmitter {
349                    on: ParticleEventKind::Death,
350                    emitter: Arc::new(StageBurst {
351                        parent: 0.0,
352                        stage: 1.0,
353                        burst: BurstEmitter::new(Time::ZERO, count(70.0), Point::ORIGIN)
354                            .lifetime(Duration::from_seconds(0.7))
355                            .speed(80.0 * speed),
356                    }),
357                    inherit: Inherit::Position,
358                    probability: 1.0,
359                })
360                .sub_emitter(SubEmitter {
361                    on: ParticleEventKind::Death,
362                    emitter: Arc::new(StageBurst {
363                        parent: 1.0,
364                        stage: 2.0,
365                        burst: BurstEmitter::new(Time::ZERO, 2, Point::ORIGIN)
366                            .lifetime(Duration::from_seconds(0.25))
367                            .speed(22.0),
368                    }),
369                    inherit: Inherit::Position,
370                    probability: 0.5,
371                })
372                .modifier(Temperature::new(ParticleCurve::linear(6500.0, 1300.0)))
373                .style(trail),
374            Self::Fireflies => simulation
375                .emitter(
376                    RectEmitter::new(bounds)
377                        .rate(10.0 * options.density)
378                        .speed(2.0 * speed)
379                        .lifetime(Duration::from_seconds(5.0))
380                        .color([1.0, 1.0, 0.1, 1.0]),
381                )
382                .modifier(Drift {
383                    seed: options.seed,
384                    strength: 5.0 * speed,
385                })
386                .modifier(Breathe(options.seed))
387                .style(ParticleStyle::GlowDots { radius: 2.0 }),
388            Self::LogoDissolve | Self::LogoAssemble => {
389                let tree = options
390                    .logo
391                    .clone()
392                    .ok_or_else(|| invalid("logo", "Logo presets require a shared VectorTree"))?;
393                tree.surface()?;
394                simulation
395                    .emitter(LogoEmitter {
396                        tree,
397                        count: count(600.0),
398                        lifetime: options.duration.as_seconds() as f32,
399                        seed: options.seed,
400                        distance: bounds.width() * 0.65,
401                        assemble: self == Self::LogoAssemble,
402                    })
403                    .modifier(LogoMotion {
404                        seed: options.seed,
405                        distance: bounds.width() * 0.65,
406                        assemble: self == Self::LogoAssemble,
407                    })
408                    .style(ParticleStyle::Dots { radius: 1.0 })
409            }
410        };
411        let (start, end) = match self {
412            Self::Sparks | Self::Fireworks => (8.0, 0.0),
413            Self::Embers | Self::Fire => (4.0, 0.0),
414            _ => (1.0, 1.0),
415        };
416        Ok(
417            simulation.modifier(IntensityOverLifetime::new(ParticleCurve::linear(
418                options.intensity * start,
419                options.intensity * end,
420            ))),
421        )
422    }
423}
424
425struct StageBurst {
426    parent: f32,
427    stage: f32,
428    burst: BurstEmitter,
429}
430
431impl ParticleEmitter for StageBurst {
432    fn accepts_event(&self, event: &ParticleEvent) -> bool {
433        event.particle.custom[3] == self.parent
434    }
435
436    fn emit(
437        &self,
438        step: u64,
439        time: Time,
440        random: &mut Random,
441        output: &mut Vec<Particle>,
442    ) -> Result<(), ValidationError> {
443        let start = output.len();
444        self.burst.emit(step, time, random, output)?;
445        for particle in &mut output[start..] {
446            particle.custom[3] = self.stage;
447        }
448        Ok(())
449    }
450}
451
452struct Drift {
453    seed: u64,
454    strength: f64,
455}
456
457impl ParticleModifier for Drift {
458    fn apply(
459        &self,
460        particle: &mut Particle,
461        step: f32,
462        time: Time,
463        _: &mut Random,
464    ) -> Result<(), ValidationError> {
465        let phase =
466            Random::for_particle(self.seed, particle.index, 0).next_float() * std::f64::consts::TAU;
467        particle.velocity[0] +=
468            (libm::sin(time.as_seconds() + phase) * self.strength) as f32 * step;
469        particle.velocity[1] +=
470            (libm::cos(time.as_seconds() * 0.7 + phase) * self.strength) as f32 * step;
471        particle.validate()
472    }
473}
474
475struct Breathe(u64);
476
477impl ParticleModifier for Breathe {
478    fn resets_color(&self) -> bool {
479        true
480    }
481
482    fn apply(
483        &self,
484        particle: &mut Particle,
485        _: f32,
486        time: Time,
487        _: &mut Random,
488    ) -> Result<(), ValidationError> {
489        let phase =
490            Random::for_particle(self.0, particle.index, 0).next_float() * std::f64::consts::TAU;
491        let energy = (1.5 + 1.4 * libm::sin(time.as_seconds() * 2.0 + phase)) as f32;
492        for channel in &mut particle.color[..3] {
493            *channel *= energy;
494        }
495        particle.validate()
496    }
497}
498
499struct FlipPhase(u64);
500
501impl ParticleModifier for FlipPhase {
502    fn apply(
503        &self,
504        particle: &mut Particle,
505        _: f32,
506        _: Time,
507        _: &mut Random,
508    ) -> Result<(), ValidationError> {
509        let phase = Random::for_particle(self.0, particle.index, 0).next_float() as f32
510            * std::f32::consts::TAU;
511        particle.custom[2] = phase;
512        particle.rotation = phase + particle.age * 0.5;
513        particle.validate()
514    }
515}
516
517struct LogoEmitter {
518    tree: Arc<crate::VectorTree>,
519    count: usize,
520    lifetime: f32,
521    seed: u64,
522    distance: f64,
523    assemble: bool,
524}
525
526impl ParticleEmitter for LogoEmitter {
527    fn emit(
528        &self,
529        step: u64,
530        _: Time,
531        random: &mut Random,
532        output: &mut Vec<Particle>,
533    ) -> Result<(), ValidationError> {
534        if step != 0 {
535            return Ok(());
536        }
537        let seed = (random.next_float() * u64::MAX as f64) as u64;
538        for (index, (point, color)) in self
539            .tree
540            .sample_colored_points(self.count, seed)?
541            .into_iter()
542            .enumerate()
543        {
544            let mut keyed = Random::for_particle(self.seed, index as u64, 0);
545            let direction = keyed.unit_vector();
546            let distance = self.distance * (0.5 + keyed.next_float());
547            let position = if self.assemble {
548                [
549                    (point.x + direction.x * distance) as f32,
550                    (point.y + direction.y * distance) as f32,
551                ]
552            } else {
553                [point.x as f32, point.y as f32]
554            };
555            output.push(Particle {
556                position,
557                custom: [point.x as f32, 0.0, point.y as f32, 0.0],
558                color,
559                lifetime: self.lifetime,
560                ..Default::default()
561            });
562        }
563        Ok(())
564    }
565}
566
567struct LogoMotion {
568    seed: u64,
569    distance: f64,
570    assemble: bool,
571}
572
573impl ParticleModifier for LogoMotion {
574    fn apply(
575        &self,
576        particle: &mut Particle,
577        _: f32,
578        _: Time,
579        _: &mut Random,
580    ) -> Result<(), ValidationError> {
581        let progress = (particle.age / particle.lifetime).clamp(0.0, 1.0);
582        let eased = progress * progress * (3.0 - 2.0 * progress);
583        let amount = if self.assemble { 1.0 - eased } else { eased };
584        let mut random = Random::for_particle(self.seed, particle.index, 0);
585        let direction = random.unit_vector();
586        let distance = self.distance * (0.5 + random.next_float());
587        particle.position = [
588            particle.custom[0] + (direction.x * distance) as f32 * amount,
589            particle.custom[2] + (direction.y * distance) as f32 * amount,
590        ];
591        particle.velocity = [0.0; 2];
592        particle.validate()
593    }
594}