From 5f935d25236ef7ea22f559a4c1df449c6b5756a4 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Mon, 28 Sep 2026 09:59:44 +0200 Subject: [PATCH 1/2] feat(emitter): radial particle field with a closed-form lifecycle particle is deprecated and the for-each + rand + sin($t) recipe has no lifecycle: a warp-tunnel style effect needs hundreds of hand-keyframed nodes (reel-10's study: 460 nodes, hundreds of KB of JSON for 3s) because nothing tracks birth, travel and death per particle. emitter fixes this by deriving each particle's age from (seed, index, time) in closed form instead of simulating: age(t) = (t + phase[i]) mod life[i]. That keeps it deterministic and seekable (still --time, render, and re-rendering the same frame all hit the same pure function), and lets a handful of scalar fields (rate, life, speed, spawn_radius, length) stand in for what used to be an explicit node per particle. Concurrency falls out of rate * average(life) by Little's law, so there's no separate particle count to desync from the other knobs. speed.{from,to} set the physically-motivated total travel distance (average velocity * life), and speed.easing then decides how that distance distributes over the particle's life -- ease_in reads as acceleration outward. Registered as a new decorative, full-bleed component (like particle, which it supersedes): default 100%/100% sizing, exempt from the viewport-overflow check. burst (issue #379's other half) is blocked: it needs a new AnimationEffect::Burst variant in rustmotion-core/src/schema/video.rs, which another workstream owns right now. Not touched. Also bumps the component count in README.md, the rustmotion-components crate description, and the component-tag corpus test in crates/rustmotion/src/tests.rs -- all three are asserted against the Component enum's variant count by existing tests and would otherwise fail cargo test --workspace for any new component, not just this one. --- README.md | 2 +- crates/rustmotion-components/Cargo.toml | 2 +- .../rustmotion-components/src/box_builder.rs | 10 + crates/rustmotion-components/src/emitter.rs | 494 ++++++++++++++++++ crates/rustmotion-components/src/lib.rs | 13 +- .../skills/rules/emitter-lifecycle.md | 91 ++++ crates/rustmotion/src/tests.rs | 1 + 7 files changed, 610 insertions(+), 3 deletions(-) create mode 100644 crates/rustmotion-components/src/emitter.rs create mode 100644 crates/rustmotion/skills/rules/emitter-lifecycle.md diff --git a/README.md b/README.md index 6755d14..45546d1 100644 --- a/README.md +++ b/README.md @@ -2141,7 +2141,7 @@ Transparency is supported with `--transparent` for PNG sequences, WebM (VP9), an - **JSON Schema:** schemars (auto-generated from Rust types) - **Parallelism:** rayon (multi-threaded frame rendering) -rustmotion ships 60 components, each implementing the `Painter` trait, through a CSS-inspired **box_tree → layout_pass → paint_pass** pipeline: +rustmotion ships 61 components, each implementing the `Painter` trait, through a CSS-inspired **box_tree → layout_pass → paint_pass** pipeline: 1. **box_tree** — builds a tree of `BoxNode { css: CssStyle, children, intrinsic }` from the resolved JSON components 2. **layout_pass** — runs [taffy](https://github.com/DioxusLabs/taffy) to compute each node's `BoxLayout { x, y, width, height }`; leaves that carry an `IntrinsicMeasure` (text, images, codeblocks, ...) are measured through a `measure_fn` diff --git a/crates/rustmotion-components/Cargo.toml b/crates/rustmotion-components/Cargo.toml index 8ae11a6..3ce7fd3 100644 --- a/crates/rustmotion-components/Cargo.toml +++ b/crates/rustmotion-components/Cargo.toml @@ -2,7 +2,7 @@ name = "rustmotion-components" version.workspace = true edition = "2021" -description = "Component library for rustmotion (60 components)" +description = "Component library for rustmotion (61 components)" license = "MIT" repository = "https://github.com/LeadcodeDev/rustmotion" readme = "../../README.md" diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index 01d84a5..3bc225f 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -1410,6 +1410,14 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { css.height = Some(CSize::Length(CLP::String("100%".into()))); } } + Emitter(_) => { + if css.width.is_none() { + css.width = Some(CSize::Length(CLP::String("100%".into()))); + } + if css.height.is_none() { + css.height = Some(CSize::Length(CLP::String("100%".into()))); + } + } Switch(c) => { if css.width.is_none() { css.width = Some(CSize::Length(CLP::Px(c.width))); @@ -1805,6 +1813,7 @@ fn component_style(c: &Component) -> &CssStyle { Countdown(c) => &c.style, Divider(c) => &c.style, DotMap(c) => &c.style, + Emitter(c) => &c.style, Gauge(c) => &c.style, GradientText(c) => &c.style, Heatmap(c) => &c.style, @@ -1864,6 +1873,7 @@ pub fn component_kind(c: &Component) -> &'static str { Countdown(_) => "countdown", Divider(_) => "divider", DotMap(_) => "dot_map", + Emitter(_) => "emitter", Gauge(_) => "gauge", GradientText(_) => "gradient_text", Heatmap(_) => "heatmap", diff --git a/crates/rustmotion-components/src/emitter.rs b/crates/rustmotion-components/src/emitter.rs new file mode 100644 index 0000000..32f6ae8 --- /dev/null +++ b/crates/rustmotion-components/src/emitter.rs @@ -0,0 +1,494 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use skia_safe::{Canvas, PaintStyle}; + +use rustmotion_core::css::CssStyle; +use rustmotion_core::engine::animator::{ease, AnimatedProperties}; +use rustmotion_core::engine::layout_pass::BoxLayout; +use rustmotion_core::engine::renderer::paint_from_hex; +use rustmotion_core::schema::{EasingType, TimelineStep}; +use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; + +const MAX_PARTICLES: u64 = 6000; +const SPAWN_SEED_SALT: u64 = 0x9E3779B97F4A7C15; +const FADE_IN_SPAN: f32 = 0.06; +const FADE_OUT_SPAN: f32 = 0.18; +const MIN_VISIBLE_ALPHA: f32 = 0.004; + +/// A point in the emitter's own box, in pixels from its top-left corner. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq)] +pub struct EmitterOrigin { + /// Horizontal offset from the box's left edge, in pixels. + pub x: f32, + /// Vertical offset from the box's top edge, in pixels. + pub y: f32, +} + +/// A closed range `[min, max]` a per-particle trait is drawn from. Order +/// does not matter on input — the smaller value always acts as the +/// minimum. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq)] +pub struct EmitterRange(pub f32, pub f32); + +impl EmitterRange { + fn ordered(self) -> (f32, f32) { + if self.0 <= self.1 { + (self.0, self.1) + } else { + (self.1, self.0) + } + } +} + +/// Where newly spawned particles appear and which way they travel. +/// `radial` is the only shape today: particles are born on a ring around +/// `origin` and travel in a straight line outward from it. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EmitterDirection { + #[default] + Radial, +} + +/// The mark painted for each particle. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EmitterShape { + /// A short line segment aligned with the direction of travel, from + /// `length` px behind the particle's position to its position — a + /// streak of light. + #[default] + Streak, + /// A filled circle at the particle's position. + Dot, +} + +/// How fast a particle travels away from its birth point over its life. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] +pub struct EmitterSpeed { + /// Speed in pixels per second at birth (`progress == 0`). + #[serde(default = "default_speed_from")] + pub from: f32, + /// Speed in pixels per second at death (`progress == 1`). + #[serde(default = "default_speed_to")] + pub to: f32, + /// Shapes how the birth-to-death travel distributes over the + /// particle's life. `linear` spends it evenly; `ease_in` holds most + /// of it for the end, reading as acceleration. + #[serde(default)] + pub easing: EasingType, +} + +impl Default for EmitterSpeed { + fn default() -> Self { + Self { + from: default_speed_from(), + to: default_speed_to(), + easing: EasingType::default(), + } + } +} + +/// A radial particle field with a real per-particle lifecycle: born, +/// travelling, dying and immediately respawned, continuously, so the +/// field at any instant is a mix of ages rather than one cohort. +/// +/// Every particle's state is derived in closed form from `(seed, index, +/// time)` — nothing is simulated frame-to-frame — so any instant renders +/// independently and byte-identically no matter how it is reached +/// (`render`, `still --time`, or the same frame twice). Supersedes the +/// deprecated `particle`, whose fixed compositions had no lifecycle at +/// all. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct Emitter { + /// Point particles are born around, in the emitter's own box, in + /// pixels from its top-left corner. Defaults to the box centre. + #[serde(default)] + pub origin: Option, + /// Average number of particles born per second. Combined with + /// `life`, this sets how many particles are alive at any instant + /// (concurrency = rate * average lifetime) — there is no separate + /// particle count to keep in sync by hand. + #[serde(default = "default_rate")] + pub rate: f32, + /// Lifetime range in seconds, `[min, max]`. Each particle draws its + /// own lifetime once, deterministically, from `seed` and its index. + #[serde(default = "default_life")] + pub life: EmitterRange, + #[serde(default)] + pub direction: EmitterDirection, + #[serde(default)] + pub speed: EmitterSpeed, + /// Ring, in pixels from `origin`, particles are born on: `[min, + /// max]`. + #[serde(default = "default_spawn_radius")] + pub spawn_radius: EmitterRange, + #[serde(default)] + pub shape: EmitterShape, + /// Streak length range in pixels, `[min, max]`. Unused when `shape` + /// is `dot`. + #[serde(default = "default_length")] + pub length: EmitterRange, + /// Particle color as a hex string. + #[serde(default = "default_color")] + pub color: String, + /// Stroke width in pixels for a `streak`, diameter for a `dot`. + #[serde(default = "default_particle_width")] + pub width: f32, + /// Deterministic seed: the same seed and the same instant always + /// paint the same pixels. + #[serde(default = "default_seed")] + pub seed: u64, + #[serde(flatten)] + pub timing: TimingConfig, + #[serde(default)] + pub style: CssStyle, + #[serde(default)] + pub timeline: Vec, +} + +fn default_rate() -> f32 { + 80.0 +} + +fn default_life() -> EmitterRange { + EmitterRange(0.6, 1.4) +} + +fn default_spawn_radius() -> EmitterRange { + EmitterRange(0.0, 24.0) +} + +fn default_length() -> EmitterRange { + EmitterRange(24.0, 24.0) +} + +fn default_color() -> String { + "#FFFFFF".to_string() +} + +fn default_particle_width() -> f32 { + 3.0 +} + +fn default_seed() -> u64 { + 1 +} + +fn default_speed_from() -> f32 { + 120.0 +} + +fn default_speed_to() -> f32 { + 480.0 +} + +rustmotion_core::impl_traits!(Emitter { + Animatable => animation, + Timed => timing, + Styled => style, +}); + +struct EmitterFrame<'a> { + emitter: &'a Emitter, + origin: EmitterOrigin, + life_min: f32, + life_max: f32, + radius_min: f32, + radius_max: f32, + length_min: f32, + length_max: f32, + travel: f32, + capacity: u64, +} + +struct ParticleState { + head: (f32, f32), + tail: (f32, f32), + alpha: f32, +} + +fn splitmix64_next(state: &mut u64) -> f64 { + *state = state.wrapping_add(SPAWN_SEED_SALT); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB); + z ^= z >> 31; + (z as f64) / (u64::MAX as f64) +} + +fn particle_rolls(seed: u64, index: u64) -> [f64; 5] { + let mut state = seed.wrapping_add(index.wrapping_mul(SPAWN_SEED_SALT)); + std::array::from_fn(|_| splitmix64_next(&mut state)) +} + +fn spawn_fade(progress: f32) -> f32 { + let fade_in = (progress / FADE_IN_SPAN).clamp(0.0, 1.0); + let fade_out = ((1.0 - progress) / FADE_OUT_SPAN).clamp(0.0, 1.0); + fade_in.min(fade_out) +} + +impl Emitter { + fn frame(&self, width: f32, height: f32) -> Option> { + if width <= 0.0 || height <= 0.0 { + return None; + } + let (life_min, life_max) = self.life.ordered(); + let life_min = life_min.max(0.01); + let life_max = life_max.max(life_min); + let life_avg = ((life_min + life_max) * 0.5) as f64; + if life_avg <= 0.0 { + return None; + } + + let capacity = ((self.rate.max(0.0) as f64) * life_avg) + .round() + .clamp(0.0, MAX_PARTICLES as f64) as u64; + + let (radius_min, radius_max) = self.spawn_radius.ordered(); + let (length_min, length_max) = self.length.ordered(); + let travel = ((self.speed.from + self.speed.to) * 0.5).max(0.0); + let origin = self.origin.unwrap_or(EmitterOrigin { + x: width / 2.0, + y: height / 2.0, + }); + + Some(EmitterFrame { + emitter: self, + origin, + life_min, + life_max, + radius_min, + radius_max, + length_min, + length_max, + travel, + capacity, + }) + } +} + +impl EmitterFrame<'_> { + fn particle_at(&self, index: u64, time: f64) -> Option { + let rolls = particle_rolls(self.emitter.seed, index); + + let life = self.life_min + rolls[0] as f32 * (self.life_max - self.life_min); + let life = life.max(0.01); + let phase = rolls[1] as f32 * life; + let age = (time.max(0.0) as f32 + phase).rem_euclid(life); + let progress = (age / life).clamp(0.0, 1.0); + + let alpha = spawn_fade(progress); + if alpha <= MIN_VISIBLE_ALPHA { + return None; + } + + let angle = rolls[2] as f32 * std::f32::consts::TAU; + let birth_radius = self.radius_min + rolls[3] as f32 * (self.radius_max - self.radius_min); + let streak_length = self.length_min + rolls[4] as f32 * (self.length_max - self.length_min); + + let eased = ease(progress as f64, &self.emitter.speed.easing) as f32; + let distance = birth_radius + eased * self.travel * life; + + let (dx, dy) = (angle.cos(), angle.sin()); + let head = (self.origin.x + dx * distance, self.origin.y + dy * distance); + let tail_distance = (distance - streak_length).max(0.0); + let tail = ( + self.origin.x + dx * tail_distance, + self.origin.y + dy * tail_distance, + ); + + Some(ParticleState { head, tail, alpha }) + } +} + +impl Painter for Emitter { + fn paint_content( + &self, + canvas: &Canvas, + layout: &BoxLayout, + _props: &AnimatedProperties, + ctx: &PaintCtx, + ) { + let Some(frame) = self.frame(layout.width, layout.height) else { + return; + }; + + let mut paint = paint_from_hex(&self.color); + paint.set_anti_alias(true); + paint.set_stroke_cap(skia_safe::PaintCap::Round); + paint.set_stroke_width(self.width.max(0.5)); + + for index in 0..frame.capacity { + let Some(particle) = frame.particle_at(index, ctx.time) else { + continue; + }; + paint.set_alpha_f(particle.alpha); + match self.shape { + EmitterShape::Dot => { + paint.set_style(PaintStyle::Fill); + canvas.draw_circle(particle.head, self.width.max(0.5), &paint); + } + EmitterShape::Streak => { + paint.set_style(PaintStyle::Stroke); + canvas.draw_line(particle.tail, particle.head, &paint); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use skia_safe::{surfaces, AlphaType, ColorType, ImageInfo}; + + const W: i32 = 400; + const H: i32 = 400; + + fn ctx_at(time: f64) -> PaintCtx { + PaintCtx { + time, + scenario_time: time, + scene_duration: 3.0, + frame_index: 0, + fps: 30, + video_width: W as u32, + video_height: H as u32, + stagger_offset: 0.0, + } + } + + fn layout() -> BoxLayout { + BoxLayout { + width: W as f32, + height: H as f32, + ..Default::default() + } + } + + fn tunnel() -> Emitter { + serde_json::from_value(serde_json::json!({ + "origin": { "x": 200.0, "y": 200.0 }, + "rate": 180.0, + "life": [0.7, 1.2], + "direction": "radial", + "speed": { "from": 200.0, "to": 1600.0, "easing": "ease_in" }, + "spawn_radius": [10.0, 40.0], + "shape": "streak", + "length": [30.0, 90.0], + "color": "#EEF4FF", + "width": 3.0, + "seed": 7 + })) + .expect("valid emitter json") + } + + fn render_at(emitter: &Emitter, time: f64) -> Vec { + let info = ImageInfo::new((W, H), ColorType::RGBA8888, AlphaType::Unpremul, None); + let mut surface = surfaces::raster(&info, None, None).expect("raster surface"); + surface.canvas().clear(skia_safe::Color::TRANSPARENT); + emitter.paint_content( + surface.canvas(), + &layout(), + &AnimatedProperties::default(), + &ctx_at(time), + ); + let row_bytes = W as usize * 4; + let mut pixels = vec![0u8; row_bytes * H as usize]; + surface.read_pixels(&info, &mut pixels, row_bytes, (0, 0)); + pixels + } + + fn painted_pixel_count(buf: &[u8]) -> usize { + buf.as_chunks::<4>().0.iter().filter(|px| px[3] > 0).count() + } + + #[test] + fn a_live_emitter_paints_ink_an_empty_one_does_not() { + let live = tunnel(); + let mut empty = tunnel(); + empty.rate = 0.0; + + let live_pixels = render_at(&live, 1.0); + let empty_pixels = render_at(&empty, 1.0); + + assert!( + painted_pixel_count(&live_pixels) > 0, + "a live emitter must paint some ink" + ); + assert_eq!( + painted_pixel_count(&empty_pixels), + 0, + "rate: 0 must leave the field completely empty, not just dim" + ); + assert_eq!( + empty_pixels, + vec![0u8; empty_pixels.len()], + "an empty emitter is byte-identical to an untouched transparent surface" + ); + } + + #[test] + fn the_same_instant_rendered_twice_is_byte_identical() { + let emitter = tunnel(); + let first = render_at(&emitter, 1.234); + let second = render_at(&emitter, 1.234); + assert_eq!( + first, second, + "same file, same instant, must produce the same bytes every time" + ); + } + + #[test] + fn a_particle_travels_between_two_instants() { + let emitter = tunnel(); + let frame = emitter.frame(W as f32, H as f32).expect("frame builds"); + + let index = (0..frame.capacity) + .find(|&i| frame.particle_at(i, 0.1).is_some() && frame.particle_at(i, 0.3).is_some()) + .expect("at least one particle is alive at both instants"); + + let early = frame.particle_at(index, 0.1).unwrap(); + let late = frame.particle_at(index, 0.3).unwrap(); + + let dist = + |p: (f32, f32), o: (f32, f32)| ((p.0 - o.0).powi(2) + (p.1 - o.1).powi(2)).sqrt(); + let early_distance = dist(early.head, (frame.origin.x, frame.origin.y)); + let late_distance = dist(late.head, (frame.origin.x, frame.origin.y)); + + assert!( + (early_distance - late_distance).abs() > 1.0, + "a travelling particle must be at a different distance from the \ + origin at two different instants (early={early_distance}, \ + late={late_distance})" + ); + } + + #[test] + fn particle_rolls_are_pure_functions_of_seed_and_index() { + assert_eq!(particle_rolls(7, 42), particle_rolls(7, 42)); + assert_ne!(particle_rolls(7, 42), particle_rolls(7, 43)); + assert_ne!(particle_rolls(7, 42), particle_rolls(8, 42)); + } + + #[test] + fn capacity_follows_rate_times_average_life() { + let emitter = tunnel(); + let frame = emitter.frame(W as f32, H as f32).unwrap(); + assert_eq!(frame.capacity, (180.0 * 0.95_f64).round() as u64); + } + + #[test] + fn rate_and_life_deserialize_from_bare_json_arrays() { + let emitter: Emitter = serde_json::from_value(serde_json::json!({ + "life": [0.5, 1.5], + "spawn_radius": [1.0, 2.0], + "length": [3.0, 4.0] + })) + .expect("arrays deserialize into EmitterRange"); + assert_eq!(emitter.life, EmitterRange(0.5, 1.5)); + assert_eq!(emitter.spawn_radius, EmitterRange(1.0, 2.0)); + assert_eq!(emitter.length, EmitterRange(3.0, 4.0)); + } +} diff --git a/crates/rustmotion-components/src/lib.rs b/crates/rustmotion-components/src/lib.rs index 6f9fc03..6685fe0 100644 --- a/crates/rustmotion-components/src/lib.rs +++ b/crates/rustmotion-components/src/lib.rs @@ -19,6 +19,7 @@ pub mod counter; pub mod cursor; pub mod divider; pub mod dot_map; +pub mod emitter; pub mod gauge; pub mod gif; pub mod gradient_text; @@ -80,6 +81,7 @@ pub use counter::Counter; pub use cursor::Cursor; pub use divider::Divider; pub use dot_map::DotMap; +pub use emitter::Emitter; pub use gauge::Gauge; pub use gif::Gif; pub use gradient_text::GradientText; @@ -295,7 +297,10 @@ impl ChildComponent { } pub fn is_decorative(&self) -> bool { - matches!(self.component, Component::Particle(_)) + matches!( + self.component, + Component::Particle(_) | Component::Emitter(_) + ) } pub fn absolute_position(&self) -> Option<(f32, f32)> { @@ -334,6 +339,7 @@ pub enum Component { Countdown(Countdown), Divider(Divider), DotMap(DotMap), + Emitter(Emitter), Gauge(Gauge), GradientText(GradientText), Heatmap(Heatmap), @@ -402,6 +408,7 @@ impl Component { Component::Countdown(c) => Some(c), Component::Divider(c) => Some(c), Component::DotMap(c) => Some(c), + Component::Emitter(c) => Some(c), Component::Gauge(c) => Some(c), Component::GradientText(c) => Some(c), Component::Heatmap(c) => Some(c), @@ -459,6 +466,7 @@ impl Component { Component::Countdown(c) => Some(c), Component::Divider(c) => Some(c), Component::DotMap(c) => Some(c), + Component::Emitter(c) => Some(c), Component::Gauge(c) => Some(c), Component::GradientText(c) => Some(c), Component::Heatmap(c) => Some(c), @@ -518,6 +526,7 @@ impl Component { Component::Countdown(c) => c, Component::Divider(c) => c, Component::DotMap(c) => c, + Component::Emitter(c) => c, Component::Gauge(c) => c, Component::GradientText(c) => c, Component::Heatmap(c) => c, @@ -557,6 +566,7 @@ impl Component { Component::Waveform(c) => Some(c), Component::Container(c) => Some(c), Component::Divider(c) => Some(c), + Component::Emitter(c) => Some(c), Component::Shape(c) => Some(c), Component::Image(c) => Some(c), Component::Icon(c) => Some(c), @@ -642,6 +652,7 @@ impl Component { | Component::Comparison(_) | Component::Countdown(_) | Component::DotMap(_) + | Component::Emitter(_) | Component::Gauge(_) | Component::Heatmap(_) | Component::Line(_) diff --git a/crates/rustmotion/skills/rules/emitter-lifecycle.md b/crates/rustmotion/skills/rules/emitter-lifecycle.md new file mode 100644 index 0000000..713ba34 --- /dev/null +++ b/crates/rustmotion/skills/rules/emitter-lifecycle.md @@ -0,0 +1,91 @@ +# Rule: Radial Particle Emitter (`emitter`) + +For a warp-tunnel, a starfield, an ambient stream of light or embers — any +field of many small moving marks that should look alive and continuous, not +a fixed cohort that pops in together — use `emitter`. It supersedes the +deprecated `particle` and the hand-rolled `for-each` + `rand($seed, $i)` + +`sin($t)` recipe: neither has a real lifecycle, so both either freeze at one +age for the whole clip or require hundreds of individually keyframed nodes +to fake motion (a real study needed 460 nodes and hundreds of kilobytes of +JSON for three seconds of tunnel). + +```json +{ + "type": "emitter", + "origin": { "x": 960, "y": 540 }, + "rate": 180, + "life": [0.7, 1.2], + "direction": "radial", + "speed": { "from": 200, "to": 1600, "easing": "ease_in" }, + "spawn_radius": [260, 470], + "shape": "streak", + "length": [60, 240], + "color": "#EEF4FF", + "width": 3, + "seed": 7 +} +``` + +| Field | Role | +|---|---| +| `origin` | `{x, y}` in the emitter's own box, in pixels. Defaults to the box centre. | +| `rate` | Average particles born per second. | +| `life` | `[min, max]` lifetime in seconds. Each particle draws its own value once, from `seed` and its index. | +| `direction` | Only `radial` today: born on a ring, travel straight outward. | +| `speed` | `{from, to, easing}` — pixels/second at birth and at death, and how the travel between them distributes over the particle's life. | +| `spawn_radius` | `[min, max]` ring, in pixels from `origin`, particles are born on. A non-zero minimum is what carves the dark "eye" out of the middle of a tunnel. | +| `shape` | `streak` (a short line aligned with the travel direction) or `dot`. | +| `length` | `[min, max]` streak length in pixels. Unused for `dot`. | +| `color` | Hex string. | +| `width` | Stroke width (`streak`) or diameter (`dot`) in pixels. | +| `seed` | Deterministic seed. Same seed, same instant, same pixels — always. | + +## There is no particle count to set + +`rate` and `life` are enough. How many particles are alive at any instant +follows from them: concurrency = `rate * average(life)`. A `rate: 180` with +`life: [0.7, 1.2]` (average 0.95 s) keeps roughly 171 particles alive at +once, continuously — there is no separate count field to keep in sync by +hand, and nothing to desync if you tune one without the other. + +## The lifecycle is closed-form, not simulated + +Every particle's age is derived directly from `(seed, index, time)`: + +``` +phase[i] = a per-particle random offset into its own life, drawn once from seed and i +age(t) = (t + phase[i]) mod life[i] +progress = age / life[i] // 0 at birth, →1 at death +``` + +Nothing is stepped frame-to-frame and no history is kept. That's what makes +`still --time 1.7` on frame 51 of a 30fps render produce the exact same +pixels as decoding frame 51 out of a full `render` — both call the same pure +function of `time`. It also means the field never looks synchronized: two +particles never share a phase unless their random draws collide, so the +tunnel reads as a continuous flow of mixed ages from the very first frame, +not a cohort that was all born at `t=0`. + +`speed.from`/`speed.to` describe the average velocity across a particle's +whole life; `speed.easing` then decides how that total travel distributes +across `progress` — `ease_in` spends most of the distance near the end, +which reads as acceleration outward. Each particle briefly fades in at +birth and fades out just before death, so appearance/disappearance is never +a hard pop. + +## It's a decorative, full-bleed component + +Like `particle`, `emitter` defaults to `100%` width/height and is treated as +**decorative**: it paints as a fullscreen layer behind the rest of the +scene's flex flow, and it is exempt from the viewport-overflow check (a +tunnel is expected to bleed past the frame edge by design). Give it an +explicit `style.width`/`style.height` if you want a contained effect inside +a card instead of the whole frame. + +## `particle` is not extended for this + +`particle`'s five presets (`confetti`, `snow`, `stars`, `bubbles`, `halo`) +are still there, deprecated, for compatibility — the fixed compositions +each preset draws have no life/death/respawn semantics and adding one to +that struct would just be `emitter` again with extra indirection. New +full-bleed particle work should reach for `emitter` directly. diff --git a/crates/rustmotion/src/tests.rs b/crates/rustmotion/src/tests.rs index 9ce7039..fa6509d 100644 --- a/crates/rustmotion/src/tests.rs +++ b/crates/rustmotion/src/tests.rs @@ -24,6 +24,7 @@ mod component_smoke { ), ("countdown", r#"{"type":"countdown","seconds":60}"#), ("divider", r#"{"type":"divider"}"#), + ("emitter", r#"{"type":"emitter"}"#), ("gauge", r#"{"type":"gauge","value":50}"#), ( "gradient_text", From 62a1c429eca71af3175c9d2ea911c49f0b89c1ad Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Mon, 28 Sep 2026 10:07:37 +0200 Subject: [PATCH 2/2] docs(emitter): index the lifecycle rule and correct the component count The rule file arrived in English. The corpus is roughly half and half, but the eight most recently written rules are all French, so the sample that suggested English was unrepresentative -- rewritten in French, content unchanged. CLAUDE.md still claimed 53 components while the enum carries 61; the audit test that pins README.md and rustmotion-components/Cargo.toml to the enum count never covered CLAUDE.md, so it drifted silently. --- crates/rustmotion/CLAUDE.md | 9 +- crates/rustmotion/skills/SKILL.md | 1 + .../skills/rules/emitter-lifecycle.md | 144 ++++++++++-------- 3 files changed, 85 insertions(+), 69 deletions(-) diff --git a/crates/rustmotion/CLAUDE.md b/crates/rustmotion/CLAUDE.md index e8b826b..0a270f1 100644 --- a/crates/rustmotion/CLAUDE.md +++ b/crates/rustmotion/CLAUDE.md @@ -69,7 +69,7 @@ La vue **`world`** est le seul mécanisme qui produit une continuité réelle en > `grid_dots` marks the intersections and reads as a texture; `grid_lines` is a grid of ruled **lines** and reads as a structure — the one to put behind a chart or a code panel. Config: `cell`, `weight`, `color`, plus `major_every`/`major_weight` for the graph-paper effect. -## Composants disponibles (53) +## Composants disponibles (61) ### Basiques `text`, `shape`, `image`, `icon`, `svg`, `video`, `gif`, `caption`, `rich_text`, `gradient_text` @@ -117,7 +117,12 @@ La vue **`world`** est le seul mécanisme qui produit une continuité réelle en > Pour faire suivre une trajectoire à un composant, utilise l'effet d'animation `motion_path` (données de chemin SVG, orientation optionnelle selon la tangente) plutôt que d'empiler des `translate`. Voir [rules/motion-path.md](.claude/skills/rustmotion/rules/motion-path.md). ### Média -`mockup`, `lottie`, `cursor`, `particle`, `qr_code` +`mockup`, `lottie`, `cursor`, `emitter`, `particle`, `qr_code` + +> `emitter` — champ de particules radial avec naissance, trajet, mort et +> renaissance, en forme close (donc cherchable par `still`). Remplace `particle`, +> déprécié, dont les cinq presets figés n'ont aucun cycle de vie. Voir +> [rules/emitter-lifecycle.md](.claude/skills/rustmotion/rules/emitter-lifecycle.md). ### Audio - `waveform` — visualisation d'onde audio réactive au volume de la piste diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index bde0f23..451ddd5 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -238,6 +238,7 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/chromatic-aberration.md](rules/chromatic-aberration.md) - Per-element red/cyan fringe on arrival: `chromatic_aberration`'s `amount`, how its curve differs from `chromatic_wipe`'s, and the `amount`-not-`amplitude` trap - [rules/inflated-material.md](rules/inflated-material.md) - `material: "inflated"`: shading derived from the clipped silhouette, so each branch of a star gets its own relief — and why `bevel` must stay small relative to the shape - [rules/material-and-light.md](rules/material-and-light.md) - Lit surfaces: `style.material`'s three presets, the scene-wide `light` that makes them agree, and why the material follows the box and not a `shape`'s own geometry +- [rules/emitter-lifecycle.md](rules/emitter-lifecycle.md) - `emitter`: a particle field whose lifecycle is closed-form, so `still --time` and a full render agree — and why there is no particle-count field - [rules/layout-surface.md](rules/layout-surface.md) - Project a flat grid onto a cylinder or sphere with one shared vanishing point — and why children still paint in declaration order - [rules/dot-map-orthographic.md](rules/dot-map-orthographic.md) - `dot_map` as a globe: orthographic projection, far-hemisphere culling, great-circle arcs and limb shading - [rules/camera-3d.md](rules/camera-3d.md) - Tilt a whole shot with `camera.rotate_x`/`rotate_y`/`perspective`: one shared vanishing point, and rotation scaled by each plane's `style.depth` diff --git a/crates/rustmotion/skills/rules/emitter-lifecycle.md b/crates/rustmotion/skills/rules/emitter-lifecycle.md index 713ba34..d34c11d 100644 --- a/crates/rustmotion/skills/rules/emitter-lifecycle.md +++ b/crates/rustmotion/skills/rules/emitter-lifecycle.md @@ -1,13 +1,14 @@ -# Rule: Radial Particle Emitter (`emitter`) +# `emitter` : un champ de particules avec un cycle de vie -For a warp-tunnel, a starfield, an ambient stream of light or embers — any -field of many small moving marks that should look alive and continuous, not -a fixed cohort that pops in together — use `emitter`. It supersedes the -deprecated `particle` and the hand-rolled `for-each` + `rand($seed, $i)` + -`sin($t)` recipe: neither has a real lifecycle, so both either freeze at one -age for the whole clip or require hundreds of individually keyframed nodes -to fake motion (a real study needed 460 nodes and hundreds of kilobytes of -JSON for three seconds of tunnel). +Pour un tunnel de warp, un champ d'étoiles, un flux de lumière ou de braises — +n'importe quel champ de petites marques en mouvement qui doit paraître **vivant +et continu**, pas une cohorte figée qui apparaît d'un bloc. + +`emitter` remplace le `particle` déprécié et la recette artisanale +`for-each` + `rand($seed, $i)` + `sin($t)` : aucune des deux n'a de cycle de vie, +donc elles gèlent à un seul âge pour tout le plan, ou demandent des centaines de +nœuds keyframés à la main. Une étude réelle a généré **460 nœuds** et des +centaines de kilo-octets de JSON pour trois secondes de tunnel. ```json { @@ -26,66 +27,75 @@ JSON for three seconds of tunnel). } ``` -| Field | Role | +| Champ | Rôle | |---|---| -| `origin` | `{x, y}` in the emitter's own box, in pixels. Defaults to the box centre. | -| `rate` | Average particles born per second. | -| `life` | `[min, max]` lifetime in seconds. Each particle draws its own value once, from `seed` and its index. | -| `direction` | Only `radial` today: born on a ring, travel straight outward. | -| `speed` | `{from, to, easing}` — pixels/second at birth and at death, and how the travel between them distributes over the particle's life. | -| `spawn_radius` | `[min, max]` ring, in pixels from `origin`, particles are born on. A non-zero minimum is what carves the dark "eye" out of the middle of a tunnel. | -| `shape` | `streak` (a short line aligned with the travel direction) or `dot`. | -| `length` | `[min, max]` streak length in pixels. Unused for `dot`. | -| `color` | Hex string. | -| `width` | Stroke width (`streak`) or diameter (`dot`) in pixels. | -| `seed` | Deterministic seed. Same seed, same instant, same pixels — always. | - -## There is no particle count to set - -`rate` and `life` are enough. How many particles are alive at any instant -follows from them: concurrency = `rate * average(life)`. A `rate: 180` with -`life: [0.7, 1.2]` (average 0.95 s) keeps roughly 171 particles alive at -once, continuously — there is no separate count field to keep in sync by -hand, and nothing to desync if you tune one without the other. - -## The lifecycle is closed-form, not simulated - -Every particle's age is derived directly from `(seed, index, time)`: +| `origin` | `{x, y}` dans la boîte de l'émetteur, en pixels. Défaut : son centre. | +| `rate` | Particules nées par seconde, en moyenne. | +| `life` | `[min, max]` en secondes. Chaque particule tire la sienne une fois, depuis `seed` et son index. | +| `direction` | `radial` uniquement pour l'instant : naissance sur un anneau, trajet droit vers l'extérieur. | +| `speed` | `{from, to, easing}` — pixels/seconde à la naissance et à la mort, et comment le trajet se répartit sur la vie. | +| `spawn_radius` | `[min, max]` de l'anneau de naissance, en pixels depuis `origin`. **Un minimum non nul est ce qui creuse l'œil sombre** au centre d'un tunnel. | +| `shape` | `streak` (un trait aligné sur la direction) ou `dot`. | +| `length` | `[min, max]` de la longueur du trait. Ignoré pour `dot`. | +| `color` | Chaîne hexadécimale. | +| `width` | Épaisseur du trait, ou diamètre du point. | +| `seed` | Graine. Même graine, même instant, mêmes pixels — toujours. | + +## Il n'y a pas de nombre de particules à régler + +`rate` et `life` suffisent. Combien de particules vivent à un instant donné en +découle : `concurrence = rate × moyenne(life)`. Un `rate: 180` avec +`life: [0.7, 1.2]` (moyenne 0,95 s) garde environ **171** particules vivantes en +continu. + +C'est délibéré : un champ `count` séparé serait une troisième valeur à tenir +d'accord avec les deux autres, et la première chose à désynchroniser en réglant +l'une sans l'autre. + +## Le cycle de vie est en forme close, pas simulé + +L'âge de chaque particule se déduit directement de `(seed, index, time)` : ``` -phase[i] = a per-particle random offset into its own life, drawn once from seed and i -age(t) = (t + phase[i]) mod life[i] -progress = age / life[i] // 0 at birth, →1 at death +phase[i] = décalage aléatoire dans sa propre vie, tiré une fois depuis seed et i +age(t) = (t + phase[i]) mod life[i] +progress = age / life[i] // 0 à la naissance, →1 à la mort ``` -Nothing is stepped frame-to-frame and no history is kept. That's what makes -`still --time 1.7` on frame 51 of a 30fps render produce the exact same -pixels as decoding frame 51 out of a full `render` — both call the same pure -function of `time`. It also means the field never looks synchronized: two -particles never share a phase unless their random draws collide, so the -tunnel reads as a continuous flow of mixed ages from the very first frame, -not a cohort that was all born at `t=0`. - -`speed.from`/`speed.to` describe the average velocity across a particle's -whole life; `speed.easing` then decides how that total travel distributes -across `progress` — `ease_in` spends most of the distance near the end, -which reads as acceleration outward. Each particle briefly fades in at -birth and fades out just before death, so appearance/disappearance is never -a hard pop. - -## It's a decorative, full-bleed component - -Like `particle`, `emitter` defaults to `100%` width/height and is treated as -**decorative**: it paints as a fullscreen layer behind the rest of the -scene's flex flow, and it is exempt from the viewport-overflow check (a -tunnel is expected to bleed past the frame edge by design). Give it an -explicit `style.width`/`style.height` if you want a contained effect inside -a card instead of the whole frame. - -## `particle` is not extended for this - -`particle`'s five presets (`confetti`, `snow`, `stars`, `bubbles`, `halo`) -are still there, deprecated, for compatibility — the fixed compositions -each preset draws have no life/death/respawn semantics and adding one to -that struct would just be `emitter` again with extra indirection. New -full-bleed particle work should reach for `emitter` directly. +Rien n'avance de frame en frame, aucun historique n'est gardé. **C'est ce qui rend +l'émetteur cherchable** : `still --time 1.7` produit exactement les pixels de la +frame 51 d'un `render` complet, parce que les deux appellent la même fonction pure +du temps. Une simulation, elle, dépendrait de tout ce qui précède — et `still` n'a +rien qui précède. + +C'est aussi ce qui empêche le champ de paraître synchronisé : deux particules ne +partagent une phase que si leurs tirages se rencontrent, donc le tunnel lit comme +un flux d'âges mélangés **dès la première frame**, pas comme une cohorte née à +`t=0`. + +`speed.from`/`speed.to` décrivent la vitesse moyenne sur toute la vie ; +`speed.easing` décide ensuite comment cette distance totale se répartit sur +`progress` — `ease_in` en dépense l'essentiel vers la fin, ce qui lit comme une +accélération vers l'extérieur. Chaque particule s'estompe brièvement à la +naissance et avant la mort, pour qu'aucune apparition ne claque. + +## C'est un composant décoratif, plein cadre + +Comme `particle`, il vaut `100%` en largeur et hauteur par défaut et il est +**décoratif** : il peint en couche plein écran derrière le flux flex de la scène, +et il est exempté du contrôle de débordement viewport — un tunnel est censé +déborder. Donne-lui un `style.width`/`style.height` explicite pour le contenir +dans une carte. + +## Un plafond, et pourquoi + +Le nombre de particules est plafonné à 6000. `rate` est piloté par l'auteur et +multiplie directement les appels de dessin par frame ; sans plafond, une valeur +aberrante n'échoue pas, elle fait ramer le rendu sans rien dire. + +## `particle` n'est pas étendu pour ça + +Ses cinq presets (`confetti`, `snow`, `stars`, `bubbles`, `halo`) restent là, +dépréciés, pour la compatibilité. Les compositions figées qu'ils dessinent n'ont +aucune sémantique de naissance, mort et renaissance ; en ajouter une reviendrait +à réécrire `emitter` avec une indirection de plus.