Skip to main content

motion_content/particles/
batch.rs

1//! Validated particle instances and spatial indexing.
2
3use 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/// Validated opaque instance payload, consumed directly by CPU/GPU paths.
68#[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    /// Number of dot/sprite/segment instances, including Plexus links.
96    pub fn len(&self) -> usize {
97        self.instances.len()
98    }
99
100    /// Whether this payload contains no instances.
101    pub fn is_empty(&self) -> bool {
102        self.instances.is_empty()
103    }
104
105    /// Conservative local geometric extent (before output antialias padding).
106    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    /// Glow dots add light; every other kind composites source-over.
346    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        // The analytic program overwrites positions and rotations on the GPU, so deferred
391        // records only carry the attributes that seeded trajectories leave at their defaults.
392        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}