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#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq)]
20pub struct EmitterOrigin {
21 pub x: f32,
23 pub y: f32,
25}
26
27#[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#[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#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
55#[serde(rename_all = "snake_case")]
56pub enum EmitterShape {
57 #[default]
61 Streak,
62 Dot,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
68pub struct EmitterSpeed {
69 #[serde(default = "default_speed_from")]
71 pub from: f32,
72 #[serde(default = "default_speed_to")]
74 pub to: f32,
75 #[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#[derive(Debug, Serialize, Deserialize, JsonSchema)]
103pub struct Emitter {
104 #[serde(default)]
107 pub origin: Option<EmitterOrigin>,
108 #[serde(default = "default_rate")]
113 pub rate: f32,
114 #[serde(default = "default_life")]
117 pub life: EmitterRange,
118 #[serde(default)]
119 pub direction: EmitterDirection,
120 #[serde(default)]
121 pub speed: EmitterSpeed,
122 #[serde(default = "default_spawn_radius")]
125 pub spawn_radius: EmitterRange,
126 #[serde(default)]
127 pub shape: EmitterShape,
128 #[serde(default = "default_length")]
131 pub length: EmitterRange,
132 #[serde(default = "default_color")]
134 pub color: String,
135 #[serde(default = "default_particle_width")]
137 pub width: f32,
138 #[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}