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;
5use motion_core::{Point, Vector2};
6use std::ops::Range;
7
8/// A whole random-access particle trajectory, not separately composable forces.
9pub trait TrajectoryModel: Send + Sync {
10    /// Optional finite natural duration for timeline registration.
11    fn duration(&self) -> Option<Duration> {
12        None
13    }
14
15    /// Stable IDs born in [from,to).
16    fn spawned(&self, from: Time, to: Time) -> Range<u64>;
17
18    /// Exact integer-tick birth time of one stable ID.
19    fn birth_time(&self, index: u64) -> Time;
20
21    /// Complete state at this age; None means absent/dead.
22    fn state(&self, index: u64, age_seconds: f32) -> Result<Option<Particle>, ValidationError>;
23}
24
25#[derive(Clone, Debug, PartialEq)]
26struct TrajectoryDefinition {
27    pub(crate) kind: u32,
28    pub(crate) parameters: [f32; 8],
29    pub(crate) count: u64,
30    pub(crate) lifetime: f32,
31    pub(crate) seed: u64,
32    pub(crate) initial: Arc<[[f32; 4]]>,
33}
34
35impl TrajectoryDefinition {
36    pub(crate) fn initial(&self, index: u64) -> Result<Particle, ValidationError> {
37        if self.count > MAX_INSTANCE_COUNT as u64
38            || !self.lifetime.is_finite()
39            || self.lifetime <= 0.0
40            || self.lifetime > 3600.0
41            || self.parameters.iter().any(|value| !value.is_finite())
42        {
43            return Err(invalid(
44                "trajectory",
45                "Invalid builtin trajectory parameters/count/lifetime",
46            ));
47        }
48        if (self.kind == 3 && self.parameters[2] < 0.0)
49            || (self.kind == 4 && (self.parameters[4] < 0.0 || self.parameters[5] < 0.0))
50        {
51            return Err(invalid(
52                "trajectory",
53                "Radius, noise amplitude and frequency must be nonnegative",
54            ));
55        }
56        let mut random = Random::from_key((self.seed, index));
57        let mut particle = Particle {
58            index,
59            lifetime: self.lifetime,
60            ..Default::default()
61        };
62        match self.kind {
63            1 => {
64                particle.position = [self.parameters[0], self.parameters[1]];
65                let angle = random.next_float() * std::f64::consts::TAU;
66                let speed = f64::from(self.parameters[6])
67                    + (f64::from(self.parameters[7]) - f64::from(self.parameters[6]))
68                        * random.next_float();
69                particle.velocity = [
70                    (libm::cos(angle) * speed) as f32,
71                    (libm::sin(angle) * speed) as f32,
72                ];
73            }
74            2 => {
75                let points = self
76                    .initial
77                    .get(index as usize)
78                    .ok_or_else(|| invalid("points", "Converge point index missing"))?;
79                particle.position = [points[0], points[1]];
80                particle.custom = [points[2], points[3], 0.0, 0.0];
81            }
82            3 => {
83                particle.custom[0] = (random.next_float() * std::f64::consts::TAU) as f32;
84            }
85            4 => {
86                particle.position = [self.parameters[0], self.parameters[1]];
87                particle.velocity = [self.parameters[2], self.parameters[3]];
88                particle.custom[0] = (random.next_float() * 32.0) as f32;
89                particle.custom[1] = (random.next_float() * 32.0) as f32;
90            }
91            _ => return Err(invalid("model", "Unknown builtin trajectory")),
92        }
93        particle.validate()?;
94        Ok(particle)
95    }
96
97    pub(crate) fn evaluate(
98        &self,
99        mut particle: Particle,
100        age: f32,
101    ) -> Result<Particle, ValidationError> {
102        let time = f64::from(age);
103        let values = self.parameters.map(f64::from);
104        match self.kind {
105            1 => {
106                let drag = values[4];
107                if drag < 0.0 || values[6] < 0.0 || values[7] < values[6] {
108                    return Err(invalid(
109                        "ballistic",
110                        "Require nonnegative drag and ordered speeds",
111                    ));
112                }
113                let product = drag * time;
114                let (decay, advance, gravity) = if product.abs() < 0.01 {
115                    let square = product * product;
116                    let cube = square * product;
117                    let fourth = cube * product;
118                    (
119                        libm::exp(-product),
120                        time * (1.0 - product * 0.5 + square / 6.0 - cube / 24.0 + fourth / 120.0),
121                        time * time
122                            * (0.5 - product / 6.0 + square / 24.0 - cube / 120.0 + fourth / 720.0),
123                    )
124                } else {
125                    let advance = -libm::expm1(-product) / drag;
126                    (libm::exp(-product), advance, (time - advance) / drag)
127                };
128                for axis in 0..2 {
129                    let velocity = f64::from(particle.velocity[axis]);
130                    particle.position[axis] = (f64::from(particle.position[axis])
131                        + velocity * advance
132                        + values[2 + axis] * gravity)
133                        as f32;
134                    particle.velocity[axis] =
135                        (velocity * decay + values[2 + axis] * advance) as f32;
136                }
137            }
138            2 => {
139                let progress = (time / f64::from(self.lifetime)).clamp(0.0, 1.0);
140                let amount = progress * progress * (3.0 - 2.0 * progress);
141                let derivative = 6.0 * progress * (1.0 - progress) / f64::from(self.lifetime);
142                for axis in 0..2 {
143                    let start = f64::from(particle.position[axis]);
144                    let delta = f64::from(particle.custom[axis]) - start;
145                    particle.position[axis] = (start + delta * amount) as f32;
146                    particle.velocity[axis] = (delta * derivative) as f32;
147                }
148            }
149            3 => {
150                let angle = f64::from(particle.custom[0]) + values[3] * time;
151                particle.position = [
152                    (values[0] + values[2] * libm::cos(angle)) as f32,
153                    (values[1] + values[2] * libm::sin(angle)) as f32,
154                ];
155                particle.velocity = [
156                    (-values[2] * values[3] * libm::sin(angle)) as f32,
157                    (values[2] * values[3] * libm::cos(angle)) as f32,
158                ];
159            }
160            4 => {
161                for axis in 0..2 {
162                    let phase = f64::from(particle.custom[axis]);
163                    let (value, derivative) = value_noise(
164                        phase + time * values[5],
165                        particle.index as u32 + axis as u32,
166                    );
167                    let (origin, _) = value_noise(phase, particle.index as u32 + axis as u32);
168                    particle.position[axis] = (f64::from(particle.position[axis])
169                        + f64::from(particle.velocity[axis]) * time
170                        + values[4] * (value - origin))
171                        as f32;
172                    particle.velocity[axis] = (f64::from(particle.velocity[axis])
173                        + values[4] * values[5] * derivative)
174                        as f32;
175                }
176            }
177            _ => unreachable!(),
178        }
179        particle.age = age;
180        particle.validate()?;
181        Ok(particle)
182    }
183}
184
185/// Pure coupled-trajectory collection, without snapshots or previous-frame state.
186#[derive(Clone)]
187pub struct ParticleTrajectory {
188    pub(crate) model: Arc<dyn TrajectoryModel>,
189    pub(crate) style: Arc<ParticleStyle>,
190}
191
192impl ParticleTrajectory {
193    /// Captures a pure coupled trajectory, without a simulation or snapshots.
194    pub fn new(model: impl TrajectoryModel + 'static) -> Self {
195        Self {
196            model: Arc::new(model),
197            style: Arc::new(ParticleStyle::default()),
198        }
199    }
200
201    /// Returns stable-order live states. A malformed custom model fails explicitly.
202    pub fn sample(&self, time: Time) -> Result<Arc<[Particle]>, ValidationError> {
203        if time < Time::ZERO {
204            return Ok(Arc::from([]));
205        }
206        let end = time
207            .ticks()
208            .checked_add(1)
209            .map(Time::from_ticks)
210            .ok_or_else(|| invalid("time", "Trajectory query overflow"))?;
211        let range = self.model.spawned(Time::ZERO, end);
212        if range.end < range.start || range.end - range.start > MAX_INSTANCE_COUNT as u64 {
213            return Err(limit(
214                "Trajectory birth range exceeds the instance allowance",
215            ));
216        }
217        let mut particles = Vec::new();
218        particles
219            .try_reserve_exact((range.end - range.start) as usize)
220            .map_err(|_| limit("Trajectory state allocation failed"))?;
221        for index in range {
222            let birth = self.model.birth_time(index);
223            if birth < Time::ZERO || birth > time {
224                return Err(invalid(
225                    "birth",
226                    "Custom model returned an invalid birth time",
227                ));
228            }
229            if let Some(mut particle) = self
230                .model
231                .state(index, (time - birth).as_seconds() as f32)?
232            {
233                particle.index = index;
234                particle.validate()?;
235                if particle.age < particle.lifetime {
236                    particles.push(particle);
237                }
238            }
239        }
240        Ok(particles.into())
241    }
242}
243
244fn model(kind: u32, center: Point, count: u64) -> TrajectoryDefinition {
245    TrajectoryDefinition {
246        kind,
247        parameters: [
248            center.x as f32,
249            center.y as f32,
250            0.0,
251            0.0,
252            0.0,
253            0.0,
254            80.0,
255            160.0,
256        ],
257        count,
258        lifetime: 3.0,
259        seed: 0,
260        initial: Arc::from([]),
261    }
262}
263
264macro_rules! common {
265    ($name:ident) => {
266        impl $name {
267            /// Replaces deterministic initial-state seed.
268            pub fn seed(mut self, seed: u64) -> Self {
269                self.0.seed = seed;
270                self
271            }
272            /// Replaces positive half-open lifetime in seconds.
273            pub fn lifetime(mut self, lifetime: Duration) -> Self {
274                self.0.lifetime = lifetime.as_seconds() as f32;
275                self
276            }
277        }
278        impl TrajectoryModel for $name {
279            fn spawned(&self, from: Time, to: Time) -> Range<u64> {
280                if from <= Time::ZERO && to > Time::ZERO {
281                    0..self.0.count
282                } else {
283                    0..0
284                }
285            }
286            fn birth_time(&self, _: u64) -> Time {
287                Time::ZERO
288            }
289            fn state(&self, index: u64, age: f32) -> Result<Option<Particle>, ValidationError> {
290                let initial = self.0.initial(index)?;
291                if index >= self.0.count || !age.is_finite() {
292                    return Err(invalid("age", "Invalid trajectory ID/age"));
293                }
294                if age < 0.0 || age >= self.0.lifetime {
295                    return Ok(None);
296                }
297                Ok(Some(self.0.evaluate(initial, age)?))
298            }
299            fn duration(&self) -> Option<Duration> {
300                if self.0.lifetime.is_finite() && self.0.lifetime > 0.0 && self.0.lifetime <= 3600.0
301                {
302                    Some(Time::from_seconds(f64::from(self.0.lifetime)))
303                } else {
304                    None
305                }
306            }
307        }
308    };
309}
310
311/// Coupled constant gravity and linear drag, including the zero-drag limit.
312#[derive(Clone, Debug)]
313pub struct Ballistic(TrajectoryDefinition);
314
315impl Ballistic {
316    /// Creates a seeded radial burst with default speed 80..160 and life 3s.
317    pub fn burst(center: Point, count: u64) -> Self {
318        Self(model(1, center, count))
319    }
320
321    /// Replaces constant acceleration in units/squared second.
322    pub fn gravity(mut self, gravity: Vector2) -> Self {
323        self.0.parameters[2] = gravity.x as f32;
324        self.0.parameters[3] = gravity.y as f32;
325        self
326    }
327
328    /// Replaces nonnegative linear damping coefficient in reciprocal seconds.
329    pub fn drag(mut self, drag: f64) -> Self {
330        self.0.parameters[4] = drag as f32;
331        self
332    }
333
334    /// Replaces deterministic radial speed range.
335    pub fn speed(mut self, minimum: f64, maximum: f64) -> Self {
336        self.0.parameters[6] = minimum as f32;
337        self.0.parameters[7] = maximum as f32;
338        self
339    }
340}
341common!(Ballistic);
342
343/// Smoothstep convergence between deterministic point sets, cycling the shorter.
344#[derive(Clone, Debug)]
345pub struct Converge(TrajectoryDefinition);
346
347impl Converge {
348    /// Prepares bounded source/target points; useful with VectorTree::sample_points.
349    pub fn new(from: &[Point], to: &[Point], duration: Duration) -> Result<Self, ValidationError> {
350        if from.is_empty() || to.is_empty() || from.len().max(to.len()) > MAX_INSTANCE_COUNT {
351            return Err(invalid(
352                "points",
353                "Converge requires nonempty point sets within the instance allowance",
354            ));
355        }
356        let mut definition = model(2, Point::ORIGIN, from.len().max(to.len()) as u64);
357        definition.lifetime = duration.as_seconds() as f32;
358        definition.initial = (0..definition.count as usize)
359            .map(|index| {
360                [
361                    from[index % from.len()].x as f32,
362                    from[index % from.len()].y as f32,
363                    to[index % to.len()].x as f32,
364                    to[index % to.len()].y as f32,
365                ]
366            })
367            .collect::<Vec<_>>()
368            .into();
369        Ok(Self(definition))
370    }
371}
372common!(Converge);
373
374/// Exact circular orbits and tangent velocity, parameterized in radians/second.
375#[derive(Clone, Debug)]
376pub struct Orbit(TrajectoryDefinition);
377
378impl Orbit {
379    /// Creates stable seeded orbital phases.
380    pub fn new(center: Point, radius: f64, count: u64) -> Self {
381        let mut definition = model(3, center, count);
382        definition.parameters[2] = radius as f32;
383        definition.parameters[3] = 1.0;
384        Self(definition)
385    }
386
387    /// Replaces angular velocity, including clockwise/counterclockwise motion.
388    pub fn angular_velocity(mut self, velocity: f64) -> Self {
389        self.0.parameters[3] = velocity as f32;
390        self
391    }
392}
393common!(Orbit);
394
395/// Closed smooth value-noise displacement and its analytic velocity derivative.
396#[derive(Clone, Debug)]
397pub struct NoiseDrift(TrajectoryDefinition);
398
399impl NoiseDrift {
400    /// Creates seeded independent smooth noise phases, default amplitude 20/frequency 1.
401    pub fn new(origin: Point, count: u64) -> Self {
402        let mut definition = model(4, origin, count);
403        definition.parameters[4] = 20.0;
404        definition.parameters[5] = 1.0;
405        Self(definition)
406    }
407
408    /// Replaces linear base velocity.
409    pub fn velocity(mut self, velocity: Vector2) -> Self {
410        self.0.parameters[2] = velocity.x as f32;
411        self.0.parameters[3] = velocity.y as f32;
412        self
413    }
414
415    /// Replaces nonnegative displacement amplitude and temporal frequency.
416    pub fn noise(mut self, amplitude: f64, frequency: f64) -> Self {
417        self.0.parameters[4] = amplitude as f32;
418        self.0.parameters[5] = frequency as f32;
419        self
420    }
421}
422common!(NoiseDrift);
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use motion_core::TimeLiteral;
428
429    #[test]
430    fn ballistic_solves_gravity_and_drag_together_with_stable_zero_limit() -> motion_core::Result<()>
431    {
432        for drag in [0.0, 1e-9, 0.8] {
433            let model = Ballistic::burst(Point::new(2.0, 3.0), 1)
434                .seed(42)
435                .speed(5.0, 5.0)
436                .gravity(Vector2::new(0.0, 10.0))
437                .drag(drag);
438            let start = model.state(0, 0.0)?.unwrap();
439            let actual = model.state(0, 1.0)?.unwrap();
440            let mut position = start.position.map(f64::from);
441            let mut velocity = start.velocity.map(f64::from);
442            let step = 1.0 / 20000.0;
443            for _ in 0..20000 {
444                for axis in 0..2 {
445                    let force = if axis == 1 { 10.0 } else { 0.0 };
446                    let acceleration = force - drag * velocity[axis];
447                    position[axis] += velocity[axis] * step + 0.5 * acceleration * step * step;
448                    velocity[axis] += acceleration * step;
449                }
450            }
451            for axis in 0..2 {
452                assert!((position[axis] - f64::from(actual.position[axis])).abs() < 0.001);
453                assert!((velocity[axis] - f64::from(actual.velocity[axis])).abs() < 0.001);
454            }
455        }
456        let converge = Converge::new(&[Point::ORIGIN], &[Point::new(10.0, 20.0)], 2.seconds())?;
457        assert_eq!(converge.state(0, 1.0)?.unwrap().position, [5.0, 10.0]);
458        let orbit = Orbit::new(Point::ORIGIN, 5.0, 1);
459        let state = orbit.state(0, 1.0)?.unwrap();
460        assert!(
461            (state.position[0] * state.position[0] + state.position[1] * state.position[1] - 25.0)
462                .abs()
463                < 0.0001
464        );
465        let noise = NoiseDrift::new(Point::ORIGIN, 1).velocity(Vector2::new(1.0, 0.0));
466        let before = noise.state(0, 0.999)?.unwrap();
467        let after = noise.state(0, 1.001)?.unwrap();
468        let exact = noise.state(0, 1.0)?.unwrap();
469        for axis in 0..2 {
470            assert!(
471                ((after.position[axis] - before.position[axis]) / 0.002 - exact.velocity[axis])
472                    .abs()
473                    < 0.02
474            );
475        }
476        let trajectory = ParticleTrajectory::new(orbit);
477        assert_eq!(
478            trajectory.sample(1.seconds())?,
479            trajectory.sample(1.seconds())?
480        );
481        Ok(())
482    }
483}