Skip to main content

motion_content/particles/
forces.rs

1//! Deterministic flow sampling and stable-order population forces.
2
3use super::*;
4use motion_core::{Point, Rect};
5
6/// Spatial attenuation with no singular denominator at a force center.
7#[derive(Clone)]
8pub enum ForceFalloff {
9    /// Constant strength inside the authored radius.
10    Constant,
11    /// Linear falloff to zero at the radius.
12    Linear,
13    /// Softened inverse-square distance.
14    InverseSquare,
15    /// Explicit distance-to-strength curve.
16    Curve(ParticleCurve),
17}
18
19/// Radius-bounded attraction/repulsion using an explicit attenuation policy.
20#[derive(Clone)]
21pub struct RadialForce {
22    /// Force center.
23    pub center: Point,
24    /// Signed acceleration scale.
25    pub strength: f32,
26    /// Finite positive influence radius.
27    pub radius: f32,
28    /// Spatial attenuation.
29    pub falloff: ForceFalloff,
30}
31
32impl ParticleModifier for RadialForce {
33    fn apply(
34        &self,
35        particle: &mut Particle,
36        step: f32,
37        _: Time,
38        _: &mut Random,
39    ) -> Result<(), ValidationError> {
40        if !self.radius.is_finite()
41            || self.radius <= 0.0
42            || !self.strength.is_finite()
43            || !self.center.x.is_finite()
44            || !self.center.y.is_finite()
45        {
46            return Err(invalid(
47                "force",
48                "Require finite force center/strength and positive radius",
49            ));
50        }
51        let delta = [
52            self.center.x as f32 - particle.position[0],
53            self.center.y as f32 - particle.position[1],
54        ];
55        let distance = libm::hypotf(delta[0], delta[1]);
56        if distance >= self.radius || distance == 0.0 {
57            return Ok(());
58        }
59        let amount = match &self.falloff {
60            ForceFalloff::Constant => 1.0,
61            ForceFalloff::Linear => 1.0 - distance / self.radius,
62            ForceFalloff::InverseSquare => 1.0 / distance.max(1.0).powi(2),
63            ForceFalloff::Curve(curve) => curve.sample(distance / self.radius)?,
64        };
65        for (velocity, delta) in particle.velocity.iter_mut().zip(delta) {
66            *velocity += delta / distance * self.strength * amount * step;
67        }
68        particle.validate()
69    }
70}
71
72/// Divergence-free curl of the shared seeded Perlin potential, advected over time.
73#[derive(Clone, Copy, Debug)]
74pub struct CurlNoise {
75    /// Positive spatial frequency.
76    pub frequency: f64,
77    /// Signed acceleration multiplier.
78    pub strength: f64,
79    /// Explicit potential seed.
80    pub seed: u32,
81    /// Potential advection rate per second.
82    pub phase_speed: f64,
83}
84
85fn shared_curl(
86    position: [f32; 2],
87    frequency: f64,
88    phase: f64,
89    seed: u32,
90) -> Result<[f64; 2], ValidationError> {
91    if !frequency.is_finite() || frequency <= 0.0 || !phase.is_finite() {
92        return Err(invalid(
93            "curl",
94            "Require positive finite frequency and finite phase",
95        ));
96    }
97    let point = position.map(|value| f64::from(value) * frequency + phase);
98    if !motion_core::noise::lattice_supported(point) {
99        return Err(invalid(
100            "curl",
101            "Curl coordinates exceed signed 32-bit lattice support",
102        ));
103    }
104    Ok(motion_core::noise::curl2(point, seed))
105}
106
107impl ParticleModifier for CurlNoise {
108    fn apply(
109        &self,
110        particle: &mut Particle,
111        step: f32,
112        time: Time,
113        _: &mut Random,
114    ) -> Result<(), ValidationError> {
115        if !self.strength.is_finite()
116            || !self.phase_speed.is_finite()
117            || !step.is_finite()
118            || step < 0.0
119        {
120            return Err(invalid(
121                "curl",
122                "Require finite strength, phase speed and nonnegative step",
123            ));
124        }
125        let direction = shared_curl(
126            particle.position,
127            self.frequency,
128            time.as_seconds() * self.phase_speed,
129            self.seed,
130        )?;
131        let velocity: [f32; 2] = std::array::from_fn(|axis| {
132            (f64::from(particle.velocity[axis]) + direction[axis] * self.strength * f64::from(step))
133                as f32
134        });
135        if velocity.into_iter().any(|value| !value.is_finite()) {
136            return Err(invalid(
137                "curl",
138                "Curl velocity exceeds finite particle support",
139            ));
140        }
141        particle.velocity = velocity;
142        particle.validate()
143    }
144
145    fn work(&self) -> u64 {
146        96
147    }
148}
149
150/// Immutable source for an evolving velocity field.
151#[derive(Clone)]
152pub enum FlowSource {
153    /// Two-dimensional curl of the shared analytic Perlin potential.
154    Curl {
155        /// Explicit stable seed.
156        seed: u32,
157        /// Positive spatial frequency.
158        frequency: f64,
159    },
160    /// Red/green channels interpreted as a normalized vector map.
161    Image {
162        /// Immutable premultiplied material whose straight RG encodes vectors.
163        image: Arc<crate::ImageAsset>,
164        /// Local domain mapped onto the image.
165        bounds: Rect,
166    },
167}
168
169/// Time-evolving noise or image flow, with no mutable frame cursor.
170#[derive(Clone)]
171pub struct FlowField {
172    /// Field material.
173    pub source: FlowSource,
174    /// Acceleration scale.
175    pub strength: f32,
176    /// Phase rate per second.
177    pub phase_speed: f64,
178}
179
180impl FlowField {
181    /// Constructs an explicit shared two-dimensional curl source.
182    pub fn curl(seed: u32, frequency: f64, strength: f32) -> Self {
183        Self {
184            source: FlowSource::Curl { seed, frequency },
185            strength,
186            phase_speed: 1.0,
187        }
188    }
189}
190
191impl ParticleModifier for FlowField {
192    fn apply(
193        &self,
194        particle: &mut Particle,
195        step: f32,
196        time: Time,
197        _: &mut Random,
198    ) -> Result<(), ValidationError> {
199        if !self.strength.is_finite() || !self.phase_speed.is_finite() {
200            return Err(invalid("flow", "Require finite strength and phase speed"));
201        }
202        let phase = time.as_seconds() * self.phase_speed;
203        let direction = match &self.source {
204            FlowSource::Curl { seed, frequency } => {
205                shared_curl(particle.position, *frequency, phase, *seed)?.map(|value| value as f32)
206            }
207            FlowSource::Image { image, bounds } => {
208                motion_core::drawing::validate_rect(*bounds, "particles.flow", "bounds")?;
209                if bounds.width() <= 0.0 || bounds.height() <= 0.0 {
210                    return Err(invalid("flow", "Require positive flow image bounds"));
211                }
212                let horizontal = ((f64::from(particle.position[0]) - bounds.left) / bounds.width()
213                    + phase)
214                    .rem_euclid(1.0);
215                let vertical = ((f64::from(particle.position[1]) - bounds.top) / bounds.height())
216                    .clamp(0.0, 1.0);
217                let sample = image.levels()[0]
218                    .pixel(
219                        (horizontal * f64::from(image.width())) as u32,
220                        ((vertical * f64::from(image.height())) as u32).min(image.height() - 1),
221                    )
222                    .ok_or_else(|| invalid("flow", "Missing image vector"))?;
223                if sample[3] == 0.0 {
224                    [0.0; 2]
225                } else {
226                    [
227                        sample[0] / sample[3] * 2.0 - 1.0,
228                        sample[1] / sample[3] * 2.0 - 1.0,
229                    ]
230                }
231            }
232        };
233        for (velocity, direction) in particle.velocity.iter_mut().zip(direction) {
234            *velocity += direction * self.strength * step;
235        }
236        particle.validate()
237    }
238
239    fn work(&self) -> u64 {
240        if matches!(self.source, FlowSource::Curl { .. }) {
241            96
242        } else {
243            1
244        }
245    }
246}
247
248/// Spatially hashed flocking evaluated from one immutable population snapshot.
249#[derive(Clone, Copy, Debug)]
250pub struct Boids {
251    /// Positive neighbor radius.
252    pub radius: f64,
253    /// Separation strength.
254    pub separation: f32,
255    /// Alignment strength.
256    pub alignment: f32,
257    /// Cohesion strength.
258    pub cohesion: f32,
259    /// Maximum population admitted by this operator.
260    pub max_particles: usize,
261}
262
263impl Default for Boids {
264    fn default() -> Self {
265        Self {
266            radius: 32.0,
267            separation: 1.0,
268            alignment: 0.5,
269            cohesion: 0.1,
270            max_particles: 4096,
271        }
272    }
273}
274
275impl ParticleGroupModifier for Boids {
276    fn apply(
277        &self,
278        particles: &mut [Particle],
279        step: f32,
280        _: Time,
281        max_work: u64,
282    ) -> Result<u64, ValidationError> {
283        if self.max_particles == 0
284            || particles.len() > self.max_particles
285            || [self.separation, self.alignment, self.cohesion]
286                .iter()
287                .any(|value| !value.is_finite() || *value < 0.0)
288        {
289            return Err(limit("Invalid flock parameters or population allowance"));
290        }
291        let pairs = super::neighbors::pairs(particles, self.radius, max_work / 16)?;
292        let work = particles.len() as u64 * 4 + pairs.len() as u64 * 32;
293        if work > max_work {
294            return Err(limit("Flock work exhausted"));
295        }
296        let mut forces = vec![[0.0_f32; 6]; particles.len()];
297        let mut counts = vec![0u32; particles.len()];
298        for (first, second) in pairs {
299            for (owner, other) in [(first, second), (second, first)] {
300                let difference = [
301                    particles[owner].position[0] - particles[other].position[0],
302                    particles[owner].position[1] - particles[other].position[1],
303                ];
304                let square =
305                    (difference[0] * difference[0] + difference[1] * difference[1]).max(1.0);
306                for axis in 0..2 {
307                    forces[owner][axis] += difference[axis] / square;
308                    forces[owner][axis + 2] += particles[other].velocity[axis];
309                    forces[owner][axis + 4] += particles[other].position[axis];
310                }
311                counts[owner] += 1;
312            }
313        }
314        for (index, particle) in particles.iter_mut().enumerate() {
315            if counts[index] == 0 {
316                continue;
317            }
318            let count = counts[index] as f32;
319            for axis in 0..2 {
320                particle.velocity[axis] += step
321                    * (forces[index][axis] * self.separation
322                        + (forces[index][axis + 2] / count - particle.velocity[axis])
323                            * self.alignment
324                        + (forces[index][axis + 4] / count - particle.position[axis])
325                            * self.cohesion);
326            }
327            particle.validate()?;
328        }
329        Ok(work)
330    }
331}
332
333/// Consecutive stable IDs coupled by Hooke springs, optionally forming a closed chain.
334#[derive(Clone, Copy, Debug)]
335pub struct Spring {
336    /// Rest distance between consecutive records.
337    pub rest_length: f32,
338    /// Nonnegative spring acceleration coefficient.
339    pub stiffness: f32,
340    /// Nonnegative damping coefficient along the connection.
341    pub damping: f32,
342    /// Connect the last record back to the first.
343    pub closed: bool,
344}
345
346impl ParticleGroupModifier for Spring {
347    fn apply(
348        &self,
349        particles: &mut [Particle],
350        step: f32,
351        _: Time,
352        max_work: u64,
353    ) -> Result<u64, ValidationError> {
354        if [self.rest_length, self.stiffness, self.damping]
355            .iter()
356            .any(|value| !value.is_finite() || *value < 0.0)
357        {
358            return Err(invalid(
359                "spring",
360                "Require finite nonnegative spring parameters",
361            ));
362        }
363        let work = particles.len() as u64 * 32;
364        if work > max_work {
365            return Err(limit("Spring work exhausted"));
366        }
367        let mut order = (0..particles.len()).collect::<Vec<_>>();
368        order.sort_by_key(|index| particles[*index].index);
369        let mut forces = vec![[0.0_f32; 2]; particles.len()];
370        let count = order.len().saturating_sub(1) + usize::from(self.closed && order.len() > 2);
371        for index in 0..count {
372            let first = order[index];
373            let second = order[(index + 1) % order.len()];
374            let delta = [
375                particles[second].position[0] - particles[first].position[0],
376                particles[second].position[1] - particles[first].position[1],
377            ];
378            let distance = libm::hypotf(delta[0], delta[1]);
379            if distance == 0.0 {
380                continue;
381            }
382            let normal = delta.map(|value| value / distance);
383            let speed = (particles[second].velocity[0] - particles[first].velocity[0]) * normal[0]
384                + (particles[second].velocity[1] - particles[first].velocity[1]) * normal[1];
385            let amount = (distance - self.rest_length) * self.stiffness + speed * self.damping;
386            for axis in 0..2 {
387                forces[first][axis] += normal[axis] * amount;
388                forces[second][axis] -= normal[axis] * amount;
389            }
390        }
391        for (particle, force) in particles.iter_mut().zip(forces) {
392            for (velocity, force) in particle.velocity.iter_mut().zip(force) {
393                *velocity += force * step;
394            }
395            particle.validate()?;
396        }
397        Ok(work)
398    }
399}
400
401/// Optional equal-mass sphere contacts using stable spatial-hash pair ordering.
402#[derive(Clone, Copy, Debug)]
403pub struct ParticleCollisions {
404    /// Contact restitution/friction; kill_on_hit is supported.
405    pub response: CollisionResponse,
406}
407
408impl ParticleGroupModifier for ParticleCollisions {
409    fn apply(
410        &self,
411        particles: &mut [Particle],
412        _: f32,
413        _: Time,
414        max_work: u64,
415    ) -> Result<u64, ValidationError> {
416        self.response.validate()?;
417        let radius = particles
418            .iter()
419            .fold(0.0_f32, |value, particle| value.max(particle.size))
420            * 2.0;
421        if radius == 0.0 {
422            return Ok(particles.len() as u64);
423        }
424        let pairs = super::neighbors::pairs(particles, f64::from(radius), max_work / 16)?;
425        let work = particles.len() as u64 + pairs.len() as u64 * 32;
426        if work > max_work {
427            return Err(limit("Particle contact work exhausted"));
428        }
429        for (first, second) in pairs {
430            let delta = [
431                particles[second].position[0] - particles[first].position[0],
432                particles[second].position[1] - particles[first].position[1],
433            ];
434            let distance = libm::hypotf(delta[0], delta[1]);
435            let minimum = particles[first].size + particles[second].size;
436            if distance >= minimum {
437                continue;
438            }
439            let normal = if distance == 0.0 {
440                [1.0, 0.0]
441            } else {
442                delta.map(|value| value / distance)
443            };
444            let relative = [
445                particles[second].velocity[0] - particles[first].velocity[0],
446                particles[second].velocity[1] - particles[first].velocity[1],
447            ];
448            let speed = relative[0] * normal[0] + relative[1] * normal[1];
449            for axis in 0..2 {
450                let correction = normal[axis] * (minimum - distance + 1e-5) * 0.5;
451                particles[first].position[axis] -= correction;
452                particles[second].position[axis] += correction;
453                if speed < 0.0 {
454                    let impulse = normal[axis] * speed * (1.0 + self.response.restitution) * 0.5;
455                    let friction =
456                        (relative[axis] - speed * normal[axis]) * self.response.friction * 0.5;
457                    particles[first].velocity[axis] += impulse + friction;
458                    particles[second].velocity[axis] -= impulse + friction;
459                }
460            }
461            if self.response.kill_on_hit {
462                for index in [first, second] {
463                    particles[index].lifetime = particles[index].age.max(f32::MIN_POSITIVE);
464                }
465            }
466        }
467        for particle in particles {
468            particle.validate()?;
469        }
470        Ok(work)
471    }
472
473    fn collisions(&self) -> bool {
474        true
475    }
476}