feat(emitter): a radial particle field with a closed-form lifecycle - #407
Merged
Merged
Conversation
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.
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.
This was referenced Sep 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First half of #379.
burstremains open — see the note at the bottom.The gap
particleis deprecated and its five frozen presets have no lifecycle. Thehand-rolled alternative —
for-each+rand($seed, $i)+sin($t)— has noneeither: nothing tracks birth, travel and death, so the field either freezes at a
single age or needs an explicit keyframed node per particle. A warp-tunnel study
in the reel produced 460 nodes and hundreds of KB of JSON for three seconds.
The shape
Each particle's age is derived from
(seed, index, time)in closed formrather than simulated:
That is what makes the component seekable:
still --time 1.5, a full render, anda re-render of that same frame all evaluate the same pure function. Two
still --time 1.5runs produce byte-identical PNGs.Concurrency falls out of
rate × average(life)by Little's law, so there isdeliberately no particle-count field to desync from the other knobs. A
6000-particle cap bounds the cost.
speed.{from,to}set the total travel distance (average velocity × life);speed.easingthen decides how that distance distributes across the particle'slife, so
ease_inreads as acceleration outward.Registered as a decorative full-bleed component like
particle, which itsupersedes: 100%/100% default sizing, exempt from the viewport-overflow check.
Docs
rules/emitter-lifecycle.md, indexed fromSKILL.md.CLAUDE.mdalso claimed53 components while the enum carries 61 — the audit test pins README.md and
rustmotion-components/Cargo.tomlto the enum count but never coveredCLAUDE.md, so it had drifted silently. Corrected, and
emitteradded to theMédia section.
Not in this PR
burst, the other half of #379, needs a newAnimationEffect::Burstvariant inrustmotion-core/src/schema/video.rs. That file was owned by theshatterworkstream (#378) while this was written, so #379 stays open for it.
Gate
cargo fmt --all --checkclean ·cargo clippy --workspace --all-targets -D warningsclean ·cargo test --workspace1746 passed.