Skip to main content

motion_content/
particles.rs

1//! Deterministic fixed-step simulations and coupled analytic particles.
2//!
3//! ```
4//! use motion_content::prelude::*;
5//! use motion_core::prelude::*;
6//! use motion_core::{
7//!     DisplayList, RasterLimits, ResourcePool, ValidatedDisplayList, MAX_PATH_VERBS,
8//!     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
9//! };
10//! use motion_render::{Composition, RenderPrepare, RenderOptions, Renderer, render_still};
11//! use motion_content::particles::*;
12//! let cache = SceneCache::new(16);
13//! let context = SceneContext::new(
14//!     50.milliseconds(),
15//!     FrameRate::from(30),
16//!     Size::new(16.0, 16.0),
17//!     &cache,
18//! )
19//! .with_clip_duration(100.milliseconds());
20//! let simulation = context.cache(("sparks", 42_u64, 100_i64), || {
21//!     ParticleSimulation::new(42)
22//!         .duration(100.milliseconds())
23//!         .emitter(BurstEmitter::new(Time::ZERO, 4, Point::new(8.0, 8.0)).speed(0.0))
24//!         .modifier(Gravity::new(Vector2::new(0.0, 10.0)))
25//!         .style(ParticleStyle::Dots { radius: 2.0 })
26//! });
27//! assert_eq!(simulation.sample(50.milliseconds())?.len(), 4);
28//! let frame = simulation
29//!     .try_render(&context)?
30//!     .compile(
31//!         context.size(),
32//!         PixelSize::new(16, 16),
33//!         ValidationLimits::default(),
34//!     )?
35//!     .prepare(ValidationLimits::default(), RasterLimits::default())?
36//!     .render_linear(RasterLimits::default())?;
37//! assert!(frame.pixel(8, 8).unwrap()[3] > 0.0);
38//! # Ok::<(), motion_core::Error>(())
39//! ```
40
41use motion_core::{Duration, Random, Time, ValidationError};
42use std::sync::{Arc, OnceLock};
43
44mod batch;
45mod collision;
46mod components;
47mod curves;
48mod events;
49mod extended;
50mod forces;
51mod neighbors;
52pub mod presets;
53mod render;
54mod sprites;
55mod style;
56#[cfg(test)]
57mod tests;
58
59use batch::Instance;
60
61mod simulation;
62mod trajectory;
63
64pub use batch::ParticleBatch;
65pub use collision::{
66    CircleCollider, CollisionResponse, PolygonCollider, SdfCollider, SegmentCollider,
67    SignedDistanceGrid,
68};
69pub use components::{
70    Attractor, BounceBox, BurstEmitter, ColorOverLifetime, DiskEmitter, Drag, Gravity,
71    ImageDensity, ImageEmitter, PathEmitter, PointEmitter, RectEmitter, Seek, SizeOverLifetime,
72    SurfaceEmitter, Vortex, Wind,
73};
74pub use curves::{
75    ColorCurve, ColorField, FieldCoordinate, IntensityOverLifetime, ParticleCurve, Temperature,
76};
77pub use events::{Inherit, ParticleEvent, ParticleEventKind, SubEmitter};
78pub use forces::{
79    Boids, CurlNoise, FlowField, FlowSource, ForceFalloff, ParticleCollisions, RadialForce, Spring,
80};
81pub use simulation::{ParticleSimulation, SimulationLimits, SimulationStats};
82pub use sprites::{ProceduralSprite, SpriteAtlas, SpriteSheet, SpriteSheetMode};
83pub use style::{
84    BokehShape, ParticleBlend, ParticleShape, ParticleStyle, SpeedStretch, StyleOptions,
85    TrailOptions,
86};
87pub use trajectory::{Ballistic, Converge, NoiseDrift, Orbit, ParticleTrajectory, TrajectoryModel};
88
89use motion_core::instances::MAX_INSTANCE_COUNT;
90
91/// Maximum particle size in design units accepted by validation.
92const MAX_PARTICLE_SIZE: f32 = 4096.0;
93
94/// Maximum particle lifetime in seconds accepted by validation.
95const MAX_LIFETIME_SECONDS: f32 = 3600.0;
96
97/// 72-byte particle record; colors are finite linear-premultiplied RGBA.
98#[repr(C)]
99#[derive(Clone, Copy, Debug, PartialEq)]
100#[cfg_attr(feature = "gpu", derive(bytemuck::Pod, bytemuck::Zeroable))]
101pub struct Particle {
102    /// Local center in design coordinates.
103    pub position: [f32; 2],
104    /// Units per second.
105    pub velocity: [f32; 2],
106    /// Linear-premultiplied RGBA, not encoded bytes.
107    pub color: [f32; 4],
108    /// Nonnegative style size multiplier.
109    pub size: f32,
110    /// Clockwise radians.
111    pub rotation: f32,
112    /// Canonical seconds since birth, owned by the simulation.
113    pub age: f32,
114    /// Positive lifetime seconds, half-open at death.
115    pub lifetime: f32,
116    /// Stable nonrecycled emission index.
117    pub index: u64,
118    /// User-owned values retained by snapshots.
119    pub custom: [f32; 4],
120}
121
122impl Default for Particle {
123    fn default() -> Self {
124        Self {
125            position: [0.0; 2],
126            velocity: [0.0; 2],
127            color: [1.0; 4],
128            size: 1.0,
129            rotation: 0.0,
130            age: 0.0,
131            lifetime: 1.0,
132            index: 0,
133            custom: [0.0; 4],
134        }
135    }
136}
137
138impl Particle {
139    pub(crate) fn validate(&self) -> Result<(), ValidationError> {
140        // Simulation steps validate every particle after every modifier, so the finiteness
141        // check avoids a chained iterator that does not vectorize.
142        let finite = |values: &[f32]| values.iter().all(|value| value.is_finite());
143        if !(finite(&self.position)
144            && finite(&self.velocity)
145            && finite(&self.color)
146            && finite(&self.custom)
147            && finite(&[self.size, self.rotation, self.age, self.lifetime]))
148            || self.size < 0.0
149            || self.size > MAX_PARTICLE_SIZE
150            || self.age < 0.0
151            || self.lifetime <= 0.0
152            || self.lifetime > MAX_LIFETIME_SECONDS
153            || !(0.0..=1.0).contains(&self.color[3])
154            || (self.color[3] == 0.0 && self.color[..3].iter().any(|value| *value != 0.0))
155        {
156            return Err(invalid(
157                "state",
158                "Invalid finite particle geometry, lifetime or premultiplied color",
159            ));
160        }
161        Ok(())
162    }
163}
164
165/// Fixed-step emitter. Callbacks are deterministic and cooperative: output is
166/// bounded after return, not a hard allocator sandbox. IDs/age are assigned by
167/// the simulation. Random streams are keyed per emitter and step.
168pub trait ParticleEmitter: Send + Sync {
169    /// Filters event-triggered births. Root emission is unaffected; ordinary emitters accept all events.
170    fn accepts_event(&self, _: &ParticleEvent) -> bool {
171        true
172    }
173
174    /// Appends births at this exact fixed-step boundary.
175    fn emit(
176        &self,
177        step: u64,
178        time: Time,
179        random: &mut Random,
180        output: &mut Vec<Particle>,
181    ) -> Result<(), ValidationError>;
182}
183
184/// Ordered per-particle simulation operator, followed by velocity integration.
185pub trait ParticleModifier: Send + Sync {
186    /// Updates one live particle; index and canonical age remain engine-owned.
187    fn apply(
188        &self,
189        particle: &mut Particle,
190        step_seconds: f32,
191        time: Time,
192        random: &mut Random,
193    ) -> Result<(), ValidationError>;
194
195    /// Requests birth-color restoration before the ordered modifier chain.
196    /// Multiplicative lifetime energy curves use this to avoid frame-count accumulation.
197    fn resets_color(&self) -> bool {
198        false
199    }
200
201    /// Conservative per-particle operator work charged before replay.
202    fn work(&self) -> u64 {
203        1
204    }
205
206    /// Optional predicted-step collision response, preserving the original callback API.
207    fn apply_event(
208        &self,
209        particle: &mut Particle,
210        step_seconds: f32,
211        time: Time,
212        random: &mut Random,
213    ) -> Result<bool, ValidationError> {
214        self.apply(particle, step_seconds, time, random)?;
215        Ok(false)
216    }
217}
218
219/// A stable-order population operator, evaluated before independent modifiers.
220pub trait ParticleGroupModifier: Send + Sync {
221    /// Applies a bounded deterministic population update and returns logical work.
222    fn apply(
223        &self,
224        particles: &mut [Particle],
225        step: f32,
226        time: Time,
227        max_work: u64,
228    ) -> Result<u64, ValidationError>;
229
230    /// Whether displaced records should emit collision events.
231    fn collisions(&self) -> bool {
232        false
233    }
234}
235
236fn invalid(field: &str, message: &str) -> ValidationError {
237    ValidationError::invalid("particles", field, message)
238}
239
240fn limit(message: &str) -> ValidationError {
241    ValidationError::limit("particles", "budget", message)
242}