1use super::lazy::{Instances, Materialized};
4use super::*;
5use motion_core::{Drawable, Point, Rect, Transform};
6
7#[derive(Clone, Debug, PartialEq)]
8pub(super) struct Instance {
9 pub(super) particle: Particle,
10 pub(super) previous: [f32; 2],
11 pub(super) line: bool,
12 pub(super) line_bounds: [f32; 4],
13 pub(super) line_guard: f64,
14}
15
16impl Instance {
17 pub(super) fn new(particle: Particle, previous: [f32; 2], line: bool) -> Self {
18 let line_bounds = if line {
19 [
20 previous[0].min(particle.position[0]),
21 previous[1].min(particle.position[1]),
22 previous[0].max(particle.position[0]),
23 previous[1].max(particle.position[1]),
24 ]
25 } else {
26 [0.0; 4]
27 };
28 let line_guard = if line {
29 previous
30 .map(f64::from)
31 .into_iter()
32 .chain(particle.position.map(f64::from))
33 .fold(1.0_f64, |scale, coordinate| scale.max(coordinate.abs()))
34 * (128.0 * f64::EPSILON)
35 } else {
36 0.0
37 };
38 Self {
39 particle,
40 previous,
41 line,
42 line_bounds,
43 line_guard,
44 }
45 }
46}
47
48#[derive(Clone, Debug)]
49pub(super) struct SpatialNode {
50 pub(super) bounds: Rect,
51 pub(super) guard: f64,
52 pub(super) end: usize,
53 pub(super) next: usize,
54 pub(super) leaf: bool,
55}
56
57impl SpatialNode {
58 pub(super) fn outside(&self, point: Point, margin: f64, point_guard: f64) -> bool {
59 let padding = margin + self.guard + point_guard;
60 point.x < self.bounds.left - padding
61 || point.x > self.bounds.right + padding
62 || point.y < self.bounds.top - padding
63 || point.y > self.bounds.bottom + padding
64 }
65}
66
67#[derive(Clone, Debug)]
69pub struct ParticleBatch {
70 pub(super) instances: Instances,
71 pub(super) kind: u32,
72 pub(super) radius: f32,
73 pub(super) width: f32,
74 pub(super) align: bool,
75 pub(super) sprite: Option<Arc<crate::ImageAsset>>,
76 pub(super) bounds: Rect,
77 pub(super) analytic:
78 Option<Result<motion_core::instances::AnalyticInstanceUpload, ValidationError>>,
79}
80
81impl PartialEq for ParticleBatch {
82 fn eq(&self, other: &Self) -> bool {
83 self.instances == other.instances
84 && self.kind == other.kind
85 && self.radius == other.radius
86 && self.width == other.width
87 && self.align == other.align
88 && self.sprite.as_ref().map(Arc::as_ptr) == other.sprite.as_ref().map(Arc::as_ptr)
89 && self.bounds == other.bounds
90 && self.analytic == other.analytic
91 }
92}
93
94impl ParticleBatch {
95 pub fn len(&self) -> usize {
97 self.instances.len()
98 }
99
100 pub fn is_empty(&self) -> bool {
102 self.instances.is_empty()
103 }
104
105 pub fn bounds(&self) -> Rect {
107 self.bounds
108 }
109
110 pub(crate) fn bytes(&self) -> u64 {
111 self.instances.len() as u64 * std::mem::size_of::<Instance>() as u64
112 + self.analytic.as_ref().map_or(0, |upload| match upload {
113 Ok(upload) => upload.bytes(),
114 Err(error) => (error.path.len() + error.field.len() + error.message.len()) as u64,
115 })
116 + self.instances.index_len() as u64 * std::mem::size_of::<SpatialNode>() as u64
117 + std::mem::size_of::<Arc<[SpatialNode]>>() as u64
118 + self.sprite.as_ref().map_or(0, |sprite| {
119 u64::from(sprite.width()) * u64::from(sprite.height()) * 16
120 })
121 }
122
123 pub(crate) fn work(&self) -> u64 {
124 self.instances.len() as u64 * 64 + self.instances.index_len() as u64 * 16 + 1
125 }
126
127 pub(crate) fn validate(&self) -> Result<(), ValidationError> {
128 if self
129 .analytic
130 .as_ref()
131 .and_then(|program| program.as_ref().ok())
132 .is_some_and(|program| program.len() != self.len())
133 {
134 return Err(invalid("analytic", "Analytic initial-state count mismatch"));
135 }
136 if self.instances.len() > MAX_INSTANCE_COUNT
137 || !self.radius.is_finite()
138 || self.radius < 0.0
139 || self.radius > 4096.0
140 || !self.width.is_finite()
141 || self.width < 0.0
142 || self.width > 4096.0
143 {
144 return Err(invalid("style", "Invalid particle style size/count"));
145 }
146 if !self.instances.is_deferred() {
147 for instance in self.instances.get()?.instances.iter() {
148 instance.particle.validate()?;
149 }
150 }
151 motion_core::drawing::validate_rect(self.bounds, "particles", "bounds")
152 }
153
154 pub(crate) fn sample(
155 &self,
156 point: Point,
157 antialias: f64,
158 destination: [f32; 4],
159 space: motion_core::drawing::BlendSpace,
160 composite: motion_core::instances::InstanceComposite,
161 ) -> Result<[f32; 4], ValidationError> {
162 self.sample_with_zero_policy::<false>(point, antialias, destination, space, composite)
163 }
164
165 pub(super) fn sample_with_zero_policy<const ZERO_IS_IDENTITY: bool>(
166 &self,
167 point: Point,
168 antialias: f64,
169 destination: [f32; 4],
170 space: motion_core::drawing::BlendSpace,
171 composite: motion_core::instances::InstanceComposite,
172 ) -> Result<[f32; 4], ValidationError> {
173 self.sample_selected::<ZERO_IS_IDENTITY>(
174 point,
175 antialias,
176 destination,
177 space,
178 composite,
179 None,
180 )
181 }
182
183 fn sample_selected<const ZERO_IS_IDENTITY: bool>(
184 &self,
185 point: Point,
186 antialias: f64,
187 mut destination: [f32; 4],
188 space: motion_core::drawing::BlendSpace,
189 composite: motion_core::instances::InstanceComposite,
190 selection: Option<&[usize]>,
191 ) -> Result<[f32; 4], ValidationError> {
192 let bounded = self.kind != 1
193 && self.kind != 2
194 && antialias.is_finite()
195 && antialias > 0.0
196 && point.x.is_finite()
197 && point.y.is_finite();
198 let point_guard = if bounded {
199 point.x.abs().max(point.y.abs()).max(1.0) * (128.0 * f64::EPSILON)
200 } else {
201 0.0
202 };
203 let indexed = selection.is_none()
204 && ZERO_IS_IDENTITY
205 && !self.instances.get()?.index.is_empty()
206 && antialias.is_finite()
207 && antialias > 0.0
208 && point.x.is_finite()
209 && point.y.is_finite();
210 let index_guard = if indexed {
211 point.x.abs().max(point.y.abs()).max(1.0) * (256.0 * f64::EPSILON)
212 } else {
213 0.0
214 };
215 let Materialized { instances, index } = self.instances.get()?;
216 let mut cursor = 0;
217 let mut position = 0;
218 let count = selection.map_or(instances.len(), <[usize]>::len);
219 let mut leaf_end = if indexed { 0 } else { count };
220 while position < count {
221 if indexed && position == leaf_end {
222 loop {
223 let node = &index[cursor];
224 if node.outside(point, antialias * 0.5, index_guard) {
225 position = node.end;
226 cursor = node.next;
227 if position == instances.len() {
228 break;
229 }
230 } else {
231 cursor += 1;
232 if node.leaf {
233 leaf_end = node.end;
234 break;
235 }
236 }
237 }
238 if position == instances.len() {
239 break;
240 }
241 }
242 let instance = &instances[selection.map_or(position, |indices| indices[position])];
243 position += 1;
244 let particle = &instance.particle;
245 let radius = f64::from(self.radius * particle.size);
246 if radius == 0.0 || particle.color[3] == 0.0 {
247 continue;
248 }
249 let relative = [
250 point.x - f64::from(particle.position[0]),
251 point.y - f64::from(particle.position[1]),
252 ];
253 let mut color = particle.color;
254 let coverage = if instance.line {
255 let guard = instance.line_guard + point_guard;
256 let padding = f64::from(self.width) * 0.5 + antialias * 0.5 + guard;
257 if bounded
258 && (point.x < f64::from(instance.line_bounds[0]) - padding
259 || point.x > f64::from(instance.line_bounds[2]) + padding
260 || point.y < f64::from(instance.line_bounds[1]) - padding
261 || point.y > f64::from(instance.line_bounds[3]) + padding)
262 {
263 0.0
264 } else {
265 let start = instance.previous.map(f64::from);
266 let end = particle.position.map(f64::from);
267 let delta = [end[0] - start[0], end[1] - start[1]];
268 let square = delta[0] * delta[0] + delta[1] * delta[1];
269 let progress = if square == 0.0 {
270 0.0
271 } else {
272 ((point.x - start[0]) * delta[0] + (point.y - start[1]) * delta[1]) / square
273 }
274 .clamp(0.0, 1.0);
275 let distance = libm::hypot(
276 point.x - start[0] - delta[0] * progress,
277 point.y - start[1] - delta[1] * progress,
278 );
279 ((f64::from(self.width) * 0.5 - distance) / antialias + 0.5).clamp(0.0, 1.0)
280 }
281 } else if self.kind == 1 {
282 let square =
283 (relative[0] * relative[0] + relative[1] * relative[1]) / (radius * radius);
284 if square > 9.0 {
285 0.0
286 } else {
287 libm::exp(-2.0 * square)
288 }
289 } else if self.kind == 2 {
290 let angle = f64::from(particle.rotation)
291 + if self.align && particle.velocity != [0.0; 2] {
292 libm::atan2(
293 f64::from(particle.velocity[1]),
294 f64::from(particle.velocity[0]),
295 )
296 } else {
297 0.0
298 };
299 let cosine = libm::cos(angle);
300 let sine = libm::sin(angle);
301 let local = [
302 relative[0] * cosine + relative[1] * sine,
303 -relative[0] * sine + relative[1] * cosine,
304 ];
305 if local[0].abs() >= radius || local[1].abs() >= radius {
306 0.0
307 } else {
308 let sprite = self.sprite.as_ref().expect("sprite material");
309 let sample = sprite_sample(
310 sprite,
311 local[0] / (2.0 * radius) + 0.5,
312 local[1] / (2.0 * radius) + 0.5,
313 );
314 color = std::array::from_fn(|channel| color[channel] * sample[channel]);
315 1.0
316 }
317 } else {
318 let reach = radius + antialias * 0.5;
319 let guard = reach.abs().max(1.0) * (128.0 * f64::EPSILON) + point_guard;
320 if bounded && relative[0].abs().max(relative[1].abs()) > reach + guard {
321 0.0
322 } else {
323 ((radius - libm::hypot(relative[0], relative[1])) / antialias + 0.5)
324 .clamp(0.0, 1.0)
325 }
326 } as f32;
327 let source = color.map(|channel| channel * coverage);
328 if ZERO_IS_IDENTITY && source == [0.0; 4] {
329 continue;
330 }
331 composite(&mut destination, source, space)?;
332 }
333 Ok(destination)
334 }
335
336 #[cfg(feature = "gpu")]
337 pub(crate) fn upload_bytes(&self) -> u64 {
338 (4 + self.instances.len() as u64 * 12
339 + self.sprite.as_ref().map_or(0, |sprite| {
340 u64::from(sprite.width()) * u64::from(sprite.height()) * 4
341 }))
342 * 4
343 }
344
345 pub(super) fn blend(&self) -> motion_core::instances::InstanceBlend {
347 if self.kind == 1 {
348 motion_core::instances::InstanceBlend::Plus
349 } else {
350 motion_core::instances::InstanceBlend::SourceOver
351 }
352 }
353
354 #[cfg(feature = "gpu")]
355 pub(crate) fn upload(&self) -> Result<motion_core::instances::InstanceUpload, ValidationError> {
356 use motion_core::instances::{InstanceKind, InstanceRecord, InstanceUpload};
357
358 let sprite_size = self
359 .sprite
360 .as_ref()
361 .map_or([0, 0], |sprite| [sprite.width(), sprite.height()]);
362 let record = |particle: Particle, previous: [f32; 2], line: bool| {
363 let rotation = f64::from(particle.rotation)
364 + if self.align && particle.velocity != [0.0; 2] {
365 libm::atan2(
366 f64::from(particle.velocity[1]),
367 f64::from(particle.velocity[0]),
368 )
369 } else {
370 0.0
371 };
372 InstanceRecord {
373 position: particle.position,
374 start: previous,
375 color: particle.color,
376 radius: self.radius * particle.size,
377 rotation: rotation as f32,
378 width: self.width,
379 kind: if line {
380 InstanceKind::Segment
381 } else {
382 match self.kind {
383 1 => InstanceKind::Glow,
384 2 => InstanceKind::Sprite,
385 _ => InstanceKind::Disc,
386 }
387 },
388 }
389 };
390 let records: Box<dyn ExactSizeIterator<Item = InstanceRecord> + '_> =
393 match &self.instances {
394 Instances::Ready(ready) => {
395 Box::new(ready.instances.iter().map(|instance| {
396 record(instance.particle, instance.previous, instance.line)
397 }))
398 }
399 Instances::Deferred { len, .. } => {
400 Box::new((0..*len).map(|_| record(Particle::default(), [0.0; 2], false)))
401 }
402 };
403 let texels = self.sprite.iter().flat_map(|sprite| {
404 (0..sprite.height()).flat_map(move |row| {
405 (0..sprite.width()).map(move |column| {
406 sprite.levels()[0]
407 .pixel(column, row)
408 .expect("source texture bounds")
409 })
410 })
411 });
412 InstanceUpload::new_blended(records, sprite_size, texels, self.blend())
413 }
414}
415
416impl motion_core::instances::InstanceBatch for ParticleBatch {
417 fn analytic_upload(&self) -> Option<&motion_core::instances::AnalyticInstanceUpload> {
418 self.analytic
419 .as_ref()
420 .and_then(|program| program.as_ref().ok())
421 }
422
423 fn analytic_fallback(&self) -> Option<&ValidationError> {
424 self.analytic
425 .as_ref()
426 .and_then(|program| program.as_ref().err())
427 }
428
429 fn blend(&self) -> motion_core::instances::InstanceBlend {
430 ParticleBatch::blend(self)
431 }
432
433 fn len(&self) -> usize {
434 self.len()
435 }
436
437 fn bounds(&self) -> Rect {
438 self.bounds()
439 }
440
441 fn bytes(&self) -> u64 {
442 self.bytes()
443 }
444
445 fn work(&self) -> u64 {
446 self.work()
447 }
448
449 fn spatial(&self) -> Option<&dyn motion_core::instances::SpatialInstances> {
450 self.instances
451 .get()
452 .ok()
453 .map(|_| self as &dyn motion_core::instances::SpatialInstances)
454 }
455
456 fn validate(&self) -> Result<(), ValidationError> {
457 self.validate()
458 }
459
460 fn sample(
461 &self,
462 point: Point,
463 antialias: f64,
464 destination: [f32; 4],
465 space: motion_core::BlendSpace,
466 composite: motion_core::instances::InstanceComposite,
467 ) -> Result<[f32; 4], ValidationError> {
468 self.sample(point, antialias, destination, space, composite)
469 }
470
471 fn sample_with_zero_identity(
472 &self,
473 point: Point,
474 antialias: f64,
475 destination: [f32; 4],
476 space: motion_core::BlendSpace,
477 composite: motion_core::instances::InstanceComposite,
478 ) -> Result<[f32; 4], ValidationError> {
479 self.sample_with_zero_policy::<true>(point, antialias, destination, space, composite)
480 }
481
482 #[cfg(feature = "gpu")]
483 fn upload_bytes(&self) -> u64 {
484 self.upload_bytes()
485 }
486
487 #[cfg(feature = "gpu")]
488 fn upload(&self) -> Result<Option<motion_core::instances::InstanceUpload>, ValidationError> {
489 self.upload().map(Some)
490 }
491
492 fn as_any(&self) -> &dyn std::any::Any {
493 self
494 }
495
496 fn content_eq(&self, other: &dyn motion_core::instances::InstanceBatch) -> bool {
497 other
498 .as_any()
499 .downcast_ref::<Self>()
500 .is_some_and(|other| self == other)
501 }
502}
503
504impl motion_core::instances::SpatialInstances for ParticleBatch {
505 fn support(&self, index: usize, antialias: f64) -> Rect {
506 let Some(instance) = self
507 .instances
508 .get()
509 .ok()
510 .and_then(|ready| ready.instances.get(index))
511 else {
512 return Rect::new(0.0, 0.0, 0.0, 0.0);
513 };
514 let particle = instance.particle;
515 let radius = f64::from(self.radius * particle.size);
516 if radius == 0.0 || particle.color[3] == 0.0 {
517 return Rect::new(0.0, 0.0, 0.0, 0.0);
518 }
519 let padding = antialias * 0.5;
520 let (left, top, right, bottom) = if instance.line {
521 let reach = f64::from(self.width) * 0.5 + padding + instance.line_guard;
522 (
523 f64::from(instance.line_bounds[0]) - reach,
524 f64::from(instance.line_bounds[1]) - reach,
525 f64::from(instance.line_bounds[2]) + reach,
526 f64::from(instance.line_bounds[3]) + reach,
527 )
528 } else {
529 let reach = radius
530 * match self.kind {
531 1 => 3.0,
532 2 => 2.0,
533 _ => 1.0,
534 }
535 + padding;
536 (
537 f64::from(particle.position[0]) - reach,
538 f64::from(particle.position[1]) - reach,
539 f64::from(particle.position[0]) + reach,
540 f64::from(particle.position[1]) + reach,
541 )
542 };
543 let guard = [left, top, right, bottom]
544 .into_iter()
545 .fold(1.0_f64, |scale, value| scale.max(value.abs()))
546 * (4096.0 * f64::EPSILON);
547 Rect::new(left - guard, top - guard, right + guard, bottom + guard)
548 }
549
550 fn sample_indices(
551 &self,
552 indices: &[usize],
553 point: Point,
554 antialias: f64,
555 destination: [f32; 4],
556 space: motion_core::BlendSpace,
557 composite: motion_core::instances::InstanceComposite,
558 ) -> Result<[f32; 4], ValidationError> {
559 self.sample_selected::<true>(
560 point,
561 antialias,
562 destination,
563 space,
564 composite,
565 Some(indices),
566 )
567 }
568}
569
570pub(super) fn build_spatial_index(
571 kind: u32,
572 radius: f32,
573 width: f32,
574 instances: &[Instance],
575) -> Arc<[SpatialNode]> {
576 if kind == 2 || instances.len() < 32 || instances.len() > MAX_INSTANCE_COUNT {
577 return Arc::from([]);
578 }
579
580 fn append(
581 style: (u32, f32, f32),
582 instances: &[Instance],
583 nodes: &mut Vec<SpatialNode>,
584 first: usize,
585 end: usize,
586 ) {
587 let (kind, radius, width) = style;
588 let mut bounds = Rect::new(
589 f64::INFINITY,
590 f64::INFINITY,
591 f64::NEG_INFINITY,
592 f64::NEG_INFINITY,
593 );
594 for instance in &instances[first..end] {
595 let particle = instance.particle;
596 let radius = f64::from(radius * particle.size);
597 let extent = if instance.line {
598 let reach = f64::from(width) * 0.5;
599 Rect::new(
600 f64::from(instance.line_bounds[0]) - reach,
601 f64::from(instance.line_bounds[1]) - reach,
602 f64::from(instance.line_bounds[2]) + reach,
603 f64::from(instance.line_bounds[3]) + reach,
604 )
605 } else {
606 let reach = radius * if kind == 1 { 3.0 } else { 1.0 };
607 Rect::new(
608 f64::from(particle.position[0]) - reach,
609 f64::from(particle.position[1]) - reach,
610 f64::from(particle.position[0]) + reach,
611 f64::from(particle.position[1]) + reach,
612 )
613 };
614 bounds.left = bounds.left.min(extent.left);
615 bounds.top = bounds.top.min(extent.top);
616 bounds.right = bounds.right.max(extent.right);
617 bounds.bottom = bounds.bottom.max(extent.bottom);
618 }
619 let guard = [bounds.left, bounds.top, bounds.right, bounds.bottom]
620 .into_iter()
621 .fold(1.0_f64, |scale, coordinate| scale.max(coordinate.abs()))
622 * (256.0 * f64::EPSILON);
623 let current = nodes.len();
624 let leaf = end - first <= 8;
625 nodes.push(SpatialNode {
626 bounds,
627 guard,
628 end,
629 next: 0,
630 leaf,
631 });
632 if !leaf {
633 let middle = first + (end - first) / 2;
634 append(style, instances, nodes, first, middle);
635 append(style, instances, nodes, middle, end);
636 }
637 nodes[current].next = nodes.len();
638 }
639 let mut nodes = Vec::with_capacity(instances.len().div_ceil(8) * 4);
640 append(
641 (kind, radius, width),
642 instances,
643 &mut nodes,
644 0,
645 instances.len(),
646 );
647 nodes.into()
648}
649
650fn sprite_sample(sprite: &crate::ImageAsset, horizontal: f64, vertical: f64) -> [f32; 4] {
651 let width = sprite.width() as usize;
652 let height = sprite.height() as usize;
653 let horizontal = horizontal * width as f64 - 0.5;
654 let vertical = vertical * height as f64 - 0.5;
655 let left = libm::floor(horizontal) as i64;
656 let top = libm::floor(vertical) as i64;
657 let fraction = [
658 (horizontal - left as f64) as f32,
659 (vertical - top as f64) as f32,
660 ];
661 let texel = |column: i64, row: i64| {
662 sprite.levels()[0]
663 .pixel(
664 column.clamp(0, width as i64 - 1) as u32,
665 row.clamp(0, height as i64 - 1) as u32,
666 )
667 .expect("source texture bounds")
668 };
669 let top_left = texel(left, top);
670 let top_right = texel(left + 1, top);
671 let bottom_left = texel(left, top + 1);
672 let bottom_right = texel(left + 1, top + 1);
673 std::array::from_fn(|channel| {
674 (top_left[channel] * (1.0 - fraction[0]) + top_right[channel] * fraction[0])
675 * (1.0 - fraction[1])
676 + (bottom_left[channel] * (1.0 - fraction[0]) + bottom_right[channel] * fraction[0])
677 * fraction[1]
678 })
679}
680
681impl Drawable for ParticleBatch {
682 fn bounds(&self, context: &motion_core::DrawContext) -> Result<Rect, ValidationError> {
683 let margin = 1.0 / context.device_scale();
684 Ok(Rect::new(
685 self.bounds.left - margin,
686 self.bounds.top - margin,
687 self.bounds.right + margin,
688 self.bounds.bottom + margin,
689 ))
690 }
691
692 fn draw(
693 &self,
694 _: &motion_core::DrawContext,
695 canvas: &mut motion_core::Canvas<'_>,
696 ) -> Result<(), ValidationError> {
697 canvas.instances(Arc::new(self.clone()), Transform::IDENTITY)
698 }
699}