1use 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
11pub trait TrajectoryModel: Send + Sync {
13 fn duration(&self) -> Option<Duration> {
15 None
16 }
17
18 fn spawned(&self, from: Time, to: Time) -> Range<u64>;
20
21 fn birth_time(&self, index: u64) -> Time;
23
24 fn state(&self, index: u64, age_seconds: f32) -> Result<Option<Particle>, ValidationError>;
26
27 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 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
192fn 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 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#[derive(Clone)]
285pub struct ParticleTrajectory {
286 pub(crate) model: Arc<dyn TrajectoryModel>,
287 pub(crate) style: Arc<ParticleStyle>,
288}
289
290impl ParticleTrajectory {
291 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 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 pub fn seed(mut self, seed: u64) -> Self {
367 self.0.seed = seed;
368 self
369 }
370 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#[derive(Clone, Debug)]
466pub struct Ballistic(TrajectoryDefinition);
467
468impl Ballistic {
469 pub fn burst(center: Point, count: u64) -> Self {
471 Self(model(1, center, count))
472 }
473
474 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 pub fn drag(mut self, drag: f64) -> Self {
483 self.0.parameters[4] = drag as f32;
484 self
485 }
486
487 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#[derive(Clone, Debug)]
498pub struct Converge(TrajectoryDefinition);
499
500impl Converge {
501 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#[derive(Clone, Debug)]
529pub struct Orbit(TrajectoryDefinition);
530
531impl Orbit {
532 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 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#[derive(Clone, Debug)]
550pub struct NoiseDrift(TrajectoryDefinition);
551
552impl NoiseDrift {
553 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 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 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}