Skip to main content

motion_content/particles/
simulation.rs

1//! Deterministic fixed-step particle simulation with bounded snapshots and replay.
2
3use super::*;
4
5mod replay;
6
7/// Logical snapshot/replay allowances, not a process-wide RSS sandbox.
8#[derive(Clone, Copy, Debug)]
9pub struct SimulationLimits {
10    /// Maximum simultaneously living records.
11    pub max_particles: usize,
12    /// Snapshot payload plus two worst-case working buffers.
13    pub max_bytes: u64,
14    /// Maximum simulated fixed boundaries.
15    pub max_steps: u64,
16    /// Maximum preparation or per-query particle/operator work.
17    pub max_work: u64,
18    /// Maximum event-triggered descendant depth, at most 32.
19    pub max_sub_depth: u8,
20    /// Maximum nonrecycled births during preparation/replay.
21    pub max_births: u64,
22    /// Maximum events retained at one fixed boundary.
23    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/// Actual immutable snapshot preparation facts.
41#[derive(Clone, Copy, Debug)]
42pub struct SimulationStats {
43    /// Number of retained snapshots including step zero.
44    pub snapshots: usize,
45    /// Adaptive spacing in fixed steps.
46    pub interval: u64,
47    /// Conservative admitted snapshot/working payload bytes.
48    pub admitted_bytes: u64,
49    /// Actual preparation work units.
50    pub work: u64,
51}
52
53/// Lazy fixed-step replay. The first clip duration fixes a shared preparation;
54/// reusing one cache instance with a different duration is rejected explicitly.
55/// Mutating any builder setting creates a fresh preparation identity.
56#[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
64impl ParticleSimulation {
65    /// Creates a lazy fixed-step simulation with an explicit deterministic seed. Cache the result in
66    /// `SceneContext::cache` rather than constructing one afresh for every frame.
67    pub fn new(seed: u64) -> Self {
68        Self {
69            config: Arc::new(Config {
70                seed,
71                duration: None,
72                step: Time::from_ticks(motion_core::TICKS_PER_SECOND / 240),
73                interval: 60,
74                emitters: Vec::new(),
75                modifiers: Vec::new(),
76                groups: Vec::new(),
77                sub_emitters: Vec::new(),
78                prewarm: Time::ZERO,
79                loop_period: None,
80                time_scale: 1.0,
81                time_offset: Time::ZERO,
82                allow_reverse: false,
83                workers: 1,
84                limits: SimulationLimits::default(),
85            }),
86            style: Arc::new(ParticleStyle::default()),
87            prepared: Arc::new(OnceLock::new()),
88        }
89    }
90
91    fn change(mut self, update: impl FnOnce(&mut Config)) -> Self {
92        update(Arc::make_mut(&mut self.config));
93        self.prepared = Arc::new(OnceLock::new());
94        self
95    }
96
97    /// Sets finite preparation duration; otherwise render inherits clip duration.
98    pub fn duration(self, duration: Duration) -> Self {
99        self.change(|config| config.duration = Some(duration))
100    }
101
102    /// Sets an exact positive integer-tick step; no variable last integration step.
103    pub fn step(self, step: Duration) -> Self {
104        self.change(|config| config.step = step)
105    }
106
107    /// Requests snapshot spacing; memory admission may increase it automatically.
108    pub fn snapshot_interval(self, steps: u64) -> Self {
109        self.change(|config| config.interval = steps)
110    }
111
112    /// Replaces logical resource allowances.
113    pub fn limits(self, limits: SimulationLimits) -> Self {
114        self.change(|config| config.limits = limits)
115    }
116
117    /// Appends one fixed-boundary emitter, in declaration order.
118    pub fn emitter(self, emitter: impl ParticleEmitter + 'static) -> Self {
119        self.change(|config| config.emitters.push(Arc::new(emitter)))
120    }
121
122    /// Appends one deterministic operator; ordering is significant.
123    pub fn modifier(self, modifier: impl ParticleModifier + 'static) -> Self {
124        self.change(|config| config.modifiers.push(Arc::new(modifier)))
125    }
126
127    /// Adds a population operator before independent particle updates.
128    pub fn group_modifier(self, modifier: impl ParticleGroupModifier + 'static) -> Self {
129        self.change(|config| config.groups.push(Arc::new(modifier)))
130    }
131
132    /// Adds a stable-order, bounded event emitter rule.
133    pub fn sub_emitter(self, emitter: SubEmitter) -> Self {
134        self.change(|config| config.sub_emitters.push(emitter))
135    }
136
137    /// Prepares a steady-state lead-in without changing the visible clip duration.
138    pub fn prewarm(self, duration: Duration) -> Self {
139        self.change(|config| config.prewarm = duration)
140    }
141
142    /// Enables deterministic dual-phase seamless looping; two live populations share the limit.
143    pub fn loop_period(self, period: Duration) -> Self {
144        self.change(|config| config.loop_period = Some(period))
145    }
146
147    /// Remaps query time. Negative scales require allow_reverse, which explicitly revives past particles.
148    pub fn time_remap(self, scale: f64, offset: Duration, allow_reverse: bool) -> Self {
149        self.change(|config| {
150            config.time_scale = scale;
151            config.time_offset = offset;
152            config.allow_reverse = allow_reverse;
153        })
154    }
155
156    /// Requests 1..=64 CPU workers; independent updates retain stable IDs and event order.
157    pub fn workers(self, workers: usize) -> Self {
158        self.change(|config| config.workers = workers)
159    }
160
161    fn prepared(&self, duration: Option<Duration>) -> Result<&Prepared, ValidationError> {
162        let duration = self.config.duration.or(duration).ok_or_else(|| {
163            invalid(
164                "duration",
165                "Provide a simulation duration or a finite clip duration",
166            )
167        })?;
168        let prepared = self
169            .prepared
170            .get_or_init(|| prepare(&self.config, duration))
171            .as_ref()
172            .map_err(Clone::clone)?;
173        if prepared.duration != duration {
174            return Err(invalid(
175                "duration",
176                "Cached simulation duration changed; use a distinct cache key/instance",
177            ));
178        }
179        Ok(prepared)
180    }
181
182    /// Queries at the containing fixed boundary, never integrating a variable
183    /// remainder. Outside [0,duration) is empty. Source initialization is shared.
184    pub fn sample(&self, time: Time) -> Result<Arc<[Particle]>, ValidationError> {
185        self.sample_in(time, None)
186    }
187
188    /// Queries using explicit finite clip metadata when no duration was configured.
189    pub fn sample_in(
190        &self,
191        time: Time,
192        clip_duration: Option<Duration>,
193    ) -> Result<Arc<[Particle]>, ValidationError> {
194        self.sample_with(&mut SampleCursor::default(), time, clip_duration)
195    }
196
197    /// Equals [`Self::sample_in`], but continues stepping from `cursor` when it already lies
198    /// between the nearest retained snapshot and `time`, so ascending queries share work.
199    pub(crate) fn sample_with(
200        &self,
201        cursor: &mut SampleCursor,
202        time: Time,
203        clip_duration: Option<Duration>,
204    ) -> Result<Arc<[Particle]>, ValidationError> {
205        let prepared = self.prepared(clip_duration)?;
206        if time < Time::ZERO || time >= prepared.duration {
207            return Ok(Arc::from([]));
208        }
209        if let Some(replay) = &prepared.replay {
210            return replay.sample(&self.config, &mut cursor.replay, time);
211        }
212        let target = time.ticks() as u64 / self.config.step.ticks() as u64;
213        let index = prepared
214            .snapshots
215            .partition_point(|snapshot| snapshot.step <= target)
216            - 1;
217        let checkpoint = &prepared.snapshots[index];
218        if cursor
219            .state
220            .as_ref()
221            .is_some_and(|state| state.step > target || state.step < checkpoint.step)
222        {
223            cursor.state = None;
224        }
225        let state = cursor.state.get_or_insert_with(|| checkpoint.clone());
226        let mut work = 0;
227        while state.step < target {
228            if let Err(error) = advance(&self.config, state, &mut work) {
229                // A partially advanced step must never seed a later query.
230                cursor.state = None;
231                return Err(error);
232            }
233        }
234        Ok(state
235            .live
236            .iter()
237            .map(|live| live.particle)
238            .collect::<Vec<_>>()
239            .into())
240    }
241
242    /// Returns preparation stats without evaluating a rendered frame.
243    pub fn stats(&self) -> Result<SimulationStats, ValidationError> {
244        Ok(self.prepared(None)?.stats)
245    }
246
247    /// Returns events at the containing fixed boundary, sorted by stable ID and trigger kind.
248    pub fn events(&self, time: Time) -> Result<Vec<ParticleEvent>, ValidationError> {
249        let prepared = self.prepared(None)?;
250        if time < Time::ZERO || time >= prepared.duration {
251            return Ok(Vec::new());
252        }
253        if let Some(replay) = &prepared.replay {
254            replay.events(&self.config, time)
255        } else {
256            Ok(Vec::new())
257        }
258    }
259
260    pub(super) fn natural_duration(&self) -> Option<Duration> {
261        self.config.duration
262    }
263}
264
265#[derive(Clone)]
266struct Config {
267    seed: u64,
268    duration: Option<Duration>,
269    step: Duration,
270    interval: u64,
271    emitters: Vec<Arc<dyn ParticleEmitter>>,
272    modifiers: Vec<Arc<dyn ParticleModifier>>,
273
274    groups: Vec<Arc<dyn ParticleGroupModifier>>,
275    sub_emitters: Vec<SubEmitter>,
276    prewarm: Duration,
277    loop_period: Option<Duration>,
278    time_scale: f64,
279    time_offset: Duration,
280    allow_reverse: bool,
281    workers: usize,
282
283    limits: SimulationLimits,
284}
285
286#[derive(Clone, Copy)]
287struct Live {
288    particle: Particle,
289    birth: Time,
290}
291
292#[derive(Clone)]
293struct State {
294    step: u64,
295    next: u64,
296    live: Vec<Live>,
297}
298
299struct Prepared {
300    duration: Duration,
301    snapshots: Vec<State>,
302    replay: Option<replay::PreparedReplay>,
303    stats: SimulationStats,
304}
305
306/// Forward-only working state shared by the ascending queries of one render call.
307///
308/// It holds the admitted working buffers rather than extra retained snapshots, and is
309/// dropped with the render, so it never carries state between frames.
310#[derive(Default)]
311pub(crate) struct SampleCursor {
312    state: Option<State>,
313    replay: replay::ReplayCursor,
314}
315
316fn charge(config: &Config, work: &mut u64, amount: usize) -> Result<(), ValidationError> {
317    *work = work
318        .checked_add(amount as u64)
319        .ok_or_else(|| limit("Simulation work overflow"))?;
320    if *work > config.limits.max_work {
321        return Err(limit("Simulation work allowance exhausted"));
322    }
323    Ok(())
324}
325
326/// Counter-derived emitter stream, independent of iteration and worker count.
327fn emitter_random(config: &Config, order: usize, step: u64) -> Random {
328    Random::for_particle(config.seed ^ 0xe11e0000 ^ order as u64, step, 0)
329}
330
331/// Counter-derived modifier stream for one particle at one step.
332fn modifier_random(config: &Config, order: usize, index: u64, step: u64) -> Random {
333    Random::for_particle(
334        config.seed ^ (order as u64).wrapping_mul(0xd1b54a32d192ed03),
335        index,
336        step,
337    )
338}
339
340fn births(
341    config: &Config,
342    state: &mut State,
343    time: Time,
344    work: &mut u64,
345) -> Result<(), ValidationError> {
346    for (index, emitter) in config.emitters.iter().enumerate() {
347        let mut output = Vec::new();
348        let mut random = emitter_random(config, index, state.step);
349        emitter.emit(state.step, time, &mut random, &mut output)?;
350        charge(config, work, output.len() + 1)?;
351        if output.len() > config.limits.max_particles.saturating_sub(state.live.len()) {
352            return Err(limit("Live particle allowance exhausted"));
353        }
354        for mut particle in output {
355            if state.next >= 1_000_000 {
356                return Err(limit("Simulation exceeds one million total births"));
357            }
358            particle.index = state.next;
359            particle.age = 0.0;
360            particle.validate()?;
361            state.next += 1;
362            state.live.push(Live {
363                particle,
364                birth: time,
365            });
366        }
367    }
368    Ok(())
369}
370
371fn advance(config: &Config, state: &mut State, work: &mut u64) -> Result<(), ValidationError> {
372    state.step += 1;
373    let time = Time::from_ticks(state.step as i64 * config.step.ticks());
374    let seconds = config.step.as_seconds() as f32;
375    charge(
376        config,
377        work,
378        state.live.len() * (config.modifiers.len() + 2),
379    )?;
380    for live in &mut state.live {
381        let particle = &mut live.particle;
382        particle.age = (time - live.birth).as_seconds() as f32;
383        if particle.age >= particle.lifetime {
384            continue;
385        }
386        let index = particle.index;
387        let age = particle.age;
388        for (order, modifier) in config.modifiers.iter().enumerate() {
389            let mut random = modifier_random(config, order, index, state.step);
390            modifier.apply(particle, seconds, time, &mut random)?;
391            particle.index = index;
392            particle.age = age;
393            particle.validate()?;
394        }
395        for axis in 0..2 {
396            particle.position[axis] += particle.velocity[axis] * seconds;
397        }
398        particle.validate()?;
399    }
400    state
401        .live
402        .retain(|live| live.particle.age < live.particle.lifetime);
403    births(config, state, time, work)
404}
405
406fn prepare(config: &Config, duration: Duration) -> Result<Prepared, ValidationError> {
407    if replay::enabled(config) {
408        let replay = replay::prepare(config, duration)?;
409        let stats = replay.stats;
410        return Ok(Prepared {
411            duration,
412            snapshots: Vec::new(),
413            replay: Some(replay),
414            stats,
415        });
416    }
417    let limits = config.limits;
418    if duration <= Time::ZERO
419        || duration.as_seconds() > 3600.0
420        || config.step <= Time::ZERO
421        || config.step > duration
422        || config.interval == 0
423        || limits.max_particles == 0
424        || limits.max_particles > 65536
425        || limits.max_work == 0
426        || config.emitters.len() > 32
427        || config.modifiers.len() > 32
428    {
429        return Err(invalid(
430            "configuration",
431            "Invalid simulation duration/step/components/limits",
432        ));
433    }
434    let steps = duration.ticks() as u64 / config.step.ticks() as u64;
435    if steps > limits.max_steps || steps > 1_000_000 {
436        return Err(limit("Simulation fixed-step allowance exhausted"));
437    }
438    let slot = limits.max_particles as u64 * std::mem::size_of::<Live>() as u64
439        + std::mem::size_of::<State>() as u64;
440    let slots = limits.max_bytes / slot;
441    if slots < 3 {
442        return Err(limit(
443            "Snapshot budget needs one snapshot and two bounded working buffers",
444        ));
445    }
446    let capacity = slots - 2;
447    let interval = if capacity == 1 {
448        steps + 1
449    } else {
450        config.interval.max(steps.div_ceil(capacity - 1))
451    };
452    let mut state = State {
453        step: 0,
454        next: 0,
455        live: Vec::new(),
456    };
457    let mut work = 0;
458    births(config, &mut state, Time::ZERO, &mut work)?;
459    let mut snapshots = vec![state.clone()];
460    while state.step < steps {
461        advance(config, &mut state, &mut work)?;
462        if state.step.is_multiple_of(interval) {
463            snapshots.push(state.clone());
464        }
465    }
466    let stats = SimulationStats {
467        snapshots: snapshots.len(),
468        interval,
469        admitted_bytes: (snapshots.len() as u64 + 2) * slot,
470        work,
471    };
472    Ok(Prepared {
473        duration,
474        snapshots,
475        replay: None,
476        stats,
477    })
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use motion_core::TimeLiteral;
484    use std::sync::atomic::{AtomicUsize, Ordering};
485
486    struct Birth(Arc<AtomicUsize>);
487
488    impl ParticleEmitter for Birth {
489        fn emit(
490            &self,
491            step: u64,
492            _: Time,
493            _: &mut Random,
494            output: &mut Vec<Particle>,
495        ) -> Result<(), ValidationError> {
496            self.0.fetch_add(1, Ordering::SeqCst);
497            if step == 0 {
498                output.push(Particle {
499                    velocity: [1.0, 0.0],
500                    lifetime: 2.0,
501                    ..Default::default()
502                });
503            }
504            Ok(())
505        }
506    }
507
508    struct Acceleration;
509
510    impl ParticleModifier for Acceleration {
511        fn apply(
512            &self,
513            particle: &mut Particle,
514            step: f32,
515            _: Time,
516            _: &mut Random,
517        ) -> Result<(), ValidationError> {
518            particle.velocity[1] += 2.0 * step;
519            Ok(())
520        }
521    }
522
523    #[test]
524    fn fixed_snapshot_replay_is_order_independent_once_initialized_and_bounded()
525    -> motion_core::Result<()> {
526        assert_eq!(std::mem::size_of::<Particle>(), 72);
527        let calls = Arc::new(AtomicUsize::new(0));
528        let simulation = ParticleSimulation::new(42)
529            .duration(1.seconds())
530            .step(10.milliseconds())
531            .emitter(Birth(calls.clone()))
532            .modifier(Acceleration)
533            .limits(SimulationLimits {
534                max_particles: 2,
535                max_bytes: 2048,
536                ..Default::default()
537            })
538            .snapshot_interval(1);
539        std::thread::scope(|scope| {
540            for _ in 0..4 {
541                let simulation = &simulation;
542                scope.spawn(move || {
543                    let point = simulation.sample(500.milliseconds()).unwrap()[0];
544                    assert!((point.position[0] - 0.5).abs() < 1e-6);
545                    assert!((point.position[1] - 0.255).abs() < 1e-6);
546                });
547            }
548        });
549        assert_eq!(calls.load(Ordering::SeqCst), 101 + 4 * 5);
550        let stats = simulation.stats()?;
551        assert!(stats.interval > 1 && stats.admitted_bytes <= 2048);
552        for time in [990, 0, 500, 40, 500] {
553            assert_eq!(
554                simulation.sample(time.milliseconds())?,
555                simulation.sample(time.milliseconds())?
556            );
557        }
558        assert!(simulation.sample(1.seconds())?.is_empty());
559        assert!(
560            simulation
561                .clone()
562                .limits(SimulationLimits {
563                    max_bytes: 1,
564                    ..Default::default()
565                })
566                .sample(Time::ZERO)
567                .is_err()
568        );
569        Ok(())
570    }
571
572    #[test]
573    fn cursor_queries_match_independent_samples_for_every_replay_path() -> motion_core::Result {
574        let plain = ParticleSimulation::new(9)
575            .duration(2.seconds())
576            .emitter(
577                crate::particles::PointEmitter::new(motion_core::Point::ORIGIN)
578                    .speed(3.0)
579                    .lifetime(700.milliseconds()),
580            )
581            .modifier(Acceleration);
582        let simulations = [
583            plain.clone(),
584            plain.clone().workers(2),
585            plain.loop_period(900.milliseconds()),
586        ];
587        // Ascending runs that cross snapshots, repeat a time, jump back and wrap the loop.
588        let times = [
589            0, 10, 240, 250, 251, 300, 300, 1200, 1490, 260, 270, 1999, 5, 2000, 1000,
590        ];
591        for simulation in &simulations {
592            let mut cursor = SampleCursor::default();
593            for time in times {
594                let time = Time::from_ticks(motion_core::TICKS_PER_SECOND * time / 1000);
595                assert_eq!(
596                    &*simulation.sample_with(&mut cursor, time, None)?,
597                    &*simulation.sample_in(time, None)?,
598                    "{time:?}"
599                );
600            }
601        }
602        Ok(())
603    }
604}