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