1#[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
15struct 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 pub fn style(mut self, style: ParticleStyle) -> Self {
226 self.style = Arc::new(style);
227 self
228 }
229}
230
231impl ParticleSimulation {
232 pub fn try_render(&self, context: &SceneContext<'_>) -> Result<Node, ValidationError> {
234 let duration = context.clip_duration();
235 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 pub fn style(mut self, style: ParticleStyle) -> Self {
248 self.style = Arc::new(style);
249 self
250 }
251
252 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;