Skip to main content

motion_content/particles/
batch.rs

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