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