Skip to main content

rustmotion_components/
emitter.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use skia_safe::{Canvas, PaintStyle};
4
5use rustmotion_core::css::CssStyle;
6use rustmotion_core::engine::animator::{ease, AnimatedProperties};
7use rustmotion_core::engine::layout_pass::BoxLayout;
8use rustmotion_core::engine::renderer::paint_from_hex;
9use rustmotion_core::schema::{EasingType, TimelineStep};
10use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
11
12const MAX_PARTICLES: u64 = 6000;
13const SPAWN_SEED_SALT: u64 = 0x9E3779B97F4A7C15;
14const FADE_IN_SPAN: f32 = 0.06;
15const FADE_OUT_SPAN: f32 = 0.18;
16const MIN_VISIBLE_ALPHA: f32 = 0.004;
17
18/// A point in the emitter's own box, in pixels from its top-left corner.
19#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq)]
20pub struct EmitterOrigin {
21    /// Horizontal offset from the box's left edge, in pixels.
22    pub x: f32,
23    /// Vertical offset from the box's top edge, in pixels.
24    pub y: f32,
25}
26
27/// A closed range `[min, max]` a per-particle trait is drawn from. Order
28/// does not matter on input — the smaller value always acts as the
29/// minimum.
30#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq)]
31pub struct EmitterRange(pub f32, pub f32);
32
33impl EmitterRange {
34    fn ordered(self) -> (f32, f32) {
35        if self.0 <= self.1 {
36            (self.0, self.1)
37        } else {
38            (self.1, self.0)
39        }
40    }
41}
42
43/// Where newly spawned particles appear and which way they travel.
44/// `radial` is the only shape today: particles are born on a ring around
45/// `origin` and travel in a straight line outward from it.
46#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
47#[serde(rename_all = "snake_case")]
48pub enum EmitterDirection {
49    #[default]
50    Radial,
51}
52
53/// The mark painted for each particle.
54#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
55#[serde(rename_all = "snake_case")]
56pub enum EmitterShape {
57    /// A short line segment aligned with the direction of travel, from
58    /// `length` px behind the particle's position to its position — a
59    /// streak of light.
60    #[default]
61    Streak,
62    /// A filled circle at the particle's position.
63    Dot,
64}
65
66/// How fast a particle travels away from its birth point over its life.
67#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
68pub struct EmitterSpeed {
69    /// Speed in pixels per second at birth (`progress == 0`).
70    #[serde(default = "default_speed_from")]
71    pub from: f32,
72    /// Speed in pixels per second at death (`progress == 1`).
73    #[serde(default = "default_speed_to")]
74    pub to: f32,
75    /// Shapes how the birth-to-death travel distributes over the
76    /// particle's life. `linear` spends it evenly; `ease_in` holds most
77    /// of it for the end, reading as acceleration.
78    #[serde(default)]
79    pub easing: EasingType,
80}
81
82impl Default for EmitterSpeed {
83    fn default() -> Self {
84        Self {
85            from: default_speed_from(),
86            to: default_speed_to(),
87            easing: EasingType::default(),
88        }
89    }
90}
91
92/// A radial particle field with a real per-particle lifecycle: born,
93/// travelling, dying and immediately respawned, continuously, so the
94/// field at any instant is a mix of ages rather than one cohort.
95///
96/// Every particle's state is derived in closed form from `(seed, index,
97/// time)` — nothing is simulated frame-to-frame — so any instant renders
98/// independently and byte-identically no matter how it is reached
99/// (`render`, `still --time`, or the same frame twice). Supersedes the
100/// deprecated `particle`, whose fixed compositions had no lifecycle at
101/// all.
102#[derive(Debug, Serialize, Deserialize, JsonSchema)]
103pub struct Emitter {
104    /// Point particles are born around, in the emitter's own box, in
105    /// pixels from its top-left corner. Defaults to the box centre.
106    #[serde(default)]
107    pub origin: Option<EmitterOrigin>,
108    /// Average number of particles born per second. Combined with
109    /// `life`, this sets how many particles are alive at any instant
110    /// (concurrency = rate * average lifetime) — there is no separate
111    /// particle count to keep in sync by hand.
112    #[serde(default = "default_rate")]
113    pub rate: f32,
114    /// Lifetime range in seconds, `[min, max]`. Each particle draws its
115    /// own lifetime once, deterministically, from `seed` and its index.
116    #[serde(default = "default_life")]
117    pub life: EmitterRange,
118    #[serde(default)]
119    pub direction: EmitterDirection,
120    #[serde(default)]
121    pub speed: EmitterSpeed,
122    /// Ring, in pixels from `origin`, particles are born on: `[min,
123    /// max]`.
124    #[serde(default = "default_spawn_radius")]
125    pub spawn_radius: EmitterRange,
126    #[serde(default)]
127    pub shape: EmitterShape,
128    /// Streak length range in pixels, `[min, max]`. Unused when `shape`
129    /// is `dot`.
130    #[serde(default = "default_length")]
131    pub length: EmitterRange,
132    /// Particle color as a hex string.
133    #[serde(default = "default_color")]
134    pub color: String,
135    /// Stroke width in pixels for a `streak`, diameter for a `dot`.
136    #[serde(default = "default_particle_width")]
137    pub width: f32,
138    /// Deterministic seed: the same seed and the same instant always
139    /// paint the same pixels.
140    #[serde(default = "default_seed")]
141    pub seed: u64,
142    #[serde(flatten)]
143    pub timing: TimingConfig,
144    #[serde(default)]
145    pub style: CssStyle,
146    #[serde(default)]
147    pub timeline: Vec<TimelineStep>,
148}
149
150fn default_rate() -> f32 {
151    80.0
152}
153
154fn default_life() -> EmitterRange {
155    EmitterRange(0.6, 1.4)
156}
157
158fn default_spawn_radius() -> EmitterRange {
159    EmitterRange(0.0, 24.0)
160}
161
162fn default_length() -> EmitterRange {
163    EmitterRange(24.0, 24.0)
164}
165
166fn default_color() -> String {
167    "#FFFFFF".to_string()
168}
169
170fn default_particle_width() -> f32 {
171    3.0
172}
173
174fn default_seed() -> u64 {
175    1
176}
177
178fn default_speed_from() -> f32 {
179    120.0
180}
181
182fn default_speed_to() -> f32 {
183    480.0
184}
185
186rustmotion_core::impl_traits!(Emitter {
187    Animatable => animation,
188    Timed => timing,
189    Styled => style,
190});
191
192struct EmitterFrame<'a> {
193    emitter: &'a Emitter,
194    origin: EmitterOrigin,
195    life_min: f32,
196    life_max: f32,
197    radius_min: f32,
198    radius_max: f32,
199    length_min: f32,
200    length_max: f32,
201    travel: f32,
202    capacity: u64,
203}
204
205struct ParticleState {
206    head: (f32, f32),
207    tail: (f32, f32),
208    alpha: f32,
209}
210
211fn splitmix64_next(state: &mut u64) -> f64 {
212    *state = state.wrapping_add(SPAWN_SEED_SALT);
213    let mut z = *state;
214    z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
215    z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
216    z ^= z >> 31;
217    (z as f64) / (u64::MAX as f64)
218}
219
220fn particle_rolls(seed: u64, index: u64) -> [f64; 5] {
221    let mut state = seed.wrapping_add(index.wrapping_mul(SPAWN_SEED_SALT));
222    std::array::from_fn(|_| splitmix64_next(&mut state))
223}
224
225fn spawn_fade(progress: f32) -> f32 {
226    let fade_in = (progress / FADE_IN_SPAN).clamp(0.0, 1.0);
227    let fade_out = ((1.0 - progress) / FADE_OUT_SPAN).clamp(0.0, 1.0);
228    fade_in.min(fade_out)
229}
230
231impl Emitter {
232    fn frame(&self, width: f32, height: f32) -> Option<EmitterFrame<'_>> {
233        if width <= 0.0 || height <= 0.0 {
234            return None;
235        }
236        let (life_min, life_max) = self.life.ordered();
237        let life_min = life_min.max(0.01);
238        let life_max = life_max.max(life_min);
239        let life_avg = ((life_min + life_max) * 0.5) as f64;
240        if life_avg <= 0.0 {
241            return None;
242        }
243
244        let capacity = ((self.rate.max(0.0) as f64) * life_avg)
245            .round()
246            .clamp(0.0, MAX_PARTICLES as f64) as u64;
247
248        let (radius_min, radius_max) = self.spawn_radius.ordered();
249        let (length_min, length_max) = self.length.ordered();
250        let travel = ((self.speed.from + self.speed.to) * 0.5).max(0.0);
251        let origin = self.origin.unwrap_or(EmitterOrigin {
252            x: width / 2.0,
253            y: height / 2.0,
254        });
255
256        Some(EmitterFrame {
257            emitter: self,
258            origin,
259            life_min,
260            life_max,
261            radius_min,
262            radius_max,
263            length_min,
264            length_max,
265            travel,
266            capacity,
267        })
268    }
269}
270
271impl EmitterFrame<'_> {
272    fn particle_at(&self, index: u64, time: f64) -> Option<ParticleState> {
273        let rolls = particle_rolls(self.emitter.seed, index);
274
275        let life = self.life_min + rolls[0] as f32 * (self.life_max - self.life_min);
276        let life = life.max(0.01);
277        let phase = rolls[1] as f32 * life;
278        let age = (time.max(0.0) as f32 + phase).rem_euclid(life);
279        let progress = (age / life).clamp(0.0, 1.0);
280
281        let alpha = spawn_fade(progress);
282        if alpha <= MIN_VISIBLE_ALPHA {
283            return None;
284        }
285
286        let angle = rolls[2] as f32 * std::f32::consts::TAU;
287        let birth_radius = self.radius_min + rolls[3] as f32 * (self.radius_max - self.radius_min);
288        let streak_length = self.length_min + rolls[4] as f32 * (self.length_max - self.length_min);
289
290        let eased = ease(progress as f64, &self.emitter.speed.easing) as f32;
291        let distance = birth_radius + eased * self.travel * life;
292
293        let (dx, dy) = (angle.cos(), angle.sin());
294        let head = (self.origin.x + dx * distance, self.origin.y + dy * distance);
295        let tail_distance = (distance - streak_length).max(0.0);
296        let tail = (
297            self.origin.x + dx * tail_distance,
298            self.origin.y + dy * tail_distance,
299        );
300
301        Some(ParticleState { head, tail, alpha })
302    }
303}
304
305impl Painter for Emitter {
306    fn paint_content(
307        &self,
308        canvas: &Canvas,
309        layout: &BoxLayout,
310        _props: &AnimatedProperties,
311        ctx: &PaintCtx,
312    ) {
313        let Some(frame) = self.frame(layout.width, layout.height) else {
314            return;
315        };
316
317        let mut paint = paint_from_hex(&self.color);
318        paint.set_anti_alias(true);
319        paint.set_stroke_cap(skia_safe::PaintCap::Round);
320        paint.set_stroke_width(self.width.max(0.5));
321
322        for index in 0..frame.capacity {
323            let Some(particle) = frame.particle_at(index, ctx.time) else {
324                continue;
325            };
326            paint.set_alpha_f(particle.alpha);
327            match self.shape {
328                EmitterShape::Dot => {
329                    paint.set_style(PaintStyle::Fill);
330                    canvas.draw_circle(particle.head, self.width.max(0.5), &paint);
331                }
332                EmitterShape::Streak => {
333                    paint.set_style(PaintStyle::Stroke);
334                    canvas.draw_line(particle.tail, particle.head, &paint);
335                }
336            }
337        }
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use skia_safe::{surfaces, AlphaType, ColorType, ImageInfo};
345
346    const W: i32 = 400;
347    const H: i32 = 400;
348
349    fn ctx_at(time: f64) -> PaintCtx {
350        PaintCtx {
351            time,
352            scenario_time: time,
353            scene_duration: 3.0,
354            frame_index: 0,
355            fps: 30,
356            video_width: W as u32,
357            video_height: H as u32,
358            stagger_offset: 0.0,
359        }
360    }
361
362    fn layout() -> BoxLayout {
363        BoxLayout {
364            width: W as f32,
365            height: H as f32,
366            ..Default::default()
367        }
368    }
369
370    fn tunnel() -> Emitter {
371        serde_json::from_value(serde_json::json!({
372            "origin": { "x": 200.0, "y": 200.0 },
373            "rate": 180.0,
374            "life": [0.7, 1.2],
375            "direction": "radial",
376            "speed": { "from": 200.0, "to": 1600.0, "easing": "ease_in" },
377            "spawn_radius": [10.0, 40.0],
378            "shape": "streak",
379            "length": [30.0, 90.0],
380            "color": "#EEF4FF",
381            "width": 3.0,
382            "seed": 7
383        }))
384        .expect("valid emitter json")
385    }
386
387    fn render_at(emitter: &Emitter, time: f64) -> Vec<u8> {
388        let info = ImageInfo::new((W, H), ColorType::RGBA8888, AlphaType::Unpremul, None);
389        let mut surface = surfaces::raster(&info, None, None).expect("raster surface");
390        surface.canvas().clear(skia_safe::Color::TRANSPARENT);
391        emitter.paint_content(
392            surface.canvas(),
393            &layout(),
394            &AnimatedProperties::default(),
395            &ctx_at(time),
396        );
397        let row_bytes = W as usize * 4;
398        let mut pixels = vec![0u8; row_bytes * H as usize];
399        surface.read_pixels(&info, &mut pixels, row_bytes, (0, 0));
400        pixels
401    }
402
403    fn painted_pixel_count(buf: &[u8]) -> usize {
404        buf.as_chunks::<4>().0.iter().filter(|px| px[3] > 0).count()
405    }
406
407    #[test]
408    fn a_live_emitter_paints_ink_an_empty_one_does_not() {
409        let live = tunnel();
410        let mut empty = tunnel();
411        empty.rate = 0.0;
412
413        let live_pixels = render_at(&live, 1.0);
414        let empty_pixels = render_at(&empty, 1.0);
415
416        assert!(
417            painted_pixel_count(&live_pixels) > 0,
418            "a live emitter must paint some ink"
419        );
420        assert_eq!(
421            painted_pixel_count(&empty_pixels),
422            0,
423            "rate: 0 must leave the field completely empty, not just dim"
424        );
425        assert_eq!(
426            empty_pixels,
427            vec![0u8; empty_pixels.len()],
428            "an empty emitter is byte-identical to an untouched transparent surface"
429        );
430    }
431
432    #[test]
433    fn the_same_instant_rendered_twice_is_byte_identical() {
434        let emitter = tunnel();
435        let first = render_at(&emitter, 1.234);
436        let second = render_at(&emitter, 1.234);
437        assert_eq!(
438            first, second,
439            "same file, same instant, must produce the same bytes every time"
440        );
441    }
442
443    #[test]
444    fn a_particle_travels_between_two_instants() {
445        let emitter = tunnel();
446        let frame = emitter.frame(W as f32, H as f32).expect("frame builds");
447
448        let index = (0..frame.capacity)
449            .find(|&i| frame.particle_at(i, 0.1).is_some() && frame.particle_at(i, 0.3).is_some())
450            .expect("at least one particle is alive at both instants");
451
452        let early = frame.particle_at(index, 0.1).unwrap();
453        let late = frame.particle_at(index, 0.3).unwrap();
454
455        let dist =
456            |p: (f32, f32), o: (f32, f32)| ((p.0 - o.0).powi(2) + (p.1 - o.1).powi(2)).sqrt();
457        let early_distance = dist(early.head, (frame.origin.x, frame.origin.y));
458        let late_distance = dist(late.head, (frame.origin.x, frame.origin.y));
459
460        assert!(
461            (early_distance - late_distance).abs() > 1.0,
462            "a travelling particle must be at a different distance from the \
463             origin at two different instants (early={early_distance}, \
464             late={late_distance})"
465        );
466    }
467
468    #[test]
469    fn particle_rolls_are_pure_functions_of_seed_and_index() {
470        assert_eq!(particle_rolls(7, 42), particle_rolls(7, 42));
471        assert_ne!(particle_rolls(7, 42), particle_rolls(7, 43));
472        assert_ne!(particle_rolls(7, 42), particle_rolls(8, 42));
473    }
474
475    #[test]
476    fn capacity_follows_rate_times_average_life() {
477        let emitter = tunnel();
478        let frame = emitter.frame(W as f32, H as f32).unwrap();
479        assert_eq!(frame.capacity, (180.0 * 0.95_f64).round() as u64);
480    }
481
482    #[test]
483    fn rate_and_life_deserialize_from_bare_json_arrays() {
484        let emitter: Emitter = serde_json::from_value(serde_json::json!({
485            "life": [0.5, 1.5],
486            "spawn_radius": [1.0, 2.0],
487            "length": [3.0, 4.0]
488        }))
489        .expect("arrays deserialize into EmitterRange");
490        assert_eq!(emitter.life, EmitterRange(0.5, 1.5));
491        assert_eq!(emitter.spawn_radius, EmitterRange(1.0, 2.0));
492        assert_eq!(emitter.length, EmitterRange(3.0, 4.0));
493    }
494}