1use super::*;
4
5mod replay;
6
7#[derive(Clone, Copy, Debug)]
9pub struct SimulationLimits {
10 pub max_particles: usize,
12 pub max_bytes: u64,
14 pub max_steps: u64,
16 pub max_work: u64,
18 pub max_sub_depth: u8,
20 pub max_births: u64,
22 pub max_events: usize,
24}
25
26impl Default for SimulationLimits {
27 fn default() -> Self {
28 Self {
29 max_particles: 16384,
30 max_bytes: 64 * 1024 * 1024,
31 max_steps: 1_000_000,
32 max_work: 100_000_000,
33 max_sub_depth: 8,
34 max_births: 1_000_000,
35 max_events: 65536,
36 }
37 }
38}
39
40#[derive(Clone, Copy, Debug)]
42pub struct SimulationStats {
43 pub snapshots: usize,
45 pub interval: u64,
47 pub admitted_bytes: u64,
49 pub work: u64,
51}
52
53#[derive(Clone)]
57pub struct ParticleSimulation {
58 config: Arc<Config>,
59 pub(crate) style: Arc<ParticleStyle>,
60
61 prepared: Arc<OnceLock<Result<Prepared, ValidationError>>>,
62}
63
64#[derive(Clone)]
66pub struct ParticleSimulationBuilder {
67 config: Config,
68 pub(crate) style: Arc<ParticleStyle>,
69}
70
71impl ParticleSimulation {
72 #[allow(clippy::new_ret_no_self)]
75 pub fn new(seed: u64) -> ParticleSimulationBuilder {
76 ParticleSimulationBuilder::new(seed)
77 }
78}
79
80impl ParticleSimulationBuilder {
81 pub fn new(seed: u64) -> Self {
83 Self {
84 config: Config {
85 seed,
86 duration: None,
87 step: Duration::from_ticks(motion_core::TICKS_PER_SECOND as u64 / 240),
88 interval: 60,
89 emitters: Vec::new(),
90 modifiers: Vec::new(),
91 groups: Vec::new(),
92 sub_emitters: Vec::new(),
93 prewarm: Duration::ZERO,
94 loop_period: None,
95 time_scale: 1.0,
96 time_offset: Duration::ZERO,
97 allow_reverse: false,
98 workers: 1,
99 limits: SimulationLimits::default(),
100 },
101 style: Arc::new(ParticleStyle::default()),
102 }
103 }
104
105 fn change(mut self, update: impl FnOnce(&mut Config)) -> Self {
106 update(&mut self.config);
107 self
108 }
109
110 pub fn duration(self, duration: Duration) -> Self {
112 self.change(|config| config.duration = Some(duration))
113 }
114
115 pub fn step(self, step: Duration) -> Self {
117 self.change(|config| config.step = step)
118 }
119
120 pub fn snapshot_interval(self, steps: u64) -> Self {
122 self.change(|config| config.interval = steps)
123 }
124
125 pub fn limits(self, limits: SimulationLimits) -> Self {
127 self.change(|config| config.limits = limits)
128 }
129
130 pub fn emitter(self, emitter: impl ParticleEmitter + 'static) -> Self {
132 self.change(|config| config.emitters.push(Arc::new(emitter)))
133 }
134
135 pub fn modifier(self, modifier: impl ParticleModifier + 'static) -> Self {
137 self.change(|config| config.modifiers.push(Arc::new(modifier)))
138 }
139
140 pub fn group_modifier(self, modifier: impl ParticleGroupModifier + 'static) -> Self {
142 self.change(|config| config.groups.push(Arc::new(modifier)))
143 }
144
145 pub fn sub_emitter(self, emitter: SubEmitter) -> Self {
147 self.change(|config| config.sub_emitters.push(emitter))
148 }
149
150 pub fn prewarm(self, duration: Duration) -> Self {
152 self.change(|config| config.prewarm = duration)
153 }
154
155 pub fn loop_period(self, period: Duration) -> Self {
157 self.change(|config| config.loop_period = Some(period))
158 }
159
160 pub fn time_remap(self, scale: f64, offset: Duration, allow_reverse: bool) -> Self {
162 self.change(|config| {
163 config.time_scale = scale;
164 config.time_offset = offset;
165 config.allow_reverse = allow_reverse;
166 })
167 }
168
169 pub fn workers(self, workers: usize) -> Self {
171 self.change(|config| config.workers = workers)
172 }
173
174 pub fn build(self) -> Result<ParticleSimulation, ValidationError> {
177 let config = &self.config;
178 if config
179 .duration
180 .is_some_and(|duration| duration <= Duration::ZERO)
181 || config.step <= Duration::ZERO
182 || config.interval == 0
183 || config.limits.max_particles == 0
184 || config.limits.max_work == 0
185 || config.emitters.len() > 32
186 || config.modifiers.len() > 32
187 || !config.time_scale.is_finite()
188 {
189 return Err(invalid(
190 "configuration",
191 "Invalid simulation duration/step/components/limits",
192 ));
193 }
194 Ok(ParticleSimulation {
195 config: Arc::new(self.config),
196 style: self.style,
197 prepared: Arc::new(OnceLock::new()),
198 })
199 }
200}
201
202impl ParticleSimulation {
203 pub fn to_builder(&self) -> ParticleSimulationBuilder {
205 ParticleSimulationBuilder {
206 config: self.config.as_ref().clone(),
207 style: self.style.clone(),
208 }
209 }
210
211 fn prepared(&self, duration: Option<Duration>) -> Result<&Prepared, ValidationError> {
212 let duration = self.config.duration.or(duration).ok_or_else(|| {
213 invalid(
214 "duration",
215 "Provide a simulation duration or a finite clip duration",
216 )
217 })?;
218 let prepared = self
219 .prepared
220 .get_or_init(|| prepare(&self.config, duration))
221 .as_ref()
222 .map_err(Clone::clone)?;
223 if prepared.duration != duration {
224 return Err(invalid(
225 "duration",
226 "Cached simulation duration changed; use a distinct cache key/instance",
227 ));
228 }
229 Ok(prepared)
230 }
231
232 pub fn sample(&self, time: Time) -> Result<Arc<[Particle]>, ValidationError> {
235 self.sample_in(time, None)
236 }
237
238 pub fn sample_in(
240 &self,
241 time: Time,
242 clip_duration: Option<Duration>,
243 ) -> Result<Arc<[Particle]>, ValidationError> {
244 self.sample_with(&mut SampleCursor::default(), time, clip_duration)
245 }
246
247 pub(crate) fn sample_with(
250 &self,
251 cursor: &mut SampleCursor,
252 time: Time,
253 clip_duration: Option<Duration>,
254 ) -> Result<Arc<[Particle]>, ValidationError> {
255 let prepared = self.prepared(clip_duration)?;
256 if time < Time::ZERO || time >= prepared.duration.after_origin() {
257 return Ok(Arc::from([]));
258 }
259 if let Some(replay) = &prepared.replay {
260 return replay.sample(&self.config, &mut cursor.replay, time);
261 }
262 let target = time.ticks() as u64 / self.config.step.ticks();
263 let index = prepared
264 .snapshots
265 .partition_point(|snapshot| snapshot.step <= target)
266 - 1;
267 let checkpoint = &prepared.snapshots[index];
268 if cursor
269 .state
270 .as_ref()
271 .is_some_and(|state| state.step > target || state.step < checkpoint.step)
272 {
273 cursor.state = None;
274 }
275 let state = cursor.state.get_or_insert_with(|| checkpoint.clone());
276 let mut work = 0;
277 while state.step < target {
278 if let Err(error) = advance(&self.config, state, &mut work) {
279 cursor.state = None;
281 return Err(error);
282 }
283 }
284 Ok(state
285 .live
286 .iter()
287 .map(|live| live.particle)
288 .collect::<Vec<_>>()
289 .into())
290 }
291
292 pub fn stats(&self) -> Result<SimulationStats, ValidationError> {
294 Ok(self.prepared(None)?.stats)
295 }
296
297 pub fn events(&self, time: Time) -> Result<Vec<ParticleEvent>, ValidationError> {
299 let prepared = self.prepared(None)?;
300 if time < Time::ZERO || time >= prepared.duration.after_origin() {
301 return Ok(Vec::new());
302 }
303 if let Some(replay) = &prepared.replay {
304 replay.events(&self.config, time)
305 } else {
306 Ok(Vec::new())
307 }
308 }
309
310 pub(super) fn natural_duration(&self) -> Option<Duration> {
311 self.config.duration
312 }
313}
314
315#[derive(Clone)]
316struct Config {
317 seed: u64,
318 duration: Option<Duration>,
319 step: Duration,
320 interval: u64,
321 emitters: Vec<Arc<dyn ParticleEmitter>>,
322 modifiers: Vec<Arc<dyn ParticleModifier>>,
323
324 groups: Vec<Arc<dyn ParticleGroupModifier>>,
325 sub_emitters: Vec<SubEmitter>,
326 prewarm: Duration,
327 loop_period: Option<Duration>,
328 time_scale: f64,
329 time_offset: Duration,
330 allow_reverse: bool,
331 workers: usize,
332
333 limits: SimulationLimits,
334}
335
336#[derive(Clone, Copy)]
337struct Live {
338 particle: Particle,
339 birth: Time,
340}
341
342#[derive(Clone)]
343struct State {
344 step: u64,
345 next: u64,
346 live: Vec<Live>,
347}
348
349struct Prepared {
350 duration: Duration,
351 snapshots: Vec<State>,
352 replay: Option<replay::PreparedReplay>,
353 stats: SimulationStats,
354}
355
356#[derive(Default)]
361pub(crate) struct SampleCursor {
362 state: Option<State>,
363 replay: replay::ReplayCursor,
364}
365
366fn charge(config: &Config, work: &mut u64, amount: usize) -> Result<(), ValidationError> {
367 *work = work
368 .checked_add(amount as u64)
369 .ok_or_else(|| limit("Simulation work overflow"))?;
370 if *work > config.limits.max_work {
371 return Err(limit("Simulation work allowance exhausted"));
372 }
373 Ok(())
374}
375
376fn emitter_random(config: &Config, order: usize, step: u64) -> Random {
378 Random::for_particle(config.seed ^ 0xe11e0000 ^ order as u64, step, 0)
379}
380
381fn modifier_random(config: &Config, order: usize, index: u64, step: u64) -> Random {
383 Random::for_particle(
384 config.seed ^ (order as u64).wrapping_mul(0xd1b54a32d192ed03),
385 index,
386 step,
387 )
388}
389
390fn births(
391 config: &Config,
392 state: &mut State,
393 time: Time,
394 work: &mut u64,
395) -> Result<(), ValidationError> {
396 for (index, emitter) in config.emitters.iter().enumerate() {
397 let mut output = Vec::new();
398 let mut random = emitter_random(config, index, state.step);
399 emitter.emit(state.step, time, &mut random, &mut output)?;
400 charge(config, work, output.len() + 1)?;
401 if output.len() > config.limits.max_particles.saturating_sub(state.live.len()) {
402 return Err(limit("Live particle allowance exhausted"));
403 }
404 for mut particle in output {
405 if state.next >= 1_000_000 {
406 return Err(limit("Simulation exceeds one million total births"));
407 }
408 particle.index = state.next;
409 particle.age = 0.0;
410 particle.validate()?;
411 state.next += 1;
412 state.live.push(Live {
413 particle,
414 birth: time,
415 });
416 }
417 }
418 Ok(())
419}
420
421fn advance(config: &Config, state: &mut State, work: &mut u64) -> Result<(), ValidationError> {
422 state.step += 1;
423 let time = Time::from_ticks(state.step as i64 * config.step.ticks() as i64);
424 let seconds = config.step.as_seconds() as f32;
425 charge(
426 config,
427 work,
428 state.live.len() * (config.modifiers.len() + 2),
429 )?;
430 for live in &mut state.live {
431 let particle = &mut live.particle;
432 particle.age = (time - live.birth).as_seconds() as f32;
433 if particle.age >= particle.lifetime {
434 continue;
435 }
436 let index = particle.index;
437 let age = particle.age;
438 for (order, modifier) in config.modifiers.iter().enumerate() {
439 let mut random = modifier_random(config, order, index, state.step);
440 modifier.apply(particle, seconds, time, &mut random)?;
441 particle.index = index;
442 particle.age = age;
443 particle.validate()?;
444 }
445 for axis in 0..2 {
446 particle.position[axis] += particle.velocity[axis] * seconds;
447 }
448 particle.validate()?;
449 }
450 state
451 .live
452 .retain(|live| live.particle.age < live.particle.lifetime);
453 births(config, state, time, work)
454}
455
456fn prepare(config: &Config, duration: Duration) -> Result<Prepared, ValidationError> {
457 if replay::enabled(config) {
458 let replay = replay::prepare(config, duration)?;
459 let stats = replay.stats;
460 return Ok(Prepared {
461 duration,
462 snapshots: Vec::new(),
463 replay: Some(replay),
464 stats,
465 });
466 }
467 let limits = config.limits;
468 if duration <= Duration::ZERO
469 || duration.as_seconds() > 3600.0
470 || config.step <= Duration::ZERO
471 || config.step > duration
472 || config.interval == 0
473 || limits.max_particles == 0
474 || limits.max_particles > 65536
475 || limits.max_work == 0
476 || config.emitters.len() > 32
477 || config.modifiers.len() > 32
478 {
479 return Err(invalid(
480 "configuration",
481 "Invalid simulation duration/step/components/limits",
482 ));
483 }
484 let steps = duration.ticks() / config.step.ticks();
485 if steps > limits.max_steps || steps > 1_000_000 {
486 return Err(limit("Simulation fixed-step allowance exhausted"));
487 }
488 let slot = limits.max_particles as u64 * std::mem::size_of::<Live>() as u64
489 + std::mem::size_of::<State>() as u64;
490 let slots = limits.max_bytes / slot;
491 if slots < 3 {
492 return Err(limit(
493 "Snapshot budget needs one snapshot and two bounded working buffers",
494 ));
495 }
496 let capacity = slots - 2;
497 let interval = if capacity == 1 {
498 steps + 1
499 } else {
500 config.interval.max(steps.div_ceil(capacity - 1))
501 };
502 let mut state = State {
503 step: 0,
504 next: 0,
505 live: Vec::new(),
506 };
507 let mut work = 0;
508 births(config, &mut state, Time::ZERO, &mut work)?;
509 let mut snapshots = vec![state.clone()];
510 while state.step < steps {
511 advance(config, &mut state, &mut work)?;
512 if state.step.is_multiple_of(interval) {
513 snapshots.push(state.clone());
514 }
515 }
516 let stats = SimulationStats {
517 snapshots: snapshots.len(),
518 interval,
519 admitted_bytes: (snapshots.len() as u64 + 2) * slot,
520 work,
521 };
522 Ok(Prepared {
523 duration,
524 snapshots,
525 replay: None,
526 stats,
527 })
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533 use motion_core::TimeLiteral;
534 use std::sync::atomic::{AtomicUsize, Ordering};
535
536 struct Birth(Arc<AtomicUsize>);
537
538 impl ParticleEmitter for Birth {
539 fn emit(
540 &self,
541 step: u64,
542 _: Time,
543 _: &mut Random,
544 output: &mut Vec<Particle>,
545 ) -> Result<(), ValidationError> {
546 self.0.fetch_add(1, Ordering::SeqCst);
547 if step == 0 {
548 output.push(Particle {
549 velocity: [1.0, 0.0],
550 lifetime: 2.0,
551 ..Default::default()
552 });
553 }
554 Ok(())
555 }
556 }
557
558 struct Acceleration;
559
560 impl ParticleModifier for Acceleration {
561 fn apply(
562 &self,
563 particle: &mut Particle,
564 step: f32,
565 _: Time,
566 _: &mut Random,
567 ) -> Result<(), ValidationError> {
568 particle.velocity[1] += 2.0 * step;
569 Ok(())
570 }
571 }
572
573 #[test]
574 fn fixed_snapshot_replay_is_order_independent_once_initialized_and_bounded()
575 -> motion_core::Result<()> {
576 assert_eq!(std::mem::size_of::<Particle>(), 72);
577 let calls = Arc::new(AtomicUsize::new(0));
578 let simulation = ParticleSimulation::new(42)
579 .duration(1.seconds())
580 .step(10.milliseconds())
581 .emitter(Birth(calls.clone()))
582 .modifier(Acceleration)
583 .limits(SimulationLimits {
584 max_particles: 2,
585 max_bytes: 2048,
586 ..Default::default()
587 })
588 .snapshot_interval(1)
589 .build()?;
590 std::thread::scope(|scope| {
591 for _ in 0..4 {
592 let simulation = &simulation;
593 scope.spawn(move || {
594 let point = simulation
595 .sample(500.milliseconds().after_origin())
596 .unwrap()[0];
597 assert!((point.position[0] - 0.5).abs() < 1e-6);
598 assert!((point.position[1] - 0.255).abs() < 1e-6);
599 });
600 }
601 });
602 assert_eq!(calls.load(Ordering::SeqCst), 101 + 4 * 5);
603 let stats = simulation.stats()?;
604 assert!(stats.interval > 1 && stats.admitted_bytes <= 2048);
605 for time in [990, 0, 500, 40, 500] {
606 assert_eq!(
607 simulation.sample(time.milliseconds().after_origin())?,
608 simulation.sample(time.milliseconds().after_origin())?
609 );
610 }
611 assert!(simulation.sample(1.seconds().after_origin())?.is_empty());
612 assert!(
613 simulation
614 .clone()
615 .to_builder()
616 .limits(SimulationLimits {
617 max_bytes: 1,
618 ..Default::default()
619 })
620 .build()?
621 .sample(Time::ZERO)
622 .is_err()
623 );
624 Ok(())
625 }
626
627 #[test]
628 fn cursor_queries_match_independent_samples_for_every_replay_path() -> motion_core::Result {
629 let plain = ParticleSimulation::new(9)
630 .duration(2.seconds())
631 .emitter(
632 crate::particles::PointEmitter::new(motion_core::Point::ORIGIN)
633 .speed(3.0)
634 .lifetime(700.milliseconds()),
635 )
636 .modifier(Acceleration)
637 .build()?;
638 let simulations = [
639 plain.clone(),
640 plain.clone().to_builder().workers(2).build()?,
641 plain.to_builder().loop_period(900.milliseconds()).build()?,
642 ];
643 let times = [
645 0, 10, 240, 250, 251, 300, 300, 1200, 1490, 260, 270, 1999, 5, 2000, 1000,
646 ];
647 for simulation in &simulations {
648 let mut cursor = SampleCursor::default();
649 for time in times {
650 let time = Time::from_ticks(motion_core::TICKS_PER_SECOND * time / 1000);
651 assert_eq!(
652 &*simulation.sample_with(&mut cursor, time, None)?,
653 &*simulation.sample_in(time, None)?,
654 "{time:?}"
655 );
656 }
657 }
658 Ok(())
659 }
660}