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::batch::{Instance, build_spatial_index};
6use super::lazy::{Instances, Materialized};
7use super::*;
8use motion_core::{Node, Rect, SceneContext};
9
10#[cfg(all(test, feature = "gpu"))]
11mod analytic_tests;
12#[cfg(test)]
13mod instance_protocol_tests;
14
15/// Seeded trajectories whose positions the GPU evaluates: the CPU keeps only a conservative
16/// center extent and a source that samples states if the CPU reference path asks for them.
17struct Deferred {
18    bounds: Rect,
19    source: Arc<dyn Fn() -> Result<Arc<[Particle]>, ValidationError> + Send + Sync>,
20}
21
22fn render(
23    style: &ParticleStyle,
24    context: &SceneContext<'_>,
25    states: Arc<[Particle]>,
26    analytic: Option<Result<motion_core::instances::AnalyticInstanceUpload, ValidationError>>,
27    deferred: Option<Deferred>,
28    mut sample: impl FnMut(Time) -> Result<Arc<[Particle]>, ValidationError>,
29) -> Result<Node, ValidationError> {
30    match style {
31        ParticleStyle::Configured { style, options } => {
32            if matches!(style.as_ref(), ParticleStyle::Trails { .. })
33                && options.blend.is_none()
34                && options.sheet.is_none()
35                && options.stretch.is_none()
36                && options.parallax == [0.0; 2]
37                && !options.sort_depth
38                && !options.flip
39                && options.trails.segments == 8
40                && options.trails.taper.is_none()
41                && options.trails.fade.is_none()
42                && !options.trails.smooth
43                && !options.trails.ribbon
44            {
45                return render(style, context, states, None, None, sample);
46            }
47            return super::extended::render(style, options, context, states, sample);
48        }
49        ParticleStyle::Shape { .. } | ParticleStyle::Bokeh { .. } => {
50            return super::extended::render(
51                style,
52                &StyleOptions::default(),
53                context,
54                states,
55                sample,
56            );
57        }
58        _ => {}
59    }
60    if let ParticleStyle::Custom(callback) = style {
61        return callback(&states, context);
62    }
63    let extent = match style {
64        ParticleStyle::Dots { radius }
65        | ParticleStyle::GlowDots { radius }
66        | ParticleStyle::Plexus { radius, .. } => *radius,
67        ParticleStyle::Sprites { size, .. } => *size,
68        ParticleStyle::Trails { width, .. } => *width,
69        _ => unreachable!("only dot, trail, plexus and sprite styles reach this branch"),
70    };
71    if !extent.is_finite() || !(0.0..=4096.0).contains(&extent) {
72        return Err(invalid(
73            "style",
74            "Particle style size must be finite in 0..=4096",
75        ));
76    }
77    if let ParticleStyle::Plexus { max_distance, .. } = style
78        && (!max_distance.is_finite() || *max_distance <= 0.0)
79    {
80        return Err(invalid(
81            "distance",
82            "Plexus distance must be finite and positive",
83        ));
84    }
85    if states.is_empty() && deferred.is_none() && !matches!(style, ParticleStyle::Trails { .. }) {
86        return Ok(Node::empty());
87    }
88    let (kind, radius, width, align, sprite) = match style {
89        ParticleStyle::Dots { radius } => (0, *radius, 1.0, false, None),
90        ParticleStyle::GlowDots { radius } => (1, *radius, 1.0, false, None),
91        ParticleStyle::Sprites {
92            image,
93            size,
94            align_to_velocity,
95        } => match image.get() {
96            Ok(image) => (2, *size * 0.5, 1.0, *align_to_velocity, Some(image)),
97            Err(_) => {
98                return Ok(Node::from(
99                    crate::Image::new(image.clone()).size(*size, *size),
100                ));
101            }
102        },
103        ParticleStyle::Trails { width, .. } => (3, 1.0, *width, false, None),
104        ParticleStyle::Plexus { radius, .. } => (4, *radius, 1.0, false, None),
105        _ => unreachable!("only dot, trail, plexus and sprite styles reach this branch"),
106    };
107    if !radius.is_finite() || radius < 0.0 || !width.is_finite() || width < 0.0 {
108        return Err(invalid("style", "Invalid particle style geometry"));
109    }
110    if let (Some(deferred), Some(Ok(upload))) = (deferred, &analytic) {
111        let count = upload.len();
112        let reach = (radius * if kind == 1 { 3.0 } else { 2.0 }).max(width);
113        let centers = deferred.bounds;
114        let source = deferred.source;
115        let instances = Instances::deferred(count, move || {
116            let states = source()?;
117            if states.len() != count {
118                return Err(invalid("analytic", "Analytic initial-state count mismatch"));
119            }
120            let mut instances = Vec::new();
121            instances
122                .try_reserve_exact(count)
123                .map_err(|_| limit("Particle instance allocation failed"))?;
124            for particle in states.iter() {
125                particle.validate()?;
126                instances.push(Instance::new(*particle, particle.position, false));
127            }
128            let instances: Arc<[Instance]> = instances.into();
129            let index = build_spatial_index(kind, radius as f32, width as f32, &instances);
130            Ok(Materialized { instances, index })
131        });
132        let batch = ParticleBatch {
133            instances,
134            kind,
135            radius: radius as f32,
136            width: width as f32,
137            align,
138            sprite,
139            bounds: Rect::new(
140                centers.left - reach,
141                centers.top - reach,
142                centers.right + reach,
143                centers.bottom + reach,
144            ),
145            analytic,
146        };
147        batch.validate()?;
148        return Ok(Node::from(batch));
149    }
150    let mut instances = Vec::new();
151    let mut append = |particle: Particle, previous: [f32; 2], line: bool| {
152        if instances.len() >= MAX_INSTANCE_COUNT {
153            return Err(limit("Particle instance allowance exhausted"));
154        }
155        instances
156            .try_reserve(1)
157            .map_err(|_| limit("Particle instance allocation failed"))?;
158        instances.push(Instance::new(particle, previous, line));
159        Ok(())
160    };
161    if let ParticleStyle::Trails { length, .. } = style {
162        if *length <= Duration::ZERO || length.as_seconds() > 60.0 {
163            return Err(invalid("trail", "Trail duration must be in (0,60] seconds"));
164        }
165        let mut previous = sample(context.time() - *length)?;
166        for segment in 1..=8 {
167            let time =
168                context.time() - *length + Duration::from_ticks(length.ticks() * segment / 8);
169            let current = sample(time)?;
170            let lookup = previous
171                .iter()
172                .map(|particle| (particle.index, particle))
173                .collect::<std::collections::BTreeMap<_, _>>();
174            for particle in current.iter() {
175                if let Some(before) = lookup.get(&particle.index) {
176                    append(*particle, before.position, true)?;
177                }
178            }
179            previous = current;
180        }
181    } else {
182        for particle in states.iter() {
183            append(*particle, particle.position, false)?;
184        }
185    }
186    if let ParticleStyle::Plexus { max_distance, .. } = style {
187        for (first, second) in super::neighbors::pairs(&states, *max_distance, 100_000_000)? {
188            append(states[first], states[second].position, true)?;
189        }
190    }
191    let mut bounds = Rect::default();
192    for (index, instance) in instances.iter().enumerate() {
193        let particle = instance.particle;
194        let reach =
195            (radius * f64::from(particle.size) * if kind == 1 { 3.0 } else { 2.0 }).max(width);
196        let left = f64::from(particle.position[0].min(instance.previous[0])) - reach;
197        let top = f64::from(particle.position[1].min(instance.previous[1])) - reach;
198        let right = f64::from(particle.position[0].max(instance.previous[0])) + reach;
199        let bottom = f64::from(particle.position[1].max(instance.previous[1])) + reach;
200        let extent = Rect::new(left, top, right, bottom);
201        bounds = if index == 0 {
202            extent
203        } else {
204            bounds.union(extent)
205        };
206    }
207    let instances: Arc<[Instance]> = instances.into();
208    let index = build_spatial_index(kind, radius as f32, width as f32, &instances);
209    let batch = ParticleBatch {
210        instances: Instances::ready(instances, index),
211        kind,
212        radius: radius as f32,
213        width: width as f32,
214        align,
215        sprite,
216        bounds,
217        analytic,
218    };
219    batch.validate()?;
220    Ok(Node::from(batch))
221}
222
223impl ParticleSimulationBuilder {
224    /// Sets presentation; it does not affect the physics snapshots.
225    pub fn style(mut self, style: ParticleStyle) -> Self {
226        self.style = Arc::new(style);
227        self
228    }
229}
230
231impl ParticleSimulation {
232    /// Renders through the instance protocol using explicit clip metadata.
233    pub fn try_render(&self, context: &SceneContext<'_>) -> Result<Node, ValidationError> {
234        let duration = context.clip_duration();
235        // Trail history is queried in ascending time, so one cursor steps through it once
236        // instead of replaying from a retained snapshot for every historical sample.
237        let mut cursor = super::simulation::SampleCursor::default();
238        let states = self.sample_with(&mut cursor, context.time(), duration)?;
239        render(&self.style, context, states, None, None, |time| {
240            self.sample_with(&mut cursor, time, duration)
241        })
242    }
243}
244
245impl ParticleTrajectory {
246    /// Replaces presentation without adding simulation state.
247    pub fn style(mut self, style: ParticleStyle) -> Self {
248        self.style = Arc::new(style);
249        self
250    }
251
252    /// Retains CPU reference states and attaches eligible seeded analytic GPU evaluation.
253    pub fn try_render(&self, context: &SceneContext<'_>) -> Result<Node, ValidationError> {
254        let primitive = match self.style.as_ref() {
255            ParticleStyle::Dots { .. } => Some((motion_core::instances::InstanceKind::Disc, false)),
256            ParticleStyle::GlowDots { .. } => {
257                Some((motion_core::instances::InstanceKind::Glow, false))
258            }
259            ParticleStyle::Sprites {
260                align_to_velocity, ..
261            } => Some((
262                motion_core::instances::InstanceKind::Sprite,
263                *align_to_velocity,
264            )),
265            _ => None,
266        };
267        let analytic = match primitive
268            .map(|(kind, align)| {
269                self.model
270                    .analytic_upload(context.time().as_seconds() as f32, kind, align)
271            })
272            .transpose()
273        {
274            Ok(upload) => upload.flatten().map(Ok),
275            Err(error) if error.kind == motion_core::ValidationErrorKind::Unsupported => {
276                Some(Err(error))
277            }
278            Err(error) => return Err(error),
279        };
280        let deferred = match (&analytic, primitive) {
281            (Some(Ok(_)), Some(_)) => self
282                .model
283                .analytic_bounds(context.time().as_seconds() as f32)?
284                .map(|bounds| {
285                    let trajectory = self.clone();
286                    let time = context.time();
287                    Deferred {
288                        bounds,
289                        source: Arc::new(move || trajectory.sample(time)),
290                    }
291                }),
292            _ => None,
293        };
294        let states = if deferred.is_some() {
295            Arc::from([])
296        } else {
297            self.sample(context.time())?
298        };
299        render(&self.style, context, states, analytic, deferred, |time| {
300            self.sample(time)
301        })
302    }
303}
304
305impl motion_core::authoring::Scene for ParticleSimulation {
306    fn render(&self, context: &SceneContext<'_>) -> Node {
307        self.try_render(context)
308            .unwrap_or_else(motion_core::authoring::transition::diagnostic)
309    }
310
311    fn duration(&self) -> Option<Duration> {
312        self.natural_duration()
313    }
314}
315
316impl motion_core::authoring::Scene for ParticleTrajectory {
317    fn render(&self, context: &SceneContext<'_>) -> Node {
318        self.try_render(context)
319            .unwrap_or_else(motion_core::authoring::transition::diagnostic)
320    }
321
322    fn duration(&self) -> Option<Duration> {
323        self.model.duration()
324    }
325}
326
327#[cfg(test)]
328mod tests;