Skip to main content

motion_content/particles/
trajectory.rs

1//! Random-access analytic particle trajectory models.
2
3use super::*;
4use motion_core::animation::value_noise;
5#[cfg(feature = "gpu")]
6use motion_core::instances::{AnalyticInstanceState, AnalyticTrajectoryKind};
7use motion_core::instances::{AnalyticInstanceUpload, InstanceKind};
8use motion_core::{Point, Rect, Vector2};
9use std::ops::Range;
10
11/// A whole random-access particle trajectory, not separately composable forces.
12pub trait TrajectoryModel: Send + Sync {
13    /// Optional finite natural duration for timeline registration.
14    fn duration(&self) -> Option<Duration> {
15        None
16    }
17
18    /// Stable IDs born in [from,to).
19    fn spawned(&self, from: Time, to: Time) -> Range<u64>;
20
21    /// Exact integer-tick birth time of one stable ID.
22    fn birth_time(&self, index: u64) -> Time;
23
24    /// Complete state at this age; None means absent/dead.
25    fn state(&self, index: u64, age_seconds: f32) -> Result<Option<Particle>, ValidationError>;
26
27    /// Optional seeded initial-state upload for supported random-access models.
28    /// Custom models and unsupported precision retain the ordinary evaluated path.
29    fn analytic_upload(
30        &self,
31        _age_seconds: f32,
32        _primitive: InstanceKind,
33        _align_to_velocity: bool,
34    ) -> Result<Option<AnalyticInstanceUpload>, ValidationError> {
35        Ok(None)
36    }
37
38    /// Conservative extent of every particle center at this age, for models whose
39    /// [`Self::analytic_upload`] lets the GPU evaluate positions. Returning `None` keeps
40    /// the ordinary evaluated path.
41    fn analytic_bounds(&self, _age_seconds: f32) -> Result<Option<Rect>, ValidationError> {
42        Ok(None)
43    }
44}
45
46#[derive(Clone, Debug, PartialEq)]
47struct TrajectoryDefinition {
48    pub(crate) kind: u32,
49    pub(crate) parameters: [f32; 8],
50    pub(crate) count: u64,
51    pub(crate) lifetime: f32,
52    pub(crate) seed: u64,
53    pub(crate) initial: Arc<[[f32; 4]]>,
54}
55
56impl TrajectoryDefinition {
57    pub(crate) fn initial(&self, index: u64) -> Result<Particle, ValidationError> {
58        if self.count > MAX_INSTANCE_COUNT as u64
59            || !self.lifetime.is_finite()
60            || self.lifetime <= 0.0
61            || self.lifetime > 3600.0
62            || self.parameters.iter().any(|value| !value.is_finite())
63        {
64            return Err(invalid(
65                "trajectory",
66                "Invalid builtin trajectory parameters/count/lifetime",
67            ));
68        }
69        if (self.kind == 3 && self.parameters[2] < 0.0)
70            || (self.kind == 4 && (self.parameters[4] < 0.0 || self.parameters[5] < 0.0))
71        {
72            return Err(invalid(
73                "trajectory",
74                "Radius, noise amplitude and frequency must be nonnegative",
75            ));
76        }
77        let mut random = Random::from_key((self.seed, index));
78        let mut particle = Particle {
79            index,
80            lifetime: self.lifetime,
81            ..Default::default()
82        };
83        match self.kind {
84            1 => {
85                particle.position = [self.parameters[0], self.parameters[1]];
86                let angle = random.next_float() * std::f64::consts::TAU;
87                let speed = f64::from(self.parameters[6])
88                    + (f64::from(self.parameters[7]) - f64::from(self.parameters[6]))
89                        * random.next_float();
90                particle.velocity = [
91                    (libm::cos(angle) * speed) as f32,
92                    (libm::sin(angle) * speed) as f32,
93                ];
94            }
95            2 => {
96                let points = self
97                    .initial
98                    .get(index as usize)
99                    .ok_or_else(|| invalid("points", "Converge point index missing"))?;
100                particle.position = [points[0], points[1]];
101                particle.custom = [points[2], points[3], 0.0, 0.0];
102            }
103            3 => {
104                particle.custom[0] = (random.next_float() * std::f64::consts::TAU) as f32;
105            }
106            4 => {
107                particle.position = [self.parameters[0], self.parameters[1]];
108                particle.velocity = [self.parameters[2], self.parameters[3]];
109                particle.custom[0] = (random.next_float() * 32.0) as f32;
110                particle.custom[1] = (random.next_float() * 32.0) as f32;
111            }
112            _ => return Err(invalid("model", "Unknown builtin trajectory")),
113        }
114        particle.validate()?;
115        Ok(particle)
116    }
117
118    pub(crate) fn evaluate(
119        &self,
120        mut particle: Particle,
121        age: f32,
122    ) -> Result<Particle, ValidationError> {
123        let time = f64::from(age);
124        let values = self.parameters.map(f64::from);
125        match self.kind {
126            1 => {
127                let drag = values[4];
128                if drag < 0.0 || values[6] < 0.0 || values[7] < values[6] {
129                    return Err(invalid(
130                        "ballistic",
131                        "Require nonnegative drag and ordered speeds",
132                    ));
133                }
134                let (decay, advance, gravity) = ballistic_terms(drag, time);
135                for axis in 0..2 {
136                    let velocity = f64::from(particle.velocity[axis]);
137                    particle.position[axis] = (f64::from(particle.position[axis])
138                        + velocity * advance
139                        + values[2 + axis] * gravity)
140                        as f32;
141                    particle.velocity[axis] =
142                        (velocity * decay + values[2 + axis] * advance) as f32;
143                }
144            }
145            2 => {
146                let progress = (time / f64::from(self.lifetime)).clamp(0.0, 1.0);
147                let amount = progress * progress * (3.0 - 2.0 * progress);
148                let derivative = 6.0 * progress * (1.0 - progress) / f64::from(self.lifetime);
149                for axis in 0..2 {
150                    let start = f64::from(particle.position[axis]);
151                    let delta = f64::from(particle.custom[axis]) - start;
152                    particle.position[axis] = (start + delta * amount) as f32;
153                    particle.velocity[axis] = (delta * derivative) as f32;
154                }
155            }
156            3 => {
157                let angle = f64::from(particle.custom[0]) + values[3] * time;
158                particle.position = [
159                    (values[0] + values[2] * libm::cos(angle)) as f32,
160                    (values[1] + values[2] * libm::sin(angle)) as f32,
161                ];
162                particle.velocity = [
163                    (-values[2] * values[3] * libm::sin(angle)) as f32,
164                    (values[2] * values[3] * libm::cos(angle)) as f32,
165                ];
166            }
167            4 => {
168                for axis in 0..2 {
169                    let phase = f64::from(particle.custom[axis]);
170                    let (value, derivative) = value_noise(
171                        phase + time * values[5],
172                        particle.index as u32 + axis as u32,
173                    );
174                    let (origin, _) = value_noise(phase, particle.index as u32 + axis as u32);
175                    particle.position[axis] = (f64::from(particle.position[axis])
176                        + f64::from(particle.velocity[axis]) * time
177                        + values[4] * (value - origin))
178                        as f32;
179                    particle.velocity[axis] = (f64::from(particle.velocity[axis])
180                        + values[4] * values[5] * derivative)
181                        as f32;
182                }
183            }
184            _ => unreachable!("the model was matched by the enclosing arm"),
185        }
186        particle.age = age;
187        particle.validate()?;
188        Ok(particle)
189    }
190}
191
192/// Drag decay, advance and gravity integrals of the ballistic model at one age.
193fn ballistic_terms(drag: f64, time: f64) -> (f64, f64, f64) {
194    let product = drag * time;
195    if product.abs() < 0.01 {
196        let square = product * product;
197        let cube = square * product;
198        let fourth = cube * product;
199        (
200            libm::exp(-product),
201            time * (1.0 - product * 0.5 + square / 6.0 - cube / 24.0 + fourth / 120.0),
202            time * time * (0.5 - product / 6.0 + square / 24.0 - cube / 120.0 + fourth / 720.0),
203        )
204    } else {
205        let advance = -libm::expm1(-product) / drag;
206        (libm::exp(-product), advance, (time - advance) / drag)
207    }
208}
209
210impl TrajectoryDefinition {
211    /// Box containing every particle center over `[0, age]`, padded for f32 rounding.
212    ///
213    /// Each model is bounded by its own closed form: ballistic motion is linear in the
214    /// monotone advance and gravity integrals, converge stays on its segment, orbit stays on
215    /// its circle and noise drift is a linear drift plus a bounded noise offset.
216    pub(crate) fn center_bounds(&self, age: f32) -> Result<Rect, ValidationError> {
217        let time = f64::from(age);
218        let values = self.parameters.map(f64::from);
219        let (advance, gravity) = if self.kind == 1 {
220            let (_, advance, gravity) = ballistic_terms(values[4], time);
221            (advance, gravity)
222        } else {
223            (0.0, 0.0)
224        };
225        let mut low = [f64::INFINITY; 2];
226        let mut high = [f64::NEG_INFINITY; 2];
227        let mut include = |axis: usize, value: f64| {
228            low[axis] = low[axis].min(value);
229            high[axis] = high[axis].max(value);
230        };
231        if self.kind == 3 {
232            for axis in 0..2 {
233                include(axis, values[axis] - values[2]);
234                include(axis, values[axis] + values[2]);
235            }
236        } else {
237            for index in 0..self.count {
238                let particle = self.initial(index)?;
239                for axis in 0..2 {
240                    let start = f64::from(particle.position[axis]);
241                    let velocity = f64::from(particle.velocity[axis]);
242                    match self.kind {
243                        1 => {
244                            for (advance, gravity) in [
245                                (0.0, 0.0),
246                                (advance, 0.0),
247                                (0.0, gravity),
248                                (advance, gravity),
249                            ] {
250                                include(
251                                    axis,
252                                    start + velocity * advance + values[2 + axis] * gravity,
253                                );
254                            }
255                        }
256                        2 => {
257                            include(axis, start);
258                            include(axis, f64::from(particle.custom[axis]));
259                        }
260                        _ => {
261                            let drift = velocity * time;
262                            let noise = 2.0 * values[4];
263                            include(axis, start + drift.min(0.0) - noise);
264                            include(axis, start + drift.max(0.0) + noise);
265                        }
266                    }
267                }
268            }
269        }
270        let pad = |axis: usize| {
271            let scale = low[axis].abs().max(high[axis].abs()).max(1.0);
272            scale * 1e-5 + 1e-3
273        };
274        Ok(Rect::new(
275            low[0] - pad(0),
276            low[1] - pad(1),
277            high[0] + pad(0),
278            high[1] + pad(1),
279        ))
280    }
281}
282
283/// Pure coupled-trajectory collection, without snapshots or previous-frame state.
284#[derive(Clone)]
285pub struct ParticleTrajectory {
286    pub(crate) model: Arc<dyn TrajectoryModel>,
287    pub(crate) style: Arc<ParticleStyle>,
288}
289
290impl ParticleTrajectory {
291    /// Captures a pure coupled trajectory, without a simulation or snapshots.
292    pub fn new(model: impl TrajectoryModel + 'static) -> Self {
293        Self {
294            model: Arc::new(model),
295            style: Arc::new(ParticleStyle::default()),
296        }
297    }
298
299    /// Returns stable-order live states. A malformed custom model fails explicitly.
300    pub fn sample(&self, time: Time) -> Result<Arc<[Particle]>, ValidationError> {
301        if time < Time::ZERO {
302            return Ok(Arc::from([]));
303        }
304        let end = time
305            .ticks()
306            .checked_add(1)
307            .map(Time::from_ticks)
308            .ok_or_else(|| invalid("time", "Trajectory query overflow"))?;
309        let range = self.model.spawned(Time::ZERO, end);
310        if range.end < range.start || range.end - range.start > MAX_INSTANCE_COUNT as u64 {
311            return Err(limit(
312                "Trajectory birth range exceeds the instance allowance",
313            ));
314        }
315        let mut particles = Vec::new();
316        particles
317            .try_reserve_exact((range.end - range.start) as usize)
318            .map_err(|_| limit("Trajectory state allocation failed"))?;
319        for index in range {
320            let birth = self.model.birth_time(index);
321            if birth < Time::ZERO || birth > time {
322                return Err(invalid(
323                    "birth",
324                    "Custom model returned an invalid birth time",
325                ));
326            }
327            if let Some(mut particle) = self
328                .model
329                .state(index, (time - birth).as_seconds() as f32)?
330            {
331                particle.index = index;
332                particle.validate()?;
333                if particle.age < particle.lifetime {
334                    particles.push(particle);
335                }
336            }
337        }
338        Ok(particles.into())
339    }
340}
341
342fn model(kind: u32, center: Point, count: u64) -> TrajectoryDefinition {
343    TrajectoryDefinition {
344        kind,
345        parameters: [
346            center.x as f32,
347            center.y as f32,
348            0.0,
349            0.0,
350            0.0,
351            0.0,
352            80.0,
353            160.0,
354        ],
355        count,
356        lifetime: 3.0,
357        seed: 0,
358        initial: Arc::from([]),
359    }
360}
361
362macro_rules! common {
363    ($name:ident) => {
364        impl $name {
365            /// Replaces deterministic initial-state seed.
366            pub fn seed(mut self, seed: u64) -> Self {
367                self.0.seed = seed;
368                self
369            }
370            /// Replaces positive half-open lifetime in seconds.
371            pub fn lifetime(mut self, lifetime: Duration) -> Self {
372                self.0.lifetime = lifetime.as_seconds() as f32;
373                self
374            }
375        }
376        impl TrajectoryModel for $name {
377            #[cfg(feature = "gpu")]
378            fn analytic_upload(
379                &self,
380                age: f32,
381                primitive: InstanceKind,
382                align: bool,
383            ) -> Result<Option<AnalyticInstanceUpload>, ValidationError> {
384                if age < 0.0 || age >= self.0.lifetime || self.0.count == 0 {
385                    return Ok(None);
386                }
387                let mut initial = Vec::new();
388                if self.0.count > MAX_INSTANCE_COUNT as u64 {
389                    return Err(limit(
390                        "Analytic initial-state count exceeds the instance allowance",
391                    ));
392                }
393                initial
394                    .try_reserve_exact(self.0.count as usize)
395                    .map_err(|_| limit("Analytic initial-state allocation failed"))?;
396                for index in 0..self.0.count {
397                    let particle = self.0.initial(index)?;
398                    initial.push(AnalyticInstanceState {
399                        position: particle.position,
400                        velocity: particle.velocity,
401                        custom: [particle.custom[0], particle.custom[1]],
402                        index: index as u32,
403                        rotation: particle.rotation,
404                    });
405                }
406                let kind = match self.0.kind {
407                    1 => AnalyticTrajectoryKind::Ballistic,
408                    2 => AnalyticTrajectoryKind::Converge,
409                    3 => AnalyticTrajectoryKind::Orbit,
410                    _ => AnalyticTrajectoryKind::NoiseDrift,
411                };
412                AnalyticInstanceUpload::new(
413                    kind,
414                    self.0.seed,
415                    age,
416                    self.0.lifetime,
417                    self.0.parameters,
418                    primitive,
419                    align,
420                    initial.into_iter(),
421                )
422                .map(Some)
423            }
424
425            fn analytic_bounds(&self, age: f32) -> Result<Option<Rect>, ValidationError> {
426                if age < 0.0 || age >= self.0.lifetime || self.0.count == 0 {
427                    return Ok(None);
428                }
429                self.0.center_bounds(age).map(Some)
430            }
431
432            fn spawned(&self, from: Time, to: Time) -> Range<u64> {
433                if from <= Time::ZERO && to > Time::ZERO {
434                    0..self.0.count
435                } else {
436                    0..0
437                }
438            }
439            fn birth_time(&self, _: u64) -> Time {
440                Time::ZERO
441            }
442            fn state(&self, index: u64, age: f32) -> Result<Option<Particle>, ValidationError> {
443                let initial = self.0.initial(index)?;
444                if index >= self.0.count || !age.is_finite() {
445                    return Err(invalid("age", "Invalid trajectory ID/age"));
446                }
447                if age < 0.0 || age >= self.0.lifetime {
448                    return Ok(None);
449                }
450                Ok(Some(self.0.evaluate(initial, age)?))
451            }
452            fn duration(&self) -> Option<Duration> {
453                if self.0.lifetime.is_finite() && self.0.lifetime > 0.0 && self.0.lifetime <= 3600.0
454                {
455                    Some(Duration::from_seconds(f64::from(self.0.lifetime)))
456                } else {
457                    None
458                }
459            }
460        }
461    };
462}
463
464/// Coupled constant gravity and linear drag, including the zero-drag limit.
465#[derive(Clone, Debug)]
466pub struct Ballistic(TrajectoryDefinition);
467
468impl Ballistic {
469    /// Creates a seeded radial burst with default speed 80..160 and life 3s.
470    pub fn burst(center: Point, count: u64) -> Self {
471        Self(model(1, center, count))
472    }
473
474    /// Replaces constant acceleration in units/squared second.
475    pub fn gravity(mut self, gravity: Vector2) -> Self {
476        self.0.parameters[2] = gravity.x as f32;
477        self.0.parameters[3] = gravity.y as f32;
478        self
479    }
480
481    /// Replaces nonnegative linear damping coefficient in reciprocal seconds.
482    pub fn drag(mut self, drag: f64) -> Self {
483        self.0.parameters[4] = drag as f32;
484        self
485    }
486
487    /// Replaces deterministic radial speed range.
488    pub fn speed(mut self, minimum: f64, maximum: f64) -> Self {
489        self.0.parameters[6] = minimum as f32;
490        self.0.parameters[7] = maximum as f32;
491        self
492    }
493}
494common!(Ballistic);
495
496/// Smoothstep convergence between deterministic point sets, cycling the shorter.
497#[derive(Clone, Debug)]
498pub struct Converge(TrajectoryDefinition);
499
500impl Converge {
501    /// Prepares bounded source/target points; useful with VectorTree::sample_points.
502    pub fn new(from: &[Point], to: &[Point], duration: Duration) -> Result<Self, ValidationError> {
503        if from.is_empty() || to.is_empty() || from.len().max(to.len()) > MAX_INSTANCE_COUNT {
504            return Err(invalid(
505                "points",
506                "Converge requires nonempty point sets within the instance allowance",
507            ));
508        }
509        let mut definition = model(2, Point::ORIGIN, from.len().max(to.len()) as u64);
510        definition.lifetime = duration.as_seconds() as f32;
511        definition.initial = (0..definition.count as usize)
512            .map(|index| {
513                [
514                    from[index % from.len()].x as f32,
515                    from[index % from.len()].y as f32,
516                    to[index % to.len()].x as f32,
517                    to[index % to.len()].y as f32,
518                ]
519            })
520            .collect::<Vec<_>>()
521            .into();
522        Ok(Self(definition))
523    }
524}
525common!(Converge);
526
527/// Exact circular orbits and tangent velocity, parameterized in radians/second.
528#[derive(Clone, Debug)]
529pub struct Orbit(TrajectoryDefinition);
530
531impl Orbit {
532    /// Creates stable seeded orbital phases.
533    pub fn new(center: Point, radius: f64, count: u64) -> Self {
534        let mut definition = model(3, center, count);
535        definition.parameters[2] = radius as f32;
536        definition.parameters[3] = 1.0;
537        Self(definition)
538    }
539
540    /// Replaces angular velocity, including clockwise/counterclockwise motion.
541    pub fn angular_velocity(mut self, velocity: f64) -> Self {
542        self.0.parameters[3] = velocity as f32;
543        self
544    }
545}
546common!(Orbit);
547
548/// Closed smooth value-noise displacement and its analytic velocity derivative.
549#[derive(Clone, Debug)]
550pub struct NoiseDrift(TrajectoryDefinition);
551
552impl NoiseDrift {
553    /// Creates seeded independent smooth noise phases, default amplitude 20/frequency 1.
554    pub fn new(origin: Point, count: u64) -> Self {
555        let mut definition = model(4, origin, count);
556        definition.parameters[4] = 20.0;
557        definition.parameters[5] = 1.0;
558        Self(definition)
559    }
560
561    /// Replaces linear base velocity.
562    pub fn velocity(mut self, velocity: Vector2) -> Self {
563        self.0.parameters[2] = velocity.x as f32;
564        self.0.parameters[3] = velocity.y as f32;
565        self
566    }
567
568    /// Replaces nonnegative displacement amplitude and temporal frequency.
569    pub fn noise(mut self, amplitude: f64, frequency: f64) -> Self {
570        self.0.parameters[4] = amplitude as f32;
571        self.0.parameters[5] = frequency as f32;
572        self
573    }
574}
575common!(NoiseDrift);
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580    use motion_core::TimeLiteral;
581
582    #[test]
583    fn ballistic_solves_gravity_and_drag_together_with_stable_zero_limit() -> motion_core::Result<()>
584    {
585        for drag in [0.0, 1e-9, 0.8] {
586            let model = Ballistic::burst(Point::new(2.0, 3.0), 1)
587                .seed(42)
588                .speed(5.0, 5.0)
589                .gravity(Vector2::new(0.0, 10.0))
590                .drag(drag);
591            let start = model.state(0, 0.0)?.unwrap();
592            let actual = model.state(0, 1.0)?.unwrap();
593            let mut position = start.position.map(f64::from);
594            let mut velocity = start.velocity.map(f64::from);
595            let step = 1.0 / 20000.0;
596            for _ in 0..20000 {
597                for axis in 0..2 {
598                    let force = if axis == 1 { 10.0 } else { 0.0 };
599                    let acceleration = force - drag * velocity[axis];
600                    position[axis] += velocity[axis] * step + 0.5 * acceleration * step * step;
601                    velocity[axis] += acceleration * step;
602                }
603            }
604            for axis in 0..2 {
605                assert!((position[axis] - f64::from(actual.position[axis])).abs() < 0.001);
606                assert!((velocity[axis] - f64::from(actual.velocity[axis])).abs() < 0.001);
607            }
608        }
609        let converge = Converge::new(&[Point::ORIGIN], &[Point::new(10.0, 20.0)], 2.seconds())?;
610        assert_eq!(converge.state(0, 1.0)?.unwrap().position, [5.0, 10.0]);
611        let orbit = Orbit::new(Point::ORIGIN, 5.0, 1);
612        let state = orbit.state(0, 1.0)?.unwrap();
613        assert!(
614            (state.position[0] * state.position[0] + state.position[1] * state.position[1] - 25.0)
615                .abs()
616                < 0.0001
617        );
618        let noise = NoiseDrift::new(Point::ORIGIN, 1).velocity(Vector2::new(1.0, 0.0));
619        let before = noise.state(0, 0.999)?.unwrap();
620        let after = noise.state(0, 1.001)?.unwrap();
621        let exact = noise.state(0, 1.0)?.unwrap();
622        for axis in 0..2 {
623            assert!(
624                ((after.position[axis] - before.position[axis]) / 0.002 - exact.velocity[axis])
625                    .abs()
626                    < 0.02
627            );
628        }
629        let trajectory = ParticleTrajectory::new(orbit);
630        assert_eq!(
631            trajectory.sample(1.seconds().after_origin())?,
632            trajectory.sample(1.seconds().after_origin())?
633        );
634        Ok(())
635    }
636}