Skip to main content

motion_content/particles/components/
modifiers.rs

1//! Force, damping, collision and lifetime-curve modifiers for particle simulations.
2
3use super::*;
4use motion_core::{Rect, Vector2};
5
6/// Constant acceleration, applied before semi-implicit integration.
7#[derive(Clone, Copy, Debug)]
8pub struct Gravity {
9    /// Acceleration in scene units per second squared.
10    pub acceleration: Vector2,
11}
12
13impl Gravity {
14    /// Creates a constant acceleration.
15    pub fn new(acceleration: Vector2) -> Self {
16        Self { acceleration }
17    }
18}
19
20/// Constant wind acceleration, independent of elapsed scene time.
21#[derive(Clone, Copy, Debug)]
22pub struct Wind {
23    /// Acceleration in scene units per second squared.
24    pub acceleration: Vector2,
25}
26
27impl Wind {
28    /// Creates a constant wind acceleration.
29    pub fn new(acceleration: Vector2) -> Self {
30        Self { acceleration }
31    }
32}
33
34macro_rules! force {
35    ($name:ident) => {
36        impl ParticleModifier for $name {
37            fn apply(
38                &self,
39                particle: &mut Particle,
40                step: f32,
41                _: Time,
42                _: &mut Random,
43            ) -> Result<(), ValidationError> {
44                particle.velocity[0] += self.acceleration.x as f32 * step;
45                particle.velocity[1] += self.acceleration.y as f32 * step;
46                particle.validate()
47            }
48        }
49    };
50}
51force!(Gravity);
52force!(Wind);
53
54/// Per-step exponential velocity damping; simulation operator order is explicit.
55#[derive(Clone, Copy, Debug)]
56pub struct Drag {
57    /// Exponential damping rate per second; finite and nonnegative.
58    pub coefficient: f64,
59}
60
61impl Drag {
62    /// Creates a damping rate.
63    pub fn new(coefficient: f64) -> Self {
64        Self { coefficient }
65    }
66}
67
68impl ParticleModifier for Drag {
69    fn apply(
70        &self,
71        particle: &mut Particle,
72        step: f32,
73        _: Time,
74        _: &mut Random,
75    ) -> Result<(), ValidationError> {
76        if !self.coefficient.is_finite() || self.coefficient < 0.0 {
77            return Err(invalid("drag", "Drag must be finite and nonnegative"));
78        }
79        let damping = libm::exp(-self.coefficient * f64::from(step)) as f32;
80        particle.velocity = particle.velocity.map(|value| value * damping);
81        Ok(())
82    }
83}
84
85/// Tangential acceleration about a softened point vortex.
86#[derive(Clone, Copy, Debug)]
87pub struct Vortex {
88    /// Vortex center.
89    pub center: Point,
90    /// Tangential acceleration scale.
91    pub strength: f64,
92}
93
94impl ParticleModifier for Vortex {
95    fn apply(
96        &self,
97        particle: &mut Particle,
98        step: f32,
99        _: Time,
100        _: &mut Random,
101    ) -> Result<(), ValidationError> {
102        let horizontal = f64::from(particle.position[0]) - self.center.x;
103        let vertical = f64::from(particle.position[1]) - self.center.y;
104        let distance = libm::hypot(horizontal, vertical).max(1.0);
105        particle.velocity[0] += (-vertical / distance * self.strength * f64::from(step)) as f32;
106        particle.velocity[1] += (horizontal / distance * self.strength * f64::from(step)) as f32;
107        particle.validate()
108    }
109}
110
111/// Softened inverse-square attraction without singular acceleration at the center.
112#[derive(Clone, Copy, Debug)]
113pub struct Attractor {
114    /// Attractor location.
115    pub center: Point,
116    /// Acceleration-strength coefficient.
117    pub strength: f64,
118}
119
120impl ParticleModifier for Attractor {
121    fn apply(
122        &self,
123        particle: &mut Particle,
124        step: f32,
125        _: Time,
126        _: &mut Random,
127    ) -> Result<(), ValidationError> {
128        let horizontal = self.center.x - f64::from(particle.position[0]);
129        let vertical = self.center.y - f64::from(particle.position[1]);
130        let distance = libm::hypot(horizontal, vertical).max(1.0);
131        let amount = self.strength / (distance * distance * distance) * f64::from(step);
132        particle.velocity[0] += (horizontal * amount) as f32;
133        particle.velocity[1] += (vertical * amount) as f32;
134        particle.validate()
135    }
136}
137
138/// Stable-index seeking to a point set, for example VectorTree::sample_points.
139#[derive(Clone, Debug)]
140pub struct Seek {
141    /// Immutable target points.
142    pub targets: Arc<[Point]>,
143    /// Velocity correction coefficient per second.
144    pub strength: f64,
145}
146
147impl ParticleModifier for Seek {
148    fn apply(
149        &self,
150        particle: &mut Particle,
151        step: f32,
152        _: Time,
153        _: &mut Random,
154    ) -> Result<(), ValidationError> {
155        if self.targets.is_empty()
156            || self.targets.len() > 65536
157            || !self.strength.is_finite()
158            || self.strength < 0.0
159        {
160            return Err(invalid("seek", "Invalid target set/strength"));
161        }
162        let target = self.targets[particle.index as usize % self.targets.len()];
163        let amount = (self.strength * f64::from(step)).min(1.0);
164        for (axis, value) in [target.x, target.y].into_iter().enumerate() {
165            let desired = value - f64::from(particle.position[axis]);
166            particle.velocity[axis] =
167                (f64::from(particle.velocity[axis]) * (1.0 - amount) + desired * amount) as f32;
168        }
169        particle.validate()
170    }
171}
172
173/// Reflects predicted next positions against a rectangle. Place last when other
174/// operators change velocity; arbitrary multiple wall crossings are supported.
175#[derive(Clone, Copy, Debug)]
176pub struct BounceBox {
177    /// Rectangle the particles bounce inside.
178    pub bounds: Rect,
179}
180
181impl BounceBox {
182    /// Creates a reflecting rectangle.
183    pub fn new(bounds: Rect) -> Self {
184        Self { bounds }
185    }
186}
187
188impl ParticleModifier for BounceBox {
189    fn apply(
190        &self,
191        particle: &mut Particle,
192        step: f32,
193        _: Time,
194        _: &mut Random,
195    ) -> Result<(), ValidationError> {
196        motion_core::drawing::validate_rect(self.bounds, "particles", "bounce_box")?;
197        for (axis, (low, high)) in [
198            (self.bounds.left, self.bounds.right),
199            (self.bounds.top, self.bounds.bottom),
200        ]
201        .into_iter()
202        .enumerate()
203        {
204            let extent = high - low;
205            if extent <= 0.0 {
206                return Err(invalid(
207                    "bounce",
208                    "Bounce box must have positive dimensions",
209                ));
210            }
211            let next = f64::from(particle.position[axis])
212                + f64::from(particle.velocity[axis]) * f64::from(step);
213            let phase = (next - low).rem_euclid(extent * 2.0);
214            let reflected = if phase <= extent {
215                low + phase
216            } else {
217                high - (phase - extent)
218            };
219            if phase > extent {
220                particle.velocity[axis] = -particle.velocity[axis];
221            }
222            particle.position[axis] =
223                (reflected - f64::from(particle.velocity[axis]) * f64::from(step)) as f32;
224        }
225        particle.validate()
226    }
227}
228
229/// Piecewise-linear linear-premultiplied RGBA over normalized lifetime.
230#[derive(Clone, Debug)]
231pub struct ColorOverLifetime {
232    /// Evenly spaced color stops, one to 256.
233    pub stops: Arc<[[f32; 4]]>,
234}
235
236impl ColorOverLifetime {
237    /// Creates a color ramp from evenly spaced stops.
238    pub fn new(stops: impl Into<Arc<[[f32; 4]]>>) -> Self {
239        Self {
240            stops: stops.into(),
241        }
242    }
243}
244
245impl ParticleModifier for ColorOverLifetime {
246    fn apply(
247        &self,
248        particle: &mut Particle,
249        _: f32,
250        _: Time,
251        _: &mut Random,
252    ) -> Result<(), ValidationError> {
253        if self.stops.is_empty() || self.stops.len() > 256 {
254            return Err(invalid("colors", "Require 1..=256 color stops"));
255        }
256        for color in self.stops.iter() {
257            Particle {
258                color: *color,
259                ..Default::default()
260            }
261            .validate()?;
262        }
263        let position =
264            (particle.age / particle.lifetime).clamp(0.0, 1.0) * (self.stops.len() - 1) as f32;
265        let lower = libm::floorf(position) as usize;
266        let upper = (lower + 1).min(self.stops.len() - 1);
267        let amount = position - lower as f32;
268        particle.color = std::array::from_fn(|channel| {
269            self.stops[lower][channel] * (1.0 - amount) + self.stops[upper][channel] * amount
270        });
271        particle.validate()
272    }
273}
274
275/// Linear nonnegative size multiplier over normalized lifetime.
276#[derive(Clone, Copy, Debug)]
277pub struct SizeOverLifetime {
278    /// Birth multiplier.
279    pub start: f64,
280    /// End-of-life multiplier.
281    pub end: f64,
282}
283
284impl ParticleModifier for SizeOverLifetime {
285    fn apply(
286        &self,
287        particle: &mut Particle,
288        _: f32,
289        _: Time,
290        _: &mut Random,
291    ) -> Result<(), ValidationError> {
292        if !self.start.is_finite()
293            || !self.end.is_finite()
294            || !(0.0..=4096.0).contains(&self.start)
295            || !(0.0..=4096.0).contains(&self.end)
296        {
297            return Err(invalid("size", "Size stops must be finite in 0..=4096"));
298        }
299        let progress = f64::from(particle.age / particle.lifetime).clamp(0.0, 1.0);
300        particle.size = (self.start * (1.0 - progress) + self.end * progress) as f32;
301        particle.validate()
302    }
303}