Skip to main content

motion_content/particles/
render.rs

1//! Instanced rendering of particle states and scene integration for simulations and trajectories.
2
3#[cfg(test)]
4use super::batch::SpatialNode;
5use super::*;
6use motion_core::{Node, Rect, SceneContext};
7
8#[cfg(test)]
9mod instance_protocol_tests;
10
11fn render(
12    style: &ParticleStyle,
13    context: &SceneContext<'_>,
14    states: Arc<[Particle]>,
15    mut sample: impl FnMut(Time) -> Result<Arc<[Particle]>, ValidationError>,
16) -> Result<Node, ValidationError> {
17    match style {
18        ParticleStyle::Configured { style, options } => {
19            if matches!(style.as_ref(), ParticleStyle::Trails { .. })
20                && options.blend.is_none()
21                && options.sheet.is_none()
22                && options.stretch.is_none()
23                && options.parallax == [0.0; 2]
24                && !options.sort_depth
25                && !options.flip
26                && options.trails.segments == 8
27                && options.trails.taper.is_none()
28                && options.trails.fade.is_none()
29                && !options.trails.smooth
30                && !options.trails.ribbon
31            {
32                return render(style, context, states, sample);
33            }
34            return super::extended::render(style, options, context, states, sample);
35        }
36        ParticleStyle::Shape { .. } | ParticleStyle::Bokeh { .. } => {
37            return super::extended::render(
38                style,
39                &StyleOptions::default(),
40                context,
41                states,
42                sample,
43            );
44        }
45        _ => {}
46    }
47    if let ParticleStyle::Custom(callback) = style {
48        return Ok(callback(&states, context));
49    }
50    let extent = match style {
51        ParticleStyle::Dots { radius }
52        | ParticleStyle::GlowDots { radius }
53        | ParticleStyle::Plexus { radius, .. } => *radius,
54        ParticleStyle::Sprites { size, .. } => *size,
55        ParticleStyle::Trails { width, .. } => *width,
56        _ => unreachable!(),
57    };
58    if !extent.is_finite() || !(0.0..=4096.0).contains(&extent) {
59        return Err(invalid(
60            "style",
61            "Particle style size must be finite in 0..=4096",
62        ));
63    }
64    if let ParticleStyle::Plexus { max_distance, .. } = style
65        && (!max_distance.is_finite() || *max_distance <= 0.0)
66    {
67        return Err(invalid(
68            "distance",
69            "Plexus distance must be finite and positive",
70        ));
71    }
72    if states.is_empty() && !matches!(style, ParticleStyle::Trails { .. }) {
73        return Ok(Node::empty());
74    }
75    let (kind, radius, width, align, sprite) = match style {
76        ParticleStyle::Dots { radius } => (0, *radius, 1.0, false, None),
77        ParticleStyle::GlowDots { radius } => (1, *radius, 1.0, false, None),
78        ParticleStyle::Sprites {
79            image,
80            size,
81            align_to_velocity,
82        } => match image.get() {
83            Ok(image) => (2, *size * 0.5, 1.0, *align_to_velocity, Some(image)),
84            Err(_) => {
85                return Ok(Node::from(
86                    crate::Image::new(image.clone()).size(*size, *size),
87                ));
88            }
89        },
90        ParticleStyle::Trails { width, .. } => (3, 1.0, *width, false, None),
91        ParticleStyle::Plexus { radius, .. } => (4, *radius, 1.0, false, None),
92        _ => unreachable!(),
93    };
94    if !radius.is_finite() || radius < 0.0 || !width.is_finite() || width < 0.0 {
95        return Err(invalid("style", "Invalid particle style geometry"));
96    }
97    let mut instances = Vec::new();
98    let mut append = |particle: Particle, previous: [f32; 2], line: bool| {
99        if instances.len() >= MAX_INSTANCE_COUNT {
100            return Err(limit("Particle instance allowance exhausted"));
101        }
102        instances
103            .try_reserve(1)
104            .map_err(|_| limit("Particle instance allocation failed"))?;
105        instances.push(Instance::new(particle, previous, line));
106        Ok(())
107    };
108    if let ParticleStyle::Trails { length, .. } = style {
109        if *length <= Time::ZERO || length.as_seconds() > 60.0 {
110            return Err(invalid("trail", "Trail duration must be in (0,60] seconds"));
111        }
112        let mut previous = sample(context.time() - *length)?;
113        for segment in 1..=8 {
114            let time = context.time() - *length + Time::from_ticks(length.ticks() * segment / 8);
115            let current = sample(time)?;
116            let lookup = previous
117                .iter()
118                .map(|particle| (particle.index, particle))
119                .collect::<std::collections::BTreeMap<_, _>>();
120            for particle in current.iter() {
121                if let Some(before) = lookup.get(&particle.index) {
122                    append(*particle, before.position, true)?;
123                }
124            }
125            previous = current;
126        }
127    } else {
128        for particle in states.iter() {
129            append(*particle, particle.position, false)?;
130        }
131    }
132    if let ParticleStyle::Plexus { max_distance, .. } = style {
133        for (first, second) in super::neighbors::pairs(&states, *max_distance, 100_000_000)? {
134            append(states[first], states[second].position, true)?;
135        }
136    }
137    let mut bounds = Rect::default();
138    for (index, instance) in instances.iter().enumerate() {
139        let particle = instance.particle;
140        let reach =
141            (radius * f64::from(particle.size) * if kind == 1 { 3.0 } else { 2.0 }).max(width);
142        let left = f64::from(particle.position[0].min(instance.previous[0])) - reach;
143        let top = f64::from(particle.position[1].min(instance.previous[1])) - reach;
144        let right = f64::from(particle.position[0].max(instance.previous[0])) + reach;
145        let bottom = f64::from(particle.position[1].max(instance.previous[1])) + reach;
146        let extent = Rect::new(left, top, right, bottom);
147        bounds = if index == 0 {
148            extent
149        } else {
150            bounds.union(extent)
151        };
152    }
153    let mut batch = ParticleBatch {
154        instances: instances.into(),
155        kind,
156        radius: radius as f32,
157        width: width as f32,
158        align,
159        sprite,
160        bounds,
161        index: Arc::from([]),
162    };
163    batch.validate()?;
164    batch.index = batch.build_spatial_index();
165    Ok(Node::from(batch))
166}
167
168impl ParticleSimulation {
169    /// Replaces presentation without invalidating the immutable physics snapshots.
170    pub fn style(mut self, style: ParticleStyle) -> Self {
171        self.style = Arc::new(style);
172        self
173    }
174
175    /// Renders through the instance protocol using explicit clip metadata.
176    pub fn try_render(&self, context: &SceneContext<'_>) -> Result<Node, ValidationError> {
177        let duration = context.clip_duration();
178        // Trail history is queried in ascending time, so one cursor steps through it once
179        // instead of replaying from a retained snapshot for every historical sample.
180        let mut cursor = super::simulation::SampleCursor::default();
181        let states = self.sample_with(&mut cursor, context.time(), duration)?;
182        render(&self.style, context, states, |time| {
183            self.sample_with(&mut cursor, time, duration)
184        })
185    }
186}
187
188impl ParticleTrajectory {
189    /// Replaces presentation without adding simulation state.
190    pub fn style(mut self, style: ParticleStyle) -> Self {
191        self.style = Arc::new(style);
192        self
193    }
194
195    /// Evaluates trajectories in Rust before uploading renderable instance states.
196    pub fn try_render(&self, context: &SceneContext<'_>) -> Result<Node, ValidationError> {
197        render(&self.style, context, self.sample(context.time())?, |time| {
198            self.sample(time)
199        })
200    }
201}
202
203impl motion_core::authoring::Scene for ParticleSimulation {
204    fn render(&self, context: &SceneContext<'_>) -> Node {
205        self.try_render(context)
206            .unwrap_or_else(motion_core::authoring::transition::diagnostic)
207    }
208
209    fn duration(&self) -> Option<Duration> {
210        self.natural_duration()
211    }
212}
213
214impl motion_core::authoring::Scene for ParticleTrajectory {
215    fn render(&self, context: &SceneContext<'_>) -> Node {
216        self.try_render(context)
217            .unwrap_or_else(motion_core::authoring::transition::diagnostic)
218    }
219
220    fn duration(&self) -> Option<Duration> {
221        self.model.duration()
222    }
223}
224
225#[cfg(test)]
226mod tests;