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, created by [`ParticleSimulationBuilder::build`]. The first clip
54/// duration fixes a shared preparation; reusing one cache instance with a different
55/// duration is rejected explicitly. The configuration never changes after building.
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
64/// Collects a particle simulation's settings; [`build`](Self::build) validates them.
65#[derive(Clone)]
66pub struct ParticleSimulationBuilder {
67    config: Config,
68    pub(crate) style: Arc<ParticleStyle>,
69}
70
71impl ParticleSimulation {
72    /// Starts a lazy fixed-step simulation with an explicit deterministic seed. Cache the
73    /// built value with `SceneContext::try_cache` rather than building one afresh for every frame.
74    #[allow(clippy::new_ret_no_self)]
75    pub fn new(seed: u64) -> ParticleSimulationBuilder {
76        ParticleSimulationBuilder::new(seed)
77    }
78}
79
80impl ParticleSimulationBuilder {
81    /// Starts a simulation with an explicit deterministic seed.
82    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    /// Sets finite preparation duration; otherwise render inherits clip duration.
111    pub fn duration(self, duration: Duration) -> Self {
112        self.change(|config| config.duration = Some(duration))
113    }
114
115    /// Sets an exact positive integer-tick step; no variable last integration step.
116    pub fn step(self, step: Duration) -> Self {
117        self.change(|config| config.step = step)
118    }
119
120    /// Requests snapshot spacing; memory admission may increase it automatically.
121    pub fn snapshot_interval(self, steps: u64) -> Self {
122        self.change(|config| config.interval = steps)
123    }
124
125    /// Replaces logical resource allowances.
126    pub fn limits(self, limits: SimulationLimits) -> Self {
127        self.change(|config| config.limits = limits)
128    }
129
130    /// Appends one fixed-boundary emitter, in declaration order.
131    pub fn emitter(self, emitter: impl ParticleEmitter + 'static) -> Self {
132        self.change(|config| config.emitters.push(Arc::new(emitter)))
133    }
134
135    /// Appends one deterministic operator; ordering is significant.
136    pub fn modifier(self, modifier: impl ParticleModifier + 'static) -> Self {
137        self.change(|config| config.modifiers.push(Arc::new(modifier)))
138    }
139
140    /// Adds a population operator before independent particle updates.
141    pub fn group_modifier(self, modifier: impl ParticleGroupModifier + 'static) -> Self {
142        self.change(|config| config.groups.push(Arc::new(modifier)))
143    }
144
145    /// Adds a stable-order, bounded event emitter rule.
146    pub fn sub_emitter(self, emitter: SubEmitter) -> Self {
147        self.change(|config| config.sub_emitters.push(emitter))
148    }
149
150    /// Prepares a steady-state lead-in without changing the visible clip duration.
151    pub fn prewarm(self, duration: Duration) -> Self {
152        self.change(|config| config.prewarm = duration)
153    }
154
155    /// Enables deterministic dual-phase seamless looping; two live populations share the limit.
156    pub fn loop_period(self, period: Duration) -> Self {
157        self.change(|config| config.loop_period = Some(period))
158    }
159
160    /// Remaps query time. Negative scales require allow_reverse, which explicitly revives past particles.
161    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    /// Requests 1..=64 CPU workers; independent updates retain stable IDs and event order.
170    pub fn workers(self, workers: usize) -> Self {
171        self.change(|config| config.workers = workers)
172    }
173
174    /// Checks everything that does not depend on the clip duration and freezes the
175    /// simulation. Duration-dependent limits are checked when it is first sampled.
176    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    /// Reopens the settings for editing; the new simulation prepares afresh.
204    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    /// Queries at the containing fixed boundary, never integrating a variable
233    /// remainder. Outside [0,duration) is empty. Source initialization is shared.
234    pub fn sample(&self, time: Time) -> Result<Arc<[Particle]>, ValidationError> {
235        self.sample_in(time, None)
236    }
237
238    /// Queries using explicit finite clip metadata when no duration was configured.
239    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    /// Equals [`Self::sample_in`], but continues stepping from `cursor` when it already lies
248    /// between the nearest retained snapshot and `time`, so ascending queries share work.
249    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                // A partially advanced step must never seed a later query.
280                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    /// Returns preparation stats without evaluating a rendered frame.
293    pub fn stats(&self) -> Result<SimulationStats, ValidationError> {
294        Ok(self.prepared(None)?.stats)
295    }
296
297    /// Returns events at the containing fixed boundary, sorted by stable ID and trigger kind.
298    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/// Forward-only working state shared by the ascending queries of one render call.
357///
358/// It holds the admitted working buffers rather than extra retained snapshots, and is
359/// dropped with the render, so it never carries state between frames.
360#[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
376/// Counter-derived emitter stream, independent of iteration and worker count.
377fn emitter_random(config: &Config, order: usize, step: u64) -> Random {
378    Random::for_particle(config.seed ^ 0xe11e0000 ^ order as u64, step, 0)
379}
380
381/// Counter-derived modifier stream for one particle at one step.
382fn 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        // Ascending runs that cross snapshots, repeat a time, jump back and wrap the loop.
644        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}