Skip to main content

motion_content/particles/
curves.rs

1//! Stateless lifetime curves, blackbody colors and position/velocity fields.
2
3use super::*;
4use motion_core::Easing;
5
6/// Bounded nonnegative scalar keyframes with explicit normalized times and easing.
7#[derive(Clone)]
8pub struct ParticleCurve {
9    /// Strictly increasing normalized times and finite values in 0..=65504.
10    pub stops: Arc<[(f32, f32)]>,
11    /// Easing between neighboring stops.
12    pub easing: Easing,
13}
14
15impl ParticleCurve {
16    /// Creates a two-stop linear curve; invalid values fail during evaluation.
17    pub fn linear(start: f32, end: f32) -> Self {
18        Self {
19            stops: Arc::from([(0.0, start), (1.0, end)]),
20            easing: Easing::Linear,
21        }
22    }
23
24    /// Samples a validated curve, extending its endpoint values outside the stop span.
25    pub fn sample(&self, progress: f32) -> Result<f32, ValidationError> {
26        validate_times(self.stops.iter().map(|stop| stop.0), self.stops.len())?;
27        if !progress.is_finite()
28            || self
29                .stops
30                .iter()
31                .any(|stop| !stop.1.is_finite() || !(0.0..=65504.0).contains(&stop.1))
32        {
33            return Err(invalid(
34                "curve",
35                "Curve progress/values must be finite and nonnegative",
36            ));
37        }
38        let (first, second, amount) = interval(&self.stops, progress, &self.easing)?;
39        let value = first.1 * (1.0 - amount) + second.1 * amount;
40        if !value.is_finite() || !(0.0..=65504.0).contains(&value) {
41            return Err(invalid("curve", "Easing produced an invalid scalar value"));
42        }
43        Ok(value)
44    }
45}
46
47fn validate_times(times: impl Iterator<Item = f32>, count: usize) -> Result<(), ValidationError> {
48    let mut previous = -1.0;
49    if count == 0 || count > 256 {
50        return Err(invalid("curve", "Require 1..=256 keyframes"));
51    }
52    for time in times {
53        if !time.is_finite() || !(0.0..=1.0).contains(&time) || time <= previous {
54            return Err(invalid(
55                "curve",
56                "Keyframe times must be finite, normalized and strictly increasing",
57            ));
58        }
59        previous = time;
60    }
61    Ok(())
62}
63
64/// Bracketing stops and the eased interpolation amount.
65type Interval<'a, Value> = (&'a (f32, Value), &'a (f32, Value), f32);
66
67fn interval<'a, Value>(
68    stops: &'a [(f32, Value)],
69    progress: f32,
70    easing: &Easing,
71) -> Result<Interval<'a, Value>, ValidationError> {
72    let upper = stops
73        .partition_point(|stop| stop.0 <= progress)
74        .min(stops.len() - 1);
75    let lower = if progress >= stops[upper].0 {
76        upper
77    } else {
78        upper.saturating_sub(1)
79    };
80    let amount = if lower == upper {
81        0.0
82    } else {
83        (progress - stops[lower].0) / (stops[upper].0 - stops[lower].0)
84    };
85    Ok((
86        &stops[lower],
87        &stops[upper],
88        easing.try_apply(f64::from(amount.clamp(0.0, 1.0)))? as f32,
89    ))
90}
91
92/// Nonuniform premultiplied color keyframes, evaluated without accumulated state.
93#[derive(Clone)]
94pub struct ColorCurve {
95    /// Normalized times and finite premultiplied colors.
96    pub stops: Arc<[(f32, [f32; 4])]>,
97    /// Interpolation easing.
98    pub easing: Easing,
99}
100
101impl ColorCurve {
102    /// Samples validated finite keyframes.
103    pub fn sample(&self, progress: f32) -> Result<[f32; 4], ValidationError> {
104        validate_times(self.stops.iter().map(|stop| stop.0), self.stops.len())?;
105        if !progress.is_finite() {
106            return Err(invalid("curve", "Nonfinite color progress"));
107        }
108        let (first, second, amount) = interval(&self.stops, progress, &self.easing)?;
109        for stop in [first, second] {
110            Particle {
111                color: stop.1,
112                ..Particle::default()
113            }
114            .validate()?;
115        }
116        let color = std::array::from_fn(|channel| {
117            first.1[channel] * (1.0 - amount) + second.1[channel] * amount
118        });
119        Particle {
120            color,
121            ..Particle::default()
122        }
123        .validate()?;
124        Ok(color)
125    }
126}
127
128impl ParticleModifier for ColorCurve {
129    fn apply(
130        &self,
131        particle: &mut Particle,
132        _: f32,
133        _: Time,
134        _: &mut Random,
135    ) -> Result<(), ValidationError> {
136        particle.color = self.sample(particle.age / particle.lifetime)?;
137        particle.validate()
138    }
139}
140
141impl ColorOverLifetime {
142    /// Converts equally spaced stops to an explicit time/easing curve.
143    pub fn curve(self, easing: Easing) -> ColorCurve {
144        let denominator = self.stops.len().saturating_sub(1).max(1) as f32;
145        ColorCurve {
146            stops: self
147                .stops
148                .iter()
149                .enumerate()
150                .map(|(index, color)| (index as f32 / denominator, *color))
151                .collect(),
152            easing,
153        }
154    }
155}
156
157/// RGB energy multiplier; alpha is unchanged and HDR energy is not clipped.
158#[derive(Clone)]
159pub struct IntensityOverLifetime {
160    /// Multiplier over normalized lifetime.
161    pub curve: ParticleCurve,
162}
163
164impl IntensityOverLifetime {
165    /// Creates an RGB energy multiplier curve.
166    pub fn new(curve: ParticleCurve) -> Self {
167        Self { curve }
168    }
169}
170
171impl ParticleModifier for IntensityOverLifetime {
172    fn apply(
173        &self,
174        particle: &mut Particle,
175        _: f32,
176        _: Time,
177        _: &mut Random,
178    ) -> Result<(), ValidationError> {
179        let intensity = self.curve.sample(particle.age / particle.lifetime)?;
180        for channel in &mut particle.color[..3] {
181            *channel *= intensity;
182        }
183        particle.validate()
184    }
185
186    fn resets_color(&self) -> bool {
187        true
188    }
189}
190
191/// Deterministic blackbody approximation, 1000..=40000 Kelvin, decoded to linear RGB.
192/// As a modifier it replaces the particle's RGB with the premultiplied blackbody color,
193/// discarding any earlier hue; alpha is unchanged.
194#[derive(Clone)]
195pub struct Temperature {
196    /// Kelvin over normalized lifetime.
197    pub curve: ParticleCurve,
198}
199
200impl Temperature {
201    /// Creates a color-temperature curve.
202    pub fn new(curve: ParticleCurve) -> Self {
203        Self { curve }
204    }
205
206    /// Evaluates a bounded color temperature, with no platform color-management dependency.
207    pub fn rgb(kelvin: f32) -> Result<[f32; 3], ValidationError> {
208        if !kelvin.is_finite() || !(1000.0..=40000.0).contains(&kelvin) {
209            return Err(invalid("temperature", "Require 1000..=40000 Kelvin"));
210        }
211        let temperature = f64::from(kelvin) / 100.0;
212        let red = if temperature <= 66.0 {
213            255.0
214        } else {
215            329.698727446 * libm::pow(temperature - 60.0, -0.1332047592)
216        };
217        let green = if temperature <= 66.0 {
218            99.4708025861 * libm::log(temperature) - 161.1195681661
219        } else {
220            288.1221695283 * libm::pow(temperature - 60.0, -0.0755148492)
221        };
222        let blue = if temperature >= 66.0 {
223            255.0
224        } else if temperature <= 19.0 {
225            0.0
226        } else {
227            138.5177312231 * libm::log(temperature - 10.0) - 305.0447927307
228        };
229        Ok([red, green, blue]
230            .map(|value| motion_core::srgb_to_linear((value / 255.0).clamp(0.0, 1.0) as f32)))
231    }
232}
233
234impl ParticleModifier for Temperature {
235    fn apply(
236        &self,
237        particle: &mut Particle,
238        _: f32,
239        _: Time,
240        _: &mut Random,
241    ) -> Result<(), ValidationError> {
242        let rgb = Self::rgb(self.curve.sample(particle.age / particle.lifetime)?)?;
243        for (channel, value) in rgb.into_iter().enumerate() {
244            particle.color[channel] = value * particle.color[3];
245        }
246        particle.validate()
247    }
248}
249
250/// Scalar coordinate used by a color field.
251#[derive(Clone, Copy, Debug)]
252pub enum FieldCoordinate {
253    /// Horizontal position.
254    X,
255    /// Vertical position.
256    Y,
257    /// Velocity magnitude.
258    Speed,
259}
260
261/// Color sampled from position or speed, with an explicit normalization interval.
262#[derive(Clone)]
263pub struct ColorField {
264    /// Source coordinate.
265    pub coordinate: FieldCoordinate,
266    /// Strictly increasing finite normalization interval.
267    pub range: [f32; 2],
268    /// Output color curve.
269    pub colors: ColorCurve,
270}
271
272impl ParticleModifier for ColorField {
273    fn apply(
274        &self,
275        particle: &mut Particle,
276        _: f32,
277        _: Time,
278        _: &mut Random,
279    ) -> Result<(), ValidationError> {
280        if self.range.iter().any(|value| !value.is_finite()) || self.range[0] >= self.range[1] {
281            return Err(invalid("field", "Invalid coordinate range"));
282        }
283        let value = match self.coordinate {
284            FieldCoordinate::X => particle.position[0],
285            FieldCoordinate::Y => particle.position[1],
286            FieldCoordinate::Speed => libm::hypotf(particle.velocity[0], particle.velocity[1]),
287        };
288        particle.color = self
289            .colors
290            .sample(((value - self.range[0]) / (self.range[1] - self.range[0])).clamp(0.0, 1.0))?;
291        particle.validate()
292    }
293}